오늘은 동시에 같은 슬롯을 두번 건드리는 일을 막기 위해 TTL 기반 슬롯 락 인프라를 구현했다.
락 획득 / 연장 / 해제 / 로직과 만료 청소 스케줄러를 붙였고,
Mockito 기반으로 서비스 로직을 단위 테스트해보았다.
TTL 기반 락을 선택한 이유
TTL(Time-To-Live)이란 ?
락이 자동으로 해제되는 시각을 함께 저장해 두고, 그 시간이 지나면 다른 주체가 락을 가져갈 수 있게 하는 방식이다.
예를 들어 tryLock("slot-1", userId, Duration.ofSeconds(30))은 30초 후 자동 만료되어 타 사용자가 재획득 가능하다는 뜻.
다른 방식들과 비교
1) DB 플래그 방식(수동 해제)
단순히 locked = true / false 만 관리하는 방식이다.
UPDATE slot SET locked = true WHERE slot_key = 'xxx';
문제점
- 프로세스 비정상 종료 시 영구 락 발생
- 운영자가 수동 해제해야 함
- 락 소유자 추적 어려움
2) Redis 기반 분산 락(Redlock 등)
Redis에 락 키를 저장하고 TTL을 설정하는 방식이다.
redis.setex("lock:slot-1", 30, "instance-id");
장점: 빠르고 분산에 적합
단점: Redis 인프라/HA 필요, RDB 트랜잭션과 분리되어 일관성 관리 복잡
(현재 프로젝트는 PostgreSQL + Flyway 중심 스택이라 과함)
결론: 개발 환경(PostgreSQL + Flyway)과 운영 단순성에 맞춰 TTL 기반 DB 락을 채택하였다.
1. Flyway 마이그레이션
슬롯 락은 DB 기반으로 관리되기 때문에, 먼저 Flyway로 slot_lock 테이블과 인덱스를 생성했다.
-- V3__slot_lock_ttl.sql
-- ===========================================
-- V3 Slot lock 테이블 + TTL 최적화 쿼리
-- ===========================================
-- 기본 테이블이 존재하는지 확인 (처음 환경에서 안전)
CREATE TABLE IF NOT EXISTS slot_lock (
slot_key TEXT PRIMARY KEY,
user_id BIGINT NOT NULL REFERENCES "user"(id) ON DELETE CASCADE,
expires_at TIMESTAMPTZ NOT NULL
);
-- 테이블이 이미 존재하는 경우 널 가능성 제한
ALTER TABLE slot_lock
ALTER COLUMN user_id SET NOT NULL,
ALTER COLUMN expires_at SET NOT NULL;
-- 만료 시간 인덱스
CREATE INDEX IF NOT EXISTS idx_slot_lock_expires_at
ON slot_lock (expires_at);
초기에 user_id를 필수로 묶었는데, 운영 중 남아 있을 수 있는 빈 락(null user)을 고려해 nullable로 복구했다.
-- V4__slot_lock_user_nullable.sql
-- ===========================================
-- V4 slot_lock.user_id nullable 복구
-- ===========================================
ALTER TABLE slot_lock
ALTER COLUMN user_id DROP NOT NULL;
2. SlotLock 엔티티
락 정보를 JPA 엔티티로 매핑했다.
Slot_key는 PK이며, expires_at은 UTC 기준으로 TTL을 기록한다.
// SlotLock.java
@Entity
@Table(name = "slot_lock")
@Getter
@Setter
public class SlotLock {
@Id
@Column(name = "slot_key")
private String slotKey;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "user_id")
private User user;
@Column(name = "expires_at", nullable = false)
private OffsetDateTime expiresAt;
}
3. SlotLockRepository
DB 레벨에서 비관적 락(PESSIMISTIC_WRITE)을 걸고, 락 삭제나 만료 정리를 위한 커스텀 쿼리를 정의했다.
// SlotLockRepository.java
// 슬롯 락 리포지토리 인터페이스
public interface SlotLockRepository extends JpaRepository<SlotLock, String> {
// 슬롯 락 조회 (락 모드: 비관적 락)
@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("select sl from SlotLock sl where sl.slotKey = :slotKey")
Optional<SlotLock> findBySlotKeyForUpdate(@Param("slotKey") String slotKey);
// 슬롯 락 삭제 (사용자 ID 또는 사용자 ID가 없는 경우)
@Modifying
@Query("""
delete from SlotLock sl
where sl.slotKey = :slotKey
and (
(:userId is null and sl.user is null)
or (:userId is not null and sl.user.id = :userId)
)
""")
int deleteBySlotKeyAndUserId(@Param("slotKey") String slotKey, @Param("userId") Long userId);
// 만료된 슬롯 락 삭제
@Modifying
@Query("delete from SlotLock sl where sl.expiresAt <= :now")
int deleteExpired(@Param("now") OffsetDateTime now);
}
4. SlotLockService - TTL 기반 락 로직
락을 잡고(Try), 갱신하고(Renew), 해제(Release)하는 모든 로직을 서비스에 집중시켰다.
TTL이 지난 락은 새 사용자가 탈취할 수 있다.
// SlotLockService.java
// 슬롯 락 서비스 클래스
@Service
@RequiredArgsConstructor
public class SlotLockService {
private final SlotLockRepository slotLockRepository;
private final UserRepository userRepository;
// 슬롯 락 시도
@Transactional
public boolean tryLock(String slotKey, Long userId, Duration ttl) {
OffsetDateTime now = OffsetDateTime.now(ZoneOffset.UTC);
OffsetDateTime expiresAt = now.plus(ttl);
User userRef = userId != null ? getUserReference(userId) : null;
// 슬롯 락 조회
Optional<SlotLock> existingLock = slotLockRepository.findBySlotKeyForUpdate(slotKey);
// 슬롯 락이 없으면 새로 생성
if (existingLock.isEmpty()) {
SlotLock newLock = new SlotLock();
newLock.setSlotKey(slotKey);
newLock.setUser(userRef); // 사용자 참조 설정
newLock.setExpiresAt(expiresAt); // 만료 시간 설정
slotLockRepository.save(newLock); // 슬롯 락 저장
return true;
}
// 슬롯 락이 있으면 소유자 확인
SlotLock lock = existingLock.get(); // 슬롯 락 가져오기
Long currentOwnerId = lock.getUser() != null ? lock.getUser().getId() : null;
// 내 락이면 TTL 갱신
if (Objects.equals(currentOwnerId, userId)) {
lock.setExpiresAt(expiresAt);
return true;
}
// 슬롯 락이 만료되었으면 소유자 변경(내 락이 아니면 소유자 변경)
if (!lock.getExpiresAt().isAfter(now)) {
lock.setUser(userRef); // 사용자 참조 설정
lock.setExpiresAt(expiresAt); // 만료 시간 설정
slotLockRepository.save(lock); // 슬롯 락 저장
return true;
}
// 슬롯 락이 있고 소유자가 다르면 실패
return false;
}
// 슬롯 락 갱신
@Transactional
public boolean renewLock(String slotKey, Long userId, Duration ttl) {
OffsetDateTime now = OffsetDateTime.now(ZoneOffset.UTC);
OffsetDateTime expiresAt = now.plus(ttl);
return slotLockRepository.findBySlotKeyForUpdate(slotKey)
.filter(lock -> Objects.equals(lock.getUser() != null ? lock.getUser().getId() : null, userId))
.map(lock -> {
lock.setExpiresAt(expiresAt);
return true;
})
.orElse(false);
}
// 슬롯 락 해제
@Transactional
public boolean releaseLock(String slotKey, Long userId) {
return slotLockRepository.deleteBySlotKeyAndUserId(slotKey, userId) > 0;
}
// 만료된 슬롯 락 삭제
@Transactional
public int cleanExpiredLocks() {
OffsetDateTime now = OffsetDateTime.now(ZoneOffset.UTC);
return slotLockRepository.deleteExpired(now);
}
// 슬롯 락 조회
@Transactional(readOnly = true)
public Optional<SlotLock> findLock(String slotKey) {
return slotLockRepository.findById(slotKey);
}
// 사용자 참조 가져오기
private User getUserReference(Long userId) {
return userRepository.findById(userId)
.orElseThrow(() -> new EntityNotFoundException("User not found: " + userId));
}
}
5. SlotLockMaintenanceJob - 만료 락 정리
주기적으로 만료된 락을 제거하는 스케줄러이다. 동시에 여러 인스턴스가 실행될 경우를 대비해,
자체 락(job:slot-lock:cleanup)을 먼저 잡고 실행한다.
기본 주기는 slot-lock.cleanup-interval-ms로 60초. (변경 가능)
// SlotLockMaintenanceJob.java
// 슬롯 락 유지 관리 작업 클래스
@Slf4j
@Component
@RequiredArgsConstructor
public class SlotLockMaintenanceJob {
// 슬롯 락 유지 관리 작업 슬롯 키
private static final String JOB_SLOT_KEY = "job:slot-lock:cleanup";
// 슬롯 락 유지 관리 작업 슬롯 TTL
private static final Duration JOB_TTL = Duration.ofMinutes(1);
// 슬롯 락 서비스
private final SlotLockService slotLockService;
// 슬롯 락 유지 관리 작업 슬롯 키 조회
@Scheduled(fixedDelayString = "${slot-lock.cleanup-interval-ms:60000}")
public void cleanExpiredLocksSafely() {
// 슬롯 락 유지 관리 작업 슬롯 키 시도
if (!slotLockService.tryLock(JOB_SLOT_KEY, null, JOB_TTL)) {
return;
}
// 슬롯 락 유지 관리 작업 슬롯 키 시도
try {
// 슬롯 락 유지 관리 작업 슬롯 키 삭제
int deleted = slotLockService.cleanExpiredLocks();
log.debug("Slot lock cleanup removed {} entries", deleted);
} finally {
// 슬롯 락 유지 관리 작업 슬롯 키 해제
slotLockService.releaseLock(JOB_SLOT_KEY, null);
}
}
}
6. 테스트
Mockito를 사용해 SlotLockService의 주요 케이스를 검증했다.
신규 락 생성, 재진입, 만료 탈취, 타인 락 실패, 해제 로직을 모두 커버했다.
@ExtendWith(MockitoExtension.class)
@MockitoSettings(strictness = Strictness.LENIENT)
class SlotLockServiceTest {
@Test
void tryLock_createsNewLock_whenNoExisting() { ... }
@Test
void tryLock_reassignsLock_whenExpired() { ... }
@Test
void releaseLock_deletes_whenOwnerMatches() { ... }
}
7. 적용
- 캘린더/작업 API의 생성·수정·삭제 흐름에 연결
- 락 실패 시 HTTP 409 반환, 완료 후 해제 보장
- WorkHourService에도 적용 → 팀 근무시간 동시 수정 방지 + 일괄 갱신
- SlotLockMaintenanceJob으로 TTL 만료분 자동 정리
- ./gradlew test 전체 통과
'Project > AutoSchedule' 카테고리의 다른 글
| (12일차)13일차 - 일정 드래그/수정 반영 (0) | 2025.11.14 |
|---|---|
| 11일차 - 클라이언트 구독 테스트 (0) | 2025.11.13 |
| 9일차 - 실시간 스케줄 브로드캐스트 구현 (1) | 2025.11.11 |
| 8.5일차 - Spring WebSocket 보안 3단계 추가 (0) | 2025.11.11 |
| 8일차 - WebSocket 환경 세팅 (0) | 2025.11.10 |