본문 바로가기
이직&취업/Java 기초 상식

동시성(Concurrency) API 에 대해 알아보자!!

by journeylabs 2025. 4. 19.
728x90
반응형

목차

     

    Java에서 멀티스레딩을 다룰 때, 기본적인 synchronizedThread 클래스만으로는 복잡한 동시성 문제를 해결하기 어렵습니다. 이때 등장하는 것이 바로 자바 동시성 API(java.util.concurrent)입니다. 이 강력한 도구들은 스레드 관리와 동기화를 더욱 효율적으로 만들어줍니다. 이 글에서는 자바 동시성 API의 주요 개념, 동작 원리, 코드 예시, Spring Framework에서의 실무 활용 사례, 그리고 주의사항까지 초보자도 이해하기 쉽게 상세히 다룹니다. 멀티스레딩의 고급 기술을 익히고 싶다면 지금 바로 읽어보세요!

    “멀티스레드를 제대로 쓰고 싶다면 반드시 알아야 할 핵심 API!”
    자바의 Concurrency API를 제대로 이해하면, 스레드 처리에 대한 게임 체인저가 될 수 있습니다.


    1. 자바 동시성 API의 주요 개념 및 특징

    1.1 자바 동시성 API란?

    자바 동시성 APIjava.util.concurrent 패키지에 포함된 클래스와 인터페이스들로, 멀티스레드 환경에서 스레드 풀 관리, 동기화, 병렬 작업 처리를 지원합니다. JDK 5부터 도입되어 synchronized나 wait()/notify() 보다 더 세밀하고 효율적인 제어를 제공합니다.

     

    주요 용어:

    구성요소 설명
    Executor, ExecutorService 스레드 풀 기반의 작업 실행기
    Future, Callable 비동기 작업 처리 및 결과 반환 지원
    CompletableFuture 비동기 로직을 체이닝 형태로 조합
    CountDownLatch, CyclicBarrier 스레드 동기화 도구
    ReentrantLock, ReadWriteLock 명시적인 Lock 제어
    AtomicInteger, AtomicReference 원자성 보장 변수
    ThreadPoolExecutor 커스터마이징 가능한 스레드풀 구현체

    특징:

    • 스레드 풀을 활용해 자원 효율성 극대화
    • 세밀한 동기화 제어 가능
    • 비동기 및 병렬 처리 지원
    • 성능과 확장성 향상

    1.2 synchronized vs 자바 동시성 API의 차이점

    synchronized는 간단하지만 유연성이 떨어지고 성능 병목이 발생할 수 있습니다. 반면, 동시성 API는 더 정교한 제어와 효율성을 제공합니다.

    구분 synchronized 자바 동시성 API
    유연성 단순한 락/블록 방식 다양한 락 및 비동기 옵션
    성능 락 대기로 오버헤드 발생 스레드 풀 및 최적화로 효율적
    복잡성 구현 간단 학습 필요하지만 강력
    사용 예 기본 동기화 대규모 멀티스레드 작업

     


    2. 자바 동시성 API의 원리 및 구조

    2.1 주요 구성 요소와 동작 원리

    • ExecutorService: 스레드 풀을 생성하고 작업을 관리.
    • Lock 인터페이스: ReentrantLock 등으로 타임아웃, 조건 변수를 지원.
    • Concurrent Collections: ConcurrentHashMap, CopyOnWriteArrayList 등으로 동시 접근 최적화.
    • Future/CompletableFuture: 비동기 작업의 결과를 처리하며 콜백 지원.

    2.2 코드 예시

    2.2.1 ExecutorService: 스레드 풀 관리

    ExecutorService는 스레드 풀을 생성하고 작업을 효율적으로 분배하는 도구입니다. 직접 스레드를 생성하는 대신 풀에서 스레드를 재사용해 오버헤드를 줄입니다.

    import java.util.concurrent.ExecutorService;
    import java.util.concurrent.Executors;
    import java.util.concurrent.TimeUnit;
    
    public class ExecutorServiceExample {
        public static void main(String[] args) throws InterruptedException {
            // 3개의 스레드를 가진 고정 풀 생성
            ExecutorService executor = Executors.newFixedThreadPool(3);
    
            // 5개의 작업 제출
            for (int i = 1; i <= 5; i++) {
                int taskId = i;
                executor.submit(() -> {
                    try {
                        Thread.sleep(1000); // 작업 시뮬레이션
                        System.out.println("Task " + taskId + " executed by " + Thread.currentThread().getName());
                    } catch (InterruptedException e) {
                        Thread.currentThread().interrupt();
                    }
                });
            }
    
            // 셧다운 요청: 더 이상 새 작업을 받지 않음
            executor.shutdown();
            // 모든 작업 완료 대기 (최대 10초)
            if (!executor.awaitTermination(10, TimeUnit.SECONDS)) {
                executor.shutdownNow(); // 강제 종료
                System.out.println("Executor terminated forcibly");
            } else {
                System.out.println("All tasks completed");
            }
        }
    }

     

    동작 원리:

    • newFixedThreadPool(3): 3개의 스레드를 유지하는 풀 생성. 초과 작업은 대기 큐에 저장.
    • submit(): Runnable 또는 Callable 작업을 풀에 제출.
    • shutdown(): 새 작업을 받지 않고 기존 작업 완료 후 종료.
    • awaitTermination(): 지정된 시간 동안 작업 완료를 기다림.

    세부 설명:

    • 5개 작업이 3개 스레드로 나뉘어 실행되며, 스레드 이름(예: pool-1-thread-1)으로 어느 스레드가 작업을 처리하는지 확인 가능. shutdownNow()는 진행 중인 작업을 중단하며, InterruptedException 처리가 필요.

    2.2.2 ReentrantLock: 유연한 락 관리

    ReentrantLocksynchronized보다 더 세밀한 제어를 제공하며, 타임아웃과 조건 변수를 지원합니다.

    import java.util.concurrent.locks.ReentrantLock;
    
    public class ReentrantLockExample {
        private int count = 0;
        private final ReentrantLock lock = new ReentrantLock();
    
        public void increment() {
            lock.lock(); // 락 획득
            try {
                count++;
                System.out.println(Thread.currentThread().getName() + " incremented count to " + count);
            } finally {
                lock.unlock(); // 락 해제 (항상 finally에서 호출)
            }
        }
    
        public int getCount() {
            return count;
        }
    
        public static void main(String[] args) throws InterruptedException {
            ReentrantLockExample example = new ReentrantLockExample();
            Runnable task = () -> {
                for (int i = 0; i < 100; i++) {
                    example.increment();
                }
            };
    
            Thread t1 = new Thread(task, "Thread-1");
            Thread t2 = new Thread(task, "Thread-2");
            t1.start();
            t2.start();
            t1.join();
            t2.join();
    
            System.out.println("Final count: " + example.getCount()); // 200 출력
        }
    }

     

    동작 원리:

    • lock(): 락을 획득하며, 이미 락을 가진 스레드가 재진입하면 카운트 증가(재진입 가능).
    • unlock(): 락을 해제하며, finally 블록에서 호출해 예외 발생 시에도 락이 풀리도록 보장.

    세부 설명:

    • synchronized와 달리 tryLock()으로 타임아웃을 설정하거나, Condition 객체를 사용해 await()/signal()로 스레드 간 통신 가능.

    2.2.3 CompletableFuture: 비동기 작업 처리

    CompletableFuture는 비동기 작업의 결과를 처리하며, 콜백과 조합으로 복잡한 흐름을 관리합니다.

    import java.util.concurrent.CompletableFuture;
    
    public class CompletableFutureExample {
        public static void main(String[] args) {
            CompletableFuture<String> future = CompletableFuture.supplyAsync(() -> {
                try { Thread.sleep(1000); } catch (Exception e) {}
                return "Task completed!";
            });
    
            future.thenAccept(result -> System.out.println(Thread.currentThread().getName() + ": " + result))
                  .exceptionally(throwable -> {
                      System.err.println("Error: " + throwable.getMessage());
                      return null;
                  });
    
            System.out.println("Main thread continues...");
            try { Thread.sleep(2000); } catch (Exception e) {} // 결과 대기
        }
    }

     

    동작 원리:

    • supplyAsync(): 비동기 작업 시작, 기본 스레드 풀(ForkJoinPool) 사용.
    • thenAccept(): 결과가 완료되면 실행되는 콜백.
    • exceptionally(): 예외 처리 로직.

    세부 설명:

    • 작업이 완료되면 별도 스레드에서 결과를 출력하며, 메인 스레드는 블록되지 않음. thenCompose()로 작업 연결도 가능.

    2.2.4 ConcurrentHashMap: 동시 접근 가능한 맵

    ConcurrentHashMap은 멀티스레드 환경에서 안전하게 동작하며, 세그먼트 단위 락으로 성능을 최적화합니다.

    import java.util.concurrent.ConcurrentHashMap;
    
    public class ConcurrentHashMapExample {
        public static void main(String[] args) throws InterruptedException {
            ConcurrentHashMap<String, Integer> map = new ConcurrentHashMap<>();
    
            Runnable task = () -> {
                for (int i = 0; i < 100; i++) {
                    map.compute("key", (k, v) -> (v == null) ? 1 : v + 1);
                }
            };
    
            Thread t1 = new Thread(task, "Thread-1");
            Thread t2 = new Thread(task, "Thread-2");
            t1.start();
            t2.start();
            t1.join();
            t2.join();
    
            System.out.println("Final value for 'key': " + map.get("key")); // 200 출력
        }
    }

     

    동작 원리:

    • compute(): 키에 대한 연산을 원자적으로 수행.
    • 내부적으로 세그먼트 단위 락 사용(JDK 8부터 CAS 기반 최적화).

    세부 설명:

    • HashMap과 달리 동시 수정이 안전하며, 전체 맵을 잠그지 않아 높은 동시성을 제공.

    2.2.5 BlockingQueue: 스레드 간 데이터 전달

    BlockingQueue는 생산자-소비자 패턴에서 스레드 간 데이터를 안전하게 전달합니다. 큐가 가득 차거나 비었을 때 스레드를 블록 합니다.

    import java.util.concurrent.ArrayBlockingQueue;
    import java.util.concurrent.BlockingQueue;
    
    public class BlockingQueueExample {
        public static void main(String[] args) {
            BlockingQueue<Integer> queue = new ArrayBlockingQueue<>(10);
    
            // 생산자
            Thread producer = new Thread(() -> {
                try {
                    for (int i = 1; i <= 15; i++) {
                        queue.put(i); // 큐가 가득 차면 대기
                        System.out.println("Produced: " + i);
                    }
                } catch (InterruptedException e) {
                    Thread.currentThread().interrupt();
                }
            });
    
            // 소비자
            Thread consumer = new Thread(() -> {
                try {
                    for (int i = 1; i <= 15; i++) {
                        int value = queue.take(); // 큐가 비면 대기
                        System.out.println("Consumed: " + value);
                    }
                } catch (InterruptedException e) {
                    Thread.currentThread().interrupt();
                }
            });
    
            producer.start();
            consumer.start();
        }
    }

     

    동작 원리:

    • put(): 큐에 데이터를 추가하며, 가득 차면 블록.
    • take(): 큐에서 데이터를 꺼내며, 비어 있으면 블록.

    세부 설명:

    • 크기 10인 큐에 15개 데이터를 넣고 빼며, 생산자와 소비자가 동기화됨. offer()/poll()로 비블록 방식도 가능.

    추가 설명 요약

    • ExecutorService: 스레드 생성 비용을 줄이고 작업 분배를 최적화. 풀 크기와 작업량 조절이 핵심.
    • ConcurrentHashMap: 동시 읽기/쓰기를 지원하며, synchronized 맵보다 성능 우수.
    • BlockingQueue: 스레드 간 데이터 전달을 안전하고 효율적으로 처리. 생산자-소비자 패턴에 이상적.

    3. 실무에서 자바 동시성 API의 활용 사례 

    3.1 Spring에서의 비동기 처리

    Spring에서는 @AsyncExecutorService를 결합해 비동기 작업을 처리하며, CompletableFuture로 결과를 관리합니다.

     

    예시: Spring Boot 비동기 작업:

    @Service
    public class AsyncService {
        @Autowired
        private TaskExecutor taskExecutor; // Spring의 ThreadPoolTaskExecutor
    
        @Async
        public CompletableFuture<String> processTask(String input) {
            return CompletableFuture.supplyAsync(() -> {
                try { Thread.sleep(1000); } catch (Exception e) {}
                return "Processed: " + input;
            }, taskExecutor);
        }
    }
    
    @RestController
    public class AsyncController {
        @Autowired
        private AsyncService asyncService;
    
        @GetMapping("/async")
        public CompletableFuture<String> runAsyncTask() {
            return asyncService.processTask("Test");
        }
    }
    • 활용: REST API 요청을 비동기로 처리해 응답 속도 개선.

    3.2 실제 활용 사례

    • ConcurrentHashMap: Spring 캐시에서 동시 접근 가능한 맵으로 데이터 저장.
    • ThreadPoolTaskExecutor: Spring Batch에서 대량 작업을 병렬 처리.

    4. 주의사항: 자바 동시성 API의 장점과 단점

    장점

    • 효율성: 스레드 풀로 자원 낭비 줄이고 성능 최적화, Thread Pool로 불필요한 스레드 생성 방지
    • 유연성: 다양한 락과 비동기 옵션으로 세밀한 제어 가능.
    • 확장성: 대규모 멀티스레드 환경에 적합.
    • 비동기 처리 용이: CompletableFuture 등으로 병렬 로직 작성 간결
    • 강력한 동기화 도구 제공: Latch, Barrier, Lock 등

    단점

    • 복잡성: 학습 곡선이 높고 디버깅 어려움.
    • 오버헤드: 잘못 사용 시 스레드 풀 크기 조절 실패로 성능 저하.
    • 리소스 관리: 스레드 풀 종료(shutdown())를 잊으면 리소스 누수 발생.
    • 디버깅 어려움: 멀티스레드는 상태 추적이 어려움
    • Deadlock 발생 가능: 락 사용 시 순서 주의 안 하면 데드락 발생

    주의사항

    • 스레드 풀 크기 조절: 작업량에 맞게 적절히 설정(예: CPU 코어 수 기반).
    • 예외 처리: 비동기 작업에서 예외를 놓치지 않도록 exceptionally() 활용.
    • 락 해제: Lock 사용 시 finally 블록에서 반드시 해제.

    5. 결론

    자바 동시성 API는 멀티스레딩 환경에서 성능과 유연성을 극대화하는 필수 도구입니다. 단순히 스레드를 관리하는 것을 넘어서, 대규모 병렬 처리, 성능 최적화, 안정적인 서비스 운영에 핵심적인 역할을 합니다. Spring Framework에서도 비동기 처리, 병렬 작업, 동시성 컬렉션 등으로 빈번히 활용되며, 현대 애플리케이션의 요구사항을 충족합니다. 하지만 복잡성과 리소스 관리에 주의하며 사용해야 합니다. 비즈니스 로직의 구조에 따라 Executor, CompletableFuture, Lock, 동기화 유틸리티를 적절히 조합해서 사용하는 것이 중요합니다.

    이 글을 통해 동시성 API의 핵심을 익히고 실무에 적용해 보면, 멀티스레딩의 강력함을 체감할 수 있을 겁니다. 지금 코드를 작성하며 동시성의 세계에 뛰어들어보세요!

    “멀티스레드를 직접 제어하기보다, 동시성 API를 잘 쓰는 것이 실력이다.”

    728x90
    반응형