본문 바로가기
프로젝트/토이 프로젝트

Spring + Redis + S3 + 이메일 인증 + Docker + CI/CD - 5

by 진꿈청 2024. 2. 22.

이번 글에서는 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
 

[SpringBoot] JPA Collection 페치 조인 최적화

Collection은 @ManyToOne가 아닌 @OneToMany를 사용하는 변수에 사용된다. 하지만, @OneToMany. 즉, 일대다 관계에서 Collection을 조회하면 데이터가 뻥튀기가 된다. 예를 들어, Order(주문), OrderItem(주문된 아이

hdbstn3055.tistory.com

  • 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는 여러 블로그들에 잘 설명이 되어있는 것 같다.