JAVA

[JAVA] 자원 정리 (try-catch , try-with-resources) 2

경딩 2024. 11. 26. 16:39

네트워크 프로그램 4 - 자원 정리 1

 

자원정리를 네트워크 프로그램에 도입해서, 네트워크 프로그램이 안전하게 자원을 정리하도록 개발해 보다.

먼저 ` finally ` 를 사용해서 자원을 정리해 보고, 이후에 참 ` try-with-resources ` ` 를 도입해 보자.

try-with-resources  항상 사용할 수 있는 것은 아니고 finally에서 직접 자원을 정리하는 경우가 많이 있다.

 

 

우선 소켓과 스트림을 종료하기 위해 간단한 유틸리티 클래스를 생성하자

package thread.control.network.tcp;

import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.net.Socket;

import static java.rmi.server.LogStream.log;

public class SocketCloseUtil {

    public static void closeAll(Socket socket, InputStream input, OutputStream outputStream){

        close(input);
        close(outputStream);
        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 outputStream) {
        if(outputStream != null) {
            try {
                outputStream.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 ` 의 닫는 순서는 상관이 없다.

클라이언트 코드 먼저 자원을 잘 정리할 수 있도록 수정해 보자

package network.tcp.v4;

import thread.control.network.tcp.SocketCloseUtil;

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

import static thread.control.network.tcp.SocketCloseUtil.*;
import static util.MyLogger.log;

public class ClientV4 {

    private static final int PORT = 12345;

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

        // finaly 블록에서 변수에 접근해야 한다. 따라서 try 블록 안에서 선언할 수 없다.
        Socket socket = null;
        DataInputStream  dataInputStream = null;
        DataOutputStream dataOutputStream = null;
        try {
            socket = new Socket("localhost", PORT);
            dataInputStream = new DataInputStream(socket.getInputStream());
            dataOutputStream = new DataOutputStream(socket.getOutputStream());

            log("소켓 연결" + socket);

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


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

                if(toSend.equals("exit")){
                    break;
                }

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

            }

        } catch (IOException e) {
            log(e);
        } finally {
            closeAll(socket, dataInputStream, dataOutputStream);
            // 자원 정리
            log("연결 종료" + socket);
        }

    }
}

자원 정리 시 finally 코드 블록에서 SocketCloseUtil.closeAll()만 호출하면 된다.

 

이번에는 서버 코드를 수정하자

package network.tcp.v4;

import thread.control.network.tcp.SocketCloseUtil;

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

import static thread.control.network.tcp.SocketCloseUtil.*;
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() {
        // finaly 블록에서 변수에 접근해야 한다. 따라서 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);
        }
    }
}

자원 정리시 finally 코드 블럭에서 SocketCloseUtil.closeAll() 만 호출하면 된다.

 

ServerV4

package network.tcp.v4;


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

import static util.MyLogger.log;

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

        }


    }
}

 

실행결과 - 클라이언트 직접 종료 시 서버 로그

기존 코드의 문제는 클라이언트를 직접 종료하면 서버의 Session에 SocketException 이 발생하면서  자원을 제대로 정리하지 못했다.

변경한 코드에서는 서버에 접속한 클라이언트를 직접 종료해도 서버의 Session 이 연결종료하는 메시지를 남기면서 자원을 잘 정리한 것을 확인할 수 있다.

 

네트워크 프로그램 5 - 자원 정리 2

try-with-resources를 이용한 자원 정리

package network.tcp.v5;

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

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

public class ClientV5 {

    private static final int PORT = 12345;

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

        try (Socket socket = new Socket("localhost", PORT);
             DataInputStream dataInputStream = new DataInputStream(socket.getInputStream());
             DataOutputStream dataOutputStream = new DataOutputStream(socket.getOutputStream())) {

            log("소켓 연결" + socket);

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


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

                if(toSend.equals("exit")){
                    break;
                }

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

            }

        } catch (IOException e) {
            log(e);
        }

    }
}

 

클라이언트에 ` try-with-resources` 를 적용했다.

자원 정리 시 ` try-with-resources` 에 선언되는 순서의 반대로 자원 정리가 적용되기 때문에 여기서는  dataOutputStream , dataInputStream, socket 순으로 close 가 호출된다

참고로 dataOutputStream   , dataInputStream, socket  모두 AutoCloseable을 구현하고 있다.

 

package network.tcp.v5;

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

import static util.MyLogger.log;

public class SessionV5 implements Runnable{

    private final Socket socket;

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


    @Override
    public void run() {

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

        } catch (IOException e) {
            log(e);
        }

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

서버에 try--with-resources를 적용했다.

` Socket ` 객체의 경우  Session에서 직접 생성하는 것이 아니라 외부에서 받아오는 객체이다. 이 경우 try 선언부에 예제와 같이 객체의 참조를 넣어두면 자원정리 시점에 AutoCloseable 이 호출된다.

AutoCloseable 이 호출되어서 정말 소캣이 close() 메서드가 호출되었는지 확인하기 위해 마지막에 socket.isClosed()를 호출하는 코드를 넣어두었다.

package network.tcp.v5;

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

import static util.MyLogger.log;

public class SessionV5 implements Runnable{

    private final Socket socket;

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


    @Override
    public void run() {

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

        } catch (IOException e) {
            log(e);
        }

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

기존 코드와 같다. ` SessionV5 ` 를 사용하는 부분을 꼭! 확인하자

 

실행 결과 - 클라이언트 직접 종료 시 서버의 로그 

 

마지막에 있는 ` isClosed: true 로그를 통해 소캣의 close() 메서드가 try-with-resources를 통해 잘 호출된 것을 확인할 수 있다.

 

네트워크 프로그램 6 - 자원 정리 3

 

이번에는 서버 종료 시 서버 소켓과 연결된 모든 소켓 자원을 다 반납하고 서버를 안정적으로 종료하는 방법을 알아보자.

서버를 종료하려면 서버에 종료라는 신호를 전달해야 한다.

예를 들어서 서버도 콘솔창을 통해서 입력을 받도록 만들고. "종료"라는 메시지를 입력하면 모든 자원을 정리하면서 서버가 종료되도록 하면 된다.

하지만 보통 서버에서 콘솔 입력은 잘하지 않으므로, 이번에 느 서버를 직접 종료하면서 자원도 함께 정리하는 방법을 알아보겠다.

 

셧다운 훅(Shutdown Hook)

자바는 프로세스가 종료될 때, 자원정리나 로그 기록과 같은 종료작업을 마무리할 수 있는 셧다운 훅이라는 기능을 지원한다. 프로세스 종료는  크게 2가지로 분류할 수 있다.

정상 종료

  • 모든 non 데몬 스레드의 실행완료로 자바 프로세스 정상종료
  • 사용자가 Ctrl+C를 눌러서 프로그램을 중단
  • kill  명령 전달 ( kill -9 제외)
  • IntelliJ의 stop 버튼 강제 종료 

 

강제종료

  • 운영체제에 프로세스를 더 이상 유지할 수 없다고 판단할 때 사용
  • 리눅스/유닉스의 ` kill -9 ` 나 Windows의 ` taskkill /F `

정상 종류의 경우레는 셧다운 훅이 작동해서 프로세스 종류 전에 필요한 처리를 할 수 있다.

반면에 강제 종료의 경우에는 셧다운 훅이 작동하지 않는다.

 

 

셧다운 훅의 사용 방법을 코드를 통해서 알아보고, 서버 종료 시 자원도 함께 정리해 보자.

 

클라이언트 코드는 기존 코드와 같다.

package network.tcp.v6;

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

import static util.MyLogger.log;

public class ClientV6 {

    private static final int PORT = 12345;

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

        try (Socket socket = new Socket("localhost", PORT);
             DataInputStream dataInputStream = new DataInputStream(socket.getInputStream());
             DataOutputStream dataOutputStream = new DataOutputStream(socket.getOutputStream())) {

            log("소켓 연결" + socket);

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


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

                if(toSend.equals("exit")){
                    break;
                }

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

            }

        } catch (IOException e) {
            log(e);
        }

    }
}

 

서버는 세션을 관리하는 세션 매니저가 필요하다

 

package network.tcp.v6;

import java.util.ArrayList;
import java.util.List;

public class SessionManagerV6 {

    private List<SessionV6> sessions = new ArrayList<>();

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

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

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

 

각 세션은 소켓과 연결스트림을 가지고 있다. 따라서 서버를 종료할 때 사용하는 세션들도 종료해야 한다.

모든 세션을 찾아서 종료하려면 생성한 세션을 보과하고 관리할 객체가 필요하다.

 

 

SessionManager

add() : 클라이언트의 새로운 연결을 통해, 세션이 새로 만들어지는 경우 add()를 호출해 세션 매니저에 세션을 추가한다.

remove() : 클라이언트의 연결이 끊어지면 세션도 함께 정리된다. 이 경우 remove()를 호출해서 세션매니저에서 세션을 제거한다.

closeAll() : 서버를 종료할 때 사용하는 세션들도 모두 닫고, 정리한다.

 

package network.tcp.v6;

import thread.control.network.tcp.SocketCloseUtil;

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

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

public class SessionV6 implements Runnable{

    private final Socket socket;
    private final DataInputStream input;
    private final DataOutputStream output;

    private final SessionManagerV6 sessionManager;
    private boolean closed = false;


    public SessionV6(Socket socket, SessionManagerV6 sessionManager) throws IOException {
        this.socket = socket;
        input = new DataInputStream(socket.getInputStream());
        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;
        SocketCloseUtil.closeAll(socket, input, output);
        closed = true;
        log("연결 종료: " + socket);
    }
}

Session은 try-with-resoureces를 사용할 수 없다.

왜냐하면 서버를 종료하는 시점에도 Session의 자원을 정리해야 하기 때문이다.

 

try-with-resources는 사용과 해제를 함께 묶어서 처리할 때 사용한다.

try-with-resources는 try 선언부에서 사용한 자원을 try 가 끝나는 시점에 정리한다. 따라서 try에서 자원의 선언과 자원 정리를 묶어서 처리할 때 사용할 수 있다. 하지만 서버를 종료하는 시점에도 Session 이 사용하는 자원을 정리해야 한다.

서버를 종료하는 시점에 자원을 정리하는 것은 Session 안에 있는 try-with-resources를 통해 처리할 수 없다.

 

 

동시성 문제 

public synchronized void close() {...}
  • 자원을 정리하는 ` close() ` 메서드는 2곳에서 호출될 수 있다
    • 클라이언트와 연결이 종료되었을 때 (exit 또는 예외발생)
    • 서버를 종료할 때
  • 따라서 close()가 다른 스레드에서 동시에 중복 호출될 가능성이 있다.
  • 이런 문제를 막기 위해 synchronized 키워드를 사용했다. 그리고 자원 정리 코드가 중복호출되는 것을 막기 위해 closed 변수를 플래그로 사용했다.

 

네트워크 프로그램 6 - 자원 정리 4

package network.tcp.v6;


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

import static util.MyLogger.log;

public class ServerV6 {

    private static final int PORT = 12345;

    public static void main(String[] args) throws IOException {
        log("서버 시작");
        SessionManagerV6 sessionManager = new SessionManagerV6();
        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(); // main 블로킹
                log("소켓 연결" + socket);

                SessionV6 session = new SessionV6(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 SessionManagerV6 sessionManager;

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

        @Override
        public void run() {
            log("shutdown 호출");
            try {
                sessionManager.closeAll();
                serverSocket.close();

                Thread.sleep(1000); // 자원 정리 대기
            } catch (Exception e) {
                e.printStackTrace();
                System.out.println("e = " + e);
            }
        }
    }
}

 

셧다운 훅 등록

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

 

  • Runtime.getRuntime().addShutdownHook()을 사용하면 자바 종료시 호출되는 셧다운 훅을 등록할 수 있다.
  • 여기서 셧다운이 발생했을 때 처리할 작업과 스레드를 등록하면 된다.
        @Override
        public void run() {
            log("shutdown 호출");
            try {
                sessionManager.closeAll();
                serverSocket.close();

                Thread.sleep(1000); // 자원 정리 대기
            } catch (Exception e) {
                e.printStackTrace();
                System.out.println("e = " + e);
            }
        }

 

셧다운 훅이 실행될 때 모든 자원을 정리한다.

sessionManger.closeAll() : 모든 세션이 사용하는 자원 ( Socket  , InputStream , OutputStrea ) 을 정리한다.

serverSocket.close() : 서버 소켓을 닫는다.

 

자원 정리 대기 이유

Thread.sleep(1000); // 자원 정리 대기

보통 모든 non 데몬 스레드의 실행이 완료되면 자바 프로세스가 정상 종료된다. 하지만 다음과 같은 종료도 있다.

  • 사용자가 Ctrl+C를 눌러서 프로그램을 중단
  • ` kill ` 명령 전달 ( ` kill -9 ` 제외)
  •  IntelliJ의 stop 버튼

이런 경우에는 non 데몬 스레드의 종료 여부와 관계없이 자바 프로세스가 종료된다.

단 셧다운 훅의 실행이 끝날 때까지는 기다려준다.

셧다운 훅이 실행이 끝나면 non 데몬 스레드의 실행여부와 상관없이 자바 프로세스가 종료된다.

따라서 다른 스레드가 자원을 정리하거나 필요한 로그를 남길 수 있도록 셧다운 훅의 실행을 잠시 대기한다.

서버를 종료하면 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 예외를 받고 종료된다.

 

 

 

 

 

참고자료 : 김영한의 실전 자바 고급 2편 - 네트워크 프로그램 1

'JAVA' 카테고리의 다른 글

자바 정규식 Reqular Expression  (0) 2024.12.23
[JAVA] 리플렉션  (2) 2024.11.27
[JAVA] 자원 정리 (try-catch , try-with-resources)  (0) 2024.11.26
[JAVA] Comparable ,Comparator  (0) 2024.11.23
[JAVA] 싱글톤 구현 방식  (0) 2024.11.21