본문으로 건너뛰기
안동민 개발노트 아이콘

안동민 개발노트

본문 시작
8장 : 웹 상태와 경계 처리

타입 변환과 포매팅

게시판의 PostId·본문 길이·날짜를 ConversionService와 로케일-인식 Formatter로 변환하고 실패 의미, 등록 범위와 왕복을 검증합니다.

HTTP 경로, 쿼리, 폼은 문자열로 도착하지만 컨트롤러와 도메인은 long, LocalDate, PostId 같은 의미 있는 타입을 원합니다. 컨트롤러마다 parseLong과 날짜 형식을 반복하면 오류 처리와 로케일 정책이 흩어집니다. Spring의 변환 시스템은 타입 간 변환을, Formatter는 사람이 읽고 쓰는 로케일-인식 텍스트 표현을 중앙화합니다.


Converter 타입 변환

경로 변수 P-1042를 도메인 값 객체로 받고 싶다면 문자열 형식을 하나의 컨버터에 둡니다. 변환 실패는 IllegalArgumentException처럼 원인이 분명한 예외로 나타내 MVC가 타입 불일치 400으로 처리하게 합니다.

src/main/java/board/domain/PostId.java
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) {
        if (text == null || !text.startsWith("P-")) {
            throw new IllegalArgumentException(
                    "post id must start with P-");
        }
        try {
            return new PostId(Long.parseLong(text.substring(2)));
        } catch (NumberFormatException exception) {
            throw new IllegalArgumentException(
                    "post id suffix must be numeric", exception);
        }
    }

    @Override
    public String toString() {
        return "P-" + value;
    }
}
src/main/java/board/web/StringToPostIdConverter.java
package board.web;

import board.domain.PostId;

import org.springframework.core.convert.converter.Converter;
import org.springframework.stereotype.Component;

@Component
public final class StringToPostIdConverter
        implements Converter<String, PostId> {
    @Override
    public PostId convert(String source) {
        return PostId.parse(source.strip());
    }
}

PostId의 생성자가 양수 불변식을 지키므로 컨버터 외 배치 가져오기에서도 잘못된 ID를 만들 수 없습니다. 컨버터는 공백 제거 정책과 외부 접두사만 책임집니다. DB 조회를 컨버터에서 수행하지 않습니다. 경로 파싱이 데이터베이스 I/O와 404까지 수행하면 단순 타입 변환이 요청 컨텍스트와 트랜잭션에 결합됩니다.

Formatter 로케일 대칭성

날짜와 숫자처럼 사람이 보는 형식은 로케일 영향을 받습니다. Formatter<T>parse(String, Locale)print(T, Locale)를 함께 제공해 폼 입력과 재표시에 같은 정책을 씁니다.

src/main/java/board/web/CharacterCountFormatter.java
package board.web;

import java.text.NumberFormat;
import java.text.ParsePosition;
import java.util.Locale;

import org.springframework.format.Formatter;
import org.springframework.stereotype.Component;

@Component
public final class CharacterCountFormatter
        implements Formatter<CharacterCount> {
    @Override
    public CharacterCount parse(String text, Locale locale) {
        NumberFormat format = NumberFormat.getIntegerInstance(locale);
        format.setGroupingUsed(true);
        ParsePosition position = new ParsePosition(0);
        Number value = format.parse(text.strip(), position);
        if (value == null || position.getIndex() != text.strip().length()) {
            throw new IllegalArgumentException(
                    "invalid localized character count");
        }
        return new CharacterCount(value.intValue());
    }

    @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 컨버터는 후보 충돌을 일으키므로 구체 대상 타입을 사용합니다.

src/main/java/board/web/WebConversionConfiguration.java
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 {
    private final StringToPostIdConverter postIds;
    private final CharacterCountFormatter characterCount;

    public WebConversionConfiguration(
            StringToPostIdConverter postIds,
            CharacterCountFormatter characterCount
    ) {
        this.postIds = postIds;
        this.characterCount = characterCount;
    }

    @Override
    public void addFormatters(FormatterRegistry registry) {
        registry.addConverter(postIds);
        registry.addFormatter(characterCount);
    }
}

Spring Boot가 이미 제공하는 ISO 날짜/시간 컨버터와 충돌하지 않게 사용자 정의 등록 필요성을 먼저 확인합니다. 애노테이션 기반 @DateTimeFormat이 필드별 형식에 더 적합할 수 있고, 애플리케이션 전체 형식을 바꾸면 기존 엔드포인트 호환성이 깨집니다.

포매팅은 표시 목적이고 영속성 직렬화와 다릅니다. DB 열, JSON, URL, 화면 텍스트가 같은 toString()을 공유한다고 가정하지 않습니다. JSON은 Jackson 모듈, DB는 JDBC/JPA 매핑, MVC는 ConversionService라는 각 어댑터 계약을 분리합니다.

로케일별 왕복 검증

src/test/java/board/web/CharacterCountFormatterTest.java
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;

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(parsed).isEqualTo(value);
    }

    @Test
    void 숫자_뒤의_임의_문자를_허용하지_않는다() {
        assertThatThrownBy(() -> formatter.parse(
                "120 chars", Locale.ENGLISH))
                .isInstanceOf(IllegalArgumentException.class)
                .hasMessage("invalid localized character count");
    }
}
formatter 계약 결과
print ko_KR 120 = 120
parse ko_KR 120 = CharacterCount[value=120]
parse en 120 chars = rejected
partial parse accepted = false
round-trip = true

숫자 1,000처럼 자릿수 구분이 실제 범위 밖이라면 포매터 파싱은 성공한 뒤 값 객체가 범위 오류를 냅니다. 변환 오류와 검증 오류 중 어느 코드로 사용자에게 보일지 정합니다. 형식은 맞지만 범위가 틀린 경우 별도 범위 메시지가 더 친절할 수 있어 파싱과 검증을 분리하기도 합니다.

400·404 구분

/api/posts/not-an-id는 PostId 형식 자체가 아니므로 400이 자연스럽습니다. /api/posts/P-999는 형식은 맞지만 리포지토리에 없으므로 404입니다. 컨버터에서 DB 조회를 하지 않아야 이 의미를 분명히 나눌 수 있습니다.

단계입력결과
Converternot-an-id400 타입 불일치
Value 객체P-0400 도메인 형식
리포지토리P-999404 찾을 수 없음
인가다른 회원의 P-41403 또는 정책상 404

보안상 리소스 존재 여부를 숨기기 위해 다른 회원 요청도 404로 통일할 수 있습니다. 이것은 컨버터가 아니라 인가 어댑터 정책입니다. 상태 선택 근거와 감사 로그를 남깁니다.

연습 문제

주간 조회 쿼리에 week=2026-W29 형식의 PublishedWeek 값 객체를 추가하세요. ISO 주차 기준 연도 규칙으로 파싱하고 출력하며 2026-W00, 2026-W54, 일반 날짜를 거부하세요. 연도 경계의 주간을 포함해 왕복 테스트를 작성합니다.

해설 보기

WeekFields.ISO와 명시적 DateTimeFormatter를 사용해 달력 연도와 주차 기준 연도를 혼동하지 않습니다. 값 객체는 해당 주의 월요일을 계산해 쿼리 범위를 제공합니다.

package board.domain;

import java.time.LocalDate;
import java.time.format.DateTimeFormatter;
import java.time.format.ResolverStyle;

public record PublishedWeek(LocalDate monday) {
    private static final DateTimeFormatter FORMATTER =
            DateTimeFormatter.ofPattern("YYYY-'W'ww-e")
                    .withResolverStyle(ResolverStyle.STRICT);

    public static PublishedWeek parse(String text) {
        LocalDate date = LocalDate.parse(text + "-1", FORMATTER);
        return new PublishedWeek(date);
    }

    public String externalForm() {
        return FORMATTER.format(monday).substring(0, 8);
    }
}

실제 JDK 포매터 패턴과 로케일의 요일 차이를 테스트에서 확인하고 필요하면 빌더로 ISO 필드를 명시합니다. 12월 말과 1월 초를 반드시 포함합니다.

다음 문서에서는 문자열을 넘어 파일과 필드가 함께 들어오는 멀티파트/폼-데이터의 경계, 크기, 스트리밍과 저장 원자성을 다룹니다.