Devin.KR

AI 개발 · 기본

바이브 코딩 실습 - 작은 서비스 만들기

핵심 규칙을 함수로 - 예약 충돌을 테스트로 고정

시간 겹침 판정 같은 핵심 규칙을 화면·DB 와 떼어 함수로, 경계값(끝 시각=시작 시각)과 반례를 테스트로 먼저, AI 가 놓치기 쉬운 경우 찾기, 완성 코드는 충돌 판정 함수와 unittest 묶음을 함께 실행한다

개발자KR · 원고 갱신

이 장에서 배우는 것

앞 장에서 공간과 예약을 표로 나누고, 예약이 어느 공간에 속하는지 표현했다. 표는 데이터를 담는 모양을 정하지만, 같은 공간에 두 예약을 받아도 되는지까지 정하지는 않는다. 이번에는 그 판단을 함수로 옮긴다. 화면에서 입력받거나 데이터베이스에 저장하기 전에 호출할 수 있는 작은 규칙을 만든다.

예제 서비스에서는 동아리와 스터디 모임이 공간을 함께 쓴다. 한 모임의 예약이 끝나는 시각에 다른 모임의 예약을 시작할 수 있다고 정한다. 이 한 문장은 비교 연산자 하나를 바꿀 만큼 구체적이다. AI가 만든 코드에 그 문장이 제대로 들어갔는지는 설명의 유창함이 아니라 실행 결과로 확인한다.

  • 시간 구간을 표현하고, 예약 시간이 겹치는 조건을 함수로 작성한다.
  • 끝 시각과 시작 시각이 같은 경계값을 테스트로 고정한다.
  • 부분 겹침, 포함 관계, 같은 구간처럼 빠뜨리기 쉬운 반례를 찾는다.
  • 표준 라이브러리 unittest로 규칙과 기대 결과를 함께 실행한다.
  • AI가 수정한 코드의 차이를 읽고, 테스트가 확인하는 범위를 설명한다.

문제 상황

독서 모임이 작은방을 09:00부터 10:00까지 예약했다. 다른 스터디는 10:00부터 11:00까지 같은 방을 쓰려고 한다. 운영자는 앞 모임이 정시에 나오면 다음 모임이 들어갈 수 있다고 판단한다. 그런데 예약 프로그램은 두 예약이 충돌한다고 표시한다. 시간 비교에서 끝점이 같은 경우까지 겹침으로 처리했기 때문이다.

반대 상황도 생긴다. 기존 예약이 09:00부터 10:00까지인데, 새 예약이 08:30부터 10:30까지 들어왔다. 프로그램이 새 예약의 시작 시각만 살피면 충돌을 놓칠 수 있다. 시작점은 기존 예약 밖에 있지만, 새 예약이 기존 예약 전체를 덮고 있다. 사람이 보기에는 분명한 충돌이지만, 조건문에는 별도의 근거가 필요하다.

이 판단을 화면 버튼 처리나 데이터베이스 조회 코드 안에 섞으면 확인하기가 번거롭다. 시간을 비교하려고 화면을 열고, 입력하고, 저장 결과까지 살펴야 한다. 함수로 분리하면 정해진 숫자를 넣고 반환값만 확인할 수 있다. 잘못된 판단을 발견했을 때도 어느 부분을 고쳐야 하는지 좁힐 수 있다.

이번 장에서는 날짜와 공간의 운영 시간은 이미 정해져 있다고 가정한다. 비교 대상은 같은 날짜의 예약이다. 청소나 이동을 위한 준비 시간도 따로 넣지 않는다. 이런 조건을 숨겨 두면 독자가 코드에서 지원하지 않는 동작을 기대하게 된다. 우선 하루 안의 예약 충돌이라는 범위를 분명히 정한다.

시간 구간의 규칙을 먼저 정한다

시각은 자정부터 지난 분으로 표현한다. 09:00은 540, 10:00은 600이다. 시각 문자열을 해석하는 코드 없이도 핵심 비교를 읽을 수 있다. 이번 함수에 들어오는 시작과 끝은 정수이며, 시작은 0 이상이고 끝은 1440 이하다. 시작은 끝보다 작아야 한다. 끝이 1440이면 해당 날짜가 끝나는 경계로 취급한다.

예약은 시작 시각을 포함하고 끝 시각은 포함하지 않는다. 이런 구간을 반열린 구간이라고 한다. 540부터 600까지의 예약은 540에서 시작하지만 600에서는 이미 끝난 것으로 본다. 따라서 600부터 시작하는 예약과 이어 붙일 수 있다. 이 규칙은 시간을 분 단위로 표현했기 때문에 우연히 생기는 결과가 아니라, 서비스가 선택한 약속이다.

기존 예약의 끝과 새 예약의 시작이 같으면 두 예약은 충돌하지 않는다

두 구간 A와 B가 겹치려면 A의 시작이 B의 끝보다 이르고, B의 시작이 A의 끝보다 일러야 한다. 두 조건을 모두 만족해야 한다. 코드로는 a_start < b_end and b_start < a_end가 된다. 시작과 끝이 맞닿는 경우에는 두 비교 중 하나가 거짓이므로 충돌하지 않는다.

이 식은 구간이 일부만 겹치는 경우와 한 구간이 다른 구간을 포함하는 경우를 함께 다룬다. 새 예약의 시작점이 어디에 놓였는지만 묻지 않는다. 두 구간의 시작과 끝을 서로 비교한다. 변수 이름을 바꾸어 A와 B의 순서를 뒤집어도 판단은 같아야 한다.

기존 예약이 540부터 600까지일 때의 기대 결과
새 예약관계충돌이유
480부터 530앞에 떨어짐거짓새 예약이 먼저 끝난다.
600부터 660끝과 시작이 같음거짓끝 시각은 포함하지 않는다.
570부터 630일부가 겹침참570부터 600까지 함께 차지한다.
510부터 630기존 예약을 포함참기존 예약 전체를 덮는다.
550부터 590기존 예약 안에 있음참새 예약 전체가 기존 예약 안에 있다.
540부터 600같은 구간참두 예약이 같은 시간을 차지한다.

공간도 비교해야 한다. 작은방과 큰방은 같은 시간에 사용할 수 있다. 시간 겹침 함수는 공간을 모르고 두 구간만 비교한다. 그 위의 충돌 검색 함수는 공간이 같은 예약만 시간 함수에 넘긴다. 이렇게 나누면 시간 규칙과 공간 선택 규칙을 따로 읽을 수 있다.

이번 입력에서는 공간 이름이 이미 정해진 값이라고 가정한다. 빈 이름이나 등록되지 않은 공간을 처리하는 문제는 여기서 구현하지 않는다. 다만 시간 구간이 잘못되면 비교 자체의 뜻이 흐려지므로, 시간의 형태와 범위는 함수의 입구에서 확인한다. 뒤집힌 구간이나 길이가 없는 구간을 충돌 없음으로 바꾸어 숨기지 않는다.

기대 결과를 테스트로 먼저 적는다

테스트(test)는 정해진 입력을 실행하고 실제 결과가 기대 결과와 같은지 확인하는 코드다. 여기서는 Python에 포함된 unittest를 사용한다. 외부 패키지를 설치할 필요가 없다. TestCase를 상속한 클래스에 test_로 시작하는 메서드를 만들면, unittest가 그 메서드를 테스트로 찾아 실행한다. 클래스와 상속은 이번 예제에서는 테스트를 모아 두는 정해진 틀로 이해하면 된다.

함수를 작성하기 전에 표의 사례부터 옮긴다. assertFalse는 결과가 거짓인지 확인하고, assertTrue는 참인지 확인한다. assertEqual은 목록처럼 구체적인 값이 기대값과 같은지 확인한다. assertRaises는 잘못된 입력이 지정한 예외를 일으키는지 확인한다. 예외(exception)는 정상적인 결과를 낼 수 없을 때 실행 흐름에 그 사실을 알리는 수단이다.

처음부터 모든 구현을 AI에게 맡기기보다, 어떤 결과를 요구하는지 먼저 적어 보내는 편이 좋다. 다음 요청문에는 이 장에서 선택한 경계 규칙과 작업 범위가 들어 있다.

같은 날짜의 공간 예약을 비교하는 함수를 작성하라. 시간은 자정부터 지난 분인 정수다. 시작은 포함하고 끝은 제외한다. 기존 예약의 끝과 새 예약의 시작이 같으면 충돌하지 않는다. 부분 겹침, 포함 관계, 같은 구간, 다른 공간을 unittest로 확인하라. 화면과 데이터베이스 접근은 함수에 넣지 말라. 테스트와 구현을 구분해 제시하고, 각 기대값의 이유를 설명하라.

AI의 답에 “끝점이 같아도 충돌한다”는 설명이 나오면, 코드 실행 전에도 명세와 어긋난 사실을 발견할 수 있다. 설명이 맞더라도 코드가 같다는 보장은 없다. 반대로 코드가 맞고 설명이 틀린 경우도 있다. 둘을 따로 읽고, 테스트 실행으로 실제 동작을 확인한다.

실습에서는 완성 코드의 테스트 메서드를 먼저 적고, 함수 자리에 임시 반환값을 넣어 본다. intervals_overlap이 언제나 False를 반환하면 겹침을 기대하는 테스트가 실패해야 한다. 언제나 True를 반환하면 떨어진 구간과 맞닿은 구간의 테스트가 실패해야 한다. 테스트가 서로 다른 판단을 요구하는지 확인한 뒤 실제 비교식을 넣는다.

테스트를 먼저 쓴다는 말은 처음부터 모든 사례를 알아야 한다는 뜻은 아니다. 현재 합의한 규칙을 코드로 남기고, 새 반례를 찾으면 그 사례를 추가한다는 뜻이다. 함수가 틀렸는데 기대값까지 함수에 맞추어 고치면 규칙이 사라진다. 기대값을 바꾸려면 코드가 아니라 서비스의 약속이 왜 달라졌는지 설명할 수 있어야 한다.

충돌 검색은 같은 공간을 고른 뒤 시간 규칙을 호출하고 충돌한 예약 번호를 모은다

이번 프로그램은 일반 실행기의 소요 시간 표시 대신 테스트 결과 객체를 직접 사용한다. 결과 객체는 실행한 테스트 수와 성공 여부를 기록한다. 정상 실행에서는 고정된 네 줄만 출력하므로 실행할 때마다 결과를 비교하기 쉽다. 실패하면 unittest가 기록한 상세 내용을 출력하고 종료한다.

AI가 놓친 경우를 반례로 찾는다

반례는 어떤 주장이나 구현이 항상 맞지는 않다는 사실을 보여 주는 입력이다. “시작점이 기존 예약 안에 있으면 충돌한다”는 구현에는 기존 예약을 통째로 덮는 새 예약이 반례가 된다. 설명을 길게 다시 요청하기보다, 이 입력과 기대 결과를 전달하면 수정해야 할 지점이 구체적으로 드러난다.

기존 예약은 540부터 600이고 새 예약은 510부터 630이다. 기대 결과는 충돌이다. 시작점만 확인하면 이 사례를 놓친다. 이 입력을 테스트로 추가하고, 두 구간의 순서를 바꾸어도 결과가 같은지 확인하라. 기존의 맞닿은 구간 테스트는 유지하라.

반례를 찾을 때는 숫자를 많이 바꾸기보다 관계를 바꾼다. 떨어짐, 맞닿음, 일부 겹침, 포함, 동일 구간을 차례로 살핀다. 같은 시작과 같은 끝도 따로 확인한다. 공간 이름만 바꾸었을 때 충돌이 사라지는지 살피면, 시간 함수는 맞지만 공간 조건을 빠뜨린 오류도 발견할 수 있다.

입력의 유효성도 별도로 확인한다. 시작과 끝이 같거나, 시작이 끝보다 크거나, 하루 범위를 벗어나면 정상 구간이 아니다. 실수나 문자열도 이번 약속의 입력이 아니다. Python의 bool은 정수와 관련된 타입이지만, True를 1분으로 받아들이는 것은 이 서비스의 의도가 아니다. 따라서 완성 코드에서는 type(value) is int로 정수 타입을 정확히 확인한다.

AI가 수정한 뒤에는 변경 전후의 차이(diff)를 읽는다. 이 작업에서는 비교식, 공간 조건, 입력 검사, 테스트 기대값이 주요 검토 대상이다. 코드가 짧더라도 테스트 한 개를 삭제하거나 기대값을 반대로 바꾸면 검증의 의미가 달라진다. 통과 개수만 보지 말고 어떤 입력을 여전히 확인하는지 읽어야 한다.

아래 완성 코드는 앞 장의 파일 없이 실행된다. 충돌 판단에 저장소가 필요하지 않으므로 데이터베이스는 열지 않는다. 기존 예약 목록은 데이터베이스에서 읽었다고 가정할 수 있는 고정된 자료다. 각 예약은 번호, 공간, 시작, 끝을 가진 사전이며, 기존 목록에 들어 있는 예약은 유효한 자료라는 전제를 둔다.

완성 코드

다음 내용을 main.py에 저장한다. 테스트와 충돌 판정 함수가 같은 파일에 있으며, 실행하면 테스트를 마친 뒤 두 가지 검색 결과를 출력하고 종료한다.

import unittest


def validate_interval(start, end):
    if type(start) is not int or type(end) is not int:
        raise ValueError("시작과 끝은 정수여야 한다.")
    if not 0 <= start < end <= 1440:
        raise ValueError("시간은 0 <= 시작 < 끝 <= 1440이어야 한다.")


def intervals_overlap(a_start, a_end, b_start, b_end):
    validate_interval(a_start, a_end)
    validate_interval(b_start, b_end)
    return a_start < b_end and b_start < a_end


def find_conflicts(room, start, end, reservations):
    validate_interval(start, end)
    conflict_ids = []
    for reservation in reservations:
        if reservation["room"] != room:
            continue
        if intervals_overlap(
            start,
            end,
            reservation["start"],
            reservation["end"],
        ):
            conflict_ids.append(reservation["id"])
    return conflict_ids


class ReservationRuleTests(unittest.TestCase):
    def test_separate_intervals(self):
        self.assertFalse(intervals_overlap(480, 530, 540, 600))
        self.assertFalse(intervals_overlap(610, 660, 540, 600))

    def test_touching_intervals(self):
        self.assertFalse(intervals_overlap(540, 600, 600, 660))
        self.assertFalse(intervals_overlap(600, 660, 540, 600))

    def test_partial_overlap(self):
        self.assertTrue(intervals_overlap(540, 600, 570, 630))
        self.assertTrue(intervals_overlap(570, 630, 540, 600))

    def test_contains_existing_interval(self):
        self.assertTrue(intervals_overlap(510, 630, 540, 600))

    def test_inside_existing_interval(self):
        self.assertTrue(intervals_overlap(550, 590, 540, 600))

    def test_identical_intervals(self):
        self.assertTrue(intervals_overlap(540, 600, 540, 600))

    def test_same_start(self):
        self.assertTrue(intervals_overlap(540, 570, 540, 600))

    def test_same_end(self):
        self.assertTrue(intervals_overlap(570, 600, 540, 600))

    def test_other_room(self):
        reservations = [
            {"id": 1, "room": "큰방", "start": 540, "end": 600},
        ]
        self.assertEqual(
            find_conflicts("작은방", 540, 600, reservations),
            [],
        )

    def test_empty_reservations(self):
        self.assertEqual(find_conflicts("작은방", 0, 1440, []), [])

    def test_invalid_intervals(self):
        invalid_pairs = [
            (600, 600),
            (660, 600),
            (-1, 60),
            (1380, 1441),
            (540.0, 600),
            ("540", 600),
            (True, 60),
            (0, False),
        ]
        for start, end in invalid_pairs:
            with self.subTest(start=start, end=end):
                with self.assertRaises(ValueError):
                    intervals_overlap(start, end, 540, 600)
                with self.assertRaises(ValueError):
                    intervals_overlap(540, 600, start, end)
                with self.assertRaises(ValueError):
                    find_conflicts("작은방", start, end, [])

    def test_multiple_conflicts(self):
        reservations = [
            {"id": 7, "room": "작은방", "start": 540, "end": 600},
            {"id": 8, "room": "큰방", "start": 540, "end": 660},
            {"id": 9, "room": "작은방", "start": 590, "end": 650},
            {"id": 10, "room": "작은방", "start": 610, "end": 680},
        ]
        self.assertEqual(
            find_conflicts("작은방", 580, 610, reservations),
            [7, 9],
        )


def main():
    suite = unittest.defaultTestLoader.loadTestsFromTestCase(
        ReservationRuleTests
    )
    result = unittest.TestResult()
    suite.run(result)

    print(f"실행한 테스트: {result.testsRun}")
    print("테스트 결과:", "통과" if result.wasSuccessful() else "실패")
    if not result.wasSuccessful():
        for test, details in result.failures + result.errors:
            print(f"실패 항목: {test}")
            print(details, end="")
        raise SystemExit(1)

    reservations = [
        {"id": 1, "room": "작은방", "start": 540, "end": 600},
        {"id": 2, "room": "큰방", "start": 540, "end": 660},
        {"id": 3, "room": "작은방", "start": 590, "end": 650},
    ]
    conflicts = find_conflicts("작은방", 580, 610, reservations)
    touching = find_conflicts("작은방", 650, 680, reservations)
    print("충돌 예약 번호:", conflicts)
    print("맞닿은 예약의 충돌 번호:", touching)


if __name__ == "__main__":
    main()

줄별 해설

import unittest는 테스트 도구를 불러온다. 이번 프로그램에는 외부 패키지, API 키, 네트워크 연결이 없다. 함수에 들어가는 값도 고정되어 있어서 현재 시각이나 컴퓨터의 시간대가 결과에 영향을 주지 않는다.

validate_interval의 첫 조건은 시작과 끝의 타입을 확인한다. or로 연결했으므로 둘 중 하나라도 정수가 아니면 ValueError를 일으킨다. 다음 조건의 0 <= start < end <= 1440은 세 가지 비교를 이어 쓴 것이다. 하루 범위와 양수 길이를 한 줄에 표현한다. 올바른 입력이면 함수는 별도 값을 돌려주지 않고 끝난다.

intervals_overlap은 두 구간을 각각 검사한 다음 비교 결과를 반환한다. 반환값은 True 또는 False다. 이 함수에는 출력, 파일 읽기, 데이터베이스 접근이 없다. 같은 인수를 주면 같은 판단을 얻는다. 두 구간이 유효하지 않을 때는 참이나 거짓으로 대신 답하지 않고 예외로 알린다.

find_conflicts는 새 예약부터 검사한다. 반복문 안에 검사 코드를 넣으면 기존 예약이 하나도 없을 때 잘못된 새 예약이 통과할 수 있다. 함수 시작에서 검사하므로 빈 목록에서도 같은 입력 규칙을 지킨다.

conflict_ids는 충돌한 예약 번호를 모으는 목록이다. 공간이 다르면 continue로 그 예약의 나머지 처리를 건너뛴다. 공간이 같을 때만 시간 함수를 호출한다. 충돌하면 번호를 추가하고, 반복이 끝나면 목록을 반환한다. 반환 순서는 입력 목록에서 발견한 순서다. 번호순으로 자동 정렬하는 약속은 넣지 않았다.

ReservationRuleTests 안에는 test_로 시작하는 메서드가 열두 개 있다. 한 메서드에 검사가 여러 번 있어도 출력되는 testsRun은 메서드 실행 수다. 따라서 출력의 12는 모든 입력 조합의 수나 assert 호출 수를 뜻하지 않는다.

test_touching_intervals와 test_partial_overlap은 인수 순서를 뒤집어 다시 검사한다. 겹침 판단이 어느 구간을 먼저 넘겼는지에 따라 달라지면 안 되기 때문이다. 포함 관계는 두 메서드로 나누어 새 구간이 더 큰 경우와 더 작은 경우를 읽기 쉽게 남긴다.

test_invalid_intervals는 여러 잘못된 입력을 반복한다. subTest는 반복 중 어떤 입력에서 검사가 실패했는지 구분해 기록하는 기능이다. 각 입력을 첫 구간, 두 번째 구간, 빈 예약 목록의 새 구간에 넣는다. 유효성 검사가 한쪽에만 있거나 반복문 안에만 있는 오류를 함께 확인한다.

test_multiple_conflicts는 공간이 다른 예약, 실제 충돌 두 개, 끝점에 맞닿는 예약을 한 목록에 섞는다. 기대값 [7, 9]는 충돌 여부뿐 아니라 어떤 예약이 충돌했는지도 확인한다. 목록 안의 기존 예약끼리 겹치는 자료는 검색 함수가 충돌 번호를 빠뜨리지 않는지 확인하기 위해 넣었다. 이 함수가 기존 목록을 정리하거나 저장을 허용한다는 뜻은 아니다.

main은 테스트를 모으고 TestResult에 결과를 기록한다. 실패하면 기록된 설명을 출력하고 종료 코드 1로 끝낸다. 종료 코드는 프로그램이 정상적으로 끝났는지 운영체제에 알리는 숫자다. 테스트가 통과했을 때만 예제 검색을 실행한다. 마지막 조건은 파일을 직접 실행했을 때 main을 호출하는 Python의 관례다.

실행 결과

macOS나 Linux에서 main.py가 있는 폴더로 이동한 뒤 다음 명령을 실행한다. 첫 명령은 경고를 오류로 취급하면서 문법을 컴파일해 확인한다. 성공하면 출력하지 않는다. py_compile은 __pycache__에 컴파일 결과를 만들 수 있지만 데이터베이스 파일은 만들지 않는다. 두 번째 명령은 테스트와 예제를 실행하고 바로 끝난다.

python3 -W error -m py_compile main.py
python3 main.py

정상 실행의 예상 출력은 다음과 같다.

실행한 테스트: 12
테스트 결과: 통과
충돌 예약 번호: [1, 3]
맞닿은 예약의 충돌 번호: []

580부터 610까지의 새 예약은 작은방의 1번과 3번 예약에 모두 겹친다. 큰방의 2번은 시간이 겹치더라도 제외된다. 650부터 680까지의 새 예약은 3번이 끝나는 시각에 시작하므로 충돌 목록이 비어 있다.

문법 검사는 프로그램을 실행했을 때의 판단까지 확인하지 않는다. 반대로 테스트가 통과했다고 모든 가능한 예약이 검증된 것도 아니다. 두 확인은 역할이 다르다. 문법과 경고를 확인하고, 선택한 사례의 동작을 확인한 뒤, 수정된 조건이 명세와 맞는지 읽어야 한다.

실무에서 자주 틀리는 것

끝점이 같은 예약을 충돌로 처리한다

다음 틀린 함수는 유효한 구간을 받아도 맞닿은 예약을 충돌로 판단한다. 이름을 따로 붙였으므로 비교 실습용으로 실행할 수 있다.

def overlap_with_closed_ends(a_start, a_end, b_start, b_end):
    return a_start <= b_end and b_start <= a_end


assert overlap_with_closed_ends(540, 600, 600, 660) is True

이 장의 약속에서는 끝점을 제외하므로 두 비교 모두 작은지를 확인해야 한다. 다음은 유효한 입력에 대한 고친 비교식이다.

def overlap_with_open_end(a_start, a_end, b_start, b_end):
    return a_start < b_end and b_start < a_end


assert overlap_with_open_end(540, 600, 600, 660) is False

준비 시간이 필요해졌다면 비교 연산자만 바꾸지 않는다. 얼마나 띄워야 하는지 새 규칙을 정하고 별도 사례를 테스트로 남겨야 한다.

새 예약의 시작점만 확인한다

다음 함수는 시작점이 기존 예약 안에 있는지만 살핀다. 새 예약이 기존 예약 전체를 덮으면 거짓을 돌려준다.

def overlap_by_start_only(start, end, old_start, old_end):
    return old_start <= start < old_end


assert overlap_by_start_only(510, 630, 540, 600) is False

고친 함수는 두 구간의 시작과 끝을 교차해서 비교한다. 포함 관계도 같은 식으로 처리한다.

def overlap_by_both_intervals(start, end, old_start, old_end):
    return start < old_end and old_start < end


assert overlap_by_both_intervals(510, 630, 540, 600) is True

이 두 비교 예제는 원인을 드러내려고 입력 검사를 생략했다. 서비스에서 사용하는 완성 함수는 앞서 작성한 intervals_overlap처럼 구간 검사도 수행한다.

시간만 확인하고 공간 조건을 빠뜨린다

다음 틀린 함수는 다른 공간의 예약까지 충돌 목록에 넣는다. 단독 실행을 위해 시간 비교식을 함수 안에 직접 적었다.

def conflicts_without_room(room, start, end, reservations):
    return [
        item["id"]
        for item in reservations
        if start < item["end"] and item["start"] < end
    ]


sample = [{"id": 2, "room": "큰방", "start": 540, "end": 600}]
assert conflicts_without_room("작은방", 540, 600, sample) == [2]

고친 예제는 같은 공간이라는 조건을 먼저 확인한다. 실제 프로그램에서는 중복된 시간 비교를 별도 함수 호출로 유지한다.

def conflicts_with_room(room, start, end, reservations):
    return [
        item["id"]
        for item in reservations
        if item["room"] == room
        and start < item["end"]
        and item["start"] < end
    ]


sample = [{"id": 2, "room": "큰방", "start": 540, "end": 600}]
assert conflicts_with_room("작은방", 540, 600, sample) == []

실제 결과를 그대로 기대값으로 삼는다

다음 코드는 함수가 언제나 틀려도 통과한다. 실제 결과와 그 결과 자신을 비교하기 때문이다.

import unittest


def broken_overlap(a_start, a_end, b_start, b_end):
    return False


check = unittest.TestCase()
actual = broken_overlap(540, 600, 570, 630)
check.assertEqual(actual, actual)

고친 검사는 명세에서 정한 True를 직접 기대한다. 아래 코드는 틀린 함수가 검사에서 걸린다는 사실까지 확인하므로 단독 실행해도 정상적으로 끝난다.

import unittest


def broken_overlap(a_start, a_end, b_start, b_end):
    return False


check = unittest.TestCase()
with check.assertRaises(AssertionError):
    check.assertTrue(broken_overlap(540, 600, 570, 630))

AI에게 테스트를 만들어 달라고 할 때도 기대값의 근거를 함께 요구한다. 구현을 다른 표현으로 반복한 테스트는 같은 잘못을 공유할 수 있다.

한눈에 보기

충돌 규칙과 확인 방법을 연결한 요약
대상약속코드에서 확인할 곳
시간 표현같은 날짜의 정수 분이며 0부터 1440 범위다.validate_interval
구간 길이시작은 끝보다 작아야 한다.잘못된 구간 테스트
끝점끝과 다음 시작이 같으면 충돌하지 않는다.맞닿은 구간 테스트
시간 겹침두 시작이 각각 상대의 끝보다 이르다.intervals_overlap
공간같은 공간의 예약만 비교한다.다른 공간 테스트
검색 결과충돌 번호를 입력 목록의 순서로 반환한다.여러 충돌 테스트
수정 검토실행 결과와 변경 내용을 함께 확인한다.테스트 기대값과 비교 조건

이번 함수는 요청을 판단하는 규칙을 담는다. 데이터베이스에서 예약을 읽거나 새 예약을 저장하지는 않는다. 다음 장에서는 입력을 받아 이 규칙으로 판단하고 저장하는 흐름을 연결한다. 그때도 이미 정한 경계값과 반례를 유지하면, 입력 방식이 바뀌어도 시간 규칙이 함께 바뀌었는지 확인할 수 있다.

연습 문제

  1. 기존 예약이 720부터 780일 때, 새 예약 660부터 720, 720부터 750, 750부터 810, 780부터 840의 충돌 여부를 적는다. 각각 어떤 비교가 결과를 결정하는지 설명한다.
  2. ReservationRuleTests에 하루 경계를 확인하는 테스트를 추가한다. 0부터 60과 60부터 120은 충돌하지 않고, 1380부터 1440과 1410부터 1440은 충돌해야 한다. 마지막 구간의 끝을 1441로 바꾸면 ValueError가 발생해야 한다.
  3. 충돌 번호의 반환 순서를 확인하는 테스트를 추가한다. 작은방 예약을 번호 9, 번호 4 순서로 넣고 두 예약 모두 새 예약과 겹치게 한다. 기대값을 [9, 4]로 정한 이유를 설명한다.
  4. AI가 맞닿은 구간의 기대값을 False에서 True로 바꾸고 모든 테스트가 통과한다고 답했다. 어떤 변경을 받아들이기 전에 확인해야 하는지 작업 기록에 세 문장으로 적는다.

정답과 해설

첫 문제의 답은 차례로 거짓, 참, 참, 거짓이다. 660부터 720은 새 예약의 끝과 기존 예약의 시작이 같다. 기존 시작 720이 새 끝 720보다 작지 않으므로 충돌하지 않는다. 720부터 750과 750부터 810은 두 비교를 모두 만족한다. 780부터 840은 새 시작 780이 기존 끝 780보다 작지 않아서 충돌하지 않는다.

두 번째 문제는 다음 메서드를 ReservationRuleTests 안에 추가하면 된다. 1440은 끝으로 허용되지만 1441은 범위를 벗어난다. 기존 테스트와 함께 실행하면 메서드가 하나 늘어 테스트 수는 13이 된다.

    def test_day_boundaries(self):
        self.assertFalse(intervals_overlap(0, 60, 60, 120))
        self.assertTrue(intervals_overlap(1380, 1440, 1410, 1440))
        with self.assertRaises(ValueError):
            intervals_overlap(1380, 1440, 1410, 1441)

세 번째 문제는 다음 메서드로 확인한다. 번호는 정렬 기준이 아니라 예약을 가리키는 값이다. 현재 함수는 입력 순서대로 검사하고 번호를 추가하므로 [9, 4]가 기대값이다. 정렬을 요구하려면 반환 순서의 약속을 먼저 바꾸어야 한다.

    def test_conflict_order(self):
        reservations = [
            {"id": 9, "room": "작은방", "start": 540, "end": 600},
            {"id": 4, "room": "작은방", "start": 570, "end": 630},
        ]
        self.assertEqual(
            find_conflicts("작은방", 580, 590, reservations),
            [9, 4],
        )

두 번째와 세 번째 문제의 메서드를 모두 추가하면 테스트 수는 14가 된다. 나머지 예제 검색 출력은 그대로다. 테스트 수를 처음 값에 맞추려고 기존 메서드를 지우지 않는다. 새로운 사례를 추가했다는 사실을 출력 변화와 함께 기록한다.

네 번째 문제의 작업 기록은 다음처럼 쓸 수 있다. 테스트 통과는 현재 기대값과 구현이 맞는다는 뜻이므로, 기대값이 명세를 지키는지도 확인해야 한다.

끝 시각과 다음 시작 시각이 같으면 충돌하지 않는다는 기존 약속을 확인했다. 변경 내용을 읽어 맞닿은 구간의 기대값이 True로 바뀐 것을 발견했으며, 명세 변경이 없으므로 False를 유지한다. 구현의 비교 조건을 검토하고 전체 테스트를 다시 실행해 경계값과 포함 관계가 함께 유지되는지 확인한다.

오탈자·오류 제보 비공개로 접수되어 원고 수정에 반영됩니다

이메일 등 개인정보는 받지 않습니다. 답변이 필요한 질문은 아래 댓글을 이용해 주세요.

READER FEEDBACK

질문·의견

내용에 관한 질문이나 더 나은 설명을 위한 의견을 남겨 주세요. 오탈자는 위의 제보 양식이 더 빨리 반영됩니다. 이 댓글은 원래 게시글과 같은 자리에 쌓입니다.

댓글 0

아직 댓글이 없습니다. 첫 댓글을 남겨 보세요.

댓글을 남기려면 로그인이 필요합니다.