본문 바로가기
TIL

Delivery프로젝트_5. (Trouble Shooting) JPA 연관관계 (단방향, 양방향)

by Wanado 2025. 2. 20.
728x90

 

단방향과 양방향의 개념적인 건 이해는 간다.

https://hoestory.tistory.com/28

 

 

둘의 차이는 무엇이고 어떤 주의점이 있는지 정리해보자.

 

ex) 음식점(일)과 리뷰(다)의 관계에서 시나리오에 따라 단방향과 양방향으로 나눌 수 있다.

양방향 >> 음식점 상세페이지에서 리뷰를 조회할경우 , 리뷰가 많을 경우

단방향>> 리뷰목록을 자주 보여줄 것인가

 

단방향일경우 리뷰(다)에서만 설정해주면 된다.

양방향일경우 음식점, 리뷰 테이블 둘다 설정이 필요해서 조금 복잡하다.

 

리뷰

  @ManyToOne
  @JoinColumn(name = "store_id", nullable = false)
  private Store store;
  
  
  public void setStore(Store store) {
    this.store = store;
  }

음식점

 @OneToMany(mappedBy = "store", cascade = CascadeType.REMOVE, orphanRemoval = true, fetch = FetchType.LAZY)
 private List<Review> reviews = new ArrayList<>();

  public void addReview(Review review) {
    if (this.reviews == null) {
      this.reviews = new ArrayList<>();
    }
    this.reviews.add(review);
    review.setStore(this);
  }

 

서비스

  @Transactional
  public Double getAvgRate(String storeId) {
    Store store = storeRepository.findById(UUID.fromString(storeId))
        .orElseThrow(() -> new RuntimeException("Store not found"));

    return store.getReviews()
        .stream()
        .mapToInt(Review::getRating)
        .average()
        .orElse(0.0);

 

그러나

양방향 연관 관계 설정 (무한 루프 발생 가능)

@RestController
@RequestMapping("/restaurants")
public class RestaurantController {
    @GetMapping("/{id}")
    public Restaurant getRestaurant(@PathVariable Long id) {
        return restaurantRepository.findById(id).orElseThrow();
    }
}
  • Restaurant을 JSON으로 변환하는 과정에서 reviews 리스트의 Review 객체도 변환됨.
  • 하지만 Review에는 다시 restaurant 필드가 존재하여, 이 과정이 계속 반복되면서 무한 루프에 빠짐

 

 

해결 방법

방법 1: @JsonManagedReference & @JsonBackReference 사용

  • @JsonManagedReference는 부모 엔티티에 적용 (Store)
  • @JsonBackReference는 자식 엔티티에 적용 (Review)
  • 이를 사용하면 Restaurant → Review 방향의 직렬화는 허용되지만, Review → Restaurant 방향 직렬화는 막음

방법 2: DTO(Data Transfer Object) 사용 (가장 권장)

무한 루프 문제를 방지하면서도 API가 원하는 데이터를 명확하게 전달하는 방법.

 

Dto 설계

public class RestaurantDTO {
    private Long id;
    private String name;
    private List<ReviewDTO> reviews;
}

======================================

public class ReviewDTO {
    private Long id;
    private String content;
    private int rating;
}

======================================

@RestController
@RequestMapping("/restaurants")
public class RestaurantController {
    @GetMapping("/{id}")
    public RestaurantDTO getRestaurant(@PathVariable Long id) {
        Restaurant restaurant = restaurantRepository.findById(id).orElseThrow();

        // DTO 변환
        List<ReviewDTO> reviewDTOs = restaurant.getReviews().stream()
            .map(review -> new ReviewDTO(review.getId(), review.getContent(), review.getRating()))
            .collect(Collectors.toList());

        return new RestaurantDTO(restaurant.getId(), restaurant.getName(), reviewDTOs);
    }
}

 

 

@ManyToOne(fetch = FetchType.LAZY) 

FetchType.LAZY로는 왜 해결이 안될까?

 

📌 fetch = FetchType.LAZY의 동작 방식

  • Review 엔티티를 조회할 때 restaurant 필드는 즉시 로딩되지 않고, 프록시 객체가 할당됨.
  • getRestaurant()를 호출할 때, 실제로 필요한 순간에 쿼리가 실행됨 (지연 로딩).
  • 쿼리 최적화에 도움이 됨. (N+1 문제 방지 가능)
더보기

FetchType.LAZY가 N+1 발생시키는거 아닌가??

 

❌ FetchType.LAZY 자체가 직접 N+1을 일으키는 것은 아니고, 잘못된 접근 방식이 원인

N+1 문제란, 하나의 엔티티를 조회할 때 추가적인 N개의 쿼리가 발생하는 현상

@OneToMany(fetch = FetchType.LAZY) 또는 @ManyToOne(fetch = FetchType.LAZY)를 사용할 때,
연관된 엔티티를 반복문 등으로 접근할 경우 추가 쿼리가 발생하여 성능 저하

 

1️⃣ 잘못된 코드 예시 (N+1 문제 발생)

public List<Restaurant> getRestaurants() {

List<Restaurant> restaurants = restaurantRepository.findAll(); // 1개의 쿼리 (SELECT * FROM restaurant)

for (Restaurant restaurant : restaurants) {

System.out.println(restaurant.getReviews().size()); // Lazy 로딩 → 리뷰 개수만큼 추가 쿼리 발생

}

return restaurants;

}

 

🚨 문제 발생 과정

  1. findAll() → 1개의 쿼리로 모든 음식점을 조회 (SELECT * FROM restaurant)
  2. 각 음식점의 reviews에 접근할 때마다 추가 쿼리 발생 (SELECT * FROM review WHERE restaurant_id = ?)
  3. 결과적으로 N개의 추가 쿼리 발생 (음식점이 100개면 100번 실행됨!)

💥 즉, FetchType.LAZY는 기본적으로 연관 엔티티를 즉시 가져오지 않기 때문에,
restaurant.getReviews()를 반복문 등에서 사용하면 N+1 문제가 발생할 수 있음.

 

✅ N+1 해결 방법

➡ FetchType.LAZY를 유지하면서도, JOIN FETCH 또는 EntityGraph, DTO 등을 사용하여 N+1 문제를 방지해야 함.

📌 해결 방법 1: JOIN FETCH 사용 (가장 많이 쓰이는 방법)

  • LEFT JOIN FETCH를 사용하여 한 번의 쿼리로 Restaurant + Review를 가져오기.
@Repository
public interface RestaurantRepository extends JpaRepository<Restaurant, Long> {
    @Query("SELECT r FROM Restaurant r LEFT JOIN FETCH r.reviews")
    List<Restaurant> findAllWithReviews();
}
public List<Restaurant> getRestaurants() {
    return restaurantRepository.findAllWithReviews(); // 단 1개의 쿼리만 실행됨
}

📌 해결 방법 2: DTO 변환 후 최적화 (fetch join + DTO)

DTO를 사용하면 불필요한 데이터 로딩을 막고 성능을 최적화할 수 있음.

public class RestaurantDTO {
    private Long id;
    private String name;
    private List<ReviewDTO> reviews;
}
public class ReviewDTO {
    private Long id;
    private String content;
    private int rating;
}
@Repository
public interface RestaurantRepository extends JpaRepository<Restaurant, Long> {
    @Query("SELECT new com.example.dto.RestaurantDTO(r.id, r.name) FROM Restaurant r")
    List<RestaurantDTO> findAllRestaurants();
}
public List<RestaurantDTO> getRestaurants() {
    return restaurantRepository.findAllRestaurants(); // 필요 데이터만 가져옴
}

❌ 문제: LazyInitializationException 발생 가능

만약 FetchType.LAZY로 설정한 후 JSON 직렬화를 하면, LazyInitializationException이 발생할 수 있습니다.

@RestController
@RequestMapping("/reviews")
public class ReviewController {
    @GetMapping("/{id}")
    public Review getReview(@PathVariable Long id) {
        return reviewRepository.findById(id).orElseThrow();
    }
}

🚨 문제 발생 (LazyInitializationException)

  • 컨트롤러에서 Review 객체를 JSON으로 변환하려고 할 때,
    restaurant 필드는 Hibernate 프록시 객체 (Lazy) 상태임.
  • 하지만, 이 객체가 직렬화(Jackson 변환)될 때 영속성 컨텍스트가 이미 종료됨.
  • 결과적으로 LazyInitializationException 발생!

해결 방법

@Transactional로 Lazy 로딩 허용

서비스 계층에서 @Transactional을 사용하여 영속성 컨텍스트를 유지하면 Lazy 필드를 안전하게 사용할 수 있습니다.

@Service
public class ReviewService {
    @Transactional
    public Review getReview(Long id) {
        return reviewRepository.findById(id).orElseThrow();
    }
}

 

  • @Transactional이 붙어 있으면 트랜잭션 범위 내에서 Lazy 필드(restaurant)도 정상적으로 초기화됨.
  • 하지만, API 성능이 저하될 가능성이 있음.
  • 필요하지 않은 경우에도 restaurant을 조회하게 될 수도 있음.

DTO(Data Transfer Object) 사용 (가장 권장)

 

 

 

728x90