본문 바로가기

Computer Science/네트워크

Socket의 연결 종료와 Timeout 설정하기 - setSoTimeOut

Socket 통신에 대해 공부하다가 궁금한 점을 기록한 내용입니다.

틀린 내용이 있다면 편하게 지적해주세요.

 

스프링에 대해서 학습하다가 간단히 아래와 같은 소켓 통신을 하는 서버 모델을 만들었습니다. 외부로부터 개방되어있는 listen socket은 8080포트로 들어오는 연결을 받아 connection 소켓에 할당합니다. 할당된 소켓은 새로운 스레드에 할당되어 클라이언트와 비동기적으로 통신하고, listenSocket은 다시 새로운 연결을 기다립니다.

public class WebServer {
    public static void main(String args[]) throws Exception {
        int port = 8080;
        
        try (ServerSocket listenSocket = new ServerSocket(port)) {
            logger.info("Web Application Server started {} port.", port);
            
            Socket connection;
            while ((connection = listenSocket.accept()) != null) {
                Thread thread = new Thread(new RequestHandler(connection));
                thread.start();
            }
        }
    }
}

위 서버의 requestHandler는 클라이언트의 요청을 받아 비즈니스 로직을 처리, 해당하는 View를 반환하고 소켓 통신이 종료될 것입니다. 정상적으로 요청이 처리된다면 스레드가 종료되고, 소켓이 close될 것입니다. 그렇지만 Socket이 만약 연결이 비정상적으로 종료되었다면 어떻게 될까요? 연결을 무한정 대기하게 될 수 있습니다.

 

TCP/IP의 구조 상 소켓에서는 네트워크 장애를 감지할 수 있는 방법이 없다고 합니다. 네트워크 장애를 감지하는 유일한 방법은 네트워크 통신을 시도해보는 방법 외에는 없습니다. 만약 통신을 시도했을 때 일정 시간동안 응답이 없다면 네트워크가 연결이 끊어져있다고 볼 수 있을 것입니다. 이러한 방법을 Socket Timeout이라고 합니다.

 

소켓 타임아웃(Socket Timeout)은 네트워크 연결 시 발생할 수 있는 문제 상황을 방지하기 위해 사용되는 기능입니다. 소켓 타임아웃은 소켓 연결 시간이 너무 오래 걸릴 경우, 혹은 데이터 송수신이 오랫동안 이루어지지 않을 경우 해당 소켓 연결을 끊는 기능입니다.

일반적으로, 소켓 연결 시 시간 제한은 두 가지 경우에 적용됩니다. 

 

1. 소켓 연결 시간 제한(Connect Timeout)

 

소켓 연결 시간 제한은 서버와의 소켓 연결이 수립되기까지 걸리는 시간을 제한합니다. 일반적으로 서버와의 소켓 연결 시간이 오래 걸릴 경우, 클라이언트 측에서는 대기 시간이 길어지는 문제가 발생합니다. 이를 방지하기 위해, 소켓 연결 시간 제한을 설정하여 일정 시간 내에 서버와의 소켓 연결이 수립되지 않으면, 해당 소켓 연결을 끊을 수 있습니다.

 

2. 응답 시간 제한(Read Timeout)

응답 시간 제한은 데이터 송수신 시간을 제한합니다. 클라이언트 측에서 서버로부터 응답을 받는데 오래 걸리는 경우, 소켓 읽기 작업이 블로킹되는 문제가 발생합니다. 이러한 문제를 방지하기 위해, 응답 시간 제한을 설정하여 일정 시간 내에 데이터 송수신이 이루어지지 않으면 해당 소켓 연결을 끊을 수 있습니다.

두 가지 경우에 대해서, dead connection 상태가 된 소켓이 계속해서 연결을 유지하는 경우가 발생할 수 있기 때문에, 응답 시간 제한을 두어 사용하지 않는 소켓을 종료시키는 방법을 생각했습니다.

 

클라이언트와 연결된 소켓의 상태를 주기적으로 확인해주는 클래스를 아래와 같이 구현했습니다. 아래 코드는 주어진 소켓 연결의 상태를 주기적으로 체크하고, 소켓이 정상적으로 연결되어 있는지를 확인합니다. Timer 클래스를 사용하여 주어진 소켓 연결(connection)의 입력 스트림 상태를 주기적으로 체크합니다. 
run() 메서드에서는 입력 스트림의 available() 메서드를 호출하여 응답이 있는지를 확인합니다. 이후 읽을 수 있는 바이트 수가 있다면, read() 메서드를 사용하여 바이트 배열(buffer)로부터 읽어들입니다. 읽어들인 바이트 수가 -1이면, 클라이언트와의 연결이 끊어졌음을 의미하므로, 이를 처리하고 Timer를 취소하여 더 이상 ping() 메서드가 호출되지 않도록 합니다.

public class SocketStatusChecker {
    /**
     * Client가 연결되면 Timer가 실행되고, 5초 후에 BufferReader에서 읽을 byte가 없거나 IOException이 발생하면(소켓이 닫힌 경우라고 생각됩니다.)
     * Timer를 취소하고 socket을 close 시도합니다.
     */
    private static final int PING_INTERVAL = 5000;

    public static void ping(Socket connection, Logger logger) {
        Timer timer = new Timer();
        timer.scheduleAtFixedRate(new TimerTask() {
            @Override
            public void run() {
                try {
                    connection.getInputStream().available();
                    byte[] buffer = new byte[1024];
                    int read = connection.getInputStream().read(buffer);

                    if (read == -1) {
                        logger.debug("Client IP : {} Port : {} 클라이언트와의 연결이 끊어졌습니다.", connection.getInetAddress(),
                                connection.getPort());
                        connection.close();
                        timer.cancel();
                    }
                } catch (IOException e) {
                    logger.debug("Client IP : {} Port : {} 클라이언트의 응답이 없습니다. 연결을 종료합니다.", connection.getInetAddress(),
                            connection.getPort());
                    try {
                        connection.close();
                    } catch (IOException ignored) {
                    }
                    timer.cancel();
                }
            }
        }, PING_INTERVAL, PING_INTERVAL);
    }
}

 

간단한 서버를 구현했으니, 요청을 보내줄 클라이언트도 작성해보겠습니다. curl을 통해서 간단한 요청을 보낼 수 있지만, 클라이언트에서 요청을 보내고 영원히 block되는 경우에 server에서 감지할 수 있는지 보겠습니다. 아래 코드에서 요청을 전송한 후, 응답을 읽고 마지막에 무한 loop에 갇혀 소켓이 종료되지 않을 것입니다. 이 경우에 클라이언트에서는 소켓을 유지하고, 서버에서는 소켓이 열려있으니 클라이언트가 종료했는지 여부를 확인하지 못하므로 죽은 연결이 된다고 볼 수 있습니다. 

 

public static void connect(Logger logger) {
        try {
            logger.debug("서버 연결 : {} 포트 번호 : {}", serverName, DEFAULT_PORT);
            Socket socket = new Socket(serverName, DEFAULT_PORT);

            BufferedReader readDataFromServer = new BufferedReader(
                    new InputStreamReader(socket.getInputStream(), "UTF-8"));
            DataOutputStream sendDataToServer = new DataOutputStream(socket.getOutputStream());


            //Server로 HTTP 요청 전송
            sendDataToServer.writeBytes("GET / HTTP/1.1\r\n"
                    + "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7\r\n"
                    + "Accept-Encoding: gzip, deflate, br\r\n"
                    + "Accept-Language: ko-KR,ko;q=0.9,en-US;q=0.8,en;q=0.7\r\n"
                    + "Cache-Control: no-cache\r\n\r\n");

            //첫 줄 읽기
            String httpResponseLine = readDataFromServer.readLine();
            logger.debug("responseLine : {}", httpResponseLine);

            //Header 읽기
            String httpResponseHeaders;
            while (!(httpResponseHeaders = readDataFromServer.readLine()).equals("")) {
                logger.debug("header : {}", httpResponseHeaders);
            }
            readDataFromServer.readLine();

            //Body 읽기
            String httpResponseBody;
            while ((httpResponseBody = readDataFromServer.readLine()) != null) {
                logger.debug("Body : {}", httpResponseBody);
            }
            //소켓 종료 안함
            while (true) {

            }
        } catch (IOException e) {
            throw new RuntimeException(e);
        }
    }

 

다음은 클라이언트에서 요청을 보냈을 때 서버에서의 log입니다. Timeout을 설정해주면 주기적으로 StreamReader를 확인하여 죽은 연결을 정리해줍니다.


SetSoTimeOut


비슷하지만 다른 구현 방법으로는 Java Socket의 setSoTimeOut 메서드를 이용하는 방법이 있습니다.

 

setSoTimeout() 메서드는 소켓 입력 스트림에서 읽을 데이터가 없을 때, 블로킹 상태가 되는 것을 방지하기 위해 사용됩니다. 스트림의 read() 메서드 Blocking 메서드이기 때문에 스트림에 어떤 데이터도 존재하지 않는다면, Blocking이 발생합니다. 만약 Blocking된 상태에서 connection이 끊어져버리면 blocking이 무한으로 이어질 것입니다. setSoTimeOut으로 시간 제한을 설정하면, 블로킹 상태에서 일정 시간 동안 데이터가 입력되지 않으면 해당 스트림에서 SocketTimeoutException이 발생합니다. 

즉, setSoTimeout() 메서드를 사용하여 소켓 입력 스트림에서 타임아웃 시간을 설정하면, 입력 스트림에서 읽을 데이터가 없는 상태가 지속될 경우, read() 메서드가 SocketTimeoutException을 발생시키고, 이를 통해 일정 시간 동안 입력 스트림에서 데이터가 들어오지 않았음을 알 수 있습니다.

따라서, setSoTimeout() 메서드와 Timer 클래스를 함께 사용하여 일정 시간 동안 클라이언트로부터 데이터를 받지 못할 경우, 해당 클라이언트와의 연결을 종료하는 예제에서 Timer 클래스는 정기적으로 입력 스트림에서 데이터가 들어오는지 체크하고, 데이터가 들어오지 않으면 read() 메서드에서 SocketTimeoutException을 발생시키므로, 이 예외를 처리하여 클라이언트와의 연결을 종료할 수 있습니다.

 

간단하게 다음처럼 timeout을 설정할 수 있습니다.

while ((connection = listenSocket.accept()) != null) {
                try {
                //시간 제한을 5초로 설정한다.
                    connection.setSoTimeout(5000);

                    Thread thread = new Thread(new RequestHandler(connection));
                    thread.start();
                } catch (Exception e) {
                    System.out.println("시간 초과!");
                }
       }

이상으로 socket 통신에서 timeout을 구현하는 방법을 살펴보았습니다.