기존 커스텀 헤더(Language-Code)를 사용하던 다국어 처리 방식을 HTTP 표준 헤더(Accept-Language)로 변경하고, 언어 결정 로직을 최적화한 과정을 정리합니다.
private static final String LANGUAGE_CODE_HEADER = "Language-Code";
String languageCode = request.getHeader(LANGUAGE_CODE_HEADER);
Accept-Language는 HTTP 표준 헤더로, 브라우저가 자동으로 설정String acceptLanguage = request.getHeader(HttpHeaders.ACCEPT_LANGUAGE);
Accept-Language 헤더는 en-US, ko-KR 같은 locale 형식이나 en, ko 같은 단순 형식 모두 가능합니다.
결정: Primary language code만 추출하여 사용 (예: en-US → en)
languageCode 필드가 en, ko 형식이므로 일관성 유지Accept-Language 헤더가 있으면 해당 언어 사용Accept-Language 헤더 → 사용자 언어 설정 → English (기본값)
고민: 헤더의 언어 코드가 DB에 없을 때 어떻게 처리할까?
결정: 사용자 언어로 Fallback
// 매 요청마다 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;
}
}
Map 조회로 O(1) 시간 복잡도Filter: Accept-Language → ThreadLocal (있으면)
LanguageUtil.getLanguageId(): ThreadLocal → User → English
Filter: Accept-Language → User → English → ThreadLocal
LanguageUtil.getLanguageId(): ThreadLocal만 반환
| 항목 | Option A | Option B |
|---|---|---|
| 로직 위치 | 분산 (Filter + Util) | 집중 (Filter) |
| User 조회 시점 | getLanguageId() 호출 시 | 요청 시작 시 |
| LanguageUtil 복잡도 | 높음 | 낮음 (단순 holder) |
| 테스트 용이성 | 어려움 | 쉬움 |
이유:
@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();
}
}
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);
}
}
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;
};
}
LanguageFilter에서 사용자 언어를 조회하려면 JWT 인증 Filter가 먼저 실행되어야 합니다.
JwtAuthenticationFilter → LanguageFilter → Controller
AuthUtil.getCurrentUserId()는 SecurityContextHolder에서 인증 정보를 가져오므로, JWT Filter가 먼저 실행되어 인증 정보를 설정해야 합니다.
| 항목 | Before | After |
|---|---|---|
| 헤더 | 커스텀 (Language-Code) | 표준 (Accept-Language) |
| DB 조회 | 매 요청마다 | 시작 시 1회 (캐싱) |
| Fallback | 없음 | Header → User → English |
| 로직 위치 | LanguageUtil (분산) | LanguageFilter (집중) |
| LanguageUtil | 복잡한 로직 포함 | 단순 ThreadLocal holder |