요약
이 글은 AI 협업 시대에 “테스트 코드는 미래 세션의 AI가 읽는, 실행 가능한 프롬프트”라는 새로운 관점을 바탕으로, 테스트 코드의 역할과 작성 방향을 정립합니다. 특히 개발 과정에서 빈번하게 발생하는 ‘AI 세션 간 맥락(Context) 휘발 문제’를 해결하는 가장 강력한 동기화 장치로서의 테스트 가치를 설명합니다. 또한 멀티 테넌트 서비스 격리 정책 사례를 통해 @DisplayName에 구현이 아닌 비즈니스 의도와 ‘왜(Why)’를 담는 구체적인 방법을 다룹니다. 나아가 인간이 명세를 확정하고 AI가 구현하는 협업 방식, 교차 검증 및 역 추론 루프 등의 실천 항목과 함께 과잉 명세와 같은 테스트 코드가 지닌 근본적인 한계점도 실무적 관점에서 살펴보는 글입니다.
서론
AI와 협업하면서 “테스트 코드는 왜 쓰는가”에 대한 답이 하나 더 늘었습니다. 일하면서 정리한 생각입니다.
테스트 코드는 미래 세션의 AI가 읽는, 실행 가능한 프롬프트다.
개요
개발 업무에 Claude Code를 사용하고 있습니다. Claude Code는 세션 단위로 관리됨에 따라 세션이 끊기면 다음 세션에서 같은 설명을 반복하게 됩니다.
- “이 엔드 포인트는 어떤 케이스를 커버해?”
- “tenantId 없을 때는 어떻게 처리하기로 했지?”
- “왜 여기서 이 예외를 먼저 잡았더라?”
특히 최근 멀티테넌트 백엔드 서비스를 개발하면서 이 문제가 반복적으로 떠올랐고, 그 과정에서 테스트 코드의 역할에 대해 다시 생각하게 됐습니다. AI는 세션이 종료되면 작업 맥락을 안정적으로 보존한다고 보기 어렵습니다. 다음 세션에서 동일 코드 베이스를 다루려면 맥락을 다시 전달해야 하고, 이 비용은 반복적으로 발생합니다.
기존에는 테스트가 이미 만든 기능이 망가지지 않게 지키는 안전장치였다면, AI와 협업하는 지금은 세션 사이에 사라지는 맥락을 붙잡아 두는 장치이기도 합니다.
맥락 전달 수단으로 검토 가능한 후보는 세 가지입니다.
- 주석: 코드와의 동기화가 강제되지 않음. 코드가 바뀌어도 주석은 그대로인 경우가 많음
- README: 함수 단위 의도를 담기에 입자도(Granularity)가 큼
- 테스트 코드: 불일치 시 CI에서 실패 — 동기화가 강제됨
결론적으로, 테스트 코드가 이 역할을 하기에 가장 적합합니다.
테스트 코드가 맥락 전달에 적합한 이유
테스트 코드는 기대와 구현이 어긋날 때 실패로 신호를 보냅니다. 주석은 코드와 어긋나도 조용히 남아 있지만, 테스트는 깨집니다. 그래서 테스트 코드는 주석이나 README보다 코드와의 동기화 압력이 강한 명세서입니다.
다만 이것이 “항상 최신 상태의 정책”을 보장한다는 뜻은 아닙니다. 정책이 바뀌었는데 테스트와 구현이 모두 이전 정책에 묶여 있으면 테스트는 그대로 통과합니다. 테스트가 강제하는 건 “과거에 합의한 기댓값을 현재 코드가 유지하는지” 뿐입니다. 이 한계는 뒤에서 다시 다룹니다.
그럼에도 테스트 하나하나가 전달하는 정보는 주석보다 풍부합니다.
- 테스트 이름 / @DisplayName → 이 함수가 다뤄야 하는 상황
- given/when/then → 입력 조건과 기대 결과
- 실패 케이스 → 어떤 예외가 어떤 상황에서 발생되는지에 대한 정보
AI 관점에서 보면, 새 세션의 AI에게 테스트 파일을 컨텍스트로 제공하는 것만으로 상당 부분 맥락이 복원됩니다.
반론 – AI는 소스 코드도 볼 수 있다
여기서 다음 반론이 가능합니다.
“AI가 소스 코드를 직접 읽을 수 있는데 왜 굳이 테스트를 해야 하는가?”
타당한 지적입니다. 소스 코드를 읽으면 함수가 무엇을 하는지는 파악됩니다. 그러나 소스 코드에는 다음 세 가지가 담기지 않습니다.
- 왜 이렇게 처리했는지: 소스는 “무엇”을 보여주지만 “왜”는 보여주지 않습니다.
- 허용된 경계: 소스는 “현재 이렇게 돌아간다”를 보여주지만, “이 경계까지만 허용된다”라는 보여주지 않습니다.
- 의도적으로 뺀 것: 소스에는 “예전에 논의했다가 뺀 케이스”의 흔적이 없습니다.
정리하면, 소스는 “현재 상태”를 보여주고 테스트는 “결정의 흔적”을 보여줍니다. AI에게 소스 코드는 “지금 이렇게 되어 있다”를 알려주고, 테스트 코드는 “이렇게 되어 있어야 한다”를 알려줍니다.
사례 – DocumentService 테스트
다음과 같은 DocumentService 테스트 케이스를 예시로 들어보겠습니다.
@Test
@DisplayName("다른 테넌트의 문서 조회 시 DOCUMENT_NOT_FOUND 에러가 발생한다")
void findByIdWrongTenant() {
// 문서는 실제로 존재함 (tenant-a 소유)
when(documentRepository.findById("doc-1"))
.thenReturn(Mono.just(testDocument("tenant-a")));
StepVerifier.create(documentService.findById("tenant-b", "doc-1"))
.expectErrorMatches(e -> e instanceof BusinessException be
&& be.getErrorCode() == ErrorCode.DOCUMENT_NOT_FOUND)
.verify();
}
이 테스트 하나에 담긴 결정사항은 다음과 같습니다.
- 문서는 실제로 존재한다: documentRepository.findById가 실제 객체를 반환하도록 모킹. “DB에 없어서 NOT_FOUND가 나는 상황”이 아님
- 그런데도 NOT_FOUND를 던진다: DOCUMENT_FORBIDDEN이나 ACCESS_DENIED 같은 에러코드를 선택하지 않음
- “해당 테넌트에게는 존재하지 않는 것과 같다”라는 정보 노출 최소화 정책: 권한 에러를 주면 “문서 ID가 존재하긴 한다”라는 정보가 노출되므로, 존재 여부 자체를 숨김
그런데 이 테스트를 다시 보면, 글의 주장에 비해 @DisplayName이 아직 약합니다. “DOCUMENT_NOT_FOUND 에러가 발생한다”까지는 드러나지만, 왜 권한 에러가 아니어야 하는지는 본문 해설에서만 나오고 테스트에는 없습니다. 한 단계 더 의도를 담으려면 이렇게 쓰는 편이 낫습니다.
@Test
@DisplayName("정보 노출 방지를 위해 다른 테넌트의 문서는 DOCUMENT_NOT_FOUND로 숨긴다")
void findByIdWrongTenant() { ... }
또는 @Nested로 정책 단위를 묶어도 좋습니다.
@Nested
@DisplayName("테넌트 격리 정책")
class TenantIsolationTest {
@Test
@DisplayName("다른 테넌트의 문서는 존재하더라도 정보 노출 방지를 위해 NOT_FOUND로 응답한다")
void findByIdWrongTenant() { ... }
}
이러면 새 세션에서 AI가 “이거 권한 에러로 바꾸는 게 더 명확하지 않을까요?”라고 제안했을 때, 테스트 파일만으로도 해당 제안이 이미 기각된 방향임을 인지할 수 있습니다.
정리 – 잘 쓰는 기준이 바뀐다
이 글은 “테스트 코드가 중요하다”가 아니라 “잘 쓴 테스트 코드가 중요하다”에 가깝습니다. 테스트 이름을 testCase1로 썼다면 소스 코드와 차이가 없으니까요. 따라서 더 정확한 주장은 다음과 같습니다.
AI와 협업하는 시대에는 테스트 코드를 잘 쓰는 기준에 항목이 하나 추가된다.
기존 기준:
- 커버리지를 확보한다.
- 독립적이다.
- 빠르게 실행된다.
- 실패 메시지가 명확하다.
추가되는 기준:
- 읽는 인간(인간 + AI)이 의도를 복원할 수 있다.
실천 항목
위에서 예로 든 findByIdWrongTenant 테스트처럼 이미 하고 있던 방식도 있고, 앞으로 더 의식할 것도 있습니다.
1. @DisplayName에 비즈니스 맥락 담기
메서드 이름은 간결하게 유지하고, @DisplayName에 “왜 그렇게 동작해야 하는지”를 서술합니다.
@DisplayName("정보 노출 방지를 위해 다른 테넌트의 문서는 DOCUMENT_NOT_FOUND로 숨긴다")
void findByIdWrongTenant() { ... }
2. 엣지 케이스 테스트에 “왜”를 드러내기
createDuplicate와 같은 메서드 이름만으로는 “중복이면 에러”라는 사실까지만 전달됩니다. @DisplayName에 “테넌트 내 문서 이름 유일성 보장을 위해 중복 등록을 거부한다”라고 덧붙이면, 이름 유일성이라는 비즈니스 규칙이 드러나고, 향후 정책 변경 시 이 테스트부터 확인해야 한다는 신호가 됩니다.
3. 의도적으로 뺀 케이스는 기본적으로 negative test로
다만 “의도적으로 뺐다”라는 말에는 두 가지가 섞여 있습니다. 현재 정책상 거부해야 하는 입력은 negative test로 남길 수 있지만, 제품 범위에서 제외한 기능 자체는 테스트보다 ADR(Architecture Decision Record) 이나 Jira에 남기는 편이 자연스럽습니다.
검토했지만 기각된 케이스를 테스트 코드에 남기고 싶을 때가 있습니다. 이때 @Disabled로 positive test를 박제하는 방식은 조심해서 써야 합니다. @Disabled 테스트는 CI에서 실패하지 않기 때문에 시간이 지나면 쉽게 썩고, 오히려 AI에게 “언젠가 활성화할 기능”처럼 잘못된 신호를 줄 수 있습니다.
현재 정책이 명확하다면, 이를 검증하는 negative test가 훨씬 안전합니다.
@Test
@DisplayName("정책상 음수 보관 일수는 허용하지 않는다")
void shouldRejectNegativeRetentionDays() { ... }
@Disabled는 향후 재검토 가능성이 높고 구현 골격을 보존할 가치가 있는 제한적 상황에서만 쓰고, 이때는 날짜·결정 주체·재검토 조건을 명시합니다. “검토했지만 기각했다”는 맥락 자체는 테스트보다 ADR이나 Jira, 커밋 메시지가 더 적합한 자리일 수 있습니다.
4. AI가 쓴 테스트는 “명세 파트”를 인간이 확정한다
여기까지는 “테스트를 어떻게 쓸 것인가”의 문제였습니다. 한 단계 더 나아가면, AI가 작성한 테스트 자체를 어떻게 검증할 것인가의 문제가 남습니다.
구현(given/when/then의 세부)은 AI가 작성하더라도, @DisplayName과 테스트 메서드 이름은 인간이 먼저 쓰거나 마지막에 검토해 확정합니다. 테스트 이름은 “우리가 이 동작을 왜 이렇게 결정했는지”를 담는 자리이므로, 여기를 AI에게 위임하면 맥락이 아니라 현재 구현의 기계적 서술만 남습니다.
실무적으로는 다음 순서가 안전합니다.
- 검증할 케이스 목록과 @DisplayName 문구를 인간이 먼저 정리
- AI에게 그 명세 아래에 구현만 작성하도록 요청
- 리뷰 시 테스트 diff를 소스 diff보다 먼저 검토
5. AI가 쓴 테스트는 다른 AI로 교차 검증한다
같은 AI가 테스트를 작성하고 그 테스트를 다시 읽으면, 자신의 가정을 자신이 강화하는 확증 편향에 빠집니다. 교차 검증이 이 편향을 완화하지만, 검증 대상이 얼마나 다른 AI 인가에 따라 효과가 달라집니다.
| 검증 주체 | 효과 | 이유 |
|---|---|---|
| 다른 제품의 모델 (예: Claude ↔ Gemini) | 큼 | 추론 습관과 기본 가정이 다를 가능성이 큼 |
| 같은 제품의 다른 세대 (예: Claude 4.6 ↔ 4.7) | 중간 | 일부 편향은 공유하지만 출력 경향은 달라질 수 있음 |
| 같은 모델의 새 세션 | 제한적 | 대화 누적 편향은 줄지만 모델 고유 편향은 남음 |
| 같은 세션 내 “다시 검토해줘” | 낮음 | 기존 맥락과 가정에 계속 영향받음 |
현실적으로는 완전히 다른 제품의 모델을 쓰는 것이 관점 다양성을 확보하기 쉽습니다. 그게 어려운 상황이라면, 최소한 새 세션이라도 돌리는 것이 안 하는 것보다 낫습니다. 단, AI 교차 검증이 인간 리뷰를 대체하지는 않습니다. AI 교차 검증은 리뷰어를 하나 더 추가하는 것이지, 책임을 넘기는 방식은 아닙니다.
6. 교차 검증이 어려울 때: 역 추론 루프
보안이나 비용 문제로 단일 모델만 써야 할 때 쓸 수 있는 대안입니다. 테스트 코드를 AI에게 주고 “이 테스트가 담고 있는 비즈니스 규칙을 역으로 추론해봐”라고 시킵니다. AI가 내가 원래 의도한 “왜”를 맞히지 못하면, 그 테스트는 맥락 전달에 실패한 것입니다. 즉, 역 추론 성공 여부가 테스트의 맥락 전달력을 자가 측정하는 지표가 됩니다.
이 글 자체가 교차 검증의 예시입니다. Claude로 초안을 작성하고 다른 제품의 모델들에게 검토를 받는 과정에서, “AI가 쓴 테스트를 AI가 읽는 오염된 루프”나 “@Disabled 박제의 위험성”처럼 단일 AI로는 떠올리기 어려운 지점이 드러났고, 그 지점들이 실천 항목으로 편입되었습니다.
한계
- 왜 만들었는지는 담지 못한다
테스트 코드는 “어떻게 동작해야 하는지”는 담지만, “왜 이 기능을 만들었는지”(PO/PM 협의 맥락)는 담지 못합니다. 이 영역은 여전히 문서와 Jira 이슈의 몫입니다. - 박제된 결정에도 유효기간이 있다
테스트가 “결정의 흔적”을 담는다는 건, 그 결정이 뒤집혔을 때 테스트가 과거의 판단에 AI를 묶어둘 수 있다는 뜻이기도 합니다. 정책이 바뀌었는데 테스트와 구현이 둘 다 구 정책에 묶여 있으면 테스트는 태평하게 통과하고, AI는 그걸 현재 결정으로 오인합니다. 정기적으로 “현재 소스 코드나 현 정책과 충돌하는 테스트 명세가 있는지”를 AI에게 검토받는 리팩토링 세션이 필요합니다. - 함수 단위를 넘어서는 결정은 담기 어렵다
“Service는 Controller를 참조하지 않는다” 같은 구조적 결정은 개별 테스트로는 담기 어렵습니다. 이 영역은 ArchUnit 같은 아키텍처 테스트 도구가 별도로 필요하며, 기회가 된다면 별도 주제로 다뤄볼 예정입니다. - 과잉 명세의 위험 — 테스트 코드의 원래 맹점
의도를 담으려고 테스트를 지나치게 촘촘하게 작성하면 작은 로직 수정에도 다수의 테스트가 깨지는 경직성이 생깁니다. 이 문제는 AI 시대에 새로 생긴 것이 아니라 테스트 코드가 원래부터 가지고 있던 맹점입니다. 다만 “의도 전달”에 과몰입하면 악화될 수 있으니, 맥락 전달력과 유연성 사이의 균형은 여전히 각자의 판단 몫입니다.
결론
AI 시대에 테스트를 안 써도 되는 시대가 온 것이 아니라, 테스트를 더 잘 써야 하는 이유가 하나 더 늘어난 것입니다. 이제 테스트 이름 하나, @DisplayName 한 줄이 인간뿐 아니라 미래 세션의 AI에게도 읽힙니다. 그걸 의식하고 작성하면 테스트는 검증 도구를 넘어 의도를 전달하는 프로토콜이 됩니다. “이 코드는 이런 경우를 이렇게 처리하기로 했다”를 가장 정확하게 표현하는 수단이 여전히 테스트 코드입니다.
테스트 코드는 미래 세션의 AI가 읽는, 실행 가능한 프롬프트다.