Intro

지금 다니는 회사에서 ondemandkorea 라는 서비스를 하고 있다. 미국에서 거주하고 있는 한인들 대상으로 하는 서비스로, 드라마, 영화, 예능 등을 서비스한다. 한국에서는 접근이 안 되고, 미국 VPN을 통해서 접근 가능하다.

이 서비스가 초기에는 워드프레스 기반으로 구축이 되어 있었고, 격변의 시대를 거쳐 지금은 대부분의 코드가 python - django 기반으로 되어있다.

이 글은 이 서비스에 next.js를 점진적으로 도입하여 어떻게 개선을 시작했는가를 기록해본 글이다.

왜 도입하게 되었는가?

작년까지만해도 개발팀은 Engineering팀 하나로 움직이고 있었고, 대부분의 인력이 Python 중심의 Back-end 경험이 있는 인력이 구성이 되어있었다. 이렇다보니 아래와 같은 문제들이 있었다.

  • django template 기반이기 때문에 SSR이 일어나는 지점과 CSR이 일어나는 지점이 마구 뒤섞여있어서 유지보수 하기가 어려웠다.
  • 과거에 만들어진 디자인 그대로 서비스 되고 있었으며, 구조상 responsive design 적용 등이 힘든 구조였다.
    • 하려면 모든 페이지에 디자인을 갈아엎어야하는 슈퍼 대공사가 필요했다.
  • 7년이 넘게 운영되던 서비스다보니 HTML/CSS/JS 등이 손대기 쉽지 않았다.
  • 일부 페이지는 django template과 vue.js가 섞여있는 구조였다. 로그인 / 회원가입 / 결제 등이 그렇다.
    • 이것이 많은 혼란을 야기했다.
  • 새로 충원된 Front-end Dev 팀 인력들이 Server side template 언어 기반으로 화면을 렌더링해본 경험이 없어서, 기존 코드를 파악하고 수정하는데 큰 어려움이 있었다.

이런저런 이유때문에 기존의 렌더링 프로세스를 개선하고, 프로젝트를 한번에 갈아엎지 않고 점진적으로 개선해나가기 위한 계획을 세우게 되었고, 이를 실행하게 되었다.

이후 글에서는 기존 서비스를 odk-web, 이번에 새로 구축한 next.js 기반의 서비스를 odk-front-server라고 한다.

Front-end 팀의 구축

위의 상황에서 작년 말쯤 Engineering 팀이 Front-end Dev / Back-end Dev 팀으로 나뉘게 되었다. 여기에 위에서 언급한대로 새로 충원된 팀 인력들이 기존 코드를 수정하는데 큰 어려움을 겪고 있던 상황이었다.

이제 팀원들도 든든하게 있으니 계획만 잘 세워서 실행만 하면 될 일이었다.

Plan

Plan A

기존 odk-web 프로젝트에 create-react-app 기반의 세팅을 하고, django template -> vue.js로 하던 렌더링을 django template -> react.js로 바꾸는 일이다.

이 방식으로 할 경우 새로운 프로젝트를 만들고 관리할 필요가 없지만, odk-web 프로젝트에 기존 jquery 기반 코드, vue.js, react.js 코드가 혼재되어 있는 끔찍한 혼종이 탄생하기 때문에 금방 머릿속에서 지워버렸다.

굳이 장단점을 나열해본다면..

장점

  • 기존 odk-web python - django 기반에서 돌아가기 때문에 별도의 프로젝트 세팅이 필요없다.

단점

  • 기존 odk-web 기반에서 돌아가기 때문에 이것만 독자적으로 관리하기가 어렵다.

Plan B

실제로 PoC 해본 것은 Plan B부터로, 아래와 같다.

  • odk-front-server라는 next.js 기반의 프로젝트를 구축한다.
    • SSR이 필요했기 때문에 next.js로 했다.
  • 각 화면을 컴포넌트 단위로 잘 나누어서 odk-front-server에서도 똑같은 디자인과 기능으로 구현한다.
  • 위에서 구현한 화면에 대응하는 odk-webdjango views.py에서 http 요청을 통해 odk-front-server에 구현된 화면에 요청하면, SSR이 된 결과물인 HTML String들이 내려올 것이다.
  • 이것을 django template에 그대로 그린다.

그림으로 표현하면 이런 느낌이다.

2798cebc-e45b-4bde-a8ea-302175888b66-1

위의 그림과는 다른 페이지의 경우이지만, 코드로 설명하면 대략 아래와 같다.

// views.py
res = requests.get('http://odk-front.local.roto.codes/membership')

그러면 res에는 아래와 같은 응답이 들어있다.

<!DOCTYPE html>
<html>

<head>
    <style data-next-hide-fouc="true">
        body {
            display: none
        }
    </style>
    <noscript data-next-hide-fouc="true">
        <style>
            body {
                display: block
            }
        </style>
    </noscript>
    <meta charSet="utf-8" />
    <meta name="viewport" content="width=device-width,minimum-scale=1,initial-scale=1" />
    <meta name="next-head-count" content="2" />
    <link rel="preload" href="http://odk-front.local.roto.codes/_next/static/css/styles.chunk.css" as="style" />
    <link rel="stylesheet" href="http://odk-front.local.roto.codes/_next/static/css/styles.chunk.css" />
    <link rel="preload" href="http://odk-front.local.roto.codes/_next/static/development/pages/membership.js?ts=1582107806735" as="script" />
    <link rel="preload" href="http://odk-front.local.roto.codes/_next/static/development/pages/_app.js?ts=1582107806735" as="script" />
    <link rel="preload" href="http://odk-front.local.roto.codes/_next/static/runtime/webpack.js?ts=1582107806735" as="script" />
    <link rel="preload" href="http://odk-front.local.roto.codes/_next/static/runtime/main.js?ts=1582107806735" as="script" />
    <link rel="preload" href="http://odk-front.local.roto.codes/_next/static/chunks/styles.js?ts=1582107806735" as="script" />
    <noscript id="__next_css__DO_NOT_USE__"></noscript>
</head>

<body>
    <div id="__next">
        <div class="app">
            <div class="header">
                <header>
                    <div class="logo">
                        <h1><a href="/"><span>ONDemandkorea</span></a></h1></div>
                </header>
            </div>
            <div class="Benefits">
                <section class="benefit-header">
                    <h1>ODK Membership</h1>
                    <h5>즐겨 보는 콘텐츠에 따라 플랜을 선택해 보세요!</h5></section>
                <section class="content">
                    <ul>
                        <li>
                            <div class="img" style="background-image:url(&#x27;http://odk-front.local.roto.codes/static/assets/membership/images/benefits1.svg&#x27;)"></div>
                            <p><strong>동영상 광고 없이</strong>
                                <br/>영상 감상</p>
                        </li>
                        <li>
                            <div class="img" style="background-image:url(&#x27;https://production-static.ondemandkorea.com/dist/2f41a3928ec51da89ad40db3a0186f20.svg&#x27;)"></div>
                            <p>한국 방송 종료 후
                                <br/><strong>1시간 이내 업데이트</strong></p>
                        </li>
                        <li>
                            <div class="img" style="background-image:url(&#x27;https://production-static.ondemandkorea.com/dist/5025d6ee90309e4c6550ea15a864259e.svg&#x27;)"></div>
                            <p><strong>Full HD</strong>의
                                <br/>선명한 화질 제공</p>
                        </li>
                        <li>
                            <div class="img" style="background-image:url(&#x27;https://production-static.ondemandkorea.com/dist/0e0e644653118a01c5fecc2f81dd1328.svg&#x27;)"></div>
                            <p><strong>영화 무제한</strong> 이용
                                <br/>(페이퍼 뷰 제외)</p>
                        </li>
                    </ul>
                </section>
            </div>
            <footer>
                <ul>
                    <li><a href="/help">고객센터</a></li>
                    <li><a href="/faq">FAQs</a></li>
                    <li>© 2012-2020 ODK Media, Inc.</li>
                </ul>
            </footer>
        </div>
    </div>
    <script src="http://odk-front.local.roto.codes/_next/static/development/dll/dll_d6a88dbe3071bd165157.js?ts=1582107806735"></script>
    <script id="__NEXT_DATA__" type="application/json">{"props":{"initialI18nStore":{"ko":{"common":{"test":"튜ㅐ수투"},"benefits":{"benefits":{"title":"ODK Membership","subtitle":"즐겨 보는 콘텐츠에 따라 플랜을 선택해 보세요!","description":["\u003cstrong\u003e동영상 광고 없이\u003c/strong\u003e\u003cbr/\u003e영상 감상","한국 방송 종료 후\u003cbr/\u003e\u003cstrong\u003e1시간 이내 업데이트\u003c/strong\u003e","\u003cstrong\u003eFull HD\u003c/strong\u003e의\u003cbr/\u003e선명한 화질 제공","\u003cstrong\u003e영화 무제한\u003c/strong\u003e 이용\u003cbr/\u003e(페이퍼 뷰 제외)"],"byMonth":"월","byYear":"연","monthly":"월간 이용권","annual":"연간 이용권","autoRenewal":"자동 갱신","billed":"연간 US${annualPrice} 청구","upgrade":"업그레이드","current":"이용중인 플랜","event":"이벤트","plus":{"description":"모든 PLUS 콘텐츠 무제한 이용\u003cbr/\u003e*KOCOWA 카테고리의 무료 에피소드\u003cbr/\u003e시청 시 동영상 광고와 함께 감상"},"premium":{"description":"PLUS 콘텐츠를 포함한 온디맨드코리아의\u003cbr/\u003e모든 콘텐츠를 무제한 이용"},"oneDayPass":{"mainText":"24시간 동안만 PREMIUM 멤버십 혜택!","subText":"PLUS 월간/연간 이용권과 중복 이용 가능","title":"24시간 이용권","oneTimePayment":"갱신 없음"},"viewableProgram":"이 플랜에서는 \u003cstrong\u003e{programTitle}\u003c/strong\u003e 등을 시청하실 수 있습니다."}}}},"initialLanguage":"ko","i18nServerInstance":null,"pageProps":{"namespacesRequired":["common","benefits"]}},"page":"/membership","query":{},"buildId":"development","assetPrefix":"http://odk-front.local.roto.codes"}</script>
    <script nomodule="" src="http://odk-front.local.roto.codes/_next/static/runtime/polyfills.js?ts=1582107806735"></script>
    <script async="" data-next-page="/membership" src="http://odk-front.local.roto.codes/_next/static/development/pages/membership.js?ts=1582107806735"></script>
    <script async="" data-next-page="/_app" src="http://odk-front.local.roto.codes/_next/static/development/pages/_app.js?ts=1582107806735"></script>
    <script src="http://odk-front.local.roto.codes/_next/static/runtime/webpack.js?ts=1582107806735" async=""></script>
    <script src="http://odk-front.local.roto.codes/_next/static/runtime/main.js?ts=1582107806735" async=""></script>
    <script src="http://odk-front.local.roto.codes/_next/static/chunks/styles.js?ts=1582107806735" async=""></script>
    <script src="http://odk-front.local.roto.codes/_next/static/development/_buildManifest.js?ts=1582107806735" async=""></script>
</body>
</html>

여기서, 정규표현식을 이용해 stylesheet와 body 아래를 추출한다.

js bundle script 링크는 body에 포함되어있으므로 script만 따로 추출할 필요가 없다.

    style_regex = re.compile('<link.+\/>')
    styles = style_regex.search(res.text).group()

    content_regex = re.compile('<body>(.+)<\/body>') 
    html_content = content_regex.search(res.text).group()

위에서 얻은 것을 render context dict에 추가한다.

    context_dict = {
        'styles': styles,
        'html_content': html_content
    }

    return render(request, 'payment/membership.html', context=context_dict)

이제 styles와 html_content를 django template에 렌더링하자.

{% extends "authenticate/base.html" %}
{% load i18n settings_value %}
{% block head_title %}
  {% trans "ODK Plus Membership Benefits" %}
{% endblock %}

{% block styles %}
{{ styles | safe }}
{% endblock %}

{% block contents %}
{{ html_content | safe }}
{% endblock %}

이렇게 하면 django template에 next.js로 렌더링된 SSR 결과물을 넣을 수 있다.

장점

  • 렌더링 교체를 페이지 단위가 아니라 컴포넌트 단위로 할 수 있다. 이를 통해 매우 점진적으로 화면을 하나하나 바꿔나갈 수 있다.
    • 프로그램 리스트의 목록 부분만 교체한다던가, GNB만 교체한다던가 하는 전략이 가능하다.

단점

  • 페이지를 교체해나갈 때마다 odk-web의 코드를 수정해야한다.

Plan C

서비스 맨앞단에 nginx가 있다는 것에 착안했다.

nginx를 이용해 페이지를 완전히 대체하는 방식이다.

  • odk-front-serverodk-web 페이지에 대응하는 페이지를 작업한다.
  • odk-front-server를 독립적으로 띄운다. https://front.ondemandkorea.com 같은 느낌으로.
  • odk-web 앞단의 nginx에서 odk-front-server에 작업이 완료된 url로 진입한 경우, odk-front-server로 proxy 한다.

장점

  • 위 플랜들 중 odk-front-server가 비교적 odk-web에 가장 독립적이다.
  • 궁극적으로 모든 페이지의 대체를 한 경우, odk-web에서 Web 렌더링 관련 코드를 모두 없앨 수 있다.
  • nginx의 라우팅만 바꾸면 되고 모든 렌더링 처리를 odk-front-server에서 하기 때문에 우리팀에서 odk-web 코드를 수정할 일이 거의 없어진다.

단점

  • 컴포넌트 단위로 갈아끼우는 방식이 아니기 때문에 페이지 단위 작업이 필요하다.

실제로 작업한 것은 Plan C 방식이고, vue.js 기반으로 된 페이지들을 먼저 갈아끼우는 것을 목표로 작업을 하게 되었다.

vue.js에 대한 특별한 악의가 있어서 먼저 대상으로 정했던 것은 아니다

겪었던 문제들

세상에는 역시 쉬운 일이 하나도 없다. 아래와 같은 문제들이 있었다.

인증처리

ondemandchina 의 경우 새로 구축된 REST API 기반이었고, 인증도 Token 기반의 인증이었기 때문에 겪지 않았던 문제였다.

문제는 odk-web이었는데, 기존 인증이 django session 기반으로 되어있고 domain strict하게 쿠키가 생성이 되기 때문에 로컬 개발 시 문제가 되었다. odk-web과 odk-front-server의 도메인과 포트가 다르기 때문이다.

이건 로컬 개발시에도 nginx 를 docker-compose로 띄우고, local.ondemandkorea.com 도메인으로 odk-webodk-front-server를 proxy하게 해서 해결했다.

이렇게 하면 로컬에 떠있는 두 서비스가 도메인이 같기 때문에 odk-web의 쿠키를 odk-front-server에서도 그대로 쓸 수 있었고, odk-web쪽에 현재 쿠키 기준으로 로그인 된 유저의 정보를 얻어오는 API를 통해 인증처리를 해결했다.

CSRF Token

django 의 경우 form을 처리할 때 기본적으로 CSRF Token을 쿠키에 생성하고, form post시 해당 값을 쿠키에서 꺼내 서버에서 검증하게 되어있다. 이는 odk-web 페이지를 돌아다니다 odk-front-server에 해당하는 페이지로 오게 되는 경우에는 해당 값이 쿠키에 생성이 되어있기 때문에 문제가 없다.

문제가 되는 경우는 쿠키를 깨끗하게 비운 다음에, odk-web에서 제공하는 페이지를 경유하지 않고 odk-front-server가 제공하는 페이지로 바로 진입하는 경우이다. 심지어 해당 페이지가 form 전송(실제로는 ajax post 요청)이 일어나는 경우 CSRF Token이 없기 때문에 CSRF Token 에러가 발생한다.

여러가지 방안을 찾다 인증처리를 ondemandchina 처럼 토큰 기반으로 가기 전까지 문제가 생기는 페이지의 CSRF Token 검증을 끄는 데코레이터를 views.py에 다는 것으로 해결했다.

쿠버네티스 지식 부족

기존 odc의 경우 s3에 bundle만 배포하고 cloudfront 연결만 하면 끝났지만, 이 프로젝트의 경우 next.js 기반에 SSR이 필요했기 때문에 docker로 배포가 나가야 했다.

이 과정에서 쿠버네티스의 대한 지식 부족으로 많은 삽질을 했다.

위의 문제들이 있었지만 SRE팀과 Back-End팀에서 많이 도와주셔서 빠르게 해결해나가며 작업을 진행할 수 있었다.

작업하면서 같이 진행한 것

Design System

작년 ondemandchina를 하면서 디자이너분들과 Design System 구축에 대해 서로 니즈가 있었던 상황이었다. 어차피 odk-front-server를 개발하게 되면 가장 기본적인 컴포넌트부터 싹 새로 만들어야하기 때문에 디자인을 크게 바꾸지 않는 선에서 한번 정리를 하고 컴포넌트 단위부터 맞춰가기로 했다.

  • px 대신 rem을 쓴다.
  • zeplin에서 디자인 시안을 넣을 때, 어떤 컴포넌트이고 어떤 컴포넌트들과의 결합인지를 먼저 디자인 팀에서 작업해서 올린다.
  • 위의 디자인 시안을 기반으로 Storybook에 디자인 팀이 정의한 컴포넌트 단위부터 만들고, Storybook을 통해 작업물을 공유해가면서 수정 / 보완한다.
  • 위에서 만들어진 컴포넌트들을 이용해서 페이지를 만든다.

이렇게 기본적인 컴포넌트 요소들에 대해 서로 합의를 맞추며 뇌 동기화를 하며 작업을 해나가니 이후 작업이 굉장히 쾌적했다. 우리 회사 디자인팀 최고다.

-------1

--------1

E2E Test

같은 기능을 하는 페이지를 새로 만드는 것은 QA팀에게도 큰 부담이 될 수 있다. 이를 보완하기 위해 작업 대상이 되는 페이지들은 QA팀에서 만들어준 Sanity Test 시나리오를 보고, 해당 시나리오에 대응하는 자동하된 E2E Test를 작성했다.

cypress 기반으로 작성했고 구축해놓으니 정말 편했다. 이제 cypress.io 에 production을 대상으로 일정시간마다 E2E를 수행하고 문제가 있으면 알려주는 작업 연동을 앞두고 있다.

여기에서 구축된 E2E Test는 다른 서비스에서도 사용할 예정이라 해당 환경과 스크립트만 모아둔 별도의 프로젝트를 만들었다. 프로젝트의 이름은 yoshi.js이다.

5de3516d-0af3-4beb-8878-d814fec6ec66
귀여우니까 이 이름으로 정했다

앞으로 남은 작업들

odk-front-server production 배포가 오늘 있었다. vue.js 로 되어있던 페이지를 전부 작업해두긴 했지만, 안전하게 나가기 위해(그리고 QA팀의 부담을 덜기 위해) 한 페이지씩 nginx를 통해 갈아치우고 있다.

시작이 반이라고 했던가. 아직 해야할 일은 많이 남았지만 그래도 레거시 시스템을 점진적으로 개선해나가는 것에 첫 걸음을 뗀 것 같아서 기분이 좋다. 팀원들과 발 빠르게 움직여서 빠른 시일내에 관련된 다른 글로 찾아올 수 있으면 좋겠다.

(추가) 팀원들의 작업 후기

같이 작업한 팀원들에게 실제 작업을 해보니 어떘었는지 물어봤고, 아래와 같은 피드백을 받았다.

이름에 근이 들어가는, 이근 대위의 열혈한 팬 L모씨

  1. 요새 시니어들이 어떻게 일하는지 눈여겨 보고있는데, 문제를 이렇게 해결할 수도 있다는 사실에 새삼 감탄
  2. nginx를 이용해 routing을 바꾸는 아이디어가 괜찮았음. nginx의 용도만 알고있었지 이걸 이렇게 응용하는 아이디어를 낼 수 있다는 사실에 역시 로토다 라고 감탄
  3. next.js를 처음 써봤는데 나쁘지 않았음. 다만 CSR에 비해 배포가 귀찮아져서 흠.
  4. 여러 변수를 고려하지않고 일을 진행해 생각보다 수월하지는 않았음.
  5. 이거 하다가 퍼져서 팀원들에게 미안한 마음뿐
  6. e2e script도 같이 작성했고, 연초 작업한 부분이 드디어 빛을 보는거 같아 무언가 보상받은 느낌
  7. 강제로 k8s 지식이 쌓이고 있음
  8. 아아 팀장님은 그저...빛 <-- 이건 먹이려고 쓴 거 같다.

팀의 디자인 시스템을 책임진다! 인간 디발자 Bong Bong Bong!

  1. 처음으로 접해본 프로젝트 구조. 7년짜리 레거시 프로젝트를 어떻게 점진적으로 정리해 나갈 수 있는지 새로운 시각을 배움
  2. 이 과정에서 시니어 개발자분들이 docker, nginx등의 구조를 선행하여 세팅 해주셨는데, 여러가지 환경에서의 문제점들이 발생했고 그걸 해결해가는 과정이 흥미롭고 좋았다. 시니어란 이런것...(별)
  3. k8s 코드를 처음봤는데, 처음에 아는말로 외계어를 쓴듯 보였다. 미들 개발자께 편하게 물어봤고, 친절히 설명해주셨다. 그래서 점차 읽히기 시작한다. 강제로 공부하게 되는 환경이 좋았다.
  4. 디자인시스템 구축 부터 페이지를 만드는 과정까지 스프린트로 달렸는데, 스프린트를 함께 하는 동료들과 긴장감을 함께 느끼며 달리는게 좋았다. 과정을 함께하며 유대감이 좀 생긴 것 같다.
  5. e2e 테스트로 느끼는 심신의 안정감을 다시 한 번 확인함. 직접 prod에 적용해보니 더 집요하게 짜야겠다는 의지가 생겼다.

히피처럼 살고싶은 오00의 후기.

윗분들의
후기에 큰 공감을 하며 ... 이 정도 규모와 이 정도 레거시가 아니면 해보기 힘든 뜻깊은 경험을 하게되어 영광이다. 나는 그저 팀장님과 팀원들이 닦아놓은 길을 따라 갔을 뿐인데 어느새 배포까지 와있다니 매직과도 같은 여정이었다. (꿈이라면 깨지않기를...악...!)