Spring Boot 3.0 Migration

Murphy·2023년 2월 7일

들어가며

Migration에 대한 내 이야기를 하기 앞서 Spring Boot의 Major 버전 업그레이드의 경우 기본적으로 공식 가이드가 나온다. 따라서 모든 작업의 시작은 해당 가이드에서 시작하는게 좋다. 이 글은 내가 Migration을 하면서 경험한 몇 가지 사례에 대해서 언급하겠다.
이 글은 Migration 시점에서 최신 버전이었더 3.0.1 버전으로의 기록이며, 글을 작성하는 시점에서 3.0.2 버전이 최신 버전이다.

Spring Boot 3.0 Migration Guide

Migration에 앞서

Migration의 대상이 된 application의 주요 정보는 다음과 같다.

  • Java 17
  • Gradle 7.4.1
  • Spring Boot 2.7.5
  • AWS SQS
    • org.springframework.cloud:spring-cloud-aws-messaging
  • Swagger
    • springdoc-openapi
  • WireMock (Test용)
  • Embedded Mongo (Test용)

공식 가이드에 따르면 3.0으로의 전환 이전에 2.7.x 버전으로 migration을 권유하고 있다. 또한 Spring Framework/Spring Security와 관련된 migration은 별도의 문서를 참고해야 한다.

Spring Security: Migrating to 6.0
Spring Framework: Upgrading to Spring Framework 6.x

그리고 Java 버전은 17 또는 그 이상을 요구하며, Java 8은 지원하지 않는다.

Spring Initializr에서 Java 11 혹은 8을 선택 할 수 있지만 build.gradlesourceCompatibility의 항목은 17에서 변경되지 않는다.

내 이야기

JavaEE를 대신하는 Jakarta EE

가장 큰 변화였고, 가장 많은 파일을 수정하게 했다. 관련된 내용은 공식 가이드에 자세히 나온다. 기본적으로 javax.* 패키지를 사용하던 부분이 jakarta.*로 변경되는 느낌으로 보면 된다.

HttpSecurity.antMatchers

SecurityFilterChain을 구성하기 위해 HttpSecurity를 설정하던 메소드 중 다음과 같은 메소드를 사용하고 있었고 해당 설정은 이제 다른 방법을 사용해야 한다.

with 2.7.x

httpSecurity.antMatchers("/healthcheck").permitAll()

with 3.0.x

httpSecurity.requestMatchers(new AntPathRequestMatcher("/healthcheck")).permitAll()

Embedded Mongo

일부 테스트 코드에서 Embedded Mongo를 사용하고 있었다. 3.0 부터는 auto-configuration에서 지원하던 Flapdoodle embedded MongoDB가 제외되었고 다른 대안 두개가 주어졌다.

우선 Flapdoodle에서 제공하는 라이브러리를 사용하기로 결정했고 코드 레벨에서는 embedded mongo 버전을 선택하는 property를 제공하는 부분을 수정해야 했다.

with 2.7.x

@TestPropertySource(properties = "spring.mongodb.embedded.version=3.5.5")

with 3.0.x

@TestPropertySource(properties = "de.flapdoodle.mongodb.embedded.version=3.5.5")

버전의 경우 이전에 사용하던 버전과 동일하게 지정했다.

Embedded Mongo를 직접 사용하는 테스트가 적어 다른 대안인 Testcontainers도 적용해 보고 비교해 보아야 겠다.

Wiremock

JakartaEE로 변경하고 테스트 코드에서 문제가 발생 했다. 이유는 Wiremock이 JavaEE를 사용하기 때문으로 보인다. 그리도 다음과 같은 이슈를 찾았다. 결과적으로 standalone 버전을 사용하면 문제가 없다는 코멘트를 보고 dependancy를 변경했다.

Wiremock Issue

with 2.7.x

com.github.tomakehurst:wiremock-jre8

with 3.0.x

com.github.tomakehurst:wiremock-jre8-standalone

Swagger

Swagger 문서를 테스트 코드를 통해서 생성하고 있었다. 다 온줄 알았는데 Swagger 생성이 안되고 에러가 발생하기 시작했다.
역시 공홈에는 답이 있었다. Spring Boot 3.0 지원하는 v2 API가 새로 나왔다. 생각보다 가볍게 통과.

springdoc-openapi v2.0.2

AWS SQS Integration

지금까지 수정을 통해서 application이 실행되는데 문제가 없었다. 그런데 SQS에서 message를 받아오지 못한다.

(공식 가이드를 잘 읽자.)

Review Dependencies
The move to Spring Boot 3 will upgrade a number of dependencies and might require work on your end. You can review dependency management for 2.7.x with dependency management for 3.0.x to asses how your project is affected.
You may also use dependencies that are not managed by Spring Boot (e.g. Spring Cloud). As your project defines an explicit version for those, you need first to identify the compatible version before upgrading.

원인은 우선 기존까지 Spring에서 관리되던 아래 프로젝트가 독립했다. 따라서 namespace를 별도로 사용한다.

org.springframework.cloud:spring-cloud-aws-*

io.awspring.cloud:spring-cloud-aws-*

Spring Cloud for Amazon Web Services

Spring Cloud AWS

여기서 딜레마가 발생했다. Migration 시점에서 해당 라이브러리는 M3 릴리즈였다. 즉, 아직 정식 릴리즈가 나오지 않은 상태였다. 아직 개발 중인 버전을 사용할지, SQS만 사용하고 있으니 관련 부분을 구현할지 고민하다 개발 중인 버전을 사용하기로 했다.

현재는 3.0.0-RC1 이 릴리즈 된 상태이다.

단순환 메소드/클래스의 변경도 있었지만 다음의 두 부분은 나름 큰 변경이었어서 기록해 둔다.

SimpleMessageListenerContainer/SqsMessageListenerContainerFactory

SQS 설정부분에 migration외에 변경이 있어 1:1로 비교하기 어렵지만 아래와 같이 변경되었다. 기본적인 설정은 쉽게 유추 할 수 있었고 특이사항은 다음과 같다.

  • autoStartup 설정 불가
  • messageInterceptor 추가 제공

이전에는 autoStartupfalse로 설정해 별도의 조건에 따라 메시지를 받아 올 수 있게 동작 시점을 제어 하였다. 해당 기능이 아직 구현이 안된 것인지 앞으로 추가가 안되는 것인지 알 수 없지만, 3.0.0-M3 버전에서는 사용 할 수 없었다. 기존 동작과의 유사성을 맞추기 위해 ApplicationReadyEvent를 받아 stop하게 했으나 처음에 한번 정도 메시지를 수신 하는 것을 확인했다. 이 정도는 수용가능해서 적용 했으나 메시지를 받지 않고 끄는 방법에 대한 고민이 필요하다.

autoStartup의 경우 ContainerFactory가 아니라 Container를 생성하는 경우 제어할 수 있다.

void myMethod(SqsAsyncClient sqsAsyncClient) {
    SqsMessageListenerContainer<Object> container = SqsMessageListenerContainer
            .builder()
            .sqsAsyncClient(sqsAsyncClient)
            .messageListener(System.out::println)
            .queueNames("myTestQueue")
            .build();
    container.start();
    container.stop();
}

SqsMessageListenerContainer

또한 messageInterceptor를 별도로 제공해 주어야 했다. 자세한 내용은 뒤에서 설명하겠다.

3.0.0-RC1 변경사항

  • SqsMessageListenerContainerFactoryconfigure()에서 사용되는 optionsshutdownTimeoutlistenerShutdownTimeout로 변경되었다.
  • TaskExcutorThreadFactory를 제공해야 하며, MessageExecutionThreadFactory를 사용하면 된다.
protected SimpleAsyncTaskExecutor sqsThreadPoolTaskExecutor(String threadNamePrefix) {
        MessageExecutionThreadFactory factory = new MessageExecutionThreadFactory();
        factory.setThreadNamePrefix(threadNamePrefix);
        SimpleAsyncTaskExecutor executor = new SimpleAsyncTaskExecutor();
        executor.setThreadFactory(factory);
        executor.setTaskDecorator(new MdcDecorator());
        return executor;
    }

with 2.7.x

	@Bean
	public QueueMessageHandlerFactory queueMessageHandlerFactory(AmazonSQSAsync amazonSQSAsync) {
		QueueMessageHandlerFactory factory = new QueueMessageHandlerFactory();
		factory.setAmazonSqs(amazonSQSAsync);
		return factory;
	}

	@Bean
	public SimpleMessageListenerContainer simpleMessageListenerContainer(
		AmazonSQSAsync amazonSQSAsync, QueueMessageHandlerFactory queueMessageHandlerFactory
	) {
		SimpleMessageListenerContainer simpleMessageListenerContainer = new SimpleMessageListenerContainer();
		simpleMessageListenerContainer.setAutoStartup(false);
		simpleMessageListenerContainer.setAmazonSqs(amazonSQSAsync);
		simpleMessageListenerContainer.setWaitTimeOut(20); // for long polling
		if(queueMessageHandler!=null) {
			simpleMessageListenerContainer.setMessageHandler(queueMessageHandlerFactory.createQueueMessageHandler());
		}
		simpleMessageListenerContainer.setMaxNumberOfMessages(10);
		simpleMessageListenerContainer.setVisibilityTimeout(DEFAULT_VISIBILITY_TIMEOUT);
		simpleMessageListenerContainer.setQueueStopTimeout(15000);
		 simpleMessageListenerContainer.setTaskExecutor(
				 threadPoolTaskExecutor(
						 awsEventInfoHolder.getThreadPoolCoreSize(),
						 awsEventInfoHolder.getThreadPoolMaxSize()
		 ));

		return simpleMessageListenerContainer;
	}

with 3.0.x

    @Bean
    public MessageInterceptor<Object> messageInterceptor() {
        return new MessageInterceptor<Object>() {
            @Override
            public Message<Object> intercept(Message<Object> message) {
                return MessageBuilder
                        .fromMessage(message)
                        .setHeaders(new MessageHeaderAccessor(message))
                        .build();
            }
        };
    }
    
    @Bean
    SqsMessageListenerContainerFactory<Object> customSqsListenerContainerFactory(
            SqsAsyncClient sqsAsyncClient,
            MessageInterceptor<Object> messageInterceptor,
            SqsMessagingMessageConverter messageConverter,
            AWSEventInfoHolder awsEventInfoHolder
    ) {
        return getFactory(
                sqsThreadPoolTaskExecutor(awsEventInfoHolder),
                messageConverter,
                messageInterceptor,
                sqsAsyncClient
        );
    }

    private SqsMessageListenerContainerFactory<Object> getFactory(
            ThreadPoolTaskExecutor awsEventInfoHolder,
            SqsMessagingMessageConverter messageConverter,
            MessageInterceptor<Object> messageInterceptor,
            SqsAsyncClient sqsAsyncClient
    ) {
        return SqsMessageListenerContainerFactory
                .builder()
                .configure(options -> options
                        .componentsTaskExecutor(awsEventInfoHolder)
                        .messageVisibility(Duration.ofSeconds(DEFAULT_VISIBILITY_TIMEOUT))
                        .shutdownTimeout(Duration.ofSeconds(15000))
                        .maxMessagesPerPoll(10)
                        .pollTimeout(Duration.ofSeconds(10))
                        .queueNotFoundStrategy(QueueNotFoundStrategy.FAIL)
                        .messageConverter(messageConverter)
                )
                .messageInterceptor(messageInterceptor)
                .sqsAsyncClient(sqsAsyncClient)
                .build();
    }
    
    @EventListener(ApplicationReadyEvent.class)
    public void onStart() {
        if(registry.isRunning()) {
            registry.stop();
        }
    }

MessageInterceptor

이전 버전에서는 messageInterceptor에 대한 구현을 별도로 하지 않았으나 migration 한 뒤 별도 구현이 필요했다. 이 또한 의도된 변경인지 아직 확인 하지 않았으나, 제공하지 않는 경우 @Header 어노테이션으로 SQS 메시지의 헤더 정보를 가져 올 수 없었다. 이를 해결하기 위해서 헤더 정보를 받아 가공해 주기위한 messageInterceptor의 제공이 필요했다. 간단하게 메세지에 포한된 헤더를 전부 MessageBuilder에 넘기는 방법을 사용했다. 효율적인 사용을 위해서는 필요한 헤더만 넘기는 것이 좋을 것 같다.

    @Override
    @SqsListener(value = {
            "${--NAME OF QUEUE--}",
            "${--NAME OF QUEUE--}"
    },
    factory = "defaultSqsListenerContainerFactory")
    public void receiveMessage(
            AwsSqsEvent<String> event,
            @Header(AwsSqsMessageHeaderName.SENDERID) String senderId,
            @Header(AwsSqsMessageHeaderName.APPROXIMATERECEIVECOUNT) int receiveCount,
            @Header(AwsSqsMessageHeaderName.MESSAGEID) String messageId,
            Visibility visibility
    ) {
        // do something
    }
    @Bean
    public MessageInterceptor<Object> messageInterceptor() {
        return new MessageInterceptor<Object>() {
            @Override
            public Message<Object> intercept(Message<Object> message) {
                return MessageBuilder
                        .fromMessage(message)
                        .setHeaders(new MessageHeaderAccessor(message))
                        .build();
            }
        };
    }
profile
Anything that can go wrong will go wrong.

0개의 댓글