티스토리 뷰

Spring

[Spring] 가상 스레드(Virtual Thread)를 지원하기 위한 스프링의 작업들과 마이그레이션 시의 주의 사항

망나니개발자 2023. 11. 28. 10:00
반응형

 

 

 

1. 가상 스레드(Virtual Thread)를 지원하기 위한 스프링의 작업들


스프링 부트(Spring Boot) 3.2부터 가상 스레드를 공식적으로 지원하기 시작했다. 스프링은 구체적으로 가상 스레드를 지원하기 위해 다음과 같은 작업들을 진행해왔는데, 각각 자세히 살펴보도록 하자.

  • 가상 스레드 사용 여부 프로퍼티 추가
  • 스레드 모델 Enum(Threading)과 Condition 어노테이션 추가
  • 가상 스레드 사용 여부에 따른 빈 등록

 

 

[ 가상 스레드 사용 여부 프로퍼티 추가 ]

스프링 부트는 애플리케이션 구성을 위한 메타 데이터를 spring-boot-autoconfigure 모듈의 spring-configuration-metadata.json으로 관리한다. 가상 스레드를 지원하기 위한 프로퍼티 역시 해당 파일에 추가되었다.

 

 

 

따라서 해당 프로퍼티 값을 true로 설정하면 스프링 부트에서 가상 스레드를 사용할 수 있다.

spring.threads.virtual.enabled=true

 

 

가상 스레드는 JVM 스택에 큰 변화를 일으키는 기술이고, 수 많은 예시 코드들과 관련 아티클들이 작성되었다. 하지만 JVM 개발자들은 자바 애플리케이션 개발자들이 가상 스레드의 의도를 제대로 이해하지 못했을 뿐만 아니라 올바르게 사용하지 않는 경우가 상당히 많았다고 한다. 그래서 “가상 스레드를 지나치게 쉽게 이용할 수 있도록 만들었구나” 라는 생각까지 든다고 한다. 왜냐하면 제대로 된 이해 없이 “몇 줄의 코드만 고쳐도 가상 스레드가 적용된다”는 내용들이 상당히 많았기 때문이다. 따라서 단순히 값을 바꿔서 적용하는게 아니라 이에 대해 제대로 이해하고 적용할 필요가 있다.

 

 

 

[ 스레드 모델 Enum(Threading)과 Condition 어노테이션 추가 ]

가상 스레드를 지원함에 따라 이제 애플리케이션의 스레드 모델은 가상 스레드 방식과 플랫폼 스레드 방식으로 나뉘게 되었다. 따라서 스프링 부트는 현재 애플리케이션이 갖는 스레드 모들을 구분하기 위한 Enum 클래스를 추가하였다. 애플리케이션이 가상 스레드 모델로 설정되었는지 판단할 때 앞서 살펴본 프로퍼티 값을 참고하고 있다.

public enum Threading {

    PLATFORM {

        @Override
        public boolean isActive(Environment environment) {
            return !VIRTUAL.isActive(environment);
        }
    },

    VIRTUAL {

        @Override
        public boolean isActive(Environment environment) {
            return environment.getProperty("spring.threads.virtual.enabled", boolean.class, false)
                && JavaVersion.getJavaVersion().isEqualOrNewerThan(JavaVersion.TWENTY_ONE);
        }
    };

    public abstract boolean isActive(Environment environment);
}

 

 

또한 스레드 모델 조건에 따라 빈을 다르게 생성하기 위한 Condition 어노테이션과 구현체도 추가되었다.

@Target({ ElementType.TYPE, ElementType.METHOD })
@Retention(RetentionPolicy.RUNTIME)
@Documented
@Conditional(OnThreadingCondition.class)
public @interface ConditionalOnThreading {

    Threading value();

}

 

 

 

[ 가상 스레드 사용 여부에 따른 빈 등록 ]

스프링이 사용하는 모듈들 중에서 가상 스레드로 교체 가능한 부분은 결국 멀티 스레드를 사용하는 부분들이다. 스프링에서는 대표적으로 아래의 경우에서 멀티 스레드가 사용되고 있다.

  • 멀티 스레드 요청을 처리하는 웹 서버
  • @Async 사용을 위한 TaskExecutor
  • @Schedule 사용을 위한 TaskScheduler
  • Redis, Kafka, RabbitMQ 등

대표적으로 그리고 필수적으로 사용되는 톰캣 관련 부분만 들여다보도록 하자.

다음은 스프링의 톰캣 자동 구성 클래스의 일부이다. 코드를 보면 conditioan 어노테이션을 사용하여 조건에 따라 가상 스레드 기반의 웹서버를 빈으로 등록하고 있다.

@AutoConfiguration
@ConditionalOnNotWarDeployment
@ConditionalOnWebApplication
@EnableConfigurationProperties(ServerProperties.class)
public class EmbeddedWebServerFactoryCustomizerAutoConfiguration {

    @Configuration(proxyBeanMethods = false)
    @ConditionalOnClass({ Tomcat.class, UpgradeProtocol.class })
    public static class TomcatWebServerFactoryCustomizerConfiguration {

        @Bean
        public TomcatWebServerFactoryCustomizer tomcatWebServerFactoryCustomizer(Environment environment,
            ServerProperties serverProperties) {
            return new TomcatWebServerFactoryCustomizer(environment, serverProperties);
        }

        @Bean
        @ConditionalOnThreading(Threading.VIRTUAL)
        TomcatVirtualThreadsWebServerFactoryCustomizer tomcatVirtualThreadsProtocolHandlerCustomizer() {
            return new TomcatVirtualThreadsWebServerFactoryCustomizer();
        }

    }

  ...

}

 

 

하지만 결국 스프링은 톰캣을 활용할 뿐이므로, 톰캣 자체에서 가상 스레드 모드를 지원해주어야 한다. 이를 위해 톰캣은 각각의 task를 위해 가상 스레드를 사용하는 VirtualThreadExecutor를 추가하였다. 기존의 멀티 스레드 모델을 위해서는 스레드풀에 미리 스레드를 생성해두고 재사용하는 ThreadPoolExecutor가 사용되었다.

public class VirtualThreadExecutor implements Executor {

    private final JreCompat jreCompat = JreCompat.getInstance();

    private Object threadBuilder;

    public VirtualThreadExecutor(String namePrefix) {
        threadBuilder = jreCompat.createVirtualThreadBuilder(namePrefix);
    }

    @Override
    public void execute(Runnable command) {
        jreCompat.threadBuilderStart(threadBuilder, command);
    }
}

 

 

그 외에도 스프링은 @Async나 @Scheduled와 같은 어노테이션을 통해 비동기 프로그래밍을 제공하였는데, 해당 부분 역시 가상 스레드를 조건에 따라 이용할 수 있도록 변경하였다. 또한 Redis, Kafka, RabbitMQ 등 역시도 가상 스레드 부분이 반영되어 있다.

만약 우리가 직접 빈으로 등록해서 사용하고 있는 쓰레드 풀이 있다면 해당 코드 역시도 변경될 필요가 있다.

@Configuration
@EnableAsync
public class AsyncConfig {

    @Bean(TaskExecutionAutoConfiguration.APPLICATION_TASK_EXECUTOR_BEAN_NAME)
    public Executor asyncTaskExecutor() {
        TaskExecutorAdapter taskExecutorAdapter = new TaskExecutorAdapter(Executors.newVirtualThreadPerTaskExecutor());
        taskExecutorAdapter.setTaskDecorator(new MdcTaskDecorator());
        return taskExecutorAdapter;
    }
}

 

 

 

 

2. 가상 스레드(Virtual Thread) 도입 시의 주의 사항


[ 가상 스레드에 대한 오해 ]

가상 스레드는 JVM 스택에 큰 변화를 일으키는 기술이며, 수 많은 예시 코드들과 관련 아티클들이 작성되었다. 하지만 JVM 개발자들은 애플리케이션 개발자들이 가상 스레드의 의도를 제대로 이해하지 못했을 뿐만 아니라 올바르게 사용하지 않는 경우가 상당히 많았다고 한다.

그래서 “지나치게 쉽게 가상 스레드를 이용할 수 있도록 만들었다” 라는 생각까지 든다고 한다. 제대로 된 이해 없이 “몇 줄의 코드만 고쳐도 가상 스레드가 적용된다”는 내용들이 상당히 많았기 때문이다. 대표적으로 다음과 같은 오해와 실수 또는 문제들이 있다.

  • 오해
    • 가상 스레드가 더 빠른 스레드라고 착각한다.
    • 가상 스레드 고정(pinning)으로 인해 monitor를 사용하는 모든 곳에 변경이 필요하다고 가정한다.
    • 가상 스레드로 인한 이점이 어디서 오는 것인지 오해한다.
    • warm-up 문제가 줄어들 것이라고 생각한다. 가상 스레드는 자바로 작성된 스레드이므로 실행할 코드가 더 많아 warm-up 문제가 더 부각된다.
  • 실수
    • 태스크(Task)를 가상 스레드로 바꾸는 것이 아니라 플랫폼 스레드를 가상 스레드로 바꾼다.
    • 스레드 풀을 위한 ThreadFactory 코드를 변경하고, 가상 스레드를 풀링한다.
    • 스레드 로컬을 무겁게 사용하는 라이브러리와 프레임워크를 사용한다.
    • 논블로킹 혹은 멀티 플렉서를 활용하는 기술과 결합하여 사용한다.

 

 

 

[ 가상 스레드 적용 시의 주의 사항 ]

따라서 JVM 개발자가 직접 다음의 가상 스레드 도입 가이드를 제안하였다.

  • 간단한 blocking/synchronous 코드로 넘어가기
  • 플랫폼 스레드를 가상 스레드로 바꾸는 것이 아니라 태스크(Task)를 가상 스레드로 바꾸기
  • 동시성 제한을 위해 스레드 풀이 아닌 세마포어와 같은 기술을 사용하기
  • 스레드 로컬에 무거운 객체를 캐시하지 않기

 

 

가상 스레드는 자바 객체일 뿐이므로 빠르게 생성하고 실행시킬 수 있다. 따라서 대부분의 경우에 굳이 무거운 논블로킹 기반의 기술과 결합하여 사용할 필요가 없다. 동기적 방식의 프로그래밍 모델을 유지하면 된다.

 

또한 디비 커넥션처럼 풀링이 필요한 곳에 가상 스레드를 적용하면 안된다. 가상 스레드는 빠르게 생성해서 실행하고 버리는 방식으로 사용하는 것이 바람직하다. 이는 스레드 풀을 이용해 동시성 제한을 하는 경우도 마찬가지다.

 

특히 주목할 부분은 스레드 로컬(Thread Local)과 관련된 부분이다. 스레드 로컬은 현재 스레드의 실행과 연관된 데이터들을 다루는 기법으로 캐싱, 파라미터 숨기기 등 다양한 목적으로 사용되고 있다. 하지만 스레드 로컬은 현실적으로 다음과 같은 문제를 갖고 있고, 이로 인해 메모리 누수나 메모리 에러 등이 발생할 수 있다.

  • 명확한 생명주기가 없음(unbounded lifetime)
  • 변경 가능성에 대해서 제약이 없음(unconstrained mutability)
  • 메모리 사용에 대해서 제약이 없음(unconstrained memory usage)
  • 값비싼 상속 기능을 사용하는 InheritableThreadLocal의 성능 문제

 

 

최종적으로 가상 스레드는 스레드 로컬을 지원하게 되었지만 이것이 JVM 개발자들이 원했던 방향은 아니다. 그들은 애초에 가상 스레드가 스레드 로컬을 지원하지 않도록 하여 많은 문제들을 예방하려고 했다. 하지만 수 많은 현존하는 코드들이 스레드 로컬을 사용하고 있었고, 선택의 여지 없이 스레드 로컬을 지원하도록 강제된 것이다.

따라서 가상 스레드로 전환하고자 한다면 무거운 객체를 스레드 로컬에 저장하지 않도록 해야 한다. 이를 위해 기본적으로 스레드 로컬의 사용에 대해 재검토를 할 필요가 있다. 실제로 JDK 내부에서도 스레드 로컬 사용으로 인한 성능을 재측정하여 일부 사용하는 부분을 제거하였다고 한다. 또한 스레드 로컬에 저장되는 메모리를 줄이기 위해 SimpleDateFormat 처럼 가변의 무거운 객체를 DateTimeFormatter와 같은 불변 객체로 전환하거나 글로벌 캐시를 사용하는 등이 도움이 된다.

물론 자바 개발자들 역시 스레드 로컬의 필요성 역시 인재하고 있기에, 기존의 스레드 로컬을 보완하기 위한 Scoped Values 라는 기능이 JEP-446 스펙으로 진행중이다. Scoped Values는 메서드 파라미터를 사용하지 않고 안전하고 효과적으로 메서드에 값을 전달할 수 있도록 한다. 이를 통해 스레드 로컬로 인한 문제를 최대한 예방할 수 있도록 지원할 것으로 보인다.

 

 

 

 

해당 포스팅을 작성하기 위해 스프링의 코드들을 보면서 역시나 추상화가 상당히 잘 되어 있고, 이로 인해 변경에 정말 유연하게 대처하는 객체 지향의 정수와 같은 프레임워크임을 다시 한번 느낄 수 있었던 것 같다.

개인적으로는 지식이 세부 기술에 지나치게 결합되는 것이 그렇게 바람직한 방향이라고 생각하지는 않지만, 스프링 만큼은 예외적으로 많이 보게 되는 것 같다. 프레임워크의 코드를 보면서 코드 작성 기법 뿐만 아니라 HTTP 관련 지식이나 디자인 패턴 등의 활용 역시 많이 배울 수 있었던 것 같다.

 

 

 

 

관련 포스팅

 

참고 자료

반응형
댓글
반응형
공지사항
최근에 올라온 글
최근에 달린 댓글
Total
Today
Yesterday
링크
TAG more
«   2024/04   »
1 2 3 4 5 6
7 8 9 10 11 12 13
14 15 16 17 18 19 20
21 22 23 24 25 26 27
28 29 30
글 보관함