에러율 0%에 도전! MSA 환경에서 Enum을 다루는 안전한 전략

이동휘·2025년 6월 21일
0

매일매일 블로그

목록 보기
31/49

최근 많은 서비스가 빠른 개발 속도와 유연한 확장을 위해 MSA(Microservice Architecture)를 채택하고 있습니다. 하지만 "은탄환은 없다"는 말처럼, MSA는 그 장점만큼이나 다양한 기술적 복잡성을 수반합니다. 그중에서도 많은 개발자들이 간과하기 쉽지만, 때로는 치명적인 장애를 유발하는 주제가 바로 Enum 관리입니다.

Java나 Kotlin을 사용하는 개발자라면 타입 안정성, 컴파일 타임 체크, IDE의 편리한 자동완성 등 다양한 장점 때문에 Enum을 애용합니다. 하지만 분산된 MSA 환경에서는 이 친숙한 도구가 예상치 못한 독이 될 수 있습니다.

이번 글에서는 MSA 환경에서 Enum이 왜 문제가 될 수 있는지, 그리고 저희는 이 문제를 시스템적으로 해결하기 위해 어떤 전략을 수립하고 구현했는지 그 경험을 공유하고자 합니다.


1. MSA에서는 왜 Enum이 독이 될 수 있을까?

단일 애플리케이션(Monolithic Architecture) 환경에서 Enum은 매우 안전하고 효율적인 도구입니다. 하지만 여러 서비스가 독립적으로 배포되고 통신하는 MSA 환경에서는 이야기가 달라집니다.

가장 흔하게 발생하는 문제는, 여러 서비스 간에 공유되는 Enum에 새로운 값이 추가되었을 때 발생합니다.

  • 시나리오: 서비스 A(제공자)와 서비스 B(소비자)가 OrderStatus라는 Enum을 공유하고 있습니다. (PENDING, PROCESSING, SHIPPED)
  • 변경 발생: 서비스 A에서 새로운 상태인 RETURNEDOrderStatus Enum에 추가하고 배포했습니다.
  • 문제 발생: 서비스 A가 RETURNED 상태 값을 서비스 B로 전달합니다. 하지만 서비스 B는 아직 RETURNED라는 값을 모르는 이전 버전의 코드로 운영 중입니다.
  • 결과: 서비스 B는 알 수 없는 "RETURNED"라는 문자열을 OrderStatus Enum 타입으로 변환(Deserialize)하려다 실패하고, InvalidFormatException과 같은 예외를 발생시키며 요청 처리에 실패합니다.

이러한 문제는 적게는 1년에 한두 번씩, 하지만 꾸준히 발생하며 서비스 안정성을 위협했습니다. 단순히 "배포 순서를 잘 지키자"는 다짐이나 "꼼꼼히 확인하자"는 휴먼 에러로 치부하기에는 너무 빈번하고 치명적이었습니다. 담당자가 바뀌거나, 새로운 서비스가 추가되거나, Enum 값이 변경되는 순간 문제는 어김없이 재발했습니다.

결국, 우리는 사람의 주의력이 아닌 시스템을 통해 근본적으로 이 문제를 예방하고 관리할 수 있는 방법이 필요하다는 결론에 도달했습니다.


2. 제공자와 소비자: 역할에 따른 Enum 처리 전략 수립

MSA 환경에서 Enum으로 인한 문제를 더 복잡하게 만드는 요인 중 하나는, 통신의 주체와 방향에 따라 기대하는 동작이 다르다는 점입니다. 우리는 요청의 주체를 기준으로 Enum 사용을 '제공자(Provider)''소비자(Consumer)'로 나누고, 각각의 유스케이스에 맞는 처리 전략을 수립했습니다.

1. 클라이언트(소비자) → 서버(제공자): 수신하는 입장은 엄격하게!

  • 상황: 클라이언트가 요청 Body나 파라미터에 Enum 값을 담아 서버로 보내고, 서버가 이를 파싱하여 처리하는 경우.
  • 전략: 서버(제공자)는 Enum 값의 의미를 정확히 해석하고 비즈니스 로직을 수행해야 합니다. 만약 정의되지 않은 Enum 값이 들어온다면, 이를 "UNKNOWN"과 같은 기본값으로 처리하여 로직을 이어가는 것은 위험합니다. 의도치 않은 상태로 데이터가 처리되어 무결성이 훼손될 수 있기 때문입니다.
  • 결론: 이 경우에는 Enum 값을 엄격하게 검증하고, 알 수 없는 값이 들어오면 즉시 에러(예: 400 Bad Request)를 반환하여 클라이언트에게 잘못된 요청임을 명확히 알려주는 것이 타당합니다.

2. 서버(제공자) → 클라이언트(소비자): 처리는 소비자의 선택에 맡기자!

  • 상황: 서버가 응답 Body에 Enum 값을 담아 클라이언트로 보내거나, Kafka와 같은 메시지 큐를 통해 다른 서비스(소비자)에게 이벤트를 전달하는 경우.
  • 전략: 서버(제공자)가 Enum을 확장하여 새로운 값을 보내더라도, 소비자 입장에서는 해당 값을 아직 모를 수 있습니다. 이때 소비자 측에서 애플리케이션이 중단되지 않고 유연하게 대응할 수 있도록 선택지를 열어주어야 합니다.
  • 소비자의 선택지:
    • 알 수 없는 값이면 무시하고 경고 로그만 남긴다.
    • Fallback 동작으로 "UNKNOWN" 또는 "기타"와 같은 기본값으로 처리한다.
    • 도메인 로직상 반드시 알아야 하는 값이면 오류를 발생시켜 문제를 명시적으로 인지하고 처리한다.
  • 결론: 소비자가 상황에 맞게 처리 전략을 선택할 수 있도록, 제공자는 알 수 없는 값이 전달되더라도 소비자의 역직렬화 과정에서 에러가 발생하지 않도록 해야 합니다.

이러한 유스케이스 분석을 통해, 우리는 Enum 역직렬화 오류로부터 자유로워질 수 있는 아래 세 가지 구체적인 해결책을 도출했습니다.

  1. Enum을 선택적으로 역직렬화하기: EnumString 도입
  2. EnumString 사용 강제화하기: ArchUnit 활용
  3. Enum 배포 의존성 끊기: Meta Expose 시스템 구축

3. 해결책 1: Enum을 선택적으로 Deserialize하기 - EnumString

알지 못하는 Enum 값이 들어왔을 때, Jackson 라이브러리의 @JsonEnumDefaultValue@JsonCreator 같은 어노테이션을 사용하여 기본값을 반환하도록 처리할 수 있습니다. 하지만 이 방법들은 개발자가 의도한 대로 동작을 세밀하게 제어하기 어렵고, 도메인 로직상 예기치 않은 버그를 유발할 수 있다는 한계가 있었습니다.

그래서 우리는 Enum 타입이 가진 장점(타입 안정성, 가독성 등)을 유지하면서도, 예상치 못한 값이 들어왔을 때 개발자가 직접 원하는 동작을 정의할 수 있는 유연한 접근법을 도입하기로 결정했습니다. 단순히 모든 Enum을 String 타입으로 처리하는 것은 타입 안정성을 해치고 사용성을 제한할 수 있으므로, 우리는 전용 래퍼 클래스인 EnumString을 직접 제작했습니다.

EnumString의 핵심 아이디어:

  1. JSON 역직렬화 시점에는 Enum 타입이 아닌 EnumString 타입으로 값을 받는다. EnumString은 내부적으로 값을 String으로 가지고 있으므로, 알 수 없는 값이 들어와도 역직렬화 에러가 발생하지 않는다.
  2. 실제 비즈니스 로직에서 해당 값이 필요한 시점에, 개발자가 명시적으로 EnumString을 실제 Enum 타입으로 변환하는 메서드를 호출한다.
  3. 이 변환 메서드는 다양한 옵션을 제공하여, 개발자가 상황에 맞게 처리 방식을 선택할 수 있도록 한다.

EnumString 추상 클래스 및 사용 예시 (Kotlin):

// EnumString의 핵심 기능을 담은 추상 클래스
abstract class EnumString<E : Enum<E>>(
    @get:JsonValue // 직렬화 시에는 내부의 String 값만 사용
    override val value: String,
) : DelegatedValue<String>(value) {

    // 실제 Enum 타입으로 변환을 시도하고, 실패 시 null을 반환
    abstract fun toEnumOrNull(): E?

    // Enum 변환 실패 시, 기본값을 제공하는 람다를 실행
    fun toEnumOrElse(defaultValueSupplier: () -> E): E {
        return toEnumOrNull() ?: defaultValueSupplier()
    }

    // Enum 변환 실패 시, 예외를 발생시킴
    fun toEnumOrThrow(
        exceptionSupplier: () -> RuntimeException = { IllegalStateException("'$value'에 해당하는 Enum을 찾을 수 없습니다.") }
    ): E {
        return toEnumOrNull() ?: throw exceptionSupplier()
    }
}

// 실제 Enum 정의
enum class Animal {
    DOG, CAT, RABBIT;
}

// Animal Enum을 위한 EnumString 구현체
class AnimalString private constructor(value: String) : EnumString<Animal>(value) {
    override fun toEnumOrNull(): Animal? {
        return try {
            Animal.valueOf(value)
        } catch (e: IllegalArgumentException) {
            null
        }
    }

    companion object {
        // JsonCreator를 사용하여 String 값으로 AnimalString 인스턴스를 생성
        @JvmStatic
        @JsonCreator
        fun from(value: String) = AnimalString(value)
    }
}

// DTO에서 Enum 대신 EnumString 사용
data class SomeRequestDto(
    val animal: AnimalString,
)

// 비즈니스 로직에서의 활용
fun processRequest(dto: SomeRequestDto) {
    // 사용 예시 1: 알 수 없는 값이면 예외 발생 (엄격한 처리)
    val animalEnumStrict: Animal = dto.animal.toEnumOrThrow()
    
    // 사용 예시 2: 알 수 없는 값이면 기본값(DOG)으로 처리
    val animalEnumDefault: Animal = dto.animal.toEnumOrElse {
        log.warn("Unknown animal type: {}. Defaulting to DOG.", dto.animal.value)
        Animal.DOG
    }

    // 사용 예시 3: 알 수 없는 값이면 null로 처리하고 분기
    val animalEnumNullable: Animal? = dto.animal.toEnumOrNull()
    if (animalEnumNullable == null) {
        // 무시하거나 다른 로직 수행
    }
}

이처럼 EnumString을 도입함으로써, 우리는 알 수 없는 Enum 입력에 대해 "예외 발생", "기본값 할당", "Null 처리" 등 원하는 동작을 코드 레벨에서 명시적으로 정의하고, 역직렬화 과정에서 발생할 수 있는 오류를 안전하게 제어할 수 있게 되었습니다.


4. 해결책 2: EnumString 사용 전파하기 - ArchUnit으로 규칙 강제화

아무리 좋은 해결책을 만들어도, 개발자들이 이를 인지하고 꾸준히 사용하지 않으면 무용지물입니다. 특히 새로 합류한 구성원이 과거의 문제를 모른 채, DTO에서 습관적으로 일반 Enum 타입을 사용할 가능성을 배제할 수 없었습니다.

우리는 이러한 휴먼 에러를 방지하고 EnumString 사용을 시스템적으로 강제할 수 있는 방법을 찾았고, 그 해답은 ArchUnit이었습니다.

  • ArchUnit이란?
    • Java/Kotlin 코드의 아키텍처 규칙을 단위 테스트처럼 코드로 작성하고 검증할 수 있게 해주는 오픈소스 라이브러리입니다. (예: "Controller 레이어는 Service 레이어만 호출해야 한다", "domain 패키지는 외부 의존성을 가지면 안 된다" 등)
  • 우리의 적용 방식:
    1. 우리는 ArchUnit을 사용하여 "외부와의 통신에 사용되는 DTO 클래스들은 필드로 일반 Enum 타입을 직접 가질 수 없다"는 커스텀 규칙을 정의했습니다.
    2. 이 규칙을 위반하는 코드가 발견되면(예: data class Foo(val animal: Animal)), CI/CD 파이프라인의 빌드 단계에서 테스트가 실패하도록 설정했습니다.

이러한 시스템적 강제화 덕분에, 모든 개발자들이 자연스럽게 EnumString 패턴을 따르게 되었고, 코드 리뷰 과정에서 Enum 사용에 대한 불필요한 논쟁을 줄일 수 있었습니다.

🤔 꼬리 질문: ArchUnit과 같은 아키텍처 테스트 도구를 사용했을 때의 장점은 무엇일까요? 코드 리뷰만으로는 아키텍처 규칙을 유지하기 어려운 이유는 무엇이라고 생각하시나요?


5. 해결책 3: Enum 배포 의존성 끊기 - Meta Expose 시스템

EnumString 도입 이후, 버전과 무관하게 무시 가능한 Enum 값에 대해서는 문제가 거의 발생하지 않았습니다. 하지만 모든 Enum이 그렇게 유연할 수는 없었습니다. 어떤 Enum은 반드시 서비스 간에 버전이 동기화되어야만 비즈니스 로직의 의미가 유지되며, 그렇지 않으면 여전히 심각한 오류가 발생할 수 있습니다.

  • 소비자 입장에서도 "정의되지 않은 Enum 값을 받으면 도메인 로직이 깨지므로 반드시 오류를 발생시켜야 하는" 경우가 있습니다.
  • 제공자 입장에서도 Enum이 갱신되지 않은 소비자에게 메시지를 보내면, 해당 소비자의 처리 실패로 인해 전체 워크플로우에 문제가 생길 수 있습니다.

결국, 우리는 각 서비스가 사용하는 Enum의 버전 동기화 상태를 실시간으로 관찰(Observe)하고, 불일치를 자동으로 감지할 수 있는 시스템이 필요하다고 판단했습니다. 그래서 Meta-Expose라는 이름의 시스템을 직접 설계하고 도입했습니다.

Meta-Expose 시스템의 핵심 구성 및 동작 방식:

  1. Enum 메타데이터 자동 노출 (Auto-Expose):
    • Meta-Expose 라이브러리를 의존성에 추가한 서비스는, 별도의 코드 작업 없이도 자신이 정의하고 있는 모든 Enum의 종류와 값 목록을 외부 API 엔드포인트로 자동 노출합니다.
  2. 중앙 Hub에서의 정합성 검증:
    • 중앙 관리 시스템인 Meta-Expose-Hub는 서비스 디스커버리(Service Discovery)를 통해 Enum API를 노출하는 모든 서비스를 자동으로 식별합니다.
    • Hub는 주기적으로 각 서비스의 Enum API를 호출하여 현재 Enum 정의 값을 수집하고, 이를 사전에 정의된 기준(Source of Truth) Enum과 비교하여 버전 일치 여부를 검증합니다.
  3. 실시간 모니터링 및 알림:
    • 검증 결과(일치, 불일치, 누락 등)는 Prometheus에 메트릭으로 수집됩니다.
    • Grafana 대시보드를 통해 어떤 서비스의 어떤 Enum이 기준 버전과 다른지 실시간으로 시각화하여 보여줍니다.
    • 불일치가 감지되면 즉시 Slack 등으로 알림을 발송하여 담당 개발자가 신속하게 조치할 수 있도록 합니다.

이 시스템을 통해 우리는 다음과 같은 효과를 얻었고, 마침내 반복되던 Enum 역직렬화 에러의 고리를 끊어낼 수 있었습니다.

  • Enum 변경 시, 모든 관련 서비스가 실제로 버전업 되었는지 배포 직후 눈으로 직접 확인할 수 있게 되었습니다.
  • 신규 Enum 추가 후 배포가 누락된 서비스를 빠르게 감지하여, 운영 환경에서 발생할 수 있는 리스크를 사전에 제거할 수 있었습니다.
  • 신규 구성원이 Enum 정의 방식에 익숙하지 않더라도, 자동화된 검증과 시각화된 대시보드 덕분에 실수를 방지할 수 있었습니다.

마치며

"실수를 완전히 막을 수는 없지만, 그 실수가 문제로 확산되지 않도록 시스템 차원에서 방어하는 것은 개발자의 몫입니다."

저희는 MSA 환경에서 Enum으로 인해 반복되던 문제를 해결하기 위해, 예측 가능한 구조와 자동화된 감시 체계를 도입하여 오류를 사전에 차단하고자 했습니다. 이러한 노력 덕분에, 수백 번의 배포와 계속해서 확장되는 MSA 환경 속에서도 Enum으로 인한 장애 없이 안정적인 서비스를 운영하는 성과를 얻을 수 있었습니다.

서비스의 안정성은 성능만큼이나 중요하며, 특히 고객의 중요한 데이터를 다루는 시스템에서는 작은 버그 하나도 결코 가볍게 넘길 수 없습니다. 구조적으로 실수를 방지하고 예측 가능한 시스템을 설계하는 여정에 이 글이 작은 영감이 되었기를 바랍니다. 긴 글 읽어주셔서 감사합니다.

0개의 댓글