옵저버 패턴
옵저버 패턴은 어떤 객체에 일이 생겼을 때, 그걸 지켜보는 다른 객체들에게 알려주는 구조다. 알리는 쪽은 "이런 일이 있었다"는 사실만 전하고, 그 일에 어떻게 반응할지는 받는 쪽이 정한다.
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 수정 | 리스너만 추가 |
| 정리 중 하나가 실패하면 | 탈퇴까지 실패 | 탈퇴는 유지 |
| 코드 흐름 | 한 메서드에서 다 보임 | 리스너를 찾아봐야 보임 |
옵저버 패턴의 핵심은 알리는 쪽과 반응하는 쪽의 책임을 나누는 거다. 그런데 그렇게 나누는 게 의미가 있으려면 반응하는 쪽이 여럿이어야 하고, 알리는 쪽이 그들을 몰라야 할 이유가 있어야 한다. 회원 탈퇴는 그 조건이 잘 맞는 경우였다.
'Design Pattern' 카테고리의 다른 글
| 팩토리 매소드, 추상 팩토리 (0) | 2026.10.06 |
|---|---|
| 데코레이터 패턴, 날씨 API 예제로 이해하기 (0) | 2026.10.05 |
| [디자인 패턴] 데코레이터 패턴, 기존 객체에 기능 덧붙이기 (0) | 2026.09.29 |
| [디자인 패턴] 옵저버 패턴, 값이 바뀌면 여러 객체에 알리기 (0) | 2026.09.28 |
| 디자인 패턴 1장: 전략 패턴(Strategy Pattern) (0) | 2026.09.20 |