try {
objectMapper.readValue(request, A::class.java)
} catch (e: JsonProcessingException) {
throw CoreException(InMemoryExceptionCode.FAILED_PARSE_JSON)
} catch (e: JsonMappingException) {
throw CoreException(InMemoryExceptionCode.FAILED_MAP_TO_SCHEMA)
}
웹 백엔드 개발을 하다 보면 ObjectMapper를 사용하는 경우가 꽤 많다는 걸 깨닫는다. Controller에서 Request를 처리하는 부분에서도 JSON 형태의 Request를 Jackson 라이브러리의 ObjectMapper가 우리가 원하는 형태의 객체로 직렬화를 해준다. 또 Redis에 값을 넣을 때도 개발자가 ObjectMapper를 주입받아서 처리해 주는 개발 방식이 매우 흔한 경우라고 보인다.
나 스스로도 위 코드처럼 ObjectMapper를 쓰는 것에 별로 이상함을 느끼지 않았던 게 사실이다. 오히려 경험적으로 항상 Exception이 발생하던 ObjectMapper를 완벽하게 제어해서 쓰기 위해서는 위 코드처럼 작성해야 한다고 생각했던 것 같다. 이 생각은 아마도 스프링에서 자체적으로 의존하고 있는 라이브러리를 깊게 살펴보지 않는 이유도 한몫할 거라고 생각된다
Controller에서 ObjectMapper가 동작하는 방식
- HTTP 요청이 들어오면 ContentType을 확인한다
- application/json인 경우 Jackson의 ObjectMapper가 동작한다
- ObjectMapper가 JSON 문자열을 지정된 클래스의 인스턴스로 변환한다 -> 역직렬화
직렬화
- 메모리에 있는 객체를 저장이나 전송할 수 있는 형태로 변환하는 과정을 말한다
- Java / Kotlin의 객체를 JSON 문자열이나 바이트 스트림으로 변환하는 것
역직렬화
- 저장되어 있거나 전송받은 데이터를 다시 메모리에서 사용할 수 있는 객체로 변환하는 과정이다
- JSON 문자열을 다시 Java / Kotlin 객체로 변환하는 것
여기서 궁금했던 부분을 하나 짚고 넘어가자면, 어? 그럼 통신을 통해서 데이터를 주고받는 데이터의 형태를 변환하는 과정이라면 DB랑 통신할 때도 ObjectMapper가 동작하나?
- 그건 아니다 ObjectMapper는 주로 HTTP 통신에서 JSON 객체 간의 변환을 담당한다
- DB와 통신 중에는 JPA의 경우 DB 테이블의 정보를 기반으로 객체(Entity)를 매핑한다
- MyBatis는 SQL 결과와 객체를 매핑한다
여기까지 알아본 바로는 ObjectMapper는 Json 문자열을 역직렬화해서 객체의 형태로 만들어준다는 것이다. 하지만 서버에 데이터가 전송되어 오는 형태는 JSON의 형태가 아니라 바이트 스트림의 형태로 오는데 이 부분은 어디서 처리되는지 좀 더 알아보자
1) 클라이언트가 JSON 데이터를 전송: {"name": "Kim", "age": 25}
2) 네트워크로 전송될 때:
- HTTP 요청은 바이트 스트림으로 변환되어 전송
- Content-Type은 application/json으로 HTTP Request 헤더에 포함
3) 스프링 서버에서:
- 바이트 스트림 -> JSON 문자열로 변환
- ObjectMapper가 JSON 문자열을 Kotlin / Java 객체로 변환
위의 이 흐름에서 그럼 바이트 스트림이 JSON으로 변환되는 과정을 좀 더 알아보겠다
바이트 스트림을 JSON으로는 누가?
HTTP 요청의 바이트 스트림을 JSON 문자열로 변환하는 과정은 Spring MVC에서 HttpMessageConverter가 담당한다
1. HTTP 요청 바이트 스트림이 도착한다
2. ServletInputStream으로 바이트 스트림을 읽는다
3. MappingJackson2HttpMessageConverter가 처리한다
- 내부적으로 ObjectMapper를 사용한다
- InputStreamReader를 통해서 바이트 스트림을 문자열로 변환한다
4. ObjectMapper가 JSON 문자열을 객체로 변환한다
스프링의 기본 설정을 살펴보면 우리가 WebMvc 설정을 위해서 설정하는 클래스에서 컨버터를 추가해서 사용하고 있는 부분을 확인할 수 있다.
WebMvcConfigurationSupport 내부
if (jackson2XmlPresent) {
Jackson2ObjectMapperBuilder builder = Jackson2ObjectMapperBuilder.xml();
if (this.applicationContext != null) {
builder.applicationContext(this.applicationContext);
}
messageConverters.add(new MappingJackson2XmlHttpMessageConverter(builder.build()));
생략...
MappingJackson2 XmlHttpMessageConverter를 들어가 보면 AbstractJackson2 HttpMessageConverter를 상속하고 있고 해당 클래스 내부로 들어가 보면 저렇게 InputStream 객체를 통해서 바이트 스트림을 받아내고 있는 부분을 확인할 수 있다
private Object readJavaType(JavaType javaType, HttpInputMessage inputMessage) throws IOException {
MediaType contentType = inputMessage.getHeaders().getContentType();
Charset charset = this.getCharset(contentType);
ObjectMapper objectMapper = this.selectObjectMapper(javaType.getRawClass(), contentType);
Assert.state(objectMapper != null, () -> "No ObjectMapper for " + javaType);
boolean isUnicode = ENCODINGS.containsKey(charset.name()) || "UTF-16".equals(charset.name()) || "UTF-32".equals(charset.name());
try {
InputStream inputStream = StreamUtils.nonClosing(inputMessage.getBody());
생략...
HTTP 통신이 들어온 시점부터 전체 흐름을 한 번 정리해보자
HTTP 요청 초기 처리
1. 클라이언트 HTTP 요청이 도착
2. Tomcat의 Connector가 요청을 받아서 Thread Pool에서 Thread 할당
3. HTTP 요청을 파싱 해서 HttpServletRequest, HttServletResponse 객체를 할당
FilterChain 처리
1. Filter ->.... -> Servlet
- DelegateFilter -> SecurityFilter 처리
DispatcherServlet 처리
1. doService() 메서드 호출
2. doDispatch() 메서드에서 실제 요청 처리:
- HandlerMapping으로 요청을 처리할 Handler(Controller) 검색
- HandlerAdapter를 통해서 Handler 실행 준비
- Handler(Controller의 메서드) 실행
@RequestBody 처리
1. ServletInputStream으로 요청 바디 읽기
2. HttpMessageConverter가 바이트 스트림을 JSON으로 변환
- 바이트 스트림 -> InputStreamReader -> JSON
- ObjectMapper가 JSON -> 객체 변환
@ResponseBody 처리
1. Handler 메서드의 반환값을 HttpMessageConverter가 처리
2. 객체 -> JSON 문자열 -> 바이트 스트림 변환
3. ServletOutputStream으로 응답 작성
그럼 이제 진짜로 ObjectMapper에서 어떤 설정값으로 try - catch를 벗어날 수 있을지 살펴보자
먼저 기존 코드에서 제어하고 있는 에러의 내용을 정리해 보겠다
1. JsonProcessingException
- Jackson 라이브러리에서 발생하는 가장 기본적인 JSON 처리 예외의 부모 클래스
- JSON 파싱이나 생성 과정에서 발생하는 모든 문제를 포괄한다
- 주요 발생 상황:
- JSON 문법이 잘못된 경우
- JSON 문자열이 불완전한 경우
- 입출력 작업 중 에러가 발생한 경우
흠... 내용을 보니 이건 개발자가 제어할 역량이 아니라 사용자 측에서 발생할 휴먼 에러에 가까운 것 같아서 try - catch 문을 완전하게 벗길 수는 없을 가능성이 생겼다
2. JsonMappingException
- JsonProcessingException의 하위 클래스, 더 구체적인 매핑 문제를 나타낸다
- DbabindException을 상속하고 있고 해당 Exception은 JsonProcessingException을 상속하고 있다
- 우선 이 부분에서 알 수 있는 건 우리가 예외 처리를 잘못하고 있다는 것이다
- 더 상위의 예외로 먼저 catch를 잡고 있었으므로, JsonMappingException은 절대로 반환될 리가 없는 코드였다
- 기본 라이브러리를 분석해야 하나 생각했었는데, 이런 기본적인 사용조차 제대로 하고 있지 못했으니까 분석하지 않았던 걸 반성해야겠다;
} catch (e: JsonProcessingException) {
throw CoreException(InMemoryExceptionCode.FAILED_PARSE_JSON)
} catch (e: JsonMappingException) {
throw CoreException(InMemoryExceptionCode.FAILED_MAP_TO_SCHEMA)
- 데이터 타입 변환이 실패할 때
- 필수 필드가 누락되었을 때
- 중첩된 객체 매핑에 실패했을 때
JsonMappingException의 경우에는 커스텀 영역에서 어느 정도 제어가 가능할 것 같다 그럼 우리가 제어할 수 있는 영역이 어떤 게 있는지 살펴보자
ObjectMapper 설정 가능 옵션
1. 기본 직렬화 / 역직렬화 설정
objectMapper.apply {
// null 값 처리
setSerializationInclusion(JsonInclude.Include.NON_NULL) // null 값 제외
setSerializationInclusion(JsonInclude.Include.NON_EMPTY) // null과 빈 컬렉션 제외
setSerializationInclusion(JsonInclude.Include.NON_DEFAULT) // 기본값 제외
setSerializationInclusion(JsonInclude.Include.ALWAYS) // 모든 필드 포함
// 알 수 없는 프로퍼티 처리
configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false) // 알 수 없는 필드 무시
configure(DeserializationFeature.FAIL_ON_NULL_FOR_PRIMITIVES, true) // 프리미티브 타입에 null 값이 오면 실패
// 날짜/시간 처리
disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS) // 날짜를 타임스탬프가 아닌 ISO-8601 형식으로
registerModule(JavaTimeModule()) // Java 8 날짜/시간 타입 지원
}
2. 명명 규칙 설정
objectMapper.apply {
// 프로퍼티 명명 규칙
setPropertyNamingStrategy(PropertyNamingStrategies.SNAKE_CASE) // snake_case
setPropertyNamingStrategy(PropertyNamingStrategies.LOWER_CAMEL_CASE) // camelCase
setPropertyNamingStrategy(PropertyNamingStrategies.UPPER_CAMEL_CASE) // PascalCase
setPropertyNamingStrategy(PropertyNamingStrategies.KEBAB_CASE) // kebab-case
}
3. 직렬화 상세 설정
objectMapper.apply {
// Pretty Printing
enable(SerializationFeature.INDENT_OUTPUT)
// Enum 처리
configure(SerializationFeature.WRITE_ENUMS_USING_TO_STRING, true)
configure(SerializationFeature.WRITE_ENUMS_USING_INDEX, false)
// 빈 컬렉션 처리
serializationInclusion(JsonInclude.Include.NON_EMPTY)
// 날짜 형식
configure(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS, false)
defaultDateFormat(new SimpleDateFormat("yyyy-MM-dd HH:mm:ss"))
// Java 8 날짜 타입을 위한 모듈 추가
addModule(new JavaTimeModule())
}
4. 역직렬화 상세 설정
objectMapper.apply {
// 숫자 처리
configure(DeserializationFeature.USE_BIG_DECIMAL_FOR_FLOATS, true) // 실수를 BigDecimal로 처리
configure(DeserializationFeature.USE_BIG_INTEGER_FOR_INTS, true) // 정수를 BigInteger로 처리
// 컬렉션 처리
configure(DeserializationFeature.ACCEPT_SINGLE_VALUE_AS_ARRAY, true) // 단일 값을 배열로 처리
configure(DeserializationFeature.ACCEPT_EMPTY_STRING_AS_NULL_OBJECT, true) // 빈 문자열을 null로 처리
// 타입 강제 변환
configure(DeserializationFeature.ADJUST_DATES_TO_CONTEXT_TIME_ZONE, true) // 날짜를 컨텍스트 타임존으로 조정
}
5. 모듈 등록 및 확장
ObjectMapper mapper = JsonMapper.builder()
// Java 8 날짜/시간 지원
.addModule(new JavaTimeModule())
// Kotlin 지원 - 최신 버전
.addModule(new KotlinModule.Builder()
.build())
// JDK 8 타입 지원
.addModule(new Jdk8Module())
// 파라미터 이름 지원
.addModule(new ParameterNamesModule())
// Afterburner 대신 Blackbird 모듈 사용 (성능 최적화)
.addModule(new BlackbirdModule())
.build();
6. 보안 관련 설정
ObjectMapper mapper = JsonMapper.builder()
// 민감한 데이터 처리
.configure(MapperFeature.DEFAULT_VIEW_INCLUSION, false)
// 다중 정의 처리
.configure(DeserializationFeature.FAIL_ON_IGNORED_PROPERTIES, true)
.configure(DeserializationFeature.FAIL_ON_INVALID_SUBTYPE, true)
.build();
7. 성능 최적화 설정
ObjectMapper mapper = JsonMapper.builder()
// 캐시 설정
.configure(MapperFeature.USE_ANNOTATIONS, true)
.configure(MapperFeature.USE_GETTERS_AS_SETTERS, true)
// JsonFactory 설정
.configure(JsonGenerator.Feature.AUTO_CLOSE_TARGET, false)
.configure(JsonGenerator.Feature.FLUSH_PASSED_TO_STREAM, false)
.build();
추천하는 설정
@Configuration
public class JacksonConfig {
@Bean
public ObjectMapper objectMapper() {
return JsonMapper.builder()
// 기본적인 예외 처리 설정
.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false) // 알 수 없는 속성 무시
.configure(DeserializationFeature.READ_UNKNOWN_ENUM_VALUES_AS_NULL, true) // 알 수 없는 ENUM값은 null로 처리
.configure(DeserializationFeature.ACCEPT_SINGLE_VALUE_AS_ARRAY, true) // 단일 값을 배열로 처리 허용
// Null 및 빈 값 처리
.serializationInclusion(JsonInclude.Include.NON_NULL) // null 값 제외
// 날짜/시간 처리
.configure(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS, false) // ISO-8601 형식 사용
.addModule(new JavaTimeModule()) // Java 8 날짜/시간 타입 지원
// 명명 규칙
.propertyNamingStrategy(PropertyNamingStrategies.SNAKE_CASE) // snake_case 사용
// 성능 최적화
.configure(MapperFeature.USE_ANNOTATIONS, true) // 어노테이션 캐싱
.configure(JsonParser.Feature.ALLOW_UNQUOTED_FIELD_NAMES, true) // 따옴표 없는 필드명 허용
// 포맷팅 (개발 환경에서 유용)
.configure(SerializationFeature.INDENT_OUTPUT, true) // 보기 좋게 출력
.build();
}
}
그리고 Processing 관련 Exception은 벗겨낼 수 없으니, Wrapper 클래스를 정의해서 쓰는 걸 추천한다
@Component
@Slf4j
public class JsonConverter {
private final ObjectMapper objectMapper;
public JsonConverter(ObjectMapper objectMapper) {
this.objectMapper = objectMapper;
}
public <T> Optional<T> fromJson(String json, Class<T> type) {
try {
return Optional.ofNullable(objectMapper.readValue(json, type));
} catch (JsonProcessingException e) {
log.error("JSON 변환 실패: {}", e.getMessage());
return Optional.empty();
}
}
public Optional<String> toJson(Object object) {
try {
return Optional.ofNullable(objectMapper.writeValueAsString(object));
} catch (JsonProcessingException e) {
log.error("JSON 변환 실패: {}", e.getMessage());
return Optional.empty();
}
}
// Collection 타입을 위한 메서드
public <T> Optional<List<T>> fromJsonList(String json, Class<T> type) {
try {
JavaType listType = objectMapper.getTypeFactory().constructCollectionType(List.class, type);
return Optional.ofNullable(objectMapper.readValue(json, listType));
} catch (JsonProcessingException e) {
log.error("JSON 리스트 변환 실패: {}", e.getMessage());
return Optional.empty();
}
}
}
사용 예시
@Service
@RequiredArgsConstructor
public class UserService {
private final JsonConverter jsonConverter;
public User processUser(String jsonData) {
return jsonConverter.fromJson(jsonData, User.class)
.orElseThrow(() -> new BusinessException("유효하지 않은 JSON 데이터"));
}
public List<User> processUsers(String jsonData) {
return jsonConverter.fromJsonList(jsonData, User.class)
.orElseGet(Collections::emptyList);
}
}
MappingJackson2HttpMessageConverter 관련 이슈(+추가)
글과 관련해서 속해 있는 커뮤니티에서 나온 이슈에 대해서 공유합니다.
1. 외부 협력사와 API 통신을 하는데 WebClient, RestClient, RestTemplate으로 전송 시 본문 내용이 비어있다는 에러가 나옴
2. okHttp, HttpOpenConnection, OpenFeign으로는 정상 동작 됨
3. 이상하게 객체를 바로 직렬화하지 않고, 문자열을 담아서 전송하면 정상 동작함
원인
결론부터 말하면 청크 전송이 문제가 된 경우이다. wireshark, tshark 등 패킷을 분석하는 도구로 분석해보면 오브젝트를 바로 바디에 넣어서 전송을 하면 청크 단위로 끊어서 전송이 되고, String을 바디에 넣으면 문자열 전체로 전송이 된다. 따라서 외부 협력사가 청크 전송을 지원하지 않는다면 문제가 발생할 수 있다
기본적으로 Object를 전송하게 되면, 위에서 살펴봤듯이 MappingJackson2HttpMessageConverter가 직렬화와 역직렬화를 담당하게 된다. 하지만 String을 전송하게 되면, 이 역할을 StringHttpMessageConverter가 담당한다 StringHttpMessageConverter는 내부의 getContentLength 메소드에서 String의 길이를 제공한다
public class StringHttpMessageConverter extends AbstractHttpMessageConverter<String> {
...
protected Long getContentLength(String str, @Nullable MediaType contentType) {
Charset charset = this.getContentTypeCharset(contentType);
return (long)str.getBytes(charset).length;
}
...
}
그에 반해서, MappingJackson2HttpMessageConverter는 AbstractJackson2HttpMessageConverter를 확장하고 있고,
public class MappingJackson2HttpMessageConverter extends AbstractJackson2HttpMessageConverter {
...
}
AbstractJackson2HttpMessageConverter를 타고 들어가서 getContentLength를 보면 상위의 메소드를 사용하고 있는데
public abstract class AbstractJackson2HttpMessageConverter extends AbstractGenericHttpMessageConverter<Object> {
...
@Nullable
protected Long getContentLength(Object object, @Nullable MediaType contentType) throws IOException {
if (object instanceof MappingJacksonValue mappingJacksonValue) {
object = mappingJacksonValue.getValue();
}
return super.getContentLength(object, contentType);
}
...
}
super.getContentLength를 타고 들어가보면
public abstract class AbstractHttpMessageConverter<T> implements HttpMessageConverter<T> {
...
@Nullable
protected Long getContentLength(T t, @Nullable MediaType contentType) throws IOException {
return null;
}
...
}
그냥 단순히 null을 반환하고 있다!!??!!?!
RestTemplate의 대체로 여겨지는 RestClient 팩토리의 경우 따로 설정하지 않으면 기본값으로 JdkClientHttpRequestFactory를 사용합니다. JdkClientHttpRequestFactory에서 JdkClientHttpRequest를 생성해서 쓰게 된다
final class DefaultRestClientBuilder implements RestClient.Builder {
...
private ClientHttpRequestFactory initRequestFactory() {
if (this.requestFactory != null) {
return this.requestFactory;
} else if (httpComponentsClientPresent) {
return new HttpComponentsClientHttpRequestFactory();
} else if (jettyClientPresent) {
return new JettyClientHttpRequestFactory();
} else {
return (ClientHttpRequestFactory)
(jdkClientPresent ? new JdkClientHttpRequestFactory() : new SimpleClientHttpRequestFactory());
}
}
...
}
JdkClientHttpRequest는 Http1Request를 사용하는데 이 객체의 headers()를 한번 보자
class Http1Request {
...
List<ByteBuffer> headers() {
if (Log.requests() && request != null) {
Log.logRequest(request.toString());
}
String uriString = requestURI();
StringBuilder sb = new StringBuilder(64);
sb.append(request.method())
.append(' ')
.append(uriString)
.append(" HTTP/1.1\r\n");
URI uri = request.uri();
if (uri != null) {
systemHeadersBuilder.setHeader("Host", hostString());
}
// GET, HEAD and DELETE with no request body should not set the Content-Length header
if (requestPublisher != null) {
contentLength = requestPublisher.contentLength();
if (contentLength == 0) {
systemHeadersBuilder.setHeader("Content-Length", "0");
} else if (contentLength > 0) {
systemHeadersBuilder.setHeader("Content-Length", Long.toString(contentLength));
streaming = false;
} else {
streaming = true;
systemHeadersBuilder.setHeader("Transfer-encoding", "chunked");
}
}
collectHeaders0(sb);
String hs = sb.toString();
logHeaders(hs);
ByteBuffer b = ByteBuffer.wrap(hs.getBytes(US_ASCII));
return List.of(b);
}
...
}
만약에 앞서 살펴본 SimpleClientHttpRequestFactory를 선택해서 사용하더라도 SimpleClientHttpRequest의 excuteInternal 메소드를 살펴보면 동일하게 청크 전송을 확인할 수 있다
만약 이러한 이슈 해결을 위해서 청크 전송을 사용하지 않는 것을 목표로 한다면,
1. MappingJackson2HttpMessageConverter의 getContentLength를 오버라이드해서 RestClient에 추가해서 사용한다
2. 요청 헤더에 Content-Type을 test/plain으로 설정해서 ObjectToStringHttpMessageConverter를 사용하도록 한다
3. RestClient에서 BufferingClientHttpRequestFactory를 사용하도록 해서 버퍼 처리를 한다
- 이 경우 버퍼로 인한 메모리 사용량이 증가하니 테스트가 필요하다
이 내용은 릴리즈 노트에 명시되었다고 한다 스프링 6.1 부터 메모리 최적화를 위해서 RestClient, RestTemplate의 구현체의 경우 요청 바디를 버퍼에 담지 않도록 변경되었고, 따라서 Content-Length 헤더가 세팅되지 않도록 수정되었다고 한다(릴리즈 노트를....확인해야 하다니....24.10.28일자의 아주 따끈한 뉴스다)
https://github.com/spring-projects/spring-framework/wiki/Spring-Framework-6.1-Release-Notes
스프링 문서에 따르면 전송 전에 바디 버퍼를 강제로 쓰게 하려면 ClientHttpRequestFactory를 BufferingClientHttpRequestFactory로 래핑해서 쓰라고 안내하고 있다
이슈의 그럼 okHttp, HttpOpenConnection, OpenFeign 들은 되는 부분은 뭐냐고 했을 때, 이 부분은 http 프로토콜 버전과 연관되어 있었습니다. 통신 프로토콜이 1.0이었는데 Spring의 Client들 경우 Content-length가 없는 상태에서 예외가 발생하면서 커넥션을 클로즈해서 실패한 것처럼 보였고, 외부 라이브러리들의 경우 Content-length가 있는 상태에서 길이만큼 커넥션이 살아있는 상태라서 마치 전송되는 것처럼 보였던 겁니다. 따라서 이 부분도 상대 서버가 청크 전송을 지원하지 않았다면 결과는 실패했을 겁니다.
'Spring' 카테고리의 다른 글
왜 기본으로 HikariCP를 선택할까? 어떤 옵션이 있을까? (3) | 2024.12.25 |
---|---|
Spring Boot 배포 시 스레드 개수에 대한 궁금증 정리 (0) | 2024.12.18 |