[내일배움캠프] 최종 프로젝트 - 동행 모집 페이지: supabase에서 목록 리스트 불러오기(supabase 외래키 data 가져오기), 메인 이미지 추가, 공통 모달 생성(zustand 및 id 값 이용한 전역 상태 관리)
오늘의 기억할 거리
1. supabase 외래키(writerId) data 가져오기
1) 첫 번째 시도
// api 폴더 하위 supabase 폴더 하위 partner.ts
import { supabase } from './supabaseClient';
export const getPartnerPosts = async () => {
let { data: partnerPosts, error } = await supabase.from('partnerPosts').select('*, users(*)').eq('writerId', 'users.id');
return { data: partnerPosts, error };
};
[오류 발생]

[오류 원인]
Supabase에서 partnerPosts 테이블과 users 테이블 사이에 "연결 관계가 2개" 있었음. 하나는 applicant, 다른 하나는 writerId.
따라서 이 2가지 관계 중 임베딩하고자 하는 정확한 관계를 지정해주어야 함.
-> 여기서는 partnerPosts 테이블과 users 테이블 사이의 writerId 관계를 사용할 것.
[수정된 코드]
// api 폴더 하위 supabase 폴더 하위 partner.ts
import { supabase } from './supabaseClient';
export const getPartnerPosts = async () => {
let { data: partnerPosts, error } = await supabase.from('partnerPosts').select('*, users!partnerPosts_writerId_fkey(*)');
return { data: partnerPosts, error };
};
[1차 해결]
이제 아래와 같이 동행글 목록 data는 잘 불러와집니다.

2) 두 번째 시도
이제 writerId를 통해 외래키로 연결한 users 정보를 화면에 렌더링하려고 하니까 다시 오류 발생

[오류 메시지]
'{ applicant: string | null; content: string; country: string; createdAt: string; endDate: string; id: string; interestUrl: string[]; isOpen: boolean; numOfPeople: number; openChat: string; region: string; startDate: string; title: string; writerId: string | null; }' 형식에 'users' 속성이 없습니다.
[수정한 코드]
Supabase DB의 type 선언 파일에서 users 속성 추가
// api 폴더 하위 supabase 폴더 하위 supabase.ts -> type 선언 파일에서 users 속성 추가
partnerPosts: {
Row: {
applicant: string | null;
content: string;
country: string;
createdAt: string;
endDate: string;
id: string;
interestUrl: string[];
isOpen: boolean;
numOfPeople: number;
openChat: string;
region: string;
startDate: string;
title: string;
writerId: string | null;
// 추가한 부분
users: {
birthday: string;
gender: string;
nickName: string;
profileImageUrl: string | null;
};
};
}
[최종 해결]
이제 users 속성 잘 뜨고 화면에도 렌더링 됨!


1. supabase 외래키 data 가져오기 (전체 코드)
: 외래키 writerId에 연동되어 있는 users 테이블 data 가져와서 -> 작성자의 users 정보 뿌려주기
1) getPartnerPosts로 partnerPosts 데이터 가져올 때 writerId에 연동된 users 정보도 가져오기
// api 폴더 하위 supabase 폴더 하위 partner.ts
import { supabase } from './supabaseClient';
export const getPartnerPosts = async () => {
let { data: partnerPosts, error } = await supabase.from('partnerPosts').select('*, users!partnerPosts_writerId_fkey(*)');
return { data: partnerPosts, error };
};
2) partnerPosts의 데이터에서 users 데이터의 type 추가
// api 폴더 하위 supabase 폴더 하위 supabase.ts -> type 선언 파일에서 users 속성 추가
partnerPosts: {
Row: {
applicant: string | null;
content: string;
country: string;
createdAt: string;
endDate: string;
id: string;
interestUrl: string[];
isOpen: boolean;
numOfPeople: number;
openChat: string;
region: string;
startDate: string;
title: string;
writerId: string | null;
// 추가한 부분
users: {
birthday: string;
gender: string;
nickName: string;
profileImageUrl: string | null;
};
};
}
3) PartnerItem 컴포넌트에서 writer의 users 정보 뿌려주기
<users 데이터 가공한 부분>
gender: 여성 | 남성
birthday: birthday로 받은 user 정보 -> 연령대 구분 (추후 초/중/후반으로 세분화 예정)
// components 폴더 하위 partner 폴더 하위 PartnerItem.tsx
import axios from 'axios';
import { useEffect, useState } from 'react';
import * as St from './style';
import { Tables } from '../../api/supabase/supabase';
import { Link } from 'react-router-dom';
type PartnerItemProps = {
post: Tables<'partnerPosts'>;
};
const PartnerItem = ({ post }: PartnerItemProps) => {
const [imageSrc, setImageSrc] = useState<string>('');
// 동행찾기 게시글작성할 때 선택한 country
let filteredCountry = post.country;
if (post.country === '미국') {
filteredCountry = '미합중국';
} else if (post.country === '베네수엘라') {
filteredCountry = '베네수엘라볼리바르';
} else if (post.country === '네팔') {
filteredCountry = '네팔연방';
} else if (post.country === '터키') {
filteredCountry = '튀르키예공화국';
}
// 국기 api key
const API_KEY = process.env.REACT_APP_API_KEY;
// 국기 이미지 가져오기
const getFlagAndDisplayImage = async (): Promise<void> => {
const url = `https://apis.data.go.kr/1262000/CountryFlagService2/getCountryFlagList2?serviceKey=${API_KEY}&pageNo=1&numOfRows=227&cond[country_nm::EQ]=${filteredCountry}`;
const response = await axios.get(url);
const imageUrl = response.data.data[0].download_url;
setImageSrc(imageUrl);
};
useEffect(() => {
getFlagAndDisplayImage();
}, []);
// birthday로 나이 계산 -> 연령대 구분
const getAgeCategory = (birthday: string) => {
const birthDate = new Date(birthday);
const currentDate = new Date();
const age = currentDate.getFullYear() - birthDate.getFullYear() - (currentDate.getMonth() < birthDate.getMonth() || (currentDate.getMonth() === birthDate.getMonth() && currentDate.getDate() < birthDate.getDate()) ? 1 : 0);
if (age >= 10 && age < 20) {
return '10대';
}
if (age >= 20 && age < 30) {
return '20대';
}
if (age >= 30 && age < 40) {
return '30대';
}
if (age >= 40 && age < 50) {
return '40대';
}
if (age >= 50 && age < 60) {
return '50대';
}
if (age >= 60 && age < 70) {
return '60대';
}
if (age >= 70 && age < 80) {
return '70대';
}
};
// Link 태그로 디테일 페이지로 params 넘겨주기
return (
<Link to={`detail/${post.id}`}>
<St.PostCard>
<St.Head>
<St.Location>
<St.FlagBox>{imageSrc && <St.FlagImage src={imageSrc} alt="Image" />}</St.FlagBox>
<h1>{post.country}</h1>
</St.Location>
<St.Status isOpen={post.isOpen}>{post.isOpen ? '모집중' : '모집완료'}</St.Status>
</St.Head>
<St.Main>
<p>
여행기간 | {post.startDate} ~ {post.endDate}
</p>
<h1>{post.title}</h1>
</St.Main>
<St.Body>
<picture>
{post.interestUrl.map((url, index) => (
<St.InterestImage key={index} src={url} alt={`interest-${index}`} />
))}
</picture>
<p>모집인원: {post.numOfPeople}명</p>
</St.Body>
<St.Footer>
<St.UserProfile>
{post.users.profileImageUrl && <St.ProfileImage src={post.users.profileImageUrl} alt="profile" />}
<p>{post.users.nickName}</p>
</St.UserProfile>
<div>
<p>
{getAgeCategory(post.users.birthday)} | {post.users.gender === 'woman' ? '여성' : '남성'}
</p>
</div>
</St.Footer>
</St.PostCard>
</Link>
);
};
export default PartnerItem;
4) 위에서 추가한 부분 styled-components 코드
// components 폴더 하위 partner 폴더 하위 style.ts
export const Footer = styled.div`
display: flex;
justify-content: space-between;
align-items: center;
margin-top: 10px;
font-size: 0.8rem;
`;
export const UserProfile = styled.div`
display: flex;
align-items: center;
gap: 5px;
`;
export const ProfileImage = styled.img`
width: 30px;
height: 30px;
border-radius: 50%;
`;
2. 동행 모집: 메인 페이지 이미지 추가
1) PartnerList 컴포넌트 -> 화면 렌더링
// components 폴더 하위 partner 폴더 하위 PartnerList.tsx
import * as St from './style';
import TravelWith from '../../assets/imgs/partner/TravelWith.jpg';
return (
<>
<St.ImageWrapper>
<St.MainImage src={TravelWith} alt="mainImage" />
<St.ImageMainText>친구와 함께라면 더 즐겁지 않을까요?</St.ImageMainText>
<St.ImageSubText>
Amigo Signal과 함께 여행에 동행할 친구를 찾아보세요.
<br />
여행이 더 즐거워질 거에요.
</St.ImageSubText>
</St.ImageWrapper>
</>
);
};
export default PartnerList;
2) 해당 style.ts 파일
// components 폴더 하위 partner 폴더 하위 style.ts
// relative로 기준 잡고, 박스 사이즈 지정한 뒤, overflow: hidden 걸어줌
export const ImageWrapper = styled.div`
position: relative;
width: 1200px;
height: 400px;
overflow: hidden;
`;
// 위의 ImageWrapper를 기준으로 absolute, transform을 통해 이미지 위치 조정
export const MainImage = styled.img`
position: absolute;
width: 100%;
transform: translate(0%, -20%);
`;
export const ImageMainText = styled.span`
position: absolute;
bottom: 55%;
left: 5%;
color: black;
font-size: 1.4rem;
font-weight: bold;
`;
export const ImageSubText = styled.span`
position: absolute;
bottom: 45%;
left: 5%;
color: black;
font-size: 0.8rem;
`;
2. 공통 모달 생성
공통 모달을 여기 저기 컴포넌트에서 사용하게 되면 -> 이벤트 버블링 or 동시에 모달이 열리고 닫히는 문제가 발생할 수 있다.
이러한 현상을 방지하기 위해 zustand를 이용해 Modal 상태 관리를 전역적으로 진행하는 과정에서,
각 Modal에 고유한 식별자(id 값)를 부여하여 관리하는 방식을 택했다.
1) zustand 전역 상태 관리 store -> modal 열고 닫는 것 관리
(1) 기존 코드
// 기존 코드 -> zustand
interface ModalStore {
isModalOpen: boolean;
openModal: () => void;
closeModal: () => void;
}
const useSessionStore = create<SessionStore>((set) => ({
session: null,
setSession: (newSession) => set({ session: newSession }),
}));
export default useSessionStore;
export const useModalStore = create<ModalStore>((set) => ({
isModalOpen: false,
openModal: () => set({ isModalOpen: true }),
closeModal: () => set({ isModalOpen: false }),
}));
(2) 변경한 코드
// 변경한 코드 -> zustand 폴더 하위 store.ts
import { create } from 'zustand';
// openedModals 객체에서 모든 modal들을 id 값 기준으로 관리
interface ModalStore {
openedModals: { [key: string]: boolean };
openModal: (id: string) => void;
closeModal: (id: string) => void;
}
// modal 열고 닫을 때, id 값을 받아서 openedModals 객체의 id에 해당하는 상태 true/false 변경
export const useModalStore = create<ModalStore>((set, get) => ({
openedModals: {},
openModal: (id: string) => {
const currentModals = get().openedModals;
set({ openedModals: { ...currentModals, [id]: true } });
},
closeModal: (id: string) => {
const currentModals = get().openedModals;
set({ openedModals: { ...currentModals, [id]: false } });
},
}));
2) 공통 Modal 컴포넌트
(1) 기존 코드
// 기존 코드 -> 공통 Modal 컴포넌트
import React from 'react';
import { createPortal } from 'react-dom';
import * as St from './style';
import { useModalStore } from '../../zustand/store';
import CloseButton from '../../../assets/imgs/partner/CloseButton.svg';
export const PORTAL_MODAL = 'portal-root';
export interface ModalProps {
children: React.ReactNode;
size: 'large' | 'medium' | 'small' | undefined;
}
const Modal: React.FC<ModalProps> = ({ children, size }) => {
const { isModalOpen, closeModal } = useModalStore();
return isModalOpen
? createPortal(
<St.Outer>
<St.Inner size={size}>
<St.CloseButton onClick={closeModal}>
<img src={CloseButton} alt="close" />
</St.CloseButton>
{children}
</St.Inner>
</St.Outer>,
document.getElementById(PORTAL_MODAL) as HTMLElement,
)
: null;
};
export default Modal;
(2) 변경한 코드
// 변경한 코드 -> components 폴더 하위 common 폴더 하위 modal 폴더 하위 Modal.tsx
import React from 'react';
import { createPortal } from 'react-dom';
import * as St from './style';
import { useModalStore } from '../../../zustand/store';
import CloseButton from '../../../assets/imgs/partner/CloseButton.svg';
export const PORTAL_MODAL = 'portal-root';
// Modal 커스텀해서 사용 시, props로 내려줄 값들 type 지정 (id, children, size)
export interface ModalProps {
id: string;
children: React.ReactNode;
size: 'large' | 'medium' | 'small' | undefined;
}
const Modal: React.FC<ModalProps> = ({ id, children, size }) => {
// zustand의 useModalStore에서 해당 값들 가져와서 -> 각각 커스텀해 사용할 모달의 id값 별로 isThisModalOpen 변수 관리
const { openedModals, closeModal } = useModalStore();
const isThisModalOpen = openedModals[id];
// isThisModalOpen이라면, Inner의 size (large, medium, small) 지정해주고
// 닫기 버튼 클릭 시 id 값을 받아서 해당 모달 closeModal 실행
return isThisModalOpen
? createPortal(
<St.Outer>
<St.Inner size={size}>
<St.CloseButton onClick={() => closeModal(id)}>
<img src={CloseButton} alt="close" />
</St.CloseButton>
{children}
</St.Inner>
</St.Outer>,
document.getElementById(PORTAL_MODAL) as HTMLElement,
)
: null;
};
export default Modal;
3) 해당 모달 컴포넌트의 style.ts
// // components 폴더 하위 common 폴더 하위 modal 폴더 하위 style.ts
import { styled } from 'styled-components';
interface InnerProps {
size?: 'large' | 'medium' | 'small';
}
export const Outer = styled.div`
position: fixed;
top: 0;
left: 0;
display: flex;
justify-content: center;
width: 100%;
height: 100%;
background-color: #00000029;
z-index: 999;
`;
// Inner는 size별로 (large, medium, small)
export const Inner = styled.div<InnerProps>`
display: flex;
flex-direction: column;
position: relative;
margin: auto;
background-color: #ffffff;
border-radius: 15px;
box-shadow: 5px 5px 12px rgba(23, 23, 23, 0.3);
${({ size }) => {
switch (size) {
case 'large':
return `
width: 60%;
height: 70%;
border-radius: 30px;
`;
case 'medium':
return `
width: 45%;
height: 55%;
border-radius: 30px;
`;
case 'small':
return `
justify-content: center;
align-items: center;
gap: 20px;
margin: 340px 16px 0 16px;
text-align: center;
line-height: 30px;
width: 360px;
height: 220px;
padding: 5px 10px;
`;
default:
return `
width: 500px;
height: 60%;
`;
}
}}
`;
export const CloseButton = styled.button`
display: flex;
justify-content: flex-end;
padding: 30px;
padding-bottom: 0px;
background: none;
border: none;
cursor: pointer;
`;
4) PartnerDetailInfo 컴포넌트에서 공통 Modal 컴포넌트 사용해보기 (openModal)
// components 폴더 하위 partner 폴더 하위 partnerDetailInfo 폴더 하위 partnerDetailInfo.tsx
import { Tables } from '../../../api/supabase/supabase';
import Modal from '../../common/modal/Modal';
import { useModalStore } from '../../../zustand/store';
import ApplicantList from './ApplicantList';
import ApplyWithInfo from './ApplyWithInfo';
import * as St from './style';
const PartnerDetailInfo = ({ partnerPostData }: { partnerPostData: Tables<'partnerPosts'> }) => {
// zustand의 useModalStore에서 해당 값들 가져오기
const { openedModals, openModal } = useModalStore();
// '참여하기' 버튼을 누르는 경우, applyWithInfo라는 id값을 가진 모달을 열어줌.
// '신청자 목록' 버튼을 누르는 경우, applicantList라는 id값을 가진 모달을 열어줌.
return (
<section>
<button onClick={() => openModal('applyWithInfo')}>참여하기</button>
{openedModals.applyWithInfo && (
<Modal id="applyWithInfo" size="medium">
<ApplyWithInfo />
</Modal>
)}
<button onClick={() => openModal('applicantList')}>신청자 목록</button>
{openedModals.applicantList && (
<Modal id="applicantList" size="large">
<ApplicantList />
</Modal>
)}
</section>
);
};
export default PartnerDetailInfo;
5) '동행 참여 신청' 모달 children 내용 -> 레이아웃 완성
// components 폴더 하위 partner 폴더 하위 partnerDetailInfo 폴더 하위 ApplyWithInfo.tsx
import React, { useState } from 'react';
import { Input } from '../../common/input/Input';
import * as St from './style';
import Button from '../../common/button/Button';
import { BtnStyleType } from '../../../types/styleTypes';
const ApplyWithInfo = () => {
const [text, setText] = useState('');
const handleText = (e: React.ChangeEvent<HTMLInputElement>) => {
setText(e.target.value);
};
// 타이핑할 때마다 글자 수(text.length) 세어주는 기능 추가
// 공통 Input, 공통 Button 컴포넌트 사용
return (
<>
<St.ModalTitle>동행 참여 신청하기</St.ModalTitle>
<Input type="textarea" inputStyleType="apply" border={true} placeholder="간단한 자기소개를 작성해주세요." value={text} onChange={handleText} />
<St.TextCount>{text.length}/300 characters</St.TextCount>
<St.SubmitApply>
<Button type="submit" styleType={BtnStyleType.BTN_DARK}>
신청
</Button>
</St.SubmitApply>
</>
);
};
export default ApplyWithInfo;
// 해당 style.ts
export const ModalTitle = styled.h1`
padding-left: 50px;
font-size: 1.4rem;
font-weight: bold;
`;
export const TextCount = styled.span`
display: flex;
justify-content: flex-end;
margin-top: 10px;
margin-right: 45px;
font-size: 12px;
color: gray;
`;
export const SubmitApply = styled.div`
display: flex;
justify-content: flex-end;
margin-right: 45px;
`;
