이 섹션에서는 네트워크 계층이 제공하는 host-to-host 전송 서비스를 process-to-process 전송 서비스로 확장하는 전송 계층의 멀티플렉싱과 디멀티플렉싱에 대해 알아본다.
목적지 호스트에서 전송 계층은 바로 아래의 네트워크 계층으로부터 세그먼트를 받는다. 전송 계층은 이 세그먼트의 데이터를 호스트에서 실행되는 적절한 애플리케이션 프로세스로 전달한다. 예를 들어 지금 컴퓨터에서 하나의 FTP 세션과 두 개의 Telnet 세션을 실행하면서 웹 페이지를 다운로드하고 있다고 해보자. 여기서 우리는 두 개의 Telnet 프로세스, 하나의 FTP 프로세스, 하나의 HTTP 프로세스, 총 네 개의 애플리케이션 프로세스를 실행한다. 컴퓨터의 전송 계층은 네트워크 계층으로부터 데이터를 받아, 이 네 프로세스 중 하나로 받은 데이터를 전달한다.
프로세스는 하나 이상의 소켓을 가질 수 있다. 따라서 아래 그림과 같이, 호스트의 저송 계층은 사실 받은 데이터를 직접 프로세스로 전달하는 게 아니라, 그 사이의 소켓으로 전달하는 것이다. 수신 호스트는 하나 이상의 소켓을 가지므로, 각 소켓에는 이를 식별하기 위한 식별자가 필요하다. 식별자의 형식은 소켓이 UDP 소켓인지, TCP 소켓인지에 따라 달라질 수 있다.
이제 수신 호스트가 어떻게 받은 전송 계층 세그먼트를 적절한 소켓으로 전달하는지에 대해 알아보자. 각 전송 계층 세그먼트는 이를 위한 필드 집합을 가지고 있다. 수신 호스트의 전송 계층은 이 필드를 분석해 수신 소켓을 식별하고 세그먼트를 해당 소켓으로 전달한다. 이렇게 전송 계층 세그먼트를 올바른 소켓으로 전달하는 작업을 가리켜 디멀티플렉싱(demultiplexing)이라 한다. 반대로, 소스 호스트의 여러 소켓에서 데이터 청크를 모아 각 청크를 헤더 정보와 함께 캡슐화해 세그먼트를 생성하고, 이렇게 생성된 세그먼트를 네트워크 계층으로 내려보내는 작업은 멀티플렉싱(multiplexing)이라 한다.
위 그림에서 볼 수 있듯, 두 종단 호스트에 사이에 있는 중간 호스트의 전송 계층의 경우, 프로세스 P1 또는 P2 아래에 있는 네트워크 계층에 도달하는 세그먼트들을 디멀티플렉싱해야 하며, 각 프로세스의 소켓으로부터 나가는 데이터를 취합해 전송 계층 세그먼트를 만들고, 이를 네트워크 계층으로 내려보내야 한다.
이제 전송 계층의 멀티플렉싱 & 디멀티플렉싱이 호스트 내에서 어떻게 실제로 이루어지는지에 대해 분석해보자.
우선 전송 계층 멀티플렉싱에는 (1) 고유 식별자를 가지고 있는 소켓이 있어야 하고 (2) 각 세그먼트는 자신이 전달되어야 할 소켓을 가리키는 특수한 필드를 가지고 있어야 한다. 이 특별한 필드에는 소스 포트 번호 필드(source port number field)와 목적지 포트 번호 필드(destination port number field)가 있다. 각 포트 번호는 0~65535에 해당하는 16-비트 숫자로, 0~0123의 포트 번호는 HTTP나 FTP와 같은 애플리케이션 프로토콜들을 위해 예약된 포트 번호들이다.
디멀티플렉싱 서비스의 경우 (1) 호스트 내의 각 소켓에는 포트 번호가 할당되어야 하고, (2) 세그먼트가 호스트에 도달했을 때, 전송 계층은 세그먼트 안의 목적지 포트 번호를 분석해, 해당 세그먼트를 적절한 소켓으로 연결시킨다. 이 세그먼트의 데이터는 해당 소켓을 통해 프로세스로 전달된다.
이전에 만들었던 UDPClient.java
파일이다.
//UDPClient.java
public void run(){
try (DatagramSocket socket = new DatagramSocket(); BufferedReader br = new BufferedReader(new InputStreamReader(System.in))) {
//...
}
}
UDP 소켓이 만들어지면, 전송 계층은 자동으로 해당 소켓에 포트 번호를 할당한다. 구체적으로 말하자면, 전송 계층은 1024~65535의 포트 번호 중, 호스트 내에서 사용되지 않는 UDP 포트들 중 하나를 할당한다.
//DatagramSocket.class
public DatagramSocket() throws SocketException {
this((SocketAddress)(new InetSocketAddress(0)));
}
public DatagramSocket(SocketAddress bindaddr) throws SocketException {
this(createDelegate(bindaddr, DatagramSocket.class));
}
//InetSocketAddress.class
public InetSocketAddress(int port) {
this(InetAddress.anyLocalAddress(), port);
}
DatagramSocket
의 기본 생성자를 보면 사실 0번 포트를 쓰고 있는 것임을 알 수 있는데, 0번 포트는 애플리케이션에서 사용할 수 있는 소스 포트 번호를 동적으로 요청할 때 사용한다.
혹은 특정 포트 번호를 지정할 수도 있는데, 생성자에 사용하고자 하는 포트 번호를 인자로 넘겨 주거나, 소켓을 생성한 후 bind()
메서드를 사용할 수 있다.
//DatagramSocket.class
public DatagramSocket(int port) throws SocketException {
this(port, (InetAddress)null);
}
public DatagramSocket(int port, InetAddress laddr) throws SocketException {
this((SocketAddress)(new InetSocketAddress(laddr, port)));
}
public void bind(SocketAddress addr) throws SocketException {
this.delegate().bind(addr);
}
//DatagramSocket을 만든 후 `bind()`를 통해 특정 포트 번호(41210번)를 지정
try (DatagramSocket socket = new DatagramSocket(null);) {
//...
socket.bind((SocketAddress) new InetSocketAddress((InetAddress) null, 41210));
//...
잘 알려진 프로토콜을 사용하는 애플리케이션의 서버 사이드를 구현하는 개발자의 경우, 해당 프로토콜에 맞는 잘 알려진 포트 번호를 사용해야 한다. 보통 애플리케이션의 클라이언트 사이드는 전송 계층이 자동으로 포트 번호를 지정하게 하는 한편, 서버 사이드의 경우 특정 포트 번호를 할당한다.
UDP 소켓에 포트 번호가 할당되었으니, 이제 UDP 멀티플렉싱/디멀티플렉싱이 정확히 어떻게 이루어지는지 알아보자. 호스트 A의, 19157번 UDP 포트를 사용하는 프로세스가 애플리케이션 데이터 청크를 호스트 B의 46428번 UDP 포트를 사용하는 프로세스로 보내고 싶어한다고 하자. 호스트 A의 전송 계층은 애플리케이션 데이터, 소스 포트 번호(19157), 목적지 포트 번호(46428), 그리고 그 외의 다른 두 값을 포함하는 전송 계층 세그먼트를 만들어 네트워크 계층으로 내려보낸다.
네트워크 계층은 세그먼트를 IP 데이터그램으로 캡술화하고, 세그먼트를 수신 호스트로 전달하기 위해 최선을 다 한다. 만약 세그먼트가 수신 호스트 B에 도달하면, 수신 호스트의 전송 계층은 세그먼트 안의 목적지 포트 번호를 분석하고, 해당 세그먼트를 해당 포트로 식별되는 소켓으로 전달한다.
UDP 소켓의 경우 목적지 IP 주소와 목적지 포트 번호를 통해 식별된다. 따라서 서로 다른 소스 주소/포트를 가지고 있는 두 UDP 세그먼트가 같은 목적지 IP 주소/포트를 가지고 있다면, 두 세그먼트는 같은 목적지 소켓의, 같은 프로세스로 연결된다.
그렇다면 소스 포트 번호는 왜 필요할까? 소스 포트 번호는 서버에서 클라이언트로 응답을 보낼 때 쓰인다. 이전에 썼던 UDPServer.java
의 코드를 보자. 패킷을 받아 클라이언트의 주소 및 포트 번호를 추출한 후, 데이터를 처리해 응답 메시지를 해당 주소/포트로 보내고 있다.
//UDPServer.java
public void run(){
//...
while (true){
//...
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);
}
}
//...
}
TCP 디멀티플렉싱의 경우, TCP 소켓과 TCP 연결 수립에 대해 깊게 살펴볼 필요가 있다. TCP 소켓과 UDP 소켓의 차이 중 하나는, 목적지 IP 주소/목적지 포트 번호의 튜플 식별되던 UDP 소켓과 달리, TCP 소켓은 소스 IP 주소/소스 포트 번호/목적지 IP 주소/목적지 포트 번호의 튜플로 식별된다는 것이다. TCP에서는 세그먼트가 호스트에 도착했을 때, 이 네 값을 모두 이용해 세그먼트를 적절한 소켓으로 전달한다.
다시 말해 UDP와 달리 TCP에서는, 호스트에 도착한, 서로 다른 소스 IP 주소나 소스 포트 번호를 가지고 있는 세그먼트들은 서로 다른 소켓으로 연결된다.
이전에 작성했던 TCP 클라이언트-서버 코드를 다시 살펴보자.
이전에 만들었던 TCPClient.java
코드를 보자.
//TCPClient.java
Socket socket = new Socket(InetAddress.getByName(serverHost), serverPort);
Socket
의 생성자들을 보자. this.connect(address)
부분에서 TCP 연결이 이뤄진다.
public Socket(InetAddress address, int port) throws IOException {
this(address != null ? new InetSocketAddress(address, port) : null, (SocketAddress)null, true);
}
private Socket(SocketAddress address, SocketAddress localAddr, boolean stream) throws IOException {
this.created = false;
this.bound = false;
this.connected = false;
this.closed = false;
this.closeLock = new Object();
this.shutIn = false;
this.shutOut = false;
this.setImpl();
if (address == null) {
throw new NullPointerException();
} else {
try {
this.createImpl(stream);
if (localAddr != null) {
this.bind(localAddr);
}
this.connect(address);//해당 포트를 이용해
} catch (IllegalArgumentException | SecurityException | IOException var7) {
Exception e = var7;
try {
this.close();
} catch (IOException var6) {
IOException ce = var6;
e.addSuppressed(ce);
}
throw e;
}
}
}
//TCPServer.java
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());
}
}
ServerSocket serverSocket
은 12000번 포트 번호를 사용하고 있는, 애플리케이션의 환영 소켓이다. 서버 프로세스는 이곳으로 연결 요청이 들어오면 serverSocket.accept()
를 통해 새로운 연결 소켓을 만든다.
위 그림에서는 호스트 C가 서버 B와 두 개의 HTTP 세션을 시작하고, 호스트 A는 서버 B와 하나의 HTTP 세션을 시작한다. 호스트 A, C와 서버 B는 모두 각자 고유한 IP 주소를 가지고 있다. 호스트 C는 각 HTTP 연결에 서로 다른 포트 번호들(26145, 7532)을 할당하고 있다. 호스트 A 또한 HTTP 연결에 26145번 포트를 할당하고 있지만, 서버 B는 네 개의 값(소스 포트/IP, 목적지 포트/IP)를 바탕으로 각 연결을 성공적으로 디멀티플렉싱할 수 있다.
마지막으로 웹 서버가 어떻게 포트 번호를 사용하는지를 보자. 예컨대 한 호스트가 80번 포트에서 웹 서버를 실행하고 있다고 하자. 클라이언트가 이 서버로 세그먼트를 보낼 때, 모든 세그먼트들은 80번의 목적지 포트를 가진다. 정확히 말하자면, 처음의 연결 수립 세그먼트는 물론 HTTP 요청 메시지를 가지고 있는 세그먼트들도 80번 목적지 포트를 가지고 있다. 서버는 이 세그먼트들을 소스 IP 주소와 소스 포트 번호를 통해 식별한다.
위 그림(Figure 3.5)에서 웹 서버는 각 연결마다 새로운 프로세스를 만들고 있고, 각 프로세스는 자신만의 연결 소켓을 가지고 HTTP 요청을 받아 응답을 보낸다. 하지만 연결 소켓과 프로세스가 항상 1:1 매칭이 되는 것은 아니다. 사실 오늘날의 고성능 웹 서버의 경우 하나의 프로세스에서, 각 클라이언트 연결마다 새로운 연결 소켓의 스레드를 만들어 사용한다. 앞서 구현한 서버는 이와 같은 방식(한 프로세스에 여러 연결 소켓을 만드는 방식. 멀티스레드로 구현된 것은 아님)으로 동작하며, 이러한 서버에서는 한 서버 프로세스가 여러 연결 소켓을 가지게 된다.
만약 클라이언트와 서버가 지속적 HTTP 연결을 사용하는 경우, 연결이 지속되는 동안 클라이언트와 서버는 같은 서버 소켓을 통해 HTTP 연결을 맺는다. 하지만 만약 클라이언트와 서버가 비-지속적 HTTP를 사용하는 경우, 각 요청/응답 마다 새로운 TCP 연결이 생기고 끊어지며, 이후의 요청/응답에는 새로운 소켓이 만들어진다. 이렇게 자주 소켓을 만들고 닫는 것은 웹 서버 성능에 큰 영향을 준다.