🦁멋쟁이 사자처럼 15기/4월달 수업 내용 정리

멋쟁이 사자처럼 31회차 [ 04 / 15 ]

코딩하는 하마 2025. 4. 15. 23:09

[학습목표]

1. 새로운 스레드 api를 활용하여, i/o 바운드 작업과 cpu 바운드 작업에 적합한 동시성 프로그래밍 모델을 설계하고 구현할 수 있다.
     - jdk21virtual Thread와 전통 ThreadPool을 비교
     - 작업 특성에 따른 최적의 스레드 구조를 설계

2. Executors Virtual Thread Executor를 활용하여, 다양한 유형의 작업을 분리 처리하는 병렬 구조를 구현할 수 있다.
Executors.newFizedThreadPool() vs Executors.newVirtualThreadPerTaskExecutor()
동시성 구조 설계 시 고려해야 할 자원 사용/ 스레드 수/ 작업 큐 등 분석
다중 요청 시뮬레이션 + 성능 비교

3. 실행 결과를 기반으로 Virtual Thread의 장단점을 분석하고 실전 환경에 적합한 스레드 모델을 선택할 수 있다.
툴을 활용해 실시간 모니터링 (실행 시간, 스레드 수 , 메모리 사용량 등 비교 분석)

4. JavaSocketServerSocket을 활용하여 기본적인 TCP 통신 구조를 설계하고 구현할 수 있다.

Virtual Threads

1. 운영체제 스레드에 직접적으로 매핑되지 않는다.

2. JVM에서 가상으로 운영체제 스레드를 만들어서 스케쥴링 한다.

3. 수천 개의 가상 스레드 -> JVM OS thread [코루틴 방식]

4. 블로킹 호출 시에서 JVM이 자동으로 스레드에서 다른 작업으로 전환한다.

 

일반 스레드는 OS(수천단위)에서 직접 처리하고 virtual threads(수십만단위)는 JVM 에서 처리한다.

 

Thread.Builder로 체크

플랫폼 스레드 = 일반 스레드 (Platform Thread) : 1:1 운영체제 매핑 , 블로킹 처리는 자원 점유 후 대기 / cpu 바운드 작업

가상 스레드 (Virtual Thread) : JavaVM 관리 , 블로킹 처리는 일시 중단 후 다른 스레드 생성 / IO 바운드 작업

 

https://docs.oracle.com/en/java/javase/21/docs/api/java.base/java/lang/Thread.html#startVirtualThread(java.lang.Runnable)

 

Thread (Java SE 21 & JDK 21)

All Implemented Interfaces: Runnable Direct Known Subclasses: ForkJoinWorkerThread A thread is a thread of execution in a program. The Java virtual machine allows an application to have multiple threads of execution running concurrently. Thread defines con

docs.oracle.com

 

ex) 

// [8. Virtual Threads (JDK 21)] - 람다 표현식 사용
public class e_VirtualThreadExample {
    public static void main(String[] args) throws InterruptedException {
        for (int i = 0; i < 10; i++) {
            Thread.startVirtualThread(() -> {
                System.out.println("Virtual Thread: " + Thread.currentThread());
                try {
                    Thread.sleep(1000);
                } catch (InterruptedException e) {
                    Thread.currentThread().interrupt();
                }
            });
        }

        Thread.sleep(2000); // 메인 스레드가 가상 스레드 종료를 기다림
    }
}

위 코드는 아래와 같이 나옴을 볼 수 있다. 

 

이번에는 java 21에 도입된 가상 스레드를 사용하는 예제를 살펴보겠다. 

package com.sec16;

//  Virtual Threads (JDK 21) - 람다 표현식 사용
public class g_VirtualThread02 {
    public static void main(String[] args) throws InterruptedException {
    	Thread.Builder builder = Thread.ofVirtual();
    	switch(builder) {
    	case Thread.Builder.OfVirtual v -> System.out.println("가상 스레드 생성");
    	case Thread.Builder.OfPlatform p -> System.out.println("플랫폼 스레드 생성");
    	}
    	
    	Thread.sleep(10000);
    }
}

위 코드에서 Thread.ofVirtual()은 가상 스레드를 생성하는 빌더(Thread.Builder)를 반환한다. 반환 타입은 인터페이스 Thread.Builder이며 실제 구현체는 Thread.Builder.ofVirtual이다. 

switch 문에서는 Thread.Builder.ofVirtual이면 가상 스레드를 나타내고 Thread.Builder.ofPlatform이면 플랫폼 스레드가 생성된다. 

 

아래는 가상 스레드와 플랫폼 스레드의 각각 실행 시간을 출력하는 코드이다.

package com.sec16;

//  Virtual Threads (JDK 21) - 람다 표현식 사용
// 2가지 빌터 테스트를 해보자. 스레드 실행 -> 종료 / 조인도 구현 / 실행시간 체크 
// 실행시간 체크 -> 0.5초 동안 sleep을 두고 작업 시뮬레이션을 해보자. 



public class g_VirtualThread03 {
    public static void main(String[] args) throws InterruptedException {
    	testBuilder(Thread.ofVirtual().name("vt-",1));
    	testBuilder(Thread.ofPlatform().name("pf-",1));
    
    }
    	
    public static void testBuilder (Thread.Builder builder) {
    	System.out.println(" \n [Test] 실행 대상 "+ builder.getClass().getSimpleName());
    	long start = System.currentTimeMillis();
    	switch(builder) {
    	case Thread.Builder.OfVirtual v -> {
    		System.out.println("가상 스레드 생성");
    		Thread thread = v.start(() -> {
    		    System.out.println("실행 중(Virtual) "+Thread.currentThread());
    		    try {
    		    	Thread.sleep(500);
    		    }catch(InterruptedException e) {
    		    	e.printStackTrace();
    		    }
    		    
    		});
    		myjoin(thread);
    		}
    	case Thread.Builder.OfPlatform p -> {
    		System.out.println("플랫폼 스레드 생성");
    		Thread thread = p.start(() -> {
    		    System.out.println("실행 중(platform) "+Thread.currentThread());
    		    try {
    		    	Thread.sleep(500);
    		    }catch(InterruptedException e) {
    		    	e.printStackTrace();
    		    }
    		    
    		});
    		myjoin(thread);
    		}
    	}
    	long duration = System.currentTimeMillis()- start;
    
    	System.out.printf("실행 시간 : %dms\n", duration);
    	
    	
    }
    private static void myjoin (Thread thread) {
    	try {
    		thread.join();
    	}catch(InterruptedException e) {
    		e.printStackTrace();
    	}
    }
      
}​

위 코드는 각각의 가상 스레드와 플랫폼 스레드의 실행결과를 나타낸 것으로 가상 스레드가 플랫폼 스레드보다 실행시간이 더 느림을 알 수 있다.


ThreadGroup

스레드 그룹은 관련된 스레드를 묶어 관리하는 목적이다. 

아래 예제를 한 번 보겠다.

package com.sec16;

public class f_ThreadGroup {
    public static void main(String[] args) {
        // 1. 스레드 그룹 생성
        ThreadGroup group = new ThreadGroup("MyThreadGroup");

        // 2. Runnable 정의
        Runnable task = () -> {
            String name = Thread.currentThread().getName();
            ThreadGroup tg = Thread.currentThread().getThreadGroup();
            for (int i = 0; i < 3; i++) {
                System.out.println("[" + tg.getName() + "] " + name + " - " + i);
                try {
                    Thread.sleep(500);
                } catch (InterruptedException e) {
                    Thread.currentThread().interrupt(); 
                }
            }
        };

        // 3. 그룹에 속한 스레드 생성
        Thread t1 = new Thread(group, task, "Thread-A");
        Thread t2 = new Thread(group, task, "Thread-B");

        // 4. 실행
        t1.start();
        t2.start();

        // 5. 그룹 정보 출력
        System.out.println("활성 스레드 수: " + group.activeCount());
        group.list();
    }
}

위 코드를 보면 Thread-A와 Thread-B가 관련된 그룹으로 묶여 같이 출력되는 것을 볼 수 있다.

 

이번엔 다른 코드를 살펴보겠다.

package com.sec16;
/*
 *  	AA
 *          A-1
 *          A-2
 *          
 *      BB
 *          B-1
 *          B-2
 */
public class f_ThreadGroup02 {
    public static void main(String[] args) {
        // 1. 스레드 그룹 생성
        ThreadGroup group_a = new ThreadGroup("AA");
        ThreadGroup group_b= new ThreadGroup("BB");

        // 2. Runnable 정의
        Runnable task = () -> {
            String name = Thread.currentThread().getName();
            ThreadGroup tg = Thread.currentThread().getThreadGroup();
            for (int i = 0; i < 3; i++) {
                System.out.println("[" + tg.getName() + "] " + name + " - " + i);
                try {
                    Thread.sleep(500);
                } catch (InterruptedException e) {
                    Thread.currentThread().interrupt(); 
                }
            }
        };

        // 3. 그룹에 속한 스레드 생성
        Thread t1 = new Thread(group_a, task, "A-1");
        Thread t2 = new Thread(group_a, task, "A-2");
        Thread t3 = new Thread(group_b, task, "B-1");
        Thread t4 = new Thread(group_b, task, "B-2");
        

        // 4. 실행
        t1.start();
        t2.start();
        t3.start();
        t4.start();

        // 5. 그룹 정보 출력
        System.out.println("활성 스레드 수: " + group_a.activeCount());
        group_a.list();
        
        System.out.println("활성 스레드 수: " + group_b.activeCount());
        group_b.list();
    }
}

위 코드를 보면 그릅 AA에 A-1와 A-2가 묶여있고 BB에는 B-1와 B-2가 묶여있는 관계임을 알 수 있다. 따라서 결과도 A-1와 A-2가 같이 묶여 출력되고 B-1와 B-2가 묶여 나오는 것을 살펴볼 수 있다. 

 

 

이번 코드는 부모와 자식 관계를 갖는 코드를 살펴보겠다. 

코드는 아래와 같다. 

package com.sec16;

// [추가 예제] ThreadGroup 사용 예제  _계층 구조
public class g_ThreadGroupDemo {
    public static void main(String[] args) {
        // 1. 스레드 그룹 생성
        ThreadGroup parent_group = new ThreadGroup("ParentGroup");
        
        // 2. 자식 스레드 
        ThreadGroup child_group = new ThreadGroup("ChildGroup");

        // 2. Runnable 정의
        Runnable task = () -> {
            String name = Thread.currentThread().getName();
            ThreadGroup tg = Thread.currentThread().getThreadGroup();
            for (int i = 0; i < 3; i++) {
                System.out.println("[" + tg.getName() + "] " + name + " - " + i);
                try {
                    Thread.sleep(500);
                } catch (InterruptedException e) {
                    Thread.currentThread().interrupt();
                }
            }
        };

        // 3. 그룹에 속한 스레드 생성
        Thread t1 = new Thread(parent_group, task, "ParentThread-A");
        Thread t2 = new Thread(child_group, task, "ChildThread-B");

        // 4. 실행
        t1.start();
        t2.start();

        // 5. 그룹 정보 출력
        System.out.println("부모 활성 스레드 수: " + parent_group.activeCount());
        parent_group.list();
        
        // 자식 그룹에서 부모 그룹도 확인 해보자.
        System.out.println("자식 활성 스레드 수: " + child_group.activeCount() +"---->"+ child_group.getParent().getName());
        child_group.list();
    }
}

위 코드를 보면 ParentGroup과 ChildGroup이라는 이름의 두 개의 스레드를 그룹을 생성한 것을 볼 수 있다. 그리고 for문을 이용하여 현재 스레드의 이름과 그룹 이름을 출력하면서 3번 반복하는 루프이다. 

각각의 그룹에 대해 parent_group과 child_group을 생성하여 실행시킨 것을 볼 수 있다.


추가적인 요소 

1) ExecutorService, Future, Callable

고정 크기 스레드 풀을 사용한다. 이들은 Java에서 멀티스레딩을 더 효율적으로 관리하기 위한 고급 API이다. 

 

ExecutorService  : 스레드를 직접 만들고 관리하지 않고 , 작업을 실행할 수 있도록 도와주는 스레드 풀 방식의 API 이다.Callable <T> : Runnable과 비슷하지만 리턴값이 있는 작업을 정의할 수 있다. 또한 예외도 던질 수 있다.Future <T> : Callable 의 작업 결과를 나중에 받을 수 있도록 도와주는 객체이다.

 

과정으로 정리해보면 다음과 같다. Callable<T> [작업 정의] -> ExecutorService.submit() [작업 실행] -> Future<T> [결과 받기]

 

코드를 통해 한 번 살펴보겠다.아래는 ExecutorService을 사용하여 고정 크기 스레드 풀로 작업을 진행해 보았다.

package com.sec16;

import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;

// [ExecutorService] 고정 크기 스레드 풀로 작업 실행
public class i_ExecutorServiceExample {
    public static void main(String[] args) {
        ExecutorService executor = Executors.newFixedThreadPool(3); // 3개 스레드 풀 _ 3개를 유지하겠다.
        // 모든 작업 스레드가 작업을 실행 중일 때, 새로운 작업은 큐에 대기했다가 실행된다.

        for (int i = 1; i <= 5; i++) {
            int taskId = i;
            executor.submit(() -> { // 비동기 작업 수행 
                System.out.println("Task " + taskId + " executed by " + Thread.currentThread().getName());
            });
        }

    
        executor.shutdown(); // 작업 제출 종료
    }
}

위 코드는 ExecutorService를 사용하여 고정된 개수의 스레드 풀로 작업을 처리하는 예제이다. 스레드를 직접 만드는 것 없이 효율적으로 여러 작업을 동시에 실행할 수 있다.

 

 여기서 newFixedThreadPool(int n)을 사용하여 고정된 스레드의 개수를 지정하였다. 그 외에도 다른 종류의 메소드를 살펴볼 수 있다. 

newCachedThreadPool () -> 필요한 만큼 생성 

newSingleThreadExecutor() -> 스레드 1개 생성

newVirtualThreadPerTaskExecutor() -> 가상 스레드 사용 ( java 21)

 

그리고 submit()을 사용해서 작업을 스레드 풀에 넘겨주었다. 여기서 람다에서 사용하는 지역 변수는 final이거나 effectively final이여야 한다. 절대 변수의 값이 바뀌지 않는다는 의미이다. 람다가 실행될 때 taskId는 람다 바깥에서 정의된 지역 변수로 이 변수가 나중에 바뀌게 되면 람다가 어떤 값을 참조하고 있는지 예측하기 어려워 final이나 effectivel final을 사용한다. 

다시 돌아와서 총 5개의 작업이 있고 위에서 항상 3개의 스레드를 유지하면서 작업을 처리 한다고 되어있으므로 3개는 먼저 실행 되고 나머지 2개는 큐에서 대기하는 방식으로 동작된다. 

 

그리고 더 이상 새로운 작업은 받지 않게 하고 현재 큐에 있는 작업이 끝나면 스레드 풀을 종료시킨다는 의미에서 shutdown()을 사용한다. 이를 쓰지 않을 경우 프로그램이 백그라운드에서 계속 돌아가게 된다. 스레드 풀 내부의 스레드들이 데몬이 아니기 때문에 JVM 이 종료되지 않고 계속 대기하게 된다. 

 

+) 여기서 데몬이란?

백그라운드에서 동작하는 보조 스레드로 메인 작업( 주 스레드)이 끝나면 자동으로 종료되는 스레드를 말한다. 

 

 

package com.sec16;

import java.util.concurrent.*;

// [Future + Callable] 결과 반환이 있는 비동기 작업
public class j_FutureCallableExample {
    public static void main(String[] args) throws Exception {
        ExecutorService executor = Executors.newSingleThreadExecutor();

        Callable<Integer> task = () -> {
            Thread.sleep(500);
            return 42;
        };

        Future<Integer> future = executor.submit(task);
        System.out.println("결과 대기 중...");
        Integer result = future.get(); // 결과 대기
        System.out.println("받은 결과: " + result);

        executor.shutdown();
    }
}

이 코드는 Callable과 Future을 사용한 비동기 작업 처리 예제이다. 

Callabe<T>와 Future<T> 그리고 get()은 리턴값이 있는 비동기이다. 결과 대기 중 상태 값을 측정하고 싶을 때, 타임아웃 처리하고 싶을 때 사용한다. 

 

우선 위에서 ExecutorService를 사용하여 스레드 풀을 하나 만들었따. 그리고 Callable을 통해 0.5초 대기 후 숫자 42를 리턴했다. 

Future을 사용하여 task를 스레드 풀에 제출하였다. 이는 바로 결과가 나오는 것이 아닌 Future라는 비동기 결과를 받게 된다. 

 

그 후 future.get()을 호출하여 결과가 나왔으면 바로 리턴 아직 작업 중이면 그 자리에서 기다란다. 

 

2) Lock, Condition, Atomic 변수

이들은 멀티스레드 환경에서 안전한 작업을 위해 사용되는 도구들이다. 

lock synchronized의 대안이자 확장판으로 synchronized보다 더 세밀한 제어가 가능하다.
condition Object.wait()와 notify()의 더 강력한 대체제로 Condition은 lock과 함께 쓰이며
스레드 간 조건 제어에 사용된다.
atomic 멀티스레드 환경에서의 간단한 동기화 변수로 락을 사용하지 않고
동기화된 방식으로 값을 다룰 수 있다. 

 

 

예제를 살펴보겠다. 

package com.sec16;

import java.util.concurrent.locks.*;

// [Lock + Condition] 스레드 간 정교한 통신
public class k_LockConditionExample {
    private static final Lock lock = new ReentrantLock();
    private static final Condition condition = lock.newCondition();
    private static boolean ready = false;

    public static void main(String[] args) throws InterruptedException {
        Thread waiter = new Thread(() -> {
            lock.lock();
            try {
                while (!ready) {
                    System.out.println(" 대기 중...");
                    condition.await();
                }
                System.out.println("✅ 조건 만족됨! 계속 진행.");
            } catch (InterruptedException e) {
                Thread.currentThread().interrupt();
            } finally {
                lock.unlock();
            }
        });

        Thread signaler = new Thread(() -> {
            lock.lock();
            try {
                ready = true;
                condition.signal();
                System.out.println("📢 조건 신호 보냄");
            } finally {
                lock.unlock();
            }
        });

        waiter.start();
        Thread.sleep(1000); // signaler보다 먼저 시작
        signaler.start();
    }
}

 

위 코드를 살펴보면 lock으로 명시적인 락을 걸어놨음을 볼 수 있다. 그리고 Condition은 락과 연결된 조건 객체로 wait이나 notify 역할을 한다. ready는 조건 상태를 나타내는 boolean 플래그이다. 

 

waiter 스레드를 먼저 살펴보면 락을 걸어놓고 ready 가 true가 될 때까지 await()를 사용해서 기다리고 있음을 볼 수 있다. 여기서 while(!ready)를 쓰는 이유는 가짜 깨어남을 방지하기 위해 해놓은 것이다. 

 

signaler 스레드는 락을 걸어놓은 후 ready를 true로 바꾸고 signal() 로 대기 중인 스레드를 깨운다. 그 후 락을 해제한다. 

전체적인 흐름을 보자면 waiter을 먼저 실행 시켜 대기를 하게 만들고 그 후 ready가 true로 되면 signal을 호출하여 신호를 보낸다.

 

 

이번에는 AtomicIntegef을 사용하여 멀티스레드 환경에서 안전하게 카운팅하는 예제를 살펴보겠다. 

package com.sec16;

import java.util.concurrent.atomic.AtomicInteger;

// [AtomicInteger] 스레드 안전한 카운터
public class l_AtomicExample {
    private static final AtomicInteger counter = new AtomicInteger(0);

    public static void main(String[] args) throws InterruptedException {
        Runnable task = () -> {
            for (int i = 0; i < 1000; i++) {
                counter.incrementAndGet(); // 원자적 증가
            }
        };

        Thread t1 = new Thread(task);
        Thread t2 = new Thread(task);
        t1.start();
        t2.start();
        t1.join();
        t2.join();

        System.out.println("최종 카운트: " + counter.get());
    }
}

 

AutomicInteger은 스레드 간 동기화 없이도 안저나게 정수 값을 다룰 수 있는 클래스이다. 

위 코드를 살펴보면 초기값이 0인 AtomicIntegef을 선언했다. 그 후 Runnable 을 통해 카운터 증가를 정의한 것을 볼 수 있다. 

그리고 t1, t2 스레드를 만들어 실행했다. 

 


여기서 join()이란? 

스레드의 실행이 끝날 때까지 기다리는 메서드이다. 이 코드가 실행되면 현재 실행 중인 스레드는 thread가 끝날 때까지 멈추고 기다린다. 멀티 스레드 환경에서 작업이 비동기적으로 실행되어 메인 스레드가 너무 빨리 끝날 수도 있다. 예를 들어보겠다.

Thread t1 = new Thread(() -> {..});
t1.start();

System.out.println("end");

위와 같은 코드를 보면 join()을 사용하지 않을 경우 end가 t1보다 먼저 출력될 수 있다.

하지만 join()을 하게 되면 t1이 끝난 후 end가 출력되도록 설정할 수 있다. 


다시 본론으로 돌아와서 왜 Atomic이 중요할까?

일반 int를 쓰게 되면 경쟁 조건으로 인해 counter++이 정확이 2000이 나오지 않을 수 있다. 여러 스레드가 동시에 같은 메모리를 읽고 쓰므로 값이 중복되거나 날아가기도 한다. 따라서 Atomic을 사용해주는 것이 좋다. 

 

 

3) 데드락 방지

이는 데드락 방지를 위한 락 순서를 고정시킨다. 

예제를 살펴보겠다.

 

package com.sec16;

// [Deadlock 방지] 락 순서 고정으로 데드락 예방

class SharedResource {
    final String name;

    SharedResource(String name) {
        this.name = name;
    }
}

public class m_DeadlockFreeExample {
    public static void main(String[] args) {
        SharedResource res1 = new SharedResource("🍞");
        SharedResource res2 = new SharedResource("🥛");

        Thread t1 = new Thread(() -> {
            synchronized (res1) {
                System.out.println("🍞 획득 by T1");
                synchronized (res2) {
                    System.out.println("🥛 획득 by T1");
                }
            }
        });

        Thread t2 = new Thread(() -> {
            synchronized (res1) { // 락 순서 동일
                System.out.println("🍞 획득 by T2");
                synchronized (res2) {
                    System.out.println("🥛 획득 by T2");
                }
            }
        });

        t1.start();
        t2.start();
    }
}

위 예제는 두 개의 공유 자원인 빵과 우유를 사용하는 두 개의 스레드가 데드락 없이 안전하게 실행되도록 설계되어 있다. 

 

데드락이란? 

여러 스레드가 서로 락을 걸고 기다리다가 영원히 대기 상테에 빠지는 문제를 말한다. 위 예를 들어 t1이 빵을 갖고 있고 우유를 기다리고 있는데 t2는 우유를 가지고 있고 빵을 기다리는 상황이다. 이러면 영원히 무한 대기 상태가 된다. 

 

스레드 t1은 res1 -> res2 순서로 락을 걸고 사용하였고 t2도 res1 -> res2 순서로 사용하도록 되어있다. 이렇게 락 순서가 일관적이므로 데드락이 발생할 수 없다.