Java/Java / / 2024. 12. 12. 16:05

[Java] 멀티스레드 I/O vs 논블로킹 I/O

Java는 기본적으로 Blocking I/O를 사용한다.

운영체제 바로 위에서 동작하지 않고 JVM을 거치는 Java에게는 이런 점이 "느린 언어다"라는 인식을 심어주었다.

 

스레드는 기본적으로 I/O 요청이 발생하면 블로킹(wait) 상태에 들어간다.

블로킹 상태일 때는 I/O 작업이 완료될 때까지 운영체제 잡스케줄러의 자원할당을 받지 않는다.

네트워크 서버와 같이 I/O 집약적인 작업을 수행하면 이런 블로킹 상태로 들어가는 것은 치명적인 성능저하로 이루어질 수 있다.

 

비효율적인 Java I/O 방식

디스크에서 유저영역으로 데이터를 가지고 오는 절차

이 사진은 왜 Java I/O가 비효율적인지 단편적인 예시를 보여준다. 

Disk에서 커널영역의 버퍼로 데이터를 복사할 때는 Disk Controller의 제어를 받으며 CPU가 관여하지 않아도된다.

그러나 커널영역의 버퍼에서 유저영역의 버퍼로 복사할 때는 CPU의 관여가 필요하며 이때 I/O로 인해 Java 프로세스는 블로킹상태가 된다.

빠른 언어의 예시로 항상 등장하는 C언어는 실행하면 직접 시스템콜을 통해 디스크에서 파일을 읽어올 수 있다. Java는 이 모든 과정을 중간에 JVM이 관여를 하므로 실행시간이 상대적으로 느리다.

위 설명은 단순히 디스크에서 데이터를 가져오는 예시이지만, 이는 Java와 JVM을 개발하는 사람들에게 큰 도전이 되었다.

 

현재는 크게 2가지 방식으로 이 문제를 해결하여 사용한다.

1. 멀티스레드 모델 : I/O를 담당하는 스레드가 블로킹 되더라도 나머지 스레드들이 동작을 하도록 하여, 지연을 최소화 하고자 하는 방법.

2. 논블로킹 모델 : I/O를 시작하자마자 함수를 실행하며, 덕분에 블로킹 상태가 유지되지않고 계속 프로세스를 수행하는 방식

 

2가지 방식에 대해 자세히 알아보자.

멀티스레드 모델 (Multi-Threaded Model)

각각의 작업을 수행하는 여러개의 스레드를 만들어서 작업을 처리한다. 

이런 방식을 사용하면 블로킹이 여러 스레드에서 발생하더라고, 작업을 다른 스레드가 처리하고 있기 때문에 어느정도 블로킹 현상이 개선된다. 

 

Java에서 이를 구현하는 간단한 코드를 보자

public class MultiThreadedServer {
    public static void main(String[] args) throws IOException {
        ServerSocket serverSocket = new ServerSocket(8080);
        System.out.println("Server is running on port 8080...");

        while (true) {
            Socket clientSocket = serverSocket.accept(); // 클라이언트 연결 수락
            new Thread(() -> handleClient(clientSocket)).start(); // 각 요청을 별도 스레드에서 처리
        }
    }

    private static void handleClient(Socket clientSocket) {
        try (BufferedReader in = new BufferedReader(new InputStreamReader(clientSocket.getInputStream()));
             PrintWriter out = new PrintWriter(clientSocket.getOutputStream(), true)) {
            String request = in.readLine();
            System.out.println("Received: " + request);
            out.println("Hello, client! You said: " + request);
        } catch (IOException e) {
            e.printStackTrace();
        }
    }
}

이 서버는 ServerSocker을 개방하여 사용자로부터 요청을 기다리고, 요청이 발생하면 핸들러 메소드를 다른 스레드로 실행시킨다.

이렇게 Client의 요청을 하나씩 스레드로 실행시키는 설계를 멀티스레드 모델이라고 한다.

 

Spring MVC는 기본적으로 멀티스레드 모델이다. 요청당 하나의 스레드를 생성하거나, 스레드 풀에서 스레드를 할당한다.

1. 클라이언트의 요청이 들어오면 디스패쳐 서블릿에서 요청을 처리할 핸들러를 찾는다.

2. 스레드 풀에서 스레드가 할당되고 해당 스레드가 요청을 처리한다.

3. 요청 처리 중 I/O 작업이 발생하면 해당 스레드는 블로킹 상태가 된다.

4. 작업이 완료되면 스레드는 응답을 반환하고 종료된다.

@RestController
@RequestMapping("/api")
public class MyController {

    @GetMapping("/hello")
    public String sayHello() {
        return "Hello, World!";
    }

    @GetMapping("/data")
    public String fetchData() throws InterruptedException {
        // I/O 블로킹 작업 예시 : 데이터베이스 조회
        Thread.sleep(5000);
        return "Data fetched!";
    }
}

논블로킹 모델 (None-Blocking Model)

논블록킹 모델은 멀티스레드 모델과 다르게, 싱글 스레드에서도 블로킹 상태를 최소화 하여 실행된다.

 

이때 Java의 셀렉터(Selector) 개념이 적용되는데, 

셀렉터란 다중 채널(소켓)을 단일 스레드로 효율적으로 관리하기 위해 사용되는 객체로, 하나 이상의 채널에서 발생하는 이벤트(읽기, 쓰기, 연결 등)를 감지하고 처리할 수 있도록 돕는 메커니즘이다.

셀렉터는 내부적으로 Queue 자료구조를 사용하여 채널에서 발생한 이벤트를 관리하며 각 채널의 이벤트를 SelectionKey 객체로 표현한다.

public class NonBlockingServer {
    public static void main(String[] args) throws IOException {
        Selector selector = Selector.open();
        ServerSocketChannel serverSocket = ServerSocketChannel.open();
        serverSocket.bind(new InetSocketAddress(8080));
        serverSocket.configureBlocking(false); // 논블로킹 설정
        serverSocket.register(selector, SelectionKey.OP_ACCEPT);

        while (true) {
            selector.select(); // 이벤트 발생 시까지 대기
            Iterator<SelectionKey> keys = selector.selectedKeys().iterator();

            while (keys.hasNext()) {
                SelectionKey key = keys.next();
                keys.remove();

                if (key.isAcceptable()) { // 새로운 클라이언트 연결 처리
                    SocketChannel client = serverSocket.accept();
                    client.configureBlocking(false);
                    client.register(selector, SelectionKey.OP_READ);
                } else if (key.isReadable()) { // 클라이언트 데이터 읽기
                    SocketChannel client = (SocketChannel) key.channel();
                    ByteBuffer buffer = ByteBuffer.allocate(256);
                    int bytesRead = client.read(buffer);

                    if (bytesRead > 0) {
                        String request = new String(buffer.array()).trim();
                        System.out.println("Received: " + request);
                        buffer.flip();
                        client.write(ByteBuffer.wrap(("Hello, client! You said: " + request).getBytes()));
                    } else {
                        client.close(); // 연결 종료
		}}}}}}

 

Spring WebFlux는 논블록킹 I/O와 리액티브 프로그래밍을 지원한다.

리액터(Reactor)를 기반으로 동작하며 Netty와 같은 논블록킹 서버를 사용한다. 

1. 요청이 들어오면 Event Loop 기반으로 작업을 관리한다.

2. I/O 작업이 필요한 경우 스레드가 블로킹상태로 대기하지 않고 즉시 반환된다.

3. 데이터가 준비되면 이벤트를 통해 처리되고 결과가 클라이언트로 반환된다.

4. 단일 스레드 또는 소규모 스레드로 대규모 요청을 처리할 수 있다.

@RestController
@RequestMapping("/api")
public class ReactiveController {

    @GetMapping("/hello")
    public Mono<String> sayHello() {
        return Mono.just("Hello, World!");
    }

    @GetMapping("/data")
    public Mono<String> fetchData() {
        return Mono.fromSupplier(() -> {
            // 논블로킹 I/O 작업
            try {
                Thread.sleep(5000); // 실제 논블로킹이라면 비동기로 작동
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            return "Data fetched!";
        });
    }
}

결론

특성 멀티스레드 모델 논블로킹 모델 Spring MVC Spring WebFlux
작동방식 요청마다 새로운 스레드 생성 또는 할당 I/O 요청이 즉시 반환되고 이벤트로 처리 블로킹 I/O 논블로킹 I/O
자원 사용 스레드 관리로 인해 메모리와 CPU 사용 증가 적은 스레드로 효율적인 처리 요청당 하나의 스레드 사용 이벤트 루프 기반, 소수의 스레드 사용
성능 적은 요청에서는 빠르지만 확장성 한계 대규모 요청에서 높은 성능 적은 요청 수에서는 적합 대규모 트래픽에 적합
프로그래밍 복잡도 상대적으로 간단함 상태 관리와 콜백 처리로 복잡 동기적, 구현이 간단 비동기적, 리액티브 프로그래밍 필요
적합한 경우 요청 수가 적고 코드 간결성이 중요한 경우 대규모 병렬 처리와 고성능 시스템 CRUD 애플리케이션 실시간 데이터 처리, 대규모 애플리케이션

 

  • 네이버 블로그 공유
  • 네이버 밴드 공유
  • 페이스북 공유
  • 카카오스토리 공유