본문 바로가기
개념정복💫/스프링 Spring 정복

양방향 매핑 시 무한 루프 문제란?

by 옹쑥이 2025. 2. 23.

JPA에서 양방향 연관 관계(예: @OneToMany, @ManyToOne 등)를 설정하면,
Spring Boot에서 JSON을 직렬화(Serialize)할 때 무한 루프(Infinite Recursion) 문제가 발생할 수 있다.


📌 무한 루프 발생 예시

@Entity
public class User {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    private String name;

    @OneToMany(mappedBy = "user", cascade = CascadeType.ALL)
    private List<Order> orders;
}

@Entity
public class Order {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    private String orderNumber;

    @ManyToOne
    @JoinColumn(name = "user_id")
    private User user;
}

문제 발생 상황

1️⃣ User 엔티티를 조회하면 orders 리스트도 같이 조회됨.
2️⃣ Order 엔티티에는 다시 User 객체가 들어있음.
3️⃣ JSON 변환 과정에서, User → Order → User → Order ... 무한 반복됨.
4️⃣ 결국, StackOverflowError(스택 오버플로우 에러) 발생!

{
  "id": 1,
  "name": "Kim",
  "orders": [
    {
      "id": 101,
      "orderNumber": "A123",
      "user": {  // 🔄 여기서 다시 user 정보가 들어가며 무한 루프!
        "id": 1,
        "name": "Kim",
        "orders": [ ... ]
      }
    }
  ]
}

위 JSON을 보면, "orders" 안에 "user"가 다시 들어가고, 그 안에 또 "orders"가 들어가면서 끝없이 반복되는 걸 확인할 수 있다..


1️⃣ 해결 방법: @JsonIgnore 사용하기

가장 간단한 해결책은 @JsonIgnore을 사용해서 불필요한 직렬화를 막는 것..!

해결 코드

@Entity
public class Order {
    @Id @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    private String orderNumber;

    @ManyToOne
    @JoinColumn(name = "user_id")
    @JsonIgnore  // 🛑 JSON 변환 시 user 필드를 제외
    private User user;
}

이렇게 하면 Order가 JSON으로 변환될 때 user 정보는 포함되지 않아서, 무한 루프 문제를 방지할 수 있다! 🎯

💡 @JsonIgnore 적용 후 JSON 결과 예시

{
  "id": 1,
  "name": "Kim",
  "orders": [
    {
      "id": 101,
      "orderNumber": "A123"
    }
  ]
}

🚀 이제 "user" 객체가 더 이상 JSON에 포함되지 않으므로, 무한 루프가 해결됨!


2️⃣ 해결 방법: DTO 변환 사용하기

@JsonIgnore을 적용하면 간단하게 해결되지만,
만약 API에서 Order의 User 정보도 필요하다면 어떻게 해야 할까요?
이럴 때는 DTO 변환 방식이 가장 좋은 해결책이 될 수 있어요.

DTO 변환 방식 적용

1️⃣ UserDTO & OrderDTO 만들기

@Getter @Setter
public class UserDTO {
    private Long id;
    private String name;
    private List<OrderDTO> orders;

    public UserDTO(User user) {
        this.id = user.getId();
        this.name = user.getName();
        this.orders = user.getOrders().stream()
                          .map(OrderDTO::new)
                          .collect(Collectors.toList());
    }
}

@Getter @Setter
public class OrderDTO {
    private Long id;
    private String orderNumber;

    public OrderDTO(Order order) {
        this.id = order.getId();
        this.orderNumber = order.getOrderNumber();
    }
}

2️⃣ Service에서 변환 후 반환

@Service
public class UserService {
    private final UserRepository userRepository;

    public UserService(UserRepository userRepository) {
        this.userRepository = userRepository;
    }

    public UserDTO getUserById(Long id) {
        User user = userRepository.findById(id)
            .orElseThrow(() -> new RuntimeException("User not found"));
        return new UserDTO(user);
    }
}

3️⃣ Controller에서 DTO 반환

@RestController
@RequestMapping("/users")
public class UserController {
    private final UserService userService;

    public UserController(UserService userService) {
        this.userService = userService;
    }

    @GetMapping("/{id}")
    public UserDTO getUser(@PathVariable Long id) {
        return userService.getUserById(id);
    }
}

💡 DTO 변환 후 JSON 결과 예시

{
  "id": 1,
  "name": "Kim",
  "orders": [
    {
      "id": 101,
      "orderNumber": "A123"
    }
  ]
}

🚀 이제 불필요한 user 객체가 JSON에 포함되지 않고, 깔끔하게 반환됨!


3️⃣ 해결 방법: @JsonManagedReference & @JsonBackReference

Spring에서는 @JsonManagedReference와 @JsonBackReference를 사용하여
양방향 매핑의 직렬화를 자동으로 조절할 수도 있다.

적용 코드

@Entity
public class User {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    private String name;

    @OneToMany(mappedBy = "user", cascade = CascadeType.ALL)
    @JsonManagedReference  // 🔵 직렬화 허용
    private List<Order> orders;
}

@Entity
public class Order {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    private String orderNumber;

    @ManyToOne
    @JoinColumn(name = "user_id")
    @JsonBackReference  // 🔴 역방향 직렬화 제외
    private User user;
}

💡 "부모(User) → 자식(Order)" 관계에서는 JSON 직렬화 가능
💡 "자식(Order) → 부모(User)" 관계에서는 직렬화 제외

🟢 적용 후 JSON 결과

{
  "id": 1,
  "name": "Kim",
  "orders": [
    {
      "id": 101,
      "orderNumber": "A123"
    }
  ]
}

🚀 양방향 관계지만, JSON 변환 시 루프가 발생하지 않도록 자동으로 처리됨!


4️⃣ 최종 정리

해결 방법 장점 단점
@JsonIgnore 간단하고 빠름 특정 필드가 JSON에서 아예 제외됨
DTO 변환 API 설계에 유연 변환 과정이 추가로 필요
@JsonManagedReference / @JsonBackReference 자동 직렬화 조절 가능 복잡한 관계에서는 예상치 못한 동작 가능

📌 실무에서 가장 추천되는 방식
✅ @JsonIgnore: 가장 쉬운 방법이지만, 일부 데이터가 JSON에서 사라질 수 있음
DTO 변환(추천!): API 설계가 자유롭고, 직렬화 문제 없이 필요한 데이터만 제공 가능
✅ @JsonManagedReference/@JsonBackReference: 자동 처리는 가능하지만, 관계가 복잡할 경우 예측하기 어려울 수 있음


🚀 결론: 가장 추천하는 해결 방법은?

무한 루프 문제를 해결하는 방법은 여러 가지가 있지만,
"API 설계의 확장성"과 "유지보수 편리함"을 고려하면 DTO 변환 방식이 가장 안정적

  • 간단한 해결 👉 @JsonIgnore
  • API 확장성 & 유지보수 용이 👉 DTO 변환 (가장 추천)
  • 자동 직렬화 조절 필요 👉 @JsonManagedReference / @JsonBackReference