2장을 떠올려 보자. 그때 우리는 코드를 한 줄도 짜지 않은 채, AI와 함께 배우는 태도 하나를 약속했다. “AI는 만들고, 검증은 끝까지 내가 한다.” 당시엔 그 약속이 다소 추상적으로 들렸을지도 모른다. 검증할 눈이 아직 없었으니까. jakarta인지 javax인지 구별할 줄도, SQL 로그를 읽을 줄도, 트랜잭션 경계가 어디서 어긋나는지도 몰랐던 시절의 약속이었다.
그런데 지금의 우리는 다르다. 3장에서 첫 엔드포인트를 손으로 띄웠고, 5장에서 DTO와 검증과 전역 예외로 견고한 API를 빚었고, 6~8장에서 JPA의 마법과 그 누수를 SQL 로그로 들여다봤고, 9~11장에서 보안의 세 갈래 길을 걸었고, 12장에서 서버가 그리는 화면까지 붙여 봤다. 무엇보다 5장과 8장에서 테스트로 동작을 증명하는 법을 익혔다. 2장에서 텅 비어 있던 그 “검증의 눈”이 열두 장을 지나며 또렷해졌다.
그렇다면 이제 진짜 질문을 던질 차례다. 백엔드를 이제 좀 아는 내가, AI와 함께 실제 기능 하나를 처음부터 끝까지 어떻게 만들까? 이 장은 이 책의 클라이맥스다. 지금까지 쌓은 모든 검증 능력을 한 흐름으로 꿰어, TaskBoard에 새 기능 하나를 AI와 함께 완주해 본다. 막연한 방법론이 아니라, 어깨너머로 따라 할 수 있는 실전 한 판이다. 함께 해 보자.
곧장 짜지 말자 — 스펙·설계·구현·테스트라는 네 박자
AI에게 기능을 맡길 때 가장 흔하게 저지르는 실수가 무엇일까? “TaskBoard에 검색 기능 만들어줘”라고 한 줄 던지고 곧장 쏟아지는 코드를 받아 드는 것이다. 익숙한 풍경이다. 그리고 십중팔구 뒷맛이 찜찜하다. AI가 멋대로 가정한 명세는 내 생각과 어긋나 있다. 기존 TaskBoard 코드의 컨벤션을 무시한 채 따로 노는 코드가 나오고, 어딘가에는 javax가 슬쩍 끼어 있다. 결국 고치는 데 처음부터 짜는 것보다 더 오래 걸린다. 초난감한 상황이다.
“TaskBoard에 검색 기능 만들어줘” 한 줄을 던지고 받은 코드가 찜찜하게 끝나는 가장 큰 이유는?
그래서 현장에 자리 잡은 흐름이 스펙 → 설계 → 구현 → 테스트라는 네 박자다. 한 단계씩 풀어 보자.
- 스펙(spec): 무엇을 만들 것인가. 입력·출력·동작·예외 케이스를 글로 못 박는다. 코드가 아니라 말로 먼저 합의하는 단계다.
- 설계(design): 어떻게 만들 것인가. 어느 계층에 무엇을 추가할지, 기존 코드의 어디에 어떻게 붙일지 큰 그림을 잡는다.
- 구현(implementation): 합의된 설계대로 코드를 쓴다. 여기서 비로소 AI가 코드를 쏟아낸다.
- 테스트(test): 만든 것이 스펙대로 동작하는지 코드로 증명한다. 5장·8장에서 익힌 그 테스트다.
눈치챘는가? 코드는 네 박자 중 세 번째에 가서야 등장한다. 입문자일수록 첫 박자부터 코드로 직행하고 싶은 유혹이 크다. 하지만 앞의 두 박자(스펙·설계)에 시간을 쓸수록, 뒤의 두 박자가 거짓말처럼 매끄러워진다. 급할수록 돌아가라는 옛말이 AI 시대에 오히려 더 잘 들어맞는다. 기억해 두자. AI에게 곧장 코드를 시키지 말고, 먼저 말로 합의하자.
네 장의 카드가 뒤섞여 있습니다. ▲▼로 순서를 바꾸고 확인해 보세요. 틀린 순서는 실제로 무엇이 무너지는지 콘솔이 알려 줍니다.
- 1구현 AI가 쏟아낸 코드를 jakarta·SQL 로그·DTO·트랜잭션 네 렌즈로 검증한다
- 2테스트 스펙의 각 줄을
@DataJpaTest·@WebMvcTest로 못 박는다 - 3스펙 “Ask me questions”로 태그 개수·검색 조건·페이징을 글로 합의한다
- 4설계 Plan Mode에 버전·컨벤션·스펙을 주고 어떤 파일을 바꿀지 계획부터 받는다
- 해볼 것
- 구현을 맨 앞에 둔 채 확인해 무엇이 무너지는지 보기
- 책의 네 박자 순서로 맞추기
- 테스트를 구현 앞에 둔 변형(TDD)도 확인해 보기
도구를 손에 쥐자 — Cursor와 Claude Code
본격적으로 들어가기 전에, 이번 실전에서 쓸 도구를 짧게 정리하자. 2장에서 우리는 AI 페어코딩 도구를 크게 두 갈래로 나눠 뒀다. 짧고 즉각적인 손질에 강한 IDE 내장형(Cursor)과, 길고 구조적인 작업에 강한 터미널 기반 도구(Claude Code 등)다. 이번 장에서 둘을 실제로 함께 부려 본다.
Cursor는 코드를 읽고 손보는 흐름 한복판에서 쓴다. 생성된 코드 한 줄을 다듬거나, “이 부분 무슨 뜻이야?”를 그 자리에서 물어보는 데 제격이다. Claude Code는 Anthropic이 만든 터미널 기반 코딩 에이전트로, 여러 파일에 걸친 긴 작업, 이를테면 기존 코드 위에 기능 하나를 처음부터 끝까지 얹는 작업에 강하다. 도구의 구체적인 기능과 UX는 빠르게 바뀌니, 화면과 동작은 늘 공식 문서를 함께 확인하자.
특히 Claude Code에는 곧장 코드를 짜지 않고 먼저 계획부터 세우게 하는 모드가 있다. 흔히 Plan Mode라고 부른다. 현재 버전에서는 단축키로 이 모드에 드나들 수 있다. 다만 키 조합과 동작은 도구가 자주 손보는 부분이니, 진입 방법은 공식 문서에서 확인하는 편이 안전하다. 이 모드에서 AI는 곧바로 파일을 고치는 대신, 기존 코드베이스를 둘러보고 “이렇게 만들겠다”는 계획을 먼저 내놓는다. 우리가 그 계획을 검토하고 승인한 뒤에야 실제 구현으로 넘어간다. 방금 이야기한 네 박자 중 스펙·설계 단계를 도구가 떠받쳐 주는 셈이다. 코드로 직행하려는 유혹을 도구 차원에서 막아 주니, 입문자에게 특히 든든하다.
거칠게 정리하면 이렇다. 큰 기능의 스펙을 다듬고 여러 파일에 걸쳐 얹는 장기 작업은 Claude Code로, 그 결과물을 코드 한복판에서 다듬고 되묻는 잔손질은 Cursor로. 물론 칼같이 나뉘지는 않는다. 익숙해지면 둘 사이를 자연스럽게 오가게 된다.
첫 박자: 스펙을 합의하자 — “Ask me questions”의 힘
이제 실제로 기능 하나를 골라 완주해 보자. TaskBoard에 태그와 검색 기능을 더하기로 한다. 할 일에 태그를 붙이고, 태그나 제목으로 할 일을 찾는 기능이다. 충분히 현실적이면서 우리가 익힌 검증 포인트를 두루 건드리는 좋은 소재다.
자, 여기서 절대 하지 말아야 할 첫 수가 있다. “TaskBoard에 태그랑 검색 기능 만들어줘”라고 던지는 것이다. 대신 스펙부터 합의한다. 그런데 스펙을 혼자 머리로 다 짜내기도 막막하다. 이때 2장에서 배운 비장의 한 수를 꺼낸다. 프롬프트 끝에 “Ask me questions”를 붙인다.
TaskBoard에 태그와 검색 기능을 추가하려고 해. 곧장 구현하지 말고, 스펙을 합의하기 위해 너가 먼저 나에게 궁금한 점을 질문해줘. (Ask me questions before implementing.)
이렇게 던지면 AI는 코드를 쏟아내는 대신 이런 질문을 되던진다.
- 태그는 할 일 하나에 여러 개 붙나요, 하나만 붙나요?
- 태그는 자유 입력인가요, 미리 정의된 목록에서 고르나요?
- 검색은 제목만 대상인가요, 태그도 함께인가요? 부분 일치인가요 정확히 일치인가요?
- 검색 결과에 페이징이 필요한가요?
이 질문들을 보는 순간, 머릿속에서 흐릿하던 명세가 또렷해진다. “아, 태그는 여러 개 붙을 수 있어야겠네”, “검색은 제목 부분 일치 + 태그 정확히 일치로 하자”, “결과가 많아질 테니 페이징은 필요하겠다”처럼 답하다 보면 스펙이 저절로 빚어진다. 모호함을 끌어안은 채 만들어진 코드보다, 한 번 명료해진 뒤 만들어진 코드가 훨씬 낫다. 2장에서 했던 이 말이 여기서 실전으로 살아난다.
답을 정리해 스펙을 이렇게 못 박았다고 하자.
태그·검색 기능 스펙 - 할 일(Task) 하나에 태그를 0개 이상 붙일 수 있다. (다대다) - 태그는 자유 입력 문자열. 같은 이름의 태그는 재사용한다. - 검색 조건: 제목 부분 일치(선택), 태그 이름 정확히 일치(선택). 둘 다 비면 전체 조회. - 결과는 페이징한다(한 페이지 20개). - 응답은 기존
TaskResponse에 태그 목록을 더한 모양.
코드 한 줄 없이도 만들 것의 절반을 이미 손에 쥐었다. 그리고 이 스펙 자체가 다음 단계에서 AI에게 줄 가장 강력한 컨텍스트가 된다.
둘째 박자: 설계 — AI에게 우리 집 사정을 알려 주자
스펙을 붙여 줬는데도 AI가 우리 코드와 따로 노는 코드를 내놓는다면, 빠진 컨텍스트는?
그래서 설계 단계에서 AI에게 우리 집 사정을 명시적으로 건넨다. 무엇을 알려 줘야 할까?
첫째, 버전과 네임스페이스. 2장부터 줄곧 강조한 그것이다. “Spring Boot 3.x, Spring Framework 6.x, jakarta.* 네임스페이스, Java 17/21” 같은 조건을 프롬프트에 못 박는다. 이 한 줄이 신선도 함정에 빠질 확률을 크게 낮춘다. Claude Code처럼 프로젝트 전체를 읽는 도구라면 기존 build.gradle과 import를 직접 보고 버전을 파악하기도 하지만, 그래도 한 번 더 못 박아 주는 편이 안전하다.
둘째, 기존 코드의 구조와 컨벤션. “컨트롤러-서비스-리포지토리 3계층으로 나뉘어 있고, 요청·응답 DTO는 record로 쓰고, 검증은 DTO에서 @Valid로, 예외는 @RestControllerAdvice에서 전역 처리한다”는 우리 집 규칙을 알려 준다. Claude Code 같은 터미널 도구는 기존 TaskController·TaskService·TaskRepository를 직접 열어 보고 패턴을 따라가므로, “기존 코드 스타일을 따라줘”라는 한마디가 잘 먹힌다.
셋째, 스펙 그 자체. 방금 합의한 스펙을 그대로 붙여 준다.
이걸 종합해 Plan Mode로 이렇게 부탁한다.
첨부한 스펙대로 태그·검색 기능을 추가하려고 해. 기존 TaskBoard 코드(컨트롤러-서비스-리포지토리 3계층, record DTO, jakarta 네임스페이스, Spring Boot 3.x)의 컨벤션을 그대로 따라줘. 곧장 구현하지 말고, 어떤 파일을 어떻게 바꿀지 계획부터 보여줘.
그러면 AI는 대략 이런 설계를 내놓는다. Task와 Tag 사이에 다대다 연관관계를 추가하고, 검색을 위한 리포지토리 메서드를 만들고, TaskResponse에 태그 목록을 더하고, 컨트롤러에 검색 엔드포인트를 추가한다는 그림이다. 여기서 우리 몫은 이 계획을 검토하는 일이다. 다대다를 어떻게 풀어낼지(중간 테이블), 검색 쿼리가 N+1을 부르진 않을지, 페이징은 어떻게 얹을지 따질 때 7장과 8장에서 익힌 감각이 작동한다. 계획 단계에서 미심쩍은 점을 미리 짚어 두면, 구현 단계에서 헛걸음이 확 줄어든다.
ch13-1 태그 붙이기 (예시 완주본 1)명령과 기대 출력 펼치기
실행해 보기
./gradlew bootRun시작 로그에서 중간 테이블을 찾는다.
create table task_tags (
tag_id bigint not null,
task_id bigint not null,
primary key (tag_id, task_id)
)
TOKEN=$(curl -s -X POST http://localhost:8080/api/auth/login \
-H "Content-Type: application/json" \
-d '{"username": "ara", "password": "pass1234"}' | sed 's/.*"token":"\([^"]*\)".*/\1/')
curl -H "Authorization: Bearer $TOKEN" http://localhost:8080/api/tasks
# [{"id":1,"title":"JPA 익히기","description":"7장","done":false,"tags":["공부"]}, …]
curl -H "Authorization: Bearer $TOKEN" -X POST http://localhost:8080/api/tasks \
-H "Content-Type: application/json" \
-d '{"title": "서버 장애 대응", "tags": ["긴급", "운영", "운영"]}'
# {"id":6,"title":"서버 장애 대응","description":null,"done":false,"tags":["긴급","운영"]}긴급은 초기 데이터의 태그를 재사용했고(INSERT 없음), 운영은 새로 만들었다. 중복된 운영은 한 번만 붙었다.
검증 ②: SQL 로그로 N+1 보기
GET /api/tasks 직후의 SQL을 본다. 할 일 1번, 태그 1번이다.
select … from task t1_0
select t1_0.task_id, t1_1.id, t1_1.name from task_tags t1_0 join tag t1_1 on t1_1.id=t1_0.tag_id where t1_0.task_id in (?, ?, …)
8장의 default_batch_fetch_size가 이미 걸려 있어서다. application.yml에서 그 줄을 지우고 다시 보면 원서 5절의 그림이 나온다.
select … from task t1_0
select … from task_tags t1_0 join tag t1_1 on t1_1.id=t1_0.tag_id where t1_0.task_id=?
select … from task_tags t1_0 join tag t1_1 on t1_1.id=t1_0.tag_id where t1_0.task_id=?
… (할 일 수만큼)
다대다라 중간 테이블을 JOIN하는 모양이다(13장 5절의 원문 보정 상자). 확인했으면 git checkout -- .으로 되돌린다.
검증 ④: 트랜잭션 경계
TaskService.findAll·findById에 @Transactional(readOnly = true)를 붙였다. TaskResponse.from이 태그(지연 로딩)를 읽기 때문이다.
findAll의 그 줄을 지우고 GET /api/tasks를 보내 보자. 7장에서 OSIV를 꺼 두었으니 500이다.
LazyInitializationException: failed to lazily initialize a collection of role: com.example.taskboard.Task.tags: could not initialize proxy - no Session
8장 보정 상자가 말한 컬렉션 쪽 메시지가 바로 이것이다. 필요한 데이터는 트랜잭션 안에서 DTO로 바꿔 내보낸다. 확인했으면 git checkout -- .으로 되돌린다.
셋째 박자: 구현 — 그리고 곧바로 시작되는 검증
계획을 승인하면 AI가 실제 코드를 쏟아낸다. 여기서부터 이 책 열두 장이 진가를 발휘한다. AI가 코드를 내놓는 그 자리에서, 우리는 1~12장의 검증 능력을 총동원해 한 줄씩 따져 묻는다. 막연한 불안이 아니라, 네 개의 또렷한 렌즈로 말이다. 하나씩 보자.
검증 ①: 버전과 네임스페이스 — jakarta인가 javax인가
AI가 Tag 엔티티와 연관관계 매핑을 만들어 줬다. 가장 먼저 할 일은 2장·6장에서 다진 그 습관, import 줄을 훑는 것이다.
// AI가 준 코드 — 여기서 멈추고 import부터 본다
import jakarta.persistence.Entity;
import jakarta.persistence.ManyToMany;
import jakarta.persistence.Table;
jakarta.persistence다. javax가 아니다. 통과다. 만약 여기에 javax.persistence가 섞여 있었다면? 2장에서 본 그대로, AI가 시간이 멈춘 평균값을 내민 셈이다. 잡아내서 고치면 된다. 보안 코드를 시켰다면 같은 눈으로 WebSecurityConfigurerAdapter, authorizeRequests, antMatchers 같은 구버전 패턴이 끼지 않았는지 본다. 이런 구버전 패턴을 한자리에 모은 부록 B 신선도 체크리스트를 검증할 때 곁에 펴 두면 든든하다.
검증 ②: SQL 로그 — 검색 쿼리가 N+1을 부르지 않는가
태그가 달린 할 일을 검색해 목록으로 돌려주는 기능이다. 8장을 지나온 우리 머릿속에 빨간 불이 켜진다. 다대다 연관관계 + 목록 조회 + 페이징 — N+1과 페이징 함정이 동시에 도사리는 그 조합 아닌가?
그러니 AI가 검색 리포지토리를 주면, 믿고 넘어가기 전에 8장에서 하던 대로 확인한다. show-sql을 켜 둔 채 검색을 한 번 실행하고 콘솔을 본다.
Task–Tag 다대다(기본값 LAZY) 매핑에서 검색 결과 할 일 3건을 받아 태그까지 응답에 담으면, SQL은 몇 번 나갈까?
원문 보정 · 앱 보충위 로그는
tag테이블에task_id가 있는 일대다 모양이다. 스펙대로 다대다(@ManyToMany)로 매핑하면 태그는 중간 테이블을 거쳐 오므로, 실제 Hibernate 6 로그는 대략select t1_0.task_id,t1_1.id,t1_1.name from task_tags t1_0 join tag t1_1 on t1_1.id=t1_0.tags_id where t1_0.task_id=?같은 모양이 할 일마다 반복된다(테이블·칼럼 이름은 매핑에 따라 다르다). 모양은 달라도 “1번 + N번”이라는 요점은 같다.
발견했으면 AI에게 되돌려 준다. “이 검색 쿼리가 N+1을 유발해. 페이징도 해야 하니 그 점을 고려해서 고쳐줘.” 8장을 떠올려 보자. 페이징이 끼면 JOIN FETCH는 행을 부풀려 탈이 나니, 배치 페치가 마음 편한 답이었다. AI가 그중 무엇을 골랐는지 보고, 우리 상황에 맞는지 판단하는 게 우리 몫이다. 페이징 화면에 컬렉션 JOIN FETCH를 들고 왔다면, “그건 페이징과 사이가 나쁘다”고 짚어 줄 수 있어야 한다. 이제 우리는 그걸 안다.
ch13-2 제목·태그로 검색하기 (예시 완주본 2)명령과 기대 출력 펼치기
실행해 보기
./gradlew bootRun
TOKEN=$(curl -s -X POST http://localhost:8080/api/auth/login \
-H "Content-Type: application/json" \
-d '{"username": "ara", "password": "pass1234"}' | sed 's/.*"token":"\([^"]*\)".*/\1/')
curl -G -H "Authorization: Bearer $TOKEN" http://localhost:8080/api/tasks/search --data-urlencode "tag=긴급"{"content":[{"id":2,"title":"N+1 잡기",…,"tags":["공부","긴급"]},{"id":5,"title":"코드 리뷰",…,"tags":["긴급"]}],
"page":{"size":20,"number":0,"totalElements":2,"totalPages":1}}
조건을 바꿔 가며 보낸다(-G와 --data-urlencode는 한글을 URL에 안전하게 싣는다).
| 조건 | 결과 |
|---|---|
title=N+1 | N+1 잡기 |
title=리 | 코드 리뷰 (부분 일치) |
| 조건 없음 | 5개 전부 |
tag=없음 | 빈 목록 |
size=2&page=1 | 러닝, 스트레칭 (totalPages 3) |
두 번째 페이지(size=2&page=1)를 요청한 직후의 SQL은 세 번이다.
select … from task t1_0 where (? is null or t1_0.title like ('%'||?||'%') escape '') and (? is null or exists(select 1 from task_tags t2_0 join tag t2_1 on t2_1.id=t2_0.tag_id where t2_1.name=? and t1_0.id=t2_0.task_id)) order by t1_0.id offset ? rows fetch first ? rows only
select count(t1_0.id) from task t1_0 where …
select t1_0.task_id, t1_1.id, t1_1.name from task_tags t1_0 join tag t1_1 on t1_1.id=t1_0.tag_id where t1_0.task_id in (?, ?, …)
- 이 페이지의 할 일.
offset … fetch first로 SQL에서 잘랐다. 8장 ch08-3의 "메모리에서 자르기" 경고가 없다 - 전체 개수.
Page라서totalElements를 위해 Spring Data가 count 쿼리를 만들었다 - 이 페이지 할 일들의 태그, 배치 페치로 한 번
tag=긴급처럼 첫 페이지에 결과가 다 들어가면 두 번으로 줄어든다. 첫 페이지라 offset이 빠지고(fetch first ? rows only만), 결과가 한 페이지 크기보다 적으면 Spring Data가 전체 개수를 이미 알아서 count 쿼리를 건너뛴다.
토큰 없이 보내면 10장 설정대로 401이다. /api/tasks/**에 속하기 때문이다.
검증 ③: DTO와 검증 — 안과 밖의 칸막이가 제대로 섰는가
검색 결과를 돌려줄 때, AI가 Task 엔티티를 그대로 응답에 실어 보내고 있지는 않은가? 5장에서 세운 그 칸막이를 떠올리자. 엔티티를 그대로 노출하면 내부 사정이 새어 나간다. 게다가 방금 본 다대다 연관관계를 엔티티째 직렬화하다 보면 트랜잭션 밖에서 지연 로딩을 건드려 LazyInitializationException까지 터질 수 있다. 그러니 응답은 반드시 TaskResponse 같은 DTO로 빚어 내보내야 한다.
검색 조건 DTO도 본다. 제목 길이 제한이나 태그 형식 같은 입력 검증이 필요하다면, 5장에서 했듯 DTO 계층에 @Valid로 걸려 있는지 확인한다. AI가 검증을 빠뜨렸거나 엉뚱한 곳(엔티티)에 걸어 뒀다면 바로잡는다. 검증과 노출의 경계는 엔티티가 아니라 DTO에서 긋는다. 5장의 이 원칙은 새 기능에도 똑같이 적용된다.
검증 ④: 트랜잭션 경계 — 어디서 열리고 닫히는가
AI가 만든 서비스 메서드에 @Transactional이 적절히 붙어 있는가? 조회 전용이라면 @Transactional(readOnly = true)가 어울린다. 그리고 8장에서 본 고약한 함정, 곧 같은 클래스 안에서 메서드를 직접 부르면 @Transactional이 무력화되는 self-invocation에 걸릴 구조는 아닌지도 살핀다. 더 중요한 건 트랜잭션 경계와 지연 로딩의 관계다. 검색 결과의 태그를 트랜잭션이 닫힌 뒤에 건드리는 흐름이라면 LazyInitializationException이 기다린다. 필요한 데이터는 트랜잭션 안에서 다 챙겨 DTO로 변환해 두자. 8장에서 권한 그 길이다.
이 네 가지 검증을 거치면 AI가 준 코드는 더 이상 “그럴듯해 보이지만 미심쩍은 코드”가 아니다. 우리가 한 줄씩 따져 옳다고 인정한 코드다. 2장에서 텅 비어 있던 검증의 눈이 여기서 네 개의 또렷한 렌즈로 완성된 셈이다.
넷째 박자: 테스트 — 동작을 코드로 못 박자
이제 마지막 박자다. 그런데 여기서 잠깐 멈추고 생각해 보자. 2장에서 AI에게 “테스트도 짜줘”라고만 했다면 어땠을까? 정작 테스트를 본 적도 없으니 그 결과물이 옳은지 가릴 수 없었을 것이다. “테스트를 본 적도 없이 테스트하라”는 모순이다. 다행히 우리는 그렇지 않다. 5장에서 @WebMvcTest와 MockMvc(프런트의 supertest와 판박이였다)를, 8장에서 @DataJpaTest를 이미 손으로 다뤄 봤다. 이 두 기둥이 마지막 박자를 떠받친다.
AI에게 검색 기능 테스트를 부탁한다. 테스트 케이스 목록은 어디서 가져오는 게 맞을까?
먼저 검색 리포지토리를 8장에서 익힌 @DataJpaTest로 검증한다.
@DataJpaTest
class TaskSearchRepositoryTest {
@Autowired
private TaskRepository taskRepository;
@Test
void 태그로_할일을_검색한다() {
// given — "긴급" 태그가 달린 할 일과 안 달린 할 일을 섞어 저장
// (태그·할 일 저장 코드는 스펙대로 구성)
// when
Page<Task> result = taskRepository.search(null, "긴급", PageRequest.of(0, 20));
// then — "긴급" 태그가 달린 것만 나와야 한다
assertThat(result.getContent())
.extracting(Task::getTitle)
.containsExactly("서버 장애 대응");
}
}
이 테스트의 진짜 값은 단언문 너머에 있다고 8장에서 말했다. 테스트를 돌리면 콘솔에 실제 나가는 SQL이 찍힌다. 검증 ②에서 N+1을 잡아 고친 코드라면, 여기서 쿼리가 한두 번으로 줄어든 게 눈으로 보인다. 고치기 전후의 SQL 변화를 추측이 아니라 로그로 확인할 수 있으니, N+1을 막았다는 판단을 테스트가 증거로 뒷받침해 주는 셈이다.
다음으로 검색 엔드포인트 전체를 5장에서 익힌 @WebMvcTest로 검증한다.
@WebMvcTest(TaskController.class)
class TaskSearchControllerTest {
@Autowired
private MockMvc mockMvc;
// (TaskService는 5장에서처럼 가짜로 채워준다)
@Test
void 검색어로_조회하면_200과_결과를_돌려준다() throws Exception {
mockMvc.perform(get("/api/tasks/search")
.param("title", "서버")
.param("page", "0"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.content").isArray());
}
}
5장의 그 테스트와 나란히 두고 보면 구조가 똑같다. 요청을 던지고, 상태코드를 단언하고, 응답 바디를 들여다본다. 우리가 하는 생각도 그때와 같다. “이런 요청을 던지면, 이런 응답이 와야 한다.” AI가 짜 준 테스트라 해도 우리는 그게 스펙의 어느 줄을 지키는지 읽어 낼 수 있고, 빠진 케이스가 있으면 채워 넣을 수 있다. 테스트를 읽고 쓰고 판단하는 눈을 5장·8장이 미리 길러 준 덕분이다.
원문 보정 · 앱 보충이 테스트를 지금의 TaskBoard에서 그대로 돌리면 초록불이 아니라 빨간불을 볼 수 있다. 9장부터 프로젝트에 Spring Security가 들어 있어서
@WebMvcTest가 보안 필터체인도 함께 올리고, 인증 정보 없는 요청은 200 대신 401(설정에 따라 403이나 로그인 페이지 리다이렉트)로 막힌다. 테스트 메서드에spring-security-test의@WithMockUser를 붙여 인증된 사용자로 요청하자(의존성이 없다면testImplementation 'org.springframework.security:spring-security-test'). 그리고 주석의 “가짜TaskService”는 Spring Boot 3.4부터@MockBean대신@MockitoBean으로 채운다.@MockBean은 deprecated다.
테스트가 초록불로 통과하면 기능 하나가 완주된다. 견고함은 두 눈이 아니라 코드로 증명할 때 비로소 무너지지 않는다. 5장에서 했던 이 다짐이 이번엔 AI와 함께 만든 기능 위에서 실현된다.
ch13-3 동작을 테스트로 못 박기 (예시 완주본 3)명령과 기대 출력 펼치기
실행해 보기
./gradlew testBUILD SUCCESSFUL
build/reports/tests/test/index.html을 열면 지금까지 쌓인 테스트가 모두 보인다. 5장의 MockMvc, 7·8장의 @DataJpaTest, 9·10장의 단위 테스트, 13장의 검색 테스트. 손으로 curl을 치던 확인들이 이제 명령 하나로 다시 돈다.
직접 깨뜨려 보기: @WithMockUser를 빼면
TaskSearchControllerTest의 @WithMockUser 줄을 지우고 다시 돌린다.
Status expected:<200> but was:<401>
원서 그대로 붙였다면 여기서 빨간불을 봤을 것이다. 컨트롤러가 아니라 보안 필터가 막았다. 확인했으면 git checkout -- .으로 되돌린다.
한 흐름으로 돌아보기 — 검증 능력의 총집결
방금 걸어온 길을 멀리서 한번 내려다보자. 흩어져 보이던 열두 장의 배움이 이 한 번의 기능 완주 안에서 어떻게 하나로 맞물렸는지 보인다. 스펙 단계에서 2장의 “Ask me questions”로 모호함을 걷어냈고, 설계 단계에서 버전 명시와 기존 코드 컨벤션을 컨텍스트로 건넸다. 구현 단계의 검증에서는 2·6장의 신선도 감각, 8장의 SQL 로그와 N+1, 5장의 DTO·검증, 7·8장의 트랜잭션 경계가 네 개의 렌즈로 동시에 작동했다. 그리고 테스트 단계에서 5장의 @WebMvcTest와 8장의 @DataJpaTest가 동작을 증거로 못 박았다.
다시 말해, 이 책의 모든 장은 따로 떨어진 지식이 아니라 AI가 준 코드를 검증하는 하나의 렌즈 세트였던 셈이다. 1장에서 “서버는 내가 통제할 5단계 파이프라인”이라고 멘탈 모델을 뒤집은 데서 시작해, 매 장이 그 파이프라인의 한 부분을 검증하는 눈을 길러 줬다. 그리고 이 13장에서 그 모든 눈이 한 흐름으로 합쳐졌다.
여기서 한 가지를 분명히 새기자. AI가 발전할수록 코드를 만드는 일은 점점 더 AI의 몫이 될 것이다. 그럴수록 사람의 가치는 코드를 판단하는 데로 옮겨 간다. 스펙을 명료하게 합의하는 능력과 생성된 코드의 옳고 그름을 가려내는 능력은 백엔드를 아는 사람만이 할 수 있는 일이고, 이 책이 열두 장에 걸쳐 길러 온 능력이다. AI 시대에 백엔드를 배우는 의미가 사라지기는커녕 오히려 더 또렷해지는 이유가 여기에 있다.
마무리
이번 장에서 우리는 TaskBoard에 태그·검색 기능 하나를 AI와 함께 완주했다. 곧장 코드로 직행하는 대신 스펙 → 설계 → 구현 → 테스트라는 네 박자를 따랐고, “Ask me questions”로 스펙을 명료히 합의한 뒤 버전·컨벤션·스펙을 컨텍스트로 건네 설계를 잡았다. 구현 단계에서는 신선도(jakarta), SQL 로그(N+1), DTO·검증, 트랜잭션 경계라는 네 렌즈로 AI 코드를 한 줄씩 따져 물었고, 마지막엔 @WebMvcTest·@DataJpaTest로 동작을 코드에 못 박았다. 2장에서 텅 비어 있던 검증의 눈이 이 한 흐름 안에서 또렷한 렌즈 세트로 합쳐지는 것을 직접 확인한 셈이다.
그렇다면 이제 마지막 질문이 남는다. 입문서의 끝자락에 다다른 우리는 이제 무엇을 할 수 있고, 다음 한 걸음은 어디로 내디뎌야 할까? 줄곧 3.x로 배워 온 이유, 곧 “왜 하필 3.x였는가”라는 2장의 약속도 아직 매듭짓지 못한 채 남아 있다. 다음 장에서 지금까지의 여정을 한자리에 모아 되짚고, 3.x에서 4.x로 향하는 길을 내다보며 이 책을 닫는다. 함께 마지막 한 걸음을 마저 걷자.
1. 네 박자 중 코드가 처음 등장하는 박자는?
2. 프롬프트 끝에 “Ask me questions”를 붙이는 이유는?
3. “목록 + 페이징 + 컬렉션 연관관계”에서 N+1을 잡을 때 책이 마음 편한 답으로 꼽은 것은?
4. 검색 결과를 응답으로 내보낼 때 노출의 경계를 긋는 곳은?
먼저 내 AI 도구로 직접 해 보세요. 프롬프트에 “Ask me questions”를 붙여 스펙을 다섯 줄 이내로 합의하고, Claude Code의 Plan Mode(진입 방법은 공식 문서 확인)로 바꿀 파일 목록부터 검토합니다. 그다음 원서 스펙대로 한 번 완주해 둔 본문의 단계들과 비교합니다.
git clone https://github.com/younggeun0/toby-react-to-spring-taskboard.git
cd toby-react-to-spring-taskboard
git checkout ch13-1ch13-1태그 붙이기 (예시 완주본 1)ch13-2제목·태그로 검색하기 (예시 완주본 2)ch13-3동작을 테스트로 못 박기 (예시 완주본 3)