Project/AutoSchedule

10일차 - SlotLock 구현

sowon02 2025. 11. 12. 16:26

오늘은 동시에 같은 슬롯을 두번 건드리는 일을 막기 위해 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 전체 통과