최근 프로젝트에서 Course 삭제 시 다음과 같은 404 에러가 발생했다.
이 문제는 다음과 같은 validateCourse 메서드에서 Course의 소유자와 삭제를 요청한 사용자가 동일한지 확인하는 과정에서 발생했다.
private void validateCourse(final User findUser, final Course findCourse) {
if (!findUser.equals(findCourse.getUser())) {
throw new ForbiddenException(FailureCode.COURSE_DELETE_ACCESS_DENIED);
}
}
디버깅을 해보니, findCourse.getUser()와 findUser는 동일한 User 객체를 참조하고 있음에도 불구하고 서로 다른 객체로 인식되어 비교에서 실패했다.
그렇다면 왜 다른 객체로 인식되었을까?
이 오류의 원인은 JPA의 지연 로딩(lazy loading) 방식 때문이다. Course와 User 간의 관계는 @ManyToOne(fetch = FetchType.LAZY)으로 설정되어 있어, Course를 처음 조회할 때 User는 실제 객체가 아닌 프록시 객체로 로딩된다.
프록시 객체는 실제 객체의 상속본(참조)이 있는 것이지 실제 객체와 동일하진 않기 때문에 두 객체가 다른 것으로 인식된다.
지연로딩이란?
지연 로딩(lazy loading)은 객체를 조회할 때 필요한 시점까지 관련된 객체를 조회하지 않고, 대신 프록시 객체로 대체하는 방식이다. 이렇게 하면 데이터베이스로부터 모든 연관된 데이터를 한 번에 가져오지 않기 때문에 성능을 최적화할 수 있다. 실제로 연관된 데이터가 필요해졌을 때에만 데이터베이스에서 조회가 이뤄진다.
예를 들어, Course를 조회할 때 Course와 연관된 User 객체도 함께 가져오지 않고, User에 접근하는 순간에만 실제 데이터를 조회하는 방식이 지연 로딩이다.
하나의 엔티티를 조회할 때 관련된 모든 엔티티를 한 번에 조회하는 것은 비효율적이다. 이때 이런 비효율적인 것을 막아주기 위해 지연 로딩이 존재한다. 지연로딩은 관련 엔티티들이 실제 필요해질 때 조회를 해주는 기능이다. 따라서 ManyToOne의 경우 되도록 지연로딩을 해놓는 것이 좋다고 생각한다.
프록시 객체란?
프록시 객체(proxy)는 실제 객체 대신 사용되는 가짜 객체이다. JPA는 지연 로딩을 지원하기 위해 연관된 엔티티를 즉시 로드하는 대신, 프록시 객체를 반환한다. 이 프록시 객체는 실제로는 User의 모든 데이터를 가지고 있지 않지만, 마치 실제 객체인 것처럼 행동하며, 특정 메서드(예: getId())가 호출될 때 비로소 데이터베이스에서 실제 데이터를 가져온다.
프록시 객체는 실제 객체와는 다르게 동작할 수 있고, JPA가 자동으로 생성한 프록시 객체와 실제 User 객체는 동일한 클래스를 상속받고 있지만 서로 다른 인스턴스로 인식될 수 있습니다. 이로 인해 위에 문제처럼 equals 비교에서 오류가 날 수 있다.
어떻게 해결할 수 있을까?
-> equals 메서드 재정의
이 문제를 해결하기 위해 User 엔티티의 equals 메서드를 다음과 같이 재정의하여 객체의 실제 식별자인 id로 비교하도록 수정했다.
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o == null || !(o instanceof User)) return false;
User user = (User) o;
return Objects.equals(id, user.id); // Compare using the unique ID
}
처음에 이 오류 관련해서 if (o == null || !(o instanceof User)) return false; 대신 if (o == null || getClass() != o.getClass()) return false; 코드를 사용한 것을 봤는데 이 코드에서 Object의 User(Course의 User)가 프록시 객체이기 때문에 class를 가져와서 비교하는 것으로 구현할 경우 false로 동일한 오류가 반복된다.
추가로 위와 같이 프록시 객체는 id도 null 값으로 설정되어 있어 getter를 사용해 실제 쿼리를 한 번 더 날려서 (coures의) user의 id를 받아와야 했다. 따라서 최종적으로 다음과 같은 코드로 equals를 재정의 해줬습니다.
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o == null || !(o instanceof User)) return false;
User user = (User) o;
return Objects.equals(id, user.getId()); // Compare using the unique ID
}
추가) 쿼리 날림에 대해서...
지연로딩으로 인해 재정의한 equals 메서드에서 프록시 객체의 getId()를 호출하면 추가적인 쿼리가 발생할 것이라고 생각했다. 따라서 다음과 같이 sout을 통해 확인해 봤다.. (sout 쓰면 안 좋다니 log.info() 사용해서 확인하세요.. 그렇지만 난 그냥 냅다 sout으로 하긴 했다 ㅎㅎ) 실제로 디버깅을 통해 확인해 보니, getId() 호출만으로는 프록시가 초기화되지 않고, 추가적인 쿼리도 발생하지 않았다.
왜 프록시 객체에서 Id를 가져올 경우엔 추가적인 쿼리가 나가지 않을까?
식별자(id)를 조회할 때는 프록시를 초기화(추가적 쿼리로 조회) 하지 않는다. 이는 JPA의 프록시 객체 내부에 있는 ByteBuddyInterceptor라는 클래스가 id 값을 이미 가지고 있기 때문이다. 정확히는 ByteBuddyInterceptor 상위의 추상 클래스인 AbstractLazyInitializer가 담당하게 되는데, AbstractLazyInitializer의 getIdentifier 메서드를 호출하고, 이 메서드는 결과적으로 id를 반환해 준다. 따라서 결과적으로 AbstractLazyInitializer가 id 값을 가지고 있기 때문에 getId()만 사용했을 경우 추가적인 쿼리가 나가지 않는다. 이로 인해 getId()는 프록시를 초기화하지 않고도 id 값을 반환할 수 있다.
(id값은 프록시가 아니라 프록시 내부의 인터셉터에 들어있고, 프록시 객체가 가진 필드값들은 모두 null이다. 일반적으로는 필드로 꺼내 쓰기 때문에 상관없겠지만, 만약 필드가 public이어서 바로 접근한다면 null에 접근하게 된다.)
따라서 결론은 id는 인터셉트 내부에 있어서 추가로 쿼리가 나가진 않지만 기본적인 프록시 객체 필드에 있진 않으니 getter를 통해 값을 가져와야 한다.
생각보다 기본이 부족한 걸 느꼈다.. 디버깅 하면서 하나하나 공부할 수 있어서 좋았다..^^
'Spring' 카테고리의 다른 글
[Spring/DB] 실제 운영 서버에서 ddl-auto update의 문제점과 Flyway 도입 (ddl-auto, Flyway) (0) | 2025.02.28 |
---|---|
[Spring/Test] 분산락을 통한 동시성 제어 - Troubleshooting (3) | 2024.12.08 |
[Spring] MDC + Logback을 활용한 Discord Webhook 연동하기(2) - Troubleshooting (1) | 2024.12.02 |
[Spring] MDC + Logback을 활용한 Discord Webhook 연동하기(1) (0) | 2024.11.19 |