티스토리 뷰
[프론트엔드 7기] 우아한프리코스 - 전략패턴(Strategy Pattern)을 이용한 우아한 편의점
밍동망동 2024. 11. 17. 02:50본 포스팅은 우아한테크코스 7기 프론트엔드 전형 지원에 대한 회고록입니다.
4주차 과제는 편의점 결제 시스템을 구현하는 것이었습니다. 구매자의 할인 혜택과 재고 상황을 고려하여 최종 결제 금액을 계산하고 안내하는 결제 시스템을 만들어야 했습니다. 이번 과제에서는 함수 10줄 제한과 입출력을 별도로 구현하라는 요구사항이 추가되었으며, 재고 관리와 할인 로직을 처리하는 것이 핵심이었습니다.
이 과제는 앞선 문제들보다 난이도가 훨씬 높아, 진행하는 동안 큰 어려움을 느꼈습니다.
특히, 프로모션 재고와 일반 재고를 나누고, 재고를 실제로 차감하는 로직을 구현하는 과정에서 많은 시간을 소요했습니다. 재고 처리 로직은 과제 해결의 키포인트였으며 재고 처리 과정에서 발생하는 비즈니스 로직의 중복을 관리하는 것이 중요한 과제였습니다.
비즈니스 로직의 중복 문제를 해결하기 위한 디자인 패턴:: 문제를 해결하기 위한 템플릿
3주차에서 세운 서브 목표는 비슷한 패턴의 비즈니스 로직을 효율적으로 처리할 수 있는 디자인 패턴 적용하는 것이었습니다. 편의점 과제는 동일 품목에 대해 프로모션 재고와 일반 재고를 모두 보유한 경우, 프로모션 재고만 있는 경우, 일반 재고만 있는 경우로 나뉘기 때문에 이 목표는 적절하게 설정된 것이었습니다.
이 문제를 해결하기 위해 세 가지 디자인 패턴을 검토했습니다.
- 전략 패턴(Strategy Pattern)
- 상태 패턴(State Pattern)
- 템플릿 메서드 패턴(Template Method Pattern)
전략 패턴은 특정 행위를 별도의 클래스(전략)로 분리하고, 상황에 따라 적절한 전략을 선택해 동적으로 실행하는 패턴입니다. 이를 통해 비슷한 로직을 캡슐화하여 중복을 줄이고 확장성을 높일 수 있다는 장점이 있습니다.
반면, 상태 패턴은 객체의 상태에 따라 다른 동작을 실행하도록 설계하는 패턴입니다. 객체가 여러 상태를 가질 때 상태별로 서로 다른 로직을 구현해 상태 전환을 명확히 관리하는 데 도움 받을 수 있습니다.
예를 들어 주문 상태(접수, 배송 중, 배송 완료)에 따라 다른 행동을 실행하는 경우 적합합니다. 하지만 편의점은 상태 변화가 중심이 아니며, 다양한 정책에 따른 로직 처리가 주된 요구사항이라는 점이 중요했습니다.
템플릿 메서드 패턴은 기본 로직을 부모 클래스에 정의하고, 세부 구현만 자식 클래스에서 오버라이드하는 방식으로 작동하지만, 이번 과제는 정책마다 공통 로직이 많지 않아 적합하지 않다고 판단했습니다. 최종적으로 확장성과 구현의 용이성 때문에 전략 패턴을 선택했습니다.
전략패턴 적용 이유 📣
- 할인 로직이 변경될 가능성이 높기 때문에 확장이 용이한 구조가 필요했다. 비즈니스 로직 확장에 유리
- 상대적으로 구현이 간단하고, 초보자의 관점에서 이해와 적용이 용이했다. 이해와 구현의 용이성
- 각 프로모션 정책이 독립적으로 관리되기 때문에 클래스의 책임이 명확해진다. 단일 책임 원칙(SRP) 준수
전략패턴, 어떻게 적용하는데?
전략 패턴을 적용하여 Promotion 클래스는 프로모션 종류에 따라 적절한 전략을 동적으로 선택하고 실행하도록 설계되었습니다.
constructor({ name = '', type = null, quantity = 0, start_date, end_date }) {
this.#name = name;
this.#quantity = quantity;
this.#dateRange = new DateRange(start_date, end_date);
this.#strategy = PromotionStrategy.from(type || PromotionType.NONE);
}
예를 들어, PromotionStratege.from(type)메서드는 주어진 프로모션 타입에 따라 적절한 전략 객체를 반환합니다. 이 전략은 getAvailableQuantity 메서드에서 실행되며 재고 차감과 할인 정책 적용 로직을 캡슐화하여 코드 중복을 줄였습니다.
getAvailableQuantity(requestedQuantity) {
this.#validateRequestedQuantity(requestedQuantity);
if (this.#dateRange.isExpired()) {
return { quantity: 0, violation: null };
}
return this.#strategy.execute(this.#quantity, requestedQuantity);
}
선택된 전략의 execute 메서드를 호출하여 요청된 수량에 대한 처리 결과를 반환합니다. 구체적인 프로모션 전략은 각자의 클래스에서 독립적으로 구현합니다.
import { ERROR_MESSAGES, PromotionViolation } from '../../utils/constants.js';
class NBuyGetOnePromotion {
#Unit;
// ...코드 생략
execute(promotionStock, requestedQuantity) {
const expectedQuantity =
Math.floor(requestedQuantity / this.#Unit) * this.#Unit;
if (expectedQuantity > promotionStock) {
const availableQuantity =
Math.floor(promotionStock / this.#Unit) * this.#Unit;
return {
quantity: availableQuantity,
violation: PromotionViolation.OUT_OF_STOCK,
freebieCount: this.#getFreebieCount(availableQuantity),
};
}
if (this.#shouldNotGetOneMore(requestedQuantity)) {
return {
quantity: expectedQuantity,
violation: null,
freebieCount: this.#getFreebieCount(expectedQuantity),
};
}
if (promotionStock >= requestedQuantity + 1) {
return {
quantity: expectedQuantity,
violation: PromotionViolation.ONE_MORE,
freebieCount: this.#getFreebieCount(expectedQuantity),
};
}
return {
quantity: expectedQuantity,
violation: PromotionViolation.OUT_OF_STOCK,
freebieCount: this.#getFreebieCount(expectedQuantity),
};
}
// ...코드 생략
}
export default NBuyGetOnePromotion;
class NoneStrategy {
// ...코드 생략
execute() {
return { quantity: 0, violation: null, freebieCount: 0 };
}
}
export default new NoneStrategy();
정책이 추가될 경우 기존 코드를 수정하지 않고 새로운 전략 클래스를 추가하기만 하면 되므로 뛰어난 구조를 구현할 수 있었습니다.
과제의 어려움
재고가 없는 경우
class Shelves {
// ...코드 생략
toString() {
return this.#inventory
.map((goods) => this.#convertToString(goods))
.flat()
.join('\n');
}
#convertToString({ summary }) {
const price = summary.price.toLocaleString();
if (summary.promotion.name) {
return this.#formatPromotionalGoods(summary, price);
}
return [
`- ${summary.name} ${price}원 ${this.#formatQuantity(summary.quantity)}`,
];
}
// ...코드 생략
#formatQuantity(quantity) {
if (quantity) {
return `${quantity}${RECEIPT.UNIT}`;
}
return RECEIPT.STOCK_UNAVAILABLE;
}
// ...코드 생략
}
이 밖에도 몇 가지 여러움이 있었습니다. 특히, 재고가 없는 경우 재고 없음이라는 문구를 출력해야 했는데 이 로직을 리팩토링하는 과정에서 시간이 걸렸습니다.
#formatQuantity(quantity) {
return quantity ? `${quantity}개` : '재고 없음';
}
처음에는 삼항 연산자를 사용하여 간결하게 구현했지만, 과제에서 삼항 연산자 사용이 금지되어 리팩토링 과정에서 if문으로 수정했습니다.
if (quantity) {
return `${quantity}${RECEIPT.UNIT}`;
}
return RECEIPT.STOCK_UNAVAILABLE;
}
단순 조건에서는 삼항 연산자가 더 가독성이 좋다고 생각했기 때문에 이 과정에서 다소 아쉬움이 남았습니다. 조금 더 가독성을 높일 방법이 있지 않을까하고 생각해봤지만, 이 부분이 최선이었습니다.
함수 10줄 제한을 향하는 자세
또한, 함수 10줄 제한을 준수하기 위해 단일 책임 원칙(SRP)에 따라 큰 볼륨의 로직을 작은 프라이빗 메서드로 분리했습니다. 프리티어를 사용하고 있다보니 가독성을 위한 줄바꿈은 계산하지 않고 10줄을 카운팅했습니다.
혹시라도 익스텐션에 의한 줄바꿈도 카운팅된다면...😨
데이터를 초기화하는 로직, 재고를 검증하는 로직, 재고를 업데이트하는 로직을 각각 독립적인 메서드로 나누며 리팩토링했습니다.
#initializeData(data) {
const { name = '', price = 0, quantity = 0 } = data;
this.#name = name;
this.#price = price;
this.#quantity = quantity;
}
이 과정에서 세부 메서드의 이름을 정의하는 일이 어려웠지만, 메서드 이름에 비즈니스 로직의 의도를 담으려 노력했습니다. 앞으로는 자주 사용하는 메서드 이름을 정리한 파일을 만들어 관리할 계획입니다.
입출력 클래스, 왜 별도 구현해야하는 건데?🫠
이번 과제에서 입출력 로직을 별도의 클래스로 분리하라는 요구사항이 추가되었습니다. 처음 이 요구사항을 접했을 때, 저는 의문에 빠졌습니다. 이 요구사항은 무엇을 의미하고 싶었던 걸까요?
Baeldung의 육각형 아키텍처 관련 글에서는 이렇게 설명합니다.
In the domain layer, we keep the code that touches and implements business logic
. This is the core of our application. This layer should be isolated from both the application part and infrastructure part. In addition, it should also contain interfaces that define the API to communicate with external parts, like the database, which the domain interacts with.
도메인 계층은 비즈니스 로직을 구현하는 코드만을 포함해야 하며, 애플리케이션 계측이나 인프라 계층과 독립적으로 유지되어야 합니다. 도메인 계층은 외부와의 상호작용(ex, DB)도 정의된 인터페이스를 통해 처리해야 합니다.
https://www.baeldung.com/hexagonal-architecture-ddd-spring
입출력을 분리해야하는 이유는 바로 의존성을 최소화하기 위함입니다. 도메인 계층은 비즈니스 로직에만 집중해야 하며, 외부와의 통신을 위한 인터페이스 또한 별도로 관리해야 합니다. 만약 도메인 클래스가 입출력 로직까지 포함한다면, 그 클래스는 지나치게 많은 책임을 가지게 되어 단일 책임 원칙(SRP)을 위반하게 됩니다.
또한, 입출력 로직을 분리하지 않으면 테스트가 어려워집니다. 사용자 입력과 같은 외부 상호작용을 테스트하려면 모킹(Mock)을 활용해야 하는데, 이 과정은 불필요한 복잡성을 더할 수 있습니다. 따라서 입출력 로직을 분리하면 도메인과 서비스 로직의 순수성을 유지하고, 변경이 발생해도 쉽게 유지보수할 수 있는 구조를 갖출 수 있습니다.
이 관점에서 다시 생각해보니, 모든 지시문을 어디까지 분리해야 할지 고민하던 불필요한 걱정이 줄어들었습니다👍👍
단순히 지시문에 맞추기만을 목표로 삼았을 때는 정답에 억지로 끼워 맞추려 전전긍긍했지만, 요구사항의 의도를 파악하니 그런 조급함이 사라졌습니다😌
최종 코딩 테스트에서는 이런 당혹스러움을 피하기 위해, 지시문의 의도를 정확히 분석하는 습관을 기르고자 합니다. 결국 문제 해결의 핵심은 단순히 정답을 맞추는 것이 아니라, 지시문에 담긴 메시지를 읽어내는 능력이라는 점을 깨달았습니다.
다음에는···.
최종 코딩테스트를 준비하며 정리본을 만들 계획입니다. 생성형 AI는 사용할 수 없지만, 검색과 템플릿 활용은 허용되므로 자주 사용되는 메서드 이름이나 정규표현식을 템플릿으로 정리해 시간을 절약할 생각입니다.
시험장에서 차분하게 문제만 풀 수 있도록 준비를 해나갈 생각입니다. 이번 과제는 어려웠지만, 디자인 패턴 적용과 로직 리팩토링 과정에서 많은 배움을 얻을 수 있었습니다.
앞으로도 지식이라는 밭을 정성껏 가꾸며, 경험으로 물을 주어 성장하는 밭을 만들어나가고자 하겠습니다🔥🔥
GitHub - mindaaaa/javascript-convenience-store-7-mindaaaa: 우아한테크코스 7기 프리코스 4주차 과제인 <편의점>
우아한테크코스 7기 프리코스 4주차 과제인 <편의점> 프로젝트입니다. 이 프로젝트는 구매자의 할인 혜택과 재고 상황을 고려하여 최종 결제 금액을 계산하고 안내하는 과제를 다루며, 클래스
github.com
'Oops, All Code! > 🚀 Woowacourse' 카테고리의 다른 글
[프론트엔드 7기] 최종코딩테스트 준비하기, 루틴을 적용한 점심메뉴 (0) | 2024.12.06 |
---|---|
[프론트엔드 7기] 우아한프리코스 - 모듈설계(DDD)를 곁들인 로또 (0) | 2024.11.05 |
[프론트엔드 7기] 우아한프리코스 - 삼항연산자와 자동차 경주 회고 (0) | 2024.10.31 |
[프론트엔드 7기] 우아한테크코스 - 정규표현식과 문자열 계산기 회고 (1) | 2024.10.22 |
우아한테크코스 7기 프론트엔드 도전: 서류 접수와 개발새발 지원서 (2) | 2024.10.10 |
- Total
- Today
- Yesterday
- javascript
- 서평
- 타입좁히기
- 대학생플리마켓
- 카드뉴스
- 도서리뷰
- typescript
- 도서추천
- 경험플리마켓
- 대학생팝업스토어
- 책추천
- 비즈플리마켓
- 플리마켓후기
- 프론트엔드
- 어휘력
- 우아한테크코스
- 트러블슈팅
- 회고
- 안성스타필드
- 프로토타입
- 소사벌
- 카페추천
- 일급객체
- react
- 프리코스
- 어른의어휘공부
- 플리마켓운영
- 소사벌맛집
- 코딩테스트
- js
일 | 월 | 화 | 수 | 목 | 금 | 토 |
---|---|---|---|---|---|---|
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 | 31 |