Skip to content

Conversation

@kylekim2123
Copy link
Member

@kylekim2123 kylekim2123 commented Jul 14, 2023

할인권(Voucher) 관리 애플리케이션 2주차

1️⃣ 프로젝트 설명

할인권과 고객 정보를 관리(생성, 조회, 수정, 삭제)하는 콘솔 애플리케이션 만들기

  1. 할인권(Voucher) 메뉴

    • 메뉴 선택
      • 생성 : 새 고정 금액 할인권 또는 퍼센트 비율 할인권 생성
      • 조회 : 전체 할인권 목록 조회
      • 수정 : 할인권의 고정 금액 또는 퍼센트 비율 수치 수정
      • 삭제 : 특정 할인권 삭제
    • 속성
      • id : UUID 기반의 할인권 고유 번호
      • type : 할인 방식(고정 금액 or 퍼센트 비율)
      • discountAmount : 할인 금액 또는 비율
    • 방식
      • 고정 금액 할인(Fix Discount)
        • 물건의 가격을 특정 고정 금액만큼 할인하는 방식
        • 1이상의 자연수, 단위 원
      • 퍼센트 비율 할인(Percent Discount)
        • 물건의 가격의 일정 비율만큼 할인하는 방식
        • 1이상 100이하의 자연수, 단위 %
  2. 고객(Customer) 메뉴

    • 메뉴 선택
      • 생성 : 새 일반 고객 생성
      • 조회 : 전체 고객 목록 조회, 블랙리스트 고객 목록 조회
      • 수정 : 고객 타입 수정 (일반 <-> 블랙리스트)
      • 삭제 : 특정 고객 삭제
    • 속성
      • id : UUID 기반의 고객 고유 번호
      • type : 고객 타입(일반 or 블랙리스트)
      • nickname : 고객 닉네임(중복 x)
    • 타입
      • 일반 고객
      • 블랙리스트 고객

2️⃣ 프로젝트 구조도

image

  1. 웹 환경에서 Home, Voucher, Customer 페이지를 이용한다고 가정하고, 콘솔로 비슷하게 세 가지의 View 파일로 나누었습니다.
  2. 애플리케이션을 실행하면 CommandLineRunner를 구현한 HomeCommand의 run() 메서드가 실행됩니다.
  3. HomeCommand에서 할인권, 고객 메뉴를 선택하면 각각에 맞는 VoucherCommand, CustomerCommand가 실행됩니다.
  4. 각 Command에서 생성, 조회, 수정, 삭제 메뉴를 선택하면 각각에 맞는 기능이 실행됩니다.
  5. 기능 실행 후 데이터베이스에 내용이 반영되고, 결과를 사용자에게 보여줍니다.

3️⃣ 예외처리 사항

  1. 홈(Home)

    • 홈 메뉴 목록에 없는 메뉴를 선택한 경우 -> MenuInputException
  2. 할인권(Voucher)

    • 할인권 메뉴 목록에 없는 메뉴를 선택한 경우 -> MenuInputException
    • 할인권 생성 시, 할인권 방식 목록에 없는 방식을 선택한 경우 -> VoucherTypeInputException
    • 할인권 생성 또는 수정 시, 조건에 맞지 않는 금액 또는 비율을 입력한 경우 -> DiscountAmountException
    • 할인권 수정 또는 삭제 시, 존재하지 않는 할인권 ID를 입력한 경우 -> NotFoundVoucherException
  3. 고객(Customer)

    • 고객 메뉴 목록에 없는 메뉴를 선택한 경우 -> MenuInputException
    • 고객 생성 시, 조건에 맞지 않는 닉네임을 입력한 경우 -> CustomerInputException
    • 고객 생성 시, 이미 있는 닉네임을 입력한 경우 -> ExistedCustomerException
    • 고객 수정 시, 고객 타입 목록에 없는 타입을 선택한 경우 -> CustomerTypeInputException
    • 고객 수정 또는 삭제 시, 존재하지 않는 닉네임을 입력한 경우 -> NotFoundCustomerException

4️⃣ 기능 구현

이전 PR까지의 기능 구현

  • 메뉴 선택
  • 프로그램 종료
  • 고정 할인권 생성
  • 비율 할인권 생성
  • 전체 할인권 조회
  • logback 이용한 로그 파일 생성
  • 실행 가능한 jar 파일 생성
  • 할인권 수정
  • 할인권 삭제
  • H2 데이터베이스 적용
  • 프로필을 이용한 local, dev 환경 구분

현재 PR 기능 구현

  • 고객 정보 생성
  • 고객 정보 조회
    • 전체 고객 목록 조회
    • 상세 고객 정보 조회
    • 블랙리스트 고객 목록 조회
  • 고객 정보 수정
  • 고객 정보 삭제

이후 계획

  • 전반적인 테스트 코드 작성
  • 콘솔에서 타임리프를 이용한 html 뷰로 변경
  • REST API로 변경

- 실제 Home, Voucher, Customer 메뉴를 각각의 웹페이지라고 가정하고 View를 분리
- Home 메뉴는 HomeView에서 입출력 담당
- Voucher 메뉴는 VoucherView에서 입출력 담당
- Customer 메뉴는 CustomerView에서 입출력 담당
@kylekim2123 kylekim2123 marked this pull request as ready for review July 14, 2023 10:27
Copy link

@hanjo8813 hanjo8813 left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

영주님 수고하셨습니다~!

case UPDATE -> updateVoucher();
case DELETE -> deleteVoucher();
case QUIT -> quitApplication();
case HOME -> isRunning = false;

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

현재 VoucherCommand는 bean으로 등록되고 있어서 isRunning을 한번 변경하면 다시 변경이 불가능한 상태더라구요
(HOME으로 간 후 다시 바우처 메뉴로 진입이 불가능)
bean이 싱글톤으로 동작한다는걸 항상 기억해주세요~!

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

반영: f5fbd50

  • 각 메뉴의 run() 메서드가 실행될 때마다, isRunning을 true로 초기화 하도록 변경했습니다.
  • 미처 발견하지 못한 버그였는데, 감사합니다!

CUSTOMER("2", "고객 메뉴"),
QUIT("quit", "프로그램 종료");

private static final Map<String, HomeMenu> MENUS = Collections.unmodifiableMap(Stream.of(values())

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

참조형 static을 제대로 파보셨으니 screaming(upper) snake case 말고 그냥 camel case로 하는게 좋을것 같습니다~~

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

반영: f2a56a9

  • MENUS -> homeMenuMap 과 같은 형식으로 변경했습니다.
  • 홈 메뉴 뿐만 아니라, 전반적으로 모두 변경했습니다.

Comment on lines 34 to 42
private void validateIsAlphaNumeric(String nickname) {
if (isNotAlphaNumeric(nickname)) {
throw new CustomerInputException(nickname);
}
}

private boolean isNotAlphaNumeric(String nickname) {
return !nickname.matches(ALPHA_NUMERIC_REGEX);
}

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

두 메소드를 합쳐도 될 것 같아요~~

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

반영: f2a56a9

  • 불필요한 메서드 삭제 후 하나로 합쳤습니다.
  • Customer 클래스 뿐만 아니라 Voucher 클래스에 대해서도 동일하게 합쳤습니다.

private static final Map<String, CustomerType> CUSTOMER_TYPES = Collections.unmodifiableMap(Stream.of(values())
.collect(Collectors.toMap(CustomerType::getNumber, Function.identity())));

private final String number;

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

다른 메뉴들에서는 메뉴 숫자에 대한 변수명을 option으로 사용중이니 통일하는게 더 좋을것 같아요~

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

반영: f2a56a9

  • number -> option 으로 변수명 모두 변경했습니다.

Comment on lines 17 to 24
FIX(VoucherTypeInfo.builder()
.number("1")
.name("고정 할인")
.message("\n고정 할인 금액을 입력하세요. (1이상의 자연수, 단위: 원)")
.condition("1이상의 자연수")
.unit("원")
.voucherGenerator(FixDiscountVoucher::new)
.build()
),

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

이걸 전에 보지 못했는데 builder를 사용하신 이유가 있을까요?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

해당 부분은 멘토님의 직전 PR 리뷰에 대한 반영사항이었습니다.

  • VoucherType의 객체 생성 과정에서 생성자의 매개변수가 너무 많아, 이를 별도의 클래스인 VoucherTypeInfo로 만들어 하나로 묶었습니다.
  • VoucherTypeInfo를 생성할 때에도 생성자 매개변수가 4개 이상이므로, 여기에 빌더 패턴을 사용하는 것이 효과적이라 생각했습니다.

Comment on lines 5 to 12
public class CustomerInputException extends RuntimeException {

private static final String INVALID_CUSTOMER_INPUT_MESSAGE = "입력하신 닉네임이 조건(공백이 없는 소문자 알파벳과 숫자 조합)에 맞지 않습니다. 다시 입력해주세요.";

public CustomerInputException(String nickname) {
super(format("{0} | 현재 입력 : {1}", INVALID_CUSTOMER_INPUT_MESSAGE, nickname));
}
}

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

현재는 각 상황에 해당하는 exception을 새로 만들어주고 있는데..
사실상 이렇게 하면 클래스를 무한생성해야 합니다.

따라서 보통 예외 클래스를 특정 기준으로 상속해서 분리하고, 예외 상황에 대한 enum을 따로 관리하게 됩니다.

  • RuntimeException
    • BuisnessException
      • (요 아래부턴 일단 나중에..)
      • Customer~Exception
      • Voucher~Exception
public enum BusinessRule {
   INVALID_VALUE(400, "값 잘못됨~");

   private int httpStatusCode;   // 다음 과제가 web환경이니 넣었습니다
   private String message;

   (생략)
}
public class BusinessException extends RuntimeException {

   private String message;
   private int httpStatusCode;
   
   // 가변인자에 대해 알아보세요~!
   public BusinessException(BusinessRule rule, Object... args) {
       this.message = rule.getMessage();
       this.httpStatusCode = rule.getHttpStatusCode();

       // 메시지 포맷은 대충 썼습니다.
       String resultMessage = message + " | " + args.toString();
       super(resultMessage);
   }
}
public ... 무언가를_검증하는_메소드(int value1, int value2) {
   (검증중...)
   throw new BusinessException(BusinessRule.INVALID_VALUE, value1, value2);
}

이 패턴은 Spring mvc에서 @ControllerAdvice 사용시 자주 쓰는 패턴인데,
이번 리뷰에서 한번 훑어보시고 어떻게 쓰면 좋을지 생각해보시면 좋을것 같습니다!

적용은 web으로 전환하는 미션에서 해주셔도 괜찮습니다~

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

한마디 거들자면,
예외를 만든다든건 다르게 처리하기 위한 하나의 그룹을 묶는 단위로도 볼수 있는데요.
바퀴를 다시 만들지 말라 라는 이야기가 있듯이 너무 row level의 예외를 만들다 보면 재원멘토가 이야기 한대로 예외가 무한정 많아지죠.
적당히 비슷하게 사용될만한 예외를 만들어 사용하던지, 자바에서 제공하는 예외를 사용하는것도 방법이 될것 같습니다.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

반영: c765c70
image

  • 기존에 무수히 만들었던 커스텀 예외 클래스를 모두 삭제했습니다.
  • 대신, 최상위 예외 클래스로 BusinessException을 만들고, 각 메뉴에서 발생할 수 있는 예외들을 자손으로 두었습니다. (사진)
  • 그리고 각 예외 세부사항에 대해서는 일일히 클래스로 만들지 않고, BusinessRule이라는 Enum 클래스 내에 정의하여 관리하도록 변경했습니다.
  • 아직 Web을 적용하지 않아서 status code는 따로 두지 않고, message만 두었습니다.

Comment on lines 25 to 28
customerRepository.findCustomerByNickname(nickname)
.ifPresent(customer -> {
throw new ExistedCustomerException(nickname);
});

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

더블체크 좋습니다~~

Comment on lines +72 to +74
private boolean isEmptyDeleteResult(int deleteCounts) {
return deleteCounts == 0;
}

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

👍

String menuOption = customerView.readUserInput();
CustomerMenu selectedMenu = CustomerMenu.from(menuOption);
executeMenu(selectedMenu);
} catch (Exception e) {

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

예외는 가급적 하위 레벨의 예외부터 잡는게 좋을것 같아요. 그래야 "예상"한 처리가 될것 같아서요.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

반영: c765c70

  • 각 메뉴에서 Exception을 catch하던 기존 코드에서, 각 메뉴에서 발생할 수 있는 예외에 대해서만 catch하도록 변경했습니다.
    • VoucherCommand에서는 VoucherException에 대한 처리만 수행
    • CustomerCommand에서는 CustomerException에 대한 처리만 수행
    • HomeCommand에서는 HomeException에 대한 처리만 수행
  • 단, HomeCommand에서는 미처 제가 예상하지 못했던 예외가 발생할 여지가 있으므로, 추가적인 catch문을 통해 Exception에 대해서도 처리하도록 하였습니다.
    try {
        String menuOption = homeView.readUserInput();
        HomeMenu selectedMenu = HomeMenu.from(menuOption);
        executeMenu(selectedMenu);
    } catch (HomeException e) {
        log.error("홈 메뉴에서 예외 발생 - {} | '{}' | 사용자 입력 : {}", e.getRule(), e.getMessage(), e.getCauseInput(), e);
        homeView.showExceptionMessage(format("{0} | 현재 입력 : {1}", e.getMessage(), e.getCauseInput()));
    } catch (Exception e) {
        log.error("원인 불명의 예외 발생 : '{}'", e.getMessage(), e);
        homeView.showExceptionMessage("알 수 없는 에러가 발생하였습니다.");
        quitApplication();
    }

executeMenu(selectedMenu);
} catch (Exception e) {
String message = e.getMessage();
log.error(message);

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

습관적으로 예외를 로그로 남길땐 stack trace까지 남기는게 좋을것 같아요.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

반영: c765c70

  • 아래와 같이 스택 트레이스까지 남기도록 수정했습니다. 감사합니다!
08:16:36.366 [main] ERROR c.d.v.command.VoucherCommand -- 할인권 메뉴에서 예외 발생 - VOUCHER_DISCOUNT_AMOUNT_INVALID | '입력하신 수치가 해당 할인권 방식의 조건에 맞지 않습니다.' | 사용자 입력 : -1
com.devcourse.voucherapp.exception.VoucherException: 입력하신 수치가 해당 할인권 방식의 조건에 맞지 않습니다.
  at com.devcourse.voucherapp.entity.voucher.FixDiscountVoucher.getValidPrice(FixDiscountVoucher.java:25)
  at com.devcourse.voucherapp.entity.voucher.FixDiscountVoucher.<init>(FixDiscountVoucher.java:20)
  at com.devcourse.voucherapp.entity.voucher.VoucherType.makeVoucher(VoucherType.java:61)
  at com.devcourse.voucherapp.service.VoucherService.create(VoucherService.java:24)
  at com.devcourse.voucherapp.controller.VoucherController.create(VoucherController.java:18)
  at com.devcourse.voucherapp.command.VoucherCommand.createVoucher(VoucherCommand.java:62)
  at com.devcourse.voucherapp.command.VoucherCommand.executeMenu(VoucherCommand.java:47)
  at com.devcourse.voucherapp.command.VoucherCommand.run(VoucherCommand.java:36)
  at com.devcourse.voucherapp.command.HomeCommand.executeMenu(HomeCommand.java:45)
  at com.devcourse.voucherapp.command.HomeCommand.run(HomeCommand.java:31)
  at org.springframework.boot.SpringApplication.callRunner(SpringApplication.java:771)
  at org.springframework.boot.SpringApplication.callRunners(SpringApplication.java:755)
  at org.springframework.boot.SpringApplication.run(SpringApplication.java:319)
  at org.springframework.boot.SpringApplication.run(SpringApplication.java:1306)
  at org.springframework.boot.SpringApplication.run(SpringApplication.java:1295)
  at com.devcourse.voucherapp.VoucherappApplication.main(VoucherappApplication.java:10)

Comment on lines 5 to 12
public class CustomerInputException extends RuntimeException {

private static final String INVALID_CUSTOMER_INPUT_MESSAGE = "입력하신 닉네임이 조건(공백이 없는 소문자 알파벳과 숫자 조합)에 맞지 않습니다. 다시 입력해주세요.";

public CustomerInputException(String nickname) {
super(format("{0} | 현재 입력 : {1}", INVALID_CUSTOMER_INPUT_MESSAGE, nickname));
}
}

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

한마디 거들자면,
예외를 만든다든건 다르게 처리하기 위한 하나의 그룹을 묶는 단위로도 볼수 있는데요.
바퀴를 다시 만들지 말라 라는 이야기가 있듯이 너무 row level의 예외를 만들다 보면 재원멘토가 이야기 한대로 예외가 무한정 많아지죠.
적당히 비슷하게 사용될만한 예외를 만들어 사용하던지, 자바에서 제공하는 예외를 사용하는것도 방법이 될것 같습니다.

Comment on lines 8 to 9
public enum ExceptionRule {
MENU_INVALID("잘못된 메뉴를 선택하셨습니다."),

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

항상 한칸 띄워주기!

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

반영: be8f200

  • 디테일 감사합니다! 다른 Enum 클래스도 전부 적용했습니다. Google style guide가 Enum은 자동으로 안 띄어 주네요 ㅠ

@kylekim2123 kylekim2123 changed the title [4기 - 김영주] Customer CRUD 기능 추가 [4기 - 김영주] SpringBoot Part2 Customer CRUD 기능 추가 Jul 19, 2023
@kylekim2123 kylekim2123 merged commit 07cf86d into kylekim2123 Jul 22, 2023
@kylekim2123 kylekim2123 deleted the kylekim2123-w2-2 branch July 22, 2023 19:26
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants