TL;DR
Loopers 백엔드 부트캠프 10주차를 마치며, TDD 루틴 구축부터 Kafka 이벤트 파이프라인, Redis 랭킹 시스템까지 겪었던 고민들을 정리했습니다. 아직 주니어로서 부족한 점이 많지만, "왜 이런 선택을 했는지"에 대한 나름의 근거를 갖게 된 것이 가장 큰 성장이라고 생각합니다.
전체 여정 요약: 흐름이 연결되며 쌓인 경험들
10주 동안의 여정을 돌아보면, 각 주차의 학습이 단순히 독립적인 기술 습득이 아니라 하나의 연결된 흐름이었음을 깨닫습니다.
1-3주차: 기초 다지기와 TDD 루틴 확립
- E2E → 통합 → 단위 테스트 순으로 점진적 리팩토링 진행
- "테스트를 작성하는 능력"보다 "설계하는 감각"이 더 중요함을 깨달음
- 실무에서는 테스트 책임을 스스로 정해야 한다는 현실적 문제 직면
4-5주차: 성능과 데이터 설계
- DB 인덱스 설계와 Redis 캐싱 적용
- 복합 인덱스가 단일 인덱스보다 체감 성능 향상에 크게 기여
- Port-Adapter 패턴으로 캐시 구조 설계, 반복 로직 제거와 유지보수성 향상
6-7주차: 이벤트 기반 아키텍처로의 전환
- Spring ApplicationEvent로 비즈니스 경계 나누기
- "무조건 이벤트로 분리하는 것이 정답이 아니다" - 상황에 맞는 판단의 중요성
- 강한 정합성이 필요한 곳은 묶고, 느슨해도 되는 곳은 분리하는 설계 원칙 확립
- Circuit Breaker 패턴으로 외부 시스템 장애 대응
8-9주차: 분산 시스템과 메시지 기반 아키텍처
- Kafka와 아웃박스 패턴으로 At Least Once 보장
- 멱등성 처리, 동시성 제어까지 - 운영 환경에서 마주할 현실적 문제들 해결
- 완벽한 분산 시스템보다는 비즈니스 요구사항을 만족하는 현실적 해결책 추구
10주차: 대용량 데이터 처리와 배치 시스템
- Redis ZSET으로 실시간 랭킹 시스템 구축
- Spring Batch를 활용한 주간/월간 랭킹 집계 시스템 개발
- Materialized View 설계로 조회 성능 최적화
각 주차의 경험이 다음 주차의 밑바탕이 되면서, 단순한 기능 구현을 넘어서 시스템 설계와 운영까지 고민해볼 수 있는 기회를 얻었습니다. 물론 아직 깊이 부족한 부분이 많지만, 적어도 어떤 방향으로 더 학습해야 할지는 조금 더 명확해진 것 같습니다.
가장 큰 전환점: "설계를 위한 설계"의 가치 발견
가장 큰 사고의 전환점은 7주차 ApplicationEvent 도입 과정이었습니다.
처음에는 "이벤트가 그렇게 좋다면 모든 곳에 적용하면 되는 거 아닌가?"라는 생각이었습니다. 하지만 실제로 적용해보니 예상치 못한 문제들이 발생했습니다:
// 처음 생각했던 단순한 방식
@KafkaListener(topics = "product-events")
public void handle(String message) {
ProductEvent event = parse(message);
productMetricsService.updateMetrics(event);
// 끝!
}
하지만 현실은 달랐습니다:
- 메시지 중복: 네트워크 이슈로 같은 이벤트가 여러 번 도착
- 처리 실패: 일시적 DB 장애로 일부 메시지만 실패
- 순서 보장: 좋아요 → 취소 순서가 바뀌면 잘못된 집계
이 경험을 통해 느낀 것은 "기술을 위한 기술"보다는 "문제 해결을 위한 기술 선택"이 중요하다는 점이었습니다. 물론 아직 그 경계를 명확히 구분하기는 어렵지만, 적어도 그런 관점에서 고민해보려는 습관은 생긴 것 같습니다.
더 중요한 전환점은 설계 단계에서의 깨달음이었습니다. 요구사항 명세 → 시퀀스 다이어그램 → 클래스 다이어그램 → ERD 순으로 설계를 진행하면서, 좋은 테스트와 구현을 위해서는 사전 설계가 중요하다는 점을 체감할 수 있었습니다. 물론 아직 설계 실력이 많이 부족하지만, 적어도 그 필요성은 확실히 느꼈습니다.
나의 Trade-off 판단: 현실과 이상 사이의 균형
1. Service vs Facade, 어디서 검증할 것인가?
선택한 방향: Service에서 검증 책임 포함
// 선택한 방식 A – Service에서 도메인 조회 및 검증
public ProductOption isOnSales(Long productOptionId) {
ProductOption productOption = productOptionRepository.findById(productOptionId)
.orElseThrow(() -> new CoreException(...));
productOption.isOnSales(); // 도메인 객체의 검증 메서드 호출
return productOption;
}
판단 근거:
- 재사용성과 테스트 효율성: 검증 로직을 여러 흐름에서 재사용할 수 있을 것 같았음
- 책임의 분리: Service는 검증된 도메인 반환, Facade는 흐름 조합에만 집중하는 게 나을 것 같았음
- 코드 가독성: 도메인이 항상 "검증된 상태"로 전달된다는 전제로 개발할 수 있을 것 같았음
지금 다시 한다면: 아직 경험이 부족해서 확신은 서지 않지만, 비슷한 선택을 할 것 같습니다. 다만 팀의 컨벤션이나 프로젝트 성격에 따라 달라질 수도 있다고 생각합니다.
2. 비관적 락 vs 낙관적 락
선택한 방향: 비관적 락 (PESSIMISTIC_WRITE)
@Lock(LockModeType.PESSIMISTIC_WRITE)
@QueryHints({@QueryHint(name = "jakarta.persistence.lock.timeout", value = "3000")})
@Query("SELECT i FROM Inventory i WHERE i.productId = :productId")
Inventory findByProductIdForUpdate(@Param("productId") Long productId);
판단 근거:
- 도메인 순수성: @Version 필드로 도메인이 복잡해지는 게 마음에 들지 않았음
- 데이터 정합성: 재고, 포인트 같은 민감한 데이터에서는 확실한 보장이 필요하다고 생각했음
- 구현 복잡도: Repository 레벨에서만 처리하면 상대적으로 간단할 것 같았음
지금 다시 한다면: 트래픽이 많은 서비스라면 성능을 고려해서 낙관적 락을 선택할 수도 있을 것 같습니다. 아직 대용량 트래픽 경험이 없어서 그때 가서 더 고민해봐야 할 문제인 것 같습니다.
3. Circuit Breaker 적용 범위
선택한 방향: 외부 경계(LoopersPgProcessor)에서만 적용
// 외부 경계에서만 CB 적용 (명확한 책임)
PaymentFacade
→ PaymentService
→ LoopersPgProcessor [@CircuitBreaker] // 외부 PG와의 경계
판단 근거:
- 장애 지점 명확화: 외부 시스템과의 연동 지점에서만 보호하는 게 더 이해하기 쉬울 것 같았음
- 비즈니스 로직 분리: 상위 계층은 비즈니스에만 집중하는 게 좋을 것 같았음
- 복잡도 관리: 모든 계층에 적용하면 오히려 디버깅이 어려워질 것 같았음
지금 다시 한다면: 아직 대규모 시스템 운영 경험이 없어서 확신은 서지 않지만, 비슷한 접근을 할 것 같습니다. 다만 실제 운영해보면서 더 나은 방법이 있는지 계속 고민해봐야 할 것 같습니다.
실전과의 연결: 실무에서 써먹을 수 있는 포인트들
1. 아웃박스 패턴의 실전 활용
// 트랜잭션 내에서 이벤트를 별도 테이블에 저장
@TransactionalEventListener(phase = TransactionPhase.BEFORE_COMMIT)
public void handle(OutboxEvent event) {
outboxEventRepository.save(event);
}
// 스케줄러로 Kafka 발행
@Scheduled(fixedDelayString = "#{${scheduling.tasks.outbox-publishing.interval-seconds:5} * 1000}")
public void publishPendingEvents() {
// 분산 락으로 중복 실행 방지하며 이벤트 발행
}
실무 적용 가능성:
- MSA 환경에서 서비스 간 메시지 전달 시 유용할 것 같음
- 이벤트 소싱 아키텍처 구축할 때 참고할 수 있을 것 같음
- 대용량 트래픽 상황에서도 응용해볼 수 있을 것 같음 (아직 경험해보지는 못했지만)
2. 멱등성 처리 템플릿
EventHandled 테이블을 통한 First Insert 기법:
@Transactional
public boolean processExactlyOnce(String eventId, String eventType,
String aggregateId, Runnable work) {
// First Insert 시도로 중복 감지
boolean started = recorder.tryStart(eventId, eventType, aggregateId);
if (!started) {
return false; // 이미 처리됨
}
try {
work.run(); // 실제 비즈니스 로직
recorder.markSuccess(eventId);
return true;
} catch (Exception e) {
recorder.markFailure(eventId, e.getMessage());
throw e;
}
}
실무 적용 가능성:
- 결제 시스템에서 중복 처리 방지할 때 써볼 수 있을 것 같음
- 외부 API 연동 시 재시도 로직 구현할 때 참고할 수 있을 것 같음
- 배치 처리 시스템에서도 응용해볼 수 있을 것 같음
3. Spring Batch와 대용량 데이터 처리
마지막 10주차에서는 Spring Batch를 활용한 주간/월간 랭킹 집계 시스템을 구현해봤습니다:
// Chunk-Oriented Processing으로 대량 데이터 처리
@Bean
public Job weeklyRankingJob(JobRepository jobRepository, Step weeklyRankingStep) {
return new JobBuilder("weeklyRankingJob", jobRepository)
.start(weeklyRankingStep)
.build();
}
@Bean
public Step weeklyRankingStep(JobRepository jobRepository,
PlatformTransactionManager transactionManager,
ItemReader<ProductMetrics> reader,
ItemProcessor<ProductMetrics, WeeklyRanking> processor,
ItemWriter<WeeklyRanking> writer) {
return new StepBuilder("weeklyRankingStep", jobRepository)
.<ProductMetrics, WeeklyRanking>chunk(1000, transactionManager)
.reader(reader)
.processor(processor)
.writer(writer)
.build();
}
실무 적용 가능성:
- 정산 시스템에서 대량의 거래 데이터 집계할 때 써볼 수 있을 것 같음
- 통계 리포트 생성이나 데이터 마이그레이션 작업에도 활용할 수 있을 것 같음
- Materialized View 패턴은 조회 성능이 중요한 대시보드 시스템에서 유용할 것 같음
앞으로의 학습 방향: 더 깊이 있는 고민을 위해
이번 부트캠프를 통해 **"어떻게 구현할 것인가"**에서 **"왜 이렇게 설계해야 하는가"**로 관점이 조금씩 바뀌어가고 있는 것 같습니다. 아직 깊이는 많이 부족하지만, 앞으로는 다음 영역들을 더 학습해보고 싶습니다:
1. 개인화와 추천 시스템
현재의 랭킹 시스템은 모든 사용자에게 동일한 랭킹을 제공합니다. 실제 서비스에서는 사용자별 취향이나 구매 이력을 반영한 개인화된 추천이 필요할 텐데, 이 부분은 아직 경험해보지 못한 영역이라 더 학습해보고 싶습니다.
2. 더 복잡한 배치 처리 시스템
마지막 10주차에서 Spring Batch를 접해봤는데, 기존의 실시간 처리와는 완전히 다른 관점이 필요하다는 걸 느꼈습니다. 대량의 데이터를 청크 단위로 나누어 처리하고, 실패 시 재시작 지점을 관리하는 부분이 생각보다 복잡했습니다.
3. 운영 관점의 모니터링
Circuit Breaker나 캐시, 배치 시스템을 구축하긴 했지만, 실제 운영에서는 각각의 상태를 모니터링하고 장애 상황을 빠르게 감지하는 부분이 더 중요할 것 같습니다. 이런 운영 관점의 기술들도 더 배워보고 싶습니다.
마무리: 완벽하지 않지만 성장한 10주
10주간의 여정을 돌아보며 가장 큰 변화는 단순한 기능 구현을 넘어서 "왜"에 대해 고민하게 된 것입니다.
여전히 부족한 부분들이 많습니다:
- 개인화된 추천 시스템은 아직 경험해보지 못했고
- 대용량 트래픽 상황에서의 실제 검증도 해보지 못했고
- Spring Batch도 기본적인 구현만 해봤을 뿐 복잡한 실패 처리나 재시작 로직은 아직 부족하고
- 운영 환경에서의 모니터링 체계도 제대로 구축해보지 못했습니다
하지만 가장 의미 있다고 생각하는 것은 "왜 이렇게 구현했는가"에 대한 나름의 근거를 갖게 된 것입니다:
- 왜 Service에서 검증을 했는가? → 책임 분리와 재사용성을 고려했기 때문
- 왜 비관적 락을 선택했는가? → 도메인 순수성과 데이터 정합성을 우선했기 때문
- 왜 이벤트 기반으로 분리했는가? → 확장성과 장애 격리를 고려했기 때문
- 왜 아웃박스 패턴을 도입했는가? → 메시지 유실을 방지하고 싶었기 때문
물론 이런 판단들이 항상 정답은 아닐 것입니다. 다른 상황이나 더 큰 규모에서는 다른 선택이 더 나을 수도 있고, 아직 제가 놓치고 있는 부분도 많을 것입니다.
하지만 적어도 "왜 그렇게 했는지"에 대한 근거를 갖고 개발하려는 습관은 생긴 것 같습니다. 앞으로도 더 많은 경험을 통해 이런 판단 능력을 키워나가고 싶고, 무엇보다 사용자에게 실제로 가치를 전달하는 시스템을 만드는 개발자가 되고 싶습니다.
'WIL' 카테고리의 다른 글
| MSA와 EDA 이해하기: 분산 아키텍처 기본 개념 정리 (0) | 2025.10.26 |
|---|---|
| DDD 강의 회고: 도메인 주도 설계의 사실과 오해 (4) | 2025.10.12 |
| 캐시 전략(Cache Strategies) 정리 (3) | 2025.08.17 |
| 동시성 문제와 RDB에서의 해결 — 비관적 락 적용기 (4) | 2025.08.10 |
| WIL – TDD & 테스트 가능한 구조 (0) | 2025.07.16 |