HomeAboutMeBlogGuest
© 2025 Sejin Cha. All rights reserved.
Built with Next.js, deployed on Vercel
📖
공부한 책
/
Good Code, Bad Code
Good Code, Bad Code
/
Ch10. 단위 테스트의 원칙

Ch10. 단위 테스트의 원칙

개요단위 테스트 기초좋은 단위 테스트는 어떻게 작성할 수 있는가?단위 테스트가 가져야 할 5가지 주요 기능1. 훼손의 정확한 감지2. 세부 구현사항에 독립적3. 잘 설명되는 실패4. 이해할 수 있는 테스트 코드 5. 쉽고 빠르게 실행퍼블릭 API에 집중하되 중요한 동작은 무시하지 말라중요한 동작이 퍼블릭 API 외부에 있을 수 있다.테스트 더블테스트 더블을 사용하는 이유테스트 단순화테스트로부터 외부 세계 보호외부로부터 테스트 보호테스트 더블의 종류목스텁목과 스텁은 문제가 될 수 있다.목이나 스텁이 실제 의존성과 다른 방식으로 동작하도록 설정되면 테스트는 실제적이지 않다.구현 세부사항과 테스트가 밀접하게 결합하여 리팩터링이 어려워질 수 있다.페이크페이크로 인해 보다 실질적인 테스트가 이루어질 수 있다.페이크를 사용하면 구현 세부 정보로부터 테스트를 분리할 수 있따.목에 대한 의견

개요

단위 테스트에 대한 정확한 정의가 없다고 해도 일반적으로 큰 문제가 되지 않는다.
단위 테스트를 구성하고 있는 것이 정확히 무엇인지, 그리고 정확한 정의가 없음에도 일부러 정의를 고안해 내고 자신이 작성한 테스트가 그 정의에 부합하는지에 대해 너무 집착하지 않는 것이 좋다.
궁극적으로 중요한 것은 코드를 잘 테스트하고 이 작업을 유지보수할 수 있는 방법으로 수행하는 점이다.

단위 테스트 기초

몇 가지 중요한 개념과 용어
  • 테스트 중인 코드 (code under test) : ‘실제 코드’라고도 하고 테스트의 대상이 되는 코드
  • 테스트 코드(test code) : 단위 테스트를 구성하는 코드
  • 테스트 케이스(test case) : 테스트 코드의 각 파일에는 일반적으로 여러 테스트 케이스가 있고, 각 테스트 케이스는 특정 동작이나 시나리오를 테스트함. 실제로 테스트 케이스는 일반적으로 함수이고, 가장 단순한 테스트 케이스가 아니라면 보통 다음과 같이 세개의 섹션으로 나뉘어져 있다.
    • 준비 : 특정 동작 호출하기 위해 몇가지 설정 수행
    • 실행 : 테스트 중인 동작을 실제로 호출
    • 단언 : 테스트 중인 동작이 실행되고 나면 실제로 올바른 일이 발생했는지 확인
  • 테스트 러너 : 테스트를 실행하는 도구
 

좋은 단위 테스트는 어떻게 작성할 수 있는가?

액면 그대로의 단위 테스트는 매우 간단해보인다. 실제 코드가 작동하는지 확인하기 위해 테스트코드를 작성하기만 하면 된다.
안타깝게도 이는 기만적인 것이며, 수년 동안 많은 개발자가 쉽게 단위 테스트를 잘못된 방식으로 작성해 왔다.
단위 테스트에서 문제가 발생하면 유지 관리가 매우 어렵고, 버그가 테스트 코드에서 발견되지 못하고 배포한 뒤에 발생할 수 있다.

단위 테스트가 가져야 할 5가지 주요 기능

1. 훼손의 정확한 감지

단위 테스트의 가장 명확하고 주된 목표는 코드가 훼손되지 않았는지 확인하는 것. 즉, 코드가 의도된 대로 수행하며 버그가 없다는 것을 확인하는 것
이것은 매우 중요한 두 가지 역할을 수행함
  • 코드에 대한 초기 신뢰를 준다.
  • 미래의 훼손을 막아준다.
    • 코드 변경으로 인해 잘 돌아가던 기능이 작동하지 않는 것을 회귀(regression)이라 하고 이러한 회귀를 탐지할 목적으로 테스트 실행하는 것을 회귀 테스트(regression test) 라 함
테스트 대상 코드가 정상임에도 불구하고 때로는 통과하고 때로는 실패하는 테스트를 플래키(flakey)라고 한다. 이것은 보통 무작위성, 타이밍 기반 레이스 조건, 외부 시스템에 의존하는 등의 테스트의 비결정적 동작에 기인한다.
플래키 테스트의 가장 분명한 단점은 개발자들이 결국에는 아무것도 아닌 것으로 판명 날 실패의 원인을 찾느라 시간을 낭비한다는 점이다.
코드에서 어떤 부분이 훼손될 때 그리고 오직 훼손된 경우에만 테스트가 실패하도록 하는 것은 매우 중요하다.
 

2. 세부 구현사항에 독립적

일반적으로 개발자가 코드베이스에 가할 수 있는 변경의 두 가지 종류
  • 기능적 변화 : 이 경우에는 코드 동작을 수정하기에 테스트도 수정해야 할 것으로 기대하고 예상함
  • 리팩터링 : 코드를 사용하는 사람에게 영향을 미치지 않아야 한다. 구현 세부사항을 변경하고 있지만 다른 사용자가 주의해야 할 행동은 없다.
    • 단위 테스트 작성할 때 가능했던 두 가지 접근 방식
      1. 테스트는 코드의 모든 동작을 확인할 뿐 아니라 다양한 구현 세부사항도 확인한다. → 리팩터링을 올바르게 수행했는지 여부와 관계없이 테스트가 실패하고, 테스트를 통과시키려면 많은 코드를 변경해야 한다.
      1. 동작만 테스트할 뿐 구현 세부 사항은 확인하지 않는다. 코드의 공개 API를 사용하여 상태를 설정하고 할 수 있는 곳에서 동작을 확인한다. → 리팩터링을 올바르게 수행했다면 테스트 코드를 수정할 필요 없이 테스트는 여전히 통과
테스트가 구현 세부정보에 의존하지 않으면 코드 리팩터링에 실수가 있었는지 확인해주는 테스트 결과를 신뢰할 수 있다.
 

3. 잘 설명되는 실패

개발자는 테스트 실패에 대한 자세한 내용을 살펴보고 무엇이 문제인지 알아낸다. 개발자는 그들이 무심코 망가뜨린 코드를 잘 모를 수 있기 때문에 테스트 실패가 무엇이 잘못됐는지 알려주지 않는다면 그것을 알아내기 위해 많은 시간을 낭비해야 한다.
테스트 실패가 잘 설명되도록 하는 좋은 방법 중 하나는 하나의 테스트 케이스는 한 가지 사항만 검사하고 각 테스트 케이스에 대해 서술적인 이름을 사용하는 것. 실패한 케이스의 이름을 확인하면 어떤 동작이 작동하지 않는지 정확하게 알 수 있다.

4. 이해할 수 있는 테스트 코드

개발자가 자신이 변경한 사항이 원하는 동작에만 영향을 미친다는 확신을 가지려면 테스트의 어느 부분에 영향을 미치고 있는지, 테스트 코드에 대한 수정이 필요한지 여부를 알 수 있어야 한다. 이를 위해서는 서로 다른 테스트 케이스가 무엇을 테스트하는지 그리고 어떻게 테스트하는지 이해하고 있어야 한다.
여기서 문제가 발생할 수 있는 가장 일반적인 두 가지 경우는
  • 한 번에 너무 많은 것으르 테스트하는 것
  • 너무 많은 공유 테스트 설정을 사용하는 것이다.
이 두가지 모두 이해하기 어렵고 추론하기 어려운 테스트로 이어질 수 있다. 이 경우 개발자들이 특정 변경 사항이 안전한지 이해하는 데 어려움을 겪을 수 있기 때문에 코드 수정의 결과가 안전하지 않을 수 있다.
 
테스트 코드를 이해하기 쉽게 만들기 위해 노력해야 하는 또 다른 이유는 일부 개발자들이 테스트를 코드에 대한 일종의 사용 설명서로 사용하기 때문이다.

5. 쉽고 빠르게 실행

  • 많은 코드 베이스에서 관련 테스트를 통과해야만 병합이 가능한 병합 전 검사를 수행한다. 단위 테스트를 실행하는 데 한 시간이 걸린다면 코드변경 병합 요청이 작거나 사소한 것과 상관없이 최소 한 시간이 걸리기 때문에 모든 개발자들의 속도가 느려진다. 또한 코드 개발중에도 단위 테스트를 수없이 많이 실행하기 때문에 느린 단위 테스트는 개발자의 작업 속도를 느리게 만든다.
  • 테스트를 빠르고 쉽게 유지해야 하는 또다른 이유는 개발자가 실제로 테스트를 할 수 있는 기회를 극대화하기 위함

퍼블릭 API에 집중하되 중요한 동작은 무시하지 말라

우리의 목표 중 하나가 구현 세부 사항에 대한 테스트를 피하는 것이라면 퍼블릭 API 만을 사용하여 테스트해야 한다는 것을 의미함
퍼블릭 API에 집중하면 호출하는 쪽에서 실제로 신경 쓰는 동작을 확인하는 테스트를 작성할 수 밖에 없다. 따라서 주어진 입력에 대해 기대하는 값이 반환되는지 확인하는 일련의 테스트 케이스를 작성할 수 있다.

중요한 동작이 퍼블릭 API 외부에 있을 수 있다.

💡
테스트는 가능하면 퍼블릭 API를 사용하여 테스트하는 것을 목표로 해야 한다. 그러나 설정을 수행하고 원하는 부수 효과를 확인하기 위해 테스트가 공용 API의 일부가 아닌 종속성과 상호작용해야 하는 경우가 많다. ’Public API 만을 이용해 테스트하라’ 와 ‘실행 세부사항을 테스트하지 말라’ 는 둘 다 훌륭한 조언이지만 테스트를 어떻게 할지 안내하는 원칙일 뿐 ‘퍼블릭 API’와 ‘구현 세부 사항’의 정의는 주관적이고 상황에 따라 달라질 수 있다는 점을 알아야 한다. 궁극적으로 중요한 것은 코드의 모든 중요한 동작을 제대로 테스트하는 것이고, 퍼블릭 API 라고 생각하는 것만으로는 이것을 할 수 없는 경우가 있다.
현실에서는 코드가 독립적인 경우는 드물고, 테스트 대상 코드가 수 많은 다른 코드에 의존하는 경우가 많은데 의존하는 코드로부터 외부 입력이 제공되거나 테스트 대상 코드가 의존하는 코드에 부수 효과를 일으킨다면 테스트의 의미가 미세하게 달라질 수 있다.
 
예로, 커피 자판기를 생각해보면 퍼블릭 API 이상의 것을 고려해야 한다. 우선 자동판매기는 설정해야 할 의존성이 있는데, 전원을 콘센트에 꽂고, 물탱크에 물을 채우고, 커피콩을 담는 통을 채우기 전에는 기계를 테스트할 수 없다. 고객에게는 이 모든 것이 구현 세부사항이지만 테스터 입장에서는 이들을 설정하지 않고는 테스트할 방법이 없다.
 
이 자판기가 스마트한 자판기라고 해보면, 물이나 커피콩이 덜어질 때마다 인터넷 연결을 통해 자동으로 담당자에게 알려준다(자판기가 의도적으로 부수 효과를 일으키는 예다). 고객은 이 기능에 대해 잘 모를 수 있고, 안다 하더라도 구현 세부사항임. 그럼에도 불구하고 자판기가 보여주는 중요한 동작이므로 테스트할 필요가 잇다.
  • 우리가 실제로 신경쓰고 테스트해야 하는 것은 반복되어 일어나는 동일한 요청이 서버로 전송되지 않는것 (캐시값이 있을때는)
  • 가능하면 퍼블릭 API를 사용하여 코드의 동작을 테스트해야 한다. 이는 순전히 퍼블릭 함수의 매개변수, 반환값, 오류 전달을 통해 발생하는 동작만 테스트해야 한다는 의미다. 그러나 코드의 퍼블릭 API를 어떻게 정의하느냐에 따라 퍼블릭 API 만으로는 모든 동작을 테스트할 수 없는 경우가 있다. 다양한 의존성을 설정하거나 특정 부수효과가 발생했는지 여부를 확인하는 것이 이에 해당함
    • 서버와 상호작용하는 코드
    • 데이터베이스에 값을 저장하거나 읽는 코드
 

테스트 더블

테스트 더블을 사용하는 이유

테스트 단순화

  • 어떤 의존성은 설정하는 데 많은 노력이 필요할 수 있다. 의존성 자체에서 많은 매개변수를 지정해야 할 수도 있고, 하위 의존성을 많이 설정해야 할 수도 있다. 설정 외에도 하위 의존성에서 원하는 부수효과가 발생했는지 검증해야 할 수도 있다.
  • 반대로 테스트 더블을 사용하면 실제 의존성을 설정하거나 하위 종속성에서 무언가를 검증할 필요가 없다.
  • 테스트 단순화에 대한 또 다른 동기는 테스트를 더 빠르게 실행하는 것이다. 의존성 코드에서 계산 비용이 많이 드는 알고리즘을 호출하거나 시간이 오래 걸리는 설정을 많이 한다면 이에 해당한다.
테스트 더블을 설정하는 것이 의존성을 실제로 사용하는 것보다 더 복잡할 때도 있으므로 테스트 단순화를 위한 테스트 더블의 사용 여부는 사례별로 고려되어야 한다.
 

테스트로부터 외부 세계 보호

코드가 실제로 실행되면 이에 대한 부수효과로 고객의 실제 계좌에서 돈이 인출되는 상황이 있다면, 이렇게 되면 당연히 안된다. 테스트는 절대 이렇게 수행되면 안 된다.
실제 BankAccount 인스턴스 대신 테스트 더블을 사용하면 테스트로부터 외부 세계를 보호할 수 있다.
또한 실제 서버로 요청을 전송하거나 실제 데이터베이스에 값을 쓰는 부수 효과를 유발하는 테스트는 일어날 가능성이 아마 더 많을 것이다. 이러한 문제는 치명적이지는 않지만 다음과 같은 문제가 발생할 수 있다.
  • 사용자는 이상하고 혼란스러운 값을 볼 수 있다 : 실제 데이터베이스에 테스트가 쓰기 동작을 한다면 테스트 레코드가 실제로 보일 수 있다.
  • 모니터링 및 로깅에 영향을 미칠 수 있다. : 테스트 결과 오류 응답이 올바르게 오는지 테스트하기 위해 서버에 일부러 잘못된 요청을 전송할 수 있다. 이 요청이 실제 서버로 전송되면 해당 서버에 대한 오류율이 증가한다. 이 경우 실제로는 문제가 없지만 개발자는 문제가 있다고 생각할 수 있다.
고객 대면 시스템이나 비즈니스에 중요한 시스템에서는 테스트가 부수 효과를 일으키지 않는 것이 중요하다.

외부로부터 테스트 보호

예를 들어 테스트 대상 코드가 실제 은행 계좌를 사용해 잔액을 읽게 되면 테스트가 엉망이 될 수 있다. 그 값이 결정적이지 않고 계속 바뀔수 있기 때문에
이것을 테스트 더블을 통해 수행하면 테스트 코드는 계정 잔액에 대해 미리 결정된 값으로 테스트 더블을 설정할 수 있다. 이는 테스트가 실행될 때마다 계정 잔액이 결정적인 값으로 항상 같을 것이라는 것을 의미한다.

테스트 더블의 종류

목

클래스나 인터페이스를 시뮬레이션 하는 데 멤버 함수에 대한 호출을 기록하는 것 외에는 어떠한 일도 수행하지 않는다.
함수가 호출될 때 인수에 제공되는 값을 기록함.
테스트 대상 코드가 의존성을 통해 제공되는 함수를 호출하는지 검증하기 위해 목을 사용할 수 있다.
목을 사용하면 실제 클래스를 사용하지 않고 settleInvoice 함수를 테스트할 수 있다. 하지만 테스트로부터 외부 세계를 보호하는 데 성공한 반면, 테스트가 비현실적이고, 중요한 버그를 잡지 못할 위험이 있다.

스텁

함수가 호출되면 미리 정해놓은 값을 반환함으로써 함수를 시뮬레이션 한다.
스텁은 테스트 대상 코드가 의존하는 코드로부터 어떤 값을 받아야 하는 경우 그 의존성을 시뮬레이션하는 데 유용하다.
목과 스텁 사이에는 분명한 차이가 있지만 개발자들이 일상적으로 목이라고 말할 때는 둘 다 지칭한다. 그리고 스텁 기능을 제공하는 많은 테스트 도구에서 특정 멤버 함수를 스텁하는 데만 사용하고자 할 때조차 목을 만들어야 한다.
함수가 호출될 때마다 미리 정해진 값을 반환하도록 설정할 수 있고, 이를 통해 코드가 올바로 동작하는지 테스트할 수 있고 테스트 역시 결정적이고 결과를 신뢰할 수 있다.

목과 스텁은 문제가 될 수 있다.

목이나 스텁이 실제 의존성과 다른 방식으로 동작하도록 설정되면 테스트는 실제적이지 않다.

  • 실제로 BankAccount의 debit 함수에 0보다 적은 금액으로 호출될때는 IllegalArgumentException 을 던지지만 이 사항이 위의 테스트 코드에서는 잡히지 않는다.
  • 테스트 코드를 작성하는 개발자는 목이 어떻게 동작할지 결정해야 하는데, 실제 의존성이 어떻게 동작하는지 이해하지 못하면 목을 설정할 때 실수를 할 가능성이 크다.
  • 스텁을 사용할 때도 같은 문제가 있을 수 있다. 특정 값이 실제 의존성 코드가 실제 반환할 수 있는 값인지에 대한 검증이 없기에 진짜 그 값을 반환하는 경우가 없을 수 있다.

구현 세부사항과 테스트가 밀접하게 결합하여 리팩터링이 어려워질 수 있다.

테스트 코드에서 목을 사용할 경우 debit과 credit이 호출되는 것을 확인하는 다양한 테스트 케이스를 작성할 것.
그러나 나중에 리팩터링 해서 debit 과 credit 이 아닌 다른 메서드를 호출하게 된다면? 테스트 케이스 다 수정해야 함
가능한 대안이 없다면 목이나 스텁을 테스트에 사용하는 것이 아예 테스트 코드를 작성하지 않는 것 보다 낫다.
그러나 저자의 개인적인 의견은 실제 의존성이나 페이크를 사용하는 것이 가능하다면 그렇게 하는 것이 보통 더 바람직하다고 함

페이크

페이크는 클래스(또는 인터페이스)의 대체 구현체로서 테스트에서 안전하게 사용할 수 있다.
페이크의 요점은 코드 계약이 실제 의존성과 동일하기 때문에 실제 클래스(또는 인터페이스)가 특정 입력을 받아들이지 않는다면 페이크도 마찬가지라는 것

페이크로 인해 보다 실질적인 테스트가 이루어질 수 있다.

목, 스텁으로 테스트 할 때는 BankAccount.debit() 함수가 마이너스 금액으로 호출되었는지 확인했다. 실제로 debit() 함수는 마이너스 금액을 허용하지 않기 때문에 코드에 버그가 있음에도 불구하고 테스트는 통과했음.
목 대신 페이크를 테스트 케이스에 사용했다면 이 버그가 발견되었을 것

페이크를 사용하면 구현 세부 정보로부터 테스트를 분리할 수 있따.

  • 목이나 스텁 대신 페이크를 사용할 때의 또다른 이점은 테스트가 구현 세부사항에 밀접하게 결합하는 정도가 덜 하다는 것이다.
  • 목을 사용할 때는 debit() 이나 credit() 함수가 호출됐는지 확인하는데 이것은 구현 세부사항임. 이와는 대조적으로 테스트가 페이크를 사용하는 경우 구현 세부사항 대신 최종 계정 잔액이 정확한지 확인함

목에 대한 의견

  • 목 찬성론자 : 런던학파라고도 일컬어지며 개발자는 단위 테스트 코드에서 의존성을 실제로 사용하는 것을 피하고 대신 목을 사용해야 한다고 주장한다. 실제 의존성의 사용을 피하고 목을 사용하는 것은 의존성 코드로부터 값을 받는 경우 스텁을 사용한다는 것을 의미하기도 한다.
    • 주장
    • 단위 테스트가 더욱 격리된다 : 특정 코드에 문제가 있을 때 해당 코드에 대한 단위 테스트에서만 테스트 실패를 유발하며 코드에 의존하는 다른 코드에 대한 테스트는 실패하지 않는다.
    • 테스트 코드 작성이 더 쉬워진다 : 의존성을 실제로 사용하려면 테스트에 필요한 항목과 해당 의존성을 올바르게 설정하고 확인하는 방법을 파악해야 하는데, 목이나 스텁을 사용하면 실제로 의존성의 설정이 필요가 없다.
  • 고전주의자 : 때로는 디트로이트 학파라고 일컬어진다. 목과 스텁은 최소한으로 사용되어야 하고 개발자는 테스트에서 의존성을 실제로 사용하는 것을 최우선으로 해야 한다고 주장한다. 실제 의존성을 사용하는 것이 가능하지 않을 때 페이크를 사용하는 것을 선호한다. 목과 스텁은 실제 의존성이나 페이크를 사용하는 것이 불가능할 때에만 최후의 수단으로 사용되어야 한다.
    • 목은 코드가 특정 호출을 하는지만 확인할 뿐 실제로 호출이 유효한지 검증하지 않는다. 많은 수의 목이나 스텁을 사용하면 코드에 문제점이 있응ㄹ 때에도 테스트는 통과할 수 있다.
    • 고전적인 접근 방식은 구현 세부사항에 대해 더 독립적인 테스트를 할 수 있다. 고전적 접근법에서는 최종 결과를 검증하는데 중점을 두어서.
// 실패 메시지가 이해하기 어려움 Test case testGetEvents failed: Expected : [Event@ea4a92b, Event@3cta99da] But was actually : [Event@3cta99da, Event@ea4a92b] // 실패에 대한 자세한 내용을 잘 설명해주는 테스트 실패 Test case testGetEvents_inChronologicalOrder failed: Contents match, but order differs Expected: [ <Spaceflight, April 12, 1961>, <Moon Landing, July 20, 1969>] But was actually: [<Moon Landing, July 20, 1969>, <Spaceflight, April 12, 1961>]
class AddressBook { private final ServerEndPoint server; private final Map<Int, String> emailAddressCache; String? lookupEmailAddress(Int userId) { String ?cachedEmail = emailAddressCache.get(userId); if(cachedEmail != null) { return cachedEmail; } return fetchAndCacheEmailAddress(userId); } private String? fetchAndCacheEmailAddress(Int userId) { String? fetchedEmail = server.fetchEmailAddress(userId); if (fetchedEmail != null) { emailAddressCache.put(userId, fetchedEmail); } return fetchedEmail; } }
void testSettleInvoice_accountDebited() { BankAccount mockAccount = createMock(BankAccount); MonetaryAmount invoiceBalance = new MonetaryAmount(5.0, Currency.USD); Invoice invoice = new Invoice(invoiceBalance, "test-id"); PaymentManager paymentManager = new PaymentManager(); paymentManager.settleInvoice(mockAccount, invoice); verifyThat(mockAccount.debit).wasCalledOnce().withArguments(invoiceBalance); }
void testSettleInvoice_insufficientFundsCorrectResultReturned() { MonetaryAmount invoiceBalance = new MonetaryAmount(10.0, Currency.USD); Invoice invoice = new Invoice(invoiceBalance, "test-id"); BankAccount mockAccount = createMock(BankAccount); when(mockAccount.getBalance()).thenReturn(new MonetaryAmount(9.99, Currency.USD)); PaymentManager paymentManager = new PaymentManager(); PaymentResult result = paymentManager.settleInvoice(mockAccount, invoice); assertThat(result.getStatus()).isEqualTo(INSUFFICIENT_FUNDS); }
void testSettleInvoice_negativeInvoiceBalance() { BankAccount mockAccount = createMock(BankAccount); MonetaryAmount invoiceBalance = new MonetaryAmount(-5.0, Currency.USD); Invoice invoice = new Invoice(invoiceBalance, "test-id"); PaymentManager paymentManager = new PaymentManager(); paymentManager.settleInvoice(mockAccount, invoice); verifyThat(mockAccount.debit).wasCalledOnce().withArguments(invoiceBalance); }
PaymentResult settleInvoice(...) { ... MonetaryAmount balance = invoice.getBalance(); if (balance.isPositive()) { customerBankAccount.debit(balance); } else { customerBankAccount.credit(absoluteAmount()); } }
class FakeBankAccount implements BankAccount { private MonetaryAmount balance; FakeBankAccount(MonetaryAmount startingBalance) { this.balance = startingBalance; } override void debit(MoentaryAmount amount) { if(amount.isNegative()) { throw new ArgumentException("액수는 0보다 적을 수 없음"); } balance = balance.subtract(amount); } ... }