MEASURED, NOT ASSUMED
문제를 정의하고 서비스로 증명해왔습니다
정답이 하나가 아닌 자리에서 무엇을 고르고 무엇을 버렸는지, 그 근거를 직접 잰 숫자와 함께 적습니다. 글에 나오는 코드는 실제 저장소의 코드입니다.
글
62편엑셀 한 장으로 영상 217편을 인코딩에 태웁니다 (스트리밍 반입과 조건부 UPDATE 선점)
137MB 원본을 힙에 담지 않고 임시 파일로 흘려보낸 이유, 파이프라인을 다섯 구간으로 자른 방식, 다중 인스턴스에서 ShedLock 없이 행 단위 조건부 UPDATE 로 선점한 근거, 그리고 Full Jitter 백오프를 직접 짠 이유를 코드와 E2E 로그로 정리했습니다. 접수에서 편성까지 2분 41초가 실측입니다.
영상은 올린 그대로 재생하지 않습니다 (OCI Media Flow 로 인코딩 파이프라인 세우기)
mp4 하나를 올리면 왜 파일이 수백 개로 쪼개져 나오는지, 코덱과 컨테이너·적응형 비트레이트·HLS·세그먼트와 키프레임까지 인코딩의 원리를 훑고, 그걸 관리형 워크플로 한 방으로 처리하는 OCI Media Flow 가 어떤 추상인지 정리했습니다. 217편 강의 영상 파이프라인을 세우며 내린 실제 선택도 근거와 함께 적었어요.
같은 타입인데 인코딩이 조용히 바뀝니다 (Redis Object 와 내부 인코딩)
String, List, Set, Hash, Sorted Set 이 메모리 안에서 어떤 모습으로 담기는지, 그리고 왜 조용히 다른 구조로 갈아타는지 봤습니다. 공유 정수 풀, embstr 44바이트의 유래, quicklist 파라미터, 되돌아오지 않는 승격까지 소스 레벨로 정리했어요.
단일 인스턴스에서 세 번 갈아탑니다 (복제, Sentinel, Cluster)
Redis 를 하나로 띄우면 그 하나가 죽을 때 전부 멈춥니다. 읽기를 나누는 복제, 자동 승격을 맡는 Sentinel, 데이터를 쪼개는 Cluster 까지, 세 계층이 각각 무엇을 푸는지, 그리고 어디서 조용히 데이터를 잃는지 정리했어요.
메모리는 껐다 켜면 사라집니다 (RDB, AOF, 그리고 하이브리드)
Redis 영속화는 스냅샷과 명령 로그 중 무엇을 고르느냐로 끝나지 않습니다. fork 가 서버를 멈추는 순간, everysec 가 디스크를 기다리는 구간, 저장 실패가 쓰기를 막는 기본값까지, 영속화가 직렬 구간과 만나는 지점을 정리했어요.
발행하고 잊습니다 (Redis Pub/Sub 과 Fire-and-Forget)
발행자와 구독자가 서로를 모른 채 채널로 연결됩니다. 편하지만 메시지를 저장하지 않아서, 발행 순간에 듣고 있지 않으면 그 메시지는 사라져요. 구독 커넥션의 제약, PUBLISH 의 O(N+M) 비용, 그리고 느린 구독자가 조용히 끊기는 진짜 유실 지점까지 파고들었습니다.
구독자가 없어도 메시지를 남깁니다 (Redis Stream 과 Consumer Group)
Pub/Sub 은 발행하고 잊지만, Stream 은 로그처럼 메시지를 남기고 소비자가 어디까지 처리했는지 추적합니다. rax 로 쌓이는 내부 구조부터 PEL 의 이중 장부, XAUTOCLAIM 회수, at-least-once 가 정확히 어디서 만들어지는지까지, Redis 안의 작은 메시지 큐를 깊게 들여다봤어요.
묶어서 실행하지만 되돌리지는 않습니다 (MULTI, EXEC, WATCH)
Redis 트랜잭션은 여러 명령을 큐에 모아 순차로 실행합니다. 그런데 RDB 를 떠올리며 롤백을 기대하면 배신당해요. 롤백이 없는 이유, WATCH 가 어떤 순간에 트랜잭션을 더럽히는지, 그리고 조건 로직이 낀 원자성은 왜 Lua 로 넘어가는지 소스 동작까지 따라가 정리했습니다.
서버는 빠른데 왕복이 느립니다 (Pipelining 과 RTT)
Redis 명령 하나는 마이크로초인데 천 번을 부르면 느려집니다. 범인은 서버가 아니라 네트워크 왕복이에요. RTT 가 쌓이는 구조, TCP 의 Nagle 과 delayed ACK 가 얹는 지연, 파이프라이닝이 이벤트 루프에서 시스템 콜을 어떻게 접는지, 그리고 응답 버퍼가 메모리를 먹는 함정까지 정리했습니다.
캐시를 어디에 언제 쓰느냐가 다릅니다 (Cache-Aside 부터 Stampede 까지)
Redis 를 가장 많이 쓰는 자리가 캐시입니다. 캐시와 DB 를 어떤 순서로 건드리느냐가 정합성을 가르고, 무효화 순서에는 낡은 값이 굳는 경합이 숨어 있어요. 읽기와 쓰기 전략, 무효화 경합, 그리고 캐시가 무너지는 세 가지 방식과 그 대응을 정리했습니다.
GC가 도는 동안 스레드는 무엇을 하는가 (Minor와 Major, safepoint, 그리고 배포 직후의 cold)
대시보드에 GC Pause 패널을 만들어 놓고 그 그래프가 튈 때 무슨 일이 벌어지는지 설명하지 못했습니다. 스레드가 멈추는 순간을 직접 재보니 제 Dockerfile이 JVM에게 아무것도 알려주지 않고 있다는 것도 같이 드러났어요.
도메인 폴더는 있는데 도메인이 없었습니다 (DDD 로 아주이벤트 경계 다시 긋기)
카카오페이 여신코어 DDD 글을 읽고 제 저장소를 세어봤습니다. 엔티티 19개 중 9개가 다른 도메인 패키지를 직접 참조하고, is_read 라는 같은 컬럼명이 세 곳에서 다른 뜻이었어요. DDD 개념을 Java 코드로 정리하고 아주이벤트를 컨텍스트 다섯 개로 다시 나눠봅니다.
포트라고 이름 붙였는데 포트가 아니었습니다 (헥사고날 정의와 두 저장소 비교)
아주이벤트의 포트 17개 중 15개가 인터페이스가 아니라 어댑터를 주입받는 구체 클래스였습니다. 아올다 클라우드는 포트 59개가 모두 인터페이스예요. Cockburn 의 원래 정의로 돌아가 primary 와 secondary 포트의 방향을 정리하고, 두 저장소가 어디서 갈렸는지 세어봤습니다.
결제 DB 다섯 가지 원칙에 제 코드를 대봤습니다 (셋은 맞고 둘은 아니었다)
금액은 DECIMAL, 상태 전이는 DB 제약, 중복은 멱등키, 불일치는 대사 배치, 결제는 삭제 불가. 실제 결제 코드에 하나씩 대보니 CHECK 제약이 0개였고 대사 배치도 없었습니다. double 누적 오차는 직접 재봤어요.
왕복이 맞아떨어져서 아무도 못 본 9시간 (JVM UTC vs 커넥션 KST)
JVM은 UTC인데 JDBC 커넥션만 Asia/Seoul이었습니다. 쓸 때 +9, 읽을 때 -9라 애플리케이션 안에서는 증상이 없었어요. 임시 테이블로 물리값을 직접 확인하고 126개 컬럼을 옮기기까지.
메모리에 두고, 커널에 한 번만 묻는다 (In-Memory 와 I/O 멀티플렉싱)
Redis가 빠른 이유를 두 층에서 봤습니다. 하나는 데이터가 사는 층이고, 하나는 커널에게 소켓을 묻는 방식이에요. 블로킹 I/O부터 select, poll, epoll, kqueue까지 그림으로 정리했습니다.
RabbitMQ는 메시지를 어디에 쌓는가 (큐 프로세스, 크레딧 흐름, prefetch)
prefetch를 3으로 잡은 이유를 설명하지 못했습니다. 브로커 안에서 큐가 Erlang 프로세스 하나라는 사실부터 크레딧 기반 흐름 제어까지, 설정값의 근거가 되는 내부 구조를 정리했어요.
Redis 분산 락은 어디서 깨지는가 (Sentinel 페일오버와 Fencing Token의 한계)
SET NX PX와 Lua 해제까지는 정석입니다. 문제는 그 다음이에요. Sentinel 페일오버에서 락이 사라지고, 그걸 막으려고 넣은 Fencing Token은 같은 Redis에서 발급되는 순간 같은 방식으로 깨집니다.
싱글 스레드인데 왜 빠른가 (Redis 이벤트 루프, io-threads, 그리고 멈추는 순간)
Redis가 빠른 이유는 싱글 스레드라서가 아니라 병목이 CPU가 아니기 때문입니다. 이벤트 루프 한 바퀴를 따라가면서 io-threads가 무엇을 나누고 무엇을 나누지 않는지, 그리고 제가 쓴 Lua가 서버를 얼마나 붙잡는지 정리했어요.
InnoDB는 어떻게 생겼는가 (클러스터드 인덱스, 버퍼 풀, redo와 undo, 그리고 락)
PK를 UUID로 잡으면 왜 나쁜지, 긴 트랜잭션이 왜 서버 전체를 느리게 만드는지, 인덱스가 없으면 왜 테이블이 통째로 잠기는지. 세 질문의 답이 전부 InnoDB의 같은 구조에서 나옵니다.
외부 API 한 번 부르는 데 계층이 여섯 개입니다 (커넥션 풀부터 Circuit Breaker까지)
타임아웃, 커넥션 풀, Bulkhead, Retry, Circuit Breaker, Rate Limiter. 각 계층이 무엇을 막고 무엇을 못 막는지 정리하다가, 제가 실측으로 고른 중첩 순서와 Resilience4j 애노테이션의 기본 순서가 반대라는 걸 알았습니다.
Spring AOP는 왜 자기 호출에서 안 먹는가 (JDK 동적 프록시와 CGLIB의 차이)
@Transactional, @Async, @CircuitBreaker가 전부 프록시로 동작합니다. 프록시가 어떻게 만들어지고 무엇을 못 감싸는지 알면, 애노테이션이 조용히 사라지는 네 가지 상황이 전부 같은 원인이라는 게 보여요.
비동기 SDK를 쓰는데 서블릿 스레드가 묶였습니다 (Future.get 제거와 콜백 풀 분리)
Firebase SDK는 이미 비동기였습니다. 블로킹을 만든 건 제 코드의 Future.get() 한 줄이었어요. 그리고 그걸 지운 뒤에 두 번째 병목이 나왔습니다.
서버를 강제로 죽였더니 49,600건이 사라졌습니다 (Transactional Outbox와 6단계 상태 머신)
공지 저장은 트랜잭션으로 지키는데 발송은 못 지킵니다. 어디까지 보냈는지 알 수 없는 상태를 없애려고 상태를 테이블에 적기 시작했어요.
키워드 672개를 contains로 훑고 있습니다 (Aho-Corasick과 71배 차이, 그런데 아직 안 바꿨어요)
키워드 672개 기준으로 재보니 Aho-Corasick이 71배 빨랐습니다. 그런데 공지 한 건당으로 환산하면 0.04ms 였어요. 측정하고 나서 안 바꾸기로 한 이야기입니다.
알림을 끄려면 구독을 해지해야 했습니다 (상태 하나를 둘로 나눈 이야기)
"알림이 너무 많아요"라는 피드백에 대한 첫 답이 "구독을 취소하세요"였습니다. 그건 답이 아니었어요. 구독과 알림 수신은 애초에 다른 축이었습니다.
메일 하나 읽었을 뿐인데 전체가 재처리됐습니다 (큐를 이벤트 타입마다 나눈 이유)
Pub/Sub 푸시 하나에 Gmail 조회, DB 저장, 알림 전송을 다 넣었더니 하나가 실패하면 전부 다시 왔습니다. 이벤트 종류별로 큐를 여섯 개로 나눈 과정입니다.
브로커를 건너가면 로그가 남남이 됩니다 (RabbitMQ 헤더와 AOP로 MDC 잇기)
스레드 경계는 TaskDecorator로 넘겼는데 프로세스 경계는 그게 안 됩니다. 메시지 헤더에 실어 보내고 Consumer에서 복원하면서, 아무 키나 실으면 안 된다는 것도 같이 알았어요.
공지를 전부 LLM에 넣지 않기로 했습니다 (규칙 스코어링 1차 필터와 캐시 산수)
배너 고를 공지를 AI에게 맡기려다 비용 구조부터 계산했습니다. Prompt Caching 최소 프리픽스와 모델별 캐시 때문에 Haiku 우선 전략이 절반쯤 어긋났어요.
429를 맞고 나서야 쿼터를 세기 시작했습니다 (Redis Lua 토큰 버킷과 1000배 스케일)
Gmail API는 호출 수가 아니라 연산별 가중치로 쿼터를 셉니다. 컨슈머 여러 개가 같은 계정을 동시에 두드리는 걸 막으려고 Redis Lua로 토큰 버킷을 짰어요.
규칙 한 줄 고칠 때마다 전체 메일을 다시 분류했습니다 (Debounce, Rate Limit, Stale Skip 세 겹)
저장 버튼을 다섯 번 누르면 전체 재분류가 다섯 번 돕니다. 막는 지점을 진입, 발행, 소비 셋으로 나눈 이야기와, 그러고도 남은 경쟁 하나.
구독은 7일 뒤에 조용히 끊깁니다 (Gmail Watch 갱신을 만료 하루 전부터 하는 이유)
Gmail의 watch는 최대 7일입니다. 만료되면 에러가 나는 게 아니라 아무 일도 안 일어나요. 만료 당일이 아니라 하루 전부터, 시간당 50개씩 나눠 갱신한 이야기입니다.
메일 2,000통을 한 번에 가져오지 않습니다 (초기 동기화를 5개씩 쪼갠 이유)
계정을 연결하면 스레드 수천 개를 받아와야 합니다. 배치 크기를 5로 잡은 이유와, 배치가 끝날 때마다 AI 기능을 조금씩 켜는 구조. 그리고 그 과정에서 찾은 상호작용 하나.
스트리밍 중간에 [PHO 까지만 온 토큰을 복원할 수는 없습니다 (SSE, 마스킹 복원, 스트림 취소)
AI 답장 초안을 스트리밍으로 내보내면서 마스킹 토큰을 되돌려야 했습니다. 토큰이 delta 경계에서 쪼개지는 문제와, 사용자가 창을 닫아도 LLM이 계속 도는 문제.
커넥션 풀이 없는 줄 알았는데 재사용률이 100%였습니다 (keep-alive 캐시와 풀의 차이)
"풀이 없어서 매번 TCP를 새로 연다"고 적었는데 재보니 아니었어요. JDK는 재사용을 합니다. 없는 건 재사용이 아니라 제한이었습니다.
결제는 됐는데 서버는 모릅니다 (클라이언트 완료 API에서 웹훅 확정으로)
결제 직후 브라우저를 닫으면 돈은 나가고 플랜은 그대로였습니다. 완료를 누가 선언하는지 바꾸면서 배운 것과, 멱등성을 지탱하는 제약이 놓인 자리.
임베딩은 되돌릴 수 없어서 마스킹부터 했습니다 (PII 두 겹 탐지와 복원 맵 없는 토큰)
메일 본문을 벡터로 만들려면 외부 모델에 원문을 보내야 합니다. 라이브러리 하나로는 한국 주민번호를 못 잡았고, 이메일과 이름은 일부러 안 가렸어요.
문서대로 DTO를 짰더니 전부 null이었습니다 (실제 응답을 캡처해서 맞추기)
OpenStack 응답에는 문서에 없는 한 겹이 더 있었습니다. 실제 응답을 파일로 떠서 DTO를 맞춘 과정과, 캡처하면서 신경 쓴 것들.
OpenStack에는 있는데 DB에는 없습니다 (보상 트랜잭션이 놓친 방향)
외부 리소스를 만들고 DB 저장에 실패하면 되돌려야 합니다. 그 보상이 안 도는 경우가 있고, 그걸 메우라고 만든 정합성 스케줄러는 정반대 방향만 봅니다.
12명이 병렬로 만든 서버에서 장애를 추적하려면 (AOP 자동 계측과 로그 표준화)
누가 만든 코드인지에 따라 로그 형식이 달랐습니다. 어노테이션을 붙이는 대신 레이어 전체를 포인트컷으로 잡았고, 그 대가로 민감값 마스킹이 문자열 검사에 걸렸어요.
재고를 확인하고 차감하면 늦습니다 (Lua 한 덩어리와 Redis가 앞서간 뒤의 문제)
GET으로 확인하고 DECR로 깎으면 그 사이에 다른 요청이 들어옵니다. Lua로 묶으면 초과 발급은 막히는데, Redis는 깎였고 DB 저장은 실패한 상태가 새로 생겨요.
예매 시작 1초에 다 들어오게 두지 않습니다 (ZSET 대기열과 입장 제어)
티켓 100장에 만 명이 몰리면 만 명이 다 DB까지 갑니다. 줄을 세우고 초당 몇 명씩만 통과시키는 구조와, popMin이 되돌릴 수 없다는 문제.
같은 키인데 다른 요청이면 어떻게 하죠 (Filter에서 막는 멱등키와 fingerprint)
중복 클릭을 컨트롤러 앞에서 끊으려고 Filter에 멱등키를 넣었습니다. 키만 보면 안 되고 요청 내용까지 봐야 했고, 그러려면 JSON을 정규화해야 했어요.
결제 멱등성을 네 겹으로 막았습니다 (결정적 멱등키부터 원장 UK까지)
앱과 스토어 웹훅이 같은 결제를 거의 동시에 밀어넣습니다. 중복 지급을 어디서 어떻게 끊었는지, 네 겹의 역할을 나눠 정리했어요.
결제에 웹훅이 왜 필요한가 (앱이 죽어도 포인트는 들어와야 한다)
앱→서버 충전 요청만으로는 결제가 유실됩니다. 스토어 웹훅을 두 번째 경로로 붙이면서 정리한 도착 순서 여섯 가지와, 웹훅이 대체재가 아닌 이유.
이거 MSA인가요? — 공통 지갑 서버와 서비스 서버의 경계 긋기
서비스 두 개가 하나의 공통 플랫폼 서버를 씁니다. 공유 DB에서 HTTP로 옮기며 무엇을 누가 소유할지 정한 기준과, 이 구조를 MSA라 부르기 어려운 이유.
LOT 단위로 포인트를 깎을 때 무엇을 먼저 태울 것인가
유료 5년, 무료 1년. 유효기간이 다른 포인트를 FIFO로만 깎으면 사용자가 손해를 봅니다. 만료 임박순 → 무료 우선 → FIFO로 정한 이유와 그 대가.
point_transaction 은 왜 이렇게 생겼나 (재화 도메인의 원장 설계)
잔액 컬럼 하나면 될 것 같았던 포인트가 테이블 다섯 개가 됐습니다. append-only 원장, lot 명세 분리, 스냅샷 컬럼이 각각 무엇을 막고 있는지.
메일 검색을 Elasticsearch 없이 PostgreSQL로 만들었습니다 (한국어 FTS와 역색인)
LIKE '%검색어%' 는 왜 느린가, 한국어는 왜 형태소 분석이 필요한가, GIN 역색인은 무엇을 저장하는가. mecab-ko 를 Postgres 에 심어 검색을 만든 과정.
어휘 검색과 벡터 검색을 RRF로 합쳤습니다 (하이브리드 메일 검색)
"작년에 계약 얘기 나눴던 메일"은 단어가 하나도 안 겹칩니다. 임베딩 검색을 붙이고, 스케일이 다른 두 점수를 순위만으로 합치는 RRF를 적용한 과정.
목록 응답 DTO 47개에 필드를 넣었는데 조회 코드는 115줄만 바뀌었습니다 (QueryDSL Projections.fields)
35만 줄짜리 관리자 API의 목록 응답 전체에 회원 필드를 일괄 추가했습니다. 조회 계층이 거의 흔들리지 않은 이유와, Projections.fields 가 조용히 넘기는 실수를 직접 실행해서 확인했어요.
Specification.where(null) 이 막혔습니다 (Spring Data JPA 3.5 에서 4.0 으로)
QueryDSL 은 null 조건을 조용히 버리는데 Specification 은 4.0 부터 거부합니다. NPE 가 날 거라고 생각했는데 아니었어요. 버전 네 개를 직접 돌려서 확인했습니다.
실제 사용자를 받는 서비스를 만들면서 배운 것 (회고)
가입자 466명, MAU 300명. 지표를 모으는 것과 읽는 것은 다른 일이었습니다.
하루 830건을 위해 분당 10만 건을 준비했습니다 (회고)
하루 830건을 보내면서 분당 10만 건을 준비했습니다. 과하게 만든 것보다 과하게 말한 것이 문제였어요.
측정하지 않은 것은 안다고 할 수 없습니다 (회고)
제가 오래 설명해온 내용이 측정 한 번에 뒤집혔습니다. 코드는 맞았고 설명이 틀렸어요.
아직 없는 행은 잠글 수 없습니다 (Lock 전용 테이블과 Unique 제약)
SELECT FOR UPDATE로는 신규 INSERT 경쟁을 막지 못합니다. 30번 시도에서 30번 모두 중복이 생겼어요.
@Transactional 안에서 외부 API를 호출하면 안 되는 이유 (커넥션 풀 실측)
트랜잭션 안에서 외부 API를 부르면 6.5배 느려지고, 부하가 조금 오르면 35%가 실패합니다.
비동기로 바꾸자 로그가 끊겼습니다 (MDC와 TaskDecorator)
비동기로 바꾸자 traceId가 200건 전부 끊겼습니다. 그리고 오염을 막는 장치가 제가 생각한 코드가 아니었어요.
PostgreSQL에서 UUID를 CHAR(36)으로 저장하고 있었습니다
PostgreSQL에서 UUID를 CHAR(36)으로 저장하면 공간은 1.74배, 조인은 1.76배 손해입니다.
Redis 조회수를 DB로 옮길 때 증가분이 사라지는 문제 (Lua 델타 차감)
Redis 조회수를 DB로 옮길 때 DEL을 쓰면 16.85%가 유실됐습니다. 읽은 만큼만 DECRBY 하면 0건이 됩니다.
Circuit Breaker와 Retry, 어느 쪽을 바깥에 둘 것인가 (Resilience4j 중첩 순서)
Resilience4j에서 CB와 Retry의 중첩 순서를 바꿔 실측했습니다. 재시도로 살아나는 오류 50건 중 32건이 차단기 때문에 실패했어요.
공지 목록 조회를 고치다가 네 번 갈아엎었습니다 (OSIV, BatchSize, Fetch Join, 페이지네이션)
OSIV를 끄자 예외가 터졌고, BatchSize와 Fetch Join을 거쳐 DTO 프로젝션까지 갔습니다. 쿼리 수는 유일한 지표가 아니었어요.