

회원 탈퇴는 개인정보를 다뤄야 하기에 삭제 시점과 보관할 데이터를 엄격하게 처리해야 한다.
민감한 개인정보(이메일, 이름)는 즉시 삭제하고, 탈퇴 회원의 행동 로그나 성별, 나이대 등은 수집해서 테이블을 분리해 저장하기로 했다.
회원 탈퇴를 구현하는 중 마주한 2가지 고민에 대해 정리했다.
기본적으로 회원 탈퇴에서 구현해야 할 과정은 아래와 같았다.
1. 탈퇴 회원 분석에 필요한 지표 저장하기
2. 회원 데이터 제거하기
3. 해당 회원과 관련된 모든 데이터 제거하기
회원 한 명에 대해 연관관계를 갖고 있는 데이터들은 아래와 같다.
articlebookmarkhighlightpetsubscribetoday_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);
}
위 구현 방식의 문제점은 두 가지이다.
1. 회원 탈퇴 메서드는 "탈퇴 확정"이 핵심 책임이다.
현재 withdraw() 메서드에서는 회원 탈퇴 뿐 만 아니라 데이터 정리까지의 책임을 갖는다.
'회원 탈퇴'는 서비스에 해당 회원이 존재하지 않는다는 동작만을 확정하는 것이 바람직하다.
또한 이렇게 무분별한 서비스 계층간의 의존성은 순환 의존 위험성을 초래하기 쉽다.
회원 탈퇴 시 어떤 정보들이 여기에 연동되어있고, 삭제되어야하는지 알 책임은 없는 것이다.
2. 한 번의 데이터 삭제 실패가 회원 탈퇴 전체의 실패로 이어진다.
이 구조의 더 큰 문제는 중요도가 다른 삭제 작업이 하나로 묶여서 실패한다는 것이다.
회원 탈퇴의 본질은 “탈퇴 의사를 반영하고 회원을 탈퇴 상태로 전이하는 것”인데, 예를 들어 reading 정리 중 일시적 오류가 났다고 해서 회원 탈퇴 자체가 실패하는 것은 과도한 결합이다. 즉, 핵심 작업과 후처리 작업이 분리되지 않았다는 문제가 있다.
문제점들을 해소할 방법으로 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를 참조하지 않아도 되며,
오로지 '회원 탈퇴'라는 이벤트 하나만 발행해주면 다른 도메인들의 삭제 책임은 분산된다.
또한 트랜잭션 전파수준을 조정해, 각 도메인 데이터 별 삭제는 자체 트랜잭션 경계를 갖도록 전파 수준을 조정했다.
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void deleteAllByMemberId(Long memberId) {
articleRepository.deleteAllByMemberId(memberId);
}
propagation으로 REQUIRES_NEW를 사용하면 부모 트랜잭션이 존재해도 새로운 자식 트랜잭션을 만들어 로직을 처리한다. 논리 트랜잭션이 새로 생기는 것이므로 이 트랜잭션의 성공/실패 여부는 부모 트랜잭션에게 영향이 가지 않는다.
이벤트 기반으로 처리하도록 구현한 후 개발 서버에 있는 내 계정을 직접 탈퇴해 테스트했다.
회원탈퇴의 Trace span을 보며 의문이 들었던 점이 있다.
회원탈퇴를 확정하기까지, 즉 MemberRepository.delete()가 커밋되기까지는 약 270ms 걸렸다.
그 이후의 span을 보면 서비스의 도메인 데이터 삭제를 처리하는데 585ms가 걸렸다.
회원탈퇴 응답 시간의 절반 이상이 도메인 데이터 삭제로 인한 것이다.
그래서 아래와 같은 의문이 들었다.
회원에게는 '빠른 피드백'이 중요하다.
회원은 '지금 즉시 모든 데이터가 제거'되는 것을 기대하는 것보다,
'탈퇴가 정상적으로 접수 되었다는 빠른 피드백'을 더 기대하는 것에 가깝다.
서비스 내부에서 사용하는 지표 데이터들을 지우느라 사용자가 기다리는 것은 불필요한 지연이라고 생각했다.
지금이야 개발서버라서 데이터의 절대적인 수 자체도 적기에 처리 시간이 그리 오래걸리지 않는다. 하지만 운영서버는 개발서버보다 사용자 수와 데이터가 훨씬 더 많고, 사용자 데이터가 누적될수록 삭제처리는 더욱 시간이 오래 걸릴 것이다.
지금 구조에서 가장 적절한 해결책은 데이터 삭제 작업들을 API 처리에서 분리하는 것이라고 생각했다.
그리고 그 방법으로는 비동기 전환이 있다.
개인적으로는 Spring의 이벤트 기반 동작의 큰 장점은 두 가지라고 생각한다.
- layer간의 결합도 감소
- 손쉽고 명확한 비동기 처리
이벤트를 도입한 이상 사실 2번 장점을 보고 '그럼 모든 정보를 다 비동기로 제거하면 엄청 빠르겠네!' 라고 할 수 있다.
하지만 비동기를 사용하는 것도 상황에 따라 다르게 적용해야한다.
지금같은 회원 탈퇴 로직에서는 회원 개인정보삭제를 비동기로 처리해버리면, 삭제를 실패했을 때 큰일 날 수 있다..
'삭제 성공'이라는 응답으로 오해를 불러일으키고 개인정보는 서비스에서 갖고있게 된다면 이는 나중에 정말 큰 법적 문제로 이어질 수 있다.
사용자에게 '정상적으로 탈퇴 처리가 됨'이라는 응답을 빠르게 보내기 위해, 일단 하나의 트랜잭션 내에서 실행되어야 할 작업으로 동기적 삭제를 해야하는 데이터와 비동기로 삭제할 데이터를 분리해보았다.
동기적으로 삭제할 데이터 : 회원 개인정보
민감한 개인정보는 사용자의 탈퇴 요청에 즉시 삭제되어야 하기 때문이다.
사용자에게 즉시 반영됨을 보장해야하는 최소한의 작업 단위가 된다.
비동기적으로 삭제할 데이터 : 회원의 연관 활동 로그, 아티클, 지표 등
사용자가 즉시 삭제됨을 기대하는 데이터들이 아닌 것들이다.
회원 탈퇴 이벤트 발행을 통해 리스너에서 자체적으로 정리하도록 위임한 작업들의 데이터들이다.
위 내용을 고려하면 이벤트리스너에서 실행되는 데이터 정리 작업은 백그라운드에서 안전하게 비동기로 수행하는 구조로 개선할 수 있어보였다.
먼저 동기로 삭제할 데이터는 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 된 후에 응답을 받을 수 있고, 백그라운드로는 회원의 읽기 지표, 아티클 등 관련 데이터를 동시다발적으로 제거할 수 있게 됐다.
Spring의 @Async를 사용해서 비동기 처리할 경우 고려해야 하는 사항이 있는데, 바로 스레드 풀이다.
비동기 작업은 TaskExecutor 인터페이스를 통해 실행되는데,
이 구현체로 SimpleAsyncTaskExecutor, ThreadPoolTaskExecutor 가 있다.
Spring은 기본적으로 IoC 컨테이너에 TaskExecutor 빈이 1개만 존재할 경우 그걸 쓰고, 아니면 taskExecutor라는 이름의 Executor 빈을 찾는다. 만약 둘 다 없으면 SimpleAsyncTaskExecutor를 사용하게 된다.
이 SimpleAsyncTaskExecutor의 내부에서는 작업마다 새 스레드를 생성해, 성능 이슈가 발생하기 쉬워 운영에서 사용하는건 좋지 못하다.
아래는 SimpleAsyncTaskExecutor의 주석 내용이다.
NOTE: This implementation does not reuse threads! Consider a thread-pooling
TaskExecutorimplementation instead, in particular for executing a large number of short-lived tasks. Alternatively, on JDK 21, consider setting setVirtualThreads totrue.
실제 SimpleAsyncTaskExecutor의 execute() 메서드를 따라가보면 doExecute()가 호출되는걸 볼 수 있는데, 최종적으로는 아래처럼 스레드 풀 관리 없이 계속해서 스레드를 생성하고 있다.

SimpleAsyncTaskExecutor가 스레드 재사용을 하지 않는 문제를 해결하기 위해, 스레드 풀을 사용하도록 설정하는 구현체이다.
스레드 풀 크기, 큐, 종료 등을 빈 생성할 때 config로 설정할 수 있다.
@EnableAsync
@Configuration
public class AsyncConfig {
@Bean(name = "taskExecutor") // name은 메서드명이 default라 안써도 된다.
public Executor taskExecutor() {
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
executor.setCorePoolSize(1); // 유지되는 스레드 개수
executor.setMaxPoolSize(3); // 최대 사용할 수 있는 스레드 개수
executor.setQueueCapacity(50); // 큐에 50개 대기 가능
executor.setWaitForTasksToCompleteOnShutdown(true); // graceful shutdown 처리 설정
executor.setAwaitTerminationSeconds(60); // 60초까지만 대기 후 강제 종료
executor.initialize();
return executor;
}
}
위 설정 값들은 절대적인 정답이 있진 않다.
얼마나 비동기 처리가 자주 발생하는지, 비동기 작업들이 어떤 속도로 수행되어야 하는지 등 기준에 따라 설정 값은 다양해질 수 있다.
corePoolSize = 1 : 현재 비동기 작업들은 자주 발생하는 핵심 트래픽 작업들이 아니다. 항상 높은 처리량을 요구하는 작업도 아니므로 평소는 순차 처리로 진행해 리소스 점유를 최소화 하고자 했다.maxPoolSize = 3 : 완전한 직렬 처리로 두기엔 여러 건의 작업이 한 번에 발생했을 때 대기열이 많아질 수 있다. 이 때는 잠깐 처리량을 높이기 위해 최대 풀 크기를 3으로 지정했다.queueCapacity = 50 : 비동기 처리가 실시간으로 완료되어야 하는 작업은 아니므로 여러 개의 작업이 대기해도 괜찮다. 그래서 즉시 실패시키기보다 큐의 크기를 늘려 어느정도 대기할 수 있도록 50개까지 늘려두었다.지금은 회원가입 디스코드 알림도 비동기로 처리되고 있어서 둘의 비동기 스레드 풀을 분리해야할까 고민했다.
하지만 현재 서비스 규모 상 MDC와 회원 탈퇴 모두 한 번에 빈번하게 일어날 기능이 아니라고 생각해 일단은 taskExecutor 하나로 통합해두었다.
만약 두 스레드 풀을 분리해야한다면 아마 이런 식으로 하게 될 것이다.
@EnableAsync
@Configuration
public class AsyncConfig {
@Bean(name = "cleanWithdrawnMemberExecutor") // 회원 탈퇴 처리 전용
public Executor cleanWithdrawnMemberExecutor() {
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
executor.setCorePoolSize(2);
...
return executor;
}
@Bean(name = "discordNotificationExecutor") // 회원가입 알림 전용
public Executor discordNotificationExecutor() {
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
executor.setCorePoolSize(1);
...
return executor;
}
}
// 비동기 로직 수행 시
@Async("cleanWithdrawnMemberExecutor") // 전용 스레드 풀 지정
@TransactionalEventListener
public void on(WithdrawEvent event) {
...
}
비동기 변환 전 회원 탈퇴 Trace를 분석했다.
main span을 보면 삭제의 모든 작업 끝까지 걸쳐서 응답을 기다리고 있는 모습이다.
응답까지 총 1.59s 소요됐고, 이 당시 VU=3/iteration=200으로 P95를 측정했을 때 1.3s이 발생했다.
Trace상 가장 마지막 처리인 WarningSetting 삭제의 span attribute를 확인해보면
실행 스레드가 요청 스레드에 속해있음을 확인할 수 있다.

WarningSettingRepository.delete() span attribute 
비동기로 변환 후의 회원 탈퇴 Trace span을 분석해보았다.
(Trace의 전반 과정을 편하게 보기 위해 duration >= 10ms 필터링을 적용해서 몇 개의 작업들이 생략되었다.)
맨 위에 있는 main span의 총 duration이 399.83ms이다.
요청 응답을 보내기까지 걸린 시간이다.
ArticleRepository.deleteByMemberId()부터 비동기로 삭제된다.

ArticleRepository.delete() span attribute 
main span이 완료된 이후로도 1.53s까지 이어지는 삭제 작업들을 확인할 수 있다.
이는 곧 HTTP 응답을 보낸 후에도 요청 스레드와 분리되어 비동기로 삭제 작업이 진행됨을 의미한다.
만약 동기로 처리했다면 사용자는 1.53s까지 응답을 기다려야했을 것이다.
비동기로 서비스 데이터를 제거해 사용자 경험에 있어서 속도의 측면은 개선할 수 있었다.
하지만 회원탈퇴의 기능을 온전히 보장하려면 "완전한 데이터 삭제"가 중요하다.
사용자가 회원 탈퇴에 성공했다는 응답을 받았지만, 지속적으로 탈퇴한 회원의 데이터가 서비스에 보인다면 매우 부정적인 영향을 줄 것이다.
지금은 삭제 실패 시 error 로그는 남기고 있지만, 삭제 실패에 대한 후처리는 없는 상태이다.
지금 서비스 규모에서는 우리 개발자들이 직접 error 로그를 통해 확인할 수 있지만,
사용자 수가 매우 증가하게 될 경우 일일이 확인해서 처리하기 어려울 것이다.
따라서 추후에 사용자가 더 많아진다면 지금보다 견고하게 데이터 삭제를 보장하고,
실패시 재시도를 보장하기 위한 방법을 고안해야한다.