처음:
상담 신청 저장
조금 뒤:
중복 검사
상품 snapshot 저장
유입 source 저장
알림톡 발송
상태 이력 저장
Audit Log 저장
권한 체크
Service 파일이 너무 커짐
Controller에 비즈니스 로직이 들어감
Prisma query가 여기저기 흩어짐
상태 변경 규칙이 중복됨
transaction 범위가 불명확함
테스트하기 어려움
AI/Codex가 수정할 때 영향 범위가 커짐
1. Controller는 요청/응답만 담당
2. Use Case는 하나의 업무 흐름 담당
3. Domain Service는 핵심 비즈니스 규칙 담당
4. Repository는 DB 접근 담당
5. 외부 API는 Adapter로 분리
6. Transaction 범위는 Use Case에서 관리
상담 신청 로직을 안정적으로 분리
상담 상태 변경 규칙을 한 곳에서 관리
알림톡/SMS 발송을 DB 저장과 분리
관리자 권한 체크를 일관되게 처리
Audit Log와 상태 이력을 누락하지 않게 구조화
엑셀 ExportJob을 비동기 작업으로 분리
Controller:
요청 받기
DTO 검증된 값 받기
현재 사용자 정보 추출
Use Case 호출
응답 반환
@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 없음
테스트 어려움
@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가 얇아짐
Service에 모든 로직을 넣기 쉽습니다.Application Service / Use Case:
하나의 업무 흐름 조합
Domain Service:
비즈니스 규칙 판단
Repository:
DB 접근
Adapter:
외부 API 호출
ConsultService
- createConsult()
- updateStatus()
- sendAlimtalk()
- exportExcel()
- validateStatus()
- writeAuditLog()
- findList()
- findDetail()
- checkPermission()
문제:
파일이 커짐
역할이 불명확함
작은 수정도 영향 범위 큼
테스트하기 어려움
AI가 수정할 때 맥락이 과도하게 커짐
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 접근 위치가 명확함
테스트하기 쉬움
Use Case:
하나의 업무 목적을 가진 실행 단위
예:
CreateConsultUseCase
UpdateConsultStatusUseCase
RequestConsultExportUseCase
UpdateProductPriceUseCase
DTO를 받아 업무 흐름 실행
권한 확인 호출
Domain Service로 규칙 검증
Repository로 데이터 조회/저장
Transaction 범위 관리
Audit Log 저장
외부 작업은 Job으로 위임
HTTP 데코레이터 처리
Request/Response 직접 조작
세부 SQL 작성 남발
외부 API 직접 호출 남발
상태 전이 규칙 하드코딩 중복
consults 한 줄만 생성하는 것이 아닙니다.고객 상담 신청
↓
상품 확인
↓
중복 신청 확인
↓
상담 snapshot 생성
↓
consult 저장
↓
notification job 생성
↓
응답 반환
@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;
});
}
}
Use Case가 업무 흐름을 조합
중복 정책은 별도 Domain Policy
DB 저장은 Repository
알림톡 실제 발송은 하지 않고 Job 생성
transaction 안에는 DB 작업만 포함
export class CreateConsultDto {
customerName: string;
phone: string;
productId: number;
source?: string;
visitorId?: string;
}
export type CreateConsultCommand = {
customerName: string;
phoneNormalized: string;
phoneMasked: string;
productId: number;
source?: string;
visitorId?: string;
requestId?: string;
};
DTO:
외부 요청 형식
Command:
내부 업무 실행에 필요한 값
장점:
전화번호 정규화/마스킹 후 전달 가능
HTTP와 무관하게 Use Case 테스트 가능
입력값 의미가 더 명확해짐
Domain Service:
상태 전이 가능 여부 판단
중복 신청 정책 판단
지원금 계산 규칙
권한 정책 판단
ConsultStatusService:
상태 전이 규칙
DuplicateConsultPolicy:
중복 신청 판단
ConsultSnapshotFactory:
상담 당시 상품 snapshot 생성
ConsultAssignmentPolicy:
상담 담당자 배정 규칙
ProductPricePolicy:
가격/지원금 규칙
ProductVisibilityPolicy:
노출 가능 여부 판단
ProductOptionPolicy:
용량/색상 옵션 유효성 판단
@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);
}
}
상태 전이 규칙이 한 곳에 있음
테스트하기 쉬움
관리자 상태 변경/자동 상태 변경에서 재사용 가능
정책 변경 시 수정 위치 명확
if status === ...가 퍼지면 나중에 수정이 힘들어집니다.상담 조회
↓
권한 확인
↓
상태 전이 검증
↓
상담 상태 업데이트
↓
상태 이력 생성
↓
Audit Log 생성
↓
commit
@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;
});
}
}
상태 전이 검증은 Domain Service
DB update는 Repository
이력과 Audit Log는 transaction 안에서 저장
외부 알림이 필요하면 notification job 생성
Use Case:
업무 흐름
Repository:
DB 조회/저장
Use Case A에서 prisma.consult.findMany
Use Case B에서 prisma.consult.findUnique
Use Case C에서 prisma.consult.update
Controller에서도 prisma 직접 사용
결과:
쿼리 중복
조회 조건 불일치
Soft Delete 조건 누락
테스트 어려움
ConsultRepository.findActiveById()
ConsultRepository.findList()
ConsultRepository.updateStatus()
ConsultRepository.createStatusHistory()
결과:
DB 접근 기준 일관화
Soft Delete 조건 관리 쉬움
쿼리 변경 위치 명확
@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,
});
}
}
tx를 받는 이유transaction 안:
tx 전달
transaction 밖:
기본 prisma 사용
장점:
같은 Repository 메서드를 transaction 안팎에서 재사용 가능
tx를 받을 수 있게 만들어두면 좋습니다.| 로직 | 위치 후보 |
|---|---|
| 요청값 검증 | 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 |
HTTP와 관련:
Controller / Guard / DTO
업무 흐름:
Use Case
업무 규칙:
Domain Service / Policy
DB 접근:
Repository
외부 시스템:
Adapter
응답 변환:
Mapper / Presenter
Use Case
↓
NotificationAdapter
↓
외부 알림톡 API
await axios.post('https://alimtalk.example.com/send', {
phone: consult.phone,
templateCode: 'CONSULT_RECEIVED',
});
문제:
외부 API 주소가 Use Case에 박힘
테스트 어려움
실패/재시도 정책 섞임
transaction 안에 들어갈 위험
@Injectable()
export class AlimtalkAdapter {
async sendConsultReceived(params: {
phone: string;
templateCode: string;
variables: Record<string, string>;
}) {
// 외부 API 호출
}
}
API Use Case:
Job row 생성
Worker:
Job 조회
외부 API/S3/파일 작업 실행
결과 저장
권한 확인
요청 조건 검증
export_jobs row 생성
audit_logs 생성
응답 반환
export_jobs 상태 PROCESSING 변경
DB에서 데이터 조회
엑셀 파일 생성
S3 업로드
상태 DONE/FAILED 저장
실패 사유 기록
Controller:
transaction 시작 X
Use Case:
transaction 시작 O
Repository:
전달받은 tx로 query 실행
업무 흐름 전체를 알고 있음
어떤 작업이 함께 commit되어야 하는지 판단 가능
Repository는 단일 DB 작업만 담당
Controller는 HTTP 계층이라 비즈니스 transaction을 모름
UpdateConsultStatusUseCase:
consult update
status history insert
audit log insert
이 3개는 같은 transaction
tx를 넘기는 방식이 실무적으로 깔끔합니다.return consult;
문제:
phoneNormalized 노출 가능
내부 컬럼 노출 가능
deletedAt 등 운영 필드 노출
프론트가 DB 구조에 의존
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,
};
}
DB 모델:
내부 저장 구조
Response DTO:
프론트에 보여줄 구조
Mapper:
내부 모델을 응답 DTO로 변환
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
도메인 기준으로 파일을 찾기 쉬움
Use Case별 책임이 명확함
Domain 규칙이 분리됨
Repository가 DB 접근을 담당
DTO와 Mapper가 분리됨
처음부터 모든 폴더를 만들 필요 없음
1단계:
controller + service + repository
2단계:
복잡한 메서드를 use-case로 분리
3단계:
중복 규칙을 domain service로 분리
4단계:
응답 노출 문제가 생기면 mapper 분리
UpdateConsultStatusUseCase
ConsultStatusService
ConsultRepository
AuditLogRepository
이유:
상태 전이 규칙 필요
이력 저장 필요
Audit Log 필요
transaction 필요
관리자 동시 수정 고려
CreateConsultUseCase
DuplicateConsultPolicy
ConsultSnapshotFactory
NotificationJobRepository
이유:
중복 신청 방지
상품 snapshot 저장
유입 정보 저장
알림톡 job 생성
개인정보 처리
RequestConsultExportUseCase
ExportJobRepository
AuditLogRepository
ExportPermissionPolicy
이유:
권한 필요
검색 조건 snapshot 필요
Job 생성 필요
개인정보 파일 생성 위험
Audit Log 필요
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();
});
});
상태 전이 규칙:
Domain Service 단위 테스트
중복 신청 정책:
Policy 테스트
상담 상태 변경:
Use Case 통합 테스트
Repository:
DB 연결 테스트
Controller:
요청/응답 테스트
Controller에는 비즈니스 로직을 넣지 않는다
상담 상태 변경은 UpdateConsultStatusUseCase에서 처리한다
상태 전이 검증은 ConsultStatusService를 사용한다
DB 접근은 ConsultRepository를 사용한다
상태 변경과 이력 저장은 transaction으로 묶는다
알림톡 실제 발송은 API 요청에서 하지 않는다
응답에는 phoneNormalized를 노출하지 않는다
상담 상태 변경 로직을 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 체크리스트를 정리해줘
모든 기능에 interface/repository/use-case/domain/event를 강제
간단한 조회에도 파일 6개 수정
도메인 모델과 ORM 모델 완전 분리
CQRS/Event Sourcing을 무리하게 도입
테스트도 없는데 구조만 복잡함
단순 CRUD:
Controller + Service + Repository 정도로 충분
복잡한 업무 흐름:
Use Case 분리
중복되는 규칙:
Domain Service 분리
외부 연동:
Adapter/Worker 분리
운영 이력 필요:
Audit Log 구조화
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 작업 규칙
을 실무 기준으로 정리해줘.
Controller에 비즈니스 로직을 넣지 않게 하는가?
상태 변경과 이력 저장을 transaction으로 묶는가?
Domain Service와 Use Case 역할을 구분하는가?
Repository가 DB 접근만 담당하도록 설명하는가?
외부 API를 transaction 밖으로 분리하는가?
현재 규모에 비해 과도한 구조를 강요하지 않는가?
AI/Codex가 따를 수 있는 규칙으로 정리하는가?
상담 신청과 관리자 상태 변경을 핵심 흐름으로 보는가?