타입 변환과 포매팅
게시판의 PostId·본문 길이·날짜를 ConversionService와 로케일을 고려하는 Formatter로 변환하고 실패 의미, 등록 범위와 왕복을 검증합니다.
HTTP의 경로와 쿼리, 폼은 문자열로 도착하지만 컨트롤러와 도메인은 long, LocalDate, PostId 같은 의미 있는 타입을 원합니다. 컨트롤러마다 parseLong과 날짜 형식을 되풀이하면 오류 처리와 로케일 정책이 흩어집니다. Spring의 변환 시스템은 타입 사이의 변환을, Formatter는 사람이 읽고 쓰는 로케일에 맞는 텍스트 표현을 한곳에 모읍니다.
CONTRACT / IMPLEMENTATION / REVIEW SCOPE
문자열 변환과 저장소 조회의 책임을 분리한다
Converter는 P- 접두사와 양수 값을 해석합니다. Formatter는 같은 로케일에서 값과 문자열을 왕복합니다. 이 문서의 실제 검사는 변환 서비스와 값 객체의 단위 테스트입니다.
VALUE / CONVERTER
P- 접두사와 양수 값을 확정
PostId.parse는 바깥 공백을 제거하고 P- 뒤를 Long.parseLong으로 읽습니다. 값은 0보다 커야 합니다. Converter는 DB를 조회하지 않습니다. 이 코드는 숫자 접미부를 별도의 정규식으로 제한하지 않습니다.
FORMATTER / ROUND TRIP
120 → "120" → 120
실제 단위 테스트는 한국어 로케일에서 CharacterCount(120)의 print와 parse가 같은 값으로 돌아오는지 확인합니다. 출력 로케일과 입력 로케일을 함께 지정합니다.
FULL INPUT / RANGE
"120 chars"는 일부만 읽혀 거부
파서가 문자열 끝까지 소비했는지 확인하고 int 범위와 값 객체의 1..720 불변식을 검사합니다. 전체 소비 확인만으로 모든 잘못된 숫자 구분자 문법을 엄격하게 거부한다고 보장하지 않습니다.
ACTUAL REGISTRY UNIT
등록한 변환 서비스에서 함께 검증
직접 만든 DefaultFormattingConversionService에 MVC 설정의 Converter와 Formatter를 등록합니다. " P-1042 " → PostId와 "120" ↔ CharacterCount를 검사합니다. 실제 HTTP 요청·DB 조회 테스트는 없습니다.
HTTP POLICY / ADDITIONAL
변환 실패 400과 조회 실패 404
문법·값 변환 실패는 요청 바인딩 경계에서 처리하고 정상 ID의 존재 여부는 저장소가 판정하는 설계입니다. 이 문서의 세 단위 테스트가 컨트롤러의 400·404를 실행해 검증한 것은 아닙니다.
EXERCISE / ADDITIONAL
ISO 주차 변환은 별도 연습
PublishedWeek helper는 ISO 주차 연도와 월요일, 엄격한 날짜 해석 및 다시 출력한 표준 문자열을 확인합니다. 이 helper의 MVC 등록과 경계 테스트는 추가 구현 범위입니다.
문자열 P-1042를 PostId 1042로 변환하고 한국어 로케일의 CharacterCount 120을 문자열 120으로 출력한 뒤 다시 읽는다. 저장소 조회와 HTTP 400·404 응답은 이 단위 테스트와 별도의 통합 범위다.
그림의 왼쪽 세 칸은 " P-1042 "의 공백을 지우고 P- 접두사를 해석해 PostId(1042)를 만드는 한 방향의 변환이며, DB는 조회하지 않습니다. 오른쪽 세 칸은 CharacterCount(120)을 한국어 로케일로 "120"이라고 출력한 뒤, 같은 로케일로 다시 읽어 원래 값으로 되돌리는 별도의 왕복입니다. 저장소 조회와 HTTP의 400·404 응답, ISO 주차의 등록은 이 단위 테스트와 다른 범위로, 아래 칸과 본문에서 따로 다룹니다.
Converter로 타입을 바꿉니다
경로 변수 P-1042를 도메인 값 객체로 받고 싶다면, 문자열 형식을 컨버터 하나에 둡니다. 변환 실패는 IllegalArgumentException처럼 원인이 분명한 예외로 나타내, MVC가 타입 불일치 400으로 처리하게 합니다.
package board.domain;
public record PostId(long value) {
public PostId {
if (value <= 0) {
throw new IllegalArgumentException(
"post id must be positive");
}
}
public static PostId parse(String text) {
String candidate = text == null ? "" : text.strip();
if (!candidate.startsWith("P-")) {
throw new IllegalArgumentException(
"post id must start with P-");
}
try {
return new PostId(Long.parseLong(candidate.substring(2)));
} catch (NumberFormatException exception) {
throw new IllegalArgumentException(
"post id suffix must be numeric", exception);
}
}
@Override
public String toString() {
return "P-" + value;
}
}package board.web;
import board.domain.PostId;
import org.springframework.core.convert.converter.Converter;
public final class StringToPostIdConverter
implements Converter<String, PostId> {
@Override
public PostId convert(String source) {
return PostId.parse(source);
}
}PostId의 생성자가 양수 불변식을 지키므로, 컨버터를 거치지 않는 배치 가져오기에서도 잘못된 ID를 만들 수 없습니다. 컨버터는 공백 제거 정책과 바깥의 접두사만 맡으며, DB 조회는 하지 않습니다. 경로를 파싱하면서 데이터베이스 I/O와 404까지 처리하면, 단순한 타입 변환이 요청 문맥과 트랜잭션에 묶이게 됩니다.
Formatter는 로케일에 대해 대칭입니다
날짜와 숫자처럼 사람이 보는 형식은 로케일의 영향을 받습니다. Formatter<T>는 parse(String, Locale)와 print(T, Locale)를 함께 제공해, 폼 입력과 다시 보여 줄 때 같은 정책을 씁니다.
package board.web;
import java.text.NumberFormat;
import java.text.ParsePosition;
import java.util.Locale;
import org.springframework.format.Formatter;
public final class CharacterCountFormatter
implements Formatter<CharacterCountFormatter.CharacterCount> {
@Override
public CharacterCount parse(String text, Locale locale) {
String candidate = text == null ? "" : text.strip();
NumberFormat format = NumberFormat.getIntegerInstance(locale);
format.setGroupingUsed(true);
ParsePosition position = new ParsePosition(0);
Number value = format.parse(candidate, position);
if (value == null || position.getIndex() != candidate.length()) {
throw new IllegalArgumentException(
"invalid localized character count");
}
long parsed = value.longValue();
if (parsed < Integer.MIN_VALUE || parsed > Integer.MAX_VALUE) {
throw new IllegalArgumentException(
"invalid localized character count");
}
return new CharacterCount((int) parsed);
}
@Override
public String print(CharacterCount value, Locale locale) {
return NumberFormat.getIntegerInstance(locale)
.format(value.value());
}
public record CharacterCount(int value) {
public CharacterCount {
if (value < 1 || value > 720) {
throw new IllegalArgumentException(
"character count must be between 1 and 720");
}
}
}
}파서가 문자열의 앞부분만 읽고 나머지를 무시하지 않도록, ParsePosition이 끝까지 도달했는지 확인합니다. 30분을 30으로 조용히 받아들이면 잘못된 단위가 숨겨집니다. 허용하는 형식은 좁게 정하고, 자리 표시자와 오류 메시지가 같은 규칙을 설명하게 합니다.
ConversionService의 범위
WebMvcConfigurer#addFormatters에 등록하면 MVC의 경로와 쿼리, 폼 바인딩에서 쓰입니다. 애플리케이션 전역의 ConversionService에 등록하면 구성 속성과 다른 Spring 기능에도 영향을 줄 수 있습니다. 범용 String -> Object 컨버터는 후보끼리 충돌을 일으키므로, 구체적인 대상 타입을 씁니다.
package board.web;
import org.springframework.context.annotation.Configuration;
import org.springframework.format.FormatterRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
@Configuration
public class WebConversionConfiguration implements WebMvcConfigurer {
@Override
public void addFormatters(FormatterRegistry registry) {
registry.addConverter(new StringToPostIdConverter());
registry.addFormatter(new CharacterCountFormatter());
}
}Spring Boot가 이미 제공하는 ISO 날짜·시간 컨버터와 충돌하지 않는지, 직접 등록할 필요가 정말 있는지 먼저 확인합니다. 필드마다 형식이 다르다면 애노테이션 기반의 @DateTimeFormat이 더 알맞을 수 있으며, 애플리케이션 전체의 형식을 바꾸면 기존 엔드포인트와의 호환이 깨집니다.
포매팅은 화면에 보여 주기 위한 것이며 영속성의 직렬화와 다릅니다. DB 열과 JSON, URL, 화면 텍스트가 같은 toString()을 함께 쓴다고 가정하지 않습니다. JSON은 Jackson 모듈이, DB는 JDBC나 JPA 매핑이, MVC는 ConversionService가 맡도록 어댑터마다 규칙을 나눕니다.
| 확장 지점 | 고르는 조건 | 영향 범위 |
|---|---|---|
Converter<S, T> | 로케일과 무관한 타입 변환 | 등록한 소스와 대상 타입 쌍 |
Formatter<T> | 같은 로케일에서 입력과 출력의 왕복 | MVC의 경로, 쿼리, 폼 표시 |
@DateTimeFormat | 필드마다 날짜 표현이 다름 | 그 필드 |
| 추가 등록 없음 | Boot의 기본 변환이 요구에 맞음 | 기본 동작 유지 |
로케일별 왕복 검증
package board.web;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import java.util.Locale;
import org.junit.jupiter.api.Test;
import org.springframework.format.support.DefaultFormattingConversionService;
import org.springframework.context.i18n.LocaleContextHolder;
import board.domain.PostId;
class CharacterCountFormatterTest {
private final CharacterCountFormatter formatter =
new CharacterCountFormatter();
@Test
void 출력한_문자열은_같은_locale에서_원래_값으로_복원된다() {
var value = new CharacterCountFormatter.CharacterCount(120);
String printed = formatter.print(value, Locale.KOREAN);
var parsed = formatter.parse(printed, Locale.KOREAN);
assertThat(printed).isEqualTo("120");
assertThat(parsed).isEqualTo(value);
}
@Test
void 숫자_뒤의_임의_문자를_허용하지_않는다() {
assertThatThrownBy(() -> formatter.parse(
"120 chars", Locale.ENGLISH))
.isInstanceOf(IllegalArgumentException.class)
.hasMessage("invalid localized character count");
}
@Test
void MVC_registry는_PostId와_CharacterCount를_정확히_연결한다() {
var conversion = new DefaultFormattingConversionService();
new WebConversionConfiguration()
.addFormatters(conversion);
LocaleContextHolder.setLocale(Locale.KOREAN);
try {
assertThat(conversion.convert(" P-1042 ", PostId.class))
.isEqualTo(new PostId(1042));
assertThat(conversion.convert(
"120",
CharacterCountFormatter.CharacterCount.class))
.isEqualTo(
new CharacterCountFormatter.CharacterCount(120));
assertThat(conversion.convert(
new CharacterCountFormatter.CharacterCount(120),
String.class))
.isEqualTo("120");
} finally {
LocaleContextHolder.resetLocaleContext();
}
}
}print ko 120 = 120
parse ko 120 = CharacterCount[value=120]
parse en 120 chars = rejected
partial parse accepted = false
round-trip = true
MVC " P-1042 " -> PostId[value=1042]
MVC CharacterCount[value=120] -> "120"자릿수 구분이 있는 숫자 1,000처럼 허용 범위를 벗어난 값은 포매터의 파싱은 성공해도 값 객체가 범위 오류를 냅니다. 변환 오류와 검증 오류 가운데 어떤 코드로 사용자에게 보여 줄지 정해야 합니다. 형식은 맞는데 범위가 틀린 경우에는 범위를 알려 주는 별도의 메시지가 더 친절할 수 있어, 파싱과 검증을 나누기도 합니다.
400과 404를 구분합니다
/api/posts/not-an-id는 PostId의 형식 자체가 아니므로 400이 자연스럽고, /api/posts/P-999는 형식은 맞지만 저장소에 없으므로 404입니다. 컨버터에서 DB를 조회하지 않아야 이 둘의 의미를 분명히 나눌 수 있습니다.
| 단계 | 입력 | 결과 |
|---|---|---|
| 컨버터 | not-an-id | 400 타입 불일치 |
| 값 객체 | P-0 | 400 도메인 형식 |
| 저장소 | P-999 | 404 찾을 수 없음 |
| 인가 | 다른 회원의 P-41 | 403이나 정책에 따라 404 |
보안상 리소스가 있는지 숨기려고 다른 회원의 요청도 404로 통일할 수 있습니다. 이것은 컨버터가 아니라 인가 어댑터의 정책이므로, 상태를 고른 이유와 감사 로그를 남깁니다.
더 깊이: 변환 실패는 어떤 예외를 거쳐 상태 코드가 될까
컨버터가 던진 예외는 그대로 컨트롤러 바깥에 나오지 않습니다. Spring Framework 7.0.10의 ConversionUtils.invokeConverter는 컨버터가 던진 예외를 종류와 상관없이 ConversionFailedException으로 감싸고, 그 위의 TypeConverterSupport는 이를 TypeMismatchException으로 바꿉니다. 경로 변수나 쿼리 파라미터를 해석하던 리졸버는 이것을 다시 MethodArgumentTypeMismatchException으로 감싸며, MVC의 기본 예외 처리는 TypeMismatchException 계열을 400으로 나타냅니다. PostId.parse가 던진 IllegalArgumentException이 400이 되는 경로가 이것입니다.
반면 소스와 대상 타입 쌍을 처리할 컨버터가 아예 없으면 ConversionNotSupportedException이 되고, 이는 클라이언트의 잘못이 아니라 서버 구성의 결함이므로 500으로 나타납니다. 컨버터 등록을 빠뜨렸을 때 400이 아니라 500이 나는 이유입니다. 또 컨버터가 예외 대신 null을 돌려주면, 필수 경로 변수에서는 MissingPathVariableException이 납니다. 이 예외는 원래 500이지만, Spring Framework 5.3.6부터는 변환 뒤에 값이 없어진 경우를 따로 표시해 400으로 바꿉니다. 그래도 형식 오류를 null로 숨기면 원인이 사라지므로, 지금처럼 이유가 담긴 예외를 던지는 편이 진단하기 쉽습니다.
정리
Converter는 로케일과 무관한 타입 변환을,Formatter는 같은 로케일에서 입력과 출력이 왕복하는 텍스트 표현을 맡습니다.- 값 객체의 생성자가 불변식을 지키게 하고, 컨버터는 형식 해석만 할 뿐 DB를 조회하지 않습니다.
- 포매터는
ParsePosition으로 문자열 끝까지 읽었는지 확인해, 단위가 붙은 입력을 조용히 받아들이지 않습니다. addFormatters에 등록한 변환은 MVC 바인딩에만 쓰이며, JSON과 DB는 각자의 어댑터가 따로 변환합니다.- 형식이 틀리면 400, 형식은 맞지만 없으면 404, 권한이 없으면 403이나 정책상의 404로 단계마다 나눕니다.
연습 문제
1. ISO 주차 값 객체
주간 조회 쿼리에 week=2026-W29 형식의 PublishedWeek 값 객체를 더하려고 합니다. ISO 주차 기준 연도의 규칙으로 파싱하고 출력하며, 2026-W00과 2026-W54, 일반 날짜는 거부해야 합니다. 해가 바뀌는 주를 포함한 왕복 테스트도 작성합니다. 어떻게 구현하나요?
해설 보기
WeekFields.ISO와 명시적인 DateTimeFormatter를 써서 달력 연도와 주차 기준 연도를 혼동하지 않습니다. 값 객체는 그 주의 월요일을 계산해 쿼리 범위를 제공합니다.
package board.domain;
import java.time.LocalDate;
import java.time.DayOfWeek;
import java.time.format.DateTimeFormatter;
import java.time.format.DateTimeFormatterBuilder;
import java.time.format.DateTimeParseException;
import java.time.format.ResolverStyle;
import java.time.temporal.ChronoField;
import java.time.temporal.IsoFields;
import java.util.Locale;
import java.util.Objects;
public record PublishedWeek(LocalDate monday) {
private static final DateTimeFormatter FORMATTER =
new DateTimeFormatterBuilder()
.appendValue(IsoFields.WEEK_BASED_YEAR, 4)
.appendLiteral("-W")
.appendValue(IsoFields.WEEK_OF_WEEK_BASED_YEAR, 2)
.toFormatter(Locale.ROOT)
.withResolverStyle(ResolverStyle.STRICT);
public PublishedWeek {
Objects.requireNonNull(monday, "monday");
if (monday.getDayOfWeek() != DayOfWeek.MONDAY) {
throw new IllegalArgumentException("date must be a Monday");
}
}
public static PublishedWeek parse(String text) {
String candidate = text == null ? "" : text.strip();
if (candidate.isEmpty()) {
throw new DateTimeParseException(
"invalid ISO week", candidate, 0);
}
try {
var parsed = FORMATTER.parse(candidate);
int year = Math.toIntExact(
parsed.getLong(IsoFields.WEEK_BASED_YEAR));
long week = parsed.getLong(IsoFields.WEEK_OF_WEEK_BASED_YEAR);
LocalDate monday = LocalDate.of(year, 1, 4)
.with(IsoFields.WEEK_OF_WEEK_BASED_YEAR, week)
.with(ChronoField.DAY_OF_WEEK, 1);
if (!FORMATTER.format(monday).equals(candidate)) {
throw new DateTimeParseException(
"invalid ISO week", candidate, 0);
}
return new PublishedWeek(monday);
} catch (RuntimeException exception) {
if (exception instanceof DateTimeParseException parseException) {
throw parseException;
}
throw new DateTimeParseException(
"invalid ISO week", candidate, 0, exception);
}
}
public String externalForm() {
return FORMATTER.format(monday);
}
}빌더에 IsoFields.WEEK_BASED_YEAR와 WEEK_OF_WEEK_BASED_YEAR를 명시해, 기본 로케일의 요일 규칙에 기대지 않습니다. 테스트에는 12월 말과 1월 초를 반드시 넣습니다.
ISO 주차 연도는 달력 연도와 해가 바뀌는 시점이 다릅니다. 2020-W53의 월요일은 2020-12-28이고 그 주에 속한 2021-01-01도 ISO 주차 연도로는 2020에 속하며, 2021-W01의 월요일은 2021-01-04입니다. Java 25의 IsoFields는 새해 날짜가 적어도 네 개 들어간 월요일 시작의 주를 첫 주로 정의합니다. 이 도우미의 입력에는 요일 필드가 없으므로 파싱 결과가 날짜로 확정되지 않습니다. 이때 get은 확정된 날짜로 주차 필드의 범위를 계산하려다 UnsupportedTemporalTypeException을 던지므로, 두 값은 파싱된 숫자를 그대로 돌려주는 getLong으로 꺼냅니다. 그다음 월요일을 직접 계산하고, 다시 출력한 주차 문자열을 원래 입력과 비교합니다. 모든 해에 53주가 있다고 가정하지 않으며, 2021-W53처럼 그해에 없는 주가 다음 해의 월요일로 넘어가더라도 다시 출력한 결과가 원래 입력과 달라 거부합니다.
2. 왕복 값과 표준 표현
PublishedWeek.parse()에 2020-W53, 2021-W01, 2021-W53을 각각 넣었을 때의 월요일과 externalForm(), 또는 예외를 예측하세요. " P-0007 "을 PostId.parse()로 읽은 뒤 출력하면 입력 문자열이 그대로 보존되나요? 이 두 도우미가 있다는 것만으로 JSON이나 HTTP의 week 인자가 자동으로 변환되는지도 설명하세요.
해설 보기
앞의 두 주차는 각각 2020-12-28과 2021-01-04의 월요일이며, 외부 표현은 입력과 같은 표준 주차 문자열로 돌아옵니다. 2021-W53은 그 주차 연도에 없는 주이므로 DateTimeParseException이 납니다. " P-0007 "은 바깥 공백을 지우고 숫자 7을 읽어 PostId(7)을 만들며, toString()의 결과는 P-7입니다. 입력 표기를 그대로 보존하는 것이 아니라 값의 정규 표현을 돌려주며, P-0은 양수 불변식을 위반합니다.
이 글의 등록 설정은 PostId와 CharacterCount만 연결합니다. PublishedWeek를 HTTP에서 쓰려면 별도의 Converter나 Formatter와 MVC 등록, 그 타입을 받는 컨트롤러 인자를 연결하고, 변환 실패의 응답을 검사해야 합니다. Jackson JSON과 DB 매핑도 이 등록이 자동으로 적용되는 대상이 아닙니다. 날짜 계산과 도우미의 예외, MVC 등록, HTTP 상태를 각각 확인합니다.
다음 글 멀티파트 파일 업로드에서는 문자열을 넘어 파일과 필드가 함께 들어오는 multipart/form-data의 처리 범위와 크기, 스트리밍, 저장의 원자성을 다룹니다.