티스토리 뷰

1. 헬스앱 사이드 프로젝트 진행 중 기획자에게 연락이 왔다.

 

2. "센터 관장이 센터 등록할 때 로딩이 너무 긴것 같아서.. 중간에 이탈이 발생할 것 같아요. 속도를 빠르게 할 수 없을까요?" 

 

3. 물론 안되죠! 라고 말하고 싶었지만, 나는 '친절한 개발자'이다.

 

4. 로직을 살펴 봤다.

 

  @Transactional
    public void saveCenterFirst(HttpServletRequest request, SaveCenterFirstReqeust saveCenterFirstReqeust) {
        Users findUser;

        try {
            // 1) 로그인 사용자 가져오기
            UsersDto user = UserContext.getUser(request);
            ...
            
            // 2) 가입 중인 경우 트레이너 프로필 Y로 변경
            findUser.setUserType(UserTypeEnum.TRAINER);
            ...
            
            // 3) 센터 저장 및 센터 트레이너로 등록
            Center center = saveCenterFirstReqeust.toEntity();
            center.setCenterStatus(CenterStatus.PENDING); // 승인 중
            ...
            ...
            centerTrainerRepository.save(CenterTrainer.create(saveCenter, trainer, CenterTrainerRole.OWNER, REQUESTED)); // 센터 오너이며 아직 요청 중

            
            // 4)사업자 등록증 저장
            try {
                List<UploadFileDto> uploadFileDtos = saveCenterFirstReqeust.getUploadFiles();
                fileService.linkToReferenceIdList(uploadFileDtos, saveCenter.getId(), FileType.BUSINESS_REGISTRATION_CERTIFICATE); //
            } catch (Exception e) {
                log.error(e.getMessage());
                throw new RestApiException(INTERNAL_SERVER_ERROR);
            }

            // 5) 센터장 가입 알림 메일 발송 (사업자 등록증 이미지 첨부)
            try {
                List<UploadFileDto> businessCertFiles = fileService.findUploadFilesByReferenceIdAndTypeAndStatusForMail(
                        saveCenter.getId(), FileType.BUSINESS_REGISTRATION_CERTIFICATE, FileStatus.SAVED, Sort.by(Sort.Direction.ASC, "ord")
                );

                SendSaveCenterEmail sendSaveCenterEmail = SendSaveCenterEmail.create(saveCenterFirstReqeust.getCenterName(), saveCenterFirstReqeust.getCenterLicenseNumber(), saveCenterFirstReqeust.getCenterMasterName()
                        , saveCenterFirstReqeust.getCenterMasterTel(), saveCenterFirstReqeust.getCenterIntro(), saveCenterFirstReqeust.getCenterTimeCode(), saveCenterFirstReqeust.getCenterAddress(), saveCenterFirstReqeust.getCenterAddressDetail()
                        , saveCenterFirstReqeust.getCenterPostcode(), saveCenterFirstReqeust.getCenterLat(), saveCenterFirstReqeust.getCenterLng(), saveCenterFirstReqeust.getUploadFiles()
                );
                emailService.sendSaveCenterEmail(
                        findUser.getUserName(),
                        sendSaveCenterEmail,
                        businessCertFiles
                );
            } catch (Exception e) {
                log.error("센터장 가입 알림 메일 발송 실패: {}", e.getMessage());
            }

        } catch (Exception e) {
            throw new RestApiException(INTERNAL_SERVER_ERROR);
        }
    }

 

5. 디버깅을 진행해보니 '센터장 가입 알림 메일 발송'에서 간헌절으로 속도가 느려짐을 캐치했다.

 

6. 운영에서는 서버속도가 빨라 dev나 qa에서는 충분히 타임아웃 등의 실패 요소가 숨어있어 보였다.

 

7. 이럴 때 사용하기 좋은 방법이 있다.

 

설명 1. Before/ After

Before — 직접 호출 방식

@Service
@RequiredArgsConstructor
public class UserService {
    private final UserRepository userRepository;
    private final EmailService emailService;
    private final SlackService slackService;
    private final StatisticsService statisticsService;

    @Transactional
    public void signUp(SignUpRequest request) {
        User user = userRepository.save(new User(request));
        emailService.sendWelcomeMail(user);      // 부수 효과 1
        slackService.notifyNewUser(user);         // 부수 효과 2
        statisticsService.increaseSignUpCount();  // 부수 효과 3
    }
}

 

signUp() 호출
  ↓
① User 저장
  ↓ (대기)
② 환영 메일 발송 (예: 2초)
  ↓ (대기)
③ 슬랙 알림 (예: 1초)
  ↓ (대기)
④ 통계 증가 (예: 0.5초)
  ↓
응답 반환 (총 3.5초+)
  • UserService가 이메일, 슬랙, 통계까지 다 알고 있음 (의존성 폭발)
  • 알림 하나 추가할 때마다 UserService 수정 필요
  • 이메일 발송이 느리면 회원가입 응답도 느려짐
  • 슬랙 API가 죽으면 회원가입 자체가 실패할 수 있음

After — 이벤트 방식

@Service
@RequiredArgsConstructor
public class UserService {
    private final UserRepository userRepository;
    private final ApplicationEventPublisher eventPublisher;

    @Transactional
    public void signUp(SignUpRequest request) {
        User user = userRepository.save(new User(request));
        eventPublisher.publishEvent(new UserSignedUpEvent(user));
    }
}

 

  • UserService는 "회원가입 했음"만 알리고, 나머지는 각 리스너가 알아서 처리
Spring이 애플리케이션 시작 시점에 @EventListener 메서드들을 스캔해서 
"이벤트 타입 → 리스너 목록" 매핑 테이블을 만들어둔다. 
publishEvent()가 호출되면 이 테이블에서 해당 이벤트 타입의 리스너들을 찾아 호출한다.

이벤트 방식의 처리 흐름 (옵션별)

이벤트는 세 가지 모드로 동작할 수 있고, 어떤 어노테이션을 붙이느냐로 결정된다.

① 기본 (@EventListener) — 동기 + 같은 트랜잭션

signUp() 호출
  ↓
① User 저장 (트랜잭션 시작)
  ↓
② publishEvent() 호출
   ├─ WelcomeEmailListener 실행 (대기)
   ├─ SlackListener 실행 (대기)
   └─ StatisticsListener 실행 (대기)
  ↓
③ 트랜잭션 커밋
  ↓
응답 반환
  • 문제: 리스너들이 같은 트랜잭션 안에서 동기로 돕니다. Before와 사실상 동일하고, 게다가 메일 발송 후 트랜잭션이 롤백되면 메일은 이미 나간 상태가 된다.

 

② @TransactionalEventListener(AFTER_COMMIT) — 동기, 커밋 후

signUp() 호출
  ↓
① User 저장
  ↓
② publishEvent() 호출 (이벤트는 일단 큐에 보관됨)
  ↓
③ 트랜잭션 커밋 ✓
  ↓
④ 보관된 이벤트의 리스너들 실행
   ├─ WelcomeEmailListener (대기)
   ├─ SlackListener (대기)
   └─ StatisticsListener (대기)
  ↓
응답 반환
  • 롤백 시 메일이 안 나가는 문제는 해결됐지만, 여전히 순차 실행이라 응답이 느리다.

 

③ @Async + @TransactionalEventListener(AFTER_COMMIT) — 비동기, 커밋 후

signUp() 호출
  ↓
① User 저장
  ↓
② publishEvent() (큐에 보관)
  ↓
③ 트랜잭션 커밋 ✓
  ↓
④ 리스너들을 별도 스레드풀로 던지고 즉시 리턴
  ↓
응답 반환 ⚡ (빠름)

  [백그라운드에서 병렬로]
  ├─ Thread-1: WelcomeEmailListener
  ├─ Thread-2: SlackListener
  └─ Thread-3: StatisticsListener
  • 이게 실무에서 가장 많이 쓰는 형태. 본질 작업(회원가입)은 즉시 응답하고, 부수 효과는 백그라운드에서 병렬 처리된다.

  ‼️ 이 경우 3번 방식에 대해 주의

@Async + ApplicationEventPublisher는 "단일 JVM 안에서만" 동작합니다. 이중화/다중 서버 환경에서는 발행한 서버에서만 리스너가 실행됩니다.

		[Load Balancer]
               /            \
         [Server A]      [Server B]
         (JVM A)         (JVM B)
         
         
         
         
Server A:
  signUp() → publishEvent(UserSignedUpEvent)
    ↓
  Server A의 리스너들만 실행됨
    ├─ WelcomeEmailListener ✓
    ├─ SlackListener ✓
    └─ StatisticsListener ✓

Server B: 아무 일도 안 일어남

서버가 이중화 된 경우에는 A에서 리스너를 사용하면 문제가 없습니다.

 

다만

[웹 서버 A] — API 받는 역할
[배치 서버 B] — 메일 발송 전담

 

문제 케이스: 이벤트를 받는 서버가 발행 서버와 달라야 할 때

예: 주문 서버에서 OrderPlacedEvent를 발행하고, 재고 서버·결제 서버·알림 서버가 각각 받아야 하는 마이크로서비스 구조.

문제 케이스 2: 처리 보장이 필요한 경우

@Async 리스너가 실행되는 도중 서버가 죽으면 이벤트는 그냥 사라집니다. 메모리에만 있었으니까요. "메일 발송 100% 보장"이 필요하면 이걸로는 안 됩니다.

 

해결책: 외부 메시지 브로커 사용

서버 간 이벤트 전파가 필요하면 메시지 큐/브로커를 도입해야 한다.

도구특징사용 시점
Redis Pub/Sub 간단, 빠름, 메시지 보장 X 캐시 무효화 같은 가벼운 신호
RabbitMQ 메시지 보장, 라우팅 유연 일반적인 비동기 작업 큐
Kafka 대용량, 이벤트 영속성, 재처리 가능 이벤트 스트리밍, MSA
AWS SQS/SNS 관리형, 인프라 부담 적음 AWS 환경

패턴: Spring 이벤트 + 외부 브로커 조합

실무에서 자주 쓰는 패턴입니다.

// 1. 도메인 로직: Spring 이벤트 발행 (JVM 내부)
@Service
public class UserService {
    @Transactional
    public void signUp(...) {
        userRepository.save(user);
        eventPublisher.publishEvent(new UserSignedUpEvent(...));
    }
}

// 2. 브릿지 리스너: 트랜잭션 커밋 후 외부 브로커로 전달
@Component
public class UserEventBridge {
    
    @TransactionalEventListener(phase = AFTER_COMMIT)
    public void publishToKafka(UserSignedUpEvent event) {
        kafkaTemplate.send("user-signed-up", event);
    }
}

// 3. 다른 서버의 컨슈머가 받아서 처리
@Component
public class WelcomeEmailConsumer {
    
    @KafkaListener(topics = "user-signed-up")
    public void handle(UserSignedUpEvent event) {
        emailService.sendWelcomeMail(...);
    }
}

 

판단 기준: "이 이벤트가 발행된 서버 안에서만 처리되어도 비즈니스적으로 문제 없는가?"

  • 회원가입 후 메일 발송 → 어느 서버가 처리하든 OK → Spring 이벤트로 충분
  • 모든 서버의 캐시를 갱신해야 함 → Spring 이벤트로 부족 → Redis Pub/Sub 필요

 

문제 케이스 2번에 대해서는 어떤 서버든 실패가 나올 수 있기 때문에, 차라리 이벤트를 보내기 전에 아웃박스로 진행하려한다.

-> 이벤트 테이블을 만들고, 이벤트를 생성한 로그를 남긴 뒤에 완료시에 SUCCESS 값을 남겨준다.

-> 추가로 배치를 만들어  EMAIL이 FAIL된 경우 따로 처리하도록 진행을 돕는 것도 방법이 된다.

 

아웃박스 패턴에 대해서는 다음 글에서 처리하려한다.

 


💁 처리 현황 

 

          // 5) 센터장 가입 알림 메일 outbox 적재 + AFTER_COMMIT 발송 트리거
            //    실패 시 외부 catch에서 INTERNAL_SERVER_ERROR로 변환되어 트랜잭션이 롤백된다.
            MailOutboxPayload payload = MailOutboxPayload.builder()
                    .recipientUserName(findUser.getUserName())
                    .centerId(saveCenter.getId())          // 발송 시점에 PII 복호화/파일 lookup
                    .centerName(center.getCenterName())
                    .build();
            String json = objectMapper.writeValueAsString(payload);

            MailOutbox outbox = MailOutbox.create(
                    "Center",                  // aggregateType
                    saveCenter.getId(),        // aggregateId
                    "CENTER_REGISTERED",       // eventType
                    json
            );
            MailOutbox saved = mailOutboxRepository.save(outbox);   // 같은 TX1
            eventPublisher.publishEvent(new CenterRegisteredEvent(saved.getId()));

        } catch (Exception e) {
            throw new RestApiException(INTERNAL_SERVER_ERROR);
        }

 

아웃박스 테이블에 create 및 save후에 이벤트를 전달

 

public record CenterRegisteredEvent(Long outboxId) {}

...

@Component
@RequiredArgsConstructor
public class MailOutboxEventListener {
    private final MailDispatcher mailDispatcher;

    @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
    public void onCenterRegistered(CenterRegisteredEvent event) {
        mailDispatcher.dispatch(event.outboxId());   // 다른 빈 → @Async 발화
    }
}

....

@Async("mailExecutor")
public void dispatch(Long outboxId) {
    if (!txService.claim(outboxId)) return;        // 단일 진입 보장 (★ 2회 발송 방지)

    MailOutbox outbox = outboxRepository.findById(outboxId).orElse(null);
    if (outbox == null) return;

    try {
        MailOutboxPayload p = objectMapper.readValue(outbox.getPayload(), MailOutboxPayload.class);
        Center center = centerRepository.findById(p.getCenterId());
        if (center == null) {
            throw new IllegalStateException("Center not found: " + p.getCenterId());
        }

        List<UploadFileDto> files = fileService.findUploadFilesByReferenceIdAndTypeAndStatusForMail(
                p.getCenterId(),
                FileType.BUSINESS_REGISTRATION_CERTIFICATE,
                FileStatus.SAVED,
                Sort.by(Sort.Direction.ASC, "ord"));

        // 평문 PII는 발송 시점에 복호화 (outbox에는 안 들어감)
        SendSaveCenterEmail dto = SendSaveCenterEmail.create(
                center.getCenterName(),
                encryptUtil.decryptLicenseNumber(center.getCenterLicenseNumber()),
                encryptUtil.decryptName(center.getCenterMasterName()),
                encryptUtil.decryptPhone(center.getCenterMasterTel()),
                center.getCenterIntro(),
                center.getCenterTimeCode(),
                center.getCenterAddress(),
                center.getCenterAddressDetail(),
                center.getCenterPostcode(),
                center.getCenterLat(),
                center.getCenterLng(),
                files
        );

        emailService.sendSaveCenterEmail(p.getRecipientUserName(), dto, files);
        txService.markSent(outboxId);
    } catch (Exception e) {
        log.error("메일 발송 실패 outboxId={}", outboxId, e);
        txService.markFailed(outboxId, e.getMessage());
    }
}

 

공지사항
최근에 올라온 글
최근에 달린 댓글
Total
Today
Yesterday
링크
«   2026/06   »
1 2 3 4 5 6
7 8 9 10 11 12 13
14 15 16 17 18 19 20
21 22 23 24 25 26 27
28 29 30
글 보관함