이번 글에서는 Order, Item에 관한 CRUD 구현과 관련 통합 테스트를 담은 내용이다.
하지만, 시작하기전에 앞 글에서 수정한 내용이 있다.
1. SecurityConfiguration 수정
주문과 관련된 사항들은 이메일 인증이 된 사용자들이 이용하는게 좋을 것 같다는 생각이 들어 관련하여 적용했다.
public class SecurityConfiguration {
...
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception{
...
http.authorizeHttpRequests(
authorize -> authorize.requestMatchers(EXCLUDE_URL.stream()
.map(AntPathRequestMatcher::new)
.toArray(RequestMatcher[]::new)).permitAll()
.requestMatchers("/orders/**").hasRole("VERIFIED_USER")
.anyRequest().hasAnyRole("USER", "VERIFIED_USER"));
return http.build();
}
...
}
2. CORS 설정
기존에 SpringBoot 2.x.x에서 사용되던 Spring Security 설정들이 많이 deprecated 되었다. 따라서, CORS 설정을 따로 안했는데 다시 설정했다.
public class SecurityConfiguration {
...
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception{
...
http.csrf(AbstractHttpConfigurer::disable).cors(httpSecurityCorsConfigurer -> httpSecurityCorsConfigurer.configurationSource(corsConfigurationSource()));
.sessionManagement(configurer -> configurer.sessionCreationPolicy(SessionCreationPolicy.STATELESS));
...
return http.build();
}
@Bean
CorsConfigurationSource corsConfigurationSource(){
CorsConfiguration configuration = new CorsConfiguration();
configuration.setAllowedOrigins(List.of("*"));
configuration.setAllowedMethods(Arrays.asList("GET", "POST", "PATCH", "DELETE"));
configuration.setAllowCredentials(true);
configuration.addExposedHeader("Authorization");
configuration.addExposedHeader("Refresh");
configuration.addAllowedHeader("*");
configuration.setMaxAge(3600L);
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/**", configuration);
return source;
}
...
}
본론
Item 및 Order에 관한 CRUD 부터 설명하려 한다.
최대한, 클린 코드를 짜기 위해 노력하긴 했는데 많이 부족한 것 같다.(객체지향적 설계를 위해 노력하긴 했다.)
ItemController
@RestController
@RequiredArgsConstructor
@RequestMapping("/items")
public class ItemController {
private final ItemService itemService;
@GetMapping
public ResponseEntity items(){
List<ItemDto> response = itemService.findAll();
return new ResponseEntity<>(new SingleResponseDto<>(response), HttpStatus.OK);
}
@PostMapping("/new/book")
public ResponseEntity newBook(@Valid @RequestBody ItemRequestDto.BookRequestDto bookDto){
ItemDto.BookDto response = itemService.saveBook(bookDto);
return new ResponseEntity<>(new SingleResponseDto<>(response), HttpStatus.OK);
}
@PostMapping("/new/album")
public ResponseEntity newAlbum(@Valid @RequestBody ItemRequestDto.AlbumRequestDto albumDto){
ItemDto.AlbumDto response = itemService.saveAlbum(albumDto);
return new ResponseEntity<>(new SingleResponseDto<>(response), HttpStatus.OK);
}
@PostMapping("/new/movie")
public ResponseEntity newMovie(@Valid @RequestBody ItemRequestDto.MovieRequestDto movieDto){
ItemDto.MovieDto response = itemService.saveMovie(movieDto);
return new ResponseEntity<>(new SingleResponseDto<>(response), HttpStatus.OK);
}
@PostMapping("/{itemId}/edit")
public ResponseEntity updateItem(@PathVariable("itemId") Long itemId, @Valid @RequestBody ItemRequestDto itemDto){
ItemDto response = itemService.updateItem(itemId, itemDto);
return new ResponseEntity<>(new SingleResponseDto<>(response), HttpStatus.OK);
}
@DeleteMapping("/{itemId}")
public ResponseEntity deleteItem(@PathVariable("itemId") Long itemId){
String response = itemService.deleteItem(itemId);
return new ResponseEntity<>(new SingleResponseDto<>(response), HttpStatus.OK);
}
}
- items(): 아이템 목록을 쭉 보내주는 메서드이다. ItemDto 리스트로 반환한다.
- newBook(): 아이템 종류 중 Book이 새롭게 추가될 때 사용되는 메서드이다.
- newAlbum(): 아이템 종류 중 Album이 새롭게 추가될 때 사용되는 메서드이다.
- newMovie(): 아이템 종류 중 Moive가 새롭게 추가될 때 사용되는 메서드이다.
- updateItem(): 아이템을 업데이트 할 때 사용되는 메서드이다.
- deleteItem(): 아이템을 삭제할 때 사용되는 메서드이다.
여기서 아이템을 반환할 때는 ItemDto를 사용하였고 전달받을 때는 ItemRequestDto를 사용하였다.
맨처음에는 ItemDto로 통일하였는데 ItemDto는 Item의 pk가 포함되어 있어 전달받을 땐 위험하기에
ItemRequestDto로 수정하였다.
ItemDto
@Getter @Setter
@NoArgsConstructor
public class ItemDto {
private Long itemId;
@NotBlank(message = "이름이 입력되지 않았습니다.")
private String name;
@Min(value = 1, message = "최소 1개")
private int price;
@Min(value = 1, message = "최소 1개")
private int stockQuantity;
public ItemDto(String name, int price, int stockQuantity) {
this.name = name;
this.price = price;
this.stockQuantity = stockQuantity;
}
public ItemDto(Item item){
this.setItemId(item.getId());
this.setName(item.getName());
this.setPrice(item.getPrice());
this.setStockQuantity(item.getStockQuantity());
}
@Getter @Setter
@NoArgsConstructor
public static class BookDto extends ItemDto{
@NotBlank(message = "Author이 입력되지 않았습니다.")
private String author;
@NotBlank(message = "Isbn이 입력되지 않았습니다.")
private String isbn;
public BookDto(String name, int price, int stockQuantity, String author, String isbn) {
super(name, price, stockQuantity);
this.author = author;
this.isbn = isbn;
}
public BookDto(Book book){
this.setItemId(book.getId());
this.setName(book.getName());
this.setPrice(book.getPrice());
this.setStockQuantity(book.getStockQuantity());
this.setAuthor(book.getAuthor());
this.setIsbn(book.getIsbn());
}
}
@Getter @Setter
@NoArgsConstructor
public static class AlbumDto extends ItemDto{
@NotBlank(message = "아티스트 입력되지 않았습니다.")
private String artist;
@NotBlank(message = "ETC가 입력되지 않았습니다.")
private String etc;
public AlbumDto(String name, int price, int stockQuantity, String artist, String etc) {
super(name, price, stockQuantity);
this.artist = artist;
this.etc = etc;
}
public AlbumDto(Album album){
this.setItemId(album.getId());
this.setName(album.getName());
this.setPrice(album.getPrice());
this.setStockQuantity(album.getStockQuantity());
this.setArtist(album.getArtist());
this.setEtc(album.getEtc());
}
}
@Getter @Setter
@NoArgsConstructor
public static class MovieDto extends ItemDto{
@NotBlank(message = "감독이 입력되지 않았습니다.")
private String director;
@NotBlank(message = "배우가 입력되지 않았습니다.")
private String actor;
public MovieDto(String name, int price, int stockQuantity, String director, String actor) {
super(name, price, stockQuantity);
this.director = director;
this.actor = actor;
}
public MovieDto(Movie movie){
this.setItemId(movie.getId());
this.setName(movie.getName());
this.setPrice(movie.getPrice());
this.setStockQuantity(movie.getStockQuantity());
this.setDirector(movie.getDirector());
this.setActor(movie.getActor());
}
}
}
ItemDto의 그 하위 아이템들이 ItemDto를 상속받아 관련 내용을 구현하게 작성하였다.
그리고 각 생성자를 통해 Entity값을 받아 XXXDto로 생성시킨다.
ItemRequestDto
@Getter @Setter
@NoArgsConstructor
public class ItemRequestDto {
@NotBlank(message = "이름이 입력되지 않았습니다.")
private String name;
@Min(value = 1, message = "최소 1개")
private int price;
@Min(value = 1, message = "최소 1개")
private int stockQuantity;
public ItemRequestDto(String name, int price, int stockQuantity) {
this.name = name;
this.price = price;
this.stockQuantity = stockQuantity;
}
@Getter
@Setter
@NoArgsConstructor
public static class BookRequestDto extends ItemRequestDto{
@NotBlank(message = "Author이 입력되지 않았습니다.")
private String author;
@NotBlank(message = "Isbn이 입력되지 않았습니다.")
private String isbn;
public BookRequestDto(String name, int price, int stockQuantity, String author, String isbn) {
super(name, price, stockQuantity);
this.author = author;
this.isbn = isbn;
}
}
@Getter @Setter
@NoArgsConstructor
public static class AlbumRequestDto extends ItemRequestDto{
@NotBlank(message = "아티스트 입력되지 않았습니다.")
private String artist;
@NotBlank(message = "ETC가 입력되지 않았습니다.")
private String etc;
public AlbumRequestDto(String name, int price, int stockQuantity, String artist, String etc) {
super(name, price, stockQuantity);
this.artist = artist;
this.etc = etc;
}
}
@Getter @Setter
@NoArgsConstructor
public static class MovieRequestDto extends ItemRequestDto{
@NotBlank(message = "감독이 입력되지 않았습니다.")
private String director;
@NotBlank(message = "배우가 입력되지 않았습니다.")
private String actor;
public MovieRequestDto(String name, int price, int stockQuantity, String director, String actor) {
super(name, price, stockQuantity);
this.director = director;
this.actor = actor;
}
public MovieRequestDto(Movie movie){
this.setName(movie.getName());
this.setPrice(movie.getPrice());
this.setStockQuantity(movie.getStockQuantity());
this.setDirector(movie.getDirector());
this.setActor(movie.getActor());
}
}
}
ItemDto와 비슷하지만, ItemId가 없다.
ItemService
@Service
@Transactional(readOnly = true)
@RequiredArgsConstructor
public class ItemService {
private final ItemRepository itemRepository;
@Transactional
public ItemDto.BookDto saveBook(ItemRequestDto.BookRequestDto bookDto){
Book book = Book.toEntity(bookDto);
itemRepository.save(book);
return new ItemDto.BookDto(book);
}
@Transactional
public ItemDto.AlbumDto saveAlbum(ItemRequestDto.AlbumRequestDto albumDto){
Album album = Album.toEntity(albumDto);
itemRepository.save(album);
return new ItemDto.AlbumDto(album);
}
@Transactional
public ItemDto.MovieDto saveMovie(ItemRequestDto.MovieRequestDto movieDto){
Movie movie = Movie.toEntity(movieDto);
itemRepository.save(movie);
return new ItemDto.MovieDto(movie);
}
@Transactional
public ItemDto updateItem(Long itemId, ItemRequestDto itemDto){
Item item = itemRepository.findById(itemId).orElseThrow(() -> new BusinessLogicException(ErrorCode.ITEM_NOT_FOUND));
item.setName(itemDto.getName());
item.setPrice(itemDto.getPrice());
item.setStockQuantity(itemDto.getStockQuantity());
return new ItemDto(item);
}
public List<ItemDto> findAll() {
return itemRepository.findAll().stream()
.map(this::convertToDto)
.collect(Collectors.toList());
}
private ItemDto convertToDto(Item item) {
if(item instanceof Book){
return new ItemDto.BookDto((Book) item);
} else if(item instanceof Album){
return new ItemDto.AlbumDto((Album) item);
} else if(item instanceof Movie){
return new ItemDto.MovieDto((Movie) item);
} else{
throw new BusinessLogicException(ErrorCode.INVALID_ITEM);
}
}
@Transactional
public String deleteItem(Long itemId) {
itemRepository.delete(
itemRepository.findById(itemId).orElseThrow(() -> new BusinessLogicException(ErrorCode.ITEM_NOT_FOUND))
);
return "Item 삭제가 완료되었습니다.";
}
}
- saveBook(): 전달받은 Dto를 Book Entity의 toEntity 메서드를 통해 Book으로 변환하여 저장한다.
- saveAlbum(): 전달받은 Dto를 Album Entity의 toEntity 메서드를 통해 Album으로 변환하여 저장한다.
- saveMovie(): 전달받은 Dto를 Movie Entity의 toEntity 메서드를 통해 Movie로 변환하여 저장한다.
- updateItem(): JPA의 강점을 활용해서 영속성 컨텍스트로 불러들인 뒤 Setter를 활용하여 Item을 업데이트한다.
- findAll(): DB의 Item값들을 읽어와 속성을 비교하여 Dto로 변환한 뒤 ItemDto의 리스트로 반환한다.
- convertToDto(): Java에서 객체를 비교해주는 instanceof를 사용하여 맞는 형식의 Dto를 생성하여 반환한다. 만약 값이 없다면 예외처리를 한다.
- deleteItem(): 우선, 해당 Item이 존재하는지 확인하고 존재하지 않는다면 예외처리를 한다.
ItemIntegrationTest
Item 통합 테스트이다. Item과 관련된 작업들에 대해 통합 테스트를 진행한다.
public class ItemIntegrationTest extends BaseIntegrationTest {
private final String BASE_URL = "/items";
private final String EMAIL = "email@gmail.com";
@Autowired
private ItemService itemService;
@Autowired
private UserService userService;
@Autowired
private JwtTokenProvider jwtTokenProvider;
@Autowired
private AES128Config aes128Config;
@BeforeEach
void beforeEach(){
UserDto.SignUp signUpDto = StubData.MockUser.getSignUpDto();
userService.signUp(signUpDto);
}
@Test
@DisplayName("Book 생성 테스트")
public void createBook() throws Exception{
//given
User user = userService.findUserByEmail(EMAIL);
CustomUserDetails userDetails = CustomUserDetails.of(user);
TokenDto tokenDto = jwtTokenProvider.generateToken(userDetails);
String accessToken = tokenDto.getAccessToken();
String refreshToken = tokenDto.getRefreshToken();
String encryptedRefreshToken = aes128Config.encryptAes(refreshToken);
ItemRequestDto.BookRequestDto bookDto = StubData.MockItem.getBookDto();
//when
String uri = UriComponentsBuilder.newInstance().path(BASE_URL + "/new/book")
.build().toUri().toString();
String json = ObjectMapperUtils.asJsonString(bookDto);
ResultActions actions = ResultActionsUtils.postRequestWithTokenAndJson(mockMvc, uri, accessToken, encryptedRefreshToken, json);
//then
actions
.andExpect(status().isOk())
.andDo(document("new-book",
getRequestPreProcessor(),
getResponsePreProcessor(),
ItemResponseSnippet.newBookSnippet()));
}
@Test
@DisplayName("Album 생성 테스트")
public void createAlbum() throws Exception{
//given
User user = userService.findUserByEmail(EMAIL);
CustomUserDetails userDetails = CustomUserDetails.of(user);
TokenDto tokenDto = jwtTokenProvider.generateToken(userDetails);
String accessToken = tokenDto.getAccessToken();
String refreshToken = tokenDto.getRefreshToken();
String encryptedRefreshToken = aes128Config.encryptAes(refreshToken);
ItemRequestDto.AlbumRequestDto albumDto = StubData.MockItem.getAlbumDto();
//when
String uri = UriComponentsBuilder.newInstance().path(BASE_URL + "/new/album")
.build().toUri().toString();
String json = ObjectMapperUtils.asJsonString(albumDto);
ResultActions actions = ResultActionsUtils.postRequestWithTokenAndJson(mockMvc, uri, accessToken, encryptedRefreshToken, json);
//then
actions
.andExpect(status().isOk())
.andDo(document("new-album",
getRequestPreProcessor(),
getResponsePreProcessor(),
ItemResponseSnippet.newAlbumSnippet()));
}
@Test
@DisplayName("Movie 생성 테스트")
public void createMovie() throws Exception{
//given
User user = userService.findUserByEmail(EMAIL);
CustomUserDetails userDetails = CustomUserDetails.of(user);
TokenDto tokenDto = jwtTokenProvider.generateToken(userDetails);
String accessToken = tokenDto.getAccessToken();
String refreshToken = tokenDto.getRefreshToken();
String encryptedRefreshToken = aes128Config.encryptAes(refreshToken);
ItemRequestDto.MovieRequestDto movieDto = StubData.MockItem.getMovieDto();
//when
String uri = UriComponentsBuilder.newInstance().path(BASE_URL + "/new/movie")
.build().toUri().toString();
String json = ObjectMapperUtils.asJsonString(movieDto);
ResultActions actions = ResultActionsUtils.postRequestWithTokenAndJson(mockMvc, uri, accessToken, encryptedRefreshToken, json);
//then
actions
.andExpect(status().isOk())
.andDo(document("new-movie",
getRequestPreProcessor(),
getResponsePreProcessor(),
ItemResponseSnippet.newMovieSnippet()));
}
@Test
@DisplayName("Item 정보 받아오기")
public void getItems() throws Exception{
//given
User user = userService.findUserByEmail(EMAIL);
CustomUserDetails userDetails = CustomUserDetails.of(user);
TokenDto tokenDto = jwtTokenProvider.generateToken(userDetails);
String accessToken = tokenDto.getAccessToken();
String refreshToken = tokenDto.getRefreshToken();
String encryptedRefreshToken = aes128Config.encryptAes(refreshToken);
createItems();
//when
String uri = UriComponentsBuilder.newInstance().path(BASE_URL)
.build().toUri().toString();
ResultActions actions = ResultActionsUtils.getRequestWithToken(mockMvc, uri, accessToken, encryptedRefreshToken);
//then
actions
.andExpect(status().isOk())
.andDo(document("get-items",
getRequestPreProcessor(),
getResponsePreProcessor(),
ItemResponseSnippet.getItemsSnippet()));
}
@Test
@DisplayName("Item 수정 테스트")
public void updateItem() throws Exception{
//given
User user = userService.findUserByEmail(EMAIL);
CustomUserDetails userDetails = CustomUserDetails.of(user);
TokenDto tokenDto = jwtTokenProvider.generateToken(userDetails);
String accessToken = tokenDto.getAccessToken();
String refreshToken = tokenDto.getRefreshToken();
String encryptedRefreshToken = aes128Config.encryptAes(refreshToken);
ItemRequestDto.MovieRequestDto movieDto = StubData.MockItem.getMovieDto();
ItemDto.MovieDto newMovie = itemService.saveMovie(movieDto);
ItemRequestDto itemDto = StubData.MockItem.updateItemDto();
//when
String uri = UriComponentsBuilder.newInstance().path(BASE_URL + "/" + newMovie.getItemId() + "/edit")
.build().toUri().toString();
String json = ObjectMapperUtils.asJsonString(itemDto);
ResultActions actions = ResultActionsUtils.postRequestWithTokenAndJson(mockMvc, uri, accessToken, encryptedRefreshToken, json);
//then
SingleResponseDto result = ObjectMapperUtils.actionsSingleResponseToItemDto(actions);
ItemDto response = (ItemDto) result.getData();
assertThat(response.getName()).isEqualTo(itemDto.getName());
assertThat(response.getPrice()).isEqualTo(itemDto.getPrice());
assertThat(response.getStockQuantity()).isEqualTo(itemDto.getStockQuantity());
actions
.andExpect(status().isOk())
.andDo(document("update-item",
getRequestPreProcessor(),
getResponsePreProcessor(),
ItemResponseSnippet.updateItemSnippet()));
}
@Test
@DisplayName("Item 삭제 테스트")
public void deleteItem() throws Exception{
//given
User user = userService.findUserByEmail(EMAIL);
CustomUserDetails userDetails = CustomUserDetails.of(user);
TokenDto tokenDto = jwtTokenProvider.generateToken(userDetails);
String accessToken = tokenDto.getAccessToken();
String refreshToken = tokenDto.getRefreshToken();
String encryptedRefreshToken = aes128Config.encryptAes(refreshToken);
ItemRequestDto.MovieRequestDto movieDto = StubData.MockItem.getMovieDto();
ItemDto.MovieDto newMovie = itemService.saveMovie(movieDto);
//when
String uri = UriComponentsBuilder.newInstance().path(BASE_URL + "/" + newMovie.getItemId())
.build().toUri().toString();
ResultActions actions = ResultActionsUtils.deleteRequestWithToken(mockMvc, uri, accessToken, encryptedRefreshToken);
//then
assertThrows(BusinessLogicException.class, () -> itemService.deleteItem(newMovie.getItemId()));
actions
.andExpect(status().isOk())
.andDo(document("delete-item",
getRequestPreProcessor(),
getResponsePreProcessor(),
ItemResponseSnippet.deleteItemSnippet()));
}
private void createItems() {
ItemRequestDto.BookRequestDto bookDto = StubData.MockItem.getBookDto();
ItemRequestDto.AlbumRequestDto albumDto = StubData.MockItem.getAlbumDto();
ItemRequestDto.MovieRequestDto movieDto = StubData.MockItem.getMovieDto();
itemService.saveMovie(movieDto);
itemService.saveBook(bookDto);
itemService.saveAlbum(albumDto);
}
}
StubData.MockItem
Item에 관한 MockItem을 생성하는 StubData이다.
Stub은 하향식 설계에서 사용되어 Test Stub이라고 부른다.
public static class MockItem extends StubData{
public static ItemRequestDto.BookRequestDto getBookDto(){
return new ItemRequestDto.BookRequestDto("book", 1000, 1000, "book", "book");
}
public static ItemRequestDto.AlbumRequestDto getAlbumDto(){
return new ItemRequestDto.AlbumRequestDto("album", 1000, 1000, "album", "album");
}
public static ItemRequestDto.MovieRequestDto getMovieDto(){
return new ItemRequestDto.MovieRequestDto("movie", 1000, 1000, "movie", "movie");
}
public static ItemRequestDto updateItemDto(){
return new ItemRequestDto("test2", 2000, 2000);
}
}
ItemResponseSnippet
ItemController로부터 전달받은 응답에 관한 Snippet이다.
public class ItemResponseSnippet {
public static Snippet newBookSnippet() {
return responseFields(
List.of(
fieldWithPath("data").type(JsonFieldType.OBJECT).description("결과 데이터"),
fieldWithPath("data.itemId").type(JsonFieldType.NUMBER).description("아이템 ID"),
fieldWithPath("data.name").type(JsonFieldType.STRING).description("책 이름"),
fieldWithPath("data.price").type(JsonFieldType.NUMBER).description("책 가격"),
fieldWithPath("data.stockQuantity").type(JsonFieldType.NUMBER).description("책 재고"),
fieldWithPath("data.author").type(JsonFieldType.STRING).description("책 저자"),
fieldWithPath("data.isbn").type(JsonFieldType.STRING).description("책 식별번호")
)
);
}
public static Snippet newAlbumSnippet() {
return responseFields(
List.of(
fieldWithPath("data").type(JsonFieldType.OBJECT).description("결과 데이터"),
fieldWithPath("data.itemId").type(JsonFieldType.NUMBER).description("아이템 ID"),
fieldWithPath("data.name").type(JsonFieldType.STRING).description("앨범 이름"),
fieldWithPath("data.price").type(JsonFieldType.NUMBER).description("앨범 가격"),
fieldWithPath("data.stockQuantity").type(JsonFieldType.NUMBER).description("앨범 재고"),
fieldWithPath("data.artist").type(JsonFieldType.STRING).description("앨범 가수"),
fieldWithPath("data.etc").type(JsonFieldType.STRING).description("앨범 ETC")
)
);
}
public static Snippet newMovieSnippet() {
return responseFields(
List.of(
fieldWithPath("data").type(JsonFieldType.OBJECT).description("결과 데이터"),
fieldWithPath("data.itemId").type(JsonFieldType.NUMBER).description("아이템 ID"),
fieldWithPath("data.name").type(JsonFieldType.STRING).description("영화 이름"),
fieldWithPath("data.price").type(JsonFieldType.NUMBER).description("영화 가격"),
fieldWithPath("data.stockQuantity").type(JsonFieldType.NUMBER).description("영화 재고"),
fieldWithPath("data.director").type(JsonFieldType.STRING).description("영화 감독"),
fieldWithPath("data.actor").type(JsonFieldType.STRING).description("영화 배우")
)
);
}
public static Snippet getItemsSnippet() {
return responseFields(
List.of(
fieldWithPath("data[]").type(JsonFieldType.ARRAY).description("결과 데이터"),
fieldWithPath("data[].itemId").type(JsonFieldType.NUMBER).description("아이템 ID"),
fieldWithPath("data[].name").type(JsonFieldType.STRING).description("아이템 이름"),
fieldWithPath("data[].price").type(JsonFieldType.NUMBER).description("아이템 가격"),
fieldWithPath("data[].stockQuantity").type(JsonFieldType.NUMBER).description("아이템 재고"),
fieldWithPath("data[].director").type(JsonFieldType.STRING).optional().description("영화 감독"),
fieldWithPath("data[].actor").type(JsonFieldType.STRING).optional().description("영화 배우"),
fieldWithPath("data[].author").type(JsonFieldType.STRING).optional().description("책 저자"),
fieldWithPath("data[].isbn").type(JsonFieldType.STRING).optional().description("책 ISBN 번호"),
fieldWithPath("data[].artist").type(JsonFieldType.STRING).optional().description("음반 아티스트"),
fieldWithPath("data[].etc").type(JsonFieldType.STRING).optional().description("음반 기타 정보")
)
);
}
public static Snippet updateItemSnippet() {
return responseFields(
List.of(
fieldWithPath("data").type(JsonFieldType.OBJECT).description("결과 데이터"),
fieldWithPath("data.itemId").type(JsonFieldType.NUMBER).description("아이템 ID"),
fieldWithPath("data.name").type(JsonFieldType.STRING).description("영화 이름"),
fieldWithPath("data.price").type(JsonFieldType.NUMBER).description("영화 가격"),
fieldWithPath("data.stockQuantity").type(JsonFieldType.NUMBER).description("영화 재고")
)
);
}
public static Snippet deleteItemSnippet() {
return responseFields(
List.of(
fieldWithPath("data").type(JsonFieldType.STRING).description("결과 데이터")
)
);
}
}
OrderController
@Slf4j
@RestController
@RequestMapping("/orders")
@RequiredArgsConstructor
public class OrderController {
private final OrderService orderService;
@PostMapping
public ResponseEntity order(@AuthenticationPrincipal CustomUserDetails user, @RequestParam("itemId") Long itemId, @RequestParam("count") int count){
OrderResponseDto orderResponseDto = orderService.order(user.getEmail(), itemId, count);
return new ResponseEntity<>(new SingleResponseDto<>(orderResponseDto), HttpStatus.CREATED);
}
@GetMapping
public ResponseEntity order(@AuthenticationPrincipal CustomUserDetails user, @Nullable @RequestParam("itemName") String itemName, @Nullable @RequestParam OrderStatus orderStatus){
List<OrderDto> response = orderService.findSearchOrder(new OrderSearch(user.getEmail(), itemName, orderStatus));
return new ResponseEntity<>(new SingleResponseDto<>(response), HttpStatus.OK);
}
@GetMapping("/pages")
public ResponseEntity orderWithPage(@AuthenticationPrincipal CustomUserDetails user,
@RequestParam(value = "offset", defaultValue = "0") int offset,
@RequestParam(value = "limit", defaultValue = "100") int limit){
List<OrderDto> response = orderService.findAll(user.getEmail(), offset, limit);
return new ResponseEntity<>(new SingleResponseDto<>(response), HttpStatus.OK);
}
@PostMapping("/{orderId}/cancel")
public ResponseEntity orderCancel(@PathVariable("orderId") Long orderId){
String response = orderService.cancelOrder(orderId);
return new ResponseEntity<>(new SingleResponseDto<>(response), HttpStatus.ACCEPTED);
}
}
- order()-Post: order를 생성하는데 사용되는 메서드이다. Login하고 인증된 사용자가 주문이 가능하다. CustomUserDetails로부터 email 정보를 받아오고 itemId와 count를 Param으로 전달받는다.
- order()-Get: order정보를 읽어와 OrderDto형태로 리스트 반환을 한다.
- orderWithPage(): Order 정보를 페이징하여 반환한다.(많이 부족함. 현재 토이 프로젝트 목표와는 맞지 않다고 생각해 따로 새롭게 연습 예정)
- orderCancel(): 주문을 취소하는데 사용되는 메서드이다.
OrderService
@Service
@Transactional(readOnly = true)
@RequiredArgsConstructor
public class OrderService {
private final OrderRepository orderRepository;
private final UserRepository userRepository;
private final ItemRepository itemRepository;
/**
* 주문
*/
@Transactional
public OrderResponseDto order(String email, Long itemId, int count){
User user = userRepository.findByEmail(email).orElseThrow(() -> new BusinessLogicException(ErrorCode.USER_NOT_FOUND));
Item item = itemRepository.findById(itemId).orElseThrow(() -> new BusinessLogicException(ErrorCode.ITEM_NOT_FOUND));
Delivery delivery = new Delivery();
delivery.setAddress(user.getAddress());
OrderItem orderItem = OrderItem.createOrderItem(item, item.getPrice(), count);
Order order = Order.createOrder(user, delivery, orderItem);
orderRepository.save(order);
return new OrderResponseDto(order);
}
public List<OrderDto> findSearchOrder(OrderSearch orderSearch) {
List<Order> orders = orderRepository.findAllWithItem(orderSearch);
List<OrderDto> result = orders.stream()
.map(OrderDto::new)
.collect(Collectors.toList());
return result;
}
public List<OrderDto> findAll(String email, int offset, int limit) {
Pageable pageable = PageRequest.of(offset, limit);
List<Order> orders = orderRepository.findAllWithItemPageable(pageable, email);
List<OrderDto> result = orders.stream()
.map(OrderDto::new)
.collect(Collectors.toList());
return result;
}
@Transactional
public String cancelOrder(Long orderId) {
// 주문 엔티티 조회
Order order = orderRepository.findById(orderId).orElseThrow(() -> new BusinessLogicException(ErrorCode.ORDER_NOT_FOUND));
//주문 취소
order.cancel();
return "주문을 취소했습니다.";
}
}
- order(): 전달받은 email, itemId, count를 토대로 주문 정보 작성 및 저장
- user가 존재하는지 검사
- item이 존재하는지 검사
- 배달지는 유저의 주소로 지정
- OrderItem 생성
- 모든 정보를 종합하여 Order 생성 및 저장
- findSearchOrder(): OrderSearch의 정보를 토대로 Order를 찾음.
- cancelOrder(): 주문을 취소함.
OrderSearch
동적 쿼리 사용을 위해 사용하는 클래스.
@Getter @Setter
@AllArgsConstructor
public class OrderSearch {
private String email;
private String itemName;
private OrderStatus orderStatus;
}
OrderCustom
QueryDsl 적용을 위해 기존에 사용한 JpaRepository를 상속받는 인터페이스가 아닌 다른 인터페이스.
public interface OrderCustom {
public List<Order> findAllWithItem(OrderSearch orderSearch);
List<Order> findAllWithItemPageable(Pageable pageable, String email);
}
OrderRepositoryImpl
public class OrderRepositoryImpl implements OrderCustom{
private final JPAQueryFactory jpaQueryFactory;
@Override
public List<Order> findAllWithItem(OrderSearch orderSearch) {
BooleanBuilder builder = new BooleanBuilder();
orderSearchSQL(orderSearch, builder);
return jpaQueryFactory
.select(order)
.distinct()
.from(order)
.join(order.user, user)
.fetchJoin()
.join(order.delivery, delivery)
.fetchJoin()
.join(order.orderItems, orderItem)
.fetchJoin()
.join(orderItem.item, item)
.fetchJoin()
.where(builder)
.fetch();
}
@Override
public List<Order> findAllWithItemPageable(Pageable pageable, String email) {
BooleanBuilder builder = new BooleanBuilder();
if(StringUtils.hasText(email)){
builder.and(user.email.eq(email));
}
return jpaQueryFactory
.select(order)
.from(order)
.join(order.user, user)
.fetchJoin()
.join(order.delivery, delivery)
.fetchJoin()
.where(builder)
.offset(pageable.getPageNumber())
.limit(pageable.getPageSize())
.fetch();
}
private void orderSearchSQL(OrderSearch orderSearch, BooleanBuilder builder) {
if(orderSearch.getOrderStatus() != null){
builder.and(order.orderStatus.eq(orderSearch.getOrderStatus()));
}
if(StringUtils.hasText(orderSearch.getItemName())){
builder.and(item.name.like(orderSearch.getItemName()));
}
if(StringUtils.hasText(orderSearch.getEmail())){
builder.and(user.email.eq(orderSearch.getEmail()));
}else{
throw new BusinessLogicException(ErrorCode.USER_NOT_FOUND);
}
}
}
- findAllWithItem(): 메서드는 JPA의 페치 조인을 활용하여 쿼리수를 줄일 수 있다. 하지만, 페이징 처리가 불가능하다. 자세한 내용은 아래 포스팅을 참고하기 바란다. https://hdbstn3055.tistory.com/9
- findAllWithItemPageable(): applicaiton.yml에 JPA batch fetch size 크기를 설정하여 페이징 처리가 가능하게 한다.
OrderIntegrationTest
Order 통합 테스트이다. Order와 관련된 작업들에 대해 통합 테스트를 진행한다.
public class OrderIntegrationTest extends BaseIntegrationTest {
private final String BASE_URL = "/orders";
private final String EMAIL = "email@gmail.com";
@Autowired
private OrderService orderService;
@Autowired
private ItemService itemService;
@Autowired
private UserService userService;
@Autowired
private JwtTokenProvider jwtTokenProvider;
@Autowired
private AES128Config aes128Config;
@BeforeEach
void beforeEach() {
UserDto.SignUp signUpDto = StubData.MockUser.getSignUpDto();
userService.signUp(signUpDto);
}
@Test
@DisplayName("Order 생성 테스트")
public void createOrder() throws Exception{
//given
User user = userService.findUserByEmail(EMAIL);
userService.updateUserEmailVerified(EMAIL);
CustomUserDetails userDetails = CustomUserDetails.of(user);
TokenDto tokenDto = jwtTokenProvider.generateToken(userDetails);
String accessToken = tokenDto.getAccessToken();
String refreshToken = tokenDto.getRefreshToken();
String encryptedRefreshToken = aes128Config.encryptAes(refreshToken);
ItemRequestDto.BookRequestDto bookDto = StubData.MockItem.getBookDto();
Long itemId = itemService.saveBook(bookDto).getItemId();
int count = 3;
//when
String uri = UriComponentsBuilder.newInstance().path(BASE_URL)
.build().toUri().toString();
ResultActions actions = ResultActionsUtils.postRequestWithTokenAndParam(mockMvc, uri, accessToken, encryptedRefreshToken, itemId, count);
//then
actions
.andExpect(status().isCreated())
.andDo(document("new-order",
getRequestPreProcessor(),
getResponsePreProcessor(),
OrderResponseSnippet.newOrderSnippet()));
}
@Test
@DisplayName("OrderStatus 별 조회 테스트")
public void orderSearchStatusTest() throws Exception{
//given
User user = userService.findUserByEmail(EMAIL);
userService.updateUserEmailVerified(EMAIL);
CustomUserDetails userDetails = CustomUserDetails.of(user);
TokenDto tokenDto = jwtTokenProvider.generateToken(userDetails);
String accessToken = tokenDto.getAccessToken();
String refreshToken = tokenDto.getRefreshToken();
String encryptedRefreshToken = aes128Config.encryptAes(refreshToken);
ItemRequestDto.BookRequestDto bookDto = StubData.MockItem.getBookDto();
Long bookId = itemService.saveBook(bookDto).getItemId();
int count = 3;
orderService.order(EMAIL, bookId, count);
ItemRequestDto.AlbumRequestDto albumDto = StubData.MockItem.getAlbumDto();
Long albumId = itemService.saveAlbum(albumDto).getItemId();
count = 2;
orderService.order(EMAIL, albumId, count);
OrderSearchDto orderSearchDto = new OrderSearchDto(null, OrderStatus.ORDER);
//when
String uri = UriComponentsBuilder.newInstance().path(BASE_URL)
.build().toUri().toString();
ResultActions actions = ResultActionsUtils.getRequestWithTokenAndParam(mockMvc, uri, accessToken, encryptedRefreshToken, orderSearchDto);
//then
actions
.andExpect(status().isOk())
.andDo(document("get-orders-orderStatus",
getRequestPreProcessor(),
getResponsePreProcessor(),
OrderResponseSnippet.getOrdersSnippet()));
}
@Test
@DisplayName("Order ItemName별 조회 테스트")
public void orderSearchItemNameTest() throws Exception{
//given
User user = userService.findUserByEmail(EMAIL);
userService.updateUserEmailVerified(EMAIL);
CustomUserDetails userDetails = CustomUserDetails.of(user);
TokenDto tokenDto = jwtTokenProvider.generateToken(userDetails);
String accessToken = tokenDto.getAccessToken();
String refreshToken = tokenDto.getRefreshToken();
String encryptedRefreshToken = aes128Config.encryptAes(refreshToken);
ItemRequestDto.BookRequestDto bookDto = StubData.MockItem.getBookDto();
Long bookId = itemService.saveBook(bookDto).getItemId();
int count = 3;
orderService.order(EMAIL, bookId, count);
ItemRequestDto.AlbumRequestDto albumDto = StubData.MockItem.getAlbumDto();
Long albumId = itemService.saveAlbum(albumDto).getItemId();
count = 2;
orderService.order(EMAIL, albumId, count);
OrderSearchDto orderSearchDto = new OrderSearchDto("book", null);
//when
String uri = UriComponentsBuilder.newInstance().path(BASE_URL)
.build().toUri().toString();
ResultActions actions = ResultActionsUtils.getRequestWithTokenAndParam(mockMvc, uri, accessToken, encryptedRefreshToken, orderSearchDto);
//then
actions
.andExpect(status().isOk())
.andDo(document("get-orders-itemName",
getRequestPreProcessor(),
getResponsePreProcessor(),
OrderResponseSnippet.getOrdersSnippet()));
}
@Test
@DisplayName("Order Paging 조회 테스트")
public void orderPagingTest() throws Exception{
//given
User user = userService.findUserByEmail(EMAIL);
userService.updateUserEmailVerified(EMAIL);
CustomUserDetails userDetails = CustomUserDetails.of(user);
TokenDto tokenDto = jwtTokenProvider.generateToken(userDetails);
String accessToken = tokenDto.getAccessToken();
String refreshToken = tokenDto.getRefreshToken();
String encryptedRefreshToken = aes128Config.encryptAes(refreshToken);
ItemRequestDto.BookRequestDto bookDto = StubData.MockItem.getBookDto();
Long bookId = itemService.saveBook(bookDto).getItemId();
int count = 3;
orderService.order(EMAIL, bookId, count);
ItemRequestDto.AlbumRequestDto albumDto = StubData.MockItem.getAlbumDto();
Long albumId = itemService.saveAlbum(albumDto).getItemId();
count = 2;
orderService.order(EMAIL, albumId, count);
//when
String uri = UriComponentsBuilder.newInstance().path(BASE_URL + "/pages")
.build().toUri().toString();
ResultActions actions = ResultActionsUtils.getRequestWithTokenAndParamAndPaging(mockMvc, uri, accessToken, encryptedRefreshToken, 100);
//then
actions
.andExpect(status().isOk())
.andDo(document("paging-orders",
getRequestPreProcessor(),
getResponsePreProcessor()));
}
@Test
@DisplayName("Order 취소 테스트")
public void orderCancelTest() throws Exception{
//given
User user = userService.findUserByEmail(EMAIL);
userService.updateUserEmailVerified(EMAIL);
CustomUserDetails userDetails = CustomUserDetails.of(user);
TokenDto tokenDto = jwtTokenProvider.generateToken(userDetails);
String accessToken = tokenDto.getAccessToken();
String refreshToken = tokenDto.getRefreshToken();
String encryptedRefreshToken = aes128Config.encryptAes(refreshToken);
ItemRequestDto.BookRequestDto bookDto = StubData.MockItem.getBookDto();
Long bookId = itemService.saveBook(bookDto).getItemId();
int count = 3;
orderService.order(EMAIL, bookId, count);
List<OrderDto> book = orderService.findSearchOrder(new OrderSearch(EMAIL, "book", null));
//when
String uri = UriComponentsBuilder.newInstance().path(BASE_URL + "/" + book.get(0).getOrderId() + "/cancel")
.build().toUri().toString();
ResultActions actions = ResultActionsUtils.postRequestWithToken(mockMvc, uri, accessToken, encryptedRefreshToken);
//then
book = orderService.findSearchOrder(new OrderSearch(EMAIL, "book", null));
assertThat(book.get(0).getOrderStatus()).isEqualTo(OrderStatus.CANCEL);
actions
.andExpect(status().isAccepted())
.andDo(document("delete-order",
getRequestPreProcessor(),
getResponsePreProcessor(),
OrderResponseSnippet.deleteOrdersSnippet()));
}
}
OrderResponseSnippet
OrderController로부터 전달받은 응답에 관한 Snippet이다.
public class OrderResponseSnippet {
public static Snippet newOrderSnippet() {
return responseFields(
fieldWithPath("data").type(JsonFieldType.OBJECT).description("결과 데이터"),
fieldWithPath("data.nickName").type(JsonFieldType.STRING).description("유저 닉네임"),
fieldWithPath("data.orderDate").type(JsonFieldType.STRING).description("주문 시각"),
fieldWithPath("data.orderStatus").type(JsonFieldType.STRING).description("주문 상태"),
fieldWithPath("data.address").type(JsonFieldType.OBJECT).description("주문 주소"),
fieldWithPath("data.address.city").type(JsonFieldType.STRING).description("도시"),
fieldWithPath("data.address.street").type(JsonFieldType.STRING).description("도로"),
fieldWithPath("data.address.zipcode").type(JsonFieldType.STRING).description("우편번호"),
fieldWithPath("data.orderItems[]").type(JsonFieldType.ARRAY).description("주문 아이템"),
fieldWithPath("data.orderItems[].itemName").type(JsonFieldType.STRING).description("주문 아이템 이름"),
fieldWithPath("data.orderItems[].orderPrice").type(JsonFieldType.NUMBER).description("주문 아이템 가격"),
fieldWithPath("data.orderItems[].count").type(JsonFieldType.NUMBER).description("주문 아이템 개수")
);
}
public static Snippet getOrdersSnippet() {
return responseFields(
fieldWithPath("data[]").type(JsonFieldType.ARRAY).description("결과 데이터"),
fieldWithPath("data[].orderId").type(JsonFieldType.NUMBER).description("주문 번호"),
fieldWithPath("data[].nickName").type(JsonFieldType.STRING).description("유저 닉네임"),
fieldWithPath("data[].orderDate").type(JsonFieldType.STRING).description("주문 시각"),
fieldWithPath("data[].orderStatus").type(JsonFieldType.STRING).description("주문 상태"),
fieldWithPath("data[].address").type(JsonFieldType.OBJECT).description("주문 주소"),
fieldWithPath("data[].address.city").type(JsonFieldType.STRING).description("도시"),
fieldWithPath("data[].address.street").type(JsonFieldType.STRING).description("도로"),
fieldWithPath("data[].address.zipcode").type(JsonFieldType.STRING).description("우편번호"),
fieldWithPath("data[].orderItems[]").type(JsonFieldType.ARRAY).description("주문 아이템"),
fieldWithPath("data[].orderItems[].itemName").type(JsonFieldType.STRING).description("주문 아이템 이름"),
fieldWithPath("data[].orderItems[].orderPrice").type(JsonFieldType.NUMBER).description("주문 아이템 가격"),
fieldWithPath("data[].orderItems[].count").type(JsonFieldType.NUMBER).description("주문 아이템 개수")
);
}
public static Snippet deleteOrdersSnippet() {
return responseFields(
fieldWithPath("data").type(JsonFieldType.STRING).description("결과 데이터")
);
}
}
마무리하며
간단한 비즈니스 코드를 작성해보았는데 테스트 코드도 작성하다보니 객체지향적으로 코드를 잘 짜고 있는지 의문이든다.
클린코드에 대해서도 공부해봐야 할 것 같다.
또한, 페이징 관련해서 기억이 잘 나지 않아 이번 토이 프로젝트가 끝나면 다시 정리해봐야 할 것 같다.
계속 강조하여 얘기하지만 참 테스트 코드는 중요한 것 같다. 기존에 테스트 코드를 작성하지 않았을 때는
계속 Postman으로 확인을 해봤어야 했고 텍스트로 확인해야 했는데 Java에서 지원하는 Junit, Spring에서 지원해주는 테스트 처리 등
쉽게 눈으로 오류를 찾고 확인이 가능해진다. 아직 많이 미흡하지만, 익숙해지기 위해 노력해야겠다.
다음 글은 S3로 이미지 CRUD를 해볼 것 같다.
그리고 요즘 Spring Cloud, AWS, Docker, 연구실에서 해봤던 k8s가 너무 하고 싶다.
AWS는 AWS Skillbuilder에 무료 강의가 있다고 하니 찾아보려 하고
Spring Cloud는 여러 블로그들에 잘 설명이 되어있는 것 같다.
'프로젝트 > 토이 프로젝트' 카테고리의 다른 글
Spring + Redis + S3 + 이메일 인증 + Docker + CI/CD - 7 (1) | 2024.02.26 |
---|---|
Spring + Redis + S3 + 이메일 인증 + Docker + CI/CD - 6 (1) | 2024.02.25 |
Spring + Redis + S3 + 이메일 인증 + Docker + CI/CD - 4 (0) | 2024.02.21 |
Spring + Redis + S3 + 이메일 인증 + Docker + CI/CD - 3 (0) | 2024.02.19 |
Spring + Redis + S3 + 이메일 인증 + Docker + CI/CD - 2 (0) | 2024.02.14 |