ewhk9887/korean_code_reviews_from_github huggingface dataset:
@@ -0,0 +1,30 @@
+package io.study.springbootlayered.api.member.domain.dto;
+
+import java.util.List;
+
+import io.study.springbootlayered.api.member.domain.entity.AuthorityType;
+import io.study.springbootlayered.api.member.domain.entity.Member;
+import io.study.springbootlayered.api.member.domain.entity.MemberAuthority;
+import lombok.AccessLevel;
+import lombok.Getter;
+import lombok.NoArgsConstructor;
+import lombok.RequiredArgsConstructor;
+
+@NoArgsConstructor(access = AccessLevel.PRIVATE)
+public class MemberDetailDto {
+
+    @Getter
+    @RequiredArgsConstructor
+    public static class Info {
+        private final String email;
+        private final String nickname;
+        private final List<AuthorityType> roles;
+
+        public static MemberDetailDto.Info of(Member member) {
+            List<AuthorityType> roles = member.getAuthorities().stream()
+                .map(MemberAuthority::getAuthority)
+                .toList();
+            return new MemberDetailDto.Info(member.getEmail(), member.getNickname(), roles);
+        }
+    }
+}

리뷰: Gradle 설정에 의하면 Java 버전이 21인데, 충분히 Record로 대체 가능한 코드로 보입니다.
@@ -0,0 +1,30 @@
+package io.study.springbootlayered.api.member.domain.dto;
+
+import java.util.List;
+
+import io.study.springbootlayered.api.member.domain.entity.AuthorityType;
+import io.study.springbootlayered.api.member.domain.entity.Member;
+import io.study.springbootlayered.api.member.domain.entity.MemberAuthority;
+import lombok.AccessLevel;
+import lombok.Getter;
+import lombok.NoArgsConstructor;
+import lombok.RequiredArgsConstructor;
+
+@NoArgsConstructor(access = AccessLevel.PRIVATE)
+public class MemberDetailDto {
+
+    @Getter
+    @RequiredArgsConstructor
+    public static class Info {
+        private final String email;
+        private final String nickname;
+        private final List<AuthorityType> roles;
+
+        public static MemberDetailDto.Info of(Member member) {
+            List<AuthorityType> roles = member.getAuthorities().stream()
+                .map(MemberAuthority::getAuthority)
+                .toList();
+            return new MemberDetailDto.Info(member.getEmail(), member.getNickname(), roles);
+        }
+    }
+}

리뷰: Validation 을 사용해서 빈값이 들어오지 않도록 하면 좋겠네요.
@@ -0,0 +1,19 @@
+package io.study.springbootlayered.api.member.domain.dto;
+
+import lombok.AccessLevel;
+import lombok.Builder;
+import lombok.Getter;
+import lombok.NoArgsConstructor;
+import lombok.RequiredArgsConstructor;
+
+@NoArgsConstructor(access = AccessLevel.PRIVATE)
+public class MemberPasswordResetDto {
+
+    @Getter
+    @Builder
+    @RequiredArgsConstructor
+    public static class Command {
+        private final String email;
+        private final String password;
+    }
+}

리뷰: 필드가 두개인데 굳이 빌더를 사용해야 하나 싶은데요,
당장 바로 아래의 MemberSigninDto 에서는 `@Builder`가 없네요.
@@ -0,0 +1,19 @@
+package io.study.springbootlayered.api.member.domain.dto;
+
+import lombok.AccessLevel;
+import lombok.Builder;
+import lombok.Getter;
+import lombok.NoArgsConstructor;
+import lombok.RequiredArgsConstructor;
+
+@NoArgsConstructor(access = AccessLevel.PRIVATE)
+public class MemberPasswordResetDto {
+
+    @Getter
+    @Builder
+    @RequiredArgsConstructor
+    public static class Command {
+        private final String email;
+        private final String password;
+    }
+}

리뷰: 모든 Dto가 내부적으로 또 Dto를 갖고 있는 이상한 구조네요...
그냥 서로 다른 파일로 분리하는게 가독성 측면에서 더 낫지 않을까요?

특히나 내부적으로 같은 이름을 쓰고 있기 때문에, 행여나 실수로 Static Import를 하는 순간 이게 어디에서 온건지 파악할 방법이 아예 없어집니다.
@@ -0,0 +1,32 @@
+package io.study.springbootlayered.api.member.application;
+
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+import io.study.springbootlayered.api.member.domain.MemberProcessor;
+import io.study.springbootlayered.api.member.domain.dto.MemberSignupDto;
+import io.study.springbootlayered.api.member.domain.event.SignupEvent;
+import io.study.springbootlayered.web.base.Events;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+
+@Slf4j
+@Service
+@RequiredArgsConstructor
+@Transactional(readOnly = true)
+public class MemberSignupService {
+
+    private final MemberProcessor memberProcessor;
+
+    @Transactional
+    public MemberSignupDto.Info signup(final MemberSignupDto.Command request) {
+        /** 회원 가입 **/
+        MemberSignupDto.Info info = memberProcessor.register(request);
+
+        /** 회원가입 완료 후 이메일 전송 **/
+        String registeredEmail = info.getEmail();
+        Events.raise(SignupEvent.of(registeredEmail));
+
+        return info;
+    }
+}

리뷰: 인라인 주석은 // 가 좋겠죠?
@@ -0,0 +1,23 @@
+package io.study.springbootlayered.api.member.domain.entity;
+
+import jakarta.persistence.Column;
+import jakarta.persistence.Embeddable;
+import lombok.EqualsAndHashCode;
+import lombok.Getter;
+
+@Embeddable
+@Getter
+@EqualsAndHashCode
+public class MemberPassword {
+
+    @Column(name = "password", nullable = false)
+    private String value;
+
+    protected MemberPassword() {
+    }
+
+    public MemberPassword(final String value) {
+        this.value = value;
+    }
+
+}

리뷰: 위에는 Equals와 hashCode를 직접 정의하더니, 이번에는 롬복을 사용하네요?
@@ -0,0 +1,42 @@
+package io.study.springbootlayered.api.member.domain.event;
+
+import org.springframework.scheduling.annotation.Async;
+import org.springframework.stereotype.Component;
+import org.springframework.transaction.event.TransactionalEventListener;
+
+import io.study.springbootlayered.infra.mail.MailService;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+
+@Slf4j
+@Component
+@RequiredArgsConstructor
+public class MemberEventListener {
+
+    private final MailService mailService;
+
+    @Async
+    @TransactionalEventListener
+    public void signupEventListener(final SignupEvent event) {
+        log.info("MemberEventListener.signupEventListener !!");
+
+        String[] toEmail = toEmailArray(event.getEmail());
+        mailService.sendMail(toEmail, "회원가입 완료 안내", "회원가입이 완료되었습니다.");
+    }
+
+    private String[] toEmailArray(final String... email) {
+        return email;
+    }
+
+    @Async
+    @TransactionalEventListener
+    public void resetPasswordEventListener(final ResetPasswordEvent event) {
+        log.info("MemberEventListener.resetPasswordEventListener !!");
+
+        String[] toEmail = toEmailArray(event.getEmail());
+        String password = event.getTempPassword();
+
+        mailService.sendMail(toEmail, "임시 비밀번호 발급 안내", "임시 비밀번호 : " + password);
+    }
+
+}

리뷰: 일반적으로 개발 환경에서는 log level을 debug로, 운영 환경에서는 info/warn 수준으로 설정합니다.

운영 환경에서 해당 요청이 들어올 "때 마다" 해당 로그가 출력되게 되면, 에러 디버깅 과정에서 매우 번거로워질 확률이 높습니다.

개발 과정에서의 디버깅용인지, 운영 환경에서 실제로 필요한 로그인지 잘 생각해보시고 로그 레벨을 지정하는게 좋습니다.
@@ -0,0 +1,15 @@
+package io.study.springbootlayered.api.member.domain.event;
+
+import lombok.Getter;
+import lombok.RequiredArgsConstructor;
+
+@Getter
+@RequiredArgsConstructor
+public class SignupEvent {
+
+    private final String email;
+
+    public static SignupEvent of(String email) {
+        return new SignupEvent(email);
+    }
+}

리뷰: 생성자랑 역할이 똑같네요.
@@ -0,0 +1,63 @@
+package io.study.springbootlayered.api.member.domain;
+
+import org.springframework.security.crypto.password.PasswordEncoder;
+import org.springframework.stereotype.Component;
+
+import io.study.springbootlayered.api.member.domain.dto.MemberDetailDto;
+import io.study.springbootlayered.api.member.domain.dto.MemberPasswordResetDto;
+import io.study.springbootlayered.api.member.domain.dto.MemberSignupDto;
+import io.study.springbootlayered.api.member.domain.entity.Member;
+import io.study.springbootlayered.api.member.domain.repository.MemberQueryRepository;
+import io.study.springbootlayered.api.member.domain.repository.MemberRepository;
+import io.study.springbootlayered.api.member.domain.validation.MemberValidator;
+import io.study.springbootlayered.web.exception.ApiException;
+import io.study.springbootlayered.web.exception.error.MemberErrorCode;
+import lombok.RequiredArgsConstructor;
+
+@Component
+@RequiredArgsConstructor
+public class MemberProcessorImpl implements MemberProcessor {
+
+    private final MemberQueryRepository memberQueryRepository;
+    private final MemberRepository memberRepository;
+    private final PasswordEncoder passwordEncoder;
+    private final MemberValidator memberValidator;
+
+    @Override
+    public MemberSignupDto.Info register(final MemberSignupDto.Command request) {
+        memberValidator.signinValidate(request);
+        Member initBasicMember = Member.createBasicMember(request.getEmail(), request.getNickname(),
+            encodePassword(request.getPassword()));
+        Member savedMember = memberRepository.save(initBasicMember);
+
+        return new MemberSignupDto.Info(savedMember.getEmail());
+    }
+
+    @Override
+    public MemberDetailDto.Info getMember(final Long memberId) {
+        Member findMember = findById(memberId);
+
+        return MemberDetailDto.Info.of(findMember);
+    }
+
+    @Override
+    public void resetPassword(final MemberPasswordResetDto.Command command) {
+        Member findMember = findByEmail(command.getEmail());
+        findMember.changePassword(encodePassword(command.getPassword()));
+    }
+
+    private Member findById(final Long memberId) {
+        return memberQueryRepository.findById(memberId)
+            .orElseThrow(() -> new ApiException(MemberErrorCode.NOT_FOUND_MEMBER));
+    }
+
+    private Member findByEmail(final String email) {
+        return memberQueryRepository.findByEmail(email)
+            .orElseThrow(() -> new ApiException(MemberErrorCode.NOT_FOUND_MEMBER));
+    }
+
+    private String encodePassword(final String password) {
+        return passwordEncoder.encode(password);
+    }
+
+}

리뷰: ~~Impl 형식의 클래스의 존재 여부를 이해하긴 어렵네요.

- 다른 구현체의 가능성이 존재하나요?
- 위의 다른 Processor는 막상 타 클래스를 상속하는 구조라 Impl을 안 달고 있네요.
@@ -0,0 +1,63 @@
+package io.study.springbootlayered.api.member.domain;
+
+import org.springframework.security.crypto.password.PasswordEncoder;
+import org.springframework.stereotype.Component;
+
+import io.study.springbootlayered.api.member.domain.dto.MemberDetailDto;
+import io.study.springbootlayered.api.member.domain.dto.MemberPasswordResetDto;
+import io.study.springbootlayered.api.member.domain.dto.MemberSignupDto;
+import io.study.springbootlayered.api.member.domain.entity.Member;
+import io.study.springbootlayered.api.member.domain.repository.MemberQueryRepository;
+import io.study.springbootlayered.api.member.domain.repository.MemberRepository;
+import io.study.springbootlayered.api.member.domain.validation.MemberValidator;
+import io.study.springbootlayered.web.exception.ApiException;
+import io.study.springbootlayered.web.exception.error.MemberErrorCode;
+import lombok.RequiredArgsConstructor;
+
+@Component
+@RequiredArgsConstructor
+public class MemberProcessorImpl implements MemberProcessor {
+
+    private final MemberQueryRepository memberQueryRepository;
+    private final MemberRepository memberRepository;
+    private final PasswordEncoder passwordEncoder;
+    private final MemberValidator memberValidator;
+
+    @Override
+    public MemberSignupDto.Info register(final MemberSignupDto.Command request) {
+        memberValidator.signinValidate(request);
+        Member initBasicMember = Member.createBasicMember(request.getEmail(), request.getNickname(),
+            encodePassword(request.getPassword()));
+        Member savedMember = memberRepository.save(initBasicMember);
+
+        return new MemberSignupDto.Info(savedMember.getEmail());
+    }
+
+    @Override
+    public MemberDetailDto.Info getMember(final Long memberId) {
+        Member findMember = findById(memberId);
+
+        return MemberDetailDto.Info.of(findMember);
+    }
+
+    @Override
+    public void resetPassword(final MemberPasswordResetDto.Command command) {
+        Member findMember = findByEmail(command.getEmail());
+        findMember.changePassword(encodePassword(command.getPassword()));
+    }
+
+    private Member findById(final Long memberId) {
+        return memberQueryRepository.findById(memberId)
+            .orElseThrow(() -> new ApiException(MemberErrorCode.NOT_FOUND_MEMBER));
+    }
+
+    private Member findByEmail(final String email) {
+        return memberQueryRepository.findByEmail(email)
+            .orElseThrow(() -> new ApiException(MemberErrorCode.NOT_FOUND_MEMBER));
+    }
+
+    private String encodePassword(final String password) {
+        return passwordEncoder.encode(password);
+    }
+
+}

리뷰: 해당 쿼리가 다른 곳에서 재사용되지 않을 것이라는 보장이 있나요?
이런 기본적인 Validation 용 쿼리는 분리해서 다루는게 좋을 것 같아요.
@@ -0,0 +1,32 @@
+package io.study.springbootlayered.api.member.application;
+
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+import io.study.springbootlayered.api.member.domain.MemberProcessor;
+import io.study.springbootlayered.api.member.domain.dto.MemberSignupDto;
+import io.study.springbootlayered.api.member.domain.event.SignupEvent;
+import io.study.springbootlayered.web.base.Events;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+
+@Slf4j
+@Service
+@RequiredArgsConstructor
+@Transactional(readOnly = true)
+public class MemberSignupService {
+
+    private final MemberProcessor memberProcessor;
+
+    @Transactional
+    public MemberSignupDto.Info signup(final MemberSignupDto.Command request) {
+        /** 회원 가입 **/
+        MemberSignupDto.Info info = memberProcessor.register(request);
+
+        /** 회원가입 완료 후 이메일 전송 **/
+        String registeredEmail = info.getEmail();
+        Events.raise(SignupEvent.of(registeredEmail));
+
+        return info;
+    }
+}

리뷰: 로그 미사용인데 해당 어노테이션 사용하고 있네요.
@@ -0,0 +1,66 @@
+package christmas.domain;
+
+import static christmas.exception.ErrorMessage.DAY_NOT_IN_RANGE;
+import static christmas.exception.ErrorMessage.ENDS_WITH_DELIMITER;
+
+import christmas.domain.constant.EventConstraint;
+import christmas.exception.OrderException;
+
+public class Day {
+    private final int day;
+
+    private Day(int day) {
+        this.day = day;
+        validate();
+    }
+
+    public static Day of(int day){
+        return new Day(day);
+    }
+
+    public int getDDayDiscountAmount(){
+        if(isBeforeDDay()){
+            return EventConstraint.INITIAL_D_DAY_DISCOUNT_AMOUNT.getValue() + (day-1) * 100;
+        }
+        return 0;
+    }
+
+    public boolean isBeforeDDay(){
+        return day <= EventConstraint.D_DAY.getValue();
+    }
+
+    public boolean isWeekDay(){
+        for (int i = 1; i <= 30; i += 7) {
+            if (day == i || day == i + 1) {
+                return false;
+            }
+        }
+        return true;
+    }
+
+    public boolean isWeekEnd(){
+        for (int i = 1; i <= 30; i += 7) {
+            if (day == i || day == i + 1) {
+                return true;
+            }
+        }
+        return false;
+    }
+
+    public boolean hasStar(){
+        if(day == 25)return true;
+        for(int i = 3; i <= 31; i += 7){
+            if(day == i)return true;
+        }
+        return false;
+    }
+
+    public void validate(){
+        validateEventPeriod();
+    }
+    private void validateEventPeriod(){
+        if (day < EventConstraint.MIN_DAY.getValue() || day > EventConstraint.MAX_DAY.getValue()) {
+            throw OrderException.from(DAY_NOT_IN_RANGE);
+        }
+    }
+}

리뷰: EventConstraint에서 선언한 상수를 가져다 쓰지 않은 이유가 있나요?
@@ -0,0 +1,64 @@
+package christmas.domain;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+import christmas.domain.constant.EventConstraint;
+import christmas.exception.ErrorMessage;
+import christmas.exception.OrderException;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+
+public class DayTest {
+
+    @DisplayName("Day 객체를 올바르게 생성하는지 테스트")
+    @Test
+    void createDay() {
+        assertThat(Day.of(5)).isNotNull();
+    }
+
+    @DisplayName("Day 객체를 생성할 때 day가 유효한 범위 내인지 확인")
+    @Test
+    void createDayWithInvalidDay() {
+        assertThatThrownBy(() -> Day.of(40))
+                .isInstanceOf(OrderException.class)
+                .hasMessageContaining(ErrorMessage.DAY_NOT_IN_RANGE.getMessage());
+    }
+
+    @DisplayName("getDDayDiscountAmount 메서드 테스트")
+    @Test
+    void getDDayDiscountAmount() {
+        assertThat(Day.of(24).getDDayDiscountAmount()).isEqualTo(
+                EventConstraint.INITIAL_D_DAY_DISCOUNT_AMOUNT.getValue() + (24-1) * 100);
+        assertThat(Day.of(26).getDDayDiscountAmount()).isEqualTo(0);
+    }
+
+    @DisplayName("isBeforeDDay 메서드 테스트")
+    @Test
+    void isBeforeDDay() {
+        assertThat(Day.of(24).isBeforeDDay()).isTrue();
+        assertThat(Day.of(26).isBeforeDDay()).isFalse();
+    }
+
+    @DisplayName("isWeekDay 메서드 테스트")
+    @Test
+    void isWeekDay() {
+        assertThat(Day.of(5).isWeekDay()).isTrue();
+        assertThat(Day.of(8).isWeekDay()).isFalse();
+    }
+
+    @DisplayName("isWeekEnd 메서드 테스트")
+    @Test
+    void isWeekEnd() {
+        assertThat(Day.of(5).isWeekEnd()).isFalse();
+        assertThat(Day.of(8).isWeekEnd()).isTrue();
+    }
+
+    @DisplayName("hasStar 메서드 테스트")
+    @Test
+    void hasStar() {
+        assertThat(Day.of(25).hasStar()).isTrue();
+        assertThat(Day.of(26).hasStar()).isFalse();
+    }
+}
+

리뷰: @ParamiterizedTest의 어노테이션을 활용해서 중복을 줄일 수 있을거 같습니다.
@@ -0,0 +1,5 @@
+const API_URL = `${import.meta.env.VITE_API_URL}`;
+
+export const PRODUCTS_ENDPOINT = `${API_URL}/products`;
+export const CART_ITEMS_ENDPOINT = `${API_URL}/cart-items`;
+export const CART_ITEMS_COUNTS_ENDPOINT = `${CART_ITEMS_ENDPOINT}/counts`;

리뷰: 이런 endpoint들을 하나의 객체로 관리해도 좋을 것 같아요!
@@ -0,0 +1,49 @@
+const generateBasicToken = (userId: string, userPassword: string): string => {
+  const token = btoa(`${userId}:${userPassword}`);
+  return `Basic ${token}`;
+};
+
+const API_URL = `${import.meta.env.VITE_API_URL}`;
+const USER_ID = `${import.meta.env.VITE_USER_ID}`;
+const USER_PASSWORD = `${import.meta.env.VITE_USER_PASSWORD}`;
+
+if (!API_URL || !USER_ID || !USER_PASSWORD) {
+  throw new Error(
+    "API_URL, USER_ID, PASSWORD environment variables are not set"
+  );
+}
+
+type HttpMethod = "GET" | "POST" | "PATCH" | "DELETE";
+
+interface RequestOptions {
+  method: HttpMethod;
+  body?: Record<string, unknown>;
+}
+
+export const fetchWithAuth = async (path: string, options: RequestOptions) => {
+  const requestInit = requestBuilder(options);
+  const response = await fetch(path, requestInit);
+
+  if (!response.ok) {
+    throw new Error(`Failed to ${options.method} ${path}`);
+  }
+
+  return response;
+};
+
+const requestBuilder = (options: RequestOptions): RequestInit => {
+  const token = generateBasicToken(USER_ID, USER_PASSWORD);
+
+  const { method, body } = options;
+
+  const headers: HeadersInit = {
+    "Content-Type": "application/json",
+    Authorization: token,
+  };
+
+  return {
+    method,
+    headers,
+    body: body ? JSON.stringify(body) : undefined,
+  };
+};

리뷰: fetch에 필요한 중복된 로직을 분리한 부분. 인상적입니다.
@@ -0,0 +1,25 @@
+import { useEffect } from "react";
+import { useErrorContext } from "../../hooks/useErrorContext";
+import { ErrorToastStyle } from "./ErrorToast.style";
+
+const ErrorToast = () => {
+  const { error, hideError } = useErrorContext();
+
+  useEffect(() => {
+    setTimeout(() => {
+      hideError();
+    }, 3000);
+  }, [error, hideError]);
+
+  if (!error) {
+    return null;
+  }
+
+  return (
+    <ErrorToastStyle>
+      오류가 발생했습니다. 잠시 후 다시 시도해 주세요.
+    </ErrorToastStyle>
+  );
+};
+
+export default ErrorToast;

리뷰: toast를 띄어줄 시간을 따로 상수로 관리하는 것도 좋을 것 같아요
@@ -0,0 +1,51 @@
+import useProducts from "../../hooks/useProducts";
+import ProductListHeader from "../ProductListHeader/ProductListHeader";
+import ProductItem from "./ProductItem/ProductItem";
+import * as PL from "./ProductList.style";
+import useInfiniteScroll from "../../hooks/useInfiniteScroll";
+import usePagination from "../../hooks/usePagination";
+
+const ProductList = () => {
+  const { page, nextPage, resetPage } = usePagination();
+
+  const { products, loading, hasMore, handleCategory, handleSort } =
+    useProducts({
+      page,
+      resetPage,
+    });
+
+  const { lastElementRef: lastProductElementRef } = useInfiniteScroll({
+    hasMore,
+    loading,
+    nextPage,
+  });
+
+  return (
+    <>
+      <ProductListHeader
+        handleCategory={handleCategory}
+        handleSort={handleSort}
+      />
+      {!loading && products.length === 0 ? (
+        <PL.Empty>상품이 존재하지 않습니다! 🥲</PL.Empty>
+      ) : (
+        <PL.ProductListStyle>
+          {products.map((item, index) => {
+            return (
+              <ProductItem
+                product={item}
+                key={item.id}
+                ref={
+                  index === products.length - 1 ? lastProductElementRef : null
+                }
+              />
+            );
+          })}
+        </PL.ProductListStyle>
+      )}
+      {loading && <PL.Loading>로딩중! 💪</PL.Loading>}
+    </>
+  );
+};
+
+export default ProductList;

리뷰: 현재 새로운 fetch를 통해 새로운 데이터를 가지고 오면 스크롤 위치가 강제로 올라가는 버그가 있습니다. 제가 생각하기에는 ProductListStyle가 조건부로 렌더링되어 컴포넌트가 매번 새롭게 만들어집니다. 그래서 리액트가 동일한 컴포넌트로 인지하지 못해서 발생한 문제 같습니다. 다음과 같은 코드로 문제를 해결할 수 있을 것 같습니다.
```tsx
return (
    <>
      <ProductListHeader
        handleCategory={handleCategory}
        handleSort={handleSort}
      />
      {products !== null && (
        <PL.ProductListStyle>
          {products.map((item, index) => {
            return (
              <ProductItem
                product={item}
                key={`${item.id}${index}`}
              />
            );
          })}
        </PL.ProductListStyle>
      )}
      {!loading && products.length === 0 && (
        <PL.Empty>상품이 존재하지 않습니다! 🥲</PL.Empty>
      )}
      {loading && <PL.Loading>로딩중! 💪</PL.Loading>}
      {!loading && (
        <div
          ref={lastProductElementRef}
          style={{ height: '30px', fontSize: '5rem' }}
        ></div>
      )}
    </>
  );
};
```
이런 방식으로 렌더링하게되면 ProductListStyle위치는 products가 null이 아닌 이상에야 계속 같은 위치에 존재하므로 스크롤이 위로 올라가지 않습니다.
@@ -0,0 +1,49 @@
+const generateBasicToken = (userId: string, userPassword: string): string => {
+  const token = btoa(`${userId}:${userPassword}`);
+  return `Basic ${token}`;
+};
+
+const API_URL = `${import.meta.env.VITE_API_URL}`;
+const USER_ID = `${import.meta.env.VITE_USER_ID}`;
+const USER_PASSWORD = `${import.meta.env.VITE_USER_PASSWORD}`;
+
+if (!API_URL || !USER_ID || !USER_PASSWORD) {
+  throw new Error(
+    "API_URL, USER_ID, PASSWORD environment variables are not set"
+  );
+}
+
+type HttpMethod = "GET" | "POST" | "PATCH" | "DELETE";
+
+interface RequestOptions {
+  method: HttpMethod;
+  body?: Record<string, unknown>;
+}
+
+export const fetchWithAuth = async (path: string, options: RequestOptions) => {
+  const requestInit = requestBuilder(options);
+  const response = await fetch(path, requestInit);
+
+  if (!response.ok) {
+    throw new Error(`Failed to ${options.method} ${path}`);
+  }
+
+  return response;
+};
+
+const requestBuilder = (options: RequestOptions): RequestInit => {
+  const token = generateBasicToken(USER_ID, USER_PASSWORD);
+
+  const { method, body } = options;
+
+  const headers: HeadersInit = {
+    "Content-Type": "application/json",
+    Authorization: token,
+  };
+
+  return {
+    method,
+    headers,
+    body: body ? JSON.stringify(body) : undefined,
+  };
+};

리뷰: fetch의 공통된 로직을 두번이나 감싸서 깔끔하게 정리해 준 것이 마음에 드네요~!
제 미션에도 적용시켜봐야겠어요 ㅋㅋㅋ 🚀 🚀🚀🚀