TIL_20250320_JPA Soft Delete 구현

Kim jisu·2025년 3월 20일

TIL

목록 보기
22/43

TIL: Spring Data JPA Soft Delete 구현 및 디버깅 과정

1. 초기 구현 및 문제 발생

  • BaseEntity 구현
    엔티티의 공통 속성으로 createdDateTime, modifiedDateTime 등과 함께 soft delete를 위한 deletedAt, deletedBy 필드를 정의했습니다.
    또한, softDelete(String userName) 메서드에서 deletedAt에 LocalDateTime.now()를, deletedBy에 전달받은 사용자명을 설정하도록 했습니다.

  • Service 계층의 deleteCompany 메서드

    @Transactional(readOnly = false)
    public void deleteCompany(UUID id) {
        Company company = companyRepository.findByIdAndDeletedAtIsNull(id)
            .orElseThrow(() -> new BaseException(Code.COMPANY_FIND_ERROR));
    
        company.softDelete("temp");
        companyRepository.save(company);
    }

    @Transactional(readOnly = false) 설정을 통해, 엔티티 변경사항이 DB에 반영되도록 했습니다.

  • 문제 상황
    PATCH 요청을 사용하여 삭제 API를 호출했지만, 로그 상에서 softDelete로 인한 SQL UPDATE가 실행되지 않았습니다.

2. 디버깅 과정 및 시도

2.1. PATCH 요청에 의한 병합 문제 의심

  • 가설:
    PATCH 요청을 처리하는 로직에서 클라이언트의 요청 본문을 기반으로 엔티티를 병합(merge)하는 과정이 있었고, 이 과정에서 softDelete로 설정한 값들이 덮어써지는 문제가 발생했을 가능성이 있었습니다.

  • 해결 시도:

    • PATCH 방식 대신에 전체 엔티티를 업데이트하는 PUT 방식을 고려하였으나, PUT 역시 전체 엔티티를 전달해야 하므로 클라이언트 부담이 큼.
    • soft delete 기능은 단순히 삭제 관련 필드만 업데이트하면 되므로, 해당 기능만을 위한 전용 엔드포인트를 구성하는 방향으로 전환.

2.2. 전용 soft delete 엔드포인트 구성

  • 전용 엔드포인트 설계:
    PATCH 대신에 soft delete 전용의 PUT 엔드포인트를 생성하여, 병합 로직 없이 삭제 관련 필드만 업데이트하도록 함.

  • Controller 코드 예시:

    @RestController
    @RequestMapping("/api/v1/companies")
    @RequiredArgsConstructor
    public class CompanyController {
    
        private final CompanyService companyService;
    
        @PutMapping("/{id}/soft-delete")
        public ResponseEntity<?> softDeleteCompany(@PathVariable("id") UUID id) {
            companyService.deleteCompany(id);
            return ResponseEntity.ok(ApiResponseData.success(Code.COMPANY_DELETE));
        }
    }

    이로써 클라이언트는 soft delete 기능만을 호출할 수 있어, 기존의 엔티티 병합 이슈를 회피할 수 있었습니다.

2.3. 추가 오류: ClassCastException 발생

  • 오류 로그:

    java.lang.ClassCastException: class java.util.ArrayList cannot be cast to class org.springframework.data.domain.Page
  • 원인 분석:

    • CompanyRepository의 findAllByDeletedAtIsNull(Pageable pageable) 메서드가 List를 반환하도록 선언되어 있었으나, 서비스 계층에서 이를 Page로 캐스팅하려 했습니다.
  • 해결 방법:
    리포지토리 메서드의 반환 타입을 List에서 Page로 변경하도록 수정했습니다.

    public interface CompanyRepository extends JpaRepository<Company, UUID> {
    
        Optional<Company> findById(UUID id);
        Optional<Company> findByIdAndDeletedAtIsNull(UUID id);
    
        List<Company> findAll();
    
        // 반환 타입 수정
        Page<Company> findAllByDeletedAtIsNull(Pageable pageable);
    
        @Query("SELECT c FROM Company c " +
            "WHERE (:keyword IS NULL OR :keyword = '' OR " +
            "      cast(c.id as string) LIKE concat('%', :keyword, '%') OR " +
            "      cast(c.hub_id as string) LIKE concat('%', :keyword, '%') OR " +
            "      lower(c.name) LIKE lower(concat('%', :keyword, '%')) OR " +
            "      lower(c.type) LIKE lower(concat('%', :keyword, '%')) OR " +
            "      lower(c.address) LIKE lower(concat('%', :keyword, '%')))")
        Page<Company> searchCompanies(@Param("keyword") String keyword, Pageable pageable);
    }

    이로 인해 ClassCastException 문제가 해결되었습니다.

3. 결론 및 배운 점

  • 엔티티 병합 문제: PATCH 요청 시 클라이언트의 본문과 기존 엔티티의 병합 과정에서 soft delete 필드가 덮어씌워질 수 있음을 확인.
  • 전용 엔드포인트의 필요성: soft delete 기능만을 위한 별도의 엔드포인트(PUT)를 구성하여 문제를 회피할 수 있음을 배움.
  • 트랜잭션 관리: @Transactional(readOnly = false)를 명시하여 변경 감지 및 업데이트가 제대로 이루어지도록 해야 함.
  • 리포지토리 반환 타입 주의: Repository 메서드의 반환 타입을 서비스 계층에서 요구하는 타입과 일치시켜 ClassCastException을 방지.

이러한 과정을 통해 soft delete 구현 시 발생할 수 있는 다양한 문제들을 파악하고, 각 문제에 대한 해결 방안을 적용해 보았습니다.

profile
Dreamer

0개의 댓글