24/1/18

Laejun Kim·2024년 1월 18일
0

TIL

목록 보기
77/89

팀 프로젝트

Problem

문제 파악

한 컴포넌트 내에서 서로 다른 테이블의 데이터를 요구하는 상황이 발생했다.

위 사진을 보면 어떤 리뷰에 대한 여러 데이터가 단일한 카드 컴포넌트에 표시되고 있는데, 사진들과 리뷰 텍스트는 reviews 테이블에 존재하는 정보지만, 작성자의 avatar 사진이나 닉네임은 users 테이블의 정보이고, 장소의 이름은 places 테이블의 정보이며 댓글 수와 좋아요 수는 각각 comments 테이블과 likes 테이블의 정보이다.

이 문제를 해결하기 위해 고려한 방법은 두가지가 있다. 하나는 reivewCard 컴포넌트 내에서 useQuery를 실행하여 각각의 다른 테이블에서 데이터를 불러와서 이를 컴포넌트 내에 뿌려주는 방법이고, 다른 하나는 reviewCard 컴포넌트에 전달하는 데이터에 처음부터 여러 테이블의 데이터를 함께 담아서 한번 전달하는 것이다.

한번에 전달하기 vs 컴포넌트에서 불러오기

컴포넌트에서 외부 테이블 정보를 불러오는 방식의 장점

  • 컴포넌트를 여러 곳에서 재활용 하기가 용이하다. reviews 테이블의 데이터만 받아오면 되기 때문이다.

  • 적용이 쉽다. supabase 에서 제공되는 api 만을 이용해서 금방 구현이 가능하다.

컴포넌트에서 외부 테이블 정보를 불러오는 방식의 단점

  • reviewCard 컴포넌트는 map 을 돌려서 많이 생성하는 컴포넌트인데 한번 생성될 때마다 내부에서 다시 useQuery를 3~4개씩 실행하기 때문에 queryKey가 빠르게 늘어난다.

  • 마찬가지의 이유로 서버 요청의 횟수가 빠르게 늘어난다.

한번에 전달하는 방식의 장점

  • 서버 요청의 수가 훨씬 적다.

  • useQuery를 한번만 사용하기 때문에 관리해야 하는 queryKey의 수가 최소로 유지된다.

한번에 전달하는 방식의 단점

  • supabase 내부에서 sql 쿼리문을 작성해 직접 함수를 만들어 줘야 한다.

  • 상황에 따라서 매번 다른 함수를 만들어 주어야 한다. 예컨대 '내가 작성한 리뷰' 에 들어갈 reviewCard 컴포넌트에서 쓸 함수와 '내가 좋아요 누른 리뷰' 에 들어갈 reviewCard 컴포넌트에 쓸 함수가 다르기 때문에 하나 하나 만들어 주어야 한다.

적용

어떤 방식이 적절할지 확신할 수 없는 상황이었기 때문에 일단 두가지 방법을 모두 구현해 보기로 했다.

//컴포넌트 내에서 외부 테이블 데이터를 받아오는 방식
type Props = {
  review: Tables<'reviews'>;
};

const ReviewCard = ({ review }: Props) => {
  const router = useRouter();
  console.log('reviewProps! >> ', review);

  const { data: likes } = useQuery({
    queryKey: ['like', review.id],
    queryFn: () => getLikes(review.id),
  });

  const { data: comments } = useQuery({
    queryKey: ['comment', review.id],
    queryFn: () => getCommentsByReviewId(review.id),
  });

  const { data: user, isLoading } = useQuery({
    queryKey: ['user', review.user_id],
    queryFn: () => getUserDataById(review.user_id),
  });
	
  	(후략)
  
//한번에 외부 테이블을 담아오는 방식
  interface Props {
  review: ReviewsFromRPC;
}

const ReviewCard2 = ({ review }: Props) => {
  const {
    images_url,
    content,
    likes_count,
    comments_count,
    unique_review_id,
    place_name,
    user_name,
    user_avatar_url,
  } = review;
	
  	(후략)

실제로 두가지 방법을 모두 시도 해본 결과 컴포넌트 내에서 외부 테이블 데이터를 요청하는 경우 queryKey가 지나치게 많이 생기고 서버 요청도 과도하게 많이 생기는 것을 확인하였다.

이게 한 페이지에서 발생하는 queryKey 인데(fresh 에 440개가 찍힌걸 보자) 아무래도 이건 너무 많다고 판단, reviewCard 컴포넌트에 전달할 props 안에 필요한 모든 데이터를 함께 넣어서 전달해야 한다고 결론 내렸다.

rpc 활용

한번에 모든 데이터를 담아서 전달하기로 결정했으니 이제는 그 기능을 수행하는 함수를 만들어야 한다.

//'좋아요' 한 장소 조회
export const getLikedReviews = async (userId: string) => {
  const { data, error } = await supabase.rpc('get_liked_reviews', {
    p_user_id: userId,
  });

  if (error) {
    throw error;
  }

  return data as ReviewsFromRPC[];
};

//placeId 기반 리뷰 조회
export const getReviewsByPlaceIdrpc = async (placeId: string) => {
  const { data, error } = await supabase.rpc('get_reviews_by_place_id', {
    p_place_id: placeId,
  });

  if (error) {
    throw error;
  }

  return data as ReviewsFromRPC[];
};

//userId 기반 리뷰 조회
export const getReviewsByUserIdrpc = async (userId: string) => {
  const { data, error } = await supabase.rpc('get_reviews_by_user_id', {
    p_user_id: userId,
  });

  if (error) {
    throw error;
  }

  return data as ReviewsFromRPC[];
};


장소 페이지에서 쓸 함수, [내가 좋아요 누른 리뷰] 탭에서 쓸 함수, [내가 작성한 리뷰] 탭에서 쓸 함수 이렇게 총 세개를 만들었는데 [내가 작성한 리뷰] 에서 쓸 함수만 대표로 살펴보자.

SQL 쿼리문을 작성하는 것은 chatGPT에 의존했다. 다음은 supabase sql 에디터에 작성한 내용이다.

CREATE OR REPLACE FUNCTION get_reviews_by_user_id(p_user_id UUID)
RETURNS TABLE (
    unique_review_id UUID,
    content TEXT,
    created_at TIMESTAMPTZ,
    user_id UUID,
    images_url JSONB,
    place_id UUID,
    user_avatar_url TEXT,
    user_name TEXT,
    place_name TEXT,
    comments_count BIGINT,
    likes_count BIGINT
)
AS $$
BEGIN
    RETURN QUERY
    SELECT
        reviews.id AS unique_review_id,
        reviews.content,
        reviews.created_at,
        reviews.user_id,
        reviews.images_url,
        reviews.place_id,
        users.avatar_url AS user_avatar_url,
        users.user_name,
        places.place_name,
        COALESCE(comment_counts.comments_count, 0) AS comments_count,
        COALESCE(like_counts.likes_count, 0) AS likes_count
    FROM
        reviews
    JOIN
        users ON reviews.user_id = users.id
    JOIN
        places ON reviews.place_id = places.id
    LEFT JOIN (
        SELECT review_id, COUNT(*) AS comments_count
        FROM comments
        GROUP BY review_id
    ) AS comment_counts ON reviews.id = comment_counts.review_id
    LEFT JOIN (
        SELECT review_id, COUNT(*) AS likes_count
        FROM likes
        GROUP BY review_id
    ) AS like_counts ON reviews.id = like_counts.review_id
    WHERE
        reviews.user_id = p_user_id;

    RETURN;
END;
$$ LANGUAGE plpgsql;

함수를 만들고 적용을 시도하는 내내 아래와 같은 에러 메시지에 시달렸다.

It could refer to either a PL/pgSQL variable or a table column

열심히 검색해보니 review_id 라는 열의 이름이 여러 테이블에 존재해서 발생하는 문제로 추정된다. 다만 내가 이 언어를 아는 것이 아니라 정확한 파악은 하지 못했다.

reviews.id AS unique_review_id이런 식으로 'ambiguous' 하다고 에러가 나는 부분에 unique 접두사를 붙여서 확실히 구별되게 만들자 에러가 해결 되었다.

그리고 이렇게 만든 함수들의 결과물로는 아래 type에 해당되는 데이터의 배열이 반환된다.

//types.d.ts
export interface ReviewsFromRPC extends Tables<'reviews'> {
  images_url: string[];
  unique_review_id: string;
  user_avatar_url: string;
  user_name: string;
  place_name: string;
  comments_count: number;
  likes_count: number;
}

성공적으로 리뷰 테이블의 정보에다가 추가적으로 필요했던 다른 테이블의 정보들까지 전부 한번에 담아올 수 있게 된 것이다.

더 만족스러운 점은 overfetching 도 일어나지 않았고, underfetching도 일어나지 않았으며 딱 필요한 것들만 정확하게 받아왔다는 점이다.

결과


지금 스크린샷을 찍은 페이지는 reviewCard 컴포넌트가 map 을 통해 28개나 생성되어 있는 페이지인데 이와 관련된 queryKey는 [reviews,'장소id'] 에 해당하는것 단 하나이다. 이는 그만큼 서버 요청이 줄이는데 성공했다는 것을 의미하기도 한다.

현재 프로젝트 규모에서는 의미가 크지 않을 수도 있지만 더 큰 규모의 프로젝트에서라면 이런식의 적용은 분명 서버 비용을 줄이는데도 크게 도움이 될 것이라고 생각한다.

0개의 댓글