이번 키오스크 과제를 하면서는 설계를 하는 데에 많은 고민을 했다.
어떤 클래스를 만들까? 이 클래스가 어떤 역할까지를 맡는 것이 맞을까?
어떤 메서드를 만들까? 이 메서드가 어떤 기능까지 수행하는 것이 맞을까?
와 같이 역할을 분리하는 데에,
특히 사용자와 상호작용 하는 부분과 내부 비즈니스 로직을 어떤 클래스에 어떤 메서드로 분할할 지를 많이 고민했다.
또 나의 코드를 읽는 이로 하여금 쉽게 이해할 수 있는 코드를 짜려고 노력을 했다.
결과적으로 시원하게 모든 고민들을 해결했다고 말할 순 없지만(애초에 가능한가?), 고민하면서 전체 코드에 대거 변화가 일어나기도 했다.
일단 구현을 하면서 마주쳤던 고민들과 공부 내용, 해결했던 방법들을 정리해보자.
들어가기 앞서 ...
우선 필수기능까지 구현한 기존의 프로젝트 구조는 이랬다.
level5
├── Kiosk.java
├── Main.java
├── Menu.java
└── MenuItem.java
각 클래스의 내용은 다음과 같다.
필수 기능에서는
- 메뉴 카테고리 조회
- 유효한 메뉴 카테고리 선택
- 선택한 카테고리의 메뉴 아이템 리스트 조회
- 유효한 메뉴 아이템 선택
까지 구현이 되었다.
도전 기능에서 추가해야 하는 기능을 정리하자면 다음과 같다.
1. 장바구니 기능 추가
- 사용자가 선택한 메뉴를 장바구니에 추가
- 장바구니는 메뉴명, 수량, 가격 정보를 저장
- 특정 메뉴만 장바구니에서 빼기 (*Lambda/Stream 활용)
2. 주문 기능 추가
- 장바구니에 담긴 모든 항목 출력
- 장바구니에 담긴 주문의 총 금액 출력
- 사용자 유형별 할인율 적용하여 최종 금액 계산 (군인/학생/일반인) (*Enum 활용)
주문 기능을 추가하는 데에는 큰 어려움이 없었기에 장바구니 기능을 구현하면서 고민하고 공부했던 내용을 중점으로 기록해보려고 한다.
🗂️ 장바구니 데이터를 저장할 자료구조
장바구니에는 메뉴명, 수량, 가격 정보가 저장되어야 하며, MenuItem이 중복으로 들어올 수 없다. (이미 장바구니에 넣은 MenuItem을 다시 넣는 경우, 수량만 추가되어야 한다.)
❌ Map<String,Order>
먼저 떠올린 방법은 MenuItem과 함께 수량 데이터를 갖는 `Order` class를 만드는 것이었다.
public class Order {
private final MenuItem item;
private int quantity;
public Order(MenuItem item, int quantity) {
this.item = item;
this.quantity = quantity;
}
public void addQuantity() {
quantity++;
}
public MenuItem getItem() {
return item;
}
public int getQuantity() {
return quantity;
}
public String getItemName() {
return item.getName();
}
}
그리고 Map을 사용해서 메뉴명을 key로, 그 메뉴를 담은 Order를 value로 갖는 HashMap<String,Order>의 형태로 장바구니 데이터를 구현했다.
장바구니에 `MenuItem`을 추가할 때 메뉴명(`MenuItem.name`)이 Map내에 존재하는 지 확인하고, 있을 시에는 해당 Order의 수량만을 추가시켜주고 없을 시에는 새로 Order를 만들어서 넣어줬다.
// <아이템 이름, 주문 내용>
private Map<String, Order> cart = new HashMap<>();
// 장바구니에 추가하는 메서드
public void addItem(MenuItem menuItem) {
if(cart.containsKey(menuItem.getName())) {
cart.get(menuItem.getName()).addQuantity();
} else {
cart.put(menuItem.getName(), new Order(menuItem,1));
}
}
그런데 구현을 하고보니 다음과 같은 점이 마음에 걸렸다.
- `Order`가 멤버로 가지는 `MenuItem`안에 이미 name이 들어있는데 key로 또 `menuItem.getName()`을 넣는다.
즉 `cart` 데이터 내에 중복된 데이터를 유지한다. - 같은 메뉴 아이템임을 비교할 때 `아이템의 이름`만을 비교하는 것이 적절한가?
좋지 못 한 설계임을 깨닫고 다음과 같이 변경했다.
✅ Map<MenuItem,Integer>
Order 클래스는 필요없는 클래스였음을 깨닫고 삭제 조치하였다. (ㅋ)
이후 MenuItem을 key로 갖고 해당 아이템의 수량을 value로 갖는 Map으로 장바구니 데이터를 수정하였다.
private final Map<MenuItem, Integer> cartItems = new HashMap<>();
Map은 중복된 key를 허용하지 않는다. MenuItem을 key로 가지는 것이 장바구니에 하나의 MenuItem만 들어올 수 있다는 사실을 가장 논리적으로, 직관적으로 표현할 수 있는 것 같다.
이제 장바구니에 메뉴 아이템을 추가하는 메서드는 다음과 같이 바뀌었다.
public void add(MenuItem menuItem) {
cartItems.put(menuItem, cartItems.getOrDefault(menuItem,0)+1);
}
기존 String이 key로 사용될 때는 String 객체의 동일성을 비교하는 것이 아니라 문자열의 문자들을 비교해서 같은 내용을 가진 문자열이면 동일한 데이터로 취급하도록 String 클래스 내에서 구현이 되어 있음을 알고 있었다. (딱 이 정도만 알고 자세히는 몰랐다.)
그렇지만 이제는 내가 만든 MenuItem 클래스의 객체가 key로 들어가게 되므로, 객체가 가진 필드의 내용이 같은 MenuItem은 동일한 데이터로 취급하도록 내가 직접 설정해주어야한다.
이를 위해 Java의 HashMap에서는 key로 들어온 객체의 유일함을 어떻게 판단하는가?를 확인해보았다.
(+) 객체의 동일성과 동등성
여기서 잠깐! 객체의 `동일성` vs. 객체의 `동등성`
✓ `동일성` (=Identity)
• 두 객체가 같은 메모리 주소를 참조하고 있는 지를 판단한다.
• 같은 인스턴스를 가리키는 두 참조변수는 동일하다.
• == 연산을 이용한 두 객체의 비교는 항상 동일성을 판단한다.
✓ `동등성` (=Equality)
• 두 객체의 내용(값)이 같은 지를 판단한다.
• `equals()` 메서드를 오버라이딩하여 객체의 논리적 동일성을 정의할 수 있다. (정의하지 않을 경우 `==` 연산과 동일하게 동작한다.)
🗂️ HashMap에서 키의 유일성을 판단하는 방법
검색을 통해 확인하게 된 사실 !
Java에서는 Hash 기반 자료구조에서 데이터의 유일성을 확인하기 위해 객체의 `hashCode()`메서드와 `equals()`메서드를 사용한다.
확인해보았다
HashMap 클래스의 `put` 메서드를 호출하면 내부적으로 `putVal` 이라는 메서드를 사용한다.
이 때, `hash(key)`의 값을 매개변수로 넘기고 있다.
`hash` 메서드를 확인해보니 다음과 같이 `key.hashCode()`를 사용하고 있음을 알 수 있었다.
아래는 `putVal` 메서드의 내용 중 일부이다.
두 번째 if문을 해석해보자면,
- key의 해시 값을 인덱스로 해서 접근한 버킷이 비어있다면(=null이라면) 해당 버킷에 새로운 노드 생성하여 데이터를 저장한다.
즉, key의 `hashCode()`를 사용하여 만든 해시값과 같은 해시값을 갖는 key가 Map내에 존재하지 않을 경우, 해당 key가 존재하지 않는다고 바로 판단하는 것이다.
그런데 만일 해시 값이 동일한 key가 존재한다면? 해시 함수의 한계에 의해 해시 충돌이 일어나 같은 해시값을 가지는 객체는 존재할 수 밖에 없다.
else문 안에서 처음 등장하는 if문을 해석해보자면,
- 만일 먼저 버킷에 저장되어 있는 노드(p)의 키가 현재 들어온 key와
- hash값이 같고
- 동일한 객체(메모리 주소 확인)이거나
혹은 동일하진 않더라도 동등한 객체라면(`key.equals()`으로 동등성 확인)
- 두 key는 동일하다고 간주한다.
- 새로운 노드를 추가하지 않고 value를 덮어쓴다.
get도 찾아보았다. 내부적으로 `getNode` 메서드를 사용한다.
- 키의 해시 값을 이용해서 버킷에 접근한 뒤, 비어있는 지를 먼저 확인한다. (비어있다면 해당 키의 데이터 없음 처리)
- 비어있지 않은 경우 `key.equals()`의 결과가 true인 데이터를 찾아 반환한다.
→ 정리
HashMap<K,V>에 새로운 데이터 <key,value>쌍을 추가할 때 : put 연산 시
✓ `key.hashCode()`
∙ 해당 데이터쌍을 저장할 버킷을 결정하는 데 사용된다.
∙ 버킷이 비어있다면 같은 key가 Map안에 존재하지 않는 것으로 바로 판단한다. (데이터 저장)
✓ `key.equals()`
∙ 버킷에 객체가 이미 존재할 때, 키가 실제로 동일한지를 비교하기 위해 사용한다.
∙ 버킷에 연결된 노드 중 결과가 true인 객체가 있는 경우 같은 key가 Map안에 존재하는 것으로 판단하여 value를 덮어쓴다.
HashMap<K,V>에 key로 value를 얻을 때 : get 연산 시
✓ `key.hashCode()`
∙ 데이터가 저장된 버킷을 찾는 데 사용된다.
∙ 버킷이 비어있다면 해당 key의 데이터가 Map안에 존재하지 않는 것으로 바로 판단한다. (null 반환)
✓ `key.equals()`
∙ 버킷에 객체가 존재할 때, 버킷 내에서 동일한 key를 찾아 value를 반환하기 위해 사용된다.
∙ 버킷에 연결된 노드 중 key.equals() 결과가 true인 key를 가진 노드를 찾아 value를 반환한다.
→ 어떤 두 객체가 HashMap에서 동일한 객체로 판단되고 싶다면 hashCode()와 equals()의 결과가 모두 동일하여야 한다.
✅ hashCode() / equals() 오버라이딩
음음 글쿤
알았으니 이제 `MenuItem` 클래스가 hashCode와 equals 메서드를 모두 오버라이딩 하도록 해주었다.
public class MenuItem {
private final String name;
private final double price;
private final String description;
// ... 생략
@Override
public boolean equals(Object o) {
if(o instanceof MenuItem item) {
return item.getName().equals(this.name)
&& item.getPrice()==this.price
&& item.getDescription().equals(this.description);
}
return false;
}
@Override
public int hashCode() {
return name.hashCode();
}
}
이제 HashMap<MenuItem,Intege>에 put 연산을 할 때,
- `hashCode()` 재정의 결과 -> name이 같은 MenuItem은 같은 버킷 인덱스를 가질 것이다.
- name이 같은 것은 같은 MenuItem이 되기 위한 필요조건이다. (name이 같아야지만 같은 MenuItem이 될 가능성이 생긴다.)
여기서 잠깐! String의 `hashCode()` 동작
∙ 문자열의 각 문자 (char)를 이용해 고유한 숫자값을 계산한다.
∙ 결과적으로 문자열 값이 동일하다면 동일한 해시코드를 반환한다.
- `equals()` 재정의 결과 -> name, price, description이 모두 동일한 MenuItem만이 같은 객체로 판단될 것이다.
- name, price, description이 모두 같은 것은 같은 MenuItem이 되기 위한 필요충분조건이다. (세 가지가 모두 같아야만 두 MenuItem이 같다고 결론 내릴 수 있다.)
🗂️ 최종, 장바구니 기능 구현
우선 Kiosk 클래스 내에서 메뉴 선택/장바구니/주문의 모든 로직을 다 수행한다면 클래스가 너무 비대해지고 복잡해지므로 장바구니 / 주문과 관련된 입출력 로직은 각각 Service로 분리해서 작성했다.
✅ Cart 클래스 - 장바구니 데이터 관리
import java.util.HashMap;
import java.util.List;
import java.util.Map;
public class Cart {
private final Map<MenuItem, Integer> cartItems = new HashMap<>();
public void add(MenuItem menuItem) {
cartItems.put(menuItem, cartItems.getOrDefault(menuItem,0)+1);
}
public void remove(MenuItem menuItem) {
cartItems.remove(menuItem);
}
public boolean isEmpty() {
return cartItems.isEmpty();
}
public void reset() {
cartItems.clear();
}
public List<MenuItem> getMenuItemsList() {
return cartItems.keySet().stream().toList();
}
public double getTotalPrice() {
return cartItems.keySet().stream()
.mapToDouble(item -> item.getPrice() * cartItems.get(item))
.sum();
}
@Override
public String toString() {
StringBuilder sb = new StringBuilder();
cartItems.keySet()
.forEach((item)-> sb.append(item.toString()).append(" [").append(cartItems.get(item)).append("개]\n"));
return sb.toString();
}
}
장바구니 데이터를 관리할 `Cart` 클래스를 따로 구현했다.
- 장바구니 데이터 추가 / 삭제 / 조회 기능을 제공한다.
- 비즈니스 로직과는 분리되어 장바구니 데이터의 생성 / 삭제 / 삽입 / 조회에만 초점을 맞춘다.
* 다시 보니 getTotalPrice() 역시 분리하여 Service단의 메서드로 정의하는 것이 좋을 것 같다.
✅ CartService 클래스 - 장바구니 관련 비즈니스 로직 수행
import com.example.kiosk.level6.Cart;
import com.example.kiosk.level6.MenuItem;
import java.util.*;
/**
* 장바구니와 관련된 로직 처리
* 장바구니 추가 / 제거 / 조회
*/
public class CartService {
private final Cart cart = new Cart();
// 장바구니에 추가
public void processAddCartLogic(MenuItem menuItem) {
Scanner sc = new Scanner(System.in);
System.out.println(menuItem);
System.out.println("위 메뉴를 장바구니에 추가하시겠습니까?");
System.out.println("1. 확인 2. 취소");
while (true) {
String inputStr = sc.nextLine();
if("1".equals(inputStr)) {
cart.add(menuItem);
System.out.println();
System.out.println(menuItem.getName()+" 이(가) 장바구니에 추가되었습니다.");
return;
} else if ("2".equals(inputStr)) {
return;
} else {
System.out.println("정해진 번호를 입력해주세요.");
}
}
}
// 장바구니에서 제거
public void processRemoveLogic() {
Scanner sc = new Scanner(System.in);
while(true) {
if(cart.isEmpty()) {
System.out.print("\n장바구니가 비었습니다. 메인 화면으로 돌아갑니다.\n");
return;
}
System.out.print("\n[ Orders ]\n");
printCartItems();
System.out.print("\n어떤 메뉴를 빼시겠습니까? 메뉴명을 입력해주세요. (cancel : 메뉴판으로 돌아가기 / all : 전부 빼기)\n");
String inputStr = sc.nextLine();
if("cancel".equals(inputStr)) {
return;
}else if("all".equals(inputStr)) {
cart.reset();
continue;
}
try {
System.out.printf("\n%s 이(가) 장바구니에서 제거되었습니다.\n",removeItemByName(inputStr).getName());
} catch(Exception e) {
System.out.println(e.getMessage());
}
}
}
private MenuItem removeItemByName(String inputName) {
MenuItem selectedItem = cart.getMenuItemsList().stream()
.filter((menuItem -> menuItem.getName().equalsIgnoreCase(inputName)))
.findFirst()
.orElseThrow(()-> {throw new IllegalArgumentException("메뉴가 존재하지 않습니다. 다시 입력해주세요.");});
cart.remove(selectedItem);
return selectedItem;
}
public void printCartItems() {
System.out.print(cart);
}
public boolean isCartEmpty() {
return cart.isEmpty();
}
public void resetCart() { cart.reset(); }
public double getTotalPrice() {
return cart.getTotalPrice();
}
}
장바구니 데이터를 가지고 비즈니스 로직을 수행하는 클래스이다.
- 장바구니 관련 입출력 (Scanner/System.out를 통해 사용자 입력을 받아오고 출력)
- 장바구니 상태 변경 처리 (ex.`removeItemByName`)
- 장바구니 상태 조회
✅ Kiosk 클래스에서의 사용
Kiosk 클래스에서는 CartService를 멤버변수로 가지며, 장바구니와 관련된 로직이 필요할 때 CartService를 사용한다.
/**
* 프로그램 전체 흐름 제어 / 상&하위 카테고리 출력 담당
*/
public class Kiosk {
private final List<Menu> menus;
private final CartService cartService;
private final OrderService orderService;
public Kiosk(List<Menu> menus) {
this.menus = menus;
cartService = new CartService();
orderService = new OrderService(cartService);
}
// 생략 ...
}
메인함수에서 호출되는 start() 메서드를 보자.
public void start() {
Scanner sc = new Scanner(System.in);
int orderIdx = menus.size()+1;
int removeFromCartIdx = menus.size()+2;
while(true) {
printMenuCategory();
int maxIdx = menus.size();
if(!cartService.isCartEmpty()) {
printOrderRelatedMenu(menus.size()+1);
maxIdx+=2;
}
try {
int menuIdx = getValidInputIdx(0,maxIdx);
if(menuIdx == 0) { // 종료 조건, 키오스크 종료 처리
return;
} else if(menuIdx<= menus.size()) {
Menu selectedMenu = menus.get(menuIdx-1);
processMenuItemSelectLogic(selectedMenu);
} else if(menuIdx==orderIdx){
orderService.processOrderLogic();
} else if(menuIdx==removeFromCartIdx){
cartService.processRemoveLogic();
}
} catch (NumberFormatException e) {
System.out.println("처리에 실패했습니다. 원하는 메뉴 번호를 숫자 형식으로 입력해주세요.");
} catch (Exception e) {
System.out.println("처리에 실패했습니다." + e.getMessage());
}
}
}
`26번` 줄에서 `cartService.processRemoveLogic()`을 호출하면 장바구니 빼기 로직이 수행된다.
다음은 Kiosk 클래스 내에서 메뉴 카테고리 선택 시 메뉴 아이템을 고르는 로직을 수행하는 메서드이다.
private void processMenuItemSelectLogic(Menu selectedMenu) {
Scanner sc = new Scanner(System.in);
while(true) {
printMenuItems(selectedMenu);
try {
int itemIdx = getValidInputIdx(0,selectedMenu.getMenuItems().size());
if(itemIdx == 0) { // 뒤로 가기, 메인 메뉴로 복귀
return;
}
MenuItem selectedMenuItem = selectedMenu.getMenuItems().get(itemIdx-1);
System.out.printf("선택한 메뉴 : %s\n\n",selectedMenuItem);
// 장바구니 추가
cartService.processAddCartLogic(selectedMenuItem);
return;
} catch (NumberFormatException e) {
System.out.println("처리에 실패했습니다. 정해진 숫자 형식으로 입력해주세요.");
} catch (Exception e) {
System.out.println("처리에 실패했습니다. " + e.getMessage());
}
}
}
`16번`줄에서 `cartService.processAddCartLogic(selectedMenuItem)`를 호출하여 장바구니 추가 로직을 수행한다!
참고 : 전체 Kiosk 클래스
package com.example.kiosk.level6;
import com.example.kiosk.level6.service.CartService;
import com.example.kiosk.level6.service.OrderService;
import java.util.List;
import java.util.Scanner;
/**
* 프로그램 전체 흐름 제어 / 상&하위 카테고리 출력 담당
*/
public class Kiosk {
private final List<Menu> menus;
private final CartService cartService;
private final OrderService orderService;
public Kiosk(List<Menu> menus) {
this.menus = menus;
cartService = new CartService();
orderService = new OrderService(cartService);
}
public void start() {
Scanner sc = new Scanner(System.in);
int orderIdx = menus.size()+1;
int removeFromCartIdx = menus.size()+2;
while(true) {
printMenuCategory();
int maxIdx = menus.size();
if(!cartService.isCartEmpty()) {
printOrderRelatedMenu(menus.size()+1);
maxIdx+=2;
}
try {
int menuIdx = getValidInputIdx(0,maxIdx);
if(menuIdx == 0) { // 종료 조건, 키오스크 종료 처리
return;
} else if(menuIdx<= menus.size()) {
Menu selectedMenu = menus.get(menuIdx-1);
processMenuItemSelectLogic(selectedMenu);
} else if(menuIdx==orderIdx){
orderService.processOrderLogic();
} else if(menuIdx==removeFromCartIdx){
cartService.processRemoveLogic();
}
} catch (NumberFormatException e) {
System.out.println("처리에 실패했습니다. 원하는 메뉴 번호를 숫자 형식으로 입력해주세요.");
} catch (Exception e) {
System.out.println("처리에 실패했습니다." + e.getMessage());
}
}
}
private void processMenuItemSelectLogic(Menu selectedMenu) {
Scanner sc = new Scanner(System.in);
while(true) {
printMenuItems(selectedMenu);
try {
int itemIdx = getValidInputIdx(0,selectedMenu.getMenuItems().size());
if(itemIdx == 0) { // 뒤로 가기, 메인 메뉴로 복귀
return;
}
MenuItem selectedMenuItem = selectedMenu.getMenuItems().get(itemIdx-1);
System.out.printf("선택한 메뉴 : %s\n\n",selectedMenuItem);
// 장바구니 추가
cartService.processAddCartLogic(selectedMenuItem);
return;
} catch (NumberFormatException e) {
System.out.println("처리에 실패했습니다. 정해진 숫자 형식으로 입력해주세요.");
} catch (Exception e) {
System.out.println("처리에 실패했습니다. " + e.getMessage());
}
}
}
public void printMenuCategory() {
System.out.println("\n[ MAIN MENU ]");
for(int i=0; i<menus.size(); i++) {
System.out.printf("%d. %s\n",i+1,menus.get(i).getCategory());
}
System.out.println("0. 종료 | 종료");
}
public void printMenuItems(Menu menu) {
System.out.println(menu);
menu.printMenuItems();
System.out.println("0. 뒤로가기");
}
public void printOrderRelatedMenu(int startIdx) {
System.out.print("\n[ ORDER MENU ]\n");
System.out.printf("%d. Orders | 장바구니를 확인 후 주문합니다.\n",startIdx);
System.out.printf("%d. Cancel | 진행중인 주문을 취소합니다.\n",startIdx+1);
}
public List<Menu> getMenus() {
return menus;
}
private int getValidInputIdx(int min, int max) {
Scanner sc = new Scanner(System.in);
String menuInput = sc.nextLine();
int menuIdx = Integer.parseInt(menuInput);
if(menuIdx<min || menuIdx>max) {
throw new IllegalArgumentException("존재하지 않는 메뉴 번호입니다. 다시 입력해주세요.");
}
return menuIdx;
}
}
이렇게 장바구니 기능 구현 완료!
장바구니 기능을 구현하면서 다음과 같은 것들을 얻을 수 있었다.
- Java에서 객체의 동일성/동등성에 대해 다시 한 번 알아보았다.
- HashMap에서 Key의 동일성을 처리하는 로직에 대해 깊게 공부해볼 수 있었다.
- 데이터 처리 로직과 비즈니스 로직을 각 클래스가 분리하여 담당하도록 설계하기 위해 고민해볼 수 있었다.
'TIL' 카테고리의 다른 글
키오스크 과제 : Stream 함수에서 인덱스 사용하기 (1) | 2025.01.16 |
---|---|
🧐 계산기 과제를 마무리하는데 생겨난 궁금증 (0) | 2025.01.09 |
계산기 과제 : 계산 결과 Lambda&Stream 필터링 조회 구현하기 (0) | 2025.01.07 |
계산기 과제 : Java Generics, Enum 활용기 (0) | 2025.01.06 |