본문 바로가기

Dev.BackEnd/JAVA

[JAVA Adv] Blocking I/O, ServerSocket, Socket 그리고 Thread Pool

Blocking I/O, ServerSocket, Socket 그리고 Thread Pool
성능에 영향을 미치는 결정적인 요인으로서의 I/O에는 크게 두 가지 I/O가 존재한다. 디스크에서 데이터를 읽어오는 I/O와 네트워크 통신에서 발생하는 I/O이다. 이 두 I/O 작업이 처리되는 속도는 CPU의 작업 처리 속도에 비해 매우 느리다. 그렇기 때문에 애플리케이션의 성능은 이 I/O 작업을 어떻게 처리하느냐에 달려있다고 할 수 있다. 그 중 네트워크 통신에 사용하는 Socket을 중심으로 I/O 방식을 알아보자.


Socket
소켓(Socket)의 정의를 다시 한 번 짚고 가자면, 소켓이란 데이터 송수신을 위한 네트워크 추상화 단위로 IP주소와 포트를 가지고 있으며 양방향 네트워크 통신이 가능한 객체다. 이 소켓에 데이터를 기록하고 읽으려면 소켓에 연결된 소켓 채널(Socket channel) 또는 스트림(Stream)을 통해 기술한다. Socket의 동작 방식은 블로킹 모드(blocking mode)논블로킹 모드(non-blocking mode)로 나뉜다. 블로킹은 요청한 작업이 성공하거나 에러가 발생하기 전까지는 응답을 돌려주지 않는 것을 말하며 논블로킹은 요청한 작업의 성공 여부와는 상관없이 바로 결과를 돌려주는 것을 말한다.


blocking I/O
Java에서 블로킹 소켓(blocking Socket)은 ServerSocket, Socket 두 가지의 클래스를 사용한다. 클라이언트가 서버로 연결 요청을 보내면 서버는 연결을 수락(accept)하고 클라이언트와 연결된 소켓을 새로 생성하는데 이 때 해당 메서드의 처리가 완료되기 전까지 스레드에 블로킹이 발생하게 된다. 또 클라이언트가 연결된 소켓을 통해서 서버로 데이터를 전송하면 서버는 클라이언트가 전송한 데이터를 읽기 위해 read 메소드를 호출하고 이 메서드의 처리가 완료되기 전까지 스레드가 블로킹 된다.


병렬 처리의 문제
블로킹 소켓은 데이터 입출력에서 스레드의 블로킹이 발생하기 때문에 동시에 여러 클라이언트에 대한 처리가 불가능하게 된다. 이러한 문제를 해결하기 위한 모델은 연결된 클라이언트별로 각각 스레드를 할당하는 모델이다. 클라이언트가 서버에 접속하면 서버는 새로운 스레드를 하나 생성하고 그 스레드에게 클라이언트 소켓에 대한 I/O 처리를 넘겨주게 된다. 이로써 서버 소켓이 동작하는 스레드는 다음 클라이언트의 연결을 처리할 수 있게 된다.


블로킹 소켓으로 여러 클라이언트에 대한 요청을 해결할 수 있게 되었다. 그런데 여러 클라이언트가 동시에 접속 요청을 하는 상황에서는 어떨까?
접속을 요청한 클라이언트들은 각각에 해당하는 스레드를 생성하고 할당하고 제거하는 시간을 모두 기다려야하기 때문에 대기 시간이 길어진다. 또한 서버는 클라이언트의 요청이 올 때마다 계속해서 스레드를 생성할 것인데 이것은 스레드가 생성되는 공간인 힙 메모리의 부족을 야기할 수 있다. 계속해서 클라이언트의 요청에 따른 스레드를 생성하다가 Out Of Memory(OOM) 오류가 발생할 수 있는 것이다. 이것은 결국 서버가 다운되고 서비스 불가 상태로 이어지게 된다.


스레드 풀(Thread Pool)
이 문제를 해결하기 위해 스레드 풀이란 개념이 등장하였다. 스레드 수 증가에 따른 OOM 오류를 피하기 위해 일정 개수의 스레드를 스레드 풀에 미리 생성해두는 방법이다. 클라이언트의 요청이 들어오면 그 요청은 일단 작업 큐에 있고, 스레드 풀에서 가용 스레드를 할당받는 방식인 것이다. 스레드 풀을 사용하면 스레드 생성, 제거에 대한 오버헤드도 사라지고, 동시 접속 클라이언트에 대해 대처할 수 있다.

Java에서는 java.uitl.concurrent.Executors에서 스레드 풀을 제공한다.
Executors의 구조는 Producer-Consumer 패턴으로 만들어져 있다. 따라서 스레드가 생성하고 나서 같은 작업을 다시 하는 일이 없다면 스레드가 처리하고 나서 바로 종료 처리해도 되지만 반복적으로 어떠한 작업을 어떠한 시점에 해야 한다면 매번 스레드를 생성하고 종료하는 로직보다는 해당 스레드가 계속 대기 중인 상태가 되어 작업할 시점이 왔을 때 처리하는 것이 좋다. Executor에서는 각종 상황에 맞는 스레드 풀을 제공하고 있다.
newFixedThreadPool
parameter로 주어진 스레드 개수만큼 스레드를 생성하고, 애플리케이션이 종료될 때까지 그 수를 유지한다.
newCachedThreadPool
처리할 작업이 많아지면 그 만큼 스레드를 증가하여 생성한다. 만약 놀고 있는 스레드가 많다면 해당 스레드를 종료시킨다. 스레드의 개수를 유동적으로 조절해주기 때문에 유연한 대처가 가능하다는 장점이 있지만 처리할 작업이 무한정으로 많아지면 스레드의 개수도 계속해서 늘어나기 때문에 위에서 발생한 OOM 오류가 발생할 수 있다.
newSingleThreadExecutor
스레드를 하나만 생성하며 생성된 스레드가 비정상적으로 종료될 경우 하나의 스레드를 다시 생성한다.
newScheduledThreadPool
스레드와 관련된 작업을 특정 시간 이후에 실행되거나 주기적으로 작업을 실행할 수 있는 스레드 풀을 생성한다.


이 방법에는 문제가 없을까?
스레드 풀의 크기를 어떻게 잡아야 할까? 동시 접속 수를 늘리기 우해서 스레드 풀의 크기를 자바 힙 메모리가 허용하는 한도까지 키우는 것이 옳을까? 자바에는 메모리를 청소해주는 GC(Garbage Collection)이 존재한다. 애플리케이션 서버가 가동되고 시간이 흐름에 따라 GC 대상이 되는 객체 수가 늘어나게 된다. 결국 애플리케이션 서버가 작동하는 중간에 GC 가 발생할 것이고 이것은 서버가 작동하지 않는 것처럼 보이게 된다. (Stop the World!) 그리고 작동하지 않는 것처럼 보이는 이 시간은 힙 크기에 비례하게 된다. 만약 동시 접속 수를 늘리기 위해 스레드 풀의 크기를 최대로 키웠다면 이 대기 시간은 길어질 것이다. 힙에 할당된 메모리가 크면 클수록 GC가 발생하는 경우는 적어지겠지만 수행시간인 길어지게 되므로 trade-off가 존재하는 것이다.

CPU의 입장에서는 어떨까? 스레드가 많으면 많을 수록 당연히 좋지 않다. 수많은 스레드가 CPU 자원을 획득하기 위해 경쟁하면서 CPU 자원을 소모하기 대문에 실제로 작업에 사용할 CPU 자원이 적어지게 되는 결과를 초래한다.

정말 온갖 문제를 모두 떠안고 있는 것처럼 블로킹 방식의 통신에 대해 이야기했지만 대부분의 서비스들은 이 방식으로도 잘 작동한다. Tomcat이 그 예가 되겠다.


>> non-blocking I/O에 대해서 >>

The end.