블로그
전체 72025. 03. 23.
0
[인프런 워밍업 클럽 3기 풀스택] 3주차 발자국
[풀스택 완성] Supabase로 웹사이트 3개 클론하기 (Next.js 14) 3주차에는 Netflix 클론 코딩을 진행했다.Supabase를 사용해 영화 테이블을 생성하고 TMDB의 영화 데이터를 삽입했다.또한, react-intersection-observer 패키지를 활용해 무한 스크롤을 구현하고, useInfiniteQuery로 페이지네이션까지 적용했다.영화 개별 페이지에서는 SEO 최적화를 위해 generateMetadata를 사용하여 ogImage까지 설정했다. 무한 스크롤 적용 방식useInfiniteQuery를 활용한 페이지네이션 useInfiniteQuery를 사용하여 검색 결과를 페이지 단위로 가져온다.queryFn에서 searchMovies 함수를 호출하여 검색어search와 페이지 번호pageParam를 기반으로 데이터를 요청한다.getNextPageParam을 이용해 다음 페이지 번호를 설정하여, lastPage.page 값이 존재하면 다음 페이지를 요청하고, 없으면 추가 요청을 중단한다.react-intersection-observer를 활용한 스크롤 감지useInView를 사용하여 특정 요소가 뷰포트 안에 들어오는지를 감지한다.threshold: 0 는 요소가 화면에 보이기 시작하면 inView가 true로 변경ref를 특정 요소에 연결하면, 그 요소가 화면에 보일 때 inView 값이 변한다.useEffect를 이용한 자동 데이터 로드inView 값이 true이고, 다음 페이지가 존재(hasNextPage)하며, 현재 데이터를 요청 중(isFetching, isFetchingNextPage)이 아니라면 fetchNextPage를 호출하여 다음 페이지 데이터를 불러온다. SEO 최적화generateMetadata 함수는 특정 페이지의 메타데이터(SEO 정보)를 동적으로 생성하는 역할을 한다.generateMetadata에서 아래와 같이 반환한다면:return { title: movie.title, description: movie.overview, openGraph: { images: [movie.image_url], }, };HTML 메타태그가 아래와 같이 생성된다:Inception Open Graph images를 추가하여 SNS에서 공유 시 미리보기 이미지가 표시되도록 한다. 3주차 미션Netflix Clone 프로젝트에 “찜하기” 기능을 추가하세요.사용자가 특정 영화를 “찜”할 수 있도록 Supabase를 활용해 즐겨찾기 리스트 구현찜한 영화를 영화 리스트 화면의 최상단으로 보여주도록 정렬github: https://github.com/thayoon/inflearn-nextjs-supabase-netflix-clone결과 화면:미션 해결 과정:1. 추후 회원 기능 추가를 위해 Supabase에서 “찜(favorites) 테이블”을 생성한다.favorites: 사용자가 찜한 영화를 저장하는 테이블한 영화(movie.id)는 여러 사용자의 favorites에 포함될 수 있다. (1:N 관계)2. 전체 영화 리스트와 찜 상태를 함께 가져오는 RCP 함수 정의Supabase에서 서브쿼리를 사용하여 영화와 찜 상태를 가져오는 get_movies_with_favorites라는 Remote Procedure Call (RPC) 함수를 정의한다.이 함수는 영화 제목을 검색하고, 해당 영화가 찜 목록에 있는지 여부(boolean)를 favorite 컬럼으로 반환한다.JavaScript: Call a Postgres function | Supabase Docsget_movies_with_favorites-- DROP FUNCTION IF EXISTS get_movies_with_favorites(varchar, integer, integer); create or replace function get_movies_with_favorites(search varchar, page int, page_size int) returns table ( id int8, image_url text, title varchar, overview varchar, vote_average float8, popularity float8, release_date varchar, favorite boolean ) as $$ begin return query select movie.*, exists ( select 1 from favorites where favorites.movie_id = movie.id ) as favorite from movie where movie.title like '%' || search || '%' limit page_size offset (page - 1) * page_size; end; $$ language plpgsql;3. 기존 영화 목록 불러오기 로직 변경기존의 movie 테이블에서 select()를 사용해 영화를 불러오는 로직을:export async function searchMovies({ search = "", page, page_size }) { // ... const { data, count, error } = await supabase .from("movie") .select("*") .like("title", `%${search}%`) .range((page - 1) * pageSize, page * pageSize - 1); // ... }위에서 정의한 get_movies_with_favorites RPC를 호출하는 방식으로 변경한다.:export async function searchMovies({ search = "", page, page_size }) { // ... const { data, error } = await supabase.rpc("get_movies_with_favorites", { search, page, page_size, }); // ... }그 결과 찜 상태를 포함한 영화를 반환받을 수 있다.:[ { id: 1, image_url: 'https://image.tmdb.org/t/p/w500/1pdfLvkbY9ohJlCjQH2CZjjYVvJ.jpg', title: 'Dune: Part Two', overview: 'Follow the mythic journey of Paul Atreides as he unites with Chani and the Fremen while on a path of revenge against the conspirators who destroyed his family. Facing a choice between the love of his life and the fate of the known universe, Paul endeavors to prevent a terrible future only he can foresee.', vote_average: 8.3, popularity: 3437.313, release_date: '2024-02-27', favorite: false }, { id: 2, image_url: 'https://image.tmdb.org/t/p/w500/kDp1vUBnMpe8ak4rjgl3cLELqjU.jpg', title: 'Kung Fu Panda 4', overview: 'Po is gearing up to become the spiritual leader of his Valley of Peace, but also needs someone to take his place as Dragon Warrior. As such, he will train a new kung fu practitioner for the spot and will encounter a villain called the Chameleon who conjures villains from the past. movie HD QUALITY, open this link leakedcinema.com', vote_average: 7.146, popularity: 2340.977, release_date: '2024-03-02', favorite: false }, // ... ]4. 영화 카드 컴포넌트에서 찜 상태 반영components/movie-card.tsxexport default function MovieCard({ movie }) { async function handleClick(favorite: boolean) { if (favorite) { await insertFavoriteMutaion.mutate(); } else { await deleteFavoriteMutaion.mutate(); } } return ( // ... {/* 찜하기 버튼 */} { handleClick(!movie.favorite); }} > {insertFavoriteMutaion.isPending || deleteFavoriteMutaion.isPending ? ( ) : ( )} // ... ); }MovieCard 컴포넌트에서 각 영화의 찜 상태(favorite)에 맞게 버튼 색상을 변경하고, 클릭 시 handleClick 함수로 찜 상태를 변경하고 해당 상태를 mutate를 통해 서버와 동기화한다.4-1. 찜 상태 반영 후 데이터 업데이트const insertFavoriteMutaion = useMutation({ mutationFn: () => insertFavorite(movie.id), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ["favoritesMovieList"], }); queryClient.invalidateQueries({ queryKey: ["movie"], }); }, });insertFavoriteMutaion과 deleteFavoriteMutaion에서 각각 찜 추가 및 삭제가 성공하면 queryClient.invalidateQueries를 사용해 관련된 데이터를 새로고침하여 UI를 최신 상태로 반영하여 유지한다.5. 화면 상단에 보여질 찜한 영화 리스트 불러오기Supabase에서 두 테이블을 외래키 기준으로 조인하여 찜한 영화 목록만 가져온다.Query nested foreign tables through a join table - JavaScript: Fetch data | Supabase Docsactions/movieActions.tsexport async function getFavoritesMovie() { const supabase = await createServerSupabaseClient(); const { data, error } = await supabase .from("favorites") .select(`*, movie!favorites_movie_id_fkey(*)`) .order("created_at", { ascending: false }); if (error) { handleError(error); } return data.map((item) => ({ ...item, movie: { ...item.movie, favorite: true, }, })); }여기서 movie 정보에 favorite 프로퍼티를 추가하고, 찜한 영화 목록이므로 모든 영화에 favorite: true를 설정한다.응답 예시:[ { id: 78, created_at: '2025-03-22T13:19:51.598+00:00', movie_id: 1, movie: { id: 1, title: 'Dune: Part Two', overview: 'Follow the mythic journey of Paul Atreides as he unites with Chani and the Fremen while on a path of revenge against the conspirators who destroyed his family. Facing a choice between the love of his life and the fate of the known universe, Paul endeavors to prevent a terrible future only he can foresee.', image_url: 'https://image.tmdb.org/t/p/w500/1pdfLvkbY9ohJlCjQH2CZjjYVvJ.jpg', popularity: 3437.313, release_date: '2024-02-27', vote_average: 8.3, favorite: true } } ]6. 찜한 목록 Swiper 적용하여 화면 상단에 출력하기components/movie-favorites-list.tsx찜한 영화가 없을 경우, 데이터를 추가할 수 있도록 전체 영화 리스트로 스크롤 이동 버튼을 추가한다."use client"; import { useRef } from "react"; export default function MovieFavoritesList() { // 전체 영화 리스트로 스크롤 이동 const moveRef = useRef(null); const scrollToAllMovies = () => { moveRef.current?.scrollIntoView({ behavior: "smooth", block: "start", }); }; return ( // ... {getFavoritesMovieQuery.isLoading ? ( ) : ( 좋아하는 영화를 추가해보세요. )} // ... ) };찜한 영화가 있을 경우, Swiper를 적용하여 캐러셀 형식으로 나타낸다.// ... import { getFavoritesMovie } from "actions/movieActions"; import { Navigation } from "swiper/modules"; import { Swiper, SwiperSlide } from "swiper/react"; import "swiper/css"; import "swiper/css/navigation"; export default function MovieFavoritesList() { // ... const getFavoritesMovieQuery = useQuery({ queryKey: ["favoritesMovieList"], queryFn: () => getFavoritesMovie(), }); return ( Your Favorite Movies💘 {getFavoritesMovieQuery.isLoading || !getFavoritesMovieQuery?.data.length ? ( // 데이터가 없는 경우 ) : ( ◀ ▶ {getFavoritesMovieQuery.data.map((item) => ( ))} )} ); } 3주차 회고이번 주 미션을 진행하면서, 처음에는 영화 목록을 가져올 때 페이지네이션을 고려하지 않고 RPC를 사용하여 데이터를 가져왔다. 하지만 구현하면서 이 방식이 최선일까? 라는 고민이 들었다.생각해본 접근 방식으로는, 처음 데이터를 가져온 후 가공하여 Recoil로 상태 관리하는 방법이 있었다.이렇게 하면 불필요한 데이터 호출을 줄일 수 있을 것 같다.하지만 이번 과제를 통해 레퍼런스를 참고하며 다양한 SQL 쿼리문을 활용하는 경험을 쌓을 수 있었던 점은 큰 배움이었다.다음주까지 모두 학습하고 사용자 인증을 적용한 후 사용자별 찜 기능, 상세 페이지에서 찜 추가/삭제 기능까지 구현할 계획이다.다음 주가 마지막 주라 강의가 많아 최대한 빠르게 수강하고 실습해야 하지만, Supabase의 하이라이트 부분을 배우게 되어 기대가 크다!
2025. 03. 14.
1
[인프런 워밍업 클럽 3기 풀스택] 2주차 발자국
[풀스택 완성] Supabase로 웹사이트 3개 클론하기 (Next.js 14) - 로펀 2주차는 Supabase Storage를 어떻게 사용하는지 학습했다. Supabase Storage 설정Supabase > DashBoard > Project > Storage버킷 생성하기Name of Bucket: 버킷 이름Public bucket: trueAdditional configuration>Allowed MIME types: image/* (이미지만 허용)Policies>For Full customizationPolicy name: 정책 이름Allowed operation: 누구나 CRUD를 할 수 있기 때문에 모두 허용Target roles: anon으로 누구나 허용Review 클릭>Save policy 클릭CRUD 각각에 대한 policies 생성server action에서 storage 접근하기export async function uploadFile(formData: FormData) { const supabase = await createServerSupabaseClient(); const files = Array.from(formData.entries()).map( ([name, file]) => file as File ); const results = await Promise.all( files.map((file) => supabase.storage .from(process.env.NEXT_PUBLIC_STORAGE_BUCKET) .upload(file.name, file, { upsert: true }) ) ); return results; }Server Action을 사용하여 Supabase Storage에 파일을 업로드하는 함수이다. FormData를 받아 파일을 읽고, Supabase Storage에 업로드하는 방식으로 동작한다. const supabase = await createServerSupabaseClient();서버 측에서 Supabase 클라이언트를 생성하여 Storage API를 사용할 수 있도록 한다. const files = Array.from(formData.entries()).map( ([name, file]) => file as File );FormData에서 모든 항목을 배열로 추출하여 File 객체로 변환하여 파일을 추출한다.const results = await Promise.all( files.map((file) => supabase.storage .from(process.env.NEXT_PUBLIC_STORAGE_BUCKET) .upload(file.name, file, { upsert: true }) ) ); return results;Promise.all()을 사용하여 여러 개의 파일을 동시에 업로드한다.supabase.storage.from(bucket).upload(filename, file, options)을 통해 지정한 버킷에 파일을 업로드한다.{ upsert: true }: 동일한 이름의 파일이 있을 경우 덮어쓴다. (insert + update) 업로드 된 결과를 반환한다. 2주차 미션github: https://github.com/thayoon/nextjs-supabase-dropbox-cloneDropbox Clone 프로젝트에 파일의 마지막 수정(업로드) 시간을 표시하는 기능을 추가하세요.파일 목록에서 각 파일의 “마지막 수정 시간”을 표시📌 참고 문서: Supabase Storage - 파일 목록 가져오기미션 해결 방법:list() 응답값 확인Supabase의 list() 함수를 사용하면 파일 정보를 가져올 수 있다.참고 문서에서 확인한 응답값은 다음과 같다:{ "data": [ { "name": "avatar1.png", "id": "e668cf7f-821b-4a2f-9dce-7dfa5dd1cfd2", "updated_at": "2024-05-22T23:06:05.580Z", "created_at": "2024-05-22T23:04:34.443Z", "last_accessed_at": "2024-05-22T23:04:34.443Z", "metadata": { "eTag": "\"c5e8c553235d9af30ef4f6e280790b92\"", "size": 32175, "mimetype": "image/png", "cacheControl": "max-age=3600", "lastModified": "2024-05-22T23:06:05.574Z", "contentLength": 32175, "httpStatusCode": 200 } } ], "error": null }이 중에서 updated_at이 파일의 마지막 수정 시간을 나타낸다. Server Action에서 list() 호출 및 데이터 반환actions/storageActions.tsexport async function searchFiles(search: string = "") { const supabase = await createServerSupabaseClient(); const { data, error } = await supabase.storage .from(process.env.NEXT_PUBLIC_STORAGE_BUCKET) .list(null, { sortBy: { column: "updated_at", order: "desc" }, search, }); handleError(error); return data; }파일 목록을 가져오는 searchFiles() 함수를 구현한다.참고 문서를 통해 sortBy 옵션을 적용하여 updated_at 을 기준으로 내림차순 정렬하여 최신 파일이 먼저 오도록 설정한다. 클라이언트 컴포넌트에서 데이터 가져오기components/dropbox-image-list.tsx "use client"; import { useQuery, useMutation } from "@tanstack/react-query"; export default function DropboxImageList({ searchInput }) { const searchImageQuery = useQuery({ queryKey: ["images", searchInput], queryFn: () => searchFiles(searchInput), }); return ( {searchImageQuery.isLoading && } {searchImageQuery.data && searchImageQuery.data.map((image) => ( )} ); } useQuery를 사용해 서버에서 데이터를 가져온다.가져온 데이터를 DropboxImage 컴포넌트로 전달한다. 마지막 수정 시간 표시components/dropbox-images.tsx"use client"; import { IconButton, Spinner, Checkbox } from "@material-tailwind/react"; import { getImageUrl } from "utils/supabase/storage"; export default function DropboxImage({ image }) { // 마지막 수정 시간 한국 시간 변환 const updated = new Date(image.updated_at) .toLocaleString("en-CA", { year: "numeric", month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit", hour12: false, timeZone: "Asia/Seoul", }) .replace(",", ""); return ( {/* Image */} {/* fileName */} {/* update time */} 마지막 수정: {updated} {/* trash Button */} ); } updated_at 값을 toLocaleString()을 사용해 한국 시간으로 변환하고 화면에 표시한다.추가 구현 사항긴 파일명 생략 표시{image.name}className에 truncate를 적용하여 긴 파일명을 한 줄로 표시하고 넘칠 경우 "..."으로 생략한다. 사진 업로드 오름차순/내림차순 정렬처음에는 사용자의 정렬 방식 선택에 따라 서버에서 데이터를 다시 호출하도록 구현했지만, 비효율적이라고 판단하여 클라이언트에서 정렬을 처리하는 방식으로 변경했다.1차 시도 - 서버에서 정렬된 데이터 요청actions/storageAction.tsexport async function searchFiles(search: string = "", isLatest) { const supabase = await createServerSupabaseClient(); const { data, error } = await supabase.storage .from(process.env.NEXT_PUBLIC_STORAGE_BUCKET) .list(null, { sortBy: { column: "updated_at", order: isLatest ? "desc" : "asc" }, search, }); handleError(error); return data; } isLatest 값에 따라 정렬 순서를 desc(최신순) 또는 asc(오래된순)으로 설정사용자가 정렬 방식을 변경할 때마다 서버 요청이 발생하여 비효율적이다.2차 시도 - 클라이언트에서 정렬 처리components/dropbox-image-list.tsx"use client"; import { Spinner, Menu, MenuHandler, MenuList, MenuItem, Button, Typography, } from "@material-tailwind/react"; import { useState } from "react"; import { useQuery } from "@tanstack/react-query"; const sortMenu = [ { title: "최신순", isLatest: true }, { title: "오래된순", isLatest: false }, ]; export default function DropboxImageList({ searchInput }) { const [openMenu, setOpenMenu] = useState(false); const [isLatest, setIsLatest] = useState(true); const searchImageQuery = useQuery({ queryKey: ["images", searchInput], queryFn: () => searchFiles(searchInput), }); return ( {/* 정렬 버튼 */} {isLatest ? "최신순" : "오래된순"} {sortMenu.map(({ title, isLatest }) => ( setIsLatest(isLatest)}> {title} ))} {/* 이미지 리스트 */} {searchImageQuery.isLoading && } {searchImageQuery.data && (isLatest ? searchImageQuery.data.map((image) => ( )) : searchImageQuery.data .slice() .reverse() .map((image) => ))} ); }서버 요청은 기본적으로 최신순으로 설정하고, 클라이언트에서 데이터를 reverse()하여 정렬을 변경하는 방식으로 개선했다. 사진 다중 삭제사진을 다중 선택하여 삭제하는 기능은 체크박스를 활용해 구현했다.사용자는 "전체 선택" 및 "선택 삭제" 기능을 통해 한 번에 여러 사진을 삭제할 수 있다.components/dropbox-image-list.tsx "use client"; export default function DropboxImageList({ searchInput }) { // ... const [allSelected, setAllSelected] = useState(false); const [isSelected, setIsSelected] = useState([]); const searchImageQuery = useQuery({ queryKey: ["images", searchInput], queryFn: () => searchFiles(searchInput), }); const deleteFileMutation = useMutation({ mutationFn: deleteFile, onSuccess: () => { searchImageQuery.refetch(); }, }); function handleChecked(isChecked) { setAllSelected(isChecked); if (isChecked && searchImageQuery.data) { setIsSelected(searchImageQuery.data.map((image) => image.name)); } else { setIsSelected([]); } } return ( 전체 선택 ({isSelected.length}/ {searchImageQuery.data && searchImageQuery.data.length} {!searchImageQuery.data && 0}) } checked={allSelected} onChange={(e) => handleChecked(e.target.checked)} /> 0 ? "blue" : "gray"} disabled={isSelected.length > 0 ? false : true} onClick={() => { setIsSelected([]); setAllSelected(false); deleteFileMutation.mutate(isSelected); }} > {deleteFileMutation.isPending ? : "선택 삭제"} // ... {searchImageQuery.isLoading && } {searchImageQuery.data && searchImageQuery.data.map((image) => ( ))} ); } 전체 선택: 사용자가 "전체 선택" 체크박스를 클릭하면, 모든 이미지가 선택된다.선택된 이미지 수와 총 이미지 수가 표시된다.선택 삭제: 사용자가 선택한 이미지들을 삭제할 수 있는 "선택 삭제" 버튼을 제공한다. 이미지가 선택되었을 때만 활성화된다.상태 관리: isSelected 배열에 선택된 이미지의 이름을 저장한다. allSelected 상태로 전체 선택 여부를 관리한다.선택 삭제 버튼을 클릭하면 deleteFileMutation을 호출하여 isSelected을 전달하여 삭제를 처리한다.삭제 작업이 완료되면 searchImageQuery.refetch()를 호출하여 이미지 리스트를 최신 상태로 갱신한다.components/dropbox-images.tsx"use client"; export default function DropboxImage({ image, isSelected, setIsSelected, setAllSelected, totalLength, }) { const isChecked = isSelected.includes(image.name); const handleChecked = (checked) => { setIsSelected((prev) => { if (checked) { const newSelected = [...prev, image.name]; if (newSelected.length === totalLength) setAllSelected(true); return newSelected; } else { setAllSelected(false); return prev.filter((item) => item !== image.name); } }); }; // ... return ( {/* Image */} {/* fileName */} {/* update time */} {/* multiple checkBox */} handleChecked(e.target.checked)} /> {/* trash Button */} ); } 개별 체크박스: 각 이미지에 대해 체크박스를 제공하고 사용자가 선택한 이미지를 isSelected 배열에 추가하거나 제거한다.상태 변화: 체크박스를 클릭하면 해당 이미지가 선택되거나 선택이 해제되고 선택된 모든 이미지가 삭제될 때 "전체 선택" 체크박스도 자동으로 갱신된다. 상태 연동: isChecked 개별 이미지에 체크박스의 체크 여부를 결정한다.isSelected 배열에 현재 이미지의 name 값이 포함되어 있는지 true, false로 설정한다.개별 이미지의 체크 여부가 isSelected 상태와 동기화된다.actions/storageActions.tsexport async function deleteFile(fileName: string[]) { const supabase = await createServerSupabaseClient(); const { data, error } = await supabase.storage .from(process.env.NEXT_PUBLIC_STORAGE_BUCKET) .remove(fileName); handleError(error); return data; } supabase.storage.from(bucket).remove(['filename'])을 통해 지정한 버킷에서 배열에 포함된 모든 파일을 삭제 요청한다. 2주차 회고☀이번주는 강의를 들으며 여러 추가 기능이 생각나 실습하는 과정이 더욱 즐거웠다.다른 러너분이 한글 파일명 업로드 오류를 찾아내고 해결하는 모습을 보고 대단하다고 느꼈다.실습에서 영어 파일명으로만 업로드 했기 때문에 이런 오류가 발생하는지 몰랐다.상황을 공유해주신 러너분 덕분에 새로운 사실을 알게 됐다. (감사합니다!)다음에 시간이 난다면 강사님께서 말씀하신 해결 방법을 적용해 보고 싶다.목요일에 중간 점검 시간에 QnA 시간을 가졌는데, 정말 도움이 많이 됐다.특히, 포트폴리오 작성 요령과 개발자로서 필요한 역량을 채우는 방법을 핵심적으로 짚어주셔서 큰 도움이 되었다.나만의 특색을 찾고 포트폴리오에 잘 정리해 봐야겠다 느꼈다.벌써 2주차가 끝났는데, 배포까지 빠르게 진행해 보고 싶다. 다음주도 화이팅!
2025. 03. 09.
0
[인프런 워밍업 클럽 3기 풀스택] 1주차 발자국
[풀스택 완성] Supabase로 웹사이트 3개 클론하기 (Next.js 14) - 로펀 1주차는 Supabase, Next.js, Tailwindcss, Recoil, React Query 소개와 기본 문법, Server Action CRUD 사용법을 배웠다.Supabase 특징오픈 소스 프로젝트 (자체 서버구축 가능)PostgresSQL 기반 (복잡한 요구사항 개발 가능)Firebase 대비 저렴다양한 연동방식 지원 (+SDK, DB Connection, GraphQL, API) Next.jsNext.js의 폴더명이 곧 Route와 같다.page.tsx는 각 폴더의 대표 파일과 같다.app/layout.tsx는 화면 레이아웃을 잡아주는 Next.js 서버 컴포넌트이므로, 클라이언트에서만 동작하는 상태 관리 라이브러리(Zustand, Recoil, React Query 등)를 직접 사용할 수 없다.따라서 React Query의 Client Provider를 별도의 클라이언트 컴포넌트로 만들어 layout.tsx에 주입해야 한다.(서버 컴포넌트는 클라이언트 상태나 브라우저 API에 접근할 수 없음)서버 액션은 API 라우트 없이 서버에서 직접 실행 가능하다. React Query서버에서 가져온 데이터를 캐싱, 동기화, 상태 관리까지 자동으로 해주는 라이브러리useQuery(): 데이터 조회 (GET 요청)const todosQuery = useQuery({ queryKey: ["todos"], // 캐싱 키 queryFn: fetchTodos, // API 요청 함수 }); const { data, isLoading, error } = todosQuery; queryKey: 데이터 캐싱 키queryFn: 데이터 호출 함수data: fetchTodos의 반환 값isLoading: 데이터 로딩 여부 (true면 로딩 중)error: 요청 실패 시 에러 객체 useMutation(): 데이터 변경 (POST, PUT, DELETE)const mutation = useMutation({ mutationFn: addTodo, onSuccess: () => { console.log("성공!"); }, onError: (error) => { console.error("에러 발생:", error); }, }); mutationFn: API 호출 함수onSuccess: 성공 시 실행할 함수onError: 실패 시 실행할 함수 TODO LIST 만들기Supabase todo 테이블 정의Supabase Next.js 연결.env 환경변수 설정Supabase Project API Keys를 사용하여 환경변수 추가package.json 수정scripts에서 generate-types 옵션 수정Supabase 로그인 후 타입 생성generate-types 실행하여 데이터베이스 테이블의 타입 정의 가져오기 Next.js에서 Supabase 클라이언트 생성브라우저/서버 환경에서 사용할 Supabase 클라이언트 생성Next.js의 모든 request에서 Supabase 인증 토큰을 갱신하도록 middleware 설정 TODO 조회 - server actionexport async function getTodos({ searchInput = "" }): Promise { const supabase = await createServerSupabaseClient(); const { data, error } = await supabase .from("todo") .select("*") .like("title", `%${searchInput}%`) .order("created_at", { ascending: true }); if (error) { handleError(error); } return data; }getTodos 함수는 searchInput을 기본값 ""로 받아 Promise 타입을 반환한다.이 함수는 서버 환경에서 실행되며, Supabase 클라이언트를 사용하여 todo 테이블에서 데이터를 조회한다.title 컬럼에서 searchInput을 포함한 값을 검색하며created_at을 기준으로 오름차순으로 정렬하여 반환한다.검색된 데이터가 없거나 오류가 발생하면 handleError 함수가 호출된다. TODO 조회 - React Query useQuery()const todosQuery = useQuery({ queryKey: ["todos", searchInput], queryFn: () => getTodos({ searchInput }), }); return ( // ... {todosQuery.data && todosQuery.data.map((todo) => )} // ... ) useQuery는 todosQuery라는 데이터를 서버에서 가져오는 비동기 작업을 관리한다.queryKey는 ["todos", searchInput]으로 설정되어, searchInput에 따라 조회된 결과가 달라지도록 한다.queryFn은 서버 액션 getTodos를 호출해, searchInput에 맞는 TODO 데이터를 반환한다.반환된 데이터(todosQuery.data)가 있을 경우, map()을 사용하여 각 TODO 항목을 렌더링한다. 1주차 미션github: https://github.com/thayoon/nextjs-supabase-todolist생성된 TODO의 생성 시간을 저장하고 이를 표시하는 기능을 추가하세요.TODO 항목 옆에 생성 시간을 표시하기component/todo.tsxexport default function Todo({ todo }) { // 한국 시간, YYYY/MM/DD HH:MM 포맷 함수 const getTime = (time: string): string => new Date(time) .toLocaleString("en-US", { year: "numeric", month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit", hour12: false, timeZone: "Asia/Seoul", }) .replace(",", ""); const createdTime = getTime(todo.created_at); return( // ... {isEditing ? ( setTitle(e.target.value)} /> ) : ( {title} ⏱️{createdTime} )} // ... ); } getTime 함수는 created_at 값을 한국 시간에 맞게 YYYY/MM/DD HH:MM 포맷으로 변환한다.isEditing이 false일 때, 생성된 TODO 항목의 생성 시간을 createdTime을 통해 표시한다.todo 매개변수는 useQuery로 가져온 데이터 객체이며, created_at 값을 이용해 생성 시간을 표시한다. (선택 사항) completed_at 필드를 추가하여 완료한 시간도 함께 저장Supabase에서 completed_at 필드를 추가하여, TODO 완료 시각을 저장한다.completed_at은 NULL을 허용하는 timestamptz 타입 칼럼이다.actions/todo-actions.ts (server action)updateTodo 함수는 completed 상태에 따라 completed_at 값을 업데이트한다.export async function updateTodo(todo: TodoRowUpdate) { const supabase = await createServerSupabaseClient(); console.log(todo); const { data, error } = await supabase .from("todo") .update({ ...todo, updated_at: new Date().toISOString(), completed_at: todo.completed ? new Date().toISOString() : null, }) .eq("id", todo.id); if (error) { handleError(error); } return data; } completed가 true일 경우 completed_at에 현재 시각을 저장하고, false일 경우 null을 저장한다.3. components/todo.tsx (React Query useMutation())completed_at 값을 확인하여 완료 시각을 표시한다.export default function Todo({ todo }) { // 한국 시간, YYYY/MM/DD HH:MM 포맷 함수 const getTime = (time: string): string => new Date(time) .toLocaleString("en-US", { year: "numeric", month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit", hour12: false, timeZone: "Asia/Seoul", }) .replace(",", ""); const completedTime = todo.completed_at ? getTime(todo.completed_at) : null; const updateTodoMutation = useMutation({ mutationFn: () => updateTodo({ id: todo.id, title, completed, }), onSuccess: () => { setIsEditing(false); queryClient.invalidateQueries({ queryKey: ["todos"], }); }, }); return( { await setCompleted(e.target.checked); await updateTodoMutation.mutate(); }} /> {isEditing ? ( setTitle(e.target.value)} /> ) : ( {title} ⏱️{createdTime}{" "}{completedTime && `~ ${completedTime}`} )} // ... ); } completed 상태에 따라 체크박스를 변경하고, 완료된 경우 completed_at 시각을 표시한다.setCompleted로 상태를 갱신하고, updateTodoMutation을 호출하여 서버에서 completed_at 값을 업데이트한다.완료된 TODO 항목에 대해 completed_at 값이 존재하면 완료 시각을 getTime() 함수로 포맷하여 표시한다.정리:TODO 완료 상태 변경:사용자가 체크박스를 클릭하면 setCompleted로 completed 상태를 갱신한다.갱신된 상태는 updateTodoMutation.mutate()를 통해 서버로 전송되어 completed_at 값이 업데이트된다.완료 시각 컴포넌트 업데이트:서버에서 completed_at 값이 갱신되면, 해당 TODO의 완료 시각이 한국 시간으로 포맷되어 표시된다.완료된 경우에는 completed_at을 getTime() 함수를 통해 표시하고, 완료되지 않은 경우에는 null이므로 표시되지 않는다.쿼리 데이터 갱신:updateTodoMutation.onSuccess에서 queryClient.invalidateQueries({ queryKey: ["todos"] })를 호출하여 todos 쿼리의 데이터를 무효화한다.쿼리의 데이터가 무효화되면 React Query는 자동으로 서버에서 최신 데이터를 다시 불러와 화면에 반영한다. 1주차 회고☀사실 Next.js를 아주 짧게 배운 상태로 강의를 듣게 됐는데, 섹션1에서 중요한 부분을 잘 설명해주셔서 큰 어려움없이 수강할 수 있었다. 그리고 실습을 따라하면서 어느정도 흐름은 알게 된 것 같다. 그렇지만 다른 Next.js 강의를 수강하며 구체적인 동작과정을 더 배워야 할 것 같다. 다음 실습도 기대된다. 다음주도 화이팅!
풀스택
2024. 10. 27.
0
[인프런 워밍업 스터디 클럽 2기 FE] 과제 제출
[따라하며 배우는 자바스크립터, 리액트 A-Z - John Ahn] JS 과제Day 2 - 음식 메뉴 앱github : https://github.com/thayoon/study/tree/main/%5BFE%5DInflearn_WarmingUP_Club_Study_2nd/day_2 Day 3 - 가위 바위 보 앱github : https://github.com/thayoon/study/tree/main/%5BFE%5DInflearn_WarmingUP_Club_Study_2nd/day_3 Day 4 - 퀴즈 앱github : https://github.com/thayoon/study/tree/main/%5BFE%5DInflearn_WarmingUP_Club_Study_2nd/day_4 React 과제Day 9 - 예산 계산기 앱github : https://github.com/thayoon/study/tree/main/%5BFE%5DInflearn_WarmingUP_Club_Study_2nd/day_9이 과제까지 너무 즐겁게 했는데..Day 10 - 디즈니 플러스 앱github : https://github.com/thayoon/study/tree/main/%5BFE%5DInflearn_WarmingUP_Club_Study_2nd/day_10여기서 CSS의 어려움을 직면했다..Day 12 - 퀴즈 앱github : https://github.com/thayoon/study/tree/main/%5BFE%5DInflearn_WarmingUP_Club_Study_2nd/day_12Next.js로 도전해보려 했지만, 아직 설계 단계에서부터 막히는 부분이 많아 어떻게 접근해야 할지 잘 모르겠다. 강의를 다시 보며 Next.js로 차근차근 새롭게 만들어봐야겠다.그래도 이번 인프런 스터디 클럽으로 새롭게 알게된 내용도 너무 많았고,오랜만에 과제해서 대학생으로 돌아간 기분도 났다. 신청하길 정말 잘했다..!
2024. 10. 20.
0
[인프런 워밍업 스터디 클럽 2기 FE] 3주차 발자국
[따라하며 배우는 자바스크립터, 리액트 A-Z - John Ahn] 강의 수강 정리프레임워크 (Framework):애플리케이션을 만들기 위해 필요한 대부분의 요소를 포함하고 있으며, 사용자가 작성한 코드를 호출한다.라이브러리 (Library):특정 기능을 모듈화하여 제공하며, 사용자가 라이브러리를 호출하여 기능을 구현한다.리액트 컴포넌트의 두 가지 유형클래스형 컴포넌트 (Class Component)함수형 컴포넌트 (Functional Component)가상 돔은 간략하게 말해서 이전 가상 돔과 비교하는 디핑 알고리즘을 적용하여 돔 조작 비용을 줄이는 것웹팩: 오픈 소스 자바스크립트 모듈 번들러로, 여러 개의 파일을 하나의 자바스크립트 코드로 압축하고 최적화하는 라이브러리바벨: 최신 자바스크립트 문법을 지원하지 않는 브라우저에서도 작동하도록 변환해주는 라이브러리npx는 Node 패키지 실행을 도와주는 도구로, npx create-react-app ./로 npm 레지스트리에 있는 create-react-app 패키지를 통해 리액트를 설치한다.SPA는 웹사이트의 전체 페이지를 하나의 페이지에 담아 동적으로 화면을 변경한다.JSX를 사용하면 자바스크립트와 HTML 구조를 함께 사용할 수 있어 UI의 데이터 변화나 이벤트 처리 부분을 쉽게 구현할 수 있다.가상 DOM을 사용하여 변경된 부분만 실제 DOM에 적용하는데, Key 속성을 사용하면 어떤 부분이 바뀌었는지를 인식할 수 있다.React State란 컴포넌트의 렌더링 결과물에 영향을 주는 데이터를 갖고 있는 객체다.React Hooks로 class없이 state를 사용할 수 있는 새로운 기능으로 코드가 더 간결해지고 HOC 컴포넌트를 Custom React Hooks로 대체하여 많은 Wrapper 컴포넌트를 줄이게 된다.(HOC: 화면에서 재사용 가능한 로직만을 분리해서 component로 만들고, 재사용 불가능한 UI와 같은 다른 부분들은 parameter로 받아서 처리하는 방법이다. 즉, 컴포넌트를 인자로 받아서 새로운 리액트 컴포넌트를 처리하는 함수다.)TDD: 테스트 주도 개발로 React에서 React Testing Library와 Jest를 함께 사용하여 테스트할 수 있다.Next.js: React 기반의 서버 사이드 렌더링(SSR) 및 정적 사이트 생성(SSG) 프레임워크로, 페이지 기반의 라우팅을 지원하고 API 라우트를 통해 백엔드 로직을 포함할 수 있다.Pre-rendering: 페이지가 요청되기 전에 HTML을 생성하는 방식Data Fetching: getStaticProps와 getServerSideProps, getStaticPaths사용으로 데이터를 가져와 페이지에 전달할 수 있다.Type annotation, Type inference: 타입 주석을 사용해 변수의 타입을 명시하거나, 타입 추론을 통해 자동으로 타입을 결정하는 방법Type assertion: 변수의 타입을 강제로 지정하여 TypeScript의 타입 체크를 우회하는 방법JS 과제전체 과제 제출 - https://www.inflearn.com/blogs/9104Day 2 - 음식 메뉴 앱github : https://github.com/thayoon/study/tree/main/%5BFE%5DInflearn_WarmingUP_Club_Study_2nd/day_2 Day 3 - 가위 바위 보 앱github : https://github.com/thayoon/study/tree/main/%5BFE%5DInflearn_WarmingUP_Club_Study_2nd/day_3 Day 4 - 퀴즈 앱github : https://github.com/thayoon/study/tree/main/%5BFE%5DInflearn_WarmingUP_Club_Study_2nd/day_4 React 과제Day 9 - 예산 계산기 앱github : https://github.com/thayoon/study/tree/main/%5BFE%5DInflearn_WarmingUP_Club_Study_2nd/day_93주차 회고그동안 리액트로 진행한 프로젝트가 있었기 때문에 강의 듣기 전에 자신만만한 상태였는데 강의 시작하고 전 그냥 리액트의 리도 모르는 사람이 되었습니다! 그리고 제가 지금까지 해온 학습은 학습이 아니었구나를 깨닫게 되었습니다. 진짜 너무나도 얕은 지식으로 개발하고 있었더라구요.. 반성하게 됐습니다.리액트 강의를 듣는데 next.js랑 CI/CD까지 배울 수 있었다니.. 너무 좋은 기회가 됐고 next.js부분 부터 강의 듣기로도 바빠서 배운 내용 정리도 못했는데 다시 복습하면서 천천히 정리해 봐야겠습니다. 그리고 항상 상태관리 라이브러리로 zustand만 사용했었는데 리덕스 보니까 설계부터 중요하다고 느꼈습니다.미션을 다 못 끝냈지만.. 최대한 빠르게 끝내보겠습니다!!
2024. 10. 13.
0
[인프런 워밍업 스터디 클럽 2기 FE] 2주차 발자국
[따라하며 배우는 자바스크립터, 리액트 A-Z - John Ahn] 배운 내용 정리섹션 5: 자바스크립트 중급 this:메소드 this는 해당 객체 참조함수 this는 Window object 참조생성자 함수 this는 빈 객체 참조화살표 함수 this는 상위 스코프 this 참조call(): 인자 값을 줘서 this가 가리키는 window 객체를 인자값으로 가리키게하고 바로 호출 func.call(object, arg1, arg2);apply(): arguments를 배열로 전달 func.apply(object, [arg1, arg2]);bind(): this에 객체만 바인딩하기 때문에 사용하려면 함수 호출 func.bind(object)(arg);conditional operator: 조건식 ? 참이라면 실행할 명령문 : 거짓이라면 실행할 명령문 JS는 동기 언어로 비동기 부분은 브라우저의 도움으로 처리한다. call stack에서 비동기 함수는 Web APIs으로 보내져서 비동기 처리가 완료되면 콜백 큐로 함수가 들어오게 되고 callback queue에서는 web api의 콜백 함수들이 대기하게 된다.이벤트 루프는 call stack과 callback queue를 계속 주시하고 있다가 call stack이 비게 되면 먼저 들어온 순서대로 콜백 큐에 있는 함수들을 call stack에 넣어준다. Closure란 다른 함수 내부에 정의한 함수가 있는 경우 해당 내부 함수는 외부 함수의 변수 및 범위에 엑세스할 수 있다. array methods:map: 배열 내의 모든 요소 각각에 대하여 주어진 함수를 호출한 결과를 모아 새로운 배열을 반환// 화살표 함수는 thisArg가 들어가지 않음 const map = arr.map(callback(currentValue[,index[,array]]),[, thisArg])filter: 주어진 함수의 테스트를 통과하는 모든 요소를 모아 새로운 배열 반환const filter = arr.filter(callback(element[,index[,array]]),[, thisArg])reducer: 배열의 각 요소에 대해 주어진 reducer 함수를 실행하고, 하나의 결과값 반환const reducer = arr.reduce(callback[, initialValue]) // 4개의 인자 // 1. 누산기 accumulator // 2. 현재 값 currentValue // 3. 현재 인덱스 currentIndex // 4. 원본 배열 array undefined: 자바스크립트 엔진이 변수를 초기화할 때 사용null: 개발자가 의도적으로 변수에 값이 없다는 것을 명시 ===: 원시 자료형 비교JSON.stringify: 객체가 깊지 않은 경우 비교lodash library: 객체가 깊은 경우 비교깊은 복사의 경우 lodash, ramda 등 라이브러리 또는 structuredClone 사용 함수 선언문: function 키워드 다음 함수 이름을 작성해 함수 이름 선언, 호이스팅 영향함수 표현식: 변수에 함수 할당, 함수 이름 익명 IIFE: 즉시 실행 함수 표현 ( function(){} )()사용 목적: 변수를 전역으로 선언하는 것을 피하고( function(){} ) > IIFE 내부 안으로 다른 변수들이 접근하는 것을 막음()> Intersection-observer: 뷰포트와 설정한 요소의 교차점을 관찰하며 요소가 뷰포트에 포함되는지 포함되지 않는지 구별하는 기능 제공observe() 메소드로 타겟 요소 선정, isIntersecting가 true라면 타겟 요소와 루트 요소가 교차한다는 뜻 순수 함수는 함수형 프로그래밍 패러다임의 한 부분으로 2가지 규칙을 갖는다. 같은 입력 값이 주어졌을 때, 언제나 같은 결과를 리턴한다. 사이드 이펙트를 만들지 않는다. 이 규칙을 따름으로써 클린 코드, 쉬운 테스트, 쉬운 디버깅, 독립적인 코드를 작성할 수 있다. 커링은 함수를 호출하는 것이 아닌 병합하는 것으로 커링을 통해 여러 인자를 단계별로 받아 원하는 시점에 최종 결과를 얻을 수 있어 함수 재사용성과 유연성에 향상된다. func()()() strict mode: 기존에 무시되던 에러들을 발생하게 하고, 최적화 작업을 어렵게 만드는 실수를 바로 잡아준다.적용법:파일에 use strict 지시자 입력함수에서만 사용하려면, function func() {"use strict"; return [];}class 문법을 사용하면 자동으로 변경html type="module" 이 부분은 자바스크립트의 꽃이 아닐까 생각이 드는 섹션이었다. 프론트엔드 면접 질문 3대장인 this, 실행 컨텍스트, 클로저 부분을 배울 수 있었다. 사실 대학다닐때 웹프로그래밍을 수강했음에도 이부분에 대해서는 따로 학습하지 못했다. 이번에 학습하면서 와 이게 이렇게? 라는 부분이 많았다. 아직 프론트엔드 개발자로서 지식이 참 부족하다는 생각이 들었다. 섹션 6: OOPOOP: 여러개의 독립된 단위 "객체" 들의 모임특징:자료 추상화상속다형성캡슐화오버로딩(Overloading): 같은 이름의 메서드 여러개를 가지면서 매개변수의 유형과 개수가 다르도록 함오버라이딩(Overriding): 상위 클래스가 가지고 있는 메서드를 하위 클래스가 재정의해서 사용 Polymorphism: 같은 메소드라도 각 인스턴스에 따라 다양한 형태를 가질 수 있는 것 Prototype: 자바스크립트 객체가 다른 객체로부터 메서드와 속성을 상속받는 매커니즘생성자함수.prototype.함수 = function(){};Object.create(prototype)ES6 Classes는 Java와 사용법이 비슷했다.Sub Class(Inheritance): 부모 클래스에 있던 기능을 토대로 자식 클래스를 만들 수 있는 것, extends 키워드 사용super(): 자식 클래스 내에서 부모 클래스의 생성자/메소드를 호출할 때 사용 분명 Java 배우면서 OOP에 대해 알고 있다 생각했지만, 오버로딩, 오버라이딩이 헷갈리고 prototype이 나오자 당황해버렸다. 다시 재학습해야 할 것 같다. 섹션 7: 비동기자바스크립트는 싱글스레드이기 때문에 하나의 일이 오래 걸리면 다른 작업들은 그 하나의 일이 끝날때 까지 기다려야 한다.이러한 문제점을 해결하기 위해 비동기로 어떠한 일을 수행하게 된다.비동기 요청이 다른 비동기 요청의 결과에 의존한다면 Callback, Promise, Async/Await를 통해 문제를 해결할 수 있다.Callback(): 콜백 함수는 특정 함수에 매개변수로 전달된 함수를 의미하고 그 콜백 함수는 함수를 함수를 전달받은 함수 안에서 호출 된다.코드 가독성이 떨어지고 에러 처리를 한다면 모든 콜백에서 각각 에러 핸들링을 해줘야 한다.Promise: new 키워드와 생성자를 사용해 Promise 객체를 만들고 생성자는 매개 변수로 resolve, reject 실행 함수를 받는다.const prom = new Promise((resolve, reject) => {}); prom .then((success) => { }) .catch((err) => { }) .finally(() => { }); Promise의 상태:대기(pending): 비동기 처리 로직이 아직 완료되지 않은 상태이행(fulfilled): 비동기 처리가 완료되어 프로미스가 결과 값을 반환해준 상태거부(rejected): 비동기 처리가 실패하거나 오류가 발생한 상태// 모두 이행되면 결과 값 실행 Promise.all(순회 가능한 객체).then(() => {}); // iterable 안에서 가장 먼저 완료된 프로미스 결과값을 이행하거나 거부 Promise.race(순회 가능한 객체).then(() => {});fetch()는 promise를 지원하기 때문에 new promise 생성자를 사용하지 않아도 된다.fetch(비동기 요청).then(() => {});Async/Await:비동기 코드를 마치 동기 코드 처럼 보이고 코드 가독성이 좋다.await는 async 안에서만 사용해야 하고, try... catc구문을 사용할 수 있다.async function func() { try { const res1 = await fetch("http://abcdefg.com"); const json1 = await res1.json(); } catch (err) { console.log(err); } finally { console.log("done."); } } func(); 섹션 8: Iterator, GeneratorSymbol은 유니크한 식별자를 만들기 위해 사용한다.const sym = Symbol('val'); // 심볼 생성 console.log(sym.description); // val // Symbol.for(key): 동일한 Symbol 공유 const sym1 = Symbol.for("hi"); const sym2 = Symbol.for("hi"); console.log(sym1 === sym2); // true // Symbol.keyFor(symbol): 특정 심볼의 키 반환 const sym3 = Symbol.for("hello"); console.log(Symbol.keyFor(sym3)); // "hello" Iterator는 Iterable 객체와 함께 사용되고 next() 메서드를 이용해 컬렉션의 요소를 순차적으로 접근할 수 있게 하는 객체다.- next() 메서드를 호출할 때마다 객체의 다음 값 반환- { value: 값, done: boolean } 형태의 객체 반환 vlaue 현재 값, done 반복이 끝났는지를 나타낸다.const arr = [1, 2]; const iterator = array[Symbol.iterator](); console.log(iterator.next()); // {value: 1, done: false} console.log(iterator.next()); // {value: 2, done: false} console.log(iterator.next()); // {value: undefined, done: true} Generator는 Iterator를 쉽게 구현할 수 있도록 도와주는 함수로 function* 키워드를 사용하여 정의한다.함수 안에서 yield 키워드로 값을 반환한다.Generator는 호출되면 바로 실행되지 않고, 반복 제어가 가능하다.yield를 사용하여 값을 반환하고 이 값을 next() 메서드로 받아온다.function* generator() { yield 1; yield 2; yield 3; } const gen = generator(); console.log(gen.next()); // { value: 1, done: false } console.log(gen.next()); // { value: 2, done: false } console.log(gen.next()); // { value: 3, done: false } console.log(gen.next()); // { value: undefined, done: true } 섹션 9: Design Pattern디자인 패턴은 문제에 대한 일반적이고 재사용 가능한 솔루션을 말한다. - Singleton 패턴: 클래스의 인스턴스화를 하나의 객체로 제한하는 디자인 패턴- factory 패턴: 동일한 코드를 계속해서 반복할 필요 없이 동일한 속성을 공유하는 여러 객체를 만들어야할 때 유리- mediator 패턴: 객체 그룹에 대한 중앙 권한을 제공- observer 패턴: event-driven 시스템 이용하는 것을 말하고 게시자-구독자 모델이라고도 한다.- module 패턴: export로 모듈을 내보내고 import로 모듈을 가져온다.script 타입에 module로 명시하여 사용한다. 모듈의 특징으로는 항상 엄격 모드로 실행하고 지연 실행과 인라인 모듈 스크립트를 비동기로 처리할 수 있고, 외부 오리진에서 스크립트를 불러오려면 CORS 헤더가 있어야 하고 중복된 스크립트는 무시된다. 섹션 10: 프로젝트 만들기 2주차 회고:1주차 KPT 회고할 때 분명 리액트가 시작되기 전에 밀린 강의를 마무리 하기로 했지만.. 아직도 자바스크립트 강의가 끝나지 않았습니다. 분명 배운 내용도 있어서 금방 끝낼 수 있다고 생각했는데 근거 없는 자신감이었습니다. 새로 보는 내용이네요.. 중간점검에 참여할 때 강사님께서 말씀하신 제출을 위한 발자국 굉장히 찔렸습니다ㅎㅎ.. 부끄럽네요.. 화요일까지 남은 부분 정리하여 업데이트 하겠습니다. 다음주가 마지막이 되는데 끝까지 포기하지 않고 과제까지 끝내보도록 하겠습니다! (중꺾마)