서론

사내에서 새로운 도메인 작업을 담당하면서 특정 로직을 실행한 뒤
후처리를 해야되는 상황에서 사용했던 방법에 대해서 공유하고자합니다.

 

가상 시나리오

회원 가입 -> 포인트 적립 및 이메일 발송

예제를 명시적으로 보이기위해 SignUp~, Update~Service 기능을 나타내는 네이밍을 사용했습니다.

 

- Before: 결합도가 강하고 좋지 않은 설계

@Getter
public class User {

    private Long id;
    private String email;

    public User(final Long id, final String email) {
        this.id = id;
        this.email = email;
    }
}

 

@Repository
public class MemoryUserRepository implements UserRepository {

    private static final Map<Long, User> store = new ConcurrentHashMap<>();
    private static final AtomicLong SEQUENCE = new AtomicLong();

    @Override
    public User save(final User user) {
        if (user.getId() == null) {
            user.setId(SEQUENCE.incrementAndGet());
        }
        store.put(user.getId(), user);
        return user;
    }
}

 

 

다음 두 인터페이스는 밑에 SignUpUserService에서 회원가입을 진행 후 실행하는 후처리 인터페이스입니다

실제로 호출하는 행위를 검증만 할 것이기 때문에 인터페이스만 만들고 구현체는 만들지 않았습니다.

// 이메일 발송
public interface SendEmailService { 

    void send(final String email);
}

 

// 포인트 업데이트
public interface UpdatePointService {

    void rewardPoint(final Long userId); // 적립

    void spendPoint(final Long userId);  // 소비
}

 

회원가입을 처리하는 SingUpUserService

SignUpService는 회원 가입을 하고, 이메일 발송 등 후처리를 진행하는 역할을 하고있습니다.

이상한 부분이 느껴지시나요?

 

SignUpService 객체는 다음과 같은 문제가 있습니다.

  • 결합도가 높다
    -> SignUpService는 후처리 로직들을 모두 직접적으로 알고있습니다.
    -> 후처리 로직들이 변경, 추가 되면 SignUpService에게도 직접적인 영향을 끼칠 수 있습니다. OCP 위반
    -> 변경에 용이하지 않다. 
  • 객체 책임이 너무 크다.
    -> 회원 가입을 하는 객체인데, 후처리 (메일 발송, 포인트 적립 등)도 책임지고 있습니다.
    -> SRP 위반 
    -> 후처리에 대한 기능들을 추가 될수록 SignUpService의 책임은 계속 커진다.

그림으로 나타내면 다음과 같습니다. (Producer가 Consumer에게 직접적으로 메세지를 보낸다)

-After: EventLister를 통한 느슨한 결합

@Service
@RequiredArgsConstructor
public class SignUpUserService {

    private final UserRepository userRepository;
    private final ApplicationEventPublisher eventPublisher;

    public void register(final String email) {
        final User user = userRepository.save(new User(email));

        eventPublisher.publishEvent(new SignUpUserEvent(user)); // 해당하는 이벤트를 등록한 리스너들에게 알림
    }
}

 

@Getter
@RequiredArgsConstructor
public class SignUpUserEvent {

    private final User user;
}

 

@Component
@RequiredArgsConstructor
public class SignUpUserEventHandler {

    private final SendEmailService sendEmailService;
    private final UpdatePointService updatePointService;

    @EventListener
    public void sendMail(final SignUpUserEvent signUpUserEvent) {
        final String email = signUpUserEvent.getUser().getEmail();
        sendEmailService.send(email);
    }

    @EventListener
    public void rewardPoint(final SignUpUserEvent signUpUserEvent) {
        final Long userId = signUpUserEvent.getUser().getId();
        updatePointService.rewardPoint(userId);
    }
}

@Async를 이용해서 비동기 처리를 하거나

@Order를 이용해서 처리 순서를 지정하는 것도 가능합니다.

 

물론 실제로 이메일 발송 등 제어할 수 없는 대상을 다루거나, 후행 작업에서 오류가 발생할 어떻게 대응할지 잘 고려해봐야합니다.

위 방식은 동기적으로 로직을 수행하고 트랜잭션이 하나로 묶이기 때문에 후행 작업을 처리하다 exception이 발생하면 모두 롤백을 진행하기 때문에 주의해야합니다.

 

애플리케이션 구성 다이어그램은 다음과 같습니다.

 

이제 SignUpService는 회원 가입만하고, 그 후엔 어떤 일이 일어나는지 전혀 모릅니다. 카카오 알림톡 발송 기능이 추가되던, 이메일 발송 메서드 이름이 변경되건 SignUpService는 아무 변경이 없게되었습니다.

 

실제로 동작하는 테스트코드로 검증을 해보겠습니다.

@SpringBootTest
class SignUpUserServiceTest {

    @Autowired
    SignUpUserService signUpUserService;

    @MockBean
    SendEmailService sendEmailService;

    @MockBean
    UpdatePointService updatePointService;

    @Test
    @DisplayName("유저를 저장하고 등록한 EventLister가 호출되는지 확인한다.")
    void verify_event() {
        final String email = "abc@naver.com";
        signUpUserService.register(email);

        verify(sendEmailService, times(1)).send(email);
        verify(updatePointService, times(1)).rewardPoint(anyLong());
    }
}

 

테스트가 정상적으로 성공하는 것을 확인할 수 있습니다.

 

 

코드는 깃허브에서 확인할 수 있습니다.

 

brick0123/event-handler

Contribute to brick0123/event-handler development by creating an account on GitHub.

github.com