신입 개발자가 쓴 글이기 때문에 틀리거나 잘못 이해한 내용이 있을 수 있습니다. 잘못된 내용은 알려주시면 바로 잡아서 다시 정리할 수 있도록 하겠습니다!
getEscapeSequence
메소드를 수정하면 이모지 파싱 문제를 해결 할 수 있음※현재, 저는 고객사의 화장품 생산 관리 시스템 개발에 참여하고 있습니다.
고객사의 생산 관리 시스템을 운영 환경에 배포하고 나서의 오후였습니다. 기능의 문제는 없는 것을 확인하고 퇴근을 준비하던 찰나, 치명적인 이슈가 보고됩니다.
“주문 조회할 때 500 에러가 나오는데요??”
주문 조회 기능은 특별한 로직을 수행하는 것이 아니라 Mapper를 통해 DB에 있는 값들을 select 해오는 단순한 기능을 수행하고, 이번 배포는 주문 조회와는 관련이 없는 기능과의 배포였고, 테스트 환경에서 발견되지 않은 현상이었기 때문에 보고된 이슈는 당황하지 않을 수 없었습니다.
먼저 주문 조회 시 해당 현상이 어떠한 로그를 거쳐 500 에러가 응답 하고 있는지 확인에 들어갔습니다.
💡 2022-04-27 17:24:46,461 ERROR [http-nio-8081-exec-4 ] c.c.s.c.e.ExceptionResolver: Could not write JSON: Unmatched first part of surrogate pair (0xd83e); nested exception is com.fasterxml.jackson.databind.JsonMappingException: Unmatched first part of surrogate pair (0xd83e) (through reference chain: 응답할 오브젝트 경로["list"]->java.util.ArrayList[0]->응답할 오브젝트 안에 들어가 있는 DTO 경로["JSON 파싱에 실패한 변수명"]) org.springframework.http.converter.HttpMessageNotWritableException: Could not write JSON: Unmatched first part of surrogate pair (0xd83e); nested exception is com.fasterxml.jackson.databind.JsonMappingException: Unmatched first part of surrogate pair (0xd83e) (through reference chain: 응답할 오브젝트 경로["list"]->java.util.ArrayList[0]->com.c.data.dto.OrderDTO["nickName"])서버에서는 응답할 오브젝트도 정상적으로 만들고 거기에 들어갈 OrderDTO 역시 문제 없이 만들어 지고 있었습니다. 뉴비 개발자 입장에서는 서비스 내에서 정상적으로 오브젝트를 다 만들고, service를 나갈 때 정상적으로 return을 거쳤는데 발생하는 문제를 이해하기 쉽지 않았습니다.
게다가 response는 난생 처음 보는 형태의 괴상한 응답이 나가고 있었습니다.
정상적인 응답이 나가던 도중 에러 응답 메시지가 같이 붙어져서 나가는 것이었습니다. 헤더에 실린 최종 응답은 결국 500 에러였지만 여지껏 봐왔던 응답과는 전혀 다른 구조로 응답이 되고 있었습니다.
(기존의 에러 응답은 에러 메세지만 나갔지만, 이렇게 보이는 응답 구조는 정상적인 응답처럼 보이지만, 정상적 응답이 그려지던 중 뒷 부분에 에러 메시지가 추가되어 있습니다.)
도대체 왜 이런 일이 벌어진 것일까요? 도대체 백엔드의 무엇이 500 에러를, 그것도 처음 보는 응답의 형태로 날려주고 있었던 것이었을까요?
주문 조회에서 500에러가 나는 경우는 단 하나의 경우였습니다. 7번째 페이지를 제외한 모든 페이지에서 정상적으로 처리되었고, 오직 7번째 페이지의 목록을 호출할 때 위와 같은 현상을 겪고 있었습니다. 우리는 7번째 페이지를 호출할 때 백엔드에서 날려주는 데이터는 무엇인지 확인했습니다.
그리고 예상치 못한 녀석을 찾을 수 있었습니다.
바로 닉네임에 🤍가 들어가 있는 데이터였습니다.
기본적으로 surveyData에 들어가 있는 닉네임은 String입니다.
그렇다면, 백엔드에서 해당 데이터를 꺼내오던 중 String으로 변환할 때 문제가 생겼던 것이었을까요?
제가 세웠던 가설들은 다음과 같습니다.
첫번째 가설의 경우, Mapper에서 DB에서 데이터를 가져올 때 파싱의 에러가 발생했다는 건, 반대로 생각하면 해당 데이터를 처음 받아서 DB에 저장할 때도 문제가 발생했을 가능성이 있다는 건데, 이 경우 별 다른 문제 없이 DB에 정상적으로 저장되었다는 것입니다.
실제로 코드를 동작시켰을 때 브레이크 포인트를 찍어보면서 어느 시점에서 에러를 내보내는지 확인해보았습니다.
세상에.... 데이터를 꺼내와서 객체에 넣는 과정 역시 문제 없이 진행되고 있었습니다.
이 과정에서 확인 한 건, 이모지를 사용하기 위해서는 mysql 서버의 인코딩은 utf8mb4로 되어 있어야 이모지를 파싱할 수 있고, 자바 String의 기본 인코딩은 utf-16을 사용하기 때문에 mysql 서버만 이모지를 저장할 수 있는 인코딩만 설정해주면 자바는 내부에서 다 파싱할 수 있다는 것입니다.
객체를 정상적으로 저장했고, 주문 리스트를 응답 결과로 내보내는 service의 메소드도 정상적으로 빠져 나오는 걸 확인했습니다.
그렇다면, 설마 만들어진 데이터가 나가던 도중 무언가의 과정을 거치는데 이 때 서버가 해당 동작을 수행하는 과정에서 에러가 발생하는 것일까요?
데이터가 프론트로 나가기 전, 우리가 설정해둔 유일한 동작은 바로 XSS 필터였습니다.
(XSS에 관한 내용은 여기서 정리하지는 않지만 나중에 꼭 정리해서 글을 쓸 예정입니다.)
service의 메소드를 빠져 나와 컨트롤러가 정상적으로 응답을 던져 주었다면, 이 데이터를 파싱 하지 못해 목록을 그려주지 못하는 건 그 순간부터 프론트가 해결해야할 문제가 됩니다. 하지만, 백엔드에서 XSS 필터를 거쳐 Json을 던져주게 된다면, 여전히 데이터를 가공하고 처리하는 로직은 백엔드에서 수행하고 있기 때문에 제대로 처리 하지 못했다면 500 에러가 응답하는 것으로 볼 수 있습니다.
정말 이 문제가 XSS 필터의 문제인지 확인하기 위해 XSS 필터를 통하지 않고 테스트를 진행 했습니다.
퇴근을 할 수 있도록 세상이 도운건지 200 ok가 떨어졌고, 프론트도 받은 값을 문제 없이 화면에 잘 그려주었습니다.
우리가 지정한 이모지는 우리가 만든 XSS 필터의 HTMLCharacterEscapes
객체의 getEscapeSequence()
메소드를 통과하게 됩니다.
@Override
public SerializableString getEscapeSequence(int ch) {
return new SerializedString(StringEscapeUtils.escapeHtml4(Character.toString((char) ch)));
}
앞서 간단하게 다룬 자바 String의 기본 인코딩은 utf-16입니다. utf-16에는 surrogate pair라는 사양이 있는데 자바에서는 2개의 char로 하나의 문자를 표현하는 형식입니다. utf-16, 즉 자바 String으로 다룰 때 surrogate pair에 대한 대응이 되지 않으면 유니코드처럼 문자열 길이가 정해져 있는 경우 올바른 유니코드로 변환되지 않습니다. 위의 getEscapeSequence()
에서 surrogate pair에 대한 처리를 해주지 않으면 일부 문자가 올바른 유니코드의 형태를 갖추지 못한 채 처리되는 에러가 발생할 것입니다.
이모지는 utf-16에서 surrogate pair로 처리되어 있기 때문에 위의 메소드를 그냥 타게 되면 적합한 유니코드로 변환되지 못한 채 XSS 필터를 거치게 되는 것입니다. 이 글 초반부 보여드렸던 백엔드의 에러 로그를 보면 이러한 원인을 명확하게 짚어주고 있습니다.
💡 2022-04-27 17:24:46,461 ERROR [http-nio-8081-exec-4 ] c.c.s.c.e.ExceptionResolver: Could not write JSON: Unmatched first part of surrogate pair (0xd83e);surrogate pair의 첫번째 부분이 맞지 않는다고 친절하게 우리가 어떻게 이 오류를 해결할 수 있는지 알려주고 있습니다.
그렇다면, 이제 utf-16, 이 녀석이 surrogate pair를 올바른 유니코드로 나타낼 수 있도록 getEscapeSequence()
를 분기를 해야겠습니다.
@Override
public SerializableString getEscapeSequence(int ch) {
char charAt = (char) ch;
if (Character.isHighSurrogate(charAt) || Character.isLowSurrogate(charAt)) {
StringBuilder sb = new StringBuilder();
sb.append("\\u");
sb.append(String.format("%04x", ch));
return new SerializedString(sb.toString());
} else {
return new SerializedString(StringEscapeUtils.escapeHtml4(Character.toString(charAt)));
}
}
Character의 isHighSurrogate()
와 isLowSurrogate()
를 사용하면 인자로 받은 녀석이 surrogate pair의 여부를 확인할 수 있습니다.
\u
와 String.format("%04x", ch)
를 통해 유니코드로 변환해서 처리해버리면 됩니다.
이 날, 이모지 대란은 순조롭게(?) 마무리 되었습니다. 글에서는 원인을 찾는 과정은 단순하게 한 줄로 되어 있지만 무엇이 잘못되었는지 어디서부터 잘못된건지 난 누구인가에 대한 고민을 하면서 힘겹게 찾았습니다.
그렇지만, 문제를 해결하는 과정은 생각보다 너무 즐거웠습니다. 괴이한 응답을 보고 다른 팀원들에게 무엇이 문제인지 물어보고 의견을 듣고, 인코딩의 문제인가 싶어 엄한 곳에서 진행한 삽질부터 XSS 필터가 문제라는 것을 찾았을 때 혼자 미친 듯이 웃으며 좋아했던 것까지, 배운 것도 많았지만 즐거운 경험이기도 했습니다.
더불어 백엔드 개발자로서도 이 과정을 통해 배운 것은 사용자는 절대 개발자의 의도로 사용하지 않는다, 이것을 정말 강렬하게 배웠습니다. 사용자가 기입하는 닉네임에 이모지를 사용해 넣을 것이란 걸 저는 절대 생각하지도 못했습니다. 개발자로서 모든 사용자의 행동을 예측할 수 는 없지만, 이러한 상황에 어느 정도는 대비를 해둘 필요가 있지 않을까를 배운 것 같습니다.
백엔드 개발자의 길은 험하기도 하지만 문제를 해결하고 배우는 즐거움은 또 우리가 앞으로 나아갈 수 있도록 도와주는 원동력이라 믿고 오늘도 끊임 없이 삽질을 하며 배우는 신입 개발자 봄도둑이었습니다.
참고
이모지 이슈 해결 : https://inseok9068.github.io/springboot/springboot-xss-response/
surrogate pair : https://frontierdev.tistory.com/141, https://www.sysnet.pe.kr/2/0/1710, https://docs.microsoft.com/en-us/globalization/encoding/surrogate-pairs
비슷한 이슈로 고전하던 중 발견한 포스팅인데 아주 유익하고 도움되네요 감사합니다!