toss reality — fiction 02

코드 리뷰 47건

넥스트비전에서는 4년 동안 코드 리뷰를 받은 적이 없었다.
토스에서는 2주 만에 47건을 받았다.

Part I

"첫 번째 PR"

2027년 2월 24일 월요일 오전 10시 04분. 입사 2주차.

도현은 스프린트 플래닝 미팅을 세 번 참관했다. 온보딩 문서를 두 번 읽었다. TDS(Toss Design System) 컴포넌트 라이브러리를 훑었다. Yarn Berry 모노레포 구조를 파악했다. 2주 동안 코드를 한 줄도 프로덕션에 올리지 않았다.

PO 강서윤이 스프린트 보드를 보며 말했다.

"도현 님, 이번 스프린트에서 결제 확인 페이지 로딩 UX 개선 건 잡아보시겠어요? 작은 건이라 온보딩 겸 해보시기 좋을 것 같아요."

도현은 고개를 끄덕였다. "작은 건." 결제 확인 페이지에서 카드 정보를 불러오는 동안 보여줄 스켈레톤 UI를 추가하는 작업이었다. 넥스트비전이었다면 30분이면 끝날 일이다. div 하나 만들고, CSS 애니메이션 넣고, push하면 된다.

도현은 VS Code를 열었다. 토스의 코드베이스는 넥스트비전의 것과 차원이 달랐다.

# 넥스트비전 프로젝트 구조
src/
  pages/
    index.js
    quote.js
    admin.js
  styles/
    global.css # 1,847줄, 전부 여기
  utils/
    helpers.js # "나중에 정리" — 정리한 적 없음

# 토스 결제 사일로 모노레포 (일부)
packages/
  payment-ui/
    src/
      components/
        PaymentConfirm/
          PaymentConfirm.tsx
          PaymentConfirm.test.tsx
          PaymentConfirm.stories.tsx
          index.ts
        CardSelector/
        SkeletonLoader/
      hooks/
      utils/
      constants/
  payment-api/
  tds-components/ # Toss Design System
  shared-utils/

넥스트비전에서는 파일이 한 손에 잡혔다. 폴더 3개, 파일 10개. 전부 내 머릿속에 있었다. 여기서는 packages만 12개다. 컴포넌트 하나에 .tsx, .test.tsx, .stories.tsx, index.ts가 세트로 붙는다. 스켈레톤 UI 하나를 추가하는데 파일을 4개 만들어야 한다.

도현은 코드를 짰다. 3일이 걸렸다. 코드를 짠 시간은 4시간이었다. 나머지 시간은 코드를 짜기 전에 기존 코드를 읽는 데 쓴 것이다. PaymentConfirm 컴포넌트의 구조, TDS의 Skeleton 컴포넌트 사용법, emotion 스타일링 문법, React Suspense 패턴. 여기서는 만드는 시간보다 읽는 시간이 압도적으로 길다.

2월 27일 목요일 오후 4시 32분. 도현은 PR을 올렸다.

$ git add .
$ git commit -m "fixed stuff"
$ git push origin feat/payment-skeleton

# GitHub — Pull Request #4,217
Title: 결제 확인 페이지 스켈레톤 UI 추가
Branch: feat/payment-skeleton → main
Files changed: 14
Lines: +812, -23

812줄. 도현은 줄 수를 신경 쓰지 않았다. 넥스트비전에서는 1,000줄짜리 커밋도 일상이었다. 한 번에 만들고, 한 번에 올리고, 한 번에 끝내는 것이 효율적이라고 생각했다.

5분 뒤 알림이 왔다.

# GitHub Actions — auto-reviewer-bot
Assigned reviewers:
  @jaewon.park (FE Lead)
  @nahyun.kim (FE)
  @eunso.cho (BE — cross-review)

⚠ PR size: 812 lines (recommended: 300-400)

리뷰어가 자동으로 할당되었다. 3명. GitHub Actions 봇이 사일로 내 개발자 중 가용한 리뷰어를 자동으로 골라 붙였다. 넥스트비전에서는 리뷰어가 0명이었다. 여기서는 5분 만에 3명이 붙는다.

그리고 경고가 떴다. PR 크기: 812줄 (권장: 300~400줄).

첫 번째 코멘트가 30분 뒤에 달렸다.

JP
박재원 reviewer 16:58
도현 님, PR 잘 봤습니다! 전체적인 방향은 좋은데, PR 크기가 800줄이 넘어요. 저희는 300~400줄을 기준으로 하고 있어서, 기능 단위로 나눠주시면 리뷰가 수월할 것 같아요.

1. 스켈레톤 컴포넌트 생성 → PR 1
2. PaymentConfirm에 스켈레톤 적용 → PR 2
3. 테스트 + 스토리북 → PR 3

이렇게 세 개로 나눠볼까요?

800줄이 왜 문제인지 처음에는 이해하지 못했다. 여기서는 main이 곧 라이브다. 800줄짜리 변경이 라이브에 직접 올라간다는 뜻이다. 그 800줄 중 하나에 버그가 있으면, 전체를 롤백해야 한다. 300줄씩 세 개로 나누면, 버그가 있는 300줄만 롤백하면 된다. 그 단순한 논리를 지금 처음 배운다.

도현은 PR을 닫고, 세 개로 나누는 작업을 시작했다.

* * *

Part II

"Conventional Commit"

2월 28일 금요일 오전 11시. PR 1을 올렸다. 스켈레톤 컴포넌트 생성. 287줄.

커밋 메시지를 썼다.

$ git commit -m "fixed stuff"

✗ commitlint: subject may not be empty
✗ commitlint: type may not be empty
✗ Expected format: type(scope): description

커밋이 거절당했다. Git hook이 커밋 메시지를 검사한 것이다. "fixed stuff"는 Conventional Commit 규칙에 맞지 않는다. 넥스트비전에서 도현의 커밋 히스토리는 이랬다.

# 넥스트비전 — 도현의 커밋 히스토리 (실제)
fix thing
update
fixed stuff
wip
asdf
ㅇㅇ
진짜 최종
진짜진짜 최종 (2)

도현은 온보딩 문서에서 Conventional Commit 규칙을 다시 찾아 읽었다.

conventional commit format
type(scope): description

// types
feat: 새 기능 추가
fix: 버그 수정
chore: 빌드, 설정 변경 (코드 영향 없음)
refactor: 리팩토링 (기능 변경 없음)
test: 테스트 추가/수정
docs: 문서 수정

// example
feat(payment): 결제 확인 페이지에 스켈레톤 UI 추가

도현은 커밋 메시지를 다시 썼다.

$ git commit -m "feat(payment): 결제 확인 페이지 스켈레톤 컴포넌트 생성"
✓ commitlint passed

"fixed stuff"로 4년간 살았다. 커밋 메시지에 의미를 쓰는 것이 습관이 아니었다. 어차피 읽는 사람이 나밖에 없었으니까. 여기서는 커밋 메시지를 다른 사람이 읽는다. 한 달 뒤에 내가 쓴 커밋을 다른 사람이 보고, 무슨 변경이었는지 이해해야 한다. "fixed stuff"로는 불가능하다.

PR 1이 올라갔다. 리뷰가 시작되었다. 첫 번째 코멘트가 14분 만에 달렸다.

NH
김나현 reviewer 11:27
- const SkeletonBox = styled.div`
- width: 100%;
- height: 48px;
- background: #e8e4dc;
- border-radius: 8px;
- animation: pulse 1.5s infinite;
- `;
도현 님, 여기서 커스텀 스켈레톤을 만드셨는데, TDS에 Skeleton 컴포넌트가 이미 있어요!

suggestion
import { Skeleton } from '@toss/tds';

<Skeleton width="100%" height={48} />

TDS. Toss Design System. emotion 기반의 사내 컴포넌트 라이브러리. 스켈레톤, 버튼, 인풋, 토스트, 바텀시트 — 모든 UI 컴포넌트가 이미 만들어져 있다. 도현은 이것을 읽기는 했지만, 습관적으로 커스텀 CSS를 짠 것이다. 넥스트비전에서는 모든 것을 직접 만들어야 했으니까.

JP
박재원 reviewer 11:42
- const [isLoading, setIsLoading] = useState(true);
- const [cardData, setCardData] = useState(null);
-
- useEffect(() => {
- fetchCardInfo().then(data => {
- setCardData(data);
- setIsLoading(false);
- });
- }, []);
데이터 페칭에 useState + useEffect 조합을 쓰셨는데, 저희는 React Query(TanStack Query)를 씁니다. 로딩/에러/캐시가 자동으로 관리돼요.

suggestion
const { data: cardData, isLoading } = useQuery({
  queryKey: ['card-info', paymentId],
  queryFn: () => fetchCardInfo(paymentId),
});

React Query. 도현은 이름은 알고 있었다. QuoteMate에서 쓸까 고민했지만, "상태관리 라이브러리를 배울 시간이 없다"는 이유로 useState + useEffect 패턴을 고수했다. 넥스트비전에서는 그것으로 충분했다. 아무도 문제 삼지 않았으니까.

JP
박재원 reviewer 11:58
- <form onSubmit={handleSubmit}>
- <input value={amount} onChange={e => setAmount(e.target.value)} />
폼 관리에 react-hook-form을 써주세요. 현재 패턴이면 리렌더가 입력마다 발생해요. 결제 페이지에서는 성능이 중요합니다.

react-hook-form. 또 모르는 것이다. 도현은 코멘트를 하나씩 읽으며 메모장에 키워드를 적었다.

# 도현의 메모 — "몰랐던 것들"

1. TDS Skeleton 컴포넌트 → 커스텀 CSS 금지
2. React Query → useState+useEffect 금지
3. react-hook-form → 수동 폼 핸들링 금지
4. emotion → CSS 파일 직접 작성 금지
5. Conventional Commit → "fixed stuff" 금지
6. PR 300~400줄 → 800줄짜리 금지

넥스트비전에서 4년간 한 것의 대부분이 여기서는 금지다.

도현은 코멘트를 반영하기 시작했다. 커스텀 스켈레톤을 TDS Skeleton으로 교체했다. useState + useEffect를 React Query로 바꿨다. emotion 문법을 처음 써봤다. 컴파일 에러 12번. 타입스크립트가 타입을 요구했다. 넥스트비전은 자바스크립트였다. 타입이 없는 세계였다.

넥스트비전에서는 "동작하면 된다"가 원칙이었다. 아무도 내 코드를 읽지 않았으니까. 여기서는 "동작하는 것"이 시작이다. 동작한 다음에, 읽기 쉬운지, 유지보수가 가능한지, 성능이 괜찮은지, 기존 패턴과 일관되는지를 본다. 4단계가 더 있다.

* * *

Part III

"47건"

3월 5일 수요일 오후 6시 14분. PR 3개 모두에 대한 리뷰가 완료되었다.

도현은 GitHub의 코멘트 탭을 열었다. 세 개 PR의 코멘트를 합산했다. 47건.

스타일/패턴 12
로직 개선 8
테스트 누락 7
TDS 미사용 6
네이밍 5
성능 4
접근성 3
기타 2

47건. 스켈레톤 UI 하나를 추가하는 데 리뷰 코멘트 47건. 넥스트비전에서는 QuoteMate를 포함해 4년간 코드 리뷰를 받은 횟수가 0건이었다. 정확히 0건. 코드를 읽어주는 사람이 없었으니까.

47건 중에서 가장 기억에 남는 것 세 개가 있었다.

첫 번째. 네이밍.

NH
김나현 reviewer 14:22
- const data = useQuery(...);
data라는 이름은 아무 의미가 없어요. 이 변수가 뭘 담는지 이름에서 알 수 있어야 합니다.

suggestion
const { data: paymentCardInfo } = useQuery(...);

"data." 넥스트비전에서 내 변수명의 80%가 data, temp, result, item이었다. 나만 읽는 코드였으니 상관없었다. 여기서는 다른 사람이 읽는다. "data"만 보고는 이것이 카드 정보인지, 결제 금액인지, 사용자 프로필인지 알 수 없다.

두 번째. 테스트.

JP
박재원 reviewer 15:07
테스트가 없습니다. 최소한 다음 케이스는 커버해주세요:

1. 로딩 중일 때 스켈레톤이 표시되는지
2. 데이터 로드 완료 후 스켈레톤이 사라지는지
3. 에러 시 에러 UI가 표시되는지
4. 카드 정보가 올바르게 렌더링되는지

테스트. 도현은 테스트 코드를 작성한 적이 없다. QuoteMate에도 테스트가 없다. 넥스트비전의 4년간 전체 테스트 커버리지는 0%다. "동작하면 테스트"라는 것이 도현의 원칙이었다. 브라우저에서 클릭해보고 되면 끝.

세 번째. 접근성.

EC
조은서 cross-review 16:33
- <div className={skeletonStyle}>
스켈레톤 UI에 aria-busy="true"role="status"를 추가해주세요. 스크린 리더 사용자가 로딩 중이라는 것을 알 수 있어야 합니다.

접근성. aria-busy. role="status". 접근성을 생각해본 적이 없다. 스크린 리더를 사용하는 사용자가 서비스를 쓸 거라고 생각해본 적이 없다. 여기서는 BE 개발자가 FE의 접근성을 지적한다. cross-review. 다른 직군이 내 코드를 보고 접근성을 이야기한다.

도현은 47건의 코멘트를 화면에 띄워놓고 하나씩 읽었다. 오후 6시가 넘었다. 사무실에는 여전히 사람들이 있었다. 도현은 자리에서 일어나지 않았다.

옆자리의 박재원이 노트북을 닫으며 말했다.

"도현 님, 리뷰 많이 받으셨죠?"

"47건이요."

재원이 고개를 끄덕였다.

"저도 처음에 그랬어요. 첫 PR에서 리뷰 40건 넘게 받았습니다. 카카오에서 올 때."

도현은 재원을 보았다. 28세. 카카오 출신. 도현보다 7살 어리다.

"카카오에서도 코드 리뷰 많이 받으셨을 텐데요."

"카카오는 좀 다릅니다. 토스 코드 리뷰는 밀도가 다르다고 해야 할까. 여기 코드스멜 워킹그룹이라고 있거든요. 매주 모여서 코드 리뷰에서 반복되는 패턴을 정리합니다. 가독성 위원회도 따로 있고요."

코드스멜 워킹그룹. 가독성 위원회. 도현은 이 단어들이 현실에 존재한다는 것이 신기했다.

"저 하나 도와드릴까요? 리뷰 반영하는 거 같이 보면 빠를 수 있어요."

도현은 2초간 망설였다. 28살한테 도움을 받는 것이 자존심이 상하는지 스스로에게 물었다. 0.5초 만에 답이 나왔다. 아니다. 여기서는 나이가 아니라 코드가 기준이다. 재원의 코드가 더 깨끗하다. 그것은 사실이다.

"부탁드립니다."

재원이 의자를 도현 쪽으로 끌고 왔다. 도현의 모니터를 함께 보며 코멘트를 하나씩 짚어나갔다.

"이 부분은 TDS의 Skeleton 컴포넌트가 props로 animation 타입을 받거든요. pulse 말고 wave도 있어요. 결제 페이지에서는 wave가 더 자연스러울 수 있어요."

"여기 React Query 옵션에 staleTime을 추가하면 좋겠어요. 카드 정보는 자주 바뀌지 않으니까, 5분 정도 캐시하면 불필요한 API 호출을 줄일 수 있습니다."

"emotion에서 css prop을 쓸 때는 이렇게 분리하면 가독성이 올라갑니다."

재원은 2시간 동안 도현 옆에 앉아 있었다. 47건 중 30건을 함께 반영했다. 도현은 재원이 설명하는 것을 들으며 메모장에 빽빽하게 적었다. 넥스트비전에서 4년 동안 동료에게서 코드를 배운 시간은 0시간이다. 여기서는 2시간 만에 TDS, React Query, emotion, 접근성, 테스트 패턴을 배웠다.

28살이 나한테 코드를 가르친다. 근데 맞는 말이다. 모든 코멘트가 맞는 말이다. 47건 중에서 "이건 좀 아닌데"라고 반박할 수 있는 것이 하나도 없다. 넥스트비전에서 4년간 쌓은 습관이, 여기서는 전부 교정 대상이다.

* * *

Part IV

"12시의 편의점"

3월 5일 수요일 밤 11시 17분.

사무실에 남은 사람은 도현과, 다른 사일로의 BE 개발자 한 명뿐이었다. 도현은 47건의 코멘트를 모두 반영하고 PR 3개를 다시 올렸다. 커밋 메시지를 세 번 확인했다. Conventional Commit 형식. TDS 컴포넌트 사용. React Query 적용. react-hook-form 적용. 테스트 4개 작성. aria-busy, role="status" 추가.

# PR #4,217-1: 스켈레톤 컴포넌트 생성
feat(payment): 결제 확인 페이지 스켈레톤 컴포넌트 생성
Lines: +142, -0
Status: ✓ Approved (2/2)

# PR #4,217-2: 스켈레톤 적용
feat(payment): PaymentConfirm에 스켈레톤 로딩 UI 적용
Lines: +98, -23
Status: ✓ Approved (2/2)

# PR #4,217-3: 테스트 + 스토리북
test(payment): 스켈레톤 로딩 UI 테스트 및 스토리 추가
Lines: +87, -0
Status: ✓ Approved (2/2)

Approve 2개. 세 개 PR 전부. 머지 버튼을 눌렀다. 초록색 "Merged" 뱃지가 떴다. 도현의 코드가 main에 합류했다. main은 라이브다. 도현의 코드가 토스 사용자의 화면에 표시된다.

넥스트비전에서는 git push origin main을 치면 그것이 배포였다. 여기서는 PR → 코드 리뷰 → Approve → 머지 → CI → 배포. 6단계. 넥스트비전의 1단계가 여기서는 6단계다. 그리고 그 5단계의 차이가, 코드의 품질 차이를 만든다.

밤 11시 42분. 도현은 사내 편의점으로 갔다. 편의점 문을 열었다. 불이 켜져 있었다. 24시간. 컵라면을 하나 골랐다. 삼각김밥을 하나 더 골랐다. 바코드를 찍었다. 결제 금액: 0원.

뜨거운 물을 부으며 도현은 생각했다.

4년 동안 코드 리뷰를 받은 적이 없었다. 내가 짠 코드가 좋은 코드인지 나쁜 코드인지 판단해줄 기준이 없었다. "동작하면 끝"이 내 기준이었다. 그 기준으로 투자자 데모까지 갔다. 동작은 했다. 하지만 좋은 코드는 아니었다. 그것을 알기까지 여기 와서 2주가 걸렸다.

컵라면을 먹으며 Slack을 열었다. 사일로 채널에 메시지가 와 있었다.

# settlement-silo
박재원 23:44
도현 님 첫 머지 축하합니다! 리뷰 반영 속도 빨랐어요. 내일부터 다음 스프린트 건 잡아볼까요?
강서윤 (PO) 23:45
축하해요 도현 님! 다음 건은 좀 더 큰 거 드려도 될까요? 카드 선택 UI 리뉴얼 건이 있어요.

도현은 컵라면 국물을 한 모금 마시고 답장을 쳤다.

한도현 23:48
감사합니다. 네, 잡아보겠습니다.

보내기를 누른 뒤 도현은 잠시 멈추었다. 그리고 한 줄을 더 썼다.

한도현 23:48
이번에는 300줄 안에 끝내보겠습니다.
박재원 23:49
ㅋㅋ 좋습니다

도현은 편의점 테이블에 앉아 빈 컵라면 용기를 정리하며 창밖을 보았다. 강남의 밤이 여전히 밝았다. 안양의 밤과는 다른 밝기다. 하지만 야근은 같다. 넥스트비전에서도 밤 12시에 사무실에 있었다. 대표가 시킨 것이 아니라, 코드가 끝나지 않아서. 여기서도 밤 12시에 사무실에 있다. 리뷰가 끝나지 않아서.

같은 야근인데, 밀도가 다르다. 넥스트비전에서의 밤은 혼자서 방향 없이 키보드를 두드리는 시간이었다. 여기서의 밤은 47건의 피드백을 반영하고, 하나하나 이해하고, 코드가 조금씩 깨끗해지는 시간이다. 같은 컵라면인데, 맛이 다르다. 넥스트비전의 컵라면은 1,500원이었다. 여기서는 0원이다. 그리고 넥스트비전의 컵라면은 혼자 먹었고, 여기서의 컵라면은 "축하합니다"라는 Slack 메시지와 함께 먹는다.

도현은 편의점을 나와 엘리베이터를 탔다. 1층 로비에서 사원증을 찍었다. 퇴근 시각: 00:03. 강남역까지 걸었다. 막차는 지났다. 택시를 잡았다. 안양까지 38,000원. 사이닝 보너스의 0.1%다.

택시 안에서 도현은 GitHub 앱을 열었다. 머지된 PR을 보았다. 세 개의 초록색 "Merged" 뱃지. 커밋 메시지가 깨끗했다. "feat(payment): 결제 확인 페이지 스켈레톤 컴포넌트 생성." "fixed stuff"가 아니라.

도현은 스크롤을 내려 47건의 코멘트를 훑었다. 스타일 12건, 로직 8건, 테스트 누락 7건, TDS 미사용 6건, 네이밍 5건, 성능 4건, 접근성 3건, 기타 2건. 이 47건은 넥스트비전에서 4년 동안 받지 못한 피드백이다. 4년간의 공백을 2주 만에 압축해서 받은 것이다.

안양에 도착했다. 원룸 문을 열었다. 천장의 금이 거기 있었다. 냉장고 모터가 돌아갔다. 이를 닦으며 거울을 보았다. 35세. 코드 리뷰 47건을 받은 사람의 얼굴이 보였다. 피곤했다. 하지만 넥스트비전에서 프린터를 고치고 나서 느꼈던 피곤과는 종류가 달랐다.

내일은 카드 선택 UI 리뉴얼을 시작한다. 이번에는 300줄. 이번에는 TDS 먼저. 이번에는 React Query부터. 이번에는 테스트를 먼저 쓴다.

컵라면 값: 0원.

47건의 코멘트 값: 측정 불가.

동작하면 끝이었다
여기서는 동작하는 것이 시작이다

넥스트비전에서 4년간 받은 코드 리뷰: 0건. 토스에서 2주간 받은 코드 리뷰: 47건.