website: 모집 켜기/끄기 기능과 구독자 안내메일 발송 구현
모집 상태를 켜고 끄는 어드민 기능에 구독자 전체에게 안내메일을 보내는 로직을 붙이면서, 동시 요청 중복 발송 문제와 실제 SMTP 발송 검증까지 다룬 작업을 정리합니다.
요약
2026년 7월 21일 website 레포의 dev 브랜치에 머지된 PR #126은 "[BE] #124 모집 관리 — 모집 켜기/끄기 + 구독자 안내메일 발송"이라는 커밋 메시지로 요약됩니다. 19개 파일이 변경되었고 817줄이 추가, 2줄이 삭제되었습니다. RecruitmentManagementService, RecruitmentManagementController 등 새 도메인 패키지가 통째로 생겼고, 테스트 코드만 500줄 넘게 추가된 걸 보면 이번 작업의 무게중심이 어디에 있었는지 짐작할 수 있습니다.
배경 및 목적
동아리 모집 공고가 열리면 미리 구독해둔 사람들에게 이메일로 알려주는 기능이 필요했습니다. 겉보기엔 "상태 플래그 하나 켜고 메일 뿌리기"로 단순해 보이지만, 실제로 파고들면 몇 가지 까다로운 지점이 있습니다.
- 어드민이 "모집 켜기" 버튼을 실수로 두 번 누르거나, 여러 관리자가 거의 동시에 누르면 구독자 전원이 메일을 중복으로 받을 수 있다.
- 구독자가 수백 명이면 발송 루프가 끝날 때까지 요청 스레드가 붙잡혀 있어서 응답이 늦어지고, 심하면 게이트웨이 타임아웃까지 걸릴 수 있다.
- 구독자 목록 중 한 명 주소가 이상해도 나머지 구독자 발송까지 막히면 안 된다.
이 세 가지를 각각 멱등성 가드, 비동기 이벤트 발행, 개별 예외 처리로 풀어낸 게 이번 커밋의 핵심입니다.
구현 내용
변경된 파일
주요 파일만 추리면 다음과 같습니다.
RecruitmentManagementController.java/RecruitmentManagementService.java— 신규 도메인 로직RecruitmentStatus.java/RecruitmentStatusRepository.java— 싱글턴 상태 엔티티RecruitmentOpenedEvent.java/RecruitmentOpenEmailEventListener.java— 비동기 발송을 위한 이벤트 구조dto/RecruitmentStatusResponse.java,dto/RecruitmentStatusUpdateRequest.javaEmailService.java,EmailType.java,templates/email/recruitment-open.html— 메일 발송 인프라 확장- 테스트 3종:
RecruitmentManagementControllerTest,RecruitmentManagementServiceTest,RecruitmentOpenEmailHttpEndToEndIntegrationTest application.yml,.env.example계열 —public-site-url설정 추가
싱글턴 상태 엔티티
모집 켜기/끄기 상태는 사이트 전체에 하나뿐이라 여러 행을 관리할 필요가 없습니다. RecruitmentStatus는 id를 항상 1L로 고정해서 두 번째 행이 생길 수 없게 만들었습니다.
public static final Long SINGLETON_ID = 1L;
@Id
private Long id = SINGLETON_ID;
@Column(nullable = false)
private boolean open = false;
private LocalDateTime openedAt;
멱등성 가드와 synchronized
이번 구현에서 가장 신경 쓴 부분은 open() 메서드입니다.
public synchronized RecruitmentStatusResponse open() {
RecruitmentStatus status = findOrCreate();
if (!status.isOpen()) {
status.markOpened();
statusRepository.save(status);
eventPublisher.publishEvent(new RecruitmentOpenedEvent());
}
return toResponse(status);
}
"닫힘→열림 전이일 때만" 이벤트를 발행하도록 if (!status.isOpen()) 한 줄로 중복 발송을 막았습니다. 문제는 이 읽고-확인하고-쓰는 과정이 원자적이지 않으면 여러 요청이 동시에 들어왔을 때 다 같이 isOpen() == false를 읽어버려 이벤트가 여러 번 발행될 수 있다는 점입니다. 그래서 메서드 전체를 synchronized로 감쌌습니다.
이 앱이 SQLite 단일 파일에 HikariCP 커넥션 풀 크기를 1로 운영하는, 애초에 단일 인스턴스 배포를 전제한 구조라 인스턴스 레벨 락만으로 충분하다고 판단했습니다. 여러 인스턴스로 수평 확장하게 되면 @Version 같은 DB 레벨 락으로 다시 가야 한다는 점을 주석으로 명시해뒀습니다. close()도 같은 락(this)을 공유하도록 synchronized를 걸어서, 열기/닫기 중 하나가 완전히 끝난 뒤에만 다음 상태 전이가 일어나게 했습니다.
발송을 컨트롤러 스레드에서 떼어내기
발송 자체는 open() 안에서 동기로 하지 않고 이벤트만 발행합니다. 구독자가 수십~수백 명이면 SMTP 건당 왕복(타임아웃 5초)이 누적되어 요청이 오래 걸릴 수 있기 때문입니다. save()가 이미 커밋된 뒤라 멱등 가드는 이 시점부터 바로 유효하므로, 실제 발송은 RecruitmentOpenEmailEventListener의 @Async 스레드가 이어받아도 안전합니다.
void sendToAllSubscribers() {
try {
subscriptionRepository.findAll().forEach(subscription -> {
try {
emailService.sendRecruitmentOpenEmail(subscription.getEmail(), publicSiteUrl);
} catch (EmailSendException e) {
// 계속 진행 — 실패는 email_log로 추적
}
});
} catch (Exception e) {
log.error("모집 안내메일 배치 발송이 구독자 개별 재시도 범위 밖에서 중단됐습니다", e);
}
}
바깥 try/catch가 없으면 findAll() 자체가 던지는 예외(DB 순간 장애 등)가 @Async 스레드에서 그대로 터져 Spring 기본 핸들러가 로그 한 줄만 남기고 끝나버립니다. email_log에도 아무 흔적이 안 남아 발송 실패를 아무도 알아채지 못하는 상황이 생길 수 있어서, 배치 전체를 감싸 명시적으로 에러 로그를 남기도록 했습니다.
실제 SMTP로 검증하는 E2E 테스트
RecruitmentOpenEmailHttpEndToEndIntegrationTest는 Testcontainers로 Mailpit(가짜 SMTP 서버)을 띄워서 목이 아닌 실제 메일 발송 경로를 검증합니다. 15명의 구독자에게 6개의 동시 요청을 날려도 정확히 1통씩만 가는지, 메일 본문의 링크가 어드민 도메인이 아닌 공개 사이트 주소를 가리키는지까지 실제 HTTP 응답으로 확인합니다.
assertThat(detail.get("HTML").asText())
.contains("href=\"https://dev.likelion-khu.com\"");
기술적 의사결정
frontend-base-url과 public-site-url 분리
리뷰 과정에서 발견된 버그 하나가 눈에 띕니다. 기존에 있던 app.frontend-base-url은 어드민 초대·비밀번호 재설정 메일 전용으로 admin.likelion-khu.com을 가리키는데, 모집 안내메일에도 이 값을 그대로 쓸 뻔했습니다. 일반 구독자는 어드민 페이지가 아니라 공개 사이트로 가야 하므로, app.public-site-url이라는 별도 설정을 새로 추가해 용도를 분리했습니다.
@Value("${app.public-site-url}")
private String publicSiteUrl;
이런 종류의 혼용은 코드만 봐서는 잘 안 보이고, 실제로 발송된 메일의 링크를 눈으로 확인하는 E2E 테스트가 있어야 잡아낼 수 있는 문제였습니다. 위에서 소개한 Mailpit 기반 테스트가 이 버그를 실제로 잡아낸 셈입니다.
synchronized vs DB 레벨 락
동시성 제어 방법으로 synchronized와 @Version 기반 낙관적 락 중에서 전자를 선택했습니다. 애플리케이션이 SQLite + 커넥션 풀 1개로 단일 인스턴스 운영을 전제하고 있어서, JVM 레벨 락만으로도 read-check-write를 원자적으로 만들기에 충분하다고 판단했기 때문입니다. @Version을 썼다면 낙관적 락 예외를 잡아서 재시도하는 로직이 추가로 필요했을 텐데, 지금 규모에서는 과한 설계라고 봤습니다. 다만 이 선택은 "단일 인스턴스"라는 전제가 깨지는 순간 무효가 되므로, 나중에 수평 확장을 하게 되면 반드시 다시 검토해야 할 부분으로 남겨뒀습니다.
배운 점 및 개선점
동시성 테스트를 짜는 게 생각보다 까다로웠습니다. RecruitmentManagementControllerTest의 open_ConcurrentTriggers_SendsOnlyOnce 테스트를 보면, CountDownLatch를 두 개(ready, start) 써서 여러 스레드가 정확히 같은 타이밍에 요청을 쏘도록 만들었습니다. 단순히 순차적으로 두 번 호출하는 테스트로는 read-check-write 경합 자체가 재현되지 않는다는 걸 확인하고 나서야 이 구조로 바꿨습니다.
또 하나 배운 점은, 워커 스레드에서 MockMvc 요청을 보낼 때 SecurityContextHolder가 기본적으로 스레드 로컬이라 테스트 스레드의 인증 정보를 워커 스레드가 못 본다는 점이었습니다. @WithMockAdminUser가 심어둔 SecurityContext를 테스트 스레드에서 미리 꺼내 워커 스레드에 직접 복사해주는 방식으로 해결했습니다.
앞으로는 email_log 실패 건수가 임계치를 넘으면 알림을 보내는 기능(코드 주석에 언급된 #113)을 붙일 계획이고, 여러 인스턴스로 확장할 시점이 오면 synchronized 락을 DB 레벨 락으로 교체하는 작업도 필요합니다.