모니터링 도구를 만들다 보면 모든 이벤트를 화면에 계속 쌓아둘 필요는 없습니다.

예를 들어 운영 대시보드에서는
가장 최근에 들어온 알림 몇 개만 빠르게 보여주는 편이 더 실용적일 수 있습니다.

이때 필요한 것은 무한히 커지는 리스트가 아니라,
최근 N개만 고정 크기로 유지하는 저장 구조입니다.

이번 글에서는 최근 알림 N개만 보관하고,
최신 기준으로 원하는 위치의 알림을 빠르게 꺼낼 수 있는 구조를 만들어보겠습니다.

핵심은 원형 버퍼(Circular Buffer) 입니다.


문제

한 운영 대시보드에는 서버 알림이 계속 들어옵니다.

알림은 다음처럼 문자열 코드로 들어온다고 하겠습니다.

DB_DOWN
API_TIMEOUT
CACHE_MISS
QUEUE_DELAY

대시보드에서는 최근 알림 N개만 메모리에 보관합니다.

다음 두 기능을 지원하는 자료구조를 구현하세요.

push_alert(code)
  • 새 알림을 버퍼에 추가합니다.
  • 버퍼가 가득 찼다면 가장 오래된 알림을 덮어씁니다.
get_from_latest(offset)
  • 최신 알림을 기준으로 offset만큼 떨어진 알림을 반환합니다.
  • offset = 0이면 가장 최근 알림입니다.
  • offset = 1이면 그 직전 알림입니다.
  • offset은 현재 저장된 알림 수보다 작다고 가정합니다.

저장 공간은 최대 N개까지만 사용해야 합니다.


예시

최근 알림을 최대 4개만 저장한다고 하겠습니다.

panel = AlertPanel(4)

알림이 아래 순서로 들어옵니다.

DB_DOWN
API_TIMEOUT
CACHE_MISS
QUEUE_DELAY

현재 최근 순서는 다음과 같습니다.

DB_DOWN, API_TIMEOUT, CACHE_MISS, QUEUE_DELAY

이때 조회 결과는 다음과 같습니다.

get_from_latest(0) -> QUEUE_DELAY
get_from_latest(1) -> CACHE_MISS
get_from_latest(2) -> API_TIMEOUT
get_from_latest(3) -> DB_DOWN

이제 새 알림이 하나 더 들어옵니다.

WORKER_RESTART

버퍼 크기는 4개뿐이므로 가장 오래된 DB_DOWN은 사라집니다.

최근 알림은 이렇게 됩니다.

API_TIMEOUT, CACHE_MISS, QUEUE_DELAY, WORKER_RESTART

조회 결과는 다음과 같습니다.

get_from_latest(0) -> WORKER_RESTART
get_from_latest(1) -> QUEUE_DELAY
get_from_latest(2) -> CACHE_MISS
get_from_latest(3) -> API_TIMEOUT

처음 떠오르는 방법

가장 단순한 방법은 리스트에 계속 알림을 추가하는 것입니다.

alerts.append(code)

그리고 최신 기준 조회는 뒤에서부터 접근하면 됩니다.

alerts[-1 - offset]

하지만 이 방식은 알림이 계속 쌓이면 리스트 크기가 끝없이 커진다는 문제가 있습니다.

이번 문제에서는 최근 N개만 필요합니다.

즉, 오래된 알림은 버리고 메모리는 고정 크기만 사용해야 합니다.


핵심 아이디어: 원형 버퍼

원형 버퍼는 고정된 크기의 배열을 하나 만들어두고,
끝까지 채우면 다시 처음 칸으로 돌아가 새 값을 덮어쓰는 구조입니다.

크기가 4인 배열을 예로 들어보겠습니다.

처음 상태:

[ _, _, _, _ ]

알림 4개를 넣으면:

[ DB_DOWN, API_TIMEOUT, CACHE_MISS, QUEUE_DELAY ]

이제 새 알림 WORKER_RESTART가 오면
배열을 늘리지 않고 처음 칸을 덮어씁니다.

[ WORKER_RESTART, API_TIMEOUT, CACHE_MISS, QUEUE_DELAY ]

겉으로 보기에는 순서가 뒤섞인 것처럼 보여도,
다음 저장 위치만 정확히 기억하면 최신 기준 조회를 빠르게 할 수 있습니다.


필요한 값

원형 버퍼 구현에는 아래 값들이 필요합니다.

변수역할
capacity최대 저장 가능한 알림 수
buffer고정 크기 배열
write_index다음 알림이 기록될 위치
count현재 저장된 알림 수

여기서 중요한 점은:

write_index = 가장 최근 알림의 위치

가 아니라,

write_index = 다음에 새 알림을 쓸 위치

라는 것입니다.

이 차이를 헷갈리면 최신 기준 조회에서 인덱스가 한 칸씩 밀릴 수 있습니다.


코드

class AlertPanel:
    def __init__(self, capacity):
        self.capacity = capacity
        self.buffer = [None] * capacity
        self.write_index = 0
        self.count = 0

    def push_alert(self, code):
        self.buffer[self.write_index] = code
        self.write_index = (self.write_index + 1) % self.capacity
        self.count = min(self.count + 1, self.capacity)

    def get_from_latest(self, offset):
        if offset < 0 or offset >= self.count:
            raise ValueError("조회할 수 없는 위치입니다.")

        index = (self.write_index - 1 - offset) % self.capacity
        return self.buffer[index]

코드 설명

1. 고정 크기 배열 만들기

self.buffer = [None] * capacity

처음부터 capacity만큼의 공간을 만들어둡니다.

알림이 아무리 많이 들어와도 배열은 이보다 커지지 않습니다.

즉, 메모리 사용량은 항상 제한됩니다.

2. 다음 저장 위치 기억하기

self.write_index = 0

첫 번째 알림은 0번 칸에 저장됩니다.

그리고 알림이 추가될 때마다 write_index를 한 칸씩 앞으로 움직입니다.

3. 새 알림 저장하기

self.buffer[self.write_index] = code

현재 write_index 위치에 새 알림을 기록합니다.

이미 값이 있으면 덮어씁니다.

즉, 가장 오래된 알림은 자연스럽게 사라집니다.

4. 끝에 도달하면 다시 처음으로 돌기

self.write_index = (self.write_index + 1) % self.capacity

capacity = 4라면 인덱스는 이렇게 순환합니다.

0 -> 1 -> 2 -> 3 -> 0 -> 1 ...

이 순환 구조 때문에 이름이 원형 버퍼입니다.

5. 현재 저장 개수 제한하기

self.count = min(self.count + 1, self.capacity)

처음에는 아직 버퍼가 가득 차지 않았을 수 있습니다.

예를 들어 용량이 4개인데 알림이 2개만 들어왔다면 count = 2입니다.

하지만 버퍼가 가득 찬 뒤에는 count가 더 커지지 않아야 하므로 min()으로 제한합니다.


최신 기준 조회는 어떻게 계산할까?

조회에서 가장 중요한 코드는 이 부분입니다.

index = (self.write_index - 1 - offset) % self.capacity

write_index는 다음에 쓸 위치입니다.

따라서 가장 최근 알림은 항상 그 바로 앞 칸에 있습니다.

즉:

offset = 0 -> write_index - 1
offset = 1 -> write_index - 2
offset = 2 -> write_index - 3

이 식을 일반화하면:

write_index - 1 - offset

가 됩니다.

그리고 음수 인덱스를 처리하기 위해 % capacity를 사용합니다.


예제로 따라가기

버퍼 크기가 4개라고 하겠습니다.

panel = AlertPanel(4)

초기 상태:

buffer = [None, None, None, None]
write_index = 0

DB_DOWN 저장

panel.push_alert("DB_DOWN")
buffer = [DB_DOWN, None, None, None]
write_index = 1

API_TIMEOUT 저장

panel.push_alert("API_TIMEOUT")
buffer = [DB_DOWN, API_TIMEOUT, None, None]
write_index = 2

CACHE_MISS 저장

panel.push_alert("CACHE_MISS")
buffer = [DB_DOWN, API_TIMEOUT, CACHE_MISS, None]
write_index = 3

QUEUE_DELAY 저장

panel.push_alert("QUEUE_DELAY")
buffer = [DB_DOWN, API_TIMEOUT, CACHE_MISS, QUEUE_DELAY]
write_index = 0

이제 버퍼 끝까지 썼으므로 다음 쓰기 위치는 다시 0입니다.

WORKER_RESTART 저장

panel.push_alert("WORKER_RESTART")
buffer = [WORKER_RESTART, API_TIMEOUT, CACHE_MISS, QUEUE_DELAY]
write_index = 1

가장 오래된 DB_DOWN이 새 알림으로 덮어써졌습니다.


조회 예시

현재 상태:

buffer = [WORKER_RESTART, API_TIMEOUT, CACHE_MISS, QUEUE_DELAY]
write_index = 1

가장 최근 알림

panel.get_from_latest(0)

계산:

(1 - 1 - 0) % 4 = 0

buffer[0]WORKER_RESTART입니다.

직전 알림

panel.get_from_latest(1)

계산:

(1 - 1 - 1) % 4 = 3

buffer[3]QUEUE_DELAY입니다.

그 이전 알림

panel.get_from_latest(2)

계산:

(1 - 1 - 2) % 4 = 2

buffer[2]CACHE_MISS입니다.


전체 실행 예시

panel = AlertPanel(4)

panel.push_alert("DB_DOWN")
panel.push_alert("API_TIMEOUT")
panel.push_alert("CACHE_MISS")
panel.push_alert("QUEUE_DELAY")

print(panel.get_from_latest(0))  # QUEUE_DELAY
print(panel.get_from_latest(1))  # CACHE_MISS
print(panel.get_from_latest(2))  # API_TIMEOUT
print(panel.get_from_latest(3))  # DB_DOWN

panel.push_alert("WORKER_RESTART")

print(panel.get_from_latest(0))  # WORKER_RESTART
print(panel.get_from_latest(1))  # QUEUE_DELAY
print(panel.get_from_latest(2))  # CACHE_MISS
print(panel.get_from_latest(3))  # API_TIMEOUT

시간 복잡도

push_alert(code)

배열 한 칸에 저장하고 인덱스만 움직입니다.

시간 복잡도: O(1)

get_from_latest(offset)

인덱스를 계산해서 바로 접근합니다.

시간 복잡도: O(1)

공간 복잡도

버퍼 크기는 최대 N으로 고정됩니다.

공간 복잡도: O(N)

왜 이 방식이 효율적일까?

최근 알림 몇 개만 필요할 때 전체 로그를 다 저장하는 것은 낭비입니다.

원형 버퍼를 사용하면:

  • 저장 공간이 늘어나지 않고
  • 오래된 값을 따로 지울 필요도 없고
  • 새 알림 추가가 빠르고
  • 최신 기준 조회도 빠릅니다

즉, 최근 N개만 유지하는 시스템에 잘 맞습니다.


헷갈리기 쉬운 부분

1. write_index는 최신 알림 위치가 아니다

write_index는 다음에 쓸 위치입니다.

가장 최근 알림은 항상 write_index - 1 위치에 있습니다.

2. 왜 % capacity를 쓰는가?

배열 끝에 도달하면 다시 처음 칸으로 돌아가기 위해서입니다.

0 -> 1 -> 2 -> 3 -> 0 -> 1

3. 오래된 알림은 어떻게 없어질까?

삭제하지 않습니다.

새 값이 같은 칸을 덮어쓰면서 가장 오래된 값이 자연스럽게 밀려납니다.

4. 앞에서부터 지우는 리스트보다 왜 나은가?

리스트 앞에서 값을 지우면 나머지 값을 당겨야 할 수 있습니다.

원형 버퍼는 그런 이동 없이 고정 배열을 돌려 쓰기 때문에 더 효율적입니다.


정리

이번 글에서는 최근 알림 N개만 보관하는 모니터링 패널 구조를 만들어봤습니다.

핵심은 원형 버퍼입니다.

고정 크기 배열을 만들고,
새 알림이 들어오면 현재 위치에 저장하고,
끝까지 가면 처음으로 돌아와 덮어쓴다.

이 구조를 사용하면 최근 데이터만 효율적으로 관리할 수 있습니다.

정리하면:

push_alert(code)       -> O(1)
get_from_latest(offset) -> O(1)
공간 복잡도             -> O(N)

이 문제는 단순히 값을 저장하는 연습이 아니라,
전체를 다 보관하지 않고도 최근 상태를 빠르게 유지하는 방법을 생각하게 해주는 문제입니다.