1. 문제 발생 :
도커 / 쿠버네티스 컨테이너 개발과 운영이라는 책을 읽다가... 스케일 아웃 이라는 부분을 읽다가, 생각해보니까 저희 서비스에 있는 모듈들은 각각 스케쥴을 실행하게되어, 이벤트 발행, 임시데이터 삭제와 같은 로직을 모든 인스턴스가 실행 할 수 있겠다 라는 문제를 생각해 내어 이를 해결 해보고자 했다.
저희 서비스에있는 여러 스케쥴 요소들중 "회원 탈퇴 후 30일이 지나면 개인정보를 완전히 삭제한다"는 요구사항이 있었습니다. 간단하게 Spring의 @Scheduled 어노테이션을 사용하여 매일 새벽 5시에 실행되는 배치(Batch) 작업을 구현했습니다.
초기 코드 (UserWithdrawService.java)
@Service
@RequiredArgsConstructor
public class UserWithdrawService {
private final UserRepository userRepository;
private final UserWithdrawRepository userWithdrawRepository;
/**
* 매일 5시에 실행하여 탈퇴 후 30일(예시로 7일)이 지난 회원 정보를 완전히 삭제합니다.
* !!문제점: 여러 인스턴스가 있다면 모두 동시에 이 메서드를 실행합니다.!!
*/
@Scheduled(cron = "0 0 5 * * *")
@Transactional
public void deleteWithdrawnUsers() {
LocalDateTime cutoff = LocalDateTime.now().minusDays(7);
List<UserWithdraw> withdraws = userWithdrawRepository.findByWithdrawDateBefore(cutoff);
for (UserWithdraw withdraw : withdraws) {
userRepository.deleteById(withdraw.getUserId());
}
}
}
로컬 개발 환경에서는 아무 문제가 없었습니다. 하지만 이 코드가 여러 개의 인스턴스(혹은 쿠버네티스 Pod)에 배포되는 운영 환경에서는 심각한 문제를 야기합니다.

새벽 5시가 되면, 모든 인스턴스가 동시에 deleteWithdrawnUsers 메서드를 실행하려고 합니다. 이는 다음과 같은 문제로 이어집니다.
- 데이터 정합성 문제: 첫 번째 인스턴스가 사용자 A를 삭제하는 동안, 두 번째 인스턴스도 사용자 A를 삭제하려고 시도하며 오류가 발생할 수 있습니다.
- 리소스 낭비: 똑같은 작업을 여러 인스턴스가 중복으로 수행하여 DB에 불필요한 부하를 줍니다.
- 데드락 (Deadlock): 여러 트랜잭션이 동일한 리소스를 놓고 경쟁하면서 교착 상태에 빠질 위험이 있습니다.
이 문제를 해결하기 위해 분산 환경에서 단 하나의 인스턴스만이 스케줄링 작업을 수행하도록 보장하는 '분산 락(Distributed Lock)' 메커니즘이 필요했습니다.
2. 해결책 탐방: 다양한 분산 락 전략의 트레이드 오프
분산 락을 구현하는 데에는 여러 가지 방법이 있으며, 각각의 장단점을 비교하여 저희 프로젝트에 가장 적합한 방식을 선택해야 했습니다.
| 전략 | 설명 | 장점 | 단점 |
|---|---|---|---|
| Active -Standby 방식 |
환경 변수 등으로 하나의 인스턴스만 'Active'로 지정하여 스케줄러를 활성화하는 방식 | 개념이 단순하고 초기 구현이 쉬움 | SPOF(단일 장애점) 발생. Active 인스턴스가 다운되면 스케줄링 중단. 수동 조치 필요 |
| Quartz Scheduler |
JDBC-JobStore를 사용하여 클러스터링을 지원하는 강력한 스케줄러 라이브러리 | 기능이 풍부하고 안정적이며, 복잡한 스케줄링에 적합 | 설정이 복잡하고 무거움. 단순한 작업에는 과할 수 있음 (Overkill) |
| Redis 기반 분산 락 |
Redis의 SETNX 같은 명령어를 사용하여 락을 구현. 락을 획득한 인스턴스만 작업 수행 |
성능이 빠르고, 이미 Redis를 사용 중이라면 인프라 추가 비용 없음 | 락 획득/해제 로직을 직접 구현해야 하며, 노드 장애 시 락이 영원히 해제되지 않는 문제(TTL로 방지)를 고려해야 함 |
| Shed Lock (선택) |
DB 테이블, Redis 등 공유 저장소를 이용하는 어노테이션 기반의 경량 락 라이브러리 | 구현이 매우 간단함 (@SchedulerLock). 기존 DB를 그대로 활용 가능(추가 인프라 불필요). Spring Scheduler와 완벽하게 통합됨 |
스케줄러가 아닌 '락'에만 집중하므로 기능은 단순함 |
저희는 "단순성"과 "기존 인프라의 재활용"이라는 두 가지 가치에 주목했습니다. ShedLock은 복잡한 설정 없이 어노테이션 하나로 분산 락을 구현할 수 있고, 이미 사용 중인 RDBMS의 테이블을 락 저장소로 쓸 수 있어 추가적인 인프라(Redis 등) 의존성을 만들지 않는다는 점에서 가장 매력적인 선택지였습니다.
3. ShedLock 적용:
ShedLock을 적용하는 과정은 놀랍도록 간단합니다.
1) shedlock 테이블 생성
ShedLock이 락 정보를 저장할 테이블을 DB에 미리 생성해야 합니다.
CREATE TABLE shedlock (
name VARCHAR(64) NOT NULL,
lock_until TIMESTAMP(3) NOT NULL,
locked_at TIMESTAMP(3) NOT NULL,
locked_by VARCHAR(255) NOT NULL,
PRIMARY KEY (name)
);
2) UserWithdrawService에 @SchedulerLock 추가
이제 서비스 코드에 어노테이션 한 줄만 추가하면 됩니다.
// UserWithdrawService.java - 수정 후
@Scheduled(cron = "0 0 5 * * *")
@SchedulerLock(name = "deleteWithdrawnUsers", lockAtLeastFor = "PT5M", lockAtMostFor = "PT15M")
@Transactional
public void deleteWithdrawnUsers() {
// ... 기존 삭제 로직 ...
}
@SchedulerLock: 이 어노테이션이 붙은 스케줄링 작업은 분산 락을 사용합니다.name: 락의 고유 이름입니다.shedlock테이블의name컬럼에 저장되며, 이 이름이 같은 작업끼리 락을 경쟁합니다.lockAtMostFor: 락을 최대 얼마 동안 유지할지 설정합니다. 작업이 끝나지 않았더라도 이 시간이 지나면 락이 자동으로 해제됩니다. (노드 장애 대비)lockAtLeastFor: 락을 최소 얼마 동안 유지할지 설정합니다. 여러 서버의 시간이 미세하게 다를 경우를 대비하여, 너무 빨리 다른 노드가 락을 획득하는 것을 방지합니다.
이것만으로도 여러 인스턴스 중 단 하나만이 deleteWithdrawnUsers를 실행하도록 보장할 수 있습니다.
4. 설정을 공통 모듈로 분리하여 재사용성 높이기
ShedLock 적용은 성공했지만, 여기서 멈추지 않았습니다. 만약 다른 마이크로서비스에서도 분산 스케줄링이 필요하다면 어떨까요? 그때마다 ShedLock 의존성을 추가하고, LockProvider Bean을 설정하는 보일러플레이트 코드를 반복해야 할 겁니다. 이는 DRY(Don't Repeat Yourself) 원칙에 위배됩니다.
그래서 저희는 스케줄링과 관련된 모든 설정을 public:infra-scheduling이라는 공통 모듈로 분리했습니다.
1) infra-scheduling 모듈 생성 및 ShedLockConfig 작성
이 모듈은 ShedLock에 대한 의존성을 가지며, 모든 설정을 담은 @Configuration 클래스를 제공합니다.
package scheduling;
import net.javacrumbs.shedlock.core.LockProvider;
import net.javacrumbs.shedlock.provider.jdbctemplate.JdbcTemplateLockProvider;
import net.javacrumbs.shedlock.spring.annotation.EnableSchedulerLock;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.scheduling.annotation.EnableScheduling;
import javax.sql.DataSource;
@Configuration
@EnableScheduling // 1. 스케줄링 기능 활성화
@EnableSchedulerLock(defaultLockAtMostFor = "PT30S") // 2. ShedLock 활성화
public class ShedLockConfig {
// 3. LockProvider Bean 자동 설정
@Bean
public LockProvider lockProvider(DataSource dataSource) {
// 이 설정을 사용하는 메인 애플리케이션의 DataSource를 주입받아 LockProvider를 생성합니다.
return new JdbcTemplateLockProvider(dataSource);
}
}
2) 메인 애플리케이션에서 의존성 추가
이제 스케줄링이 필요한 auth 서비스의 build.gradle에 단 한 줄만 추가하면 됩니다.
// auth/build.gradle
dependencies {
// ...
implementation project(':public:infra-scheduling')
}
3) 메인 애플리케이션 코드 정리infra-scheduling 모듈이 @EnableScheduling과 @EnableSchedulerLock 설정을 모두 책임지므로, 메인 애플리케이션 클래스는 매우 깔끔해집니다.
package auth;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.context.annotation.ComponentScan;
// @EnableScheduling, @EnableSchedulerLock 모두 삭제!
@SpringBootApplication
@ComponentScan(basePackages = {"auth", "scheduling" /*, ... */}) // 공통 모듈 패키지 스캔 추가
public class AuthApplication {
public static void main(String[] args) {
SpringApplication.run(AuthApplication.class, args);
}
}
이제 어떤 서비스든 분산 스케줄링이 필요하면, infra-scheduling 모듈 의존성을 추가하고, 서비스 메서드에 @Scheduled와 @SchedulerLock 어노테이션만 붙이면 됩니다. 설정은 중앙에서 관리되므로 일관성 있고 유지보수가 용이해집니다.
5. 결론: 좋은 아키텍처는 문제를 해결하고, 더 나은 구조를 남긴다
분산 환경에서의 스케줄링 동시성 문제는 자칫 간과하기 쉽지만, 시스템의 안정성에 치명적인 영향을 줄 수 있습니다. 저희는 이 문제를 해결하는 과정에서 다음과 같은 교훈을 얻었습니다.
- 문제 인식: 분산 환경에서는 모든 것이 여러 번 실행될 수 있다는 가정을 해야 합니다.
- 적합한 도구 선택: ShedLock은 '간단한 분산 락'이라는 문제에 정확히 부합하는 가볍고 효율적인 도구였습니다.
- 구조 개선: 문제를 해결하는 데 그치지 않고, 관련 코드를 공통 모듈로 추상화하여 재사용성을 높이고 보일러플레이트를 제거했습니다. 이는 장기적으로 프로젝트의 생산성과 유지보수성을 크게 향상시킵니다.
이러한 과정을 통해 저희 시스템은 더욱 견고하고 확장 가능한 구조를 갖추게 되었습니다. 여러분도 비슷한 문제를 겪고 계신다면 ShedLock과 모듈화를 통한 접근 방식을 강력히 추천합니다.
'BindProject' 카테고리의 다른 글
| 전역 에러 핸들러로 try-catch를 걷어내기(2)_모듈간 참조 해결 (0) | 2025.07.23 |
|---|---|
| 전역 에러 핸들러로 try-catch를 걷어내기 (3) | 2025.07.23 |
| 2025-07-19 업체/룸 요구사항 정리 회의 (2) | 2025.07.19 |
| BIND PROJECT WEEKLY REPORT(7-1st) (0) | 2025.07.07 |
| BindPorject : SpringBoot 를 이용한 비동기이미지 처리 Flow 설명 (2) | 2025.07.01 |