모종닷컴

TCP 연결과 종료를 이해하고 코드와 패킷을 분석해보자 본문

Programming

TCP 연결과 종료를 이해하고 코드와 패킷을 분석해보자

모종 2022. 9. 3. 20:37
반응형

매번 느끼는 것이지만 작업을 하다 보면 몰랐던 것들이 너무 많습니다. 대학교 때 소켓 프로그래밍을 해봤지만 지금 다시 소켓 프로그래밍을 하려니 궁금한 것도 많고 그러네요. 오늘은 TCP handshake에 대해 알아보려고 합니다. 연결을 할 때 3 way handshake , 종료할 때는 4 way handshake로 하고 있습니다. 

TCP 연결 과정 - 3 way handshake

http://www.ktword.co.kr/test/view/view.php?m_temp1=1901&id=995

TCP 종료 과정- 4 way handshake

http://www.ktword.co.kr/test/view/view.php?no=2436

툴 준비

실제로 어플리케이션을 만들고 패킷을 분석해서 실제로도 위와 같은 동작을 하는지 보려고 합니다. 패킷을 탈취하고 분석하기 위해서는 몇 가지 툴이 필요합니다. 저는 가장 흔하게 사용되고 있는 tcpdump와 wireshark를 사용하려고 합니다. 설치는 어렵지 않습니다.

tcpdump 설치 (mac 기준)

brew install tcpdump

wireshark 설치 

https://www.wireshark.org/#download 페이지에 들어가서 macOS로 설치하시면 됩니다.

어플리케이션 코드 작성

코드를 최대한 간단하게 작성해보려고 했는데 블로그에 올리기에는 조금 양이 많아 보이기도 하네요. 크게 패키지는 서버, 클라이언트 공통 패키지로 나누었습니다.

공통 패키지

공통 패키지에는 상수를 저장하고 있는 ApplicationConstants를 담아놨습니다. 그리고 현업에서 주로 TCP를 사용한다면 대개 메시지를 전문길이를 나타내는 바이트와 실제 메시지를 붙여서 전송하는데요. 예를 들면 "0010HELLOWORLD" 같은 식으로 전송합니다. 그러면 가장 앞의 4자리를 읽어 메시지의 사이즈를 가져오고 사이즈만큼의 바이트를 읽어 들이는 것입니다. 이런 로직은 서버와 클라이언트 모두가 사용하는 공통 로직이기 때문에 이런 전송 및 수신하는 로직을 MessageHandler라는 클래스에 담았습니다.

object ApplicationConstants {
    const val SERVER_PORT = 9999
    const val SERVER_IP = "127.0.0.1"
}
object MessageHandler {

    private const val MESSAGE_LENGTH_BYTE_SIZE = 4
    private val defaultCharset = charset("MS949")

    /**
     * 메시지 길이를 메시지 앞 부분에 붙여서 보낸다.
     * */
    fun send(message: String, outputStream: OutputStream) {
        val lengthString = message.toLengthString()
        outputStream.write((lengthString + message).toByteArray(defaultCharset))
        outputStream.flush()
    }

    /**
     * 메시지를 읽어 출력.
     * */
    @kotlin.jvm.Throws(IllegalStateException::class)
    fun receive(inputStream: InputStream) {
        println("===========read size")
        val readBuffer = ByteArray(MESSAGE_LENGTH_BYTE_SIZE)
        val read = inputStream.read(readBuffer)
        if (read == -1) {
            throw IllegalStateException("read exit code")
        }

        val messageSize = String(readBuffer, defaultCharset).toInt()
        println("Message size is $messageSize")

        println("===========read message")
        val messageBuffer = ByteArray(messageSize)
        inputStream.read(messageBuffer)

        val message = String(messageBuffer, defaultCharset)
        println("Message is $message")
    }

    private fun String.toLengthString() =
        toByteArray(defaultCharset).size.toString().padStart(MESSAGE_LENGTH_BYTE_SIZE, '0')
}

서버 패키지

class TcpServer {
    private val serverSocket = ServerSocket(9999)

    fun start() {
        // hook 등록
        addShutdownHook()

        // 메인 스레드로 진행하지 말자.
        Thread {
            while (true) { // TODO: 스레드 개수 체크하는 로직 필요.
                println("대기중")
                val socket = serverSocket.accept()
                WorkerThread(socket).run()
            }
        }.start()
    }

    private fun addShutdownHook() {
        Runtime.getRuntime().addShutdownHook(Thread {
            println("shutdown server.")
            try {
                if (!serverSocket.isClosed) {
                    serverSocket.close()
                }
            } catch (e: IOException) {
                e.printStackTrace()
            }
        })
    }

    class WorkerThread(private val socket: Socket) : Runnable {
        private val inputStream = socket.getInputStream()
        private val outputStream = socket.getOutputStream()

        override fun run() {
            try {
                while (true) {
                    MessageHandler.receive(inputStream)
                    MessageHandler.send("i am mojong", outputStream)
                }
            } catch (e: IllegalStateException) {
                println("client exited.. close client socket.")
                socket.close()
            }
        }
    }
}
object ServerMain {
    @JvmStatic
    fun main(args: Array<String>) {
        TcpServer().start()
    }
}

클라이언트 패키지

class TcpClient {
    private val socket = Socket(ApplicationConstants.SERVER_IP, ApplicationConstants.SERVER_PORT)
    private val inputStream = socket.getInputStream()
    private val outputStream = socket.getOutputStream()

    fun start() {
        addShutdownHook()
        try {
            MessageHandler.send("Hello 모종닷컴", outputStream )
            MessageHandler.receive(inputStream)
        } catch (e: IllegalStateException) {
            println(e.message)
        } catch (e: IOException) {
            println("ioexception : ${e.message}")
        } finally {
            socket.close()
            println("소켓 종료.")
        }
    }

    private fun addShutdownHook() {
        Runtime.getRuntime().addShutdownHook(Thread {
            println("shutdown client.")
            if(!socket.isClosed) {
                socket.close()
                println("close socket")
            }
        })
    }
}
object ClientMain {
    @JvmStatic
    fun main(args: Array<String>) {
        TcpClient().start()
    }
}

테스트 하기

테스트도 간단합니다. ServerMain을 먼저 실행해주고, ClientMain을 실행해주면 됩니다. 출력을 보도록 하겠습니다.

서버(좌측), 클라이언트(우측)

TCP 상태 확인해보기

TIME_WAIT 눈으로 보기

4-way handshake에 따르면 클라이언트는 현재 'TIME_WAIT' 상태일 겁니다.

netstat -vanp tcp | grep 9999

위 커맨드를 터미널에 입력해보면 실제  127.0.0.1.{클라이언트 소켓}        127.0.0.1.9999         TIME_WAIT  이런 결과를 보실 수 있습니다.

2MSL 시간 기다려보기

TIME_WAIT 상태가 되면 2MSL 시간 뒤에 자동으로 없어질 겁니다. MSL 시간이 어떻게 되는지 궁금하다면 Mac에서는 아래 명령어를 통해 알 수 있습니다.

sysctl net.inet.tcp | grep msl

저는 이 시간이 15000로 15초가 되겠네요. 2MSL 시간이니 30초 뒤에 위의 netstat 명령어를 다시 입력해보면 사라져 있을 겁니다.

패킷 분석해보기

이제는 패킷을 분석해보겠습니다. 먼저 네트워크 인터페이스 중 loopback에 해당하는 인터페이스를 가져와야 하기 때문에 아래 명령어를 먼저 실행시켜야 합니다.

sudo tcpdump -D

출력 중 Loopback에 해당하는 네트워크 인터페이스를 기억하고 터미널에 해당 네트워크의 패킷을 탈취하기 위해 아래 명령어를 칩니다.

sudo tcpdump -i lo0 -w local.pcap

위를 코멘트가 의미하는 건 "lo0 네트워크의 패킷을 현재 터미널을 연 폴더에 local.pcap 파일에 저장할 것이다" 입니다. 커맨드가 실행되면 다시 테스트를 해보도록 하겠습니다.

ServerMain을 실행하고 , ClientMain을 실행합니다. 정상적으로 실행이 되었다면 이제 터미널로 다시 돌아가 control + c 를 눌러 tcpdump를 종료해줍니다. 

그리고 local.pcap 파일을 wireshark로 열어줍니다.

잠깐 테스트하는 사이에 많은 패킷이 오갔었네요. 필터를 해서 패킷을 봐야 하는데 아까 위의 코드를 보면 서버 포트를 9999로 열었었죠? 그럼 위 필터에 tcp.port == 9999 를 입력해서 패킷을 필터링해서 보면 됩니다.

이제 필터링된 패킷을 보면서 tcp의 연결과 종료 과정을 보도록 합시다.

연결 과정 패킷

  1. 클라이언트 소켓이 59704로 열렸었나 보네요. 클라이언트에서 서버로 먼저 SYN 패킷을 보냈습니다. Seq = 0으로 보냈습니다.
  2. 그다음 패킷은 서버가 클라이언트에게 [SYN,ACK] 패킷을 주었네요. 상단 3-way handshake에서 나타냈듯이 Ack는 클라이언트에서 보낸 Seq + 1인 1을 보내주었네요.
  3. 마지막으로 다시 클라이언트가 서버로 [ACK] 패킷을 보냈어요. 마찬가지로 Ack는 Seq + 1인 1을 보내주었고, Seq는 이전 순서 번호에서 1 증가한 1을 보내주었네요.

저희가 위에서 보았던 3-way handshake가 그대로 이루어졌습니다.

종료 과정 패킷

  1. 코드를 보시면 알겠지만 클라이언트가 소켓을 종료하도록 되어있어요. 그래서 클라이언트가 서버에게 먼저 [FIN] 패킷을 보냈어요. (ACK는 그냥 이전에 보냈던 메시지와 동일한 것으로 봐서 한 번 더 체크하는 개념으로 보낸 게 아닐까 추측해봅니다.)
  2. 서버가 FIN 패킷을 받고 나서 응답에 대해 [ACK]패킷을 보내주었어요. 
  3. 서버가 클라이언트 소켓을 close() 했나 봅니다. [FIN] 패킷을 보내주었습니다. 
  4. 클라이언트도 인지했다는 의미로 [ACK] 패킷을 보내주었습니다.

마치며

TCP 연결과 종료 과정을 보고 이걸 실제로 눈으로 확인하는 과정을 봤습니다. 사실 이 글을 쓰게 된 것이 처음에 코드를 잘못짜서 TCP 상태가 FIN_WAIT와 CLOSE_WAIT 상태에 빠지게 되서였습니다. 클라이언트 소켓은 닫아놓고 서버에서 연결된 소켓을 닫지 않아서였는데요.처음부터 연결과 종료과정을 잘 이해했다면 이걸 금방 해결했을 텐데 참 어렵게 돌아왔네요. 시간이 된다면 글을 읽으신 분도 한번 예시 코드를 건드려보면서 이렇게 하면 이런 상태에 빠지는구나 하는 것들을 눈으로 직접 보면 도움이 많이 될 것 같습니다. 긴 글 읽어주셔서 감사합니다. 

반응형

'Programming' 카테고리의 다른 글

SQS 중복 수신 이슈  (0) 2022.12.24
MySQL vs MongoDB Atomic Counter 비교  (0) 2022.09.09
Quartz Job Scheduling 2편 - API로 활용  (0) 2022.08.27
멀티 모듈 설계 고민  (0) 2022.08.24
Postman 꿀팁 방출  (0) 2022.08.07