순환 참조를 줄인다는 것
Spring으로 프로젝트를 개발하다보면 엔티티 클래스를 구현 할 때 서로 연관성 있는 두개의 엔티티를 연결하기 위해 양방향 매핑을 종종 사용하게 된다. 이를 사용한 이유는 추후 두개의 객체에 접근하려 할 때 쉽게 접근이 가능했기 때문이다.
내가 이번에 공부한 내용은 순환 참조에 대한 내용인데, 내가 종종 사용했던 양방향 매핑과 순환 참조는 연관성이 있었다!
순환 참조가 무엇이고 이를 사용할 경우 어떤 문제점과 해결을 어떻게 하면 좋을지에 대해 작성해보려한다.
순환 참조란
두 개 이상의 객체나 컴포넌트가 서로를 참조함으로써 의존 관계에 사이클이 생기는 상황을 말한다.
순환 참조가 발생한 코드 예시
@Data
@NoArgsConstructor
@Entity(name = "event")
class EventEntity {
@Id
private String id;
private String name;
@OneToMany(mappedBy = "linkedBooth")
private List<BoothEntity> booths;
}
===========================================
@Data
@NoArgsConstructor
@Entity(name="booth")
class BoothEntity {
@Id
private String id;
private String name;
@ManyToOne
private EventEntity event;
}
위의 경우 Booth 와 Event 엔티티 클래스에 @OneToMany 와 @ManyToOne 을 이용해서 양방향 매핑을 적용했다. 따라서 Booth 와 Event 클래스는 서로를 참조했기 때문에 순환 참조를 하고 있는 상황이다.
양방향 매핑을 하게되면 자연스럽게 순환 참조를 하게되는데 이는 안티패턴을 적용하고 있는 것이다.
우리는 최대한 순환 참조를 피해 코드를 작성하는 것이 좋다.
순환 참조가 안티 패턴인 이유
1. 무한 루프
Event 객체가 Booth 객체의 메서드를 호출하고 Booth 객체가 Event 객체의 메소드를 호출할 경우 무한 루프가 만들어지게 된다.
// 객체 할당
Event event = new Event(1, "event1", new ArrayList<>());
event.getBooths().add(new Booth(1, "booth1", event));
event.getBooths().add(new Booth(2, "booth2", event));
// 직렬화
ObjectMapper objectMapper = new ObjectMapper();
String result = objectMapper.writeValueAsString(developerGroup);
System.out.println(result);
위의 예시는 상호 참조가 있는 객체를 만들고 JSON 직렬화를 진행한 것이다.
이를 실행하면 다음과 같은 에러가 발생한다.
com.fasterxml.jackson.databind.JsonMappingException: Infinite recursion(StackOverflowError)..
이 에러는 직렬화가 불가능해서 오류가 발생한다는 내용이다.
Event 객체 입장에서는 Booth 객체 리스트를 직렬화하려고 하는데, Booth 객체도 Event 객체를 참조하고 있어 직렬화 하는 과정에서 무한 루프에 빠지게 되기 때문이다.
따라서 순환 참조가 있는 객체를 직렬화 하려고 하면 프로그램은 콜 스택이 한계에 다다라서 StackOverflow 에러가 발생한다.
2. 시스템 복잡도
@Data
@NoArgsConstructor
@Entity(name = "event")
class EventEntity {
@Id
private String id;
private String name;
@OneToMany(mappedBy = "linkedBooth")
private List<BoothEntity> booths;
}
===========================================
@Data
@NoArgsConstructor
@Entity(name="booth")
class BoothEntity {
@Id
private String id;
private String name;
private String admin;
@ManyToOne
private EventEntity event;
// 행사 내 모든 부스 관리자들의 목록을 반환하는 메서드
public List<String> getBoothAdminList(){
List<String> adminList = new ArrayList<>();
for(Booth booth : event.getbooths()){
list.add(booth.admin);
}
return list;
}
}
위의 예시는 부스 엔티티 클래스에 행사 내 부스 관리자들의 목록을 반환하는 메서드를 작성한 예시다.
하지만 행사 전체 부스 관리자의 정보를 부스 엔티티에서 반환하는 메서드는 현실 세계에서 벌여질 수 없는 어색한 상황이다.
이렇게 어떤 요구 사항이 있을 때 무의미하고 불필요한 방법으로 구현 될 수도 있다는 사실을 개발할 때 확실한 노이즈가 된다.
시스템의 복잡도가 높아진다는 것은 코드의 복잡도가 높아질 뿐만 아니라 개발자가 겪는 논리적 혼란도 포함된다.
순환 참조를 해결하는 방법
불필요한 참조 제거하기
꼭 필요하지 않은 참조를 제거하거나 필요에 따라 표현해야한다면 한쪽이 다른 한쪽의 식별자를 갖고 있기 해서 간접 참조 형태로 바꾸는 방법이다.
@Data
@NoArgsConstructor
@Entity(name = "event")
class EventEntity {
@Id
private String id;
private String name;
@OneToMany(mappedBy = "linkedBooth")
private List<BoothEntity> booths; // 필요 고민
}
상황에 따라 Event 객체에 Booth 객체가 필요로 할 수 있겠지만 Event 클래스가 부스 목록을 모두 갖고 있는건 꼭 필요한 것이 아닐수도 있다.
위와 같이 진행할 경우 앞으로 EventEntity 클래스의 객체를 JPA를 통해 불러올 때마다 n+1 쿼리 가 발생할 위험도 생긴다.
n+1 쿼리
데이터베이스에서 필요한 정보를 한 번에 가져오는 대신 여러 번의 추가 쿼리로 데이터를 요청해 가져오는 상황을 의미한다.
부모 객체를 가져올 때 이에 속하는 자식 객체를 모두 JOIN해서 한번에 가져오고 싶을 경우,
ORM은 이 동작을 수행할 때 부모 객체 가져오는 쿼리 하나 n개의 자식 객체를 가져오는 쿼리 n개를 만들어 db에 요청할 수 있어 불필요한 db 부하와 성능 저하를 초래할 수 있다.
만약 booths 변수를 Event 객체에서 제거할 경우 부스 정보를 가져올 방법은?
booth 정보가 필요할 때마다 BoothRepository.findByEventId(eventId); 와 같은 메서드를 호출해서 부스 목록을 가져올 수 있다.
간접 참조 활용하기
@Data
@NoArgsConstructor
@Entity(name="booth")
class BoothEntity {
@Id
private String id;
private String name;
// @ManyToOne
// private EventEntity event;
private long eventId;
}
위의 코드는 BoothEntity 클래스가 갖고 있던 EventEntity 클래스로의 참조를 없애고 eventId 라는 변수를 만들었다.
이렇게 작성할 경우 부스에서 행사 정보가 필요할 때 EventRepository.findById(eventId) 와 같은 메소드를 호출해서 팀 정보를 불러올 수 있다.
간접 참조를 활용한다는 것은 기존에 직접 참조하던 것을 참조 객체의 식별값을 이용해 참조하도록 바꾼다는 의미다.
공통 컴포넌트 분리
만약 서비스가 같은 컴포넌트에 순환 참조가 있고 그것이 설정상 필수적일 경우, 양쪽 서비스에 있던 공통 기능을 하나의 공통 컴포넌트로 분리하는 방법이 있다.
이 방법은 공통 기능을 분리하는 과정에서 책임 분배가 적절하게 재조정된다. 컴포넌트의 기능적 분리는 결과적으로 과하게 부여됐던 책임을 분산하고 기능적 응집도를 높이는 효과를 가져올 수 있다.
Spring으로 프로젝트를 구현하면서 항상 연관된 엔티티는 @ManyToOne이나 @OneToMany 를 이용해서 양방향 매핑을 해왔었는데 순환 참조의 원인이 된다는 점을 이번 기회에 배우게 되었다!
순환 참조를 피하기 위해서 앞으로 구현 할 때 양방향 매핑에 대해 더 고민해보고 사용하는 자세를 갖어야겠다는 생각이 들었다.
참고
저바/스프링 개발자를 위한 실용주의 프로그래밍 서적
'Programming > Spring' 카테고리의 다른 글
| Cursor 기반 페이지네이션 적용해보기 (0) | 2025.08.19 |
|---|---|
| Spring container와 Bean (1) | 2025.05.23 |
| Persistence Context와 EntityManager (3) | 2025.05.19 |
| 안티패턴이 무엇이며 어떻게 피할까 (0) | 2025.04.05 |
| 스프링에서의 싱글톤 컨테이너 (1) | 2025.03.17 |