[TIL] ArrayDeque vs ArrayBlockingQueue — 코드리뷰로 배우는 Java Queue 정리
ArrayDeque와 ArrayBlockingQueue의 차이, 코드리뷰에서 발견한 OOM 위험, 그리고 Reactor Sinks 버퍼로 ArrayBlockingQueue를 선택한 이유를 정리했습니다.
📝 TIL (Today I Learned)
“OOM 방지”라고 주석을 달았는데, 실제로는 OOM이 날 수 있는 코드가 되어 있었습니다. 코드리뷰 한 줄이 Java Queue의 핵심 차이를 알려 준 경험을 정리합니다.
🍀 새롭게 배운 것
- ArrayDeque와 ArrayBlockingQueue는 이름은 비슷하지만 설계 목적이 다르다.
ArrayDeque(256)의 256은 초기 용량일 뿐, 넘치면 자동으로 확장된다. 반면ArrayBlockingQueue(256)의 256은 최대 용량으로, 절대 늘어나지 않는다.- 리액티브 스트림의 버퍼처럼 크기를 제한해야 할 때는 ArrayBlockingQueue가 적합하고, 단순 스택/큐 용도면 ArrayDeque가 더 가볍고 빠르다.
💥 OOM이란?
OOM은 Out Of Memory의 줄임말로, JVM이 더 이상 객체를 만들 수 없을 정도로 메모리가 부족해질 때 발생하는 에러다.
- 대표 에러:
java.lang.OutOfMemoryError - 주로 터지는 곳: JVM 힙(Heap) 메모리 (객체가 쌓이는 공간)
- 전형적인 원인: 리스트/큐/맵 같은 버퍼가 끝없이 커지면서 메모리를 다 써버리는 경우
그래서 코드에서 "OOM 방지"라고 쓴 의도는 보통 "버퍼가 무한히 커지지 않게 상한을 둬서, 프로세스가 메모리 때문에 죽지 않게 하자" 는 의미다.
🧯 왜 OOM을 방지하려고 했나
여기서 outboundSink는 Gemini로 보낼 JSON 메시지를 임시로 쌓아두는 큐(버퍼) 역할을 한다.
사용자 음성 → (PCM → Base64 변환) → outboundSink 큐 → WebSocket → Gemini
빠름 ↑ 느림 ↓
문제는 생산 속도 > 소비 속도인 상황에서 터진다.
- Gemini 서버가 느리거나, 네트워크가 불안정하거나, WebSocket send 루프가 잠깐 막히면
- 앱은 계속 사용자 음성을 변환해서 큐에 넣으려 하고
ArrayDeque는 가득 차도 내부 배열을 계속 키우며 수용한다 → 힙을 고갈시킬 때까지 성장할 수 있다
ArrayDeque: [256] → [512] → [1024] → ... → OOM 💥
ArrayBlockingQueue: [256] → FAIL_OVERFLOW (메시지 드롭, 경고 로그) ✅
특히 AI 통화처럼 오디오 데이터가 실시간·연속적으로 생성되는 경우, Gemini가 1초만 늦어져도 청크가 빠르게 쌓이고 통화가 길어질수록 누적되기 쉽다.
그래서 256개 상한은 단순히 "메모리 아끼자"가 아니라, "최대 이 정도까지만 버퍼링하고 그 이상은 드롭해서 최신 음성 우선" 이라는 의도적인 backpressure 전략이기도 하다. (음성 통화에서는 오래된 오디오를 지연 전송하는 것보다 드롭하는 편이 UX가 더 낫다.)
📖 발단: 코드리뷰 한 줄
// 수정 전
this.outboundSink = Sinks.many().unicast().onBackpressureBuffer(new ArrayDeque<>(256));
// 수정 후
this.outboundSink = Sinks.many().unicast().onBackpressureBuffer(new ArrayBlockingQueue<>(256));
리뷰어의 지적은 명확했습니다.

256이라는 숫자를 넣었으니 “최대 256개”라고 생각했지만, ArrayDeque에서는 그렇지 않다는 걸 몰랐다. 왜 틀렸는지, 두 클래스가 어떻게 다른지부터 정리해 본다.
1. Java의 Queue 계층 구조
먼저 전체 그림을 보자.
java.util.Collection
└── java.util.Queue (interface)
├── java.util.Deque (interface)
│ └── java.util.ArrayDeque (class) ← 오늘의 주인공 1
└── java.util.concurrent.BlockingQueue (interface)
└── java.util.concurrent.ArrayBlockingQueue (class) ← 오늘의 주인공 2
이름이 비슷해 보이지만, 패키지도 다르고 설계 목적 자체가 다르다.
| ArrayDeque | ArrayBlockingQueue | |
|---|---|---|
| 패키지 | java.util | java.util.concurrent |
| 용량 | 무제한 (자동 확장) | 고정 (생성자에서 결정) |
| 스레드 안전 | 아님 | 안전 (내부 Lock) |
| 블로킹 | 없음 | put() / take() 블로킹 지원 |
| 주 용도 | 단일 스레드 자료구조 | 멀티스레드 생산자-소비자 패턴 |
2. ArrayDeque — 빠르지만 “최대 개수” 제한이 없다
2.1 내부 구조: 원형 배열
ArrayDeque는 원형 배열(circular array) 로 구현된다.
초기 상태 (capacity=256으로 생성했을 때):
[ ][ ][ ][ ]...[ ] (256칸)
↑
head = tail = 0
데이터를 계속 추가하면:
[A][B][C]...[Z][?]...
↑
용량이 꽉 차면?
배열을 두 배로 키운다(grow). 내부에서 grow()가 호출되며 Arrays.copyOf로 더 큰 배열을 만들고 기존 요소를 복사한다.
// OpenJDK ArrayDeque 내부 코드 (실제 구현 일부)
private void grow(int needed) {
int oldCapacity = elements.length;
int newCapacity;
int jump = (oldCapacity < 64) ? (oldCapacity + 2) : (oldCapacity >> 1);
newCapacity = oldCapacity + jump;
elements = Arrays.copyOf(elements, newCapacity);
}
2.2 핵심: 256은 “초기 용량”일 뿐
ArrayDeque<String> deque = new ArrayDeque<>(256);
// 이 256은 "초기 용량(initial capacity)"
// 256개가 꽉 차면 512, 1024, ... 로 자동 확장됨
// 메모리가 허용하는 한 무한 확장 가능
그래서 “최대 256개로 막겠다” 는 의도와 맞지 않는다. OOM 방지라고 주석을 달았어도, 실제로는 버퍼가 무한히 커질 수 있어 OOM 위험이 남는다.
2.3 ArrayDeque가 적합한 상황
- 단일 스레드에서 스택/큐가 필요할 때
- 크기를 제한할 필요가 없을 때
- 성능이 최우선일 때 (Lock 없음)
// 예: DFS 탐색에서 스택으로 사용
Deque<Node> stack = new ArrayDeque<>();
stack.push(root);
while (!stack.isEmpty()) {
Node current = stack.pop();
// ...
}
3. ArrayBlockingQueue — “최대 개수”가 있고, 멀티스레드에 안전하다
ArrayBlockingQueue는 이름 그대로 “배열 기반 + 블로킹이 가능한 큐”다. 처음 보면 “블로킹”이 무슨 의미인지 헷갈릴 수 있으니, 먼저 BlockingQueue가 뭔지부터 짚고 간다.
3.1 BlockingQueue란? — “가득 차면/비면 기다리는 큐”
일반 Queue는 “큐가 가득 찼을 때 넣으려 하면 예외나 false만 반환”한다. BlockingQueue는 여기에 “그럴 때 스레드를 멈추고(블로킹) 자리가 날 때까지 기다린다” 는 옵션을 추가한 인터페이스다.
- put(e)
큐가 가득 차 있으면 “빈 자리가 생길 때까지” 현재 스레드를 대기시킨다. (블로킹) - take()
큐가 비어 있으면 “원소가 들어올 때까지” 현재 스레드를 대기시킨다. (블로킹)
그래서 생산자(데이터를 넣는 쪽) 와 소비자(데이터를 꺼내는 쪽) 가 서로 속도를 맞출 수 있다. “큐가 가득 찼는데 계속 넣으려 하면, 넣는 쪽이 잠시 멈춰 있다가 자리가 나면 다시 넣는다” 식으로 동작한다. 이걸 생산자-소비자(Producer-Consumer) 패턴이라고 부른다.
ArrayBlockingQueue는 이 BlockingQueue를 고정 크기 배열로 구현한 클래스다.
3.2 내부 구조: 고정 배열 + Lock + Condition
ArrayBlockingQueue도 원형 배열을 쓰지만, 크기를 절대 늘리지 않는다.
public class ArrayBlockingQueue<E> extends AbstractQueue<E>
implements BlockingQueue<E> {
final Object[] items; // 고정 크기 배열 (생성 후 크기 변경 없음)
int takeIndex; // 꺼낼 위치
int putIndex; // 넣을 위치
int count; // 현재 원소 수
final ReentrantLock lock; // 하나의 Lock으로 전체 제어
private final Condition notEmpty; // "비어 있음" → take() 대기
private final Condition notFull; // "가득 참" → put() 대기
}
- ReentrantLock
여러 스레드가 동시에offer,put,poll,take등을 호출해도, 한 번에 한 스레드만 큐를 수정하도록 보장한다. (스레드 안전) - Condition (notEmpty, notFull)
“꺼낼 게 생길 때까지 기다려” / “빈 자리가 생길 때까지 기다려” 를 표현한다.take()는 큐가 비어 있으면notEmpty.await()로 대기하고, 누군가put()하면notEmpty.signal()로 깨운다. 반대로put()은 가득 차면notFull.await()로 대기한다.
생성자에서 배열 크기가 한 번 정해지면 끝이다.
public ArrayBlockingQueue(int capacity) {
if (capacity <= 0) throw new IllegalArgumentException();
this.items = new Object[capacity]; // 이 크기로 고정. 절대 늘어나지 않음
this.lock = new ReentrantLock();
this.notEmpty = lock.newCondition();
this.notFull = lock.newCondition();
}
3.3 핵심: 256은 “최대 용량”
ArrayBlockingQueue<String> queue = new ArrayBlockingQueue<>(256);
// 이 256은 "최대 용량(maximum capacity)"
// 256개가 꽉 차면 더 이상 들어가지 않음 (또는 블로킹)
// 배열이 확장되는 일은 절대 없음
그래서 “최대 256개까지만 쌓이게 하겠다”는 요구와 정확히 맞는다.
3.4 넣을 때 네 가지 선택 — add / offer / put / offer(timeout)
“가득 찼을 때 어떻게 할지”를 메서드로 선택할 수 있다.
ArrayBlockingQueue<String> queue = new ArrayBlockingQueue<>(3);
queue.add("A");
queue.add("B");
queue.add("C");
// 큐가 꽉 찬 상태
// 1. add() → 넣지 못하면 예외
queue.add("D"); // IllegalStateException: Queue full
// 2. offer() → 넣지 못하면 false 반환 (논블로킹)
boolean success = queue.offer("D"); // false
// 3. put() → 빈 자리가 생길 때까지 현재 스레드 대기 (블로킹)
queue.put("D"); // 스레드가 여기서 멈춤
// 4. offer(e, timeout, unit) → 지정 시간만큼 기다렸다가 실패
boolean ok = queue.offer("D", 1, TimeUnit.SECONDS); // 1초 후 false
- add / offer
“지금 못 넣으면 그냥 실패” (예외 또는 false). 논블로킹. - put / offer(timeout)
“못 넣으면 기다린다” (블로킹). 생산자-소비자 패턴에서 “큐가 가득 차면 잠시 멈추고 자리 나면 넣기”를 할 때 사용한다.
실무에서는 “반드시 넣어야 하는 경우”에는 put(), “넣을 수 없으면 포기해도 되는 경우”에는 offer() 또는 offer(timeout)을 쓰면 된다.
4. 이번 코드에서 왜 중요한가 — Reactor Sinks와의 연결
원래 코드로 돌아가 보자.
this.outboundSink = Sinks.many().unicast().onBackpressureBuffer(new ArrayBlockingQueue<>(256));
Sinks.many().unicast().onBackpressureBuffer(Queue)는 내부 버퍼로 사용할 Queue를 우리가 넘겨준다. Reactor는 이 큐에 “아직 보내지 못한 메시지”를 쌓아 두고, 보낼 수 있을 때 꺼내서 보낸다.
Gemini로 보낼 메시지 생산 ──→ [outboundSink] ──→ WebSocket send()
↑
여기가 ArrayBlockingQueue
메시지를 보내는 속도보다 생산하는 속도가 빠르면, 큐가 버퍼 역할을 한다.
ArrayDeque였다면 (수정 전)
메시지가 계속 쌓임 → ArrayDeque 자동 확장 → 512 → 1024 → 2048...
→ OutOfMemoryError (OOM)
“256개로 막겠다”는 의도와 달리, 실제로는 무한히 커질 수 있어서 OOM 위험이 있다.
ArrayBlockingQueue로 바꾼 후 (수정 후)
메시지가 256개 초과 시 → Queue가 더 받지 않음 (용량 고정)
→ Reactor가 FAIL_OVERFLOW 등을 감지
→ 정해진 오버플로우 처리(예: 로그 출력 후 해당 메시지만 버림)
→ 프로세스는 계속 실행
즉, OOM으로 서버 전체가 죽는 대신, 메시지 일부 유실로 피해를 최소화할 수 있다. 이게 백프레셔(Backpressure) 를 버퍼로 받을 때 “크기를 제한하는 것”의 의미다.
5. 스레드 안전성 — concurrent 패키지가 왜 필요한가
5.1 ArrayDeque는 스레드에 안전하지 않다
여러 스레드가 같은 ArrayDeque에 동시에 add/poll 하면, 내부 인덱스가 꼬이면서 데이터 유실이나 ArrayIndexOutOfBoundsException이 날 수 있다. 단일 스레드 전용이다.
// 위험한 코드 (멀티스레드 환경)
ArrayDeque<String> deque = new ArrayDeque<>();
// 스레드 A: deque.add("message");
// 스레드 B: deque.add("other"); // 동시 수정 → race condition
5.2 ArrayBlockingQueue는 ReentrantLock으로 보호된다
offer, put, poll, take 등이 모두 같은 Lock을 걸고 들어가기 때문에, 여러 스레드가 동시에 접근해도 한 번에 한 스레드만 큐를 수정한다. Reactor Sinks는 내부적으로 멀티스레드 환경을 가정하므로, 스레드 안전한 Queue를 넘겨줘야 한다.
// ArrayBlockingQueue 내부의 offer() 구현 (요지)
public boolean offer(E e) {
final ReentrantLock lock = this.lock;
lock.lock();
try {
if (count == items.length)
return false;
enqueue(e);
return true;
} finally {
lock.unlock();
}
}
6. 실무에서의 선택 기준
┌─────────────────────────────────────────────────────────┐
│ Queue 선택 가이드 │
│ │
│ 단일 스레드? │
│ YES → ArrayDeque (빠름, Lock 없음) │
│ NO ↓ │
│ │
│ 크기를 제한해야 함? │
│ YES → ArrayBlockingQueue (고정 용량 + 스레드 안전) │
│ NO → LinkedBlockingQueue (무제한 + 스레드 안전) │
│ │
│ 생산자/소비자 속도 차이가 큰 경우? │
│ → ArrayBlockingQueue + put()/take() 블로킹 활용 │
│ │
│ 리액티브 파이프라인 버퍼? │
│ → ArrayBlockingQueue (크기 제한 + 스레드 안전 둘 다 필요)│
└─────────────────────────────────────────────────────────┘
대표적인 사용 사례
| 상황 | 적합한 Queue |
|---|---|
| DFS/BFS 알고리즘, 역순 처리 | ArrayDeque |
| 함수 호출 스택 시뮬레이션 | ArrayDeque |
| 멀티스레드 작업 큐, ThreadPoolExecutor 내부 | ArrayBlockingQueue |
| Reactor Sinks 버퍼 (크기 제한 필요) | ArrayBlockingQueue |
| 로그 비동기 처리 (유실 허용, 크기 제한) | ArrayBlockingQueue |
| 메시지 큐 (크기 제한 없음, 멀티스레드) | LinkedBlockingQueue |
✅ 정리
| 항목 | ArrayDeque | ArrayBlockingQueue |
|---|---|---|
| 핵심 특성 | 빠른 단일 스레드 자료구조 | 스레드 안전 + 용량 제한 |
| 생성자 인자 의미 | 초기 용량 (자동 확장) | 최대 용량 (절대 안 늘어남) |
| 스레드 안전 | 아님 | 안전 (ReentrantLock) |
| 가득 찼을 때 | 배열 자동 확장 | false / 예외 / 블로킹 |
| OOM 가능성 | 있음 | 없음 (용량 고정) |
| 성능 | 빠름 | 상대적으로 느림 (Lock 비용) |
한 줄 요약:
ArrayDeque(256)→ “처음에 256칸 만들어줘, 모자라면 늘릴게”ArrayBlockingQueue(256)→ “딱 256칸만 써. 넘치면 거절할게”
코드리뷰가 알려준 것은 단순한 클래스 교체가 아니라, “크기 제한”과 “스레드 안전성” 을 의식하고 자료구조를 골라야 한다는 점이었다.