카카오페이지 글로벌 웹 개발 후기

landvibe
16 min readJan 16, 2020

최근에 카카오페이지가 인도네시아에 진출했다. 초기부터 참여한 프로젝트라 그 의미가 남다르다.

새로운 프로젝트의 시작은 언제나 설렌다. 그동안 하고 싶었지만 여건상 하지 못했던 것들을 마음껏 도입했다. 일정의 압박이 있었지만 현실과 타협하지 않으려고 부단히 노력했는데, 그중에 하나가 리뷰를 거치지 않고 머지 된 코드가 없다는 것이다(물론 급한 경우에는 선 머지 후 리뷰 과정을 거쳤지만).

사용한 기술 스텍

  • react: 글로벌 프로젝트도 리액트를 기반으로 만들었다. 특히 클래스형 컴포넌트 없이 모두 훅으로 구현했다. 이 번 프로젝트를 통해 훅의 장단점을 몸소 느낄 수 있었다. 특히 재사용 가능한 로직을 쉽게 작성할 수 있다는 장점이 크게 와닿았다.
  • redux: 리덕스도 훅을 지원하면서 사용하기 매우 편해졌다. 미들웨어로는 redux-saga를 사용했다. API 호출 로직을 모두 사가에서 구현했는데, 결과는 만족스러웠다.
  • next.js: 서버사이드 렌더링을 하기 위해 사용했다. 꾸준한 업데이트로 날로 발전하는 프레임워크다. 특히 최근에 추가된 dynamic routing 기능을 잘 쓰고있다.
  • typescript: 나는 항상 타입스크립트에 만족한다. 타입스크립트 측 주도로 자바스크립트 언어에 추가된 optional chaining도 매우 마음에 든다.
  • styled-components & rebass: 팀원 모두가 CSS를 어느 정도 작성할 줄 알기 때문에 css-in-js 방식을 적극적으로 활용하고 있다. rebass에서 제공하는 축약된 속성 이름 덕분에 생산성이 높아졌다고 생각한다. 그리고 variant라는 개념을 통해 좀 더 높은 추상화 단계로 스타일을 적용할 수 있어서 마음에 든다.

목차

하고 싶은 얘기를 다음의 순서로 정리해봤다. 순서에 특별한 의미는 없다.

  • 리액트 훅
  • Next.js
  • CSS 코드 작성
  • 반응형 웹
  • 서버사이드 렌더링
  • 서버 통신 로직
  • Lint
  • 디버깅 환경 개선
  • 타임존

리액트 훅

리액트 훅을 사용하면 컴포넌트의 공통 로직을 쉽게 구현할 수 있다. 우리 팀에서도 공통 로직을 다수의 커스텀 훅으로 구현했다. 아래는 그중 몇 가지를 소개한다.

useBlockIfNotLogin()

로그인 상태에서만 접근 가능한 페이지에서 사용한다. 미로그인 상태라면 로그인 페이지로 보낸다.

useIfLoginUser(callback, deps)

로그인 상태에서만 callback을 호출한다

useBlockUnsavedChange(msg, isBlock)

변경 사항이 있는 상태에서 페이지를 벗어나려고 할 때 팝업을 띄운다. 회원 가입과 같이 여러 개의 input 요소가 있는 경우에 사용한다.

useEffectAfterTrue(callback, isTrue)

두 번째 매개변수가 참이 되는 순간에 callback을 호출한다. 단, callback이 반환하는 함수는 isTrue가 거짓이 되거나 컴포넌트가 언마운트될 때 호출되어야 한다.

useEffectOnChange(callback, value)

value가 변경될 때만 callback을 호출한다. 마운트 시에는 변경되지 않은 것으로 보고 callback을 호출하지 않는다. callback이 반환하는 함수는 value가 변경되거나 컴포넌트가 언마운트될 때 호출되어야 한다.

useLocalStorage(key, …) => [value, setValue]

로컬 스토리지에 값을 쓰거나 읽을 때 사용한다. 만약 같은 key로 여러 개의 useLocalStorage 훅을 사용했을 때, 한쪽에서 setValue를 호출하면 다른 쪽에서도 반응한다.

훅이 가진 장점이 많지만 제대로 사용하려면 많은 노력이 필요하다. 비록 처음에는 사용하기 편해 보여도 useEffect 훅의 deps를 잘못 관리하는 경우가 많다. [실전 리액트 프로그래밍]의 개정판에서는 훅을 제대로 사용하는 방법에 대해서 다뤄볼 예정이다.

Next.js

서버사이드 렌더링을 위해 Next.js 프레임워크를 사용했다. 약 2년간 꾸준히 사용하고 있는데, 매우 만족하고 있다. 아래는 이 번 프로젝트를 통해 Next.js에 대해 새롭게 알게 된 내용이다.

dynamic routing

Next.js 버전 8부터 지원하는 기능이다. 이 번 프로젝트에서는 동적으로 구성된 url이 많아서 잘 쓰고 있다. dynamic routing이 없을 때 부수적으로 작성해야 할 코드가 많았는데, 그에 비하면 많이 편해졌다.

안드로이드 백버튼으로 팝업 닫기

이 요구 사항을 받았을 때 안될 거라고 생각했다. 하지만 어느 정도 동작하게 만들 수 있는 방법이 있다. 자바스크립트에서 안드로이드 백버튼 이벤트를 받을 수 있다면 좋겠지만, 그런 건 없었다.

SPA는 화면 라우팅과 관련된 모든 이벤트를 받을 수 있고 자바스크립트로 원하는 화면을 렌더링할 수 있다. 안드로이드 백버튼은 브라우저 입장에서 뒤로가기 이벤트이며 팝업이 보일 때 뒤로 가기 이벤트를 받으면 팝업을 닫으면 된다. 첫 화면에서는 뒤로 가기 이벤트를 받을 수 없다는 치명적인 단점이 있다. 이를 굳이 해결하자면, 첫 화면에서 팝업이 뜨는 경우에는 같은 url을 history에 push 하는 방법이 있다. Next.js의 router.beforePopState 함수와 window 객체의 history.go 함수를 잘 활용하면 안드로이드 백버튼으로 팝업 닫기 기능을 구현할 수 있다.

shallow와 popstate

Next.js에서 라우팅할 때 shallow 옵션을 주면 getInitialProps가 불리지 않는다. 성능 최적화를 위해 지원하는 기능으로 이해하고 있다. 예를 들어, /home 페이지에서 이벤트 탭을 눌렀을 때, /home?tab=event 와 같이 url만 변경하고 굳이 getInitialProps 함수를 호출할 필요가 없을 때 사용할 수 있다.

문제는 입력했던 shallow 옵션이 popstate에서도 그대로 적용된다는 것이다. 우리 프로젝트에서는 /home?tab=event 페이지에서 다른 페이지로 갔다가 뒤로가기로 돌아올 때 getInitialProps 함수가 호출되어야 하는데 호출되지 않아서 문제가 됐었다. 이 경우에는 popstate 이벤트를 받아서 shallow 옵션을 꺼줘야한다. 개인적으로는 shallow 옵션이 살아있는 게 비정상적인 동작으로 보인다.

CSS 코드 작성

우리 프로젝트에서는 styled-components와 rebass를 적극적으로 활용한다. 마크업 개발자 없이 모든 스타일 코드를 팀 내에서 소화하고 있다. 나름대로 디자인 시스템을 갖춰나가고 있다. 예를 들어, 우리 프로젝트에서 모든 텍스트는 PageText라는 컴포넌트를 사용해서 렌더링한다.

<PageText variant=”textStyle1">카카오페이지</PageText>
<PageText variant=”textStyle2">만화, 소설</PageText>

variant는 디자이너가 만든 텍스트 스타일을 가리킨다. 디자이너와의 협업 툴로 사용하는 제플린에서 모든 텍스트는 variant 값을 갖고 있다. 개발자는 제플린에 노출된 variant 값을 복사해서 그대로 코드에 붙여 넣으면 간단하게 텍스트에 스타일을 적용할 수 있다.

버튼도 variant를 이용해서 스타일을 적용한다.

<TextButton
variant="buttonStyle1"
textVariant="textStyle1"
onClick={...}
>
좋아요
</TextButton>

이때 textVariant은 PageText에서 입력했던 텍스트의 variant와 같은 타입이다.

반응형 웹

반응형으로 개발하기 좋은 환경을 구축하기 위해 신경을 썼다. user-agent로 PC, Tablet, Mobile 여부를 판단해서 렌더링 하는 경우는 거의 없고 대부분 미디어 쿼리로 대응했다. 그런데 미디어 쿼리를 직접 작성하는 것은 코드가 직관적이지도 않고 생산성도 떨어지는 것 같아서 몇 가지 함수와 컴포넌트를 만들어서 사용했다.

아래는 우리 프로젝트에서 반응형 웹을 위해 사용하고 있는 함수와 컴포넌트의 사용 예다.

useScreenType 훅

우리 프로젝트에서는 화면을 3가지(small, medium, large)로 구분한다. useScreenType 훅은 현재 화면의 타입을 반환한다.

const { isLarge, isNotLarge } = useScreenType();
// ...
{ isLarge && <GlobalNaviBar className="hideNotLarge"> ...
{ isNotLarge && <Header className="hideLarge"> ...

서버사이드에서는 사용자의 화면 크기를 알 수 없으므로 컴포넌트 마운트 이후 시점에만 해당 정보를 사용할 수 있다는 단점이 있다.

Responsive 컴포넌트

화면 타입별로 스타일을 적용해주는 컴포넌트다. 자식 컴포넌트에 미디어 쿼리를 붙여주는 방식으로 동작한다.

<Responsive
styleLarge={{ paddingTop: '50px' }}
styleNotLarge={{ paddingTop: '30px' }}
>
<Box ...
</Responsive>

컴포넌트 마운트 여부와 상관없이 자유롭게 사용할 수 있다는 장점이 있다.

CSS class 활용

<GlobalNaviBar className="hideNotLarge"> ...
<Header className="hideLarge"> ...

이 방식은 사용하지 않는 화면 타입의 요소까지 생성하므로 useScreenType 훅에 비해 html 파일의 크기가 커진다는 단점이 있다. 하지만 사용하기 편하다는 것이 특장점이다.

서버사이드 렌더링

대량의 트래픽을 감당하기 위해서는 정적 파일이나 서버사이드 캐싱을 잘 활용해야 한다. 정적 파일의 내용이 다양한 변수에 영향을 받는다면 관리가 힘들어진다. 여기서 말하는 변수에는 user-agent, 사용자 엑세스 토큰 등을 포함한다. 특히 사용자 엑세스 토큰에 의존적이라면 서버에서 미리 대응하는 것이 거의 불가능하다.

이 번 프로젝트에서는 서버사이드에서 사용자 엑세스 토큰에 접근할 수 없도록 강제했다. 그리고 user-agent는 되도록 컴포넌트 마운트 이후에만 접근하는 방향으로 작업했다. 덕분에 렌더링 코드를 작성할 때 신경 써야 할 부분이 많이 줄었고, 정적 파일이나 서버사이드 캐싱도 적극적으로 활용할 수 있게 되었다.

서버 통신 로직

우리 프로젝트에서 거의 모든 서버 통신은 redux-saga를 통해서 이뤄진다. 아래는 서버 통신을 위해 작성한 리덕스 사가 코드다.

function* fetchData1(action) {
const { param1, param2 } = action.payload;
const { success, data } = yield call(callApi, { url: '/api/something' });
if (success && data) {
// ...
}
}
export default function*() {
yield all([
takeLeading(
ActionType.FetchData1,
makeFetchSaga({ fetchSaga: fetchData1, canCache: true }),
),
takeLeading(
ActionType.FetchData2,
makeFetchSaga({ fetchSaga: fetchData2, canCache: false }),
),
// ...
]);
}

makeFetchSaga 함수에서 서버 통신과 관련된 여러 가지 상태를 관리한다. 통신 중, 통신 완료, 통신 실패, 통신 성공, 통신 느림 등의 기본적인 상태뿐만 아니라 통신 결과 캐싱, 페이징 등의 부가적인 기능도 지원한다.

makeFetchSaga가 관리하는 서버 통신 상태를 이용해서 몇 가지 훅과 컴포넌트를 만들었다.

// FetchData1의 통신 상태를 가져온다.
const { isSlow, isFetching } = useFetchInfo(ActionType.FetchData1);
// FetchData1이 isSlow인 경우에 로딩을 보여준다
useFetchFullLoading(ActionType.FetchData1);
// FetchData1이 isSlow인 경우에 버튼 내부에 로딩을 보여준다
<FetchButton actionType={ActionType.FetchData1} ...
// 페이징으로 동작하는 무한 스크롤 리스트다.
<FetchList actionType={ActionType.FetchData1} ...

Lint

팀 프로젝트에서는 컨벤션을 잘 지키는 게 중요하다. 하지만 우리는 인간이기 때문에 자주 실수를 하기 마련이다. 컨벤션 문서를 잘 정리했다고 하더라도 신입 개발자가 모든 컨벤션을 잘 지키기는 힘들다. 이럴 때는 커스텀 lint rule이 많은 도움이 된다. 아래는 우리 프로젝트에서 사용 중인 커스텀 룰이다.

needAltRule

img 태그에 alt 속성이 있는지 검사한다. 접근성을 위해 모든 img 태그에는 alt 속성을 입력하는 게 좋다. lighthouse에서도 접근성을 평가할 때 img 태그에 alt가 있는지 확인한다. 특별한 의미 없이 꾸미는 용도로만 사용되는 img 태그라도 alt=””를 넣어주는 게 좋다.

needCursorRule

onClick 이벤트가 붙은 요소에 curso: “pointer” 속성이 있는지 검사한다. 터치가 안되는 디바이스에서는 클릭이 가능하다는 시각적인 효과를 제공하는 게 중요하다.

noNextRouterRule

Next.js의 router 객체를 직접 사용하지 못하도록 한다. 우리 프로젝트에서는 모든 페이지 라우팅을 중앙에서 처리한다. 만약 Next.js의 router를 직접 사용하면 중앙에서 처리되지 않기 때문에 문제가 될 수 있다.

noDateRule

new Date() 처럼 Date 생성자 함수를 호출하지 못하도록 한다. 정해진 타임존으로 동작해야 하므로 커스텀으로 만든 날짜 관련 함수를 사용해야 한다.

디버깅 환경 개선

저사양 디바이스에서 디버깅을 할 때마다 답답함을 느꼈다. 특히 앱 내에서의 웹뷰 디버깅 환경은 매우 열악하다.

pushLog라는 함수를 만들어서 중요한 곳에 로그를 많이 심어놨다. pushLog 함수는 로그를 콘솔에 출력하고 메모리에도 저장한다. 그리고 프로덕션 환경이 아닌 곳에서는 화면 귀퉁이를 여러 번 클릭하면 디버깅 페이지로 이동하도록 했다. 디버깅 페이지에서는 지금까지 쌓인 로그를 확인할 수 있다.

pushLog를 호출하는 코드는 프로덕션 빌드 시에 삭제되도록 바벨 플러그인을 제작했다. 그리고 화면 귀퉁이를 여러 번 클릭하면 디버깅 페이지로 이동시키는 코드는 아래와 같다. if (IS_DEBUG) 블럭도 프로덕션 빌드 시에 제거되도록 했다.

if (IS_DEBUG) {
addEventListener('touchstart', ...
}

앞으로 개발 환경 개선을 위해 if (IS_DEBUG) 블록을 적극적으로 활용할 예정이다.

타임존

글로벌 프로젝트는 타임존에 대한 고민이 필수다. 타임존을 잘 관리하기 위해서는 자바스크립트 Date 객체에 대한 이해가 필요하다. 자바스크립트 Date 객체는 timestamp와 timezone을 기반으로 동작한다.

timestamp는 1970년 1월 1일 0시 0분 0초부터 흘러간 시간을 나타낸다. 자바스크립트 timestamp는 밀리초를 의미하므로 1970년 1월 1일 0시 0분 2초의 timestamp는 2000이다.

자바스크립트 timestamp는 항상 UTC 시간을 나타낸다. 따라서 Date.now() 또는 new Date().getTime() 를 한국과 미국에서 동시에 실행하면 같은 값이 나온다. 하지만 한국과 미국에서 new Date().getHours()는 다른 값이 나오는데, 이는 timezone이 다르기 때문이다.

자바스크립트 Date 객체를 그림으로 표현하면 다음과 같다.

자바스크립트 Date 객체

만약 getHours 메서드가 사용자의 위치와 상관없이 항상 한국 시간을 반환하길 원한다면 어떻게 해야 할까? 자바스크립트 Date 객체는 timezone을 변경하는 메서드를 지원하지 않는다. 대신 timestamp를 변경해서 문제를 해결할 수 있다.

function getKoreanDate() {
const timestamp = Date.now() + (new Date().getTimezoneOffset() * 60000) + (9 * 3600 * 1000);
return new Date(timestamp);
}
const date = getKoreanDate()
const day = date.getDate();
const month = date.getMonth() + 1;

위 코드에서 day, month 변수는 전 세계 어느 나라에서 실행해도 항상 한국의 일, 월을 가리킨다.

마치며

카카오페이지 글로벌 웹을 개발할 때는 최대한 앱과 기능상의 차이가 없게 하려고 노력했다. 아직 푸시와 콘텐츠 다운로드 기능은 구현하지 못했는데, 추후 기능 추가를 고려하고 있다.

똑같은 기능을 구현하고 앱과 웹을 비교해보면서 느낀 점은 화면 전환과 같은 UI 효과는 역시 앱이 투자 대비 좋은 결과를 얻을 수 있다는 점이다. 아직 개선할 여지가 많지만 웹 개발은 어쩔 수 없이 브라우저 호환성에 많은 시간이 들어간다.

아래는 lighthouse로 측정한 카카오페이지 글로벌 웹의 점수다. 두 곳에서 100점을 얻어서 기분이 좋았지만, 성능 측면에서 아직 개선할 여지가 많이 남아있다. 성능 점수는 주로 첫 페이지 로딩과 관련된 항목이 많은데, SPA에서는 높은 점수를 받기가 참 힘든 것 같다(맞다 변명이다ㅜㅜ).

카카오페이지 글로벌 웹 lighthouse score

동남아 출시를 위해 인도네시아 출장을 여러 번 가봤는데, 역시 네트워크 환경이 우리나라보다 많이 안 좋았다. 따라서 개발할 때 이미지 크기와 로딩 처리를 신경 쓰면서 개발해야 했다.

글로벌 프로젝트와 국내 프로젝트의 가장 큰 차이점은 다국어, 타임존, 국가별 배포다. 인도네시아어로 된 화면은 아직도 적응이 안 된다. 아직 하나의 국가만 지원하는 단계라서 TODO로 남겨놓은 작업도 많다. 두 번째 국가를 지원할 때쯤이면 새로운 고민들이 쏟아져 나올 것 같다.

2020–11–04 인프런에 리액트 강의를 오픈했습니다.

--

--