들어가며
어시스턴트로서의 6개월이라는 시간이 끝났다.
원래는 회고 형식의 글들을 쓰지 않지만, 취업활동의 끝자락에서 얼떨결에 잡게된 기회였고, 나름 소중한 기회라고 생각해 이것 저것 열심히 해보려고 노력했었는데 그 생각들과 과정들을 기록해두는 것이 도움이되지 않을까라는 생각에서 회고를 작성하기로 했다.
얻은 것도, 다시 생각해보게 된 부분들도, 느낀점도 많았으니 하나 하나 정리해나가보도록 해야겠다.
시작
나의 어시스턴트 포지션은 서버플랫폼팀에서의 프론트엔드 엔지니어 역할을 맡는 것이었다.
크게는 기존에 팀 내에서 관리하고 있던 내부 플랫폼 어드민 대시보드를 개선하는 역할이었으나, 막상 업무를 맡게되고 이야기를 나누고 프로젝트를 살펴보다보니 막연히 생각했던 것처럼 간단하게 해결되는 문제는 아니었다.
팀의 요구사항은 단순한 개선보다는 총체적인 마이그레이션 작업이었다.
다음과 같은 요구사항과 문제들을 마이그레이션 작업에서 해결해야했다.
- 기술스택의 최신화
- 컨벤션이 잡히지 않은 프로젝트의 FE 컨벤션 및 가이드라인 마련
- FE 코드에 익숙하지 않은 팀 내 개발자들을 위해 DX 개선
- 클라이언트의 총체적인 사용성 및 UX 개선
각각의 문제들을 모두 완벽하게 해결하지는 못했지만, 하나 하나의 주제들에 대해서 고민했던 내용들과 시도했던 방법들을 적고 다시금 되돌려 보고자 한다.
첫 만남
레거시 프로젝트와의 첫 만남은 썩 유쾌하지는 않았다.
대량의 코드가 한 파일내에 존재한다던가, 인프라 및 플랫폼 로직들이 React 및 FE 로직들과 많이 엮여있어 흐름을 알아보기 힘들거나, 컨벤션이 잡히지 않아 한 가지 기능을 여러 패턴으로 구현한 부분들이 많이 보였다.
추가적으로 Vite + React-Router 기반이었기 때문에 라우팅과 디렉토리 구조도 접근 및 이해하기 쉽지 않았다.
생각보다 프로젝트 내부에서 관리하고 있는 로직들이 간단하지 않은 부분들이 많았기 때문에 (access-control 등) 이 기능들을 그대로 옮겨 오면서, 좀 더 관리하기 쉬운 패턴 그리고 추가적으로 개선할 수 있는 부분들을 개선하는 것이 녹록치 않게 느껴졌다.
마지막으로 마이그레이션을 달성해야할 페이지 갯수 또한 적지 않았고, 여기에는 마이그레이션 뿐만 아니라 UX 적으로 레이아웃을 리디자인하고 개선해야할 부분도 존재했다.
처음 프로젝트를 맡고 정말 얼마 동안은 머리를 싸매고 고민하는 시간이 실제로 코드를 타이핑하는 시간들 보다 길었었던 것 같다.
이 많은 실타래들을 내가 정말 다 맡아서 잘 풀고 가치를 만들어낼 수 있을까 그리고 그정도 실력이 될까 하는 의문도 많이 들었다.
반면 한 편으로는 단순히 일이어서가 아니라 정말 문제를 해결하고 더 나은 가치를 제공할 수 있다면 좋겠다는 욕심도 들어 설레기도 했다.
나에게는 정말 개발이라는 과정이 삽질과 삽질의 연속이며 이 과정속에서 한 걸음씩 나아간다고 생각하는데, 마음속에서 두 가지 생각과 마음이 상충하면서 이 번에도 역시 수 많은 삽질들을 계속 해왔던 것 같다.
삽질의 나날들
가장 분명하면서도 가장 쉽고 깔끔하게 해결할 수 있는 문제부터 잡고 들어가기로 했다. 그리고 이 부분이 프로젝트의 가장 첫 단추가 되는 부분이기도 했다.
기술스택의 최신화
작업의 종류가 마이그레이션이었기 때문에 결국은 가장 첫 발이 새로운 프로젝트의 시작을 어떻게 하느냐였다. 그리고 여기에는 기술스택을 취사 선택하는 부분들이 포함되어 있었다.
내부적으로 템플릿을 사용해서 시작했고 그 위에서 작업을 했기 때문에 굵직한 부분들에 큰 선택지가 많이 있지는 않았지만 내가 생각하는 부분들과 크게 다르지 않았기 때문에 적절했었다고 생각한다.
Vite + React-Router의 기술 스택은Next로 최신화 되었다.Ant-Design의 디자인시스템은 사내 디자인시스템으로 교체되었다.- API 클라이언트 및 서버 상태는
ky,Tanstack으로 교체되었다. - 기타 상태 관리 및 유틸들이 추가되었다.
한 가지 시작하면서 조금 아쉬웠던 부분은 굳이 Next 를 들고 갔었어야 했었나 하는 부분이었다.
대부분의 페이지가 Client Component 였던 데다가 프로젝트 초기에 SSR 관련 Hydration 에러가
디자인 시스템 컴포넌트에서 발생해서 layout 레벨에서 suppress 해줬어야 했기 때문이다.
렌더링에서 서버 파워를 전혀 사용하지 않았기 때문에 Vite 기반의 Tanstack Router (Start) 가
Framework 레벨에서 눈에 들어왔지만 이미 규격화된 템플릿을 사용한다는 선택지를 대체할 만한 이유가 크게는 없어서 포기했다.
(추후에 Tanstack Router 기반의 새로운 템플릿이 만들어지고 공유된 걸 보면 틀린 방향의 생각은 아니었던 걸지도 모르겠다.)
이를 제외하고 내가 어플리케이션을 위해 추가적으로 선택했던 기술스택들은 다음과 같다.
React Hook Form: Form 동작을 Headless 하게 구현한 기술스택Zod: Form 데이터의 Validation 을 책임져 줄 런타임 타입 체커Nuqs: URL Query Param 과 React State 의 연동Tanstack Table: React Hook Form 과 마찬가지로 Headless 방식의 Table 라이브러리
컨벤션 구상하기 + DX 개선하기
기술스택이 마련되고 프로젝트를 진행할 준비가 끝났으니 실제로 프로젝트를 진행하면 되었다.
그런데 진짜 프로젝트를 진행할 준비가 마쳐진것일까?
오히려 가장 큰 부분이 남았다고 볼 수도 있었다. 그리고 이 부분이 어쩌면 마이그레이션에서 가장 큰 부분을 차지하는 부분이기도 했다.
프로젝트에 적절한 FE 컨벤션을 구성하고, 이를 바탕으로 DX 를 끌어올리기
가장 먼저 눈에 들어온 부분은 크게 두 가지 였다.
- 페이지 (라우트) 구성요소를 정형화하기
- API 함수와 그 구성요소들을 정형화하기
첫 번째의 경우 문제점이 명확했다.
- 페이지 레이아웃 및 컴포넌트 구성이 정해진 규약 없이 사용되고 있었다.
- 컴포넌트 분리 및 라우트 레벨의 구성요소가 명확한 기준 없이 구분되고 있었다.
이를 해결 하기 위해서, 간단하지만 페이지들의 공통 레이아웃 역할을 할 수 있는 컴포넌트를 작성하고 이를 활용함으로 전체적인 어플리케이션의 UI 통일성을 확보함과 동시에 하나의 페이지를 작성하는 패턴을 만들었다.
또한 Next 의 파일 라우팅을 활용해서, 기존의 React-Router 기반의 파일 작성방식을 다음과 같이 분리했다.
- 모든 화면 구성요소들은 디렉토리를 갖고,
index.tsx를 통해 파일을 작성한다.- 컴포넌트일지라도, 내부의 구성요소나 로직이 복잡한 경우가 있었다. 이를 위해 컴포넌트 또한 내부에 필요한 파일들을 디렉토리 구조로 포함하는 게 좋을 것 같아 이렇게 만들었다.
- 컴포넌트가 하나의 책임만을 갖도록 만들 수 있다면 그렇게 만들고 분리한다.
- 가장 문제라고 파악됐던 하나의 파일에 너무 많은 코드가 포함되어있던 부분을 이런 방식으로 처리해 한 파일에 최대 300줄 안팎의 코드를 배치 시켰다.
- 공통 및 연결 로직은
Context를 활용하거나 부모에게 위임한다.- 공통 로직이 한 파일내에 엮여있음으로 그 자체로 연결 및 작성은 쉬웠을지 몰라도 수정 및 삭제의 경우 파악하는 것이 매우 힘들었다. 컴포넌트 분리와 함께 해당 방식으로 처리하면 연결 부분에 대한 문제들이 자연스럽게 해결될 것이라고 생각했다.
다음은 API 의 파일 들의 문제였다.
- API 가 도메인으로 분리되어 모든 함수 및 타입들이 관리되고 있었다.
- API 활용 자체가 여러 가지 패턴 (
useEffect, Promise.then, async-await, tanstack-query) 들이 혼용되고 있었다.
API 함수들이 도메인 별로 관리되고 있는 것 자체는 문제가 되지 않았지만, 도대체 어디에서 쓰이는 함수인지 어떤 타입이 존재하는 것인지 역으로 파악하는 것이 꽤나 힘들었고 이를 해결해야 한다고 느꼈다.
API 활용 방식도 당연히 그때 그때 생각나는 대로 작성하면 편하겠지만 결국 모든 비용을 유지 관리 하는 부분에서 지불해야했기 때문에 이 또한 해결이 필요했다.
이 문제들은 다음과 같이 접근 하기로 했다.
- API 함수들의 위치를
app라우트의 디렉토리를 그대로 따라 가도록 변경했다.- API 함수들이 꽤나 많았음에도, 몇몇 함수들을 제외하고는 결국 하나의 라우트에서 호출하고 마는 경우가 많았다. 이에 따라 라우트의 구조와 그대로 위치시키되 공통 레벨을 하나씩 둬 필요한 경우 공통레벨에 위치시켜 import 의 혼란을 막고자했다.
Tanstack Query를 기본으로 두고, 모든 API 쿼리패턴을 이에 맞도록 수정했다.- 서버 상태 및 캐시 관리를 효율적으로 도와줄 수 있는
Tanstack Query를 적극 도입하고, 이를 정형화 시켰다. 추가로 정형화 함과 동시에 효율적으로 사용할 수 있도록query option및query key등을 default 로 넣어줘 간단한 선언만으로 바로 hook 을 사용할 수 있는 팩토리 함수를 작성해 사용했다.
- 서버 상태 및 캐시 관리를 효율적으로 도와줄 수 있는
이렇게 큰 두가지 문제를 대략적으로 구상하고 나니 프로젝트의 큰 가닥 및 구성요소가 자리잡기 시작했다.
그리고 이를 바탕으로 페이지들의 구성요소들을 차근차근 계획해나갈 수 있었다.
팩토리 패턴
API 함수를 정형화 하고 나면서 query option 을 default 로 빠르게 생성해줄 수 있는 유틸이 있으면 편할 것이라는 제안을 들었고
이를 활용함과 동시에 조금 더 확장해서 API 함수 자체만 작성하고 이를 던져주면 알아서 Tanstack Query 의 hook 이 사용가능한 상태로 나오는 건 어떨까 라는 생각을 하게 되었다.
결국 query hook 사용에서 제일 중요한 부분은 query-key, query-fn 이 두 프로퍼티들이라는 생각이 들었고, 이는 충분히 정형화될 수 있다고 생각했다.
query-key: 쿼리 캐시를 관리하는 만큼 반응성을 컨트롤해줘야 했는데 이는 API 함수에 전부 정의되어 있다고 생각했다.params, paths, body등 다만, 구분될 수 있는 하나의 키는 제공해야했기 때문에 API 함수의 프로퍼티를 확장해endpoint를 명명하도록 처리했다. (이렇게 하면 devtools 에서도 좀 더 보기 쉽기 때문이었다.)query-fn: API 함수 그자체였다. 그러니까 팩토리 훅은endpoint가 정의된query-fn하나만 인자로 넣어주면 곧 바로 hook 이 튀어나오는 형태였다.
시그니쳐를 대략 표시하자면 이런 느낌이되었다.
const getAQuery = async () => {
return; //...
};
getAQuery.endpoint = "...";
export const useAQuery = createQueryHook(getAQuery, {//queryOptions extending});
useAQuery({// queryOptions extending})문제는 함수 자체를 정의하는 작업은 간단했지만, 타입 파이프라이닝이 자동으로 유추되고 확장이 잘 되도록 유지하는 게 생각보다 쉽지 않았다. api 함수들도 parameters 들이 정의되어있는 타입과 되어있지 않은 상태를 확장하는 부분도 함수 오버로딩등으로 처리해야했고,
나중에는 query options 의 select 프로퍼티도 많이 활용하게 되면서, 이를 확장하는 부분도 생각보다 꽤 머리아프게 되었었지만
결국 다 정의해서 잘 사용하게 되었다.
닫혀있는 타입시스템(라이브러리)을 열고 확장한다는게 생각보다 쉽지는 않은 일이구나 라는 걸 느꼈다.
그럼에도 불구하고 결과적으로는 꽤 만족스러웠다. 실제로 쿼리를 작성하면서 이것저것 고민하는 시간이 눈에 띄게 줄었고, 컨벤션이 잡히고 나서는 API 함수 하나만 작성해두면 나머지는 거의 자동으로 따라오는 느낌이었다. 나중에 AI agent 를 활용하기 시작했을 때도, 패턴을 한 번 주입해두니 그대로 찍어내는 데 편리하게 써먹을 수 있었다.
다만 한계도 분명히 있었다. infiniteQuery 나 useQueries 처럼 조금 다른 형태의 쿼리 패턴을 사용해야 할 때에도 동일하게 이점을 얻으려면
팩토리 함수를 먼저 작성해야 했었기 때문이다.
결국 그런 경우에는, 얼마 없었기도 했지만, 패턴 바깥으로 나와서 직접 작성하는 것이 나았다. 더 다양한 쿼리 패턴을 하나의 큰 틀로 품을 수 있었다면 어땠을까 하는 아쉬움이 남는다.
코드 제네레이션?
적절하게 프로젝트 구성 및 컨벤션을 마련하고 나면, 자연스럽게 어드민 대시보드의 특성상
많은 부분이 정형화 될 것이라고 생각했고 그렇게 되면 Nx 처럼 코드 제네레이션 등을 도입할 수 있을 것이라고 생각했다.
그리고 이러한 코드 제네레이션을 도입하면 신규 페이지 작성 등을 처리할 때 굉장히 시간을 단축할 수 있지 않을까 생각했다.
이를 위해서 단순히 API 함수들과 쿼리 함수들을 정형화된 틀에 생성하는 것에 더불어 페이지 라우트에도 자동으로 연결시켜줄 수 있으면 좋겠다는 생각이 들었다.
어떻게 하면 각각의 다른 쿼리 패턴과 호출 위치들을 하나로 모아서 사용 가능하게 만들 수 있을까?
Remix 와 Tanstack Router 의 loader 패턴을 떠올렸다.
Route 를 정의할 때, 데이터 로딩에만 사용될 Context 를 같이 정형화된 방식으로 구성하고 사전에 작성만하면
필요한 컴포넌트가 필요한 때에 데이터를 언제든지 접근하고 빼내서 사용하면 되는 것이 아닐까?
Next 에서도 이전의 getServerSideProps 가 이런 역할을 했었기에 생각이 자연스럽게 그쪽으로 흘러갔다.
다만 세 가지 모든 예시가 SSR 을 상정하고 있는데에 반해, 내 경우는 CSR 상황이라는 것이 문제였다.
예시들이 SSR 내의 통합된 데이터 페칭 방법을 규정하는 것이 목적이라면,
내 경우는 CSR 내의 하나의 레이어를 둬 뒤에서 돌아가는 FE 로직들을 노출하지 않고 쉽게 사용하게끔 만드는 게 목적이었다.
결국 예상대로 잘 흘러가지는 못했고 사용되지 못했다.
- 코드제네레이션에 대한 니즈 보다 AI agent 사용에 대한 니즈가 더 우선했고
CSR내에 존재하는 다양한 변수들을 하나로 컨트롤 한다는게 문제가 많은 접근이었다.
대강 구현 및 사용은 되도록 만들어 뒀으나 폐기처리할 수 밖에 없었다.
사용성을 끌어올리려고 시도했던 것인데 남들이 아니라 내가 깨달음을 얻게된 경험이었다.
그래도 이 경험이 완전히 헛된 것은 아니었다. 이 시도를 통해서 추상화와 설계를 대하는 관점이 조금 잡히기 시작했고, 이후의 작업에서는 욕심의 범위와 바운더리를 좁혀서 접근하는 습관이 생겼다.
근시안적인 목표를 두고, 섣부르게 추상화를 시도하지 말자.
숲을 봐야 오히려 어떤 나무들이 살고있는지 알게 될 수 있다.
디자인 시스템과 컴포넌트
기존 프로젝트의 Ant Design 디자인 시스템이 굉장히 opinionated 한 디자인 시스템이자 컴포넌트 라이브러리었던 것에 비해,
사내 디자인 시스템은 컴포지션 기반의 컴포넌트 빌딩 블록의 역할을 하도록 구성되어 있었다.
데이터 렌더링 형태의 기존 코드베이스에서의 작업이 익숙한 개발자들과, UX 적으로 유려하지만 컴포지션을 통해서 사용해야하기 때문에 보일러플레이트가 많은 디자인시스템 사이의 브릿지 형태를 해줄 공통 컴포넌트 작업이 필요했다.
그 중에서도 대시보드 어플리케이션에서 가장 큰 역할을 해줄 수 있는 Form, Table, Select 컴포넌트들에 크게 신경을 썼다.
Form
Form 의 기본 구성 요소들은 FormData + Input + Validation 으로 구성된다.
디자인시스템이 Input 까지는 제공해주었으나, 그 이후의 기능들을 어떻게 구현할지는 전적으로 내 결정에 달려있었다.
먼저 다음과 같이 몇 가지 요소를 생각해 보았다.
- 새로운 폼 요소를 빠르게 구성할 수 있는가?
- 보일러 플레이트를 최대한 줄일 수 있는가?
- validation 에 대한 방식을 최대한 일괄적이게 만들 수 있는가?
- 폼 영역안에서도 다른 컴포넌트와의 조합이 가능한가
React Hook Form 을 사용함으로써, Form 의 기능적인 구현은 위임할 수 있었으나 단순히 그것만으로
모든 질문의 대답을 완벽하게 할 순 없었다.
따라서 다음과 같은 구조를 활용하기로 결정했다.
Form ContextBoundary 를 구성하고 Form Data 를 책임질 수 있는Form컴포넌트를 구성한다.Form의 구성요소들인 각각의 인풋 컴포넌트들을 컴포지션할 수 있는 각각의 컴포넌트로 구성한다.- 각각의 인풋 요소들은 내부에서
useFormContext를 통해서 데이터 및 validation 정보에 접근하며,dataName이라는 필드를 통해서 외부에서 FormData 에 직접적으로 매핑할 수 있도록 한다. - 각각의 요소들은 레이아웃 컴포넌트를 통해서 일괄적인 UI 및 배치를 가지도록 하고,
Controller컴포넌트를 통해 연결하도록 한다. - 각각의 컴포지션 컴포넌트 들은 Validation UI 에 대한 로직만을 가지며, 실질적인 판단 로직은
Zod에 위임한다.
이렇게 구성함으로 각각의 문제점들에 대해 해결할 수 있는 Form 컴포넌트를 구현해 사용할 수 있었다.
const schema = z.object({
name: z.string().min(1),
email: z.string().email(),
});
<Form schema={schema} onSubmit={handleSubmit}>
<Form.TextField dataName="name" label="이름" />
<Form.TextField dataName="email" label="이메일" />
<SomeComponent />
<Form.Submit>제출</Form.Submit>
</Form>;다만 생각보다 FormData 내부에 접근해 이를 조건부로 사용하거나 확장해야하는 경우가 발생할 경우가 생겼었는데,
이럴 경우 결국 컴포넌트 내부에서 useFormContext 를 직접적으로 호출해 값에 접근해야했다.
값에 접근하게 될 경우, 의도치 않게 컴포넌트가 인풋 타이핑 이벤트 등에 반응성을 지닐 수 있었는데 이 부분을 해결할 수 있는 더 나은 시스템을 생각하지 못한 부분은 조금 아쉽게 느껴진다.
Zod 를 활용한 부분은 처음에는 복잡성을 너무 증가시키지 않을까 걱정했으나 오히려 validation 이 복잡해지면
복잡해질 수록 그 진가가 드러났다.
schema 를 따로 파일로 분할할 수 있을 뿐만 아니라 refine, super-refine 등을 활용해 보다 복잡한 validation 도 처리가 가능했기 때문이다.
만약 RHF 만을 사용했다면 구현이 얼마나 많이 복잡해졌었을까라는 생각이 든다.
Table
Form 과 마찬가지로 어드민 대시보드에서 Table 은 거의 모든 페이지에 존재했다.
디자인시스템이 Table UI 는 제공해주었지만, 정렬이나 필터링 같은 기능은 직접 구현해야 했다.
매번 페이지마다 이를 반복해서 작성하는 건 비효율적이었고, Tanstack Table 을 기반으로 DataTable 컴포넌트를 구성하기로 했다.
Form 이 컴포지션 방식이었다면, Table 은 반대로 config 주입 방식을 택했다.
테이블을 구성할 때마다 컴포넌트를 조립하는 것보다, 데이터와 컬럼 정의만 넘겨주면 알아서 렌더링되는 형태가 더 적합하다고 판단했기 때문이다.
const columns = createColumns<User>([
{ accessorKey: "name", header: "이름" },
{ accessorKey: "email", header: "이메일" },
{ accessorKey: "role", header: "역할" },
]);
<DataTable data={data} columns={columns} sorting filtering />;레이어 구조는 DataTable config → Tanstack Table config 으로 치환 → 디자인시스템 렌더링 순서였다.
Tanstack Table 이 제공하는 옵션이 워낙 많다 보니, 자주 쓰는 것들만 props 로 한 단계 얕게 열어두고 나머지는 내부에서 처리하는 방식을 택했다.
추가적으로 Tanstack Table 이 제공하지는 않지만 편의성으로 loading 분기와 empty 분기 그리고 디자인시스템의 props 등을 판별해 컴포넌트가 렌더링 할 수 있게끔 추가해주었다.
지금 돌아보면 config wrapper 레이어를 두지 않고 Tanstack Table 옵션을 그대로 받게 했다면 더 가볍지 않았을까 싶기도 하다.
당시엔 사용 편의를 위해 감춰두는 게 맞다고 생각했는데, 오히려 레이어가 하나 더 생기면서 예외 케이스가 생길 때마다 그 레이어를 열어줘야 하는 상황이 반복됐기 때문이다.
Select
Select 류 컴포넌트도 비슷한 맥락이었다.
디자인시스템이 빌딩블록은 제공해줬지만, 매번 조립하는 보일러플레이트가 많았고
Combobox 같은 확장된 컴포넌트의 경우 아이템 CRUD 로직까지 직접 구현해야 했다.
거기에 대량의 아이템을 렌더링할 때 성능 문제도 있어서 React-Window 로 가상화까지 처리해야 했다.
Table 과 마찬가지로 데이터 주입 방식으로 구성했고, 자주 쓰는 기능들 — 검색, 멀티셀렉, 가상화, 검색어 하이라이팅 등 — 을 props 로 간단히 켜고 끌 수 있도록 만들었다.
아쉬운 점도 있었다. 디자인시스템의 기본 데이터 형태가 { value: string } 이었는데, label 과 value 를 분리해서 다뤄야 하는 경우가 많아 데이터를 변환하는 과정이 계속 따라붙었다.
기능을 계속 props 로 확장하다 보니 컴포넌트가 점점 비대해진 부분도 Trade-off 였다. 사용하는 입장에서는 편했지만, 내부가 복잡해질수록 예외 케이스를 처리하는 비용도 함께 늘었다.
플러스 알파
프로젝트를 개발하면서 사실상 나는 개발자임과 동시에 그 어플리케이션을 가장 많이 사용하는 첫 번째 사용자가 되는 것과 마찬가지였다.
그 마음가짐으로 어떻게 하면 조금이라도 더 나은 UX 및 DX 를 제공하고 사용하는데 좀 더 직관적이고 덜 불편하게 될 수 있을까를 많이 고민했다.
그러나 고민과 실제 구현이 사용자에게 닿았는지는 또 별개의 문제였다.
다음은 그 고민들을 실제로 기능들로 뽑아낸 내용들이다.
레이아웃 리디자인
어플리케이션을 마이그레이션 하면서 큰 주제중에 하나였던 것은 전역에서 셀렉트하나로 갑자기 등장하고 사라졌던 히든 페이지들을 정리하고 개선하는 것이었다.
셀렉트 값에 몇몇 메뉴들과 라우트들이 의존하게 되면서, 그 값을 전역적으로 먼저 선행해서 선택하지 않으면 안되는 방식이었다.
다만 이 방식에서 명확한 문제점이라고 생각되었던 건 다음과 같다.
- 갑자기 전체의 사이드바 메뉴가 변경된다. 기존의 라우트들과 연결이 끊어져 뒤로 돌아갈 수 없다.
- 진입점이 모호하다. 어떤 메뉴를 접근하는 건지 사용자의 입장에서 명확하지 않다.
- 셀렉트에 어떤 값이 배정되어있는지 설명이 없었기 때문에 사용자가 알 수 없다.
종합적으로 가장 문제가 되는 점이라고 느껴졌던 것은, 도대체 내가 뭘 하고 있는지 알 수 없다는 점이었다. 따라서 이를 중점적으로 재설계를 진행했다.
- 히든 라우트들을 명확한 하나의 진입점으로 모았다.
- 하나의 독립된 레이아웃을 가지고 있던 페이지들을, 진입점 라우트 내부에 또 다른 레이아웃 그룹으로 묶고 소메뉴로 표시해 명백하게 하나의 그룹으로 묶여있음을 보여주려고 했다.
- 하나의 진입 라우트는 더이상 사이드바나 전체 어플리케이션의 레이아웃을 바꾸지 않는다.
- 내부 라우트들이 셀렉트 값에 의존적인 상황 자체를 바꿀 순 없었기에, 선택하지 않아도 접근이 가능하도록 변경했다.
다만, 선택하지 않았을 경우에 명확하게 피드백을 표시했다.
local-storage를 통해 history 를 구현해 한 번이라도 선택한 적이 있다면 자동으로 선택되도록 하여 최대한 선택하지 않았을 경우의 UI 가 표시되지 않도록 신경썼다.
이렇게 변경하고 나니 확실히 무엇을 하고 있는지, 어떤 작업을 하려고 라우트에 방문했는지 알 수 있게 되었고 다른 메뉴 및 라우트들과 공존할 수 있게 되었다.
다만 이 방식이 사용자들이 기존에 익숙했던 방식을 갑자기 바꾸었던 변화였기 때문에 즉각적인 사용성 증대로 와닿지는 않았던 점은 조금 아쉽게 느껴졌다.
Toast 와 Bulk
어드민 대시보드의 특성상 bulk 단위의 action 을 처리해야할 경우가 많았다.
기존 프로젝트에서도 로직이 구현은 되어 있었으나, 빈번히 사용되는 것에 비해 하나의 패턴으로 구현되어 활용되고 있는 부분은 없었다.
따라서 코드들을 정리하고 executeBulkAction 이라는 하나의 패턴으로 구현해 사용했다.
여기에 진행 상황을 Toast 로 실시간 피드백하고 싶었는데, 문제가 있었다. Toast 는 띄워진 그 순간의 closure 를 닫아버리기 때문에, 이후에 변화하는 데이터를 반영하려면 외부 Store 와 엮어줄 필요가 있었다.
이를 위해 전역 Store 를 두고 Toast Context 가 이를 바라보도록 구성했다.
Store 의 setter 를 onProgress 콜백으로 넘겨주면, Bulk 가 진행되면서 Toast 컨텐츠가 실시간으로 업데이트되는 구조였다.
executeBulkAction({
requests: [...],
handler: (request, signal) => deleteItem(request, signal),
cooldown: 200,
onProgress: ({ successCount, errorCount }) =>
toast.info(`성공 ${successCount} / 실패 ${errorCount}`),
});추가로 Toast 에 취소 버튼을 달고 AbortController 와 연결해, 진행 중에도 취소할 수 있도록 엮었다.
전역 작업과 Manager
작업이 오래 진행될 수 있다면 작업 진행 scope 는 하나의 라우트의 귀속되면 안된다.
기존 프로젝트의 경우에는 polling 과 bulk action 이 많았음에도 불구하고 해당 부분에 대한 고려가 되어있지 않아서 라우트가 변경될 경우 그대로 작업사항을 잃게 되는 문제가 있었다.
이를 해결하기 위해서 전역 레벨에서 작업을 열고 확인할 수 있는 시스템이 있었으면 좋겠다고 생각했고, 이를 위해 TaskManager 라는 앱 내의 시스템을 도입하기로 했다.
접근 자체는 간단했다.
- 전역 Store 를 두어 작업을 등록하고 등록된 작업의 progress, success, failure 등에 대한 정보를 확인할 수 있다.
- Task 와 Step 의 단위를 두어 장기 작업에 대해 handler 와 각각의 result 피드백에 대한 선언이 쉽게 가능하도록 한다.
- 필요하다면 전역 UI 를 두어 이를 notify 하거나 dismiss 할 수 있게끔 한다.
TaskManager.run({
name: "작업 이름",
args: { id },
steps: [
{
name: "step1",
action: ({ args }) => someAction(args.id),
repeat: {
until: (result) => result.status === "done",
interval: 3000,
maxRetries: 10,
},
onSuccess: () => toast.success("완료"),
onError: (error) => toast.error(error.message),
},
],
onCancel: () => toast.info("취소됨"),
});Task 와 Step 단위로 선언하면, 실행 및 취소 그리고 전역 UI 연결은 TaskManager 가 알아서 처리했다.
Step 에 repeat 을 두어 polling 처럼 특정 조건이 만족될 때까지 반복 실행이 필요한 경우도 선언적으로 처리할 수 있었다.
Task 의 실행 주체는 굳이 React 내부가 아닐 수 있기 때문에 Jotai 를 통해서 hook 이 아닌 update store 방식을 활용했다.
구현적으로 아쉬웠던 부분은, CRUD API 와 history 관련해서 UI 와 엮여있던 부분의 API 를 더 깔끔하게 손봤다면 좋지 않았을까 하는 아쉬움이 있다.
추가적으로 페이지 Referesh 에 대한 부분은 일부러 무시했는데, 당연히 신경 써야 하는 부분임에도 Storage 관련 로직과 Revivial 로직등을 엮기 시작하면 달성하고자하는 기능보다 훨씬 많은 오버헤드가 있을 것이라고 판단했기 때문이다.
Route Registry
라우트가 늘어나고 관련 기능들이 확장되면서 Sidebar, CMDK, 메뉴 관리 등 여러 곳에서 라우트 정보가 산개되기 시작했다.
문제는 Next 가 file based routing 을 사용하고 있기 때문에 route 정보를 사용한다는게 생각보다 쉽게 되지 않았다.
더군다나 단순히 route 정보를 읽는 것이 아니라 meta 정보들을 통해서 각각의 route 를 open / close 를 컨트롤하고 추가 정보를 표시해야하는 상황이 생겼다.
결국 하나의 Registry 로 모아 single source of truth 를 시도해 병치와 혼란을 막기로 결정했다.
다만, Tanstack Router 처럼 자동으로 Registry Generation 을 생각해 보았으나 webpack 및 turbopack 을
wrapping 해야하고 ROI 가 충분하지 않았기 때문에 수동으로 작업하되 수동 작업하는 곳을 줄이는 것으로 최종 결정했다.
CMDK 와 즐겨찾기
기존에 몇몇 메뉴에만 붙어있던 키바인딩을 같이 이관해달라는 요청이 있었다. (vimium 때문에 존재했는지도 몰랐지만) 매번 페이지마다 키바인딩을 관리하는 건 비효율적이라고 판단해, 대신 Command Palette 를 도입을 제안해 아예 대체했다.
키보드 내비게이션이 필요한 것이라면 전역적으로 사용가능하고 더 나은 인터페이스를 도입하지 않을 이유가 없었기 때문이었다. (개인적으로 Raycast 를 아주 잘 쓰고 있는 것도 한 몫했다.)
Fuse 를 통해서 사전 등록한 검색어를 활용해 Fuzzy 검색이 가능하도록 구현했고,
추가적으로 페이지 퀵 내비게이션 뿐만 아니라 몇몇 페이지의 조회 기능을 떼어와서 전역적으로 데이터 조회를 할 수 있는 형태로도 확장했다.
물론 빠른 키보드 내비게이션을 위해서 즐겨찾기 기능과 keyboard-event 를 활용해 alt + 1, 2, 3... 등으로도 빠르게 이동가능하도록 처리했다.
복잡한 Form 과 템플릿
필드가 수십 개에 달하며 내부에 분기가 있고 validation 이 굉장히 복잡한 Form 이 있었는데, 이 페이지를 개발하면서 이를 수정하고 테스트하는 것이 여간 힘든일이 아니었다.
기존에 Copy 비슷한 기능이 존재하긴 했으나, 이를 활용하기 위해서는 완성된 하나의 데이터 페이지를 찾아가 이를 통해서만 활용이 가능했다.
내가 명확하게 뭘 작성할지 판단이 선 상황이었으면 모를까 그렇지 않은 상황에서 활용성이 조금 부족하다고 생각했다.
추가적으로 기본적인 field 만 선별적으로 작성한뒤 varation 을 만들고 싶은 경우에는 값을 지워야하니 귀찮을 수 있지 않을까?
따라서 템플릿 저장 및 불러오기 기능을 추가하되, Form 작성 언제 어떤 순간에서라도 저장하고 불러올 수 있도록 구현했다.
다만 이전까지 Client 영속성은 local storage 에만 의존하고 있었는데, local storage 만으로는 감당이 안되는 데이터 구조였기 때문에 indexedDB 도입을 결심했다.
Tanstack Query 와 엮어서 따로 이를 위한 Async 처리를 하지 않아도 마치 서버 API 처럼 캐싱과 상태 관리를 동일한 방식으로 다룰 수 있도록 래핑했다.
IndexedDB 특성상 응답이 빠르기 때문에 별도의 로딩 처리 없이도 자연스럽게 동작했다.
넓어진 시야
프로젝트의 방향성과 달성해야할 목표들과 더불어서 가장 의미가 있었다고 생각하는 부분 중에 하나는 여러 방면에서 시야가 조금 넓어졌다는 것이다.
따져보자면 크게 세 가지로 나눌 수 있을 것 같다.
AI 와 Agent
나는 이 프로젝트를 맡기 전까지만 해도 이정도까지 AI 를 작업에 많이 활용하는 축에 속하지는 않았다.
프로젝트에 임하기 전 즈음에 agent 들과 cursor 가 막 대두되고 있었을 때였던 걸로 기억하는데 LLM 은 검색 및 리서칭에 적극적으로 활용하고 있었으나 agent 활용에 대해서는 무언가 조심스러운 면이 있었다.
이전에 비슷한 경험을 했었던 거라면 github copilot 이 있었는데, 필요한 snippet 을 적재적소에 끼워넣어주는 역할은 참 잘해줬었는데 오히려 이에 흐름을 맡겨 버리면 의도치 않은 코드가 갑자기 생겨나있거나, 전혀 예상치도 않은 부분에서 의도하지 않은 부분이 끼어있기도 했기 때문이다.
그 때도 했던 생각이었는데 코드나 feature 의 컨텍스트가 디테일해지고 복잡해질 수록 내가 드라이브를 잡고 핸들링을 해나가는게 점점 더 어려워지는 느낌이었다.
오히려 LLM 의 경우 내가 필요한 window 만을 제공할 수 있었기 때문에 좀 더 디테일하게 컨트롤 할 수 있는 부분이 있었고 그래서 활용도가 높았다고 생각했다.
이 프로젝트를 진행하면서 Agent 를 꽤나 마음대로 활용할 수 있는 리소스가 제공 되었기 때문에 최대한 많이 활용해볼 수 있었던 것 같고 그에 따라 이것 저것 많이 시도해볼 수 있는 기회가 있었다.
이에 대해 느낀 점은 굉장히 상반되는 것 같다.
확실히 code writing 의 시대는 끝이 난 것 같다. 그러나 한편으로는 code writing 을 반대로 의식적으로 더더욱 신경써야할 것 같다는 것이다.
확실히 Agent 는 어느정도의 context 가 확보가 된다면, output 을 standard 한 기준 이상으로 출력해주는 수준에 도달한 것으로 보인다.
개발자는 더이상 code writing 에 신경쓰지 않고 설계적인 측면만을 고려해 어플리케이션을 구성하면 굉장히 빠른 속도로 어플리케이션을 개발할 수 있다.
이 속도라는 것이 예전 copilot 의 편의성과는 비교할 수 가 없을 정도로 너무나 빠른 속도로 너무나 많은 text를 output 으로 내놓기 때문에 결국은 이 output들을 얼마나 좋은 퀄리티로 컨트롤하고 수정할 필요가 없을정도로 만드느냐가 문제가 되는 지경에 이르렀다.
garbage in, garbage out 이라는 말이 많이 쓰이는데, AI 시대에 정말 이보다 더 중요한 말이 있을까 싶다.
나의 경우 상용 프로젝트들에 비해서는 굉장히 작은 코드량과 복잡도를 지니고 있지만 (개인적으로는 그래도 어느정도 규모가 있다고 생각하고 싶지만) 컨벤션을 정리하고 패턴을 정의하고 난 후와 하기 전과의 AI agent 의 활용도와 효율성이 정말 눈에 띄게 차이가 나게 느껴졌다.
벤치마킹까지 할 정도까지 열성적이지는 않았지만, 그래도 눈에 띄는 말로 표현하자면 컨벤션이 잡히기 전 혼란스러운 상태에서는 agent 하나를 제대로 컨트롤해서 페이지 하나를 완성하기 까지 정말 오랜 시간이 걸렸다면
컨벤션이 잡히고 패턴이 분명해지고 난 후에는 git working-tree 를 통해서 병렬작업으로 페이지를 2개 + a 그리고 hotfix 까지 처리하는 지경에 이르게 된 것이다.
다만 여기서 아까 말했던 code writing 의 중요성으로 되돌아갈 필요가 있는 것 같다.
결국 좋은 context 를 만들고 시스템을 구성하고 이해하고 판단할 수 있는 능력은 전부 code writing 을 직접해온 그 과정들에 있기 때문이다.
코드를 한줄 한줄 써가며 직접 연결하며 그 시스템을 몸으로 이해하고 머리로 디버깅을하며 그 활용도를 이해할 수 있기 때문이다.
agent 를 손발로 쓰기 시작하면서 이러한 부분들이 많이 약해질 수 있겠구나 이부분들에 대해서 확실히 투자하고 훈련을 진행해야하겠구나 라는 걸 실감하기도 했다.
Skills 와 Hooks
단순히 그냥 느낀점만 쓰고 마무리하려고 생각하지는 않았다.
Agent 를 컨트롤 하고 활용하는 것도 하나의 능력이라면, 결국에는 작업에 필요한 Context 를 사전에 정의해놓고 이를 잘 활용하도록 구성해놓은 set 이 필요할텐데 이를 Claude 에서는 Skills 라고 부른다.
나 같은 경우 프로젝트에 참고하면 좋을 패턴들과 컨벤션들이 많이 존재했기에 이러한 부분들을 Skills 에 각각 example 들과 사전 정의된 prompt set 들을 정의해두고 Skills 로 정의해 두었다.
그런데 Skills 로 정의를 하면 뭐할까? agent 가 이를 읽고 활용하는 것이 굉장히 불확실했고 확률게임에 불과했다.
같은 고민을 하고 있는 사람이 없을까 직접 찾아본 결과 하나 유용한 게시글을 보았고 그 블로그 게시물을 참고해 bash script 를 작성해 Agent 의 Hook 에 추가했다.
agent hook 을 통해서 webpack 의 hook 과 비슷하게 agent 와의 챗 세션 싸이클 동안에 추가적인 지시 혹은 script 를 통해 일정한 작업을 실행할 수 있는 escape hatch 를 말한다.
여기서 나는 userprompt hook 에 접근해 사용자가 agent 에 프롬프트 할 때 반드시 모든 Skills 들을 판단해 활용평가를 하고 필요한 경우, Skills 를 로드에 컨텍스트에 활용하도록 유도했다.
이를 통해서 유의미하게, agent 가 현재 활용할 수 있는 skills 리스트를 확인하고 이를 task 에 반영할 수 있도록 유도할 수 있었다.(단순히 cat 을 통해서 agent 에게 skills 를 먼저 읽고 이를 작업해 활용하라는 지시일 뿐이었음에도)
해당 프로젝트의 경우 문서를 남겨두긴 했으나, 사실상 나의 작업과 이후 작업자들의 연결고리 역할을 해줄 수 있는 부분이 코드 베이스와 agent 활용 정도가 전부였기 때문에 이러한 과정이 필요했고 유용하게 사용될 수 있다고 믿는다.
그 외의 것들
해당 과정에서 프로젝트 외적으로 가장 크게 시야가 넓어질 수 있었던 부분이 인프라에 대한 시야라고 생각한다.
이전에는 단순히 몇 가지의 클라우드 서비스와 대강의 배포 과정들에 대해서 어렴풋이 알고 넘어갔었다면, 이 포지션을 통해서 (아주 디테일하고 속속들이 알고 있지는 못하지만) 파이프라인들과 수 많은 피쳐들이 어떤 과정들을 통해서 러닝 어플리케이션에 머지되고 실제로 어떤 환경에서 배포들을 거치는지 어깨너머로 확인할 수 있었다.
누군가가 알려주진 않았지만 (알려 달라고 요청해도 알려줄 수가 없었겠지만) 다양한 용어와 고민점들을 옆에서 들을 수 있었고 공부하고 알아보아야할 리스트들에 적어놓을 수 있었다.
추가적으로 FE 관점에서도 여러 가지 추가적인 사고들을 할 수 있었는데, 이전에는 단순히 개발 과정에만 집중했었다면 이제는 모니터링, 테스팅, regression (컴포넌트), A/B 지표 등의 개발 이후의 과정에 대해서 좀 더 고민하고 다룰 수 있을 것 같다는 생각이 든 것이다.
모든 경험을 총체적으로 할만한 프로덕트와 환경은 아니었으나 코드 베이스가 점차 거지고 도메인 로직이 복잡해지면서 테스팅 코드의 절대적인 필요성을 느꼇고 (agent 의 코드 output 이 너무나 많아 이를 검증하고 활용하는데에 대한 필요성도 포함해서) 로깅을 부착해보고 Kibana를 통해서 대략적으로 사용패턴과 사용 방식들을 직접 눈으로 확인하다 보니 실제 활용할 수 있는 이런 데이터들을 통해 UX 나 DX 를 좀 더 나은 방향으로 이끌 수 있지 않을까 하는 생각이 들었다.
그래서 정말 해보고 싶은 것들도 많고 공부해보고 싶은 것들도 많아 실제 프로덕트에 좀 더 가까이 다가가 문제와 직접 부딪히고 해결해나가면서 성장하고 싶다는 생각이 프로젝트 마지막에는 굉장히 많이 들었던 것 같다.
피드백 루프와 개발에대한 생각
마지막은 개발에 대한 이야기이자 개발에 대한 이야기가 아닐지도 모르겠다.
무언가를 만들어내거나 만들지 않더라도 어떤 행동을 할 때 그 행동이 외부와 어떤 영향을 주고 받느냐 하는 문제에 관한 것이다.
긍정적이지 않더라도 (좀 더 자세하게는 악영향을 미치는 부정적 싸이클만 아니라면) 하나의 피드백 루프와 싸이클안에서 작업하고 이를 고민하고 발전시켜나가는 부분들이 얼마나 소중한가에 대한 것이다.
이번 프로젝트를 하면서 굉장히 많이 느끼고 또 소중히 하고 그리고 필요로 했던 부분이아니었나 싶기도 하다.
프로젝트 초창기에는 나도 프로젝트도 환경도 서로서로 받아들이기 위해서 소통을 많이하고 피드백을 많이 주고받았고 이러한 부분들이 긍정적인 방향으로 이끌었으나
어느정도 자리가 잡히고 이후에 남은일이 주어진 작업들을 계속해 나가는 과정에 도달했을 때, 꽤나 많이 여러가지 생각들을 하게 되었던 것 같다.
실제로 프로덕트가 내부 팀들에게 공개가 되고 난후, 꽤 많은 수정 요청들과 작업해야할 사항들이 늘어났음에도 불구하고 오히려 훨씬 재밌게 느껴지기 시작했다.
내가 하는 개발이 무언가를 움직이게 만들고 (아무리 그게 버그일지라도) 실제로 사용하는 사람들이 주는 불편사항들과 개선사항들을 받는 것이 내가 만든 프로덕트가 살아있다고 느끼게 만들어줬다.
개발이라는 프로세스가 단순히 에디터 창을 열고 텍스트와 코드 더미를 수정하는 것이 아니라 그 행위의 모든 가치가 결국 이러한 부분에 있는 게 아닐까 하는 생각을 많이 하게 되었던 것 같다.
마지막
개인이자 팀
마지막으로 정말 아쉬웠던 점은 팀으로 일하고 같이 고민하고 무언가를 결정할 기회가 굉장히 적었다는 점이다.
탓할만한 부분이 단 하나 있다면 어쩔 수 없는 내 위치와 그에 따른 업무의 우선순위가 아니었을까.
앞으로의 목표가 있다면 앞서 말한 그 루프와 팀으로써 그리고 팀원이자 개인으로써 업무를 잘해나갈 수 있는 인원이 되기를 바란다는 것 뿐이다.
개발자
나는 무언가 만들어내는 사람이 되고 싶었던 것 같다.
좋아하는 취미나 활동들도 내 생각, 시선, 감정들을 무언가의 눈에 보이는 결과물들로 뽑아내는 것에 가까웠다.
개발도 내게 있어서 마찬가지였다.
Frontend 를 타겟으로 잡아나가게 된 건 그저 시작점일 뿐, 사실상 하고 싶었던 건 기능하는 하나의 완성된 무언가를 내놓는 것이었다.
그리고 그 완성이 단순히 동작하는 것만을 의미하지 않는다는 걸, 이번 프로젝트를 하면서 좀 더 분명하게 알게 된 것 같다. 결국 내가 개발에서 좋아하는 것도, 잘하고 싶은 것도 craftmanship 에 가깝다.
문제를 발견하고, 더 나은 방식을 고민하고, 그걸 코드로 깔끔하게 풀어내는 과정. 잘못된 구조를 보면 불편함을 느끼고, 패턴을 보면 어떻게 활용할 수 있을지를 생각하는 것. 이번 프로젝트를 하면서 그 감각들을 꽤 많이 쓴 것 같고, 그 과정들이 제일 재밌었던 것 같다.
앞으로도 그 부분을 잃지 않고 싶다.
아직 완전하지도 완성되지도 않고 가야할 길이 멀지만 빠르게 찍어내는 것보다, 제대로 된 것을 만들어 내는 과정에 속하고 싶다.