프로젝트 개요
핸디버스는 공연/행사 관람객들의 이동 수요를 모아 편안하게 공연을 관람할 수 있도록 버스 노선을 개설, 제공해주는 수요 응답형 모빌리티 서비스
입니다.
문제 정의 및 솔루션
기술적 차별점과 개인 기여
핸디버스는 제대로된 소프트웨어도 없던 시절, 팀에 초기 창업 멤버
로 합류하여 기획과 백엔드 개발 전과정을 참여하였습니다.
백엔드 개발
수요 기반 노선 설계 자동화 기술 개발
모집된 수요조사를 기반으로, 지역 별 최적화된 노선 설계를 자동으로 해주는 알고리즘을 개발했습니다.
실제 서비스 데이터에서 도출된 노선 추천 화면
다양한 비즈니스 상황에 대응할 수 있도록 추천 설계 기준을 변수화 하였습니다.
알고리즘 최적화를 통해 2000개의 수요데이터 기준 200ms 내로 처리되도록 설계했습니다. 또한 캐싱을 활용해 실제 연산 처리에 대한 빈도 또한 최적화 하였습니다. 이를 통해 최상단의 대시보드에서 수많은 행사에 대한 노선 예측 결과를 복수조회 해도 부담없게 제작하였습니다.
MSA로의 확장성을 고려한 DDD 기반 모놀리틱 서비스 설계
스타트업의 특성상 구조보다 빠른 기능 구현에 집중하기 마련입니다. 허나 이로 인해 대부분의 스타트업의 초기 코드는 빠르게 레거시가 쌓이며 잘 구조화되지 못하기 마련입니다.
소프트웨어 공학 및 아키텍처에 관심이 많던 저는, 서비스 전반에 DDD의 전술적 설계 전략을 녹여내어 각각의 서비스들이 독립된 BC(bounded-context)를 구성하고, 도메인의 순수성을 유지하도록 코드를 설계 하였습니다.
이를 위해 결제 도메인과 예약 도메인 등 서로 밀접하지만 다른 BC에 속한 도메인들의 소통을 이벤트 기반 비동기 메시징을 통해 설계하였습니다.
이외에도, 디자인 패턴을 활용해 SOLID 원칙을 준수하며 설계했습니다. 계층간 DIP를 통해 도메인 레이어의 순수성을 보장하고, PG사나 OAUTH 등 외부 채널에 대한 서비스는 인터페이스와 팩토리 패턴으로 추상화하여 상위 서비스와 느슨히 결합시키고 내부 로직을 캡슐화 하였습니다.
팀에 백엔드 개발자가 저 혼자이던 상황에서 MSA는 엄청난 오버엔지니어링이라 도입할 수 없었습니다. 다만 기업의 근간이 될 코어 서버인 만큼 앞으로 누가오든 쉽게 온보딩하고, 점진적으로 개선하기 쉽게 단일 책임을 지키는 코드를 짜고자 노력하였습니다.
ex) AggregateRoot class와 생성시 추가되는 도메인 이벤트
export class Payment extends AggregateRoot<PaymentProps> {
private constructor(props: PaymentProps, id: UniqueEntityID) {
super(props, id);
}
// 생략
public static createNew(newProps: PaymentNewProps): Result<Payment> {
const nowDayjs: Dayjs = getNowDayjs();
const paymentOrError: Result<Payment> = this.create(
{
...newProps,
discountAmount: newProps.principalAmount - newProps.paymentAmount,
refundableAmount: newProps.paymentAmount,
createdAt: nowDayjs,
updatedAt: nowDayjs,
isConfirmed: false,
refundRequests: [],
},
new UniqueEntityID(),
);
if (paymentOrError.isSuccess) {
paymentOrError.value.addDomainEvent(
PaymentCreatedEvent.create(paymentOrError.value.id.getString(), {
paymentId: paymentOrError.value.id.getString(),
originType: newProps.originType,
originId: newProps.originId.getString(),
userId: newProps.userId.getString(),
}),
);
}
return paymentOrError;
}
ex) 데코레이터로 영속화 시 이벤트 발행에 대한 관심사 처리
@Injectable()
@DomainEventsDispatcher()
export class MysqlPaymentRepository extends PaymentRepository {}
ex) 이벤트 핸들러
@Injectable()
export class ShuttleOperationPaymentCreatedHandler extends EventHandler<PaymentCreatedEvent> {
constructor(
@Inject(EVENT_SUBSCRIBER)
private readonly eventSubscriber: IEventSubscriber,
private readonly reservationService: ReservationService,
slackWhenErrorOccuredUseCase: SlackWhenErrorOccuredUseCase,
) {
super(slackWhenErrorOccuredUseCase);
}
async onModuleInit() {
await this.eventSubscriber.subscribe(PaymentCreatedEvent, this);
}
@Transactional()
async processEvent(event: PaymentCreatedEvent): Promise<void> {
const { paymentId, originType, originId, userId }: PaymentCreatedPayload = event.payload;
if (originType === OriginType.RESERVATION) {
const reservation = await this.reservationService.getById(new UniqueEntityID(originId), {
userId: new UniqueEntityID(userId),
});
if (!reservation) {
throw new InternalServerErrorException("Reservation not found");
}
reservation.injectPaymentId(new UniqueEntityID(paymentId));
await this.reservationService.update(reservation);
}
}
}
EventHandler의 상속체는 처리중인 이벤트를 Promise Set으로 보유하며 리트라이, 알림, graceful-shutdown을 보장받습니다.
ex) PG사 내부 서비스 로직 추상화
export interface PaymentProvider<T extends ProviderPaymentData = ProviderPaymentData> {
confirmPayment(payment: Payment, paymentKey: string): Promise<boolean>;
fetchProviderPayment(payment: Payment): Promise<T>;
getInprogressPayment(payment: Payment): Promise<T>;
refundPayment(
payment: Payment,
paymentKey: string,
refundAmount: number,
refundReason: string,
): Promise<ProviderRefundResponse>;
}
@Transactional()
async confirmPayment(payment: Payment, paymentKey: string) {
const provider: PaymentProvider = this.paymentFactory.getProvider(payment.pgType);
await provider.confirmPayment(payment, paymentKey);
payment.confirmPayment();
await this.paymentPersistor.update(payment);
}
ex) 계층간의 DIP
domains/reservations/reservation.persistor
@Injectable()
export abstract class ReservationPersistor {
abstract create(reservation: Reservation): Promise<Reservation>;
abstract update(reservation: Reservation): Promise<Reservation>;
abstract updateMany(reservations: Reservation[]): Promise<Reservation[]>;
abstract delete(reservation: Reservation): Promise<void>;
abstract deleteMany(reservationIds: UniqueEntityID[]): Promise<void>;
}
infrastructues/reservations/repository-reservation.persistor
@Injectable()
export class RepositoryReservationPersistor extends ReservationPersistor {
constructor(private readonly reservationRepository: ReservationRepository) {
super();
}
override async create(reservation: Reservation): Promise<Reservation> {
return this.reservationRepository.save(reservation);
}
//생략
TS는 런타임 시 인터페이스가 휘발되기에 추상 클래스를 활용하였습니다. 마찬가지로 ReservationRepository는 MysqlReservationRepository 등의 구현체를 가지며 DI됩니다.
기획 기여
도메인 주도 설계(DDD)의 전략적 설계 방법론들을 차용하여, 이벤트 스토밍을 통해 제품의 비즈니스 도메인을 도출하고, 모호했던 정책들을 구체화 했습니다.