본문 바로가기

프로젝트/고민

이벤트 기반으로 책임 분리

확장성 있는 코드

확장성 이라는 개념에는 여러가지 요소가 있다.

 

  • 기능의 확장
  • 저장소의 확장
  • 서버의 확장

기능의 확장

예를 들어 회원가입을 하면 회원가입 쿠폰을 뿌린다거나, 회원가입 쿠폰을 뿌리는 이벤트를 종료하는 상황이 있을 것이다.

저장소의 확장

데이터가 너무나도 많이 쌓여 RDB가 아닌 NoSQL로 저장소를 변경하는 상황이 발생하거나, 읽기 성능을 최적화 하기 위해 별도의 저장소를 사용하는 상황이 발생할 수 있을 것이다.

서버의 확장

특정 도메인에 사용자가 과도하게 몰려 도메인 별로 트래픽을 분산해야 하는 상황이 발생할 수 있을 것이다.

 

앞선 상황 이외에도 확장이라는 개념에는 또 다른 요소가 있을 수 있겠지만 나는 코딩을 할 때 위 3가지의 상황을 과하지 않게 예측하고 코드를 작성하려고 노력한다. (너무 코드의 품질만을 생각하면 구현 난이도가 상승해서 생산성이 많이 떨어질 수 있다.)

 

나는 위 요소 중에서 기능, 서버의 확장을 염두하고 개발하는 방법에 대해 작성하고자 한다.

이벤트 기반으로 책임 분리하기

대부분의 기능에는 메인 기능과 서브 기능이 존재한다. 예를 들어 회원가입을 생각해보자.

보통 회원가입을 한다면 회원가입 축하 카톡이나 이메일이 온다.

즉, 메인 기능인 회원가입과 부가적인 기능인 알림 기능이 있다.

코드로 보자면 아래와 같을 것이다.

@RequiredArgsConstructor
@Service
public class LoginService {

    private final UserRepository userRepository;
        private final AlarmService alarmService;

    @Transactional
    public Long signUp(SignUpRequest signUpRequest) {
        var user = userRepository.save(signUpRequest.toEntity());
        alarmService.sendSignUpMessage(user);                
        return user.getId();
    }
}

언뜻 괜찮아 보이지만 sendSignUpMessage 메소드가 비동기가 아닌 이상, 알람을 보내는 트랜잭션과 회원가입을 하는 트랜잭션이 묶이게 된다.(전파 타입을 통해 제어가 가능하겠지만 더 읽기 어려운 코드가 되지 않을까..?)

즉, 메인 기능이 서브 기능에 종속적인 구조가 된다. 알람 기능이 고장나면 회원가입도 되지 않는셈이다.

 

이게 과연 맞는 상황일까??

 

이런 식의 코드는 다음의 문제를 갖는다

  • 트랜잭션이 길어진다. → 이는 성능과도 밀접한 관계가 있다.
  1. 일단 트랜잭션이 시작하면 db 커넥션을 계속 갖고 있다가 트랜잭션이 종료되면 반납을 할 것이다. 성능적으로 당연히 문제가 생길 것이다.
  2. MySQL 기준으로 트랜잭션이 길어지면 언두로그가 점점 쌓이게 될 것이다. 트랜잭션이 조금 길어진다 해서 db 서버에 큰 영향은 없겠지만, 몇만 혹은 몇십만의 트래픽을 마주하면 긴 트랜잭션 또한 병목지점 중 하나가 될 수도 있다고 생각한다.
  • 코드의 가독성이 떨어진다. 지금은 서브 기능이 하는 일이 단순해서 읽기 어렵지는 않지만 이 코드가 회원가입이 아닌 주문이라고 생각하면 서브 기능이 덕지덕지 붙을 것이다. 그러면 코드는 아마 읽기 힘들 듯..
  • 이후의 확장이 어렵다. 만약 회원가입 이벤트를 해서 이벤트 쿠폰을 발행해줘야 하는 상황이라면? 기존의 코드에 계속 덧붙여서 쓰게 될 것이다. (이러면 또 트랜잭션이 길어지고.. aop를 써도 되겠지만 근본적인 해결은 아닐 것이다.)
  • 의존 관계가 순환될 위험이 크다. 당연히 loginServcie에서 알람도 호출하고, 쿠폰도 호출하고 등등 서브기능의 모든 것을 호출한다면 의존관계가 엉망이 될 것이다. (이런식이면 레거시 코드가 될듯)

이러한 고민을 이번 프로젝트에 담아 이벤트 기반의 옵저버 패턴으로 해결했다.

아래 코드는 웨이팅을 취소하는 코드다. 이 기능에 붙은 서브 기능은 웨이팅 취소시 알림을 보내주는 것이다.

@Service
@RequiredArgsConstructor
public class WaitingService {

    private final WaitingRepository waitingRepository;
    private final WaitingLine waitingLine;

    @Transactional
    public void cancelWaiting(Long waitingId) {
        var waiting = waitingRepository.findById(waitingId)
                .orElseThrow(() -> new NoSuchElementException("등록된 웨이팅이 존재하지 않습니다. waitingId : " + waitingId));

        var shopId = waiting.getShopWaiting().getShopId();

        waitingLine.cancel(shopId, waitingId, waiting.getIssuedTime());
        waiting.changeWaitingStatus(WaitingStatus.CANCEL);
    }

}

이 코드에 이벤트 발행 코드를 작성해 웨이팅과 알람을 완전히 분리했다.

@Service
@RequiredArgsConstructor
public class WaitingService {

    private final WaitingRepository waitingRepository;
    private final WaitingLine waitingLine;
    private final ApplicationEventPublisher eventPublisher;

    @Transactional
    public void cancelWaiting(Long waitingId) {
        var waiting = waitingRepository.findById(waitingId)
                .orElseThrow(() -> new NoSuchElementException("등록된 웨이팅이 존재하지 않습니다. waitingId : " + waitingId));

        var shopId = waiting.getShopWaiting().getShopId();

        waitingLine.cancel(shopId, waitingId, waiting.getIssuedTime());
        waiting.changeWaitingStatus(WaitingStatus.CANCEL);
        eventPublisher.publishEvent(new WaitingCanceledEvent(waiting));
    }

}

아래는 이벤트가 발행되기를 바라보고 있는 옵저버이다.

@RequiredArgsConstructor
@Component
public class SendAlarmWithWaitingCanceledEventHandler {

    private static final String WAITING_CANCEL_MESSAGE = "웨이팅을 취소햇습니다.";

    @Value("${spring.data.redis.topic.alarm}")
    private String topic;

    private final StringRedisTemplate redisTemplate;
    private final WaitingRepository waitingRepository;

    @Async
    @Transactional(readOnly = true)
    @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
    public void sendAlarmAfterWaitingCreatedEvent(WaitingCanceledEvent event) {
        var waitingId = event.waiting().getId();
        var alarmInfo = waitingRepository.findWaitingAlarmInfoById(waitingId)
                .orElseThrow(() -> new NoSuchElementException("존재하는 웨이팅 정보가 없습니다: " + waitingId));

        var message = new AlarmMessage(String.valueOf(alarmInfo.userId()),
                alarmInfo.shopName(),
                WAITING_CANCEL_MESSAGE);
        redisTemplate.convertAndSend(topic, message);
    }

}

서브 기능은 메인 기능이 완료된 후 발생해야하기 때문에 AFTER_COMMIT이라는 옵션을 줬으며, 이 코드가 추후 추가될 가능성이 있는 서브 기능과 동기적으로 작동할 이유가 없기 때문에 비동기 어노테이션을 붙였다.

 

 

그림으로 나타내자면 위와 같은 구조이다. 기존 코드의 변경 없이 서브 기능을 무한정 붙일 수 있다.

 

현재 프로젝트는 이런 구조로 이벤트를 이용해 의존 순환 없이 서브 기능을 계속해서 덧붙일 수 있는 코드를 작성했다. (근데 메인 기능의 경계를 정확하게 해야 함 너무 분리하면 읽기가 정말 어렵다.)

 

또한, 이벤트 발행을 통해 도메인 간 영역 분리를 할 수 있어 나중에 특정 도메인 만을 서비스로 떼어내더라도 코드의 변경은 크지 않을 것이다. 

 

이런 고민과 해결과정을 겪으며 서브 기능의 확장에 유연한 코드를 작성할 수 있게 된 것 같다.

'프로젝트 > 고민' 카테고리의 다른 글

코드의 품질 관리를 도움 받기: SonarCloud, CodeMetrics  (0) 2023.10.13
우리의 문제 상황에 맞는 락 구현  (0) 2023.10.06
테이블 설계  (0) 2023.10.05
주문 코드 리팩토링  (0) 2023.08.19
OpenFeign 적용기  (0) 2023.07.12