프로그래밍 일기 — 테스트
프로그래밍은 끝없는 시험(Test)의 연속
코드를 작성하면 그것에 대해서 일반적으로는 테스팅 코드(Testing Code)를 작성하는게 일반적이다. 자신이 만든 프로그램에 에러가 있을 수 있고, 혹시라도 숨겨져있는 버그가 있을 수 있기 때문이다.
내일은 마지막 시험이 있는 날이다. 이번에는 Fail이 나고 싶지않다. 프로그래머는 시험의 연속인 것 같다. 내가 쓰는 프로그램도 항상 잘 되는지를 테스트해야하기 때문이다. 프로그래머는 입사할 때 코딩테스트도 본다. 결코 시험에서 그 누구보다도 자유로울 수 없는 것이 우리의 운명이다.
이왕 이렇게 된거, 최대한 즐거운 마음으로 테스트에 임하는 것이 좋다. 사실 테스트라는 것은 내가 제대로 된 방향으로 가고 있는지, 내가 배운 것 중 무엇을 놓치고 있는지 알려주기 위한 것이지, 스스로가 부족하며 아직 많이 멀었다고 자책을 하기 위함이 아니다. 테스트를 최대한 긍정적인 방향으로 생각하고 받아들인다면 자신의 성장 방향을 잘 잡을 수 있는 나침반이 되어 줄 수 있다.
내일 시험에서 아무래도 가장 주요 주제 중 하나는 테스트(Test)가 될 것이라 생각하여 이 개념에 대해 정리해 보는 시간을 가지려고 한다.
JUnit5
자바 프로그래밍에서 지원하는 테스트 프레임워크다. IntelliJ를 실행하고 프로젝트를 실행해서 코드를 적어보며 확인해보자.
테스트 시점에 따른 Annotation
// 테스트 시점에 따른 도구
@BeforeEach
void setUp() {
System.out.println("각각의 테스트 코드가 실행되기 전에 수행");
}
@AfterEach
void tearDown() {
System.out.println("각각의 테스트 코드가 실행된 후에 수행\n");
}
@BeforeAll
static void beforeAll() { // 반드시 static이어야한다.
System.out.println("모든 테스트 코드가 실행되기 전에 초초로 수행\n");
}
@AfterAll
static void afterAll() { // static으로 만든다.
System.out.println("모든 테스트 코드가 수행된 후 마지막으로 수행");
}
테스트 내용 설정 관련 Annotation
// 테스트 그룹화 Annotation
@Test
@DisplayName("테스트의 내용을 한눈에 알아볼 수 있게 네이밍 해줄 수 있습니다.")
void test1() {
System.out.println("테스트의 수행 내용들을 빠르게 파악할 수 있습니다.");
}
@Nested
@DisplayName("주제 별로 테스트를 그룹지어서 파악하기 좋습니다.")
class Test1 {
@Test
@DisplayName("Test1 - test1()")
void test1() {
System.out.println("Test1.test1");
}
@Test
@DisplayName("Test1 - test2()")
void test2() {
System.out.println("Test1.test2");
}
}
@Nested
@DisplayName("Test2 다른 주제")
class Test2 {
@Test
@DisplayName("Test2 - test1()")
void test1() {
System.out.println("Test2.test1");
}
@Test
@DisplayName("Test2 - test2()")
void test2() {
System.out.println("Test2.test2");
}
}
// 테스트 순서 정렬 Annotation
@Nested
@DisplayName("주제 별로 테스트를 그룹지어서 파악하기 좋습니다.")
@TestMethodOrder(MethodOrderer.OrderAnnotation.class)
class Test1 {
@Order(1)
@Test
@DisplayName("Test1 클래스")
void test() {
System.out.println("\nTest1 클래스");
}
@Order(3)
@Test
@DisplayName("Test1 - test1()")
void test1() {
System.out.println("Test1.test1");
}
@Order(2)
@Test
@DisplayName("Test1 - test2()")
void test2() {
System.out.println("Test1.test2");
}
}
- 팁: 테스트를 메서드 단위로 순서를 매기고 싶다면
@TestMethodOrder(MethodOrderer.OrderAnnotation.class)
Annotation 설정 필요
테스트 조건 관련 Annotation
// 테스트 반복 Annotation
@RepeatedTest(value = 5, name = "반복 테스트 {currentRepetition} / {totalRepetitions}")
void repeatTest(RepetitionInfo info) {
System.out.println("테스트 반복 : " + info.getCurrentRepetition() + " / " + info.getTotalRepetitions());
}
// 매개변수 활용 Annotation
@DisplayName("파라미터 값 활용하여 테스트 하기")
@ParameterizedTest
@ValueSource(ints = {1, 2, 3, 4, 5, 6, 7, 8, 9})
void parameterTest(int num) {
System.out.println("5 * num = " + 5 * num);
}
Assertion
public class Calculator {
public Double operate(double num1, String op, double num2) {
switch (op) {
case "*":
return num1 * num2;
case "/":
if (validateNum(num2)) {
return num1 / num2;
} else {
return null;
}
case "+":
return num1 + num2;
case "-":
return num1 - num2;
default:
throw new IllegalArgumentException("잘못된 연산자입니다.");
}
}
public boolean validateNum(double num) {
if (num == 0) {
return false;
} else {
return true;
}
}
}
// Assertions.assertEquals(expected, actual)
// 예상값과 실제 값이 다르면 테스트가 실패
@Test
@DisplayName("assertEquals")
void test1() {
Double result = calculator.operate(5, "/", 2);
assertEquals(2.5, result);
}
@Test
@DisplayName("assertEquals - Supplier")
void test1_1() {
Double result = calculator.operate(5, "/", 0);
// 테스트 실패 시 메시지 출력 (new Supplier<String>())
assertEquals(2.5, result, () -> "연산자 혹은 분모가 0이 아닌지 확인해보세요!");
}
@Test
@DisplayName("assertNotEquals")
void test1_2() {
Double result = calculator.operate(5, "/", 0);
assertNotEquals(2.5, result);
}
// Assertions.assertTrue(boolean)
// 해당 파라미터 값이 true인지 확인
@Test
@DisplayName("assertTrue 와 assertFalse")
void test2() {
assertTrue(calculator.validateNum(9));
assertFalse(calculator.validateNum(0));
}
// Assertions.assertNotNull(actual)
// assertNotNull()메서드는 해당 파라미터 값이 null이 아님을 확인
@Test
@DisplayName("assertNotNull 과 assertNull")
void test3() {
Double result1 = calculator.operate(5, "/", 2);
assertNotNull(result1);
Double result2 = calculator.operate(5, "/", 0);
assertNull(result2);
}
// Assertions.assertThrows(expectedType, executable)
// assertThrows() 메서드는 첫 번째 파라미터에 예상하는 Exception 타입을 넣어두고,
// 두 번째 파라미터에 실행 코드를 넣는다.
// 실행 코드의 결과가 예상한 해당 클래스 타입이면 테스트 성공
@Test
@DisplayName("assertThrows")
void test4() {
IllegalArgumentException exception = assertThrows(IllegalArgumentException.class, () -> calculator.operate(5
assertEquals("잘못된 연산자입니다.", exception.getMessage());
}
Given — When — Then
테스트 코드 스타일의 표현 방식 중 하나로써, 가장 널리쓰이는 방식 중 하나다.
- Given: 테스트 하고자하는 대상을 실제로 실행하기 전에 테스트에 필요한 값(상태)을 미리 선언
- When: 테스트 하고자하는 대상을 실제로 실행
- Then: 어떤 특정한 행동(테스트 대상 실행) 때문에 발생할거라고 예상되는 결과에 대해 예측하고 맞는지 확인
class CalculatorTest {
Calculator calculator;
@BeforeEach
void setUp() {
calculator = new Calculator();
}
@Test
@DisplayName("계산기 연산 성공 테스트")
void test1() {
// given
int num1 = 5;
String op = "/";
int num2 = 2;
// when
Double result = calculator.operate(num1, op, num2);
// then
assertNotNull(result);
assertEquals(2.5, result);
}
@Test
@DisplayName("계산기 연산 실패 테스트 : 분모가 0일 경우")
void test1_1() {
// given
int num1 = 5;
String op = "/";
int num2 = 0;
// when
Double result = calculator.operate(num1, op, num2);
// then
assertNull(result);
}
@Test
@DisplayName("계산기 연산 실패 테스트 : 연산자가 잘못됐을 경우")
void test1_2() {
// given
int num1 = 5;
String op = "?";
int num2 = 2;
// when - then
IllegalArgumentException exception = assertThrows(IllegalArgumentException.class, () -> calculator.operate(5,
assertEquals("잘못된 연산자입니다.", exception.getMessage());
}
}
- 더 많은 Annotation은 JUnit5 doc을 참조: https://junit.org/junit5/docs/current/user-guide/#writing-tests-annotations
Mockito
아래와 같은 코드를 테스트한다고 가정한다.
@Service
public class ProductService {
// ...
public static final int MIN_MY_PRICE = 100;
// ...
@Transactional
public Product updateProduct(Long id, ProductMypriceRequestDto requestDto) {
int myprice = requestDto.getMyprice();
if (myprice < MIN_MY_PRICE) {
throw new IllegalArgumentException("유효하지 않은 관심 가격입니다. 최소 " + MIN_MY_PRICE + " 원 이상으로 설정해 주세요.");
}
Product product = productRepository.findById(id).orElseThrow(() -> new NullPointerException("해당 상품을 찾을 수 없습니
다."));
product.setMyprice(myprice);
return product.getId();
}
// ...
}
ProductService
의 updateProduct
라는 메서드를 테스트한다면 ProductService
객체 생성시 생성자로 ProductRepository
, FolderRepository
, ProductFolderRepository
를 전달해 줘야할 것이다. 하지만 이들은 인터페이스이니만큼 전달하기 위해서는 객체로 생성을 해 줘야하는데, updateProduct
메서드 내부의 productRepository.findById(id)
코드는 어떻게 처리할 것인가 등등 여러 문제가 생길 수 있다.
class ProductServiceTest {
@Test
@DisplayName("관심 상품 희망가 - 최저가 이상으로 변경")
void test1() {
// given
Long productId = 100L;
int myprice = ProductService.MIN_MY_PRICE + 3_000_000;
ProductMypriceRequestDto requestMyPriceDto = new ProductMypriceRequestDto();
requestMyPriceDto.setMyprice(myprice);
ProductService productService = new ProductService();
// when
ProductResponseDto result = productService.updateProduct(productId, requestMyPriceDto);
// then
assertEquals(myprice, result.getMyprice());
}
@Test
@DisplayName("관심 상품 희망가 - 최저가 미만으로 변경")
void test2() {
// given
Long productId = 200L;
int myprice = ProductService.MIN_MY_PRICE - 50;
ProductMypriceRequestDto requestMyPriceDto = new ProductMypriceRequestDto();
requestMyPriceDto.setMyprice(myprice);
ProductService productService = new ProductService();
// when
Exception exception = assertThrows(IllegalArgumentException.class, () -> {
productService.updateProduct(productId, requestMyPriceDto);
});
// then
assertEquals(
"유효하지 않은 관심 가격입니다. 최소 " +ProductService.MIN_MY_PRICE + " 원 이상으로 설정해 주세요.",
exception.getMessage()
);
}
}
이러한 문제를 막기 위해서 테스트를 메서드 별로 분리하는 것이 아니라 케이스 별로 구분하는 것이 더 좋다. 이를 위해 Mockito는 가짜 객체(Mock Object)를 생성해준다. 이 것은 실제 객체와 겉만 비슷하다. 같은 클래스명과 함수명을 같지만 실제 DB작업을 하지는 않는다. 테스트에 필요한 결과값을 리턴하는 역할만하며, 테스트 케이스를 분리하는 기준이다. Mockito framework는 Mock 객체를 쉽게 만들 수 있는 방법을 제공한다.
테스트 케이스를 아래와 같이 분리할 수 있을 것이다.
- Controller 클래스만 테스트할 때 범위: Controller, Service, Repository
- Service 클래스만 테스트할 때 범위: Service, Repository
- Repository 클래스만 테스트할 때 범위: Repository
@ExtendWith(MockitoExtension.class) // @Mock 사용을 위해 설정합니다.
class ProductServiceTest {
@Mock
ProductRepository productRepository;
@Mock
FolderRepository folderRepository;
@Mock
ProductFolderRepository productFolderRepository;
@Test
@DisplayName("관심 상품 희망가 - 최저가 이상으로 변경")
void test1() {
// given
Long productId = 100L;
int myprice = ProductService.MIN_MY_PRICE + 3_000_000;
ProductMypriceRequestDto requestMyPriceDto = new ProductMypriceRequestDto();
requestMyPriceDto.setMyprice(myprice);
// 테스트를 위한 사용케이스 정의
User user = new User();
ProductRequestDto requestProductDto = new ProductRequestDto(
"Apple <b>맥북</b> <b>프로</b> 16형 2021년 <b>M1</b> Max 10코어 실버 (MK1H3KH/A) ",
"https://shopping-phinf.pstatic.net/main_2941337/29413376619.20220705152340.jpg",
"https://search.shopping.naver.com/gate.nhn?id=29413376619",
3515000
);
ProductService productService = new ProductService(productRepository, folderRepository, productFolderRepository);
given(productRepository.findById(productId)).willReturn(Optional.of(product));
// when
ProductResponseDto result = productService.updateProduct(productId, requestMyPriceDto);
// then
assertEquals(myprice, result.getMyprice());
}
@Test
@DisplayName("관심 상품 희망가 - 최저가 미만으로 변경")
void test2() {
// given
Long productId = 200L;
int myprice = ProductService.MIN_MY_PRICE - 50;
ProductMypriceRequestDto requestMyPriceDto = new ProductMypriceRequestDto();
requestMyPriceDto.setMyprice(myprice);
ProductService productService = new ProductService(productRepository, folderRepository, productFolderRepository);
// when
Exception exception = assertThrows(IllegalArgumentException.class, () -> {
productService.updateProduct(productId, requestMyPriceDto);
});
// then
assertEquals(
"유효하지 않은 관심 가격입니다. 최소 " +ProductService.MIN_MY_PRICE + " 원 이상으로 설정해 주세요.",
exception.getMessage()
);
}
}
통합 테스트 (Integration Test)
@SpringBootTest
라는 스프링이 동작하도록 해주는 Annotation을 활용한다. 테스트 수행할 때 스프링이 동작하며, Spring IoC/DI기능을 활용한다. Repository를 사용해 DB CRUD가 가능하다.
예를 들어서 신규 관심상품을 등록할 때 User는 테스트 사용자인 1번 사용자이며, 신규 등록된 관심상품의 희망 최저가를 변경하고 싶다고 하자. 회원 ID로 등록된 모든 관심상품을 조회한다면 아래와 같은 질문을 가질 수 있다.
- 조회된 관심 상품 중 1번 유저가 등록한 관심 상품이 존재하는가?
- 변경된 희망 최저가가 잘 반영되었는가?
// test > service > ProductServiceIntegrationTest.java
package com.sparta.myselectshop.service;
import com.sparta.myselectshop.dto.ProductMypriceRequestDto;
import com.sparta.myselectshop.dto.ProductRequestDto;
import com.sparta.myselectshop.dto.ProductResponseDto;
import com.sparta.myselectshop.entity.User;
import com.sparta.myselectshop.repository.UserRepository;
import org.junit.jupiter.api.*;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.data.domain.Page;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) // 서버의 PORT 를 랜덤으로 설정합니다.
@TestInstance(TestInstance.Lifecycle.PER_CLASS) // 테스트 인스턴스의 생성 단위를 클래스로 변경합니다.
@TestMethodOrder(MethodOrderer.OrderAnnotation.class)
class ProductServiceIntegrationTest {
@Autowired
ProductService productService;
@Autowired
UserRepository userRepository;
User user;
ProductResponseDto createdProduct = null;
int updatedMyPrice = -1;
@Test
@Order(1)
@DisplayName("신규 관심상품 등록")
void test1() {
// given
String title = "Apple <b>에어팟</b> 2세대 유선충전 모델 (MV7N2KH/A)";
String imageUrl = "https://shopping-phinf.pstatic.net/main_1862208/18622086330.20200831140839.jpg";
String linkUrl = "https://search.shopping.naver.com/gate.nhn?id=18622086330";
int lPrice = 173900;
ProductRequestDto requestDto = new ProductRequestDto(
title,
imageUrl,
linkUrl,
lPrice
);
user = userRepository.findById(1L).orElse(null);
// when
ProductResponseDto product = productService.createProduct(requestDto, user);
// then
assertNotNull(product.getId());
assertEquals(title, product.getTitle());
assertEquals(imageUrl, product.getImage());
assertEquals(linkUrl, product.getLink());
assertEquals(lPrice, product.getLprice());
assertEquals(0, product.getMyprice());
createdProduct = product;
}
@Test
@Order(2)
@DisplayName("신규 등록된 관심상품의 희망 최저가 변경")
void test2() {
// given
Long productId = this.createdProduct.getId();
int myPrice = 173000;
ProductMypriceRequestDto requestDto = new ProductMypriceRequestDto();
requestDto.setMyprice(myPrice);
// when
ProductResponseDto product = productService.updateProduct(productId, requestDto);
// then
assertNotNull(product.getId());
assertEquals(this.createdProduct.getTitle(), product.getTitle());
assertEquals(this.createdProduct.getImage(), product.getImage());
assertEquals(this.createdProduct.getLink(), product.getLink());
assertEquals(this.createdProduct.getLprice(), product.getLprice());
assertEquals(myPrice, product.getMyprice());
this.updatedMyPrice = myPrice;
}
@Test
@Order(3)
@DisplayName("회원이 등록한 모든 관심상품 조회")
void test3() {
// given
// when
Page<ProductResponseDto> productList = productService.getProducts(user,
0, 10, "id", false);
// then
// 1. 전체 상품에서 테스트에 의해 생성된 상품 찾아오기 (상품의 id 로 찾음)
Long createdProductId = this.createdProduct.getId();
ProductResponseDto foundProduct = productList.stream()
.filter(product -> product.getId().equals(createdProductId))
.findFirst()
.orElse(null);
// 2. Order(1) 테스트에 의해 생성된 상품과 일치하는지 검증
assertNotNull(foundProduct);
assertEquals(this.createdProduct.getId(), foundProduct.getId());
assertEquals(this.createdProduct.getTitle(), foundProduct.getTitle());
assertEquals(this.createdProduct.getImage(), foundProduct.getImage());
assertEquals(this.createdProduct.getLink(), foundProduct.getLink());
assertEquals(this.createdProduct.getLprice(), foundProduct.getLprice());
// 3. Order(2) 테스트에 의해 myPrice 가격이 정상적으로 업데이트되었는지 검증
assertEquals(this.updatedMyPrice, foundProduct.getMyprice());
}
}
Controller 테스트
// test > mvc > MockSpringSecurityFilter
package com.sparta.myselectshop.mvc;
import jakarta.servlet.*;
import jakarta.servlet.http.HttpServletRequest;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import java.io.IOException;
public class MockSpringSecurityFilter implements Filter {
@Override
public void init(FilterConfig filterConfig) {}
@Override
public void doFilter(ServletRequest req, ServletResponse res, FilterChain chain) throws IOException, ServletException {
SecurityContextHolder.getContext()
.setAuthentication((Authentication) ((HttpServletRequest) req).getUserPrincipal());
chain.doFilter(req, res);
}
@Override
public void destroy() {
SecurityContextHolder.clearContext();
}
}
// test > mvc > UserProductMvcTest.java
package com.sparta.myselectshop.mvc;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.sparta.myselectshop.config.WebSecurityConfig;
import com.sparta.myselectshop.controller.ProductController;
import com.sparta.myselectshop.controller.UserController;
import com.sparta.myselectshop.dto.ProductRequestDto;
import com.sparta.myselectshop.entity.User;
import com.sparta.myselectshop.entity.UserRoleEnum;
import com.sparta.myselectshop.security.UserDetailsImpl;
import com.sparta.myselectshop.service.FolderService;
import com.sparta.myselectshop.service.KakaoService;
import com.sparta.myselectshop.service.ProductService;
import com.sparta.myselectshop.service.UserService;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.context.annotation.ComponentScan;
import org.springframework.context.annotation.FilterType;
import org.springframework.http.MediaType;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.setup.MockMvcBuilders;
import org.springframework.util.LinkedMultiValueMap;
import org.springframework.util.MultiValueMap;
import org.springframework.web.context.WebApplicationContext;
import java.security.Principal;
import static org.springframework.security.test.web.servlet.setup.SecurityMockMvcConfigurers.springSecurity;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.view;
@WebMvcTest(
controllers = {UserController.class, ProductController.class},
excludeFilters = {
@ComponentScan.Filter(
type = FilterType.ASSIGNABLE_TYPE,
classes = WebSecurityConfig.class
)
}
)
class UserProductMvcTest {
private MockMvc mvc;
private Principal mockPrincipal;
@Autowired
private WebApplicationContext context;
@Autowired
private ObjectMapper objectMapper;
@MockBean
UserService userService;
@MockBean
KakaoService kakaoService;
@MockBean
ProductService productService;
@MockBean
FolderService folderService;
@BeforeEach
public void setup() {
mvc = MockMvcBuilders.webAppContextSetup(context)
.apply(springSecurity(new MockSpringSecurityFilter()))
.build();
}
private void mockUserSetup() {
// Mock 테스트 유져 생성
String username = "sollertia4351";
String password = "robbie1234";
String email = "sollertia@sparta.com";
UserRoleEnum role = UserRoleEnum.USER;
User testUser = new User(username, password, email, role);
UserDetailsImpl testUserDetails = new UserDetailsImpl(testUser);
mockPrincipal = new UsernamePasswordAuthenticationToken(testUserDetails, "", testUserDetails.getAuthorities());
}
@Test
@DisplayName("로그인 Page")
void test1() throws Exception {
// when - then
mvc.perform(get("/api/user/login-page"))
.andExpect(status().isOk())
.andExpect(view().name("login"))
.andDo(print());
}
@Test
@DisplayName("회원 가입 요청 처리")
void test2() throws Exception {
// given
MultiValueMap<String, String> signupRequestForm = new LinkedMultiValueMap<>();
signupRequestForm.add("username", "sollertia4351");
signupRequestForm.add("password", "robbie1234");
signupRequestForm.add("email", "sollertia@sparta.com");
signupRequestForm.add("admin", "false");
// when - then
mvc.perform(post("/api/user/signup")
.params(signupRequestForm)
)
.andExpect(status().is3xxRedirection())
.andExpect(view().name("redirect:/api/user/login-page"))
.andDo(print());
}
@Test
@DisplayName("신규 관심상품 등록")
void test3() throws Exception {
// given
this.mockUserSetup();
String title = "Apple <b>아이폰</b> 14 프로 256GB [자급제]";
String imageUrl = "https://shopping-phinf.pstatic.net/main_3456175/34561756621.20220929142551.jpg";
String linkUrl = "https://search.shopping.naver.com/gate.nhn?id=34561756621";
int lPrice = 959000;
ProductRequestDto requestDto = new ProductRequestDto(
title,
imageUrl,
linkUrl,
lPrice
);
String postInfo = objectMapper.writeValueAsString(requestDto);
// when - then
mvc.perform(post("/api/products")
.content(postInfo)
.contentType(MediaType.APPLICATION_JSON)
.accept(MediaType.APPLICATION_JSON)
.principal(mockPrincipal)
)
.andExpect(status().isOk())
.andDo(print());
}
}
// config > JpaConfig
@Configuration // 아래 설정을 등록하여 활성화 합니다.
@EnableJpaAuditing // 시간 자동 변경이 가능하도록 합니다.
public class JpaConfig {
}
API 예외 처리
// org.springframework.http > HttpStatus
public enum HttpStatus {
// 1xx Informational
CONTINUE(100, Series.INFORMATIONAL, "Continue"),
// ...
// 2xx Success
OK(200, Series.SUCCESSFUL, "OK"),
CREATED(201, Series.SUCCESSFUL, "Created"),
// ...
// 3xx Redirection
MULTIPLE_CHOICES(300, Series.REDIRECTION, "Multiple Choices"),
MOVED_PERMANENTLY(301, Series.REDIRECTION, "Moved Permanently"),
FOUND(302, Series.REDIRECTION, "Found"),
// ...
// --- 4xx Client Error ---
BAD_REQUEST(400, Series.CLIENT_ERROR, "Bad Request"),
UNAUTHORIZED(401, Series.CLIENT_ERROR, "Unauthorized"),
PAYMENT_REQUIRED(402, Series.CLIENT_ERROR, "Payment Required"),
FORBIDDEN(403, Series.CLIENT_ERROR, "Forbidden"),
// ...
// --- 5xx Server Error ---
INTERNAL_SERVER_ERROR(500, Series.SERVER_ERROR, "Internal Server Error"),
NOT_IMPLEMENTED(501, Series.SERVER_ERROR, "Not Implemented"),
BAD_GATEWAY(502, Series.SERVER_ERROR, "Bad Gateway"),
// ...
ResponseEntity
// RestApiException.java
@Getter
@AllArgsConstructor
public class RestApiException {
private String errorMessage;
private int statusCode;
}
// ResponseEntity
@PostMapping("/folders")
public ResponseEntity<RestApiException> addFolders(@RequestBody FolderRequestDto folderRequestDto,
@AuthenticationPrincipal UserDetailsImpl userDetails) {
try {
List<String> folderNames = folderRequestDto.getFolderNames();
folderService.addFolders(folderNames, userDetails.getUser());
return new ResponseEntity<>(HttpStatus.OK);
} catch(IllegalArgumentException ex) {
RestApiException restApiException = new RestApiException(ex.getMessage(), HttpStatus.BAD_REQUEST.value());
return new ResponseEntity<>(
// HTTP body
restApiException,
// HTTP status code
HttpStatus.BAD_REQUEST);
}
}
@ExceptionHandler
Spring에서 예외 처리를 위해 지원하는 애너테이션이다. 특정 Controller에서 발생한 예외처리를 위해 사용하며, @ExceptionHandler
가 붙어있는 메서드는 Controller에서 예외가 발생했을 때 호출되고, 해당 예외를 처리하는 로직을 담는다. AOP를 활용하므로, 모든 메서드마다 try-catch
가 필요없지만, 모든 Controller에 개별적으로 추가해야한다는 단점이 있다.
// @ExceptionHandler 예제
@ExceptionHandler({IllegalArgumentException.class})
public ResponseEntity<RestApiException> handleException(IllegalArgumentException ex) {
RestApiException restApiException = new RestApiException(ex.getMessage(), HttpStatus.BAD_REQUEST.value());
return new ResponseEntity<>(
// HTTP body
restApiException,
// HTTP status code
HttpStatus.BAD_REQUEST
);
}
Global 예외처리 방법
@ExceptionHandler
가 모든 Controller에 예외처리를 필요로한다는 단점을 극복하기위해 활용할 수 있는 방법이다. @ControllerAdvice
이라는 Spring에서 예외처리를 위한 클래스 레벨 Annotation을 활용한다. 이 것은 모든 Controller에서 발생한 예외를 처리하기 위해 사용한다. @ControllerAdvice
가 붙은 클래스라면 @ExceptionHandler
메서드를 정의해 예외를 처리하는 로직을 담을 수 있다. 중앙 집중화하기 좋으며, 각각의 Controller에서 예외처리 로직을 반복하지 않아도 되기때문에 코드의 중복을 방지하고 유지보수성을 높일 수 있다. 또한 이를 활용하면 예외 처리 로직을 모듈화해서 관리하기 쉬우므로, 팀 내에서 공통된 처리 로직을 공유하고 다른 팀에서의 예외 처리를 참고할 수 있다. 개발 생산성을 향상시켜준다.
@RestControllerAdvice(
@ControllerAdvice + @ResponseBody)
@RestControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler({IllegalArgumentException.class})
public ResponseEntity<RestApiException> handleException(IllegalArgumentException ex) {
RestApiException restApiException = new RestApiException(ex.getMessage(), HttpStatus.BAD_REQUEST.value());
return new ResponseEntity<>(
// HTTP body
restApiException,
// HTTP status code
HttpStatus.BAD_REQUEST
);
}
}
Error메시지 관리하기
Spring의 properties 파일을 이용하여 에러 메시지를 관리할 수 있다. 에러 메시지는 properties
파일에서 key-value 형태로 작성되고, 작성된 값은 messageSource
를 Bean으로 등록해 사용할 수 있다. SpringBoot 에서는 messageSource
가 자동으로 Bean으로 등록된다.
// resources > messages.properties
below.min.my.price=최저 희망가는 최소 {0}원 이상으로 설정해 주세요.
not.found.product=해당 상품이 존재하지 않습니다.
// ProductResponseDto.java
// messageSource는 SpringBoot에서 자동으로 Bean으로 등록
private final MessageSource messageSource;
...
@Transactional
public ProductResponseDto updateProduct(Long id, ProductMypriceRequestDto requestDto) {
int myprice = requestDto.getMyprice();
if (myprice < MIN_MY_PRICE) {
throw new IllegalArgumentException(messageSource.getMessage(
"below.min.my.price",
new Integer[]{MIN_MY_PRICE},
"Wrong Price",
Locale.getDefault()
));
}
Product product = productRepository.findById(id).orElseThrow(() ->
new ProductNotFoundException(messageSource.getMessage(
"not.found.product",
null,
"Not Found Product",
Locale.getDefault()
))
);
product.update(requestDto);
return new ProductResponseDto(product);
}
Exception 클래스를 직접 구현해 사용할 수도 있다.
// ProductNotFoundException.java
package com.sparta.myselectshop.exception;
public class ProductNotFoundException extends RuntimeException{
public ProductNotFoundException(String message) {
super(message);
}
}
messageSource.getMessage()
메서드를 활용하면 아래와 같은 파라미터를 매개변수로 활용하게 된다.
- 첫 번째 파라미터는
messages.properties
파일에서 가져올 메시지의 키 값을 전달한다. - 두 번째 파라미터는 메시지 내에서 매개변수를 사용할 때 전달하는 값이다.
- 세 번째 파라미터는 언어 설정을 전달한다.
Locale.getDefault()
메서드는 기본 언어 설정을 가져오는 메서드다.
// GlobalExceptionHandler.java
package com.sparta.myselectshop.exception;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
@RestControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler({IllegalArgumentException.class})
public ResponseEntity<RestApiException> illegalArgumentExceptionHandler(IllegalArgumentException ex) {
RestApiException restApiException = new RestApiException(ex.getMessage(), HttpStatus.BAD_REQUEST.value());
return new ResponseEntity<>(
// HTTP body
restApiException,
// HTTP status code
HttpStatus.BAD_REQUEST
);
}
@ExceptionHandler({NullPointerException.class})
public ResponseEntity<RestApiException> nullPointerExceptionHandler(NullPointerException ex) {
RestApiException restApiException = new RestApiException(ex.getMessage(), HttpStatus.NOT_FOUND.value());
return new ResponseEntity<>(
// HTTP body
restApiException,
// HTTP status code
HttpStatus.NOT_FOUND
);
}
@ExceptionHandler({ProductNotFoundException.class})
public ResponseEntity<RestApiException> notFoundProductExceptionHandler(ProductNotFoundException ex) {
RestApiException restApiException = new RestApiException(ex.getMessage(), HttpStatus.NOT_FOUND.value());
return new ResponseEntity<>(
// HTTP body
restApiException,
// HTTP status code
HttpStatus.NOT_FOUND
);
}
}
참조:
(1) https://pixabay.com/illustrations/checklist-clipboard-questionnaire-1622517/