2019–06–03 이 글을 계기로 리액트 책을 썼습니다.
2020–08–18 인프런에 리액트 강의를 오픈했습니다.
그동안 react로 여러 사이트를 만들어봤지만 대부분 사내 직원용 사이트 수준이었다. 기껏 해봐야 CMS를 만들어 본 것이 가장 큰 규모였다. 그러다 최근에 카카오페이지 웹을 react로 포팅하는 재밌는 경험을 하게 됐다. 카카오페이지 웹은 6년 차에 접어든 나름 오래된 서비스다. 내부 코드를 들여다보면 그간의 무수한 프로그래머들의 근심과 걱정의 흔적들이 고스란히 베여있다. 이 서비스의 전체 코드를 포팅하겠다고 선언한 게 본인이었지만 두려움도 컸다. 결정적으로 본인이 카카오페이지 웹 프로젝트에 1도 관여한 적이 없었다(0.5정도 기여했을라나). 단지 react에 관심이 많았고, 어쩌다가 웹팀을 맡게 되었다.
기존 프로젝트는 spring과 jsp 기반으로 되어있었다. API 서버를 호출하는 게 아니라 spring controller에서 데이터를 만들면 jsp가 받아서 렌더링 하는 방식이었다. react로 포팅한다는 것은 SPA(single page application)을 만들겠다는 말이고, API 서버는 별도로 존재해야 한다는 걸 의미한다. 다행히 카카오페이지 모바일 앱에서 사용하는 API가 있었다. 딱 맞는 옷은 아니었지만 적당히 수선해서 입을만했다.
프로젝트를 진행하기 전에 결정해야 될 사항은 다음과 같았다.
- 서버사이드 렌더링(SSR)을 할 것인가?
- SSR을 한다면 어떤 프레임워크를 사용할 것인가?
- 정적 타입을 적용할 것인가?
- 정적 타입을 적용한다면 flow? typescript?
서버사이드 렌더링(SSR)에 대한 고민
SSR은 보통 두 가지 장점을 가진다.
- 장점 1: 검색엔진 최적화(SEO)
- 장점 2: 사용자 입장에서의 빠른 첫 페이지 렌더링
장점 2는 저사양 스마트폰 사용자가 많고, 네트워크 인프라가 약한 나라에서는 큰 도움이 된다. 보통 장점 2를 얘기할 때 인도를 예로 많이 드는데 우리나라 환경에서는 큰 장점이 못 된다는 게 개인적인 생각이다.
SSR은 공짜가 아니다. 코드 복잡도가 크게 증가하고 서버 관리 부담도 생긴다. 하지만 B2C 서비스라면 대부분의 경우 SEO는 선택이 아니라 필수다. SEO 때문에 SSR을 적용하기로 결정했다.
SSR을 위해서 어떤 프레임워크를 사용할 것인가
이전 글에서 설명했듯이 크게 보면 create-react-app 기반으로 SSR 환경을 직접 구축하는 방법과 이미 SSR 환경이 구축되어있는 next.js를 선택하는 방법이 있다. 개인적으로 create-react-app을 좋아하지만 SSR 개발 환경을 손수 구축하는 건 결코 만만한 일이 아니다. 빌드 환경이야 금방 구축하겠지만 개발 편의를 위해 next.js가 제공하는 기능을 손수 개발하려면 배보다 배꼽이 더 커질 수 있다. 그래서 next.js를 선택했다.
정적 타입 도입에 대한 고민
javscript는 동적 타입 언어다. 따라서 변수의 타입은 런타임에 결정된다. 이와 대조적으로 java는 정적 타입 언어다. 따라서 변수의 타입은 컴파일 타임에 결정된다. 마찬가지로 python은 동적 타입 언어이고, C++은 정적 타입 언어다. 그 개념은 단순하지만 개발 시 큰 차이가 존재한다.
동적 타입 언어는 타입에 대한 고민을 많이 하지 않아도 되기 때문에 배우기 쉽고 코드의 양이 적을 때 생산성이 좋다. 간단한 배치 스크립트를 작성할 때는 (반대하는 사람도 있겠지만) 동적 타입 언어가 좋다고 할 수 있다. 문제는 큰 규모의 프로젝트다. 동적 타입 언어는 변수 이름을 잘 못 입력해도 런타임 에러가 발생한다.
하지만 내가 정작 타입 언어를 도입 하려는 건 생산성 향상의 이유가 더 크다. 동적 타입 언어는 IDE 차원에서 지원할 수 있는 기능이 매우 제한적이다.
속성값 리스트업
product라는 오브젝트 안에 속성값이 10개 이상 있다고 가정해보자. 동적 타입 언어로 코드를 작성할 때는 프로그래머가 product의 속성값을 머릿속으로 기억하고 있어야 한다. 하지만 정적 타입 언어는 product.
을 입력하면 IDE가 product의 속성값을 리스트업 해준다.
Auto Import
이건 타입 시스템의 문제라기보다는 우리 팀 전원이 사용 중인 vscode에서 typescript는 지원해주는데 javascript는 지원하지 않고 있다. 아마 동적 타입 언어에서는 auto import 기능 구현이 까다로운 게 아닐까 싶다.
Rename Symbol
변수 이름은 바뀔 일이 많다. 변수 이름이 독특해서 찾아 바꾸기로 해결되면 다행이지만 그렇지 않은 경우도 많다.
그 밖의 장점으로는 타입 자체가 훌륭한 문서가 된다는 점이다. javascript로 작업을 한다면 별도의 문서를 남기거나 주석으로 타입 정보를 표현할 수밖에 없다. react 컴포넌트에는 prop-types가 있지만 prop이 함수 타입인 경우에는 충분히 표현이 안된다. 지금까지 나열한 여러 장점 때문에 정적 타입을 도입하기로 결정했다.
Flow vs Typescript
javascript에 정적 타입을 지원해주는 여러 라이브러리가 있지만 flow와 typescript가 대표적이다. flow는 react를 개발하고 있는 페이스북에서 만들었기 때문에 react를 특별히 잘 지원해주겠지라는 생각도 있었다. 하지만 typescript도 충분히 react를 잘 지원해주고 있고 flow에 비해 react에 대한 지원이 부족하다는 느낌을 못 받았다. typescript는 언어 레벨이고 flow는 babel을 통해서 지원한다는 기술적인 차이가 있지만, 결정에 가장 큰 영향을 미친 요소는 커뮤니티의 크기였다. typescript 커뮤니티가 훨씬 크고 활발하다고 느꼈다. 웬만한 라이브러리의 타입은 커뮤니티에서 typescript 타입으로 다 만들어놨다. 그래서 typescript를 쓰기로 결정했다. 팀 전원이 vscode를 사용한다는 점도 결정에 한몫했다.
일반 팁
prettier를 사용하자
prettier는 코드를 자동으로 포맷팅해주는 툴이다. 개인적으로 너무 만족해서 기회가 될 때마다 한 번 써보라고 권해주는 툴이다. eslint에도 자동 포맷팅 기능이 있지만 prettier가 훨씬 잘한다. eslint는 포맷팅도 지원하지만, prettier는 포맷팅만을 위해 나온 툴이니 당연한 결과다.
‘코드 포맷팅은 싫고, 내 코드는 내 스타일대로 하고 싶다’라고 말하는 사람도 있다. 그래 뭐 개인의 취향은 존중한다. 하지만 2명 이상이 협업할 때는 코딩 컨벤션이 있는 게 좋고, 그 컨벤션을 맞추기 위해 시간을 투자하기보다는 툴을 사용하는 게 백번 낫다. 파일 저장 시마다 prettier가 돌도록 설정하는 걸 추천한다.
빠른 첫페이지 로딩
카카오페이지 홈 화면을 보여주기 위해서는 많은 수의 이미지를 로드해야 한다. UI 이미지를 제외하고 컨텐츠 이미지만 포함해도 약 35개 정도 된다. 기존 시스템에서는 이 35개의 이미지가 우선순위 없이 서로 경쟁적으로 로드됐다. 그런데 가려진 영역을 제외하고 사용자에게 보이는 컨텐츠 이미지는 많아봐야 5개 정도다. 네트워크가 느리거나 저사양 디바이스를 사용하는 사용자를 위해 이미지 로딩에 우선순위를 적용하는 게 좋다. 5개를 먼저 로드하고 나머지 30개를 로드하도록 개선했다.
android 2.3 지원
2018–12–01 지금은 android 2.3을지원하지 않습니다.
카카오페이지는 사용자 층이 넓은 만큼 저사양 폰도 지원해야 한다. android 2.3은 그만 지원하자고 말을 꺼내봤지만 통하지 않았다 (다른 서비스 중에 android 2.3을 지원하는 곳이 있는지 궁금한데 혹시 이 글을 읽고 있는 분들 중에 아시는 분은 댓글 부탁드립니다). 아무튼 android 2.3을 지원해야 한다. QA 팀에서 android 2.3 기기를 빌려왔다. next.js로 헬로월드를 실행해보니 안된다. 물론 첫 페이지는 서버 렌더링을 하기 때문에 눈으로는 보이지만 자바스크립트가 먹통이다. 호기심에 create-react-app도 돌려봤지만 역시 안된다. 잠깐 써보면서 박물관에나 있을 법한 기기라고 생각하며 디버깅을 시작했다. 알게 된 사실은 android 2.3에서는 ES5를 부분적으로만 지원한다. 우선 function.bind 기능이 없어서 bind 폴리필을 사용했다. 이 번엔 다른 에러가 나왔다.
unexpected token default
한참의 삽질 끝에 default 같은 예약어를 오브젝트의 속성 이름으로 사용할 때 에러가 발생한다는 걸 알게 됐다.
- obj.default
- const obj = {default: “some string”}
위 코드를 다음과 같이 변경해야 android 2.3에서 에러가 안 난다.
- obj[“default”]
- const obj = {“default”: “some string”}
다행히 이걸 자동으로 해주는 바벨 플러그인이 있다.
- transform-es3-member-expression-literals
- transform-es3-property-literals
문제는 이 플러그인을 .babelrc에 등록한다고 해결되지 않는다. next.js, create-react-app 모두 node_modules에 있는 외부 패키지는 속도 문제 때문에 바벨 컴파일에서 제외하기 때문이다. 그래서 현재는 next.js로 빌드 후 위의 두 플러그인으로 바벨 컴파일을 한 번 더 돌리는 방법을 사용 중이다. 이로 인해 next.js 빌드 시 생성된 소스맵이 최종적으로 만들어진 코드와 일치하지 않는 문제가 있다. 하… android 2.3 지원하기는 이 정도로 봉합하기로 했다.
브라우저 캐싱
이미지, 폰트 등의 리소스는 자주 변경되지 않기 때문에 브라우저 캐싱을 이용하면 좋다. 만약 리소스의 url이 다음과 같다고 해보자.
https://page.kakao.com/static/someImage.png
이 방식은 이미지가 변경됐을 때 문제가 된다. 물론 http etag 값을 이용해서 서버에게 리소스가 변경됐는지 매번 물어볼 수도 있지만 썩 좋은 방식은 아니다. 만약 url이 아래와 같고 이미지가 변경됐을 때 해시값도 변경된다면 서버에게 매번 물어볼 필요 없이 항상 브라우저 캐싱을 사용할 수 있다.
https://page.kakao.com/static/someImage.png?hash=oajndgf
이처럼 리소스가 변경됐을 때 url에 해시값을 붙여주는 여러 툴이 있다. 우리는 일반적으로 많이 쓰이는 webpack의 file-loader를 사용했다.
moment vs date-fns
자바스크립트 날짜 라이브러리의 절대 강자는 moment였고 지금도 변함없는 사실이다. 그러나 개인적으로 moment에서 date-fns로 눈을 돌리게 만든 moment의 두 가지 단점이 있다.
- moment 객체는 mutable variable이다.
- 번들 사이즈가 크다.
mutable variable은 버그의 근원이다. 자세한 내용은 지난번 포스트에서 얘기했다. 그리고 moment는 필요한 기능만 임포트 할 수 없다. 물론 타임존은 선택적으로 포함시킬 수 있지만, 그렇게 해도 번들 사이즈가 많이 크다. date-fns는 moment의 이러한 단점을 극복하기 위해 나왔다. date-fns owner의 글도 한 번 읽어보자.
npm 다운로드 그래프를 보면 절대적인 수치는 여전히 moment가 우세하지만 date-fns가 빠르게 상승하고 있다.
서버 렌더링 캐싱
서버 렌더링은 CPU 자원을 많이 사용한다. 서버에 로드가 몰리는데 모든 페이지를 서버에서 렌더링 한다면 부담이 클 수밖에 없다. 사실 대부분의 페이지는 모든 사용자에게 동일한 렌더링 결과를 던져준다. 때문에 렌더링 결과를 캐싱 한다면 서버의 부담을 크게 덜어줄 수 있다. 대표적으로 이벤트 페이지가 서버 렌더링 캐싱으로 크게 효과를 볼 수 있는 페이지다. 10분에 수백만 페이지뷰가 발생한다고 생각해보자. 서버 렌더링 결과를 1분 동안 캐싱해서 사용한다면 아무리 많은 페이지뷰가 발생해도 단 10 번의 서버 렌더링으로 서비스를 할 수 있다. 구현이 궁금하다면 여기를 참고하자.
자바스크립트 파일 압축
안 할 이유가 없다. uglify도 해야 하지만 gzip 압축도 하자. 자바스크립트뿐만 아니라 모든 텍스트 파일은 압축하자.
next.js 팁
next.js를 사용하면서 느낀 점은 create-react-app에 비해서 문서화가 잘 안되어있다는 점이다. 개발하면서 알게 된 next.js 팁을 정리해본다.
initPage HOC
2018–12–01 next.js 버전 7에서는 _app.js를 이용하면 됩니다.
모든 페이지에 공통으로 적용되는 기능은 HOC(higher order component)로 만들어서 사용했다. 로그인 여부, user-agent 파싱 정보 등을 global store에 저장할 때 유용하다. 모든 페이지 컴포넌트를 initPage로 한 번 감싸주면 된다.
이벤트 페이지
next.js를 빌드하면 main.js라는 파일이 만들어진다. main.js는 첫 페이지 로딩 시 클라이언트로 내려주는데, 파일 크기가 제법 크다. main.js에는 절반 이상의 페이지에서 공통으로 사용되는 패키지나 next.js 라우팅 관련 코드가 들어가 있다. 이벤트 페이지와 같이 라우팅이 필요 없는 단일 페이지는 main.js를 클라이언트로 내려줄 필요가 없다. _document.js를 이용하면 main.js는 내려주지 않으면서 next.js의 서버 렌더링을 그대로 사용할 수 있다. 아래 코드에서 isStaticPage 변수를 잘 살펴보자.
import Document, { Head, Main, NextScript } from 'next/document';
import flush from 'styled-jsx/server';export default class MyDocument extends Document {
static getInitialProps({ renderPage, pathname }) {
const { html, head, errorHtml, chunks } = renderPage();
const styles = flush();
const isStaticPage = pathname.startsWith('/event');
return { html, head, errorHtml, chunks, styles, isStaticPage };
}render() {
const { styles, head, isStaticPage } = this.props;
return (
<html>
{isStaticPage && (
<head>
{(head || []).map((h, i) => React.cloneElement(h, { key: h.key || i}))}
{styles || null}
</head>
)}
{!isStaticPage && <Head />}
<body>
<Main />
{!isStaticPage && <NextScript />}
</body>
</html>
);
}
}
next/link 클릭 이벤트
2018–12–01 next.js 버전 7에서는 Link 밑의 onClick 이벤트가 동작합니다.
next.js에서 제공해주는 Link 컴포넌트 사용 시 클릭 이벤트를 붙이고 싶은 경우가 있다. 사용해본 사람은 알겠지만 Link 컴포넌트의 첫 번째 자식에 onClick 이벤트를 붙여도 동작하지 않는다. next.js가 내부적으로 onClick을 사용하기 때문이다. 물론 Link를 사용 안 하고 router를 사용할 수도 있겠지만 SEO를 위해서는 anchor 태그에 href를 입력하고 router에서 또다시 url을 입력해야 하는 번거로움이 있다. github 이슈로도 등록이 됐었지만 next.js 개발팀은 지원할 계획이 없다고 한다. 대표적으로 통계 로그를 보내고 싶은 경우에 꼭 필요한 기능인데 아쉬운 부분이다. 비슷한 고민을 하고 있다면 아래 코드를 참고하자.
Universal Webpack
이건 팁은 아니지만 언급할 필요가 있다. next.js 5부터 next.config.js로 설정하면 서버와 클라이언트 코드 모두에 적용이 된다. 너무나 당연해 보이지만 next.js 4 이하 버전에서는 클라이언트 코드에만 webpack 설정이 적용됐다. 때문에 위에서 언급한 file-loader는 next.js 4 이하 버전에서는 사용할 수 없었다. universal webpack 이 적용되면서 next.config.js에서 사용할 수 있는 다양한 플러그인이 쏟아져 나오고 있다.
클라이언트 렌더링 안하기
도대체 어떤 바보가 react로 개발된 웹에서 클라이언트 렌더링을 안 하겠냐고 반문하겠지만 카카오페이지 웹을 개발하면서 그렇게 할 수밖에 없는 상황이 있었다. 아래 코드에서 에러 객체에 cancelled 속성값을 주는 게 포인트다. 에러 객체에 cancelled 속성값을 붙여주면 getInitialProps 이후의 동작은 모두 취소된다. next.js 공식 문서에는 없고 프레임워크 내부에서 사용하는 값이기 때문에 흑마법과 같은 존재다. 꼭 필요한 경우가 아니라면 사용하지 말자.
서버 렌더링시 주의할 점
전역 변수는 공유 자원이다
아래의 코드를 살펴보자. parsedUserAgent 변수를 생성 후 코드의 이곳저곳에서 가져다가 사용해보자. 이렇게 편할 수가 없다. userAgent 값은 변하지 않기 때문에 상수값이라고 생각하며 맘 편히 사용한다. 개발 시 전혀 문제가 되지 않다가 배포했더니 뭔가 잘 못 됐다는 걸 직감한다. 그렇다 내 얘기다.
클라이언트 렌더링 시에는 전혀 문제가 되지 않는다. 문제는 서버 렌더링 시에 전역 변수는 여러 요청들 사이에 공유된다는 점이다. 서버에서는 하나의 요청이 처리 중일 때 다른 요청이 끼어들 수 있다. 따라서 parsedUserAgent 변수는 자신이 설정했던 값이 아닐 수 있다.
서버와 클라이언트 양쪽 모두에서 실행되는 코드를 작성하는 건 효율적이지만 이런 위험도 뒤따른다. 코드를 작성할 때 이 코드가 서버에서도 실행된다는 사실을 잊지 말자.
서버 렌더링 시 생성된 데이터 전달
서버 렌더링 과정에서 생성된 데이터는 클라이언트로 전달해야 한다. 만약 아래와 같이 코드를 작성했다면 currentCash 데이터는 클라이언트로 전달되지 않는다.
카카오페이지에서는 redux로 상태 관리를 하고 있다. redux는 워낙 많은 사람들이 사용하고 있기 때문에 조금만 검색해보면 서버에서 생성된 redux 상태 값을 클라이언트로 전달하는 코드를 쉽게 찾을 수 있다.
사용자의 화면 크기에 의존적인 코드
서버 렌더링을 하면서 꽤나 골치 아팠던 문제 중 하나는 화면 크기에 의존적인 코드였다. 서버에서는 사용자의 화면 크기를 알 수 없다. 화면 크기를 알 수 없는데 렌더링을 해야 하는 딜레마에 빠진다. 물론 css의 vw, vh 같은 단위를 이용하면 어느 정도 해결은 되지만 안드로이드 4.3 이하에서는 지원하지 않는다. 이때는 안드로이드 하위 버전을 위한 css를 별도로 만드는 것도 하나의 방법이 된다. 아무리 생각을 해봐도 답이 없는 경우에는 componentDidMount 이후에 화면 크기를 이용해야 한다. 물론 클라이언트에서는 마운트 이전에도 화면 크기를 알 수 있지만 서버 렌더링과 클라이언트 첫 렌더링 결과가 다르면 react가 불평을 한다.
화면 크기에 의존적인 외부 패키지도 의외로 많다. react-virtualized의 경우에도 나름 많은 수의 스타 개수에도 불구하고 화면 크기에 의존적이기 때문에 서버 렌더링에 취약하다. 나름 PR도 보내면서 해결책을 찾아보려 했지만 결국 SSR은 포기하고 마운트 이후 시점에 렌더링 하는 걸로 결론이 났다.
앞으로 적용하고 싶은 것
테스트 고도화
현재는 중요한 로직은 유닛테스트를 작성해서 관리하고 있다. 그리고 모든 커밋마다 CI에서 테스트를 돌리고 실패하면 슬랙메시지가 전송된다. 하지만 통합 테스트나 E2E 테스트는 전무한 상황이다. 비즈니스 로직이 꽤나 복잡해서 똑같은 실수를 반복하는 경우가 많았다. 여러 단계를 거쳐야 확인이 가능한 로직이 많은데 유닛테스트로는 한계가 있다. 현재 puppeteer와 cypress를 눈여겨보고 있다.
높은 서버 로드 처리
대부분의 경우 서버 로드는 특정 시간대에 몰린다. 특정 시간대를 위해 서버를 늘리자니 바쁘지 않은 다른 시간대에는 서버 자원의 낭비가 발생한다. 별생각 없이 어떤 블로그 글을 보다가 좋은 팁을 얻었다. 서버 로드가 높아지는 경우에는 서버 렌더링을 포기하고 전부다 클라이언트 렌더링으로 전환한다는 아이디어다. 웹서버에서 대부분의 자원은 서버 렌더링에 사용되기 때문에 이거다 싶었다. 서버 자원이 부족할 때는 클라이언트의 자원을 사용한다니.. 뭔가 스마트해 보였다. 물론 검색엔진이 요청할 때는 무조건 서버 렌더링을 해야 한다는 점은 명심해야 한다.
마치며
typescript는 매우 만족스럽다. 연관된 코드는 서로 타입으로 연결되어있어서 코드 간의 이동이 쉬워졌다. 초기에는 설정이 잘 안돼서 고생을 좀 했다. 특히 next.js 4로 시작했을 때는 universal webpack이 안돼서 커맨드 라인에서 tsc를 사용했는데, tsc를 실행할 때마다 자바스크립트 파일이 생성돼서 골치 아팠던 기억이 난다. 현재 typescript를 사용하지 않은 프로젝트도 관리하고 있기 때문에 그 차이를 확실히 체감하고 있다. 앞으로 신규 프로젝트는 무조건 typescript를 사용할 생각이다.
서버 렌더링은 꼭 필요한 경우가 아니라면 하지 말자. 세상에 공짜는 없다. 복잡도가 많이 증가한다. 하지만 SEO가 필요한 프로젝트라면 선택이 아니라 필수다. 서버 렌더링이 필요하다면 next.js, 필요하지 않다면 create-react-app을 추천한다.
next.js도 만족스럽다. 서버 렌더링이라는 본연의 기능에 충실하다. 하지만 create-react-app 보다 문서화가 잘 안되어 있어서 열심히 찾아봐야 하는 단점이 있다. next.js는 zeit라는 나름 걸출한 회사에서 개발하고 있기 때문에 든든하다. 본인들의 사이트도 next.js로 개발했고, 최근에는 github에서도 next.js로 신규 서비스를 개발하고 있다는 소식이 들린다.
어쩌다 보니 react contributor가 됐다. 처음에는 다른 사람이 실수한거 롤백하면 버그 수정되는 줄 알고 꿀이다 싶어서 PR을 보냈다. 그게 아니란 걸 깨닫고 버그 잡느라 고생 좀 했지만 머지되는 순간 짜릿한 희열을 느꼈다.
카카오페이지 웹은 이제 제2막이 시작됐다. 앞으로 VOD 플레이어도 들어갈 예정이라 앱에서 구매한 작품을 큰 화면에서 보고 싶었던 사용자들은 기대해도 좋겠다.