ewhk9887/korean_code_reviews_from_github huggingface dataset:
@@ -3,38 +3,52 @@
 import com.flab.ccinside.api.trendingpost.application.port.ViewPostEvent;
 import com.flab.ccinside.api.trendingpost.application.port.in.PostSystemUsecase;
 import com.flab.ccinside.api.trendingpost.application.port.in.UpdateViewCountCommand;
-import jakarta.annotation.PostConstruct;
-import java.util.concurrent.BlockingQueue;
-import java.util.concurrent.ExecutorService;
-import java.util.concurrent.Executors;
-import lombok.RequiredArgsConstructor;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Queue;
+import java.util.concurrent.ScheduledExecutorService;
+import java.util.concurrent.TimeUnit;
 import lombok.extern.slf4j.Slf4j;
 import org.springframework.stereotype.Component;
 
 @Slf4j
 @Component
-@RequiredArgsConstructor
 public class InMemoryMessageQueue {
 
-  private final PostSystemUsecase postSystemUsecase;
-  private final BlockingQueue<ViewPostEvent> queue;
-  private final ExecutorService executorService = Executors.newSingleThreadExecutor();
+  private static final Long INITIAL_DELAY = 10L;
+  private static final Long FIXED_DELAY = 100L;
+  private static final int THRESHOLD = 100;
 
-  @PostConstruct
-  public void init() {
-    executorService.submit(
-        () -> {
-          try {
-            while (true) {
-              var event = queue.take();
-              log.info("View post event consumed. postId: {}", event.postId());
-              var command = new UpdateViewCountCommand(event.postId());
-              postSystemUsecase.updateViewCount(command);
-            }
-          } catch (InterruptedException e) {
-            log.error("consume error: {}", e.getMessage());
-            throw new RuntimeException(e);
-          }
-        });
+  public InMemoryMessageQueue(
+      PostSystemUsecase postSystemUsecase,
+      Queue<ViewPostEvent> queue,
+      ScheduledExecutorService executorService) {
+
+    executorService.scheduleWithFixedDelay(
+        createEventConsumerRunnable(postSystemUsecase, queue),
+        INITIAL_DELAY,
+        FIXED_DELAY,
+        TimeUnit.MILLISECONDS);
+  }
+
+  private Runnable createEventConsumerRunnable(
+      PostSystemUsecase postSystemUsecase, Queue<ViewPostEvent> queue) {
+    return () -> {
+      try {
+        List<UpdateViewCountCommand> commands = new ArrayList<>();
+        ViewPostEvent event;
+        int count = 0;
+        while ((event = queue.poll()) != null && count < THRESHOLD) {
+          log.info("View post event consumed. postId: {}", event.postId());
+          var command = new UpdateViewCountCommand(event.postId());
+          postSystemUsecase.updateViewCount(command);
+          commands.add(command);
+          count++;
+        }
+        postSystemUsecase.persistViewCountsInBatch(commands);
+      } catch (Exception e) {
+        log.error("error: {}", e.getMessage());
+      }
+    };
   }
 }

리뷰: queue가 계속 차 있는 경우에는 무한 루프에 빠지게 될 것 같네요.
@@ -3,38 +3,52 @@
 import com.flab.ccinside.api.trendingpost.application.port.ViewPostEvent;
 import com.flab.ccinside.api.trendingpost.application.port.in.PostSystemUsecase;
 import com.flab.ccinside.api.trendingpost.application.port.in.UpdateViewCountCommand;
-import jakarta.annotation.PostConstruct;
-import java.util.concurrent.BlockingQueue;
-import java.util.concurrent.ExecutorService;
-import java.util.concurrent.Executors;
-import lombok.RequiredArgsConstructor;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Queue;
+import java.util.concurrent.ScheduledExecutorService;
+import java.util.concurrent.TimeUnit;
 import lombok.extern.slf4j.Slf4j;
 import org.springframework.stereotype.Component;
 
 @Slf4j
 @Component
-@RequiredArgsConstructor
 public class InMemoryMessageQueue {
 
-  private final PostSystemUsecase postSystemUsecase;
-  private final BlockingQueue<ViewPostEvent> queue;
-  private final ExecutorService executorService = Executors.newSingleThreadExecutor();
+  private static final Long INITIAL_DELAY = 10L;
+  private static final Long FIXED_DELAY = 100L;
+  private static final int THRESHOLD = 100;
 
-  @PostConstruct
-  public void init() {
-    executorService.submit(
-        () -> {
-          try {
-            while (true) {
-              var event = queue.take();
-              log.info("View post event consumed. postId: {}", event.postId());
-              var command = new UpdateViewCountCommand(event.postId());
-              postSystemUsecase.updateViewCount(command);
-            }
-          } catch (InterruptedException e) {
-            log.error("consume error: {}", e.getMessage());
-            throw new RuntimeException(e);
-          }
-        });
+  public InMemoryMessageQueue(
+      PostSystemUsecase postSystemUsecase,
+      Queue<ViewPostEvent> queue,
+      ScheduledExecutorService executorService) {
+
+    executorService.scheduleWithFixedDelay(
+        createEventConsumerRunnable(postSystemUsecase, queue),
+        INITIAL_DELAY,
+        FIXED_DELAY,
+        TimeUnit.MILLISECONDS);
+  }
+
+  private Runnable createEventConsumerRunnable(
+      PostSystemUsecase postSystemUsecase, Queue<ViewPostEvent> queue) {
+    return () -> {
+      try {
+        List<UpdateViewCountCommand> commands = new ArrayList<>();
+        ViewPostEvent event;
+        int count = 0;
+        while ((event = queue.poll()) != null && count < THRESHOLD) {
+          log.info("View post event consumed. postId: {}", event.postId());
+          var command = new UpdateViewCountCommand(event.postId());
+          postSystemUsecase.updateViewCount(command);
+          commands.add(command);
+          count++;
+        }
+        postSystemUsecase.persistViewCountsInBatch(commands);
+      } catch (Exception e) {
+        log.error("error: {}", e.getMessage());
+      }
+    };
   }
 }

리뷰: executorService가 로직을 제대로 실행한다는 것은 어떻게 테스트 할 수 있을까요?
@@ -3,38 +3,52 @@
 import com.flab.ccinside.api.trendingpost.application.port.ViewPostEvent;
 import com.flab.ccinside.api.trendingpost.application.port.in.PostSystemUsecase;
 import com.flab.ccinside.api.trendingpost.application.port.in.UpdateViewCountCommand;
-import jakarta.annotation.PostConstruct;
-import java.util.concurrent.BlockingQueue;
-import java.util.concurrent.ExecutorService;
-import java.util.concurrent.Executors;
-import lombok.RequiredArgsConstructor;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Queue;
+import java.util.concurrent.ScheduledExecutorService;
+import java.util.concurrent.TimeUnit;
 import lombok.extern.slf4j.Slf4j;
 import org.springframework.stereotype.Component;
 
 @Slf4j
 @Component
-@RequiredArgsConstructor
 public class InMemoryMessageQueue {
 
-  private final PostSystemUsecase postSystemUsecase;
-  private final BlockingQueue<ViewPostEvent> queue;
-  private final ExecutorService executorService = Executors.newSingleThreadExecutor();
+  private static final Long INITIAL_DELAY = 10L;
+  private static final Long FIXED_DELAY = 100L;
+  private static final int THRESHOLD = 100;
 
-  @PostConstruct
-  public void init() {
-    executorService.submit(
-        () -> {
-          try {
-            while (true) {
-              var event = queue.take();
-              log.info("View post event consumed. postId: {}", event.postId());
-              var command = new UpdateViewCountCommand(event.postId());
-              postSystemUsecase.updateViewCount(command);
-            }
-          } catch (InterruptedException e) {
-            log.error("consume error: {}", e.getMessage());
-            throw new RuntimeException(e);
-          }
-        });
+  public InMemoryMessageQueue(
+      PostSystemUsecase postSystemUsecase,
+      Queue<ViewPostEvent> queue,
+      ScheduledExecutorService executorService) {
+
+    executorService.scheduleWithFixedDelay(
+        createEventConsumerRunnable(postSystemUsecase, queue),
+        INITIAL_DELAY,
+        FIXED_DELAY,
+        TimeUnit.MILLISECONDS);
+  }
+
+  private Runnable createEventConsumerRunnable(
+      PostSystemUsecase postSystemUsecase, Queue<ViewPostEvent> queue) {
+    return () -> {
+      try {
+        List<UpdateViewCountCommand> commands = new ArrayList<>();
+        ViewPostEvent event;
+        int count = 0;
+        while ((event = queue.poll()) != null && count < THRESHOLD) {
+          log.info("View post event consumed. postId: {}", event.postId());
+          var command = new UpdateViewCountCommand(event.postId());
+          postSystemUsecase.updateViewCount(command);
+          commands.add(command);
+          count++;
+        }
+        postSystemUsecase.persistViewCountsInBatch(commands);
+      } catch (Exception e) {
+        log.error("error: {}", e.getMessage());
+      }
+    };
   }
 }

리뷰: 말씀해주신대로, 비동기 프로세스 생성에 대한 부분과, 비즈니스 로직 처리에대한 부분을 분리하여 각각 테스트하도록 수정했습니다.
감사합니다 :)
@@ -3,38 +3,52 @@
 import com.flab.ccinside.api.trendingpost.application.port.ViewPostEvent;
 import com.flab.ccinside.api.trendingpost.application.port.in.PostSystemUsecase;
 import com.flab.ccinside.api.trendingpost.application.port.in.UpdateViewCountCommand;
-import jakarta.annotation.PostConstruct;
-import java.util.concurrent.BlockingQueue;
-import java.util.concurrent.ExecutorService;
-import java.util.concurrent.Executors;
-import lombok.RequiredArgsConstructor;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Queue;
+import java.util.concurrent.ScheduledExecutorService;
+import java.util.concurrent.TimeUnit;
 import lombok.extern.slf4j.Slf4j;
 import org.springframework.stereotype.Component;
 
 @Slf4j
 @Component
-@RequiredArgsConstructor
 public class InMemoryMessageQueue {
 
-  private final PostSystemUsecase postSystemUsecase;
-  private final BlockingQueue<ViewPostEvent> queue;
-  private final ExecutorService executorService = Executors.newSingleThreadExecutor();
+  private static final Long INITIAL_DELAY = 10L;
+  private static final Long FIXED_DELAY = 100L;
+  private static final int THRESHOLD = 100;
 
-  @PostConstruct
-  public void init() {
-    executorService.submit(
-        () -> {
-          try {
-            while (true) {
-              var event = queue.take();
-              log.info("View post event consumed. postId: {}", event.postId());
-              var command = new UpdateViewCountCommand(event.postId());
-              postSystemUsecase.updateViewCount(command);
-            }
-          } catch (InterruptedException e) {
-            log.error("consume error: {}", e.getMessage());
-            throw new RuntimeException(e);
-          }
-        });
+  public InMemoryMessageQueue(
+      PostSystemUsecase postSystemUsecase,
+      Queue<ViewPostEvent> queue,
+      ScheduledExecutorService executorService) {
+
+    executorService.scheduleWithFixedDelay(
+        createEventConsumerRunnable(postSystemUsecase, queue),
+        INITIAL_DELAY,
+        FIXED_DELAY,
+        TimeUnit.MILLISECONDS);
+  }
+
+  private Runnable createEventConsumerRunnable(
+      PostSystemUsecase postSystemUsecase, Queue<ViewPostEvent> queue) {
+    return () -> {
+      try {
+        List<UpdateViewCountCommand> commands = new ArrayList<>();
+        ViewPostEvent event;
+        int count = 0;
+        while ((event = queue.poll()) != null && count < THRESHOLD) {
+          log.info("View post event consumed. postId: {}", event.postId());
+          var command = new UpdateViewCountCommand(event.postId());
+          postSystemUsecase.updateViewCount(command);
+          commands.add(command);
+          count++;
+        }
+        postSystemUsecase.persistViewCountsInBatch(commands);
+      } catch (Exception e) {
+        log.error("error: {}", e.getMessage());
+      }
+    };
   }
 }

리뷰: 확인해주셔서 감사합니다.
Threshold를 설정하도록 변경했습니다 :)
@@ -0,0 +1,14 @@
+package store.model;
+
+public final class Membership {
+    private static final int DISCOUNT_PERCENTAGE = 30;
+    private static final int MAX_DISCOUNT = 8_000;
+
+    private Membership() {
+    }
+
+    public static long calculateDiscount(long amount) {
+        long discountAmount = amount * DISCOUNT_PERCENTAGE / 100;
+        return Math.min(discountAmount, MAX_DISCOUNT);
+    }
+}

리뷰: 건당 최대 할인량(8000)을 고정하고 사용하신 부분이 저랑 다르네요!

저는 어플리케이션의 한 사이클 (시작~종료) 의 최대 멤버십 할인량을 8000원이라고 보았습니다 😄 

만약 해당 방식으로 할인율 및 최대 할인액을 상수처리 해서 사용하실 것이라면, Answer 클래스처럼 enum으로 관리하면 어떨까요?
@@ -0,0 +1,59 @@
+package store.model.item;
+
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.List;
+import store.error.FileParsingException;
+import store.utils.MarkdownReader;
+
+public class ItemLoader {
+    private static final String NULL = "null";
+
+    private ItemLoader() {
+    }
+
+    public static ItemLoader getInstance() {
+        return new ItemLoader();
+    }
+
+    public List<Item> loadItems(String filePath) {
+        try {
+            List<String[]> itemData = MarkdownReader.readFile(filePath);
+            return parseItems(itemData);
+        } catch (IOException e) {
+            throw new FileParsingException(e);
+        }
+    }
+
+    private List<Item> parseItems(List<String[]> itemData) {
+        List<Item> items = new ArrayList<>();
+        for (int i = 0; i < itemData.size(); i++) {
+            Item item = parseItem(itemData.get(i));
+            items.add(item);
+            if (shouldAddNonPromotionItem(item, i, itemData)) {
+                items.add(new Item(item.getName(), item.getPrice(), 0, null));
+            }
+        }
+        return items;
+    }
+
+    private Item parseItem(String[] itemData) {
+        String name = itemData[0];
+        int price = Integer.parseInt(itemData[1]);
+        int quantity = Integer.parseInt(itemData[2]);
+        String promotionName = itemData[3];
+        if (NULL.equals(promotionName)) {
+            promotionName = null;
+        }
+        return new Item(name, price, quantity, promotionName);
+    }
+
+    private boolean shouldAddNonPromotionItem(Item currentItem, int currentIndex, List<String[]> itemData) {
+        if (currentItem.getPromotionName() == null) {
+            return false;
+        }
+        boolean isLastItem = currentIndex == itemData.size() - 1;
+        boolean isDifferentNextItem = !isLastItem && !itemData.get(currentIndex + 1)[0].equals(currentItem.getName());
+        return isLastItem || isDifferentNextItem;
+    }
+}

리뷰: 이 부분도 신경 쓰셨군요 😄 멋집니다
@@ -0,0 +1,34 @@
+package store.error;
+
+import store.model.item.Item;
+import store.model.order.OrderItem;
+
+public class PromotionConfirmationForFreeException extends RuntimeException {
+    private Item item;
+    private OrderItem orderItem;
+    private int shortfall;
+
+    public PromotionConfirmationForFreeException(Item item, OrderItem orderItem, int shortfall) {
+        super();
+        this.item = item;
+        this.orderItem = orderItem;
+        this.shortfall = shortfall;
+    }
+
+
+    public Item getItem() {
+        return item;
+    }
+
+    public OrderItem getOrderItem() {
+        return orderItem;
+    }
+
+    public int getOrderQuantity() {
+        return orderItem.getQuantity();
+    }
+
+    public int getShortfall() {
+        return shortfall;
+    }
+}

리뷰: 커스텀 예외안에 도메인 정보들이 있네요~ 아마 예외 발생 시 사용자에게 도메인 정보를 보여줘야해서 이런식으로 구현하신것 같아요~

혹시 이외에 다른 의도가 있으신지 궁금하네요 !
@@ -0,0 +1,23 @@
+package store.model;
+
+import java.util.function.Function;
+
+public enum SalesAmountType {
+    MEMBERSHIP("멤버십할인", Membership::calculateDiscount);
+
+    private final String viewName;
+    private final Function<Long, Long> expression;
+
+    SalesAmountType(String viewName, Function<Long, Long> expression) {
+        this.viewName = viewName;
+        this.expression = expression;
+    }
+
+    public long calculate(long amount) {
+        return expression.apply(amount);
+    }
+
+    public String getViewName() {
+        return viewName;
+    }
+}

리뷰: 멤버십 할인 관련 클래스를 enum으로 구현하신 의도가 궁금합니다 !
@@ -0,0 +1,81 @@
+package store.model.item;
+
+import store.error.FileContentException;
+
+public class Item {
+    private static final String KOREAN_REGEX = "^[가-힣]+$";
+    private static final int MAX_PRICE = 100_000_000;
+    public static final int MAX_QUANTITY = 999;
+
+    private final String name;
+    private final int price;
+    private final String promotionName;
+    private int quantity;
+
+    public Item(String name, int price, int quantity, String promotionName) {
+        validate(name, price, quantity);
+        this.name = name;
+        this.price = price;
+        this.quantity = quantity;
+        this.promotionName = promotionName;
+    }
+
+    private void validate(String name, int price, int quantity) {
+        validateName(name);
+        validatePrice(price);
+        validateQuantity(quantity);
+    }
+
+    private void validateName(String name) {
+        if (name == null || name.trim().isEmpty()) {
+            throw new FileContentException("[ERROR] 상품 이름이 잘못되었습니다.");
+        }
+        if (!name.matches(KOREAN_REGEX)) {
+            throw new FileContentException("[ERROR] 상품 이름은 한글만 입력할 수 있습니다.");
+        }
+    }
+
+    private void validatePrice(int price) {
+        if (price < 0) {
+            throw new FileContentException("[ERROR] 가격은 0원 이상이어야 합니다.");
+        }
+        if (price > MAX_PRICE) {
+            throw new FileContentException("[ERROR] 가격은 최대 1억원 이하까지로만 설정할 수 있습니다.");
+        }
+    }
+
+    private void validateQuantity(int quantity) {
+        if (quantity < 0) {
+            throw new FileContentException("[ERROR] 수량은 0개 이상으로만 설정할 수 있습니다.");
+        }
+        if (quantity > MAX_QUANTITY) {
+            throw new FileContentException("[ERROR] 수량은 최대 999개까지 설정할 수 있습니다.");
+        }
+    }
+
+    void decreaseQuantity(int quantity) {
+        if (quantity < 0) {
+            throw new IllegalArgumentException("[ERROR] 잘못된 입력입니다. 다시 입력해 주세요.");
+        }
+        if (this.quantity == 0) {
+            throw new IllegalArgumentException("[ERROR] 재고 수량을 초과하여 구매할 수 없습니다. 다시 입력해 주세요.");
+        }
+        this.quantity -= quantity;
+    }
+
+    public String getName() {
+        return name;
+    }
+
+    public int getPrice() {
+        return price;
+    }
+
+    public int getQuantity() {
+        return quantity;
+    }
+
+    public String getPromotionName() {
+        return promotionName;
+    }
+}

리뷰: 혹시 이 친구는 외부에서도 쓰이는 건가요??
@@ -0,0 +1,81 @@
+package store.model.item;
+
+import store.error.FileContentException;
+
+public class Item {
+    private static final String KOREAN_REGEX = "^[가-힣]+$";
+    private static final int MAX_PRICE = 100_000_000;
+    public static final int MAX_QUANTITY = 999;
+
+    private final String name;
+    private final int price;
+    private final String promotionName;
+    private int quantity;
+
+    public Item(String name, int price, int quantity, String promotionName) {
+        validate(name, price, quantity);
+        this.name = name;
+        this.price = price;
+        this.quantity = quantity;
+        this.promotionName = promotionName;
+    }
+
+    private void validate(String name, int price, int quantity) {
+        validateName(name);
+        validatePrice(price);
+        validateQuantity(quantity);
+    }
+
+    private void validateName(String name) {
+        if (name == null || name.trim().isEmpty()) {
+            throw new FileContentException("[ERROR] 상품 이름이 잘못되었습니다.");
+        }
+        if (!name.matches(KOREAN_REGEX)) {
+            throw new FileContentException("[ERROR] 상품 이름은 한글만 입력할 수 있습니다.");
+        }
+    }
+
+    private void validatePrice(int price) {
+        if (price < 0) {
+            throw new FileContentException("[ERROR] 가격은 0원 이상이어야 합니다.");
+        }
+        if (price > MAX_PRICE) {
+            throw new FileContentException("[ERROR] 가격은 최대 1억원 이하까지로만 설정할 수 있습니다.");
+        }
+    }
+
+    private void validateQuantity(int quantity) {
+        if (quantity < 0) {
+            throw new FileContentException("[ERROR] 수량은 0개 이상으로만 설정할 수 있습니다.");
+        }
+        if (quantity > MAX_QUANTITY) {
+            throw new FileContentException("[ERROR] 수량은 최대 999개까지 설정할 수 있습니다.");
+        }
+    }
+
+    void decreaseQuantity(int quantity) {
+        if (quantity < 0) {
+            throw new IllegalArgumentException("[ERROR] 잘못된 입력입니다. 다시 입력해 주세요.");
+        }
+        if (this.quantity == 0) {
+            throw new IllegalArgumentException("[ERROR] 재고 수량을 초과하여 구매할 수 없습니다. 다시 입력해 주세요.");
+        }
+        this.quantity -= quantity;
+    }
+
+    public String getName() {
+        return name;
+    }
+
+    public int getPrice() {
+        return price;
+    }
+
+    public int getQuantity() {
+        return quantity;
+    }
+
+    public String getPromotionName() {
+        return promotionName;
+    }
+}

리뷰: 가격과 수량 제한을 두신 이유가 있으실것 같아요~ 그리고 수치는 어떤 기준으로 정하신건가요 ??
@@ -0,0 +1,76 @@
+package store.model.order;
+
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+
+public class OrderItem {
+    private static final String DELIMITER = "-";
+    private static final String REGEX = "[\\[\\]]";
+    private static final Pattern EACH_PATTERN = Pattern.compile("\\[([가-힣]+)-(\\d+)]");
+    private static final String EMPTY = "";
+
+    private final String name;
+    private final int quantity;
+    private int promotionAppliedQuantity = 0;
+    private int totalOrderQuantity = 0;
+    private int nonPromotionQuantity = 0;
+    private int price = 0;
+
+    public OrderItem(String item) {
+        validate(item);
+        String[] itemData = parse(item);
+        this.name = itemData[0];
+        this.quantity = Integer.parseInt(itemData[1]);
+    }
+
+    private void validate(String item) {
+        Matcher matcher = EACH_PATTERN.matcher(item);
+        if (!matcher.matches()) {
+            throw new IllegalArgumentException("올바르지 않은 형식으로 입력했습니다. 다시 입력해 주세요.");
+        }
+    }
+
+    private String[] parse(String item) {
+        return item.replaceAll(REGEX, EMPTY).split(DELIMITER);
+    }
+
+    public String getName() {
+        return name;
+    }
+
+    public int getQuantity() {
+        return quantity;
+    }
+
+    public void increasePromotionAppliedQuantity(int quantity) {
+        promotionAppliedQuantity += quantity;
+    }
+
+    public void increaseNonPromotionQuantity(int quantity) {
+        nonPromotionQuantity += quantity;
+    }
+
+    public int getNonPromotionQuantity() {
+        return nonPromotionQuantity;
+    }
+
+    public int getPromotionAppliedQuantity() {
+        return promotionAppliedQuantity;
+    }
+
+    public void increaseTotalOrderQuantity(int quantity) {
+        totalOrderQuantity += quantity;
+    }
+
+    public int getTotalOrderQuantity() {
+        return totalOrderQuantity;
+    }
+
+    public void setPrice(int price) {
+        this.price = price;
+    }
+
+    public int getPrice() {
+        return this.price;
+    }
+}

리뷰: 저는 필드가 많아지면 서로 관련이 있는 요소들은 VO로 감싸거든요~

이에 대해서 철원님 생각은 어떠신가요 ??
@@ -0,0 +1,23 @@
+package store.utils;
+
+import java.io.BufferedReader;
+import java.io.FileReader;
+import java.io.IOException;
+import java.util.List;
+
+public final class MarkdownReader {
+    private static final String DELIMITER = ",";
+    private static final int LINES_TO_SKIP = 1;
+
+    private MarkdownReader() {
+    }
+
+    public static List<String[]> readFile(String filePath) throws IOException {
+        try (BufferedReader br = new BufferedReader(new FileReader(filePath))) {
+            return br.lines()
+                    .skip(LINES_TO_SKIP)
+                    .map(line -> line.split(DELIMITER))
+                    .toList();
+        }
+    }
+}

리뷰: 리더를 따로 분리하신거 좋네요 ㅎㅎ