최근 많은 서비스가 빠른 개발 속도와 유연한 확장을 위해 MSA(Microservice Architecture)를 채택하고 있습니다. 하지만 "은탄환은 없다"는 말처럼, MSA는 그 장점만큼이나 다양한 기술적 복잡성을 수반합니다. 그중에서도 많은 개발자들이 간과하기 쉽지만, 때로는 치명적인 장애를 유발하는 주제가 바로 Enum 관리입니다.
Java나 Kotlin을 사용하는 개발자라면 타입 안정성, 컴파일 타임 체크, IDE의 편리한 자동완성 등 다양한 장점 때문에 Enum을 애용합니다. 하지만 분산된 MSA 환경에서는 이 친숙한 도구가 예상치 못한 독이 될 수 있습니다.
이번 글에서는 MSA 환경에서 Enum이 왜 문제가 될 수 있는지, 그리고 저희는 이 문제를 시스템적으로 해결하기 위해 어떤 전략을 수립하고 구현했는지 그 경험을 공유하고자 합니다.
단일 애플리케이션(Monolithic Architecture) 환경에서 Enum은 매우 안전하고 효율적인 도구입니다. 하지만 여러 서비스가 독립적으로 배포되고 통신하는 MSA 환경에서는 이야기가 달라집니다.
가장 흔하게 발생하는 문제는, 여러 서비스 간에 공유되는 Enum에 새로운 값이 추가되었을 때 발생합니다.
OrderStatus
라는 Enum을 공유하고 있습니다. (PENDING
, PROCESSING
, SHIPPED
)RETURNED
를 OrderStatus
Enum에 추가하고 배포했습니다.RETURNED
상태 값을 서비스 B로 전달합니다. 하지만 서비스 B는 아직 RETURNED
라는 값을 모르는 이전 버전의 코드로 운영 중입니다."RETURNED"
라는 문자열을 OrderStatus
Enum 타입으로 변환(Deserialize)하려다 실패하고, InvalidFormatException
과 같은 예외를 발생시키며 요청 처리에 실패합니다.이러한 문제는 적게는 1년에 한두 번씩, 하지만 꾸준히 발생하며 서비스 안정성을 위협했습니다. 단순히 "배포 순서를 잘 지키자"는 다짐이나 "꼼꼼히 확인하자"는 휴먼 에러로 치부하기에는 너무 빈번하고 치명적이었습니다. 담당자가 바뀌거나, 새로운 서비스가 추가되거나, Enum 값이 변경되는 순간 문제는 어김없이 재발했습니다.
결국, 우리는 사람의 주의력이 아닌 시스템을 통해 근본적으로 이 문제를 예방하고 관리할 수 있는 방법이 필요하다는 결론에 도달했습니다.
MSA 환경에서 Enum으로 인한 문제를 더 복잡하게 만드는 요인 중 하나는, 통신의 주체와 방향에 따라 기대하는 동작이 다르다는 점입니다. 우리는 요청의 주체를 기준으로 Enum 사용을 '제공자(Provider)'와 '소비자(Consumer)'로 나누고, 각각의 유스케이스에 맞는 처리 전략을 수립했습니다.
1. 클라이언트(소비자) → 서버(제공자): 수신하는 입장은 엄격하게!
2. 서버(제공자) → 클라이언트(소비자): 처리는 소비자의 선택에 맡기자!
이러한 유스케이스 분석을 통해, 우리는 Enum 역직렬화 오류로부터 자유로워질 수 있는 아래 세 가지 구체적인 해결책을 도출했습니다.
EnumString
도입EnumString
사용 강제화하기: ArchUnit 활용EnumString
알지 못하는 Enum 값이 들어왔을 때, Jackson 라이브러리의 @JsonEnumDefaultValue
나 @JsonCreator
같은 어노테이션을 사용하여 기본값을 반환하도록 처리할 수 있습니다. 하지만 이 방법들은 개발자가 의도한 대로 동작을 세밀하게 제어하기 어렵고, 도메인 로직상 예기치 않은 버그를 유발할 수 있다는 한계가 있었습니다.
그래서 우리는 Enum 타입이 가진 장점(타입 안정성, 가독성 등)을 유지하면서도, 예상치 못한 값이 들어왔을 때 개발자가 직접 원하는 동작을 정의할 수 있는 유연한 접근법을 도입하기로 결정했습니다. 단순히 모든 Enum을 String
타입으로 처리하는 것은 타입 안정성을 해치고 사용성을 제한할 수 있으므로, 우리는 전용 래퍼 클래스인 EnumString
을 직접 제작했습니다.
EnumString
의 핵심 아이디어:
EnumString
타입으로 값을 받는다. EnumString
은 내부적으로 값을 String
으로 가지고 있으므로, 알 수 없는 값이 들어와도 역직렬화 에러가 발생하지 않는다.EnumString
을 실제 Enum 타입으로 변환하는 메서드를 호출한다.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 처리" 등 원하는 동작을 코드 레벨에서 명시적으로 정의하고, 역직렬화 과정에서 발생할 수 있는 오류를 안전하게 제어할 수 있게 되었습니다.
EnumString
사용 전파하기 - ArchUnit으로 규칙 강제화아무리 좋은 해결책을 만들어도, 개발자들이 이를 인지하고 꾸준히 사용하지 않으면 무용지물입니다. 특히 새로 합류한 구성원이 과거의 문제를 모른 채, DTO에서 습관적으로 일반 Enum 타입을 사용할 가능성을 배제할 수 없었습니다.
우리는 이러한 휴먼 에러를 방지하고 EnumString
사용을 시스템적으로 강제할 수 있는 방법을 찾았고, 그 해답은 ArchUnit이었습니다.
data class Foo(val animal: Animal)
), CI/CD 파이프라인의 빌드 단계에서 테스트가 실패하도록 설정했습니다.이러한 시스템적 강제화 덕분에, 모든 개발자들이 자연스럽게 EnumString
패턴을 따르게 되었고, 코드 리뷰 과정에서 Enum 사용에 대한 불필요한 논쟁을 줄일 수 있었습니다.
🤔 꼬리 질문: ArchUnit과 같은 아키텍처 테스트 도구를 사용했을 때의 장점은 무엇일까요? 코드 리뷰만으로는 아키텍처 규칙을 유지하기 어려운 이유는 무엇이라고 생각하시나요?
EnumString
도입 이후, 버전과 무관하게 무시 가능한 Enum 값에 대해서는 문제가 거의 발생하지 않았습니다. 하지만 모든 Enum이 그렇게 유연할 수는 없었습니다. 어떤 Enum은 반드시 서비스 간에 버전이 동기화되어야만 비즈니스 로직의 의미가 유지되며, 그렇지 않으면 여전히 심각한 오류가 발생할 수 있습니다.
결국, 우리는 각 서비스가 사용하는 Enum의 버전 동기화 상태를 실시간으로 관찰(Observe)하고, 불일치를 자동으로 감지할 수 있는 시스템이 필요하다고 판단했습니다. 그래서 Meta-Expose라는 이름의 시스템을 직접 설계하고 도입했습니다.
Meta-Expose 시스템의 핵심 구성 및 동작 방식:
이 시스템을 통해 우리는 다음과 같은 효과를 얻었고, 마침내 반복되던 Enum 역직렬화 에러의 고리를 끊어낼 수 있었습니다.
"실수를 완전히 막을 수는 없지만, 그 실수가 문제로 확산되지 않도록 시스템 차원에서 방어하는 것은 개발자의 몫입니다."
저희는 MSA 환경에서 Enum으로 인해 반복되던 문제를 해결하기 위해, 예측 가능한 구조와 자동화된 감시 체계를 도입하여 오류를 사전에 차단하고자 했습니다. 이러한 노력 덕분에, 수백 번의 배포와 계속해서 확장되는 MSA 환경 속에서도 Enum으로 인한 장애 없이 안정적인 서비스를 운영하는 성과를 얻을 수 있었습니다.
서비스의 안정성은 성능만큼이나 중요하며, 특히 고객의 중요한 데이터를 다루는 시스템에서는 작은 버그 하나도 결코 가볍게 넘길 수 없습니다. 구조적으로 실수를 방지하고 예측 가능한 시스템을 설계하는 여정에 이 글이 작은 영감이 되었기를 바랍니다. 긴 글 읽어주셔서 감사합니다.