트랜잭셔널 아웃박스 패턴의 실제 구현 사례 (29CM)

Greg Lee
21 min readDec 25, 2023

--

이 글에서는 실무 관점에서의 Apache Kafka 활용 에서 잠깐 소개했던 트랜잭셔널 아웃박스 패턴 (Transactional Outbox Pattern) 을 실제로 구현하여 활용하고 있는 29CM 의 사례를 소개하고자 한다.

트랜잭셔널 아웃박스 패턴이란?

비동기 메시징을 활용한 서비스 구현에서는 비즈니스 로직이 실행되었을 때, 이를 표현하는 이벤트도 온전하게 발행되는 것이 중요하다. 도메인 로직이 완료된 이후에 이벤트가 발행되지 않는다면, 해당 이벤트를 바라보는 컨슈머는 특정한 로직을 실행할 수 없게 되고, 이로 인해 전체 서비스의 데이터 정합성이 깨지거나 특정한 로직에서 버그가 발생할 수 있기 때문이다.

이와 같이 서비스 로직의 실행과 그 이후의 이벤트 발행을 원자적으로(atomically) 함께 실행하는 것을 트랜잭셔널 메시징(Transactional Messaging) 이라고 한다. 그리고 이러한 트랜잭셔널 메시징을 구현하는 방법은 크게 2가지가 있다.

  1. 트랜잭셔널 아웃박스 패턴 (Transactional Outbox Pattern)
  2. 변경 데이터 캡쳐 (Change Data Capture)

이 중에서 트랜잭셔널 아웃박스 패턴을 실제로 구현하여 활용하고 있는 29CM 의 사례를 상세하게 풀어서 설명하고자 한다.

트랜잭셔널 아웃박스 패턴 (https://microservices.io/patterns/data/transactional-outbox.html 참고)

29CM 는 왜 아웃박스 패턴을 선택했는가?

20년 8월부터 마이크로서비스로의 전환을 시작했던 29CM 는 서비스 전환 과정에서 마이크로서비스가 주는 장점을 충분히 활용하면서 서비스를 키워갔지만, 서비스가 고도화될수록 동기식 HTTP API 를 주요한 통신 방식으로만 사용하는 것에 대한 한계 상황이 발견되기 시작했다. 그래서 과거부터 검토해오던 이벤트 드리븐 아키텍처(EDA) 를 적극적으로 사용하기로 결정했다.
(물론 과거에도 aws sqs 와 같은 단순한 메시징 큐는 활용하고 있었지만, 29CM 의 복잡한 요구사항을 대부분 커버할 수 있다고 생각했던 apache kafka 를 적극적으로 활용하기로 결정했다)

이후 메시지를 발행하는 프로듀서(producer) 와 이를 수신하는 컨슈머(consumer) 를 생각할 때, 전체 서비스의 하단에 위치하는 주요한 도메인(가령 상품 도메인) 에서 메시지를 발행하는 구조를 만드는 것이 우선적으로 필요하다고 봤고, 이 때 상품 도메인 서비스에 트랜잭셔널 아웃박스 패턴을 적용하기 위한 기술 검토를 시작하게 되었다.

상품 서비스에서 트랜잭셔널 메시징을 구현하기 위해서는 변경 데이터 캡쳐 (Change Data Capture, 이하 CDC) 를 활용할 수도 있었지만, 그 당시에 트랜잭셔널 아웃박스 패턴 (Transactional Outbox Pattern) 를 선택했던 이유는 다음과 같다.
(참고로 CDC 를 적용한다면 Debezium 와 같이 충분히 검증되고 널리 사용되는 플랫폼을 쓰는게 맞다고 생각하여 Debezium 을 중심으로 기술 검토를 진행했다)

팀 내에서 CDC 를 적용해본 경험이 없었다.

CDC 는 데이터의 시간 값이나 데이터베이스의 트랜잭션 로그와 같은 데이터의 변경점을 확인하여 동작하는 방식이기 때문에, 메인 서비스의 코드를 변경하지 않더라도 이벤트의 발생 시점을 캡쳐하여 이를 메시지 브로커에 발행할 수 있는 장점이 있다. 하지만 이러한 장점이 있더라도 이를 팀 내에서 충분히 활용할 수 있는 준비가 되지 않았다면 추후 안정적인 서비스 운영과 고도화를 진행해야 할 때 어려움이 따를 것이라 생각했다. 단순히 CDC 를 설치하고 팀 내에 적용하는 것은 금방 할 수도 있겠지만, 이를 수월하게 활용할만큼의 충분한 지식과 경험이 아직 쌓인 상태는 아니라고 봤다.

CDC 는 애플리케이션이 원하는 형태로 메시지를 발행하지 않는다.

데이터베이스에 적용된 CDC 는 주로 MySQL 의 binlog 와 같은 트랜잭션 로그를 활용하여 변경된 데이터를 확인하고, 이후 타겟으로 정한 메시지 브로커에 메시지를 발행한다. 이러한 방식은 발행되는 메시지의 형태가 테이블의 스키마에 의존적일 수 밖에 없고, 발행하는 메시지에 특정한 값을 추가하거나 의미에 맞게 값을 변경하기에는 어려움이 따른다. 요구사항에 따라서는 메시지를 수신하는 컨슈머 측에서 메시지 자체를 그대로 활용할 수 없기 때문에, 별도의 API 를 추가로 호출해야 할 수도 있다.

CDC 는 테이블 스키마 변경에 취약하다.

CDC 는 테이블의 구조에 의존적이기 때문에 테이블의 스키마가 변경되면 CDC 를 통해 메시지 브로커로 전달되는 메시지의 형태도 바뀌게 된다. 이는 테이블의 스키마를 변경하는 DDL 이 실행될 때마다 CDC 를 통해 생성되는 메시지의 형태가 바뀔 수 있다는 것이고, 그 때마다 메시지를 수신하는 컨슈머 측의 로직 변경을 요구하게 된다. 이는 마이크로서비스가 추구하는 서비스 간의 독립된 구현과 운영을 어렵게 만드는 요인이 된다.

물론 메시지의 형태가 테이블의 스키마에 강한 의존성을 가지게 되는 이슈는 카프카 스트림즈 또는 Debezium 의 Tranform 을 활용하거나, 내부 메시지 모델과 외부에 퍼블리싱하는 메시지 모델을 분리하면 충분히 극복 가능한 사항이다. 하지만 그 당시의 29CM 에서는 이러한 기술들을 내재화하면서 고도화 하기에는 내부 리소스가 충분하지 않았고, 트랜잭셔널 아웃박스 패턴을 코드 구현으로 어렵지 않게 만들어 낼 수 있다는 판단이 있었기 때문에 이를 직접 구현하여 활용하기로 결정하였다.

서비스 아키텍처의 도메인 의존관계를 볼 때, 주로 하단에 있는 도메인에서 이벤트를 발행하고, 상단에 있는 도메인에서 이벤트를 수신하여 필요한 로직을 처리한다. 29CM 에서는 상품 도메인이 하단에 위치하는 핵심 도메인인데, 상품 도메인 서비스에 아웃박스 패턴을 적용하여 이벤트를 발행하고자 하였다.
지금부터는 29CM 에서 22년 8월에 구현한 트랜잭셔널 아웃박스 패턴의 설계와 구현 과정을 소개하겠다.

아웃박스 패턴 구현의 초기 설계

여기서는 Spring 에서 제공하는 ApplicationEventPublisher 를 활용한 이벤트 발행과 수신에 대한 자세한 설명은 생략한다. 해당 설명은 Spring 공식 레퍼런스나 https://www.baeldung.com/spring-events 와 같은 아티클을 참고했으면 한다.

트랜잭셔널 아웃박스 패턴을 위해 화이트보드를 활용하여 진행했던 초기 설계는 다음과 같다.
(코드 구현 과정에선 좋은 IDE 가 큰 도움이 되지만, 초기 설계와 개발 방향성을 정의하는 과정에서는 화이트보드만큼 좋은 도구는 없다고 생각한다. ^^)

트랜잭셔널 아웃박스 패턴 구현을 위한 초기 설계 (지우지 말아주세요 ㅋ)
  1. Commerce 서비스의 주문 완료와 같은 주요한 도메인 로직에서 @Transactional 을 선언하고, 그 안에서 “(1) 주문 완료 처리 (2) outbox 테이블에 주문 완료 이벤트 정보를 저장하여, 이벤트 발행 요청을 기록 (3) Spring 의 ApplicationEventPublisher.publishEvent( ) 를 활용하여 주문 완료 이벤트 발행” 을 담는다. 이렇게 로직을 구현하면 도메인 로직과 이벤트 발행이 하나의 @Transactional 에 묶였기 때문에 해당하는 로직이 모두 성공하거나, 모두 실패하게 된다. (도메인 로직과 메시지 발행에 대한 정합성 유지)
  2. 1번 구간의 ApplicationEventPublisher.publishEvent( ) 로 발행한 주문 완료 메시지를 리스닝하는 메서드를 구현한다. 해당 메서드에는 @TransactionalEventListener 를 붙이고 메서드 파라미터에는 해당 메서드가 리스닝하는 이벤트를 선언하여 1번 구간의 ApplicationEventPublisher 가 발행한 이벤트를 수신한다. 이 때 @TransactionalEventListener 는 1번 구간에서 트랜잭션 commit 이 실행된 이후에만 리스너가 동작하게 되는데, 이 때문에 도메인 로직이 오류 없이 온전히 실행되었을 때에만 이벤트가 발행되는 것을 보장할 수 있다.
    이 때 해당 리스너가 이벤트를 수신하여 카프카로 메시지를 발행할 때, 카프카 클러스터 장애 또는 네트워크 상의 장애로 인해 카프카로의 메시지 발행이 실패할 수 있다. 이 때에는 1번 구간의 outbox 테이블에 기록된 메시지 발행 정보를 주기적으로 확인하면서 재발행을 시도하는 (배치와 같은) 별도의 메시지 릴레이 로직을 통해 카프카로 메시지 발행을 다시 시도할 수 있다. 결국 언젠가는 모든 서비스 간의 데이터 정합성이 맞춰지게 된다. (eventually consistency)

아래 그림은 1번 구간과 2번 구간의 성공, 실패에 따라 발생 가능한 4가지 case 를 표현한다.

  1. 1번 성공, 2번 성공
    도메인 로직이 성공적으로 실행되어 트랜잭션 commit 이 발생하고, 이후에 주문완료 이벤트가 발행된다. 이후 TransactionalEventListener 에서 해당 이벤트를 읽고서 카프카로 메시지를 발행한다.
  2. 1번 실패, 2번 성공
    도메인 로직은 실패했으나 이와 연관된 이벤트는 발행되는 상황을 표현하는 케이스인데, 이는 발생 가능성이 없다고 봐야한다. 도메인 로직의 트랜잭션 commit 이 실패하면 TransactionalEventListener 에서도 해당 이벤트를 리스닝하지 않는다. 이는 spring 에서 보장하는 기능이다.
  3. 1번 성공, 2번 실패
    도메인 로직이 성공적으로 실행되고 트랜잭션 commit 도 이루어졌으나, 이후 TransactionalEventListener 에서 카프카에 메시지를 발행하는 과정이 실패한 케이스이다. 도메인 로직이 성공적으로 실행된 상황에서 카프카로의 메시지 발행이 실패하면 전체 서비스 간의 데이터 정합성이 불일치하게 되지만, outbox 테이블에 발행할 이벤트 정보를 기록해놨기 때문에 일정 주기마다 동작하는 배치 등을 통해서 카프카로 메시지를 발행하는 로직을 다시 실행할 수 있다. 이는 시간이 지나면 언젠가는 모든 시스템의 데이터가 일치하게 되는 eventually consistency 가 이루어지는 것을 보장한다.
  4. 1번 실패, 2번 실패
    도메인 로직이 실패하면 이와 연관된 이벤트 발행도 이루어지지 않는 케이스를 표현한다. 비록 로직은 실패했지만 전체적인 정합성은 깨지지 않았기 때문에 의도한 상황으로 봐도 무방하다.

이러한 설계의 핵심은 “도메인 로직이 실행되었을 때, 이를 표현하는 이벤트도 반드시 발행되게 하는 것” (Transactional Messaging) 이다. 그리고 이를 위해 도메인 로직과 이벤트 발행 정보를 기록하는 로직을 하나의 트랜잭션으로 묶는 것(Transactional Outbox Pattern)과 spring 이 제공하는 TransactionalEventListener 를 활용하는 것도 중요한 구현 요소이다.

실제 구현과 리뷰 과정

트랜잭셔널 아웃박스 패턴에 대한 초기 설계가 완료되고, 이를 기반으로 상품 도메인 서비스에 트랜잭셔널 아웃박스 패턴을 적용하는 개발 검토가 진행되었다. 그 과정에서 초기 설계와는 다른 2개의 컨셉이 추가로 도출되었고, 이는 다음과 같다.

1. TransactionalEventListener 에서 BEFORE_COMMIT 활용

(원활한 로직 설명을 위해 아래의 이미지에서 붉은 색으로 표기한 숫자는 같은 라인의 로직을 가리키는 대표값으로 사용하고자 한다)
초기 설계에서는 아래 이미지와 같이 하나의 트랜잭션 안에서 1. 도메인 로직 처리 2. outbox 테이블에 이벤트 기록 3. 이벤트를 발행 한다. 1 ~ 3번 구간이 성공적으로 진행되고 트랜잭션 commit 이 이루어지면 3번 구간에서 발행된 이벤트를 바라보는 4번 구간에서 이벤트를 읽어서 카프카에 메시지를 발행한다. 이 때 4번 구간에서는 TransactionalEventListener 의 phase = AFTER_COMMIT 을 붙였는데, 이는 앞단의 로직에서 트랜잭션 commit 이 완료된 이후 시점에 이벤트를 읽겠다는 의미이다.

이러한 설계를 기반으로 로직을 구현하는 과정에서 초기 설계와는 다른 개발 방향이 제안되었는데 이는 다음과 같다.

  1. 트랜잭션으로 묶인 도메인 로직에서 outbox 테이블에 이벤트를 기록하는 2번 구간의 로직을 별도의 Listener 로 옮긴다. 해당 Listener 는 (4번 구간의 Listener 와 동일하게) 3번 구간에서 발행되는 이벤트를 바라본다. 다만 (4번 Listener 와는 다르게) 2번 Listener 는 phase = BEFORE_COMMIT 이 붙은 @TransactionalEventListener 을 선언한다.
  2. 즉, 3번 구간의 이벤트를 바라보는 Listener 가 기존 1개에서 2개로 변경되었는데, 2번 구간의 Listener 는 phase = BEFORE_COMMIT 를 붙이고, 4번 구간의 Listener 는 기존대로 phase = AFTER_COMMIT 을 붙인다.

도메인 트랜잭션이 완료되었을 때 실행되는 로직은 크게 3가지로 볼 수 있다.

  1. 도메인 로직
  2. outbox 테이블에 이벤트 기록
  3. 이벤트를 카프카로 전송

기존 설계에서는 1번과 2번을 하나의 트랜잭션 안에 묶고 처리했다면, 변경된 구현에서는 2번을 별도의 Listener 로 옮기고, 도메인 이벤트가 발행되었을 때 2번 로직을 처리하는 phase = BEFORE_COMMIT 의 Listener 와 3번 로직을 처리하는 phase = AFTER_COMMIT 의 Listener 가 각각 동시에 동작하도록 한다는 것이다.

사실 기존 설계와 변경된 구현 간에는 큰 차이가 없다고 봐도 무방하다. 다만 도메인 이벤트의 발행 전과 후를 비교할 때, 변경된 구현에서는 이벤트 발행 이후의 로직은 모두 TransactionalEventListener 에서 처리하게 하였고, 이는 “이벤트 발행 이후에 처리되는 모든 로직은 TransactionalEventListener 에서 구현한다” 는 일관성을 유지할 수 있게 해주는 장점이 있다고 봤다.

우리가 spring 이 제공하는 ApplicationEventPublisher 를 사용하는 근본적인 이유는, 처리해야 할 도메인 로직과 그 이후에 처리되어야 할 로직을 분리하기 위함이다.

  1. 도메인 로직이 완료된 이후에 이를 표현하는 이벤트를 발행하고
  2. 도메인 로직 완료 이후에 처리해야 할 것들은 해당 이벤트를 바라보는 Listener 에 구현한다면
  3. 도메인 로직은 도메인의 요구사항에만 집중할 수 있고, 그 이외의 것들은 해당 이벤트를 바라보는 Listener 에 추가하기만 하면 된다.

이러한 관점에서 본다면 기존 설계보다는 변경된 구현이 확장성과 응집성이 좀 더 높은 방식이라고 봤고, 여러 번의 리뷰를 거쳐서 후자의 방식을 채택하게 되었다.
아래는 (1) 초기 설계에 맞춘 구현(2) 변경된 컨셉에 맞춘 (최종적으로 완성된) 구현의 예시이다.

(1) 초기 설계에 맞춘 구현

@Transactional
public InventoryQuantityUpdateInfo updateInventoryQuantity(XxxxCommand command) {
// 1. 도메인 로직
XxxxResult updateResult = updateInventoryQuantity(command);

// 2. outbox table 에 이벤트 기록
recordEventToOutboxTable(updateResult);

// 3. 이벤트 발행 (InventoryExternalEvent 발행)
inventoryEventService.eventPublish(InventoryEventCommand.from(updateResult));

return InventoryQuantityUpdateInfo.of(updateResult);
}
@EventHandler
@RequiredArgsConstructor
public class InventoryExternalEventMessageListener {
private final InventoryExternalEventSendService sendService;

// 2. 카프카에 메시지 전송 (TransactionPhase.AFTER_COMMIT)
@Async(EVENT_ASYNC_TASK_EXECUTOR)
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
public void sendMessageHandler(InventoryExternalEvent event) {
sendService.send(InventoryExternalEventMessagePayload.from(event));
}
}

(2) 변경된 컨셉에 맞춘 구현

@Transactional
public InventoryQuantityUpdateInfo updateInventoryQuantity(XxxxCommand command) {
// 1. 도메인 로직
XxxxResult updateResult = updateInventoryQuantity(command);

// 2. 이벤트 발행 (InventoryExternalEvent 발행)
inventoryEventService.eventPublish(InventoryEventCommand.from(updateResult));

return InventoryQuantityUpdateInfo.of(updateResult);
}
@EventHandler
@RequiredArgsConstructor
public class InventoryExternalEventRecordListener {
private final InventoryExternalEventRecorder eventRecorder;

// 1. outbox 테이블에 이벤트 기록 (TransactionPhase.BEFORE_COMMIT)
@TransactionalEventListener(phase = TransactionPhase.BEFORE_COMMIT)
public void recordMessageHandler(InventoryExternalEvent event) {
eventRecorder.save(event.toEventRecordCommand());
}
}
@EventHandler
@RequiredArgsConstructor
public class InventoryExternalEventMessageListener {
private final InventoryExternalEventSendService sendService;

// 2. 카프카에 메시지 전송 (TransactionPhase.AFTER_COMMIT)
@Async(EVENT_ASYNC_TASK_EXECUTOR)
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
public void sendMessageHandler(InventoryExternalEvent event) {
sendService.send(InventoryExternalEventMessagePayload.from(event));
}
}

2. 메시지 발행 상태 값의 상세화

초기 설계에서는 outbox 테이블에 발행할 이벤트 정보를 기록할 때, 이벤트의 상태값을 표현하는 published 와 같은 boolean 형태의 상태 값을 선언하였다. 처음 outbox 테이블에 이벤트를 기록할 때에는 published = false 로 선언하여 이벤트 발행을 해야하지만 아직 발행이 되지 않았음을 명확히 표현하고, 이후 트랜잭션이 commit 되고 이를 바라보는 TransactionalEventListener 에서 이벤트를 읽어서 발행에 성공했을 때에는 published = true 로 바꾸고자 하였다.
이런 식으로 상태 값을 관리하면, 이후에 배치 로직을 구현하여 outbox 테이블의 데이터를 주기적으로 체크하면서, 이벤트 발행 요청 시점(created_at) 은 한참 지났는데 아직도 published = false 인 데이터를 일괄로 읽어서 이벤트 발행을 재시도할 수 있다고 봤다.

그런데 실제로 로직을 구현하는 과정에서 이벤트 발행이 실패하는 경우는 한 가지 경우가 아니라 최소 두 가지 경우가 발생한다고 확인하였고, 이후에 이벤트의 상태 값을 좀 더 세분화하였다.

  1. init : 이벤트 발행 등록. outbox 에 처음 기록될 때의 상태 값을 말함
  2. send_succes : 이벤트 발행 성공
  3. send_fail : 이벤트 발행 실패. 카프카에 메시지를 전송했지만 실패함

대부분의 경우는 spring 내부에서 이벤트가 발행된 직후에 이를 Listener 가 읽어서 카프카에 성공적으로 메시지를 발행하게 된다. 그리고 이러한 이벤트들은 outbox 테이블에 send_success 로 업데이트 될 것이다. 관건은 outbox 테이블에 등록된 이벤트 정보가 언제 send_fail 로 남고, 언제 init 으로 남는가이다.

Listener 에서 이벤트를 읽어서 카프카로 메시지를 발행할 때, 카프카 클러스터 또는 네트워크 상의 이슈가 발생했다면 메시지 발행 과정이 실패할 것이고, 이 때에는 상태 값이 send_fail 로 변경된다. 그러나 트랜잭션 commit 이후에 해당 이벤트를 Listener 가 읽지 못하는 상황이 발생한다면 outbox 테이블의 이벤트는 init 상태로 남게 될 것이다. (Listener 가 이벤트를 읽은 후에 카프카로 메시지를 발행하는 과정에서 실패했다면 send_fail 로 업데이트 되겠지만, 지금은 이벤트 발행 직후에 해당 이벤트를 읽지도 못한 상황을 말하는 것이다)

이러한 상황은 주로 트랜잭션 commit 직후에 (서비스 배포와 같이) 로직을 실행하는 프로세스가 shutdown 되는 상황에서 발생할 수 있다. 그리고 실제로 outbox 테이블에서 오랜 시간 init 상태로 남아있는 이벤트 기록들은 모두 서비스 배포 직후에 발생하였다. 보통 이러한 상황이 발생하지 않도록 “현재 실행중인 로직이 완료될 때까지 기다렸다가 서비스를 종료하는 것" 을 graceful shutdown 이라고 하는데, 위와 같은 문제 상황은 실제로 graceful shutdown 이 정상적으로 이루어지지 않았기 때문에 발생하였다.

좀 더 자세히 살펴보면 우리의 구현에서 카프카에 메시지를 발행하는 로직은 ThreadPoolTaskExecutor 를 사용하는 @Async 로직이었는데, 해당 로직에서 graceful shutdown 을 정상적으로 수행하기 위해서는 아래 2개의 옵션을 명시적으로 선언해야 한다. 그런데 우리는 setAwaitTerminationSeconds 설정을 누락했었고, 그 때문에 서비스 배포 과정에서 pod 가 rolling 될 때 카프카 전송을 담당하는 async thread 가 바로 죽는 상황이 발생했었다.

executor.setWaitForTasksToCompleteOnShutdown(true);
executor.setAwaitTerminationSeconds(10);

초기 구현과 테스트 과정에서 몇 번의 디버깅을 통해 문제의 원인을 찾았고, 이후 ThreadPoolTaskExecutor 를 사용할 때에도 graceful shutdown 이 가능한 설정을 적용하였다. 그 후에는 서비스 배포 직후 outbox 테이블에서 오랜 기간 init 상태로 남아있는 이벤트는 사라지게 되었다.
(위의 설정과 관련한 자세한 사항은 https://www.baeldung.com/spring-boot-graceful-shutdown 를 참고한다.)

그래서 지금은?

22년 8월부터 적용된 상품 서비스에서의 아웃박스 패턴 로직은 지금까지 큰 이슈 없이 정상적으로 운영 중이다. 해당 로직을 통해 상품의 재고 정보가 변경될 때마다 카프카에 메시지가 누락 없이 정확하게 발행되고, 이를 바라보는 상위 서비스들은 해당 메시지를 읽어서 각자가 필요로 하는 개별적인 로직을 실행하고 있다. 그리고 도메인 로직이 명확하게 실행되었지만 모종의 사유로 이벤트 발행에 실패한 것들(이벤트의 상태가 ‘send_succes’ 가 아니면서 created_at 이 현 시간 기준으로 10분 이상 넘어간 것들) 은 별도의 배치 로직을 통해서 이벤트 재발행을 시도하고 있다.

트랜잭셔널 아웃박스 패턴에 대한 이론적인 글과 내용은 많지만 실제로 구현하여 운영 중인 사례는 아직 많지 않은 듯 하다. 본 글을 통해 트랜잭셔널 아웃박스 패턴의 적용을 검토 중인 분들에게 도움이 되었으면 한다. 그리고 팀에 필요한 개발을 위해 좀 더 나은 방향성을 제안하고 실제 로직으로 구현해주신 29CM 의 윤정오님, 김도영님, 정태훈님에게 감사의 말씀 드립니다.

--

--