Notice
Recent Posts
Recent Comments
Link
«   2026/01   »
1 2 3
4 5 6 7 8 9 10
11 12 13 14 15 16 17
18 19 20 21 22 23 24
25 26 27 28 29 30 31
Tags
more
Archives
Today
Total
관리 메뉴

che01 님의 블로그

TypeConverter — HTTP 요청 문자열을 객체로 편리하게 변환하기 본문

Spring

TypeConverter — HTTP 요청 문자열을 객체로 편리하게 변환하기

che01 2025. 6. 5. 19:14

웹 개발하다 보면 항상 맞닥뜨리는 귀찮은 일이 하나 있다. HTTP로 넘어오는 모든 데이터는 문자열인데, 실제로는 숫자나 날짜, 객체로 써야 한다는 것. 매번 Integer.valueOf() 이런 식으로 변환하기엔 너무 번거롭다.

문제 상황

// HTTP 요청: GET /users?age=25&active=true
// 실제로 받는 건 이거
String age = request.getParameter("age");        // "25"
String active = request.getParameter("active");  // "true"

// 근데 쓸 때는 이래야 함
int userAge = Integer.valueOf(age);
boolean isActive = Boolean.valueOf(active);

Spring이 알아서 해주는 자동 변환

Spring은 @RequestParam, @PathVariable 같은 어노테이션 쓸 때 자동으로 타입 변환해준다.

@RestController
public class UserController {
    
    @GetMapping("/users")
    public String getUsers(@RequestParam Integer age,           // "25" → 25
                          @RequestParam Boolean active,        // "true" → true
                          @RequestParam LocalDate birthDate) { // "2023-01-15" → LocalDate
        return String.format("나이: %d, 활성: %s, 생일: %s", age, active, birthDate);
    }
    
    @GetMapping("/users/{userId}")
    public String getUser(@PathVariable Long userId) { // "123" → 123L
        return "유저 ID: " + userId;
    }
    
    // 리스트도 됨
    @GetMapping("/users/bulk")
    public String getUsers(@RequestParam List<Long> userIds) { // "1,2,3" → [1L, 2L, 3L]
        return "유저 IDs: " + userIds;
    }
}

Enum도 된다:

public enum UserStatus {
    ACTIVE, INACTIVE, PENDING
}

@GetMapping("/users/status")
public String getUsersByStatus(@RequestParam UserStatus status) {
    // /users/status?status=ACTIVE → UserStatus.ACTIVE로 변환됨
    return "상태: " + status;
}

커스텀 변환기 만들기

기본 제공되는 게 부족하면 직접 만들면 된다.

돈(Money) 객체 변환

@Getter
@AllArgsConstructor
public class Money {
    private final BigDecimal amount;
    private final String currency;
    
    @Override
    public String toString() {
        return amount + " " + currency;
    }
}

@Component
public class StringToMoneyConverter implements Converter<String, Money> {
    
    @Override
    public Money convert(String source) {
        if (source == null || source.trim().isEmpty()) {
            return null;
        }
        
        String[] parts = source.trim().split("\\s+");
        if (parts.length != 2) {
            throw new IllegalArgumentException("돈 형식이 잘못됨: " + source);
        }
        
        BigDecimal amount = new BigDecimal(parts[0]);
        String currency = parts[1];
        
        return new Money(amount, currency);
    }
}

이제 컨트롤러에서 이렇게 쓸 수 있다:

@PostMapping("/payments")
public String processPayment(@RequestParam Money amount) {
    // POST /payments?amount=100.50 USD
    // "100.50 USD" 문자열이 Money 객체로 자동 변환
    return "결제 처리: " + amount;
}

날짜 변환 (여러 형식 지원)

@Component
public class StringToLocalDateTimeConverter implements Converter<String, LocalDateTime> {
    
    private static final List<DateTimeFormatter> FORMATTERS = Arrays.asList(
        DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"),
        DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm"),
        DateTimeFormatter.ofPattern("yyyy-MM-dd"),
        DateTimeFormatter.ISO_LOCAL_DATE_TIME
    );
    
    @Override
    public LocalDateTime convert(String source) {
        if (source == null || source.trim().isEmpty()) {
            return null;
        }
        
        source = source.trim();
        
        for (DateTimeFormatter formatter : FORMATTERS) {
            try {
                if (formatter == DateTimeFormatter.ofPattern("yyyy-MM-dd")) {
                    return LocalDate.parse(source, formatter).atStartOfDay();
                } else {
                    return LocalDateTime.parse(source, formatter);
                }
            } catch (DateTimeParseException ignored) {
                // 다음 형식 시도
            }
        }
        
        throw new IllegalArgumentException("날짜 파싱 실패: " + source);
    }
}

사용:

@GetMapping("/events")
public String getEvents(@RequestParam LocalDateTime startDate,
                       @RequestParam LocalDateTime endDate) {
    // 이런 형식들 다 됨:
    // ?startDate=2023-12-25
    // ?startDate=2023-12-25 14:30
    // ?startDate=2023-12-25 14:30:45
    
    return String.format("%s부터 %s까지 이벤트", startDate, endDate);
}

고급 변환기들

ConverterFactory - 비슷한 타입들 일괄 처리

@Component
public class StringToEnumConverterFactory implements ConverterFactory<String, Enum> {
    
    @Override
    public <T extends Enum> Converter<String, T> getConverter(Class<T> targetType) {
        return new StringToEnum<>(targetType);
    }
    
    private static class StringToEnum<T extends Enum> implements Converter<String, T> {
        private final Class<T> enumType;
        
        public StringToEnum(Class<T> enumType) {
            this.enumType = enumType;
        }
        
        @Override
        public T convert(String source) {
            if (source == null || source.trim().isEmpty()) {
                return null;
            }
            
            // 대소문자 구분 안함
            return (T) Enum.valueOf(enumType, source.trim().toUpperCase());
        }
    }
}

GenericConverter - 복잡한 변환

@Component
public class CollectionToStringConverter implements GenericConverter {
    
    @Override
    public Set<ConvertiblePair> getConvertibleTypes() {
        return Collections.singleton(new ConvertiblePair(Collection.class, String.class));
    }
    
    @Override
    public Object convert(Object source, TypeDescriptor sourceType, TypeDescriptor targetType) {
        if (source == null) return null;
        
        Collection<?> collection = (Collection<?>) source;
        return collection.stream()
                .map(String::valueOf)
                .collect(Collectors.joining(", "));
    }
}

변환기 등록하기

설정에서 등록

@Configuration
public class WebConfig implements WebMvcConfigurer {
    
    @Override
    public void addFormatters(FormatterRegistry registry) {
        registry.addConverter(new StringToMoneyConverter());
        registry.addConverter(new StringToLocalDateTimeConverter());
        registry.addConverterFactory(new StringToEnumConverterFactory());
    }
}

@Component로 자동 등록

@Component // 이거 붙이면 자동으로 등록됨
public class StringToMoneyConverter implements Converter<String, Money> {
    // ...
}

실무에서 주의할 점

에러 처리 제대로 하기

@Component
public class SafeStringToIntegerConverter implements Converter<String, Integer> {
    
    @Override
    public Integer convert(String source) {
        if (source == null || source.trim().isEmpty()) {
            return null;
        }
        
        try {
            return Integer.valueOf(source.trim());
        } catch (NumberFormatException e) {
            throw new ConversionFailedException(
                TypeDescriptor.valueOf(String.class),
                TypeDescriptor.valueOf(Integer.class),
                source, e);
        }
    }
}

성능 생각하기 (캐시 활용)

@Component
public class CachedStringToUserConverter implements Converter<String, User> {
    
    private final UserService userService;
    private final Cache<String, User> userCache;
    
    public CachedStringToUserConverter(UserService userService) {
        this.userService = userService;
        this.userCache = Caffeine.newBuilder()
            .maximumSize(1000)
            .expireAfterWrite(10, TimeUnit.MINUTES)
            .build();
    }
    
    @Override
    public User convert(String userId) {
        return userCache.get(userId, key -> userService.findById(Long.valueOf(key)));
    }
}

테스트 작성

@ExtendWith(MockitoExtension.class)
class StringToMoneyConverterTest {
    
    private StringToMoneyConverter converter;
    
    @BeforeEach
    void setUp() {
        converter = new StringToMoneyConverter();
    }
    
    @Test
    void 정상_변환_테스트() {
        // given
        String input = "100.50 USD";
        
        // when
        Money result = converter.convert(input);
        
        // then
        assertThat(result.getAmount()).isEqualByComparingTo("100.50");
        assertThat(result.getCurrency()).isEqualTo("USD");
    }
    
    @Test
    void 잘못된_형식_예외_테스트() {
        // given
        String input = "잘못된 돈";
        
        // when & then
        assertThatThrownBy(() -> converter.convert(input))
            .isInstanceOf(IllegalArgumentException.class);
    }
}

정리

Spring 타입 변환은 웹 개발할 때 정말 유용하다. 기본 제공되는 것들로 웬만한 건 다 해결되고, 특별한 요구사항 있으면 커스텀 변환기 만들어서 쓰면 된다.

핵심:

  • @RequestParam, @PathVariable에서 자동 변환 활용
  • 복잡한 객체는 커스텀 Converter 만들기
  • 에러 처리랑 성능 고려하기
  • 테스트 꼭 작성하기

다음에 "문자열을 객체로 어떻게 바꾸지?" 고민되면 Spring 타입 변환 써보자.