반복되는 Enum 변환 로직, 제네릭 유틸리티로 중앙화하기

mseo39·2025년 11월 30일

TIL

목록 보기
16/19
post-thumbnail

왜 Enum 변환 로직을 중앙화했는가?

현재 프로젝트에서는 외부 API가 에러 응답을 줬을 때 처리하는 로직을 모듈화하기 위해 AOP를 사용하고 있습니다.
여기서 외부 API가 반환하는 에러코드를 우리 시스템 내부의 도메인별 Enum타입으로 변환하는 기능이 있는데 기존에는 어떤 에러 Enum이든 유연하게 처리하기 위해 리플렉션을 사용하여 static fromCode(String code) 메서드를 호출했습니다.

하지만 이러한 구현 방식은 다음과 같은 문제점이 있었습니다.

  • Java 인터페이스의 제약: Java의 인터페이스는 인스턴스 메서드의 구현은 강제할 수 있지만, static 메서드의 구현은 강제할 수 없습니다. 따라서 BaseErrorCode 인터페이스가 있더라도, 개발자가 fromCode 구현을 누락하는 것을 컴파일 타임에 막을 수 없습니다.
  • 리플렉션의 불안정성:
    • AOP Aspect는 "fromCode"라는 문자열에 의존하여 메서드를 호출합니다.
    • 오타가 발생하거나 메서드명이 변경될 경우 런타임에 NoSuchMethodException이 발생할 위험이 있습니다.
  • 중복 코드: 새로운 에러 Enum을 추가할 때마다 내부 캐싱(Map) 로직과 변환 로직을 반복적으로 작성해야 합니다.

이에 리플렉션을 제거하고 타입 안정성을 확보하며 변환 로직을 중앙화하기 위한 리팩토링을 진행했습니다.


해결책: 제네릭 유틸리티 클래스 EnumMapper

Enum의 표준 메서드인 getEnumConstants()와 제네릭()을 활용하여, 특정 구현체에 의존하지 않고 모든 에러 Enum을 처리할 수 있는 유틸리티를 만들었습니다.

장점:

  • 리플렉션 제거: 컴파일 타임에 타입 체크가 가능해집니다.
  • 응집도 향상: 흩어져 있던 변환 로직이 유틸리티 클래스 한 곳으로 모입니다.
  • 인터페이스 강제: BaseErrorCode 인터페이스를 통해 최소한의 규약을 강제할 수 있습니다.

구현 상세

Before : 분산된 로직과 리플렉션

각 Enum마다 변환 로직을 직접 구현해야 했고 Aspect는 리플렉션을 사용했습니다

AiErrorCode.java

public enum AiErrorCode implements BaseErrorCode {
    // ... 상수들 ...
    
    // [문제점] 모든 Enum마다 이 코드를 복사-붙여넣기 해야 함
    private static final Map<String, AiErrorCode> codeMap = ...;
    
    public static AiErrorCode fromCode(String code) { // 인터페이스로 강제 불가
        return codeMap.getOrDefault(code, UNEXPECTED);
    }
}

ExternalApiErrorHandlingAspect.java

// [문제점] "fromCode"라는 문자열 오타가 나면 런타임 에러 발생
Method method = enumClass.getDeclaredMethod("fromCode", String.class);
BaseErrorCode code = (BaseErrorCode) method.invoke(null, rawCode);

After : EnumMapper를 통한 중앙화

변환 로직을 EnumMapper로 이동하고 Enum은 순수한 데이터만 남깁니다. Aspect는 유틸리티를 호출하기만 하면 됩니다.
util/EnumMapper.java

public class EnumMapper {
    // 제네릭을 사용하여 모든 Enum 타입에 대해 재사용 가능
    public static <T extends Enum<T> & BaseErrorCode> Optional<T> fromCode(Class<T> enumClass, String code) {
        if (code == null || enumClass == null) return Optional.empty();

        return Arrays.stream(enumClass.getEnumConstants()) // 모든 상수 순회
                .filter(e -> e.getErrorReason().getCode().equals(code))
                .findFirst();
    }
}

AiErrorCode.java

@Getter
@AllArgsConstructor
public enum AiErrorCode implements BaseErrorCode {
    POLICY_VIOLATION(400, "POLICY_VIOLATION", "정책 위반"),
    UNEXPECTED(500, "UNEXPECTED", "서버 오류");

    private final ErrorReason errorReason;
    
    // [개선] static 로직이 모두 사라지고, 데이터 정의만 남음
    @Override
    public ErrorReason getErrorReason() { return errorReason; }
}

ExternalApiErrorHandlingAspect.java

// [개선] 리플렉션 없이, 타입 안전하게 호출
BaseErrorCode internalCode = EnumMapper.fromCode(enumClass, rawCode)
        .orElseGet(() -> EnumMapper.getFallback(enumClass));

코드 분석

제네릭 타입 매개변수 <T extends Enum<T> & BaseErrorCode>

이 문법은 두 가지 제약 조건을 가집니다

  • T extends Enum<T>: T는 반드시 Enum 타입이어야 함을 의미합니다. 이를 통해 enumClass.getEnumConstants() 메서드를 호출하여 해당 Enum의 모든 상수를 배열로 가져올 수 있게 됩니다.
  • & BaseErrorCode: T는 동시에 BaseErrorCode 인터페이스를 구현해야 함을 의미합니다. 이를 통해 e.getErrorReason() 메서드를 호출하여 에러 코드를 비교할 수 있게 됩니다.

결과적으로 "BaseErrorCode를 구현한 Enum만 변환 가능하다"는 타입 제약을 가집니다

Optional API 활용

  • fromCode 메서드는 반환 타입으로 Optional<T>를 사용합니다. 이는 "변환에 실패할 수 있음"을 명시적으로 알리는 것입니다.
  • 외부 API가 우리가 모르는 이상한 코드를 보낼 수 있습니다. 이때 시스템이 멈추지 않고
    • Optional.empty()를 반환
    • .orElseThrow()를 통해 명시적으로 예외를 던지거나, .orElse(DEFAULT)로 기본값을 설정 가능
    • NullPointerException을 컴파일 단계에서 방지 -> 값이 없을 경우 어떻게 할 것인지 강제로 처리하게 함

성능 이슈: .filter & getEnumConstants()

  • Enum.values()는 static 메서드라 제네릭 타입에서 직접 호출할 수 없습니다. 대신 Class 객체의 getEnumConstants()를 사용했습니다.
  • 주의점: getEnumConstants()는 호출될 때마다 내부의 Enum 상수 배열을 복제(clone)해서 반환합니다.
  • 만약 이 메서드가 초당 수만 번 호출되는 고트래픽 환경이라면, Map을 이용한 캐싱(Caching)을 적용하여 성능을 최적화해야 합니다. (현재 프로젝트 규모에서는 O(N) 순회 비용이 미미하다고 판단하여 가독성을 선택했습니다.)

더 생각해볼 것

변환 실패 시 기본값(Fallback) 처리 전략 현재는 BaseErrorCode 인터페이스에 default boolean isDefault() { return false; } 메서드를 두는 방식을 고려하고 있습니다. 특정 상수에서만 이를 true로 오버라이드하면 EnumMapper가 기본값을 찾을 수 있습니다.

하지만 이 방식은 개발자가 isDefault 오버라이드를 깜빡할 경우, 기본값이 없는 상태가 되어버린다는 단점이 있습니다. "반드시 하나의 기본값을 가져야 한다"는 제약을 컴파일 타임에 강제할 수 있는 더 좋은 패턴이 있을지 고민 중입니다.


profile
하루하루 성실하게

0개의 댓글