웹 프로그래머를 위한 HTTP 완벽 가이드 읽는 법

HTTP 완벽가이드는 이름 그대로 HTTP를 매우 자세히 다루고 있는 책이다. HTTP를 이해해야 하는 사람은
웹 프로그래머만은 아니므로, 이 책은 웹 프로그래머만을 위해 쓰여진 책이 아니다.
따라서 웹 프로그래머가 이 책의 내용을 모두 다 완전히 이해해야 하는 것은 아니며,
중요도가 장 마다 크게 다르다.

웹 프로그래머의 시간은 유한한데 반해 공부해야 할 내용은 어마어마하게 많으므로,
중요한 것만 읽고 안 읽어도 되는 부분은 그냥 넘어가자.

무엇을 읽고 무엇을 안 읽을 것인가

I. HTTP: 웹의 기초

Part I은 모두 읽는 것이 좋다. 기초가 튼튼해야 이후의 내용을 잘 이해할 수 있다.

1장 “HTTP 개관”은 HTTP에 대해 개략적으로 설명해주므로, 이후의 내용들을
이해하는데 도움이 된다.

2장 “URL과 리소스”와 3장 “HTTP 메시지”는 반드시 읽어야 한다. 이 장들을 읽지
않으면 이후 내용들도 이해하기 어렵다. 특히 3장은 이름대로 HTTP 그 자체를
설명하는 장이다.

4장 “커넥션 관리”도 읽는 것이 좋다. HTTP 기저에서 TCP가 어떻게 동작하는지
설명한다. 읽고 나면 HTTP가 왜 느린지 이해할 수 있게 될 것이다.

II. HTTP 아키텍처

Part II는 유용한 내용이 많다. 특히 5장이 가장 중요하고 그 다음은 7장이 유용하다.

5장 “웹 서버”는 웹 서버가 어떻게 동작하는지 설명한다. 웹 프로그래머라면 반드시
이해해야 할 것이다.

6장 “프락시”도 읽는 것이 좋다. 프락시에 대한 이야기는 이후에도 계속 나오게
된다. 또한 네트워크 엔지니어들과 대화하려면 프락시 정도는 이해하는 것이 좋다.

7장 “캐시”도 읽어 두자. 제목은 캐시지만 캐시 뿐 아니라 조건부 요청(304로
응답하는 그거)도 다룬다. 웹 프로그래머라면 반드시 써먹게 될 것이다. 15장에서도
같은 내용을 다루기는 하지만 7장이 더 자세하다.

8장 “통합점: 게이트웨이, 터널, 릴레이”는 꼭 읽어야 하는 건 아니다. 나중에
궁금해지면 읽어도 별 상관은 없다.

9장 “웹 로봇”은 로봇이나 검색엔진에 관심이 있다면 읽어보자. 웹 서비스를
운영하게 된다면 웹 로봇이 무슨 원리와 규칙으로 동작하는지 궁금해질 것이다. 혹은 그냥 웹에 대한 교양이라는 느낌으로 읽어도 좋다.

10장 “HTTP/2.0″는 HTTP/2가 궁금하다면 읽어보자. HTTP/2의 목적은 성능 개선이니, HTTP/1이 느린 것이 불만인 사람도 읽어보자. HTTP/2가 완성되기 전에 쓴
것이긴 하지만 최신 명세와 크게 다른 점은 없을 것이다.

III. 식별, 인가, 보안

Part III는 13장 빼고는 대체로 유용하다.

11장 “클라이언트 식별과 쿠키”를 읽으면 쿠키에 대해 올바르게 이해할 수 있게 된다.

12장 “기본 인증”도 읽으면 좋다. 기본 인증(Basic Authentication)은 여전히 종종
쓰이기 때문이다. 그리고 매우 쉬워서 읽기도 좋다.

13장 “다이제스트 인증”은 읽을 필요가 없다. 다이제스트 인증 쓰는 것은 거의 본
일이 없다. 이걸 공부해도 써 먹을 일은 아마 없을 것이다. 심지어 내용도 복잡해서
읽어도 이해가 잘 안된다. 여럿이 같이 스터디 중이라면 이 장은 그냥 제끼자.

14장 “보안 HTTP”은 HTTPS를 다루고 있다. 읽는 것이 좋다. 몰라도 HTTPS를 쓸 수는
있겠지만, 왜 HTTPS가 안전한지 이해하고 싶다면 읽는 것이 좋다.

IV. 엔터티, 인코딩, 국제화

파트 I가 HTTP에 대한 기본적인 이해를 위해 필요했다면, 파트 IV는 HTTP를 제대로
쓰기 위해서 필요하다. 여기서 다루는 내용들은 대체로 웹 서버나 웹 프레임워크가
알아서 처리해주지 않아서 웹 프로그래머가 이해해야 하는 것들이 많다. 가급적 모두
읽도록 하자.

그 중에서도 16장 “국제화”에서 다루는 내용은 비단 웹 프로그래밍이나 HTTP에만
적용되는 내용이 아니라 다국어를 다루는 모든 프로그래머가 알아야 할 내용이므로 활용 범위가 매우 넓다.

V. 콘텐츠 발행 및 배포

이 파트의 내용은 선택적으로 필요에 따라 읽으면 된다. 스터디를 하고 있다면 이
파트 전체를 생략해도 괜찮다.

18장 “웹 호스팅”은 웹 서비스 운영을 시작하게 되면 그때 읽어도 무방하다.

19장 “배포 시스템”은 FrontPage와 WebDAV을 다루는데, 지금 FrontPage나 WebDAV을 쓸 일이 없다면 읽지
않아도 무방하다.

20장 “리다이렉션과 부하 균형”은 앞부분만 좀 읽고 넘어가도 된다. 이 장에서
다루고 있는 캐시 배열 라우팅 프로토콜 같은 거 나는 써본 일이 없다. 혹시
네트워크 엔지니어 역할까지 겸하고 있다면 알 필요가 있을지도 모르겠는데, 나는
그런 경험을 해 본 일이 없어서 잘 모르겠다.

21장 “로깅과 사용추적”도 역시 필요에 따라 읽으면 된다. 로그 포맷에 대한 내용은
사용하고 있는 웹 서버의 매뉴얼을 읽어도 충분할 것이고, 사용 추적은 필요하지
않을 수도 있다.

VI. 부록

부록은 대체로 HTTP 명세나 IANA 웹사이트 등에서 찾아볼 수 있는 것들이다. 책이
두꺼워서 들고다니기 무겁다면 잘라내도 좋다. 그냥 인터넷에서 찾아봐도 된다.

세줄 요약

매우 바쁘다면 1-3장만 읽자. 그 정도만 읽어도 큰 도움이 된다.

조금 바쁘다면 1-5, 7, 11, 12, 14, 15, 16, 17장을 읽자.

13, 19장은 관심있는 사람만 읽자.

Advertisements

파일시스템별 timestamp 정밀도

대부분의 파일시스템은 파일들마다 생성시각, 최종변경시각, 최종접근시각을 항상 기록해 둔다.

그런데 이 시간들의 정밀도는 파일시스템별로 제각각이다. 위키백과를 뒤져서 정리해보았다.

  • FAT는
    • 최종변경시각은 2초
    • 생성시각은 10밀리초
    • 최종접근시각은 1일
    • 삭제시각은 2초
  • HFS+는 1초
  • ext2,3도 1초
  • exFAT는 10밀리초
  • NTFS는 100나노초
  • ext4는 1나노초

ext4의 등장 이후 1나노초 수준의 정밀도를 요구하는 목소리가 많아지면서, 각 프로그래밍 언어 등에서도 이에 대한 지원이 들어가기 시작했다.

정정
  • java8이 1나노초 정밀도를 지원하게 되는 줄 알았는데, r6404를 보니 오버플로우 방지를 위해 1마이크로초로 잘라내고 있었다. (2013/02/03)

Scala의 variant

Scala의 variant는 낯설고도 헷갈리기 쉬운 개념이다.

개념 정의

A <: B 일때 C[A] <: C[B] 라면, C는 covariant다.
A <: B 일때 C[A] >: C[B] 라면, C는 contravariant다.

역도 성립한다.

C가 covariant라면, A <: B 일때 C[A] <: C[B]이다.
C가 contravariant라면, A <: B 일때 C[A] >: C[B]이다.

선언하는 법

C를 covariant로 선언하려면,

class C[+A] { ... }

C를 contravariant로 선언하려면,

class C[-A] { ... }

아래 코드의 두번째줄에서 타입 에러가 발생하지 않으려면,

object Nil extends List[Nothing] { ... }
val x : List[String] = Nil

List는 아래와 같이 covariant로 선언되어야 한다.

class List[+T] { ... }

이렇게 하면 List가 covariant이므로, String >: Nothing 일때 List[String] >: List[Nothing] 이다. 따라서 에러가 발생하지 않는다.

Scala와 Haskell의 문법

Functional Progrmaming Principles in Scala 수업을 듣고 스터디를 하면서 배운 스칼라의 문법 및 언어 특징들을 Haskell과 비교하면서 적어본다.

Type inference

Scala는 함수를 정의할 때 return type이 무엇인지 생략해도 알아서 추론을 할 수 있다.

scala> def double (x: Int) = x * 2
scala> :type double
(x: Int)Int

하지만 formal parameter의 타입은 생략할 수 없다.

scala> def double (x) = x * 2
<console>:1: error: ':' expected but ')' found.
   def double(x) = x * 2
               ^

Haskell은 둘 다 생략해도 정확하게 추론해준다.

Prelude> let double x = x * 2
Prelude> :t double
double :: Num a => a -> a

currying

Scala는 currying을 쉽게 할 수 있도록, multiple parameter list를 지원한다.

scala> def sum1 (x: Int, y: Int) = x + y
scala> def sum2 (x: Int)(y: Int) = x + y
scala> sum1(3, 4) == sum2(3)(4)
res2: Boolean = true

Haskell도 지원한다.

Prelude> let sum1 (x, y) = x + y
Prelude> let sum2 x y = x + y
Prelude> sum1(3, 4) == sum2 3 4
True

Haskell은 어째 후자가 더 자연스러워보인다.

High order function

Scala에서 function은 first class citizen이다.

scala> def apply (f: Int => Int, x: Int) = f(x)
scala> def double (x: Int) = > x * 2
scala> apply(double, 4)
res1: Int = 8

apply의 첫번째 parameter에 들어갈 함수가 Int가 아닌 다른 타입을 다룰 수 있게 하려면 다음과 같이 타입 파라메터를 명시해야한다.

scala> def apply[T, U](f: T => U, x: T) = f(x)
scala> def double (x: Int) => x * 2
scala> apply(double, 4)
res2: Int = 8
scala> def hello (x: String) => "hello, " + x
scala> apply(hello, "world")
res3: java.lang.String = hello, world

Haskell은 타입 추론이 잘 되니 타입 파라메터가 필요없다.

Prelude> let apply (f, x) = f x
Prelude> let double x = x * 2
Prelude> apply(double, 4)
8
Prelude> let hello x = "hello, " ++ x
Prelude> apply(hello, "world")
"hello, world"

Anonymous function

Scala에서 익명 함수(anonymous function)를 정의하는 문법은 보통 함수을 정의하는 문법과 살짝 다르다.

scala> def sum (x: Int, y: Int) = x + y
scala> sum(3, 4)
res1: Int = 7
scala> ((x: Int, y: Int) => x + y)(3, 4)
res2: Int = 7

솔직히 항상 헷갈린다. 특히 ==>로 바뀌는 부분이.

Haskell도 마찬가지로 익명 함수 정의 문법이 별도로 있다.

Prelude> let sum x y = x + y
Prelude> sum 3 4
7
Prelude> (x y -> x + y) 3 4
7

Haskell도 =->로 바뀐다. 뿐만 아니라 파라메터 목록 앞에 가 더해진다.

Scala나 Haskell이 맨날 쓰는 언어면 익명함수 문법 정도 그냥 외워버리겠지만 어쩌다 한번 쓰는거다보니 매번 까먹고 적어놓은 것을 뒤적거리게 된다.

ECMAScript(JavaScript)의 Object와 Property

JavaScript(이하 ECMAScript)를 처음 접하면 프로토타입 개념이 혼란을 일으키곤 하는데, Object가 어떻게 생성되고 프로퍼티를 어떻게 읽고 쓰는지만 확실히 기억해두면 혼란을 피할 수 있다.

Object 생성하기 (( ECMA-262 13.2.2 ))

ECMAScript에서 오브젝트란 프로퍼티들의 정렬되지 않은 컬렉션이다. 오브젝트는 생성자를 통해 만들어진다. Foo()라는 생성자가 있다면 다음의 방법으로 만들 수 있다.

x = new Foo();

이 때 생성자가 prototype이란 이름의 프로퍼티를 갖고 있다면 그 프로퍼티의 값이 생성되는 오브젝트의 프로토타입이 된다.

Foo.prototype = new Number();
x = new Foo();

new Number() 오브젝트는 x의 프로토타입이므로, x 오브젝트에 어떤 property를 추가하지 않았더라도 그 property가 new Number() 오브젝트에 존재한다면 x 오브젝트의 프로퍼티처럼 읽게 된다. 다음 절을 읽어보자.

프로퍼티 읽기/쓰기

ECMASCript에서 오브젝트의 프로퍼티의 읽기/쓰기는 다음과 같이 동작한다.

읽기

프로퍼티를 읽는 내부 메소드를 GET이라고 한다. GET은 다음과 같이 동작한다. (( ECMA-262 8.12.3 ))

어떤 오브젝트에 대해 GET P를 하면,

  1. 이름이 P인 프로퍼티가 있으면 그 프로퍼티의 값을 반환
  2. 없으면 [[Prototype]] 내부 메소드로 프로토타입을 가져오고 null이 아니라면 [[Get]] (P) 를 수행, null이면 undefined 반환.

쓰기

프로젝트를 쓰는 내부 메소드를 PUT이라고 하는데, PUT은 프로퍼티 존재 유무 확인하고 그런 거 없이 그냥 현재 오브젝트에 바로 값을 쓴다. 쓸 수 없다면 예외를 던진다. (( ECMA-262 8.12.5 ))

vim에 컴파일러/인터프리터의 에러메시지 연동시키기

대부분의 IDE에서는, 컴파일 에러나 런타임 에러가 발생했을 때, 소스코드에서 해당 에러의 원인이 된 라인으로 바로 이동하는 기능이 있다.

물론 vim에서도 약간의 설정으로 가능한 기능이다. 설정 및 사용법을 알아보자.

makeprg 설정

우선 현재의 소스코드를 해석하거나 컴파일할 때 사용할 프로그램을 설정한다. 여기서는 python으로 해 보겠다.

:set makeprg=python

이렇게 하고 나면, :make %로 현재 편집중인 파일을 파이썬으로 실행해 볼 수 있다.

errorformat 설정

다음은 vim이 파이썬의 에러메시지를 해석할 수 있도록 에러포맷을 정의해야 한다.

:set errorformat=%C %.%#,%A  File "%f"\, line %l%.%#,%Z%[%^ ]%\@=%m

이제 :make %로 편집중인 소스코드를 파이썬으로 실행했을 때 에러가 발생한다면, 자동으로 커서가 에러가 발생한 위치로 옮겨진다.

파이썬이 출력한 에러메시지에서 어떤 줄이 위의 식에 매칭된다면, vim은 그 줄이 소스코드에서 에러의 원인이 되는 위치를 알려주는 것으로 생각하고 해석하여 파일명, 라인번호 등의 정보를 얻어낸다. 위의 식이라면 %f에 대응하는 것이 파일명, %l에 대응하는 것이 라인번호다.

예를 들어 파이썬이 아래와 같은 메시지를 출력한다면,

Traceback (most recent call last):
File "test.py", line 13, in <module>
    x = MyClass()
TypeError: __init__() takes exactly 2 arguments (1 given)

vim은 두번째 줄을 인식해서 test.py와 줄번호 13을 얻어낼 것이다.

자세한 문법은 :help errorformat으로 확인해보자.

만약 errorformat과 대응하는 에러메시지가 1개 이상이라면, cncp로 각 에러를 오갈 수 있으며, clist로 에러메시지의 목록을 확인할 수 있다.

단축키 한번에 실행하기

매번 :make %을 입력하는 것이 번거롭다면, 다음과 같이 단축키 한번에 실행되도록 할 수도 있다. 다음은 f5 키로 실행하도록 하는 설정이다.

:map<f5> :make %<cr>

설정파일에 설정하기

물론 위에서 언급한 errorformat이나 makeprg의 설정 등은 모두 언어별로 다를 수 밖에 없다. 따라서 vim의 syntax 설정 디렉토리에 넣어두는 것이 좋을 것이다. 파이썬이라면 ~/.vim/syntax/python.vim 파일에 다음의 설정을 추가하면 된다.

set errorformat=%C %.%#,%A  File "%f"\, line %l%.%#,%Z%[%^ ]%\@=%m
set makeprg=python
map<f5> :make %<cr>

구글 캘린더에 자연어로 이벤트 추가하는 자바스크립트 애플리케이션 만들기

구글캘린더에 자연어로 이벤트를 추가하는 아주 간단한 웹 애플리케이션을 만들었다.

‘굳이’ 서버사이드 스크립트를 전혀 사용하지 않고 만들었는데, 첫째는 서버에 부담을 주기 싫었고 둘째는 웹서버가 없어도 로컬에서 웹브라우저만으로 동작할 수 있게 하고 싶었기 때문이다.

여기에서 테스트해볼 수 있다. 소스코드도 거기서 참조하고 있는 자바스크립트 파일들이 전부이다.

만들기

OAuth 인증

요게 가장 난감하다. OAuth 인증과정에서 구글 서버하고 내 서버가 서로 통신을 해야 하기 때문이다. 이걸 순수하게 클라이언트 사이드 스크립트로만 처리하려면 약간 지저분해지는 것은 피하기 어렵다.

우선 여기서 웹애플리케이션용 클라이언트 아이디를 받급받자.

client id를 얻었다면 이걸로 구글 서버에 access token을 달라고 요청하는 코드를 작성한다. redirect_uri 를 줘야 하는데, 이건 window.location에서 얻어온다. 요청하는 것도 window.location을 이용한다.

var redirect_uri =
    'https://' + window.location.host + window.location.pathname;

window.location = "https://accounts.google.com/o/oauth2/auth" +
    "?response_type=token" +
    "&client_id=" + client_id +
    "&redirect_uri=" + redirect_uri +
    "&scope=https://www.googleapis.com/auth/calendar" +
    "&state=profile";

인증이 되면 구글의 인증 서버가 redirect_uri로 요청을 보낸다. access token이 query에 붙어서 오므로 이것을 뜯어내어 사용한다. window.location.hash.substring(1) 로 가져올 수 있다.

var params = {};
var queryString = window.location.hash.substring(1);
var regex = /([^&=]+)=([^&]*)/g;
var m;

while (m = regex.exec(queryString)) {
    params[decodeURIComponent(m[1])] = decodeURIComponent(m[2]);
}

access_token = params.access_token;
window.location.hash = "";

주소창에 access token이 떡 노출된다. 찝찝하니까 window.location.hash = ""; 로 지워버렸다. 깔끔하지 못하지만 ‘굳이’ 서버를 거치지 않고 싶으니 별 수 없다.

위의 스크립트는 최우선으로 실행되도록 한다. 왜냐면 저 인증과정에서 구글의 인증 페이지로 이동하게 되므로 다른 작업은 해봤자 무의미한 시간 및 트래픽 낭비이기 때문이다.

그럼 여기서부터는 HTML 페이지 및 자바스크립트 파일들을 다 로딩한다음 작업을 수행해도 된다. jquery도 마음껏 쓸 수 있다.

access token은 사용하기 전에 반드시 validation을 해야 한다. 이제 jquery를 써서 비동기로 요청을 보내자. validation에 성공하면 callback 함수를 호출해서 다음 할 일을 진행하도록 하자.

var validateAccessToken = function(callback) {
  var url =
    'https://www.googleapis.com/oauth2/v1/tokeninfo?access_token=' +
    access_token;

  $.get(url, function(data) {
    callback();
  }).error(function() {
    notifyError('access token is invalid');
  });
}

validation까지 끝났다면 모든 준비는 완료되었다. 이제 진짜 하고 싶은 일을 할 수 있다.

이벤트 입력받아 자연어 처리하기

사용자의 캘린더에 이벤트를 추가하려면, 우선 사용자로부터 이벤트를 입력받아야 한다.

내가 생각하기에 가장 쉬운 이벤트 추가 방법은 그냥 자연어로 입력받는 것이다. 이렇게:

party with you@mail.com at my house on Friday 7pm

제대로 된 자연어 처리 기능을 넣는 것은 너무 어려우니 아주 단순하게 처리해보자.

내가 택한 방식은, 일단 조사 단위로 자르고 각 구절을 파싱하는 것이다. ‘with’, ‘at’, ‘on’, ‘in’ 기준으로 자른다.

var phrases = [];
var prepositions = ['with', 'at', 'on', 'in'];

for (var i in prepositions) {
  var name = prepositions[i];
  var matched = source.match(new RegExp('\b' + name + '\b'));
  if (matched) {
    phrases.push({name: name, index: matched.index});
  }
}

이렇게 잘라내면, ‘party’, ‘you@mail.com’, ‘my house’, ‘Friday 7pm’을 얻을 수 있다.

그리고 파싱을 하면 되는데… 사실 파싱이라고 해봤자, 이벤트 요약(party), 참석자(you@mail.com), 장소(my house)는 굳이 파싱할 필요가 없다. 그대로 이벤트에 넣어주면 된다.

일시(Friday 7pm)만 파싱해주면 되는데, 이건 Sugar 라는 훌륭한 라이브러리가 있으므로 그걸 그대로 사용했다.

date = Date.create(phrase);

그럼 이제 필요한 건 다 얻었으니, 이벤트를 만들어서 사용자의 캘린더에 넣어주면 된다.

구글 캘린더 API로 이벤트 추가하기

우선 캘린더를 얻어야 한다. 사용자에게 캘린더가 여러개 있을 수도 있는데, 그냥 여기선 첫번째 캘린더를 얻자.

var getTheFirstCalendar = function(callback) {
  url = 'https://www.googleapis.com/calendar/v3/users/me/calendarList?maxResults=1&minAccessRole=writer';
  $.ajax(url,
    {
      datatype: 'json',
      headers: {
        Authorization: 'Bearer ' + access_token
      },
      success: function(data) {
        if (typeof data === 'string') {
          data = JSON.parse(data);
        }
        callback(data.items[0].id);
      },
      error: function(jqXHR, textStatus, error) {
        notifyError('Failed to get a calendar: ' + error + 'from ' + url);
      }
    }
  );
}

여기서도 마찬가지로 성공했으면 다음 작업 진행을 위해 callback을 호출하게 했다.

캘린더까지 얻어왔다면 이벤트를 추가하면 된다. 아까 사용자로부터 입력받아 파싱까지 완료된 이벤트 데이터를 구글 캘린더 API에서 요구하는 형식에 맞게 맞춰주고 json으로 만들어 이벤트 추가 요청을 보낸다.

var addEvent = function(calendar_id) {
  if (data.date) {
    data.end = data.start = {
      dateTime: moment(data.date).format()
    }
  } else {
    data.start = data.end = {
      dateTime: moment().format()
    };
  }

  if (data.emails) {
    data.attendees = [];
    for (var i in data.emails) {
      data.attendees.push({
        email: data.emails[i],
        responseStatus: 'needsAction'
      });
    };
  }

  data.reminders = { useDefault: true };

  calendar_id = encodeURIComponent(calendar_id);
  url = 'https://www.googleapis.com/calendar/v3/calendars/' + calendar_id + '/events?sendNotifications=false';
  $.ajax(url,
    {
      contentType: 'application/json; charset=utf8',
      datatype: 'json',
      type: 'post',
      headers: {
        Authorization: 'Bearer ' + access_token
      },
      data: JSON.stringify(data),
      success: function(data) {
        notifySuccess('Created successfully. Click to go to the event.');
      },
      error: function(jqXHR, textStatus, error) {
        notifyError('Failed to create an event: ' + error + ' from ' + url);
      }
    }
  );
}

폼 제출(submit)

form submit을 ‘굳이’ 자바스크립트로 했다. form으로 submit 한 요청에 대한 응답을 처리할 수 있는 이벤트가 있고 거기에 핸들러를 등록할 수 있다면 안 해도 될 것 같은데, 그런건 못 찾았다.

$('#new-event').submit(function() {
  $('#submit_button').button('loading');
  getTheFirstCalendar(addEvent);
  return false;
});

기타 고려사항

HTTPS

OAuth는 HTTP, HTTPS 어떤 scheme으로도 사용할 수 있지만 HTTP를 사용했다간 access token이 암호화되지 않은 채로 사용자에게 전달될테니 그보다는 HTTPS를 사용하자. startssl에서 무료로 인증서를 발급받을 수 있다.

Free (( 아이콘을 얻은 사이트에 Free라고 되어있었다. 공짜라고 하려다가 Free는 공짜가 아니야! 라고 할까봐 )) 아이콘

무료로 쓸 수 있는 품질 좋은 아이콘 없나 찾아 헤매다가 glyphish라는 아이콘 셋을 찾았다. 200개는 무료, 400개는 25달러다.

브라우저 호환성

몽땅 클라이언트 사이드 자바스크립트로 동작하므로, 브라우저 호환성은 완전 꽝이다. 지금 내가 만든 이것도 꽤 많은 브라우저에서 안 돌아갈 것이다.

그러니 꼭 클라이언트 사이드에서 전체 기능이 동작할 필요가 없다면 굳이 이렇게 만들지 않는 것이 좋다.