중급 proj - 개인 개발 보고서 - 백지연

백지연·2025년 6월 5일
0

Project

목록 보기
2/3

1. 프로젝트 개요

프로젝트 목적

최애의 포토는 디지털 시대의 새로운 수집 문화를 선도하는 플랫폼으로, 사용자가 좋아하는 아이돌, 스포츠 스타, 그림 등의 디지털 포토카드를 손쉽게 사고팔 수 있는 공간을 제공한다. 사용자는 디지털 자산으로 나만의 컬렉션을 완성하고, 서로의 포토카드를 교환하며, 자랑하는 재미와 함께 상호 교류를 즐길 수 있도록 지원한다.

핵심 기능

1. 인증 및 사용자 관리

  • 회원가입, 로그인, 로그아웃 기능을 제공하며, Google OAuth를 통한 소셜 로그인도 지원한다.
  • 사용자는 계정에 부여된 권한에 따라 서비스 기능에 접근할 수 있다.

2. 포토 카드 거래 시스템

  • 상점 (마켓플레이스): 판매할 포토카드 목록을 조회할 수 있으며, 검색, 등급/장르/설명/매진 여부 필터링, 최신/오래된 순, 낮은/높은 가격 순 정렬 기능이 제공된다. 무한 스크롤링 방식으로 카드를 로드하며, SOLD OUT 표기가 적용된다.
  • 내 포토 카드 판매: 소유 중인 포토카드를 선택하여 판매 장 수, 장당 판매 금액, 교환 희망 정보 등을 입력 후 상점에 업로드할 수 있다.
  • 포토 카드 상세 조회: 판매 정보 수정 및 판매 취소, 교환 카드 제시 목록 조회 및 승인/거절이 가능하다. 다른 사용자의 카드인 경우 포인트로 구매하거나 교환 제안을 보낼 수 있다.
  • 포토 카드 구매: 포인트를 사용하여 원하는 카드를 구매할 수 있으며, 구매 시 구매자의 포인트 차감 및 판매자의 포인트 증가 로직이 적용된다.
  • 포토 카드 교환 제안: 소유한 카드로 다른 카드와의 1장 단위 교환 제안이 가능하며, 판매자 승인 시 소유권이 변경된다. 교환 제안자는 내가 제안한 카드 목록을 확인할 수 있다.
  • 포토 카드 교환 제안 승인/거절: 판매자는 판매 포토카드에 대한 교환 제안을 승인/거절할 수 있으며, 발행량만큼 교환 완료 시 SOLD OUT 처리된다.

3. 알림 시스템

  • 교환 제안 도착, 교환 제안 승인/거절, 카드 구매, 내 카드 품절 등 주요 이벤트 발생 시 알림이 전송된다.
  • 알림 발생 시간에 따라 '시간', '일', '주', '개월', '년' 단위로 표시된다.

4. 마이 갤러리 및 포토 카드 생성

  • 내 포토카드 전체 조회: 구매했거나 생성한 포토카드를 전체 조회하며, 필터, 정렬, 검색, 페이지네이션 기능을 제공한다.
  • 포토 카드 생성하기: 본인이 찍은 사진을 업로드하고, 이름, 최소 가격, 등급, 총 발행량, 장르, 설명 등의 정보를 입력하여 카드를 생성 및 등록할 수 있다. 생성된 카드는 즉시 본인 소유가 된다.
  • 내 포토 카드 상세: 카드 선택 시 상세 페이지로 이동하며, 정보 확인 및 판매(=상점 등록)가 가능하다.

5. 나의 판매 포토카드

  • 내가 판매를 위해 상점에 올린 포토 카드 목록을 조회할 수 있으며, 필터링(등급, 장르, 판매방법, 매진 여부) 및 페이지네이션이 가능하다.
  • 상태(판매 중 / 교환 제안 대기 중)에 따라 동일한 카드도 여러 항목으로 나뉘어 표시된다.

6. 포인트 시스템

  • 랜덤 상자 뽑기를 통해 1시간에 1번 포인트 획득 기회를 제공하며, 뽑은 포인트는 적립된다.
  • 포토 카드 생성은 유저당 한 달에 최대 3장까지 가능하며, 카드당 최대 10장까지만 발행 가능하다. 생성 기회는 매달 1일 자정에 초기화된다.

2. 담당한 작업

BE

기능작업
초기 DB 모델 설계전체 서비스 초기 데이터베이스 스키마 설계
판매판매 생성 API
판매를 위한 IDLE 카드 조회 API
판매 상세 API
판매 수정 API
판매 삭제 API

FE

기능작업
판매판매 상세 페이지
판매 조회 반응형(모달창, 바텀시트)
판매 등록 반응형(모달창, 바텀시트, 페이지)
판매 수정 반응형(모달창, 바텀시트, 페이지)

Chores

기능
중간, 최종 발표자료(PPT) 제작

3. 기술적 성과

BE 기술 스택

기술 스택
언어 및 런타임JavaScript
BE 프레임워크Express
DB & ORMPostgreSQL
Prisma
인증Passport.js
JWT
bcrypt
FE 프레임워크Next.js
React
React DOM
스타일링Tailwind CSS
상태 관리 & 데이터 페칭tanstack/react-query
개발도구nodemon, prisma CLI, ESLint, eslint-config-next , Turbopack
배포Vercel (FE), Render (BE)
디자인Figma
협업Discord
Notion
zep
github
라이브러리cors, cookie-parser, multer, dotenv, date-fns (BE), react-hot-toast, react-icons, clsx, date-fns (FE)...

4. 문제점 및 해결과정

트러블 슈팅 1: 복잡한 DB 모델 설계 및 포토카드 상태 관리

  • 문제상황
    → 초기 데이터베이스를 설계할 때 고려해야 할 조건이 많아 로직 구조를 잡는 데 어려움을 겪었다. 특히, 동일한 포토카드를 여러 장 발행한 경우 각 카드의 상태를 개별적으로 관리해야 했고, 카드가 거래나 교환을 통해 다른 사용자에게 넘어가는 복잡한 흐름을 데이터베이스에 어떻게 효과적으로 반영할지가 큰 난관이었다. 이로 인해 초기 모델 구성에 많은 시간이 소요되었다.

  • 원인분석
    → 기존에는 단순히 포토카드 테이블과 유저 테이블만을 고려했으나, 이는 각 포토카드 인스턴스(개별 카드 한 장 한 장)의 소유권 및 상태 변화를 추적하기에는 한계가 있었다. 예를 들어, 5장의 동일한 포토카드 중 3장은 판매 중이고, 1장은 구매되었으며, 나머지 1장은 유저가 소유만 하고 있는 상황을 효과적으로 표현하고 관리할 수 있는 모델이 필요했다.

  • 해결방안
    UserPhotoCard를 참조하는 중간 테이블인 UserCard 모델을 새롭게 설계하여 각 포토카드 인스턴스(개별 카드)의 개별 상태를 관리할 수 있도록 하였다. cardStatus 값은 IDLE (미판매), LISTED (판매 등록), SOLD (판매 완료)의 세 가지로 나누어 정의했다.
    이 방식으로 거래나 교환이 발생했을 때는 UserCard 테이블에서 해당 카드의 status와 함께 소유자의 userId만 변경하면 되도록 구성하였다. 이를 통해 각 카드 한 장 한 장의 상태를 유연하게 추적하고 관리할 수 있게 되었으며, 복잡한 거래 로직을 효율적으로 처리할 수 있는 기반을 마련했다.



트러블 슈팅 2: .env 환경 변수 인식 문제

  • 문제 상황
    → 개발 환경에서 .env 파일에 설정된 환경 변수가 특정 노트북에서는 정상적으로 인식되어 애플리케이션이 작동했지만, 다른 컴퓨터에서는 환경 변수를 인식하지 못해 401 Unauthorized 에러가 발생하는 문제가 발생했다. 백엔드 app.js 파일에 dotenv를 사용하여 환경 변수를 로드하도록 설정했음에도 불구하고 이러한 차이가 발생하여 원인 파악에 어려움을 겪었다.

  • 원인 분석
    package.jsondev 스크립트가 nodemon ./src/app.js로 설정되어 있었는데, 이 방식으로는 Node.js가 dotenv 라이브러리를 통해 .env 파일을 애플리케이션이 시작되기 전에 적절히 로드하지 못할 수 있다는 것을 확인했다. 특히 개발 환경에 따라 Node.js의 실행 방식이나 .env 파일의 위치, 또는 dotenv 로딩 타이밍에 미묘한 차이가 발생하여 환경 변수 인식 오류로 이어진 것으로 판단했다.

  • 해결 방안
    package.json의 dev 스크립트를 nodemon -r dotenv/config ./src/app.js로 수정하여 해결했다. -r (require) 옵션은 Node.js 프로세스가 시작될 때 특정 모듈을 먼저 로드하도록 지시한다. dotenv/config를 사용함으로써 nodemon이 app.js를 실행하기 전에 dotenv가 .env 파일을 미리 로드하도록 강제할 수 있었다. 이 변경을 통해 모든 개발 환경에서 .env 환경 변수가 일관되게 인식되었고, 401 Unauthorized 에러를 해결할 수 있었다.


5. 협업 및 피드백

이번 중급 프로젝트은 초급때보다는 훨씬 난이도 있는 핵심 기능을 구현하며 복잡한 시스템 설계와 유저 플로우 고민의 중요성을 깊이 체감할 수 있었다. 특히 '최애의 포토'는 거래 시스템을 중심으로 한 포인트 차감, 교환 제안 등 다양한 상호작용이 있어, 개발 초기부터 팀원들과의 협의를 통해 API 명세와 DB 스키마를 선제적으로 설계하는 것이 얼마나 중요한지 깨달을 수 있었다.

초급 프로젝트에서 부족했던 부분들을 보완하고자, 이번에는 코드 컨벤션, Git 브랜치 전략, 커밋 컨벤션 등을 프로젝트 초기에 더 명확하게 정의하고 공유하는 데 집중했다. 이를 통해 불필요한 merge conflict를 줄이고, 코드 리뷰 과정에서 더욱 생산적인 피드백을 주고받을 수 있었다. 백엔드와 프론트엔드 간의 데이터 흐름을 명확히 함으로써 각자의 개발 효율을 높일 수 있었다.

프로젝트 전반에 걸쳐 발생한 문제점들을 함께 논의하고 해결하며, 기술적인 역량뿐만 아니라 협업 역량도 한층 더 성장시킬 수 있었던 소중한 경험이었다. 특히, 맡은 파트의 대부분이 모달과 바텀시트 구현이었는데, 모달 구현에 익숙하지 않아 예상보다 많은 시간이 소요되어 아쉬움이 남았다. 하지만 이 과정을 통해 코드에 대한 확신과 해석 능력이 초급 프로젝트 때보다 훨씬 성장했다고 느낄 수 있었다.

확실히 난이도가 높아지니 한 명의 코드에 문제가 생겼을 때 문제가 파급되는 기능이 많아지는 것을 체감할 수 있었다. 앞으로는 코드를 더욱 꼼꼼하게 다시 한번 살펴보며 협업에 임해야겠다는 강한 책임감을 느끼게 되었다.


6. 코드 품질 및 최적화

BE

1. Prisma 트랜잭션을 통한 데이터 일관성 및 안정성 확보

사용자가 판매 중인 포토카드를 '판매 내리기' 할 때, 단순히 판매 글만 삭제하는 것이 아니라 해당 포토카드와 연결된 유저 카드(UserCard)의 상태를 '판매 중(LISTED)'에서 '보유 중(IDLE)'으로 동시에 변경해야 한다.

이 두 가지 데이터베이스 작업(유저 카드 상태 변경, 판매 글 삭제)은 하나의 논리적인 단위로 처리되어야 한다. 즉, 유저 카드 상태 변경이 성공했는데 판매 글 삭제가 실패하거나 그 반대의 경우 데이터 불일치가 발생할 수 있다.

prisma.$transaction을 사용함으로써 이 모든 작업이 성공적으로 완료되어야만 데이터베이스에 반영되고, 중간에 어떤 단계라도 실패하면 모든 변경사항이 자동으로 롤백되어 데이터의 정합성을 강력하게 보장한다. 이는 예상치 못한 오류 발생 시 데이터 손상을 방지하는 중요한 최적화이다.

// src/shop/shop.repository.js
async deleteShop(shopId) {
    try {
        const result = await prisma.$transaction(async (tx) => {
            // 1. 판매글 상세 정보 조회
            const shop = await tx.shop.findUnique({
                where: { id: shopId },
                include: { photoCard: true }
            });

            if (!shop) {
                throw new Error('해당 판매글을 찾을 수 없습니다.');
            }

            // 2. 유저 카드 상태를 'LISTED'에서 'IDLE'로 변경
            // 판매글에 연결된 모든 userCardId를 가져와 일괄 업데이트
            await tx.userCard.updateMany({
                where: {
                    photoCardId: shop.photoCardId,
                    userId: shop.sellerId,
                    status: 'LISTED'
                },
                data: {
                    status: 'IDLE'
                }
            });

            // 3. 판매글 삭제
            await tx.shop.delete({
                where: { id: shopId }
            });

            return true;
        });
        return result;
    } catch (error) {
        console.error('트랜잭션 실패 (판매글 삭제):', error);
        throw error;
    }
}

FE
1. React Query를 활용한 효율적인 서버 상태 관리 및 무한 스크롤 구현

서버 상태 관리 중앙화 - useInfiniteQuery를 사용하여 포토카드 목록 데이터를 서버 상태로 관리하고, 컴포넌트 내에서 직접 useState로 데이터를 관리하고 API 호출 로딩, 에러 처리를 하는 대신, React Query를 사용하여 코드의 복잡성을 줄이고 예측 가능한 데이터 흐름을 만들었다.

무한 스크롤 구현 - useInfiniteQuery는 페이지 단위로 데이터를 가져오며 getNextPageParam을 통해 다음 페이지를 가져올 기준을 설정했다.

// src/components/market/MyCardsSellBottomSheet.jsx
import { useInfiniteQuery } from '@tanstack/react-query';
import { useInView } from 'react-intersection-observer';
import { fetchMyCards } from '@/lib/api/shop';

export default function MyCardsSellBottomSheet({ isOpen, onClose, onCardSelectedForSale }) {
    // ... (state 및 router, modal hook 선언)

    const { ref: loaderRef, inView } = useInView({ threshold: 0.1 }); // 무한 스크롤을 위한 ref와 inView 상태

    const { data, fetchNextPage, hasNextPage, isFetchingNextPage, isLoading } =
        useInfiniteQuery({
            queryKey: ['marketIDLECards', { keyword, filter }], // 쿼리 키: keyword와 filter가 변경될 때마다 새로운 데이터 요청
            queryFn: ({ pageParam = 1 }) =>
                fetchMyCards({ // 데이터를 가져오는 함수
                    page: pageParam,
                    take: 10,
                    keyword,
                    filterType: filter.type,
                    filterValue: filter.value,
                }),
            enabled: isOpen && !!user, // 모달이 열려있고 사용자가 로그인했을 때만 API 요청
            getNextPageParam: (lastPage) => { // 다음 페이지 파라미터를 결정하는 로직
                return lastPage.currentPage < lastPage.totalPages
                    ? lastPage.currentPage + 1
                    : undefined;
            },
            keepPreviousData: false, // 이전 페이지 데이터를 유지할지 여부
        });

    useEffect(() => {
        // loaderRef가 뷰포트 내에 들어오고, 다음 페이지가 있으며, 현재 다음 페이지를 가져오는 중이 아닐 때 fetchNextPage 호출
        const shouldFetch = inView && hasNextPage && !isFetchingNextPage;
        if (shouldFetch) fetchNextPage();
    }, [inView, hasNextPage, isFetchingNextPage, fetchNextPage]);

    const allCards = data?.pages.flatMap(page => page.result) || []; // 모든 페이지의 카드를 하나의 배열로 병합

    // ... (renderCardList 등)
}

2. Skeleton UI를 통한 사용자 경험 개선

데이터 로딩 중인 동안 빈 화면을 보여주는 대신, 실제 콘텐츠가 로드될 위치에 회색 박스 형태의 스켈레톤 컴포넌트를 미리 렌더링하여 사용자가 체감하는 로딩 시간이 짧아지고, 더 부드럽고 전문적인 사용자 경험을 제공하고자했다.

// src/app/sale/[id]/page.jsx
import TransactionSkeleton from '@/components/ui/skeleton/TransactionSkeleton';
import ExchangeInfoSkeleton from '@/components/ui/skeleton/ExchangeInfoSkeleton';

function SaleSkeleton() {
    return (
        <div className="mx-auto w-[345px] tablet:w-[704px] pc:w-[1480px]">
            <TransactionSkeleton type="seller" /> {/* 메인 카드 정보 스켈레톤 */}
            <ExchangeInfoSkeleton /> {/* 교환 희망 정보 스켈레톤 */}
        </div>
    );
}

export default function SalePage() {
    // ... (useQuery로 데이터 로딩 중)

    if (isLoadingShop) return <SaleSkeleton />; // 데이터 로딩 중일 때 스켈레톤 UI 렌더링

    // ... (실제 데이터 렌더링)
}

3. useMemo를 활용한 값 메모이제이션 (성능 최적화)

filterCounts는 allCards (모든 페이지의 카드 데이터)를 기반으로 각 등급과 장르별 카드의 개수를 계산한다.
useMemo 훅을 사용하여 allCards가 변경되지 않는 한, 컴포넌트가 리렌더링되더라도 getFilterCounts 함수를 다시 호출하지 않고 이전에 계산된 filterCounts 값을 재사용한다.

이는 불필요한 계산을 방지하여 컴포넌트의 렌더링 성능을 향상시키고, 특히 데이터 양이 많아질 때 더욱 큰 효과가 있었다.

// src/components/market/MyCardsSellBottomSheet.jsx
import React, { useEffect, useState, useMemo } from 'react'; // useMemo 임포트

export default function MyCardsSellBottomSheet({
  isOpen,
  onClose,
  onCardSelectedForSale,
}) {
  // ... (useInfiniteQuery 선언 및 allCards 생성)

  const getFilterCounts = cards => { // 필터 카운트를 계산하는 함수
    const counts = {grade: {}, genre: {}};
    cards.forEach(card => {
      counts.grade[card.cardGrade] = (counts.grade[card.cardGrade] || 0) + 1;
      counts.genre[card.cardGenre] = (counts.genre[card.cardGenre] || 0) + 1;
    });
    return counts;
  };

  const filterCounts = useMemo(() => { // useMemo로 filterCounts 값 메모이제이션
    if (!data) return null;
    return getFilterCounts(allCards);
  }, [data]); // data (allCards를 포함)가 변경될 때만 재계산
}

7. 향후 개선 사항 및 제안

  1. 판매 및 교환 중복 코드 공통 컴포넌트 제작
  • 공통된 UI/UX인 부분을 각자 따로 개발을 진행했다. 해당 부분이 데스크탑은 모달창, 태블릿은 바텀시트, 모바일은 풀페이지로 반응형이 구현되어야했어서 코드가 상당히 길다.
    중복되는 코드를 컴포넌트로 제작하면 코드 최적화를 할 수 있다.
  1. PointHistory 모델 사용
  • PointHistory 모델은 단순히 숫자로 된 잔액이 아닌, 포인트의 '생애 주기'를 기록하고 관리하여 서비스의 투명성, 신뢰성, 그리고 사용자 데이터 분석으로 사용해보면 좋을 것 같다.

0개의 댓글