JAVA/JAVA | Spring 학습기록

[Spring] 이벤트 발행 및 @TransactionalEventListener을 이용한 의존성 줄이기

kth990303 2023. 4. 25. 15:24
반응형

사이드 프로젝트에서 회원탈퇴 로직을 구현하는 백로그를 담당중이었다.

하지만 회원 탈퇴는 단순 memberService.delete() 메서드 (해당 프로젝트는 물리적 삭제 프로세스를 따르고 있다.)만으로 끝나는 간단한 로직이 아니었다. Member와 타 객체 간 연관관계가 꽤 많았기 때문이다.

 

회원은 특정 카페에 대한 리뷰를 작성할 수 있다.

회원은 특정 카페에 대해 댓글을 작성할 수 있다.

회원은 특정 카페에 대해 즐겨찾기를 등록할 수 있다.

 

그렇기 때문에 회원이 탈퇴하면 

1. 리뷰와의 연관관계를 끊고 (리뷰 삭제는 안함)

2. 댓글과의 연관관계를 끊고 (댓글 삭제는 안함)

3. 즐겨찾기와의 연관관계를 끊고 해당 즐겨찾기 객체들을 삭제 

 

로직을 수행해야 한다.

갑자기 추가되는 무수한 의존성

원래 MemberService에서는 타 서비스 객체의 의존성이 아예 없었다.

그런데 탈퇴 로직 하나때문에 MemberService에서 위와 같이 의존성들이 추가되면, 이후에 변경의 여파나 의존성 파악의 어려움을 동반하는 스파게티 코드가 될 것이 불보듯 뻔했다.

(이미 해당 프로젝트의 핵심 비즈니스 객체인 CafeService에서 수많은 의존성을 가지고 있기 때문에... 더 이상 의존성 스파게티는 진짜 안된다)

 

그런데 최근에 난 아래와 같은 생각을 한 적이 있다.

@TransactionalEventListener이 꼭 필요한 상황 외에는 이벤트 및 MQ에 대한 절실한 필요성을 느낀 적이 없었는데 확실히 요구사항이 휙휙 바뀌는 경우에는 절실하게 느껴질 것 같다. 내가 몸담고 있는 사이드프젝에도 타 서비스 (또는 타 repository) 의존성을 빼고 이벤트 전파 쪽으로 코드를 리팩터링해봐야겠다.

(스프링캠프 2023을 다녀오고 나서 느낀 점)

https://kth990303.tistory.com/440

 

[230422] Spring Camp 2023 갔다온 후기

KSUG에서 주최한 스프링캠프 2023 컨퍼런스에 다녀왔다~ https://springcamp.ksug.org/2023/ Spring Camp 2023 '스프링 캠프'는 애플리케이션 서버 개발자들과 함께 가치있는 기술에 관한 지식과 정보를 '공유'하

kth990303.tistory.com

 

그런데 지금, 아주 적절한 상황이 아닌가!

이벤트 발행으로 의존성 리팩터링 드가자~~

바로 지금이 ApplicationEventPublisher를 사용하기 아주 적절한 시기였다!

(물론 이벤트 발행의 단점 또한 고려하여 결정을 내린 것이다. 이벤트 발행 방식의 단점은 아래에 서술하였다.)

 

특히 3번에서 즐겨찾기들을 삭제하는 로직은 DB I/O 비용이 어마어마하리라 생각해 bulk delete로 진행하며, 연관관계만 끊어지면 회원탈퇴에는 문제가 없으니 조금 나중에 따로 처리해도 문제는 없겠다 판단. @TransactionalEventListener이랑 @Async, 아니면 Spring Scheduler을 이용한 배치 작업으로 돌려도 괜찮을 것 같다는 생각이 들었다.

 

@TransactionalEventListener는 아래 글의 중반부부터 참고하면 좋을 듯하다.

https://kth990303.tistory.com/387

 

[Spring] REQUIRES_NEW 옵션만으론 자식이 롤백될 때 부모도 롤백된다

두 개 이상의 트랜잭션이 합류할 경우, 트랜잭션 전파레벨 옵션 설정으로 트랜잭션을 관리할 수 있다. 특히, 트랜잭션 전파레벨 중 REQUIRED, REQUIRES_NEW는 실제로도 꽤나 자주 쓰이는 옵션이라 알아

kth990303.tistory.com


1. ApplicationEventPublisher - 리뷰와의 연관관계, 코멘트와의 연관관계 끊기

이벤트 발행은 스프링의 ApplicationEventPublisher를 이용하여 이벤트 핸들러에게 전달되면 이벤트들을 수행하는 방식으로 이루어진다. 이를 통해 타 객체의 의존성 없이, 이벤트 발행을 진행할 수 있다. MQ의 pub/sub 구조를 이용하여 MSA 기반에서 동작하게 하는 것을 생각하면 될 듯하다. 여담으로, 멀티캐스트 기반으로 동작하여 독립적으로 여러 이벤트를 이벤트 핸들러에게 전달하게 구현돼있다. SpringEventMultiCaster가 SpringEventPublisher의 구현체이기 때문이다.

 

MemberService.class

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
@Service
@RequiredArgsConstructor
public class MemberService {
    private final MemberRepository memberRepository;
    private final ApplicationEventPublisher applicationEventPublisher;
 
    @Transactional
    public void delete(String email) {
        Member findMember = memberRepository.findByEmail(email)
                .orElseThrow(NotFoundMemberException::new);
        // 이벤트 발행하여 연관관계 끊도록
        applicationEventPublisher.publishEvent(new MemberEvent(findMember));
 
        // 이벤트 수행 완료되면 delete
        memberRepository.delete(findMember);
    }
}
 
cs

기본적으로 이벤트 발행 로직은 동기적으로 작동한다. 그렇기 때문에 위 코드에서 publishEvent() 메서드를 이용하여 이벤트 발행 후에 이벤트 수행까지 마치고나서 memberRepository.delete() 로직이 수행된다.

 

그리고 이벤트 방식은 기본적으로 이벤트 발행 및 이벤트 핸들러 호출 과정의 오버헤드가 존재한다. 그리고 이벤트 방식은 의존성 파악하기가 오히려 어려워지고 객체 생명주기 관리가 어렵다는 단점도 존재한다. 따라서 모든 상황에서 이벤트 발행이 정답은 아니다

나는 해당 프젝이 모놀로식 방식인데다가, 지금처럼 특정 로직으로 인한 지나치게 많은 의존성이 발생하는 경우라 판단이 들어서 이벤트 발행을 선택한 것이다.

 

MemberEvent.class

1
2
3
4
5
6
7
8
9
10
11
12
13
package mocacong.server.service.event;
 
import lombok.Getter;
import lombok.RequiredArgsConstructor;
import mocacong.server.domain.Member;
 
@Getter
@RequiredArgsConstructor
public class MemberEvent {
 
    private final Member member;
}
 
cs

Getter만 있으면 크게 문제없다.

참고로 스프링 4.2버전 부터는 이벤트 객체를 생성할 때 ApplicationEvent를 상속받을 필요가 사라졌다.

출처: https://docs.spring.io/spring-framework/docs/current/javadoc-api/org/springframework/context/ApplicationEventPublisher.html

따라서 위처럼 단순히 DTO 느낌이 나도록 만들면 된다.

kotlin이었다면 data class 지정으로 바로 끝난다. (그리워요 kotlin)

 

CafeService.class

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
@Service
@RequiredArgsConstructor
public class CafeService {
 
    @EventListener
    public void updateReviewWhenMemberDelete(MemberEvent event) {
        Long memberId = event.getMember()
                .getId();
        reviewRepository.findAllByMemberId(memberId)
                .forEach(Review::removeMember);
        scoreRepository.findAllByMemberId(memberId)
                .forEach(Score::removeMember);
    }
}
 
cs

위처럼 Member와 Review의 의존성을 끊어주는 작업을 수행해주었다. 회원은 많은 리뷰를 작성할 수 있기 때문에 DB의 findAllBy... 작업으로 리뷰들을 찾아주어 연관관계를 끊어주었다.

코멘트와 즐겨찾기도 마찬가지로 진행해주면 된다.

 

이렇게 하여 Member를 성공적으로 삭제할 수 있다!


2. @TransactionalEventListener - 끊어진 연관관계 객체들 삭제

리뷰, 코멘트의 경우는 회원이 탈퇴된다고 해서 삭제시킬 필요가 없지만, 즐겨찾기는 얘기가 다르다.

회원이 없는데 굳이 해당 회원이 등록한 즐겨찾기 엔티티를 남겨둘 필요가 있을까? 

 

이왕이면 삭제하는 것이 불필요한 메모리 낭비를 줄일 수 있으므로 삭제해주도록 하자.

FavoriteService.class

1
2
3
4
5
6
7
8
9
10
11
12
@EventListener
public void deleteAllWhenMemberDelete(MemberEvent event) {
    Member member = event.getMember();
    // 회원이 등록한 즐겨찾기 모두 조회
    favoriteRepository.findAllByMemberId(member.getId())
            .forEach(favorite -> {
                // 회원-즐겨찾기 연관관계를 끊어준다
                favorite.removeMember();
                // 해당 즐겨찾기 삭제
                favoriteRepository.deleteById(member.getId());
            });
}
cs
어? 그러면 그냥 연관관계 끊어진 객체마다 deleteById를 하면 되는 거 아닌가?
이렇게 짜면 바로 해결~ 룰루랄라

 

물론 이렇게 짜도 문제는 없다. 기능 자체는 문제없이 돌아간다.

하지만 Bulk Delete로 작동하지 않아 굉장히 느리다는 문제가 존재한다.

이벤트는 기본적으로 동기로 작동하기 때문에, 해당 로직 수행 후에 회원탈퇴에 해당되는 member.delete() 로직이 수행될 것이다. 그런데 위와 같이 작성하면 회원은 탈퇴 과정을 기다리다가 답답해할 확률이 높다. (탈퇴하지 말라는 무언의 압박, 오히려 좋아..)

 

개선된 FavoriteService.class와 FavoriteRepository.class

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
@EventListener
public void deleteAllWhenMemberDelete(MemberEvent event) {
    Member member = event.getMember();
    // 회원이 등록한 즐겨찾기 모두 조회
    // 회원-즐겨찾기 연관관계를 끊어준다
    favoriteRepository.findAllByMemberId(member.getId())
            .forEach(Favorite::removeMember);
 
    // 즐겨찾기 member_id가 null인 경우 bulk-delete
    favoriteRepository.deleteAllByMemberIdIsNull();
}
 
 
// deleteAllByMemberIdIsNull() 
public interface FavoriteRepository extends JpaRepository<Favorite, Long> {
 
    @Modifying(clearAutomatically = true, flushAutomatically = true)
    @Query("delete from Favorite f where f.member.id IS null")
    void deleteAllByMemberIdIsNull();
}
 
cs

따라서 @Query를 이용하여 bulk delete로 작성해주고, 영속성컨텍스트에 반영된 내용을 바로 DB에 flush 해주기 위해 @Modifying 어노테이션의 clearAutomatically, flushAutomatically를 true로 해주는 방식으로 개선했다.

이렇게 하면 즐겨찾기 개수 N번마다 DB에 접근하여 delete하는 i/o 비용을 한 번의 쿼리로 개선할 수 있다.

 

근데 조금만 더 생각해보자.

member 회원 탈퇴는 즐겨찾기와의 연관관계만 끊어주면 바로 가능한 로직이다.

굳이 즐겨찾기를 삭제하는 DB 접근 비용까지 기다리면서 회원탈퇴를 시킬 필요가 있을까?

 

연관관계만 끊기 -> 회원 탈퇴 -> 즐겨찾기 엔티티 삭제

 

과정으로 진행하면 회원탈퇴까지 기다리는 시간을 더더욱 줄일 수 있어보인다!

그런데 여기서 즐겨찾기 엔티티 삭제 과정은, 연관관계를 끊은 이후에 일어남이 보장돼야 한다.

또, 회원 탈퇴 이후에 이루어져야 회원탈퇴까지의 시간을 줄일 수 있어보인다.

회원 탈퇴 메서드를 다시 한 번 보자.

@Transactional 메서드 내에서 실행중이며, delete 연산이 수행되고 난 다음 트랜잭션을 커밋한다.

해당 메서드 수행 이후에 즐겨찾기 엔티티 삭제 과정이 진행되면 시기적절해보인다. 따라서 @TransactionalEventListener를 이용할 수 있겠다.

TransactionalEventListener

AsyncService.class

1
2
3
4
5
6
7
8
9
10
11
12
13
@Service
@RequiredArgsConstructor
public class AsyncService {
 
    private final FavoriteRepository favoriteRepository;
 
    @Async
    @Transactional(propagation = Propagation.REQUIRES_NEW)
    @TransactionalEventListener
    public void deleteFavoritesWhenMemberDeleted(MemberEvent event) {
        favoriteRepository.deleteAllByMemberIdIsNull();
    }
}
cs

비동기적으로 작동되길 희망하므로 @Async를 붙여주었다.

참고로 별도의 서비스 클래스에 작성한 이유는 @Async, @Transactional이 AOP 기반이라서 self-invocation 문제를 유발할 수 있기 때문이다. 그런데 사실 위 메서드의 경우는 FavoriteService.class에 있어도 이벤트 발행 메서드는 MemberService.class로 위치가 달라 별도 클래스를 안만들어주어도 되긴 한다. 

 

또, 해당 트랜잭션 이벤트에서 insert, update, delete 쿼리에 해당되는 로직을 수행하므로 DB에 수정이 반영된다. 그렇기 때문에 @Transactional의 전파레벨을 REQUIRES_NEW로 설정하여 별도의 트랜잭션으로 작동되게 했다. 

 

이는 TransactionSynchronization 클래스의 afterCommit 메서드 주석에 안내돼있는 바와 같다. 

이벤트를 발행하는 메서드의 트랜잭션이 이미 커밋됐고, 기본 전파레벨 옵션인 REQUIRED 전파레벨일 경우 부모 트랜잭션에 참여한다. 따라서 이벤트를 수행하는 메서드에서는 트랜잭션 커밋이 발생하지 않는다. 그렇기 때문에 REQUIRES_NEW와 같은 전파레벨로 트랜잭션을 새로 생성하여 독립적으로 수행해야 한다.

 

추가로 `@Async` 때문에 비동기이므로 순서가 어긋나지 않을까 걱정될 수 있다. 하지만 보통의 경우 `@Async`와 `@TransacitonalEventListener(phase = TransactionPhase.AFTER_COMMIT)`의 우선순위는 후자가 더 높아서 회원탈퇴 트랜잭션 커밋 이후에 비동기로 해당 메서드가 수행된다. 따라서 걱정하지 않아도 된다는 점.


이렇게 하여 타 패키지 의존성을 최대한 제거하고 리팩터링할 수 있게 변경됐다.

하지만 이러한 경우, 테스트 코드를 짜기가 번거로울 수 있다. (특히 비동기로 작동되는 경우 더더욱!)

 

그리고 앞에서도 언급했듯이 이벤트 방식은 객체 생명주기나 의존성 파악에 어려움을 줄 수 있으며, 이벤트 핸들러 호출 등으로 인한 오버헤드가 발생할 수 있기 때문에 적절한 상황에 맞게 쓰는 것이 중요하다. 

반응형