TIL - 20260826

juni·2026년 8월 26일

TIL

목록 보기
440/468

0826 백엔드 아키텍처 고도화 (3/N): Transaction Boundary와 상태 변경 Use Case 설계


✅ 1. Transaction Boundary란 무엇인가?

  • Transaction Boundary는 transaction을 어디서 시작하고 어디서 끝낼지 정하는 기준입니다.
  • 쉽게 말하면 “어떤 작업들을 반드시 한 덩어리로 성공하거나 실패하게 만들 것인가”를 정하는 것입니다.
  • 백엔드 아키텍처에서는 transaction boundary를 잘못 잡으면 데이터 정합성이 깨지거나, 반대로 transaction이 너무 길어져 성능 문제가 생길 수 있습니다.
Transaction 시작
  ↓
DB 작업 A
  ↓
DB 작업 B
  ↓
DB 작업 C
  ↓
모두 성공 → commit
하나라도 실패 → rollback

➕ 1-1. 왜 중요한가?

상담 상태는 변경됐는데 이력이 안 남는 문제 방지
Audit Log 없이 상품 가격만 바뀌는 문제 방지
ExportJob은 생성됐는데 작업 조건이 저장되지 않는 문제 방지
알림톡 발송 기록과 실제 발송 상태가 어긋나는 문제 방지
  • transaction boundary는 단순 DB 기술 문제가 아닙니다.
  • 운영자가 믿을 수 있는 데이터 흐름을 만드는 기준입니다.

✅ 2. Transaction Boundary는 어디에 두는 게 좋은가?

  • 일반적으로 transaction boundary는 Use Case에 두는 것이 좋습니다.
  • Controller는 HTTP 요청/응답만 알고, Repository는 개별 DB 작업만 압니다.
  • 반면 Use Case는 전체 업무 흐름을 알고 있기 때문에 어떤 작업을 함께 묶어야 하는지 판단하기 좋습니다.
Controller:
transaction 시작 X

Use Case:
transaction 시작 O

Repository:
전달받은 tx로 query 실행

➕ 2-1. Controller에 두면 안 좋은 이유

HTTP 계층에 DB 흐름이 섞임
비즈니스 단위 transaction을 이해하기 어려움
Controller가 커짐
테스트하기 어려움

➕ 2-2. Repository에 두면 안 좋은 이유

Repository 내부 transaction이 숨겨짐
여러 Repository 작업을 하나로 묶기 어려움
상태 변경은 성공했는데 Audit Log 실패 같은 문제가 생김

➕ 2-3. Use Case가 적합한 이유

업무 흐름 전체를 알고 있음
여러 Repository 작업을 하나로 묶을 수 있음
상태 변경/이력/Audit Log 정합성을 보장하기 쉬움
테스트 시 transaction 범위를 확인하기 쉬움
  • Use Case는 “무엇이 하나의 업무인가”를 표현하는 계층입니다.
  • 따라서 transaction도 Use Case 기준으로 잡는 것이 자연스럽습니다.

✅ 3. 상태 변경 Use Case가 중요한 이유

  • 상담이나 주문의 상태 변경은 단순 update가 아닙니다.
  • 상태 변경에는 상태 전이 규칙, 관리자 권한, 상태 이력, Audit Log, 알림 Job, 목록 캐시 갱신까지 연결될 수 있습니다.
상담 상태 변경
  ↓
현재 상태 확인
  ↓
상태 전이 규칙 검증
  ↓
권한 확인
  ↓
consults.status 업데이트
  ↓
consult_status_histories 생성
  ↓
audit_logs 생성
  ↓
필요 시 notification_jobs 생성

➕ 3-1. 단순 update가 위험한 이유

await prisma.consult.update({
  where: { id: consultId },
  data: { status: nextStatus },
});

문제:

이전 상태를 모름
누가 바꿨는지 모름
상태 전이 규칙이 없음
상태 이력이 없음
Audit Log가 없음
동시 수정 충돌을 감지하기 어려움
  • 상태 변경은 운영 업무의 흔적입니다.
  • 단순히 현재 상태만 바꾸면 나중에 문제를 추적하기 어렵습니다.

✅ 4. 좋은 상태 변경 Use Case의 구조

1. command 입력
2. 상담 조회
3. 권한 확인
4. 현재 상태 확인
5. 상태 전이 검증
6. 동시성 조건 확인
7. 상태 업데이트
8. 상태 이력 생성
9. Audit Log 생성
10. 필요 시 Job 생성
11. commit
12. 응답 DTO 반환

➕ 4-1. 역할 분리

역할담당
Controller요청값, 현재 관리자, request 정보 전달
Use Case상태 변경 업무 흐름 조합
Domain Service상태 전이 규칙 검증
RepositoryDB 조회/수정/이력 저장
AuditLogRepositoryAudit Log 저장
NotificationJobRepository알림 Job 생성
Mapper응답 DTO 변환
  • 이 구조가 있으면 상태 변경 로직이 커져도 파일별 책임이 유지됩니다.
  • AI/Codex에게도 수정 지시를 명확하게 줄 수 있습니다.

✅ 5. UpdateConsultStatusCommand 설계

  • Use Case는 Controller DTO를 그대로 받기보다 내부 업무 실행에 필요한 Command를 받는 것이 좋습니다.
export type UpdateConsultStatusCommand = {
  consultId: number;
  nextStatus: ConsultStatus;
  reason?: string;
  memo?: string;

  adminId: number;
  adminRole?: string;

  requestId?: string;
  ipAddress?: string;
  userAgent?: string;

  expectedVersion?: number;
};

➕ 5-1. 필드 의미

필드의미
consultId변경 대상 상담
nextStatus변경할 상태
reason변경 사유
memo변경 메모
adminId변경한 관리자
requestId요청 추적 ID
expectedVersionoptimistic lock용 버전

➕ 5-2. Command를 쓰는 이유

Controller DTO와 내부 로직 분리
requestId/ip/userAgent 같은 운영 정보 포함 가능
테스트 입력값 만들기 쉬움
상태 변경에 필요한 값이 명확함
  • Command는 Use Case의 계약입니다.
  • 어떤 값이 있어야 업무를 실행할 수 있는지 명확히 보여줍니다.

✅ 6. 상태 전이 Domain Service

  • 상태 전이 규칙은 Use Case 안에 직접 박아두지 않는 것이 좋습니다.
  • 상담 상태 변경, 자동 중복 처리, Worker 상태 변경 등 여러 곳에서 재사용될 수 있기 때문입니다.
@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);
  }
}

➕ 6-1. 이 구조의 장점

상태 전이 규칙이 한 곳에 모임
테스트하기 쉬움
정책 변경 시 수정 위치가 명확함
Use Case가 규칙 세부사항에 덜 의존함
  • 상태 규칙은 운영 정책입니다.
  • 정책은 한 곳에서 관리되어야 합니다.

✅ 7. 권한 체크는 어디에서 할까?

  • 상태 변경 권한은 Guard 또는 Policy에서 처리할 수 있습니다.
  • 단순히 로그인 여부만 보는 것은 Guard에서 처리하고, 세부 상태 변경 권한은 Use Case에서 Policy를 호출하는 방식이 좋습니다.
Guard:
로그인 관리자 확인

Use Case:
상태 변경 권한 Policy 호출

Policy:
이 관리자가 이 상태 변경을 할 수 있는지 판단

➕ 7-1. 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;
  }
}

➕ 7-2. Use Case에서 호출

const canChange = this.permissionPolicy.canChangeStatus({
  adminRole: command.adminRole,
  currentStatus: consult.status,
  nextStatus: command.nextStatus,
});

if (!canChange) {
  throw new ForbiddenException('해당 상태로 변경할 권한이 없습니다.');
}
  • 권한은 Repository가 판단하면 안 됩니다.
  • 권한은 비즈니스 정책이므로 Use Case나 Policy에서 다루는 것이 좋습니다.

✅ 8. 상태 변경 Transaction 기본 예시

@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;
    });
  }
}

➕ 8-1. 중요한 포인트

상담 조회
상태 전이 검증
상태 업데이트
상태 이력 생성
Audit Log 생성
모두 같은 transaction 안에서 실행
  • changedAt을 한 번만 생성해서 상태 업데이트, 이력, 로그에 같이 쓰는 것이 좋습니다.
  • 그래야 같은 사건의 시간이 미세하게 달라지는 일을 줄일 수 있습니다.

✅ 9. 동시성까지 고려한 상태 변경

  • 관리자 두 명이 같은 상담을 동시에 수정할 수 있습니다.
  • 이때 마지막 요청이 이전 변경을 덮어쓰면 Lost Update 문제가 생깁니다.
관리자 A:
NEW → CALLING

관리자 B:
NEW → CANCELED

최종:
CANCELED

문제:
A의 변경이 조용히 덮임

➕ 9-1. 현재 상태 조건으로 방어

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(
    '이미 다른 관리자가 상담 상태를 변경했습니다. 새로고침 후 다시 시도해주세요.',
  );
}

➕ 9-2. Repository 메서드 예시

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를 반환할 수 있습니다.

✅ 10. Version 기반 Optimistic Lock

  • 상태만으로 충돌을 판단하기 부족하면 version 컬럼을 사용할 수 있습니다.
  • 사용자가 상세를 열었을 때 version을 함께 받고, 상태 변경 요청 시 expectedVersion을 보냅니다.
상담 상세 조회:
version = 3

상태 변경 요청:
expectedVersion = 3

DB update:
id = 10 AND version = 3

성공:
version = 4

실패:
이미 다른 사람이 수정

➕ 10-1. Prisma 예시

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(
    '이미 수정된 상담입니다. 새로고침 후 다시 시도해주세요.',
  );
}

➕ 10-2. 적용 기준

상담 상세에서 여러 필드를 동시에 수정
관리자 여러 명이 같은 상담을 자주 수정
상태 외에도 메모/담당자/태그 변경 충돌을 막고 싶음
  • 단순 상태 변경만 있다면 현재 상태 조건으로도 충분할 수 있습니다.
  • 상담 상세 전체 편집이 많아지면 version 기반 optimistic lock이 더 안정적입니다.

✅ 11. 상태 변경 이력 생성 시점

  • 상태 이력은 상태 update가 성공한 뒤 생성해야 합니다.
  • 단, 둘은 같은 transaction 안에 있어야 합니다.
1. 현재 상담 조회
2. 상태 전이 검증
3. 상태 update 시도
4. update 성공 확인
5. 상태 이력 생성
6. Audit Log 생성
7. commit

➕ 11-1. 잘못된 순서

상태 이력 먼저 생성
  ↓
상태 update 실패
  ↓
이력은 있는데 실제 상태는 그대로

➕ 11-2. 좋은 순서

상태 update 성공
  ↓
상태 이력 생성
  ↓
Audit Log 생성
  ↓
모두 commit
  • transaction 안이라면 중간 실패 시 rollback되지만, 그래도 논리 순서는 중요합니다.
  • 실제 변경이 성공한 뒤 이력을 남기는 것이 자연스럽습니다.

✅ 12. Audit Log 저장 기준

  • 상태 이력과 Audit Log는 목적이 다릅니다.
  • 상태 이력은 상담 상세에서 상태 흐름을 보기 위한 기록이고, Audit Log는 관리자 작업 추적을 위한 공통 기록입니다.
consult_status_histories:
상담 상태 흐름

audit_logs:
관리자 작업 기록

➕ 12-1. 상태 변경 Audit Log

{
  "actorType": "ADMIN",
  "actorId": 1,
  "action": "CONSULT_STATUS_UPDATE",
  "targetType": "CONSULT",
  "targetId": "10",
  "beforeValue": {
    "status": "CALLING"
  },
  "afterValue": {
    "status": "CALLED"
  }
}

➕ 12-2. 주의

전화번호 원본 저장 금지
상담 메모 전체 저장 주의
토큰/Secret 저장 금지
before/after에는 변경된 필드 중심으로 저장
  • Audit Log는 오래 남을 수 있기 때문에 개인정보를 더 조심해야 합니다.
  • 변경 사실 중심으로 남기고, 민감한 원본 값은 피하는 것이 좋습니다.

✅ 13. 상태 변경과 알림 Job

  • 특정 상태로 변경될 때 알림톡이나 SMS를 보내야 할 수 있습니다.
  • 이때 실제 외부 API 호출을 transaction 안에서 하면 안 됩니다.
  • 대신 notification job row를 transaction 안에서 생성하고, Worker가 이후 발송하는 구조가 안전합니다.
상태 변경 transaction
  ↓
consults.status 업데이트
  ↓
status_history 생성
  ↓
audit_log 생성
  ↓
notification_jobs 생성
  ↓
commit
  ↓
Worker가 알림톡 발송

➕ 13-1. Job 생성 예시

if (command.nextStatus === 'CONVERTED') {
  await this.notificationJobRepository.create(
    {
      type: 'CONSULT_CONVERTED',
      targetType: 'CONSULT',
      targetId: String(command.consultId),
      payload: {
        consultId: command.consultId,
      },
    },
    tx,
  );
}

➕ 13-2. 왜 Job은 transaction 안에 넣어도 되는가?

Job row 생성은 DB 작업
상태 변경과 함께 commit되어야 함
상태 변경이 rollback되면 Job도 생성되면 안 됨

➕ 13-3. 왜 실제 발송은 밖으로 빼야 하는가?

외부 API는 rollback 불가
응답 지연 가능
실패/재시도 필요
transaction을 오래 잡게 됨
  • DB에 “발송해야 할 일”을 기록하는 것은 transaction 안에 둡니다.
  • 실제 발송은 Worker에서 처리합니다.

✅ 14. 상태 변경 결과 응답 설계

  • 상태 변경 후 DB 모델을 그대로 반환하지 않는 것이 좋습니다.
  • 관리자 화면에 필요한 최소 정보만 내려주면 됩니다.

➕ 14-1. 응답 예시

export type UpdateConsultStatusResponse = {
  id: number;
  status: ConsultStatus;
  lastStatusChangedAt: Date;
  lastHandledByAdminId: number;
};

➕ 14-2. Mapper 예시

export function toUpdateConsultStatusResponse(consult: Consult) {
  return {
    id: consult.id,
    status: consult.status,
    lastStatusChangedAt: consult.lastStatusChangedAt,
    lastHandledByAdminId: consult.lastHandledByAdminId,
  };
}

➕ 14-3. 주의

phoneNormalized 반환 금지
상담 메모 전체 반환 불필요
내부 version 반환 여부는 프론트 정책에 맞춤
Audit Log 정보는 별도 화면에서 조회
  • 상태 변경 API는 상태 변경 결과만 명확히 내려주면 됩니다.
  • 상세 정보가 필요하면 상세 API를 다시 조회하는 구조도 좋습니다.

✅ 15. 상태 변경 에러 설계

  • 상태 변경은 실패 이유가 명확해야 합니다.
  • 운영자는 왜 상태 변경이 안 되는지 알아야 합니다.

➕ 15-1. 에러 후보

상황HTTP 상태메시지
상담 없음404상담을 찾을 수 없습니다.
권한 없음403해당 상태로 변경할 권한이 없습니다.
잘못된 상태 전이400현재 상태에서 해당 상태로 변경할 수 없습니다.
동시 수정 충돌409이미 다른 관리자가 수정했습니다.
validation 실패400변경 사유를 입력해주세요.

➕ 15-2. 기준

없는 데이터:
404

권한 문제:
403

요청값/상태 전이 문제:
400

동시성 충돌:
409

서버/DB 장애:
500
  • 모든 실패를 500으로 보내면 안 됩니다.
  • 운영자가 조치할 수 있는 에러는 명확하게 구분해야 합니다.

✅ 16. 상태 변경 Use Case 테스트

  • 상태 변경 Use Case는 반드시 테스트 가치가 높습니다.
  • 상태, 이력, Audit Log, transaction이 모두 연결되기 때문입니다.

➕ 16-1. 테스트 케이스 후보

NEW → CALLING 성공
CANCELED → CONVERTED 실패
권한 없는 관리자의 CONVERTED 변경 실패
상태 변경 시 status_history 생성
상태 변경 시 audit_log 생성
동시 수정 시 409 Conflict
Audit Log 생성 실패 시 전체 rollback
notification job 생성 조건 확인

➕ 16-2. Domain Service 단위 테스트

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

➕ 16-3. Use Case 통합 테스트

테스트 DB 준비
  ↓
상담 row 생성
  ↓
Use Case 실행
  ↓
consults.status 확인
  ↓
status_history 확인
  ↓
audit_log 확인
  • Domain Service는 빠른 단위 테스트가 좋습니다.
  • Use Case는 transaction과 DB 결과를 확인하는 통합 테스트가 더 의미 있습니다.

✅ 17. 주문 상태 변경 Use Case로 확장

  • 상담 상태 변경 구조는 주문 상태 변경에도 거의 그대로 적용할 수 있습니다.
  • 주문은 상담보다 외부 시스템, 배송, 정산과 연결될 수 있어 더 엄격한 구조가 필요합니다.
주문 조회
  ↓
권한 확인
  ↓
상태 전이 검증
  ↓
order.status 업데이트
  ↓
order_status_history 생성
  ↓
audit_log 생성
  ↓
필요 시 notification_job 생성

➕ 17-1. OrderStatusService

RECEIVED → VERIFYING
VERIFYING → APPROVED
APPROVED → OPENING
OPENING → OPENED
OPENED → SHIPPING
SHIPPING → DONE

➕ 17-2. 상담과 다른 점

외부 개통 시스템과 상태 동기화 가능
배송 상태와 연결 가능
고객 안내 메시지와 연결 가능
정산/실적 기준과 연결 가능
상태 되돌리기 정책이 더 중요
  • 상담 상태 변경 구조를 먼저 안정화하면 주문 상태 변경에도 재사용할 수 있습니다.
  • Use Case/Domain Service/Repository 패턴이 반복 적용됩니다.

✅ 18. ExportJob 상태 변경 Use Case

  • Worker가 ExportJob 상태를 변경할 때도 상태 변경 Use Case 또는 전용 Service가 필요할 수 있습니다.
  • 작업 상태도 정합성이 중요하기 때문입니다.
PENDING
  ↓
PROCESSING
  ↓
DONE 또는 FAILED

➕ 18-1. 상태 변경 기준

PENDING → PROCESSING 가능
PROCESSING → DONE 가능
PROCESSING → FAILED 가능
DONE → PROCESSING 불가
FAILED → PROCESSING은 재시도 정책에 따라 가능

➕ 18-2. Worker에서 주의할 점

동일 Job을 두 Worker가 동시에 처리하지 않게 하기
PROCESSING 변경 시 lock 또는 조건 update 사용
실패 사유 저장
재시도 횟수 관리
  • 상태 변경 구조는 상담/주문뿐 아니라 Job 처리에도 중요합니다.
  • 상태가 있는 데이터는 대부분 상태 전이 규칙이 필요합니다.

✅ 19. 상태 변경 Use Case와 이벤트

  • 상태 변경 후 다른 작업이 이어져야 하는 경우가 있습니다.
  • 예를 들어 개통 완료 상태가 되면 알림 발송, 통계 증가, 외부 시스템 연동 등이 필요할 수 있습니다.

➕ 19-1. 직접 호출 방식

상태 변경 Use Case
  ↓
notification job 생성
  ↓
stats update
  ↓
audit log

장점:

흐름이 명확함
추적하기 쉬움
초기 구현이 단순함

단점:

Use Case가 점점 커질 수 있음
후속 작업이 많아지면 복잡해짐

➕ 19-2. 이벤트 방식

상태 변경
  ↓
ConsultStatusChangedEvent 저장 또는 발행
  ↓
Handler들이 후속 작업 처리

장점:

후속 작업 분리 가능
알림/통계/외부 연동 확장 쉬움

단점:

흐름 추적이 어려워질 수 있음
초기 구조가 복잡해짐

➕ 19-3. 현재 기준 추천

초기:
Use Case 안에서 필요한 Job row 생성

후속 작업 증가:
Outbox/Event 구조 검토

주의:
외부 API 직접 호출은 피하기
  • 지금 단계에서는 이벤트 구조를 과하게 도입하지 않아도 됩니다.
  • 먼저 Use Case에서 DB Job을 생성하고 Worker로 넘기는 정도가 현실적입니다.

✅ 20. Transaction 안에서 하지 말아야 할 것

  • transaction 안에는 DB 정합성에 꼭 필요한 작업만 넣어야 합니다.
  • 외부 호출이나 오래 걸리는 작업을 넣으면 lock 시간이 길어지고 장애 가능성이 커집니다.
알림톡/SMS 실제 발송
외부 API 호출
S3 업로드
엑셀 파일 생성
대량 파일 처리
네트워크 요청
사용자 응답 대기
긴 반복문 작업

➕ 20-1. 좋은 기준

넣어도 됨:
DB row 생성/수정
상태 이력 저장
Audit Log 저장
Job row 생성

빼야 함:
외부 API 호출
파일 생성/업로드
오래 걸리는 계산
  • transaction은 짧게 끝나야 합니다.
  • DB에 기록할 일과 외부에서 실행할 일을 분리해야 합니다.

✅ 21. Transaction 실패 시 고려할 점

  • transaction 안에서 하나라도 실패하면 전체 rollback됩니다.
  • 이때 어떤 실패가 전체 실패로 이어져야 하는지 정책이 필요합니다.

➕ 21-1. 전체 실패가 맞는 경우

상태 업데이트 실패
상태 이력 생성 실패
Audit Log 생성 실패
Job row 생성 실패

➕ 21-2. 전체 실패로 묶지 않는 게 나은 경우

알림톡 실제 발송 실패
외부 시스템 일시 장애
통계 캐시 업데이트 실패
비핵심 분석 이벤트 저장 실패

➕ 21-3. 기준

운영 데이터 정합성에 필수:
transaction 안

부가 작업/외부 작업:
Job 또는 비동기 처리

실패해도 재시도 가능:
Worker 처리
  • 모든 것을 하나의 transaction으로 묶으면 오히려 장애가 커집니다.
  • 반드시 같이 성공해야 하는 작업만 묶어야 합니다.

✅ 22. 상태 변경 Use Case 파일 구조 추천

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

➕ 22-1. 작게 시작하는 구조

consult/
  application/
    update-consult-status.use-case.ts
  domain/
    consult-status.service.ts
  infra/
    consult.repository.ts

➕ 22-2. 언제 더 나눌까?

Use Case 파일이 너무 커짐
Command 타입이 여러 곳에서 재사용됨
Mapper가 복잡해짐
상태 변경 정책이 늘어남
권한 정책이 복잡해짐
  • 처음부터 폴더를 너무 잘게 쪼갤 필요는 없습니다.
  • 하지만 상태 변경은 중요도가 높기 때문에 별도 Use Case로 분리할 가치가 큽니다.

✅ 23. AI/Codex에게 맡길 때 주의사항

  • 상태 변경 Use Case는 AI/Codex에게 맡기기 좋은 작업이지만, 조건을 매우 명확히 줘야 합니다.
  • 특히 transaction, 이력, Audit Log, 개인정보 노출 금지를 꼭 명시해야 합니다.

➕ 23-1. Codex 요청 예시

상담 상태 변경 로직을 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 체크리스트를 정리해줘

➕ 23-2. 리뷰 기준

transaction이 Use Case에 있는가?
Repository 내부에서 몰래 transaction을 열지 않는가?
상태 변경과 이력/Audit Log가 같은 tx를 쓰는가?
상태 전이 규칙이 중복되지 않는가?
동시 수정 충돌 처리가 있는가?
외부 API를 transaction 안에서 호출하지 않는가?
개인정보가 응답/로그에 노출되지 않는가?
기존 관리자 화면 흐름이 깨지지 않는가?
  • AI에게는 단순히 “리팩토링해줘”라고 하면 안 됩니다.
  • 정합성 조건과 운영 제약을 같이 줘야 안전합니다.

✅ 24. 실무 체크리스트

➕ 24-1. Transaction Boundary 체크리스트

  • transaction이 Use Case에서 시작되는가?
  • Controller에 transaction 로직이 없는가?
  • Repository 내부에 숨은 transaction이 없는가?
  • 같은 업무 흐름의 DB 작업이 같은 tx를 쓰는가?
  • transaction 안에 외부 API 호출이 없는가?
  • transaction 시간이 길어질 작업이 없는가?
  • 실패 시 rollback되어야 할 범위가 명확한가?
  • transaction 밖에서 처리할 작업이 Job으로 분리되어 있는가?

➕ 24-2. 상태 변경 Use Case 체크리스트

  • 현재 상태를 조회하는가?
  • 상태 전이 규칙을 검증하는가?
  • 상태 변경 권한을 확인하는가?
  • 동시 수정 충돌을 처리하는가?
  • 상태 업데이트와 상태 이력 생성이 같은 transaction인가?
  • Audit Log가 함께 저장되는가?
  • 필요 시 notification job을 생성하는가?
  • 응답 DTO에서 민감정보를 제외하는가?

➕ 24-3. 상태 이력 체크리스트

  • fromStatus와 toStatus가 모두 저장되는가?
  • changedByAdminId가 저장되는가?
  • 변경 사유와 메모가 필요한가?
  • createdAt이 상태 변경 시점과 일치하는가?
  • 상태 update 성공 후 이력을 생성하는가?
  • 이력 생성 실패 시 전체 rollback되는가?
  • 상담 상세에서 이력을 조회할 수 있는가?
  • 현재 상태와 마지막 이력 정합성을 점검할 수 있는가?

➕ 24-4. 동시성 체크리스트

  • 같은 상담을 여러 관리자가 동시에 수정할 수 있는가?
  • 현재 상태 조건 update를 사용하는가?
  • version 기반 optimistic lock이 필요한가?
  • 충돌 시 409 Conflict를 반환하는가?
  • 프론트에서 충돌 메시지를 보여줄 수 있는가?
  • 상태 변경 후 목록 캐시를 갱신하는가?
  • 상태 필터에서 row가 사라지는 UX를 고려했는가?
  • 테스트 케이스에 동시 수정 상황이 포함되어 있는가?

✅ 25. AI에게 Transaction Boundary와 상태 변경 설계를 물어볼 때 좋은 질문법

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 체크리스트
를 실무 기준으로 정리해줘.

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

transaction을 Controller가 아니라 Use Case에 두는가?
Repository 내부 숨은 transaction을 피하는가?
상태 변경/이력/Audit Log를 같은 tx로 묶는가?
외부 API 호출을 transaction 밖으로 분리하는가?
상태 전이 규칙을 Domain Service로 분리하는가?
권한 Policy와 Repository 책임을 구분하는가?
동시 수정 충돌 처리를 고려하는가?
Audit Log에 개인정보를 넣지 않도록 경고하는가?
현재 프로젝트 규모에 맞는 현실적인 구조를 제안하는가?

📌 요약

  • Transaction Boundary는 어떤 DB 작업들을 하나의 성공/실패 단위로 묶을지 정하는 기준입니다.
  • NestJS + Prisma 구조에서는 보통 Controller나 Repository가 아니라 Use Case가 transaction boundary를 갖는 것이 가장 자연스럽습니다.
  • 상담 상태 변경은 단순 update가 아니라 현재 상태 조회, 권한 확인, 상태 전이 검증, 동시성 방어, 상태 업데이트, 상태 이력 생성, Audit Log 저장까지 포함하는 업무 흐름입니다.
  • 상태 전이 규칙은 ConsultStatusService 같은 Domain Service에 두고, Use Case는 그 규칙을 호출해 업무 흐름을 조합하는 역할을 맡는 것이 좋습니다.
  • Repository는 Prisma query를 담당하고, Use Case가 연 transaction의 tx를 전달받아 같은 transaction 안에서 DB 작업을 실행해야 합니다.
  • 관리자 여러 명이 같은 상담을 동시에 수정할 수 있으므로 현재 상태 조건 update 또는 version 기반 optimistic lock으로 Lost Update를 방지해야 합니다.
  • 상태 변경 이력은 fromStatus, toStatus, changedByAdminId, reason, createdAt을 저장하고, 상태 update 성공 후 같은 transaction 안에서 생성해야 합니다.
  • Audit Log는 관리자 작업 추적을 위한 공통 로그이며, 상태 변경과 함께 저장하되 전화번호 원본, 토큰, Secret, 상담 메모 전체 같은 민감정보는 넣지 않아야 합니다.
  • 알림톡/SMS 실제 발송은 transaction 안에서 하지 말고, 상태 변경 transaction 안에서는 notification_jobs row만 생성한 뒤 Worker가 발송하도록 분리하는 것이 안전합니다.
  • 상태 변경 Use Case는 테스트 가치가 높으며, 정상 상태 전이, 잘못된 상태 전이, 권한 실패, 동시 수정 충돌, 이력/Audit Log 생성, rollback 여부를 확인해야 합니다.

0개의 댓글