Development/Errors

[MockMvc] MockMvc 테스트 시 한글이 깨져서 테스트에 실패하는 문제

개발하는 곰돌이 2024. 6. 19. 16:13

목차

    문제의 상황

    Spring REST Docs를 공부해보기 위해 이전에 작성했던 코드의 컨트롤러 테스트 코드를 MockMvc를 사용해서 작성하고 있었다.

    @SpringBootTest
    @AutoConfigureMockMvc
    @AutoConfigureRestDocs
    class AdminControllerTest @Autowired constructor(
        private val tokenProvider: TokenProvider,
        private val memberRepository: MemberRepository,
        private val mockMvc: MockMvc,
        private val objectMapper: ObjectMapper = ObjectMapper()
    ) {
        @Test
        fun `모든 사용자 찾기`() {
            val token = "Bearer ${tokenProvider.createAccessToken("admin:ADMIN")}"
            // given
            val uuids = memberRepository.saveAll(
                listOf(
                    Member("colabear754", "1234", "콜라곰", type = MemberType.USER),
                    Member("ciderbear754", "1234", "사이다곰", type = MemberType.USER),
                    Member("fantabear754", "1234", "환타곰", type = MemberType.USER)
                )
            ).map { it.id.toString() }
            // when
            val action = mockMvc.perform(get("/admin/members").header(HttpHeaders.AUTHORIZATION, token))
            // then
            action.andExpect { result ->
                val response = result.response
                val (status, message, data) = objectMapper.readValue(response.contentAsByteArray, ApiResponse::class.java)
                response.status shouldBe 200
                status shouldBe ApiStatus.SUCCESS
                message shouldBe null
                (data as List<*>).map { (it as Map<*, *>)["id"] } shouldBe uuids
                data.map { (it as Map<*, *>)["name"] } shouldBe listOf("콜라곰", "사이다곰", "환타곰")
                data.map { (it as Map<*, *>)["type"] } shouldBe List(3) { "USER" }
            }
        }
    }

    근데 별 문제 없이 테스트를 통과할거라는 생각과 달리 테스트가 터져버렸다.

    분명히 테스트를 위해 DB에 집어넣은 이름값을 복붙해서 검증했는데 한글이 깨져버려서 테스트가 터지는 상황이 발생했다.

    문제 원인

    구글 크롬과 같은 주요 브라우저들이 기본으로 UTF-8 문자들을 해석하게 되어 스프링부트 2.2.0(스프링 5.2)부터 기본 인코딩에 굳이 UTF-8을 명시하지 않게 되었다고 한다.

     

    즉, 기존에 Content type = application/json;charset=UTF-8이었던 것이 Content type = application/json으로 변경되었다는 것이다.

     

    하지만 charset=UTF-8이 빠지게 되면서 MockMvc는 응답 Body의 내용을 제대로 인코딩하지 못해 한글이 깨져버린 것이다.

    해결 방안

    생각보다 다양한 해결법이 있었다.

    테스트 응답의 Content Type을 변경

    테스트 응답에 charset=UTF-8이 빠져서 생기는 문제니까 단순히 테스트 응답의 Content type을 application/json;charset=UTF-8로 변경하는 것이다.

    @Test
    fun `모든 사용자 찾기`() {
        val token = "Bearer ${tokenProvider.createAccessToken("admin:ADMIN")}"
        // given
        val uuids = memberRepository.saveAll(
            listOf(
                Member("colabear754", "1234", "콜라곰", type = MemberType.USER),
                Member("ciderbear754", "1234", "사이다곰", type = MemberType.USER),
                Member("fantabear754", "1234", "환타곰", type = MemberType.USER)
            )
        ).map { it.id.toString() }
        // when
        val action = mockMvc.perform(get("/admin/members").header(HttpHeaders.AUTHORIZATION, token))
        // then
        action.andExpect { result ->
            val response = result.response
            response.contentType = MediaType.APPLICATION_JSON_UTF8_VALUE	// 이 줄 추가
            val (status, message, data) = objectMapper.readValue(response.contentAsString, ApiResponse::class.java)
            response.status shouldBe 200
            status shouldBe ApiStatus.SUCCESS
            message shouldBe null
            (data as List<*>).map { (it as Map<*, *>)["id"] } shouldBe uuids
            data.map { (it as Map<*, *>)["name"] } shouldBe listOf("콜라곰", "사이다곰", "환타곰")
            data.map { (it as Map<*, *>)["type"] } shouldBe List(3) { "USER" }
        }
    }

    한글이 깨지는 문제 없이 테스트를 통과하지만 MediaType.APPLICATION_JSON_UTF8이 Deprecated 되었기 때문에 뭔가 찝찝한 해결법이다.

    응답 Body를 ByteArray로 받기

    한글이 깨지는 문제는 응답 Body의 raw 데이터를 문자열로 변환하는 과정에서 발생하는 문제라고 생각해서 raw 데이터 그대로 받는 ByteArray로 받아보았다.

    @Test
    fun `모든 사용자 찾기`() {
        val token = "Bearer ${tokenProvider.createAccessToken("admin:ADMIN")}"
        // given
        val uuids = memberRepository.saveAll(
            listOf(
                Member("colabear754", "1234", "콜라곰", type = MemberType.USER),
                Member("ciderbear754", "1234", "사이다곰", type = MemberType.USER),
                Member("fantabear754", "1234", "환타곰", type = MemberType.USER)
            )
        ).map { it.id.toString() }
        // when
        val action = mockMvc.perform(get("/admin/members").header(HttpHeaders.AUTHORIZATION, token))
        // then
        action.andExpect { result ->
            val response = result.response
            // contentAsString -> contentAsByteArray
            val (status, message, data) = objectMapper.readValue(response.contentAsByteArray, ApiResponse::class.java)
            response.status shouldBe 200
            status shouldBe ApiStatus.SUCCESS
            message shouldBe null
            (data as List<*>).map { (it as Map<*, *>)["id"] } shouldBe uuids
            data.map { (it as Map<*, *>)["name"] } shouldBe listOf("콜라곰", "사이다곰", "환타곰")
            data.map { (it as Map<*, *>)["type"] } shouldBe List(3) { "USER" }
        }
    }

    response에서 Body를 받아오는 부분만 ByteArray로 바꿨을 뿐인데 인코딩 문제 없이 테스트를 통과했다. 아무래도 Jackson 라이브러리의 ObjectMapper에서 ByteArray를 객체로 변환할 때는 인코딩 문제가 없는 듯 하다.

    MockMvc에 인코딩 필터 추가

    MockMvc를 커스터마이징해서 UTF-8로 인코딩하도록 필터를 추가할 수도 있다.

     

    여기서는 커스텀 어노테이션을 사용해서 MockMvc에 커스텀 필터를 적용하도록 했다.

    @Target(AnnotationTarget.CLASS)
    @Retention(AnnotationRetention.RUNTIME)
    @Import(MockMvcConfigWithEncoding.Utf8Encoding::class)
    annotation class MockMvcConfigWithEncoding {
        class Utf8Encoding {
            @Bean
            fun utf8Config() = MockMvcBuilderCustomizer { it.addFilter(CharacterEncodingFilter("UTF-8", true)).build() }
        }
    }

    어노테이션 내부의 클래스 명과 내부 클래스의 메소드 명은 임의로 작성하고, @Import에 작성한 내부 클래스를 넣어서 커스텀 어노테이션이 붙은 클래스는 해당 빈이 적용되도록 설정해준다.

    @SpringBootTest
    @MockMvcConfigWithEncoding	// 작성한 커스텀 어노테이션 추가
    @AutoConfigureMockMvc
    @AutoConfigureRestDocs
    class AdminControllerTest @Autowired constructor(
        private val tokenProvider: TokenProvider,
        private val memberRepository: MemberRepository,
        private val mockMvc: MockMvc,
        private val objectMapper: ObjectMapper = ObjectMapper()
    ) {
        @Test
        fun `모든 사용자 찾기`() {
            val token = "Bearer ${tokenProvider.createAccessToken("admin:ADMIN")}"
            // given
            val uuids = memberRepository.saveAll(
                listOf(
                    Member("colabear754", "1234", "콜라곰", type = MemberType.USER),
                    Member("ciderbear754", "1234", "사이다곰", type = MemberType.USER),
                    Member("fantabear754", "1234", "환타곰", type = MemberType.USER)
                )
            ).map { it.id.toString() }
            // when
            val action = mockMvc.perform(get("/admin/members").header(HttpHeaders.AUTHORIZATION, token))
            // then
            action.andExpect { result ->
                val response = result.response
                val (status, message, data) = objectMapper.readValue(response.contentAsString, ApiResponse::class.java)
                response.status shouldBe 200
                status shouldBe ApiStatus.SUCCESS
                message shouldBe null
                (data as List<*>).map { (it as Map<*, *>)["id"] } shouldBe uuids
                data.map { (it as Map<*, *>)["name"] } shouldBe listOf("콜라곰", "사이다곰", "환타곰")
                data.map { (it as Map<*, *>)["type"] } shouldBe List(3) { "USER" }
            }
        }
    }

    MockMvc의 인코딩 타입을 UTF-8로 변경했기 때문에 한글 깨짐 문제 없이 테스트를 통과한다.

    MockMvcBuilder 커스터마이징

    위 방법과 비슷한데, 필터를 추가하지 않고 MockMvcBuilderCustomizer를 구현한 스프링 빈 클래스를 작성하여 MockMvc의 결과값 인코딩 방식을 UTF-8로 변경하는 방법이다.

    @Component
    class MockMvcEncodingCustomizer : MockMvcBuilderCustomizer {
        override fun customize(builder: ConfigurableMockMvcBuilder<*>?) {
            builder?.alwaysDo { it.response.characterEncoding = "UTF-8" }
        }
    }

    해당 클래스만 작성해서 스프링 빈으로 등록해놓으면 @AutoConfigureMockMvc가 달린 클래스에 자동으로 적용된다고 한다.

    MockMvc 스타일의 테스트코드 작성

    원래 작성했던 테스트 코드는 Kotest를 사용해서 코틀린 스타일의 테스트 코드를 작성해보려고 굳이 응답 Body를 꺼내서 Kotest의 shouldBe로 값을 검증하고 있었다.

     

    이렇게 검증하는 코드를 MockMvc의 andExpect()를 사용하는 코드로 변경해봤다.

    @Test
    fun `모든 사용자 찾기`() {
        val token = "Bearer ${tokenProvider.createAccessToken("admin:ADMIN")}"
        // given
        val uuids = memberRepository.saveAll(
            listOf(
                Member("colabear754", "1234", "콜라곰", type = MemberType.USER),
                Member("ciderbear754", "1234", "사이다곰", type = MemberType.USER),
                Member("fantabear754", "1234", "환타곰", type = MemberType.USER)
            )
        ).map { it.id.toString() }
        // when
        val action = mockMvc.perform(get("/admin/members").header(HttpHeaders.AUTHORIZATION, token))
        // then
        action.andExpect(status().isOk)
            .andExpect(jsonPath("$.status").value(ApiStatus.SUCCESS.name))
            .andExpect(jsonPath("$.data[0].id").value(uuids[0]))
            .andExpect(jsonPath("$.data[0].name").value("콜라곰"))
            .andExpect(jsonPath("$.data[0].type").value(MemberType.USER.name))
            .andExpect(jsonPath("$.data[1].id").value(uuids[1]))
            .andExpect(jsonPath("$.data[1].name").value("사이다곰"))
            .andExpect(jsonPath("$.data[1].type").value(MemberType.USER.name))
            .andExpect(jsonPath("$.data[2].id").value(uuids[2]))
            .andExpect(jsonPath("$.data[2].name").value("환타곰"))
            .andExpect(jsonPath("$.data[2].type").value(MemberType.USER.name))
    }

    이 방법도 테스트를 잘 통과한다.

     

    andExpect()의 인자에는 MockMvcResultMatchers 객체가 들어가게 되는데, 이 클래스를 상속하는 JsonPathResultMatchers 클래스에서 JSON 내부의 값을 가져올 때 UTF-8로 인코딩을 해서 인코딩 문제가 발생하지 않는다.

    JsonPathResultMatchers 클래스의 getContent를 사용해서 JSON 내부의 값을 가져오는데, 인코딩 방식으로 UTF-8을 사용하고 있다.

    참조 링크

     

    MockMvc no longer handles UTF-8 characters with Spring Boot 2.2.0.RELEASE

    After I upgraded to the newly released 2.2.0.RELEASE version of Spring Boot some of my tests failed. It appears that the MediaType.APPLICATION_JSON_UTF8 has been deprecated and is no longer returne...

    stackoverflow.com

     

    Deprecate MediaType.APPLICATION_JSON_UTF8 in favor of APPLICATION_JSON · Issue #22788 · spring-projects/spring-framework

    In spring-framework/spring-web/src/main/java/org/springframework/http/MediaType.java: * <p>This {@link MediaType#APPLICATION_JSON} variant should be used to set JSON * content type because while * ...

    github.com

     

    dev-tips/Spring Test MockMvc의 한글 깨짐 처리.md at master · HomoEfficio/dev-tips

    개발하다 마주쳤던 작은 문제들과 해결 방법 정리. Contribute to HomoEfficio/dev-tips development by creating an account on GitHub.

    github.com

     

    Springboot 2.2.x MockMvc 인코딩 이슈

    developer JH website.

    jehuipark.github.io

     

    MockMvc 테스트 시 인코딩 문제로 검증 실패 | MockMvcBuilderCustomizer

    문제 해결 - MockMvc 테스트 결과 인코딩 오류

    velog.io

     

    728x90