일정 충돌 검사기 - 날짜·시간 계산의 함정
이 장에서 배우는 것
앞 장에서 문제 은행을 읽고 채점할 때는 정답과 오답을 나누는 기준이 중요했다. 일정 충돌 검사에서도 먼저 기준을 정해야 한다. 오전 10시에 끝나는 회의와 오전 10시에 시작하는 회의가 충돌하는지, 밤 11시에 시작해 다음 날 끝나는 작업을 어떻게 적을지에 따라 같은 데이터의 결과가 달라진다.
이 장에서는 여러 일정에서 시간이 겹치는 묶음을 찾는 프로그램을 만든다. Python의 datetime으로 날짜와 시각을 함께 해석하고, 시작 순서로 정렬한 뒤 한 번 훑어 충돌 묶음을 구성한다. 코드를 짧게 만드는 것보다 구간의 의미를 명세에 적고 그 의미가 경계에서도 유지되는지 확인하는 데 초점을 둔다.
- 시작은 포함하고 끝은 제외하는 시간 구간을 정의한다.
- 날짜를 포함한 datetime으로 자정을 넘는 일정을 표현한다.
- 정렬한 일정을 한 번 훑으면서 가장 늦은 종료 시각을 유지한다.
- 직접 겹치는 일정 쌍과 겹침으로 연결된 일정 묶음을 구별한다.
- 경계 사례 검사, 실행 결과 확인, 변경 차이 검토로 AI가 만든 코드를 검증한다.
문제 상황
작은 행사 운영팀이 같은 작업 공간을 쓰는 일정을 모았다. 준비 작업, 회의, 검토 작업이 오전에 이어지고 밤에는 장비 작업과 인계가 잡혀 있다. 담당자가 각자의 일정을 따로 작성해 전체 시간표에는 겹침이 생겼다. 사람 눈으로는 오전 일정만 확인하고 자정 뒤의 인계를 놓치기 쉽다.
여기서 만들 도구는 예약을 변경하지 않는다. 입력한 일정을 읽어 시간순으로 정리하고, 담당자가 함께 조정해야 할 일정 묶음을 출력한다. 파일 입력이나 명령행 옵션은 붙이지 않는다. main.py 안에 고정된 예시 데이터를 넣어 실행할 때마다 같은 결과를 얻는다. 현재 시각, 운영체제의 지역 설정, 네트워크 응답에 의존하지 않는 작은 프로그램으로 범위를 정한다.
특히 ‘충돌 묶음’의 뜻을 먼저 합의해야 한다. 준비와 회의가 겹치고 회의와 검토가 겹치면 세 일정을 한 묶음으로 보여 준다. 준비와 검토가 직접 겹치지 않아도 같은 조정 대상에 속한다. 모든 충돌 쌍을 나열하는 도구와는 출력 단위가 다르다.
| 항목 | 정한 규칙 | 확인 방법 |
|---|---|---|
| 입력 | 이름, 시작 날짜·시각, 종료 날짜·시각으로 된 고정 목록 | main.py 한 파일로 실행한다. |
| 시간 기준 | 모든 일정은 같은 지역의 벽시계 시각이다. | 시간대가 다른 일정은 받지 않는다. |
| 끝점 | 시작은 포함하고 종료는 제외한다. | 끝과 시작이 같으면 충돌하지 않는다. |
| 유효성 | 종료는 시작보다 뒤여야 한다. | 같거나 앞서면 ValueError를 낸다. |
| 출력 | 겹침으로 연결된 일정이 둘 이상인 묶음만 출력한다. | 단독 일정은 묶음 수에 넣지 않는다. |
| 재현성 | 날짜와 입력 순서를 고정하고 정렬 기준을 명시한다. | 반복 실행한 출력을 비교한다. |
구간의 끝을 먼저 정한다
이 프로그램은 일정을 반열린 구간(half-open interval)으로 취급한다. 수학 기호로는 [시작, 종료)다. 오전 9시부터 10시까지인 일정은 9시를 포함하지만 10시는 포함하지 않는다. 따라서 10시에 다음 일정이 시작해도 시간 자체는 겹치지 않는다. 이동이나 정리 시간이 필요하다면 별도 규칙을 추가해야 한다.
유효한 두 일정 a와 b가 겹치는 조건은 a의 시작이 b의 종료보다 이르고, b의 시작이 a의 종료보다 이른 것이다. 두 비교가 모두 참이어야 한다. 등호를 넣으면 맞닿은 일정까지 충돌로 판단한다. 이 작은 차이는 코드 작성자의 취향이 아니라 업무 규칙에 해당한다.
빈 구간의 취급도 정해야 한다. 시작과 종료가 같은 일정을 허용하면 ‘시간을 차지하지 않는 표시’라는 별도 의미가 필요해진다. 이번 프로그램은 공간 사용을 검사하므로 길이가 0인 일정도 잘못된 입력으로 거부한다. 구간 겹침 함수는 이미 이 검사를 통과한 일정만 받는다고 가정한다.
자정은 시각 숫자만 비교해서 처리하지 않는다. 2026-10-30 23:40에 시작해 2026-10-31 00:20에 끝나는 일정은 정상이다. 날짜를 버리고 23:40과 00:20만 비교하면 종료가 앞선 것으로 보인다. 날짜와 시각을 함께 datetime으로 바꾸면 두 값의 대소 관계가 실제 순서와 맞는다.
반대로 종료가 시작보다 이르다는 이유만으로 하루를 더해 주지는 않는다. 같은 날 15:00에서 14:00으로 적은 값은 단순 오타일 수 있다. 프로그램이 이를 다음 날까지 이어지는 일정으로 고치면 입력 실수를 숨긴다. 다음 날 끝나는 일정은 종료 날짜까지 명시하도록 요구한다.
여기서 사용하는 datetime에는 시간대 정보가 없다. 이번 예시는 모든 입력이 같은 지역에서 작성되며 일광 절약 시간 전환처럼 시계가 되돌아가거나 건너뛰는 상황은 다루지 않는다. 해외 지사나 여러 지역의 예약을 합치는 도구로 확장한다면 시간대와 모호한 시각의 처리 정책부터 다시 정해야 한다. 단순히 지역 이름 하나를 덧붙이는 것으로 요구사항이 모두 해결되지는 않는다.
AI에게 요청하고 경계 사례로 수정한다
요청문에는 함수 이름보다 입력의 의미와 완료 조건을 먼저 적는다. ‘일정 충돌 검사기를 만들어 달라’는 말만으로는 맞닿은 일정, 자정 넘김, 묶음의 의미가 정해지지 않는다. 다음 요청문은 구현 범위를 좁히고 검사할 항목까지 함께 전달한다.
Python 3.12 이상 표준 라이브러리만 사용하는 일정 충돌 검사기를 main.py 한 파일로 작성하라. 입력은 코드 안의 고정 목록이며 각 항목은 이름, 시작, 종료다. 날짜·시각 형식은 YYYY-MM-DD HH:MM이다. 모든 입력은 같은 지역의 시각이며 시간대 변환은 하지 않는다.
시작은 포함하고 종료는 제외한다. 종료가 시작과 같거나 앞서면 거부하라. 자정 넘김은 종료 날짜를 다음 날로 적어 표현하며 프로그램이 날짜를 추정해 보정하지 마라.
출력 단위는 직접 겹치는 모든 쌍이 아니라, 겹침 관계로 연결된 일정 묶음이다. 시작 순서로 정렬한 뒤 현재 묶음의 가장 늦은 종료를 유지하며 한 번 훑어라. 둘 이상인 묶음만 출력하라. 맞닿음, 포함 관계, 같은 시작, 자정 넘김, 빈 입력, 연쇄 겹침을 검사하고 고정된 예시 출력을 제시하라.
AI의 1차 결과를 검토할 때는 설명이 자연스러운지보다 실제 분기 조건을 확인한다. 다음 표는 이런 요청에서도 흔히 섞여 나오는 결함과 이를 드러내는 검사를 정리한 것이다. 대화 원문을 재현한 표가 아니라, 이 장에서 수정 대상으로 삼는 1차 구현의 검토 기록이다.
| 문제 | 발견 방법 | 고친 방향 |
|---|---|---|
| 시작이 이전 종료 이하이면 충돌로 처리했다. | 09:00~10:00과 10:00~11:00을 넣었다. | 끝 제외 규칙에 맞춰 엄격한 부등호를 쓴다. |
| 시각 부분만 잘라 비교했다. | 23:40부터 다음 날 00:20까지인 입력을 넣었다. | 날짜와 시각 전체를 datetime으로 읽는다. |
| 바로 앞 일정의 종료만 기억했다. | 긴 일정 안에 짧은 일정 두 개를 넣었다. | 현재 묶음의 최대 종료를 유지한다. |
| 시작이 같은 일정의 정렬 기준이 불명확했다. | 동일 시작 입력의 순서를 바꿔 비교했다. | 시작, 종료, 이름 순으로 정렬한다. |
| 연쇄 묶음의 모든 일정이 서로 겹친다고 설명했다. | A와 B, B와 C만 겹치는 사례를 그렸다. | 직접 겹침과 연결된 묶음을 구별한다. |
수정 요청도 관찰한 실패에서 출발한다. ‘더 정확하게 고쳐 달라’보다 어떤 입력에서 어떤 결과가 나와야 하는지 적는 편이 검토하기 쉽다. 아래 요청은 자료구조 전체를 바꾸라고 하지 않고 실패한 규칙을 한정한다.
09:00~12:00, 09:30~10:00, 11:00~11:30을 넣으면 세 일정이 한 묶음이어야 한다. 현재 코드는 바로 앞 일정의 종료인 10:00만 보고 마지막 일정을 분리한다. 현재 묶음의 최대 종료를 유지하도록 수정하라. 맞닿은 일정은 계속 분리하고, 이 두 조건을 검사에 추가하라. 변경한 비교식과 상태 갱신을 설명하라.
검사는 두 층으로 나눈다. 먼저 두 구간만 비교하는 함수가 끝점 규칙을 지키는지 확인한다. 다음으로 목록을 묶는 함수가 정렬, 포함 관계, 마지막 묶음 처리를 제대로 수행하는지 확인한다. 한 층만 검사하면 개별 비교는 맞는데 묶음 구성은 틀린 상태를 놓칠 수 있다.
| 사례 | 첫 일정 | 둘째 일정 | 겹침 |
|---|---|---|---|
| 분리 | 09:00~10:00 | 11:00~12:00 | 아니오 |
| 맞닿음 | 09:00~10:00 | 10:00~11:00 | 아니오 |
| 부분 겹침 | 09:00~10:00 | 09:30~10:30 | 예 |
| 포함 | 09:00~12:00 | 10:00~11:00 | 예 |
| 동일 | 09:00~10:00 | 09:00~10:00 | 예 |
| 같은 시작 | 09:00~10:00 | 09:00~09:30 | 예 |
| 같은 종료 | 09:00~10:00 | 09:30~10:00 | 예 |
| 자정 뒤 겹침 | 30일 23:40~31일 00:20 | 31일 00:10~00:40 | 예 |
| 자정 뒤 맞닿음 | 30일 23:40~31일 00:20 | 31일 00:20~00:40 | 아니오 |
표의 날짜 생략 항목은 모두 같은 날이다. 코드에서는 생략 없이 전체 날짜를 넣는다. 두 구간의 순서를 바꿔도 결과가 같아야 하므로 각 행을 정방향과 역방향으로 검사한다. 출력에서는 표의 행 수인 9건으로 센다.
정렬한 뒤 가장 늦은 종료를 유지한다
시작 순서로 정렬하면 다음 일정이 현재 묶음에 들어가는지 간단히 판단할 수 있다. 현재 묶음에서 가장 늦은 종료를 latest_end라고 하자. 다음 일정의 시작이 latest_end보다 이르면 묶음에 추가한다. 추가한 일정이 더 늦게 끝나면 latest_end도 갱신한다.
다음 시작이 latest_end와 같거나 늦다면 현재 묶음은 끝난다. 정렬되어 있으므로 그 뒤에 나오는 일정이 이미 끝난 묶음으로 되돌아와 겹칠 수는 없다. 현재 묶음에 일정이 둘 이상이면 결과에 보관하고, 다음 일정으로 새 묶음을 시작한다. 반복문이 끝난 뒤 남은 묶음도 같은 방식으로 처리한다.
묶음의 최대 종료를 지키는 이유는 포함 관계 때문이다. A가 09:00~12:00이고 B가 09:30~10:00이라면 B를 읽은 뒤에도 최대 종료는 12:00이다. 이어지는 C가 11:00~11:30이면 A와 겹치므로 같은 묶음에 들어간다. B의 종료만 기억하면 C를 잘못 분리한다.
이 방식이 모든 충돌 쌍을 만들어 주지는 않는다. A가 09:00~10:00, B가 09:30~10:30, C가 10:20~11:00이면 셋은 한 묶음이지만 A와 C는 겹치지 않는다. 출력 제목을 ‘충돌 쌍’으로 바꾸면 결과의 의미가 틀어진다. 모든 쌍이 필요한 업무라면 출력량 자체가 크게 늘 수 있으며 알고리즘과 검사를 따로 설계해야 한다.
일정 수를 n이라고 할 때 정렬에는 O(n log n), 이후 순회에는 O(n)이 걸린다. ‘한 번 훑는다’는 설명은 정렬 이후의 단계에 해당한다. 전체 프로그램이 정렬 없이 선형 시간에 끝난다는 뜻은 아니다. 반환할 일정 목록을 보관하므로 메모리 사용도 일정 수에 따라 늘어난다.
완성 코드
다음 내용을 main.py로 저장한다. 외부 파일을 읽지 않으므로 임시 폴더는 필요하지 않다. 입력 목록은 일부러 시간순으로 배치하지 않아 정렬 동작도 확인한다. 검사에 실패하면 예외로 실행이 중단되며, 통과한 경우에만 예시 보고서를 출력한다.
from dataclasses import dataclass
from datetime import datetime
FORMAT = "%Y-%m-%d %H:%M"
@dataclass(frozen=True)
class Event:
name: str
start: datetime
end: datetime
def make_event(name: str, start: str, end: str) -> Event:
start_time = datetime.strptime(start, FORMAT)
end_time = datetime.strptime(end, FORMAT)
if end_time <= start_time:
raise ValueError(f"{name}: 종료는 시작보다 뒤여야 한다")
return Event(name, start_time, end_time)
def overlaps(a: Event, b: Event) -> bool:
return a.start < b.end and b.start < a.end
def conflict_groups(events: list[Event]) -> list[list[Event]]:
ordered = sorted(
events, key=lambda event: (event.start, event.end, event.name)
)
if not ordered:
return []
result: list[list[Event]] = []
current = [ordered[0]]
latest_end = ordered[0].end
for event in ordered[1:]:
if event.start < latest_end:
current.append(event)
latest_end = max(latest_end, event.end)
else:
if len(current) > 1:
result.append(current)
current = [event]
latest_end = event.end
if len(current) > 1:
result.append(current)
return result
def check(condition: bool, label: str) -> None:
if not condition:
raise AssertionError(label)
def run_checks() -> tuple[int, int, int]:
def day(name: str, start: str, end: str) -> Event:
return make_event(
name, f"2026-10-30 {start}", f"2026-10-30 {end}"
)
base = day("기준", "09:00", "10:00")
night = make_event(
"야간", "2026-10-30 23:40", "2026-10-31 00:20"
)
cases = [
("분리", base, day("B", "11:00", "12:00"), False),
("맞닿음", base, day("B", "10:00", "11:00"), False),
("부분 겹침", base, day("B", "09:30", "10:30"), True),
(
"포함",
day("A", "09:00", "12:00"),
day("B", "10:00", "11:00"),
True,
),
("동일", base, day("B", "09:00", "10:00"), True),
("같은 시작", base, day("B", "09:00", "09:30"), True),
("같은 종료", base, day("B", "09:30", "10:00"), True),
(
"자정 뒤 겹침",
night,
make_event("B", "2026-10-31 00:10", "2026-10-31 00:40"),
True,
),
(
"자정 뒤 맞닿음",
night,
make_event("B", "2026-10-31 00:20", "2026-10-31 00:40"),
False,
),
]
for label, a, b, expected in cases:
check(overlaps(a, b) == expected, label)
check(overlaps(b, a) == expected, f"{label}: 역방향")
group_cases = [
("빈 입력", [], []),
("단독", [base], []),
(
"포함과 분리",
[
day("C", "11:00", "11:30"),
day("D", "12:00", "13:00"),
day("B", "09:30", "10:00"),
day("A", "09:00", "12:00"),
],
[["A", "B", "C"]],
),
(
"연쇄와 마지막 묶음",
[
day("C", "10:20", "11:00"),
day("A", "09:00", "10:00"),
day("B", "09:30", "10:30"),
],
[["A", "B", "C"]],
),
]
for label, events, expected in group_cases:
actual = [
[event.name for event in group]
for group in conflict_groups(events)
]
check(actual == expected, label)
invalid_cases = [
("길이 0", "2026-10-30 09:00", "2026-10-30 09:00"),
("종료 역전", "2026-10-30 23:40", "2026-10-30 00:20"),
]
for label, start, end in invalid_cases:
try:
make_event(label, start, end)
except ValueError:
pass
else:
raise AssertionError(f"{label}: 거부해야 한다")
return len(cases), len(group_cases), len(invalid_cases)
def main() -> None:
pair_count, group_count, invalid_count = run_checks()
print(
f"검사 통과: 구간 {pair_count}건, "
f"묶음 {group_count}건, 잘못된 입력 {invalid_count}건"
)
rows = [
("인계", "2026-10-31 00:10", "2026-10-31 00:40"),
("검토", "2026-10-30 10:20", "2026-10-30 11:00"),
("준비", "2026-10-30 09:00", "2026-10-30 10:00"),
("점검", "2026-10-30 11:00", "2026-10-30 11:30"),
("야간 작업", "2026-10-30 23:40", "2026-10-31 00:20"),
("회의", "2026-10-30 09:30", "2026-10-30 10:30"),
]
events = [make_event(*row) for row in rows]
groups = conflict_groups(events)
print(f"전체 일정: {len(events)}개")
print(f"충돌 묶음: {len(groups)}개")
for number, group in enumerate(groups, start=1):
start = group[0].start.strftime(FORMAT)
end = max(event.end for event in group).strftime(FORMAT)
print(f"[{number}] {start} ~ {end}")
for event in group:
start = event.start.strftime(FORMAT)
end = event.end.strftime(FORMAT)
print(f" - {event.name}: {start} ~ {end}")
if __name__ == "__main__":
main()
줄별 해설
맨 위의 두 가져오기는 일정 자료형과 날짜 계산에 필요한 기능만 불러온다. FORMAT은 입력 해석과 출력 표시에서 함께 사용한다. 같은 형식을 여러 곳에 직접 적지 않아 날짜 표시를 수정할 때 확인할 지점을 줄인다.
Event는 이름, 시작, 종료를 묶는다. frozen=True는 만든 뒤 속성을 다시 대입하지 못하게 한다. 정렬 과정에서 원본 일정의 시간이 바뀌면 검사 결과를 추적하기 어려우므로 값처럼 취급한다. 다만 이 선언 자체가 종료 순서까지 검사해 주는 것은 아니다. 이 프로그램의 생성 경로는 make_event로 통일한다.
make_event의 두 strptime 호출은 문자열을 datetime으로 변환한다. 달력에 없는 날짜나 형식에 맞지 않는 문자열은 변환 단계에서 ValueError가 발생한다. 그다음 비교는 변환에는 성공했지만 일정으로는 잘못된 길이와 순서를 거른다. 자동으로 다음 날을 추정하는 코드가 없다는 점도 명세와 맞춰 확인한다.
overlaps는 두 비교를 and로 연결한다. 어느 쪽 일정이 먼저 시작하더라도 사용할 수 있다. 이 함수는 경계 규칙을 직접 검사하는 작은 기준점이다. 묶음 함수에서는 정렬된 순서를 활용하므로 같은 식을 그대로 호출할 필요가 없다.
conflict_groups의 sorted는 새 목록을 만든다. 전달받은 events의 순서를 바꾸지 않아 호출한 쪽에서 원본 입력을 계속 사용할 수 있다. 시작과 종료가 같을 때는 이름으로 순서를 정한다. 세 값이 모두 같은 중복 항목은 입력 개수만큼 유지된다. 중복 제거는 이번 프로그램의 요구사항에 없다.
빈 목록 검사는 ordered[0]을 읽기 전에 나온다. 첫 일정으로 current와 latest_end를 초기화한 뒤 나머지를 반복한다. 반복문 안에서 current는 지금 구성 중인 묶음이고 latest_end는 그 묶음 전체에서 가장 늦은 종료라는 관계를 계속 유지해야 한다.
겹침 분기의 max는 종료가 짧은 일정이 들어와도 묶음의 끝을 앞당기지 않는다. 분리 분기에서는 기존 묶음을 저장한 뒤 current와 latest_end를 함께 새 일정으로 바꾼다. 둘 중 하나만 갱신하면 다음 반복에서 서로 다른 묶음의 상태를 섞게 된다.
반복문 뒤의 조건은 마지막 묶음을 결과에 넣는다. 분리 분기에서만 저장하면 입력이 끝날 때까지 이어지는 충돌이 사라진다. 검사 목록의 ‘연쇄와 마지막 묶음’은 이 누락을 함께 잡는다. 단독 일정은 유효한 입력이지만 보고할 충돌은 아니므로 결과에서 제외한다.
check는 조건이 거짓일 때 AssertionError를 직접 발생시킨다. assert 문은 실행 옵션에 따라 제거될 수 있으므로 여기서는 검사 동작을 명시적인 함수로 둔다. 잘못된 입력 검사에서는 ValueError가 나와야 성공이다. 아무 예외도 없으면 try의 else에서 실패를 알린다.
main은 검사를 먼저 실행한 뒤 고정 데이터를 만든다. 묶음 제목의 시작은 정렬된 첫 일정의 시작이고, 종료는 묶음 전체의 최대 종료다. 이것은 묶음이 차지하는 전체 범위이며 그 시간 동안 모든 일정이 동시에 진행된다는 뜻은 아니다. 마지막 조건문은 파일을 직접 실행할 때만 main을 호출한다.
실행 결과
먼저 문법과 경고를 확인하는 컴파일 명령을 실행하고, 이어서 프로그램을 실행한다. 명령은 다음과 같다. 컴파일이 성공하면 별도 출력이 없다. 아래 결과는 완성 코드에 대응하는 예상 출력이며, 독자는 자신의 환경에서 실제 출력과 비교해야 한다.
python3 -W error -m py_compile main.py
python3 main.py
검사 통과: 구간 9건, 묶음 4건, 잘못된 입력 2건
전체 일정: 6개
충돌 묶음: 2개
[1] 2026-10-30 09:00 ~ 2026-10-30 11:00
- 준비: 2026-10-30 09:00 ~ 2026-10-30 10:00
- 회의: 2026-10-30 09:30 ~ 2026-10-30 10:30
- 검토: 2026-10-30 10:20 ~ 2026-10-30 11:00
[2] 2026-10-30 23:40 ~ 2026-10-31 00:40
- 야간 작업: 2026-10-30 23:40 ~ 2026-10-31 00:20
- 인계: 2026-10-31 00:10 ~ 2026-10-31 00:40
점검은 검토가 끝나는 11:00에 시작하므로 충돌 묶음에 나타나지 않는다. 첫 묶음에서는 준비와 검토가 직접 겹치지 않지만 회의가 둘을 연결한다. 두 번째 묶음에서는 날짜가 달라도 실제 구간이 겹친다. 이 세 가지를 출력에서 직접 확인하면 단순히 ‘검사 통과’ 문구만 읽는 것보다 결과의 의미를 더 잘 검토할 수 있다.
AI가 수정한 버전을 받았다면 실행 성공만으로 검토를 마치지 않는다. 이전 버전을 버전 관리에 남겨 두고 아래 명령으로 변경 차이(diff)를 살펴본다. 비교식에서 등호가 빠졌는지, 최대 종료를 갱신하는지, 검사 기대값을 바꿔 실패를 숨기지는 않았는지 확인한다. 검사를 통과시키려고 기대값을 함께 바꾸면 업무 규칙이 바뀐 사실을 놓칠 수 있다.
git diff -- main.py
실무에서 자주 틀리는 것
맞닿음을 겹침에 포함한다
다음 코드는 각각 독립 실행되는 비교 예다. 잘못된 코드에서는 두 일정이 10:00에 맞닿기만 해도 True가 된다. 끝을 제외한다는 규칙을 정했다면 두 비교에 등호를 넣지 않는다.
from datetime import datetime
a_start = datetime(2026, 10, 30, 9)
a_end = datetime(2026, 10, 30, 10)
b_start = datetime(2026, 10, 30, 10)
b_end = datetime(2026, 10, 30, 11)
print(a_start <= b_end and b_start <= a_end)
고친 코드는 다음과 같다. 출력은 False다.
from datetime import datetime
a_start = datetime(2026, 10, 30, 9)
a_end = datetime(2026, 10, 30, 10)
b_start = datetime(2026, 10, 30, 10)
b_end = datetime(2026, 10, 30, 11)
print(a_start < b_end and b_start < a_end)
시각만 남겨 자정의 순서를 잃는다
날짜를 이미 읽었더라도 time()으로 시각만 꺼내 비교하면 날짜 정보가 사라진다. 아래 잘못된 코드는 다음 날 종료를 앞선 시각으로 판단해 False를 출력한다.
from datetime import datetime
start = datetime(2026, 10, 30, 23, 40)
end = datetime(2026, 10, 31, 0, 20)
print(start.time() < end.time())
고친 코드는 날짜를 포함한 값을 그대로 비교하므로 True를 출력한다. 자정을 넘는다는 사실을 별도 예외 분기로 처리할 필요가 없다.
from datetime import datetime
start = datetime(2026, 10, 30, 23, 40)
end = datetime(2026, 10, 31, 0, 20)
print(start < end)
짧은 일정으로 묶음의 끝을 덮어쓴다
긴 일정에 포함된 짧은 일정을 읽었을 때 종료를 그대로 대입하면 이전의 긴 범위를 잃는다. 아래 잘못된 코드는 뒤에 오는 11:00 일정이 연결되지 않는다고 판단해 False를 출력한다.
from datetime import datetime
latest_end = datetime(2026, 10, 30, 12)
short_end = datetime(2026, 10, 30, 10)
next_start = datetime(2026, 10, 30, 11)
latest_end = short_end
print(next_start < latest_end)
고친 코드는 최대 종료를 유지한다. 같은 입력에서 True를 출력한다. 상태 변수의 뜻을 ‘직전 종료’가 아니라 ‘현재 묶음의 최대 종료’로 적어 두면 이 대입을 검토하기 쉽다.
from datetime import datetime
latest_end = datetime(2026, 10, 30, 12)
short_end = datetime(2026, 10, 30, 10)
next_start = datetime(2026, 10, 30, 11)
latest_end = max(latest_end, short_end)
print(next_start < latest_end)
한눈에 보기
| 주제 | 구현 기준 | 검토할 경계 |
|---|---|---|
| 구간 | 시작 포함, 종료 제외 | 종료와 다음 시작이 같을 때 |
| 날짜 | 전체 날짜·시각을 datetime으로 변환 | 자정과 월말을 넘을 때 |
| 입력 검증 | 종료가 시작보다 뒤여야 함 | 길이 0과 종료 역전 |
| 정렬 | 시작, 종료, 이름 순서 | 입력 순서 변경과 동일 시작 |
| 상태 | 현재 묶음의 최대 종료 유지 | 긴 일정 안에 짧은 일정이 있을 때 |
| 마지막 처리 | 순회 뒤 남은 묶음 저장 | 마지막까지 충돌이 이어질 때 |
| 출력 의미 | 겹침으로 연결된 묶음 | 묶음 안에서 직접 겹치지 않는 두 일정 |
시간 계산의 핵심은 복잡한 산술보다 경계를 일관되게 다루는 데 있다. 날짜를 포함한 값, 엄격한 부등호, 최대 종료라는 세 기준을 요구사항과 검사표에 함께 남긴다. 다음 장에서 명령줄 도구를 만들 때도 입력을 어떤 값으로 해석하고 무엇을 거부할지 먼저 정하는 습관을 이어 간다.
연습 문제
- 준비 시간이 10분 필요하다고 가정한다. 기존 종료 뒤 10분까지 공간을 점유하는 규칙을 추가하려면 어느 값을 바꾸어 비교해야 하는지 설명하고, 정확히 10분 간격인 사례의 기대 결과를 정하라.
- 2026-10-31 23:50부터 2026-11-01 00:10까지인 일정과 2026-11-01 00:05부터 00:20까지인 일정의 검사를 추가하라. 월이 바뀌어도 기존 비교식으로 충분한 이유를 설명하라.
- 단독 일정도 결과에 포함하는 전체 사용 시간표를 만들려면 conflict_groups의 어느 두 조건을 바꿔야 하는가. 빈 입력의 결과도 함께 적어라.
- 완성 프로그램에 방 이름을 추가하고 같은 방의 일정만 비교하도록 확장하는 요청문을 작성하라. 다른 방의 동일 시간 일정, 같은 방의 맞닿은 일정, 방별 출력 순서를 완료 조건에 포함하라.
정답과 해설
첫 번째 문제에서는 datetime에 timedelta(minutes=10)을 더해 비교용 종료를 만든다. 원래 종료는 표시용으로 보존하고, 점유 종료를 별도로 계산하는 편이 의미가 분명하다. 묶음 판정과 최대 종료 갱신에는 모두 점유 종료를 사용해야 한다. 한쪽만 바꾸면 판정과 상태가 서로 다른 규칙을 따른다.
09:00~10:00 일정의 점유 종료는 10:10이다. 다음 일정이 10:09에 시작하면 충돌하고 10:10에 시작하면 맞닿을 뿐이므로 충돌하지 않는다. 10분 간격을 허용하는지 여부도 이처럼 끝 제외 규칙으로 표현할 수 있다. 준비 시간이 일정 전에 필요한 것인지, 종료 뒤 정리 시간인지가 실제 업무에서 다르다면 요청문의 용어부터 수정해야 한다.
두 번째 문제의 기대값은 True다. make_event로 두 일정을 만든 뒤 overlaps의 정방향과 역방향을 검사한다. datetime은 연도, 월, 일과 시각을 함께 비교하므로 월말용 조건문은 필요하지 않다. 종료 문자열을 같은 날짜의 00:10으로 잘못 적으면 유효성 검사에서 거부되어야 하며, 그 실패도 추가해 볼 수 있다.
세 번째 문제에서는 분리 분기와 반복문 뒤에 있는 두 len(current) > 1 조건을 제거하고 각각 current를 저장한다. current는 비어 있지 않으므로 단독 일정도 결과에 들어간다. 빈 입력은 앞의 조기 반환으로 여전히 빈 목록이 된다. 결과의 의미가 바뀌므로 함수 이름과 출력의 ‘충돌 묶음’도 ‘사용 묶음’처럼 새 의미에 맞게 바꾸고, 단독 검사 기대값을 수정해야 한다.
네 번째 문제의 요청문 예시는 다음과 같다. 요구사항을 넓힐 때도 기존 경계 규칙을 되풀이해 우연한 동작 변경을 막는다.
완성 일정 검사기에 방 이름을 추가하라. 방별로 일정을 나눈 다음 같은 방 안에서만 기존 충돌 묶음 알고리즘을 적용하라. 시작 포함·종료 제외와 종료 역전 거부 규칙을 유지하라. 다른 방에서 같은 시간에 진행하는 두 일정은 충돌로 보고하지 마라.
방은 이름의 문자열 오름차순, 각 방의 묶음은 시작 순서로 출력하라. 방 이름은 비어 있으면 거부하라. 다른 방의 동일 시간, 같은 방의 부분 겹침, 같은 방의 맞닿음, 입력 순서를 뒤집은 경우를 검사하라. 기존 검사도 유지하고, 수정 후 실행 결과와 변경한 부분을 제시하라.
확장 코드를 받으면 검사 결과와 함께 변경 차이를 읽는다. 방별로 목록을 나누는 수정이 종료 포함 여부나 날짜 해석까지 바꾸지는 않았는지 확인한다. 새 기능이 성공하는 것과 기존 규칙이 유지되는 것은 각각 확인할 대상이다.