왜 내가 만든 서버는 서버가 먼저 요청을 끊는 걸까? 궁금증 해결기

2024. 8. 23. 19:16·etc

배경

네트워크 스터디를 진행하면서 HTTP 1.1에서는 일반적으로 연결을 끊을 때 클라이언트 측이 먼저 close를 통해 FIN 플래그를 1로 만든 패킷을 보낸다는 사실을 알게 되었습니다.

 

이에 제가 구현한 WAS 서버에서도 마찬가지인지가 궁금해져 WireShark를 통해 패킷을 주고받은 내역을 확인해 보게 되었는데, 저의 WAS 서버에서는 클라이언트 측이 아닌 서버 측에서 먼저 FIN 플래그를 보내 연결을 끊고 있었습니다.

 

물론 서버 - 클라이언트 둘 다 먼저 연결을 끊는 것이 가능하기 때문에 크게 문제 되는 상황은 아니지만, 학습 차원에서 WAS 서버와 통신을 진행할 때도 클라이언트가 먼저 FIN 요청을 보내도록 하는 상황을 만들어 보고 싶었습니다.

기존 코드 구조

소켓을 이용해 실제로 클라이언트의 요청을 읽고, 응답을 만드는 간단한 코드입니다. 스레드에 해당 RequestHandler 객체를 전달하여 실행합니다. (다른 메서드들은 표기생략)

public class RequestHandler implements Runnable {
    private Socket connection;

    public RequestHandler(Socket connectionSocket) {
        this.connection = connectionSocket;
    }

    public void run() {
        try (InputStream in = connection.getInputStream(); OutputStream out = connection.getOutputStream()) {
            BufferedReader br = new BufferedReader(new InputStreamReader(in, "UTF-8"));
            String line = br.readLine();

            // request의 첫번째 줄에서 path 분리하기
            String[] requestLine = line.split(" ");
            String path = "src/main/resources/static/" + requestLine[1];

            // request 요청은 마지막 끝에 공백문자가 포함되어 오기 때문에, ""을 체크하여 while문을 돈다
            while (!line.equals("")) {
                line = br.readLine();
                logger.debug("header : {}", line);
            }

            DataOutputStream dos = new DataOutputStream(out);
            byte[] mybody = parseFileToByte(path);
            response200Header(dos, mybody.length);
            responseBody(dos, mybody);

        } catch (IOException e) {
            logger.error(e.getMessage());
        }
    }
}

해당 코드를 통해 응답을 하는 경우에는 FIN 패킷을 다음과 같이 서버 → 클라이언트 순서로 주고받게 됩니다.

 

8080 포트에서 먼저 FIN 패킷이 전송되는 것을 확인할 수 있다

이유가 무엇일까?

왜 이런 현상이 일어나는 것일까 생각하다가 InputStream과 OutputStream을 try-with-resources 구문을 통해 자원 해제하고 있다는 점이 관련이 있어 보였습니다. 그래서 해당 스트림들을 사용 후 닫지 않도록 코드를 수정했습니다.

public class RequestHandler implements Runnable {
    private Socket connection;

    public RequestHandler(Socket connectionSocket) {
        this.connection = connectionSocket;
    }

    public void run() {
        try {
            // try-with-resources 제거
            InputStream in = connection.getInputStream();
            OutputStream out = connection.getOutputStream();

            BufferedReader br = new BufferedReader(new InputStreamReader(in, "UTF-8"));
            String line = br.readLine();

            String[] requestLine = line.split(" ");
            String path = "src/main/resources/static/" + requestLine[1];
            while (!line.equals("")) {
                line = br.readLine();
                logger.debug("header : {}", line);
            }

            DataOutputStream dos = new DataOutputStream(out);
            byte[] mybody = parseFileToByte(path);
            response200Header(dos, mybody.length);
            responseBody(dos, mybody);

        } catch (IOException e) {
            logger.error(e.getMessage());
        }
    }
}

코드를 수정하고 스트림들을 정리하지 않을 경우 아래 사진과 같이 클라이언트 측에서 먼저 종료를 요청하는 것을 확인할 수 있었습니다. 그렇지만 한 가지 의문인 점은 소켓이 아니고 소켓의 스트림을 close 했을 뿐인데, 연결 자체가 끊어지고 FIN을 보내게 되는 상황이 일어난다는 것입니다. 왜일까요?

 

클라이언트 포트 65378에서 먼저 FIN 패킷을 전송했다

Socket Stream의 종료는 곧 Socket의 종료

먼저 해당 스트림들이 어디에 속해 있는지부터 살펴보겠습니다. 스트림들은 Socket 객체에서 getInputStream, getOutputStream 을 통해 생성된 것들입니다. 즉 소켓을 읽고 쓰는 데 사용하는 스트림인 것인데요. 자바 공식 문서에서 해당 메서드들과 관련된 부분을 살펴보면 다음과 같은 내용이 있습니다.

getOutputStream
Returns an output stream for this socket.If this socket has an associated channel then the resulting output stream delegates all of its operations to the channel. If the channel is in non-blocking mode then the output stream's write operations will throw an IllegalBlockingModeException.
Closing the returned OutputStream will close the associated socket.

 

문서는 getOutputStream을 통해 가져와진 OutputStream이 닫힐 경우 연관된 소켓도 닫힌다고 설명하고 있습니다. (getInputStream 역시 동일합니다)

따라서 서버 코드에서 해당 스트림들을 정리해 주게 되면 소켓 또한 정리되는 효과가 있었던 것입니다.

따라서 코드가 끝까지 실행되면 소켓이 닫히게 되고, 이는 곧 연결이 끊어지게 됨을 의미하므로 서버에서 FIN 요청을 보내게 되었던 것으로 보입니다.

서버에서도 FIN 응답을 하도록 하려면?

그런데 앞서 패킷을 보면 클라이언트 측으로부터 FIN 을 받고 OK 응답을 보낸 후, 서버는 아직 FIN을 보내지 않았다는 것을 알 수 있습니다. 이렇게 되면 정상적인 TCP의 연결 끊기 동작이 완료되지 않아 문제가 될 수 있겠죠. 따라서 이 부분에 대해 코드 수정이 필요합니다.

 

만일 또 다시 맨 끝에 명시적으로 스트림이나 소켓을 닫아주도록 하면 동일하게 서버에서 연결을 먼저 종료합니다. 그렇기 때문에 단순히 바로 소켓을 닫는다면 원하는 동작을 확인할 수 없었습니다.

 

그렇다면 결국 클라이언트 측에서 연결을 끊었을 때 이를 감지하고 그때 서버에서 소켓을 닫아주는 작업이 필요하게 되는데요.

클라이언트와 서버를 이어주는 파이프 역할을 하는 스트림이 끝났다는 것은 연결의 종료를 의미하는 것으로 볼 수 있으므로, 이를 위해 inputStream에서 스트림의 끝을 의미하는 -1 값을 감지하는 방식으로 코드를 구현해 보았습니다.

public class RequestHandler implements Runnable {
    private Socket connection;

    public RequestHandler(Socket connectionSocket) {
        this.connection = connectionSocket;
    }

    public void run() {
        try {
            InputStream in = connection.getInputStream();
            OutputStream out = connection.getOutputStream();

            BufferedReader br = new BufferedReader(new InputStreamReader(in, "UTF-8"));
            String line = br.readLine();

            String[] requestLine = line.split(" ");
            String path = "src/main/resources/static/" + requestLine[1];
            while (!line.equals("")) {
                line = br.readLine();
                logger.debug("header : {}", line);
            }

            DataOutputStream dos = new DataOutputStream(out);
            byte[] mybody = parseFileToByte(path);
            response200Header(dos, mybody.length);
            responseBody(dos, mybody);

            // -1이 감지된 경우 socket을 닫아준다
            while (true) {
                int result = in.read();
                if (result == -1) {
                    logger.debug("-1 이 감지되었습니다");
                    break;
                }
            }
            connection .close();
        } catch (IOException e) {
            logger.error(e.getMessage());
        }
    }
}

계속 inputStream을 read하면서 -1 이 들어오면 반복문을 끝내고 소켓을 닫아 주도록 했습니다. 이렇게 변경한 후 다시 패킷을 확인해 보니 서버 측에서 정상적으로 FIN 응답이 날아가는 것을 확인할 수 있었습니다.

 

클라이언트 - 서버 순으로 서로 주고 받는 것을 확인 가능하다

한 가지, read 시 이벤트 발생 전까지는 Block되기 때문에 클라이언트로부터 요청이 오지 않을 것을 대비해 Timeout을 설정해 준다든지 추가적인 처리가 필요할 것 같다는 생각이 듭니다.

마치며..

클라이언트나 서버 둘 다 연결 끊기의 주체가 될 수 있음은 물론이요, 통신에서 1회성 연결 후 끊어버리는 방법이 아니라 연결 이후 지속적으로 송수신하는 방법도 있고, 심지어 HTTP 버전이 올라감에 따라 TCP를 사용하지 않아서 이런 handshake가 필요 없는 경우도 있는 세상에서 누가 먼저 연결을 끊는지를 확인하는 일이 크게 중요한 것은 아닐지도 모르지만 개인적인 궁금증을 해소하는 재밌는 시간이었습니다.

관련해서 이야기를 들어주고 함께 상황을 고민해 준 스터디원분들께도 감사하다는 말씀 드리며 글을 마칩니다! 😎

'etc' 카테고리의 다른 글

트랜잭션 격리 수준과 동시성 제어 이야기 (2) : SERIALIZABLE과 Deadlock  (3) 2025.01.05
트랜잭션 격리 수준과 동시성 제어 이야기 (1) : @Transactional과 synchronized  (1) 2025.01.03
리눅스 scp 사용 시 Permission denied (publickey).lost connection 오류 해결기  (0) 2024.04.13
'etc' 카테고리의 다른 글
  • 트랜잭션 격리 수준과 동시성 제어 이야기 (2) : SERIALIZABLE과 Deadlock
  • 트랜잭션 격리 수준과 동시성 제어 이야기 (1) : @Transactional과 synchronized
  • 리눅스 scp 사용 시 Permission denied (publickey).lost connection 오류 해결기
seondays
seondays
  • seondays
    Maybe seondays
    seondays
  • 전체
    오늘
    어제
    • 분류 전체보기 (38)
      • python (5)
      • Java (16)
      • Dart (0)
      • 문제정리 (13)
      • etc (4)
  • 태그

    buddyguard
    Python
    트러블슈팅
  • 인기 글

  • 최근 댓글

  • 최근 글

  • hELLO· Designed By정상우.v4.10.3
seondays
왜 내가 만든 서버는 서버가 먼저 요청을 끊는 걸까? 궁금증 해결기
상단으로

티스토리툴바