예약 목록에는 두 회의가 빈틈없이 이어져 있는데, 제출하면 충돌 오류가 뜬다. 다른 기록에서는 긴 예약 하나가 짧은 예약 세 개를 완전히 포함하는데도 통계 페이지의 최대 동시 예약 수가 하나로 나온다. 두 문제 모두 UI부터 살펴볼 필요는 없다. 클라우드 Mac에서 구간 계산을 순수 Swift 함수로 분리하고 경계 규칙과 기준 결과를 고정하면, 오류를 재현 가능한 입력으로 좁힐 수 있다. 여기서는 단일 리소스의 점유 시간만 다루며, 날짜 표시나 네트워크 요청은 다루지 않는다.
먼저 반개구간 규칙을 확정하기
각 예약을 [start, end)로 표현한다. 시작 시각은 포함하고 종료 시각은 포함하지 않는다. 따라서 [0, 10)과 [10, 20)은 충돌하지 않지만, [0, 10)과 [9, 20)은 충돌한다. 시간 눈금은 초나 분으로 정할 수 있지만, 한 번의 계산에서는 반드시 같은 단위를 사용해야 한다. 서로 다른 정밀도의 값을 그대로 섞어서는 안 된다.
입력 단계에서 종료 시각이 시작 시각보다 늦지 않은 예약은 거부해야 한다. 길이가 0인 알림은 리소스를 점유하는 예약이 아니므로 별도의 데이터 모델로 처리해야 한다. 정렬에 포함하려고 몰래 1초를 덧붙이지 말자.
점유 구간 병합과 최대 동시 점유 수 계산은 서로 다른 문제다. 구간을 병합하면 중첩 횟수 정보가 사라지므로, 먼저 병합한 뒤 동시 예약 수를 계산해서는 안 된다.
업무 규칙상 두 회의 사이에 준비 시간이 필요하다면, 먼저 그 시간을 점유 구간에 반영한 뒤 동일한 규칙을 적용해야 한다. UI 계층에서만 별도로 검사하면 예약 목록과 제출 API가 서로 다른 결과를 내게 된다.
독립 실행 가능한 핵심 구현 작성하기
다음 내용을 Intervals.swift로 저장한다. 표준 라이브러리만 사용하며, 병합할 때는 실제로 겹치는 구간만 합친다. 끝과 시작이 맞닿은 구간은 겹친 것으로 보지 않는다. 여기서 정수 눈금을 사용하는 이유는 우선 알고리즘과 날짜 변환을 분리하기 위해서다.
typealias Slot = Range<Int>
func merged(_ input: [Slot]) -> [Slot] {
precondition(input.allSatisfy { !$0.isEmpty })
let sorted = input.sorted {
if $0.lowerBound == $1.lowerBound {
return $0.upperBound < $1.upperBound
}
return $0.lowerBound < $1.lowerBound
}
var result: [Slot] = []
for slot in sorted {
if let last = result.last,
slot.lowerBound < last.upperBound {
result[result.count - 1] =
last.lowerBound..<max(last.upperBound, slot.upperBound)
} else {
result.append(slot)
}
}
return result
}
func peak(_ input: [Slot]) -> Int {
precondition(input.allSatisfy { !$0.isEmpty })
let events = input.flatMap {
[(time: $0.lowerBound, delta: 1),
(time: $0.upperBound, delta: -1)]
}.sorted {
if $0.time == $1.time {
return $0.delta < $1.delta
}
return $0.time < $1.time
}
var active = 0
var maximum = 0
for event in events {
active += event.delta
maximum = max(maximum, active)
}
return maximum
}
precondition(merged([]).isEmpty)
precondition(peak([]) == 0)
precondition(merged([0..<10, 10..<20]) == [0..<10, 10..<20])
precondition(peak([0..<10, 10..<20]) == 1)
precondition(merged([0..<10, 5..<15, 10..<20]) == [0..<20])
precondition(peak([0..<10, 5..<15, 10..<20]) == 2)
precondition(merged([0..<30, 5..<10, 8..<12]) == [0..<30])
precondition(peak([0..<30, 5..<10, 8..<12]) == 3)
precondition(peak([0..<10, 0..<10]) == 2)
터미널에서 실행한다.
xcrun swift Intervals.swift
검사를 통과하면 아무 출력 없이 종료 코드 0으로 끝난다. assert에만 의존하지 않고 precondition을 사용하면, 일반적인 최적화 빌드에서 어설션이 비활성화되어 테스트가 무의미해지는 일을 피할 수 있다. 검증 스크립트에서도 안전성 검사를 비활성화하는 컴파일 옵션을 사용해서는 안 된다.
같은 시각의 이벤트와 중첩 구간 검증하기
스위프 라인 알고리즘은 시작을 +1, 종료를 -1로 기록한다. 같은 시각에서는 반드시 감소를 먼저 처리하고 증가를 나중에 처리해야 한다. 그렇지 않으면 서로 인접한 두 예약을 잠깐이나마 동시 예약 두 개로 계산하게 된다. 같은 시각의 처리 순서를 명시하지 않고 시간만으로 정렬하는 것은 흔한 경계 조건 오류다.
고정 테스트 사례는 최소한 다섯 유형을 유지한다. 빈 목록, 서로 인접한 구간, 일부가 겹치는 구간, 완전히 포함되는 구간, 완전히 동일한 구간이다. 동일한 구간 두 개가 독립된 예약 두 건을 뜻하는지는 업무 입력의 의미에 달려 있다. 이 글에서는 각 원소를 하나의 점유 예약으로 보므로, 완전히 동일한 예약 두 건의 동시 예약 수는 둘이다.
병합 알고리즘에는 놓치기 쉬운 세부 사항이 하나 더 있다. 오른쪽 끝점을 갱신할 때는 두 값 중 큰 값을 선택해야 한다. 현재 구간의 종료 시각으로 단순히 덮어쓰면, 긴 예약이 짧은 예약을 포함할 때 오히려 병합 결과가 짧아질 수 있다. 입력 순서가 뒤섞여 있어도 결과는 같아야 하며, 서버가 우연히 정렬해서 보내 준다는 가정에 의존해서는 안 된다.
별도의 계산 경로로 교차 검증하기
고정 테스트 사례는 구현과 똑같은 착각을 바탕으로 기대값을 잘못 작성할 수 있다. 범위가 작은 정수 표본이라면 각 눈금이 몇 개의 구간에 포함되는지 직접 세어 독립적인 기준 결과로 삼을 수 있다. 다음 코드를 같은 파일 끝에 추가하면 실행할 수 있다. 테스트용 코드이며, 운영용 알고리즘을 대체하지는 않는다.
func referencePeak(_ input: [Slot]) -> Int {
guard let start = input.map(\.lowerBound).min(),
let end = input.map(\.upperBound).max() else {
return 0
}
return (start..<end).map { tick in
input.filter { $0.contains(tick) }.count
}.max() ?? 0
}
let candidates: [Slot] = (0..<5).flatMap { start in
((start + 1)...5).map { start..<$0 }
}
for first in candidates {
for second in candidates {
let sample = [first, second]
precondition(peak(sample) == referencePeak(sample))
precondition(merged(sample) ==
merged(Array(sample.reversed())))
precondition(merged(merged(sample)) == merged(sample))
}
}
각 검사는 순서대로 동시 예약 수의 정확성, 입력 순서에 대한 독립성, 병합의 멱등성을 확인한다. 이산적인 기준 알고리즘은 여기서 정한 정수 눈금 규칙에만 적용된다. 이 결과만으로 임의의 연속 시간까지 검증했다고 주장할 수는 없다. 무작위 테스트를 추가한다면 난수 시드만 기록하지 말고 실패한 입력 자체를 저장해야 한다. 그래야 도구 체인이 바뀐 뒤에도 재현하기 쉽다.
프로젝트 통합 전 입력 검증과 실패 기록 정비하기
실제 프로젝트에 통합할 때는 파싱, 정규화, 구간 계산을 세 계층으로 분리한다. 먼저 동일한 시간 기준의 시작값과 종료값을 얻고, 정밀도를 통일한 다음, 종료가 시작보다 엄격히 뒤인지 검사한다. 정밀도를 변환하면 원래 아주 짧았던 예약의 길이가 0이 될 수 있으므로, 변환 전에만 검증해서는 안 된다.
여러 회의실이 하나의 예약 목록을 공유한다면 먼저 리소스 식별자로 그룹을 나누고 각각 계산한다. 서로 다른 회의실에서 동시에 진행하는 회의는 충돌로 처리하면 안 된다. 참석자도 중복 예약을 막아야 한다면 참석자에 대한 별도의 리소스 제약을 구성한다. 모든 예약을 조건 없이 하나의 스위프 라인에 섞어 넣어서는 안 된다.
실패 기록에는 리소스 식별자, 정규화된 구간, 기대값, 실제값, 도구 체인 버전만 남기면 된다. 참석자 정보까지 첨부할 필요는 없다. 수정하기 전에 먼저 실패 입력을 고정 테스트 사례에 추가하고, 전체 스크립트를 실행한다. 최종 통과 기준은 경계 사례, 기준 계산, 업무 규칙에 따른 그룹 분리가 모두 일치하는 것이어야 한다. 특정 화면에서 충돌 알림이 더 이상 뜨지 않는 것만으로는 충분하지 않다.
자주 묻는 질문
앞 예약이 끝나는 순간 다음 예약이 시작되면 충돌인가요?
이 글의 반개구간 규칙에서는 충돌이 아닙니다. 같은 시각에는 종료 이벤트를 먼저 처리합니다. 준비 시간이 필요하면 점유 구간을 늘린 뒤 충돌을 검사합니다.
병합한 구간 개수로 최대 동시 예약 수를 알 수 있나요?
알 수 없습니다. 병합하면 점유 시간 범위는 남지만 중첩 깊이는 사라집니다. 원본 구간의 시작·종료 이벤트로 최대 동시 예약 수를 계산해야 합니다.
날짜 대신 정수 시간부터 테스트하는 이유는 무엇인가요?
날짜 파싱 문제와 구간 알고리즘을 분리하기 위해서입니다. 실제 입력은 같은 시간 기준과 정밀도로 변환하고, 종료가 시작보다 늦지 않은 구간은 거부해야 합니다.
전용 물리 Mac에서 프로젝트 실행
클라우드 Mac으로 빌드와 테스트를 진행하고 원격 데스크톱을 이용해 보세요. 프로젝트에 맞는 사양과 요금 주기를 선택할 수 있으며, 실제 이용 가능 여부는 콘솔에 실시간으로 표시됩니다.