항해99 9주차 WIL - AOP 에 대한 나의 오해와 EventListener

Ming-Gry·2022년 11월 20일
1

항해99 WIL

목록 보기
9/12

이번 주차엔 AOP 에 대해 오해하고 있어 이 부분에서 고생을 좀 했었다. 이에 대한 내용을 정리해 포스팅해보도록 하겠다.

1) AOP

1-1) AOP 란?

AOP 란 Aspect Oriented Programming 의 약자로, 관점 지향 프로그래밍이라는 뜻을 갖고 있다. 프로그래밍 로직을 핵심적인 관점과, 부가적인 관점으로 나누고 그 관점을 기준으로 모듈화 한다는 뜻을 갖고 있다.

1-2) 나의 오해

여기서부터 나의 오해가 시작됐다. 지난 포스팅을 보면 우리는 투두 완료, 피드 작성, 댓글 및 리액션 추가 등 유저의 활동에 따라 뱃지를 지급하는 기능을 만들기로 하였다. 내가 이 기능 개발을 맡게 되었는데, 이 부분에서 나는 뱃지 지급이 투두, 피드, 댓글 등과 같이 핵심적인 기능이 아니라 부가적인 기능이라고 생각하여 AOP 를 적용하였다.

결론부터 말하자면 핵심 기능과 부가 기능에 대한 오해를 하고 있었던 것이다. 어쨌든 핵심기능이라 하면, 각 API 별 수행하는 비즈니스 로직이고 부가기능핵심기능을 보조하는 기능으로 로그 기록, API 수행 시간 저장 같은 기능을 맡게 된다.

어쨌든 뱃지 지급 기능 또한 핵심 기능에 속한다는 것이었다. 왜? 유저와 관련이 있는 기능이기 때문이다. 로그 기록이나 API 수행 시간 같은 것은 서버에서 개발자가 필요한 것이지 유저와는 관련이 없기 때문에 부가 기능에 속하는 것이다.

그럼에도 불구하고 잘했던 점은 어쨌든 TodoService 나 FeedService 등의 관심사와 뱃지를 지급하는 관심사는 다르기 때문에, 이를 모듈화 하여 BadgeEventListener 에서 이를 만들도록 했다는 것이다. 또한 BadgeService 를 DI 하지 않고 새로운 기술을 써봤다는 것도 의의가 있다고 생각한다.

1-3) AOP 의 한계(?)

어쨌든 EventListner 를 쓰기 전에 AOP 로 개발을 진행했다. 그러나 @Transactional 먹히지 않아 머릿 속이 새하얘졌다. 분명 내 코드에는 문제가 없는데 왜 먹히지 않는 것인지 도저히 이해할 수 없었다. 기술 멘토에게 이를 질문했지만 답장이 없었고...... 무한 구글링과 스택 오버 플로우에서도 마땅한 이유를 찾을 수 없었다. 사실 아직도 왜 안되는지는 잘 모르겠다.

내가 짰던 코드와 문제 상황은 아래와 같았다.




정말 저 메소드의 아무 것도 작동하지 않았다. 하다 못해 에러가 발생하던지 print 메소드라도 작동을 해줬으면 좋았으련만, IntelliJ 가 옆에서 띄워주는 것처럼 This advice advises no methods 라는 오류 메세지만 있을 뿐이었다.

정확하진 않지만 결국 내가 내린 결론은, AOP 에서는 @Transactional 이 작동하지 않는다 라는 것이다. 이게 맞는 건진 사실 아직도 잘 모르겠다. 그러나 확실한 건 나는 작동하지 않았다는 것이다. 물론 이유가 있었겠지...?

추측컨데 @Transactional 이 AOP 로 작동한다는 것과 관련이 있을 수도 있다고 생각한다. AOP 에선 AOP 가 안 먹히는 문제가 아닐까? 또, AOP 내부 메소드에서는 DB 와 관련된 작동을 못하도록 만들어놨을 수 있다고 추측해본다. 이 문제에 대한 답을 알고 계신 분이 있다면 댓글로 알려주시길 진심으로 부탁드린다.

2) AOP 가 아닌 다른 기술 - EventListener

2-1) EventListener

결국 돌고 돌아 EventListner 라는 기능을 알게 되었다. 어떤 경로로 이걸 알았는지 기억은 나지 않지만 아마도 Service 간 강결합 문제를 검색하다가 알게 되었던 것 같다.

왜냐하면 위에도 기술했지만 TodoService 나 FeedService 에 BadgeService 를 DI 하여 구현한다면 아래와 같은 코드가 나왔을 것이다.

@Service
@RequiredArgsConstructor
public class TodoService {

	private final TodoRepository feedRepository;
    private final BadgeService badgeService; // BadgeService DI
    
    @Transactional
    public ResponseEntity<?> completedTodo(Long id, MemberDetailsImpl memberDetails) {

        Todo todo = todoRepository.findById(id).orElseThrow(
                () -> new DoBlockExceptions(ErrorCodes.NOT_FOUND_TODO)
        );

        if (!todo.getMember().getId().equals(memberDetails.getMember().getId())) {
            throw new DoBlockExceptions(ErrorCodes.NOT_VALID_WRITER);
        }

        if (LocalDate.now(ZoneId.of("Asia/Tokyo")).isBefore(todo.getTodoDate().getDate())) {
            throw new DoBlockExceptions(ErrorCodes.NOT_ABLE_COMPLETE_TODO);
        }

        todo.completeTask();

        badgeService.createTodoBadge(memberDetails); // 이 부분이 문제다

        if (todo.isCompleted()) {
            return ResponseEntity.ok("투두가 완료되었습니다.");
        } else return ResponseEntity.ok("투두 완료가 취소되었습니다.");
    }
}

위에 있는 badgeService.createTodoBadge() 메소드가 문제다. 분명 TodoService 는 Badge 와는 아무런 관련이 없는데 Todo 완료와 Badge 생성 로직이 섞여있고 BadgeService 가 TodoService 에 강하게 의존하는 모습이다. 이것을 EventListner 로 해결할 수 있다.

EventPublisher 가 받을 Event Class 는 아래와 같이 정의했다. Inner Class 를 사용해 Class 안에서 다양한 Event 들을 정의하고 관리할 수 있도록 했다. 위의 코드에서는 CompletedTodoBadgeEvent 를 넣어줬다. 다양한 Badge Type 구현을 위해 Enum 또한 사용했다.

public class BadgeEvents {

    @Getter
    @AllArgsConstructor
    public static class CreateBadgeEvent{

        private BadgeType badgeType;
        private Member member;
    }

    @Getter
    @AllArgsConstructor
    public static class CompletedTodoBadgeEvent {

        private MemberDetailsImpl memberDetails;
    }

    @Getter
    @AllArgsConstructor
    public static class CreateFeedBadgeEvent{

        private MemberDetailsImpl memberDetails;
    }
}
@Getter
@AllArgsConstructor
public enum BadgeType {

    CTT("갓생스타터", "일정 3개를 달성하셨네요! 갓생은 이제부터 시작입니다"),
    CTTY("계란 한판", "일정 30개를 달성하셨네요! 시간 참 빠르죠? 벌써 계란 한판"),
    CTF("진짜 갓생러", "일정 50개를 달성하셨네요! 진짜 갓생러가 나타났다");
    
    private final String badgeName;
    private final String badgeDetail;
}
@Service
@RequiredArgsConstructor
public class TodoService {

	private final TodoRepository feedRepository;
    private final ApplicationEventPublisher applicationEventPublisher; //EventPublisher DI
    
    @Transactional
    public ResponseEntity<?> completedTodo(Long id, MemberDetailsImpl memberDetails) {

        Todo todo = todoRepository.findById(id).orElseThrow(
                () -> new DoBlockExceptions(ErrorCodes.NOT_FOUND_TODO)
        );

        if (!todo.getMember().getId().equals(memberDetails.getMember().getId())) {
            throw new DoBlockExceptions(ErrorCodes.NOT_VALID_WRITER);
        }

        if (LocalDate.now(ZoneId.of("Asia/Tokyo")).isBefore(todo.getTodoDate().getDate())) {
            throw new DoBlockExceptions(ErrorCodes.NOT_ABLE_COMPLETE_TODO);
        }

        todo.completeTask();
        
        //EventPublisher 에서 EventPublish, Event Class Type 을 넣어 필요한 인자값을 전달하도록 했다.
        applicationEventPublisher.publishEvent(new BadgeEvents.CompletedTodoBadgeEvent(memberDetails));

        if (todo.isCompleted()) {
            return ResponseEntity.ok("투두가 완료되었습니다.");
        } else return ResponseEntity.ok("투두 완료가 취소되었습니다.");
    }
}

TodoService 에서 Event 를 Publish 했으니 발행된 이벤트를 처리해줄 EventListner 가 필요하다. 그 구현은 아래와 같다.

@Component
@RequiredArgsConstructor
public class BadgeEventListener {

    private final TodoRepository todoRepository;
    private final BadgeRepository badgeRepository;

    @Async
    @Transactional
    @EventListener(classes = BadgeEvents.CompletedTodoBadgeEvent.class)
    public void createTodoBadges(BadgeEvents.CompletedTodoBadgeEvent badgeEvents){

        long completedTodo = todoRepository.countAllByMemberAndCompleted(badgeEvents.getMemberDetails().getMember(), true);

        createBadges(completedTodo, 3L, BadgeType.CTT, badgeEvents.getMemberDetails().getMember());

        createBadges(completedTodo, 30L, BadgeType.CTTY, badgeEvents.getMemberDetails().getMember());

        createBadges(completedTodo, 50L, BadgeType.CTF, badgeEvents.getMemberDetails().getMember());
    }
    
    @Transactional
    public void createBadges(Long count, Long limit, BadgeType badgeType, Member member){

        if(Objects.equals(count, limit) && !badgesRepository.existsByBadgeTypeAndMember(badgeType, member)){

            Badges badges = Badges.builder()
                    .member(member)
                    .badgeType(badgeType)
                    .build();

            badgesRepository.save(badges);
    }
}

완료된 TodoRepository 에서 완료된 Todo 의 갯수를 찾고 createBadges() 메소드에 이를 넘겨 Badge 를 저장하는 방식이다. 조금 비효율적으로 보일 수도 있는데, 이렇게 한 이유는 이미 발급된 뱃지가 있다면 생성되지 않게 하기 위해서이다.

현재 BadgeType.CTT 는 투두 완료를 3개 했을 때 발급이 되는데, 이 투두 완료는 언제든 취소할 수 있기 때문에 뱃지를 한 번 발급 받은 후 완료한 투두를 모두 취소하고 다시 3개를 완료하게 된다면 뱃지가 또 발급될 가능성이 있기 때문이다.

그리고 실제 코드에는 이 예시 코드보다 더 많은 뱃지들이 존재하므로 메소드 재사용성을 위해 이렇게 만들었다. 어차피 IF 문을 계속 돌릴 바에 이게 더 깔끔하다고 생각하기 때문이다.

또 위의 코드에서는 @Async 를 사용해 비동기화 처리를 해주었는데, 이에 대한 자세한 내용은 아래의 포스팅을 참고하도록 하자.

자세히 다루진 않겠지만 이렇게 비동기 처리 해준 이유는 EventListener 가 작동하는 동안 기존의 메소드가 완료되지 않고 계속 스레드를 갖고 있는 상태가 되기 때문이다. 다시 말해 EventListener 가 동작하는 시간 만큼 응답이 늦어지기 때문인 것이다. 그렇기 때문에 @Async 를 사용해 스레드를 분리하도록 했다.

[Spring] @Async를 이용한 비동기 처리에 대해 : https://bepoz-study-diary.tistory.com/399
ApplicationEventPublisher 기반으로 강결합 및 트랜잭션 문제 해결 : https://cheese10yun.github.io/event-transaction/#null

그러나 여기에 비밀이 있는데, 정상적으로 DB 에 꽂히기는 하지만 약간의 문제가 있다. 그래서 난 @EventListener 가 아니라 @TransacitionalEventListener 를 사용했다.

2-2) TransactionalEventListener

TransactionalEventListner 는 EventListener 와 하는 동작은 똑같지만 EventPublish 에서 Transaction 의 상태에 따라 동작하도록 한다.

위의 코드로 보자면 TodoService 의 completeTodo() 메소드의 @Transactional 작동 상태에 따라 EventListner 가 동작하는 것이다. 이에 따른 옵션은 4가지가 있다.

  1. BEFORE_COMMIT : 커밋 직전에 수행 (트랜잭션 진입 전이 아님)
  2. AFTER_COMMIT : 커밋 직후 수행
  3. AFTER_ROLLBACK : 롤백 직후 수행
  4. AFTER_COMPLETION : 트랜잭션이 완료된 뒤 수행

기본 값은 AFTER_COMMIT 인데, 이외에 다른 옵션을 적용하고자 하면 아래와 같이 써주면 된다.

@TransactionalEventListener(phase = TransactionPhase.AFTER_COMPLETION)

이를 쓰는 이유는 다음과 같다. @Transactional 이 있는 메소드에서 예외가 발생되거나, Rollback 이 됐을 때는 EventListener 가 작동하면 안되기 때문이다. 어쨌든 @Transactional 이 붙어있는 메소드에 EventListener 를 사용한다면 가급적 @TransactionalEventListener 를 쓰는 걸 추천한다.

@Component
@RequiredArgsConstructor
public class BadgeEventListener {

    private final TodoRepository todoRepository;
    private final BadgeRepository badgeRepository;

    @Async
    @Transactional
    @TransactionalEventListener(classes = BadgeEvents.CompletedTodoBadgeEvent.class)
    public void createTodoBadges(BadgeEvents.CompletedTodoBadgeEvent badgeEvents){

        long completedTodo = todoRepository.countAllByMemberAndCompleted(badgeEvents.getMemberDetails().getMember(), true);

        createBadges(completedTodo, 3L, BadgeType.CTT, badgeEvents.getMemberDetails().getMember());

        createBadges(completedTodo, 30L, BadgeType.CTTY, badgeEvents.getMemberDetails().getMember());

        createBadges(completedTodo, 50L, BadgeType.CTF, badgeEvents.getMemberDetails().getMember());
    }
    
    @Transactional
    public void createBadges(Long count, Long limit, BadgeType badgeType, Member member){

        if(Objects.equals(count, limit) && !badgesRepository.existsByBadgeTypeAndMember(badgeType, member)){

            Badges badges = Badges.builder()
                    .member(member)
                    .badgeType(badgeType)
                    .build();

            badgesRepository.save(badges);
    }
}

그러나 재밌는 것은 분명히 @EventListener 에서 @TransactionalEventListener 로 바꿨을 뿐인데 @EventListener 를 썼을 때는 DB 에 정상적으로 꽂히던 것이 제대로 꽂히질 않는다! 여기서 살짝 멘붕...

2-3) Transaction 전파 속성

위에서 EventListener 가 작동하지 않는 이유는 Transaction 의 전파 속성 때문이다. 사실 이 포스팅까지 하면 굉장히 내용이 길어질 것 같기 때문에 자세한 설명은 아래의 포스팅을 참고하도록 하자.

[Spring] 스프링의 트랜잭션 전파 속성(Transaction propagation) 완벽하게 이해하기 : https://mangkyu.tistory.com/269

어쨌든 @TransactionalEventListener 와 @Async 까지 써서 코드를 정말 잘 짰다고 생각했는데, 제대로 동작하지 않아 정말 슬펐다... 그래도 다행인건 print 를 찍어봤을 때, AOP 와는 다르게 print 는 정상적으로 작동하고 DB 에 save 만 되지 않는다는 사실을 확인할 수 있었다.

그래서 @Transactional 에 대해 다시 공부해본 결과, 트랜잭션 전파 속성이라는 것이 있다는 걸 알게 됐다. 이를 공부하다 보니 TransactionalEventListener 는 EventPublisher 의 Transaction 상태에 따라 작동하는 것인데, 이 때문이 아닐까? 라는 생각에 도달할 수 있었다. 결과만 말하면 Transaction에 REQUIRES_NEW 옵션을 사용해 성공할 수 있었다. 뒷걸음치다가 쥐 잡은 격이었는데, 원래 이렇게 써야된다는 것을 이번에 다시 공부하면서 처음 알았다.

기존의 EventListener 에 적용한 @Transactional 은 기본 옵션인 REQUIRED 옵션에 해당한다.

REQUIRED 옵션은 논리 트랜잭션은 다수이나 실제적인 물리 트랜잭션은 1개로 묶이게 된다. 만약 여기서 로직2 에 예외가 발생하면 로직1 도 로직2 와 같은 물리 트랜잭션에 속해있으므로 함께 롤백된다. 커밋도 마찬가지로 로직2 까지 커밋이 완료되어야 로직1 이 커밋되는 형식이다.

따라서 REQUIRES_NEW 옵션을 사용해 논리 트랜잭션에 맞게 물리 트랜잭션도 함께 분리했다. 여기에도 비밀이 있는데 REQUIRED 와 REQUIRES_NEW 를 함께 쓰면 또 무슨 비밀이 있다고 한다... 이번에 공부하면서 알았다. 이는 아래의 포스팅을 참고하도록 하자.

어쨌든 이를 적용해 아래의 코드가 최종 코드로 정상 작동하는 것을 확인했다.

@Component
@RequiredArgsConstructor
public class BadgeEventListener {

    private final TodoRepository todoRepository;
    private final BadgeRepository badgeRepository;

    @Async
    @Transactional(Transactional.TxType.REQUIRES_NEW)
    @TransactionalEventListener(classes = BadgeEvents.CompletedTodoBadgeEvent.class)
    public void createTodoBadges(BadgeEvents.CompletedTodoBadgeEvent badgeEvents){

        long completedTodo = todoRepository.countAllByMemberAndCompleted(badgeEvents.getMemberDetails().getMember(), true);

        createBadges(completedTodo, 3L, BadgeType.CTT, badgeEvents.getMemberDetails().getMember());

        createBadges(completedTodo, 30L, BadgeType.CTTY, badgeEvents.getMemberDetails().getMember());

        createBadges(completedTodo, 50L, BadgeType.CTF, badgeEvents.getMemberDetails().getMember());
    }
    
    @Transactional
    public void createBadges(Long count, Long limit, BadgeType badgeType, Member member){

        if(Objects.equals(count, limit) && !badgesRepository.existsByBadgeTypeAndMember(badgeType, member)){

            Badges badges = Badges.builder()
                    .member(member)
                    .badgeType(badgeType)
                    .build();

            badgesRepository.save(badges);
    }
}

[Spring] REQUIRES_NEW 옵션만으론 자식이 롤백될 때 부모도 : https://kth990303.tistory.com/387
Transactional REQUIRES_NEW에 대한 오해 : https://woodcock.tistory.com/40
[Spring] Transaction PROPAGATION.REQUIRES_NEW 의 '독립'이란 의미? : https://truehong.tistory.com/140

그렇다면 도대체 왜 TransactionalEventListener 에서는 Transaction 전파 속성을 바꿔줘야할까? 그 이유는 TransactionalEventListener 는 부모 트랜잭션 (위에서는 TodoService 의 completedTodo() 메소드) 커밋 후에 실행되기 때문에 이벤트 리스너의 트랜잭션을 분리시켜주어야 하기 때문이다!

원래는 내가 쓴 코드를 공개하고 싶지 않아서 이렇게까지 길게 쓸 의도를 갖고 있지 않았는데... 하다 보니 길어지기 되었다. 그러나 이 기능을 맡게 된 덕분에 내가 AOP 에 대해 오해하고 있었다는 사실과 EventListener 기능, 그리고 Transaction 전파 속성까지 공부할 수 있는 아주 뜻 깊은 시간이었고, 지금 취준으로 인해 공부가 미진했는데 이에 대해 다시금 되새기며 내 것으로 만들 수 있는 좋은 시간이었다.

내가 고생한 내용은 깃허브를 공개해놓을 테니 여기서 보도록 하자!

Do!Block BE Github : https://github.com/Hanghae99-DoBlock/BE/blob/main/src/main/java/com/sparta/doblock/events/listener/BadgeEventListener.java

참고 :
[Spring] 스프링 AOP (Spring AOP) 총정리 : 개념, 프록시 기반 AOP, @AOP : https://engkimbs.tistory.com/746
[SpringBoot] AOP(Aspect Oriented Programming, 관점 지향 프로그래밍)의 개념 및 사용 방법 예제 코드 : https://mangkyu.tistory.com/121
Spring Event + Async + AOP 적용해보기 : https://supawer0728.github.io/2018/03/24/spring-event/
Spring 의 @EventListener : https://sunghs.tistory.com/139
ApplicationEventPublisher 기반으로 강결합 및 트랜잭션 문제 해결 : https://cheese10yun.github.io/event-transaction/#null
Spring - Event Driven : https://velog.io/@backtony/Spring-Event-Driven
[Spring] @Async Annotation(비동기 메소드 사용하기) : https://velog.io/@gillog/Spring-Async-Annotation%EB%B9%84%EB%8F%99%EA%B8%B0-%EB%A9%94%EC%86%8C%EB%93%9C-%EC%82%AC%EC%9A%A9%ED%95%98%EA%B8%B0
[Spring] @Async를 이용한 비동기 처리에 대해 : https://bepoz-study-diary.tistory.com/399
Spring @EventListener : https://brunch.co.kr/@springboot/422
[Spring] 스프링의 트랜잭션 전파 속성(Transaction propagation) 완벽하게 이해하기 : https://mangkyu.tistory.com/269
Transactional REQUIRES_NEW에 대한 오해 : https://woodcock.tistory.com/40
[Spring] Transaction PROPAGATION.REQUIRES_NEW 의 '독립'이란 의미? : https://truehong.tistory.com/140
[Spring] @TransactionalEventListener : https://parkadd.tistory.com/108
ApplicationEventPublisher를 통한 문제 해결 : https://blog.naver.com/PostView.nhn?blogId=anstnsp&logNo=222361322823
Spring Boot 이벤트핸들링 과 @TransactionalEventListener, @Transactional : https://sukyology.tistory.com/18
[Spring] REQUIRES_NEW 옵션만으론 자식이 롤백될 때 부모도 : https://kth990303.tistory.com/387
[프로젝트] TransactinoalEventListener (+ 트랜잭션 전파 속성) : https://pomo0703.tistory.com/196

profile
항상 진심이지만 뭔가 안풀리는 개발 (주의! - 코린이가 배우고 이해한 내용을 끄적이는 공간이므로 실제 개념과 일부 다를 수 있음!)

0개의 댓글