솔브드 굿즈는 어떻게 탄생했을까

솔브드는 문제해결을 좀 더 재밌게 할 수 있도록 항상 여러 고민들을 하고 있습니다. 항상 솔브드의 기획을 도와 주시는 havana723님의 아이디어로 이번에는 (문제해결 자체와는 약간 거리가 있지만) 시즌 2 종료를 기념으로 지금까지의 성과를 모아볼 수 있는 개인화 굿즈를 만들었습니다. 시즌 2 종료 시점의 프로필과 1년간의 변화량, 그리고 스트릭이 있는 아크릴 굿즈 등입니다. 지난 1년을 돌아보고 다음 1년의 의지를 다지는 계기가 되길 바라는 마음으로 준비했습니다.

이번 굿즈 샵의 총 주문 건은 정확한 값을 밝힐 수는 없지만 몇 백에서 천몇 백 건 쯤이었습니다. 이렇게 많은 개인화 작업을 어떻게 했는지 시행착오와 우여곡절을 이야기해 보려고 합니다.

시제품

아크릴 굿즈의 제작은 디자인으로부터 시작됩니다.

디자인은 굿즈 제작에서 제일 쉬운 부분입니다. 다만 고려해야 할 점들이 있습니다. 바로 인쇄가 되는 방식인데요, 인쇄 방식이 다소 특이해서 인쇄소에 세 개의 파일을 제출해야 합니다.

아크릴은 기본적으로 투명한 재료입니다. 투명한 매체에 한 번만 인쇄하면 글씨도 투명해지기 때문에, 보통 위 그림과 같이 색상을 1차로 인쇄하고, 거기에 흰색 잉크로만 2차로 인쇄해 발색이 선명하도록 합니다. 뒷면이 흰색이면 예쁘지 않기 때문에 색상을 3차로 한 번 더 인쇄해 앞뒷면이 같도록 할 수 있습니다. 앞면에 인쇄된 내용의 손상을 막기 위해 앞면이 아니라 뒷면에 차곡차곡 인쇄됩니다.

따라서 디자인은 하나를 하더라도 인쇄소에 보낼 파일은 총 세 개가 됩니다. 아크릴 모양을 결정하는 칼선 파일, 인쇄할 내용을 결정하는 색상 파일, 그리고 인쇄 영역 중 발색을 선명하게 할 부분을 결정하는 흰색 파일입니다. 또, 뒷면에 인쇄하기 때문에 파일은 전부 좌우반전되어 있어야 합니다.

일반적인 아크릴 굿즈 작업파일은 아래와 같이 됩니다.

인쇄물이기 때문에 색상 작업은 CMYK로 해야 하는데요, CMYK는 잉크의 비율로 색상을 나타내는 방법이기 때문에 따로 ‘투명도’라는 개념이 없고, 따라서 투명과 흰색이 구별되지 않습니다. 그런 이유로 흰색 파일에서 흰색으로 인쇄할 영역은 대신 검정 잉크 100%로 작업해야 합니다.

그리고 보통 제가 사용하는 글꼴이 인쇄업체에 설치되어 있지 않은 경우도 일반적이기 때문에 모든 글자를 도형으로 바꿔서 파일로 전달해야 합니다.

여기까지가 이제 제가 일상적으로 하는 일입니다. 디자인을 시작한 시점부터 여기까지 오는 데에는 하루면 충분합니다. 다만 이건 한 종류의 디자인을 할 때의 이야기입니다.

많은 종류의 디자인을 작업해야 한다면

하지만 몇십 몇백 종류의 디자인을 작업해야 한다면 이야기는 조금 달라집니다. 이번 굿즈 샵의 경우가 딱 그렇습니다. 주문자의 1년 통계를 불러와서 디자인에 적용해야 합니다.

디자인 프로그램은 그렇게 친절하지 않습니다. 값을 하나하나 바꾸고, 글자들을 도형으로 바꾸고, 좌우반전하는 과정을 거쳐야 합니다. 이 과정에서 실수를 하지 않기란 어렵습니다.

하지만 우리가 누군가요, 개발자잖아요! 아마도 이런 작업을 자동화할 수 있는 좋은 방법이 있을 겁니다.

첫 번째 방법 — ExtendScript (실패)

제가 아크릴 파일을 제작하는 프로그램인 Adobe Illustrator에도 이런 자동화에 대한 수요가 많기 때문에 ‘액션’이라는 기능을 제공합니다. 일련의 작업 과정을 ‘녹화’하고, 나중에 액션을 한 번 클릭하면 녹화한 작업 과정을 그대로 실행해 줍니다. 말 그대로 매크로 같은 기능입니다.

그리고 더 나아가서 ExtendScript라는 자체 스크립팅 언어로 매크로를 짤 수도 있습니다. 예전에는 ExtendScript Toolkit이라는 자체 IDE를 제공했지만, 요즘은 VS Code 확장 플러그인을 설치해서 VS Code에서 ExtendScript 코드를 작성할 수 있습니다.

ExtendScript Toolkit

말이 ExtendScript지 사실 JavaScript와 거의 유사한 언어라서, TypeScript로 밥을 벌어먹고 있는 제가 쓰기에는 최고라고 생각했습니다. 이 기능이 있다는 걸 생각해내자마자 이 스크립트로 굿즈 파일을 자동 생성해 보기로 결심하고, 리서치에 들어갔습니다. ExtendScript가 JavaScript ES3 기반이라는 걸 알기 전까지는 순탄해 보였습니다.

저는 TypeScript 없는 환경에서 작업하지 않기 때문에 어떻게든 모던한 개발 방식으로 코드를 작성하고, 하나의 매크로 파일로 묶어 보려고 환경을 구성을 시도했습니다. Webpack과 TypeScript를 쓸 수 있으면 좋을 것 같습니다.

타입 정의와 API 문서

타입 정의는 따로 Adobe에서 제공하는 것은 없고, 대신 types-for-adobe라는 패키지가 있습니다. 문서도 docsforadobe.dev에 올라와 있는데, 공식인지 아닌지는 잘 모르겠습니다.

Typescript: "target": "es3"

놀랍게도 tsconfig.json의 target 필드의 값으로 es3이 허용됩니다1, 2. TypeScript 컴파일러 tsc는 아래와 같은 코드를…

`Hello ${person}, today is ${date.toDateString()}!`;

… 이렇게 바꿔 줍니다.

"Hello ".concat(person, ", today is ").concat(date.toDateString(), "!");

와, 즐겁네요! 이제 ES6 기능을 마구마구 쓰면서 개발할 수 있겠습니다.

아래 코드가 실행되지 않는 걸 경험하기 전까지는 말이죠.

elements.forEach((e) => e.move(groupItem, ElementPlacement.PLACEATBEGINNING));

로그를 열어 보니 forEach가 없다고 합니다. 아니, downleveling 잘 해 준다며요!

애석하게도 forEach라던가 Map, Set 같은 것들은 polyfill되지 않습니다. 이런 것들은 직접 polyfill해 줘야 합니다.

core-js

좋아요, polyfill이라는 단어가 나왔으니 core-js를 써 봅시다. webpack.config.js를 만들고, @babel/preset-env 같은 걸 구성해 봅시다.

그러면 이제 아래 코드가 실행이 안 됩니다.

x >= 10 ? x.toString() : x < 0 ? '00' : `0${x}`

이건 또 왜일까요?

ES3 스펙

ES3 스펙은 삼항 연산자 안에 삼항 연산자를 쓸 수 있도록 되어 있습니다3. 여기서 하나 상기해야 되는 사실이 있습니다. 이 코드가 돌아가는 환경은 어떤 Node 런타임과도, 어떤 브라우저와도 다르다는 사실입니다.

안타깝게도 ExtendScript는 ES3 같은 무언가지 ES3 그 자체가 아닙니다. Adobe가 ES3을 구현하려고 노력한 결과물입니다. 구현에 성공한 결과물이 아닙니다.

그런 이유로, ExtendScript에서 삼항 연산자 안에 삼항 연산자를 바로 적는 것은 허용되지 않습니다. 괄호를 씌워줘야 합니다(a ? (b ? c : d) : e)4.

ESLint 입장에서는 이런 환경은 듣도 보도 못했으므로 당연히 빨간 줄도 안 그어 줍니다. 빨간 줄을 그어 주기 위해 babel 플러그인을 만들어야 했습니다.

module.exports = function (babel) {
  const { types: t } = babel;

  return {
    name: "wrap-ternary-in-parentheses",
    visitor: {
      ConditionalExpression(path) {
        const { node } = path;

        // Only transform if not already in parentheses
        if (!t.isParenthesizedExpression(path.parent)) {
          const parenthesizedExpression = t.parenthesizedExpression(node);
          path.replaceWith(parenthesizedExpression);
        }
      },
    },
  };
};

유감스럽게도 이와 같은 코드를 몇 개 더 짜야 했습니다.

산 넘어 산

일단 어떻게든 샘플 데이터로 태그 레이팅 아크릴을 그리는 데에 성공했습니다. 이제 자동화를 해야겠죠? 일단 ExtendScript로 전부 해 보려고 했습니다.

여기서 ExtendScript와 일반적인 자바스크립트 환경이 갖는 또 다른 차이점이 발목을 잡습니다. ExtendScript에는 fetch가 없습니다. 이 정도는 예상했으니까 그럴 수 있다고 칩시다. 그런데 XMLHttpRequest도 없습니다. ExtendScript가 네트워크와 통신할 수 있는 유일한 방법은 소켓을 직접 여는 것뿐입니다.

system.callSystem()이라는 게 있긴 한데, 이건 Illustrator에는 없고 Bridge라는 다른 앱에만 있는 기능이라, BridgeTalk 같은 걸 써야 했습니다. 이걸로 curl 해서 가져올 수 있는가 싶었는데, 결과적으로 잘 안 됐습니다.

자동화를 솔브드의 관리자 API를 직접 호출하는 방법으로 하려고 했는데, 이래서는 TCP 소켓을 기반으로 HTTP와 HTTPS를 직접 구현해야 했습니다. 다행히도 JSXGetURL이라는 확장 프로그램이 어느 정도 해결해 줬으나, 플러그인을 유료로 판매하는 경우가 많은 그래픽 디자인 도구 시장 특성상, (작업하던 5월 당시에는) 플러그인 개발자께서 6월이 지나면 해당 확장 프로그램의 동작을 멈추게 하고 유료 판매 모델을 도입하려고 했던 것 같습니다. 지금은 해당 시한이 12월로 연기된 것으로 보이는데, 당시에는 사용 시간 제한이 너무 큰 단점으로 다가왔습니다. 7월에도 수정 작업을 해야 할 수 있었으니까요.

그래도 할 수 있는 데까지 한 번 해 보리라 생각했습니다. 다만 여기까지 만든 후 문서화되어 있지 않은 ExtendScript와 JS의 차이점, 그리고 부실한 Illustrator API 문서에 너무 스트레스를 받아서 도저히 더 이상 할 수 없다고 판단했고, 이 방법은 그만뒀습니다.

이 정도까지 구현했습니다.

두 번째 방법 — SVG 생성

ExtendScript로 작업을 했던 이유는 CMYK 색상 공간에서 작업하기 용이해서였습니다. 특히 흰색 잉크 인쇄 레이어는 K100 색상으로 작업되어야 했는데요, CMYK의 K100과 RGB의 #000000은 전혀 다른 색상이기 때문에 이런 부분을 해결해줄 수 있겠다고 생각했기 때문이었음이 가장 큰 이유였습니다.

#000000은 C93 M88 Y89 K80으로 변환됩니다.

ExtendScript를 쓸 수 없다면, 이미지를 SVG 형식으로 만들 수 있는지 고민해보기로 합니다. SVG는 벡터 그래픽으로, 이미지를 여러 도형들로 정의하는 표현 방식입니다. Illustrator는 SVG 포맷을 읽을 수 있습니다.

SVG의 색상 공간은 RGB이기는 해서, 흰색 잉크 레이어를 검정색 이미지로 만들면 K100이 나오지는 않는데요, 대신 도형은 색상 변경이 이미지보다 훨씬 편리하기 때문에 Illustrator로 가져온 뒤 K100으로 색상만 바꿔 주면 됩니다.

이제 SVG를 어떻게 자동으로 생성할 것인지만 고민하면 됩니다.

도와주세요, 사토리님!

갑자기 뭔 오타쿠 게임을 들고 오나 싶을 수 있겠는데요, 귀엽고 재밌는 스무고개 게임이니 관심이 있으시다면 꼭 해 보시기 바랍니다. 오타쿠 게임을 통해 힘든 개발 과정을 이겨내고 뭐 그런 건 아닙니다.

예전에 Vercel이 오픈 그래프(OG) 이미지를 생성하는 기능을 추가했다고 홍보한 적 있어서, 기반 기술에 대해 관심을 갖고 찾아본 적이 있습니다.

해당 기술은 HTML과 CSS과 비슷한 문법의 코드를 작성하면 SVG로 바꿔 주는 라이브러리 satori를 기반으로 하고 있습니다. 오픈 소스여서 누구나 쓸 수 있습니다.

OG 이미지에 대한 이야기를 잠깐 하고 넘어가 봅시다. HTML + CSS가 워낙 괜찮은 레이아웃 엔진이라 OG 이미지를 HTML과 CSS 기반으로 만드려는 시도는 많이 있었는데요, 다 좋은데 HTML과 CSS는 너무 기능이 많아서 HTML과 CSS로 OG 이미지를 렌더하려면 브라우저를 켜서 캡쳐해야 했습니다. 자동화는 되지만 꽤 느립니다.

satori는 반면에, 비슷한 문법으로 이미지 렌더에 꼭 필요한 기능들만을 별도의 렌더 방법으로 제공함으로서 브라우저를 켜지 않아도 되게 하여 이 문제를 해결합니다. 아니, HTML이면 HTML이고 CSS면 CSS지 무슨 비슷한 문법일까요?

satori는 React 렌더러입니다. React 렌더러가 무엇인가 하면, 쉽게 말하면 react-native 같은 겁니다. react-native도 JSX 코드를 짜지만 ‘렌더’되면 DOM 트리가 나오는 게 아니라 여러 플랫폼의 네이티브 코드가 나오죠. 이렇게 React는 간혹 DOM 이외의 곳들에서도 쓰이는데, 심지어는 React로 영수증을 생성할 수 있는 프로젝트 react-thermal-printer도 있습니다.

여하튼 satori가 ‘렌더’하는 것은 SVG 코드입니다. 이미지 내 요소들을 프론트엔드 개발자에게는 너무 익숙한 선언적 방법으로 작성할 수 있게 해 주고, flexbox와 최대한 비슷하게 동작하는 자체 레이아웃 엔진을 구현해 친숙한 개발 방법도 챙겼습니다. 이미 JavaScript + React로 많은 걸 짜 왔던 솔브드의 입장에서는 레이아웃 구현의 공수가 낮으리라 예상했습니다. 실제로 solved.ac 프로필 상단부 레이아웃을 satori를 이용해 구현하면 이렇게 됩니다.

<span
  style={{
    fontSize: 48,
    fontWeight: 700,
  }}
>
  {handle}
</span>
{badge !== null && (
  <img
    src={badge.badgeImageUrl}
    style={{
      width: 64,
      height: 64,
      filter: 'drop-shadow(0 8px 8px rgba(100, 100, 100, 0.3))',
      marginLeft: 12,
    }}
  />
)}
<img
  src={classImgUrl(classValue, classDecoration)}
  style={{
    width: 72,
    height: 72,
  }}
/>

정말 편하게 레이아웃을 구성할 수 있었습니다. 모든 과정이 순탄합니다.

자동 생성을 고려해 봅시다. 저번에 ExtendScript로 했던 방식과 다르게 이제 아예 서버에 적당한 파라미터로 GET 요청을 보내면 서버에서 SVG 코드를 계산해 주도록 합니다. 이제 주문 목록을 기반으로 서버에 이미지 생성 리퀘스트를 보내고, 서버가 보내온 SVG를 어딘가 저장하는 스크립트를 짜면 자동 생성 완료입니다. 그런 고로, 위에 적은 코드는 솔브드의 클라이언트에 있는 코드가 아니라 API 서버에 있는 코드입니다.

이제 특정 URL에 주문번호만 넣으면 해당 주문번호의 굿즈를 자동 생성해 줍니다.

앞서 말했듯이 걱정했던 것들 중에 폰트 문제도 있었는데요, 웹 폰트 환경과 디자인 프로그램에서의 폰트 환경은 약간 달라서 변환이 필요할까도 고민이었는데, 스크린샷을 보시면 아시겠지만 satori가 텍스트를 애초에 모양을 따서 도형으로 렌더해 줘서 이런 고민이 필요없었습니다. 폰트를 읽는 정도의 능력이라니 대단하군요. 이제 이걸 Adobe Illustrator에 가져오기만 하면 됩니다.

일반적인 Express.js 프로젝트에서 이런 디펜던시를 보게 될 일이 얼마나 있을까요?

난관

그러나 당연하게도 이런 일반적이지 않은 작업이 순탄할 리가 없습니다.

Adobe Illustrator는 SVG를 지원합니다. 적어도 저는 그렇게 알고 있었습니다. 하지만 막상 파일을 가져오려고 해 보니 뭔가 불길한 경고가 뜹니다.

무슨 일이 일어날까요? 정말 기대가 됩니다.

오…

Illustrator는 제가 열심히 만든 SVG는 전혀 읽지 못하는 기염을 보여줍니다.

알아보니까 Illustrator는 SVG의 <mask> 요소를 그렇게 좋아하지 않는 것 같습니다5,6. 게다가 <image> 요소의 이미지 URL을 나타내는 src 속성에 Base64로 표현된 이미지가 있는데, 브라우저는 완벽하게 읽어 주지만 Illustrator는 이게 무슨 말인지 모르는 것 같아 보입니다.

SVG의 구조를 변경할 수 있을까 싶어서 satori가 생성한 SVG를 열어봤는데 <mask> 요소 없이는 아무것도 그리지 못할 것 같은 구조였습니다. 막다른 길을 만난 걸까요?

세 번째 방법 — PNG 생성

애초에 이런 고민들을 했던 이유는 마스크가 K100이어야 하기 때문이었습니다. 굳이 SVG가 아니더라도 RGB 색상 공간의 이미지를 불러와 CMYK의 K100으로 바꿀 수 있는 기능이 Illustrator에 있으면 됩니다. 메뉴 이곳저곳을 뒤져보다가 뭔가 그럴싸한 메뉴를 발견했습니다. ‘편집 > 색상 편집 > 회색 음영으로 변환’이었습니다.

CMYK 모드의 문서에서 ‘회색 음영’이 뜻하는 건 단 하나밖에 없을 것 같습니다. 속는 셈 치고 눌러 봅시다.

성공했습니다. 완벽한 K100입니다. 진작 메뉴부터 뒤져볼 걸…

뭔가 처음 생각했던 개발 방법과는 많이 달라진 느낌이 없지 않지만 여하튼 이제 정말로 굿즈를 자동 생성하기 위한 모든 퍼즐 조각이 모였습니다. 이제 맞추기만 하면 됩니다.

resvg를 사용하면 SVG를 PNG로 렌더할 수 있습니다. Express 서버에 아래 네 줄만 추가해 주면 됩니다. 다만 역시 PNG 렌더 과정이 추가되니 SVG보다는 다소 느렸습니다.

  const resvg = new Resvg(svg, opts)
  const pngData = resvg.render()
  const pngBuffer = pngData.asPng()

  res.contentType('image/png').send(pngBuffer)

이제 정말로 자동화에 돌입합니다. Illustrator에는 ‘변수’라는 기능이 있습니다. CSV로 ‘데이터 세트’를 정의하면 여기에 저장된 값을 그대로 가져다 쓸 수 있고, 각 데이터 세트마다 어떤 매크로를 실행하게 할 수 있습니다. 약간 디자이너 버전 forEach 함수 같은 느낌입니다.

데이터 세트의 변수 유형으로는 이미지도 있습니다. 파일시스템 내의 경로를 CSV 파일 내에 정의해 주면 됩니다. 예를 들어 이렇게요.

ItemId,@Profile,@ProfileMask,@Streak,@StreakMask
41,/Users/shiftpsh/merch-generate/images/41_profile.png,/Users/shiftpsh/merch-generate/images/41_profile_mask.png,/Users/shiftpsh/merch-generate/images/41_streak.png,/Users/shiftpsh/merch-generate/images/41_streak_mask.png
60,/Users/shiftpsh/merch-generate/images/60_profile.png,/Users/shiftpsh/merch-generate/images/60_profile_mask.png,/Users/shiftpsh/merch-generate/images/60_streak.png,/Users/shiftpsh/merch-generate/images/60_streak_mask.png
103,/Users/shiftpsh/merch-generate/images/103_profile.png,/Users/shiftpsh/merch-generate/images/103_profile_mask.png,/Users/shiftpsh/merch-generate/images/103_streak.png,/Users/shiftpsh/merch-generate/images/103_streak_mask.png

일단 이런 CSV를 생성해 주는 Python 스트립트를 만들었습니다. 대충

  • 주문 목록을 가져오고
  • 하나의 주문 당 한 번, Express 서버에 아크릴 이미지 파일 생성 요청을 보내고, 그걸 PNG로 저장
  • 저장된 파일의 절대 경로를 CSV에 작성

하는 코드입니다.

def main():
    rows = read_file()
    print(f"Found {len(rows)} orders")
    count = 0

    with open('s2_2023_merge.csv', 'w') as csvfile:
        writer = csv.writer(csvfile)
        writer.writerow([
            "ItemId",
            "@Profile", "@ProfileMask",
            "@Streak", "@StreakMask"
        ])
        for row in rows:
            count += 1
            print(f"Downloading images for order {row[0]}... ({count}/{len(rows)})")
            item_id = row[0]
            order_id = row[1]
            writer_out = [item_id]
            for image_id in image_ids:
                filename = f"images/{item_id}_{image_id}.png"
                filename_mask = f"images/{item_id}_{image_id}_mask.png"
                download_and_save_image(image_url(order_id, image_id, item_id, False), filename)
                download_and_save_image(image_url(order_id, image_id, item_id, True), filename_mask)
                writer_out.append(filename_prefix + filename)
                writer_out.append(filename_prefix + filename_mask)
            writer.writerow(writer_out)

이렇게 생성된 CSV 파일을 Illustrator에 넣어 줍니다. ‘변수 > 변수 라이브러리 불러오기…’를 선택하면 데이터 세트를 불러올 수 있습니다.

이제 이미지를 로드한 직후 저장 직전까지 수행할 작업들을 ‘액션’으로 정의합니다.

할 일들을 액션으로 정의하면 ‘액션 > 일괄 처리’를 통해 데이터 세트에 대해 앞서 언급한 forEach 같은 걸 돌릴 수 있게 됩니다. 제 경우에는 두 번째 캔버스에 그려지는 마스크의 검정색(#000000)을 K100으로 바꿔야 했기 때문에 ‘회색 음영으로 변환’을 넣어 줬습니다.

이제 정말로 굿즈가 자동 생성됩니다! 작업을 돌려 놓고 쉬다 오면 생성이 완료될 겁니다. 생성 완료된 파일은 인쇄소에 바로 전달할 수 있는 형태입니다.

발주

엄청난 박스들이 왔습니다. 잠잘 공간만 겨우 남긴 채로 5일 동안 포장을 했습니다.

뒤늦게 깨달은 실수

이상한 점을 찾아봅시다.

2023년 6월 5일 오전 6시를 기준으로 생성해야 하는 프로필 그래픽이 무슨 이유에서인지 2023년 6월 4일 오후 9시를 기준으로 생성되었습니다. 대체 왜일까요?

한국 시각 오전 6시니까 UTC 기준 전날 오후 9시인 건 맞는데, 크게 간과한 부분이 있습니다. 위의 코드는 틀렸습니다. 대신 아래와 같이 적어야 합니다.

new Date('2023-06-04T21:00:00Z')
// or
new Date('2023-06-05T06:00:00+09:00')

로컬에서 서버를 돌렸기 때문에 당연히 시간대를 표시하지 않았으니 저 줄은 한국 시각으로 해석되고, 9시간 앞선 데이터로 굿즈를 만들어 발주하게 된 것입니다. NASA가 인치와 센티미터를 헷갈려서 위성을 추락시킨 사례를 처음 듣고 웃어넘겼는데 그런 기관이 할 수 있는 실수라면 저도 마땅히 할 수 있는 것이었습니다. 오만해지지 말아야 합니다. 네.

인쇄 사고가 발생한 굿즈를 전량 다시 제작했고 다시 보냈습니다. 결국에 굿즈 샵 캠페인을 열고 남은 건 없습니다. 여러분이 남습니다.

짧은 소감

이외에도 굿즈 샵 개최를 위해 결제모듈을 연동하고 쇼핑몰 시스템을 자체 제작하는 등 해 보지 않았던 시도들을 해 보면서 재밌게 개발했습니다. Express 프로젝트에 React 설치해서 굿즈 자동화 하는 건 여태까지 듣도 보도 못한 개발 과정이고 어디서도 해보지 못할 경험인 것 같습니다.

서버비에 도움이 될까 기대했는데 이 부분에서는 너무 큰 실수를 해서 안타깝고 아쉽습니다. 아무리 테스트와 점검이 귀찮고 힘들고 오래 걸리더라도 이렇게 중요한 일을 하기 전에는 제대로 점검해야겠다는 생각을 하게 된 계기를 마련하게 되었습니다. 위성 추락보다는 싸게 배웠으니 다행입니다.

끝으로 솔브드에 많은 관심 가져 주셔서 항상 감사드립니다!

각주

  1. https://www.typescriptlang.org/tsconfig#target
  2. https://www.typescriptlang.org/docs/handbook/2/basic-types.html#downleveling
  3. https://www-archive.mozilla.org/js/language/e262-3.pdf, 168쪽
  4. https://community.adobe.com/t5/after-effects-discussions/extendscript-throws-on-nested-ternary-operator/m-p/9573874
  5. https://community.adobe.com/t5/illustrator-discussions/illustrator-does-not-understand-svg-masks/m-p/12408862
  6. https://github.com/MakieOrg/Makie.jl/issues/882

UCPC 2022에서 번거로운 디스크립션 작업을 초고속으로 해결한 방법

사용해 보기: BOJ 디스크립션 툴 / 소스: GitHub


한국에서는 프로그래밍 대회가 많이 열립니다! 정말 고무적인 일입니다.

의외로 전국 대학생 프로그래밍 경시대회ICPC 리저널이 매년 열리는 나라는 많지 않습니다. 서강대학교에서는 2005년부터 매년 대회를 열어 작년에는 무려 17번째 교내 프로그래밍 대회가 열렸고, 전국 대학생 프로그래밍 대회 동아리 연합에서는 올해로 11번째 대회를 개최했습니다. 넥슨과 삼성전자 — 대한민국 최고의 게임 기업과 대한민국 최대의 정보기술 기업 — 도 꾸준히 관심을 갖고 대회를 열고 있습니다(각각 7회, 8회째). 최근에는 현대모비스 및 여러 스타트업들도 자체 대회를 개최하고 있습니다.

이렇게 프로그래밍 대회에 대한 국가적 관심이 커지고 있는 상황에서 학교/동아리 및 커뮤니티 대회 개최에 대한 수요가 커지는 것은 어떻게 보면 당연한 일인데요, 백준 온라인 저지가 학교/동아리 대회에 대해 무료로 채점 환경을 제공하고 있다는 건 참 다행인 점입니다.

디스크립션 작업에서 발생하는 문제들

하지만 이런 좋은 플랫폼이 있음에도 불구하고 온사이트 대회에서는 문제지를 만들어야 한다는 점 때문에 디스크립션을 작성하는 과정에서 마주하는 근본적 문제들이 존재합니다.

  • 출제자: (BOJ에서만 지문을 수정하고) 디스크립션 수정했어요!
  • 검수자 A: (출력할 문제지를 보면서) 🤔 어디가 수정됐다는 거지…
  • 검수자 B: (BOJ 지문과 출력할 문제지가 다른 상황을 보면서) 😵 어느 쪽이 의도된 지문일까?

이런 상황이 생길 수 있기 때문에 세팅 경험이 많은 사람이 있다면 BOJ와 문제지 중 한 쪽을 유일한 원천single source of truth으로 두고 작업하도록 하는 경우를 볼 수 있습니다. 가령 지문 작업은 BOJ에서만 하고 마지막 날에 모든 문제를 문제지에 옮긴다던가, 아니면 반대로 하는 식입니다.

그래도 여전히 몇 가지 문제가 있습니다. 가령 문제지를 Google Docs나 Word 등에서 작업하고 BOJ Stack에 붙여넣으면 포매팅이 영 이상해집니다.

어느새인가 들어가 있는 볼드, 전부 깨져 있는 수식
  • BOJ에 문제를 올리려면 HTML이 꽤 깨끗해야 합니다. 개인적으로 좋은 제약조건이라고 생각합니다. 근데 워드 프로세서는 일반적으로 그렇지 않죠. 이 제약 때문에 워드 프로세서에서 바로 붙여넣기할 수 없습니다.
  • 그렇다고 메모장에 붙여넣은 후 거기서 다시 가져오자니 열심히 만들어 둔 예쁜 리스트와 수식들이 전부 깨집니다. 공들여 만든 수식 $X=\frac{p_1l_1+p_2l_2+\cdots +p_Nl_N}{p_1+p_2+\cdots +p_N} =\frac{\sum_{i=1}^{N}p_il_i}{\sum_{i=1}^{N}p_i}$를 붙여넣었더니 X=p1l1+p2l2++pNlNp1+p2++pN=i=1Npilii=1Npi가 되어 있는 건 그다지 유쾌한 경험은 아니겠죠.

반대로 가자니… BOJ 수식 렌더 방식은 LaTeX인데, 이걸 그대로 지원하는 워드 프로세서는 잘 없는 것 같고, 그렇다고 Markdown 기반으로 작업하자니 글자에 색상을 못 넣는다거나 그림 포매팅을 자유롭게 하지 못하는 등의 제약이 많습니다.1

워드 프로세서를 사용하지 않는다면 어떨까요? 프로그래밍 문제의 디스크립션에는 수식이 정말 많이 등장하기 때문에 ICPC를 비롯한 여러 대회에서 여러 세터들이 LaTeX로 세팅하는 편입니다. 요즘에는 문제 제작에 있어서 필수불가결한 플랫폼인 Codeforces의 Polygon 플랫폼도 디스크립션을 LaTeX로 입력하도록 하고 있으며, UCPC도 LaTeX로 문제지를 세팅하고 있습니다.

BOJ의 수식 렌더 방식이 LaTeX라니 뭔가 순조롭게 옮길 수 있을 것 같습니다. 하지만 LaTeX에도 문제가 많습니다. 가령…

  • 수식뿐 아니라 본문에서도 여러 명령어를 사용할 수 있습니다. 예를 들어 \alpha 명령어는 그리스 문자 α를 입력합니다.
  • 리스트도 명령어를 쳐서 만듭니다. \begin{enumerate} ... \end{enumerate} 등입니다.
  • 기타 LaTeX만의 이상한 점들이 있습니다. 예를 들어 '는 무조건 오른쪽 닫는 작은따옴표입니다. ``는 왼쪽 여는 큰따옴표를 렌더합니다.

결국 지금까지는 어떻게 하든 문제마다 디스크립션을 한 땀 한 땀 옮겨 줘야 하는 경우가 대부분이었습니다. 제 경우에는 이런 작업을 2019/2020/2021년 서강대학교 프로그래밍 대회, 2020/2021 겨울/2021 여름 신촌 연합, UCPC 2020의 일곱 번의 대회에서 모든 문제에 대해 해 왔고 올해도 숨은 왼쪽 여는 따옴표 찾기를 하고 싶지는 않았습니다.

그래서 만들었습니다: 디스크립션 툴

BOJ 디스크립션 툴

그래서 LaTeX, 특히 Polygon 플랫폼과 많은 대회들이 사용하는 olymp.sty 형식 디스크립션들을 복사-붙여넣기할 수 있는 HTML로 바꿔 주는 툴을 만들었습니다. latex-utensils를 이용해 LaTeX를 파싱해 AST로 만들고, AST를 탐색하면서 다시 HTML로 빌드해 주는 툴입니다. 생성되는 HTML은 BOJ Stack 가이드라인을 최대한 따르려고 노력합니다.

HTML 수식 모드

원하는 경우 MathJax를 사용하지 않고 sup, sub, em 등을 이용해 수식을 렌더하도록 할 수 있습니다. 이 모드에서 렌더한 모든 수식도 BOJ 가이드라인을 최대한 따르려고 노력합니다. 예를 들어,

  • LaTeX에서는 연산자와 문자 사이에 띄어쓰기가 없더라도 변환된 HTML에는 자동으로 띄어쓰기가 들어갑니다.
  • 수식 안에 있는 모든 문자는 HTML로 변환할 때 이탤릭이 됩니다.
  • \times&times;가 됩니다. - (빼기 기호)는 &minus;가 됩니다.

라이트 버전 MathJax라고 생각하시면 됩니다. 아직 안 되는 것들도 있습니다. 명령어 재정의와 tabular 환경, 그리고 이미지 지원이 대표적인 예인데, 이미지 지원을 제외하고는 아마 다른 대회에서 출제/검수를 맡게 될 때 만들지 않을까 싶습니다.

바뀐 워크플로우

이제 Polygon 패키지 혹은 문제지를 만들고, 변환기의 도움을 받아 HTML로 변환한 뒤 BOJ Stack에 복사/붙여넣기만 하면 됩니다.

디스크립션 작업을 버전 관리가 되는 Polygon 혹은 실시간 편집이 되는 Overleaf의 둘 중의 하나로 일원화할 수 있어서 플랫폼 간의 컨플릭트가 사라지고, LaTeX에서 HTML 또는 HTML에서 LaTeX로 변환하는 과정을 더 이상 손으로 하지 않아도 되기 때문에 실수할 여지도 줄어들고 시간도 아낄 수 있습니다. 디스크립션 변환할 시간에 틀린 풀이 하나 더 짜고 데이터 하나 더 만들어 넣을 수 있게 되었습니다.

이미 UCPC 2022의 모든 문제를 이 툴을 사용해 변환했습니다. 모든 변환은 클라이언트에서 이루어지니 문제 유출 염려 없이 안심하시고 사용하셔도 괜찮습니다.

ckeditor 대응

Stack은 ckeditor를 쓰고 있는데, 포매팅이 있는 외부 HTML을 복사-붙여넣기하면 시맨틱한 요소를 빼고 모든 포매팅을 지워 버립니다. 아래 스크립트를 실행해 붙여넣기 필터를 전부 없애 줘야 합니다.

var o=CKEDITOR.filter.instances;Object.keys(o).forEach((k)=>o[k].disable())

여담

툴이 완벽하지 않은 부분들이 있으니 버그를 발견하신 경우 GitHub에 이슈 혹은 PR을 남겨 주시면 감사하겠습니다.

각주

  1. 디스크립션에서 글자에 색상을 넣지 못하는 건 의외로 중요한 이슈입니다! 특히 입력부에서 제시하는 어떤 문자열을 그대로 출력하라고 할 때 모노스페이스 폰트와 함께 글자 색상을 바꾸면 가독성이 굉장히 향상됩니다.

2022년에 React 컴포넌트 라이브러리 만들기

@solved-ac/ui-react를 만들기 위한 여정

TL;DR:

  • create-react-library는 쓰지 마세요.
  • peerDependencies에 추가하는 라이브러리는 devDependencies에도 추가하세요.
  • styled-components 기반 라이브러리에서 SSR 이슈가 발생한다면 이 글을 참고하세요.

저는 다음 달이면 3년차가 되는 프론트엔드 개발자입니다. 하나 고백하자면, 안타깝게도 저에게는 프론트엔드 사수가 있었던 적이 없습니다. 여태까지 독학한 React 지식으로 얼렁뚱땅 일해왔다고 할 수 있습니다. 여태까지는 잘 먹혔습니다.

근데 이제 파트장입니다. 야 이거 큰일 났다. 면접도 내가 봐야 되고 신규입사자 교육도 내가 해야 되는데 나는 아는 게 하나도 없네…

그래서 인터넷의 힘을 빌리기로 합니다.

작성했던 코드를 잘 짰던 못 짰던 일단 올리고 보는 겁니다. 이렇게 하면 사수 분들이 마구마구 생기겠지? 회사 코드를 올릴 수는 없고, 마침 개인적으로 컴포넌트 재사용에 대한 니즈가 있던 solved.ac 코드를 정리해서 올려보기로 합니다.

첫 삽 뜨기

모르는 게 있을 때 취해야 하는 참된 개발자의 자세, 바로 구글 켜기입니다.

구글에 creating a react component library를 검색한 결과

1시간 정도 구글링해본 결과 아래 옵션들로 정리할 수 있었습니다.

Bit은 좋아 보이지만 나중에 뭔가 하려면 돈을 내야 될 것 같은 분위기를 느껴서 제외했습니다. 그냥 rollup을 직접 쓰는 것과 create-react-library의 도움을 받는 것 중에서 고민하다가 create-react-library를 골랐습니다. 간단해 보여서였습니다.

프로젝트를 만들고 기존에 쓰던 테마 정의와 함께 Button 컴포넌트를 옮겨왔습니다.

어 그런데 뭔가 이상합니다.

이 왜 any?

분명히 테마도 타입 정의가 잘 되어 있고 styled-components도 잘 임포트되어 있는데 테마 속성들이 전부 any로 뜹니다. 심지어는 styled component prop도 any가 뜹니다. 타입 추론이 없는데 어떻게 개발을 합니까? 이건 천재지변입니다.

styled-componentspeerDependencies에만 있고 devDependencies에는 없었음을 확인하고 고치는 데는 의외로 많은 시간이 걸렸습니다.

dependencies, devDependencies, peerDependencies

TL;DR: 라이브러리를 개발할 때 peerDependencies에 뭔가를 추가하려면 devDependencies에도 똑같은 패키지를 추가해야 합니다.

너무 기니까 dependencies를 줄여서 deps라고 부르도록 합시다.

depsdevDeps는 패키지를 빌드했을 때 프로덕션 번들에 포함되는지 아닌지의 차이가 있습니다. devDeps에는 주로 @types/*라던가 Prettier, Babel 플러그인과 같이 개발 과정이나 빌드 등을 도와주는 패키지들이 들어갑니다. 이미 완성된 코드에다 ESLint를 돌릴 이유는 없으니까요.

하지만 depsdevDeps는 사실 일반적인 프론트엔드 프로젝트에서는 별 상관이 없습니다. 이는 webpack의 번들 방식 때문인데, webpack은 entryPoint부터 시작해서 import들을 따라가면서 패키지들을 필요에 따라 넣기 때문입니다. create-react-app@types/* 같은 의존 패키지들을 전부 devDeps가 아니라 deps에 때려박아도 별 일 없는 이유이기도 합니다. 개인적으로는 싫지만…

peerDependencies

라이브러리를 만들면 아무도 의존하지 않는 패키지 – 예를 들면 프론트엔드 앱 – 를 만들 때는 볼 수 없었던 peerDeps와 마주하게 됩니다. peerDeps에 의존성을 추가하면 내 패키지에서 의존성을 관리하는 대신 내 패키지를 의존하는 패키지에서 의존성을 대신 관리하게 됩니다.

말이 조금 헷갈리는데, 예를 들어 내 프로젝트가 라이브러리 A, B, C를 쓰는데, 세 라이브러리 모두가 D라는 패키지에 의존한다고 합시다.

  • 세 라이브러리에서 D를 deps로 두는 경우에는 node_modules에 A > D, B > D, C > D 모두가 들어가게 됩니다.
  • D를 peerDeps로 두는 경우에는 node_modules에 A, B, C, D가 따로따로 들어가고, 내 프로젝트 단에서 A, B, C 각각이 내 프로젝트에서 직접 가져온 D에 의존할 수 있도록 해 줍니다.

요약하면, peerDeps는 의존성 트리 최적화를 위해 내 패키지를 쓸 패키지들에게 ‘이거 대신 설치해 주세요’라고 설명하는 것과 같습니다. 이건 ‘내 패키지 자체에서는 이 의존성을 굳이 쓰지 않겠어요’라는 말과 같은 말입니다. 다 좋은데 그러면 내가 내 패키지는 어떻게 개발하죠?

빌드된 모습

결과적으로는 peerDepsdeps 모두에 의존성이 들어가야 합니다. 이렇게 하면 빌드된 index.js에서 peerDepsrequire를 사용하도록 바뀌고 나머지는 잘 번들됩니다. 이 require는 로컬 환경에서는 deps에 의해 설치된 패키지를, 피의존 환경에서는 이 패키지의 peerDeps에 의해 설치된 패키지를 활용할 것입니다.

알고 나면 어렵지 않은 이유지만, 라이브러리를 만드는 입장에서 peerDeps를 설명해 둔 리소스가 현저히 적어서 알기까지 너무 오래 걸렸습니다. 이건 험난한 여정의 시작일 뿐이라는 걸 당시의 저는 몰랐습니다.

인터넷에 올리기 부끄럽지 않은 코드 짜기

const ButtonContainer = styled.button<ButtonContainerProps>`
  display: inline-block;
  vertical-align: middle;
  text-align: center;
  background: ${({ backgroundColor }) => backgroundColor}; // XXX
  /* ... */
`

이건 안 좋은 코드의 예입니다. solved.ac에서는 버튼에 색상을 그렇게 많이 집어넣거나 색상에 애니메이션을 줄 일이 없었기 때문에 기존에는 이렇게 구현했지만, styled-components는 모든 경우의 수마다 CSS 클래스를 하나씩 만들 거고 자칫 다이나믹할 수 있는 값을 이런 식으로 구현하면 퍼포먼스 이슈가 생길 것은 안 봐도 비디오, 웰 노운 팩트입니다.

따라서 CSS 변수를 사용하기로 합니다. 스타일드 컴포넌트 안에서는 색상 등을 var(--solvedac-button-background-color) 등으로 정의하고, 컴포넌트에 인라인 스타일로 --solvedac-button-background-color: #17ce3a와 같은 식으로 넣어주면 됩니다. 이걸 잘 쓰기 위해 타입스크립트의 도움을 받고 싶습니다. 예를 들어 아래와 같은 코드를 작성하면…

const [vars, v] = cssVariables(
  [
    'backgroundColor',
    'hoverBackgroundColor',
    'textColor',
    'hoverTextColor',
    'hoverShadow',
    'activeShadow',
  ],
  'button'
)

…스타일드 컴포넌트에서는 이렇게 가져다 쓰고…

const ButtonContainer = styled.button<ButtonContainerProps>`
  display: inline-block;
  vertical-align: middle;
  text-align: center;
  background: ${v.backgroundColor}; // Does not trigger class name generation which is good
  /* ... */
`

…인라인 스타일은 이렇게 넣을 수 있게 말이죠.

<ButtonContainer
  disabled={disabled}
  circle={circle}
  fullWidth={fullWidth}
  style={{
    [vars.backgroundColor]: computedBackgroundColor,
    [vars.hoverBackgroundColor]: computedHoverColor,
    [vars.textColor]:
      computedBackgroundColor &&
      readableColor(computedBackgroundColor, solvedTheme),
    /* ... */
    ...style,
  }}
  {...rest}
>
  {children}
</ButtonContainer>

이게 전부 타입 추론이 되게 하기 위해서 열심히 타입스크립트 매드무비를 찍습니다. readonly를 사용하면 string 배열을 tuple 취급하게 할 수 있습니다. 여기서 [...T]는 TS 4.0 기능입니다.

export const cssVariables = <T extends Array<string>>(
  names: readonly [...T],
  prefix: string
): [
  { [key in T[number]]: `--solvedac-${string}` },
  { [key in T[number]]: `var(--solvedac-${string})` }
] => {
  const vars = Object.fromEntries(
    names.map((name) => [
      name,
      `--solvedac-${prefix}-${name
        .replace(/[A-Z]/g, (m) => `-${m.toLowerCase()}`)
        .replace(/^-/, '')}`,
    ])
  ) as { [key in T[number]]: `--solvedac-${string}` }

  const v = Object.fromEntries(
    Object.entries(vars).map(([k, v]) => [k, `var(${v})`])
  ) as { [key in T[number]]: `var(--solvedac-${string})` }

  return [vars, v]
}

이렇게 하면 에디터가 타입 추론을 잘 해 줍니다. 그런데 갑자기 빌드가 되지 않습니다. 이번엔 왜일까요?

CSSProperties와 다시 만나는 declaration merging

리액트는 기본적으로 인라인 스타일에 --solvedac-button-background-color 같은 걸 허용하지 않습니다. CSSProperties의 키가 아니기 때문입니다. 리액트의 index.d.ts에는 이런 주석이 달려 있습니다.

export interface CSSProperties extends CSS.Properties<string | number> {
    /**
     * The index signature was removed to enable closed typing for style
     * using CSSType. You're able to use type assertion or module augmentation
     * to add properties or an index signature of your own.
     *
     * For examples and more information, visit:
     * https://github.com/frenic/csstype#what-should-i-do-when-i-get-type-errors
     */
}

직접 인덱스 시그니쳐를 만들고 싶으면 type assert를 하거나 module augmentation을 하라고 합니다.

Type assert는 웬만해서는 쓰기 싫기 때문에 declaration merging을 했습니다. 예전에 테마 타입 정의하는 데 고생한 적이 있어서 비교적 쉽게 해결했습니다. 이렇게 하면 됩니다.

type CustomProp = { [key in `--${string}`]: string }
declare module 'react' {
  // eslint-disable-next-line @typescript-eslint/no-empty-interface
  export interface CSSProperties extends CustomProp {}
}

오래된 react-scripts, 관리가 중단된 microbundle-crl

리액트 프로젝트에서 타입스크립트 관련해서 뭔가 안 된다 싶으면 이 친구입니다. react-scripts는 자기만의 webpack 설정 등을 쓰기로 유명합니다.

create-react-libraryreact-scripts@3.4.1을 설치해 줍니다. TS 4.0을 지원하지 않는 버전입니다. yarn add react-scripts@latest -D로 해결해 줍니다.

이외에도 microbundle-crl은 예전 버전의 Babel을 사용하고 있으며, 2년 전 microbundle 소스의 포크입니다. Unfork 해 줍니다.

Zero configuration을 표방하는 패키지를 종종 보게 되는데, 일반적인 경우에는 좋지만 일반적이지 않은 경우에는 꽤 골치아파지게 됩니다. 여담으로 react-scripts를 쓰면서 webpack 구성을 바꿔야 하는 경우가 있다면, eject하는 대신 react-app-rewired를 사용해 보시기 바랍니다.

이제 모든 게 다 잘 됩니다. 슬슬 solved.ac에 적용해 볼까요?

이상해요

뭔가 이상한 솔브드

사이트에 새 컴포넌트들을 적용하고 띄워 보니 패딩이 사라져 있습니다. 그럴 리가 없는데… 링크를 타고 다른 페이지들을 로드해 보면 정상적으로 보입니다. Prop `className` did not match와 같은 증상이 또 나타났습니다! 이번엔 테마 정의도 declaration merging으로 해 줬고 babel-plugin-styled-components도 잘 설정해 줬는데 대체 왜?

SSR의 악몽

생성되어 있는 componentId

dist/index.js를 확인해 봤을 때 컴포넌트 ID는 빌드 시점에 생성되는 것을 알 수 있습니다. Babel 플러그인이 잘 동작했다는 뜻입니다.

그러면 의심이 가는 부분은 solved.ac 프론트엔드 프로젝트에서 ServerStyleSheetcollectStyles 해 주는 부분입니다. 이 방향으로 검색해 봤더니 FAQ가 하나 나옵니다. yarn link에 대한 내용이지만 이 라이브러리에도 적용할 수 있을 것 같습니다.

styled-components는 싱글톤이고, 각자의 스코프에서 렌더할 컴포넌트들을 전부 관리합니다. 따라서 solved.ac 프론트엔드 프로젝트와 방금 만든 UI 라이브러리에 있는 styled-components는 다른 styled-components라는 뜻입니다. 이걸 강제로 같게 만들어서 한 쪽의 styled-components가 프론트엔드 프로젝트와 UI 라이브러리 모두의 컴포넌트를 관리하게 해 줘야 합니다.

모든 require('styled-components')가 특정 경로의 모듈로 resolve 되도록 모듈 alias를 해 주면 되는데 Next.js + Typescript 환경에서는 비교적 간단하게 해결할 수 있었습니다.

{
  "compilerOptions": {
    "paths": {
      "styled-components": ["./node_modules/styled-components"]
    }
  }
}

이렇게 해 주면 SSR도 잘 적용되는 것을 확인할 수 있습니다.

Update: @solved-ac/ui-react는 이제 emotion을 사용하고 있습니다. emotion은 일부 셀렉터를 제외하고는 SSR 환경에서 별도의 설정 없이 사용할 수 있습니다. (2022/06/20)

사수 무제한 제공 거짓말 사건

컴포넌트를 라이브러리화하고 정상적으로 렌더하기 위한 과정들이었습니다. 모르면 맞아야 하는 도메인 지식들이 너무 많은데, 컴포넌트 라이브러리를 만드는 사람이 많지 않은지 리소스도 상당히 적었고 그로 인해 너무 고생을 많이 했습니다. 나는 있는 코드를 그대로 올리면 누군가 코드 리뷰를 해 주지 않을까 싶었던 것뿐인데!

그래도 이제 잘 동작하니까 계속 여러 컴포넌트들을 정리해서 올려봐야겠어요. 가뜩이나 리소스 없는 분야인 것 같은데 이 글이라도 여러분의 쓸데없는 삽질 예방에 도움이 되었으면 좋겠습니다.

Prop `className` did not match

Next.js + styled-components에서 Prop `className` did not match가 발생하는 이유와 해결 방법

작년 가을에 solved.ac 프론트엔드를 Typescript로 리팩터하면서 당황스러운 경험을 했습니다. 사이트 내의 아무 링크나 클릭해서 다른 페이지로 가면 스타일시트가 전부 깨지는 것이었습니다.

콘솔을 열어 보니 다음과 같은 에러가 뜹니다.

Warning: Prop `className` did not match. Server: "sc-xxxxxx xxxxx" Client: "sc-yyyyyy yyyyy"

오류에서 알 수 있듯이 서버와 클라이언트에서 클래스네임이 일치하지 않아서 발생한 오류임을 알 수 있습니다. Next.js는 SSRServer-side Render을 도와주는 프레임워크입니다. SEO검색 엔진 최적화 등을 위해 처음 페이지를 로드할 때는 서버에서 렌더해 오지만, 페이지에서 링크를 클릭해 다른 페이지로 넘어갈 때는 CSR로 페이지를 렌더합니다. SEO와 속도 두 가지를 해결해 주는 프레임워크죠.

그럼 서버와 클라이언트는 기본적으로 같은 로직을 공유할 텐데 왜 이런 일이 일어나는 걸까요?

babel-plugin-styled-components가 없어서

첫 번째 이유는 babel-plugin-styled-components가 없어서입니다. 이 Babel 플러그인은 환경과 상관없이 일관된 className을 생성(consistently hashed component classNames between environments)해 줍니다. 헐 그럼 styled-components는 이 플러그인 없으면 기본적으로 className 생성을 ‘환경’에 의존한다는 뜻인가요 네 그렇습니다.

styled-components는 styled 함수로 만든 컴포넌트마다 generateId 함수를 이용해 유일한 식별자를 생성하는데요, 함수에서 확인할 수 있다시피 전역 카운터를 하나 두고 컴포넌트 하나를 처리할 때마다 증가시켜 가면서 생성됩니다.

위의 방법으로 식별자를 생성하면 어떤 일이 일어날 수 있을까요? 컴포넌트가 생성되는 순서에 따라 같은 컴포넌트이더라도 다른 식별자가 붙을 수 있게 됩니다! CSR에서는 상관없겠지만 SSR과 CSR을 같이 활용하는 경우 서버와 클라이언트가 컴포넌트를 생성하는 순서에 따라 식별자가 달라질 수 있습니다. babel-plugin-styled-components는 이런 식별자 생성 과정을 정규화해 줍니다.

다음과 같이 해결할 수 있습니다.

1. 플러그인 설치:

npm i --save-dev babel-plugin-styled-components

2. 프로젝트 루트의 .babelrc 편집(없을 경우 생성):

{
  "plugins": ["babel-plugin-styled-components"]
}

3. Next.js를 사용 중인 경우 이곳의 _document.js를 복붙해 오세요.

그래도 안 돼요

이 포스트의 핵심입니다.

solved.ac의 경우에는 저걸 다 했는데도 안 되길래 몇 달 내내 이거 고치려고 삽질을 했습니다. Typescript로 리팩터링하기 전엔 없던 문제였는데 Typescript를 들고 오면서 생긴 오류라, 뭐가 문제인지 찾아보다가 커스텀 테마 타입 정의 때문이라는 걸 알았습니다.

solved.ac 프론트엔드 코드에는 SolvedTheme라는 타입이 있고, 여기에 테마 정의를 넣습니다.

interface SolvedTheme {
  defaultFonts: string
  codeFonts: string
  background: string
  textColor: string
  textColorInverted: string
  textSecondaryColor: string
  border: string
  borderColor: string
  tableHeaderBackground: string
  tableBackground: string
  footerBackground: string
  primaryColor: string
}

기존의 경우 styled-components로 만든 컴포넌트에서는 이를 아래와 같은 식으로 활용해 왔습니다.

const TopBarContainer = styled.div`
  position: fixed;
  width: 100%;
  height: 48px;
  line-height: 48px;
  background: ${({ theme }) => theme.background};
  border-bottom: ${({ theme }) => theme.border};
  top: 0;
  left: 0;
  z-index: 10000;
`

Javascript에서는 문제가 없는 코드이지만 Typescript에서는 컴파일 에러가 납니다. theme의 타입이 위에서 정의한 SolvedTheme가 아닌 styled-components에 내장된 DefaultTheme이기 때문입니다.

우선 이 문제를 해결하기 위해 열심히 구글링을 했고, styled-components를 아래와 같이 제 타입 정의로 감싼 뒤 이렇게 만들어진 새로운 styled를 여기저기 import 해 와서 사용했습니다. import styled from '../styles/Themes' 같은 식으로요.

import * as styledComponents from 'styled-components'
import { ThemedStyledComponentsModule } from 'styled-components'

const {
  default: styled,
  css,
  createGlobalStyle,
  ThemeProvider,
  ThemeConsumer,
  keyframes,
} = styledComponents as ThemedStyledComponentsModule<SolvedTheme>

export { css, createGlobalStyle, keyframes, ThemeProvider, ThemeConsumer }
export default styled

그런데 이렇게 정의해 버리면 babel-plugin-styled-components가 의미가 없어집니다. babel-plugin-styled-components는 import 경로가 ‘styled-components’인 경우에만 작동하기 때문인데요.

옵션에서 바꿀 수 있어 보이지만 깔끔한 해결책은 아닌 거 같고, 대신 styled.d.ts를 새로 만들고 여기에서 DefaultThemeSolvedTheme으로 두면 됩니다. 그러면 위의 문제를 해결하면서 커스텀 테마의 타입 정의도 사용할 수 있습니다.

import 'styled-components'
import { SolvedTheme } from './Themes'

declare module 'styled-components' {
  export interface DefaultTheme extends SolvedTheme {}
}

(21/12/23 수정: 코드가 declaration merging을 사용하도록 수정했습니다. type DefaultTheme = SolvedTheme로 하는 경우에는 VS Code 에디터 자동완성 등의 기능을 제대로 활용하지 못했습니다.)

일반적인 원인은 아닐지 모르겠지만, 제가 영문도 모르고 몇 달간 고생한 걸 다른 분들이 겪지 않았으면 하는 마음에서 제 사례를 소개했습니다.

참고한 리소스