2015. 6. 4. 18:59ㆍTDD
TDD의 아이러니 중 하나는 TDD가 테스트 기술이 아니라는 점이다(워드 커닝엄의 선문답이다). TDD는 분석 기술이며, 설계 기술이기도 하다. 사실은 개발의 모든 활동을 구조화하는 기술이다.
- 《테스트 주도 개발》by 켄트 벡
 
이 포스트는 켄트 벡이 《테스트 주도 개발》에서 2부 예제로 만들다가 만 xUnit코드를 이어서 개발하는 과정을 설명한다. 아래는 켄트 벡이 남긴 xUnit에 대한 작업 목록과 소스 코드이다. 이 포스트에선 'TestSuite를 자동으로 만들기' 기능을 구현할 생각이다. 소스 코드에 대한 설명은 《테스트 주도 개발》을 참고하길 바란다.
| 
 | 
class TestCase:
    def __init__(self, name):
        self.name = name
    def setUp(self):
        pass
    def tearDown(self):
        pass
    def run(self, result):
        result.testStarted()
        self.setUp()
        try:
            method = getattr(self, self.name)
            method()
        except:
            result.testFailed()
        self.tearDown()
class TestSuite:
    def __init__(self):
        self.tests = []
    def add(self, test):
        self.tests.append(test)
    def run(self, result):
        for test in self.tests:
            test.run(result)
class TestResult:
    def __init__(self):
        self.runCount = 0
        self.failureCount = 0
    def testStarted(self):
        self.runCount = self.runCount + 1
    def testFailed(self):
        self.failureCount = self.failureCount + 1
    def summary(self):
        return "%d run, %d failed" % (self.runCount, self.failureCount)
class WasRun(TestCase):
    def setUp(self):
        self.log = "setUp "
    def tearDown(self):
        self.log = self.log + "tearDown "
    def testMethod(self):
        self.log = self.log + "testMethod "
    def testBrokenMethod(self):
        raise Exception
class TestCaseTest(TestCase):
    def setUp(self):
        self.result = TestResult()
    def testTemplateMethod(self):
        test = WasRun("testMethod")
        test.run(self.result)
        assert("setUp testMethod tearDown " == test.log)
    def testResult(self):
        test = WasRun("testMethod")
        test.run(self.result)
        assert("1 run, 0 failed" == self.result.summary())
    def testFailedResult(self):
        test = WasRun("testBrokenMethod")
        test.run(self.result)
        assert("1 run, 1 failed" == self.result.summary())
    def testFailedResultFormatting(self):
        self.result.testStarted()
        self.result.testFailed()
        assert("1 run, 1 failed" == self.result.summary())
    def testSuite(self):
        suite = TestSuite()
        suite.add(WasRun("testMethod"))
        suite.add(WasRun("testBrokenMethod"))
        suite.run(self.result)
        assert("2 run, 1 failed" == self.result.summary())
suite = TestSuite()
suite.add(TestCaseTest("testTemplateMethod"))
suite.add(TestCaseTest("testResult"))
suite.add(TestCaseTest("testFailedResult"))
suite.add(TestCaseTest("testFailedResultFormatting"))
suite.add(TestCaseTest("testSuite"))
result = TestResult()
suite.run(result)
print result.summary()
1. 테스트 추가하기: 인터페이스 설계하기
테스트를 작성할 때는 오퍼레이션의 완벽한 인터페이스에 대해 상상해 보는 것이 좋다. 우리는 지금 오퍼레이션이 외부에서 어떤 식으로 보일지에 대한 이야기를 테스트에 적고 있는 것이다.
- 《테스트 주도 개발》 by 켄트 벡
 
내가 생각하는 단위 테스트의 목표는 세 가지이다.
- 인터페이스 설계: 클래스 이름, 메서드 이름, 파라미터 개수 등을 결정한다.
- 샘플 코드: 다른 개발자에게 API 사용법을 공유한다.
- 회귀 테스트: 코드가 제대로 돌아가는지 확인한다.
TDD에서 테스트를 구현 코드보다 먼저 작성하는 이유는 '인터페이스 설계' 때문이다. 나머지 두 가지 목표는 덤으로 얻는 가치이다. 기능을 사용하는 가장 단순한 API를 상상하고, API를 테스트 코드로 작성해서 눈으로 확인한다. 테스트는 짧고 단순할수록 좋다. 복잡한 테스트는 인터페이스를 단순하게 만들라는 신호이다.
우선, 켄트 벡이 작성한 TestCaseTest#testSuite 테스트를 살펴보자. 아래는 WasRun의 TestSuite를 '수동'으로 생성하는 방법을 보여준다. 이 테스트를 수정해서 WasRun의 TestSuite를 '자동'으로 생성해 보자.
class TestCaseTest(TestCase):
  def testSuite(self):
    suite = TestSuite()
    suite.add(WasRun("testMethod"))
    suite.add(WasRun("testBrokenMethod"))
    suite.run(self.result)
    assert("2 run, 1 failed" == self.result.summary())
아래는 생각난 순서대로 작성한, WasRun의 Testsuite를 '자동'으로 생성하는 인터페이스이다.
suite = WasRun.suite() #팩토리 메서드
suite = TestSuite(WasRun) #완결 생성자(변환 생성자) 
두 인터페이스 중 하나를 골라야 한다. 나는 생성자로 TestSuite를 만들 생각이다. 팩토리 메서드가 더 유연한 인터페이스지만, 지금 당장 필요한 건 아니다. 일단 단순한 인터페이스로 시작하는 것이 좋다.
 
TestCase#testSuite 테스트를 고쳐서 WasRun의 TestSuite를 '자동'으로 만들어보자.
class TestCaseTest(TestCase):
  def testSuite(self):
    suite = TestSuite(WasRun)
    suite.run(self.result)
    assert("2 run, 1 failed" == self.result.summary()) 
그리고 돌려보면, 실패한다.
  > 5 run, 1 failed
2. 테스트 통과시키기: 가짜로 구현하기
테스트를 통과시키는 방법은 세 가지다.
- 명백한 구현
- 가짜로 구현하기
- 삼각측량
TDD를 마스터하고 싶으면 '가짜로 구현하기'를 많이 사용하는 것이 좋다. 처음엔 '가짜로 구현하기'가 바보 같아 보일 수 있다. 그래도 '가짜로 구현하기'와 '작은 단계'로 리팩터링 하는 방법을 연습하는 편이 좋다. 그러면 오버엔지니어링을 피하고, 현재 요구사항에 꼭 맞는 설계를 만들 수 있다. 이 포스트에서는 '가짜로 구현하기'와 '작은 단계'로 리팩토링 하는 전략을 쓰겠다.
 
일단 테스트를 통과시켜야 한다. 지금은 컴파일 에러가 있다. 현재 테스트에서 TestSuite생성자에 파라미터를 넣어 주는데, TestSuite는 파라미터를 받지 않는다.
class TestSuite:
  def __init__(self):
    self.tests = [] 
우선 TestSuite생성자에 파라미터를 하나 추가하자. 파라미터 이름은 testClass로 하자.
class TestSuite:
  def __init__(self, testClass):
    self.tests = [] 
그리고 돌려보자.
  > Traceback (most recent call last): suite = TestSuite()
  > TypeError: __init__() takes exactly 2 arguments (1 given) 
이상한 에러가 생겼다. 새로운 에러의 원인은 실행 코드에 있다. 실행 코드에서 TestSuite를 만들 때 파라미터를 전달하지 않는다.
# 실행코드(TestCaseTest의 TestSuite 만들기)
suite = TestSuite() # <= 여기가 문제이다
suite.add(TestCaseTest("testTemplateMethod"))
suite.add(TestCaseTest("testResult"))
suite.add(TestCaseTest("testFailedResult"))
suite.add(TestCaseTest("testFailedResultFormatting"))
suite.add(TestCaseTest("testSuite"))
result = TestResult()
suite.run(result)
print result.summary() 
이 에러를 잡는 가장 단순한 방법이 뭘까? 가장 먼저 떠오른 아이디어는 메서드 오버로딩이다. 파라미터가 없는 생성자와 파라미터를 받는 생성자 두 개를 만드는 것이다. 하지만 파이썬은 메서드 오버로딩을 지원하지 않아서 쓸 수 없다. 다른 방법을 찾아보니 '기본 파라미터 값'이라는 기능이 있다. 이걸 쓰면 오버로딩을 흉내 낼 수 있다고 한다. 이 기능을 사용하자.
testClass의 '기본 파라미터 값'으로 None을 넣어주자. 그리고 돌려보자.
class TestSuite:
  def __init__(self, testClass=None):
    self.tests = [] 
컴파일 에러는 잡았지만, 테스트는 계속 실패하고 있다.
  > 5 run, 1 failed 
테스트를 통과시키는 단순한 방법이 뭘까? 하드코딩으로 WasRun의 TestSuite를 생성하면 된다. 기존의 TestCase#testSuite 테스트에서 WasRun의 TestSuite를 생성하던 코드를 사용하자. 조건문을 추가해서 testClass가 WasRun인 경우에만 실행되도록 만들자.
class TestSuite:
  def __init__(self, testClass=None):
    self.tests = []
    if (testClass is WasRun):
      self.add(WasRun("testMethod"))
      self.add(WasRun("testBrokenMethod"))그리고 돌려보면 성공한다.
  > 5 run, 0 failed
3. 중복코드 제거하기: 제품 코드 설계하기
'가짜로 구현하기'를 마스터하려면 중복을 찾는 훈련을 해야 한다. TDD에서 중복은 두 가지 형태로 나타난다.
- '테스트 코드'와 '제품 코드' 사이의 중복
- '제품 코드'와 '제품 코드' 사이의 중복
지금은 '제품 코드'와 '제품 코드' 사이에 중복이 있다. 코드에서 'testMethod'라는 글자가 두 번 나온다. TestSuite 클래스의 생성자에서 '문자열'로 'testMethod'가 사용되고, WasRun 클래스의 '메서드 이름'으로 'testMethod'가 사용된다. 중복이다.
class TestSuite:
  def __init__(self, testClass=None):
    self.tests = []
    if (testClass is WasRun):
      self.add(WasRun("testMethod")) # <= 문자열로 'testMethod' 사용
      self.add(WasRun("testBrokenMethod"))
class WasRun(TestCase):
    def setUp(self):
        self.log = "setUp "
    def tearDown(self):
        self.log = self.log + "tearDown "
    def testMethod(self): # <= 메서드 이름으로 'testMethod' 사용
        self.log = self.log + "testMethod "
    def testBrokenMethod(self):
        raise Exception
이 중복만 없애면 이번 리팩토링이 끝난다. 당장 생각나는 해결책은 리플렉션을 쓰는 것이다. 리플렉션을 쓰면 WasRun의 메서드 리스트를 동적으로 찾을 수 있다. 이 구현으로 하드코딩 된 생성자를 없앨 수 있다. 하지만 이 방법은 단계가 너무 크다(구현하기까지 조금 오래 걸린다). 단계가 크면 중간에 실수할 가능성이 높아지고, 어디에서 실수를 했는지 바로 알 수 없다. 작은 단계로 중복을 조금씩 없애보자.
 
아래 코드에서 5번, 6번 라인을 보면 self#add 메서드가 두 번 나온다. 문자열을 리스트에 넣고 for문으로 돌리면 self#add 메서드를 한 줄로 만들 수 있다. 
class TestSuite:
  def __init__(self, testClass=None):
    self.tests = []
    if (testClass is WasRun):
      self.add(WasRun("testMethod"))
      self.add(WasRun("testBrokenMethod")) 
우선 리스트를 하나 추가하자.
class TestSuite:
  def __init__(self, testClass=None):
    self.tests = []
    if (testClass is WasRun):
      names = ["testMethod", "testBrokenMethod"]
      self.add(WasRun("testMethod"))
      self.add(WasRun("testBrokenMethod")) 
돌려보면 돌아간다. 아직 고친 부분이 없으니 당연하다. 이제 추가한 리스트를 이용해서 6번 라인을 수정하자.
class TestSuite:
  def __init__(self, testClass=None):
    self.tests = []
    if (testClass is WasRun):
      names = ["testMethod", "testBrokenMethod"]
      self.add(WasRun(names[0]))
      self.add(WasRun("testBrokenMethod")) 
돌려보면 돌아간다. 7번 라인도 같은 방식으로 고치자.
class TestSuite:
  def __init__(self, testClass=None):
    self.tests = []
    if (testClass is WasRun):
      names = ["testMethod", "testBrokenMethod"]
      self.add(WasRun(names[0]))
      self.add(WasRun(names[1])) 
돌아간다. names의 스펠링이 틀리지 않은 것 같다. for문을 써서 중복 코드(self#add 메서드 호출)를 제거하자.
class TestSuite:
  def __init__(self, testClass=None):
    self.tests = []
    if (testClass is WasRun):
      names = ["testMethod", "testBrokenMethod"]
      for name in names:
        self.add(WasRun(name)) 
돌아간다. 
이제 리스트만 동적으로 얻을 수 있다면 'TestSuite'와 'WasRun'사이의 중복을 없앨 수 있다. Java에서는 리플렉션을 쓰면 메서드 리스트를 알아낼 수 있다. 파이썬에 비슷한 기능이 있는지 찾아보자. API문서를 찾아보면 dir이라는 함수가 있다. 이 함수는 파라미터의 모든 멤버(필드, 메서드)를 리스트로 반환한다. '필드'리스트는 필요 없지만, 다른 함수가 없기 때문에 이걸 써야겠다.
dir 함수를 써서 names에 새 리스트를 넣어주자.
class TestSuite:
  def __init__(self, testClass=None):
    self.tests = []
    if (testClass is WasRun):
      names = ["testMethod", "testBrokenMethod"]
      names = dir(WasRun)
      for name in names:
        self.add(WasRun(name)) 
돌려보면 실패한다. 실패하는 이유는 dir 함수가 상속한 클래스의 멤버까지 찾아내기 때문이다. 현재 코드는 WasRun -> TestCase -> object 순으로 상속한다. 결국 dir(WasRun)는 WasRun, TestCase, object의 모든 멤버를 반환한다. 테스트를 통과시키려면 필요 없는 멤버를 걸러내야 한다.
 
리스트에서 특정 원소만 뽑아내는 기능이 있으면 좋겠다. API문서를 찾아보면 filter 함수가 있다. filter 함수는 두 개의 파라미터를 받는다. 람다와 리스트이다. 람다에 원하는 조건식을 써서 filter 함수에 전해주면, filter 함수는 조건에 맞는 원소만 찾아서 새 리스트로 반환한다.
 
내가 원하는 원소는 이름이 'test'로 시작하는 메서드이다. fileter 함수와 람다를 써서 코드를 수정하자.
class TestSuite:
  def __init__(self, testClass=None):
    self.tests = []
    if (testClass is WasRun):
      names = ["testMethod", "testBrokenMethod"]
      names = filter((lambda each: each.startswith("test")), dir(WasRun))
      for name in names:
        self.add(WasRun(name)) 
돌려보면 잘 돌아간다. 위 코드에서 5번 라인은 이제 필요 없다. 지워버리자.
class TestSuite:
  def __init__(self, testClass=None):
    self.tests = []
    if (testClass is WasRun):
      names = filter((lambda each: each.startswith("test")), dir(WasRun)) 
      for name in names:
        self.add(WasRun(name)) 
잘 돌아간다. 다른 중복을 찾아보자. 5번 라인과 7번 라인에 WasRun이 두 번 나온다. 현재 조건문에서 testClass는 항상 WasRun이다. 조건식이 이것을 보장한다. testClass를 쓰면 중복을 없앨 수 있을 것 같다. 실험해 보자.
우선, 5번 라인의 WasRun을 testClass로 바꿔보자. 그리고 돌려보자.
class TestSuite:
  def __init__(self, testClass=None):
    self.tests = []
    if (testClass is WasRun):
      names = filter((lambda each: each.startswith("test")), dir(testClass))
      for name in names:
        self.add(WasRun(name)) 
잘 돌아간다. 다행이다. 7번 라인도 WasRun을 testClass로 바꾸자.
class TestSuite:
  def __init__(self, testClass=None):
    self.tests = []
    if (testClass is WasRun):
      names = filter((lambda each: each.startswith("test")), dir(testClass))
      for name in names:
        self.add(testClass(name)) 
잘 돌아간다. 이제 코드에 WasRun에 대한 내용이 없다. 충분히 추상화를 한 것 같다. 이 코드로 다른 TestCase의 TestSuite를 만들 수 있을 것 같다. 실험해 보자.
실행코드를 보면 '수동'으로 TestCaseTest의 TestSuite를 만드는 코드가 있다.
# 테스트 실행코드
suite = TestSuite()
suite.add(TestCaseTest("testTemplateMethod"))
suite.add(TestCaseTest("testResult"))
suite.add(TestCaseTest("testFailedResult"))
suite.add(TestCaseTest("testFailedResultFormatting"))
suite.add(TestCaseTest("testSuite"))
result = TestResult()
suite.run(result)
print result.summary() 
위 코드를 고쳐서 '자동'으로 TestSuite를 만들자.
suite = TestSuite(TestCaseTest)
result = TestResult()
suite.run(result)
print result.summary() 
그리고 돌려보자.
  > 0 run, 0 failed 
실패한다. 원하던 결과가 아니다. '0 run, 0 failed'가 아닌 '5 run, 0 failed'가 나와야 한다. 
TestSuite생성자를 고쳐보자. 일단, 조건문을 추가해서 testClass 가 TestCaseTest 인 경우에 실행되도록 만들자.
class TestSuite:
  def __init__(self, testClass=None):
    self.tests = []
    if (testClass is WasRun):
      names = filter((lambda each: each.startswith("test")), dir(testClass))
      for name in names:
        self.add(testClass(name))
    elif (testClass is TestCaseTest):
      names = filter((lambda each: each.startswith("test")), dir(testClass))
      for name in names:
        self.add(testClass(name)) 
돌아간다. 두 조건문의 코드가 똑같다. 중복이다. 조건문을 지우고, 하나의 로직으로 만들자.
class TestSuite:
  def __init__(self, testClass=None):
    self.tests = []
    names = filter((lambda each: each.startswith("test")), dir(testClass))
    for name in names:
      self.add(testClass(name)) 
돌아간다. 이제 '기본 파리미터 값'을 쓰는 코드가 없다. 지워보자.
class TestSuite:
  def __init__(self, testClass):
    self.tests = []
    names = filter((lambda each: each.startswith("test")), dir(testClass))
    for name in names:
      self.add(testClass(name)) 
돌아간다. 코드 추상화가 끝났다. 모든 TestSuite를 자동으로 만들 수 있다. 추상화는 끝났지만, 리팩토링은 조금 더 해야 한다. 위 코드의 4, 5, 6번 라인은 서로 깊게 연관된 로직이다. 해당 라인을 addTests라는 메서드로 뽑아내자.
class TestSuite:
  def __init__(self, testClass):
    self.tests = []
    self.addTests(testClass)
  def addTests(self, testClass):
    names = filter((lambda each: each.startswith("test")), dir(testClass))
    for name in names:
      self.add(testClass(name)) 
돌아간다. 7번 라인은 다른 곳 보다 복잡하다. 메서드로 뽑아내서 단순화해야겠다. 이 코드는 정적 메서드로 뽑아내자. 7번 라인은 인스턴스 멤버를 쓰지 않기 때문에 인스턴스 메서드나 정적 메서드로 뽑아낼 수 있다. 내 기준엔 정적 메서드가 더 단순하다. 
class TestSuite:
  def addTests(self, testClass):
    names = TestSuite._findTestNames(testClass)
    for name in names:
      self.add(testClass(name))
  @staticmethod
  def _findTestNames(testClass):
    return filter((lambda each: each.startswith("test")), dir(testClass)) 
돌아간다. 이제 복잡한 filter 함수를 정리하자. 우선, 람다에 이름을 붙여주자.
class TestSuite:
  @staticmethod
  def _findTestNames(testClass):
    find = (lambda each: each.startswith("test"))
    return filter(find, dir(testClass)) 
돌아간다. 메서드 이름을 생각하면 each를 name으로 바꾸는 게 좋겠다.
class TestSuite:
  @staticmethod
  def _findTestNames(testClass):
    find = (lambda name: name.startswith("test"))
    return filter(find, dir(testClass)) 
돌아간다. dir(testClass)도 이름을 붙여서 균형을 맞추자.
class TestSuite:
  @staticmethod
  def _findTestNames(testClass):
    find = (lambda name: name.startswith("test"))
    allNames = dir(testClass)
    return filter(find, allNames) 
돌아간다. 됐다. 이제 addTests 메서드로 되돌아가자.
class TestSuite:
  def addTests(self, testClass):
    names = TestSuite._findTestNames(testClass)
    for name in names:
      self.add(testClass(name)) 
3번 코드는 인라인 시키자. 그래도 코드 읽기는 불편하지 않을 것 같다.
class TestSuite:
  def addTests(self, testClass):
    for name in TestSuite._findTestNames(testClass):
      self.add(testClass(name)) 
돌아간다. 모든 리팩토링이 끝났다. 새 기능을 추가했고, 잘 돌아가도록 구현했다. 그리고 올바른 자리로 옮겨줬다. 할 일을 끝냈으니 목록에서 지울 수 있다.
 
그런데 걱정거리가 하나 있다. 현재 코드는 test로 시작하는 메서드만 있다면 TestSuite를 만들 수 있다. 꼭 TestCase를 상속하지 않아도 TestSuite를 만들 수 있다. 이걸 막아야 하나? 아직 모르겠다. 일단 목록에 적어놓자. 나중에 xUnit을 어떻게 사용할지 정리되면 처리하자.
 
| 
 | 
 
오늘 작업한 제품 코드는 아래와 같다.
class TestSuite:
  def __init__(self, testClass):
    self.tests = []
    self.addTests(testClass)
  def addTests(self, testClass):
    for name in TestSuite._findTestNames(testClass):
      self.add(testClass(name))
  @staticmethod
  def _findTestNames(testClass):
    find = (lambda name: name.startswith("test"))
    allNames = dir(testClass)
    return filter(find, allNames)
 
마치며
| 사전 설계 | 설 계 -> 구 현 -> 테 스 트 | 
| T D D (점진적인 설계) | 테 스 트     ->      구  현      ->      리팩토링 (인터페이스 설계) (구현코드 설계) | 
 
설계를 언제 해야 하나?
'설계하기'란 결과물의 구조를 결정하는 일을 말한다. 건축가에게 설계하기란 건물구조를 결정하는 일을 말하고, 작가에게 설계하기란 글의 구조를 결정하는 일을 말하고, 프로그래머에게 설계하기란 코드 구조를 결정하는 일을 말한다.
 
전통적으로 소프트웨어 설계는 건축 설계를 모방해 왔다. 하지만 건축에서 효과적인 방식은 소프트웨어에서는 통하지 않았다. 두 제품이 다른 세계의 제품이기 때문이다. 건물은 물리세계 제품이다. 물리세계 제품은 '구현하기 전'과 '구현한 후'의 설계 수정비용 차이가 크다. 예를 들어 화장실 크기를 넓히고 싶다 치자. 구현하기 전에는 거의 공짜로 고칠 수 있다. 지우개와 연필만 있으면 화장실 크기를 바꿀 수 있다. 반면 구현한 후에는 많은 돈을 써야 고칠 수 있다. 기존 화장실 벽을 부수고, 새로운 벽을 세워야 하기 때문에 돈이 많이 든다. 이런 이유로 물리세계 제품은 '구현하기 전에 설계'하는 전략을 사용한다.
 
가상세계 제품은 '구현한 후 설계'하는 전략을 사용한다. 글, 음악, 그림은 대표적인 가상세계 제품이다. 이 중에서 글을 쓰는 작가들의 작업방식을 살펴보자. 작가이자 글쓰기 선생인 윌리엄 진서는 《글쓰기 생각 쓰기》에서 "좋은 글쓰기의 핵심은 고쳐쓰기"라고 말한다. 또 "고쳐쓰기는 부끄러운 일이 아니다"라고 말한다. 대부분의 작가들은 고쳐쓰기를 적극적으로 활용한다. 작가들도 건축가들처럼 '구현하기 전에 설계'를 하긴 하지만, 완벽한 사전설계를 하지 않는다. 고쳐쓰기로 잘못된 설계를 바로잡을 수 있기 때문에 작가들은 최소한의 사전설계만 한다.
 
TDD는 작가들의 작업방식과 비슷하다. 일단 돌아가는 코드(초고)를 만들고 코드를 여러 번 리팩토링(고쳐쓰기)한다. TDD의 핵심은 설계시점의 변화에 있다. TDD는 '구현하기 전 설계'보다 '구현한 후 설계'를 적극적으로 이용한다.
'TDD' 카테고리의 다른 글
| TDD 사이클의 단계별 목표 (0) | 2024.09.08 | 
|---|---|
| 학습 테스트 (라이브러리 사용법부터 오픈소스 기여까지) (4) | 2024.09.03 | 
| TDD로 다형성 로직 설계하기 (2) | 2024.09.02 |