자바의 네트워크 프로그래밍

de_sj_awa·2021년 5월 2일
0

1. 네트워크 프로그래밍이란?

스마트폰이나 노트북 등으로 인터넷을 사용해 왔다. 그리고, 텔넷이나 FTP 등을 사용하여 서버와 통신을 해 본 사람도 있을 것이다. 이렇게 사용자들이 바로 옆에 있는 장비와 데이터를 주고 받는 작업을 보통 네트워킹(Networking)이라고 한다. 이러한 네트워킹은 다음과 같은 레이어(Layer)로 구분하도록 되어 있다.

애플리케이션 레이어
(HTTP, ftp, telnet ...)
트랜스포트 레이어
(TCP, UDP ...)
네트워크 레이어
(IP, ...)
링크 레이어
(device driver, ...)
- 네트워크의 일반적인 레이어

실제로는 OSI 7 layer라고 해서 복잡한 레이어가 네트워크 전문가들에게 존재한다. 이 그림의 애플리케이션 레이어 중 가장 대표적인 HTTP(Hypertext Transfer Protocol), FTP(File Transfer Protocol), Telnet은 모두 TCP(Transmission Control Protocol) 통신을 한다. 만약 자바로 TCP 통신을 한다면 자바에서 제공하는 API를 사용하면 된다. 즉, 애플리케이션 레이어에서 프로그래밍만 하면 트랜스포트 레이어에서의 처리는 자바에서 다 알아서 처리하므로 걱정할 필요가 없다.

TCP 통신은 가장 대표적인 통신 방법으로 "연결 기반 프로토콜"이라고 불린다. 만약 TCP를 사용하여 바로 옆에 있는 장비나 지구 반대편에 있는 장비로 데이터를 보내는 경우에 전송이 성공되었다는 통보를 받는다. 다시 말해서 TCP는 상대방이 데이터를 받았는지를 확실히 보장할 수 있다.

그림의 프로토콜 레이어를 보면 UDP라는 것이 있다. UDP는 User Datagram Protocol의 약자다. UDP와 TCP가 다른 점 중 하나는 UDP는 다른 장비가 데이터를 제대로 받았는지에 대한 보장을 못한다는 것이다.

여기까지의 내용만 보면 "아니 그럼 TCP 쓰면 되지, 왜 UDP 같은 것을 만든 거야?"라고 생각할 수 있다. TCP를 사용하면 데이터가 전송된다는 보장을 받을 수 있지만 내부적으로 처리하는 절차가 매우 복잡하게 되어 있다. 그만큼 TCP는 UDP 보다 비싸고 느리며, 무겁다.

이 세상에 있는 모든 데이터가 꼭 전송이 보장되어야 할 필요는 없다. 이번에 데이터를 받지 않아도 다음에 받는 데이터를 사용해도 될 수 있다. 그리고, 100만개 중에 몇 개 정도는 유실되어도 큰 문제가 되지 않는 시스템들이 있을 수 있다. 그러한 경우에는 무조건(반드시) 무거운 TCP를 사용하여 데이터를 주고 받을 필요가 없다.

이번에는 포트(port)에 대해서 알아보자. 일반적인 웹 애플리케이션에서는 80이라는 번호의 포트를 사용한다. 이건 정해져 있다. 99를 쓰고 싶다면, 웹 서버에 99번을 사용한다고 지정을 해 좋고, 사용자가 웹 서버에 붙기 위해서 주소 뒤에 콜론(:)을 붙인 뒤 99라고 적어주어야 한다(즉 URI를 브라우저에서 입력하면 자동으로 :80이 붙은 것과 같다).

또 다른 예로 웹으로 SSL이라는 안전한 통신을 하려면 443이라는 포트를 사용하게 된다. 이렇게 정해져 있는 포트는 되도록이면 다른 용도로 사용하지 않는 것이 좋다. 따라서 0~1023까지는 사용하는 것이 제한되어 있다. 하지만 포트는 16비트로 구성되어 65,355까지 사용할 수가 있으니 그 외의 값들은 임의로 사용하면 된다.

2. Socket 클래스

방금 살펴본 TCP 통신을 자바에서 수행하려면 Socket 클래스를 사용하면 된다. 이 java.net 패키지에는 많은 클래스들이 선언되어 있다.

이 Socket 클래스는 데이터를 보내는 쪽(보통 클라이언트)에서 객체를 생성하여 사용한다. 데이터를 받는 쪽(보통 서버)에서 클라이언트 요청을 받으면, 요청에 대한 Socket 객체를 생성하여 데이터를 처리한다. 즉, 이 Socket 클래스는 서버 쪽이 되었든, 클라이언트 쪽이 되었든 원격에 있는 장비와의 연결 상태를 보관하고 있다고 생각하면 된다.

그러면 서버에서는 어떻게 데이터를 받을까? 서버에서는 ServerSocket이라는 클래스를 사용하여 데이터를 받는다. 방금 서버에서 요청에 대한 Socket 객체를 만든다고 했는데, 이 객체는 별도로 new 키워드를 사용하여 만들 필요는 없고 ServerSocket 클래스에서 제공하는 메소드에서 클라이언트 요청이 생기면 Socket 객체를 생성하여 전달해 준다.

ServerSocket 클래스의 API를 보면 매우 많은 메소드가 제공되는 것을 볼 수 있다. 하지만 생성자와 2개의 메소드만 알아도 충분히 네트워킹 프로그래밍이 가능하다. 먼저 생성자를 살펴보자.

생성자 설명
ServerSocket() 서버 소켓 객체만 생성한다.
ServerSocket(int port) 지정된 포트를 사용하는 서버 소켓을 생성한다.
ServerSocket(int port, int backlog) 지정된 포트와 backlog 개수를 가지는 소켓을 생성한다.
ServerSocket(int port, int backlog, InetAddress bindAddr) 지정된 포트와 backlog 개수를 가지는 소켓을 생성하여, bindAddr에 있는 주소에서의 접근만을 허용한다.

여기서 처음 보는 backlog라는 값이 있다. 쉽게 생각하면 큐의 개수라고 생각하면 된다. ServerSocket 객체가 바빠서 연결 요청을 처리 못하고 대기시킬 때가 있는데, 그 때의 최대 대기 개수라고 생각하면 된다.

두 번째 생성자는 backlog의 개수를 지정하지 않고 있는데, 이렇게 지정하지 않을 경우에 backlog의 개수는 50개가 있다. 그런데, 만약 만든 애플리케이션의 접속이 원활하지 않는다면, 이 개수를 적절하게 증가시키는 것이 좋다.

그리고, InetAddress라는 클래스의 객체인 bindAddr이 있는데, 이는 특정 주소에서만 접근이 가능하도록 지정할 때 사용한다.

한 가지 유의할 점은 매개 변수가 없는 ServerSocket 클래스를 제외한 나머지 클래스들은 객체가 생성되자마자 연결을 대기할 수 있는 상태가 된다. 거꾸로 말해서 ServerSocket() 생성자는 별도로 연결 작업을 해야만 대기가 가능하다. 객체 생성후 사용자의 요청을 대기하는 메소드가 바로 accept() 메소드다. 그리고, 소켓 연결이 끝난 이후에 소켓을 닫는 메소드는 close()다.

리턴 타입 메소드 설명
Socket accept() 새로운 소켓 연결을 기다리고, 연결이 되면 Socket 객체를 리턴
void close() 소켓 연결을 종료

여기서 close() 메소드 처리를 하지 않고, JVM이 계속 동작이라면, 해당 포트는 동작하는 서버나 PC에서 다른 프로그램이 사용할 수 없다.

이번에는 데이터를 보내기 위한 Socket 클래스에 대해서 알아보자.

데이터를 받는 서버에서는 클라이언트에서 접속을 하면 Socket 객체를 생성하지만, 데이터를 보내는 클라이언트에서는 Socket 객체를 직접 생성해야만 한다. java.net 패키지에 있는 Socket 클래스의 생성자는 다음과 같다.

생성자 설명
Socket() 소켓 객체만 생성
Socket(Proxy proxy) 프록시 관련 설정과 함께 소켓 객체만 생성
Socket(SocketImpl impl) 사용자가 지정한 SocketImpl 객체를 사용하여 소켓 객체만 생성
Socket(InetAddress address, int port) 소켓 객체 생성 후 address와 port를 사용하는 서버에 연결
Socket(InetAddress address, int port, InetAddress localAddr, int localPort) 소켓 객체 생성 후 address와 port를 사용하는 서버에 연결하며, 지정된 localAddr와 localPort에 접속
Socket(String host, int port) 소켓 객체 생성 후 host와 port를 사용하는 서버에 연결
Socket(String host, int port, InetAddress localAddr, int localPort) 소켓 객체 생성 후 host와 port를 사용하는 서버에 연결하며, 지정된 localAddr과 localPort에 접속

보는 것과 같이 매우 많은 Socket 생성자가 존재하는 것을 볼 수 있다. 밑에서 두 번째에 있는 host와 port를 지정하는 생성자를 사용하는 것이 가장 편하고, 대부분 다른 생성자들은 별도의 용도가 있는 Socket 객체를 생성하는 것이라고 생각하면 된다. 그리고, 위에 있는 세 개의 생성자를 제외한 나머지 생성자들은 모두 객체 생성과 같이 지정된 서버에 접속을 한다.

Socket 클래스도 ServerSocket 클래스와 마찬가지로 close() 메소드를 사용하여 소켓을 닫는다.

3. 간단한 소켓 통신

앞에서 설명한 메소드들을 사용하여 간단한 데이터를 주고, 받는 프로그램을 작성해보자. 먼저 소켓을 대기하는 서버를 다음과 같이 만들자.

package e.network;

import java.io.BufferedReader;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.net.ServerSocket;
import java.net.Socket;

public class SocketServerSample {
    public static void main(String[] args){
        SocketServerSample sample = new SocketServerSample();
        sample.startServer();
    }
    public void startServer(){
        ServerSocket server = null;
        Socket client = null;
        try{
            server = new ServerSocket(9999);
            while(true){
                System.out.println("Server:Waiting for request.");
                client = server.accept();
                System.out.println("Server:Accepted");
                InputStream stream = client.getInputStream();
                BufferedReader in = new BufferedReader(new InputStreamReader(stream));
                String data = null;
                StringBuilder receiveData = new StringBuilder();
                while((data=in.readLine()) != null){
                    receiveData.append(data);
                }
                System.out.println("Received data:" + receiveData);
                in.close();
                stream.close();
                client.close();
                if(receiveData != null && "EXIT".equals(receiveData.toString())){
                    System.out.println("Stop SocketServer");
                    break;
                }
                System.out.println("-------------");
            }
        }catch (Exception e){
            e.printStackTrace();
        }finally {
            if(server != null){
                try{
                    server.close();
                }catch (Exception e){
                    e.printStackTrace();
                }
            }
        }
    }
}

이 예제는 데이터가 올 때마다 해당 데이터 내용을 출력하고, 계속 대기 상태로 유지된다. 만약 넘어오는 데이터가 "EXIT"이라면 더 이상 대기하지 않고 프로그램이 종료된다.

데이터를 받는 서버의 소스를 살펴보았으니, 데이터를 전송하는 클라이언트의 소스를 살펴보자.

package e.network;

import java.io.BufferedOutputStream;
import java.io.OutputStream;
import java.net.Socket;
import java.util.Date;

public class SocketClientSample {
    public static void main(String[] args){
        SocketClientSample sample = new SocketClientSample();
        sample.sendSocketSample();
    }
    public void sendSocketSample(){
        for(int loop=0; loop<3; loop++){
            sendSocketData("I liked java at "+new Date());
        }
        sendSocketData("EXIT");
    }
    public void sendSocketData(String data){
        Socket socket = null;
        try{
            System.out.println("Client:Connecting");
            socket = new Socket("127.0.0.1", 9999);
            System.out.println("Client:Connect status="+socket.isConnected());
            Thread.sleep(1000);
            OutputStream stream = socket.getOutputStream();
            BufferedOutputStream out = new BufferedOutputStream(stream);
            byte[] bytes = data.getBytes();
            out.write(bytes);
            System.out.println("Client:Send data="+data);
            out.close();
        }catch (Exception e){
            e.printStackTrace();
        }finally {
            if(socket != null){
                try{
                    socket.close();
                }catch (Exception e){
                    e.printStackTrace();
                }
            }
        }
    }
}

이 예제는 sendSocket() 메소드를 총 3회 호출하고, 마지막에 "EXIT"을 호출하여 작업을 마치도록 되어 있다. 따라서, "EXIT"이 호출된 이후에는 서버 프로그램이 종료되므로 사용한 서버에 다시 접근할 수 없다.

그러면 작성한 클래스를 실행해 보자. 먼저 ServerSocketSample 클래스를 실행해보자. 반드시 먼저 실행해야 한다. 정상적으로 시작되었을 경우에는 다음과 같은 메시지를 출력하고 대기한다.

Server:Waiting for request.

이 서버 프로그램을 중단하지 말고, 새로운 커맨드 창을 띄워서 SocketServerSample 클래스를 실행하자. 이 클래스를 실행하면 서버에 접속 후 1초씩 대기하면서 데이터를 전송한다. 클라이언트 쪽에 출력되는 내용은 다음과 같다.

Client:Connecting
Client:Connect status=true
Client:Send data=I liked java at Mon May 03 02:56:46 KST 2021
Client:Connecting
Client:Connect status=true
Client:Send data=I liked java at Mon May 03 02:56:47 KST 2021
Client:Connecting
Client:Connect status=true
Client:Send data=I liked java at Mon May 03 02:56:48 KST 2021
Client:Connecting
Client:Connect status=true
Client:Send data=EXIT

Process finished with exit code 0

서버 쪽에 출력되는 내용은 다음과 같다.

Server:Waiting for request.
Server:Accepted
Received data:I liked java at Mon May 03 02:56:46 KST 2021
-------------
Server:Waiting for request.
Server:Accepted
Received data:I liked java at Mon May 03 02:56:47 KST 2021
-------------
Server:Waiting for request.
Server:Accepted
Received data:I liked java at Mon May 03 02:56:48 KST 2021
-------------
Server:Waiting for request.
Server:Accepted
Received data:EXIT
Stop SocketServer

당연한 이야기지만, 서버에 출력되는 데이터 내용은 클라이언트에 출력되는 내용과 동일하다. 만약 이 두 개의 데이터가 다르다면 프로그램에 문제가 있는 것이다.

추가로 이 예제를 실행하면서 발생할 수 있는 예외에는 다음과 같은 것들이 있으니, 예외가 발생하면 다음의 내용을 잘 읽어보자.

  • java.net.BindException : Address already in use 서버를 띄워 놓고 또 띄웠을 때 발생한다. 이미 지정된 port 번호를 사용하고 있기 때문에 동일한 port 번호를 사용할 수 없기 때문이다.
  • java.net.ConnectException : Connection refused 서버를 띄워 놓지 않고 클라이언트 프로그램만 수행했을 때 발생한다. 왜냐면 받을 서버가 없으니 던질 곳도 없기 때문이다.

참고로 여기서 클라이언트에서 서버로 데이터를 전송했지만, 실제로는 반대로 데이터를 전송하는 것도 상관 없다. 꼭 단방향으로 전달될 필요는 없다.

4. UDP 통신과 Datagram 관련 클래스

이번에는 UDP 통신을 하는 방법을 알아보자. UDP는 TCP와 달리 데이터가 제대로 전달되었다는 보장을 하지 않는다. 그러므로, UDP 관련 프로그램은 데이터의 유실이 있어도 문제가 없을 때에만 사용하는 것이 좋다.

자바에서 UDP 통신을 하려면 TCP와 마찬가지로 데이터를 주고 받기 위한 클래스가 필요하다. 그런데, TCP와는 다르게, 클래스 하나에서 보내는 역할과 받는 역할을 모두 수행할 수 있다. 그 클래스는 바로 DatagramSocket이다. 그리고, TCP에서는 스트림 객체를 얻어 데이터를 주거나 받았지만, UDP 통신을 할 때에는 스트림을 사용하지 않고 DatagramPacket이라는 클래스를 사용한다.

먼저 DatagramSocket 클래스에 대해서 알아보자. DatagramSocket의 생성자는 Socket 클래스의 생성자와 비슷하며, 다음과 같다.

생성자 설명
DatagramSocket() 소켓 객체 생성 후 사용 가능한 포트로 대기
DatagramSocket(DatagramSocketImpl impl) 사용자가 지정한 SocketImpl 객체를 사용하여 소켓 객체만 생성
DatagramSocket(int port) 소켓 객체 생성 후 지정된 port로 대기
DatagramSocket(int port, InetAddr address) 소켓 객체 생성 후 address와 port를 사용하는 서버에 연결
DatagramSocket(SocketAddress address) 소켓 객체 생성 후 address에 생성된 서버로 연결

이 DatagramSocket 클래스도 일반적인 연결을 하는 클래스들 처럼 close() 메소드를 제공하며, 더 이상 사용할 필요가 없을 때에는 이 메소드를 호출해 주어야 한다. 그리고, 데이터를 받기 위해서 대기할 때에는 receive() 메소드를 사용하고, 데이터를 보낼 때에는 send() 메소드를 사용하면 된다. 이 두 개의 메소드는 다음과 같이 선언되어 있다.

리턴 타입 메소드 설명
void receive(DatagramPacket packet) 메소드 호출시 요청을 대기하고, 만약 데이터를 받았을 때에는 packet 객체에 데이터를 저장
void send(DatagramPacket packet) packet 객체에 있는 데이터 전송

물론 이 외에도 여러 가지 메소드들이 존재하지만, 간단하게 사용하려면 이 정도만 알고 있으면 데이터를 주고 받는 데 큰 문제가 발생하지 않는다. 그러면 이 두 개의 메소드의 매개 변수로 제공되는 DatagramPacket클래스에 대해 알아보자. DatagramPacket 클래스의 생성자 중 단 하나만 데이터를 받기 위한 생성자이며, 나머지 생성자들은 데이터를 전송하기 위한 생성자이다.

생성자 설명
DatagramPacket(byte[] buf, int length) length의 크기를 갖는 데이터를 "받기" 위한 객체 생성
DatagramPacket(byte[] buf, int length, InetAddress address, int port) 지정된 address와 port로 데이터를 전송하기 위한 객체 생성
DatagramPacket(byte[] buf, int offset, int length) 버퍼의 offset이 할당되어 있는 데이터를 전송하기 위한 객체 생성
DatagramPacket(byte[] buf, int offset, int length, InetAddress address, int port) 버퍼의 offset이 할당되어 있고, 지정된 address와 port로 데이터를 전송하기 위한 객체 생성
DatagramPacket(byte[] buf, int offset, int length, SocketAddress address) 버퍼의 offset이 할당되어 있고, 지정된 소켓 address로 데이터를 전송하기 위한 객체 생성
DatagramPacket(byte[] buf, int length, SocketAddress address) 지정된 소켓 address로 데이터를 전송하기 위한 객체 생성

여기서 byte 배열은 전송되는 데이터다. 그리고, offset은 전송되는 byte 배열의 첫 위치이다. 만약 offset이 1이면 배열의 0번째부터가 아닌 1번째부터 데이터를 전송한다. 그리고, length는 데이터의 크기를 의미하는데, 이 값이 byte 배열의 크기보다 작으면 java.lang.IllegalArgumentException이 발생한다.

이 DatagramPacket 클래스의 여러 메소드 중 꼭 알아야 하는 메소드는 getData()와 getLength()다. getData()는 byte[]로 전송받은 데이터를 리턴하며, getLength()는 전송받은 데이터의 길이를 int 타입으로 리턴한다.

지금까지 UDP 통신을 위해서 필요한 클래스와 메소드들에 대해서 알아봤으니, 예제를 통해서 어떻게 사용하면 되는지 확인해보자.

5. 간단한 UDP 통신

UDP 통신을 하는 예제를 간단하게 살펴보자. 먼저 서버쪽 소스를 보자.

package e.network;

import java.net.DatagramPacket;
import java.net.DatagramSocket;

public class DatagramServerSample {

    public static void main(String[] args){
        DatagramServerSample sample = new DatagramServerSample();
        sample.startServer();
    }
    public void startServer(){
        DatagramSocket server = null;
        try{
            server = new DatagramSocket(9999);
            int bufferLength = 256;
            byte[] buffer = new byte[bufferLength];
            DatagramPacket packet = new DatagramPacket(buffer, bufferLength);
            while(true){
                System.out.println("Server:Waiting for request.");
                server.receive(packet);
                int dataLenth = packet.getLength();
                System.out.println("Server:received. Data Length="+dataLenth);
                String data = new String(packet.getData(), 0, dataLenth);
                System.out.println("Received data:"+data);
                if(data.equals("EXIT")){
                    System.out.println("Stop DatagramServer");
                    break;
                }
                System.out.println("------------------");
            }
        }catch (Exception e){
            e.printStackTrace();
        }finally {
            if(server != null){
                try{
                    server.close();
                }catch (Exception e){
                    e.printStackTrace();
                }
            }
        }
    }
}

이번에는 데이터를 전송하는 클라이언트 예제를 살펴보자.

package e.network;

import java.net.DatagramPacket;
import java.net.DatagramSocket;
import java.net.InetAddress;
import java.util.Date;

public class DatagramClientSample {
    
    public static void main(String[] args){
        DatagramClientSample sample = new DatagramClientSample();
        sample.sendDatagramSample();
    }
    public void sendDatagramSample(){
        for(int loop=0; loop<3; loop++){
            sendDatagramData("I liked UDP "+new Date());
        }
        sendDatagramData("EXIT");
    }
    public void sendDatagramData(String data){
        try{
            DatagramSocket client = new DatagramSocket();
            InetAddress address = InetAddress.getByName("127.0.0.1");
            byte[] buffer = data.getBytes();
            DatagramPacket packet = new DatagramPacket(buffer, 0, buffer.length, address, 9999);
            client.send(packet);
            System.out.println("Client:Send data");
            client.close();
            Thread.sleep(1000);
        }catch (Exception e){
            e.printStackTrace();
        }
    }
}

TCP 예제처럼 서버 클래스를 먼저 실행하자. 그러면 다음과 같은 한 줄이 출력된 후 대기하고 있을 것이다.

Server:Waiting for request.

이 프로그램을 중단하지 말고, 새로운 커맨드 창을 띄워서 클라이언트 프로그램을 실행하자.

Client:Send data
Client:Send data
Client:Send data
Client:Send data

클라이언트 프로그램에서는 4번에 걸쳐 데이터를 전송하였다. 정상적인 경우라면 클라이언트 프로그램이 수행된 이후에, 서버 프로그램의 로그는 다음과 같이 출력된다.

Server:Waiting for request.
Server:received. Data Length=40
Received data:I liked UDP Mon May 03 03:37:27 KST 2021
------------------
Server:Waiting for request.
Server:received. Data Length=40
Received data:I liked UDP Mon May 03 03:37:28 KST 2021
------------------
Server:Waiting for request.
Server:received. Data Length=40
Received data:I liked UDP Mon May 03 03:37:29 KST 2021
------------------
Server:Waiting for request.
Server:received. Data Length=4
Received data:EXIT
Stop DatagramServer

정상적으로 4번의 데이터를 받고 출력했으며, 마지막 데이터가 "EXIT"이기 때문에 프로세스를 종료한다.

그런데 여기서 한 가지만 짚어보자. UDP는 TCP와 다르게 데이터가 성공적으로 전송되지 않아도 예외를 발생시키지 않는다. 서버 프로그램은 수행하지 말고, 클라이언트 프로그램만 수행해보자. 결과가 어떻게 나오는가?

데이터를 받을 서버에 붙지 않더라도 아무런 이상 없이 프로그램이 종료되는 것을 보았을 것이다. 이처럼 UDP로 통신을 할 때에는 서버에서 데이터를 받을 준비가 되어 있지 않더라도, 클라이언트에서는 아무런 오류를 내지 않고 그냥 수행하도록 되어 있다. 이와는 반대로, TCP의 경우에는 서버에 접속하지 못하면,

java.net.ConnectException: Connection refused: connect

와 같이 ConnectException이 발생한다.

6. 자바에서 웹 페이지 요청을 하려면?

자바에서 인터넷을 통하여 웹 페이지 요청을 할 수도 있다. 자바 API에서 제공하는 URI이라는 클래스를 사용하면 아주 간단한 요청은 처리할 수 있다. 만약 운영하는 시스템 내에서 웹 페이지를 요청할 일이 있다면, 이 URI라는 클래스를 사용하는 것을 권장하지 않는다. 왜냐하면 이 클래스에서는 연결에 대한 상세한 설정을 할 수 없기 때문이다. 그래서, 일반적으로 Apache의 Http Components를 많이 사용한다.

참고

  • 자바의 신
profile
이것저것 관심많은 개발자.

0개의 댓글