매주 한 편씩 쓰다 보니 10주가 지나 있었습니다

TL;DR — 10주 동안 매주 한 편씩 블로그를 썼습니다. 토요일 저녁마다 "정상이 어디에요?" 라고 스스로에게 물었고, 돌아오는 답은 언제나 "아 5분만 더 가면 돼" 였습니다. 그 5분이 열 번 쌓여 10주가 되었고, 저는 끝내 포기하지 않았습니다. 이 글은 그 열두 편의 흔적이 만든 열한 번째 저를 돌아보는 총괄 회고이자, 마지막 라운드(Spring Batch + Materialized View + 기간별 Ranking API) 에서 내린 결정을 기록한 인덱스입니다.
프롤로그 — 글을 쓰기 시작한 2주차의 저
처음부터 매주 블로그를 쓸 생각은 아니었습니다. 1주차에는 Ktlint·EditorConfig·Git Hook 같은 도구를 세팅하고, 팀 동료의 질문 ("Kotlin 에 ESLint 같은 거 있나요?") 에 답하면서 "개인의 의지에 기대지 말고 기술로 강제하자" 는 작은 규칙 하나를 세웠습니다. 같은 주에 TDD 도 처음 실전으로 만났는데, 그때 제 인상은 TDD 가 "테스트 먼저 쓰기" 라는 기법이 아니라 팀이 같은 요구사항을 같은 언어로 보게 하는 장치 라는 쪽이었습니다.
매주 한 편씩 쓰는 습관이 본격적으로 시작된 것은 2주차였습니다. 토요일 저녁 10시, "지루하고 현학적인 설계문서가 필요한 이유" 라는 제목의 글을 쓰다가 제 머릿속에서 질문이 하나 떠올랐습니다.

"정상이 어디에요? 아직도 한참 남았는데…"
"아 5분만 더 가면 돼 ㅋㅋㅋ"
그 5분이 30분이 되고, 30분이 두 시간이 되었지만, 어쨌든 그 밤에 한 페이지짜리 정리 글이 완성됐습니다. 요구사항 → 시퀀스 다이어그램 → 도메인 모델 → ERD 로 내려가는 설계 순서가 테이블부터 짜는 것보다 왜 더 견고한지, 제 언어로 정리한 첫 글이었습니다.
쓰고 나니 그전까지 희미하던 것들이 조금 선명해져 있었습니다. 그 감각이 좋아서 그 다음 주부터도 계속 썼죠. 10주가 지났고 11편이 쌓였습니다. 이 글은 그 11편을 시간순으로 되짚으면서 각 주차에서 무엇을 배웠고, 그 배움들이 어떻게 마지막 라운드의 Spring Batch · Materialized View · 기간별 Ranking API 결정으로 이어졌는지 정리한 총괄 회고입니다.
주차별 배운 점 — 11편의 블로그가 남긴 것
Week 1 — Ktlint가 선명하게 코드를 핥고 있었다 + [WIL - TDD] 대리님 ~ 레드 페이즈에서…

다룬 주제: 개발 환경 자동화, TDD 의 진짜 용도
프로젝트 시작과 동시에 두 편을 썼습니다. Ktlint 쪽은 "팀원이 '여기 ESLint 같은 거 없나요?' 라고 물은 순간, 우리가 얼마나 개인 의지에 기대어 스타일을 맞추고 있었는지" 를 깨달은 기록이었고, TDD 쪽은 결제 배포 실패 사례를 되짚으며 의사결정표 같은 테스트 케이스 설계가 곧 요구사항 문서 라는 관점을 정리한 글이었습니다.
제가 얻은 감각: 코드 품질도, 테스트도 개인의 선의에 맡기면 깨진다. 도구가 자동으로 품질을 보장해야 사람이 본질적인 설계 논의에 집중할 수 있고, 테스트는 "통과/실패 여부" 가 아니라 팀 전체가 같은 그림을 보게 만드는 장치 라는 감각.
Week 2 — 지루하고 현학적인 설계문서가 필요한 이유

다룬 주제: 설계 문서의 가치, 합의의 구조
급할수록 설계 문서를 건너뛰려는 유혹이 커지지만, 그럴수록 작업 범위·검수 기준·용어 해석에서 소통 비용이 폭증한다는 경험을 썼습니다. 슬랙 메시지 100건보다 다이어그램 한 장 + 10분 대화가 더 효율적이라는 것, 그리고 요구사항 → 시퀀스 → 도메인 → ERD 순서로 내려가야 테이블이 견고해진다는 판단을 정리했습니다.
제가 얻은 감각: 이 글이 이후 10주의 "판단을 글로 남기는 습관" 의 원점이었습니다. 이 감각이 없었다면 10주차의 구현 계획서(§1 현황 분석 · §1.1 대안 비교 · §Phase 1.0 기존 인프라 활용) 같은 구조는 나올 수 없었을 겁니다.
Week 3 — SQL에 월세 내던 비즈니스 규칙, 도메인으로 이사했습니다
SQL에 월세 내던 비즈니스 규칙, 도메인으로 이사했습니다
부제: 오늘, 비즈니스 규칙이 SQL에서 탈출한 비율이 0.03퍼센트 늘었습니다"오늘 전 세계 극심한 빈곤 상태에 있는 사람의 수가 0.01퍼센트 줄었습니다!"이 문장을 뉴스 속보로 내보내는 방송국은
your-friendly-neighborhood.com
다룬 주제: 도메인 모델링, 비즈니스 규칙의 위치
과거 업무에서 겪었던 할인 계산 버그를 되짚으며, 비즈니스 규칙이 SQL 안에 흩어져 있던 구조의 한계를 풀어냈습니다. SQL 에 규칙이 있으면 코드를 읽는 것만으로는 의미를 알 수 없고, 테스트도 어렵고, 변경 비용이 누적됩니다. 그 규칙을 도메인 레이어로 옮긴 경험이 도메인 모델링의 첫 실전 수업이었습니다.
제가 얻은 감각: 데이터만 들고 있는 엔티티(Anemic Domain Model) 는 장기적으로 부채를 만든다. 비즈니스 의미가 반복해서 나타나는 규칙이라면, 그 규칙이 살아야 할 자리는 도메인 객체 안 이다.
Week 4 — 4년 전 포인트 시스템이 나에게 남긴 것
4년 전 포인트 시스템이 나에게 남긴 것: Slack 장애 알림 주인공이 되지 않는 날이 되기까지
내가 동시성 문제를 두 번 겪은 이야기 — 포인트 시스템의 트라우마가 이커머스 프로젝트에서 약이 된 건에 대하여 나는 동시성이라는 단어를 들으면 명치가 살짝 아프다. 비유가 아니다. 진짜
your-friendly-neighborhood.com
다룬 주제: 트랜잭션, 동시성, @Transactional 너머
포인트 시스템에서 마주했던 race condition 경험을 현재의 재고 차감 문제와 나란히 놓았습니다. @Transactional 하나로 정합성이 지켜질 거라는 믿음이 깨진 순간, 비관적/낙관적 락의 선택, 그리고 락 범위를 정확히 좁히는 감각을 정리했습니다.
재고가 -3 이 되던 새벽에 저는 다시 똑같은 질문을 꺼냈습니다. "정상이 어디에요? 또 뭐가 틀렸는데…" 그리고 답은 그날도 같았죠. "아 5분만 더 가면 돼 ㅋㅋㅋ". 그 5분 동안 제가 한 건 락 범위를 딱 한 칼럼 단위로 좁히는 일이었고, 그제야 테스트가 초록색으로 바뀌었습니다.
제가 얻은 감각: 정합성은 "트랜잭션을 걸었는가" 가 아니라 "무엇을 함께 묶었는가" 의 문제입니다. 범위가 넓으면 데드락, 좁으면 Lost Update. 그 사이를 정확히 찾는 것이 설계였습니다.
Week 5 — 인덱스와 캐시, 구조 개선을 통해 읽기 성능을 향상시키며 정리한 판단들 + 읽기 최적화 관점을 중복 처리 이슈에 적용해 보면 무엇이 보이는가
읽기 최적화 관점을 중복 처리 이슈에 적용해 보면 무엇이 보이는가
TL;DR: 하나의 요청만 보냈다고 믿었던 결제 시스템이 왜 같은 거래를 두 번 처리하게 되었는지, 로그와 구조를 따라가며 끝까지 이해해 본 기록 이번 이슈를 다시 돌아보면서 가장 오래 남은 감
your-friendly-neighborhood.com
다룬 주제: 복합 인덱스, 캐시 레이어, 구조 개선, 그 관점을 중복 처리에 적용
한 주에 두 편을 썼습니다. 첫 글은 느린 쿼리의 원인이 "데이터 양" 이 아니라 "데이터를 찾는 경로" 라는 것, 커버링 인덱스·캐시 레이어 도입의 전제 조건(무효화 전략) 을 정리한 글이었고, 두 번째 글은 같은 관점을 결제의 중복 처리 이슈에 적용해 "읽기를 최적화한다는 건 결국 쓰기 경로의 의미를 다시 묻는 일" 이라는 연결을 만들었습니다.
제가 얻은 감각: 인덱스는 물리 설계의 문제가 아니라 그 테이블이 답해야 하는 질문의 형태 에 따라 결정된다. 캐시는 "빠르니까 쓴다" 가 아니라 "무효화를 감당할 수 있을 때 쓴다". 그리고 읽기 최적화의 렌즈가 중복처리 같은 쓰기 설계에도 그대로 통한다는 것.
Week 6 — 뽀빠이 식당으로 보는 트래픽 몰림, Retry, 그리고 Idempotency
뽀빠이 식당으로 보는 트래픽 몰림, Retry, 그리고 Idempotency
한 줄 요약같은 순간에 요청이 몰리면 시스템은 생각보다 쉽게 흔들릴 수 있습니다. 그래서 Exponential Backoff + Jitter, Token Bucket 같은 장치로 몰림을 나누어 보고, 결제처럼 상태를 바꾸는 요청에서
your-friendly-neighborhood.com
다룬 주제: Retry 전략, 멱등성, 외부 시스템 연동과 회복 탄력성
트래픽이 몰렸을 때의 Retry 전략과 멱등성 설계를 친근한 비유로 풀었습니다. 재시도가 안전해지려면 "몇 번 시도할 것인가" 가 아니라 요청 자체가 멱등인가 를 먼저 묻게 된 주차였습니다.
제가 얻은 감각: 외부 연동이 들어오는 순간 @Transactional 은 더 이상 모든 걸 지켜 주지 않습니다. 멱등 키(eventId, requestId) 설계 는 옵션이 아니라 필수 전제였습니다. 이 감각이 7주차 Outbox 와 10주차 registerEvent 가드의 기초가 되었습니다.
Week 7 — Kafka를 붙일 때 가장 먼저 본 것은 왜…
Kafka를 붙일 때 가장 먼저 본 것은 왜 manual ack였을까
TL;DR: Kafka를 처음 붙일 때 나는 Producer 옵션보다 Consumer의 manual ack가 더 먼저 눈에 들어왔고, 그 지점에서야 "메시지를 받는 것"보다 "언제 처리 완료로 간주할 것인가"가 더 중요한 질문일 수 있겠
your-friendly-neighborhood.com
다룬 주제: 이벤트 분리, Kafka, Outbox 패턴
Kafka 를 붙이는 동기를 "비동기니까" 가 아니라 "실패 가능 지점을 한 곳으로 모으기 위해" 라는 관점으로 정리했습니다. 비즈니스 트랜잭션 안에서 kafkaTemplate.send() 를 직접 호출하는 순진한 코드의 위험과, Outbox 테이블로 메시지 전송을 원자적으로 만드는 패턴을 다뤘습니다.
Kafka Consumer 가 이벤트를 못 받을 때마다 로그를 뒤지며 "정상이 어디에요?" 를 또 꺼냈고, 매번 "아 5분만 더 가면 돼 ㅋㅋㅋ" 라는 자기 대답으로 로그 한 줄을 더 읽었습니다. 결국 문제는 내 트랜잭션이 커밋되기 전에 Kafka 전송이 먼저 성공했기 때문이라는, 5분치 로그가 가르쳐 준 결론이었습니다.
제가 얻은 감각: 정합성이 필요한 두 세계가 있을 때, 둘 다 내 DB 안에 녹여라. Kafka 는 내 트랜잭션에 들어올 수 없으니, Kafka 로 보낼 메시지 자체를 DB 에 기록해 두면 됩니다. 이 발상이 10주차의 Outbox 착각 에피소드에서 다시 한 번 시험대에 올랐습니다.
📎 곁가지 — 이것이 Spring AI다 (2026.04.01)
Spring AI 프레임워크를 훑어본 외도 글입니다. 본 회고의 10주 흐름과는 결이 다르지만, "한 주에 꼭 과제 주제로만 쓰지 않아도 괜찮다" 는 여유의 기록으로 남겨둡니다.
Week 8 — 주문 대기열 시스템 설계: Redis Sorted Set 의 내부 구조가 실시간 순번 조회를 가능하게 하는 원리
주문 대기열 시스템 설계: Redis Sorted Set의 내부 구조가 실시간 순번 조회를 가능하게 하는 원리
TL;DR — 트래픽 폭증 시 하류 시스템을 보호하면서 유저에게 공정한 순서를 보장하기 위해 Redis Sorted Set 기반의 주문 대기열을 구현했다. 처음에는 "Redis가 빠르니까"라는 이유만으로 충분하다고
your-friendly-neighborhood.com
다룬 주제: 대기열, 유량제어, Redis Sorted Set 의 Skip List 내부 구조
트래픽 피크 상황에서 요청을 즉시 거절하지 않고 공정한 순서로 처리 하는 대기열 시스템을 만들었고, Redis Sorted Set 이 Skip List 기반이라는 점과 순번 조회의 시간 복잡도가 어떻게 나오는지까지 파고들었습니다.
제가 얻은 감각: 대기열의 본질은 "트래픽을 없애는 것" 이 아니라 "피크를 평탄화하는 것" 입니다. 그리고 자료구조 선택의 근거를 "빠르니까" 에서 멈추면 안 된다는 것. 운영 중 이상 동작을 진단하려면 왜 그 자료구조가 이 연산에 O(log N) 을 보장하는지 까지 이해해야 합니다.
Week 9 — "0.3ms의 세계로 들어가도 되는 걸까"
"0.3ms의 세계로 들어가도 되는 걸까" — INFP 백엔드 개발자의 Redis ZSET 랭킹 파이프라인 구축기
TL;DR: ORDER BY score DESC의 300ms가 불안해서 Redis ZSET의 0.3ms 세계로 넘어갔다. Kafka 이벤트 파이프라인으로 가중치 기반 점수를 실시간 누적하고, 자정 콜드 스타트는 전일 점수 10%를 이월하는 Carry-Over
your-friendly-neighborhood.com
다룬 주제: 실시간 집계, Redis ZSET 기반 일간 랭킹 파이프라인
Kafka 이벤트 파이프라인부터 가중치 점수 시스템, ZREVRANGE 기반 TOP N 조회, 콜드 스타트 완화를 위한 Score Carry-Over 배치까지 — 실시간 랭킹 전체 흐름을 만들었습니다. 부수 기능(랭킹)이 핵심 기능(주문)을 끌어내리지 않도록 각 단계에 fallback 을 깔아 두는 것이 관건이었습니다.
가중치를 조정하고 · Carry-Over 를 얹고 · fallback 을 깔다 보면 또 "정상이 어디에요?" 가 터져 나왔고, 또 "아 5분만 더 가면 돼 ㅋㅋㅋ" 로 한 단계씩 더 쌓아 올렸습니다. 그 5분들이 모여서 0.3ms 의 ZREVRANGE 뒤에 콜드 스타트 완화 배치까지 달라붙은 파이프라인이 완성됐습니다.
제가 얻은 감각: 실시간 시스템의 정확성은 "완벽" 이 아니라 "감내할 수 있는 오차" 의 문제입니다. 콜드 스타트를 완전히 없앨 수 없다는 걸 받아들이고, 이를 완화하는 장치(Carry-Over) 를 따로 두는 편이 전체 시스템을 더 단단하게 만들었습니다. 그리고 ZSET 의 TTL 을 2일로 잡은 결정이 10주차에 배치를 도입해야 하는 이유가 되기도 했습니다.
Week 10 — 이 글의 주제: 배치와 Materialized View 로 대규모 집계를 풀기
퀘스트가 요구한 세 가지
- Spring Batch Job: 하루치 메트릭 테이블(product_metrics)을 읽어 주·월간으로 집계. Chunk-Oriented 로 대량 처리, 파라미터 기반 실행
- Materialized View 설계: mv_product_rank_weekly / mv_product_rank_monthly 에 TOP 100 적재
- Ranking API 확장: GET /api/v1/rankings?date=yyyyMMdd&size=20&page=1 에 기간(period) 을 추가해 일간·주간·월간을 모두 제공하되, 조회 형태에 따라 적절한 데이터 소스를 선택
제가 내린 네 가지 판단
1) 원천 데이터의 공백부터 메운다 — product_metrics_daily 의 등장
9주차까지의 product_metrics 는 상품별 누적 테이블(UNIQUE(product_id)) 이라 일별 스냅샷을 뽑을 수 없었고, Redis ZSET 은 TTL 이 2일이라 7일치를 합칠 수도 없었습니다. 배치가 읽을 원재료 자체가 없었던 거죠.
그래서 스트리머가 Kafka 이벤트를 받을 때마다 product_metrics_daily 라는 일별 스냅샷 테이블에도 upsert 하도록 설계를 바꿨습니다. 이 upsert 는 기존 누적 테이블 쓰기와 같은 @Transactional 안 에서 일어나도록 해서, SUM(daily.likes_count) == cumulative.likes_count 라는 불변조건이 즉시 성립하도록 했습니다.
@Transactional
fun handle(event: CatalogEventMessage) {
if (!registerEvent(event.eventId)) return // 멱등 가드 (Week 6 의 유산)
val metrics = productMetricsJpaRepository.findByProductId(event.productId) ?: ProductMetricsModel(productId = event.productId) if (metrics.isStale(event)) return // 버전 가드
metrics.apply(event) productMetricsJpaRepository.save(metrics) // 누적 (기존)
upsertDailyMetrics(event) // 일별 (신규)
rankingRepository.incrementScore(...) // Redis ZSET (Week 9 의 경로)
}
4주차의 트랜잭션 감각, 6주차의 멱등성, 7주차의 Outbox 연결 감각, 9주차의 ZSET 이 이 한 함수에 모두 모여 있다 는 사실이 인상적이었습니다. 매주 쓴 글의 주제들이 결국 한 핸들러 안에서 만났죠.
2) Chunk-Oriented 의 Reader 는 Paging 이 아닌 Cursor
TOP 100 을 뽑는 쿼리에서 JdbcPagingItemReader 와 JdbcCursorItemReader 사이의 선택이 고민이었습니다. 쿼리는 GROUP BY product_id ORDER BY score DESC LIMIT 100 이었죠.
- Paging: 페이지마다 GROUP BY 재실행 → 비용도 들고, 페이지 사이 데이터가 바뀌면 드리프트 가능성
- Cursor: 쿼리 한 번으로 커서 스트리밍 → 스냅샷 일관성 확보
TOP 100 은 작은 결과집합이라 Paging 의 복잡성을 얹을 이유가 없었고, Cursor 한 번으로 끝냈습니다.
StepBuilder(AGGREGATE_STEP, jobRepository)
.chunk<WeeklyAggregationRow, WeeklyProductRankModel>(CHUNK_SIZE, transactionManager) .reader(weeklyRankingItemReader) // JdbcCursorItemReader (SQL 단에서 TOP 100 절단)
.processor(weeklyRankingProcessor) // rank_position 1..100 부여
.writer(weeklyRankingItemWriter) // JpaItemWriter (mv_product_rank_weekly) .build()
purge Step(Tasklet) → aggregate Step(Chunk) 의 2단 구성을 선택해, requestDate 재실행 시에도 결과가 동일하도록 멱등 을 보장했습니다. 6주차에서 배운 "중복 실행도 결과가 하나" 의 연장이었습니다.
3) MV 엔티티는 쓰기/읽기 이중 매핑
같은 테이블(mv_product_rank_weekly) 에 대해 commerce-batch 모듈에는 쓰기용 엔티티, commerce-api 모듈에는 @Immutable 로 선언한 읽기용 엔티티를 별도로 두었습니다. 이중 매핑은 코드 중복이지만, 읽기 쪽에서 실수로 INSERT/UPDATE 가 나갈 수 없도록 컴파일 타임에 막는 하드닝이었습니다.
4) Ranking API 의 period 기본값은 DAILY — 하위 호환 유지
@GetMapping
fun getRankings(
@RequestParam(defaultValue = "DAILY") period: RankingPeriod,
@RequestParam date: String, @RequestParam(defaultValue = "20") size: Int,
@RequestParam(defaultValue = "1") page: Int,
): ApiResponse<RankingPageInfo> {
val parsedDate = LocalDate.parse(date, DateTimeFormatter.BASIC_ISO_DATE) return ApiResponse.success(rankingFacade.getRankings(period, parsedDate, page, size))}
9주차의 기존 클라이언트는 period 를 보내지 않아도 일간 경로(Redis ZSET) 로 그대로 동작하고, 주간/월간은 MV 테이블에서 조회됩니다. Service 안에서 when(period) 로 데이터 소스가 분기되며, 날짜 정규화(WEEKLY → ISO 월요일, MONTHLY → yyyy-MM) 도 내부에 캡슐화했습니다. 클라이언트는 주 중 어느 요일을 보내도 같은 결과를 받습니다.
10주차가 가르쳐 준 것 — 겸손
기술보다 겸손 이 이번 라운드의 진짜 교훈이었습니다.
구현 계획서를 쓰면서 "스트리머에 자체 Outbox 를 둘까" 라는 대안을 검토하다가, YAGNI 로 거절했다고 적었습니다. 그런데 그 판단을 할 때 저는 이미 7주차부터 프로젝트에 적용되어 있던 기존 Outbox 파이프라인 을 확인하지 않은 상태였습니다. "거절한 대안" 이 사실은 "이미 있는 것을 다시 만들 뻔한 판단" 이었던 거죠.
계획서 §1 에 "현황 분석" 섹션을 끼워 넣고 대안 비교표를 세 번째 다시 그리며 저는 또 물었습니다. "정상이 어디에요?" 그리고 또 스스로에게 답했습니다. "아 5분만 더 가면 돼 ㅋㅋㅋ". 그 5분 동안 저는 목차 순서를 바꿨습니다.
- §1 현황 분석 (Baseline Snapshot) — "지금 무엇이 있는가" 를 대안 비교보다 먼저
- §1.1 대안 비교 — "없는 것" 에 대해서만 대안을 나열
- §Phase 1.0 기존 인프라 활용 / 변경 없는 범위 — 건드리지 않을 것을 명시
2주차에 쓴 "설계 문서가 필요한 이유" 가 10주차에 와서야 "지금 있는 것을 먼저 적는다" 라는 한 줄로 구체화된 셈입니다. 코드는 한 줄도 바뀌지 않았지만, 10주 중 가장 값진 교훈이었습니다.
가장 큰 전환점 네 가지
전환점 1 — 2주차: 글로 쓰지 않은 설계는 내 것이 아니다
/32 를 쓰면서 "설계 문서 = 합의의 구조" 라는 감각을 얻었고, 그 감각이 이후 10주 내내 매주 한 편의 글을 쓰게 만든 엔진 이 되었습니다. 이 전환이 없었다면 10주차의 구현 계획서도, 지금 쓰는 이 회고도 없었을 겁니다.
전환점 2 — 4주차: @Transactional 너머의 세계가 있다
그전까지 정합성은 저에게 "트랜잭션을 걸면 된다" 였습니다. 재고가 -3 이 되던 날, 격리 수준과 락의 조합 이라는 또 하나의 세계가 있다는 걸 알았습니다. 이 감각이 없었다면 7주차의 Outbox, 10주차의 "단일 TX 안 쓰기 세 곳" 같은 선택도 나올 수 없었을 겁니다.
전환점 3 — 7주차: 이벤트 분리는 확장성의 기초였다
Kafka 를 붙이는 동기를 "비동기니까" 가 아니라 "실패 가능 지점을 한 곳으로 모으기 위해" 로 이해하게 된 순간입니다. 이 관점은 10주차에 다시 써먹혔습니다. 스트리머의 daily upsert 가 "Outbox 를 새로 만들 필요가 없는" 이유가 바로 "실패 가능 지점을 한 @Transactional 로 이미 모아 두었기" 때문이니까요.
전환점 4 — 10주차: 모른다는 것을 모를 수 있다
대안 목록을 글로 적어야 거절할 수 있다는 건 이미 알고 있었는데, 그 목록을 적기 전에 "지금 무엇이 있는가" 를 적지 않으면 거절 자체가 근거를 잃는다 는 건 10주차에야 체감했습니다. 설계 문서의 §1 자리에 현황 분석 이 들어가야 하는 이유를 이제는 몸으로 압니다.
Trade-off 판단 하나 — 왜 동기 upsert 였나
Round 10 에서 가장 오래 고민한 것은 "product_metrics_daily 를 동기로 쓸까, 비동기로 분리할까" 였습니다.
대안 정합성 적시성 추가 인프라
| A. 동기 upsert (단일 TX) | ✅ SUM(daily) == cumulative 즉시 성립 | ✅ 즉시 | 없음 |
| B. 스트리머 자체 Outbox | △ eventually consistent | △ 폴링 지연 | 테이블 · 릴레이 · 컨슈머 |
| C. 별도 토픽으로 발행 | ✅ | △ | 토픽 · 컨슈머 |
| D. 별도 일배치로 재구성 | ✅ | ✗ 1일 지연 | Job |
A 를 골랐습니다. 세 가지 이유에서였습니다.
- 핸들러에 추가되는 비용은 1 row insert 만큼의 트랜잭션 길이뿐
- 불변조건이 즉시 성립 해서 확인 쿼리를 언제 돌려도 일치
- 기존 Outbox(7주차) 가 이미 producer 측 정합성을 보장하므로, 스트리머 쪽에 또 다른 Outbox 를 두는 건 중복 설계
지금 다시 한다면 처음부터 DailyMetricEvent 같은 독립 도메인 이벤트로 모델링했을 것 같습니다. 동작은 같지만, 나중에 비동기로 분리할 필요가 생겼을 때 옮기기 쉬워지니까요. "지금의 단순함" 과 "미래의 유연성" 을 한 번에 얻을 수 있는 작은 차이였습니다.
실전에서 쓰려는 기준들
매주의 배움이 실무에 어떻게 연결될지도 기록해 두었습니다.
주차 배움 실무 연결 포인트
| 1 | 품질/TDD 의 자동화 | CI 에서 포맷/커밋/테스트를 기술적으로 강제, 의사결정표 기반 요구사항 공유 |
| 2 | 설계 문서 | 주요 PR/기능에 ADR 한 장을 짧게라도 남기기, 다이어그램 우선 소통 |
| 3 | 비즈니스 규칙의 위치 | 도메인 모델에 규칙을 심는 판단, 계층 간 책임 분리 리뷰 포인트 |
| 4 | 락과 동시성 | 결제·재고·포인트 등 공유 자원 설계 시 락 범위 선정 기준 |
| 5 | 읽기 최적화 | 인덱스/캐시 도입 전에 "이 테이블이 답해야 할 질문" 정의 |
| 6 | 멱등성 | 외부 연동 API 재시도/복구, 중복 제출 방어 |
| 7 | Outbox | 모놀리식에서 이벤트 드리븐으로 가는 첫 단추 |
| 8 | 대기열 | 블랙 프라이데이/선착순 쿠폰 같은 피크 트래픽 평탄화 |
| 9 | 실시간 집계 | 리더보드, 실시간 지표 대시보드, 추천 캐시 레이어 |
| 10 | 배치 + MV + 기간 API | 일/주/월 정산, 리포트, 사전 집계 기반 조회 최적화 |
아직 부족한 것 — 그리고 다음 여정
솔직히 이번 10주에서 저는 "돌아가는 것" 을 만드는 법을 익혔습니다. 하지만 "운영 가능한 것" 은 별개 문제였습니다.
- 성능 감각의 공백: EXPLAIN ANALYZE 를 손에 꼽게 썼습니다. 인덱스를 설계는 했는데 실제로 타는지 검증하는 습관이 아직 없습니다
- 관측 가능성의 공백: 로그를 찍는 선에서 멈췄습니다. Prometheus · Grafana · SLO 같은 도구/개념을 손에 익혀야 합니다
- CI/CD 의 공백: 1주차에 Ktlint/Git Hook 까지는 세웠지만, GitHub Actions · 카나리 배포 · 무중단 마이그레이션을 직접 세워 본 적이 없습니다
- 성숙한 테스트 기법의 공백: 1주차에 TDD 를 붙잡았지만, Property-based testing · Mutation testing · Contract testing 까지는 아직 닿지 못했습니다
다음 여정은 아마 이 공백들을 하나씩 메우는 시간이 될 것 같습니다. 그리고 그 여정에서도 저는 또 "정상이 어디에요?" 를 물을 테고, 또 "아 5분만 더 가면 돼 ㅋㅋㅋ" 를 혼잣말로 되뇌일 겁니다. 그게 지난 10주 동안 제가 배운 전진의 방식이었으니까요.
에필로그 — 열한 편의 글이 만든 열한 번째 저
10주 전의 저는 "왜 그렇게 했어요?" 라는 질문에 답하지 못했습니다. 10주 뒤의 저는 그 질문을 반기게 되었습니다. 답이 이미 매주 토요일 저녁의 글 속에 적혀 있으니까요.
설계를 글로 남길 수 있다는 건, 제가 내린 판단의 근거를 제가 안다는 뜻이었습니다. 그리고 그 글들이 서로를 참조하며 다음 판단을 더 또렷하게 만들었습니다. 2주차의 "설계문서의 가치" 가 10주차의 "§1 현황 분석" 으로, 6주차의 멱등성이 7주차의 Outbox 로, 7주차의 Outbox 가 10주차의 "단일 TX 안 쓰기 세 곳" 으로 이어지는 식이었죠.
매 주 토요일마다 저는 "정상이 어디에요?" 라고 자문했습니다. 답은 언제나 "아 5분만 더 가면 돼 ㅋㅋㅋ" 였고, 그 5분이 30분이 되고, 30분이 새벽 두 시가 되는 날도 있었습니다. 중간에 "이 정도면 됐다, 오늘은 안 쓰자" 라고 한 번쯤은 주저앉아도 괜찮았을 텐데, 저는 끝내 포기하지 않았습니다. 열 번의 5분이 모여서 10주가 되었고, 그 10주가 이 한 페이지의 인덱스로 남았습니다.
다음 여정에서도 저는 매주 한 편씩 쓸 생각입니다. 정상이 어디인지는 이번에도 모를 겁니다. 그래도 5분만 더 가면 될 것 같다는 그 가벼운 농담 하나로, 다음 5분도 쓰고 다음 다음 5분도 쓰게 될 것 같습니다.
그 사이, 이 글이 그 열한 편을 한 페이지에 압축한 인덱스이기를 바랍니다.
📂 참고 — 이 10주의 흔적들
주차 글 제목 핵심 주제
| 1 | Ktlint가 선명하게 코드를 핥고 있었다 | 코드 품질 자동화 |
| 1 | [WIL - TDD] 대리님 ~ 레드 페이즈에서… | TDD 와 팀 소통 |
| 2 | 지루하고 현학적인 설계문서가 필요한 이유 | 설계 문서·합의의 구조 |
| 3 | SQL에 월세 내던 비즈니스 규칙, 도메인으로 이사했습니다 | 도메인 모델링 |
| 4 | 4년 전 포인트 시스템이 나에게 남긴 것 | 트랜잭션·동시성 |
| 5 | 인덱스와 캐시, 구조 개선을 통해 읽기 성능을 향상시키며 정리한 판단들 | 읽기 최적화 |
| 5 | 읽기 최적화 관점을 중복 처리 이슈에 적용해 보면 무엇이 보이는가 | 멱등성·중복처리 |
| 6 | 뽀빠이 식당으로 보는 트래픽 몰림, Retry, 그리고 Idempotency | 외부연동·회복탄력성 |
| 7 | Kafka를 붙일 때 가장 먼저 본 것은 왜… | 이벤트 분리·Outbox |
| — | 이것이 Spring AI다 | (곁가지) Spring AI |
| 8 | 주문 대기열 시스템 설계: Redis Sorted Set 의 내부 구조가 실시간 순번 조회를 가능하게 하는 원리 | 대기열·자료구조 |
| 9 | "0.3ms의 세계로 들어가도 되는 걸까" | 실시간 랭킹 |
| 10 | (이 글) | 배치 · Materialized View · 기간별 Ranking API |
출처.
인생이 산으로 가네 -
https://www.threads.com/@nurungi_hamster/post/DBii_2JTYRL