개발/Spring

[Spring boot 프로젝트 정리 1] 자기참조 - 도서 카테고리 구현

TutleKing 2023. 3. 14. 01:53

NHN Acadmey에서 인증과정 중 제가 구현한 파트에 대해 정리하고자 글을 씁니다.

인터넷 도서 사이트를 제작하는 프로젝트였고 그 중 카테고리, 주문 조회, 결제, 캐싱처리를 담당하였습니다.

예스알라딘 사이트 

 

첫번째로는 카테고리입니다. 

카테고리는 2단으로 구현을 했습니다. 부모 카테고리 - 자식 카테고리로 구현 하였고 자기 참조 방식을 사용하였습니다.

해당 카테고리를 등록, 수정, 삭제, 순서 변경까지 가능하도록 구현하였습니다. 

직접 구현한 2단 카테고리


아래는 카테고리의 엔티티 맵핑 관련한 코드입니다. 

  • JPA에서 entity를 생성할때, 기본 생성자(NoArgs)를 protected 까지 허용해주기 때문에 롬복 어노테이션을 사용하여 선언하였습니다.
  • 카테고리는 static한 id를 사용하기로 정책으로 정했습니다. 고로 부모 카테고리는 10000씩, 자식 카테고리는 100씩 증가하여 생성됩니다.
  • soft delete (실제 삭제가 아님)를 위해 isDeleted라고 하는 필드를 사용합니다.
  • 자기 참조를 위해 depth와 parent(Category)를 사용합니다. 
    • @ManyToOne : 부모 카테고리는 자식 카테고리를 여러개 가질 수 있습니다.
  • 편의성을 위해 children이라고하는 자식 카테고리 List를 양방향으로 선언했습니다.
    • @OneToMany : mappedBy를 통해 Fk주인이 아님을 명시, Lazy fetch를 통해 일단 N+1 방지 
    • cascade : soft delete 정책으로 인해 delete에 대한 side effect를 고려하지는 않았지만 persist, merge를 사용해야만 getChildren()을 선언시 쿼리문이 1번만 나감. (더 공부 필요)
  • verfiyChange()는 JPA EntityManger의 특성 중 하나인 dirty check(변경 감지)를 사용하기 위해 선언하였습니다.
@Getter
@Builder
@ToString
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@AllArgsConstructor(access = AccessLevel.PRIVATE)
@Table(name = "categories")
@Entity
public class Category {

    public static final int DEPTH_PARENT = 0;
    public static final int DEPTH_CHILD = 1;
    public static final long TERM_OF_PARENT_ID = 10000L;
    public static final long TERM_OF_CHILD_ID = 100L;

    @Id
    private Long id;

    @Column(length = 30, nullable = false)
    private String name;

    @Builder.Default
    @Column(name = "is_shown", nullable = false)
    private boolean isShown = true;

    @Column(name = "`order`")
    private Integer order;

    @Builder.Default
    @Column(name = "depth", nullable = false)
    private int depth = 0;

    // soft delete를 위해 사용 
    @Builder.Default
    @Column(name = "disable", nullable = false)
    private boolean isDisable = false;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "parent_id")
    private Category parent;

    @ToString.Exclude
    @Builder.Default
    @OneToMany(mappedBy = "parent", fetch = FetchType.LAZY, cascade = CascadeType.ALL)
    private List<Category> children = new ArrayList<>();

    /**
     * entity의 변경감지를 위해 사용
     *
     * @param name
     * @param isShown
     * @param order
     */
    public void verifyChange(
            String name,
            Boolean isShown,
            Integer order
    ) {
        if (Objects.nonNull(name)) {
            this.name = name;
        }
        if (Objects.nonNull(isShown)) {
            this.isShown = isShown;
        }
        if (Objects.nonNull(order)) {
            this.order = order;
        }
    }

}

다음은 카테고리 생성 관련한 dto와 서비스 코드(비즈니스 코드) 입니다. 

@Getter
@NoArgsConstructor
@AllArgsConstructor
public class CategoryRequestDto {

    @NotBlank
    private String name;

    @NotNull
    private Boolean isShown;
    private Integer order;
    private Long parentId;


    /**
     * 카테고리 수정을 위해 해당 dto 를 Category 로 변환
     *
     * @param id     카테고리 id
     * @param depth  2차 카테고리일 경우 '0', 1차 카테고리일 경우 '1'
     * @param parent 2차 카테고리일 경우 사용, 1차 카테고리일 경우 null 입력
     * @return
     */
    public Category toEntity(Long id, int depth, Category parent) {
        return Category.builder()
                .id(id)
                .name(this.name)
                .isShown(this.isShown)
                .order(this.order)
                .depth(depth)
                .parent(parent)
                .build();
    }

    public void setOrder(Integer order) {
        this.order = order;
    }
}
  • 카테고리를 생성하는 2가지의 경우에 따라 코드를 구성하였습니다.
    • 부모 카테고리(=1차 카테고리)를 생성 : parentId가 null
    • 자식 카테고리(=2차 카테고리)를 생성 : parentId가 notNull
  • 우선 id가 자동 증가가 아니기때문에 최근에 생성된 id를 조회해 그 id에 10000 or 100을 더합니다. 
  • 또한 순서도 가장 마지막 순서의 카테고리 뒤 (제일 마지막 번호)에 배정합니다.
  • 그리고는 Jpa에서 제공하는 save 메서드를 통해 데이터 베이스에 저장합니다. 
....

@Transactional
@Override
public CategoryResponseDto create(CategoryRequestDto createRequest) {

    if (Objects.nonNull(createRequest.getParentId())) {
        return saveCategoryByAddingChildId(createRequest);
    }
    return saveCategoryByAddingId(createRequest);
}

/**
 * 1차 카테고리인 경우,카테고리 생성시 id에 10000L씩 더해서 id 배정
 *
 * @param createRequest
 * @return CategoryResponseDto
 */
private CategoryResponseDto saveCategoryByAddingId(CategoryRequestDto createRequest) {
    CategoryOnlyIdDto onlyParentId = queryCategoryRepository.getLatestIdByDepth(Category.DEPTH_PARENT);

    createRequest.setOrder(
            queryCategoryRepository.getLatestOrderByDepth(Category.DEPTH_PARENT) + 1);
    Category category = commandCategoryRepository.save(createRequest.toEntity(
            onlyParentId.getId() + Category.TERM_OF_PARENT_ID, Category.DEPTH_PARENT, null));
    return CategoryResponseDto.fromEntity(category);
}

/**
 * 2차 카테고리 인 경우, 카테고리 생성시 id에 100L씩 더해서 id 배정
 *
 * @param createRequest
 * @return CategoryResponseDto
 */
private CategoryResponseDto saveCategoryByAddingChildId(CategoryRequestDto createRequest) {
    Long parentId = createRequest.getParentId();
    CategoryOnlyIdDto onlyChildId = queryCategoryRepository.getLatestChildIdByDepthAndParentId(
            Category.DEPTH_CHILD,
            parentId
    );
    Category parentCategory = queryCategoryRepository.findById(parentId)
            .orElseThrow(() -> new ClientException(
                    ErrorCode.CATEGORY_NOT_FOUND,
                    "Category not found with id : " + parentId
            ));

    createRequest.setOrder(queryCategoryRepository.getLatestChildOrderByDepthAndParentId(
            Category.DEPTH_CHILD,
            parentId
    ) + 1);
    Category category = commandCategoryRepository.save(createRequest.toEntity(
            onlyChildId.getId() + Category.TERM_OF_CHILD_ID,
            Category.DEPTH_CHILD,
            parentCategory
    ));
    return CategoryResponseDto.fromEntity(category);
}

다음은 컨트롤러 코드입니다.

  • Rest API는 버전을 명시하고 계층구조를 지키기 위해 '/v1/categories'를 사용하였습니다.
  • 생성을 위해 POST를 선정하였고 응답은 201 created를 사용하였습니다. 
  • @Valid + BindingResult 를 사용하여 Request에 대하여 검증합니다.
    • @Valid 어노테이션은 Dispatcher Servlet에서 HandlerAdapter를 통해 적절한 컨트롤러를 찾아 요청을 전달하는 과정 가운데  ArgumnetResolver에서 검증합니다. 
@Slf4j
@RequiredArgsConstructor
@RestController
@RequestMapping("/v1/categories")
public class CommandCategoryController {

    private final CommandCategoryService commandCategoryService;


    /**
     * 카테고리를 생성하기위해 Post 요청을 처리하는 기능
     *
     * @param categoryRequest 생성시 필요한 이름, 노출 여부, 상위 카테고리 id가 존재
     * @return ResponseEntity로 카테고리 생성 성공시 생성된 카테고리의 일부 데이터를 반환
     */
    @PostMapping
    @ResponseStatus(HttpStatus.CREATED)
    public ResponseDto<CategoryResponseDto> createCategory(
            @Valid @RequestBody CategoryRequestDto categoryRequest,
            BindingResult bindingResult
    ) {
        checkRequestValidation(bindingResult);

        CategoryResponseDto categoryResponseDto = commandCategoryService.create(categoryRequest);
        return ResponseDto.<CategoryResponseDto>builder()
                .success(true)
                .status(HttpStatus.CREATED)
                .data(categoryResponseDto)
                .build();
    }
    ...
 }

 

위 예시는 생성만 보여드렸지만 수정, 삭제 등 다양한 기능을 구현하였습니다.

 

코드 참고는 아래 깃헙에서 하시면 됩니다. 

https://github.com/NHN-YesAladin/yesaladin_shop 

반응형