설명만 읽을 때는 알겠는데, 빈 폴더에서 첫 API를 만들려고 하면 갑자기 손이 멈춰요.지금까지는 Spring Boot가 왜 필요한지,
main 메서드가 어떻게 실행 중인 앱이 되는지, 빈(bean)이 어떻게 만들어지는지, 웹 요청이 Spring MVC 안에서 어떤 순서로 지나가는지, JSON 계약이 왜 중요한지까지 봤어요.
그런데 실제 프로젝트를 시작하면 질문이 바뀌어요.
“프로젝트 생성 명령은 뭘 써야 하지?"오늘은 작은 Todo API를 처음부터 만들어볼게요. 완성된 구조를 먼저 던져두고 설명하지 않고, 프로젝트 목표 → 프로젝트 초기화 → 생성된 파일 확인 → API 코드 작성 → 테스트 → 실행 확인 순서로 갈 거예요.
"생성된 파일 중 어디부터 봐야 하지?"
"컨트롤러부터 만들까요, 테스트부터 만들까요?"
"정말 서버가 뜨고 HTTP 응답이 나가는지 어디서 확인하죠?”
실습 코드는 실습 프로젝트 저장소의
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 프로젝트 폴더가 생겨요. 그 안으로 들어갑니다.
생성된 프로젝트에서 먼저 볼 파일은 많지 않아요
생성 직후에는 파일이 꽤 많아 보여요. 하지만 처음에는 아래 정도만 보면 충분해요.build.gradle의 중요한 부분은 이렇게 생겼어요.
spring-boot-starter-webmvc가 Spring MVC, 내장 Tomcat, HTTP message conversion을 준비해요. spring-boot-starter-validation은 @NotBlank, @Size 같은 Jakarta Bean Validation을 연결해요.
생성 직후 테스트도 먼저 돌려볼게요.
이제 API 계약을 먼저 정해요
코드를 만들기 전에 오늘의 HTTP 계약을 먼저 잡아둘게요.
생성 요청 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을 먼저 만드는 이유가 있어요. 곧 만들 TodoResponse가 TodoItem을 받아 응답 DTO로 바꾸기 때문이에요. TodoResponse만 먼저 만들면 TodoItem을 찾을 수 없어서 컴파일 에러가 나겠죠. 그래서 이 둘은 같은 단계에서 만드는 게 좋아요.
이제 src/main/java/me/nvim/blog/todo/todo/TodoResponse.java를 만들어요.
TodoResponse를 따로 두는 이유는 내부 저장 객체를 그대로 밖으로 내보내지 않기 위해서예요. 지금은 둘이 비슷해 보여도, 나중에 내부 상태와 API 응답 모양은 달라질 수 있어요.
여기까지 만들고 한 번 확인해도 돼요.
저장 경계와 시간을 준비해요
다음은 저장 경계를 만들 차례예요. 아직 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를 만들어요.
여기서도 확인할 수 있어요.
TodoItem과 Clock을 모두 찾을 수 있고, 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를 만들어요.
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를 만들어요.
- 어떤 URL을 열지
- 어떤 method를 받을지
- request body를 어떤 DTO로 읽을지
- 성공했을 때 status를 무엇으로 줄지
Locationheader를 넣을지
설정에 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에 한 줄을 추가해요.
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
AutoConfigureMockMvc import가 아래 package에 있어요.
src/test/java/me/nvim/blog/todo/TodoApiTests.java를 만들어요.
조금 더 깊게 보면, 이 테스트는 “controller 메서드를 직접 호출하는 테스트”가 아니에요. 요청을 문자열과 header로 만들고, Spring MVC가 실제 요청을 처리할 때 쓰는 경로를 지나가게 해요.
그래서
MockMvc 테스트는 빠르면서도 꽤 넓은 경계를 잡아요. JSON 변환, validation, URL mapping, HTTP status, header, response body까지 한 번에 확인할 수 있죠.
아래 메서드 호출들도 각각 역할이 있어요.
정리하면 이 파일은 “service 로직만 맞나요?”를 보는 테스트가 아니에요. **우리 API를 클라이언트가 HTTP로 불렀을 때 약속한 모양으로 응답하나요?**를 보는 테스트예요.
테스트를 다시 실행해요.
실제 서버도 띄워봐요
테스트만 통과했다고 끝내지 않고, 실제 HTTP 서버를 띄워볼게요.이번 커밋에서 일부러 하지 않은 것들
이번 글은 첫 커밋이라서 일부러 많은 것을 미뤘어요.
이건 빠뜨린 목록이 아니라 다음 글들이 들어올 자리예요. 첫 커밋은 작아야 해요. 대신 나중에 자랄 방향이 보여야 해요.
참고한 링크
- Spring Boot Reference - Spring Boot CLI
- Spring Boot Reference - Developing Your First Spring Boot Application
- Spring Boot Reference - Spring MVC
- Spring Boot Reference - Validation
자, 정리해볼까요?
- 첫 실습 글은 완성된 구조를 먼저 설명하기보다 프로젝트 목표, 초기화, 생성 파일 확인, 코드 작성, 테스트, 실행 확인 순서로 가는 편이 좋아요.
- 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응답을 함께 확인해야 첫 커밋이 말뿐인 예제가 아니라 실행 가능한 기준점이 돼요.