본문으로 건너뛰기

"javascript" 태그로 연결된 8개 게시물개의 게시물이 있습니다.

모든 태그 보기

redux-observable 사용하기

· 약 6분

redux-observable은 RxJS로 Redux에서 비동기 액션을 처리할 수 있게 해줍니다.

기초

액션을 Observable로 다루기

redux-observable에서는 Redux 스토어에 들어오는 액션들을 Observable로 다룰 수 있게 해줍니다. dispatch가 호출되면, 액션이 스토어에서 처리된 후에 Observable에 액션이 출력됩니다.

실제로는 Observable을 확장한 ActionsObservable을 얻을 수 있는데 여기에는 특정 종류의 액션만 걸러낼 수 있는 ofType 연산자가 추가로 제공됩니다. .ofType('ACTION_TYPE').filter(action => action.type === 'ACTION_TYPE')와 동일합니다.

Epic

redux-observable에서는 액션이 들어오는 이벤트를 받아서 추가적인 액션을 발생시킬 수 있습니다. (이미 들어온 액션을 바꾸거나 없앨 수는 없습니다.) 이렇게 액션의 Observable을 추가로 발생시킬 액션의 Observable로 바꿔주는 함수를 Epic이라고 부릅니다. 그림으로 보면 다음과 같습니다.

그림

Epic은 '서사시'라는 뜻인데 Epic이 실행되는 동안 발생하는 액션을 어떻게 처리할지에 대한 이야기이기 때문에 그런 이름이 된 것이 아닐까 생각합니다.

PING 액션을 받아서 PONG 액션을 발생시키는 가장 간단한 Epic을 생각해볼 수 있습니다. (별로 쓸모는 없지만)

function pingEpic(action$) {
return action$.ofType('PING')
.map(action => ({ type: 'PONG' }));
}

실제로도 유용할 것 같은, 액션을 받아서 비동기 API를 호출하고 성공 액션을 발생시키는 가장 기본적인 Epic은 다음과 같이 생겼습니다.

function fetchPostsEpic(action$) {
return action$.ofType('FETCH_POSTS')
.mergeMap(action =>
getPosts()
.map(posts => ({ type: 'FETCH_POSTS_SUCCESS', payload: posts }))
.catch(err => Observable.of({
type: 'FETCH_POSTS_ERROR', payload: err, error: true
}))
);
}

리듀서는 이런 식으로 만들 수 있을겁니다.

function reducer(state = {}, action) {
switch (action.type) {
case 'FETCH_POSTS':
// Epic과 무관하게 FETCH_POSTS는 리듀서로 들어옵니다!
return { isLoading: true };

case 'FETCH_POSTS_SUCCESS':
return { isLoading: false, posts: action.payload };

case 'FETCH_POSTS_ERROR':
return { isLoading: false, error: action.payload };

default:
return state;
}
}

여러 Epic 합성하기

일반적으로 처리하는 액션 타입에 따라 여러 개의 Epic을 만들어서 합성하여 사용하게 됩니다. 합성은 combineEpics 함수를 사용하고, 이렇게 합쳐져서 최종적으로 만들어진 Epic을 Root Epic이라고 합니다. (리듀서를 combineReducers로 합쳐서 루트 리듀서를 만드는 것과 비슷합니다)

import { combineEpics } from 'redux-observable';

const rootEpic = combineEpics(
pingEpic,
fetchPostsEpic,
);

적용

의존성 설치

npm으로 rxjsredux-observable을 설치합니다.

Epic Middleware 추가하기

Epic을 실제로 적용하려면 미들웨어를 통해서 Redux 스토어에 붙입니다.

import { createStore, applyMiddleware } from 'redux';
import { createEpicMiddleware } from 'redux-observable';

const epicMiddleware = createEpicMiddleware(rootEpic);

const store = createStore(
rootReducer,
applyMiddleware(
epicMiddleware,
...
)
);

실전 팁

프로젝트 구조

공식 문서에서는 Ducks 패턴을 추천하고 있습니다. Ducks 패턴은 연관된 액션 타입, 액션 크리에이터와 리듀서를 하나의 모듈로 묶는 방식인데 여기에 Epic이 추가되는 겁니다.

Epic에서 스토어 상태 가져오기

사실 Epic의 두번째 파라미터로는 Redux 스토어가 들어옵니다. 따라서 필요할 때 getState()를 호출하여 스토어 상태에 따라 액션을 처리할 수 있습니다.

function addCommentEpic(action$, store) {
return action$.ofType('ADD_COMMENT')
.mergeMap(action => {
const { currentUser } = store.getState();
return addComment(currentUser, action.body)
.map(...);
})
}

비동기 요청 취소하기

RxJS의 takeUntil 연산자를 적용하면 특정 액션이 들어올 때 동작을 취소할 수 있습니다.

function fetchPostsEpic(action$) {
return action$.ofType('FETCH_POSTS')
.mergeMap(action =>
getPosts()
.map(posts => ({ type: 'FETCH_POSTS_SUCCESS', payload: posts }))
// FETCH_POSTS_CANCEL 액션이 들어오면 구독 취소
.takeUntil(action$.ofType('FETCH_POSTS_CANCEL'))
);
}

액션 종료 시에 알림 받기

Epic의 구조상 액션을 dispatch하는 곳에서 액션 처리가 완료된 것을 알기 어렵습니다. 모든 것을 Redux에서 관리하는 것이 최선이긴 하지만 때로는 탈출구가 필요하기도 합니다.

어쩔 수 없을 때는 redux-observable에 올라온 이슈에서 힌트를 얻어서 액션에 콜백을 같이 넘기는 방법을 사용해볼 수 있습니다. (콜백보다는 Promise나 RxJS의 Subject를 사용하면 약간 더 깔끔합니다.)

function fetchPostsEpic(action$) {
return action$.ofType('FETCH_POSTS')
.mergeMap(action =>
getPosts()
.do(posts => {
if (action.meta.callback)
action.meta.callback(posts); // 밖에 알려주기
})
.map(posts => ({ type: 'FETCH_POSTS_SUCCESS', payload: posts }))
);
}

dispatch({
type: 'FETCH_POSTS',
meta: { callback: () => console.log('done!') }
});

RxJS로 React 컴포넌트 상태 관리하기

· 약 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를 사용해보는 것도 나쁘지 않을 것 같습니다. 물론 상태를 최소화하는 방법을 먼저 생각해보는 게 중요합니다.

애니메타의 React 서버 렌더링 아키텍쳐

· 약 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에 올리기: 조금 더 안정화가 되면 올려보려고 생각 중입니다.

React 소스 코드 읽기 - 유틸리티들

· 약 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에 구현되어 있습니다.

React 소스 코드 읽기 - ReactElement

· 약 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의 내부 구현은 그동안 자주 바뀌어왔고 앞으로도 언제든지 바뀔 수 있습니다.

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/ 디렉토리 안에 있다.

GraphQL과 Relay: 웹 애플리케이션 개발의 미래

· 약 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 아키텍쳐 (출처)

더 읽어보기

jQuery to React

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