-
SpringBoot API(등록/수정/조회) 만들기DEV/SpringBoot 2025. 5. 12. 09:21

Spring Boot는 Java 기반 애플리케이션을 쉽게 개발하고 배포할 수 있도록 도와주는 오픈소스 프레임워크예요.
최소한의 설정으로 실행 가능한 독립적인 애플리케이션을 빠르게 만들 수 있도록 도와줍니다 🙌
📌 등록/수정/조회 API 만들기 - 3계층 구조와 테스트까지
Spring Boot로 웹 서비스를 개발하면서, REST API를 만드는 것은 매우 기본이지만 중요한 과정입니다.
이번 포스팅에서는 Web Layer, Service Layer, Repository Layer로 나누고, DTO와 Domain Model을 구분하여API를 구현한 과정을 정리해보았습니다.
✅ API 구현을 위한 구조 설계
📂 3계층 구조
Layer 역할 Web Layer API 요청을 받고 응답을 처리 Service Layer 트랜잭션 흐름과 도메인 객체 간 로직 순서 제어 Repository Layer DB 접근 및 CRUD 처리 📌 DTO vs Domain Model
- DTO (Data Transfer Object) : 외부 요청/응답에 사용하는 데이터 전용 객체
- Domain Model (Entity) : DB 테이블과 매핑되는 핵심 비즈니스 객체
✅ 구현 클래스
- PostsApiController (Web Layer)
- PostsService (Service Layer)
- PostsSaveRequestDto (DTO)
- Posts (Domain Model / Entity)
✅ 테스트 기반 개발 흐름
- 처음에는 PostsApiController, PostsService, PostsSaveRequestDto를 작성
- PostsApiControllerTest를 작성하여 등록 API 정상 동작 확인
- 이후 PostsResponseDto, PostsUpdateRequestDto, Posts까지 확장하며 전체 등록/수정/조회 구조 구현
- 테스트도 PostsApiControllerTest를 수정하여 수정 API까지 확인
✅ 코드
아래는 실제 구현한 클래스 및 테스트 코드입니다. 각 영역의 역할을 명확히 분리하고 DTO를 통해 안전하게 데이터 전송이 이루어집니다.
🧩 PostsApiController.java
PATH: /src/main/com.kior.blog.springboot/web/PostsApicontroller
package com.kior.blog.springboot.web; import com.kior.blog.springboot.service.PostsService; import com.kior.blog.springboot.web.dto.PostsSaveRequestDto; import lombok.RequiredArgsConstructor; import org.springframework.web.bind.annotation.*; @RequiredArgsConstructor @RestController public class PostsApiController { private final PostsService postsService; @PostMapping("/api/v1/posts") public Long save(@RequestBody PostsSaveRequestDto requestDto) { return postsService.save(requestDto); } }🧩 PostsService.java
PATH: /src/main/com.kior.blog.springboot/service/PostsService
package com.kior.blog.springboot.service; import com.kior.blog.springboot.domain.posts.PostsRepository; import com.kior.blog.springboot.web.dto.PostsSaveRequestDto; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @RequiredArgsConstructor @Service public class PostsService { private final PostsRepository postsRepository; @Transactional public Long save(PostsSaveRequestDto requestDto) { return postsRepository.save(requestDto.toEntity()).getId(); } }🧩 PostsSaveRequestDto.java
PATH: /src/main/com.kior.blog.springboot/web/dto/PostsSaveRequestDto
package com.kior.blog.springboot.web.dto; import com.kior.blog.springboot.domain.posts.Posts; import lombok.Builder; import lombok.Getter; import lombok.NoArgsConstructor; @Getter @NoArgsConstructor public class PostsSaveRequestDto { private String title; private String content; private String author; @Builder public PostsSaveRequestDto(String title, String content, String author) { this.title = title; this.content = content; this.author = author; } public Posts toEntity() { return Posts.builder() .title(title) .content(content) .author(author) .build(); } }🧪 PostsApiControllerTest.java
PATH: /src/test/com.kior.blog.springboot/web/PostsApiControllerTest
package com.kior.blog.springboot.web; import com.kior.blog.springboot.domain.posts.Posts; import com.kior.blog.springboot.domain.posts.PostsRepository; import com.kior.blog.springboot.web.dto.PostsSaveRequestDto; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; import org.springframework.boot.test.web.client.TestRestTemplate; import org.springframework.boot.web.server.LocalServerPort; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.test.context.junit.jupiter.SpringExtension; import java.util.List; import static org.assertj.core.api.Assertions.assertThat; @ExtendWith(SpringExtension.class) @SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) public class PostsApiControllerTest { @LocalServerPort private int port; @Autowired private TestRestTemplate restTemplate; @Autowired private PostsRepository postsRepository; @AfterEach public void tearDown() { postsRepository.deleteAll(); } @Test public void Posts_등록된다() throws Exception { // given String title = "title"; String content = "content"; PostsSaveRequestDto requestDto = PostsSaveRequestDto.builder() .title(title) .content(content) .author("author") .build(); String url = "http://localhost:" + port + "/api/v1/posts"; // when ResponseEntity<Long> responseEntity = responseEntity = restTemplate.postForEntity(url, requestDto, Long.class); // then assertThat(responseEntity.getStatusCode()).isEqualTo(HttpStatus.OK); assertThat(responseEntity.getBody()).isGreaterThan(0L); List<Posts> all = postsRepository.findAll(); assertThat(all.get(0).getTitle()).isEqualTo(title); assertThat(all.get(0).getContent()).isEqualTo(content); } }✅ Test 컴파일 확인.

1. 기존 HelloController 다르게 @WebMvcTest를 사용하지 않은 것은 JPA 동작하지 않기 때문입니다.
그러므로 JPA 테스트할 때는 @SpringBootTest, TestRestTemplate를 사용하면 됩니다.2. RANDOM_PORT 실행과 insert 쿼리 동작을 확인할 수 있습니다.
🧩 PostsUpdateRequestDto.java
PATH: /src/main/com.kior.blog.springboot/web/dto/PostsUpdateRequestDto
package com.kior.blog.springboot.web.dto; import lombok.Builder; import lombok.Getter; import lombok.NoArgsConstructor; @Getter @NoArgsConstructor public class PostsUpdateRequestDto { private String title; private String content; @Builder public PostsUpdateRequestDto(String title, String content) { this.title = title; this.content = content; } }🧩 PostsResponseDto.java
PATH: /src/main/com.kior.blog.springboot/web/dto/PostsReponseDto
package com.kior.blog.springboot.web.dto; import com.kior.blog.springboot.domain.posts.Posts; import lombok.Getter; @Getter public class PostsResponseDto { private Long id; private String title; private String content; private String author; public PostsResponseDto(Posts entity) { this.id = entity.getId(); this.title = entity.getTitle(); this.content = entity.getContent(); this.author = entity.getAuthor(); } }🧩 Posts.java (Entity)
PATH: /src/main/com.kior.blog.springboot/domain.posts/Posts
package com.kior.blog.springboot.domain.posts; import lombok.Builder; import lombok.Getter; import lombok.NoArgsConstructor; import javax.persistence.Column; import javax.persistence.Entity; import javax.persistence.GeneratedValue; import javax.persistence.GenerationType; import javax.persistence.Id; @Getter @NoArgsConstructor @Entity public class Posts { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; @Column(length = 500, nullable = false) private String title; @Column(columnDefinition = "TEXT", nullable = false) private String content; private String author; @Builder public Posts(String title, String content, String author) { this.title = title; this.content = content; this.author = author; } public void update(String title, String content) { this.title = title; this.content = content; } }🧩 (추가) PostsApiController.java
(생략)... @PutMapping("/api/v1/posts/{id}") public Long update(@PathVariable Long id, @RequestBody PostsUpdateRequestDto requestDto) { return postsService.update(id, requestDto); } @GetMapping("/api/v1/posts/{id}") public PostsResponseDto findById(@PathVariable Long id) { return postsService.findById(id); } ...(생략)🧪 PostsApiControllerTest.java
(생략)... @Test public void Posts_수정된다() throws Exception { //given Posts savedPosts = postsRepository.save(Posts.builder() .title("title") .content("content") .author("author") .build()); Long updateId = savedPosts.getId(); String expectedTitle = "title2"; String expectedContent = "content2"; PostsUpdateRequestDto requestDto = PostsUpdateRequestDto.builder() .title(expectedTitle) .content(expectedContent) .build(); String url = "http://localhost:" + port + "/api/v1/posts/" + updateId; HttpEntity<PostsUpdateRequestDto> requestEntity = new HttpEntity<>(requestDto); //when ResponseEntity<Long> responseEntity = restTemplate.exchange(url, HttpMethod.PUT, requestEntity, Long.class); //then assertThat(responseEntity.getStatusCode()).isEqualTo(HttpStatus.OK); assertThat(responseEntity.getBody()).isGreaterThan(0L); List<Posts> all = postsRepository.findAll(); assertThat(all.get(0).getTitle()).isEqualTo(expectedTitle); assertThat(all.get(0).getContent()).isEqualTo(expectedContent); } ...(생략)✅ Test 컴파일 확인.

1. update 쿼리가 수행됨을 확인 가능.
🖥️ 웹 콘솔로 결과 확인하기
API 호출 후, 실제로 데이터가 잘 저장되었는지 확인하려면 H2 Database Console을 이용하면 아주 편리합니다.
✅ 1. application.properties에 설정 추가
spring.jpa.show_sql=true spring.h2.console.enabled=true # H2 메모리 DB 설정 spring.datasource.url=jdbc:h2:mem:testdb spring.datasource.driver-class-name=org.h2.Driver spring.datasource.username=sa spring.datasource.password= # JPA가 테이블 자동 생성하도록 설정 spring.jpa.hibernate.ddl-auto=create-drop만약... 메모리 DB 설정이 없을 경우에는...
TestConnection 할때 아래의 Error 메세지를 확인하게 됩니다. 그러니 당황하지 않고 application.properties에 DB 설정을 추가합니다.
✅ 2. 브라우저에서 H2 콘솔 접속
Spring Boot 애플리케이션을 실행한 후, [ http://localhost:8080/h2-console ]로 접속합니다:
접속하면 다음과 같은 내용으로 변경합니다.


^ Test Connection Success ^ Connect(연결) 클릭하면 SQL 콘솔 화면이 나오며, 다음과 같은 쿼리를 직접 실행해볼 수 있습니다

간단하게 insert쿼리를 통해서 데이터를 추가하고 조회해보겠습니다.

---
TIP: H2는 인메모리 DB이므로 애플리케이션을 재시작하면 데이터가 초기화됩니다.
테스트 확인 용도로만 사용하는 것이 좋습니다.✅ 마무리하며
브라우저에서 API 조회 기능을 확인하며 마무리 합니다.

Spring Boot에서 API를 설계할 때 단순히 동작하는 코드가 아니었습니다.
계층 간 역할을 명확히 나누고 테스트로 검증하는 개발 방식은 유지보수성과 안정성을 크게 높여준다고 합니다.
특히 Service Layer는 단순히 로직을 넣는 곳이 아니라,
트랜잭션을 묶고 도메인 로직의 흐름을 제어하는 역할이라는 점을 확인할 수 있었습니다.

'DEV > SpringBoot' 카테고리의 다른 글
SpringBoot JPA Auditing 생성/수정시간 자동화하기 (0) 2025.05.28 SpringBoot Use JPA (0) 2025.04.29 SpringBoot Use Lombok (0) 2025.03.26 SpringBoot Hello Controller (0) 2025.03.11 SpringBoot used Git for Intellij (0) 2025.03.07