티스토리 뷰
이메일 발송, 알림, 통계 집계, 쿠폰 발급 등 회원에게 불필요한 로직일 때, 내 로직이 느릴 때, 본질로직과 부수로직 분리, 리스너, 이벤트 리스너 개발(JAVA SPRING)
dev_0hoon 2026. 5. 5. 13:051. 헬스앱 사이드 프로젝트 진행 중 기획자에게 연락이 왔다.
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
- 로그인
- 향해플러스백엔드
- thymleaf
- hypertexttransferprotocol
- 스프링공부
- 리터럴
- reject
- rejectValue
- 백엔드 개발자 역량
- BindingResult
- React
- ArgumentResolver
- filter
- HTTP
- 컨트
- Intercepter
- 향해99
- 향해플러스
- 예외처리
- SpringBoot
- JPA
- 백엔드 개발자 공부
- Java
- react실행
- 항해플러스
- exception
- jpa api
- 스프링부트
- 항해99
- 인터셉터
| 일 | 월 | 화 | 수 | 목 | 금 | 토 |
|---|---|---|---|---|---|---|
| 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 |
