2026. 2. 13. 18:20ㆍ개인 프로젝트, 작품
1. 배경: 캡처를 안 해놨다
스뮤클럽(SMU-CLUB)은 교내 동아리 지원/관리 서비스입니다.
이 서비스에는 4개의 스케줄러가 돌고 있습니다.
- 동아리 모집 자동 마감 배치
- 개인정보보호를 위한 30일 이후 동아리 멤버 정보 삭제 배치
- 동아리 합/불 통보 이메일 재전송 배치
- Oracle Storage 고아파일 삭제 배치
서비스 오픈 전 테스트를 하던 중, 스케줄러가 동작하지 않는 걸 우연히 발견한 적이 있습니다.
당시에는 고치는 데 급급했고, 솔직히 말하면 로그를 캡처해둘 생각을 못 했습니다.
이후 "로그를 저장해야된다는 것도 몰라서 이런일이 다시 일어나지 말아야겠다"라고 생각하고 로그를 .txt 파일로 저장하도록 하였습니다.
블로그를 쓰려고 보니 증빙할 게 없더라고요. 😅
2. 로그를 다시 열어봤더니
그런데 오랜만에 프로젝트를 관리하면서 로그를 다시 열어봤습니다.

2026-03-11T15:10:00.001Z INFO [scheduling-1] ExpiredClubMemberCleanupScheduler : [스케줄러] 만료된 동아리 회원 정리 스케줄러 시작
2026-03-11T15:10:00.008Z INFO [scheduling-1] ExpiredClubMemberCleanupScheduler : 만료된 동아리 회원이 없습니다.
2026-03-11T15:10:01.000Z INFO [scheduling-1] ExpiredClubMemberCleanupScheduler : [스케줄러] 만료된 동아리 회원 정리 스케줄러 시작
2026-03-11T15:10:01.002Z INFO [scheduling-1] ExpiredClubMemberCleanupScheduler : 만료된 동아리 회원이 없습니다.
2026-03-11T15:10:02.000Z INFO [scheduling-1] ExpiredClubMemberCleanupScheduler : [스케줄러] 만료된 동아리 회원 정리 스케줄러 시작
2026-03-11T15:10:02.002Z INFO [scheduling-1] ExpiredClubMemberCleanupScheduler : 만료된 동아리 회원이 없습니다.
...
// 15:10:00 ~ 15:10:59까지 매초 반복. 총 60번 실행.
// (서버 로그는 UTC 기준이라 15:10으로 찍혀 있지만, KST로는 00:10입니다.)
만료된 동아리 회원을 정리하는 스케줄러가 매일 새벽 00시 10분에 딱 한 번 실행되어야 하는데,
00:10:00부터 00:10:59까지 매초 60번 실행되고 있었습니다.
원인은 cron 표현식의 실수였습니다.
// ❌ 잘못된 cron: 초 필드가 *이라 매초 실행됨
@Scheduled(cron = "* 10 0 * * *", zone = "Asia/Seoul")
// ✅ 올바른 cron: 00시 10분 00초에 1회 실행
@Scheduled(cron = "0 10 0 * * *", zone = "Asia/Seoul")
이번엔 마감 대상이 없어서 60번 모두 "만료된 동아리 회원이 없습니다."만 찍히고 끝났습니다.
서비스는 아무 문제 없이 돌아간 것처럼 보였고, 로그를 직접 까보지 않았다면 아무도 몰랐을 겁니다.
로그를 보면 다 나온다
개발하다 보면 문제가 생겼을 때 항상 한쪽은 배제하고 다른 곳에서 원인을 찾으려고 하는 것 같습니다.
"코드 로직이 잘못됐나?", "API 호출이 실패했나?" 같은 쪽으로 먼저 가게 되죠.
근데 이번에 깨달은 건, 로그를 보면 모든 게 다 나온다는 겁니다.
과거의 스케줄러 미동작도 당시 로그를 남겨뒀다면 더 빨리 잡을 수 있었을 거고, 이번 cron 버그도 로그를 보고 발견했습니다.
그리고 여기서 중요한 질문이 하나 생겼습니다.
이번엔 단순 중복 실행이라 큰 문제가 없었지만, 만약 이게 진짜 실패였다면? 에러 한 줄 남기고 끝이었을 것이다.
스케줄러는 일반 API와 다릅니다.
API는 사용자가 즉시 에러를 마주하지만, 배치 작업은 사용자 인터랙션이 없으니 에러가 로그 한 줄 남기고 자취를 감춥니다.
만약 프로덕션 환경에서 스케줄러가 실패한다면, 나는 언제 그 사실을 알 수 있을까?
3. 매번 try-catch를 복붙할 순 없잖아
필요한 건 명확했습니다. 스케줄러가 실패하면 즉시 개발자에게 알려주는 시스템!
근데 문제는, 지금 스케줄러가 4개이고 앞으로 더 늘어날 수 있다는 겁니다.
만약 알림을 단순하게 구현한다면 이렇게 됩니다.
// 이걸 스케줄러마다 반복해야 됨..
@Scheduled(cron = "0 0 0 * * *")
public void batchMethodA() {
try {
// ... 비즈니스 로직
} catch (Exception e) {
discordWebhook.send("jobA 실패: " + e.getMessage()); // 반복!
}
}
@Scheduled(cron = "0 10 0 * * *")
public void batchMethodB() {
try {
// ... 비즈니스 로직
} catch (Exception e) {
discordWebhook.send("jobB 실패: " + e.getMessage()); // 또 반복!
}
}
// ... C, D, E, F ...
4개밖에 없는데 뭘 고민해? 라고 할 수도 있지만, 서비스를 유지/보수하면서 배치가 추가될 가능성을 고려해야합니다.
반복적인 try-catch + 알림 코드를 매번 복붙하는 건 개발자가 피해야 할 행동이라고 생각했습니다.
비슷한 고민을 한 팀들은 어떻게 해결했는지 테크 블로그를 찾아봤습니다.
4. 레퍼런스에서 얻은 힌트
🍜 우아한형제들: "How" — 커스텀 어노테이션이라는 해결법
우아한형제들의 시의적절한 커스텀 어노테이션 글에서, Thiiing 서비스의 약 40개 스케줄러에서 서버 증설 시 중복 실행 문제를 @TaskOccupy라는 커스텀 어노테이션으로 해결한 사례를 보았습니다.
어노테이션은 내부 로직이 명확하지 않으면 플로우를 이해하기 어렵게 하고,
하물며 '커스텀'이라면 그 부담은 더욱 커진다고 언급하고 있었지만, 핵심 원칙은 명확했습니다.
"적재적소에 사용된다면, 불필요한 반복 코드가 줄고 개발자는 비즈니스 로직에만 집중할 수 있다."
여기서 인사이트를 얻었습니다.
우리 서비스로 돌아와서 생각해보니, 스케줄러 실패 감지도 똑같이 반복되는 공통 관심사였습니다.
🥗 마켓컬리: "Why & What" — 뭘 알림 대상으로 삼을 것인가
마켓컬리의 배송 상품팀의 알람 문화 글에서는 알람 피로도(Alert Fatigue) 문제를 다루고 있었습니다.
의도된 예외까지 웹훅으로 알림이 뜨면, 개발자는 대응할 게 없는 알람에 피로감을 느끼게 됩니다.
나중에 진짜 중요한 에러가 발생해도 확인을 안 하게 되는 상황이 생긴다는 내용이었습니다.
👍 요약본!
로그 레벨에 대한 명확한 규칙을 정하고, 무분별한 에러 로그, 즉 가짜 에러들을 줄여나가는 가정입니다.
1. 가짜 에러

2. 진짜 에러

3. 외부시스템 에러

4. 어플리케이션 계층에서 처리할 수 없는 에러

이 글을 보고 "진짜 에러일 때만 알림을 받을 수 있게 하는 게 좋겠다!"는 확신을 얻었습니다.
두 힌트의 조합
우아한형제들은 "어떻게(How)" 커스텀 어노테이션으로 공통 관심사를 분리하는 방법을 알려주었고,
마켓컬리는 "왜(Why)" 그리고 "무엇을(What)" 진짜 에러만 알림 대상으로 삼아야 하는 이유를 알려주었습니다.
두 글에서 힌트를 얻어, 이런 모습을 목표로 설계했습니다.
@Scheduled(cron = "0 0 0 * * *")
@DiscordAlert("동아리 모집 마감") // 어노테이션 하나로 try-catch 해방
public void batchMethod() {
// 비즈니스 로직에만 집중 가능
}
- 목적: 스케줄러 실패 시 즉시 Discord 알림
- 대상: 모든 중요 스케줄러 메서드
- 효과: 반복 코드 제거 + 비즈니스 로직 집중
- 원칙: 진짜 예외가 발생했을 때만 알림 (Alert Fatigue 방지)
5. 구현: @DiscordAlert 어노테이션
5-1. 전체 구조

5-2. @DiscordAlert 어노테이션 정의
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface DiscordAlert {
String value(); // 스케줄러 이름 (알림 메시지에 표시)
}
5-3. AOP Aspect 구현
@Aspect
@Component
@RequiredArgsConstructor
@Slf4j
public class DiscordAlertAspect {
private final DiscordWebhookService discordWebhookService;
@Around("@annotation(discordAlert)")
public Object handleDiscordAlert(ProceedingJoinPoint joinPoint, DiscordAlert discordAlert) throws Throwable {
String jobName = discordAlert.value();
try {
Object result = joinPoint.proceed();
// 정상 종료 시 아무것도 하지 않음 (Alert Fatigue 방지)
return result;
} catch (Exception e) {
// 진짜 예외가 발생했을 때만 알림
String errorMessage = String.format(
"🚨 **[스케줄러 실패 알림]**\n" +
"━━━━━━━━━━━━━━━\n" +
"📌 작업명: %s\n" +
"❌ 에러: %s\n" +
"🕐 발생 시간: %s\n" +
"━━━━━━━━━━━━━━━",
jobName,
e.getMessage(),
LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"))
);
try {
discordWebhookService.send(errorMessage);
} catch (Exception webhookException) {
// 웹훅 전송 실패가 원래 예외를 삼키면 안 됨
log.error("[DiscordAlert] 웹훅 전송 실패: {}", webhookException.getMessage());
}
throw e; // 원래 예외는 반드시 다시 던짐
}
}
}
여기서 신경 쓴 부분이 몇 가지 있습니다.
첫째, 정상 종료 시에는 아무것도 하지 않습니다.
마켓컬리에서 배운 Alert Fatigue 방지 원칙입니다. "성공했습니다" 알림이 매일 4번씩 오면 결국 안 보게 됩니다.
둘째, 웹훅 전송 실패가 원래 예외를 삼키면 안 됩니다.
Discord 서버가 죽어있다고 해서 스케줄러의 원래 에러 정보까지 날아가면 안 되니까요.
웹훅 실패는 별도로 로그만 남기고, 원래 예외는 반드시 다시 던집니다.
셋째, throw e로 원래 예외를 다시 던집니다.
AOP가 예외를 잡아먹으면 호출한 쪽에서는 정상 종료된 줄 알게 됩니다.
알림은 보조 수단이지, 에러 핸들링의 주체가 되면 안 됩니다.
5-4. Discord Webhook Service
@Service
@Slf4j
public class DiscordWebhookService {
@Value("${discord.webhook.url}")
private String webhookUrl;
private final RestTemplate restTemplate = new RestTemplate();
public void send(String message) {
Map<String, String> body = Map.of("content", message);
try {
restTemplate.postForEntity(webhookUrl, body, String.class);
log.info("[Discord] 알림 전송 완료");
} catch (Exception e) {
log.error("[Discord] 알림 전송 실패: {}", e.getMessage());
throw e; // Aspect에서 처리하도록 다시 던짐
}
}
}
5-5. 적용 모습
@Component
@RequiredArgsConstructor
@Slf4j
class ExpiredClubMemberCleanupScheduler {
private final BatchClubMemberService batchClubMemberService;
@Scheduled(cron = "0 10 0 * * *", zone = "Asia/Seoul")
@DiscordAlert("만료된 동아리 회원 정리 스케줄러") // 이 한 줄이면 끝
public void cleanupExpiredClubMembers() {
log.info("[스케줄러] 만료된 동아리 회원 정리 스케줄러 시작");
List<ClubMember> expiredClubMembers = batchClubMemberService.findExpiredClubMembers();
if (expiredClubMembers.isEmpty()) {
log.info("만료된 동아리 회원이 없습니다.");
return;
}
log.info("[스케줄러] 총 {}명의 만료된 동아리 회원 정리 처리 시도", expiredClubMembers.size());
int result = batchClubMemberService.cleanupExpiredClubMembers(expiredClubMembers);
log.info("[스케줄러] 만료된 동아리 회원 정리 처리 완료 ({}건)", result);
}
}
기존에는 try-catch로 감싸고 catch 블록 안에서 discordWebhook.send()를 호출해야 했는데,
이제 @DiscordAlert 한 줄이면 AOP가 알아서 처리합니다.
스케줄러가 10개, 20개로 늘어나도 어노테이션 하나만 붙이면 되고, 비즈니스 로직에서 에러 알림 코드가 완전히 사라집니다.
6. 한계: 이걸로 모든 걸 잡을 수 있는 건 아니다
여기까지 쓰고 나면 "이야 이제 완벽하겠네!" 싶지만, 솔직히 그렇지 않습니다.
2장에서 다뤘던 cron 표현식 버그를 다시 떠올려보면, 60번 중복 실행되어도 매번 정상 종료되면 @DiscordAlert는 아무 알림도 보내지 않습니다.
당연합니다. 이 어노테이션은 예외가 발생했을 때 동작하는 구조니까요. 예외 없이 잘못 동작하는 케이스는 잡지 못합니다.
이런 걸까지 커버하려면 실행 횟수 모니터링이나 헬스체크 같은 추가적인 장치가 필요하고, 이건 향후 고도화 과제로 남겨두었습니다.
하지만 "스케줄러가 터졌는데 아무도 몰랐다"는 상황은 확실히 막을 수 있게 됐고,
가장 시급한 문제부터 해결했다는 점에서 의미가 있다고 생각합니다.
7. 배운 점
로그에 답이 있다
개발하다 보면 문제가 생겼을 때 항상 한쪽은 배제하고 다른 곳에서 원인을 찾으려고 합니다.
"코드 로직이 잘못됐나?", "외부 API가 문제인가?" 같은 쪽으로 먼저 가게 되죠.
이번 경험을 통해 깨달은 건, 로그를 보면 모든 게 다 나온다는 것이었습니다.
cron 버그도 로그를 보고 발견했고, 과거에 캡처를 안 해놨던 게 지금 블로그 쓸 때 얼마나 아쉬운지도 뼈저리게 느꼈습니다.
그런 의미에서 @DiscordAlert 같은 커스텀 어노테이션은 로그를 매번 직접 까보지 않아도 즉시 대응할 수 있게 해주는 수단입니다.
문제를 해결하고 싶으면 로그를 잘 보자. 그리고 로그를 더 잘 볼 수 있는 시스템을 만들자.
레퍼런스의 힘
혼자 고민했으면 한참 돌아갔을 문제를, 선배 개발자들의 글을 보고 방향을 잡을 수 있었습니다.
- 우아한형제들: 반복되는 공통 관심사를 커스텀 어노테이션으로 분리하는 방법을 배웠습니다.
- 마켓컬리: "진짜 에러일 때만 알림을 받는다"는 운영 문화의 인사이트를 얻었습니다.
완벽한 코드는 없다
버그를 만들지 않는 것보다, 버그를 빠르게 발견하는 시스템을 갖추는 게 더 중요합니다.
@DiscordAlert도 만능은 아니지만, 적어도 "터졌는데 아무도 몰랐다"는 상황은 막을 수 있습니다.
📌 다음 글 예고
다음은 스케줄러의 또 다른 문제였던
" 단일 트랜잭션의 함정에서 Chunk 처리까지 스케줄러 트랜잭션 최적화기"를 다루겠습니다.
올리브영의 배치 중단 사례, 트랜잭션 분할, 그리고 Real MySQL 인덱스 설계까지 이어집니다.
'개인 프로젝트, 작품' 카테고리의 다른 글
| 블록체인과 CBDC (1) | 2026.05.01 |
|---|---|
| Elasticsearch - BM25 (0) | 2026.03.22 |
| 단일 트랜잭션의 함정에서 Chunk 처리까지 - 스케줄러 트랜잭션 최적화하기 (0) | 2026.03.14 |
| 코코아톡 클론 코딩 (0) | 2022.09.20 |