하나의 실패가 전체를 망가뜨리지 않도록

서여·2026년 2월 12일
post-thumbnail

들어가며

운영 중인 블로그에 기능들이 추가되며 하나 둘 기존 구조에 대한 문제가 보이기 시작했습니다. 성능 측면에서는 아직 블로그 접속자 및 게시물의 양이 많지 않아 최적화의 필요성은 못 느끼고 있지만, 이번에 개발 서버에서 테스트를 하면서 앞으로 개발에 있어 심각해 보이는 기술 부채를 마주했습니다.

개발서버는 운영 서버와 다르게 평소에 실행되어있지 않기 때문에, 스트릭 스케쥴링이 되지 않았습니다. 이에 따라 게시물 발행 로직이 실행 될 때 스트릭 데이터가 유효하지 않아 예외가 발생했고, 이 예외의 연쇄작용으로 게시물 발행 메일이 구독자들에게 전송되지 않았습니다.

이번 글에서는 이 문제의 원인을 찾아보고 어떻게 해결할지를 적어보려고 합니다.



1. 원인 분석

이 문제는 구독 기능이 만들어지며 눈에 보이기 시작했습니다. 현재 게시물을 출간하면 스트릭 도메인에서 이를 감지하여 스트릭 관련 비지니스 로지을 처리한 뒤 게시물을 구독자에게 메일을 발송합니다. 이때 스트릭에서 예외 혹은 오류가 발생하게 되면, 뒤에 있는 구독 메일 발송도 실행되지 않습니다.

문제의 코드

public void createPost(PostCreateCommand command) { // 게시물 발행 메서드
    // 게시물 DB 저장
    List<Tag> tags = tagService.findOrCreateAll(command.tags());
    Post post = postRepository.save(command.toEntity());
    postTagService.createPostTag(post, tags);

    // 스트릭 업데이트
    streakService.addStreakCount(LocalDateTime.now());

    // 구독자에게 메일 발송
    if (post.getIsPublished()) {
        newsLetterService.sendPublishedPostMails(subscriptionService.getSubscribedEmail(),
            postCommandFactory.createPostMailCommand(post));
    }
}

게시물 발행 로직을 도식화 하면 아래와 같은 그림이 됩니다.

게시물 발행 로직

게시물 발행 로직은 총 3단계로 나누어집니다. 이때 하나의 단계라도 실패하면 게시물 발행 로직 전체가 실패합니다. 저는 게시물 발행 로직에서 가장 핵심은 게시물을 DB에 저장하는 것이라고 생각합니다. 스트릭 업데이트와 메일 발송은 게시물 발행에 따른 부수적인 기능으로 이 두 개가 모두 실패했다고 해서 게시물 발행이 실패한 것은 아닙니다. 따라서 문제의 원인은 게시물 발행 로직에 스트릭 업데이트와 메일 발송 로직이 직렬로 연결되어 있는것입니다.

다음은 이를 해결하기 위한 게시물 발행 로직을 재설계 해보겠습니다.



2. 해결 방안

아래는 재설계한 게시물 발행 로직입니다. 게시물 DB에 저장하면 성공으로 판단하고, 스트릭 업데이트와 메일 발송을 외부에서 진행합니다. 게시물 발행 성공 단계에서는 스트릭, 메일기능을 호출만 하고 사용자에게 응답을 내려줍니다.

위처럼 설계를 진행했을때 장점이 있습니다.

첫 번째는 사용자 응답 시간이 단축된다는 것입니다. 메일 발송 기능은 아직 최적화가 되지 않아 약 3-5초 정도 소요 시간이 걸립니다. 기존 로직에선 메일 발송이 완료될 때 까지 응답이 내려오지 않았습니다. 하지만 이젠 기능을 호출만 하고 응답을 내려주니, 응답 시간이 상당히 줄어들 것으로 기대됩니다.

두 번째는 스트릭 기능과 메일 발송 기능이 독립적으로 실행되기 때문에 둘 중 하나가 실패하더라도 다른 기능에 영향을 주지 않습니다.



3. 구현 - Spring Event를 사용하자

Spring Event?

애플리케이션 내부에서 발생한 사실을 다른 컴포넌트에게 알리는 메커니즘

이를 더욱 쉽게 설명하면 한 도메인에서 어떤 이벤트(사실)가 발생하면 이 이벤트를 구독하고 있는 다른 도메인에서 특정 일을 수행하는 것을 의미합니다. 그리고 이는 옵저버 패턴과 같습니다.

게시물 발행 로직을 예시로 들면, 게시물 발행에 성공하게 되면 "게시물 발행됨" 이라는 이벤트를 생성합니다. 그럼 이 생성된 이벤트를 스트릭 서비스와 메일 발송 서비스가 핸들링하여 각각 스트릭을 업데이트하고 메일을 발송합니다.

구현 코드

게시물 발행 메서드

@Transactional
public void createPost(PostCreateCommand command) {
    // 게시물 저장
    List<Tag> tags = tagService.findOrCreateAll(command.tags());
    Post post = postRepository.save(command.toEntity());
    postTagService.createPostTag(post, tags);

    // 게시물 발행 이벤트 발행
    publisher.publishEvent(
        new PostPublishedEvent(post.getId(), post.getTitle(), post.getSummary(),
            post.getPublishedAt())
    );
}

스트릭 이벤트 핸들러

@Slf4j
@Component
@RequiredArgsConstructor
public class StreakEventHandler {

    private final StreakService streakService;

    @Transactional(propagation = Propagation.REQUIRES_NEW)
    @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
    public void handle(PostPublishedEvent event) {
        try {
            streakService.addStreakCount(event.publishedAt());
        } catch (Exception e) {
            log.error("Streak 업데이트 실패", e);
        }
    }
}

메일 발송 이벤트 핸들러

@Component
@RequiredArgsConstructor
public class SubscriptionEventHandler {

    private final SubscriptionService subscriptionService;
    private final NewsLetterService newsLetterService;

    @Async
    @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
    public void handle(PostPublishedEvent event) {
        newsLetterService.sendPublishedPostMails(subscriptionService.getSubscribedEmail(),
            new PostMailCommand(event.postId(), event.title(), event.summary()));
    }
}

코드 도식화

위 코드를 도식화 해보았습니다. 게시물 발행에 성공하면 게시물 발행됨 이벤트를 발행합니다. 그럼 그 이벤트를 스트릭 이벤트 핸들러와 메일 발송 이벤트 핸들러가 핸들링합니다. 이때, 원래는 비동기로 처리하려고 했지만, 스트릭 업데이트는 DB에 직접 기록하기도 하고 단순한 insert 로직이기 때문에 한 스레드 안에서 동기적으로 처리하도록 했습니다. 하지만 메일 발송은 시간이 오래걸리기도 하고, 외부 메일 서버를 사용하기 때문에 한 스레드에서 처리할 필요가 없습니다. 따라서 게시물 발행 응답 속도 개선을 위해 비동기로 처리했습니다.



4. 성능 개선 결과

결과는.. 재미있었습니다..

각각 20번씩 게시물 발행을 요청하여 최소, 평균, 최대 응답 시간을 계산해보았습니다.

응답 시간개선 전개선 후응답 시간 감소율
평균2909ms12.10ms99.58%
최소2737ms9.21ms99.66%
최대3262ms25.33ms99.22%

응답 시간 감소율 약 99%를 찍으며.. 이는 성능의 문제가 아니라 개선 전 어플리케이션 아키텍쳐 설계의 고질적 병목 문제임을 알 수 있었습니다. 여러분 설계 잘하세용



5. 구조적 위험 - 이벤트로 실행된 비지니스 로직이 실패하면?

이벤트와 비동기 처리를 이용해 사이드 비지니스 로직 실패로 인한 발행 실패 문제를 해결하고 게시물 발행 응답 속도를 개선했습니다. 하지만 이 구조의 근본적인 문제가 있습니다.

바로 비지니스 로직에서 예외가 발생하여 실패하는 경우 입니다. 스트릭 핸들러에선 예외가 발생하면 try-catch문으로 예외를 로깅만 하도록 했고, 메일 발송은 아예 다른 스레드이므로 예외가 발생해도 이벤트 발행 스레드에선 예외를 받지 않습니다.

이런 경우, 스트릭 저장에 실패했을때 데이터 신뢰도를 복구할 수 없을 수도 있고, 게시물이 발행 되었는데도 메일이 발송되지 않는 경우가 발생할 수도 있습니다. 따라서 이번 문제를 해결하기 위해 이벤트를 단순 메모리 기반 콜백이 아닌 영속화된 이벤트 저장 구조로 전환하기로 했습니다. 이는 outbox 패턴이라고도 부르는데, 이를 적용하여 더 안전한 블로그 운영을 하고싶습니다. 아직은 이에 대해 잘 모르기 때문에 추후 학습하여 블로그에 작성하도록 하갰습니다.



6. 새롭게 배운 내용

이벤트는 느슨한 동기 호출이다(이벤트는 만능이 아니다)

저는 개념적으로 이벤트를 알았기에 이벤트를 던지면 핸들러들이 비밀스러운 매커니즘으로 이벤트를 핸들링해서 동시에(비동기적으로) 비지니스 로직을 수행한다고 생각했습니다.

하지만 이번에 스프링 이벤트에 대해 공부해보니 단순히 이벤트가 발행되면 구독하고 있는 핸들러들을 for문으로 돌려 각각 동기적으로 수행한다는 것을 알았습니다. 따라서 이는 한 트랜젝션 안에서 수행하는 강한 동기적 수행 로직을 트랜젝션을 분리할 수 있는 느슨한 동기적 수행 로직으로 변환 시켜주는 것임을 알았습니다.

비동기는 어렵다

비동기를 처리하기 어렵다는 생각을 했습니다. 메일 발송 같은 외부 서버를 사용하는 서비스 로직 같은 경우엔 현재 비지니스 로직과 관련성이 없기 때문에 비동기 처리를 하여 다른 스레드에서 수행해도 괜찮았지만, 만약 같은 서비스(DB를 공유하는)에서 비동기로 처리하게 된다면 고려해야 할 사항이 많아져 신뢰도를 관리하기 어렵다는 생각이 들었습니다.



마치며

어찌보면 단순한 문제였지만 나름 깊이 고민을 해보며 여러 고민을 해본 것 같아 즐거웠던 시간이었습니다! 그리고 트랜젝션과 비동기의 개념이 필요해지면서 머리가 많이 복잡했습니다.. 그래서 이 개념도 확실히 공부해야겠습니당 긴 글 읽어주셔서 감사합니다!

profile
안녕하세요:) 아키텍트가 되고 싶은 백엔드 개발자 지망생입니다.

0개의 댓글