인프런 - 실전 자바 로드맵/실전 자바 - 고급 2편, 입출력, 네트워크, 리플렉션

네트워크 - 프로그램, 네트워크 프로그램1, 네트워크 프로그램2, 네트워크 프로그램3, 자원 정리

소소한나구리 2025. 2. 25. 17:30
728x90

출처 : 인프런 - 김영한의 실전 자바 - 고급2편 (유료) / 김영한님  
유료 강의이므로 정리에 초점을 두고 코드는 일부만 인용


네트워크 프로그램1

UDP는 직접 사용할 일이 많지 않아 TCP/IP로 예제를 진행하므로 UDP의 네트워크 프로그램은 별도로 검색해 볼 것

예제

MyLogger

스레드 정보와 현재 시간을 출력하는 간단한 로깅 유틸리티 작성(멀티스레드 강의에서 사용했던 로깅 유틸리티와 동일함)

네트워크에서는 기본적으로 멀티스레드가 필요하기 때문에 스레드 정보를 확인하기 위해 네트워크 프로그램 강의에서는 이 로거를 통해 출력을 진행

package util;

public abstract class MyLogger {

    private static final DateTimeFormatter formatter = DateTimeFormatter.ofPattern("HH:mm:ss.SSS");

    public static void log(Object obj) {
        String time = LocalTime.now().format(formatter);
        System.out.printf("%s [%9s] %s\n", time, Thread.currentThread().getName(), obj);
    }
}

프로그램 설명

매우 간단한 네트워크 클라이언트, 서버 프로그램을 개발

클라이언트가 "Hello"라는 문자를 서버에 전달하면 서버는 클라이언트의 요청에 " World!"라는 단어를 더해서 "Hello World!"라는 단어를 클라이언트에 응답함

  • 클라이언트 -> 서버: "Hello"
  • 클라이언트 <- 서버: "Hello World!"

ClientV1

package network.tcp.v1;

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

    public static void main(String[] args) throws IOException {
        log("클라이언트 시작");
        Socket socket = new Socket("localhost", PORT);

        // 보조스트림인 DataXxxStream 사용하면 더 편리함
        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();
    }
}

ServerV1

package network.tcp.v1;

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();
    }
}

프로그램 실행

반드시 서버를 먼저 실행하고, 그다음에 클라이언트를 실행해야 함

서버를 시작하지 않고 클라이언트 먼저 실행하면 ConnectException 예외가 발생함

커넥션 및 소켓과 관련된 다양한 예외들이 있는데 뒤에서 한 번에 정리함

 

실행 결과 - 클라이언트

  • 클라이언트의 localport는 랜덤으로 생성되기 때문에 다른 숫자가 나올 수 있음, localport에 대해선 이후에 다룸
더보기

09:14:15.576 [     main] 클라이언트 시작
09:14:15.584 [     main] 소켓 연결: Socket[addr=localhost/127.0.0.1,port=12345,localport=60967]
09:14:15.585 [     main] client -> server: Hello
09:14:15.586 [     main] client <- server: Hello World!
09:14:15.586 [     main] 연결 종료: Socket[addr=localhost/127.0.0.1,port=12345,localport=60967]

 

실행 결과 - 서버

더보기

09:14:12.753 [     main] 서버 시작
09:14:12.756 [     main] 서버 소켓 시작 - 리스닝 포트: 12345
09:14:15.584 [     main] 소켓 연결: Socket[addr=/127.0.0.1,port=60967,localport=12345]
09:14:15.586 [     main] client -> server: Hello
09:14:15.586 [     main] client <- server: Hello World!
09:14:15.586 [     main] 연결 종료: Socket[addr=/127.0.0.1,port=60967,localport=12345]

 

localhost, 127.0.0.1

localhost는 현재 사용 중인 컴퓨터 자체를 가리키는 특별한 호스트 이름으로 127.0.0.1이라는 IP로 매핑됨

  • google.com, naver.com과 같은 호스트 이름이지만 자기 자신의 컴퓨터를 뜻하는 이름임

127.0.0.1은 IP 주소 체계에서 루프백 주소(loopback address)로 지정된 특별한 IP 주소인데, 이 주소는 컴퓨터가 스스로를 가리킬 때 사용되며 "localhost"와 동일하게 취급됨

127.0.0.1은 컴퓨터가 네트워크 인터페이스를 통해 외부로 나가지 않고 자신에게 직접 네트워크 패킷을 보낼 수 있도록 함

** 주의

  • 만약 localhost가 잘 되지 않는다면 개인 PC의 운영체제에 localhost가 127.0.0.1로 매핑되어있지 않은 문제이기 때문에 localhost 대신에 127.0.0.1을 직접 입력하면 됨
  • BindException
    • Exception in thread "main" java.net.BindException: Address already in use
    • 이런 예외가 발생한다면 이미 12345라는 포트를 다른 프로세스가 사용하고 있다는 뜻으로 포트를 다른 숫자로 변경해서 사용하면 됨
    • 직접 작성한 서버 프로그램을 아직 종료하지 않은 상태로 다시 실행하는 경우에도 12345 포트를 이미 점유하고 있으므로 같은 예외가 발생할 수 있음(대부분은 기존에 실행한 포트를 끄고 다시 실행하므로 문제가 없음)
    • 서버가 실행된 상태에서 인텔리제이를 종료할 때 아래 그림과 같은 창이 뜬다면 반드시 Terminate로 종료해야 12345 포트를 점유하고 있는 서버 프로세스가 종료됨
    • 만약 Disconnect로 종료하면 서버 프로세스가 그대로 살아있는 상태로 유지되기 때문에 다시 실행할 경우 12345 포트를 사용하지 못하므로 mac의 경우 터미널에서 lsof -i 포트번호로 찾아서 kill -9 PID 명령을 통해 강제 종료하거나 재부팅하면 됨

 

** 참고 - 인텔리제이 종료 시 서버 종료 설정

  • setting -> System Settings로 이동
  • Confirm before exiting the IDE를 체크하면 인텔리제이 종료 시 종료할 것인지를 묻는 확인 창이 뜸
  • When closing a tool window with a running process 옵션으로 인텔리제이 종료 시 서버 종료 유/무를 선택할 수 있음
    • Terminate process: 서버까지 모두 종료
    • Disconnect: 서버는 유지 인텔리제이만 종료
    • Ask: 어떻게 할지 묻는 창을 띄움 (**주의에서 설명한 이미지)

분석

InetAddressMain - DNS 탐색

TCP/IP 통신에서는 통신할 대상 서버를 찾을 때 호스트 이름이 아니라 IP주소가 필요하기 때문에 네트워크 프로그램을 분석하기 전에 호스트 이름으로 IP를 찾는 방법을 알아야 함

 

자바의 InetAddress 클래스를 사용하면 호스트 이름으로 대상 IP를 찾을 수 있음

  • InetAddress.getByName("호스트명") 메서드를 사용해서 해당하는 IP주소를 조회함
  • 이 과정에서 시스템의 호스트 파일을 먼저 확인함
    • /etc/hosts (리눅스, mac)
    • C:\Windows\System32\drivers\etc\hosts (Windows)
  • 호스트 파일에 정의되어 있지 않다면 DNS 서버에 요청해서 IP 주소를 얻음
  • 만약 호스트 파일에 localhost가 없다면 127.0.0.1 localhost를 추가하거나 IP를 직접 사용하면 됨
package network.tcp.v1;

public class InetAddressMain {
    public static void main(String[] args) throws UnknownHostException {
        InetAddress localhost = InetAddress.getByName("localhost");
        System.out.println("localhost = " + localhost);

        InetAddress google = InetAddress.getByName("google.com");
        System.out.println("google = " + google);
    }
}
/* 실행 결과
localhost = localhost/127.0.0.1
google = google.com/172.217.25.174
*/

클라이언트 코드 분석

클라이언트와 서버의 연결은 Socket을 사용함

private static final int PORT = 12345;

Socket socket = new Socket("localhost", PORT);  // 클라이언트 소켓
  • localhost를 통해 자신의 컴퓨터에 있는 12345 포트에 TCP 접속을 시도함
    • localhost는 IP가 아니므로 내부에서 InetAddress를 사용하여 해당 IP를 찾음
    • localhost는 127.0.0.1이라는 IP에 매핑되어 있으므로 127.0.0.1:12345에 TCP 접속을 시도함
  • 연결이 성공적으로 완료되면 Socket 객체를 반환함
  • Socket은 서버와 연결되어 있는 연결점이라고 생각하면 됨
  • Socket 객체를 통해서 서버와 통신할 수 있음

클라이언트와 서버 간의 데이터 통신은 Socket이 제공하는 스트림을 사용함

DataInputStream input = new DataInputStream(socket.getInputStream());// 외부에서 받을 때
DataOutputStream output = new DataOutputStream(socket.getOutputStream());// 외부에 보낼 때
  • Socket은 서버와 데이터를 주고받기 위한 스트림을 제공함
  • InputStream: 서버에서 전달한 데이터를 클라이언트가 받을 때 사용함
  • OutputStream: 클라이언트에서 서버에 데이터를 전달할 때 사용함
  • InputStream, OutputStream을 그대로 사용하면 모든 데이터를 byte로 변환해서 전달해야 하기 때문에 번거로우므로 DataInputStream, DataOutputStream이라는 보조 스트림을 사용하여 자바 타입의 메시지를 편리하게 주고받을 수 있도록 작성
String toSend = "Hello";
output.writeUTF(toSend);

String received = input.readUTF();
  • OutputStream을 통해 서버에 "Hello"메시지를 전송
  • InputStream을 통해 서버가 전달한 메시지를 받음

자원은 반드시 정리

input.close();
output.close();
socket.close();
  • 사용이 끝나면 사용한 자원은 반드시 반납해야 함
  • 지금은 간단하고 허술하게 자원을 정리했지만 뒤에서 자원 정리를 매우 자세히 다룸

서버 코드 분석

서버 소켓

private static final int PORT = 12345;

ServerSocket serverSocket = new ServerSocket(PORT); // 서버소켓

서버는 특정 포트를 열어두어야 클라이언트가 해당 포트를 지정해서 접속할 수 있음

서버는 서버 소켓(ServerSocket)이라는 특별한 소켓을 사용하며 지정한 포트를 사용해서 서버 소켓을 생성하면 클라이언트는 해당 포트로 서버에 연결할 수 있음

 

클라이언트 - 서버 연결 과정 그림

  • 서버가 12345 포트로 서버 소켓을 열어두면 클라이언트는 이제 12345 포트로 서버에 접속할 수 있음
  • 클라이언트가 12345 포트에 연결을 시도하는데 이때 OS 계층에서 TCP 3 way handshake가 발생하고 TCP 연결이 완료됨
  • TCP 연결이 완료되면 서버는 OS backlog queue라는 곳에 클라이언트와 서버의 TCP 연결 정보를 보관하며 이 연결 정보에는 클라이언트와 서버의 IP, PORT 정보가 모두 들어있음

클라이언트와 랜덤 포트

TCP 연결 시에는 클라이언트 서버 모드 IP, 포트 정보가 필요함

 

서버의 경우에는 포트가 명확하게 지정되어 있어야 클라이언트에서 서버에 어떤 포트에 접속해야 할지 알 수 있는 반면, 서버에 접속하는 클라이언트의 경우에는 자신의 포트가 명확하게 지정되어 있지 않아도 됨

클라이언트는 보통 포트를 생략하는데, 생략할 경우 클라이언트 PC에 남아 있는 포트 중 하나가 랜덤으로 할당됨

 

클라이언트의 포트도 명시적으로 할당할 수 있지만 잘 사용하지 않음

 

accept()

Socket socket = serverSocket.accept();  // 서버소켓에 연결
  • 서버 소켓은 단지 클라이언트와 서버의 TCP 연결만 지원하는 특별한 소켓임
  • 실제 클라이언트와 서버가 정보를 주고받으려면 Socket 객체가 필요하기 때문에 serverSocket.accept() 메서드를 호출하여 TCP 연결 정보를 기반으로 Socket객체를 만들어서 반환해야 함

accept() 호출 -> Socket 생성

  • accpt()를 호출하면 backlog queue에서 TCP 연결 정보를 조회함, 만약 TCP 연결 정보가 없으면 연결 정보가 생성될 때까지 대기함(블로킹)
  • 요청이 있으면 해당 정보를 기반으로 Socket 객체를 생성하고 사용한 TCP 연결 정보는 backlog queue에서 제거됨
  • 클라이언트와 서버의 Socket은 TCP로 연결되어 있으므로 스트림을 통해 메시지를 주고받을 수 있음
DataInputStream input = new DataInputStream(socket.getInputStream());       // 외부에서 받기
DataOutputStream output = new DataOutputStream(socket.getOutputStream());   // 외부에 보내기

String received = input.readUTF();

String toSend = received + " World!";
output.writeUTF(toSend);
  • InputStream: 서버 입장에서 보면 클라이언트가 전달한 데이터를 서버가 받을 때 사용함
  • OutputStream: 서버에서 클라이언트에 데이터를 전달할 때 사용함
  • 클라이언트가 전달한 메시지("Hello")를 전달받아서 " World!"메시지를 붙여서 반환하고 OutputStream을 통해 서버에서 클라이언트로 메시지를 전송함
input.close();
output.close();
socket.close();
serverSocket.close();
  • 마찬가지로 필요한 자원을 사용하고 나면 꼭 정리해야 함

문제

  • 이 프로그램은 메시지를 하나만 주고받으면 클라이언트와 서버가 모두 종료됨
  • 메시지를 계속 주고받고 원할 때 종료할 수 있도록 변경해야 제대로 된 클라이언트와 서버 프로그램이라고 할 수 있음

네트워크 프로그램2

예제

클라이언트와 서버가 메시지를 계속 주고받다가 "exit"라고 입력하면 클라이언트와 서버를 종료하도록 수정

 

ClientV2

  • Scanner를 생성하고 클라이언트와 서버가 메시지를 주고받는 부분만 while로 반복하면 됨
  • exit를 입력하면 클라이언트는 exit 메시지를 서버에 전송하고 클라이언트는 while문을 빠져나가면서 연결을 종료함
package network.tcp.v2;

public class ClientV2 {
        // 소켓 연결 코드 동일
        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);
        }

    // 이후 자원정리 코드 동일
    
    }
}

ServerV2

  • 마찬가지로 클라이언트와 서버가 메시지를 주고 받는 부분만 while로 반복하고 클라이언트에서 exit 메시지가 전송되면 while을 빠져나가서 연결을 종료함
package network.tcp.v2;

public class ServerV2 {
       
       // 소켓 연결 코드 동일
       
        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);
        }

    // 자원 정리 코드 동일
}

실행 결과 - 클라이언트

더보기

11:51:12.663 [     main] 클라이언트 시작
11:51:12.670 [     main] 소켓 연결: Socket[addr=localhost/127.0.0.1,port=12345,localport=63610]
전송 문자: Java
11:51:21.572 [     main] client -> server: Java
11:51:21.572 [     main] client <- server: Java World!
전송 문자: Spring
11:51:23.133 [     main] client -> server: Spring
11:51:23.134 [     main] client <- server: Spring World!
전송 문자: JPA
11:51:24.963 [     main] client -> server: JPA
11:51:24.964 [     main] client <- server: JPA World!
전송 문자: exit
11:51:25.968 [     main] client -> server: exit
11:51:25.969 [     main] 연결 종료: Socket[addr=localhost/127.0.0.1,port=12345,localport=63610]

 

실행 결과 - 서버

더보기

11:51:09.750 [     main] 서버 시작
11:51:09.753 [     main] 서버 소켓 시작 - 리스닝 포트: 12345
11:51:12.670 [     main] 소켓 연결: Socket[addr=/127.0.0.1,port=63610,localport=12345]
11:51:21.572 [     main] client -> server: Java
11:51:21.572 [     main] client <- server: Java World!
11:51:23.133 [     main] client -> server: Spring
11:51:23.134 [     main] client <- server: Spring World!
11:51:24.963 [     main] client -> server: JPA
11:51:24.964 [     main] client <- server: JPA World!
11:51:25.968 [     main] client -> server: exit
11:51:25.969 [     main] 연결 종료: Socket[addr=/127.0.0.1,port=63610,localport=12345]

문제

덕분에 클라이언트와 서버가 필요할 때까지 계속 메시지를 주고받을 수 있게 되었음

 

하지만 서버는 하나의 클라이언트가 아니라 여러 클라이언트의 연결을 처리할 수 있어야 하기 때문에 새로운 클라이언트로 접속을 시도하면

정상적으로 수행되지 않음

 

아래 이미지를 보면 ClientV2는 정상적으로 수행되지만 새로 만든 ClientV2-2는 소켓 연결 이후 메시지를 전송하면 서버로부터 문자가 오지 않아 계속 블로킹 상태로 대기하는 것을 확인할 수 있음

 

서버의 로그를 보면 ClientV2에서 전달한 정보만 있을 때문 ClientV2-2에서 전달한 정보는 서버가 받고 있지 않는 문제를 확인할 수 있음

 

** 참고 - IntelliJ에서 같은 클라이언트 동시 실행하기(같은 main 동시에 실행)

  • IntelliJ에서 같은 클래스의 main() 메서드를 다시 실행하면 기존에 실행하던 자바 프로세스를 종료해 버리고 다시 실행하게 되므로 아래 이미지를 따라 하면 클라이언트를 여러 개 실행할 수 있음
더보기

1. Run -> Edit Configurations... 혹은 인텔리제이 상단의 점 3개 클릭 후 Edit... 을 클릭


2. 복사할 Application을 선택 후 Copy Configuration을 선택하면 복사가 

3. 우측의 Name을 원하는 이름으로 변경

4. 기존 창에서 원하는 Application을 선택해서 바로 실행해도 되고, 설정을 나와서 IntelliJ에서 실행해도 됨

서버 소켓과 연결을 더 자세히 분석

그림 설명

여러 클라이언트가 서버에 접속한다고 가정하고 설명

  • 서버가 12345 서버 소켓을 열어두고 랜덤 포트를 사용하는 클라이언트가 먼저 12345 서버에 접속을 시도하고 OS 계층에서 TCP 연결이 완료되며 Os backlog queue에 TCP 연결 정보를 보관함
  • 여기서 중요한 점은 이 시점에 TCP 3 way handshake가 완료되었기 때문에, 클라이언트와 서버의 TCP 연결은 이미 완료되었고 클라이언트의 소켓 객체도 정상 생성된 것임
  • 그리고 이 시점에서는 아직 서버의 소켓 객체(서버 소켓이 아닌 서버에서 데이터를 주고받는 소켓 객체)는 생성되지 않았음
  • 랜덤 포트를 사용하는 다른 클라이언트가 12345 포트의 서버에 접속을 시도하고 연결을 완료하면 2개의 클라이언트가 모두 서버와 연결이 완료되었고 클라이언트의 소켓은 모두 정상 생성됨

  • 서버가 클라이언트와 데이터를 주고받으려면 소켓을 획득해야 하므로 ServerSocket의 accpt() 메서드를 호출하여 backlog 큐의 정보를 기반으로 소켓 객체를 하나 생성함
  • Queue 자료구조이므로 순서대로 데이터를 꺼내기 때문에 처음 요청한 클라이언트의 접속 정보를 기반으로 서버에 소켓이 하나 생성되어 해당 클라이언트와 서버는 소켓의 스트림을 통해 서로 데이터를 주고받을 수 있음
  • 두 번째 요청한 클라이언트도 이미 서버와 TCP 연결은 되어있으므로 서버에 두번째 클라이언트의 접속 정보를 기반으로 한 Socket객체가 없더라도 해당 클라이언트도 서버로 메시지를 보낼 수 있음

소켓을 통해 스트림으로 메시지를 주고받는 과정

소켓을 통해 스트림으로 메시지를 주고받는다는 것은 위의 그림의 과정처럼 자바 애플리케이션이 소캣 객체의 스트림을 통해 서버와 데이터를 주고 받는 것임

 

클라이언트가 서버에 메시지를 전송하는 경우 애플리케이선 -> OS TCP 송신 버퍼 -> 클라이언트 네트워크 카드를 거쳐서 서버에 도착하면 서버에서는 서버 네트워크 카드 -> OS TCP 수신 버퍼 -> 애플리케이션의 과정을 거치게 됨

 

여기서 두 번째 클라이언트가 보낸 메시지는 서버 애플리케이션에서 아직 읽지 않았기 때문에 서버 OS의 TCP 수신 버퍼에서 대기하게 됨

핵심적인 내용은 소켓 객체 없이 서버 소켓만으로도 TCP 연결은 완료되지만 연결 이후에 서로 메시지를 주고받으려면 소켓 객체가 필요하다는 것임

 

accept()는 이미 연결된 TCP 연결 정보를 기반으로 서버 측에 소켓 객체를 생성하며 이 소켓 객체가 있어야 스트림을 사용해서 메시지를 주고받을 수 있음

  • 그림처럼 소켓을 연결하면 소켓의 스트림을 통해 OS TCP 수신 버퍼에 있는 메시지를 읽을 수 있고 전송할 수 있게 됨
  • accept() 메서드는 backlog 큐에 새로운 연결 정보가 도착할 때까지 블로킹 상태로 대기하는 블로킹 메서드임

ServerV2의 문제

ServerV2에 둘 이상의 클라이언트가 작동하지 않는 이유

Socket socket = serverSocket.accept(); // 블로킹

while (true) {
    String received = input.readUTF(); // 블로킹

accept()를 호출해야 소켓 객체를 생성하고 클라이언트와 메시지를 주고받을 수 있음

그러나 새로운 클라이언트가 접속했을 때 서버의 main 스레드는 while문으로 기존 클라이언트와 메시지를 주고받는 부분만 반복하고 있기 때문에 accept() 메서드를 절대로 호출할 수 없음

 

여기에는 2개의 블로킹 작업이 있는데 해결을 위한 핵심은 별도의 스레드가 필요하다는 것임

 

클라이언트와 서버의 연결을 처리하기 위해 대기하는 accept() 메서드와 클라이언트의 메시지를 받아서 처리하기 위해 대기하는 readXxx() 메서드가 모두 블로킹 메서드임

 

각각의 블럭이 작업은 별도의 스레드에서 처리하지 않으면 다른 블로킹 메서드 때문에 계속 대기할 수밖에 없게 됨


네트워크 프로그램3

여러 클라이언트가 동시에 접속할 수 있는 서버프로그램 작성

구조

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

역할의 분리

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

ClientV3

  • ClientV2 코드와 완전히 동일하고 이름만 다름, v3 패키지에 위치

SessionV3

  • Session의 목적은 소켓이 연결된 클라이언트와 메시지를 반복해서 주고 받는 것임
  • 생성자를 통해 Socket 객체를 입력받고 Runnable을 구현해서 별도의 스레드에서 실행함
package network.tcp.v3;

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);
        }
    }
}

ServerV3

  • main 스레드는 서버 소켓을 생성하고 serverSocket.accept()을 호출해서 연결을 대기함
  • 새로운 연결이 추가될 때 마다 Session 객체를 생성하고 별도의 스레드에서 Session 객체를 실행하며 이 과정을 반복함
package network.tcp.v3;

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();
        }
    }
}

실행 결과 - ClientV3

더보기

14:17:41.227 [     main] 클라이언트 시작
14:17:41.234 [     main] 소켓 연결: Socket[addr=localhost/127.0.0.1,port=12345,localport=64335]
전송 문자: hello
14:17:48.481 [     main] client -> server: hello
14:17:48.482 [     main] client <- server: hello World!
전송 문자: hi!
14:18:14.249 [     main] client -> server: hi!
14:18:14.250 [     main] client <- server: hi! World!
전송 문자: exit
14:21:27.607 [     main] client -> server: exit
14:21:27.608 [     main] 연결 종료: Socket[addr=localhost/127.0.0.1,port=12345,localport=64335]

 

실행 결과 - ClientV3-2

더보기

14:17:59.408 [     main] 클라이언트 시작
14:17:59.414 [     main] 소켓 연결: Socket[addr=localhost/127.0.0.1,port=12345,localport=64341]
전송 문자: Java
14:18:03.224 [     main] client -> server: Java
14:18:03.224 [     main] client <- server: Java World!
전송 문자: Spring
14:18:19.606 [     main] client -> server: Spring
14:18:19.606 [     main] client <- server: Spring World!
전송 문자: one more
14:21:37.137 [     main] client -> server: one more
14:21:37.138 [     main] client <- server: one more World!
전송 문자: exit
14:21:38.922 [     main] client -> server: exit
14:21:38.922 [     main] 연결 종료: Socket[addr=localhost/127.0.0.1,port=12345,localport=64341]

 

실행 결과 - ServerV3

더보기

14:17:37.287 [     main] 서버 시작
14:17:37.292 [     main] 서버 소켓 시작 - 리스닝 포트: 12345
14:17:41.234 [     main] 소켓 연결: Socket[addr=/127.0.0.1,port=64335,localport=12345]
14:17:48.481 [ Thread-0] client -> server: hello
14:17:48.482 [ Thread-0] client <- server: hello World!
14:17:59.414 [     main] 소켓 연결: Socket[addr=/127.0.0.1,port=64341,localport=12345]
14:18:03.224 [ Thread-1] client -> server: Java
14:18:03.224 [ Thread-1] client <- server: Java World!
14:18:14.249 [ Thread-0] client -> server: hi!
14:18:14.250 [ Thread-0] client <- server: hi! World!
14:18:19.606 [ Thread-1] client -> server: Spring
14:18:19.606 [ Thread-1] client <- server: Spring World!
14:21:27.608 [ Thread-0] client -> server: exit
14:21:27.608 [ Thread-0] 연결 종료: Socket[addr=/127.0.0.1,port=64335,localport=12345]
14:21:37.137 [ Thread-1] client -> server: one more
14:21:37.137 [ Thread-1] client <- server: one more World!
14:21:38.922 [ Thread-1] client -> server: exit
14:21:38.923 [ Thread-1] 연결 종료: Socket[addr=/127.0.0.1,port=64341,localport=12345]

 

멀티스레드를 활용한 덕분에 여러 클라이언트가 서버에 접속해도 문제없이 동작하며 각각의 연결이 별도의 스레드에서 처리되는 것을 확인할 수 있음

블로킹이 되는 부분은 서버 소켓을 통해 소켓을 연결하는 부분과 각 클라이언트와 메시지를 주고받는 부분으로 나누어서 처리해야 함

 

문제

여기서 실행 중인 클라이언트를 exit를 입력해서 종료하는 것이 아니라 강제로 Stop 버튼을 눌러서 직접 종료 후 서버 로그를 확인해 보면 EOFException 예외가 발생함

/* 클라이언트를 직접 종료 후 서버 로그
14:27:15.454 [     main] 서버 시작
14:27:15.457 [     main] 서버 소켓 시작 - 리스닝 포트: 12345
14:27:19.031 [     main] 소켓 연결: Socket[addr=/127.0.0.1,port=64393,localport=12345]
Exception in thread "Thread-0" java.lang.RuntimeException: java.io.EOFException
	at network.tcp.v3.SessionV3.run(SessionV3.java:44)
	at java.base/java.lang.Thread.run(Thread.java:1583)
Caused by: java.io.EOFException
	at java.base/java.io.DataInputStream.readFully(DataInputStream.java:210)
	at java.base/java.io.DataInputStream.readUnsignedShort(DataInputStream.java:341)
	at java.base/java.io.DataInputStream.readUTF(DataInputStream.java:575)
	at java.base/java.io.DataInputStream.readUTF(DataInputStream.java:550)
	at network.tcp.v3.SessionV3.run(SessionV3.java:25)
	... 1 more
*/

클라이언트의 연결을 직접 종료하면 클라이언트 프로세스가 종료되면서 클라이언트와 서버의 TCP 연결도 함께 종료됨

이때 서버에서 readUTF()로 클라이언트가 메시지를 읽으려고 하면 EOFException이 발생하는데 소켓의 TCP 연결이 종료되었기 때문에 더는 읽을 수 있는 메시지가 없기 때문임

EOF(파일의 끝)가 여기서는 전송의 끝이라는 뜻임

 

여기서 심각한 문제가 하나 있는데, 이렇게 예외가 발생해 버리면 그대로 catch 문으로 이동하게 되면서 서버에서 자원 정리 코드를 호출하지 못한다는 점임

서버 로그를 보면 연결 종료 로그가 없는 것을 확인할 수 있음

 

자바 객체는 GC가 되지만 자바 외부의 자원은 자동으로 GC가 되는 것이 아니기 때문에 꼭! 정리를 해주어야 함

(TCP 연결의 경우 운영체제가 어느 정도 연결을 정리해 주지만 직접 연결을 종료할 때 보다 더 많은 시간이 걸릴 수 있음)

 

자원이 계속 쌓여서 가득 차게 되면 무조건 문제가 발생하게 됨

특히 클라이언트는 종료하고 다시 실행해도 되고 컴퓨터를 자주 재부팅하기도 하지만 서버의 경우에는 몇 달 몇 년 동안 계속 프로세스가 살아서 실행될 수 있기 때문에 사용한 자원을 사용한 후에는 즉각 정리해야 함

 

자원 정리는 서버 개발자에게 매우 중요한 내용이므로 아래 챕터에서 매우 자세히 다룸

 

** 참고

Mac에서는 EOFException이 발생했지만 윈도우에서는 SocketException 예외가 발생하는데 이는 각각의 OS가 남아있는 TCP 연결을 정리하는 방식이 다르기 때문임

 

클라이언트의 연결을 직접 종료하면 클라이언트 프로세스가 종료되면서 클라이언트와 서버의 TCP 연결도 함께 종료되며 이때 소켓을 정상적으로 닫지 않고 프로그램을 종료했으므로 OS가 남아있는 TCP 연결을 정리하려고 시도함

 

이때 MAC은 TCP 연결을 정상 종료하고 윈도우는 TCP 연결을 강제 종료하기 때문에 발생되는 예외가 다름

TCP 연결의 정상 종료와 강제 종료의 차이는 이후 네트워크 예외에서 설명함


자원 정리

자바 중급 1편에서 다룬 예외 처리의 실전 심화 내용

예제 코드

CallException, CloseException

  • 체크 예외 2개를 생성
package network.tcp.autocloseable;

public class CallException extends Exception {

    public CallException(String message) {
        super(message);

    }
}

package network.tcp.autocloseable;

public class CloseException extends Exception {

    public CloseException(String message) {
        super(message);

    }
}

ResourceV2

  • call(): 정상 로직 호출
  • callEx(): 비정상 로직 호출 CallException을 던짐
  • close(): 정상 종료
  • closeEx(): 비정상 종료, CloseException을 던짐
package network.tcp.autocloseable;

public class ResourceV1 {
    private String name;

    public ResourceV1(String name) {
        this.name = name;
    }

    public void call() {
        System.out.println(name + " call");
    }

    public void callEx() throws CallException {
        System.out.println(name + " callEx");
        throw new CallException(name + " ex");
    }

    public void close() {
        System.out.println(name + " close");
    }

    public void closeEx() throws CloseException {
        System.out.println(name + " closeEx");
        throw new CloseException(name + " ex");
    }
}

V1, Basic

ResourceCloseMainV1

  • 서로 관련된 자원은 나중에 생성한 자원을 먼저 정리해야 함
  • 이 예제는 먼저 생성한 resource1과 나중에 생성한 resource2가 서로 관련이 없기 때문에 생성과 종료 순서가 크게 상관이 없지만, resource2가 resource1의 정보로 생성된 상황에서는 resource2의 입장에서 resource1의 정보를 참고하고 있기 때문에 나중에 생성한 자원을 먼저 닫아주어야 함
  • 여기서도 resource1의 정보를 기반으로 resource를 생성한다고 가정하고 진행하여 resource2부터 닫았음
  • 실행 결과를 보면 callEx()를 호출하면서 예외가 발생하였으므로 자원 정리 코드가 정상 호출되지 않았음
  • 지금의 예제코드는 예외가 발생하면 자원이 정리되지 않는다는 문제가 있음
package network.tcp.autocloseable;

public class ResourceCloseMainV1 {
    public static void main(String[] args) {
        try {
            logic();
        } catch (CallException e) {
            System.out.println("CallException 예외 처리");
            e.printStackTrace();
        } catch (CloseException e) {
            System.out.println("CloseException 예외 처리");
            e.printStackTrace();
        }
    }

    private static void logic() throws CallException, CloseException {
        ResourceV1 resource1 = new ResourceV1("resource1");
        ResourceV1 resource2 = new ResourceV1("resource2");

        resource1.call();
        resource2.callEx(); // CallException
        
        System.out.println("자원 정리");    // 호출 안됨    
        
        resource2.closeEx();
        resource1.closeEx();
    }
}
/* 실행 결과
resource1 call
resource2 callEx
CallException 예외 처리
network.tcp.autocloseable.CallException: resource2 ex
	at network.tcp.autocloseable.ResourceV1.callEx(ResourceV1.java:16)
	at network.tcp.autocloseable.ResourceCloseMainV1.logic(ResourceCloseMainV1.java:21)
	at network.tcp.autocloseable.ResourceCloseMainV1.main(ResourceCloseMainV1.java:6)
*/

V2, Finally 사용

ResourceCloseMainV2

package network.tcp.autocloseable;

public class ResourceCloseMainV2 {
    public static void main(String[] args) {
        try {
            logic();
        } catch (CallException e) {
            System.out.println("CallException 예외 처리");
            e.printStackTrace();
        } catch (CloseException e) {
            System.out.println("CloseException 예외 처리");
            e.printStackTrace();
        }
    }

    private static void logic() throws CallException, CloseException {
        ResourceV1 resource1 = null;
        ResourceV1 resource2 = null;

        try {
            resource1 = new ResourceV1("resource1");
            resource2 = new ResourceV1("resource2");
            resource1.call();
            resource2.callEx(); // CallException

        } catch (CallException e) {
            System.out.println("ex: " + e);
            throw e;    // CallException을 다시 던짐
        } finally {
            if (resource2 != null) {
                resource2.closeEx();    // CloseException 발생
            }
            if (resource1 != null) {
                resource1.closeEx();    // 이 코드 호출 안됨, 자원 정리 안됨
            }
        }
    }
}

/* 실행 결과
resource1 call
resource2 callEx
ex: network.tcp.autocloseable.CallException: resource2 ex
resource2 closeEx
CloseException 예외 처리
network.tcp.autocloseable.CloseException: resource2 ex
	at network.tcp.autocloseable.ResourceV1.closeEx(ResourceV1.java:25)
	at network.tcp.autocloseable.ResourceCloseMainV2.logic(ResourceCloseMainV2.java:31)
	at network.tcp.autocloseable.ResourceCloseMainV2.main(ResourceCloseMainV2.java:6)
*/

null 체크

V1의 문제를 해결하기 위해 finally 코드 블록을 사용해서 자원을 닫는 코드가 항상 호출되도록 수정

만약 resource2 객체를 생성하기 전에 예외가 발생하면 resource2는 null이 되기 때문에 null 체크를 해야 함

resource1의 경우에도 resource1을 생성하는 중에 예외가 발생할 수 있으므로 null 체크가 필요함

 

자원 정리 중에 예외가 발생하는 문제

finally 코드 블록은 항상 호출되기 때문에 자원이 잘 정리될 것 같지만 자원을 정리하는 중에 finally 코드 블록 안에서 resource2.closeEx()를 호출하면서 CloseException 예외가 발생함

결과적으로 resource1.closeEx()는 호출되지 않고, resource1의 자원은 정리되지 않음

 

핵심 예외가 바뀌는 문제

여기에서 발생한 핵심적인 예외는 CallException임 

그런데 finally 코드 블록에서 자원을 정리하면서 CloseException 예외가 추가로 발생하여 CallException 예외 때문에 자원을 정리하고 있는데 자원 정리중에 또 예외가 발생해 버림

 

이 경우 logic()을 호출한 쪽에서는 핵심 예외인 CallException이 아니라 finally블록에서 새로 생성된 CloseException을 받게 되어 핵심 예외인 CallException에 대한 정보가 사라져 버림

 

개발자가 원하는 예외는 핵심 예외임, 이 핵심 예외를 확인해야 제대로 된 문제를 찾을 수 있음

자원을 닫는 중에 발생한 예외는 부가 예외일 뿐임

 

정리하면 이 코드에는 close() 시점에 예외가 발생하면 이후 다른 자원을 닫을 수 없는 문제와 finally 블럭 안에서 자원을 닫을 때 예외가 발생하면 핵심 예외가 finally에서 발생한 부가 예외로 바뀌어 버리고 핵심 예외가 사라지는 문제가 있음

V3, Finally 안에서 try - catch 사용

ResourceCloseMainV3

  • finally블럭에 각 자원을 닫는 코드마다 try - catch문을 적용하여 자원을 닫는 중에 예외가 발생하여도 예외를 처리하도록 코드를 작성하여 문제를 해결
  • 실행해 보면 V2 버전에서 발생했던 close() 실점에 예외가 발생하면 다른 자원을 닫을 수 없는 문제와 finally 블럭 안에서 자원을 닫을 때 예외가 발생하면 핵심 예외가 부가 예외로 바뀌는 문제를 해결함
  • 그러나 문제들은 해결되었으나 코드가 너무 지저분해지는 문제와 더불어서 아래 4가지의 문제는 여전히 발생할 가능성이 있음
    • resource 변수를 선언하면서 동시에 할당할 수 없음(try, finally 코드 블록과 변수 스코프가 다른 문제)
    • catch 이후에 finally 호출을 하기 때문에 자원 정리가 조금 늦어짐
    • 개발자가 실수로 close()를 호출하지 않을 가능성이 있음
    • 개발자가 자원을 닫는 순서를 실수할 가능성이 있음(자원을 생성한 순서와 반대로 닫아야 함)
package network.tcp.autocloseable;

public class ResourceCloseMainV3 {
    public static void main(String[] args) {
    
    // 기존 코드 동일 생략
    
    }
    
    private static void logic() throws CallException, CloseException {
        // 기존 코드 동일 생략

        } finally {
            if (resource2 != null) {
                try {
                    resource2.closeEx();    // CloseException 발생
                } catch (CloseException e) {
                    // close()에서 발생한 예외는 로깅을 출력하고 버림
                    System.out.println("close ex: " + e);
                }
            }
            if (resource1 != null) {
                try {
                    resource1.closeEx();    // 이 코드 호출 안됨, 자원 정리 안됨
                } catch (CloseException e) {
                    System.out.println("close ex: " + e);
                }
            }
        }
    }
}
/* 실행 결과
resource1 call
resource2 callEx
ex: network.tcp.autocloseable.CallException: resource2 ex
resource2 closeEx
close ex: network.tcp.autocloseable.CloseException: resource2 ex
resource1 closeEx
close ex: network.tcp.autocloseable.CloseException: resource1 ex
CallException 예외 처리
network.tcp.autocloseable.CallException: resource2 ex
	at network.tcp.autocloseable.ResourceV1.callEx(ResourceV1.java:16)
	at network.tcp.autocloseable.ResourceCloseMainV3.logic(ResourceCloseMainV3.java:24)
	at network.tcp.autocloseable.ResourceCloseMainV3.main(ResourceCloseMainV3.java:6)
*/

V4, try - with - resources 적용

지금까지 수많은 자바 개발자들이 자원 정리 때문에 고통받아왔는데, V3에서 나열한 문제를 한 번에 해결하는 것이 바로 자바 중급 1편에서 학습한 try-with-resources 구문임

 

try-with-resources의 관한 기본적인 내용은 https://nagul2.tistory.com/412 내용 참고

 

ResourceV2

  • try-with-resources를 사용하기 위해 AutoCloseable을 구현
  • close() 메서드를 호출하면 항상 CloseException을 던지도록 수정
package network.tcp.autocloseable;

public class ResourceV2 implements AutoCloseable {
    private String name;

    public ResourceV2(String name) {
        this.name = name;
    }

    public void call() {
        System.out.println(name + " call");
    }

    public void callEx() throws CallException {
        System.out.println(name + " callEx");
        throw new CallException(name + " ex");
    }
    
    @Override
    public void close() throws CloseException {
        System.out.println(name + " close");
        throw new CloseException(name + " ex");
    }
}

ResourceCloseMainV4

  • try-with-resources를 사용하여 logic() 메서드의 코드가 매우 간결하게 예외를 처리할 수 있음
  • e.getSuppressed() 관련 코드는 없어도 동작하지만, 내부 예외를 꺼내보기 위해 추가된 코드임
  • 실행 결과를 보면 핵심예외인 CallException뿐만 아니라 부가 예외인 CloseException도 같이 출력되는 것을 확인할 수 있음
package network.tcp.autocloseable;

public class ResourceCloseMainV4 {
    public static void main(String[] args) {
        try {
            logic();
        } catch (CallException e) {
            System.out.println("CallException 예외 처리");
            Throwable[] suppressed = e.getSuppressed();
            for (Throwable throwable : suppressed) {
                System.out.println("suppressedEx = " + throwable);
            }
            e.printStackTrace();
        } catch (CloseException e) {
            System.out.println("CloseException 예외 처리");
            e.printStackTrace();
        }
    }

    private static void logic() throws CallException, CloseException {
        try (ResourceV2 resource1 = new ResourceV2("resource1");
             ResourceV2 resource2 = new ResourceV2("resource2")) {

            resource1.call();
            resource2.callEx();
        } catch (CallException e) {
            System.out.println("ex: " + e);
            throw e;
        }
    }
}
/* 실행 결과
resource1 call
resource2 callEx
resource2 close
resource1 close
ex: network.tcp.autocloseable.CallException: resource2 ex
CallException 예외 처리
suppressedEx = network.tcp.autocloseable.CloseException: resource2 ex
suppressedEx = network.tcp.autocloseable.CloseException: resource1 ex
network.tcp.autocloseable.CallException: resource2 ex
	at network.tcp.autocloseable.ResourceV2.callEx(ResourceV2.java:16)
	at network.tcp.autocloseable.ResourceCloseMainV4.logic(ResourceCloseMainV4.java:25)
	at network.tcp.autocloseable.ResourceCloseMainV4.main(ResourceCloseMainV4.java:6)
	Suppressed: network.tcp.autocloseable.CloseException: resource2 ex
		at network.tcp.autocloseable.ResourceV2.close(ResourceV2.java:22)
		at network.tcp.autocloseable.ResourceCloseMainV4.logic(ResourceCloseMainV4.java:21)
		... 1 more
	Suppressed: network.tcp.autocloseable.CloseException: resource1 ex
		at network.tcp.autocloseable.ResourceV2.close(ResourceV2.java:22)
		at network.tcp.autocloseable.ResourceCloseMainV4.logic(ResourceCloseMainV4.java:21)
		... 1 more
*/

try- with-resources는 단순하게 close()를 자동 호출해 준다는 정도의 기능만 제공하는 것이 아니라 지금까지 자원 해제를 위해 고민했던 6가지 문제를 모두 해결하는 장치임

 

2가지 핵심 문제

  • close() 시점에 예외를 던지면 이후 다른 자원을 닫을 수 없음
  • finally 블럭 안에서 자원을 닫을 때 예외가 발생하면 핵심 예외가 finally에서 발생한 부가 예외로 바뀌고 핵심 예외가 사라짐

4가지 부가 문제

  • resource 변수를 선언하면서 동시에 할당할 수 없음(try, finally 코드 블록과 변수 스코프가 다른 문제)
  • catch 이후에 finally 호출을 하기 때문에 자원 정리가 조금 늦어짐
  • 개발자가 실수로 close()를 호출하지 않을 가능성이 있음
  • 개발자가 자원을 닫는 순서를 실수할 가능성이 있음(자원을 생성한 순서와 반대로 닫아야 함)

Try with resources 장점

  • 리소스 누수 방지: 모든 리소스가 제대로 닫히도록 보장함, 실수로 finally 블럭을 적지 않거나 finally 블럭안에서 자원 해제 코드를 누락하는 문제를 예방함
  • 코드 간결성 및 가독성 향상: 명시적인 close() 호출이 필요 없기 때문에 코드가 간결해지고 읽기 쉬워짐
  • 스코프 범위 한정: 리소스로 사용되는 resource1, resource2 변수의 스코프가 try 블럭안으로 한정되므로 코드 유지보수가 더 쉬워짐
  • 조금 더 빠른 자원 해제: 기존에는 try -> catch -> finally의 과정으로 catch 이후에 자원을 반납했지만 Try with resource는 try 블럭이 끝나는 즉시 close()를 호출함
  • 자원 정리 순서: 먼저 선언한 자원을 나중에 정리함
  • 부가 예외 포함: 아래에서 설명

Try with resources 예외 처리와 부가 예외 포함

  • try-with-resources를 사용하는 중에 핵심 로직 예외와 자원을 정리하는 중에 발생하는 부가 예외가 모두 발생하면 핵심 예외에 부가 예외를 반환하면서 동시에 부가 예외도 필요하면 확인할 수 있음
    • try-with-resources는 핵심 예외를 반환하고, 부가 예외는 핵심 예외 안에 Suppressed로 담아서 반환함
    • 개발자는 자원 정리 중에 발생한 부가 예외를 e.getSuppressed()를 통해 활용할 수 있음
  • 자바 예외는 e.addSuppressed(ex)라는 메서드가 있어서 예외 안에 참고할 예외를 담아 둘 수 있는데, 이 기능도 try-with-resources와 함께 등장하였음
728x90