$백엔드 개발자 Rueun의 기술 블로그|Java · Spring · 클린 아키텍처🌱
#Development

상태 머신을 도메인 객체로 표현하는 방법

@2025-09-12·20 min read·📖 series: 클린 아키텍처로 실제 서비스 만들기

상태 머신을 도메인 객체로 표현하는 방법

분석 파이프라인이 있는 시스템을 설계할 때 가장 까다로운 문제 중 하나는 상태 관리다. "지금 이 작업이 어느 단계에 있는가?", "다음 단계로 넘어갈 수 있는 조건이 충족됐는가?", "실패했을 때 어디서부터 재시도해야 하는가?" 같은 질문들이 끊임없이 생긴다.

이 글에서는 문서 분석 시스템의 분석 파이프라인을 설계하면서, 상태 전이 규칙을 Enum에 캡슐화하고 도메인 객체가 스스로 유효성을 검증하도록 만든 과정을 정리한다.

분석 파이프라인의 복잡성

이 시스템의 분석 파이프라인은 3단계로 구성된다.

  1. EXTRACT_DOCUMENT: 문서에서 텍스트와 이미지를 추출한다.
  2. RECALL_CANDIDATE: 추출된 이미지와 유사한 이미지 후보를 탐색한다.
  3. CHECK_DUPLICATE: 후보 이미지들과 표절 여부를 최종 판정한다.

각 단계는 외부 분석 엔진을 호출하고, 처리가 완료되면 다음 단계로 넘어간다. 각 단계는 독립적으로 실패할 수 있고, 실패한 단계부터 재시도할 수 있어야 한다.

여기서 상태는 두 가지 차원으로 나뉜다.

  • AnalysisStatus: 전체 작업의 진행 상태 (PENDING → PROCESSING → COMPLETED / FAILED)
  • AnalysisStep: 현재 파이프라인의 어느 단계를 처리 중인가 (EXTRACT_DOCUMENT → RECALL_CANDIDATE → CHECK_DUPLICATE)

이 두 상태가 조합되어 "3단계 처리 중", "2단계에서 실패" 같은 상황을 표현한다.

서비스 레이어에 if/else로 상태를 관리했다면

설계를 시작하기 전에, 잘못된 설계가 어떤 모습인지 먼저 살펴보자. 많은 프로젝트에서 상태 전이 로직이 서비스 레이어에 이런 식으로 흩어져 있다.

JAVA
// 나쁜 예시: 서비스 레이어에 상태 전이 로직이 흩어짐
public void processNextStep(Long jobId) {
    AnalysisJob job = jobRepository.findById(jobId);
 
    // 상태 전이 조건을 서비스가 직접 판단
    if (job.getAnalysisStatus() == AnalysisStatus.PENDING) {
        job.setAnalysisStatus(AnalysisStatus.PROCESSING);
        job.setAnalysisStep(AnalysisStep.EXTRACT_DOCUMENT);
    } else if (job.getAnalysisStatus() == AnalysisStatus.PROCESSING
            && job.getAnalysisStep() == AnalysisStep.EXTRACT_DOCUMENT) {
        job.setAnalysisStep(AnalysisStep.RECALL_CANDIDATE);
    } else if (job.getAnalysisStatus() == AnalysisStatus.PROCESSING
            && job.getAnalysisStep() == AnalysisStep.RECALL_CANDIDATE) {
        job.setAnalysisStep(AnalysisStep.CHECK_DUPLICATE);
    } else {
        throw new IllegalStateException("Cannot transition");
    }
    // ...
}

이 코드의 문제점이 보이는가?

첫째, 도메인 규칙이 서비스에 있다. 상태 전이가 유효한지 판단하는 것은 비즈니스 규칙이다. 이 규칙이 서비스 코드에 섞여 있으면, 비슷한 로직이 여러 서비스에 중복되거나 서로 다르게 구현될 위험이 있다.

둘째, 도메인 객체가 빈껍데기가 된다. setAnalysisStatus(), setAnalysisStep() 같은 Setter만 가진 객체는 그냥 데이터 구조체다. 어떤 서비스에서든 아무 상태나 마음대로 설정할 수 있어서 잘못된 상태가 만들어질 수 있다.

셋째, 테스트가 어렵다. 상태 전이 로직을 테스트하려면 서비스 전체를 테스트해야 한다.

🚨Anemic Domain Model의 위험

Setter만 있고 비즈니스 로직이 없는 도메인 객체를 "빈혈 도메인 모델(Anemic Domain Model)"이라고 한다. 겉으로는 객체지향처럼 보이지만, 실제로는 절차적 프로그래밍과 다를 바 없다. 도메인 규칙이 서비스 전체에 흩어져 유지보수가 어려워진다.

Enum으로 상태 전이 규칙을 캡슐화하기

핵심 아이디어는 간단하다. "어떤 상태에서 어떤 상태로 전이할 수 있는가"를 Enum 자신이 알고 있게 하자.

JAVA
public enum AnalysisStatus {
    PENDING("대기 중"),
    PROCESSING("처리 중"),
    COMPLETED("분석 완료"),
    FAILED("분석 실패");
 
    private final String description;
 
    // 허용된 상태 전이 규칙을 Enum 내부에 선언
    private static final Map<AnalysisStatus, Set<AnalysisStatus>> TRANSITIONS = Map.of(
        PENDING,    Set.of(PROCESSING, FAILED),
        PROCESSING, Set.of(PROCESSING, COMPLETED, FAILED),
        COMPLETED,  Set.of(),
        FAILED,     Set.of(PROCESSING)   // 재시도 허용
    );
 
    public void validateTransition(final AnalysisStatus nextStatus) {
        if (!canTransitionTo(nextStatus)) {
            throw new IllegalStateException(
                "Invalid status transition from " + this + " to " + nextStatus
            );
        }
    }
 
    public boolean canTransitionTo(final AnalysisStatus nextStatus) {
        if (this == nextStatus) return true;
        return TRANSITIONS.getOrDefault(this, Collections.emptySet()).contains(nextStatus);
    }
}

AnalysisStep도 같은 방식으로 설계됐다.

JAVA
public enum AnalysisStep {
    EXTRACT_DOCUMENT("문서 내용 추출"),
    RECALL_CANDIDATE("이미지 후보 추출"),
    CHECK_DUPLICATE("이미지 표절 검사");
 
    private static final Map<AnalysisStep, Set<AnalysisStep>> TRANSITIONS = Map.of(
        EXTRACT_DOCUMENT, Set.of(EXTRACT_DOCUMENT, RECALL_CANDIDATE),
        RECALL_CANDIDATE, Set.of(RECALL_CANDIDATE, CHECK_DUPLICATE),
        CHECK_DUPLICATE,  Set.of(CHECK_DUPLICATE, EXTRACT_DOCUMENT) // 재시작 허용
    );
 
    public void validateTransition(final AnalysisStep nextStep) {
        if (!canTransitionTo(nextStep)) {
            throw new IllegalStateException(
                "Invalid analysis step transition from " + this + " to " + nextStep
            );
        }
    }
 
    public boolean canTransitionTo(final AnalysisStep nextStep) {
        if (this == nextStep) return true;
        return TRANSITIONS.getOrDefault(this, Collections.emptySet()).contains(nextStep);
    }
}
ℹ️Map.of()로 불변 전이 테이블 선언

Java 9+의 Map.of()는 불변 맵을 생성한다. 런타임에 전이 규칙이 변경될 위험이 없다. 또한 static final로 선언해 JVM이 클래스 로딩 시점에 한 번만 초기화한다.

AnalysisJob: 두 상태를 함께 관리하는 도메인 객체

AnalysisJobAnalysisStatusAnalysisStep 두 상태를 함께 관리한다. 상태 변경 메서드는 두 Enum의 검증을 순차적으로 호출한다.

JAVA
public class AnalysisJob {
    private Long id;
    private Long documentId;
    private String documentKey;
    private DocumentMeta documentMeta;
    private AnalysisStep analysisStep;
    private AnalysisStatus analysisStatus;
    private String errorMessage;
 
    public static AnalysisJob createNew(
            final Long documentId,
            final DocumentMeta documentMeta) {
        return AnalysisJob.builder()
                .documentId(documentId)
                .documentKey(documentMeta.getDocumentKey())
                .documentMeta(documentMeta)
                .analysisStatus(AnalysisStatus.PENDING)      // 초기 상태 고정
                .analysisStep(AnalysisStep.EXTRACT_DOCUMENT)  // 첫 단계 고정
                .build();
    }
 
    // 정상 상태 전이
    public void updateStatus(
            final AnalysisStatus analysisStatus,
            final AnalysisStep analysisStep) {
        // 도메인 규칙 검증을 Enum에 위임
        this.analysisStatus.validateTransition(analysisStatus);
        this.analysisStep.validateTransition(analysisStep);
        this.analysisStatus = analysisStatus;
        this.analysisStep = analysisStep;
    }
 
    // 실패 상태 전이
    public void updateFailStatus(
            final AnalysisStep analysisStep,
            final String errorMessage) {
        this.analysisStep.validateTransition(analysisStep);
        this.analysisStatus = AnalysisStatus.FAILED;
        this.analysisStep = analysisStep;
        this.errorMessage = errorMessage;
    }
 
    public boolean isInProgress() {
        return this.analysisStatus == AnalysisStatus.PENDING
            || this.analysisStatus == AnalysisStatus.PROCESSING;
    }
}

이 설계에서 중요한 점은 updateStatus()가 단순히 값을 설정하는 것이 아니라, 유효한 전이인지 검증한 후에만 상태를 변경한다는 것이다. 외부에서 아무 상태나 주입할 수 없다.

AnalysisStatus와 AnalysisStep을 분리한 이유

처음에는 두 상태를 하나의 Enum으로 합치는 것을 고려했다. PENDING_EXTRACT, PROCESSING_EXTRACT, PROCESSING_RECALL 같은 식으로.

그러나 이렇게 하면 조합의 수가 폭발적으로 증가한다. 3단계 파이프라인에 4가지 상태를 곱하면 이론적으로 12가지 조합이 생기고, 이 중 의미 없는 조합(예: COMPLETED_EXTRACT)도 생겨버린다.

두 상태를 분리하면 각각이 자신의 관심사만 담당한다.

  • AnalysisStatus는 "이 작업이 진행 중인가, 완료됐는가, 실패했는가"를 표현한다.
  • AnalysisStep은 "지금 어느 단계를 처리하고 있는가"를 표현한다.

isInProgress() 같은 메서드도 AnalysisStatus만으로 판단할 수 있어서 명확하다.

💡상태 분리 기준

두 가지 상태 차원이 독립적으로 변할 수 있다면 분리하는 것이 좋다. 반면 항상 함께 변한다면 하나로 합치는 것이 오히려 명확할 수 있다. 이 시스템에서는 같은 Step을 유지하면서 Status만 FAILED로 바뀌는 케이스가 있었기 때문에 분리가 자연스러웠다.

상태 전이 다이어그램

AnalysisStatus 전이

AnalysisStatus 상태 전이

PENDING
대기 중
updateStatus()
PROCESSING
처리 중
updateStatus()
COMPLETED
분석 완료
FAILED
분석 실패

AnalysisStep 전이

AnalysisStep 전이 — createNew() 시 EXTRACT_DOCUMENT 고정

EXTRACT_DOCUMENT
문서 내용 추출
↺ 재시도
추출 완료
RECALL_CANDIDATE
이미지 후보 탐색
↺ 재시도
탐색 완료
CHECK_DUPLICATE
표절 검사
↺ 재시도
전체 재시작: CHECK_DUPLICATE → EXTRACT_DOCUMENT (허용)

실제 파이프라인 흐름

실제 파이프라인 — Status × Step 조합 흐름

FAILED / (현재 단계)
실패 → updateFailStatus() → 재시도 시 PROCESSING으로 복귀

도메인 검증이 일어나는 흐름

실제로 서비스 코드에서 상태 전이를 호출하는 흐름을 보면, 도메인 검증이 어디서 일어나는지 명확해진다.

JAVA
// 서비스 코드 (간략화)
public void completeExtraction(Long jobId) {
    AnalysisJob job = jobRepository.findById(jobId)
        .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND));
 
    // 서비스는 "무엇을 할지"만 지시한다.
    // "할 수 있는지"는 도메인이 판단한다.
    job.updateStatus(AnalysisStatus.PROCESSING, AnalysisStep.RECALL_CANDIDATE);
 
    jobRepository.save(job);
}

서비스 코드는 놀랍도록 단순하다. 상태 전이가 유효한지 판단하는 복잡한 if/else 로직이 전혀 없다. 유효하지 않은 전이라면 AnalysisStatus.validateTransition() 또는 AnalysisStep.validateTransition()에서 즉시 예외가 발생한다.

1

서비스가 상태 변경 요청

서비스는 도메인 규칙을 모른 채 job.updateStatus(nextStatus, nextStep)을 호출한다.

2

AnalysisJob이 두 Enum에 검증 위임

updateStatus() 내부에서 this.analysisStatus.validateTransition(analysisStatus)this.analysisStep.validateTransition(analysisStep)을 순차 호출한다.

3

Enum이 전이 테이블에서 허용 여부 확인

각 Enum은 자신의 TRANSITIONS 맵에서 현재 상태 → 다음 상태가 허용되는지 확인한다.

4

유효하면 상태 변경, 무효하면 예외

전이가 허용되면 상태가 변경된다. 허용되지 않으면 IllegalStateException이 발생해 서비스 레이어로 전파된다.

정보전문가 패턴 관점에서

이 설계는 정보전문가(Information Expert) 패턴을 충실히 따른다.

정보전문가 패턴의 핵심은 "어떤 책임을 수행하는 데 필요한 정보를 가진 객체에 그 책임을 할당하라"는 것이다.

"이 상태 전이가 유효한가?"라는 질문에 답하려면 "어떤 전이가 허용되는가"라는 정보가 필요하다. 이 정보를 가장 잘 아는 객체는 바로 AnalysisStatus Enum 자신이다. 따라서 검증 책임을 Enum에 할당하는 것이 자연스럽다.

서비스가 이 정보를 외부에서 가져와 판단하는 것은 정보전문가 패턴을 위반한다. "내가 어떤 상태인지는 내가 가장 잘 안다."

이 설계의 장점

잘못된 상태 전이가 런타임에 즉시 차단된다. 개발자 실수나 예상치 못한 이벤트 순서로 인한 잘못된 상태 전이가 도메인 레벨에서 예외를 발생시킨다. 데이터베이스에 잘못된 상태가 저장되는 상황 자체가 방지된다.

상태 전이 규칙이 한 곳에 모여 있다. TRANSITIONS 맵을 보면 어떤 전이가 허용되는지 한눈에 파악할 수 있다. 규칙이 서비스 여러 곳에 흩어져 있지 않다.

테스트가 단순해진다. 각 Enum의 validateTransition() 메서드를 독립적으로 테스트할 수 있다. Spring Context나 DB 없이도 도메인 규칙의 정확성을 검증할 수 있다.

JAVA
@Test
void 유효한_상태_전이는_허용된다() {
    assertDoesNotThrow(() ->
        AnalysisStatus.PENDING.validateTransition(AnalysisStatus.PROCESSING)
    );
}
 
@Test
void 완료_상태에서_다른_상태로_전이하면_예외가_발생한다() {
    assertThrows(IllegalStateException.class, () ->
        AnalysisStatus.COMPLETED.validateTransition(AnalysisStatus.PROCESSING)
    );
}

비즈니스 의도가 코드에 드러난다. FAILED에서 PROCESSING으로의 전이가 허용된다는 것은 "실패한 작업을 재시도할 수 있다"는 비즈니스 규칙을 코드로 표현한 것이다. 주석 없이도 의도가 명확하다.

정리

상태 전이 로직을 서비스에서 도메인 Enum으로 옮기는 것은 작은 변화처럼 보이지만, 효과는 크다. 서비스가 단순해지고, 도메인 규칙이 응집되며, 잘못된 상태가 만들어지는 경우가 원천 차단된다.

다음 편에서는 파일 스토리지 연동을 Port & Adapter 패턴으로 설계한 방법을 다룬다. 왜 서버가 파일을 중계하지 않고 Presigned URL 방식을 선택했는지, 그리고 FileStorage 인터페이스 하나로 기술 교체 유연성을 어떻게 확보했는지 살펴볼 것이다.

§ 목차