PROJECT/GDSC 프로젝트 트랙 : 물품 대여 서비스

[SpringBoot] 특정 카테고리 정보 조회 API 구현

yeonee911 2024. 8. 14. 14:39

TMI. 제일 좋아하는 캐릭터는 앨리스. 정확히는 그 배경인 '이상한 나라'가 좋음

 

[WIL] 거참 공부하기 싫은 날씨네 이런 날은... : 8월 둘째 주😪 (tistory.com)

 

[WIL] 거참 공부하기 싫은 날씨네 이런 날은... : 8월 둘째 주😪

https://youtu.be/jJTKX1O5pOw?si=LdKUvJTjiVqnyERC1. GDSC 프로젝트 트랙 : JAVA & Spring Boot1.1 POSTMAN, 아프지 말고 건강해야 한다💉카테고리 전체 조회api까지 만들었으나 도저히 확인할 방법이 없었다. requestBody

yeonee911.tistory.com

 

8월 2주차 WIL에 썼던 무한참조의 늪... 을 해결했다!

새벽 4시까지 오류 고치려다가 실패하고... 다음날 아침에 눈 뜨자마자 작업해서 고쳤다. 생각보다 쉽게 고쳐져서 허무했음.이거를 못 찾고 계속 삽질했던 건가...?

후후후 뿌듯한 커밋 기록


1. 목표🎯

/category/{cateogoryId}로 GET요청을 보내면 해당 정보를 반환한다.

반환하는 정보는 해당 카테고리 번호(categoryId), 카테고리명(name),  카테고리 설명(description), 카테고리에 속하는 물품 리스트(items)이다.

 

 

2. 기능 명세서📜

Name Method URI Domain AuthZ
(특정) 카테고리 정보 조회  GET /category/{categoryId} category MEMBER

 

 

3. 1차 코드 작성💻

깃허브 기록 뒤져왔다. 이럴 때마다 느끼는 점. 커밋을 기능 단위별로 잘 해두자.. 

3.1 CategoryController

// 특정 카테고리 정보 조회
	@GetMapping("/{categoryId}")
	public ResponseEntity<CategoryDetailResponse>getCategoryById(@PathVariable Long categoryId) {
		CategoryDetailResponse response = categoryService.findCategoryById(categoryId);
		return ResponseEntity.ok(response);
	}

 

3.2 CategoryService

/**
	 * 특정 카테고리 조회
	 */
	public CategoryDetailResponse findCategoryById(Long categoryId) {
		Category category = categoryRepository.findById(categoryId)
			.orElseThrow(() -> new CustomException(CATEGORY_NOT_FOUND));
		return CategoryDetailResponse.of(category);
	}

 

3.3 CategoryRepository

public interface CategoryRepository extends JpaRepository<Category, Long> {

	Optional<Category> findById(Long categoryId);
}

 

3.4 CategoryDetailResponse

package mango.rentalsystem.domain.category.dto.response;

import java.util.List;

import mango.rentalsystem.domain.category.domain.Category;
import mango.rentalsystem.domain.department.domain.Department;
import mango.rentalsystem.domain.item.domain.Item;

public record CategoryDetailResponse(
	Long categoryId,
	String name,
	String description,
	Department department,
	List<Item> items
) {
	public static CategoryDetailResponse of(Category category) {
    return new CategoryDetailResponse(
			category.getId(),
			category.getName(),
			category.getDescription(),
			category.getDepartment(),
			category.getItems()
		);
	}
}

 

 

4. 오류🤯

악몽이었다...

4.1 1차 원인 분석 및 해결

CategoryResponse가 Department객체에 접근하고, 그 Department 객체가 다시 Category객체를 참조하는 구조에서 발생할 수 있습니다.

Department와의 순환 참조가 문제였다.

 

해결방법으로는 몇 가지가 있는데 주로 dto를 반환하는 게 좋다고 했다.

WIL을 쓰며 이런 질문을 남겼었다.

하지만 기획적인 측면에서 생각했을 때, Department 객체를 받아온 목적이 어떤 학과의 카테고리인지 알기 위함이었다.

 

그래서 아예 Department를 객체로 받아오는 것이 아니라 departmentId를 받아오기로 했다. 어차피 Id값은 고유하기 때문에 충분히 department를 식별 가능하기 때문이다. 

엔티티 구조 설계

하지만 이후 아예 departmentId조차 빼 버렸다.

 

엔티티 구조 설계를 보면, 학과(department)와 물품 종류(category)는 일대다(1:N)관계이다. 따라서 애초에 특정 카테고리를 조회한다는것은 department(학과)가 정해져 있다는 뜻이다. 

 

더 이상 순환참조는 일어나지 않았다. 하지만 더 큰 문제에 시달리게 되었으니...

 

4.2 2차 오류 발생

아예 서버 에러가 뜨기 시작했다.ㅠㅠ

items를 받아오지 못하는 것이었다.

department에 할 질문이 아니라 items에 해야하는 것이었음을....

카테고리와 다르게 items는 여러 item을 포함한 리스트이다.

그렇다면 CategoryDetailResponse라는 dto안에 ItemResponse를 중첩할 수 있을까??

이걸 해결하는게 너무 어려웠다...

 

4.3 해결?

ItemResponse를 새로 파야할지, 아니면 CategoryDetailResponse안에 함께 넣어줘야하는지!! 그걸 한참 헤맸다.

결론은 CategoryDetailResponse 반환값 중 items 리스트안의 각각의 item들을 dto로 반환한 다음, 그것들을 하나의 리스트로 묶어 items를 구성했다.

말로 하니까 좀 헷갈려서... 코드로 보는게 나을 듯

package mango.rentalsystem.domain.category.dto.response;

import java.util.Collections;
import java.util.List;
import java.util.stream.Collectors;

import lombok.AllArgsConstructor;
import lombok.Getter;
import lombok.NoArgsConstructor;
import mango.rentalsystem.domain.category.domain.Category;
import mango.rentalsystem.domain.item.domain.Item;
import mango.rentalsystem.domain.item.domain.ItemStatus;

@Getter
@NoArgsConstructor
@AllArgsConstructor
public class CategoryDetailResponse {
    private Long categoryId;
    private String name;
    private String description;
    private List<ItemResponse> items;

    public static CategoryDetailResponse from (Category category) {
       List<ItemResponse> itemResponses = category.getItems() != null
          ? category.getItems().stream()
          .map(ItemResponse::from)
          .collect(Collectors.toList())
          : Collections.emptyList();

       return new CategoryDetailResponse(
          category.getId(),
          category.getName(),
          category.getDescription(),
          itemResponses
       );
    }

    @Getter
    @AllArgsConstructor
    public static class ItemResponse {
       private Long itemId;
       private ItemStatus itemStatus;
       private Double itemReview;

       public static ItemResponse from(Item item) {
          return new ItemResponse(
             item.getId(),
             item.getItemStatus(),
             item.getItemReview()
          );
       }
    }
}

 

4.4 3차오류

 Caused by: org.hibernate.InstantiationException: No default constructor for entity 'mango.rentalsystem.domain.item.domain.Item'

이건 또 무슨 에러일까...

 

오류는 Hibernate가 엔티티를 인스턴스화하려고 할 때 발생합니다. 이 오류는 주로 다음과 같은 원인으로 발생합니다
기본 생성자 없음: JPA/Hibernate는 엔티티를 생성할 때 기본 생성자(파라미터가 없는 생성자)를 필요로 합니다. 엔티티 클래스에 기본 생성자가 없으면 이 오류가 발생할 수 있습니다.
기본 생성자가 private로 선언되어 있는 경우: 기본 생성자가 private로 선언되어 있다면, Hibernate가 해당 생성자에 접근할 수 없어 오류가 발생할 수 있습니다.

내 경우에 Item엔티티에 기본 생성자가 없기 때문에 발생한 오류 같았다. 

Lombok을 이용하고 있었기 때문에 @NoArgsConstructor 어노테이션 하나 달아줬더니 바로 해결됐다.

 

@NoArgsConstructor 파라미터가 없는 생성자를 자동으로 생성해준다.

이번 프로젝트에서 @AllArgsConstructor와 함께 엄청 썼는데, 어떨 때 어떤 어노테이션을 써야하는 지 헷갈림... 심지어 두 개 같이 쓰는 경우도 있다. 이것에 관해서는 따로 정리할 듯

참고하면 좋을 링크

Difference Between Lombok @AllArgsConstructor, @RequiredArgsConstructor and @NoArgConstructor | Baeldung

spring boot - Why to use @AllArgsConstructor and @NoArgsConstructor together over an Entity? - Stack Overflow

 

 

5. 해결🥳

// ResponseBody
{
    "categoryId": 1,
    "name": "A",
    "description": "컴공A",
    "items": [
        {
            "itemId": 1,
            "itemStatus": "IDLE",
            "itemReview": 0.0
        },
        {
            "itemId": 5,
            "itemStatus": "IDLE",
            "itemReview": 0.0
        }
    ]
}

이렇게 예쁘게 response를 준다. 뿌듯.

결론은 CategoryDetailResponse 반환값 중 items 리스트안의 각각의 item들을 dto로 반환한 다음, 그것들을 하나의 리스트로 묶어 items를 구성했다.

혹시 이해되시나요??

 

JSON 구조를 보면,

1. "categoryId", "name", "description", "items"를 하나의 {}로 묶었다.

2. "items"는 리스트이다.

3. 리스트 안에 "itemId", "itemStatus", "itemReview"를 하나의 {}로 묶은 ItemResponse들을 여러 개 넣어줬다. 

 

 

6. 최종 코드🍀

6.1 CategoryController

// 특정 카테고리 정보 조회
@PreAuthorize("hasRole('ADMIN') or hasRole('MEMBER')")
@GetMapping("/{categoryId}")
public CategoryDetailResponse getCategoryDetail(@PathVariable Long categoryId) {
    Category category = categoryService.getCategoryById(categoryId);
    return CategoryDetailResponse.from(category);
}

글 쓰다가 알아챈 건데, ResponseEntity를 빼버렸다. 앗...

 

6.2 CategoryService

/**
 * 특정 카테고리 조회
*/
public Category getCategoryById(Long categoryId) {
    return categoryRepository.findById(categoryId)
       .orElseThrow(() -> new CustomException(CATEGORY_NOT_FOUND));
}

 

6.3 CategoryDetailResponse

package mango.rentalsystem.domain.category.dto.response;

import java.util.Collections;
import java.util.List;
import java.util.stream.Collectors;
import lombok.AllArgsConstructor;
import lombok.Getter;
import lombok.NoArgsConstructor;
import mango.rentalsystem.domain.category.domain.Category;
import mango.rentalsystem.domain.item.domain.Item;
import mango.rentalsystem.domain.item.domain.ItemStatus;

@Getter
@NoArgsConstructor
@AllArgsConstructor
public class CategoryDetailResponse {
    private Long categoryId;
    private String name;
    private String description;
    private List<ItemResponse> items;

    public static CategoryDetailResponse from (Category category) {
       List<ItemResponse> itemResponses = category.getItems() != null
          ? category.getItems().stream()
          .map(ItemResponse::from)
          .collect(Collectors.toList())
          : Collections.emptyList();

       return new CategoryDetailResponse(
          category.getId(),
          category.getName(),
          category.getDescription(),
          itemResponses
       );
    }

    @Getter
    @AllArgsConstructor
    public static class ItemResponse {
       private Long itemId;
       private ItemStatus itemStatus;
       private Double itemReview;

       public static ItemResponse from(Item item) {
          return new ItemResponse(
             item.getId(),
             item.getItemStatus(),
             item.getItemReview()
          );
       }
    }
}

 

6.4 Item

package mango.rentalsystem.domain.item.domain;

@Entity
@Getter
@Builder
@NoArgsConstructor(access = AccessLevel.PROTECTED)	// 3차 오류 해결!
@AllArgsConstructor
public class Item {
    @Id
    @GeneratedValue
    @Column(name = "item_id")
    private Long id;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "category_id")
    private Category category;

    private ItemStatus itemStatus;

    private Double itemReview;

}

막상 글로 쓰고 나니 별 거 없는 것 같다....

문제는 이걸 저녁8시?쯤부터 새벽 4시까지 하고도 못 고쳐서 결국 다음날에서야 고쳤다는 건데...

아무튼! 이제 비슷한 상황이 생긴다면 잘할 수 있을 것 같다. 아쟈쟛