본문으로 건너뛰기

· 약 32분

(이 글은 Relay 문서 중 Thinking in GraphQL 장을 번역한 것입니다. 245df1e0 리비전을 기준으로 하고 있으며, 저장소의 라이센스를 따라 BSD 라이센스입니다. 원작자의 허락으로 별도로 받지는 않았음을 밝힙니다.)

GraphQL은 클라이언트에서 데이터를 가져오는 새로운 방식을 제시합니다. 제품 개발자와 클라이언트 애플리케이션의 요구사항에 중점을 둔 방식입니다. 뷰에서 정확히 어떤 데이터가 필요한지 명시할 수 있는 방법을 제공하며, 클라이언트는 필요한 데이터를 단 한번의 네트워크 요청으로 가져올 수 있습니다. GraphQL을 사용하면 REST 등 기존 접근 방식에 비해 애플리케이션이 데이터를 더욱 효율적으로(리소스 중심 REST 방식 대비) 가져올 수 있고 (커스텀 엔드포인트를 만들 때 발생할 수 있는) 서버 로직의 중복을 줄일 수 있습니다. 그에 더해 GraphQL은 제품 코드와 서버 로직의 결합도를 낮추는 데 도움을 줍니다. 예를 들어 제품에서 필요한 정보가 많아지거나 적어질 때 서버의 관련 엔드포인트를 모두 수정하지 않아도 됩니다. It's a great way to fetch data.

이 글에서는 GraphQL 클라이언트 프레임워크를 만든다는 것의 의미를 알아보고, 그것이 기존의 REST 시스템을 위한 클라이언트와 어떻게 다른지 비교해볼 것입니다. 그 과정에서 Relay의 설계 결정사항에 어떤 배경이 있는지 살펴보며 Relay가 단지 GraphQL 클라이언트일 뿐만 아니라 선언적으로 데이터를 가져올 수 있는 프레임워크임을 보여드리겠습니다. 자, 이제 데이터를 가져오기 시작해볼까요!

데이터 가져오기

스토리의 목록과 각 스토리의 상세 정보를 가져오는 간단한 애플리케이션을 생각해봅시다. 리소스 중심 REST 방식에서는 다음과 같을 것입니다.

// 스토리 ID 목록을 가져온다. 단, 상세 정보는 가져오지 않음.
rest.get('/stories').then(stories =>
// 결과값은 연결된 리소스의 목록
// `[ { href: "http://.../story/1" }, ... ]`
Promise.all(stories.map(story =>
rest.get(story.href) // 링크 따라가기
))
).then(stories => {
// 결과값은 스토리의 목록
// `[ { id: "...", text: "..." } ]`
console.log(stories);
});

이렇게 하면 서버에 n+1개의 요청을 보내야 합니다. 리스트를 가져올 때 1번, 각 항목을 가져올 때 n번. GraphQL에서는 같은 데이터를 단 하나의 네트워크 요청으로 가져올 수 있습니다. (별도의 엔드포인트를 만들지 않고 가능합니다. 만약 만든다면 계속 관리해야겠지요)

graphql.get('query { stories { id, text } }').then(
stories => {
// 스토리의 목록
// `[ { id: "...", text: "..." } ]`
console.log(stories);
}
);

여기까지는 GraphQL을 그저 일반적인 REST 방식의 효율적인 버전으로만 사용했습니다. GraphQL 버전의 중요한 장점 두가지를 알 수 있습니다.

  • 모든 데이터를 한번의 라운드트립으로 가져올 수 있습니다.
  • 클라이언트와 서버가 서로 독립적입니다. 서버 엔드포인트가 정확한 데이터를 돌려주는 것에 의존하는 대신 클라이언트가 필요한 데이터를 명시합니다.

간단한 애플리케이션에서는 이미 이것만으로도 큰 개선일 것입니다.

클라이언트측 캐싱

서버로부터 같은 정보를 반복해서 읽어오면 느릴 수 있습니다. 예를 들어 스토리 목록에서 하나의 항목으로 갔다가 다시 목록으로 돌아온다고 하면, 전체 목록을 다시 가져와야 합니다. 일반적으로 쓰이는 방법인 캐싱으로 이러한 문제를 해결해 보겠습니다.

리소스 중심 REST 시스템에서는 URI에 기반한 응답 캐시를 관리할 수 있습니다.

var _cache = new Map();
rest.get = uri => {
if (!_cache.has(uri)) {
_cache.set(uri, fetch(uri));
}
return _cache.get(uri);
};

응답 캐싱 방식은 GraphQL에도 적용됩니다. 기초적인 방법은 REST 버전과 비슷하게 작동할 것입니다. 쿼리 문자열 자체를 캐시 키로 사용할 수 있습니다.

var _cache = new Map();
graphql.get = queryText => {
if (!_cache.has(queryText)) {
_cache.set(queryText, fetchGraphQL(queryText));
}
return _cache.get(queryText);
};

이제 이전에 캐시된 데이터를 요청하면 네트워크 요청 없이도 바로 답을 받을 수 있습니다. 이는 애플리케이션의 체감 성능을 향상시키는 실용적인 접근법입니다. 그러나 이러한 방식은 데이터 일관성 문제를 일으킬 수 있습니다.

캐시의 일관성

GraphQL에서는 여러 쿼리의 결과가 겹치는 경우가 자주 있습니다. 그런데 위에서 사용한 응답 캐시는 이렇게 결과가 겹치는 성질을 고려하지 않고 개별 쿼리에 대해서만 캐싱합니다. 예를 들어 다음과 같이 스토리를 가져오는 쿼리를 날리고,

query { stories { id, text, likeCount } }

가져온 스토리 중 하나의 likeCount가 증가한 이후에 다시 가져오면

query { story(id: "123") { id, text, likeCount } }

이제 스토리가 어떤 방법으로 접근되는지에 따라 다른 likeCount를 보게 될 것입니다. 첫번째 쿼리를 사용하는 뷰는 오래된 카운트를 사용하는데 두번째 쿼리는 업데이트된 카운트를 표시합니다.

그래프를 캐시하기

GraphQL에 적합한 캐시 방식은 계층 구조의 응답을 단일 계층의 레코드 집합으로 정규화하는 것입니다. Relay에서는 ID에 대응되는 레코드의 map 자료구조로 이러한 방식의 캐시를 구현하였습니다. 각 레코드는 필드명에 대응되는 필드 값의 map입니다. 레코드는 다른 레코드와 연결될 수 있고 링크는 최상위 계층의 map을 참조하는 특별한 타입의 값으로 저장됩니다. (따라서 순환 그래프도 표현할 수 있습니다.) 이렇게 하여 각 서버상의 레코드는 가져오는 방법에 무관하게 한번만 저장됩니다.

다음은 스토리의 글과 작성자 이름을 가져오는 예제 쿼리입니다.

query {
story(id: "1") {
text,
author {
name
}
}
}

그리고 다음과 같은 응답을 받을 수 있습니다.

query: {
story: {
text: "Relay is open-source!",
author: {
name: "Jan"
}
}
}

응답은 계층 구조로 되어있지만, 모든 레코드를 단일 계층으로 만들어 캐시할 것입니다. 다음은 Relay가 이 응답을 캐시하는 예입니다.

Map {
// `story(id: "1")`
1: Map {
text: 'Relay is open-source!',
author: Link(2),
},
// `story.author`
2: Map {
name: 'Jan',
},
};

이것은 단순화한 예제에 불과합니다. 실제로는 일대다 관계와 페이지 관리 등도 캐시에서 처리할 수 있어야 합니다.

캐시 사용하기

그러면 이제 이 캐시를 어떻게 사용할 수 있을까요? 응답을 받았을 때 캐시에 쓰는 것과, 캐시를 읽어 쿼리가 로컬에서 완전히 처리될 수 있는지 판단하는 두가지 작업을 살펴봅시다. (위에서 사용한 _cache.has(key)와 같지만 그래프를 위한 것입니다)

캐시 추가하기

캐시를 추가하려면 계층 구조의 GraphQL 응답을 순회하면서 정규화된 캐시 레코드를 만들거나 업데이트하게 됩니다. 처음엔 응답 그 자체만 있으면 충분히 응답을 처리할 수 있을 것처럼 보이지만 실제로는 단순한 쿼리에서만 가능합니다. user(id: "456") { photo(size: 32) { uri } }라는 쿼리를 생각해보면, photo를 어떻게 저장해야 할까요? photo를 캐시의 필드명으로 사용하면 다른 쿼리에서 같은 필드를 다른 인자값으로 가져올 수도 있으므로 (예: photo(size: 64) {...}) 안됩니다. 비슷한 문제가 페이지를 나누는 경우에도 발생합니다. 만약 11번째부터 20번째까지의 스토리를 stories(first: 10, offset: 10)로 가져온다면 새로운 결과는 기존 목록에 덧붙여져야 합니다.

따라서, GraphQL을 위한 정규화된 응답 캐시는 응답 페이로드와 쿼리를 동시에 처리해야만 합니다. 예를 들어 위에서 예로 든 photo 필드는 photo_size(32)처럼 자동 생성된 필드명으로 캐시하면 필드와 인자값을 유일하게 구별할 수 있을 것입니다.

캐시에서 읽기

캐시에서 읽어오려면 쿼리를 순회하면서 각 필드를 처리해야 합니다. 잠깐, 이거 GraphQL이 하는 일과 정확히 같은거 아닌가요? 그렇습니다! 캐시에서 읽어오는 작업은 실행기의 특수한 케이스입니다. 첫째, 모든 결과값이 고정된 자료 구조에서 오므로 사용자 정의 필드 함수가 필요하지 않습니다. 둘째, 결과값은 항상 동기적입니다 — 캐시된 데이터가 존재하거나, 또는 없거나.

Relay에는 조금씩 다른 여러 종류의 쿼리 순회(traversal)가 구현되어 있습니다. 캐시나 응답 페이로드 등 다른 데이터와 동시에 쿼리를 순회하는 작업입니다. 예를 들어 쿼리할 때는 "diff" 순회를 돌면서 어떤 필드가 없는지 찾습니다. (React에서 가상 DOM 트리를 비교하는 것과 비슷합니다.) 이렇게 하면 대부분의 경우 가져올 데이터의 양을 줄일 수 있고 쿼리 전체가 캐시되어 있을 때는 네트워크 요청을 아예 하지 않아도 됩니다.

캐시 업데이트

이렇게 정규화된 캐시 구조는 겹치는 결과를 중복 없이 캐시할 수 있게 해줍니다. 각 레코드는 가져온 방법에 관계 없이 한번만 저장됩니다. 앞의 일관성 없는 데이터 예제로 돌아가서 이 캐시가 그러한 시나리오에서 어떤 도움을 주는지 살펴봅시다.

첫번째 쿼리는 스토리의 목록이었습니다.

query { stories { id, text, likeCount } }

정규화된 응답 캐시에서는, 목록의 각 스토리에 대한 레코드가 만들어지고 stories 필드는 각 레코드에 대한 링크를 담을 것입니다.

위의 스토리 중 하나의 정보를 다시 가져오는 두번째 쿼리는 다음과 같았습니다.

query { story(id: "123") { id, text, likeCount } }

이 쿼리의 응답이 정규화될 때, Relay는 id를 가지고 결과값이 기존 데이터와 겹친다는 것을 알아낼 수 있습니다. 새로운 레코드를 만드는 대신 기존의 123 레코드가 업데이트될 것입니다. 따라서 새로운 likeCount는 두 쿼리 모두와 이 스토리를 참조하는 다른 모든 쿼리에 적용됩니다.

데이터-뷰 일관성

정규화된 캐시는 캐시의 일관성을 보장합니다. 그렇다면 뷰에 대해서는 어떨까요? React 뷰가 항상 캐시의 현재 정보를 반영하는 것이 이상적일겁니다.

스토리의 글과 댓글을 작성자 이름 및 사진과 함께 표시하는 경우를 생각해봅시다. GraphQL 쿼리는 다음과 같습니다.

query {
node(id: "1") {
text,
author { name, photo },
comments {
text,
author { name, photo }
}
}
}

처음 이 스토리를 가져오면 캐시는 다음과 같이 됩니다. 스토리와 댓글이 같은 author 레코드에 연결되어 있는 것을 확인할 수 있습니다.

// 참고: 구조를 명확하게 알 수 있도록 `Map`을 초기화할 때 슈도코드를 사용함
Map {
// `story(id: "1")`
1: Map {
author: Link(2),
comments: [Link(3)],
},
// `story.author`
2: Map {
name: 'Yuzhi',
photo: 'http://.../photo1.jpg',
},
// `story.comments[0]`
3: Map {
author: Link(2),
},
}

이 스토리의 작성자가 같은 스토리에 댓글도 달았습니다. 흔한 일이죠. 이제 다른 뷰가 작성자에 대한 새로운 정보를 가져와서 프로필 사진이 새로운 URI로 바뀌는 경우를 생각해보겠습니다. 다음 부분이 유일하게 캐시된 데이터에서 바뀔 것입니다.

Map {
...
2: Map {
...
photo: 'http://.../photo2.jpg',
},
}

photo 필드의 값이 변경됐으므로 레코드 2 또한 바뀌었습니다. 그리고 그게 전부입니다. 캐시의 다른 부분은 영향을 받지 않았습니다. 하지만 분명히 새로운 사진을 보여주려면 는 작성자에 대한 UI 상의 인스턴스 두 개(스토리의 작성자, 댓글의 작성자)에 변경 사항을 반영해야 합니다.

이럴 때 일반적인 반응은 "그냥 불변(immutable) 데이터 구조를 쓰면 되겠지"입니다. 하지만 그렇게 하면 무슨 일이 일어날까요?

ImmutableMap {
1: ImmutableMap {/* 이전과 같음 */}
2: ImmutableMap {
... // 다른 필드는 그대로
photo: 'http://.../photo2.jpg',
},
3: ImmutableMap {/* 이전과 같음 */}
}

2를 새로운 불변 레코드로 바꾸면 캐시 객체의 새로운 불변 인스턴스가 만들어집니다. 그러나 1, 3 레코드는 그대로입니다. 데이터가 정규화되어 있기 때문에 story 레코드 하나만 봐서는 story의 내용이 바뀌었는지 알 수 없습니다.

뷰의 일관성을 유지하기

단일 계층의 캐시에서 뷰를 최신으로 유지하는 방법은 여러가지입니다. Relay에서는 각 UI 뷰와 뷰에서 참조하고 있는 ID 간의 매핑을 관리하는 방식을 선택했습니다. 이 경우 스토리 뷰는 스토리(1), 작성자(2), 댓글들(3, ...)의 업데이트를 구독하게 됩니다. 데이터를 캐시에 쓸때 영향을 받은 ID를 추적하여 그 ID들을 구독하고 있는 뷰에 알려줍니다. 영향을 받은 뷰가 다시 렌더링되고 관계 없는 뷰는 제외되어 더 나은 성능을 냅니다. (Relay는 안전하면서도 효과적인 shouldComponentUpdate의 기본값을 제공합니다.) 이러한 전략을 사용하지 않으면 작은 변화만으로도 모든 뷰를 새로 렌더링해야 할 것입니다.

참고로 이 방법은 쓰기에도 마찬가지로 적용할 수 있습니다. 캐시가 업데이트되면 영향을 받는 뷰가 통지를 받게 되는데, 쓰기 또한 그저 캐시를 업데이트하는 일이기 때문입니다.

뮤테이션

지금까지 데이터를 쿼리하고 뷰를 최신으로 유지하는 과정을 살펴보았는데 아직 쓰기에 대해서는 이야기하지 않았습니다. GraphQL에서는 쓰기를 뮤테이션(mutation)이라고 부릅니다. 사이드 이펙트가 있는 쿼리라고 생각하시면 됩니다. 다음은 현재 유저가 주어진 스토리를 좋아요 표시하는 뮤테이션의 예입니다.

// 사람이 읽을 수 있는 이름을 붙이고 입력값의 타입을 정의
// 여기서는 좋아요 표시할 스토리의 id
mutation StoryLike($storyID: String) {
// 뮤테이션 필드를 호출하고 사이드 이펙트를 발생시킴
storyLike(storyID: $storyID) {
// 뮤테이션이 끝나고 다시 가져올 필드를 정의
likeCount
}
}

뮤테이션의 결과로 바뀔수도 있는 데이터를 쿼리하고 있습니다. 왜 서버가 그냥 무엇이 바뀌었는지 알려줄 수 없나요?라고 생각하실 수 있습니다. 그 이유는, 복잡해서 그렇습니다. GraphQL은 임의의 데이터 저장 레이어(또는 여러 소스의 모음)을 추상화하며 프로그래밍 언어 독립적입니다. 더군다나 GraphQL의 목표는 뷰를 만드는 제품 개발자에게 유용한 형태로 데이터를 제공하는 것입니다.

저희는 GraphQL 스키마가 디스크에 데이터가 저장되는 형태와 약간 차이가 있거나 심지어 완전히 다르다는 것을 알게 되었습니다. 간단히 말해, 하위 계층의 데이터 스토리지(디스크)에서 바뀌는 데이터와 제품에서 보는 스키마(GraphQL)가 항상 1:1로 대응되지는 않는다는 것입니다. 개인정보 공개 설정이 적절한 예입니다. 나이처럼 유저에게 보이는 필드 하나를 알려주기 위해서도, 데이터 저장 레이어에서는 현재 유저가 그 값을 볼 수 있는지 판단하려면 엄청나게 많은 기록을 살펴봐야 할 수 있습니다. (그 사람과 친구인지? 나이를 공유했는지? 차단한 적이 있는지 등등)

이렇듯 현실적인 제약 때문에 GraphQL에서는 클라이언트가 뮤테이션 후에 바뀔 수도 있는 것들을 쿼리하도록 하고 있습니다. 그렇다면 그 쿼리에는 무엇이 들어가야 하는가? Relay를 개발하면서 여러가지 아이디어를 시도해 보았는데 간단히 살펴보고 왜 현재 방식을 선택했는지 알아봅시다.

  • 방법 1: 지금까지 앱에서 쿼리한 모든 것을 다시 가져오기. 실제로는 전체 데이터 중 아주 일부분만 바뀌더라도 서버가 전체 쿼리를 실행하고 결과를 다운로드하고 처리가 끝날 때까지 기다려야 하므로 비효율적입니다.

  • 방법 2: 현재 렌더링된 뷰에서 필요한 쿼리만 다시 가져오기. 방법 1의 개선판입니다. 그러나 캐시되어 있지만 지금 보이지 않는 데이터는 갱신되지 않습니다. 이런 데이터를 낡은 것으로 표시하거나 캐시에서 없앨 방법이 있지 않다면 이후의 쿼리는 오래된 정보를 읽게 됩니다.

  • 방법 3: 뮤테이션 이후에 바뀔수도 있는 필드의 고정된 리스트를 다시 가져오기. 이런 리스트를 팻(fat) 쿼리라고 부릅니다. 일반적인 애플리케이션은 팻 쿼리의 일부분만 렌더링하는데, 이 방법으로는 명시된 모든 필드를 가져와야 하므로 비효율적일 수 있습니다.

  • 방법 4 (Relay): 바뀔 수도 있는 것(팻 쿼리)와 캐시에 있는 데이터의 교집합을 다시 가져오기. Relay에서는 데이터 캐시와 함께 각 항목을 가져오기 위해 사용된 쿼리도 기억하고 있습니다. 이러한 쿼리는 추적(tracked) 쿼리라고 합니다. 추적 쿼리와 팻 쿼리에서 겹치는 부분을 찾으면 애플리케이션에서 업데이트해야 하는 정보만 정확히 쿼리할 수 있습니다.

데이터 가져오기 API

이제까지 데이터 가져오기의 저수준 측면을 살펴보고 어떻게 여러가지 친숙한 개념이 GraphQL로 변환될 수 있는지 보았습니다. 다음으로 한발짝 물러나 제품 개발자들이 데이터 가져오기와 관련하여 자주 마주치는 고수준의 문제를 짚어보겠습니다.

  • 뷰 계층에서 필요한 모든 데이터 가져오기
  • 비동기 상태 전환을 관리하고 동시적인 요청을 조정
  • 에러 관리
  • 실패한 요청 재시도
  • 쿼리/뮤테이션 응답으로부터 로컬 캐시 업데이트
  • 뮤테이션을 큐에 쌓아 레이스 컨디션 방지
  • 서버의 뮤테이션 응답을 기다리는 동안 낙관적으로(optimistically) UI 업데이트

명령형(imperative) API를 사용한 일반적인 접근 방법으로는 개발자들이 이처럼 핵심과는 무관한 복잡함을 다룰 수 밖에 없습니다. 예를 들어 낙관적인 UI 업데이트를 생각해보겠습니다. 서버의 응답을 기다리는 동안 사용자에게 피드백을 줄 수 있는 방법입니다. 무엇을 해야 하는가에 대한 로직은 꽤나 분명합니다. 사용자가 "좋아요"를 누르면, 스토리를 좋아요 표시한 것으로 만들고 서버에 요청을 보내면 됩니다. 하지만 실제로 구현하는건 훨씬 복잡한 경우가 많습니다. 명령형 방식에서는 우리가 이 모든 과정을 구현해야 합니다. UI에 가서 버튼을 켜진 상태로 만들고, 네트워크 요청을 시작하고, 필요하면 재시도를 하고, 실패하면 요청을 보내고(그리고 버튼을 다시 끄고), 등등. 데이터 가져오기도 마찬가지입니다. 어떤 데이터가 필요한지를 명시하는 것이 거의 항상 어떻게 그리고 언제 그것을 가져올 지 결정합니다. 이제 Relay에서 어떻게 이런 문제를 해결하는지 살펴보겠습니다.


번역하면서 생각한 것

이 글대로라면 Relay는 현재 다음과 같은 시나리오를 처리할 수 없다. (코드를 자세히 읽어보진 않았지만 아마 실제로도 안될 것 같다.)

항상 동시에 바뀌는 필드 a, b가 있다고 할 때, 다음과 같은 첫번째 쿼리를 날린다.

query {
story(id: 1) {
id
author { id, a, b }
}
}

이 때 다른 사용자(아니면 다른 기기)가 a, b의 값을 업데이트한다.

다른 화면으로 가면서 기존에 가져왔던 레코드의 a 필드만 다시 쿼리한다.

query {
story(id: 2) {
id
author { id, a } # Story 1과 같은 작성자
}
}

그러면 이제 작성자 레코드의 캐시는 a만 최신이 되고 b는 낡은 데이터가 그대로 유지된다. 그 다음에 다시 원래 화면으로 돌아오면, diff 결과 모든 데이터가 캐시에 있으므로 새로운 쿼리를 보내지 않게 되고 ab가 서로 다른 버전이 된다.

이 경우 가장 큰 문제는 캐시를 믿을 수가 없다는 것이다. 애플리케이션 개발자는 a, b가 항상 일관성있을 것으로 기대할텐데 그런 가정이 깨지게 된다.

가장 깔끔한 해결책은 서버에서 레코드가 변경될 때마다 달라지는 '리비전' 값을 두고, 프레임워크 수준에서 항상 쿼리에 리비전 필드를 넣어주는 것이다. 만약 기존에 알던 리비전과 다른 값이 온다면 새로운 필드만 저장하고 기존의 변경되었을지도 모르는 필드는 캐시에서 제거하면 된다.

그리고 또 한가지 부족한 점은 캐시에 유효기간이 없다는 점이다. 웹 환경에서는 한 페이지를 오래 열어두는 경우가 잘 없으니 괜찮지만 네이티브 앱이라면 너무 오래된 데이터를 보여주지 않는게 좋을 것이다. 물론 Force Fetching 기능을 쓰면 캐시된 값을 잠시 보여주는 동시에 쿼리를 날리고 결과적으로는 최신 값이 나오게 할 수 있긴 하다. 다만 완전히 캐시하는 정책과 항상 최신을 보여주는 정책의 중간 정도가 있어서 나쁠 것은 없을 것 같다.

그러고보면 리비전, 유효기간 둘 다 HTTP 캐시의 시맨틱과도 유사하다는 생각이 든다. (각각 ETag, Expires)

· 약 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 한국 커뮤니티에서 번역한 한국어 문서도 있습니다.