서론
최근에 만든 CGV 대규모 트래픽 대응 예매 서비스에서 Kafka가 쓰였지만, 서비스를 분리하지 않고 한 어플리케이션에 모두 구현 했다. 사실상 kafka 이벤트 기반 아키텍쳐와 부하 분산의 이점을 제대로 활용하지 못한 것이다.(프로젝트 기한이 정해져있고, 개발 영역을 보지않고 인프라 영역만 평가 요소가 들어가기 때문에 급하게 만들긴 했다)
대개 Kafka는 MSA 구조에서 각 서비스들을 연결하기 위한 이벤트 메시지 큐로써 활용되기에, 모놀로그 식으로 구현된 서비스를 MSA로 바꿔보고 싶었다.
우선 이번 글에서는 비지니스를 분리해 서비스들의 연결을 카프카의 메시지로 처리하고, 스프링에서 제공하는 MSA 라이브러리를 통해 아키텍처를 구성하는 것을 목적으로 한다.
서비스 분리

현재 ERD는 다음과 같다. 이를 영화와 상영 스케쥴, 그리고 좌석까지 관리하는 Movie 서비스와 예매를 관리하는 Reservation 서비스로 분리하려한다. 이를 위해서는 좌석 엔티티와 예매 정보 엔티티의 관계를 끊고 독립적인 서비스로 만들어줘야한다.

이렇게 Reservation 테이블에서 외래키를 없애고 일반 컬럼으로 넣어준 다음, 코드에서도 관계를 끊고 비지니스 로직도 분리를 해야한다.
@Transactional
public ReservationRes createReservation(String userName, Long seatId){
Seat seat=reservationRepository.findSeatBySeatIdWithRock(seatId)
.orElseThrow(() -> new CustomException(StatusCode.SEAT_NOT_EXIST));
if(!seat.getIsReserved())
seat.soldout();
else throw new CustomException(StatusCode.SEAT_SOLD_OUT);
Reservation reservation= Reservation.builder()
.userName(userName)
.status(Status.RESERVED)
.seat(seat)
.build();
return ReservationRes.from(reservationRepository.save(reservation));
}
이전의 예매 서비스 로직이다. 흐름을 간단하게 보면 다음과 같다.
- 좌석 조회(비관적 락) 후 예약 여부 확인 -> 좌석 예약 처리 -> 예매 생성
보다시피 하나의 역할만 하지않고 좌석과 예매에 관한 비지니스를 동시에 처리한다.
이를 MSA 구조로 바꾸려면,
- MovieService - 좌석 조회(비관적 락) 후 예약 여부 확인 -> 좌석 예약 처리 -> Kafka 메시지 큐에 예매 생성 이벤트 메시지 생성
- ReservationService - @KafkaListener를 통해 예매 생성
위와 같이 처리해야한다.
구현
MovieService(예매 이벤트 생성)
@Transactional
public void createReservationRequest(Long scheduleId, Long seatId, String userName){
Seat seat=seatRepository.findBySeatIdWithRock(seatId)
.orElseThrow(() -> new CustomException(StatusCode.SEAT_NOT_EXIST));
if(!seat.getIsReserved())
seat.soldout();
else throw new CustomException(StatusCode.SEAT_SOLD_OUT);
reservationProducer.sendReservationRequest(scheduleId,seatId,userName);
}
좌석 조회(비관적 락) 후 좌석 예약처리, reservationProducer을 통해 카프카에 메시지를 보낸다.
ReservationProducer
@Component
@RequiredArgsConstructor
@Slf4j
public class ReservationProducer {
private final KafkaTemplate<String, ReservationEvent> kafkaTemplate;
public void sendReservationRequest(Long scheduleId, Long seatId, String userName) {
ReservationEvent request = new ReservationEvent(scheduleId, seatId, userName);
String topic = "reservation-" + scheduleId;
kafkaTemplate.send(topic, seatId.toString(), request); // seatId 기준 파티셔닝
log.info("예메 요청 전송됨: 사용자={}, 좌석ID={}, 스케줄ID={}, 토픽={}",
userName, seatId, scheduleId, topic);
}
}
scheduleId(영화 상영스케쥴)로 토픽을 구분하고, seatId(좌석 id)로 파티션을 나눠 처리를 빠르게 할 수 있도록 했다.
ReservationService- ReservationConsumer
@Component
@RequiredArgsConstructor
@Slf4j
public class ReservationConsumer {
private final ReservationRepository reservationRepository;
@KafkaListener(topicPattern = "reservation-.*", groupId = "reservation-group")
@Transactional
public void consume(ConsumerRecord<String, ReservationEvent> record) {
ReservationEvent request = record.value();
log.info("예약 이벤트 수신: 사용자={}, 좌석ID={}, 스케줄ID={}", request.getUserName(), request.getSeatId(), request.getScheduleId());
Reservation reservation= Reservation.builder()
.userName(request.getUserName())
.status(Status.RESERVED)
.seatId(request.getSeatId())
.build();
reservationRepository.save(reservation);
log.info("예약 저장 완료: 사용자={}, 좌석ID={}", request.getUserName(), request.getSeatId());
}
}
reservation-.*로 들어온 토픽을 구분해 메시지를 가져와 예약을 생성한다.
스프링 MSA 라이브러리
이제 서비스 분리를 통해 MovieService, ReservationService 두 개의 RestApi와 서비스가 존재한다.
여기에 더해, MSA를 구축하기 위해서는 API 게이트웨이가 요청을 받고 각 서비스에 맞게 분산하여 정확한 위치로 전달해줘야한다.
다음과 같은 이미지를 예로 들 수 있다.

이와 같은 프로세스를 구축하기 위해서는 호출할 서비스를 찾는 Service Discovery가 필요하고, API 게이트웨이는 Service Discovery를 연결하여 로드밸런싱 할 수 있다.
이를 위해 Spring Cloud Netflix Eureka를 활용하여 MSA 를 구성해보고자 한다.
Eureka는 서비스 디스커버리 서버로, 각 마이크로서비스가 자신의 위치를 등록하고, 다른 서비스(예: API Gateway)가 이를 찾아서 호출할 수 있도록 도와준다.
Eureka 서버 구성
build.gradle
implementation 'org.springframework.cloud:spring-cloud-starter-netflix-eureka-server'
위 의존성을 추가한 뒤,
@SpringBootApplication
@EnableEurekaServer
public class EurekaApplication {
public static void main(String[] args) {
SpringApplication.run(EurekaApplication.class, args);
}
}
@EnableEurekaServer 애노테이션을 추가하여 Eureka 서버로서 동작하게 한다.
application.properties
spring.application.name=eureka
server.port=8761
eureka.client.register-with-eureka=false
eureka.client.fetch-registry=false
이 어플리케이션은 서비스 서버나 API Gateway 서버가 아니므로,
eureka.client.register-with-eureka=false
eureka.client.fetch-registry=false
를 통해 자신은 eureka 서버에 등록하지 않는다.

서버에 접속하면 이렇게 어떤 서비스가 등록돼있는 지 볼 수 있다.
Api Gateway 서버 구성
build.gradle
implementation 'org.springframework.cloud:spring-cloud-starter-gateway'
implementation 'org.springframework.cloud:spring-cloud-starter-netflix-eureka-client'
build.gradle에 위 의존성을 추가한다.
application.properties
spring.application.name=gateway
server.port=8500
eureka.client.register-with-eureka=true
eureka.client.fetch-registry=true
eureka.client.service-url.defaultZone=http://localhost:8761/eureka
eureka 서버에 Api gateway 서버를 등록하고 url도 등록을 해준다.
eureka.client.fetch-register=true를 통해 Eureka 서버로부터 등록된 서비스 목록을 가져오도록 설정 한다. API Gateway는 Eureka서버로부터 등록된 서비스들의 목록을 얻을 수 있다.
이로써 Api Gateway는 다른 서비스 어플리케이션의 url을 하드코딩할 필요 없이, eureka 서버에 등록돼있는 서비스의 이름으로 요청을 분산할 수 있게된다.
ApiGateWayConfig
@Configuration
public class ApiGateWayConfig {
@Bean
public RouteLocator customRouteLocator(RouteLocatorBuilder builder) {
return builder.routes()
.route("movie-service", r -> r.path("/api/movies/**", "/api/schedules/**", "/api/seats/**")
.uri("lb://movie-service"))
.route("reservation-service", r -> r.path("/api/reservations/**")
.uri("lb://reservation-service"))
.build();
}
}
API Gateway에 들어오는 요청을 경로별로 서비스에 라우팅하는 설정이다. 유레카 서버를 둠으로 써, uri에 서비스 이름으로만 라우팅할 수 있게된다.

게이트웨이 서버를 실행하니 유레카 서버에 GATEWAY 서비스가 등록되었다!
Service 서버 구성
각 서비스 서버에도 유레카 등록 설정을 해주면 된다.
build.gradle
implementation 'org.springframework.cloud:spring-cloud-starter-netflix-eureka-client'
Spring Initializr를 통해 프로젝트를 만들지 않고 의존성을 추가하면 다음을 추가해줘야한다.
ext {
set('springCloudVersion', "2024.0.1")
}
dependencies {
implementation 'org.springframework.cloud:spring-cloud-starter-netflix-eureka-client'
}
dependencyManagement {
imports {
mavenBom "org.springframework.cloud:spring-cloud-dependencies:${springCloudVersion}"
}
}
application.properties
spring.application.name=movie-service
server.port=8501
eureka.client.register-with-eureka=true
eureka.client.fetch-registry=true
eureka.client.service-url.defaultZone=http://localhost:8761/eureka
설정을 마친 뒤 유레카 서버에 들어가면 다음과 같이 모든 서비스가 등록이 된 걸 볼 수 있다.

테스트
이제 API Gateway를 통해 요청이 각각의 서비스로 잘 라우팅 되는지 Postman을 통해 테스트 해보겠다.

API Gateway로 요청을 보냈지만 movie 서비스가 요청을 받는걸 볼 수 있다.

또한 예매 내역 조회도 Reservation 서비스로 요청이 가는걸 볼 수 있다.
마무리
영화 서비스와 예약 서비스를 MSA 구조로 분리하고, 서비스들 간의 통신을 Kafka 메시지 큐를 통해 이벤트 기반 아키텍처로 구성해보았다.
하지만 이벤트 기반 비동기 통신에서는 각 서비스가 독립적으로 동작하기 때문에, 한 서비스에서 성공한 작업이 다른 서비스에서 실패할 경우 데이터 불일치 문제가 발생할 수 있다.
이를 해결하기 위해서는 최종적 일관성을 보장하는 보상 트랜잭션(Compensating Transaction)이나 사가(Saga) 패턴과 같은 분산 트랜잭션 관리 기법을 도입하는 것이 필수적이다.
따라서 다음 글에는 비동기 통신에서 발생할 수 있는 문제점들에 대해 알아보고 해결해보도록 하겠다.
'MSA' 카테고리의 다른 글
| [MSA] MSA 구조에서의 SAGA 패턴과 분산 트랜잭션 (0) | 2025.06.01 |
|---|