http와 소켓의 관계를 살펴보자

dominic·2022년 8월 1일
0

최근 스터디 모임에서 주소창에 www.naver.com을 입력하면 일어나는 일에 대해 발표를 진행했다. http와 tcp를 연결하여 설명하는 과정에서 브라우저와 웹 서버는 각자의 소켓을 생성하고 연결을 한다는 내용을 언급했고 http는 연결을 유지하지 않는데 왜 연결지향인 소켓을 만드는지 질문을 받았다.

준비 과정에서 http, 소켓으로 검색한 내용을 봤을 때, 마치 http는 비연결지향이기 때문에 소켓을 사용하지 않는 것처럼 느껴지는 글들을 꽤 보았기에 충분히 오해가 생길 수 있다는 생각이 들었고 이 부분을 정리하기 위해 Tomcat의 코드를 활용하여 http 역시 tcp를 기반으로 동작하는 소켓 통신임을 확인하고자 한다. 내용을 진행하기 전에 소켓은 통신을 위한 인터페이스, http는 프로토콜임을 인지하자.

# http 통신 흐름

http는 3-way handshake(TCP Established) 후 요청/응답을 주고받고 4-way handshake(TCP Terminated)를 거치는 흐름을 갖는다. 1.0 버전의 경우 매 요청마다 연결 수립, 종료 과정을 거치며 1.1버전은 keep-alive 기능을 통해 세션을 일정 시간 유지할 수 있다.

출처: https://code-lab1.tistory.com/196?category=1269341

# 소켓이란?

소켓은 원격지에 존재하는 두 프로세스가 서로 통신을 하기 위한 인터페이스이다.

# 소켓 프로그래밍 흐름

클라이언트와 통신을 위해 서버는 다음 과정을 거친다. socket()을 통해 생성되는 소켓은 서버에서 연결 처리를 위해 생성하는 소켓이고 실제 클라이언트와 통신에 사용되는 소켓은 클라이언트의 연결이 발생하면 accept()를 통해 얻게 된다.

출처: https://sean.tistory.com/93

# 테스트 환경

포스팅 진행을 위해 진행했던 Spring Boot 프로젝트에서 디버깅을 통해 Tomcat 코드를 살펴보고 실제 송/수신되는 패킷은 와이어샤크를 통해 확인한다.

Tomcat의 소켓 생성 부분에 bp(break point) 설정을 하기 위해 우선 컨트롤러에서 응답을 리턴하는 코드에 bp를 걸고 리턴 이후 흐름을 추적한 후 socket 생성하는 코드를 확인할 수 있었다.

# Tomcat 코드 확인하기

먼저 소켓에 생성과 연결 준비 부분은 Tomcat의 NioEndpoint 클래스의 initServerSocket 메서드에서 확인할 수 있고 코드를 따라가면 실제 소켓을 관리하고 다루는 객체는 sun.nio.ch 패키지의 ServerSocketChannelImpl 객체임을 알 수 있다.

// https://github.com/apache/tomcat/blob/main/java/org/apache/tomcat/util/net/NioEndpoint.java
protected void initServerSocket() throws Exception {
        if (getUseInheritedChannel()) {
           ...
        } else {
            serverSock = ServerSocketChannel.open(); // 소켓 생성
            socketProperties.setProperties(serverSock.socket());
            InetSocketAddress addr = new InetSocketAddress(getAddress(), getPortWithOffset());
            serverSock.bind(addr, getAcceptCount()); // bind, listen
        }
        serverSock.configureBlocking(true); //mimic APR behavior
}

초기화를 거치면 Tomcat은 Acceptor 스레드(https://github.com/apache/tomcat/blob/main/java/org/apache/tomcat/util/net/Acceptor.java)를 만들고 루프를 돌면서 연결되는 세션에 대해 accept 처리를 한다. 이때 실제 accept 처리는 sun.nio.ch 패키지의 ServerSocketChannelImpl 클래스의 implAccept 메서드에서 native 함수인 Net.accept를 통해 처리된다.

// sun.nio.ch 패키지의 ServerSocketChannelImpl 클래스
public SocketChannel accept() throws IOException {
        int n = 0;
        FileDescriptor newfd = new FileDescriptor();
        SocketAddress[] saa = new SocketAddress[1];

        acceptLock.lock();
        try {
            boolean blocking = isBlocking();
            try {
                begin(blocking);
                n = implAccept(this.fd, newfd, saa);
                if (blocking) {
                    while (IOStatus.okayToRetry(n) && isOpen()) {
                        park(Net.POLLIN);
                        n = implAccept(this.fd, newfd, saa);
                    }
                }
            } finally {
                end(blocking, n > 0);
                assert IOStatus.check(n);
            }
        } finally {
            acceptLock.unlock();
        }

        if (n > 0) {
            return finishAccept(newfd, saa[0]);
        } else {
            return null;
        }
}

private int implAccept(FileDescriptor fd, FileDescriptor newfd, SocketAddress[] saa)
        throws IOException
    {
        if (isUnixSocket()) {
        ...
        } else {
            InetSocketAddress[] issa = new InetSocketAddress[1];
            int n = Net.accept(fd, newfd, issa); // 연결 요청이 발생하면 클라이언트와 연결된 소켓을 얻는다.
            if (n > 0)
                saa[0] = issa[0];
            return n;
        }
}

[참고] Acceptor 스레드를 통해 accept를 처리하는 이유는 accept가 블로킹되기 때문이다.

흐름이 Net.accept에 도달하면 스레드는 블로킹이 되고 다음 라인에 bp를 설정하고 브라우저에서 요청을 전달하면 연결이 수립되고 와이어샤크에서 3-way handshake 과정을 확인할 수 있다.

연결 종료의 경우 NioEndpoint 클래스의 timeout 메서드에서 확인할 수 있는데, 이 로직에서 일정 시간 요청을 기다리고 요청이 없으면 소켓을 종료할 수 있도록 처리한다.

protected void timeout(int keyCount, boolean hasEvents) {
	long now = System.currentTimeMillis();
    // This method is called on every loop of the Poller. Don't process
    // timeouts on every loop of the Poller since that would create too
    // much load and timeouts can afford to wait a few seconds.
    // However, do process timeouts if any of the following are true:
    // - the selector simply timed out (suggests there isn't much load)
    // - the nextExpiration time has passed
    // - the server socket is being closed
    if (nextExpiration > 0 && (keyCount > 0 || hasEvents) && (now < nextExpiration) && !close) {
    	return;
    }
    int keycount = 0;
    try {
    	for (SelectionKey key : selector.keys()) {
        	keycount++;
            NioSocketWrapper socketWrapper = (NioSocketWrapper) key.attachment();
            try {
            	if (socketWrapper == null) {
                	... 생략 ...
                } else if (close) {
                	... 생략 ... 
                } else if ((socketWrapper.interestOps() & SelectionKey.OP_READ) == SelectionKey.OP_READ ||
                                  (socketWrapper.interestOps() & SelectionKey.OP_WRITE) == SelectionKey.OP_WRITE) {
                	boolean readTimeout = false;
                    boolean writeTimeout = false;
                    // Check for read timeout
                    if ((socketWrapper.interestOps() & SelectionKey.OP_READ) == SelectionKey.OP_READ) {
                      	... 생략 ...
                   	}
                    	... 생략 ...
                            
                    // 이 흐름에서 소켓을 닫게 된다.
                    if (readTimeout || writeTimeout) {
                        key.interestOps(0);
                        // Avoid duplicate timeout calls
                        socketWrapper.interestOps(0);
                        socketWrapper.setError(new SocketTimeoutException());
                        if (readTimeout && socketWrapper.readOperation != null) {
                        	... 생략 ...
                        } else if (writeTimeout && socketWrapper.writeOperation != null) {
                        	... 생략 ...
                        } else if (!processSocket(socketWrapper, SocketEvent.ERROR, true)) {                            
                        	cancelledKey(key, socketWrapper);
                        }
                    }                        
                    ...
                }
            }
            ...
        }
}

종료 과정에서 발생하는 4-way handshake 또한 확인할 수 있다.

# http는 tcp 기반에서 소켓을 이용해 통신한다

코드를 살펴본 결과 Tomcat에서 소켓을 사용해 http 요청을 처리한다는 것을 알 수 있다. 다만 프로토콜의 특성에 따라 서버 측에서 요청을 처리 후 연결을 종료한다. 그러므로 http가 tcp 기반에서 소켓을 이용해 통신한다는 사실을 기억하자.

마지막으로 하나의 요청에 대해서 발생하는 패킷의 흐름을 살펴보고 내용을 마무리 한다.

1개의 댓글