새소식

인기 검색어

개발일기

FD는 뭐고 TCP/IP 소켓은 뭐지?

  • -

궁금증이 생긴 이유

docker 위에 db를 띄우고 spring 애플리케이션을 실행시킨 상황에 lsof 명령어를 수행했다.

 

그런데 이때 예상하기론 mysql ps 정보만 떠야하는데, 그것 외에 여럿 많은 정보를 볼 수 있었다.

 

mysql과 java의 연결, 그리고 개수가 10 * 2인 걸 봐서 대충 connection pool이겠거니 싶었다. (Hikari CP의 default connection 수는 10개. 클라이언트 -> 서버 / 서버 -> 클라이언트 스레드가 각 존재해야 하므로 * 2)

 

그런데 localhost 다음에 보이는 포트 번호는 뭐지? 하는 궁금증이 들었다. 나는 분명 3306 포트로 mysql을 띄웠는데 왜 처음보는 포트들이 등록되어 있고, 또한 왜 각자 다른 포트들이 등록되어 있나 궁금했다.

 

분명 mysql 포트는 3306으로 띄워져 있는데 여러 포트로 띄워져 있는거지? 내부에서 포트 포워딩을 하는 건가? 하는 생각이 들어 궁금증을 해소할 겸 여기 보이는 여러 정보들을 조사해 보았다.

 

 

Q1. FD는 무엇이고 왜 다 다를까?

 

FD는 File Descriptor(파일 디스크립터)의 약자이다. Unix 시스템에서 모든 것을 파일이라고들 하는데, 일반 정규파일 외에도 디렉토리, 소켓, 파이프, 블록 디바이스, 캐릭터 디바이스 등 모든 객체들을 파일로 관리된다.

 

프로세스에서 이 파일들에 접근하기 위해서는 파일명으로 접근하는데, 이게 너무 길기 때문에 간편하게 접근하기 위한 포인터(혹은 alias)로 FD를 사용하는 것이다.

 

프로세스를 생성하면 기본적으로 0(stdin, 표준 입력), 1(stdout, 표준 출력), 2(stderr, 표준 에러)를 할당받는데, 이는 표준 스트림이라고 부르며, Buffered 방식에 해당한다.

 

Q1-1.그렇다면 표준 스트림 또한 파일이라는 이야기인가? 뭔가 소켓 / 파이프가 하는 기능 같은데?

 

위에서 말했듯이, Unix에서는 모든 것이 파일이다.

 

Q1-2.그렇다면 0, 1, 2에 대한 소켓을 모든 프로세스들이 공유하는 건가?

 

각 프로세스들은 자신만의 FD 테이블을 하나씩 갖는다.

 

출처 : https://jidokhada.tistory.com/5

 

위 그림은 FD를 잘 표현하고 있어 가져왔다. open() 시스템콜이 호출되면 system file table에 entry가 하나씩 생기며, 원하는 파일에 대한 inode를 포인팅한다. 여기에 대한 참조를 새로 발급된 FD로 알 수 있다.

 

inode는 실제 file에 대한 metadata가 저장되어 있기 때문에 inode를 알고 있으면 그 파일에 접근할 수 있다는 걸 의미한다.

 

다시 lsof 그림을 보자. 여기서 FD가 가리키는 숫자는 FD 번호를 의미하고, u는 read + write임을 의미한다.

 

 

Q1-3. 프로세스에 대한 FD는 왜 필요한 걸까? PID로 확인하면 되는 것 아닌가?

 

위에서 말했듯, Unix에서 모든 것은 파일이다. 해당 프로세스의 FD를 볼 수 있는 것은 시스템 모니터링에 큰 도움을 받을 수 있다.

 

가령 프로세스에서 파일을 참조하면 reference count라는 게 올라가는데, 한 프로세스에서 특정 파일을 삭제했을 때 reference count가 0이 아니면 파일 시스템에선 ls로 조회할 순 없지만 실제로는 남아있는 현상이 발생할 수 있다.

 

이때 lsof를 사용해서 누가 쓰고 있는지 조회하는 데 사용할 수 있다.

- [Linux] lsof 명령어 사용법 : https://dev.plusblog.co.kr/44

 

Q2. Name에 있는 포트번호는 왜 다 다를까?

 

이건 나의 네트워크에 대한 지식이 부족해서 생긴 의문이다. 내 생각에 포트 번호는 프로세스별로 하나씩 존재한다고 생각했다. 왜냐하면 Spring 애플리케이션 띄울 때 포트 지정하면 그거 하나로만 뜨니까 ^_^;;;

 

이번 기회에 TCP/IP 소켓 관점에서 서버 - 클라이언트 간 connection 맺는 과정에 대해서 공부를 하게 되었다.

 

이 과정에서 TCP/IP 소켓 프로그래밍에 대해 아주 쉽게 중요한 부분만 잘 정리한 글을 발견했다.

- 소켓 프로그래밍. (Socket Programming) : https://recipes4dev.tistory.com/153

 

간단히 정리해 보자면, 데이터 송수신시 client와 server 간 socket이란 것을 생성해야 한다.

 

1. client에서는 원하는 데이터를 얻고싶을 때 socket을 생성해서 데이터를 가지고 있는 host(IP)와 프로세스(port)를 기술해서 요청한다.

2. server측에서는 문지기(socket)가 존재하는데, 손님(client의 요청)을 기다리고(listen) 있다.

3. 요청이 들어오고 요청이 받아들여질 수 있는 것인지 확인 후(3-way handshake), 입뺀시키지 않고 입장(accept)시킨다. 단, 테이블 담당 웨이터 (accept() 요청 후 생성된 소켓) 를 생성한다. 이제 손님의 요청은 테이블 담당 웨이터가 받고, 문지기는 다시 문을 지키러 간다.

4. 이제 client측에서 요청하면 server에선 요청 정보의 <src (ip) addr, src (socket) port, dst addr, dst port> 정보를 확인하고 , 애플리케이션으로 요청을 전달한다.

5. 애플리케이션에서 데이터를 만들고, 응답 정보의 < src (ip) addr, src (socket) port, dst addr, dst port > 정보를 확인하고 해당하는 소켓을 통해 client로 데이터를 전달한다.

6. 데이터 송수신이 끝나면 담당 웨이터에게 정리하라 한다. (close)

 

이런 식으로 동작하는데, 나의 경우에는 TCP/IP 소켓 개념도 잘 모르고 있었고 client socket에는 port num이 무작위(정확히는 나름의 공식이 있겠지만)로 정해진다는 걸 몰랐기 때문에 잘못 알았던 것이다.

 

생각해 보면 당연하긴 하다. server 측에서 8080포트로 listen한다고 해서 client socket에서 8080 포트로 client socket을 만들면, client 측에서는 8080 port 애플리케이션을 못띄우지 않을까...?

 

Q2-1. accept()로 만든 data send용 socket의 port num은 어떻게 될까?

 

TCP spec(RFC-793)을 보면 다음과 같은 정의가 있다.

 

Multiplexing:
To allow for many processes within a single Host to use TCP communication facilities simultaneously, the TCP provides a set of addresses or ports within each host.
Concatenated with the network and host addresses from the internet communication layer, this forms a socket. 
A pair of sockets uniquely identifies each connection.
That is, a socket may be simultaneously used in multiple connections.

- RFC 793 : https://datatracker.ietf.org/doc/html/rfc793

 

TCP 서버는 주소와 포트 번호로 이루어진 소켓을 만들고, 이 소켓은 여러 요청에 대해서 동시에 처리할 수 있어야 한다. 즉, 하나의 소켓은 여러 연결에서 동시에 사용될 수 있어야 한다.

 

라는 말인데, 위에서 보는 소켓 프로그래밍에선 server socket이 connection 수립용 socket과 데이터를 송신하는 socket으로 분리하는 것을 볼 수 있다.

 

여하튼 위 정의 대로라면 논리적으로 봤을 때 socket은 client와 맵핑되어야 하는 것이므로, 생성된 send용 socket도 accept용 socket과 같은 port num을 가져야 한다.

 

이 내용은 다음 영상에서 잘 다뤄준다.

-  [2부] 프로토콜 표준과는 다르게 실제로는 소켓(Socket)이 어떻게 식별되는가? : https://www.youtube.com/watch?v=WwseO8l8rZc

 

이건 내 생각인데, 이렇게 분리된 이유 자체는 Unix 소켓에서 send와 recv 시스템콜이 모두 blocking이라서 소켓을 분리해 멀티 스레드로 동작시키지 않으면 blocking하는 동안 다른 요청들을 처리하지 못하기 때문에 소켓을 분리한 것 아닐까? 하는 생각이다.

 

이렇게 하면 인터페이스도 지킬 수 있고, 성능적인 측면에서도 잘 잡아낼 수 있다.

 

또한, send용 socket 생성시 놀고 있는 Server port num을 사용할 경우, port num의 범위는 0 ~ 65535이기 때문에 소켓을 저 범위 안에서만 만들 수 있을 것이기 때문에 수많은 사용자를 받지는 못할 것이다.

 

Q2-2. socket을 매번 생성하면 스레드를 할당하니 그것도 비용일텐데, socket pool을 생성해놓고 갖다 쓰면 안되나?

 

물론 socket을 생성하려면 src(client) 정보가 필요하다. 따라서 client와 server간 서로 아는 상태에서만 쓸 수 있을 것이다.

 

가만 생각해보니 낯이 익다. connection pool이 있다. 생각해 보니 socket만 재활용하는 것이 아니라, connection 자체를 재활용하는 것이 훨씬 비용을 아낄 수 있다.

 

그런데 찾아보니 socket pool을 사용하는 경우도 있다. DNS에서 사용하는 것 같은데, 보안과 관련된 용도로 사용하는 것 같다. 이게 TCP/IP socket인지도 의문이다.

 

Q2-3. 만약 비동기 시스템콜을 사용하면 소켓을 하나만 뚫어놓고 사용할 수 있나?

 

이걸 찾아보다가 socket에도 2가지 모드가 있음을 발견하였다. blocking socket과 non-blocking socket이 존재하는데, non-blocking socket을 사용할 경우 하나의 스레드에서도 동작할 수 있는 것 같다.

 

간단하게 보았을 때, 이벤트 루프를 계속 돌면서 fd를 확인하며 변화가 있는지 확인하는 구조로 동작하는 것 같다.

 

이때는 기존의 시스템콜이 아닌 select(), epoll(), poll(), accept() 과 같은 시스템콜을 사용하는 것 같은데, 이 부분은 파려면 각 잡고 시간을 많이 쏟아야 하는 것 같아 나중으로 미루었다.

 

Q2-4. Java에선 소켓 수립 과정 어떻게 구현하나?

 

예제 프로젝트들도 좋지만 기왕이면 실제 프로젝트 코드를 보면 좋을 것 같아서 Netty의 Nio socket 부분을 보았다.

 

netty의 NioServerSocketChannel 소스를 살펴보았는데 별다른 최적화는 존재하지 않는 것 같다.

 

새 채널 생성시

  // NioServerSocketChannel.java
    private static ServerSocketChannel newChannel(SelectorProvider provider, InternetProtocolFamily family) {
        try {
            ServerSocketChannel channel =
                    SelectorProviderUtil.newChannel(OPEN_SERVER_SOCKET_CHANNEL_WITH_FAMILY, provider, family);
            return channel == null ? provider.openServerSocketChannel() : channel;
        } catch (IOException e) {
            throw new ChannelException("Failed to open a socket.", e);
        }
    }

 

 

 

요청시 - nio

// NioSocketChannel.java
    @Override
    protected boolean doConnect(SocketAddress remoteAddress, SocketAddress localAddress) throws Exception {
        if (localAddress != null) {
            doBind0(localAddress);
        }

        boolean success = false;
        try {
            boolean connected = SocketUtils.connect(javaChannel(), remoteAddress);
            if (!connected) {
                selectionKey().interestOps(SelectionKey.OP_CONNECT);
            }
            success = true;
            return connected;
        } finally {
            if (!success) {
                doClose();
            }
        }
    }

 

요청시 - oio

 

 // AbstractEpollChannel.java
     protected boolean doConnect(SocketAddress remoteAddress, SocketAddress localAddress) throws Exception {
        if (localAddress instanceof InetSocketAddress) {
            checkResolvable((InetSocketAddress) localAddress);
        }

        InetSocketAddress remoteSocketAddr = remoteAddress instanceof InetSocketAddress
                ? (InetSocketAddress) remoteAddress : null;
        if (remoteSocketAddr != null) {
            checkResolvable(remoteSocketAddr);
        }

        if (remote != null) {
            // Check if already connected before trying to connect. This is needed as connect(...) will not return -1
            // and set errno to EISCONN if a previous connect(...) attempt was setting errno to EINPROGRESS and finished
            // later.
            throw new AlreadyConnectedException();
        }

        if (localAddress != null) {
            socket.bind(localAddress);
        }

        boolean connected = doConnect0(remoteAddress);
        if (connected) {
            remote = remoteSocketAddr == null ?
                    remoteAddress : computeRemoteAddr(remoteSocketAddr, socket.remoteAddress());
        }
        // We always need to set the localAddress even if not connected yet as the bind already took place.
        //
        // See https://github.com/netty/netty/issues/3463
        local = socket.localAddress();
        return connected;
    }

 

한 가지 신경 쓰이는 점은 처음 예상했던 바는 oio는 blocking socket, nio는 non-blocking socket으로 돌아갈줄 알았다. 실제로도 nio의 경우 channel이라는 단어가 자주 나왔고, oio에선 언급되지 않았다.

 

그런데 oio 구현체를 따라가던 중 AbstractEpollChannel에서 doConnect를 받아옴을 확인할 수 있었는데 여기서 epoll은 fd에 상태를 기록해 놓고 확인하는 비동기 방식인 걸로 알고 있는데... 아마 oio도 non-blocking하게 동작하는 것 같다.

 

더 자세히 파보다간 2-3과 같은 이유로 주화입마에 걸릴 것 같아서 우선 제쳐두고 nio와 epoll부터 공부하고 다시 들여다 봐야겠다.

 

결론

FD와 TCP/IP 소켓에 대하여 공부해서 궁금증을 해결할 수 있었다.

 

FD는 파일에 대한 참조이고, TCP/IP 소켓은 커넥션을 생성할 때 사용된다.

 

non-blocking socket 부분에 대해서는 더 깊게 공부하지 않았는데, 비동기에 대해 공부해야 할 때가 되면 이때부터 다시 공부해 보면 좋을 것 같다.

 

 

참조

- 소켓 프로그래밍. (Socket Programming) : https://recipes4dev.tistory.com/153

- RFC 793 : https://datatracker.ietf.org/doc/html/rfc793

- [2부] 프로토콜 표준과는 다르게 실제로는 소켓(Socket)이 어떻게 식별되는가? : https://www.youtube.com/watch?v=WwseO8l8rZc

- [Linux] lsof 명령어 사용법 : https://dev.plusblog.co.kr/44

- [소켓] 블록킹,논블록킹,동기 I/O,비동기 I/O(Overlapped I/O) : https://marmelo12.tistory.com/287

- SSS-1_2. UNIX 시스템 소개 및 구조 : https://jidokhada.tistory.com/5

Contents

포스팅 주소를 복사했습니다

이 글이 도움이 되었다면 공감 부탁드립니다.