[TIL/WIL] 4주차 과제: 키오스크 만들기
[GitHub] KioskProejct Repository
* 기능 구현 도중 고민 사항, 문제 발견, 해결 내용 등은 모두 [TIL/WIL]에 작성되었습니다.
* 기능 구현이 모두 끝나고 추가 개선을 하면서 작성한 트러블슈팅 입니다.
1. 개요
[내일배움캠프] 4주차 과제 '키오스크 만들기' 기능을 모두 구현한 후, 개인적으로 마음에 들지 않는 내용. 추가 개선이 필요한 사항을 살펴보았다. 개선 1순위는 '깔끔한 코드를 만들자'였고, 2순위는 '객체 지향 프로그래밍이라는 걸 기억하자'였다.
클린 코드를 위한 리팩터링 과정 트러블슈팅을 작성하려고 한다. 객체 지향 설계 원칙을 유지하며 좀 더 좋은 프로그램을 만들기 위한 고민 해결 과정을 담아낸다.
2. 트러블 슈팅
크게 세 가지 문제를 해결하려고 한다.
- 확장성이 높은가? - '카테고리, 제품, 할인'에 유형을 추가해도 기능에 문제가 없어야 한다.
- 다형성을 향상 시키자 - 입력받은 값으로 제어하는 swtich 개선, 직접 출력을 제거하고 참조하기
- OCP를 지키지 못하는 모듈을 찾아 응집도, 결합도를 고려하여 개선하자
완벽한 코드가 아니기에 다른 문제가 더 있을 수 있지만 눈에 제일 잘 띄는(마음에 안 드는) 문제는 이와 같다.
하나의 문제만 해결하는 것이 아니기 때문에 발단,전개,… 로 작성하는 것보다 각 문제에 맞게 트러블슈팅을 작성하는 것이 더 좋을 것 같다. 포스팅 제일 위, 목차에서 각 문제를 누르면 해결 과정으로 이동 된다.
다른 것을 수정하기 전에 사용하는 정보를 먼저 완성하려고 한다.
프로그램에서 사용하는 데이터는 버거, 음료, 디저트로 나누는 '카테고리', 그 카테고리에 속한 '제품', 할인 적용을 위한 '선택 유형'이 있다. 이 세가지에 따라서 결과가 달라지기 때문에 세 가지를 먼저 제대로 설계하기로 했다.
배경
< 카테고리와 제품을 관리하는 Menu >
- MenuItem은 제품 이름, 가격, 설명을 가지고 있는 '제품'을 나타내는 클래스
- Menu는 List<MenuItem> 타입의 category를 가지고 있는 추상 클래스
- BurgersMenu, DrinksMenu, DessertsMenu는 각각 이 Menu를 상속받는 구현 클래스
각자의 생성자는 부모 Menu의 category를 이용해 제품을 저장
제품을 출력하는 메서드는 Menu를 Override해서 사용
< 장바구니를 관리하는 ShoppingCart >
- CartItem은 제품 이름, 선택 수량, 가격을 가지고 있는 '장바구니에 속한 제품'을 나타내는 클래스
- ShoppingCart는 List<CartItem> 타입의 cart를 가지고 있는 장바구니 관리 클래스
< 할인 유형과 할인율 계산을 위한 Discount>
- Enum타입의 Discount
- 선택할 수 있는 할인 유형을 가지고 있으며, 각 상수는 유형 이름, 할인율을 가지고 있다.
💡'제품' 을 생성하여, MenuItem과 CartItem이 상속하도록 변경
장바구니 선택 전, 선택 후 사용하는 제품에 대한 정보는 동일.
MenuItem과 CartItem은 사용 목적이 동일하고, 필드와 메서드가 비슷하다.
MenuItem | CartItem | |
공통점 | - name:String - price:double + getName():String + getPrice():double |
|
차이점 | - explanation:String + getExplanation():String |
- quantity:int + getQuantity():int + setQuantity(int):void |
- 인터페이스 - 추상클래스 -클래스 로 구현
제품을 공통으로 나타내는 getName(), getPrice()를 담은 인터페이스 Item을 생성하였다. 인터페이스에 필드를 넣을 수 있지만, static으로 선언되어 공통으로 사용하는 필드를 담기엔 무리가 있다. → 구현 도중에도 비슷한 상황을 겪음
이 인터페이스를 상속받은 추상클래스 Product를 만들어, 그 안에 name과 price를 필드로 넣고, 메서드를 오버라이드 하였다.
public interface Item {
String getName();
double getPrice();
}
public abstract class Product implements Item {
protected String name;
protected double price;
@Override
public String getName() {
return name;
}
@Override
public double getPrice() {
return price;
}
}
새로 추가된 인터페이스와 추상클래스를 토대로 MenuItem과 CartItem이 수정되었다.
공통으로 가지고 있던 필드와 메서드는 추상클래스를 상속받아 사용하고,
개별적으로 가지고 있던 필드와 메서드만 남겨두었다.
💡직접 출력하고 있는 Kiosk의 printMenu() 변경
// Kiosk
private void printMenu() {
System.out.println("[ MAIN MENU ]");
System.out.println("1. Burgers\n2. Drinks\n3. Desserts");
System.out.println("0. 종료");
}
private void printOrder() {
System.out.println("[ ORDER MENU ]");
System.out.println("4. Orders\n5. Cancel");
}
private void checkCart() {
System.out.println("위 메뉴를 장바구니에 추가하시겠습니까?");
System.out.println("1. 확인\t\t2. 취소");
}
메뉴 출력을 위해 생성했던 메서드. 이 중 printMenu()는 카테고리 이름을 문자열로 그냥 출력하고 있다.
- 최상위 클래스 Object에서 제공하는 메서드 사용
// Menu
public String getCategoryName(){
return this.getClass().getSimpleName();
}
클래스를 가져오는 getClass()를 활용하기로 했다.
자기 자신(this)의 클래스를 가져와(getClass())그 이름을 반환한다(getSimpleName())
추상클래스인 Menu를 구현하는 건 결국 상속받은 클래스들
this를 사용하는 건 상속받은 일반 클래스로 각자 자기 자신을 의미한다.
// Kiosk
public Kiosk(){
category.add(new Burgers());
category.add(new Drinks());
category.add(new Desserts());
}
private void printMenu() {
System.out.println("[ MAIN MENU ]");
for (int i = 0; i < category.size(); i++) {
System.out.println((i + 1) + ". " + category.get(i).getCategoryName());
}
System.out.println("0. 종료");
}
Kiosk에서 List<Menu> category를 만들어, Kiosk 생성자에 각 메뉴를 넣어주었다.
그 덕분에 category 안에 있는 메뉴들을 하나씩 출력할 수 있다.
하지만 이 방법은 Kiosk가 모든 카테고리를 알아야 하며, 카테고리 이름을 출력하기 위해 새로운 리스트를 생성한다.
이 방법을 개선시킬 수 있는 또 다른 방법을 찾아야 한다.
※ 이슈 #5에 대한 추가 해결이 필요해 보임.
🛠️ 관리 기능과 실제 사용 기능을 명확히 구분
Menu는 각 카테고리 자체를 관리하고 있는지, 제품을 관리하고 있는지 그 기능이 모호해보인다.
각 카테고리 별로 자기만의 제품을 모아둘 수 있어야 하고, 그 제품을 활용하는 것이 Menu의 역할이다.
- 인터페이스와 추상클래스의 성격을 고려하여 재구성
- 공통으로 수행할 추상 메서드를 가진 인터페이스 Category를 생성
인터페이스 Burgers, Drinks, Desserts는 자기만의 제품을 가지고 있을 카테고리 그 자체 - 이 인터페이스를 다중 상속 받는 추상 클래스 Menu는 카테고리를 활용하기 위한 메서드 구현 → 관리
추상 클래스를 최종 구현할 일반 클래스 생성
인터페이스의 변수는 static final로 제공된다. 이 성격을 활용하여 각자 List를 가질 카테고리를 생성해주었다.
자바는 다중 상속은 지원하지 않는다. 하지만 인터페이스는 다중 상속이 가능하다.
각자 List를 가지고 있는 3개의 인터페이스를 상속받아 메서드를 오버라이딩했다. Menu에서 재구성한 이 메서드들은 Menu를 실제 구현할 일반클래스에서 사용하려고 한다.
인터페이스에서 선언된 List는 Menu의 필드를 상속받는 각 클래스의 생성자에서 깊은 복사로 받아와 사용한다.
public interface Burgers extends Category {
List<MenuItem> burgers = new ArrayList<>();
}
public abstract class Menu implements Desserts, Burgers, Drinks {
List<MenuItem> category;
...
}
public class BurgersMenu extends Menu {
public BurgersMenu() {
burgers.add(new MenuItem("ShakeBuger", 6.9, "토마토, 양상추, 쉑소스가 토핑된 치즈버거"));
burgers.add(new MenuItem("SmokeShack", 8.9, "베이컨, 체리 페퍼에 쉑소스가 토핑된 치즈버거"));
burgers.add(new MenuItem("Cheeseburger", 6.9, "포테이토 번과 비프패티, 치즈가 토핑된 치즈버거"));
burgers.add(new MenuItem("Hamburger", 5.4, "비프패티를 기반으로 야채가 들어간 기본버거"));
category = new ArrayList<>(burgers);
}
}
처음에 아무 생각 없이 Menu의 생성자에서 3개의 각 제품을 모두 넣어줬다가 객체가 인스턴스 될 때마다 제품의 수가 배로 늘어나서 당황했다. 당연하게도 인스턴스 될 때마다 Menu의 생성자가 동작하니 같은 내용이 계속 추가 됐다. 결국 각 제품별로 생성자에서 원본에 제품을 추가하고, 그 원본을 받아오기로 결정했다.
외부 모듈과 사용자들은 실제 원본으로 사용하지 않고, 이 원본을 복사하여 가져온 List를 사용하니 캡슐화도 같이 성공했다.
💡Menu는 메뉴와 그 제품을 관리하는 역할 → Kiosk에서 제외
위에서 수정한 내용을 토대로 Menu를 Kiosk에서 조금 더 숨겨보겠다. Kiosk는 main에서 직접 사용하는 클래스로 외부로 유출될 가능성이 가장 큰 클래스. Menu는 각 메뉴과 제품을 관리하기 때문에 정보가 드러날 위험이 있다. Menu와 Kiosk 사이의 다리 역할을 할 클래스를 새로 생성하여, 이 다리 클래스가 Kiosk에게 정보를 전달해보자.
다리 역할이라고 했지만 브릿지 패턴을 구현하지는 못한 것 같다.
일단 할 수 있는 만큼 하고, 나중에 공부를 하게 된다면 수정하고 싶다!
- Display 를 생성하여 Kiosk에서 사용하기
- 제품과 카테고리의 최고 상위 메서드는 Category
제품 그 자체를 나타낼 Burgers, Drinks, Desserts
이 제품을 관리할 Menu
Menu를 구현할 BurgersMenu, DrinksMenu, DessertMenu - main()에서 가져올 Kiosk
Kiosk가 화면으로 가져올 Display
Display는 Menu가 합성으로 존재
public class Display {
List<Menu> menus = new ArrayList<>();
public Display() {
menus.add(new BurgersMenu());
menus.add(new DrinksMenu());
menus.add(new DessertsMenu());
}
public Menu getMenu(int idx) {
return menus.get(idx);
}
public void printMainMenu() {
System.out.println("[ MAIN MENU ]");
for (int i = 0; i < menus.size(); i++) {
String temp = menus.get(i).getClass().getSimpleName();
System.out.println((i + 1) + ". " + temp.substring(0, temp.length() - 4));
}
}
}
기존에 Menu에서 사용하던 printMainMenu()가 Display로 이동했다. 화면으로 보이는 출력부는 display에서 관리한다.
만약 새로운 카테고리 유형이 생기면
제품을 저장할 Category아래 인터페이스를 생성 → 제품이 담길 카테고리
Menu가 상속받도록 한다 → 새로운 카테고리를 메뉴에 등록
Menu를 구현할 일반 클래스를 생성해 안에 제품을 채워준다. → 제품 등록
Display 생성자에 카테고리를 추가해준다. → 화면에 출력
💡입력 받을 숫자와 선택 메뉴를 직접 비교하지 않고 객체와 변수 사용
// 예시로 가져온 메뉴 선택 메서드 -사용자에게 입력받은 값을 인자로-
public void setMenu(int input) {
if (input == 1) {
menu = burger;
} else if (input == 2){
menu = drink;
} else if (input == 3) {
menu = dessert;
}
}
위와 같은 경우를 포함하여 swtich와 if를 사용하는 경우 중, 사용자에게 입력받은 값을 하나씩 고르는 방식을 수정했다.
새로운 카테고리가 생길 경우, 바로 위에 작성한 방식만으로 메뉴를 선택할 수 없다.
setMenu와 같은 관련 메서드를 찾아 하나씩 조건문을 추가하는 하드코딩이 필요
- Map을 이용해 번호와 내용을 함께 저장하고 입력을 key로 사용
// BurgersMenu, DrinksMenu, DessertsMenu 객체가 담겨있는 menus
List<Menu> menus = new ArrayList<>();
// 입력 번호와 메뉴 순번을 미리 지정하기 위한 Map
Map<Integer, String> menuNumber = new HashMap<>();
// 입력 번호와 그 이름을 key, value로 사용하여 반환
public Map<Integer, String> printMainMenu() {
System.out.println("[ MAIN MENU ]");
for (int i = 0; i < menus.size(); i++) {
menuNumber.put(i + 1, menus.get(i).getCategoryName());
System.out.println((i + 1) + ". " + menuNumber.get(i+1));
}
System.out.println("0. 종료");
return menuNumber;
}
// 사용자에게 입력받은 번호를 이용해 메뉴 선택
public void setMenu(int input) {
for (Menu m:menus) {
String name = m.getCategoryName();
if (menuNumber.get(input).equals(name)){
menu = m;
}
}
}
관련된 리스트와 Map에 값을 넣는 printMainMenu()까지 함꼐 가져온 코드
printMainMenu()는 사용자에게 전체 메뉴를 보여주기 위한 출력 메서드
출력을 하면서 보여주는 번호는 사용자가 입력할 번호 → 사용자가 입력하는 번호를 key로 사용하면 값을 바로 가져올 수 있다.
사용자에게 입력받은 값을 이용하기 위해 조건문을 사용해 비교했다.
'입력받은 값'이 기준이 되는 거라면, 그 자체로 하나로 묶어 사용하면 되지 않을까? 라는 생각으로 Map을 만들었다.
비슷한 기능을 추가하지 않고, 기존에 만들어둔 Menu의 메서드 getCategoryName() 을 활용하였다.
Menu에 인스턴스를 넣고, 그 요소에 접근하면 계속 새로 인스턴스가 되었다. 제품 4개를 가진 메뉴가 24개까지 증가하는 모습을 보여, 이를 최대한 줄이기 위해 출력도 하고 접근도 해야 할 menuNumber에는 String을 저장하는 방법을 선택했다.
💡버거, 음료, 디저트 외에 카테고리가 더 생긴다면 기존 '4. 장바구니 ~' 입력은?
만약 Chicken이라는 메뉴가 새로 생기면
[ MAIN MENU ]
1. Burgers
2. Drinks
3. Desserts
4. Chicken
[ ORDER MENU ]
4. Orders
5. Cancle
위와 같은 모습이 나올 수 있다. 그 외에 새로운 메뉴가 생기면 5, 6, ~ 으로 증가한다.
장바구니와 취소를 위한 Orders와 Cancle의 번호를 수정할 필요가 있다.
- size()를 이용해 수정하기
private int printMenu() throws BadInputException {
menuNumber = display.printMainMenu();
// 장바구니에 주문 내역이 있으면
if (cart.isOrder()) {
System.out.println("[ ORDER MENU ]");
System.out.println((display.getSize()+1) + ". Orders\n" + (display.getSize()+2) + ". Cancel");
orderChecked = display.getSize()+2;
}
// 메뉴를 선택하고 잘못된 번호는 예외 처리
int input = new Scanner(System.in).nextInt();
if (input > orderChecked){
throw new BadInputException();
}
// 선택한 번호 반환
return input;
}
Display에 있는 메뉴 크기를 위한 getSize()를 이용했다.
새로 추가될 가능성이 있는 건 제품. 장바구니를 확인하고 삭제하는 order menu에는 더 추가될 사항이 없다.
[버거, 음료, 디저트, 치킨]이 있으면 getSize()의 값은 4
[버거, 음료, 디저트, 치킨, 상품]이 있으면 getSize()의 값은 5
장바구니와 출력은 그 메뉴에 맞춰서 하나씩 뒤로 밀려 또 다른 예외가 발생할 위험이 사라졌다.
3. 마무리
트러블슈팅을 통해 개선을 진행하면서 가장 큰 변화는 인터페이스를 생성한 것과 입력받은 값에 대해 직접 비교하지 않은 것이었다.
인터페이스를 알맞게 사용하면 객체 지향 프로그래밍의 특징을 살리는데 도움이 된다. 메뉴에서 선택받을 제품과 장바구니에서 확인할 제품은 결국 '제품'이라는 동일한 성질을 가지고 있었고, 각 메뉴에 대한 내용도 동일했다. 같은 것은 하나로 묶고 확장을 시키는 방식으로 구성했다.
처음에 작성한 것처럼 switch(input){ case 1: ... break; case 2: ... break; ... }
를 이용하는 건 다형성을 보장할 수 없다. 여러 디자인패턴을 사용해 해결하는 방식이 있지만 아직 디자인패턴을 깊게 공부하지 않아 적용하기엔 무리가 있었다. Map을 이용하거나 메서드를 가져오는 등 할 수 있는 방법으로 참조하며 개선했다.
하지만 아직 Kiosk에 불안정한 점이 많다. 이 Kiosk를 다른 사람이 사용하는 경우, 같이 제공해야 하는 클래스가 많다. 이 문제를 해결하기 위한 다른 구성(팩토리와 같은)이 필요하다. 과제 제출은 현재 구성한 것까지 마무리하지만, 나중에 사용자와 관리자를 명확히 구분하고 싶다.
관리자는 제품에 대한 CRUD가 가능하고 Kiosk 시스템 내부를 변경하는 것은 불가능하다. - DB로 예를 들면 DML만 가능하며 DDL, DCL은 불가능하도록-
'내일배움캠프' 카테고리의 다른 글
[일정 관리 앱] 일정을 생성하고 조회하기 - html view 반환 (0) | 2025.01.27 |
---|---|
일정 관리를 위한 서버를 간단하게 만들어보자 (0) | 2025.01.24 |
[TIL/WIL] 4주차 과제: 키오스크 만들기 (0) | 2025.01.14 |
[TIL/WIL] 2-3주차 과제: 계산기 만들기 (0) | 2025.01.07 |
스타터 노트 (1) | 2024.11.25 |