시작하며
안녕하세요. 카카오페이에서 비즈플랫폼을 개발하고 있는 마틴입니다. 기술블로그를 통해서 개발자 분들이 바로 적용해 볼 수 있는 팁을 공유해 보고자 합니다.
시스템 운영업무를 하다보면, 간혹 DB에는 있는데 백오피스에 노출이 되지 않는 경우가 생각보다 빈번하게 있습니다. 그 때마다 꽤 많은 시간을 들여가며 디버깅을 하게 되는데요. 이제는 해당 케이스에 대해서 통달했고, 공백 문자나 눈에 안 보이는 특수 문자가 들어있음을 짐작해보곤 합니다.
그런데 언제까지 소잃고 외양간을 고칠 것인가! 소가 사라지는 개구멍(아니 소구멍..)을 빠삭하게 알고 있는 것보다는, 미리 외양간을 고쳐두는 것이 낫지 않을까 생각했습니다. 이번 기회에 새롭게 만들고 있는 시스템에서는 아예 공백 문자를 제거하여 정확한 정보를 넣도록 개선하였습니다.
아마 IT 업계에 있는 많은 서비스에서도 동일한 경험을 하시고 계실 것입니다. 이 세상의 외양간들에 뚫려있는 구멍 때문에 고생하시는 개발자분들께 가볍지만 실용적인 팁을 공유해 보려고 합니다.
QueryParam 공백 문자 제거
블로그 제목과는 조금 벗어나는 내용이긴 한데요, 파라미터 공백 제거 방법을 공유하는 김에 좀 더 쉽고 간단한 방법을 알려드리겠습니다. QueryParam으로 들어오는 데이터의 공백 문자 제거는 생각보다 어렵지 않습니다. 이미 알고 있는 몇 가지 기술을 조합하면 간단하게 공백을 제거할 수 있습니다.
흔히 @RestControllerAdvice를 활용하여 Global ExceptionHandling하는 것을 많이 활용하고 계실 것입니다. 하지만 해당 어노테이션은 예외를 처리하기 위한 방법만으로 사용하는 것은 아닙니다. 스프링에서 제공하는 또 다른 어노테이션인 @InitBinder를 조합하면, 들어오는 쿼리 파라미터에 변조를 가할 수 있습니다. 아래처럼 코드를 작성하면, 모든 RestController의 쿼리 파라미터에 대해서 trim() 처리를 해서 앞뒤 공백 문자가 모두 삭제됩니다.
@RestControllerAdvice
public class CustomParameterHandler {
@InitBinder
public void InitBinder(WebDataBinder dataBinder) {
StringTrimmerEditor ste = new StringTrimmerEditor(true); // emptyAsNull: true (빈문자열은 null로 파싱함)
dataBinder.registerCustomEditor(String.class, ste);
}
}
개발자라면 자고로 이렇게 하면 된다
는 말보다는 짧은 테스트코드로 결과를 확인해 보고 싶을 것입니다. 테스트코드를 함께 공유드립니다.
@RestController
public class TestController {
@GetMapping("/test")
public TestResponse testOnGet(String params) {
return new TestResponse(params);
}
@Data
private static class TestResponse {
private final String message;
}
}
@SpringBootTest
class CustomParameterHandlerTest {
private MockMvc mockMvc;
@Autowired
private WebApplicationContext wac;
@BeforeEach
public void beforeNode() {
mockMvc = MockMvcBuilders
.webAppContextSetup(wac)
.addFilters(new CharacterEncodingFilter("UTF-8", true)) // 로그한글깨짐 문제해결
.alwaysDo(print())
.build();
}
@Test
void paramsTrimmed() throws Exception {
mockMvc.perform(get("/test?params= string "))
.andExpect(status().isOk())
.andExpect(jsonPath("$.message").value("string"));
}
@Test
void paramsTrimmedThenNull() throws Exception {
mockMvc.perform(get("/test?params= "))
.andExpect(status().isOk())
.andExpect(jsonPath("$.message").value(nullValue()));
}
}
이렇게 코딩을 하면 Get Method를 통해 들어오는 쿼리 파라미터의 값의 앞뒤에 붙어있는 공백 문자를 제거할 수 있습니다. 특히, emptyAsNull 옵션을 주게 되면 공백 문자는 아예 null로 파싱해주기 때문에 입력받은 파라미터에 대해서 validation 절차가 좀 더 간단해집니다.
RequestBody Json의 공백 문자 제거
하지만 조회(Get)의 경우가 아닌 데이터를 입력하는 경우(Post/Put)에는 어떠할까요? 데이터를 입력하는 경우에는 QueryParam보다 RequestBody를 선호합니다. 그중에서도 Json Body를 사용하는 것이 일반적입니다. 위처럼 @InitBinder는 QueryParam에 적용되는 어노테이션이기 때문에 Post나 Put 환경에는 적용되지 않습니다.
Json Body를 가공하기 위해서는 SpringBoot에서 기본으로 차용하고 있는 Jackson에 Deserializer를 적용해 주어야 합니다.
@Configuration
public class JsonConfig {
public ObjectMapper objectMapper() {
return Jackson2ObjectMapperBuilder
.json()
.modules(customJsonDeserializeModule())
.build();
}
private SimpleModule customJsonDeserializeModule() {
SimpleModule module = new SimpleModule();
module.addDeserializer(String.class, new StringStripJsonDeserializer());
return module;
}
}
public class StringStripJsonDeserializer extends JsonDeserializer<String> {
@Override
public String deserialize(JsonParser p, DeserializationContext ctx) throws IOException {
String value = p.getValueAsString();
if (value == null) {
return null;
}
String valueStripped = value.strip();
return valueStripped.length() != 0 ? valueStripped : null;
}
}
커스텀 모듈로 JsonDeserializer를 만들어서 objectMapper 클래스에 설정을 넣어줍니다. 이렇게 하면 Json 데이터를 읽을 때마다 문자열 양옆 공백을 제거해 줍니다. 마찬가지로 테스트 코드를 만들어서 적용해 보도록 하겠습니다.
@SpringBootTest
class JsonConfigTest {
protected MockMvc mockMvc;
@Autowired
private WebApplicationContext wac;
@BeforeEach
public void beforeNode() {
mockMvc = MockMvcBuilders
.webAppContextSetup(wac)
.addFilters(new CharacterEncodingFilter("UTF-8", true)) // 로그한글깨짐 문제해결
.alwaysDo(print())
.build();
}
@DisplayName("StringStripJsonDeserializer")
@Nested
class StringStripJsonDeserializer {
@Test
void whenValueIsNull() throws Exception {
mockMvc.perform(post("/test")
.contentType(MediaType.APPLICATION_JSON)
.content("{\"message\" : null}"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.message").value(nullValue()));
}
@Test
void whenValueIsEmpty() throws Exception {
mockMvc.perform(post("/test")
.contentType(MediaType.APPLICATION_JSON)
.content("{\"message\" : \"\"}"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.message").value(nullValue()));
}
@Test
void whenValueIsBlank() throws Exception {
mockMvc.perform(post("/test")
.contentType(MediaType.APPLICATION_JSON)
.content("{\"message\" : \" \"}"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.message").value(nullValue()));
}
@Test
void whenValueIsWhiteSpaceOfUnicode() throws Exception {
mockMvc.perform(post("/test")
.contentType(MediaType.APPLICATION_JSON)
.content("{\"message\" : \"\u2003 \u200A\"}")) // strip() is better than trim()
.andExpect(status().isOk())
.andExpect(jsonPath("$.message").value(nullValue()));
}
@Test
void whenValueIsNotString() throws Exception {
mockMvc.perform(post("/test")
.contentType(MediaType.APPLICATION_JSON)
.content("{\"message\" : \" \"}"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.message").value(nullValue()));
}
@Test
void whenValueContainsWhiteSpace() throws Exception {
mockMvc.perform(post("/test")
.contentType(MediaType.APPLICATION_JSON)
.content("{\"message\" : \" \"}"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.message").value(nullValue()));
}
}
}
Json Body로 입력된 데이터들의 공백을 제거하고, 위의 경우에는 Empty String의 경우 null로 치환하는 것까지 잘 적용됨을 확인할 수 있습니다. Empty String을 그대로 사용할 것인지 아니면 null로 인식하여 사용할지는 개발하시는 분이 선택하여 구현하면 될 것 같습니다. 각 서비스의 도메인 스타일에 따라서 조금씩 다를 수 있습니다.
strip() va trim()
여기서 잠깐!! strip()과 trim()에는 다음과 같은 차이점이 있습니다. 일반적으로 String class의 trim() 메서드가 더욱 익숙하고 친근한 것으로 생각됩니다.
하지만 최근(이라기엔 너무 오래된) JDK 버전에서는 strip()이라는 메서드를 지원하고 있습니다. strip()은 공백처럼 사용되는 일부 유니코드에 대해서도 추가적으로 제거해주는 메서드입니다. trim() 메서드의 상위호환이기 때문에 굳이 쓰지 않을 이유가 없습니다.
여담으로, 많은 개발자분들이 최신 JDK를 사용한다고 github 소스의 기술명세나 자신의 이력서에 기록해둡니다. 하지만 실제로 최신 버전의 Java API를 사용해본적이 있는가 하면 그렇지 않은 경우가 대부분입니다. (JDK 11버전도 최신이 아니라는 건 함정…) 최신 API 써본 것이 무엇인지 물어보면 stream() 이야기를 많이 하게 되는데, 그건 JDK8에서 도입되었습니다. 무려 9년전… 2014년이면 아이폰 5S를 사용하던 시절입니다.
Jackson에서 숫자의 파싱 (의문의 시작)
제가 만든 Deserializer는 String.class에 대해서 작동하도록 만들었습니다. (JsonDeserializer<String>) 그렇다면 다른 타입의 클래스에는 해당 Deserializer가 동작하지 않아야 합니다. 그래서 해당 케이스도 테스트로 함께 만들어 두려고 했습니다. (굳이??)
class StringStripJsonDeserializerTest {
@Test
void whenValueIsStringContainsWhiteSpace() throws Exception {
String json = "{\"message\" : \" martin \", \"number\" : \" 100 \"}";
TestMessage result = new ObjectMapper().readValue(json, TestMessage.class);
assertThat(result.getMessage(), is("martin"));
assertThat(result.getNumber(), is(100L));
}
@Data
private static class TestMessage {
@JsonDeserialize(using = StringStripJsonDeserializer.class)
private String message;
private Long number;
}
}
예상했던 결과는 Long을 파싱하다가 오류가 발생하는 상황이었습니다. 공백이 존재하므로 그냥 오류가 나지 않을까? 설마 Jackson에서 저 정도까지 지원을 할까? 궁금했습니다. 그런데 예상은 빗나갔습니다.
이걸 또 통과합니다… 이 사실을 옆에 있는 개발자에게 이야기했더니, “숫자는 앞뒤 공백이 의미가 없으니까 그게 더 합리적인 것 같은데요?”라고 이야기했습니다. 흠, 또 생각해보니 그런 것도 같습니다. 하지만 왜 그런지 궁금해하는 것이 개발자의 숙명!!
Jackson 코드 살펴보기
일단, 간단하게 StringDeserializer를 먼저 살펴보도록 하겠습니다.
@JacksonStdImpl
public class StringDeserializer extends StdScalarDeserializer<String> // non-final since 2.9
{
...
@Override
public String deserialize(JsonParser p, DeserializationContext ctxt) throws IOException
{
// The critical path: ensure we handle the common case first.
if (p.hasToken(JsonToken.VALUE_STRING)) {
return p.getText();
}
// [databind#381]
if (p.hasToken(JsonToken.START_ARRAY)) {
return _deserializeFromArray(p, ctxt);
}
return _parseString(p, ctxt, this);
}
...
}
JsonToken.VALUE_STRING이 구체적으로 뭔지는 잘 모르겠으나, 주석에 “the common case first”라고 친절하게 적혀있습니다. 일반적인 String 문자열의 케이스에는 JsonParser.getText()를 호출합니다.
Json Parser는 추상클래스라서 실제 구현체는 많은 것들이 있습니다. 그중 일반적으로 많이 사용하게 되는 UTF8DataInputJsonParser를 살펴보겠습니다.
@Override
public String getText() throws IOException
{
if (_currToken == JsonToken.VALUE_STRING) {
if (_tokenIncomplete) {
_tokenIncomplete = false;
return _finishAndReturnString(); // only strings can be incomplete
}
return _textBuffer.contentsAsString();
}
return _getText2(_currToken);
}
대충 buffer에서 contentAsString()으로 가져옵니다. 여러 케이스에 대한 대비는 존재하지만, 사실상 데이터 가공 없이 그대로 가져옵니다. 그래서 String의 경우에는 별도의 Custom Parser를 만들어주지 않으면 Empty나 Blank에 대한 리스크에 상시 노출됩니다.(사족: 물론, Jackson 정도의 추상화된 레이어에서 문자열에 대한 추가적인 가공은 오히려 범용성을 해칠 가능성이 큽니다. 그래도 BLANK_AS_NULL같은 옵션을 추가로 제공하는 건 어땠을까 생각해봅니다.)
그럼 Number 쪽은 어떻게 다른지 코드를 살펴보도록 하겠습니다.
public class NumberDeserializers
{
private final static HashSet<String> _classNames = new HashSet<String>();
static {
// note: can skip primitive types; other ways to check them:
Class<?>[] numberTypes = new Class<?>[] {
Boolean.class,
Byte.class,
Short.class,
Character.class,
Integer.class,
Long.class,
Float.class,
Double.class,
// and more generic ones
Number.class, BigDecimal.class, BigInteger.class
};
for (Class<?> cls : numberTypes) {
_classNames.add(cls.getName());
}
}
public static JsonDeserializer<?> find(Class<?> rawType, String clsName) {
if (rawType.isPrimitive()) {
if (rawType == Integer.TYPE) {
return IntegerDeserializer.primitiveInstance;
}
if (rawType == Boolean.TYPE) {
return BooleanDeserializer.primitiveInstance;
}
if (rawType == Long.TYPE) {
return LongDeserializer.primitiveInstance;
}
if (rawType == Double.TYPE) {
return DoubleDeserializer.primitiveInstance;
}
if (rawType == Character.TYPE) {
return CharacterDeserializer.primitiveInstance;
}
if (rawType == Byte.TYPE) {
return ByteDeserializer.primitiveInstance;
}
if (rawType == Short.TYPE) {
return ShortDeserializer.primitiveInstance;
}
if (rawType == Float.TYPE) {
return FloatDeserializer.primitiveInstance;
}
// [databind#2679]: bit odd place for this (Void.class handled in
// `JdkDeserializers`), due to `void` being primitive type
if (rawType == Void.TYPE) {
return NullifyingDeserializer.instance;
}
} else if (_classNames.contains(clsName)) {
// Start with most common types; int, boolean, long, double
if (rawType == Integer.class) {
return IntegerDeserializer.wrapperInstance;
}
if (rawType == Boolean.class) {
return BooleanDeserializer.wrapperInstance;
}
if (rawType == Long.class) {
return LongDeserializer.wrapperInstance;
}
if (rawType == Double.class) {
return DoubleDeserializer.wrapperInstance;
}
if (rawType == Character.class) {
return CharacterDeserializer.wrapperInstance;
}
if (rawType == Byte.class) {
return ByteDeserializer.wrapperInstance;
}
if (rawType == Short.class) {
return ShortDeserializer.wrapperInstance;
}
if (rawType == Float.class) {
return FloatDeserializer.wrapperInstance;
}
if (rawType == Number.class) {
return NumberDeserializer.instance;
}
if (rawType == BigDecimal.class) {
return BigDecimalDeserializer.instance;
}
if (rawType == BigInteger.class) {
return BigIntegerDeserializer.instance;
}
} else {
return null;
}
// should never occur
throw new IllegalArgumentException("Internal error: can't find deserializer for "+rawType.getName());
}
...
}
NumberDeserializer인데, primitive와 wrappered 타입을 모두 지원하는 것까진 예상대로입니다. 하지만 의외로 Character나 Boolean까지 지원을 하고 있습니다. 흔히 우리가 사용하는 0 = false, 1 = true의 개념으로 입력값은 숫자가 들어오더라도, Generic에 맞춰서 파싱을 해주는 것으로 보입니다.
특히 중요한 부분은 아래 deserialize() 메서드부터입니다.
@Override
public Long deserialize(JsonParser p, DeserializationContext ctxt) throws IOException {
if (p.isExpectedNumberIntToken()) {
return p.getLongValue();
}
if (_primitive) {
return _parseLongPrimitive(p, ctxt);
}
return _parseLong(p, ctxt, Long.class);
}
protected final Long _parseLong(JsonParser p, DeserializationContext ctxt,
Class<?> targetType)
throws IOException
{
String text;
switch (p.currentTokenId()) {
case JsonTokenId.ID_STRING:
text = p.getText();
break;
case JsonTokenId.ID_NUMBER_FLOAT:
final CoercionAction act = _checkFloatToIntCoercion(p, ctxt, targetType);
if (act == CoercionAction.AsNull) {
return (Long) getNullValue(ctxt);
}
if (act == CoercionAction.AsEmpty) {
return (Long) getEmptyValue(ctxt);
}
return p.getValueAsLong();
case JsonTokenId.ID_NULL: // null fine for non-primitive
return (Long) getNullValue(ctxt);
case JsonTokenId.ID_NUMBER_INT:
return p.getLongValue();
// 29-Jun-2020, tatu: New! "Scalar from Object" (mostly for XML)
case JsonTokenId.ID_START_OBJECT:
text = ctxt.extractScalarFromObject(p, this, targetType);
break;
case JsonTokenId.ID_START_ARRAY:
return (Long) _deserializeFromArray(p, ctxt);
default:
return (Long) ctxt.handleUnexpectedToken(getValueType(ctxt), p);
}
final CoercionAction act = _checkFromStringCoercion(ctxt, text);
if (act == CoercionAction.AsNull) {
return (Long) getNullValue(ctxt);
}
if (act == CoercionAction.AsEmpty) {
return (Long) getEmptyValue(ctxt);
}
text = text.trim(); // ← 여기
if (_checkTextualNull(ctxt, text)) {
return (Long) getNullValue(ctxt);
}
// let's allow Strings to be converted too
return _parseLong(ctxt, text);
}
_parseLong() 메서드의 가장 하단부를 보면, text.trim()으로 데이터를 한번 재가공하고 있습니다. (사족: 기존 변수에 가공한 데이터를 re-assign하는 것은 그리 좋은 방식이 아니네요. aka., bad-smell)
_parseLongPrimitive() 메서드도 사실한 구현이 거의 비슷하니 내용은 생략하도록 하겠습니다. 소스를 받아서 해당 클래스 코드를 쭈욱 살펴보시면 좋을 것 같습니다. (사족: 코드가 거의 복붙인데, Jackson이 그리 좋은 코드 스타일은 아닌 것으로 보입니다.)
그런데, 코드를 따라 가다보면 NumberInput.class에서 재미있는 부분이 보입니다.
public static long parseLong(String s)
{
// Ok, now; as the very first thing, let's just optimize case of "fake longs";
// that is, if we know they must be ints, call int parsing
int length = s.length();
if (length <= 9) {
return (long) parseInt(s);
}
// !!! TODO: implement efficient 2-int parsing...
return Long.parseLong(s);
}
숫자의 자리수가 9자리 이하이면 parseInt()를 호출한 뒤 long으로 type casting을 합니다. 그리고 자리수가 9자리를 넘어가면 parseLong()을 써서 long으로 직접 파싱을 진행합니다.
/**
* Helper method to (more) efficiently parse integer numbers from
* String values. Input String must be simple Java integer value.
* No range checks are made to verify that the value fits in 32-bit Java {@code int}:
* caller is expected to only calls this in cases where this can be guaranteed
* (basically: number of digits does not exceed 9)
* <p>
* NOTE: semantics differ significantly from {@link #parseInt(char[], int, int)}.
*
* @param s String that contains integer value to decode
*
* @return Decoded {@code int} value
*/
public static int parseInt(String s) {
...
}
parseInt()는 Jackson에서 직접 구현해서 사용했는데요. 아래 주석을 보면 사용상 유의사항을 적어두었습니다. 숫자 range check를 하지 않으니 조심하라고 적혀있습니다. (주석을 친절하게 잘 적어 놓긴 했지만, clean code와는 거리가 조금 있습니다)
parseLong()은 java.lang의 기본 Long.class를 활용하고 있는데요. 주석 내용도 그렇고, 아마도 Legacy 코드의 잔재가 아닌가 생각됩니다.
마치며
Jackson에서 StringDeserializer는 문자열 앞뒤 공백 문자까지 파싱하고 있습니다. 파싱할 때 문자열을 그대로 반영하기 때문입니다. 하지만 정제된 데이터를 다루는 플랫폼이라면 이야기가 조금 달라집니다. 카카오페이는 블로그나 카페같이 유저 콘텐츠를 그대로 기록하는 서비스가 아닙니다. 공백 문자는 잠재적 오류를 발생시킬 리스크입니다. 그렇기 때문에 별도의 Custom Deserializer를 만들어서 사용하는 것을 추천드립니다.
Jackson에서 NumberDeserializer는 문자열의 앞뒤 공백 문자를 제거한 뒤, 숫자로 파싱을 진행합니다. Custom Deserializer를 만들어주지 않아도 문제가 없습니다. 다만, 문자열 내에 공백이 존재하면 파싱 오류가 발생합니다.
덧붙임 꿀팁 하나, 많은 개발자들이 오픈소스의 동작을 책으로 공부하는 경우가 많습니다. 책은 큰 흐름에 대해서 설명해주기 때문에 무언가를 처음 공부할 때에는 도움이 되는 경우가 많습니다. 하지만 실제로 구체적인 동작 하나하나를 책에 기록하지 않고(기록하면 두꺼워서 사람들이 안 읽음…), 또 많은 오픈소스들은 책이 출간된 이후에도 많은 업데이트가 발생합니다. 바쁘더라도 짬짬이 소스를 따라가며 실제 내부 동작들을 읽어보는 걸 추천드립니다.
덧붙임 꿀팁 둘, Jackson은 Legacy가 많아서인지 아니면 로우 레벨의 parser이기 때문인지, 코드 자체가 클린 코드의 best practice와는 거리가 좀 있는 듯합니다.
책에는 나오지 않는 개발 꿀팁으로 또다시 찾아뵙겠습니다. 카카오페이 기술 블로그 관심 많이 가져주세요 😊