Skip to main content
설명만 읽을 때는 알겠는데, 빈 폴더에서 첫 API를 만들려고 하면 갑자기 손이 멈춰요.
지금까지는 Spring Boot가 왜 필요한지, main 메서드가 어떻게 실행 중인 앱이 되는지, 빈(bean)이 어떻게 만들어지는지, 웹 요청이 Spring MVC 안에서 어떤 순서로 지나가는지, JSON 계약이 왜 중요한지까지 봤어요. 그런데 실제 프로젝트를 시작하면 질문이 바뀌어요.
“프로젝트 생성 명령은 뭘 써야 하지?"
"생성된 파일 중 어디부터 봐야 하지?"
"컨트롤러부터 만들까요, 테스트부터 만들까요?"
"정말 서버가 뜨고 HTTP 응답이 나가는지 어디서 확인하죠?”
오늘은 작은 Todo API를 처음부터 만들어볼게요. 완성된 구조를 먼저 던져두고 설명하지 않고, 프로젝트 목표 → 프로젝트 초기화 → 생성된 파일 확인 → API 코드 작성 → 테스트 → 실행 확인 순서로 갈 거예요.
실습 코드는 실습 프로젝트 저장소main 브랜치, todo-api-first-commit 태그를 기준으로 확인할 수 있어요.이 글의 실행 기준은 Spring Boot 4.0.7, Java 21, Gradle wrapper예요. 그래서 명령도 전역 gradle이 아니라 ./gradlew를 사용해요.

오늘 만들 프로젝트의 목표부터 잡아볼게요

처음부터 “실무형 Todo 서비스”를 만들지는 않을 거예요. 인증, DB, pagination, OpenAPI, Docker까지 한 번에 넣으면 첫 커밋이 아니라 작은 프로젝트 하나가 되어버려요. 오늘 목표는 딱 이 정도예요. 처음부터 DB를 붙이지 않는 이유는 단순해요. 오늘 보고 싶은 핵심은 데이터베이스가 아니라 Spring MVC가 HTTP 요청을 Java 코드로 넘기고, Java 응답을 JSON으로 돌려주는 첫 흐름이에요. 이 흐름의 핵심은 “먼저 프로젝트가 서 있는지 확인하고, 그다음 API를 얹는다”는 점이에요. 생성 직후부터 테스트가 통과해야 나중에 실패가 생겼을 때 우리가 추가한 코드 쪽을 의심할 수 있어요.

Spring CLI로 프로젝트를 만들어요

먼저 Spring CLI로 프로젝트를 생성해요.
명령이 끝나면 todo-api 프로젝트 폴더가 생겨요. 그 안으로 들어갑니다.
여기서 옵션을 하나씩 보면 이런 뜻이에요.
Spring Initializr는 Gradle Groovy와 Gradle Kotlin 프로젝트를 모두 만들 수 있어요. 그래서 --build=gradle만 쓰면 환경에 따라 더 구체적인 타입을 고르라는 메시지를 볼 수 있어요. 이 글에서는 독자가 같은 결과를 보도록 --type=gradle-project를 명시해요.

생성된 프로젝트에서 먼저 볼 파일은 많지 않아요

생성 직후에는 파일이 꽤 많아 보여요. 하지만 처음에는 아래 정도만 보면 충분해요. build.gradle의 중요한 부분은 이렇게 생겼어요.
여기서 spring-boot-starter-webmvc가 Spring MVC, 내장 Tomcat, HTTP message conversion을 준비해요. spring-boot-starter-validation@NotBlank, @Size 같은 Jakarta Bean Validation을 연결해요. 생성 직후 테스트도 먼저 돌려볼게요.
실제로 확인한 결과는 이렇게 끝났어요.
아직 Todo API는 하나도 만들지 않았는데 테스트가 통과했죠. 이건 “프로젝트 생성 자체는 정상”이라는 기준점이에요.

이제 API 계약을 먼저 정해요

코드를 만들기 전에 오늘의 HTTP 계약을 먼저 잡아둘게요. 생성 요청 JSON은 이렇게 받을 거예요.
응답 JSON은 이렇게 생겼으면 해요.
여기서 중요한 건 createdAt 값 자체가 아니에요. 서버가 Todo를 만들 때 생성 시각을 넣고, JSON 응답으로 직렬화한다는 점이에요.

소스 코드는 DTO와 TodoItem부터 만들어요

실제로 소스를 짤 때는 controller부터 만들면 바로 TodoService가 없다는 에러를 만나기 쉬워요. 그래서 먼저 가장 바깥 계약인 요청 DTO, 응답 DTO부터 만들고, 응답 DTO가 필요로 하는 내부 상태 객체 TodoItem도 같은 단계에서 만들게요. 지금 만들 파일은 여기예요.
todo-api
src
main
java
me.nvim.blog.todo
TodoApiApplication.java
todo
+ CreateTodoRequest.java
+ TodoItem.java
+ TodoResponse.java
src/main/java/me/nvim/blog/todo/todo/CreateTodoRequest.java를 만들어요. 클라이언트가 보내는 요청 모양이에요.
@NotBlank@Size(max = 80)는 이 객체가 HTTP 경계에서 검증되어야 한다는 표시예요. 이 객체가 service 깊숙한 곳까지 “검증 안 된 문자열”을 끌고 들어가지 않게 해줘요. 다음은 src/main/java/me/nvim/blog/todo/todo/TodoItem.java예요.
TodoItem은 HTTP 요청이나 응답 DTO가 아니에요. 서버 안에서 관리하는 Todo의 현재 상태예요. done이 바뀔 수 있기 때문에 record가 아니라 class로 만들었고, package-private으로 둬서 같은 todo package 안에서만 쓰게 했어요. 여기서 TodoItem을 먼저 만드는 이유가 있어요. 곧 만들 TodoResponseTodoItem을 받아 응답 DTO로 바꾸기 때문이에요. TodoResponse만 먼저 만들면 TodoItem을 찾을 수 없어서 컴파일 에러가 나겠죠. 그래서 이 둘은 같은 단계에서 만드는 게 좋아요. 이제 src/main/java/me/nvim/blog/todo/todo/TodoResponse.java를 만들어요.
TodoResponse를 따로 두는 이유는 내부 저장 객체를 그대로 밖으로 내보내지 않기 위해서예요. 지금은 둘이 비슷해 보여도, 나중에 내부 상태와 API 응답 모양은 달라질 수 있어요. 여기까지 만들고 한 번 확인해도 돼요.
아직 API endpoint는 없지만, 새로 만든 세 파일은 서로 필요한 참조를 모두 갖고 있어서 컴파일 에러가 나지 않아야 해요.

저장 경계와 시간을 준비해요

다음은 저장 경계를 만들 차례예요. 아직 DB는 쓰지 않고 메모리에 저장할 거예요. 생성 시각을 넣기 위해 Clock도 빈(bean)으로 준비해요. 이번 단계에서 만들 파일은 두 개예요.
todo-api
src
main
java
me.nvim.blog.todo
TodoApiApplication.java
todo
CreateTodoRequest.java
+ TimeConfig.java
TodoItem.java
+ TodoRepository.java
TodoResponse.java
먼저 src/main/java/me/nvim/blog/todo/todo/TimeConfig.java를 만들어요.
그리고 src/main/java/me/nvim/blog/todo/todo/TodoRepository.java를 만들어요.
“DB도 없는데 repository라는 이름을 써도 되나요?”라는 생각이 들 수 있어요. 괜찮아요. 여기서 repository는 JPA라는 특정 기술 이름이 아니라 저장 경계예요. 지금은 메모리이고, 나중에는 JDBC, JPA, Redis, 외부 API가 될 수 있어요. 첫 커밋에서 경계를 만들어두면 나중에 저장 방식을 바꿀 때 controller부터 다시 흔들 필요가 줄어들어요. 다만 한계도 분명히 알아야 해요. 여기서도 확인할 수 있어요.
repository는 TodoItemClock을 모두 찾을 수 있고, Clock 빈도 준비되어 있어서 context test가 깨지지 않아야 해요.

Service에서 Todo 흐름을 연결해요

이제 application behavior를 담는 service를 만들어요.
todo-api
src
main
java
me.nvim.blog.todo
TodoApiApplication.java
todo
CreateTodoRequest.java
TimeConfig.java
TodoItem.java
TodoRepository.java
TodoResponse.java
+ TodoService.java
src/main/java/me/nvim/blog/todo/todo/TodoService.java를 만들어요.
처음에는 service가 얇아 보여요. 그래도 괜찮아요. controller가 HTTP 계약을 맡고, service가 “Todo를 만든다”, “완료 처리한다”, “없으면 404로 본다” 같은 application behavior를 맡도록 자리를 나누는 게 목적이에요. 여기서도 한 번 확인해요.
TodoService는 이미 존재하는 TodoRepository, TodoItem, TodoResponse만 참조하므로 이 단계에서도 중간 에러 없이 지나갈 수 있어요.

마지막에 Controller를 붙여요

이제 HTTP endpoint를 열 차례예요. controller를 마지막에 만드는 이유는 간단해요. controller는 DTO와 service를 모두 참조하니까, 앞 단계가 준비된 뒤 붙이는 편이 가장 덜 헷갈려요.
todo-api
src
main
java
me.nvim.blog.todo
TodoApiApplication.java
todo
CreateTodoRequest.java
TimeConfig.java
+ TodoController.java
TodoItem.java
TodoRepository.java
TodoResponse.java
TodoService.java
src/main/java/me/nvim/blog/todo/todo/TodoController.java를 만들어요.
controller는 HTTP와 가까운 결정을 해요.
  • 어떤 URL을 열지
  • 어떤 method를 받을지
  • request body를 어떤 DTO로 읽을지
  • 성공했을 때 status를 무엇으로 줄지
  • Location header를 넣을지
반대로 “Todo를 어떻게 저장하고 완료 처리할지”는 controller가 직접 알 필요가 없어요. 그 일은 service로 넘겨요. 이 그림에서 우리가 직접 호출하지 않은 일이 많죠. JSON 변환, validation 실행, controller method 호출, response body 쓰기는 Spring MVC가 맡아요. 우리는 그 흐름에 필요한 경계를 코드로 알려주는 거예요. controller까지 붙였으면 다시 확인해요.
이제 실제 endpoint도 열려 있지만, 아직 API 계약을 강하게 검증하는 테스트는 없어요. 다음 단계에서 그 테스트를 추가할게요.

설정에 Problem Details를 켜요

validation 실패 응답은 그냥 400만 내려도 되지만, 이 시리즈에서는 REST API 글에서 본 Problem Details 흐름을 같이 확인할 거예요. 이번 단계에서 수정할 파일은 하나예요.
todo-api
src
main
java
me.nvim.blog.todo
TodoApiApplication.java
todo
CreateTodoRequest.java
TimeConfig.java
TodoController.java
TodoItem.java
TodoRepository.java
TodoResponse.java
TodoService.java
resources
~ application.properties
src/main/resources/application.properties에 한 줄을 추가해요.
이제 Spring MVC가 처리하는 기본 에러 응답에서 application/problem+json 형태를 확인할 수 있어요. field별 상세 에러 계약은 아직 만들지 않을 거예요. 첫 커밋에서는 “입력 검증 실패가 HTTP 400 문제 응답이 된다”는 경계까지만 잡아요.

테스트로 첫 API 계약을 붙잡아요

마지막으로 API 테스트를 추가해요. 이 시점에는 controller, service, repository, DTO가 모두 만들어져 있으니 테스트 파일을 추가해도 참조 에러가 나지 않아요.
todo-api
src
main
java
me.nvim.blog.todo
TodoApiApplication.java
todo
CreateTodoRequest.java
TimeConfig.java
TodoController.java
TodoItem.java
TodoRepository.java
TodoResponse.java
TodoService.java
test
java
me.nvim.blog.todo
TodoApiApplicationTests.java
+ TodoApiTests.java
Boot 4의 Web MVC 테스트에서는 AutoConfigureMockMvc import가 아래 package에 있어요.
src/test/java/me/nvim/blog/todo/TodoApiTests.java를 만들어요.
여기서 테스트가 “된다”만 보고 넘어가면 아쉬워요. 이 파일은 실제 서버 포트를 열지는 않지만, Spring Boot 앱을 테스트용으로 띄우고 HTTP 요청처럼 controller까지 밀어 넣어요. 먼저 class 위와 field에 붙은 Annotation부터 볼게요. 조금 더 깊게 보면, 이 테스트는 “controller 메서드를 직접 호출하는 테스트”가 아니에요. 요청을 문자열과 header로 만들고, Spring MVC가 실제 요청을 처리할 때 쓰는 경로를 지나가게 해요. 그래서 MockMvc 테스트는 빠르면서도 꽤 넓은 경계를 잡아요. JSON 변환, validation, URL mapping, HTTP status, header, response body까지 한 번에 확인할 수 있죠. 아래 메서드 호출들도 각각 역할이 있어요. 정리하면 이 파일은 “service 로직만 맞나요?”를 보는 테스트가 아니에요. **우리 API를 클라이언트가 HTTP로 불렀을 때 약속한 모양으로 응답하나요?**를 보는 테스트예요. 테스트를 다시 실행해요.
실제로 확인한 결과는 이렇게 끝났어요.
이 테스트가 모든 설계를 증명하지는 않아요. 하지만 적어도 첫 커밋의 HTTP 계약은 붙잡아요.

실제 서버도 띄워봐요

테스트만 통과했다고 끝내지 않고, 실제 HTTP 서버를 띄워볼게요.
실행 로그에서는 이런 줄을 확인했어요.
이제 생성 요청을 보내요.
응답은 이렇게 왔어요.
빈 제목을 보내면 validation이 동작해요.
응답은 이렇게 확인했어요.
목록 조회와 완료 처리도 확인해요.
여기까지 오면 첫 커밋은 단순한 코드 묶음이 아니에요. 프로젝트 생성, wrapper 테스트, API 계약, validation, JSON 응답, 실제 HTTP 확인까지 하나의 기준점으로 남아요.

이번 커밋에서 일부러 하지 않은 것들

이번 글은 첫 커밋이라서 일부러 많은 것을 미뤘어요. 이건 빠뜨린 목록이 아니라 다음 글들이 들어올 자리예요. 첫 커밋은 작아야 해요. 대신 나중에 자랄 방향이 보여야 해요.

참고한 링크


자, 정리해볼까요?

  • 첫 실습 글은 완성된 구조를 먼저 설명하기보다 프로젝트 목표, 초기화, 생성 파일 확인, 코드 작성, 테스트, 실행 확인 순서로 가는 편이 좋아요.
  • Spring CLI로 todo-api 프로젝트를 만들고, Java 21과 Gradle wrapper 기준으로 시작했어요.
  • spring-boot-starter-webmvc는 Spring MVC와 HTTP/JSON 처리 흐름을 준비하고, spring-boot-starter-validation은 Bean Validation을 연결해요.
  • controller는 HTTP 계약을 맡고, service는 application behavior를 맡고, repository는 저장 경계를 맡아요.
  • ./gradlew test와 실제 curl 응답을 함께 확인해야 첫 커밋이 말뿐인 예제가 아니라 실행 가능한 기준점이 돼요.
다음 글에서는 Spring MVC와 WebFlux를 비교해볼 거예요. 둘 다 “웹 요청을 처리한다”는 말은 같지만, 요청을 붙잡는 모델과 확장 방식은 꽤 다르게 생겼어요.