🍀 요약
  • 개발 일정 정리
  • 페이지 내부 탭 만들기
  • API 연결

 

🍀 내용

📌 개발 일정

지난 주에 레이아웃을 만들면서 개선점을 도출했기 때문에, 이번주부터 api를 연결하면서 개선점을 반영하기로 했다. 특히 멘토님께서 야구 경기 데이터들을 시각화하는 것을 강조하셨기 때문에, Game탭 부터 api 연결을 시작했다.

그래서 정리한 개발 일정은 다음과 같다.

  • 2주차: API 연결(Game 부분, Media 부분)
  • 3주차: API 연결(Player부분, Media부분)
  • 4주차: 중간발표 준비 및 코드 디테일 통일, 애니메이션 라이브러리 사용
  • 5주차: 새로운 기능 추가 - 챗봇, 라이브채팅 기능, 소셜로그인 
  • 6주차: 트러블 슈팅, 렌더링 최적화
  • 7주차: 배포 및 최종 발표 준비

 

📌 내가 맡은 부분

1. 페이지 내부 탭 만들기

경기 일정, 박스스코어, 순위기록, 관전포인트 부분

본래 Navigation Bar로 구성하려했는데, Media 페이지를 작업하시던 다른 팀원 분이 상단 배너를 탭으로 구성해놓은 것을 발견했다.

이 UI가 더 보기에 좋아보여 팀원분이 작업하신 코드에서 상단 배너 부분을 공통 컴포넌트로 옮겨서 재구성했다.

 

🧷 hook : useTabFromUrl.tsx

import { isNullish } from '@/lib';
import { startTransition, useCallback } from 'react';
import { useLocation, useNavigate } from 'react-router';

type TabConfig = {
  value: string;
  path: string;
};
type UseTabFromUrlProps = {
  basePath: string;
  tabs: TabConfig[];
  defaultTab?: string;
};

export const useTabFromUrl = ({
  basePath,
  tabs,
  defaultTab,
}: UseTabFromUrlProps) => {
  const navigate = useNavigate();
  const location = useLocation();

  // 현재 탭 상태
  const currentTab = (() => {
    const currentPath = location.pathname;
    const matchedTab = tabs.find((tab) => currentPath.includes(tab.path));
    return matchedTab?.value || defaultTab || tabs[0].value;
  })();

  // 탭 변경 핸들러
  const handleTabChange = useCallback(
    (value: string) => {
      const targetTab = tabs.find((tab) => tab.value === value);

      // 탭이 없으면 기본 탭으로 리다이렉트
      if (!targetTab) {
        const firstTab = tabs.at(0);
        if (isNullish(firstTab?.path)) {
          return;
        }
        const fallbackPath = `${basePath}${firstTab.path}`;
        navigate(fallbackPath, { replace: true });
        return;
      }

      // 탭이 있으면 탭 경로로 리다이렉트
      startTransition(() => {
        const newPath = `${basePath}${targetTab.path}`;
        navigate(newPath, { preventScrollReset: true });
      });
    },
    [basePath, navigate, tabs]
  );

  return { currentTab, handleTabChange };
};

 

 

🧷 사용 방법

탭 이름 선언

const 해당_TABS_CONFIG = [
  { value: 'ktWiz', label: 'KT Wiz 경기' },
  { value: 'allLeague', label: '전체 리그' },
  
];

 

링크 이동 필요 시 path 사용

const NEWS_TABS_CONFIG = [
  { value: 'news', path: '/wiznews' },
  { value: 'press', path: '/wizpress' },
];

 

useTabFromUrl 호출

  const { currentTab, handleTabChange } = useTabFromUrl({
    basePath: '/media',
    tabs: NEWS_TABS_CONFIG,
    defaultTab: 'news',
  });

 

탭 정렬

{/* 탭 */}
<TabsList className="media-tabs-list">
   {GAME_TABS_CONFIG.map((tab) => (
       <TabsTrigger
          key={tab.value}
          value={tab.value}
          onClick={() =>
            handleTabChange(tab.value as 'ktWiz' | 'allLeague')
          }
          className="media-tabs-trigger px-6 py-2.5"
        >
          {tab.label}
        </TabsTrigger>
    ))}
</TabsList>

 

탭 변경 시 하단 콘텐츠 변경

<TabsContent value="ktWiz">
	{/* value 탭일 때 보여줄 컴포넌트 전달
			 -> TabsContent 자식으로 보여줄 컴포넌트나 코드 작성 */}
  <CalenderBody renderCellContent={renderCellContent} />
</TabsContent>
<TabsContent value="allLeague">
	 <div>
	   <h1>보여줄 거 작성</h1>
	   <p>하면 될 것 같아요</p>
	 </div>
</TabsContent>

 

 

2. Game - 박스스코어 데이터 연동

1. axios를 이용해 박스스코어 데이터 호출하는 코드 작성

/* src/features/game/apis/boxScore.ts */

import axios from 'axios';

const API_URL = import.meta.env.VITE_REACT_APP_API_URL;

export const getMatchData = async (gameDate: string, gmkey: string) => {
  try {
    const res = await axios.get(`${API_URL}/game/boxscore`, {
      params: { gameDate, gmkey },
    });
    if (res.status !== 200) {
      throw new Error(`Failed to fetch data. Status code: ${res.status}`);
    }
    return res.data.data;
  } catch (err) {
    console.error('박스스코어 api GET 에러: ', err);
    throw err;
  }
};

 

2. 호출한 데이터의 타입을 types/BoxscoreData.ts 에 정의 

3. 호출 파라미터(gameDate, gameKey)를 Props로 받아옴

4. 데이터 호출

5. 호출한 데이터(data)를 박스스코어 페이지의 데이터로 셋팅(setMatchData(data))

/* src/features/game/BoxScoreTab.tsx 일부 */

interface Props {
  gameDate: string | undefined;
  gameKey: string | undefined;
}

const BoxScoreTab = ({ gameDate, gameKey }: Props) => {
  const [matchData, setMatchData] = useState<BoxScoreData>();

  useEffect(() => {
    fetchMatchData();
  }, []);

  /**TODO: 최신 경기 날짜 전달 - 오늘 기준으로 경기가 있는 날짜 확인*/
  const fetchMatchData = async () => {
    if (!gameDate && !gameKey) {
      const data = await getMatchData('20241011', '33331011KTLG0');
      setMatchData(data);
    }
    if (gameDate && gameKey) {
      const data = await getMatchData(gameDate, gameKey);
      setMatchData(data);
    }
  };

 

7. 각 컴포넌트에 해당하는 데이터 부분을 props로 전달

 

 

💥트러블슈팅(to-do)

박스스코어 페이지는 메인페이지와 경기 일정 탭의 캐러셀에서 <경기정보> 버튼을 눌렀을 때 해당 경기의 박스스코어 페이지로 링크되어야한다. 이 부분을 useNavigate로 전달했는데 페이지 주소는 바뀌지만 404 페이지로 링크되는 문제가 있었다. 지금 생각해보니 Props로 gameDate와 gameKey를 받아오게 해놓고 이를 전달하지 않았기 때문인 것 같다.

import { GameSchedule } from '@/features/game/types/match-schedule';
import { format, isValid, parse } from 'date-fns';
import { useCallback } from 'react';
import { useNavigate } from 'react-router';

const CarouselCard = ({ data }: { data: GameSchedule | null }) => {
  const navigate = useNavigate();

  const formatDate = useCallback((date: string): string => {
    const parsedDate = parse(date, 'yyyyMMdd', new Date());
    return isValid(parsedDate)
      ? format(parsedDate, 'yyyy.MM.dd')
      : '날짜 정보 없음';
  }, []);

  const handleGameInfoClick = () => {
    if (data) {
      const gameDate = data.gameDate.toString();
      const gameKey = data.gmkey;
      navigate(`/game/regular/boxscore/${gameDate}/${gameKey}`);
    }
  };

  return (
    <CarouselItem
      className={
      {/* ... */}
	const CarouselCard = ({ data }: { data: GameSchedule | null }) => {
                    <button
                      type="button"
                      className="bg-gray-400 text-white rounded-full hover:bg-gray-500 py-1 px-3 w-24"
                      onClick={handleGameInfoClick}
                    >
                      경기 정보
                    </button>

 

그런데 이 부분을 수정하기 전에 비상사태가 발생했다. KT wiz에서 2025년 시즌 정보 업데이트로 인해, 27일까지 홈페이지 공사에 들어가 api가 사용이 중지된 것이다.... 27일 이후에 바로 이 부분을 수정해야겠다.

 

 

🍀 평가

 

진짜 바보같은 짓을 했는데... gmkey 를 gmKey라고 써놓아서 계속 api호출에 실패했었다!!!!!!!!! 근데 나는 console에 에러가 뜨지 않았기에, data의 상태 관리를 잘못한 줄 알았다. 그래서 이 부분을 계속 고치다가, ai를 돌리고 별 짓을 다해도 안 되길래 혹시나 해서 network탭을 보니 api를 호출한 내역이 없는거다. 이때 api 호출 코드를 뜯어보다가 api 주소를 보고 저 오타를 찾을 수 있었다. 결론적으로 엉뚱한 곳에서 약 3일동안 삽질을 했다........ 그래도 발견해서 정말 다행이다. 다음부터는 이런 실수를 하지 않겠지😂 

 

이번 주에는 다른 사람들의 코드를 볼 일이 많았다. 다른 사람의 작업에 내 작업을 얹기도 했고, 팀장을 맡아 모든 팀원들의 PR을 보았기 때문이다. 다른 사람의 코드를 이해하고 리뷰하면서 배우는 점이 많았다. 실무에 나가면 내 코드보다 다른 사람의 코드를 보는 일이 훨씬 많다고 했다. 실무 연습에 많은 도움이 된 것 같다.

🍀 요약
  • 프로젝트 셋팅 
  • 역할 분담
  • 레이아웃 작업
🍀 내용

 

프로젝트는 선착순으로 폼을 제출한 순서대로 배정되었는데, 나는 이 프로그램에 참여하기 전부터 하고 싶었던 KT wiz 홈페이지 개선 프로젝트에 배정되었다!! (수 년 간의 폼림픽으로 다져진 경험이 빛을 발하는 순간)

팀원 분들도 오프라인 교육장에서 가까운 자리에 앉아서 내적 친밀감이 있던 분들과 한 팀이 되어 기뻤다.ㅎㅎ

 

📌 프로젝트 셋팅

기술 스택

Framework  React + Vite
Language TypeScript
Styling Tailwind CSS, Shadcn UI, GSAP
API / Store Axios, Zustand
Project Management Biome, Husky

 

팀원 개개인의 개발 실력을 고려해서 스택을 정했다. Next 와 React 중에 선택할 수 있었는데 아직 React를 배우는 단계인 팀원들이 대다수였기 때문에(나 포함) 리액트로 프레임워크를 정했다. 사전직무교육기간에 배운 Tailwind CSS와 Shadcn UI, Axios를 활용하기로 했고, 전역 상태 관리를 위해 Zustand라는 라이브러리를 활용하기로 했다. 사전직무교육기간에는 Jotai를 썼었지만, 이 라이브러리가 스토어(Store)를 사용해서 컴포넌트 간 공유할 데이터를 중앙에서 관리하는 현재 가장 많이 사용되는 라이브러리였기 때문이다.

상태 관리 라이브러리 비교

 

우리는 개발방향을 화려한 모션 을 넣는 것으로 정했다. 아무래도 프론트엔드만 작업하다보니 기능 구현에서 보여줄 수 있는 것은 한정적이므로, 우수 프로젝트로 선정되기 위해서는 이목을 끄는 것이 중요하다고 생각했기 때문이다. GSAP는 웹 화면에 쉽게 사용할 수 있는 강력한 타임라인 기반의 애니메이션 자바스크립트 라이브러리이다. 이를 이용해 인터랙티브한 웹사이트를 만들어보기로 했다. 근데 일정 정리를 하다보니까 할 수 있을지 모르겠다

 

깃허브에 프로젝트 셋팅은 2년차 개발자이신 팀원분이 BiomeHusky를 사용하여 해주셨다.

 

본래 나는 ES Lint + Prettier를 활용하고 있었는데, Biome은 모호한 오류 메시지를 피하고, 문제가 있을 때 정확히 어디가 문제인지와 해결 방법을 알려주어 편리했다. 또한 ESLint는 2020년부터 새로운 규칙이 추가되지 않고 있지 않은데, Biome는 formatter와 linter rule이 200+이상 있고 현재도 계속 linter rule이 추가되고 있다. 또 Biome는 개발 환경을 단순화하여 ESLint보다 빠르게 개발 환경을 설정할 수 있다고 한다.

 

Husky란 git hook 설정을 도와주는 package이다.

  🤔 여기서 git hook 이란?
     Git 과 관련한 어떤 이벤트가 발생했을 때 특정 스크립트를 실행할 수 있도록 하는 기능을 말한다. (ex. commit, push)

 

husky는 npm install 과정에서 사전에 세팅해둔 git hook을 다 적용시킬 수 있어서 모든 팀원이 git hook 실행이 되도록 하기 편하다.

 

우리는 커밋 템플릿을 설정했기 때문에, 이를 팀원 모두에게 적용할 수 있도록 강제하기 위해 hook을 설정했다. 따라서 husky로 이 템플릿을 지키지 않았을 때  merge, push 와 같은 과정에서 작업을 중단하도록 할 수 있다.

 

 

스크럼 규칙

  • 매일[월-금] 1시에 10분~20분 회의 후 6시까지 개발 코어타임 [디스코드]
  • 매주 (수)는 대면 회의
  • 진행도 점검
  • 그 날 뭐 개발할지 공유
  • 이슈나 문제 있으면 어떻게 해결하면 좋을지
  • 카메라 on (자율)

 

브랜치 전략

  • main: 배포
  • dev: 개발
  • features: 이슈 별 
    • ex) 이슈 #1에 대한 개발 브랜치 명 features #1
    • 이슈는 이슈 템플릿에 따라 생성
  • features #1을 나중에 dev 브랜치에 합병

 

📌 역할 분담

아직 API가 없었기에, 이번주는 피그마와 KT wiz 웹사이트를 보면서 레이아웃을 잡기로 했다.

먼저 공통 디자인을 정했다.

 

- 배경: 전체적으로 데이터들의 가독성을 높이기 위해 배경을 어두운색으로 정했다.

- 공통 색상: KT wiz의 대표 색상 (빨강, 검정, 하양)

  1. 바탕: 검정(#141414)
  2. 텍스트 : 하얀색(#FFFFFF), 좀 어두운 하얀색(#ECEEF2)
  3. 강조: 빨간색(#D60C0C)
  4. 필요 시: 회색(#35383e) → 직접 입력

 

그 다음 각 페이지 별로 역할을 나누어 레이아웃을 작업했다. 각자 맡은 페이지에서 개선할 점을 도출하면서 그때그때 회의록에 작성했고, 필요한 디자인을 찾아 반영했다. 그래서 빠르게 전체 레이아웃 작업을 마칠 수 있었다.

 

📌 내가 맡은 부분

나는 Kt wiz Park 부분을 맡았다. 원래 지도 부분은 이미지였는데 여기서 카카오지도 api를 연결해서 화면을 동적으로 바꾸었다. 주변 교통량을 표시하는 속성도 추가했다.

 

💥 트러블슈팅

div에서 border를 설정했는데 안 보이는 문제가 있었다.

이는 border의 width를 빼먹었기 때문이였다. border를 표시하기 위해서는 border-width, border-style, border-color 이 세 가지를 반드시 설정해야한다. 

<div class="flex w-auto h-auto border-2 border-solid border-black bg-white">
    <p>
        주소 : 경기도 수원시 장안구 경수대로 893(조원동) 수원 케이티 위즈 파크
        (구 : 경기도 수원시 장안구 조원동 775)
    </p>
</div>

 

tailwind에서는 border-숫자 로 width를 표현한다.

 

🍀 평가

 

학교 수업과 동아리 이외에, 기업과 함께 이렇게 제대로 된 개발 프로젝트를 하는 것은 처음이다. 팀원분들과 대화를 나누면서 개발적인 측면보다 Git 플로우나 협업 방식에서 내가 모르는 점이 많아서 놀랐다. 일경험사업을 해본 경험이 있고 나름 프로젝트 경험이 많다고 생각해서 팀장을 하겠다고 했는데, 내가 팀장으로서 부족한 점이 매우 많은 것 같았다... 그래서 팀원분들에게 많이 배우고 소통해서 프로젝트를 원활하게 이끌어나가야겠다고 생각했다. 적극적인 팀원분들을 만나서 정말 행운이다.

🍀 What I learned this week

📌  Next.js와 Supabase를 이용한 TO-DO List 개발

🧷 Supabase와 연동하여 TO-DO List의 CRUD 기능 구현

🧷 사용자 정보와 TO-DO List를 연동하여 로그인, 로그아웃, 프로필 수정 기능 구현

 

📌  Next.js와 Supabase를 이용한 TO-DO List 개발

 

<데이터베이스 테이블 구조>

 

처음에 로그인 기능을 구현하기 전에는, todos와 board 테이블 2개를 생성하여 작업했다.

  • todos: TO-DO 목록을 관리하는 테이블
  • boards: 각 TO-DO 당 세부 내용 보드를 관리하는 테이블

 

로그인을 위해 Supabase에서 제공하는 Auth.users 테이블을 사용할 수 있었다. 회원가입을 하면 자동으로 여기에 row가 추가된다.  이 테이블의 id값을 todos와 profiles의 FK로 활용했다. 각 유저 당 TO-DO 목록을 관리하기 위해서다. id와 password 외에 더 많은 사용자 데이터를 다루기 위해 profiles 테이블을 새로 생성해주었다. (Auth.users에서 metadata로 다루는 방법도 있다고 하는데 이 방법은 잘 모르겠어서 새로운 테이블을 만들고 user.id와 FK로 연동했다.)

  • profiles: 회원 가입 시 생성한 사용자 정보를 관리하는 테이블

🧷 데이터 전역 관리를 위해 Jotai의 Atom 사용

import { Board, Todo, User } from '@/types';
import { atomWithStorage } from "jotai/utils";
import {atom} from 'jotai';

/** supabase에 저장되어 있는 table 내에 있는 모든 데이터 조회 */

/** 전체 todo 목록 조회 */
export const todosAtom = atom<Todo[]>([]);

/* 단일 개별 Todo 상태 */
export const boardAtom = atom<Board[]>([]);

export const userAtom = atomWithStorage<User | null>("user", null);

 

이를 위해 types 폴더에서 각각 파일로 type을 선언했다.

Todo

interface Todo {
    id: number;
    title: string;
    from_date: Date;
    to_date: Date;
    boards_id: number;
  }

export type {Todo}

Board

export interface Board {
    id: number; //board만의 id
    title: string;
    from_date: Date;
    to_date: Date;
    contents: string;
    is_checked: boolean;
    todo_id: number;
  }

User

interface User {
    id: string,
    name: string,
    email: string,
    avatar: string,
};

export type {User}

 

🧷 Supabase와 연동하여 TO-DO List의 CRUD 기능 구현

TO-DO 생성

리액트의 hook을 연습하기 위해 이를 따로 훅으로 분리했다.

"use client";

import { useToast } from "@/hooks/use-toast";
import { supabase} from "@/lib/supabase";
import { todosAtom, userAtom } from "@/stores/atom";
import { useAtom, useAtomValue } from "jotai";
import { useRouter } from "next/navigation";

export function useCreateTodo() {
  const router = useRouter();
  const user = useAtomValue(userAtom);
  const [todos, setTodos] = useAtom(todosAtom);
  const {toast} = useToast();

  const createPage = async () => {
    try {
      const { data, status, error } = await supabase
        .from('todos')
        .insert({
          user_id: user?.id,
          title: null,
          from_date: null,
          to_date: null,
        })
        .select();

      if (data && status===201 ) {
        setTodos((prevTodos)=> [...prevTodos, data[0]]); //상태 업데이트

        router.push(`/board/${data[0].id}`);
        console.log(data);
      }

      if (error) {
        toast({
          variant: "destructive",
          title: '에러가 발생했습니다.',
          description: `Supabase 오류: ${error.message || "알 수 없는 오류"}`
        });
      }
    } catch (error) {
      /**네트워크나 예기치 않은 오류 */
      toast({
        variant: "destructive",
        title: '네트워크 오류',
        description: '서버와 연결할 수 없습니다. 다시 시도해주세요.'
      });
    }
  };

  return createPage;
}

 

이를 다음과 같은 방식으로 활용했다.

import { useCreateTodo } from "@/hooks/api/supabase";


function Home() {
  
  const createPage = useCreateTodo();
 
/*...*/

 

Board CRUD / TO-DO 삭제

BoardPage에서 각 TO-DO의 Board들을 관리했다.

 const router = useRouter();
  const { toast } = useToast();

  const params = useParams();
  const tid = params.id;

  const [boards, setBoards] = React.useState<Board[]>([]);
  const [todoTitle, setTodoTitle] = React.useState<string>('');
  const [todoStartDate, setTodoStartDate] = React.useState<Date>();
  const [todoEndDate, setTodoEndDate] = React.useState<Date>();
  const [progress, setProgress] = React.useState<number>(0);

  React.useEffect(() => {
    getTodoTitleAndDate();
    getBoards();
  }, [tid]);

 

 

useParams로 url에 있는, 해당하는 todo의 id를 받아온다. 이를 tid라 정의했다. tid에 따라 렌더링 결과가 달라지므로 이를 의존성 배열에 주입했다.

 

 

  • Board 생성
 const createBoard = async () => {
    try {
      const { data, error } = await supabase
        .from('boards')
        .insert({
          title: '제목을 입력해주세요.',
          from_date: undefined,
          to_date: undefined,
          contents: '',
          is_checked: false,
          todo_id: tid,
        })
        .select();

      if (data) {
        setBoards((prevBoards) => [...prevBoards, ...data]);
        toast({
          title: '새로운 TODO-BOARD를 생성했습니다.',
          description: '생성한 BOARD를 예쁘게 꾸며주세요!',
        });
      }
/*...*/

 

  • Todo 삭제 : tid(todo id)와 일치하는 id(board id)의 모든 board들을 삭제하고, todo도 삭제한다.
async function deleteTodo() {
    if (confirm('이 TODO 페이지를 삭제하시겠습니까?') === true) {
      try {
        const { data } = await supabase
          .from('boards')
          .delete()
          .eq('todo_id', tid);
        const { error } = await supabase
          .from('todos')
          .delete()
          .eq('id', tid)
          .select();

        router.push('/');
        toast({
          variant: 'default',
          title: '해당 TODO 삭제를 완료했습니다.',
          description: '새로운 TODO가 생기면 언제든 추가해주세요!',
        });
/*...*/

 

  • Board 업데이트, 삭제
 const handleBoardChange = React.useCallback(
    async (changedBoardData: Board) => {
      setBoards((prevItems) =>
        prevItems.map((item) =>
          item.id === changedBoardData.id ? changedBoardData : item
        )
      );

      try {
        const { error } = await supabase
          .from('boards')
          .update(changedBoardData)
          .eq('id', changedBoardData.id);

        if (error) {
          toast({
            variant: 'destructive',
            title: '에러가 발생했습니다.',
            description: `Supabase 오류: ${error.message || '알 수 없는 오류'}`,
          });
        }
      } catch (error) {
        toast({
          variant: 'destructive',
          title: '네트워크 오류',
          description: '서버와 연결할 수 없습니다. 다시 시도해주세요.',
        });
      }
    },
    []
  );

  const updateBoardChange = async () => {
    const { error } = await supabase.from('boards').upsert(boards);
  }; //데이터베이스에 모든 보드의 항목을 업데이트

  const handleDelete = (id: number) => {
    //UI업데이트
    setBoards((prevItem) => prevItem.filter((item) => item.id !== id));
    toast({
      title: '선택하신 TODO-BOARD가 삭제되었습니다.',
      description:
        "새로운 TODO-BOARD를 생성하려면 'Add New Board' 버튼을 눌러주세요!",
    });
  };

 

이 페이지는 여러 컴포넌트들이 중첩되어있다. 따라서 자식 컴포넌트가 onChange라는 Prop으로 부모컴포넌트에 자신의 변화를 알려주도록했다. 또한 board 삭제는 이 페이지에서는 렌더링만 담당한다. 각 단일 보드를 삭제하는 것이기 때문에 실제 삭제는 자식 컴포넌트인 BoardItem에서 구현했다.

/* 예시: BoardItem.tsx에서 BoardPage에 데이터 변경을 알리는 함수 구현 */ 

interface Props {
  data: Board;
  onDelete: (id: number) => void; //부모 컴포넌트에서 상태 업데이트
  onChange: (changedBoardData: Board) => void;
}

function BoardItem({ data, onDelete,onChange}: Props) {
  const [item, setItem] = useState<Board>(data);

  useEffect(() => {
    setItem(data);
  }, [data]);
  
  const handleBoardChange = (changedBoardData: Board) => {
    setItem(changedBoardData); //UI
    onChange(changedBoardData); //부모 컴포넌트에 변경 알림

  };
  
   const deleteBoard = async (id: number) => {
    try {
      const { error } = await supabase.from('boards').delete().eq('id', id); //DB에서 제거
      if (error) throw error;
      onDelete(id); //부모 컴포넌트에 삭제 알림
    } catch (error) {
      console.error('board delete 오류: ' + error);
    }
  };
  /*...*/

 

BoardItem.tsx 에서도 동일한 방식으로 자식 컴포넌트의 변화를 감지하도록 했다.

 

🧷 사용자 정보와 TO-DO List를 연동하여 로그인, 로그아웃, 프로필 수정 기능 구현

로그인 페이지

사용자 정보를 Jotai의 atom에 저장하여 전역적으로 관리할 수 있도록 했다.

즉 로그인을 하면, profile테이블에 저장되어있는 정보를 가져와 atom과 브라우저 내 쿠키에 저장한다.

function LoginPage() {
  const supabase = createClient();
  const router = useRouter();

  const [_, setUser] = useAtom(userAtom);
  const [emailInput, setEmailInput] = useState('');
  const [passwordInput, setPasswordInput] = useState('');

  const signIn = async () => {
    try {
      const {
        data: { user },
        error,
      } = await supabase.auth.signInWithPassword({
        email: emailInput,
        password: passwordInput,
      });

      if (user) {
        //console.log(user);

        /*쿠키에 저장할 유저데이터:profiles에서 찾음 */
        const { data: profileData, error: profileError} = await supabase.from('profiles').select().eq('id',user.id).single();
        
        if(profileData){
          //console.log(profileData);
          const userData = {
            id: user.id,
            name: profileData.user_name,
            email: profileData.email,
            avatar: profileData.avatar_url,
          };

          document.cookie = `user=${JSON.stringify(
            userData
          )}; path=/; max-age=3600`; //한 시간 동안 유효
  
          setUser(userData);

          toast({
            title: '로그인을 성공하였습니다.',
            description: '자유롭게 To-Do 관리를 해주세요!',
          });
          router.push('/board'); // 로그인 페이지로 이동
        }
      }

      if (error) {
        toast({
          variant: 'destructive',
          title: '에러가 발생했습니다.',
          description: `Supabase 오류: ${error.message || '알 수 없는 오류'}`,
        });
      }
    } catch (error) {
      toast({
        variant: 'destructive',
        title: '네트워크 오류',
        description: '서버와 연결할 수 없습니다. 다시 시도해주세요!',
      });
    }
  };

 /*...*/ 
 }

 

 

회원가입 페이지

사용자로부터 이름, 이메일, 비밀번호를 입력받고 그 정보를 profiles 테이블에 저장한다.

프로필 사진은 일단 정적으로 설정해두었다. 

'use client';

/* ... */

function SignupPage() {
  const router = useRouter();
  const supabase = createClient();

  const [nameInput, setNameInput] = useState('');
  const [emailInput, setEmailInput] = useState('');
  const [passwordInput, setPasswordInput] = useState('');

  const saveUserEmail = (e: React.ChangeEvent<HTMLInputElement>) => {
    setEmailInput(e.target.value);
  };

  const saveUserPassword = (e: React.ChangeEvent<HTMLInputElement>) => {
    setPasswordInput(e.target.value);
  };

  //DB저장
  const handleSignup = async () => {
    try {
      if (passwordInput.length < 6) {
        alert('비밀번호는 6자리 이상이여야합니다.');
      }

      const {
        data: { user },
        error,
      } = await supabase.auth.signUp({
        email: emailInput,
        password: passwordInput,
      });

      if (error) {
        toast({
          variant: 'destructive',
          title: '에러가 발생했습니다.',
          description: `Supabase 오류: ${error.message || '알 수 없는 오류'}`,
        });
      }

      if (user) {
        //user가 생성되면
        //profile db에 추가
        const { data, error: profileError } = await supabase
          .from('profiles')
          .insert({
            id: user?.id,
            user_name: nameInput,
            avatar_url: '/assets/profile.jpg',
            email: emailInput,
          })
          .select();
        
        if (profileError) throw profileError;

        toast({
          title: '회원가입 성공',
          description: '프로필이 생성되었습니다.',
        });

        router.push('/');
      }
    } catch (error) {
      toast({
        variant: 'destructive',
        title: '에러가 발생했습니다.',
        description: '네트워크 오류',
      });
    }
  };
/* ... */
}

 

 

로그아웃 기능

const handleLogout = async () => {
    try {
      const { error } = await supabase.auth.signOut();

      document.cookie = 'user=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/;';
      localStorage.clear();

      router.push('/');
      toast({
        title: '로그아웃을 완료하였습니다.',
        description: 'TASK 관리 앱을 사용해주셔서 감사합니다.',
      });
/* ... */
  • cookie의 날짜를 아예 과거 (1970년 1월 1일)로 설정하여 쿠키가 만료되도록 했다. 
  • localStorage.clear()로 데이터를 삭제해준다.
  • router.push('/')로 초기 화면(로그인)으로 이동한다.

 

프로필 수정 기능

interface Props {
  onClose: () => void;
}

export function ProfileCard({ onClose }: Props) {
 const [user, setUser] = useAtom(userAtom);
  const [userName, setuserName] = React.useState(user?.name);

  const changeUserName = async () => {
    try {
      const { error } = await supabase
        .from('profiles')
        .update({ user_name: userName })
        .eq('id', user?.id);

         // 전역 상태 업데이트
        setUser((prevUser): User | null  => {
            if (prevUser === null) return user;
            return {...prevUser, name: userName || ''}
        });

        onClose();
    
        if (error) throw error;
    
    } catch (error) {
        console.error('네트워크 오류');
    }
  };
/*...*/

 

먼저 저장된 userAtom을 가져와야한다.

여기서는 사용자의 이름을 수정하기 위해, userAtom으로 가져온 user의 이름을 userName으로 받아온다.

changeUserName() 함수에서 supabase 테이블을 update하고, 새 이름을 렌더링 하기 위해 setUser로 상태를 업데이트 한다.

이때 주의해야할 점은 atom을 썼으므로 전역으로 상태를 업데이트 해야한다는 것이다. (처음에 atom을 가져오지 않아 수정된 이름이 렌더링 되지않는 문제가 발생했었다.) 이때 prevUser-변경되기 전 user의 데이터-와 변경된 이름(userName)을 렌더링한다. 따라서 이름을 제외한 user의 데이터들과 변경된 이름(userName)이 렌더링 된다.

 

 

 

소감

개인적으로 아직 상태관리에 익숙하지 않아 어려웠다. UI 상태 변경과 데이터베이스 업데이트를 동시에 신경써야했기 때문이다. 특히 Auth기능은 localStorage와 쿠키도 이용해야해서 어려웠다. 그만큼 배운점이 많았던 프로젝트였고 앞으로 리팩토링을 하면서 더 정리해보아야 할 것 같다.

 

 

2주차까지 CSS가 너무 어려웠는데, 계속 하다보니 이제 어느 정도 감이 잡혀 tailwindCSS를 다룰 수 있게 되었다.

아직 useEffect, useMemo, useCallback 등 React의 Hook들을 쓰는 것이 어렵다. 그래서 죄다 useEffect로 구현했는데 효율성이 떨어지는 것 같다. Hook에 대한 개념을 다져서 코드를 더 효율적으로 리팩토링 해야겠다.

 

 

전체 소스코드: https://github.com/mal0070/next-board

 

GitHub - mal0070/next-board

Contribute to mal0070/next-board development by creating an account on GitHub.

github.com

 

 

 

——————————————————————————

본 후기는 [유데미x스나이퍼팩토리] 프론트엔드 프로젝트 캠프 과정(B-log) 리뷰로 작성 되었습니다.

🍀 What I Learned this week

📌 이미지 오픈 API를 통해 이미지 정보 조회 사이트 만들기

📌  Next.js와 Supabase를 이용한 TO-DO List 개발

 

이번주는 총 2개의 프로젝트를 진행하면서 React와 Next, 그리고 Typescript에 대해 자세히 배울 수 있었다.

일주일 만에 2개나 프로젝트를 만들어서 어려웠지만 정말 배운 점이 많았다. 

📌  이미지 오픈 API를 통해 이미지 정보 조회 사이트 만들기

 

구현한 기능

🥯 Jotai 라이브러리의 store를 이용하여 API fetch

 

  • Jotai 라이브러리란 ?
    •  중앙집중식 상태관리 라이브러리
    • props에 대한 depth가 너무 깊어지는 걸 막기 위해 사용한다.(데이터 추적 어려움)
    • atom (=state) : 전역에서 쓰이는 상태값
// src/store/index.ts

import { atom } from "jotai";
import axios from "axios";
export const searchValueAtom = atom<string>('korea');
export const pageAtom = atom<number>(1);
export const fetchAPI = async (searchValue: string, page: number) => {
    const API_KEY = 'gcyfsAL2xYOU7tSWNxnPBikSgoeze88F9cdW2zNwjNM';
    const BASE_URL = 'https://api.unsplash.com/search/photos';
    try {
      const res = await axios.get(
        `${BASE_URL}/?query=${searchValue}&page=${page}&per_page=30&client_id=${API_KEY}`
      );
      return res;
      } catch (error) {
      console.error('API 호출 중 오류 발생');
      throw error;
    }
  }; //store에 모듈화시켜서 코드 재사용성 높임

 

이를 Hompage에서, 서버API로부터 이미지 데이터들을 불러올 때 이용했다.

//src/views/HomePage.tsx

/*...*/

import { useAtom } from 'jotai';
import { fetchAPI, pageAtom, searchValueAtom } from '@/store';

function HomePage() {
  const { toast } = useToast();

  const [searchValue] = useAtom(searchValueAtom);
  const [page] = useAtom(pageAtom);
  const [images, setImages] = useState([]);
  const fetchImages = useCallback(async () => {
    try {
      const res = await fetchAPI(searchValue, page);
      
      if (res.status === 200 && res.data) {
        setImages(res.data.results);
        console.log(res.data);
      } else {
        toast({
          variant: 'destructive',
          title: 'API 호출 실패',
          description: 'API 호출을 위한 필수 파라미터 값을 체크해보세요!',
        });
      }  
    } catch(error) {
      console.log(error);
    }
  }, [searchValue, page, toast]); //필요한 의존성들만 주입
  /*const fetchAPI = async () => {
    const API_KEY = 'gcyfsAL2xYOU7tSWNxnPBikSgoeze88F9cdW2zNwjNM';
    const BASE_URL = 'https://api.unsplash.com/search/photos';
@@ -36,11 +60,11 @@ function HomePage() {
    } catch (error) {
      console.log(error);
    }
  };*/

  useEffect(() => {
    fetchImages();
  }, [fetchImages]); //fetchImages가 변경될 때만 실행

  return (
    <div className="page">
    
   /*...*/

 

 

🥯 navigation bar

동적 라우팅을 위해 경로에 ':id'를 지정했다.

//src/App.tsx

/*...*/

<BrowserRouter>
    <Routes>
      <Route path="/" element={<HomePage/>}></Route>
      {/* 동적 라우팅 */}
      <Route path="/search/:id" element={<HomePage />}></Route>
      <Route path="/bookmark" element={<Bookmark/>}></Route>
    </Routes>
    <Toaster/>
    
  /*...*/

 

 

Homepage에 Nav컴포넌트가 추가되어있으므로, 이 컴포넌트의 값에 따라 다른 주소로 접근할 수 있게 된다.

 

Nav컴포넌트에서 useLocation()으로 현재 URL 정보를 가져왔다.

// src/components/common/nav/Nav.tsx

import { useEffect, useState } from 'react';
import { useAtom } from 'jotai';
import navJson from './nav.json';
import { Link, useLocation } from 'react-router-dom';
import styles from './nav.module.scss';
import { searchValueAtom } from '@/store';

interface Nav {
  index: number;
  path: string;
  label: string;
  searchValue: string;
  isActive: boolean;
}

function Nav() {
  const location = useLocation();
  const [searchValue, setSearchValue] = useAtom(searchValueAtom);
  const [navItem, setNavItem] = useState<Nav[]>(navJson);

  useEffect(()=> {
    navItem.forEach((nav: Nav)=> {
      nav.isActive = false;
      
      if(nav.path === location.pathname || location.pathname.includes(nav.path)){
        nav.isActive = true;
        setSearchValue(nav.searchValue);
      }
    });
    setNavItem([...navItem]);
  },[location.pathname])
  
  /* ... */

 

 

🥯 localStorage를 이용해 북마크 데이터 저장

  • localStorage란?
    • 웹 스토리지(web storage)에는 로컬 스토리지(localStorage)와 세션 스토리지(sessionStorage)가 있다.
    • 세션 스토리지는 웹페이지의 세션이 끝날 때 저장된 데이터가 지워지는 반면에, 로컬 스토리지는 웹페이지의 세션이 끝나더라도 데이터가 지워지지 않는다.
    • 다시 말해, 브라우저에서 같은 웹사이트를 여러 탭이나 창에 띄우면, 여러 개의 세션 스토리지에 데이터가 서로 격리되어 저장되며, 각 탭이나 창이 닫힐 때 저장해 둔 데이터도 함께 소멸한다. 반면에, 로컬 스토리지의 경우 여러 탭이나 창 간에 데이터가 서로 공유되며 탭이나 창을 닫아도 데이터는 브라우저에 그대로 남아 있다.

로그인 기능을 구현하지 않았기에, 한 유저에 대한 북마크 저장 목록을 유지하기 위해 로컬 스토리지를 사용했다.

 

트러블 슈팅

localStorage를 이용해서 북마크 데이터를 저장할 때, 처음에 그냥 findIndex썼더니 오류가 났다.

    const addBookmark = (imageData: ImageDataType) => {
        console.log(imageData);
        const getLocalStorage = localStorage.getItem("bookmark");
        let bookmarks: ImageDataType[] = [];
        if (getLocalStorage) {
            try {
                bookmarks = JSON.parse(getLocalStorage); //parsing을 할 땐 try-catch문으로 에러를 방지해주는 것이 좋다. 
            } catch (error) {
                console.error("Error parsing localStorage:", error);
                bookmarks = [];
            }
        }
        if (bookmarks.length === 0) {
            localStorage.setItem("bookmark", JSON.stringify([imageData]));
            toast({
                title: "로컬스토리지에 올바르게 저장되었습니다.",
            });
        } else {
            const imageExists = bookmarks.findIndex((item: ImageDataType) => item.id === imageData.id) > -1;
            if (imageExists) {
                toast({
                    variant: "destructive",
                    title: "로컬스토리지에 해당 데이터가 이미 저장되어 있습니다.",
                });
            } else {
                bookmarks.push(imageData);
                localStorage.setItem("bookmark", JSON.stringify(bookmarks));
                toast({
                    title: "로컬스토리지에 올바르게 저장되었습니다.",
                });
            }
        }
    };

 

=> Uncaught TypeError: bookmarks.findIndex is not a function at addBookmark

 

타입스크립트는 자바스크립트와 달리, 타입을 정확하게 지정해줘야한다! 따라서 Array인 경우 로직을 수행하도록 명확하게 경우를 구분지어주었다.

 const addBookmark = (imageCard: ImageCardType) => {
    console.log(imageCard);

    let bookmarks: ImageCardType[] = [];
    const getLocalStorage = localStorage.getItem('bookmark'); //null || string

    if (getLocalStorage) {
      try {
        const parsedBookmarks = JSON.parse(getLocalStorage);
        if (Array.isArray(parsedBookmarks)) {
          bookmarks = parsedBookmarks;
        } else {
          console.error("Stored data is not an array");
        }
      } catch (error) {
        console.error("Data parsing error: ", error);
      }
    }
    
    const imageExists = bookmarks.findIndex((item: ImageCardType) => item.id === imageCard.id);
  
    if (imageExists > -1) {
      console.log('Data is already saved.');
    } else {
      bookmarks.push(imageCard);
      localStorage.setItem('bookmark', JSON.stringify(bookmarks));
      console.log('Data has been saved.');
    }
  };

 

 

 

소스코드 : https://github.com/mal0070/react-album

 

 

이 프로젝트는 지난주와 이번주에 걸쳐서 진행되었는데, 복습 겸 구현했던 것을 정리해보았다.

이 주에 진행한 TO-DO List 개발은 다음주에 마무리지으면서 정리해보겠다.

 

 

 

 

 

——————————————————————————

본 후기는 [유데미x스나이퍼팩토리] 프론트엔드 프로젝트 캠프 과정(B-log) 리뷰로 작성 되었습니다.

🍀 What I Learned this week

📌 자바스크립트 문법

  🧷 자바스크립트는 프로토타입 기반 언어!

  🧷 배열

  🧷 this의 의미

  🧷 콜백함수와 비동기 함수, 에러 핸들링

  🧷 클래스

📌 React 프로젝트: Vite + Shadcn UI를 활용, tailwind.css 사용 

  🧷 오픈 날씨 API를 활용한 리액트 대시보드 만들기

  🧷 이미지 오픈 API를 통해 이미지 정보 조회 사이트 만들기

📌 타입스크립트 개요

 

 

📌 React 프로젝트: Vite + Shadcn UI를 활용, tailwind.css 사용 

 

프로젝트 초기 설정을 하는 방법은 다음 포스트에 있다. ⬇️⬇️

https://blog.naver.com/weartstudio/223645181841

  🧷 오픈 날씨 API를 활용한 리액트 대시보드 만들기

 

 

GitHub 링크: https://github.com/mal0070/weather-dashboard

  🧷 이미지 오픈 API를 통해 이미지 정보 조회 사이트 만들기

 

 

GitHub링크: https://github.com/mal0070/react-album

 

 

 

 

📌 타입스크립트 개요

 

바닐라 JS는 자유도가 너무 높다는 함정이 있다. 따라서 이를 보완하기 위해, 타입이 있는 Javascript인 Typescript가 등장했다.

즉 타입스크립트는 자바스크립트의 상위 집합이다.

 

  • 변수 선언 시 타입을 지정하거나,  리터럴 타입으로 선언할 수 있다.
let rocker; //값은 undefined, 타입은 any
  • OR: 유니언타입 → 어떤 타입인지 모르지만 두 개이상 옵션 중 반드시 하나가 될 수 있는 가능성을 가지고 있는 타입!
    • 두가지 가능성을 가지고 있기 때문에, 어느 하나의 프로토타입 메서드를 쓸 수 없다.
  • 옵셔널 → 값이 있거나 없거나

 

🍀 소감

 

나에게 부족한 부분을 발견할 수 있었던 주였다. CSS를 제대로 공부해본 적이 없어서 화면 레이아웃을 구성할 때 이해가 잘 되지 않았다. 특히 grid와 flex가 어려웠다. 강사님께서 프론트엔드 개발자로서 중요한 역량은 '화면을 보고 어떻게 컴포넌트로 구성할 것인지' 라고 하셨다. 그동안 나는 자바스크립트 언어 공부에 집중했으니 이제 HTML과 CSS로 화면을 구성하는 연습을 해야겠다고 생각했다. 이를 리액트를 추가적으로 공부하면서 함께 해야겠다.

 

또 다음주부터는 WIL보다 TIL을 작성해보겠다. (배우는 양이 많아 WIL로 압축하기가 힘들었다😅)

 

 

 

 

 

——————————————————————————

본 후기는 [유데미x스나이퍼팩토리] 프론트엔드 프로젝트 캠프 과정(B-log) 리뷰로 작성 되었습니다.

+ Recent posts