Recommanded Free YOUTUBE Lecture: <% selectedImage[1] %>

클라이언트와 서버 프로그램은 데이터의 타입과 데이터를 교환하는 것으로 서비스를 수행한다. 이러한 작업을 위해서는 클라이언트와 서버가 주고 받는 데이터를 해석하기 위한 프로토콜을 맞추는 작업이 선행되야 한다. 이번 장에서는 클라이언트 - 서버간 데이터 통신 프로토콜 설계 방법을 (작동 하는)예제 프로그램을 만들면서 고민해 보려 한다.

Contents

소개

클라이언트와 서버 프로그램은 서로 정보를 교환한다. TCP와 UDP는 이들 프로그램 사이에 데이터를 교환하는 전령역할을 한다. 하지만 TCP와 UDP는 데이터를 전송하는 일만 할 뿐, 데이터를 처리하는 일을 하는 건아니다. 데이터 처리는 어디까지나 (서비/클라이언트)프로세스가 하는 일이다. 프로세스가 데이터를 처리하기 위해서는 "애플리케이션을 위한 프로토콜"이 필요하다. 프로토콜은 데이터의 유형, 형식, 인코딩 방법등을 정의한 프로그래밍 규약서로 서버와 클라이언트 사이에서 일어날 수 있는 모든 일을 정의 한다.

프로토콜 디자인

명시적으로 "프로토콜 디자인을 이렇게 해야 한다"라는 어떤 규격은 없다. 다만 프로토콜을 디자인 하기 위해서 고려해야 할 이슈들은 있다. 이들 이슈들을 잘 분류하고 정의 한다면, 좋은 프로토콜을 만들 가능성을 높일 수 있다.
  • 통신 방식 : 브로드캐스트인가 Point to Point 방식인가.
브로드캐스트라면 반드시 UDP를 사용해야 한다. Local 네트워크에서의 사용은 기술적 이슈가 없지만, 광대역 네트워크에서 사용할 거라면 (실험적인)MBONE을 사용하는 등 해결해야 할 이슈가 있다. PtP는 UDP, TCP 아무거나 선택해도 된다.
  • stateful 혹은 stateless : 연결을 유지할 것인가 유지 하지 않을 것인가 ? HTTP 같은 경우 stateless로 연결을 유지하지 않는다. Telnet은 연결을 유지한다. 어떤 방식을 선택하는게 더 단순하면서, 효율적으로 작동할지를 고민해야 한다.
  • 전송 프로토콜의 신뢰성 : 신뢰성과 성능과는 교환(trade off)관계에 있다. 어떤 애플리케이션은 신뢰성이 그다지 중요하지 않을 수 있다.
  • 응답 메시지가 필요 한가 : 만약 그렇다면, 응답 메시지가 누락과 timeout에 대한 처리 방안을 고민해야 한다.
  • 데이터 형식은 : 보통 MIME 혹은 byte encoding 둘 중 하나를 사용한다.
  • bursty한 서비스인가 아니면 steady 한 서비스인가 : Bursty는 일정한 간격을 두고 집중적으로 트래픽이 발생하는 서비스를 의미한다. steady는 지속적으로 트래픽이 발생하는 서비스다. 대부분의 인터넷 서비스들은 bursty한 트래픽을 발생한다. Steady 트래픽을 발생하는 대표적인 서비스들은 비디오 stream 이나 음성서비스들이다. 서비스 타입에 따라서 QoS 전략을 세워야 한다.
  • Standalone 애플리케이션인지 아니면 다른 애플리케이션의 하부 컴포넌트 혹은 라이브러리인지

버전 컨트롤

서버/클라이언트 시스템은 시간에 따라서 진화한다. 프로토콜역시 애플리케이션 진화에 따라 변하기 마련이라서, 프로토콜 버전 변화에 따른 조치를 취해야 한다. 처음 개발한 프로토콜의 버전이 1.0이었다고 가정해 보자. 여기에 새로운 기능의 추가 혹은 성능 향상등을 위해서 2.0 버전의 새로운 프로토콜이 만들어졌다고 가정해보자. 버전 1.0을 지원하는 서버에 버전 2.0을 사용하는 클라이언트가 접속하면, 서버는 클라이언트의 요청을 이해하지 못할 수도 있다.

가장 쉬운 해결방법 중 하나는 프로토콜에 버전을 명시하는 거다. 아래 그림을 보자.

  • 서버 프로그램은 프로토콜 버전 1.0과 2.0을 모두 지원한다.
  • 클라이언트 프로그램은 요청을 보낼 때, 자신이 사용하는 프로토콜 버전 정보도 함께 전송한다.
  • 클라이언트가 v1.0 프로토콜로 요청을 보내면, 서버는 v1.0 프로토콜 규격에 따라서 요청을 처리하고 그 결과를 전송한다.
  • 클라이언트가 v2.0 프로토콜로 요청을 보내면, 서버는 v2.0 프로토콜 규격에 따라서 요청을 처리하고 그 결과를 전송한다.

Web

HTTP는 다른 버전의 프로토콜을 처리하는 방법을 보여주는 좋은 예제다. HTTP 클라이언트는 요청을 보낼 때, 자신이 사용하는 버전번호도 함께 보낸다.
요청 버전
GET / 1.0을 사용한다고 가정한다.
GET / HTTP/1.0 1.0 버전을 사용한다.
GET / HTTP/1.1 1.1 버전을 사용한다.
HTTP가 비교적 깔끔하게 정리된 것과는 달리 content 부분은 다양한 버전이 난립하고 있다.
  • HTML 1-4 버전은 모두 다르다. 게다가 HTML5 버전도 있다.
  • 몇몇 브라우저는 표준을 따르지 않는다.
  • 어떤 컨텐츠는 별도의 컨텐츠 핸들러를 필요로 한다. Flash가 대표적인 예다.
  • CSS의 출력물이 브라우저 별로 차이가 있다.
  • Javascript에 대한 지원이 서로 다르다.
  • Java 런타임 엔진도 다르다.
  • HTML 문법오류에 대한 대응 방법이 브라우저마다 다르다.

메시지 포맷

데이터 포맷

데이터 전송에 사용하는 포맷은 "byte encode"와 "chracter encode"의 두 가지다.

Byte 포맷

Byte 포맷은 데이터를 byte 단위로 다룬다. 보통 아래와 같은 형식을 따른다.
  • 메시지의 첫번째 바이트에는 "메시지의 타입"을 설정한다.
  • 메시지 핸들러는 수신한 데이터의 첫번째 바이트를 읽어서 메시지의 타입을 확인하고, 이 정보를 토대로 메시지를 어떻게 다룰지를 결정한다.
  • 보통 미리 정의된 데이터 형식을 따르는데, 구조체로 정의 할 수 있다.
데이터 형식 정의의 한 예다.
type request structure {
   var type int
}

Character 포맷

데이터 전송에 "문자"를 사용한다. 예를 들어 integer 234를 전송할 경우 4byte(int32), 8byte(int64) 크기의 이진 데이터를 보내는 대신에 '2', '3', '4' 문자로 이루어진 "234" 3byte를 전송한다.

Character 포맷의 특징은 다음과 같다.
  • 메시지는 하나 이상의 줄(line)들로 이루어진다.
    • 첫 줄에는 보통 보내려고 하는 데이터의 정보를 포함한다.
  • 첫 줄 다음에는 실제 데이터들을 보낸다.
  • character 포맷을 다루는 프로그램들은 특히 문자열을 잘 다루어야 한다.
Character 포맷을 사용하는 프로그램의 의사코드다.
handleClient() {
    line = conn.readLine() {
    if(line.startWith(...)) {
        ......
    } else if (line.start(....)) {
    {
}

문자 포맷은 설정과 디버깅이 쉽다는 장점이 있다. telnet은 대표적인 문자 포맷 사용 프로그램인데, 키보드로 입력한 문자가 그대로 전송되고, 그 결과 역시 문자 형태로 전송된다. 따라서 tcpdump와 같은 프로그램을 사용하지 않고도, 클라이언트 프로그램만으로 거의 대부분의 테스트가 가능하다.

Go 언어는 문자 스트림을 위한 지원을 하지 않고 있다. 문자 셋과 문자 인코딩은 문자 포맷 기반 프로그램 개발있어서 중요한 문제 중 하나다. 이 내용은 다음 장에서 다룰 것이다.

만약 모든 데이터를 ASCII로 전송하기로 했다면, 데이터는 매우 간단히 다룰 수 있다. 이런 류의 프로그램은 줄바꿈 문자를 기본 단위로 데이터를 다루는데, 운영체제에 따른 줄바꿈 규칙에 주의해야 한다. 예를 들어 유닉스계열 운영체제는 줄바꿈을 위해서 '\n'을 사용한다. 반면, 윈도우의 경우 "\r\n"을 사용한다. 일반적으로 인터넷상에서는 "\r\n" 을 사용하므로, 유닉스 기반의 프로그램을 개발한다면 줄바꿈 문자 처리에 조심할 필요가 있다.

간단한 예제

디렉토리를 브라우징 하는 간단한 프로그램을 만들어 보려고 한다. FTP 프로그램이라고 보면 될 것 같다. 단 파일을 다운로드 하는 기능은 포함하지 않을 거다. CD(change directory)와 파일 목록을 전송하는 기능만 포함할 것이다.

서버 프로그램은 대략 다음과 같이 작동할 거다. 아래 코드는 의사코드다.
read line from user
while not eof do
  if line == dir
    list directory
  else

  if line == cd <dir>
    change directory
  else

  if line == pwd
    print directory
  else

  if line == quit
    quit
  else
    complain

  read line from user
non-distributed 애플리케이션, 그러니까 stand-along 프로그램은 아래와 같은 구성을 가진다.

클라이언트&서버 프로그램의 경우는 좀 다르다. 파일을 제어하는 건 서버이기 때문에, 파일 처리 관련 기능들은 서버만 필요하다. 대신 "UI"는 가지고 있을 필요가 없다. 클라이언트는 유저와의 상호작용을 위한 "UI"를 가지고 있지만, 파일 처리 관련 기능은 가지고 있지 않을 거다. 반면 서버에 연결하기 위한 통신 채널을 가지고 있다. 클라이언트 & 서버 프로그램의 구성은 다음과 같이 묘사할 수 있다.

FTP 클라이언트 프로그램은 파일 정보를 가지고 있지 않다. 유저로 부터 (키보드로 부터의)입력을 받아서 분석 한 다음, FTP 프로토콜에 맞게 변경한 다음 서버로 보낸다. 대략 다음과 같은 코드를 가진다.
# 유저 입력을 기다린다.
read line from user 

# 유저가 명령을 입력하면, 분석해서 요청을 서버에 전달한다.
while not eof do
  if line == dir
    list directory
  else

  if line == cd <dir>
    change directory
  else

  if line == pwd
    print directory
  else

  if line == quit
    quit
  else
    complain

read line from user

프로토콜 내용

FTP 프로토콜은 기능별로 다음과 같이 정의 할 수 있을 것이다.
Client Request Server response
dir 파일의 목록을 전송
cd <dir> 디렉토리를 변경
pwd 현재 디렉토리 이름
quit 연결종료

텍스트 프로토콜

4개의 기능만 가지고 있는 단순한 프로토콜이다. 가장 복잡한 데이터라고 해봐야 디렉토리와 파일에 대한 목록정도가 될거다. 이 경우 앞서 다루었던 (무겁고 귀찮은)직렬화 기술을 사용할 필요는 없다. 그냥 단순한 텍스트를 사용하기로 했다.

이런 단순한 프로토콜일 경우에도, 메시지 형식을 위해서 약속을 정해야 한다. 다음과 같은 약속을 만들었다.
  • 모든 메시지는 7-bit US-ASCII를 따른다.
  • 메시지는 대소문자를 구분한다.
  • 각 메시지는 줄(line)으로 구분할 수 있다.
  • 메시지의 첫 줄에는 메시지 정보가 있다. 나머지 줄은 데이터다.
  • cd 같은 요청은 뒤에 "매개 변수"가 추가로 필요하다. 명령과 매개변수는 "하나의 공백문자"로 구분한다.
  • 줄 바꿈은 CR-LF(\r\n)이다.
실제 애플리케이션 처럼 만들려면, 몇 가지 약속을 더 추가해야 할 거다.
  • 메시지는 대소문자를 구분하지 않아야 한다. 입력 받은 문자를 전부 소문자로 변환한 후 처리하면 된다.
  • 메시지 단어 사이에 공백문자가 몇 개가 있더라도 상관없이 처리할 수 있어야 한다. 두 개 이상의 공백문자를 하나 인 것처럼 처리해야 한다.
  • "\n"도 "\r\n"과 마찬가지로 줄 바꿈으로 처리한다.
프로토콜을 실제 애플리케이션에서 사용할 수 있는 수준으로 정리했다.
Client Request Server Response
send "DIR" 모든 파일 목록을 전송한다. 파일목록은 "\r\n"으로 구분한다. 목록을 다 보낸 후에는 "\r\n"을 하나 더 보낸다.
send "CD <DIR>" 디렉토리 변경. 성공하면 "OK", 실패하면 "ERROR"를 반환한다.
send "PWD" 현재 작업 디렉토리의 이름을 출력한다.
위에 정의한 프로토콜을 이용해서 FTP 서버, 클라이언트 프로그램을 만들었다.

Server 프로그램
package main

import (
	"fmt"
	"net"
	"os"
)

// 요청 프로토콜. 대문자만 처리하다.
const (
	DIR = "DIR"
	CD  = "CD"
	PWD = "PWD"
)

func main() {
	server := "0.0.0.0:1202"
	tcpAddr, err := net.ResolveTCPAddr("tcp", server)
	checkError(err)

	listener, err := net.ListenTCP("tcp", tcpAddr)

	for {
		conn, err := listener.Accept()
		if err != nil {
			fmt.Println("Accept error %s\n", err.Error())
			continue
		}
		go handleClient(conn)
	}
}

// 클라이언트 요청을 처리한다.
func handleClient(conn net.Conn) {
	defer conn.Close()

	var buf [512]byte
	for {
		n, err := conn.Read(buf[0:])
		if err != nil {
			conn.Close()
			return
		}

		s := string(buf[0:n])
		if s[0:2] == CD {
			chdir(conn, s[3:])
		} else if s[0:3] == DIR {
			dirList(conn)
		} else if s[0:3] == PWD {
			pwd(conn)
		}
	}
}

func chdir(conn net.Conn, s string) {
	if os.Chdir(s) == nil {
		conn.Write([]byte("OK"))
	} else {
		conn.Write([]byte("ERROR"))
	}
}

func pwd(conn net.Conn) {
	s, err := os.Getwd()
	if err != nil {
		conn.Write([]byte(""))
		return
	}
	conn.Write([]byte(s))
}

func dirList(conn net.Conn) {
	defer conn.Write([]byte("\r\n"))

	dir, err := os.Open(".")
	if err != nil {
		return
	}

	names, err := dir.Readdirnames(-1)
	if err != nil {
		return
	}
	for _, nm := range names {
		conn.Write([]byte(nm + "\r\n"))
	}
}

func checkError(err error) {
	if err != nil {
		fmt.Fprintf(os.Stderr, "Fatal error : %s\n", err.Error())
		os.Exit(1)
	}
}

"client 프로그램"
package main

import (
	"bufio"
	"bytes"
	"fmt"
	"net"
	"os"
	"strings"
)

// 유저는 소문자를 이용해서 요청을 한다.
const (
	uiDir  = "dir"
	uiCd   = "cd"
	uiPwd  = "pwd"
	uiQuit = "quit"
)

// 서버와의 통신에는 대문자를 사용한다.
// 유저 요청 명령은 대문자로 변경된다.
// quit는 서버 전달 명령이 아니므로 여기에서 정의할 필요는 없다.
const (
	DIR = "DIR"
	CD  = "CD"
	PWD = "PWD"
)

func main() {
	if len(os.Args) != 2 {
		fmt.Println("Usage: ", os.Args[0], "host")
		os.Exit(1)
	}

	host := os.Args[1]

	conn, err := net.Dial("tcp", host+":1202")
	checkError(err)

	reader := bufio.NewReader(os.Stdin)
	for {
		line, err := reader.ReadString('\n')
		// lose trailing whitespace
		line = strings.TrimRight(line, " \t\r\n")
		if err != nil {
			break
		}

		// split into command + arg
		strs := strings.SplitN(line, " ", 2)
		// decode user request
		switch strs[0] {
		case uiDir:
			dirRequest(conn)
		case uiCd:
			if len(strs) != 2 {
				fmt.Println("cd <dir>")
				continue
			}
			fmt.Println("CD \"", strs[1], "\"")
			cdRequest(conn, strs[1])
		case uiPwd:
			pwdRequest(conn)
		case uiQuit:
			conn.Close()
			os.Exit(0)
		default:
			fmt.Println("Unknown command")
		}
	}
}

func dirRequest(conn net.Conn) {
	conn.Write([]byte(DIR + " "))

	var buf [512]byte
	result := bytes.NewBuffer(nil)
	for {
		n, _ := conn.Read(buf[0:])
		result.Write(buf[0:n])
		length := result.Len()
		contents := result.Bytes()
		if string(contents[length-4:]) == "\r\n\r\n" {
			fmt.Println(string(contents[0 : length-4]))
			return
		}
	}
}

func cdRequest(conn net.Conn, dir string) {
	conn.Write([]byte(CD + " " + dir))
	var response [512]byte
	n, _ := conn.Read(response[0:])
	s := string(response[0:n])
	if s != "OK" {
		fmt.Println("Failed to change dir")
	}
}

func pwdRequest(conn net.Conn) {
	conn.Write([]byte(PWD))
	var response [512]byte
	n, _ := conn.Read(response[0:])
	s := string(response[0:n])
	fmt.Println("Current dir \"" + s + "\"")
}

func checkError(err error) {
	if err != nil {
		fmt.Println("Fatal error ", err.Error())
		os.Exit(1)
	}
}

상태

애플리케이션은 상태정보를 유지한다. 예를 들자면
  • 현재 읽고 있는 파일의 위치
  • 마우스의 위치
  • 유저가 입력한 값들
분산 시스템에서는 클라이언트와 서버의 상태정보를 유지해야 한다. 클라이언트라면 서버의 상태, 서버라면 클라이언트의 상태를 알고 있어야 한다.

중요한 점은 프로세스는 자신의 상태원격에 있는 프로세스의 상태를 알고 있어야 한다는 거다. 상태 정보를 충분히 가지고 있지 않으면 문제가 생길 수 있다. 예컨데 UDP 통신을 하는데, 상대가 내 정보를 제대로 받았다는 상태를 알지 못하면, 파일 업데이트가 실패하거나 소프트웨어 에러가 생길 수 있다.

파일을 읽는 프로그램을 예로 들어보자. stand along 애플리케이션이라면은 파일을 찾고, 열고, 읽는 코드를 자체에 내장한다. 또한 파일 목록과 파일의 (읽은)위치를 기록한 테이블을 가지고 있을 거다. 애플리케이션이 파일을 읽어서 위치가 변경되면, 테이블의 정보를 업데이트 하는 것으로 현재 상태를 유지한다. DCE(Dsitributed file system)의 경우 서버 프로세스는 클라이언트가 연(open) 파일과 위치를 알고 있어야 한다. 서로 상태 동기화가 이루어져야 하는데, 네트워크에서의 동기화는 상당히 취약한 측면이 있다. 네트워크 지연에 따른 timeout, 네트워트 단절, 클라이언 프로그램의 문제 등등으로 동기화가 깨질 수 있다.

DEC 파일 시스템의 경우 서버가 파일의 정보를 유지한다.

NFS의 경우 클라이언트가 파일 정보를 유지한다.

서버가 클라이언트에 대한 정보를 유지한다면, 정보 동기화에 문제가 생겼다면 이를 복구할 수 있어야 한다. 정보를 저장하지 않을 거라면, 서버와의 트랜잭션 마다 충분한 정보를 주고 받아야 한다.

연결을 신뢰할 수 없을 경우에는 동기화를 유지하기 위해서 추가적인 조치를 취해야 한다. 전형적인 예로 은행의 계좌 거래 시스템을 들 수 있다. 은행의 계좌 거래 시스템은 all or nothing로 작동해야 한다. 모든 기능이 완료되거나 (하나라도 문제가 생기면)실패해야 한다. 트랜잭션 서버는 서버와 클라이언트 간의 매 트랜잭션을 모두 저장한다. 만약 트랜잭션 중 하나가 실패하면, 저장된 트랜잭션 정보를 이용해서 rollback 시킨다.

Application State Transition Diagram

Transition 다이어그램을 그리는 것으로 애플리케이션이 각 단계에서 유지해야할 상태 정보가 어떠해야 한지를 추적할 수 있다.

예제 : Login 후 파일 전송까지의 다이어그램

다이어그램은 테이블로 나타낼 수도 있다.
현재 상태 Transition 다음 상태
login login failed login
login succeeded file transfer
afile transfer dir file transfer
get file transfer
logout login
quit -

클라이언트 state transition digrams

다음은 클라이언트 state transition digram 이다. write와 read를 기준으로 자세히 기술했다.
현재 상태 Write Read 다음 상태
login LOGIN name password FAILED login
SUCCEEDED file transfer
file transfer CD dir SUCCEEDED file transfer
FAILED file transfer
GET filename #lines + conents file transfer
ERROR file transfer
DIR #files + filename file transfer
ERROR file transfer
quit none quit
logout none login

서버 state transition digrams

서버의 state transition digram이다. read와 write를 기준으로 기술했다. 클라이언트와 read, write 방향만 다를 뿐 동일하다. 비교적 단순한 프로그램이라서 동일한데, 프로그램이 복잡해지면 서버와 클라이언트의 다이어그램에도 많은 차이가 있을 수 있다.
현재 상태 Read Write 다음 상태
login LOGIN name password FAILED login
SUCCEEDED file transfer
file transfer CD dir SUCCEEDED file transfer
FAILED file transfer
GET filename #lines + filenames file transfer
ERROR file transfer
DIR #files + filenames file transfer
ERROR file transfer
quit none quit
logout none login

정리