Post

MSA 구현하기(13) - 상품 카테고리 기능 추가 (feat. SQL)

Skala 과정에서 마이크로 서비스 아키텍처 구조에 대해 새롭게 알게 되었습니다.
배운 것을 실제로 구현해보기 위해 이 여정을 시작하기로 했습니다.
처음 글부터 보러가기

DB 시간에 온라인 쇼핑몰을 예시로 ERD 및 테이블을 생성해보는 실습 시간이 있었는데
제 프로젝트에도 카테고리를 추가하는 것이 좋을 것 같아서 해보기로 했습니다!


SQL로 직접 테이블 추가하기

R2DBC를 사용하고 있기 때문에 Spring에서 엔티티 클래스를 작성한다고 해도
자동으로 테이블을 생성해주지 않습니다!
사실 그래서 해봐야겠다는 생각이 들었습니다 ㅎㅎ

item 테이블에 category_id 열 추가

1
2
ALTER TABLE category
CHANGE COLUMN sub_category_id parent_id INT;

우선 item 테이블에 category_id 열을 새로 추가해주었습니다.


category 테이블 생성

1
2
3
4
5
6
7
8
CREATE TABLE `category` (
  `id` int(11) NOT NULL AUTO_INCREMENT,
  `name` varchar(20) NOT NULL,
  `parent_id` int(11) DEFAULT NULL,
  PRIMARY KEY (`id`),
  KEY `FK_category` (`parent_id`),
  CONSTRAINT `FK_category` FOREIGN KEY (`parent_id`) REFERENCES `category` (`id`)
)

category 테이블은 셀프 조인을 통해 계속해서 세부 카테고리를 생성할 수 있도록 만들었습니다.

category-table

DBeaver로 확인해보면 이렇게 셀프 조인을 하고 있는 것을 확인할 수 있습니다!


category 내 데이터 삽입

1
2
3
4
5
6
7
INSERT INTO `category` (name, parent_id)
VALUES ('FOOD', null);
.
.
.
INSERT INTO `category` (name, parent_id)
VALUES ('FROZEN FOOD', 1);

INSERT 를 통해 category 안에 샘플 데이터를 몇 개 넣어주었습니다.

id가 자동으로 증가되도록 설정되어 있기 때문에 행을 삽입할 때 id를 명시해주지 않아도 자동으로 1씩 증가되며 삽입됩니다.

category-rows


item의 category_id에 값 넣어주기

1
UPDATE item SET category_id = 10 WHERE id = 1;

임시로 생성해놓았던 상품들 중 하나에 값을 넣어봤습니다!

item-category

잘 삽입되어 있는 것을 확인할 수 있습니다 ☺️


Spring 코드로 테이블 연결하기

R2DBC 기반으로 되어 있기 때문에 잘 연결이 될 지 불안했지만..
생각보다 굉장히 수월하게 연결되었습니다!

Category.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
@Table
@Getter
@AllArgsConstructor
@NoArgsConstructor
public class Category {
    @Id
    private Long id;
    private String name;
    private Long parentId;

    public Category(String name) {
        this.name = name;
        this.parentId = null;
    }

    public Category(String name, Long parentId) {
        this.name = name;
        this.parentId = parentId;
    }
}

대분류 카테고리의 경우 parentId가 없이 추가되니, 생성자도 여러개 만들어주었습니다!

@Table@Id 모두 org.springframework.data 에서 import 되도록 해주세요


CategoryRepository.java

1
2
3
4
public interface CategoryRepository extends R2dbcRepository<Category, Long> {
    Flux<Category> findByParentIdIsNull();
    Flux<Category> findByParentId(Long parentId);
}

대분류만 찾기 위해 findByParentIdIsNull() 함수를 선언하고
대분류 및 세부 카테고리에 대해 깊이가 1인 카테고리 목록들만 불러올 수 있게 findByParentId() 함수를 선언해주었습니다.


CategoryService.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
@Service
@Slf4j
@RequiredArgsConstructor
public class CategoryService {
private final CategoryRepository categoryRepository;

    public Flux<AllCategoryResponse> allCategories() {
        return categoryRepository.findByParentIdIsNull()
                .flatMap(this::getAllCategory);
    }

    public Mono<CategoryDetailsResponse> getCategoryDetails(Long id) {
        return categoryRepository.findByParentId(id)
                .map(Category::getName)
                .collectList()
                .map(CategoryDetailsResponse::new);
    }

    public Mono<Void> register(
            CategoryRegisterRequest request,
            Long userId,
            UserRole role
    ) {
        log.info("user({}) registering new category: {}", userId, request.getName());

        RoleCheck.isAdmin(role);

        Mono<Category> categoryMono;

        if (request.getParentId() == null) {
            categoryMono = Mono.just(new Category(request.getName()));
        } else {
            categoryMono = categoryRepository.findById(request.getParentId())
                    .switchIfEmpty(Mono.error(new CategoryNotFoundException()))
                    .map(parent -> new Category(request.getName(), parent.getId()));
        }

        return categoryMono
                .flatMap(categoryRepository::save)
                .switchIfEmpty(Mono.error(new CategoryRegisterException()))
                .then();
    }

    private Mono<AllCategoryResponse> getAllCategory(Category category) {
        return categoryRepository.findByParentId(category.getId())
                .map(Category::getName)
                .collectList()
                .map(children -> new AllCategoryResponse(category.getName(), children));
    }

}

서비스 클래스는 위와 같이 작성해주었습니다.
세부 로직은 각자 상황에 맞게 구성하면 되고, WebFlux 환경에서 원활하게 동작할 수 있도록 Mono나 Flux를 반환하도록 만들어주었습니다!


CategoryController.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
@RestController
@RequestMapping("/category")
@Slf4j
@RequiredArgsConstructor
public class CategoryController {
    private final CategoryService categoryService;

    /** [권한 제한 없음]
     * 전체 카테고리 조회
     * @return 전체 카테고리 이름 및 깊이 1 세부 카테고리 목록
     */
    @GetMapping
    public Flux<AllCategoryResponse> allLargeCategories() {
        return categoryService.allCategories();
    }

    /** [권한 제한 없음]
     * 개별 카테고리 별 세부 카테고리 조회
     * @param id 개별 카테고리 Id
     * @return 개별 카테고리 별 깊이 1 세부 카테고리 목록
     */
    @GetMapping("/{categoryId}")
    public Mono<CategoryDetailsResponse> categoryDetails(
            @PathVariable("categoryId") Long id
    ) {
        return categoryService.getCategoryDetails(id);
    }

    /** [관리자]
     * 카테고리 등록(추가)
     * @param request 카테고리 등록 Request
     * @param userId 로그인 된 사용자 User Id
     * @param role 로그인 된 사용자 Role
     * @return Void
     */
    @PostMapping("/register")
    public Mono<Void> registerCategory(
            @RequestBody CategoryRegisterRequest request,
            @RequestHeader("x-user-id") Long userId,
            @RequestHeader("x-user-role") UserRole role
    ) {
        return categoryService.register(request, userId, role);
    }

}

컨트롤러 클래스도 위와 같이 작성해주었습니다.


마치며

결과 화면

모든 카테고리를 조회하면 all-category


세부 카테고리를 조회하면 category-details


커스텀 에러도 잘 동작합니다! custom-error-code


이제 백엔드 부분은 버그가 있지 않는 이상 더 건드리지 않고, 수업 시간에 배운 프론트나 AI 부분을 적용시켜보고자 합니다.

나중에 다시 보자 스프링아,,,

다음 글부터는 화면 구성에 대해 다뤄보겠습니다!

👻🛒



참고 자료

Skala 교육 자료!

This post is licensed under CC BY 4.0 by the author.