본문으로 건너뛰기

· 약 14분

Status Quo

이전에 GraphQL과 Relay에 대해 글을 썼습니다. 그때는 GraphQL과 Relay의 실체가 없었는데 2015년 7월에 ReactEurope 컨퍼런스에서 GraphQL RFC가 공개되었고 8월에 Relay Technical Preview가 공개되었습니다.

그러나 이 기술들이 공개된 지 2달도 채 지나지 않았기 때문에, 아직 진지하게 쓰기에는 생태계가 전반적으로 미숙한 상황으로 보입니다.

현재 성숙한 GraphQL 구현이 레퍼런스 구현인 graphql-js 뿐이어서 당장 GraphQL을 지원하는 서버를 작성하기가 쉽지 않습니다. 그래서 GraphQL의 파이썬 구현인 graphqllib를 만들고 있지만 진도가 더딘 편입니다. 처음에 graphql-js를 포팅하며 시작했는데, graphql-js의 API가 자주 바뀌어 따라가지 못하는 상황입니다.

또한 웹이 아닌 환경을 위한 GraphQL 클라이언트 구현이 아직 존재하지 않아서, 그 쪽도 연구가 필요해 보입니다. 페이스북에서 좀 더 여러가지 라이브러리를 공개해주면 해결되리라 생각합니다. (얼마 전에 C++ 파서 구현인 libgraphqlparser도 공개되었습니다.)

어쨌든 이제 실제 문서와 코드를 볼 수 있기 때문에 이전에 애매했던 것이 명확해졌습니다. 그래서 GraphQL에 대해 제대로 다시 소개해서 예전 글을 보강하고자 합니다. RFC 스펙 문서는 상당히 격식 있게 쓰여 있기 때문에(어렵지는 않지만), 스펙 문서만 읽어서는 GraphQL이 어떤 것인지 금방 감이 오지는 않습니다. 그래서 GraphQL 스펙을 중요하다고 생각되는 부분만 매우 간단히 요약하였습니다.

GraphQL이란?

GraphQL은 클라이언트 애플리케이션에서 어떤 데이터가 필요한지 기술할 수 있는 쿼리 언어입니다. 대부분의 REST API에서는 실제 클라이언트가 표시하는 형태와 API 프로토콜이 일치하지 않는 경우가 많습니다. (심지어, 의도적으로 뷰와 독립적으로 설계하는 편입니다.) 이와 달리 GraphQL은 쿼리를 클라이언트의 UI 계층 구조와 유사하게 구성할 수 있습니다. 따라서 쿼리가 하나의 커다란 트리를 이룹니다.

GraphQL 쿼리의 예
사용자 프로필 컴포넌트를 GraphQL 쿼리로 나타낸 예 (courtesy of Laney Kuenzel)

GraphQL은 스키마가 미리 정의되어 있는 강타입 언어입니다. 타입은 정적 분석으로 실수를 줄이는데도 도움을 주지만, 무엇보다 그 자체로 문서의 역할도 하므로 개발자 사이의 커뮤니케이션에도 도움이 됩니다. 또한 introspection을 통해 스키마 정보를 GraphQL로 쿼리할 수 있고, 이를 이용해 툴을 만들 수 있습니다.

GraphiQL
GraphiQL — Introspection을 사용하는 GraphQL IDE

실렉션 세트(selection set)

객체의 어떤 필드를 선택할 것인지 실렉션 세트로 나타낼 수 있습니다. 다음은 가장 간단한 형태입니다.

{
id
text
}

id, text 필드를 선택하는 쿼리입니다. 쿼리를 실행하면 다음과 같은 JSON 형식의 결과가 나올 것입니다.

{
"id": 42,
"text": "Hello, world!"
}

인자(argument)

필드에는 인자를 넘길 수 있습니다.

{
pictureURL(width: 50, height: 50)
}

참고로 GraphQL은 SQL 같은 강력한 쿼리 언어는 아닙니다. GraphQL 표준에서는 인자에 따른 실제 반환값의 시맨틱은 정해져있지 않습니다. (WHEREORDER BY 같은 것이 따로 없고, 인자의 이름은 GraphQL 표준 입장에서는 임의의 문자열입니다.)

별명(alias)으로 필드 이름을 바꿀 수 있습니다. 같은 필드를 다른 인자로 가져올 때도 사용합니다.

{
fullName: name
smallPic: profilePic(size: 64)
bigPic: profilePic(size: 1024)
}

실렉션 세트의 중첩

만약 필드가 객체나 리스트 타입(스칼라가 아닌 타입)이라면 하위 객체의 실렉션 세트를 반드시 명시해야 합니다.

{
id
text
author {
name
pictureURL(width: 50, height: 50)
posts {
id
text
}
}
}

왜 모든 필드를 반드시 명시해야 할까?

기존에 있는 애플리케이션을 GraphQL로 표현해보려 하면 모든 필드를 명시하는 것이 생각보다 귀찮습니다. 하지만 제가 생각하기에는 다음과 같은 장점이 있습니다.

  • 꼭 필요한 데이터만 가져오므로 성능에 도움이 됩니다. 통신하는 데이터 양을 절약할 수 있고 서버에서도 낭비되는 자원을 줄일 수 있습니다.
  • 어떤 필드를 요청했는지 분명하기 때문에 필드가 응답에 포함되어 있지 않은 경우의 모호함이 없습니다. 필드가 들어오지 않았을 때 실제 서버 상에서 그 필드가 비어있을 수도 있고 요청한 표현 방식에서 생략되었을 수도 있는데, 모든 필드를 명시하면 이런 문제가 없습니다.

쿼리 루트(query root)

앞서 살펴본 예제들은 이미 어떤 객체가 주어졌다고 가정하고 작성되어 있습니다. 그렇다면 이 최상위 객체는 어디서 오는 것일까요. 일반적인 REST API라면 URL로 리소스를 찾게 되지만, GraphQL API는 하나의 엔드포인트를 사용하는 것이 컨벤션입니다. (강제되는 것은 아닙니다)

GraphQL 쿼리의 가장 바깥 실렉션 세트는 '쿼리 루트' 객체로부터 시작하도록 정해져 있습니다. 따라서 GraphQL 서버는 쿼리 루트에 인터페이스의 시작점들을 노출할 수 있습니다. 가령 현재 사용자나 특정 ID의 글을 가져오는 것 등이겠지요.

{
# 나는 누구인가?
me {
name
}

# id=42인 글 찾기
post(id: "42") {
title
}
}

프래그먼트(fragment)

같은 실렉션 세트가 한 쿼리 안에서 중복될 수 있습니다. 예를 들어 한 화면에 글쓴이의 프로필과 댓글 작성자의 프로필이 나오는데 같은 컴포넌트를 쓴다면 같은 필드가 필요할 것입니다. 이때 프래그먼트를 사용하여 중복을 제거할 수 있습니다.

# GraphQL 문서에 쿼리 하나만 있는 경우가 아니면 명시적으로 쿼리임을 나타내야 합니다.
query sampleQuery {
post(id: "4") {
id
text
author { ...basicUserInfo }
}
users {
...basicUserInfo
}
}

# User 타입에 대한 프래그먼트임을 명시했습니다.
fragment basicUserInfo on User {
name
pictureURL(width: 50, height: 50)
}

이처럼 프래그먼트는 조합이 가능한데, 이는 Relay 프레임워크의 핵심 기능으로 사용됩니다. 각 뷰마다 프래그먼트를 가지고 있고, 이 프래그먼트들이 뷰 계층의 가장 아래에서부터 조합되어 하나의 큰 GraphQL 쿼리를 구성하게 됩니다.

GraphQL은 다형성을 지원합니다. 인터페이스로 공통 필드를 정의할 수 있고, 유니온(union) 타입으로 여러 타입을 하나로 묶을 수 있습니다. 여러 타입을 가지는 필드도 프래그먼트의 타입 조건으로 구별할 수 있습니다.

query samplePolymorphicQuery {
timeline {
data { # data는 Post 또는 User의 리스트
# 인라인 프래그먼트, Post 타입일 때
... on Post {
title
}

# 프래그먼트 참조. User 타입일 때
...basicUserInfo
}
}
}

뮤테이션(mutation)

GraphQL은 데이터의 읽기 외에 쓰기(변형)도 지원합니다. 쿼리와 뮤테이션은 거의 같은 문법을 가지고 있지만, 쿼리는 필드를 순서 없이 평가하는 반면 뮤테이션의 필드는 항상 순서대로 평가합니다. 또한 쿼리가 쿼리 루트로부터 시작하는 것처럼 뮤테이션은 뮤테이션 루트에서 시작합니다.

mutation sampleMutation {
setName(name: "Zuck") {
newName
}
}

Relay의 GraphQL 확장

Relay는 GraphQL과 React로 애플리케이션을 개발하기 위한 프레임워크입니다. GraphQL에서는 기본적인 쿼리 시스템만 정의하기 때문에 실제 애플리케이션을 구현하려면 어느 정도 정해진 컨벤션이 필요합니다. Relay에서 정의한 컨벤션이 꼭 Relay를 쓰는 경우가 아니라도 쓸만하기 때문에 소개하겠습니다. 그리고 제목을 '확장'이라고 적긴 했지만 GraphQL을 확장한다기보다 몇가지 제약 조건을 추가했다고 보는 것이 좀 더 정확합니다.

커넥션(connection)

일대다 관계를 표현하기 위한 컨벤션입니다. Post 하위의 Comment 콜렉션을 가져오는 예를 들어보겠습니다.

{
comments(first: 5) { # CommentConnection
edges { # CommentEdge
cursor
node { # Comment
author { name }
text
}
}
pageInfo {
hasNextPage
hasPreviousPage
}
}
}

먼저, 커넥션을 리턴하는 필드는 first/after, last/before 인자를 받아야 합니다. comments(first: 5, after: "some cursor")first 5 comments after "some cursor"처럼 읽으면 됩니다. some cursor 이후의 댓글 5개를 가져오는 쿼리입니다.

커넥션 타입의 이름은 Connection으로 끝나야 하며 edgespageInfo 필드를 가집니다.

  • edgesnodecursor 필드를 가지고 Edge로 끝나는 이름의 타입의 리스트를 리턴해야 합니다. node는 항목의 실제 값이고 cursor는 결과를 페이지로 나눠 탐색하는데 사용하는 값입니다.
  • pageInfo는 현재 가져온 데이터보다 앞/뒤로 데이터가 더 있는지 나타냅니다.

더 알아보기

  • GraphQL (Working Draft)
  • graphql-js: GraphQL의 자바스크립트 레퍼런스 구현입니다.
  • Relay: GraphQL과 React로 애플리케이션을 개발할 수 있는 프레임워크입니다.
  • graphql-relay-js: graphql-js에 Relay의 GraphQL 컨벤션을 제공하는 모듈입니다.
  • graphql-swapi: GraphQL 버전의 SWAPI를 GraphQL 쿼리 브라우저인 GraphiQL로 직접 쿼리해볼 수 있습니다. 구조를 이해하는데 도움이 됩니다.

· 약 12분

Facebook은 React.js Conf 2015에서 Relay와 GraphQL이라는 두 가지 기술을 공개했습니다. (같은 날 발표된 React Native 때문에 약간 묻힌 것도 같지만...) 아직 공개된 코드는 없는데, 올해 안에 오픈소스로 릴리즈 예정이라고 합니다.

개인적으로는 최근 몇년 동안 고민하던 문제를 제대로 된 방법으로 해결해줄 것으로 보여서 굉장히 큰 기대를 하고 있습니다. 이 글에서 제가 왜 이렇게 흥분(...)하고 있는지 설명해보려 합니다.

N+1 쿼리 문제

N+1 쿼리 문제는 ORM을 사용해서 DB에 접근할 때 자주 발생합니다. 꽤 잘 알려진 문제이긴 하지만 간단히 예를 들어보겠습니다.

게시판의 글 목록에서 사용자의 이름을 표시해야 한다고 생각해봅시다. 가장 간단하게는 다음과 같이 될 것입니다. (Django로 예를 들었지만, 다른 스택에서도 비슷하겠죠.)

# views.py
def list_post(request):
posts = Post.objects.order_by('-id')
return render_template('list.html', posts=posts)
{% raw %}
{# list.html #}
{% for post in posts %}
{{ post.title }} by {{ post.user.name }}
{% endfor %}
{% endraw %}

이 경우, 루프 안에서 post.user를 가져오는 쿼리를 다시 날리게 되므로 글의 갯수만큼 쿼리가 더 발생하게 됩니다. 그래서 N+1 쿼리 문제라고 합니다. 이를 해결하기 위해서 글 목록을 가져올 때 사용자 정보도 함께 가져오도록 합니다. (JOIN을 사용하게 되겠죠.)

# views.py
def list_post(request):
posts = Post.objects.order_by('-id') \
.select_related('user') # 추가!
return render_template('list.html', posts=posts)

이제 한번의 쿼리만으로 글 목록과 사용자 정보를 가져올 수 있게 되었네요! 그런데 잘 생각해보면 미묘한 문제점이 몇 가지 있습니다.

  • 사용자의 정보가 필요하다는 사실은 list.html 템플릿이 알고 있는데, 실제로 사용자 정보를 미리 가져오는 코드는 views.py에 있습니다. 관심사의 분리(separation of concerns)가 제대로 되지 않았다고 볼 수 있습니다.
  • 템플릿에서는 사용자의 이름만 필요한데, 쿼리에서는 사용자의 모든 필드를 가져오게 되어 있습니다.
  • 만약 views.py를 사용자의 이름 필드만 가져오도록 고친다면, list.html에서 사용자의 다른 필드가 필요해졌을 때 views.py도 수정해야 합니다. 즉 views.pylist.html 사이에 보이지 않는 의존성이 생기게 됩니다.

사실 보통 DB 서버는 웹 서버와 매우 가까이 있기 때문에 N+1 쿼리 문제로 인한 오버헤드가 엄청나게 크지는 않습니다. 그래서 앞서 제기한 문제는 사소한 것일지도 모릅니다.

단일 페이지 애플리케이션

지금은 2015년이죠! 서버에서 HTML을 생성하는 것은 너무 구닥다리 같군요. (넝담~ ㅎㅎ) 클라이언트 측 자바스크립트에서 API 서버와 통신해서 인터페이스를 그려봅시다.

이제 서버와 클라이언트가 확실하게 분리되었기 때문에 템플릿에서 데이터가 필요해졌을때 lazy하게 가져오는 방식은 사용할 수 없습니다. 중간에 필요한 데이터를 다시 API 서버에서 가져오는 것은 너무 부담이 큽니다. 즉 N+1 쿼리 문제가 발생하면 안됩니다. 따라서 한번의 API 요청으로 필요한 데이터를 모두 가져와야 합니다.

보통의 REST API라면 이런 경우에 글 목록에 무조건 사용자 정보를 붙여서 해결하게 됩니다. 좀 더 세밀한 조정이 필요하다면 ?include_user=true와 같은 파라미터로 켜고 끌 수 있게 하거나, 사용자 정보에서 일부 필요한 필드만 들어있는 형태와 전체 필드가 들어있는 형태를 선택할 수 있게 하기도 합니다. 아니면 글 목록 '뷰'를 위한 별도의 API 엔드포인트를 추가할 수도 있겠죠.

문제는 이러한 요구사항이 꽤 자주 발생함에도 불구하고 그때그때 ad hoc하게 적당히 해결만 하고 넘어가게 된다는 것입니다. 주의를 기울이지 않으면 금방 일관성 없는 API가 되어버립니다. 그리고 주로 클라이언트를 구현하다가 서버에서 내려주는 데이터에 빠진 게 있다는 것을 알게 되죠. 만약 클라이언트와 서버를 구현하는 팀이 다르다면 고스란히 커뮤니케이션 비용 증가로 이어집니다.

만약 SQL 쿼리처럼 클라이언트에서 서버의 데이터 형식을 결정할 수 있는 일반적인 방법이 있다면 어떨까요?

GraphQL

위에서 제기한 문제를 해결해주는 것이 GraphQL입니다. 다음은 GraphQL 쿼리의 예입니다.

{
user(id: 3500401) {
id,
name,
isViewerFriend,
profilePicture(size: 50) {
uri,
width,
height
}
}
}

위와 같은 쿼리는 다음의 JSON 응답 형식을 반환합니다.

{
"user" : {
"id": 3500401,
"name": "Jing Chen",
"isViewerFriend": true,
"profilePicture": {
"uri": "http://someurl.cdn/pic.jpg",
"width": 50,
"height": 50
}
}
}

아직 자세히 공개된 바는 없지만, 서버에서 GraphQL 타입 시스템에 따라 스키마를 정의하면 클라이언트에서 원하는 형식대로 쿼리가 가능하다고 합니다. 또한 GraphQL은 Facebook의 모바일 앱이 HTML5 기반에서 네이티브로 옮겨갈 때 쯤부터 사용되었다고 하니 Facebook 정도로 충분히 복잡한 애플리케이션도 잘 표현할 수 있을 것으로 예상되며, 웹 애플리케이션 외의 영역에서도 쓸모가 있다고 볼 수 있습니다.

컴포넌트와 데이터 페칭

아까 글 목록 예제에서 나왔던 템플릿을 재사용할 수 있고 관리하기 편하도록 여러 개의 '컴포넌트'로 분리한다고 생각해봅시다. 다음과 같은 계층 구조를 만들어볼 수 있습니다.

  • PostList
    • PostItem
      • UserInfo
    • PostItem
      • UserInfo
    • ...

UserInfo는 작성자의 이름을, PostItem에서는 글 제목을 보여준다고 합시다. 이때 PostItem에서는 UserInfo에 작성자 이름을 넘겨줘야 하므로 PostList에서 글 목록을 가져올 때 작성자의 이름도 가져와야 합니다.

만약 UserInfo에서 작성자의 프로필 사진도 보여주고 싶다면 어떻게 해야 할까요? 실제로 데이터를 가져오는 코드는 PostList에 있으므로, PostList에서 작성자의 프로필 사진을 가져오도록 수정해야 합니다. 그러나 PostListPostItem만을 명시적으로 참조하고 있으므로 PostListUserInfo 사이에는 보이지 않는 의존 관계가 생기게 됩니다. UserInfo를 포함한 컴포넌트가 PostList 뿐이라면 괜찮을지도 모르지만 더 많은 곳에서 사용되고 있다면 모든 사용처를 찾아서 수정하는 것은 매우 힘든 일이 될 것입니다.

Relay

Relay는 React 애플리케이션을 위한 데이터 관리 프레임워크입니다. Relay의 중요한 특징은 각 컴포넌트마다 필요한 데이터를 선언하고, 컴포넌트의 계층 구조를 따라서 필요한 데이터를 상위 컴포넌트로 전달 및 조합하여 단일 GraphQL 쿼리로 만들어 준다는 것입니다. (그래서 이름이 Relay인 것이죠!)

방금 전에 살펴보았던 컴포넌트 간의 보이지 않는 의존성 문제도 Relay의 방식을 사용한다면 해결할 수 있습니다. UserInfo는 사용자의 이름이 필요합니다. PostItemUserInfo에서 필요한 모든 데이터와 글의 제목이 필요합니다. 최종적으로 PostListPostItem에서 필요한 모든 데이터를 가져오면 됩니다. UserInfo에서 프로필 사진이 필요해진다면 UserInfo 컴포넌트만 수정하면 됩니다.

뿐만 아니라 데이터를 변경할 때 서버의 응답이 오기 전에 미리 수정사항을 반영해주는 기능(optimistic update), 데이터의 일부분만 캐시에서 미리 가져올 수 있는 기능 등도 제공할 것이라고 합니다. 구체적인 구현이 어떻게 될지는 알 수 없지만 자연스럽게 가능하다면 정말 편리할 것입니다.

Facebook Groups iOS 앱에서 Relay를 사용하고 있다고 합니다. (React Native 앱이기도 합니다)

Relay Architecture
Relay 아키텍쳐 (출처)

더 읽어보기

· 약 11분

파이썬 웹 애플리케이션을 배포할 때 고려할 점들이 많이 있습니다. 특히 선택할 수 있는 대안이 많으면 고민이 되는데요. 멋지게 '베스트 프랙티스'를 제시하면 좋겠지만 아직 그러기엔 많이 부족하고, 이 글에서는 제가 선택한 방식과 이유를 정리해서 공유해봅니다.

운영체제: Ubuntu

일반적으로 CentOS 아니면 Ubuntu를 사용하는 것 같습니다. Ubuntu를 선택한 가장 큰 이유는 '익숙해서'입니다. 그 밖에는 다양한 써드 파티 패키지(PPA)에 쉽게 접근할 수 있다는 점, 그리고 개발 환경으로 Ubuntu 데스크탑을 사용하면 배포할 때 환경 맞추기 훨씬 수월하다는 점이 있겠습니다. (CentOS를 데스크탑으로 쓰신다면야 말리지는 않겠습니다...) 버전은 굳이 LTS를 고집할 필요 없이 적당히 최신 버전을 쓰면 되는 것 같습니다.

WSGI 서버: Gunicorn

WSGI 서버로는 uWSGI 또는 Gunicorn이 많이 쓰입니다. uWSGI는 설정할 수 있는 파라미터도 많고 성능도 좋긴 하지만, Gunicorn은 성능이 크게 뒤지지 않으면서 훨씬 간단해서 관리하기가 수월하다고 생각합니다. 그리고 uWSGI는 graceful reload가 안돼서 재시작할 때 다운타임이 발생하는 것으로 보입니다. (해결 방법이 있다면 알려주세요.)

프로세스 관리: Upstart

대안이라면 supervisord가 있겠지만 Upstart는 Ubuntu에 기본으로 들어있어서 덜 귀찮죠. Upstart에 대해 자세히 알고 싶으시면 저번에 쓴 글을 참고하세요. 앞으로 Ubuntu가 systemd로 전환한다면 systemd를 사용하게 되겠죠.

웹 서버: nginx

이 선택은 딱히 논란의 여지가 없을 겁니다. 리버시 프록시와 정적 리소스 서빙에 사용합니다. 다만 반드시 Apache로 돌려야 하는 레거시가 있다면 예외입니다.

코드 배포: Git

애플리케이션 코드가 업데이트 되었을 때 어떻게 받아올 것인가 하는 이야기입니다. 정석대로(?) 하자면 Git 작업 사본과 실제 서비스되는 애플리케이션 코드를 분리하는 것이 좋습니다. 왜냐하면, git pull을 하는 동안 코드의 중간 상태가 노출될 수 있기 때문입니다. 예를 들어 이전 버전의 템플릿 파일과 새로운 버전의 템플릿 파일이 공존하게 된다면 서비스에 문제가 생길 수 있습니다. 하지만 대부분 아주 잠깐 발생하는 일이고, 템플릿 캐싱을 켜는 등 우회할 수 있는 방법이 있어서 큰 문제가 되지는 않습니다.

virtualenv나 설정 파일 등을 어디에 위치시킬 것인지도 고민하게 됩니다. 많은 경우 Git 작업 사본 안에 만들게 되는데, 그런 경우 .gitignore 파일에 확실히 추가하여 저장소에 들어가지 않도록 주의가 필요합니다.

돌아가는 서비스가 많을수록 헷갈리지 않도록 디렉토리 구조를 통일하면 도움이 됩니다. 애플리케이션 코드에서도 가급적 설정 파일 등의 위치나 파일명을 강제하지 말고 환경 변수에서 읽도록 하는 편이 좋습니다.

파이썬 패키지: virtualenv에 직접 설치

시스템에서 제공하는 바이너리 패키지를 설치할 수도 있지만 별로 권장하지 않습니다. 일단 virtualenv로 격리할 수도 없고, 최신 버전이 아닐 가능성이 매우 높고, 언제 업데이트가 될지도 알 수 없습니다. 다만 배포 대상 서버가 많다면 빌드 시간이 낭비되니까 직접 패키징하는 것을 고려해봐야 합니다.

추가로, 의존성을 setup.py로 관리할지 아니면 requirements.txt를 사용할지도 선택해야 하는데요. 이 쪽은 특별히 선호하는 방식이 있지는 않습니다. 하지만 많은 경우에 requirements.txt로만 관리해도 충분합니다. 어쨌든 관리가 되고 있다는 것이 중요합니다.

정적 리소스 배포

캐시 관리 전략: URL 기반

리소스가 갱신되었을 때 브라우저의 캐시를 어떻게 무효화 할 것인지 고려해봐야 합니다. 가장 확실한 방법은 리소스가 바뀔 때마다 URL을 바꾸는 것입니다. 아예 파일명을 바꾸거나 주소 뒤에 쿼리 문자열을 붙여서 (?v=3처럼) URL이 바뀌도록 해줍니다. 이런 일을 손으로 하면 실수할 가능성이 높으므로 가급적 자동으로 처리할 수 있는 시스템을 만드는 것이 좋습니다. 이렇게 하면 Expiry 헤더를 매우 길게 잡아 서버에 전혀 요청이 들어오지 않게 만들 수도 있어 성능상 이득이 있습니다.

내려주는 곳: 예산에 따라

서버가 한국에 있고, 접속자도 모두 한국 거주자라면 그냥 애플리케이션과 같은 서버에서 내려주면 되므로 특별히 고려할 것이 없습니다. 하지만 서버와 접속자의 위치가 다르고 충분한 예산이 있다면 CDN을 사용하는 것이 좋습니다.

CDN을 사용할 경우 S3와 같은 스토리지 서비스에 파일을 올릴지, 아니면 일반 웹 서버를 사용할지 선택해야 합니다. 스토리지 서비스를 사용할 경우에는 애플리케이션 배포 시 추가적인 리소스 배포 과정이 필요하지만 훨씬 안정적이라는 장점이 있습니다.

전처리: 상황에 따라

최근 웹 프론트엔드 개발에서는 SASS나 CoffeeScript처럼 전처리 과정이 필요한 언어를 사용하는 추세입니다. 크게 세 가지 방법이 있습니다.

  1. 배포하기 전에 미리 빌드: 배포할 서버 수가 많을 때 유리합니다. 혼자 개발하는 서비스라면 아예 Git 저장소에 같이 커밋할 수도 있습니다. 또한 스토리지 서비스를 통해 리소스를 내려줄 경우, 어차피 업로드하는 과정이 필요하므로 업로드하기 전에 해주면 됩니다.
  2. 서비스하는 서버에서 미리 오프라인 빌드: Git 훅 스크립트로 지정해두면 편하게 할 수 있습니다. 또한 애플리케이션 서버와 무관하므로 안전하다는 장점이 있습니다.
  3. 애플리케이션 서버에서 온라인 빌드: webassets 같은 라이브러리를 사용하여 애플리케이션 서버가 관리하도록 합니다. 서버 구동이 느려지거나 서비스에 영향을 미칠 수 있으므로 추천하는 방법은 아닙니다.

오류 추적: Sentry

실제 서비스를 시작하면 테스트할 때는 발견하지 못했던 예외가 발생할 수 있습니다. Sentry를 통해 오류를 수집하면 많은 도움이 됩니다. 또한 로거와 연동해서 치명적인 오류는 아니지만 예외적인 상황에 로그를 남기게 하면 비상시에 대처할 수 있어 좋습니다.

더 생각해볼 것들

  • 배포 자동화: Fabric으로 원격 배포를 자동화합니다.
  • 서버 설정 자동화: Chef, Puppet이나 Ansible 등을 사용해서 서버 세팅 과정을 형상 관리/자동화합니다.
  • 성능 모니터링: New Relic이 굉장히 좋지만, 가격이 만만치 않아서 아직 적절한 대안을 찾지 못했습니다.
  • 서버 접속 권한과 민감한 정보 관리
  • 업로드 된 파일 관리
  • 데이터베이스 / 메모리 캐시
  • 백그라운드 워커 (Celery) / 메시지 큐

· 약 7분

오래 돌아야 하는 서버 또는 워커를 어떻게 관리하고 계신가요? 설마 이렇게 하고 계신가요?

  • screen이나 tmux 안에 띄워놓고 잊어버리기
  • nohup으로 실행해두고 잊어버리기
  • 프로세스가 꺼졌는지 한참동안 모르고 있다가 당황하기
  • 시스템 재부팅 될 때마다 헬을 만나기

우분투에서 기본으로 제공되는 Upstart를 사용하면,

  • 시스템 부팅 시에 서비스 띄우기
  • 다른 서비스가 시작된 후에 서비스 띄우기
  • 프로세스가 오류로 꺼지면 자동으로 다시 띄우기
  • stdout/stderr를 로그 파일에 기록하기
  • 로그 파일이 커지면 쪼개기

와 같은 기능을 어렵지 않게 사용할 수 있습니다.

설정 파일 설치하기

Upstart 서비스 설정 파일은 /etc/init/에 모여있습니다. 따라서 /etc/init/ 디렉토리에 서비스명.conf 파일을 만들어 넣으면 됩니다.

심볼릭 링크로 설치하기

/etc/init/에는 시스템 서비스의 설정 파일도 모두 들어있기 때문에, 조금 더 관리를 편하게 하려면 별도의 디렉토리에 서비스 설정 파일을 모아두는 것도 좋은 선택입니다. 그러려면 /etc/init에 심볼릭 링크를 걸어야 합니다.

sudo ln -s /home/ubuntu/something.conf /etc/init/

주의! /etc/init/에 직접 들어있지 않고 심볼릭 링크로 들어있는 파일이 수정될 때는 Upstart가 변화를 감지하지 못합니다. 따라서 다음 명령어로 설정 파일을 다시 불러오게 해야 합니다.

sudo initctl reload-configuration

서비스 관리

설정 파일 작성법을 알아보기 전에 서비스 관리하는 방법을 먼저 알아둡시다.

  • 시작: sudo start 서비스명
  • 중단: sudo stop 서비스명
  • 재시작: sudo restart 서비스명 (주의: 서비스 설정 파일을 다시 읽어오지 않습니다. 설정 파일이 바뀌었으면 stop 후 start할 것)
  • 점잖은 재시작: sudo reload 서비스명 (정확히는, 프로세스에 HUP 시그널을 보냅니다)

설정 파일 작성하기

명령어 지정

가장 간단하게는 exec 뒤에 명령어를 쓰면 됩니다.

exec uname -a

좀 더 긴 쉘 스크립트가 필요할 경우 script 구문을 사용합니다.

script
sleep 5
uname -a
end script

어떤 명령어들은 실행하면 자동으로 백그라운드로 들어가는(detach/daemonize) 경우가 있습니다. 이를 방지하는 옵션이 있다면 켜는 것이 좋습니다. (보통 --foreground--nodetach)

포어그라운드 모드로 실행하는 옵션을 지원하지 않는 경우 Upstart 문서를 참고해서 설정하시기 바랍니다.

자동 시작/중단 조건

서비스를 특정 조건이 만족되었을 때 시작되거나 중단되게 할 수 있습니다.

가장 많이 사용하게 될 설정은 부팅 시 시작, 시스템 종료 시 중단이겠죠? 다음과 같이 적으면 됩니다.

start on runlevel [2345]
stop on runlevel [016]

또는 다른 서비스가 시작된 이후에 띄우고 싶을 수도 있습니다. A 서비스가 시작된 후를 조건으로 지정하려면,

start on started A

and 연산자로 여러 조건을 조합할 수도 있습니다.

start on started mysql and started nginx

mysql과 nginx 서비스가 시작된 뒤가 시작 조건이 됩니다.

실행 권한

프로세스는 기본적으로 root 권한을 가지고 실행됩니다. 보안을 위해 별도의 사용자/그룹 권한을 주고 실행하는 것을 권장합니다.

setuid username
setgid groupname

실행 환경 설정

  • 환경 변수 설정: env KEY=value (환경 변수는 exec/script 구문 안에서 $KEY로 참조할 수 있습니다)
  • 디렉토리 변경: chdir /path/to/current/dir

자동 재시작(respawn)

한 줄만 추가하면 프로세스가 예기치 않게 종료됐을 때 (종료 코드가 0이 아닐 때) 자동으로 다시 실행됩니다.

respawn

프로세스가 너무 빨리 되살아나는 것을 방지하기 위해 5초 동안 10번 재시작되면 더이상 재시작하지 않습니다. 이 제한은 respawn limit으로 바꿀 수 있습니다.

# 주의: respawn limit 구문이 있더라도 respawn 구문이 없으면 자동 재시작이 되지 않습니다
respawn
respawn limit COUNT INTERVAL # INTERVAL초 동안 COUNT번 재시작되면 포기합니다.
respawn limit unlimited # 제한을 없앱니다.

로그 파일

stdout/stderr로 출력된 내용은 /var/log/upstart/서비스명.log에 저장됩니다.

우분투 (14.04 기준)에서는 이 로그 파일이 하루에 한번 분할되고 최대 7개의 파일이 유지되는 것이 기본 설정입니다. /etc/logrotate.d/upstart에서 설정을 바꿀 수 있습니다.

설정 파일 예제

gunicorn으로 파이썬 웹 애플리케이션을 실행하는 예제입니다.

대안

  • 우분투 차기 (또는 차차기) 버전에서 Upstart가 systemd로 대체될 예정이라고 합니다. 데비안에서는 이미 systemd가 기본입니다.
  • 시스템과 독립적으로 작동하면서 Upstart 같은 기능을 제공하는 Supervisor도 많이 사용됩니다.

· 약 14분

업데이트: React 0.13에 맞춰 수정했습니다. (2015년 5월 14일)

React는 Facebook에서 만든 자바스크립트 UI 라이브러리입니다. 간단한 jQuery 코드를 React 앱으로 조금씩 바꿔가면서 React에 대한 이해를 돕는 것이 이 글의 목표입니다. 맛보기 정도로 생각해 주시기 바랍니다. Step by step from jQuery to Backbone에서 아이디어를 차용했습니다.

시작하기에 앞서 예제로 사용할 마크업 및 jQuery 코드를 살펴봅시다.

<body>
<div class="new-status">
<h2>New monolog</h2>
<form action="">
<textarea></textarea><br>
<input type="submit" value="Post">
</form>
</div>

<div class="statuses">
<h2>Monologs</h2>
<ul></ul>
</div>

<script src="http://code.jquery.com/jquery-1.11.0.min.js"></script>
<script>
$(document).ready(function() {
$('.new-status form').submit(function(e) {
e.preventDefault();

$.ajax({
url: '/status',
type: 'POST',
dataType: 'json',
data: { text: $('.new-status').find('textarea').val() },
success: function(data) {
$('.statuses').append('<li>' + data.text + '</li>');
$('.new-status').find('textarea').val('');
}
});
});
});
</script>
</body>

다음과 같은 기능을 구현한 꽤나 평범한 코드입니다.

  • 글 내용을 입력할 수 있는 칸이 있습니다.
  • 버튼을 누르면 입력한 내용을 서버에 보냅니다.
  • 요청이 성공하면 목록에 추가한 글을 덧붙이고 입력창의 내용을 비웁니다.

첫 걸음

React 라이브러리 파일을 불러오고, 마크업을 React에서 사용하는 일종의 템플릿 언어인 JSX 형식으로 바꿉니다.

<body>
<script src="http://code.jquery.com/jquery-1.11.0.min.js"></script>
<script src="http://fb.me/react-0.13.3.js"></script>
<script src="http://fb.me/JSXTransformer-0.13.3.js"></script>
<script type="text/jsx">
React.render(
<div className="app">
<div className="new-status">
<h2>New monolog</h2>
<form action="">
<textarea /><br />
<input type="submit" value="Post" />
</form>
</div>

<div className="statuses">
<h2>Monologs</h2>
<ul></ul>
</div>
</div>
, document.body);

$(document).ready(function() {
// 아까 코드 그대로
});
</script>
</body>

JSX를 사용하면 자바스크립트 코드와 HTML 코드를 섞을 수 있게 됩니다. JSX 코드는 자바스크립트 코드로 변환됩니다. 예를 들어 <a href="http://example.com/"><b>Link</b></a>React.createElement('a', {href: "http://example.com/"}, React.createElement('b', null, "Link"))로 바뀝니다.

React와 함께 포함시킨 JSXTransformer가 이러한 변환 작업을 해줍니다. 자동으로 type="text/jsx"<script> 태그를 찾아서 자바스크립트 코드로 컴파일됩니다.

JSX는 HTML과 비슷하긴 하지만 약간 다릅니다. 다소 헷갈릴 수도 있지만 몇가지 사항만 주의하면 됩니다. 처음의 HTML 마크업과 달라진 부분을 살펴봅시다.

  • 한번 열린 태그는 반드시 닫아야 합니다: <br><input><br />, <input />으로 바뀌었습니다.
  • 일부 속성의 이름이 다릅니다: classclassName으로 바뀌었습니다. (전체 목록)
  • 최상위 노드가 필요합니다: 따라서 <div className="app">으로 한번 감쌌습니다.

React.render 함수는 첫번째 인자로 받은 JSX 코드를 두번째 인자의 DOM 노드에 출력해줍니다. 여기서는 document.body, 즉 <body> 태그에 마크업을 출력했습니다.

컴포넌트

React가 제공하는 가장 중요한 기능은 컴포넌트입니다. 컴포넌트를 통해 UI 요소를 추상화하고 조립할 수 있게 됩니다. 말로 설명하면 어려우니 코드를 보겠습니다.

var NewStatus = React.createClass({
render: function() {
return <div className="new-status">
<h2>New monolog</h2>
<form action="">
<textarea /><br />
<input type="submit" value="Post" />
</form>
</div>;
}
});

var App = React.createClass({
render: function() {
return <div className="app">
<NewStatus />
<div className="statuses">
<h2>Monologs</h2>
<ul></ul>
</div>
</div>;
}
});

React.render(<App />, document.body);

$(document).ready(function() {
// 아까 코드 그대로
});

앱 전체를 App 컴포넌트에 집어넣고, 글 입력 폼을 NewStatus 컴포넌트로 분리했습니다. React.createClass 함수로 컴포넌트를 선언하고, render 메소드에서 JSX 코드를 리턴하는 것을 알 수 있습니다.

DOM ready 이벤트 없애기

이제는 jQuery를 쓰던 부분을 하나씩 없애보겠습니다. 먼저 $(document).ready(...)를 없애볼까요?

var NewStatus = React.createClass({
render: function() {
// 아까 코드 그대로
},
componentDidMount: function() {
$('.new-status form').submit(function(e) {
e.preventDefault();

$.ajax({
url: '/status',
type: 'POST',
dataType: 'json',
data: { text: $('.new-status').find('textarea').val() },
success: function(data) {
$('.statuses').append('<li>' + data.text + '</li>');
$('.new-status').find('textarea').val('');
}
});
});
}
});

...

//$(document).ready(function() {
// ...
//});

NewStatus 컴포넌트의 componentDidMount 메소드로 DOM ready 이벤트 핸들러의 내용을 옮겼습니다. componentDidMount 메소드는 DOM 노드가 실제로 문서에 추가된 이후에 호출되므로, 이전 코드와 같은 동작입니다.

submit 이벤트 핸들러

JSX에서는 속성에 문자열 대신 JS 표현식을 바로 넣을 수 있습니다. 이를 이용하면 이벤트 핸들러 함수를 <form> 태그에 바로 연결할 수 있습니다.

var NewStatus = React.createClass({
render: function() {
return <div className="new-status">
<h2>New monolog</h2>
- <form action="">
+ <form action="" onSubmit={this.handleSubmit}>
<textarea /><br />
<input type="submit" value="Post" />
</form>
</div>;
},
- componentDidMount: function() {
- $('.new-status form').submit(function(e) {
+ handleSubmit: function(e) {
e.preventDefault();

$.ajax({
url: '/status',
type: 'POST',
dataType: 'json',
data: { text: $('.new-status').find('textarea').val() },
success: function(data) {
$('.statuses').append('<li>' + data.text + '</li>');
$('.new-status').find('textarea').val('');
}
});
- });
}
});

보시다시피 JS 표현식을 속성으로 넣을 때는 따옴표 대신 중괄호를 사용합니다. 덕분에 componentDidMount가 필요 없어졌네요.

DOM 쿼리 없애기

JSX 태그에 ref 속성을 추가하면 해당 컴포넌트 안에서 그 노드에 바로 접근할 수 있습니다.

var NewStatus = React.createClass({
render: function() {
return <div className="new-status">
<h2>New monolog</h2>
<form action="" onSubmit={this.handleSubmit}>
- <textarea /><br />
+ <textarea ref="text" /><br />
<input type="submit" value="Post" />
</form>
</div>;
},
handleSubmit: function(e) {
e.preventDefault();

+ var $text = $(React.findDOMNode(this.refs.text));
$.ajax({
url: '/status',
type: 'POST',
dataType: 'json',
- data: { text: $('.new-status').find('textarea').val() },
+ data: { text: $text.val() },
success: function(data) {
$('.statuses').append('<li>' + data.text + '</li>');
- $('.new-status').find('textarea').val('');
+ $text.val('');
}
});
}
});

React.findDOMNode 함수는 왜 필요한 것일까요? 사실은 <textarea> 같은 JSX 태그도 React 컴포넌트이기 때문입니다. 따라서 컴포넌트가 실제로 사용하는 DOM 노드에 접근하기 위해 별도의 함수를 호출해야 합니다.

컴포넌트 간 통신

이제 $('.statuses')를 없애야 하는데, ref를 사용하고 싶어도 그것이 다른 컴포넌트에 속해 있어서 쓸 수가 없네요. 어떻게 하면 App 컴포넌트에 접근할 수 있을까요?

var NewStatus = React.createClass({
render: function() {
// 그대로
},
handleSubmit: function(e) {
e.preventDefault();

+ var self = this;
var $text = $(React.findDOMNode(this.refs.text));
$.ajax({
url: '/status',
type: 'POST',
dataType: 'json',
data: { text: $text.val() },
success: function(data) {
- $('.statuses').append('<li>' + data.text + '</li>');
+ self.props.onCreate(data);
$text.val('');
}
});
}
});

var App = React.createClass({
render: function() {
return <div className="app">
- <NewStatus />
+ <NewStatus onCreate={this.handleCreate} />
<div className="statuses">
<h2>Monologs</h2>
- <ul></ul>
+ <ul ref="items"></ul>
</div>
</div>;
- }
+ },
+ handleCreate: function(data) {
+ $(React.findDOMNode(this.refs.items)).append('<li>' + data.text + '</li>');
+ }
});

React에서는 자식 컴포넌트가 부모 컴포넌트에 바로 접근할 수 없습니다. 따라서 데이터가 항상 부모에서 자식 방향으로만 전달됩니다. 부모 컴포넌트가 자식 컴포넌트에 데이터를 넘겨줄 때는 prop을 사용합니다. JSX 태그의 속성을 모두 this.props에서 참조할 수 있습니다.

여기서는 onCreate라는 속성으로 handleCreate 메소드를 넘겨주었고, NewStatus 컴포넌트에서 this.props.onCreate로 접근합니다. (JS에서 this는 스코프를 따르지 않으므로 self라는 변수에 임시로 넣었습니다.)

이제 handleCreate 함수가 App 컴포넌트에 속해있으므로 ref를 만들어서 <ul> 요소에 접근할 수 있게 되었습니다.

append 없애기

이제 리스트에 새로운 항목을 추가하는 코드를 React로 대체해보겠습니다. 기존 코드의 또 한가지 문제는 HTML을 한땀한땀 문자열로 만들고 있다는 것입니다. 만약 내용에 HTML이 들어간다면 XSS 취약점이 발생할 겁니다. JSX는 자동으로 HTML escape를 해주는데, 이 코드를 JSX로 바꿀 수는 없을까요?

var App = React.createClass({
+ getInitialState: function() {
+ return {items: []};
+ },
render: function() {
return <div className="app">
<NewStatus onCreate={this.handleCreate} />
<div className="statuses">
<h2>Monologs</h2>
- <ul ref="items"></ul>
+ <ul>{this.state.items}</ul>
</div>
</div>;
},
handleCreate: function(data) {
- $(React.findDOMNode(this.refs.items)).append('<li>' + data.text + '</li>');
+ this.setState({
+ items: this.state.items.concat(<li>{data.text}</li>)
+ });
}
});

React에서는 DOM을 직접 수정하는 API를 제공하지 않습니다. 그 대신 새로운 데이터를 가지고 컴포넌트를 완전히 새로 렌더링할 수는 있습니다. 만약 새로운 항목을 추가하기 전에 있던 기존 항목들을 모두 저장하고 있다면, 새로 렌더링할 때 그 데이터를 다시 사용할 수 있을 겁니다.

위의 코드에서는 기존 항목을 기억하기 위해 state를 사용했습니다. getInitialState에서 초기값을 설정할 수 있고, 다른 메소드에서는 this.state에 접근하여 값을 읽을 수 있습니다. 또한, setState 메소드로 값을 수정하면 컴포넌트가 다시 렌더링됩니다.

또 한 가지 새로운 개념은 자바스크립트 표현식을 JSX 태그의 내용에 집어넣은 것입니다. 문자열이나 배열, 또는 JSX 코드를 중괄호로 감싸 넣으면 그 자리에 들어갑니다.

state가 바뀔 때마다 매번 렌더링을 하면 느려지지 않을까요? 물론 직접 DOM을 수정하는 것만큼 빠르지는 않겠지만, React가 기존에 렌더링 된 내용과 새로 렌더링 될 내용을 비교하여 바뀐 부분만 렌더링하기 때문에 상당히 빠릅니다. 또한 차이점만 반영되기 때문에, App 컴포넌트가 새로 렌더링 되어도 NewStatus 컴포넌트에 들어있는 <textarea>의 내용이나 선택 상태가 사라지지 않고 유지됩니다.

데이터와 표현 분리

지금은 this.state.items에 JSX 코드를 바로 집어넣었지만, 이렇게 하면 나중에 다른 마크업을 사용하고 싶을 때 문제가 생길 것입니다. 예를 들어 마지막으로 추가된 항목만 다른 스타일로 보여주고 싶다면 어떻게 해야 할까요.

따라서 state에는 순수한 데이터만 넣고, JSX는 모두 render 메소드 안에서 생성하도록 수정합시다.

var App = React.createClass({
getInitialState: function() {
return {items: []};
},
render: function() {
return <div className="app">
<NewStatus onCreate={this.handleCreate} />
<div className="statuses">
<h2>Monologs</h2>
- <ul>{this.state.items}</ul>
+ <ul>{this.state.items.map(function(item) {
+ return <li>{item.text}</li>;
+ })}</ul>
</div>
</div>;
},
handleCreate: function(data) {
this.setState({
- items: this.state.items.concat(<li>{data.text}</li>)
+ items: this.state.items.concat(data)
});
}
});

최종 코드

아직 jQuery를 사용하는 부분이 조금 남아있기는 하지만 거의 대부분의 코드를 React로 전환했습니다.

더 알아보기

설명을 위해 단순한 예제를 사용했기 때문에 React 코드가 원래 코드보다 어려워 보일 수도 있습니다. 그러나 조금 더 복잡한 애플리케이션을 만든다면 React를 사용할 때 훨씬 관리하기 쉬운 코드를 작성할 수 있습니다.

또한 다양한 개념을 다루려고 굳이 거쳐갈 필요 없는 과정을 일부러 넣거나 복잡한 설명을 생략한 부분이 있습니다. React를 제대로 공부하시려면 공식 사이트의 문서를 읽으시길 바랍니다.

2015-05-14 추가: React 한국 커뮤니티에서 번역한 한국어 문서도 있습니다.