일 | 월 | 화 | 수 | 목 | 금 | 토 |
---|---|---|---|---|---|---|
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 |
- 자바의 정석 기초편 ch13
- 스프링 입문(무료)
- 자바 중급2편 - 컬렉션 프레임워크
- 자바의 정석 기초편 ch6
- 자바의 정석 기초편 ch1
- 자바로 계산기 만들기
- 2024 정보처리기사 시나공 필기
- 스프링 mvc2 - 로그인 처리
- 자바의 정석 기초편 ch14
- 스프링 mvc1 - 스프링 mvc
- 자바 고급2편 - 네트워크 프로그램
- 자바 고급2편 - io
- 데이터 접근 기술
- 자바의 정석 기초편 ch4
- 자바 기초
- 스프링 mvc2 - 타임리프
- @Aspect
- 자바의 정석 기초편 ch5
- 자바의 정석 기초편 ch2
- 스프링 mvc2 - 검증
- 자바의 정석 기초편 ch7
- 자바의 정석 기초편 ch11
- 자바의 정석 기초편 ch9
- 자바의 정석 기초편 ch12
- 2024 정보처리기사 수제비 실기
- 자바로 키오스크 만들기
- 람다
- 스프링 트랜잭션
- 자바 중급1편 - 날짜와 시간
- 스프링 고급 - 스프링 aop
- Today
- Total
개발공부기록
자바로 키오스크 만들기, 피드백 반영하여 리펙토링 회고(불변 객체 적용, getOrDefault 활용, 객체지향적인 장바구니 만들기) 본문
자바로 키오스크 만들기, 피드백 반영하여 리펙토링 회고(불변 객체 적용, getOrDefault 활용, 객체지향적인 장바구니 만들기)
소소한나구리 2025. 3. 26. 14:32키오스크 과제 개발 회고
- 필수 기능 구현 회고 - https://nagul2.tistory.com/463
- 도전 기능 구현 회고 - https://nagul2.tistory.com/469
필수 LV5 - 불변 객체 캡슐화
순수 자바로 개발한 키오스크에서 튜터링을 통해 먼저 캡슐화가 제대로 적용되지 않았다는 피드백을 받았다.
처음에는 이해가 가질 않았는데 캡슐화는 순수 자바 코드 상에서 제대로 캡슐화가 적용되었는가를 파악하는 것이기 때문에 필드의 값을 직접 반환하지 말고 새로운 객체를 반환하여 완전한 불변 객체로 캡슐화를 하는 것이 좋다는 의미였다.
현재 MenuItem은 이미 불변 객체로 설계가 되어있기 때문에 필드의 값이 변경될 여지가 없어 상관이 없지만, 리스트 자료구조 자체를 반환하면 반환된 참조값을 통해 자료구조의 값을 변경할 수 있기 때문에 조회할 때는 새로운 값을 반환하여 원본 데이터에는 영향이 없도록 수정할 필요가 있어 보였다
그래서 아래의 코드처럼 menuItem들을 저장하는 List를 반환할 때는 새로운 List를 반환하여 기존의 자료구조와 전혀 관계 없는 리스트를 반환하도록 변경했다
Menu 클래스의 코드 수정
private final List<MenuItem> menuItems = new ArrayList<>();
// 기존 메서드
public List<MenuItem> findAllMenuItem() {
return menuItems;
}
// 변경된 메서드
public List<MenuItem> findAllMenuItem() {
return new ArrayList<>(menuItems);
}
도전 LV1 - getOrDefault 활용
장바구니를 구현할 때 기존 상품이 담겨져있으면 상품이 추가되도록 하는 요구사항이 있었는데 맵으로 장바구니를 구현할 때는 이를 빼먹고 단순히 put으로 저장했었다.
그래서 조언을 얻은 대로 아래의 코드 처럼 장바구니에서 menuItem을 조회할 때 getOfDefault()메서드로 조회하여 없으면 값을 0으로 반환하고 있다면 key에 해당되는 값을 반환되도록 한다음, 해당 값에 파라미터로 넘겨진 수량을 더해서 장바구니에 저장되도록 로직을 수정했다.
getOrDefault()메서드는 꽤 자주 사용할 것 같아서 기억해두자!
private final Map<MenuItem, Integer> cart = new HashMap<>();
public void addCart(MenuItem menuItem, int quantity) {
Integer defaultQuantity = cart.getOrDefault(menuItem, 0);
int addQuantity = defaultQuantity + quantity;
cart.put(menuItem, addQuantity);
System.out.println(menuItem.getName() + " "+ addQuantity + "개를 장바구니에 추가 하였습니다.");
}
도전 LV2 - 장바구니 설계 구조 변경
사실 장바구니 설계를 어떤 것이 좋은 설계일지 고민을 많이 하다가 튜터링을 받았었는데.. 뭔가 오해가 있었다는 것을 새로운 피드백을 통해서 알게 되었다.
사실 Map의 자료구조나 List의 자료구조, 아니면 Redis를 활용하거나 다양한 구현 방법이 있을 수 있지만 우선 장바구니의 수량의 개념이기 때문에 실제 Item인 MenuItem에 수량을 가지는 것은 잘못된 선택이었다는 것이다.
오히려 Map의 자료구조로 <MenuItem, Integer>의 수량을 가지는 것이 낫지 장바구니의 수량을 MenuItem을 가지는 것은 객체지향에 어긋나며 MenuItem(상품 아이템)이 가지는 수량은 재고의 개념으로 가지고 있어야 한다는 것이였다.
내가 알고있던 장바구니 예제에서의 Item 필드의 수량도 장바구니의 수량이 아니라 아이템 재고에서의 수량이었다.
근데 Map으로 장바구니를 구현할 때 아이템에 별도의 절대로 중복될 수 없는 index와 같은 key값이 정수 타입으로 존재하여 해당 값을 key로하고 value로 MenuItem이 있어야 한다고 하여 장바구니의 수량을 Integer로 가져가는 것이 잘못된 선택이라는 튜터링이였는데 이부분도 맞다고 생각한다.
그래서 나는 애초에 이런 논란이 없게 장바구니는 List로 가져가고 장바구니에 담는 아이템을 별도로 MenuItem과 장바구니의 수량을 필드로 가지고 있는 CartItem을 만들어서 장바구니는 CartItem을 가지는 구조로 설계를 변경하기로 했다.
패키지 구조 변경
우선 단일 패키지로 구분없이 존재하던 클래스를 각각의 용도에 맞게 패키지로 구분 지었다
cart 패키지
- 장바구니와 관련된 클래스들을 모아두었다
- 실제 장바구니인 ShoppingCart와 장바구니의 요소인 CartItem을 가지고 있다
discount 패키지
- 할인 정책을 관리하는 DiscountPolicy를 가지고 있다
kiosk 패키지
- 실제 프로그램의 흐름을 관리하는 Kiosk클래스를 가지고 있다
Menu 패키지
- MenuItem의 상위 개념인 카테고리를 구분하는 Menu 클래스와 Menu에 속하는 실제 상품인 MenuItem 클래스를 가지고 있다.
lv2 패키지
- 위의 패키지들을 모두 가지고 있는 상위 패키지로 프로그램을 실행하는 Main 클래스를 가지고 있다
util 패키지
- 금액을 포맷팅하는 PriceFormatter 추상 클래스는 다른 패키지에서도 쓸 수 있도록 때문에 완전히 별도의 util 패키지로 뺐다.
여기서 변경이 발생한 부분은 장바구니를 담당하는 ShoppingCart, CartItem 그리고 수량을 보관하고 있던 MenuItem이다
MenuItem
기존 MenuItem
public class MenuItem {
// 변경이 없는 필드와 메서드는 생략
private int quantity;
public int getQuantity() {
return quantity;
}
public void addQuantity(int quantity) {
this.quantity += Math.abs(quantity);
}
public long itemPrice() {
return quantity * price;
}
}
기존의 상품인 MenuItem은 장바구니의 수량을 가지고 있어 잘못된 객체지향 설계를 가지고 있기 때문에 수량을 뜻하는 quantity필드와 이와 관련된 수량을 반환하고 수량을 증가시키고 수량과 단가를 곱한 아이템의 합계 금액을 반환하는 메서드를 모두 지웠다.
CartItem
public class CartItem {
private final MenuItem menuItem;
private int cartQuantity;
public CartItem(MenuItem menuItem, int cartQuantity) {
this.menuItem = menuItem;
this.cartQuantity = Math.abs(cartQuantity);
}
public CartItem addQuantity(int quantity) {
cartQuantity += Math.abs(quantity);
return this;
}
public MenuItem getMenuItem() {
return menuItem;
}
public int getCartQuantity() {
return cartQuantity;
}
public long itemPrice() {
return menuItem.getPrice() * cartQuantity;
}
}
장바구니에 실제 담기는 요소인 CartItem으로 상품인 MenuItem과 해당 아이템이 장바구니에 몇개 담겨져있는지 나타내는 int cartQuantity를 가지고 있다.
여기에 기존에 MenuItem이 가지고 있던 수량을 반환하는 메서드, 단가와 수량을 곱하여 상품의 합계 금액을 반환하는 메서드, 장바구니의 수량을 증가시키는 메서드를 모두 가지고 있다.
수량을 입력할 때 실수로 음수로 들어올 경우를 대비하여 Math.abs()함수로 매개변수의 수량을 양수로 반환하여 저장하도록 구현했다.
ShoppingCart
장바구니 구조
public class ShoppingCart {
private final List<CartItem> cart = new ArrayList<>();
// 변경이 없는 메서드는 생략
}
장바구니를 구현한 List 자료구조를 보면 CartItem을 제네릭타입으로 사용하고 있다.
이제 장바구니에 아이템을 저장하려면 선택된 MenuItem과 수량 정보를 가지고 CartItem을 생성하여 저장해야 한다.
addCart()
public void addCart(MenuItem menuItem, int quantity) {
Optional<CartItem> optionalCartItem = cart.stream()
.filter(cartItem -> cartItem.getMenuItem().equals(menuItem))
.findAny();
if (optionalCartItem.isPresent()) {
optionalCartItem.get().addQuantity(quantity);
} else {
cart.add(new CartItem(menuItem, quantity));
}
System.out.println(menuItem.getName() + " " + quantity + " 개가 장바구니에 추가 되었습니다.");
}
가장 변경이 많은 장바구니에 상품과 수량을 담는 메서드이다.
장바구니는 MenuItem이 아닌 CartItem을 담기 때문에 선택된 MenuItem과 수량 정보를 기반으로 CartItem을 생성해서 장바구니에 담아야 한다
하지만 여기서 장바구니에 이미 동일한 상품이 담겨져 있다면 수량만 증가하는 로직이 필요하여 cart를 stream 문법을 통해 cart에 저장되어있는 요소와 저장할 MenuItem이 동일한지를 비교하여 같은 상품을 Optional로 반환한다.
이때 편하게 동등성 비교를 하기 위해서 CartItem에 equals()와 hashCode()를 오버라이딩 해둔 것이며 이미 MenuItem에는 오버라이딩 해두었다.
이렇게 조회된 optionalCartItem을 isPresent()로 확인하여 동일한 MenuItem이 있다면 get()으로 장바구니에 담긴 CartItem을 꺼내서 addQuantity()메서드를 호출하여 기존 수량에 선택한 수량을 더해주고, 없다면 CartItem을 새로 생성하여 cart.add()로 장바구니에 CartItem을 저장하는 식으로 장바구니 구현을 완료했다.
그 외 변경이 발생한 메서드들
public void showCart() {
System.out.println("[Orders]");
cart.stream().forEach(cartItem -> System.out.printf("%-18s | %d개 | 상품 합계: ₩ %10s | %s%n",
// itemPrice(): 수량이 반영된 개별 메뉴당 합계 가격
cartItem.getMenuItem().getName(),
cartItem.getCartQuantity(),
priceFormat(cartItem.itemPrice()),
cartItem.getMenuItem().getDescription()));
System.out.println();
}
public long getTotalPrice() {
long totalPrice = cart.stream()
.mapToLong(CartItem::itemPrice)
.sum();
System.out.println("[Total]");
String priceFormat = priceFormat(totalPrice);
System.out.println("₩ " + priceFormat);
return totalPrice;
}
public void deleteOneCartList(String cancelMenuItemName) {
int index = IntStream.range(0, cart.size())
.filter(i -> cart.get(i).getMenuItem().getName().equalsIgnoreCase(cancelMenuItemName))
.findFirst()
.getAsInt();
System.out.println(cart.get(index).getMenuItem().getName() + " 가 장바구니에서 제거 되었습니다.");
cart.remove(index);
}
실제 동작하는 구조가 변경된 것은 아니며 기존에는 장바구니에서 상품인 MenuItem을 직접 가지고 있었지만, 이제는 장바구니의 요소인 CartItem을 가지고 있기 때문에 CartItem에서 getMenuItem()으로 상품을 꺼내서 해당 필드를 조회하는 방식으로 구조가 변경되었다.
최종 회고
캡슐화를 할 때 내부에 요소를 담고있는 참조형 필드들은 내부의 요소까지도 캡슐화가 되어있는지 고려해야하며, 조회할 때에는 필드를 직접 반환하는 것보다 새로운 객체를 반환하는 것이 좋다는 개념을 다시한번 복습할 수 있는 계기가 되었다.
추가적으로 장바구니를 구현하기 위한 다양한 설계에 대해서 고민해볼 수 있는 시간을 가질 수 있었다는게 오히려 큰 소득이였다.
Map으로 구현할 때 아이템을 key로 할 수도 있는 관점과 오히려 별도로 중복되지 않는 프라이머리키와 같은 값을 key로 해야 한다는 관점의 차이와, 객체지향적으로 장바구니를 구현하려면 장바구니의 요소를 뜻하는 객체를 별도로 하나 만들어서 설계하는 것이 더 좋을 수 있다는 것! 이러한 흐름은 내가 앞으로도 무언가를 설계할 때 어떤 것이 더 좋은 설계인지 고민할 때 더 좋은 방향으로 판단할 수 있도록 해주는 좋은 경험이었다고 생각한다.