프로젝트를 진행하면서 구축하였던 "주문-결제 프로세스"를 기록으로 남겨보고자 한다.
사용자가 제품을 결제하고 주문하는 과정은 어떤 과정속에서 진행될까? 물론 도메인마다 상이할 수도 있겠지만 이번에 참여한 o2o 서비스의 경우, 흔히 생각하는 배달앱과 비슷한 프로세스로 진행이 된다.
장바구니 -> 결제(주문)서 -> pg사 연동을 통한 결제 진행 -> 결제 성공 시 주문 프로세스 진행 -> 주문 성공 혹은 취소 -> 주문 성공 시 매장 서버에 주문 접수 알림
( 물론 위의 과정 사이 사이에 디테일한 여러 포인트가 있을 것이다... )
아직 진행중인 "메시징 큐 (Rabbit MQ)"를 통한 Server to Server 통신을 제외한 유저의 주문 결제 프로세스 과정을 담아보려 한다.
주문 결제 프로세스를 진행하는 과정은 정말 많이 고민하고 시간을 많이 투자하였던 것 같다. 일전에 Stripe를 통해 간단한 주문 결제 과정을 체험해본 적은 있지만 그것은 사실 아무것도 아니였다. 이번엔 실제 유저의 사용을 고려해야했고, 주문 혹은 주문 취소시 발생할 수 있는 모든 부가적 요소 (재고처리, 푸시알림, 취소시 롤백 등) 에 대한 고민 역시 요구되었다.
실제 금전을 통한 결제가 이뤄지는 만큼 100%는 당연히 힘들겠지만 최대한 문제가 없는 상황을 만들려 하였고, 설령 금전과 관련된 문제 (결제는 되었지만 주문이 되지 않았다, 주문은 되었지만 결제가 되지 않았다)가 일어나더라도 이를 빠르게 트래킹 하기위해 일련의 장치를 두는 것을 고려할 필요도 있었다.
API 설계 역시 마찬가지다. (이미지 출처: 미리)
더 나은 유저 경험을 위해 어떻게 화면 설계를 보안하고, 각 화면 혹은 버튼마다 어떤 기능을 제공할 것인가에 대한 명세를 고민하였다. 단순 코드 한 줄 짜는것보다 클라이언트와 서버간, 화면 명세에 따른 API 플로우를 정의하고 고민을 나누는 것이 더 중요한 시간이었다.
당시 서버의 진행 상황이 조금 더 빨랐기 때문에 나 역시 간단히 React를 사용해 toss payments 결제 창을 띄워 전체 결제 -> 주문 플로우을 미리 설계해 보는 시간을 가져 보았고, 추후 실제 앱(Flutter) 클라이언트와 연동하는데 있어 빠르게 API를 적용해 볼 수 있게 되었다.
결제 주문을 진행하면서 고민하였던 모든 생각을 다 녹여 낼 순 없겠지만 앞으로 진행될 각 파트에서 조금 더 기능적으로 구체화 된 고민을 공유해보고자 한다.
그럼, 이번 포스팅에선 결제 주문 서 페이지로 넘어가기 전, 첫 시작을 알리는 "장바구니(카트)" 페이지를 잠깐 들여다보며 이번 시리즈를 예고해 보도록 하겠다.
옵션에 대한 수량은 존재하지 않지만 테이블에서 보다 시피 각 메뉴에 대해 주문할 "수량"은 분명히 체킹해 줄 필요가 있었다.
유저는 단일 메뉴가 아닌 여러 메뉴를 주문할 수 있고, 각 메뉴당 (재고 선 안에서)원하는 수량을 장바구니에서 설정해 줄 수 있다.
물론, 메뉴 상세 페이지 등에서 메뉴의 잔여 재고 수량을 보여주도록 하고 있지만 유저가 장바구니로 넘어온 시점에서 재고 수량이 달라질 수 있다.
A,B 메뉴를 장바구니에 담은 유저가 있다고 가정하자. 만약 A 메뉴의 잔여 수량이 5개이지만 유저가 7개를 주문 수량으로 넣었을 경우는 충분히 일어날 수 있는 상황이다. 하지만 이 상황은 엄연히 "품절"이 일어난 것이 아닌 잔여 수량의 불충분으로 인한 미스이다. 즉, 해당 체킹을 굳이 결제-주문 과정까지 넘겨주지 않고 해당 화면 내에서 검증해주는 것이 바람직하다 판단하였다. (이미지 출처: 미리)
현재 위의 화면에서 "OOOO원 주문하기"를 클릭하게 되면 일반적으로 주문서 페이지로 이동하게 된다. 이에 따라 주문서 페이지로 이동하기 전, 수량 체킹을 수행하는 API를 두어 선처리를 해주게끔 한다.
여러 메뉴를 주문하고, 동시에 각 메뉴마다 여러 수량을 주문하였을 경우 우리가 앞서 가정한대로 특정 메뉴의 주문수량이 잔여수량이 넘기게 된다면 "서버"는 "클라이언트"에게 어떤 값을 넘겨주어야 할까?
이것에 초점을 맞추어 해당 API 로직을 설계한다.
Request DTO
장바구니 화면에서 클라이언트는 각 메뉴의 ID(식별자)값과 주문 수량을 알고 있는 상태이다. 두 값을 객체형태로 배열에 담아 서버 요청에 사용한다.
export class CartMenuReqDto {
@ApiProperty({
example: 1,
description: '주문할 메뉴 ID',
required: true,
})
@IsInt()
menuId: number;
@ApiProperty({
example: 10,
description: '주문할 메뉴 수량',
required: true
})
@IsInt()
@Min(1)
cartMenuQuantity: number;
}
export class MenuStockCheckReqDto {
@ApiProperty({
type: [CartMenuReqDto],
})
@IsArray()
@ValidateNested({ each: true })
@Type(() => CartMenuReqDto)
menus: CartMenuReqDto[];
}
Command
클라이언트로부터 전달받은 위의 DTO 객체는 프리젠테이션 내부 매핑 과정을 거쳐 아래와 같은 command 객체로써 domain layer에게 전달된다.
export class CartMenuReqCommand {
constructor(
public menuId: number,
public cartMenuQuantity: number
) {}
}
export class MenuStockCheckReqCommand {
constructor(
public menus: CartMenuReqCommand[]
) {}
}
Response DTO
클라이언트에게 응답할 필드를 정의한 객체이다. 재고 수량 조건에 맞지 않는 주문 메뉴가 존재하는가에 대한 불리언 정보와, 수량 조건에 맞지 않는 메뉴에 대한 정보를 객체로 묶은 배열로써 전달해준다.
export class InefficientMenu {
@ApiProperty({
example: 1,
description: '재고 수량이 불충분한 메뉴의 메뉴ID',
required: true,
})
menuId: number;
@ApiProperty({
example: '메뉴명 1',
description: '재고 수량이 불충분한 메뉴의 메뉴명',
required: true,
})
menuName: string;
@ApiProperty({
example: 2,
description: '해당 메뉴의 남은 재고 수량',
required: true,
})
remainQuantity: number;
constructor(menuId: number, menuName: string, remainQuantity: number) {
this.menuId = menuId;
this.menuName = menuName;
this.remainQuantity = remainQuantity;
}
}
export class MenuStockCheckResponse {
@ApiProperty({
example: 1,
description: '재고 수량이 불충분한 메뉴가 존재하는지 여부',
required: true,
})
isInSufficientMenuExisting: boolean;
@ApiProperty({
type: [InefficientMenu]
})
@Type(() => InefficientMenu)
@ValidateNested({ each: true })
inEfficientMenus: InefficientMenu[];
constructor(isInSufficientMenuExisting: boolean, inEfficientMenus: InefficientMenu[]) {
this.isInSufficientMenuExisting = isInSufficientMenuExisting;
this.inEfficientMenus = inEfficientMenus;
}
}
Adaptor (Service, Controller는 생략)
public async checkMenuStocksIsSufficient(menuStockCheckReqCommand: MenuStockCheckReqCommand): Promise<MenuStockCheckResponse> {
const inEfficientMenus: InefficientMenu[] = [];
for (const menuData of menuStockCheckReqCommand.menus) {
const { menuId, cartMenuQuantity } = menuData;
const menu = await this.menuRepository.findByCondition({
select: {
menuId: true,
name: true,
quantity: true,
},
where: {
menuId: menuId,
},
});
if (menu.quantity < cartMenuQuantity) {
// Menu doesn't have enough stock
inEfficientMenus.push({
menuId: menu.menuId,
menuName: menu.name,
remainQuantity: menu.quantity,
});
}
}
const isInSufficientMenuExisting = inEfficientMenus.length !== 0;
return new MenuStockCheckResponse(isInSufficientMenuExisting, inEfficientMenus);
}
정상적 주문 수행 (재고 미스 X)
재고 수량 미스
자, 이렇게 재고 수량에 대한 미스가 발생하게 되면 위의 응답 정보를 통해 클라이언트는 주문서로 넘어갈지 특정 dialog 창을 띄워 유저에게 상황을 인지 시킬지를 판별할 수 있게 된다.
(결제-주문 포스팅은 계속 이어집니다 👍👍👍)