WebClient와 ObjectMapper 직렬/역직렬화 문제 해결하기

임현규·2023년 6월 20일
0

Meca project 개발 일지

목록 보기
21/27

WebClient에서 역직렬화 문제

이번에 어플리케이션 서버에서 형태소를 분석해서 점수를 매기기 위해서 형태소 분석을 외부 API로 부터 호출하기로 했다. 문제는 WebClient로 호출할 API의 응답형태는 다음과 같았다.

이러한 형태를 받기위해 NlpToken이라는 타입을 만들었다.

@Getter
public class NlpToken {

	private final String morph;
	private final MorphemePosition pos;
	private final int beginIndex;
	private final int endIndex;

	public NlpToken(String morph, String pos, int beginIndex, int endIndex) {
		this.morph = morph;
		this.pos = MorphemePosition.valueOf(pos.toUpperCase());
		this.beginIndex = beginIndex;
		this.endIndex = endIndex;
	}

	public NlpToken(String morph, MorphemePosition pos, int beginIndex, int endIndex) {
		this.morph = morph;
		this.pos = pos;
		this.beginIndex = beginIndex;
		this.endIndex = endIndex;
	}

	@Override
	public boolean equals(Object o) {
		if (this == o)
			return true;
		if (o == null || getClass() != o.getClass())
			return false;
		NlpToken nlpToken = (NlpToken)o;
		return beginIndex == nlpToken.beginIndex && endIndex == nlpToken.endIndex && Objects.equals(morph,
			nlpToken.morph)
			&& pos == nlpToken.pos;
	}

	@Override
	public int hashCode() {
		return Objects.hash(morph, pos, beginIndex, endIndex);
	}

	@Override
	public String toString() {
		return "Token{" +
			"morph='" + morph + '\'' +
			", pos=" + pos +
			", startIndex=" + beginIndex +
			", endIndex=" + endIndex +
			'}';
	}
}

그리고 이 타입을 응답으로 api를 호출하기 위해 다음과 같이 코드를 짯다.

@Component("koreanMorphemeAnalyzer")
@RequiredArgsConstructor
public class KoreanMorphemeAnalyzer implements MorphemeAnalyzer {

	private final WebClient webClient;

	@Override
	public List<NlpToken> analyze(String text) {
		return webClient.get()
			.uri("http://localhost:8070/api/v1/konlp/analyze?text=" + text)
			.retrieve()
			.bodyToMono(new ParameterizedTypeReference<List<NlpToken>>() {
			})
			.block();
	}
}

문제는 이 코드를 실행시에 다음과 같은 오류가 발생했다.

Caused by: com.fasterxml.jackson.databind.exc.InvalidDefinitionException: Cannot construct instance of com.almondia.meca.cardhistory.domain.vo.NlpToken (no Creators, like default constructor, exist): cannot deserialize from Object value (no delegate- or property-based Creator)

문제 해결하기

위의 문제는 기본적으로 역직렬화를 위해 필요한 기본 생성자(인자 없는 생성자)가 존재하지 않기 때문에 발생하는 것이다.

기본 생성자를 이용해 해결하기

기본 생성자를 활용하면 위의 문제를 해결할 수 있을지도 모른다. 그러려면 NlpToken 타입의 코드를 수정해야 한다.

final을 제거하고 기본 생성자 추가하기

NlpToken타입의 큰 문제점은 필드가 final로 지정되어서 생성자를 이용한 인스턴스 생성이 강제된다는 것이다. 이 때문에 objectMapper에서 역직렬화시 기본 생성자로 타입을 생성하고 필드 주입을 수행할 수 없다.

고치면 다음과 같다.

@Getter
@NoArgsConstructor(access = lombok.AccessLevel.PROTECTED)
public class NlpToken {

	private String morph;
	private MorphemePosition pos;
	private int beginIndex;
	private int endIndex;

	public NlpToken(String morph, String pos, int beginIndex, int endIndex) {
		this.morph = morph;
		this.pos = MorphemePosition.valueOf(pos.toUpperCase());
		this.beginIndex = beginIndex;
		this.endIndex = endIndex;
	}

	public NlpToken(String morph, MorphemePosition pos, int beginIndex, int endIndex) {
		this.morph = morph;
		this.pos = pos;
		this.beginIndex = beginIndex;
		this.endIndex = endIndex;
	}

	@Override
	public boolean equals(Object o) {
		if (this == o)
			return true;
		if (o == null || getClass() != o.getClass())
			return false;
		NlpToken nlpToken = (NlpToken)o;
		return beginIndex == nlpToken.beginIndex && endIndex == nlpToken.endIndex && Objects.equals(morph,
			nlpToken.morph)
			&& pos == nlpToken.pos;
	}

	@Override
	public int hashCode() {
		return Objects.hash(morph, pos, beginIndex, endIndex);
	}

	@Override
	public String toString() {
		return "NlpToken{" +
			"morph='" + morph + '\'' +
			", pos=" + pos +
			", beginIndex=" + beginIndex +
			", endIndex=" + endIndex +
			'}';
	}
}

개발자가 기본 생성자로 접근하는것을 막기 위해 protected로 지정하고 최대한 프레임워크에서만 활용하도록 한다. 그리고 setter는 존재하지 않고 인자를 포함한 생성자를 이용해 타입의 인스턴스를 생성한다.

한계점

개발자가 직접 접근하지 못하게 public 대신 protected로 생성한다고 해도 여전히 reflection에 의해 Value Object의 특성이 깨질 위험이 있다. proxy로 생성하거나, 기본 생성자를 생성해 필드 주입하는 것의 경우 어쩔수 없이 이러한 위험성을 감수해야하긴 하다. 그리고 필드 주입의 경우 생성자를 이용한 전처리가 불가능하기 때문에 이 또한 아쉬운 부분이다.

Deserializer 모듈을 이용해 해결하기

ObjectMapper 생성후 빈 등록 이전에 jackson의 Module을 활용해서 Deserializer를 등록할 수 있다.

public class NlpTokenModule extends SimpleModule {

	@Override
	public String getModuleName() {
		return super.getModuleName();
	}

	@Override
	public void setupModule(SetupContext context) {
		SimpleDeserializers simpleDeserializers = new SimpleDeserializers();
		simpleDeserializers.addDeserializer(NlpToken.class, new NlpTokenDeSerializer());
		context.addDeserializers(simpleDeserializers);
	}
}

우선 SimpleModule을 상속해서 필요한 DeSerializer 등록을 해준다.

public class NlpTokenDeSerializer extends StdDeserializer<NlpToken> {

	protected NlpTokenDeSerializer() {
		this(null);
	}

	protected NlpTokenDeSerializer(Class<?> vc) {
		super(vc);
	}

	@Override
	public NlpToken deserialize(JsonParser p, DeserializationContext ctxt) throws IOException {
		JsonNode jsonNode = p.getCodec().readTree(p);
		String morph = jsonNode.get("morph").asText();
		String pos = jsonNode.get("pos").asText();
		int startIndex = jsonNode.get("beginIndex").asInt();
		int endIndex = jsonNode.get("endIndex").asInt();
		return new NlpToken(morph, pos, startIndex, endIndex);
	}
}

그리고 다음과 같이 표준 Deserializer를 활용해서 간단하게 역직렬화 로직을 구현한다.

deserialize 메서드를 설명하자면, JsonParser를 활용해 JsonNode 타입을 리턴한다. JsonNode에서 필요한 field(json key)를 요청하면 value 값을 얻을 수 있고 이를 어떤 타입으로 리턴할지 결정한다. 그리고 이를 NlpToken 생성자를 이용해 인스턴스를 생성한다.

이 방법을 이용하면 Value Object의 특성에 맞게 생성자 호출 방식으로 NlpToken의 역직렬화를 구현할 수 있고 별도의 어노테이션을 NlpToken에 추가하지 않기 때문에 NlpToken이 Jackson 라이브러리에 의존하지 않는 장점이 있다.

@Configuration
public class JacksonConfiguration {

	@Bean
	@Primary
	public ObjectMapper getObjectMapper() {
		return new ObjectMapper()
			.registerModule(new LocalDateTimeModule())
			.registerModule(new WrapperModule())
			.registerModule(new NlpTokenModule())
			.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false);
	}
}

최종적으로 ObjectMapper 생성시 만든 모듈을 등록해준다.

여전히 동작하지 않는 문제

그러나 문제는 역직렬화를 설정해서 이를 바탕으로 ObjectMapper를 Bean으로 등록했음에도 위와 똑같은 문제의 에러가 발생했다. 똑같은 에러가 발생했다는 것은 우리가 추가한 Deserializer가 정상적으로 동작하지 않는다는 것을 의미했다. 디버깅 결과 실제로 Deserializer의 deserialize 메서드를 실행 조차 하지 않는다.

그래서 ObjectMapper 생성시 모듈이 정상적으로 추가되지 않았거나 Bean 생성시 커스텀으로 만든 ObjectMapper가 제대로 bean으로 관리되지 않는가 의심이 들었지만 테스트 결과 모두 문제가 없었다.

WebClient 등록시 ObjectMapper를 따로 등록해주어야 한다.

WebClient에서 직렬화/역직렬화시 Jackson2JsonEncoder, Jackson2JsonDecoder를 사용한다. 해당 타입은 spring 기본 ObjectMapper를 기반으로 인스턴스를 생성하고 이 때문에 우리가 지정한 역직렬화가 동작하지 않는 것이였다. 그래서 WebClient에 다음과 같은 코드를 추가해주면 된다.

@Configuration
@RequiredArgsConstructor
public class WebConfiguration {

	private final ObjectMapper objectMapper; // objectMapper 생성자 주입

	@Bean
	public WebClient getWebClient() {
		return WebClient.builder()
			.clientConnector(new ReactorClientHttpConnector(getHttpClient()))
			.defaultHeader(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_FORM_URLENCODED_VALUE)
			.codecs(configurer -> {
                // jsonEncoder와 jsonDecoder 생성시 주입받은 objectMapper를 이용해 생성하도록 한다.
				configurer.defaultCodecs().jackson2JsonEncoder(new Jackson2JsonEncoder(objectMapper));
				configurer.defaultCodecs().jackson2JsonDecoder(new Jackson2JsonDecoder(objectMapper));
			})
			.filter(new WebClientExceptionHandler())
			.build();
	}

	private HttpClient getHttpClient() {
		return HttpClient.create()
			.responseTimeout(Duration.ofSeconds(5))
			.doOnConnected(connection -> connection.addHandlerLast(new ReadTimeoutHandler(5))
				.addHandlerLast(new WriteTimeoutHandler(5)));
	}

}

성공

WebClient로 외부 API에서 요청한 정보를 원하는 타입의 생성자방식으로 역직렬화하는데 성공했다.

profile
엘 프사이 콩그루

0개의 댓글