프로그래밍 일기 — 테스트

배우는 자(Learner Of Life)
49 min readSep 14, 2023

--

프로그래밍은 끝없는 시험(Test)의 연속

프로그래밍을 배운다는 건 끝없는 시험의 연속인 것 같다(1).

코드를 작성하면 그것에 대해서 일반적으로는 테스팅 코드(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());
}
}

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();
}
// ...
}

ProductServiceupdateProduct 라는 메서드를 테스트한다면 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() 메서드를 활용하면 아래와 같은 파라미터를 매개변수로 활용하게 된다.

  1. 첫 번째 파라미터는 messages.properties 파일에서 가져올 메시지의 키 값을 전달한다.
  2. 두 번째 파라미터는 메시지 내에서 매개변수를 사용할 때 전달하는 값이다.
  3. 세 번째 파라미터는 언어 설정을 전달한다. 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/

--

--

배우는 자(Learner Of Life)
배우는 자(Learner Of Life)

Written by 배우는 자(Learner Of Life)

배움은 죽을 때까지 끝이 없다. 어쩌면 그게 우리가 살아있다는 증거일지도 모른다. 배움을 멈추는 순간, 혹은 배움의 기회가 더 이상 존재하지 않는 순간, 우리의 삶은 어쩌면 거기서 끝나는 것은 아닐까? 나는 배운다 그러므로 나는 존재한다. 배울 수 있음에, 그래서 살아 있음에 감사한다.

No responses yet