[Java] 우테코 프리코스 3주차 - 자판기

19 분 소요

시작하며…

어느덧 3주의 프리코스의 마지막 과제가 출제되었다.
보통 최종 코딩테스트가 3주차와 비슷한 수준으로 나오는 것 같아서, 더 열심히 과제를 풀어보려 한다.

항상 1,2번째 과제는 동일하고 3번째 과제가 다르게 나와서 내심 어떤게 나올지 궁금했는데, 자판기를 구현하는 과제가 나왔다.
그 동안에는 모르는 부분이 있었다면 인터넷에서 로직을 찾아 보고 대략 해답을 얻을 수 있었지만 이번에는 혼자서 모든 걸 해내야한다는 생각에
설레기도 하면서 두렵기도 하다. 물론 PR된 다른참가자걸 보고 힌트를 얻을 수도 있지만, 마지막 과제인만큼 혼자 힘으로 처음부터 구현해보려 한다!

추가로 다음주에 있을 코딩테스트가 온라인으로 바뀌었다고 한다…
내심 직접 가서 우형 본사가 어떻게 되어있는지 구경도 해보고 더 자극도 받고 싶었는데 아쉬웠다.
이제 우형 본사를 직접 눈으로 볼 방법은 우테코에 붙는 방법밖에 없다. 화이팅!!!

기능 구현 목록 작성

이제 어느덧 익숙해진 기능 구현 목록 작성이다.
이번에도 기능과 예외를 중점으로 프로그래밍 실행 결과를 보며 어느 기능이 모여 이 프로그램이 되는지 따져가며 하나씩 적어 내려갔다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
## 🛠 기능 구현 목록
* 자판기가 보유할 금액을 입력받는다. 
    * 사용자가 입력한 값은 숫자로만 이루어져야 한다.
    * 사용자가 입력한 값은 10으로 나누어 떨어져야 한다.
    * 공백이 입력되서는 안된다.
* 보유하고 있는 금액으로 갖고있는 동전을 무작위로 생성한다.
* 자판기가 보유한 동전들의 갯수를 출력한다.
* 자판기에 추가할 상품명과 가격, 수량을 입력받는다.
    * 상품명, 가격, 수량은 쉼표(,)로 구분한다.
        * 가격과 수량은 숫자로 이루어져야 한다.
        * 공백값이 들어와서는 안된다.
    * 개별 상품은 세미콜론(;)으로 구분한다.
* 투입 금액을 입력 받는다.
    * 투입금액은 숫자로만 이루어져야한다.
    * 투입금액은 공백이 들어와서는 안된다.
* 현재 자판기가 갖고있는 돈을 출력한다.
* 구매할 상품명을 입력받는다.
    * 없는 상품을 입력받으면 안된다.
    * 구매할 상품의 가격보다 적은 돈을 갖고있으면 안된다.
* 남은 금액이 상품의 최저가격보다 적으면 바로 잔돈을 돌려준다.
* 모든 상품이 소진 됐다면, 바로 잔돈을 돌려준다.

기능 구현 목록을 적으며 느낀점은, 역시 마지막 과제… 만만치 않다는 것이다.

우선 보유하고 있는 금액으로 동전을 무작위로 생성하는 부분이 조금 어려워보였다.
하지만 막상 구현하면 어렵지 않은 경우가 많으니, 직접 몸으로 부딪혀 보며 만들어 보자!

자판기가 보유할 금액을 입력받는다.

테스트 코드 작성

테스트 코드 작성은 진짜 오랫동안 연습해야 정착될 것같다는 생각이든다…
나름 꽤 열심히 해보려 하는데 도메인 지식이 부족해서 그런가 너무 까다롭고 어렵다.

테스트 코드를 작성해야하는 기능과 안해도 되는 기능에 대한 기준이 애매해서 항상 헷갈렸는데,
우테코 회고글 중에서 public이 붙은 메서드는 왠만하면 모두 작성하는게 좋다고 해서 그렇게 해보려고 한다.

1
2
3
4
5
6
7
class VendingMachineTest {
    @ParameterizedTest
    @ValueSource(strings = {"", "qwe", "1413"})
    public void 자판기_투입금액_예외테스트(String input) {
        assertThatThrownBy(() -> new MachineMoney(input)).isInstanceOf(IllegalArgumentException.class);
    }
}

입력을 받고 유효성 검사

우선 VendinMachine이라는 클래스를 하나 만들어주고, 자판기가 가진 돈을 저장받아야 하므로 int money를 포장한 MachineMoney라는
클래스를 만들어 주었다. 여기서 돈에 대한 유효성 검사를 진행하도록 했다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
public class MachineMoney {
    final static int MIN_COIN_UNIT = 10;

    private int money;

    public int getMoney() {
        return money;
    }

    public MachineMoney(String money) throws IllegalArgumentException {
        machineMoneyValidation(money);
        this.money = Integer.parseInt(money);
    }

    private void machineMoneyValidation(String money) throws IllegalArgumentException {
        isDigitString(money);
        isBlank(money);
        isCorrectAmount(money);
    }

    private void isDigitString(String money) throws IllegalArgumentException {
        for (char c : money.toCharArray()) {
            isDigit(c);
        }
    }

    private void isDigit(char c) throws IllegalArgumentException {
        if (!Character.isDigit(c)) {
            throw new IllegalArgumentException("[ERROR] 입력값은 숫자로만 이루어져야 합니다.");
        }
    }

    private void isBlank(String money) throws IllegalArgumentException {
        if (money.isEmpty()) {
            throw new IllegalArgumentException("[ERROR] 입력값이 비어있습니다.");
        }
    }

    private void isCorrectAmount(String money) throws IllegalArgumentException {
        if (Integer.parseInt(money) % MIN_COIN_UNIT > 0) {
            throw new IllegalArgumentException("[ERROR] 입력값은 10으로 나누어 떨어져야 합니다.");
        }
    }

상수에 대한 관리를 어떻게 해야할지 내내 고민했는데, 모든 상수를 Constant 클래스에서 모아서 관리하는것 보다는, 공용으로 쓰지 않고 그 클래스에서만
사용하는 간단한 상수는 클래스 자체에서 관리하는게 더 편할 것 같다는 생각이 들어 그렇게 하기로 했다.

이제 throw한 예외가 출력되면 이를 catch하고 다시 값을 받을 값들을 구현해보자.
먼저, “자판기가 보유하고 있는 금액을 입력해 주세요.” 라는 문구를 출력하고 값을 받도록 InputView 클래스를 정의해주고, 해당 메서드를 작성해준다.

1
2
3
4
5
6
public class InputView {
    public static String printSetMachineMoney() {
        System.out.println("자판기가 보유하고 있는 금액을 입력해 주세요.");
        return Console.readLine();
    }
}

그리고 출력되는 메시지는 상수이므로, 따로 SystemMessage 클래스를 만들어, 모든 시스템 메시지를 이 클래스에서 관리 하도록 해줄 것 이다.

1
2
3
4
5
6
7
8
9
10
11
12
13
public enum SystemMessage {
    PRINT_SET_MACHINE_MONEY_MESSAGE("자판기가 보유하고 있는 금액을 입력해 주세요.");

    private String message;

    SystemMessage(String message) {
        this.message = message;
    }

    public String print() {
        return message;
    }
}

그럼 이제 다시 InputView를 수정해주면,

1
2
3
4
5
6
public class InputView {
    public static String printSetMachineMoney() {
        System.out.println(SystemMessage.PRINT_SET_MACHINE_MONEY_MESSAGE.print());
        return Console.readLine();
    }
}

이로써 입력을 받고 유효성 검사를 하도록 기능을 추가했다.

테스트 코드도 올바르게 동작하는것으로 보아, 이 코드가 유효성을 올바르게 체크하고 예외를 출력하고 있음을 알 수 있다.

그럼 만들어준 MachinMoney를 멤버 변수로 갖는 VendingMachine 클래스를 만들어서 MachinMoney를 set할 수 있도록 작성 해준다.

1
2
3
4
5
6
7
8
public class VendingMachine {
    MachineMoney machineMoney;
    
    public void setMachineMoney(String money) throws IllegalArgumentException {
        machineMoney = new MachineMoney(money);
    }

}

이제 이 기능이 Application에서 돌아가도록 Controller를 만들어 뷰와 도메인을 이어주자!

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public class VendingMachineController {
    public void run() {
        VendingMachine vendingMachine = new VendingMachine();
        setMachineMoney(vendingMachine);
    }

    private void setMachineMoney(VendingMachine vendingMachine) {
        try {
            vendingMachine.setMachineMoney(InputView.printSetMachineMoney());
        } catch (IllegalArgumentException exception) {
            OutputView.printErrorMessage(exception);
            setMachineMoney(vendingMachine);
        }
    }

VendingMachineController 클래스를 만들어주고, 이 객체를 main에서 생성해주고 run() 메서드를 실행하면 프로그램이 동작하게 메서드를 만들어 줄 것 이다.

setMachineMoney() 메서드가 먼저 동작되어, 자판기가 보유할 돈을 입력받고 이에 대한 유효성 검사를 진행한 뒤, 만약 IllegarArgumentException이
발생하면 이를 catch해서 에러 메시지를 출력한 후, 다시 그 부분의 입력을 받게 될 것이다.

For input string : 에러 해결

이 부분에서 사실 한참을 헤멨는데, 테스트 코드는 모두 통과하는데도 불구하고 자꾸 에러메시지가 출력이 안되고 “For input string: “ㅁㄴㅇ”“라는 에러가 출력되었다.
분명 코드대로라면 IllegalArgumentException을 받으면 이에 대한 에러메시지를 출력해야 하는데, 모든 에러에 대해서 이러한 메시지만을 출력하였다.

그래서 이 에러를 검색했더니 Integer.ParseInt()에서 문자열이 들어올 때 출력되는 에러라고 해서, MachinMoney의 Money를 스트링으로 받아서 ParseInt로 넘겨주어 int로 변경하는 부분에서 발생하는 줄 알고, money의 자료형을 String으로 바꾸었는데도 안 고쳐져서 한참을 헤매버렸다.

다시 코드를 천천히 검토해보니, 유효성 검사 순서에서 isCorrectAmount 메서드가 가장 첫줄에 있어 숫자가 아닌 문자가 들어왔을때 먼저 예외를 출력해주지 못하고 바로 parseInt로 들어가서 생긴 오류였다. 그래서 간단하게 isCorrectAmount() 메서드를 가장 마지막에 검사하도록 했더니 바로 해결 되었다.

입력받은 금액을 사용해 랜덤으로 동전 생성

받은 금액을 기준으로 랜덤으로 동전을 생성해야 하는데, 이 부분이 가장 구현하기 조금 까다로웠다.

우선 Coin 클래스를 사용해서 만들어야했는데, enum을 다루는게 익숙하지 않다보니 Coin과 내가 구현해야할 기능을 연결하는 과정에서 많이 헤멨다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
public enum Coin {
    COIN_500(500),
    COIN_100(100),
    COIN_50(50),
    COIN_10(10);

    private final int amount;

    Coin(final int amount) {
        this.amount = amount;
    }
    // 추가 기능 구현

} 

우선 코인들이 한글이름을 하나씩 갖고있고 이걸 받아와 출력하는게 좋을것 같아서 클래스 멤버 변수를 더 추가해주기로 했다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
public enum Coin {
    COIN_500(500,0),
    COIN_100(100, 0),
    COIN_50(50, 0),
    COIN_10(10, 0);

    private final int amount;
    private final String coinUnit = "원 - ";
    private int count;
    private final String countUnit = "개";

    Coin(final int amount, int count) {
        this.amount = amount;
        this.count = count;
    }
    // 추가 기능 구현

    public int getAmount() {
        return amount;
    }

    public void setCount(int count) {
        this.count = count;
    }

    @Override
    public String toString() {
        return amount + coinUnit + count + countUnit;
    }
}

Coin은 amount와 count를 갖고, amount는 금액을 나타내고 count는 동전의 개수를 나타낸다.
toString()을 호출하면, 동전의 단위와 함께 동전이 몇개 있는지 출력해준다.

이제 VendingMachine에서 coin과 machineMoney를 사용해서 입력받은 돈을 기준으로 랜덤으로 동전을 생성해보려고 한다.

우선, 내가 생각한 로직은 다음과 같다.

  1. 입력으로 받은 돈을 500원으로 먼저 나눈다. ex) 1000 / 500 = 2
  2. 몫은 pickNumberInRange(0, ) 메서드의 두번째 매개변수가 된다.
  3. pickNumberIntange()로 나온 값은 해당 동전의 count가 된다.
  4. pickNumberInRange()로 나온 값 * MachineMoney 값을 MachinMoney에서 뺴준다.
  5. 해당 로직을 모든 coin에 적용시켜준다. 앞코인에서 변경된 MachineMoney값이 뒤 코인의 입력값이 된다.
  6. 단, 마지막 coin은 남은돈을 모두 해당 coin으로 전환한다.
  7. MachoneMoney는 마지막에 항상 0이어야 한다.

해당 로직을 구현하여 VendingMachine 클래스에 메서드로 작성해보았다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
 public void makeRandomAllCoin() {
        for (Coin c : coin.values()) {
            makeRandomCoin(c);
        }
    }

    private void makeRandomCoin(Coin coin) {
        if (coin == Coin.COIN_10) {
            coin.setCount(machineMoney.getMoney() / coin.getAmount());
            return;
        }
        int number = Randoms.pickNumberInRange(0, machineMoney.getMoney() / coin.getAmount());
        machineMoney.minusMoney(coin.getAmount() * number);
        coin.setCount(number);
    }

그럼 이렇게 만든 메서드들을 사용하여 VendingMachineController에서 View와 이어주자.

1
2
3
4
5
6
7
8
9
10
11
public void run() {
    VendingMachine vendingMachine = new VendingMachine();
    setMachineMoney(vendingMachine);

    makeRandomCoin(vendingMachine);
}

private void makeRandomCoin(VendingMachine vendingMachine) {
    vendingMachine.makeRandomAllCoin();
    OutputView.printCoinStatus(vendingMachine.getCoin());
}

해당 코드 구현까지의 실행 결과는 다음과 같다.

1
2
3
4
5
6
자판기가 보유하고 있는 금액을 입력해 주세요.
1000
500 - 0
100 - 3
50 - 10
10 - 20

자판기에 추가할 상품명과 가격, 수량을 입력받는다.

Merchandise라는 클래스를 만들어 상품의 이름, 가격, 수량을 관리하기로 했다.
여기서 가격이랑 수량은 아까 MachineMoney와 동일하게 숫자, 공백에 대한 유효성 검사가 필요한 항목이라서, 중복을 방지하기 위해
MachineMoney에서 숫자의 유효성을 검증하는 메서드들을 추상화 해서 따로 클래스로 만들어 상속해주기로 했다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
public class MachineMoney {
    final static int MIN_COIN_UNIT = 10;

    private int money;

    public int getMoney() {
        return money;
    }

    public MachineMoney(String money) throws IllegalArgumentException {
        machineMoneyValidation(money);
        this.money = Integer.parseInt(money);
    }

    private void machineMoneyValidation(String money) throws IllegalArgumentException {
        isDigitString(money);
        isBlank(money);
        isCorrectAmount(money);
    }

    private void isDigitString(String money) throws IllegalArgumentException {
        for (char c : money.toCharArray()) {
            isDigit(c);
        }
    }

    private void isDigit(char c) throws IllegalArgumentException {
        if (!Character.isDigit(c)) {
            throw new IllegalArgumentException("[ERROR] 입력값은 숫자로만 이루어져야 합니다.");
        }
    }

    private void isBlank(String money) throws IllegalArgumentException {
        if (money.isEmpty()) {
            throw new IllegalArgumentException("[ERROR] 입력값이 비어있습니다.");
        }
    }

    private void isCorrectAmount(String money) throws IllegalArgumentException {
        if (Integer.parseInt(money) % MIN_COIN_UNIT > 0) {
            throw new IllegalArgumentException("[ERROR] 입력값은 10으로 나누어 떨어져야 합니다.");
        }
    }

여기서 isCorrectAmount를 제외한 메서드들은 모든 숫자 유효성 검사에서 사용할 수 있는 메서드이므로, 따로 NumberValidation 클래스를 만들어
숫자의 유효성 검사가 필요한 클래스는 이 클래스를 상속받아 사용 하기로 했다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
public class NumberValidation {
    protected void numberValidation(String number) throws IllegalArgumentException {
        isDigitString(number);
        isBlank(number);
    }

    protected void isDigitString(String number) throws IllegalArgumentException {
        for (char c : number.toCharArray()) {
            isDigit(c);
        }
    }

    protected void isDigit(char c) throws IllegalArgumentException {
        if (!Character.isDigit(c)) {
            throw new IllegalArgumentException(ErrorMessage.NOT_DIGIT_MESSAGE.print());
        }
    }

    protected void isBlank(String number) throws IllegalArgumentException {
        if (number.isEmpty()) {
            throw new IllegalArgumentException(ErrorMessage.NULL_ERROR_MESSAGE.print());
        }
    }
}

money를 모두 number로 바꾸고, machineMoneyValidation도 numberValidation으로 바꾸어 추상화 해주었다.

이렇게 만들어진 클래스를 MachineMoney와 Merchandise클래스에 상속해준다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
public class Merchandise extends NumberValidation{
    String name;
    int price;
    int quantity;

      public Merchandise(String name, String price, String quantity) {
        isBlank(name);
        this.name = name;

        numberValidation(price);
        this.price = Integer.parseInt(price);

        numberValidation(quantity);
        this.quantity = Integer.parseInt(price);
    }
}

name은 숫자 자료형은 아니지만, 공백에 대한 유효성검사가 필요해서 isBlank를 사용해서 검사해 주기로 했다.

이제 이렇게 구현한 Merchandise를 리스트로 가질 MerchandiseList를 만들어 입력이 들어오면 문자열을 분해해서 Merchandise형식으로
변환하여 넣어주자.

1
2
3
4
5
6
7
public class MerchandiseList {
    private ArrayList<Merchandise> merchandiseList;

    public MerchandiseList() {
        merchandiseList = new ArrayList<Merchandise>();
    }
}

만들어준 MerchandiseList가 문자열로 들어온 상품들의 정보대로 새로운 Merchandise를 생성해서 리스트에 담을 수 있도록 하자.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
public class MerchandiseList {
    final static int MERCHANDISE_NAME_INDEX = 0;
    final static int MERCHANDISE_PRICE_INDEX = 1;
    final static int MERCHANDISE_QUANTITY_INDEX = 2;
    final static int SUBSTRING_BRACKET_INDEX = 1;
    final static int MERCHANDISE_INFO_COUNT = 3;

    private ArrayList<Merchandise> merchandiseList;

    public MerchandiseList() {
        merchandiseList = new ArrayList<Merchandise>();
    }

    public String getAllMerchandiseInfo() {
        ArrayList<String> list = new ArrayList<String>();
        for(Merchandise merchandise : merchandiseList) {
            list.add(merchandise.toString());
        }
        return String.join(",", list);
    }

    public void addAllMerchandise(String merchandises) throws IllegalArgumentException {
        for (String merchandise : merchandises.split(";")) {
            isCorrectBracket(merchandise);
            addMerchandise(merchandise.substring(SUBSTRING_BRACKET_INDEX, (merchandise.length() - 1)));
        }
    }

    public void addMerchandise(String merchandise) throws IllegalArgumentException {
        ArrayList<String> array = new ArrayList<String>(Arrays.asList(merchandise.split(",")));

        isBlank(merchandise);
        isNullInList(array);

        merchandiseList.add(new Merchandise(array.get(MERCHANDISE_NAME_INDEX),
                array.get(MERCHANDISE_PRICE_INDEX),
                array.get(MERCHANDISE_QUANTITY_INDEX)));
    }

    private void isBlank(String merchandise) throws IllegalArgumentException{
        if (merchandise.isEmpty()) {
            throw new IllegalArgumentException(ErrorMessage.NULL_ERROR_MESSAGE.print());
        }
    }

    public void isCorrectBracket(String merchandise) throws IllegalArgumentException {
        if (!merchandise.startsWith("[") || !merchandise.endsWith("]")) {
            throw new IllegalArgumentException(ErrorMessage.NOT_CORRECT_BRACKET.print());
        }
    }

    public void isNullInList(ArrayList<String> array) {
        if (array.size() < MERCHANDISE_INFO_COUNT) {
            throw new IllegalArgumentException(ErrorMessage.NULL_IN_LIST.print());
        }
    }
}

아까 랜덤 코인을 생성하는 부분이 까다롭다고 생각했는데, 이 부분이 이번 미션에서 제일 까다로웠다…
새로운 Merchandise 생성을 위해서 문자열로 받은 값을 ;를 기준으로 나누고 []을 벗기고 ,를 기준으로 나눠 각각 이름, 가격, 수량을 가진 Merchandise를 만들어 MerchandiseList에 넣어주면 되는데,
사실 그냥 벗겨서 넣는 기능 자체만을 구현하면 쉬웠겠지만 이를 깨끗하고 읽기쉽게 구현하는게 어려웠다. 그리고 분류하는 기준이 늘어난 만큼 발생하는 예외가 많아서
예외들을 모두 처리해주느라 복잡했다.

테스트 코드 작성

테스트코드 작성하는게 너무 까다로워보여서 귀찮은 마음에 중간까지는 계속 Application을 실행시켜서 테스트 하다가 결국 빠른 검증을 위해 테스트 코드를 작성했는데, 정말 테스트 코드를 작성 했을때 낭비되는 시간이 일일히 테스트하던 때에 비해서 비약적으로 줄었다…;;;

그동안도 머리로는 알고있었는데 역시 사람은 직접 겪어서 생고생을 해야 말을 듣나보다… 앞으로는 무조건 테스트 코드를 작성해서 테스트하는 습관을 진짜 꼭!!! 들여야겠다.

테스트를 위해 작성한 테스트 코드는 다음과 같다.

1
2
3
4
5
6
7
8
9
10
11
12
13
@Test
public void 자판기_상품등록_테스트() {
    VendingMachine vendingMachine = new VendingMachine();
    vendingMachine.addMerchandises("[사이다,2000,10];[콜라,1500,20]");
    assertThat(vendingMachine.getAllMerchandiseInfo()).isEqualTo("사이다200010,콜라150020");
}

@ParameterizedTest
@ValueSource(strings = {"", "[],[]", "[]","[asd,d,10]","[asd,20],[사이다,2000,20][콜라,2000,20]"})
public void 자판기_상품등록_예외_테스트(String input) {
    VendingMachine vendingMachine = new VendingMachine();
    assertThatThrownBy(() -> vendingMachine.addMerchandises(input)).isInstanceOf(IllegalArgumentException.class);
}

테스트를 위해서 vendingMachine에 getAllMerChandiseInfo()라는 메서드를 만들어서, Merchandise에 오버라이딩 해준 toString이 상품이름, 가격, 수량을 붙여서 출력하게 했다.

예외 출력 테스트는 많은 예외를 한번에 테스트 하기 위해 ValueSource를 사용해서 되도록 많은 예외를 잡아보려 했다.

역시 테스트 코드를 작성하고 개발을 하니 코딩하고 바로 컨트롤 R로 한번에 다 테스트 해버리면 되니까 개발 속도가 너무 빠르게 올라갔다.
뿐만 아니라 과제가 아닌 실제 서비스하는 프로그램이었다면 테스트 코드를 작성함으로써 혹여나 유지보수 중에 같이 발생 하는 오류를 지나치지 않고 내가 원하는 출력값을 항상 얻을 수 있는 상태가 되어 더욱 안정적일 것이다.

물론 아직 실력이 조금 부족해서 테스트코드를 프로덕션 코드보다 먼저 작성하는건 어렵지만, 후에라도 꼭 작성해서 테스트코드 작성하는 실력을 키워야겠다.

프로덕션 코드 구현

이제 merchandiseList를 VendinMachine에 멤버 변수로 추가해주고 VendinMachine이 입력을 받아 상품을 자판기에 추가할 수 있도록 해보자.

public class VendingMachine {
    MachineMoney machineMoney;
    Coin coin;
    MerchandiseList merchandiseList;

    public VendingMachine() {
        merchandiseList = new MerchandiseList();
    }

    public void setMachineMoney(String money) throws IllegalArgumentException {
        machineMoney = new MachineMoney(money);
    }

    public Coin getCoin() {
        return coin;
    }

    public void makeRandomAllCoin() {
        for (Coin c : coin.values()) {
            makeRandomCoin(c);
        }
    }

    private void makeRandomCoin(Coin coin) {
        if (coin == Coin.COIN_10) {
            coin.setCount(machineMoney.getMoney() / coin.getAmount());
            return;
        }
        int number = Randoms.pickNumberInRange(0, machineMoney.getMoney() / coin.getAmount());
        machineMoney.minusMoney(coin.getAmount() * number);
        coin.setCount(number);
    }

    public void addMerchandise(String merchandise) {
        merchandiseList.addAllMerchandise(merchandise);
    }

    public String getAllMerchandiseInfo() {
        return merchandiseList.getAllMerchandiseInfo();
    }
}

vendinMachine은 상품의 정보를 입력받으면 해당 정보를 바탕으로 상품을 만들고 이를 merchandiseList에 추가한다.

메서드의 이름 짓기

테스트 코드는 통과하는데 계속 Application에 값을 넣으면 “]” 기호가 제거가 안되는 버그가 발생해서 진짜 한참을 헤맸는데,
상품을 추가하는 addMerchandise 메서드와, 이를 사용해서 받은 입력의 상품을 모두 추가하는 addMerchandises의 이름이 비슷해서 잘못 선언한것이 문제였다.

메서드 이름을 지을 때 for문이 indent가 2가 되는경우, 하나만 받아 기능하는 메서드를 하나 만들고 이를 여러개를 받는 메서드에서 사용하는 경우가 많아 메서드 이름을 s를 붙여 단수와 복수로 선언하곤 했는데, 이렇게 하니까 자동완성기능에서 제대로 확인을 안하고 사용하게 되는 경우가 많은것 같다.

그래서 s를 붙이지 않고 addAllMerchandise로 메서드명을 바꿔주었다.

투입 금액을 입력받는다.

테스트 코드

1
2
3
4
@Test
public void 자판기_투입금액_예외_테스트() {
    assertThatThrownBy(() -> vendingMachine.setInputMoney("1000")).isInstanceOf(IllegalArgumentException.class);
}

setInputMoney라는 메서드를 실행해서 투입금액을 입력받고, 만약 예외 상황시 올바르게 에외를 출력하는지 확인 하는 코드를 작성해보았다.

투입 금액에 대한 유효성 검사를 담당하는 InputMoney 클래스를 만들어 주도록 하자.

1
2
3
4
5
6
7
8
public class InputMoney extends NumberValidation {
    private int inputMoney;

    public InputMoney(String inputMoney) {
        numberValidation(inputMoney);
        this.inputMoney = Integer.parseInt(inputMoney);
    }
}

마찬가지로 숫자이므로, NumberValidation을 상속해 숫자에 대한 유효성 검사를 간결하게 해줄 수 있었다.

이제 InputView에 view를 구성해주고, 이를 controller에서 모델과 연결해주자.

1
2
3
4
public static String printSetInputMoneyMessage() {
    System.out.println(SystemMessage.SET_INPUT_MONEY_MESSAGE.print());
    return Console.readLine();
}

InputView에서는 메시지 출력과 함께 입력을 받는 printSetInputMoneyMessage()를 만들어주고,

이를 VendinMachineController에서 VendingMachine과 이어준다.
VendingMachine.class

1
2
3
 public void setInputMoney(String inputMoney) {
        new InputMoney(inputMoney);
    }

VendingMachineController.class

1
2
3
private void setInputMoney(VendingMachine vendingMachine) {
    vendingMachine.setInputMoney(InputView.printSetInputMoneyMessage());
}

테스트 코드를 통과하는 것으로 보아, 올바르게 코드가 작동하는 것을 알 수 있다.

현재 자판기에 남아있는 금액과 함께 구매할 상품명을 입력받는다.

테스트 코드

1
2
3
4
5
6
@Test
public void 물건_구매_테스트() {
    vendingMachine.addMerchandise("[사이다,2000,20]");
    vendingMachine.sell("사이다");
    assertThat(vendingMachine.getAllMerchandiseInfo()).isEqualTo("사이다200019");
}

테스트 코드로 우선 물건을 구매 했을 떄, 해당 물건의 수량이 줄어드는지 확인해보는 것 까지만 작성해보았다.

메서드 이름 변경

지금까지 VendingMachineContoller에서 set이라는 이름을 붙여서 다음과 같이 메서드 이름을 명명했는데,

1
2
3
setMachineMoney();
setMerchandiseInfo();
setInputMoney();

get은 객체에다가 물어봐서 값을 가져오는거니까 사용자에게 받아오는건 set을 써야한다고 생각했다.

그러나 객체가 있는 메서드는 앞에 객체의 이름이 붙기떄문에 (ex) vendinMachine.getMoney) 객체에서 값을 가져오고, 객체에다가 값을 지정해주기 때문에 get으로 얻어오지만, controller에서는 메서드의 이름만 사용하므로 사용자에게 받아온다는 의미로 get을 쓰는게 더 자연스로운것 같아서 set으로 되어있던 이름들을 아래와 같이 변경해주었다.

1
2
3
getMachineMoney();
getMerchandiseInfo();
getInputMoney();

프로덕션 코드

VendingMachine에 purchase() 메서드를 추가해서 사용자가 물건을 입력하면 사용자가 자판기에게서 구매하는 코드를 작성해보려 한다.

Merchandise 클래스에 purchase() 메서드를 만들어주고 이를 merchandiseList에서 모든 상품들을 순환하며 입력받은 이름의 상품을 찾아 해당 상품의 sell()을 실행하는 방식으로 로직을 작성하기로 했다.
merchandise.class

1
2
3
4
5
6
7
8
9
10
public void purchase(String merchandiseName) {
    merchandiseList.purchase(merchandiseName, inputMoney.getInputMoney());
    inputMoney.use(merchandiseList.getPrice(merchandiseName));
}
public boolean canPurchase() {
    if (merchandiseList.cantBuyAllMerchandise(inputMoney.getInputMoney()) || merchandiseList.AllMerchandiseSoldOut()){
        return true;
    }
    return false;
}

merchandiseList.class

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
public void purchase(String merchandiseName, int money) {
    for (Merchandise merchandise : merchandiseList) {
        if (isExist(merchandise, merchandiseName)) {
            merchandise.purchase(merchandiseName, money);
            return;
        }
    }
    throw new IllegalArgumentException(ErrorMessage.NOT_EXIST_MERCHANDISE.print());
}

public boolean isExist(Merchandise merchandise, String merchandiseName) {
    if (merchandise.isSameName(merchandiseName)) {
        return true;
    }
    return false;
}

public boolean AllMerchandiseSoldOut() {
    for (Merchandise merchandise : merchandiseList) {
        try{
            merchandise.isSoldOut();
            return false;
        }
        catch (IllegalArgumentException e) {

        }
    }
    return true;
}

public boolean cantBuyAllMerchandise(int money) {
    for (Merchandise merchandise : merchandiseList) {
        try{
            merchandise.isExpensive(money);
            return false;
        }
        catch (IllegalArgumentException e) {

        }
    }
    return true;
}

merchandise.class

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
public boolean isSameName(String merchandiseName) {
    if (merchandiseName.equals(name)) {
        return true;
    }
    return false;
}

public boolean isExpensive(int money) throws IllegalArgumentException {
    if (money < price) {
        throw new IllegalArgumentException();
    }
    return false;
}

public boolean isSoldOut() throws IllegalArgumentException{
    if (quantity <= SOLD_OUT_QUANTITY) {
       throw new IllegalArgumentException();
    }
    return false;
}

public void decreaseQuantity() {
    this.quantity -= DECREASE_QUANTITY_VALUE;
}

근데 여기서 자꾸 merchandise의 isSamName() 메서드 if(merchandiseName == name) 부분이 실행이 안되서 거의 30분을 헤맸는데,
자바가 문자열은 == 연산자 사용 시 값을 비교한느게 아니라 주소값을 기준으로 비교한다는걸 처음 알았다… 이걸 이제 알다니…
그래서 분명 똑같은 문자열인데 혹시 공백이 섞여들어갔나 해서 그걸 찾는다고 30분동안 생고생을 했다.

이래서 파이썬을 너무 오래쓰면 안된다 ㅜ

어쨌든 값을 비교하는 equals 메서드 사용으로 해당 오류를 해결했다.

제품 중복 예외 추가

물건을 자판기가 판매하는 로직을 작성하다 보니, 제품을 추가 할 때 중복검사로 같은 제품이 추가 되지 않도록 예외를 하나 더 추가 해주어야 한다는 사실을 알았다.
Readme 파일을 변경하고 다음과 같이 예외처리 코드를 추가 해주었다.

merchandise.class

1
2
3
4
5
public void isSameName(String merchandiseName) throws IllegalArgumentException {
    if (merchandiseName.equals(name)) {
        throw new IllegalArgumentException(ErrorMessage.DUPLICATE_MERCHANDISE_EXIST_MESSAGE.print());
    }
}

merchandiseList.class

1
2
3
4
public void isDuplicationInList(String merchandiseName) {
    merchandiseList.stream()
            .forEach(merchandise -> merchandise.isSameName(merchandiseName));
}

다음과 같이 만약 이미 생성된 상품명중에 동일한 이름을 가진 상품이 있다면 에러메시지와 함께 예외를 출력하게 했다.

모든 상품이 소진되었거나, 남은 금액이 최저가격보다 적다면 잔돈을 돌려준다.

테스트코드

여기서부터는 테스트 코드 작성 도저히 감도 잘 안잡히기도 하고 마지막 부분이라 그냥 과제에 있던 ApplicationTest를 사용해서 하기로 했다…
랜덤 요소가 들어간 테스트코드를 테스트 가능하게 하는게 정말 어려운 것 같다 ㅜ 더 연습해야 겠다는 생각이 든다.

프로덕션 코드

이제 잔돈을 반환해주는 코드를 만들어줘야하는데, 여기는 코딩테스트 공부할 때 많이 풀었었던 그리디 알고리즘을 적용해서 코드를 짜주면 된다.
알고리즘 배워서 어따 쓰나 했는데, 알고리즘을 실제 코드에서 응용해보는건 처음인 것 같다.

로직은 다음과 같다.

  • 잔돈의 상태를 저장할 changeList를 만든다.
  • 모든 Coin을 하나씩 방문하며 다음과 같은 로직을 실행한다.
    • 해당 코인의 amount를 현재 남은 투입금액으로 나누어 해당 동전의 단위로 환전해준다.
    • 환전한 동전의 개수가 자판기가 현재 갖고있던 돈보다 크다면, 해당 동전의 정보를 changeList에 넣는다.
    • 환전한 동전의 개수가 자판기가 현재 갖고있던 돈보다 적다면, 바로 해당 동전의 count를 환전한 동전의 개수로 변경한뒤,
      이 동전의 정보를 changeList에 넣고 함수를 종료한다.

코인의 정보는 toString을 오버라이딩하여, 과제의 출력에 형식에 맞게 “amount원 - count개”의 문자열을 출력하게 하여 changeList에 저장하도록 했다.

구현한 프로덕션 코드는 아래와 같다
Coin.enum

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
public enum Coin {
    COIN_500(500, 0),
    COIN_100(100, 0),
    COIN_50(50, 0),
    COIN_10(10, 0);

    private final int amount;
    private final String coinUnit = "원 - ";
    private int count;
    private final String countUnit = "개";

    Coin(final int amount, int count) {
        this.amount = amount;
        this.count = count;
    }
    // 추가 기능 구현

    public int getAmount() {
        return amount;
    }

    public List<Integer> getAmountList() {
        return Arrays.stream(Coin.values())
                .map(coin -> coin.getAmount())
                .collect(Collectors.toList());
    }

    public int getCount() {
        return count;
    }

    public void setCount(int count) {
        this.count = count;
    }

    public void addCount(Coin coin) {
        coin.count++;
    }

    public void randomCoinCount(int number) {
        for (Coin coin : Coin.values()) {
            if (coin.getAmount() == number) {
                addCount(coin);
            }
        }
    }

    @Override
    public String toString() {
        return amount + coinUnit + count + countUnit;
    }
}

vendingMachine.class

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public ArrayList<String> getChange() {
    ArrayList<String> changeList = new ArrayList<String>();
    for(Coin coin : Coin.values()){
        int moneyToCoin = inputMoney.getInputMoney() / coin.getAmount();

        if (moneyToCoin < coin.getCount()) {
            coin.setCount(moneyToCoin);
            changeList.add(coin.toString());
            return changeList;
        }
        changeList.add(coin.toString());
        inputMoney.subtractMoney(moneyToCoin * coin.getAmount());
    }
    return changeList;
}

위의 로직 그대로 else문을 사용하지 않기 위해서 만약 환전한 동전의 개수가 자판기의 현재 갖고있던 돈보다 적다면, 해당 coin의 count를 환전한 동전의 개수로 변경 한 뒤, 바로 changeList를 return 함으로써 종료하도록 했다.

이제 이를 vendingMachine()에서 getChange를 통해 얻은 ChangeList를 출력해주는 메서드를 OutputView에서 구현해줌으로써 controller에서 출력하게 만든다.

OutputView.class

1
2
3
4
public static void printChange(ArrayList<String> changeList) {
    changeList.stream()
            .forEach(System.out::println);
}

VendingMachineContoller.class

1
2
3
4
private void printChange(VendingMachine vendingMachine) {
    OutputView.printMoneyStatus(vendingMachine);
    OutputView.printChange(vendingMachine.getChange());
}

추가로, 잔돈 출력 전에 현재 투입금액을 한번 더 출력해주기 위해 다시 한번 prinMoneyStatus() 메서드를 사용해주었다.

이로써 VendinMachineController의 run() 함수를 사용하여 모든 테스트를 통과 할 수 있었다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
public class VendingMachineController {
    public void run() {
        VendingMachine vendingMachine = new VendingMachine();
        getMachineMoney(vendingMachine);

        makeRandomCoin(vendingMachine);
        getMerchandiseInfo(vendingMachine);

        getInputMoney(vendingMachine);

        do {
            purchase(vendingMachine);
        } while (!vendingMachine.canPurchase());

        printChange(vendingMachine);
    }

    private void getMachineMoney(VendingMachine vendingMachine) {
        try {
            vendingMachine.setMachineMoney(InputView.printGetMachineMoneyMessage());
        } catch (IllegalArgumentException exception) {
            OutputView.printErrorMessage(exception);
            getMachineMoney(vendingMachine);
        }
    }

    private void makeRandomCoin(VendingMachine vendingMachine) {
        vendingMachine.makeRandomAllCoin();
        OutputView.printCoinStatus(vendingMachine.getCoin());
    }

    private void getMerchandiseInfo(VendingMachine vendingMachine) {
        try {
            vendingMachine.addMerchandise(InputView.printGetMerchandiseMessage());
        } catch (IllegalArgumentException exception) {
            OutputView.printErrorMessage(exception);
            getMerchandiseInfo(vendingMachine);
        }
    }

    private void getInputMoney(VendingMachine vendingMachine) {
        vendingMachine.setInputMoney(InputView.printGetInputMoneyMessage());
    }

    private void purchase(VendingMachine vendingMachine) {
        try {
            OutputView.printMoneyStatus(vendingMachine);
            vendingMachine.purchase(InputView.printPurchaseMessage());
        } catch (IllegalArgumentException exception) {
            OutputView.printErrorMessage(exception);
            purchase(vendingMachine);
        }
    }

    private void printChange(VendingMachine vendingMachine) {
        OutputView.printMoneyStatus(vendingMachine);
        OutputView.printChange(vendingMachine.getChange());
    }
}

카테고리:

업데이트:

댓글남기기