Web/Java

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

🚀 기능 요구사항

반환되는 동전이 최소한이 되는 자판기를 구현한다.

  • 자판기가 보유하고 있는 금액으로 동전을 무작위로 생성한다.
    • 투입 금액으로는 동전을 생성하지 않는다.
  • 잔돈을 돌려줄 때 현재 보유한 최소 개수의 동전으로 잔돈을 돌려준다.
  • 지폐를 잔돈으로 반환하는 경우는 없다고 가정한다.
  • 상품명, 가격, 수량을 입력하여 상품을 추가할 수 있다.
    • 상품 가격은 100원부터 시작하며, 10원으로 나누어떨어져야 한다.
  • 사용자가 투입한 금액으로 상품을 구매할 수 있다.
  • 남은 금액이 상품의 최저 가격보다 적거나, 모든 상품이 소진된 경우 바로 잔돈을 돌려준다.
  • 잔돈을 반환할 수 없는 경우 잔돈으로 반환할 수 있는 금액만 반환한다.
    • 반환되지 않은 금액은 자판기에 남는다.
  • 사용자가 잘못된 값을 입력할 경우 IllegalArgumentException를 발생시키고, "[ERROR]"로 시작하는 에러 메시지를 출력 후 해당 부분부터 다시 입력을 받는다.
  • 아래의 프로그래밍 실행 결과 예시와 동일하게 입력과 출력이 이루어져야 한다.

 

 


 

이번 미션은 지난 2주차에서 결심했던 것과 2주차 피드백주신 내용을 반영하려고 했습니다.

 

3주차 진행계획

  1. 기능목록을 작성할 때 model부터 작성할 수 있도록 설계한다.
  2. 코드를 작성할때 신경쓸 수 있는 부분은 최대한 신경써서 refactor commit을 줄여본다.
    (무작정 refactor commit을 줄이는 것이 아니라 나중에 해야지 하고 넘어가는 습관 없애기 위함)
  3. 완성하고, 두번 더 새로 작성해서 완성한다.
  4. 3주차 과제에 익숙해지면 다른 기수의 3주차 과제와 최종시험을 구현해본다.

 

2주차 피드백중 중요항목 정리

  1. 기능 목록은 구현해야 할 기능 목록을 정리하는 데 집중한다.
    - 특히, 정상적인 경우에 이어 예외적인 상황도 정리한다.
    - 예외 상황은 기능을 구현하며 계속해서 추가해 나간다.
  2. git을 통해 관리할 자원에 대해서 고려한다.
    - 개발도구가 자동으로 생성하는 폴더/파일들은 add를 하지 않아도 된다.
  3. 직접 작성한 메서드가 Java api에도 존재한다면 api를 활용한다.
  4. 배열대신 Collection을 사용하라
  5. 객체에 메세지를 보내라
  6. 필드의 수를 줄이기 위해 노력한다.

 

 

 

첫번째 구현

 

오늘은 12/11 토요일.

 

수요일에 과제가 나오고 오늘까지 일단 완성을 했습니다.

하지만 여기서 PR을 보내지 않고 첫번째 구현내용을 한번 정리하고 두번째 구현으로 넘어가려고 합니다.

 

 

기능목록 작성

수요일 하루종일 기능목록만 작성했습니다.

시험때는 20분안에 작성해야 하겠지만 3주차 과제가 생각보다 어려워서 기능목록 작성하는데 시간을 많이 썼습니다.

그리고 model부터 작성할 수 있도록 생각하며 작성하다보니 기능목록이 좀 길어졌습니다.

2주차 피드백에서는 기능 목록을 세세한 부분까지 정리하지 말라고 되어있지만, 그렇다고 기능목록을 대충 생각나는대로 적으면 구현을 시작할때 순서가 좀 애매해졌습니다.

 

그래서 어느정도 내가 개발을 하는 순서에 따라서 어떤 기능을 구현할지 목록을 작성했습니다.

 

실제 프로젝트를 진행할 때는 프론트엔드가 View를 제작하는 동안 백엔드는 model을 제작해야 합니다.

그래서 입력받고 출력하는 부분은 View이기 때문에 저는 model부분부터 개발하는 연습을 하는 것이 맞다고 판단했습니다.

 

 

 

구현

2주차에서 model을 작성하는 것에 어느정도 익숙해 놓아서, 2주차에서 view부터 개발하던 것과 달리 domain부터 개발을 바로 시작할 수 있었습니다.

 

기능목록 순서대로 개발을 진행했습니다.

자판기 객체를 machine으로 정하고 현재 개발중인 기능에 관련된 필드들만 작성하며 개발을 진행했습니다.

"객체에 메세지를 던져라"라는 피드백에 맞게 이번엔 서비스 로직에서 여러가지를 수행하지 않고 machine객체에 메서드를 만들어 놓고 서비스로직에서는 호출만 하도록 구현했습니다.

 

view와controller 없이 model부터 작성하다보니 테스트를 할 수 없었습니다.

그래서 기능구현이 잘되었는지 확인하기 위해 기능을 하나 만들때마다 유닛단위 테스트 코드를 작성했습니다.

 

 

 

 

두번째 구현에 반영해야할 점

  1. 시작할때 새로운 브랜치에서 시작하기
  2. git으로 관리하지 않아도 될 폴더와 파일들을 .gitignore에 추가하기
  3. machine에 coin과 Item을 처음부터 Map으로 저장하기
  4. LinkedHashMap을 상속받아 해당 key의 값에 변화를 줄 수 있는 메서드 구현해보기
  5. .gitAttributes를 통해 LF설정이 잘 되고있는지 확인하기
  6. 최대한 스피드하게 작성해보기 (늦어도 10시간 컷 해보기)

 

 

 

두번째구현

 

오늘은 12/13 월요일.

어제 오후부터 시작한 두번째 구현이 방금 완료됐습니다.

프로젝트세팅 ~ 구현완료까지 순 개발시간은 약 6시간정도 됐습니다.

 

두번째 구현 제한시간은 10시간으로 두고 여유롭게 개발했는데 예상보다 시간이 많이 단축됐습니다.

세번째 구현은 5시간 제한으로 두고 빠르게 개발이 가능할 것 같습니다.

 

프로젝트 세팅

.gitignore로 관리해야할 폴더와 파일들을 찾아봤는데 인텔리제이(아니면 우테코저장소)에서 설정이 되어있어서 원격저장소에 올라오는 폴더와 파일들이 이미 제한되어 있었습니다.

 

 

또 LF설정에 관련해서도 찾아보았습니다.

  • core.autocrlf = true : commit 시점에 CRLF를 LF로 변환, checkout 시에는 CRLF로 다시 변환
  • core.autocrlf = input : commit 시점에 CRLF를 LF로 변환, checkout 시에는 아무것도 하지 않음

지금까지 core.autocrlf = true로 되어있어서 checkout시마다 자꾸 CRLF로 변환되던 것이었습니다.

그래서

git config --global --replace-all core.autocrlf input

설정을 해주어서 한번 LF로 변환시키고 난 뒤에는 변화가 없도록 수정했습니다.

 

 

 

 

 

SortedMap

이번엔 처음부터 잔돈과 상품을 List가 아닌 Map형태로 저장했습니다.

아무래도 결국에 사용할땐 항상 Map으로 사용하게 되어서 애초에 저장할때부터 List가 아닌 Map으로 저장해서 꺼내쓰기 편하게 만들면 좋을 것 같다는 생각이었습니다.

 

그런데 문제는 잔돈을 출력할때 500 - 100 - 50 - 10 원의 내림차순 순서로 출력을 했습니다.

HashMap은 순서를 저장하지 않는 Map이고, LinkedHashMap은 들어온 순서를 저장하기 때문에 랜덤으로 생성되는 동전을 내림차순으로 저장할 수 없었습니다.

 

그래서 이 동전 Map을 어떻게 정렬할 지 고민하다가 SortedMap을 발견했습니다.

SortedMap은 Key값에따라 자동으로 정렬이 되는 Map입니다.

SortedMap의 대표적인 구현체는 TreeMap입니다.

TreeMap은 간단히 말하면 이진트리를 기반으로 한 Map 컬렉션 입니다.

TreeMap을 생성할때 Comparator구현객체를 넣어주면 이 구현객체를 기준으로 key값을 비교할 수 있습니다.

 

정리하면, Map을 직접 정렬할 필요 없이 SortedMap을 이용해 put할때마다 자동으로 정렬되게 하면 됩니다.

private final SortedMap<Coin, Integer> coins = new TreeMap<>((c1, c2) -> c2.getAmount() - c1.getAmount());

TreeMap에 Coin의 amount를 내림차순으로 비교하도록 람다식을 넣어줬습니다.

 

이제 좀 더 깔끔하게 잔돈출력이 가능합니다.

	public static void printMachineCoins(SortedMap<Coin, Integer> coins) {
		for (Coin key : coins.keySet()) {
			System.out.println(key.getAmount() + "원 - " + coins.get(key) + "개");
		}
	}

 

 

 

 

MapSupporter

첫번째 구현에서 map에서 특정 key에 해당하는 value의 값을 증가하거나 감소시키는 로직이 필요했습니다.

해당 로직에서는 만약 key가 없다면 value가 0인 <key, value>를 생성해 넣어야 했습니다.

 

하지만 기존 map에서는 이런 기능이 없이 해당 로직이 수행되는 곳마다 key가 있는지 검증하고 없으면 추가하고 있으면 해당 value를 변화시키는 로직을 반복해서 짜야했습니다.

 

그래서 두번째 구현때는 LinkedHashMap을 상속받아 이 로직을 수행하는 새로운 Map을 만들려고 했으나, 제네릭스 이해의 부족, 혹은 다른 부분의 이해부족으로 상속받아 해결하지 못했습니다.

그냥 새로운 객체 MapSupporter를 만들어서 static메소드로 map과 여러 파라미터를 받아 로직을 수행하도록 구현했습니다.

 

그래도 매번 로직을 짜는 것 보단 해당 역할을 하는 클래스를 따로 분리해서 전보다 깔끔한 코드가 되었습니다.

for (int i = 0; i < coins.get(coin); i++) {
  if (returnCoinsAmount < coin.getAmount()) {
	  break;
  }
  MapSupporter.increaseCoinCount(returnCoins, coin, 0, 1);
  returnCoinsAmount -= coin.getAmount();
}

 

 

 

Item

두번째 구현을 하다보니까 깨달은 점인데, 첫번째 구현때는 Item의 quantity 감소 로직을 machine에서 수행하고 있었습니다.

그래서 이번엔 Item과 관련된 로직들은 Item 객체에서 직접 수행하도록 했습니다.

soldOut(), decreaseQuantity() 등.

 

 

 

MachineRepository

이것도 두번째 구현을 하다보니까 깨달았습니다.

첫번째 구현때는 Controller에서 Machine객체를 생성해서 계속 service로직에 넘겨주면서 진행했습니다.

근데 이렇게 되면 Controller에서 Machine 의 public 메소드가 호출 됐습니다.

 

만약 협업을 하게된다면 팀원은 MachineService를 통해 메서드를 호출하지 않고 그냥 Machine의 메서드를 호출할 수도 있습니다.

 

Controller에서는 Machine을 모르고 MachineService만을 이용하도록 수정해야 했습니다.

그래서 저번에 도입을 하지 않았던 repository의 필요성이 느껴지며 repository와 generate메서드를 도입했습니다.

generate는 호출하게되면 새로운 Machine객체를 생성해 repository에 저장하고 id를 반환합니다.

 

Controller에서는 MachineService.generate()를 통해 Id를 제공받고 이제 MachineService에 id를 넘겨주며 서비스 로직을 호출합니다.

public void run() {
  Long machineId = machineService.generate();
  ...
  machineService.addCoins(machineId, getCoinAmountByUser());
  ...
}

 

Repository에서는 Map으로 Machine 객체를 저장하기 때문에 각 메소드마다 findById()를 수행해도 성능에 크게 지장이 없습니다.

 

MachineRepository

public class MachineRepository {
	private Long id = 0L;
	private final Map<Long, Machine> machineDB = new HashMap<>();

	public Long generate() {
		Long returnId = id;
		machineDB.put(id, new Machine());
		id += 1;
		return returnId;
	}

	public Machine findById(Long id) {
		return machineDB.get(id);
	}
}

 

 

 

상속

첫번째 구현의 Validator와 Parser는 난잡했습니다.

그냥 단지 줄 수를 줄이기 위해 여러 메소드로 분리하고 있었습니다.

 

다시 이 객체들을 보면서 상속으로 의미상 구분이 가능하고 공통적인 메소드도 모을 수 있을 것 같았습니다.

 

Validator가 사용되는 곳은 Coin을 입력받을때랑 Item을 입력받을때 입니다.

또한, Coin, Item 입력에서 빈문자인지, 숫자인지 등등 공통적으로 쓰이는 메소드가 있었습니다.

 

그래서 기본 Validator에는 isEmpty(), isDigit() 메소드를 넣어놓고, ItemValidator와 CoinValidator가 이를 상속받고 각자 객체특성에 맞는 메소드를 추가로 구현했습니다.

 

Validator

package vendingmachine.util.validator;

public class Validator {
	public static void isEmpty(String input) {
		if (input.isEmpty()) {
			throw new IllegalArgumentException("[ERROR] 값을 입력해 주세요.");
		}
	}

	public static void isNumber(String input) {
		try {
			Integer.parseInt(input);
		} catch (NumberFormatException e) {
			throw new IllegalArgumentException("[ERROR] 숫자가 아닙니다.");
		}
	}
}

 

CoinValidator

public class CoinValidator extends Validator {
	...

	public static void isRightCoin(String input) throws IllegalArgumentException {
		isEmpty(input);
		isNumber(input);
		isDivisible(input);
	}
	...
}

 

ItemValidator

public class ItemValidator extends Validator {
	
    ...

	public static void isRightItemInput(String input) throws IllegalArgumentException {
		isEmpty(input);
		isRightItems(input);
	}
    ...
}

 

이렇게 구현하니 공통적인 메소드도 하나의 객체에 묶이고 특정 메소드가 필요한 곳에서 해당 객체만 부르면 됩니다.

CoinValidator.isRightCoin(input);
ItemValidator.isRightItemInput(input);

 

 

 

 

 

클래스 분리

우테코3기 프리코스 오픈톡방에서 다른분들 얘기를 보다보니 Coin 저장소를 따로 사용한다는 분이 있었습니다.

 

왜 필요하지 잠깐 생각하고 아차 싶어서 요구사항을 다시한번 자세히 읽어보니 클래스로 분리하고 객체끼리 관계를 맺어 하나의 프로그램을 완성하라고 되어있었습니다.

그래서 바로 제 코드의 Machine 객체가 떠올랐고 Machine객체 안에서 Item을 저장하고 coin을 찾고 등등의 메서드를 구현하고 수행하고 있었습니다.

 

그래서 Machine에서 가지고 있던 Coin과 Item관련 자료구조들을 각자 클래스로 분리해주었습니다.

 

Old Code

public class Machine {
	private final SortedMap<Coin, Integer> coins = new TreeMap<>((c1, c2) -> c2.getAmount() - c1.getAmount());
	private int inputCoinAmount;
	private final Map<String, Item> items = new HashMap<>();
    ...

 

New Code

public class Machine {
	private final CoinStorage coinStorage = new CoinStorage();
	private final ItemStorage itemStorage = new ItemStorage();
    ...

 

그리고 Machine 코드에선 ConStorage, ItemStorage를 가지고 있고, 필요할때 해당 객체의 메서드를 불러오는 식으로 바꿨습니다.

 

Old Code

	public void addCoins(SortedMap<Coin, Integer> coins) {
		for (Coin coin : coins.keySet()) {
			MapSupporter.increaseCoinCount(this.coins, coin, 0, coins.get(coin));
		}
	}

	public SortedMap<Coin, Integer> getCoins() {
		return this.coins;
	}

	public void addInputCoinAmount(final int amount) {
		this.inputCoinAmount += amount;
	}

	public int getInputCoinAmount() {
		return inputCoinAmount;
	}

	public void addItems(Map<String, Item> items) {
		for (String itemName : items.keySet()) {
			Item item = items.get(itemName);
			this.items.put(itemName, item);
		}
	}

 

New Code

	public void addCoins(SortedMap<Coin, Integer> coins) {
		coinStorage.addCoins(coins);
	}

	public void addItems(Map<String, Item> items) {
		itemStorage.addItems(items);
	}

	public SortedMap<Coin, Integer> getCoins() {
		return coinStorage.getCoins();
	}

	public void addInputCoinAmount(final int amount) {
		coinStorage.addInputCoinAmount(amount);
	}

	public int getInputCoinAmount() {
		return coinStorage.getInputCoinAmount();
	}
...

 

 

기존에 Machine에서 잡다한 메소드를 다 구현했다면,

새로운 Machine에선 각자 특성에 맞는 객체를 가지고 있고, 필요할 떄 이 객체들의 메소드를 불러와서 사용합니다.

왼쪽그림 : Old, 오른쪽 그림 : New

 

 

CoinRepository, ItemRepository가 아니라 왜 Storage?

domain - repository - service로 가는 이 개념을 따르기 위함입니다.

*domain은 repository와 service를 몰라야하는데 machine이라는 domain에서 CoinRepository와 ItemRepository의 메소드를 사용할 수 없었습니다.

그래서 같은 domain의 개념으로 묶어주기 위해 CoinStorage, ItemStorage라는 이름을 지어주고 domain으로 사용했습니다.

 

 

*domain은 repository와 service를 몰라야하는데

유지보수의 편리성을 위해서 그렇습니다.

domain에서는 repository를 모르기때문에 repository가 수정되더라도 domain은 수정할 필요가 없겠죠.

 

 

 

 

세번째 구현에 반영해야할 점

개발 패턴은 거의 정형화 되었습니다.

 

  1. 초기설정, 기능목록작성
  2. Domain + Service 개발
  3. InputView에서 입력받아 Controller에 전달
  4. 입력값 검증, 파싱 후 Model에 넘기기
  5. Model에서 반환된 값 OutputView에 넘기기

 

가장 시간이 많이걸리는 부분은

  1. Domain + Service 개발
  2. 입력값 검증, 파싱 후 Model에 넘기기

입니다.

 

세번째 구현부터는 가장 중요한 부분이 시간싸움입니다.

 

시간배분

할 것 소요시간
초기설정, 기능목록작성 30 분
Domain + Service 개발 (+ 유닛단위 테스트코드 작성) 2 시간
InputView에서 입력받아 컨트롤러에 연결,
입력값 검증, 파싱 후 Model에 넘기기
1.5 시간
Model에서 반환된 값 OutputView에 넘기기 0.5 시간
애플리케이션 테스트 0.5시간

 

두번째 구현에서 정형화한 패턴을 그대로 이어받아서 시간만 빨리 줄여보려고 합니다.

두번째 구현에서도 여러가지 삽질을 했음에도 약 6시간에 끝낸 것을 보면 충분히 가능할 것 같습니다.

 

 

3주차 코드보기