땃쥐네

[토이프로젝트] 게시판 시스템(board-system) 8. 메시지,국제화 / API 응답 규격 본문

Project/Board-System

[토이프로젝트] 게시판 시스템(board-system) 8. 메시지,국제화 / API 응답 규격

ttasjwi 2024. 10. 16. 17:05

 

이번에 다룰 주제는 메시지/국제화 기능 및 API 응답 규격입니다.

 

 

(자바코드로 예외 메시지 내려주게 하는 방법 이미지)

 

사용자에게 응답을 내려줄 때 내려줄 메시지를 java 코드 상에 직접 작성하는 방법도 있긴 하지만, 이 방법은 다국어에 대해 열려있지 못 한 방식입니다. 그래서, java 코드 상에서는 메시지를 식별할 수 있는 code 를 관리하고 이 code를 기반으로 사용자 환경에 따라 제각각 다른 메시지를 내려줄 수 있게 하려고 합니다.

 

또 저는, API 응답 규격을 일관된 방식으로 내려주고자 하기 위해 API 응답 규격을 정해두려고 합니다.


 

1. 메시지/국제화

1.1 MessageResolver 인터페이스 정의

package com.ttasjwi.board.system.core.message

interface MessageResolver {
    /**
     * code 에 대응하는 메시지를 찾습니다.
     */
    fun resolveMessage(code: String): String

    /**
     * code 에 대응하는 메시지 설명(description) 을 찾습니다.
     */
    fun resolveDescription(
        code: String,
        args: List<Any?> = emptyList()
    ): String
}

 

우선 core 모듈에 메시지를 획득하는 역할을 담당하는 MessageResolver 인터페이스를 정의했습니다.

 

resolveMessage : code 에 해당하는 메시지를 얻어옵니다.

resolveDescription : code 에 해당하는 메시지 설명을 얻어옵니다. 구성을 위해 인자들(args)의 리스트를 전달 할 수 있습니다.

 

1.2 External Message 모듈

rootProject.name = "board-system"

include(

    // 설정 최종 구성 및 실행
    "board-system-container",

    // 공용 모듈
    "board-system-core",
    "board-system-logging",

    // api
    "board-system-api:api-deploy",

    // external : 외부 기술 의존성
    "board-system-external:external-message",
)

 

settings.gradle 쪽에는 "board-system-external:external-message" 모듈을 새로 선언했습니다.

 

이 모듈에서는 메시지/국제화 관련 설정을 집약해두며, core 모듈의 MessageResolver를 구현하여 스프링 빈으로 등록할 예정입니다.

 

 

intellij 의 gradle 을 reload 하면 인텔리제이가 잘 인식을 합니다.

 

1.3 External Message 모듈의 의존성

enum class Dependencies(
    private val groupId: String,
    private val artifactId: String,
    private val version: String? = null,
    private val classifier: String? = null,
) {

	// 생략
    // yaml message
    YAML_RESOURCE_BUNDLE(groupId = "dev.akkinoc.util", artifactId = "yaml-resource-bundle", version = "2.13.0");


}

 

buildSrc 에 위치한 Dependencies enum 에, yaml-resource-bundle 의존성을 추가했습니다.

 

이 라이브러리를 사용하여, 스프링의 메시지/국제화 기능 사용 시 yaml 파일을 사용하여 메시지를 얻어올 수 있도록 할 것입니다. (스프링의 MessageSource 기능에서는 properties 파일만 지원되기 때문에, 이렇게 별도의 라이브러리를 사용했습니다.)

 

 

dependencies {
    implementation(Dependencies.SPRING_BOOT_WEB.fullName)
    implementation(Dependencies.YAML_RESOURCE_BUNDLE.fullName)
}

 

external-message 모듈의 dependencies 에는 위와 같이 spring-boot-starter-web과 yaml-resource-bundle 에 대해 의존성을 추가합니다.

 

spring-boot-starter-web 을 의존성으로 추가한 이유는 사용자의 웹 요청이 들어왔을 때 사용자의 로케일 정보를 얻어올 지,그리고 그것을 기반으로 spring-mvc 쪽에서 국제화는 어떻게 할 것인지에 대한 설정을 하기 위함입니다.

 

1.4 로케일 설정값(spring.web.locale)

 

 

 

external-message 모듈의 main/resources 에서, message-config.yml 을 작성하겠습니다.

 

spring:
  web:
    locale: ko

 

spring.web.locale 을 ko 로 설정합니다.

이 값은 사용자의 기본 로케일을 설정하며, Accept-Language 헤더에 의해 덮여질 수 있습니다.

 

 

 

# container/src/main/resources/application.yml
spring.application.name: board-system
spring:
  profiles:
    active: local
  config:
    import:
      - deploy-config.yml
      - message-config.yml
# container/src/test/resources/application.yml
spring.application.name: board-system-test
spring:
  profiles:
    active: test
  config:
    import:
      - deploy-config-test.yml
      - message-config.yml

---

 

message-config.yml 을 컨테이너의 설정에 포함시킵니다.

 

@ConfigurationProperties("spring.web")
public class WebProperties {

    /**
     * Locale to use. By default, this locale is overridden by the "Accept-Language"
     * header.
     */
    private Locale locale;

    /**
     * Define how the locale should be resolved.
     */
    private LocaleResolver localeResolver = LocaleResolver.ACCEPT_HEADER;

 

참고로 우리가 설정한 message-config.yml 은 WebProperties 클래스에 바인딩되며

 

@Override
@Bean
@ConditionalOnMissingBean(name = DispatcherServlet.LOCALE_RESOLVER_BEAN_NAME)
public LocaleResolver localeResolver() {
    if (this.webProperties.getLocaleResolver() == WebProperties.LocaleResolver.FIXED) {
       return new FixedLocaleResolver(this.webProperties.getLocale());
    }
    AcceptHeaderLocaleResolver localeResolver = new AcceptHeaderLocaleResolver();
    localeResolver.setDefaultLocale(this.webProperties.getLocale());
    return localeResolver;
}

 

스프링부트 spring-boot-starter-web 기준, 자동구성 클래스 WebMvcAutoConfiguration 클래스에서 LocaleResolver 빈 등록에 사용됩니다.

 

자세히 보시면 WebProperties 가 사용되는 것을 확인할 수 있는데요.

 

localeResolver 설정은 우리가 따로 안 했으므로 Accept-LocaleResolver가 사용되는 것을 확인할 수 있어요.

그리고 DefaultLocale 설정은 WebProperties 의 spring.web.locale 값을 이용해 설정됩니다.

 

package org.springframework.web.servlet;

public interface LocaleResolver {

	Locale resolveLocale(HttpServletRequest request);

	void setLocale(HttpServletRequest request, @Nullable HttpServletResponse response, @Nullable Locale locale);

}

 

LocaleResolver 는 spring.web.servlet 에서 정의된 인터페이스로, 사용자의 Http 요청객체(HttpServletRequest) 를 읽고 사용자의 로케일을 가져오는 역할을 합니다.

 

위의 자동구성에 의해 기본 구현체는 AcceptHeaderLocaleResolver가 들어간다는 것을 볼 수 있죠? 이 클래스를 볼게요.

 

@Override
public Locale resolveLocale(HttpServletRequest request) {
    Locale defaultLocale = getDefaultLocale();
    if (defaultLocale != null && request.getHeader("Accept-Language") == null) {
       return defaultLocale;
    }
    Locale requestLocale = request.getLocale();
    List<Locale> supportedLocales = getSupportedLocales();
    if (supportedLocales.isEmpty() || supportedLocales.contains(requestLocale)) {
       return requestLocale;
    }
    Locale supportedLocale = findSupportedLocale(request, supportedLocales);
    if (supportedLocale != null) {
       return supportedLocale;
    }
    return (defaultLocale != null ? defaultLocale : requestLocale);
}

 

AcceptHeaderLocaleResolver 는 resolveLocale 을 호출했을 때

 

우리가 디폴트 로케일을 설정했고 Accept-Language 헤더가 없다면 디폴트 로케일을 반환하고

그렇지 않을 경우 Accept-Language 헤더를 기반으로 로케일을 가져오게 됩니다.

 

그런데 이런 과정을 거쳐서 LocaleResolver 구현체로 AcceptHeaderLocaleResolver가 빈으로 등록되긴 했지만 어디서 사용되는지 여기까지 코드만 놓고 보면 모릅니다.

일단 이 부분은 국제화 기능 구현을 하는데 집중하고 아래에서 다루도록 하겠습니다.

 

1.5 메시지 설정

 

마저 메시지 기능 구현을 해보도록 하겠습니다.

 

# message-config.yml
spring:
  web:
    locale: ko

message:
  encoding: "UTF-8"
  errorBaseName: message/error-message
  generalBasename: message/general-message

 

message-config.yml 에서는 설정을 더 덧붙여보겠습니다.

 

이건 스프링 설정은 아니고 커스텀 정의한 설정값입니다.

 

message.encoding : 인코딩

message.errorBaseName: 에러 메시지 파일의 시작 패턴

message.generalBaseName: 일반 메시지 파일의 시작 패턴

 

package com.ttasjwi.board.system.core.config

import org.springframework.boot.context.properties.ConfigurationProperties
import org.springframework.boot.context.properties.bind.ConstructorBinding

@ConfigurationProperties(prefix = "message")
class MessageProperties @ConstructorBinding constructor(
    val encoding: String,
    val errorBaseName: String,
    val generalBaseName: String,
)

 

그리고 이를 MessageProperties 에 바인딩합니다.(ConfigurationProperties)

 

# board-system-external/external-message/main/resources/message/error-message_ko.yml
Error:
  NullArgument:
    message: "필수값 누락"
    description: "''{0}''은(는) 필수입니다."
# board-system-external/external-message/main/resources/message/error-message_en.yml

Error:
  NullArgument:
    message: "Missing required value"
    description: "The field ''{0}'' is required.
# board-system-external/external-message/main/resources/message/general-message_ko.yml
Example:
  message: "예제 메시지"
  description: "예제 설명(args={0},{1},{2})"
# board-system-external/external-message/main/resources/message/general-message_en.yml
Example:
  message: "Example Message"
  description: "Example Description(args={0},{1},{2})"


메시지 파일들입니다. "{...}" 와 같은 패턴은 yml 리딩 과정에서 문제가 발생할 여지가 있기 때문에 쌍따옴표("") 으로 감사주도록 했습니다.

 

참고로 {0}, {1}, ... 부분은 args의 인자들이 순서대로 대입되는 부분입니다.

 

 

package com.ttasjwi.board.system.core.config

import dev.akkinoc.util.YamlResourceBundle
import org.springframework.context.MessageSource
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration
import org.springframework.context.support.ResourceBundleMessageSource
import java.util.*

@Configuration
class MessageConfig(
    private val messageProperties: MessageProperties,
) {

    @Bean(name = ["errorMessageSource"])
    fun errorMessageSource(): MessageSource {
        val messageSource = YamlMessageSource()
        messageSource.setDefaultLocale(Locale.KOREAN)
        messageSource.setBasename(messageProperties.errorBaseName) // 메시지를 찾을 위치
        messageSource.setDefaultEncoding(messageProperties.encoding) // 인코딩
        messageSource.setAlwaysUseMessageFormat(true) // 메시지 포맷 규칙 사용
        messageSource.setUseCodeAsDefaultMessage(true) // 메시지를 못 찾으면 코드 그 자체를 디폴트 메시지로 사용

        return messageSource
    }


    @Bean(name = ["generalMessageSource"])
    fun generalMessageSource(): MessageSource {
        val messageSource = YamlMessageSource()

        messageSource.setBasename(messageProperties.generalBaseName) // 메시지를 찾을 위치
        messageSource.setDefaultLocale(Locale.KOREAN)
        messageSource.setDefaultEncoding(messageProperties.encoding) // 인코딩
        messageSource.setAlwaysUseMessageFormat(true) // 메시지 포맷 규칙 사용
        messageSource.setUseCodeAsDefaultMessage(true) // 메시지를 못 찾으면 코드 그 자체를 디폴트 메시지로 사용

        return messageSource
    }

    private class YamlMessageSource : ResourceBundleMessageSource() {

        override fun doGetBundle(basename: String, locale: Locale): ResourceBundle {
            return ResourceBundle.getBundle(basename, locale, YamlResourceBundle.Control)
        }
    }
}

 

메시지를 가져오는 설정부입니다.

 

MessageProperties 를 의존성 주입받아서 이를 기반으로 MessageSource (스프링의 메시지/국제화 기능을 위한 인터페이스) 를 구성합니다.

 

여기서 저는  yaml 을 통해 메시지를 가져올 수 있도록 YamlMessageSource 를 클래스를 선언하고 이를 기반으로 하여 MessageSource두 개를 빈으로 등록했습니다.

 

하나는 일반 메시지를 위한 MessageSource (generalMessageSource), 하나는 에러메시지를 위한 (errorMessageSource)로 했어요.

 

참고로 이렇게 설정하면 로케일 값을 전달했을 때 메시지 파일명 뒤에 _ko.yml / _en.yml / ... 을 순서대로 순회하며 맞는 로케일의 메시지 파일을 찾게 됩니다.

package org.springframework.context;

public interface MessageSource {

	@Nullable
	String getMessage(String code, @Nullable Object[] args, @Nullable String defaultMessage, Locale locale);


	String getMessage(String code, @Nullable Object[] args, Locale locale) throws NoSuchMessageException;

	String getMessage(MessageSourceResolvable resolvable, Locale locale) throws NoSuchMessageException;

}

 


참고로 스프링은 MessageSource 라는 인터페이스를 제공하며 스프링 부트는 기본 구현체를 자동구성으로 등록해줍니다.

기본 구현체는 messages.properties 를 기반으로 메시지를 가져오게 돼요.

메시지에 대응되는 code, args, locale 을 제공하면 그 설정에 맞게 메시지를 가져올 수 있습니다. 이를 통해 메시지/국제화 처리를 할 수 있죠.

 

그런데 이 기본 구현체는 yaml 파일을 기반으로는 사용할 수 없기 때문에 구현체 변경을 위해 yaml-resource-bundle 을 사용한 것입니다.

 

 

1.6 메시지/국제화 - MessageResolver 구현

package com.ttasjwi.board.system.core.message

import org.springframework.beans.factory.annotation.Qualifier
import org.springframework.context.MessageSource
import org.springframework.context.i18n.LocaleContextHolder
import org.springframework.stereotype.Component

@Component
internal class MessageResolverImpl(
    @Qualifier("generalMessageSource")
    private val generalMessageSource: MessageSource,

    @Qualifier("errorMessageSource")
    private val errorMessageSource: MessageSource,
) : MessageResolver {

    companion object {
        private const val ERROR_MESSAGE_PREFIX = "Error."
    }

    override fun resolveMessage(code: String): String {
        val messageSource = selectMessageSource(code)
        return messageSource.getMessage("$code.message", null, LocaleContextHolder.getLocale())
    }

    override fun resolveDescription(code: String, args: List<Any?>): String {
        val messageSource = selectMessageSource(code)
        return messageSource.getMessage("$code.description", args.toTypedArray(), LocaleContextHolder.getLocale())
    }

    private fun selectMessageSource(code: String): MessageSource {
        return if (code.startsWith(ERROR_MESSAGE_PREFIX)) {
            errorMessageSource
        } else {
            generalMessageSource
        }
    }
}


앞서 core 모듈에 선언했던 MessageResolver 인터페이스의 구현체 MessageResolverImpl 입니다.

스프링의 MessageSource 에게 위임하여, 메시지를 찾아올 수 있도록 합니다. 

 

저는 code 값이 "Error." 으로 시작하면 errorMessageSource 에서 메시지를 찾도록 하고, 그렇지 않을 경우 "generalMessageSource" 를 통해 메시지를 가져올 수 있게 했습니다.

 

이때 LocaleContextHolder 를 통해 Locale 정보를 가져와서 로케일 정보를 함께 전달하여 사용하도록 했습니다.

 

여기서 이런 궁금증이 나오실 겁니다.

 

LocaleResolver를 설정했을 뿐이지 LocaleContextHolder 를 사용하는 코드는 작성하는 일이 없었지 않느냐?

LocaleContextHolder에 Locale 은 어디서 설정되는건가?

 

이 부분은 이후 후술하도록 하겠습니다.


2. 메시지/국제화 테스트

 

 

이번에는 테스트를 작성해보겠습니다.

 

package com.ttasjwi.board.system

import org.springframework.boot.autoconfigure.SpringBootApplication
import org.springframework.boot.context.properties.ConfigurationPropertiesScan
import org.springframework.boot.runApplication

@SpringBootApplication
@ConfigurationPropertiesScan
class MessageTestApplication

fun main(args: Array<String>) {
    runApplication<MessageTestApplication>(*args)
}

 

스프링부트 테스트를 위해, 인접한 곳에 메인클래스를 하나 만들어둡니다.

 

spring.application.name: board-system-external-message-test

spring:
  profiles:
    active: test
  config:
    import:
      - message-config.yml

---

 

테스트폴더 application.yml 에서는 test 프로필로 실행하도록 설정하고, message-config.yml 을 가져오도록 합니다.

 

package com.ttasjwi.board.system.core.message

import org.assertj.core.api.Assertions.assertThat
import org.junit.jupiter.api.DisplayName
import org.junit.jupiter.api.Test
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.boot.test.context.SpringBootTest
import org.springframework.context.i18n.LocaleContextHolder
import org.springframework.test.context.ActiveProfiles
import java.util.*

@SpringBootTest
@ActiveProfiles("test")
@DisplayName("MessageResolverImpl 작동 테스트")
class MessageResolverImplTest @Autowired constructor(
    private val messageResolver: MessageResolver
) {

    @Test
    @DisplayName("일반 메시지 - 한국어 테스트")
    fun generalMessageKoreanTest() {
        LocaleContextHolder.setLocale(Locale.KOREAN)

        val code = "Example"

        val message = messageResolver.resolveMessage(code)
        val description = messageResolver.resolveDescription(code, listOf(1, 2, "야옹"))

        assertThat(message).isEqualTo("예제 메시지")
        assertThat(description).isEqualTo("예제 설명(args=1,2,야옹)")
    }

    @Test
    @DisplayName("일반 메시지 - 영어 테스트")
    fun generalMessageEnglishTest() {
        LocaleContextHolder.setLocale(Locale.ENGLISH)

        val code = "Example"

        val message = messageResolver.resolveMessage(code)
        val description = messageResolver.resolveDescription(code, listOf(1, 2, "nyaa"))

        assertThat(message).isEqualTo("Example Message")
        assertThat(description).isEqualTo("Example Description(args=1,2,nyaa)")
    }

    @Test
    @DisplayName("에러 메시지 - 한국어 테스트")
    fun errorMessageKoreanTest() {
        LocaleContextHolder.setLocale(Locale.KOREAN)

        val code = "Error.NullArgument"

        val message = messageResolver.resolveMessage(code)
        val description = messageResolver.resolveDescription(code, listOf("username"))

        assertThat(message).isEqualTo("필수값 누락")
        assertThat(description).isEqualTo("'username'은(는) 필수입니다.")
    }

    @Test
    @DisplayName("에러 메시지 - 영어 테스트")
    fun errorMessageEnglishTest() {
        LocaleContextHolder.setLocale(Locale.ENGLISH)

        val code = "Error.NullArgument"

        val message = messageResolver.resolveMessage(code)
        val description = messageResolver.resolveDescription(code, listOf("username"))

        assertThat(message).isEqualTo("Missing required value")
        assertThat(description).isEqualTo("The field 'username' is required.")
    }
}

 

MessageResolverImpl 이 잘 작동하는 지 테스트하는 부분입니다.

 

LocaleContextHolder 를 통해 로케일을 설정하고, 그 로케일 설정을 기반으로 MessageResolverImpl 이 우리가 의도한 대로 잘 작동하는 지 테스트하는 것입니다.

 

 

package com.ttasjwi.board.system

import com.ttasjwi.board.system.core.message.MessageResolver
import org.springframework.context.i18n.LocaleContextHolder
import org.springframework.http.ResponseEntity
import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.RestController


@RestController
class MessageTestController(
    private val messageResolver: MessageResolver
) {

    @GetMapping("/api/test")
    fun test(): ResponseEntity<MessageTestResponse> {
        val locale = LocaleContextHolder.getLocale()

        val code = "Example"
        val args = listOf(1, 2, 3)
        val message = messageResolver.resolveMessage(code)
        val description = messageResolver.resolveDescription(code, args)

        return ResponseEntity.ok(
            MessageTestResponse(
                locale = locale.toString(),
                code = code,
                message = message,
                description = description
            )
        )
    }

    class MessageTestResponse(
        val locale: String,
        val code: String,
        val message: String,
        val description: String
    )
}

 

이건 중형테스트 목적으로 작성한 컨트롤러인데요.

Spring MVC 와 결합해서 실행한 뒤, Mock HTTP 요청을 보냈을 때 제가 작성한 국제화/메시지가 잘 적용되어지는 지 확인하기 위함입니다.

 

package com.ttasjwi.board.system.core.message

import com.ttasjwi.board.system.MessageTestController
import com.ttasjwi.board.system.core.config.MessageConfig
import com.ttasjwi.board.system.core.config.MessageProperties
import org.junit.jupiter.api.DisplayName
import org.junit.jupiter.api.Test
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.boot.context.properties.EnableConfigurationProperties
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest
import org.springframework.context.annotation.Import
import org.springframework.http.MediaType
import org.springframework.test.context.ActiveProfiles
import org.springframework.test.web.servlet.MockMvc
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get
import org.springframework.test.web.servlet.result.MockMvcResultHandlers.print
import org.springframework.test.web.servlet.result.MockMvcResultMatchers.*
import java.util.*

@ActiveProfiles("test")
@WebMvcTest(controllers = [MessageTestController::class])
@EnableConfigurationProperties(MessageProperties::class)
@Import(value = [MessageConfig::class, MessageResolverImpl::class])
@AutoConfigureMockMvc
@DisplayName("WebMvc에서 메시지/국제화가 잘 적용되는 지 테스트")
class WebMvcLocaleTest {

    @Autowired
    private lateinit var mockMvc: MockMvc

    @Test
    @DisplayName("Accept Language 헤더가 없을 때 디폴트 로케일인 한국어 메시지 설정을 따른다.")
    fun test1() {
        mockMvc
            .perform(get("/api/test"))
            .andDo(print())
            .andExpectAll(
                status().isOk,
                content().contentType(MediaType.APPLICATION_JSON),
                jsonPath("$.locale").value(Locale.KOREAN.toString()),
                jsonPath("$.code").value("Example"),
                jsonPath("$.message").value("예제 메시지"),
                jsonPath("$.description").value("예제 설명(args=1,2,3)"),
            )
    }

    @Test
    @DisplayName("Accept Language 헤더가 한국어로 설정되면 한국어 메시지가 반환된다.")
    fun test2() {
        mockMvc
            .perform(get("/api/test")
                .header("Accept-Language", "ko"))  // 한국어를 우선으로 설정
            .andDo(print())
            .andExpectAll(
                status().isOk,
                content().contentType(MediaType.APPLICATION_JSON),
                jsonPath("$.locale").value(Locale.KOREAN.toString()),
                jsonPath("$.code").value("Example"),
                jsonPath("$.message").value("예제 메시지"),
                jsonPath("$.description").value("예제 설명(args=1,2,3)"),
            )
    }

    @Test
    @DisplayName("Accept Language 헤더가 여러 언어를 포함하고, 한국어가 우선시되면 한국어 응답이 나간다.")
    fun test3() {
        mockMvc
            .perform(get("/api/test")
                .header("Accept-Language", "ko;q=0.9, en;q=0.8"))  // 한국어를 우선
            .andDo(print())
            .andExpectAll(
                status().isOk,
                content().contentType(MediaType.APPLICATION_JSON),
                jsonPath("$.locale").value(Locale.KOREAN.toString()),
                jsonPath("$.code").value("Example"),
                jsonPath("$.message").value("예제 메시지"),
                jsonPath("$.description").value("예제 설명(args=1,2,3)"),
            )
    }

    @Test
    @DisplayName("Accept Language 헤더가 영어로 설정되면 영어 응답이 나간다.")
    fun test4() {
        mockMvc
            .perform(get("/api/test")
                .header("Accept-Language", "en"))  // 영어를 우선
            .andDo(print())
            .andExpectAll(
                status().isOk,
                content().contentType(MediaType.APPLICATION_JSON),
                jsonPath("$.locale").value(Locale.ENGLISH.toString()),
                jsonPath("$.code").value("Example"),
                jsonPath("$.message").value("Example Message"),
                jsonPath("$.description").value("Example Description(args=1,2,3)"),
            )
    }

    @Test
    @DisplayName("Accept Language 헤더가 여러 언어를 포함하고, 영어가 우선시되면 영어 응답이 전송된다.")
    fun test5() {
        mockMvc
            .perform(get("/api/test")
                .header("Accept-Language", "en;q=0.9, ko;q=0.8"))  // 영어를 우선
            .andDo(print())
            .andExpectAll(
                status().isOk,
                content().contentType(MediaType.APPLICATION_JSON),
                jsonPath("$.locale").value(Locale.ENGLISH.toString()),
                jsonPath("$.code").value("Example"),
                jsonPath("$.message").value("Example Message"),
                jsonPath("$.description").value("Example Description(args=1,2,3)"),
            )
    }

}

 

 

WebMvc 와 결합했을 때 잘 작동하는 지 확인하는 테스트입니다.

 

1. Accept-Language 헤더가 없는 경우

2. Accept-Language 헤더에 ko만 있는 경우

3. Accept-Language 헤더에 en만 있는 경우

4. Accept-Language 헤더에 ko, en 이 있는데 ko 우선도가 높은 경우

5. Accept-Language 헤더에 ko, en 이 있는데 en 우선도가 높은 경우

 

총 5가지 경우로 케이스 분류를 하고 각 경우에 대해서 테스트를 해봤습니다.

 

 

 

일단 테스트가 잘 작동됩니다.


 

3. 국제화 동작 원리 및 현재 가진 문제점

일단 이렇게 국제화가 동작하는 것은 확인했는데, 어떤 원리로 동작하는 지 제가 분석한 내용을 기반으로 이야기 해보도록 하겠습니다. 약간 틀릴 수 도 있는 점 양해 부탁드립니다!

 

3.1 LocaleContextHolder / LocaleContext

public final class LocaleContextHolder {

	private static final ThreadLocal<LocaleContext> localeContextHolder =
			new NamedThreadLocal<>("LocaleContext");

	private static final ThreadLocal<LocaleContext> inheritableLocaleContextHolder =
			new NamedInheritableThreadLocal<>("LocaleContext");

	// Shared default locale at the framework level
	@Nullable
	private static Locale defaultLocale;

 

우선 LocaleContextHolder 라는 개념을 설명하고 가겠습니다.

 

스프링에서는 LocaleContextHolder 라는 유틸 클래스를 제공하며, 현재 스레드 사용자의 로케일 정보를 스레드가 유효한 동안 저장하여 관리할 수 있습니다.

 

내부적으로 ThreadLocal 을 사용하여 구현되어 있습니다.

서블릿 기반의 Spring Web (Spring MVC)에서는 사용자 요청마다 스레드가 하나씩 할당되므로, 현재 요청의 로케일 정보를 저장하는 도구라고 생각하시면 될듯합니다.

 

public interface LocaleContext {

    /**
     * Return the current Locale, which can be fixed or determined dynamically,
     * depending on the implementation strategy.
     * @return the current Locale, or {@code null} if no specific Locale associated
     */
    @Nullable
    Locale getLocale();

}

 

LocaleContext 는 로케일을 한단계 감싼 래퍼 역할이라고 생각하시면 될 것 같습니다.

 

 

3.2 LocaleContextHolder 의 Locale 설정 작업이 일어나는 지점

 

 

 

사용자의 로케일은 크게 두 군데에서 설정이 일어납니다.

한 곳은 RequestContextFilter 이고, 또 다른 곳은 DispatcherServlet(FrameworkServlet) 에서 일어납니다.

 

@Bean
@ConditionalOnMissingBean({ RequestContextListener.class, RequestContextFilter.class })
@ConditionalOnMissingFilterBean(RequestContextFilter.class)
public static RequestContextFilter requestContextFilter() {
    return new OrderedRequestContextFilter();
}
public class OrderedRequestContextFilter extends RequestContextFilter implements OrderedFilter {
    private int order = -105;

    public OrderedRequestContextFilter() {
    }

    public int getOrder() {
        return this.order;
    }

    public void setOrder(int order) {
        this.order = order;
    }
}

 

 

RequestContextFilter는 사용자 요청이 들어왔을 때 요청과 관련된 컨텍스트를 설정하는 필터인데요.

SpringBootWebSecurityConfiguration 에서 자동구성되어 서블릿 필터로 등록됩니다.

 

여기서 기본 필터의 순서값은 -105 로 지정되는 것을 주의해줍시다.

 

	@Override
	protected void doFilterInternal(
			HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
			throws ServletException, IOException {

		ServletRequestAttributes attributes = new ServletRequestAttributes(request, response);
		initContextHolders(request, attributes);

		try {
			filterChain.doFilter(request, response);
		}
		finally {
			resetContextHolders();
			if (logger.isTraceEnabled()) {
				logger.trace("Cleared thread-bound request context: " + request);
			}
			attributes.requestCompleted();
		}
	}

	private void initContextHolders(HttpServletRequest request, ServletRequestAttributes requestAttributes) {
		LocaleContextHolder.setLocale(request.getLocale(), this.threadContextInheritable);
		RequestContextHolder.setRequestAttributes(requestAttributes, this.threadContextInheritable);
		if (logger.isTraceEnabled()) {
			logger.trace("Bound request context to thread: " + request);
		}
	}

 

RequestContextFilter 쪽에서는 Request 에서 Locale 을 꺼내오고 이를 LocaleContextHolder에 저장합니다.

 

여기서 문제점은 LocaleResolver 를 사용하지 않고 Request 를 통해 Locale을 바로 가져온다는 점입니다.

HttpServletRequest 구현체의사양에 따라 Locale 을 가져오는 방식이 달라질 수 있어서 어떤 방식으로 로케일이 설정되는지 확실하지 않을 수 있습니다.

 

간단히 말하면, 어떤 Locale 이 설정되는지 Request 구현코드를 까서 봐야만 알 수 있고 확실하지 않아서 예상치 못 한 버그를 야기시킬 수 있다는 것입니다.

 

protected final void processRequest(HttpServletRequest request, HttpServletResponse response)
       throws ServletException, IOException {
       
    // 생략
    // 별도로 buildLocaleContext 를 호출해서 별도의 로케일을 새로 할당함 
    LocaleContext previousLocaleContext = LocaleContextHolder.getLocaleContext();
    LocaleContext localeContext = buildLocaleContext(request);

    RequestAttributes previousAttributes = RequestContextHolder.getRequestAttributes();
    ServletRequestAttributes requestAttributes = buildRequestAttributes(request, response, previousAttributes);

    WebAsyncManager asyncManager = WebAsyncUtils.getAsyncManager(request);
    asyncManager.registerCallableInterceptor(FrameworkServlet.class.getName(), new RequestBindingInterceptor());
    
    // LocaleContext 재설정
    initContextHolders(request, localeContext, requestAttributes);

    try {
       doService(request, response);
    }

 

 

그리고 이런 필터들이 끝나고 나서 DispatcherServlet으로 오게 됩니다.

우리가 흔히 알고 있는 Spring MVC 의 시작점인 DispatcherServlet은 FrameworkServlet을 상속하고 있고 여기서 로케일 컨텍스트 재설정이 일어납니다.(FrameworkServlet 쪽 코드를 함께 읽어야합니다.)

 

buildLocaleContext 를 호출하게 되는데, 이 부분은 DispatcherSerlvet 쪽에서 오버라이드 하고 있습니다.)

 

@Override
protected LocaleContext buildLocaleContext(final HttpServletRequest request) {
    LocaleResolver lr = this.localeResolver;
    if (lr instanceof LocaleContextResolver localeContextResolver) {
       return localeContextResolver.resolveLocaleContext(request);
    }
    else {
       return () -> (lr != null ? lr.resolveLocale(request) : request.getLocale());
    }
}


여기서는 request를 통해 로케일을 가져오지 않고

LocaleResolver가 있다면 LocaleResolver 를 통해, 사용자 로케일 정보를 가져오는 것을 위임하고 있습니다.

 

요청 객체 구현 사양에 따라 가져오는 것 대신, 스프링의 LocaleResolver를 통해 로케일을 resolver 하도록 하는 것이죠.

 

LocaleResolver 관련 내용은 위에서 한 번 다뤘으니 그 부분을 다시 봐주세요.

 

private void initContextHolders(HttpServletRequest request,
       @Nullable LocaleContext localeContext, @Nullable RequestAttributes requestAttributes) {

    if (localeContext != null) {
       LocaleContextHolder.setLocaleContext(localeContext, this.threadContextInheritable);
    }
    if (requestAttributes != null) {
       RequestContextHolder.setRequestAttributes(requestAttributes, this.threadContextInheritable);
    }
}

 

결국 DispatcherServlet 에서는 LocaleResolver가 있다면 이를 우선시해서 사용자의 Locale을 가져오고 이 값으로 로케일컨텍스트 홀더값을 덮어씌웁니다.

 

이 RequestContextFilter -> DispatcherServlet 순으로 이루어지는 LocaleContextHolder 설정 작업을 거치게되고

 

override fun resolveMessage(code: String): String {
    val messageSource = selectMessageSource(code)
    return messageSource.getMessage("$code.message", null, LocaleContextHolder.getLocale())
}

 

이후 제가 작성한 MessageResolverImpl 코드가 작동하면 의도한대로 잘 작동할 것입니다.

DispatcherServlet 이후에 작동한다면요.

 

 

 

 

지금은 스프링 시큐리티를 따로 설정하지 않았는데

 

DispatcherServlet에 들어오지 않고 MessageResoslverImpl 이 호출된다면 위의 LocaleResolver 를 기반으로 한 로케일 설정이 작동되지 않으므로 의도한대로 로케일이 먹히지 않을 가능성이 생겨요.

 

서블릿으로 넘기지 않고 필터체인에서 응답 메시지를 작성할 때가 있는데 이럴 때 LocaleResolverImpl 을 호출한다면 Locale 설정이 완전히 되지 않은 상태에서 LocaleCocntextHolder 에게 로케일을 요청하게 되고, 의도치 않은 로케일 값을 꺼내오는 결과를 낳을 수 있겠죠.

 


 

4. 로케일 필터 추가 설정

 

package com.ttasjwi.board.system.core.locale

import jakarta.servlet.FilterChain
import jakarta.servlet.http.HttpServletRequest
import jakarta.servlet.http.HttpServletResponse
import org.springframework.context.i18n.LocaleContextHolder
import org.springframework.web.filter.OncePerRequestFilter
import org.springframework.web.servlet.LocaleResolver

class CustomLocaleContextFilter(
    private val localeResolver: LocaleResolver
) : OncePerRequestFilter() {

    override fun doFilterInternal(
        request: HttpServletRequest,
        response: HttpServletResponse,
        filterChain: FilterChain
    ) {
        val locale = localeResolver.resolveLocale(request)
        LocaleContextHolder.setLocale(locale)

        try {
            filterChain.doFilter(request, response)
        } finally {
            LocaleContextHolder.resetLocaleContext()
        }
    }
}

 

LocaleResolver를 의존성으로 가지고 있는 필터를 하나 만들었습니다.

localeResolver를 통해 현재 요청의 Locale 추출을 위임하고 그 결과를 LocaleContextHolder 에 저장합니다.

 

package com.ttasjwi.board.system.core.config

import com.ttasjwi.board.system.core.locale.CustomLocaleContextFilter
import org.springframework.boot.web.servlet.FilterRegistrationBean
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration
import org.springframework.web.servlet.LocaleResolver

@Configuration
class LocaleConfig {

    companion object {

        /**
         * 스프링 자동구성에 등록된 RequestContextFilter 의 순서가 -105 로 되어있으므로 이 보다 순서를 더 뒤에 실행되도록 함
         * RequestContextFilter : -105
         * 스프링 시큐리티 필터(DelegatingFilterProxy) 기본 순서는 -100 이므로 로케일 설정이 스프링 시큐리티보다 먼저 시행됨
         */
        private const val LOCALE_FILTER_ORDER = -104
    }

    @Bean
    fun customFilterRegistration(localeResolver: LocaleResolver): FilterRegistrationBean<CustomLocaleContextFilter> {
        val registration = FilterRegistrationBean<CustomLocaleContextFilter>()
        registration.filter = CustomLocaleContextFilter(localeResolver)
        registration.order = LOCALE_FILTER_ORDER

        return registration
    }
}

 

이 필터는 FilterRegistrationBean 을 빈으로 등록하여 필터 등록을 해줄건데요.

순서값은 -104 로 지정했습니다.

 

그 이유는 RequestContextFilter 의 기본 순서가 -105 로 되어 있고

 

// SecurityFilterAutoConfiguration
@Bean
@ConditionalOnBean(
    name = {"springSecurityFilterChain"}
)
public DelegatingFilterProxyRegistrationBean securityFilterChainRegistration(SecurityProperties securityProperties) {
    DelegatingFilterProxyRegistrationBean registration = new DelegatingFilterProxyRegistrationBean("springSecurityFilterChain", new ServletRegistrationBean[0]);
    registration.setOrder(securityProperties.getFilter().getOrder());
    registration.setDispatcherTypes(this.getDispatcherTypes(securityProperties));
    return registration;
}
@ConfigurationProperties(prefix = "spring.security")
public class SecurityProperties {

	public static final int DEFAULT_FILTER_ORDER = OrderedFilter.REQUEST_WRAPPER_FILTER_MAX_ORDER - 100;
	private final Filter filter = new Filter();

	private final User user = new User();

	public User getUser() {
		return this.user;
	}

	public Filter getFilter() {
		return this.filter;
	}

	public static class Filter {

		private int order = DEFAULT_FILTER_ORDER;

 

스프링 시큐리티 필터의  순서가 -100 으로 잡혀있기 때문입니다.

 

 

 

이렇게 설정하면 RequestContextFilter -> MyLocaleContextFilter -> Spring  Security 순으로 실행되고

스프링 시큐리티 필터쪽에서 응답 메시지를 내릴 때 MessageResolver를 사용할 경우,

로케일 설정 역시 LocaleResolver를 기반으로 로케일을 가져와서 국제화 기능을 작동시킬 수 있을겁니다.

 

 

class CustomLocaleContextFilter(
    private val localeResolver: LocaleResolver
) : OncePerRequestFilter() {

    companion object {
        private val log = getLogger(CustomLocaleContextFilter::class.java)
    }


    override fun doFilterInternal(
        request: HttpServletRequest,
        response: HttpServletResponse,
        filterChain: FilterChain
    ) {

        log.info{ "필터 이전 로케일 = ${LocaleContextHolder.getLocale()}"}
        val locale = localeResolver.resolveLocale(request)
        LocaleContextHolder.setLocale(locale)

        log.info{ "필터 이후 로케일 = $locale"}

 

실제로 CustomLocaleContextFilter 에 로그를 남겨볼게요.

필터 이전 로케일 값, 필터 이후 로케일 값을 각각 출력해보도록 합시다.

 

필터 이전에는 RequestContextFilter 에서 셋팅해준 로케일이 사용될 것이고

필터 이후에는 제가 커스텀하게 설정한 로케일이 셋팅될거에요.

 

@ActiveProfiles("test")
@WebMvcTest(controllers = [MessageTestController::class])
@EnableConfigurationProperties(MessageProperties::class)
@Import(value = [MessageConfig::class, LocaleConfig::class, MessageResolverImpl::class])
@AutoConfigureMockMvc
@DisplayName("WebMvc에서 메시지/국제화가 잘 적용되는 지 테스트")
class WebMvcLocaleTest {

 

WebMvcLocaleTest 쪽에서는 LocaleConfig 을 설정으로 포함시키도록 하여 테스트를 실행하도록 수정해서

테스트를 실행해볼게요.

 

CustomLocaleContextFilter    : 필터 이전 로케일 = en
CustomLocaleContextFilter    : 필터 이후 로케일 = ko

 

Accept-Language 헤더를 담지 않은 요청을 보낸 테스트 케이스의 경우,

CustomLocaleContextFilter 를 거치면서 로케일이 ko 로 바뀐 것을 로그로 확인할 수 있습니다.

 

만약 이런 조치를 취하지 않았다면 나중에 (스프링 시큐리티를 쓴다면) 스프링 시큐리티 필터에서 메시지 처리를 했을 때 문제가 발생할 수 있었을겁니다.

 

 

테스트 역시 잘 통과합니다.


5. API 응답 규격

 

이제 메시지/국제화 기능이 잘 동작하는 것을 확인했으니 API 응답 규격을 변경해보겠습니다.

 

rootProject.name = "board-system"

include(

    // 설정 최종 구성 및 실행
    "board-system-container",

    // 공용 모듈
    "board-system-core",
    "board-system-logging",

    // api
    "board-system-api:api-core",
    "board-system-api:api-deploy",

    // external : 외부 기술 의존성
    "board-system-external:external-message",
)

 

루트의 settings.gradle.kts 에서는 새로 모듈을 선언하겠습니다.

api-core 모듈은 모든 api 모듈들에서 공통적으로 의존하는 것들을 모아두기로 했습니다.

 

dependencies {
    implementation(Dependencies.SPRING_BOOT_STARTER.fullName)

    // api
    implementation(project(":board-system-api:api-core"))
    implementation(project(":board-system-api:api-deploy"))

    // external
    implementation(project(":board-system-external:external-message"))
}

tasks.getByName("bootJar") {
    enabled = true
}

tasks.getByName("jar") {
    enabled = false
}

 

컨테이너에서는 api-core 를 의존하게 했고(당장은 쓰진 않겠지만 통합 테스트를 작성할 일이 생기면 쓸 수 있을 것 같아서 등록했습니다.)

 

dependencies {
    implementation(project(":board-system-api:api-core"))
    implementation(Dependencies.SPRING_BOOT_WEB.fullName)
    implementation(Dependencies.KOTLIN_JACKSON.fullName)
}

 

 

api-deploy 에서도, api-core 를 의존하게 했습니다.

 

package com.ttasjwi.board.system.core.api

abstract class ApiResponse(
    val isSuccess: Boolean,
    val code: String,
    val message: String,
    val description: String,
)

class SuccessResponse<T>(
    code: String,
    message: String,
    description: String,
    val data: T
): ApiResponse(
    isSuccess = true,
    code = code,
    message = message,
    description = description
)

class ErrorResponse(
    code: String,
    message: String,
    description: String,
    val errors: List<ErrorItem>
): ApiResponse(false, code, message, description) {

    class ErrorItem (
        val code: String,
        val message: String,
        val description: String,
        val source: String,
    )
}

 

ApiResponse.kt 파일입니다.

모든 Api 응답의 표준으로 ApiResponse 를 정의하고

 

성공일 경우 SuccessResponse, 실패일 경우 ErrorResponse 형태로 응답되도록 했어요.

성공일 경우 isSuccess 가 true, 실패일 경우 false 가 전달됩니다.

 

code 는 Api 처리 결과를 간단하게 설명하는 코드

message는 code에 대응하는 한 줄 설명

description 은 message 의 상세 설명입니다.

 

성공할 경우, data 형태로 응답 데이터를 전달하고 실패할 경우 errors 형태로, 1개 이상의 에러 응답들을 추가로 담도록 했어요.

 

package com.ttasjwi.board.system.core.api

import org.assertj.core.api.Assertions.assertThat
import org.junit.jupiter.api.DisplayName
import org.junit.jupiter.api.Test

@DisplayName("ApiResponse 테스트")
class ApiResponseTest {

    private class SampleSuccessResponse (
        val example: String
    )

    @Test
    @DisplayName("SuccessResponse 의 isSuccess 값은 true 이다.")
    fun successResponseTest() {
        val response = SuccessResponse(
            code = "code",
            message = "message",
            description = "description",
            data = SampleSuccessResponse(
                example = "example"
            )
        )
        assertThat(response.isSuccess).isTrue()
        assertThat(response.code).isEqualTo("code")
        assertThat(response.message).isEqualTo("message")
        assertThat(response.description).isEqualTo("description")
        assertThat(response.data.example).isEqualTo("example")
    }


    @Test
    @DisplayName("ErrorResponse 의 isSuccess 값은 false 이다.")
    fun errorResponseTest() {
        val response = ErrorResponse(
            code = "code",
            message = "message",
            description = "description",
            errors = listOf(
                ErrorResponse.ErrorItem(
                    code = "ErrorCode",
                    message = "ErrorMessage",
                    description = "ErrorDescription",
                    source = "something"
                )
            )
        )
        assertThat(response.isSuccess).isFalse
        assertThat(response.code).isEqualTo("code")
        assertThat(response.message).isEqualTo("message")
        assertThat(response.description).isEqualTo("description")
        assertThat(response.errors[0].code).isEqualTo("ErrorCode")
        assertThat(response.errors[0].message).isEqualTo("ErrorMessage")
        assertThat(response.errors[0].description).isEqualTo("ErrorDescription")
        assertThat(response.errors[0].source).isEqualTo("something")
    }
}

 

간단하게 테스트코드도 작성해봤습니다.

 

SuccessResponse 는 isSuccess 값이 true 이고, ErrorResponse 는 isSuccess 값이 false 인지 확인하고

전달한 값들이 그대로 잘 return 되는 지 확인합니다.

 

 

빌드테스트가 잘 통과됩니다.

 


 

6. 신규 API 규격을 기존 HealthCheck API 에 적용해보기

새로 만든 API 규격을 국제화 기능 테스트도 겸해서 기존 만들어둔 HealthCheck API 에 반영해보겠습니다.

 

package com.ttasjwi.board.system.deploy.api

class HealthCheckResponse (
    val profile: String,
)

 

우선 HealthCheckResponse 입니다. 현재 프로필값을 명시적으로 profile 필드를 통해 응답하도록 했어요.

 

package com.ttasjwi.board.system.deploy.api

import com.ttasjwi.board.system.core.api.SuccessResponse
import com.ttasjwi.board.system.core.message.MessageResolver
import com.ttasjwi.board.system.deploy.config.DeployProperties
import org.springframework.http.ResponseEntity
import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.RestController

@RestController
class HealthCheckController(
    private val messageResolver: MessageResolver,
    private val deployProperties: DeployProperties,
) {

    @GetMapping("/api/v1/deploy/health-check")
    fun healthCheck(): ResponseEntity<SuccessResponse<HealthCheckResponse>> {
        val code = "HealthCheck.Success"
        val args = listOf("$.data.profile")
        val response = SuccessResponse(
            code = code,
            message = messageResolver.resolveMessage(code),
            description = messageResolver.resolveDescription(code, args),
            data = HealthCheckResponse(
                profile = deployProperties.profile
            )
        )
        return ResponseEntity.ok(response)
    }
}

 

그리고 이어서, HealthCheckController 를 수정했습니다.

 

code 는 HealthCheck 가 성공했다는 의미를 담은 "HealthCheck.Success"

args는 응답의 "$.data.profile" 을 응답 Description 문자열에 포함시키기 위해 지정했습니다.

 

앞서 만들어 둔 MessageResolver를 통해 메시지를 가져오도록 했습니다.

 

 

그리고 이렇게 커밋하면 빌드 테스트가 깨집니다.

 

테스트 코드를 새로 API 변경에 맞춰 변경하지 않았고

MessageResolver 의존성을 추가했으나 해당 빈이 api-deploy 모듈에서는 의존성에 포함되지 않기 때문입니다.

 

 

테스트 코드를 변경해보도록 하겠습니다.

 

package com.ttasjwi.board.system.core.message.fixture

import com.ttasjwi.board.system.core.message.MessageResolver

class MessageResolverFixture : MessageResolver {

    override fun resolveMessage(code: String): String {
        return "$code.message"
    }

    override fun resolveDescription(code: String, args: List<Any?>): String {
        return "$code.description(args=$args)"
    }
}

 

일단 MessageResolver 의 구현체로 MessageResolverFixture를 간단하게 정의했습니다.

code 가 전달되면 code.message / code.description(args=$args) 가 전달되도록 간단하게 구현했어요.

 

여기서 의문이 하나 드실 수 있습니다.

우리는 이미 MessageResolverImpl 을 external-message 모듈에 구현해뒀으니 이걸 스프링 빈으로 등록한뒤 끌어서 사용하면 되지 않을까 말이죠.

 

하지만 MessageResolverImpl 은 스프링 부트 의존성을 끌어서 MessageResolver 를 구현했습니다. 향후 변경할 여지도 있을 수 있고 api-deploy 모듈에서 external-message 모듈을 의존해야하는 문제가 생깁니다.

 

가능한 external 계층에서 구현한 개별 구현체들을 의존성으로 직접 포함하는 것은 container 에서만 하도록 하고 나머지 모듈들에서는 하지 않도록 할거에요.

 

다른 모듈에서 특정 기술을 사용해서 복잡하게 만든 구현체를 빈으로 등록하기 보다 테스트를 좀더 간소하게 하기 위해서 픽스쳐를 사용하는 것이 테스트하기 더 간편하기 때문에 Fixture를 사용하겠습니다.

 

package com.ttasjwi.board.system.core.message.fixture

import org.assertj.core.api.Assertions.assertThat
import org.junit.jupiter.api.DisplayName
import org.junit.jupiter.api.Test

@DisplayName("MessageResolverFixture 테스트")
class MessageResolverFixtureTest {

    private val messageResolver = MessageResolverFixture()

    @Test
    @DisplayName("resolveMessage: code를 전달하면 code 뒤에 code.message 을 반환한다.")
    fun testGeneralTitle() {
        val code = "hello"
        val title = messageResolver.resolveMessage(code)
        assertThat(title).isEqualTo("$code.message")
    }

    @Test
    @DisplayName("resolveDescription: code와 args를 전달하면 `code.description(args=[args원소들])'을 반환한다.")
    fun testDescription() {
        val code = "hello"
        val args = listOf(1, 2)
        val description = messageResolver.resolveDescription(code, args)
        assertThat(description).isEqualTo("$code.description(args=[1, 2])")
    }

    @Test
    @DisplayName("resolveDescription: code만 전달하면 `code.description(args=[])'을 반환한다.")
    fun testDescriptionOnlyCode() {
        val code = "hello"
        val description = messageResolver.resolveDescription(code)
        assertThat(description).isEqualTo("$code.description(args=[])")
    }

    @Test
    @DisplayName("resolveDescription: code와 args(빈리스트)를 전달하면 `code.description(args=[])'을 반환한다.")
    fun testDescriptionWhenArgsNull() {
        val code = "hello"
        val description = messageResolver.resolveDescription(code, emptyList())
        assertThat(description).isEqualTo("$code.description(args=[])")
    }
}

 

픽스쳐 역시 테스트 대상이므로 테스트 코드를 작성했습니다.

code 를 전달했을 때 message 가 의도한 대로 반환되는 지

code, args를 전달했을 때 description 이 의도한 대로 반환되는 지 테스트했습니다.

 

package com.ttasjwi.board.system.deploy.api

import com.ttasjwi.board.system.core.message.MessageResolver
import com.ttasjwi.board.system.core.message.fixture.MessageResolverFixture
import com.ttasjwi.board.system.deploy.config.DeployProperties
import org.junit.jupiter.api.DisplayName
import org.junit.jupiter.api.Test
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.boot.context.properties.EnableConfigurationProperties
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest
import org.springframework.boot.test.context.TestConfiguration
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Import
import org.springframework.http.MediaType
import org.springframework.test.context.ActiveProfiles
import org.springframework.test.web.servlet.MockMvc
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get
import org.springframework.test.web.servlet.result.MockMvcResultHandlers.print
import org.springframework.test.web.servlet.result.MockMvcResultMatchers.*

@ActiveProfiles("test")
@WebMvcTest(controllers = [HealthCheckController::class])
@EnableConfigurationProperties(DeployProperties::class)
@AutoConfigureMockMvc
@Import(HealthCheckControllerMiddleTest.FixtureBeanConfig::class)
@DisplayName("HealthCheckController 중형 테스트: 스프링 MVC와 컨트롤러가 잘 결합하여 동작하는가?")
class HealthCheckControllerMiddleTest {

    @Autowired
    private lateinit var mockMvc: MockMvc


    @TestConfiguration
    class FixtureBeanConfig {

        @Bean
        fun messageResolverFixture(): MessageResolver {
            return MessageResolverFixture()
        }
    }

    @Test
    @DisplayName("healthCheck : 현재 서버가 살아있다면 200 상태코드와 함께 프로필 관련 정보를 포함하여 json 으로 응답한다.")
    fun healthCheckTest() {
        mockMvc
            .perform(get("/api/v1/deploy/health-check"))
            .andDo(print())
            .andExpectAll(
                status().isOk,
                content().contentType(MediaType.APPLICATION_JSON),
                jsonPath("$.isSuccess").value(true),
                jsonPath("$.code").value("HealthCheck.Success"),
                jsonPath("$.message").value("HealthCheck.Success.message"),
                jsonPath("$.description").value("HealthCheck.Success.description(args=[\$.data.profile])"),
                jsonPath("$.data.profile").value("test")
            )
    }
}

 

그리고 HealthCheckController 테스트 코드 쪽에서는

 

MessageResolverFixture 를 @TestConfiguration 을 사용해 테스트용 빈으로 등록하여 테스트코드가 잘 작동되도록 하고

새 API 사양에 맞게 응답이 잘 오는 지 테스트를 수정합니다.

 

 

이렇게 하면 테스트가 잘 작동되어 빌드테스트를 통과합니다.

 

# external-message/src/main/resources/general-message_ko.yml
HealthCheck.Success:
  message: "헬스체크 성공"
  description: "헬스체크에 성공했습니다. 현재 서버 프로필은 ''{0}''필드를 참고하세요."

Example:
  message: "예제 메시지"
  description: "예제 설명(args={0},{1},{2})"
# external-message/src/main/resources/general-message_en.yml
HealthCheck.Success:
  message: "Health check successful"
  description: "Health check was successful. Please refer to the ''{0}'' field for the current server profile."

Example:
  message: "Example Message"
  description: "Example Description(args={0},{1},{2})"

 

이번엔 external-message 모듈에 작성해둔 메시지 파일을 추가 수정합니다.

 

api-deploy 모듈 테스트를 할때는 fixture를 통해 테스트를 하지만, 실 환경에서는 external-message 모듈 의 MessageResolverImpl 이 스프링부트 MessageSource를 통해 메시지를 가져오니까요.

 

이에 맞춰서 ko, en 각각의 HealthCheck.Success 에 대한 message, description 을 추가 작성했습니다.

 

- name: 배포 대상 포트/PROFILE 확인
  run: |
    response=$(curl -s -w "%{http_code}" "http://${{ secrets.LIVE_SERVER_IP }}/api/v1/deploy/health-check")
    STATUS="${response: -3}"  # 마지막 3글자 (HTTP 상태 코드)
    BODY="${response::-3}"     # 나머지 (응답 본문)

    echo "STATUS=$STATUS"

    if [ "$STATUS" = "200" ]; then
      CURRENT_PROFILE=$(echo "$BODY" | jq -r '.data.profile')
      echo "CURRENT_PROFILE=$CURRENT_PROFILE"
      CURRENT_UPSTREAM="$CURRENT_PROFILE"
    else
      CURRENT_UPSTREAM="green"
    fi

    echo "CURRENT_UPSTREAM=$CURRENT_UPSTREAM"
    
    echo "CURRENT_UPSTREAM=$CURRENT_UPSTREAM" >> $GITHUB_ENV

 

배포 yaml 파일도 수정합니다.

기존에는 응답의 문자열을 그대로 사용했지만 이번에는 json 으로 응답을 받기 때문에 이에 맞춰서 스크립트를 수정했습니다.

 

jq -r 을 사용해 json 응답의 .data.profile 값을 꺼내와 사용하도록 했어요.

 

 

 

 

여기까지 하면 일단 빌드테스트 자체는 잘 되지만

 

 

배포에서 실패합니다. 

 

현재 라이브 서버는 api가  실제 변경되지 않은 상황이라서 json 응답이 오지 않고, 이를 json 으로 파싱하려 시도했으나 실패해서 발생하는 문제입니다.

 

docker ps -al
CONTAINER ID   IMAGE                     COMMAND                  CREATED          STATUS          PORTS                                       NAMES
45cc3d18374a   ttasjwi/board-be:latest   "java -DSpring.profi…"   15 minutes ago   Up 15 minutes   0.0.0.0:8081->8080/tcp, :::8081->8080/tcp   blue


docker stop 45cc3d
45cc3d
docker remove 45cc3d18
45cc3d18

일단 기존에 작동되던 컨테이너를 죽이고 Re-run jobs를 클릭해 다시 배포를 실행해보겠습니다.

 

 

또 실패합니다.

이번엔 기존 배포 컨테이너 정지 과정에서 실패해서 그렇습니다. 기존 컨테이너가 없거든요.

 

하지만, 신규 컨테이너는 띄워졌으므로 배포 자체는 성공했습니다.

 

 

실제로 api 를 호출해보면 잘 응답이 오긴 합니다. blue 가 띄워졌네요.

 

다시 Re-run jobs 를 클릭해 실행해보면 배포 스크립트가 전체 성공합니다.

 


7. 프로덕션 환경에서 국제화는 잘 적용되는가?

이제 프로덕션 환경인 EC2에서 국제화가 잘 적용되는지 확인해보겠습니다.

 

 

기존 헬스체크 요청을 개발자 도구를 통해 확인해보면 Accept-Language 헤더에 ko 가 우선도 높게 전달되었고 그 결과 한국어 응답이 온 것을 추측할 수 있어요.

 

브라우저 환경을 영어로 변경하면 어떻게 될까요?

 

 

 

크롬의 설정 > 언어로 들어갑니다. (주소창에 chrome://settings/languages 로 들어가도 돼요.)

 

여기서 기본 언어 설정이 한국어 > 영어 > ... 순으로 되어있는데요.

영어를 가장 위로 이동시키겠습니다.

 

이 상태로 API 를 다시 호출해보면 영어로 응답이 오는 것을 볼 수 있어요.

(여기서 {0} 으로 온건 무시해주세요. 제가 메시지 파일을 잘못해서 그런건데, 다시 수정했습니다!)

 

 

브라우저(크롬)에서 요청을 작성할 때 Accept-Language 헤더 값을 en 을 우선시해서 작성해줬기 때문이에요.

이를 우리 서버에서 읽고 영어 국제화 파일을 통해 메시지를 가져와서 메시지를 내려준 것입니다.

 

 

Postman 을 통해 Accept-Language 헤더 없이 요청을 보내도 기본 로케일로 설정한 ko 를 기반으로 응답이 오는 것을 확인할 수 있습니다.

 

이것으로 메시지/국제화 기능 / API 응답 규격 설정 포스팅을 마무리하겠습니다. 감사합니다!

 


 

GitHub Repository: https://github.com/ttasjwi/board-system

 

GitHub - ttasjwi/board-system

Contribute to ttasjwi/board-system development by creating an account on GitHub.

github.com

 

 

PR : https://github.com/ttasjwi/board-system/pull/24

 

Comments