ewhk9887/korean_code_reviews_from_github huggingface dataset:
@@ -0,0 +1,24 @@
+import {IMAGE_BASE_URL} from '../../constants/movie';
+import filledStar from '../../assets/star_filled.png';
+import {Movie} from '../../types/movie';
+import * as S from './styles';
+import {toOneDecimalPlace} from '../../utils/number';
+
+export type MovieItemProps = Pick<Movie, 'id' | 'title' | 'poster_path' | 'vote_average'>;
+
+const MovieItem = ({id, title, poster_path, vote_average}: MovieItemProps) => (
+  <S.MovieItem id={String(id)}>
+    <a href="#">
+      <S.MovieContent>
+        <S.Thumbnail src={`${IMAGE_BASE_URL}${poster_path}`} loading="lazy" alt={`${title}-포스터`} />
+        <S.Title>{title}</S.Title>
+        <S.ScoreContainer>
+          <S.Score>{toOneDecimalPlace(vote_average)}</S.Score>
+          <S.StarImg src={filledStar} alt="" />
+        </S.ScoreContainer>
+      </S.MovieContent>
+    </a>
+  </S.MovieItem>
+);
+
+export default MovieItem;

리뷰: 오 Pick을 사용했군요! 올리는 Pick과 Omit의 사용 기준이 있을까요? 저는 Omit을 주로 사용하는 편이라서요!

그리고 MovieItemProps는 다른 곳에서 사용되지 않기 때문에 export가 없어도 될 것 같슴다!
@@ -0,0 +1,24 @@
+import {IMAGE_BASE_URL} from '../../constants/movie';
+import filledStar from '../../assets/star_filled.png';
+import {Movie} from '../../types/movie';
+import * as S from './styles';
+import {toOneDecimalPlace} from '../../utils/number';
+
+export type MovieItemProps = Pick<Movie, 'id' | 'title' | 'poster_path' | 'vote_average'>;
+
+const MovieItem = ({id, title, poster_path, vote_average}: MovieItemProps) => (
+  <S.MovieItem id={String(id)}>
+    <a href="#">
+      <S.MovieContent>
+        <S.Thumbnail src={`${IMAGE_BASE_URL}${poster_path}`} loading="lazy" alt={`${title}-포스터`} />
+        <S.Title>{title}</S.Title>
+        <S.ScoreContainer>
+          <S.Score>{toOneDecimalPlace(vote_average)}</S.Score>
+          <S.StarImg src={filledStar} alt="" />
+        </S.ScoreContainer>
+      </S.MovieContent>
+    </a>
+  </S.MovieItem>
+);
+
+export default MovieItem;

리뷰: id는 li태그의 id인 걸까요? id태그를 넣어준 이유가 궁금해요!
@@ -0,0 +1,66 @@
+import {useAtomValue} from 'jotai';
+import useGetList from '../../hooks/useGetList';
+import useInfiniteScroll from '../../hooks/useInfiniteScroll';
+import * as S from './styles';
+import MovieItem from '../MovieItem';
+import SkeletonMovieList from '../skeleton/MovieList';
+import {searchTextAtom} from '../../jotai/atoms';
+
+interface MovieListProps {
+  openModal: (movieId: number) => void;
+}
+
+const MovieList = ({openModal}: MovieListProps) => {
+  const {movies, hasNextPage, fetchNextPage, isLoading, isFetchingNextPage} = useGetList();
+  const searchText = useAtomValue(searchTextAtom);
+
+  const lastElementRef = useInfiniteScroll({
+    fetchNextPage,
+    isLoading,
+    isLastPage: !hasNextPage,
+  });
+
+  const handleItemClick = (event: React.MouseEvent<HTMLUListElement>) => {
+    event.preventDefault();
+
+    const listItem = (event.target as HTMLElement).closest('li');
+    if (!listItem) return;
+
+    const id = Number(listItem.id);
+    const selectedItem = movies.find(item => item.id === id);
+
+    if (selectedItem) openModal(id);
+  };
+
+  return (
+    <S.MovieList onClick={handleItemClick}>
+      {isLoading ? (
+        <SkeletonMovieList />
+      ) : (
+        movies.map((movie, index) => {
+          const isLastMovie = index === movies.length - 1;
+
+          return (
+            <div
+              key={movie.id}
+              ref={isLastMovie ? lastElementRef : null} // 마지막 요소에 ref 연결
+            >
+              <MovieItem
+                key={movie.id}
+                id={movie.id}
+                title={movie.title}
+                poster_path={movie.poster_path}
+                vote_average={movie.vote_average}
+              />
+            </div>
+          );
+        })
+      )}
+      {isFetchingNextPage && <SkeletonMovieList />}
+
+      {searchText && movies.length === 0 && <S.Message>검색 결과가 없습니다.</S.Message>}
+    </S.MovieList>
+  );
+};
+
+export default MovieList;

리뷰: infiniteQuery에서 새로운 배열을 기존 배열에 넣어주고 있기 때문에 이 부분은 없어도 될 것 같아요~!
@@ -0,0 +1,30 @@
+import styled from '@emotion/styled';
+import searchImg from '../../assets/search_button.png';
+
+export const Search = styled.section`
+  display: flex;
+  height: 3rem;
+
+  background: #fff;
+  padding: 8px;
+  border-radius: 4px;
+
+  button {
+    width: 14px;
+    border: 0;
+    text-indent: -1000rem;
+    background: url(${searchImg}) no-repeat center center;
+    background-size: contain;
+  }
+`;
+
+export const SearchInput = styled.input`
+  font-size: 13px;
+
+  border: 0;
+
+  &:focus {
+    outline: none;
+    border: none;
+  }
+`;

리뷰: form으로도 처리해볼 수 있을 것 같은데 올리는 어떤 방법이 더 편할까요?
@@ -0,0 +1,17 @@
+import React, {useRef} from 'react';
+
+import * as S from './styles.ts';
+import useModalClose from '../../../hooks/useModalClose.ts';
+
+interface ModalBackgroundProps {
+  closeModal: (() => void) | null;
+  children?: React.ReactNode;
+}
+
+const ModalBackground = ({children, closeModal}: ModalBackgroundProps) => {
+  const modalBackgroundRef = useRef<HTMLDivElement>(null);
+  useModalClose({closeModal, modalBackgroundRef});
+
+  return <S.ModalBackground ref={modalBackgroundRef}>{children}</S.ModalBackground>;
+};
+export default ModalBackground;

리뷰: React.FC를 사용한 이유가 있을까요?!
@@ -0,0 +1,81 @@
+import {BASE_URL, POPULAR_MOVIE_URL, SEARCH_URL} from '../constants/movie';
+import {MovieDetail, MovieList} from '../types/movie';
+import {createApiErrorMessage} from '../utils/error';
+
+export const getMovieList = async ({pageParam = 1}: {pageParam?: number}) => {
+  const params = {
+    api_key: import.meta.env.VITE_TMDB_TOKEN,
+    language: 'ko-KR',
+    page: String(pageParam),
+  };
+  const endpoint = `${POPULAR_MOVIE_URL}?${new URLSearchParams(params).toString()}`;
+
+  const response = await fetch(endpoint, {
+    method: 'GET',
+    headers: {
+      Authorization: `Bearer ${import.meta.env.VITE_TMDB_TOKEN}`,
+      accept: 'application/json',
+    },
+  });
+
+  if (!response.ok) {
+    throw new Error(createApiErrorMessage(response.status));
+  }
+
+  const data = await response.json();
+  return data as MovieList;
+};
+
+interface GetSearchedMovieListParam {
+  pageParam?: number;
+  title: string;
+}
+
+export const getSearchedMovieList = async ({pageParam = 1, title}: GetSearchedMovieListParam) => {
+  const params = {
+    api_key: import.meta.env.VITE_TMDB_TOKEN,
+    query: title, // 검색어
+    language: 'ko-KR',
+    page: String(pageParam),
+  };
+
+  const endpoint = `${SEARCH_URL}?${new URLSearchParams(params).toString()}`;
+
+  const response = await fetch(endpoint, {
+    method: 'GET',
+    headers: {
+      Authorization: `Bearer ${import.meta.env.VITE_TMDB_TOKEN}`,
+      accept: 'application/json',
+    },
+  });
+
+  if (!response.ok) {
+    throw new Error(createApiErrorMessage(response.status));
+  }
+
+  const data = await response.json();
+  return data as MovieList;
+};
+
+export const getMovieDetail = async (movieId: number) => {
+  const params = {
+    api_key: import.meta.env.VITE_TMDB_TOKEN,
+    language: 'ko-KR',
+  };
+  const endpoint = `${BASE_URL}/movie/${movieId}?${new URLSearchParams(params).toString()}`;
+
+  const response = await fetch(endpoint, {
+    method: 'GET',
+    headers: {
+      Authorization: `Bearer ${import.meta.env.VITE_TMDB_TOKEN}`,
+      accept: 'application/json',
+    },
+  });
+
+  if (!response.ok) {
+    throw new Error(createApiErrorMessage(response.status));
+  }
+
+  const data = await response.json();
+  return data as MovieDetail;
+};

리뷰: 서버에서 데이터를 호출하는 지연 시간이 있기 때문에 delay를 걸지 않아도 좋을 것 같아요! 저는 구현할 때 네트워크 속도나 performance에서 CPU를 제어하면서 테스트했답니다😊
@@ -0,0 +1,59 @@
+import React, {Component, ReactNode} from 'react';
+
+import {TOAST_ERROR} from '../../../constants/error';
+
+export interface FallbackProps {
+  error: Error;
+  resetErrorBoundary: () => void;
+}
+
+interface ErrorBoundaryProps {
+  fallback: React.ComponentType<FallbackProps>;
+  children: ReactNode;
+  resetQueryError?: () => void;
+}
+
+interface ErrorBoundaryState {
+  hasError: boolean;
+  error: Error | null;
+}
+
+class ErrorBoundary extends Component<ErrorBoundaryProps, ErrorBoundaryState> {
+  constructor(props: ErrorBoundaryProps) {
+    super(props);
+    this.state = {hasError: false, error: null};
+  }
+
+  static getDerivedStateFromError(error: Error): ErrorBoundaryState {
+    // 에러가 발생하면 상태를 업데이트하여 fallback을 표시
+    return {hasError: true, error};
+  }
+
+  componentDidCatch(error: Error, errorInfo: React.ErrorInfo) {
+    // 에러를 로깅
+    console.error('ErrorBoundary caught an error', error, errorInfo);
+  }
+
+  resetErrorBoundary = () => {
+    const {resetQueryError} = this.props;
+    if (resetQueryError) resetQueryError();
+    this.setState({hasError: false, error: null});
+  };
+
+  render() {
+    const {hasError, error} = this.state;
+    const {children, fallback: FallbackComponent} = this.props;
+
+    // 에러 메세지에 TOAST_ERROR 포함하면 fallback 대상에서 제외
+    const isHandleError = !error?.message.includes(TOAST_ERROR);
+
+    // 에러가 발생했을 때 fallback 컴포넌트로 대체
+    if (hasError && error && isHandleError) {
+      return <FallbackComponent error={error} resetErrorBoundary={this.resetErrorBoundary} />;
+    }
+
+    return children;
+  }
+}
+
+export default ErrorBoundary;

리뷰: 오홍 토스트 구현도 궁금해지네요🤩 기대해봐도....좋을까요....?ㅋㅋㅋㅋㅋㅋ
@@ -0,0 +1,66 @@
+import {useAtomValue} from 'jotai';
+import useGetList from '../../hooks/useGetList';
+import useInfiniteScroll from '../../hooks/useInfiniteScroll';
+import * as S from './styles';
+import MovieItem from '../MovieItem';
+import SkeletonMovieList from '../skeleton/MovieList';
+import {searchTextAtom} from '../../jotai/atoms';
+
+interface MovieListProps {
+  openModal: (movieId: number) => void;
+}
+
+const MovieList = ({openModal}: MovieListProps) => {
+  const {movies, hasNextPage, fetchNextPage, isLoading, isFetchingNextPage} = useGetList();
+  const searchText = useAtomValue(searchTextAtom);
+
+  const lastElementRef = useInfiniteScroll({
+    fetchNextPage,
+    isLoading,
+    isLastPage: !hasNextPage,
+  });
+
+  const handleItemClick = (event: React.MouseEvent<HTMLUListElement>) => {
+    event.preventDefault();
+
+    const listItem = (event.target as HTMLElement).closest('li');
+    if (!listItem) return;
+
+    const id = Number(listItem.id);
+    const selectedItem = movies.find(item => item.id === id);
+
+    if (selectedItem) openModal(id);
+  };
+
+  return (
+    <S.MovieList onClick={handleItemClick}>
+      {isLoading ? (
+        <SkeletonMovieList />
+      ) : (
+        movies.map((movie, index) => {
+          const isLastMovie = index === movies.length - 1;
+
+          return (
+            <div
+              key={movie.id}
+              ref={isLastMovie ? lastElementRef : null} // 마지막 요소에 ref 연결
+            >
+              <MovieItem
+                key={movie.id}
+                id={movie.id}
+                title={movie.title}
+                poster_path={movie.poster_path}
+                vote_average={movie.vote_average}
+              />
+            </div>
+          );
+        })
+      )}
+      {isFetchingNextPage && <SkeletonMovieList />}
+
+      {searchText && movies.length === 0 && <S.Message>검색 결과가 없습니다.</S.Message>}
+    </S.MovieList>
+  );
+};
+
+export default MovieList;

리뷰: 앗 이 부분때문에 영화가 2번 보였던 거네요😂 덕분에 알아차렸어요!
@@ -1,5 +1,22 @@
+import MovieListContainer from './components/MovieListContainer';
+import Header from './components/Header';
+import QueryErrorBoundary from './components/common/QueryResetErrorboundary';
+
 function App() {
-  return <div>hi</div>;
+  return (
+    <div style={AppStyle}>
+      <QueryErrorBoundary>
+        <Header />
+        <main>
+          <MovieListContainer />
+        </main>
+      </QueryErrorBoundary>
+    </div>
+  );
 }
 
+const AppStyle = {
+  paddingBottom: '48px',
+};
+
 export default App;

리뷰: 사실 있는 코드를 가져왔습니다ㅋㅋㅋㅋ
@@ -0,0 +1,56 @@
+import * as S from './styles';
+import Portal from '../common/Portal';
+import ModalBackground from '../common/ModalBackground';
+import useGetMovieDetail from '../../hooks/useGetMovieDetail';
+import {IMAGE_BASE_URL} from '../../constants/movie';
+import {toOneDecimalPlace} from '../../utils/number';
+import filledStar from '../../assets/star_filled.png';
+import MyRating from '../MyRating';
+
+interface DetailModalProps {
+  selectedMovieId: number;
+  closeModal: () => void;
+}
+
+const DetailModal = ({selectedMovieId, closeModal}: DetailModalProps) => {
+  const {data} = useGetMovieDetail(selectedMovieId);
+
+  const {poster_path, vote_average, title, overview, genres} = data;
+
+  return (
+    <Portal>
+      <ModalBackground closeModal={closeModal}>
+        <S.ModalContainer>
+          <S.Header>
+            <h2>{title}</h2>
+            <button type="button" onClick={closeModal}>
+              닫기
+            </button>
+          </S.Header>
+
+          <S.Content>
+            <S.Poster src={`${IMAGE_BASE_URL}${poster_path}`} alt={`${title} 포스터`} />
+
+            <S.RightContent>
+              <S.ShortInfo>
+                <S.Genres>{genres.map(genre => genre.name).join(', ')}</S.Genres>
+                <S.Rate>
+                  <span>{toOneDecimalPlace(vote_average)}</span>
+                  <img src={filledStar} alt="" />
+                </S.Rate>
+              </S.ShortInfo>
+
+              <S.Overview>{overview}</S.Overview>
+
+              <S.MyRatingWrapper>
+                <MyRating movieId={selectedMovieId} />
+              </S.MyRatingWrapper>
+            </S.RightContent>
+          </S.Content>
+        </S.ModalContainer>
+      </ModalBackground>
+    </Portal>
+  );
+};
+
+export default DetailModal;

리뷰: 사실 저는 1초 언저리의 짧은 로딩 시간이라면 스켈레톤을 거는 게 오히려 정신없다고 생각해서 선호하지 않는 편인데, 모달은 1초대의 로딩이지만 화면 전환이 바로 일어날 것을 기대하고 클릭하는 거니까 모달은 바로 띄우되 로딩 UI를 보여주는 게 훨씬 자연스러울 것 같네요! 
 
개발하면서 눌렀던 것만 계속 눌러서 느린 줄도 모르고 있었네요...ㅋㅋㅋㅋㅋ
@@ -0,0 +1,81 @@
+import {BASE_URL, POPULAR_MOVIE_URL, SEARCH_URL} from '../constants/movie';
+import {MovieDetail, MovieList} from '../types/movie';
+import {createApiErrorMessage} from '../utils/error';
+
+export const getMovieList = async ({pageParam = 1}: {pageParam?: number}) => {
+  const params = {
+    api_key: import.meta.env.VITE_TMDB_TOKEN,
+    language: 'ko-KR',
+    page: String(pageParam),
+  };
+  const endpoint = `${POPULAR_MOVIE_URL}?${new URLSearchParams(params).toString()}`;
+
+  const response = await fetch(endpoint, {
+    method: 'GET',
+    headers: {
+      Authorization: `Bearer ${import.meta.env.VITE_TMDB_TOKEN}`,
+      accept: 'application/json',
+    },
+  });
+
+  if (!response.ok) {
+    throw new Error(createApiErrorMessage(response.status));
+  }
+
+  const data = await response.json();
+  return data as MovieList;
+};
+
+interface GetSearchedMovieListParam {
+  pageParam?: number;
+  title: string;
+}
+
+export const getSearchedMovieList = async ({pageParam = 1, title}: GetSearchedMovieListParam) => {
+  const params = {
+    api_key: import.meta.env.VITE_TMDB_TOKEN,
+    query: title, // 검색어
+    language: 'ko-KR',
+    page: String(pageParam),
+  };
+
+  const endpoint = `${SEARCH_URL}?${new URLSearchParams(params).toString()}`;
+
+  const response = await fetch(endpoint, {
+    method: 'GET',
+    headers: {
+      Authorization: `Bearer ${import.meta.env.VITE_TMDB_TOKEN}`,
+      accept: 'application/json',
+    },
+  });
+
+  if (!response.ok) {
+    throw new Error(createApiErrorMessage(response.status));
+  }
+
+  const data = await response.json();
+  return data as MovieList;
+};
+
+export const getMovieDetail = async (movieId: number) => {
+  const params = {
+    api_key: import.meta.env.VITE_TMDB_TOKEN,
+    language: 'ko-KR',
+  };
+  const endpoint = `${BASE_URL}/movie/${movieId}?${new URLSearchParams(params).toString()}`;
+
+  const response = await fetch(endpoint, {
+    method: 'GET',
+    headers: {
+      Authorization: `Bearer ${import.meta.env.VITE_TMDB_TOKEN}`,
+      accept: 'application/json',
+    },
+  });
+
+  if (!response.ok) {
+    throw new Error(createApiErrorMessage(response.status));
+  }
+
+  const data = await response.json();
+  return data as MovieDetail;
+};

리뷰: 이 부분은 프로젝트 할 때 계속 써와서 그런가 별 생각 없이 썼어요...ㅎ (큰 문제가 있었다면 누군가 지적해줬겠지 하는 안일함) 당장 드는 생각은 일반적인 경우에는 단언을 해도 문제는 없을 것 같다는 생각입니다. 하지만 서버가 깨져서 이상한 값을 보내주는 경우에는 디버깅하기 어려울 수도 있을 것 같아요. 

타입 추론은 as가 애초에 이걸로 추론해라! 라고 명령하는 거니까 문제없이 되는 것 같습니다. 실제로 리액트 쿼리 코드를 쓸 때 제네릭으로 리턴 타입을 명시해주지 않아도 알아서 data 타입을 추론해주더라고요.  

만약 고쳐본다면 Promise에 제네릭으로 타입을 걸어줄 것 같습니다~!
@@ -0,0 +1,51 @@
+package nextstep.app.config;
+
+import nextstep.security.*;
+import nextstep.security.domain.MemberDetailService;
+import nextstep.security.filter.*;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.web.filter.DelegatingFilterProxy;
+
+import java.util.List;
+
+@Configuration
+public class SecurityConfig {
+
+    private final MemberDetailService memberDetailService;
+
+    public SecurityConfig(MemberDetailService memberDetailService) {
+        this.memberDetailService = memberDetailService;
+    }
+
+    @Bean
+    public DelegatingFilterProxy delegatingFilterProxy() {
+        return new DelegatingFilterProxy(filterChainProxy(List.of(securityFilterChain())));
+    }
+
+    @Bean
+    public FilterChainProxy filterChainProxy(List<SecurityFilterChain> securityFilterChains) {
+        return new FilterChainProxy(securityFilterChains);
+    }
+
+    @Bean
+    public SecurityFilterChain securityFilterChain() {
+        return new DefaultSecurityFilterChain(
+                List.of(new ExceptionHandlerFilter(),
+                        new BasicAuthenticationFilter(authenticationManager()),
+                        new LoginAuthenticationFilter(authenticationManager()),
+                        new SecurityContextHolderFilter(new HttpSessionSecurityContextRepository()))
+        );
+    }
+
+    @Bean
+    public AuthenticationManager authenticationManager() {
+        List<AuthenticationProvider> providers = List.of(daoAuthenticationProvider());
+        return new ProviderManager(providers);
+    }
+
+    @Bean
+    public DaoAuthenticationProvider daoAuthenticationProvider() {
+        return new DaoAuthenticationProvider(memberDetailService);
+    }
+}

리뷰: 필터의 순서는 중요합니다!
SecurityContextHolderFilter는 BasicAuthenticationFilter, LoginAuthenticationFilter보다 먼저 설정되어야 할 것 같아요~
@@ -0,0 +1,35 @@
+package nextstep.security;
+
+import nextstep.security.domain.MemberDetail;
+import nextstep.security.domain.MemberDetailService;
+import nextstep.security.exception.AuthenticationException;
+
+public class DaoAuthenticationProvider implements AuthenticationProvider {
+
+    private final MemberDetailService memberDetailService;
+
+    public DaoAuthenticationProvider(MemberDetailService memberDetailService) {
+        this.memberDetailService = memberDetailService;
+    }
+
+    @Override
+    public Authentication authenticate(Authentication authentication) {
+        if (authentication.isAuthenticated()) {
+            return authentication;
+        }
+
+        MemberDetail member = memberDetailService.findByUsername((String) authentication.getPrincipal());
+        if (!member.isCorrectPassword((String) authentication.getCredentials())) {
+            throw new AuthenticationException();
+        }
+
+        UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken(member, null);
+        authenticationToken.setAuthenticated(true);
+        return authenticationToken;
+    }
+
+    @Override
+    public boolean supports(Class<?> authentication) {
+        return authentication.isAssignableFrom(UsernamePasswordAuthenticationToken.class);
+    }
+}

리뷰: Pssword를 확인하는 역할을 Member에게 주셨는데, Password를 검증하는 알고리즘은 확장 가능한 설계가 필요할 것 같아요. (e.g. 단순 패스워드 확인, BCrypt 암호화된 정보를 확인, ...)
따라서 Member 역할이 아닌 별도의 확장 포인트로 설계해 보면 어떨까요?
@@ -0,0 +1,35 @@
+package nextstep.security;
+
+import nextstep.security.domain.MemberDetail;
+import nextstep.security.domain.MemberDetailService;
+import nextstep.security.exception.AuthenticationException;
+
+public class DaoAuthenticationProvider implements AuthenticationProvider {
+
+    private final MemberDetailService memberDetailService;
+
+    public DaoAuthenticationProvider(MemberDetailService memberDetailService) {
+        this.memberDetailService = memberDetailService;
+    }
+
+    @Override
+    public Authentication authenticate(Authentication authentication) {
+        if (authentication.isAuthenticated()) {
+            return authentication;
+        }
+
+        MemberDetail member = memberDetailService.findByUsername((String) authentication.getPrincipal());
+        if (!member.isCorrectPassword((String) authentication.getCredentials())) {
+            throw new AuthenticationException();
+        }
+
+        UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken(member, null);
+        authenticationToken.setAuthenticated(true);
+        return authenticationToken;
+    }
+
+    @Override
+    public boolean supports(Class<?> authentication) {
+        return authentication.isAssignableFrom(UsernamePasswordAuthenticationToken.class);
+    }
+}

리뷰: setAuthenticated는 여기서만 true로 지정하고 있는데, 이러한 설계를 하신 이유가 있을까요?
@@ -0,0 +1,70 @@
+package nextstep.security.filter;
+
+import jakarta.servlet.*;
+import jakarta.servlet.http.HttpServletRequest;
+import nextstep.security.*;
+import nextstep.security.exception.AuthenticationException;
+import nextstep.security.util.Base64Convertor;
+import org.springframework.web.filter.GenericFilterBean;
+
+import java.io.IOException;
+
+import static nextstep.security.filter.LoginAuthenticationFilter.SPRING_SECURITY_CONTEXT_KEY;
+
+public class BasicAuthenticationFilter extends GenericFilterBean {
+
+    private final AuthenticationManager authenticationManager;
+
+    public BasicAuthenticationFilter(AuthenticationManager authenticationManager) {
+        this.authenticationManager = authenticationManager;
+    }
+
+    @Override
+    public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {
+        HttpServletRequest httpRequest = (HttpServletRequest) request;
+        if (notTarget(httpRequest)) {
+            chain.doFilter(request, response);
+            return;
+        }
+
+        Authentication resultAuthentication = authenticationManager.authenticate(getAuthenticationFrom(httpRequest));
+        SecurityContextHolder.getContext().setAuthentication(resultAuthentication);
+        chain.doFilter(request, response);
+    }
+
+    private static boolean notTarget(HttpServletRequest httpRequest) {
+        return !httpRequest.getRequestURI().startsWith("/members");
+    }
+
+    private static Authentication getAuthenticationFrom(HttpServletRequest httpRequest) {
+        if (httpRequest.getSession().getAttribute(SPRING_SECURITY_CONTEXT_KEY) != null) {
+            SecurityContext attribute = (SecurityContext) httpRequest.getSession().getAttribute(SPRING_SECURITY_CONTEXT_KEY);
+            return attribute.getAuthentication();
+        }
+
+        String basicToken = extractToken(httpRequest);
+        String[] usernameAndPassword = splitToken(basicToken);
+
+        String username = usernameAndPassword[0];
+        String password = usernameAndPassword[1];
+        return new UsernamePasswordAuthenticationToken(username, password);
+    }
+
+    private static String extractToken(HttpServletRequest httpRequest) {
+        String authorization = httpRequest.getHeader("Authorization");
+        return authorization.split(" ")[1];
+    }
+
+    private static String[] splitToken(String basicToken) {
+        String decodedString = Base64Convertor.decode(basicToken);
+        String[] usernameAndPassword = decodedString.split(":");
+        validateToken(usernameAndPassword);
+        return usernameAndPassword;
+    }
+
+    private static void validateToken(String[] usernameAndPassword) {
+        if (usernameAndPassword.length != 2) {
+            throw new AuthenticationException();
+        }
+    }
+}

리뷰: 각 필터에서 SecurityContext를 사용할 때는 SecurityContextHolder를 통해 사용하면 어떨까요?