1. Locale 우선순위를 어떻게 줄 것인가?
처음에는 스프링이 기본 제공하는 AcceptHeaderLocaleResolver만으로 충분해 보였다. 하지만 몇 가지 요구가 추가됐다.
- 관리자 화면에서는 URL 파라미터로 언어를 강제로 지정하고 싶다.
- 백오피스에서는 API Gateway가 붙이는 헤더(
X-Accept-Language)를 우선으로 삼아야 한다. - 사용자가 마지막으로 선택한 언어를 쿠키에 저장했다가 재방문 시 적용하고 싶다.
이 요구를 만족시키려면 Resolver 하나로는 부족했다. 그래서 Chain of Responsibility 패턴을 차용해 Resolver들을 순차적으로 시도하도록 CompositeLocaleResolver를 만들었다.
@Bean
fun localeResolver(
supportedLocales: List<Locale>,
defaultLocale: Locale,
): LocaleResolver {
val acceptHeaderResolver =
AcceptHeaderLocaleResolver().apply {
setDefaultLocale(defaultLocale)
setSupportedLocales(supportedLocales)
}
return CompositeLocaleResolver(
listOf(
QueryParamLocaleResolver("lang", supportedLocales),
HeaderLocaleResolver("X-Accept-Language", supportedLocales),
CookieLocaleResolver("i18n_locale", supportedLocales),
acceptHeaderResolver,
),
defaultLocale,
)
}
Resolver 각각은 전략 객체처럼 자신의 책임만 수행한다. 예를 들어 쿼리 파라미터를 담당하는 Resolver는 아래와 같이 단순하다.
class QueryParamLocaleResolver(
private val paramName: String?,
private val supportedLocales: List<Locale>,
) : LocaleResolver {
override fun resolveLocale(request: HttpServletRequest): Locale? {
val lang = request.getParameter(paramName)
if (lang != null) {
val locale = Locale.forLanguageTag(lang)
if (supportedLocales.contains(locale)) {
return locale
}
}
return null
}
override fun setLocale(
req: HttpServletRequest,
res: HttpServletResponse?,
locale: Locale?,
): Unit = throw UnsupportedOperationException()
}
이 구조 덕분에 우선순위를 바꾸거나 다른 Resolver를 끼워 넣을 때 기존 코드를 건드릴 일이 거의 없다.
2. 예외 응답을 일관되게 만들기
초기 버전에서는 예외마다 서로 다른 JSON 구조를 내보냈다. 프론트에서는 케이스별로 분기 처리를 해야 했고, 번역 키가 빠질 경우 메시지 자체가 비어 버렸다. “통일된 헤더 + 데이터 바디” 포맷을 강제하고 싶어서 CommonResponse를 도입했다.
@Schema(description = "공통응답 객체")
data class CommonResponse<T>(
val header: Header,
val data: T? = null,
) {
companion object {
fun ok(): CommonResponse<Unit?> = ok<Unit?>(null)
fun <U> ok(data: U?): CommonResponse<U?> {
val header =
Header(
isSuccessful = true,
resultCode = ResponseTypeCodeKind.SUCCESS.resultCode,
message = null,
)
return CommonResponse(header, data)
}
}
}
예외 처리는 GlobalValidationExceptionHandler와 CustomErrorAttributes가 맡는다. 두 컴포넌트 모두 현재 Locale을 직접 조회해 메시지를 가져오고, 키가 누락되면 폴백 문자열을 사용한다.
val headerMessage = messageSource.getMessage(
"method.argument.not.valid.exception",
null,
"MethodArgumentNotValidException",
locale,
)
에러 속성 클래스를 재정의한 이유는 /error 엔드포인트까지 CommonResponse 형태로 감싸고 싶어서였다. 그렇지 않으면 스프링 기본 JSON 구조가 내려가기 때문에 클라이언트가 두 가지 포맷을 동시에 처리해야 했다.
val attrs = errorAttributes.getErrorAttributes(web, options)
val status = (attrs["status"] as? Int) ?: 500
val header =
Header(
isSuccessful = false,
resultCode = (attrs["code"] as? Int) ?: -1,
message = (attrs["message"] as? String) ?: "Internal Server Error",
)
`GlobalValidationExceptionHandler`는 `@RestControllerAdvice`로 등록되어 모든 컨트롤러에서 발생한 검증/비즈니스 예외를 가로챈다. `MessageSource`를 주입받아 각 에러 상황에 맞는 메시지 키를 조회하고, 등록되지 않은 키는 기본 문구로 대체한다. 덕분에 한글·영어 등 Locale에 따라 동일한 오류도 서로 다른 표현으로 노출된다.
@RestControllerAdvice
class GlobalValidationExceptionHandler(
private val messageSource: MessageSource,
) {
@ExceptionHandler(MethodArgumentNotValidException::class)
fun handleMethodArgumentNotValid(ex: MethodArgumentNotValidException): ResponseEntity<CommonResponse<Map<String, Any>?>> {
val locale = LocaleContextHolder.getLocale()
val headerMessage = messageSource.getMessage(
"method.argument.not.valid.exception",
null,
"MethodArgumentNotValidException",
locale,
)
return commonResponse(HttpStatus.BAD_REQUEST, ValidationCodeKind.GENERIC.resultCode, headerMessage)
}
}
3. 메시지 키와 결과 코드를 한 곳에서 관리하기
HTTP Status, 내부 코드, 메시지 키가 여기저기 흩어져 있으면 릴리스 때마다 누락이 발생한다. 이 세 가지를 묶어 선언하려고 만든 것이 ResponseTypeCodeKind다. 서비스별로 확장 가능한 형태로 잡았고, 모든 커스텀 예외는 이 Enum을 참조한다.
enum class ResponseTypeCodeKind(
override val httpStatus: Int,
override val resultCode: Int,
override val message: String,
) : ResponseTypeCodeInterface {
SUCCESS(HttpStatus.OK.value(), 2000, "system.message.success.ok"),
UNAUTHORIZED(HttpStatus.UNAUTHORIZED.value(), 4010, "unauthorized"),
ACCESS_DENIED(HttpStatus.FORBIDDEN.value(), 4030, "access.denied"),
INTERNAL_SERVER_ERROR(HttpStatus.INTERNAL_SERVER_ERROR.value(), 5001, "system.message.error.internalServerError"),
KEYCLOAK_ADMIN_LOGIN_ERROR(HttpStatus.INTERNAL_SERVER_ERROR.value(), 5002, "keycloak.admin.login.error"),
}
아직 공통 번들에 모든 키가 들어가 있지는 않다. 대신 messageSource.getMessage(key, args, key, locale)처럼 세 번째 인자로 폴백을 지정해, 키가 빠져도 화면에 문자열이 비어 보이지 않게 했다. 장기적으로는 테스트 단계에서 “Enum에 정의된 모든 메시지 키가 properties에 존재하는지” 검사하는 작업을 넣을 계획이다.
4. Enum 라벨 하드코딩을 없애기
관리자 화면에서 Enum 라벨을 하드코딩해 두면 백엔드가 새 값을 추가할 때마다 프론트도 같이 수정해야 했다. 이를 해결하려고 Enum이 스스로 메시지 키 규칙을 갖도록 I18nEnum 인터페이스를 만들고, 공통 유틸리티가 Locale에 맞는 라벨을 만들어 주도록 했다.
interface I18nEnum {
fun messageKey(): String = "${'$'}{this.javaClass.simpleName.lowercase()}.${'$'}{(this as Enum<*>).name.lowercase()}"
}
fun <T> convert(value: T): String where T : Enum<T>, T : I18nEnum =
messageSource.getMessage(
value.messageKey(),
null,
value.name,
LocaleContextHolder.getLocale(),
)!!
애플리케이션 기동 시 I18nEnum 구현체를 스캐닝해서 캐시하고, 필요할 때마다 I18nEnumResponse(name, label) 목록으로 변환해 프론트에 내려준다. 덕분에 Enum을 추가해도 규칙만 지키면 라벨은 자동으로 따라온다.
이번에 Locale 결정, 메시지 번들 관리, 응답 구조를 명확히 분리할 수 있었고, 새 모듈에서도 최소한의 설정만으로 재사용할 수 있는 기반을 만들었다.
'트러블슈팅' 카테고리의 다른 글
| Gradle에서 task.named vs tasks.withType의 차이 (0) | 2025.04.28 |
|---|---|
| JPA, 트랜잭션, IDENTITY, SEQUENCE 전략에 대한 오해와 이해 (0) | 2025.04.16 |
| NoOpServerSecurityContextRepository 와 ReactiveSecurityContextHolder 차이 (0) | 2025.04.12 |
| spring batch reader (0) | 2025.01.31 |
| 코틀린에서 파이썬 코드 실행하기 (2) | 2024.12.28 |