TIL - 20260824

juni·2026년 8월 24일

TIL

목록 보기
438/468

0824 백엔드 아키텍처 고도화 (1/N): Domain Service, Use Case와 비즈니스 로직 분리


✅ 1. 백엔드 아키텍처를 왜 고도화해야 하는가?

  • 백엔드 아키텍처는 단순히 폴더를 예쁘게 나누는 작업이 아닙니다.
  • 기능이 늘어나도 코드가 무너지지 않게 만드는 구조입니다.
  • 처음에는 Controller, Service, Prisma만 있어도 충분해 보이지만, 상담 신청, 상태 변경, 알림톡, 엑셀 Export, 권한, Audit Log, 유입 분석이 붙기 시작하면 코드가 금방 복잡해집니다.
처음:
상담 신청 저장

조금 뒤:
중복 검사
상품 snapshot 저장
유입 source 저장
알림톡 발송
상태 이력 저장
Audit Log 저장
권한 체크

➕ 1-1. 구조가 약할 때 생기는 문제

Service 파일이 너무 커짐
Controller에 비즈니스 로직이 들어감
Prisma query가 여기저기 흩어짐
상태 변경 규칙이 중복됨
transaction 범위가 불명확함
테스트하기 어려움
AI/Codex가 수정할 때 영향 범위가 커짐
  • 백엔드 구조가 약하면 기능 하나 추가할 때마다 기존 기능이 깨질 가능성이 커집니다.
  • 1인 개발자일수록 구조를 너무 복잡하게 만들면 안 되지만, 최소한의 책임 분리는 필요합니다.

✅ 2. 좋은 백엔드 구조의 목표

  • 좋은 구조는 “멋진 패턴을 많이 쓰는 것”이 아니라, 변경이 생겨도 어디를 고쳐야 하는지 명확한 구조입니다.
1. Controller는 요청/응답만 담당
2. Use Case는 하나의 업무 흐름 담당
3. Domain Service는 핵심 비즈니스 규칙 담당
4. Repository는 DB 접근 담당
5. 외부 API는 Adapter로 분리
6. Transaction 범위는 Use Case에서 관리

➕ 2-1. 현재 프로젝트 기준 목표

상담 신청 로직을 안정적으로 분리
상담 상태 변경 규칙을 한 곳에서 관리
알림톡/SMS 발송을 DB 저장과 분리
관리자 권한 체크를 일관되게 처리
Audit Log와 상태 이력을 누락하지 않게 구조화
엑셀 ExportJob을 비동기 작업으로 분리
  • 목표는 대기업식 과한 아키텍처가 아닙니다.
  • 지금 프로젝트 규모에 맞게 “실수 줄이고, 변경 편하게 하고, 테스트 가능하게” 만드는 것이 핵심입니다.

✅ 3. Controller의 역할

  • Controller는 HTTP 요청을 받고 응답을 반환하는 입구입니다.
  • Controller는 비즈니스 판단을 많이 하면 안 됩니다.
  • 인증된 관리자 정보, DTO, query parameter, requestId 등을 Use Case로 넘기는 정도가 좋습니다.
Controller:
요청 받기
DTO 검증된 값 받기
현재 사용자 정보 추출
Use Case 호출
응답 반환

➕ 3-1. 나쁜 Controller 예시

@Post(':id/status')
async updateStatus(@Param('id') id: string, @Body() body: UpdateStatusDto) {
  const consult = await this.prisma.consult.findUnique({
    where: { id: Number(id) },
  });

  if (consult.status === 'CANCELED') {
    throw new BadRequestException('취소된 상담은 변경할 수 없습니다.');
  }

  await this.prisma.consult.update({
    where: { id: Number(id) },
    data: { status: body.status },
  });

  await this.prisma.consultStatusHistory.create({
    data: {
      consultId: Number(id),
      fromStatus: consult.status,
      toStatus: body.status,
    },
  });

  return { ok: true };
}

문제:

Controller가 DB 직접 접근
상태 전이 규칙이 Controller에 있음
transaction 없음
이력 저장 누락 가능
Audit Log 없음
테스트 어려움

➕ 3-2. 좋은 Controller 예시

@Post(':id/status')
async updateStatus(
  @Param('id', ParseIntPipe) consultId: number,
  @Body() body: UpdateConsultStatusDto,
  @CurrentAdmin() admin: CurrentAdminDto,
  @Req() req: Request,
) {
  return this.updateConsultStatusUseCase.execute({
    consultId,
    nextStatus: body.status,
    reason: body.reason,
    memo: body.memo,
    adminId: admin.id,
    requestId: req.headers['x-request-id'] as string,
    ipAddress: req.ip,
    userAgent: req.headers['user-agent'],
  });
}

장점:

요청 처리만 담당
비즈니스 로직은 Use Case로 이동
테스트 범위 분리
Controller가 얇아짐
  • Controller는 얇게 유지하는 것이 좋습니다.
  • 비즈니스 규칙이 Controller에 들어가기 시작하면 나중에 유지보수가 어려워집니다.

✅ 4. Service의 역할을 다시 나누기

  • NestJS에서는 보통 Service에 모든 로직을 넣기 쉽습니다.
  • 하지만 서비스가 커지면 역할을 나누는 것이 좋습니다.
Application Service / Use Case:
하나의 업무 흐름 조합

Domain Service:
비즈니스 규칙 판단

Repository:
DB 접근

Adapter:
외부 API 호출

➕ 4-1. 모든 것을 한 Service에 넣은 구조

ConsultService
  - createConsult()
  - updateStatus()
  - sendAlimtalk()
  - exportExcel()
  - validateStatus()
  - writeAuditLog()
  - findList()
  - findDetail()
  - checkPermission()

문제:

파일이 커짐
역할이 불명확함
작은 수정도 영향 범위 큼
테스트하기 어려움
AI가 수정할 때 맥락이 과도하게 커짐

➕ 4-2. 역할별로 나눈 구조

consult/
  application/
    create-consult.use-case.ts
    update-consult-status.use-case.ts
    get-consult-list.use-case.ts

  domain/
    consult-status.service.ts
    duplicate-consult-policy.ts

  infra/
    consult.repository.ts

장점:

업무 흐름별 파일이 작아짐
상태 전이 규칙 재사용 가능
DB 접근 위치가 명확함
테스트하기 쉬움
  • 처음부터 완벽한 Clean Architecture로 갈 필요는 없습니다.
  • 그래도 “Use Case / Domain / Repository” 정도의 구분은 실무에서 효과가 큽니다.

✅ 5. Use Case란 무엇인가?

  • Use Case는 하나의 사용자 행동 또는 업무 흐름을 처리하는 단위입니다.
  • 예를 들어 상담 신청 생성, 상담 상태 변경, 상품 가격 수정, 엑셀 Export 요청 생성이 각각 Use Case가 될 수 있습니다.
Use Case:
하나의 업무 목적을 가진 실행 단위

예:
CreateConsultUseCase
UpdateConsultStatusUseCase
RequestConsultExportUseCase
UpdateProductPriceUseCase

➕ 5-1. Use Case가 담당하는 것

DTO를 받아 업무 흐름 실행
권한 확인 호출
Domain Service로 규칙 검증
Repository로 데이터 조회/저장
Transaction 범위 관리
Audit Log 저장
외부 작업은 Job으로 위임

➕ 5-2. Use Case가 담당하지 않는 것

HTTP 데코레이터 처리
Request/Response 직접 조작
세부 SQL 작성 남발
외부 API 직접 호출 남발
상태 전이 규칙 하드코딩 중복
  • Use Case는 흐름을 조합하는 계층입니다.
  • 핵심 규칙은 Domain Service로 빼고, DB 접근은 Repository로 빼면 더 깔끔합니다.

✅ 6. 상담 신청 Use Case 예시

  • 고객이 상담 신청을 하면 단순히 consults 한 줄만 생성하는 것이 아닙니다.
  • 중복 신청 확인, 상품 snapshot 저장, 유입 정보 저장, 알림톡 발송 job 생성까지 엮일 수 있습니다.
고객 상담 신청
  ↓
상품 확인
  ↓
중복 신청 확인
  ↓
상담 snapshot 생성
  ↓
consult 저장
  ↓
notification job 생성
  ↓
응답 반환

➕ 6-1. Use Case 예시

@Injectable()
export class CreateConsultUseCase {
  constructor(
    private readonly consultRepository: ConsultRepository,
    private readonly productRepository: ProductRepository,
    private readonly duplicatePolicy: DuplicateConsultPolicy,
    private readonly notificationJobRepository: NotificationJobRepository,
    private readonly prisma: PrismaService,
  ) {}

  async execute(command: CreateConsultCommand) {
    return this.prisma.$transaction(async (tx) => {
      const product = await this.productRepository.findActiveById(
        command.productId,
        tx,
      );

      if (!product) {
        throw new BadRequestException('신청 가능한 상품이 아닙니다.');
      }

      const duplicate = await this.duplicatePolicy.check({
        phoneNormalized: command.phoneNormalized,
        productId: command.productId,
        tx,
      });

      if (duplicate.isDuplicated) {
        throw new ConflictException('이미 접수된 상담 신청입니다.');
      }

      const consult = await this.consultRepository.create(
        {
          customerName: command.customerName,
          phoneNormalized: command.phoneNormalized,
          phoneMasked: command.phoneMasked,
          productId: product.id,
          productNameSnapshot: product.modelName,
          carrierSnapshot: product.carrier,
          source: command.source,
          visitorId: command.visitorId,
        },
        tx,
      );

      await this.notificationJobRepository.createConsultReceivedJob(
        {
          consultId: consult.id,
        },
        tx,
      );

      return consult;
    });
  }
}

➕ 6-2. 설계 포인트

Use Case가 업무 흐름을 조합
중복 정책은 별도 Domain Policy
DB 저장은 Repository
알림톡 실제 발송은 하지 않고 Job 생성
transaction 안에는 DB 작업만 포함
  • 상담 저장과 알림톡 실제 발송은 분리하는 것이 안전합니다.
  • 외부 API 호출은 transaction 밖의 Worker에서 처리하는 구조가 좋습니다.

✅ 7. Command 객체

  • Use Case에 넘기는 입력값을 Command라고 부를 수 있습니다.
  • Controller DTO와 Use Case Command를 분리하면 HTTP 계층과 비즈니스 계층의 의존성이 줄어듭니다.

➕ 7-1. DTO 예시

export class CreateConsultDto {
  customerName: string;
  phone: string;
  productId: number;
  source?: string;
  visitorId?: string;
}

➕ 7-2. Command 예시

export type CreateConsultCommand = {
  customerName: string;
  phoneNormalized: string;
  phoneMasked: string;
  productId: number;
  source?: string;
  visitorId?: string;
  requestId?: string;
};

➕ 7-3. 분리하는 이유

DTO:
외부 요청 형식

Command:
내부 업무 실행에 필요한 값

장점:
전화번호 정규화/마스킹 후 전달 가능
HTTP와 무관하게 Use Case 테스트 가능
입력값 의미가 더 명확해짐
  • 작은 프로젝트에서는 DTO를 그대로 넘겨도 됩니다.
  • 하지만 로직이 커질수록 Command 분리가 구조를 안정시킵니다.

✅ 8. Domain Service란 무엇인가?

  • Domain Service는 특정 도메인의 핵심 비즈니스 규칙을 담당합니다.
  • DB 저장이나 HTTP 요청보다 “업무 규칙”에 집중합니다.
Domain Service:
상태 전이 가능 여부 판단
중복 신청 정책 판단
지원금 계산 규칙
권한 정책 판단

➕ 8-1. 상담 도메인 예시

ConsultStatusService:
상태 전이 규칙

DuplicateConsultPolicy:
중복 신청 판단

ConsultSnapshotFactory:
상담 당시 상품 snapshot 생성

ConsultAssignmentPolicy:
상담 담당자 배정 규칙

➕ 8-2. 상품 도메인 예시

ProductPricePolicy:
가격/지원금 규칙

ProductVisibilityPolicy:
노출 가능 여부 판단

ProductOptionPolicy:
용량/색상 옵션 유효성 판단
  • Domain Service는 DB와 완전히 분리할 수도 있고, 필요한 조회만 Repository를 통해 할 수도 있습니다.
  • 중요한 것은 규칙이 Controller나 여러 Service에 흩어지지 않는 것입니다.

✅ 9. 상태 전이 Domain Service

  • 상담 상태 변경 규칙은 여기저기 흩어지면 안 됩니다.
  • 한 곳에서 관리해야 합니다.

➕ 9-1. 예시

@Injectable()
export class ConsultStatusService {
  private readonly allowedTransitions: Record<ConsultStatus, ConsultStatus[]> = {
    NEW: ['CALLING', 'CANCELED', 'DUPLICATED'],
    CALLING: ['CALLED', 'PENDING', 'CANCELED'],
    CALLED: ['CONVERTED', 'PENDING', 'CANCELED'],
    PENDING: ['CALLING', 'CANCELED'],
    CONVERTED: [],
    CANCELED: [],
    DUPLICATED: [],
  };

  validateTransition(current: ConsultStatus, next: ConsultStatus) {
    const allowed = this.allowedTransitions[current] ?? [];

    if (!allowed.includes(next)) {
      throw new BadRequestException(
        `${current} 상태에서 ${next} 상태로 변경할 수 없습니다.`,
      );
    }
  }

  isFinalStatus(status: ConsultStatus) {
    return ['CONVERTED', 'CANCELED', 'DUPLICATED'].includes(status);
  }
}

➕ 9-2. 장점

상태 전이 규칙이 한 곳에 있음
테스트하기 쉬움
관리자 상태 변경/자동 상태 변경에서 재사용 가능
정책 변경 시 수정 위치 명확
  • 상태값은 운영 정책입니다.
  • 코드 곳곳에 if status === ...가 퍼지면 나중에 수정이 힘들어집니다.

✅ 10. 상담 상태 변경 Use Case

  • 상태 변경은 DB 정합성과 운영 이력이 중요합니다.
  • Use Case에서 transaction으로 현재 상태 변경, 상태 이력, Audit Log를 묶는 것이 좋습니다.
상담 조회
  ↓
권한 확인
  ↓
상태 전이 검증
  ↓
상담 상태 업데이트
  ↓
상태 이력 생성
  ↓
Audit Log 생성
  ↓
commit

➕ 10-1. 예시

@Injectable()
export class UpdateConsultStatusUseCase {
  constructor(
    private readonly prisma: PrismaService,
    private readonly consultStatusService: ConsultStatusService,
    private readonly consultRepository: ConsultRepository,
    private readonly auditLogRepository: AuditLogRepository,
  ) {}

  async execute(command: UpdateConsultStatusCommand) {
    return this.prisma.$transaction(async (tx) => {
      const consult = await this.consultRepository.findByIdOrThrow(
        command.consultId,
        tx,
      );

      this.consultStatusService.validateTransition(
        consult.status,
        command.nextStatus,
      );

      const updated = await this.consultRepository.updateStatus(
        {
          consultId: command.consultId,
          nextStatus: command.nextStatus,
          lastHandledByAdminId: command.adminId,
        },
        tx,
      );

      await this.consultRepository.createStatusHistory(
        {
          consultId: command.consultId,
          fromStatus: consult.status,
          toStatus: command.nextStatus,
          changedByAdminId: command.adminId,
          reason: command.reason,
          memo: command.memo,
        },
        tx,
      );

      await this.auditLogRepository.create(
        {
          actorType: 'ADMIN',
          actorId: command.adminId,
          action: 'CONSULT_STATUS_UPDATE',
          targetType: 'CONSULT',
          targetId: String(command.consultId),
          beforeValue: { status: consult.status },
          afterValue: { status: updated.status },
          requestId: command.requestId,
          ipAddress: command.ipAddress,
          userAgent: command.userAgent,
        },
        tx,
      );

      return updated;
    });
  }
}

➕ 10-2. 설계 포인트

상태 전이 검증은 Domain Service
DB update는 Repository
이력과 Audit Log는 transaction 안에서 저장
외부 알림이 필요하면 notification job 생성
  • 상태 변경 로직은 서비스 품질을 크게 좌우합니다.
  • 현재 상태만 바꾸는 단순 update로 끝내면 운영 추적성이 떨어집니다.

✅ 11. Repository란 무엇인가?

  • Repository는 DB 접근 로직을 모아두는 계층입니다.
  • Prisma query를 Use Case 곳곳에 흩뿌리지 않고 한 곳에 모읍니다.
Use Case:
업무 흐름

Repository:
DB 조회/저장

➕ 11-1. Repository가 없을 때

Use Case A에서 prisma.consult.findMany
Use Case B에서 prisma.consult.findUnique
Use Case C에서 prisma.consult.update
Controller에서도 prisma 직접 사용

결과:
쿼리 중복
조회 조건 불일치
Soft Delete 조건 누락
테스트 어려움

➕ 11-2. Repository가 있을 때

ConsultRepository.findActiveById()
ConsultRepository.findList()
ConsultRepository.updateStatus()
ConsultRepository.createStatusHistory()

결과:
DB 접근 기준 일관화
Soft Delete 조건 관리 쉬움
쿼리 변경 위치 명확
  • Repository Pattern은 다음 회차에서 더 깊게 다루면 좋습니다.
  • 이번에는 Use Case와 DB 접근을 분리하는 핵심 개념만 잡으면 됩니다.

✅ 12. Repository 예시

@Injectable()
export class ConsultRepository {
  constructor(private readonly prisma: PrismaService) {}

  findByIdOrThrow(consultId: number, tx: Prisma.TransactionClient = this.prisma) {
    return tx.consult.findUniqueOrThrow({
      where: { id: consultId },
    });
  }

  updateStatus(
    params: {
      consultId: number;
      nextStatus: ConsultStatus;
      lastHandledByAdminId: number;
    },
    tx: Prisma.TransactionClient = this.prisma,
  ) {
    return tx.consult.update({
      where: { id: params.consultId },
      data: {
        status: params.nextStatus,
        lastStatusChangedAt: new Date(),
        lastHandledByAdminId: params.lastHandledByAdminId,
      },
    });
  }

  createStatusHistory(
    params: {
      consultId: number;
      fromStatus: ConsultStatus;
      toStatus: ConsultStatus;
      changedByAdminId: number;
      reason?: string;
      memo?: string;
    },
    tx: Prisma.TransactionClient = this.prisma,
  ) {
    return tx.consultStatusHistory.create({
      data: params,
    });
  }
}

➕ 12-1. tx를 받는 이유

transaction 안:
tx 전달

transaction 밖:
기본 prisma 사용

장점:
같은 Repository 메서드를 transaction 안팎에서 재사용 가능
  • Prisma transaction을 사용할 때 Repository가 tx를 받을 수 있게 만들어두면 좋습니다.
  • 다만 타입이 복잡해질 수 있으므로 프로젝트 기준을 정해야 합니다.

✅ 13. 비즈니스 로직은 어디에 둘까?

  • 백엔드 구조에서 가장 많이 헷갈리는 것이 “이 로직을 어디에 둘까?”입니다.
로직위치 후보
요청값 검증DTO / Pipe
로그인 관리자 확인Guard / Decorator
권한 확인Policy / Guard / Use Case
상태 전이 규칙Domain Service
중복 신청 판단Domain Policy
DB 저장Repository
Transaction 흐름Use Case
알림톡 발송Adapter / Worker
Audit Log 저장Repository / Use Case
응답 형태 가공Presenter / Mapper

➕ 13-1. 간단한 기준

HTTP와 관련:
Controller / Guard / DTO

업무 흐름:
Use Case

업무 규칙:
Domain Service / Policy

DB 접근:
Repository

외부 시스템:
Adapter

응답 변환:
Mapper / Presenter
  • 이 기준만 있어도 코드가 훨씬 덜 섞입니다.
  • 모든 것을 완벽히 분리하려 하기보다 “섞이면 위험한 것”부터 분리하면 됩니다.

✅ 14. Adapter란 무엇인가?

  • Adapter는 외부 시스템과 연결하는 계층입니다.
  • 알림톡, SMS, 카카오 API, PG, S3, Notion, 외부 CRM 같은 연동을 직접 Use Case에 넣지 않도록 분리합니다.
Use Case
  ↓
NotificationAdapter
  ↓
외부 알림톡 API

➕ 14-1. 나쁜 예시

await axios.post('https://alimtalk.example.com/send', {
  phone: consult.phone,
  templateCode: 'CONSULT_RECEIVED',
});

문제:

외부 API 주소가 Use Case에 박힘
테스트 어려움
실패/재시도 정책 섞임
transaction 안에 들어갈 위험

➕ 14-2. 좋은 예시

@Injectable()
export class AlimtalkAdapter {
  async sendConsultReceived(params: {
    phone: string;
    templateCode: string;
    variables: Record<string, string>;
  }) {
    // 외부 API 호출
  }
}
  • 외부 API는 장애 가능성이 높습니다.
  • Use Case와 분리해야 실패 처리, 재시도, 테스트가 쉬워집니다.

✅ 15. Worker와 Use Case 분리

  • 시간이 오래 걸리는 작업은 API 요청에서 바로 처리하지 말고 Worker로 넘기는 것이 좋습니다.
  • 엑셀 Export, 알림톡 발송, 대량 처리, 웹훅 재시도 등이 후보입니다.
API Use Case:
Job row 생성

Worker:
Job 조회
외부 API/S3/파일 작업 실행
결과 저장

➕ 15-1. API Use Case가 할 일

권한 확인
요청 조건 검증
export_jobs row 생성
audit_logs 생성
응답 반환

➕ 15-2. Worker가 할 일

export_jobs 상태 PROCESSING 변경
DB에서 데이터 조회
엑셀 파일 생성
S3 업로드
상태 DONE/FAILED 저장
실패 사유 기록
  • Use Case와 Worker를 분리하면 API 응답이 빨라지고 실패 추적이 쉬워집니다.
  • Worker 구조는 다음 회차들에서 더 깊게 다루면 좋습니다.

✅ 16. Transaction Boundary란 무엇인가?

  • Transaction Boundary는 어디서 transaction을 시작하고 끝낼지 정하는 기준입니다.
  • 보통 하나의 Use Case가 transaction boundary를 갖는 것이 좋습니다.
Controller:
transaction 시작 X

Use Case:
transaction 시작 O

Repository:
전달받은 tx로 query 실행

➕ 16-1. 왜 Use Case에서 관리할까?

업무 흐름 전체를 알고 있음
어떤 작업이 함께 commit되어야 하는지 판단 가능
Repository는 단일 DB 작업만 담당
Controller는 HTTP 계층이라 비즈니스 transaction을 모름

➕ 16-2. 예시

UpdateConsultStatusUseCase:
consult update
status history insert
audit log insert

이 3개는 같은 transaction
  • transaction을 Repository 안에서 너무 잘게 시작하면 여러 작업을 하나로 묶기 어렵습니다.
  • Use Case에서 transaction을 열고 Repository에 tx를 넘기는 방식이 실무적으로 깔끔합니다.

✅ 17. Mapper / Presenter

  • DB 모델을 그대로 응답으로 내보내는 것은 좋지 않습니다.
  • 응답 형태를 별도로 가공하는 Mapper나 Presenter를 둘 수 있습니다.

➕ 17-1. 위험한 응답

return consult;

문제:

phoneNormalized 노출 가능
내부 컬럼 노출 가능
deletedAt 등 운영 필드 노출
프론트가 DB 구조에 의존

➕ 17-2. Mapper 예시

export function toConsultListItemDto(consult: ConsultWithProduct) {
  return {
    id: consult.id,
    customerName: consult.customerName,
    phone: consult.phoneMasked,
    status: consult.status,
    productName: consult.productNameSnapshot ?? consult.product?.modelName,
    source: consult.source,
    createdAt: consult.createdAt,
  };
}

➕ 17-3. 기준

DB 모델:
내부 저장 구조

Response DTO:
프론트에 보여줄 구조

Mapper:
내부 모델을 응답 DTO로 변환
  • Mapper를 두면 개인정보 노출을 줄일 수 있습니다.
  • 프론트와 DB 구조가 강하게 묶이는 것도 줄어듭니다.

✅ 18. 폴더 구조 추천

  • 현재 프로젝트에서는 너무 복잡한 구조보다 도메인별로 정리하는 방식이 좋습니다.
src/
  modules/
    consult/
      consult.controller.ts
      consult.module.ts

      application/
        create-consult.use-case.ts
        update-consult-status.use-case.ts
        get-consult-list.use-case.ts
        get-consult-detail.use-case.ts

      domain/
        consult-status.service.ts
        duplicate-consult-policy.ts
        consult-snapshot.factory.ts

      infra/
        consult.repository.ts

      dto/
        create-consult.dto.ts
        update-consult-status.dto.ts
        consult-list-query.dto.ts

      mappers/
        consult-response.mapper.ts

➕ 18-1. 장점

도메인 기준으로 파일을 찾기 쉬움
Use Case별 책임이 명확함
Domain 규칙이 분리됨
Repository가 DB 접근을 담당
DTO와 Mapper가 분리됨

➕ 18-2. 과하지 않게 시작하는 방법

처음부터 모든 폴더를 만들 필요 없음

1단계:
controller + service + repository

2단계:
복잡한 메서드를 use-case로 분리

3단계:
중복 규칙을 domain service로 분리

4단계:
응답 노출 문제가 생기면 mapper 분리
  • 구조를 한 번에 크게 갈아엎으면 부담이 큽니다.
  • 먼저 상담 상태 변경, 상담 신청 생성처럼 복잡한 기능부터 Use Case로 빼는 것이 좋습니다.

✅ 19. 현재 프로젝트에 먼저 적용할 후보

➕ 19-1. 1순위: 상담 상태 변경

UpdateConsultStatusUseCase
ConsultStatusService
ConsultRepository
AuditLogRepository

이유:

상태 전이 규칙 필요
이력 저장 필요
Audit Log 필요
transaction 필요
관리자 동시 수정 고려

➕ 19-2. 2순위: 상담 신청 생성

CreateConsultUseCase
DuplicateConsultPolicy
ConsultSnapshotFactory
NotificationJobRepository

이유:

중복 신청 방지
상품 snapshot 저장
유입 정보 저장
알림톡 job 생성
개인정보 처리

➕ 19-3. 3순위: 엑셀 Export 요청

RequestConsultExportUseCase
ExportJobRepository
AuditLogRepository
ExportPermissionPolicy

이유:

권한 필요
검색 조건 snapshot 필요
Job 생성 필요
개인정보 파일 생성 위험
Audit Log 필요
  • 이 세 가지부터 구조화하면 운영 품질이 크게 올라갑니다.
  • 모든 기능을 한 번에 바꾸지 말고 위험도가 높은 기능부터 분리하는 것이 좋습니다.

✅ 20. 테스트 가능성

  • 구조를 나누는 중요한 이유 중 하나는 테스트하기 쉬워지기 때문입니다.
  • 특히 Domain Service는 DB 없이도 테스트할 수 있습니다.

➕ 20-1. 상태 전이 테스트 예시

describe('ConsultStatusService', () => {
  const service = new ConsultStatusService();

  it('NEW 상태에서 CALLING으로 변경할 수 있다', () => {
    expect(() =>
      service.validateTransition('NEW', 'CALLING'),
    ).not.toThrow();
  });

  it('CANCELED 상태에서 CONVERTED로 변경할 수 없다', () => {
    expect(() =>
      service.validateTransition('CANCELED', 'CONVERTED'),
    ).toThrow();
  });
});

➕ 20-2. 테스트하기 좋은 구조

상태 전이 규칙:
Domain Service 단위 테스트

중복 신청 정책:
Policy 테스트

상담 상태 변경:
Use Case 통합 테스트

Repository:
DB 연결 테스트

Controller:
요청/응답 테스트
  • 모든 것을 e2e로만 테스트하면 느리고 유지보수가 어렵습니다.
  • 규칙은 작은 단위로, 흐름은 Use Case 단위로 테스트하는 것이 좋습니다.

✅ 21. AI/Codex와 아키텍처 규칙

  • AI/Codex에게 코드를 맡길수록 구조 규칙이 중요해집니다.
  • 규칙이 없으면 AI가 Controller에 로직을 넣거나, Prisma query를 아무 데나 추가하거나, transaction 없이 update를 만들 수 있습니다.

➕ 21-1. AI 작업 규칙 예시

Controller에는 비즈니스 로직을 넣지 않는다
상담 상태 변경은 UpdateConsultStatusUseCase에서 처리한다
상태 전이 검증은 ConsultStatusService를 사용한다
DB 접근은 ConsultRepository를 사용한다
상태 변경과 이력 저장은 transaction으로 묶는다
알림톡 실제 발송은 API 요청에서 하지 않는다
응답에는 phoneNormalized를 노출하지 않는다

➕ 21-2. Codex 요청 예시

상담 상태 변경 로직을 Use Case 구조로 분리해줘.

조건:
1. Controller는 UpdateConsultStatusUseCase만 호출하게 해줘
2. 상태 전이 검증은 ConsultStatusService로 분리해줘
3. DB 접근은 ConsultRepository로 분리해줘
4. consults.status 업데이트, consult_status_histories 생성, audit_logs 생성을 하나의 transaction으로 묶어줘
5. 기존 API 응답 형식은 유지해줘
6. phoneNormalized, token, Secret은 응답이나 로그에 노출하지 마
7. 수정 후 변경 파일 목록, 위험 요소, QA 체크리스트를 정리해줘
  • AI에게 “좋게 리팩토링해줘”라고 하면 위험합니다.
  • 수정 범위와 구조 규칙을 명확히 줘야 합니다.

✅ 22. 과한 아키텍처를 피해야 하는 이유

  • 구조를 나누는 것은 좋지만, 너무 과하면 개발 속도가 느려집니다.
  • 1인 개발자 프로젝트에서 대규모 엔터프라이즈 구조를 그대로 가져오면 유지보수 부담이 커질 수 있습니다.

➕ 22-1. 과한 구조 예시

모든 기능에 interface/repository/use-case/domain/event를 강제
간단한 조회에도 파일 6개 수정
도메인 모델과 ORM 모델 완전 분리
CQRS/Event Sourcing을 무리하게 도입
테스트도 없는데 구조만 복잡함

➕ 22-2. 현실적인 기준

단순 CRUD:
Controller + Service + Repository 정도로 충분

복잡한 업무 흐름:
Use Case 분리

중복되는 규칙:
Domain Service 분리

외부 연동:
Adapter/Worker 분리

운영 이력 필요:
Audit Log 구조화
  • 아키텍처는 도구입니다.
  • 복잡한 문제를 해결하기 위해 구조를 나누는 것이지, 구조 자체가 목적이 되면 안 됩니다.

✅ 23. 실무 체크리스트

➕ 23-1. Controller 체크리스트

  • Controller에 Prisma query가 직접 들어가지 않는가?
  • Controller에 상태 전이 규칙이 들어가지 않는가?
  • Controller는 DTO, 현재 사용자, request 정보만 정리하는가?
  • Controller는 Use Case를 호출하는가?
  • 응답 구조가 일관적인가?
  • 개인정보를 그대로 반환하지 않는가?
  • 인증/권한은 Guard 또는 Use Case에서 명확히 처리되는가?
  • Controller 테스트가 과도하게 복잡하지 않은가?

➕ 23-2. Use Case 체크리스트

  • 하나의 업무 흐름을 담당하는가?
  • 이름만 봐도 목적이 명확한가?
  • transaction boundary가 명확한가?
  • Domain Service로 규칙을 검증하는가?
  • Repository로 DB 접근을 위임하는가?
  • 외부 API 호출을 직접 하지 않는가?
  • Audit Log/상태 이력 누락이 없는가?
  • 실패 시 어떤 에러를 반환할지 명확한가?

➕ 23-3. Domain Service 체크리스트

  • 핵심 비즈니스 규칙이 한 곳에 모여 있는가?
  • 상태 전이 규칙이 중복되지 않는가?
  • DB 없이 테스트 가능한 규칙이 분리되어 있는가?
  • 운영 정책 변경 시 수정 위치가 명확한가?
  • 과하게 많은 책임을 갖고 있지 않은가?
  • 이름이 도메인 의미를 담고 있는가?
  • 예외 메시지가 운영자가 이해할 수 있는가?
  • 테스트 케이스가 있는가?

➕ 23-4. Repository 체크리스트

  • Prisma query가 Repository에 모여 있는가?
  • Soft Delete 조건이 누락되지 않는가?
  • 목록 조회 select가 최소화되어 있는가?
  • transaction client를 받을 수 있는가?
  • 중복 query가 줄어드는가?
  • 메서드 이름이 조회 의도를 설명하는가?
  • raw SQL 사용 시 이유가 명확한가?
  • Repository가 비즈니스 규칙까지 담당하지 않는가?

✅ 24. AI에게 백엔드 아키텍처 분리를 물어볼 때 좋은 질문법

NestJS + Prisma + PostgreSQL 기반 온라인 휴대폰 판매몰 백엔드 구조를 개선하려고 해.

서비스 상황:
1. 고객은 상품 상세에서 상담 신청을 함
2. 상담 신청 시 중복 검사, 상품 snapshot 저장, 유입 source 저장, 알림톡 발송 job 생성이 필요함
3. 관리자는 상담 목록에서 상태를 변경함
4. 상태 변경 시 상태 전이 검증, consults.status 업데이트, 상태 이력 생성, Audit Log 생성이 필요함
5. 관리자 엑셀 다운로드는 ExportJob으로 분리하려고 함
6. 알림톡/SMS 같은 외부 API는 transaction 안에서 직접 호출하지 않으려고 함
7. 현재 Service가 너무 커지는 것을 막고 싶음
8. AI/Codex가 수정할 때 영향 범위를 줄이고 싶음

요청:
- Controller, Use Case, Domain Service, Repository, Adapter 역할 구분
- 상담 신청 Use Case 구조
- 상담 상태 변경 Use Case 구조
- 상태 전이 Domain Service 예시
- Repository에서 transaction client를 받는 방식
- 알림톡 발송을 Job/Worker로 분리하는 기준
- 폴더 구조 추천
- 과한 아키텍처를 피하는 기준
- AI/Codex 작업 규칙
을 실무 기준으로 정리해줘.

➕ 24-1. AI 답변 검증 기준

Controller에 비즈니스 로직을 넣지 않게 하는가?
상태 변경과 이력 저장을 transaction으로 묶는가?
Domain Service와 Use Case 역할을 구분하는가?
Repository가 DB 접근만 담당하도록 설명하는가?
외부 API를 transaction 밖으로 분리하는가?
현재 규모에 비해 과도한 구조를 강요하지 않는가?
AI/Codex가 따를 수 있는 규칙으로 정리하는가?
상담 신청과 관리자 상태 변경을 핵심 흐름으로 보는가?

📌 요약

  • 백엔드 아키텍처 고도화의 목적은 패턴을 많이 쓰는 것이 아니라, 기능이 늘어나도 변경 위치가 명확하고 운영 실수를 줄일 수 있는 구조를 만드는 것입니다.
  • Controller는 요청/응답 처리에 집중하고, 비즈니스 흐름은 Use Case로 분리하는 것이 좋습니다.
  • Use Case는 하나의 업무 흐름을 담당하며, 권한 확인, 도메인 규칙 검증, Repository 호출, transaction 관리, Audit Log 저장을 조합합니다.
  • Domain Service는 상태 전이 규칙, 중복 신청 정책, 상품 노출 정책처럼 핵심 비즈니스 규칙을 한 곳에서 관리합니다.
  • Repository는 Prisma query를 모아두는 계층이며, Soft Delete 조건, 목록 select, 상태 변경 query 같은 DB 접근 기준을 일관되게 관리하는 데 도움이 됩니다.
  • 상담 신청 생성은 중복 검사, 상품 snapshot, 유입 정보, notification job 생성이 엮이므로 Use Case로 분리하기 좋은 후보입니다.
  • 상담 상태 변경은 상태 전이 검증, 현재 상태 업데이트, 상태 이력 생성, Audit Log 생성이 transaction으로 묶여야 하므로 가장 먼저 구조화할 가치가 큽니다.
  • 알림톡/SMS, S3 업로드, 엑셀 생성 같은 외부 또는 장기 작업은 transaction 안에서 직접 실행하지 말고 Adapter, Job, Worker로 분리하는 것이 안전합니다.
  • 응답 DTO는 DB 모델을 그대로 반환하지 말고 Mapper를 통해 필요한 필드만 내려주는 것이 개인정보 보호와 프론트 안정성에 좋습니다.
  • 1인 개발자 프로젝트에서는 모든 기능에 과한 패턴을 강제하기보다, 복잡한 업무 흐름부터 Use Case, Domain Service, Repository로 점진적으로 분리하는 것이 현실적입니다.

0개의 댓글