성능과 안정성 모두 고려한 회원 탈퇴 구현하기

o_z·2025년 11월 22일

회원 탈퇴는 개인정보를 다뤄야 하기에 삭제 시점과 보관할 데이터를 엄격하게 처리해야 한다.

원래 기업에서는 다른 DBMS를 두어, 회원이 탈퇴했을 경우 운영 서비스에서 사용하지 않는 다른 DBMS에 개인정보를 이전하고 보관하는 것이 일반적이라고 한다. 하지만 우리는 현재 그럴 수 있는 규모가 아니므로, 테이블을 분리하는 방법으로 선택했다.

민감한 개인정보(이메일, 이름)는 즉시 삭제하고, 탈퇴 회원의 행동 로그나 성별, 나이대 등은 수집해서 테이블을 분리해 저장하고자 한다.


1. Soft delete vs Hard delete

개인정보보호 관련 법령에 의하면 탈퇴한 회원에 대해 법적 효력을 가질 수 있는 데이터(ex: 결제/거래 내역)는 일정기간에 대해 분리 보관의 의무를 지닌다.
‘분리 보관’은 별도 테이블이나 DB 등으로 논리·물리적으로 분리하여 저장하는 것을 의미한다.
단순히 member 테이블에 deleted = true 플래그만 두는 soft delete만으로는 이 요건을 충족했다고 보긴 어렵다.

만약 법적·정책적으로 탈퇴 후 일정 기간 개인정보를 보관해야 하는 상황이었다면,
soft delete를 적용하더라도 개인정보를 별도 테이블로 분리하거나, 암호화한 뒤 분리 저장하는 설계가 필요했을 것이다.

하지만 봄봄은 결제/거래 기능이 없어, 법적으로 탈퇴 회원의 개인정보를 일정 기간 의무적으로 보관해야 하는 대상은 아니다.
그럼에도 운영 편의성과 향후 분석을 고려해 탈퇴 시 회원 정보를 어떻게 남겨둘지 soft delete vs hard delete 를 고민하게 되었다.

soft delete가 필요한 경우는 언제일까?

1️⃣ 서비스 특성상 탈퇴한 회원의 활동 로그 노출을 위해 회원 정보를 남겨야 하는 경우

다른 프로젝트에서는 탈퇴한 회원의 게시글과 댓글을 다른 회원이 계속 조회할 수 있어야 했고,
여러 테이블에서 회원을 참조하는 FK 관계가 존재했다.
이 경우 게시글·댓글의 작성자를 잃지 않기 위해, 회원 엔티티를 hard delete 하는 대신 soft delete로 상태만 변경해 두는 전략을 사용했었다.

반면 봄봄은 온전히 개인화된 뉴스레터 종합 관리 서비스로,
탈퇴한 회원의 정보나 활동 로그가 다른 회원에게 노출될 필요가 없다.
또한 현재 DB 레벨의 FK 제약도 사용하지 않으므로, 참조 무결성을 위해 회원 row를 남겨둘 필요도 없다.

따라서 활동 로그 노출과 참조 무결성 유지 관점에서 봄봄 탈퇴 회원 정보를 soft delete로 보관해야 할 근거는 없다.

2️⃣ 재가입 제한을 위해 회원 정보를 남겨두어야 하는 경우

일부 서비스는 일정 기간 재가입 제한을 위해 탈퇴한 회원의 이메일이나 식별자를 별도로 보관한다.
이 경우 탈퇴 후에도 동일 이메일로 재가입을 막아야 하므로, soft delete 또는 분리 보관이 필요할 수 있다.

하지만 봄봄은 탈퇴한 회원도 재가입할 수 있는 정책을 채택했다.
따라서 일반 탈퇴 회원의 이메일을 재가입을 제한하기 위한 목적으로는 보관하지 않기로 했다.

이에 따라 재가입 제한 정책 관점에서도, 봄봄의 회원 정보를 soft delete로 장기간 보관해야 할 필요성은 낮다고 판단했다.

종합적으로, 봄봄의 회원 탈퇴 처리에는 soft delete를 적용하지 않고 hard delete를 채택했다.


2. 남겨야 할 데이터

탈퇴하는 회원의 활동 지표를 남겨두고, 이를 토대로 서비스 타겟층과의 핏을 지속적으로 비교하며 방향성을 찾아갈 수 있을 것이라고 생각했다.
그래서 아래와 같은 데이터들을 탈퇴한 회원 테이블에 따로 저장하도록 구현했다.

1️⃣ 회원의 기본 정보

  • birth_date : 회원 나이 분석
  • gender : 회원 성별 분석

⇒ 어느 연령대와 성별에 서비스 핏이 덜 맞는지 파악할 수 있다.

2️⃣ 가입 & 활동 기간

  • joined_date : 가입한 날짜
  • deleted_date : 탈퇴 날짜

⇒ 온보딩 실패 vs 장기 사용 후 이탈 경우에 대해 구분 가능한 정보이다.

3️⃣ 아티클 소비 데이터

  • continue_reading : 연속 읽기 날수
  • bookmarked_count: 북마크한 횟수
  • highlight_count : 하이라이트 횟수

⇒ 가입 후 아티클 소비 지표가 없는 참여자일 경우, 온보딩/초기 문제로 유추할 수 있다.
⇒ 단순 독자가 아니라 적극적 참여자의 이탈일 경우, 경쟁 서비스 이동/UX 문제를 유추할 수 있다.

4️⃣ 데이터 만료 기간

  • expired_date : 데이터 만료 기간

⇒ 최대 데이터 보관 기간으로 90일이 지나면 삭제 스케줄러로 정보가 삭제된다.


3. 회원 탈퇴 및 데이터 삭제 구현하기

기본적으로 회원 탈퇴에서 구현해야 할 과정은 아래와 같았다.

1. 탈퇴 회원 분석에 필요한 지표 저장하기
2. 회원 데이터 제거하기
3. 해당 회원과 관련된 모든 데이터 제거하기

Spring 이벤트 발행으로 의존성 줄이기

회원 한 명에 대해 연관관계를 갖고 있는 데이터들은 아래와 같다.

  • article
  • bookmark
  • highlight
  • pet
  • subscribe
  • today_reading, monthly_reading, yearly_reading...

회원이 탈퇴했을 때 회원과 관련된 모든 데이터를 제거해야한다.
이 때 이 도메인들에 대한 모든 service 계층을 회원 탈퇴를 위해 의존해야할까?
만약 모든 service를 의존해 데이터를 삭제하게 되면 아래처럼 된다.

@Transactional
public void withdraw(Long memberId) {
    Member member = findMemberById(memberId);
    
    /*
    회원 탈퇴 신청 시 즉시 withdrawnMember로 정보 이전
    이벤트에서 회원 관련된 모든 정보 제거: articles, pet, highlight, bookmark, reading, subscribe
     */
    withdrawService.migrateDeletedMember(member);
    articleService.deleteByMember(member);
    petService.deleteByMember(member);
    highlightService.deleteByMember(member);
    bookmarkService.deleteByMember(member);
    subscribeService.deleteByMember(member);
    readingService.deleteByMember(member);

    memberRepository.delete(member);
    log.info("회원 탈퇴 처리 완료. MemberId: {}", memberId);
}

위와 같이 구현하면 회원 탈퇴를 처리하는 withdraw() 메서드는 사실상 모든 도메인의 정리 책임을 갖게 되는 거대한 서비스가 된다.
이렇게 무분별한 서비스 계층간의 의존성은 순환 의존 위험성을 초래하기 쉽다.

'회원 탈퇴'는 이제 서비스에 해당 회원이 존재하지 않는다는 동작만을 의미하는 것이 바람직하다.
회원 탈퇴 시 어떤 정보들이 여기에 연동되어있고, 삭제되어야하는지 알 책임은 없는 것이다.

회원 탈퇴 하나에 대해 거의 모든 도메인과의 결합도가 높아져,
이를 낮출 전략을 고민하다가 Spring의 이벤트 발행을 도입하게 되었다.

'회원 탈퇴'는 일종의 도메인 이벤트로 존재할 수 있다.
또한 회원 탈퇴는 어떤 도메인 데이터들을 어떻게 지워야하는지는 각 도메인 스스로가 알고있어야 하는 책임이라고 생각했다.

그래서 회원 탈퇴 시에는 각 도메인 별 서비스 메서드를 직접 호출하는 것이 아니라,
WithdrawnEvent 하나만을 발행하고 다른 도메인들의 삭제 EventListener들이 이를 구독하는 방식으로 변경했다.

@Transactional
public void withdraw(Long memberId) {
    Member member = findMemberById(memberId);
    
    /*
    회원 탈퇴 신청 시 즉시 withdrawnMember로 정보 이전
    이벤트에서 회원 관련된 모든 정보 제거: articles, pet, highlight, bookmark, reading, subscribe
     */
    withdrawService.migrateDeletedMember(member);
    applicationEventPublisher.publishEvent(new WithdrawEvent(memberId));

    memberRepository.delete(member);
    log.info("회원 탈퇴 처리 완료. MemberId: {}", memberId);
}

이벤트 기반 방식으로 변경하게 되면서 각 도메인 별 구독한 리스너 클래스는 아래와 같이 생성됐다.

@Slf4j
@Component
@RequiredArgsConstructor
public class DeleteArticlesByWithdrawListener {

    private final ArticleService articleService;
	
    // default 옵션은 AFETER_COMMIT: 이벤트 발행처의 트랜잭션이 커밋된 후에 실행된다.
    @TransactionalEventListener
    public void on(WithdrawEvent event) {
        log.info("회원 탈퇴 따른 아티클 삭제 시작 - memberId={}", event.memberId());
        try {
            articleService.deleteAllByMemberId(event.memberId());
        } catch (Exception e) {
            log.error("회원 탈퇴 따른 아티클 삭제 처리 실패 - memberId={}", event.memberId(), e);
        }
    }
}

이제 회원 탈퇴 시 MemberService는 더이상 모든 도메인의 service를 참조하지 않아도 되며,
오로지 '회원 탈퇴'라는 이벤트 하나만 발행해주면 다른 도메인들의 삭제 책임은 분산된다.

비동기 처리로 응답 속도 개선하기

이벤트 기반으로 처리하도록 구현한 후 개발 서버에 있는 내 계정을 직접 탈퇴해 테스트했다. 회원 탈퇴 시 대략 2.4s가 소요되었다.

처음에는 "2초대면 그대로 가도 되지 않을까?" 싶었지만, 몇 가지 생각이 들면서 응답 속도 개선을 시도하게 됐다.

1. '개발 환경'임에도 2.4초가 소요된다.

개발 서버는 트래픽도 없을 뿐더러, 데이터 수도 현재 우리 서비스 운영 환경에 비해 매우 적다.
이런 개발 환경에서도 2.4초가 걸린다면, 실제 데이터가 많이 쌓여있는 운영 환경에서는 이보다 더 느려질 가능성이 있을 것이라고 판단했다.

2. 회원은 '빠른 피드백'이 중요하다.

회원은 '지금 즉시 모든 데이터가 제거'되는 것을 기대하는 것보다,
'탈퇴가 정상적으로 접수 되었다는 빠른 피드백'을 더 기대하는 것에 가깝다.
서비스 내부에서 사용하는 지표 데이터들을 지우느라 사용자가 수 초를 기다려야 하는 것은 UX상으로 불필요한 지연이라고 생각했다.

3. 응답 시간의 대부분이 '핵심 개인정보'가 아닌 '부가 서비스 데이터' 삭제에 쓰인다.

요청 trace를 분석했을 때, 실제로 회원의 '개인정보'가 삭제되는 시간 대비 아티클, 읽기 지표 등 회원의 '서비스 데이터'가 삭제되는 시간의 비중이 약 50%는 더 차지하고 있었다. 다수 연관된 이 데이터들은 사용자가 탈퇴 완료 화면을 보는 시점과 완전히 동일한 트랜잭션 내에서 처리될 필요는 없다고 판단했다.

위와 같은 이유들로 탈퇴 API의 응답 성능 개선을 시도했다.

사용자에게 '정상적으로 탈퇴 처리가 됨'이라는 응답을 빠르게 보내기 위해, 일단 하나의 트랜잭션 내에서 실행되어야 할 작업으로 동기적 삭제를 해야하는 데이터와 비동기로 삭제할 데이터를 분리해보았다.

  • 동기적으로 삭제할 데이터 : 회원 개인정보
    민감한 개인정보는 사용자의 탈퇴 요청에 즉시 삭제되어야 하기 때문이다.
    사용자에게 즉시 반영됨을 보장해야하는 최소한의 작업 단위가 된다.

  • 비동기적으로 삭제할 데이터 : 회원의 연관 활동 로그, 아티클, 지표 등
    사용자가 즉시 삭제됨을 기대하는 데이터들이 아닌 것들이다.
    회원 탈퇴 이벤트 발행을 통해 리스너에서 자체적으로 정리하도록 위임한 작업들의 데이터들이다.

위 내용을 고려하면 이벤트리스너에서 실행되는 데이터 정리 작업은 백그라운드에서 안전하게 비동기로 수행하는 구조로 개선할 수 있어보였다.

먼저 동기로 삭제할 데이터는 member 테이블로, 이미 위에서 withdraw()의 단일 트랜잭션 내에서 처리하고 있었다.

이제 비동기적으로 회원 연관 데이터들을 삭제하도록 이벤트리스너 메서드를 수정해보자.

@Slf4j
@Component
@RequiredArgsConstructor
public class DeleteArticlesByWithdrawListener {

    private final ArticleService articleService;

	@Async // 비동기처리를 위한 애노테이션 추가
    @TransactionalEventListener
    public void on(WithdrawEvent event) {
        log.info("회원 탈퇴 따른 아티클 삭제 시작 - memberId={}", event.memberId());
        try {
            articleService.deleteAllByMemberId(event.memberId());
        } catch (Exception e) {
            log.error("회원 탈퇴 따른 아티클 삭제 처리 실패 - memberId={}", event.memberId(), e);
        }
    }
}

이 외에도 WithdrawEvent를 구독하고 있는 모든 이벤트 리스너들의 함수에 @Async로 비동기처리를 적용했다.
이로써 회원은 탈퇴했어도 개인정보만 확실히 제거되어 commit 된 후에 응답을 받을 수 있고, 백그라운드로는 회원의 읽기 지표, 아티클 등 관련 데이터를 동시다발적으로 제거할 수 있게 됐다.

비동기 삭제를 추가한 후 다시 개발 서버에서 탈퇴를 테스트했다.
부가 데이터들의 비동기 삭제를 적용하니 2.4s에서 약 0.5s까지 개선되었다. 이정도면 꽤 만족스러운 개선 결과다!


참고

profile
트러블슈팅과 구현기를 위주로 기록합니다-

0개의 댓글