Loading the catalog…
Loading the catalog…
"분명 탈퇴 여부를 검사하는 if문이 있는데, 왜 탈퇴 계정 복구가 전혀 동작하지 않았을까?" 소프트 딜리트(Soft Delete)를 구현할 때 흔히 사용하는 Hibernate의 전역 엔티티 필터인 @SQLRestriction 이 어떻게 5개 비즈니스 로직을 조용히 '죽은 코드(Dead Code)'로 만들어버렸는지, 그리고 이를 어떻게 해결했는지에 대한 실전 백엔드 트러블슈팅 기록입니다. 🚨 1. 증상: 조용히 마비되어 있던 탈퇴 계정 복구 기능들 보안 코드 리뷰를 진행하던 중, 놀랍게도 서비스 전반에서 '탈퇴(Soft Delete) 회원의 계정 복구'와 관련된 모든 경로가 사실상 100% 마비 되어 있다는 사실을 발견했습니다: 비밀번호 로그인 시 : 탈퇴 회원이 정확한 이메일/비밀번호를 쳐도 복구 안내( DELETED_ACCOUNT ) 대신 단순 *"이메일 또는 비밀번호가 올바르지 않습니다"*만 반환. 탈퇴 계정 복구 시도 시 : 비밀번호 기반 복구든 이메일 인증 기반 복구든, 항상 *"복구 가능한 탈퇴 계정이 없습니다"*라는 에러만 발생. 소셜(OAuth) 로그인 시 (가장 심각) : 탈퇴한 회원이 카카오/구글 로그인을 시도하면, 복구 안내를 띄우는 게 아니라 아예 기존 이메일을 무시하고 신규 가입 화면으로 이동 . 정상 회원들은 아무 문제 없이 로그인되었기 때문에, 이 버그는 운영 환경에서 아무런 에러 로그도 남기지 않은 채 조용히 방치되어 있었습니다. 🔍 2. 원인 분석: @SQLRestriction 이 만든 보이지 않는 벽 엔티티 코드를 열어보자마자 범인이 드러났습니다: @Entity @Table(name = "users") @SQLRestriction("is_deleted = false") // 💥 Hibernate 전역 필터 public class User { // ... private boolean isDeleted; } JPA에서 탈퇴 회원을 일반 조회에서 자동으로 제외하기 위해 걸어둔 클래스 레벨의 @SQLRestriction("is_deleted = false") 이 문제였습니다. 이 어노테이션이 붙으면, UserRepository.findByEmail() 을 포함해 Spring Data JPA가 생성하는 모든 파생 쿼리와 연관관계 조회에 무조건 AND is_deleted = false 조건이 강제로 삽입 됩니다. 비즈니스 로직에 숨어있던 '죽은 코드(Dead Code)' // AuthService.java public LoginResult login(LoginRequest request) { // ❌ findByEmail()은 애초에 is_deleted = false 조건 때문에 탈퇴 회원을 절대 반환하지 않는다! (항상 null) User user = userRepository.findByEmail(request.getEmail()) .orElseThrow(() -> new BadCredentialsException("이메일 또는 비밀번호 불일치")); // 💥 아래의 if문은 영원히 실행될 수 없는 100% '죽은 코드'였다! if (user.isDeleted()) { return LoginResult.deletedAccount(); } // ... } 개발자는 당연히 user.isDeleted() 분기를 꼼꼼하게 작성해 두었지만, 정작 JPA 조회 단계에서 이미 탈퇴 회원이 걸러져 null 이 리턴되므로 예외가 터져버려 해당 if문에는 평생 도달할 수 없었던 것 입니다. 이와 똑같은 실수가 복구 로직, 소셜 로그인 성공 핸들러, 인증 코드 발송 로직 등 총 5곳의 메서드에 복사-붙여넣기처럼 퍼져 있었습니다. 🛠️ 3. 해결책: 전역 필터를 우회하는 명시적 네이티브 쿼리 도입 Hibernate의 @SQLRestriction 은 JPQL이나 파생 메서드 쿼리에는 무조건 붙지만, nativeQuery = true 로 작성된 순수 SQL에는 개입하지 않습니다. 1 전용 네이티브 조회 메서드 작성 public interface UserRepository extends JpaRepository<User, Long> { // 일반 조회: @SQLRestriction이 적용되어 활성 회원(is_deleted = false)만 조회 Optional<User> findByEmail(String email); // 🚀 탈퇴 회원 포함 조회: 전역 필터를 우회하기 위해 Native Query 사용 @Query(value = "SELECT * FROM users WHERE lower(email) = lower(:email)", nativeQuery = true) Optional<User> findByEmailIncludingDeleted(@Param("email") String email); } 2 탈퇴 여부를 검사해야 하는 5곳의 호출부 전면 교체 AuthService.login() : findByEmailIncludingDeleted 로 교체하여 DELETED_ACCOUNT 시그널 정상 반환. AuthService.restoreAccountWithPassword() / restoreAccount() : 탈퇴 회원을 정확히 조회해 is_deleted = false 로 원상복구. OAuth2SuccessHandler : 탈퇴 회원이 소셜 로그인 시 신규 가입이 아닌 복구 페이지( /restore )로 올바르게 분기. 🧪 4. 검증: 회귀 테스트 추가 AuthServiceTest 에 탈퇴 계정 시나리오를 꼼꼼하게 추가했습니다: 탈퇴한 계정으로 로그인 시도 시 DELETED_ACCOUNT 예외/응답이 정상 반환되는가? 비밀번호/이메일 인증 복구 시 실제로 계정이 활성화되는가? 이미 활성 상태인 회원이 복구 API를 호출하면 올바르게 거절되는가? ➡️ 테스트 4건 모두 작성 및 통과! 💡 이번 트러블슈팅을 통해 얻은 교훈 엔티티 전역 필터(@SQLRestriction, @Where)의 위험성 : 전역 필터는 편리하지만, 코드를 읽는 사람에게 조건을 "암묵적으로 은폐"시킵니다. 비즈니스 로직 작성자는 쿼리 레벨에서 이미 필터링되었다는 사실을 잊고 중복 분기 처리를 하다가 버그를 만들기 쉽습니다. 우회 메서드의 네이밍과 경고 주석 : findByEmailIncludingDeleted 처럼 "이 조회가 전역 필터를 깬다"는 사실을 이름에 명확히 드러내야 합니다. 원본 findByEmail() 의 Javadoc에도 *"탈퇴 회원 조회가 필요한 경우 반드시 findByEmailIncludingDeleted 를 사용하세요"*라는 주석을 남겨 다음 개발자의 실수를 방지해야 합니다.
What RADAR observed and classified to build this opportunity. It is what the source published, not a verification that the offer is still active.
[JPA/Hibernate] 탈퇴 계정 복구 기능이 왜 먹통이었을까? (@SQLRestriction의 함정과 죽은 코드의 비극). "분명 탈퇴 여부를 검사하는 if문이 있는데, 왜 탈퇴 계정 복구가 전혀 동작하지 않았을까?" 소프트 딜리트(Soft Delete)를 구현할 때 흔히 사용하는 Hibernate의 전역 엔티티 필터인 @SQLRestriction 이 어떻게 5개 비즈니스 로직을 조용히 '죽은 코드(Dead Code)'로 만들어버렸는지, 그리고 이를 어떻게 해결했는지에 대한 실전 백엔드 트러블슈팅 기록입니다. 🚨 1. 증상: 조용히 마비되어 있던 탈퇴 계정 복구 기능들 보안 코드 리뷰를 진행하던 중, 놀랍게도 서비스 전반에서 '탈퇴(Soft Delete) 회원의 계정 복구'와 관련된 모든 경로가…
Open source