당근페이 금융 거래 내역 Aggregator 개발기

Winter You
당근 테크 블로그
27 min readAug 8, 2024

안녕하세요. 저는 당근페이 머니 서비스팀의 서버 엔지니어 윈터(Winter.you)라고 해요. 머니 서비스팀에서는 당근머니를 활용한 동네 금융 생태계를 만들고 있어요. 오늘은 당근페이의 거래 내역이 가지고 있던 구조적 문제를 Aggregator 개발을 통해 개선해 본 경험을 소개해 보려고 해요.

Aggregator 도입 배경

당근페이의 기존 거래 내역 구조

혹시 당근 앱에서 중고거래를 해보셨나요? 중고거래를 할 때 당근페이로 송금하면 간편하게 돈을 주고받을 수 있는데요!

기능이 매우 단순했던 초기 당근페이 거래 내역. 관련 거래내역을 당근페이 홈에서 확인할 수 있답니다! 😆

중고거래 송금은 당근페이의 첫 번째 서비스이고, 아직까지도 머니 서비스팀에서 가장 집중하고 있는 서비스 중 하나예요. 그러다 보니 초창기부터 최근까지 거래 내역은 중고거래 송금 내역을 중심으로 고도화됐어요.

이러한 서비스 역사가 개발 구조에도 그대로 반영되어 있어요. 거래 내역은 결국 송금 내역만 보여주면 되기 때문에, 클라이언트가 단순하게 송금 서버를 호출하여 유저의 거래 내역을 보여주는 방식으로 구현되어 있었어요.

신규 서비스 등장에 따른 개선 필요성

하지만 중고거래 송금 기능만 있던 과거에 비해 현재는 당근 앱 내 결제, 당근머니 체크카드 등 서비스가 다양해졌어요. 당근페이 서비스는 여러 마이크로 서비스로 구성되기 때문에 덩달아 당근페이 서버의 종류도 많아졌죠.

위에서 소개한 거래 내역의 개발 구조는 개발할 당시에는 분명 자연스러운 구조였을 거예요. 하지만 서비스의 종류가 다양해지면서 명확한 구조적인 한계로 다가왔어요. 거래 내역을 보여주는 웹 클라이언트는 송금 서버만을 바라보고 있었기 때문에 당근 카드 서버, 결제 서버 등 다양한 서버가 제공하는 거래 내역을 추가 개발 없이는 보여줄 수 없었어요.

개선 방향 고민

다양한 서비스의 거래 내역을 통합된 형태로 보여주기 위해서는, 아래와 같이 클라이언트가 신규 서비스를 모두 일일이 연동하는 방법을 고려해 볼 수 있어요. 하지만 이 방법은 신규 서버가 늘어나게 되면 불가피하게 클라이언트의 추가적인 작업으로 이어져요. 또한 클라이언트가 각 서버가 제공하는 거래 내역을 어떻게 합쳐서 어떤 순서로 보여줄지도 결정해야 해요. 게다가 연동해야 할 서버가 늘어나면서 클라이언트의 구현이 복잡해지고 대상 서버를 관리해야 되는 등 유지 보수의 어려움을 만들기도 하죠.

결국 팀에서는 새로운 시스템이 필요하다는 판단을 내렸어요. 그 시스템은 신규 서비스의 증가가 클라이언트의 추가 연동 작업으로 이어지지 않도록 하고, 여러 도메인을 합쳐 일관된 인터페이스로 클라이언트에게 내역을 제공해야 해요.

결론적으로 기존 거래 내역의 문제 해결 요구사항을 아래와 같이 정의했어요.

  • 신규 서비스 증가에 따라 해당 서비스에 제공하는 내역이 거래 내역에 잘 반영되어야 함.
  • 그러나 신규 서비스 증가로 인한 작업이 클라이언트 엔지니어의 작업으로 전파되지 않아야 함.

Aggregator 개발 과정

위에서의 요구사항을 충족하기 위한 방안으로 Aggregator를 도입하기로 결정했어요. Aggregator가 구체적으로 무엇이고 어떻게 개발했는지 그 과정과 제 고민을 공유해 드릴게요.

Aggregator란?

클라이언트가 얻고자 하는 정보가 하나의 마이크로 서비스에서 다루는 도메인에 국한되지 않을 때, Aggregator는 여러 도메인의 정보를 가공하여 적절한 형태로 응답해요.

예를 들어 송금 서버가 다루는 송금 내역, 결제 서버가 다루는 결제 내역 등을 합쳐 클라이언트에게 새로운 형태의 응답을 제공할 수 있어요. 도메인 간 융합으로 새로운 가치를 만들어 내는 거죠.

거래 내역 요건

사실 Aggregator를 도입하더라도 거래 카테고리가 많아지는 것일 뿐 디자인이나 세부 구현 요건이 변하지는 않아요. 따라서 도입 후에도 기존의 핵심 기능을 유지하는 것이 중요했어요.

기존 당근페이 거래 내역의 요건은 아래와 같아요.

  • 무한 스크롤을 지원해요. 유저의 모든 내역을 Pagination을 통해 처음부터 끝까지 확인할 수 있어야 해요. 현재는 [1] Cursor Based Pagination을 사용하고 있어요.
  • 전체, 송금, 충전, 당근 카드, 결제 등의 거래 카테고리를 가지고 있고, 클릭 시 해당 카테고리의 거래 내역만 볼 수 있어요.
  • 가장 최근에 생성된 거래를 먼저 보여줘요. (거래 시간 기준 내림차순으로 정렬해요.)
  • 특정 거래 내역을 클릭하면 더 자세한 정보를 담고 있는 거래 내역 상세 페이지로 이동해요.

새로 구현할 Aggregator는 위에서 소개한 기존 거래 내역의 요건을 만족하면서, 동시에 당근페이의 다양한 서버에서 제공하는 모든 금융 거래 내역을 유저에게 보여주어야 했어요.

Aggregator 구현 방안 선택지

Aggregator는 당근 카드, 송금, 결제 등 다양한 유형의 거래 내역을 웹 클라이언트에 일관된 인터페이스로 제공하기 위해 거래 데이터를 조회해야 해요. 거래 데이터를 조회하는 방법은 여러 가지가 있지만, 여기서는 간단히 두 가지 방법만 소개할게요.

  1. 마이크로 서비스에 저장된 데이터를 직접 조회
  • 클라이언트가 Aggregator에서 제공하는 API를 호출하면, Aggregator는 여러 개의 내부 서버에 API 요청을 보내요. 그 결과를 가공하여 클라이언트에 응답해요.

2. 거래 이벤트를 통해 동기화된 데이터를 조회

  • 각 내부 서버에서 거래가 발생할 때 이벤트 큐(ex. Kafka, RabbitMQ 등)에 거래 내역 이벤트를 발행해요. 이 이벤트를 Aggregator 서버가 구독하여 전용 테이블에 내역을 적재해요.
  • 클라이언트가 거래 내역 조회를 요청하면, Aggregator는 테이블에 미리 적재된 데이터를 조회해서 제공해요.

각 선택지의 장단점 비교

1번 방식의 장점은 신규 기술 스택 등을 고려하지 않고 HTTP 통신 방식만으로 구현이 가능하다는 거예요. 저는 개인적으로 HTTP 통신 방식이 익숙해서, 좀 더 빠르게 구현이 가능할 것으로 보였어요.

단점의 경우 Aggregator로 거래 내역 조회가 발생하면, 해당 트래픽이 각 내부 서버로 그대로 전파돼요. 대규모 트래픽이 발생할 시 해당 트래픽의 영향으로 모든 서버에 영향을 미칠 수 있어요. 다양한 서버 중 하나라도 장애가 발생하면 영향도가 커질 수 있는 구조예요.

2번 방식의 장점은 거래 내역 조회의 트래픽이 각 내부 서버로 이어지지 않아서, 예기치 못한 장애가 각 서버로 전파될 가능성이 낮아요.

단점은 HTTP 통신으로만 구현하는 1번 방식에 비해 복잡해요. 기존 거래 내역에서는 사용하지 않았던 이벤트 큐를 도입해야 하고, 모든 내부 서버의 거래 내역을 쌓을 수 있는 테이블 모델링이 필요했기 때문이에요. 또 이미 실시간으로 쌓이고 있는 기존 거래 내역을 Aggregator에서 관리하는 테이블에 저장하기 위해 데이터 마이그레이션도 고려해야 했어요.

또, 별도의 장치가 없다면 통신 상황에 따라 데이터 저장이 누락될 가능성이 존재해요. 1번 방식에서는 항상 내부 서버로 실시간 조회하는 방식이기에 누락에 대한 걱정이 크게 없지만, 2번 방식에서는 통신 장애가 발생하여 이벤트 큐로부터 데이터를 Consume 하지 못했을 경우나 Aggregator 내부 장애로 인해 데이터 저장에 실패할 경우를 대비하여 복구 장치를 새롭게 마련할 필요가 있어요.

결론적으로 개발 방향성을 1번 방식으로 정했어요. 유저에게 가치를 빠르게 전달하는 것이 중요하기 때문에, 상대적으로 구현하기 용이하고 간단한 방식을 선택하는 게 좋겠다고 판단했어요.

Aggregator 세부사항 구현 과정

이제부터는 구체적으로 어떤 고민을 하며 Aggregator의 세부사항들을 구현해 나갔는지 소개해 드릴게요.

1. Pagination 기능 구현하기

첫 번째로, Aggregator를 도입하고도 기존 거래 내역과 동일한 유저 경험을 줄 수 있도록 Pagination을 구현했어요.

Cursor Based Pagination의 구성 요소

— cursor: 클라이언트 입장에서 조회하고자 하는 데이터의 기준이 되는 값이에요. 서버에선 이 값을 넘겨받아 기준 값보다 큰 거래 내역, 혹은 기준 값보다 작은 거래 내역을 응답해요.

— page_size: 클라이언트 입장에서 응답받기를 원하는 아이템의 개수예요. (ex. 클라이언트가 page_size=5로 설정하여 API 호출하면 서버는 거래 내역 5개를 응답해요.)

기존에는 위와 같이 클라이언트와 송금 서버 간의 Pagination만 실행했어요. 하지만 이제는 Aggregator와 다양한 서비스 간의 Pagination이 가능해야 했기 때문에, 이를 구현하기 위한 개발 방식을 결정해야 했어요.

Aggregator 도입 후의 Pagination은 간단하게 클라이언트로부터 전달받은 cursor, page_size를 그대로 내부 서버에 전파하는 방식으로 구현했어요. 그 과정에서 아래와 같은 고민을 했어요.

  • 적절한 cursor 값을 결정해야 해요.

클라이언트가 전달하는 1개의 cursor값이 모든 서버에서 유의미하게 동작해야 하기 때문에, cursor 값으로 활용할 수 있을 만한 존재를 찾아야 했어요.

당근 카드, 송금, 결제 등 당근페이의 모든 서비스에서 발생하는 거래는 [2] Snowflake ID 기반으로 생성된 id를 가지고 있어요. 그래서 id만으로 거래가 언제 생성됐는지 식별할 수 있어요.

그래서 id를 cursor값으로 활용했을 때 웹 클라이언트가 요청한 cursor값을 그대로 내부 서버로 전파해도 문제없이 pagination을 수행할 수 있었어요. 또, cursor보다 작은 거래 내역을 조회하면 id 자체가 거래 시간의 timestamp를 내포하고 있기에 요건에 따른 정렬 순서(거래 시간 기준 내림차순)를 보장하기도 용이했어요. 따라서 cursor에 쓰일 값으로 각 거래 내역의 id를 활용하기로 결정했어요.

  • 클라이언트가 원하는 개수만큼만 내역을 응답해야 해요.

내부 서버는 위 그림과 동일하게 3개가 있다고 가정할게요. 만약 클라이언트가 아래와 같이 id를 기반으로 한 cursor와 page_size를 함께 요청하면,

curl -X 'GET' \
'https://{host}/transactions?cursor=1000000000000&page_size=5' \

요청을 받은 Aggregator는 cursor=1000000000000 와 page_size=5를 그대로 파라미터로 사용하여 3개의 내부 서버로 조회해요. 모든 서버가 거래 내역 5개를 잘 응답해 줬다면, Aggregator는 아래와 같이 거래 내역 15개(=5개 * 3개의 서버)를 가지고 있게 돼요.

{
"transactions": [
{
"id": "1000000000001",
"service_name": "CARD",
...
},
{
"id": "1000000000013",
"service_name": "CARD",
...
},
{
"id": "1000000000002",
"service_name": "PAYMENT",
...
},
,
... (생략)
,
{
"id": "1000000000015",
"service_name": "TRANSFER",
...
}
]
}

이때, 거래 시간 역순으로 정렬한 뒤 page_size만큼만 잘라서 응답하면, 클라이언트가 요청한 개수만큼 내역을 응답할 수 있게 돼요. 클라이언트에 응답하는 next_cursor도 잘라낸 5개의 내역 중 마지막 내역의 id를 응답하면 되고요. (참고로 한 번 조회한 데이터는 응답을 마치면 메모리에서 지우기 때문에, 다음 Pagination 요청 시 중복해서 조회되는 데이터가 발생할 수 있어요. 하지만 이 정도는 성능, 비용 측면에서 크게 문제 되지 않는다고 생각했어요.)

최종적으로는 아래와 같이 표현된 데이터를 클라이언트에게 응답하게 돼요.

{
"next_cursor": "1000000000011", <-- 마지막 거래 내역의 id
"contents": [
{
"id": "1000000000015",
...
},
{
"id": "1000000000014",
...
},
{
"id": "1000000000013",
...
},
{
"id": "1000000000012",
...
},
{
"id": "1000000000011",
...
}
]
}

2. 카테고리별 호출해야 하는 내부 서버 관리

위에서 잠시 소개했듯, 거래 내역에는 다양한 카테고리가 존재해요. 카테고리에 따라 어떤 내부 서버를 호출해야 하는지가 달라질 수 있어요.

아래 내용은 실제 케이스가 아니에요. 예시를 설명하기 위해 가상의 규칙을 정해보았어요.

아래와 같이 카테고리별로 호출해야 하는 서버를 정의할 수 있다고 가정할게요. (서버는 송금, 결제, 당근 머니, 당근 카드 총 4개 존재)

  • 전체: 송금, 결제, 당근 머니, 당근 카드 모든 서버 호출 필요
  • 송금: 송금 서버만 호출
  • 충전 : 당근 머니 서버만 호출
  • 당근 카드 : 당근 카드/ 송금 서버 호출
  • 결제 : 결제 서버만 호출

먼저, 카테고리는 개수와 각 요소의 정의가 명확하기 때문에 enum으로 표현해요.

enum class TransactionViewCategory(
val displayOrder: Int,
val displayName: String,
) {
ALL(0, "전체"),
TRANSFER(1, "송금"),
DEPOSIT(2, "충전"),
CARD(3, "당근 카드"),
PAYMENT(4, "결제"),
;

fun getSupportedServiceNames(): List<ServiceName> = when (this) {
ALL -> listOf(ServiceName.TRANSFER, ServiceName.PAYMENT, ServiceName.MONEY, ServiceName.CARD)
TRANSFER -> listOf(ServiceName.TRANSFER)
DEPOSIT -> listOf(ServiceName.MONEY)
CARD -> listOf(ServiceName.TRANSFER, ServiceName.CARD)
PAYMENT -> listOf(ServiceName.PAYMENT)
}
}

enum class ServiceName {
TRANSFER,
PAYMENT,
MONEY,
CARD,
;
}

위와 같이 카테고리 정보를 TransactionViewCategory 로 정의하고, 내부 서버를 ServiceName이라는 별도의 enum으로 정의해서 카테고리별로 어떤 내부 서버를 호출해야 되는지를 판단하는 메서드를 정의했어요.

이 카테고리 enum은 클라이언트가 거래 내역을 요청할 때 호출하는 API 명세의 파라미터 값에 포함되어 있어요. 따라서 해당 카테고리 정보로 ServiceName 목록을 판단하여 적절한 거래 내역을 만들어 응답하도록 했어요.

@GetMapping("/transactions")
fun paginateTransactions(
@RequestParam("user_id") userId: String,
@RequestParam("cursor") cursor: String,
@RequestParam("page_size") pageSize: Int,
@RequestParam("category") category: TransactionViewCategory,
): Response {
val targetServiceNames = category.getSupportedServiceNames()
val transactionViews: List<TransactionView> = targetServiceNames.map { serviceName ->
when (serviceName) {
ServiceName.TRANSFER -> // 송금 서버를 호출해서 카테고리에 걸맞은 내역을 만든다.
ServiceName.PAYMENT -> // 결제 서버를 호출해서 카테고리에 걸맞은 내역을 만든다.
ServiceName.MONEY -> // 당근 머니 서버를 호출해서 카테고리에 걸맞은 내역을 만든다.
ServiceName.CARD -> // 당근 카드 서버를 호출해서 카테고리에 걸맞은 내역을 만든다.
else -> {
logger.error("NotSupported ServiceName - serviceName($serviceName)")
emptyList()
}
}
}.flatten()
...
}

3. 내부 서버 호출 병렬 처리

거래 내역을 제공하기 위해 동시에 여러 개의 내부 서버로 API를 호출해야 하는 경우가 많아요. 내부 서버를 순차적으로 호출하게 되면, 대략 (내부 서버의 수 * 각 서버의 평균 응답 시간) 만큼의 조회 시간이 발생해요. 송금 서버만 조회했던 기존 거래 내역 조회보다 응답 시간이 늘어날 수 있어요.

Aggregator에서의 내부 서버 호출은 상호 독립적이기 때문에 동시에 호출해도 괜찮아요. 그래서 이 부분은 코루틴을 활용해 병렬로 호출하도록 개선해서 내역 조회의 응답 시간을 단축시켰어요.

결과적으로 유저는 Aggregator가 도입되더라도 기존 거래 내역의 응답 시간과 큰 차이 없이 거래 내역을 볼 수 있었어요.

@GetMapping("/transactions")
suspend fun paginateTransactions(
@RequestParam("user_id") userId: String,
@RequestParam("cursor") cursor: String,
@RequestParam("page_size") pageSize: Int,
@RequestParam("category") category: TransactionViewCategory,
): Response {
val targetServiceNames = category.getSupportedServiceNames()
val transactionViewFlow = targetServiceNames.asFlow()
.flatMapMerge { serviceName ->
flow {
val transactionView = when (serviceName) {
ServiceName.TRANSFER -> // 송금 서버를 호출해서 카테고리에 걸맞은 내역을 만든다.
ServiceName.PAYMENT -> // 결제 서버를 호출해서 카테고리에 걸맞은 내역을 만든다.
ServiceName.MONEY -> // 당근머니 서버를 호출해서 카테고리에 걸맞은 내역을 만든다.
ServiceName.CARD -> // 당근 카드 서버를 호출해서 카테고리에 걸맞은 내역을 만든다.
else -> {
logger.error("NotSupported ServiceName - serviceName($serviceName)")
emptyList()
}
}
emit(transactionView)
}
}
.flowOn(Dispatchers.IO)
val transactionViews = coroutineScope { transactionViewFlow.toList() }
...
}

4. 거래 내역 목록에서 상세 내역 진입을 위한 Token 정의

거래 내역 목록에서 특정 거래를 클릭하면 해당 거래의 상세 내용을 확인할 수 있는 기능을 제공하고 있어요. 목록 조회와 마찬가지로 웹 클라이언트가 상세 내용을 조회하려면 거래 상세 조회 API를 호출해야 해요.

거래 내역 상세 화면은 목록 형태처럼 여러 서버의 데이터를 Aggregate 해야 하는 상황은 아니기 때문에 처음에는 각 서버에서 API를 직접 제공하는 방법도 고려해 봤어요. 이렇게 되면 거래 상세 정보 조회 시 당근 카드, 결제, 송금 서버 등을 직접 호출해야 하는데, 각 서버에서 상세 정보 조회에 대한 별개의 인터페이스를 제공할 가능성이 높아져요. 클라이언트에선 통일된 규칙 없이 상이한 인터페이스에 대해 모두 대응해야 하는 비효율이 발생해요.

따라서 클라이언트에 일관된 인터페이스를 제공할 수 있도록 Aggregator에서 상세 정보 조회 기능을 제공하도록 했어요.

처음에는 거래 내역 목록 조회 시 응답에 포함된 id를 Key값으로 사용하여, 거래 상세 정보를 조회할 수 있도록 아래와 같이 인터페이스를 설계했어요.

@GetMapping("/transactions/{id}")
fun getDetailTransaction(
@PathVariable("id") id: String,
@RequestParam("user_id") userId: String,
) {
// 전달받은 id를 가지고 내부 서버에 관련한 정보를 조회할 수 있어야 한다.
// TRANSFER? CARD? PAYMENT? MONEY?
}

하지만 전달받은 id만으로는 정보가 부족했어요. 이 id가 어떤 서비스의 거래인지 결정할 수 없었기 때문이에요. 예를 들어 클라이언트가 id를 1000000000015로 설정해서 거래 내역 상세 조회 API를 호출했다고 가정해 볼게요. 요청을 받은 Aggregator 서버는 이1000000000015값이 송금 내역의 id인지, 결제 내역의 id인지 판단할 수 있어야 하지만 판단하기에는 정보가 부족해요.

처음엔 위 문제를 해결하기 위해 API 스펙에 ServiceName과 같은 필드를 추가하는 방법도 고려했어요.

@GetMapping("/transactions/{id}")
fun getDetailTransaction(
@PathVariable("id") id: String,
@RequestParam("user_id") userId: String,
@RequestParam("service_name") serviceName: ServiceName,
) {
// 전달받은 id를 가지고 내부 서버에 관련한 정보를 조회할 수 있어야 한다.
val detailTransactionView = when (serviceName) {
ServiceName.TRANSFER -> // 송금 서버를 호출해서 카테고리에 걸맞은 내역을 만든다.
ServiceName.PAYMENT -> // 결제 서버를 호출해서 카테고리에 걸맞은 내역을 만든다.
ServiceName.MONEY -> // 당근머니 서버를 호출해서 카테고리에 걸맞은 내역을 만든다.
ServiceName.CARD -> // 당근 카드 서버를 호출해서 카테고리에 걸맞은 내역을 만든다.
}
...
}

하지만 이렇게 ServiceName 을 추가하면 클라이언트와 내부 서버 사이에 의존 관계가 생겨요.

상세 내역 조회를 위한 필드로 ServiceName이 추가된다면, 클라이언트는 자체적으로 특정 내역의 ServiceName을 판단할 수 없기 때문에 거래 내역 목록 조회 응답 시에 ServiceName 필드를 서버에서 응답해 줘야 해요.

이렇게 되면 ‘클라이언트 엔지니어는 ServiceName은 어떤 역할을 하지?', ‘ServiceName에는 어떤 값들이 올 수 있지?' 등에 대해 의문이 생기게 되고, ServiceName에 대한 정의가 클라이언트 쪽 코드에 작성될 가능성이 높아져요. 그런데 Aggregator가 거래 내역을 위해 연동하고 있는 내부 서버의 역할이나 존재를 클라이언트 쪽에서 직접적으로 의존하거나 알고 있을 필요는 없다고 생각했어요.

Aggregator Token 도입

이 문제를 해결하기 위해 Aggregator가 해석할 수 있는 Token이라는 개념을 정의했어요. Token은 특정 데이터를 재료로 삼아 만들 수 있는 하나의 문자열 데이터를 의미해요.

위에서 특정 id가 어떤 서비스 거래 내역의 id인지 모른다는 문제가 있었어요. 결국 상세 내역 조회 API의 로직에서는 어떤 서비스의 거래인지 판단할 수 있는 추가적인 정보들이 필요해요.

Aggregator Token 객체를 직접 정의해 볼게요.

먼저, 기존 id를 담을 수 있는 serviceId 필드, 해당 거래가 어디 서비스의 거래인지 식별하는 serviceName, Token의 재료 구성이 달라질 상황을 대비하여 version필드도 정의해 두었어요.

data class AggregatorToken(
val serviceId: String,
val serviceName: ServiceName,
val version: String = "v1",
)

이 객체를 ObjectMapper를 통해 JSON 문자열로 변환할 수 있어요.

val aggregatorToken = AggregatorToken("1000000000015", "TRANSFER", "v1")
val aggregatorTokenJson = objectMapper.writeValueAsString(aggregatorToken)
{
"service_id": "1000000000015",
"service_name": "TRANSFER",
"version": "v1"
}

위 Json 데이터를 Encoding하여 아래와 같이 단일 문자열로 만들 수 있어요.

val aggregatorToken = AggregatorToken("1000000000015", "TRANSFER", "v1")
val aggregatorTokenJson = objectMapper.writeValueAsString(aggregatorToken)
val token = Encoder.encode(aggregatorTokenJson)
eyJzZXJ2aWNlX25hbWUiOiJDQ

위 과정을 거쳐 생성한 문자열을 “Token”이라고 표현해요. Token을 만들기 위해 id를 재료로 사용하기 때문에, Token은 특정 거래 내역을 식별할 수 있는 존재로 사용될 수 있어요. Token은 필요시 다시 Json으로 Decoding해서 원하는 여러 정보를 추출할 수 있기 때문에, 기존 Json 데이터에 있는 id나 ServiceName을 활용하여 해당 거래 내역이 어떤 서비스의 거래인지 식별이 가능해요.

최종적으로 클라이언트가 응답받게 되는 거래 내역은 아래와 같이 표현해 볼 수 있어요. 각 내역의 식별자 역할을 Aggregator가 만든 Token이 담당하고 있어요.

{
"next_cursor": "next_cursor",
"contents": [
{
"token": "eyJzZXJ2aWNlX25hbWUiOiJDQ",
"transaction_title": "당근 장바구니",
"transaction_view_category": "TRANSFER",
"transaction_type_name": "송금",
"transaction_amount": 4000,
"in_out_code": "IN",
"thumbnail_url": "https://${host}/karrot_bag.png",
"created_at": 1720170327500
},
{
"token": "YxIiwic2VydmljZV9pZCI6Ij8",
"transaction_title": "스타벅스",
"transaction_view_category": "CARD",
"transaction_type_name": "당근카드 결제",
"transaction_amount": 5400,
"in_out_code": "OUT",
"thumbnail_url": "https://${host}/card.png",
"created_at": 1720170327499
},
...
{
"token": "FxEiwic2ZydmnjZV9pZCI7Ik9",
"transaction_title": "배추김치",
"transaction_view_category": "PAYMENT",
"transaction_type_name": "결제",
"transaction_amount": 16000,
"in_out_code": "OUT",
"thumbnail_url": "https://${host}/kimchi.png",
"created_at": 1720170216399
}
]
}

또한 거래 내역 상세 조회를 위한 인터페이스는 별도의 ServiceName 필드 등을 추가하지 않고 Token만 전달받을 수 있도록 설계됐어요. 이제는 Token을 해석해서 특정 id의 거래가 어떤 서버의 거래인지 알 수 있어요.

@GetMapping("/transactions/{token}")
fun getDetailTransaction(
@PathVariable("token") token: String,
@RequestParam("user_id") userId: String,
) {
// 전달받은 token를 해석(Decode)하여 내부 서버에 관련한 정보를 조회할 수 있다.
val token = Decoder.decode(token, objectMapper)
val detailTransactionView = when (token.serviceName) {
ServiceName.TRANSFER -> // 송금 서버를 호출해서 카테고리에 걸맞은 내역을 만든다.
ServiceName.PAYMENT -> // 결제 서버를 호출해서 카테고리에 걸맞은 내역을 만든다.
ServiceName.MONEY -> // 당근머니 서버를 호출해서 카테고리에 걸맞은 내역을 만든다.
ServiceName.CARD -> // 당근 카드 서버를 호출해서 카테고리에 걸맞은 내역을 만든다.
}
...
}

마무리하며

지금까지 거래 내역을 위한 Aggregator를 만들기 위해 고민했던 과정을 글로 적어보았어요. 제 글이 Aggregator를 도입하려는 개발자나 팀이 판단하는 데에 조금이라도 좋은 인사이트가 되었으면 좋겠어요. 😄

저는 개발하면서 특정 로직의 책임과 역할을 어디까지 Aggregator에 담을 것인지 고민하는 게 가장 어려웠어요. Aggregator와 내부 서버 간의 책임 경계 정의가 어렵지만 가장 중요하다고 생각이 들기도 하고요. 이 부분을 해소하기 위해 저희 팀의 백엔드 팀원들은 다 함께 모여 가볍고 빠르게 이야기를 많이 나눴어요. ‘이건 어디에 개발을 해야 하지?’와 같은 이야기를 자주 하다 보면 나름의 기준이 명확해지는 지점이 생기더라고요.

소개한 방법이 정답은 아니기 때문에 당연히 더 나은 방법이 있을 것 같아요. 당근페이 거래 내역의 트래픽은 계속해서 증가하고 있기 때문에, 위의 <Aggregator 구현 방안 선택지>에서 소개했던 2번 방법이나 CDC(Change Data Capture)를 통해 데이터 파이프라인을 구축하여 좀 더 본격적으로 개선해 보는 것도 고려하고 있어요.

구현하는 팀의 서비스 트래픽 수준이나 다루는 도메인과 리소스 상황에 따라서도 Aggregator 구현 방법은 많이 달라질 수 있을 거예요. 무엇보다 중요한 건 현재 상황을 잘 고려하여 적절한 개발 범위를 결정하고, 미래의 어떤 시점이 오면 어떻게 대응할지를 그려보는 거예요.

이와 관련해서 더 이야기하고 싶은 분들은 winter.you@daangnpay.com로 언제든 연락 주시면 더 자세하고 재밌는 이야기를 해볼 수 있을 것 같아요.

또 제가 속한 당근페이의 머니 서비스팀에서는 다양한 문제를 빠르게 해결하며 성장하는 문화를 가지고 있어요. 머니 서비스팀의 백엔드 엔지니어도 채용을 진행하고 있으니, 저희의 도전과 함께하여 성장하고 싶으신 분들은 아래 링크에 많은 관심 부탁드릴게요. 😉

긴 글 읽어주셔서 감사합니다.

참조

--

--