3주 동안 4개의 AI 코드 어시스턴트를 사용해 본 결과: 실제로 발견한 것
Source: Dev.to
Source:
소개
3주 전, 프로덕션에서만 나타나는 버그를 디버깅하고 있었습니다. FastAPI 엔드포인트가 사용자가 여러 컬럼을 동시에 정렬할 때 페이지네이션 데이터가 일관되지 않게 반환되었습니다. 우리 ORM (SQLAlchemy 2.0.36)은 캐시 결과가 새로운 객체인지 이미 채워진 객체인지에 따라 서로 다른 쿼리를 생성했습니다. 전형적인 경우였죠.
제가 주목한 점은 버그 자체가 아니라 디버깅 과정에서 여러 AI 어시스턴트가 보여준 극명한 차이였습니다. 한 어시스턴트는 두 번째 시도에 정확히 필요한 코드를 제공했고, 다른 어시스턴트는 10분 동안 같은 이야기를 반복했습니다. 이를 보며 생각하게 되었습니다: 우리가 이 어시스턴트에 대해 가지고 있는 기대는 깔끔한 데모에서 비롯된 것인지, 실제 사용에서 나온 것인지 말이죠.
그래서 저는 다음 3주 동안 체계적인 테스트를 진행했습니다. 저는 4인 팀에서 B2B 분석 플랫폼을 구축하고 있으며, 주요 스택은 FastAPI + React + PostgreSQL, 배포는 AWS에서 이루어집니다. 백로그에 있던 실제 작업을 사용했으며, 별도로 만든 연습 문제는 사용하지 않았습니다.
평가한 후보
| 어시스턴트 | 버전 / 설정 |
|---|---|
| GitHub Copilot | 백엔드에 Claude Sonnet 4.5 활성화 (설정 패널에서) |
| Cursor | 0.47 |
| Claude Code (CLI) | Sonnet 4.5 |
| Windsurf | 1.9.2 |
어떻게 테스트를 설계했는가 (중요하게 만들기 위해)
첫 번째로 배제한 것은 개별 함수 자동완성 벤치마크였습니다. HumanEval와 SWE‑bench는 기본 모델을 비교하는 데 유용하지만, 4 000줄의 히스토리와 특이한 의존성을 가진 서비스에서 화요일 오후에 어시스턴트가 도움이 될지 여부는 알려주지 않습니다.
네 가지 작업 카테고리를 정의했습니다:
- 넓은 컨텍스트 이해 – 800 줄 이상의 모듈을 주고 기존 패턴을 따르는 기능을 추가하도록 요청.
- 부분적인 정보로 디버깅 – 스택 트레이스와 관련 코드 조각을 제공하고, 레포 전체 컨텍스트는 제공하지 않음.
- 제약 조건이 있는 리팩토링 – “공개 API를 깨뜨리지 않고, 기존 테스트를 변경하지 않으면서 이것을 바꿔라”.
- 테스트 생성 – 강한 결합을 가진 레거시 코드에 대한 통합 테스트 작성.
각 카테고리마다 서로 다른 실제 작업 세 개를 선택했습니다. 평가는 주관적이었으며(알고 있습니다) 세 가지 측면에서 이루어졌습니다:
- 첫 번째 시도가 바로 사용 가능했는가?
- 몇 번의 반복이 필요했는가?
- 이전에 없던 버그를 도입했는가?
초당 토큰 속도는 측정하지 않았습니다; 출력이 잘못되었다면 빠르다는 것이 중요하지 않습니다.
컨텍스트 이해: 여기서 좋은 사람과 평범한 사람을 구분한다
가장 큰 차이를 만든 테스트는 다음과 같습니다: 데이터 내보내기 모듈(data_export/pipeline.py, 약 1 100줄)이 데코레이터를 통한 플러그인 등록 패턴을 사용합니다. 각 어시스턴트에게 정확히 같은 패턴을 따라 새로운 내보내기 형식 지원을 추가하도록 요청했습니다.
# Patrón existente que el asistente necesitaba reconocer y replicar
@export_registry.register("csv")
class CSVExporter(BaseExporter):
"""
Exporta datos en formato CSV con soporte para encodings custom.
El registry inyecta config via __init_subclass__ — no tocar ese flujo.
"""
def export(self, queryset: QuerySet, options: ExportOptions) -> BytesIO:
# La lógica de chunking está en BaseExporter.stream_chunks()
# Los exporters solo deben implementar _serialize_chunk()
buffer = BytesIO()
for chunk in self.stream_chunks(queryset, options.chunk_size):
buffer.write(self._serialize_chunk(chunk, options))
return buffer
def _serialize_chunk(self, data: list[dict], options: ExportOptions) -> bytes:
# ... implementación real omitida por brevedad
결과
| 어시스턴트 | 코멘트 | 반복 횟수 | 대략적인 시간 |
|---|---|---|---|
| Claude Code (CLI) | 두 번째 시도에서 — 첫 번째는 등록 데코레이터가 빠졌음 — stream_chunks()를 올바르게 사용하고 __init_subclass__ 관습을 준수하는 Parquet exporter를 만들었습니다. 두 줄만 수정했습니다. | 2 | ~6 분 |
| Cursor | 결과는 기능적으로 올바르지만 stream_chunks() 패턴을 무시하고 처음부터 자체 청크 루프를 작성했습니다. 작동은 하지만 BaseExporter를 변경하면 해당 exporter가 변경을 상속받지 않아 새로운 기술 부채가 생깁니다. | 1 | — |
| Copilot | Cursor와 유사합니다. 데코레이터는 이해하지만 _serialize_chunk()가 별도 메서드로 존재하는 이유를 이해하지 못합니다. 첫 번째 시도에서는 모든 로직을 export()에 넣었습니다. | 1 | — |
| Windsurf | 긍정적인 놀라움 — 기대보다 패턴을 더 잘 파악했습니다. 결과는 Claude Code와 동일한 수준이었지만 (두 번 교환 vs. 네 번) 더 많은 반복이 필요했습니다. | 4 | — |
결론:
내부 패턴이 명확하지 않은 코드의 경우, 컨텍스트 윈도우와 모델이 이를 처리하는 방식이 자동완성 속도보다 더 중요합니다. 코드베이스에 자체적인 관습이 있다면 — 2년이 지나면 어느 코드베이스든 관습이 생깁니다 — 차이를 느끼게 될 것입니다.
부분적인 정보로 디버깅: 오후 11시의 스택 트레이스 상황
이것은 실무에서 가장 자주 마주치는 사용 사례입니다. 스테이징에서 뭔가가 터지고, 트레이스백과 관련 코드는 있지만, 전체 컨텍스트를 제공할 시간이 없습니다.
지난 달에 실제로 발생한 버그 세 가지를 사용했습니다. 설명을 위해 가장 흥미로운 사례: 모델이 Optional 필드와 model_rebuild()를 통해 순환 참조를 가지고 있을 때 Pydantic v2 직렬화 과정에서 발생한 RecursionError.
개인 메모: 여기서 나는 시간을 잡아먹는 실수를 저질렀습니다: 나는 어떤 어시스턴트든 Pydantic v1과 v2의 차이를 내가 명시하지 않아도 이해할 것이라고 가정했습니다. Copilot은 v1에서는 동작하지만 v2에서는 deprecated 되었거나 완전히 제거된 세 가지 다른 해결책을 제시했습니다 —
validator대신field_validator,__fields__대신model_fields. 이러한 오류는 모델이 두 버전의 지식을 혼합하고 있음을 나타냅니다. Pydantic을 잘 모르면 이를 눈치채지 못하고 작동하지 않을 코드를 구현하는 데 30분을 낭비할 수 있습니다.
그때부터 나는 항상 초기 프롬프트에 정확한 버전을 명시합니다. 처음부터 그렇게 했어야 했습니다.
Claude Code가 이와 같은 모호함을 가장 잘 다뤘습니다 — 부분적으로는 CLI이며 전체 레포에 접근할 수 있어 pyproject.toml을 읽고 답변하기 전에 설치된 버전을 확인할 수 있기 때문이라고 생각합니다. Windsurf도 비슷한 기능을 가지고 있습니다. 채팅 모드의 Copilot은 기본적으로 이를 수행하지 않습니다.
컨텍스트에 정확한 버전을 항상 명시하세요. 그리고 어시스턴트가 프로젝트 설정 파일에 접근할 수 있다면 활용하세요 — 이는 답변 품질을 완전히 바꾸는 정보입니다.
제한이 있는 리팩토링: 대부분이 실패하는 곳
이 카테고리는 테스트하기 가장 답답했습니다. 어시스턴트들이 너무 야심차게 행동하기 때문이죠. 함수 하나를 리팩터링해 달라고 하면 모듈 전체를 다시 작성해 버립니다.
작업: 우리는 notifications/dispatcher.py에 120줄짜리 함수가 있는 알림 서비스가 있었는데, 이 함수가 너무 많은 일을 하고 있었습니다. throttling 로직을 별도 클래스로 추출하고 싶었지만, 함수의 공개 시그니처와 기존 테스트는 건드리지 않으려 했습니다 — 27개의 통합 테스트가 있었고, 실행하는 데 40초가 걸렸으며, 이를 수정하고 싶지 않았습니다.
# 변경하면 안 되는 시그니처:
async def dispatch_notification(
user_id: int,
event: NotificationEvent,
channels: list[NotificationChannel],
*,
priority: Priority = Priority.NORMAL,
metadata: dict | None = None
) -> DispatchResult:
...
Copilot과 Cursor는 각각 다른 정도의 심각성을 가지고, API를 깨뜨릴 수 있는 변경을 제안했습니다. Cursor는 channels를 list에서 *args로 바꾸자는 제안을 했는데, 이는 사소해 보이지만 이미 리스트를 만들어서 전달하고 있는 모든 코드를 깨뜨립니다. Copilot은 기본값이 없는 새로운 파라미터를 추가했는데, 이는 명백히 모든 것을 망가뜨립니다.
Claude Code는 제약을 잘 지켰습니다. Windsurf도 제약을 지켰지만, throttler 구현에 미묘한 버그가 있었습니다: user_id만을 throttling 키로 사용하고 channel을 무시했기 때문에, 사용자가 이메일로 많은 알림을 받으면 SMS도 동시에 throttled 상태가 되었습니다. 이를 수정하기 위해 추가적인 교환을 진행했습니다.
예상치 못한 점이 하나 있습니다. Cursor에게 *“dispatch_notification의 공개 시그니처와 관찰 가능한 동작을 절대 바꾸지 말라”*고 명시적으로 말했을 때, 성능이 크게 향상되었습니다. 지시문의 정확성이 생각보다 더 큰 영향을 미칩니다. Cursor가 올바른 리팩토링을 할 수 없어서가 아니라, 기본적으로 별다른 말을 하지 않으면 모든 것을 바꿀 수 있다고 가정하기 때문입니다.
테스트 생성: 가장 변동성이 큰 사용 사례
여기서 변동성은 테스트하는 코드 종류에 따라 엄청납니다. 일관된 패턴: 모든 도우미는 새롭고 잘 구조화된 코드에 대한 테스트를 생성하는 데 능숙하고; 레거시 코드와 전역 의존성이 있는 경우 모두 평균 이하입니다. 이는 타당합니다 — 코드가 테스트하기 어렵다면 외부에서 그 코드를 이해하기도 어렵기 때문입니다.
도우미들을 구분 짓는 것은 레거시 코드가 문제가 될 때 그들이 어떻게 행동하느냐입니다: 그 사실을 알려주나요, 아니면 겉보기에 좋은 테스트만 생성하고 실제로는 당신이 생각하는 것을 테스트하지 않나요?
Claude Code는 두 번에 걸쳐 다음과 같은 말을 한 유일한 도우미였습니다:
“이 함수는 전역 싱글톤에 부수 효과가 있습니다; 먼저 이를 모킹하지 않으면 생성되는 테스트가 취약해집니다 — 수행해 드릴까요?”
그것은 유용합니다. 나머지는 단순히 테스트를 생성하고 마치 올바른 것처럼 제시했습니다. 제 샘플은 작지만, 이를 충분히 일관되게 관찰할 수 있었습니다.
나의 실제 추천 — 직설적으로
주로 IDE에서 작업하고 코드베이스가 중간 규모(< 100 k 라인)인 경우
- Cursor는 여전히 가장 통합된 옵션이며 전반적인 경험이 더 좋습니다. 에디터에서의 자동완성이 다른 대안보다 빠르고 자연스럽습니다.
- 백엔드 모델을 Claude Sonnet 4.5로 변경하세요 — 출력 품질 차이가 추가 비용을 정당화하고 지연 시간도 허용 범위입니다.
아키텍처 작업, 대규모 리팩토링 또는 어려운 버그 디버깅을 많이 하는 경우
- Claude Code CLI. 전체 레포를 읽고, 명령을 실행하며, 긴 세션 동안 컨텍스트를 유지하는 능력이 복잡한 작업에서 실제적인 이점을 제공합니다. 통합 IDE만큼 편리하지는 않으며 — 학습 곡선이 존재하지만 — 고복잡도 작업에서는 결과가 눈에 띄게 좋습니다.
Copilot
- 솔직히 말해, 이미 GitHub 구독이 있고 주요 사용 사례가 빠른 자동완성과 인라인 제안이라면 잘 작동합니다.
- 복잡한 추론이나 비표준 패턴의 코드 이해에서는 지속적으로 뒤처졌습니다. Copilot에 Claude 백엔드를 통합한 것은 비교적 최근이며 — 개선될 가능성이 있습니다.
Windsurf
- 팀에게 긍정적인 놀라움을 주었습니다. Cursor Pro에 비용을 지불하고 싶지 않으며 표준 Copilot보다 더 강력한 것을 원하는 팀에게는 진지한 옵션입니다.
- 레포 컨텍스트에 사용하는 Cascade 모델이 가격 대비 기대 이상으로 잘 작동합니다.
바꾸지 않을 한 가지, 어시스턴트와 무관하게
당신의 특정 상황에 맞는 좋은 프롬프트 작성법을 배우세요. *“refactoriza esto”*와 “dispatch_notification 함수를 리팩터링해서 throttling 로직을 별도 클래스로 추출하고, 공개 인터페이스는 변경하지 않으며, test_dispatcher.py 테스트와의 호환성을 유지해” 사이의 차이는 쓸모없는 결과와 바로 사용할 수 있는 결과의 차이입니다. 어시스턴트는 좋지만, 마법사는 아닙니다.
이 결과들은 제 스택(FastAPI / Python / React)과 작업 스타일, 그리고 백로그에 있는 작업들을 반영한 것입니다. Rust를 사용하거나 200만 라인의 Java 모노레포에서 작업한다면 순위가 달라질 가능성이 높습니다. 하지만 방법론—실제 코드를 가지고 시험해 보세요, 데모가 아니라—은 동일하게 적용됩니다.