AI를 활용하면서 개발자의 책임
TOC
AI에게 기능을 맡기면 빠르다. 이건 이제 논쟁거리가 아니다. 요구사항을 설명하면 동작하는 코드가 나오고, 테스트까지 붙여 준다. 리뷰에서 지적할 게 없을 때도 많다.
그런데 지난 몇 달 동안 내가 가장 많이 고친 코드는 동작하지 않는 코드가 아니라, 잘 동작하는데 틀린 코드였다. 테스트는 통과하고, 화면도 뜨고, 리뷰에서도 안 걸린다. 그런데 틀렸다.
작업은 Claude Code(Opus 5)에 superpowers 플러그인을 붙여서 했다. 바로 코드를 쓰지 않고 설계를 먼저 묻고, 계획을 문서로 남기고, 구현과 리뷰를 다른 주체로 분리하고, 완료를 선언하기 전에 검증을 강제하는 작업 흐름이다. 아래 사례들은 그 흐름 안에서 나온 것들이라, 그렇게까지 해도 남는 것이 무엇인지를 보여 준다고 생각한다.
이 글은 그런 사례 셋을 정리한 것이다. "AI를 쓰지 말자"는 이야기가 아니라, 생산성을 AI가 가져간 다음에 개발자에게 남는 몫이 무엇인가에 대한 메모다.
1. 1초마다 화면 전체가 다시 그려지고 있었다
헤더에 세션 만료까지 남은 시간을 보여 주는 카운트다운이 있다. 05:42, 05:41… 이런 칩이다.
요구사항은 단순하다. 남은 시간을 매초 보여 주고, 1분 남으면 연장 팝업을 띄운다. AI에게 맡기면 금방 나온다. 실제로 그렇게 나왔고, 잘 동작했다.
구조는 이랬다.
// 셸 레이아웃
export const OutletLayout = () => {
const { remainingSeconds, warningOpen, extendSession } = useSessionTimer(...);
return (
<div>
<Sidebar />
<Topbar sessionTimer={<SessionTimer remainingSeconds={remainingSeconds} />} />
<main>
<Outlet /> {/* ← 지금 보고 있는 화면 전체 */}
</main>
<SessionExpiryDialog open={warningOpen} ... />
</div>
);
};
보이는가? useSessionTimer 가 레이아웃에 있다. 그 훅은 1초마다 remainingSeconds 를 갱신한다. 상태가 바뀌면 그 컴포넌트가 다시 그려지고, 다시 그려지면 그 아래 전부가 다시 그려진다.
즉 사이드바도, 헤더도, <Outlet /> 아래 지금 보고 있는 화면 전체도 매초 다시 그려지고 있었다.
왜 아무도 못 봤나
이게 핵심이다. 이 코드는 버그가 아니다.
- 테스트는 통과한다. 남은 시간이 줄어드는 것도, 팝업이 뜨는 것도 전부 맞다
- 화면도 멀쩡하다. React는 빠르고, DOM은 바뀐 것만 갱신한다
- 코드 리뷰에서도 안 걸린다. 훅 하나를 레이아웃에서 부른 것뿐이다
문제는 행이 많은 목록 화면에서 드러났다. 표가 커지면 매초 다시 그리는 비용이 체감된다. 스크롤이 미끄럽지 않고, 입력이 한 박자 늦는다. 그런데 그 증상을 보고 "세션 타이머 때문"이라고 떠올리기는 쉽지 않다. 둘은 코드상 아무 관계가 없어 보인다.
확인은 React DevTools의 Highlight updates 로 했다. 켜 놓고 가만히 두면, 매초 사이드바와 표의 모든 행과 페이징까지 테두리가 번쩍였다.
고친 방법
값을 쓰는 곳이 어디인지부터 셌다. 둘뿐이었다 — 헤더의 시간 칩, 만료 경고 팝업.
그래서 그 둘을 한 조각 안으로 모으고, 훅을 그 조각이 부르게 했다.
export function SessionCountdown() {
const { remainingSeconds, warningOpen, extendSession } = useSessionTimer(...);
return (
<>
<SessionTimer remainingSeconds={remainingSeconds} ... />
<SessionExpiryDialog open={warningOpen} ... />
</>
);
}
레이아웃은 <SessionCountdown /> 만 꽂는다. 매초 다시 그려지는 범위가 이 조각 안으로 갇혔다.
여기서 두 가지를 더 정해야 했다.
팝업이 헤더 슬롯 안에 있어도 되나? 구조상 이상해 보인다. 만료 팝업이 헤더 칩 옆에 있다니. 그런데 Dialog 는 포털로 body에 붙는다 — 트리에서 어디에 있든 화면에서는 같은 자리에 뜬다. 구조가 어색해 보이는 것과 결과가 달라지는 것은 다른 문제다.
필요한 값을 프롭으로 받을까? 받으면 안 된다. 레이아웃이 그 값을 들고 있어야 하고, 그러면 다시 레이아웃의 관심사가 되어 원점으로 돌아간다. 조각이 스토어에서 직접 읽게 했다.
테스트를 이렇게 썼다
이 부분이 개인적으로 가장 중요하다고 생각한다.
let siblingRenders = 0;
/** 형제 조각 — 화면 자리에 해당한다. 그려질 때마다 센다. */
function Sibling() {
siblingRenders += 1;
return <p>화면 내용</p>;
}
it("★ 초가 흘러도 형제를 다시 그리지 않는다", () => {
render(
<>
<SessionCountdown />
<Sibling />
</>,
);
expect(siblingRenders).toBe(1);
act(() => {
vi.advanceTimersByTime(3_000);
});
expect(siblingRenders).toBe(1); // 3초가 흘러도 그대로다
});
왜 이렇게까지 하냐면, "옆이 안 바뀐다"는 눈으로 확인할 수 없기 때문이다.
숫자가 줄어드는 건 보인다. 팝업이 뜨는 것도 보인다. 그런데 "형제가 다시 그려지지 않는다"는 화면만 봐서는 영원히 알 수 없다. 그리고 눈으로 확인할 수 없는 성질은 반드시 되돌아온다 — 다음 사람이 "훅을 위로 올리면 깔끔하겠는데" 하는 순간 조용히 원상복구된다.
실제로 훅을 다시 레이아웃 자리로 올려 보고 이 테스트가 깨지는 것을 확인했다. 깨지지 않는 테스트는 테스트가 아니다.
여기서 남는 것
AI가 만든 코드는 요구사항을 정확히 만족했다. 문제는 요구사항에 "이 상태가 어디에 살아야 하는가"가 들어 있지 않았다는 것이다.
그건 명세의 언어가 아니라 구조의 언어다. 그리고 구조는 "무엇을 만들까"가 아니라 "이게 어디에 붙으면 무엇까지 끌려 들어가나"를 묻는 일이다.
개발자의 몫 ① — 상태가 어느 높이에 사는지 본다. 기능이 맞는지가 아니라, 이 상태가 바뀔 때 무엇까지 함께 바뀌는지를 묻는다.
2. 코딩 컨벤션 — 도구가 안 잡아 주는 자리
컨벤션 이야기를 하면 보통 "들여쓰기 2칸이냐 4칸이냐" 같은 걸 떠올린다. 그건 포매터가 한다. AI도 잘 지킨다. 문제는 지키지 않아도 아무 일도 일어나지 않는 규칙들이다.
공백 하나가 사라져서 클래스가 통째로 죽은 일
표의 한 칸에 조건부로 배경색을 깔아야 했다. 이렇게 썼다.
<div className={`flex flex-wrap items-center gap-1${
isInvalid ? '' : ' bg-danger-bg rounded px-2 py-1'
}`}>
동작했다. 그리고 Prettier가 줄바꿈을 정리하면서 앞 공백을 먹었다.
isInvalid ? "" : "bg-danger-bg rounded px-2 py-1";
// ^ 공백이 사라졌다
결과는 gap-1bg-danger-bg. 그런 이름의 클래스는 없다. 배경색이 안 나온다.
아무것도 터지지 않는다. 타입 체크 통과, 린트 통과, 테스트 통과. 문구는 별도 <span>이라 멀쩡히 떠서 화면을 봐도 "배경만 좀 연한가?" 싶다. 나는 이걸 처음엔 디자인 토큰 값이 너무 연해서라고 오해했고, 토큰 값까지 찾아보고 나서야 클래스 자체가 없다는 걸 알았다.
고치는 건 한 줄이다.
<div className={cn(
'flex flex-wrap items-center gap-1',
isInvalid && 'bg-danger-bg rounded px-2 py-1',
)}>
cn(clsx/tailwind-merge 류)은 조각을 공백으로 잇는다. 같은 사고가 구조적으로 안 난다.
교훈은 "cn을 쓰자"가 아니다. 문자열 이어붙이기로 클래스를 만들면 조용히 실패한다는 것이고, 조용히 실패하는 코드는 규칙으로 금지해야 한다는 것이다. 사람이 매번 조심해서 될 일이 아니다.
다크모드를 "고려"하지 않는 방법
같은 칸에 배경색을 넣으면서 이런 질문을 받았다. "배경이 빨간색이면 글씨는 흰색이야 검정이야? 다크모드도 생각해야 하는 거 아냐?"
맞는 질문인데, 답은 "고르지 않는다"였다.
디자인 시스템이 색을 쌍으로 발행하고 있었다.
:root {
--danger-fg: hsl(0 70% 54%);
--danger-bg: hsl(0 85% 95%);
}
.dark {
--danger-fg: hsl(0 82% 76%);
--danger-bg: hsl(0 45% 26%);
}
bg-danger-bg text-danger 라고 쓰기만 하면 라이트에서는 연한 분홍 바탕 + 빨간 글씨, 다크에서는 어두운 빨강 바탕 + 밝은 빨간 글씨가 된다. 다크모드 대응이 토큰 안에서 이미 끝나 있다.
반대로 "진한 빨강으로 해 주세요"는 간단해 보이지만 할 수 없었다. 그 위에 올릴 글씨색 토큰이 없기 때문이다. 없는 색을 지어내는 순간 다크모드를 내가 책임져야 하고, 디자인 시스템과 따로 놀기 시작한다.
개발자의 몫 ② — 색·간격을 값으로 고르지 않는다. 토큰에 없다면 그건 "내가 정할 일"이 아니라 디자인 시스템에 물어볼 일이다.
규칙이 코드 바깥에 있으면 반드시 어긋난다
백엔드 쪽 사례 하나. 인증 프레임워크가 API 경로의 첫 세그먼트를 권한 키로 쓴다.
/api/v1/{권한키}/.../{메서드명}
^^^^^^ ^^^^^^^^
메뉴 권한 판정 read* → 읽기 / create*·update* → 쓰기
그래서 화면 라우트와 API 경로의 첫 세그먼트가 같아야 한다. 다르면 403이다.
어떤 화면이 이 규칙을 어기고 있었다. 라우트 쪽 키와 API 경로의 첫 세그먼트가 서로 달랐다. 이건 의도된 거였고 코드에 주석도 달려 있었는데, 결과적으로 "그 화면을 쓰려면 다른 메뉴의 권한이 필요하다"는 상태가 됐다.
증상은 "어제는 되던 목록이 오늘 403"이었다. 원인은 코드 변경이 아니라 로그인한 계정이 달랐던 것이다. 권한이 적은 계정으로 보면 메뉴는 보이는데 조회가 막힌다.
이런 종류의 규칙은 타입 시스템도 린터도 못 잡는다. 런타임에, 그것도 특정 권한의 계정으로 들어갔을 때만 드러난다.
개발자의 몫 ③ — 시스템 경계의 규칙을 안다. AI는 내가 준 파일들 안에서 일관성을 맞춘다. 그 바깥에 있는 약속은 내가 알려 줘야 하고, 어겼을 때 어디서 터지는지도 내가 안다.
3. UI 개발에서 내가 확인하게 된 것들
프론트엔드는 "화면이 뜨면 맞는 것 같다"는 착각이 제일 잘 생기는 영역이다. 실제로 틀렸던 것들만 모았다.
리스트 키를 의심한다
검색 조건을 바꿨는데 표에 낡은 행이 남았다. 결과는 1건인데 2줄이 보이고, 둘 다 "순서 1"이었다.
서버를 한참 봤다. 응답은 정확히 1건이고 페이징도 1-1 / 1 이었다. 조회는 멀쩡했다.
범인은 React key였다. 목록의 각 행을 "단계 ID"로 식별하고 있었는데, 그 값은 행마다 발급되는 값이 아니라 설정에 정의된 단계의 ID였다. 같은 설정을 쓰는 서로 다른 건이 같은 키를 갖는다.
건 A doc=DP1095… step=AT5D7F… ← 서로 다른 건인데
건 B doc=DPD4FA… step=AT5D7F… ← 키가 같다
React는 둘을 같은 것으로 보고 낡은 행을 재사용했다.
키는 "그 행 자체의 id"여야 한다. 부모의 id도, 분류의 id도 아니다. 그리고 이건 응답을 눈으로 봐서는 모른다 — 값이 중복되는 실제 데이터를 만나야 드러난다.
"안 바뀌는 것"을 테스트한다
1번 사례의 연장이다. UI 테스트는 보통 "이게 보인다"를 잰다. 그런데 틀리는 건 보통 그 반대쪽이다.
- 옆 컴포넌트가 다시 그려지지 않는다
- 고치고 나면 에러 문구가 사라진다
- 멀쩡한 행에는 경고가 번지지 않는다
앞의 gap-1bg-danger-bg 건을 겪고 나서, 나는 배경색 자체를 재는 테스트를 붙였다.
it("★ 문제 문구가 있으면 칸에 danger 바탕이 깔린다", () => {
const cell = renderWithAlert("…");
expect(cell).toHaveClass("bg-danger-bg");
});
문구를 재는 테스트는 이미 있었고 통과하고 있었다. 문구와 배경은 같이 움직여야 하는데 따로 깨진 게 그 사고였다.
레이아웃 속성이 "먹는지" 확인한다
표의 토글 칸에 가운데 정렬을 걸었는데 안 먹었다. 컬럼 설정에 align: 'center' 가 분명히 있었다.
이유는 간단했다. align: 'center' 는 결국 text-align: center 인데, 그건 인라인 내용에만 듣는다. 쓰고 있던 스위치 컴포넌트의 루트가 display: flex — 블록 레벨이었다. 고정 너비 블록은 text-align 으로 안 움직인다.
<Switch className="mx-auto" /> // 좌우 여백을 같게 나눠 실제로 가운데로 보낸다
비슷한 걸 하나 더 겪었다. 드롭다운 패널이 표 오른쪽 끝까지 늘어났다. position: fixed 인데 너비 제한이 min-width 뿐이라, 최대가 없어서 남는 자리만큼 커진 것이다.
max-h-60 min-w-40 ← 전
max-h-60 w-max max-w-72 min-w-40 ← 후: 내용 너비에 맞추고 위아래로 가둠
둘 다 공통점이 있다. 속성은 걸려 있는데 안 듣는다. 개발자 도구에서 클래스는 보이니 "적용됐다"고 읽기 쉽다. 적용된 것과 듣는 것은 다르다.
느린 자리는 추측하지 말고 잰다
화면이 느리다고 느꼈을 때, 나는 "렌더링이 많아서겠지" 하고 최적화할 자리를 찾는 습관이 있었다. 한 번은 그러지 않고 게이트웨이 액세스 로그에서 응답 시간을 뽑아 봤다.
readServers ms=5087
readServers ms=5111
readServers ms=5094 ← 6건이 5,087~5,111ms
24ms 안에 몰려 있다. 이건 고정 타임아웃이다. 렌더링 문제가 아니라 그 API가 서버 상태를 확인하느라 걸리는 시간이었고, 과거엔 243ms 였다.
프론트에서 아무리 메모이제이션을 해도 5초는 5초다. 숫자를 보기 전에 처방을 정하면 엉뚱한 데를 고친다.
내 코드가 아닌 것을 가른다
이건 최근에 한 번 크게 데였다. 화면에 설명 안 되는 빈 공간이 생기고, 개발자 도구 DOM에 <canvas popover="manual"> 이 하나 보였다. <html> 바로 아래, <head> 앞에.
우리 코드엔 없었다. index.html 에도 없고, 런타임 의존성 어디에도 없었다. 그리고 그 위치가 결정적이었다 — HTML 파서는 그 자리에 온 요소를 <body>로 옮긴다. 즉 마크업으로는 나올 수 없는 위치고, 스크립트로 직접 꽂아야만 가능하다.
브라우저 확장이었다. 나중에 dev 서버 로그에 스택 트레이스가 찍혀서 확정됐다.
InvalidStateError: Failed to execute 'showPopover' ... Invalid on disconnected popover elements.
> drawWeb chrome-extension://fmkadmap…/react_devtools_backend_compact.js
내가 리렌더링을 확인하려고 켜 둔 React DevTools의 하이라이트 기능이었다. 그게 초당 한 번씩 오류를 던졌고, 결국 dev 서버까지 내려갔다.
개발자의 몫 ④ — 재현 환경을 한 겹씩 벗긴다. 시크릿 창에서 재현되나? 확장을 끄면? 빌드본에서는? 이걸 안 하면 내 코드에 없는 버그를 몇 시간 쫓는다.
정리하면
AI가 만든 코드에서 내가 고친 것들을 다시 보면, 공통점이 있다.
전부 "맞는데 틀린" 것들이었다.
| AI가 잘하는 것 | 내가 해야 하는 것 | |
|---|---|---|
| 기능 | 요구사항대로 동작시킨다 | 이 상태가 어디에 살아야 하는지 정한다 |
| 컨벤션 | 준 규칙을 일관되게 지킨다 | 조용히 실패하는 패턴을 규칙으로 금지한다 |
| 스타일 | 클래스를 정확히 조합한다 | 토큰에 없는 값을 지어내지 않게 막는다 |
| 경계 | 준 파일 안에서 일관성을 맞춘다 | 시스템 밖의 약속을 알려 준다 |
| 검증 | 보이는 것을 테스트한다 | 안 보이는 성질을 테스트로 못박는다 |
오른쪽 열에는 공통점이 하나 더 있다. 요구사항에 적히지 않는다는 것.
"세션 타이머를 헤더에 넣어 주세요"에는 그 상태가 어느 높이에 살아야 하는지가 없다. "이 칸에 빨간 배경"에는 다크모드가 없다. "목록을 보여 주세요"에는 키가 겹칠 수 있다는 사실이 없다.
생산성은 AI가 가져가도 된다고 생각한다. 다만 적히지 않은 것을 아는 일은 당분간 우리 몫이고, 그게 지금 내가 "개발자의 책임"이라고 부르고 있는 것이다.
