땃쥐네

[JPA] JPA 지연로딩과 프록시, 그로 인한 트러블 슈팅 본문

Spring/Spring Data

[JPA] JPA 지연로딩과 프록시, 그로 인한 트러블 슈팅

ttasjwi 2023. 5. 12. 11:14

팀프로젝트 진행 과정에서, 단위 테스트는 통과했으나 실제 API를 통해 전체적으로 잘 작동되는지 확인했을 때 예외가 터지는 부분이 발생해서 삽질을 하게 됐습니다.

 

 

저희 프로젝트에서는 '여행 일정 관리 서비스'를 구현하고 있습니다. 사용자(여행자, Tripper)는 여러개의 여행(Trip)을 가질 수 있고, 여행(Trip)은 여러 개의 연속된 여행일(Day)를 가질 수 있으며, 여행일에는 여러 개의 일정(Schedule)들을 가질 수 있습니다. 어떤 여행일(Day)에도 속하지 않은 일정들은 Trip의 임시보관함(TemporaryStorage)에 속한 일정들로 간주하고 있습니다.

 

일정을 생성하는 과정에는 tripId, dayId 라는 필드를 전달함으로서 어느 Trip의 어느 Day에 생성할지 지정할 수 있습니다.

만약 dayId가 null이면 해당 Trip의 임시보관함에 새로 일정을 추가하는 것이고, dayId가 null이 아니면 해당 Trip의 해당 Day에 일정을 추가하는 것이죠.

 

@RequiredArgsConstructor
@Service
public class ScheduleCreateService implements ScheduleCreateUseCase {

    private final ScheduleRepository scheduleRepository;
    private final DayRepository dayRepository;
    private final TripRepository tripRepository;

    @Override
    @Transactional
    public Long createSchedule(Long tripperId, ScheduleCreateCommand createCommand) {
        Long dayId = createCommand.getDayId();
        Long tripId = createCommand.getTripId();

        Day day = findDay(dayId);
        Trip trip = findTrip(tripId);
        validateCreateAuthority(trip, tripperId);

        Schedule schedule;
        try {
            schedule = trip.createSchedule(day, createCommand.getTitle(), createCommand.getPlace());
        } catch (ScheduleIndexRangeException e) {
            scheduleRepository.relocateDaySchedules(tripId, dayId);
            trip = findTrip(trip.getId());
            day = findDay(dayId);
            schedule =  trip.createSchedule(day, createCommand.getTitle(), createCommand.getPlace());
        }
        scheduleRepository.save(schedule);
        return schedule.getId();
    }

}

일정 생성의 서비스 클래스 코드입니다.

생성하려는 일정을 소속시킬 Day, 그리고 그 Day가 속한 Trip을 찾아온 뒤, Trip에게 위임하여 일정을 생성하게 합니다.

 

    private Day findDay(Long dayId){
        return (dayId == null)
                ? null
                : dayRepository.findById(dayId).orElseThrow(() -> new DayNotFoundException("Schedule을 Day에 넣으려고 했는데, 해당하는 Day가 존재하지 않음."));
    }

 

이때, dayId가 null일 경우는 임시보관함으로 간주하는데 이를 처리하기 위해 dayId가 null 이면 day를 바로 null로 다루도록 합니다. 그리고 DayRepository에서는 Day를 바로 갖고옵니다. (여기서 Day만 가져오는 부분이 이번 글에서 다루는 문제의 원인입니다.)

 

	# Trip의 메서드
    public Schedule createSchedule(Day day, String title, Place place) {
        return (day == null)
                ? makeTemporaryStorageSchedule(title, place)
                : makeDaySchedule(day, title, place);
    }

 

여행은 일정을 생성하라는 요청을 받고, day가 null 이면 자신의 임시보관함에 일정을 생성하고, day가 null 이 아니면 makeDaySchedule 메서드을 통해서, day가 자기 자신의 day인지 확인하고 day에게 일정 생성을 위임합니다.

 

그런데 이번 문제 상황은 Day에 일정을 새로 생성하는 과정에서, 발생한 것입니다.

 

    private Schedule makeDaySchedule(Day day, String title, Place place) {
        if (!day.getTrip().equals(this)) {
            throw new InvalidTripDayException("해당 day는 Trip의 Day가 아님");
        }

        return day.createSchedule(title, place);
    }

makeDaySchedule 메서드입니다. Trip에 속하지 않은 Day를 보내게 되면 잘못된 요청으로 간주하고 예외를 발생시키는 식으로 Trip 도메인 코드에 로직이 구현되어 있습니다.

 

우리 프로젝트에서는 JPA를 사용하고 있으므로 영속성 컨텍스트를 통해 엔티티들이 관리될테고, 올바른 요청이라면 Day의 getTrip()을 통해 얻어온 trip은 동일한 참조의 Trip 엔티티일 것이므로 동등성 조건이 통과될 것이라고 생각했습니다. 하지만 이 상황에서는, 분명 제대로 Trip에 속한 dayId로 요청을 보냈음에도 예외가 발생하고 있습니다.

 

(* 여기서 Day에서 getTrip을 통해 동등성을 비교하기보다 day에게 Trip을 자기 자신을 전달해서 그가 가진 Trip과 비교하게해달라고 책임을 위임하는게 더 좋을 수 있어서 수정의 여지가 있긴 한데 그 부분은 이 글에서 다루지 않겠습니다.)

day의 식별자는 1번이고 우리가 Trip에 등록한 Day가 맞다.

그래서 해당 검증부에 디버거를 걸고 확인을 해봤습니다. 분명 여기로 전달된 day의 dayId는 1번으로, 해당 Trip에 속한 Day가 맞습니다. 그렇다면 getTrip을 통해 가져온 Trip이 Trip 자기 자신과 달라진 것인가? 이 부분을 볼 필요가 있는데요.

 

그런데 day가 가진 trip은 프록시 Trip이였다.

 

day는 내부적으로 trip을 갖고 있으나, 이 trip은 정확히는 Trip이 아닌 Trip을 상속하여 만들어진 프록시 클래스(Trip$HibernateProxy$...) 인 것을 확인할 수 있습니다. 이 trip은 내부적으로 target이라는 필드를 갖고 있는데 이것이 진짜 Trip입니다. target의 진짜 Trip은 이 getter를 호출한 시점에 지연로딩이 일어나서 가져와집니다.

 

본론으로 돌아가보면 왜 아까 상황에서 false가 반환된 것일까 생각해봅시다.

`day.getTrip()` 을 통해 얻어온 Trip과의 동등성을 비교하는데 타입부터가 다르고, 객체 참조도 다르며, 별달리 동등성 조건을 오버라이드 안 하고 동등성 비교를 하기 때문에 동등성 비교에서 false가 반환됐던 것입니다.

@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Table(name = "days")
@Entity
public class Day {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    @Column(name = "day_id")
    private Long id;

    @Column(name = "trip_date")
    private LocalDate tripDate;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "trip_id")
    private Trip trip;

 

Day 클래스를 살펴보면 Trip은 지연로딩 대상으로 되어 있어서, Day만을 가져온다면 trip은 프록시 객체로 채워지게 됩니다.

    private Schedule makeDaySchedule(Day day, String title, Place place) {
        if (!day.getTrip().equals(this)) {
            throw new InvalidTripDayException("해당 day는 Trip의 Day가 아님");
        }

        return day.createSchedule(title, place);
    }

 

여기서, 제가 의도한 대로 Trip에게 전달된 Day가 Trip의 Day임을 검증하는 방법은 여러 방법이 있을 수 있습니다.

 

가장 먼저 첫번째로 생각한 방법은 day.getTrip().getId()를 통해 식별자를 얻어오고 Trip의 Day와 비교하는 것인데요. 이 방식을 사용하면 프록시 trip이 자신의 target에게 getId 요청을 하여 id를 얻어온 뒤 반환해주기 때문에 제 의도대로 동작하긴 할겁니다. 하지만 이 부분은 테스트 코드 상에서 id가 없는 상태에서 진행되는 도메인 테스트들도 있다보니 테스트 작성이 힘들어질 것 같다는 생각이 들었습니다.

 

그 다음으로 생각한 방법은 day.getTrip을 통해 가져온 Trip이 프록시가 아닌, 진짜 Trip이 되도록 하는 것입니다.

 

@RequiredArgsConstructor
@Service
public class ScheduleCreateService implements ScheduleCreateUseCase {

	// 생략
    private Day findDay(Long dayId){
        return (dayId == null)
            ? null
            : dayRepository.findById(dayId).orElseThrow(() -> new DayNotFoundException("Schedule을 Day에 넣으려고 했는데, 해당하는 Day가 존재하지 않음."));
    }

서비스 계층에서 Day를 찾아올 때 findById를 통해 Day를 찾아오는데 이 방식대로면 Day의 Trip을 함께 찾아오지 않고 Trip은 지연로딩 대상이 됩니다.

public interface ScheduleRepository extends JpaRepository<Schedule, Long> {

    @Query("SELECT s" +
            " FROM Schedule as s JOIN FETCH s.trip" +
            " WHERE s.id = :scheduleId")
    Optional<Schedule> findByIdWithTrip(@Param("scheduleId") Long scheduleId);
    private Day findDay(Long dayId){
        return (dayId == null)
                ? null
                : dayRepository.findByIdWithTrip(dayId).orElseThrow(() -> new DayNotFoundException("Schedule을 Day에 넣으려고 했는데, 해당하는 Day가 존재하지 않음."));
    }

Day를 찾아올 때 Trip도 함께 찾아올 수 있도록 페치조인을 하도록 하여, Trip도 함께 찾아오게 합니다.

 

현재 로직에서는 Trip도 어차피 반드시 사용되는 만큼, 지연로딩을 통해 Trip을 새로 질의 하지 않고 한번에 가져오면 쿼리 횟수도 줄어들고 성능상 이득이 약간 생기는 이점도 생깁니다.

 

이렇게 하면 Day가 가진 Trip은 진짜 Trip이 됩니다. 영속성 컨텍스트에서 직접적으로 관리되는 진짜 Trip 엔티티입니다.

 

진짜 Trip을 함께 가져왔다.

디버거를 찍어보면 이제 Day의 Trip은 페치조인을 통해 즉시 가져왔으므로 실제 Trip 타입의 Trip이 그대로 주입됩니다.

같은 영속성 컨텍스트에서 관리되는 동일한 식별자의 Trip이므로 this와 참조도 같습니다.

 

따라서 동일한 객체이므로 동등하고 제가 의도한 대로 동작하게 될 것입니다.


# 느낀 점

- 1년전에 JPA를 학습하면서 잠깐 배웠던 내용인데, 단순히 책과 강의로 배웠던 것에서 그치지 않고 실제 프로젝트에서 직접 삽질을 하면서 문제로 접하게 되어 더 의미 있었던 것 같습니다.

- 같은 영속성 컨텍스트의 동일 식별자 객체니 동일할거고, 동등성이 통과될거라는 전제하에 equals로 비교했었는데 지연로딩을 통해 가져온 프록시 엔티티에 대해 동등성 비교를 하게 되면, 예상치 못한 상황이 발생할 수 있음을 확인했습니다. 로직에서 함께 자주 사용한다면 페치 조인을 해서 함께 갖고오도록 하는 것이 성능상에서도 이득이 많고, 같은 엔티티에 대한 동일성이 보장되므로 동등성 통과도 편리해지겠다는 결론을 내렸습니다.


# 진행 중인 프로젝트 리포지토리

- Trilo-BE

- 해당 이슈 PR

Comments