[Java] 소켓 통신과 네트워크 예외

2025. 1. 10. 17:01·Java
728x90
반응형

두 개의 호스트가 TCP/IP 프로토콜을 사용하여 서로 통신을 한다고 하자. 클라이언트와 서버 간의 데이터 통신을 위해서는 양쪽에 Socket이 열려있어야 한다. Socket은 서버와 연결되어 있는 연결점이라고 생각하면 된다. 자바에서는 다음과 같이 소켓을 통해 TCP 접속을 시도할 수 있다.

 

먼저 클라이언트에서 서버와 통신하기 위한 코드를 살펴보자.


클라이언트 코드

Socket

Socket socket = new Socket("localhost", PORT);
  • Socket 생성자의 매개변수로 호스트명과 포트번호를 전달한다.
    • 전달된 호스트명과 매핑되는 IP를 찾기 위해 Socket은 내부적으로 InetAddress를 사용한다
    • InetAddress.getByName("호스트명")을 통해 호스트명과 매핑되는 IP 주소를 조회할 수 있다. 이 과정에서 시스템의 호스트 파일을 먼저 확인하고, 호스트 파일에 정의되어 있지 않다면, DNS 서버에 요청하여 IP 주소를 얻는다
  • 연결이 성공적으로 완료되면 Socket 객체를 반환한다
    • Socket = 서버와 연결되어 있는 연결점
    • Socket 객체를 통해 서버와 통신 가능
  • 서버에서 클라이언트와 TCP 연결을 위해서는 클라이언트도 포트 정보가 필요하지만, 클라이언트는 자신의 포트를 지정할 필요가 없다. 보통 포트를 생략하여 랜덤 포트를 할당받아 서버가 이 포트를 통해 자신과 통신 가능하도록 한다. (명시적으로 할당은 가능하지만 잘 사용하지 않는다)

Socket - InputStream, OutputStream

클라이언트와 서버 간의 통신을 위해서는 Socket이 제공하는 스트림을 사용해야 한다.

DataInputStream input = new DataInputStream(socket.getInputStream());
DataOutputStream output = new DataOutputStream(socket.getOutputStream());
  • socket.getInputStream(), socket.getOutputStream()을 통해 입력 스트림과 출력 스트림을 얻을 수 있다. 이를 통해 연결된 서버와 데이터를 주고받을 수 있다.
  • InputStream , OutputStream 을 그대로 사용하면 모든 데이터를 byte로 변환해서 전달해야 하기 때문에 번거롭다. 여기서는 DataInputStream , DataOutputStream 이라는 보조 스트림을 사용해서, 자바 타입의 메시지를 편리하게 주고 받을 수 있도록 했다

자원 정리

// 자원 정리
log("연결 종료: " + socket);
input.close();
output.close();
socket.close();

사용이 끝나면 사용한 자원은 반드시 반납해야 한다.

클라이언트 전체 코드

import java.io.DataInputStream;
import java.io.DataOutputStream;
import java.io.IOException;
import java.net.Socket;

import static util.MyLogger.log;

public class ClientV1 {

  private static final int PORT = 12345;

  public static void main(String[] args) throws IOException {
    log("클라이언트 시작");

    Socket socket = new Socket("localhost", PORT);
    DataInputStream input = new DataInputStream(socket.getInputStream());
    DataOutputStream output = new
    DataOutputStream(socket.getOutputStream());
    log("소캣 연결: " + socket);

    // 서버에게 문자 보내기
    String toSend = "Hello";
    output.writeUTF(toSend);
    log("client -> server: " + toSend);

    // 서버로부터 문자 받기
    String received = input.readUTF();
    log("client <- server: " + received);

    // 자원 정리
    log("연결 종료: " + socket);
    input.close();
    output.close();
    socket.close();
  }
}

 

이제 서버 코드를 작성해보자!


서버 코드

ServerSocket

서버는 요청을 받기 위해, 클라이언트가 접속이 가능하게 하기 위해 특정한 포트를 계속해서 열어두고 있어야 한다.

ServerSocket serverSocket = new ServerSocket(PORT);
  • 서버는 서버 소켓(ServerSocket)이라는 특별한 소켓을 사용한다
  • 지정한 포트를 사용해 서버 소켓을 생성하면, 클라이언트는 해당 포트로 서버에 연결할 수 있다

클라이언트와 서버의 연결 과정을 자세히 알아보자

  • 서버가 12345 포트로 서버 소켓을 열어두면, 클라이언트는 이 포트를 통해 서버에 접속할 수 있다
  • 클라이언트가 12345 포트에 연결 시도 시, 다음과 같은 과정이 발생한다
    1. OS 계층에서 TCP 3 way handshake가 발생하고, TCP 연결이 완료된다
    2. TCP 연결이 완료되면 서버는 OS backlog queue라는 곳에 클라이언트와 서버의 TCP 연결 정보를 보관한다
      • 이 연결 정보를 보면 클라이언트의 IP, PORT, 서버의 IP, PORT 정보가 모두 들어있다

accept()

Socket socket = serverSocket.accept();
  • 서버 소켓은 단지 클라이언트와 서버의 TCP 연결만 지원하는 특별한 소켓이다
  • 실제 클라이언트와 서버가 정보를 주고 받으려면 마찬가지로 Socket 객체가 필요하다. 이는 serverSocket.accept() 메서드를 호출하여 반환받을 수 있다.
    1. accept() 호출 시 OS backlog queue에서 TCP 연결 정보를 조회한다.
      • 만약 TCP 연결 정보가 없다면, 연결 정보가 생성될 때까지 대기한다(= 블로킹 된다)
    2. 해당 정보를 기반으로 Socket 객체를 생성한다
    3. 사용한 TCP 연결 정보는 OS backlog queue에서 제거한다

서버 전체 코드

import java.io.DataInputStream;
import java.io.DataOutputStream;
import java.io.IOException;
import java.net.ServerSocket;
import java.net.Socket;

import static util.MyLogger.log;

public class ServerV1 {

  private static final int PORT = 12345;

  public static void main(String[] args) throws IOException {

    log("서버 시작");
    ServerSocket serverSocket = new ServerSocket(PORT);
    log("서버 소켓 시작 - 리스닝 포트: " + PORT);

    Socket socket = serverSocket.accept();
    log("소켓 연결: " + socket);
    DataInputStream input = new DataInputStream(socket.getInputStream());
    DataOutputStream output = new
    DataOutputStream(socket.getOutputStream());

    // 클라이언트로부터 문자 받기
    String received = input.readUTF();
    log("client -> server: " + received);

    // 클라이언트에게 문자 보내기
    String toSend = received + " World!";
    output.writeUTF(toSend);
    log("client <- server: " + toSend);

    // 자원 정리
    log("연결 종료: " + socket);
    input.close();
    output.close();
    socket.close();
    serverSocket.close();
  }
}
  • 서버에서의 입력 스트림은 클라이언트가 전달한 데이터를 서버가 받을 때 사용된다
  • 서버에서의 출력 스트림은 클라이언트에 데이터를 전달할 때 사용된다
  • => 클라이언트의 Output은 서버의 Input이고 반대로 서버의 Output은 클라이언트의 Input

현재 코드는 메시지를 하나만 주고 받으면 클라이언트와 서버가 모두 종료된다.. 계속해서 메세지를 주고받고, 원할 때 종료할 수 있도록 변경해보자


V2

이번에는 클라이언트와 서버가 메시지를 계속 주고 받다가, "exit"라고 입력하면 클라이언트와 서버를 종료해보자.

 

클라이언트

public class ClientV2 {
  public static final int PORT = 12345;
  public static void main(String[] args) throws IOException {
    log("클라이언트 시작");

    Socket socket = new Socket("localhost", PORT);
    DataInputStream input = new DataInputStream(socket.getInputStream());
    DataOutputStream output = new
    DataOutputStream(socket.getOutputStream());
    log("소캣 연결: " + socket);

    Scanner scanner = new Scanner(System.in);
    while (true) {
      System.out.print("전송 문자: ");
      String toSend = scanner.nextLine();

      // 서버에게 문자 보내기
      output.writeUTF(toSend);
      log("client -> server: " + toSend);
      if (toSend.equals("exit")) break;

      // 서버로부터 문자 받기
      String received = input.readUTF();
      log("client <- server: " + received);
    }

    // 자원 정리
    log("연결 종료: " + socket);
    input.close();
    output.close();
    socket.close();
  }
}
  • exit를 입력하면 클라이언트는 exit 메시지를 서버에 전송하고, 클라이언트는 while 문을 빠져나가면서 연결을 종료

서버

public class ServerV2 {
  private static final int PORT = 12345;
  public static void main(String[] args) throws IOException {
    log("서버 시작");

    ServerSocket serverSocket = new ServerSocket(PORT);
    log("서버 소켓 시작 - 리스닝 포트: " + PORT);

    Socket socket = serverSocket.accept(); // 블로킹
    log("소켓 연결: " + socket);
    DataInputStream input = new DataInputStream(socket.getInputStream());
    DataOutputStream output = new
    DataOutputStream(socket.getOutputStream());

    while (true) {
      // 클라이언트로부터 문자 받기
      String received = input.readUTF(); // 블로킹
      log("client -> server: " + received);

      // 클라이언트 종료시 서버도 함께 종료
      if (received.equals("exit")) break;

      // 클라이언트에게 문자 보내기
      String toSend = received + " World!";
      output.writeUTF(toSend);
      log("client <- server: " + toSend);
    }

    // 자원 정리
    log("연결 종료: " + socket);
    input.close();
    output.close();
    socket.close();
    serverSocket.close();
  }
}
  • exit 메시지가 전송되면, 서버는 while 문을 빠져나가면서 연결을 종료

V2 문제

원하는대로 잘 이루어졌지만 서버가 여러 클라이언트와 통신할 수 없다는 문제가 남아있다.

 

이는 여러 클라이언트가 접속 시 TCP 연결이 수립된 후, OS backlog queue에는 잘 저장되고 클라이언트의 소켓도 잘 생성되지만.. 정작 서버는 ServerSocket.accept() 메서드 호출 시 backlog 큐의 정보를 기반으로 소켓 객체를 '하나만' 생성하게 되기 때문이다. 서버가 클라이언트와 데이터를 주고 받으려면 각 클라이언트 당 하나씩 소켓이 있어야 하는데, 우리의 코드는 serverSocket.accept()를 한번만 호출하여 하나의 클라이언트와만 소통이 가능하다.

 

소켓 객체가 없더라도 서버 소켓만으로도 TCP 연결은 완료된다. 하지만 연결 이후에 서로 메시지를 주고 받으려면 소켓 객체가 필요한데, 이는 1개 밖에 생성하지 못하기에 1개의 클라이언트만 메시지를 주고받을 수 있다. 즉, 여러 클라이언트를 통해 서버와 연결을 시도하면 모두 TCP 연결은 완료된다. 단지 데이터를 주고받을 수 있는 클라이언트가 하나 뿐이라는 것이다.

 

데이터를 주고받지 못하는 다른 클라이언트들이 보낸 메시지는 서버 애플리케이션에서 아직 읽지 않았기에 서버 OS의 TCP 수신 버퍼에서 대기한다. 소켓을 연결해야 소켓의 스트림을 통해 OS TCP 수신 버퍼에 있는 메시지를 읽을 수 있고, 또 전송할 수도 있다.

 

 

정리하면 문제는 다음과 같다.

  • 새로운 클라이언트가 접속하면?
    • 새로운 클라이언트 접속 시 서버의 main 스레드는 accept()를 절대로 호출할 수 없다.. 이미 while 문으로 넘어가 기존 클라이언트와 메시지를 주고받고 있기 때문
    • accept()를 호출해야 소켓 객체를 생성하고 새로운 클라이언트와 메시지를 주고받을 수 있다
  • 2개의 블로킹 작업 -> 핵심은 별도의 스레드가 필요함!
    • accept(): 클라이언트와 서버의 연결을 처리하기 위해 대기
    • readXxx(): 클라이언트의 메시지를 받아서 처리하기 위해 대기
    • 각각의 블로킹 작업은 별도의 스레드에서 처리해야 한다. 그렇지 않으면 다른 블로킹 메서드 때문에 진행하지 못하고 계속 대기할 수 있다.

=> serverSocket.accept()를 계속 돌면서 소켓 객체를 생성해주는 스레드 1개 + 클라이언트 접속 시 클라이언트의 메시지를 대기하고 읽는 스레드 1개가 필요하다!


V3

  • 서버의 main 스레드는 서버 소켓을 생성하고, 서버 소켓의 accept()를 반복해서 호출해야 한다
  • 클라이언트가 서버에 접속하면 서버 소켓의 accept() 메서드가 Socket 객체를 반환한다.
  • main 스레드는 이 정보를 기반으로 Runnable을 구현한 Session 이라는 별도의 객체를 만들고, 새로운 스레드에서 이 객체를 실행한다
  • Session 객체를 실행하는 스레드는 연결된 클라이언트와 메시지를 주고받는다

즉,

  • main 스레드: 새로운 연결이 있을 때마다 Session 객체와 별도의 스레드를 생성하고, 별도의 스레드에서 Session 객체를 실행하도록 한다
  • Session 담당 스레드: 자신의 소켓이 연결된 클라이언트와 메시지를 반복해서 주고 받는 역할을 담당한다

Server

public class ServerV3 {
  private static final int PORT = 12345;

  public static void main(String[] args) throws IOException {
    log("서버 시작");

    ServerSocket serverSocket = new ServerSocket(PORT);
    log("서버 소켓 시작 - 리스닝 포트: " + PORT);

    while (true) {
      Socket socket = serverSocket.accept(); // 블로킹
      log("소켓 연결: " + socket);

      SessionV3 session = new SessionV3(socket);
      Thread thread = new Thread(session);
      thread.start();
    }
  }
}

Session

public class SessionV3 implements Runnable {

  private final Socket socket;

  public SessionV3(Socket socket) {
      this.socket = socket;
  }

  @Override
  public void run() {
    try {
      DataInputStream input = new DataInputStream(socket.getInputStream());
      DataOutputStream output = new DataOutputStream(socket.getOutputStream());

      while(true) {
        // 클라이언트로부터 문자 받기
        String received = input.readUTF(); // 블로킹
        log("client -> server: " + received);
        if (received.equals("exit")) break;

        // 클라이언트에게 문자 보내기
        String toSend = received + " World!";
        output.writeUTF(toSend);
        log("client <- server: " + toSend);
      }

      // 자원 정리
      log("연결 종료: " + socket);
      input.close();
      output.close();
      socket.close();
    } catch (IOException e) {
        throw new RuntimeException(e);
    }
  }
}

 

서버 소켓을 통해 소켓을 연결하는 부분과 각 클라이언트와 메시지를 주고 받는 부분이 별도의 스레드로 나뉘어 있다.

블로킹 되는 부분은 이렇게 별도의 스레드로 나누어 실행해야 한다.

V3 문제

  • 클라이언트 연결 종료 시
    • 클라이언트 프로세스가 종료되면서, 클라이언트와 서버의 TCP 연결도 함께 종료된다.
    • 이때, 서버에서 readUTF()로 클라이언트 메시지를 읽으려고 하면 더 읽을 수 있는 메시지가 없기에 EOFException이 발생한다
  • => 이렇게 예외가 발생하면, 서버에서 자원 정리 코드를 호출하지 못한다

try-with-resources 구문을 이용해서 자원을 정리하도록 만들자!

 

또한, 서버를 종료할 때 서버 소켓과 연결된 모든 소켓 자원을 다 반납하고 서버를 안정적으로 종료하기 위해 '셧다운 훅'을 사용해보자!
자바는 프로세스가 종료될 때, 자원 정리나 로그 기록과 같은 종료 작업을 마무리 할 수 있는 셧다운 훅이라는 기능을 지원한다.

 

프로세스 종료의 2가지 분류

  • 정상 종료
    • 모든 논 데몬 스레드의 실행 완료로 자바 프로세스 정상 종료
    • 사용자가 Ctrl+C를 눌러 프로그램 중단
    • kill 명령 전달(kill -9 제외)
    • 인텔리제이 stop 버튼
  • 강제 종료
    • 운영체제에서 프로세스를 더 이상 유지할 수 없다고 판단할 때 사용
    • 리눅스/유닉스의 kill -9나 Windows의 taskkill /F

정상 종료의 경우 셧다운 훅이 작동하지만, 강제 종료의 경우에는 셧다운 훅이 작동하지 않는다


V4

먼저 소켓과 스트림을 종료하는 데에 사용할 수 있는 간단한 유틸 클래스를 만들자

public class SocketCloseUtil {

  public static void closeAll(Socket socket, InputStream input, OutputStream output) {
    close(input);
    close(output);
    close(socket);
  }

  public static void close(InputStream input) {
    if (input != null) {
      try {
          input.close();
      } catch (IOException e) {
          log(e.getMessage());
      }
    }
  }

  public static void close(OutputStream output) {
    if (output != null) {
      try {
          output.close();
      } catch (IOException e) {
          log(e.getMessage());
      }
    }
  }

  public static void close(Socket socket) {
    if (socket != null) {
      try {
          socket.close();
      } catch (IOException e) {
          log(e.getMessage());
      }
    }
  }
}
  • 기본적인 null 체크와 자원 종료시 예외를 잡아서 처리하는 코드가 들어가 있다
  • 각각의 예외를 잡아서 처리했기 때문에 Socket , InputStream , OutputStream 중 하나를 닫는 과정에서 예외가 발생해도 다음 자원을 닫을 수 있다
  • Socket 을 먼저 생성하고, Socket 을 기반으로 InputStream , OutputStream 을 생성하기 때문에 닫을 때는 InputStream , OutputStream 을 먼저 닫고 Socket 을 마지막에 닫아야 한다. (InputStream , OutputStream 의 닫는 순서는 상관이 없다.)
public class ClientV4 {

  public static final int PORT = 12345;

  public static void main(String[] args) {
    log("클라이언트 시작");

    try(
        Socket socket = new Socket("localhost", PORT);
        DataInputStream input = new DataInputStream(socket.getInputStream());
        DataOutputStream output = new DataOutputStream(socket.getOutputStream())
    ) {
      log("소캣 연결: " + socket);
      Scanner scanner = new Scanner(System.in);
      while (true) {
        System.out.print("전송 문자: ");

        String toSend = scanner.nextLine();

        // 서버에게 문자 보내기
        output.writeUTF(toSend);
        log("client -> server: " + toSend);
        if (toSend.equals("exit")) break;

        // 서버로부터 문자 받기
        String received = input.readUTF();
        log("client <- server: " + received);
      }
    } catch (IOException e) {
        log(e);
    }
  }
}

 

SessionManager


각 세션은 소켓과 연결 스트림을 가지고 있다. 따라서 서버를 종료할 때 사용하는 세션들도 함께 종료해야 한다. 모든 세션들을 찾아서 종료하려면 생성한 세션을 보관하고 관리할 객체가 필요하다.

 

=> 서버에 세션을 관리하는 세션 매니저를 추가하자
=> 그리고 세션 매니저는 여러 스레드에서 동시 접근이 가능하므로 동시성 처리가 필요하다. synchronized를 적용해주자!

public class SessionManagerV4 {
  private List<SessionV4> sessions = new ArrayList<>();

  public synchronized void add(SessionV4 session) {
      sessions.add(session);
  }

  public synchronized void remove(SessionV4 session) {
      sessions.remove(session);
  }

  public synchronized void closeAll() {
    for (SessionV4 session : sessions) {
        session.close();
    }

    sessions.clear();
  }
}

 

Session

public class SessionV4 implements Runnable {

  private final Socket socket;
  private final DataInputStream input;
  private final DataOutputStream output;
  private final SessionManagerV4 sessionManager;
  private boolean closed = false;

  public SessionV4(Socket socket, SessionManagerV4 sessionManager) throws IOException {
    this.socket = socket;
    this.input = new DataInputStream(socket.getInputStream());
    this.output = new DataOutputStream(socket.getOutputStream());
    this.sessionManager = sessionManager;
    this.sessionManager.add(this);
  }

  @Override
  public void run() {
    try {
      while (true) {
        // 클라이언트로부터 문자 받기
        String received = input.readUTF();
        log("client -> server: " + received);
        if (received.equals("exit")) break;

        // 클라이언트에게 문자 보내기
        String toSend = received + " World!";
        output.writeUTF(toSend);
        log("client <- server: " + toSend);
      }
    } catch (IOException e) {
      log(e);
    } finally {
      sessionManager.remove(this);
      close();
    }
  }

  // 세션 종료시, 서버 종료시 동시에 호출될 수 있다.
  public synchronized void close() {
    if (closed) return;

    closeAll(socket, input, output);
    closed = true;
    log("연결 종료: " + socket);
  }
}
  • 아쉽지만 Session에서는 try-with-resources를 사용할 수 없다.
    • try-with-resources는 사용과 해제를 함께 묶어서 처리할 때 사용한다
      • try에서 선언된 자원을 try가 끝나는 시점에 정리한다.
    • 하지만 지금은 서버를 종료하는 시점에도 Session이 사용하는 자원을 정리해야 한다. 서버를 종료하는 시점에 자원을 정리하는 것은 Session 안에 있는 try-with-resources로 처리할 수 없다.
  • 자원을 정리하는 close() 메서드는 2곳에서 호출될 수 있기에 동시성 문제를 막기 위해 synchronized 키워드를 사용했다
    • 클라이언트와 연결이 종료되었을 때 (exit 입력 또는 예외 발생)
    • 서버를 종료할 때 셧다운 훅에 의해
  • 추가적으로 자원 정리 코드의 동시성 문제는 synchronized로 막았지만, 중복 호출되는 비효율성을 줄이기 위해 closed 변수를 플래그로 사용했다

Server

public class ServerV4 {
  private static final int PORT = 12345;

  public static void main(String[] args) throws IOException {
    log("서버 시작");
    SessionManagerV4 sessionManager = new SessionManagerV4();
    ServerSocket serverSocket = new ServerSocket(PORT);
    log("서버 소켓 시작 - 리스닝 포트: " + PORT);

    // ShutdownHook 등록
    ShutdownHook shutdownHook = new ShutdownHook(serverSocket, sessionManager);
    Runtime.getRuntime().addShutdownHook(new Thread(shutdownHook, "shutdown"));

    try {
      while (true) {
        Socket socket = serverSocket.accept(); // 블로킹
        log("소켓 연결: " + socket);

        SessionV4 session = new SessionV4(socket, sessionManager);
        Thread thread = new Thread(session);
        thread.start();
      }
    } catch (IOException e) {
        log("서버 소캣 종료: " + e);
    }
  }

  static class ShutdownHook implements Runnable {
    private final ServerSocket serverSocket;
    private final SessionManagerV4 sessionManager;

    public ShutdownHook(ServerSocket serverSocket, SessionManagerV4 sessionManager) {
      this.serverSocket = serverSocket;
      this.sessionManager = sessionManager;
    }

    @Override
    public void run() {
      log("shutdownHook 실행");

      try {
        sessionManager.closeAll();
        serverSocket.close();
        Thread.sleep(1000); // 자원 정리 대기
      } catch (Exception e) {
        e.printStackTrace();
        System.out.println("e = " + e);
      }
    }
  }
}
  • Runtime.getRuntime().addShutdownHook() 을 사용하면 자바 종료시 호출되는 셧다운 훅을 등록할 수 있다.
  • 셧다운 훅이 실행될 때 모든 자원을 정리한다
    • 셧다운 훅 실행 시 자원 정리 대기를 하는 것을 볼 수 있는데, 이는 다른 스레드가 자원을 정리하거나 필요한 로그를 남길 수 있도록 셧다운 훅의 실행을 잠시 대기하는 것이다.

실행결과 - 서버 종료 결과

17:43:57.484 [ main] 서버 시작
17:43:57.486 [ main] 서버 소켓 시작 - 리스닝 포트: 12345
17:43:59.037 [ main] 소켓 연결: Socket[addr=/
127.0.0.1,port=65407,localport=12345]
17:44:00.799 [ shutdown] shutdownHook 실행
17:44:00.800 [ shutdown] 연결 종료: Socket[addr=/
127.0.0.1,port=65407,localport=12345]
17:44:00.800 [ Thread-0] java.net.SocketException: Socket closed
17:44:00.800 [ main] 서버 소캣 종료: java.net.SocketException: Socket closed

 

서버를 종료하면 shutdown 스레드가 shutdownHook 을 실행하고, 세션의 Socket 의 연결을 close()로 닫는다.

  • [ Thread-0] java.net.SocketException: Socket closed
  • Session 의 input.readUTF() 에서 입력을 대기하는 Thread-0 스레드는 SocketException: Socket closed 예외를 받고 종료된다. 참고로 이 예외는 자신의 소켓을 닫았을 때 발생한다.

shutdown 스레드는 서버 소켓을 close()로 닫는다.

  • [ main] 서버 소캣 종료: java.net.SocketException: Socket closed
  • serverSocket.accept(); 에서 대기하고 있던 main 스레드는 java.net.SocketException: Socket closed 예외를 받고 종료된다.

네트워크 예외

연결 예외

  • java.net.UnknownHostException
    • 호스트를 알 수 없음
    • 존재하지 않는 호스트 or IP
  • java.net.ConnectException: Connection refused
    • 연결이 거절되었음
    • => 일단 네트워크를 통해 해당 IP의 서버 컴퓨터에 접속은 헀다는 뜻
    • 발생 상황
      • 주로 IP에 해당하는 서버는 켜져있지만, 사용하는 PORT가 없을 때 주로 발생
      • 클라이언트가 연결 시도 중에 RST 패킷을 받을 때 이 예외 발생
        • TCP RST 패킷은 TCP 연결에 문제가 있다는 뜻이다. 이 패킷을 받으면 받은 대상은 바로 연결을 해제해야 함
        • ex) 네트워크 방화벽 등에서 무단 연결로 인지하고 연결을 막는 경우, 서버 컴퓨터의 OS는 이때 TCP RST(Reset)이라는 패킷을 보내서 연결을 거절

타임아웃

네트워크 연결을 시도해서 서버 IP에 연결 패킷을 전달했지만 응답이 없는 경우 어떻게 될까?

 

OS 기본 대기 시간
TCP 연결을 시도했는데 연결 응답이 없다면 OS에는 연결 대기 타임아웃이 설정되어 있다.

  • Windows: 약 21초
  • Linux: 약 75초에서 180초 사이
  • mac test 75초
    => 해당 시간이 지나면 java.net.ConnectException: Operation timed out 이 발생

TCP 연결을 클라이언트가 계속해서 기다리도록 하는 것보다는, 일정 시간이 지나면 현재 연결에 문제가 있다고 알려주는 것이 더 나은 방법이다.

 

OS 기본 대기 시간을 사용하지 않고, TCP 연결 타임아웃 시간을 우리가 직접 설정해보자!

public class ConnectTimeoutMain {
  public static void main(String[] args) throws IOException {
    try {
      Socket socket = new Socket();
      socket.connect(new InetSocketAddress("192.168.1.250", 45678), 1000);
    } catch (SocketTimeoutException e) {
      // java.net.SocketTimeoutException: Connect timed out
      e.printStackTrace();
    }
  }
}
  • new Socket()
    • Socket 객체를 생성할 때 인자로 IP, PORT를 모두 전달하면 생성자에서 바로 TCP 연결을 시도
    • 하지만 IP, PORT를 모두 빼고 객체를 생성하면, 객체만 생성되고, 아직 연결은 시도하지 않는다.
    • 추가적으로 필요한 설정을 더 한 다음에 socket.connect() 를 호출하면 그때 TCP 연결을 시도
      • 방식을 사용하면 추가적인 설정을 더 할 수 있는데, 대표적으로 타임아웃을 설정
  • public void connect(SocketAddress endpoint, int timeout) throws IOException
    • InetSocketAddress : SocketAddress 의 자식. IP, PORT 기반의 주소를 객체로 제공한다.
    • timeout : 밀리초 단위로 연결 타임아웃을 지정할 수 있다
    • 타임아웃 시간이 지나도 연결이 되지 않으면 java.net.SocketTimeoutException: Connect timed out 예외 발생

TCP 소켓 타임아웃 - read 타임 아웃

소켓 타임아웃 또는 read 타임 아웃이라고 부르는 타임아웃은 TCP 연결과 관련이 되어있다.

연결이 잘 된 이후에 클라이언트가 서버에 어떤 요청을 했다고 가정하자.


그런데 서버가 계속 응답을 주지 않는다면, 무한정 기다려야 하는 것일까?
서버에 사용자가 폭주하고 매우 느려져서 응답을 계속 주지 못하는 상황이라면 어떻게 해야할까?

=> 이런 경우에 사용하는 것이 바로 소켓 타임아웃(read 타임아웃)이다.

public class SoTimeoutServer {
  public static void main(String[] args) throws IOException, InterruptedException {
    ServerSocket serverSocket = new ServerSocket(12345);
    Socket socket = serverSocket.accept();

    Thread.sleep(1000000);
  }
}
  • 서버는 소켓을 연결은 하지만, 아무런 응답을 주지 않는다.
public class SoTimeoutClient {
  public static void main(String[] args) throws IOException, InterruptedException {
    Socket socket = new Socket("localhost", 12345);
    InputStream input = socket.getInputStream();

    try {
      socket.setSoTimeout(3000); // 타임아웃 시간 설정
      int read = input.read(); // 기본은 무한 대기
      System.out.println("read = " + read);
    } catch (Exception e) {
        e.printStackTrace();
    }

    socket.close();
  }
}
  • socket.setSoTimeout() 을 사용하면 밀리초 단위로 타임아웃 시간을 설정할 수 있다. 여기서는 3초를 설정했다.
  • 3초가 지나면 java.net.SocketTimeoutException: Read timed out 예외 발생
  • 타임아웃 시간을 설정하지 않으면 (= socket.setSoTimeout() 설정하지 않으면) read()는 응답이 올 때까지 무한 대기한다
    • 실무에서 이 소켓 타임아웃(= read 타임 아웃)을 누락하여 장애가 발생하는 경우가 많다
    • 외부 서버와 통신을 하는 경우 반드시 '연결 타임아웃'과 '소켓 타임아웃'을 지정하자

강제 종료

Server

public class ResetCloseServer {
  public static void main(String[] args) throws IOException, InterruptedException {
    ServerSocket serverSocket = new ServerSocket(12345);
    Socket socket = serverSocket.accept();
    log("소캣 연결: " + socket);

    socket.close();
    serverSocket.close();
    log("소캣 종료");
  }
}
  • 서버는 소켓이 연결되면 단순히 연결을 종료한다.

Client

public class ResetCloseClient {
  public static void main(String[] args) throws IOException, InterruptedException {
    Socket socket = new Socket("localhost", 12345);
    log("소캣 연결: " + socket);
    InputStream input = socket.getInputStream();
    OutputStream output = socket.getOutputStream();

    // client <- server: FIN
    Thread.sleep(1000); // 서버가 close() 호출할 때 까지 잠시 대기

    // client -> server: PUSH[1]
    output.write(1);

    // client <- server: RST
    Thread.sleep(1000); // RST 메시지 전송 대기

    try {
      // java.net.SocketException: Connection reset 발생!
      int read = input.read();
      System.out.println("read = " + read);
    } catch (SocketException e) {
        e.printStackTrace();
    }

    try {
        output.write(1);
    } catch (SocketException e) {
      //java.net.SocketException: Broken pipe
      e.printStackTrace();
    }

  }
}

실행 결과

11:10:36.119 [ main] 소캣 연결: Socket[addr=localhost/
127.0.0.1,port=12345,localport=54490]
java.net.SocketException: Connection reset
...
java.net.SocketException: Broken pipe
...

 

분석

  • 서버는 종료를 위해 socket.close()를 호출
    • 서버는 클라이언트에 FIN 패킷 전송
  • 클라이언트는 FIN 패킷을 받음
    • 클라이언트의 OS에서 FIN에 대한 ACK 패킷 전달
  • 클라이언트는 output.write(1)를 통해 서버에 메시지 전달
    • 데이터를 전송하는 PUSH 패킷이 서버에 전달
  • 서버는 이미 FIN으로 종료를 요청했는데, PUSH 패킷으로 데이터가 전송되었음.. 서버가 기대하는 값은 FIN 패킷임
    • => 서버는 TCP 연결에 문제가 있다고 판단하고 즉각 연결을 종료하라는 RST 패킷을 클라이언트에 전송
  • 클라이언트에 RST 패킷 도착
    • => RST 패킷이 도착했다는 것은 현재 TCP 연결에 심각한 문제가 있으므로 해당 연결을 더는 사용하면 안된다는 의미
    • RST 패킷이 도착하면 자바는 read()로 메시지를 읽을 때 java.net.SocketException: Connection reset 예외를 던짐
    • RST 패킷이 도착하면 자바는 write()로 메시지를 전송할 때 java.net.SocketException: Broken pipe 예외를 던짐

정리

  • 상대방이 연결을 종료한 경우 데이터를 읽으면 EOF가 발생
    • -1, null , EOFException 등이 발생
    • 이 경우 연결을 끊어야 한다. (socket.close() 호출하기)
  • java.net.SocketException: Connection reset
    • RST 패킷을 받은 이후에 read() 호출
  • java.net.SocketException: Broken pipe
    • RST 패킷을 받은 이후에 write() 호출
  • java.net.SocketException: Socket is closed
    • 자신이 소켓을 닫은 이후에 read() , write() 호출
728x90
반응형

'Java' 카테고리의 다른 글

[Java] JDBC 정리  (0) 2025.04.01
[Java] 제네릭(Generics)  (0) 2025.01.07
[Java] 스레드 풀 & ExecutorService  (0) 2025.01.07
[Java] 동시성 컬렉션  (0) 2025.01.06
[Java] CAS - 동기화와 원자적 연산  (1) 2025.01.06
'Java' 카테고리의 다른 글
  • [Java] JDBC 정리
  • [Java] 제네릭(Generics)
  • [Java] 스레드 풀 & ExecutorService
  • [Java] 동시성 컬렉션
mxruhxn
mxruhxn
소소하게 개발 공부 기록하기
    반응형
    250x250
  • mxruhxn
    maruhxn
    mxruhxn
  • 전체
    오늘
    어제
    • 분류 전체보기 (150)
      • Java (21)
      • Spring (4)
      • Database (13)
      • Operating Syste.. (1)
      • Computer Archit.. (0)
      • Network (24)
      • Data Structure (6)
      • Algorithm (11)
      • Data Infra (7)
      • DevOps (12)
      • ETC (27)
      • Project (21)
      • Book (1)
      • Look Back (1)
  • 블로그 메뉴

    • 링크

      • Github
    • 공지사항

    • 인기 글

    • 태그

    • 최근 댓글

    • 최근 글

    • hELLO· Designed By정상우.v4.10.0
    mxruhxn
    [Java] 소켓 통신과 네트워크 예외
    상단으로

    티스토리툴바