안녕하세요, 카카오뱅크 알림경험엔지니어링팀 서버 개발자 Remy, Kenneth, Jett입니다.
플랫폼 개발자는 항상 딜레마에 직면합니다. 성능을 높이면 코드가 복잡해지고, 코드가 복잡해지면 다른 개발자가 이해하기 어려워집니다. Lock-Free 자료구조, Work-Stealing 알고리즘, GC(Garbage Collection) 최적화 같은 기법들은 성능에는 효과적이지만, 이런 복잡성이 코드 전반에 퍼지면 새로운 기능을 추가하는 데 큰 부담이 됩니다.
저희 팀은 카카오뱅크의 UMS(Unified Messaging System) 발송기를 재구축하며 이 딜레마를 경험했습니다. 하루 N억 건의 메시지를 처리해야 하는 시스템이기에 성능 최적화는 필수였지만, 동시에 Push, Email, SMS, 알림톡 등 계속 추가되는 채널을 빠르게 지원해야 했습니다.
레거시 발송기의 가장 큰 문제는 개발자의 멘탈 모델이 복잡하다 못해, 여러 개발자의 멘탈을 터트렸다는 것입니다. 새로운 채널을 추가하려면 Thread Pool 관리, 재시도 로직, 큐 처리 메커니즘 등 시스템 내부를 모두 이해해야 했습니다. “Push는 어떻게 구현되어 있더라?”, “이 코드를 건드리면 다른 채널도 영향받지 않을까?“라는 불안감 속에서 며칠씩 코드를 파고들어야 했고, 수정 후에는 예상치 못한 곳에서 버그가 터졌습니다.
신규 발송기를 설계하며 내린 핵심 결정은 이것이었습니다:
“개발자의 멘탈 모델을 단순하게 유지하라”
복잡한 성능 최적화는 시스템 내부(Core Layer)에 완전히 숨기고, 개발자에게는 단순한 인터페이스(Service Layer)만 제공하는 것입니다. 이를 통해 높은 처리량을 유지하면서도, 새로운 채널 추가는 단기간에 끝낼 수 있는 시스템을 만들 수 있었습니다.
이 글에서는 두 가지를 공유합니다:
- 계층 분리 설계: Service/Core Layer로 복잡성 격리
WorkStealQueue구현: Lock-Free와 Barrier를 활용한 성능 최적화
이 글이 고성능 플랫폼을 설계하면서도 개발자 경험을 개선하려는 분들께 도움이 되길 바랍니다.
시작 배경: 레거시의 두 가지 문제
레거시 발송기를 분석하며 두 가지 근본적인 문제를 발견했습니다.
문제 1: 구조적 한계로 인한 성능 병목
발송기는 하루 N억 건의 메시지를 처리하는 시스템이기에, 다음과 같은 성능 지표를 만족해야 했습니다.
• 지연시간: P99 < 200ms
• 안정성: GC로 인한 장애 최소화
하지만 레거시 발송기는 아래 두 가지와 같은 구조적 한계로 이를 달성할 수 없었습니다.
1. Thread Pool 미사용
레거시는 요청이 들어올 때마다 새로운 Thread를 생성했습니다.
요청 1,000개 → Thread 1,000개 생성
→ Context Switching 오버헤드
→ Thread Pool 고갈 → OOM(Out Of Memory, 메모리 부족)
요청마다 Thread를 생성하는 방식은 트래픽 증가에 따라 Thread 수가 함께 늘어나는 구조입니다. 이로 인해 Context Switching 오버헤드와 메모리 사용량이 증가하며, 고트래픽 상황에서는 지연시간 증가나 메모리 부족으로 이어질 수 있습니다.
2. 단일 큐로 인한 Tail Latency
레거시는 단일 큐 구조였기 때문에, 느린 작업 하나가 뒤의 빠른 작업들을 막았습니다.
Worker 1: [Task(100ms)] [Task(2000ms)] [Task(100ms)] [Task(100ms)]
↑ 느린 작업 때문에 뒤의 작업들이 대기
Worker 2: [유휴 상태]
Worker 3: [유휴 상태]
프로파일링 결과, P99 Latency의 80%가 이런 대기 시간이었습니다.
문제는 알림의 특성상 수 초도 긴 시간이라는 점입니다. 인증번호 SMS나 거래 확인 Push처럼 즉각적인 인터렉션이 필요한 경우, 수 초의 지연은 사용자 이탈로 이어집니다. 하지만 레거시에서는 일부 요청이 수 분 이상 걸리기도 했습니다.
문제 2: 복잡한 멘탈 모델로 인한 확장성 한계
구조적 문제만큼 심각했던 것은 개발자의 멘탈 모델이었습니다.
카카오뱅크는 Push, Email, SMS, 알림톡 등 다양한 채널로 고객과 소통합니다. “브랜드 메시지 추가 부탁드립니다"와 같은 요청이 끊임없이 들어왔지만, 레거시에서는 채널 하나를 추가하는 데 며칠이 걸렸습니다.
공통 기능의 중복 구현 문제
더 큰 문제는 채널마다 필요한 공통 기능들이었습니다. 예를 들어 광고성 메시지는 법적으로 야간 발송이 금지되어 있습니다. 이런 기능은 Push 발송 코드에서 한 번, Email 발송 코드에서 또 한 번 각자 구현하는 게 아니라, 플랫폼 공통 레이어에서 한 번만 구현하여 모든 채널이 사용하도록 해야 합니다.
• 광고 발송 가능 시간 체크 (야간 발송 차단)
• Rate Limiting (채널별 초당 발송량 제한)
• 재시도 정책 (실패 시 재발송 규칙)
• 결과 추적 (발송 성공/실패 통계)
• 비용 추적 (채널별 발송 비용 집계)
하지만 레거시는 구조가 불명확했습니다. 새로운 기능을 추가하려 할 때마다 개발자 간에도 이견이 발생했습니다:
"광고 시간 체크는 어디에 넣어야 하지?"
→ A: "각 Handler에 넣자"
→ B: "공통 로직이니 상위에 넣자"
→ C: "아니면 Sender에?"
왜 이런 일이 반복되었나?
핵심은 구조입니다. 좋은 구조는 의사결정의 속도를 높입니다. Spring Framework를 사용할 때를 떠올려보세요. “이 비즈니스 로직은 어디에 둘까?” Service Layer에 둡니다. “DB 접근은 어디에서 처리할까?” Repository에서 처리합니다. Controller/Service/Repository라는 명확한 계층이 있기 때문에, 우리는 코드를 어디에 배치할지 크게 고민하지 않아도 됩니다.
플랫폼 개발자의 책임은 이런 구조를 만드는 것입니다. 좋은 구조를 제공함으로써 모든 개발자의 속도를 빠르게 만드는 것, 그것이 플랫폼의 가치입니다.
하지만 레거시는 그런 명확한 구조가 없었습니다. “광고 시간 체크"라는 단순한 기능 하나를 추가하는데도, 어디에 넣을지부터 논의해야 했습니다. 구조가 불명확하니 의사결정이 느려지고, 결과적으로 개발자는 매번 시스템 전체를 이해해야 하는 부담을 안게 되었습니다.
개발자가 이해해야 했던 것들:
• Thread 관리: 어떻게 생성/해제되는가?
• 큐 처리: 어디서 어떻게 Poll 하는가?
• 동시성 제어: Lock은? 경합은?
2. 구조적 불명확성:
• 이 기능은 어디에 넣어야 하지?
• Push를 수정하면 Email에 영향은?
• 왜 채널마다 구현이 다르지?
3. 도메인 복잡성:
• 실패 통계는 어디서 수집?
• 광고 시간 체크는 누가?
새 채널을 추가하거나 공통 기능을 구현하려면 이 모든 것을 이해해야 했습니다. “Push는 어떻게 구현되어 있더라?”, “이 코드를 건드리면 다른 채널도 영향받지 않을까?”, “이 기능은 정말 여기에 넣는 게 맞나?“라는 불안감 속에서 코드를 파헤쳐야 했고, 수정 후에는 예상치 못한 곳에서 버그가 터졌습니다.
더 큰 문제는 팀 차원의 비용이었습니다. 구조가 불명확하니 매번 논의가 필요했고, 결정 후에도 “정말 이게 맞나?“라는 의구심이 남았습니다. 이런 심리적 부담은 개발 속도를 늦추고, 더 나은 방법을 시도해보는 것조차 주저하게 만들었습니다.
Trade-off를 넘어서
일반적으로 성능과 단순성은 Trade-off 관계입니다.
일반적인 경우: 성능 ↑ → 복잡도 ↑ (Trade-off)
레거시의 상황: 성능 ↓ + 복잡도 ↑ (둘 다 나쁨)
레거시 발송기는 초기 요구사항에는 충분했지만, 시간이 지나며 두 가지 한계가 동시에 드러났습니다. 신규 발송기의 목표는 이 Trade-off를 극복하는 것이었습니다:
- 성능 최적화 - 내부 메커니즘 개선으로 10,000 TPS 이상 처리 및 낮은 지연시간 달성
- 구조 개선 - Service/Core Layer 분리로 멘탈 모델 단순화 및 빠른 확장
결국 신규 발송기를 설계하며 한 가지 원칙을 세웠습니다:
“복잡한 성능 최적화는 시스템 내부(Core Layer)에 숨기고, 개발자(Service Layer)에게는 단순한 인터페이스만 제공하자”
이 원칙을 바탕으로 아키텍처를 설계하기 시작했습니다.
Part 1: Service Layer - 개발자가 보는 단순한 인터페이스
검증된 패턴: 복잡성을 숨기는 프레임워크들
“복잡성을 숨기고 단순한 인터페이스를 제공한다"는 패턴은 사실 많은 프레임워크에서 이미 활용하고 있는 검증된 설계 방식입니다.
• Spring Framework: @Transactional 어노테이션 하나로 복잡한 트랜잭션 관리, 롤백, 커넥션 풀링 등을 숨깁니다. 개발자는 비즈니스 로직만 작성하면 됩니다.
• JPA/Hibernate: 복잡한 SQL 생성, 영속성 컨텍스트, 캐싱 등을 내부에 숨기고, repository.save(entity)라는 단순한 API를 제공합니다.
• Kafka Streams: 복잡한 스트림 처리, 상태 저장, 재처리 로직을 숨기고, stream.filter().map().groupBy()라는 선언적 API를 제공합니다.
• React/Vue: Virtual DOM의 복잡한 Diffing 알고리즘을 숨기고, 개발자는 "상태가 바뀌면 화면도 바뀐다"는 단순한 멘탈 모델만 가지면 됩니다.
이들의 공통점은 선언적(Declarative) API입니다. 개발자는 “어떻게(How)” 작동하는지가 아니라 “무엇을(What)” 할 것인지에만 집중합니다. @Transactional은 “이 메서드는 트랜잭션으로 묶인다"는 의도만 선언하면, 커넥션 관리나 롤백 같은 구현은 프레임워크가 담당합니다.
신규 발송기도 동일한 철학을 따랐습니다. 복잡한 성능 최적화는 내부에 숨기고, 개발자에게는 단순한 인터페이스만 제공하는 것입니다.
설계 원칙: 두 개의 레이어
신규 발송기의 핵심 아이디어는 시스템을 두 개의 명확한 레이어로 분리하는 것입니다. 이러한 구조를 통해 다음을 달성하고자 했습니다.
• Core Layer: 복잡한 성능 최적화를 완전히 캡슐화하여 개발자로부터 숨김
• 명확한 경계: 두 레이어 사이의 인터페이스를 최소화하고 명확하게 정의
Service Layer: 개발자가 보는 세상
Service Layer에서 개발자가 구현해야 하는 것은 두 가지입니다.
- RequestHandler: 채널별 비즈니스 로직
- Sender: 외부 API 호출 로직
먼저 RequestHandler 인터페이스를 살펴보겠습니다.
interface RequestHandler<T> {
suspend fun availableHandle(req: T): Boolean // 내가 처리할 수 있는 요청인가?
suspend fun handle(queue: WorkQueue<T>) // 요청 처리하기
fun alias(): String // 핸들러 식별자
}
메서드가 딱 3개입니다. 개발자는 이 3개 메서드만 구현하면 되고, 그 외의 복잡한 것들은 신경 쓸 필요가 없습니다.
Push 채널 구현 예시:
@Component
class PushRequestHandler(
private val messageRenderer: MessageRenderer,
private val sender: PushSender,
private val resultProcessor: ResultProcessor
) : RequestHandler<RequestDto> {
override suspend fun availableHandle(req: RequestDto) =
req.channel == Channel.PUSH
override suspend fun handle(queue: WorkQueue<RequestDto>) {
while (queue.poll()?.let { request ->
// 1. 메시지 조립
val message = messageRenderer.render(request)
// 2. 전송
val response = sender.send(message)
// 3. 결과 처리
resultProcessor.process(request, response)
} != null) { }
}
override fun alias() = "push"
}
여기서 중요한 점은 개발자가 알아야 할 것과 몰라도 되는 것이 명확하게 구분되어 있다는 것입니다.
• 내 채널의 비즈니스 로직: 조립 → 전송 → 결과 처리
• queue.poll()로 요청을 하나씩 가져온다
• Handler는 상태를 가지지 않음(Stateless): 멤버 변수로 요청 데이터를 저장하거나, 이전 요청의 결과를 기억할 필요가 없습니다. 각 요청은 독립적으로 처리됩니다.
개발자가 몰라도 되는 것:
• 동시성 처리 및 성능 최적화
Sender 인터페이스도 마찬가지로 단순합니다.
interface Sender<Request, Response> {
suspend fun send(request: Request): Response
}
// Push Sender 구현 예시
@Component
class KakaoPushSender : Sender<PushRequest, PushResponse> {
override suspend fun send(request: PushRequest) =
webClient.post()
.uri("/api/send")
.bodyValue(request)
.awaitBody<PushResponse>()
}
Sender는 “외부 API 호출"만 담당합니다. 재시도나 타임아웃 같은 복잡한 로직은 Core Layer에서 처리됩니다.
계층 분리의 효과
Service Layer와 Core Layer를 명확하게 분리하면 다음과 같은 효과를 얻을 수 있습니다.
1. 개발자는 비즈니스 로직에만 집중
Handler 개발자는 동시성 처리 및 발송 건 분배가 어떻게 구현되었는지 알 필요가 없습니다. queue.poll()로 요청을 받아서 처리하면 됩니다.
2. 독립적인 변경
Sender를 A 벤더에서 B 벤더로 교체해도 Handler 코드는 수정할 필요가 없습니다. 인터페이스만 동일하면 내부 구현은 자유롭게 바꿀 수 있습니다.
3. 내부 최적화의 자유
Core Layer는 Service Layer에 영향을 주지 않고 자유롭게 최적화할 수 있습니다. 실제로 동시성 처리 및 큐 분배 메커니즘을 여러 번 크게 변경했지만, Handler 코드는 단 한 줄도 수정하지 않았습니다.
Core Layer: 성능 최적화가 숨겨진 곳
Service Layer가 개발자에게 단순함을 제공한다면, Core Layer는 그 단순함을 가능하게 만드는 복잡한 최적화가 숨겨진 곳입니다.
하지만 Service Layer의 개발자는 이런 것들을 전혀 알 필요가 없습니다. queue.poll()만 호출하면 됩니다. 어디선가 발송 요청 건들을 적절하게 분배해준다는 것만 알면 충분합니다.
Core Layer의 구체적인 구현에 있어 어떻게 10,000 TPS를 달성했는지, 어떻게 Tail Latency를 해결했는지는 Part 2에서 자세히 살펴보겠습니다.
Part 2: Core Layer - 개발자가 몰라도 되는 성능 최적화
Part 1에서 Handler가 queue.poll()로 요청을 가져온다고 했습니다. 개발자는 이 queue가 어떻게 구현되었는지 몰라도 됩니다. 하지만 이 queue의 실제 구현체는 단순한 Queue가 아닌 WorkStealQueue입니다.
왜 일반적인 Queue를 쓰지 않고 WorkStealQueue를 만들었을까요? 핵심은 순차와 병렬을 섞는 것입니다.
순차 vs 병렬: 둘 다 필요하다
Part 1에서 Handler 개발자는 단순히 queue.poll()만 호출한다고 했습니다. 이제부터는 그 뒤에 숨겨진 복잡한 메커니즘을 살펴보겠습니다.
알림 시스템은 대부분의 요청을 병렬로 처리하면 성능이 좋습니다. 하지만 일부 상황에서는 순차 처리가 필요할 수 있습니다.
예를 들어:
• 순차 처리 (미래 요구사항): 동일 사용자의 연속된 요청 → 순서 보장 필요
현재는 모든 요청을 병렬로 처리하지만, 나중에 순차 처리가 필요한 케이스가 생길 수 있습니다. 그래서 확장 가능한 구조를 만들었습니다.
핵심 아이디어:
- Queue를 적절한 기준으로 분리 → 각 Queue 내에서는 순차 처리
- 분리된 Queue들을 병렬로 스케줄링 → 전체적으로는 병렬 처리
Queue를 어떻게 분리하는가
현재 구현: 채널 기준 분리
Email Queue: [요청1] [요청2]
SMS Queue: [요청1] [요청2] [요청3] [요청4]
각 채널마다 별도의 Queue를 가지므로, Push Handler는 Push Queue에서만 poll()하고, Email Handler는 Email Queue에서만 poll()합니다.
확장 가능성
구조상 분리 기준을 변경하는 것은 어렵지 않습니다. 예를 들어:
• 우선순위별로 Queue 분리 → 중요한 알림 먼저 처리
이런 요구사항이 생기더라도 Handler 코드는 변경할 필요가 없습니다. Queue 분배 로직만 수정하면 됩니다.
분리된 Queue를 어떻게 병렬로 스케줄링하는가
여러 개의 Queue가 생긴 상황에서 문제가 되는 것은 부하가 불균형할 때입니다.
상황: Push Queue에 느린 작업이 몰림
Worker 1 (Push 처리 중): [느린 작업 2000ms] [빠른 작업 100ms] [빠른 작업 100ms]
↑ 이 작업들은 2000ms를 기다려야 함
Worker 2 (Email 처리 중): [유휴 상태]
Worker 3 (SMS 처리 중): [유휴 상태]
결과적으로 Worker 2와 3은 유휴 상태로 남아 CPU 리소스가 낭비되었고, Push Queue 뒤쪽에 있는 작업들은 불필요하게 대기하면서 Tail Latency가 증가했습니다. 실제 측정 결과, 레거시 발송기에서는 P99 Latency의 80%가 이러한 대기 시간에서 발생했습니다.
해결책: Work-Stealing
Work-Stealing은 병렬 처리에서 널리 사용되는 부하 분산 기법입니다. Java의 Fork/Join Framework나 Go의 Goroutine Scheduler에서도 사용합니다.
기본 아이디어:
- 각 Queue는 독립적으로 존재
- 유휴 상태인 Worker가 바쁜 Queue의 작업을 가져감 (steal)
예시:
Push Queue: [Task A] [Task B] [Task C] [Task D]
↑ Worker 2가 여기서 steal
Worker 2 (유휴): → [Task D] 가져와서 처리
알림 시스템의 특수성: 자원 중심 vs 데이터 중심
흔히 고성능 스케줄러를 설계할 때 Work-Stealing 기법을 떠올리곤 합니다. 하지만 알림 시스템에서는 다른 접근이 필요합니다. Work-Stealing이 노는 일꾼(Worker)이 없게 만드는 자원 중심 알고리즘이라면, 메시징 시스템은 오래 기다리는 메시지가 없게 만드는 데이터 중심 알고리즘이어야 하기 때문입니다. 효율성보다 중요한 것은 결국 메시지가 제때 도착하는 ‘신뢰성’입니다.
문제 상황:
Worker 1: [느린 작업 2000ms] [빠른 작업 100ms] [빠른 작업 100ms]
↑ 이 작업들은 2000ms를 기다려야 함
Worker 2: [유휴 상태]
Worker 3: [유휴 상태]
결과:
- 빠른 작업들이 불필요하게 지연됨 (Tail Latency 증가)
- Worker 2, 3은 놀고 있음
- 인증번호 SMS, 출금 알림 등 빠른 응답이 필요한 메시지도 함께 지연
이런 상황을 방지하기 위해, 신규 발송기는 오래 대기 중인 큐를 찾아서 자동으로 분산시킵니다.
Age-Based Work-Stealing:
상황: Worker 1에 느린 작업이 들어옴
시작 (시각: 0ms):
Worker 1: [느린 작업 2000ms] [빠른 작업 100ms] [빠른 작업 100ms]
큐 생성 시각: 0ms
Worker 2: [다른 작업 처리 중...]
Worker 3: [다른 작업 처리 중...]
시각: 500ms (중앙 관리자가 100ms마다 스캔 중)
스캔 결과:
"Worker 1의 큐가 500ms 이상 대기 중이네? (Age = 500ms)"
"Worker 2가 유휴 상태가 되었네?"
→ Worker 1 큐를 반으로 나눠서 Worker 2에게 할당
Work-Stealing 후:
Worker 1: [느린 작업 2000ms] [빠른 작업 100ms]
큐 Age: 500ms (원래 생성 시각 유지)
Worker 2: [빠른 작업 100ms]
큐 Age: 500ms (원래 생성 시각 유지)
결과:
- Worker 1: 2100ms에 완료
- Worker 2: 600ms에 완료 (Age 500ms + 처리 100ms)
- 두 번째 빠른 작업이 2000ms 기다리지 않고 600ms에 처리됨!
신규 발송기는 중앙 관리자가 100ms마다 모든 큐를 스캔하며, 큐 생성 시각을 추적하여 Age가 500ms를 초과한 큐를 발견하면 자동으로 분산시킵니다. 이를 통해 느린 작업 뒤의 빠른 작업들이 불필요하게 지연되는 것을 방지합니다.
Work-Stealing 구현의 핵심 아이디어
Service Layer의 개발자는 이런 구현 세부사항을 전혀 알 필요가 없습니다. 하지만 플랫폼 개발자는 이런 복잡성을 다뤄야 합니다. Work-Stealing을 구현하는 방법은 여러 가지가 있습니다. 가장 간단한 방법은 Lock을 사용하는 것이지만, 고처리량 시스템에서는 Lock 획득/해제 오버헤드가 누적됩니다. 따라서 신규 발송기는 Lock-Free 방식을 선택했습니다.
Age Tracking: 오래 기다린 메시지부터 처리
일반적인 Work-Stealing은 “유휴 Worker가 있으면 steal"하지만, 알림 시스템에서는 오래 기다린 메시지를 우선 처리하는 것이 더 중요합니다. 신규 발송기는 중앙 관리자가 100ms마다 모든 큐를 스캔하며, 큐 생성 시각을 추적하여 Age가 500ms를 초과한 큐를 발견하면 자동으로 다른 Worker에게 분산시킵니다. 이를 통해 느린 작업 뒤의 빠른 작업들이 불필요하게 지연되는 것을 방지합니다.
Lock-Free + Zero-Copy
큐를 안전하게 나누는 것이 Work-Stealing의 핵심 과제입니다. 가장 간단한 방법은 Lock을 거는 것이지만, 초당 10,000번 이상의 steal이 발생하는 환경에서는 Lock 획득/해제 오버헤드가 누적됩니다. 신규 발송기는 CAS(Compare-And-Set)와 Barrier를 조합했습니다. CAS로 큐 분할 시점을 원자적으로 결정하고, Barrier로 명확한 경계를 표시합니다. 이렇게 하면 Lock 없이도 안전하게 큐를 나눌 수 있습니다.
더 중요한 것은 Zero-Copy입니다. 큐를 나눌 때 배열을 복사하지 않고 경계(Barrier)만 표시하면, 인덱스 조정만으로 큐 분할이 완료됩니다. 초당 200번의 steal이 발생하더라도 새로운 배열을 생성하지 않으므로 메모리 할당이 최소화되고 GC 압박이 감소합니다.
Service Layer의 개발자는 이런 구현을 알 필요가 없습니다. 하지만 플랫폼 개발자 관점에서, 핵심 아이디어를 의사코드로 표현하면 다음과 같습니다.
class WorkStealQueue {
// AtomicReferenceArray: 개별 요소의 visibility 보장
private val tasks = AtomicReferenceArray<Task>(1024)
private val head = AtomicInteger(0)
private val tail = AtomicInteger(0)
private val barrier = AtomicInteger(-1) // -1: steal 가능, 양수: steal 중
fun poll(): Task? {
val h = head.get()
val b = barrier.get()
val limit = if (b >= 0) b else tail.get()
if (h >= limit) return null
return tasks.get(h % 1024).also {
tasks.set(h % 1024, null) // GC 최적화: 즉시 해제
head.incrementAndGet()
}
}
fun steal(): List<Task>? {
val h = head.get()
val t = tail.get()
if (t - h < 2) return null // 작업이 너무 적음
val mid = (h + t) / 2
// CAS로 barrier 설정 - 다른 Worker와 경합 방지
if (!barrier.compareAndSet(-1, mid)) return null
// Zero-Copy: 배열 복사 없이 인덱스로 분할
val stolen = (mid until t).mapNotNull { idx ->
tasks.getAndSet(idx % 1024, null)
}
tail.set(mid) // 원본 큐는 mid까지만
barrier.set(-1) // steal 완료
return stolen
}
}
이런 복잡한 동시성 처리가 Core Layer에 숨겨져 있기 때문에, Handler 개발자는 단순히 queue.poll()만 호출하면 됩니다.
GC(Garbage Collection) 최적화
Lock-Free만으로는 충분하지 않습니다. 하루 N억 건을 처리하는 시스템에서는 얼마나 자주 GC가 발생하는가가 성능을 좌우합니다. 레거시 발송기는 노드들이 힙에 분산되어 있어 CPU 캐시 효율이 낮았고, steal 시마다 새 리스트를 생성하여 GC 압박이 컸습니다. 실제로 초당 200번의 steal이 발생할 경우 분당 10,000개 이상의 객체가 생성되었습니다.
신규 발송기는 고정 크기 Array를 선택했습니다. 요소들이 메모리에 연속 배치되어 CPU 캐시 효율이 극대화되고, steal 시에도 새 객체를 생성하지 않고 인덱스만 조정합니다. 또한 처리 완료된 요청은 즉시 null로 설정하여 GC가 빠르게 회수할 수 있도록 했습니다. 이렇게 하지 않으면 이미 처리된 객체가 Old Generation으로 승격되어 Full GC를 유발할 수 있습니다.
시스템 전반의 최적화
Core Layer의 Work-Stealing과 GC 최적화가 핵심이지만, 다른 레이어에서도 세부 최적화를 적용했습니다.
Inline Class
하루 N억 건을 처리하다 보면 작은 wrapper 객체도 부담이 됩니다. Kotlin의 inline class를 사용하면 런타임에 wrapper 없이 primitive 타입처럼 동작하여 객체 생성 오버헤드를 제거할 수 있습니다.
템플릿 캐싱 (Renderer)
메시지 조립 시 템플릿 본문을 매번 파싱하면 비효율적입니다. 템플릿을 전처리하여 변수 위치를 미리 계산해두고, 조립 시점에는 해당 위치에 값만 채워넣습니다. 이렇게 하면 복잡도가 O(M×N)에서 O(N)으로 개선됩니다 (M: 치환 변수 개수, N: 템플릿 본문 길이).
레거시: 매번 템플릿 파싱 → 변수 찾기 → 치환 (O(M×N))
신규: 전처리로 변수 위치 캐싱 → 조립 시 값만 채움 (O(N))
이런 최적화들은 개발자가 직접 신경 쓸 필요가 없습니다. Core Layer에서 자동으로 처리되며, Service Layer의 Handler는 단순히 비즈니스 로직만 작성하면 됩니다.
마치며
레거시 발송기는 두 가지 문제를 동시에 안고 있었습니다. 성능 병목과 복잡한 멘탈 모델. 일반적으로 이 둘은 Trade-off 관계입니다. 성능을 높이면 코드가 복잡해지고, 코드를 단순하게 만들면 성능이 떨어집니다.
하지만 신규 발송기는 이 Trade-off를 극복했습니다. Service/Core Layer 분리라는 명확한 구조를 통해서입니다.
- ﹒Service Layer: 개발자는 3개 메서드만 구현하면 됩니다. 동시성 처리나 Work-Stealing 같은 복잡한 것들은 알 필요가 없습니다.
- ﹒Core Layer: Lock-Free, Work-Stealing, GC 최적화 같은 복잡한 성능 최적화는 모두 여기에 숨겨져 있습니다.
결과적으로 아래와 같은 성과를 얻을 수 있었습니다.
• 구조: 새로운 채널 추가는 단기간에 완료
• 멘탈 모델: 개발자는 비즈니스 로직에만 집중
플랫폼 개발자의 책임
돌이켜보면, 이 프로젝트의 핵심은 기술이 아니라 구조였습니다. Spring Framework가 Controller/Service/Repository 계층을 제공함으로써 수많은 개발자의 의사결정 속도를 빠르게 만드는 것처럼, 플랫폼 개발자의 책임은 좋은 구조를 만들어 모든 개발자의 속도를 빠르게 만드는 것입니다. 복잡한 성능 최적화를 다루는 것도 중요하지만, 그 복잡성을 적절히 숨기고 단순한 인터페이스를 제공하는 것이 더 중요합니다. 그래야 팀 전체가 빠르게 움직일 수 있습니다.
“개발자의 멘탈 모델을 단순하게 유지하라”
글의 도입부에서 언급한 이 문장이 바로 이 글의 핵심 메시지입니다.
다음 편 예고
이번 글에서는 신규 발송기의 플랫폼 구조를 다뤘습니다. Service/Core Layer 분리를 통해 개발자의 멘탈 모델을 단순하게 유지하면서도 높은 성능을 달성하는 방법을 살펴봤습니다. 하지만 이것은 시작에 불과합니다. 좋은 플랫폼을 구축했다면, 이제 그 위에서 실제 채널을 구현해야 합니다.
다음 편에서는 카카오뱅크의 핵심 발송 채널 중 하나인 이메일 발송을 다룹니다. Service Layer의 단순한 인터페이스 위에서, 실제로 어떻게 이메일 채널을 구현했는지, 그 과정에서 마주한 기술적 도전과 해결 방법을 공유하겠습니다.