Post

God Enum은 왜 나중에 아키텍처 부채가 되는가

enum 하나에 응답 포맷, 도메인 분류 로직, i18n 메타데이터, 카탈로그 정의를 함께 넣기 시작하면 왜 나중에 구조 부채가 커지는지 실제 사례를 바탕으로 정리한 글이다.

처음엔 그냥 분류 enum 하나였을 뿐이다.
그런데 시간이 지나면서 그 enum이 응답 문자열도 들고, 등급 계산도 하고, i18n 키와 색상도 품고, fallback까지 책임지기 시작했다.

문제가 본격적으로 보인 건 사소한 변경이 사소하지 않게 커질 때였다.
enum 이름 하나를 바꾸면 API 응답이 흔들리고, descKey가 같이 깨지고, 분류 함수 결과까지 영향 범위가 번졌다.
거기에 of(name) 같은 매핑 함수까지 붙어 있으면 선언 순서 때문에 특정 값이 영원히 선택되지 않는 dead branch도 생긴다.

이번 문제를 한 문장으로 요약하면 이렇다.

enum 하나가 “분류 목록”이 아니라 응답 포맷, 도메인 로직, 표시 메타, 카탈로그 정의를 함께 떠안기 시작하면 그 순간부터 구조 부채가 쌓인다.

이 글에서 말하고 싶은 핵심도 단순하다.

God Enum의 문제는 enum을 썼다는 데 있지 않다.
서로 다른 레이어의 책임을 한 타입에 합친 상태가 문제다.

문제는 enum이 아니라 책임이 붙는 방향이다

enum은 원래 나쁜 도구가 아니다.
도메인 안에서 가능한 상태나 분류 목록을 표현하는 데는 여전히 좋은 선택이다.

문제는 그 enum이 시간이 지나면서 이런 것들을 같이 떠안기 시작할 때다.

  • 외부 API나 응답 JSON에 어떤 문자열이 나갈지
  • 어떤 입력이 어떤 등급으로 분류될지
  • 이 값을 화면에서 어떤 키, 색상, 아이콘으로 표시할지
  • 도메인 안에 어떤 분류가 존재하는지

이 네 가지는 얼핏 비슷해 보이지만 사실 서로 다른 책임이다.

  1. Wire Format 외부와 주고받는 문자열 표현
  2. Domain Classification 입력을 어떤 카테고리로 분류할지 결정하는 규칙
  3. Presentation Metadata 화면에서 어떤 이름, 색상, 아이콘으로 보여줄지
  4. Catalog Definition 도메인 안에 어떤 값들이 존재하는지

카탈로그 정의와 wire format 정도는 같은 enum에 잠깐 같이 있어도 버틸 수 있다.
문제는 여기에 분류 규칙과 표시 메타데이터가 같이 붙는 순간부터다.

그때부터 enum은 단순한 상수 목록이 아니라, 구조 경계를 무너뜨리는 접착제 역할을 하게 된다.

실제로는 이런 식으로 부채가 자란다

처음엔 보통 이렇게 시작한다.

1
2
3
4
5
enum class StormStatus {
    NORMAL,
    WARNING,
    DANGER,
}

여기까진 괜찮다.
그런데 요구사항이 하나씩 붙으면서 enum이 달라진다.

1
2
3
4
5
6
7
8
9
10
11
12
13
enum class StormStatus(
    val descKey: String,
    val color: String,
) {
    NORMAL("storm.normal", "blue"),
    WARNING("storm.warning", "yellow"),
    DANGER("storm.danger", "red"),
    ;

    companion object {
        fun calculateGrade(speed: Float, locale: Locale): StormStatus { ... }
    }
}

이 순간부터 한 타입 안에 여러 층의 관심사가 같이 섞인다.

  • descKey, color는 presentation 메타데이터다
  • calculateGrade(...)는 domain classification이다
  • Locale은 cross-cutting context다
  • enum value 자체는 catalog definition이다

겉보기에는 응집도가 높은 것처럼 보일 수 있다.
“StormStatus 관련 정보는 다 여기 있네”라고 느끼기 쉽기 때문이다.

하지만 이건 응집이 아니라 결합이다.
서로 다른 이유로 바뀌는 것들이 한 타입에 모여 있는 상태다.

God Enum이 무서운 이유는 변경 반경이 커지기 때문이다

이 구조가 위험한 이유는 코드가 길어져서가 아니다.
한 군데를 바꿀 때 의도치 않게 너무 많은 곳이 같이 흔들리기 때문이다.

예를 들어 enum 이름 하나를 바꾼다고 해보자.

영향 범위가 이렇게 번진다.

  • API 응답 문자열이 바뀐다
  • 도메인 분류 결과가 바뀔 수 있다
  • i18n 키가 깨질 수 있다
  • 화면 색상 매핑도 같이 흔들린다
  • 클라이언트와 서버 동시 배포가 필요해질 수 있다

즉 변경 하나의 폭발 반경이 너무 넓다.

이 문제는 특히 멀티모듈 구조에서 더 커진다.
Presentation 패키지에 있는 enum을 Domain 서비스가 직접 import하기 시작하면, 의존 방향도 거꾸로 흐른다.

예를 들어 이런 상황이 생긴다.

  • 응답 DTO용 enum이 app-api에 있다
  • 그런데 domain/service 코드가 그 enum의 정적 함수나 필드를 호출한다

이건 사실상 “도메인이 표현 계층을 안다”는 뜻이다.
처음엔 편해 보여도, 나중에는 같은 분류 정보가 다른 모듈에서 필요해질 때마다 더 안 좋은 의존이 복제된다.

조용히 더 위험한 건 fallback과 dead branch다

God Enum 구조는 조용한 오분류 버그와도 잘 엮인다.

특히 외부 문자열을 enum으로 매핑할 때 of(name) 같은 함수가 붙기 시작하면 더 그렇다.

예를 들어 이런 코드가 있다.

1
2
3
4
5
6
7
8
9
10
11
enum class FooStatus(val apiName: String) {
    FOO("Foo"),
    BAR("Bar"),
    BAZ("Foo"),
    ;

    companion object {
        fun of(name: String) =
            entries.find { it.apiName == name } ?: FOO
    }
}

여기서 BAZ는 사실상 도달 불가능하다.
entries.find는 선언 순서상 앞의 FOO("Foo")를 먼저 잡기 때문이다.

이런 버그가 더 위험한 이유는 조용히 틀린다는 점이다.

  • 빌드는 통과한다
  • 기본 fallback도 있다
  • 런타임 에러도 안 난다
  • 대신 오분류가 응답에 섞여 나간다

God Enum 안에 wire format과 fallback 정책까지 같이 들어가면, 이런 종류의 버그는 더 찾기 어려워진다.
“분류 규칙이 틀린 건지”, “응답 매핑이 틀린 건지”, “enum 선언이 중복된 건지”가 한 군데에 섞여 있기 때문이다.

구조적으로 보면 어떤 위반이 일어나는가

이 패턴을 구조 관점에서 보면 보통 아래 신호들이 같이 나타난다.

1. 레이어 역전

도메인 코드가 presentation 패키지의 enum을 import한다.
분류 규칙이 응답 타입에 종속된다.

2. Cross-cutting context 누수

분류 함수 시그니처에 Locale, User, TenantId 같은 값이 들어온다.
표현 메타가 domain classification 함수까지 침투한 상태다.

3. 요청/응답 타입과 도메인 타입의 경계 소실

클라이언트와 주고받는 값, 내부 분류 결과, fallback 정책이 한 타입에 묶인다.
입력 검증과 출력 표현이 분리되지 않는다.

4. 재사용 불가

다른 도메인이나 다른 모듈에서 분류 정보가 필요해도, 표현 계층에 있는 enum을 그대로 끌고 와야 한다.
그 순간 안 좋은 의존이 전염된다.

그래서 어떻게 나누는 게 좋은가

핵심은 복잡한 패턴을 억지로 도입하는 게 아니다.
같이 바뀌지 않는 것들을 분리하는 것이다.

내가 지금 기준으로 나누는 방식은 이렇다.

1. Domain Catalog는 순수하게 둔다

1
2
3
4
5
enum class StormStatus {
    NORMAL,
    WARNING,
    DANGER,
}

여기에는 메타 필드도, 정적 계산 함수도 넣지 않는다.

2. 분류 규칙은 별도 Policy로 뺀다

1
2
3
interface StormClassificationPolicy {
    fun classify(speed: Float): StormStatus
}

이렇게 하면 입력이 바뀌거나 분류 규칙이 달라져도 enum 자체는 안 흔들린다.

3. Presentation 메타데이터는 별도 Resolver로 둔다

1
2
3
4
interface StormPresentationResolver {
    fun descKey(status: StormStatus): String
    fun color(status: StormStatus): String
}

이렇게 하면 i18n 키나 색상 정책을 바꿔도 도메인 타입은 그대로 둘 수 있다.

4. Wire Format은 DTO로 분리한다

응답 문자열이 domain enum과 같은 모양이더라도 타입은 분리하는 편이 낫다.
그래야 enum 이름 변경이 바로 외부 breaking change로 이어지지 않는다.

리팩터는 한 번에 다 하지 않는 편이 낫다

이런 구조를 보면 한 번에 다 뜯어고치고 싶어진다.
하지만 대개는 단계적으로 가는 편이 더 낫다.

내가 보통 추천하는 순서는 이렇다.

1단계. Cross-cutting context부터 격리

분류 함수 시그니처에서 Locale, User 같은 표현/세션 메타를 뺀다.
이건 효과에 비해 위험이 가장 낮다.

2단계. Domain enum과 Wire DTO 분리

enum을 domain 쪽으로 옮기고, 응답용 타입은 따로 만든다.
의존 방향이 정상화되기 시작한다.

3단계. Policy와 Metadata Resolver 분리

여기까지 가면 4가지 책임이 대부분 풀린다.
다만 이 단계는 범위가 커질 수 있으니 다른 같은 패턴과 묶어서 ADR처럼 결정하는 편이 낫다.

중요한 건 완벽한 구조보다 폭발 반경을 줄이는 순서다.

코드 리뷰에서 어떻게 빨리 알아차릴까

지금은 enum을 볼 때 먼저 이 신호들을 본다.

  • companion object 안에 분류 로직이 있는가
  • enum 필드에 descKey, color, icon 같은 메타가 붙어 있는가
  • 서비스 코드가 응답 패키지 enum을 import하는가
  • UNKNOWN, OTHER 같은 fallback 값이 분류 정책까지 같이 떠안고 있는가
  • of(name) 매핑 함수가 있고, 조회 키 고유성 테스트가 없는가
  • enum value 이름 변경이 클라이언트 breaking change로 이어지는 구조인가

이 중 2~3개만 겹쳐도 God Enum일 가능성이 높다.

마무리

God Enum의 문제는 enum이 길어지는 데 있지 않다.
서로 다른 레이어의 책임이 한 타입에 눌어붙기 시작하는 데 있다.

처음엔 편하다.
분류도 여기 있고, 색상도 여기 있고, 응답 문자열도 여기 있고, 계산 함수도 여기 있으면 찾기 쉽기 때문이다.

하지만 시간이 지나면 그 편의성은 구조 부채로 바뀐다.

  • 의존 방향이 흐려지고
  • 변경 반경이 커지고
  • fallback과 오분류가 조용히 쌓이고
  • 다른 모듈로 같은 문제가 전염된다

그래서 지금은 enum을 볼 때 이렇게 생각한다.

  • 이건 정말 catalog만 표현하고 있는가
  • 아니면 이미 분류 규칙, 표현 메타, wire format까지 같이 안고 있는가
  • 이 enum 이름 하나를 바꿨을 때 API, UI, 도메인 로직이 같이 흔들리는가

God Enum은 대개 어느 날 갑자기 등장하지 않는다.
편의를 위해 하나씩 붙이다 보면 나중에 “왜 이 enum 하나 바꾸는 게 이렇게 무겁지?”라는 순간이 온다.

그 질문이 들릴 때가 이미 신호다.
그 enum은 상수 목록이 아니라, 아키텍처 부채가 되고 있다.

This post is licensed under CC BY 4.0 by the author.