🎯 목표 설정
사실 4주차가 시작된 화요일 오후 3시에 요구 사항을 보고 식은땀이 났다. 기존 과제와는 비교도 안 될 정도로 많은 요구 사항이 있었기 때문이다. 개발을 시작한 지 오래되지는 않았지만, 그간 학습한 내용을 정리하고 적용해 온 덕분에, 이전 3주차 과제까지는 기능 요구 사항을 보며 머릿속에서 설계를 그릴 수 있었다. 하지만 이번 과제는 달랐다.
감사하게도 3주차 과제에 대한 리뷰를 많이 받아 정말 많은 도움이 되었지만, 해당 내용을 기능 구현 단계에서 적용하기에는 벅차다고 느꼈다. 그래서 이번 과제는 체계적으로 계획을 세워 접근하기로 했다. 우선 기능 구현을 목표로 잡고, 이후에 무지막지한 리팩토링을 진행할 계획을 세웠다.
🔽 진행 계획
- 기능 구현 목록 정리
- 단위 테스트 코드 작성
- 기능 구현
- 통합 테스트 코드 작성
- 3주차 피드백을 바탕으로 무지막지한 리팩토링
- 배운 점 정리
🔽 3주차 피드백 반영
또한, 3주차 피드백 중 이번 과제에 적용할 수 있는 내용들을 반영하려고 노력했다.
- 여러 곳에서 사용되는 상수 하나의 Enum 파일에서 관리
- InputView에서 반복되는 로직을 분리
- View와 Model의 분리를 위한 DTO 도입
- \n 대신 System.lineSeparator() 사용
- 객체 내에서 검증
- main() 메서드 포함 메서드 라인 제한
- 테스트 코드 리팩토링
💻 진행 과정
▶ 기능 구현 목록 정리
구현 기능 목록 작성에만 3일이 걸렸다. 이전과 달리 코드 설계와 분기문 작성에 많은 시간을 투자했기 때문이다...
▶ 단위 테스트 코드 작성 및 기능 구현
단위 테스트 코드 작성과 기능 구현을 함께 진행했다. 이번 과제에서 설계가 잘못되어 구조를 변경해야 했을 때가 있었는데, 작성했던 단위 테스트 코드들이 큰 도움이 되었다. 이 부분은 따로 고찰에서 더 깊이 작성해 보려고 한다.
class PurchaseTransactionHandlerTest {
@Test
@DisplayName("프로모션 포함 상품 구매: 증정 계산 true")
void processWithGift_calculateGift_true() {
// given
Product product = Product.of("콜라", 1000, "탄산2+1");
Integer quantity = 4;
// when
transactionHandler.processWithGift(product, quantity, true);
// then
Receipt receipt = receiptManager.get();
Stock promotionStock = stockManager.findPromotionAndGeneralStocks(product.getName()).getFirst();
assertThat(promotionStock.getQuantity()).isEqualTo(6);
assertThat(receipt.getPurchasedStocks().size()).isEqualTo(1);
assertThat(receipt.getPurchasedStocks().getFirst().getProductName()).isEqualTo("콜라");
assertThat(receipt.getPurchasedStocks().getFirst().getQuantity()).isEqualTo(4);
assertThat(receipt.getGiftStocks().size()).isEqualTo(1);
assertThat(receipt.getGiftStocks().getFirst().getProductName()).isEqualTo("콜라");
assertThat(receipt.getGiftStocks().getFirst().getQuantity()).isEqualTo(1);
}
@Test
@DisplayName("프로모션 포함 상품 구매: 증정 계산 false")
void processWithGift_calculateGift_false() {
// given
Product product = Product.of("콜라", 1000, "탄산2+1");
Integer quantity = 4;
// when
transactionHandler.processWithGift(product, quantity, false);
// then
Receipt receipt = receiptManager.get();
Stock promotionStock = stockManager.findPromotionAndGeneralStocks(product.getName()).getFirst();
assertThat(promotionStock.getQuantity()).isEqualTo(6);
assertThat(receipt.getPurchasedStocks().size()).isEqualTo(1);
assertThat(receipt.getPurchasedStocks().getFirst().getProductName()).isEqualTo("콜라");
assertThat(receipt.getPurchasedStocks().getFirst().getQuantity()).isEqualTo(4);
assertThat(receipt.getGiftStocks().size()).isEqualTo(1);
assertThat(receipt.getGiftStocks().getFirst().getProductName()).isEqualTo("콜라");
assertThat(receipt.getGiftStocks().getFirst().getQuantity()).isEqualTo(4);
}
...
}
▶ 통합 테스트 코드 작성
기능 구현을 완료한 후, 리팩토링을 거친 뒤 통합 테스트 코드를 작성하여 전체 흐름을 점검했다. 이 과정에서 다양한 경우의 수를 고려하여 전반적인 검토가 가능했다.
public class IntegrationTest extends NsTest {
...
@Test
void 단일_상품_구매_프로모션_투플러스원_수량_추가_불필요_멤버십_있음() {
assertSimpleTest(() -> {
run("[콜라-6]", "Y", "N");
assertThat(output().replaceAll("\\s", "")).contains(
"행사할인-2,000",
"멤버십할인-0",
"내실돈4,000"
);
});
}
@Test
void 단일_상품_구매_프로모션_투플러스원_수량_추가_필요_추가_함_멤버십_있음() {
assertSimpleTest(() -> {
run("[콜라-5]", "Y", "Y", "N");
assertThat(output().replaceAll("\\s", "")).contains(
"행사할인-2,000",
"멤버십할인-0",
"내실돈4,000"
);
});
}
...
}
🔨 기술 고도화 및 리팩토링
▶ 싱글톤 패턴 사용
이번 과제에서는 편의점 재고가 차감될 때 항상 최신 상태로 유지되어야 한다는 조건이 있었다. 이를 보자마자 지난주 피드백으로 언급된 싱글톤 패턴이 떠올랐다. 사실 과제만 놓고 보면 일급 컬렉션으로도 충분히 해결할 수 있지만, 싱글톤 패턴을 학습할 겸 확장성도 고려할 겸 싱글톤으로 구현했다. 지난주에는 getter 내 null 체크 방식으로 싱글톤을 구현했으나, 멀테스레드 환경에서 동시성 문제가 발생할 수 있다는 지적을 받았다. 이번에는 이러한 문제를 방지하기 위해 내부 정적 클래스를 활용해 싱글톤 패턴을 구현했다.
🔽 재고를 관리하는 StockManager
public class StockManager {
private List<Stock> stocks;
private StockManager() {
this.stocks = new ArrayList<>();
}
private static class ProductManagerHolder {
private static final StockManager INSTANCE = new StockManager();
}
public static StockManager getInstance() {
return ProductManagerHolder.INSTANCE;
}
...
}
🔽 내부 정적 클래스의 특징
- StockManager가 로드될 때는 초기화되지 않고, getInstance() 메서드를 통해 처음 호출될 때 로드된다. (지연 초기화)
- 내부 클래스 사용으로, 멀티스레드 환경에서도 안전한 싱글톤 패턴을 구현할 수 있다.
▶ View와 Model의 분리를 위한 DTO 도입
지난주에 View가 Model을 직접 알고 있는 것에 대한 피드백을 받았다. 2주차에 MVC 패턴을 공부했지만, 이 부분을 잘 이해하지 못하고 구현했던 것 같다.
- View는 Model과 Controller의 정보를 몰라야 한다.
- View는 Model의 정보를 따로 저장해서는 안 된다.
🔽 3주차 OutputView
3주차에서는 RankCounter 객체를 그대로 OutputView에 전달하여 출력했다.
public class OutputView {
...
public static void printWinningStatistics(RankCounter rankCounter) {
System.out.println(WINNING_STATISTICS_OUTPUT_START_MESSAGE.getMessage());
System.out.println(FIFTH_PLACE_OUTPUT_MESSAGE.getMessage(
FIFTH_PLACE.getMatchCount(), FIFTH_PLACE.getPrizeAmount(), rankCounter.getRankCount(FIFTH_PLACE)));
System.out.println(FOURTH_PLACE_OUTPUT_MESSAGE.getMessage(
FOURTH_PLACE.getMatchCount(), FOURTH_PLACE.getPrizeAmount(), rankCounter.getRankCount(FOURTH_PLACE)));
System.out.println(THIRD_PLACE_OUTPUT_MESSAGE.getMessage(
THIRD_PLACE.getMatchCount(), THIRD_PLACE.getPrizeAmount(), rankCounter.getRankCount(THIRD_PLACE)));
System.out.println(SECOND_PLACE_OUTPUT_MESSAGE.getMessage(
SECOND_PLACE.getMatchCount(), SECOND_PLACE.getPrizeAmount(), rankCounter.getRankCount(SECOND_PLACE)));
System.out.println(FIRST_PLACE_OUTPUT_MESSAGE.getMessage(
FIRST_PLACE.getMatchCount(), FIRST_PLACE.getPrizeAmount(), rankCounter.getRankCount(FIRST_PLACE)));
}
...
}
🔽 4주차 OutputView
4주차에서는 ReceiptResponse DTO를 사용해 필요한 데이터를 전달했다.
public class OutputView {
...
private static void printReceiptSummary(ReceiptResponse response) {
println(RECEIPT_DIVISION_HEADER.getMessage());
println(RECEIPT_TOTAL_PURCHASE_AMOUNT.getMessage(
response.getTotalPurchaseQuantity(),
response.getTotalPurchaseAmount()
));
println(RECEIPT_PROMOTION_DISCOUNT.getMessage(response.getPromotionDiscount()));
println(RECEIPT_MEMBERSHIP_DISCOUNT.getMessage(response.getMembershipDiscount()));
println(RECEIPT_FINAL_AMOUNT.getMessage(response.getFinalAmount()));
}
...
}
🔽 장점
- DTO를 통해 View와 Model 간의 직접적인 연결을 줄여 MVC 패턴의 원칙을 더 잘 지킬 수 있다.
- ReceiptResponse와 같은 DTO를 사용함으로써, View에 필요한 데이터만 명확하게 전달할 수 있다.
- Model에 변경이 발생해도 View에 큰 영향을 미치지 않으며, 필요한 경우 DTO에서 전달할 정보만 수정하면 된다.
🔽 아쉬운 점
- DTO를 사용해도 의존성이 완전히 사라지는 것은 아니다. 직접적인 의존성은 줄어들지만, 여전히 View는 DTO와의 의존성을 갖는다. 즉, 간접적인 의존성으로 View와 Model이 느슨하게 연결되는 구조이다.
- 이는 MVC 구조에서 어느 정도 필요한 의존성이라고 볼 수 있다.
▶ 검증을 입력 검증과 비즈니스 검증으로 분리
이전에는 모든 검증과 파싱을 Validator와 Parser에서 처리했지만, 이번에는 구조를 목적에 맞게 변경했다.
- 입력 검증: CommonParser, CommonValidator 유틸 클래스를 통해 DTO에 적용
- 비즈니스 검증: 해당 로직 내에서 직접 수행
🔽 Promotion의 Reqeust DTO
입력에 필요한 검증을 유틸 클래스를 활용해 진행했다.
public class PromotionRequest {
...
// 구매 조건 수량 검증
private static Integer parseRequiredCount(List<String> line) {
String purchaseRequiredCount = line.get(1);
CommonValidator.validateNotNull(purchaseRequiredCount);
CommonValidator.validateNumeric(purchaseRequiredCount);
return CommonParser.convertStringToInteger(purchaseRequiredCount);
}
// 증정 수량 검증
private static Integer parseGiftCount(List<String> line) {
String giftCount = line.get(2);
CommonValidator.validateNotNull(giftCount);
CommonValidator.validateNumeric(giftCount);
return CommonParser.convertStringToInteger(giftCount);
}
// 시작 날짜 검증
private static LocalDate parseStartDate(List<String> line) {
String startDate = line.get(3);
CommonValidator.validateNotNull(startDate);
CommonValidator.validateDate(startDate);
return CommonParser.parseDate(startDate);
}
...
}
🔽 ConvenienceService
비즈니스 로직에서 필요한 검증을 진행했다.
public class ConvenienceService {
...
// 재고가 충분한지 검증
private void validateSufficientStocksQuantity(String productName, Integer quantity) {
Integer totalQuantity = stockManager.calculatePromotionAndGeneralStockQuantity(productName);
if (totalQuantity < quantity) {
throw new IllegalArgumentException(INSUFFICIENT_STOCK.getMessage());
}
}
// 없는 상품을 요청했는지 검증
private void validateNonExistentProduct(List<Stock> stocks) {
if (stocks.isEmpty()) {
throw new IllegalArgumentException(NOT_FOUND_PRODUCT.getMessage());
}
}
}
🔽 장점
- 입력 검증은 CommonParser와 CommonValidator 유틸 클래스에서 수행되고, 비즈니스 로직 검증은 서비스 클래스 내에서 진행되어 각 부분이 자신의 역할에 집중할 수 있게 된다.
- 입력 검증은 주로 DTO에 적용되기 때문에, 다양한 곳에서 재사용될 수 있다.
- 각각 독립적으로 테스트가 가능하다.
- 새로운 요구사항이나 정책에 따라 검증 로직을 쉽게 확장할 수 있다. (예: 다른 입력 형식이나 추가적인 검증이 필요한 경우, CommonValidator만 수정하거나 비즈니스 검증 로직을 추가하면 된다.)
▶ Runnable, Supplier
이번 과제에서는 Runnable과 Supplier를 사용하여 Controller의 반복 로직을 처리했다. 이 부분은 송선권 님의 3주차 PR 리뷰에서 인상 깊었던 부분을 참고했으며, 이번 과제에서 이를 활용하고자 했다.
🔽 Runnable과 Supplier를 적용한 ConvenienceController
스택 오버플로우 방지를 위해 최대 재시도 횟수를 추가적으로 설정했다.
public class ConvenienceController {
...
public void run() {
do {
execute(this::processStart);
execute(() -> processStatuses(processPurchaseProducts()));
execute(this::processMembershipApplicationStatus);
execute(this::processReceipt);
} while (execute(this::processAdditionalPurchaseStatus));
}
...
private void execute(Runnable action) {
execute(action, 0);
}
private void execute(Runnable action, int attempts) {
try {
action.run();
} catch (RuntimeException e) {
if (attempts == MAX_RETRIES) {
OutputView.printErrorMessage(e);
return;
}
OutputView.printErrorMessage(e);
execute(action, attempts + 1);
}
}
private boolean execute(Supplier<Boolean> action) {
return execute(action, 0);
}
private boolean execute(Supplier<Boolean> action, int attempts) {
try {
return action.get();
} catch (RuntimeException e) {
if (attempts == MAX_RETRIES) {
OutputView.printErrorMessage(e);
return false;
}
OutputView.printErrorMessage(e);
return execute(action, attempts + 1);
}
}
}
🔽 장점
- 반복 로직을 하나의 메서드로 처리하여 코드가 가독성이 좋아지고, 재사용성도 높아졌다.
- 에러 발생시 동일한 방식으로 처리하도록 하여 코드의 일관성을 유지했다.
- Runnable은 반환값이 없는 작업을, Supplier<Boolean>은 Boolean 값을 반환하는 작업을 각각 처리할 수 있어 메서드를 유연하게 다룰 수 있었다.
▶ 여러 곳에서 사용되는 상수 하나의 파일에서 관리
3주차에서는 각 클래스마다 필요한 상수를 각각 생성하여 관리했다. 그러나 이렇게 하니 각 클래스에서 static final로 선언한 것과 큰 차이가 없었고, 상수가 클래스 간에 재사용되지 못한다는 피드백을 받았다. 또한, 상수가 String, Integer 등 여러 타입으로 섞여 있어 Object로 설정한 후 캐스팅을 통해 반환했는데, 이로 인해 객체 활용이 제한되고 타입 검증을 할 수 없었다는 피드백을 받았다. Enum을 활용하고자 잘못된 방향으로 과도하게 사용하려 했던 것이다. 이를 개선하기 위해 4주차에서는 공통적으로 사용하는 상수들을 모아 Constant 클래스를 만들어 관리했다.
🔽 3주차 Constant
public enum LottoServiceConstant implements BaseConstant {
LOTTO_PRICE(1000),
MIN_LOTTO_NUMBER(1),
MAX_LOTTO_NUMBER(45),
LOTTO_NUMBER_COUNT(6),
EARNING_RATE_DECIMAL_PLACE(1)
;
private final Object constant;
LottoServiceConstant(Object constant) {
this.constant = constant;
}
@Override
public String getStringValue() {
return (String) constant;
}
@Override
public Integer getIntegerValue() {
return (Integer) constant;
}
}
🔽 4주차 Constant
public class ConvenienceConstant {
public static final int MAX_RETRIES = 3;
public static final int MAX_MEMBERSHIP_DISCOUNT = 8000;
public static final double MEMBERSHIP_DISCOUNT_RATE = 0.3;
public static final String NO_PROMOTION = "null";
public static final String EMPTY_STRING = "";
public static final String NOT_IN_STOCK = "재고 없음";
public ConvenienceConstant() {
}
}
▶ \n 대신 System.lineSeparator() 사용
3주차 OutputMessage에서는 개행 문자를 \n으로 직접 포함했다. 하지만 이는 시스템마다 사용하는 개행 문자가 다를 수 있어 지양해야 한다는 피드백을 받았다. 따라서 이번 주차에는 System.lineSeparator()를 사용하여 개행 문자를 처리하도록 개선했다.
🔽 3주차 OutputMessage
public enum OutputMessage {
LOTTO_PURCHASE_AMOUNT_INPUT_MESSAGE("\n구입 금액을 입력해 주세요."),
WINNING_TICKET_INPUT_MESSAGE("\n당첨 번호를 입력해 주세요."),
BONUS_NUMBER_INPUT_MESSAGE("\n보너스 번호를 입력해 주세요."),
PURCHASE_NUMBER_OUTPUT_MESSAGE("\n%d개를 구매했습니다."),
WINNING_STATISTICS_OUTPUT_START_MESSAGE("\n당첨 통계\n---"),
...
}
🔽 4주차 OutputView
public class OutputView {
public static void printStartGuidance() {
printNewLine();
println(START_GUIDANCE.getMessage());
}
...
private static void printNewLine() {
System.out.print(System.lineSeparator());
}
}
▶ Application의 main 메서드 분리
3주차부터 메서드 라인을 15줄로 제한하는 조건이 있었고, 4주차에는 이 제한이 10줄로 줄었다. 3주차 공통 피드백에서는 Application의 main 메서드도 이 제한 조건을 준수해야 한다는 피드백이 있었다. 이에 따라 실행 흐름을 가독성 좋게 분리하고, 실행 메서드를 따로 분리하여 개선했다.
🔽 Application 클래스
public class Application {
public static void main(String[] args) {
PromotionManager promotionManager = PromotionManager.getInstance();
StockManager stockManager = StockManager.getInstance();
ReceiptManager receiptManager = new ReceiptManager();
stockManager.clearStocks();
InventoryService inventoryService = new InventoryService(promotionManager, stockManager);
ConvenienceService convenienceService = new ConvenienceService(promotionManager, stockManager, receiptManager);
InventoryController inventoryController = new InventoryController(inventoryService);
ConvenienceController convenienceController = new ConvenienceController(convenienceService);
start(inventoryController, convenienceController);
}
private static void start(InventoryController inventoryController, ConvenienceController convenienceController) {
inventoryController.setup();
convenienceController.run();
}
}
🔍 클린 코드 원칙 준수 점검 및 코드 개선
▶ 디미터 법칙
이번 과제에서 Model의 Stock 객체와 Product 객체는 아래와 같은 구조로 설계되었다.
이러한 구조에서는 Stock 객체에서 Product의 속성에 접근하기 위해 Stock.getProduct().get~와 같은 방식으로 접근해야 했다. 하지만 이는 디미터 법칙을 위반하는 접근 방식이며, 이를 개선하고자 했다. (하지만, 개선된 방법도 좋은 것은 아니다. 이후 고찰에 작성할 예정이다.)
🔽 개선 전 코드
public static class InnerStockResponse {
...
private static InnerStockResponse from(Stock stock) {
return new InnerStockResponse(
stock.getProduct().getName(),
stock.getProduct().getPrice(),
formatPromotionName(stock.getProduct().getPromotionName()),
formatQuantity(stock.getQuantity()));
}
...
}
🔽 개선 후 코드
개선 후 코드에서는 Stock 객체가 Product의 속성에 직접 접근하지 않고, 대신 Stock 클래스 내부에 Product의 정보를 가져오는 메서드를 추가했다. 이렇게 하면 InnerStockResponse에서 Stock의 속성에만 접근하게 되어, 디미터 법칙을 준수할 수 있다.
public class Stock {
...
public String getProductName() {
return product.getName();
}
public Integer getProductPrice() {
return product.getPrice();
}
public String getPromotionName() {
return product.getPromotionName();
}
public Integer getQuantity() {
return quantity;
}
}
public static class InnerStockResponse {
...
private static InnerStockResponse from(Stock stock) {
return new InnerStockResponse(
stock.getProductName(),
stock.getProductPrice(),
formatPromotionName(stock.getPromotionName()),
formatQuantity(stock.getQuantity()));
}
...
}
🔽 개선점
이 방식으로 개선하면 Stock 객체가 Product와 강하게 결합된다. 사실, Getter 메서드를 사용하는 것보다 객체 내부에서 필요한 정보를 직접 처리하는 것이 더 객체지향적인 설계이다. 향후 이 부분에 대해 추가적인 개선을 고찰해 볼 예정이다.
✏️ 배운 점 정리
▶ 싱글톤 패턴의 동시성 문제
3주차 과제도 싱글톤을 구현했으나, 멀테스레드 환경에서 동시성 문제가 발생할 수 있다는 지적을 받았다. 이번 과제에서는 내부 정적 클래스 방식을 사용하여 이를 개선하고자 했다.
🔽 3주차 과제의 싱글톤 패턴
3주차 과제에서는 LottoRandomUtil 클래스에서 getter 메서드 내 null 체크로 싱글톤 인스턴스를 생성하고 반환하는 방식을 사용했다.
public class LottoRandomUtil implements RandomUtil {
private static LottoRandomUtil lottoRandomUtil;
private LottoRandomUtil() {
}
public static LottoRandomUtil getLottoRandomUtil() {
if (lottoRandomUtil == null) {
lottoRandomUtil = new LottoRandomUtil();
}
return lottoRandomUtil;
}
...
}
이 방식은 멀티스레드 환경에서 여러 스레드가 getLottoRandomUtil()을 동시에 호출할 경우 lottoRandomUtil이 null로 인식되어 여러 스레드가 동시에 인스턴스를 생성할 수 있다.
🔽 4주차 과제의 싱글톤 패턴
4주차 과제에서는 내부 정적 클래스 방식을 사용하여 싱글톤을 구현했다.
public class StockManager {
private List<Stock> stocks;
private StockManager() {
this.stocks = new ArrayList<>();
}
private static class ProductManagerHolder {
private static final StockManager INSTANCE = new StockManager();
}
public static StockManager getInstance() {
return ProductManagerHolder.INSTANCE;
}
...
}
ProductManagerHolder는 StockManager.getInstance()가 호출될 때 처음 로드되며, 이때 StockManager 인스턴스가 생성된다. 클래스 로딩 과정에서 자바는 초기화가 원자적으로 수행되도록 보장(여러 스레드가 동시에 호출하더라도 자바가 자동으로 순서를 조정)하므로, 여러 스레드가 동시에 getInstance()를 호출해도 동시성 문제가 발생하지 않는다.
▶ DTO를 활용한 검증과 파싱의 분리
3주차까지 검증과 파싱을 입력과 완전히 분리하는 것에 대한 고민이 많았다. 입력 단계에서 검증해야 할 부분이 있는가 하면, 비즈니스 로직에서 검증해야 할 부분도 있었기 때문이다. 무작정 Validator와 Parser에 모든 검증과 파싱을 맡기려 하다 보니, Parser에서 검증이 이루어지는 등 책임 분리가 명확하지 않았다.
이를 DTO를 통해 해결할 수 있었다. 입력에 대한 검증은 DTO에서, 비즈니스 로직에 대한 검증은 비즈니스 로직에서 진행함으로써, 책임을 명확히 분리할 수 있었다.
▶ Runnable, Supplier
이번 과제에서는 Runnable과 Supplier를 활용하여 Controller에서 반복 로직을 처리했다. 이 방법은 코드 가독성을 높이고 재사용성을 강화해 주며, 여러 작업을 간편하게 관리할 수 있었다.
🔽 Runnable
Runnable는 반환값이 없는 작업을 정의하는 함수형 인터페이스이다. run() 메서드 하나로 구성되어 있으며, 단순히 특정 작업을 실행할 때 사용할 수 있다.
public class ConvenienceController {
...
// do 부분에 Runnable 사용
public void run() {
do {
execute(this::processStart);
execute(() -> processStatuses(processPurchaseProducts()));
execute(this::processMembershipApplicationStatus);
execute(this::processReceipt);
} while (execute(this::processAdditionalPurchaseStatus));
}
...
private void execute(Runnable action) {
execute(action, 0);
}
private void execute(Runnable action, int attempts) {
try {
action.run();
} catch (RuntimeException e) {
if (attempts == MAX_RETRIES) {
OutputView.printErrorMessage(e);
return;
}
OutputView.printErrorMessage(e);
execute(action, attempts + 1);
}
}
...
}
🔽 Supplier
Supplier는 반환값이 필요한 작업을 정의하는 함수형 인터페이스이다. get() 메서드 하나로 구성되어 있으며, 실행 후 결과를 반환해야 하는 작업에 적합하다. Supplier는 특히 조건부 로직이나 반복 작업을 통한 값 반환에 유용하다.
public class ConvenienceController {
...
// while 부분에 Supplier 사용
public void run() {
do {
execute(this::processStart);
execute(() -> processStatuses(processPurchaseProducts()));
execute(this::processMembershipApplicationStatus);
execute(this::processReceipt);
} while (execute(this::processAdditionalPurchaseStatus));
}
...
private boolean execute(Supplier<Boolean> action) {
return execute(action, 0);
}
private boolean execute(Supplier<Boolean> action, int attempts) {
try {
return action.get();
} catch (RuntimeException e) {
if (attempts == MAX_RETRIES) {
OutputView.printErrorMessage(e);
return false;
}
OutputView.printErrorMessage(e);
return execute(action, attempts + 1);
}
}
}
▶ Markdown 파일 불러오는 방법
이번 과제에서는 상품과 프로모션의 데이터를 Markdown 파일에서 불러와야 했다. Markdown 파일을 읽는 다양한 방법이 있지만, 이번 과제에서는 InputStream과 BufferedReader를 활용했다.
- InputStream을 통해 파일 불러오기
- InputStream을 사용하여 파일을 스트림 형태로 불러올 수 있다.
- getClass().getResourceAsStream("파일 경로")는 resource 폴더에 위치한 파일을 불러올 수 있다.
- BufferedReader로 줄 단위로 읽기
- 파일을 문자를 스트림으로 변환한 후 BufferedReader를 사용해 파일을 한 줄씩 읽을 수 있다.
- 예외 처리
- 파일을 읽는 과정에서는 파일이 없거나 읽기 권한이 없는 경우 예외가 발생할 수 있다. 이를 try-catch 블록으로 감싸 예외를 처리해야 한다.
🔽 MarkdownFileReader 클래스
처음 사용해 보는 기능이라 코드 가독성이 다소 부족할 수 있다.
public class MarkdownFileReader {
private MarkdownFileReader() {
}
public static List<String> readFile(String resourcePath) {
try (BufferedReader reader = createReader(resourcePath)) {
return readLines(reader);
} catch (IOException e) {
throw new IllegalStateException(NOT_FOUND_FILE.getMessage());
}
}
private static BufferedReader createReader(String resourcePath) {
InputStream inputStream = MarkdownFileReader.class.getResourceAsStream(resourcePath);
if (inputStream == null) {
throw new IllegalStateException(NOT_FOUND_FILE.getMessage());
}
return new BufferedReader(new InputStreamReader(inputStream));
}
private static List<String> readLines(BufferedReader reader) throws IOException {
List<String> lines = new ArrayList<>();
reader.readLine(); // 헤더 스킵
String line;
while ((line = reader.readLine()) != null) {
lines.add(line);
}
return lines;
}
}
💡 고찰
▶ 아쉬운 점, 이후 리팩토링할 부분
1. ReceiptManager의 싱글톤 미적용
현재 ReceiptManager는 싱글톤이 아니다. 처음에는 Repository 개념으로 설계했지만, 이후 StockManager와 PromotionManger을 싱글톤으로 설계하면서 ReceiptManager 또한 하나만 생성되는 것이 구조적으로 더 적합하다는 생각이 들었다. 이후 리팩토링을 통해 이를 싱글톤으로 적용할 예정이다.
public class ReceiptManager {
private Receipt receipt;
public void createReceipt() {
receipt = Receipt.createAndInitialize();
}
public Receipt get() {
return receipt;
}
}
2. 결제 로직 개선 필요
현재 결제 로직은 결제를 두 번 진행한다. 중간 결제 후, 사용자에게 추가 상품 수량 여부 또는 정가 결제 여부에 대한 응답을 받고 추가 결제를 진행하는 방식이다. 하지만 이는 실제 트랜잭션 흐름을 살펴봤을 때 치명적인 문제를 일으킬 수 있다. 결제를 미리 반영하면 사용자가 결제를 취소했을 때 이를 되돌릴 로직이 없기 때문이다. 이 문제를 리팩토링을 통해 개선할 예정이다.
3. 객체는 객체 답게
현재 Model의 Stock 객체는 아래와 같은 구조로 설계되어 있다.
디미터 법칙을 준수하기 위해 Stock 클래스에 Proudct의 정보를 가져오는 메서드를 추가하여 해결했지만, 사실 객체지향 설계에서는 Getter 없이 객체 내부에서 로직을 처리하는 것이 더 적합한 방식이다. 시간 부족으로 인해 현재는 해당 부분을 전부 리팩터링하지 못했지만, 이후 이 부분을 개선하고자 한다.
▶ Repository-Service-Controller 구조
처음 백엔드 개발을 시작했을 때는 많은 사람들이 사용하는 Repository-Service-Controller 구조를 깊이 이해하지 않고 무작정 따라 사용했다. 이번 프리코스를 진행하며 1주차부터 4주차까지 코드가 크게 발전했는데, 특히 책임 분리 측면에서 많은 개선이 있었다.
초반에는 요구 사항이 단순해 코드 작성에 큰 어려움이 없었지만, 요구 사항이 점차 늘어나면서 각각의 책임을 어디에 할당해야 할지 판단이 어려웠다. 그 과정에서 MVC 패턴을 도입하게 되었고, "이것이 실제 서비스라면 어떻게 구성해야 할까"라는 고민을 통해 Repository와 Service의 필요성을 깨닫게 되었다. 사용자의 데이터를 관리하고 업데이트해야 하는 부분에서 Repository가 필요함을 느꼈고, 비즈니스 로직과 핵심 기능을 집중시키기 위해서는 Service가 필요했다. 데이터가 여러 클래스를 거치면서 책임 분리의 필요성도 절실히 느꼈다. 이러한 구조가 단순히 형식적으로 채택된 것이 아니라는 것을 이번 경험을 통해 깊이 이해하게 됐던 것 같다.
▶ 스프링이 다 해줘서 몰랐는데
이전에는 스프링부트를 사용하면서 자동화된 기능들에 대해 깊이 생각하지 않고 사용해 왔던 것 같다. 이전에는 이렇게 사용해오고 있었다는 사실조차 몰랐다. 프리코스를 통해 스프링부트 없이 직접 개발하면서, 이러한 코드의 작성 방식을 깊이 이해할 수 있었다. 특히 싱글톤을 직접 구현해 의존성 주입과 멀티스레드 동시성 문제를 해결하면서 스프링부트의 빈 등록이 얼마나 효율적이고 편리한지 실감했다. 또한, 트랜잭션 어노테이션 역시 복잡한 과정을 단순화해 주는 강력한 도구임을 깨달았다. 이번 4주차 과제에서 결제 절차를 사용자 입력 전후로 나누어 진행했는데, 회고하면서 이 방식이 실제 사용자가 사용하게 되었을 때 치명적일 수 있음을 알게 되었고, 트랜잭션의 필요성을 절감하게 되었다. 해당 부분을 구현하면서 트랜잭션 어노테이션이 얼마나 중요하고 복잡한 과정을 담고 있는지 알 수 있었다.
▶ 우테코 커뮤니티
같은 분야에서의 토론이 주는 즐거움과 몰입의 힘을 경험했다. 이전에는 토론할 기회가 많지 않아 혼자서 코드를 작성하곤 했지만, 프리코스를 진행하면서 토론하기 채널과 함께 나누기 채널을 통해 많은 사람들과 의견을 나눌 수 있었다. 이를 통해 내가 아는 지식이 정확한지 확인하고, 설명하면서 더 깊이 이해하게 되었다. 또한, 코드 작성 시 정답이 없는 문제에 대해 다른 사람들도 비슷한 고민을 하고 있다는 점에서 동질감을 느꼈고, 다양한 시각에서 나오는 의견들을 접하며 몰입을 경험하게 되었다. 같은 관심사를 집요하게 파고드는 토론 속에서 즐거움을 느꼈고, 이러한 토론이 우아한테크코스 본 과정에서도 이어질 것이라 생각하니 참여하고 싶다는 열망이 더욱 커졌다.
▶ PR 리뷰에서 얻은 추가 고찰 (11.19 추가)
- 리뷰 과정을 통해 처음 설계의 중요성을 다시 한번 깨달았다. 처음 설계가 얼마나 이후 로직 구현을 탄탄하고 효율적으로 이끌어 줄 수 있는지 체감할 수 있었다.
- 네이밍 실력이 부족하다는 점을 깊이 반성했다. ChatGPT를 통해 네이밍 추천을 자주 받다 보니 스스로의 네이밍 역량이 점점 약해지고 있는 것 같다. 더 많은 고민과 노력을 통해 네이밍 실력을 향상시켜야겠다고 다짐했다.
- null을 직접 사용하는 것보다는 Optional이나 다른 기술을 활용하여 null-safe한 방식을 고민해야겠다는 필요성을 느꼈다.
- line.get(0)처럼 의미를 알기 어려운 부분이 있었는데, 이러한 코드는 상수화를 통해 더 높은 가독성을 제공할 수 있음을 배웠다.
- record와 Stream API에 대해 알고는 있었지만, 깊이 이해하지 못해 적극적으로 활용하지 못했다. 남은 기간 동안 이와 같은 기술들에 대해 공부하고 적용해보려 한다.
- static 키워드에 대해서도 정확히 이해하지 못하고 사용하는 경우가 많다는 점을 반성하며, static 메서드의 적절한 활용 시점에 대해 더 공부할 예정이다. static 메서는 언제 사용해야 할까?
- 필드 초기화 방식인 명백한 초기화와 생성자 초기화에 대해 더 깊이 알아보고, 각각의 장단점과 사용 시점을 공부하려고 한다.
- 트랜잭션 핸들러를 구현하려 했으나 현재 로직에서는 제대로 적용하지 못했다. 이후 리팩토링 단계에서 트랜잭션 로직을 개선할 계획이다.
🗣️ 사담
사실 4주차 기능 요구사항이 많긴 했지만, 처음에는 이렇게 긴 여정을 하게 될 줄 몰랐다. 목요일부터 이 글을 쓰고 있는 월요일 아침 10시까지 밤낮을 뒤바꿔 가며 작업했다. 개발 끈이 길진 않지만, 알고리즘 쪽은 열심히 공부해 왔어서 예제 테스트가 실패한 적이 거의 없었는데, 이번에는 처음 결과를 보고 정말 충격을 받았다...
정말 월요일 새벽 5시쯤 멘탈이 나갔던 것 같다. 마음을 다 잡고 차분히 트러블슈팅을 진행했다.
첫 번째 문제는, 프로모션 재고만 있을 때, 일반 재고까지 표시되어야 하는 상황에서 발생했다. 원인은 싱글톤 인스턴스를 DTO에서 바로 호출해 버렸기 때문이었고, 이를 해결한 직후이다.
두 번째 문제는, Application에서 싱글톤 인스턴스를 매번 초기화하는 한 줄을 추가하고 나서 성공했다. ApplicationTest에서 @BeforeEach를 통해 테스트 의존성을 분리해도 실제 채점 환경에서는 먹히지 않는 듯했다. 단순히 @BeforeEach에서 적용한 것을 Application에서 추가하면 되는 문제였는데, 사실 단 한 줄만 추가하면 됐던 것인데, 당시 멘탈이 나가서 폭주하고 있던 터라 쉽게 해결하지 못했다. 함께 프리코스를 진행하던 프론트엔드 지인이 툭 던진 말에 정신을 차리고 성공할 수 있었다. 이번 경험을 통해 절감했던 건, 이런 상황에서 멘탈을 차분히 잡는 게 얼마나 중요한지, 그리고 기술을 제대로 이해하고 사용하는 것이 필요하다는 점을 느꼈다.
제출하고 보니 커밋도 230개여서 놀랐다.
🎬 프리코스를 마무리하며
프리코스를 진행하면서 많은 어려움이 있었지만, 그 어려움을 해결해 나가는 과정 자체도 정말 즐거웠다(멘탈이 나간 건 살짝 안 즐거웠다.). 개인적으로, 개발자로서 내가 적합한지에 대한 끊임없는 물음표가 있었는데, 이번 프리코스를 통해 이러한 고민을 해소하며 한층 더 개발자의 꿈에 다가가게 된 것 같다. 우아한테크코스에 떨어지면 아쉽겠지만, 프리코스를 통해 많은 것을 배웠고, 진심으로 최선을 다해 이 과정을 즐긴 것 같다!
📍 참고 자료
'우아한테크코스' 카테고리의 다른 글
[우아한테크코스] 최종 합격 기록 (7) | 2024.12.29 |
---|---|
[우아한테크코스] 1차 합격, 최종 코딩 테스트 기록 (4) | 2024.12.15 |
[우아한테크코스] Java Enum 활용기 (4) | 2024.11.05 |
[우아한테크코스] 프리코스 3주차 기록 (8) | 2024.11.05 |
[우아한테크코스] 프리코스 2주차 기록 (2) | 2024.10.29 |