콘팅이 서비스를 잘게 쪼갠 이유

0. 개요

1. 대기큐, 티켓, 결제, 좌석, 카탈로그 서비스로 나눈 이유

티케팅 서비스에서 가장 많은 트래픽이 발생하는 비즈니스 로직은 예매 과정이라 생각합니다. 저희 백엔드 팀의 목표는 예매 과정에서 최대한의 가용성을 보장하도록 시스템을 설계하고자 했습니다.

먼저 예매시 유저 플로우를 분석하였습니다.

먼저 콘팅의 예매시 유저 플로우를 알아보겠습니다. 다음과 같은 유저 플로우를 가집니다.

짧은 시간동안 여러 페이지를 옮겨 다니면서 다양한 API를 호출합니다. 해당 플플로우에서는 짧은 시간 내에 DB IO, Network IO가 대량으로 일어납니다. 짧은 시간 동안 시스템에서 일어나는 IO의 양이 많을 경우 가장 좋은 해결책은 비동기 방식이라 생각했습니다.

<결제/티켓> 도메인을 따로 분리한 이유

하지만 결제 시스템을 비동기 방식으로 적용하기에는 리스크가 크다고 생각했습니다. 국내 결제 시스템 특성상 PG 사와의 통신을 통해 결제 로직이 구현되고 관련된 트랜잭션을 고려한다면, 비동기는 적합하지 않습니다.

그렇다고 같은 프로젝트 내에서 r2dbc(비동기 방식 ORM)와 jpa(동기 방식 ORM)를 동시에 사용할 경우 각 구현체는 서로 다른 패키지에 포함되어야만하고 설정 클래스에서도 컴포넌트 스캔할 대상을 일일히 지정해야했기 때문에 코드간 결합성이 증가하고 유지보수하기가 어려워집니다.

R2DBC와 JPA/JDBC 용 repo를 같은 패키지 내에 배치하면 한쪽 Repo를 못찾거나 지원 예외가 발생합니다. 때문에 아래 코드와 같이 repo패키지를 분리후 각각의 repo 패키지를 설정해야합니다. 위 같은 방식은 repo의 개수가 늘어날 때마다 Config 클래스에 불필요한 보일러 플레이트를 생성합니다. 특히 레이어별로 정리한 폴더구조가 아닌 도메인별로 정리한 폴더 구조일 경우 *의 활용도 불가능합니다.

@EnableJpaRepositories("com.c209.payment.domain.order.repository.sync")
@EnableR2dbcRepositories("com.c209.payment.domain.order.repository.async")

로직에 알맞은 비동기 방식, 동기 방식을 구현하고, 코드의 유지보수성을 늘리고 확장성을 고려하여 “좌석”과 “결제/티켓” 도메인을 분할하였습니다.

대기열 서비스를 추가한 이유

동기 방식을 채택한 “결제/티켓” 서비스의 핵심은 일정 트래픽이 넘지 않도록 제한해야합니다. 서비스 특성상 특정시간(예매 가능시간)이 되면 스파이크성 트래픽이 발생합니다. 스파이크성 트래픽이 발생할 경우, 동기 방식에서 치명적이라 판단하였습니다. 스파이크성 트래픽이 결제 서비스에 발생하는 것을 방지하기 위해, 대기열 서버를 추가하였습니다. 결제시 유저 플로우는 다음과 같이 바뀝니다.

결제와 티켓 도메인을 분할한 이유

서비스 특성상 결제와 티켓 발행 로직은 같은 서비스에 처리하여 트랜잭션을 보장하는 것이 맞습니다. 하지만 콘팅에서는 QR 기반 티켓을 제공합니다. 티켓의 검표과정 또한 처리해야하기 때문에 다음과 같은 특수한 상황에서 문제가 발생합니다.

A 가수의 공연 예매일과 B가수의 공연 당일이 겹쳤을 경우 

A가수의 공연을 예매하기 위해서 결제 API가 요청이, B가수의 티켓 검표를 진행하기 위해 티켓 API 요청이 동시에 발생합니다. 위와 같은 상황을 가정했을 때, 티켓 서비스와 결제 서비스를 안정적으로 처리하기 위해 두 서비스를 분리하였습니다.

2. 발생했던 문제와 해결한 방식

문제1. 데이터 정합성은 어떻게?

서비스를 분할할수록 비즈니스 로직에 따라 서비스 간의 데이터 정합성을 맞추는 것이 중요합니다.

  1. kafka를 활용한 비동기 큐로 결제서비스가 pub, 좌석 서비스와 티켓서비스가 Sub으로 구현하였습니다. 결제 발생시 좌석 서비스에서는 결제된 좌석이 사용 불가능한 좌석으로 업데이트, 티켓 서비스는 구매 유저와 좌석 정보를 바탕으로 티켓을 생성합니다.
  2. 최종 결제 수행 전 webhook을 활용해 해당 좌석이 여전히 구매 가능한 좌석인지 좌석 서비스에서 조회하는 로직을 추가했습니다.

카프카, 간단 요약

콘팅에서는 카프카를 비동기 메시징 큐로 활용합니다. 카프카는 분산 스트리밍 플랫폼으로, 대량의 데이터를 처리하고 실시간으로 전송하는 데 사용합니다. 모든 데이터는 로그 형식으로 파일 시스템에 기록됩니다. 시간순으로 완전히 정렬된 데이터 흐름(=레코드 시퀀스)를 보장합니다. 로그를 한곳에 모아 처리할 수 있도록 중앙집중화되어 있으며, 대용량 데이터를 수집하고 실시간 스트리밍으로 소비할 수 있습니다.

레코드는 프로듀서가 보낸 순서로 기록되어 순서가 보장됩니다. 레코드의 위치(offset)으로 컨슈머가 소비한 메시지의 위치를 표시합니다. 각 컨슈머 그룹마다 레코드의 위치를 가지고 있기 때문에 같은 소스에서 서로 다른 여러 개의 컨슈머들이 개별적으로 소비할 수 있습니다. 한 소스에서 여러 소비자가 손실이나 변형 없이 메시지를 소비할 수 있습니다.

이름설명
토픽데이터의 주제를 나타내며, 이름으로 분리된 로그입니다.
메시지를 보낼 때는 특정 토픽을 지정합니다.
파티션토픽은 하나 이상의 파티션으로 나눠질 수 있으며, 각 파티션은 순서가 있는 연속된 메시지의
로그입니다.

콘팅에서 카프카를 활용하는 방식

결제와 티켓 발급을 처리하는 방식으로 분산 시스템 이벤트 기반 아키텍처를 사용하고 있습니다. 콘팅의 분산시스템이 어떻게 나뉘어 있는지 간략하게 설명하고, 카프카를 팀에서 활용하는 방식을 소개하겠습니다.

결제 이벤트를 받아 티켓을 발급하는 로직, 결제 이벤트를 받아

문제2. 서로 다른 도메인의 데이터를 join 해야하는 경우

DB가 분리되면서 편하게 join이 불가능해집니다. join 쿼리가 발생할 경우 network io가 추가로 발생하게 됩니다. 관련해서 문제가 발생하는 API가 있었습니다. 바로 내가 가지고 있는 입장권 조회입니다.

내가 가진 입장권을 출력하기 위해서는 티켓 서비스에서 user_id로 내가 가진 티켓 정보(공연 id, 좌석 id)를 조회해와야합니다.

select seat_id, performance_id from ticket where user_id = :userId

조회해온 seat_id와 performance_id로 카탈로그 서비스 API에서 공연 정보를 조회해야합니다.

select {공연 메타데이터 칼럼들} from performance 
where performance_id in {앞선 쿼리에서 조회한 performance_id들}

보시는 바와 같이, 서비스간 결합성이 생기고 불필요한 네트워크 IO와 DB IO가 발생합니다. 이를 해결하기 위해서 모바일 로컬 DB인 Realm을 활용했습니다.

스플래시 화면에서 공연 정보를 먼저 조회해와 realm에 저장합니다.

사용자가 입장권 페이지에 접속할 때는 단순히 티켓 서비스에서 내가 가지고 있는 티켓의 공연 id만 조회해온 후 realm에서 공연 데이터를 찾아 페이지를 렌더링하는 방식으로 구현했습니다.