본 포스팅은 패스트캠퍼스 환급 챌린지 참여를 위해 작성하였습니다.

 

 

강의 요약

오늘 강의에서는 여태까지의 서비스들을 모두 도커 컴포즈로 묶어 실행시키고 현재 서비스간 로직에서 이벤트 구조로 변경하기 위해 먼저 이벤트를 설계하는 내용이었다. 저번 글에 적었던 것처럼 주문 -> 결제 -> 배송로직은 이벤트라기보다는 커맨드 형식으로 설계하는 느낌을 받고 있엇는데 강의에서도 살짝 언급은 되었지만 명확하진 않았다. 따라서 분산 시스템에서 메시지 타입을 분류하는 기준과 좋은 이벤트 설계를 위해 알아보자.

 

메시지 타입 분류

Azure 메시징 시스템(Azure Service Bus, Azure Event Hubs 등)의 수석 아키텍트인 Clemens Vasters는 분산 메시징 유형을 분류하는 방식을 제안했는데, 그는 분산 메시징을 두 가지 범주로 분류한다.

 

  • Intents는 제어권을 이전하는 메시지로, Commands(특정 행동 요청)와 Queries(정보 조회 요청)가 포함된다.
  • Facts는 이미 발생한 사실을 나타내며, Events(상태 변화 알림)와 Documents(정보 전달)가 이에 해당한다.

이 분류가 중요한 이유는 각 메시지 타입에 따라 설계 방식과 책임 소재가 달라지기 때문이다. Command는 수신자가 특정 동작을 수행해야 하는 의무를 지니지만, Event는 발행자가 단순히 사실을 알리는 것으로 수신자의 반응을 기대하지 않는다.

 

EDA의 안티 패턴: Passive-Aggressive Event

EDA를 도입하면서 흔히 저지르는 실수 중 하나가 모든 통신을 이벤트로 만들려는 시도인데, 이 과정에서 발생하는 대표적인 안티 패턴이 바로 "Passive-Aggressive Event"이다.

 

Passive-Aggressive Event란

Martin Fowler는 이 패턴을 "passive-aggressive command"로 명명했다. 발행자가 수신자에게 특정 행동을 기대하면서도 이벤트 형태로 메시지를 포장하는 경우를 말한다.

 

좋은 이벤트의 특성은 다음과 같다.

  • 과거에 발생한 사실을 나타낸다.
  • 발행자는 누가 구독하는지 알지도 않고 관심도 없다.
  • 수신자의 반응을 기대하지 않는다

Passive-Aggressive Event는 이 세 번째 특성을 위반한다. 겉으로는 이벤트처럼 보이지만, 실제로는 특정 수신자가 특정 동작을 수행하기를 암묵적으로 기대한다.

 

구체적인 예시 비교

결제가 접수되면 확인 이메일을 발송하고 상태를 변경하는 시나리오를 살펴보자.

 

이벤트 기반 설계

[Payment Service] --PaymentReceived--> [Email Service]
                  <--PaymentConfirmed--

  • Payment Service는 "PaymentReceived" 이벤트를 발행하고
  • Email Service는 이를 구독하여 이메일을 발송한 뒤 "PaymentConfirmed" 이벤트를 발행하거나 Payment Service를 직접 호출하여 상태를 변경한다.

 

커맨드 기반 설계

[Payment Service] --SendConfirmationEmail--> [Email Service]
                  <--Success/Failure Response--

  • Payment Service가 "SendConfirmationEmail" 커맨드를 전송하고, Email Service는 처리 결과를 응답한다.
  • Payment Service는 응답을 받아 상태를 변경한다.

 

왜 커맨드가 더 적절한가

첫 번째 설계에서 Email Service는 결제 도메인의 비즈니스 로직을 알게 된다. "결제가 접수되면 확인 이메일을 보내야 하고, 이메일이 발송되면 결제 상태를 변경해야 한다"는 로직이 Payment Service가 아닌 Email Service에 위치하게 되는 것이다.

구분 이벤트 방식 커맨드 방식
비즈니스 로직 위치 Email Service에 분산 Payment Service에 집중
Email Service 역할 결제 로직 인지 필요 이메일 발송만 담당
재사용성 결제 도메인에 종속 범용적으로 활용 가능
책임 소재 불명확 명확
  • 이런 패턴이 여러 서비스에 반복되면 "분산 모놀리스(Distributed Monolith)"가 된다.
  • 마이크로서비스의 단점(네트워크 복잡성, 운영 부담)과 모놀리스의 단점(강한 결합)을 동시에 갖게 되는 최악의 상황이다.

 

올바른 선택 기준

메시지 타입 선택은 다음 기준으로 판단할 수 있다.

 

이벤트를 선택해야 할 때

  • 발생한 사실을 여러 관심 있는 서비스에 알릴 때
  • 수신자가 누구인지 발행자가 알 필요 없을 때
  • 수신자의 반응 여부가 발행자의 다음 동작에 영향을 주지 않을 때

커맨드를 선택해야 할 때

  • 특정 서비스가 특정 동작을 수행해야 할 때
  • 수행 결과에 따라 다음 동작이 결정될 때
  • 명확한 요청-응답 흐름이 필요할 때

 

커맨드의 기술적 구현

커맨드와 이벤트는 개념적 차이뿐 아니라 구현에 사용하는 도구도 달라진다. 핵심은 전달 방식의 차이다.

1:1 전달 vs 브로드캐스트

  • 커맨드: 특정 대상에게 작업을 시키는 것 → 1:1 전달 (Targeted Delivery)
  • 이벤트: 사실을 알리는 것 → N:N 브로드캐스트 (Broadcast)

이벤트는 "누가 듣든 상관없이" 발행하므로 SNS, Kafka 같은 Pub/Sub 방식이 적합하다. 반면 커맨드는 "특정 컴포넌트 하나가 처리해야" 하므로 1:1로 전달되는 방식이 필요하다.

 

비동기 커맨드: 메시지 큐 (SQS, RabbitMQ)

  • 커맨드를 비동기로 처리할 때는 메시지 큐가 적합하다.
특성 설명
1:1 전달 특정 서비스의 큐에 직접 메시지를 넣는다
작업 보장 수신자가 처리할 때까지 메시지를 보관한다
로드 밸런싱 여러 워커가 하나의 큐를 바라보며 작업을 분배한다

 

앞서 다룬 결제-이메일 예시를 메시지 큐로 구현하면 다음과 같다.

[올바른 구현: 메시지 큐 사용]

1. 이메일 서비스 앞단에 EmailSendingQueue(SQS) 배치
2. 결제 서비스가 SendConfirmationEmail 메시지를 큐에 직접 삽입
3. 이메일 서비스는 큐에서 메시지를 꺼내 작업만 수행

→ 이메일 서비스는 "왜" 보내는지 알 필요 없음
→ 결제 서비스는 "언젠가는 처리된다"는 보장을 받음

  • 메시지 큐를 사용한다는 것은 "할 일을 쪽지에 적어 담당자의 서류함(Queue)에 넣어두는 것"과 같다.
  • 담당자가 자리에 없어도 서류함에 쪽지는 남아 있으니 작업이 유실되지 않는다.

 

동기 커맨드: REST / gRPC

  • 커맨드라고 해서 반드시 메시지 큐만 써야 하는 것은 아니다.
  • 즉시 결과값(성공/실패)이 필요한 경우 동기식 통신이 더 적합하다.
[동기 커맨드가 적합한 경우]

- 결과값을 받아야 다음 로직을 진행할 수 있을 때
- 실패 시 즉시 롤백이나 보상 처리가 필요할 때
- 요청-응답 패턴이 자연스러운 경우

  • 단, REST/gRPC는 수신자가 죽어 있으면 요청 자체가 실패한다.
  • 담당자에게 직접 전화를 거는 것과 같아서, 전화를 안 받으면 일을 시킬 수 없다. 따라서 큐를 쓸 때보다 결합도가 높아진다.

 

비동기 커맨드인데 '응답'이 꼭 필요하다면?

만약 비동기 커맨드로 처리했는데 응답이 필요하면 어떻게 해야할까?  
 

커맨드 후 이벤트(Command followed by Event) 패턴

  • 가장 권장되는 비동기 처리 방식이다. '요청'은 큐로 보내고, '응답'은 이벤트로 받는 것이다.
  • 이를 "원인과 결과(Cause and Effect)" 또는 "시스템 변경의 전과 후(Before and After)"라고 설명할 수 있다.
  • 논리적으로는 요청-응답 패턴이지만, 구현은 완전히 비동기적으로 이루어진다.
 

작동 흐름

  • 송신자(주문 서비스): ProcessPayment라는 커맨드를 결제 서비스의 큐에 넣는다.
  • 수신자(결제 서비스): 큐에서 메시지를 꺼내 결제를 처리한다.
  • 결과 통보: 처리가 완료되면 결제 서비스는 PaymentProcessed(성공) 또는 PaymentFailed(실패)라는 이벤트를 이벤트 버스에 발행한다.
  • 확인: 처음에 커맨드를 보냈던 주문 서비스는 이 결과 이벤트를 구독하고 있다가, 이벤트가 도착하면 그때 비로소 "아, 결제가 끝났구나"라고 인지하고 후속 로직을 수행한다.
이 방식은 시스템 간의 물리적인 결합을 끊으면서도 논리적인 흐름을 유지할 수 있는 강력한 패턴이다.

 

왜 이것이 중요한가: 캡슐화와 변경 비용

David Parnas가 1970년대부터 강조한 정보 은닉(Information Hiding) 원칙의 관점에서 이 문제를 살펴보면 더 명확해진다.

이벤트 방식에서 Email Service는 "PaymentReceived 이벤트가 오면 확인 이메일을 보내고, PaymentConfirmed 이벤트를 발행해야 한다"는 결제 도메인의 비즈니스 로직을 알게 된다. 이는 Payment Service의 캡슐화를 깨뜨리는 것이다. 결제 프로세스가 변경되면(예: 확인 이메일 발송 시점 변경, 추가 검증 단계 삽입) Email Service의 코드도 함께 수정해야 한다.

 

변경 비용의 증가

결제 프로세스 변경 시 영향 범위:

[이벤트 방식]
Payment Service 수정 + Email Service 수정 + 연관 서비스 N개 수정

[커맨드 방식]
Payment Service 수정

  • Email Service가 결제 도메인에 종속되면 재사용성도 떨어진다.
  • User Account Service에서 환영 이메일을 보내려면? Bank Account Service에서 알림 이메일을 보내려면?
  • Email Service가 각 도메인의 비즈니스 로직을 모두 이해해야 한다면, 결국 모든 도메인의 로직이 Email Service에 축적된다.

 

Distributed Monolith의 형성

이런 패턴이 시스템 전반에 반복되면 "분산 모놀리스"가 형성된다.

Distributed Monolith = 분산 시스템의 단점 + 모놀리스의 단점

분산 시스템의 단점:
- 네트워크 지연과 장애
- 분산 트랜잭션의 복잡성
- 운영 및 모니터링 부담

모놀리스의 단점:
- 강한 결합으로 인한 변경 전파
- 독립적 배포 불가
- 단일 장애 지점

  • 서비스를 물리적으로 분리했지만 논리적으로는 강하게 결합되어 있어, 하나의 변경이 여러 서비스에 연쇄적으로 영향을 미친다.
  • 마이크로서비스의 이점(독립적 개발, 배포, 확장)은 사라지고 복잡성만 남는다.

 

SOLID와 DDD 관점에서의 해석

단일 책임 원칙(SRP)의 관점에서 Email Service는 "이메일을 발송한다"는 하나의 책임만 가져야 한다. "결제가 완료되면 확인 이메일을 보내고 상태를 변경한다"는 결제 도메인의 책임이며, 이는 Payment Service에 있어야 한다.

 

DDD의 Bounded Context 관점에서도 마찬가지다. 결제 컨텍스트의 비즈니스 규칙은 결제 컨텍스트 내부에 캡슐화되어야 한다. Email Service는 메시징 컨텍스트로서, 결제 컨텍스트가 무엇을 요청하든 이메일 발송이라는 자신의 역할만 수행하면 된다.

명확한 경계와 책임 분리는 조직의 흐름을 개선하고 인지 부하를 줄인다. 각 팀은 자신의 서비스가 어떤 계약(Contract)을 제공하는지만 이해하면 되고, 다른 서비스의 내부 로직을 알 필요가 없다.

 

결합도에 대한 오해

"이벤트는 느슨한 결합, 커맨드는 강한 결합"이라는 공식은 널리 퍼져 있지만 오해의 소지가 있다.

결합도를 결정하는 것은 메시지 타입이 아니라 캡슐화다.

캡슐화는 시스템이 최소한의 인터페이스만 노출하면서 통합할 수 있게 해준다. 위 예시에서 커맨드를 사용하면 Payment Service는 이메일 발송에 필요한 최소한의 정보(수신자, 제목, 내용)만 전달한다. Email Service는 이메일을 왜 보내야 하는지 알 필요 없이, 요청받은 이메일을 발송하는 역할만 수행한다.

[커맨드 방식의 인터페이스]

SendConfirmationEmail {
  to: "customer@example.com"
  subject: "결제 확인"
  body: "결제가 완료되었습니다."
}

→ Email Service는 이것이 결제 확인인지, 회원가입 환영인지,
   비밀번호 재설정인지 알 필요도 없고 관심도 없다.

각 서비스는 자신의 책임 범위 내에서만 동작하므로 실제 결합도는 오히려 낮아진다. 이벤트라는 형식을 사용하면서 암묵적인 기대와 숨겨진 의존성을 만드는 것보다, 커맨드로 명시적인 계약을 맺는 것이 더 낮은 결합도를 달성할 수 있다.

 

그렇다면 항상 커맨드를 선호해야 하는가?

절대 그렇지 않다. 이벤트를 발행하고 다운스트림 컨슈머가 반응하든 말든 상관없는 상황이라면, 이벤트가 더 적절하다.

예를 들어 "주문이 완료되었다"는 이벤트는 재고 서비스, 배송 서비스, 분석 서비스, 추천 서비스 등 여러 서비스가 각자의 목적에 맞게 구독하고 처리할 수 있다. 주문 서비스는 누가 이 이벤트를 구독하는지 알 필요 없고, 새로운 서비스가 추가되어도 코드 변경이 필요 없다.

핵심은 숨겨진 의존성과 백채널을 통해 의도치 않게 결합도를 높이지 않도록 주의하는 것이다.

 

EDA에 대한 흔한 오해들

Passive-Aggressive Event 패턴을 이해하면서 함께 짚어볼 만한 EDA의 오해들이 있다.

EDA는 Event Sourcing과 다르다

Event Sourcing은 서비스 내부에서 상태를 저장하는 방식이다. 현재 상태 대신 상태 변경 이벤트를 저장하고, 이벤트를 재생하여 상태를 복원한다.

EDA는 서비스 간 통신 방식이다. 서비스가 상태 변경을 이벤트로 발행하고, 다른 서비스가 구독하여 처리한다.

두 패턴은 상호 보완적이지만 독립적으로 사용할 수 있다. Event Sourcing 없이 EDA를 구현할 수 있고, EDA 없이 단일 서비스 내에서 Event Sourcing을 적용할 수 있다.

 

Kafka 사용이 곧 EDA는 아니다

Kafka는 로그 기반 메시지 브로커이며, 전송하는 메시지가 이벤트인지 커맨드인지는 사용자의 설계에 달려 있다. Kafka를 사용하면서도 모든 메시지를 커맨드로 설계할 수 있고, Kafka 없이도 HTTP Feeds 같은 방식으로 EDA를 구현할 수 있다.

시스템을 이벤트 기반으로 만드는 것은 도구가 아니라 메시지의 의미(semantics)와 시스템 전체의 흐름이다.

 

네이밍만으로는 이벤트가 되지 않는다

메시지 이름을 "OrderCreated", "PaymentProcessed"처럼 과거형으로 짓는다고 해서 자동으로 좋은 이벤트가 되지는 않는다.

만약 각 이벤트가 정확히 하나의 수신자를 가지고, 그 수신자가 다시 이벤트를 발행하여 원래 발행자에게 응답하는 구조라면, 이는 이벤트로 위장한 요청-응답 패턴이다. 시스템 전체의 메시지 흐름을 보고 판단해야 한다.

 

 

참고 출처

 

시작 시간
종료 시간
학습 인증 - 디지털 필기
수강 인증

 

https://fastcampus.info/4oKQD6b

 

본 포스팅은 패스트캠퍼스 환급 챌린지 참여를 위해 작성하였습니다.

강의 요약

오늘 강의에서는 검색 서비스와 카탈로그 서비스의 개발 과정을 다루었다. 검색 서비스에서는 태그 정보를 Redis에 캐싱하여 조회 성능을 개선하는 방법을 학습했다. 카탈로그 서비스에서는 NoSQL인 Cassandra를 활용한 상품 관리 방식을 다루었으며, 카탈로그 서비스의 변경사항을 검색 서비스에 동기화하기 위해 OpenFeign을 활용하는 패턴을 학습했다. 강의 내용이 로컬 수준의 간단한 로직 위주로 진행되어, 추가적으로 관심 있던 MongoDB 샤딩 클러스터 구조에 대해 학습하였다. 특히 샤딩 클러스터를 도입할 때 반드시 고려해야 할 제약사항과 트레이드오프에 초점을 맞추었다.

 

MongoDB Sharded Cluster 아키텍처

MongoDB의 샤딩은 대용량 데이터셋과 높은 처리량을 요구하는 애플리케이션을 위해 데이터를 여러 머신에 분산하는 방법이다. 수직적 확장(Vertical Scaling)의 하드웨어 한계를 극복하기 위한 수평적 확장(Horizontal Scaling) 전략이다.

 

클러스터 구성 요소

  • 각 샤드는 데이터의 부분집합을 포함하며, 반드시 Replica Set으로 배포해야 한다.
  • mongos는 쿼리 라우터 역할을 하며, 클라이언트 애플리케이션과 샤드 클러스터 사이의 인터페이스를 제공한다.
  • Config Server는 클러스터의 메타데이터와 구성 설정을 저장한다.

 

Shard Key 선택의 중요성

Shard Key는 컬렉션의 도큐먼트가 샤드 간에 어떻게 분산되는지를 결정한다. 클러스터의 성능, 효율성, 확장성에 직접적인 영향을 미치므로 신중한 선택이 필요하다.

샤딩 전략 특징 적합한 경우 주의사항
Hashed Sharding 샤드 키 값의 해시를 기반으로 분산 균등한 데이터 분산이 필요할 때, 단조 증가 키 사용 시 범위 쿼리 시 모든 샤드에 브로드캐스트
Ranged Sharding 샤드 키 값의 범위를 기반으로 분산 범위 쿼리가 빈번할 때 핫스팟 발생 가능성, 데이터 불균형 위험

 

Sharded Cluster에서의 트랜잭션 제약사항

MongoDB 4.2부터 샤딩된 클러스터에서도 분산 트랜잭션을 지원하지만, 여러 제약사항과 성능 고려사항이 존재한다.

 

성능 오버헤드

  • 샤딩된 클러스터에서의 트랜잭션은 단일 샤드 트랜잭션보다 더 큰 성능 비용이 발생한다.
  • 단일 샤드만 대상으로 하는 트랜잭션은 Replica Set 트랜잭션과 동일한 성능을 보이지만, 여러 샤드에 걸친 트랜잭션은 코디네이션 오버헤드가 추가된다.

 

Read Concern 제약

Read Concern 샤드 클러스터에서의 동작
local 샤드 간 스냅샷 일관성 보장하지 않음
majority 샤드 간 동일 스냅샷 보장하지 않음
snapshot 샤드 간 동기화된 스냅샷 뷰 제공
  • 샤드 클러스터 트랜잭션에서 샤드 간 일관된 스냅샷이 필요하다면 snapshot Read Concern을 사용해야 한다.

 

Arbiter 제약

  • Arbiter가 포함된 레플리카 셋인 경우 아래와 같은 제약사항이 존재한다.
  • Arbiter가 포함된 샤드에서는 멀티 샤드 트랜잭션의 쓰기 작업 불가
  • 해당 샤드에서 읽기 또는 쓰기를 시도하면 트랜잭션 오류 발생 후 중단
  • Arbiter는 멀티 샤드 트랜잭션에 필요한 데이터 작업에 참여할 수 없음

 

Write Concern 제약

멀티 샤드 트랜잭션은 내부적으로 2-Phase Commit 프로토콜을 사용한다. 이 과정에서 "커밋할 것인지 롤백할 것인지"에 대한 결정 기록은 사용자가 지정한 Write Concern과 무관하게 {w: "majority", j: true}가 강제 적용된다. 트랜잭션 원자성 보장을 위해 MongoDB가 내부적으로 강제하는 부분이다.

 

런타임 제한

트랜잭션은 기본적으로 1분 미만의 런타임을 가져야 한다. transactionLifetimeLimitSeconds 파라미터로 조정 가능하며, 샤드 클러스터의 경우 모든 샤드 Replica Set 멤버에서 동일하게 설정해야 한다.

 

Cross-Shard 쿼리 제약사항

$lookup 제약

샤드 클러스터 환경에서 $lookup Aggregation 스테이지 사용 시 주의가 필요하다.

$graphLookup 제약

- 트랜잭션 내에서 샤딩된 컬렉션을 대상으로 $graphLookup 사용 불가
- 이는 트랜잭션 내 제한된 작업(Restricted Operations)에 해당

브로드캐스트 쿼리

쿼리에 Shard Key 또는 Compound Shard Key의 접두사가 포함되지 않으면, mongos는 모든 샤드에 쿼리를 브로드캐스트해야 한다. 이러한 scatter/gather 쿼리는 장시간 실행되는 작업이 될 수 있다.

 

Chunk Migration과 트랜잭션

진행 중인 트랜잭션이 컬렉션에 대한 락을 보유하고 있고, 해당 컬렉션과 관련된 청크 마이그레이션이 시작되면, 마이그레이션의 특정 단계는 트랜잭션이 락을 해제할 때까지 대기해야 한다. 이는 청크 마이그레이션의 성능에 영향을 줄 수 있다.

청크 마이그레이션이 트랜잭션과 인터리빙되는 경우(예: 마이그레이션이 진행 중인 동안 트랜잭션이 시작되고, 트랜잭션이 컬렉션에 대한 락을 획득하기 전에 마이그레이션이 완료되는 경우), 트랜잭션은 커밋 중에 오류가 발생하고 중단된다.

 

기타 운영 고려사항

DDL 작업과의 충돌

멀티 도큐먼트 트랜잭션이 진행 중일 때, 동일한 데이터베이스나 컬렉션에 영향을 미치는 새로운 DDL 작업(예: createIndex)은 트랜잭션 뒤에서 대기한다. 이 대기 중인 DDL 작업이 존재하는 동안, 해당 데이터베이스나 컬렉션에 접근하는 새로운 트랜잭션은 필요한 락을 획득하지 못하고 maxTransactionLockRequestTimeoutMillis 대기 후 중단된다.

WiredTiger 캐시 압력

커밋되지 않은 트랜잭션이 WiredTiger 캐시에 과도한 압력을 가하면, 트랜잭션이 중단되고 Write Conflict 오류가 반환된다. 트랜잭션이 너무 커서 WiredTiger 캐시에 맞지 않는 경우, TransactionTooLargeForCache 오류와 함께 중단된다.

커밋 시점의 외부 읽기

트랜잭션 커밋 중에 외부 읽기 작업이 동일한 도큐먼트를 읽으려고 시도할 수 있다. 트랜잭션이 여러 샤드에 쓰는 경우:

  • snapshot 또는 linearizable Read Concern을 사용하는 외부 읽기는 트랜잭션의 모든 쓰기가 보이는 상태가 될 때까지 대기
  • 다른 Read Concern을 사용하는 외부 읽기는 트랜잭션 이전 버전의 도큐먼트를 읽음

 

참고 출처

 

시작 시간
종료 시간
학습 인증 - 디지털 필기
수강 인증

 

 

https://fastcampus.info/4oKQD6b

 

 

본 포스팅은 패스트캠퍼스 환급 챌린지 참여를 위해 작성하였습니다.

 

 

 

강의 요약

오늘 강의에서는배송 서비스 개발과 배송 상태에 대해 스케줄링 잡을 이용하여 강의를 진행했다. 마찬가지로 어댑터 패턴을 사용하였고(모킹 수준) 강의에서 진행되는 서비스들은 모두 별도의 서비스로 MSA 서비스 구조로 진행하는 것 같다. MSA 구조에서 서비스 간 통신과 외부 트래픽 관리는 시스템 설계의 핵심 요소다. 오늘 학습 내용을 확장하여 Load Balancer, Reverse Proxy, API Gateway의 차이점과 내용에 대해 다시 복습해보자.

 

 

Load Balancer vs Reverse Proxy vs API Gateway

MSA 환경에서 트래픽을 효율적으로 관리하기 위한 세 가지 핵심 컴포넌트가 있다. 기능이 일부 중복되기도 하지만, 각 컴포넌트는 고유한 목적과 강점을 가진다.

 

1. Load Balancer

Load Balancer는 들어오는 네트워크 트래픽을 여러 백엔드 서버에 분산하여 단일 서버에 부하가 집중되는 것을 방지한다. 성능, 가용성, 내결함성, 확장성 향상이 주요 목적이다. Load Balancer는 동작하는 네트워크 계층에 따라 두 가지 유형으로 구분된다.

 

 

핵심 기능

기능 설명
트래픽 분산 Round Robin, Least Connections, Weighted Distribution 등의 알고리즘으로 요청 분배
헬스 체크 백엔드 서버의 상태를 지속적으로 모니터링하여 비정상 서버를 로테이션에서 제외
세션 유지 IP 기반 또는 쿠키 기반으로 동일 사용자의 요청을 같은 서버로 라우팅
SSL 터미네이션 HTTPS 암복호화를 로드밸런서에서 처리하여 백엔드 서버 부하 감소
고가용성 장애 발생 시 정상 서버로 트래픽을 자동 우회

 

Layer 4 (전송 계층)

  • IP 주소와 TCP/UDP 포트 정보만으로 라우팅 결정
  • 요청 내용을 검사하지 않아 빠르고 효율적
  • 유연한 라우팅이 어려움

Layer 7 (애플리케이션 계층)

  • HTTP 메서드, URL, 헤더, 쿠키 등 요청 내용 기반 라우팅
  • /api 요청은 API 서버로, /images 요청은 정적 서버로 분기 가능
  • 더 많은 리소스 소비

적용 판단 기준

  • 동일한 애플리케이션의 여러 인스턴스에 트래픽을 분산해야 할 때
  • 수평적 확장으로 트래픽 증가에 대응해야 할 때
  • 자동 장애 복구가 필요할 때
  • 상태 비저장(Stateless) 애플리케이션을 운영할 때

대표 솔루션: AWS ELB, Google Cloud Load Balancing, HAProxy

 

2. Reverse Proxy

Reverse Proxy는 클라이언트와 백엔드 서비스 사이에 위치하여 요청을 중계한다. 클라이언트 입장에서는 모든 응답이 단일 서버에서 오는 것처럼 보인다.

핵심 기능

기능 설명
보안 및 추상화 백엔드 서버의 IP, 포트 등을 외부로부터 은닉하여 DDoS, 포트 스캐닝 등 공격 방어
SSL/TLS 터미네이션 클라이언트-프록시 구간만 암호화하고 내부 통신은 평문 처리 가능
캐싱 정적 콘텐츠(이미지, CSS, JS)를 캐싱하여 백엔드 부하 감소 및 응답 속도 향상
압축 Gzip, Brotli 등으로 응답을 압축하여 대역폭 절약
URL 재작성 외부 URL을 내부 서비스 경로로 매핑

 

로드 밸런싱과의 조합

실제 시스템에서는 Reverse Proxy와 Load Balancer를 함께 사용하는 경우가 많다. NGINX 같은 도구는 두 기능을 모두 수행할 수 있다.

  • 클라이언트 요청이 Reverse Proxy에 도착
  • 요청 경로에 따라 적절한 Load Balancer로 라우팅
  • Load Balancer가 서버 그룹 내 정상 서버로 요청 전달

 

적용 판단 기준

  • 백엔드 인프라를 외부로부터 은닉해야 할 때
  • 정적 콘텐츠 캐싱으로 성능을 개선해야 할 때
  • SSL 처리 부담을 백엔드에서 분리해야 할 때
  • 동일 도메인에서 여러 서비스를 호스팅할 때
  • URL 재작성이나 헤더 조작이 필요할 때

대표 솔루션: NGINX, Apache HTTP Server, Traefik

 

3. API Gateway

API Gateway는 모든 클라이언트 요청의 단일 진입점 역할을 한다. MSA 환경에서 여러 마이크로서비스를 통합 관리하는 데 특화되어 있다.

핵심 기능

기능 설명
단일 진입점 모든 API를 하나의 엔드포인트로 통합하여 클라이언트 로직 단순화
요청 라우팅 URL 경로, HTTP 메서드, 헤더 등을 기반으로 적절한 서비스로 라우팅
인증/인가 OAuth 2.0, JWT 등을 통한 중앙 집중식 보안 정책 적용
Rate Limiting 클라이언트별 요청 제한으로 백엔드 보호 및 공정한 사용 보장
요청/응답 변환 헤더 추가/제거, JSON-XML 변환, 페이로드 재구성
API 집계 여러 마이크로서비스 응답을 하나로 조합하여 클라이언트 왕복 횟수 감소
프로토콜 변환 HTTP-gRPC, REST-WebSocket 등 프로토콜 간 변환
로깅/모니터링 모든 API 트래픽에 대한 중앙 집중식 로그 및 메트릭 수집

적용 판단 기준

  • MSA 환경에서 모든 API의 단일 진입점이 필요할 때
  • JWT, OAuth2 등 토큰 기반 인증을 중앙에서 관리해야 할 때
  • API Rate Limiting, Throttling으로 백엔드를 보호해야 할 때
  • 여러 서비스의 응답을 집계하여 단일 응답으로 제공해야 할 때
  • 외부 개발자에게 API를 제공하고 키 관리가 필요할 때
  • API 버전 관리가 필요할 때

대표 솔루션: Amazon API Gateway, Kong Gateway, Apigee, Netflix Zuul

 

선택 기준 비교

요구사항 권장 선택
동일 인스턴스 간 트래픽 분산 Load Balancer
수평적 확장 및 자동 장애복구 Load Balancer
백엔드 보안 및 캐싱 Reverse Proxy
SSL 터미네이션 및 압축 Reverse Proxy
MSA 환경의 API 통합 관리 API Gateway
중앙 집중식 인증/인가 API Gateway
Rate Limiting 및 Throttling API Gateway
복수 서비스 응답 집계 API Gateway

 

세 컴포넌트의 조합

대규모 시스템에서는 세 컴포넌트를 계층적으로 조합하여 사용한다.

1. 클라이언트 요청
        ↓
2. (Optional) Edge Load Balancer (지역/지연시간 기반 라우팅)
        ↓
3. Reverse Proxy /  Web Tier Load Balancer (SSL 터미네이션, 캐싱, 헤더 처리)
        ↓
4. API Gateway (인증, Rate Limiting, 라우팅, 응답 집계)
        ↓
5. Internal Load Balancer (마이크로서비스 인스턴스 간 분산)
        ↓
6. Backend Services / Microservices Services (비즈니스 로직 처리)
  • AWS 환경에서는 API Gateway로 API 트래픽을 관리하고, ELB로 EC2 인스턴스나 컨테이너에 트래픽을 분산하는 구성이 일반적이다.
  • NGINX는 Reverse Proxy와 Layer 7 Load Balancer 역할을 동시에 수행할 수 있어 유연하게 활용된다.

 

참고 출처

 

시작 시간
종료 시간
학습 인증 - 디지털 필기
수강 인증

 

https://fastcampus.info/4oKQD6b

 

본 포스팅은 패스트캠퍼스 환급 챌린지 참여를 위해 작성하였습니다.

 

강의 요약

오늘 강의에서는 결제 서비스 개발 과정에서 어댑터 패턴을 이용한 외부 인터페이스 연동 방법을 다루었다. 결제 서비스는 다양한 PG사와 연동해야 하는데, 각 PG사마다 API 스펙이 다르다. 어댑터 패턴을 적용하면 이러한 차이를 내부 비즈니스 로직으로부터 격리할 수 있다. 강의에서는 물론 실제 결제 코드를 작성하진 않았다. 어댑터 패턴에 대해서 알아보자.

 

Adapter Pattern

어댑터 패턴은 호환되지 않는 인터페이스를 가진 객체들이 협업할 수 있도록 중간에서 변환 역할을 수행하는 래퍼 객체를 만드는 구조 패턴이다. 기존 애플리케이션이 XML 형식을 사용하는데 새로 도입하려는 외부 라이브러리가 JSON만 지원하는 상황을 예로 들 수 있다. 이때 라이브러리 소스 코드를 수정할 수 없거나, 수정하더라도 기존 의존성에 영향을 줄 수 있다.

 

어댑터 패턴의 구현 방식은 객체 어댑터와 클래스 어댑터 두 가지가 있다. 객체 어댑터는 객체 합성 원칙을 사용하여, 어댑터가 서비스 객체를 필드로 가지고 래핑하는 방식이다. 클래스 어댑터는 다중 상속을 사용하여 클라이언트 인터페이스와 서비스 클래스를 동시에 상속받는 방식인데, Java처럼 다중 상속을 지원하지 않는 언어에서는 사용할 수 없다. 따라서 일반적으로 객체 어댑터 방식이 널리 사용된다.

 

객체 어댑터는 클라이언트 인터페이스를 구현하면서 실제 서비스 객체를 래핑한다. 클라이언트는 어댑터의 인터페이스를 통해 호출하고, 어댑터는 이를 실제 서비스 객체가 이해할 수 있는 형식으로 변환하여 전달한다. 이를 통해 인터페이스 변환 코드를 비즈니스 로직에서 분리할 수 있고, 클라이언트 코드 수정 없이 새로운 어댑터를 도입할 수 있다. 다만 새로운 인터페이스와 클래스가 추가되어 코드 복잡성이 증가하므로, 때로는 서비스 클래스를 직접 수정하는 것이 더 간단할 수 있다.

 

 

헥사고날 아키텍처와 Outgoing Adapter

헥사고날 아키텍처는 비즈니스 로직의 완전한 자급자족을 목표로 한다. 라이브러리, 서비스, 데이터베이스 등 모든 서드파티 도구들은 외부 모듈을 변환하는 GoF 어댑터 패턴 뒤에 숨겨진다. 즉 헥사고날 아키텍처에서 어댑터는 외부 세계와 애플리케이션 코어 사이의 경계를 담당하며, 이 중 Outgoing Adapter는 애플리케이션에 의해 호출되는 주도되는(driven) 어댑터로, 영속성 어댑터나 외부 API 연동 어댑터가 여기에 해당한다.

 

애플리케이션 서비스는 영속성 기능을 사용하기 위해 포트 인터페이스를 호출하고, 이 포트는 실제로 영속성 작업을 수행하는 어댑터 클래스에 의해 구현된다. 포트는 애플리케이션 서비스와 영속성 코드 사이의 간접적인 계층으로, 영속성 문제에 신경 쓰지 않고 도메인 코드를 개발하기 위해 이러한 간접 계층을 추가한다. 영속성 코드를 리팩토링하더라도 코어 코드를 변경하는 결과로 이어지지 않는다.

 

의존성 역전 원칙

Outgoing Adapter에서 핵심은 제어 흐름과 의존성 방향이 반대라는 점이다. 제어 흐름은 서비스가 어댑터를 호출하고 어댑터가 외부 시스템을 호출하는 방향이다. 그러나 의존성 방향은 어댑터가 포트 인터페이스를 구현하므로, 어댑터에서 코어로 향한다. 이것이 의존성 역전 원칙이다.

애플리케이션 계층에 포트 인터페이스를 만들고, 어댑터에 해당 포트를 구현한 클래스를 둔다. 포트 인터페이스를 구현한 실제 어댑터 객체는 의존성 주입을 통해 애플리케이션 계층에 제공된다. 이를 통해 영속성 어댑터의 입력 모델이 애플리케이션 코어에 있기 때문에 영속성 어댑터 내부를 변경하는 것이 코어에 영향을 미치지 않는다. 포트의 명세만 지켜진다면 영속성 계층 전체를 교체할 수도 있다.

 

결국 어댑터 패턴이든 헥사고날 아키텍처의 Outgoing Adapter든 핵심은 동일하다. 외부 시스템과의 결합도를 낮추고, 변경에 유연하게 대응할 수 있는 구조를 만드는 것이다. 다만 모든 상황에 이 구조가 최선은 아니며, 시스템의 복잡도와 변경 가능성을 고려하여 적용 여부를 판단해야 한다.

 

 

참고 출처

 

시작 시간
종료 시간
학습 인증 - 디지털 필기
수강 인증

 

 

https://fastcampus.info/4oKQD6b

 

 

본 포스팅은 패스트캠퍼스 환급 챌린지 참여를 위해 작성하였습니다.

 

강의 요약

오늘 강의에서는 회원 서비스 REST API 구현을 진행했다. 실습 중심의 구현 강의였기 때문에 Kafka Consumer의 Offset Commit에 대해 보충 학습을 진행했다.

 

Kafka Offset이란

Kafka에서 Offset은 토픽의 파티션 내 각 레코드(메시지)에 할당되는 고유 식별자다. Offset은 0부터 시작하여 메시지가 저장될 때마다 1씩 증가한다. Offset은 파티션 내에서만 고유하며, 파티션 간에는 고유하지 않다.

Kafka는 Offset을 영구적으로 저장하여 장애 발생 시 컨슈머가 특정 지점부터 재개할 수 있도록 한다. 모든 컨슈머는 처리한 Offset 정보를 커밋하여 Kafka에 알린다. Offset이 없다면 중복 처리나 데이터 유실을 방지할 방법이 없다.

데이터베이스에서 SQL 문 실행 후 변경사항을 영구 저장하기 위해 commit하는 것처럼, 파티션에서 읽은 후 처리된 메시지의 위치를 표시하기 위해 Offset을 commit한다.

 

Committing Offset

Committing Offset은 오프셋 정보를 Kafka 브로커에 저장하는 것을 의미한다. 이 과정은 컨슈머가 어떤 메시지를 성공적으로 처리했는지 추적하는 데 사용된다. 컨슈머가 재시작하거나 크래시되면 마지막으로 커밋된 오프셋부터 처리를 재개할 수 있어 메시지가 재처리되거나 건너뛰어지지 않도록 보장한다.

 

Offset 저장 위치: __consumer_offsets 토픽

컨슈머가 그룹에 참여하면 Kafka 브로커는 __consumer_offsets라는 내부 토픽을 생성하여 토픽, 파티션 레벨에서 컨슈머 오프셋 상태를 저장한다. Kafka Auto Commit이 활성화되어 있으면 컨슈머는 마지막으로 처리한 메시지 오프셋을 이 토픽에 정기적으로 커밋한다.

컨슈머 그룹의 컨슈머가 크래시나 연결 끊김으로 실패하면, Kafka는 누락된 하트비트를 감지하고 리밸런스를 트리거한다. 실패한 컨슈머의 파티션을 활성 컨슈머에게 재할당하여 메시지 소비가 계속되도록 한다. 이때 내부 토픽의 영구 저장된 상태를 사용하여 소비를 재개한다.

[consumer-user-data,user-data,0]::OffsetAndMetadata(offset=2, leaderEpoch=Optional[0], ...)
[consumer-user-data,user-data,1]::OffsetAndMetadata(offset=0, leaderEpoch=Optional.empty, ...)

 

auto.offset.reset 설정

컨슈머가 처음 그룹에 참여할 때, auto.offset.reset 설정에 따라 레코드를 가져올 오프셋 위치를 결정한다. earliest 또는 latest로 설정할 수 있다. 단, __consumer_offsets 토픽에 해당 컨슈머 그룹의 커밋된 오프셋이 이미 존재하면 auto.offset.reset 설정이 earliest로 되어 있더라도 마지막 커밋된 오프셋부터 재개한다.

 

Offset Commit 전략

카프카의 오프셋 커밋 전략에 대해 알아보자.

 

Auto Commit

가장 간단한 방식이다. Kafka는 기본적으로 Auto Commit을 사용하며, poll() 메서드가 반환한 가장 큰 오프셋을 5초마다 커밋한다. enable.auto.commit 설정으로 활성화하고, auto.commit.interval.ms로 간격을 조정할 수 있다.

KafkaConsumer<Long, String> consumer = new KafkaConsumer<>(props);
consumer.subscribe(KafkaConfigProperties.getTopic());
ConsumerRecords<Long, String> messages = consumer.poll(Duration.ofSeconds(10));
for (ConsumerRecord<Long, String> message : messages) {
    // 메시지 처리
}

 

문제점

  • 애플리케이션 장애 시 데이터 유실 가능성이 있다.
  • poll()이 100개의 메시지를 반환하고, 컨슈머가 60개를 처리했을 때 Auto Commit이 발생한다고 가정하자. 이후 장애로 컨슈머가 크래시되면, 새 컨슈머는 오프셋 101부터 읽기 시작하여 61~100번 메시지가 유실된다.
  • 반대로 Auto Commit 간격 전에 컨슈머가 크래시되면 중복 처리가 발생할 수도 있다.

 

Manual Sync Commit - commitSync()

수동 커밋을 사용하려면 먼저 Auto Commit을 비활성화해야 한다.

Properties props = new Properties();
props.put(ConsumerConfig.ENABLE_AUTO_COMMIT_CONFIG, "false");

KafkaConsumer<Long, String> consumer = new KafkaConsumer<>(props);
consumer.subscribe(KafkaConfigProperties.getTopic());
ConsumerRecords<Long, String> messages = consumer.poll(Duration.ofSeconds(10));
// 메시지 처리
consumer.commitSync();

  • 이 방식은 메시지 처리 후에만 오프셋을 커밋하여 데이터 유실을 방지한다.
  • 그러나 오프셋 커밋 전에 컨슈머가 크래시되면 중복 읽기는 방지하지 못한다.
  • commitSync()는 완료될 때까지 코드를 블로킹한다.
  • 에러 발생 시 계속 재시도한다. 이로 인해 애플리케이션 처리량이 감소한다.

 

Manual Async Commit - commitAsync()

Kafka는 commitAsync()를 제공하여 오프셋을 비동기적으로 커밋한다. 다른 스레드에서 오프셋을 커밋하여 동기 커밋의 성능 오버헤드를 극복한다.

KafkaConsumer<Long, String> consumer = new KafkaConsumer<>(props);
consumer.subscribe(KafkaConfigProperties.getTopic());
ConsumerRecords<Long, String> messages = consumer.poll(Duration.ofSeconds(10));
// 메시지 처리
consumer.commitAsync();

  • commitAsync()는 실패 시 재시도하지 않는다. 그 이유는 다음과 같다.
  • 오프셋 300을 커밋하려는 commitAsync()가 문제로 실패했다고 가정하자.
  • 재시도 전에 다른 commitAsync() 호출이 오프셋 400을 커밋할 수 있다(비동기이므로).
  • 실패한 commitAsync()가 재시도하여 오프셋 300을 성공적으로 커밋하면, 이전 커밋 400을 덮어써서 중복 읽기가 발생한다.
  • 이것이 commitAsync()가 재시도하지 않는 이유다.

 

Commit Specific Offset

때로는 오프셋에 대해 더 많은 제어가 필요하다. 메시지를 작은 배치로 처리하고 처리되는 즉시 오프셋을 커밋하고 싶을 수 있다. commitSync()와 commitAsync()의 오버로드된 메서드를 사용하여 특정 오프셋을 커밋할 수 있다.

Map<TopicPartition, OffsetAndMetadata> currentOffsets = new HashMap<>();
int messageProcessed = 0;

while (true) {
    ConsumerRecords<Long, String> messages = consumer.poll(Duration.ofSeconds(10));
    for (ConsumerRecord<Long, String> message : messages) {
        // 메시지 처리
        messageProcessed++;
        currentOffsets.put(
            new TopicPartition(message.topic(), message.partition()),
            new OffsetAndMetadata(message.offset() + 1));
        if (messageProcessed % 50 == 0) {
            consumer.commitSync(currentOffsets);
        }
    }
}

  • TopicPartition을 키로, OffsetAndMetadata를 값으로 사용하는 currentOffsets 맵을 관리한다.
  • 처리된 메시지 수가 50에 도달하면 currentOffsets 맵과 함께 commitSync()를 호출하여 해당 메시지들을 커밋된 것으로 표시한다.

 

Storing Offset

Storing Offset은 처리 중 컨슈머 애플리케이션 내의 로컬 변수나 인메모리 데이터 구조에 현재 오프셋을 보관하는 것을 의미한다. 처리된 오프셋을 로컬에 저장한 후 일괄 커밋할 수 있다. Offset Committing과 Storing Offset은 대안이 아니라 상호 보완적이다.

 

Auto Commit을 활성화하고 EnableAutoOffsetStore를 비활성화하는 조합을 사용할 수 있다. Auto Commit을 활성화하면 수동으로 오프셋을 커밋할 필요가 없어 블로킹 네트워크 호출을 방지할 수 있다. EnableAutoOffsetStore를 비활성화하면 어떤 오프셋이 커밋될지 완전히 제어할 수 있다. Confluent에서도 수동 오프셋 커밋 대신 이 접근 방식을 권장한다.

// EnableAutoCommit = true, EnableAutoOffsetStore = false

while (!cancelled) {
    var consumeResult = consumer.Consume(cancellationToken);

    // 메시지 처리
    ...

    // 처리된 오프셋만 저장. 오프셋 커밋은 자동으로 수행됨
    consumer.StoreOffset(consumeResult);
}

 

 

메시지 전달 전략별 구현

At-Most-Once (최대 한 번)

메시지가 컨슈머에게 최대 한 번 발행된다. 메시지가 유실될 수 있지만 재전달되지 않는다.

// EnableAutoCommit = false

while (!cancelled) {
    var consumeResult = consumer.Consume(cancellationToken);
    consumer.Commit(consumeResult);  // 처리 전에 오프셋 커밋

    // 오프셋 커밋 후 메시지 처리
    ...
}

  • 메시지를 가져온 후 처리 전에 오프셋을 커밋한다.
  • 오프셋 커밋 후 컨슈머가 크래시되면 해당 메시지는 다시 소비되지 않는다.

 

At-Least-Once (최소 한 번)

메시지가 컨슈머에게 최소 한 번 발행되며, 메시지 유실이 없음을 보장한다. 장애 시 메시지가 재전달되므로 중복 처리 가능성이 있다.

// EnableAutoCommit = true, EnableAutoOffsetStore = false

while (!cancelled) {
    var consumeResult = consumer.Consume(cancellationToken);

    // 메시지 처리
    ...

    // 처리된 오프셋 저장. 오프셋 커밋은 자동으로 수행됨
    consumer.StoreOffset(consumeResult);
}

  • 메시지를 가져와 처리한 후 오프셋을 저장한다.
  • 오프셋 커밋 전에 컨슈머가 크래시되면 메시지가 다시 소비된다.

 

Exactly-Once (정확히 한 번)

메시지가 컨슈머에게 정확히 한 번 전달되며, 메시지 유실이나 재처리가 없음을 보장한다. 이 전략을 제공하려면 컨슈머가 멱등성을 가져야 한다. 컨슈머 멱등성은 잠금 메커니즘 사용, Inbox 패턴 사용, 멱등하게 실행되도록 컨슈머 로직 작성 등 여러 방법으로 제공할 수 있다.

// EnableAutoCommit = true, EnableAutoOffsetStore = false

while (!cancelled) {
    var consumeResult = consumer.Consume(cancellationToken);
    var message = JsonSerializer.Deserialize<ComplexType>(consumeResult.message);

    // 메시지의 고유 ID로 잠금 존재 여부 확인
    if (_lockManager.LockExist(message.uniqueId) is false) {
        // 메시지 처리
        ...

        // 메시지 처리 후 잠금 추가
        _lockManager.AcquireLock(message.uniqueId);
    }

    // 처리된 오프셋 저장
    consumer.StoreOffset(consumeResult);
}

 

 

참고 출처

 

 

시작 시간
종료 시간
학습 인증 - 디지털 필기
수강 인증

 

https://fastcampus.info/4oKQD6b

 

 

본 포스팅은 패스트캠퍼스 환급 챌린지 참여를 위해 작성하였습니다.

 

 

강의 요약

오늘 강의에서는 Spring Data Cassandra를 활용한 NoSQL 데이터 접근 계층 구성 방법을 학습했다. Cassandra는 분산 데이터베이스로서 대규모 데이터 처리와 높은 가용성이 필요한 시스템에서 활용된다. 스프링에서 사용하는 방법과 마찬가지로 테스트 관련 내용을 알아보자.


Spring Data Cassandra 설정

Spring Data Cassandra는 두 가지 설정 방식을 제공한다.

 

Java Config 방식

AbstractCassandraConfiguration을 상속받아 설정 클래스를 작성한다.

@Configuration
public class CassandraConfig extends AbstractCassandraConfiguration {

    @Override
    protected String getKeyspaceName() {
        return "testKeySpace";
    }

    @Bean
    public CassandraClusterFactoryBean cluster() {
        CassandraClusterFactoryBean cluster = new CassandraClusterFactoryBean();
        cluster.setContactPoints("127.0.0.1");
        cluster.setPort(9142);
        return cluster;
    }
}

Spring Boot 방식

application.properties를 통해 간결하게 설정한다.

spring.data.cassandra.keyspace-name=testKeySpace
spring.data.cassandra.port=9142
spring.data.cassandra.contact-points=127.0.0.1
  • 필수 설정 항목으로는 contactPoints(서버 호스트명), port(요청 수신 포트), keyspaceName(데이터 복제를 정의하는 네임스페이스)이 있다.

 

Entity 매핑과 Primary Key

@Table
public class Book {
    @PrimaryKeyColumn(name = "isbn", ordinal = 2, 
      type = PrimaryKeyType.CLUSTERED, ordering = Ordering.DESCENDING)
    private UUID id;
    
    @PrimaryKeyColumn(name = "title", ordinal = 0, 
      type = PrimaryKeyType.PARTITIONED)
    private String title;
    
    @PrimaryKeyColumn(name = "publisher", ordinal = 1, 
      type = PrimaryKeyType.PARTITIONED)
    private String publisher;
    
    @Column
    private Set<String> tags = new HashSet<>();
}

 

타입 역할
PARTITIONED 데이터가 저장될 노드를 결정하는 파티션 키
CLUSTERED 파티션 내에서 데이터 정렬 순서를 결정하는 클러스터링 키

 

@DataCassandraTest 슬라이스 테스트

슬라이스 테스트의 필요성

  • @SpringBootTest는 전체 애플리케이션 컨텍스트를 로드하므로 테스트 시간이 길어질 수 있다.
  • 특정 계층만 테스트할 때는 해당 계층에 필요한 컴포넌트만 로드하는 것이 효율적이다.

 

@DataCassandraTest가 로드하는 컴포넌트

  • CassandraAutoConfiguration, CassandraDataAutoConfiguration
  • CassandraRepositoriesAutoConfiguration
  • CassandraReactiveDataAutoConfiguration, CassandraReactiveRepositoriesAutoConfiguration
  • CacheAutoConfiguration

스캔 대상: @Table 엔티티, @Repository 인터페이스

스캔 제외: 일반 @Component, @ConfigurationProperties 빈

 

테스트 클래스 구성

@RunWith(SpringRunner.class)
@DataCassandraTest
@Import(CassandraConfig.class)
public class InventoryServiceIntegrationTest {

    @Autowired
    private InventoryRepository repository;

    @Test
    public void givenVehiclesInDBInitially_whenRetrieved_thenReturnAllVehiclesFromDB() {
        List<Vehicle> vehicles = repository.findAllVehicles();
        assertThat(vehicles).isNotNull();
        assertThat(vehicles).isNotEmpty();
    }
}

Testcontainers를 활용한 테스트 환경

public class InventoryServiceLiveTest {
    public static DockerComposeContainer container =
        new DockerComposeContainer(new File("src/test/resources/compose-test.yml"));

    @BeforeAll
    static void beforeAll() { container.start(); }

    @AfterAll
    static void afterAll() { container.stop(); }
}

 

참고 출처

 

 

시작 시간
종료 시간
학습 인증 - 디지털 필기
수강 인증

 

https://fastcampus.info/4oKQD6b

 

 

 

본 포스팅은 패스트캠퍼스 환급 챌린지 참여를 위해 작성하였습니다.

 

강의 요약

오늘 강의에서는 Spring for Kafka의 기본 개념과 구성 요소를 다루었다. Spring Kafka는 Apache Kafka와 Spring Framework를 통합하여 메시지 기반 애플리케이션을 구축할 수 있도록 지원한다. KafkaTemplate을 통한 메시지 발행, @KafkaListener를 사용한 메시지 소비, 그리고 이를 Spring Boot 환경에서 설정하는 방법을 학습했다. 그러면 테스트를 위해서는 어떻게해야할까? 알아보도록 하자

 

Spring Kafka 통합 테스트 전략

Kafka를 사용하는 애플리케이션을 개발할 때 가장 큰 과제 중 하나는 신뢰할 수 있는 테스트 환경을 구축하는 것이다. 외부 Kafka 브로커에 의존하면 테스트의 독립성과 재현성이 떨어질 수 있다. Spring Kafka Test는 이러한 문제를 해결하기 위해 두 가지 주요 접근 방식을 제공한다.

 

EmbeddedKafka를 활용한 테스트

의존성 설정

Spring Kafka Test 모듈은 테스트 환경에서 인메모리 Kafka 브로커를 실행할 수 있는 기능을 제공한다.

<dependency>
    <groupId>org.springframework.kafka</groupId>
    <artifactId>spring-kafka-test</artifactId>
    <version>3.1.1</version>
    <scope>test</scope>
</dependency>

테스트 구성

@EmbeddedKafka 어노테이션을 사용하면 테스트 실행 시 자동으로 Kafka 브로커가 시작된다.

@SpringBootTest
@DirtiesContext
@EmbeddedKafka(partitions = 1, brokerProperties = {
    "listeners=PLAINTEXT://localhost:9092",
    "port=9092"
})
class EmbeddedKafkaIntegrationTest {

    @Autowired
    private KafkaConsumer consumer;

    @Autowired
    private KafkaProducer producer;

    @Value("${test.topic}")
    private String topic;

    @Test
    public void givenEmbeddedKafkaBroker_whenSendingMessage_thenMessageReceived()
      throws Exception {
        String data = "Test message";

        producer.send(topic, data);

        boolean messageConsumed = consumer.getLatch().await(10, TimeUnit.SECONDS);
        assertTrue(messageConsumed);
        assertThat(consumer.getPayload(), containsString(data));
    }
}

  • @DirtiesContext 어노테이션은 각 테스트 간 컨텍스트를 격리하여 테스트 독립성을 보장한다.
  • partitions 속성은 토픽당 파티션 수를 지정하며, 테스트 환경에서는 단순성을 위해 1로 설정하는 것이 일반적이다.

 

컨슈머 설정의 핵심

테스트에서 중요한 설정은 auto-offset-reset: earliest이다. 이 설정은 컨슈머가 메시지를 읽기 시작하는 오프셋 위치를 지정한다. 테스트 환경에서는 컨테이너가 메시지 발송 이후에 시작될 수 있으므로, earliest로 설정하여 토픽의 처음부터 메시지를 읽도록 보장해야 한다.

 

Testcontainers를 활용한 테스트

Testcontainers의 필요성

EmbeddedKafka는 빠르고 가벼운 장점이 있지만, 실제 Kafka 브로커와 미묘한 차이가 있을 수 있다. 또한 포트 충돌 가능성도 존재한다. Testcontainers는 Docker 컨테이너로 실제 Kafka 브로커를 실행하여 이러한 한계를 극복한다.

의존성 추가

<dependency>
    <groupId>org.testcontainers</groupId>
    <artifactId>kafka</artifactId>
    <version>1.19.3</version>
    <scope>test</scope>
</dependency>

테스트 구성

@RunWith(SpringRunner.class)
@SpringBootTest
@DirtiesContext
public class KafkaTestContainersTest {

    @ClassRule
    public static KafkaContainer kafka =
      new KafkaContainer(DockerImageName.parse("confluentinc/cp-kafka:5.4.3"));

    @Test
    public void givenKafkaDockerContainer_whenSendingMessage_thenMessageReceived()
      throws Exception {
        String data = "Test message";

        producer.send(topic, data);

        boolean messageConsumed = consumer.getLatch().await(10, TimeUnit.SECONDS);
        assertTrue(messageConsumed);
    }
}

동적 포트 바인딩 처리

Testcontainers는 포트 충돌을 방지하기 위해 동적으로 포트를 할당한다. 따라서 브로커 주소를 설정 파일에 하드코딩할 수 없다.

@Bean
public Map<String, Object> consumerConfigs() {
    Map<String, Object> props = new HashMap<>();
    props.put(ConsumerConfig.BOOTSTRAP_SERVERS_CONFIG, kafka.getBootstrapServers());
    props.put(ConsumerConfig.AUTO_OFFSET_RESET_CONFIG, "earliest");
    props.put(ConsumerConfig.GROUP_ID_CONFIG, "baeldung");
    return props;
}

@Bean
public ProducerFactory<String, String> producerFactory() {
    Map<String, Object> configProps = new HashMap<>();
    configProps.put(ProducerConfig.BOOTSTRAP_SERVERS_CONFIG, kafka.getBootstrapServers());
    return new DefaultKafkaProducerFactory<>(configProps);
}

  • kafka.getBootstrapServers() 메서드는 컨테이너가 시작된 후 동적으로 할당된 포트 정보를 포함한 브로커 주소를 반환한다.

 

테스트 전략 선택 시 고려사항

EmbeddedKafka 선택이 적합한 경우

  • 빠른 테스트 실행 속도가 중요한 경우
  • Docker 환경이 제약되거나 사용할 수 없는 경우
  • 간단한 메시지 발행/소비 로직을 검증하는 경우
  • CI/CD 파이프라인에서 실행 시간을 최소화해야 하는 경우

Testcontainers 선택이 적합한 경우

  • 실제 프로덕션 환경과 최대한 유사한 테스트가 필요한 경우
  • Kafka의 특정 버전이나 설정을 정확히 재현해야 하는 경우
  • 복잡한 Kafka 설정이나 커스텀 플러그인을 테스트하는 경우
  • 다른 시스템과의 통합 테스트가 필요한 경우

 

두 방식 모두 외부 Kafka 브로커 의존성을 제거하여 테스트의 독립성과 재현성을 확보한다는 공통 목표를 가진다. 프로젝트의 요구사항, 테스트 복잡도, 실행 환경의 제약사항을 종합적으로 고려하여 적절한 방식을 선택하는 것이 중요하다.

 

 

참고 출처

 

 

시작 시간
종료 시간
학습 인증 - 디지털 필기
수강 인증

 

 

https://fastcampus.info/4oKQD6b

 

본 포스팅은 패스트캠퍼스 환급 챌린지 참여를 위해 작성하였습니다.

 

 

강의 요약

오늘 강의에서는 컨테이너 기술의 핵심인 Docker와 Docker Compose에 대해 학습했다. 애플리케이션을 컨테이너로 패키징하여 일관된 환경에서 실행할 수 있는 Docker의 기본 개념과, 여러 컨테이너를 효율적으로 관리할 수 있는 Docker Compose의 활용법을 다루었다. 도커 관련된 내용을 추가적으로 알아보자.

 

Docker의 핵심 구성 요소

도커의 핵심 구성 요소에 대해 알아보자.

 

Docker Image

Docker 이미지는 컨테이너의 기반이 되는 읽기 전용 템플릿이다. 애플리케이션 코드, 라이브러리, 의존성, 설정 파일 등 실행에 필요한 모든 요소를 포함한다.

이미지는 레이어 구조로 구성된다. Dockerfile의 각 명령어가 하나의 레이어를 생성하며, 이 레이어들이 쌓여 완전한 이미지를 형성한다. 이러한 레이어 기반 구조는 다음과 같은 장점을 제공한다.

 

레이어 재사용을 통한 효율성

  • 동일한 베이스 이미지를 사용하는 여러 애플리케이션은 공통 레이어를 공유한다
  • 이미지 빌드 시 변경된 레이어만 재생성되어 빌드 속도가 향상된다
  • 저장 공간을 절약할 수 있다

이미지 배포와 버전 관리

  • Docker Hub나 프라이빗 레지스트리를 통해 이미지를 공유할 수 있다
  • 태그를 통해 버전을 관리하며, 롤백이 용이하다
  • 환경 간 일관성을 보장하여 "내 컴퓨터에서는 되는데" 문제를 해결한다

 

하지만 이미지 크기가 커질수록 배포 시간이 증가하므로, 불필요한 파일을 제거하고 multi-stage build를 활용하여 최종 이미지 크기를 최적화해야 한다.

 

Docker Container

컨테이너는 이미지의 실행 가능한 인스턴스다. 이미지의 읽기 전용 레이어 위에 쓰기 가능한 레이어를 추가하여 생성된다.

 

격리성과 독립성

  • 각 컨테이너는 독립된 파일시스템, 네트워크 스택, 프로세스 공간을 갖는다
  • 한 컨테이너의 변경사항이 다른 컨테이너나 호스트 시스템에 영향을 주지 않는다
  • 동일한 호스트에서 충돌 없이 여러 애플리케이션을 실행할 수 있다

리소스 효율성

  • VM과 달리 호스트 커널을 공유하여 오버헤드가 적다
  • 가볍고 빠른 시작 시간을 제공한다
  • 단일 호스트에서 다수의 컨테이너를 실행하여 리소스 활용도를 극대화할 수 있다

컨테이너는 ephemeral한 특성을 가지므로, 영구적으로 보존해야 할 데이터는 Volume을 통해 관리해야 한다. 컨테이너 내부의 데이터는 컨테이너 삭제 시 함께 사라진다.

 

Dockerfile

Dockerfile은 이미지 빌드 과정을 자동화하는 스크립트다. 선언적 문법으로 이미지 생성 단계를 정의한다.

 

주요 명령어

  • FROM: 베이스 이미지 지정
  • WORKDIR: 작업 디렉토리 설정
  • COPY: 파일을 호스트에서 이미지로 복사
  • RUN: 이미지 빌드 중 명령어 실행
  • EXPOSE: 컨테이너가 리스닝할 포트 선언
  • CMD: 컨테이너 시작 시 실행할 기본 명령어

Multi-stage Build

프로덕션 환경에서는 빌드 도구가 필요 없다. Multi-stage build를 사용하면 빌드 단계와 실행 단계를 분리하여 최종 이미지 크기를 크게 줄일 수 있다.

# Build Stage
FROM python:3.9-slim AS build
WORKDIR /app
COPY requirements.txt .
COPY . .

# Final Stage
FROM python:3.9-slim
WORKDIR /app
COPY --from=build /app /app
RUN pip install --no-cache-dir -r requirements.txt
EXPOSE 5000
CMD ["gunicorn", "--bind", "0.0.0.0:5000", "app:app"]

  • 빌드 스테이지에서 생성된 산출물만 최종 이미지로 복사하여, 빌드 의존성을 제외하고 런타임에 필요한 구성 요소만 포함시킨다.

 

레이어 캐싱 최적화

  • 자주 변경되지 않는 명령어를 Dockerfile 상단에 배치한다
  • 의존성 설치와 애플리케이션 코드 복사를 분리하여 변경 시 재빌드 범위를 최소화한다
  • .dockerignore 파일로 불필요한 파일이 빌드 컨텍스트에 포함되지 않도록 한다

 

Docker Compose

Docker Compose는 여러 컨테이너로 구성된 애플리케이션을 정의하고 실행하는 도구다. YAML 파일로 서비스를 구성하고, 단일 명령어로 전체 애플리케이션 스택을 관리할 수 있다.

 

멀티 컨테이너 관리의 필요성

현대적인 애플리케이션은 웹 서버, 데이터베이스, 캐시, 메시지 큐 등 여러 서비스로 구성된다. 각 서비스를 개별적으로 실행하고 연결하는 것은 복잡하고 오류가 발생하기 쉽다.

 

Docker Compose의 장점

  • 서비스 간 의존성과 네트워크를 선언적으로 정의한다
  • 개발 환경과 프로덕션 환경 구성을 코드로 관리한다
  • docker-compose up 한 번으로 모든 서비스를 시작할 수 있다
  • 서비스별로 스케일링이 가능하다

적용 시 고려사항

Docker Compose는 단일 호스트 환경에 적합하다. 프로덕션 환경에서 여러 호스트에 걸친 오케스트레이션이 필요하다면 Kubernetes나 Docker Swarm 같은 도구를 고려해야 한다. 개발 환경에서는 Compose가 간단하고 효과적이지만, 규모가 커지면 더 강력한 오케스트레이션 솔루션이 필요하다.

 

참고 출처

 

 

 

시작 시간
종료 시간
학습 인증 - 디지털 필기
수강 인증

https://fastcampus.info/4oKQD6b

 

본 포스팅은 패스트캠퍼스 환급 챌린지 참여를 위해 작성하였습니다.



강의 요약

오늘 강의에서는 데이터베이스 선택의 핵심인 RDB와 NoSQL의 차이점과 NoSQL 데이터베이스 중 하나인 Apache Cassandra의 기본 개념과 특징을 다루었다. RDB는 ACID 트랜잭션과 정규화된 스키마를 통해 데이터 일관성을 보장하지만 수평 확장에 한계가 있다. 반면 NoSQL은 특정 워크로드에 최적화된 데이터 모델을 제공하며, Cassandra는 그중에서도 대규모 쓰기 성능과 수평 확장성에 강점을 가진 분산 데이터베이스다.

 

Cassandra의 핵심 특징

카산드라의 핵심 특징을 알아보자.

 

Masterless 복제 아키텍처

Cassandra의 가장 큰 특징은 마스터 노드가 없는 완전 분산 아키텍처다. 전통적인 데이터베이스는 Master-Slave 구조를 사용하여 마스터는 쓰기를, 슬레이브는 읽기와 복제를 담당한다. 이 구조는 마스터가 단일 장애 지점이 되며, 노드 추가/제거 시 복잡한 재구성이 필요하고, 마스터 장애 시 선출 과정이 필요하다.

 

Cassandra의 모든 노드는 동등한 역할을 수행한다. 데이터 쓰기 요청은 임의의 노드로 전달되고, 해당 노드는 Replication Factor에 따라 다른 노드들에 데이터를 복제한다. 데이터 조회 시에도 모든 노드가 요청을 처리할 수 있으며, Gossip Protocol을 통해 클러스터 내 노드들이 지속적으로 통신하며 데이터 위치와 노드 상태를 파악한다.

 

이 구조는 단일 장애 지점을 제거하고, 노드 추가 시 단순히 클러스터에 참여시키기만 하면 되므로 운영이 단순하다. 마스터 선출이나 복잡한 장애 조치 없이 고가용성을 달성할 수 있다.

 

조정 가능한 일관성 수준

CAP 정리에 따르면 분산 시스템은 일관성, 가용성, 파티션 내성 중 두 가지만 보장할 수 있다. Cassandra는 기본적으로 가용성을 우선하는 AP 시스템이지만, 일관성 수준을 애플리케이션 요구사항에 맞게 조정할 수 있다.

 

Replication Factor는 데이터가 몇 개의 노드에 복제될지 결정한다. Consistency Level은 읽기와 쓰기 작업 시 몇 개의 노드가 응답해야 성공으로 처리할지 정의한다. 예를 들어 Replication Factor가 3이고 Write Consistency Level이 QUORUM이면, 3개 노드 중 최소 2개가 쓰기를 완료해야 클라이언트에 성공 응답을 반환한다.

 

강한 일관성이 필요한 경우 READ + WRITE > Replication Factor 조건을 만족하도록 설정할 수 있다. 하지만 이는 가용성을 희생하므로 Cassandra의 강점을 활용하지 못하게 된다. 대부분의 경우 최종 일관성으로 충분하며, 이것이 Cassandra가 선택되는 주요 이유다.

 

선형 확장성

대부분의 데이터베이스는 비선형적으로 확장된다. 노드를 2배로 늘려도 성능이 2배 향상되지 않는다. 이는 데이터양이 증가하면 단일 작업의 오버헤드도 증가하기 때문이다. 관계형 데이터베이스는 유니크 키 검증, 외래 키 매핑, 강한 일관성 보장 등으로 데이터가 많아질수록 작업이 복잡해진다.

 

Cassandra는 선형 확장을 제공한다. Netflix의 벤치마크 결과에 따르면 노드 수에 정비례하여 초당 쓰기 처리량이 증가하며, 100만 writes/s 이상에서도 성능 저하 없이 확장 가능했다. 이는 파티션 키를 통한 데이터 분산과 각 노드의 독립적인 쓰기 처리 때문이다.

 

높은 쓰기 성능

Cassandra는 LSM Tree 기반의 스토리지 엔진을 사용한다. 쓰기 요청은 먼저 메모리의 Memtable에 기록되고, 동시에 Commit Log에 순차적으로 append된다. Memtable이 가득 차면 SSTable로 플러시되며, 백그라운드 컴팩션이 진행된다.

이 구조는 랜덤 쓰기를 순차 쓰기로 변환하여 디스크 I/O를 최소화한다. 또한 다수의 노드가 동시에 쓰기를 처리할 수 있어 단일 마스터 병목이 없다. Netflix는 1백만 writes/s를 처리하면서도 성능 저하 없이 확장 가능함을 검증했다.

 

Cassandra를 선택해야 하는 경우

그러면 카산드라를 사용해야하는 경우는 언제일까?

 

대규모 쓰기 워크로드

초당 수천에서 수백만 건의 쓰기가 발생하는 시스템에서 Cassandra는 탁월한 선택이다. IoT 센서 데이터, 시계열 데이터, 로그 수집 시스템이 대표적이다. 선형 확장과 높은 쓰기 처리량은 이러한 워크로드에 최적화되어 있다.

 

단일 노드의 리소스 한계

단일 노드는 CPU, 메모리, 네트워크 대역폭이 물리적으로 제한된다. 또한 단일 장애 지점이 되며, 트래픽 패턴에 따른 탄력적 확장이 불가능하다. Cassandra는 노드를 추가하여 리소스를 수평적으로 확장하고, 고가용성을 제공하며, 수요에 따라 노드를 동적으로 조정할 수 있다.

 

선형 확장이 필요한 환경

비즈니스 성장에 따라 데이터와 트래픽이 증가할 때 Cassandra의 선형 확장은 예측 가능한 용량 계획을 가능하게 한다. 노드를 두 배로 늘리면 성능도 두 배가 되므로, 향후 확장에 대한 불확실성을 제거할 수 있다.

 

Cassandra를 피해야 하는 경우

그러면 피해야하는 경우도 알아보자.

 

예측 불가능한 쿼리 패턴

Cassandra의 데이터 모델링은 쿼리 패턴에 의존한다. 파티션 키 없는 쿼리는 모든 노드를 스캔해야 하므로 프로덕션에서 비현실적이다. 쿼리 패턴을 미리 정의할 수 없거나 임시 쿼리가 빈번한 경우 적합하지 않다.

 

강한 ACID 트랜잭션

Cassandra는 파티션 수준의 원자성만 제공하며 롤백을 지원하지 않는다. 여러 파티션에 걸친 트랜잭션이 일부 성공하고 일부 실패할 경우 불일치가 발생할 수 있다. 금융 시스템처럼 강한 ACID 보장이 필수인 경우 PostgreSQL이나 MySQL이 더 적합하다.

 

복잡한 관계형 쿼리

Cassandra는 JOIN, 외래 키, 복잡한 집계를 지원하지 않는다. 다대다 관계나 복잡한 JOIN 쿼리가 필요한 경우 관계형 데이터베이스를 선택해야 한다.

 

소규모 시스템

단일 노드로 충분한 시스템에서 Cassandra는 과도한 복잡성을 추가한다. 분산 시스템의 운영 오버헤드, 학습 곡선, 리소스 비용을 고려하면 단순한 단일 노드 데이터베이스가 효율적이다. Cassandra의 진가는 대규모 확장이 필요한 환경에서 발휘된다.

 

유연한 스키마

개별 레코드가 서로 다른 컬럼을 가져야 하는 경우 MongoDB 같은 문서 데이터베이스가 더 적합하다. Cassandra는 테이블 수준에서 정의된 스키마를 따라야 한다.

 

 

참고 출처

 

 

 

시작 시간
종료 시간
학습 인증 - 디지털 필기
수강 인증

 

 

https://fastcampus.info/4oKQD6b

 

 

 

본 포스팅은 패스트캠퍼스 환급 챌린지 참여를 위해 작성하였습니다.

 

 

강의 요약

오늘 강의에서는  메시지 브로커와 카프카에 대해서 간단히 학습했다. 메시지 브로커와 이벤트 브로커는 두 용어를 혼용하여 사용하는 경우가 많지만, 하지만 두 용어는 서로 다른 설계 철학과 사용 목적을 가지고 있다. 특히 이벤트 브로커의 경우 오프셋(offset) 개념이 핵심이며, 이는 메시지 소비 위치를 추적하고 재처리를 가능하게 하는 중요한 메커니즘이다. Apache Kafka를 중심으로 이벤트 브로커의 기본 개념과 아키텍처를 살펴보자.

 

Message Broker vs Event Broker

메시지 브로커와 이벤트 브로커는 비동기 분산 시스템에서 서로 다른 문제를 해결하기 위해 설계되었다. 두 패러다임을 이해하고 적절히 선택하는 것이 시스템 아키텍처의 핵심이다.

 

Event Broker의 특징

Event Broker는 이벤트 시퀀스를 저장하는 구조로 설계되었다. 이벤트는 도착한 순서대로 큐 또는 토픽에 추가되며, 한번 기록된 이벤트는 불변(immutable)이다. 순서 변경이 불가능하며, 브로커는 토픽이나 큐를 구독하는 여러 타입의 구독자에게 이벤트를 전달한다.

프로듀서와 컨슈머는 서로를 알 필요가 없으며, 이벤트는 성공적으로 소비된 후에도 큐나 토픽에서 삭제되지 않고 수일에서 수주간 보관될 수 있다. 이는 이벤트 재처리나 새로운 컨슈머의 히스토리컬 데이터 처리를 가능하게 한다.

 

Message Broker의 특징

Message Broker는 서비스 또는 컴포넌트 간 통신을 위해 사용된다. 프로듀서로부터 받은 메시지를 컨슈머에게 비동기로 전달하여 애플리케이션 간 정보 교환을 제공한다.

큐 개념을 지원하며, 메시지는 일반적으로 짧은 시간 동안만 저장된다. 큐의 메시지는 컨슈머가 처리 가능해지는 즉시 소비되고, 성공적으로 처리된 후 삭제되는 것이 목적이다. 큐에서 메시지 처리 순서는 보장되지 않으며 변경될 수 있다.

 

Message Broker 적합 케이스

단기성 커맨드나 태스크 지향 처리에는 메시지 브로커가 적합하다. 아래 다이어그램은 RabbitMQ의 fanout 메시지 분산 방식을 보여주며, 각 서비스는 fanout 교환기에 연결된 자체 큐를 갖는다. 특히 RabbitMQ는 amqp 기반으로 신뢰성 높은 메시지 전달을 보장한다.

 

  • 이커머스에서 새 제품을 웹사이트에 추가하는 경우를 예로 들면, 제품 서비스는 새 제품 정보를 fanout 교환기로 전송하고, 교환기는 연결된 모든 큐(재고 서비스 큐, 검색 서비스 큐, 추천 서비스 큐)에 메시지를 전달한다.
  • 메시지가 성공적으로 소비되면 큐에서 삭제되며, 서비스는 메시지를 재처리하거나 보관할 필요가 없다.

 

Event Broker 적합 케이스

현재 또는 과거 이벤트와 같이 대량의 데이터를 처리해야 하는 경우, 즉 단일 또는 배치 방식으로 처리해야 할 때 이벤트 브로커가 적합하다.

 

 

  • 엔터테인먼트 평점 웹사이트에서 영화 작가와 감독 정보를 추가하는 경우, Kafka는 짧은 시간에 수억 건의 영화 데이터를 데이터 웨어하우스에서 추출할 수 있다.
  • 각 컨슈머 그룹은 영화 토픽 스트림을 독립적으로 처리하며, 필요시 과거 데이터를 재처리할 수 있다.

 

Poll vs Push 메커니즘

적절한 브로커를 선택하기 위해서는 다음 사항들을 고려하여 결정 하는 것이 좋다.

 

Kafka (Event Broker)

  • 컨슈머가 파티션으로 나뉜 토픽에서 메시지를 순서대로 bulk polling
  • 각 컨슈머는 하나 이상의 파티션 담당 (암묵적 스레딩 모델)
  • 파티션 레벨에서 메시지 처리 순서 보장
  • 컨슈머가 성공/실패 시나리오 모두 처리

RabbitMQ (Message Broker)

  • 브로커가 메시지를 컨슈머에게 push
  • 각 메시지를 원자적으로 처리 (명시적 스레딩 모델)
  • 브로커가 메시지 분산 관리
  • delayed 메시지, 우선순위 기본 제공

 

Error Handling

Kafka의 컨슈머 중심 접근

  • 에러 핸들링 책임이 컨슈머에게 있음
  • 메시지가 여러번 처리에 실패하는 poison pill 발생 시 컨슈머가 처리 시도 횟수 추적 필요
  • DLQ 토픽으로 메시지 전송 시 컨슈머가 프로듀서 역할 수행
  • 일부 엣지 케이스에서 메시지 손실 가능성 존재

RabbitMQ의 브로커 중심 접근

  • 브로커가 메시지 처리 실패 추적
  • poison pill은 자동으로 DLQ 교환기로 라우팅
  • 메시지 재큐잉 또는 검사용 DLQ 라우팅 지원
  • 처리 실패한 메시지 손실 방지 보장

 

Consumer Acknowledgment

Kafka

  • 컨슈머가 bulk 메시지의 offset 커밋
  • 기본값: 처리 성공 여부와 무관하게 자동 커밋 (메시지 손실 가능)
  • 수동 커밋 설정으로 변경 가능하지만 컨슈머가 실패 처리 구현 필요

RabbitMQ

  • 메시지 단위로 ack/nack 처리
  • 브로커가 재시도 정책 및 DLQ 관리
  • 수동 acknowledgment 설정 시 실패/타임아웃에 자동 재처리

 

기술 선택 시 고려사항

비동기 처리 시스템 선택 시 다음 질문에 답해야 한다. 두 패러다임 모두 at-least-once 전달을 보장하므로, 컨슈머는 멱등성을 고려하여 설계해야 한다. 즉 선택은 해결하려는 문제의 본질과 시스템 요구사항에 달려있다.

 

메시지 손실 허용 여부

  • 손실 불가: RabbitMQ의 브로커 중심 에러 핸들링이 유리
  • 손실 허용 가능하고 높은 처리량 필요: Kafka 고려

처리 순서 보장 필요성

  • 엄격한 순서 보장: Kafka의 파티션 기반 순서 보장 활용
  • 순서 무관: RabbitMQ의 유연한 메시지 분산 활용

과거 데이터 재처리

  • 필요: Event Broker (메시지 보관 및 offset 기반 재처리)
  • 불필요: Message Broker (처리 후 즉시 삭제)

운영 복잡도

  • 단순한 에러 핸들링 필요: RabbitMQ (브로커가 DLQ 관리)
  • 세밀한 제어 필요: Kafka (컨슈머가 모든 로직 구현)

 

참고 출처

 

 

 

시작 시간
종료 시간
학습 인증 - 디지털 필기
수강 인증

 

 

 

https://fastcampus.info/4oKQD6b

+ Recent posts