본문으로 건너뛰기

· 약 6분

TL;DR: Diff Monster는 GitHub 풀 리퀘스트 리뷰를 조금 편하게 해주는 웹 기반 도구입니다.

아래 글을 읽기 싫다면 지금 바로 접속해보세요.

스크린샷

어쩌다 이런 걸 만들게 되었나요

  • 기: 연초부터 회사에서 Bitbucket Server를 그만 쓰고 GitHub로 옮기게 되었습니다. 다른건 다 좋은데 코드 리뷰 인터페이스가 너무 불편했습니다.
  • 승: 5월에 긴 연휴가 있어서 한번 코드 리뷰 툴을 만들어보자는 무모한 계획을 세웠습니다.
  • 전: 당연히 연휴 중에 완성 못했고, GitHub API에서 되는게 별로 없어서 꾸역꾸역 개발했습니다.
  • 결: 뭔가 간신히 나오긴 했습니다.

무슨 기능이 있나요

파일 트리

변경된 파일을 디렉토리 계층 구조로 묶어서 볼 수 있습니다.

파일 트리

파일명으로 퍼지 검색이 가능합니다.

퍼지 검색
RIC를 입력하면 RealInterceptorChain.java를 찾아줍니다.

GitHub와 비교: GitHub는 파일 목록이 항상 펼쳐져 있지 않아서 파일 사이를 이동할 때 불편합니다. 심지어 목록을 펼칠 때마다 맨 위부터 다시 파일을 찾아야 합니다. 그리고 그냥 목록으로 나열되어 있어서 보기가 힘듭니다.

파일마다 리뷰 완료 기록

파일마다 리뷰 완료 여부를 표시할 수 있습니다. 큰 PR을 볼 때도 리뷰를 어디까지 했는지 쉽게 알 수 있습니다. (일단 작동은 하지만 좀 더 개선의 여지는 있습니다. 단축키를 추가한다거나.)

파일마다 리뷰 완료 기록

GitHub와 비교: GitHub엔 없는 기능!

리뷰 댓글 작성

GitHub에서 제공하는 기능 중 기본적인 것은 대부분 구현했습니다. 모아서 한번에 올리기도 됩니다. (작성한 댓글은 GitHub에 저장됩니다.)

Commented via Diff Monster
댓글을 달 때 마지막에 작은 서명이 붙으니 놀라지 마세요. :)

GitHub와 비교: 아직 댓글 수정, 답글 달기, 리뷰 취소는 지원하지 않습니다.

승인하기

한번 클릭으로 풀 리퀘스트를 승인(approve)할 수 있습니다.

원 클릭

GitHub와 비교: GitHub에서는 Review changes 클릭 - Approve에 체크 - Submit review 클릭의 세 단계를 거쳐야 합니다. 물론 리뷰 요약을 작성할 수 있도록 이렇게 했겠지만 Approve할 때 사족을 다는 경우는 별로 없다고 생각합니다.

3단계
GitHub은 세 번 클릭해야 한다.

인박스

리뷰해야 할 풀 리퀘스트를 한 곳에서 볼 수 있습니다. 오른쪽 위의 상자 아이콘을 누르면 나옵니다. (로그인 필요)

인박스

GitHub와 비교: GitHub에도 풀 리퀘스트 모아보기 페이지가 있긴 합니다. 근데 리뷰 댓글을 하나라도 달면 거기서 풀 리퀘스트가 사라져 버립니다. (왜 그랬을까?) Diff Monster에서는 Reviewing 탭에 계속 나와요.

앞으로의 계획

처음에 계획했던 기능은 대부분 구현했는데 아직 GitHub에서 제공하는 모든 기능이 되지는 않아서 보충이 필요합니다. 그리고 UI에 자잘하게 개선해야 할 부분이 많이 있습니다.

저희 회사에서 몇몇 분들이 써보시긴 했지만 다른 팀의 워크플로우에는 잘 맞지 않을 수도 있어서 그런 부분도 피드백을 받아 보완해보고 싶은 생각입니다. (물론 이대로 방치될 가능성이 상당히 높습니다 ㅋㅋㅋㅋㅋ)

링크

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