"과연 DB에 failed_notifications 테이블을 만들고, 실패한 알림을 이곳에 INSERT한 뒤 기존 notifications 테이블에서 DELETE 처리하는 것이 최선일까?"
프로젝트를 진행하며 알림 발송 실패 처리에 대해 고민하게 되었습니다.
보통 실패 처리를 논할 때
"Redis의 지연 큐(Delayed Queue)를 쓰자" 혹은 "Kafka의 DLQ(Dead Letter Queue)를 쓰자"라고 결론을 내리기도 합니다.
하지만 무턱대고 새로운 인프라를 추가한다는 것은
관리 포인트 증가, 그리고 새로운 장애 전파 가능성의 증가를 의미하기도 합니다.
그래서 이번 글에서는 새로운 써드파티 인프라(Kafka, Redis 등)에 기대지 않고,
현재 가진 기술(Spring, Java, MySQL, OS)을 활용하여 실패 알림을 재처리하는 방법들을 정리해 보려고 합니다.
(아직 학습해 나가는 과정이라 틀린 내용이 있을 수 있습니다. 다양한 관점에서 고민해 본 흔적으로 너그럽게 봐주시면 감사하겠습니다 !)
1. Spring/Java 네이티브를 극한으로 활용 (In-Memory 방식)
가장 먼저 고민해 볼 수 있는 것은 DB I/O 자체를 발생시키지 않고 애플리케이션 메모리 단에서 해결하는 방법입니다.
사실 이 부분에서 @Retryable을 먼저 도입했었다가 실패 알림을 테이블에 저장하는 방식으로 변경한 것이긴 합니다 ..
① Spring @Retryable + 지수 백오프(Exponential Backoff)
- 개념: FCM이나 외부 API 호출 실패의 상당수는 '일시적인 네트워크 지연'일 확률이 높습니다. 이를 굳이 실패 테이블에 넣고 스케줄러로 다시 읽어올 필요 없이, 실패 즉시 메모리 단에서 짧게 여러 번 재시도하는 방식입니다.
- 활용: @Retryable(backoff = @Backoff(delay = 1000, multiplier = 2)) 설정을 통해 1초, 2초, 4초 간격으로 스레드가 대기하며 재시도하게 만듭니다.
- 장단점: 네트워크를 타는 DB I/O가 아예 발생하지 않는다는 엄청난 장점이 있습니다. 하지만 서버가 재시도 도중 다운되면 데이터가 휘발됩니다. 따라서 최대 3회 재시도 후에도 실패하면(@Recover 발동) 최후의 수단으로만 DB나 파일에 남기는 하이브리드 구성이 필요합니다. 아니면 여기서도 Graceful Shutdown을 ..
② Java DelayQueue 또는 Netty HashedWheelTimer -> 이건 Netty를 써야 가능 ..
- 개념: DB 테이블 대신 JVM 메모리 상의 큐에 실패한 알림을 '예약된 시간'과 함께 넣어두는 방식입니다.
- 활용: 실패 알림을 DelayQueue에 넣고 +5분 타이머를 맞춥니다. 백그라운드 스레드가 큐를 바라보고 있다가 시간이 지난 객체만 쏙쏙 뽑아 다시 전송합니다.
- 장단점: DB 부하를 꺼리는 환경에서 처리량을 확보할 수 있습니다. 단, 이 역시 노드 다운 시 휘발되므로 Graceful Shutdown 시 큐에 남은 데이터를 디스크에 플러시(Dump)하는 방어 로직이 필수입니다.
2. MySQL(RDBMS)을 튜닝하는 방식
만약 메모리 방식의 휘발성이 우려되어 RDBMS를 계속 사용해야 한다면,
기존 스케줄러 방식에서 흔히 발생하는 '데드락'과 '무한 루프'를 회피할 수 있는 설계가 필요합니다.
③ 테이블 분리 금지: State Machine (상태 머신) 패턴
- 개념: 실패 테이블을 따로 만들면 INSERT(실패 테이블)와 DELETE(기존 테이블) 쿼리가 묶여 트랜잭션이 무거워질 수 있습니다. 크게 차이 없을 것 같긴 합니다만 .. 그럴 가능성이 존재한다는 의미입니다.
- 활용: 아예 테이블을 분리하지 않고 단일 테이블에 status(PENDING, SENT, FAILED), retry_count, next_retry_at 컬럼만 두어 상태 머신처럼 관리합니다.
- 장단점: 실패 시 상태만 UPDATE 하면 되므로 데드락 확률이 줄어들 수 있습니다. 스케줄러는 단순히 next_retry_at < NOW()인 레코드만 폴링하면 됩니다.
④ MySQL 8.0 SKIP LOCKED 기반의 멀티 스레드 폴링
- 개념: 다중 서버 환경에서 여러 스케줄러 스레드가 실패 테이블에 동시에 접근할 때 발생하는 락 경합을 막는 SQL 문법입니다. 새로 추가된 만큼 학습이 필요합니다 !
- 활용: SELECT * FROM notifications WHERE status = 'FAILED' FOR UPDATE SKIP LOCKED LIMIT 100
- 장단점: 1번 스레드가 1~100번 알림에 락을 걸고 가져가면, 2번 스레드는 블로킹(대기) 상태에 빠지지 않고 락이 걸린 부분을 건너뛰어(Skip) 101~200번을 즉시 가져갑니다. 복잡한 파티셔닝이나 Redis 분산 락 없이도 RDBMS만으로 병렬 재처리가 가능해집니다.
3. OS 및 파일 시스템 활용 (Local File WAL)
DB가 아닌 로컬 파일 시스템(Local File System)을 큐나 백업 용도로 활용하는 경우를 고려해 볼 수 있습니다.
실패한 알림 데이터를 DB가 아니라 서버의 로컬 디스크 파일(failed-notifications.jsonl)에 한 줄씩 Append(추가)하고, 별도 스레드나 OS의 Cron이 이를 읽어 재전송하는 Write-Ahead Log 방식입니다.
💡 "어차피 둘 다 하드디스크에 쓰는 건데 뭐가 다를까 ?"
처음 이 방식을 접했을 때, 제 머릿속에는 한 가지 의문이 들었습니다.
"DBMS를 쓰든 OS의 파일 시스템을 쓰든, 결국 물리적인 하드디스크(Storage)에 쓰는 건 똑같은데 왜 굳이 DB 대신 파일을 쓰라는 걸까? 물리 디스크가 망가지면 둘 다 날아가는 건 똑같지 않나?"
하지만 아키텍처 관점에서 이 둘은 "목적지(디스크)에 도달하기까지의 과정"에서 차이가 있다고 생각합니다.
- 네트워크와 커넥션 풀의 유무 (가장 큰 차이) 스프링 서버가 DB 서버에 데이터를 쓰려면 네트워크를 타야 하고, HikariCP에서 커넥션을 빌려와야 하며, 트랜잭션을 맺어야 합니다. 알림 발송이 실패하는 상황은 이미 시스템 부하나 네트워크가 불안정할 확률이 높습니다. 이때 원격 DB로 무거운 커넥션을 맺고 쿼리를 날리는 것 자체가 또 다른 실패를 유발하거나 데드락으로 이어질 수 있습니다. 반면 로컬 파일 저장은 네트워크와 커넥션 풀이라는 거대한 장애 포인트를 우회합니다.
- 장애 전파 방지 (Failure Isolation) 만약 DB 서버 자체가 뻗어버린다면 어떻게 될까요 ? DB에 의존하는 구조라면 실패 로그조차 남기지 못하고 시스템이 마비됩니다. 반면 로컬 파일 구조라면 DB가 죽어있어도 웹 서버는 자기 디스크에 실패 내역을 차곡차곡 적어둘 수 있습니다. (단일 실패점 격리) 그래도 그냥 애플리케이션 서버 자체가 뻗어 버리면 진짜 별 수 없긴 합니다. 그래서 뭐 서버를 여러 대 두든 그런 식으로 고가용성을 확보하는 것도 고려해야 합니다. 그럼에도 전부 뻗어 버리면 어떡하냐 ? 라는 식의 무지성 실패 반복에 대한 생각은 잠시 접어 두겠습니다.
- 디스크 I/O 방식의 차이 MySQL에 INSERT 할 때는 데이터만 쓰는 것이 아니라 B-Tree 인덱스 업데이트, 락 확보, Undo/Redo 로그 기록 등 디스크의 여러 곳을 찌르는 무거운 작업이 동반됩니다. 반면 텍스트 파일 맨 끝에 내용만 한 줄 덧붙이는 작업(Sequential I/O)은 메인 비즈니스 로직에 성능적 부담을 거의 주지 않을 수 있습니다.
4. 패러다임의 전환: Client-Pull (동기화) 방식 - 이건 일단 지금 프론트가 없긴 합니다 ..
마지막으로, 아예 생각의 틀을 바꿔볼 수도 있습니다. "서버가 굳이 재전송을 할 필요가 있을까 ?"
서버는 알림 전송에 실패하면 단순히 DB에 is_read = false 로만 남겨두고 재처리 시도는 하지 않습니다. 대신 모바일 앱(클라이언트)이 백그라운드에서 포그라운드(화면 켬)로 올라오는 순간, 앱이 알아서 /api/notifications/missed API를 호출해 스스로 못 받은 알림을 가져가게(Pull) 만듭니다.
이렇게 하면 서버의 복잡한 재시도(Retry) 아키텍처 자체가 통째로 날아가며, 시스템이 극도로 단순해지고 안정성이 올라가는 효과를 얻을 수 있습니다.
마무리 및 프로젝트 회고
알림 도메인은 유실되어서는 안 되기에 안정적인 처리가 무척이나 중요합니다.
알림 도메인이 뭡니까 ? 진짜로 '알림'이 중요한 도메인이라는 거겠죠. 그럼 알림 전송이 실패하지 않도록 해야겠죠 ?
저는 현재 진행 중인 SSA(Simple Schedule App) 프로젝트에서 여러 가지 방안을 고민하며 다음과 같은 아키텍처 결정을 내렸습니다.
우선 일시적인 네트워크 장애를 극복하기 위해 매번 DB I/O를 발생시키는 것은 낭비라고 판단하여, Spring Retry 기반의 인메모리 지수 백오프를 1차 방어선으로 염두에 두었습니다.
다만 JVM 크래시 시 데이터가 휘발될 위험을 막고자 영속화가 필요했고,
트래픽 규모를 고려했을 때 Kafka 도입은 오버엔지니어링이라 판단하여 현재 인프라인 MySQL을 활용하는 방식을 택했습니다.
스케줄러 환경에서 발생할 수 있는 데드락 문제를 회피하기 위해 '배치 사이즈 기반의 순차 처리' 구조를 직접 구현하였으며, 추후 다중 서버 환경으로 스케일아웃(Scale-out) 시 스케줄러 간 경합이 심해진다면 MySQL 8.0의 SKIP LOCKED 문법을 활용해 병렬 처리로 고도화할 수 있겠다는 생각입니다.
이번 고민을 통해 다시 한 번, 방법은 많고 장단점을 고려하면서 트레이드오프를 저울질해야 한다는 생각이 들었습니다 ..
맹목적으로 최신 툴(Kafka, Redis)에 의존하기보다,
Java 네이티브와 RDBMS, 나아가 OS 레벨의 특성까지 이해하고 적재적소에 꺼내 쓸 줄 아는 것이
진짜 엔지니어이지 않을까 .. 하는 생각입니다.
그래도 막상 면접 보면 이런 여러 가지 방법들에 대한 생각을 하기가 쉽지 않네요 ..
'SSA > Back' 카테고리의 다른 글
| [SSA] 실패한 알림에 대해서는 순차 처리 ? 병렬 처리 ? (0) | 2025.12.14 |
|---|---|
| [SSA] 실패한 알림 재전송 시 데드락 테스트 (0) | 2025.12.13 |
| [SSA] 비동기 콜백 등록 후 스케줄러를 통해 Retry를 하게 될 경우 무한 루프에 빠진다 ..? (0) | 2025.12.13 |
| [SSA] 스케줄러를 통해 Retry를 하게 될 경우 트랜잭션 처리 (0) | 2025.12.11 |
| [SSA] 카프카 메시지를 발행할 때 ZERO-PAYLOAD ? 아니면 Event-Carried State Transfer ? (0) | 2025.12.11 |