[TIL] ArrayDeque vs ArrayBlockingQueue — 코드리뷰로 배우는 Java Queue 정리

ArrayDeque와 ArrayBlockingQueue의 차이, 코드리뷰에서 발견한 OOM 위험, 그리고 Reactor Sinks 버퍼로 ArrayBlockingQueue를 선택한 이유를 정리했습니다.

📝 TIL (Today I Learned)
“OOM 방지”라고 주석을 달았는데, 실제로는 OOM이 날 수 있는 코드가 되어 있었습니다. 코드리뷰 한 줄이 Java Queue의 핵심 차이를 알려 준 경험을 정리합니다.


🍀 새롭게 배운 것

  • ArrayDequeArrayBlockingQueue는 이름은 비슷하지만 설계 목적이 다르다.
  • ArrayDeque(256)의 256은 초기 용량일 뿐, 넘치면 자동으로 확장된다. 반면 ArrayBlockingQueue(256)의 256은 최대 용량으로, 절대 늘어나지 않는다.
  • 리액티브 스트림의 버퍼처럼 크기를 제한해야 할 때는 ArrayBlockingQueue가 적합하고, 단순 스택/큐 용도면 ArrayDeque가 더 가볍고 빠르다.

💥 OOM이란?

OOMOut Of Memory의 줄임말로, JVM이 더 이상 객체를 만들 수 없을 정도로 메모리가 부족해질 때 발생하는 에러다.

  • 대표 에러: java.lang.OutOfMemoryError
  • 주로 터지는 곳: JVM 힙(Heap) 메모리 (객체가 쌓이는 공간)
  • 전형적인 원인: 리스트/큐/맵 같은 버퍼가 끝없이 커지면서 메모리를 다 써버리는 경우

그래서 코드에서 "OOM 방지"라고 쓴 의도는 보통 "버퍼가 무한히 커지지 않게 상한을 둬서, 프로세스가 메모리 때문에 죽지 않게 하자" 는 의미다.


🧯 왜 OOM을 방지하려고 했나

여기서 outboundSinkGemini로 보낼 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));

리뷰어의 지적은 명확했습니다.

코드리뷰 이미지1

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

이름이 비슷해 보이지만, 패키지도 다르고 설계 목적 자체가 다르다.

 ArrayDequeArrayBlockingQueue
패키지java.utiljava.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

✅ 정리

항목ArrayDequeArrayBlockingQueue
핵심 특성빠른 단일 스레드 자료구조스레드 안전 + 용량 제한
생성자 인자 의미초기 용량 (자동 확장)최대 용량 (절대 안 늘어남)
스레드 안전아님안전 (ReentrantLock)
가득 찼을 때배열 자동 확장false / 예외 / 블로킹
OOM 가능성있음없음 (용량 고정)
성능빠름상대적으로 느림 (Lock 비용)

한 줄 요약:

  • ArrayDeque(256) → “처음에 256칸 만들어줘, 모자라면 늘릴게”
  • ArrayBlockingQueue(256) → “딱 256칸만 써. 넘치면 거절할게”

코드리뷰가 알려준 것은 단순한 클래스 교체가 아니라, “크기 제한”과 “스레드 안전성” 을 의식하고 자료구조를 골라야 한다는 점이었다.