목록으로

신입 6개월 회고 - 코드보다 결정이 일이 됐다

2026년 04월 30일23 min read
#Retrospective#Career#Frontend#Vue#Nuxt#AI#Architecture

목차 보기
MES INVITE YOU 라고 적힌 공중전화 부스 그림과 따르르르르 호출음
MES INVITE YOU

회사에 들어온 지 6개월이 지났다. 한 달쯤 전부터 회고를 써야겠다는 생각이 들었는데, 막상 노트북 앞에 앉으니 정리가 잘 안 됐다. 6개월 동안 무엇을 했는지는 이력서에 적을 수 있다. 그런데 무엇이 달라졌는지는 따로 정리해 두지 않으면 모른다.

코드를 짜는 시간이 줄었다

가장 먼저 떠오른 건 이상한 사실이었다. 입사 전에 기대하던 것, 그러니까 코드를 많이 짜면서 손에 익히는 시간이 생각보다 많이 줄었다는 점이다. AI 도구가 보일러플레이트 한 화면을 1분에 만들고, 컴포넌트 초안을 30초에 던져 준다. 신입의 본업이 "일단 많이 짜 본다"라고 알고 있었는데, 그 명제 자체가 효율의 정당성을 잃어버린 셈이다.

처음엔 약간 막막했다. 그러면 6개월차에 나는 도대체 뭘 쌓고 있는 걸까. 회고를 쓰면서 가장 오래 매달린 질문이 이거였다.

지금 와서 보면 답은 단순하다. 코드를 짜는 시간 대신 결정을 내리는 시간이 늘었다. 어떤 패턴을 라이브러리로 올릴지, 어떤 옵션을 컨슈머에게 노출할지, 어떤 컨벤션을 코드로 강제할지. 코드 생산이 빨라진 만큼, 그 코드를 어디에 어떻게 둘지에 대한 결정의 비중이 상대적으로 커졌다. 6개월간 늘어난 건 코드량이 아니라 이런 결정의 누적이었다.

첫 두 달, 메뉴 한 건을 통째로

R&D 부서에 배치되고 처음 두 달은 본격 업무라기보다 신입 과제 겸 온보딩이었다. 사내 솔루션의 기존 템플릿 위에 마스터 데이터 화면 한 건을 직접 만들어 보는 과제였다. 작업 일정을 다루는 캘린더 화면이었는데, 월·일·시간·아젠다 네 가지 뷰를 다 지원하고 컨텍스트 메뉴, 일괄 교대조 변경, 휴일 변환, 클립보드 복사, 변경 이력, 시간 충돌 검증까지 들어가는 화면이었다.

규모로만 보면 신입 과제로 큰 편이지만 진짜 가치는 다른 데 있었다. 무엇을 넣고 뺄지를 직접 결정할 수 있었다는 것. 사수가 매주 피드백을 했는데, "왜 이 기능을 넣었나?", "왜 이건 빼기로 했나?", "추가하기 전에 어떤 대안을 고려했나?" 같은 질문이 코드 품질 지적보다 훨씬 많았다.

처음에는 "있으면 좋아 보이는" 기능을 다 넣었다. 매주 답변을 준비하다 보니 자연스럽게 알게 된 게 있다. 기능 하나를 넣는다는 건 그 기능이 잘 안 동작할 때의 대응까지 포함한 결정이다. 컨텍스트 메뉴 하나만 추가해도 키보드 접근성, 모바일 터치, 동시 편집 충돌, 권한별 노출이 따라 붙는다. 한 번 추가한 기능을 다음 주에 다시 빼기로 결정하면서, 기능을 넣는 결정만큼이나 빼는 결정도 정당한 의사결정이라는 걸 천천히 받아들이게 됐다.

이 화면은 실제로 솔루션에 들어갔다. 운영 환경에서 동작하는 자기 코드를 보는 건 처음이라 좀 멍했다. 동시에 그 만족감의 절반이 "어떤 기능을 왜 넣었는가"를 매주 다듬으며 만들어진 거였다는 것도 어렴풋이 알게 됐다.

짧은 새 모듈, 그리고 코어 작업

온보딩이 끝나고 짧게 새 모듈 하나를 맡았다. 그러다 해외 프로젝트 착수가 결정되면서 우선순위가 통째로 바뀌었다. 우리 팀이 그 전부터 준비하고 있던 작업, 그러니까 사내 솔루션의 프론트엔드를 코어와 커스텀으로 분리하고 코어를 별도 라이브러리 패키지로 떼어내는 일이 가장 시급한 과제로 올라왔다. 나는 그 작업의 가장 막내로 합류했다.

합류 첫날의 어색함이 또렷하다. 여기서 내가 정한 컨벤션은 N명의 다른 개발자에게 그대로 강제된다. 신입 과제에서 내린 결정은 한 화면의 운명을 바꾸는 정도였는데, 라이브러리에서 내리는 결정은 사내 솔루션을 쓰는 모든 프로젝트, 모든 개발자의 일상을 바꾼다. 같은 "결정"인데 단위가 달랐다.

이후 6개월의 대부분이 이 단위에 적응하는 시간이었다. i18n, 인증과 SSR, 디자인 시스템, 공개 API 마이그레이션, 그리드 표준화. 하나씩 적어 본다.

i18n, 그냥 되던 것을 분해해 본 일

처음 마주한 결정은 i18n 리팩터에서 나왔다. 기존 i18n 코어 패키지는 동작이 잘 됐다. 다만 안을 들여다보니 묘하게 불편한 점이 있었다. 패키지가 자기도 모르는 사이에 HTTP 백엔드와 언어 디텍터를 자동으로 와이어링하고 있었고, request, loadPath, parse 같은 transport 디테일이 컨슈머 측 설정에 그대로 새고 있었다. 더 큰 문제는 이 코어를 단독으로 테스트하기가 어려웠다는 것이다.

방향을 바꿨다. 코어가 framework-agnostic 해야 한다는 원칙을 처음부터 다시 잡고, transport 의 형태를 명확한 인터페이스 하나로 압축했다. 컨슈머가 자기 환경에 맞춰 사전을 어떻게 가져올지 결정하고, 코어는 사전이라는 결과물의 형태에만 의존하도록 분리했다. Nuxt SSR 환경에서는 init 시점에 사전을 한 번 주입하는 방식으로, 런타임 중간에 addResourceBundle 같은 부수 효과를 끼얹지 않도록 손봤다.

작업을 마치고 가장 먼저 든 생각은 코드보다 인터페이스에 대한 것이었다. 코어를 단독으로 테스트하기 어렵다는 건 기술적인 문제처럼 보이지만 실은 경계가 잘못 그어졌다는 신호였다. 코어가 무엇을 알아야 하는지를 인터페이스로 못 박지 않으면 컨슈머의 환경 디테일이 코어 안으로 새기 시작한다. 이 깨달음이 이후 다른 작업의 기준선이 됐다.

타입 한 줄이 회의 30분을 줄여 줬다. 같은 의미의 두 시그니처가 어떻게 다른지 비교하면 명확하다.

ts
// Before
i18nCore.init({
  backend: {
    request: (options, url, payload, callback) => { /* ... */ },
    loadPath: '/locales/{{lng}}/{{ns}}.json',
    parse: (data) => JSON.parse(data),
  },
})

// After
interface DictionaryLoader<TLng extends string, TNs extends string> {
  load(lng: TLng, ns: TNs): Promise<Record<string, string>>
}

i18nCore.init({
  loader: makeDictionaryLoader({ /* consumer options */ }),
})

문서 한 페이지가 코드 한 줄로 옮겨가는 순간이다. 이전 시그니처에서는 컨슈머마다 backend 객체를 다르게 채웠고, 그 차이가 동작 차이로 이어졌다. 새 인터페이스로 강제하니 사람이 지킬 규칙이 컴파일러가 강제하는 규칙으로 바뀌었다.

i18n transport 시그니처 비교

Auth와 SSR, Pinia에서 토큰을 빼던 날

다음으로 손을 댄 건 인증과 SSR 이었다. 시작은 한 줄짜리 코드였다. 사내 인증 패키지의 Pinia 스토어가 accessToken을 자기 상태로 들고 있었고, Nuxt SSR payload 가 그 상태를 직렬화해서 클라이언트로 통째로 내보내고 있었다.

기능은 잘 돌아갔다. 두 가지가 마음에 걸렸다. 토큰이 직렬화 가능한 영역에 노출되어 있다는 점, 그리고 권한 정보(permissionMap)가 메뉴 리스트(menuList)와 별도로 저장되어 두 데이터가 같은 정보를 두 번 들고 있다는 점이었다.

방향은 두 가지였다. 토큰은 직렬화 가능한 상태에서 빼서 cookie 와 메모리 양쪽에서 통제 가능한 형태로 옮긴다. permissionMap은 별도 저장 대신 menuList에서 derive 하도록 한다. 그리고 메뉴 프리로드는 plugin 레벨이 아니라 라우트 진입 시점의 미들웨어에서 라우트 인지 형태로 가져오게 한다.

작업이 끝난 뒤 가장 무겁게 남은 건 SSR 자체에 대한 감각이었다. SSR 은 한 번 잘못 서랑 메모리와 보안 양쪽이 새는 구조다. 동기 흐름과 달리 SSR 에서 새는 데이터는 응답에 직렬화되어 사용자 브라우저까지 도달한다. 한 번 새기 시작하면 패치가 어렵다. 이후로 직렬화 경계를 처음 그을 때부터 "이 데이터가 직렬화돼도 되는가"를 물어보는 습관이 생겼다.

디자인 시스템, 도메인을 도로 끄집어내다

사내 디자인 시스템 패키지에는 의외로 도메인 종속 컴포넌트가 섞여 있었다. SearchForm처럼 검색 조건이라는 비즈니스 컨벤션이 깊이 박힌 컴포넌트, AlertDialog처럼 사내 메시지 포맷에 맞춰진 다이얼로그, FileUploader처럼 백엔드 업로드 엔드포인트를 가정하는 컴포넌트들이었다. 시작할 때는 자연스러웠을 결정인데 결과적으로 디자인 시스템의 경계가 흐려져 있었다.

작업 방향은 단순했다. 디자인 시스템에는 base 프리미티브만 남기고, 도메인 결합이 있는 컴포넌트는 솔루션 코드 쪽으로 환원한다. 솔루션 쪽에서 디자인 시스템의 base 를 import 해 도메인 wrapper 를 직접 작성하는 형태로 바꿨다. 환원이 단순한 코드 이동이 아니라는 건 작업 중에 드러났다. 컴포넌트가 쓰는 i18n 키, 검색 폼이 의존하는 코드 테이블, 다이얼로그가 호출하는 메시지 서비스 같은 것들이 디자인 시스템의 경계를 따라 도미노처럼 끌려 들어와 있었다. 하나를 빼면 옆 컴포넌트의 의존이 새로 보였고, 결국 한 사이클이 아니라 두세 사이클을 돌아야 정리됐다.

디자인 시스템의 경계는 한 번 흐려지면 되돌리기까지의 비용이 생각보다 크다. 이 작업을 하면서 처음부터 명확한 import 방향을 잡는 게 왜 강조되는지를 체감했다.

공개 API의 경계를 옮긴다는 것

API 시스템 마이그레이션은 결정의 무게가 가장 무거웠다. 사내 솔루션의 공개 API 경계가 처음에는 한 라이브러리 패키지 안에 있었다. 모든 프로젝트가 그 패키지를 import 해서 named resource 와 호출 함수를 가져다 썼다. 시간이 지나면서 그 경계가 비대해졌다. 솔루션 종속적인 호출과 도메인 무관한 호출이 한 곳에 섞이면서, 라이브러리가 "공통 코어"라기보다 "공통 보관함"에 가까워졌다.

방향은 공개 API 경계를 라이브러리 패키지 바깥, 솔루션 앱 안의 별도 디렉터리로 옮기는 거였다. 라이브러리 측에는 named resource 와 defineModule 같은 모양만 남기고, 실제 모듈 정의는 솔루션 측 코드가 책임진다. 사용되지 않던 서비스 모듈은 마이그레이션 도중에 제거했고, 헬퍼 안쪽에 숨어 있던 엔드포인트 몇 개를 first-class 자원으로 끌어올렸다.

이 작업에서 가장 자주 했던 결정이 "이 export 를 그대로 둘 것인가"였다. 라이브러리는 한 번 export 하면 영원히 책임진다는 걸 가장 또렷하게 체감한 순간이다. 작업이 진행될수록 외부 노출과 내부 자유의 비율을 어떻게 잡느냐가 결정의 핵심이라는 게 분명해졌다. 컨슈머가 적게 알수록 코어는 자유롭고, 컨슈머가 많이 알수록 코어는 발이 묶인다.

그리드, 다섯 종의 시그니처를 정렬하기

그리드는 사내 솔루션에서 사용 빈도가 가장 높은 컴포넌트군이다. 일반 그리드, 무한 스크롤 그리드, 트리 그리드, 피벗 그리드, 저장 그리드까지 다섯 종류가 있고, 각각 다른 옵션 객체와 fetcher 시그니처를 가지고 있었다. 컨슈머 입장에서는 본질적으로 같은 동작을 다섯 번 다른 방식으로 외워야 하는 상태였다.

작업의 본질은 옵션과 fetcher 시그니처를 다섯 종 모두에 걸쳐 통일하는 일이었다. fetcher 가 받아야 할 컨텍스트(facId, loginId, 언어, HTTP 클라이언트)를 MesFetchContext 타입으로 묶었고, 옵션의 messageName, extraPayload, select 같은 필드를 다섯 종에 동일한 의미로 적용했다. 같은 모양의 컴포저블, 그러니까 useMesGrid, useMesInfiniteGrid, useMesTreeGrid, useMesPivotGrid, useMesSave 로 외부 인터페이스를 정렬했다.

항목BeforeAfter
fetcher 시그니처5종이 제각각단일 타입
옵션 객체 키그리드마다 다름5종 동일
외워야 할 형태5가지1가지

작업이 끝났을 때 컨슈머 측 코드는 단조로워졌다. 단조롭다는 건 boilerplate 가 줄었다는 뜻이고, 어떤 그리드를 쓰든 같은 모양의 코드가 나온다는 뜻이다. 반복되는 패턴을 standard 로 결정화하는 일이 라이브러리 작업의 본질이라는 감각을 가장 또렷하게 받은 작업이었다. 폴리레포 분리 직전에 이 정렬이 끝나야 했던 이유도 같은 맥락이다. 옵션이 정렬되지 않은 채로 패키지가 쪼개지면 결국 패키지마다 같은 결정을 다시 내리게 된다.

6개월의 결론

여기까지가 직접 손을 댄 작업의 큰 줄기다. i18n, 인증, 디자인 시스템, API 경계, 그리드. 각 작업의 기술적 디테일은 전부 다른데, 매번 같은 모양의 결정을 마주했다. 이 패턴을 코어에 올릴 것인가 컨슈머에 둘 것인가. 이 옵션을 외부에 노출할 것인가 내부에 숨길 것인가. 이 컨벤션을 코드로 강제할 것인가 문서로 강제할 것인가.

지난 6개월 동안 내가 쌓은 게 뭐냐고 누가 물으면, 코드량은 아니다. 코드는 도구가 더 잘 쓴다. 쌓인 건 위 같은 결정의 기록이다. 결정을 내릴 때마다 매번 막내라는 무게가 따라왔다. 내가 정한 인터페이스가 다른 개발자의 일상에 매일 반영된다는 사실, 한 번 잘못 그은 경계가 나중에 모두를 끌어들인다는 사실이 매번 다시 무겁게 와 닿았다.

그래도 6개월 끝에서 한 가지는 분명해졌다. 이 무게는 반복으로만 다뤄지는 종류의 무게다. 첫 i18n 인터페이스를 그을 때보다 다섯 번째 그리드 옵션을 정렬할 때 결정이 더 빠르고 덜 흔들렸다. 도메인 지식, 다른 개발자와의 커뮤니케이션, 기술 방향성을 적는 일은 코드량과는 별개의 축으로 쌓이고 있다는 감각이 든다.

다음 6개월

다음 분기의 가장 큰 그림은 두 갈래로 나뉜다. 솔루션을 구성하는 여러 웹 앱은 각각 독립된 폴리레포로 분리해서 자기 배포 주기와 자기 의존성을 가지도록 하고, 그동안 한 패키지에 몰려 있던 라이브러리 코드는 작은 패키지 여러 개로 쪼개 모노레포 안에 둔다. 앱 쪽은 자유도와 독립성을, 라이브러리 쪽은 패키지 간 정합성과 동시 변경의 편의를 우선하는 분업이다. 추가 모듈의 코어 분리, 도메인 별 라이브러리화도 이 그림 안에 들어 있다.

apps / packages / shared / infra / docs 디렉터리로 정리된 모노레포 구조
라이브러리 쪽 모노레포가 잘 정리되면 이런 모양이지 않을까 싶어 GPT 에 그려 봤다. 손그림 수준의 예시이고 실제 디렉터리 이름은 작업하면서 다시 잡아야 한다. 솔루션 앱은 이 그림 바깥의 별도 폴리레포로 빠진다.

매 결정마다 막내라는 단어가 점점 어색해지길 바라고, 동시에 결정의 무게는 계속 무겁게 느끼며 기록을 이어 가고 싶다. 컨슈머의 보일러플레이트를 한 줄 줄이는 결정이 결국 라이브러리 개발자의 가장 중요한 일이라는 걸 잊지 않도록.

검색...

검색...