시니어 개발자를 위한 AI 코딩: 무엇을 위임하고 무엇을 하지 않을 것인가
요약
시니어 개발자가 AI 에이전트를 활용하여 생산성을 높이는 실무적인 방법론을 다룹니다. 기존 패턴을 따르는 보일러플레이트 작성, 명세 기반의 테스트 생성, 타입 정의 등 AI에게 효과적으로 위임할 수 있는 작업 범위를 제시합니다.
핵심 포인트
- 기존 코드 패턴을 재현하는 보일러플레이트 작업 위임
- 구현이 아닌 명세(Specification)를 기반으로 한 테스트 생성
- 타입 정의 및 인터페이스 작성의 자동화
- 정규 표현식 등 복잡한 문법 작업의 효율화
- AI 결과물을 검토하고 제어하는 시니어의 판단력 강조
화요일 아침입니다. 백로그에 작업 하나가 있습니다: 기존 웹훅 (webhook) 파이프라인에 새로운 결제 이벤트 유형을 추가하는 것입니다. 스키마 (schema)는 이미 존재하고, 핸들러 (handler) 패턴도 존재하며, 이미 세 개의 유사한 이벤트 유형이 연결되어 있습니다. 저는 에이전트 (agent)를 열고, 관련 파일들을 지정한 뒤, 제가 필요한 내용을 설명합니다. 12분 후 구현이 완료되었고, 테스트를 통과했으며, 저는 커밋하기 전에 디프 (diff)를 읽고 있습니다.
그게 전부입니다. 극적인 일도, 놀라운 발견도 없습니다. 이것이 현재 생산적인 아침의 모습입니다.
하지만 그 12분은 제가 AI 도구를 정기적으로 사용하기 전에는 갖지 못했던 특정한 종류의 주의력을 필요로 합니다. 즉, 어디에서 속도를 늦춰야 하고 어디에서 에이전트가 실행되도록 내버려 두어야 하는지에 대한 훈련된 감각입니다. 제가 여기서 쓰고 싶은 내용은 바로 그 감각입니다. 그 철학에 대한 것이 아니라 (왜 AI가 평등화하기보다 증폭시키는지에 대해서는 이전 기사에서 다루었습니다), 실제 업무일의 운영적 형태에 대해 쓰고 싶습니다.
망설임 없이 위임하는 부분
"완전히 신뢰하라"는 것은 잘못된 프레임입니다. 왜냐하면 저는 여전히 모델이 생성한 결과물을 읽기 때문입니다. 더 나은 프레임은 다음과 같습니다: 정답이 충분히 잘 정의되어 있어서 깊은 정밀 조사 없이도 결과물을 빠르게 평가할 수 있는 작업들이 있다는 것입니다. 저는 이러한 작업들을 자유롭게 위임합니다.
기존 패턴을 따르는 보일러플레이트 (Boilerplate). 만약 새로운 CRUD 엔드포인트 (endpoint)가 필요하고 코드베이스에 이미 10개의 유사한 엔드포인트가 존재한다면, 저는 에이전트에게 그중 두 개를 가리키며 동일한 스타일로 새 것을 요청합니다. 결과물은 대개 첫 번째 시도에서 정확합니다. 저는 그것을 읽고, 동일한 에러 처리 (error-handling) 컨벤션 (convention)을 따르는지 확인한 뒤 다음 단계로 넘어갑니다. 여기서의 가치는 모델이 저보다 똑똑하다는 것이 아닙니다. 모델이 기존 패턴을 컨텍스트 (context)에 유지하고, 제가 의식적으로 노력하지 않아도 그것을 정확하게 재현할 수 있다는 점에 있습니다.
이미 글로 명시한 동작에 대한 테스트. 만약 제가 함수가 무엇을 해야 하는지 — 어떤 입력을 받는지, 어떤 엣지 케이스 (edge cases)를 처리하는지, 무엇을 반환하거나 던져야 하는지 — 기술한 설명이 있다면, 모델은 그 설명과 잘 일치하는 테스트를 작성합니다. 이는 모델이 방금 작성한 코드에 대해 테스트를 작성해 달라고 요청하는 것과는 다르며, 후자는 결과물이 다르고 품질이 훨씬 낮습니다. 저는 첫 번째 글에서 그 패턴에 대해 자세히 설명했습니다. 이 차이는 중요합니다. 제 명세 (specification)로부터 생성된 테스트는 동작 (behavior)을 테스트하지만, 모델이 작성한 코드로부터 생성된 테스트는 구현 (implementation)을 테스트하기 때문입니다.
타입 정의 (Type definitions) 및 인터페이스 (interfaces). 데이터 구조를 평이한 언어로 설명하고 TypeScript 인터페이스를 요청하는 것은, 제가 처음부터 직접 타이핑하는 것보다 모델이 더 잘 수행하는 작업입니다. 결과물은 완벽하고, 이름이 잘 지어져 있으며, 제가 요청할 경우 JSDoc을 포함합니다. 저는 이를 검토하고, 선호하는 방식에 따라 이름을 조정한 뒤 다음 단계로 넘어갑니다.
정규 표현식 (Regular expressions). 예전에는 중간 정도의 복잡한 케이스만 되어도 패턴을 작성하고, REPL에서 엣지 케이스를 테스트하고, 조정하는 데 20분 정도 걸리곤 했습니다. 이제는 제가 신경 쓰는 엣지 케이스를 포함하여 매칭 요구 사항을 설명하면, 모델이 정규 표현식을 생성하고, 저는 제가 실제로 가진 입력값들을 통해 이를 검증합니다. 소요 시간이 대략 20분에서 약 2분으로 줄었습니다. 또한 모델은 수많은 예시를 통해 패턴 매칭 (pattern-matched)을 학습했기 때문에, 명명된 그룹 (named groups)을 사용하여 읽기 쉬운 정규 표현식을 만드는 데 있어 저보다 더 뛰어납니다.
형식 변환 (Format conversions) 및 데이터 변환 (data transformations). API 응답을 다른 형태로 파싱하거나, 일회성 CSV-to-JSON 변환기를 작성하거나, 로그 파일을 위한 jq 파이프라인을 구축하는 작업 등이 있습니다. 이러한 작업들은 무엇이 "정확한" 것인지에 대해 모호함이 없는, 잘 정의된 입력-출력 (inputs-to-outputs) 과정입니다. 저는 입력 형식을 설명하고 출력 형식을 설명하면, 모델이 코드를 생성합니다.
Bash 원라이너 (one-liners) 및 스크립팅 (scripting). 저는 일을 처리할 수 있을 만큼의 Bash 지식은 가지고 있습니다. 하지만 Bash 작성을 즐기지는 않습니다. 이 부분에서 모델은 잘 작동하며, 저는 프로덕션 (production)에 영향을 주는 것을 실행하기 전에 출력을 검토합니다.
이미 작성한 코드에 대한 문서화 (Documentation). 결정 사항에 대한 설명이 필요한 곳에는 제가 직접 주석을 작성합니다. 모델은 JSDoc 블록을 채우거나, 실제로 읽기 쉽지만 문서화가 부족한 부분에 대한 인라인 설명 (inline clarifications)을 작성합니다. 결과물은 초안으로서 수용할 만한 수준이며, 제가 쓰는 방식과 다르게 느껴지는 부분은 제가 직접 수정합니다.
AI를 사용하지만 모든 줄을 읽는 경우
이 영역들은 리스크가 큰 (high-stakes) 분야입니다. 초안 작성 속도가 확실히 빠르기 때문에 여전히 AI의 도움을 받지만, 출력물을 아직 완전히 신뢰하지 못하는 개발자가 보낸 풀 리퀘스트 (pull request)처럼 취급합니다. 모든 줄을 읽습니다. 중요한 부분은 코드가 프로덕션 근처에 가기도 전에 격리된 환경에서 테스트를 거칩니다.
SQL 쿼리 (queries), 특히 JOIN 및 멀티 테넌시 (multi-tenancy)가 포함된 경우. 저는 기존의 Drizzle ORM 스키마를 기반으로 작업하며, 모델은 제 설명을 바탕으로 정확한 쿼리를 생성할 수 있습니다. 적절한 멀티 테넌시를 갖춘 SaaS 제품의 경우, 이 영역에서 AI가 생성한 가장 미묘한 실수들을 발견하곤 합니다. 하지만 쿼리에서 "정확하다"는 것은 단순히 "결과를 반환한다"는 것 이상의 의미를 갖습니다. 이는 올바른 테넌트 (tenant)로 필터링되어야 하고, 실제로 존재하는 인덱스 (index)를 사용해야 하며, 호출자가 필요한 컬럼 (column)만 반환해야 하고, 프로덕션의 데이터 분포 하에서 예측 가능하게 동작해야 함을 의미합니다. 두 개 이상의 테이블을 건드리거나 핫 패스 (hot path)에서 실행되는 모든 쿼리에 대해, 저는 출력을 주의 깊게 읽고 배포하기 전에 실제 프로덕션 데이터베이스에서 EXPLAIN ANALYZE를 실행합니다. 모델은 제 인덱스가 무엇인지, 제 데이터가 어떻게 생겼는지 알 수 없습니다. 그것을 아는 것은 저입니다.
돈과 관련된 모든 것. Stripe 결제 생성 (charge creation), 인보이스 품목 계산 (invoice line item calculation), 부가가치세 (VAT) 적용, 환불 로직 (refund logic) 등이 해당됩니다. 모델이 초안을 작성하면, 저는 마치 작성자를 신뢰하지 않는 시니어 엔지니어처럼 검토합니다. 모든 조건부 분기 (conditional branch), 모든 반올림 동작 (rounding behavior), 모든 실패 경로 (failure path)를 확인합니다. 결제가 잘못되면 그 피해는 실질적이며 종종 되돌릴 수 없습니다. 이 코드에서 속도는 우선순위가 아닙니다.
사용자 입력 처리 (User input handling). 폼 제출 (form submissions), 웹훅 페이로드 (webhook payloads), API 파라미터 (API parameters)와 같이 시스템 외부에서 데이터를 받는 모든 코드가 해당됩니다. 저는 검증 (validation)이 제가 생각할 수 있는 예외 케이스 (edge cases)를 모두 다루는지, 에러 메시지가 내부 구조를 유출하지 않는지, 그리고 어떤 입력값도 검증 레이어 (validation layer)를 거치지 않고 데이터베이스나 파일 시스템에 도달할 수 없는지를 확인합니다.
함수 내부의 인증 확인 (Auth checks inside functions). 인증이 어디에서 이루어져야 하는가라는 아키텍처적인 문제는 아닙니다. 그것은 모델이 코드를 작성하기 전에 제가 결정하고 문서화하는 사항입니다. 하지만 함수에 권한이나 소유권을 확인하는 가드 절 (guard clause)이 있을 때, 저는 이를 주의 깊게 읽습니다. 잘못된 필드를 확인하거나, 차단(closed)하는 대신 허용(open)해 버리거나, null 케이스를 고려하지 않는 등의 미묘한 오류는 테스트에서는 보이지 않다가 누군가 이를 악용할 때만 드러나는 종류의 문제입니다.
마이그레이션 (Migrations). 모델은 마이그레이션을 빠르게 생성하며 구문 (syntax)도 대개 정확합니다. 저는 모든 마이그레이션을 두 번 읽습니다. 한 번은 그것이 무엇을 하는지 확인하기 위해, 또 한 번은 그것이 기존 데이터에 어떤 영향을 미칠 수 있는지 확인하기 위해서입니다. 저는 운영 환경 (production)에 적용하기 전에 운영 데이터의 복사본에서 마이그레이션을 실행합니다. 모델은 데이터베이스의 실제 행 (rows)에 대한 지식이 없습니다.
위임하지 않는 영역
slug="fractional-cto"
text="AI 도구가 팀이 안전하게 평가할 수 있는 속도보다 더 빠르게 코드를 생성하고 있다면, 저는 시니어 엔지니어링 리더들이 이를 통제할 수 있는 검토 규율과 가드레일 (guardrails)을 구축하도록 돕습니다."
/>
이 목록이 짧은 이유는 이전 기사에서 원칙들을 다루었기 때문입니다. 여기서는 이를 논쟁이 아닌 실무 (practices)로서 명시하고자 합니다.
아키텍처 결정 (Architectural decisions). 모듈이 어디에 위치할지, 애플리케이션이 어떻게 계층화될지, 두 서비스 사이의 모듈 경계가 무엇인지와 같은 사항들입니다. 저는 모델이 무언가를 작성하기 전에 이러한 결정들을 직접 내리고 이를 모델에게 명시적으로 전달합니다. 만약 그렇게 하지 않으면, 모델은 이를 저 대신 로컬에서 일관성 없게 결정해 버릴 것입니다. 또한 세션마다 서로 다르게 결정할 수도 있습니다. 저는 이러한 결정 사항들을 기록하기 위해 모든 프로젝트에서 CLAUDE.md 파일을 사용합니다. 이에 대한 자세한 내용은 이 시리즈의 다음 기사에서 다루겠습니다.
보안 토폴로지 (Security topology). 인증이 어디에서 발생하는지, 어떤 경로 (routes)가 공개되어 있는지, 인증되지 않은 요청이 어디까지 접근할 수 있는지와 같은 사항들입니다. 모델은 메커니즘을 구현할 수 있지만, 그것이 어디에 위치할지는 제가 결정합니다. 이는 안전하게 위임할 수 있는 작업이 아닙니다. 왜냐하면 보안의 정확성은 전체 요청 경로 (request path)를 이해하는 것에 달려 있는데, 제가 명시적으로 제공하지 않는 한 모델은 이를 이해하지 못하기 때문입니다. 설령 제공하더라도 저는 직접 검증합니다.
스키마 설계 (Schema design). 모델은 DDL (Data Definition Language) 초안을 빠르게 작성합니다. 어떤 컬럼을 Nullable로 만들지, 어떤 인덱스가 실제로 실행될 쿼리에 유용할지, 이 필드를 비정규화 (denormalize)할지 아니면 정규화 (normalized)된 상태로 유지할지 등의 결정은 쿼리 패턴, 데이터 성장 예측, 그리고 운영상의 제약 사항을 알아야만 가능합니다. 저는 이러한 결정들을 내린 후 모델에게 DDL을 생성하도록 요청합니다.
트레이드오프 결정 (Trade-off decisions). 모델은 옵션을 제시하는 데 능숙합니다. "이 캐싱 계층을 구현하는 세 가지 방법과 각각의 트레이드오프는 다음과 같습니다"와 같이 말이죠. 저는 그 결과물을 시작점으로 활용합니다. 하지만 실제 선택에는 모델이 알 수 없는 컨텍스트 (context)가 필요합니다. 즉, 운영 팀이 무엇을 지원할 수 있는지, 비용 상한선은 얼마인지, 이 영역에 이미 어떤 기술 부채 (technical debt)가 존재하는지, 각 옵션에 대한 팀의 경험은 어떠한지 등입니다. 모델은 이를 알지 못합니다. 저는 모델에게 제가 결정한 것을 말해주는 것이지, 그 반대로 하지 않습니다.
운영 장애 디버깅 (Production incident debugging). 무언가 실제로 고장 났을 때, 저는 관찰에서 진단으로 이어지는 직접적인 경로를 원합니다. 모델은 그 경로에 지연 (latency)을 발생시킵니다. 모델이 그럴듯하게 들리지만, 제가 보고 있는 시스템의 구체적인 상태가 아닌 일반적인 패턴에 기반한 가설들을 생성하기 때문입니다. 운영 장애 상황에서 제가 원하는 것은 로그, 메트릭 (metrics), 그리고 시스템에 대한 저 자신의 지식이지, 무엇이 잘못되었을지도 모른다는 자신감 넘치는 추측이 아닙니다. 저는 에이전트 (agent)를 끄고 직접 디버깅합니다.
내가 멈추게 되는 신호들
이 부분은 제가 개발하는 데 시간이 걸렸던 부분이며, 자주 글로 쓰이는 내용도 아닙니다. 세션 중간에 실시간으로, 해당 세션에서 다른 것을 수락하기 전에 저를 멈추게 하고 재고하게 만드는 특정한 출력물들이 있습니다.
모델이 기존 파일을 수정하는 대신 새로운 파일을 생성할 때. 만약 제가 기존 모듈을 확장해달라고 요청했는데 모델이 그 옆에 새로운 파일을 만들어냈다면, 모델이 기존 코드를 보지 못했거나 기존 코드를 우회하기로 결정한 것입니다. 어느 쪽이든, 그 출력물은 아마도 무언가를 중복 생성했을 것입니다. 저는 이를 거부하고 기존 파일에 대한 명시적인 포인터를 주어 다시 지시합니다.
새로운 의존성 (dependency)이 나타날 때. 저는 승인한 적이 없습니다. 라이브러리를 요청하지도 않았습니다. 모델이 작업에 편리하다는 이유로 의존성을 추가한 것입니다. 저는 매번 여기서 멈춥니다. 의존성을 추가하는 것은 보안 표면 (security surface), 번들 크기 (bundle size), 유지보수 부담 등 프로젝트 전체에 영향을 미치는 결정입니다. 이는 결코 암묵적으로 이루어져서는 안 됩니다.
모델이 코드베이스에 이미 존재하는 패턴을 재발명할 때. 이는 제가 이미 존재하는 것에 대해 모델에게 충분한 컨텍스트 (context)를 제공하지 않았을 때 발생합니다. 그 결과는 통합되어야 할 무언가의 두 번째 버전이 만들어지는 것이며, 이제 저는 처음에는 없었던 불일치 (inconsistency)를 갖게 됩니다. 저는 출력을 거부하고 기존 패턴을 명시적으로 제공합니다.
제가 알지 못하는 API 메서드를 자신 있게 사용하는 경우. 때로는 환각 (hallucination)된 메서드 이름일 수도 있습니다. 때로는 제가 실행 중인 버전보다 더 최신 버전에 추가된 실제 메서드일 수도 있습니다. 때로는 올바를 때도 있습니다. 저는 예외 없이 매번 확인합니다. 왜냐하면 환각된 API 호출의 실패 모드 (failure mode)는 정적으로는 문제가 없어 보이는 코드 경로에서 런타임 에러 (runtime error)를 발생시키기 때문입니다.
// 모델이 생성한 코드입니다. 저는 이 시그니처(signature)를 가진 `.parseAsync`를 알지 못했습니다.
const result = await schema.parseAsync(input, { strict: true });
// 확인 결과: Zod의 `parseAsync`에는 `strict` 옵션이 존재하지 않습니다.
...
에러를 삼켜버리는 try/catch. 모델은 무언가를 try/catch로 감싸는 코드를 많이 보았기 때문에 이를 추가합니다. catch 블록이 로그를 남기고 null을 반환하거나, 더 나쁜 경우, 폴백 값 (fallback value)을 반환하고 계속 진행할 때, 이는 실패를 드러내기보다 숨겨버립니다. 저는 항상 catch 블록이 무엇을 하는지 확인합니다. 로그 속으로 사라져 버리는 에러는 곧 발생할 사고를 기다리고 있는 것과 같습니다.
반환 값 (return value) 대신 반환 타입 (return type)을 확인하는 테스트.
// 이것은 테스트가 아닙니다. 이것은 노이즈 (noise)입니다.
it("returns a number", () => {
expect(typeof calculateTax(100, "FI")).toBe("number");
...
저는 이 내용에 대해 첫 번째 글에서 작성한 바 있습니다. 모델 출력에서 첫 번째 패턴이 보이면, 저는 해당 테스트를 거부하고 제가 검증하고자 하는 실제 동작에 대한 설명을 작성합니다.
AI 자동 생성 콘텐츠
본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기