Java Virtual Thread 도입 가이드: 실무 적용과 API 선택

배현서·2024년 12월 4일

시스템 사고

목록 보기
9/10
post-thumbnail

들어가며

Java 21에서 정식으로 도입된 Virtual Thread는 높은 동시성 처리를 위한 혁신적인 기능입니다. 하지만 실제 현업에서 Virtual Thread를 도입할 때는 신중한 접근이 필요합니다. 이번 글에서는 카카오페이의 Virtual Thread 도입 경험을 바탕으로 실무 적용 시 고려사항을 살펴보고, 더불어 현재 사용 가능한 API들의 특징과 선택 기준을 알아보겠습니다.

Part 1: Virtual Thread 실무 도입 시 고려사항

Virtual Thread의 주요 장점

  1. 리소스 효율성

    • 스레드 생성과 Context switch 오버헤드 약 10배 감소
    • 메모리 사용량 약 200배 감소
    • JVM Heap이 허용하는 한 제한 없는 가용성
  2. 개발 생산성

    • 기존 Thread 프로그래밍 모델 유지
    • 동시성 코드 작성 용이
    • Spring Boot 3.2부터 기본 지원

실무 적용 시 주의점

  1. 라이브러리 호환성

    • 모든 Java 라이브러리가 pinning 구간에 대해 완벽히 대응되지 않음
    • synchronized 블록 등에서 성능 저하 가능성
  2. 인프라 부하

    • 높은 가용성으로 인한 Redis, MySQL 등 인프라 과부하 위험
    • Connection Pool 고갈 가능성
    • 연계 서비스의 처리량 제한으로 인한 병목 현상
  3. 시스템 안정성

    • 장애의 전파 가능성
    • 전체 시스템에 대한 영향도 고려 필요

카카오페이의 도입 전략

  1. 선별적 적용
    • Spring Boot의 기본 Virtual Thread 설정은 비활성화
    spring.threads.virtual.enabled=false
  2. 점진적 도입
    • I/O 작업이 많은 특정 구간에만 Virtual Thread Executor 사용
    @Configuration
    public class AsyncConfig {
        @Bean
        public Executor customTaskExecutor() {
            return new TaskExecutorAdapter(
                new VirtualThreadTaskExecutor("custom-task-")
            );
        }
    }

Part 2: Virtual Thread API 선택 가이드

Virtual Thread를 구현할 때는 두 가지 주요 API를 선택할 수 있습니다: Preview 상태인 StructuredTaskScope와 안정화된 CompletableFuture입니다. 각각의 특징과 적용 사례를 살펴보겠습니다.

Preview API: StructuredTaskScope 이해하기

Preview 기능은 Java가 새로운 기능을 정식으로 채택하기 전에 개발자 커뮤니티의 피드백을 받기 위해 시험적으로 제공하는 기능입니다. StructuredTaskScope는 Project Loom의 일부로 제안된 구조적 동시성(Structured Concurrency) 구현체입니다.

Preview API 사용 시 고려사항

  1. 컴파일러 설정

    # 컴파일 시
    javac --enable-preview --release 21 YourFile.java
    
    # 실행 시
    java --enable-preview YourFile
  2. 경고 메시지

    java.util.concurrent.StructuredTaskScope.ShutdownOnFailure은(는) 
    테스트 버전 API이며 향후 릴리즈 버전에서 제거될 수 있습니다

API 구현 비교

1. StructuredTaskScope 구현 예시

public class ImageUploader {
    public void uploadImages(List<ImageData> images) {
        try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
            // 각 이미지에 대한 업로드 태스크 생성
            for (ImageData image : images) {
                scope.fork(() -> {
                    uploadImage(image);
                    return null;
                });
            }

            // 모든 작업 완료 대기
            scope.join().throwIfFailed();
            
        } catch (InterruptedException | ExecutionException e) {
            logger.error("이미지 업로드 중 오류 발생", e);
            throw new ImageUploadException("업로드 실패", e);
        }
    }
}

2. CompletableFuture 구현 예시

public class ImageUploader {
    public void uploadImages(List<ImageData> images) {
        try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
            List<CompletableFuture<Void>> futures = images.stream()
                .map(image -> CompletableFuture.runAsync(
                    () -> uploadImage(image), 
                    executor
                ))
                .toList();

            // 모든 작업 완료 대기
            CompletableFuture.allOf(futures.toArray(CompletableFuture[]::new))
                .join();
                
        } catch (Exception e) {
            logger.error("이미지 업로드 중 오류 발생", e);
            throw new ImageUploadException("업로드 실패", e);
        }
    }
}

API 선택 기준

StructuredTaskScope 선택이 좋은 경우

  • 복잡한 동시성 패턴이 필요한 경우
  • 작업 그룹의 수명 주기를 명확하게 관리해야 하는 경우
  • 실험적인 프로젝트나 프로토타입에서 최신 기능을 적극 활용하고 싶은 경우
  • 코드의 가독성과 유지보수성이 중요한 경우

CompletableFuture 선택이 좋은 경우

  • 프로덕션 환경의 안정적인 서비스 구현이 필요한 경우
  • 기존 비동기 코드와의 호환성이 중요한 경우
  • 복잡한 작업 체인이나 조합이 필요한 경우
  • Preview 기능 사용에 제약이 있는 경우

실전 적용 예시: 대량 데이터 처리

실제 업무에서 자주 마주치는 대량 데이터 처리 시나리오를 통해 두 API의 사용법을 비교해보겠습니다.

시나리오: 대량 사용자 데이터 처리

public class UserDataProcessor {
    private final UserRepository userRepository;
    private final NotificationService notificationService;
    
    // StructuredTaskScope 사용 예시
    public void processUsersWithStructuredScope(List<Long> userIds) {
        try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
            for (Long userId : userIds) {
                scope.fork(() -> {
                    User user = userRepository.findById(userId);
                    processUserData(user);
                    notificationService.notify(user);
                    return null;
                });
            }
            scope.join().throwIfFailed();
        } catch (Exception e) {
            handleError(e);
        }
    }
    
    // CompletableFuture 사용 예시
    public void processUsersWithCompletableFuture(List<Long> userIds) {
        try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
            List<CompletableFuture<Void>> futures = userIds.stream()
                .map(userId -> CompletableFuture.runAsync(() -> {
                    User user = userRepository.findById(userId);
                    processUserData(user);
                    notificationService.notify(user);
                }, executor))
                .toList();
                
            CompletableFuture.allOf(futures.toArray(CompletableFuture[]::new))
                .join();
        } catch (Exception e) {
            handleError(e);
        }
    }
}

어떤 방식을 선택해야 할까?

Preview API 선택이 좋은 경우

  • 실험적인 프로젝트나 프로토타입 개발
  • 최신 Java 기능을 적극적으로 도입하고자 하는 경우
  • 코드의 간결성과 가독성이 중요한 경우

Stable API 선택이 좋은 경우

  • Production 환경의 안정적인 서비스
  • 장기적인 유지보수가 필요한 프로젝트
  • 기존 CompletableFuture 기반 코드와의 통합이 필요한 경우

결론

Virtual Thread는 Java의 동시성 처리에 있어 큰 혁신을 가져왔습니다. Preview API인 StructuredTaskScope는 더 간결하고 직관적인 코드를 작성할 수 있게 해주지만, 아직 실험적인 단계에 있습니다. 반면 CompletableFuture를 사용한 Stable API 방식은 더 안정적이지만 코드가 다소 복잡해질 수 있습니다.

프로젝트의 성격과 요구사항을 고려하여 적절한 방식을 선택하는 것이 중요합니다. Preview 기능들이 정식으로 채택되면 StructuredTaskScope를 사용하는 것이 더 좋은 선택이 될 것으로 예상됩니다.

참고 자료

0개의 댓글