블로그 글을 만들고, 조회하고, 업데이트하고, 삭제하는 RESTful API를 만들고 스프링 부트 3와 JPA를 어떻게 사용하는지 알아봅니다.
✅ 식당에서는 점원에게 요리를 주문하고, 점원은 주방에 가서 조리를 요청합니다.
✅ 여기서 손님은 클라이언트, 요리사를 서버라고 합니다.
✅ 그리고 중간에 있는 점원은 API입니다.
✅ REST API는 웹의 장점을 최대한 활용하는 API입니다.
REST는 Representational State Transfer를 줄인 표현입니다.
✅ 즉, URL의 설계 방식을 말합니다.
✅ 서버/클라이언트 구조
✅ 무상태
✅ 캐시 처리 가능
✅ 계층화
✅ 인터페이스 일관성
장점
✅ URL만 보고도 무슨 행동을 하는 API인지 명확하게 알 수 있음!
✅ 서버와 클라이언트의 역할이 명확하게 분리된다!
✅ HTTP 표준을 사용하는 모든 플랫폼에서 사용 가능!단점
✅ HTTP 메서드 방식 개수에 제한이 있다
✅ 공식적으로 제공되는 표준 규약이 없다
dependencies {
implementation 'org.springframework.boot:spring-boot-starter-web'
testImplementation 'org.springframework.boot:spring-boot-starter-test'
//스프링 데이터 JPA
implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
runtimeOnly 'com.h2database:h2' //인메모리 데이터베이스
compileOnly 'org.projectlombok:lombok' //롬복
annotationProcessor 'org.projectlombok:lombok'
}
@Entity
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Article {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "id", updatable = false)
private Long id;
@Column(name = "title", nullable = false)
private String title;
@Column(name = "content", nullable = false)
private String content;
@Builder
public Article(String title, String content) {
this.title = title;
this.content = content;
}
}
public interface BlogRepository extends JpaRepository<Article, Long> {
}
@NoArgsConstructor
@AllArgsConstructor
@Getter
public class AddArticleRequest {
private String title;
private String content;
public Article toEntity() {
return Article.builder()
.title(title)
.content(content)
.build();
}
}
@RequiredArgsConstructor
@Service
public class BlogService {
private final BlogRepository blogRepository;
//블로그 글 추가 메서드
public Article save(AddArticleRequest request) {
return blogRepository.save(request.toEntity());
}
}
@RequiredArgsConstructor
@RestController
public class BlogApiController {
private final BlogService blogService;
@PostMapping("/api/articles")
public ResponseEntity<Article> addArticle(@RequestBody AddArticleRequest request) {
Article savedArticle = blogService.save(request);
return ResponseEntity.status(HttpStatus.CREATED)
.body(savedArticle);
}
}
spring:
datasource:
url: jdbc:h2:mem:testdb
h2:
console:
enabled: true
@SpringBootTest
@AutoConfigureMockMvc
class BlogApiControllerTest {
@Autowired
protected MockMvc mockMvc;
@Autowired
protected ObjectMapper objectMapper;
@Autowired
private WebApplicationContext context;
@Autowired
BlogRepository blogRepository;
@BeforeEach
public void mockMvcSetUp(){
this.mockMvc = MockMvcBuilders.webAppContextSetup(context).build();
blogRepository.deleteAll();
}
@DisplayName("addArticle: 블로그 글 추가에 성공한다.")
@Test
public void addArticle() throws Exception{
//given
final String url = "/api/articles";
final String title = "title";
final String content = "content";
final AddArticleRequest userRequest = new AddArticleRequest(title, content);
//객체 JSON으로 직렬화
final String requestBody = objectMapper.writeValueAsString(userRequest);
//when
//설정한 내용을 바탕으로 요청 전송
ResultActions result = mockMvc.perform(post(url)
.contentType(MediaType.APPLICATION_JSON_VALUE)
.content(requestBody));
//then
result.andExpect(status().isCreated());
List<Article> articles = blogRepository.findAll();
assertThat(articles.size()).isEqualTo(1);
assertThat(articles.get(0).getTitle()).isEqualTo(title);
assertThat(articles.get(0).getContent()).isEqualTo(content);
}
}
@RequiredArgsConstructor
@Service
public class BlogService {
...
public List<Article> findAll() {
return blogRepository.findAll();
}
}
@Getter
public class ArticleResponse {
...
@GetMapping("/api/articles")
public ResponseEntity<List<ArticleResponse>> findAllArticles() {
List<ArticleResponse> articles = blogService.findAll()
.stream()
.map(ArticleResponse::new)
.toList();
return ResponseEntity.ok()
.body(articles);
}
}
INSERT INTO article (title, content) VALUES ('제목 1', '내용 1');
INSERT INTO article (title, content) VALUES ('제목 2', '내용 2');
INSERT INTO article (title, content) VALUES ('제목 3', '내용 3');
@SpringBootTest
@AutoConfigureMockMvc
class BlogApiControllerTest {
...
@DisplayName("findAllArticles: 블로그 글 전체 조회에 성공한다.")
@Test
public void findAllArticles() throws Exception{
//given
final String url = "/api/articles";
final String title = "title";
final String content = "content";
blogRepository.save(Article.builder()
.title(title)
.content(content)
.build());
//when
final ResultActions resultActions = mockMvc.perform(get(url)
.contentType(MediaType.APPLICATION_JSON));
//then
resultActions
.andExpect(status().isOk())
.andExpect(jsonPath("$[0].title").value(title))
.andExpect(jsonPath("$[0].content").value(content));
}
}
@RequiredArgsConstructor
@Service
public class BlogService {
...
public Article findById(Long id) {
return blogRepository.findById(id).orElseThrow(
() -> new IllegalArgumentException("not found" + id));
}
}
@RequiredArgsConstructor
@RestController
public class BlogApiController {
...
@GetMapping("/api/articles/{id}")
public ResponseEntity<ArticleResponse> findArticleById(@PathVariable Long id) {
Article article = blogService.findById(id);
return ResponseEntity.ok()
.body(new ArticleResponse(article));
}
}
@SpringBootTest
@AutoConfigureMockMvc
class BlogApiControllerTest {
...
@DisplayName("findArticleById: 블로그 글 조회에 성공한다.")
@Test
public void findArticle() throws Exception{
//given
final String url = "/api/articles/{id}";
final String title = "title";
final String content = "content";
Article savedArticle = blogRepository.save(Article.builder()
.title(title)
.content(content)
.build());
//when
final ResultActions resultActions = mockMvc.perform(get(url, savedArticle.getId()));
//then
resultActions
.andExpect(status().isOk())
.andExpect(jsonPath("$.title").value(title))
.andExpect(jsonPath("$.content").value(content));
}
}
@RequiredArgsConstructor
@Service
public class BlogService {
...
public void deleteById(Long id) {
blogRepository.deleteById(id);
}
}
@RequiredArgsConstructor
@RestController
public class BlogApiController {
...
@DeleteMapping("/api/articles/{id}")
public ResponseEntity<Void> deleteArticleById(@PathVariable Long id) {
blogService.deleteById(id);
return ResponseEntity.ok()
.build();
}
}
@SpringBootTest
@AutoConfigureMockMvc
class BlogApiControllerTest {
...
@DisplayName("deleteArticleById: 블로그 글 삭제에 성공한다.")
@Test
public void deleteArticleById() throws Exception{
//given
final String url = "/api/articles/{id}";
final String title = "title";
final String content = "content";
Article savedArticle = blogRepository.save(Article.builder()
.title(title)
.content(content)
.build());
//when
final ResultActions resultActions = mockMvc.perform(delete(url, savedArticle.getId()))
.andExpect(status().isOk());
//then
List<Article> articles = blogRepository.findAll();
assertThat(articles).isEmpty();
}
}
@Entity
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Article {
...
public void update(String title, String content) {
this.title = title;
this.content = content;
}
}
@NoArgsConstructor
@AllArgsConstructor
@Getter
public class UpdateArticleRequest {
private String title;
private String content;
}
@RequiredArgsConstructor
@Service
public class BlogService {
...
@Transactional
public Article update(long id, AddArticleRequest request) {
Article article = blogRepository.findById(id).orElseThrow(
() -> new IllegalArgumentException("not found" + id));
article.update(request.getTitle(), request.getContent());
return article;
}
}
@RequiredArgsConstructor
@RestController
public class BlogApiController {
...
@PutMapping("/api/articles/{id}")
public ResponseEntity<Article> updateArticleById(@PathVariable Long id,
@RequestBody AddArticleRequest request) {
Article updatedArticle = blogService.update(id, request);
return ResponseEntity.ok()
.body(updatedArticle);
}
}
@SpringBootTest
@AutoConfigureMockMvc
class BlogApiControllerTest {
...
@DisplayName("updateArticleById: 블로그 글 수정에 성공한다.")
@Test
public void updateArticleById() throws Exception{
//given
final String url = "/api/articles/{id}";
final String title = "title";
final String content = "content";
Article savedArticle = blogRepository.save(Article.builder()
.title(title)
.content(content)
.build());
final String newTitle = "newTitle";
final String newContent = "new content";
UpdateArticleRequest request = new UpdateArticleRequest(newTitle, newContent);
//when
final ResultActions result = mockMvc.perform(put(url, savedArticle.getId())
.contentType(MediaType.APPLICATION_JSON_VALUE)
.content(objectMapper.writeValueAsString(request)));
//then
result
.andExpect(status().isOk());
Article article = blogRepository.findById(savedArticle.getId()).get();
assertThat(article.getTitle()).isEqualTo(newTitle);
assertThat(article.getContent()).isEqualTo(newContent);
}
}
✅ REST API는 웹의 장점을 최대한 활용하는 API로, 자원을 이름으로 구분해 자원의 상태를 주고받는 방식입니다.
✅ JpaRepository를 상속받으면 Spring JPA에서 지원하는 여러 메서드를 간편하게 사용할 수 있습니다.
✅ 롬복을 사용하면 더 깔끔하게 코드를 작성할 수 있습니다.
✅ 테스트 코드를 작성하면 코드의 기능이 제대로 작동한다는 것을 검증할 수 있습니다.