모니터링 도구를 만들다 보면 모든 이벤트를 화면에 계속 쌓아둘 필요는 없습니다.
예를 들어 운영 대시보드에서는
가장 최근에 들어온 알림 몇 개만 빠르게 보여주는 편이 더 실용적일 수 있습니다.
이때 필요한 것은 무한히 커지는 리스트가 아니라,
최근 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)
이 문제는 단순히 값을 저장하는 연습이 아니라,
전체를 다 보관하지 않고도 최근 상태를 빠르게 유지하는 방법을 생각하게 해주는 문제입니다.