네트워크 애플리케이션들은 어떻게 만들어지는지에 대해 알아보자. 네트워크 애플리케이션에서는 서로 다른 종단 시스템에서 위치한 클라이언트 프로그램과 서버 프로그램이 하나의 쌍을 이룬다. 두 프로그램들이 실행될 때, 클라이언트 프로세스와 서버 프로세스가 생성되고, 이 프로세스들은 소켓으로 읽고 씀으로써 통신한다.
네트워크 애플리케이션에는 두 종류가 있다.
이 섹션에서는 간단한 클라이언트-서버 애플리케이션을 구현하는 코드를 보며 클라이언트-서버 애플리케이션 개발의 핵심 이슈들을 살펴볼 것이다.
개발 단계에서 개발자는 우선 애플리케이션이 UDP에서 돌아갈지 TCP에서 돌아갈지를 결정해야 한다.
Recall
- TCP: 연결 기반. 두 종단 시스템 사이에서 데이터가 흐르는 신뢰할 수 있는 바이트-스트림 채널을 제공
- UDP: 비연결. 각 데이터 패킷을 한 종단 시스템에서 다른 종단 시스템으로 보내되, 손실 없이 제대로 전달될 것임을 보장하지는 않음.
RFC에 정의된 프로토콜을 구현하는 클라이언트, 또는 서버 프로그램을 사용할 때에는 해당 프로토콜과 관련한, 잘 알려진 포트 번호를 써야 한다. 반대로, 독점 애플리케이션에는 개발할 때 그런 잘 알려진 포트 번호를 쓰지 않도록 주의해야 한다.
책에서는 파이썬을 통해 소켓 프로그래밍을 하고 있지만, 자바로 변형해서 한 번 구현해보자.
UDP 소켓을 통해 통신하는 프로그램 사이의 상호작용이 어떻게 일어나는지 살펴보자. 송신 프로세스는 데이터 패킷을 소켓으로 내보내기 전에, 우선 패킷에 목적지의 주소를 붙여야 한다. 패킷이 송신자의 소켓을 통과하고 나면, 인터넷은 이 목적지 주소를 가지고 패킷을 라우팅해 수신 프로세스의 소켓으로 보낸다. 수신 소켓에 패킷이 도달하면, 수신 프로세스는 소켓을 통해 패킷을 탐색하고 패킷의 컨텐츠에 맞는 액션을 취한다.
패킷에 붙는 목적지의 주소에는 무엇이 들어갈까? 여기에는 목적지의 IP 주소와 수신 소켓의 포트 번호가 포함된다. 목적지의 IP 주소를 패킷에 넣음으로써 인터넷의 라우터는 패킷을 목적지 호스트로 라우팅할 수 있게 된다. 하지만 호스트가 각각 하나 이상의 소켓을 사용하는, 여러 네트워크 애플리케이션 프로세스들을 실행할 수도 있으므로, 목적지 호스트의 소켓도 지정해줄 필요가 있다. 포트 번호가 여기에 쓰인다.
패킷에는 송신자의 소스 주소(소스 호스트의 IP 주소 + 포트 번호를 포함)도 붙는다. 하지만 소스 주소를 패킷에 붙이는 일은 UDP 애플리케이션 코드에서 하는 일은 아니고, 그 아래 OS에 의해 자동적으로 이루어진다.
다음과 같은 일을 하는 간단한 클라이언트-서버 프로그램을 만들어보도록 하자.
아래의 그림은 UDP 전송 시스템을 통해 통신하는 클라이언트와 서버의 소켓 관련 주요 활동들을 보여준다.
우선은 UDP 클라이언트부터 시작하자. 서버의 포트는 12000번을 쓴다고 가정한다.
package udp;
import java.io.BufferedReader;
import java.io.InputStreamReader;
import java.net.DatagramPacket;
import java.net.DatagramSocket;
import java.net.InetAddress;
public class UDPClient {
public static String serverHost = "localhost";
public static Integer serverPort = 12000;
public String bytesToString(byte[] bytes) {
int len = 0;
for (byte b : bytes) {
if (b == 0) break;
len++;
}
return new String(bytes, 0, len);
}
public void run() {
try (DatagramSocket socket = new DatagramSocket(); BufferedReader br = new BufferedReader(new InputStreamReader(System.in))) {
InetAddress address = InetAddress.getByName(serverHost);
String input = br.readLine();
DatagramPacket outPacket = new DatagramPacket(input.getBytes(), input.length(), address, serverPort);
DatagramPacket inPacket = new DatagramPacket(new byte[1024], 1024);
socket.send(outPacket);
socket.receive(inPacket);
System.out.println(bytesToString(inPacket.getData()));
}
catch (Exception e) {
System.out.println(e.getMessage());
}
}
public static void main(String[] args) {
new UDPClient().run();
}
}
자바에서는 UDP 통신 소켓으로 DatagramSocket
을 사용한다. 새 DatagramSocket
을 만들고, 이 소켓을 통해 패킷(DatagramPacket
)을 주고 받는다.
DatagramPacket outPacket = new DatagramPacket(input.getBytes(), input.length(), address, serverPort);
DatagramPacket inPacket = new DatagramPacket(new byte[1024], 1024);
데이터를 주고 받을 때에는 버퍼를 만들고, 해당 버퍼의 크기도 지정해줘야 한다.
package udp;
import java.net.DatagramPacket;
import java.net.DatagramSocket;
import java.net.InetAddress;
public class UDPServer {
public static Integer serverPort = 12000;
public String bytesToString(byte[] bytes) {
int len = 0;
for (byte b : bytes) {
if (b == 0) break;
len++;
}
return new String(bytes, 0, len);
}
public void run(){
try (DatagramSocket socket = new DatagramSocket(serverPort)) {
DatagramPacket outPacket;
DatagramPacket inPacket;
while (true){
inPacket = new DatagramPacket(new byte[1024], 1024);
socket.receive(inPacket);
InetAddress address = inPacket.getAddress();
int port = inPacket.getPort();
String input = bytesToString(inPacket.getData());
System.out.println("[Server] Received packet from " + address + ":" + port + ". data: " + input);
String output = input.toUpperCase();
outPacket = new DatagramPacket(output.getBytes(), output.length(), address, port);
socket.send(outPacket);
}
}
catch (Exception e) {
System.out.println(e.getMessage());
}
}
public static void main(String[] args) {
new UDPServer().run();
}
}
UDP 서버는 while(true)
루프를 돌면서 패킷을 계속해서 주고 받는다.
InetAddress address = inPacket.getAddress();
int port = inPacket.getPort();
클라이언트에서 따로 명시해서 보내지는 않았지만, 서버에서는 들어오는 패킷의 주소와 포트 번호를 알 수 있고, 서버는 이를 가지고 클라이언트에게 응답 메시지를 보낸다.
잘 주고 받음을 확인할 수 있다.
UDP와 달리 TCP는 연결 기반 프로토콜이다. 클라이언트와 서버는 서로에게 데이터를 보내기 전에 핸드셰이크를 통해 TCP 연결을 맺어야 한다. TCP 연결의 한 쪽은 클라이언트 소켓에, 다른 한 쪽은 서버 소켓에 붙어있다.
따라서 TCP 연결을 맺을 때에는 클라이언트의 소켓 주소와 서버의 소켓 주소를 알아야 한다. TCP 연결이 맺어지고 나면 양쪽은 소켓을 통해 TCP 연결로 데이터를 내보내기만 하면 된다. UDP에서 패킷을 소켓으로 내보내기 전에 목적지 주소를 패킷에 추가해줘야 했던 것과 차이가 있다.
TCP에서 클라이언트와 서버가 어떻게 상호작용하는지를 조금 더 자세히 살펴보자. TCP 연결을 시작하는 건 클라이언트다. 서버가 클라이언트의 연결에 응답할 수 있으려면,
서버 프로세스가 실행되고 있으면, 클라이언트 프로세스는 서버와 TCP 연결을 시작한다. 이 작업은 클라이언트 프로그램이 TCP 소켓을 만들 때 진행된다. 클라이언트는 TCP 소켓을 만들 때, 서버의 주소(호스트 주소 + 환영 소켓의 포트 번호)을 명시해야 한다. 소켓을 만들고 나면 클라이언트는 3-way 핸드셰이크를 시작하고 서버와의 TCP 연결을 맺는다. 3-way 핸드셰이크는 전송 계층에서 일어나며, 클라이언트와 서버 프로그램에게 드러나지는 않는다.
3-way 핸드셰이크 동안, 클라이언트는 서버 프로세스의 환영 소켓에 노크를 한다. 서버는 이 노크를 들으면, 해당 클라이언트 전용의 새 소켓을 만든다. 아래에 나올 예시에서 환영 소켓은 serverSocket
, 새로이 만들어진 소켓은 connectionSocket
으로 이름 붙여져있다.
애플리케이션의 관점에서, 클라이언트의 소켓과 서버의 연결 소켓은 파이프로 직접 연결되어 있다. 아래의 그림에서 볼 수 있듯, 클라이언트 프로세스는 임의의 바이트를 자신의 소켓으로 내보내고, TCP는 서버 프로세스가 각 바이트를 보내진 순서대로 받을 수 있음을 보장한다. 클라이언트는 자신의 소켓을 통해 데이터를 주고 받고, 서버는 특정 클라이언트 전용 연결 소켓을 통해 데이터를 주고 받는다.
아래의 그림은 TCP 전송 시스템을 통해 통신하는 클라이언트와 서버의 소켓 관련 주요 활동들을 보여준다.
이번에도 클라이언트 코드부터 살펴보자.
package tcp;
import java.io.BufferedReader;
import java.io.InputStreamReader;
import java.io.PrintWriter;
import java.net.InetAddress;
import java.net.Socket;
public class TCPClient {
public static String serverHost = "localhost";
public static Integer serverPort = 12000;
public void run() {
try (Socket socket = new Socket(InetAddress.getByName(serverHost), serverPort); BufferedReader br = new BufferedReader(new InputStreamReader(System.in))) {
String input = br.readLine();
PrintWriter writer = new PrintWriter(socket.getOutputStream(), true);
writer.println(input);
writer.flush();
BufferedReader reader = new BufferedReader(new InputStreamReader(socket.getInputStream()));
String output = reader.readLine();
System.out.println(output);
}
catch (Exception e){
System.out.println(e.getMessage());
}
}
public static void main(String[] args){
new TCPClient().run();
}
}
클라이언트에서는 Socket socket
을 통해 새로운 소켓을 만든다. 소켓을 새로 만들면 서버 프로세스와의 TCP 연결이 맺어진다.
PrintWriter writer
를 통해 입력받은 문자열을 소켓의 아웃풋스트림에 쓰고, BufferedReader reader
를 통해 클라이언트 소켓으로 들어오는 인풋스트림을 읽어와 출력한다.
package tcp;
import java.io.BufferedReader;
import java.io.InputStreamReader;
import java.io.OutputStreamWriter;
import java.io.PrintWriter;
import java.net.*;
public class TCPServer {
public void run() {
try (ServerSocket serverSocket = new ServerSocket(12000) ){
while (true) {
Socket connectionSocket = serverSocket.accept();
BufferedReader reader = new BufferedReader(new InputStreamReader(connectionSocket.getInputStream()));
String input = reader.readLine();
SocketAddress address = connectionSocket.getRemoteSocketAddress();
int port = connectionSocket.getPort();
System.out.println("[Server] Received packet from " + address + ":" + port + ". data: " + input);
PrintWriter writer = new PrintWriter(new OutputStreamWriter(connectionSocket.getOutputStream()));
writer.println(input.toUpperCase());
writer.flush();
}
} catch (Exception e) {
System.out.println(e.getMessage());
}
}
public static void main(String[] args) {
new TCPServer().run();
}
}
ServerSocket serverSocket
은 서버에서 사용할 새로운 환영 소켓을 만든다. 이 소켓으로 새로운 연결이 들어오면 serverSocket.accept()
로 받아 새로운 연결 소켓 Socket connectionSocket
을 만든다.
클라이언트와 마찬가지로, BufferedReader reader
를 통해 연결 소켓으로 들어오는 정보를 받고, 문자열을 대문자로 바꾼 후 PrintWriter writer
를 통해 연결 소켓으로 변환된 문자열을 쓴다.
잘 받아온다.