워밍업

TCP(Transmission Control Protocol)는 데이터를 보내면 어찌되었든 반드시 도달하며(예외는 있더라도), 또한 순서대로 도달한다는 특징이 있는데, 뭔가 상당히 복잡한 메커니즘을 사용하여 이를 구현하고 있다. 구현 부분은 네트워크의 하위 레이어에서 알아서 자동으로 수행해주기 때문에, 애플리케이션 개발자로서는 동작 원리에 대해 잘 모르기도 한다. 잠시 그림을 보며 개념을 알아보자.

그림 출처

  • networkstack1
  • networkstack2

위 그림은 네트워크 통신의 송신측과 수신측에서 일어나는 일을 단계적으로 보여준다. 첫번째 그림 왼쪽 맨 위의 애플리케이션에서 write를 호출하여 사용자 데이터를 준비하는 것부터 시작해서, 아래로 내려오면서 다음 작업이 수행되어 마지막에는 네트워크 인터페이스 카드에서 패킷을 전송하고 인터럽트를 준비하는 것으로 끝난다. Kernel과 Device 레이어의 동작은 애플리케이션 개발자의 관여 없이 이루어진다. 각 단계마다 그림 오른쪽에 데이터 전송과 관련하여 PDU(Protocol Data Unit)라는 개념이 사용되고 있다. 모드버스 통신을 할 때 모드버스 TCP 프레임이 PDU(Function Code, Address/Length, Data 등)와 ADU(트랜잭션ID, 디바이스ID 등)로 이루어진다고 공부한 적이 있었다. 좀 더 넓은 개념에서 PDU는 각 전송 계층마다 명칭이 달라지고 해당 계층에 필요한 추가 정보가 붙게 되는데, TCP 계층에서는 세그먼트, IP 계층에서는 패킷이라고 부르며, 대략 아래 그림과 같다.

그림 출처

img

개념은 이 정도로 살펴보고 코드를 작성해보자. Go에서 TCP 통신을 하기 위해 애플리케이션 개발자는 net 패키지의 listen, dial, accept, write, read만 사용하면 일단 기본적인 네트워크 프로그래밍은 어느 정도 가능하다. 위에서 봤듯이 나머지 복잡한 부분은 OS나 NIC 등에서 알아서 처리해준다.

아래 코드는 listen, dial, accept, write, read를 사용하여 TCP 통신으로 자기 자신과 데이터를 주고 받는다. 서버가 8000포트에서 연결을 대기하다가 새로운 연결이 들어오면 수신한 데이터를 출력하고, 1초마다 새로운 클라이언트가 접속하여 데이터를 전송한 후 접속을 종료하도록 하였다.

package main

import (
    "net"
    "time"
)

func main() {
    l, _ := net.Listen("tcp", "localhost:8000") // listen (해당 포트에서 최초 연결신청을 받음)
    defer l.Close()
    for {
        go func() {
            conn, _ := l.Accept() // accept (클라이언트마다 새로 할당되는 실제 통신을 위한 소켓)
            defer conn.Close()
            recvBuf := make([]byte, 4096)
            for {
                count, _ := conn.Read(recvBuf) // read
                if count > 0 {
                    println(string(recvBuf[:count]))
                }
            }
        }()
        go func() {
            conn, _ := net.Dial("tcp", "localhost:8000") // dial
            count, _ := conn.Write([]byte("New Connection...")) // write
            _ = count
            defer conn.Close()
        }()
        time.Sleep(time.Duration(1000) * time.Millisecond)
    }
}

소켓, 소켓 버퍼

코드에서 사용한 포트 번호의 역할은 무엇일까? Tcp Socket 통신이므로 서버와 클라이언트는 소켓을 사용해 통신을 한다. 소켓은 네트워크 통신을 위해 데이터가 드나드는 일종의 창구이다. 포트 번호는 소켓에 붙은 인식표 같은 것으로, 해당 애플리케이션이 통신 과정에서 어떤 창구를 사용해야 하는지 알려주는 역할을 한다고 볼 수 있다.

보통 서버와 클라이언트가 소켓에 쓰고, 읽음으로써 통신이 이루어진다고 이야기한다. 그런데 위 코드만으로는 알기 어려우나, 실제 통신 과정에서는 OS 소켓 버퍼라는 것이 등장한다. OS 소켓 버퍼는 소켓 통신에 사용되는 일종의 파일이라고 보면 될 것 같다. OS 소켓 버퍼에는 송신 버퍼와 수신 버퍼가 있는데, 프로그래머가 write와 read를 호출할 때 데이터가 이 송신 버퍼와 수신 버퍼를 거친다. 이들 버퍼는 OS가 관리하고, FIFO 로 동작하며, 꽉 차 있거나 비어있을 때 write와 read를 블로킹(대기) 상태로 만든다. 이는 모드에 따라 다르며 블록/논블록 설정이 가능하다. 참고로 블로킹 상태가 되더라도 연결은 유지된다. 아무튼 소켓 버퍼와 관련한 동작을 살펴보면,

  • write를 호출하면 일단 송신 버퍼에 데이터가 채워지고, 네트워크로 전송되면서 송신 버퍼가 비워진다.
  • 보낼 데이터가 더 있는데 송신버퍼가 가득 찬 상태라면 write 동작이 블로킹된다.
  • read를 호출하면 수신 버퍼로부터 데이터를 가져오고, 가져온 데이터만큼 수신 버퍼가 비워진다.
  • 데이터를 가져오려고 했는데 수신 버퍼가 비어있는 상태라면 read 동작이 블로킹된다.
  • 보내는 쪽에서는 보낼 데이터가 더 있는데 받는 쪽에서는 수신 버퍼가 가득 찬 상태라면 write 동작이 블로킹되는 듯 하다(이 부분은 다시 확인 필요).

요약하면,

  • 송신 버퍼가 가득 차 있다면 -> write 블로킹
  • 수신 버퍼가 비어 있다면 -> read 블로킹

아래 코드에서 수신 측은 수신 버퍼가 7byte이고 1초마다 read하고 있으며, 송신 측은 딜레이 없이 write를 시도하고 있다. 코드를 실행해보면 송신 측에서 빠르게 숫자들을 출력한 후 블로킹되는 것을 볼 수 있다. 이는 송신 버퍼가 가득 찬 것으로 추정할 수 있다. 조금 기다리면 다시 송신 측에서 숫자들을 출력하는데, OS에서 송신 버퍼를 일부 비운 후 write가 다시 동작을 재개한 것으로 보인다.

package main

import (
    "net"
    "time"
)

func main() {
    l, _ := net.Listen("tcp", "localhost:8000")
    defer l.Close()
    for {
        conn, _ := l.Accept()
        defer conn.Close()
        go func() {
            recvBuf := make([]byte, 7)
            for {
                n, _ := conn.Read(recvBuf)
                println(n, string(recvBuf[:n]))
                time.Sleep(time.Duration(1000) * time.Millisecond)
            }
        }()
    }
}
package main

import (
    "net"
    "strconv"
)

func main() {
    conn, _ := net.Dial("tcp", "localhost:8000")
    i := 0
    for {
        i = i + 1
        n, _ := conn.Write([]byte(">>>" + strconv.Itoa(i) + "<<<"))
        println(n, i)
    }
}

스트림과 데이터 경계

TCP는 스트림 지향이라는 방식을 사용한다. 스트림의 특징은 개울물처럼 끊임없이 바이트가 흘러들어 어디서부터 어디까지가 한 단위의 데이터인지 그 경계를 알 수가 없다는 것이다. 예를 들어 1KB짜리 센싱 데이터를 10개를 보냈다고 하자. 그러면 수신 측 버퍼에 10KB가 들어왔지만, 어디부터 어디까지 끊어 읽어야 할지는 알 수가 없다. 1KB짜리 10개인지, 10KB짜리 1개인지, 아니면 100KB짜리가 아직 덜 수신된 것인지 TCP 계층에서는 알 방법이 없다. 그래서 애플리케이션 계층에서 경계를 구분해주어야 하며, 이를 Message Framing 이라고 부르기도 한다. 보통 고정길이, 길이접두사, 구분자의 3가지 방법을 사용하는 것 같다. 여기서는 구분자 방법을 사용해보자. 전에 파이썬으로 시리얼 통신을 할 때 write로 어떤 명령을 ‘\n’과 함께 보내고 readline으로 응답을 한 줄 읽어왔었다. 여기서는 ‘\n’이 구분자의 역할을 한다고 볼 수 있다.

package main

import (
    "net"
)

func main() {
    l, _ := net.Listen("tcp", "localhost:8000")
    defer l.Close()
    for {
        conn, _ := l.Accept()
        defer conn.Close()
        go func() {
            recvBuf := make([]byte, 4096)
            msg := ""
            for {
                n, _ := conn.Read(recvBuf)
                if n > 0 {
                    for i := 0; i < n; i++ {
                        s := string(recvBuf[i])
                        if s != "\n" { // 구분자가 나올 때까지 바이트를 합침
                            msg = msg + s
                        } else { // 구분자가 나오면 메시지 완성
                            println(msg)
                            msg = ""
                        }                        
                    }
                }
            }
        }()
    }
}
package main

import (
    "net"
    "time"
    "strconv"
)

func main() {
    conn, _ := net.Dial("tcp", "localhost:8000")
    i := 0
    for {
        i = i + 1
        n, _ := conn.Write([]byte(">>>" + strconv.Itoa(i) + "<<< \n")) // 끝에 구분자 추가
        println(n, i)
        time.Sleep(time.Duration(10) * time.Millisecond)
    }
}

참고로 UDP는 메시지 지향(데이터그램)이라는 방식을 사용한다. 스트림과 데이터그램은 전화통화(지속성)와 문자메시지(비연결성)에 비유할 수 있다. UDP는 유저 메시지(애플리케이션 데이터)외의 부가 정보가 별로 없어 가볍고 빠르지만, 수신 측에 제대로 전달되지 않을 수 있다.

정리

TCP 네트워크에서 애플리케이션 레이어를 제외한 Kernel과 Device 레이어는 OS 등이 자동으로 관리한다. Go에서 TCP 네트워크 프로그래밍을 위해 애플리케이션 개발자는 net 패키지의 listen, dial, accept, write, read를 사용한다. write와 read는 OS가 관리하는 소켓 버퍼를 사용해 소켓 통신을 한다. 소켓 버퍼는 write가 사용하는 송신 버퍼와 read가 사용하는 수신 버퍼가 있는데, 기본 모드에서 송신 버퍼가 가득 차면 write는 블로킹 상태가 되고, 수신 버퍼가 비면 read는 블로킹 상태가 된다. TCP는 부가 정보가 붙어있는 세그먼트라는 전송 단위에 바이트 스트림 형태의 사용자 데이터를 담아 전송하는데, 스트림의 특성상 데이터의 경계가 모호해지게 된다. 데이터의 경계를 파악하여 의미있는 단위로 구분해주기 위해, 애플리케이션 계층에서 사용자 데이터의 끝에 구분자를 추가하는 방법 등을 사용한다.

참고