1. 프로젝트 생성
이번 프로젝트의 목표는 회원가입과 로그인/로그아웃 조지기!!
간단한 블로그 구성에 스프링 시큐리티를 조합한 버전과
JWT + OAuth2를 조합한 버전 2가지 버전을 만들어보며, 관련 개념을 학습하고
다음 개인 프로젝트의 기반을 다지려고 한다.
공부할 때, 이 책을 많이 참고했는데 현재 버전에 코드가 달라진 부분들이 있어서 그런 부분들을 수정하면서 DTO에 자바 record를 도입하거나, 엔티티가 API에 그대로 노출되지 않게 변경하는 등 자잘한 코드 변경점이 있다.
GENERATE로 생성된 프로젝트의 압축파일을 푼 뒤,
IntelliJ에서 build.grade을 선택하여 'Open as Project' 진행
롬복 사용을 위해 'annotation processing'을 켜주고
Build and Run과 Gradle 부분도 필요시 적절하게 변경해 준다.
resources - static에 index.html 파일을 하나 만들어 간단하게 내용을 채우고, 프로젝트가 잘 실행되는지 확인해 본다.
굿굿~~ 잘 세팅이 된 듯하다. 생성 후에는 Git으로 버전 관리를 하도록 한다!
2. 데이터베이스 설정
application.properties
spring.application.name=flog
# JPA 설정
logging.level.org.hibernate.SQL=debug
logging.level.org.hibernate.type.descriptor.sql=trace
spring.jpa.properties.hibernate.format_sql=true
spring.jpa.properties.hibernate.highlight_sql=true
spring.jpa.hibernate.ddl-auto=create
spring.jpa.hibernate.naming.physical-strategy=org.hibernate.boot.model.naming.PhysicalNamingStrategyStandardImpl
# 데이터소스 설정
spring.datasource.driver-class-name=com.mysql.cj.jdbc.Driver
spring.datasource.url=jdbc:mysql://localhost:3306/flog?characterEncoding=UTF-8&serverTimezone=Asia/Seoul
spring.datasource.username=flog
spring.datasource.password=flog
- 애플리케이션 설정
- spring.application.name=flog
- 애플리케이션 이름을 flog로 설정
- 스프링 부트 애플리케이션의 식별자로 사용될 수 있다.
- spring.application.name=flog
- JPA 설정
- logging.level.org.hibernate.SQL=debug
- Hibernate가 실행하는 SQL 쿼리를 debug 레벨로 로그에 출력
- 애플리케이션에서 어떤 SQL 쿼리가 실행되고 있는지 알 수 있다.
- logging.level.org.hibernate.type.descriptor.sql=trace
- ?에 어떤 값이 들어갔는지 바인딩 파라미터 확인
- spring.jpa.properties.hibernate.format_sql=true
- 출력되는 SQL 쿼리를 보기 쉽게 포맷팅
- SQL 로그를 읽기 쉽게 만들어 디버깅과 개발 중에 유용!
- spring.jpa.properties.hibernate.highlight_sql=true
- SQL 쿼리 로그에 색상 추가
- spring.jpa.hibernate.ddl-auto=create
- 애플리케이션 시작 시 기존 데이터베이스 스키마를 삭제하고, JPA 엔티티에 기반하여 새로운 스키마를 생성
- create 옵션은 애플리케이션을 재시작할 때마다 데이터베이스를 다시 생성하므로, 데이터 손실이 발생할 수 있다.
- 주로 개발 환경에서 사용!
- 같이 볼만한 글 하단 첨부
- spring.jpa.hibernate.naming.physical-strategy=org.hibernate.boot.model.naming.PhysicalNamingStrategyStandardImpl
- Hibernate에서 물리적인 네이밍 전략을 설정
- 여기서는 엔티티 필드의 이름을 데이터베이스 열이름으로 매핑
- 기본적인 매핑 전략이다.
- logging.level.org.hibernate.SQL=debug
- 데이터소스 설정
- spring.datasource.driver-class-name=cohttp://m.mysql.cj.jdbc.Driver
- 데이터베이스 연결을 위한 JDBC 들라이브 클래스를 설정
- 여기서는 MySQL JDBC 드라이브를 사용
- spring.datasource.url=jdbc:mysql://localhost:3306/flog?characterEncoding=UTF-8&serverTimezone=Asia/Seoul
- 데이터베이스 연결 URL을 설정
- MySQL 데이터베이스에 연결하며, 데이터베이스 이름은 flog
- 문자 인코딩을 UTF-8로 설정하고, 서버 시간대는 Asia/Seoul로 설정
- spring.datasource.username=flog
- 데이터베이스에 연결할 때 사용할 사용자 이름을 설정
- spring.datasource.password=flog
- 데이터베이스에 연결할 때 사용할 비밀번호를 설정
- spring.datasource.driver-class-name=cohttp://m.mysql.cj.jdbc.Driver
참고) 주석의 한글이 깨져보인다면, properties 파일의 인코딩을 UTF-8로 변경해주자!
Docker로 MySQL 사용하기
프로젝트에 Docker 디렉터리를 하나 만들고, flog-mysql.sh를 생성
docker run -d \
--name flog-mysql \
-e MYSQL_ROOT_PASSWORD="flog" \
-e MYSQL_USER="flog" \
-e MYSQL_PASSWORD="flog" \
-e MYSQL_DATABASE="flog" \
-p 3306:3306 \
mysql
- 최신 버전의 MySQL 이미지를 가져와 flog-mysql이름을 가진 컨테이너로 실행
의존성 추가
runtimeOnly 'com.mysql:mysql-connector-j'
3. REST API란?
- REST API는 웹의 장점을 최대한 활용하는 API
- Representational State Transfer
- 자원을 이름으로 구분해 자원의 상태를 주고받은 API 방식
- REST API의 장점은 URL만 보고도 무슨 행동을 하는 API인지 명확하게 알 수 있다는 점이다.
- 규칙 1) URL에는 동사를 사용하지 말고, 자원을 표시해야 한다.
- 규칙 2) 동사는 HTTP 메서드로 해결한다.
설명 | 적합한 HTTP 메서드와 URL |
전체 블로그 글 조회하기 | GET /articles |
특정 id를 가진 블로그 글 조회하기 | GET /articles/1 |
블로그 글 추가하기 | POST /articles |
블로그 글 수정하기 | PUT /articles/1 |
블로그 글 삭제하기 | DELETE /articles/1 |
4. 스프링 데이터 JPA의 Auditing 기능 & Article 엔티티 만들기
- 엔티티는 JPA에서 데이터베이스 테이블에 매핑되는 클래스로, @Entity 애노테이션을 통해 엔티티 클래스를 생성할 수 있다.
- 가장 먼저 article 테이블에 매핑되는 Article 엔티티를 생성한다.
- 추가로 엔티티의 생성 시각과 수정 시각도 관리하고 싶기 때문에, 스프링 데이터 JPA의 Auditing 기능을 사용한다.
결과적으로 적용된 코드는 다음과 같다.
JpaConfig
package me.progfrog.flog.config;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.jpa.repository.config.EnableJpaAuditing;
@Configuration
@EnableJpaAuditing
public class JpaConfig {
}
TimeBaseEntity
package me.progfrog.flog.domain;
import jakarta.persistence.Column;
import jakarta.persistence.EntityListeners;
import jakarta.persistence.MappedSuperclass;
import lombok.Getter;
import org.springframework.data.annotation.CreatedDate;
import org.springframework.data.annotation.LastModifiedDate;
import org.springframework.data.jpa.domain.support.AuditingEntityListener;
import java.time.LocalDateTime;
@MappedSuperclass
@EntityListeners(AuditingEntityListener.class)
@Getter
public abstract class TimeBaseEntity {
@CreatedDate
@Column(updatable = false)
private LocalDateTime createdAt;
@LastModifiedDate
@Column
private LocalDateTime updatedAt;
}
Article
package me.progfrog.flog.domain;
import jakarta.persistence.*;
import lombok.AccessLevel;
import lombok.Getter;
import lombok.NoArgsConstructor;
@Entity
@Table(name = "article")
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Getter
public class Article extends TimeBaseEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(updatable = false)
private Long id;
@Column(nullable = false)
private String title;
@Column(nullable = false)
private String content;
public Article(String title, String content) {
this.title = title;
this.content = content;
}
public void update(String title, String content) {
this.title = title;
this.content = content;
}
}
5. DTO 만들기
- DTO란 Data Transfer Object의 약자로, 계층 간 데이터 교환을 위해 사용하는 데이터 전송 객체를 의미한다.
- DTO를 사용함으로써 필요한 데이터만 전송할 수 있다.
- 특히 스프링 OSIV가 활성화된 상태라면, 프레젠테이션 계층에서 엔티티를 수정한 직후에 트랜잭션을 시작하는 서비스 계층을 호출하면 변경 감지가 동작하면서 의도치 않게 엔티티의 수정 사항이 데이터베이스에 반영될 수 있다. 따라서, DB에서 가져온 후에는 DTO에 담아 코드를 작성하는 것이 안전하다.
- DTO는 자바 16부터 정식으로 도입된 레코드를 사용하여 구현하였다.
ArticleDto
- Service에서 Controller로 엔티티를 넘겨줄 때, 엔티티를 직접 노출하지 않기 위해 생성한 DTO이다.
- Service는 Article 엔티티를 넘겨줘야 할 필요가 있을 때, Article 엔티티를 무조건 ArticleDto로 변환하여 반환한다.
- 만약 같은 엔티티에 대해 API 별로 내려줘야 하는 필드가 다르다면, 해당 역할은 ArticleDto를 반환받은 Controller에서 수행하도록 한다.
package me.progfrog.flog.dto.article;
import me.progfrog.flog.domain.Article;
import java.time.LocalDateTime;
public record ArticleDto(
Long id,
String title,
String content,
LocalDateTime createdAt,
LocalDateTime updatedAt
) {
public ArticleDto(Article article) {
this(
article.getId(),
article.getTitle(),
article.getContent(),
article.getCreatedAt(),
article.getUpdatedAt()
);
}
}
그 외에도 Article 생성 및 수정 요청에 사용할 Dto와 응답을 내려줄 때 사용할 Dto를 추가한다.
package me.progfrog.flog.dto.article;
import me.progfrog.flog.domain.Article;
public record ArticleReqAddDto(
String title,
String content
) {
public Article toEntity() {
return new Article(title, content);
}
}
package me.progfrog.flog.dto.article;
public record ArticleReqUpdateDto(
String title,
String content
) {
}
package me.progfrog.flog.dto.article;
import java.time.LocalDateTime;
public record ArticleResDto(
Long id,
String title,
String content,
LocalDateTime createdAt,
LocalDateTime updatedAt
){
public ArticleResDto(ArticleDto dto) {
this(
dto.id(),
dto.title(),
dto.content(),
dto.createdAt(),
dto.updatedAt()
);
}
}
6. 리포지토리 👉 서비스 👉 컨트롤러 구현하기
BlogRepository
- BlogRepository는 데이터베이스 관련 로직을 사용한다.
- 이때 스프링 데이터 JPA를 사용하는데, 스프링 데이터 JPA는 스프링 프레임워크에서 JPA를 편리하게 사용할 수 있도록 지원하는 프로젝트다. 이 프로젝트는 데이터 접근 계층을 개발할 때 지루하게 반복되는 CRUD 문제를 세련된 방법으로 해결한다. 우선 CRUD를 처리하기 위한 공통 인터페이스를 제공한다. 그리고 리포지토리를 개발할 때 인터페이스만 작성하면 실행 시점에 스프링 데이터 JPA가 구현 객체를 동적으로 생성해서 주입해 준다. 따라서, 데이터 접근 계층을 개발할 때 구현 클래스 없이 인터페이스만 작성해도 개발을 완료할 수 있다. 스프링 데이터 JPA를 사용하기 위해서는 build.gradle에 의존성을 추가해줘야 하지만, 프로젝트 생성 당시 이미 추가를 완료했기 때문에 바로 코드를 작성하였다.
package me.progfrog.flog.repository;
import me.progfrog.flog.domain.Article;
import org.springframework.data.jpa.repository.JpaRepository;
public interface BlogRepository extends JpaRepository<Article, Long> {
}
BlogService
- BlogService는 비즈니스 로직을 처리한다.
- 게시글을 조회, 추가, 수정, 삭제하는 등의 작업을 수행한다.
package me.progfrog.flog.service;
import lombok.RequiredArgsConstructor;
import me.progfrog.flog.domain.Article;
import me.progfrog.flog.dto.article.ArticleDto;
import me.progfrog.flog.dto.article.ArticleReqAddDto;
import me.progfrog.flog.dto.article.ArticleReqUpdateDto;
import me.progfrog.flog.repository.BlogRepository;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.List;
@Service
@RequiredArgsConstructor
@Transactional
public class BlogService {
private final BlogRepository blogRepository;
@Transactional(readOnly = true)
public List<ArticleDto> findAll() {
return blogRepository.findAll()
.stream()
.map(ArticleDto::new)
.toList();
}
@Transactional(readOnly = true)
public ArticleDto findById(Long articleId) {
return new ArticleDto(findArticleById(articleId));
}
public ArticleDto save(ArticleReqAddDto reqAddDto) {
Article article = blogRepository.save(reqAddDto.toEntity());
return new ArticleDto(article);
}
public ArticleDto update(Long articleId, ArticleReqUpdateDto reqUpdateDto) {
Article article = findArticleById(articleId);
article.update(reqUpdateDto.title(), reqUpdateDto.content());
return new ArticleDto(article);
}
public void delete(Long articleId) {
Article article = findArticleById(articleId);
blogRepository.delete(article);
}
private Article findArticleById(Long articleId) {
return blogRepository.findById(articleId)
.orElseThrow(() -> new IllegalArgumentException("article not found: " + articleId));
}
}
@Transactional(readOnly = true)
- 데이터를 조회하는 메서드에 readOnly = true 옵션을 적용한다.
- 트랜잭션에 readyOnly = true 옵션을 주면, 강제로 플러시를 호출하지 않는 한 플러시가 일어나지 않는다.
- 따라서, 트랜잭션을 커밋해도 영속성 컨텍스트를 플러시 하지 않는다.
- 플러시 할 때 일어나는 스냅샷 비교와 같은 무거운 로직들을 수행하지 않으므로 성능이 향상된다.
- 물론 트랜잭션을 시작했으므로 트랜잭션 시작, 로직 수행, 트랜잭션 커밋과 같은 과정은 이루어지며 단지 영속성 컨텍스트를 플러시 하지 않을 뿐이다.
BlogApiController
BlogApiController는 HTTP 요청을 받고 이 요청을 비즈니스 계층으로 전송하는 역할을 한다. 서버에서 응답 데이터를 만드는 방법은 크게 3가지로 분류할 수 있다.
- 정적 리소스: 예) 웹 브라우저에 정적인 HTML, css, js를 제공할 때는 정적 리소스를 사용
- 뷰 템플릿 사용: 예) 웹 브라우저에 동적인 HTML을 제공할 때는 뷰 템플릿을 사용
- HTTP 메시지 사용: 예) HTTP API를 제공하는 경우에는 HTML이 아니라 데이터를 전달해야 하므로, HTTP 메시지 바디에 JSON 같은 형식으로 데이터를 실어 보냄
이 중 3번을 구현한다. 이는 @RestController 애노테이션 사용하여 개발할 수 있다. @RestController는 @Controller와 @ResponseBody를 같이 사용한 것과 동일한 역할을 하며, 메서드의 반환 값이 뷰를 통해 렌더링 되지 않고 HTTP 응답 본문으로 직접 전송된다.
package me.progfrog.flog.api;
import lombok.RequiredArgsConstructor;
import me.progfrog.flog.dto.article.ArticleReqAddDto;
import me.progfrog.flog.dto.article.ArticleReqUpdateDto;
import me.progfrog.flog.dto.article.ArticleResDto;
import me.progfrog.flog.service.BlogService;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.util.List;
@RestController
@RequiredArgsConstructor
@RequestMapping("/api/articles")
public class BlogApiController {
private final BlogService blogService;
@GetMapping
public ResponseEntity<List<ArticleResDto>> getAllArticles() {
List<ArticleResDto> articles = blogService.findAll()
.stream()
.map(ArticleResDto::new)
.toList();
return ResponseEntity.ok()
.body(articles);
}
@GetMapping("/{articleId}")
public ResponseEntity<ArticleResDto> getArticle(@PathVariable(name = "articleId") Long articleId) {
ArticleResDto resDto = new ArticleResDto(blogService.findById(articleId));
return ResponseEntity.ok()
.body(resDto);
}
@PostMapping
public ResponseEntity<ArticleResDto> addArticle(@RequestBody ArticleReqAddDto reqAddDto) {
ArticleResDto resDto = new ArticleResDto(blogService.save(reqAddDto));
return ResponseEntity.status(HttpStatus.CREATED)
.body(resDto);
}
@PutMapping("/{articleId}")
public ResponseEntity<ArticleResDto> updateArticle(@PathVariable(name = "articleId") Long articleId, @RequestBody ArticleReqUpdateDto reqUpdateDto) {
ArticleResDto resDto = new ArticleResDto(blogService.update(articleId, reqUpdateDto));
return ResponseEntity.ok()
.body(resDto);
}
@DeleteMapping("/{articleId}")
public ResponseEntity<Void> deleteArticle(@PathVariable(name = "articleId") Long articleId) {
blogService.delete(articleId);
return ResponseEntity.ok().build();
}
}
7. 테스트 코드 만들기
테스트용 application.properties 추가하기
테스트 케이스는 격리된 환경에서 실행하고, 끝나면 데이터를 초기화하는 것이 좋다. 그런 면에서 메모리 DB를 사용하는 것이 가장 이상적이다. 테스트 케이스를 위한 스프링 환경과, 애플리케이션을 실행하는 환경은 보통 다르므로 설정 파일을 다르게 사용하자! 다음과 같이 테스트용 설정 파일을 추가하면 된다.
test/resources/application.properties
logging.level.org.hibernate.SQL=debug
logging.level.org.hibernate.type.descriptor.sql=trace
spring.jpa.properties.hibernate.format_sql=true
spring.jpa.properties.hibernate.highlight_sql=true
- test에서 스프링을 실행하면 이 위치에 있는 설정 파일을 읽고, 만약 이 위치에 없으면 src/resources/application.properties를 읽는다.
- 스프링 부트는 datasource 설정이 없으면, 기본적으로 메모리 DB를 사용하고, driver-class도 현재 등록된 라이브러리를 보고 찾아준다. 추가로 ddl-auto도 create-drop 모드로 동작한다. 따라서, datasource나 JPA 관련된 별도의 추가 설정을 하지 않아도 된다.
- SQL 쿼리 로그를 살펴보기 위해 디버깅 용도의 프로퍼티만 몇 가지 추가해 주었다.
BlogApiControllerTest
package me.progfrog.flog.api;
import com.fasterxml.jackson.databind.ObjectMapper;
import me.progfrog.flog.domain.Article;
import me.progfrog.flog.dto.article.ArticleReqAddDto;
import me.progfrog.flog.dto.article.ArticleReqUpdateDto;
import me.progfrog.flog.repository.BlogRepository;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.mockito.Mockito;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.http.MediaType;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.ResultActions;
import org.springframework.test.web.servlet.setup.MockMvcBuilders;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.context.WebApplicationContext;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
@SpringBootTest
@AutoConfigureMockMvc
@Transactional
class BlogApiControllerTest {
private final static String URL_PREFIX = "/api/articles";
@Autowired
protected MockMvc mockMvc;
@Autowired
protected ObjectMapper objectMapper;
@Autowired
private WebApplicationContext context;
@Autowired
BlogRepository blogRepository;
@BeforeEach
public void setUp() {
this.mockMvc = MockMvcBuilders.webAppContextSetup(context).build();
}
@DisplayName("getAllArticles(): 블로그 글 목록 조회에 성공한다.")
@Test
void getAllArticles() throws Exception {
// given
Article savedArticle = createDefaultArticle();
// when
final ResultActions resultActions = mockMvc.perform(get(URL_PREFIX)
.accept(MediaType.APPLICATION_JSON));
// then
resultActions
.andExpect(status().isOk())
.andExpect(jsonPath("$[0].title").value(savedArticle.getTitle()))
.andExpect(jsonPath("$[0].content").value(savedArticle.getContent()));
}
@DisplayName("getArticle(): 블로그 글 조회에 성공한다,")
@Test
void getArticle() throws Exception {
// given
final String url = URL_PREFIX + "/{articleId}";
Article savedArticle = createDefaultArticle();
// when
final ResultActions resultActions = mockMvc.perform(get(url, savedArticle.getId()));
// then
resultActions
.andExpect(status().isOk())
.andExpect(jsonPath("$.title").value(savedArticle.getTitle()))
.andExpect(jsonPath("$.content").value(savedArticle.getContent()));
}
@DisplayName("addArticle(): 블로그 글 추가에 성공한다.")
@Test
void addArticle() throws Exception {
// given
final String title = "title~~~";
final String content = "content!!";
final ArticleReqAddDto reqAddDto = new ArticleReqAddDto(title, content);
// 객체 -> JSON 직렬화
final String requestBody = objectMapper.writeValueAsString(reqAddDto);
// when
// 설정한 내용을 바탕으로 요청 전송
ResultActions result = mockMvc.perform(post(URL_PREFIX)
.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);
}
@DisplayName("updateArticle(): 블로그 글 수정에 성공한다.")
@Test
void updateArticle() throws Exception {
// given
final String url = URL_PREFIX + "/{articleId}";
Article savedArticle = createDefaultArticle();
final String newTitle = "new title!!!";
final String newContent = "new content~~";
ArticleReqUpdateDto reqUpdateDto = new ArticleReqUpdateDto(newTitle, newContent);
// when
ResultActions result = mockMvc.perform(put(url, savedArticle.getId())
.contentType(MediaType.APPLICATION_JSON_VALUE)
.content(objectMapper.writeValueAsString(reqUpdateDto)));
// then
result.andExpect(status().isOk());
Article article = blogRepository.findById(savedArticle.getId()).get();
assertThat(article.getTitle()).isEqualTo(newTitle);
assertThat(article.getContent()).isEqualTo(newContent);
}
@DisplayName("deleteArticle(): 블로그 글 삭제에 성공한다.")
@Test
void deleteArticle() throws Exception {
// given
final String url = URL_PREFIX + "/{articleId}";
Article savedArticle = createDefaultArticle();
// when
mockMvc.perform(delete(url, savedArticle.getId()))
.andExpect(status().isOk());
// then
List<Article> articles = blogRepository.findAll();
assertThat(articles).isEmpty();
}
private Article createDefaultArticle() {
String title = "title~~~";
String content = "content!!!";
return blogRepository.save(new Article(title, content));
}
}
8. 테스트 코드 좀 더 살펴보기
given-when-then 패턴
given-when-then 패턴은 테스트 코드를 세 단계로 구분해 작성하는 방식을 의미
given: 테스트 실행을 준비하는 단계
when: 테스트를 진행하는 단계
then: 테스트 결과를 검증하는 단계
JUnit
- JUnit은 자바 언어를 위한 단위 테스트 프레임워크
- 단위 테스트란, 작성한 코드가 의도대로 작동하는지 작은 단위로 검증하는 것(이때 보통 단위는 메서드)
- @Test 애노테이션으로 메서드를 호출할 때마다 새 인스턴스를 생성, 독립 테스트 가능
- 예상 결과를 검증하는 어설션 메서드 제공
- 자동 실행, 자체 결과를 확인하고 즉각적인 피드백 제공
@DisplayName
- 테스트의 이름을 명시
@BeforeAll
- 전체 테스트를 시작하기 전에 처음으로 한 번만 실행
- 예를 들어, 데이터베이스를 연결해야 하거나 테스트 환경을 초기화할 때 사용
- 전체 테스트 실행 주기에서 한 번만 호출되어야 하기 때문에 메서드를 static으로 선언해야 한다.
@BeforeAll
static void beforeAll() {
// something...
}
@BeforeEach
- 테스트 케이스를 시작하기 전에 매번 실행
- 예를 들어, 테스트 메서드에서 사용하는 객체를 초기화하거나 테스트에 필요한 값을 미리 넣을 때 사용
@BeforeEach
void beforeEach() {
// something...
}
@AfterAll
- 전체 테스트를 마치고 종료하기 전에 한 번만 실행
- 예를 들어, 데이터베이스 연결을 종료할 때나 공통적으로 사용하는 자원을 해제할 때 사용
- 전체 테스트 주기에서 한 번만 호출되어야 하므로 메서드를 static으로 선언
@AfterAll
static void afterAll() {
// something
}
@AfterEach
- 각 테스트 케이스를 종료하기 전 매번 실행
- 예를 들어, 테스트 이후에 특정 데이터를 삭제해야 하는 경우 사용
@AfterEach
void afterEach() {
// something...
}
애노테이션 중심으로 JUnit 실행 흐름 살펴보기
AssertJ로 검증문 가독성 높이기
AssertJ는 JUnit과 함께 사용해 검증문의 가독성을 확 높여주는 라이브러리
메서드 이름 | 설명 |
isEqualTo(A) | A 값과 같은지 검증 |
isNotEqualTo(A) | A 값과 다른지 검증 |
contains(A) | A 값을 포함하는지 검증 |
doesNotContain(A) | A 값을 포함하지 않는지 검증 |
startsWith(A) | 접두사가 A인지 검증 |
endsWith(A) | 접미사가 A인지 검증 |
isEmpty() | 비어 있는 값인지 검증 |
isNotEmpty() | 비어 있지 않은 값인지 검증 |
isPositive() | 양수인지 검증 |
isNegative() | 음수 인지 검증 |
isGreaterThan(1) | 1보다 큰 값인지 검증 |
isLessThan(1) | 1보다 작은 값인지 검증 |
자주 사용하는 애노테이션
@SpringBootTest
- @SpringBootTest는 메인 애플리케이션 클래스에 추가하는 애노테이션인 @SpringBootApplication이 있는 클래스를 찾고, 그 클래스에 포함되어 있는 빈을 찾은 다음 테스트용 애플리케이션 컨텍스트를 생성
@AutoConfigureMockMvc
- @AutoConfigureMockMvc는 MockMvc를 생성하고 자동으로 구성하는 애노테이션으로, 애플리케이션을 서버에 배포하지 않고도 테스트용 MVC 환경을 만들어 요청 및 전송, 응답 기능을 제공하는 유틸리티 클래스
- 컨트롤러를 테스트할 때 사용
HTTP 주요 응답 코드
result.andExpect(status().isOk())에서 isOk() 대신 사용될 수 있는 매핑 메서드 정리
코드 | 매핑 메서드 |
200 OK | isOk() |
201 Created | isCreated() |
400 Bad Request | isBadRequest() |
403 Forbidden | isForbidden() |
404 Not Found | isNotFound() |
500 Internal Server Error | isInternalServerError() |
400번 대 응답 코드 | is4xxClientError() |
500번 대 응답 코드 | is5xxServerError() |
9. 마무리
작성한 테스트들이 모두 통과하였다!
다음 시간에는 타임리프를 사용해 블로그 화면을 구성해 보도록 하겠다 :)