A Guide to Java Sockets

급식·2023년 10월 26일
post-thumbnail

음성 분석처럼 오래 걸리는 작업이 완료되었을 때 클라이언트에 작업이 완료되었음을 알려주는 기능을 구현해야 하는 상황이 있었다. 숏 폴링으로 구현할 수도 있겠지만,, 네트워크 오버헤드나 불필요한 반복 질의에서 오는 비효율성을 개선할 여러 방법 중 가장 간단한 방법으로 소켓 통신이 있다.

단순히 연결을 끊지 않고 계속 유지하면서 통신할 수 있는 방법 정도로만 알고 있는 것 같아서, 작동 원리를 자바로 구현해보며 익혀보려고 한다.

코드 및 내용 전문은 Baeldung의 "A guide to Java Sockets"을 번역해서 올린 것이므로, 가능하면 원본을 보면 좋을 것 같다.


1. Overview

소켓 프로그래밍 은 기기들이 네트워크로 서로 연결된 다수의 컴퓨터들 간에 실행되는 프로그램을 작성한다는 의미이다.

소켓 프로그래밍에 사용할 수 있는 프로토콜로는 User Datagram Protocol(UDP)Transfer Control Protocol(TCP)가 있다.

두 프로토콜의 가장 큰 차이는 UDP는 클라이언트와 서버 사이에 세션이 없는 비연결성인데 반해 TCP는 통신을 위해 클라이언트와 서버 사이에 상호 배타적인 연결이 수립되어야 한다는 점이다.

이번 튜토리얼은 TCP/IP를 활용한 소켓 프로그래밍 입문을 주제로, Java를 사용해 클라이언트/서버 애플리케이션을 구현해볼 것이다.


2. 프로젝트 준비

Java는 저수준 통신을 구현하는 여러 클래스와 인터페이를 제공한다.

이들은 대부분 java.net 패키지에 포함되어 있으므로, 다음과 같이 import 하면 된다.

import java.net.*;

또한 통신 중에 입출력 스트림에 읽고/쓰기 위해 다음과 같이 java.io 패키지를 import 해야 한다.

import java.io.*;

요점만 간단히 설명하기 위해, 같은 컴퓨터에서 실행되는 클라이언트-서버 프로그램을 작성할 것이다. 만약 다른 네트워크에 있는 컴퓨터끼리 통신시키려면, IP 주소를 바꾸면 된다. 이번 튜토리얼에서는 127.0.0.1, 즉 localhost를 사용한다.


3. 간단한 예제

가장 간단한 클라이언트-서버 예제부터 시작해보자. 클라이언트가 서버에 인사하고, 서버가 이에 대답하는 양방향 통신 애플리케이션을 만들 것이다.

다음 GreetServer.java 코드로 서버 애플리케이션을 만들어보자. 튜토리얼의 다른 부분에서는 아래 부분을 생략하겠다. (저는 편의상 다 넣겠습니다! 일부 원본과 다를 수 있습니다.)

package server;

import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStreamReader;
import java.io.PrintWriter;
import java.net.ServerSocket;
import java.net.Socket;

public class GreetServer {
    private ServerSocket serverSocket;
    private Socket clientSocket;
    private PrintWriter out;
    private BufferedReader in;

    public void start(int port) throws IOException {
        serverSocket = new ServerSocket(port);
        clientSocket = serverSocket.accept();
        out = new PrintWriter(clientSocket.getOutputStream(), true);
        in = new BufferedReader(new InputStreamReader(clientSocket.getInputStream()));

        String greeting = in.readLine();
        if (greeting.equals("hello server")) {
            out.println("hello client");
        } else {
            out.println("unrecognised greeting");
        }
    }

    public void stop() throws IOException {
        in.close();
        out.close();
        clientSocket.close();
        serverSocket.close();
    }

    public static void main(String[] args) throws IOException {
        GreetServer server = new GreetServer();
        server.start(6666);
    }
}

클라이언트 GreetClient.java는 다음과 같이 작성한다.

package client;

import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStreamReader;
import java.io.PrintWriter;
import java.net.Socket;

public class GreetClient {
    private Socket clientSocket;
    private PrintWriter out;
    private BufferedReader in;

    public void startConnection(String ip, int port) throws IOException {
        clientSocket = new Socket(ip, port);
        out = new PrintWriter(clientSocket.getOutputStream(), true);
        in = new BufferedReader(new InputStreamReader(clientSocket.getInputStream()));
    }

    public String sendMessage(String msg) throws IOException {
        out.println(msg);
        String resp = in.readLine();
        return resp;
    }

    public void stopConnection() throws IOException {
        in.close();
        out.close();
        clientSocket.close();
    }
}

이제 서버를 시작해보자. IDE에서 아래와 같이 자바 애플리케이션을 실행하면 된다.

그다음 단위 테스트를 사용해 서버에 인사 메시지를 보내고, 아래와 같이 응답을 받을 것이다.

package test;

import client.GreetClient;
import org.junit.Test;
import org.junit.Assert;

import java.io.IOException;

public class GreetTest {
    @Test
    public void given_클라이언트_인사_when_통신_시작_후_서버_응답_then_확인() throws IOException {
        GreetClient client = new GreetClient();
        client.startConnection("127.0.0.1", 6666);

        String response = client.sendMessage("hello server");
        Assert.assertEquals(response, "hello client");
    }
}

위 예제를 통해 튜토리얼의 뒷부분에 나올 내용을 짐작할 수 있다. 즉, 아직은 위에서 어떤 일이 일어나고 있는지 완전히 이해하지 못해도 좋다.

다음 섹션에서는 이 간단한 예제를 사용하여 소켓 통신을 분석하고, 더 복잡한 통신에 대해서도 자세히 살펴보겠다.


4. 소켓 작동 원리

위의 예제는 이번 섹션의 각 부분을 단계별로 살펴보는데 사용된다.

소켓 은 네트워크 상의 다른 컴퓨터에서 실행되고 있는 두 개의 프로그램을 연결하는 하나의 양방향 통신 링크를 의미한다. 소켓은 포트 번호에 묶이기 때문에 전송 계층은 애플리케이션으 오고 가는 데이터의 출발지와 도착지를 알 수 있다.

4.1. 서버

보통 서버는 네트워크의 특정 컴퓨터에서 실행되며 특정 포트에 묶여 있는 포트를 가지고 있다. 우리의 경우 서버와 클라이언트가 컴퓨터에서 실행되고 있으며, 6666번 포트로 통신한다.

ServerSocket serverSocket = new ServerSocket(6666);

서버는 클라이언트가 연결을 요청할 때까지 소켓을 바라보고 있는다. 이를 listening이라 하며, listening의 다음 단계는 다음과 같다.

Socket clientSocket = serverSocket.accept();

서버 코드가 accept 메서드를 만나면, 클라이언트가 연결 요청을 보낼 때까지 실행 흐름을 막는다(block).

문제 없이 모든게 잘 실행된다면, 서버는 연결을 accept 한다. Accept 한 다음, 서버는 동일한 로컬 포트 6666에 바인딩된 clientSocket이라는 이름의 새 소켓을 받으며 여기에 원격 통신을 위한 클라이언트의 주소와 포트가 설정되어 있다.

여기서 새 Socket 객체는 서버를 클라이언트와 직접 연결한다. 그 다음 입출력 스트림에 접근하여 각각 클라이언트와 메시지를 주고 받을 수 있게 된다.

PrintWriter out = new PrintWriter(clientSocket.getOutputStream(), true);
BufferedReader in = new BufferedReader(new InputStreamReader(clientSocket.getInputStream()));

이제 서버는 소켓이 스트림과 함께 닫힐 때까지 클라이언트와 계속해서 메시지를 교환할 수 있다.

그러나 위의 예제에서 서버는 연결을 종료할 때까지 인사 응답을 단 한 번만 보낼 수 있다. 이는 테스트를 다시 실행했을 때 서버가 연결을 거부하게 될것임을 의미한다.

계속해서 통신하려면 입력 스트림을 while 루프 안에서 읽어 클라이언트가 연결 종료 요청을 보낼 때까지 실행해야 한다. 이 작업은 다음 섹션에서 해볼 것이다.

매 새로운 클라이언트마다 서버는 accept 메서드 호출에 의해 반환되는 새 소켓을 필요로 한다. 튜토리얼에서는 serverSocket 을 사용하여 연결된 클라이언트의 요청을 처리하며 연결 요청을 계속 수신한다. 첫 번째 예제에서는 아직 이 기능을 허용하지는 않았다.

4.2. 클라이언트

클라이언트는 서버가 실행되고 있는 기기의 호스트 이름이나 IP, 그리고 서버가 리스닝 중인 포트의 번호를 알고 있어야 한다.

연결을 요청하기 위해 클라이언트는 서버의 컴퓨터 및 포트에서 서버와의 접촉을 시도한다.

Socket clientSocket = new Socket("127.0.0.1", 6666);

또한 클라이언트는 서버에게 스스로의 정보를 밝혀야 하므로, 시스템에 의해 할당된 로컬 포트 번호를 바인드하여 연결이 지속되는 동안 사용한다. 즉, 클라이언트의 포트 번호는 우리가 직접 다루지 않는다.

위의 생성자는 서버가 연결을 허용했을 때만 새 소켓을 생성한다(연결 거부 예외가 발생할 수도 있다). 연결이 성공적으로 수립되면, 서버와 통신하는 이 소켓으로부터 입출력 스트림을 얻을 수 있다.

PrintWriter out = new PrintWriter(clientSocket.getOutputStream(), true);
BufferedReader in = new BufferedReader(new InputStreamReader(clientSocket.getInputStream()));

클라이언트의 입력 스트림은 서버의 출력 스트림에 연결되고, 반대로 서버의 입력 스트림은 클라이언트의 출력 스트림에 연결됨을 알 수 있다.


5. 계속해서 통신하기

현재 서버는 클라이언트가 연결될 때까지 실행 흐름을 막고 있으며, 이후 클라이언트의 메시지를 수신하기 위해 다시 흐름을 막는다. 이후 메시지 하나가 오고 간 다음, 이를 계속해서 처리하도록 작성하지 않았기 때문에 연결을 닫아버린다. 따라서 이는 ping 요청을 보내는 정도에만 유용하다.

하지만 만약 채팅 서버를 만들어야 한다면, 서버와 클라이언트가 번갈아가며 통신하는 기능이 반드시 필요할 것이다.

수신되는 메시지에 대한 서버의 입력 스트림을 지속적으로 받기 위해, while 루프가 필요하다.

이를 위해 클라이언트로부터 수신한 메시지를 다시 되돌려주는 EchoServer.java라는 이름의 새 서버를 만들 것이다.

package server;

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

public class EchoServer {
    private ServerSocket serverSocket;
    private Socket clientSocket;
    private PrintWriter out;
    private BufferedReader in;

    public void start(int port) throws IOException {
        serverSocket = new ServerSocket(port);
        clientSocket = serverSocket.accept();
        out = new PrintWriter(clientSocket.getOutputStream(), true);
        in = new BufferedReader(new java.io.InputStreamReader(clientSocket.getInputStream()));

        String inputLine;
        while ((inputLine = in.readLine()) != null) {
            if (inputLine.equals(".")) {
                out.println("good bye");
                break;
            }
            out.println(inputLine);
        }
    }

    public static void main(String[] args) throws IOException {
        EchoServer server = new EchoServer();
        server.start(4444);
    }
}

. 문자를 받으면 루프를 종료하는 종료 조건이 추가되었다.

이제 GreetServer에서 그랬던 것처럼 메인 메서드를 사용해 EchoServer를 실행해보자. 단 이번에는 혼동을 피하기 위해 4444같은 다른 포트를 사용한다.

EchoClientGreetClient와 유사하므로, 기존 코드를 복사하여 사용할 수 있다.

EchoServer 에 대한 다수의 요청이 발생할 때 소켓이 닫히지 않음을 보여주는 테스트 케이스를 작성할 것이다. 이 테스트는 같은 클라이언트에서 요청을 보내는 한 항상 통과할 것이다.

서로 다른 케이스에 대한 여러 클라이언트를 만드는 작업을 해보자.
제일 먼저, 서버와의 연결을 초기화 하는 setUp 메서드를 만들 것이다.

@Before
public void setup() {
    client = new EchoClient();
    client.startConnection("127.0.0.1", 4444);
}

또한 자원을 해제하기 위해 tearDown 메서드를 만들겠다. 이러한 패턴은 네트워크 자원을 사용하는 모든 경우에 유용하다.

@After
public void tearDown() {
    client.stopConnection();
}

마지막으로 몇 번의 요청을 통해 에코 서버를 테스트 해보겠다.

@Test
public void given_클라이언트는_when_서버의_에코_메시지를_then_받을_수_있다() throws IOException {
    String resp1 = client.sendMessage("hello");
    String resp2 = client.sendMessage("world");
    String resp3 = client.sendMessage("!");
    String resp4 = client.sendMessage(".");

    assertEquals(resp1, "hello");
    assertEquals(resp2, "world");
    assertEquals(resp3, "!");
    assertEquals(resp4, "good bye");
}

위의 구현은 서버가 연결을 닫기 전까지 단 한 번의 통신만 가능했던 맨 처음의 예제보다 발전했다. 이제는 세션을 종료하고 싶을 때 서버에게 종료 신호를 보낼 수 있게 되었다.


6. 다수의 클라이언트를 처리하는 서버

이전의 예제는 첫번째보단 낫지만, 아직 좋은 해결 방법이라고 보기는 어렵다. 서버는 다수의 클라이언트와 다수의 요청을 동시에 처리할 수 있어야 한다.

이번 섹션에서는 다수의 클라이언트를 다루는 방법을 배울 것이다.

또한 연결 거부 예외나 서버와의 연결 초기화 없이 동일한 클라이언트가 연결이 끊긴 이후 다시 연결을 수립하는 기능도 다루어볼 것이다. 이전 섹션에서는 이러한 기능이 불가능했다.

즉, 서버가 클라이언트의 여러 요청에 대해 더 강력하고 탄력적으로 대응할 수 있게 된다는 의미이다.

이를 달성하기 위해 매 클라이언트마다 새 소켓을 만들고 각 클라이언트의 요청을 서로 다른 스레드에서 처리할 것이다. 따라서 동시에 통신되고 있는 클라이언트의 수는 실행되고 있는 스레드의 수와 같을 것이다.

메인 스레드는 새 연결을 받기 위해 while 루프를 돌 것이다.

이제 하나씩 구현해보자. EchoMultiServe.java를 구현할 것이며, 이 클래스 안에 각 소켓을 통한 클라이언트와의 통신을 관리할 핸들러 스레드 클래스를 만들 것이다.

package server;

import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStreamReader;
import java.io.PrintWriter;
import java.net.ServerSocket;
import java.net.Socket;

public class EchoMultiServer {
    private ServerSocket serverSocket;

    public void start(int port) throws IOException {
        serverSocket = new ServerSocket(port);
        while (true)
            new EchoClientHandler(serverSocket.accept()).start();
    }

    private static class EchoClientHandler extends Thread {
        private Socket clientSocket;
        private PrintWriter out;
        private BufferedReader in;

        public EchoClientHandler(Socket socket) {
            this.clientSocket = socket;
        }

        @Override
        public void run() {
            try {
                out = new PrintWriter(clientSocket.getOutputStream(), true);
                in = new BufferedReader(new InputStreamReader(clientSocket.getInputStream()));

                String inputLine;
                while ((inputLine = in.readLine()) != null) {
                    if (inputLine.equals(".")) {
                        out.println("good bye");
                        break;
                    }
                    out.println(inputLine);
                }
                in.close();
                out.close();
                clientSocket.close();
            } catch (IOException e) {
                throw new RuntimeException(e);
            }
        }
    }
}

while 루프 안에서 accept를 호출하는 부분에 주목하라. while 루프가 실행될 때마다, accept 부분에서 새 클라이언트와 연결될 때까지 실행 흐름이 블록된다. 그 다음으로 요청한 클라이언트를 위한 핸들러 스레드인 EchoClientHandler가 생성된다.

스레드 내부에서 일어나는 일은 하나의 클라이언트에 대해서만 작동하던 EchoServer의 기능과 같다. EchoMultiServer는 이 기능을 EchoClientHandler에 위임함으로써 while 루프 안에서 더 많은 클라이언트의 요청을 리스닝할 수 있게 되었다.

서버를 테스트하기 위해 이전에 사용했던 EchoClient를 그대로 사용해도 된다. 이번에는 각각 서버에서 여러 메시지를 주고 받는 여러 클라이언트를 만들어 보겠다.

이번에는 메인 메서드에서 포트를 5555번으로 설정하겠다.

package test;

import client.EchoClient;
import org.junit.Assert;
import org.junit.Test;
import server.EchoMultiServer;
import server.EchoServer;

import java.io.IOException;

import static org.junit.Assert.assertEquals;

public class EchoMultiTest {
    @Test
    public void given_클라이언트1이_when_서버_응답을_then_받을_수_있다() throws IOException {
        EchoClient client1 = new EchoClient();
        client1.startConnection("127.0.0.1", 5555);
        String msg1 = client1.sendMessage("hello");
        String msg2 = client1.sendMessage("world");
        String terminate = client1.sendMessage(".");

        assertEquals(msg1, "hello");
        assertEquals(msg2, "world");
        assertEquals(terminate, "good bye");
    }

    @Test
    public void given_클라이언트2가_when_서버_응답을_then_받을_수_있다() throws IOException {
        EchoClient client2 = new EchoClient();
        client2.startConnection("127.0.0.1", 5555);
        String msg1 = client2.sendMessage("hello");
        String msg2 = client2.sendMessage("world");
        String terminate = client2.sendMessage(".");

        assertEquals(msg1, "hello");
        assertEquals(msg2, "world");
        assertEquals(terminate, "good bye");
    }
}

서버는 이제 다수의 연결을 처리할 수 있으므로, 원한다면 새 클라이언트를 만들어내는 더 많은 테스트 케이스를 만들어도 된다.


7. 결론

이번 튜토리얼에서는 TCP/IP 위에서 작동하는 소켓 프로그래밍을 처음 배우며 Java로 간단한 클라이언트/서버 애플리케이션을 작성했다.

코드 전문은 Github 프로젝트에서 확인할 수 있다.


역시 만들면서 배우는게 제일 이해가 잘 되는 것 같다. 간단한 채팅 프로그램도 만들어보고,, 얼마전에 스프링에서 비교적 고차원적인 소켓 프로그래밍 방법을 제공하는걸 본 것 같은데, 그것도 한 번 해봐야겠다.

profile
그래 다 먹고 살자고 하는 건데,, 🥹

0개의 댓글