대출 신청 기능을 Clean Architecture로 만들어 보겠습니다.
6-1. 폴더 구조
src/loan/
├── entities/ # ① Entities
│ ├── loan.ts
│ └── credit-policy.ts
├── use-cases/ # ② Use Cases
│ ├── apply-loan/
│ │ ├── apply-loan.input-port.ts # Input Boundary + Input Data
│ │ ├── apply-loan.output-port.ts # Output Boundary + Output Data
│ │ └── apply-loan.interactor.ts # Use Case Interactor
│ └── ports/
│ ├── loan.gateway.ts # Data Access Interface
│ └── credit-score.gateway.ts # 외부 신용 조회 인터페이스
├── adapters/ # ③ Interface Adapters
│ ├── controllers/
│ │ ├── loan.controller.ts
│ │ └── apply-loan.request.ts
│ ├── presenters/
│ │ └── apply-loan.json-presenter.ts
│ └── gateways/
│ ├── typeorm-loan.gateway.ts
│ ├── loan.orm-entity.ts
│ └── nice-credit-score.gateway.ts
└── loan.module.ts # ④ Frameworks (조립)
엉클 밥은 최상위 폴더 이름이 controllers/, services/처럼 기술을 외치지 말고 loan/, order/처럼 업무를 외치라고 말합니다. 이것을 Screaming Architecture(소리치는 아키텍처)라고 합니다. 폴더 구조만 봐도 "이건 대출 시스템이구나"를 알 수 있어야 한다는 뜻입니다.
6-2. ① Entities
// entities/credit-policy.ts
export class CreditPolicy {
private static readonly MIN_SCORE = 500;
static isEligible(score: number): boolean {
return score >= CreditPolicy.MIN_SCORE;
}
// 신용 점수에 따라 한도 결정: 업무 규칙
static limitFor(score: number): number {
if (score >= 900) return 50_000_000;
if (score >= 700) return 30_000_000;
return 10_000_000;
}
static annualRateFor(score: number): number {
return score >= 800 ? 0.045 : 0.07;
}
}
// entities/loan.ts
export class Loan {
private constructor(
readonly id: string,
readonly applicantId: string,
readonly principal: number,
readonly annualRate: number,
private balance: number,
) {}
static open(id: string, applicantId: string, amount: number, score: number): Loan {
if (!CreditPolicy.isEligible(score)) {
throw new NotEligibleError(score);
}
const limit = CreditPolicy.limitFor(score);
if (amount > limit) {
throw new ExceedsLimitError(amount, limit);
}
return new Loan(id, applicantId, amount, CreditPolicy.annualRateFor(score), amount);
}
monthlyInterest(): number {
return Math.floor((this.balance * this.annualRate) / 12);
}
get currentBalance(): number {
return this.balance;
}
}
6-3. ② Use Cases: 포트 정의
// use-cases/apply-loan/apply-loan.input-port.ts
export interface ApplyLoanInput {
applicantId: string;
amount: number;
}
export interface ApplyLoanInputPort {
execute(input: ApplyLoanInput, presenter: ApplyLoanOutputPort): Promise<void>;
}
// use-cases/apply-loan/apply-loan.output-port.ts
export interface ApplyLoanSuccess {
loanId: string;
approvedAmount: number;
annualRate: number;
monthlyInterest: number;
}
export interface ApplyLoanOutputPort {
approved(output: ApplyLoanSuccess): void;
rejected(reason: 'LOW_CREDIT_SCORE' | 'EXCEEDS_LIMIT', detail: string): void;
}
// use-cases/ports/loan.gateway.ts
export interface LoanGateway {
nextId(): string;
save(loan: Loan): Promise<void>;
}
// use-cases/ports/credit-score.gateway.ts
export interface CreditScoreGateway {
scoreOf(applicantId: string): Promise<number>;
}
6-4. ② Use Cases: Interactor
// use-cases/apply-loan/apply-loan.interactor.ts
// 데코레이터 없음: 순수 TypeScript 클래스
export class ApplyLoanInteractor implements ApplyLoanInputPort {
constructor(
private readonly loans: LoanGateway,
private readonly creditScores: CreditScoreGateway,
) {}
async execute(input: ApplyLoanInput, presenter: ApplyLoanOutputPort): Promise<void> {
const score = await this.creditScores.scoreOf(input.applicantId);
let loan: Loan;
try {
loan = Loan.open(this.loans.nextId(), input.applicantId, input.amount, score);
} catch (e) {
if (e instanceof NotEligibleError) {
return presenter.rejected('LOW_CREDIT_SCORE', `신용 점수 ${score}점`);
}
if (e instanceof ExceedsLimitError) {
return presenter.rejected('EXCEEDS_LIMIT', `한도 ${e.limit}원`);
}
throw e;
}
await this.loans.save(loan);
presenter.approved({
loanId: loan.id,
approvedAmount: loan.principal,
annualRate: loan.annualRate,
monthlyInterest: loan.monthlyInterest(),
});
}
}
Interactor에는 @Injectable()조차 없습니다. Nest.js가 사라져도 이 파일은 그대로 동작합니다.
6-5. ③ Interface Adapters: Presenter
// adapters/presenters/apply-loan.json-presenter.ts
export interface ApplyLoanViewModel {
status: number;
body: Record<string, unknown>;
}
export class ApplyLoanJsonPresenter implements ApplyLoanOutputPort {
viewModel: ApplyLoanViewModel | null = null;
approved(output: ApplyLoanSuccess): void {
this.viewModel = {
status: 201,
body: {
loanId: output.loanId,
approvedAmount: output.approvedAmount.toLocaleString('ko-KR') + '원',
annualRate: (output.annualRate * 100).toFixed(1) + '%',
monthlyInterest: output.monthlyInterest.toLocaleString('ko-KR') + '원',
},
};
}
rejected(reason: string, detail: string): void {
const messages: Record<string, string> = {
LOW_CREDIT_SCORE: '신용 점수가 기준에 미달합니다',
EXCEEDS_LIMIT: '신청 금액이 한도를 초과합니다',
};
this.viewModel = {
status: 422,
body: { code: reason, message: messages[reason], detail },
};
}
}
금액에 쉼표를 찍고, 이율을 퍼센트로 바꾸고, 거절 사유를 한국어 문구로 바꾸는 표현 로직이 모두 Presenter에 있습니다. Interactor와 Entity는 이런 일을 모릅니다.
6-6. ③ Interface Adapters: Controller
// adapters/controllers/apply-loan.request.ts
export class ApplyLoanRequest {
@IsString() applicantId: string;
@IsInt() @Min(1_000_000) amount: number;
}
// adapters/controllers/loan.controller.ts
@Controller('loans')
export class LoanController {
constructor(
@Inject(APPLY_LOAN_INPUT_PORT) private readonly applyLoan: ApplyLoanInputPort,
) {}
@Post()
async apply(@Body() req: ApplyLoanRequest, @Res() res: Response) {
const presenter = new ApplyLoanJsonPresenter();
await this.applyLoan.execute(
{ applicantId: req.applicantId, amount: req.amount },
presenter,
);
res.status(presenter.viewModel!.status).json(presenter.viewModel!.body);
}
}
Presenter는 요청마다 새로 만듭니다. 결과를 담는 상태(viewModel)를 가지기 때문에 싱글턴으로 공유하면 요청끼리 섞입니다.
6-7. ③ Interface Adapters: Gateway
// adapters/gateways/typeorm-loan.gateway.ts
@Injectable()
export class TypeOrmLoanGateway implements LoanGateway {
constructor(
@InjectRepository(LoanOrmEntity) private readonly repo: Repository<LoanOrmEntity>,
) {}
nextId(): string {
return randomUUID();
}
async save(loan: Loan): Promise<void> {
await this.repo.save({
id: loan.id,
applicantId: loan.applicantId,
principal: loan.principal,
annualRate: loan.annualRate,
balance: loan.currentBalance,
});
}
}
6-8. ④ Frameworks: 조립 (Main Component)
// loan.module.ts
export const APPLY_LOAN_INPUT_PORT = Symbol('APPLY_LOAN_INPUT_PORT');
@Module({
imports: [TypeOrmModule.forFeature([LoanOrmEntity]), HttpModule],
controllers: [LoanController],
providers: [
TypeOrmLoanGateway,
NiceCreditScoreGateway,
{
provide: APPLY_LOAN_INPUT_PORT,
// 데코레이터 없는 Interactor를 직접 조립한다
useFactory: (loans: LoanGateway, scores: CreditScoreGateway) =>
new ApplyLoanInteractor(loans, scores),
inject: [TypeOrmLoanGateway, NiceCreditScoreGateway],
},
],
})
export class LoanModule {}
엉클 밥은 이런 조립 코드를 Main 컴포넌트라고 부릅니다. 모든 구체 클래스를 알고, 모든 것을 연결하는 가장 지저분한(dirtiest) 곳입니다. 지저분함을 한 곳에 몰아두었기 때문에 나머지가 깨끗해질 수 있습니다.
6-9. 전체 흐름
sequenceDiagram
participant Client
participant Ctrl as LoanController ③
participant I as ApplyLoanInteractor ②
participant CS as CreditScoreGateway ③
participant E as Loan / CreditPolicy ①
participant G as TypeOrmLoanGateway ③
participant P as JsonPresenter ③
Client->>Ctrl: POST /loans
Ctrl->>I: execute(input, presenter)
I->>CS: scoreOf(applicantId)
CS-->>I: 820
I->>E: Loan.open(..., 820)
E-->>I: loan
I->>G: save(loan)
I->>P: approved(output)
Ctrl->>Ctrl: presenter.viewModel 꺼내기
Ctrl-->>Client: 201 { approvedAmount: "30,000,000원", ... }