이번주 월요일부터 개발작업에 들어갔다. 와이어프레임이 거의 나와서(혜원님의 열일) 개발을 바로 시작할 수 있었다. 내가 맡은 기능은 아래와 같다. 우선 월요일에 특산물 전체보드와 상세페이지를 대충 끝냈고, 화요일에 피그마에 나와있는대로 섬세하게 ui를 조정했다.

  • 특산물 판매 전체 보드
  • 특산물 상세페이지
  • 장바구니
  • 메인페이지

 

대망의 장바구니 .. 회원가입 후 쿠폰을 𖤐발급𖤐 해줘서 장바구니에 담은 상품을 결제할 때 사용해야한다.(무조건) 쿠폰함을 따로 만들어 선택하는 것이 어떠냐는 의견이 나왔지만 .. 내가 생각해도 그것이 사용자 입장에서 너무 좋겠지만 .. 시간대비 효율이 너무 떨어질 것 같았다.. 우리한테 남은 시간이 그렇게 많지 않기 때문에 추후 선택 사항으로 남겨놓았다. 근데 뭔가 하면 할수록...... 기능들이 더 생겨요... 왜...

 

 


 

 

🥑 상세페이지

상세페이지에서 로직은 아래와 같다.

장바구니 담기 => '장바구니로 이동하시겠습니까?'
문구 출력과 확인 버튼을 누르면 장바구니로 이동

이미 같은 상품이 담겼다면 => '이미 담긴 상품입니다. 장바구니로 이동하시겠습니까?' 
문구 출력과 확인 버튼을 누르면 장바구니로 이동

 

🧺 장바구니

장바구니에 담은 상품이 없다면 => 디폴트 이미지 출력 + '상품을 담아주세요' 버튼

장바구니에 상품이 담겨있다면 => 상품이미지, 상품명, 가격, 수량 + '바로 구매하기' 버튼

 

 

 

 

 

장바구니 담기

우선 장바구니는 회원 전용으로 운영할 것이기 때문에 사용자가 로그인 했는지 확인한다. 로그인했다면 사용자의 데이터를 가져온다.

 const {
      data: { user },
      error: userError
    } = await supabase.auth.getUser();
    if (userError || !user) {
      alert('로그인을 해주세요.');
      return;
    }

 

해당 사용자 id로 담은 상품이 있는지 확인한다.

try {
      const { data: cartData, error: cartError } = await supabase
        .from('cart')
        .select('*')
        .eq('product_id', food.product_id)
        .eq('user_id',user.id)
        .maybeSingle();

      if (cartError) {
        alert('장바구니에 담기 중 에러가 발생했습니다.');
        return;
      }

 

만약 상품이 없다? 그럼 insert 하자. 들어가야하는 데이터는 아래와 같다.

 if (!cartData) {
        const { error: insertError } = await supabase.from('cart').insert({
          product_id: food.product_id,
          image: food.title_image,
          product_name: food.food_name,
          product_price: food.price,
          user_id: user.id
        });
        if (insertError) {
          alert('장바구니에 상품이 담기지 않았습니다.');
          return;
        }
      } else {
        const isConfirmed = confirm(
          '이미 장바구니에 담긴 상품입니다. 장바구니로 이동하시겠습니까?'
        );
        if (isConfirmed) {
          router.push('/cart');
        }
        return;
      }
    } catch (error) {
      alert('장바구니 담기 중 오류가 발생했습니다.');
      console.error(error);
    }

    const isConfirmed = confirm(
      '장바구니에 담겼습니다. 장바구니로 이동하시겠습니까?'
    );
    if (isConfirmed) {
      router.push('/cart');
    }
  };

 

 

 

 

장바구니 페이지

장바구니 페이지에 있는 데이터들을 가져온다.

const {
    data: cartData,
    isPending,
    error
  } = useQuery({
    queryKey: ['cart'],
    queryFn: async () => {
      const {
        data: { user },
        error: userError
      } = await supabase.auth.getUser();

      if (!user) return [];
      if (userError) console.error(userError.message);

      const { data, error } = await supabase
        .from('cart')
        .select('*, product:product_id(*)')
        .eq('user_id', user.id);

      if (error) throw new Error(error.message);

      return data;
    }

Next.js 프로젝트 생성

npx create-next-app@latest

 

 

supabase 테이블 생성

 

 

superbase client

yarn add @supabase/supabase-js

 

📁 utils / 📁 supabase / client.ts

import { createBrowserClient } from '@supabase/ssr'

export function createClient() {
  return createBrowserClient<Database>(
    process.env.NEXT_PUBLIC_SUPABASE_URL! as string,
    process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY! as string
  )
}

export default supabase;

 

 

supabase type 설정

supabase package 다운받기

npm i supabase@">=1.8.1" --save-dev
npx supabase login

 

supabase 파일 생성

npx supabase init

 

types폴더에 supabase.ts 파일 생성

$PROJECT_REF 에는 프로젝트 이름을 써주면 된다.

npx supabase gen types typescript --project-id "$PROJECT_REF" --schema public > types/supabase.ts

 

 

자동 완성

 

supabaseClient.ts 생성

createClient의 타입으로 Database를 반드시 줘야한다

import { Database } from '@/types/supabase';
import { createBrowserClient } from '@supabase/ssr';
import { SupabaseClient } from '@supabase/supabase-js';

export function createClient(): SupabaseClient<Database> {
  return createBrowserClient(
    process.env.NEXT_PUBLIC_SUPABASE_URL!,
    process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!
  );
}

//client component 보일러 플레이트 방지
const supabase = createClient();

export default supabase;

 

 

 

 

📌 스크립트  처리

타입이 업데이트 될 경우 아래 명령어를 다시 입력해줘야한다. 그러기에는 너무 긴 명령어.. pakage.json에 들어가 script를 추가하자.

npx supabase gen types typescript --project-id "$PROJECT_REF" --schema public > types/supabase.ts

 

 

$PROJECT_ID 에는 프로젝트 id값을 넣어주면 된다. 프로젝트 ID값은 supabase의 Project Setting에서 확인할 수 있다.

"gen": "npx supabase gen types typescript --project-id \"$PROJECT_ID\" --schema public > types/supabase.ts"

 

지정한 스크립트를 아래 명령어를 통해 실행할 수 있고, 이 명령어로 타입을 업데이트 시켜줄 수 있다.

npm run gen

 

 

 

[프로젝트] Next.js 심화 프로젝트 (1) - 기획편

사전 기획 - 마인드맵  선정 주제각 지역별 전통 시장을 소개하고 특산물을 판매하는 웹사이트를 제작하기로 했다.  레퍼런스 리서치  페이지별 세부 기능

ejunyang.tistory.com

 

 

기능 선정

 

이번에 맡은 기능은 아래와 같다.

  • 메인페이지
  • 특산물 전체 보드 페이지
  • 특산물 디테일 페이지
  • 장바구니
  • 선택사항 => 체크한 상품만 결제 페이지로 넘어가기

실시간 채팅을 구현해보고싶었는데 사다리타기에서 졌다.. 하지만 맡으신 혜미님은 훨씬 더 잘 구현하실 것 같다 넘 든든한 팀원,, 실시간 채팅 기능은 개인 프로젝트 때 적용해보아야겠다!

 

 

[프로젝트] 공공데이터포털 API 가져오기

심화 프로젝트 주제로 전통시장을 홍보하는 웹사이트를 구현하기로 했다. 원래는 공공데이터포털에 '전통시장표준데이터규격' api를 사용하려고 했는데 계속해서 SERVICE KEY IS NOT REGISTERED ERROR 이

ejunyang.tistory.com

 

우선 전통시장을 홍보하는 웹사이트이기때문에 전통시장 api가 잘 불러와지는지 확인했다. 아주 잘 가져와지는 것 확인~ 대신 api에 이미지가 없어서 이건 네이버 이미지 검색 api를 활용하려고 한다.

 

 

 

supabase 세팅

오늘 DB 세팅까지 마쳤다. 우선 연결해야하는 테이블들은 연결해두었는데 작업하다가 분명 테이블을 만질 일이 있을 것 같다.. 팀원들이랑 우선적으로 초기세팅은 마친 상태이다.

 

 

 

와이어프레임

어제 웹 와이어프레임을 모두 완료했는데, 어젯 밤 앱으로 먼저 시작해야한다고 전달받았다.. 그게 기본적인 순서라고.. 전체 와이어 프레임은 아니지만 앱 와이어프레임까지 모두 끝냈다.

 

 

 

심화 프로젝트 주제로 전통시장을 홍보하는 웹사이트를 구현하기로 했다. 원래는 공공데이터포털에 '전통시장표준데이터규격' api를 사용하려고 했는데 계속해서 SERVICE KEY IS NOT REGISTERED ERROR 이라는 오류가 떴다. 인증키를 재발급 받아보기도하고, 인코딩된 키값을 넣어 호출하면 해당 키값이 한번더 인코딩되어 온다고해서 decodeURIComponent() 메서드를 사용해서 디코딩 작업을 했는데도 오류가 사라지지 않았다.. 공공데이터 키값으로 해당 에러를 경험한 사람이 많아서 우선은 구글에 나오는 방법들로 시도해봤지만 에러가 고쳐지지 않아 공공데이터포털에 문의를 넣은 상태이다. 키 값의 문제가 아니라고 하면 에러 이유에 대해서 더 알아봐야할 것 같다.

그렇게 돌고 돌아 다른 api 를 찾다가 원래 가지고 오려는 api보다 더 많은 정보가 있는 '전통시장현황' api를 발견했다. 

 

 

 

 

활용신청

 

공공데이터 포털

국가에서 보유하고 있는 다양한 데이터를『공공데이터의 제공 및 이용 활성화에 관한 법률(제11956호)』에 따라 개방하여 국민들이 보다 쉽고 용이하게 공유•활용할 수 있도록 공공데이터(Datase

www.data.go.kr

 

 

활용신청을 누르면 해당 api를 어떤 목적으로 사용할껀지 물어보는 창이 나온다. 목적을 입력하고 신청하면 완료.

 

 

서비스를 신청하면 신청내역에 인코딩한 키와 디코딩 키 값이 있다. 

 

 

활용명세

아래로 조금 내리면 OPEN API 호출 버튼이 있다. 이걸로 먼저 테스트할 수 있다.

 

 

여기 나오는 url을 사용하면 된다.

서버응답

키값을 넣지 않고 호출하려고 하면 아래와 같은 서버 에러가 난다.

 

응답

인증키를 넣어주면 제대로 호출되는 것을 확인 할 수 있다.

 

 

 

📁 api / 📁 market / 📑 route.ts

환경변수에 키값을 넣고 요청 url에 넣고 호출해줘야한다. 마이페이지에 뜨는 End Point 주소와는 다르다.

import { NextRequest, NextResponse } from 'next/server';

const MARKET_URL =
  'api_url';

export const GET = async (request: NextRequest) => {
  try {
    const serviceKey = encodeURIComponent('serviceKey');
    const apiKey = process.env.NEXT_PUBLIC_API_KEY;
    const response = await fetch(
      `${MARKET_URL}?${serviceKey}=${apiKey}&page=1&perPage=1388`
    );
    const data = await response.json();
    return NextResponse.json(data);
  } catch (error) {
    return NextResponse.json(
      { error: '데이터를 가져오지 못했습니다.' },
      { status: 500 }
    );
  }
};

사전 기획 - 마인드맵

 

 

선정 주제

각 지역별 전통 시장을 소개하고 특산물을 판매하는 웹사이트를 제작하기로 했다.

 

 

레퍼런스

 

리서치

 

 

페이지별 세부 기능

 

 

 

🔗 마이페이지(1)

 

[팀프로젝트] 리액트로 뉴스피드 웹사이트 제작하기 - 마이페이지(1)

🔗 사전기획 [팀프로젝트] 리액트로 뉴스피드 웹사이트 제작하기 - 사전기획리액트로 첫 팀프로젝트가 시작됐다. 뭔가 프로젝트다운 프로젝트를 하는 것 같다. 인스타그램이나 페이스북,  블

ejunyang.tistory.com

 

 

 

 

 

✏️ 내가 쓴 게시글 불러오기

로그인한 아이디와 같은 유저 아이디를 가진 데이터의 모든 정보를 posts 테이블에서 가져온다.

 const { data: postData, error: postError } = await supabase
        .from('posts')
        .select('*')
        .eq('user_id', userData.email);
      setPosts(postData);

      if (postError) {
        console.error('게시글 정보를 가져오지 못했습니다.', postError);
        return;
      }

 

 

❤️ 좋아요 누른 게시글 불러오기

    const { data: likeData, error: likeError } = await supabase
        .from('user_info')
        .select('post_heart')
        .eq('user_id', userData.email);

      if (likeError) {
        console.error('좋아요한 게시글 정보를 가져오지 못했습니다.', likeError);
        return;
      }

 

JSON 형식의 문자열로 저장된 게시글 ID들이 들어있고, 이 문자열을 JSON.parse를 사용하여 JavaScript 배열로 변환한 다음 likedPostIds라는 변수에 저장한다.

 

let likedPostIds = JSON.parse(likeData[0].post_heart);

 

좋아요한 게시글 ID가 담겨있는 likedPostId를 map 을 돌려 새로운 배열을 반환한다. 각 게시물 ID (postId)에 대해 Supabase 데이터베이스에서 해당 ID와 일치하는 게시물의 세부 데이터를 가져온다. likedPostDetails 배열에서 null이 아닌 요소들만 필터링하여 setLikedPosts 함수로 전달한다.

      try {
        const likedPostDetails = await likedPostIds.map(async (postId) => {
            const { data: likedPostData, error } = await supabase
            .from('posts')
            .select('*')
            .eq('id', postId);

            if (error) {
              console.error('좋아요한 게시글의 상세 정보를 가져오지 못했습니다.', error);
              return null;
            }
            return likedPostData;
          })
        setLikedPosts(likedPostDetails.filter((post) => post !== null));
      } catch (error) {
        console.error('오류', error);
      }

2

 

👾 오류

likedPostIds가 배열이 아니라는 타입오류가 났다. 찾아보니 async 함수는 항상 Promise를 반환한다는 것을 알아냈다. 반환된 프로미스 객체를 비동기식으로 처리하기 위래 Promise all 메서드를 사용해보았다.

 

🔗 참고자료

 

Promise.all() 로 비동기 처리를 구현해 보자

- Promise.all() 은 여러 개의 Promise 들을 비동기적으로 실행하여 처리할 수 있다.

velog.io

 

 

 

💡 해결

흐름은 아래와 같다. 아래 흐름으로 간다면 Promise.all이 완료됐을 때, likedPostDetails는 각 postId에 대한 쿼리 결과의 배열이 된다.

  • Promise.all은 전달된 모든 Promise가 해결될 때까지 기다린 후, 그 결과를 배열로 반환
  • likedPostIds.map은 각 postId에 대해 비동기 작업을 수행하는 Promise 객체의 배열을 반환
  • 해당 배열을 Promise.all에 전달하여 모든 비동기 작업이 완료될 때까지 기다림
  • await를 사용하여 모든 Promise가 완료될 때까지 기다림

 

 const likedPostDetails = await Promise.all(
          likedPostIds.map(async (postId) => {
            const { data: likedPostData, error } = await supabase.from('posts').select('*').eq('id', postId);

            if (error) {
              console.error('좋아요한 게시글의 상세 정보를 가져오지 못했습니다.', error);
              return null;
            }
            return likedPostData;
          })
        );
        setLikedPosts(likedPostDetails.filter((post) => post !== null));
      } catch (error) {
        console.error('오류', error);
      }

 

 

 

📌 북마크한 게시글 불러오기

 const { data: saveData, error: saveError } = await supabase
        .from('user_info')
        .select('post_save')
        .eq('user_id', userData.email);
      setSavePosts(saveData);

      if (saveError) {
        console.error('북마크한 게시글 정보를 가져오지 못했습니다.', saveError);
        return;
      }

 

 

saveData[0]가 존재하는지 확인하고, 존재하지 않으면 undefined 처리. 옵셔널 체이닝으로(?) saveData[0]가 존재하지 않으면 undefined를 반환한다. 좋아요 게시글의 세부데이터를 가져오는 방식과 같은 방식이다.

 

let savePostIds = JSON.parse(saveData[0]?.post_save || '[]');
      try {
        const savePostDetails = await Promise.all(
          savePostIds.map(async (postId) => {
            const { data: savedPostData, error } = await supabase.from('posts').select('*').eq('id', postId);

            if (error) {
              console.error('북마크한 게시글의 상세 정보를 가져오지 못했습니다.', error);
              return null;
            }
            return savedPostData;
          })
        );
        setSavePosts(savePostDetails.filter((post) => post !== null));
      } catch (error) {
        console.error('오류', error);
      }
    };

 

 

 

🔗 사전기획

 

[팀프로젝트] 리액트로 뉴스피드 웹사이트 제작하기 - 사전기획

리액트로 첫 팀프로젝트가 시작됐다. 뭔가 프로젝트다운 프로젝트를 하는 것 같다. 인스타그램이나 페이스북,  블로그 같이 게시글을 쓰고 읽고 수정하고 삭제할 수 있는 뉴스피드 웹사이트를

ejunyang.tistory.com

 

 

 

 

 

 

마이페이지 구현

마이페이지에 필요한 기능을 정리해보았다.

1. 로그인한 사용자의 데이터 가져오기 => id, nickname, 프로필 이미지
2. 사용자가 쓴 게시글 데이터 가져오기
3. 사용자가 좋아요 누른 게시글 데이터 가져오기
4. 사용자가 저장한 북마크 게시글 데이터 가져오기

 

 

 

컴포넌트 분리

아래와 같이 컴포넌트를 나누었다.

 

 

 

 

사용자 데이터 가져오기

 

준혁님께서 Auth.api 를 만들어주셔서 getUser를 가지고와서 사용했다. 

export const getUser = async () => {
  try {
    const {
      data: { user },
      error
    } = await supabase.auth.getUser();

    if (error) {
      console.log('Error:', error);
      return null;
    }

    // console.log('User data:', user);
    return user;
  } catch (error) {
    console.log('Error:', error);
    return null;
  }
};

 

마이페이지에서 화면에 보여줘야할 정보가 프로필 이미지와 닉네임이었기때문에 프로필 이미지와 닉네임은 useState 를 사용해줬다. 그리고 이번 과제를 하면서 supabase를 처음 다루어보았는데 우리가 Auth 테이블과 member 테이블 두개를 만들어놓은 상태로 개발을 진행한 상태라서 프로필을 변경할 땐 두 테이블 다 데이터를 업데이트해줘야했다. 

 useEffect(() => {
    const fetchData = async () => {
      const userData = await getUser();
      setProfileUrl(userData.user_metadata.imageSrc);
      setUser(userData);
      setNickname(userData.user_metadata.nickname);

      if (!userData) {
        console.error('유저 정보를 가져올 수 없습니다.');
        return;
      }
      const { data: memberData, error: memberError } = await supabase
        .from('member')
        .select('*')
        .eq('user_id', userData.email);

      if (memberError) {
        console.error('회원정보를 가져오지 못했습니다.', memberError);
        return;
      }
    };
    fetchData();
  }, []);

 

 

 

 

프로필 수정하기

 

변경해야할 닉네임은 useRef로 처리해서 DOM에 접근할 수 있도록 했다. state로 처리 하기엔 인풋에 값을 변경할때마다 불필요한 렌더링이 일어나기 때문에 useRef로 처리하는게 좋겠다고 생각했다.

  useEffect(() => {
    const fetchData = async () => {
      const userData = await getUser();
      if (userData) {
        setUser(userData);
        setProfileImg(userData.user_metadata.imageSrc);
        nicknameRef.current ? (nicknameRef.current.value = userData.user_metadata.nickname) : '';
      } else {
        console.error('회원정보를 불러오지 못했습니다.', error);
      }
    };
    fetchData();
  }, []);

 

 

 

 

프로필 업데이트

const handleUpdateData = async (e) => {
    e.preventDefault();
    try {
      const newNickname = nicknameRef.current.value;
      const image = e.target.image.files[0];
      const userData = await getUser();
      const ImageData = await apiImg(image);

      if (!ImageData) {
        throw new Error('이미지 업로드 실패');
      }

      // auth 업데이트
      const { data: authData, error: AuthError } = await supabase.auth.updateUser({
        data: { nickname: newNickname, imageSrc: ImageData }
      });

      if (AuthError) {
        console.error('Auth 업데이트 실패', AuthError.message);
        return;
      }

      console.log('Auth 업데이트 성공', authData);
      setUser(authData);

      // member 테이블 업데이트
      const { data, error } = await supabase
        .from('member')
        .update({ user_name: newNickname, user_imageSrc: ImageData })
        .eq('user_id', userData.email)
        .select('*');

      if (error) {
        console.error('member 업데이트 실패', error.message);
        return;
      }

      console.log('member 업데이트 성공', data);
      navigate(-1);
    } catch (error) {
      console.error('업데이트 실패', error);
    }
  };

 

👾 오ㅗ류

지출내역리스트 코드를 작성하다 마주한 에러이다. 한참 고민하다가 if문 구문 순서를 바꾸어 보았다.

const ExesList = () => {
  const { selectedMonth } = useContext(ExpenseContext);
  const { userInfo } = useContext(AuthContext);

  const {
    data: expenses = [],
    isPending,
    error,
  } = useQuery({
    queryKey: ["expenses"],
    queryFn: getExpense,
  });

  const filteredExpenses = userInfo
    ? expenses.filter(
        (exe) => exe.month === selectedMonth && exe.user === userInfo.id
      )
    : [];

  const totalAmount =
    filteredExpenses && filteredExpenses.length > 0
      ? filteredExpenses.reduce((acc, cur) => {
          return (acc += cur.amount);
        }, 0)
      : 0;
      
      
  if (isPending) {
    return <div>로딩중입니다.</div>;
  }

  if (error) {
    return <div>데이터를 불러오지 못했습니다.</div>;
  }


  return (
    <div>
      <TotalTitle>
        {`${selectedMonth + 1}월`} 한 달 동안
        <strong>총 {totalAmount}원</strong>
        사용했어요.
      </TotalTitle>
      <ul>
        {filteredExpenses &&
          filteredExpenses.map((exe) => {
            return <ExesItem key={exe.id} exe={exe} />;
          })}
      </ul>
    </div>
  );
};

export default ExesList;

 

 

 

 

💡 해결

const ExesList = () => {
  const { selectedMonth } = useContext(ExpenseContext);
  const { userInfo } = useContext(AuthContext);

  const {
    data: expenses = [],
    isPending,
    error,
  } = useQuery({
    queryKey: ["expenses"],
    queryFn: getExpense,
  });

  const filteredExpenses = userInfo
    ? expenses.filter(
        (exe) => exe.month === selectedMonth && exe.user === userInfo.id
      )
    : [];

  if (isPending) {
    return <div>로딩중입니다.</div>;
  }

  if (error) {
    return <div>데이터를 불러오지 못했습니다.</div>;
  }

  const totalAmount =
    filteredExpenses && filteredExpenses.length > 0
      ? filteredExpenses.reduce((acc, cur) => {
          return (acc += cur.amount);
        }, 0)
      : 0;

 

이렇게 위치를 바꾸니까 해결됐다. 코드의 흐름을 보니 이 때 expenses 데이터가 아직 로딩되지 않았거나 에러가 발생했다면, filteredExpenses의 계산 과정에서 문제가 발생했던 것.  isPending 및 error 체크를 먼저 수행하도록 변경하면 데이터 로딩 상태와 에러 상태를 먼저 확인하고, 해당 상태에 맞는 UI를 렌더링할 수 있다.

 

따라서 isPending 및 error 체크 구문의 위치를 변경함으로써, 데이터 로딩 상태와 에러 상태를 먼저 확인하고 그에 따른 적절한 UI를 렌더링할 수 있게 되었다. 코드의 안정성과 사용성을 높이는 데 도움이 되는 것 같다!

+ Recent posts