지난 글(SnowFlake 적용기 - 구현)에서 실제로 SnowFlake를 적용해서 채번을 진행해보았다. 채번되어 나온 값은 크다면 크다고 할 수 있는 값이 나오게 되는데, 이렇게 해서 나온 값은 JavaScript에서 문제가 된다.
클라이언트에 값을 내려주고 로직을 처리하기 위한 데이터가 온전한 형태를 유지하지 못하는 모습을 확인할 수 있었다. 이것은 JavaScript의 자료형이 감당(?) 할 수 있는 크기를 넘어서 나온것이고, 실제 문서에도 지원하는 범위가 64비트보다는 작은 모습을 볼 수 있다.
서버에서는 값을 잘 내려주고 있는데, JavaScript에서 이상하다는게 뭐가 문제야? 라고 할 수 있지만, 클라이언트와 서버를 모두 보고있는 입장에서 전혀 문제가 안될 수 없었다.
하지만 String으로 Model값을 변경해서 내려준다면 정상적으로 내려와 사용할 수 있게 된다.
이제 여기서 선택의 기로에 서게된다.
전처리와 노가다(?)의 선택
이를 해결하기 위한 여러가지 선택지를 고려해볼 수 있었다. 크게는 두가지로 나눌 수 있었으면 세부항목은 이렇다.
1. 서버에서 가공해서 내려주기
- Model 타입 변경
- @JsonSerialize 사용
- ResponseBodyAdvice로 response 가공
2. 클라이언트에서 가공해서 사용하기(?)
- dataFilter로 response 가공
서버 - Model 타입 변경
먼저 Model 타입 변경이다. 주어진 Model의 타입을 Long에서 String으로 변경해주는 방법이다.
@Getter
public class TestEntityResponseModel {
private String id;
private Long column_1;
private String column_2;
private int column_3;
public TestEntityResponseModel(TestEntity testEntity) {
// 얘는 어떻게할래?.... toString.... String.valueOf....
this.id = testEntity.getId(); // error
this.column_1 = testEntity.getColumn_1();
this.column_2 = testEntity.getColumn_2();
this.column_3 = testEntity.getColumn_3();
}
}
기존에 Long이었던 타입을 String으로 바꿔준 모습이다. 그런데 TestEntity의 채번값은 Long타입이고, 생성자로 받아서 Model을 만들어줄 떄, getId()의 String처리가 부가적으로 들어가게된다. 해주면 되는거 아닌가 싶지만, 이게 수백개 수천개가 될지도 모르는 상황에 이방법은 좋아보이지 않았다.
서버 - @JsonSerialize 사용
다음은 @JsonSerialize 사용이다. Model은 직렬화 과정을 통해서 Json 형태의 모습으로 response 하게 된다. 이 때, response에 내려주는 해당 필드 값을 다른 형태로 바꿔서 내려줄 수 있도록 하는 어노테이션이다.
@Getter
public class TestEntityResponseModel extends BaseModel {
private Long column_1;
private String column_2;
private int column_3;
public TestEntityResponseModel(TestEntity testEntity) {
this.id = testEntity.getId();
this.column_1 = testEntity.getColumn_1();
this.column_2 = testEntity.getColumn_2();
this.column_3 = testEntity.getColumn_3();
}
}
@Getter
public class BaseModel {
@JsonSerialize(using = ToStringSerializer.class)
protected Long id;
}
어떤 필드든 상관 없지만, Id를 상속받는 구조로 짜여진 Model에서 저렇게 어노테이션을 선언해주는 것 만으로, Long 타입에서 String으로 변경되어 제대로 response를 내려주는 모습을 확인할 수 있었다. 이걸로 방향을 잡고 싶었지만, 몇가지 소소하게(?) 걸리는 것들이 있었다.
@Getter
public class TestEntityResponseModel extends BaseModel {
@JsonSerialize(using = ToStringSerializer.class)
private Long otherId;
private Long column_1;
private String column_2;
private int column_3;
public TestEntityResponseModel(TestEntity testEntity, OtherEntity otherEntity) {
this.id = testEntity.getId();
this.otherId = otherEntity.getId();
this.column_1 = testEntity.getColumn_1();
this.column_2 = testEntity.getColumn_2();
this.column_3 = testEntity.getColumn_3();
}
}
모두 BaseModel의 Id를 상속받는 Model이 아닐 수도 있으며, 이때에는 해당 Model에 어노테이션을 따로 정의해주어야한다. 첫번째 방법처럼 노가다가 필요한 작업인데, 수정범위가 적다는 장점이 있다. 코드 추가만 있을 뿐, 변경해야하는 부분이 없다.
서버 - ResponseBodyAdvice로 response 가공
다음은 ResponseBodyAdvice로 response 가공하는 방법이다. ResponseBodyAdvice는 Spring에서 제공해주는 Interface로, implements해서 구현체를 만들 수 있다. request 들어와 로직을 수행하고 최종적으로 response 내려주기전에 거치는 일종의 Interceptor? 의 역할을 수행하는 듯 하다.
@RestControllerAdvice
public class ControllerResponseBodyAdvice implements ResponseBodyAdvice<Object> {
@Override
public boolean supports(MethodParameter returnType, Class<? extends HttpMessageConverter<?>> converterType) {
return false;
}
@Override
public Object beforeBodyWrite(Object body, MethodParameter returnType, MediaType selectedContentType, Class<? extends HttpMessageConverter<?>> selectedConverterType, ServerHttpRequest request, ServerHttpResponse response) {
return body;
}
}
기본 형태는 다음과 같다. Override된 두 메서드는 ResponseBodyAdvice가지게 되면 볼 수 있다. supports()에서 beforeBodyWrite()를 수행할지 Boolean 값으로 확인하게 된다. 이때 값이 true라면 beforeBodyWrite()를 통해서 response Model 전처리가 가능하다.
@RestControllerAdvice
public class ControllerResponseBodyAdvice implements ResponseBodyAdvice<Object> {
@Override
public boolean supports(MethodParameter returnType, Class<? extends HttpMessageConverter<?>> converterType) {
// ResponseWrapper로 감싼 Model만 로직을 수행하게 하자 -> 타입검사 수행
return false;
}
@Override
public Object beforeBodyWrite(Object body, MethodParameter returnType, MediaType selectedContentType, Class<? extends HttpMessageConverter<?>> selectedConverterType, ServerHttpRequest request, ServerHttpResponse response) {
// body -> JsonObject로 변환
// 만들어진 JsonObject에 있는 id값을 모두 String 값으로 변환
// 최종적으로 만들어진 결과값을 ResponseWrapper로 감싸서 return
return body;
}
}
조건으로는 정하기 나름이지만, 여기서는 ResponseWrapper 클래스인 Object만 전처리를 수행하기로 했다.
그리고, beforeBodyWrite() 에서 데이터를 모두 순회하여 Id로 칭할 수 있는 값들을 모두 String 형태로 바꿔줄 예정이다.
Id로 칭할 수 있는 데이터의 조건은 단순하게 key가 "id"를 포함하고 있는가, 타입이 Long인가 만을 확인하기로 하였다.
마지막으로 수정된 JsonObject를 ResponseWrapper 형태의 Json으로 변환하여 return할 것이다.
@Getter
@AllArgsConstructor
@NoArgsConstructor
public class ResponseWrapper<T> {
private T data;
}
@RequiredArgsConstructor
@RestControllerAdvice
public class ControllerResponseBodyAdvice implements ResponseBodyAdvice<Object> {
private final ObjectMapper objectMapper;
@Override
public boolean supports(MethodParameter returnType, Class<? extends HttpMessageConverter<?>> converterType) {
// ResponseWrapper로 감싼 Model만 로직을 수행하게 하자 -> 타입검사 수행
return returnType.getParameterType().equals(ResponseWrapper.class);
}
@SneakyThrows
@Override
public Object beforeBodyWrite(Object body, MethodParameter returnType, MediaType selectedContentType, Class<? extends HttpMessageConverter<?>> selectedConverterType, ServerHttpRequest request, ServerHttpResponse response) {
// body -> JsonObject로 변환
JSONObject jsonObject = new JSONObject(objectMapper.writeValueAsString(body));
// 만들어진 JsonObject에 있는 id값을 모두 String 값으로 변환
extractObject("data", jsonObject);
// 최종적으로 만들어진 결과값을 ResponseWrapper로 감싸서 return
return objectMapper.readValue(jsonObject.toString(), ResponseWrapper.class);
}
@SneakyThrows
private void extractObject(String headKey, JSONObject jsonObject) {
JSONObject obj = (JSONObject) jsonObject.get(headKey);
Iterator iter = obj.keys();
while (iter.hasNext()) {
String key = String.valueOf(iter.next());
Object value = obj.get(key);
if (key.toLowerCase().contains("id") && value.getClass().equals(Long.class)) {
obj.put(key, String.valueOf(value));
} else if (obj.get(key).getClass() == JSONObject.class) {
extractObject(key, obj);
}
}
}
}
extractObject()에서는 순회하면서 String으로 변환해줄 id를 찾아서 바꿔준다. id에 toLowerCase() 를 해준 이유는, 그냥 id면 문제가 없는데 otherId처럼 대문자가 섞이면 구분을 못할거 같아서 한번 바꿔준뒤에 문자열 검사를 진행하도록 하였다. 이대로 API를 request하면 id가 들어간 값들이 모두 String 처리가 되어 나오게된다.
그런데... 이렇게 개인 환경에서 적용했을 떄는 별 문제가 없었는데..... 실제 업무에 적용하려다보니, 알 수 없는 에러들을 계속해서 볼 수 있었다. 하나 해결하면 하나가 생기고....... 반환하려는 Object의 틀을 깨려다보니, 기존에 작성된 여러 코드들에서 Side Effect가 일어나는 모습을 볼 수 있어서, 이 방법도 적용하려면 실제 환경에서의 삽질이 조금더 필요할 듯하다.
클라이언트 - dataFilter로 response를 가공
이번엔 클라이언트에서 dataFilter로 response를 가공하는 방법이다. 그런데 JavaScript에서 다룰 수 있는 비트수가 64비트보다 적은데 뭘하려고 하나 싶을 수 있다. 나는 이러한 문제를 해결하려는 대단한 사람들이 분명히 있을 거라 생각했고, 좀 찾아보니까 금방 찾을 수 있었다.
이걸 그대로 돌려보니까 제대로 값이 나온다. 이제 제대로 내려온 response를 사용하기 전에, 위 로직을 타게하고 사용하게 하면된다. 테스트를 수행하기 위한 API 호출 라이브러리는 fetch, axios, ajax등이 있지만, 실제 일하는 환경이 ajax이기 때문에 공식문서를 참조하여 ajax로 진행할 수 있었다.
response를 사용하기전에 한번 데이터를 처리해주기 위해서 아래처럼 dataFilter를 사용하였다.
$.ajaxSetup({
// ...
dataFilter: function (data, type) {
const parsed = JSON.parse(data, function(key, value) {
if(typeof value !== 'number' || Number.MAX_SAFE_INTEGER > value) {
return value;
}
const maxLen = Number.MAX_SAFE_INTEGER.toString().length - 1;
const needle = String(value).substr(0, maxLen);
const re = new RegExp(`${needle}\\+`);
const matches = data.match(re);
if (matches) {
return matches[0];
}
return value;
}
return JSON.stringfy(parsed);
}
});
기능들이 정상적으로 수행되는 모습을 볼 수 있었다. 하지만 이 방법은 정말로 API 호출이 공통화된 모듈을 사용하여 했을 경우에 공통 필터로써 작용할 수 있으며, 독립된 모듈로 구현되어있다면 모두 다 추가해주어야한다. 또한 클라이언트 개발하는 입장에서, 서버에서 내려준 값이 너무 크기 때문에 너희가 한번 전처리를 해줘야해! 라는 가이드를 줘야할 필요성도 있을 것 같다.
나중에 알았지만, npm에서는 크기가 큰 정수형을 제대로 Parse해주는 라이브러리도 있었다.
그래서 선택은....
이번 글은 좋든 안좋든 문제 해결을 위한 여러가지 방법들을 나열해 보았다. 결국 단순 노가다(?) 작업으로 마칠 수도 있고, 전처리 작업을 통해서 작업을 할 수도 있었다. 어떤 방법이 더 좋다라고 할 수는 없을 것 같다. 노가다는 누락의 위험이 있을 수 있고, 전처리는 알 수 없는 문제가 생길 수도 있기에 신중하게 방법을 선택해야할 것 같다. 지금으로써는 노가다 방법이지만, @JsonSerizlize 방식이 좀 뚜렷하지 않나 싶다. 그러다가도 전처리 방식이 깔끔한 것 같은데 이 방식은 어디서 어떻게 터질지 모르는 문제들이 기다리고 있을 듯하다. 나름 어려운 고민이었다.
'Tech' 카테고리의 다른 글
Event가 동작할 것만 같지? - 잔여 이벤트 처리하기 (1) | 2023.05.01 |
---|---|
Event가 동작할 것만 같지? - Spring Event와 Domain Event (0) | 2023.05.01 |
Dead Code 분석도구 - Scavenger (Feat. DEVIEW 2023) (0) | 2023.03.27 |
SnowFlake 적용기 - 구현 (3) | 2023.01.07 |
SnowFlake 적용기 - Unique한 값 (0) | 2023.01.07 |