Study for./Architecture

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

harrykim 2026. 4. 17. 15:18

정상이 어디에요?

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 로 대규모 집계를 풀기

퀘스트가 요구한 세 가지

  1. Spring Batch Job: 하루치 메트릭 테이블(product_metrics)을 읽어 주·월간으로 집계. Chunk-Oriented 로 대량 처리, 파라미터 기반 실행
  2. Materialized View 설계: mv_product_rank_weekly / mv_product_rank_monthly 에 TOP 100 적재
  3. 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. §1 현황 분석 (Baseline Snapshot) — "지금 무엇이 있는가" 를 대안 비교보다 먼저
  2. §1.1 대안 비교 — "없는 것" 에 대해서만 대안을 나열
  3. §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. 핸들러에 추가되는 비용은 1 row insert 만큼의 트랜잭션 길이뿐
  2. 불변조건이 즉시 성립 해서 확인 쿼리를 언제 돌려도 일치
  3. 기존 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://i.namu.wiki/i/1MphbkZr4cIo4qbsE_UQs5pzIuv49vHtswFQlxZEH2H62ICviFrM9v6lyG7igcvfxrpuJTyV9soTaSvELmCGbw.webp 

 

인생이 산으로 가네 -

https://www.threads.com/@nurungi_hamster/post/DBii_2JTYRL