안녕하세요 개발자 stark입니다! 오늘은 Enum을 우아하게 사용하는 방법을 소개드리고자 합니다.
스프링 MVC에서 @RequestParam이나 @PathVariable로 enum을 받을 때는 스프링 내부에서 StringToEnumConverter가 동작하게 됩니다. 그리고 이 Converter는 문자열과 enum 상수가 정확히 일치해야만 매핑됩니다. 그러나 개발을 하다 보면 소문자를 허용해야 할 수도 있고, 잘못 들어온 값에 대해 일관된 에러 메시지를 내려줄 필요도 있습니다. 이를 해결하기 위해 아래와 같이 커스텀 컨버터 + 전역 예외 처리를 구성하면, 원하는 대로 Enum을 바인딩하고 에러 응답을 깔끔하게 제어할 수 있습니다.
저도 이것을 잘 몰랐는데 제가 enum을 대문자로만 받도록 로직을 구성했다가 관련 오류가 발생했고 이것을 확인해 주신 제 사수님께서 enum을 안전하게 사용하는 멋진 방법을 알려주셨습니다. 지금부터 컨버터를 사용해서 enum을 안전하게 사용해 봅시다.
시작하며: 스프링 MVC의 Enum 처리 방식 이해하기
스프링 MVC에서 @RequestParam이나 @PathVariable로 Enum 타입을 받으면, 내부적으로 StringToEnumConverterFactory라는 기본 컨버터가 동작합니다. 이 컨버터는 문자열을 해당 Enum 타입으로 변환하는 역할을 수행하는데요, 한 가지 특징이 있습니다. 바로 문자열이 Enum에 정의된 상수 이름과 정확히 일치해야 한다는 점입니다.
코드로 살펴보자면 StringToEnumConverterFactory는 이렇게 작성되어 있습니다.
@SuppressWarnings({"rawtypes", "unchecked"})
final class StringToEnumConverterFactory implements ConverterFactory<String, Enum> {
@Override
public <T extends Enum> Converter<String, T> getConverter(Class<T> targetType) {
return new StringToEnum(ConversionUtils.getEnumType(targetType));
}
private static class StringToEnum<T extends Enum> implements Converter<String, T> {
private final Class<T> enumType;
StringToEnum(Class<T> enumType) {
this.enumType = enumType;
}
@Override
@Nullable
public T convert(String source) {
if (source.isEmpty()) {
// It's an empty enum identifier: reset the enum value to null.
return null;
}
return (T) Enum.valueOf(this.enumType, source.trim());
}
}
}
위 코드를 좀 더 자세히 살펴보면 왜 Enum정의 상수와 일치해야만 하는지 바로 이유를 알 수 있습니다.
요청이 들어오면 StringToEnumConverterFactory의 Enum.valueOf(this.enumType, source.trim()) 메서드를 호출하는데, 이 Enum.valueOf(...)는 자바 표준 라이브러리의 메서드로, 대소문자를 포함해 Enum 상수 이름과 정확히 일치해야만 해당 상수를 반환합니다. 만약 조금이라도 다르면 바로 IllegalArgumentException을 던지는 구조입니다. 한번 코드를 살펴봅시다.
public abstract class Enum<E extends Enum<E>>
implements Constable, Comparable<E>, Serializable {
// 이 메서드입니다.
public static <T extends Enum<T>> T valueOf(Class<T> enumClass,
String name) {
T result = enumClass.enumConstantDirectory().get(name);
if (result != null)
return result;
if (name == null)
throw new NullPointerException("Name is null");
throw new IllegalArgumentException(
"No enum constant " + enumClass.getCanonicalName() + "." + name);
}
}
이 코드를 보면 valueOf() 메서드 내부의 첫 번째 라인인 enumClass.enumConstantDirectory().get(name)에서 Enum과 정확히 일치하는 문자열(name)을 찾습니다. 이때 만약 대소문자가 다르거나, Enum에 정의되지 않은 값이면 result는 null이 반환됩니다. 만약 name값이 null이면 NullPointerException을, 그렇지 않으면 IllegalArgumentException을 던집니다. 결국, ENUM에 선언된 것과 정확히 똑같은 문자열 키를 찾지 못하면 실패한다는 것이 핵심입니다.
한 줄씩 정리해 봅시다.
- 스프링의 기본 Converter가 → Enum.valueOf()를 호출
- Enum.valueOf()가 → enumConstantDirectory().get(name)로 정확한 이름 매칭 시도
- 매칭 실패 시 → IllegalArgumentException 발생
- 스프링 MVC에서 변환 예외를 잡아 → 400 Bad Request 응답.
결과적으로, 문자열이 Enum 상수와 정확히 일치하지 않으면 변환 실패라는 구조가 이어집니다. 이 과정 전부가 한 맥락으로 연쇄 동작한다고 보시면 됩니다.
좀 더 직관적으로 이해하기 위해 다음과 같은 주문 상태 Enum이 있다고 해보겠습니다.
public enum OrderStatus {
CREATED,
PAID,
SHIPPED
}
이때 API 요청으로 다음과 같은 값들이 들어온다면 어떻게 될까요?
- ✅ "CREATED" (대문자) → 정상 처리
- ❌ "created" (소문자) → 400 Bad Request
- ❌ "Created" (섞임) → 400 Bad Request
- ❌ "FINISHED" (전혀 다른 거) → 400 Bad Request
이와 같이 기본 컨버터는 대소문자를 엄격하게 구분하며, Enum에 정의되지 않은 값이 들어오면 변환에 실패합니다.
만약 변환에 실패하면 스프링은 다음과 같은 형태의 에러 메시지를 반환합니다.
Failed to convert value of type 'java.lang.String' to required type 'com.example.OrderStatus'
근데 이러한 에러 메시지는 다음과 같은 문제가 있습니다.
- 실제로 어떤 값이 잘못되었는지 알기 어렵습니다.
- 어떤 값을 사용해야 하는지 가이드가 없습니다.
- 클라이언트 친화적이지 않습니다. (백엔드 개발자는 이해하기 쉽지만 전혀 다른 프론트에서는 이해하기 어렵습니다.)
Enum의 동작방식이 간단히 이해되었다면 이제부터 커스텀 Converter를 구현하고 전용 에러 메시지까지 적용해 봅시다.
1. Enum 및 예외 클래스 구성하기
Enum 선언: OrderStatus
package com.example.test.controller;
import lombok.Getter;
@Getter
public enum OrderStatus {
CREATED,
PAID,
SHIPPED,
DELIVERED;
// 대소문자 구분 없이 변환 가능하도록 구현
public static OrderStatus from(String value) {
try {
return OrderStatus.valueOf(value.toUpperCase());
} catch (IllegalArgumentException e) {
// 잘못된 Enum 값 -> 커스텀 예외 던짐
throw new InvalidEnumValueException("주문 상태", value, OrderStatus.class);
}
}
}
Enum의 valueOf 메서드를 사용 시 정확히 일치하는 문자열이 없으면 IllegalArgumentException을 던집니다. 이를 from 메서드에서 캐치하여 좀 더 구체적인 커스텀 예외로 변환합니다.
Enum 전용 예외클래스 선언하기: InvalidEnumValueException
package com.example.test.controller;
import java.util.Arrays;
import java.util.stream.Collectors;
public class InvalidEnumValueException extends RuntimeException {
public InvalidEnumValueException(String fieldName,
String invalidValue,
Class<? extends Enum<?>> enumClass) {
super(String.format("잘못된 %s 값입니다: '%s'. 가능한 값: %s",
fieldName,
invalidValue,
Arrays.stream(enumClass.getEnumConstants())
.map(Enum::name)
.collect(Collectors.joining(", "))
));
}
}
잘못된 값이 어떤 것이었는지, 그리고 가능한 Enum 상수 목록이 무엇인지를 메시지에 포함시킵니다. 이렇게 전용 에러를 구성해 두면 협업하는 front, client에서 에러 내용을 파악할 때 큰 도움이 됩니다.
2. 전역 예외 처리 구성하기
전역 예외처리 클래스 구성: GlobalExceptionHandler
package com.example.test.controller;
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.ResponseStatus;
import org.springframework.web.bind.annotation.RestControllerAdvice;
@RestControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(InvalidEnumValueException.class)
@ResponseStatus(HttpStatus.BAD_REQUEST)
public ErrorResponse handleInvalidEnumValueException(InvalidEnumValueException e) {
return new ErrorResponse("INVALID_ENUM_VALUE", e.getMessage());
}
}
클래스에 @RestControllerAdvice 어노테이션을 사용하여 전역 에러 처리를 구성합니다. 만약 InvalidEnumValueException 발생 시 400 Bad Request로 응답하고 에러 코드는 "INVALID_ENUM_VALUE"로 통일하여 클라이언트가 에러 유형을 식별하기 쉽도록 하였습니다.
발생한 에러 정보를 담아서 반환할 객체: ErrorResponse
package com.example.test.controller;
import lombok.AllArgsConstructor;
import lombok.Getter;
@AllArgsConstructor
@Getter
public class ErrorResponse {
private String code; // 에러 코드
private String message; // 에러 메시지
}
최종적으로 반환될 에러 응답 포맷입니다. 필요에 따라 timestamp, path 등을 추가로 담을 수도 있습니다.
3. 커스텀 컨버터 및 스프링 설정하기
커스텀 컨버터 구성하기: StringToOrderStatusConverter
package com.example.test.controller;
import org.springframework.core.convert.converter.Converter;
import org.springframework.stereotype.Component;
@Component
public class StringToOrderStatusConverter implements Converter<String, OrderStatus> {
@Override
public OrderStatus convert(String source) {
// Enum의 from 메서드를 활용해 대소문자 구분 없이 변환
return OrderStatus.from(source);
}
}
문자열을 OrderStatus로 변환하는 커스텀 컨버터를 선언합니다. source.toUpperCase()를 컨버터 내부에서 직접 할 수도 있지만, Enum 쪽에 from 메서드를 둬 재사용성을 높였습니다. 또한 Lombok의 Setter 대신 내부 로직(from)으로만 상태를 매핑하도록 구현했습니다.
여기서 잠깐! 커스텀 Converter는 왜 사용할까요?
- 확장된 매핑 로직
- Enum의 값을 문자열로 매핑할 때, 단순히 enum name과 같아야 한다는 제약을 벗어나, 소문자/대문자 무시, 공백 제거, 특정한 코드 값 매핑 등 맞춤형 로직을 적용하고 싶을 때 유용합니다.
- Enum의 값을 문자열로 매핑할 때, 단순히 enum name과 같아야 한다는 제약을 벗어나, 소문자/대문자 무시, 공백 제거, 특정한 코드 값 매핑 등 맞춤형 로직을 적용하고 싶을 때 유용합니다.
- 예외 처리 일원화
- 잘못된 값이 들어오면 직접 작성한 메시지나 HTTP 상태 코드로 통일된 응답을 내려줄 수 있습니다. 예를 들어 IllegalArgumentException을 잡아서 ResponseStatusException으로 포장해 400 에러를 일관성 있게 반환한다든지 말이죠.
- 잘못된 값이 들어오면 직접 작성한 메시지나 HTTP 상태 코드로 통일된 응답을 내려줄 수 있습니다. 예를 들어 IllegalArgumentException을 잡아서 ResponseStatusException으로 포장해 400 에러를 일관성 있게 반환한다든지 말이죠.
- 가독성 & 유지보수
- Enum 내부 로직(fromCode 등)과 스프링 Web MVC의 변환 로직을 구분하면, 나중에 Enum을 수정하거나 에러 정책을 바꾸더라도 한 곳에서만 코드를 변경하면 되므로 유지보수가 편리합니다.
- Enum 내부 로직(fromCode 등)과 스프링 Web MVC의 변환 로직을 구분하면, 나중에 Enum을 수정하거나 에러 정책을 바꾸더라도 한 곳에서만 코드를 변경하면 되므로 유지보수가 편리합니다.
컨버터는 클래스를 선언한 다음 빈(@Component) 등록만 하면 사용 가능한가요?
- 스프링이 Bean으로 등록된 컨버터를 "무조건" HTTP 요청 변환 로직에 사용하는 것은 아닙니다. 스프링은 "ConversionService"라는 내부 변환 서비스를 사용해 HTTP 파라미터, JSON 바인딩 등을 처리합니다. 기본적으로는 Spring Boot가 자체적으로 만든 ConversionService(FormattingConversionService)를 사용하며, 커스텀 Converter는 이 ConversionService에 직접 등록해야 실제 HTTP 요청 변환 시점에 적용됩니다. 따라서 Converter 클래스를 @Component로만 등록해 두면 그냥 Bean 팩토리에만 들어갈 뿐, 스프링 MVC가 사용하는 ConversionService와 연결되지 않을 수 있습니다.
설정 클래스를 작성합시다: WebConfig
package com.example.test.controller;
import lombok.RequiredArgsConstructor;
import org.springframework.context.annotation.Configuration;
import org.springframework.format.FormatterRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
@RequiredArgsConstructor
@Configuration
public class WebConfig implements WebMvcConfigurer {
private final StringToOrderStatusConverter orderStatusConverter;
@Override
public void addFormatters(FormatterRegistry registry) {
// 커스텀 컨버터 등록
registry.addConverter(orderStatusConverter);
}
}
@Configuration + WebMvcConfigurer 조합으로 ConversionService에 커스텀 컨버터를 추가합니다. 이렇게 해야 @RequestParam, @PathVariable 등 HTTP 요청 바인딩 시점에 커스텀 컨버터가 동작합니다.
주의사항! WebMvcConfigurer에 등록을 해야만 컨버터가 제대로 동작합니다.
- WebMvcConfigurer의 addFormatters(FormatterRegistry registry) 메서드는 스프링 MVC에서 사용하는 ConversionService에 커스텀 Converter를 추가해 주는 역할을 합니다. 만약 registry.addConverter(커스텀_컨버터)를 호출하면, 스프링 MVC 레벨에서 HTTP 파라미터 → Enum 변환 시점에 우리가 구현한 컨버터가 우선적으로 동작하게 됩니다. 즉, Bean 등록만으로는 부족하고, ConversionService에 등록까지 되어야 실제 HTTP 요청 바인딩에서 커스텀 로직이 적용됩니다.
- 이렇게 등록 과정을 명시적으로 제어하는 이유는, 스프링이 기본적으로 제공하는 여러 컨버터들과 충돌하지 않도록 우선순위를 지정하거나, 특정 컨버터만 등록/해제하는 등 유연한 조정이 필요하기 때문입니다.
4. 컨트롤러 작성하기
OrderController
package com.example.test.controller;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
@RequestMapping("/orders")
@RestController
public class OrderController {
// PathVariable Enum 바인딩
@GetMapping("/status/{status}")
public ResponseEntity<String> getOrdersByStatus(@PathVariable OrderStatus status) {
return ResponseEntity.ok("주문 상태: " + status);
}
// RequestParam Enum 바인딩
@GetMapping("/search")
public ResponseEntity<String> searchOrders(@RequestParam OrderStatus status) {
return ResponseEntity.ok("검색된 주문 상태: " + status);
}
}
@PathVariable, @RequestParam 모두 OrderStatus 타입을 직접 받습니다. 만약 소문자인 "created"가 들어와도, 컨버터가 OrderStatus.CREATED로 변환합니다. 또한 잘못된 값 "unknown" 같은 게 들어오면 InvalidEnumValueException를 발생시키고 직접 선언해 둔 GlobalExceptionHandler에서 처리합니다.
5. 테스트코드 작성하기 (검증)
통합테스트 작성: OrderControllerTest
package com.example.test;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.web.servlet.MockMvc;
import static org.hamcrest.Matchers.containsString;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
@DisplayName("주문 상태 Enum 파라미터 바인딩 테스트")
@AutoConfigureMockMvc
@SpringBootTest
class OrderControllerTest {
@Autowired
private MockMvc mockMvc;
@DisplayName("PathVariable - 대문자 요청시 정상 처리된다")
@Test
void testPathVariableEnum_UpperCase_Success() throws Exception {
mockMvc.perform(get("/orders/status/CREATED"))
.andExpect(status().isOk())
.andExpect(content().string("주문 상태: CREATED"));
}
@DisplayName("PathVariable - 소문자 요청시에도 정상 처리된다")
@Test
void testPathVariableEnum_LowerCase_Success() throws Exception {
mockMvc.perform(get("/orders/status/created"))
.andExpect(status().isOk())
.andExpect(content().string("주문 상태: CREATED"));
}
@DisplayName("RequestParam - 대문자 요청시 정상 처리된다")
@Test
void testRequestParamEnum_UpperCase_Success() throws Exception {
mockMvc.perform(get("/orders/search?status=CREATED"))
.andExpect(status().isOk())
.andExpect(content().string("검색된 주문 상태: CREATED"));
}
@DisplayName("RequestParam - 소문자 요청시에도 정상 처리된다")
@Test
void testRequestParamEnum_LowerCase_Success() throws Exception {
mockMvc.perform(get("/orders/search?status=created"))
.andExpect(status().isOk())
.andExpect(content().string("검색된 주문 상태: CREATED"));
}
@DisplayName("잘못된 주문 상태 값이 들어오면 INVALID_ENUM_VALUE 에러를 반환한다")
@Test
void testInvalidEnumValue() throws Exception {
mockMvc.perform(get("/orders/status/invalid"))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.code").value("INVALID_ENUM_VALUE"))
.andExpect(jsonPath("$.message").value(
containsString("잘못된 주문 상태 값입니다: 'invalid'. 가능한 값: CREATED, PAID, SHIPPED, DELIVERED")
));
}
}
실제로 호출해 보면 잘 동작하는 것을 확인할 수 있습니다.
예외 발생 시 응답 예시
{
"code": "INVALID_ENUM_VALUE",
"message": "잘못된 주문 상태 값입니다: 'invalid'. 가능한 값: CREATED, PAID, SHIPPED, DELIVERED"
}
마무리하며
정리해 보자면 위의 코드에서처럼 Enum 내부 로직(from) + 커스텀 Converter + 전역 예외 처리를 사용하면, 대소문자 구분 없이 안전하게 Enum 파라미터를 받을 수 있고, 잘못된 값이 들어왔을 때는 INVALID_ENUM_VALUE 에러 코드와 함께, 가능한 Enum 상수 목록까지 전달하여 보다 친절한 에러 응답을 제공할 수 있습니다.
Enum을 이렇게 사용함으로써 좀 더 안전하고 명확하게 프로젝트를 구성해 나갈 수 있다는 것을 배웠습니다. Enum의 컨버터는 이것뿐만 아니라 JPA에서 지원하는 AttributeConverter도 있습니다. 다음 포스팅에서는 AttributeConverter를 사용해서 Enum뿐만 아니라 다양한 커스텀 타입을 DB에 매핑하거나 커스텀하게 직렬화하는 용도로 활용하는 방법을 소개드리도록 하겠습니다.
긴 글 읽어주셔서 감사드리며 이 글이 개발에 많은 도움이 되었으면 좋겠습니다 :)
'Spring > Spring에서 Java 활용하기' 카테고리의 다른 글
[Spring] 직접 개발한 라이브러리 Fortune Cookie : API 응답에 재미 더하기 (1) | 2024.12.21 |
---|---|
[Spring] 의존성과 결합도 제대로 알기 (2) | 2024.11.15 |
[Java] Enum NPE 문제 빠르게 해결하기 (feat. equals, switch, AttributeConverter) (0) | 2024.11.03 |
[Java] 메서드 추출(Extract Method)로 복잡한 비즈니스 로직 개선하기 (0) | 2024.11.02 |
[Spring] synchronized를 사용한 동시성 문제 해결방법 (1) | 2024.06.07 |