Spring
[Spring] Thread와 Java / Spring에서의 Multi-Thread 구현
s_ih_yun
2025. 6. 4. 01:23
728x90
0. 동기 / 비동기 & 블로킹 / 논블록킹
- 동기 / 비동기 : 요청한 작업에 대해 완료 여부를 신경 써서 작업을 순차적으로 수행할지 아닌지 관점
- 블로킹 / 논블록킹 : 현재 작업이 block(차단, 대기) 되느냐 아니냐에 따라 다른 작업을 수행할 수 있는지 관점
0.1. 동기(Synchronous) vs 비동기(Asynchronous)
- 요청한 작업에 대해 완료 여부를 신경 써서 작업을 순차적으로 수행할지 아닌지
- 전체적인 작업에 대한 순차적인 흐름 유무
- 동기 : 요청한 작업에 대해 완료 여부를 따져 순차대로 처리하는 것
- 요청한 작업에 대해 순서가 지켜진다
- 비동기 : 요청한 작업에 대해 완료 여부를 따지지 않기 때문에 자신의 다음 작업을 그대로 수행
- 요청한 작업에 대해 순서가 지켜지지 않을 수 있다
- 느린 작업(like I/O 작업)이 발생할 때, 기다리지 않고 다른 작업을 처리하면서 멀티 작업 진행 가능
- 전반적인 시스템 성능 향상에 도움
- 동시 처리 → 멀티 스레드 / 멀티 프로세스 같은 방식으로 구현 가능
1.2. Blocking vs Non-Blocking
- 현재 작업이 block(차단, 대기) 되느냐 아니냐에 따라 다른 작업을 수행할 수 있는지
- 전체적인 작업의 흐름 자체를 막냐 안 막냐
- Example) 파일을 읽는 작업이 있을 때
- Blocking → 파일을 다 읽을 때까지 대기
- Non-Blocking → 파일을 다 읽지 않아도 다른 작업 가능
- 호출된 함수가 호출한 함수에게 제어권을 바로 주느냐 안주느냐로 Blocking / Non-Blocking 구분
- 제어권 : 함수의 코드나 프로세스의 실행 흐름을 제어할 수 있는 권리
- 운영체제의 커널(kernel)에서 I/O 동작을 설명하는 부분
- 제어권 : 함수의 코드나 프로세스의 실행 흐름을 제어할 수 있는 권리
1.3. 동기 / 비동기 + Blocking / Non-Blocking 조합
💡콜백(CallBack) 함수 : 비동기 / 논블로킹에서 다른 작업의 완료 여부나 결과에 대한 후처리를 위해 이용되는 방식
- Sync + Blocking
- 다른 작업이 진행되는 동안 자신의 작업을 처리하지 않고 (Blocking)
- 다른 작업의 완료여부를 받아 순차적으로 처리 (Sync)
- 다른 작업의 결과가 자신의 작업에 영향 주는 경우에 활용 가능
- Sync + Non-Blocking
- 다른 작업이 진행되는 동안에도 자신의 작업을 처리하고 (Non-Blocking)
- 다른 작업의 완료여부를 받아 순차적으로 처리 (Sync)
- 구현에 따라 Sync + Blocking 보다 효율적일 수 있음 (ex. 파일 다운로드 진행바)
- Async + Blocking
- 다른 작업이 진행되는 동안 자신의 작업을 처리하지 않고 (Blocking)
- 다른 작업의 결과를 바로 처리하지 않아 작업 순서가 지켜지지 않음 (Async)
- 실무에서 잘 다룰 일 없음
- Async + Non-Blocking
- 다른 작업이 진행되는 동안에도 자신의 작업을 처리하고 (Non-Blocking)
- 다른 작업의 결과를 바로 처리하지 않아 작업 순서가 지켜지지 않음 (Async)
- 다른 작업의 결과가 자신의 작업에 영향을 주지 않은 경우 활용 가능
1. 멀티 스레드
1.1. 스레드 (Thread)
💡 CPU 수행의 기본 단위 또는 프로세스 안의 제어권 프름
- 구성
- Thread ID
- Program Counter
- Register Set
- Stack Space
- 각각 스레드는 주로 최소한 자신의 레지스터 상태와 스택을 가진다
- Code / Data 섹션, 운영체제 자원들은 스레드끼리 공유
- 프로세스와의 비교
- 프로세스 생성 / Context Switching 작업은 무겁고 잦으면 성능 저하 발생
- 스레드를 생성하거나 Switching하는 것은 그에 비해 가벼움
- 프로세스간 데이터 공유를 위해서는 메시지 패싱 / 공유 메모리 또는 파이프 사용
- → 효율이 떨어지고 개발자가 구현, 관리하기 번거로움
- 프로세스 생성 / Context Switching 작업은 무겁고 잦으면 성능 저하 발생
- 유저 스레드(User-level Thread) : 커널 위에서 커널 지원 없이 유저 수준의 스레드 라이브러리(Thread Library)가 관리하는 스레드
- 안정성은 떨어지지만 성능 저하 X
- 커널 스레드(Kernel-level Thread) : 커널이 지원하는 스레드
- 안정적이지만 성능 저하 (유저 모드에서 커널모드로 바꿔줘야 하기 때문)
- 유저 스레드와 커널 스레드 사이 관계
- Many-to-One Model
- One-to-One Model
- Many-to-Many Model
- Two-level Model
1.2. 멀티 스레드 (Multi Thread)
💡 한 프로세스가 여러 스레드로 동시에 여러 작업을 수행하는 것
- 프로세서가 여러 개인 경우, 병렬성(Parallelism) 증가
- 프로세스의 스레드들이 각각 다른 프로세서에서 병렬적 수행 (병렬성은 CPU 개수에 비례)
- 프로세서가 하나인 경우, 동시성(Concurrency) 증가
- 실제로는 각각의 시간에 한 작업만 수행되지만, 병렬적으로 수행되는 것처럼 보인다
- 한 스레드가 Blocked 되더라도 커널이 다른 스레드로 Switch 시켜 실행 → 빠른 처리 계산 속도 증가
- 장점
- 응답성 (Responsiveness)
- 작업이 끝나기 전 사용자에게 응답하지 않는 싱글 스레드와 달리, 작업을 분리해서 수행하므로 실시간으로 사용자에게 응답
- 자원 공유 (Resource Sharing)
- 자신이 속한 프로세스 내의 스레드들과 메모리 / 자원을 공유
- 경제성 (Economy)
- 스레드 생성이 프로세스 생성보다 싸다
- Context Switching의 오버헤드 또한 스레드가 더 경제적
- 확장성 (Scalability)
- 한 프로세스는 한 프로세서에서만 수행 가능한 싱글 스레드와 달리, 한 프로세스를 여러 프로세서에서 수행 가능
- 응답성 (Responsiveness)
- 단점
- 동기화 문제 : 임계 영역 (Critical Section)
- 둘 이상의 스레드가 동시에 실행하면 문제를 일으키는 코드 블록 → 동기화 필요
- 과한 동기화 → 과한 Lock → 병목 현상 → 성능 저하
- 동기화 방법 : 뮤텍스 / 세마포어
- 스레드 생성 시간으로 인한 오버헤드
- Context Switching, 동기화 등 이유로 인함
- 동기화 문제 : 임계 영역 (Critical Section)
2. 자바의 멀티 스레드
2.0. Java의 스레드 모델
- 쓰레드의 종류
- OS 쓰레드 : OS 커널 레벨에서 생성되고 관리되는 쓰레드
- CPU에서 실제 실행되는 단위이자, CPU 스케쥴링의 단위
- 사용자 코드, 커널 코드 모두 실행
- 유저 쓰레드 : 쓰레드 개념을 프로그래밍 레벨에서 추상화한 것
- Java의 Thread 클래스가 여기 해당
- 쓰레드와 관련된 시스템 콜은 이미 추상화된 Thread 클래스를 통해 사용
- 시스템 콜 : 프로그램에서 OS 커널이 제공하는 서비스를 이용하고 싶을 때 사용
- OS 쓰레드와 유저 쓰레드가 연결 되는 방식에 따라 One-to-One, Many-to-One, Many-to-Many 모델이 존재
- Java는 One-to-One 모델 : OS 쓰레드와 유저 쓰레드가 1:1로 연결
- OS 쓰레드 : OS 커널 레벨에서 생성되고 관리되는 쓰레드
- 자바의 멀티 쓰레드 구현 방법
- Runnable 인터페이스를 구현
- Thread 클래스를 상속
2. 1. Runnable 인터페이스를 구현
- 추상 메서드 run() 하나만 있는 함수형 인터페이스
- 객체 인스턴스를 Thread 객체 생성자에 전달한 뒤 start() 메서드 호출
- start() 메서드 호출 시 새로운 스레드가 생성, 생성도니 스레드에서 run() 메서드 실행
- Example
public class HelloRunnable implements Runnable {
@Override
public void run() { // 쓰레드가 수행할 작업을 작성
System.out.println("hello");
}
}
/* 실행 */
public class HelloRunnable implements Runnable {
@Override
public void run() {
System.out.println("hello");
}
public static void main(String[] args) {
HelloRunnable runnable = new HelloRunnable();
Thread thread = new Thread(runnable);
thread.start();
}
}
2.2. Thread 클래스를 상속
- java.lang 패키지 안에 Runnable 인터페이스를 구현한 클래스
- 오버라이딩한 run() 메서드가 있고, 본 스레드를 실행 하는 start() 메서드도 있음
- Example
public class HelloThread extends Thread{
@Override
public void run() { // 쓰레드가 수행할 작업을 작성
System.out.println("hello");
}
}
/* 생성한 스레드 실행 */
public class HelloThread extends Thread{
@Override
public void run() {
System.out.println("hello");
}
public static void main(String[] args) {
HelloThread thread = new HelloThread();
thread.start();
}
}
- main 스레드
- 자바 프로그램이 가지는 최소 한 개의 스레드
- JVM이 생성하는 것으로, 자바 프로그램이 실행되면 작업을 수행
- 각 스레드는 별도의 호출 스택을 가진다
- JVM은 스레드들을 스케줄링하여 CPU 자원 할당
3. 스프링의 멀티 스레드
3.0. Spring에서 기본적으로 사용하는 방식
- Spring - 기본적으로 멀티 쓰레드, 동기 방식 사용
- Thread per Request 모델 : 하나의 요청을 처리하기 위해 하나의 쓰레드 사용
- 3개의 요청이 3개의 쓰레드 A, B, C에 할당
- A 쓰레드가 어떤 요청을 처리할 때 I/O 작업이 필요해지면 A 쓰레드는 Blocking
- 다른 요청이 들어오더라도 A 쓰레드는 해당 요청 처리가 끝나지 않았기 때문 (동기의 순차처리)
- Spring boot를 사용할 경우, 기본적으로 쓰레드 풀에 200개의 쓰레드를 만들어 둠
- 요청마다 하나의 쓰레드를 사용하기 때문에, 매번 쓰레드를 생성하면 비용 많이 소모
- 멀티 쓰레드 / 동기 방식으로 많은 양의 트래픽 처리 가능
- But, App 처리량을 늘리기 위해서는 쓰레드 개수 늘려야 함 → 컨텍스트 스위칭 비용 증가 → 메모리 사용량 증가 → 쓰레드 개수 늘리는 방식 한계 有
- 쓰레드 개수 늘리지 않고 처리량 늘리는 방법 → 비동기 방식
- 비동기 방식으로 구현하는 방법 3가지
- Spring Webflux
- 가상 쓰레드
- @Async
3.1. Spring Webflux
- 기존 방식에 처리량에 한계가 있을 때, 비동기 / Non-Blocking 방식의 App 개발을 지원하는 모듈
- Reactive 프로그래밍 방식
- 코드를 작성하고 이해하는 데 어려움
- 기존 자바 프로그래밍은 쓰레드 기반이기 때문에 새롭게 작성해야 함
💡 리액티브 프로그래밍 (Reactive Programming)
- 데이터 흐름과 변화에 반응하는 시스템을 구축
- 리액티브 프로그래밍을 하며 데이터를 비동기적으로 처리
- Reactive 프로그래밍으로의 재작성 문제로 인해,
기본적인 Spring 방식을 사용하되 트래픽이 많은 서비스만 부분적으로 채택해서 사용
3.2. 가상 쓰레드
- 2023년 9월 Java21에 나오면서 도입
- OS 쓰레드와 1:1 연결되지 않고, JVM 자체적으로 스케쥴링하여 Carrier Thread를 통해 연결
- 기존) 요청을 처리하는 쓰레드가 Blocking이 발생 → OS 쓰레드도 Blocking
- 가상 쓰레드) Blocking 발생 시, 내부 스케쥴링을 통해 OS와 연결된 Carrier 쓰레드는 다른 가상 쓰레드의 작업을 처리
- 결과적으로 OS 쓰레드는 Blocking X
- 처리량 한계가 있는 기존 MVC 방식 보완, 작성 / 이해가 어려운 Spring Webflux 단점 보완
3.3. @Async
- @Async 어노테이션 : 비동기 실행 메서드 간편하게 구현 가능
- 스프링이 관리하는 별도의 스레드 풀에서 실행
- 비동기 작업은 TaskExecutor 인터페이스를 통해 실행
- TaskExecutor 인터페이스와 구현체들를 사용하여 스레드 풀 생성 및 사용 가능
- 해당 인터페이스를 구현하여 직접 스레드풀 관리 가능
- 구현체 : SimpleAsyncTaskExecutor , ThreadPoolTaskExecutor
3.3.1. @Async 어노테이션
- @Async 를 통해 실행된 비동기 함수는 별도의 스레드로 실행
- @Async를 사용하기 위해서는 @EnableAsync 선언 필요
- Example
- AsyncService.java
- 비동기로 asyncMethod 선언
@Slf4j
@Service
public class AsyncService {
@Async
public void asyncMethod() {
try {
Thread.sleep(2000);
log.info("thread name: {}", Thread.currentThread().getName());
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
}
- AsyncConfig.java
- @Async를 사용하기 위해서는 @EnableAsync 선언 필요
@EnableAsync
@Configuration
public class AsyncConfig {
}
- Controller.java : 비동기 동작을 확인하기 위한 컨드롤러
@Slf4j
@RestController
@RequestMapping(value = "/")
public class ExampleController {
private final AsyncService asyncService;
public ExampleController(AsyncService asyncService) {
this.asyncService = asyncService;
}
@GetMapping
public ResponseEntity<Void> temp() {
log.info("controller start ...");
asyncService.asyncMethod();
log.info("controller end ...");
return new ResponseEntity<>(HttpStatus.OK);
}
}
3. 3.2. TaskExecutor : @Async의 스레드풀 관리
- @Async 가 비동기로 동작할 때, TaskExecutor 인터페이스를 통해 실행
- 여기서 사용되는 구현체에는 SimpleAsyncTaskExecutor , ThreadPoolTaskExecutor 가 있다
- SimpleAsyncTaskExecutor
- 스프링 환경에서 기본으로 사용
- 매 실행마다 새로운 스레드를 생성하여 작업 실행
- 스레드 재사용 X → 성능 이슈 주의
- 사용 지양하기를 권장
- Example) AsyncConfig.java
@EnableAsync
@Configuration
public class AsyncConfig {
@Bean
public Executor getAsyncExecutor() {
return new SimpleAsyncTaskExecutor();
}
}
- ThreadPoolTaskExecutor
- 스레드풀을 이용해 스레드를 관리
- 스레드 재사용 → 성능 최적화 가능
- Example) AsyncConfig.java
@EnableAsync
@Configuration
public class AsyncConfig implements AsyncConfigurer {
@Override
public Executor getAsyncExecutor() {
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
executor.setCorePoolSize(10);
executor.setMaxPoolSize(100);
executor.setQueueCapacity(50);
executor.setThreadNamePrefix("custom-");
executor.setRejectedExecutionHandler(new ThreadPoolExecutor.AbortPolicy());
executor.initialize();
return executor;
}
}
- ThreadPoolTaskExecutor 의 스레드 풀 설정
- Thread Size
- corePoolSize : 스레드풀에서 기본적으로 유지되는 스레드 수 (default : 1)
- maxPoolSize : 스레드풀에서 사용 가능한 최대 스레드 수 (default : Integer.MAX_VALUE)
- queueCapacity : 스레드풀 작업 큐의 사이즈
- Thread Name
- threadNamePrefix : 생성 스레드 이름에 사용될 접두사
- RejectedExcutionHandler
- TaskRejectedException : 모든 스레드가 작업중이고 BlockingQueue에서도 추가 작업을 받을 수 없을 때 발생하는 예외
- 전략에 따라 예외 발생 대신 다른 선택 가능 : 호출한 곳에서 작업 실행, 해당 작업 스킵, 큐에 오래된 작업 삭제하고 새 작업 추가
- TaskRejectedException : 모든 스레드가 작업중이고 BlockingQueue에서도 추가 작업을 받을 수 없을 때 발생하는 예외
- Thread Size
📌 References
https://320hwany.tistory.com/107
https://akku-dev.tistory.com/89
https://80000coding.oopy.io/80fdeed4-c57b-440b-9f5b-edd62d2dd493
https://f-lab.kr/insight/java-multithreading-basic-spring-utilization
https://devoong2.tistory.com/entry/Spring-Async-사용-방법-및-TaskExecutor-ThreadPool
728x90