본문 바로가기
Spring/JPA

[Spring JPA] 데이터 정합성을 지키는 '데이터 수명주기 종료 프로토콜' 설계

by coding_whale 2026. 5. 23.
반응형

1. 도입부 (Introduction)

백엔드 엔지니어링을 수행하면서 데이터베이스 설계 단계에서 가장 많이 고민하고, 또 실제 상용화 배포 시점에 가장 잦은 예외 장애를 일으키는 구간이 바로 사용자 회원 탈퇴(Withdrawal) 영역이다. 데이터베이스 모델링 과정에서 테이블 간의 릴레이션(Relation)을 Foreign Key(외래 키)로 촘촘히 엮어둔 경우, 탈퇴 로직에서 '단순 삭제'를 무작위 시도하는 순간 데이터베이스 엔진은 즉각 외래 키 위반(FK Constraint Violation) 예외를 발생시키며 트랜잭션을 롤백시켜 버리기 때문이다.

탈퇴 처리는 단순한 테이블 속 데이터 소거 행위가 아니라 "사용자와 관련된 모든 관계, 활동 기록, 외부 알림 채널, 그리고 독립적 콘텐츠 데이터를 정해진 규칙대로 안전하게 이식해내고 수명을 끝내는 고도의 종료 프로토콜(Termination Protocol)"로 해석해야만 한다.

어떤 연관 리포지토리를 어떤 선후 관계로 호출해야 FK 예외가 터지지 않는지, 그리고 탈퇴 처리 이후에도 좀비처럼 살아남는 알림/푸시 토큰 등의 찌꺼기 개인정보를 어떻게 완벽하게 제거할 수 있는지 범용적 아키텍처 템플릿을 통해 깊이 있게 파헤쳐 본다.

 

 

2. 주요 특징 및 핵심 로직 (Main Features & Logic)

안전한 탈퇴 종료 파이프라인의 대원칙은 "자식(Referencing Entity)을 먼저 제거하고, 부모(Referenced Entity)를 가장 마지막에 제거하는 것"이다. 데이터베이스가 엮고 있는 계층 구조를 역순으로 훑어가며 데이터를 깔끔하게 클리닝해야 무결성이 유지된다.

 

전체 프로토콜의 단계별 삭제 대상과 그에 대응하는 리포지토리를 일목요연하게 비교 대조하면 다음과 같다.

라이플사이클단계 삭제 타켓 테이블 (Repository) 구체적인 삭제 규칙 및 연산 방식 기술적 당위성
[1] 관계 해제 FollowRepository, UserBlockRepository 내가 팔로우/차단했거나, 나를 팔로우/차단한 양방향 매칭 제거 관계 리스트 조회 시 Null Pointer 방지
[2] 활동 클리닝 RecordLikeRepository, RecordScrapRepository, RecordCommentRepository 다른 사람 게시글에 남겨둔 나의 흔적(좋아요, 댓글 등) 제거 다른 사용자의 원본 데이터 무결성 보장
[3] 북마크 제거 BookmarkRepository, UserBookmarkRepository 사용자가 큐레이션해 둔 개인 북마크 컬렉션 삭제 인덱싱 메모리 고갈 최소화 및 정합성 보장
[4] 로그/개인정보 SearchRepository, UserVisitRepository, ExhibitionClickLogRepository 검색 기록, 방문 통계, 전시 클릭 데이터 파쇄 개인정보 보호법(최소 보존의 법칙) 완벽 준수
[5] 외부 운영계 PushTokenRepository, NotificationRepository 디바이스 푸시 키 소거 및 알림 수신함 일괄 파괴 탈퇴 회원에게 유령 알림 푸시 전송 사태 원천 차단
[6] 독립 콘텐츠 RecordRepository (자식 선삭제 필수) 탈퇴자가 쓴 글의 자식들(댓글, 좋아요 등) 선제거 후 본문 제거 외래 키(FK) 참조 제약 위반 오류 차단
[7] 최종 종결 UserRepository 유저 데이터베이스 엔티티 물리 제거 모든 수명주기 프로토콜의 완전 종료

 

 

3. 상세 가이드 및 심층 분석 (Detailed Guide)

A) 사용자 탈퇴 수명주기 종료 전용 비동기/원자적 서비스 구현

사용자 탈퇴 요청 시, 의존 관계의 종착지인 User를 무너뜨리기 전에 역순으로 의존 노드들을 물리 수거하는 핵심 트랜잭션 서비스다.

package com.example.platform.user.service;

import com.example.platform.user.domain.User;
import com.example.platform.user.domain.ExhibitionRecord;
import com.example.platform.user.repository.*;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.List;
import java.util.NoSuchElementException;

@Slf4j
@Service
@RequiredArgsConstructor
public class UserWithdrawService {

    private final UserRepository userRepository;
    private final FollowRepository followRepository;
    private final UserBlockRepository userBlockRepository;
    private final RecordLikeRepository recordLikeRepository;
    private final RecordScrapRepository recordScrapRepository;
    private final RecordCommentRepository recordCommentRepository;
    private final BookmarkRepository bookmarkRepository;
    private final UserBookmarkRepository userBookmarkRepository;
    private final SearchRepository searchRepository;
    private final UserVisitRepository userVisitRepository;
    private final ExhibitionClickLogRepository exhibitionClickLogRepository;
    private final ReportRepository reportRepository;
    private final UserGenreRepository userGenreRepository;
    private final PushTokenRepository pushTokenRepository;
    private final NotificationRepository notificationRepository;
    private final RecordRepository recordRepository;

    /**
     * [탈퇴 핵심 프로토콜] 정해진 역순을 완벽히 지켜 데이터를 정리하는 수명주기 마감 메인 메서드다.
     * 무조건 전체 과정이 하나의 트랜잭션 블록 내에서 완전히 동기화되어 원자적(All-or-Nothing)으로 처리되어야 한다.
     * @param userId 탈퇴하려는 사용자의 PK
     */
    @Transactional
    public void withdraw(Long userId) {
        log.info("사용자 탈퇴 종료 프로토콜 개시. 대상 유저 ID: {}", userId);
        
        // 1. 탈퇴 주체 객체 검증 로드
        User user = userRepository.findById(userId)
                .orElseThrow(() -> new NoSuchElementException("탈퇴 대상 사용자가 식별되지 않습니다."));

        // 2. 소셜 관계 데이터 정리 (양방향 다중 관계 일괄 컷)
        // 사용자가 팔로워/팔로잉 중 어디에 속해있든, 혹은 차단 주체/객체 중 어디에 속해있든 모든 관계 로우 소거
        followRepository.deleteAllByFollowerOrFollowing(user, user);
        userBlockRepository.deleteAllByBlockerOrBlocked(user, user);

        // 3. 타인의 글에 남긴 주체적 활동 내역 제거
        recordLikeRepository.deleteAllByUser(user);
        recordScrapRepository.deleteAllByUser(user);
        recordCommentRepository.deleteAllByUser(user);

        // 4. 수집된 북마크 컬렉션 삭제
        bookmarkRepository.deleteAllByUser(user);
        userBookmarkRepository.deleteAllByUser(user);

        // 5. 개인정보 리스크 성격의 검색어 및 로그 데이터 원천 삭제
        searchRepository.deleteAllByUser(user);
        userVisitRepository.deleteAllByUser(user);
        exhibitionClickLogRepository.deleteAllByUser(user);
        
        // 6. 신고 내역 제거 (신고자 혹은 피신고자 관계 모두 정렬)
        reportRepository.deleteAllByReporterOrReportedUser(user, user);
        
        // 7. 기타 부속 속성 및 외부 푸시/알림 데이터 초기화
        userGenreRepository.deleteByUserId(user.getId());
        pushTokenRepository.deleteByUserId(user.getId());
        notificationRepository.deleteAllByReceiverUserIdOrActorUserId(user.getId(), user.getId());

        // 8. 본인이 작성한 독립 콘텐츠(ExhibitionRecord) 자식 선제거 및 본문 파괴
        // ExhibitionRecord(부모)를 참조하는 좋아요/스크랩/댓글(자식)을 먼저 제거해야 
        // 하위 Cascade 및 FK 무결성 예외가 터지는 현상을 완벽하게 피할 수 있다.
        List<ExhibitionRecord> records = recordRepository.findAllByUserOrderByCreatedAtDesc(user);
        if (!records.isEmpty()) {
            log.info("탈퇴 유저 작성 원본 글 수거 진행. 대상 개수: {}", records.size());
            
            // 유저가 쓴 글들에 붙은 타인들의 댓글, 좋아요, 스크랩 선삭제
            recordCommentRepository.deleteAllByRecordIn(records);
            recordLikeRepository.deleteAllByRecordIn(records);
            recordScrapRepository.deleteAllByRecordIn(records);
            
            // 자식 데이터가 전멸한 안전지대 시점에서 비로소 기록 원본들을 일괄 삭제
            recordRepository.deleteAll(records);
        }

        // 9. 최종 종결: 모든 잔여 FK 레퍼런스가 완벽히 무력화되었으므로 유저 엔티티를 마지막에 안심하고 제거
        userRepository.delete(user);
        
        log.info("사용자 탈퇴 수명주기 마감 완료. 유저 고유 ID {} 영구 보류 해제.", userId);
    }
}

 

B) 안전한 API 게이트 통신 컨트롤러 구현

탈퇴 비즈니스의 시작점을 엄격하게 본인 인증 경계로 제한하고, 정책 파괴 위험이 있는 오용 진입을 제어한다.

package com.example.platform.user.controller;

import com.example.platform.user.service.UserWithdrawService;
import com.example.platform.member.domain.Member; // 현재 로그인 멤버
import com.example.platform.common.api.ApiResponse; // 공통 통신 Wrapper 규격
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*;

@RestController
@RequestMapping("/api/users")
@RequiredArgsConstructor
public class UserController {

    private final UserWithdrawService userWithdrawService;

    /**
     * 회원 탈퇴 요청 수용 엔드포인트
     * 인증 필터를 거쳐 들어온 본인의 고유 정보만을 활용해 2차 훼손 요청 유입을 가로막는다.
     */
    @DeleteMapping("/withdraw")
    public ApiResponse<Void> withdrawUser(@CurrentUser Member currentMember) {
        // 모든 복잡다단한 DB 삭제 순서는 외부에서 조작 불가능하도록 완전 캡슐화한 서비스로 단건 위임한다.
        userWithdrawService.withdraw(currentMember.getId());
        return ApiResponse.ok(null);
    }
}

 

 

4. 실무 팁 및 실패 시나리오 분석 (Tips & Debugging)

Deletion Cascade 순서 오류의 실무 비정상 현상

만약 개발자가 단순히 코드를 작성하면서 아래와 같이 삭제 연산 순서를 한 줄이라도 잘못 뒤틀면 어떻게 될까?

// [심각한 예외 유발 코드 예시]
recordRepository.deleteAll(records); // ❌ 부모를 먼저 지우려 함!
recordCommentRepository.deleteAllByRecordIn(records); // 자식이 나중에 지워짐

이 쿼리가 가동되는 순간 데이터베이스 콘솔에는 아래와 같은 무시무시한 외래 키 참조 오류 메시지가 배출되며, 트랜잭션 격리 규칙에 의해 사용자는 "탈퇴가 불가능한 에러"를 겪게 된다. ERROR: update or delete on table "exhibition_record" violates foreign key constraint "fk_record_comment_on_record" on table "record_comment"

 

이와 같은 실패 시나리오별 디버깅 추적 포인트를 명료하게 분류한다.

  • 회원이 탈퇴 처리를 마쳤는데 다른 회원들에게 계속 스마트폰 알림/푸시가 발송되는 장애: pushTokenRepository.deleteByUserId 나 notificationRepository 정리 구문이 withdraw() 메서드 내부에서 정상 실행되지 않고 주석 처리되었거나 누락되었는지 확인해야 한다. 탈퇴 회원이 타 회원에게 보냈던 활동 이벤트가 운영 캐시 큐에 남아 있는 경우, 좀비 디바이스 푸시가 지속되는 보안 리스크를 유발한다.
  • 탈퇴 처리는 성공했는데, DB 관리 도구로 뒤져보니 소수의 댓글(Comment)들이 여전히 null 작성자로 남아 있는 오염 현상: JPA 엔티티 연관관계 매핑(@ManyToOne) 설정 시 On Delete Set Null 혹은 엉뚱한 Cascade 옵션이 끼어들어, 영속성 삭제 파괴 쿼리가 흘러가는 대신 대상 유저와의 매핑을 null로 끊어두기만 한 소프트 우회가 발생했는지 자바 필드 어노테이션 상태를 추적해야 한다.

 

 

5. 하드 삭제 vs 소프트 삭제 전격 비교 (Hard vs Soft Delete)

실무 설계 시 가장 고민하는 아키텍처적 양대 산맥인 두 삭제 패러다임의 명암을 티스토리 맞춤형으로 명료하게 해부했다.

설계 속성 비교 물리 하드 삭제 (Hard Deletion) 논리 소프트 삭제(Soft Deletion + Anonymous)
물리적 상태 DELETE 쿼리로 데이터 영구 파쇄 is_deleted = true 혹은 deleted_at 필드 변경 후 보관
개인정보법 이점 개인정보가 디스크에서 완전히 말소되므로 법률 리스크 제로 개인정보 컬럼(nickname, email 등)을 수동 마스킹(*) 처리해야 해 복잡함
운영 분석/복구 유저의 탈주 이력이나 통계 추적이 불가능하며, 수동 복구 불가 탈퇴한 회원의 구매 통계, 서비스 체류 기간 등의 누적 데이터 활용 가용
JPA 조회 오버헤드 테이블 데이터 크기 자체가 가벼워지므로 고성능 유지 모든 엔티티 조회에 @Where(clause = "is_deleted = false") 추가 오버헤드 발생
수립 난이도 FK 자식-부모 역순 정렬을 완벽히 지키기 위한 설계 수립 피로도 존재 관계를 파괴하지 않고 상태값만 바꾸므로 외래 키 충돌이 전혀 없음

 

 

6. 마무리 (Conclusion)

사용자 탈퇴라는 프로세스는 백엔드가 수행할 수 있는 가장 고난도의 트랜잭션 설계 영역 중 하나다. 정밀하게 조율된 자식-부모 선삭제 파이프라인과 트랜잭션 원자적 수립, 그리고 외부 채널로의 전파 전이를 철저히 분리할 때 비로소 어떤 장애 속에서도 데이터를 안전하게 격리하고 데이터베이스의 평화를 수호할 수 있게 된다.

 

 

7. 복습 질문과 명쾌한 해답 (Review Q&A)

Q1. 왜 탈퇴 프로토콜에서 최상위 유저 엔티티(User)를 반드시 가장 마지막 시점에 지워야 하는가?

A1. 탈퇴 서비스 전반에 걸친 데이터 정리 로직이 유저 객체(User user)를 식별 기준점으로 삼기 때문이다. 만약 가장 위인 userRepository.delete(user)를 먼저 실행하면 영속성 컨텍스트에서 유저의 고유 식별 정보가 유실되어, 그 하위에 설계된 followRepository나 recordRepository를 지울 때 타겟 대조군 데이터가 무엇이었는지 DB 세션이 유효하게 매핑을 완료하지 못한다. 따라서 모든 자식 정보와 개인 이력을 완벽하게 초기화한 후, 정합성 검증의 뿌리가 되는 부모 객체를 최종 종결하는 것이 유일무이한 인과관계다.

Q2. 작성한 전시 기록(ExhibitionRecord) 본문을 지우기 전, 기록의 하위 자식 데이터인 댓글, 좋아요, 스크랩을 먼저 선삭제해야 하는 데이터 무결성 상의 당위성은 무엇인가?

A2. 외래 키 제약 조건(Foreign Key Constraint)에 의한 트랜잭션 중단을 예방하는 안전 방벽이다. 자식 관계인 RecordComment나 RecordLike 테이블이 부모인 ExhibitionRecord의 고유 PK 번호를 바라보는 형태로 데이터가 적재되어 있는데, 이 상황에서 무작위로 부모를 먼저 날려버리면 DB 무결성 엔진은 정합성 수호를 위해 예외를 뱉어 로직을 셧다운시킨다. 따라서 외래 키 참조 관계의 끝단인 자식들부터 순서대로 안전하게 증발시킨 뒤, 아무도 바라보는 자가 없는 무참조 상태의 부모 객체를 마침내 소거해야 온전한 커밋을 달성할 수 있다.

Q3. 개인정보 최소화 관점에서, 회원 탈퇴 시 서비스 기능 데이터 외에 로그성 정보나 푸시 토큰 정보를 반드시 함께 파쇄해야 하는 실무적 이유는 무엇인가?

A3. 법적 컴플라이언스 이행과 운영 사고 차단이라는 두 가지 절대 목적을 위함이다. 회원 탈퇴가 일어난 뒤에도 회원의 푸시 토큰(PushToken) 정보를 DB 구석에 남겨둘 경우, 운영 어드민이 마케팅 전역 푸시 알림을 발송했을 때 이미 탈퇴한 사람의 스마트폰으로 자꾸 광고 메시지가 도달하는 연쇄 오작동을 유발한다. 또한, 대한민국 개인정보보호법에 의거하면 동의 목적을 달성하거나 탈퇴를 마친 정보주체의 신원 로그 정보(검색 흔적 등)는 특별법에 의한 유예 보존 의무가 적용되지 않는 한 파기하는 것이 절대 의무다. 이를 묵인하면 대규모 벌금 형사고발 조치와 대외적 브랜드 신뢰 하락으로 이어진다.

반응형