이번 글에서는 디미페이 2.0 백엔드 개발 과정에 집중해서 v2 백엔드가 어떻게 구성되고, QR 코드 결제 과정을 따라가 보며 DDD 빌딩 블록으로 비즈니스 로직을 추상화한 사례를 이야기해볼게요.
기술 스택
디미페이 v2 백엔드는 Bun 런타임과 타입스크립트, Nestjs(express), Mikro ORM으로 개발되었어요. 제가 선택한 도구들은 이전 글에서 자세한 소개와 그 이유를 읽어보실 수 있어요.
폴더 구조
백엔드는 헥사고날 이케틱처와 클린 아키텍처를 기반으로 해요. 그래서 크게 도메인 계층, 애플리케이션 계층, 인터페이스 계층, 인프라 계층의 네 레이어로 구성돼요. 저는 폴더 구조가 아키텍처 못지않게 중요하다고 생각해요. 잘 조직된 구조는 아키텍처가 잘 성장할 수 있는 뿌리가 되고, 신중하게 구성되지 않은 구조는 좋은 아키텍처라도 프로젝트를 실패로 이끌어갈 수 있다는 걸 깨달았어요. 저는 v2를 시작할 때 DDD를 제대로 구현하지 못하더라도 폴더 구조만큼은 완벽하게 만들고 시작하고 싶었어요.
책에서도 폴더구조에 대한 언급은 찾기 어려웠어요. 그래서 오픈소스가 폴더 구조를 설계하는데 많은 도움이 됐는데, 종합해 보면 크게 세 가지 유형으로 폴더를 구성할 수 있어요.
계층 기준
먼저 계층 기준이에요. 계층을 기준으로 삼으면 소스 폴더에 domain, application, interface, infra 폴더를 만들고 그 안에서 세부사항을 관리해요(개발 환경에 따라 폴더 이름은 적절히 변형될 수 있어요). 가장 눈에 띄는 장점은 계층 구조가 명확하게 구분되어 코드상에서도 시각적으로 계층을 파악할 수 있다는 점 같아요.
저는 한 도메인이나 컨텍스트에 관련된 코드가 여러 곳에 흩어져있는 게 오히려 도메인 모델에 집중하며 개발하는데 불편하다고 느껴요. 도메인 주도 설계는 도메인을 중심으로 비즈니스를 생각하는 것이고, 도메인 모델을 코드에 반영할 때도 이 흐름을 반영하는 게 더 자연스럽다고 생각해요.
어떤 계층의 세부 계층 폴더를 계층과 같은 레벨에 위치하는 경우도 봤는데, 이러면 코드상에 시각화된 계층 구조도 파괴되어 개발자들을 혼란스럽게 만들 수 있어요. 누가 도메인 서비스를 src/service 폴더 안에서 구현했는데, 다른 개발자는 domain/service에 구현하는 시나리오를 충분히 생각해 볼 수 있죠. 이제 앞으로 어떤 일이 벌어질진 생각만 해도 끔찍하지 않나요? 이 방법은 DDD를 사용하지 않는 간단한 계층형 애플리케이션을 구현할 때 사용하는 게 좋을 것 같아요.
모델 기준
다음은 모델을 중심으로 설계하는 거예요. 즉, 폴더 당 하나의 에그레겟(aggregate)을 위치시키는 거죠. 위 상황보단 느낌이 좋아요. 그리고 더 도메인 중심적으로 만드는 것 같기도 해요.
여기서 한 가지 공유하고 싶은 상황이 있어요. 예를 들어 쿠폰(Coupon) 엔티티와 쿠폰 타입(Coupon Type) 엔티티가 있다고 해볼게요. 두 엔티티는 각각 에그리겟 루트예요. 저희는 쿠폰의 유효성을 쿠폰 자체의 유효기간과 쿠폰 타입의 정책, 그리고 일부 글로벌 속성에 따라 결정되는 쿠폰 유효성 비즈니스 요구사항을 구현해야 해요.
일단 어떤 위치에서 구현하면 좋을까요? 비즈니스 로직이니 도메인 레이어에서 시작하면 되겠고, 쿠폰과 관련된 로직이니 쿠폰 엔티티의 정적 메서드로 만들어 볼까요? 이 메서드는 파라미터로 쿠폰과 쿠폰 타입의 참조를 받고, 쿠폰 타입에 따른 유효성 검사 코드가 들어있어요. 하지만 구현하려 보니 쿠폰 엔티티에서 쿠폰 타입의 세부사항을 알아야 한다는 것을 깨달았어요. 쿠폰 타입의 비즈니스 로직을 구현하기에 적절한 곳은 아니죠. 이 문제는 반대로 쿠폰 타입에서 정적 메서드를 만들 때도 똑같이 발생하는 문제예요. 그렇다고 애플리케이션 서비스에서 구현하면 로직이 외부로 새어나가죠.
보통 이렇게 엔티티에 속하기 애매한 연산은 도메인 서비스를 만들어 구현해요. 도메인 서비스는 도메인 계층에서 무상태(엔티티의 상태를 변경하지 않는 등) 연산을 수행하는 책임을 가져요. 어떻게 만들어야 할진 해결됐지만, 어떤 폴더에 구현할진 아직 해결되지 않았요. 다시 한번 생각해 볼까요? 맥락상 쿠폰의 유효성을 검사하기 때문에 쿠폰 폴더 안에 만드는 게 자연스럽지만, 쿠폰 타입도 로직에서 중요한 부분이에요. 이 고민에서 타협점을 찾으면 쿠폰 폴더에 도메인 서비스의 인터페이스를 정의하고, 쿠폰 타입 폴더에서 이 인터페이스를 구현하는 선택을 할 수 있어요. 상황에 따라 반대가 되는 경우가 적절할 수도 있어요.
저는 이 구현 방법이 적절하다고 생각해요. 하지만 불필요한 인터페이스가 만들어지는 걸 피할 수 없어요. 인터페이스를 만드는 걸 선호할 수도 있고, 잘 이용한다면 좋은 도구가 되지만, 기술적 구현이 없는 간단한 인터페이스는 관리의 부담을 추가시킬 수 있어요.
우리는 이 시나리오로 이 폴더 구조의 문제점을 발견할 수 있어요. 바로 엔티티 간의 결합도를 나타내기 어렵다는 점이에요. 쿠폰과 쿠폰 타입은 높은 확률로 같은 바운디드 컨텍스트나 서브도메인 안에 있을 거예요. 그러나 파일 시스템상엔 반영되기 어렵다는 점이 있어요. 코드베이스가 복잡해질수록 저처럼 인지 부하가 쉽게 오는 개발자들은 처음 코드를 이해하는데 힘든 시간을 가질 거예요.🤕
모듈 기준
마지막으로 모듈을 중심으로 폴더 구조를 설계하는 방법이에요. 모듈은 높은 결합을 가진 도메인 객체들을 담는 컨테이너예요. 바로 위 문제를 해결하는 방법이에요. 항상 그런 건 아니지만 모듈로 묶는 기준은 보통 바운디드 컨텍스트가 좋은 참고가 될 거예요. 예를 들어 PaymentMethod 모듈엔 PaymentMethod 에그리겟과 Card, Point 엔티티 등이 포함될 수 있겠죠.
이 방법은 어떻게 보면 처음 방법과 완전히 반대되는 설계예요. 모듈을 만들 땐 컴포넌트 유형에 따라 만들지 않거든요. 즉, 모든 서비스를 하나의 모듈로 만들고 모든 엔티티를 하나의 모듈로 만들지 않는다는 거죠. 그리고 여러 DDD 서적도 모듈로 설계하라고 권장하고 있어요.
이 이외에도 여러 가지 모듈을 설계 규칙이 있어요. 이름은 개발자가 임의로 정하는 것이 아닌 유비쿼터스 언어가 반영되도록 지어야 하고, 모듈들을 느슨하게 결합하고 순환 의존성을 피해야 해요.
저는 모듈 기준 폴더 구조를 선택했어요. 모듈 폴더로 강한 결합을 가진 엔티티를 묶고, 그 안에 계층 별 폴더를 만들었어요. 계층별 폴더는 각 계층을 더 세밀하게 나누는데 유리하고, 논리적 개념을 실제 코드에 반영할 수 있어 코드를 쉽게 이해할 수 있어요.
nestjs-rest-cqrs-example 리파지토리에서 크게 영감을 받았어요. CQS 패턴을 깔끔하게 구현하고 있어서 폴더 구조뿐만 아니라 코드도 저에게 좋은 참고가 되었어요.
게다가 모듈이라는 개념은 Nest와 겹치기도 해요. Nest는 모듈을 기준으로 연관된 대상들을 묶고 export와 import 필드를 통해 다른 모듈과 상호작용할 수 있어요.
전 모델 기준 폴더 구조지만 코드 수준에선 패키지나 네임스페이스로 "모듈"을 구현하는데요?
맞아요, 모델을 기준으로 폴더 구조를 설계해도 여전히 코드 수준에서 모듈을 구현할 수 있어요. 하지만 이를 파일 시스템 수준에서도 적용한다면 모델의 결합도를 효과적으로 나타낼 수 있을 거예요.
이제 이 안은 어떻게 구성되는지 설명해도 괜찮을 것 같아요. 여러 설명 방법이 있지만, 저희의 핵심 도메인인 QR 결제 요청부터 응답까지 어떤 일이 일어나는지 그 과정을 따라가 보며 각 계층과 컴포넌트에서 무슨 일이 일어나는지 설명하면 좋을 것 같아요.
QR 결제 개요
디미페이는 QR, 페이스사인, 쿠폰 결제로 총 세 가지의 결제 유형을 제공하고 있어요. 그중 QR 코드 결제는 학생이 휴대폰에서 결제 수단을 선택하고 QR 코드를 만든 뒤, 키오스크에 결제 QR 코드를 인식시키는 흐름이에요. 결제가 끝나면 앱은 결제 완료 이벤트를 받아 결제 상태를 나타내고, 푸쉬 알림도 함께 받아 결제 내역을 확인할 수 있어요.
인터페이스
인터페이스 계층은 웹 요청이나 외부 I/O를 담당하는 계층이에요. 지금 저희 서버는 REST와 SSE만으로 API를 제공하고 있어요. 만약 WebSocket이나 graphql과 같은 새로운 게이트웨이를 추가한다면, 이 계층에서 구현해요.
QR 트랜잭션은 REST API로 처리되니 HTTP 요청 라우트를 등록하는 부분을 살펴볼게요. Nest에선 컨트롤러(controllers)에서 메서드 데코레이터로 라우트를 등록해요.
// kiosk/interface/controllers/approve.controller.ts
@ApiTag('Transaction')
@Controller('kiosk')
export class ApproveController {
constructor(
private readonly qrTransactionService: QRTransactionService,
) {}
@ApiSuccessResponse(TransactionApproveResponseDto)
@ApiExceptionResponse(
WrongPayToken,
DisabledProduct,
FailedToCancelTransaction,
ForbiddenUser
)
@ApiHeaders(QRTransactionApproveHeader, QRTransactionApproveDCH)
@Kiosk({ transactionId: true })
@Post('qr')
qrApprove(
@Headers() headers: QRTransactionApproveHeader,
@Headers() dch: QRTransactionApproveDCH,
@Body() body: ApproveRequestDto
) {
return this.qrTransactionService.approve(body, headers.token, dch)
}
}
qrApprove 메서드는 오직 qrTransactionService의 approve 메서드를 호출하지만 데코레이터의 비중이 훨씬 높죠. 위에서 사용된 데코레이터는 다음 의미를 가져요.
@Controller('kiosk')
: ApproveController를 컨트롤러로 등록하고,kiosk
접두사를 사용한다.@Kiosk({ transactionId: true })
: 인증된 키오스크만 접근하도록 만드는KioskJwtGuard
를 적용한다.@Post('qr')
:/kiosk/qr
에 POST 메서드로 라우터를 등록한다.@Headers()
: 헤더 값을 주입받는다.@Body()
: 본문 값을 주입받는다.
많은 일이 일어나고 있는데, 먼저 @Headers와 @Body 데코레이터를 한번 볼까요? 이 데코레이터는 요청의 해더와 본문을 주입받는 데코레이터예요. 물론 request 객체를 바로 주입받고 객체 안에 있는 본문과 해더를 가져올 수도 있어요. 하지만 이렇게 타입을 명시적으로 나타내면 타입 안전이 보장될 뿐만 아니라, pipe가 이를 참조해 입력 유효성 검사도 함께 수행할 수 있어 런타임 타입 안전까지 달성할 수 있어요.
pipe는 요청 데이터를 검증하고 변환(string to int)하는 컴포넌트예요.
그런데 pipe는 어떻게 필드에 대한 자세한 사양을 알고 입력을 검사한다는 걸까요? 아래는 @Body와 함께 사용된 ApproveRequestDto예요. 속성마다 두 개의 데코레이터가 함께 있어요. @Zod 데코레이터는 필드의 스키마를 정의하고, @ApiProperty는 Open API 사양을 지정해요. pipe는 이 메타데이터를 읽어 zod 스키마를 구성해요.
// kiosk/interface/dtos/transactionProduct.dto.ts
export class TransactionProductDto {
@ApiProperty({ format: 'uuid', description: '상품 ID' })
@Zod(z.string().uuid())
id!: string
@ApiProperty({ description: '상품 수량' })
@Zod(z.number().min(1))
amount!: number
}
// kiosk/interface/dto/approve.request.dto.ts
export class ApproveRequestDto {
@ApiProperty({ type: [TransactionProductDto] })
@Zod([TransactionProductDto])
products!: TransactionProductDto[]
}
이 패턴은 본문과 해더, 쿼리 파라미터 등에서 사용할 수 있어요.
한 속성의 타입을 세 번이나 정의하네요?
눈치챘나요? 클래스 속성에서 한 번, @Zod에서 한 번, @ApiProperty에서 또 한 번 정의하고 있어요. 어건 어쩔 수 없는 타입스크립트의 한계예요. Or is it?🤭 AOT로 오직 타입스크립트의 "타입"만으로 유효성 검사와 Open API 사양까지 정하는 방법이 있어요. 삼촌께서 만든 nestia를 한번 살펴보세요.
Dev Command Headers
위 코드를 자세히 봤다면 뭔가 이상한 점을 또 하나 찾을 수 있을 거예요. 잘 보면 @Headers() 데코레이터가 두 개나 있어요. 하나는 QRTransactionApproveHeader이고, 다른 하나는 QRTransactionApproveDCH에요.
저는 이번 Dev Command Header라는 장치를 도입해 봤어요. 이 해더는 데브 환경에서 프런트 개발자들이 API의 응답을 자유롭게 조정할 수 있도록 만들어주는 특별한 해더예요. 예를 들어 이번 API에서는 실제 결제 QR 코드를 발급받지 않고 이 해더에 입력된 사용자 ID와 결제 수단 ID로 결제를 진행하거나, 특정 오류를 반환하도록 제어할 수 있고, 로그인 API에선 테스트를 위해 JWT 리프레시 토큰의 유효 기간을 10초로 설정해 볼 수 있어요.
@DCH()
export class QRTransactionApproveDCH {
@HeaderOptions({
description:
'DP-TOKEN 헤더를 무시하고 주어진 사용자 ID와 결제수단 ID로 결제합니다.',
})
@Zod(z.string().optional())
userId?: string
@HeaderOptions({ description: '결제 수단 ID' })
@Zod(z.string().optional())
paymentMethodId?: string
}
이 장치가 없을 땐 앱 개발자가 저에게 직접 연락하여 서버 설정을 바꾸고 되돌리는 일이 잦았어요. 서로 개발하는 시간이 다르기 때문에 개발 시간을 많이 늦추는 원인이 되었지만, 요청 단위에서 조정이 가능해지니 모두 행복해질 수 있었어요. 😇
Guards & @Kiosk Decorator
@Kiosk 데코레이터를 다시 살펴볼게요.
- @Kiosk({ transactionId: true }) : 인증된 키오스크만 접근하도록 만드는 KioskJwtGuard를 적용한다.
가드(guard)를 적용한다고 되어있어요. 보통 가드는 입력 유효성 검사를 맡는 if문이라고 잘 알려져 있지만, 여기선 조금 다른 의미로 사용돼요. Nest의 가드는 pipe로 넘어오기 전에 권한이나 역할 같은 어떤 조건에 따라 라우터가 요청을 수행할지 결정하는 책임을 가지고 있어요.
가드는 애플리케이션 계층에 속해요.
처음 Nest를 배울 땐 인증과 관련된 작업을 미들웨어에서 처리하면 되지 않을까라고 생각했었어요. 하지만 가드가 미들웨어와 다른 점은 가드는 라우터와 컨트롤러 단위로 적용할 수 있고, 실행 컨택스트(ExecutionContext)를 사용할 수 있다는 거예요. ExecutionContext는 가드의 canActivate 메서드로 전달되는 파라미터로, getClass와 getMethod 메서드로 현재 컨트롤러와 처리기의 참조를 가져올 수 있어요. 그럼 컨트롤러나 처리기에 정의된 메타데이터를 가져올 수 있고, 가드의 행동을 세밀하게 조정할 수 있어요. 예를 들어 User 가드는 기본적으로 jwt를 검증하고, 유효한 사용자인지 확인하지만, 여기서 추가로 @Role(['Teacher']) 데코레이터를 만들어 사용하면 가드가 메서드에 저장된 역할 정보를 불러와 추가적인 인증 절차를 적용하도록 만들 수 있죠.
저희 키오스크 가드는 jwt를 처리하고, 옵션으로 해더에 유효한 트랜잭션 ID를 가지고 있는지 확인해요.
@Injectable()
export class KioskAuthGuard implements CanActivate {
async canActivate(context: ExecutionContext) {
const request = context.switchToHttp().getRequest<Request>()
try {
// 로직 처리
// 라우터에 적용된 `NeedTransactionId` 데코레이터 값을 불러옴
const needTransactionId = this.reflector.get(
NeedTransactionId,
context.getHandler()
)
if (needTransactionId) {
// 트랜잭션 ID 확인 로직 처리
kiosk.transactionId = transactionId
}
this.context.setContextKiosk(kiosk)
return true
} catch (error) {
JwtGuard.errorHandler(error, this.logger)
}
}
}
@Kiosk 데코레이터는 가드를 적용하는 UseGuard를 호출하고, 옵션에 따라 API 사양도 함께 등록해요.
export const Kiosk = (options?: Partial<KioskAuthOptions>) =>
applyDecorators(
JWT({ tokenType: 'access', clientTypes: [ClientType.KIOSK] }),
UseGuards(KioskAuthGuard),
NeedTransactionId(options?.transactionId ?? false),
IIF(!!options?.transactionId, [
ApiDescriptionTag('TID'),
ApiHeader({
name: KioskAuthGuard.TRANSACTION_ID_HEADER,
required: true,
description: '트랜잭션 ID',
}),
]),
),
ApiDescriptionTag('Kiosk'),
ApiBearerAuth()
)
@Kiosk 데코레이터는 클래스 레벨에서 적용할 수도 있지만, 휴먼 에러를 방지하기 위해 메서드 레벨에서만 사용해요.
정리
애플리케이션 서비스로 들어가기 전까지 어떤 일이 일어나는지 정리해 볼게요.
- 컨트롤러에서 라우터가 등록된다.
- 등록된 엔드포인트로 요청이 들어온다.
- 가드(guard)가 인증과 인가를 처리한다.
- 가드를 통과하면 파이프(pipe)가 요청 데이터를 검증하고 변환한다.
- 입력 데이터에 문제가 없으면 라우트 처리기가 애플리케이션 서비스를 호출한다.
애플리케이션 서비스
애플리케이션 서비스는 적절한 도메인 로직을 호출하는 도메인 로직의 클라이언트예요. 우리가 직접 도메인 로직을 끝 사용자에게 노출시키지 않는 이유는 도메인 로직을 편하게 이용할 수 있게 만들어 줄 뿐만 아니라 적절한 리소스 접근과 트랜잭션 관리 등 유즈케이스(use case)를 구현하기 위해서예요.
흐름상 지금 애플리케이션 계층을 설명해야 하지만, 방금 말했듯 애플리케이션 서비스는 결제 도메인 로직을 호출하니 결제 도메인이 어떻게 동작하는지 부터 설명하는게 좋을 것 같아요.
결제 과정 추상화
저희는 세 가지 결제 방법과 두 가지 지급 수단을 지원하고 있어요. 그러나 지식을 잘 종합해 보면 "각 결제 방법으로 알맞은 지급 수단을 불러오고 그 지급 수단으로 결제를 진행한다"라는 같은 행동이 반복된다는 점을 포착할 수 있어요.
- QR: 결제 토큰에 저장된 결제 수단을 불러오고 입력된 상품의 구매 가능 여부를 확인한 뒤 결제 승인을 내리고 DB에 결제 내역을 저장한다.
- 페이스 사인: 페이스 사인 인증 토큰으로 사용자가 선택한 결제 수단을 불러오고 상품 검증, 승인, 내역 저장을 한다.
- 쿠폰 결제: 쿠폰을 불러오고 사용 가능한 쿠폰인지 확인한 뒤 상품 검증, 승인, 내역 저장의 단계를 거친다.
제가 말한 공통점이 보이나요? 지급 수단을 가져오는 방법만 결제 방법에 따라 다르고, 그 이후는 거의 같은 로직이에요. 이로써 우리는 "결제 애플리케이션 서비스는 지급 수단을 불러오고 결제 로직을 호출한다"라고 추상화할 수 있어요. 그리고 상품 검증, 승인, 결제, 저장은 로직은 아마 결제 도메인 서비스로 구현될 것 같아요. 아직 완벽히 추상화되진 않았지만 성과가 있어요.
지금 가장 걱정되는 부분은 결제 도메인 서비스에 너무 많은 책임이 들어갈 것 같다는 거예요. 상품 검증, 승인, 결제, 저장은 서로 다른 책임을 가지고 있어요. 이는 단순히 도메인 서비스를 여러 함수로 나누는 문제가 아니에요. 만약 지금 상태로 도메인 서비스를 만든다면 아래와 같은 코드가 될 거예요.
async approve(
purchaseMethod: PaymentMethod | Coupon,
products: TransactionProduct[],
transactionType: TransactionType,
purchaseType: PurchaseType,
) {
await this.validateProducts(products)
let transaction: Transaction
if(transactionType === TransactionType.PAYMENT_METHOD) {
if(purchaseType === PurchaseType.GENERAL_CARD) {
transaction = this.generalCardApprove(purchaseMethod as PaymentMethod, products)
}
throw new Error('...')
} else if (transactionType === TransactionType.COUPON) {
transaction = this.couponApprove(purchaseMethod as Coupon, products)
} else {
throw new Error('...')
}
// transaction을 DB에 저장
if(isSaved) return transaction
// 지급 수단에따라 결제 취소 진행
}
지금 보니 확실히 여기서 상품 유효성 검사를 하는 건 아닌 것 같아요. 마치 라면 레시피의 첫 지시가 냄비를 깨끗이 씻는다라는 것과 비슷해요. 해야 하는 건 맞지만, 그 위치가 적절하지 않아요. 그리고 지급 수단에 따라 경우를 나눠 승인 로직과 취소 로직도 호출하고 있어요. 게다가 purchaseMethod는 유비쿼터스 언어도 아니고 지금껏 한 번도 사용되지 않은 용어예요.
안타깝게도 실제로 초기에 이런 코드를 사용했었어요. 😶🌫️
Order 값 객체
여러 시행착오를 겪으며 저는 결제 요청을 주문(Order)으로 처리해 보았어요. 주문이라는 단계는 "주문이 처리가능한지 확인하고, 주문을 승인한다"라는 자연스러운 흐름을 만들었어요. 주문 검증엔 상품 확인, 사용자 검증(비활성화 등) 로직이 들어가고, 결제 로직은 오직 주문을 승인해 주기만 하면 돼요.
전 주문을 Order 값 객체(value object)로 구현했어요. 그리고 purchaseMethod를 사용하지 않는 대신 지급 수단별로 PaymentMethodOrder, GeneralCardOrder, CouponOrder라는 서브 타입을 사용해서 지급 수단을 식별해요.
export abstract class Order {
readonly totalPrice: number
constructor(
readonly products: OrderProduct[],
readonly transactionType: TransactionType,
readonly purchaseType: PurchaseType
) {
this.validateProducts()
this.totalPrice = this.calculateTotalPrice()
}
abstract getUser(): Promisable<User | null>
public getProductsNames(): string[] {
return // ...
}
private validateProducts() {
if (this.products.length === 0) {
throw new NoProductsInOrder()
}
for (const { product, amount } of this.products) {
if (amount < 1) {
throw new WrongAmount()
}
if (product.disabled) {
throw new DisabledProductExists()
}
}
}
private calculateTotalPrice() {
if(this.totalPrice !== undefined) return this.totalPrice
return // ...
}
}
위 상황과 비교해서 보면 결제 도메인 서비스는 상품 검증의 책임을 가지지 않아 결제에 관련된 로직에만 집중할 수 있게 되었고, 애플리케이션 서비스도 Order 객체를 만든다는 더 높은 수준의 추상화된 작업을 수행하면 돼요.
값 객체는 엔티티에서만 사용하는 거 아니었나요?
값 객체는 보통 엔티티의 속성으로 사용되지만, 이렇게 서비스를 호출할 때 DTO대신 사용할 수도 있어요.
Order 값 객체 덕분에 도메인 서비스가 얼마나 간단해졌는지 한번 보세요.
async approve(order: Order) {
let transaction: Transaction
if(order instanceof GeneralCardOrder) {
transaction = this.generalCardApprove(order)
} else if (order instanceof CouponOrder) {
transaction = this.couponApprove(purchaseMethod as Coupon, products)
} else {
throw new Error('...')
}
// transaction을 DB에 저장
if(isSaved) return transaction
// 지급 수단에따라 결제 취소 진행
}
승인 전략
그런데 아직 Order의 유형에 따라 승인과 취소 메서드를 선택하는 부분이 지저분한 것 같아요. 이런 상황에 딱 적절한 해결책이 있는데, 바로 전략 패턴(strategy pattern)을 사용하는 거예요. 저는 도메인 계층에서 PaymentStrategy라는 인터페이스를 만들고 지급 수단에 따라 이 인터페이스를 구현하도록 리펙터링 했어요.
아래는 실제 사용하는 PaymentStrategy 인터페이스예요. 승인을 구현하는 approve 메서드와 트랜잭션 엔티티 커밋에 실패했을 때를 위해 결제를 취소하는 cancel 메서드가 정의되어 있어요.
// transaction/domain/services/transaction/strategies/strategy.interface.ts
export interface PaymentStrategy<O extends Order = Order> {
approve(order: O): Promisable<ApproveResult>
cancel(transaction: Transaction): Promisable<CancelResult>
}
아래는 위 인터페이스를 구현하는 GeneralCardStrategy예요.(코드를 줄이기 위해 중요하지 않은 부분은 생략했어요.)
// transaction/domain/services/transaction/strategies/generalCard/generalCard.strategy.ts
export class GeneralCardStrategy implements PaymentStrategy<GeneralCardOrder> {
constructor(private readonly pgTransactionPort: PGTransactionPort) {}
async approve(order: GeneralCardOrder) {
const generalCard = await order.paymentMethod.typeEntity.loadOrFail()
const user = await order.getUser()
const approveResult = await this.pgTransactionPort.approve({
user,
billingKey: generalCard.billingKey,
amount: order.totalPrice,
producstNames: order.getProductsNames(),
})
return Ok({ /* ... */ })
}
async cancel(approveSuccess: ApproveSuccess) {
const cancelResult = await this.pgTransactionPort.cancel({
cancelAmount: approveSuccess.totalPrice,
cancelmessage: '시스템 오류로인한 취소',
TID: approveSuccess.billingId as string,
})
// ...
return Ok(None)
}
}
도메인 서비스는 Order 유형에 따라 전략을 선택하기만 하면 돼요.
async approve(order: Order) {
const strategy = this.resolveStrategy(order)
const transaction = await strategy.approve(order)
// transaction을 DB에 저장
if(isSaved) return transaction
const cancelResult = await strategy.cancel(transaction)
// ...
return transaction
}
결제 도메인 서비스는 완전히 결제에 관련된 로직에만 집중하고, Order 별 승인 로직도 제자리를 찾았어요. 그리고 무엇보다 확장과 유지보수하기에 유리해졌어요. 새로운 결제 방법이 필요한가요? 컨트롤러와 애플리케이션 서비스를 추가하면 돼요. 새로운 지급 방법이 생겼나요? 새 결제 전략을 만들면 돼요. 쿠폰 승인 절차에 변경 사항이 생겼나요? 쿠폰 전략만 리펙터링하면 돼요!
애플리케이션 서비스
드디어 애플리케이션 서비스를 살펴볼 수 있어요! 위에서 말한 대로 order 객체를 만들고 승인 도메인 서비스를 호출해요.
async approve(
command: ApproveRequestDto,
token: string,
dch: QRTransactionApproveDCH
): Promise<TransactionApproveResponseDto> {
if (Coupon.isCoupon(token)) {
return this.couponTransactionService.approve({
coupon: token,
...command,
})
}
const kiosk = this.contextService.getContextKiosk({ transactionId: true })
const paymentMethod = await this.getPaymentMethod(token, dch)
if (!paymentMethod) {
throw new WrongPayToken()
}
const orderProducts = await this.transactionDomainService.mapProducts(command.products)
const order = Order.createFromPaymentMethod(
TransactionType.APP_QR,
kiosk,
orderProducts,
paymentMethod
)
const transaction = await this.transactionDomainService.approve(order)
return {
status: transaction.status,
message: transaction.statusMessage,
totalPrice: transaction.totalPrice,
}
}
정리
지금까지의 내용을 정리해 볼게요.
- 요청이 들어오면 키오스크 인증과 본문 유효성 검사를 진행한다. (인터페이스 계층)
- 애플리케이션 서비스는 결제수단을 불러오고 Order 값 객체를 만들어 결제 도메인 서비스를 호출한다. (애플리케이션 계층)
- Order 객체가 만들어질 때 상품 유효성 검사를 한다 (도메인 계층)
- 결제 승인 도메인 서비스는 Order로 결제 전략을 선택하고, 결제 승인을 낸 뒤, 결제 내역을 저장한다. (도메인 계층)
폴더 구조
최종 폴더 구조는 다음과 같아요.
도메인 이벤트
혹시 처음에 QR 결제 개요에서 말한 내용을 기억하고 계신가요? 결제가 끝나면 결제 이벤트를 받고, 푸쉬 알림도 받는다고 했었어요. 그런데 아직 어디서도 이 내용이 다뤄지지 않았어요.
결제 완료 푸쉬 알림 구현을 예시로 들어볼게요. 일단 알림 전송은 비즈니스 로직이라고 하기엔 알맞지 않아요. 오히려 사용 사례(use-case)에 더 가까운 것 같아요. 그럼 애플리케이션 서비스에서 결제 승인 후 알림을 보내면 되겠네요.
그런데 만약 알림을 보내는데 실패하면 어쩌죠? 오류는 그냥 무시해 버리면 된다고요? 그럼 극단적으로 알림을 보내는데 10초가 걸리면 어떡해요? 결제 애플리케이션 서비스는 하나가 아니라는 것도 기억해야 해요. 새로운 서비스가 추가될 가능성도 고려해야 해요. 새로운 애플리케이션 서비스를 만들어 분리하면 될까요? 결제 방법마다 알림 전송 규칙이 다르면 분리할 수 없을걸요? 아 그리고 결제가 끝나면 해야 할 일이 5개나 더 있다는 것도 잊지 마세요:)
이 문제의 해결 방법을 찾는 건 의미 없는 행동일 거예요. 접근 방법부터 잘못되었거든요. 푸쉬 알림은 결제 과정에서 필수적인 작업이 아니에요. 조금 늦거나 실패해도 크게 문제 되지 않죠. 그리고 결제 애플리케이션 서비스가 결제 외의 관심사를 가져서도 안 돼요. 저희는 도메인 이벤트를 이용해서 깔끔하게 해결할 수 있어요.
도메인 이벤트는 도메인에서 어떤 사건이 발생했다는 정보를 알리는 이벤트예요. 만약 여러분의 도메인에서 "~할 때" 라든가 "~하면 ~알려주세요", "~가 끝나면"같은 말이 오가면 높은 확률로 도메인 이벤트를 사용해야 할 거예요. 더 자세한 판단 근거는 한 트랜잭션에서 일관성이 보장되는 게 아닌, 결과적(eventual) 일관성을 요구하는지로 확인할 수 있어요.
도메인 이벤트는 주로 엔티티를 참조하기 때문에 이벤트를 발생시킨 프로세스에서 발행하여 메모리에 남아있는 참조를 사용하도록 최적화할 수도 있고, Amazon SQS 같은 외부 이벤트 스트림으로 넘길 수도 있어요.
저희는 결제가 완료되면 TransactionCompleted라는 이름의 이벤트를 발행하고, 이벤트 발생 시 아래 작업을 수행하도록 했어요.
- 주 결제수단 변경: 결제에 성공하면 결제에 사용된 결제 수단을 주 결제 수단으로 설정한다.
- 데브 환경 결제 취소: 데브 서버에선 승인된 결제를 자동으로 취소한다.
- 결제 상태 변경: 결제 상태를 알리는 SSE의 결제 상태를 변경한다.
- ERP 판매 등록 큐 추가: 우리는 재고 관리를 서드파티 ERP로 관리하는데 ERP에 판매 기록을 등록하기 위해 전용 큐(queue)에 트랜잭션 참조를 등록한다.
- 푸쉬 알림 전송: 성공 시 결제 금액과 상품을, 실패 시 실패 원인을 푸시 알림으로 보낸다.
이 작업들은 모두 결제 트랜잭션에서 함께 수행되는 게 아닌 결제 후 조금 늦게 처리돼도 괜찮은 것들이에요.
Nest에서 이벤트를 발행하려면 EventEmitter 라이브러리를 사용하거나 CQRS 라이브러리를 사용할 수 있어요. EventEmitter는 범용 이벤트 발행기이고, CQRS는 에그리겟에 한정하여 이벤트를 전파할 수 있어요. 지금 상황에선 어떤 걸 쓰든 상관없지만, 저는 EventEmitter를 선택했어요. 왜냐하면 CQRS 라이브러리의 작동 방법을 아직 완벽히 이해하지 못했고, 이건 CQRS 패턴을 사용할 때가 오히려 적절할 것 같았거든요.
Mikro ORM에선 라이프사이클 훅을 이용하면 도메인 이벤트를 효과적으로 발행할 수 있어요. 엔티티의 생성(afterCreate), 업데이트(afterUpdate) 등 기본적으로 지원하는 훅에서 eventEmitter로 다시 이벤트를 발급해요.
@Injectable()
export class DomainEventPublisher implements EventSubscriber {
constructor(
em: EntityManager,
private readonly eventEmitter: EventEmitter2
) {
em.getEventManager().registerSubscriber(this)
}
getSubscribedEntities(): EntityName<Transaction>[] {
return [Transaction]
}
afterCreate(args: EventArgs<unknown>) {
if (args.entity instanceof Transaction) {
this.eventEmitter.emit(
TransactionCompletedEvent.EVENT_NAME,
new TransactionCompletedEvent(args.entity)
)
}
}
}
이 이벤트를 구독하는 구독자는 @OnEvent 데코레이터로 등록할 수 있어요.
@Injectable()
export class TransactionCompletedEventHandler {
@OnEvent(TransactionCompletedEvent.EVENT_NAME)
async sendNotification(event: TransactionCompletedEvent) {
// 푸쉬 알림 전송
}
}
여기서 한 가지 생각해 볼 게 있어요. 이벤트 구독자는 이벤트를 발급한 대상이 속한 모듈에서 만드는 게 좋을까요, 아니면 이벤트를 처리하는 모듈에서 만드는게 좋을까요?
전체 플로우 정리
지금까지 모든 과정을 정리하면 다음과 같아요.
완벽한 DDD
것 보기엔 DDD를 잘 구현한 것 같지만 아직 고칠 부분이 많아요. 원시 타입을 너무 많이 쓰고 있고, 애플리케이션 서비스에 도메인 로직이 들어있는 경우도 꽤 있어요. 그리고 고쳐야 한다는 걸 모르는 부분도 분명히 있겠죠.
전 프로젝트 처음부터 DDD를 완벽하게 적용할 수 없다는 것과 이해하는데 오랜 시간이 걸릴 것이라는 걸 알고 있었어요. 초기엔 일부 DDD 빌딩 블록을 완전히 잘못 이해하기도 했었어요. 그래서 처음부터 일부러 무리해서 완벽하게 만드려고 하진 않았어요.
하지만 이 방법을 프로덕션을 목표로 사용한다면 그리 좋은 전략은 아니에요. 수시로 구현 방법이 바뀌고 로직이 여러 계층에서 왔다 갔다 하는 과도기를 거칠 땐 테스트 코드를 짜기 애매하거든요. 테스트를 작성해도 일주일만 있으면 쓸모없는 코드가 돼버리기 쉬웠어요. 리펙터링 과정에선 테스트에 신경 쓸 시간도 없죠. 처음 개념을 접한다면 작은 프로젝트를 리펙터링해보면서 공부하는 게 좋다고 생각해요.
한 가지 고백하자면 v2 첫 런칭날 코드 버그로 상품 수량이 적용되지 않는 버그가 발견됐어요. 이슈는 금방 고치고 누락된 결제를 추가로 진행했지만, 1년 전 추억을 다시 느끼는 참 좋은 시간이었어요.
나도 DDD?
저는 이 글로 많은 사람들이 DDD를 알았으면 좋겠어요. 만약 DDD에 관심이 생긴 분이 계시다면 이 분들을 위해 제가 참고한 것들과 DDD를 공부할 수 있는 리소스를 공유해 드릴게요.
- Domain-Driven Hexagon 리파지토리
- Github topics - ddd (ddd 관련 리파지토리를 찾을 수 있어요.)
- 도메인 주도 설게 첫걸음(블라드 코노노프) - 온라인에서 원서(Learning Domain-Driven Design) ebook을 찾을 수 있어요. (DDD가 처음이라면 굿)
- 도메인 주도 설계(에릭 에반드) - 온라인에서 원서(Domain-Driven Design) ebook을 찾을 수 있어요. (DDD를 본격적으로 공부하려면 굿)
- 도메인 주도 설계 구현(반 버논) - 온라인에서 원서(Implementing Domain-Driven Design)를 찾을 수 있어요. (DDD를 잘 구현하고 싶으면 굿)
- 엔터프라이즈 애플리케이션 아키텍처 패턴(마틴 파울러) - 온라인에 원서(Patterns of Enterprise Application Architecture)를 찾을 수 있어요. (남들과 다른 OOP 개발자가 되고 싶다면 굿)
만약 책을 읽는다면 실제 프로젝트와 함께 보는 걸 권장드려요. 제가 지금 해줄 수 있는 유일한 조언인데, 책에서도 실생활 예제를 다루고 있지만, 이 글처럼 전체 코드가 아닌 단편적인 예제 코드만 보여주기 때문에 처음엔 쉽게 이해되지 않을 수도 있어요. 위 링크에서 자신 있는 언어로 된 별이 많은 리파지토리를 하나 선택해서 그 코드와 책을 함께 본다면 더 쉽게 배울 수 있을 거예요.
쓰다 보니 내용이 정말 많아졌네요. 티스토리 에디터도 버벅거리기 시작했어요. 여기까지 다 읽은 사람은 다섯 손가락 안에 들 것 같지만 최대한 재밌게 쓰려고 노력했다는 걸 알아줬으면 좋겠어요.
읽어주셔서 정말 고맙습니다! 🙇♂️🙇♂️
'디미페이' 카테고리의 다른 글
Local Token 0.4 Release Note (1) | 2024.11.24 |
---|---|
QR 코드에 최대한 많이 때려넣기 (0) | 2024.10.15 |
🤫오프라인이지만 온라인이어야 해요 - 로컬 생성 결제 토큰 (3) | 2024.10.05 |
🐣백지에서 시작하는 디미페이 v2 백엔드 리팩터링 (2) | 2024.09.28 |