컴퓨터 구조 운영체제 네트워크 자료구조 알고리즘 데이터베이스 내 학습
ds · 19

원형 버퍼

링버퍼버퍼큐

배열로 큐를 만들면 앞에서 꺼낼 때마다 문제가 생깁니다. 맨 앞 원소를 지우면 나머지를 한 칸씩 당겨야 해서 O(n)이 되고, 당기지 않고 앞쪽 칸을 비워 두면 뒤쪽 끝이 차서 앞쪽이 남아 있어도 더 쓸 수 없습니다. 원형 버퍼(circular buffer, ring buffer)는 고정 크기 배열의 끝과 처음을 이어 붙여 원처럼 쓰는 큐입니다. 읽을 위치(head)와 쓸 위치(tail) 두 인덱스만 움직이고 원소는 옮기지 않으므로 쓰기와 읽기가 모두 O(1)입니다.

인덱스가 배열 끝에 닿으면 나머지 연산(% 용량)으로 0으로 돌아가고, 읽혀서 비워진 앞쪽 칸이 다시 쓰입니다. 그래서 메모리가 처음 잡은 크기에서 늘지 않고, 실행 중에 메모리를 새로 할당하지 않아도 됩니다. 이 토픽에서는 head와 tail이 움직이는 모습, 끝에서 처음으로 돌아가는 순간, 그리고 head == tail이 비어 있음과 가득 참을 모두 뜻할 수 있는 문제를 단계별로 봅니다.

핵심 개념

head와 tail

head는 다음에 읽을 칸, tail은 다음에 쓸 칸입니다. 쓰면 tail이, 읽으면 head가 한 칸씩 앞으로 갑니다.

핵심 개념

고정 크기 배열

용량이 처음에 정해지고 늘어나지 않습니다. 크기가 일정해서 메모리를 예측할 수 있고 실행 중 할당이 필요 없습니다.

핵심 개념

나머지 연산으로 되감기

인덱스를 (i + 1) % 용량으로 올리면 마지막 칸 다음이 0번 칸이 됩니다. 이 한 줄이 배열의 끝과 처음을 이어 줍니다.

핵심 개념

비어 있음과 가득 참의 구분

head == tail은 비어 있을 때도, 가득 찼을 때도 성립합니다. 한 칸을 비워 두거나, 개수를 따로 세거나, 인덱스를 되감지 않고 세는 방법으로 구분합니다.

핵심 개념

가득 찼을 때의 정책

새 쓰기를 거부하거나 기다리게 할 수 있고, 가장 오래된 값을 덮어쓸 수도 있습니다. 어느 쪽을 고를지는 데이터를 잃어도 되는지에 달려 있습니다.

핵심 개념

2의 거듭제곱 용량

i가 0 이상이고 용량이 2의 거듭제곱이면 i % 용량을 i & (용량 - 1)이라는 비트 AND로 바꿀 수 있어 계산이 단순해집니다.

쓰기와 읽기가 모두 O(1)입니다 — 인덱스 하나를 올리고 칸 하나를 쓰거나 읽을 뿐 다른 원소를 건드리지 않습니다. 배열로 만든 단순 큐에서 앞을 꺼낼 때 나머지를 당기는 O(n)과 대비됩니다.
공간은 O(용량)으로 고정입니다 — 처음에 배열 하나만 잡고, 원소가 오가도 크기가 변하지 않습니다. 연결 리스트 큐처럼 원소마다 노드를 할당하지 않아도 됩니다.
용량을 넘으면 선택해야 합니다 — 크기가 고정이므로 가득 찬 뒤에는 쓰기를 거부하거나 오래된 값을 덮어쓰는 정책이 필요합니다. 동적으로 크기를 늘리려면 새 배열에 옮겨 담아야 해서 원형 버퍼의 장점이 줄어듭니다.
순서는 FIFO입니다 — 배열 안에서는 위치가 돌아가 있어도 읽는 순서는 먼저 쓴 것이 먼저입니다. 칸 번호가 아니라 head에서 tail 방향의 순서가 큐의 순서입니다.
이 시각화에서 확인할 것 — "쓰기와 읽기" 탭에서 tail과 head가 한 칸씩 움직이는 모습을, "끝에서 처음으로" 탭에서 인덱스가 마지막 칸을 지나 0으로 돌아오는 순간을, "가득 참과 빈 상태" 탭에서 head == tail이 두 상태를 모두 뜻하는 문제와 두 가지 해결 방법을 STEP으로 확인하세요.

원형 버퍼의 배경

원형 버퍼는 생산자가 쓰는 속도와 소비자가 읽는 속도가 다를 때 둘 사이의 완충 지대로 쓰입니다. 크기가 고정이고 원소를 옮기지 않아서 장치 드라이버나 오디오 처리처럼 지연이 일정해야 하는 곳에 일반적으로 쓰입니다. 리눅스 커널 문서는 생산자와 소비자가 각각 하나일 때 양쪽 끝이 공유하는 하나의 락 없이, 메모리 배리어로 순서를 맞춰 쓰는 원형 버퍼 사용법을 따로 설명합니다. 커널의 CIRC_CNT와 CIRC_SPACE 매크로는 크기가 2의 거듭제곱인 버퍼를 전제로 합니다.

숫자로 확인하기

쓰기와 읽기 — 용량 8에서 A, B, C, D를 쓰면 tail이 0에서 4가 되고, 두 번 읽으면 head가 2가 됩니다. 원소는 2개이고 칸 0과 1은 비워진 채 다시 쓸 수 있습니다.
끝에서 처음으로 — 용량 8에서 tail이 7일 때 한 칸을 쓰면 (7 + 1) % 8 = 0이 되어 배열 끝에서 처음으로 돌아옵니다.
가득 참 — 용량 4에서 네 칸을 모두 채우면 tail이 (3 + 1) % 4 = 0으로 돌아와 head와 같아집니다. 처음의 빈 상태와 head == tail로 똑같아 보입니다. 한 칸을 비우는 방식이면 저장할 수 있는 원소는 3개입니다.
파이썬 확인 — 용량 4에서 a, b, c를 쓰고 a, b를 읽은 뒤 d, e, f를 쓰면 배열은 ['e', 'f', 'c', 'd']이고 head와 tail이 모두 2인 가득 찬 상태가 됩니다. 이어서 읽으면 c, d, e, f 순서로 나옵니다.
덮어쓰기 확인 — 용량 3에 1부터 5까지 쓰면 [3, 4, 5]만 남고, maxlen=3인 파이썬 deque도 같은 결과를 냅니다.

자주 하는 오해

실제로 원형으로 생긴 메모리가 있다 — 메모리는 일직선입니다. 원은 인덱스를 나머지 연산으로 되감아서 만든 논리적인 모양일 뿐입니다.
head == tail이면 항상 비어 있다 — 가득 찼을 때도 head == tail이 될 수 있습니다. 그래서 칸 하나를 비워 두거나 개수를 따로 세는 등의 약속이 필요합니다.
읽으면 값이 지워진다 — 읽기는 head만 옮기고, 읽은 칸의 값은 다음에 덮어쓰기 전까지 배열에 남아 있을 수 있습니다. 시각화에서는 읽은 칸을 비운 것으로 그리지만, 구현에 따라 실제로 지우지 않기도 합니다.
가득 차면 크기를 자동으로 늘린다 — 기본적인 원형 버퍼는 크기가 고정입니다. 늘리는 구현도 있지만 새 배열을 만들어 원소를 순서대로 옮겨야 해서 O(n)이 듭니다.

면접에서 이렇게 물어요

면접 질문

원형 버퍼는 왜 쓰나요?

고정 크기 배열로 큐를 만들면서 쓰기와 읽기를 O(1)에 하기 위해서입니다. 원소를 옮기지 않고 인덱스만 움직이며, 읽혀서 빈 앞쪽 칸을 다시 써서 메모리를 낭비하지 않습니다.

면접 질문

가득 참과 빈 상태는 어떻게 구분하나요?

head == tail만으로는 구분할 수 없어서 칸 하나를 비워 두고 (tail + 1) % 용량 == head를 가득으로 보거나, 원소 개수를 따로 세거나, head와 tail을 되감지 않고 계속 증가시켜 tail - head로 개수를 구합니다(인덱스가 정수 한계를 넘어도 맞으려면 용량이 2의 거듭제곱이어야 합니다).

면접 질문

용량을 2의 거듭제곱으로 잡는 이유는?

i가 0 이상이면 i % 용량 대신 i & (용량 - 1)로 칸 번호를 구할 수 있어 나눗셈이 필요 없습니다. 용량 제한이 생기는 대신 계산이 단순합니다.

면접 질문

여러 스레드가 쓰려면 어떻게 하나요?

쓰는 쪽과 읽는 쪽이 하나씩이면 각자 자기 인덱스만 바꾸므로 락 없이 만들 수 있지만, 메모리 순서를 보장하는 장치가 필요합니다. 생산자나 소비자가 여럿이면 락이나 원자적 연산 등 별도의 동기화가 필요합니다.

실제 시스템에서는

리눅스 커널 — 커널 문서가 원형 버퍼의 사용법을 설명하고, 쓰는 쪽과 읽는 쪽이 하나씩일 때 락 없이 쓰는 방법과 메모리 배리어의 필요성을 다룹니다. 들어 있는 개수(CIRC_CNT)와 남은 공간(CIRC_SPACE)을 계산하는 매크로도 이 문서에 나옵니다. 이 문서는 쓰는 쪽 인덱스를 head, 읽는 쪽 인덱스를 tail이라고 불러서, 이 페이지의 head와 tail과는 반대입니다.
파이썬 deque — maxlen을 주면 가득 찬 상태에서 새 항목을 넣을 때 반대쪽 끝의 항목이 버려져, 최근 n개만 유지하는 용도에 쓸 수 있습니다.
LMAX Disruptor — 스레드 사이에서 메시지를 주고받는 라이브러리로, 핵심 자료구조가 링 버퍼이며 크기를 2의 거듭제곱으로 잡습니다.
오디오 입출력 — 리눅스 ALSA 같은 오디오 계층은 소리 데이터를 링 버퍼로 주고받으며, 읽는 위치와 쓰는 위치를 따로 관리합니다.

함께 보면 좋은 토픽

스택과 큐 — 원형 버퍼가 구현하는 큐의 기본 동작을 봅니다.
배열과 연결 리스트 — 배열 기반 큐와 연결 리스트 기반 큐의 차이를 봅니다.
프로세스 간 통신 — 생산자와 소비자 사이의 버퍼가 쓰이는 곳을 봅니다.
프로세스 동기화 — 생산자 소비자 문제와 동기화를 봅니다.

참고 자료

공식 문서 — Linux Kernel Circular Buffers
공식 문서 — Python collections.deque
공식 문서 — LMAX Disruptor User Guide

Python — head와 tail로 만든 원형 버퍼 큐

from queue import Full

class RingBuffer:
    def __init__(self, capacity):
        self.buf = [None] * capacity
        self.cap = capacity
        self.head = 0                          # 읽을 위치
        self.tail = 0                          # 쓸 위치
        self.size = 0                          # 개수를 따로 센다

    def enqueue(self, v):
        if self.size == self.cap:
            raise Full('buffer full')
        self.buf[self.tail] = v
        self.tail = (self.tail + 1) % self.cap     # 끝에 닿으면 0으로 돌아간다
        self.size += 1

    def dequeue(self):
        if self.size == 0:
            raise IndexError('buffer empty')
        v = self.buf[self.head]
        self.buf[self.head] = None
        self.head = (self.head + 1) % self.cap
        self.size -= 1
        return v

rb = RingBuffer(4)
for v in 'abc':
    rb.enqueue(v)
print(rb.dequeue(), rb.dequeue(), rb.head, rb.tail, rb.size)   # a b 2 3 1
rb.enqueue('d'); rb.enqueue('e'); rb.enqueue('f')
print(rb.buf, rb.head, rb.tail, rb.size)       # ['e', 'f', 'c', 'd'] 2 2 4
try:
    rb.enqueue('g')
except Full as e:
    print(e)                                 # buffer full
print([rb.dequeue() for _ in range(4)])        # ['c', 'd', 'e', 'f']

원소를 옮기지 않고 head와 tail 두 인덱스만 움직입니다. enqueue는 tail 칸에 값을 쓰고 tail을 (tail + 1) % cap으로 한 칸 앞으로 보내고, dequeue는 head 칸의 값을 읽고 head를 같은 식으로 보냅니다. 둘 다 배열을 훑지 않으므로 O(1)입니다. 시각화의 쓰기와 읽기 탭이 보여 주는 동작과 같습니다. 용량 4에서 a, b, c를 넣고 a, b를 꺼내면 head는 2, tail은 3이고 원소는 1개입니다. 이어서 d, e, f를 넣으면 tail이 배열 끝(칸 3)을 지나 0으로 돌아와 e가 칸 0에, f가 칸 1에 쓰이고, 버퍼 배열은 ['e', 'f', 'c', 'd']가 됩니다. 이때 head와 tail이 모두 2로 같지만 원소가 4개인 가득 찬 상태입니다. head == tail만으로는 비어 있음과 가득 참을 구분할 수 없어서, 이 코드는 size를 따로 세어 판단합니다. 가득 찬 상태에서 enqueue하면 예외(여기서는 표준 라이브러리 queue의 Full)를 던지고, dequeue는 쓴 순서대로 c, d, e, f가 나옵니다. 배열 안에서는 순서가 돌아가 있어도 읽는 순서는 먼저 쓴 것이 먼저(FIFO)입니다.

Python — 가득 차면 가장 오래된 값을 덮어쓰는 버퍼

class OverwriteBuffer:
    def __init__(self, capacity):
        self.buf = [None] * capacity
        self.cap = capacity
        self.count = 0                         # 지금까지 쓴 총 개수

    def write(self, v):
        self.buf[self.count % self.cap] = v   # 가장 오래된 값을 덮어쓴다
        self.count += 1

    def items(self):
        n = min(self.count, self.cap)
        start = (self.count - n) % self.cap
        return [self.buf[(start + i) % self.cap] for i in range(n)]

ob = OverwriteBuffer(3)
for i in range(1, 6):
    ob.write(i)
print(ob.items())                           # [3, 4, 5]

from collections import deque
dq = deque(maxlen=3)
for i in range(1, 6):
    dq.append(i)
print(list(dq))                              # [3, 4, 5]

로그나 센서 값처럼 최근 n개만 필요한 경우에는 가득 찼을 때 쓰기를 거부하지 않고 가장 오래된 값을 덮어씁니다. 여기서는 읽는 쪽 인덱스를 따로 두지 않고 지금까지 쓴 총 개수 count 하나로 위치를 계산합니다. count % cap이 다음에 쓸 칸이고, 가득 찬 뒤에는 그 칸에 있던 가장 오래된 값을 새 값이 덮어씁니다. 용량 3에 1부터 5까지 쓰면 1과 2가 차례로 덮어써져 최근 값 [3, 4, 5]만 남습니다. items는 가장 오래된 값부터 읽기 위해 (count - n) % cap 칸에서 시작합니다. 파이썬 표준 라이브러리의 deque에 maxlen을 주면 같은 동작을 합니다. maxlen이 있는 deque는 가득 찬 상태에서 한쪽 끝에 항목을 추가할 때 반대쪽 끝의 항목을 버리므로, 위 직접 구현과 같은 [3, 4, 5]가 나옵니다.

Java — 용량을 2의 거듭제곱으로 잡고 나머지 연산을 비트 AND로 대신하기

static class Ring {
    private final int[] buf;
    private final int mask;
    private int head = 0;
    private int tail = 0;

    Ring(int capacityPowerOfTwo) {
        buf = new int[capacityPowerOfTwo];
        mask = capacityPowerOfTwo - 1;
    }

    boolean offer(int v) {
        if (tail - head == buf.length) return false;   // 가득 참
        buf[tail & mask] = v;
        tail++;
        return true;
    }

    int poll() {
        if (tail == head) return -1;                  // 비어 있음
        return buf[head++ & mask];
    }
}

Ring r = new Ring(4);
for (int i = 1; i <= 5; i++) System.out.print(r.offer(i) + " ");
System.out.println();                                    // true true true true false
System.out.println(r.poll() + " " + r.poll());                          // 1 2
r.offer(6); r.offer(7);
System.out.println(r.poll() + " " + r.poll() + " " + r.poll() + " " + r.poll());   // 3 4 6 7

i가 0 이상이고 용량이 2의 거듭제곱이면 i % cap과 i & (cap - 1)의 결과가 같아서, 나머지 연산 대신 보통 더 빠른 비트 AND 한 번으로 칸 번호를 구할 수 있습니다. 용량 4의 mask는 3(이진수 11)이고 7 & 3은 3입니다. 이 방식은 용량을 2의 거듭제곱으로 제한하는 대신 계산이 단순해집니다. 여기서는 head와 tail을 0으로 되돌리지 않고 계속 증가시킵니다. 인덱스가 int 범위를 넘어 음수가 되어도 용량이 2의 거듭제곱이라서 tail & mask와 tail - head가 계속 맞습니다. 원소 개수는 tail - head로 구하므로 size 변수가 따로 없고, tail - head가 0이면 비어 있고 용량과 같으면 가득 찬 것이라 head == tail의 모호함이 없습니다. 용량 4에 1부터 5를 넣으면 앞의 네 개만 들어가고 다섯 번째는 false입니다. 1, 2를 꺼낸 뒤 6, 7을 넣으면 6과 7이 칸 0과 1에 쓰이고, 남은 값을 꺼내면 쓴 순서대로 3, 4, 6, 7이 나옵니다.

Q1

원형 버퍼에서 head 또는 tail 인덱스를 한 칸 앞으로 옮길 때 쓰는 계산은?

끝에 도달하면 다시 처음으로 돌아와야 합니다.
인덱스를 1 증가시킨 뒤 용량으로 나눈 나머지를 취하면, 배열 끝에 도달했을 때 다시 0으로 돌아와 원형으로 순환하게 됩니다.

Q2

원형 버퍼에서 head == tail 일 때 비어 있는지 가득 찼는지 구분하는 흔한 방법은?

같은 상태가 두 가지 의미를 가질 수 있습니다.
head와 tail이 같을 때 비어 있음과 가득 참이 모호해지므로, 한 칸을 비워 두는 규칙을 쓰거나 원소 개수(size)를 따로 관리해 구분합니다.

Q3

원형 버퍼를 큐로 사용할 때의 장점은?

일반 배열 큐는 앞에서 꺼낼 때 원소를 당겨야 합니다.
원형 버퍼는 head와 tail 인덱스만 이동시키므로 원소를 옮기지 않고도 O(1)로 삽입·삭제할 수 있고 메모리도 고정 크기라 예측 가능합니다. 스트리밍 데이터의 버퍼로 자주 쓰입니다.