현재 프로젝트에서는 외부 API가 에러 응답을 줬을 때 처리하는 로직을 모듈화하기 위해 AOP를 사용하고 있습니다.
여기서 외부 API가 반환하는 에러코드를 우리 시스템 내부의 도메인별 Enum타입으로 변환하는 기능이 있는데 기존에는 어떤 에러 Enum이든 유연하게 처리하기 위해 리플렉션을 사용하여 static fromCode(String code) 메서드를 호출했습니다.
하지만 이러한 구현 방식은 다음과 같은 문제점이 있었습니다.
static 메서드의 구현은 강제할 수 없습니다. 따라서 BaseErrorCode 인터페이스가 있더라도, 개발자가 fromCode 구현을 누락하는 것을 컴파일 타임에 막을 수 없습니다."fromCode"라는 문자열에 의존하여 메서드를 호출합니다. Enum을 추가할 때마다 내부 캐싱(Map) 로직과 변환 로직을 반복적으로 작성해야 합니다.이에 리플렉션을 제거하고 타입 안정성을 확보하며 변환 로직을 중앙화하기 위한 리팩토링을 진행했습니다.
Enum의 표준 메서드인 getEnumConstants()와 제네릭()을 활용하여, 특정 구현체에 의존하지 않고 모든 에러 Enum을 처리할 수 있는 유틸리티를 만들었습니다.
장점:
BaseErrorCode 인터페이스를 통해 최소한의 규약을 강제할 수 있습니다.각 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);
변환 로직을 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만 변환 가능하다"는 타입 제약을 가집니다
fromCode 메서드는 반환 타입으로 Optional<T>를 사용합니다. 이는 "변환에 실패할 수 있음"을 명시적으로 알리는 것입니다.Optional.empty()를 반환.orElseThrow()를 통해 명시적으로 예외를 던지거나, .orElse(DEFAULT)로 기본값을 설정 가능변환 실패 시 기본값(Fallback) 처리 전략 현재는 BaseErrorCode 인터페이스에 default boolean isDefault() { return false; } 메서드를 두는 방식을 고려하고 있습니다. 특정 상수에서만 이를 true로 오버라이드하면 EnumMapper가 기본값을 찾을 수 있습니다.
하지만 이 방식은 개발자가 isDefault 오버라이드를 깜빡할 경우, 기본값이 없는 상태가 되어버린다는 단점이 있습니다. "반드시 하나의 기본값을 가져야 한다"는 제약을 컴파일 타임에 강제할 수 있는 더 좋은 패턴이 있을지 고민 중입니다.