본문 바로가기

Design Pattern

옵저버 패턴으로 회원 탈퇴 후 남는 데이터 정리하기

옵저버 패턴

옵저버 패턴은 어떤 객체에 일이 생겼을 때, 그걸 지켜보는 다른 객체들에게 알려주는 구조다. 알리는 쪽은 "이런 일이 있었다"는 사실만 전하고, 그 일에 어떻게 반응할지는 받는 쪽이 정한다.

Spring에서는 ApplicationEventPublisher로 이벤트를 발행하고 @EventListener나 @TransactionalEventListener로 받으면 이 구조를 쉽게 만들 수 있다.

다만 이벤트를 쓰면 코드 흐름이 한눈에 안 보이게 된다. 메서드를 직접 호출하면 따라가기 쉬운데, 이벤트로 바꾸면 누가 이걸 받는지 찾아다녀야 한다. 그래서 이번엔 쓸 곳을 찾기 전에 기준부터 정했다.

  • 하나의 사건에 서로 독립적인 여러 대상이 반응하는가
  • 발행하는 쪽이 반응하는 쪽을 몰라야 하는 의존 방향인가
  • 반응하다 실패해도 원래 작업은 유지되어야 하는가

반응하는 쪽이 하나뿐이라면 이벤트로 감싸는 것보다 그냥 메서드를 호출하는 게 낫다고 봤다. 그러면 간접 호출만 하나 늘어날 뿐이다.

회원 탈퇴가 딱 맞았다

프로젝트에서 이 세 가지를 다 만족한 게 회원 탈퇴였다. 탈퇴하면 DB 데이터는 cascade로 지워지는데, DB 밖에 남는 데이터가 있었다.

남는 데이터 위치

업로드한 사진 파일 디스크 (FileService)
최근 퀴즈 출제 기록 메모리 Map (QuizService)
AI 호출 기록 메모리 Map (AiRateLimiter)

처음 떠오르는 방법은 UserService에서 이걸 하나씩 호출하는 거다.

userRepository.delete(user);
fileService.deleteImages(photoUrls);
quizService.clearHistory(userId);
aiRateLimiter.clear(userId);

 

이러면 회원 모듈이 퀴즈, AI, 파일 모듈을 전부 알아야 한다. 나중에 탈퇴할 때 정리할 데이터가 하나 더 생기면 UserService를 또 고쳐야 하고, 그중 하나가 예외를 던지면 탈퇴 자체가 실패한다.

그래서 UserService는 "탈퇴했다"는 사실만 알리고, 정리는 데이터를 가진 쪽이 알아서 하게 했다.

구현

발행하는 쪽

@Transactional
public void deleteUser(Integer userId) {
    User user = findUserById(userId);
    List<String> photoUrls = collectPhotoUrls(user);   // 커밋 후엔 조회할 수 없으므로 미리 수집
    userRepository.delete(user);
    eventPublisher.publishEvent(new UserWithdrawnEvent(userId, photoUrls));
}

UserService는 누가 이 이벤트를 받는지 모른다. 이벤트만 던지고 끝이다.

반응하는 쪽

FileService는 사진을 지운다.

@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
public void onUserWithdrawn(UserWithdrawnEvent event) {
    for (String photoUrl : event.photoUrls()) {
        try {
            deleteImage(photoUrl);
        } catch (IOException e) {
            log.error("탈퇴 회원 사진 삭제 실패: ...", e);
        }
    }
}

QuizService와 AiRateLimiter는 메모리에 있는 기록을 지운다.

@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
public void onUserWithdrawn(UserWithdrawnEvent event) {
    recentQuizzes.remove(event.userId());
}

 

 

흐름을 그려보면 이렇다.

UserService.deleteUser → UserWithdrawnEvent
   └ AFTER_COMMIT ┬ FileService    : 사진 파일 삭제
                  ├ QuizService    : 출제 기록 제거
                  └ AiRateLimiter  : 호출 기록 제거

나중에 정리할 데이터가 늘어나도 UserService는 건드릴 필요 없이 리스너만 하나 추가하면 된다.

왜 AFTER_COMMIT인가

@TransactionalEventListener는 트랜잭션의 어느 시점에 실행할지 고를 수 있다. 기본값이 AFTER_COMMIT이긴 한데, 그냥 기본값이라서 쓴 건 아니다.

먼저 파일 삭제는 롤백이 안 된다. 탈퇴는 롤백됐는데 사진만 사라지면 곤란하니까 커밋이 끝난 뒤에 지워야 한다.

또 커밋 후에는 사용자 데이터를 조회할 수 없다. 그래서 사진 경로를 탈퇴 전에 모아서 이벤트에 담아 보낸다. 리스너가 이벤트만 보고 일을 끝낼 수 있게 필요한 정보를 미리 넣어둔 셈이다.

마지막으로 정리하다 하나가 실패했다고 탈퇴가 취소되면 안 된다. 테스트해보니 커밋 이후 리스너가 예외를 던져도 탈퇴는 그대로 성공했고, 다른 리스너도 정상적으로 실행됐다.

구현하다 막힌 부분

QuizService는 클래스에 @Transactional(readOnly = true)가 붙어 있었는데, 이런 클래스 안에 @TransactionalEventListener를 두니까 애플리케이션이 뜰 때 에러가 났다. (사용 중인 버전은 Spring 7)

@TransactionalEventListener method must not be annotated with @Transactional
unless when declared as REQUIRES_NEW or NOT_SUPPORTED

 

 

출제 기록 정리는 메모리만 건드리니까 트랜잭션이 필요 없다. 그래서 메서드에 NOT_SUPPORTED를 명시해서 해결했다.

@Transactional(propagation = Propagation.NOT_SUPPORTED)
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
public void onUserWithdrawn(UserWithdrawnEvent event) {
    recentQuizzes.remove(event.userId());
}

테스트로 확인한 것

  • 커밋되면 세 리스너가 모두 실행되고, 다른 사용자의 데이터는 그대로 남는다.
  • 탈퇴가 롤백되면 아무것도 지워지지 않는다.
  • 리스너 하나가 실패해도 탈퇴와 나머지 리스너는 영향을 받지 않는다.
  • /uploads/../파일처럼 업로드 폴더 밖을 가리키는 경로는 지우지 않는다.

아직 남은 문제

커밋 후 파일 삭제가 실패하면 고아 파일이 남는다. 이걸 완전히 막으려면 주기적으로 정리하는 배치가 따로 있어야 할 것 같다.

메모리 Map 정리는 서버가 한 대일 때만 의미가 있다. 서버를 늘리게 되면 Redis 같은 곳으로 옮겨야 한다.

마무리

직접 호출 옵저버 패턴

UserService의 의존 파일, 퀴즈, AI 모듈 전부 이벤트 하나
정리 대상이 늘어나면 UserService 수정 리스너만 추가
정리 중 하나가 실패하면 탈퇴까지 실패 탈퇴는 유지
코드 흐름 한 메서드에서 다 보임 리스너를 찾아봐야 보임

 

옵저버 패턴의 핵심은 알리는 쪽과 반응하는 쪽의 책임을 나누는 거다. 그런데 그렇게 나누는 게 의미가 있으려면 반응하는 쪽이 여럿이어야 하고, 알리는 쪽이 그들을 몰라야 할 이유가 있어야 한다. 회원 탈퇴는 그 조건이 잘 맞는 경우였다.