forbidden code — fiction 02

Copilot이 낳은 아이

대학 3학년부터 AI와 함께 코딩을 배웠다. 졸업 작품도 AI가 짰다.
면접은 완벽했다. 첫 코드 리뷰까지는.

Part I

"면접은 완벽했다"

윤한결 24 · frontend developer · 0 years · seoul

2026년 2월 마지막 주 월요일. 서울 성수동.

윤한결은 출입증 목걸이를 목에 걸며 엘리베이터 버튼을 눌렀다. 출입증 사진 속 자신이 어색하게 웃고 있었다. 오늘이 입사 첫날이다.

한결은 운이 좋다는 걸 알고 있었다. 2025년 졸업. 그해 IT 신규 채용 공고는 564건. 전년의 684건에서 또 줄었다. 네이버를 제외한 대형 플랫폼사들은 신입 공채를 아예 중단했다. 신입 비중은 2022년 53.5%에서 2024년 37.4%로 떨어졌다. 동기 80명 중 개발자로 취직한 건 12명이다.

난 운이 좋았다. 요즘 신입은 뽑지도 않으니까.

면접은 완벽했다. 3개월간 준비했다. LeetCode 400문제를 AI 없이 풀었다. 라이브 코딩에서 이진 탐색 트리의 최소 공통 조상을 12분 만에 구현했다. 면접관이 고개를 끄덕였다. "잘하시네요."

하지만 그건 면접이었다. 면접을 위해 준비한 근육이었다. 면접이 끝나자마자 한결은 Copilot을 다시 켰다. 3개월간 꺼두었던 것을. 면접 준비와 실제 개발은 다른 근육이다. 아니, 한결에게 실제 개발이란 AI와 함께하는 것이었다.

대학 3학년. 2023년. GitHub Copilot이 학생 무료 라이선스를 제공하기 시작했다. 교수가 과제를 내면 한결은 Copilot에게 먼저 물었다. "이 과제 어떻게 접근하면 돼?" Copilot이 뼈대를 짜주면 한결이 살을 붙였다. 정확히는 Copilot이 살까지 붙여주면 한결이 변수명을 바꿨다.

졸업 작품. React와 Node.js로 만든 교내 중고거래 플랫폼. 심사위원이 칭찬했다. "코드 구조가 실무 수준이네요." 맞다. 실무 수준이다. AI가 짰으니까.

엘리베이터가 5층에서 멈췄다. 문이 열리자 통유리 너머로 책상 20개가 보였다. 스타트업 사무실. 한결이 배정받은 자리에는 맥북과 모니터가 놓여 있었다. 모니터 옆에 포스트잇이 붙어 있었다.

#dev-onboarding
이서연 09:00
한결 님 환영합니다. 온보딩 멘토 이서연입니다. 환경 세팅 먼저 해주세요. 사내 위키에 가이드 있어요. 완료되면 말씀해주세요.

환경 세팅. 한결은 가이드를 열었다. Node 20, pnpm, VS Code 확장 목록. 그리고 마지막 항목.

# 사내 AI 도구 세팅
$ npm install -g @anthropic-ai/claude-code
$ claude auth login

Authenticated as yoon.hangyeol@paybridge.io
License: Enterprise (unlimited)

# 팀 라이선스가 지급됩니다. 제한 없이 사용하세요.

Enterprise. 무제한. 한결은 안심했다. 대학 시절 Copilot 무료 라이선스가 만료돼서 과제를 못 낸 적이 있다. 여기서는 그런 일이 없을 것이다.

이제 진짜 개발하면 된다.

"진짜 개발"이 무엇인지, 한결은 아직 몰랐다.

* * *

Part II

Tab이 생각한다

입사 3일차. 수요일 오전.

서연이 한결에게 첫 번째 태스크를 할당했다. 사용자 프로필 페이지에 필터링 기능을 추가하는 것. 부서별, 상태별, 검색어로 사용자를 걸러내는 간단한 UI.

#dev-frontend
이서연 10:15
PAY-342 할당했습니다. 사용자 프로필 목록에 필터 추가. Figma 시안 링크 달아놨어요. 목요일까지 PR 올려주세요.

한결은 Jira 티켓을 열었다. Figma 시안을 확인했다. 검색 입력창 하나, 드롭다운 둘. 간단하다.

터미널을 열었다.

$ claude

> 사용자 프로필 목록 페이지에 필터링 기능을 추가해줘.
  검색어, 부서, 상태(active/inactive)로 필터링.
  기존 UserList 컴포넌트 아래에 추가.
  debounce 적용, TypeScript, 프로젝트 컨벤션 맞춰줘.

Claude가 코드를 생성하기 시작했다. 한결은 커피를 마시며 기다렸다. 4분 37초.

// UserProfileFilter.tsx — Claude 생성
import { useCallback, useMemo, useState } from 'react';
import { debounce } from 'lodash-es';

interface FilterState {
  query: string;
  department: string;
  status: 'active' | 'inactive' | 'all';
}

export function UserProfileFilter({ users, onFilter }: Props) {
  const [filters, setFilters] = useState<FilterState>({
    query: '', department: '', status: 'all'
  });

  const debouncedSearch = useCallback(
    debounce((nextFilters: FilterState) => {
      onFilter(applyFilters(users, nextFilters));
    }, 300),
    [users, onFilter]
  );

  const filteredUsers = useMemo(() =>
    applyFilters(users, filters),
    [users, filters]
  );

  // ... 이하 60줄

코드가 완성됐다. useCallback, useMemo, debounce. 한결은 코드를 훑어보았다. 잘 돌아가는 것 같다. 로컬에서 테스트했다. 필터가 동작했다. 검색어를 입력하면 0.3초 후에 목록이 갱신됐다. 드롭다운도 잘 동작했다.

한결은 PR을 올렸다. 수요일 오후 2시. 태스크 할당 후 4시간. 서연이 준 마감은 목요일이었다. 하루 일찍 끝냈다.

빠르게 끝내서 좋은 인상을 줘야지.

목요일 오전. 서연의 코드 리뷰가 올라왔다.

이서연 review — changes requested
코드는 동작합니다. 하지만 몇 가지 질문이 있어요.
const debouncedSearch = useCallback(
  debounce((nextFilters) => { ... }, 300),
  [users, onFilter]
);
Q1.useCallback은 왜 넣었나요? debounce 함수를 useCallback으로 감싸면 어떤 이점이 있나요?

한결은 리뷰를 읽었다. useCallback은 왜 넣었나? Claude가 넣었으니까. 한결은 답변을 달기 위해 잠시 생각했다.

useCallback은... 메모이제이션... 리렌더링 최적화... 맞지?

한결이 답변을 타이핑했다. "성능 최적화를 위해서입니다. 리렌더링 시 함수 재생성을 방지합니다."

서연의 답변이 2분 만에 올라왔다.

이서연 comment
useCallback 안의 debounce는 매 호출마다 새 debounce 인스턴스를 만들지 않나요? dependency array에 users가 있으면 users가 바뀔 때마다 debounce가 새로 생성되고, 이전 타이머가 취소되지 않습니다. 이 조합이 의도한 대로 동작하는지 확인해봐주세요.

한결은 리뷰를 세 번 읽었다. useCallback의 dependency array가 바뀌면 함수가 재생성된다. 그러면 debounce도 새로 만들어진다. 이전 debounce의 타이머는? 정리가 안 된다. 메모리 누수.

이건... 버그인가?

두 번째 질문이 올라왔다.

이서연 comment
debounce((nextFilters) => { ... }, 300)
Q2. debounce를 300ms로 설정한 이유가 있나요?

300ms. 왜 300인가. 200이 아니고 500이 아니라 300인 이유. 한결은 대답할 수 없었다. Claude가 300을 넣었다. 한결은 300을 의심하지 않았다.

세 번째 질문.

이서연 comment
Q3. 이 컴포넌트가 리렌더링되는 조건을 설명해주세요. useMemouseCallback이 동시에 있을 때, 상태 변경이 어떤 순서로 전파되나요?

한결은 키보드 위에서 손을 내렸다. 리렌더링 조건. useState가 바뀌면 리렌더링된다. useMemo는 의존성이 바뀌면 재계산된다. useCallback은 의존성이 바뀌면 함수가 재생성된다. 그런데 이 세 개가 동시에 있을 때 어떤 순서로 동작하는가?

한결은 모른다. 써본 적은 있다. 하지만 동작 원리를 설명하라면 말이 안 나온다. Tab 키를 누르면 코드가 나왔고, 코드가 동작하면 넘어갔다. Tab이 생각하고, 한결은 승인했다.

한결은 30분 동안 답변을 쓰지 못했다. 그리고 서연이 다가왔다.

"한결 씨, 잠깐 이야기할까요?"

* * *

Part III

Anthropic이 맞았다

회의실. 서연과 한결 둘이 앉았다. 서연이 노트북을 열었다.

"한결 씨. 코드 잘 돌아가요. 동작에는 문제없어요."

"감사합니다."

"질문에 대답을 못한 건 괜찮아요. 신입이니까. 하나만 물어볼게요." 서연이 한결을 바라보았다. "이 코드, 얼마나 직접 짰어요?"

3초의 침묵. 한결은 거짓말을 할 수 있었다. "직접 짰습니다." 하지만 서연의 눈이 이미 답을 알고 있었다. PR의 커밋 히스토리를 보면 안다. 첫 커밋이 4시간 만에 올라왔고, 파일 하나가 통째로 추가됐다. 사람이 4시간 만에 완성된 컴포넌트를 만들지 않는다. AI가 만든다.

"... Claude가 대부분 짰습니다."

서연이 고개를 끄덕였다. 놀라지 않았다.

"코드 품질은 괜찮아요. 구조도 나쁘지 않아요. 하지만 한결 씨가 이 코드를 설명할 수 없다면, 이 코드는 한결 씨 것이 아니에요."

"... 네."

"면접에서는 잘했잖아요. LeetCode도 혼자 풀었고."

"그건 면접이니까요. 3개월 동안 매일 연습했어요. AI 없이."

"근데 실무에서는?"

한결은 대답하지 못했다. 서연이 노트북에서 무언가를 열었다.

anthropic research — how ai assistance impacts coding skills
Anthropic의 연구에 따르면, AI 보조를 받으며 학습한 그룹은 AI 없이 학습한 그룹에 비해 사후 퀴즈 점수가 17% 낮았다. 가장 큰 차이가 나타난 영역은 디버깅 능력이었다. AI가 즉시 답을 제공하면, 학습자는 오류를 스스로 추적하는 과정을 건너뛰게 된다.

"이 연구 알아요?"

"아뇨."

"AI 보조를 받으면서 코딩을 배운 사람은, 그렇지 않은 사람보다 실력이 17% 낮다는 연구예요. 특히 디버깅. AI가 항상 답을 줬으니까, 답을 스스로 찾는 근육이 안 만들어진 거죠."

한결의 손이 무릎 위에서 움켜쥐어졌다.

"저도 알아요. 사실 대학 과제도 대부분 AI가 짰어요. 동작하면 됐으니까. 교수님도 결과물만 보셨고. 졸업 작품도..."

"그럼 한결 씨는 뭘 배운 거예요?"

질문이 가슴을 찔렀다. 한결은 4년간 컴퓨터공학을 전공했다. 자료구조, 알고리즘, 운영체제, 네트워크. 수업을 들었다. 과제를 냈다. 시험을 봤다. 하지만 과제의 절반은 AI가 짰고, 시험은 이론이었다. 시험에서 A+을 받았지만 Array.reduce가 내부적으로 어떻게 동작하는지 설명하라면 말이 안 나온다.

학습된 무기력. Stack Overflow에서 봤던 단어다. AI가 항상 답을 줬기 때문에, 스스로 답을 찾는 근육이 발달하지 않았다. Tab을 누르면 코드가 나왔다. Tab이 생각을 대신했다. 나는 Tab을 누르는 법을 배웠을 뿐이다.

서연은 분노하지 않았다. 질책하지 않았다. 대신 한 가지를 제안했다.

"한결 씨. 이 PR의 코드를 전부 지우고, AI 없이 다시 짜봐요. 월요일까지."

"... 전부요?"

"전부. 같은 기능. 같은 스펙. AI 없이."

한결은 서연의 눈을 보았다. 거기에 경멸은 없었다. 연민도 아니었다. 교사의 눈이었다. 과제를 내는 교사.

"못 할 수도 있어요."

"괜찮아요. 못 하면 못 한다고 말해요. 그게 시작이니까."

한결은 고개를 끄덕였다.

Tab을 누르면 코드가 나왔다
Tab이 생각을 대신했다

* * *

Part IV

처음부터 다시

금요일 저녁. 한결은 퇴근 후 집 근처 카페에 앉았다.

노트북을 열었다. VS Code. Claude Code 연결을 끊었다. Copilot 확장도 비활성화했다. 자동완성은 IntelliSense만 남겼다. IDE가 갑자기 텅 빈 느낌이었다. 자동으로 코드가 흘러나오지 않는 에디터. 커서만 깜박이는 빈 파일.

한결은 UserProfileFilter.tsx를 새로 만들었다. 빈 파일. 첫 줄부터 직접 쳐야 한다.

// UserProfileFilter.tsx — 빈 파일
|

# 커서가 깜박인다. Tab을 눌러도 아무 일도 일어나지 않는다.

import. React에서 뭘 가져와야 하지? useState는 안다. useCallback은? 서연이 질문했던 그것. 한결은 MDN을 열었다. 아니, React 공식 문서를 열었다.

react.dev — usecallback
useCallback is a React Hook that lets you cache a function definition between re-renders. On the initial render, useCallback returns the function you have passed. On subsequent renders, it will return an already stored function from the last render (if the dependencies haven't changed), or return the function you have passed during this render.

"re-renders 사이에 함수 정의를 캐싱한다." 한결은 문서를 읽었다. 처음으로 제대로 읽었다. useCallback은 함수의 참조를 유지하는 것이다. 리렌더링 시 같은 함수 객체를 반환하는 것이다. 그래서 자식 컴포넌트에 props로 넘길 때 불필요한 리렌더링을 막는 것이다.

그런데 이 필터 컴포넌트에서 useCallback이 필요한가? 자식에게 함수를 넘기지 않는다. 자기 안에서 쓸 뿐이다. 그러면 useCallback은 필요 없다.

Claude가 넣어준 useCallback은... 불필요했다?

한결은 useCallback 없이 코드를 짜기 시작했다. debounce도 직접 구현하기로 했다. lodash에서 가져오는 대신.

// 한결이 직접 작성한 debounce
function useDebounce<T>(value: T, delay: number): T {
  const [debounced, setDebounced] = useState(value);

  useEffect(() => {
    const timer = setTimeout(() => {
      setDebounced(value);
    }, delay);
    return () => clearTimeout(timer);
  }, [value, delay]);

  return debounced;
}

12줄. useStateuseEffect로 debounce를 구현했다. 값이 바뀌면 타이머를 설정하고, 타이머 안에서 debounced 값을 갱신한다. 컴포넌트가 언마운트되거나 값이 다시 바뀌면 clearTimeout으로 이전 타이머를 정리한다.

이 코드를 쓰는 데 40분이 걸렸다. React 공식 문서에서 useEffect의 cleanup 함수가 어떻게 동작하는지 읽었다. setTimeout의 반환값이 타이머 ID라는 것을 확인했다. 40분. Claude였으면 3초. 하지만 한결은 이제 이 12줄의 모든 것을 안다.

필터 컴포넌트를 계속 짰다.

// 한결이 직접 작성한 필터 컴포넌트
export function UserProfileFilter({ users, onFilter }: Props) {
  const [query, setQuery] = useState('');
  const [dept, setDept] = useState('');
  const [status, setStatus] = useState<Status>('all');

  const debouncedQuery = useDebounce(query, 300);

  useEffect(() => {
    const filtered = users.filter(u => {
      if (debouncedQuery && !u.name.includes(debouncedQuery)) {
        return false;
      }
      if (dept && u.department !== dept) {
        return false;
      }
      if (status !== 'all' && u.status !== status) {
        return false;
      }
      return true;
    });
    onFilter(filtered);
  }, [debouncedQuery, dept, status, users, onFilter]);

  // ... JSX 렌더링
}

Claude가 짜준 코드보다 길다. useMemo도 없다. useCallback도 없다. 대신 useEffect 안에서 필터링하고, debounced 값이 바뀔 때만 실행된다. 기술적으로 Claude의 코드가 더 "효율적"일 수 있다. 하지만 한결의 코드는 동작 원리를 한 줄씩 설명할 수 있다.

useEffect인가 — debounced 값이 바뀔 때만 필터링을 실행하기 위해서. 왜 useState를 세 개로 분리했는가 — 하나의 객체로 합치면 어떤 필터가 바뀌었는지 추적하기 어려워서. 왜 300ms인가 — 사실은 모른다. 하지만 적어도 이제 "모른다"는 것을 안다.

밤 11시. 4시간이 지났다. 한결은 동일한 기능을 완성했다. 로컬에서 테스트했다. 동작한다. 코드는 Claude 버전보다 20줄 더 길고, lodash 의존성이 없고, useCallbackuseMemo가 없다.

한결은 git diff를 보았다.

$ git diff --stat
 src/components/UserProfileFilter.tsx | 68 --- 82 +++
 src/hooks/useDebounce.ts           | 14 +++
 2 files changed, 96 insertions(+), 68 deletions(-)

월요일 아침. 한결은 새 PR을 올렸다.

#dev-frontend
윤한결 09:22
PR#251 올렸습니다. AI 없이 다시 작성했습니다. 각 함수에 주석으로 선택 이유를 달았습니다.

서연이 리뷰를 열었다. 30분 만에 코멘트가 달렸다.

이서연 review — approved
useDebounce 훅 분리가 좋네요. 한 가지 — useEffect dependency array에 onFilter가 있는데, 부모 컴포넌트가 리렌더링될 때마다 onFilter 참조가 바뀌면 필터링이 불필요하게 재실행될 수 있어요. 부모에서 useCallback으로 안정화하거나, 여기서 ref로 최신값을 유지하는 패턴을 쓸 수 있어요. 다음 PR에서 적용해봐요. Approved.

Approved. 한결은 그 단어를 보며 숨을 내쉬었다. 그리고 서연의 코멘트를 다시 읽었다. onFilter 참조 안정화. 이번에는 이해했다. useEffect가 dependency array의 참조가 바뀔 때마다 실행된다는 것을 알기 때문이다. 일주일 전에는 이 코멘트를 이해하지 못했을 것이다.

점심시간. 한결은 5층 복도에서 진우를 만났다. 1편의 그 백엔드 개발자. 한결은 진우를 며칠째 관찰하고 있었다. 진우는 매주 월요일에 AI를 안 쓴다고 했다.

"진우 선배."

"어, 한결 씨."

"저도 No AI Day 할래요. 월요일에 같이 해도 되나요?"

진우가 한결을 보았다. 신입의 얼굴에 각오가 서 있었다.

"좋지. 월요일에 같이 하자."

한결은 자리로 돌아와서 PR에 마지막 작업을 했다. Claude가 짜준 첫 번째 PR의 코드를 열었다. 그리고 한 줄 한 줄, 코멘트를 달기 시작했다. "왜 이렇게 짰는지" 설명을. AI가 짠 코드에 사람의 이해를 붙이는 작업.

// 이전 PR — AI 코드에 한결이 추가한 주석

// useCallback: 자식 컴포넌트에 넘기지 않으므로 불필요.
// 참고: useCallback은 함수 참조 안정화가 목적.
// props로 전달하지 않는 함수에는 성능 이점 없음.
// → PR#251에서 제거함.

// useMemo: 필터 결과를 캐싱하지만,
// useEffect + useDebounce 패턴이 더 직관적.
// useMemo는 렌더링 중 실행되고,
// useEffect는 렌더링 후 실행됨.
// 필터링 결과가 UI에 즉시 반영될 필요가 없으므로
// useEffect가 적합.
// → PR#251에서 useEffect로 변경함.

Addy Osmani가 말했다. AI가 70%의 뼈대를 만들지만, 나머지 30% — 엣지 케이스, 성능, 보안 — 는 사람이 해야 한다고. 한결은 아직 30%를 채울 실력이 안 된다. 하지만 적어도 30%가 비어 있다는 것을 알게 됐다.

Andrew Ng이 말했다. AI 시대에 오히려 코딩을 배워야 한다고. 코드가 쉬워질수록 더 많은 사람이 코딩해야 한다고. 한결은 코딩을 "배운" 적이 없었다. AI가 코딩을 "해줬을" 뿐이었다. 이제 처음부터 배우기 시작한다.

카페에서의 4시간. 한결이 직접 짠 96줄. 그 96줄이 한결의 첫 번째 코드다. 대학 4년간 제출한 수천 줄의 코드 중, 진짜 자기 것은 하나도 없었다. 이 96줄이 처음이다.

한결은 24살이다. AI와 함께 태어난 세대. Copilot이 키워준 아이. 하지만 아이는 자라야 한다. Tab 키를 누르는 법을 잊고, 코드를 쓰는 법을 배워야 한다. 처음부터.

Tab을 누르면 코드가 나왔다
이해는 나오지 않았다

AI 보조 학습자의 퀴즈 점수는 17% 낮다. 가장 큰 차이는 디버깅 능력이다. AI가 답을 주면, 답을 찾는 근육은 만들어지지 않는다.