우테코 4기 프리코스 3주차 회고
자판기
3주라는 시간이 짧다면 짧고 길다면 긴 시간이지만 생각보다 벌써 3주차 미션을 끝내는 시간이 온 것 같아요.
1, 2주차 미션에 비해서 도메인 자체가 복잡하다고 느껴졌고, 객체를 잘 분리해도 분리한 객체를 가지고 어떻게 관계를 잘 맺느냐가 힘들었던 것 같아요. 추가로 최종 미션에서 5시간 제한이 걸려있어 이번 미션에서 5시간 제한을 두고 구현해보고 싶었는데 이것저것 욕심이 생기는 나머지 validation을 빡빡하게 한다거나, input부분에서 클린 코드를 지향하려다보니 5시간을 못지키고 구현하게 된게 엄청 아쉬운 것 같습니다.
프리코스의 3주차 [자판기]에 대해서 시작하겠습니다🙆♂️
마찬가지로 2주차가 끝나고 3주차 미션에서 2주차의 피드백과 함께 시작하였습니다.
2주차 피드백 반영하는 과정
1. 축약하지 마라
의도를 드러낼 수 있다면 메소드건 필드건 이름이 길어져도 괜찮다 라는 피드백이었습니다. 레포 요구사항, 혹은 메일에서 항상 극단적으로 요구사항을 지키다보면 새로운 깨달음을 얻을 수 있다라고 하여 매번 극단적으로 피드백을 받아들이기로 했어요.
private static void checkPriceLargeThanStandardPrice(int price) {
if (price < STANDARD_PRICE) {
throw new ProductLeastPriceException();
}
}
private static void checkPriceDivisableByLeastCoin(int price) {
if (price % Coin.leastCoin() != 0) {
throw new ProductNotDivisableException();
}
}
validation도 마찬가지로 로직을 보지 않고 메소드명만 보고도 어떤 validation인지 확인을 할 수 있도록, 커스텀 exception도 클래스명만 보고도 어떤 exception인지 확인할 수 있을 정도로 구체적으로 명시했어요.
네이밍을 구체적으로 명시하면서 축약되었을 때 얼마나 제 코드를 다른 사람이 보게 되었을 때 리딩 시간이 길었을까 하는 반성을 하게 된 것 같아요. 물론 마냥 길다고 좋은 것은 아니지만 앞으로도 네이밍만 보더라도 어떤 의미인지 알 정도로 축약하지 않고 의미 전달을 목적을 두어야겠다는 생각을 할 수 있었습니다 :)
2. 함수(메서드) 라인에 대한 기준
조금 어려웠던 부분인 것 같아요.
main의 객체 메소드들 같은 경우 각각의 기능이 명확해서 메소드를 분리하면 그만이긴했어요. 다만 문제는 테스트코드에서 발생했습니다. 테스트코드의 경우 최대한 native하게 작성해서 실패 케이스를 정확하게 잡아내는 것이 더 중요하다고 생각하고 있어요. 그래서 때때로 요구조건인 15라인을 넘어서는 코드를 만들기 부지기수였구요. 아래와 같이 말이죠.
@Test
@DisplayName("남는 금액 없이 잔돈을 반환할 수 있다.")
void changeCoinsTest() {
// given
Money money = Money.valueOf("450");
Money remainMoney = Money.init();
Map<Coin, Integer> coinMap = Coin.createEmptyCoinMap();
coinMap.put(Coin.valueOfAmount(100), 4);
coinMap.put(Coin.valueOfAmount(50), 1);
Coins coins = new Coins(coinMap);
Map<Coin, Integer> expected = new TreeMap<>(coinMap);
// when
Map<Coin, Integer> result = coins.changeCoins(money);
// then
assertAll(
() -> assertThat(result).isEqualTo(expected),
() -> assertThat(money.currentMoney()).isEqualTo(remainMoney.currentMoney())
);
}
두 가지 포기할 수 없는 부분이 있었는데 이를 해결하고자 했죠. 하나는 BDD 방식의 테스트코드 작성이 제가 보아도, 다른사람이 보더라도 테스트코드의 가독성을 높여준다는 것과 최대한 native하게 짠다는 것.
그래서 이를 해결하기 위해서 coinMap을 put하는 과정과 예상 결과값을 만들어내는 과정 자체를 공통으로 묶어 @BeforeEach
로 생성하고, 동일한 자원을 공유하는 테스트를 @Nested
클래스로 묶었어요. 다음과 같이 말이죠.
@Nested
@DisplayName("잔돈을 반환할 수 있다.")
class ChangeCoinsTest {
private Coins coins;
private Map<Coin, Integer> expected;
@BeforeEach
void beforeEach() {
Map<Coin, Integer> coinMap = Coin.createEmptyCoinMap();
coinMap.put(Coin.valueOfAmount(100), 4);
coinMap.put(Coin.valueOfAmount(50), 1);
coins = new Coins(coinMap);
expected = new TreeMap<>(coinMap);
}
@Test
@DisplayName("남는 금액 없이 잔돈을 반환할 수 있다.")
void changeCoinsTest() {
// given
Money money = Money.valueOf("450");
Money remainMoney = Money.init();
// when
Map<Coin, Integer> result = coins.changeCoins(money);
// then
assertAll(
() -> assertThat(result).isEqualTo(expected),
() -> assertThat(money.currentMoney()).isEqualTo(remainMoney.currentMoney())
);
}
오히려 이전보다 메소드의 길이가 짧아짐으로써 메소드가 가지는 역할 자체가 줄어들어 더 확연하게 테스트코드의 역할을 알 수 있게 되었어요.
메소드의 라인을 분리함으로써 1번 피드백때와 마찬가지로 다른 사람이 보았을 때 해당 메소드가 하는 역할을 한눈에 알아볼 수 있다는 장점이 생긴 것 같습니다 :)
3. 발생할 수 있는 예외케이스에 대해 고민한다.
앞서 이야기한 5시간 목표로 구현을 하고자했는데 그걸 못지킨 가장 큰 원인이에요 🤣
예외케이스를 처음에는 일정 수준에 대해서 고민하고자 했는데 이것도 예외가 되지 않을까? 이 부분도 예외에 어긋나는 부분이네 라는 생각이 드는 순간부터 추가해야할 예외케이스가 많아졌거든요.
피드백에서는 정상적인 경우를 구현하는 것보다 예외 상황을 모두 고려해 프로그래밍하는 것이 더 어렵다고 이야기했는데 상당히 공감가는 부분이었습니다. 프로젝트 개발 시에는 물론 프레임워크가 잡아주는 부분이 일정 이상 있지만 네이티브한 프로그램을 만들다보니 많은 부분에 대해서 생각해주어야했고, 프레임워크가 잡아주는데 익숙해지기보다 지금처럼 가능한 모든 예외케이스에 대해서 생각하는 습관을 가져야겠다라는 생각을 하게 되었어요 🙆♂️
4. 비즈니스 로직과 UI 로직을 분리해라
비즈니스 로직과 UI 로직을 분리해라라는 피드백과 더불어 이전 미션보다 예외상황이 너무 많이 발생해서 예외 발생시 재입력하는 로직을 만드는데 상당히 힘들었던 것 같아요.
try-catch문을 통해서 재귀를 이용해서 재입력을 받았지만 객체를 분리하고 관계를 맺는 과정에서 여러 객체의 exception이 발생하다보니 어떻게하면 재귀가 잘 돌아가는가에 대해서 고민하면서 개발했었거든요.
다른 좋은 방법이 있을 수 있고 제 방법에서 고쳐야할 방법도 있지만 InputView는 입력, OutputView는 출력만 진행하게 하고 각 객체의 생성을 Application에서 진행하도록 하여 UI로직은 예외 케이스에 대한 부분도 없도록 만드려고 했어요. 물론 Application에서 비즈니스적인 로직의 경우 각각의 도메인이 가지도록 했구요 :)
자판기 코드 분석
1. 객체의 분리와 관계 맺기
1, 2주차에서 연습했던 객체 분리를 구현해나가면서 계속 신경쓰다보니 더어려운 미션일 수 있는데 오히려 덜 어렵게(?) 미션을 진행한 것 같기는 해요. 다만 어려운건 매한가지였습니다 ㅎㅎ..
초기 기능 목록을 구현한 뒤 계속해서 코드를 구현해나가며 가장 신경썼던 건 여기서 더 분리할 수 있지 않을까? 이 책임은 다른 새로운 객체가 맡아도 되지 않을까? 였어요.
가장 처음 코드를 보면 VendingMachine
이라는 객체는 다음과 같은 역할을 맡았어요.
public class VendingMachine {
private static final String INTEGER_REGEX = "[0-9]+";
private final Map<Coin, Integer> coins;
public VendingMachine(Map<Coin, Integer> coins) {
this.coins = coins;
}
public static VendingMachine createByMoney(String money) {
checkMoneyIntegerFormat(money);
return new VendingMachine(new HashMap<>());
}
private static void checkMoneyIntegerFormat(String money) {
if (!money.matches(INTEGER_REGEX)) {
throw new IllegalArgumentException("[ERROR] 금액은 양의 숫자여야 합니다.");
}
}
}
coins라는 현재 머신이 가지고 있는 코인 갯수 Map 을 가지면서 돈을 받아서 본인이 직접 coin을 생성하게끔 하는 것이었죠. 그럼 문제가 무엇이냐. 최종 제출 기준으로 20개가까이 있는 기능 목록을 모두 VendingMachine
이 짊어져야하고 코드의 길이가 생각하기 싫을만큼 길어집니다. 객체가 가져야할 책임을 너무 많이 받아버리는 셈이죠.
그래서 처음부터 하나씩 하나씩 분리하기로 했었어요.
Money ? 돈은 입력받아야하는데 0보다 작으면 안되는 것도 검사해야하고 상품을 구매할 때 차감 가능한지도 검사해야하네. Money 객체를 새로 만들어서 사용해야겠다.
Map<Coin, Integer> coins;로 가져야하는 역할이 너무 많네. 일급 컬랙션으로 두고 Coins가 해야할 역할을 전부 여기 이관하면 VendingMachine은 로직을 갖지않고 기능을 호출하기만 하면 되겠다.
이런 식으로 말이죠 :)
그 후에는 분리된 객체들을 가지고 서로의 관계를 맺는 것에 집중했던 것 같아요. 이 작업도 만만치 않았구요 ㅎㅎ
VendingMachine
에서 이름을 받아 무언가를 구매하고자할 때 상품의 관리를 맡는 Products
에게 호출해 이름에 해당하는 상품이 있는지 확인하고, Money
와 함께 이 상품을 구매할 수 있는지 가격확인을 하는 방식이었어요.
말로써는 쉬워보이지만 각각의 객체가 맡아야하는 역할을 죄다 분리해놓은 상태인데 관계를 맺다보니 2개의 객체에서 비슷비슷한 validation이 발생하게 되어 혼란이 있기 부지기수였어요. 그럴 때마다 기능리스트를 체크하면서 어떤 객체가 어떤 로직을 수행해야하는지 꼼꼼하게 확인해보았던 것 같아요.
1, 2주차 미션보다 요구되는 내용들이 많다보니 더 힘들고 어려웠지만 사실 지금 이 파트의 객체 관계맺기 때문에 오히려 고통속에서 즐거움을 찾을 수 있었던 것 같습니다 :)
2. 생성자에서 validation을 해야할까?
적용하지는 않았지만 3주간 미션을 진행하면서 생각하고 있는 부분이에요.
사실 생성자에서는 생성한다라는 기능만 주고싶기 때문에 원래는 validation없이 생성하는 역할만 주고, 정적 팩토리 메소드와 같은 곳에서 validation을 해서 반환을 하도록 개발을 해왔었어요. 이번 프리코스 기간 중에는 이것조차도 오버프로그래밍으로 생각하고 개발하는게 아닌가?하는 생각으로 하고있지는 않지만요. 다음과 같이요.
public Product(String name, int price, int remainAmount) {
checkNameIsEmpty(name);
checkPriceLargeThanStandardPrice(price);
checkPriceDivisableByLeastCoin(price);
this.name = name;
this.price = price;
this.remainAmount = remainAmount;
}
이렇게 되었을 때의 문제는 Product 생성자가 validation까지 진행하게 되어서 어떤 특정 상황에 대한 재사용성이 떨어진다라고 생각해요. 물론 지금이야 domain 객체가 항상 저런 validation 상황이 주어지고 생성자에 지금처럼 validation을 두어도 문제가 없지만 Product 생성할 때 지금의 예외상황이 없는 Product를 생성해도 될 때가 생길 수도 있거든요.
그래서 항상 생성자는 생성의 역할만 하게 두고 private으로 막아두고 정적 팩토리 메소드에서 특정 생성을 계속해서 만드는 편이었는데 아직까지도 고민되는 부분인 것 같아요. 물론 생성자를 다양하게 만드는 것도 좋지만 제 개인적인 생각으로는 사용하지 않는 메소드는 없는게 좋고, 쓰임새없는 메소드조차도 없는게 좋다라고 생각하거든요.
남은 기간동안에도 꾸준하게 고민해야할 주제인 것 같아요 :)
3. TreeMap
이번 기능 목록을 보자마자 생각했던 것 중 두 가지가 있어요.
- Coin을 종류에 따라 저장해야하네? 이건 Map을 써야겠다.
- 잔돈을 돌려줄 때 최소 개수의 동전을 반환해야하면 큰 동전부터 계산해야겠네.
사실 두 번째 생각은 처음에만 생각하고 중간에 로직짜는 도중에는 생각하지 않았던 것이 있었어요. 그럼 문제가 뭘까요? 답은 Map을 어떤 구현체를 쓰느냐에 다라 달려있습니다.
기본적으로 Map의 구현체를 많은 사람들이 HashMap을 사용해요. 하지만 HashMap의 경우 순서가 없이 저장되는 자료구조이죠. 그렇다보니 계산하는 로직은 다 만들어두었으나 두번째에서 최소 개수의 동전을 반환하려면 큰 동전부터 계산해야하지만 뒤죽박죽인 순서로 계산하다보니 테스트케이스에서 에러가 발생했죠.
그런 상황에서 Map의 구현체를 다시 고려하게 되었습니다.
두가지 Map 구현체 고려사항이 있었어요. LinkedHashMap
, TreeMap
.
TreeMap의 경우 자주 사용하지 않다보니 먼저 LinkedHashMap을 고려했었는데요. List자료구조처럼 입력된 순서대로 저장되는 자료구조에요. 구현으로 주어졌던 Enum클래스가 다음과 같이 순서대로 이루어졌기에 그냥 사용할까 생각했었죠.
500부터 10까지 이미 내림차순으로 구현되어있으니 순서대로 들어가니 편하겠구나 생각했었어요. 하지만 바로 생각을 바꾸었던건 Coin의 변경점이 있을 때 였어요. coin이 뒤죽박죽 섞여있다면 가장 높은 순서대로 Map에 저장되는 보장이 없었거든요. 그게 TreeMap으로 변경한 이유였습니다.
TreeMap은 다른 Map 구현체들과는 다르게 특정 순서를 적용해서 생성할 수 있어요. 다만 적용하면서 한가지 문제가 있었습니다. Enum은 Comparable<T>
인터페이스를 이미 구현하고 있으며 compareTo()
가 final로 선언되어있기 때문에 Overridng이 불가능한 것이죠.
즉, 선언한 순서대로 compareTo가 지정되어있기 때문에 제가 만약 amount를 뒤죽박죽 섞어놓는다면 앞서 이야기한대로 amount에 대한 보장이 되지 않는 셈이죠.
public static Map<Coin, Integer> createEmptyCoinMap() {
Map<Coin, Integer> coinMap = new TreeMap<>(coinAmountDescendingComparator());
Arrays.stream(values())
.forEach(coin -> coinMap.put(coin, 0));
return coinMap;
}
private static Comparator<Coin> coinAmountDescendingComparator() {
return (o1, o2) -> o2.amount - o1.amount;
}
따라서 Coin Map을 생성하는 메소드에서 TreeMap을 생성할 때 내림차순으로 내려가는 Comparator를 추가해서 다음과 같이 구현했습니다. 이렇게 될 경우 TreeMap을 반환하는 메소드는 Coin의 필드들이 순서가 아무리 뒤죽박죽이어도 amount에 대해서 내림차순으로 반환하게 되겠죠.
TreeMap도 그렇고 Enum도 자주 사용해보지 않았는데 이번 기회에 사용하는데 있어서 시행착오를 겪고나니 조금 더 친숙해지는 느낌이 듭니다 :)
4. 패키지 구성을 어떻게 가져가야했을까?
1주차, 2주차 미션때는 고민하지 못했던 부분이에요. 그 전 주차에서는 도메인이 다양하지 않았고 그에 따라 분리된 객체도 적어서 domain, exception과 같이 하나의 패키지에 관리해도되었었거든요.
지금 패키지 구조를 잠깐 살펴볼게요.
├── main
│ └── java
│ └── vendingmachine
│ ├── Application.java
│ ├── domain
│ │ ├── Coin.java
│ │ ├── Coins.java
│ │ ├── Money.java
│ │ ├── Product.java
│ │ ├── Products.java
│ │ └── VendingMachine.java
│ ├── exception
│ │ ├── CoinNotExistAmountException.java
│ │ ├── CoinNotFoundLeastException.java
│ │ ├── MoneyLessThanCoinException.java
│ │ ├── MoneyLessThanUseMoneyException.java
│ │ ├── MoneyPositiveIntegerValueException.java
│ │ ├── MoneyShareByLeastCoin.java
│ │ ├── ProductLeastPriceException.java
│ │ ├── ProductNameEmptyException.java
│ │ ├── ProductNameNotFoundException.java
│ │ ├── ProductNonRemainAmountException.java
│ │ ├── ProductNotDivisableException.java
│ │ ├── ProductNotEnoughMoneyException.java
│ │ ├── ProductNotFoundLeastException.java
│ │ └── ProductsNameDuplicateException.java
│ ├── strategy
│ │ ├── CoinCreateStrategy.java
│ │ └── RandomCoinCreateStrategy.java
│ └── view
│ ├── InputView.java
│ └── OutputView.java
패키지 구조는 정해진게 없고 상황에 따라 다르다고는 하지만 뭔가 개선점이 필요한 것 같이 보여요. 그래서 다 개발을 하고 패키지 구조를 다시 볼 때 두가지 고려를 했었습니다.
- VendingMachine이라는 하나의 도메인에 전부 포함되는 도메인이니 굳이 분리하지 않아도 되지 않을까?
- Coin, Product는 별도의 도메인으로 설정하고 분리해도되지않을까?
결국 첫번째 생각으로 고치지는 않았지만 지금과 같은 상황이 되었을 때 다른 사람이 보았을 때 다른 최선의 방법이 있을 것이라고 생각이 들어요. 고치고 싶었던 가장 큰 이유는 exception이 너무 많아지다보니 관리가 되지 않아서인데 이런 상황에서는 어떤 더 좋은 방법이 있는지 계속 고민해보아야할 것 같네요.
생각 정리
마지막 미션이라 후련하면서도 최종 미션이 있다는 생각에 부담감이 오는 마무리였던 것 같습니다.
잘 만들고, 5시간 내로 만들었다면 부담감이 덜 했을 것 같은데 부족한 점이 아직도 많이 보이고 시간도 제시간내에 구현완성은 하지 못했거든요. 해왔던 것들을 다시 정리하고 부족한 점이 어떤 것이 있는지만 보완하고 최종 미션까지 부담감을 덜어내야할 것 같습니다 :)
3주차까지 진행하면서 3주차는 참 어렵고도 재미있었던 것 같아요. 1, 2주차까지는 큰 어려움없이 만들고자 했던대로 만들어서 재미있었던 것 같은데 반대로 3주차는 제가 하고싶은 최대한을 구현하려다보니 복잡했고 그걸 해결해나가는 과정에서 뿌듯함이 있어 즐거웠거든요.
3주간 진행을 하다보니 우테코에 합격하고 싶다는 의지는 점점 더 커지는 것 같네요. 최종 미션도 잘 마무리해서 우테코에 합격하고 싶습니다. 잘 마무리해서 좋은 결과를 만들어 냈으면 좋겠습니다 🙇♂️