Server측 번들 테스트하기

요즘 관심있는주제이고, 실제로도 작업을 짬짬히 하고있는 주제이기도합니다. Server측과 Client측을 엄격하게 테스트하려는 시도가 얼마나 성공적인지, 얼마나 진지하게 시도되는지 잘 알지는 못하지만, 우리 개발자들이 가장 난해해하고 해결하는데 가장 시간을 많이 들이는 에러였기에 꼭 해결해야하는 문제였습니다.

Package의 발전과정

 import '@corporation/utils'

이 Package의 엔트리를 보면 개발자가 얻을수있는 정보는 많지않다. 비즈니스로직은 아닌데, 유틸성 함수들이 모여있는곳이구나! 정도만 알수있다. 하지만 개발자에게는 꽤 긍정적인 신호라고 볼수있는것이, 이 패키지를 발전시켜보고싶은 의욕이 생긴다. 개발자는 공통 로직을 모아보고, 반복하지않으려고 하니까. @corporation/functions 는 결국 거대한 패키지이자, 모든 패키지들이 자주쓰는 패키지로 발전합니다.

// packages/utils/index.ts
export * from './functions';
export * from './react';
export * from './test';
export * from './dom';
export * from './zod';
export * from './time';
// ...etc
export * from './language';

이런 구조는 ServerSide에서 뭔가를 하는 Nextjs나 Remix같은 플랫폼에서는 아주 빠른시간안에 문제가생깁니다.

 // import '@corporation/utils'

import '@corporation/utils/functions'
import '@corporation/utils/react'
import '@corporation/utils/test'
import '@corporation/utils/dom'
import '@corporation/utils/zod'
// ...etc
import '@corporation/utils/language';

이렇게 배럴파일을 나누면 일부 문제가 해결됩니다. zod 로직만 필요하면 zod subpath만 접근하면되고, 서버사이드에서는 @corporation/utils/functions 가 안전하므로, 서버사이드에서는 특정 subpath를 쓰지말아달라라는 모종의 약속이 생깁니다. 하지만 개발자 조직 규모가 커지고, @corporation/utils/functions 가 서버사이드에서 동작가능해야한다는 약속은 흐려지고 잊혀집니다. @corporation/utils/functions 는 subpath 이름이 개발자에게 큰정보를 주지않으므로, 머지않아 브라우저 로직이 배럴에 들어가게됩니다. 테스트코드로 저 subpath가 서버측에서 안전한지를 검증하지않았으니까요. 결국 이렇게 됩니다.

// import '@corporation/utils/functions'

import '@corporation/utils/functions/device'
import '@corporation/utils/functions/log'
import '@corporation/utils/functions/array'
import '@corporation/utils/functions/cookie'
import '@corporation/utils/functions/datadog'

이런식으로 서버측에서 에러만 안나게 하자! 전략으로 갑니다. 이것도 근본적인 해결책은 아닙니다.

Cjs Require Test

말그대로 nodejs 환경에서 배럴을 불러오는겁니다.

// test.cjs
require('@corporation/utils/functions')

nodejs 서버에서 test.cjs 를 호출하기만 하면됩니다. @corporation/utils/functions 의 cjs 환경에 맞게 번들된 배럴이 서버사이드에서 에러를 내지않고 import 가 가능하다는것 까지는 커버가 됩니다.

Playwrights

// fixtures/ssr/src/app/page.tsx
impoirt { showLog } from '@corporation/utils/functions'

// edge runtime을 사용하는경우 runtime 명시 하면됩니다.
// export const runtime = 'edge'

export default function Home() {
  showLog('hello world');
  return <h1>app router: /</h1>
}

// fixtures/ssr/e2e/app/page.spec.tsx
import { expect, test } from '@playwright/test'

test('/', async ({request}) => {
  const response = await request.get('/');
  expect(response.status()).toBe(200);
  expect(await response.text()).toContain('app router: /')
})

예시를 Nextjs로 들었지만, Nextjs나 remix 서버사이드에서 특정 패키지의 배럴이 import 성공하는지 체크하고, Playwright에서 에러없이 렌더링이 가능한지를 테스트합니다.

개발자에게 세심함을 보여주면, 따라옵니다.

import '@corporation/server-utils'

@corporation/utils/* 보다는 @corporation/server-utils 는 확실히 server side를 고려했다는 암시를 줍니다. 패키지이름만으로도 주는 인상은 효과적이라고 생각합니다. 워크스페이스안에 cjs 테스트코드나, playwrights 테스트코드까지있으면 개발자는 자연스럽게 서버사이드에서 동작하려면 어떻게해야하는지 의식하고, CI에서 망가지지않는 패키지를 만들기위해서 노력하게됩니다.

To Be Continued…

몇가지 치명적인 한계가 존재한다는걸 짚어야합니다. 결국 운영과 사람의 문제입니다.

  1. 이걸 관리하는 오너십이 누구인가? 로 귀결됨. 오너십이 불분명하면 CJS require 테스트, Playwrights도 시대에 뒤처지고 커버리지가 낮아짐.
  2. 새 개발자가 @corporation/utils/functions 를 수정하거나 추가할때는 게이트를 거치게되지만, 완전히 새로운 패키지를 만들었을때 이 인프라가 존재한다는걸 자연스럽게 알게되도록 접근성이 좋아야겠죠. README 로 알려줄것인가? README 보세요 라고 하는건 시스템으로 해결하는게 아닐뿐더러 SRE팀원으로서 할말이 아니라고 생각함. 개발자의 세심함과 선의에 기대는 방법이기때문이죠.