0. 문제 상황 - 무한참조 문제 발생
문제 상황 설명에 앞서 도메인 설명을 간략하게 하겠습니다.
저는 출석부 서비스 "체쿠리"를 개발 중입니다.
회원은 N 개의 출석부를 가질 수 있습니다.
출석부는 N 명의 회원을 가질 수 있습니다.
출석부를 여러 명의 선생님이 함께 관리하는 시스템인거죠.
그리고 출석부는 사용 가능 요일( AttendanceDays )이 있습니다.
1개의 출석부는 [월, 화, 수, 목, 금 ] 등 1..N 개의 AttendanceDays 가 있습니다.
문제는 여기서 발생합니다.
회원이 가진 출석부를 조회하는 API를 통해서
AttendanceBook, AttendanceDays 를 함께 호출했더니 아래와 같은 결과가 발생했습니다.
AttendanceBook, AttendanceDays 가 서로를 계속 호출하여
무한루프에 갇힌 모습을 확인할 수 있었습니다.
왜 그러는 걸까요 ?
1. 문제 원인
public class AttendanceBook extends BaseEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "attendance_book_id")
private Long id;
@Comment("출석부 이름")
@Column(nullable = false)
private String title;
@OneToMany(mappedBy = "attendanceBook", cascade = CascadeType.ALL)
private List<AttendanceDay> attendanceDays = new ArrayList<>();
@OneToMany(mappedBy = "attendanceBook")
private List<UserAttendanceBook> userAttendanceBooks = new ArrayList<>();
}
public class AttendanceDay extends BaseEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "attendance_day_id")
private Long id;
@Enumerated(EnumType.STRING)
private DayOfWeek day;
@ManyToOne(fetch = LAZY)
@JoinColumn(name = "attendance_book_id")
private AttendanceBook attendanceBook;
}
엔티티 코드를 살펴 봅시다.
분명히 ManyToOne, OneToMany 관계에 fetchType 또한 LAZY 로 설정한 것도 확인 가능합니다.
그런데 왜 서로를 무한 호출하는 걸까요?
이는 JSON 직렬화 과정의 문제였습니다.
1. Jackson(ObjectMapper)은 객체를 JSON으로 변환할 때 객체의 모든 필드를 직렬화하려고 시도합니다.
2. JSON 직렬화 도중 양쪽이 서로를 계속 참조하면서 직렬화가 끝나지 않고 무한 루프가 발생합니다.
2. 해결 방법 - 1안 ( @JsonIgnore )
가장 쉬운 해결 방법은 직렬화 할 때
양방향 관계 중 한쪽의 필드를 JSON 직렬화에서 제외하는 방법입니다.
구현도 간단합니다 한 쪽에 @JsonIgnore 를 달아주면 됩니다.
그러면 OneToMany 와 ManyToOne 중 어느 쪽에 달면 될까요 ?
보통 OneToMany 쪽에 @JsonIgnore 어노테이션을 달아줍니다.
이유 1. OneToMany 쪽은 데이터 양이 많기 때문
이유 2. 설계 관점에서 부모 객체에서 자식 리스트를 항상 포함하지 않아도 되는 경우가 많습니다.
이유 3. 반대로, 자식 객체에서는 부모를 포함하는 것이 자연스럽고 데이터 구조상 이해하기 쉬운 경우가 많습니다.
그런데 잘 생각해 보면 ...
Entity 를 API 의 결과로 노출하는 것이 맞을까요 ?
3. 해결 방법 - 2안 ( ResponseDto 만들기 )
Entity 를 외부로 노출하게 되면
Entity 가 외부의 요구사항을 알아야 하는 문제가 발생하고
요구사항에 의해 엔티티 클래스가 자주 변경되야하는 문제가 발생하게 됩니다.
그래서 Entity 를 노출하는 것이 아니라 결과 DTO 를 만드는 것이 더 좋은 방법이 될 것입니다.
그리고 이 과정에서 자연스럽게 무한루프 문제도 해결이 됩니다.
@Data
@Builder
public class AttendanceBookResponse {
private Long id;
private String title;
private List<DayOfWeek> availableDays;
}
lombok 을 활용하면 구현 또한 굉장히 간단하게 만들 수 있습니다.
이제 조회 로직을 수정해 봅시다.
리턴 타입을 Entity 에서 Response DTO 로 변경해 주었습니다.
public List<AttendanceBookResponse> getMyAttendanceBook() {
Long userId = getAuthenticatedUserId();
List<AttendanceBook> collect = userAttendanceBookRepository.findByUserId(userId).stream()
.map(UserAttendanceBook::getAttendanceBook)
.collect(toList());
return collect.stream()
.map(attendanceBook -> AttendanceBookResponse.builder()
.id(attendanceBook.getId())
.title(attendanceBook.getTitle())
.availableDays(attendanceBook.getAttendanceDays().stream().map(AttendanceDay::getDay).collect(toList()))
.build()
).collect(toList());
}
AttendanceBook Entity 를 바로 노출하는 것이 아니라 결과를 조회한 후에 DTO 를 생성하도록 변경했습니다.
이 때 AttendanceBook : AttendanceDays = 1:N 이고 fetchType = LAZY 로 설정해주었기 때문에
DTO 를 생성하는 시점에서 attendanceBook.getAttendanceDays 를 실행하는 시점에 attendanceDays 조회를 실행하게 됩니다.
이제 결과를 한 번 확인해 봅시다.
예상했던 대로 무한 참조 문제가 해결된 것을 확인할 수 있습니다.