모바일 줌 SpringBoot → NodeJS 전환기 (feat. VueJS SSR)


정말 오래간만에 기술 블로그에 글을 작성하는 것 같습니다.
이 글을 쓰기 약 3개월 전인 03월 26일 모바일 줌 프로젝트는 내부적으로 다시한번 큰 변화를 맞았습니다. 어플리케이션의 언어와 운영환경을 변경한 것인데, 팀 내에 공유했던 자료와 완료 보고에 사용했던 자료들을 이용해 저희 팀이 어떤 선택을 했고 왜 그런 선택을 했는지, 또 어떤 성과를 보였는지 공유하고자 합니다.

들어가며

모바일 줌 모바일 줌 (m.zum.com)

모바일 줌은 줌 인터넷에서 제공하고 있는 모바일 포털 서비스입니다.
모바일 줌은 Spring Boot 백엔드에 Vue.js 프론트엔드를 이용한 프로젝트로 운영되고 있었고, 이번 작업을 통해 백엔드로 Node.js를 사용하는 AWS 도커 운영 환경으로 변경하였습니다.

아키텍쳐1 Spring Boot 어플리케이션에서 Node.js 어플리케이션으로 변경되었습니다

모바일 줌의 Spring Boot 백엔드를 Node.js 백엔드로 변경하게 된 이유는 크게 세가지입니다.

  1. 개발 생산성 증가
    • 어플리케이션 크기와 목적에 맞는 프레임워크 선택을 통해 개발 생산성 증가
  2. 성능 향상
    • 저사양 환경에서 더 높은 성능을 보이는 Node.js 사용
  3. SSR 적용
    • Vue.js SSR Renderer를 통해 검색엔진 최적화(SEO)를 수행.

Netflix에서 Node.js를 사용하게 된 이유에서도 알 수 있듯 프론트 서비스에 Node.js를 사용하는 것은 여러가지 이유로 매우 효율적입니다.

이 이유와 성과에 대해 설명하기 전에 2019년부터 어떤 작업을 진행해왔는지 하나씩 살펴보도록 하겠습니다.


Server Side Rendering?

모던 프론트엔드 프레임워크 이야기에서 빠질 수 없는 SSR은 Node.js 전환의 이유 중 하나이기도 합니다.
실제로 SSR만을 위해 Node.js 어플리케이션으로 작성하는 경우도 있습니다. 이번 작업은 단순히 SSR 때문에 Node.js 어플리케이션으로 변경한 것은 아니지만, 그만큼 비중이 있는 이야기이므로 SSR에 대해 조사하고 테스트했던 내용에 대해 먼저 풀어가고자 합니다.

모바일 줌은 약 2년 전 Vue.js를 기반으로 한 SPA 프로젝트로 다시 개발되었습니다.
많은 분들이 이미 알고 계시듯 SPA는 모듈화, 빠른 DOM 변경, 간편한 이벤트 핸들링 등 장점이 많은 반면, JS 실행 후 DOM이 삽입된다는 점 때문에 발생하는 제약사항과 단점도 있습니다.
그리고 그 단점을 조금이나마 해소하고자 수행하는 것이 SSR입니다. SSR에 관련된 많은 참고 자료들이 있지만 간단하게 설명드리자면…


SSR이란

SSR이란2 SSR은 기존 템플리팅 방식(서버 템플리팅)과 크게 다르지 않습니다


SSR 수행시 Vue.js 어플리케이션을 서버에서 실행하여 HTML을 생성하고 삽입하기 때문에 응답에 컨텐츠에 해당하는 HTML이 포함되어 있습니다.

SSR의 효율성과 부하와 관련된 정보는 이 글의 내용에서 벗어나기 때문에 제외되었습니다.

이렇게 서버에서 SSR을 수행할 수 있도록 만들어진 객체를 SSR Renderer라고 합니다. SSR Renderer를 실행하여 페이지에 해당하는 HTML을 얻고, 그 HTML을 서버 템플릿에 삽입하는 것이죠. 설명드린 이 일련의 과정을 SSR이라고 합니다.

Vue.js 는 Vue SSR 렌더링 가이드를 제공하고 있습니다.
사이트에서 SSR에 대한 기본 개념과 하는 이유 등을 자세하게 확인하실 수 있습니다.

SSR은 왜?

모바일 줌닷컴에 SSR을 적용하려고 했던 이유는 크게 두가지입니다.

SSR의 문제?

Vue.js 비롯해 대부분의 모던 SPA 프레임워크는 SSR을 지원합니다. 다만 지원만 합니다.
예제 프로젝트는 많지만 리얼 월드에 적용할만한 예제가 없습니다. 작업하며 겪었던 큰 문제와 제가 선택한 해결법은 아래와 같습니다.

  declare const global; // Node.js global 객체

  // JSDOM 8.5 버전을 이용하여 브라우저 객체 생성 후 global 객체에 바인딩
  global.document = jsdom(``, {
    url: RenderingOption.projectDomain,
    userAgent: RenderingOption?.userAgent,
    cookieJar: RenderingOption.cookieJar
  });

  global.window = document.defaultView;
  global.location = window.location;
  global.navigator = window.navigator;

  // Vue SSR Renderer의 renderToString 메소드 실행
  // 렌더링 중에 global의 window 객체 사용함  
  const resultHtml = await renderer.renderToString(RenderingOption.rendererContext || {});

  // JSDOM close 이후 SSR 결과 반환
  global.window.close();

  return resultHtml;

JSDOM 객체를 global에 바인딩하면 SSR Renderer는 브라우저 객체를 사용할 수 있게 됩니다.

JSDOM을 적용하기 위해서도 우여곡절이 있었지만, 어쨌든 가장 큰 걸림돌이었던 브라우저 객체 사용 문제는 이렇게 해결이 되었습니다.



결국 JAVA 백엔드에서는 정상적이고 유지가능한 방법이 없다고 판단하게 되었습니다.
정확히는 ‘SSR을 수행해서 얻는 이득’‘SSR을 수행하는데 드는 리소스’를 비교했을 때 SSR은 수행할 가치가 없다는 결론을 내리게 된 것이죠.

우선순위 이렇게 사용자에게 보이지 않고 영향이 작은 작업은 나중에 하는게 맞겠죠?


여기까지가 2019년 하반기에 수행했던 최적화 방안 및 기술 리서치입니다.
팀 내에 관련 정보를 공유하고 한동안 다른 프로젝트와 서스테이닝 작업을 진행했죠.




그리고 2020년이 되었습니다.

2020년 상반기 모바일 줌은 다시 한번 변화를 맞게 됩니다. 바로 운영 환경 변경입니다.
줌 인터넷도 다양한 서비스를 도커 기반으로 옮기고 비용 감소 및 오토 스케일링 최적화를 하기로 했습니다.

이쯤 해서 저희 포털 개발팀은 한가지 큰 고민을 하고 있었습니다.
‘과연 Spring Boot가 효율적인가?’ 라는 고민이죠.
당연히 장단점이 있고, 유지보수를 위해 Spring을 이용하는 것은 아주 유효합니다.

하지만 저희 팀이 맡고 있는 프론트 서비스 프로젝트는 대부분 ‘API Aggregation & Frontend Serving’를 주력으로 하는 프로젝트이기 때문에, 대부분의 비즈니스 로직은 API 서버에서 수행하고 프론트 서비스는 사용자에게 보여주어야 하는 정보를 처리하는 역할을 합니다.

아키텍쳐2 위 아키텍처에서 유추하실 수 있듯 프론트 서비스는 아주 가볍게 운영할 수 있습니다

저희 팀은 그런 프로젝트에 Spring Boot는 너무 무겁고 과하다는 결론을 내리게 됩니다.
그래서 바꿉니다. Node.js로. Express.js로.

컨테이너 변경 Spring Boot 어플리케이션을 Node.js 어플리케이션으로 변경합니다

이것은 Netflix에서 택한 방법이기도 합니다.
서비스의 일부를 Node.js 기반으로 변경하여 개발 생산성과 유지보수 편의성을 높이는 방법이죠.


표준화 라이브러리

이제 다른 문제가 발생합니다. Node.js 기반 웹 프레임워크인 Express.js는 너무 자유로워 지옥같은 코드가 만들어질 확률이 너무 높습니다.
저는 이런 문제를 막기 위해 표준화 라이브러리 개발을 택했습니다. 백엔드 코드는 Typescript를 사용하도록 권장하고, 자주 사용하는 기능을 데코레이터로 개발해두어 코딩 스타일을 어느정도 강제화하는 방법이죠.

자주 사용되는 기능은 대표적으로 캐시가 있습니다.
프론트 서비스 서버에서 API 서버로 요청을 보내고 응답을 캐시해두는 패턴은 대부분의 서비스에서 사용하고 있지만 각 서비스마다 다르게 구현되어 유지보수에 어려움이 있었습니다.

표준화 라이브러리에는 백엔드에서 사용될 데코레이터와 프론트엔드에서 사용될 Webpack 설정 등 다양한 코드와 기능을 작성했고, 사내 넥서스에 배포하여 npm install로 손쉽게 설치할 수 있도록 했습니다.
아래 코드는 사내 표준화 라이브러리를 사용한 예제 코드입니다

/**
 * HomeController.ts 
 * 표준화 예시 코드
 */
@Controller({path: '/'})
export class HomeController {

  constructor(@Inject(CalculateService) private calculateService: CalculateService) {
  }

  @GetMapping({path: '/hello'})
  public hello(req: Request, res: Response) {
    res.json({
      hello: 'world',
      add: this.calculateService.add(100, 200)
    });
  }
}


/**
 * CaculateService.ts 
 */
@Service()
export class CalculateService {
  constructor(@Yml('application') private application?: any) {
    console.log('constructor')
  }

 /**
  * 이 클래스의 객체 생성 후 실행
  */
  @PostConstructor()
  public async postConstructor() {
    console.log('post constructor')
  }

  /**
   * 이 메소드 결과를 캐시함. 
   * 10초마다 이 메소드를 실행하여 result.completed가 false일 때에만 캐시
   */
  @Caching({key: 'test', refreshCron: '10 * * * * *', unless: (result) => result.completed === true})
  public async test() {
    const result = await Axios.get('https://jsonplaceholder.typicode.com/todos/1');
    return result.data;
  }


  public add(x, y) {
    return x + y;
  }
}

스프링 계열과 비슷하게 구현하였는데, 데코레이터를 이용하여 Express.js의 기능을 사용하고 더불어 설정 값이 저장된 Yml 파일을 로드하거나 메소드의 응답 값을 캐시하는 등 유용한 기능을 구현해 두었습니다. 이 라이브러리는 다른 서브도메인 개발에도 큰 도움을 줄 수 있습니다. 같은 방식으로, 같은 코드로 서비스 개발이 가능하기 때문입니다.


다시 SSR

Node.js 기반 백엔드로 변경하며 SSR을 적용하지 않을 이유가 없어졌습니다.
앞서 말씀드렸듯 여러가지 테스트를 했었고, 준비를 해 두었기 때문에 손쉽게 적용할 수 있었습니다.
하지만 표준화 라이브러리에 JSDOM이 포함된 SSR 수행 코드를 녹여내기 위해 옵션을 추가하거나 공통화된 개발을 진행했습니다.

SSR_OPTION 다른 프로젝트에서도 사용하기 위해 이런 옵션들을 추가했습니다.

특히 SSR을 적용하면서 생각보다 PV가 많이 늘어나게 되었는데, referer 값을 확인해본 결과 검색 엔진 노출 빈도가 크게 증가했음을 알 수 있었습니다.


도커 컨테이너 생성

Node.js를 기반으로 하는 프로젝트는 본래 파일을 모두 전송하고 npm run … 명령어로 어플리케이션을 실행합니다. 어플리케이션을 관리하기 위해 PM2나 간단하게는 nodemon과 같은 NPM 어플리케이션을 추가로 사용하죠.
하지만 이번 작업의 목표 중 하나는 도커 컨테이너 기반 운영 환경이었습니다. 그렇기 때문에 Node.js 어플리케이션 도커라이징을 위해 도커 파일 생성과 젠킨스 설정이 필요했습니다. 이 작업은 수많은 가이드가 있으니 어려운 점은 없습니다. 단지 timezone 설정을 추가해야 했을 뿐이죠.

도커 타임존 설정 도커 컨테이너의 타임존 설정

추가로 표준화 라이브러리에 적용해 두었던 winston logger에도 타임존 설정이 필요했습니다. winston timezone 타임존 오프셋을 계산한 후 toISOString 메소드를 이용하여 출력

간단하게 toISOString 메소드를 이용하여 로그 시간을 출력하기 위해 타임존 오프셋을 계산하고 date 값을 조정했습니다. 좋은 방법은 아니지만 단순 로그 출력에는 효과적입니다.

이런 설정이 적용된 winston 로그는 winston log 위와 같이 출력되어 도커 로그로 쌓이게 됩니다.


성과

위의 고민들과 추가적인 작업들을 통해 모바일 줌 프로젝트는
도커 컨테이너 운영 환경에 Node.js Express 백엔드에 Typescript 기반으로 Vue.js 프론트엔드를 구성했고 SSR이 적용된 프로젝트가 되었습니다.
설명하니 거창해 보이네요. 성과를 정리하면 아래와 같습니다.

  1. SSR 적용으로 타 검색엔진에서의 유입 증가
    기획팀의 도움을 받아 PV 증감을 비교해 보았는데 생각보다 큰 PV 변화를 확인할 수 있었습니다.

    SSR_PV 가볍게 생각할 수 없는 수준의 PV 증가를 확인했습니다

    이 결과를 통해 네이버와 다음의 사이트 크롤러는 헤드리스 브라우저를 사용하지 않는다는 것을 알 수 있었습니다. 의외인 것은 JS를 실행하는 크롤러를 통해 웹 사이트를 수집하는 구글에서 유입된 pv도 증가했다는 것입니다. 이는 검색엔진 노출 친화도가 높아져 검색 결과 순위가 상승한 것으로 예측됩니다.

  2. 사양 대비 성능(TPS) 증가
    TPS CHECK Jmeter를 이용한 테스트 결과

    많은 테스트 결과들이 보여주든 싱글 쓰레드에 가까운 Node.js 특성상 코어가 작은 시스템일 때 상대적으로 더 좋은 성능을 보여줍니다. 실제로 같은 사양 대비 TPS가 약 40%증가하는 결과를 얻었습니다.
    TPS뿐만 아니라 메모리 사용량도 절반 이상 줄어들어 같은 컨테이너에 다른 어플리케이션을 추가로 더 기동할 수 있을 정도로 긍정적인 결과를 볼 수 있었습니다.

  3. 코드 다이어트
    Express 백엔드로 전환함으로서 전체 코드를 크게 줄여낼 수 있었습니다.

    Code Lines 1608 라인에서 472 라인으로 약 1/4로 줄어들었습니다

    같은 기능을 구현했음에도 백엔드 코드의 라인 수가 이렇게 줄어든 것은 언어적 특성뿐 아니라, 표준화 라이브러리에 Scheduling, Adapter 등 기능이 포함되어 직접 구현할 필요가 없어졌기 때문입니다. 이런 변화는 프로젝트의 주요 기능인 프론트엔드 개발에 더 집중할 수 있게 해줍니다.

    또한, 제가 가장 중요하게 생각하고 있는 유지보수 편의성에도 아주 큰 강점이 됩니다.
    코딩 스타일을 어느정도 강제화함으로서 담당자가 변경되고 인수인계 받더라도 비슷한 코딩 스타일로 작성되어 있어 적응하기 쉽기 때문이죠.

마치며

이 글에서는 자세하게 설명하지 않았지만 표준화 라이브러리를 유지보수 하는 것 자체도 꽤나 큰 작업입니다.
새로운 기능 (혹은 데코레이터)가 필요하게 될 수도 있고 버그가 발견될 수도 있겠죠.
하지만 앞으로 줌 인터넷에서 진행하게 될 많은 서브 도메인 사이트를 개발할 때에는 분명 큰 힘이 될 것이고, 그 장점이 단점을 상쇄할 것이라고 생각합니다.

어쨌든 이렇게 모바일 줌은 최신 스펙을 겸비한 꽤나 재미있는 프로젝트로 완전히 탈바꿈했습니다.
사실 모바일 줌에서 가장 재미있는 부분은 Dynamic Component를 활용한 프론트엔드와 API 부분입니다만, 어째선지 글을 작성하지 않았었네요. 어디선가 다시 이야기할 기회가 올 거라 생각합니다. 어쩌면 서브도메인에서, 어쩌면 줌닷컴 메인을 개편하고 다시 이야기 할 수 있겠죠.

2019년 하반기와 2020년 상반기를 아우른 이 글은 여기서 마무리 짓겠습니다.

지금까지 읽어주셔서 감사합니다.