React Application Performance Optimization
What comes first?
암호화폐 거래소 GOPAX의 프론트엔드 개발자로서 퍼포먼스를 가장 중요하게 생각하고 두번째로는 생산성 그 다음이 안정성이다. 당연히 거래소니까 안정성을 먼저 생각해야 하는거 아니냐 라고 이야기하기 쉽지만, 실제로 유저들은 퍼포먼스에 매우 민감하게 반응하고, 심각한 오류가 아닌 이상 무시하는 경우가 대부분이다. 오히려 필요하다고 생각하는 어떤 기능이 개발 되지 않을때 악성 피드백을 주는 경우가 많고, 지금과 같이 빠르게 변하는 시장 상황에서는 productivity over stability 라고 우선은 판단하고 있다. (버그는 늘 죄송합니다 ㅜ ㅜ) 물론 안정성은 그 과정에서 최대한 희생하지 않으려 노력하고 있다.
Preliminary
GOPAX 에서는 남들 다쓰는 redux 로 state 를 관리하고 있다. redux 자체는 단방향 데이터 플로우를 함수형 패러다임으로 설계한 경량 라이브러리로 퍼포먼스적인 면에서 희생되는 부분은 크지않다. 만약 redux 의 dispatch -> reducer -> store 로의 흐름이 브라우저에 부담을 준다면 그건 라이브러리의 문제보다는 설계상의 결함이나 코드의 복잡성을 확인하는 편이 좋다.
redux 에서 비동기 관리는 redux-thunk 와 redux-observable 을 둘 다 사용하고 있다. 이건 과도기이기 때문이기도 한데, redux-thunk 이해하기 쉽고 쓰기에 편리하기는 하지만 코드의 가독성이 떨어지고 API 의 숫자가 많아질수록 action creator 와 scaffolding 이 산만해지는 경향이 나타나서 legacy 로 판단해 걷어내려고 하고 있다.
GOPAX 의 경우 모든 API 를 WebSocket 으로 주고 받기 때문에 redux-thunk 로 관리하기 위해 약간의 HACK 을 감수해야 했다. WebSocket 통신을 위해 redux action creator 를 promise 로 래핑한 형태로 만들고, 이를 socket 메시지를 받으면 sequence 를 확인해서 resolve 함수를 부르는 방식으로 구성되었다. 코드 자체는 동작하지만 redux action creator 가 promise 객체를 리턴하는 방식 자체가 임기응변이었고, request 와 response 가 완전하게 분리되어있는 WebSocket 의 특성상 socket response 를 받고 나서 다시 resolve 와 reject 를 호출해야 하는 번거로움이 있어, 하나의 코드 상에 API 흐름을 표시하는 패턴을 만들 수 없었다.
때문에 rxjs 의 fromEvent 로 웹소켓 이벤트를 래핑하여 stream 을 만들었고, redux-observable 의 epic 안에서 subscription 을 핸들링 하는 방식으로 구조를 개선하였다.
export const socketStream = Observable
.fromEvent(WebSocket, 'data')
.share()
아래 처럼 redux-observable 의 mergeMap 을 적극적으로 활용하면 실제로는 완전 비동기인 request 와 response 를 하나의 프로세스 처럼 관리할 수 있다.
export const getSomething = action$ => action$
.ofType(GET_SOMETHING)
.mergeMap(action => Observable.concat(
Observable.of(createRequest('GetSomething', action.payload)),
socketStream
.takeUntil(action$.ofType(GET_SOMETHING_SUCCESS, GET_SOMETHING_FAILED))
.filter(response => response.type === 'GetSomething')
.map((response) => {
if (response.result) {
return getSomethingFailed(response.error)
}
return getSomethingSuccess(response.data)
})
))
단, 이렇게 하는 경우에 네트워크의 유실 등의 이유로 인해서 GET_SOMETHING_SUCCESS
혹은 GET_SOMETHING_FAILED
가 불리지 않게 되면, 모든 websocket stream 을 체크하는 socketStream 에 대한 subscription 이 해제되지 않아 메모리 누수현상이 나타난다. 이는 화면을 오랫동안 열어두는 HTS 어플리케이션인 경우 문제가 누적되어 브라우저 속도를 느리게 만든다.
export const getSomething = action$ => action$
.ofType(GET_SOMETHING)
.mergeMap(action => Observable.concat(
Observable.of(
createRequest('GetSomething', action.payload)
),
Observable.race(
Observable
.timer(3000)
.mapTo(getSomethingCancelled())
socketStream
.filter(response => response.type === 'GetSomething')
.map((response) => {
if (response.result) {
return getSomethingFailed(response.error)
}
return getSomethingSuccess(response.data)
}),
)
.takeUntil(action$.ofType(
GET_SOMETHING_SUCCESS, GET_SOMETHING_FAILED, GET_SOMETHING_CANCELLED
))
))
위와 같이 시간이 지나면 강제로 unsubscribe 하는 방식으로 좀더 안정적으로 구현할 수 있다. GOPAX 에도 빨리 도입해야지... 다만, 소켓 통신은 특성상 워낙 request 에 대한 response 가 빨라서 이런 케이스가 발생하는 일은 거의 없다.
Memory Optimization
GOPAX 웹 프론트엔드 어플리케이션은 최대한 하드코딩을 지양하고 실시간으로 에셋이나 트레이딩 페어들을 업데이트를 반영하고 있기 때문에 당연히 redux 내부 store 의 state 변화에 민감하게 반응해야 한다 -> 는 곧 store 에 많은 데이터를 가지고 있어야 함을 뜻한다.
특히 오더북 같은 많은 데이터를 핸들링 해야 하는 경우와 최근 거래 기록 혹은 노티피케이션 등 같이 계속해서 쌓이는 종류의 데이터들은 시간이 지남에 따라 브라우저 메모리에 많은 부담을 준다. 실제로 테스트 과정에서 메모리 부족으로 인한 Major GC 가 여러번 일어났고 브라우저는 점점 느려져서 나중에는 다운될 지경에 이른 적이 있었다.
1. Array.prototype.slice()
80%의 문제는 이걸로 해결되었다. 그려질 필요도 없는 너무 많은 데이터가 redux store 에 저장되지 않도록 outdated 된 데이터 혹은 화면에 그려질 필요가 없는 기록들은 메모리에서 지워주었다.
const reducer = (state, action) => {
...
case NOTIFICATION_RECEIVED:
return {
...state,
notifications: [
action.payload.notification,
..._.take(state.notifications, 24),
],
}
...
}
사실 위와 같은 구조는 추천하지 않는다. redux 는 특수한 경우가 아니고는 nested object 나 array 를 구성하지 않는편이 좋다. 코드 실수로 immutable 을 보장하지 못하게 된 경우에 shallow compare 에서 문제가 생기기 때문이다.
const notificationReducer = (state, action) => {
...
case NOTIFICATION_RECEIVED:
return [
action.payload.notification,
..._.take(state.notifications, 24),
]
...
}
그렇게 하더라도 아래처럼 reducer 안에서 다시 reducer 를 부르는 형태로 구성하는 편이 좋다. 그래야 코드도 간결해지고 reducer 상에서 코드가 지나치게 복잡해져서 버그를 만들어내는 것을 피할 수 있다.
const reducer = (state, action) => {
...
case NOTIFICATION_RECEIVED:
return {
...state,
notifications: notificationReducer(state.notifications, action),
}
...
}
2. reselect
어떤 데이터의 형태가 다음과 같다고 가정하자.
const data = [{
symbol: 'ETH',
fullName: 'Ethereum',
}, {
symbol: 'BTC',
fullName: 'Bitcoin',
}, {
...
}]
ETH
라는 symbol 에 해당하는 fullName Ethereum
를 얻기 위해서 lodash 의 find 함수를 사용하면 다음과 같은 코드를 작성할 수 있다.
const getFullNameFromSymbol = (symbol) => _.find(data, { symbol }).fullName
이 코드를 react-redux 의 connect 를 활용하여 component 에서 호출할 수 있게 만들면 다음과 같은 형태가 된다.
const mapStateToProps = (state) => ({
getFullNameFromSymbol: (symbol) => _.find(state.data, { symbol }).fullName
})
export default connect(mapStateToProps)(SomeComponent)
이런 경우 symbol 검색에 대한 시간 복잡도는 O(n) 이 된다. 사실 대부분의 웹 어플리케이션에서 이러한 데이터는 많지 않기 때문에, 퍼포먼스 문제가 발생하는 가능성은 대단히 적다. 다만, 이러한 코드가 다양한 컴포넌트에서 여러번 불리게 되거나, 지나치게 변경이 많은 경우 혹은 모바일 디바이스와 속도가 느린 경우에는 이 역시 큰 부담이 될 수 있다.
때문에 보통 이러한 코드들은, closure 를 사용하여 아래와 같은 형태로 작성할 수 있다.
const mapStateToProps = (state) => ({
getFullNameFromSymbol: (symbol) => {
const dataBySymbol = _.groupBy(state.data, 'symbol')
return dataBySymbol[symbol].fullName
}
})
이렇게 되면 한 컴포넌트에서 여러번 해당 함수가 불릴 경우에는 시간복잡도가 O(1) 으로 줄어들지만 dataBySymbol
을 만들때 groupBy 연산이 O(n) 인 만큼 여러 컴포넌트에서 사용될 경우 큰 이점이 없다.
const getFullNameFromSymbolSelector = (() => {
let previousData = null
let dataBySymbol = null
return (data) => {
if (!dataBySymbol || data !== previousData) {
dataBySymbol = _.groupBy(state.data, 'symbol')
previousData = data
}
return (symbol) => dataBySymbol[symbol].fullName
}
})()
const mapStateToProps = (state) => ({
getFullNameFromSymbol: getFullNameFromSymbolSelector(state.data)
})
그러면 위처럼 memoization 과 selector 패턴을 이용하면 data 의 변화가 있을 경우에만 dataBySymbol 을 변경하여 반복된 계산이 일어나는 것을 방지할 수 있다. 물론 dataBySymbol
만 selector 로 만들어 component 에서 직접 호출하도록 방식을 변경하면 이러한 경우 훨씬 더 날씬한 효율적인 container props 형태를 만들어낼 수 있다.
const dataBySymbolSelector = (() => {
let previousData = null
let dataBySymbol = null
return (data) => {
if (!dataBySymbol || data !== previousData) {
dataBySymbol = _.groupBy(state.data, 'symbol')
previousData = data
}
return dataBySymbol
}
})()
const mapStateToProps = (state) => ({
dataBySymbol: dataBySymbolSelector(state.data)
})
class SomeComponent extends Component {
...
getFullNameFromSymbol: (symbol) => dataBySymbol[symbol].fullName
...
}
이것을 좀 더 편리하게 패턴화 시켜주는 라이브러리가 reselect 이다. reselect 를 활용하면 위와 같은 코드를 다음과 같이 간단하면서 가독성을 높이는 방식으로 작성할수 있다.
import { createSelector } from 'reselect'
const getData = state => state.data
const dataBySymbolSelector = createSelector(
getData,
(data) => _.groupBy(data, 'symbol')
)
위 코드의 getData
를 input-selector
라고 부르는데 이 데이터들이 memoization 되어 변경 사항이 없을 경우 _.groupBy
연산을 하지 않고 기존에 만들어져있는 object 를 리턴해주게 된다. 이 패턴은 퍼포먼스적인 개선 뿐만 아니라 component 사이에 공유할 수 있는 selector 형태를 connect 함수에 강제하여 코드 패턴의 일관성을 보장해준다.
Render Optimization
React는 간단하게 말하면 state 와 props 가 변경될때마다 render 함수가 불리고 변경 사항이 browser 에 반영하는 라이브러리라고 할 수 있는데 바로 이 점이 많은 혼란을 가져온다. 암호화폐 거래소는 변동이 심할경우 1초에 몇번이나 가격 변동이 일어나고 이를 server 에서 socket broadcasting 을 통해서 브라우저에 전달한다. 자신의 거래 행위는 자신을 포함한 다른 모든 접속자에게 해당 이벤트를 전달시키고, 이러한 행위자가 수백 수천명이 되면 서버 부담은 백엔드가 알아서 하겠지만 브라우저의 렌더링 빈도가 크게 올라간다.
1. connect redux state
container 를 구현할때 편의를 위해 아래와 같은 코드를 사용하는 경우가 있는데 이는 대단히 잘못된 방식이다.
const mapStateToProps = (state) => ({
...state
})
export default connect(mapStateToProps)(SomeComponent)
이러한 방식으로 코드를 작성하게 되면 모든 state 가 component 에 바인딩되니 편할수는 있어도 redux 가 그야말로 손톱하나만 까딱해도 component 에 변경사항이 전파되고 re-rendering 이 일어난다. 때문에 필요한 state 만 선택하여 render 함수가 최소한으로 불리게 하는 것은 기본 중의 기본이다.
... 라고 생각하겠지만 실제로는 퍼포먼스 면에서 그다지 손실이 크지 않다. React 는 대단히 잘만들어진 라이브러리로 이미 상당한 수준의 virtual dom 렌더링 최적화가 되어있다. 연산량이 많아지는 것은 사실이라도 일반적인 경우 rendering 으로 인한 UI block 현상이나 밀림 현상은 목도하기 어렵다. 그렇다곤 하더라도 업데이트가 매우 많은 거래소와 같은 어플리케이션이나 모바일 브라우저 혹은 저사양 컴퓨터, 구형 브라우저 혹은 IE 개개끼들 에서는 영향을 줄 수 있으니, 반드시 필요한 state 만 바인딩 하도록 하자.
2. shouldComponentUpdate
React 문서 에서 퍼포먼스 문제가 생길 경우 shouldComponentUpdate
를 적극적으로 활용하라고 하지만, 실은 디버깅을 어렵게 만드는 버그의 원흉이 되기도 한다.
shouldComponentUpdate(nextProps, nextState) {
return nextProps.data !== this.props.data
&& nextState.isLoading !== this.state.isLoading
}
이렇게 코드를 써두고 개발중 혹은 새로운 기능 추가로 인하여 state 나 props 에 상태변수가 추가되는 경우 분명히 변경사항이 props 나 state 에는 적용되었는데도 도무지 component 가 반응하지 않는 매직 현상이 나타날 수 있다. 사실은 반응하지 않는다기보다는 이상하게 늦게 표시되거나 (state 가 변경되었지만 shouldComponentUpdate 에 의해서 억제되었고, props 가 그 이후에 변경되어 반영되는 현상), 간헐적으로 표시되는 것처럼 보이는 경우가 생긴다.
게다가 1. connect redux state
에서도 썼듯이 초당 수십번의 업데이트가 있는 매우 "바쁜" 컴포넌트가 아니고서야 애초에 퍼포먼스 부스트를 경험할 프로젝트 자체가 많지 않다. "초당 수십번의 업데이트" 는 곧 서버에서 "초당 수십번의 리퀘스트" 를 날려야 하는 것인데 애초에 ajax http request 는 그렇게 빠르지 않고 그러한 것을 요하는 프로젝트 또한 흔치 않다.
때문에 shouldComponentUpdate 에 의존하기 보다는 애초에 컴포넌트에서 DOM 변경이 필요한 props 와 state 를 최적화 시켜 작성하는 편이 더 낫다. 그럼에도 불구하고 shouldComponentUpdate 를 사용해야 할 때가 있는데 GOPAX 의 경우 오더북 이다. 오더북은 데이터의 양도 많거니와 사용자가 오더 한줄을 추가할때마다 루프를 돌면서 데이터를 가공해줘야 하고 또 각 오더 엔트리에는 변경사항을 깜빡임을 통해 표시하는 UI 까지 가지고 있어서 최소한으로 render 함수를 호출하도록 구성해야 한다.
export const createDiffChecker = (diffs) => (prev, next) => _.some(diffs, (diff) => (
_.at(prev, diff)[0] !== _.at(next, diff)[0]
))
const stateDiffChceker = createDiffChecker(['isLoading'])
const propsDiffChecker = createDiffChecker(['data'])
class SomeComponent extends Component {
...
shouldComponentUpdate(nextProps, nextState) {
return stateDiffChecker(nextState, this.state) || propsDiffChecker(nextProps, this.props)
}
...
}
shouldComponentUpdate 를 사용해야 하는 경우에는 위와 같은 pattern 을 만들어서 사용하자.
3. static getDerivedStateFromProps
React 16 에서 생긴 새로운 method 중 async rendering 을 지원하는 life cycle method 인 getDerivedStateFromProps
도 써볼만하다.
다만 static method 이기 때문에 this
context 에 접근할 수 없고 return 한 object 가 state 에 업데이트 되는 방식으로 구성되어 있다.
class SomeComponent extends Component {
...
static getDerivedStateFromProps(nextProps, prevState) {
if (nextProps.data !== prevState.data) {
return {
data: nextProps.data,
}
}
return null
}
...
}
prevProps
에 접근할 수 없기 때문에 그렇게 효율적인 방식인지는 의문이 생긴다. memoization 을 활용하는 방식을 제안하고 있으니 관심이 있으면 링크를 클릭해보자. 다만 이 method 는 렌더링 속도를 개선하기 위함이 아닌 UI blocking 을 막기 위한 비동기 렌더링임을 유념하자.
One More Thing
사실은 위의 모든 방법을 동원해도 해결되지 않는 지점이 있다.
고팍스의 EOS/KRW 오더북을 예를 들어보자. 15,415원
에 366.2801 개의 EOS 를 구매하기 위한 오더를 넣게 되면, 엄청난 일이 일어난다. 15,405원
, 15,410원
, 15,415원
세줄의 매도 주문을 매수하는 것으로 보이지만 오더 한줄은 동일한 가격으로 여러사람이 넣은 매도 주문들을 합한 결과물이다. 또한 매도 주문 하나가 매수 주문에 의해 체결될때는 기본적으로 두개의 이벤트가 발생하게 된다. 15,405원 에 해당하는 EOS X개 가 내 지갑으로 들어오게 되는 transaction
이 일어나고 해당하는 오더를 다른 유저들의 오더북 에서 삭제해주어야 하기 때문에 order
가 변경되는 이벤트 가 또 발생한다.
따라서 오더 한줄을 지울때마다 오더 한줄에 숨어있는 2x
개의 오더 이벤트가 발생하고 매수 주문이 3 줄을 긁는 결과가 된다면 3 * 2x
즉 최소 6 개의 이벤트가 모든 브라우저에 전파된다. 실제로는 6개가 훨씬 넘는 이벤트가 동시에 전파될 것이라고 예상할 수 있다. 아무리 react 와 react-dom 이 최적화 되어있는 라이브러리라 해도 초당 수십개가 넘는 이벤트를 처리하기 위해서는 UI blocking 을 피할수 없다.
redux 자체는 immutable functional programming 을 지향하다 보니 퍼포먼스에 최적화 되어있는 라이브러리라고는 할 수 없지만, 위에서도 언급했듯 state 하나가 변경되는 작업이 browser 를 blocking 할 정도로 느리다면 이는 라이브러리의 문제가 아니라 코드 자체의 최적화 문제라고 할 수 있다. 사실 redux 에서 performance 문제는 별로 발생하지 않고 (발생해도 고치기가 쉽지 않다) 대부분 react-redux
의 connect
에서 일어나게 된다.
connect 함수는 기본적으로 action 이 dispatch 될때마다 mapStateToProps 함수를 호출한다. 따라서 초당 수십번의 이벤트로 인해 mapStateToProps
함수가 호출되고 이에 따라서 state 변경 사항이 child components 로 propagation 된다면 모든 업데이트가 적용될때까지 browser 가 얼어붙는 것이 어찌보면 당연한 이야기이기도 하다.
그런데... 꼭 그래야만 하나? 사람의 눈은 초당 60프레임까지 충분히 인식하고 그 이상이 가능한 사람도 있으나 그렇다고 해서 매초마다 의사결정을 내릴 수 있는 것도 아니고, 그럴 필요도 없다. 따라서 초당 모든 업데이트를 반영하기 보다는 batch 로 업데이트 하면 프론트엔드 개발자의 적 UI blocking 이 일어나지 않게 된다. redux enhancer 패턴을 활용하면 간단하게 작성할 수 있고, 이미 몇가지 매직 라이브러리들이 만들어져 있다.
import { debounce } from 'lodash'
import { createStore } from 'redux'
import { batchedSubscribe } from 'redux-batched-subscribe'
const debounceNotify = debounce(notify => notify(), 1000 / 60)
const store = createStore(reducer, intialState, batchedSubscribe(debounceNotify));
GOPAX 는 redux-batched-subscribe 라이브러리를 사용하여 초당 60 프레임을 기준으로 업데이트가 debounce
되도록 하였고, 지금은 관련된 퍼포먼스 문제가 발생하지 않고 있다. 다만, debounce
의 특성상 업데이트 지연 현상이 오랫동안 발생할 수 있어 조만간 throttle
방식으로 변경할 예정이다. redux-batched-subscribe
코드는 다음번에 redux middleware 관련 블로그 포스트를 작성하면서 코드를 뜯어보도록 하겠다.
Conclusion
React 와 Redux 는 잘 만들어졌고 조합 또한 좋기 때문에 코드를 정상적으로 썼다는 전제 하에 퍼포먼스 문제가 생기는 프로젝트가 많지 않다. GOPAX 라는 실시간 암호화폐 거래소 어플리케이션을 개발하고 유지보수 하지 않았다면 browser 가 터져 나가는 느려질 정도의 경험을 하긴 어려웠을 것이다.
많은 문제는 약간의 실수를 피하는 것 만으로도 해결 된다. 생각보다 browser 메모리는 한정적인 자원이고, 때문에 오랫동안 메모리에 상주하고 데이터가 누적되는 어플리케이션 구조를 가지고 있다면 효율적으로 사용하는 것이 필수적이다. 실제로 많은 문제는 연산량이나 렌더링 복잡도 보다는 메모리 용량문제로 인한 garbage collection 때문에 생겨났고, redux store 를 최적하는 것으로 쉽게 고칠 수 있었다. 어플리케이션의 퍼포먼스를 개선하기 위해서는 어떤 정답을 찾기 보다는, 생각할 수 있는 다양한 원인을 찾아보고 문제가 될 수 있는 병목을 최대한 제거하는 것이 좋은 접근방식이라고 할 수 있겠다.