본문으로 건너뛰기

· 약 6분

RDBMS를 쓰는 이유 중 하나는 트랜잭션입니다. 하지만 RDBMS의 트랜잭션을 너무 믿다가는 깜짝 놀랄 일이 벌어질 수도 있습니다.

국민 여러분 안심하십시오
??? : 국민 여러분 안심하십시오

문제

다음과 같이 얼핏 보면 무해해 보이는 코드가 있습니다.

# CREATE TABLE account (id integer, money integer, state text);
# INSERT INTO account (id, money, state) VALUES (1, 10, 'poor');

tx = begin()
state = tx.query("SELECT state FROM account WHERE id = 1")
if state == "poor":
tx.query("UPDATE account SET state = 'rich', money = money * 1000 WHERE id = 1")
tx.commit()

이런 코드가 동시에 다음 순서로 실행되면 어떤 일이 벌어질까요?

트랜잭션 A트랜잭션 B
BEGIN
SELECT state FROM account WHERE id = 1
BEGIN
SELECT state FROM account WHERE id = 1
UPDATE account SET state = 'rich', money = money * 1000 WHERE id = 1
COMMIT
UPDATE account SET state = 'rich', money = money * 1000 WHERE id = 1
COMMIT

money가 10,000,000이 됩니다.

왜죠?

SQL 표준에서 isolation level은 READ UNCOMMITTED, READ COMMITTED, REPEATABLE READ, SERIALIZABLE 네 가지입니다. SERIALIZABLE이 가장 높은 격리수준이지만 성능 상의 이유로 MySQL (InnoDB)은 REPEATABLE READ, PostgreSQL은 READ COMMITTED가 기본값입니다.

이러한 기본 isolation level에서 UPDATE 쿼리는 대상 레코드를 다른 트랜잭션이 먼저 업데이트한 뒤 커밋된 경우 업데이트 된 데이터를 보게 됩니다.

... a target row might have already been updated (or deleted or locked) by another concurrent transaction by the time it is found. In this case, the would-be updater will wait for the first updating transaction to commit or roll back (if it is still in progress). ... If the first updater commits, the second updater ... will attempt to apply its operation to the updated version of the row. (Postgres 문서)

The snapshot of the database state applies to SELECT statements within a transaction, not necessarily to DML statements. If you insert or modify some rows and then commit that transaction, a DELETE or UPDATE statement issued from another concurrent REPEATABLE READ transaction could affect those just-committed rows, even though the session could not query them. (MySQL 문서)

해결책

Isolation level 높이기

MySQL에서는 SERIALIZABLE 밖에 답이 없는데 이 경우에 항상 락이 걸리므로 현실적으로 사용하기 힘듭니다.

Postgres는 REPEATABLE READ로 올리면 이러한 문제가 없습니다. 대신 트랜잭션 A가 UPDATE를 시도할 때 트랜잭션이 중단되어 버리므로 애플리케이션 단에서 전체 트랜잭션을 처음부터 재시도해야 합니다.

a target row might have already been updated (or deleted or locked) by another concurrent transaction by the time it is found. In this case, the repeatable read transaction will wait for the first updating transaction to commit or roll back (if it is still in progress). ... if the first updater commits (and actually updated or deleted the row, not just locked it) then the repeatable read transaction will be rolled back with the message ERROR: could not serialize access due to concurrent update because a repeatable read transaction cannot modify or lock rows changed by other transactions after the repeatable read transaction began. (Postgres 문서)

SELECT FOR UPDATE 사용

업데이트 할 레코드를 가져올 때 SELECT 쿼리 대신 SELECT FOR UPDATE 문을 사용하면 락이 걸립니다. 그러면 트랜잭션 B가 읽기를 시도할 때 트랜잭션 A가 커밋 (또는 롤백)되기까지 기다리게 되므로 문제가 발생하지 않습니다.

UPDATE 한번에 모든 것을 처리

SELECT를 하지 말고 UPDATE account SET state = 'rich', money = money * 1000 WHERE id = 1 AND state = 'poor'와 같이 처리할 수도 있습니다. 이렇게 하면 로직이 애플리케이션 코드에서 SQL로 옮겨가기는 하지만 마지막으로 커밋된 데이터를 기준으로 작동해서 문제가 발생하지 않습니다.

낙관적(optimistic) 락

테이블에 버전 필드를 추가해서 SELECT할 때 가져옵니다. 그리고 UPDATE할 때 WHERE 절에 기존 버전을 추가하고 +1된 버전으로 업데이트를 시도합니다. 업데이트 된 레코드 수를 검사해서 0개라면 다른 트랜잭션에서 버전이 변경된 것을 알 수 있습니다. 이렇게 충돌을 감지한 경우 애플리케이션 단에서 전체 트랜잭션을 처음부터 재시도해야 할 수도 있습니다.

ORM에서 낙관적 락 기능을 제공하는 경우도 있습니다.

결론

데이터베이스에서 데이터를 읽은 다음 애플리케이션에서 처리 후 다시 쓰는 경우에 주의가 필요합니다.

· 약 15분

요즘 소프트웨어 개발자라면 자동화 된 테스트가 필요하다는 것에 대부분 동의할 것입니다. 그러나 테스트를 짜본 적이 없으면 처음엔 어떻게 해야 할지 막막하기만 합니다. 저도 그랬고, 공부하려 해도 라이브러리 사용법이나 뜬구름 잡는 소리(TDD? BDD?)는 많은데 구체적인 방법은 찾아보기 힘든 것 같습니다. 그래서 이 글에서는 RESTful API 서버로 영역을 한정하여 하나의 테스트 작성 방식을 제시해보려 합니다.

애플리케이션 아키텍쳐에 대한 가정

특정 프레임워크에 한정되는 내용은 아니므로 일반적인 MVC 아키텍쳐를 가정하고 설명할 것입니다.

  • 모델: 데이터베이스에 접근하는 모듈. 서비스/DAO처럼 추상화 되어 있는 경우도 모델로 통칭하기로 합니다.
  • 컨트롤러: HTTP 요청을 받아 다듬은 다음 모델 계층을 호출해서 요청을 수행하고 결과를 돌려주는 모듈. 뷰, 요청 핸들러, 리소스라고 부르기도 합니다.
  • UI 테스트는 다른 접근 방식이 필요하기 때문에 뷰/템플릿은 고려하지 않고 JSON만을 돌려준다고 가정합니다.
  • 단일(monolithic) 애플리케이션을 가정합니다. 여러 작은 애플리케이션끼리 통신하는 아키텍쳐(SOA/마이크로서비스)에서는 다른 전략이 필요할 것입니다.

외부 인터페이스를 통해 통합 테스트

테스트에 대한 자료를 검색해보면 단위 테스트에 대한 것이 대부분인 것 같습니다. 단위 테스트는 하나의 모듈(클래스, 함수)을 격리시켜서 테스트하는 방법입니다. 한편 통합 테스트는 여러 모듈을 결합하여 시스템이 전체적으로 잘 동작하는지 테스트하는 방법이라고 할 수 있습니다.

비교적 외부에 노출되는 인터페이스가 명확한 RESTful API는 모듈을 각각 테스트하지 않아도 가장 상위 모듈을 테스트하면 하위 모듈도 대부분 커버됩니다. 구체적으로는 컨트롤러를 테스트하면 모델 계층도 거의 테스트됩니다. 따라서 통합 테스트만 작성하는 것을 기본으로 하고, 단위 테스트는 단위 테스트 방식으로 접근하면 노력이 덜 드는 경우에만 작성하는 것이 효율적입니다.

컨트롤러를 사용해서 테스트하는 방법은 컨트롤러 객체/함수를 직접 호출해서 테스트하는 방법과 HTTP 클라이언트를 사용하는 방법이 있습니다. 사용자는 HTTP를 통해 API에 접근할 것이고, 또한 URL 라우팅이나 미들웨어까지 포함해 테스트하기 위해서는 HTTP 클라이언트를 사용하는 것이 좋습니다. (물론 꼭 서버를 띄워서 소켓 통신을 해야 한다는 뜻은 아닙니다. 많은 프레임워크가 HTTP 요청을 시뮬레이션하는 방법을 제공하고 있으므로 그런 기능을 사용하면 됩니다.)

실제 데이터베이스를 사용

테스트 시 데이터베이스를 끼워넣는 전략이 여러가지 있습니다. 먼저 아예 데이터베이스를 사용하지 않는 방법입니다. 데이터베이스에 접근하는 인터페이스를 추상화하고 가짜 객체를 만듭니다. 이렇게 하면 데이터베이스의 동작을 매번 코드로 작성해줘야 하는 불편함이 있고 실제 환경과 거리가 멀어지므로 통합 테스트에는 적합하지 않습니다. 또한 SQL 기반의 RDBMS를 사용한다면 DB에도 로직을 어느 정도 맡기게 되므로 DB까지 포함해서 테스트하는 것이 좋다고 생각합니다.

실제 데이터베이스를 사용하더라도, ORM을 사용한다면 서비스에 쓰는 DB보다 가벼운 SQLite나 HSQLDB 같은 인메모리 DB를 사용해서 테스트할 수 있습니다. 하지만 ORM의 추상화가 항상 완벽하지는 않으므로 서비스에서 쓰는 것과 같은 종류의 DB를 띄워서 테스트하는 것이 좋습니다.

많은 프레임워크들이 테스트 시 DB를 비우는 기능을 제공하고 있기 때문에 개발 환경에 DB를 설치하는 부담을 제외하면 크게 불편함은 없는 것 같습니다. 요즘은 Docker 같은 기술도 있으니 로컬 개발 환경에 DB를 쉽게 설치할 수 있을 것입니다.

물론, 로컬 환경에 띄울 수 없는 외부 서비스는 추상 인터페이스를 만들고 가짜 객체를 사용하는 수 밖에 없겠습니다.

테스트를 작성할 예제 API

간단한 할일 목록 API를 테스트한다고 생각하겠습니다. 다음과 같은 API 엔드포인트가 있다고 합시다.

  • GET /tasks
    • 저장된 할일 목록을 최근에 추가된 것 먼저 돌려줍니다.
    • excludeCompleted=true 파라미터를 지정하면 완료되지 않은 목록을 돌려줍니다.
  • POST /tasks
    • 할일을 추가합니다.
    • 추가된 할일 객체를 돌려줍니다.

Task 객체는 다음과 같이 생겼습니다.

{
"id": "idididid",
"text": "Write tests",
"completed": false
}

테스트 작성하기

그럼 본격적으로 테스트를 작성해보겠습니다. 먼저 GET /tasks를 테스트해봅니다. 하나의 테스트 함수는 크게 세 단계로 나눠집니다.

  1. 원하는 상태를 준비
  2. 테스트할 API 호출
  3. 응답 및 상태 검증

저장된 할일 목록이 제대로 돌아오는지 확인하려면 할일을 저장해야 합니다. 데이터베이스를 직접 조작해서 할일을 저장할 수도 있습니다. 어떤 프레임워크는 미리 정해진 데이터(fixture)를 DB에 로드하는 기능을 제공합니다. 간단한 테스트에서는 이렇게 해도 무방하지만, 조금만 복잡해져도 fixture 데이터를 직접 작성하기 쉽지 않습니다. 테스트 코드와 테스트 데이터가 한 곳에 모이지 않게 되므로 테스트 데이터가 그렇게 만들어진 의도를 알아내기 힘들어질 수 있고 중복이 발생합니다. 또한 애플리케이션 차원에서 구현된 DB상 데이터의 제약을 제대로 지키기 어렵습니다.

따라서 외부 인터페이스를 통해 테스트한다는 원칙에 따라 POST /tasks를 호출해서 할일을 생성하겠습니다. 아직 테스트되지 않은 API를 사용한다는 것이 어색하게 느껴질 수 있지만 괜찮습니다.

# 파이썬 코드처럼 보이지만 파이썬이 아닐지도 모릅니다...
class TasksControllerTest(TestCase):
def setUp(self):
# 각 메소드 실행 전에 호출되는 메소드
self.client = Client()
self.reset_db() # 데이터베이스를 청소해주는 게 있다고 칩시다.

def testGet(self):
# 원하는 상태를 준비
task1 = self.client.post('/tasks', {'text': 'Write tests', 'completed': False}).json()
task2 = self.client.post('/tasks', {'text': 'Eat lunch', 'completed': True}).json()

# 테스트할 API 호출
response = self.client.get('/tasks')

# 응답 검증
assert response.statusCode == 200
assert response.json() == [task2, task1]

# 쓰기가 발생하는 API가 아니므로 호출 이후의 상태를 따로 검증하지는 않습니다.

이렇게 기본적인 동작을 테스트했습니다. 여기다가 할 일이 하나도 없는 엣지 케이스의 테스트 케이스를 하나 더 추가해볼 수 있을 겁니다.

    def testGet__empty(self):
response = self.client.get('/tasks')
assert response.statusCode == 200
assert response.json() == []

테스트 헬퍼 만들기

이제 GET /tasks API의 완료되지 않은 할일만 돌려주는 동작을 테스트해야 할텐데, 할일을 저장하는 코드의 중복이 생깁니다. 그리고 매번 text를 적어주기도 귀찮으니 적당히 자동으로 만들어주면 좋을 것 같습니다. 그러므로 할일을 만들어주는 함수를 따로 빼내겠습니다. 테스트 코드라고 해서 막 짜도 되는 것이 아니라, 실제 코드와 마찬가지로 읽고 관리하기 쉽도록 신경쓰는 것이 좋습니다.

    # 모든 필드를 입력하지 않아도 되도록 적당한 기본값을 넣어줍니다.
def _newTask(self, text=some_random_string(), completed=False):
return self.client.post('/tasks', {'text': text, 'completed': completed}).json()

물론 테스트 케이스 클래스에 메소드로 넣지 않고 일반 함수로 만들거나 Client를 상속받아서 그쪽에 추가하는 방법도 있습니다. 여튼 여기서 중요한 것은 테스트 환경을 준비해주는 공통 헬퍼를 만들었다는 점입니다.

이제 excludeCompleted=true인 경우의 테스트 케이스를 다음과 같이 테스트 헬퍼를 이용하여 작성할 수 있습니다. 기존 메소드도 리팩토링할 수 있겠죠.

    def testGet__excludeCompleted(self):
completedTask = self._newTask(completed=True)
doingTask = self._newTask(completed=False)
response = self.client.get('/tasks', {'excludeCompleted': True})
assert response.statusCode == 200
assert response.json() == [doingTask]

쓰기 API의 테스트

이제 POST /tasks API를 테스트해봅니다. 한 가지 주의할 점은, 이 API를 호출할 때 앞서 작성한 테스트 유틸리티를 사용하지 않는 것입니다. 테스트 대상이 되는 API에는 헬퍼 함수를 사용하지 않아야 헬퍼를 건드려서 의도치 않게 테스트의 동작이 바뀌는 일을 방지할 수 있습니다.

앞서 작성한 테스트와 다른 점은 API의 응답 외에도 API를 호출하고 난 뒤의 상태를 검증해야 한다는 것입니다. 이때도 데이터베이스를 직접 확인할 수 있지만 외부 인터페이스만을 사용한다는 원칙에 따라 GET /tasks를 호출해서 확인합니다.

    def testPost(self):
request = {'text': 'Drink coffee', 'completed': False}
response = self.client.post('/tasks', request)

# 응답 검증
assert response.statusCode == 200
created = response.json()
assert created['id']
assert created['text'] == request['text']
assert created['completed'] == request['completed']

# 상태 검증
assert self._getTasks() == [created]

def _getTasks(self):
# 이것도 테스트 헬퍼를 만들었습니다!
return self.client.get('/tasks').json()

결론

이상으로 RESTful API 서버의 테스트를 작성하는 방법을 간단히 살펴보았습니다. (너무 간단했나요?)

저는 외부 인터페이스만 사용해서 테스트라는 원칙을 알게 된 이후로 테스트 작성하는 것이 한결 수월해졌기 때문에 그 아이디어를 끝까지 밀고 가 보았습니다. 실제 API의 사용자가 어떤 순서로 API를 호출하게 될 것인지 상상하면서 테스트 코드를 만들 수 있어서인 것 같습니다. 하지만 정말 어쩔 수 없을 때는 DB에 직접 접근하는게 훨씬 쉬울 수 있으니 실용성을 따져가면서 코딩하시면 됩니다.

· 약 9분

최근 UI 프로그래밍에 Rx 패턴이 많이 쓰이고 있습니다. React는 Rx와 이름은 비슷하지만 상태를 다루는 방식은 명령형에 가깝습니다. 상태를 최소화하고 최대한 바깥으로 밀어내는 식으로 문제를 회피할 수는 있지만, 실제 애플리케이션에서는 어찌되었든 상태를 직접 다뤄야 하는 상황이 오곤 합니다.

그렇다면 React 컴포넌트에서도 Rx를 활용하여 상태를 관리할 수 있지 않을까 생각해서 한번 시도해 보았습니다. React에 익숙하다는 가정을 하고 설명하겠습니다. (Rx에 대해 잘 모르신다면 다른 글을 몇 개 읽어보고 오시면 좋습니다)

RxJS 설치하기

(이 글은 얼마 전 정식 버전이 나온 RxJS 5를 기준으로 작성되었습니다.)

npm의 rxjs 패키지를 설치하면 됩니다. (rx라는 패키지도 있는데 이쪽은 RxJS 구버전이므로 주의가 필요합니다.)

DOM 이벤트를 Observable로 표현하기

RxJS에는 DOM 노드의 이벤트를 직접 구독할 수 있는 기능이 제공되지만 여기서는 직접 React의 이벤트를 Observable로 바꿔보겠습니다.

Subject를 만들고 입력 내용이 바뀔 때마다 next 메소드를 호출하여 Observer에 보냅니다.

Subject는 Observable이기도 하므로 구독해서 state를 업데이트 해봅니다. 여기까지는 기본적으로 EventEmitter와 다를 것이 없습니다.

import * as Rx from 'rxjs';

class EventsExample extends React.Component {
constructor() {
super();
this.state = { text: '' };
this.text$ = new Rx.Subject();
}

render() {
return (
<div>
<input onChange={e => this.text$.next(e.target.value)} />
{this.state.text}
</div>
);
}

componentDidMount() {
this.text$.subscribe(text => this.setState({ text }));
}
}

이벤트를 바로 구독하는 대신 중간에 연산자를 넣어 보겠습니다. filter로 길이가 2글자 이상일 때만 이벤트를 발생시키고, map으로 뒤에 문자열을 덧붙입니다.

이렇게 함수형 연산자를 통해 이벤트 스트림을 원하는 형태로 변형할 수 있는 것이 Rx의 장점입니다. map, filter 외에도 여러가지 강력한 연산자를 사용할 수 있습니다.

class EventsExample extends React.Component {
// ...

componentDidMount() {
this.text$
.filter(text => text.length >= 2)
.map(text => text + '!')
.subscribe(text => this.setState({ text }));
}
}

props에 따라 네트워크 요청을 하는 컴포넌트

여기서부터는 검색어를 props로 받아서 GitHub API를 호출하고 받아온 데이터를 렌더링하는 컴포넌트를 만들어 보겠습니다.

네트워크 요청은 비동기 작업이기 때문에 여러가지 복잡한 상황을 처리해야 할 수 있습니다.

  • 응답을 받기 전에 다른 검색어가 props로 들어오면 새로운 요청을 보내야 합니다.
  • 이전 요청의 응답이 새 요청의 응답보다 늦게 도착하면 무시해야 합니다. (또는, 새 요청을 보내면서 이전 요청을 취소합니다.)
  • 요청이 실패하면 다시 요청을 보내봅니다.
  • 재시도 중에도 다른 검색어가 props로 들어오면 재시도를 중단합니다.

이런 경우에 Rx를 이용하면 깔끔한 코드를 작성할 수 있습니다.

props를 Observable로 표현하기

먼저 검색어가 props에서 들어오므로 이를 Observable로 만들 것입니다.

위에서와 마찬가지로 Subject를 사용하여 componentWillReceiveProps 라이프사이클 메소드가 불릴 때마다 새 props를 전파합니다. 초기값을 나타내기 위해 BehaviorSubject를 사용했습니다.

그리고 map 연산자로 props에서 query 프로퍼티만을 취했습니다.

class SearchExample extends React.Component {
constructor(props) {
super(props);
this.state = { data: null };
this.props$ = new Rx.BehaviorSubject(props);
}

componentWillReceiveProps(nextProps) {
this.props$.next(nextProps);
}

componentWillUnmount() {
this.props$.complete();
}

render() {
const data = this.state.data;
return <ul>{data && data.map(item => <li>{item.login}</li>)}</ul>;
}

componentDidMount() {
const query$ = this.props$.map(props => props.query);
query$.subscribe(query => console.log(query));
}
}

GitHub API 호출

RxJS의 AjaxObservable로 GitHub API를 호출해보겠습니다. RxJS 5가 나온지 아직 얼마 되지 않아서 문서화가 제대로 되어있지 않은데 다음과 같이 사용합니다.

// 주의: subscribe를 할 때마다 요청을 전송함
Rx.Observable.ajax.get('https://api.github.com/search/users?q=dittos')
.map(r => r.response.items)
.subscribe(data => console.log(data));

이제 query가 바뀔 때마다 요청을 보내려면 각 querymergeMap하여 AjaxObservable로 바꿔줍니다. (flatMap으로 불리기도 하는 연산자입니다.)

class SearchExample extends React.Component {
// ...

componentDidMount() {
const query$ = this.props$.map(props => props.query);
const result$ = query$.mergeMap(query => {
if (query === '')
return Rx.Observable.of(null);
else
return Rx.Observable.ajax.get('https://api.github.com/search/users?q=' + query)
.map(r => r.response.items);
});
result$.subscribe(data => this.setState({ data }));
}
}

연산자를 붙여서 잘(?) 처리하기

위에서 나열한 요구사항을 만족하기 위해 Rx 연산자를 추가해보겠습니다.

  • 이전 요청의 응답이 새 요청의 응답보다 늦게 도착하면 무시해야 합니다mergeMap은 요청이 들어간 순서를 따지지 않고 응답이 도착하는 대로 뿜어냅니다. switchMap으로 변경해서 이전 요청을 취소하도록 만들 수 있습니다. (switchMapflatMapLatest로 불리기도 합니다.)
  • 요청이 실패하면 다시 요청을 보내봅니다retry를 적용하면 Observable이 실패 상태로 끝났을 때 다시 Observable을 구독할 수 있습니다.
  • 재시도 중에도 다른 검색어가 props로 들어오면 재시도를 중단합니다. 이미 switchMap으로 변경했으므로 자동으로 재시도가 중단됩니다. :)
class SearchExample extends React.Component {
// ...

componentDidMount() {
const query$ = this.props$.map(props => props.query);
const result$ = query$.switchMap(query => {
if (query === '')
return Rx.Observable.of(null);
else
return Rx.Observable.ajax.get('https://api.github.com/search/users?q=' + query)
.map(r => r.response.items)
.retry(3);
});
result$.subscribe(data => this.setState({ data }));
}
}

입력창과 합치기

처음의 DOM 이벤트 예제와 결합하여 전체 애플리케이션을 완성합니다.

text$debounceTime 연산자를 적용해서 검색 요청이 너무 빠르게 발생하는 것을 방지합니다.

class EventsExample extends React.Component {
constructor() {
super();
this.state = { text: '' };
this.text$ = new Rx.Subject();
}

render() {
return (
<div>
<input onChange={e => this.text$.next(e.target.value)} />
<SearchExample query={this.state.text} />
</div>
);
}

componentDidMount() {
this.text$.debounceTime(200)
.subscribe(text => this.setState({ text }));
}
}

CodePen에서 실행해보실 수 있습니다.

결론

React에서 복잡한 상태를 관리해야 할 때 RxJS를 사용해보는 것도 나쁘지 않을 것 같습니다. 물론 상태를 최소화하는 방법을 먼저 생각해보는 게 중요합니다.

· 약 4분

Jersey는 Java의 REST 웹 서비스 표준인 JAX-RS의 레퍼런스 구현체입니다. Spring의 무거움이나 서블릿을 직접 쓸때의 번거로움이 덜해서 자주 사용하고 있습니다. 특히 Jersey와 몇몇 라이브러리의 통합을 제공하는 Dropwizard를 쓰면 REST 서비스를 빠르게 만들 수 있습니다.

Jersey 1.x에서는 Guice와의 연동을 지원했는데 2.0 이후에는 HK2라는 의존성 주입 프레임워크를 내장하게 되면서 다른 의존성 주입 라이브러리와는 통합이 쉽지 않게 되었습니다. Dropwizard도 0.8.0부터는 Jersey 2.x를 사용하고 있기 때문에 뭔가 대책이 필요했습니다. jersey2-guice 같은 해결책도 있지만 그냥 HK2를 그대로 써도 되지 않을까 해서 조사를 해보았습니다.

리소스에 의존성 주입

TwitterClient라는 인터페이스가 있다고 가정해봅시다. 리소스 클래스의 생성자에 javax.inject.Inject 어노테이션을 붙여서 객체를 주입할 수 있습니다.

@Path("/tweets")
public class TweetsResource {
private final TwitterClient twitterClient;

@Inject
public TweetsResource(TwitterClient twitterClient) {
this.twitterClient = twitterClient;
}

// ...
}

Guice와 별로 다를 것은 없습니다. 해보진 않았지만 아마 setter/field 인젝션도 가능할겁니다.

바인딩 설정

Jersey에서는 일반적으로 ResourceConfig 객체에 리소스를 등록합니다. (Dropwizard에서는 Environment#jersey()를 통해 얻을 수 있습니다.)

ResourceConfig config = new ResourceConfig();
config.register(TweetsResource.class);

마찬가지로 바인딩 설정 또한 ResourceConfig에 등록할 수 있습니다. Guice의 AbstractModule과 유사한 AbstractBinder를 상속받고 바인딩 DSL을 사용해서 설정할 수 있습니다.

config.register(new AbstractBinder() {
// ...
});

HK2의 바인딩 DSL도 Guice와 상당히 비슷한데, HK2는 구현이 앞에 오고 인터페이스가 뒤에 온다는 차이가 있습니다. (HK2의 용어로는 인터페이스 = contract, 구현 = service입니다.)

  • bind(new TwitterClientImpl()).to(TwitterClient.class): TwitterClientImpl 인스턴스를 TwitterClient 인터페이스에 바인딩
  • bind(TwitterClientImpl.class).to(TwitterClient.class): TwitterClient 인터페이스의 구현 클래스로 TwitterClientImpl을 바인딩 (주입 시마다 새로 인스턴스 생성)
  • bind(TwitterClientImpl.class).to(TwitterClient.class).in(Singleton.class): TwitterClient 인터페이스의 구현 클래스로 TwitterClientImpl을 바인딩 (주입 시 하나의 싱글턴 인스턴스 공유)
  • bindAsContract(TwitterClientImpl.class): bind(TwitterClientImpl.class).to(TwitterClientImpl.class)와 같습니다.

그 밖에도 bindFactory 같은 것들이 제공되는데 HK2 문서가 그다지 친절하지 않아서 완전히 파악하지는 못했지만 웬만하면 문제는 없을 것 같습니다.

Mock 객체 주입해서 테스트

Jersey 테스트 프레임워크와 적당한 Mock 라이브러리를 사용해서 TwitterClient의 Mock 객체를 주입하는 테스트를 다음과 같이 작성할 수 있습니다.

public class HK2Demo extends JerseyTest {
TwitterClient twitterClient;

@Override protected Application configure() {
twitterClient = mock(TwitterClient.class);

ResourceConfig config = new ResourceConfig();
config.register(TweetsResource.class);
config.register(new AbstractBinder() {
@Override protected void configure() {
bind(twitterClient).to(TwitterClient.class);
}
});
return config;
}

@Test public void testPost() {
target("/tweets").request()
.post(Entity.form(new Form("message", "Hi!")));
verify(twitterClient).tweet("Hi!");
}
}

참고 문서

· 약 14분

애니메타는 제가 2009년부터 취미로 개발하고 있는 사이트입니다. 애니메이션 감상 기록을 공유하는 서비스로, 적당한 방문자와 적당한 활성 사용자를 가진 적당한 규모를 가지고 있어서 기능 추가보다는 여러가지 기술적인 실험의 장이 되고 있습니다. (유저 여러분께는 죄송합니다...)

처음에는 Django와 jQuery로 작성되어 있던 사이트를 React 기반으로 전환한 경험을 공유하려고 합니다. 프로젝트는 소스 코드가 공개되어 있으니 궁금하신 분은 참고하시면 됩니다.

서버 렌더링

서버 렌더링은 클라이언트측 JavaScript 애플리케이션을 서버에서 미리 HTML으로 렌더링하는 기술입니다. 몇 년 전만 하더라도 서버와 클라이언트에서 같은 뷰 코드를 사용하는 "isomorphic" 애플리케이션은 성배로 불리기도 했었는데요. React에서는 ReactDOMServer.renderToString API를 사용하면 꽤 쉽습니다. 오히려 렌더링에 필요한 데이터를 준비하는 일이 복잡합니다.

서버에서 미리 렌더링을 하는 이유는 거의 SEO (검색엔진 최적화)를 위해서입니다. 이제 Google은 JavaScript도 실행해서 크롤링할 수 있다고는 하지만 검색 랭킹에서 어떤 영향을 받는지, 모든 JavaScript가 언제나 실행되는 것인지 분명하게 밝혀지지 않았습니다.

또 하나의 이유는 더 나은 사용자 경험입니다. 특히 체감 속도면에서 아주 크진 않지만 장점이 있다고 봅니다. 완전히 클라이언트에서만 실행되는 애플리케이션은 JavaScript 코드를 모두 다운받고 필요한 데이터를 가져올 때까지 빈 화면이 보이다 한번에 렌더링되는게 보통입니다. 서버에서 미리 HTML을 내려주면 초기 레이턴시는 약간 늘어나겠지만 점진적으로 내용을 표시하는 것이 가능합니다. 렌더링에 필요한 데이터도 서버 내에서 미리 가져오면 DB나 API 서버에서 가까우니 훨씬 빠르게 가져올 수 있습니다. 물론 자바스크립트를 끄거나 불러오지 못한 경우에도 내용을 볼 수 있는 건 덤입니다.

과도기

과도기
과도기의 구조

한번에 최종 구조로 전환할 수 있었으면 좋았겠지만 취미 프로젝트의 특성상 집중적으로 긴 시간을 사용하기는 힘듭니다. 따라서 조금씩 전환할 수 있는 방식을 처음에 적용했습니다. 위의 그림에서 알 수 있듯이 기존의 DB 접근 코드는 그대로 두고 Django 템플릿으로 되어있던 뷰만 React로 다시 작성합니다. 그리고 DB에서 가져온 데이터를 별도의 Node.js 렌더 서버에 JSON으로 전달하여 React 컴포넌트를 HTML로 렌더링하는 것입니다.

이러한 방법도 실제로 유효한 접근이고 널리 사용되고 있습니다. ReactJS.NET, react-rails, Hypernova 등이 비-JS 서버에서 렌더 서버를 호출하는 방식입니다.

하지만 최종 목표는 다음과 같이 서버와 클라이언트가 하나의 코드를 공유하고, 첫 로딩 이후에는 클라이언트가 직접 API 서버와 통신하는 구조입니다.

최종 목표
최종 목표

따라서 다음 단계는 Node.js로 프론트엔드 서버를 작성하는 작업이었습니다. 여전히 서버와 클라이언트 사이에 코드가 모두 공유되는 것은 아니지만 Django에서 바로 DB에 접근하던 것을 Node.js 서버에서 API 서버를 호출하는 식으로 바꾸는 것입니다. 아직 Django인 페이지와 프론트엔드 서버를 통하는 페이지가 공존하는 혼돈의 상태가 한동안 지속되었지만 결국 모든 페이지를 포팅하였습니다.

React Router + Redux

요즘 웬만한 React 애플리케이션은 React Router와 Redux를 씁니다. 애니메타도 마찬가지로 React Router와 Redux로 서버 렌더링을 시도했습니다. 잘 작동하고 isomorphic 애플리케이션이라는 목표를 달성하긴 했지만 몇가지 만족스럽지 못한 부분들이 있었습니다.

React Router가 마음에 들지 않았던 점은 계층 구조 때문에 데이터를 중복으로 가져오는 경우가 발생하는 것입니다. 하나의 페이지가 여러 층의 route로 구성되는데 각 route는 서로를 알지 못하므로 같은 데이터를 여러번 가져오게 될 수 있습니다. URL을 기준으로 중복을 제거하는 것도 시도해 보았지만 상위 route의 데이터의 일부분이 하위 route와 겹치는 경우는 알기 힘듭니다. 한동안 GraphQL과 Relay에 관심을 가졌던 이유도 이런 문제를 해결해주기 때문이었습니다.

한편 Redux는 불필요한 코드가 늘어나는 점이 불만이었습니다. 사이트의 특성상 API에서 가져온 내용을 보여주기만 하고 변경은 거의 일어나지 않는데, 간단한 데이터도 Redux에 저장해야 했습니다. 물론 Redux에 잘 저장하면 캐싱도 되고 장점이 많지만 복잡도에 비해 이득이 크지 않다고 판단이 되었습니다. 또한 Redux에 데이터를 넣는 코드는 route에 있는데, route와 Redux state의 구조가 다르므로 Redux에서 데이터를 꺼내는 코드를 한 벌 더 작성하는 것도 귀찮았습니다.

다 마음에 안드니까 내가 만들자

앞에서 언급한 문제를 해결하는 최대한 단순한 구조가 어떤 것일지 고민하다가 Nuri라는 라이브러리를 직접 개발하게 되었습니다.

먼저 React Router의 계층 구조에서 오는 문제는 단일 계층만을 사용하는 (무식한) 방법으로 해결했습니다. 그렇게 되니 굳이 React Router를 사용할 이유가 별로 없어졌고 몇 개의 라이브러리를 조합하여 간단한 라우팅을 구현했습니다. (Reddit 모바일 사이트에서 사용하는 node-horse 라이브러리에서 아이디어를 많이 얻었습니다.)

또한 각 route마다 별도의 데이터 저장 공간을 두어, route가 불러온 데이터를 React 컴포넌트에서 바로 접근할 수 있도록 했습니다.

Nuri 맛보기

라우팅

라우팅 설정 방식은 Express 같은 서버용 라이브러리와 비슷합니다.

먼저 App 객체를 만듭니다.

import {createApp} from 'nuri';

var app = createApp();

App에는 특정 URL 패턴을 처리할 RouteHandler를 등록할 수 있습니다. 핸들러에서 중요한 속성은 loadcomponent입니다.

app.route('/posts/:id', {
load: (request) => {
return fetch('http://api.example.com/posts/' + request.params.id);
},
component: Post
});

load 함수는 요청을 받아서 필요한 데이터를 가져오는 Promise를 리턴합니다. 데이터를 모두 가져오면 component에 지정한 React 컴포넌트가 렌더링됩니다. 가져온 데이터는 data prop으로 접근할 수 있습니다.

이렇게 작성한 애플리케이션은 클라이언트에서는 nuri/client 모듈을 이용해서 실행할 수 있고, 서버에서는 nuri/server 모듈로 미리 렌더링할 수 있습니다.

상태 관리

Nuri에는 간단한 상태 관리 시스템이 내장되어 있어서 Redux/Flux 스토어를 사용하지 않아도 됩니다.

컴포넌트에 제공되는 writeData 함수로 data prop을 변경하고 컴포넌트가 다시 렌더링되게 할 수 있습니다.

class Posts extends React.Component {
render() {
return <ul>
{this.props.data.posts.map(post => <li>{post.title}</li>)}
<li><button onClick={this._addPost.bind(this)}>Add Post</button></li>
</ul>
},

_addPost() {
this.props.writeData(data => {
// You can *mutate* the data
data.posts.push({ title: new Date().toString() });
});
// The component is re-rendered with the changed data.
}
}

또한 라우팅과 상태 관리가 연계되어, 각 페이지의 상태가 브라우저 히스토리에 대응합니다. 어떤 페이지의 상태를 변경한 뒤 다른 페이지에 갔다가 브라우저의 뒤로 가기를 누르면 별도의 네트워크 로딩 없이 이전에 변경한 데이터가 바로 보이게 됩니다.

앞으로 남은 과제

  • 성능 개선: Node.js의 싱글 스레드 모델 특성상 CPU를 점유하는 렌더링 중에는 다른 요청을 처리할 수 없습니다. 보통 수십 밀리초 정도 걸리기 때문에 크게 걱정할 일은 아니지만 작업량을 좀 더 공평하게 분배할 수 있는 방법을 찾아야 합니다. (현재 개발되고 있는 React의 Fiber reconciler가 렌더링 작업을 쪼개서 스케줄링할 수 있다고 하는데 기대가 됩니다.)
  • 데이터 재사용: React Router의 계층 구조에서는 페이지의 상위 route가 일치하면 하위 route의 데이터만 가져오는 것이 가능했습니다. 현재 구조에서는 따로 캐시도 없고 이전 페이지의 데이터를 재사용할 방법이 없는데, 어떻게 할 수 있을지 고민하고 있습니다.
  • 서버 코드 라이브러리화: nuri/server 모듈에는 아직 렌더링에 관련된 코드만 있고 실제 서버는 직접 구현해야 합니다. Express 미들웨어를 라이브러리로 만들어서 App만 넘기면 되도록 하고 싶습니다.
  • 개발 환경 개선: 지금은 코드가 바뀌면 클라이언트 쪽 코드만 자동으로 빌드되고, 서버 코드는 재시작해야 반영이 됩니다.
  • Nuri를 npm에 올리기: 조금 더 안정화가 되면 올려보려고 생각 중입니다.

· 약 6분

React 소스코드 읽기 시리즈

  1. 모듈 시스템
  2. ReactElement
  3. 유틸리티들

본격적으로 코드를 읽으려고 하니 복잡한 개념은 아닌데 익숙하지 않아 걸리는 부분들이 있어서 확실히 짚고 넘어가려고 합니다.

클래스 선언

React 생태계에서는 최신 자바스크립트 표준을 사용하는 것이 보통이지만 React 자체는 ES5로 작성되어 있습니다. 그래서 ES2015 클래스가 아닌 프로토타입 상속을 주로 볼 수 있습니다. 대부분 다음과 같은 패턴으로 클래스를 정의합니다.

function SomeClass() {
// ...
}

var Mixin = {
// SomeClass의 메소드들
};

assign(
SomeClass.prototype,
SomeMixin, // SomeMixin에 정의된 메소드를 믹스인한다.
Mixin
);

여기서 assign 함수는 ES2015의 Object.assign입니다.

의존성 주입

React의 일부 모듈은 여러 플랫폼을 지원하기 위해 실제 구현 클래스를 주입(inject)할 수 있게 설계되어 있습니다. 그런 모듈은 열어봐도 실제 구현을 찾을 수 없어서 당황할 수 있는데요. ReactDefaultInjection 모듈에서 어떤 구현 클래스가 주입되는지 확인할 수 있습니다. 물론 React DOM 환경에서 저렇게 주입되는 것이고, React Native는 다른 클래스를 주입합니다.

트랜잭션

React의 트랜잭션은 어떤 함수를 실행하기 전과 후에 특정 동작을 수행할 수 있도록 감싸줍니다. 함수를 감싸는 트랜잭션 래퍼(wrapper)는 함수 수행 중에 예외가 나도 항상 호출되도록 되어 있어서 외부 자원의 상태를 안전하게 관리할 수 있습니다.

트랜잭션 래퍼는 initializeclose 메소드를 구현하며 각각 함수 실행 전과 후에 호출됩니다. 트랜잭션은 Transaction.Mixin을 믹스인하고 트랜잭션 래퍼의 배열을 리턴하는 getTransactionWrappers 메소드를 구현해야 합니다. 트랜잭션의 perform 메소드를 호출해서 특정 함수를 트랜잭션 안에서 실행할 수 있습니다.

다음의 예제 코드를 살펴봅시다.

// Transaction Wrappers
var A = {
initialize: function() {
console.log('A.initialize')
},
close: function() {
console.log('A.close')
}
};
var B = {
initialize: function() {
console.log('B.initialize')
},
close: function() {
console.log('B.close')
}
};

// Transaction
function Tx() {
this.reinitializeTransaction();
}
assign(Tx.prototype, Transaction.Mixin, {
getTransactionWrappers: function() {
return [A, B];
}
});

function f(a, b) {
console.log('f(' + a + ', ' + b + ')')
throw new Error('error!')
console.log('f end')
}

var tx = new Tx();
tx.perform(
// Tx 안에서 실행할 함수
f,
// 함수의 this context를 지정
null,
// 함수의 인자를 지정
1, 2
);

실행하면 다음과 같은 로그가 출력됩니다.

A.initialize
B.initialize
f(1, 2)
A.close
B.close
Error: error!
... stack trace ...

풀링

자주 할당되는 객체를 사용이 끝난 뒤 해제하지 않고 다시 사용하는 것을 풀링이라고 합니다. React에서는 PooledClass 모듈이 객체 풀링에 사용됩니다.

클래스에 풀링을 추가하려면, 객체가 풀에 반환될 때 객체의 상태를 초기화하는 destructor 메소드를 구현하고 PooledClass.addPoolingTo를 호출합니다. 그리고 풀링이 추가된 클래스는 getPooled 함수로 풀에서 인스턴스를 가져올 수 있습니다. 풀에서 가져온 인스턴스는 사용이 끝난 뒤에 반드시 release 함수로 반환해줘야 합니다.

function SomeClass() {
console.log('construct')
}
assign(SomeClass.prototype, {
destructor: function() {
// 객체가 해제될 때 초기화
console.log('release')
}
});
PooledClass.addPoolingTo(SomeClass);

var inst = SomeClass.getPooled(null); // `construct` 출력
SomeClass.release(inst); // `release` 출력
var inst2 = SomeClass.getPooled(null); // 아무것도 출력되지 않음!
console.log(inst === inst2); // true

배치

같은 컴포넌트가 연쇄적으로 여러번 업데이트될 때, 마지막 한번만 실제로 렌더링을 할 수 있다면 효율적일 것입니다. React는 기본적으로 업데이트를 배치로 묶어서 처리합니다.

렌더링 작업은 기본적으로 ReactUpdates.batchedUpdates 함수를 통해 실행됩니다. setState 같은 메소드는 바로 렌더링을 발생시키지 않고 업데이트 큐에만 추가합니다. (ReactUpdates.enqueueUpdate) setState를 호출하더라도 변경된 상태를 바로 this.state로 읽을 수 없는 이유입니다. 배치가 끝나면, 쌓여있던 업데이트가 한번에 처리됩니다. (ReactUpdates.flushBatchedUpdates)

배치 전략은 주입되는 의존성이며 기본 배치 전략은 ReactDefaultBatchingStrategy에 구현되어 있습니다.

· 약 9분

React 소스코드 읽기 시리즈

  1. 모듈 시스템
  2. ReactElement
  3. 유틸리티들

React의 깊은 부분으로 들어가기 전에 (다소 지루할 수는 있지만) 먼저 표면에 드러난 컴포넌트 정의 API들을 살펴보려고 합니다. JavaScript와 React에 어느 정도 익숙한 분을 위한 글이고, 여기서 설명하는 내용은 모두 구현 디테일이므로 언제든 바뀔 수 있으니 주의하세요. React 15.0.0-rc.1 버전을 기준으로 하고 있습니다.

React Core와 DOM

React 0.14부터 React는 Core와 DOM, 두 개의 패키지로 분리되었습니다. 컴포넌트를 정의할 때 사용되는 API는 Core 패키지에 존재하고 플랫폼 독립적입니다. (여기서 플랫폼이란 브라우저(react-dom), 서버(react-dom/server), React Native 등을 의미합니다.) 따라서 지금 보려고 하는 것은 Core 패키지에 속하는 코드입니다.

JSX는 createElement 함수 호출로 변환됩니다

React 코드에서는 JSX 문법으로 가상 DOM 구조를 나타냅니다. 그리고 JSX가 일반적인 JS 코드로 변환된다는 것은 이미 알고 계실겁니다. 예를 들어 <Nav color="blue" />React.createElement(Nav, {color: 'blue'})가 됩니다. 이때 React.createElement 함수는 ReactElement 타입의 객체를 리턴합니다. 그러면 createElement의 소스 코드를 읽어봅시다.

__DEV__

일단 어디에도 선언되어 있지 않은 __DEV__라는 변수가 사용되고 있습니다. 이 변수의 값은 빌드 과정에서 개발 모드인지 프로덕션 모드인지에 따라 각각 true 또는 false로 정해집니다. 대부분 개발자가 실수하지 않도록 각종 경고를 내주는 코드를 가두는 데에 사용되고 있습니다. 편리한 기능이지만 실제 서비스 시에는 불필요하고 성능이 저하될 수 있으므로 프로덕션 모드에서는 아예 없애버릴 수 있도록 하는 것입니다.

props 정규화

createElement에서 가장 먼저 하는 작업은 React에서 예약되어 있는 prop을 제거하는 것입니다. (128-147행) key, ref를 별도의 변수에 저장하고 그들을 제외한 나머지는 props 객체에 복사됩니다. 컴포넌트 안에서 this.props.key처럼 해서 key 프로퍼티에 접근할 수 없는 이유입니다.

다음으로는 자식 엘리먼트들을 props.children에 넣습니다. (149-160행) <Parent x="y">asdf{a}qwer</Parent>React.createElement(Parent, {x: 'y'}, 'asdf', a, 'qwer')로 번역되므로 세번째 인자부터 마지막 인자까지가 children 배열이 됩니다.

단, 자식이 한 개일 경우에는 배열로 만들지 않고 자식 엘리먼트가 바로 children이 됩니다. (불필요하게 배열이 할당되지 않도록 하기 위해서로 보입니다) 따라서 컴포넌트 안에서 this.props.children이 배열인지 아닌지 알기 어렵기 때문에 이를 일관성있게 다루기 위한 React.Children 유틸리티 함수들이 제공되고 있습니다.

props 정규화의 마지막 과정으로 컴포넌트에 선언된 defaultProps가 복사됩니다. (162-170행)

ReactElement 객체의 구조

정규화 및 추출을 마친 값들은 ReactElement 함수에 넘겨지면서 객체로 만들어집니다. 그 코드는 다음과 같습니다.

var element = {
// This tag allow us to uniquely identify this as a React Element
$$typeof: REACT_ELEMENT_TYPE,

// Built-in properties that belong on the element
type: type,
key: key,
ref: ref,
props: props,

// Record the component responsible for creating this element.
_owner: owner,
};

if (__DEV__)
... // 생략

return element;

$$typeof 프로퍼티는 이 객체가 ReactElement임을 나타내주는 표식입니다. 같은 파일 안에 선언되어 있는 React.isValidElement 함수는 이 값을 가지고 올바른 Element인지 검사하도록 되어있습니다. REACT_ELEMENT_TYPE의 값은 ES2015 Symbol을 사용할 수 있을 경우에는 Symbol이고 아니면 매직 넘버 0xeac7을 사용하도록 되어있습니다. (eac7은 react에서 따온 것일까요? 🙂)

type, key, ref, props는 넘어온 그대로 들어가므로 크게 설명이 필요 없을 것 같습니다.

Owner

owner는 아까 createElement의 마지막 부분에서 ReactCurrentOwner.current가 넘어오고 있습니다. 이것이 어떻게 작동하는지 보러 가기 전에 먼저 간단히 설명하면, 컴포넌트에서 this.refs를 만들기 위해 필요합니다.

ReactCurrentOwner 모듈 자체는 current 프로퍼티만을 가지는 객체를 노출하고 있습니다. 일종의 싱글턴 객체로 사용됩니다. current 프로퍼티는 컴포넌트의 render 메소드가 호출되기 직전에 현재 컴포넌트 객체로 설정됩니다. 그리고 렌더링이 완료된 후 refs에 붙게 됩니다. (이 과정은 나중에 다시 자세히 볼 예정입니다.)

따라서 render 메소드 밖에서 미리 만들어진 ReactElement에는 _owner 프로퍼티가 null로 되어 있습니다. 여기에 ref가 붙어있으면 렌더 시에 다음과 같이 오류가 나게 됩니다.

var el = <div ref="x" />;

class C extends React.Component {
render() { return el; }
}

ReactDOM.render(<C />, ...);
// Error: Invariant Violation: addComponentAsRefTo(...): Only a ReactOwner can have refs. This usually means that you're trying to add a ref to a component that doesn't have an owner (that is, was not created inside of another component's `render` method). Try rendering this component inside of a new top-level component which will hold the ref.

전역 싱글턴을 쓰지 않고 render 메소드에서 리턴된 ReactElement를 순회하면서 owner를 붙일 수도 있었겠지만, 순회하는 비용이 들기 때문에 이렇게 구현한 것 같습니다. 그리고 ReactElement는 불변 객체기 때문에 복사하는 비용도 무시하기 힘들 것입니다.

createElement의 최적화

위에서 살펴봤듯 createElement에서는 생각보다 여러가지 작업이 수행됩니다. 특히 props를 정규화하는 과정에서 몇 개의 객체가 새로 할당됩니다. render 메소드가 병목이 되는 경우는 거의 없지만 어떤 경우에는 여기서 발생하는 오버헤드를 줄이고 싶을 수 있습니다.

이미 ReactElement 객체의 구조를 알고 있으니 createElement를 호출하지 않고 컴파일 타임에 미리 객체를 만들어버릴 수 있지 않을까요? 이런 아이디어를 구현해 놓은 것이 Babel의 react-inline-elements 플러그인입니다.

JSX 태그를 createElement 호출로 변환하지 않고 바로 객체 리터럴로 변환해줍니다. 예를 들어 <Baz foo="bar"></Baz>는 다음과 같은 코드로 컴파일됩니다.

({
$$typeof: babelHelpers.typeofReactElement,
type: Baz,
key: null,
ref: null,
props: babelHelpers.defaultProps(Baz.defaultProps, {
foo: "bar"
}),
_owner: null
});

마치며

다시 한번 이 모든 것은 구현 디테일임을 강조하고 싶습니다. React의 내부 구현은 그동안 자주 바뀌어왔고 앞으로도 언제든지 바뀔 수 있습니다.

· 약 3분

React 소스코드 읽기 시리즈

  1. 모듈 시스템
  2. ReactElement
  3. 유틸리티들

React 소스 코드는 조금 특이한 모듈 시스템을 사용해서 사전 지식이 없으면 구조를 파악하기가 힘듭니다.

대부분의 소스 코드는 src/에 들어있습니다. 파일들을 열어보면 CommonJS처럼 require 함수로 모듈을 불러오고 module.exports에 넣은 값이 외부에 공개되는 것을 알 수 있습니다. 그런데 require할 때의 모듈 경로가 디렉토리를 뺀 모듈명만으로 되어있습니다. 예를 들어 src/React.js에서 require('ReactDOM')를 하는데 실제 이 모듈은 src/renderers/dom/ReactDOM.js에 있습니다.

이는 React가 페이스북 내부에서 사용하는 모듈 시스템으로 작성되어 있기 때문입니다. 각 파일에 보면 @providesModule이라는 주석이 달려있는데 여기에 있는 이름이 해당 파일의 모듈명이 됩니다. (대부분 파일명과 동일한 것으로 생각됩니다.) 또한 일부 모듈은 React가 아니라 페이스북의 JavaScript 프로젝트에서 공통으로 사용하는 fbjs에 들어있으므로 React 저장소에서 찾을 수 없는 경우 살펴봐야 합니다.

한가지 팁으로, GitHub의 파일 브라우저에서 t 키를 누르면 파일명으로 저장소 전체를 검색할 수 있어 실제 파일을 빠르게 찾을 수 있습니다. (GitHub 단축키)

npm의 react 패키지를 설치해보면 lib/ 디렉토리 안에 모든 모듈이 플랫하게 들어있는 것을 확인할 수 있습니다. 그리고 npm 패키지의 디렉토리 구조는 packages/ 디렉토리 안에서 확인할 수 있습니다. 결론적으로 npm의 react 패키지의 진입점은 packages/react/react.js이고 이는 src/React.js를 그대로 노출합니다.

npm의 react 패키지

요약하면,

  • require('blahblah')는 react 저장소 어딘가에 있는 blahblah.js를 참조한다.
  • react 저장소에 없으면 fbjs 저장소에 있다.
  • npm 패키지의 진입점은 packages/ 디렉토리 안에 있다.

· 약 9분

그동안은 파이썬 애플리케이션을 배포할 때 설정이 쉽다는 이유로 gunicorn을 주로 사용했다. (예전에 올린 관련 글) 이번에 우연히 uWSGI의 여러가지 기능에 대해 알게 되었는데, 호기심이 생겨 문서를 읽으면서 이것저것 적용해보았다. 공식 문서가 잘 쓰여있긴 하지만 워낙 기능이 방대해서 유용할만한 기능을 정리해본다.

혹시 uWSGI의 기본적인 설정 방법을 알고 싶다면 haruair님의 블로그 글을 추천한다.

설정 파일 변수 (placeholder)

이걸 고급 기능이라고 해야할 지 의문이지만... 설정 파일 안에서 반복되는 내용은 변수로 두고 한번에 치환할 수 있다.

# %(app_dir) -> /path/to/app
set-placeholder = app_dir=/path/to/app

# virtualenv = /path/to/app/env와 동일
virtualenv = %(app_dir)/env

부속 프로세스 관리

메인 웹 애플리케이션과 항상 같이 실행되는 부속 데몬/워커 프로세스가 있는 경우가 많다. 예를 들면 Celery 워커가 있다. 이런 프로세스들을 따로 시스템 서비스로 등록하지 않고 uWSGI가 관리하게 만들 수 있다. attach-daemon 옵션에 함께 실행할 명령을 지정하면 된다. 단, 이때 실행되는 명령어는 데몬화(daemonize)되면 안되고 foreground 모드로 실행되어야 한다.

attach-daemon = /path/to/worker/script

마스터 프로세스가 종료(리로드)되면 부속 프로세스도 함께 종료된다는 것에 주의해야 한다. 이것을 방지하려면 smart-attach-daemon 옵션을 사용해서 PID 파일 경로를 지정해 주어야 한다. 자세한 것은 문서를 보자.

정해진 시간에 명령 실행 (cron)

놀랍게도 cron도 uwsgi로 대체할 수 있다. 보통 crontab에 직접 등록해놓으면 애플리케이션 설정과 분리되어 있기 때문에 잊어버리거나 실수하기 쉬운데, uwsgi 설정에 넣으면 상당히 편리할 것이다. cron 옵션으로 지정하면 되는데, crontab의 설정과 거의 동일하다. 단, crontab에서 */10처럼 쓰던 것을 -10으로 써야 한다. *-1로 쓰면 된다.

# crontab: */5 * * * * /path/to/some/script
cron = -5 -1 -1 -1 -1 /path/to/some/script

cron 대신 unique-cron 옵션을 지정하면 정해진 시각이 되어도 이전에 실행되던 프로세스가 있으면 그 프로세스가 끝날때까지 대기해서 항상 하나의 프로세스만 실행된다. 자세한 것은 문서에서 더 볼 수 있다.

정적 파일 서빙

정적 파일을 uwsgi에서 전송하도록 할 수 있다. nginx에 맡기는 것에 비해 큰 장점이 있지는 않지만 애플리케이션에 대한 정보가 하나의 설정 파일에 들어있게 만들 수 있어서 좋은 것 같다. static-map 옵션으로 특정 문자열로 시작하는 경로를 가진 요청을 파일시스템 상의 특정 디렉토리에 매핑할 수 있다. 그리고 static-expires로 특정 패턴의 주소에 Expires 헤더를 설정할 수 있다. (자세한 설명)

# /static/a.jpg -> /path/to/static/a.jpg
static-map = /static=/path/to/static
# /static/build/로 시작하는 요청의 만료일을 요청 시각으로부터 2592000초 이후로 설정
static-expires-uri = /static/build/* 2592000

접속자가 없을 때 프로세스 종료 (cheap 모드)

어드민 툴 같은 서비스는 아주 자주 사용하지는 않기 때문에 항상 켜두면 메모리가 낭비된다. cheapidle 옵션을 사용하면 접속자가 일정 시간동안 없으면 프로세스를 종료해서 시스템 자원을 절약할 수 있다.

먼저 cheap 옵션은 첫번째 요청이 들어올 때까지 worker 프로세스를 실행하지 않도록 해준다. (master 프로세스는 항상 실행되어 있다) 또한 idle 옵션에 지정한 시간동안 아무런 접속이 없으면 프로세스가 cheap 모드로 들어간다.

# cheap 모드로 실행, 60초 동안 요청이 없으면 cheap 모드로 전환
cheap = true
idle = 60

Graceful reload

기능이라기보다는 작동 방식에 대한 설명인데, uWSGI는 Gunicorn과 리로드 동작 방식이 다르다. 먼저 uWSGI는 master 모드로 실행해야 graceful reload가 가능하다. 또한 Gunicorn은 HUP 시그널을 받으면 기존 프로세스는 그대로 둔 채 먼저 새로운 워커 프로세스를 실행하고 이전 워커를 하나씩 종료하는 방식인데 반해, uWSGI는 먼저 워커 프로세스를 모두 종료한 뒤 새로운 워커를 실행하는 방식이다. 따라서 uWSGI에서는 리로드되는 동안 들어오는 요청은 처리가 지연될 수 있다. (단, Gunicorn과 달리 리로드 되는 동안에도 메모리 사용량이 증가하지 않는다.)

다른 리로드 방식도 있으니 궁금하다면 문서를 읽어보자.

Emperor 모드: 여러 앱 관리

uWSGI 프로세스 자체를 관리하려면 upstart나 systemd 설정을 작성해서 시스템 서비스로 등록하는 것이 일반적이다. Emperor 모드를 사용하여 init 데몬과 별도로 uWSGI 프로세스를 관리할 수 있다. 가장 큰 장점은 배포판에서 사용하는 init 데몬의 종류에 상관 없이 서비스를 관리할 수 있다는 점일 듯 하다. 그리고 프로세스의 실행을 특정 포트에 접속하는 클라이언트가 나타날 때까지 지연하는 Socket Activation 기능을 사용할 수 있다. (물론 systemd에서도 가능하지만...)

먼저 설치는, 우분투 기준으로 uwsgi-emperor 패키지를 설치하면 된다. (구버전 우분투에서는 PPA 저장소를 사용하여 최신 버전을 설치할 수 있다.) 패키지를 설치하면 자동으로 emperor 프로세스가 실행된다. 설정 파일은 /etc/uwsgi-emperor/emperor.ini에 있다.

설정 파일에서 특별히 건드릴 부분은 없고, 마지막에 보면 emperor 옵션이 있을 것이다. 이 옵션에 지정된 디렉토리에 uWSGI 설정 파일을 새로 추가하면 그 설정대로 프로세스가 실행된다. (이때 관리되는 자식 프로세스를 vassal이라고 한다. 황제(emperor)와 신하(vassal)!) 설정 파일이 수정되거나 수정 시각이 변경(touch)되면 프로세스가 리로드된다. 디렉토리에서 설정 파일이 삭제되면 서비스가 종료된다. 물론 여러개의 디렉토리를 모니터링하게 할 수도 있으므로 나는 홈 디렉토리 밑의 설정 파일 디렉토리를 추가했다.

...
# vassals directory
emperor = /etc/uwsgi-emperor/vassals
emperor = /home/ditto/uwsgi-services

· 약 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)