최근의 언어처리계라면 대부분 JSON 파싱은 표준 라이브러리로 처리할 수 있다. Go 언어도 표준 라이브러리에 encoding/json 패키지가 있어, JSON을 파싱할 수 있다.

 

대부분 JSON 파서는 객체형가 사전형 등 내장형 인스턴스를 생성하지만, Go 언어의 JSON 파서는 그와 달리 미리 만들어놓은 구조체에 대응하는 파서로 되어 있다. Go 언어에서 가장 많이 사용되는 구조체를 이용한 파싱을 알아보자.

 

1.0 Go 언어의 구조체 태그를 사용한 JSON 파싱

[도서 정보 JSON을 구조체로 변환]

package main

import (
	"encoding/json"
	"fmt"
)

type Book struct {
	Title string `json:"title"`
	Author string `json:"author"`
}

var jsonString = []byte (`
[
	{"title": "The Art of Community", "author": "Jono Baacon"},
	{"title": "Mithril", "author": "Yoshiki Shibukwa"}
]`)

func main() {
	var books []Book
	err := json.Unmarshal(jsonString, &books)
	if err != nil {
		panic(err)
	}
	for _,book := range books {
		fmt.Println(book)
	}
}

출력

 

1.1 생략됐는지 또는 제로 값인지 판정

 Go 언어는 변수를 반드시 기본값으로 초기화한다. 숫자 값인 경우는 0, 문자열이면 빈 문자열 ""로 초기화된다. 

type EditHistory struct {
	Id int `json: "id"`
	Name string `json: "name"`
	Price int `json: "price"`
}

위 구조체에 json.Unmarshal을 사용해 값ㄷ을 읽어오면, 가격이 0으로 설정된 것인지, 애초에 JSON에 포함되지 않았는지 구별할 수 없다. 이때, 포인터 변수로 만들어두면, JSON에 포함되지 않은 경우는 nil인지 판정할 수 있게 된다.

type EditHistory struct {
	Id int `json: "id"`
	Name *string `json: "name"`
	Price *int `json: "price"`
}

 

1.2 특별한 형 변환을 하고 싶을 때

JSON과 프로그램에서 다루고 싶은 표현이 항상 같은 것은 아니다. JSON에는 날짜를 나타내는 데이터형이 없지만, 프로그램 안에서는 각 언어의 날짜 표현 클래스나 구조체를 사용하고 싶은 경우를 생각해볼 수 있다. 그런 때는 직접 형을 정의하고 UnmarshalJSON() 메서드를 정의해서 형 변환을 구현할 수 있다. Go 언어에서 이 메서드를 가진 구조체는 json.Unmarshaler 인터페이스를 충족한다. Go 언어의 파서는 이 인터페이스를 가진 오브젝트에서는 이 변환 메서드를 사용한다.

[형 변환]

type DueDate struct {
	time.Time
}

func(d *DueDate) UnmarshalJSON(raw []byte) error {
	epoch, err := strconv.Atoi(string(raw))
	if err != nil {
		return err
	}
	d.Time = time.Unix(int64(epoch), 0)
	return nil
}

우선 type 선언으로 time.Time이 들어간 DueDate형을 만들었다. 이것으로 time.Time의 메서드도 모두 이용할 수 있게 된다.

 

다음으로 UnmarshalJSON() 메서드를 재정의한다. 이 메서드에는 바이트열이 전달되므로, 그 형의 규칙에 따라 해석하고 인스턴스를 초기화한다. 여기 전달되는 값은 에폭타임이므로 먼저 초를 나타내는 수치 형으로 변환하고, time.Unix() 함수로 time.Time 구조체의 오브젝트를 생성한다. 이제 해당 구조체를 사용할 구조체를 만든다. 아래를 참고하자.

type ToDo struct {
	Task string `json:"task"`
	Time DueDate `json:"due"`
}

var jsonString2 = []byte(`[
	{"task": "Go 언어 공부", "due": 140600200},
	{"task": "Java 언어 공부", "due": 140600400}
]`)

func main() {
	var todos []ToDo
	err := json.Unmarshal(jsonString2, &todos)
	if err != nil {
		panic(err)
	}
	for _,todo := range todos {
		fmt.Println(todo)
	}
}

UnmarshalJSON()을 구현하는 위치는 개개의 데이터 형뿐만 아니라, 하나 위 계층의 ToDo 구조체에 부여할 수도 있다.

 

2.0 JSON 응용하기

2.1 출력 시 출력을 가공하기

json_output처럼 구조체의 인스턴스를 json.Marshal()에 전달하면 JSON을 출력할 수 있다.

[JSON으로 출력하기]

	d, _ := json.Marshal(Book{"눈을 뜨자! JavaScript", "Cody Lindley"})
	log.Println(string(d))

출력

구조체에 붙인 태그는 읽기뿐만 아니라 쓰기에도 이용된다.

 

UnmarshalJSON()을 정의해 읽기 처리를 사용자화한 것처럼 MarshalJSON() 메서드를 정의하면 쓰기 처리도 사용화할 수 있다. ToDo에 종료 플래그 Done을 더해서 출력 시에 완료 항목은 JSON에서 제외하도록 해보자.

 

ToDo 배열을 ToDoList라는 형으로 선언하고, 이 형의 MarshalJSON을 정의한다. Go 언어에서는 type을 이용해 기존의 형 등을 바탕으로 새로운 형을 만든다. 배열도 형으로 만들 수 있다. 조금 전에 만든 Due 구조체에도 출력 변환 함수도 만들어두자.

[MarshalJSON 정의]

// 날짜 시리얼라이즈
func (d * DueDate) MarshalJSON() ([]byte, error) {
	return []byte(strconv.Itoa(int(d.Unix()))), nil
}

type ToDoList []ToDo

// 리스트를 필터링해서 시리얼라이즈
func (l ToDoList) MarshalJSON() ([]byte, error) {
	tmpList := make([]ToDo, 0, len(l))
	for _, todo := range l {
		if !todo.Done {
			tmpList = append(tmpList, todo)
		}
	}
	return json.Marshal(tmpList)
}

 

2.2 상황에 따라 형이 변하는 JSON 파싱

JSON이 단순한 구조체에 매핑할 수 있으면 되지만, API에 따라서는 유연한 해석이 필요한 경우도 있다. 반환되는 JSON의 data 속성은 이벤트 종류에 따라 달라진다. JSON이라는 데이터를 다룰 때 객체지향으로 설계된 공통 인터페이스를 공유하는 다른 종류의 오브젝트가 들어가는 경우는 있을 수 있다. 이 경우는 json.Unmarshal(), json.RawMessage로 극복할 수 있다. 

{
    "created": 14422888882,
    "data": {
        "email": null,
        "id": "cus_a16c7b03492343de4",
        "object": "customer"
    },
    "id": "event_54b3dfe32452435ccf",
    "object": "event",
    "type": "customer"
}

형이 정해지지 않은 데이터는 json.RawMessage로 해둔다. 이것은 []byte의 별칭이다. 파싱 도중에 일시정지한 상태가 일단 저장된다. 나머지는 종류별 변환 메서드를 준비해두면 사용자는 파싱된 데이터를 이용할 수 있다.

type Event struct {
	Created EpochDate `json:"create"`
	Data json.RawMessage `json:"data"`
	Id string `json:"id"`
	Object string `json:"object"`
	Types string `json:"type"`
}

 

만약 공통 인터페이스를 제공한다면, 공개용과 읽기 쓰기용 구조체를 나눠 대응할 수 있다. Data에는 공통 인터페이스를 가진 구조체가 들어간다. 이쪽이 코드는 길어지지만, Go언어가 갖춘 형에 의한 분기를 이용할 수 있으므로 이용하는 쪽 코드는 Go 언어다워 진다.

 

2.3 일반 데이터형으로 변환

조금 더 간단하게는 구조체 선언을 하지 않는 방법도 있다. Go언어에서 베리언트 형으로서 사용되는 interface() 형을 사용하면, 파서가 JSON의 데이터 구조에 맞는 인스턴스를 생성해준다.

[interface()로 json 데이터 받기]

func main() {
	var books []interface{}
	err := json.Unmarshal(jsonString, &books)
	if err != nil {
		log.Fatal(err)
	}
	for _, book := range books {
		log.Println(book)
	}
}

위 코드는 JSON에 어떤 배열이 들어 있다는 것을 전제로 interface{} 배열을 전달하고 있다. 물론, 오브젝트가 오는 것을 알고 있으면, map[string]interface{}를 사용할 수도 있다. JSON 규격에서는 자바스크립트와 같은 형을 사용하기에 수치는 float64밖에 없다. 따라서 정수로 다룰 수가 없다.

 

실제 코드 안에서 처리하려면 형 변환을 해야 한다. Go 언어의 형 변환에는 크게 두 종류가 있다. 특정 형식이 올 것을 알고 있을 경우는 변수 하나로 결과를 받을 수 있지만, 만약 다른 형의 데이터가 들어오면 오류를 일으켜 프로그램이 종료되어 버린다. 두 번째 형식이라면, 만약 다른 형이 들어온 경우에는 ok 변수에 false가 들어가는 대신 프로그램이 비정상적으로 종료되진 않게 된다. 

 

어느 형이 오는지 모르는 때는 switch 문을 사용할 수 있다. JSON을 트리형 데이터의 표현에 이용하는 경우는 이 방법을 사용하게 될 것이다.

	// 특정 형으로 캐스팅하기 (1)
	bookList := books.([]interface{})

	// 특정 형으로 캐스팅하기 (2)
	bookMap, ok := books.(map[string]interface{})

	// switch 문
	switch v := value.(type) {
	case bool:
		log.Println("bool", value)
	case float64:
		log.Println("float64", value)
	case string:
		log.Println("string", value)
	case map[string]interface{}:
		log.Println("map[string]interface{}", value)
	case []interface{}:
		log.Println("[]interface{}", value)
	}

 

2.4 JSON 스키마

Go 언어의 JSON 라이브러리는 구조체에 대한 매핑을 하므로, 빠르게 변환될 것이라고 기대할 수 있을 것이다. 하지만 불행히도 그것을 보장하는 기능은 없다. 읽어들인 JSON과 구조체의 키가 일치하지 않아도, JSON 문법이 이상하지 않은 이상 오류가 발생하지 않는다. 예상대로 키가 있는지 검증은 구조체에 대한 매핑이 아니라 스키마를 사용한 확인이 필요하다. 

 

Go 언어의 표준 라이브러리에는 없지만, JSON 스키마 검증을 시행할 타사 라이브러리가 있다.

해당 github에 사용방법이 꽤 자세하게 나와 있다.

 

 

 

 

 

Reference

  • 리얼월드 HTTP

1.0 RESTful API

오래전 매시업이라는 단어가 많이 사용됐다고 한다. 구글 지도를 비롯해 클라이언트에서 처리할 수 없는 대용량 데이터, 날씨 정보 등 관칙 시스템에서 보내는 실시간 정보를 취급하는 웹 서비스가 많이 만들어졌다. 이러한 서비스는 웹 브라우저에서 사용하는 것을 전제로 만들어졌지만, API도 제공하고 있어 웹 서비스를 조합해 사용할 수도 있다. 웹 서비스를 결합해 새로운 부가가치 창출 방법을 '매시업'이라고 한다. 이렇게 웹 서비스의 인터페이스로서 HTTP를 기반으로 한 서버/클라이언트 간 통신이 널리 이용되게 되었다. 

 

REST는 로이 필딩 씨가 2000년에 네트워크를 기반으로 하는 소프트웨어 아키텍처 논문에서 발표한 것이다. 필딩 씨는 HTTP 책정에도 관여했고, 아파티 웹 서버 프로젝트의 공동 설립자 중 한 사람이기도 하다. REST는 매시업이 활발하게 이루어지게 되고, 웹이 브라우저에서 웹 애플리케이션 간의 연계를 위한 것으로 용도가 넓어지면서 알려지고 사용되기 시작했다. 구글의 디자인 가이드에 따르면, 2010년에는 전 세계의 네트워크를 통해 공개된 API의 74%가 REST API로 되어 있다.

 

REST는 HTTP 신택스를 프로토콜의 토대로 사용했는데, 통신의 시맨틱도 HTTP를 따르자는 생강기다. 대략적으로 아래와 같은 특성을 가지고 있다.

  • API가 웹 서버를 통해 제공된다.
  • GET /users/[사용자 ID]/repositories처럼 경로에 메서드를 보내 서비스를 얻는다.
  • API가 성공했는지 스테이터스 코드로 클라이언트에 알려준다.
  • URL은 리소스의 위치를 나타내는 표현이고, 서비스의 얼굴로서 중요하다.
  • 필요에 따라 GET 매개변수, POST의 바디 등 추가 정보를 보낼 수도 있다.
  • 서버에서 오는 반환값으로는 JSON 또는 XML과 같은 구조화 텍스트나 이미지 데이터 등이 반환되는 경우가 많다.

클라이언트 관점에서는 서버에서 아래와 같은 것을 기대할 수 있다.

  • URL은 리소스의 계층을 나타낸 경로로 되어있다. 명사로만 구성된다.
  • 리소스에 대해 HTTP 메서드를 보내 리소스 취득, 갱신, 추가 등의 작업을 한다.
  • 스테이터스 코드를 보고 요청이 제대로 처리됐는지 판정할 수 있다.
  • GET 메서드는 여러 번 호출해도 상태를 변경하지 않는다.
  • 클라이언트 쪽에서 관리해야 할 상태가 존재하지 않고, 매번 요청을 독립적으로 발행할 수 있다.
  • 트랜잭션은 존재하지 않는다.

REST 원칙을 준수한 것을 RESTful이라고 표현한다.

 

1.1 REST API와 RPC의 차이

웹 서비스와 인터페이스 설계 아키텍처는 REST만이 아니다. RPC에 따른 API와 REST에 따른 API는 설계 철학이 다르다. 

 

RPC의 경우 URL은 하나로, 요청할 때 서비스 이름과 메서드 이름을 전달해 '무엇을 할 것인가' 지정한다. 객체지향적 관점에서 설명하면, 웹 서버를 통해 공개된 정적 오브젝트의 인스턴스를 URL을 통해 발견하고 그 메서드를 호출한다고 비유할 수 있다. 호출하는 창구는 URL 하나이며, HTTP 메서드는 모두 POST이다. 서버에 대한 요청은 모두 바디에 넣어 보낸다. HTTP 서버의 로그를 보더라도 동일한 URL에 대한 액세스가 있다는 것밖에는 알 수 없다.

 

REST로 정보를 갱신할 때는 수정된 리소스를 첨부해서 '이걸로 덮어쓰라'고 전송한다. GET으로 가져올 수 있는 실제 리소스 이외에는 모두 메타데이터이다. 메타데이터의 취득과 수정은 헤더로 한다.

 

RPC의 경우는 데이터든 메타데이터든 똑같이 다룬다. 변경이나 응답은 전용 명령을 사용한다. 실제 동작은 API로 구현하기 나름이지만, 리소스 자체를 그대로 전송하는 디자인은 거의 없다.

 

애플리케이션에 따라 어떤 표현이 다루기 쉬운지 이름적으로는 정할 순 없다. 최근에는 드롭박스가 API를 버전 업하며 REST API를 버리고 RPC로 변경했다는 소식이 화제가 됐다.

 

1.2 Web API와 트랜잭션

RPC의 경우 트랜잭션과 비슷한 API를 제공할 수도 있지만, 트랜잭션이 필요한 단위로 원자적인 API를 제공하는 것이 현재 현실적인 해법이다.

 

JSON-RPC는 여러 요청을 배열로 모아 동시에 발행할 수 있다. 키 값 스토어 Redis API의 멀티를 사용하면 여러 명령을 묶어 동시에 실행할 수 있다. RPC가 모두 이러한 호출을 지원하는 것은 아니지만, 이렇게 여러 명령을 원자적으로 (도중에 끼어들지 못하게) 실행하면 간단한 트랜잭션처럼 다룰 수 있다. 데이터를 가져와서 가공하고 다시 데이터베이스에 넣으려면 여러 명령을 묶는 것만으로는 실현할 수 없어, 도중에 인터럽트가 발생해 병렬 처리 특유의 문제가 일어날 가능성이 있다.

 

Redis는 Lua를 사용해 서버 측에서 처리함으로써 이런 경우에 대처할 수 있게 되어 있다. 또한 MongoDB는 단순한 증가보다도 복잡한 데이터 가공을 하나의 쿼리 호출로 할 수 있게 되어 있다. 그러나 둘 다 클라이언트와 서버 양쪽에서 API 구현이나 이용에 드는 수고가 적지 않다. 트랜잭션이 필요한 경우에는 그에 상응하는 복잡한 구현이 필요하다. 

 

중요한 것은 가능한 한 원자 레벨로 API르르 나누는 것이다. Redis와 MongoDB는 일반 저장소로서 작은 단위의 API가 제공되지만, 웹 API는 가급적 한 번에 끝내는 것이 중요하다. 특히 RPC의 경우 아파치 스리프트나 구글 gRPC 등 서버와 클라이언트의 소스 코드 생성기를 사용해 구현하는 경우가 많다. 이런 경우에는 여러 요청을 동시에 발행할 수도 없다. 이 점은 HTTP도 마찬가지다. HTTP도 한 번의 액세스에 하나의 메서드가 호출된다. 복수의 요청을 원자적으로 실행할 수는 없다. HTTP/2에서는 호출 순서와 API 서버에서 처리되는 순서 또는 응답이 돌아오는 순서가 같다는 보증도 없어진다.

 

클라이언트 입장에서는 모아서 한꺼번에 처리하고 싶은 대상이 있다고 해도, 그에 해당하는 API가 반드시 있고, 그 API 하나만 호출하면 필요한 일을 할 수 있는 형태가 이상적이다. 하지만 이 이상에도 제약이 있다. "Web API: The Good Parts"에서는 다수의 불특정 개발자를 위한 API를 LSUDs라는 말로 설명한다. 다수가 사용하는 API에서는 유스 케이스를 좁힐 수 없어, 아무리 해도 크기가 작은 API 집합으로 할 수 밖에 없다. 

 

1.3 HATEOAS

HATEOASHypermedia As The Engine Of Application State는 REST의 일부인 '인터페이스의 일반화'를 사양으로 만든 것이다. 리소스 간의 관련 정보와 리소스의 조작 링크를 응답에 포함시킴으로써 리소스의 자가서술력을 향상시킨다. REST API의 성숙도 수준에서 최상위에 위치한다. 

HTTP/1.1 200 OK
Content-Type: application/xml
Content-Length: ...

<?xml version="1.0"/>
<account>
    <account_number>12345</account_number>
    <link rel="deposit" href="https://example.com/account/12345/deposit" />
    <link rel="withdraw" href="https://example.com/account/12345/withdraw" />
    <link rel="transfer" href="https://example.com/account/12345/transfer" />
    <link rel="close" href="https://example.com/account/12345/close" />
</account>

위는 HATEOAS의 예제이다. <link> 태그로 주어진 정보가 HATEOAS를 위해 추가된 것으로, 이 오브젝트에 대해 실행할 수 있는 작업과 관련 오브젝트의 링크를 포함한다. API에 대한 사전 지식이 전혀 없이 링크를 따라가는 것만으로 API의 기능을 충분히 사용할 수 있게 되는 것이다. 사용자가 브라우저로 웹사이트를 탐색할 때도 페이지의 내용을 보고 링크를 따라가며 원하는 페이지를 찾는다. 그런 일을 API가 할 수 있게 되는 것이 HATEOAS가 이상으로 하는 상태이다.

 

1.4 RESTful와 REST-ish

REST API는  계층화된 리소스이다. 예를들어, URL에 동사가 들어가거나 /api 문자가 들어가고, 포맷을 나타내는 문자(/json)나 버전이 들어가는 등 원래는 리소스의 주소여야 하는 URL에 불필요한 정보가 들어가는 사례도 볼 수 있다. REST를 제안한 필딩 씨는 이렇게 설계된 웹 서비스가 'REST'를 자칭하면, 격렬하게 공격하는 것으로 유명하다. 

 

기본적으로는 REST를 지키고 싶지만, 완전히 RESTful하게 됐는지 자신이 없을 때가 있다. 그럴 때는 'REST-ish'라고 부르는 방법이 있다.

 

2.0 메서드

REST API를 이용할 때는 서비스가 지정하는 메서드를 사용한다. 기본적으로 사용하는 메서드는 GET, POST, PUT, DELETE, PATCH, HEAD, OPTIONS로 7가지이다. 이 중 자주 사용되는 것은 GET, POST, PUT, DELETE 4가지이다.

 

HTTP 사양에서 메서드의 분류에는 '안전'과 '멱등'이라는 두 가지 지표가 있다. '안전'은 실행해도 리소스를 변화시키지 않는 것이다. '멱등'은 서버의 리소스는 변경되지만, 몇 번을 실행해도 결과가 바뀌지 않는다는 것이다. GET, HEAD, OPTIONS는 안전한 메서드이고, PUT, DELETE는 멱등한 메서드로 정의되어 있다. RESTful한 API에서도 이 규칙을 따라야 한다.

 

 

3.0 스테이터스 코드

API를 이용할 때 스테이터스 코드는 상세한 오류 내용이나 서버의 상황을 알려주는 귀중한 정보원이 된다. 오류 발생 시 어떤 스테이터스 코드를 반환할 것인지는 서비스에 따라 차이가 있고, 오류 유형 별로 반환할 스테이터스 코드 목록을 문서로 제공하기도 한다. 

 

3.1 100번대 (정보)

100번대는 성공이나 실패가 결정되기 이전 상태라는 것을 나타낸다.

  • 100 Continue
  • 101 Switching Protocols
  • 102 Processing
  • 103 Early Hints

둘 다 바디는 포함하지 않는다. 

 

3.2 200번대 (성공)

정상 종료했을 때 200번대의 응답이 반환된다.

  • 200 OK
  • 201 Created
  • 202 Accepted
  • 203 Non-Authoriative Information
  • 204 No Content
  • 205 Reset Conent
  • 206 Parial Content
  • 207 Multi-Status
  • 208 Already Reported
  • 226 IM Used

 

3.3 300번대 (리디렉트)

  • 300 Multiple Choices
  • 301 Moved Permanently
  • 302 Found
  • 303 See Other
  • 304 Not Modified
  • 307 Temporary Redirect
  • 308 Permanent Redirect

 

3.4 400번대 (클라이언트 오류)

400번대 스테이터스 코드는 클라이언트에서 기인하는 오류이다. 요청할 URL이 틀렸거나 필요한 헤더가 부족할 때 등등 반환된다.

 

400 번대의 스테이터스 코드는 너무 많은 관계로 궁금하다면 링크를 통해 확인할 수 있다.

 

3.5 500번대 (서버 오류)

500번대 스테이터스 코드는 서버 오류로 다양한 종류가 있지만, 클라이언트 요청에는 문제가 없기 때문에 서비스를 받으려면 서버가 복구될 때까지 기다리는 수밖에 없다.

 

500 번대의 스테이터스 코드도 링크를 통해 확인할 수 있다.

 

 

4.0 바디

바디에는 서버로부터 반환된 정보가 저장된다. 웹 API가 공개되기 시작한 무렵은 거의 XML이였지만, 최근은 JSON을 사용하는 경우가 많다. RESTful API의 엄격한 규칙을 따른다면, Accept: application/json 헤더를 부여하고 서버와 니고시에이션하는 것이 관습이지만, JSON만 지원하는 경우라면 헤더를 부여하지 않아도 서버는 응답해줄 것이다.

 

5.0 실제 REST API 살펴보기 (PAY.jp)

깃허브나 슬랙 등 많은 서비스가 REST API를 제공한다. 브라우저로 조작할 필요가 있었떤 작업을 자동화할 수 있다. PAY.jp라는 결제 플랫폼이 있다. 해당 API 이용자는 토큰을 사용해야한다. 웹사이트에 로그인하면 액세스 토큰을 생성하고 다운로드할 수 있다. 대부분이 OAuth2를 자주 사용하고 있고 해당 플랫폼도 OAuth2를 사용한다. 사용자 ID와 암호를 사용하지 않고 토큰을 사용하는 이유는 만약 유출이 되어도 토큰 단위로 무효로 할 수 있기 때문이다. 

 

토큰을 어떻게 서비스 전달할지는 서브시에 따라 차이가 있지만, PAY.jp는 BASIC 인증의 사용자 이름으로 전달한다. BASE64로 인코딩된 액세스 토큰을 Authorization 헤더에 넣어 서버에 전달한다.

 

PAY.js의 통신 프로토콜은 HTTPS, 메서드는 GET/POST/DELETE, 응답 형식은 JSON으로 제공한다. 

 

6.0 실제 REST API 살펴보기 (Github)

구글이 Go 언어용 깃허브 API 액세스 라이브러리를 제공하므로 직접 액세스해볼 필요는 없을지도 모르지만, 모범 코드가 있으니 확인해보자. 링크에 잘 정리가 되어있으니 확인해보고 싶으면 살펴봐도 좋다.

 

7.0 REST API에 액세스할 때 주의할 점

7.1 타임아웃

클라이언트를 작성할 때 Context를 넘겨주곤한다. 이 Context는 취소 처리, 시간 만료 등을 통일적으로 처리하는 방법으로서 Go 언어 1.7부터 도입됐다. 비동기인 여러 기능을 실행하거나 Goroutine을 넘어선 처리를 실행할 때 바텀업으로 타임아웃 처리를 하려고 하면, 읽기 편한 코드를 쓸 수 없다. 또한 여러 부분으로 나누어진 처리를 할 때도 Context 자체가 데이터를 가질 수 있도록 되어 있어, 세션 간에 공유하는 데이터 등을 가지게 할 수 있다.

 

아래 코드는 2초 후 타임 아웃이 된다. 함수를 빠져나올 때 cancel()을 호출한다. 

import (
	"context"
	"time"
)

func timeout() {
	ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
	defer cancel()

	// 타임아웃 기능이 있는 클라이언트
	oauth2.NewClient(ctx, conf.TokenSource(ctx, token))
	// 긴 처리
}

 

7.2 액세스 수 제한

어떤 웹 서비스든 1초당 호출 횟수의 상한이 정해져 있따. 대개 1초에 10회 정도로 설정되어 있을 것이라고 생각한다. 액세스 속도를 조정하는 패키지가 준표준 라이브러리에 있다.

  • https://godoc.org/golang.org/x/time/rate
$ go get golang.org/x/time/rate

 

rate 패키지를 이용한 액세스 수 제한 (go 코드)

package main

import (
	"context"
	"time"

	"golang.org/x/time/rate"
)

func main() {
	// 1초당 상한 횟수
	RateLimit := 10
	// 토큰의 최대 보유 수
	BucketSize := 10
	ctx := context.Background()
	e := rate.Every(time.Second/RateLimit)
	l := rate.NewLimiter(e, BucketSize)

	for _, task := range tasks {
		err := l.Wait(ctx)
		if err != nil {
			panic(err)
		}
		// 여기서 task 실행
	}
}

 

 

 

 

Reference

  • 리얼월드 HTTP

1.0 기존의 공격

'기존'이라고 하는 것은 브라우저를 겨냥한 공격이 아니라는 뜻으로 저자가 부르는 것일 뿐, 일반적인 분류는 아니라고 한다. 기존형은 브라우저 외부인 OS에 접근하는 것이 특징이다. 컴퓨터에 위협을 주는 소프트웨어를 '맬웨어'라고 한다. 맬웨어는 증식 방법이나 목적에 따라 여러 가지로 분류된다.

 

증식 방법으로는 다른 실행 파일 등을 감염시켜 감염된 파일이 실행되면 다른 프로그램에도 자기 복제해서 증가하는 '컴퓨터 바이러스'와 적극적으로 네트워크 장비나 OS의 보안 취약점을 공격해 감염을 펼치는 '웜'이 있다. USB 메모리 같은 자동 실행 구조를 악용하거나 언뜻 분간하기 어려운 파일 이름이나 오피스 애플리케이션의 매크로 등을 이용하기도 한다. 어떤 프로그램이든 실행되지 않으면 의미가 없으므로, 이 맬웨어들은 사용자가 의도하지 않아도 실행되도록 계획한다. 바이러스처럼 다른 프로그램을 변조해, 그 프로그램을 시작할 때 실행되는 일도 있다. 웜의 경우 숙주가 없을 수도 있다. 이 경우 OS 부팅 스크립트나 레지스트리에 자신을 등록한다.

 

공격 방법도 몇 가지로 나눌 수 있다. 예전에는 단순히 OS를 부팅할 수 없게 만들거나 속도가 느려지게 하는 등 파괴가 목적인 것이 있었다. 그 외에는 OS 설정을 마음대로 바꿔 프록시 서버를 설정해서 통신 내용을 훔쳐보거나 키 입력을 기록해 암호를 훔치려는 것도 있다. 또 외부에서 원격 조작하기 위해 백도어를 설치하는 맬웨어도 있다. 이런 맬웨어는 특정 웹사이트의 서비스를 방해할 목적으로 일제히 대량의 요청을 보내버리는 DDos 공격의 발판이 될 수도 있다. 키 입력을 기록하는 것을 '키로거', 백도어를 만드는 것을 '트로이 목마'라고 부르는 등 행동에 따라 다양한 분류가 있다. 단 공격 수법이 서서히 인지되어 이름이 붙은 것도 있어, 이름이나 정의가 경우에 따라 다를 수 있다.

 

기존형으로 부르지만, 오래돼 더는 사용되지 않는다는 뜻은 아니다. 브라우저 플러그인의 보안 허점 등을 통해 언제든 컴퓨터에 대한 공격이 이루어질 수도 있다. 다음에 소개할 브라우저를 노린 공격의 전 단계로서 웹 서버에 침입해 전송할 데이터를 악의적인 스크립트로 변경하거나 데이터베이스에 액세스하여 사용자 정보를 훔칠 수 있다. 현재도 표적형 공격 메일로 대표되는 악성 프로그램을 전송해 이루어지는 공격은 중대한 위협 중 하나이며, 웹 서비스 개발자가 서비스를 보호하는 데 위협이라는 점은 변함이 없다.

 

 

2.0 브라우저를 노리는 공격의 특징

브라우저는 원래 '열람용 애플리케이션'이므로, OS 영역에서 뭔가를 하는 것은 아니다. 하지만 브라우저는 다른 서비스에 연결되는 창이다. 브라우저가 표적이 되고 브라우저에 저장된 다양한 웹사이트의 로그인 정보가 노출될 경우, 페이스북이나 라인 등 외부 서비스에 저장된 개인 정보 혹은 대화 내용 등을 도둑맞을 수 있다. 

 

웹 서비스에서는 최근 두드러지게 발전한 프론트엔드 기술과 여러 사이트를 가로지르는 계정 연계, HTTPS/HTTP의 혼재 등 개발자가 알아야 할 사양의 복잡성이 증가하고 있다. 개별 보안 기능이 강력해도 웹 서비스 등 구현상의 실수나 착각 등으로 보안에 구멍이 생긴다. 정규 웹사이트에서 HTTP를 통해 전송된 문서 안에 함정이 설치되기도 한다. 

 

브라우저 벤더는 보안에 민감하다. HTTP 사양에는 보안에 대한 배려가 많이 포함되어 있다. 리퍼러 설명에도 소개했지만, 기존 기능이라도 보안을 고려하여 기능이 수정되는 경우도 있다. 

 

3.0 크로스 사이트 스크립팅

웹 서비스 개발자가 가장 먼저 주의해야하는 많은 공격의 기점이 되는 공격 방법이다. 게시판 등 사용자 입력 콘텐츠에서 사용자가 입력한 내용을 아무런 필터링 없이 공개하는 것이 가장 발생하기 쉬운 시나리오이다. 만약, '이름을 넣어주세요'라는 텍스트 상자가 있고, 거기에 데이터를 입력했다고 가정해보자. 이름은 다른 사용자도 볼 수 있다. 웹 서비스 쪽 프로그램에서 입력된 값을 전혀 확인하지 않고 HTML에 그대로 넣어 출력할 경우, 이름 대신 악의적인 스크립트가 삽입된 채로 게시되면 그 콘텐츠를 보는 사람의 브라우저에서 그대로 실행된다. 이를 '크로스 사이트 스크립팅'으로 줄여서 XSS라고 한다.

 

실제로 아래와 같은 내용이 삽입됐다고 하면, 이름을 볼 때마다 경고 대화 상자가 표시된다.

<script>alert("error");</script>

이것이 다른 모든 공격의 기점이 될 수 있다고 했다. 예를들어 투입된 스크립트가 쿠키에 액세스되면, 다른 서버로 전송되어 쿠키 정보가 유출된다. '로그인됨'이라는 세션 토큰을 도둑맞으면, 사용자 ID와 암호가 없어도 '로그인 상태'를 가로챌 수 있다. 또한 로그인 폼이 해킹되어 사용자가 입력한 정보를 다른 서버로 전송해버리거나 피싱 사이트로 전송되는 등 온갖 위험이 있다.

 

서버 측의 방어 방법으로는 여러 가지가 있다. 사용자가 입력한 내용을 그대로 HTML로 출력하는 일은 하지말자. 사용자 입력은 악의적인 입력이 들어올 것으로 보고 그대로 출력하지 않도록 해야한다. 이를 막기 위해 새니타이즈(입력 전)와 이스케이프(출력 전)하는 방법이 있는데, 현재는 이스케이프라는 출력 직전에 깨끗하게하는 방법이 일반적이라고 한다.

 

3.1 유출 방지 쿠키의 설정

XSS의 다음 방어선으로 httpOnly 속성을 부여하는 방법이 있다. 이 속성을 부여하면 자바스크립트에서 액세스할 수 없는 쿠키가 된다. XSS 공격자는 자바스크립트를 사용한다. 자바스크립트에서 액세스할 수 없는 정보에는 접할 수 없기 때문에 자바스크립트에 의한 세션 토큰 누설의 위험을 줄일 수 있다.

 

3.2 X-XXS-Protection 헤더

X-XXS-Protection 헤더를 사용하면 HTML의 인라인에서 스크립트 태그를 사용하는 경우 등 명확하게 수상한 패턴을 감지한다. X-가 붙은 비공식 헤더이지만, 인터넷 익스플로러, 크롬, 사파리 등의 브라우저가 지원한다. 단순한 패턴 매칭에 의한 판정이므로, 문제가 없는 코드 패턴을 '문제 있음'으로 판정하는 이른바 긍정 오류가 될 가능성이 있다.

 

3.3 Content-Security-Policy 헤더

웹사이트에서 사용할 수 있는 기능을 세밀하게 ON/OFF를 할 수 있는 강력한 기술로 W3C에 의해 정의됐다. 웹사이트에 필요한 기능을 서버에서 설정하여 XSS처럼 자바스크립트가 에상치 못한 동작하는 것을 억제한다.

 

또한 브라우저 환경에서는 헤더로 옵트인되지만, 크롬 OS 등에서 사용되는 크롬 앱은 Content-Security-Policy 지원이 필수로 되어있다.

 

Content-Security-Policy는 브라우저가 실시하는 검사이다. 오류는 클라이언트 쪽에 표시되지만, 서버 개발자가 그 오류 정보를 직접 볼 수는 없다. 오류 보고서를 보낼 곳을 지정해 클라이언트가 오류 정보를 통지함으로써 서버 개발자도 클라이언트에서 발생한 문제를 알 수 있다.

 

3.4 Content-Security-Policy와 자바스크립트 템플릿 엔진

자바스크립트가 널리 사용되고, 서버에서 하던 많은 일을 브라우저 환경에서 처리하게 되었다. 그 하나는 클라이언트 사이드에서의 HTML 템플릿이 있다.

 

Hogan.js는 트위터가 만든 자바스크립트용 템플릿 엔진이다. Mustache 템플릿 엔진과 같은 문법을 지원하지만, 고속으로 동작한다. 컴파일하면 템플릿을 해석해 내부 함수를 호출하는 소스 코드를 생성하고, 최종적으로 new Function을 사용해 함수를 동적으로 생성한다. 이로써 실행 시에 파싱 처리가 필요 없어지고, 옵션 전개와 문자열 결합에 특화되므로 문자열을 빠르게 생성할 수 있다. 

 

이와 비슷한 사례로 자바스크립트 프레임워크인 Vue.js가 있다. 버전 2.0에서는 런타임 버전과 독립실행형 버전 두 가지로 출시됐다. 둘 사이의 차이는 아래와 같다.

  • 독립실행형 버전은 template 옵션에 문자열로 HTML 템플릿을 작성할 수 있다. 이 템플릿은 실행 시에 자바스크립트 코드로 변환되고 new Function을 사용해 render() 메서드가 만들어진다.
  • 런타임 버전은 template 옵션을 사용할 수 없다. 사전에 vue-loader(WebPack 용) 및 vueify(Browserify 용)에서 자바스크립트 소스 코드를 브라우저 환경용에 최적화하고 사전에 render() 메서드를 만들어둘 필요가 있다.

당연한 일이지만, new Function은 CSP(Content-Security-Policy)의 unsafe-eval 지시문에서 허용하는 문자열의 함수 동적 생성에 해당하므로 사용할 수 없다. 따라서 이런 템플릿 엔진은 Content-Security-Policy를 따를 때 장애가 된다. 

 

3.5 Mixed Content

웹의 HTTPS화가 진행되고 있지만, 광고나 외부 서비스가 제공하는 콘텐츠 등으로 HTTP가 뒤섞이는 수가 있다. 요즘 브라우저는 이런 Mixed Content의 경우 오류 또는 경고를 내보내게 되어 있다. 

 

근본적으로 모든 것을 HTTPS로 수정하는 이외의 대처 방법 중 하나로서, CSP 헤더의 upgrade-insecure-request 지시문을 사용하는 방법이 있다. 이 지시문을 사용하면 이미지 등의 링크가 http://로 시작되는 URL이라도 https://라고 적혀 있는 것처럼 가지러 간다. 하지만, 요즘은 naver, google 등이 프로토콜을 모두 h2 혹은 h3를 사용해서 사용될 일은 없을 것 같다..

 

4.0 중간자 공격

프록시 서버가 통신을 중계할 때 통신 내용을 빼내 정보가 누설되는 문제이다. HTTP 그대로 웹사이트의 로그인 폼을 이용해 로그인하면, 사용자 이름과 암호를 도둑맞게 된다. 로그인을 시도하지 않아도 세션 토큰을 훔쳐가면, 보호받아야 할 정보에 액세스를 허용하게 된다. 이런 중간자 공격을 방지하려면 HTTPS(TLS)를 이용하는 수밖에 없다. TLS는 '통신 경로를 신뢰할 수 없는' 상태에서도 보안을 유지하면서 통신할 수 있게 하는 메커니즘이다. 이로써 통신 내용 감청, 통신, 변조, 클라이언트와 서버의 의도하지 않은 요청 전송 등을 방지할 수 있다. 하지만, 서버에 대한 해킹이나 브라우저에 대한 XSS에는 주의할 필요가 있다.

 

세션 토큰이 쿠키로 저장되어 있어 조건(URL)이 맞으면 전손 요청 시 쿠키가 부여된다. XSS에서 소개한 바와 같이 서버가 쿠키를 부여할 때 secure를 지정해 TLS로 통신 경로가 보호될 때만 전송하므로 중간자 공격에 대한 내성이 높아진다.

 

4.1 HSTS

중간자 공격에 대항하는 HTTP의 기능 중 하나가 HTTP Strict Transport Security(HSTS)이다. HSTS는 서버 측에서 접속할 때 HTTPS로 접속해 달라고 전달하는 기능이다. 서버에서 HTTP를 통해 접속을 요청하고 싶을 때는 다음과 같은 헤더를 응답 헤더에 부여한다. includeSubDomains가 브라우저에 서브 도메인도 대상임을 전달한다. 물론 이 헤더를 반환할 때 동시에 리디렉션할 수도 있다.

Strict-Transport-Security: max-age=31536000;includeSubDomains

브라우저 내부에는 이 헤더를 보낸 URL의 데이터베이스가 있다. 브라우저가 지정된 사이트에 액세스할 때 자동으로 HTTPS 사이트에 접속하러 간다. 유효 기간 동안만 이 지시가 생존한다. 위 예시에서는 31,536,000초로 1년간 전송할 것을 브라우저에 의뢰한다.

 

HSTS에는 단점이 있다. 처음 연결할 때는 아직 HTTPS 로 접속하련느 서버의 지시가 도달하지 않은 상태이다. 그래서 첫 접속은 HTTP로 통신이 이루어진다. 이 상태에서 악의적인 프록시가 리디렉션 URL을 변조하면, 브라우저가 피싱 사이트로 유도될 가능성이 있다. TLS에는 서명이 있어서 변조되면 탐지할 수 있지만, 첫 번째 접속이 HTTP를 통해 이루어질 경우 브라우저에서 공격을 탐지할 수 없다.

 

이 문제를 해결하기 위해 새로운 방호벽이 추가됐다. 크롬은 처음부터 이 데이터베이스를 설정해두려고 정보를 수집한다. 웹사이트에 신청해두고 최신 브라우저를 다운로드하면, 처음부터 HTTPS로 접속하게 된다. RFC에는 없지만, 이곳에 신청한 경우에는 preload 지시문을 Strict-Transport-Security 헤더에 부여한다.

 

4.2 HTTP 공개 키 피닝

TLS 인증서에는 디지털 서명을 위한 공개 키가 붙어 있다. 이 공개 키를 사용해서 인증서를 검증한다. 피닝은 핀으로 고정한다는 의미이다. 공개 키 목록을 처음 액세스할 때 받아와서 로컬에 핀으로 꼽아 저장해둔다. 두 번째 이후로 액세스할 때는 서버가 보낸 인증서의 공개 키와 고정해둔 공개 키를 비교해 사이트 인증서가 무단으로 변경되지 않았는지 확인한다.

 

5.0 세션 하이재킹

이름 그대로 웹 서비스의 세션 토큰을 훔쳐 웹사이트에 로그인하는 공격이다. 일반 웹 서비스의 경우, 처음에 사용자 이름과 암호를 입력받은 후 로그인했음을 나타내는 세션 토큰을 쿠키로 브라우저에 보낸다. 브라우저가 이 쿠키를 저장하면 두 번째 이후 액세스부터는 사용자 이름과 암호가 필요 없어진다. 이 세션 토큰을 도난당하면 로그인된 상태로 웹 사이트에 액세스할 수 있으므로 사용자 이름 암호가 유출된 것과 같은 상태가 된다. 

 

쿠키를 훔치는 행위의 발판이 되는 것이 크로스 사이트 스크립팅과 중간자 공격이다. 다음과 같은 방법으로 이러한 공격으로부터 자신을 보호하는 것이 세션 하이재킹을 피하는 효과적인 수단이다.

  • HTTPS화
  • Set-Cookie: httpOnly, secure

 

5.2 쿠키 인젝션

stateless한 HTTP에서 브라우저의 상태를 보존하는 쿠키는 편리한 반면, 항상 공격의 위험과 등을 맞대고 있다. 쿠키에 특화된 공격 방법도 발견됐다. 쿠키 인젝션을 그 중 하나이다.

 

쿠키 인젝션은 쿠키의 사양을 역으로 취한 방법으로 HTTPS 연결을 우회할 수 있다. 발표 당시의 공격 수법은 HTTPS로 은닉된 도메인의 쿠키에 대해 HTTP가 아닌 다른 하위 도메인으로부터 덮어쓰거나, 보다 URL의 상세한 쿠키를 설정함으로써 원래 HTTPS로 지정된 도메인의 쿠키를 무효화하고 세션 고정화 공격의 발판으로 하는 것이다. 

 

2017/3 크롬 및 파이어폭스는 쿠키 인젝션에 대한 대책이 마련되어 있다. 서브 도메인에서 재구성할 수 없게 되어 있고, 동일한 도메인이라도 secure가 붙은 쿠키는 HTTP에서 덮어 쓸 수 없다. 

 

6.0 사이트 간 요청 위조

피해자에게 의도하지 않는 조작을 하게 만드는 공격이 사이트 간 요청 위조(CSRF)이다. 본인이 의도하지 않은 서버에 요청을 관계없는 페이지나 사이트에서 보내게 할 수 있다. 실제로 요청을 보내는 것은 공격자가 아니라 피해 사용자이며, 웹 브라우저가 유도된 페이지에 대해서도 쿠키를 발행하므로 로그인 상태는 유지된다. 이로써 피해 사용자의 권한으로 임의의 조작을 실행할 수 있게 된다.

 

일본에서도 다른 사용자에게 살해 예고를 하도록 조작해서 무고한 사람을 범인으로 만드는 삭너이 일어난 적이 있었다. 이때도 CSRF가 이용됐다. 

 

6.1 CSRF 대책 토큰

CSRF를 방지하는 방법으로는 HTTP 스테이트리스성을 제한하는 방법이 자주 사용된다. 폼을 설정할 때 숨겨진 필드에 무작위로 생성한 토큰을 집어넣고, POST 요청을 받은 서버 쪼겡서 올바른 토큰이 포함되지 않은 모든 요청을 거절하는 방법 등이 있다. 웹 애플리케이션 프레임워크에는 이 토큰을 생성하거나 토큰을 검증하는 미들웨어가 포함되어 있을 것이다.

 

브라우저 쪽에서는 CSRF 대책 토큰인지 아닌지 신경쓰지 않고, 저너송된 폼을 그대로 반환하기만 하므로 특별한 기능을 필요로 하지 않는다. 

 

덧붙여 각 사용자의 고유한 값이라고 하면 세션 토큰이 떠오르지만, 세션 토큰을 CSRF 대책 토큰으로 사용해서는 안된다. 쿠키는 httpOnly로 보호할 수 있지만, CSRF 대책 토큰 구현 방법으로 자주 사용되는 것은 숨겨진 필드이므로 보호하지 못하고 자바스크립트에서 쉽게 접근할 수 있다. 

 

6.2 SameSite 특성

크롬에서 쿠키에 해당 특성을 부여할 수 있다. 이 특성을 부여하면 요청을 보내기 전의 페이지가 같은 사이트에 없는 한 쿠키를 보내지 않게 된다. 이렇게 하면 무관한 사이트에서 요청할 수 없으므로 CSRF를 방지할 수 있다. 

 

7.0 클릭재킹

클릭재킹 사례는 두 가지 모두 IFRAME을 이용한다. 하나는 트위터 등 자주 사용되는 웹사이트를 투명하게 하여 악의적인 페이지 위에 겹치는 방법이다. 사용자가 볼 때는 악의적인 페이지가 표시된다. 예를들어, 클릭 유도 버튼을 자주 사용하는 웹사이트에서 로그아웃, 소셜 네트워크 공유 등의 버튼에 맞춰 배치함으로써 사용자가 인지하지 못한 채 유도된 작업을 하게 된다. 사이트 간 요청 위조와 같은 공격이 실제 사이트에 대해 직접 실행된다.

 

7.1 X-Frame-Options 헤더

IFRAME 내부에 표시될 뿐이지 웹사이트 쪽에서 보면 사용자의 조작은 평소와 같으므로, 사이트 간 요청 위조 같은 조치는 취할 수 없다. 브라우저의 클릭재킹 방지 기능으로는 X-Frame-Options 헤더가 있다. 악용되는 것을 막고 싶은 정규 사이트 쪽에서 이 헤더를 전송하면 페이지가 IFRAME내에서 이용되는 것을 방지한다. 브라우저는 이 헤더를 보고 적절하게 표시를 거절함으로써 사용자를 보호한다.

 

8.0 리스트형 계정 해킹

보안에 취약한 웹 서버가 해킹당해 사용자 ID 및 일반 텍스트로 저장된 비밀번호가 유출되면, 같은 이메일 주소와 비밀번호를 돌려쓰는 사용자의 보안은 무효가 된다는 것이다. 

 

2FA, 지오로케이션 등을 이용해서 방지할 수는 있다. 하지만, 와벽하게 막을 수 없기때문에 사용자가 신경써서 서비스마다 다른 암호를 설정하는 것이 가장 견고한 보안 대책이다.

 

9.0 웹 애플리케이션을 위한 보안 가이드라인

  • X-Frame-Options 헤더
  • Content-Security-Policy 헤더
  • Strict-Transport-Security 헤더
  • Public-Key-Pins 헤더
  • Set-Cookie 헤더
  • CSRF 대책 토큰
  • 2단계 인증
  • 지오IP
  • X-Content-Type-Options 헤더
  • X-XSS-Protection 헤더

 

10.0 웹 광고 및 보안

TV 광고는 프로그램을 시청하는 사람의 취향을 생각하지않고, 똑같은 광고를 일률적으로 내보내는 구조이다. 반면에 웹 광고는 한 걸음 더 나아가 같은 사이트에서도 사용자마다 광고를 다르게 내보낼 수 있다. 효율적으로 사용자의 관심을 끄는 광고를 내보내려면, 어떤 사용자가 무엇에 관심이 있는지 정보를 얻는 것이 이상을 실현하는 열쇠이다. 

 

이를 실현하기 위해서 방문한 URL 목록을 가져와 사용자의 취향을 추정한다. 광고가 사용자에게 ID를 할당하고, 광고 업자가 추적하는 웹사이트를 사용자가 방문했다는 정보를 기록해 점을 연결함으로써 사용자의 열람 기록을 복원하는 기술이다. 

 

기술에는 쿠키 기반 측정 방식과 핑거 프린트 방식이 있다. 하지만, 방문한 웹사이트의 기록 정보가 개인 정보에 해당한다. 웹 브라우저 업체는 개인의 사생활을 보호하는 방향으로 기능을 강화하고 있다. 웹 광고의 역사는 보안과 투쟁의 역사이다. 

 

10.1 서드파티 쿠키

쿠키에는 퍼스트파티 쿠키, 서드파트 쿠키가 있다. 브라우저가 액세스한 서비스가 해당 서비스 내에서만 유효한 쿠키를 퍼스트파티 쿠키, 광고 등의 용도로 외부 서비스에서 읽을 수 있는 쿠키를 삽입해, 사이트를 넘어서 행동 추적을 가능하게 해주는 쿠키가 서드파티 쿠키라고 한다.

 

한 사이트에 쿠키가 저장되어 있기 때문에 사용자가 직접 사이트를 방문하는 경우라면 해당 사이트 서버로 쿠키가 직접 전송되며, 그 시점에서 사용자가 과거에 방문했던 정보를 모아서 가져올 수 있다.

 

10.2 쿠키 이외의 대체 수단

클라이언트를 고유하게 식별하는 수단으로 가장 많이 사용되는 방법이 쿠키에 세션 토큰을 기록하는 방법이다. 쿠키의 경우 보안 강도에 따라 무시하거나 재설정하거나 화긴하는 수단도 있지만, 다른 방법으로 재설정하기 어려운 쿠키 흉내를 낼 수 있다. 이를 좀비 쿠키라고 한다. 자바스크립트를 사용해 이러한 쿠키를 조작하는 라이브러리로 에버쿠키도 있다. 

  • HTML5의 각종 스토리지 (세션, 로컬, 글로벌)
    • 서버에 액세스해 ID를 취득하고 브라우저의 쿠키 영역이 아니라 브라우저가 따로 준비한 스토리지에 저장함으로써 쿠키와 같은 기능을 실현 
  • ETag
    • 절대로 변하지 않는 이미지 파일 등의 ETag에 사용자 ID를 넣어 클라이언트에 전송함으로써 실현
  • 임의로 생성한 이미지
    • 픽셀 단위로 색 정보를 추출할 수 있으므로 ID 값을 여러 픽셀의 색 정보로서 삽입
  • 기타 플래시나 실버라이트, 자바 애플릿 등의 저장 영역

이러한 수단을 사용하면 어느 샌가 ID가 삽입되어 행동이 감시당할 우려가 있다. 

 

10.3 구글 애널리틱스

광고 이외에도 사용자 식별에 의미가 있는 경우가 있다. 사용자의 방문 기록을 가져오는 구글 애널리틱스가 해당된다. 구글 애널리틱스의 경우 웹사이트에 자바스크립트를 설치한다. 자바스크립트 코드를 사용해 웹사이트는 자신의 도메인 안에서 움직인다는 전제로 퍼스트파티 쿠키로서 ID 정보를 쿠키에 저장한다.

 

10.4 사용자를 특정하지 않고 추정

익스페리언의 AdTruth에서 사용하는 방법은 쿠키 등으로 ID를 브라우저에 심어서 이용하는 방법과 다르다. IP 주소나 브라우저가 보낼 유저 에이전트 정보 등 외부에서 관츨할 수 있는 정보를 바탕으로 단말기의 동일성을 추측하는 방법으로 핑거 프린트 방식이다. 클라이언트 쪽에는 어떠한 정보도 가지지 않고, 서버 쪽에서 추정 한 ID를 바탕으로 광고를 게재한다. 익스페리언의 웹사이트에 따르면 아래 정보를 바탕으로 하고 있다고 한다.

  • CPU, 시스템 설정 언어, 유저 에이전트, 사용자 설정 언어, 기본 언어 설정
  • 브라우저 애플리케이션 코드명, 브라우저 이름, 버전, 언어
  • 브라우저 플러그인
  • 시스템 시각, 스크린 설정, 폰트 높이
  • 도메인, 로컬 시간, 시간대

지오IP에 가까운 정보같다. 그러나 이 기법은 미국에서는 잘 동작하는 것 같지만, 타 지역에서는 정확도에 문제가 있다고 한다.

 

 

 

 

Reference

  • 리얼월드 HTTP

1.0 HTTP/2

Go 언어는 2016년 2월에 발표된 1.6 버전부터 기본으로 HTTP/2 지원이 내장되어 있다. HTTPS 통신을 한다고 설정하는 것만으로, NPN/ALPN 니고시에이션 후보에 HTTP/2를 나타내는 h2가 추가된다. 클라이언트와 서버 모두 니고시에이션에서 상대방이 HTTP/2를 지원한다는 것을 확인할 수 있으면 HTTP/2로 통신한다.

 

[HTTP 프로토콜 버전 확인 (Go 코드)]

package main

import (
	"fmt"
	"net/http"
)

func main() {
	resp, err := http.Get("https://google.com/")
	if err != nil {
		panic(err)
	}
	defer resp.Body.Close()
	fmt.Printf("Protocol Version: %s\n", resp.Proto)
}

출력

 

당연히 Go 언어의 HTTP/2 지원은 환경 변수로 제어할 수 있다. (GODEBUG 환경 변수를 통해 제어 가능)

환경 변수를 통해 version을 설정한 결과물

 

2.0 HTTP/2의 서버 푸시

Go 언어는 1.8부터 서버 푸시를 지원했다.

 

[서버 푸시 이용 방법 (Go 코드)]

func handler(w http.ResponseWriter, r *http.Request) {
	pusher, ok := w.(http.Pusher)
	if ok {
		pusher.Push("/style.css", nil)
	}
	// 일반 이벤트 핸들러 처리
}

청크 형식의 전송 지원에서는 http.ResponseWriter를 http.Flusher로 캐스팅해 특수한 메서드 Flush()에 액세스했지만, HTTP/2의 서버 푸시는 http.Pusher로 캐스팅해 사용한다.

 

[서버 푸시 구현]

package main

import (
	"fmt"
	"io/ioutil"
	"net/http"
)

var image []byte

// 이미지 파일 준비
func init() {
	var err error
	image, err = ioutil.ReadFile("./image.png")
	if err != nil {
		panic(err)
	}
}

// HTML을 브라우저로 송신
// 이미지를 푸시한다.
func handlerHtml(w http.ResponseWriter, r *http.Request) {
	// Pusher로 캐스팅 가능하면(HTTP/2로 접속했다면) 푸시한다.
	pusher, ok := w.(http.Pusher)
	if ok {
		pusher.Push("/image", nil)
	}
	w.Header().Add("Content-Type", "text/html")
	fmt.Fprintf(w, `<html><body><img src="/image"></body></html>`)
}

// 이미지 파일을 브라우저로 송신
func handlerImage(w http.ResponseWriter, r *http.Request) {
	w.Header().Set("Content-Type", "image/png")
	w.Write(image)
}

func main() {
	http.HandleFunc("/", handlerHtml)
	http.HandleFunc("/image", handlerImage)
	fmt.Println("start http listening :18888")
	err := http.ListenAndServe(":18888", nil)
	fmt.Println(err)
}

테스트

핸들러는 HTML과 이미지를 모두 준비하고 있다. HTML에 액세스가 있을 때 이미지의 URL을 푸시한다.

 

Push() 메서드의 첫 번째 인수에 경로를 전달한다. 내부 동작으로는 의사적으로 서버 액세스를 발생시켜 /image.png 콘텐츠를 얻으면, PUSH_PROMISE 프레임으로 클라이언트에 보낸다. HTTP/1.1 코드와의 차이는 Pusher로 캐스팅하고 가능하다면 푸시하는 부분뿐이다. 최소한의 업데이트로 서버 푸시에 대응할 수 있고, 캐스팅할 수 있는지 확인하는 코드라면 호환성이 있으므로 HTTP/1.1 환경에서도 동작한다. 

 

GODEBUG=http2server=0을 지정해서 HTTP/1.1에서 동작시켰을 때의 결과와 비교해보자. 크롬 개발자 도구의 네트워크 탭을 열어 비교해볼 수 있다. 왼쪽은 HTTP/1.1, 오른쪽은 HTTP/2 환경이다.

 

tls를 추가해 http/2 테스트는 이후에 해보자.

 

HTTP/1.1에서는 요청을 보낸 후 다은로드가 끝날 때까지 1.1밀리초 정도 걸린다. 또한 요청을 전송할 준비가 될 때까지의 대기시간도 2밀리초 걸린다. HTTP/2는 이미 다운로드가 끝나 로컬에 저장했기 때문에 큐에서 읽기만 하면 된다. 0.3밀리초정도 소모된다.

 

3.0 server-sent events

server-sent event는 청크 방식의 응용으로 http.Flusher를 사용하여 구현할 수 있다.

 

3.1 서버 구현

소수를 계속 표시하는 서비스를 만들어보자. 두 개의 파일로 나누어 구현한다.

  • 서버
  • HTML 뷰

우선은 서버 쪽이다. Go 언어 표준 라이브러리에서 확률론적으로 소수를 구하는 메서드 ProbableyPrime()을 사용한다.

[서버 코드]

package main

import (
	"fmt"
	"io/ioutil"
	"math/big"
	"net/http"
	"time"
)

var html []byte

// HTML을 브라우저에 송신
func handlerHtml(w http.ResponseWriter, r *http.Request) {
	// Pusher로 캐스팅 가능하면 푸시
	w.Header().Add("Content-Type", "text/html")
	w.Write(html)
}

// 소수를 브라우저에 송신
func handlerPrimeSSE(w http.ResponseWriter, r *http.Request) {
	flusher, ok := w.(http.Flusher)
	if !ok {
		http.Error(w, "Streaming unsupported!", http.StatusInternalServerError)
		return
	}
	
	closeNotify := w.(http.CloseNotifier).CloseNotify()
	w.Header().Set("Content-Type", "text/event-stream")
	w.Header().Set("Cache-Control", "no-cache")
	w.Header().Set("Connection", "keep-alive")
	w.Header().Set("Access-Control-Allow-Origin", "*")

	var num int64 = 1
	for id := 1; id <= 100; id++ {
		// 통신이 끊겨도 종료
		select {
		case <- closeNotify:
			fmt.Println("Connection closed from client")
			return
		default:
		// do nothing
		}
		for {
			num++
			// 확률론적으로 소수를 구함
			if big.NewInt(num).ProbablyPrime(20) {
				fmt.Println(num)
				fmt.Fprintf(w, "data: {\"id\": %d, \"number\": %d}\n\n", id, num)
				flusher.Flush()
				time.Sleep(time.Second)
				break;
			}
		}
		time.Sleep(time.Second)
	}
	// 100개가 넘으면 송신 종료
	fmt.Println("Connection closed from server")
}

func main() {
	var err error
	html, err = ioutil.ReadFile("index.html")
	if err != nil {
		panic(err)
	}
	http.HandleFunc("/", handlerHtml)
	http.HandleFunc("/prime", handlerPrimeSSE)
	fmt.Println("start http listening :18889")
	err = http.ListenAndServe(":18888", nil)
	fmt.Println(err)
}

정적 HTML을 반환하는 handler와 server-sent events 전송하는 handler를 정의했다. 

 

[뷰(index.html)]

<!DOCTYPE html>
<html>
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1" />
    <title>소수</title>
    <script src="//unpkg.com/mithril/mithril.js"></script>
    <script src="//unpkg.com/mithril-stream"></script>
  </head>
  <body>
    <div id="root"></div>
    <script>
      const component = {
        oninit: function (vnode) {
          this.primes = stream([]);
          const sse = new EventSource("/prime");
          sse.onmessage = (event) => {
            this.primes().splice(0, 0, JSON.parse(event.data));
            m.redraw();
          };
        },
        view: function () {
          return m("div", [
            m(
              "ul",
              this.primes().map((prime) => {
                return m("li", { key: prime.id }, prime.number);
              })
            ),
          ]);
        },
      };
      m.mount(document.getElementById("root"), component);
    </script>
  </body>
</html>

예제는 간결하게 표현하려고 클라이언트와 서버가 일대일로 되어 있다. 실제로는 채팅방 등 웹을 방문하는 여러 사용자 간 정보를 공유하기 위해 사용하는 경우가 많을 것이다. 그룹 간 사용자에게 데이터를 보내고자 사용자마다 만든 메시지, 클로즈 알림 채널을 배열로 관리해 메시지를 브로드캐스트하는 구조 등을 만들게 될 것이다.

 

3.2 클라이언트 구현

server-sent events는 구분 문자가 줄 바꿈이라는 것을 미리 알고 있는 청크 형식이므로, 이전에 배운 클라이언트 구현 방법 중 간편한 방법으로 구현할 수 있다.

 

[server-sent events 클라이언트 코드]

package main

import (
	"bufio"
	"bytes"
	"context"
	"fmt"
	"io"
	"net/http"
	"os"
)

type Event struct {
	Name string
	ID string
	Data string
}

func EventSource(url string) (chan Event, context.Context, error) {
	req, err := http.NewRequest("GET", url, nil)
	if err != nil {
		return nil, nil, err
	}
	ctx, cancel := context.WithCancel(req.Context())
	res, err := http.DefaultClient.Do(req)
	if err != nil {
		return nil, nil, err
	}
	if res.StatusCode != 200 {
		return nil, nil, fmt.Errorf("Response Status Code should be 200, but %d\n", res.StatusCode)
	}
	events := make(chan Event)
	go receiveSSE(events, cancel, res)
	return events, ctx, nil
}

func receiveSSE(events chan Event, cancel context.CancelFunc, res *http.Response) {
	reader := bufio.NewReader(res.Body)
	var buffer bytes.Buffer
	event := Event{}
	for {
		line, err := reader.ReadBytes('\n')
		if err != nil {
			close(events)
			if err == io.EOF{
				cancel()
				return
			}
			panic(err)
		}
		switch {
		case bytes.HasPrefix(line, []byte(":ok")):
			//skip
		case bytes.HasPrefix(line, []byte("id:")):
			event.ID = string(line[4 : len(line)-1])
		case bytes.HasPrefix(line, []byte("event:")):
			event.Name = string(line[7 : len(line)-1])
		case bytes.HasPrefix(line, []byte("data:")):
			buffer.Write(line[6:])
		case bytes.Equal(line, []byte("\n")):
			event.Data = buffer.String()
			buffer.Reset()
			if event.Data != "" {
				events <- event
			}
			event = Event{}
		default:
			fmt.Fprintf(os.Stderr, "Parse Error: %s\n", line)
			cancel()
			close(events)
		}
	}
}

func main() {
	events, ctx, err := EventSource("http://localhost:18888/prime")
	if err != nil {
		panic(err)
	}
	for {
		select {
		case <- ctx.Done():
			return
		case event := <-events:
			fmt.Printf("Event(ID=%s, Event=%s): %s\n", event.ID, event.Name, event.Data)
		}
	}
}

 

 

4.0 웹소켓

Go 언어용 웹소켓 구현은 여러 종류가 있다. 준표준 라이브러리(pkg.go.dev/golang.org/x/net/websocket)도 있지만, 이 준표준 라이브러리의 README에는 비 추천이라고 쓰여있다. 대체 라이브러리로 추천되는 것이 Go 언어의 웹 애플리케이션 프레임워크 Gorilla에서 제공하는 웹소켓 구현이다. 이 라이브러리는 서버와 클라이언트의 기능을 다 갖추고 있다.

사용법은 링크를 따라 들어가자.

 

4.1 서버 구현

[서버의 HTTP 핸들러 내에서 업그레이드]

var upgrader = websocket.Upgrader{} // use default options

func echo(w http.ResponseWriter, r *http.Request) {
	c, err := upgrader.Upgrade(w, r, nil)
	if err != nil {
		log.Print("upgrade:", err)
		return
	}
}

필수 헤더를 몇 개 꺼내 검증한 후, 프로토콜 업그레이드 절에서 소개한 http.Hijacker 메서드로 내부 소켓을 추출한다.

 

Upgrade() 메서드 내용

	h, ok := w.(http.Hijacker)
	var rw *bufio.ReadWriter
	notConn, rw, err = h.Hijack()

다음과 같은 응답 헤더를 포함하는 통신 응답을 작성한다. 그다음은 이 소켓을 직접 사용해 응답을 반환한다. 이것으로 업그레이드는 완료다. websocket.Conn은 net.Conn과의 호환성은 없지만, 비슷한 메서드를 몇 가지 갖추고 있다.

HTTP/1.1 101 Switching Protocols
Upgrade: websocket
Connection: Upgrade

업그레이드 완료 후, 통신에서 주로 사용하는 메서드는 다음 두 가지이다. 메시지 종류를 받고 반환하는 것 이외에는 net.Conn의 Read(), Write() 메서드와 같다.

 

[사용 방법]

// 쓰기 메서드
ws.WriteMessage(messageType int, data []byte) error

// 읽기 메서드
ws.ReadMessage() (messageType int, data []byte, err error)

 

 

4.2 클라이언트 구현

Go 언어의 표준 라이브러리 net.Dial을 본뜬 API로 간단히 접속할 수 있게 됐다.

 

[웹소켓의 클라이언트 코드]

c, _, err := websocket.DefaultDialer.Dial(u.string(), nil)

ULR을 인수로 전달하면, 서버 절에서 소개한 것과 같은 websocket.Conn 소켓이 반한된다. 나머진 서버와 완전히 똑같은 API를 사용해 양방향 통신을 할 수 있다.

 

클라이언트에서 하는 일은 서버와 마찬가지로 프로토콜 업그레이드이다. net.Requset 구조체를 사용해 GET의 HTTP 요청을 상대의 URL이 가리키는 서버에 보낸다. 응답으로 스테이터스 코드 101이 오는지 확인한 후, 필요한 헤더를 가져와 클라이언트의 소켓 구조체를 초기화한다.

 

4.3 방의 구현

server-sent events든 웹소켓이든 커뮤니케이션을 위해 이용한다면 방 기능을 구현해야 한다. 해당 예제에서는 같은 URL에 액세스한 사람은 같은 방에 들어가지만, 대규모 서비스면 방에 들어가는 멤버를 선택하기 위한 로비도 필요하다.

 

Gorilla의 chat 예제에서는 'Hub'라는 이름의 방을 구현한다.

[방 구현]

type Hub struct {
	// 등록된 클라이언트
	clients map[*Client]bool
	// 클라이언트가 보내는 메시지
	broadcast chan []byte
	// 클라이언트의 등록 요청
	register chan *Client
	// 클라이언트의 말소 요청
	unregister chan *Client
}

// 초기화 후에 goruntine으로 실행되는 메서드
func (h *Hub) run() {
	for {
		select {
		case client := <-h.register:
			h.clients[client] = true
		case client := <-h.unregister:
			if _, ok := h.clients[client]; ok {
				delete(h.clients, client)
				close(client.send)
			}
		case message := <- h.broadcast:
			for client := range h.clients {
				select {
				case client.send <- message:
				default:
					close(client.send)
					delete(h.clients, client)
				}
			}
		}
	}
}

초기화 시 클라이언트 목록을 관리하는 map과 클라이언트가 보내는 비동기 메시지를 수신하는 채널이 세 개(메시디, 등록, 말소) 있다. 메시지 브로드캐스트 채널에 메시지를 보내면 관리하는 모든 클라이언트의 소켓에 포워드한다. 접속 및 종료 처리도 하고 있다.

 

 

 

 

Reference

  • 리얼월드 HTTP

1.0 반응형 디자인

이전 유저 에이전트 정보를 보고 모바일, 웹 등을 구분하여 콘텐츠를 배포하는 방식이 주류였다. 현재는 작은 화면에 적합한 레이아웃 또는 테블릿의 가로와 세로에 각각 최적인 레이아웃 등 다양한 경우 적절하게 표현할 수 있도록 하는 것이 반응형 디자인이다.

 

2.0 시맨틱 웹

웹에서 표면적인 '텍스트', '문서'가 아니라 '의미'를 다룰 수 있게 해서 웹의 가능성을 더욱 넓히고자 하는 운동이다. 웹의 아버지인 팀 버너스리가 주장했다. 시맨틱 웹에서 '의미를 다룬다'는 것은 페이지에 포함된 정보를 분석해서 정보를 집약하거나 검색하는 과정을 사람 손을 거치지 않게 하는 것이다. 그러기 위해서 데이터를 정규화해 프로그램에서 접근 가능한 형태로 만들어야 한다. 

 

1. RDF

시맨틱 웹의 최초로 URI로 식별되는 엔티티 간의 관계성을 기술한다. 

 

2. 더블린 코어

컴퓨터 전문가가 아니더라도 알기 쉬운 용어로 제안. 자원은 대부분 누군가의 저작물인 경우가 많은데, 효과적으로 검색할 수 있도록 저작물 특유의 특성을 정의. 오픈 eBook이라는 규격에 포함되는 파일 목록과 서적 정보를 담은 OPF 파일이라는 EPUB 파일의 핵심 파일이 있다. 해당 파일의 dc:로 시작하는 메타데이터가 더블린 코어의 구성 요소이다.

 

3. RSS

RDF의 응용 사례로서 가장 성공한 것으로 웹사이트의 업데이트 기록 요약에 관한 배급 형식이다. 블로그나 웹사이트를 업데이트하면 블로그 엔진과 콘텐츠 관리 시스템이 자동으로 업데이트 한다. 이 RSS 파일에는 새로운 순서대로 신규 콘텐츠의 요약이 저장된다.

 

4. 마이크로포맷

RDF와 달리 HTML 태그와 클래스를 사용해 표면해 간다. 웹사이트 간의 공유 용어집(스키마)을 카탈로그화해서 정보 유통을 활성화한다. 자세한 내용은 microformats.org에 집약되어 있다.

 

5. 마이크로데이터

WHATWG, W3C 등에서 논의돼 온 HTML에 삽입 가능한 시맨틱 표현 형식이다. 마이크로포맷과 마찬가지로 어휘집을 가지고 있으며, 현재도 검색 엔진이 지원한다. 마이크로포맷과 다리 기존 HTML 속성과 충돌하지 않도록 고유 속성을 사용한다. 

 

6. RDF의 역습

RDF 자체는 널리 사용되지 않았지만, 이후에 여러가지 파생 포맷이 만들어졌고 schema.org에 대량의 어휘집이 게재되어 있다. schema.org에서는 예제 데이터로 마이크로데이터, RDFa, JSON-LD 세 종류를 게재했다. 구글이 추천하는 포맷으로서 향후 가장 많이 이용될 것으로 예상되는 것이 JSON 형식의 JSON-LD이다. 

 

 

3.0 오픈 그래프 프로토콜

오픈 그래프 프로토콜은 소셜 네트워크에서 사용되는 메타데이터로, 소셜 네트워크에서 가장 높은 점유율을 자랑하는 페이스북이 개발했다. 오픈 프로토콜이 설정된 웹사이트의 URL을 SNS 등에 붙여 넣으면 기사의 일부가 인용되고 이미지도 표시된다. 티스토리에서도 사용되고 있다. 아래 사진과 같이 링크를 붙였을 때 나타난다.

오픈 그래프 프로토콜은 더블린 코어, link-rel canonical, 마이크로포맷, RDFa에 영감을 받았다고 되어 있으므로 시맨틱 웹의 자손이라고 볼 수 있다.

 

4.0 AMP

휴대 기기에서 웹페이지의 로딩 속도를 빠르게하는 방법이다. 네트워크 환경이 나쁜 나라에서는 극적인 사용자 경험 향상을 얻을 수 있다고 한다. 어떤 페이지에서나 속도가 빨라지는 것은 아니고, 구글에서 말하길 뉴스, 요리법, 영화 목록, 제품 페이지, 리뷰, 동영상, 블로그 등 모든 유형의 정적 웹 콘텐츠에서 효능이 있다고 한다. 

 

5.0 HTTP 라이브 스트리밍에 의한 동영상 스트리밍 재생

HTTP 라이브 스트리밍(HLS)은 애플이 2009년에 제창한 동영상 스트리밍 재생 방식이다. HLS의 내부는 매우 단순하다. WWDC 2016에서 HLS를 소개한 세션의 페이지는 <video> 태그를 사용한다. 

 

5.5 HLS의 장점과 단점

HLS의 장점은 서버와 클라이언트의 통신에 특수한 프로토콜이 아니라 전 세계 라우터가 지원하는 HTTP를 사용하는 점이다. 만약, 특수한 프로토콜이라면 전용 서버를 설치하고 통신할 포트를 열어야 할 필요가 있다. 하지만, HLS는 HTTP이므로 일반 웹 서버와 동일한 서버를 사용할 수도 있고, 콘텐츠 전송 네트워크 또한 사용할 수 있다.

 

단점으로는 '스트리밍'라고 자칭하지만, 실체는 점진적 다운로드 방식이라는 점이다. 청크별로 다운로드가 끝나지 않으면, 재생이 시작되지 않으므로 지연이 발생한다. 애플의 자료에서는 표준으로 30초 정도 지연이 있다고 적혀 있다. 12초의 청크로 회선이 허용하는 최대 화질로 영상을 재생하려면 '12x2+인코딩 시간'만큼 지연이 발생한다.

 

가장 큰 단점은 지원되지 않는 환경이 많다는 점이다. 특히, 데스크탑에서의 지원이 좋지 않다. 

 

6.0 MPEG-DASH 동영상 스트리밍 재생

HTTP 라이브 스트리밍은 애플이 제창한 방식이었지만, 다른 브라우저의 밴더는 더 폭 넓게 공통화할 수 있는 기술로서 수 많은 동영상 포맷을 배출한 MPEG 컨소시엄에서 표준화를 진행하고자 했다. 

 

DASH는 Dynamic Adaptive Streaming over HTTP의 약자로 HTTP를 사용해 동적으로 적절한 비트레이트로 스트리밍하는 방식을 뜻한다. MPEG-DASH가 목표하는 방향은 HLS와 거의 같고, HLS를 대폭 확장한 것이다. 

 

MPEG-DASH 또한 HTTP를 통한 점진적 다운로드를 핵심으로 하는 스트리밍 방식이다. 레퍼런스 구현으로서 video.js라는 라이브러리가 제공된다. 해당 라이브러리를 이용하면 브라우저에서 MPEG-DASH를 사용할 수 있다. 

 

6.1 MPEG-DASH와 HLS 재생 방법의 차이

HTTP를 통한 점진적인 다운로드를 핵심으로 하는 스트리밍 방식이라는 점에서 같지만, 대전제가 되는 재생 방법이 달라진다.

 

HLS는 HTML에서 재생한다기보다는 브라우저 자체에 HLS의 .m3u8 파일을 해석해서 재생하는 시스템이 포함되어 있었다. 또한 mac OS에서 미디어 플레이어 프레임워크를 이용하여 브라우저 이외에서도 재생할 수 있다.

 

마찬가지로 MPEG-DASH도 브라우저 외의 뷰어가 있다. 레퍼런스 구현으로는 자바스크립트로 만든 동영상 플레이어 dash.js가 제공된다. 브라우저 자체에서 프로토콜을 직접 해석하는 것이 아니라 데이터 분석은 자바스크립트로 하고 동영상 재생은 브라우저의 코덱을 자바스크립트에서 다루는 API, HTML5 Media Source Extensions를 이용해 실시한다. 

 

애플은 제공하지 않지만, MPEG-DASH와 같은 Media Source Extensions을 사용해 지원하지 않는 브라우저로도 HLS를 재생하는 동영상 플레이어도 이미 개발됐으며, 다양한 프로그램과 라이브러리가 두 방식을 다 지원하고 있어 현재는 차이가 거의 없어졌다고 봐도 문제 없다. 또한, 2016년 WWDC에서 애플은 MPEG-DASH 측에 양보하는 발표를 했다. Fragmented MPEG4를 지원하고, 이를 통해 MPEG-DASH와 같은 미디어를 사용할 수 있게 되었다.

 

 

 

 

Reference

  • 리얼월드 HTTP

1.0 HTTP/2

HTTP/2는 데이터 표현이 HTTP/1.1와는 크게 달라졌다.

  • 스트림(1.1의 파이프라인에 가까운 것)을 사용해 마이너리 데이터를 다중으로 송수신하는 구조로 변경했다.
  • 스트림 내 우선 순위 설정과 서버 사이드에서 데이터 통신을 하는 서버 사이드 푸시를 구현했다.
  • 헤더가 압축되게 되었다.

단 메서드, 헤더, 스테이터스 코드, 바디라는 HTTP가 제공하는 네 개의 기본 요소는 바뀌지 않는다. HTTP/2의 목적은 통신 고속화뿐이다. 물론 빠른 속도는 중요하지만, 지금까지 해당 책에서 설명한 내용은 현재도 변함이 없다.

 

HTTP/1.0 부터 HTTP/1.1에 걸쳐, TCP 소켓 수준에서 보면 아래와 같은 개선이 이루어졌다.

기능 효과
캐시(max-age) 통신 자체를 취소
캐시(ETag, Date) 변경이 없으면 바디 전송을 취소
Keep-Alive 액세스마다 연결에 걸리는 시간(1.5TTL)을 줄임
압축 응답 바디 크기 절감
청크 응답 전송 시작을 빠르게 함
파이프라이닝 통신 다중화

 

한 번의 통신이 연결되려면 접속 확립 요청을 주고받으며 몇 번씩 패킷이 오가게 된다. 이때 데이터 크기를 통신 속도로 나눈 시간만큼 통신이 걸린다. 통신 대기가 있으면, 그만큼 통신이 완료되는 시간은 길어진다.

 

지금까지 이루어진 고속화는 통신의 모든 부분에서의 고속화 기여했다. HTTP/2에서는 그동안 손보지 못한 헤더부 압축이나 규격화됐지만 여전히 활용되지 않은 파이프라이닝을 대체하는 구현이 추가됐다.

 

HTTP/2은 이전까지와는 완전히 다른 프로토콜이므로, 반대로 하위 호환성 문제가 일어나기 어렵다. HTTP의 텍스트 프로토콜 내부에서의 버전 전환이 아니다. HTTP/1.1과는 전혀 다른 프로토콜로 취급되므로 파이프라이닝과 같은 문제가 생길 수 없다.

 

1.1 스트림을 이용한 통신 고속화

HTTP/2의 가장 큰 변화는 텍스트 기반 프로토콜에서 바이너리 기반 프로토콜로 변화했다는 점이다. 각 데이터는 프레임 단위로 송수신을 한다. HTTP/1.1까지는 하나의 요청이 TCP 소켓을 독접했기 때문에, 하나의 오리지널 서버에 대해 2~6개의 TCP 접속을 해서 병렬화했다.

 

HTTP/2에서는 하나의 TCP 접속 안에 스트림이라는 가상의 TCP 소켓을 만들어 통신한다. 스트림은 프레임에 따른 플래그로 간단히 만들고 닫을 수 있는 규칙으로 되어있고, 일반 TCP 소켓과 같은 핸드세이크는 필요 없다. 그래서 ID 값과 TCP 통신 용량이 허락하는 한, 손쉽게 몇 만번의 접속이라도 병렬화 할 수 있다. 각 접속 상태의 state machine이 정의되어 있으며, 비슷한 모델로 되어 있는 것을 알 수 있다.

HTTP/2 스트림 접속 스테이트 머신

 

TCP는 닫힌 상태로 LISTEN하다가, 클라이언트에서 접속 요청이 있으면 비로소 ESTABLISH(통신 가능) 상태가 된다. HTTP/2의 스트림은 처음부터 LISTEN과 거의 같은 IDLE 상태이고, 헤더를 받으면 즉시 통신 가능한 OPEN 상태가 된다. 즉 통신할 수 있을 때까지의 단계가 줄어든 것이다.

 

요소 크기 의미
Length 24 페이로드 크기
Type 8 프레임 종류
Flags 8  
R 1 예약 영역
Stream Identifier 31 스트림 식별자. 같음 값이면 같은 스트림
Frame Payroad Length로 지정한 길이 프레임의 실제 데이터

 

공통 헤더 목록 중 주목해야 할 것은 Stream Identifier이다. HTTP/2에서는 의사적인 소켓으로 스트림이 만들어진다. TCP 소켓상의 데이터를 봐도, 스트림이라는 실체가 있는게 아니다. 같은 Stream Identifier를 가진 일련의 프레임은 수신 시에 그룹화되며, '같은 스트림에서 나온 데이터'로 취급된다.

 

종류 데이터 선택적 데이터 설명
HEADERS 헤더 의존하는 스트림과 우선도, 베타 플래그 압축된 헤더, 우선도는 최초 헤더만 사용 가능
DATA 데이터    
PRIORITY 의존하는 스트림 우선도, 베타 플래그    
RST_STREAM 오류 코드   오류 정보를 반환하고, 스트림을 바로 종료
SETTINGS 식별자(16비트),
설정값(32비트)
   
PUSH_PROMISE 스트림 ID 요청 헤더 필드 서버 푸시 시작 예약
PING 8바이트 데이터   응답 속도 측정용 프레임, PING을 받으면 ACK 플래그를 설정해 반환
GOAWAY 최종 스트림ID, 오류 코드 추가 디버그 정보 커넥션을 종료
WINDOW_UPDATE 윈도우 크기 추가로 수신할 수 있는 데이터 크기  
CONTINUATION   HEADERS/PUSH_PROMISE에 이어지는 데이터  

 

SETTINGS에서는 헤더 테이블 크기, 푸시 허가, 최대 병렬 스트림 수, 초기 윈도우 크기, 최대 프레임 크기, 최대 헤더 리스트 크기를 변경할 수 있다.

 

옵션은 공통 헤더의 Flags에 '이 데이터가 있어요'라고 지정한 비트가 켜지면 정해진 크기의 정보가 순서대로 프레임에 추가된다. 비트가 켜지지 않으면, 그 데이터는 프레임째 생략되므로 불필요한 데이터 때문에 데이터 길이가 늘어나는 것을 방지한다. 

가장 작은 HEADERS 프레임

 

 

1.2 HTTP/2의 애플리케이션 계층

HTTP/1.0은 단순히 데이터를 운반하는 상자였다. 그 뒤로 Keep-Alive, 파이프라이닝 등 하위 계층의 통신 처리에 영향을 주는 기능도 포함하기 시작했지만, 이 책에서 몇 번씩이나 언급하고 있는 메서드와 경로, 헤더, 바디, 스테이터스 코드라는 네 가지 기본 요소가 존재한다는 것은 변함이 없다. 하지만 메서드와 경로, 스테이터스코드, 나머지 스테이터스 코드에 포함되어 있던 프로토콜 버전은 모두 유사 헤더 필드화되어 헤더 안에 들어간다. 상위 애플리케이션에서 본 기능성에는 변경이 없지만, 구현상으로는 헤더와 바디만 있다.

HTTP/2의 바이터리화된 통신

HTTP/1.1은 텍스트 프로토콜이었다. 헤더의 종단을 찾으려면 빈 줄을 찾을 때까지 1바이트씩 미리 읽어 발견할 필요가 있었다. 오류 처리도 있고 서버로서는 해석까지 포함해 순차적으로 처리할 수 밖에 없으므로 고급 병렬 처리는 어렵다. HTTP/2는 바이너리와되어, 처음에 프레임 크기가 들어간다. TCP 소켓 레이어에서는 테이블 프레임 단위로 쉽게 분리할 수 있으므로, 수신 측 TCP 소켓의 버퍼를 빠르게 비울 수 있고, 통신 상대에게 다음 데이터를 고속으로 요청할 수 있다.

 

바디는 Content-Length로 크기가 유일하게 정해지는 경우가 많고 청크 형식의 경우도 크기가 적혀 있기 때문에, 하나의 요청에 대한 로드 처리 비용은 다르지 않다. 그러나 요청이 여러 개일 때는 이야기가 다르다. HTTP/1.1에서는 청크 형식이라고해도 하나의 요청 중에 다른 요청을 처리할 수 없었다. 여섯 개의 TCP 세션으로 처리하더라도 무거운 응답이 여섯 개 있으면, 다른 통신을 전혀 할 수 없다. HTTP/2에서는 청크가 프레임으로 분할되어 있고 프레임끼리는 독립적이므로, 중간에 다른 프레임이 끼어들어도 문제가 없다.

 

 

1.3 플로 컨트롤

HTTP/2는 인터넷 4계층 모델 중 애플리케이션 층에 해당되지만, Transport 계층에 가까운 것을 내부에 가지고 있는 게 특징이다. 플로 컨트롤로 TCP 소켓과 거의 같은 기능을 구현했다. 물론 TCP가 패킷 순서 제어와 재전송 처리를 해주므로 구현은 단순하다. TCP 소켓과 HTTP/2 스트림의 관계는 OS 스레드와 그린 스레드와 비슷하다고 할 수 있다.

 

플로 컨트롤은 스트림을 효율적으로 흐르게 하려고 이용되는 통신량 제어 처리이다. 통신 속도가 지나치게 차이 나는 기기의 조합으로 통신할 때 빠른 쪽이 느린 쪽에 대량으로 패킷을 보내버려 처리할 수 없게되는 사태를 방지하는게 목적이다. 이를 실현하고자 구체적으로는 통신하는 곳의 윈도우 크기 관리를 사용한다. 윈도우 크기는 받아들일 수 있는 빈 버퍼 크기이다. 기본 초기 윈도우 크기는 64킬로바이트이다. 손신하는 쪽은 상대방의 최대 버퍼 크기만큼 데이터를 보낸다. 수신하는 쪽에서 전송된 패킷을 처리하고 버퍼에 여유가 생기면, WINDOW_UPDATE 프레임을 이용해 새로 생긴 여유 버퍼 크기를 송신하는 쪽에 반환한다. 소신하는 쪽에서 이 통지를 받으면, 새로 생긴 여유 버퍼를 채울 만큼 이어지는 데이터를 보낸다. 

 

SETTINGS 프레임을 사용하면, 초기 윈도우 크기, 최대 병렬 스트림 수, 최대 프레임 크기, 최대 헤더 리스트 크기와 같은 속도에 관련된 매개변수를 조정할 수 있다.

 

1.4 서버 푸시

시맨틱스를 보면 HTTP/1.1과 HTTP/2는 거의 같지만, 서버 푸시로 불리는 기능만은 다르다. 서버 푸시를 이용해 우선순위가 높은 콘텐츠를 클라이언트가 요구하기 전에 전송할 수 있게 됐다. 단, 웹소켓처럼 양방향 통신을 실현하는 기술과 달리, 어디까지나 CSS와 자바스크립트, 이미지 등 웹페이즈를 구성하는 파일을 다운로드 하는 용도로 이용된다. 푸시된 콘텐츠는 사전에 캐시에 들어간다. 콘텐츠가 캐시에 들어간 뒤에 클라이언트가 그 파일을 요청하면, 곧바로 다운로드할 수 있는 것처럼 보인다. 채팅이나 구글 스프레드시트로 공동 편집 작업을 할 때 다른 사람이 입력한 주석을 서버가 배포하는 용도로는 사용할 수 없다.

 

1.5 HPACK을 이용한 헤더 압축

헤더는 HPACK이라는 방식으로 압축된다. HTTP 헤더는 정해진 이름이나 결과가 자주 출현하기때문에, 이를 외부 사전에 넣어두면 압축 후 크기가 작아진다. HTTP/2에서는 정적 테이블이라는 이름으로 사전에 빈번하게 출현하는 헤더 이름과 헤더 값을 테이블로 가지고 있다.

 

추가로 같은 커넥션에서 등장한 HTTP 헤더는 인덱스화되어 동작 테이블에 저장된다. 다시 등장할 때는 인덱스 값만으로 표헌할 수 있으므로 작은 크기로 송수신할 수 있다.

 

1.6 SPDY와 QUIC

HTTP/2의 역사를 말할 때 빠뜨릴 수 없는 것이 SPDY(스피디)이다. SPDY는 구글이 개발한 HTTP 대체 프로토콜로, 거의 그대로 HTTP/2가 되었다.

 

SPDY는 구글의 서비스 내부에서 사용됐고, 크롬과 파이어폭스, 인터넷 익스플로러, 사파리에도 구현됐습니다. 구글의 강점은 대규모 트래픽이 일어나는 웹 서비스와 점유율이 높은 브라우저를 모두 가지고 있는 점이다. 자사 서비스와 자사 브라우저 간에서만 활성화되는 프로토콜이라도 대규모 검증 실험을 할 수 있다. SPDY는 2010년 크롬에서 구현된 뒤에도 업그레이드를 거듭하며 2014년에 HTTP/2에 바톤을 넘기고 그 역할을 마쳤다. 처음으로 SPDY가 구현된 크롬도 2016년 5월에 출시된 크롬51에서 SPDY를 무효화하고 HTTP/2로 단일화했다.

 

구글이 SPDY를 개발했던 이유는 그동안 HTTP가 개선해온 전송 속도를 한층 더 향상시키기 위해서이다. 웹사이트 구성에 따라 효과가 크게 달리지기에 도입 효과는 30%에서 세 배 이상까지 여러 수치를 들 수 있지만 병렬 접속으로 블로킹이 줄어들어 작은 파일을 많이 전송할수록 빨라진다.

 

'웹사이트의 자바스크립트, CSS, 이미지 등은 가급적 적은 파일 수로 정리하는 고속화 방식'이 HTTP/1.1 시대에 빠른 웹사이트를 만드는 일반적인 기술이었다. SPDY와 HTTP/2 이 후, 파일 정리의 효과는 작아진다. 압축이 되긴 하지만, 헤더의 크기는 0이 아니고, 파일 크기가 작아질수록 패킷의 틈새가 낭비되는 경우도 증가한다. 또한 결합하는 편이 여전히 통신량은 줄어들지만, 자잘한 편이 변경이 있을 때 캐시가 살아남을 확률이 높아진다.

 

SPDY는 어디까지나 HTTP와 같은 층인 TCP 소켓상에 구현됐지만, 구글은 한 층 더 빠르게 하려고 UDP 소켓상에 QUIC(퀵)이라는 프로토콜을 준비했다. TCP는 접속 초기에 여러번 통신을 주고받을 필요가 있다. 에러를 정정하거나 순서를 정렬하기 위해 수신 통지를 반환해야 하는 등 고기능인 만큼 성능은 다소 떨어진다.

 

TCP와 쌍이 되는 경량 프로토콜이 UDP이다. TCP에서 재전송 처리, 혼잡 제어 등 고급 기능을 제거해서 처음 접속할 때의 니고시에이션을 가볍게 한 프로토콜이다. 패킷 유실시 재전송 처리, 통신 경로 혼잡 시 제어 등 QUIC은 많은 기능을 자체적으로 구현했다. 

 

일반적인 HTTPS 통신에서는 TCP 핸드세이크를 실시한 후에 별도로 TLS 핸드세이크를 할 필요가 있어, 몇 번씩 왕복하며 패킷을 교환할 필요가 있었다. QUIC은 양쪽을 통합해, 더 적은 횟수의 통신으로 접속할 수 있다. 첫 접속에서도 1왕복 통신으로 니고시에이션하거나 재접속할 때는 니고시에이션 없이 0RTT로 재전송할 수 있는 구조로 되어 있다. 또 스마트폰이 3G/4G 회선에서 와이파이 연결로 전환했을 때의 재접속도 원활하게 됐다.

 

QUIC이 뛰어난 점은 재접속과 첫 통신 비용뿐만이 아니다. 이외의 TCP와 다름없는 듯하지만, HTTP/2와 협조해 동작함으로써 두 계 층에서 중복됐던 테스크를 단순화했다. 예를 들어, 거의 같은 기능이 TCP와 HTTP/2 양쪽에 있던 flow control이 일원화 됐다. 패킷 순서 정렬화에서는 애플리케이션 층을 모르는 TCP는 모든 패킷을 우직하게 정렬하지만, HTTP/2에서는 스트림 단위로 필요한 만큼 정렬한다.

 

2.0 Fetch API

Fetch API는 XMLHttpRequest와 마찬가지로 서버 액세스를 하는 함수이다. 자바스크립트에서 이요되며, 아래와 같은 특징이 있다.

  • XMLHttpRequest보다 오리진 서버 밖으로의 액세스 등 CORS 제어가 쉬워진다.
  • 자바스크립트의 모던한 비동기 처리 작성 기법인 프로미스를 따른다.
  • 캐시를 제어할 수 있다.
  • 리디렉트를 제어할 수 있다.
  • 리퍼러 정책을 설정할 수 있다.
  • Service Worker 내에서 이용할 수 있다.

Fetch API는 저수준 API로 소개되기도 하고 W3C사양에도 저수준이라고 적혀있다. 이 뜻은 캐시 등의 제어가 가능하다는 뜻이다. 

 

2.1 Fetch API의 기본

아래는 Fetch API를 사용하는 예이다.

fetch("news.json", {
		method: 'GET',
		mode: 'cors',
		credentials: 'include',
		cache: 'default',
		headers: {
			'Content-Type': 'application/json'
		}
	}).then((response) => {
		return response.json();
	}).then((json) => {
		console.log(json);
	})

기본 요소는 아래 네가지이다.

  1. XMLHttpRequest처럼 오브젝트를 만드는게 아니라 fetch() 함수를 호출한다.
  2. fetch() 함수의 두 번째 인수는 옵션 오브젝트 (생략 가능)
  3. .then() 함수에 서버 응답이 돌아온 후에 호출되는 콜백을 넘겨준다.(프로미스)
  4. .then() 에 넘겨주는 콜백이 다시 시간이 걸리는 처리를 하고, 그 처리가 프로미스를 반환할 때는 .then()을 연결한다.

Fetch API의 첫 번째, .then() 절은 응답의 헤더 부근까지 읽기를 마친 시점에서 호출된다. Fetch API는 다양한 자료형을 지원해서, 첫 번째 .then() 절 안에서 바디를 어떤 형식으로 가져올지 메서드 호출로 결정한다. 그 뒤 처리는 서버에서의 읽기를 동반하므로, 다시 .then() 절을 호출해 다운로드가 끝나기를 기다린다. 해당 예제에서는 JSON 형식으로 읽어들인다.

 

아래는 Fetch API로 이용할 수 있는 메서드이다.

대응 메서드 목록
CORS 안전 GET, HEAD, POST
금지 메서드 CONNECT, TRACE, TRACK

GET, HEAD, POST의 세 가지는 CORS 안전으로서 특별하게 취급된다.

 

아래는 Fetch API CORS 모드이다.

설정값 Fetch 기본 XHR 기본 설명
cors   O 다른 오리진 서버로의 액세스를 허용한다.
same-origin     다른 오리진 서버로의 액세스를 오류로 한다.
no-cors O   CORS 접속은 무시되고 빈 응답이 돌아온다.

 

쿠키 제한은 credentials에 설정한다. 설정할 수 있는 값은 아래와 같다. XMLHttpRequest의 경우는 withCredential 프로퍼티에 true를 설정하면 include를 설정한 것과 마찬가지가 된다.

 

아래는 Fetch API Credentials이다.

설정값 Fetch 기본 XHR 기본 설명
omit O   쿠키를 보내지 않는다.
same-origin   O 출처가 같은 경우에만 쿠키를 보낸다.
include     쿠키를 보낸다.

 

앱은 속도와 보안 향상이라는 두 방향으로 발전했는데, Fetch API는 후자에 해당한다. 기본으로 더 엄격한 설정이 선택되어 있고, 필요에 따라 명시적으로 해제하는 설계로 되어 있다.

 

2.2 Fetch API만 할 수 있는 것

 

캐시 제어

Fetch API의 특징으로서 자주 소개되는 것이 캐시이다. 캐시는 세밀하게 제어할 수 있다. no-store와 reload, no-cache를 사용하면, 캐시 상태와 상관 없이 강제로 요청이 발생한다. 이 가운데 no-cache의 경우는 캐시에 대한 정보를 보내므로, 바디가 송신되지 않은 채 304 Not Modified를 수신할 가능성이 있다.

 

반대로 캐시를 적극적으로 하는 것이 force-cahce와 only-if-cached이다. Max-Age 헤더로 지정한 기한이 다 되어도 적극적으로 캐시를 사용한다. 후자는 캐시가 없으면 오류가 발생하고 외부로의 요청은 일어나지 않는다.

아래는 Fetch API 캐시 제어이다.

설정값 기본 설명
Default O 표준적인 브라우저 동작을 따른다.
no-store   캐시가 없는 것으로 해서 요청한다. 결과도 캐시하지 않는다.
Reload   브라우저 새로고침과 같이 캐시가 없는 것으로 해서 요청한다. ETag 등은 보내지 않는다. 캐시 가능하면 결과를 캐시한다.
no-cache   기한 내의 캐시가 있어도 HTTP 요청을 보낸다. 로컬 캐시의 ETag 등도 보내고, 서버가 304를 반환하면 캐시한 콘텐츠를 사용한다.
force-cache   기한이 지난 캐시라도 있으면 이용한다. 없으면 HTTP 요청을 보낸다.
only-if-cached   기한이 지난 캐시라도 있으면 사용한다. 없으면 오류가 발생한다.

 

리디렉트 제어 

아래는 Fetch API 리디렉트 제어이다.

설정값 기본 설명
follow O 리디렉트를 따라간다. (최대 20 리디렉트까지)
manual   리디렉트를 따라가지 않고 리디렉트가 있다는 것만 전달한다.
error   네트워크 오류로 한다.

manual 지정 시 리디렉트가 있으면, 응답 자체가 아니라 응답을 감싸 필터링된 결과를 응답으로서 반환한다. 이 응답은 type 속성에 opaqueredirect라는 문자열이 들어있는 이외의 정보는 필터링되어 아무것도 얻을 수 없다. 리디렉트 도중에는 보안상 누설돼선 곤란한 URL이나 헤더가 포함되기 때문이다. 바디는 null로 상태도 0이고 헤더도 얻을 수 없으므로, 실질적으로 '리디렉트가 있었다'는 것밖에 모른다. error와 달리 오류가 되지 않는 정도의 의미이다.

 

Service Worker 대응

Fetch API로는 할 수 있고 XMLHttpRequest로는 할 수 없는 잡다하게 있지만, 가장 큰 일은 Service Worker 대응일 것이다. 현재 Service Worker 내에서 외부 서비스로 접속할 때는 Fetch API만 사용할 수 있는 사양으로 되어 있다.

 

웹이 애플리케이션의 기능성을 지닐 수 있게 하려는 노력이 구글을 중심으로 이루어졌다. 그런 노력의 하나로서 애플리케이션의 생애주기와 통신 내용을 제어할 수 있게 하는 Service Worker가 개발됐다. Service Worker는 웹 서비스의 프론트엔드 자바스크립트와 서버 사이에서 동작하는 중간 레이어이다.

 

3.0 server-sent events

server-sent events는 HTML5의 기능 중 하나다. 기술적으로는 HTTP/1.1의 청크 형식을 이용한 통신 기능을 바탕으로 한다. 청크 형식은 거대한 파일 콘텐츠를 작게 나누어 전송하기 위한 통신 방식이었다. 청크 형식의 '조금씩 전송'한다는 특징을 응용해, 서버에서 임의의 시점에 클라이언트에 이벤트를 통지할 수 있는 기능을 실현했다. 

 

HTTP는 기본적으로 클라이언트에서 요청을 서버로 보내고, 서버가 요청에 대해 응답하는 클라이언트/서버 모델이다. 통신의 시작은 클라이언트가 결정하고 , 클라이언트가 1회 요청하면 1회 응답이 발생하는 것이 기본 구성이다. 서버에서 정보를 돌려보내는 방법으로는 4장에서 소개한 코멧이 있다. 클라이언트에서 정기적으로 요청을 보내 서버 이벤트를 검출하거나 요청을 받은 상태에서 응답을 보류하는 방법이 자주 사용됐다. 

 

server-sent events는 코멧의 롱 폴링과 청크 응답을 조합해, 한 번의 요청에 대해 서버에서 여러 이벤트 전송을 제공한다. 검증된 청크 방식을 사용하므로, 프록시 지원도 포함해 하위 호환성에 문제는 없다. 

 

server-sent events는 청크 방식을 사용하지만, HTTP 위에 별도의 텍스트 프로토콜을 실었다. 이는 이벤트 스트림으로 불리며, MIME 타입은 text/event-stream이다. 

아래는 text/event-stream 예제이다.

id: 10
event: ping 
data: {"time": 2016-12-26T15:52:01+0000}

id: 11
data: Message from PySpa
data: #eng channel

텍스트의 문자 인코딩은 UTF-8이다. 데이터는 태그 뒤에 기술한다. 아래와 같이 네 종류의 태그가 있고, 빈 줄로 데이터를 구분한다. 

태그 종류 설명
id 이벤트를 식별하는 ID. 재전송 처리에서 이용된다.
event 이벤트 이용을 설정한다.
data 이벤트와 함께 보낼 데이터
retry 재접속 대기 시간 파라미터 (밀리 초)

자바스크립트 쪽에서는 server-sent events에 EventSource 클래스를 써서 액세스한다. 이 클래스는 이벤트 스트림을 해석하거나 접속이 끊겼을 때 재접속한다. 

 

4.0 웹소켓

웹소켓은 서버/클라이언트 사이에 오버헤드가 적은 양방향 통신을 실현한다. 통신이 확립되면 서버/클라이언트 사이에서 일대일 통신을 수행한다. 프레임 단위로 송수신하지만, 상대방이 정해져 있으므로 전송할 곳에 관한 정보는 갖지 않는다. HTTP의 기본 요소 중에서 바디만 보내는 것과 같다. 프레임은 데이터 크기 등을 가질 뿐 오버헤드도 2바이트에서 14바이트밖에 안된다. 통신이 시작되면 양쪽에서 자유롭게 데이터를 주고받을 수 있다.

 

4.1 웹소켓은 스테이트풀

웹소켓이 HTTP 기반 프로토콜과 다른 점은 '스테이트풀 통신'이라는 점이다. HTTP는 속도를 위해 Keep-Alive 등의 복잡한 메커니즘도 갖추게 됐지만, 기본적으로 요청 단위로 접속이 끊어져도 시맨틱스 측면에서는 문제가 없다. 로드 밸런서(ELB)를 이용해 서버 여러 대에 분산해두고, 요청할 때마다 다른 서버가 응답해도 된다. Server-Sent Events도 전송한 ID를 일원적으로 관리해서 보증할 수 있다면, 요청을 다루는 서버가 바뀌더라도 이상 없도록 설계되어 있다.

 

웹소켓을 사용할 때 서버는 메모리에 데이터를 가진 상태로 통신하는 케이스가 많을 것이다. 문자 채팅과 같은 유스케이스에선 단일 서버에 모든 브라우저가 접속할 뿐만 아니라, 멤캐시나 레디스를 중계해서 부하를 분산시킬 수도 있다. 하지만 여러 사람이 실시간으로 동시에 플레이하는 게임처럼 부하 분산에 따른 지연을 허용할 수 없는 경우도 있다. 예를들어, 채팅이면 '대화방' 단위로 커넥션을 온 메모리로 관리한다. 이런 경우 일단 접속이 끊어졌을 때 재접속은 이전과 같은 서버로 연결할 필요가 있어, 단순한 HTTP 기반 로드 밸런서를 사용할 수 없다. 웹 소켓 운용 시에 클라이언트에서 서버를 지정해 재접속할 수 있도록 로드 밸런서를 사용하지 않은 채 운용하는 사례도 볼 수 있다. 그렇지 않으면 TCP 수준의 로드 밸런서를 구사하는 웹소켓 대응 로드 밸런서를 이용할 필요가 있다. 

 

5.0 WebRTC

WebRTC는 이제까지 소개한 프로토콜과는 아주 다르다. 지금까지는 브라우저와 서버의 통신에 이용되는 프로토콜이었다면, WebRTC는 브라우저끼리의 P2P 통신에도 사용된다. RTC는 Realtime Communication의 줄임말로 화상 전화 등 실시간 커뮤니케이션을 구현하는 기반으로서 설계됐다. 

 

통신 기반에 사용하는 transport 계층은 재전송 처리를 해주는 TCP가 아니라 오류 처리나 재전송 처리를 하지 않는 UDP를 메인으로 사용한다. P2P 통신이므로 상대 브라우저를 찾는 시그널링, 라우터 내부의 사설 주소만 있는 컴퓨터에서 동작하는 브라우저를 위해 NAT을 넘어 사설 주소만으로 통신할 수 있는 기술도 사용된다.

 

WebRTC 프로토콜의 자세한 설명은 "High Performance Browser Networking"을 참조하자.

 

6.0 HTTP 웹 푸시

HTTP 웹 푸시는 웹사이트에 스마트폰 애플리케이션과 같은 알림 기능을 제공하는 구조이다. 브라우저가 없는데 통신할 수 있는 것은 Service Worker에 있다. Service Worker 프론트엔드 브라우저의 HTML 랜더링 기능으로 웹 서버와 프론트엔드 중간에 있는 프록시 서버와 같은 존재다. addEventListener() 이벤트 핸들러를 등록하여 필요할 때만 실행된다. 

 

 

 

 

 

 

 

 

 

Reference

이전에 배운 몇 가지 기능을 Go 언어로 표현해보자. 이전에 배운 내용을 재확인하며, 이해를 돕는 용도이다.

 

 

1.0 Keep-Alive

Go 언어의 HTTP API는 아무 설정을 하지 않더라도 기본으로 Keep-Alive가 유효하다. 따라서 올바르게 통신이 완료된 뒤에도 세션이 유지되도록 되어 있다. 단 그렇게 되려면 클라이언트 코드 쪽에서 반드시 response.Body를 끝까지 다 읽고난 후에 닫아야 한다고 도큐먼트에 명시돼 있다(Link). 소켓이라는 하나의 파이프를 시분할로 공유하는 시스템이므로 끝까지 다 읽고 종료했다는 사실을 명시하지 않으면, 다음 작업을 언제 시작해야 할지 판단할 수 없기때문에 재이용할 수 없다. response.Body()에는 바로 전의 HTTP 접속이 성공하지 않았을 땐 nil이 저장되지만, 성공했을 때는 예를 들면 Content-Length: 0으로 바디가 비어 있을 때도 반드시 io.Reader의 실체가 들어가므로, 오류 시 외는 모두 읽어야만 한다.

 

Keep-Alive를 위해 바디를 모두 읽는다. 코드는 아래와 같다.

	resp, err := http.Get("http://www.naver.com")
	if err != nil {
		panic(err)
	}
	defer resp.Body.Close()
	body, err := ioutil.ReadAll(resp.Body)

 

 

2.0 TLS

Go 언어는 표준 라이브러리를 사용한 TLS 통신(https)이 가능하다. Go 언어의 표준 라이브러리는 openssl 등의 라이브러리를 이용하는 것이 아니라 밑바닥부터 만든 Go 언어의 코드를 사용한다. 이를 직접 구현해보자.

 

2.1 인증서 만들기

기본적으로는 시스템이 가진 인증서를 사용해 인증서를 확인한다.

 

하나의 인증서를 만드는 기본 흐름은 아래와 같다.

  • OpenSSL 커맨드로 비밀 키 파일을 만든다.
  • 인증서 요청 파일을 만든다.
  • 인증서 요청 파일에 서명해서 인증서를 만든다.

보통 인증서 요청까지는 필요로 하는 사람이 작성한 후 인증기관에서 유료로 서명을 받는다. 하지만, 이번은 테스트 목적이기 때문에 스스로 서명할 것이다. 참고로, 자기 서명 인증서는 통신 경로 은닉화에는 사용할 수 있지만 서버의 신원 보증에는 안된다. 각 컴퓨터에 설치하면 수동으로 믿을 수 있는 인증서라고 가르칠 수 있다.

 

우선은 openssl.cnf 파일을 복사해 설정을 변경한다. 커맨드라인만으로는 설정할 수 없는 항목이 있기때문에 설정 파일 편집이 필요하다. openssl.cnf 파일의 템플릿은 /etc/local/openssl/openssl.cnf와 /etc/ssl/openssl.cnf, C:\OpenSSL\bin\openssl.cnf에 있다. 인증기관, 서버, 클라이언트의 세 가지 인증서 작성을 위한 설정을 끝에 추가한다.  OpenSSL 설정 파일은 아래와 같다.

[CA]
basicConstraints=critical,CA:TRUE,pathlen:0
keyUsage=digitalSignature,keyCertSign,cRLSign

[Server]
basicConstraints=CA:FALSE
keyUsage=digitalSignatrue,dataEncipherment
extendedKeyUsage=serverAuth

[Client]
basicConstraints=CA:FALSE
keyUsage=digitalSignature,dataEncipherment
extendedKeyUsage=clientAuth

 

OpenSSL의 권장 설정 항목

[req_distinguished_name]
# 기본 국가 코드
countryName_default = KR
# 기본 도/주
stateOrProvinceName_default = Incheon
# 기본 도시명
localityName_default = namdong
# 기본 조직명
0.organizationName_default = example.com
# 기본 관리자 메일 주소
emailAddress = qazyj@exampe.com

 

모두 작성하면 아래와 같다.

# openssl.cnf

[ req ]
default_bits           = 2048
default_keyfile        = privkey.pem
distinguished_name     = req_distinguished_name
req_extensions         = req_ext
x509_extensions        = v3_ca

[ req_distinguished_name ]
countryName            = Country Name (2 letter code)
# 기본 국가 코드
countryName_default = KR
stateOrProvinceName    = State or Province Name (full name)
# 기본 도/주
stateOrProvinceName_default = Incheon
localityName           = Locality Name (eg, city)
# 기본 도시명
localityName_default = namdong
organizationName       = Organization Name (eg, company)
# 기본 조직명
organizationName_default = example.com
commonName             = Common Name (e.g. server FQDN or YOUR name)
commonName_max         = 64
# 기본 관리자 메일 주소
emailAddress = qazyj@exampe.com

[ req_ext ]
subjectAltName         = @alt_names

[ v3_ca ]
subjectAltName         = @alt_names

[ alt_names ]
DNS.1                  = localhost
DNS.2                  = example.com


[CA]
basicConstraints=critical,CA:TRUE,pathlen:0
keyUsage=digitalSignature,keyCertSign,cRLSign

[Server]
basicConstraints=CA:FALSE
keyUsage=digitalSignatrue,dataEncipherment
extendedKeyUsage=serverAuth

[Client]
basicConstraints=CA:FALSE
keyUsage=digitalSignature,dataEncipherment
extendedKeyUsage=clientAuth

 

아래와 같이 루트 인증기관 인증서를 작성하자.

# RSA 2048 비트 비밀 키 생성
$ openssl genrsa -out ca.key 2048

# 인증서 서명 요청(CSR) 작성
$ openssl req -new -sha256 -key ca.key -out ca.csr -config openssl.cnf

# 인증서를 자신의 비밀 키로 서명해서 생성
$ openssl x509 -in ca.csr -days 365 -req -signkey ca.key -sha256 -out ca.crt -extfile ./openssl.cnf -extensions CA

 

모두 다 하고나면 아래와 같이 4개의 파일이 있다.

 

각 과정에서 생성된 파일을 확인하려면 아래 명령어로 확인할 수 있다.

# 비밀 키 확인
$ openssl rsa -in ca.key -text

# 인증서 서명 요청(CSR) 확인
$ openssl req -in ca.csr -text

# 인증서 확인
$ openssl x509 -in ca.crt -text

 

다음으로 아래 순서대로 서버의 인증서를 작성하자.

# RSA 2048 비트 비밀 키 생성
$ openssl genrsa -out server.key 2048

# 인증서 서명 요청(CSR) 작성
$ openssl req -new -nodes -sha256 -key server.key -out server.csr -config openssl.cnf

# 인증서를 자신의 비밀 키로 서명해서 생성
$ openssl x509 -req -days 365 -in server.csr -sha256 -out server.crt -CA ca.crt -CAkey ca.key -CAcreateserial -extfile ./openssl.cnf -extensions Server

이제 사용할 비밀 키와 인증서를 만들었다.

!! 참고 !!
CSR을 만들 때, Common Name을 localhost로 입력해야 한다. 만약, localhost로 하지않는다면 request 테스트할 때 아래와 같은 에러를 마주할 것이다.
curl: (60) SSL: certificate subject name 'KYJ' does not match target host name 'localhost'

 

2.2 HTTP 서버와 인증서 등록

HTTPS 접속 테스트를 위해, HTTPS 서버를 준비하자. 코드는 아래와 같다.

package main

import (
	"fmt"
	"log"
	"net/http"
	"net/http/httputil"
)

func handler(w http.ResponseWriter, r *http.Request) {
	dump, err := httputil.DumpRequest(r, true)
	if err != nil {
		http.Error(w, fmt.Sprint(err), http.StatusInternalServerError)
		return
	}
	fmt.Println(string(dump))
	fmt.Fprintf(w, "<html><body>hello</body></html>\n")
}

func main() {
	http.HandleFunc("/", handler)
	log.Println("start http listening : 18443")
	err := http.ListenAndServeTLS(":18443", "/openssl/server.crt", "/openssl/server.key", nil)
	log.Println(err)
}

http와 크게 다르지 않지만, listenAndServer부분이 TLS가 붙은 것을 볼 수 있다. 그리고 작성한 인증서와 비밀 키의 파일 이름을 인수로 받는다.

 

직접 request를 날려보자.

$ curl https://localhost:18443 
curl: (60) SSL certificate problem: unable to get local issuer certificate
More details here: https://curl.se/docs/sslcerts.html

위와 같은 오류가 날 것이다. curl 커맨드가 인증서를 찾지 못했기 때문이다. 

 

인증서를 포함해서 아래와 같이 request를 보내자.

curl --cacert ca.crt https://localhost:18443

성공적으로 response가 오는 모습을 볼 수 있다.

 

2.3 Go 언어를 이용한 클라이언트 구현

Go 언어를 사용해서 서버를 구현하는 코드는 매우 단순했다. 클라이언트를 구현하는 코드도 아래와 같이 단순하다.

package main

import (
	"crypto/tls"
	"crypto/x509"
	"log"
	"net/http"
	"net/http/httputil"
	"os"
)

func main() {
	cert, err := os.ReadFile("../openssl/ca.crt")
	if err != nil {
		panic(err)
	}
	certPool := x509.NewCertPool()
	certPool.AppendCertsFromPEM(cert)
	tlsConfig := &tls.Config{
		RootCAs: certPool,
		InsecureSkipVerify: true,	// 안정성 낮은 tls
	}

	// 클라이언트 작성
	client := &http.Client {
		Transport: &http.Transport{
			TLSClientConfig: tlsConfig,
		},
	}

	// 통신
	resp, err := client.Get("https://localhost:18443")
	if err != nil {
		panic(err)
	}
	defer resp.Body.Close()
	dump, err := httputil.DumpResponse(resp, true)
	if err != nil {
		panic(err)
	}
	log.Println(string(dump))
}

 

 

2.4 클라이언트 인증서

TLS의 기능 중에 클라이언트 인증서를 이용한 클라이언트 인증이 있다. 이 기능은 보통의 TLS와 반대로 서버가 클라이언트에 인증서를 요구한다. server측 코드를 아래와 같이 수정한다.

package main

import (
	"crypto/tls"
	"fmt"
	"log"
	"net/http"
	"net/http/httputil"
)

func handler(w http.ResponseWriter, r *http.Request) {
	dump, err := httputil.DumpRequest(r, true)
	if err != nil {
		http.Error(w, fmt.Sprint(err), http.StatusInternalServerError)
		return
	}
	fmt.Println(string(dump))
	fmt.Fprintf(w, "<html><body>hello</body></html>\n")
}

func main() {
	server := &http.Server{
		TLSConfig: &tls.Config{
			ClientAuth: tls.RequireAndVerifyClientCert,
			MinVersion: tls.VersionTLS12,
		},
		Addr: ":18443",
	}
	http.HandleFunc("/", handler)
	log.Println("start http listening :18443")
	err := server.ListenAndServeTLS("../openssl/server.crt", "../openssl/server.key")
	log.Println(err)
}

client 키와 인증서를 다시 아래 명령어를 통해 생성해주자.

# RSA 2048 비트 비밀 키 생성
$ openssl genrsa -out client.key 2048

# 인증서 서명 요청(CSR) 작성
$ openssl req -new -nodes -sha256 -key client.key -out client.csr -config openssl.cnf

# 인증서를 자신의 비밀 키로 서명해서 생성
$ openssl x509 -req -days 365 -in client.csr -sha256 -out client.crt -CA ca.crt -CAkey ca.key -CAcreateserial -extfile ./openssl.cnf -extensions Client

client 측 코드도 아래와 같이 변경해보자.

package main

import (
	"crypto/tls"
	"log"
	"net/http"
	"net/http/httputil"
)

func main() {
	cert, err := tls.LoadX509KeyPair("../openssl/client.crt", "../openssl/client.key")
	if err != nil {
		panic(err)
	}

	client := &http.Client{
		Transport: &http.Transport{
			TLSClientConfig: &tls.Config{
				Certificates: []tls.Certificate{cert},
			},
		},
	}
    // 아래는 앞의 코드와 같다.
}

 

 

3.0 프로토콜 업그레이드

프로토콜 업그레이드는 통신 도중에 HTTP 이외의 통신을 하는 방법이었다. go의 표준 net/http 패키지는 많은 일을 해주지만, 업그레이드 후에는 HTTP의 문맥에서 벗어나 통신하므로 직접 소켓을 송수신하게 된다. 원래는 HTTP처럼 제대로 된 송수신 규약을 만들 필요가 있지만, 이 책에서는 예제로서 간단히 송수신만 하는 프로토콜을 구현한다.

 

3.1 서버 코드

서버 쪽도 특수한 통신을 할 필요가 있기때문에 핸들러를 작성하자. 서버쪽 코드에 아래와 같은 핸들러를 추가했다.

func handlerUpgrade(w http.ResponseWriter, r *http.Request) {
	// 이 엔드포인트에서는 변경 외는 받아들이지 않는다.
	if r.Header.Get("Connection") != "Upgrade" || r.Header.Get("Upgrade") != "MyProtocol" {
		w.WriteHeader(400)
		return
	}
	fmt.Println("Upgrade to MyProtocol")
	
	// 소켓을 획득
	hijacker := w.(http.Hijacker)
	conn, readWriter, err := hijacker.Hijack()
	if err != nil {
		panic(err)
		return
	}
	defer conn.Close()
	
	// 포로토콜이 바뀐다는 응답을 보낸다.
	response := http.Response{
		StatusCode: 101,
		Header: make(http.Header),
	}
	response.Header.Set("Upgrade", "MyProtocol")
	response.Header.Set("Connection", "Upgrade")
	response.Write(conn)

	// 오리지널 통신 시작
	for i := 1; i <= 10; i++ {
		fmt.Fprintf(readWriter, "%d\n", i)
		fmt.Println("->", i)
		readWriter.Flush()   // Trigger "chunked" encoding and send a chunk ...
		recv, err := readWriter.ReadByte()
		if err == io.EOF {
			break
		}
		fmt.Printf("<- %s", string(recv))
		time.Sleep(500 * time.Millisecond)
	}
}

이 코드에서 핵심은 2곳이다. http.ResponseWriter를 http.Hijacker로 캐스팅하는 부분이다. 하이재킹하면 http.ResponseWriter는 아무런 전송 메시지를 보내지 않게 된다. header와 statuscode를 보내지 않는 대신 소켈을 직접 조작할 수 있게 된다. 소켓을 닫는 것은 프로그래머의 책임이다.

 

또 한 곳은 http.Response를 만들어 response.Write() 소켓에 응답을 수동으로 써넣는 부분이다. 소켓을 직접 읽고 쓸 경우 conn.Write()를 사용해 HTTP 응답을 직접 써넣을 수도 있지만, 이 헬퍼 메서드를 사용하면 수동으로 출력 포맷을 HTTP로 정돈할 필요가 없어진다. 연결을 유지한 채로 HTTP의 응답을 반활할 수 있다.

 

하이재킹 시 응답의 두 번쨰는 낮은 수준의 소켓을 감싼 bufio.ReadWriter이다. bufio.ReadWriter는 io.Reader에 데이터 읽기를 편리하게 하는 bufio.Reader와 쓰기를 편리하게 하는 bufio.Writer 기능을 추가한 입출력용 인터페이스이다.

Tip!
하이재킹(Hijacking)은 컴퓨터 보안 용어 중 하나로, 일반적으로 컴퓨터 네트워크나 시스템을 불법적으로 점령하거나 통제하는 행위를 가리킵니다. 이는 보안을 위협하고, 사용자의 개인정보를 탈취하거나 시스템을 악용하여 다른 공격에 이용될 수 있습니다. 종종 악성 소프트웨어나 악성 코드를 사용하여 시스템에 침입하거나, 네트워크 상에서 데이터를 가로채는 등의 방법으로 이루어집니다. 이는 비인가된 접근으로 인해 사용자나 시스템 소유자의 권한을 빼앗거나 피해를 입히는 행위로 볼 수 있습니다.

 

테스트를 해보고싶다면 main 함수를 아래와 같이 작성해주면 된다.

func main() {
	http.HandleFunc("/upgrade", handlerUpgrade)
	log.Println("start http listening :18888")
	err := http.ListenAndServe(":18888", nil)
	if err != nil {
		log.Println(err)
	}
}

 

3.2 클라이언트 코드

클라이언트 쪽 코드도 소켓을 직접 다룬다. 서버와 같은 하이재킹 구조가 없으므로, 통신을 시작할 때부터 소켓을 다룰 필요가 있다. 코드는 아래와 같다.

package main

import (
	"bufio"
	"fmt"
	"io"
	"log"
	"net"
	"net/http"
	"time"
)

func main() {
	// TCP 소켓 열기
	dialer := &net.Dialer{
		Timeout: 30*time.Second, 
		KeepAlive: 30*time.Second,
	}
	
	conn, err := dialer.Dial("tcp", "localhost:18888")
	if err != nil {
		panic(err)
	}
	defer conn.Close()
	reader := bufio.NewReader(conn)

	// 요청을 작성해 소켓에 직접 써넣기
	request, _ := http.NewRequest("GET", "http://localhost:18888/upgrade", nil)
	request.Header.Set("Connection", "Upgrade")
	request.Header.Set("Upgrade", "MyProtocol")
	err = request.Write(conn)
	if err != nil {
		panic(err)
	}

	// 소켓에서 직접 데이터를 읽어와 응답 분석
	resp, err := http.ReadResponse(reader, request)
	if err != nil {
		panic(err)
	}
	log.Println("Status:", resp.Status)
	log.Println("Headers:", resp.Header)

	// 오리지널 통신 시작
	counter := 10
	for {
		data, err := reader.ReadByte()
		if err == io.EOF {
			break
		}
		fmt.Println("<-", string(data))
		fmt.Fprintf(conn, "%d\n", counter)
		fmt.Println("->", counter)
		counter--
	}

}

net.Dialer 구조체를 사용해 TCP 소켓을 연다. request.Writer로 소켓에 직접 요청을 적는다. 요청에는 서버에서 기대하는 헤더값을 추가한다. 여기서도 마찬가지로 bufio.Reader의 ReadBytes() 메서드를 이용한다.

 

 

4.0 청크

Go 에서 청크는 net/http의 각 기능에 처음부터 들어있다. API를 사용할 경우 완전히 통신 패키지 내부에 은폐돼 있으므로, 단순히 송수신하는 것 뿐이라면 통신이 청크 방식으로 이루어지는지 의식할 필요는 없다. 

 

http.Post로 2048바이트 이상의 파일을 전송할 때, Request.ContentLength를 설정하지 않고 보낼 경우 자동으로 청크 형식으로 업로드 된다. ioutil.ReadAll()을 호출하면 다 읽을 때까지 블록한다. 1초씩 10회로 나누어 데이터를 수신하는 경우는 10초 후에 모아서 통신 결과가 돌아온다. 청크 송수신을 Go 언어로 구현하는 방법을 배워보자. 

  1. 서버에서 청크 형식으로 송신하기
  2. 클라이언트에서 순차적으로 수신하기 (간단판)
  3. 클라이언트에서 순차적으로 수신하기 (완전판)
  4. 클라이언트에서 송신하기

 

4.1 서버에서 송신하기

서버에서 청크 형식으로 전송하는 것은 간단하다. http.ResponseWriter를 http.Flusher 인터페이스로 캐스팅하면, 숨겨진 Flush() 메서드를 사용할 수 있게 된다. 

 

func handlerChunked(w http.ResponseWriter, r *http.Request) {
	flusher, ok := w.(http.Flusher)
	if !ok {
		panic("expected http.ResponseWriter to be an http.Flusher")
	}
	for i := 1; i <= 10; i++ {
		fmt.Fprintf(w, "Chunk #%d\n", i)
		flusher.Flush()
		time.Sleep(500*time.Millisecond)
	}
	flusher.Flush()
}

 

이 코드는 0.5초마다 텍스트를 클라이언트에 반환한다. 이전 코드에선 모두 출력한 후에 클라이언트에 메시지를 보냈지만, 해당 코드에선 출력할 때마다 Flush()를 호출함으로써 클라이언트는 루프를 돌 때마다 결과를 받게 된다. 

루프 안의 Flush()를 추석처리하고 돌려보면, 1번씩 출력되는 것이 아닌 한번에 출력이 된다.

 

4.2 클라이언트에서 순차적으로 수신하기 (간단판)

package main

import (
	"bufio"
	"bytes"
	"io"
	"log"
	"net/http"
)

func main() {
	resp, err := http.Get("http://localhost:18888/chunked")
	if err != nil {
		log.Fatal(err)
	}

	defer resp.Body.Close()
	reader := bufio.NewReader(resp.Body)
	for {
		line, err := reader.ReadBytes('\n')
		if err == io.EOF {
			break
		}
		log.Println(string(bytes.TrimSpace(line)))
	}
}

bufio.Reader의 ReadBytes()로 구분 문자까지의 내용을 읽고 있다. 서버에서 1초에 한줄씩 전송되고 있다면, ReadBytesd() 메서드는 응답이 올 때까지 블록된다.

 

이런 구조만으로 서버 쪽에서 임의의 시점에서 응답을 할 수 있게 된다. 이 방법은 사용자가 올바른 형식으로 보내는 것을 전제로 하는 방법이다. 끝에 구분 문자 이외 다른 것이 있으면, 거기서 처리가 차단되어 버린다.

 

4.3 클라이언트에서 순차적으로 수신하기 (완전판)

업그레이드 구현에선 TCP 소켓을 직접 다룸으로써 임의의 프로토콜에 대응할 수 있었다. 청크도 같은 방법으로 직접 다룰 수 있다.

 

청크의 기능은 단순하다.

  • 16진수의 청크 길이가 전송된다. 
  • 지정된 크기의 데이터가 전송된다.
  • 길이 0이 전송되면, 서버에서의 응답이 끝난 것을 알 수 있다.

저수준 소켓으로 청크를 직접 읽어오게 한 것이 아래 코드이다.

package main

import (
	"bufio"
	"io"
	"log"
	"net"
	"net/http"
	"strconv"
	"time"
)

func main() {
	// TCP 소켓 열기
	dialer := &net.Dialer{
		Timeout: 30*time.Second,
		KeepAlive: 30*time.Second,
	}
	conn, err := dialer.Dial("tcp", "localhost:18888")
	if err != nil {
		panic(err)
	}

	// 요청 보내기
	request, err := http.NewRequest("GET", "http://localhost:18888/chunked", nil)
	err = request.Write(conn)
	if err != nil {
		panic(err)
	}

	// 읽기
	reader := bufio.NewReader(conn)
	// 헤더 읽기
	resp, err := http.ReadResponse(reader, request)
	if err != nil {
		panic(err)
	}
	if resp.TransferEncoding[0] != "chunked" {
		panic("wrong transfer encoding")
	}

	for {
		// 크기를 구하기
		sizeStr, err := reader.ReadBytes('\n')
		if err == io.EOF {
			break
		}
		// 16진수의 크기를 해석. 크기가 0이면 닫는다.
		size, err := strconv.ParseInt(string(sizeStr[:len(sizeStr)-2]), 16, 64)
		if size == 0 {
			break
		}
		if err != nil {
			panic(err)
		}
		// 크기만큼 버퍼를 확보하고 읽어오기
		line := make([]byte, int(size))
		reader.Read(line)
		reader.Discard(2)
		log.Println(" ", string(line))
	}
}

 

 

5.0 원격 프로시저 호출

Go 언어는 net/rpc 패키지에서 RPC를 실현하는 프레임워크를 제공한다. 오브젝트를 생성해 등록하면, 외부에서 액세스할 수 있게 된다. 

 

net/rpc에서 공개되는 메서드는 아래 조건을 충족시켜야 한다.

  • 메서드가 속한 구조체의 형이 공개되어 있다.
  • 메서드가 공개되어 있다. (대문자로 시작하는 이름을 가진다.)
  • 메서드는 두 개 인수를 가지며, 양쪽 다 공개되어 있거나 내장형이다.
  • 메서드의 두 번째 인수는 포인터이다.
  • 메서드는 error 형의 반환값을 가진다.

아래 코드는 각각 JSON-RPC 서버와 클라이언트가 된다.

package main

import (
	"log"
	"net"
	"net/http"
	"net/rpc"
	"net/rpc/jsonrpc"
)

// 메서드가 속한 구조체
type Calculator int

// RPC로 외부에서 호출되는 메서드
func (c *Calculator) Multiply(args Args, result *int) error {
	log.Printf("Multiply called: %d, %d\n", args.A, args.B)
	*result = args.A*args.B
	return nil
}

// 외부에서 호출될 때의 인수
type Args struct {
	A, B int
}

func main() {
	calculator := new(Calculator)
	server := rpc.NewServer()
	server.Register(calculator)
	http.Handle(rpc.DefaultRPCPath, server)
	log.Println("start http listening :18888")
	listener, err := net.Listen("tcp", ":18888")
	if err != nil {
		panic(err)
	}
	for {
		conn, err := listener.Accept()
		if err != nil {
			panic(err)
		}
		go server.ServeCodec(jsonrpc.NewServerCodec(conn))
	}
}

server 코드로 net.rpc.Server를 작성한다. 계산 처리를 할 구조체를 추가하고 HTTP 핸들러로서 등록한다. 그 후에는 클라이언트에서 접속이 있을 때마다 JSON-RPC의 코덱을 만들어 서버에 등록한다.

 

package main

import (
	"log"
	"net/rpc/jsonrpc"
)

type Args struct {
	A, B int
}

func main() {
	client, err := jsonrpc.Dial("tcp", "localhost:18888")
	if err != nil {
		panic(err)
	}
	var result int
	args := &Args{4, 5}
	err = client.Call("Calculator.Multiply", args, &result)
	if err != nil {
		panic(err)
	}
	log.Printf("4 x 5 = %d\n", result)
}

client 코드는 더 간단하다. jsonrpc.Dial 함수를 호출하면 코덱이 설정된 클라이언트가 반환되므로, 반환된 클라이언트의 Call 메서드로 서버의 메서드를 호출할 수 있다. RPC 관련 기능은 표준 API로 제공되지만, 품질이 높다고는 할 수 없다. 대기하는 경로와 코덱을 동시에 간단히 지정할 수 있는 API가 없고, 어느 한 쪽을 기본으로 사용하는 방법만 제공된다. 

 

 

 

 

 

 

Reference

1.0 파일 다운로드 후 로컬에 저장하기

브라우저가 파일을 어떻게 처리할지 결정하는 것은 확장자가 아니라 서버가 보낸 MIME 타입이었다. 이미지 파일 링크를 브라우저에서 열었을 떄, image/png라면 이미지 파일로 보고 브라우저에서 표시한다. PDF 파일도 인라인 표시가 된다. 이것이 기본 동작이다.

 

예를 들어, 서버 응답에 Content-Disposition 헤더가 있으면, 브라우저는 다운로드 대화상자를 표시하고 파일을 저장한다. filename으로 지정된 파일명이 다운로드 대화 상자에 기본값으로 표시된다.

Content-Disposition: attachment; filename=filename.xlsx

 

Content-Disposition 헤더를 이용한 다운로드 기능은 HTTP를 위해 만들어진 것은 아니다. 이메일의 첨부 파일을 위해 RFC 1806에서 정의된 규격이다. HTTP/1.0과 초기 버전 HTTP/1.1(RFC 2068)에는 없었고, 2년 후 개정된 HTTP/1.1 (RFC 2616)에서 언급되었다. HTTP의 RFC 규격에는 없었지만, 몇몇 브라우저에서 이미 구현된 이 기능은 첫 HTTP/1.0이 나오고 15년 후인 2011년에 RFC 6266에서 정식 사양으로 정해졌다.

 

 

2.0 다운로드 중단과 재시작

파일 크기가 크면 클 수록 다운로드 시간이 오래 걸리고, 다운로드 시간이 길어지면 통신이 불안정해지거나 다운로드 도중에 실패할 확률이 높아진다. 요즘 회선 환경에서는 실패하지 않고 다운로드를 마치는 경우가 많겠지만, 중단된다면 중단된 지점부터 다시 시작하는 방법을 제공한다. 오리지널 사양은 RFC 2616, 최신 사양은 RFC 7233이다. 

 

재시작은 지정한 범위를 잘라내 다운로드 한다는 뜻이다. 서버가 범위 지정 다운로드를 지원하는 경우는 Accept-Ranges 헤더를 응답에 부여한다.

Accept-Ranges: bytes

위는 bytes 단위로 범위 지정 다운로드를 하는 것이다. bytes와 none만이 존재하는데, none은 범위 지정을 하지 않는다. 범위 지정을 하는 경우

HTTP/1.1 206 Partial Content

Content-Length: 1000
Content-Ranges: 1000-1999/5000

위와 같은 형식으로 원하는 범위를 받을 수 있다. 위는 응답헤더이고 요청 헤더는 아래와 같다.

Range: bytes=1000-1999

 

 

2.2 병렬 다운로드

서버가 세션마다 대역을 제한할 경우, 영역을 나눠 세션마다 Range 헤더를 이용해 HTTP 접속을 하는 것으로 병렬로 다운로드할 수 있다. 다운로드한 데이터 조각을 이후에 결합하면, 전체 다운로드 시간이 줄어든다. 

 

다만 병렬 다운로드는 서버에 지나치게 부단을 주기 때문에 별로 권장되진 않는다. 브라우저의 경우는 같읍 웹사이트에 대한 동시 접속 수를 2~6으로 제한하고 있다. 요즘은 정적 파일을 캐시해 서버에 부단을 주지 않고 배포하는 CDN이 보급 돼, 다운로드가 몰려 속도가 떨어지는 일도 줄어들었다. 비교적 파일 크기가 큰 파일로는 동영상이 있지만, 동영상은 일부만 미리 읽어와도 재생이 시작되므로 모든 콘텐츠가 다운로드 될 때까지 기다릴 필요가 없다.

 

 

3.0 XMLHttpRequest

지금까지 소개한 다운로드 기능을 자바스크립트로 사용할 수 있게 해주는 기능이 XMLHttpRequest이다. XMLHttpRequest는 HTTP 통신과 마찬가지로 클라이언트가 서버에 요청을 보내고, 응답으로 서버가 클라이언트에 데이터를 보낼 수 있는 것이다. 헤더를 송수신할 수 있고, 캐시 제어나 쿠키 송수신 등 변함이 없다. FormData 클래스를 사용하면, multipart/form-data 형식으로 파일 등을 보낼 수도 있다. 단, HTTP처럼 서버 측에서 클라이언트에 요청을 보낼 수는 없다. 

var xhr = new XMLHttpRequest();
xht.open("GET", "/json", true);
xht.onload = function() {
	// 응답이 들어왔을 때 호출되는 메서드
    if (xhr.status == 200) {
		// JSON 파싱해서 표시
        console.log(JSON.parse(xhr.responseText));
    }
};
xhr.setRequestHeader("MyHeader", "HeaderValue");
xhr.send();
curl -H "MyHeader=HeaderValue" /json

과 같은 의미이다. open에서 세번 째 파라미터를 true로 주면 비동기로 처리가 된다. 동기 실행일 경우 응답이 돌아올 때까지 send() 메서드가 끝나지 않게된다.

 

 

3.1 XMLHttpRequest와 브라우저의 HTTP 요청 차이

  • 송수신할 때 HTML 화면이 새로고침되지 않는다. (HTTP의 경우 응답이 올 때, 새로운 페이지가 렌더링 됌)
  • GET과 POST 이외의 메서드도 전송할 수 있다.
  • 폼의 경우 키와 값이 일대일이 되는 형식의 데이터만 전송할 수 있고, 응답은 브라우저로 표시되어 버리지만, 플레인 텍스트, JSON, 바이너리 데이터, XML 등 다양한 형식을 송수신할 수 있다.
  • 몇 가지 보안상 제약이 있다. (이후 설명)

 

 

4.0 지오로케이션

클라이언트의 물리적 위치에 기반한 서비스를 접해본 경험이 있을 것이다. 물리적 위치를 측정하는 데는 2가지가 있다.

1. 클라이언트 자신이 측정해서 서버에 보냄

2. 서버가 클라이언트 위치를 추측

 

4.1 클라이언트 자신이 위치를 구하는 방법

최근 모던 브라우저는 지오로케이션 API를 제공한다. 예를들어, 스마트폰이라면 내장된 GPS나 기지국 정보를 활용해 위치 정보를 알려줄 수 있다. GPS가 없는 컴퓨터 경우 와이파이 등을 이용한 위치 측정으로 대략적인 위치를 추측해서, 위도와 경도를 알려줄 수 있다. 하지만, 위치 정보는 사생활과 직결되므로, 사용자가 허락한 경우에만 위치 정보를 사용할 수 있다. (App에서 위치 정보 제공 여부를 묻는 이유) 

 

와이파이 자체에는 GPS가 없으므로 와이파이에서 위치 정보를 알아내는 것은 조금 교묘하고 대규모 방식으로 이루어진다. 우선 와이파이 엑세스 포인트의 고유 ID(BBSID)와 위도 경도 정보를 데이터베이스로 사전에 구축해둔다. 클라이언트는 OS의 API를 이용해 현재 자신이 액세스할 수 있는 액세스 포인트의 BSSID를 가져와 서버에 문의해 위도와 경도를 조회한다.

 

BSSID는 와이파이 기기 식별자의 48비트 수치로, 기기마다 독특한 수치로 되고 있다. 맥 주소와 같은 것이라고 한다.

 

스마트폰은 스스로 위도와 경도를 측정하고 와이파이에 접속할 수 있으므로, 차로 이동하지 않아도 스마트폰을 이용해 위치 정보를 수집할 수 있다. 스마트폰을 이용하여 위치 정보를 수집하는 방법은 클라우드소싱이라고 불리며, 2008년경 W3C 지오로케이션 API로서 규격화 됐다.

 

 

4.2 서버가 클라이언트 위치를 추측하는 방법

지오 IP라고 불리는 IP 주소로 추측하는 방법이다. IP 주소는 지역마다 등록 관리 기관이 있어, 기업이나 프로바이더 등에 IP 주소를 할당한다. 그렇다고 등록 기관에 문의하면 되는가? 라고 묻는다면 등록 기관은 정확한 장소까지는 관리하지 않는다. 이 방식도 지오로케이션처럼 꾸준히 모은 데이터를 바탕으로 위치 정보를 알려주는 서비스로 맥스마인드, ip2location, ipligence, 도코도코JP가 있다.

 

 

6.0 원격 프로시저 호출

프로시저는 각 언어가 제공하는 함수, 클래스 메서드와 같은 것 을 뜻한다. 

 

RPC (원격 프로시저 호출)이란 것은 다른 컴퓨터에 있는 기능을 마치 자신의 컴퓨터 안에 있는 것처럼 호출하고, 필요에 따라 반환값을 받는 구조이다. 원격 메서드 호출(RMI)이라고 불리는 경우도 있다.

 

RPC의 역사는 1980년대까지 거슬러 올라간다. RPC에는 다양한 방식이 있다. 인터넷의 확산과 함께 HTTP를 기반으로 하는 RPC가 몇 종류 등장했다.

 

 

6.1 XML-RPC

최초로 규격화된 RPC는 XML-RPC이다. 유저랜드 소프트웨어와 마이크로소프트가 1998년에 개발했다. XML-RPC의 규격은 xmlrpc.com에 있다. XML-RPC 자체는 RFC화되지 않았지만, XML-RPC를 바탕으로 한 RFC가 있다.(RFC 3529)

 

XML-RPC가 만들어진 무렵 HTTP/1.1이 있었지만, 사양 요청 예제에는 HTTP/1.0으로 적혀있었다. Content-Length를 명시해야 했기때문에, HTTP/1.1의 청크 방식은 지원하지 않고 단순한 프로토콜 위에 구축됐다. 전송에 사용하는 method는 POST이고 인수, 반환값 모두 XML으로 표현하기 때문에, Content-Type은 항상 text/xml 이다. GET은 캐시될 가능성이 있기때문에 RPC 통신에는 적합하지 않다.

 

 

6.2 SOAP

SOAP는 XML-RPC를 확장해서 만들어진 규격이다. 2016년 웹의 아키텍처로서 마이크로 서비스가 자주 화제로 오르지만, SOAP는 10년 전쯤 자주 화제가 됐고, 서비스 지향 아키텍처 안에서 큰 역할을 했었다.

 

SOAP는 W3C에서 규격화됐다. W3C 사이트에 1.1 사양이 게재된 것은 XML-RPC에서 불과 2년 만인 2000년 이다. 이때 Java의 J2EE가 발흥하기도 했고, 업계 전체가 웹 애플리케이션으로 방향을 바꾼 시기이다. 

SOAP 메시지 구조

SOAP는 단순한 RPC였던 XML-RPC보다는 복잡하게 되어있다. SOAP 자체는 데이터 표현 형식으로, SOAP 규격 안에 SOAP를 사용한 RPC인 SOAP-RPC도 정의되어 있다. 메시지 구조를 보면 HTTP와 같다. HTTP 안에 미니 HTTP와 같은 구조로 되어있다. 이로써 HTTP 이외에도 메일 전송 프로토콜(SMTP)를 써서 SOAP 메시지를 주고받을 수도 있다. 헤더에는 요청의 메서드나 트랜잭션 정보를 기술하고, 엔벨로프에는 데이터가 들어간다. 

POST /Reservations HTTP/1.1
Host: travelcompany.example.org
Content-Type: application/soap+xml; charset="utf-8"
Content-Length: nnnn

<?xml version='1.0' ?>
<env:Envelope xmlns:env="http://www.w3.org/2003/05/soap-envelope" >
 <env:Header>
   <t:transaction
           xmlns:t="http://thirdparty.example.org/transaction"
           env:encodingStyle="http://example.com/encoding"
           env:mustUnderstand="true" >5</t:transaction>
 </env:Header>  
 <env:Body>
  <m:chargeReservation
     env:encodingStyle="http://www.w3.org/2003/05/soap-encoding"
          xmlns:m="http://travelcompany.example.org/">
   <m:reservation xmlns:m="http://travelcompany.example.org/reservation">
    <m:code>FT35ZBQ</m:code>
   </m:reservation>
    <o:creditCard xmlns:o="http://mycompany.example.com/financial">
     <n:name xmlns:n="http://mycompany.example.com/employees">
           Åke Jógvan Øyvind
     </n:name>
     <o:number>123456789099999</o:number>
     <o:expiration>2005-02</o:expiration>
    </o:creditCard>
   </m:chargeReservation
  </env:Body>
</env:Envelope>

샘플은 W3C 사이트에서 인용했다. 딱 봐도 복잡해 보인다... 이후에 공부할 일이 있다면 읽어보면 좋을 듯 하다.

 

 

6.3 JSON-RPC

JSON-RPC는 XML-RPC의 XML 대신 JSON을 이용한 RPC이다. 복잡한 SOAP와 대조적으로 단순하게라는 방침으로 내세웠다. XML-RPC처럼 IETF와 W3C가 아닌 jsonrpc.org라는 자체 사이트에 사양을 게재하고 있다. JSON-RPC는 HTTP 이외에 TCP/IP 소켓 등을 사용하는 것도 가정하고 있어, 사양이 최대공약수적으로 기술되어 있다. 단순함을 추구하면서 몇 가지 XML-RPC와 다른 기능도 갖추고 있다.

 

기본 응답은 아래와 같다. --> 부분이 request, <--부분이 response이다. 링크를 참고했다.

--> {"jsonrpc": "2.0", "method": "subtract", "params": {"minuend": 42, "subtrahend": 23}, "id": 3}
<-- {"jsonrpc": "2.0", "result": 19, "id": 3}

request와 response를 보면, json형식으로 간결하게 표현하는 것을 볼 수 있다. 책에서는 request 시 필요한 헤더로 Content-Type, Content-Length, Accept 헤더가 필요하다고 명시되어 있지만, jsonrpc.org에는 명시되어 있지 않다. 다만, json obejct의 경우는 지켜야 한다.

---gRPC는 소개하지 않는듯하다.

 

 

7.0 WebDAV

WebDav는 HTTP/1.1에 포함되진 않지만, 이 시기에 만들어졌고 수많은 환경에서 지원되고 있다.

 

WebDAV는 HTTP를 확장해 분산 파일 시스템으로 사용할 수 있게 한 기술로, 마이크로소프트가 개발해 1999년에 RFC 2518로 책정됐다. 현재는 RFC 4918로 갱신돼, 관련된 RFC 3253(버전 관리), RFC 3744(액세스 제어)도 추가로 정의됐다. 

 

WebDAV의 용어는 아래와 같다.

  • 리소스
    • 일반 파일 시스템에서는 데이터를 저장하는 아토믹 요소를 '파일'로 부르지만, WebDAV에서는 HTTP 용어를 이어받아 리소스로 부른다.
  • 컬렉션
    • 폴더와 디렉터리에 해당하는 요소
  • 프로퍼티
    • 리소스와 컬렉션이 가질 수 있는 추가 속성이다. 작성일시, 갱신일시, 최종 갱신자와 같은 정보가 해당한다.
    • 분산 파일 시스템은 같은 폴더를 여러 사람이 동시에 보고 데이터를 공유할 수 있지만, 같은 파일을 동시에 편집하게 될 경우가 있다. 같은 파일에 여러 사람이 동시에 기록하면 마지막에 전송된 내용 이외에는 지워져버린다. 이를 피하기 위해 먼저 선언한 사람 이외의 변경을 거절하는 시스템이다.

HTTP/1.1의 POST, GET, PUT, DELETE 메서드를 그대로 이용하지만 파일 시스템으로서는 기능이 부족해 몇 가지 메서드가 추가됐다.

 

기본 조작으로서 COPY와 MOVE가 추가됐다. 모두 GET하고나서 POST(MOVE의 경우 그다음에 DELETE)하면 에뮬레이션할 수 있지만, 동영상 소재로 10GB 분량의 콘텐츠가 있을 때 전체를 일단 로컬에 저장했다가 다시 업로드하는 것은 비효율적이다. 그런 경우 처음부터 원격 웹 서버에서만 이동하거나 삭제한 후 결과만 알려주는게 효율적이다. POST 메서드는 리소스만 작성할 수 있으므로, 컬렉션을 작성하는 MKCOL 메서드가 추가됐다. 콜렉션 내의 요소 목록은 프로퍼티로 취득하므로 PROPFIND 메서드를 사용한다. LOCK/UNLOCK 메서드로 파일 잠금 여부를 제어한다.

 

현재 오픈 소스 개발에서 가장 많이 사용되는 버전 관리 시스템인 Git에서는 전송용 프로토콜로 SSH와 HTTPS 두 가지를 지원한다. 사실은 이 HTTPS 안에서는 WebDAV를 사용한다. SSH는 암호화된 통신 경로를 사용하지만, 그 안의 통신은 오리지널 Git 프로토콜을 사용한다. HTTPS라면 어느 WebDAV 서버를 사용해도 호스트할 수 있으므로 설정이 간단하다는 장점이 있지만, 차분한 전송할 수 있는 Git 프로토콜 쪽이 통신 속도는 뛰어나다.

 

 

8.0 웹 사이트 간 공통 인증 및 허가 플랫폼

인터넷 보급과 함께 여러 웹 서비스가 등장했다. 사용자는 웹 서비스마다 메일 주소, 사용자 아이디, 패스워드를 입력해서 계정을 만들어야 했다. 인간의 기억력에는 한계가 있기때문에 모든 사이트에 같은 아이디와 패스워드를 이용하는 사람이 많을 것이다. 만약, 어떤 웹 서비스가 해킹됐다면 유출된 패스워드를 바탕으로 다른 사이트로 침입이 가능하게 된다. 이런 공격을 리스트형 공격이라고 부른다.

 

외부 서비스가 제공하는 인증 기반에 합승하는 기술도 몇 가지 개발됐다. 직접 사용자 ID와 패스워드를 관리하는 기반 시스템을 구축하려면 많은 시간과 노력이 필요하다. 흔히 있는 '비밀 질문'과 같은 것을 구현하거나, 2단계 인증을 구현해야 하기도 하다. 공격에 노출되기도 하고, 정보 유출 시엔 보상하는 경우도 있다. 플랫폼을 운영 관리하는 게 아니라면, 자사 내에서 사용자 ID와 패스워드 관리를 그만 둘 경우 방대한 작업으로부터 해방돼 서비스 개발에 집중할 수 있고 보안 리스크도 줄어든다.

 

해당 책에서는 아래와 같은 항목을 설명한다.

  • 싱글 사이온
  • 커베로스 인증
  • SAML
  • 오픈아이디
  • 오픈소셜
  • OAuth (이 중 현재 가장 많이 사용되는 듯?하다)
  • 오픈아이디 커넥트

우선 인증(Authentication)과 권한 부여(Authorization)의 차이를 확인하자.

 

Authentication(인증)
로그인하려는 사용자가 누구인지 확인한다. 브라우저를 조작하는 사람이 서비스에 등록된 어느 사용자 ID의 소유자인지 확인
Autorization(권한 부여)
인증된 사용자가 누구인지 파악한 후, 그 사용자에게 어디까지 권한을 부여할지 결정한다.

실제로 이와 같은 기술을 사용할 때는 OAuth 2.0 혹은 오픈아이디 커넥트 중 하나가 될 것이라고 생각하지만, 구현할 때는 각각의 기술을 제대로 배워둘 필요가 있다고 한다. 다행히 OpenID 파운데이션 제팬 홈페이지에 일본어로 된 RFC와 사양서가 있다. 사용할 언어에 라이브러리와 샘플 코드가 있어 구현하기 쉽다고 해도 관련 문서를 훑어보길 추천한다고 한다. 

 

 

8.1 싱글 사인온

기업 내에서 사용하는 웹 서비스나 시스템이 많아지면, 싱글 사인온(SSO)이 검토된다. 싱글 사인온은 시스템 간 계정 관리를 따로따로 하지 않고, 한 번의 로그인을 전 시스템에 유효하게 하는 기술이다. 현재도 많이 사용되는 듯 하다. (네이버, 쿠팡 등)

 

싱글 사인온은 다른 기술과 달리 프로토콜이나 정해진 규칙이 아니고, 이런 용도로 사용되는 시스템을 가리키는 명칭이다. 구현 방식도 웹에만 한정되지 않다. 싱글 사인온을 실현하는 데는 몇 가지 방법이 있다.

 

각 서비스가 인증 서버에 직접 액세스하러 가는 방법이 가장 이해하기 쉬울 것이다. 사용자 ID와 패스워드를 서비스마다 입력해야 하므로 싱글 사인온은 아니지만, 사용자 ID를 일원화해 관리할 수 있게 된다. 각 애플리케이션은 인증 시스템에 로그인하는 과정을 대행한다. 그 밖에도 다음에 소개하는 티켓을 이용한 방법이 있다.

 

웹 서비스로 한정되지만, 각 서비스의 앞단에 HTTP 프로시 서버를 두고 인증을 대행하는 방법과 각 서비스에 인증을 대행하는 에이전트를 넣고 로그인 시 중앙 서버에 액세스해 로그인 됐는지 확인하는 방법도 있다.

 

8.2 커베로스 인증

인터넷보다도 이전 시대부터 내려온 방법으로는 본래의 사용자 관리 구조를 하나로 정리해, 모든 시스템에서 이용하는 방법이 있다. 공통 규격으로서 기업 내에서도 많이 이용되는 것이 RFC 2251에 정의된 LDAP이다. OpenLDAP, 액티브 디렉터리(AD) 같은 구현이 있다. LDAP는 원래 싱글 사인온을 위한 시스템이 아니라 이용자, 조직, 서버 등 기업 내 정보를 일원화해 관리하는 데이터베이스이다. v3에서 추가된 SASL(Simple Authentication and Security Layer)이라는 인증 기능과 세트로 기업 내 마스터 인증 시스템으로 사용된다. RFC 1510(최신은 RFC 4120)에 정의된 커베로스 인증이 널리 사용된다.

 

커베로스 인증을 하면, 티켓 보증 서버로의 액세스 토큰인 티켓 보증 티켓(TGT)과 세션 키를 얻을 수 있다. 서비스와 시스템을 사용할 때는 TGT와 세션 키를 티켓 보증 서버(TGSA)에 보내고, 클라이언트에서 서버로 액세스하기 위한 티켓과 세션 키를 받는다. 이들은 서비스가 가진 비밀 키로 암호화 되어있다. 사용자가 서비스를 사용할 때는 이 티켓과 세션 키를 서비스에 보냄으로서 싱글 사인온이 실현된다.

 

8.3 SAML

최근에는 많은 사내 시스템이 웹 서비스로서 구현됐다. SAML(Security Assertion Markup Language)은 웹 계통의 기술(HTTP/SOAP)을 전제로 한 싱글 사인온 구조이다. 옥타, 원로그인 같은 SaaS(Software as a service)형태로 제공되는 서비스도 있다. SAML은 XML 기반의 표준을 많이 다루는 OASIS(Organization for the Advancement of Structured Information Standards)에서 책정된 규격이다.

 

SAML은 쿠키로 세션을 관리하는 웹의 구조를 따르고, 도메인을 넘어선 서비스 간 통합 인증을 할 수 있다. SAML을 지원하는 싱글 사인온을 조사해보면 수많은 SaaS의 웹 서비스가 나온다. 이 서비스들은 외부 도메인에 있는 구글의 G 스위트, 사이보우즈의 킨톤, 마이크로소프트의 오피스 365, 온라인 스토리지인 드롭박스 등과 연계할 수도 있다.

 

서비스 간 정보 교환 메타데이터도 공통화됐다. 우선은 아래 용어를 기억하자.

사용자
브라우저를 조작하는 사람
인증 프로바이더
ID를 관리하는 서비스
서비스 프로바이더
로그인이 필요한 서비스

 

구현 방법은 아래 여섯가지가 있다. 해당 책에서는 HTTP POST 바인딩 구현을 소개한다.

  • SAML SOAP 바인딩
  • 리소스 SOAP(PAOS) 바인딩
  • HTTP 리디렉트 바인딩
  • HTTP POST 바인딩
  • HTTP 아티팩트 바인딩
  • SAML URI 바인딩

우선은 사전 준비로 인증 프로바이더에 서비스 정보를 등록한다. 등록할 때는 메타데이터로 불리는 XML 파일을 사용한다. 여기에 XML 파일을 인용하진 않지만 아래와 같은 항목이 포함된다.

  • 서비스 ID (인증 프로바이더가 서비스를 식별하기 위한 것)
  • 인증 프로바이더가 HTTP-POST할 엔드포인트 URL
  • 바인딩(urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST 등)
  • 경우에 따라서는 X.509 형식의 공개 키

서비스 프로바이더 쪽에도 인증 프로바이더 정보를 등록한다. 등록할 정보는 인증 프로바이더가 XML 파일로 제공한다. 여기에도 일련의 통신에서 사용할 엔드포인트 URL 목록과 인증서가 포함된다. 

 

실제 통신은 단순하다. 사용자가 서비스를 이용하려고 접속했다고 가정해보자. 아직 로그인하지 않았다면, 서비스 프로바이더는 인증 프로바이더로 리디렉트 한다. 폼을 이용한 리디렉트로 HTTP의 POST를 사용해 리디렉트 한다. HTTP 리디렉트 바인딩에는 일반적인 302 스테이터스 코드를 사용한다.

 

브라우저는 인증 프로바이더 화면을 표시한다. 그 화면에서 로그인에 성공하면 인증 프로바이더는 로그인 정보를 서비스 프로바이더로 POST한다. 이때도 자동으로 POST하는 HTML을 반환한다. 이제 서비스 프로바이더는 사용자가 로그인에 성공한 것을 알 수 있으므로, 처음에 사용자가 요청한 페이지의 콘텐츠를 보여준다.

 

 

8.4 오픈아이디

오픈아이디는 중앙형 ID 관리를 하지 않고, 이미 등록된 웹 서비스의 사용자 정보로 다른 서비스에 로그인할 수 있는 시스템입니다. (OAuth와 같은 것 같은데..?) 2005년에는 1.0 사양이, 2007년에 2.0 사양이 정해졌지만, 현재는 후속 기술인 오픈아이디 커넥트를 이용하는 웹사이트는 늘고 있고, 오픈 아이디로 이용할 수 있는 서비스는 줄고 있다. 오픈아이디의 고유 용어로는 몇가지가 있는데 아래와 같다.

오픈아이디 프로바이더
사용자 정보를 가진 웹 서비스, 사용자는 이미 이 서비스의 ID가 있다. 2017년 현재 야후!재팬과 하테나가 지원한다.
릴레잉 파티
사용자가 새로 이용하고 싶은 웹 서비스, 2017년 이용할 수 있는 서비스는 이벤트 등록과 참가를 관리하는 웹 서비스인 ATND가 있다.
사용자 입력 식별자
사용자가 입력할 URL 형식으로 된 문자열, 오픈아이디 프로바이더가 제공하며, 오픈아이디 프로바이더의 사용자 프로필 화면 등에 표시된다. 사용자를 판별하는 ID가 아니라 서비스명 수준의 식별자가 사용되기도 한다.

 

야후!재팬과 하테나 계정이 있는 사용자라면 인증 정보로 ATND를 이용할 수 있다. 

 

 

8.5 오픈소셜

오픈소셜은 플랫폼으로서 승승장구를 이어가고 있는 페이스북에 맞서고자 SNS 분야에서 늦게 출발한 구글과 페이스북에게 바짝 추격당한 마이스페이스가 손잡고 소셜 네트워크 공통 API로서 개발됐다. 2007년 공개 이후, 현재는 W3C에 이관돼 소셜 웹 프로토콜로서 사양이 책정되고 있다. 

 

오픈소셜은 회원 정보나 친구 관계를 가져오는 Person&Friend API, 액티비티를 작성하는 Activities API, 정보를 저장하거나 공유하는 Persistence API, 다른 멤버에게 메시지를 보내는 requestSendMessage 등 다양한 기능을 지원하며 인증에만 머무르지 않았고 플랫폼을 지향했다.

 

오픈아이디의 릴레잉 파티의 경우, 서비스 쪽 제약은 거의 없고 오픈아이디 프로바이더와 릴레잉 파티를 사전에 등록할 필요도 없다. 오픈소셜의 경우, 오픈아이디 프로바이더에 해당하는 부분이 소셜 네트워크 서비스 제공자이다. 인증과 권한 부여를 모두 이쪽에서 한다. 릴레잉 파티인 서드 파티 애플리케이션은 서버를 SNS 외부에 준비하지만, UI 부분은 가젯으로 불리며 미리 정해진 규칙에 따라 XML 파일을 만들고, 친구 목록을 가져오거나 하는 각종 API를 이용해 자바스크립트와 AJAX로 애플리케이션을 개발한다. 오픈아이디와 비교하면 여러가지가 밀겹화돼 있다. 

 

브라우저의 관점, 즉 HTTP 차원에서 보면 오픈소셜을 사용하는 소셜 인터넷 워크 서비스에 평소처럼 로그인할 뿐 특별한 기능은 사용하지 않는다. HTML의 IFRAME을 사용해 가젯이라는 서드파티 앱에 액세스하지만, 브라우저에서 사용하는 자바스크립트의 AJAX API나 도메인 외부 서버에 대한 요청 등은 이 가젯 서버에서 대응한다.

 

가젯 제공자와 플랫폼 제공자 사이의 통신은 책에서 설명해온 HTTP 그 자체이다. 가젯 설정 파일인 XML은 외부 서버에 두고, 가젯 서버가 HTTP로 가져온다. 클라이언트 브라우저의 요청도 가젯 서버가 중계해서 HTTP로 외부 서버에 도달한다. 외부 서버가 사용자 정보를 취득하려면 RESTful API에 HTTP로 액세스 한다.

 

 

8.6 OAuth

OAuth는 인증이 아니라 권한을 부여하는 시스템으로서 개발됐고, 현재도 활발하게 이용되고 있다. OAuth는 2006년 말부터 검토되기 시작됐다. 그러다가 2008년에 RFC 5849로 OAuth 1.0이 공개됐고, 2012년에 최신인 2.0이 RFC 6749, RFC 6750으로 공개됐다. 해당 책에서는 2.0에 관해서 소개한다.

 

OAuth 이전까지는 사용자가 누구인가를 판정하는 인증 프로세스였다. OAuth는 인증이 아니라 권한 부여에 특화된 시스템이다. 

권한 부여 서버
오픈아이디에서 말하는 오픈아이디 프로바이더, 사용자는 이 권한 부여 서버의 계정이 있다.
리소스 서버
사용자가 허가한 권한으로 자유롭게 액세스할 수 있는 대상, 트위터나 페이스북의 경우는 권한 부여 서버와 같다.
클라이언트
오픈아이디에서 말하는 릴레잉 파티, 사용자가 이제부터 사용할 서비스나 애플리케이션, 오픈아이디와 달리 권한 부여 서버에 애플리케이션 정보를 등록하고, 전용 ID를 가져와야 한다. 이 ID를 credential이라고 부르기도 한다.

 

오픈 아이디와 OAuth 모두 비슷하게 화면을 전환한다. 사용자가 새로운 웹 서비스를 이용하려고 할 때, 이미 계정이 있는 서비스의 웹사이트로 전환된다. 바뀐 화면에서 사용자가 승인 버튼을 누르면, 처음에 연 웹 서비스 화면으로 다시 돌아와 정상적으로 서비스를 이용할 수 있게 된다. (단, 예외도 존재한다.)

 

화면 전환이 비슷하다고 해도, 인증보다 권한 부여는 큰 영향을 미친다. OAuth로 하는 일은 비유하면 전기요금이나 가스요금을 내기 위해 신용카드 번호를 맡기는 것과 같다. 오픈 아이디는 사용자가 이용하려는 웹 서비스가 신용카드 회사에 "이 사람이 회원이 맞나요?"라고 묻고, YES/NO로 응답받는 정도에 불과하지만, OAuth는 최종적으로 회원의 신용카드 번호를 받아온다. 웹 서비스는 그 카드번호가 유효하면 요금을 청구할 수 있다.

 

위 사례는 조금 극단적이긴 하지만, 실제로 클라이언트=외부 웹 서비스에 무엇을 허가할지는 '범위'로 결정된다. OAuth도 오픈아이디처럼 나중에 권한 부여 서버의 설정 화면에서 허가를 취소하거나 허가 범위를 변겨할 수 있다.

 

OAuth 2.0은 4가지 플로를 제공하며, 웹 서비스 이외에도 사용할 수 있다.

Authorization Code
자주 사용되는 일반적인 방법이다. 웹 서비스의 서버 내에 client_secret을 감출 수 있고, 외부에서 볼 가능성이 없는 경우에 사용한다.
Implicit Grant
client_secret없이 액세스할 수 있는 패턴, client_secret이 없으므로 클라이언트의 신원을 보증하지 않는다. 안전하게 client_secret을 유지할 수 없는 자바스크립트, 사용자 단말에 애플리케이션 코드를 다운로드하는 스마트폰 앱 등을 위한 방법이다.
Resource Owner Password Credentials Grant
이제까지는 클라이언트 자신이 사용자의 ID와 패스워드에 접근하지 않았지만, 이 방법은 예외다. 허가 서버가 신뢰하는 클라이언트에서 사용한다. iOS에 내장된 페이스북, 트위터 연계 등 특수한 경우다.
Client Credentials Grant
사용자 동의 없이 client_id와 client_secret만으로 액세스하는 방법이다. client_secret을 이용한 클라이언트 인증만 하므로, 서버 사이드 등 client_secret을 외부로부터 감출 수 있는 환경에서 이용할 수 있다.

 

 

8.7 오픈아이디 커넥트

오픈아이디 커넥트는 OAuth 2.0을 기반으로 한 권한 부여뿐만 아니라 인증으로 사용해도 문제가 없게 확장한 규격이다. 2014년 2월에 출시됐다. 인증과 허가에 모두 사용할 수 있고, 서비스 제공자(릴레잉 파티, 클라이언트)에서 구현하기도 어렵지 않아 앞으로 사실상의 표준이 될 것이다. 현 시점에서는 구글, 야후!재팬, 믹시 등이 오픈아이디 커넥트 프로바이더로서 서비스를 제공하고 있다.

 

클라이언트 입장에서 볼 때 OAuth 2.0과의 차이는 사용자 프로필에 액세스하는 방법을 규격화한 점이다. 일반 액세스 토클과 별개로 ID 토큰이 발행되는데, 이 토큰을 사용해 액세스할 수 있다. 사용자 관점에서의 절차는 오픈아이디, OAuth와 같다.

 

오픈아이디 커넥트에서는 액세스 토큰과 ID 토큰을 가져오기 위해 두 개의 엔드포인트와 세가지 플로를 정의했다.

권한 부여 엔드포인트
클라이언트가 권한 부여 요청을 보낼 서비스 창구, 클라이언트 인증을 하는 플로에서는 토큰 엔드포인트에 액세스하기 위한 키(권한 부여 코드)를 반환한다. 인증하지 않는 플로에서는 액세스 토큰과 ID 토큰은 이 엔드포인트가 반환한다.
토큰 엔드포인트
액세스 토큰과 ID 토큰을 반환하는 창구. 클라이언트를 인증해 강한 권한을 가진 토큰을 반환한다.

 

세 개의 플로는 아래와 같다.

Authorization Code Flow
OAuth의 Authorization Code와 같다. Client_secret을 은닉할 수 있는 서버 환경용, 권한 부여 엔드 포인트에 액세스해서 권한 부여 코드를 가져온 후, 토큰 엔드포인트에 액세스해서 토큰을 얻는다. 클라이언트 인증을 사용할 수 있으므로 가장 강한 권한을 허용할 수도 있다.
Implicit Flow
OAuth의 Implicit Grant와 같다. HTML 상의 자바스크립트 등 client_secret을 은닉할 수 없는 클라이언트 환경용. 권한 부여 엔드포인트에 액세스해 코드와 토큰을 한 번에 가져온다.
Hybrid Flow
Implicit Flow와 비슷하지만 권한 부여 엔드포인트에서 통신에 필요한 토큰과 추가 정보를 얻기 위한 권한 부여 코드를 얻을 수 있다. 이 권한 부여 코드를 사용해 토큰 엔드포인트에 액세스한다.

 

OAuth 2.0에서는 Implicit Grant로 사용자 인증을 하려고 하면, 차가 지나갈 정도로 보안에 큰 구멍이 뚫린다고 한다. 이에 대해서도 오픈아이디 커넥트에서는 해시 코드를 사용해 각종 토큰을 검증할 수 있게 개량했다.


OAuth 2.0과의 차이는 Hybrid Flow가 가장 크다. OAuth가 목표로 한 것은 다음 3자간의 워크 플로이다.

  • 사용자
  • 클라이언트
  • 권한 부여 서버

그러나 모바일 애플리케이션이 보급되면서 클라이언트가 다시 둘로 나뉘어 4자간의 워크 플로가 필요해졌다.

  • 사용자
  • 클라이언트 1 : 스마트폰 단말의 애플리케이션
  • 클라이언트 2: 백엔드 웹 서비스
  • 권한 부여 서버

Hybrid Flow는 클라이언트 1에서 client_secret을 은닉할 수 없는 환경을 위한 Implicit Flow로 권한을 부여한 후에, 클라이언트 2가 클라이언트 인증을 하면서 토큰의 엔드포인트에 요청을 보내 더 강력한 권한을 가진 토큰을 얻을 수 있게 됐다.

 

 

 

 

Reference

  • 리얼월드 HTTP : 역사와 코드로 배우는 인터넷과 웹 기술

 

+ Recent posts