Java 네트워크 프로그래밍 – 자원 정리 적용

이전 글에서 try-with-resources가 자원 정리 문제를 어떻게 해결하는지 확인했다. 이번에는 그 이론을 실제 네트워크 프로그램에 직접 적용한다. 먼저 finally로 자원을 직접 정리하는 방식(V4)을 적용하고, 이후 try-with-resources로 더 개선한다(V5). 두 방식 모두 다루는 이유는, 실무에서는 상황에 따라 try-with-resources를 적용할 수 없는 경우도 있기 때문이다

SocketCloseUtil — 자원 정리 유틸리티

소켓과 스트림을 닫는 코드는 여러 곳에서 반복된다. 공통 유틸리티로 분리하자

import static network.tcp.SocketCloseUtil.closeAll;

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 체크를 통해 객체 생성 도중 예외가 발생한 경우를 안전하게 처리한다. 각 자원을 개별 try-catch로 감싸기 때문에 하나를 닫다가 예외가 발생해도 나머지를 계속 닫는다. 자원 정리 중 발생한 예외를 로깅만 하고 흘려보낸다. 이 시점에 개발자가 직접 대응할 수 있는 방법이 없기 때문이다
  • 닫는 순서도 중요하다. Socket을 기반으로 InputStreamOutputStream을 만들기 때문에, 열 때의 역순인 스트림 먼저, 소켓 나중 순서로 닫아야 한다. InputStreamOutputStream 사이의 순서는 무관하다

V4 — finally로 자원 정리

ClientV4

package network.tcp.v4;

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

import static network.tcp.SocketCloseUtil.closeAll;
import static util.MyLogger.log;

public class ClientV4 {
    public static final int PORT = 12345;

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

        // finally 블록에서 접근해야 하므로 try 블록 밖에서 선언
        Socket socket = null;
        DataInputStream input = null;
        DataOutputStream output = null;

        try {
            socket = new Socket("localhost", PORT);
            input = new DataInputStream(socket.getInputStream());
            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);
        } finally {
            closeAll(socket, input, output);
            log("연결 종료: " + socket);
        }
    }
}

SessionV4

import static network.tcp.SocketCloseUtil.closeAll;
import static util.MyLogger.log;

public class SessionV4 implements Runnable {
    private final Socket socket;

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

    @Override
    public void run() {
        // finally 블록에서 접근해야 하므로 try 블록 밖에서 선언
        DataInputStream input = null;
        DataOutputStream output = null;

        try {
            input = new DataInputStream(socket.getInputStream());
            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);
            }
        } catch (IOException e) {
            log(e);
        } finally {
            closeAll(socket, input, output);
            log("연결 종료: " + socket);
        }
    }
}

ServerV4

public class ServerV4 {

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

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

실행 결과 – 클라이언트 강제 종료 시

15:57:51.442 [     main] 서버 시작
15:57:51.444 [     main] 서버 소켓 시작 - 리스닝 포트: 12345
15:57:52.913 [     main] 소켓 연결: Socket[addr=/127.0.0.1,port=51722,localport=12345]
15:57:54.784 [ Thread-0] java.io.EOFException
15:57:54.785 [ Thread-0] 연결 종료: Socket[addr=/127.0.0.1,port=51722,localport=12345]

V3에서는 클라이언트를 강제 종료하면 서버 세션에서 예외가 발생하며 자원 정리 로그없이 종료됐다. V4에서는 예외가 발생해도 finally 블록이 반드시 실행되므로 “연결 종료” 로그가 남고 자원이 정상적으로 반납된다

V5 — try-with-resources 적용

ClientV5

Socket, DataInputStream, DataOutputStream 모두 AutoCloseable을 구현하고 있어 try-with-resources를 바로 적용할 수 있다

public class ClientV5 {
    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);
        }
    }
}
  • 선언 역순으로 output → input → socket 순서로 자동 닫힌다

SessionV5

서버 세션의 경우 Socket은 외부 (ServerV5)에서 생성해서 넘겨받는다. 이처럼 직접 생성하지 않은 객체도 try 선언부에 참조만 넣으면 AutoCloseable이 호출된다

public class SessionV5 implements Runnable {
    private final Socket socket;

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

    @Override
    public void run() {
        try (socket; // 외부에서 받은 소켓도 참조만 넣으면 자동 close
             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);
            }
        } catch (IOException e) {
            log(e);
        }

        log("연결 종료: " + socket + " isClosed: " + socket.isClosed());
    }
}

ServerV5

public class ServerV5 {

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

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

실행 결과 – 클라이언트 강제 종료 시

16:13:26.171 [     main] 서버 시작
16:13:26.173 [     main] 서버 소켓 시작 - 리스닝 포트: 12345
16:13:27.334 [     main] 소켓 연결: Socket[addr=/127.0.0.1,port=53788,localport=12345]
16:13:29.256 [ Thread-0] java.io.EOFException
16:13:29.262 [ Thread-0] 연결 종료: Socket[addr=/127.0.0.1,port=53788,localport=12345] isClosed: true
마지막
  • 마지막 줄의 isClosed: true가 핵심이다. try-with-resources가 소켓의 close()를 정상적으로 호출했음을 확인할 수 있다

V4와 V5 비교

두 방식을 나란히 비교하면 try-with-resources의 장점이 명확하게 드러난다

항목V4(finally)V5(try-with-resources)
변수 선언try 밖에서 null 초기화 후 선언try 선언부에서 바로 할당
자원 정리 코드SocketCloseUtil 직접 호출자동
닫는 순서 보장개발자가 수동으로 처리선언 역순으로 처리
코드 복잡도상대적으로 복잡간결
외부 주입 객체 처리closeAll(socket, …)try (socket; …)

그럼에도 finally를 직접 사용해야 하는 경우가 있다. 자원의 생명주기가 여러 메서드에 걸쳐 있거나, AutoCloseable을 구현하지 않은 자원을 다뤄야 할 때다. 두 방식 모두 익혀두는 것이 중요하다

출처 – 김영한 님의 강의 중 김영한의 실전 자바 – 고급 2편, I/O, 네트워크, 리플렉션