단방향과 양방향의 개념적인 건 이해는 간다.
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;
}
🚨 문제 발생 과정
- findAll() → 1개의 쿼리로 모든 음식점을 조회 (SELECT * FROM restaurant)
- 각 음식점의 reviews에 접근할 때마다 추가 쿼리 발생 (SELECT * FROM review WHERE restaurant_id = ?)
- 결과적으로 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) 사용 (가장 권장)
'TIL' 카테고리의 다른 글
| @NoArgsConstructor,@AllArgsConstructor와(access=AccessLevel.PROTECTED) + @Builder (0) | 2025.03.10 |
|---|---|
| StackOverflowError:null (feat.AuthenticationManager) (0) | 2025.03.07 |
| Delivery프로젝트_6. 단위테스트와 통합테스트 (1) | 2025.02.19 |
| Delivery프로젝트_5.(Trouble Shooting)AOP/프록시객체/Lazy Loading (0) | 2025.02.18 |
| Delivery프로젝트_4. HttpClient (feat. API_test) (0) | 2025.02.17 |