[우테코 7기] 모바일 프리코스 4주차 회고록

[4주차] 편의점

 

🚀 할인 혜택과 재고를 고려한 최종 결제 금액을 계산하고 안내하는 편의점 결제 시스템 구현

 

✒️ 기능 요구 사항

  • 사용자가 입력한 상품의 가격과 수량을 기반으로 최종 결제 금액을 계산한다.
    • 총구매액은 상품별 가격과 수량을 곱하여 계산하며, 프로모션 및 멤버십 할인 정책을 반영하여 최종 결제 금액을 산출한다.
  • 구매 내역과 산출한 금액 정보를 영수증으로 출력한다.
  • 영수증 출력 후 추가 구매를 진행할지 또는 종료할지를 선택할 수 있다.
  • 사용자가 잘못된 값을 입력할 경우 IllegalArgumentException를 발생시키고, "[ERROR]"로 시작하는 에러 메시지를 출력 후 그 부분부터 입력을 다시 받는다.
    • Exception이 아닌 IllegalArgumentException, IllegalStateException 등과 같은 명확한 유형을 처리한다.

재고 관리

  • 각 상품의 재고 수량을 고려하여 결제 가능 여부를 확인한다.
  • 고객이 상품을 구매할 때마다, 결제된 수량만큼 해당 상품의 재고에서 차감하여 수량을 관리한다.
  • 재고를 차감함으로써 시스템은 최신 재고 상태를 유지하며, 다음 고객이 구매할 때 정확한 재고 정보를 제공한다.

프로모션 할인

  • 오늘 날짜가 프로모션 기간 내에 포함된 경우에만 할인을 적용한다.
  • 프로모션은 N개 구매 시 1개 무료 증정(Buy N Get 1 Free)의 형태로 진행된다.
  • 1+1 또는 2+1 프로모션이 각각 지정된 상품에 적용되며, 동일 상품에 여러 프로모션이 적용되지 않는다.
  • 프로모션 혜택은 프로모션 재고 내에서만 적용할 수 있다.
  • 프로모션 기간 중이라면 프로모션 재고를 우선적으로 차감하며, 프로모션 재고가 부족할 경우에는 일반 재고를 사용한다.
  • 프로모션 적용이 가능한 상품에 대해 고객이 해당 수량보다 적게 가져온 경우, 필요한 수량을 추가로 가져오면 혜택을 받을 수 있음을 안내한다.
  • 프로모션 재고가 부족하여 일부 수량을 프로모션 혜택 없이 결제해야 하는 경우, 일부 수량에 대해 정가로 결제하게 됨을 안내한다.

멤버십 할인

  • 멤버십 회원은 프로모션 미적용 금액의 30%를 할인받는다.
  • 프로모션 적용 후 남은 금액에 대해 멤버십 할인을 적용한다.
  • 멤버십 할인의 최대 한도는 8,000원이다.

영수증 출력

  • 영수증은 고객의 구매 내역과 할인을 요약하여 출력한다.
  • 영수증 항목은 아래와 같다.
    • 구매 상품 내역: 구매한 상품명, 수량, 가격
    • 증정 상품 내역: 프로모션에 따라 무료로 제공된 증정 상품의 목록
    • 금액 정보
      • 총구매액: 구매한 상품의 총 수량과 총 금액
      • 행사할인: 프로모션에 의해 할인된 금액
      • 멤버십할인: 멤버십에 의해 추가로 할인된 금액
      • 내실돈: 최종 결제 금액
  • 영수증의 구성 요소를 보기 좋게 정렬하여 고객이 쉽게 금액과 수량을 확인할 수 있게 한다.

4주차 과제 구현 리포지토리

 

GitHub - medAndro/kotlin-convenience-store-7-medAndro: 편의점 미션 저장소

편의점 미션 저장소. Contribute to medAndro/kotlin-convenience-store-7-medAndro development by creating an account on GitHub.

github.com

 

 

3주차 공통 피드백

단일책임원칙(15라인), 예외상황 고려, 비즈니스와 UI로직 분리, enum사용, 불변객체 val사용, private 접근 제한, 예외 케이스 테스트, 테스트코드와 구현 코드와의 분리, 단위 테스트나 private등 테스트하기 어려운 코드의 분리 등 여러 객체지향적인 관점에서의 코딩법을 제시한다
4주차 과제에서 되돌아보면 지난 피드백들을 가급적이면 지키려고 노력했지만 한정된 시간에 빠르게 작업하다보니 모델 객체에 비즈니스 로직을 포함시켜 객체를 객체답게 사용하는 "Tell, Don't Ask" 원칙을 지켰어야 하는데 그렇지 못한 부분이 있어서 약간의 아쉬움을 남긴것 같다, 필드(인스턴스 변수)의 수를 줄이기 위해 노력하라는 피드백도 있었는데 로직상 빼기가 어렵고 꼭 넘겨줘야 돌아갈것 같은 변수들을 어떻게하면 줄일지 고심하는게 쉽지많은 않았던 것 같다. StoreService같은 경우는 프로모션의 몇개 구매시 몇개 증정과 같은 데이터의 buy와 get을 계속 넘겨받으며 작업해서 특히 더 길어졌던것 같다...

 

3주차 코드리뷰 지적받은 부분

interface를 주입하는게 어떨것 같냐고 했는데 4주차에 적용해볼까 하지만 시간상 생략하였다... 현재 프로젝트에서는 하나의 인터페이스에 하나만 구현될게 뻔하니 굳이 복잡성을 늘릴 필요가 없을것 같기도 하고...
그리고 input.isNotBlank() && input.isNotEmpty()와 같은 검사는 isNotBlank만 검사해도 충분하다는걸 알았다 불필요한 중복된 검사를 최소화하는게 중요할듯 싶다.

 

3주차 피드백은 다들 잘했다고 해서 사실상 없었다고 봐도...

 

4주차 편의점 과제를 구현하며 느낀점

지난 기수의 크리스마스 프로모션 의 재탕이나 그정도겠거니 예상을 하고 각오는 했었지만... 처음 미션을 보고 3주차 과제에 비해 요구사항이 길어지긴 했어도 적당히 어려운 정도라고 생각했는데 경기도 오산이었고 요구사항 분석을 하면서 고민해야 할 부분이 생각보다 아주 많았다고 느껴졌다. 행사할인 금액과 멤버십 할인의 적용과 그것을 묻는 안내 메시지가 어느 경우에 출력되야 정상일지 판독하는게 참 힘들었다...  프로모션 할인이 적용되지 않는 수량, 영수증의 각 금액의 의미, 요구사항에 드러나지 않는 숨겨진 내용을 파악하는게 여간 아리송했다. 

 

예를 들어 프로모션 할인 적용 금액과 영수증의 멤버십할인 표시금액이 서로 다르다는것을 눈치채야 했고 모든 케이스에서 멤버십 적용 여부를 물어보는것 같다. (멤버십 적용할게 없으면 안물어봐야 맞지 않나?)

 

아래의 예제에서 "프로모션 할인이 적용된것 은 6개"이며 2+1 증정품이라고 생각하기보다 2+1을 하나의 묶음 전체에 프로모션이 걸리면서 한개의 금액만큼 깎아주는 프로모션을 받는것 정도로 이해하면 편하다.

- 콜라 1,000원 7개 탄산2+1
- 콜라 1,000원 10개

구매하실 상품명과 수량을 입력해 주세요. (예: [사이다-2],[감자칩-1])

[콜라-10]

현재 콜라 4개는 프로모션 할인이 적용되지 않습니다. 그래도 구매하시겠습니까? (Y/N)

N

 

위 예시에서 미적용 질문에 N이라 답하면 "정가로 결제해야하는 수량만큼 제외한 후 결제를 진행한다." 는 조건 때문에 3개만 결제되도록 설계되어야 한다

 

그리고 사용자에게 입력을 받는건
증정품 프로모션 받기 여부 (선택 질문) -> 프로모션 미적용 알림(선택 질문) -> 멤버십 알림 (필수 질문)
순서로 이루어져야 한다.

구매하실 상품명과 수량을 입력해 주세요. (예: [사이다-2],[감자칩-1])
[콜라-2]

현재 콜라은(는) 1개를 무료로 더 받을 수 있습니다. 추가하시겠습니까? (Y/N)
N

현재 콜라 2개는 프로모션 할인이 적용되지 않습니다. 그래도 구매하시겠습니까? (Y/N)
N

멤버십 할인을 받으시겠습니까? (Y/N)
Y

 

위 상황과 같이 모든 질문을 거쳐야 하는 경우도 생긴다. (증정품을 받지 않았으니 프로모션 미적용이기 때문)

 

 

마지막으로 혹시나 누군가에게 도움이 될까 싶어 정리한 케이스들은 아래와 같다.

2+1 행사상품을 4개 구입하는 케이스

  • 전체 구매시 영수증의  수량과 구매액 : 4개, 4000원
  • 프로모션 할인 적용 수량과 금액(영수증 미표시) : 2+1개, 3000원
    • "일부 정가로 결제된다는 안내메세지"의 N을 선택한 경우의 총구매액이기도 하다.
  • 영수증의 행사할인  수량과 금액: +1개, 1000원
  • 영수증의  멤버십할인 금액 : (총구매액- 프로모션 할인 적용 수량) * 30% = 300원

 

기타 예제 케이스 모음

  • 2+1 프로모션 재고가 3개 남았고 구매자가 3개를 요청한 경우, 바로 계산
  • 2+1 프로모션 재고가 3개 남았고 구매자가 1개를 요청한 경우, 1개 프로모션 미적용 안내
  • 2+1 프로모션 재고가 3개 남았고 구매자가 2개를 요청한 경우, 1개 무료 증정 제공 안내
    • 증정품 제공을 받지 않으면 2개 프로모션 미적용 안내
  • 2+3 프로모션 재고가 5개 일반 재고가 0개 남았고 구매자가 3개를 요청한 경우 2개 무료 증정 제공 안내
    • 증정품 제공을 받지 않으면 3개 프로모션 미적용 안내
  • 2+1 프로모션 재고가 2개 남았고 구매자가 2개를 요청한 경우, 2개 프로모션 미적용 안내
  • 2+1 프로모션 재고가 3개 일반 재고가 3개 남았고 구매자가 4개를 요청한 경우, 1개 프로모션 미적용 안내
  • 2+1 프로모션 재고가 2개 일반 재고가 3개 남았고 구매자가 4개를 요청한 경우, 4개 프로모션 미적용 안내

 

크리스마스 프로모션과 같은 이전기수의 프리코스 문제들도 나중에 풀어봐야겠다.

 

 

날아다니는 스파게티 괴물...

 

메서드당 10줄 이내로 구현하라는 요구사항이 있었지만 날밤을 꼬박 새도 일주일 안에 도저히 못 끝낼것 같아서....

 

이번 과제에서 가장 핵심이 되는 부분인
"상품명과 수량을 입력받아 넘기면 프로모션을 진행하지 않는 상품일 경우 바로 DB역할을 하는 리포지토리에 넘겨주고 프로모션을 진행중인 상품이면 프로모션 수량과 프로모션이 아닌 수량을 계산하는데, 프로모션이 적용되지 않은 상품의 구매수량이 발견되면 사용자에게에게 물어봐서 구매를 할지 말지 결정하고, 최종적으로 확정된 프로모션 수량과 일반 판매의 수량을 리포지토리에 넘겨준다. 리포지토리에서 계산된 전체 재고, 그 중의 증정품 재고의 수량과 프로모션 할인 적용 금액을 영수증 객체에 기록하는 작업을 하는 코드"를 일단 아래와같이 구현하였다

더보기
    fun writeReceipt(buyProductName: String, buyQuantityOrigin: Int) {
        val product = productRepo.getProducts()[buyProductName]!!
        val promotionName = product.getPromotionName()
        var buyQuantity = buyQuantityOrigin

        val buy = productRepo.getBuyByPromoName(promotionName)
        val get = productRepo.getGetByPromoName(promotionName)
        if (buy != null && get != null) {
            val promoUnit = buy + get
            var remain = buyQuantity % promoUnit

            val isCanGetBonusbak = remain == buy && ((buyQuantity + get) <= product.getPromoQuantity())
            val isCanGetBonus = remain >= buy && ((buyQuantity + get) <= product.getPromoQuantity())
            val getFreeAmount = get - (remain-buy)
            if (isCanGetBonus) {
                val addPromoInfoMessage = Messages.INPUT_ADD_PROMOTION.ynMessage(buyProductName, getFreeAmount)
                // 증정품 추가여부 입력 상자 호출
                if(inputView.readValidYN(addPromoInfoMessage)){
                    buyQuantity += getFreeAmount
                    remain = buyQuantity % promoUnit
                }
            }

            var cantBuyPromoUnitAmount = (buyQuantity / promoUnit) - (product.getPromoQuantity() / promoUnit)
            if (cantBuyPromoUnitAmount < 0) {
                cantBuyPromoUnitAmount = 0
            }
            val canNotPromoAmount = remain+(cantBuyPromoUnitAmount*promoUnit)
            var bonusAmount = get * ((buyQuantity-canNotPromoAmount) / promoUnit)

            if (canNotPromoAmount>0) {
                val infoMessage = Messages.INPUT_NOT_DISCOUNT.ynMessage(buyProductName, canNotPromoAmount)

                // 프로모션 할인 증정이 적용되지 않아도 구매 Y/N 입력폼
                if (inputView.readValidYN(infoMessage)) {
//                    productRepo.addReceipt(product, buyQuantity, get * (buyQuantity / promoUnit), get, buy)
//                    return
                } else {
                    buyQuantity -= canNotPromoAmount
//                    productRepo.addReceipt(product, buyQuantity , bonusAmount, get, buy)
//                    return
                }
            }

            productRepo.addReceipt(product, buyQuantity, bonusAmount, get, buy)

        }else{
            productRepo.addReceipt(product, buyQuantity, 0, get,buy)
        }
    }

이런 스파게티 코드가 탄생하였다. 그래도 돌아가는 쓰레기라도 만들어서 다행이려나 싶었고.

기간내 과제를 못끝내는거 아닌가 식은땀 흘리며 코딩했는데 한숨 돌리긴 했다

 

최종적으로 메서드 분리를 통해 수정을 마친 코드가 아래와 같다.

더보기
fun appendReceiptByProductName(buyProductName: String, buyQuantityOrigin: Int) {
    val product = productRepo.getProducts()[buyProductName]
    if (product != null) processReceipt(product, buyQuantityOrigin)
}

private fun processReceipt(product: Product, buyQuantityOrigin: Int) {
    val promotionInfo = productRepo.getBuyGetPairByPromoName(product.getPromotionName())
    if (promotionInfo.first == null) {
        productRepo.addReceipt(product, buyQuantityOrigin, 0, null, null)
        return
    }
    processPromotionalProduct(product, buyQuantityOrigin, promotionInfo)
}

private fun processPromotionalProduct(product: Product, buyQuantityOrigin: Int, promotionInfo: Pair<Int?, Int?>) {
    val (buy, get) = promotionInfo
    requireNotNull(buy)
    requireNotNull(get)

    val adjustedQuantity = calculatePromotionalQuantity(product, buyQuantityOrigin, buy, get)
    val (finalQuantity, bonusAmount) = calculateFinalAmounts(product, adjustedQuantity, buy, get)

    productRepo.addReceipt(product, finalQuantity, bonusAmount, get, buy)
}

private fun calculatePromotionalQuantity(product: Product, buyQuantity: Int, buy: Int, get: Int): Int {
    val promoUnit = buy + get
    val remain = buyQuantity % promoUnit
    if (remain >= buy && ((buyQuantity + get) <= product.getPromoQuantity())) {
        val freeAmount = get - (remain - buy)
        if (inputView.readValidYN(Messages.INPUT_ADD_PROMOTION.ynMessage(product.getName(), freeAmount))) {
            return buyQuantity + freeAmount
        }
    }
    return buyQuantity
}

private fun calculateFinalAmounts(product: Product, buyQuantity: Int, buy: Int, get: Int): Pair<Int, Int> {
    val nonPromotionalAmount = calculateNonPromotionalAmount(product, buyQuantity, buy, get)
    val finalQuantity = adjustQuantityByUserInput(product, buyQuantity, nonPromotionalAmount)
    return calculateBonusAmount(buyQuantity, finalQuantity, nonPromotionalAmount, buy, get)
}

private fun calculateNonPromotionalAmount(product: Product, buyQuantity: Int, buy: Int, get: Int): Int {
    val promoUnit = buy + get
    val remain = buyQuantity % promoUnit
    val nonPromotionalUnits = ((buyQuantity / promoUnit) - (product.getPromoQuantity() / promoUnit))
        .coerceAtLeast(0)
    return remain + (nonPromotionalUnits * promoUnit)
}

private fun adjustQuantityByUserInput(product: Product, buyQuantity: Int, nonPromotionalAmount: Int): Int {
    if (nonPromotionalAmount == 0) return buyQuantity
    val message = Messages.INPUT_NOT_DISCOUNT.ynMessage(product.getName(), nonPromotionalAmount)
    if (inputView.readValidYN(message)) return buyQuantity
    return buyQuantity - nonPromotionalAmount
}

private fun calculateBonusAmount(
    buyQuantity: Int, finalQuantity: Int, nonPromotionalAmount: Int, buy: Int, get: Int
): Pair<Int, Int> {
    val promoUnit = buy + get
    val bonusAmount = get * ((buyQuantity - nonPromotionalAmount) / promoUnit)

    return Pair(finalQuantity, bonusAmount)
}​

 

인자가 너무 많은것 같은 느낌이긴 하지만 일단 이것으로 돌아가긴 하니까 만족하기로 했다...

 

 

이번 과제에서 아쉬웠던 점

내가 원한건 이 코드가 아니야...

메서드 인자를 너무 많이 받은점, 그리고 객체를 객체답게 사용해야 하는데 ProductRepository를 간이 DBMS처럼 이용하다보니 너무 많은 책임을 줘버린것 같다. 

 

위에 스파게티 코드에서 언급한 StoreService의 코드들 다시 봐도 맘에 안든다... 내가 원한건 이런 코드가 아닌데 내 능력과 한정된 시간 속에서 이게 최선인것 같다. (더 고치면 버그만 일어날것 같다...)

 

분명 발견 못한 버그가 있을거라는 불안감...
3주차처럼 제출하고나서 개운하지 않다.

 

이번 과제를 통해 알게된 코틀린

  • takeUnless 확장함수
    • 중괄호 안의 조건을 만족하지 않는 경우 자기 자신을 반환하하고 만족하면 null을 반환하는 확장함수이며 아래와 같은 상황에서 사용하였다. (완전히 반대인 takeIf 확장함수도 있다.)
    private fun createPromotionalProduct(
        name: String, price: String, quantity: String, promotion: String
    ): Product = Product(
        name = name,
        price = price.toInt(),
        promoQuantity = calculatePromoQuantity(promotion, quantity),
        quantity = calculateNormalQuantity(promotion, quantity),
        promotion = promotion.takeUnless { it == "null" }
    )

프로모션 데이터가 "null"문자열로 저장되어 있는경우 조건을 만족하여 null을 반환하고 만족하지 않는 경우 자기 자신을 반환하게 된다.

  • associate()
    • 리스트를 Map형태로 변형할 수 있다. groupBy()와는 다르게 key가 중복일 경우 마지막 요소를 value로 저장한다.
    fun getAllProductQuantity(): Map<String, Int> {
        return products.values.associate { product ->
            product.getName() to product.getQuantity() + product.getPromoQuantity()
        }
    }

상품 객체들이 저장된 products 리스트를 LinkedHashMap 자료형으로 변환하여 매번 리스트를 순회하여 상품을 찾지 않고 이름을 Key값으로 자료를 빨리 찾아올 수 있도록 구현하는 과정에서 사용하였다.

  • startsWith()과 endsWith()
    • 특정 문자열로 시작하거나 끝나는지 확인하는 String의 확장함수로서 상품명을 입력받을때 대괄호 [, ]로 묶여있는지 검증할때 사용하였다
  • buildString { append(String1) append(String2) ... }
    • 자바의 StringBuilder의 추상화된 메소드로서 append를 통해 여러 문자열을 합쳐준다. 문자열을 합칠때 기존 객체를 사용하여 메모리 부담이 적다. 였다. 

 

과제 제출

비공개 리포지토리에 우테코 계정을 협업자로 추가한뒤 해당 리포지토리를 공유하는 방식으로 진행되었다.

 

디스코드를 보니 로컬에선 되는데 3/4에서 테스트가 안넘어갔다는분들이 많이 보였지만 나는 문제없이 한번에 넘어갔다...  마지막 주차까지 한방에 다 돌아가서 다행이다... (실제 채점은 얼마나 틀렸을지 미지수지만 말이지)

 

혹시라도 3/4 지옥에 삐지신 분이 이 포스트를 보신다면 위로를 남깁니다...
도움이 되는 글을 쓰고싶어도 한방에 4/4떠서 저는 원인을 모르겠어요 ㅠㅠ

 

프리코스를 마치며

처음 코딩을 접했던 이래로 대학교3+2년 군대2년 포함해서 총 7년이 넘어가는 시간동안 코딩을 했지만 정작 객체지향과 깃허브를 이용한 협업과 관련해서는 많이 무지했던것 같다는걸 프리코스를 통해 실감하게 해주었던 것 같다.
그리고 프리코스 덕분에 단기간에 생각보다 많은 성장을 이룰 수 있었던 것 같다. 학교에서 배운 자바가 가물가물한 상태로  프리코스를 지원하며 코틀린을 속성으로 시작했고 마지막 프리코스까지 머리를 싸매며 완주에 성공한데다(버그는 있을것 같지만...) MVC와 클린코딩 그리고 주석보다 코드를 이용하여 의도를 나타내는 습관을 강제로 들이게 됐다. (이전에는 yy xx 이렇게 변수를 대충 지었는데 말이지...)

 

혹시나 자바는 배웠고 코틀린은모르지만 우테코 모바일에 한번 지원해볼까? 라는 사람이 있다면...
4주차를 마친 이 시점에서 내가 하고싶은 조언은 일단 지원해보는것을 추천한다. (특히 평소에 코드를 더럽게, 객체지향을 절차지향처럼 짜고 있었다면 더욱 추천한다. 내가 그랬거든...)  프리코스를 미션을 달성하기 위해 코틀린을 자연스럽게 공부하게 되면서 이제는 코틀린 코드를 대충은 읽어볼 수준은으로 익숙해지기도 했다 (물론 잘 모르는부분이 수도없이 튀어나와서 계속 찾아봐야겠지만.)

 

프리코스 커뮤니티에서 서로 코드 리뷰를 하며 나의 부족한 부분을 깨닫고 남의 코드를 리뷰해보면서 알게 모르게 얻어가는것 또한 많았던다고 느끼는지라 설령 우테코에 떨어진다고 해도 4주간 밤을 지새워가며 몰입하며 불태웠던 그 시간은 보상 받을 수 있는 날이 오리라...

 

1+1, 2+1 멤버십 10% 할인 보면 앞으로 PTSD 올듯...