개요
우리는 하나의 서비스 안에서 여러 역할을 분리한 MSA 구조로 시스템을 운영해왔다.
Main, Inference, Auth, IAM, RAG, Embedding, Queue 등 각 서버는 명확한 책임을 가지고 있었고, 모든 서버는 클러스터 내부에서 독립적으로 배포되며 서로 HTTP 요청을 통해 통신했다.
이 덕분에 기능 단위로 개발과 배포를 분리할 수 있었고, 팀 단위 혹은 도메인 단위로 개발 속도를 유지하는 데에는 분명한 장점이 있었다.
다만 현재 운영 중인 서비스의 v1 구조를 돌아보면, 이를 완전한 MSA라고 부르기에는 한계가 있었다.
각 서비스가 분리되어 있기는 했지만, 서비스 간 통신은 대부분 동기 HTTP 호출에 의존하고 있었고, 비동기 이벤트 기반 처리는 거의 존재하지 않았다.
그렇기 때문에 Kafka와 같은 메시지 브로커를 사용하지 않았고, MSA라고 부르기에는 이벤트 기반 처리나 내부 통신 속도 등 아쉬운 지점이 많았다.
그래도 MSA가 가질 수 있는 최소한의 장점은 모두 가져갔기 때문에 서비스 규모가 작을 때는 충분히 실용적이었지만, 서비스가 커질수록 다음과 같은 고민이 자연스럽게 생기기 시작했다.
- 특정 서비스 장애가 연쇄적으로 전파될 가능성
- 트래픽 증가 시 동기 호출 구조의 한계
- 내부 서비스끼리 HTTP 호출
- 데이터 정합성과 이벤트 처리의 복잡성
그래서 이번에는 MSA 다운 MSA를 구축해보기 위해, Kafka 기반의 이벤트 드리븐 아키텍처, Outbox Pattern, 그리고 기존 프로젝트에서 사용했던 경험을 발전시켜 실제 규모 있는 기업의 실무 환경에서 사용될 법한 MSA 구조를 직접 설계하고 구현해보기로 했다.
프로젝트 구조
이번 프로젝트에서는 결제 기능을 중심으로 한 간단한 MSA 구조를 구현하는 것을 목표로 한다.
도메인은 단순하지만, 실제 MSA 환경에서 반드시 고민해야 할 요소들을 최대한 현실적으로 담아내는 데에 초점을 맞췄다.
전체 시나리오는 크게 두 단계로 구성되어 있다.
1. 주문 생성
클라이언트는 POST /api/order API를 통해 주문을 요청한다.
이 단계에서는 실제 결제가 발생하지 않고, 결제 대기 상태의 Order가 생성된다.
Stripe에서 결제 대시보드 링크를 발급받는 것과 비슷하게, 이 주문에 대해 이후 결제를 진행하면 된다는 맥락의 리소스를 만드는 단계라고 보면 된다.
2. 실제 결제 요청이다.
POST /api/order/{order_id} API를 호출하면 결제가 수행되고, 이 시점에 실제 재고 감소가 일어난다.
결제가 성공하면 Order 서비스는 Kafka를 통해 결제 성공 이벤트를 발행하고, 해당 이벤트는 다른 서비스들로 전파된다.
이후의 처리는 모두 비동기 이벤트 기반으로 이루어진다.
각 서비스는 결제 성공 이벤트를 구독하고, 자신의 책임 범위 내에서 필요한 작업을 비동기적으로 처리한다.
예를 들어 알림 발송, 로그 적재, 통계 집계, 후속 도메인 처리 등이 여기에 해당한다.

겉보기에는 매우 단순한 결제 시나리오처럼 보이지만, 이 구조 안에는 MSA에서 핵심적으로 고려해야 할 문제들이 모두 들어 있다.
- 비동기 이벤트 기반 처리
- 서비스 간 내부 통신 방식
- 동시 결제 요청 시 데이터 정합성 보장
- 결제와 재고의 트랜잭션 경계
- 이벤트 발행과 데이터 저장의 일관성
이러한 이유로, 이 결제 시나리오는 MSA 아키텍처의 본질적인 고민들을 실험하기에 매우 적합한 주제라고 판단했다.
단순히 동작하는 예제를 만드는 것이 아니라, 실무에서 실제로 사용될 수 있는 구조에 최대한 가까운 형태를 구현해보려고 한다.
프로젝트 코드
내부 통신은 HTTP가 아닌 gRPC로
gRPC는 HTTP/JSON 기반 통신과 비교했을 때 서비스 간 내부 통신에 특화된 프로토콜이다.
gRPC는 텍스트 기반 JSON 대신 바이너리 기반의 Protocol Buffers를 사용하기 때문에 직렬화·역직렬화 비용이 훨씬 낮고 네트워크 효율도 높다.
일반적으로 gRPC는 동일한 조건에서 HTTP/JSON 대비 수 배 이상의 성능 차이를 보인다고 알려져 있다. 특히 요청·응답이 잦고, 내부 서비스 간 호출이 많은 MSA 환경에서는 이 차이가 누적되면서 전체 시스템 지연(latency)에 큰 영향을 준다.
이런 이유로 외부 API는 HTTP, 서비스 간 내부 통신은 gRPC로 명확히 분리하는 전략을 선택했다. 내부 통신에서 발생하는 불필요한 지연을 최소화하고, 결제와 같이 응답 시간이 중요한 도메인에서 병목을 줄이기 위함이다.
물론 gRPC는 HTTP에 비해 구현 난이도가 더 높다.
단순히 컨트롤러를 하나 추가하는 방식이 아니라, proto 파일을 통해 요청 메시지, 응답 메시지, RPC 함수 시그니처를 명확히 정의해야 한다.
이 과정에서 클라이언트와 서버는 반드시 동일한 스펙을 공유해야 하며, 스키마 변경에 대한 관리도 필요해진다.
하지만 이 제약은 단점이기보다는 명확한 계약(Contract) 으로써 작동하여 proto 파일을 기준으로 서비스 간 인터페이스가 고정되기 때문에, 요청/응답 스펙이 암묵적으로 깨지는 문제를 방지할 수 있고 타입 안정성이 보장되며 서비스 간 커뮤니케이션 비용이 명확해진다.
실제로 규모 있는 MSA 환경에서는 이러한 이유로 외부와의 통신은 HTTP, 내부 통신은 gRPC를 채택하는 경우가 많다.
이번 프로젝트에서도 동일한 방향을 따르며, 성능과 구조적 명확성을 모두 확보하는 것을 목표로 한다.
Proto 정의
아래는 wallet 서비스와 order 서비스 간의 gRPC 통신 명세를 정의한 proto 파일이다.
문법 자체는 크게 어렵지 않고, 전반적으로 매우 직관적인 구조를 가지고 있다.
요청과 응답 메시지를 명확하게 정의할 수 있고, enum 타입도 지원하기 때문에 결제 결과와 같은 상태 값을 문자열이 아닌 명확한 타입으로 표현할 수 있다.
proto 파일은 빌드 시점에 실제 Java/Kotlin 코드로 변환되기 때문에, gRPC 함수 구현 역시 일반 인터페이스를 구현하는 것처럼 자연스럽게 진행할 수 있다.
syntax = "proto3";
package wallet;
option java_multiple_files = true;
option java_package = "com.example.wallet.grpc";
option java_outer_classname = "WalletProto";
service WalletService {
rpc DecreaseWallet (DecreaseWalletRequest)
returns (DecreaseWalletResponse);
}
message DecreaseWalletRequest {
string orderId = 1;
string userId = 2;
int64 amount = 3;
}
enum PaymentResult {
PAYMENT_RESULT_UNSPECIFIED = 0;
SUCCEED = 1;
INSUFFICIENT_BALANCE = 2;
INVALID_PAYMENT = 3;
FAILED = 4;
}
message DecreaseWalletResponse {
string orderId = 1;
string userId = 2;
int64 requestedAmount = 3;
PaymentResult paymentResult = 4;
int64 beforeBalance = 5;
int64 afterBalance = 6;
bool success = 7;
string message = 8;
}syntax = "proto3";
package order;
option java_multiple_files = true;
option java_package = "com.example.order.grpc";
option java_outer_classname = "OrderProto";
service OrderService {
rpc FindOrder (FindOrderRequest)
returns (FindOrderResponse);
}
message FindOrderRequest {
string orderId = 1;
string userId = 2;
}
enum OrderStatus {
ORDER_STATUS_UNSPECIFIED = 0;
CREATED = 1;
PAID = 2;
FAILED = 3;
}
message FindOrderResponse {
string orderId = 1;
string aggregateId = 2;
string userId = 3;
int64 amount = 4;
OrderStatus status = 5;
bool exists = 6;
string message = 7;
}gRPC Server
gRPC도 하나의 통신 프로토콜이기 때문에 포트 정의와 통신 주소 설정이 필요하다.
Spring 환경에서는 application.yml에 아래와 같이 설정할 수 있다.
여기서 정의한 order, wallet과 같은 서비스 이름은 Client 쪽에서 직접 참조되는 식별자이기 때문에, 실제 도메인 의미를 잘 드러내는 이름으로 지정하는 것이 중요하다.
grpc:
server:
port: 9090
client:
order:
address: "static://localhost:9091"
negotiationType: plaintextgRPC 통신을 사용하기 위해서는 Server와 Client를 모두 명시적으로 구현해야 한다.
먼저 서버 측 코드부터 살펴보자.
빌드 시 proto 파일을 기준으로 인터페이스가 자동 생성되고, 이를 실제 비즈니스 로직이 담긴 Service를 주입받아 구현하면 된다.
@GrpcService
class WalletGrpcServer(
private val walletService: WalletServiceFacade
) : WalletServiceGrpcKt.WalletServiceCoroutineImplBase() {
override suspend fun decreaseWallet(
request: DecreaseWalletRequest
): DecreaseWalletResponse {
val paymentResponse: PaymentResponse = walletService.decreaseAmount(
userId = request.userId,
orderId = request.orderId,
)
return DecreaseWalletResponse.newBuilder()
.setOrderId(paymentResponse.orderId)
.setUserId(paymentResponse.userId)
.setRequestedAmount(paymentResponse.requestedAmount)
.setPaymentResult(paymentResponse.paymentResult)
.setBeforeBalance(paymentResponse.beforeBalance)
.setAfterBalance(paymentResponse.afterBalance)
.setSuccess(paymentResponse.paymentResult == PaymentResult.SUCCEED)
.setMessage(paymentResponse.paymentResult.toString())
.build()
}
}gRPC Client
이제 Order 서비스에서는 Wallet 서비스와 통신하기 위해 HTTP 호출이나 DB 접근이 아닌 gRPC Client를 사용하게 된다.
다만, gRPC Client를 직접 사용하는 대신 기존 Service 구조와 동일한 형태로 사용할 수 있도록 한 단계 더 감싸는 Facade를 둔다.
@Component
class WalletGrpcClient {
@GrpcClient("wallet")
lateinit var stub: WalletServiceGrpcKt.WalletServiceCoroutineStub
suspend fun decrease(
orderId: String,
userId: String,
amount: Long
): DecreaseWalletResponse {
val request = DecreaseWalletRequest.newBuilder()
.setOrderId(orderId)
.setUserId(userId)
.setAmount(amount)
.build()
return stub.decreaseWallet(request)
}
}ServerFacade에서 실제 Service와 같은 방식으로 사용할 수 있도록, gRPC Client를 주입받아 gRPC용 서비스를 만들어주었다.
@Component
class WalletService(
private val walletGrpcClient: WalletGrpcClient
) {
fun decreaseBalance(userId: String, orderId: String, amount: Long): DecreaseWalletResponse =
runBlocking {
walletGrpcClient.decrease(
orderId = orderId,
userId = userId,
amount = amount,
)
}
}gRPC Client 활용
따라서 Order Service에서 Wallet Service는 내부적으로 데이터베이스가 아닌 gRPC 통신으로 이루어져 있으나, Service Facade에서는 그 정보를 몰라도 똑같은 인터페이스로 통신할 수 있다.
@Service
class OrderServiceFacade(
private val orderService: OrderService,
private val walletService: WalletService
) {
fun handlePayment(userId: String, orderId: String): PaymentResponse {
val orderResponse = orderService.findCreatedOrderByOrderId(userId, orderId)
val decreaseWalletResponse = walletService.decreaseBalance(
orderId = orderResponse.orderId,
userId = userId,
amount = orderResponse.amount,
)
return PaymentResponse(
userId = userId,
orderId = decreaseWalletResponse.orderId,
aggregateId = orderResponse.aggregateId,
paymentResult = decreaseWalletResponse.paymentResult,
requestedAmount = decreaseWalletResponse.requestedAmount,
beforeBalance = decreaseWalletResponse.beforeBalance,
afterBalance = decreaseWalletResponse.afterBalance,
success = decreaseWalletResponse.success,
message = decreaseWalletResponse.message,
)
}
}여기까지 구현하면 서비스 간 동기 통신 로직은 완성된다.
결제 요청 시 Wallet Service로 gRPC 호출이 전달되고, 실제 재고(잔액) 감소와 응답까지 정상적으로 처리된다.
하지만 현재 구조에서는 결제가 완료되더라도, 비동기 이벤트 처리가 구현되어 있지 않기 때문에 Order는 여전히 진행 중 상태에 머물게 된다.
다음 글에서는 Outbox 패턴을 도입해 이벤트를 데이터베이스에 안전하게 저장하고, Kafka 장애 상황에서도 이벤트가 유실되지 않도록 비동기 이벤트 처리 구조를 완성해볼 예정이다.

답글 남기기