10주간의 백엔드 부트캠프 회고: 설계부터 운영까지, 실전적 고민의 기록

2025. 9. 17. 16:27·WIL

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
'WIL' 카테고리의 다른 글
  • MSA와 EDA 이해하기: 분산 아키텍처 기본 개념 정리
  • DDD 강의 회고: 도메인 주도 설계의 사실과 오해
  • 캐시 전략(Cache Strategies) 정리
  • 동시성 문제와 RDB에서의 해결 — 비관적 락 적용기
JoshDev
JoshDev
    • 분류 전체보기 (24)
      • Java (3)
      • Spring (9)
      • Test Code (2)
      • WIL (6)
      • Vue.js (2)
      • WEB (0)
      • DB (1)
        • MySQL (1)
  • 인기 글

  • hELLO· Designed By정상우.v4.10.4
JoshDev
10주간의 백엔드 부트캠프 회고: 설계부터 운영까지, 실전적 고민의 기록
상단으로

티스토리툴바