📌 문제 상황
@Override
@Transactional
public MemberWithdrawHistoryDto cancel(Member loginMember, Long id) {
MemberWithdrawHistory memberWithdrawHistory = memberWithdrawHistoryRepository.findById(id).orElseThrow(() -> new CustomException(ErrorCode.NOT_FOUND_MEMBER_WITHDRAW_HISTORY));
if(!memberWithdrawHistory.getMember().equals(loginMember)){ // false (?)
throw new CustomException(ErrorCode.INVALID_AUTH_TOKEN);
}
...
}
출금 신청을 취소하는 로직에서 if(memberWithdrawHistory.getMember().equals(loginMember)) 해당 구문의 의도는 로그인한 사용자와, MemberWithdrawHistory.getMember() 가 같지 않을 경우 취소를 막아야하는 것이다.
당연히 로그인한 사용자와 출금 신청을 한 사용자가 같은데도 (Member pk인 Id 가 같음) 의도한 true 값이 아닌 false 값이 도출되는 것이다.
🤔 생각의 흐름과 해결 방법
1. 정말 같은 사용자가 맞나?
if (!memberWithdrawHistory.getMember().getId().equals(loginMember.getId())) { // true
...
}
혹시 모를 실수였을까봐 id 를 비교해봤지만, id 가 일치하는 같은 객체이다.
2. 프록시 객체..? 와 실객체가 비교됐나?
💡 프록시 객체란? (Proxy Object)
- 실제 엔티티 객체 대신에 DB 조회를 지연할 수 있는 가짜 객체
- 연관 객체가 실제로 사용될 때만 DB에서 로딩해주는 지연 로딩을 이용할 때 필요하다.
- 글로벌 fetch 전략을 FetchType.LAZY로 지정한 필드에 자동으로 적용되며, 프록시 객체를 직접 생성할 수도 있다.
MemberWithdrawHistory 엔티티에서 Member 는 지연 로딩으로 연결되어있으므로 memberWithdrawHistory.getMember()는 프록시 객체일 것이고, 이로 인해 객체가 같지 않다고 판단되는 걸까?
➡️ Member 를 다시 정의해서 비교해볼까?
MemberWithdrawHistory memberWithdrawHistory = memberWithdrawHistoryRepository.findById(id).orElseThrow(() -> new CustomException(ErrorCode.NOT_FOUND_MEMBER_WITHDRAW_HISTORY));
Member member = memberRepository.findById(loginMember.getId()).orElseThrow(() -> new CustomException(ErrorCode.NOT_FOUND_MEMBER));
if (memberWithdrawHistory.getMember().equals(member)) { // true
throw new CustomException(ErrorCode.INVALID_AUTH_TOKEN);
}
➡️ memberWithdrawHistory.getMember() 와 member 가 같은 것으로 보아 둘 다 프록시 객체인가 보다..!
- MemberWithdrawHistory 에서 Member 는 지연 로딩(lazy loading) 으로 연관관계가 설정되어있으므로, memberWithdrawHistory.getMember() 는 무조건 프록시 객체이다. 그런데 member 와 같다고 하니 member 또한 프록시 객체일 것이다.
- loginMember.getId() 로 탐색을 하니 당연히 loginMember 와 같은 객체일 것이고, 이는 영속성 컨텍스트에 캐싱되어있는 프록시 객체인 memberWithdrawHistory.getMember() 와 동일하다.
3. 그러면 loginMember 가 프록시 객체가 아닌가?
우선 결론은 “Yes” 이다. 디버깅을 하면 프록시 객체 여부를 손쉽게 확인할 수 있는데, 다음과 같다.
- loginMember (@Authentication 을 통해 가져온) : 실객체
- memberWithdrawHistory.getMember() : 프록시 객체
- member (memberRepository.findById(loginMember.getId) : 프록시 객체
➡️ loginMember 는 어디서 난거냐
Spring Security의 @AuthenticationPrincipal ****어노테이션은 현재 인증된 사용자의 정보를 제공하기 위해 사용한다. 이 어노테이션을 통해 주입되는 객체는 Spring Security가 인증을 처리하고 세션에 저장한 사용자 정보를 담고 있다. 이게 Controller 를 통해 넘어온 loginMember 이다.
➡️ 왜 loginMember 는 실객체인가?
Spring Security에서는 각 사용자에 대해 별도의 객체 인스턴스를 생성하여 제공한다. 이 객체는 해당 사용자의 인증 정보를 포함하며, 다른 사용자와는 별개의 공간에 위치한 독립적인 인스턴스로 생성된다.
일반적으로 해당 어노테이션을 통해 제공되는 객체는 프록시 객체가 아니며, 직접 제공된다.
(그런데 사실 실객체라해도, 동일한 인스턴스라면 프록시 객체와 실객체는 같다고 나와야한다.)
4. 그렇다면 해결책은?
결론 먼저 얘기하자면 다음과 같이 비교를 하면 된다.
if (!memberWithdrawHistory.getMember().getId().equals(loginMember.getId())) {
throw new CustomException(ErrorCode.INVALID_AUTH_TOKEN);
}
사실 위의 방법 포함 2가지가 있을 줄 알았다.
- pk 인 Id 값 비교
- 프록시 객체인 memberWithdrawHistory.getMember() 을 unproxy 하기 → unproxy 객체끼리의 비교
하지만 2번째 방법을 적용해봤을 때 내 예상과는 달랐다.
public MemberWithdrawHistoryDto cancel(Member loginMember, Long id) {
MemberWithdrawHistory memberWithdrawHistory = memberWithdrawHistoryRepository.findById(id).orElseThrow(() -> new CustomException(ErrorCode.NOT_FOUND_MEMBER_WITHDRAW_HISTORY));
Member unproxyMember = (Member) Hibernate.unproxy(memberWithdrawHistory.getMember());
if (!unproxyMember.equals(loginMember)) { // false
throw new CustomException(ErrorCode.INVALID_AUTH_TOKEN);
}
💡 (Member) Hibernate.unproxy() : 이를 실제 Member 객체로 변환
이는 2번에서의 “그런데 사실 실객체라해도, 동일한 인스턴스라면 프록시 객체와 실객체는 같다고 나와야한다.” 이 부분을 간과한 결과이다.
애초에 같은 인스턴스였으면 실객체, 프록시객체 상관없이 실객체.equals(프록시객체) 의 결과가 true 로 나와야한다. 하지만 동일한 인스턴스가 아니였으므로(서로 다른 인스턴스로 관리되고 있는 경우) false 가 나온 것이다.
😂 왜 loginMember 와 memberWithdrawHistory.getMember 다른 인스턴스일까?
사실 이 부분에서 이유는 나와있다. @AuthenticationPrincipal 어노테이션을 통해 받아오는 사용자 정보는 독립적인 인스턴스로 관리되고 있기때문에, service 로직에서 가져오는 Member 와 다른 인스턴스인 것이다.
결론 요약 🎂
- @Authentication 어노테이션으로 받아온 사용자 정보(loginMember) 는 객체 타입이 같을지라도(Member), Spring Security 에서 보안을 위해 각 사용자에대해 별도의 객체 인스턴스를 생성해주며, 이는 독립적이다.
- 따라서 memberWithdrawHistory.getMember().equals(loginMember) == false 이다.
- 애초에 다른 인스턴스이므로 !
☁️ 느낀점
JPA 엔티티 영속성, 프록시 객체, Spring Security 의 이해도가 더 필요함을 느꼈고, 기본이 중요하다 싶다.
특히 ‘equals() 는 Object 의 값/주소값을 비교한다’ 라는 사전적 의미는 알고 있었지만, 인스턴스 개념으로 다가가니 .. 본질을 잊고 있었다는 치명적인 실수를 저질렀다. 이래서 짧은 한줄도 생각하고 짜라는 것이군.. ‼️
'Dev > Spring boot' 카테고리의 다른 글
[Spring] Bean Scope (0) | 2025.05.21 |
---|