서버 개발자의 본질은 네트워크를 통해 클라이언트의 요청을 수신하고 이에 대한 응답을 제공하는 애플리케이션을 만드는 데 있습니다. 오늘날에는 수많은 추상화 덕분에 개발자가 네트워크 설정을 직접 다루지 않아도, 단순히 Controller라는 개념을 사용해 직관적인 코드만으로 HTTP 서버를 띄울 수 있게 되었습니다.
그러나 추상화는 본질적으로 동작을 은닉하고 이를 고수준의 언어로 포장합니다. 이 과정이 겹겹이 쌓이다 보니 서버, 통신, HTTP, 서블릿, 톰캣, 스프링, 스프링부트 등을 설명하는 글들은 오히려 불필요한 첨언과 혼란스러운 용어만 늘어나는 경우가 많습니다. 추상화 위에서 실질적 동작을 억지로 설명하려다보니 무슨 말을 하는 지 모르겠는 글들을 참 많이 봤습니다.
이번 시리즈에서는 Socket API에서 출발해 ServletAPI, Tomcat, Spring Web MVC, Spring Boot까지 단계를 차근차근 밟아 올라가며, 실제 동작 원리와 엄밀한 개념을 바텀업 방식으로 살펴보고자 합니다.
시리즈에 사용된 전체 소스코드는 아래에서 확인하실 수 있습니다.
Socket API
소켓은 프로세스간 통신에서 사용되는 프로그래밍 인터페이스입니다.
네트워크 공부하며 보셨을 계층형 네트워크 모델입니다. 4계층 아래의 로직은 OS 커널과 네트워크 인터페이스 카드(NIC) 단에서 이루어지죠.
서버 개발자는 결국 서버 프로그램을 만듭니다. 이는 OS 입장에서는 프로세스죠. 서버에서 클라이언트와 통신을 하는 건 결국 OS 입장에서는 프로세스간 통신이 되겠습니다.
이 통신을 제어하기 위해서는 유저 애플리케이션에서 네트워크 통신을 제어할 수 있어야 하고, 커널에서 처리하는 전송 계층 및 네트워크 스택을 유저 모드에서 사용할 수 있도록 시스템 콜을 제공합니다.
다만 유저가 저수준에서 4계층 프로토콜을 제어하는 것은 과한 복잡성을 가져옵니다. 이를 쉽고 표준화된 인터페이스로 사용할 수 있도록, 버클리 대학교에서 1983년 4.2BSD(Berkeley Software Distribution) UNIX 운영체제에 Socket API를 도입합니다.

위와 같은 함수들로 유저는 C언어를 통해 4계층 통신을 제어할 수 있게 됩니다.
socket()

int socket(int domain, int type, int protocol); 을 호출하면 해당 엔드포인트에 대한 파일 디스크립터를 return 합니다. 소켓 API의 가장 특이한 점은 프로세스간 통신 == 소켓 을 파일 디스크립터로 추상화한 것입니다. 파일을 다룰 때 사용하던 open , read, write, close 등을 소켓 통신에서도 그대로 활용할 수 있게하여 애플리케이션 입장에서는 단지 디스크립터에 바이트 스트림을 쓰는 동작만 수행하면 됩니다.
domain을 통해 3계층 주소 패밀리를 무엇을 쓸 건 지(IPv4 / IPv6), type으로 무엇을 주고 받을건지 (바이트 스트림, 데이터그램, raw), protocol을 통해 프로토콜 (TCP/UDP/RawIP)을 설정할 수 있습니다.
bind()

bind는 소켓에 주소, 즉 IP주소와 포트를 바인딩합니다. 소켓이 어떤 로컬 IP와 포트를 사용할지를 운영체제에 알려주는 것이라 볼 수 있습니다. 여기서 IP주소는 소유한 NIC의 IP입니다.
주로 서버 애플리케이션에서 사용합니다.
포트는 소켓에 바인딩됩니다. 따라 말하면 모든 프로세스가 갖는 것은 아니다 라는 것을 알 수 있습니다. 또한 한 프로세스가 여러 소켓을 열어 여러 포트를 바인딩 할 수도 있겠죠.
connect()

connect는 연결지향적인 통신을 할 때 커넥션을 만들어 주는 것으로, 즉 TCP 클라이언트에서 사용하는 함수로 3-way handshaking을 담당합니다. 연결을 맺지 않는 UDP 소켓에선 쓰지 않습니다.
인자의 주소는 연결을 맺을 대상의 주소입니다. 로컬 주소는 제공하지 않습니다. 즉 OS가 알아서 부여합니다.

TCP 통신 과정의 패킷을 까보면 클라이언트 측 주소는 54714과 같은 남는 포트 값을 OS가 채워주는 것을 확인할 수 있습니다.
listen()
listen은 클라이언트로 부터 소켓을 받을 준비를 합니다. 이때부터 클라이언트의 connect 요청을 받을 수 있게 되며 핸드셰이킹이 완료된 통신들은 Accept Queue에서 대기하며 accept 함수를 호출할 때 까지 대기합니다. backlog 값을 통해 이 큐의 값을 지정할 수 있습니다.
accept()

accept()는 Accept Queue에서 대기 중인, 완전히 연결이 수립된 클라이언트를 꺼내오는 함수입니다. 큐에서 연결을 성공적으로 꺼내오면, accept()는 통신을 위한 새로운 소켓을 생성하여 반환합니다. 블로킹 함수로 연결이 없는 경우 대기합니다.
연결을 위해 대기하던 소켓과는 다른 소켓으로 같은 포트를 지니나 클라이언트 측 src_ip, src_port를 추가로 지니며 특정 클라이언트와의 일대일 통신 채널이라고 생각하시면 되겠습니다.
send / recv
데이터를 주고 받는 함수입니다.
앞서 본 연결과 관련된 함수들과는 독립적입니다. send와 sendTo 혹은 recv와 recvFrom이 있는데 차이는 주소 구조체를 받냐 안받냐 입니다. 연결을 맺어 놓은 상태(TCP) 면 목적지 명시 없이 send/recv 호출을 통해 연결해둔 목적지로 통신이 가능합니다.
UDP처럼 비연결성 소켓은 앞선 메서드 호출 없이 sendTo로 그냥 대상을 명시하고 데이터를 바로 보내고 받습니다.
자바에서의 Socket
앞서 BSD의 Socket API 명세들을 살펴봤는데, API 자체가 절차지향 기반이라 객체지향에 익숙한 자바 프로그래머들이 보면 조금 이해하기 힘든 감이 있을 수 있습니다. 서버단에서 필요한 소켓 기능과 클라이언트 단에서 필요한 소켓 기능, 연결이 필요할 때 사용할 소켓 기능 등 따로 응집 없이 메서드로 다 제공하고 있죠.
Java도 프로그래밍 언어 단에서 저 Socket API를 이용해 4계층 미만 통신을 제어할 수가 있는데, Java의 객체지향 철학에 맞추어 기능들을 분할하고 응집해놓았습니다.
TCP측 구현체로는 java.base 모듈 java.net 패키지에 Socket 클래스와 ServerSocket 클래스가 있습니다. (UDP는 DatagramSocket 클래스가 따로 존재합니다)
Socket.java

A socket is an endpoint for communication between two machines. The actual work of the socket is performed by an instance of the SocketImpl class.
자바 프로그래머가 실제로 사용하는 클래스에 해당되는 Socket class 입니다. 앞서 살펴보았던 Socket API 에서 서버 측에서만 사용하는 특수한 기능(bind(), listen()) 등의 기능이 차치된 클래스라고 보시면 좋겠습니다.
주석을 보면 Impl class에서 실질적 동작을 처리하고 있다고 하는데, SocketImpl이 특정 인터페이스를 implements 해서 Impl이라고 네이밍 한 것은 아니고 브릿지(Bridge) 디자인 패턴의 한 형태로 구현된 것입니다.
Socket 클래스는 개발자에게 일관된 고수준 API를 제공하는 역할만 하도록 깔끔하게 남습니다. Impl 클래스에서 네이티브 소켓 기능과 직접 통신하는 저수준의 실제 작업 맡아 처리합니다.


주로 직접 생성 시 NioSocketImpl을 사용합니다. ServerSocket과 Socket 클래스 모두 동일하고 NioSocketImpl엔 JNI, native 키워드로 함수들이 정의되어 있으며 JDK 내부 C++ 함수를 실행하고, C++ 함수에선 운영체제에 맞는 Socket 관련 시스템 콜을 C Wrapper 함수로 호출 합니다.

생성자를 통해 Remote 프로세스와 연결을 맺습니다. connect() 호출까지 생성 시 수행합니다.



노출된 public 생성자를 보면 로컬 주소 바인드 로직도 보입니다. 여기서 주소에 해당하는 건 로컬 IP로 보통 NIC까지 세밀하게 제어를 하지 않으니 빈 값을 줍니다.





최종적으로 JNI를 통해 C++로 실제 SocketAPI의 bind()를 호출합니다. 로컬 포트가 없다면 여기서 시스템콜로 받아옵니다.
JNI가 무엇인지나 Java에서 어떻게 시스템콜까지 닿는 지는 생략하겠습니다. 주제는 조금 다른데 아래 글에서 좀 더 자세히 다루어서 참고하셔도 좋을 것 같네요.
connect() 로직을 통해 프로세스 간 연결이 성공하면, 소켓 API의 철학에 맞게 연결을 파일 디스크립터로 다뤘었습니다. Java도 마찬가지로 아랫단에선 파일 디스크립터를 지닙니다.
하지만 자바는 이러한 접근 방식을 그대로 사용하지 않습니다. 자바의 핵심 철학인 플랫폼 독립성과 객체지향적 추상화에 맞게, 운영체제에 종속적인 로우레벨의 파일 디스크립터를 개발자에게 직접 노출하지 않습니다.
대신 자바는 이 네트워크 연결을 InputStream과 OutputStream이라는 친숙한 객체로 감싸서 제공합니다.
구현은 실제 fd 객체를 활용합니다. 스트림에서는 은닉된 쓰레드 파킹 관련 로직도 수행합니다.
TCP Socket API의 객체화
이 소켓 클래스를 클라이언트 측 소켓이라고 설명하시기도 하던데 주로 클라이언트 측 소켓 통신 구현에 직접적으로 사용할 뿐이지 클라이언트용 소켓은 엄밀히는 아닙니다.
일반적인 TCP 통신에서의 소켓 파이프라인입니다.
Socket API에서 accept를 호출하면 통신을 위한 새로운 소켓을 생성하여 반환한다고 했었죠? 위의 Session Block을 보시면 각각의 local/remote IP&Port가 등록된 소켓이 클라이언트와 서버에 하나씩 존재하고 이를 통해 통신을 하는 걸 알 수 있습니다.
저 파이프라인엔 되게 특별한 소켓이 하나 있죠?
바로 서버에서 사용하는 리스너 소켓, 즉 bind(), listen()을 호출해서 클라이언트의 연결을 대기하고 accept()를 통해 실질적 세션용 소켓을 만드는 데 쓰이는 소켓 하나만 역할이 다릅니다.
Java에서는 이를 아예 별도의 클래스인 ServerSocket 클래스로 분리해두고 그 외의 모든 기능은 Socket 클래스에 넣어 두었습니다.
ServerSocket.java

바로 이 클래스입니다. 이름만 보면 Socket을 상속받았을 것 처럼 생겼지만 위계관계에서 전혀 별도의 class 인 것을 알 수 있습니다.

리스너 소켓에 해당하기에 bind(), listen(), accept()의 책임을 집니다.
생성자에서 C단의 bind와 listen 호출에서 사용되던 파라미터를 객체지향적으로 생성자에서 받고 있습니다. Impl 생성 로직은 일반 Socket과 동일해 기본적으로 NioSocketImpl을 사용합니다.

ServerSocket도 bind()함수는 동일하게 존재하나 listen() 이 합쳐진 상태로 존재합니다. 따라서 인자로 backlog도 받습니다. 따라 자바 프로그래머는 소켓을 다룰 때 listen API 자체는 명시적으로 호출할 일이 없죠.
(bind -> listen) -> (loop) accept를 수행해야하는 리스너 소켓이니 굳이 listen이라는 저수준 동작은 노출하지 않았습니다.

SocketAPI 명세와 동일하게 accept()호출 시 클라이언트와 새로운 소켓을 생성하여 리턴합니다.

타고타고 가면 나오는 native accept의 Unix 쪽 구현체입니다. Socket API accept함수를 호출하고 있고, 앞서 살펴 봤듯 native socket api의 accept 함수가 블로킹 함수여서 결국 자바에서 accept를 호출하면 현재 스레드를 블로킹합니다.
try (ServerSocket server = new ServerSocket(8080)) {
while (true) {
try (Socket socket = server.accept();
BufferedReader in = new BufferedReader(new InputStreamReader(socket.getInputStream()));
PrintWriter out = new PrintWriter(socket.getOutputStream(), true)) {
out.println("Hello " + in.readLine());
}
}
}
이런식으로 서버 소켓을 활용할 수 있습니다. 어차피 accept는 블로킹 함수이니 while문을 돌려줍니다.
다만 이제 저렇게 처리하면 서버가 연결을 1개만 받을 수 있겠죠? 저기서 클라이언트와 연결되어 생긴 새 소켓을 다른 스레드에서 처리해주고 리스너 소켓은 다시 요청을 대기하면 가장 기본적인 형태의 웹서버가 되겠네요.
SW 서버 개발자의 바운더리 안에서는 가장 low level에 해당하는 Socket API와 Java측 구현에 대해 알아보았습니다. 앞으로 이 시리즈에서 Socket을 활용한 TCP 서버를 구축하고, 이후 HTTP 프로토콜을 소켓을 활용해 구현하고, 서블릿, 톰캣, 스프링까지 차례로 올라가면서 어떻게 Spring MVC에서 서버 프로세스가 올라가는 지 바텀 업으로 확인해보겠습니다.
