-
누적 지원자 5000명 IT 비영리단체 DND... 이제는 바꿔야할 때 feat. 마이그레이션회고 2024. 8. 2. 02:08
IT 사이드 프로젝트 동아리 DND에서 운영진 활동을 하고 있고, 11번째 기수가 바쁘게 진행 중인 와중에 누적 지원자수도 5000명이 넘었다.
현재 DND의 홈페이지 관리를 하고 있고 홈페이지를 마이그레이션을 하게 되었다. 이 과정에서 왜 마이그레이션을 했어야만 했는지, 기술적으로 고민했던 포인트와 개선했던 점들을 정리해 보고자 과정기를 작성해보려고 한다.잘 동작하는 홈페이지.. 왜 마이그레이션을 했을까?
마이그레이션 할 수밖에 없었던 이유는 크게 4가지의 정도 있다.
1. 추가적인 기능 개발과 유지보수하기가 힘들어진 레거시 코드
현재 DND에서는 몇 가지 기능을 추가하고 싶은 작업을 계획하고 있었다. 그렇지만, 기존의 홈페이지는 vue 2로 운영 중이었고 사소한 버그들 조차 수정하기 어려운 상태로 방치되어 있었다. 그로 인해서 간단한 상태 변경만 가능했고, 신규 개발이나 개선은 할 수 없는 상태였다.
2. 기존 DND 홈페이지를 유지보수하던 운영진의 부재
이전에 홈페이지를 운영하신 운영진의 장기 부재로 그 홈페이지 관리를 내가 담당하겠다고 했다. 이후 간단한 인수인계만 받았고 vue를 low 하게 사용해 본 내가 레거시 코드인 vue2 기반의 홈페이지를 관리하기엔 쉽지 않았다.
3. 불필요한 홈페이지 운영 리소스를 줄이기 위한 어드민 페이지 구현
대부분의 데이터가 정적인 JSON 파일로 S3에 올려져 관리되고 있다. 관리되고 있는 것들에는 프로젝트, 운영진, 후기 등이 있다. 이 데이터들을 한번 올려두면 거의 대부분 수정될 일이 없긴 하다. 하지만, 1년에 2번씩 매 기수 시작과 끝날 때마다 JSON 파일을 수동으로 수정해서 다시 올리고 있다. 한 번 두 번.. 정도는 할만했었는데 이 과정이 너무 귀찮고 왜 이렇게 불필요하고 비효율적으로 해야 할지 모르겠어서 어드민 페이지도 같이 구현하기로 했다.
4. 성능 개선
vue2 기반으로 구현되어 있는 코드는 기본적으로 대부분 SSG, SSR, ISR를 고려하지 않고 CSR로 동작하고 있었다. 그래서 페이지 자체가 정적인 페이지임에도 데이터를 가져오기 위해 불필요하게 api를 호출하기도 하기도 하였다.
이러한 이유로 마이그레이션 하기로 결정하게 되었다.
마이그레이션 전 중점적으로 고민해야 할 사항들
새롭게 프로젝트를 시작하기 전에 내가 왜 마이그레이션을 하는지를 곱씹어보면서, 제일 필요하다고 생각한 것부터 나열해 봤다. 개인적으로 마이그레이션을 결정하게 된 가장 큰 이유이자 첫 번째 이유는 수동으로 데이터를 수정하기가 너무 귀찮은 이유가 가장 컸다. (귀찮아.. 자동화.. 내놔 제발) 그래서 결국은 마이그레이션을 하더라도 admin을 고려해서 만들어야 한다.
두 번째로는 DND는 여러 운영진 있고 앞으로 내가 운영진을 안 하게 되어도 다른 운영자분들도 유지보수를 할 수 있는 코드 공통화 & 테스트 코드와 불필요한 외부 라이브러리 의존성은 최대한 없애야 한다고 생각했다. 그렇게 하지 않으면 언젠가는 또 새로운 프로젝트가 생길 것이다. 물론 언젠가는 그렇게 되겠지만 그 시기를 최대한 늦추고 싶은 바람이다.
세 번째 성능 개선의 포인트 + 비용 이슈였다. 성능 개선의 포인트로는 SEO 최적화가 필수였고, 불필요하게 CSR로만 동작하는 정적인 페이지를 ISR(SSG) 방식으로 바꾸는 방식과 client side에서 호출되는 api 제거하기 위한 부분이다.
비용 이슈는 DND는 비영리 단체로 후원을 통해서 운영되는 단체이다. 그렇지만 현재 IT 전반적인 업계 상황이 좋지 못해 후원이 들어오지 않는 상태여서 비용이 드는 부분은 최대한 제거하려고 한다. 따라서 aws에 올려둔 데이터를 최대한 제거하는 작업이 필수였다.이제 프로젝트 구조와 기술 스택을 고민해 보자
이런 점들을 고려해 프로젝트 구조와 기술 스택을 고민해 보자.
기술 스택
Next.js, React, TypeScript
일단, 가장 익숙하고 대중적인 React, TypeScript를 선택했다.
SEO 최적화와 잘 만들어져 있는 유용한 기능들도 사용하기 위해서 Next.js 프레임워크를 선택했다. Remix나 Gatsby와 같은 친구도 고려해 볼 수 있었지만, 빠르게 개발을 해야 하는 상황에서 가장 익숙한 프레임워크를 사용하는 게 맞을 것 같았고 Gatsby나 Remix는 얕게만 해본 경험이 있어서 학습을 해야 한다는 점이 부담스러워 Next.js를 선택했다. (다음번에 기회가 된다면 Remix는 사용해보고 싶다.)스타일
스타일은 css-in-js(styled-component, emotion), scss(sass) module, tailwind css, zero runtime css-in-js(styleX, vanilla-extract, panda css, linaria...)와 같이 적용할 수 있는 방법이 굉장히 많았다. 개인적으로 스타일이 현재 프론트엔드 생태계에서 적용할 수 있는 방법이 가장 많지 않을까라는 생각이 든다. (약간 혼돈의 카오스 느낌..)
모든 기술 선택에 있어서 정답이 없듯이 내가 정했던 기준을 바탕으로 하나씩 경우의 수를 제거해나 가보자.먼저 스타일을 javascript 코드와 같이 관리할 수 있어서 강력한 개발생산성으로 많은 사랑을 받았고, 여전히 많이 사용하는 (run time) css-in-js는 런타임에 stylis를 사용해 css를 구문 분석을 하여 클래스 이름을 생성 후 <style> 태그 생성 후 <head> 태그에 삽입을 한다. 이런 과정으로 인해 next.js app router와 react의 서버 컴포넌트가 생긴 시점에 이런 런타임 css-in-js는 호환되지 않는다. 결과적으로 css-in-js를 사용하여 스타일을 적용한 컴포넌트에서는 "use client" 선언문을 사용해서 클라이언트 컴포넌트로만 사용이 가능하다. 단지 스타일을 적용하고 싶을 뿐인데.. 클라이언트 컴포넌트로 사용을 해야 한다니..! 이건 불필요하게 클라이언트 컴포넌트를 사용하는 셈이기에 원했던 방법이 아니다. 심지어 emotion은 아직 지원하지 않는다.
이러한 이유로 런타임 기반의 css-in-js는 사용하지 않았다. 그렇다면 zero runtime css-in-js는 어떨까?
zero runtime css-in-js는 기존 css-in-js의 장점을 가져가면서 스타일이 런타임이 아닌 빌드 타임에 생성된다. 즉, 스타일을 주입하기 위해 추가적인 런타임 작업이 필요하지 않다. 그렇기 때문에 서버 컴포넌트에서도 충분히 사용이 가능하다. 여러 가지 zero runtime css-in-js가 있었고 그중 vanilla extract, panda css, styleX가 보였고 이들을 간단히 살펴보았다.
styleX는 메타에서 관리하고 있으며 babel을 사용해서 빌드 시에 css를 생성하며 동적 스타일링도 지원하기도 한다. 하지만, 아직 버전 0대이고 가장 중요한 next.js의 호환성이 좋지 못하다는 점이었다. 이유는 babel을 사용해야 하는데 next.js에서는 내부 컴파일러로 swc를 사용하고 있기 때문에 next.js에서 제공하는 기본 기능들을 사용하지 못할 수도 있다. (예를 들어 next/font.)
panda css는 ChakraUI에서 관리하고 있으며 공식 문서에 next.js app router 세팅도 소개하고 있다.
vanilla extract는 이미 당근에서도 사용 중이었고 확장성과 타입 안정성, 객체형태로 풀어나가는 방법 등 개발자 경험에 있어서 매우 좋아 보였다.이런 좋은 점들이 있음에도 zero runtime css-in-js는 선택하지 않았다.
첫 번째 이유로 위에서 얘기했던 중점적으로 고민했던 사항들 중 내가 운영진 활동을 하지 않게 되어도 앞으로 관리할 다른 운영자분들을 생각하여 유지보수성을 우선적으로 생각해 볼 필요가 있었다. 그런 점에서 보았을 때 css-in-js의 문법에 대한 학습이 필요하다는 점과 언제 또 바뀔지 모르는 불확실한 css-in-js의 생태계, css-in-js를 사용하기 위한 해당 라이브러리에 대한 의존성이 생기는 점으로 인해 유지보수성을 떨어트리는 요소들이 분명 존재했다.
두 번째 이유로는 css-in-js에서 느꼈던 좋았던 장점들과 개발 생산성이 뛰어나다는 점이 있었지만, 결국은 styled-component나 emotion처럼 언젠가는 또 바뀔 것 같은 불확실함이 컸던 것 같다. 빠르게 변하는 프론트 생태계에서 또 다른 좋은 라이브러리들이 생겨 마이그레이션 하는 과정이 지친다고 해야 할까...? 확실히 느꼈던 계기가 회사에서 styled-component를 사용하고 있었고 next.js에서 app router로 전환하기 위해서 모든 styled-component를 걷어낼 수밖에 없었던 명확한 이유와 사용하고 있는 모든 곳에서 걷어내며 통합 테스트를 한 과정이 그렇게 쉽지만은 않았다는 점에서 지칠 때로 지쳐버렸던 게 아닐까 싶었다.세 번째로 고려했던 tailwind css이다. tailwind css는 사용하기 전에는 className이 길어지는 점과 react 컴포넌트의 tag의 className에 인라인 스타일과 같이 적용되어 있어서 관심사가 분리되어있지 않아 가독성이 좋지 않은 점으로 인해서 개인적으로 선호하지 않았다. 하지만 사용하기 전까지는 모른다고 직접 사용해보니 느꼈던 장점은 일관성 있는 디자인 시스템을 적용할 수 있으며 개발 생산성 또한 css나 style블록으로 이동하지 않아도 돼서 매우 좋았고, shadcn, nextui와 같은 ui 라이브러리에서 tailwind css를 기본적으로 사용하고 있다는 점도 활발한 생태계를 입증하고 있었다.
하지만 tailwind css도 선택하지 않았다. tailwind css도 zero runtime css-in-js를 선택하지 않은 이유와 비슷하다.
첫 번째로 css-in-js보단 덜 하겠지만 학습 곡선이 분명 있었고, 일부 css 속성과 고급 기술을 사용할 때에는 불필요한 인라인 스타일을 추가하거나 결국 css를 파일을 생성해야 한다.
두 번째로는 나중에 마이그레이션을 하게 되면 tailwind css는 스타일과 코드가 매우 밀접하게 연결이 되어있어서 관심사 분리가 되어 있지 않아서 걷어내기가 쉽지 않을 수밖에 없다.
세 번째로는 tailwind css의 장점인 특정 값만 허용하여 일관된 디자인을 적용할 수 있다는 점인데 이 부분이 지금 현 상황에서는 단점이 될 수밖에 없었다. 이유는 현재 디자인이 일관되지 않은 spacing, size로 옛날 디자인을 바탕으로 작업을 해야 하기 때문에 모든 값을 일관되게 사용할 수 없어 결국 많은 값들을 커스텀 스타일을 사용해서 작성해야 한다. 이 문제를 해결하려면 먼저 일관되게 디자인이 재작업이 되어야 하는데 지금 당장 재작업을 할 수 있는 디자이너 분들의 여유도 없어서 그렇게 되면 마이그레이션 진행이 늦어질 수밖에 없었다.마지막으로 고려했던 module scss(sass)이다. sass는 공식적으로 next.js에서 제공해서 추가로 설정을 해줄 필요가 없이 바로 사용이 가능하며, 서버 컴포넌트에서도 바로 사용이 가능하다. 또한, css와의 호환성도 뛰어나고 넓은 범용성을 가진 scss는 css만 알고 있으면 다른 의존성에 영향 없이 스타일을 수정할 수 있어서 유지보수성도 좋다.
import styles from './index.module.scss' export default function Page() { return <h1 className={styles.title}>Hello, Scss!</h1> }
물론 단점도 있는데 tailwind css나 css-in-js에서 개발 생산성을 높여주는 요소들을 scss에서 mixin 함수로 직접 구현해야 한다는 점과 스타일을 작성하기 위해서 매 컴포넌트마다 scss파일을 생성해야 하기 때문에 코드와 스타일 파일 간 스위칭이 지속적으로 일어날 수밖에 없어 비교했을 때에 생산성이 떨어지는 점이 있다.
하지만 이런 개발 생산성을 조금 포기하면 내가 고려했던 부분은 모두 충족하게 된다.
scss는 의존성에 가장 영향을 받지 않아서 유지보수하기에도 좋으며, 스타일과 코드 간의 관심사가 명확히 구분되어 나중에 마이그레이션 할 때에도 쉽다. 이런 장점이라면 css를 사용하는 게 가장 좋지 않을까?라고 생각할 수도 있겠지만 css로 직접 사용하는 건 확실히 아쉬운 부분이 있다. 변수의 사용 및 조건문, 반복문, mixin 등 우리가 가독성과 재사용성에 도움을 주는 부분에서 공통으로 관리해서 불필요한 반복적인 코드를 줄여줄 수 있어 scss를 활용하면 매우 강력한 기능을 활용할 수 있다.@mixin text($text, $color-code: '') { // ... 생략 @if $font == 'H1' { font-size: var(--h1-font-size); } @else if $font == 'H2' { font-size: var(--h2-font-size); letter-spacing: var(--h2-letter-spacing); } // ... 생략 }
.title { @include text('H3/ExtraBold', 'white'); @include xs { @include text('H4/ExtraBold'); } } .description { @include text('body2/Regular', 'gray04'); &:hover { color: color('gray03'); } }
결국에는 이러한 고민들을 거쳐 scss module를 사용하기로 결정했다.
zustand & react-query
클라이언트 상태 관리 라이브러리는 zustand와 서버 상태 관리를 도와주는 data fetching 라이브러리는 react-query로 가장 익숙한 라이브러리를 선택했다. 하지만, 두 라이브러리는 거의 사용할 일이 없을 것이다. 특히 react-query를 사용해서 클라이언트 사이드에서 api를 호출할 일이 거의 없기 때문에 사용하게 된다면, 그때 추가할 예정이다. 서버 쪽에서 호출할 때에는 단순히 fetch api를 사용할 예정이다.
nextjs app router에서는 기본 fetch api를 확장하여 cache를 컨트롤할 수 있기 때문에 fetch api를 사용하기를 권장하고 있다.fetch(`https://...`, { cache: 'force-cache' | 'no-store' }); fetch(`https://...`, { next: { revalidate: false | 0 | number } }); fetch(`https://...`, { next: { tags: ['collection'] } });
axios를 포함한 다른 라이브러리를 사용할 경우에는 직접 캐싱 관련 설정을 해줘야 한다. axios가 주는 간편안 이점이 있긴 하지만, api를 호출할 일이 거의 없기 때문에 fetch api로도 충분할 거라 판단했다.
프로젝트 구조
프로젝트 구조에서 가장 고민이 되었던 건 admin과 web 홈페이지를 함께 사용하는 모노레포 형식으로 할지, 따로따로 각 프로젝트별 레포지토리를 하나로 구성할지가 가장 고민이 되었다.
고민을 했던 이유는 모노레포로 구성시 프로젝트 구조가 복잡해지고, 구조를 세팅하는 시간이 오래 걸린다는 점과 굳이라는 단어가 계속 떠다녔던 이유가 있었다. 기본적으로 lint 설정, typescript 세팅을 제외한 admin과 web 간의 공통으로 사용할만한 코드나 컴포넌트가 있을까라는 의문이 있었고, 애초에 두 프로젝트는 서로 다른 목적을 가져서 필요 없다고 생각이 들었다. 그래서 홈페이지를 먼저 개발할 때에는 모노레포를 선택하지 않고 개발하였다.
하지만, 홈페이지 개발 막바지에 어드민 개발을 시작할 때에는 모노레포로 바꿔야겠다고 생각하여 변경하였다. 이유는 어드민은 홈페이지의 정적 데이터를 수정하거나 추가하여 홈페이지를 업데이트를 하기 위해서 만든다. 그런데 이 데이터가 수정되고 추가되는걸 단순히 form 형태가 아니라 미리 보기 형태까지도 제공하면 비개발자가 데이터를 추가하거나 수정할 때에도 더 직관적일 것이고 개인적으로 재밌겠다는 생각이 들었다.
그러려면 홈페이지에서 사용하는 여러 컴포넌트들이 어드민에 사용되어야 하기 때문에 중복된 컴포넌트를 제거하기 위해서는 공통화시킬 필요가 있어진다.그렇게 하여 이후에 모노레포로 구성하였다.
turborepo with yarn v4.
모노레포 도구는 vercel에서 만든 turborepo를 사용했었는데, 이전에 사용했던 경험도 있고 개발자 경험이 좋았고 원격 캐시 기능도 있어서 빠른 빌드를 할 수 있어서 좋았다. 버전 2.0에서는 터미널 UI가 개선되었는데 꽤나 이쁘다...
turborepo를 사용할 때 한 가지 이슈가 있었던 점은 패키지 매니저를 yarn berry의 PnP와 zero install기능을 사용하고 있었는데, turborepo에서는 아직 이 PnP기능을 제대로 지원하지 않아서 의존성을 못 찾는 이슈가 있어 PnP 기능을 걷어내고 패키지 의존성을 node_modules로 변경하여 관리할 수밖에 없었다.
// .yarnrc.yml nodeLinker: node-modules
turborepo의 버전이 v1 초반에서는 공식적으로 지원하지 않았었고, turborepo의 메인테이너분께서PnP의 지원을 계획하지 않는다고 하셨던 걸로 알고 있었는데 버전이 업데이트되면서 PnP의 기능은 동작하도록 업데이트된 것 같았다.
- turborepo 1.2 yarn pnp turbo run 명령어에 한해서 부분지원
- turborepo 1.5 turbo prune 명령어까지 yarn 2+ 지원실제로 로컬에서 사용할 때에는 문제없이 잘 동작했는데, vercel에 배포할 때 의존성을 못 찾는 이슈가 있는 것 같아서 몇 시간 동안 해결방법을 찾다가 결국 마음 편하게.. PnP 모드를 사용하기 않게 되었다. (매번 PnP기능의 장점 때문에 사용하고 싶은데 이런 안 되는 이슈들 또는 기존과 다르게 설정해줘야 하는 귀찮은 일들이 반복적으로 생기다 보니까 하지말까라는 생각이 들긴 하지만 또 해결했을 때 뿌듯함 때문에 계속 시도하게 되는 것 같다.)
- vercel build 파이프라인은 정적 빌드에 대해서만 yarn2 지원. 서버리스 기능 및 SSR에서는 yarn 2 지원하지 않는다.
- https://github.com/vercel/vercel/discussions/4223그렇게 하여 전체적인 프로젝트 구조는 하나의 프로젝트 안의 apps 밑에 admin과 web으로 모노레포 형식으로 vercel에 두 프로젝트를 나누어 배포하는 형태로 구성하였다.
admin과 web에서 사용하는 공통 코드들은 packages 밑으로 이동시켜 core(api, util..), eslint, ui(component, hook)을 공통으로 사용하도록 구성하였다.
프로젝트 구조는 app router로 변경된 이후 공식문서에도 언급되어 있듯이 정답이 없는 것 같다. 활용할 수 있는 방법이 굉장히 많고 할 수 있는 방법 또한 일관성을 유지한 구조로 자유롭게 정하면 되는 것 같다.
There is no "right" or "wrong" way when it comes to organizing your own files and folders in a Next.js project.
The following section lists a very high-level overview of common strategies. The simplest takeaway is to choose a strategy that works for you and your team and be consistent across the project.src의 역할을 하는 app을 root로 사용해서 내부 안에 모든 코드가 다 들어가는 방법도 있었는데, app을 router방식으로만 사용하는 방법을 선택했다.
Atomic Design
컴포넌트를 나누는 기준으로는 atomic design을 사용해서 구조를 나눴다.
Atomic 디자인을 사용한 이유는 첫 번째 지금처럼 복잡하지 않은 프로젝트에서는 컴포넌트 간의 관계가 그렇게 복잡해지지 않아 컴포넌트를 독립적으로 잘 모듈화 하여 개발할 수 있다는 장점이었다. 회사에서 매우 복잡한 프로젝트에서 atomic 디자인 패턴으로 개발한 경험이 있었는데 각 컴포넌트끼리의 관계가 너무 복잡해지는 단점과 molecules와 organism 중 어디에 위치해야 할지에 대한 고민들과 팀원 간의 생각하는 위치가 다른 점들로 프로젝트가 커질수록 점점 복잡해지는 단점이 있었다. 하지만, 지금 홈페이지와 커지거나 수정될 가능성이 적고 admin과 web 두 군데의 프로젝트에서 동일하게 사용할 컴포넌트를 atomic 디자인을 사용했을 때 장점 드러날 수 있는 구조라고 생각했다.
두 번째로는 작은 단위의 컴포넌트부터 시작하여 점진적으로 복잡한 구조를 만드는 과정은 일관된 컴포넌트를 만들어 디자인 시스템을 만들기에도 좋고 무엇보다 테스트를 작성하기 좋은 구조이다. 그로 인해서 admin과 web에서 사용될 컴포넌트를 따로 빼서 모듈화 하여 사용하기에 좋아 리팩터링 하기에도 적합해 보였다.atomic 디자인의 templates 폴더는 거의 사용하지 않았다. 이유는 next.js에서 제공하는 layout.js와 template.js를 활용했기 때문이다. atomic 디자인에서 templates가 컴포넌트들을 레이아웃의 잡고 배치하는 구조를 만들어주는 역할을 하기 때문에 그런 점에서 next.js에서 제공해 주는 layout.js와 template.js의 역할이 비슷했다. 공식문서에서도 layout은 여러 경로 간에 공유되는 UI, template은 각 자식 layout이나 page를 wrapping 한다는 점에서 역할이 비슷하다고 생각하였다.
next.js에서 제공해 주는 layout과 template를 사용하면 이점이 생기는데, 이 둘은 다른 props를 받을 수 없고 오로지 자식 컴포넌트만 받기 때문에 atomic 디자인에서 template에 해당하는 컴포넌트의 기준을 명확히 나눌 수 있는 기준을 세우기 좋았다. (정말로 props의 의존하지 않는 layout만 해당하는 구조를 잡아주는 역할)
그리고 이렇게 나누어 template을 관리하면 next.js에서 template와 layout 기준으로 code splitting 효과를 얻을 수 있어서 초기 로딩 시간(First Load JS)을 줄일 수 있다.단점도 있었는데 route에 template의 하위에 있는의 있는 모든 layout과 template이 적용된다는 점이었다.
- Atomic Design Pattern: Structuring Your React Application
- Atomic Design Pattern의 Best Practice 여정기
- 아토믹 디자인을 활용한 디자인 시스템 도입기사용했던 & 고민했던 기술적인 포인트
서버컴포넌트
next.js 13에서 app router 베타를 거쳐 13.4에서 stable로 바뀌었고 서버 컴포넌트도 사용해 보았다. (사실 next.js 12 때부터 사용해 보긴 했었다.)
next.js에서는 서버 컴포넌트를 사용할 때 기본적으로 static rendering(정적 렌더링)으로 동작하고, 동적으로 변할 수 있는 쿠키나 searchParams를 사용하면 동적 렌더링으로 변한다. 서버 컴포넌트를 사용하면 여러 가지 이점을 누릴 수 있다.
dnd 홈페이지를 build 한 내용인데 확인해보면 데이터 패칭이나 동적 함수를 사용하지 않은 소개 페이지인 /dnd/about, /dnd/culture 페이지는 정적 콘텐츠로 사전 렌더링된 것으로 확인해 볼 수 있었다.
/organiers/[id]와 /projects/[id]는 빌드 시점에 데이터를 가져와 페이지를 사전에 생성하는 방식인 SSG가 적용되어 있다.export function generateStaticParams() { const projects = getProjects(); return projects.map(({ id }) => ({ id: String(id), })); }
동적렌더링이 적용되어 있는 페이지들은 searchParams를 사용하거나 데이터 패칭이 발생하는 곳이다.
compound component pattern
서버 컴포넌트의 이점을 누릴 수 있는 부분들로 인해서 compound component pattern을 유용하게 사용하여 서버 컴포넌트와 클라이언트 컴포넌트를 잘 조합하여 사용할 수 있었다.
하나의 예로 modal 컴포넌트를 들어보자.
먼저 modal을 전역적으로 관리할 것인가, local context로 관리할 것인가에 대한 부분에서 마침 참고하기 좋은 유튜브를 발견해서 참고하였다.
결론만 말하자면 대부분의 경우에서는 전역적으로 관리하는 것보다 local context로 관리하는 게 더 많은 이점을 누릴 수 있다는 내용이었고 참고하여 modal을 지역적으로 관리하도록 구성하였다.
지역적으로 사용할 경우 모달을 열고, 닫는 로직이 중복적으로 사용될 것이다. 그리고 이렇게 사용했을 경우에 사용자의 클릭 이벤트가 발생하기 때문에 사용하는 컴포넌트에서는 서버 컴포넌트로 사용이 불가능하다. 물론 모달을 열고 닫는 로직을 가진 클라이언트 컴포넌트를 새로 생성하면 해결되긴 하지만 불필요한 컴포넌트가 생길 뿐이다.function SomeComponent() { const { toggle } = useModalContext(); return ( <div> <h1>title</h1> <button onClick={() => toggle(true)}>모달 열기</button> </div> ); }
동일한 로직을 가지는 모달을 여는 버튼 컴포넌트를 자식으로 받아서 공통으로 사용되는 ModalOpenButton을 React.cloneElement를 사용해서 compound pattern을 조금 더 유연하게 사용할 수 있다.
function ModalOpenButton({ children: child, } : { children: ReactElement<ButtonHTMLAttributes<HTMLButtonElement>> }) { const { toggle } = useModalContext(); return cloneElement(child, { onClick: (e: MouseEvent<HTMLButtonElement>) => { toggle(true); child.props.onClick?.(e); }, }); } export default ModalOpenButton;
이러한 모달의 구성으로 DND에 지원하는 모달을 구성해 봤다.
function ApplyModal({ children: child }: Props) { return ( <Modal> <Modal.OpenButton> {cloneElement(child, child.props, [ <span key="default-label">{`${CURRENT_FLAG}기 지원하기`}</span>, child.props.children, ])} </Modal.OpenButton> <Modal.ContentsBase title={`${CURRENT_FLAG}기 지원하기`} size="small"> {/* ... */} </Modal.ContentsBase> </Modal> ); }
실질적으로 이 ApplyModal을 사용하는 곳을 확인해 보자.
function ShareAlarmSection() { /* ... */ return ( <div className={styles.shareAlarmSection}> /* ... */ <ApplyModal> <Button fullWidth size="xLarge" buttonType="primary" /> </ApplyModal> </div> ); } // ... function HomePage() { /* ... */ return ( <div className={styles.homepage}> /* ... */ <ApplyModal> <Button isAnimated={false}> <RightArrowIcon className={styles.arrowIcon} width={20} height={20} /> </Button> </ApplyModal> </div> ); }
모달을 사용하는 쪽에서는 내부 동작하는 과정은 몰라도 되는 블랙박스 형태로 구현이 가능해졌다. 즉, 각 모달을 열고 닫는 버튼의 중복되는 사용자 이벤트는 없이 각 버튼의 속성을 다르게 적용할 수 있게 되었다. 그리고 이렇게 사용하면 사용자 이벤트가 없어서 서버 컴포넌트에서 바로 임포트하여 사용이 가능하다.
- Kent C. Dodds의 글을 참고하였다.
https://www.epicreact.dev/soul-crushing-components
https://kentcdodds.com/blog/compound-components-with-react-hooksserver actions
server action도 next.js 14에서 stable 상태가 되었고, 서버에서 실행되는 비동기적 함수를 호출할 수 있다. react에서 'use server' 지시문을 사용해 서버 함수에 대한 참조를 생성할 수 있다. 이번에 server action을 사용해 form과 관련한 api 호출을 할 때에 사용했는데, DND 홈페이지에서는 사용자의 form을 받아 api를 호출하는 경우가 없어서 server action을 사용하는 일은 없었다.
홈페이지에 보이는 정적인 데이터를 수정과 사용자 인증을 하기 위한 역할로 admin 페이지에서만 server action 기능을 활용해 사용해 보았다.먼저 어드민에서는 간단하게 auth.js(next auth)를 사용해서 간단하게 인증 과정을 구현했다. auth.js는 아직 베타 버전이지만 이렇게 간단하게 인증 처리를 하기에는 문제가 없었고 이전 next auth의 사용성과 비교해 보았을 때 app router에서 너무 간단하게 사용이 가능해서 바로 적용해서 사용하였다.
import { Button } from '@dnd-academy/ui'; import { signIn } from '@/auth'; function SignIn() { return ( <form action={async () => { 'use server'; await signIn('google', { redirectTo: '/' }); }} > <Button type="submit">로그인</Button> </form> ); } export default SignIn;
로그인 버튼을 클릭하면 form 양식이 server action을 통해 호출되고 로그인 인증 상태에 성공하게 된다. server action을 사용하면 안전하게 쿠키를 수정하거나 삭제할 수 있다. 그리고 server action은 비동기 함수여서 재사용 가능하도록 export 해서 사용이 가능하다. (주의해야 할 사항으로 재사용을 위해 action 파일을 만들고 export 시킬 때에는 해당 파일에 동기 함수가 없어야만 한다.)
import { Button } from '@dnd-academy/ui'; import { signOutAction } from '@/auth'; function SignOut() { return ( <form action={signOutAction}> <Button type="submit">로그아웃</Button> </form> ); } export default SignOut;
다음으로 admin에서 form 양식을 사용해서 server action을 활용해 본 예시를 들어보도록 하자.
홈페이지에 다음과 같은 누적 지원자 수, 총 참가자 수, 총 프로젝트 수, 이탈자 수를 보여주는 섹션이 있다. 이 섹션은 매 기수 진행될 때마다 json파일 데이터를 수동으로 수정해서 직접 업데이트를 해주고 있었다.
이 플로우를 내가 직접 수정하는 게 아닌 admin에서 운영자분들이 수정할 수 있게끔 form을 받아 server action 기능을 활용해 보았다.현재 admin은 빠르게 PoC 정도로 디자인과 별개로 기능만 개발해 둔 점 참고 바란다.
아래 이미지는 admin에서 카드 섹션 데이터를 업데이트하는 페이지이다.
이제 form 요소를 받아 server action 함수를 호출하는 플로우를 확인해 보자.async function totalCountStatusAction(formData: FormData) { 'use server'; const requestForm = { cumulativeApplicants: formData.get('cumulativeApplicants'), dropouts: formData.get('dropouts'), totalParticipants: formData.get('totalParticipants'), totalProjects: formData.get('totalProjects'), }; const jsonString = JSON.stringify(requestForm); const requestBlob = new Blob([jsonString], { type: 'application/json' }); await put('total_count_status.json', requestBlob); } function TotalCountStatusForm({ totalCountStatus }: Props) { const { cumulativeApplicants, dropouts, totalParticipants, totalProjects, } = totalCountStatus; return ( <div> <form action={totalCountStatusAction}> <input type="number" name="cumulativeApplicants" min={0} placeholder="누적 지원자 수" defaultValue={cumulativeApplicants} required /> <input type="number" name="totalParticipants" min={0} placeholder="총 참가자 수" defaultValue={totalParticipants} required /> <input type="number" name="totalProjects" min={0} placeholder="총 프로젝트 수" defaultValue={totalProjects} required /> <input type="number" name="dropouts" min={0} placeholder="이탈자 수" defaultValue={dropouts} required /> <Button type="submit">업데이트하기</Button> </form> </div> ); }
이렇게 각 input의 입력된 name의 form 요소에 따라서 server action을 통해서 데이터를 처리할 수 있다.
하지만 우리는 데이터를 처리하는 것뿐만 아니라 사용자한테도 업데이트에 성공했는지, 실패했는지를 알려줘야 한다. 사용자는 서버에서 비동기적으로 통신하는 api 상태를 알 수 없다.사용자에게 로딩 중인지, 성공했는지 혹은 실패했는지를 알려주자.
form 작업과 관련한 유용한 hook들이 생겼는데, useFormState(useActionState), useFormStatus hook을 사용해서 progressive enhancement를 유지할 수 있다.function TotalCountStatusForm({ initialTotalCountStatus }: Props) { const [state, formAction] = useFormState(totalCountStatusAction, null); const { cumulativeApplicants, dropouts, totalParticipants, totalProjects, } = initialTotalCountStatus; return ( <div> <form action={formAction}> <input type="number" name="cumulativeApplicants" min={0} placeholder="누적 지원자 수" defaultValue={cumulativeApplicants} required /> <input type="number" name="totalParticipants" min={0} placeholder="총 참가자 수" defaultValue={totalParticipants} required /> <input type="number" name="totalProjects" min={0} placeholder="총 프로젝트 수" defaultValue={totalProjects} required /> <input type="number" name="dropouts" min={0} placeholder="이탈자 수" defaultValue={dropouts} required /> <Button type="submit">업데이트하기</Button> </form> {state?.message && ( <div className={clsx(state.messageType && styles[state.messageType])}> {state.message} </div> )} </div> ); }
이렇게 useFormState를 통해서 에러 상태가 발생했는지, 성공했는지의 메시지를 사용자에게 즉각적으로 보여줄 수 있다.
useFormState를 사용하면 prevState를 가지고 있기 때문에 server action도 수정이 필요하다. 그리고 사용자에게 보여줄 state를 return 해준다.export async function totalCountStatusAction( prevState: TotalCountStatusStateType | null, formData: FormData, ): Promise<TotalCountStatusStateType> { try { const requestForm = { cumulativeApplicants: formData.get('cumulativeApplicants'), dropouts: formData.get('dropouts'), totalParticipants: formData.get('totalParticipants'), totalProjects: formData.get('totalProjects'), }; const jsonString = JSON.stringify(requestForm); const requestBlob = new Blob([jsonString], { type: 'application/json' }); await put('total_count_status.json', requestBlob); revalidatePath('/total-count-status'); return { message: '수정사항이 반영되었습니다.', messageType: 'success', }; } catch (error) { return { message: '수정사항이 반영되지 않았습니다. 잠시 후 다시 시도해주세요.', messageType: 'error', }; } }
이렇게 성공, 에러 상태를 바로 보여줄 수 있으며, zod 같은 라이브러리를 통해 유효성 검사도 처리할 수 있다.
그리고 위 예시 코드를 보면 revalidatePath를 확인해 볼 수 있는데, 성공한 경우에는 캐시가 적용되어 있는 경우 바로 반영이 안 되는 경우가 있어 revalidatePath나 revalidateTag를 통해 캐시를 다시 검증할 수 있다.참고로 react-dom의 useFormState는 form의 의존하는 것을 보다 보편적으로 action 함수에서 사용되는 네이밍으로 react 19에서는 useActionState로 변경되었다.
하지만, next.js(14.2.4 기준)에서는 useActionState로 변경한 경우 동작하지 않아, react-dom의 useFormState로 적용해야 한다.
https://github.com/vercel/next.js/issues/65673이제 비동기적으로 동작하는 action 함수에 대한 form 로딩 상태를 useFormStatus를 사용해서 추가해 보자.
'use client'; function SubmitButton({ label }: Props) { const { pending } = useFormStatus(); return ( <Button type="submit" disabled={pending}>{label}</Button> ); }
useFormStatus를 활용해서 pending상태를 확인할 수 있다. 주의할 점은 useFormStatus는 form 내부 context에 위치해야 한다.
function TotalCountStatusForm({ initialTotalCountStatus }: Props) { const [state, formAction] = useFormState(totalCountStatusAction, null); const { cumulativeApplicants, dropouts, totalParticipants, totalProjects, } = initialTotalCountStatus; return ( <div> <form action={formAction}> <input type="number" name="cumulativeApplicants" min={0} placeholder="누적 지원자 수" defaultValue={cumulativeApplicants} required /> <input type="number" name="totalParticipants" min={0} placeholder="총 참가자 수" defaultValue={totalParticipants} required /> <input type="number" name="totalProjects" min={0} placeholder="총 프로젝트 수" defaultValue={totalProjects} required /> <input type="number" name="dropouts" min={0} placeholder="이탈자 수" defaultValue={dropouts} required /> <SubmitButton label="업데이트하기" /> </form> {state?.message && ( <div className={clsx(state.messageType && styles[state.messageType])}> {state.message} </div> )} </div> ); }
만약 밖에서 action에 대한 로딩상태를 확인하고 싶으면 useTransition hook을 사용하면 된다. pending 상태를 확인하고 싶은 함수에 startTransition을 감싸주면 된다. 이 둘의 또 다른 차이점은 useFormStatus는 form에 최적화되어 있어 form의 state도 확인이 가능하지만, useTransition은 form state를 확인할 수 없고 form의 pending 상태를 확인하기 위해서 startTransition 같은 콜백 함수를 감쌀 필요도 없다.
function TotalCountStatusForm({ initialTotalCountStatus }: Props) { const [state, formAction] = useFormState(totalCountStatusAction, null); const [isPending, startTransition] = useTransition(); const { cumulativeApplicants, dropouts, totalParticipants, totalProjects, } = initialTotalCountStatus; const handleSubmit = (formData: FormData) => startTransition(() => formAction(formData)); return ( <div> <form action={handleSubmit}> <input type="number" name="cumulativeApplicants" min={0} placeholder="누적 지원자 수" defaultValue={cumulativeApplicants} required /> <input type="number" name="totalParticipants" min={0} placeholder="총 참가자 수" defaultValue={totalParticipants} required /> <input type="number" name="totalProjects" min={0} placeholder="총 프로젝트 수" defaultValue={totalProjects} required /> <input type="number" name="dropouts" min={0} placeholder="이탈자 수" defaultValue={dropouts} required /> <Button type="submit" disabled={isPending}>업데이트하기</Button> </form> {state?.message && ( <div className={clsx(state.messageType && styles[state.messageType])}> {state.message} </div> )} </div> ); }
turborepo의 원격 캐시를 사용한 CI 속도 개선
기존에 CI를 원래 github actions에 캐시를 적용하여 lint와 test를 검증하였다. 캐시는 이미 적용되어있긴 해서 CI pipeline이 느린 편은 아니었다.
실제로 캐시가 적용되어 있으면 아래와 같이 install, package build를 건너뛰고 바로 lint와 test를 검증한다.turborepo를 사용하게 되면서 원격 캐시 기능을 활용해보고 싶어서 이 과정도 build시에 같이 통합할 수 있지 않을까 싶어서 적용해 보았다. 사실 이건.. 뭐가 정답인지 모르겠고 굳이 안 해도 될 것 같았지만, turborepo의 원격 캐시를 활용하면 어떻게 될지 궁금해서 적용해 보았다.
먼저 전역에 turbo.json에 deploy시 실행되어야 할 각 의존성을 적용하면 turborepo가 똑똑하게 vercel에서 각 의존성에 맞춰서 실행이 된다. 예를 들어, 홈페이지(web)를 빌드를 하고 싶으면 packages 빌드가 먼저 일어나고 테스트 및 린트가 실행되고 모든 게 성공하면 build가 되어야 한다. 각 실행의 의존성을 turbo.json에 추가만 해주면 된다.
"tasks": { "deploy": { "dependsOn": [ "lint", "stylelint", "test:coverage", "build" ] }, }
vercel에 배포 시 이 과정이 모두 성공해야지 배포가 이루어지고 만약에 중간에 실패했을 경우에도 이전에 성공한 pipeline에 대해서는 캐시가 반영이 된다. 변경사항에 대해서도 캐시 miss가 발생해야 하는 트리거를 turbo.json에 적용할 수 있다.
"test:coverage": { "dependsOn": ["@dnd-academy/ui#build", "@dnd-academy/core#build"], // 테스트는 다음 build에 의존되어있음. 즉, 먼저 실행되어야함. "outputs": ["coverage/**"], // outputs 디렉토리의 변경사항을 추적하고 캐싱. "inputs": [ // 테스트 파일만 확인한다. "**/*.test.{tsx,ts}" ] },
아래 사진의 홈페이지 빌드시 vercel의 빌드 로그를 보면, 6개의 작업이 성공했고 그중에 4가지의 작업에서 캐시가 발생한 것을 확인할 수 있다. 소요된 시간은 12초 정도 걸린 것을 확인할 수 있다.
turborepo를 사용한 vercel deploy로그에서는 run summary를 통해 가시적으로 확인도 가능했고, 총 2분 12초의 소요시간이 걸리는 작업에 2분의 시간을 절약하고 12초가 걸린 것을 확인해 볼 수 있었다.
vercel에서 제공하는 유용한 친구들
이번에 이것저것 적용해 보면서 vercel에서 제공하고 있는 유용한 기능들을 사용해 봤다.
특히 아직 베타 버전인 vercel blob이라는 기능을 사용해 봤는데 vercel blob은 amazon의 정적인 데이터를 관리하는 S3와 동일하게 storage 관리를 할 수 있는 vecel에서 제공해 주는 api이다. vercel blob은 일정 수준으로 사용하면 hobby 계정에서도 무료로 사용할 수 있다.홈페이지에서는 단순히 api 호출을 통해서 데이터를 보여주는 용도로 사용하고 있고, admin에서는 이 데이터의 정보를 업데이트하기 위해서 vercel/blob의 api를 사용해서 수정 및 삭제를 할 수 있다.
'use server'; import { revalidatePath } from 'next/cache'; import { put } from '@vercel/blob'; export async function totalCountStatusAction( _: TotalCountStatusStateType | null, formData: FormData, ): Promise<TotalCountStatusStateType> { /* ... */ await put('total_count_status.json', requestBlob, { access: 'public', token: process.env.DND_ACADEMY_V2_BLOB_READ_WRITE_TOKEN, addRandomSuffix: false, }); /* ... */ }
vercel storage dashboard에서 blob 정보를 확인할 수 있다.
vercel analytics & speed insights
구글 애널리틱스와 web vitals와 같은 기능을 vercel에 자체적으로 제공해주고 있다. 이미 구글 애널리틱스를 적용해 두어서 굳이 써야 되나 싶어서 이 두 개는 사용해보지는 않았다.
적용하기가 매우 간단하고 hobby 계정에서도 일정 기능을 활용해서 사용이 가능해서 한 번쯤은 적용해볼까 한다.root layout에 아래와 같이 적용만 하면 끝이다.
import { Analytics } from '@vercel/analytics/react'; import { SpeedInsights } from '@vercel/speed-insights/next'; function RootLayout({ children }: { children: ReactNode }) { return ( <html lang="ko"> <body> <Analytics /> <SpeedInsights /> </body> </html> ); }
이런 간편한 기능을 보면서 느낀 점은 모든 부분에서 vercel이 다 해보려고 하는구나.. 하는 생각이 들었다. 기존에 web vitals나 google analytics의 귀찮은 세팅과 적용을 쉽게 적용할 수 있게 해둔걸 보니 너무 편해지다가 vercel의 노예가 될 것 같은 느낌도 들었다.
아쉬웠던 점
기존에 있던 디자인을 바탕으로 디자인 시스템을 정의했던 점
이번 마이그레이션 작업은 혼자 작업을 했다. 디자인도 이전에 되어있던 디자인이여서 디자인 시스템이 제대로 적용이 안되어있었다. 디자인을 수정을 요청하기에는 다들 바쁘셔서 어쩔 수 없이 임의적으로 정의하고 수정하게 되었다. 그러다 보니 컴포넌트를 공통적으로 사용하기 위해 고민한 시간이 너무 많았고, 공통화를 시기키 위해서 props 명이나 스타일을 수정한 부분도 있었다.
그리고 gap이나 padding 값도 spacing이 적용되어있지 않아서 어려움 부분이 있었고, 개별적으로 적용만 해두었고 추후에 공통화 작업이 필수적으로 이루어져야만 한다. 결국 일을 두 번 하게 되는 셈이긴 하지만 현재 상황에서는 어쩔 수 없는 부분이다.
그나마 몇몇 개의 컴포넌트를 storybook에 공통화를 시켜두긴 했지만, 아직 덜 반영된 부분이 더 많아서 이 부분도 적용이 필요하다.
dnd-academy-v2 storybook
테스트 작성
테스트 작성에 아쉬움도 있는데 많은 부분에 테스트를 작성하지 못했다는 것이다. 사실 정적인 페이지가 대부분이라 이벤트가 없는 곳은 테스트할 필요는 없지만, 그 나머지 부분에서도 많이 작성하지 못해 아쉬웠다.
현재 유틸함수나 공통 컴포넌트들만 테스트를 작성해 두었다. 결국 시간이라는 비용과 비례해서 효율적인 테스트 작성이 필요한데 앞으로 테스트 작성의 우선순위를 정해 보면 package/ui, core의 공통으로 사용되는 컴포넌트들이나 유틸함수에 누락된 테스트를 작성해야겠다.
오버 엔지니어링
개인적으로 회사에서 여러 프로젝트를 진행해 보면서 DND 홈페이지 마이그레이션은 작은 규모의 프로젝트이다. 근데 그 과정에서 너무 불필요하게 기술적으로 고민한 시간이 많았다는 점이 아쉬웠다. 물론 빠르게 기능만 개발했으면 더 금방 끝났을 마이그레이션임에 분명하지만 결국 내가 아니더라도 누군가는 계속 유지보수하기 위해서는 어느 정도의 기술적인 고민은 필요했다고 생각했다.
이런 고민의 과정을 오랫동안 한 것 같아서 오버 엔지니어링이 된 것 같지만 이번에 마이그레이션을 진행하면서 새로운 것들을 사용해 보고 적용해 보며 고민한 것들이 많아서 나름 뿌듯하다. 프로젝트 자체로 보면 아쉽긴 하지만 개인적인 고민과 배운 것에 있어서는 만족스럽다.
마이그레이션 과정기를 마무리하며
위 lighthouse 지표에 Best Practices의 점수가 높지 않은 이유는 dnd에 문의 사항 창구로 채널톡을 사용하고 있는데 best practices 항목에 서드 파티 쿠키의 영향을 받아서 그런 것 같다. 이 부분은 내가 고려해야 할 사항은 아닌 것 같다.
전체적으로 보았을 때 만족스러운 마이그레이션이었다. 아직 admin 작업이 꽤 많이 남긴 했지만, 홈페이지를 마이그레이션 한 이 시점에 회고를 쓰기에는 매우 적합했던 것 같다. 기존에 관리되고 있던 비공개 repository도 archive처리를 해두었고, 현재 마이그레이션 된 repository는 깃허브에 공개하는 방향으로 변경하였다. 그리고 홈페이지 성능도 향상되었고 각 페이지별로 디테일 수정 및 개선해야 할 포인트도 한꺼번에 적용할 수 있게 되었다.
이전부터 요청사항이 있을 때마다 수동으로 하는 과정이 너무 귀찮고, 새로운 개발을 해야 할 때에는 개발조차 불가능했었는데 이번 기회에 마이그레이션을 통해서 앞으로의 발판과 성능개선을 해서 뿌듯하다. admin은 아직 개발이 남았지만 이 작업을 마무리하면 귀찮게 수동으로 데이터를 업데이트하는 작업은 내가 안 해도 된다..!! ㅠ
두 달간 vercel을 사용해 봤는데 무료로 계속 유지되고 있어서 비용적인 부분도 확실히 해소가 되었다.DND에서 참여자로 시작해 운영진으로 활동하면서 DND에 대한 동아리의 애정이 생기게 되었고, 홈페이지를 마이그레이션 한 이유도 이 애정 중 하나가 아닐까 싶다.
DND는 비영리단체여서 운영진활동을 해서 얻는 것도 없어서 왜 그런 걸 하냐고 물어보는 사람들이 많기도 하지만, 매 기수 참여자분들에게 긍정적인 영향을 끼치고 함께 자라는 환경을 만들어주는 과정에서 나는 보람을 느끼는 것 같다.나를 포함한 DND 운영진분들은 1년에 두 기수씩 참여하는 모든 분들에게 더 나은 환경을 제공하기 위해 8주 간의 유의미한 DND 활동을 위해서 많은 노력을 하고 있다. 지금도 이미 많은 지원자들이 DND에 지원하고 있지만, 앞으로도 더 많은 관심과 지원 바란다.
쓰다 보니 모든 내용을 담고 싶어서 너무 길어진 것 같다. 마지막으로 DND는 참여자분들에게 따로 각 기수별 참여비도 받지 않고 있고 참여하는 8주간의 정규 과정은 전부 무료로 운영하고 있습니다. 그렇다 보니 참여자분들에게 더 좋은 환경과 유의미한 활동을 드리는 데에 있어서 지원하는 데에 한계가 있을 수밖에 없습니다. (오프라인 장소 대관, 서버비, 행사 운영비) 이런 부분을 해소하기 위해서는 후원이 필요한데 요즘은 업계 시장이 좋지 않아서 후원해 주는 곳이 한 군데도 없어 DND 운영도 어려움이 큰 것 같습니다. 혹시나 하는 마음으로 후원 문의 주시면 너무나 감사하겠습니다.
'회고' 카테고리의 다른 글
[회고] IT 커뮤니티 동아리 SIPE 2기를 마무리하며... (6) 2024.07.16 [회고] 3년차 프론트엔드 개발자 늦은 2023년 회고 (34) 2024.02.12 벌써 2년 프론트엔드 개발자의 2022년 회고 (4) 2022.12.30 [회고] 주니어 프론트엔드 개발자의 좌충우돌 웹 거래소 런칭기 (8) 2022.09.29 [회고] Github Daily Commit(1일 1커밋) 2년차 그리고.. (0) 2022.05.25