Devin.KR

CS · 기본

비전공자를 위한 컴퓨터공학 지도 - 전공 4년을 개발자 눈으로 따라잡기

소프트웨어 공학 - 요구사항·설계·테스트·협업

요구사항과 명세, 모듈화와 결합도, 테스트 단계, 버전 관리와 리뷰, 완성 코드는 unittest 로 명세를 테스트로 옮기는 예

개발자KR · 원고 갱신

이 장에서 배우는 것

앞 장에서 코드가 실행되기까지의 과정을 살펴보았다. 이번에는 그 코드가 무엇을 해야 하는지 정하고, 여러 사람이 바꾸어도 약속을 지키게 만드는 방법을 다룬다. 혼자 만든 학습 계획 프로그램도 기능이 늘어나면 같은 문제를 만난다. “필요한 과목을 추천한다”는 말만으로는 어떤 결과가 맞는지 판단하기 어렵다.

소프트웨어 공학(software engineering)은 소프트웨어를 만들고 고치는 일을 요구사항, 설계, 검증, 협업의 관점에서 다루는 분야다. 전공 수업에서는 구현 자체뿐 아니라 변경을 어떻게 통제하고 결과를 어떻게 확인할지도 배운다. 이 장에서는 과목 추천 기능의 약속을 정하고, 그 약속을 실행 가능한 테스트로 옮긴다.

  • 사용자의 바람을 입력과 출력이 분명한 요구사항으로 바꾼다.
  • 역할을 나누고 모듈 사이의 의존 관계를 줄이는 기준을 익힌다.
  • 단위 테스트, 통합 테스트, 인수 테스트가 확인하는 범위를 구별한다.
  • 버전 관리와 코드 리뷰가 변경을 검토하는 과정을 이해한다.
  • 표준 라이브러리 unittest로 명세를 검사하는 프로그램을 만든다.

문제 상황

다른 전공을 공부하다 개발자가 된 사람이 과목별 학습 상태를 기록하는 도구를 만든다고 하자. 사용자는 이미 공부한 과목과 앞으로 살펴볼 후보 과목을 입력한다. 도구는 아직 공부하지 않았고 선수 과목을 충족한 후보를 정해진 개수만큼 돌려준다.

처음에는 목록을 잘라 반환하면 될 것 같다. 그러나 동료가 검토하면서 질문한다. 후보에 같은 과목이 두 번 있으면 어떻게 하는가. 이미 공부한 과목은 제외하는가. 자료구조를 모르는 상태에서 알고리즘을 추천하는가. 추천 개수가 음수이면 어떤 뜻인가. 모르는 과목 이름은 무시하는가, 오류로 알려 주는가.

이 질문은 문법 문제가 아니다. 서로 다른 기대를 코드에 넣으면 각자 보기에는 맞는 프로그램이 만들어진다. 개발자는 먼저 판단 기준을 합의해야 한다. 나중에 결과가 달라졌을 때도 그 기준을 근거로 의도한 변경인지 실수인지 구별할 수 있어야 한다.

이번 예제의 과목 관계는 동작을 확인하기 위한 작은 모형이다. 실제 교육과정 전체를 대표하지 않는다. 특히 소프트웨어 공학을 시작하려면 Python만 알면 충분하다는 교육적 결론을 내리는 자료가 아니다. 이 프로그램 안에서 사용할 규칙을 작게 정한 것이다.

요구사항을 검사 가능한 명세로 바꾼다

요구사항(requirement)은 사용자가 필요로 하는 동작이나 지켜야 할 제약이다. “학습 순서를 정하는 데 도움을 준다”는 말은 목적을 설명하지만, 구현이 맞는지 검사할 기준으로는 넓다. 명세(specification)는 그 요구를 입력, 출력, 오류 조건처럼 판단 가능한 형태로 적은 약속이다. 전공 수업에서는 목적을 분석하고 명세로 구체화하는 과정을 요구공학이라고 부른다.

사용자에게 보이는 동작을 정하는 것은 기능 요구사항이다. 처리 시간이나 사용 가능한 실행 환경처럼 동작의 품질과 제약을 정하는 것은 비기능 요구사항이다. 여기서는 외부 패키지와 네트워크 없이 실행된다는 제약을 둔다. 다만 처리 시간의 상한은 정하지 않았으므로, 테스트가 통과했다고 성능 목표까지 달성했다고 말하지 않는다.

먼저 입력 범위를 정한다. known은 이미 공부한 과목 이름의 집합이고, candidates는 사용자가 우선순위를 정해 전달한 과목 이름의 리스트다. 두 입력의 원소는 문자열이며, 이 자료형을 지키는 호출을 대상으로 한다. limit은 추천할 최대 개수다. 반환값은 과목 이름의 새 리스트다. 함수는 입력 집합과 리스트를 수정하지 않는다.

과목 추천 기능에서 합의한 명세
번호조건기대하는 동작
R1추천 가능한 후보가 여러 개다후보의 입력 순서를 유지한다.
R2후보가 중복되거나 이미 공부한 과목이다중복은 한 번만 남기고 학습 완료 과목은 제외한다.
R3선수 과목을 충족하지 못했다그 후보는 제외한다.
R4최대 개수가 0 이상의 정수다그 개수를 넘기지 않는다. 0이면 빈 리스트다.
R5최대 개수가 음수이거나 정수가 아니다ValueError를 발생시킨다. 참과 거짓도 허용하지 않는다.
R6입력에 등록되지 않은 과목이 있다ValueError를 발생시킨다.

경계 조건도 명세에 포함한다. 최대 개수가 0이어도 과목 이름은 검사한다. 오류가 있는 입력을 단순히 빈 결과로 바꾸지 않겠다는 결정이다. 한 번의 추천에서 선수 과목의 충족 여부는 호출 당시 known으로만 판단한다. 앞에서 추천한 과목을 곧바로 공부했다고 간주하지 않는다.

예를 들어 자료구조와 알고리즘을 함께 후보로 넣어도, known에 자료구조가 없다면 알고리즘은 제외한다. “이번 추천 안에서 선수 과목을 순서대로 배운다”는 기능을 원한다면 별도의 명세가 필요하다. 구현자가 편한 쪽으로 해석할 사안이 아니다.

입력으로 지켜야 하는 조건을 사전 조건, 실행 뒤 보장할 결과를 사후 조건이라고 부른다. 함수가 처리하는 범위와 호출자가 지켜야 할 범위를 구별하면 불필요한 추측이 줄어든다. 이 예제는 집합 대신 숫자를 전달하는 호출까지 모두 검사하는 범용 입력 검증기는 아니다.

사용자의 바람을 조건과 예로 구체화하면 테스트로 확인할 수 있다

테스트 사례(test case)는 특정 입력과 기대 결과를 짝지은 검사 하나다. “중복을 제거한다”는 문장을 읽고 끝내는 대신, 같은 과목을 두 번 넣었을 때 한 번만 나오는지 실행한다. 요구사항 번호와 테스트 이름을 연결해 두면 규칙을 바꿀 때 어디를 다시 확인해야 하는지 찾기 쉽다. 전공 수업에서는 이런 연결을 추적성이라고 부른다.

역할을 나누고 변경의 영향을 줄인다

모듈(module)은 관련 기능을 묶어 다른 부분과 구분하는 단위다. Python에서는 파일 하나를 모듈이라고 부르지만, 설계에서는 책임이 나뉜 구성 요소를 넓게 가리키기도 한다. 모듈화는 파일 수를 늘리는 일이 아니라, 어떤 부분이 무엇을 책임지는지 나누는 일이다.

완성 코드는 한 파일에 두되 세 역할을 나눈다. CATALOG는 과목과 선수 과목의 관계를 보관한다. build_plan은 그 자료를 읽어 추천 규칙을 적용한다. 테스트 클래스는 호출 결과를 명세와 비교한다. 추천 함수 안에서 화면 입력을 받거나 결과를 출력하지 않으므로, 테스트가 함수를 직접 호출할 수 있다.

응집도(cohesion)는 한 구성 요소 안의 일들이 같은 목적을 향해 얼마나 모여 있는지 나타내는 관점이다. 추천 판단과 파일 저장과 화면 꾸미기를 한 함수에 넣으면 서로 다른 이유로 같은 함수를 수정하게 된다. 반대로 추천 규칙을 한곳에 모으면 규칙을 바꿀 때 찾아갈 위치가 분명해진다.

결합도(coupling)는 구성 요소들이 서로 얼마나 의존하는지 나타내는 관점이다. 추천 함수가 특정 화면 객체나 저장 경로를 알아야 한다면 그 환경이 바뀔 때 함수도 영향을 받는다. 이 예제는 집합, 리스트, 정수를 받아 리스트를 돌려주는 경계로 의존을 줄인다. 다만 전역 자료인 CATALOG에는 의존한다. 과목표를 여러 종류 지원하게 되면 과목표도 인자로 받는 설계를 검토할 수 있다.

인터페이스(interface)는 구성 요소를 사용하는 쪽과 제공하는 쪽 사이의 접점과 약속이다. 여기서는 build_plan의 인자와 반환값, 오류 규칙이 인터페이스에 해당한다. 내부 반복문을 바꾸더라도 이 약속이 유지되면 호출하는 쪽의 변경을 줄일 수 있다.

작은 함수로 나누었다고 언제나 설계가 좋아지는 것은 아니다. 각 함수가 서로의 내부 상태를 자주 바꾸면 읽어야 할 관계가 늘어난다. 이번에는 입력을 수정하지 않고 결과를 새로 만드는 방식으로 상태 변화의 범위를 좁힌다. 뒤의 통합 검사에서 호출 전후의 입력도 비교한다.

테스트와 협업으로 변경을 확인한다

테스트는 실행한 사례에서 기대한 결과가 나오는지 확인하는 활동이다. 모든 입력에 대해 옳음을 증명하는 활동과는 다르다. 테스트가 통과해도 빠뜨린 조건이 있을 수 있으므로, 명세를 검토하는 일과 코드를 실행하는 일을 함께 해야 한다.

테스트 단계마다 확인하려는 범위가 다르다
단계확인 대상이 예제의 사례주의점
단위 테스트작은 기능의 규칙중복 제거와 개수 제한각 규칙의 경계값도 검사한다.
통합 테스트구성 요소의 연결과목표와 추천 함수의 연결연결 규모가 작은 예제다.
시스템 테스트전체 제품의 동작실제 입력 화면과 저장 기능까지 연결한 검사이 장에는 해당 제품이 없다.
인수 테스트사용자가 합의한 완료 기준구체적인 학습자의 예상 추천 결과자동 검사와 사용자 검토를 구별한다.

unittest는 Python 표준 라이브러리에 포함된 테스트 도구다. TestCase를 상속한 클래스에 test_로 시작하는 메서드를 작성하면 테스트로 수집할 수 있다. 상속은 기존 클래스의 기능을 이어받는 방법이며, 여기서는 검사 결과를 기록하는 기능을 사용하기 위해 적용한다. assertEqual은 실제 값과 기대 값이 같은지 검사하는 메서드다. 이런 확인문을 단언(assertion)이라고 부른다.

테스트 단계는 사용하는 도구나 파일 이름만으로 정해지지 않는다. 같은 unittest로 작은 함수도, 여러 구성 요소가 연결된 동작도 검사할 수 있다. 완성 코드의 통합 검사는 메모리에 있는 과목표와 함수의 연결만 확인한다. 실제 저장 장치나 화면과의 연결까지 검증했다고 해석하면 범위를 넘는다.

버전 관리(version control)는 파일의 변경 이력과 특정 시점의 상태를 기록하는 방법이다. Git에서는 관련 변경을 하나의 기록으로 남기는 단위를 커밋(commit), 작업 흐름을 나누어 진행하는 갈래를 브랜치(branch)라고 부른다. 병합(merge)은 갈라진 변경을 한 흐름으로 합치는 일이다. 이 예제에서는 Git 명령을 실행하지 않고, 검토한 버전과 현재 버전이 같은지 확인하는 규칙만 함수로 표현한다.

코드 리뷰(code review)는 다른 사람이 변경의 목적과 구현을 검토하는 과정이다. 리뷰에서는 명세 누락, 읽기 어려운 분기, 입력 변경, 테스트의 빈틈을 살핀다. 테스트가 통과했다는 사실은 검토에 필요한 근거 중 하나다. 다른 목적의 변경을 한꺼번에 넣으면 검토자가 각 변경의 이유를 따라가기 어려워지므로, 하나의 설명으로 묶이는 변경을 기록하는 편이 좋다.

검토 후 코드를 고쳤다면 이전 승인만으로 현재 변경을 승인했다고 볼 수 없다. 완성 코드의 can_merge는 현재 버전과 검토한 버전이 같고 테스트도 성공했을 때만 참을 반환한다. 버전 문자열은 실제 저장소를 조회한 값이 아니라 비교 규칙을 실험하기 위한 표식이다. 실제 협업 도구는 승인자, 대상 변경, 실행 결과의 연결도 관리해야 한다.

검토 뒤 코드가 바뀌면 현재 버전의 테스트와 리뷰를 다시 확인해야 한다

변경이 올라올 때 검사를 자동 실행하는 방식을 지속적 통합(continuous integration)이라고 부른다. 이 장의 프로그램은 외부 자동 실행 환경을 만들지 않는다. 대신 실패하면 0이 아닌 종료 상태를 반환해, 나중에 자동 실행 환경이 성공과 실패를 구별할 수 있게 한다.

완성 코드

다음 내용을 main.py로 저장한다. 외부 패키지, 입력 대기, 파일 저장, 네트워크 요청은 없다. 테스트 실행 시간처럼 실행마다 달라질 수 있는 값도 출력하지 않는다. 기대 결과는 추천 함수로 다시 계산하지 않고 각 테스트에 직접 적는다.

import unittest


CATALOG = {
    "Python": frozenset(),
    "자료구조": frozenset({"Python"}),
    "알고리즘": frozenset({"자료구조"}),
    "소프트웨어 공학": frozenset({"Python"}),
}


def build_plan(known, candidates, limit):
    if type(limit) is not int or limit < 0:
        raise ValueError("limit은 0 이상의 정수여야 한다")

    names = set(known) | set(candidates)
    if not names.issubset(CATALOG):
        raise ValueError("등록되지 않은 과목이 있다")

    result = []
    seen = set()
    for subject in candidates:
        if subject in seen:
            continue
        seen.add(subject)
        if subject in known:
            continue
        if not CATALOG[subject].issubset(known):
            continue
        result.append(subject)
    return result[:limit]


def can_merge(current_version, reviewed_version, tests_passed):
    return (
        current_version == reviewed_version
        and tests_passed is True
    )


class PlanTests(unittest.TestCase):
    def test_r1_candidate_order(self):
        actual = build_plan(
            {"Python"},
            ["소프트웨어 공학", "자료구조"],
            2,
        )
        self.assertEqual(actual, ["소프트웨어 공학", "자료구조"])

    def test_r2_duplicates_and_known(self):
        actual = build_plan(
            {"Python"},
            ["Python", "자료구조", "자료구조"],
            3,
        )
        self.assertEqual(actual, ["자료구조"])

    def test_r3_prerequisites_use_initial_known(self):
        actual = build_plan(
            {"Python"},
            ["자료구조", "알고리즘"],
            2,
        )
        self.assertEqual(actual, ["자료구조"])

    def test_r4_limit(self):
        candidates = ["자료구조", "소프트웨어 공학"]
        self.assertEqual(build_plan({"Python"}, candidates, 1), ["자료구조"])
        self.assertEqual(build_plan({"Python"}, candidates, 0), [])
        self.assertEqual(build_plan({"Python"}, [], 3), [])

    def test_r5_invalid_limit(self):
        for value in (-1, 1.5, "2", True, None):
            with self.subTest(value=value):
                with self.assertRaises(ValueError):
                    build_plan({"Python"}, ["자료구조"], value)

    def test_r6_unknown_subject(self):
        cases = [
            ({"미등록 과목"}, [], 1),
            ({"Python"}, ["미등록 과목"], 0),
        ]
        for known, candidates, limit in cases:
            with self.subTest(known=known, candidates=candidates):
                with self.assertRaises(ValueError):
                    build_plan(known, candidates, limit)

    def test_integration_catalog_and_inputs(self):
        known = {"Python", "자료구조"}
        candidates = ["알고리즘", "소프트웨어 공학"]
        before_known = known.copy()
        before_candidates = candidates.copy()
        actual = build_plan(known, candidates, 2)
        self.assertEqual(actual, ["알고리즘", "소프트웨어 공학"])
        self.assertEqual(known, before_known)
        self.assertEqual(candidates, before_candidates)

    def test_acceptance_learning_request(self):
        actual = build_plan(
            {"Python"},
            ["알고리즘", "소프트웨어 공학", "자료구조"],
            1,
        )
        self.assertEqual(actual, ["소프트웨어 공학"])

    def test_review_matches_current_version(self):
        self.assertTrue(can_merge("v2", "v2", True))
        self.assertFalse(can_merge("v3", "v2", True))
        self.assertFalse(can_merge("v2", "v2", False))


def main():
    suite = unittest.defaultTestLoader.loadTestsFromTestCase(PlanTests)
    result = unittest.TestResult()
    suite.run(result)
    print(f"실행한 테스트: {result.testsRun}")
    print(f"실패: {len(result.failures)}")
    print(f"오류: {len(result.errors)}")
    print("판정: " + ("통과" if result.wasSuccessful() else "실패"))
    raise SystemExit(0 if result.wasSuccessful() else 1)


if __name__ == "__main__":
    main()

줄별 해설

import unittest는 표준 라이브러리의 테스트 도구를 가져온다. 별도 설치가 필요하지 않다. CATALOG의 각 키는 등록된 과목이고 값은 그 과목의 선수 과목 집합이다. frozenset은 생성 뒤 원소를 추가하거나 제거할 수 없는 집합이다. 과목표를 읽는 코드에서 선수 과목 목록을 실수로 수정하는 일을 줄인다. 바깥쪽 사전까지 수정 불가능해지는 것은 아니다.

type(limit) is not int는 최대 개수의 자료형을 확인한다. Python에서는 bool이 int의 하위 형식이므로 isinstance(True, int)는 참이다. 이번 명세에서는 참과 거짓을 개수로 허용하지 않기 때문에 정확히 int인지 검사한다. 뒤의 음수 검사와 함께 R5를 구현한다.

set(known) | set(candidates)는 두 입력에 등장한 과목을 모은다. 세로선은 집합의 합집합 연산이다. issubset(CATALOG)는 그 과목들이 모두 사전의 키에 포함되는지 검사한다. 결과가 거짓이면 ValueError를 발생시킨다. 이 검사가 추천 개수 제한보다 먼저 실행되므로 limit이 0이어도 등록되지 않은 이름을 찾아낸다.

result = []는 반환할 목록을, seen = set()은 이미 살펴본 후보를 기록할 집합을 만든다. candidates를 앞에서부터 순회하기 때문에 결과 순서도 후보 순서를 따른다. 집합은 중복 여부를 확인하는 보조 수단이며, 반환 순서를 만드는 수단으로 사용하지 않는다.

if subject in seen은 같은 후보를 다시 처리하지 않게 한다. 처음 본 후보는 seen에 기록한 뒤, 이미 공부했는지 검사한다. CATALOG[subject].issubset(known)은 필요한 선수 과목을 모두 공부했는지 검사한다. known에 새 원소를 넣지 않으므로 앞에서 추천한 과목이 뒤 후보의 자격을 바꾸지 않는다.

result[:limit]는 결과의 앞부분을 새 리스트로 돌려준다. 추천 가능한 과목이 limit보다 적으면 있는 만큼 반환한다. 0이면 빈 리스트가 된다. 후보 전체를 검사한 뒤 자르므로 구현이 단순하지만, 후보가 매우 많다면 필요한 개수를 모은 뒤 순회를 끝내는 설계를 검토할 수 있다. 그때도 입력 검증의 순서는 지켜야 한다.

can_merge는 버전 일치와 검사 성공을 함께 요구한다. tests_passed is True는 전달된 값이 참 자체인지 확인한다. 문자열처럼 비어 있지 않다는 이유로 참으로 취급되는 값을 성공 표시로 받아들이지 않는다. 이 함수의 입력은 협업 상태를 단순화한 모형이며 실제 버전 관리 기능은 아니다.

class PlanTests(unittest.TestCase) 아래의 test_ 메서드 아홉 개가 검사 단위다. R1부터 R6까지는 명세 번호를 이름에 넣었다. test_r3_prerequisites_use_initial_known은 추천한 과목을 학습 완료로 간주하지 않는다는 조건까지 확인한다. test_r4_limit은 1, 0, 빈 후보라는 서로 다른 경계를 검사한다.

subTest는 반복문 속 여러 입력을 구분해 기록하는 문맥이다. with는 해당 검사 구간의 시작과 끝을 도구가 관리하게 한다. assertRaises(ValueError)는 안쪽 코드에서 지정한 예외가 발생해야 성공한다. 정상 반환하거나 다른 예외가 나오면 기대와 다르다고 기록한다. 하위 사례가 여러 개여도 이 프로그램의 testsRun은 테스트 메서드 아홉 개를 센다.

통합 검사는 과목표에 등록된 선수 관계가 추천에 반영되는지 확인하고 입력의 복사본과 원본을 비교한다. 인수 기준 검사는 “Python을 공부한 사람이 주어진 후보 중 하나를 받는다”는 구체적인 요청을 표현한다. 자동화한 인수 기준의 예이며, 실제 사용자가 도구를 써 보고 목적에 맞는지 판단하는 과정 전체를 대신하지 않는다.

main은 테스트를 모아 실행하고 결과 수만 출력한다. failures는 단언이 맞지 않은 실패, errors는 예상하지 않은 예외 등으로 검사를 정상 진행하지 못한 오류를 담는다. 마지막의 SystemExit는 프로그램을 종료하며 성공이면 0, 실패이면 1을 전달한다. 파일을 직접 실행했을 때만 main을 호출하는 마지막 조건은, 다른 코드가 이 파일을 가져올 때 테스트가 자동 실행되지 않게 한다.

실행 결과

macOS나 Linux의 터미널에서 main.py가 있는 디렉터리로 이동한 뒤 실행한다. Python 3.12 이상을 사용한다. 난수를 사용하지 않으므로 난수 초기값을 정할 필요가 없다.

python3 main.py

예상 출력은 다음과 같다. unittest의 기본 진행 문구 대신 필요한 수치만 출력하도록 구성했으므로 실행 시간이 표시되지 않는다.

실행한 테스트: 9
실패: 0
오류: 0
판정: 통과

테스트가 실제로 변경을 잡는지도 작은 실험으로 확인할 수 있다. 원본을 따로 보관하고 return result[:limit]를 return result로 바꾸면 개수 제한과 인수 기준 검사가 실패한다. 이때 판정도 실패가 된다. 실험 뒤에는 원래 코드를 복구한다. 실패를 유도하는 실험은 검사가 실행된다는 근거가 되지만, 모든 종류의 잘못을 찾는다는 보장은 아니다.

실무에서 자주 틀리는 것

순서가 약속인데 집합만 반환한다

중복 제거에 집중하다가 사용자 우선순위를 잃는 경우다. 다음 두 코드는 각각 따로 실행할 수 있다. 첫 코드는 원래 후보 순서를 결과에 담지 못하고, 고친 코드는 처음 등장한 순서를 보존한다.

# 틀린 코드
candidates = ["소프트웨어 공학", "자료구조", "소프트웨어 공학"]
result = sorted(set(candidates))
print(result)
# 고친 코드
candidates = ["소프트웨어 공학", "자료구조", "소프트웨어 공학"]
result = []
seen = set()
for subject in candidates:
    if subject not in seen:
        seen.add(subject)
        result.append(subject)
print(result)

틀린 코드의 결과는 자료구조가 먼저 나온다. 고친 코드의 결과는 소프트웨어 공학이 먼저 나온다. 정렬은 중복 제거와 다른 요구다. 자료를 다루는 방법이 명세의 순서 조건을 지키는지 함께 확인해야 한다.

기대 결과도 검사 대상 함수로 만든다

기대 결과를 같은 함수로 계산하면 같은 잘못을 두 번 실행하고도 검사가 통과할 수 있다. 기대 값은 명세에서 도출해야 한다. 다음 코드는 완성 코드의 build_plan을 정의한 상태에서 실행하는 비교 예다.

# 틀린 코드
actual = build_plan({"Python"}, ["자료구조"], 1)
expected = build_plan({"Python"}, ["자료구조"], 1)
assert actual == expected
# 고친 코드
actual = build_plan({"Python"}, ["자료구조"], 1)
expected = ["자료구조"]
assert actual == expected

이 짧은 예의 assert는 조건이 거짓이면 AssertionError를 발생시킨다. 완성 코드에서는 실행 옵션에 따라 생략될 수 있는 assert 문 대신 unittest의 검사 메서드를 사용한다. 중요한 점은 기대 값이 구현과 독립적인 판단 기준이어야 한다는 것이다.

읽기만 할 입력을 수정한다

호출자가 전달한 목록을 직접 바꾸면, 호출 뒤 다른 기능이 보는 후보도 달라진다. 다음 코드는 추천 규칙 전체가 아니라 입력 변경 문제만 보여 주는 작은 실험이다.

# 틀린 코드
def remaining(candidates):
    candidates.remove("Python")
    return candidates

items = ["Python", "자료구조"]
print(remaining(items))
print(items)
# 고친 코드
def remaining(candidates):
    return [name for name in candidates if name != "Python"]

items = ["Python", "자료구조"]
print(remaining(items))
print(items)

틀린 코드에서는 두 출력이 모두 자료구조만 담는다. 고친 코드에서는 반환값만 자료구조를 담고, items에는 두 과목이 남는다. 입력을 수정하는 설계도 가능하지만, 그러려면 수정한다는 사실 자체가 인터페이스의 약속이어야 한다.

검토 뒤 변경한 코드에도 승인을 그대로 적용한다

검사 성공과 리뷰 승인은 같은 정보가 아니다. 검토한 버전과 현재 버전을 연결하지 않으면 다른 내용을 승인한 기록으로 현재 변경을 통과시킬 수 있다.

# 틀린 코드
current_version = "v3"
reviewed_version = "v2"
tests_passed = True
approved = reviewed_version is not None
print(approved and tests_passed)
# 고친 코드
current_version = "v3"
reviewed_version = "v2"
tests_passed = True
approved = current_version == reviewed_version
print(approved and tests_passed)

첫 코드는 True, 고친 코드는 False를 출력한다. 이 실험이 보여 주는 것은 승인과 변경 상태를 연결해야 한다는 원칙이다. 실제 저장소의 병합 조건은 팀의 정책과 도구 설정으로 구현한다.

한눈에 보기

소프트웨어 공학의 핵심과 실무·면접의 연결
핵심실무에서 쓰이는 곳면접에서 묻는 방식답변의 근거
요구사항과 명세구현 전 기대 결과 합의“모호한 요청을 어떻게 구체화하는가”입력·출력·오류와 경계 사례를 설명한다.
모듈화와 결합도변경이 퍼지는 범위 조절“이 역할을 왜 분리했는가”변경 이유와 의존 관계를 설명한다.
테스트 단계규칙과 연결의 문제 탐지“단위 검사만으로 무엇이 부족한가”검사한 범위와 빠진 범위를 구분한다.
버전 관리와 리뷰이력 보존과 변경 검토“리뷰 후 수정하면 무엇을 확인하는가”현재 변경에 대한 검사와 승인을 확인한다.

면접에서는 용어 정의 뒤에 직접 내린 결정을 붙이는 편이 이해를 돕는다. 예를 들어 “후보 순서를 보존해야 해서 결과는 리스트로 만들고 중복 확인에만 집합을 썼다”라고 설명할 수 있다. “테스트 아홉 개가 통과했다”에서 멈추지 않고 과목 이름 오류, 0개 제한, 입력 보존을 확인했다고 말하면 검사의 범위가 드러난다.

더 깊게 볼 서재의 책으로는 Martin Fowler의 Refactoring과 Steve Freeman·Nat Pryce의 Growing Object-Oriented Software, Guided by Tests를 들 수 있다. 앞의 책에서는 동작을 보존하며 구조를 바꾸는 판단을, 뒤의 책에서는 테스트와 객체 간 협력을 함께 설계하는 관점을 살펴볼 수 있다. 예제 언어와 도구가 이 장과 다르므로 Python 문법을 그대로 찾기보다 변경 전후의 책임과 검증 방법을 중심으로 읽는다.

이 장의 명세와 코드는 위 책의 예제를 옮긴 것이 아니라 과목 추천 상황을 위해 구성한 것이다. 도구의 동작을 확인할 때는 Python unittest 공식 문서를 참고할 수 있다. 문서는 도구 사용의 사실을 확인하는 자료이며, 어떤 요구사항을 정할지는 사용자의 목적에 따라 판단해야 한다.

연습 문제

  1. known이 빈 집합이고 후보가 Python, 자료구조 순서이며 최대 개수가 2이면 결과는 무엇인가. 실행 전에 예상하고, 완성 코드에 테스트 메서드를 추가해 확인하라.
  2. “최대 개수가 0이면 과목 이름을 검사하지 않고 빈 리스트를 반환한다”로 명세를 바꾼다고 하자. 기존 테스트 중 어느 사례가 달라져야 하는지 찾고 구현 순서를 설명하라.
  3. 과목표에 테스트 설계라는 과목을 추가하고, 선수 과목은 소프트웨어 공학으로 정하라. 선수 과목을 공부한 경우와 공부하지 않은 경우를 각각 테스트하라.
  4. 현재 버전과 검토한 버전이 같아도 테스트가 실패하면 병합할 수 없다는 규칙을 설명하라. 이어서 이 모형이 실제 협업 환경에서 확인하지 못하는 정보 두 가지를 적어라.

정답과 해설

  1. 결과는 Python만 담은 리스트다. 자료구조의 선수 과목은 Python이지만, 같은 호출에서 추천한 과목은 known에 추가되지 않는다. PlanTests 안에 다음 메서드를 추가한다. 추가 뒤 테스트 수는 10이 된다.

        def test_empty_known_does_not_chain(self):
            actual = build_plan(set(), ["Python", "자료구조"], 2)
            self.assertEqual(actual, ["Python"])
    
  2. test_r6_unknown_subject의 두 번째 사례는 더 이상 오류를 기대할 수 없다. known이 유효하고 후보가 미등록 과목이며 limit이 0인 호출은 빈 리스트를 반환해야 한다. 최대 개수의 자료형과 음수 여부를 검사한 직후 다음 코드를 넣고, 해당 사례는 별도 테스트로 옮긴다.

        if limit == 0:
            return []
    

    새 테스트는 다음과 같다. 원래의 오류 사례 목록에서는 두 번째 항목을 제거한다. 이는 원래 코드의 결함 수정이 아니라 입력 오류 처리 정책의 변경이다.

        def test_zero_limit_skips_subject_validation(self):
            actual = build_plan({"Python"}, ["미등록 과목"], 0)
            self.assertEqual(actual, [])
    
  3. CATALOG에 다음 항목을 추가한다. 이 문제는 원래 명세를 기준으로 독립적으로 실험한다.

        "테스트 설계": frozenset({"소프트웨어 공학"}),
    

    PlanTests 안에 다음 메서드를 추가한다. 후보 이름이 등록되어 있으므로 선수 과목을 모르는 경우는 입력 오류가 아니라 추천 제외다.

        def test_new_subject_prerequisite(self):
            self.assertEqual(
                build_plan({"Python"}, ["테스트 설계"], 1),
                [],
            )
            self.assertEqual(
                build_plan(
                    {"Python", "소프트웨어 공학"},
                    ["테스트 설계"],
                    1,
                ),
                ["테스트 설계"],
            )
    
  4. can_merge는 버전 일치와 테스트 성공을 모두 요구하므로 can_merge("v2", "v2", False)는 거짓이다. 버전이 같다는 사실은 검토 대상이 일치한다는 뜻일 뿐, 검사까지 성공했다는 뜻은 아니다. 이 모형은 승인한 사람에게 검토 권한이 있는지, 전달된 테스트 결과가 실제 현재 변경을 실행한 결과인지 확인하지 않는다. 완성 코드의 마지막 테스트가 확인하는 범위는 세 인자로 표현한 판단 규칙이다.

소프트웨어 공학에서 이 장이 남기는 기준은 네 가지다. 기대를 검사 가능한 약속으로 적고, 변경 이유에 맞춰 역할을 나누고, 검사 범위를 구별하고, 검토한 변경과 실제 반영할 변경을 연결한다. 다음 장에서 보안의 요구를 다룰 때도 무엇을 보호하고 어떤 동작을 금지할지 먼저 명세하는 습관을 이어 간다.

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

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

READER FEEDBACK

질문·의견

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

댓글 0

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

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