Spring Boot 다국어 처리 리팩토링 기록

Yong Lee·2025년 12월 15일

개요

기존 커스텀 헤더(Language-Code)를 사용하던 다국어 처리 방식을 HTTP 표준 헤더(Accept-Language)로 변경하고, 언어 결정 로직을 최적화한 과정을 정리합니다.


1. 커스텀 헤더 vs 표준 헤더

기존 방식

private static final String LANGUAGE_CODE_HEADER = "Language-Code";
String languageCode = request.getHeader(LANGUAGE_CODE_HEADER);

변경 이유

  • Accept-Language는 HTTP 표준 헤더로, 브라우저가 자동으로 설정
  • 클라이언트에서 별도 설정 없이도 사용자의 선호 언어 전달 가능
  • RESTful API 설계 원칙에 부합

변경 후

String acceptLanguage = request.getHeader(HttpHeaders.ACCEPT_LANGUAGE);

Accept-Language 파싱 고려사항

Accept-Language 헤더는 en-US, ko-KR 같은 locale 형식이나 en, ko 같은 단순 형식 모두 가능합니다.

결정: Primary language code만 추출하여 사용 (예: en-USen)

  • DB에 저장된 languageCode 필드가 en, ko 형식이므로 일관성 유지
  • 불필요한 복잡성 제거

2. 언어 Fallback 체인 설계

요구사항

  1. Accept-Language 헤더가 있으면 해당 언어 사용
  2. 헤더가 없으면 로그인한 사용자의 언어 설정 사용
  3. 사용자 정보도 없으면 기본값(영어) 사용

Fallback 체인

Accept-Language 헤더 → 사용자 언어 설정 → English (기본값)

유효하지 않은 언어 코드 처리

고민: 헤더의 언어 코드가 DB에 없을 때 어떻게 처리할까?

결정: 사용자 언어로 Fallback

  • 사용자가 의도적으로 설정한 언어가 있다면 그것을 존중
  • 바로 영어로 가는 것보다 자연스러운 UX

3. 언어 캐싱 전략

문제점

// 매 요청마다 DB 조회 발생
return languageRepository.findByLanguageCode(languageCode)
    .map(Language::getId)
    .orElse(null);

해결책: 애플리케이션 시작 시 캐싱

@Component
@RequiredArgsConstructor
public class LanguageCache {

    private final LanguageRepository languageRepository;
    private final Map<String, Long> languageCodeToIdMap = new ConcurrentHashMap<>();
    private Long defaultLanguageId;

    @PostConstruct
    public void init() {
        languageRepository.findAll().forEach(language -> {
            languageCodeToIdMap.put(language.getLanguageCode(), language.getId());
            if ("en".equals(language.getLanguageCode())) {
                defaultLanguageId = language.getId();
            }
        });
    }

    public Long getLanguageId(String languageCode) {
        return languageCodeToIdMap.get(languageCode);
    }

    public Long getDefaultLanguageId() {
        return defaultLanguageId;
    }
}

장점

  • 언어 정보는 거의 변하지 않는 정적 데이터
  • 매 요청마다 DB 조회 불필요
  • Map 조회로 O(1) 시간 복잡도

4. 언어 결정 로직 위치 선정

Option A: LanguageUtil에서 처리 (Lazy Loading)

Filter: Accept-Language → ThreadLocal (있으면)
LanguageUtil.getLanguageId(): ThreadLocal → User → English

Option B: LanguageFilter에서 처리 (Eager Loading)

Filter: Accept-Language → User → English → ThreadLocal
LanguageUtil.getLanguageId(): ThreadLocal만 반환

비교

항목Option AOption B
로직 위치분산 (Filter + Util)집중 (Filter)
User 조회 시점getLanguageId() 호출 시요청 시작 시
LanguageUtil 복잡도높음낮음 (단순 holder)
테스트 용이성어려움쉬움

결정: Option B

이유:

  1. 단일 책임 원칙: 언어 결정은 Filter, 저장은 Util
  2. 명확한 흐름: Fallback 체인이 한 곳에서 명시적으로 보임
  3. 예측 가능성: 요청 시작 시 언어가 결정되어 일관성 보장
  4. 테스트 용이: Filter만 테스트하면 언어 결정 로직 검증 가능

5. 최종 구현

LanguageFilter.java

@Component
@RequiredArgsConstructor
public class LanguageFilter extends OncePerRequestFilter {

    private final LanguageCache languageCache;
    private final UserRepository userRepository;

    @Override
    protected void doFilterInternal(...) {
        try {
            LanguageUtil.clear();

            if (shouldApplyLanguageFilter(request)) {
                Long languageId = resolveLanguageId(request);
                LanguageUtil.setLanguageId(languageId);
            }

            filterChain.doFilter(request, response);
        } finally {
            LanguageUtil.clear();
        }
    }

    private Long resolveLanguageId(HttpServletRequest request) {
        // 1. Accept-Language 헤더
        Long languageId = resolveFromHeader(request);
        if (languageId != null) {
            return languageId;
        }

        // 2. 인증된 사용자의 언어
        languageId = resolveFromUser();
        if (languageId != null) {
            return languageId;
        }

        // 3. 기본값 (English)
        return languageCache.getDefaultLanguageId();
    }
}

LanguageUtil.java

public class LanguageUtil {

    private static final ThreadLocal<Long> languageIdHolder = new ThreadLocal<>();

    private LanguageUtil() {}

    public static void clear() {
        languageIdHolder.remove();
    }

    public static Long getLanguageId() {
        return languageIdHolder.get();
    }

    public static void setLanguageId(Long languageId) {
        languageIdHolder.set(languageId);
    }
}

6. Swagger 설정

문제

Accept-Language는 표준 헤더지만 Swagger UI에 자동으로 표시되지 않습니다.

해결

@Bean
public OperationCustomizer globalHeaderCustomizer() {
    return (operation, handlerMethod) -> {
        if (shouldAddLanguageHeader(handlerMethod)) {
            operation.addParametersItem(
                new Parameter()
                    .in("header")
                    .name(HttpHeaders.ACCEPT_LANGUAGE)
                    .description("언어 코드 (예: en, ko)")
                    .required(false)
                    .example("ko")
            );
        }
        return operation;
    };
}

7. 주의사항

Filter 순서

LanguageFilter에서 사용자 언어를 조회하려면 JWT 인증 Filter가 먼저 실행되어야 합니다.

JwtAuthenticationFilter → LanguageFilter → Controller

AuthUtil.getCurrentUserId()SecurityContextHolder에서 인증 정보를 가져오므로, JWT Filter가 먼저 실행되어 인증 정보를 설정해야 합니다.


결론

항목BeforeAfter
헤더커스텀 (Language-Code)표준 (Accept-Language)
DB 조회매 요청마다시작 시 1회 (캐싱)
Fallback없음Header → User → English
로직 위치LanguageUtil (분산)LanguageFilter (집중)
LanguageUtil복잡한 로직 포함단순 ThreadLocal holder
profile
오늘은 어떤 새로운 것이 나를 즐겁게 할까?

0개의 댓글