Programming/Java

자바 소켓 통신(Socket)을 사용하는 이유와 동작 원리 및 코드

Jan92 2022. 1. 25. 00:03

Java 소켓 통신(Socket)을 사용하는 이유

먼저 자바에서 소켓 통신은 C 또는 C++ 언어로 구현된 프로젝트와의 통신에 많이 사용됩니다. 

이유는 Java와 C의 데이터 개념이 다르기 때문인데요. C에서는 구조체를 사용하는데 반해서 Java에는 구조체가 없습니다.

이처럼 Java의 Object 구조를 C에서 이해하지 못하고 C의 구조체를 자바에서 이해하지 못하기 때문에 서로 통신을 위해서는 byte 단위로 정보를 주고받아야 합니다.

(Socket을 사용한 전문 통신)

 

 

 

Http 통신과 Socket 통신의 차이점

- 단방향 통신인 Http 통신

Http 통신은 Client의 요청(Request)이 있을 때만 서버가 응답(Response)하여 해당 정보를 전송하고 곧바로 연결을 종료하는 방식입니다. Client가 요청을 보내는 경우에만 Server가 응답하는 단방향 통신으로 반대로 Server가 Client에게 요청을 보낼 수는 없습니다.

 

- 양방향 통신인 Socket 통신

Server와 Client가 특정 Port를 통해 실시간으로 양방향 통신을 하는 방식입니다. Http 통신과는 다르게 Server와 Client가 특정 Port를 통해 연결되어 있어서 실시간으로 양뱡향 통신을 할 수 있습니다.

Streaming 중계나 실시간 채팅, 게임 등과 같이 즉각적으로 정보를 주고받는 경우에 사용됩니다.

 

 

 

Stream이란? (InputStream, OutputStream)

아래 Socket 통신 예제 코드에서 보게 될 Stream에 대해서 간단하게 이야기하고 넘어가겠습니다.

Stream은 프로그램 동작 중 외부에서 데이터를 읽거나 외부로 데이터를 출력하는 작업에 사용됩니다. 이때 데이터는 어떤 통로를 통해서 이동되는데 이 통로를 Stream이라고 합니다. 

자바에서는 외부에서 데이터를 읽는 역할을 수행하는 InputStream과 외부로 데이터를 출력하는 역할을 수행하는 OutputStream이 존재하며, 이 둘은 단일 방향으로 연속적으로 흘러갑니다.

(단방향이라는 특징 때문에 하나의 스트림으로 입출력을 동시에 할 수 없어서 InputStream과 OutputStream이 따로 존재합니다.)

 

 

 


 

소켓 통신 흐름

Socket 통신 흐름 살펴보기

소켓은 응용프로그램에서 TCP/IP를 이용하는 창구 역할을 하며, 두 프로그램이 네트워크를 통해 서로 통신을 수행할 수 있도록 양쪽에서 생성되는 링크의 단자입니다. 두 소켓이 연결되면 서로 다른 프로그램이 서로 데이터를 전달할 수 있게 됩니다.

 

이러한 Socket 통신은 일련의 규칙이 정해져 있는데요.

  1. 먼저 기다리는 측을 Server라고 하며, Server에서는 Port를 열고 Client의 접속을 기다립니다.
  2. 그리고 접속하는 측을 Client라고 하며, Server의 IP와 Port에 접속하여 통신이 연결됩니다.
  3. Server와 Client 간의 통신은 Send, Receive의 형태로 주고받습니다.
  4. 그리고 통신이 끝나면 close()로 접속을 끊습니다.

 

 

 

실제 코드를 통해 더 자세하게 알아보기

public class TcpServerExample {

    public static int tcpServerPort = 9999;

    public static void main(String[] args) {
        new TcpServerExample(tcpServerPort);
    }

    public TcpServerExample(int portNo) {
        tcpServerPort = portNo;
        try {
            // ServerSocket 생성
            ServerSocket serverSocket = new ServerSocket();
            serverSocket.bind(new InetSocketAddress(tcpServerPort));
            System.out.println("Starting tcp Server: " + tcpServerPort);
            System.out.println("[ Waiting ]\n");
            while (true) {
                // socket -> bind -> listen socket 클래스 내부에 구현되어 있음
                Socket socket = serverSocket.accept();
                System.out.println("Connected " + socket.getLocalPort() + " Port, From " + socket.getRemoteSocketAddress().toString() + "\n");
                // Thread
                Server tcpServer = new Server(socket);
                tcpServer.start();
            }
        } catch (IOException io) {
            io.getStackTrace();
        }
    }

    public class Server extends Thread {
        private Socket socket;

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

        public void run() {
            try {
                while (true) {
                    // Socket에서 가져온 출력스트림
                    OutputStream os = this.socket.getOutputStream();
                    DataOutputStream dos = new DataOutputStream(os);

                    // Socket에서 가져온 입력스트림
                    InputStream is = this.socket.getInputStream();
                    DataInputStream dis = new DataInputStream(is);

                    // read int
                    int recieveLength = dis.readInt();

                    // receive bytes
                    byte receiveByte[] = new byte[recieveLength];
                    dis.readFully(receiveByte, 0, recieveLength);
                    String receiveMessage = new String(receiveByte);
                    System.out.println("receiveMessage : " + receiveMessage);
                    System.out.println("[ Data Receive Success ]\n");

                    // send bytes
                    String sendMessage = "서버에서 보내는 데이터";
                    byte[] sendBytes = sendMessage.getBytes("UTF-8");
                    int sendLength = sendBytes.length;
                    dos.writeInt(sendLength);
                    dos.write(sendBytes, 0, sendLength);
                    dos.flush();

                    System.out.println("sendMessage : " + sendMessage);
                    System.out.println("[ Data Send Success ]");
                }
            } catch (EOFException e) {
                // readInt()를 호출했을 때 더 이상 읽을 내용이 없으면 EOFException이 발생한다.
            } catch (IOException e) {
                e.printStackTrace();
            } finally {
                try {
                    if (this.socket != null) {
                        System.out.print("\n[ Socket closed ] ");
                        System.out.println("Disconnected :" + this.socket.getInetAddress().getHostAddress() + ":"
                                + this.socket.getPort());
                        this.socket.close();
                    }
                } catch (Exception e) {
                }
            }
        }
    }
}

Server

데이터 송수신은 blocking 방식으로 동작합니다. 때문에 메인 스레드로만 구성하면 데이터를 올바르게 수신하는데 문제가 생길 수 있어 Multi-Thread 서버로 구현하였습니다.

* Thread 클래스는 start() 메서드 실행 시 run() 메서드가 수행되도록 내부적으로 동작합니다. 

 

 

Server 코드에서는 'ServerSocket'과 'Socket' 두 가지 소켓을 볼 수 있는데요.

서버 소켓은 말 그대로 서버 프로그램에서 사용하는 소켓으로 ServerSocket 객체를 생성하여 클라이언트가 연결해오는 것을 기다립니다.

클라이언트가 연결해 올 때마다 요청은 요청 큐(Request Queue)에 쌓이고, 각각의 클라이언트 연결에 accept() 함으로써 요청을 요청 큐에서 꺼내고 Socket 객체가 리턴됩니다.

이렇게 리턴되는 Socket을 활용하여 클라이언트와 데이터를 주고받으며, 예시와 같은 멀티스레드(Multi-Thread) 환경에서는 Socket을 생성한 스레드에 주어서 클라이언트와 데이터를 주고받습니다.

 

 

Socket socket = ServerSocket.accept();

ServerSocket에서 내부적으로 동작하는 코드를 살펴보면 accept()를 통해 Socket 객체를 가지고 오는 부분에서 내부적으로 bind -> listen 과정이 실행됩니다.

 

 

 

 

 

public class TcpClientExample {

    public static void main(String[] args) {

        Socket socket = null;

        try {
            // Server와 통신하기 위한 Socket
            socket = new Socket();
            System.out.println("\n[ Request ... ]");
            // Server 접속
            socket.connect(new InetSocketAddress("localhost", 9999));
            System.out.println("\n[ Success ... ]");

            byte[] bytes = null;
            String message = null;
            // Socket에서 가져온 출력스트림
            OutputStream os = socket.getOutputStream();
            DataOutputStream dos = new DataOutputStream(os);

            // send bytes
            message = "클라이언트에서 보내는 데이터";
            bytes = message.getBytes("UTF-8");

            dos.writeInt(bytes.length);
            dos.write(bytes, 0, bytes.length);
            dos.flush();

            System.out.println("\n[ Data Send Success ]\n" + message);

            // Socket에서 가져온 입력스트림
            InputStream is = socket.getInputStream();
            DataInputStream dis = new DataInputStream(is);

            // read int
            int receiveLength = dis.readInt();

            // receive bytes
            if (receiveLength > 0) {
                byte receiveByte[] = new byte[receiveLength];
                dis.readFully(receiveByte, 0, receiveLength);

                message = new String(receiveByte);
                System.out.println("\n[ Data Receive Success ]\n" + message);
            }

            // OutputStream, InputStream close
            os.close();
            is.close();

            // Socket 종료
            socket.close();
            System.out.println("\n[ Socket closed ]\n");

        } catch (Exception e) {
            e.printStackTrace();
        }

        if (!socket.isClosed()) {
            try {
                socket.close();
                System.out.println("\n[ Socket closed ]\n");
            } catch (IOException e) {
                e.printStackTrace();
            }
        }
    }
}

Client

Socket socket = new Socket("localhost", 포트번호);

Socket socket = new Socket(new InetSocketAddress("localhost", 포트번호));

 

Client에서 Socket을 사용하기 위한 두 가지 방법입니다. 연결하려는 외부 서버의 IP주소 대신 도메인 이름을 알고 있을 때 InetSocketAddress class를 사용할 수 있습니다.

 

 

 

InputStream read() method

데이터를 받기 위해 InputStream의 read() 메서드를 호출하면 상대방이 데이터를 보내기 전까지 블로킹이 됩니다.

read() 메서드가 블로킹 해제되고 리턴되는 경우는 다음과 같습니다.

  1. 상대방이 데이터를 보냈을 때 (리턴 값 => 읽은 바이트 수)
  2. 상대방이 정상적으로 Socket의 close() 메서드를 호출했을 때 (-1)
  3. 상대방이 비정상적으로 종료했을 때 (IOException 발생)

 

 

 


 

 

추가적으로 궁금했던 부분 InputStream, OutputStream close() 여부

자바에서 InputStream, OutputStream 같은 자원은 사용하고 나서 해제하지 않으면 메모리 누수 및 특정 프로그램의 독점으로 인해 해당 객체가 올바르게 작동하지 않을 수 있습니다. 때문에 close() 메소드를 통해 자원을 해제해야 하는데요.

 

예시 코드를 보면 Server 코드에서는 InputStream, OutputStream 객체를 close() 하는 부분이 없고, Client 코드에서는 close()하는 부분이 있습니다.

 

Socket Class close() method

사실 Client 코드에서 is, os를 close()하는 부분이 없더라도 동작하는데 아무런 문제가 없는데요. 이유는 Socket class의 close() 메소드를 통해 알 수 있습니다.

Closing this socket will also close the socket's InputStream and OutputStream.

 

(여기서 반대로 InputStream, OutputStream이 close() 되면 Socket도 close() 되기 때문에 이 점은 주의해야 합니다.)

 

 

 

***

InputStream, OutputStream, Socket은 Try-catch-final이 아닌 Try-with-resources 구문을 사용해서 자원을 쉽게 해제할 수 있지만 해당 예시에서는 try(...) 구문 안에서 자원을 선언 및 할당하지 않았기 때문에 final을 통해 처리하거나 직접 close() 처리를 하였습니다. 

 

 

 

< Socket을 사용한 전문 통신 참고 자료 >

 

전문통신이란? Java 전문통신(Fixed Length Format) 문자열 길이 맞추는 메서드

전문 통신이란, 먼저 'Fixed Length Format'은 전문을 구성하는 field들의 길이를 입력받을 수 있는 최대 사이즈로 고정시키는 방식입니다. 대규모의 프로젝트를 진행하다 보면 서로 다른 시스템끼리

wildeveloperetrain.tistory.com

 

 

< 참고 자료 >

 

소켓 프로그래밍. (Socket Programming)

1. 소켓(Socket) 만약 네트워크와 관련된 프로젝트를 진행하면서, 사용자(User)의 관점이 아닌, 개발자(Developer)의 관점에서 네트워크를 다뤄본 경험이 있다면, "소켓(Socket)"이라는 용어가 아주 낯설

recipes4dev.tistory.com

 

[JAVA 실습 #5] TCP 전문 통신 (fixed length) - 전문 헤더 읽기 readInt, writeInt 활용 (2)

자바(JAVA) 실습 - TCP 전문 통신 (fixed length) - readInt, writeInt 활용 (2) 환경 OpenJDK 1.8 FixedData 1. Client가 전송하는 전문의 형태를 지정 2. 배열로 각 전문의 키와 값을 지정 3. 기타 정보 저장 Re..

tlo-developer.tistory.com