Transaction 시작
↓
DB 작업 A
↓
DB 작업 B
↓
DB 작업 C
↓
모두 성공 → commit
하나라도 실패 → rollback
상담 상태는 변경됐는데 이력이 안 남는 문제 방지
Audit Log 없이 상품 가격만 바뀌는 문제 방지
ExportJob은 생성됐는데 작업 조건이 저장되지 않는 문제 방지
알림톡 발송 기록과 실제 발송 상태가 어긋나는 문제 방지
Controller:
transaction 시작 X
Use Case:
transaction 시작 O
Repository:
전달받은 tx로 query 실행
HTTP 계층에 DB 흐름이 섞임
비즈니스 단위 transaction을 이해하기 어려움
Controller가 커짐
테스트하기 어려움
Repository 내부 transaction이 숨겨짐
여러 Repository 작업을 하나로 묶기 어려움
상태 변경은 성공했는데 Audit Log 실패 같은 문제가 생김
업무 흐름 전체를 알고 있음
여러 Repository 작업을 하나로 묶을 수 있음
상태 변경/이력/Audit Log 정합성을 보장하기 쉬움
테스트 시 transaction 범위를 확인하기 쉬움
상담 상태 변경
↓
현재 상태 확인
↓
상태 전이 규칙 검증
↓
권한 확인
↓
consults.status 업데이트
↓
consult_status_histories 생성
↓
audit_logs 생성
↓
필요 시 notification_jobs 생성
await prisma.consult.update({
where: { id: consultId },
data: { status: nextStatus },
});
문제:
이전 상태를 모름
누가 바꿨는지 모름
상태 전이 규칙이 없음
상태 이력이 없음
Audit Log가 없음
동시 수정 충돌을 감지하기 어려움
1. command 입력
2. 상담 조회
3. 권한 확인
4. 현재 상태 확인
5. 상태 전이 검증
6. 동시성 조건 확인
7. 상태 업데이트
8. 상태 이력 생성
9. Audit Log 생성
10. 필요 시 Job 생성
11. commit
12. 응답 DTO 반환
| 역할 | 담당 |
|---|---|
| Controller | 요청값, 현재 관리자, request 정보 전달 |
| Use Case | 상태 변경 업무 흐름 조합 |
| Domain Service | 상태 전이 규칙 검증 |
| Repository | DB 조회/수정/이력 저장 |
| AuditLogRepository | Audit Log 저장 |
| NotificationJobRepository | 알림 Job 생성 |
| Mapper | 응답 DTO 변환 |
export type UpdateConsultStatusCommand = {
consultId: number;
nextStatus: ConsultStatus;
reason?: string;
memo?: string;
adminId: number;
adminRole?: string;
requestId?: string;
ipAddress?: string;
userAgent?: string;
expectedVersion?: number;
};
| 필드 | 의미 |
|---|---|
consultId | 변경 대상 상담 |
nextStatus | 변경할 상태 |
reason | 변경 사유 |
memo | 변경 메모 |
adminId | 변경한 관리자 |
requestId | 요청 추적 ID |
expectedVersion | optimistic lock용 버전 |
Controller DTO와 내부 로직 분리
requestId/ip/userAgent 같은 운영 정보 포함 가능
테스트 입력값 만들기 쉬움
상태 변경에 필요한 값이 명확함
@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(currentStatus: ConsultStatus, nextStatus: ConsultStatus) {
const allowed = this.allowedTransitions[currentStatus] ?? [];
if (!allowed.includes(nextStatus)) {
throw new BadRequestException(
`${currentStatus} 상태에서 ${nextStatus} 상태로 변경할 수 없습니다.`,
);
}
}
isFinalStatus(status: ConsultStatus) {
return ['CONVERTED', 'CANCELED', 'DUPLICATED'].includes(status);
}
}
상태 전이 규칙이 한 곳에 모임
테스트하기 쉬움
정책 변경 시 수정 위치가 명확함
Use Case가 규칙 세부사항에 덜 의존함
Guard:
로그인 관리자 확인
Use Case:
상태 변경 권한 Policy 호출
Policy:
이 관리자가 이 상태 변경을 할 수 있는지 판단
@Injectable()
export class ConsultStatusPermissionPolicy {
canChangeStatus(params: {
adminRole: string;
currentStatus: ConsultStatus;
nextStatus: ConsultStatus;
}) {
if (params.adminRole === 'SUPER_ADMIN') {
return true;
}
if (params.nextStatus === 'CONVERTED') {
return false;
}
return true;
}
}
const canChange = this.permissionPolicy.canChangeStatus({
adminRole: command.adminRole,
currentStatus: consult.status,
nextStatus: command.nextStatus,
});
if (!canChange) {
throw new ForbiddenException('해당 상태로 변경할 권한이 없습니다.');
}
@Injectable()
export class UpdateConsultStatusUseCase {
constructor(
private readonly prisma: PrismaService,
private readonly consultRepository: ConsultRepository,
private readonly consultStatusService: ConsultStatusService,
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 changedAt = new Date();
const updated = await this.consultRepository.updateStatus(
{
consultId: command.consultId,
nextStatus: command.nextStatus,
lastHandledByAdminId: command.adminId,
changedAt,
},
tx,
);
await this.consultRepository.createStatusHistory(
{
consultId: command.consultId,
fromStatus: consult.status,
toStatus: command.nextStatus,
changedByAdminId: command.adminId,
reason: command.reason,
memo: command.memo,
createdAt: changedAt,
},
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: command.nextStatus,
},
requestId: command.requestId,
ipAddress: command.ipAddress,
userAgent: command.userAgent,
},
tx,
);
return updated;
});
}
}
상담 조회
상태 전이 검증
상태 업데이트
상태 이력 생성
Audit Log 생성
모두 같은 transaction 안에서 실행
changedAt을 한 번만 생성해서 상태 업데이트, 이력, 로그에 같이 쓰는 것이 좋습니다.관리자 A:
NEW → CALLING
관리자 B:
NEW → CANCELED
최종:
CANCELED
문제:
A의 변경이 조용히 덮임
const result = await tx.consult.updateMany({
where: {
id: command.consultId,
status: consult.status,
},
data: {
status: command.nextStatus,
lastHandledByAdminId: command.adminId,
lastStatusChangedAt: changedAt,
},
});
if (result.count === 0) {
throw new ConflictException(
'이미 다른 관리자가 상담 상태를 변경했습니다. 새로고침 후 다시 시도해주세요.',
);
}
updateStatusIfCurrent(
params: {
consultId: number;
currentStatus: ConsultStatus;
nextStatus: ConsultStatus;
lastHandledByAdminId: number;
changedAt: Date;
},
tx: PrismaTx = this.prisma,
) {
return tx.consult.updateMany({
where: {
id: params.consultId,
status: params.currentStatus,
},
data: {
status: params.nextStatus,
lastHandledByAdminId: params.lastHandledByAdminId,
lastStatusChangedAt: params.changedAt,
},
});
}
updateMany를 쓰면 조건에 맞는 row 수를 확인할 수 있습니다.count === 0이면 이미 상태가 바뀐 것으로 보고 409 Conflict를 반환할 수 있습니다.version 컬럼을 사용할 수 있습니다.상담 상세 조회:
version = 3
상태 변경 요청:
expectedVersion = 3
DB update:
id = 10 AND version = 3
성공:
version = 4
실패:
이미 다른 사람이 수정
const result = await tx.consult.updateMany({
where: {
id: command.consultId,
version: command.expectedVersion,
},
data: {
status: command.nextStatus,
version: {
increment: 1,
},
lastHandledByAdminId: command.adminId,
lastStatusChangedAt: changedAt,
},
});
if (result.count === 0) {
throw new ConflictException(
'이미 수정된 상담입니다. 새로고침 후 다시 시도해주세요.',
);
}
상담 상세에서 여러 필드를 동시에 수정
관리자 여러 명이 같은 상담을 자주 수정
상태 외에도 메모/담당자/태그 변경 충돌을 막고 싶음
1. 현재 상담 조회
2. 상태 전이 검증
3. 상태 update 시도
4. update 성공 확인
5. 상태 이력 생성
6. Audit Log 생성
7. commit
상태 이력 먼저 생성
↓
상태 update 실패
↓
이력은 있는데 실제 상태는 그대로
상태 update 성공
↓
상태 이력 생성
↓
Audit Log 생성
↓
모두 commit
consult_status_histories:
상담 상태 흐름
audit_logs:
관리자 작업 기록
{
"actorType": "ADMIN",
"actorId": 1,
"action": "CONSULT_STATUS_UPDATE",
"targetType": "CONSULT",
"targetId": "10",
"beforeValue": {
"status": "CALLING"
},
"afterValue": {
"status": "CALLED"
}
}
전화번호 원본 저장 금지
상담 메모 전체 저장 주의
토큰/Secret 저장 금지
before/after에는 변경된 필드 중심으로 저장
상태 변경 transaction
↓
consults.status 업데이트
↓
status_history 생성
↓
audit_log 생성
↓
notification_jobs 생성
↓
commit
↓
Worker가 알림톡 발송
if (command.nextStatus === 'CONVERTED') {
await this.notificationJobRepository.create(
{
type: 'CONSULT_CONVERTED',
targetType: 'CONSULT',
targetId: String(command.consultId),
payload: {
consultId: command.consultId,
},
},
tx,
);
}
Job row 생성은 DB 작업
상태 변경과 함께 commit되어야 함
상태 변경이 rollback되면 Job도 생성되면 안 됨
외부 API는 rollback 불가
응답 지연 가능
실패/재시도 필요
transaction을 오래 잡게 됨
export type UpdateConsultStatusResponse = {
id: number;
status: ConsultStatus;
lastStatusChangedAt: Date;
lastHandledByAdminId: number;
};
export function toUpdateConsultStatusResponse(consult: Consult) {
return {
id: consult.id,
status: consult.status,
lastStatusChangedAt: consult.lastStatusChangedAt,
lastHandledByAdminId: consult.lastHandledByAdminId,
};
}
phoneNormalized 반환 금지
상담 메모 전체 반환 불필요
내부 version 반환 여부는 프론트 정책에 맞춤
Audit Log 정보는 별도 화면에서 조회
| 상황 | HTTP 상태 | 메시지 |
|---|---|---|
| 상담 없음 | 404 | 상담을 찾을 수 없습니다. |
| 권한 없음 | 403 | 해당 상태로 변경할 권한이 없습니다. |
| 잘못된 상태 전이 | 400 | 현재 상태에서 해당 상태로 변경할 수 없습니다. |
| 동시 수정 충돌 | 409 | 이미 다른 관리자가 수정했습니다. |
| validation 실패 | 400 | 변경 사유를 입력해주세요. |
없는 데이터:
404
권한 문제:
403
요청값/상태 전이 문제:
400
동시성 충돌:
409
서버/DB 장애:
500
NEW → CALLING 성공
CANCELED → CONVERTED 실패
권한 없는 관리자의 CONVERTED 변경 실패
상태 변경 시 status_history 생성
상태 변경 시 audit_log 생성
동시 수정 시 409 Conflict
Audit Log 생성 실패 시 전체 rollback
notification job 생성 조건 확인
it('CANCELED 상태에서는 CONVERTED로 변경할 수 없다', () => {
expect(() =>
service.validateTransition('CANCELED', 'CONVERTED'),
).toThrow();
});
테스트 DB 준비
↓
상담 row 생성
↓
Use Case 실행
↓
consults.status 확인
↓
status_history 확인
↓
audit_log 확인
주문 조회
↓
권한 확인
↓
상태 전이 검증
↓
order.status 업데이트
↓
order_status_history 생성
↓
audit_log 생성
↓
필요 시 notification_job 생성
RECEIVED → VERIFYING
VERIFYING → APPROVED
APPROVED → OPENING
OPENING → OPENED
OPENED → SHIPPING
SHIPPING → DONE
외부 개통 시스템과 상태 동기화 가능
배송 상태와 연결 가능
고객 안내 메시지와 연결 가능
정산/실적 기준과 연결 가능
상태 되돌리기 정책이 더 중요
PENDING
↓
PROCESSING
↓
DONE 또는 FAILED
PENDING → PROCESSING 가능
PROCESSING → DONE 가능
PROCESSING → FAILED 가능
DONE → PROCESSING 불가
FAILED → PROCESSING은 재시도 정책에 따라 가능
동일 Job을 두 Worker가 동시에 처리하지 않게 하기
PROCESSING 변경 시 lock 또는 조건 update 사용
실패 사유 저장
재시도 횟수 관리
상태 변경 Use Case
↓
notification job 생성
↓
stats update
↓
audit log
장점:
흐름이 명확함
추적하기 쉬움
초기 구현이 단순함
단점:
Use Case가 점점 커질 수 있음
후속 작업이 많아지면 복잡해짐
상태 변경
↓
ConsultStatusChangedEvent 저장 또는 발행
↓
Handler들이 후속 작업 처리
장점:
후속 작업 분리 가능
알림/통계/외부 연동 확장 쉬움
단점:
흐름 추적이 어려워질 수 있음
초기 구조가 복잡해짐
초기:
Use Case 안에서 필요한 Job row 생성
후속 작업 증가:
Outbox/Event 구조 검토
주의:
외부 API 직접 호출은 피하기
알림톡/SMS 실제 발송
외부 API 호출
S3 업로드
엑셀 파일 생성
대량 파일 처리
네트워크 요청
사용자 응답 대기
긴 반복문 작업
넣어도 됨:
DB row 생성/수정
상태 이력 저장
Audit Log 저장
Job row 생성
빼야 함:
외부 API 호출
파일 생성/업로드
오래 걸리는 계산
상태 업데이트 실패
상태 이력 생성 실패
Audit Log 생성 실패
Job row 생성 실패
알림톡 실제 발송 실패
외부 시스템 일시 장애
통계 캐시 업데이트 실패
비핵심 분석 이벤트 저장 실패
운영 데이터 정합성에 필수:
transaction 안
부가 작업/외부 작업:
Job 또는 비동기 처리
실패해도 재시도 가능:
Worker 처리
consult/
application/
update-consult-status/
update-consult-status.command.ts
update-consult-status.use-case.ts
update-consult-status.mapper.ts
domain/
consult-status.service.ts
consult-status-permission.policy.ts
infra/
consult.repository.ts
audit-log/
infra/
audit-log.repository.ts
notification/
infra/
notification-job.repository.ts
consult/
application/
update-consult-status.use-case.ts
domain/
consult-status.service.ts
infra/
consult.repository.ts
Use Case 파일이 너무 커짐
Command 타입이 여러 곳에서 재사용됨
Mapper가 복잡해짐
상태 변경 정책이 늘어남
권한 정책이 복잡해짐
상담 상태 변경 로직을 UpdateConsultStatusUseCase로 분리해줘.
조건:
1. Controller는 DTO와 현재 관리자 정보를 받아 Use Case만 호출하게 해줘
2. 상태 전이 검증은 ConsultStatusService에서 처리해줘
3. 상태 변경 권한은 ConsultStatusPermissionPolicy로 분리해줘
4. DB 접근은 ConsultRepository를 사용해줘
5. consults.status 업데이트, consult_status_histories 생성, audit_logs 생성을 하나의 Prisma transaction으로 묶어줘
6. 상태 변경 시 changedAt은 한 번 생성해서 status, history, audit log에 같이 사용해줘
7. 동시 수정 방지를 위해 현재 상태 조건 또는 version 조건을 사용해줘
8. 알림톡 실제 발송은 하지 말고 notification_jobs row만 생성하게 해줘
9. phoneNormalized, token, Secret, 상담 메모 전체는 응답이나 Audit Log에 노출하지 마
10. 기존 API 응답 형식이 깨지지 않게 Mapper를 추가해줘
11. 변경 후 테스트 케이스와 QA 체크리스트를 정리해줘
transaction이 Use Case에 있는가?
Repository 내부에서 몰래 transaction을 열지 않는가?
상태 변경과 이력/Audit Log가 같은 tx를 쓰는가?
상태 전이 규칙이 중복되지 않는가?
동시 수정 충돌 처리가 있는가?
외부 API를 transaction 안에서 호출하지 않는가?
개인정보가 응답/로그에 노출되지 않는가?
기존 관리자 화면 흐름이 깨지지 않는가?
fromStatus와 toStatus가 모두 저장되는가?changedByAdminId가 저장되는가?createdAt이 상태 변경 시점과 일치하는가?NestJS + Prisma + PostgreSQL 기반 온라인 휴대폰 판매몰에서 상담 상태 변경 Use Case를 설계하려고 해.
서비스 상황:
1. 관리자는 상담 목록에서 상담 상태를 변경함
2. 상태는 NEW, CALLING, CALLED, PENDING, CONVERTED, CANCELED, DUPLICATED가 있음
3. 상태 변경 시 현재 상태 검증과 상태 전이 규칙 검증이 필요함
4. 상태 변경 권한은 관리자 role에 따라 달라질 수 있음
5. 상태 변경 시 consults.status 업데이트, consult_status_histories 생성, audit_logs 생성을 하나의 transaction으로 묶고 싶음
6. 관리자 여러 명이 같은 상담을 동시에 수정할 수 있음
7. 특정 상태로 변경되면 알림톡 발송이 필요하지만 실제 외부 API 호출은 transaction 안에서 하지 않으려고 함
8. Repository는 Prisma query를 담당하고, Use Case가 transaction boundary를 갖게 하고 싶음
9. 응답에는 phoneNormalized나 민감정보를 노출하면 안 됨
요청:
- Transaction Boundary를 어디에 둬야 하는지
- UpdateConsultStatusUseCase 구조
- Command 타입 설계
- ConsultStatusService 상태 전이 규칙 예시
- 권한 Policy 분리 방식
- Repository에 tx를 전달하는 방식
- 동시 수정 방지를 위한 현재 상태 조건 또는 version 방식
- 상태 이력과 Audit Log 저장 순서
- notification job 생성 기준
- 테스트 케이스와 QA 체크리스트
를 실무 기준으로 정리해줘.
transaction을 Controller가 아니라 Use Case에 두는가?
Repository 내부 숨은 transaction을 피하는가?
상태 변경/이력/Audit Log를 같은 tx로 묶는가?
외부 API 호출을 transaction 밖으로 분리하는가?
상태 전이 규칙을 Domain Service로 분리하는가?
권한 Policy와 Repository 책임을 구분하는가?
동시 수정 충돌 처리를 고려하는가?
Audit Log에 개인정보를 넣지 않도록 경고하는가?
현재 프로젝트 규모에 맞는 현실적인 구조를 제안하는가?
ConsultStatusService 같은 Domain Service에 두고, Use Case는 그 규칙을 호출해 업무 흐름을 조합하는 역할을 맡는 것이 좋습니다.tx를 전달받아 같은 transaction 안에서 DB 작업을 실행해야 합니다.fromStatus, toStatus, changedByAdminId, reason, createdAt을 저장하고, 상태 update 성공 후 같은 transaction 안에서 생성해야 합니다.notification_jobs row만 생성한 뒤 Worker가 발송하도록 분리하는 것이 안전합니다.