본문 바로가기

우아한테크코스 6기/프리코스

우아한테크코스 6기 프리코스 4주차

728x90

1. 3주차 공통 피드백

 

 함수(메서드) 라인에 대한 기준

 

함수 15라인으로 제한

main() 함수에도 해당

공백 라인도 한 라인에 해당

15라인이 넘어간다면 함수 분리를 위한 고민

 

✅ 발생할 수 있는 예외 상황에 대해 고민한다

 

예) 로또 미션

 

  1. 로또 구입 금액에 1000 이하의 숫자를 입력
  2. 당첨 번호에 중복된 숫자를 입력
  3. 당첨 번호에 1~45 범위를 벗어나는 숫자를 입력
  4. 당첨 번호와 중복된 보너스 번호를 입력

 

 

 

왼쪽에서 오른쪽으로 저번 미션에서 구현한 메서드이다.

 

이번 미션에서 발생할 수 있는 예외에 대해 README.md 파일 기능 목록에 적어두었다. 보통 어떠한 기능을 수행하다가 예외 사항이 발생하는 것이기에 굳이 기능 목록과 예외를 분리하지 않았다. 

 

발생할 수 있는 예외는 다음과 같다.

① 식당 날짜를 1부터 31 사이의 숫자로 입력하지 않았을 경우

② 주문할 메뉴와 개수를 올바른 형식으로 입력하지 않았을 경우

③ 각 메뉴의 개수가 0 이하 20 초과일 경우

④ 메뉴가 존재하지 않을 경우

⑤ 중복된 메뉴가 있을 경우

⑥ 메뉴의 종류가 모두 음료일 경우(7) 총 주문 수량이 20 초과일 경우

 

③: 기능 요구 사항에서는 " 메뉴는 한 번에 최대 20개까지만 주문할 수 있습니다."라고 했지만 애초에 주문 수량을 최대 20으로 제한하면 총 주문 수량 계산 전에 미리 예외를 확인할 수 있다.

 

예외 처리는 모두 각각의 객체가 담당하도록 했다. 예를 들어, 메뉴의 종류가 모두 음료일 경우 발생하는 예외는 메뉴가 음료 타입에 대한 정보를 갖고 있기 때문에 해당 객체가 일을 하도록 하기 위해 Menu 열거형에서 예외를 발생했다.

 

⑦ 총 주문 수량 같은 경우는 바뀔 수 있는 가능성이 있기에 상수로 처리해주었다.

 

✅ 비즈니스 로직과 UI 로직을 분리한다

✅ 현재 객체의 상태를 보기 위한 로그 메시지 성격이 강하다면 toString()을 통해 구현한다.

 

toString()은 결국 객체의 상태 즉 인스턴스 변수를 반환하는 메서드이다. 처음에는 Benefit에서 모든 종류의 Event의 혜택 금액을 가져와 혜택 내역을 만들었다. 하지만 어차피 Event의 변화하는 상태가 곧 discount 할인 금액이기 때문에 이 인스턴스 변수와 이벤트 이름을 사용해 Event 객체에 toString() 메서드를 만드는 방법으로 리팩토링했다. 이렇게 하니 Benefit 에서는 그저 Event 타입의 객체를 조건에 맞는 형식으로 프린트하기만 하면 된다.

 

① 할인 혜택이 하나도 없는 경우: "없음"
② 할인 혜택이 있는 이벤트와 없는 이벤트가 같이 있는 경우: 할인 혜택이 없는 이벤트는 아무것도 출력하지 않기

 

 

✅ View에서 사용할 데이터라면 getter 메서드를 통해 데이터를 전달한다.

 

매개변수가 없는 Output의 print 메서드를 제외하고는 모두 getter 메서드를 통해 데이터를 갖고 오도록 했다.

 

✅ 연관성이 있는 상수는 static final 대신 enum을 활용한다

 

예) 로또 미션 Rank

 

이번에는 메뉴를 열거형 클래스로 만들게 됐다. 만약 메뉴를 열거형으로 만들지 않는다면 일일이 Menu라는 클래스를 만들고 인스턴스를 생성해야 한다. Menu라는 열거형은 타입과 가격을 멤버 변수로 갖는다. 또한 1월 새해 프로모션 때 메뉴가 삭제 추가되어도 변경이 용이하다.

 

✅ final 키워드를 사용해 값의 변경을 막는다

 

Event 클래스의 NAME과 YEAR는 모두 상수이지만 각각의 접근 제어자가 final, final static으로 다르다. NAME 같은 경우는 서로 다른 Event 타입을 조상으로 갖는 인스턴스마다 다른 상수이지만 YEAR와 같은 상수는 모든 Event 객체에 대해 공통인 상수이다. 

또한 LAST_DAY_OF_MONTH 해당 월의 마지막 날짜도 static 블럭을 이용해 상수화했는데 다른 연, 월에 사용해도 프로그램의 변경을 최소하하기 위해서였다. 만약 12월의 마지막 날짜인 31일을 사용했다면 2월과 같이 28일까지만 있는 월에서는 유효하지 않은 값을 잡지 못할 것이다.

 

✅ 객체의 상태 접근을 제한한다
✅ 인스턴스 변수의 접근 제어자는 private으로 구현한다.

 

상속할 인스턴스 변수의 경우는 protected를 사용했다.

 

✅ 객체는 객체스럽게 사용한다 (⭐⭐⭐⭐⭐)

 

Lotto 클래스는 numbers를 상태 값으로 가지는 객체이다. 그런데 이 객체는 로직에 대한 구현은 하나도 없고, numbers에 대한 getter 메서드만을 가진다.

 

 

Lotto에서 데이터를 꺼내지(get) 말고 메시지를 던지도록 구조를 바꿔 데이터를 가지는 객체가 일하도록 한다.

 

 

(참고. getter를 사용하는 대신 객체에 메시지를 보내자)

 

위의 참고 내용을 요약하자면 다음과 같다. 결국 객체란 속성 즉, 인스턴스 변수의 모음이다. 링크 안 예시에서 Car이라는 객체는 position이라는 속성을 갖고 있다. 그런데 Cars가 이 객체 고유의 속성을 getter로 꺼내와 직접 비교하는 것은 객체 지향적인 설계가 아닌 것이다. 

 

상태를 가지는 객체를 추가했다면 객체가 제대로 된 역할을 하도록 구현해야 한다.
객체가 로직을 구현하도록 해야한다.
상태 데이터를 꺼내 로직을 처리하도록 구현하지 말고 객체에 메시지를 보내 일을 하도록 리팩토링한다.

 

결론적으로 Car의 고유 속성인 position을 비교하는 일은 Car 자체가 하는 일이되도록 코드를 구현해야 한다.

 

✅ 필드(인스턴스 변수)의 수를 줄이기 위해 노력한다

 

 

수익률과 총 당첨 금액을 굳이 필드를 생성해 저장할 필요가 없다.

 

이번 미션도 마찬가지로 예상 결제 금액은 총 주문 금액 - 혜택 금액이기 때문에 Customer 객체에 굳이 인스턴스 변수를 만들지 않았다.

 

✅ 성공하는 케이스 뿐만 아니라 예외에 대한 케이스도 테스트한다
✅ 예외에 대한 부분 또한 처리해야 한다. 
✅ 결함이 자주 발생하는 부분 중 하나는 경계값이므로 이 부분을 꼼꼼하게 확인해야 한다.

 

예) 당첨 번호 중 로또 번호와 중복되는 부분이 있는지 확인

 

  • EventTest의 경우 유효한 날짜(31)와 유효하지 않은 날짜(32) 모두 테스트했다. 예외 같은 경우에는 isInstanceOf와 hasMessage를 통해 발생한 에러의 타입과 에러 메시지 모두 확인했다. 
  • 최대 주문 수량이 20개 즉 21개부터 예외가 발생하기 때문에 21개 경계값일 때 에러가 발생하는지 확인했다.


✅ 반복적으로 하는 부분을 중복되지 않게 만들어야 한다. 

 

예를 들어 단순히 파라미터의 값만 바뀌는 경우라면 아래와 같이 테스트할 수 있다.

예) @ParameterizedTest

 

크리스마스 디데이 이벤트는 1일부터 25일까지 모두 25개의 테스트 케이스가 있기 때문에 @ParametrizedTest를 사용했다.

 

✅ 테스트를 위한 코드는 구현 코드에서 분리되어야 한다

테스트를 위한 편의 메서드를 구현 코드에 구현하지 마라. 아래의 예시처럼 테스트를 통과하기 위해 구현 코드를 변경하거나 테스트에서만 사용되는 로직을 만들지 않는다.

테스트를 위해 접근 제어자를 바꾸는 경우
테스트 코드에서만 사용되는 메서드

 

(참고. 메서드 시그니처를 수정하여 테스트하기 좋은 메서드로 만들기)

 

단위 테스트하기 어려운 코드를 단위 테스트하기 쉽게 만들기 위해서는 변화하는 부분을 그 메서드에서 제거해야 한다. 자동차 경주 미션을 보면 자동차가 움직이기 위한 조건은 숫자 4 이상이었는데 이때 이 수가 랜덤하기 때문에 테스트가 불가했다. 해당 숫자를 매개변수로 바꾸고 랜덤한 숫자를 만드는 부분을 메서드에서 제거하면 테스트가 가능하다. 하지만 결국 이 기능이 어디론가 가기 때문에 해당 글에서는 인터페이스를 그에 대한 해결책으로 제시했다. 인터페이스가 공통적인 부분을 뽑아내 추상화하는 작업에 쓰이기 때문에 이런 방식으로 시도하면 되지 않을까 싶다.

 

✅ private 함수를 테스트 하고 싶다면 클래스(객체) 분리를 고려한다


가독성의 이유만으로 분리한 private 함수의 경우 public으로도 검증 가능하다고 여겨질 수 있다. public 함수가 private 함수를 사용하고 있기 때문에 자연스럽게 테스트 범위에 포함된다. 하지만 가독성 이상의 역할을 하는 경우, 테스트하기 쉽게 구현하기 위해서는 해당 역할을 수행하는 다른 객체를 만들 타이밍이 아닐지 고민해 볼 수 있다. 

 

  • LottoApplication을 보면 execute를 제외한 모든 함수가 위에서 말한 가독성의 이유만으로 분리한 private 함수였다. private이어서 테스트하지 못했지만 언급한 것처럼 해당 역할을 따로 수행하는 객체를 만들어야겠다고 생각했다.
  • Converter라는 util 클래스가 없다면 이 기능을 클래스 안에 넣어야 하기 때문에 예외가 발생하는 경우를 따로 테스트할 수 없다. 해당 기능을 Converter라는 util 클래스로 분리함으로써 중복되는 코드를 제거했다.

 


 

2. 프로그래밍 요구 사항

 

✅ 도메인 로직에 단위 테스트를 구현해야 한다. 단, UI(System.out, System.in, Scanner) 로직은 제외한다.

✅ 핵심 로직을 구현하는 코드와 UI를 담당하는 로직을 분리해 구현한다.

 

이번 미션을 하는 동안 디자인 패턴 일단 이런 것보다는 아래 문장을 계속 되뇌이면서 진행했다.

 

해당 객체의 정보가 필요할 때는 메시지를 던져 객체가 직접 계산해 갖고 오게 한다.

 

예를 들어 혜택을 담고 있는 클래스(Benefit)를 보면 모든 Event 타입의 객체를 배열로 포함하고 있다. 고객이 혜택을 확인하기 위해서는 모든 Event의 할인을 다 더해야 하는데 이때 Benefit이 직접 계산하기 보다는 Event로부터 할인을 모두 갖고 와 한번에 더하는 것이 객체 지향 설계의 관점으로 보면 더 알맞다.

 

몇몇의 객체는 포함 관계 또는 상속 관계를 적용했다.

 

(1) 포함 관계

 

① Benefit이 Order를 포함
② Event가 Order를 포함
③ Customer가 Order, Benefit를 포함

 

① 위에서 설명

Event는 Order의 주문 내역(purchase)를 확인해 할인 금액을 계산해야 한다.

③ Customer는 Order의 할인 전 주문 금액과 Benefit의 할인 금액을 이용해 고객의 예상 결제 금액을 확인한다.

 

(2) 상속 관계

 

  • Event를 추상 클래스로, 서로 다른 이벤트 5가지를 구현하는 형태로 만들었다. 모든 Event 객체가 공통으로 구현해야 하는 calculateDiscount 메서드를 필수적으로 구현하게 강제하기 위해 Event를 그냥 클래스가 아닌 추상 클래스로 만들어주었다. 또한 조상이 초기화해야 하는 조상 멤버는 super 생성자를 통해 Event 5개 자손 객체가 코드의 중복 없이 초기화하도록 했다.
  • 내년 새해 이벤트에도 이벤트 이름이 바뀌거나 적용하는 할인이 달라지더라도 똑같이 Event 객체를 상속하는 방식으로 구현 가능하다. 

 

✅ 사용자가 잘못된 값을 입력할 경우 IllegalArgumentException를 발생시키고, "[ERROR]"로 시작하는 에러 메시지를 출력 후 그 부분부터 입력을 다시 받는다.

✅ Exception이 아닌 IllegalArgumentException, IllegalStateException 등과 같은 명확한 유형을 처리한다.

 

대부분의 예외가 모두 잘못된 사용자의 입력값 때문에 발생하기 때문에 IllegalArgumentException을 사용했다. 한 가지 사용자의 입력값 (문자열)을 숫자로 바꾸는 과정에서 사용하는 메서드 Integer.parseInt()가 정확히 어떤 예외를 발생하는지 알아보았다 (NumberFormatException).

예외의 메시지를 확인하는 작업은 EventPlannerApplication에서 try-catch문으로 메시지를 꺼내와 Ouput의 메서드에 넘겨주었다. catch문에서는 IllegalArgumentException으로 매개변수의 타입을 설정했는데 NumberFormatException의 조상이 IllegalArgumentException이기 때문에 모든 프로그램에서 발생할 수 있는 예외를 모두 수용할 수 있다.

 


 

3. 기능 요구 사항

 

핵심 기능 파악 후 간단한 것부터 시작했다. 예를 들어 계산기와 숫자 야구 게임의 핵심 기능은 다음과 같다.

 

계산기 - 계산 기능이 핵심 - 일단 두 개의 피연산자부터 구현하기 시작한다.
숫자 야구 게임 - 사용자 숫자와 컴퓨터의 숫자를 비교한 결과를 알려준다.

 

이번 크리스마스 프로모션의 핵심 기능은 이미 이벤트 목표에서 알려주었다. 바로 고객이 혜택을 느끼게 하는 것인데 이를 위해서는 할인 금액을 계산하는 것이 핵심이다. 핵심 기능인 Event를 구현하기 위해 Menu 열거형과 Order 객체를 먼저 구현하기 시작했다.

 

✅ 주문하실 메뉴와 개수를 알려 주세요. (e.g. 해산물파스타-2,레드와인-1,초코케이크-1)

 

이 부분은 다음과 같은 예 때문에 split 메서드를 두 번 사용하는 것으로는 충분하지 않았다. 

 

아이스크림-해산물파스타-2,시저샐러드-4,초코케이크-3

 

고객의 입장에서는 아이스크림과 해산물파스타를 동일 수량으로 시키고 싶기 때문에 충분히 가능한 주문 형식이다. split("-") 메서드를 두 번 쓴다고 가정했을 때 아이스크림, 해산물파스타, 2, 시저샐러드, 4, 초코케이크, 3 이런 형식으로 주문이 분리된다.

이러한 시나리오를 사전에 방지하기 위해 정규식을 도입했다. 정규식은 정해진 규칙을 패턴화해 이 형식에 부합하는가 여부에 따라 참, 거짓을 알려준다.

"([가-힣]+)-([1-9]|1[0-9]|20)"

 

이 정규식은 다음과 같은 규칙을 갖고 있다. (한글 글자 1개 이상)-(1부터 20까지의 정수). 어차피 최대 주문 수량이 20개이기 때문에 한 메뉴를 20개 넘게 시킬 수는 없다.

 

✅ 평일 할인(일요일~목요일): 평일에는 디저트 메뉴를 메뉴 1개당 2,023원 할인

✅ 주말 할인(금요일, 토요일): 주말에는 메인 메뉴를 메뉴 1개당 2,023원 할인

 

해당 기능 외 다른 기능 몇몇 기능도 스트림을 사용해 코드를 줄였다. 스트림은 데이터를 담고 있는 객체(배열, 컬렉션 등)들을 표준화된 방법으로 다루기 위한 기능이다. 원래 해당 기능을 구현하기 위해 디저트 메뉴의 개수, 메인 메뉴의 개수를 구하는 메서드를 따로 구했는데 스트림을 사용함과 동시에 메뉴의 타입을 인자로 주어 리팩토링했다. 

 

✅ 12월 이벤트 참여 고객의 5%가 내년 1월 새해 이벤트에 재참여하는 것

✅ 이벤트 기간: '크리스마스 디데이 할인'을 제외한 다른 이벤트는 2023.12.1 ~ 2023.12.31 동안 적용

 

이벤트 목표에 보면 위와 같은 사항이있다. 이 목표는 결국 해당 크리스마스 프로모션 프로그램을 내년 새해 이벤트에도 써야 한다는 의미이다. 이 목표에 부합하기 위해 위에서 언급한 것 외에 신경 쓴 부분이 바로 LocalDate 객체를 사용하는 것이었다.

평일 할인, 주말 할인, 특별 이벤트 모두 요일과 관련되어 있는 할인을 제공했다. 물론 이 프로그램을 12월만 사용할 것이라면 일일이 며칠은 무슨 요일 이런 식으로 매칭해도 된다. 하지만 이 프로그램을 1월에도 사용하려면 Java API에서 이미 제공하는 기능을 적극적으로 활용하는 것이 좋다. LocalDate를 사용하면 현재 일자에 대한 객체를 생성하고 해당 월의 마지막 날짜, 요일, 시간 등 날짜와 시간에 관한 정보를 손쉽게 얻을 수 있다.

새해 프로모션을 2월까지 진행한다고 가정하면 유효한 날짜를 입력 받을 때 28로 변경해주어야 하지만 LocalDate의 lastDayOfMonth()를 사용하면 해당 값을 상수화할 수 있다.

반응형