최애의 포토는 디지털 시대의 새로운 수집 문화를 선도하는 플랫폼으로, 사용자가 좋아하는 아이돌, 스포츠 스타, 그림 등의 디지털 포토카드를 손쉽게 사고팔 수 있는 공간을 제공한다. 사용자는 디지털 자산으로 나만의 컬렉션을 완성하고, 서로의 포토카드를 교환하며, 자랑하는 재미와 함께 상호 교류를 즐길 수 있도록 지원한다.
1. 인증 및 사용자 관리
2. 포토 카드 거래 시스템
3. 알림 시스템
4. 마이 갤러리 및 포토 카드 생성
5. 나의 판매 포토카드
6. 포인트 시스템
기능 | 작업 |
---|---|
초기 DB 모델 설계 | 전체 서비스 초기 데이터베이스 스키마 설계 |
판매 | 판매 생성 API |
판매를 위한 IDLE 카드 조회 API | |
판매 상세 API | |
판매 수정 API | |
판매 삭제 API |
기능 | 작업 |
---|---|
판매 | 판매 상세 페이지 |
판매 조회 반응형(모달창, 바텀시트) | |
판매 등록 반응형(모달창, 바텀시트, 페이지) | |
판매 수정 반응형(모달창, 바텀시트, 페이지) |
기능 |
---|
중간, 최종 발표자료(PPT) 제작 |
기술 스택 | |
---|---|
언어 및 런타임 | JavaScript |
BE 프레임워크 | Express |
DB & ORM | PostgreSQL |
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)... |
User
와 PhotoCard
를 참조하는 중간 테이블인 UserCard
모델을 새롭게 설계하여 각 포토카드 인스턴스(개별 카드)의 개별 상태를 관리할 수 있도록 하였다. cardStatus
값은 IDLE
(미판매), LISTED
(판매 등록), SOLD
(판매 완료)의 세 가지로 나누어 정의했다.userId
만 변경하면 되도록 구성하였다. 이를 통해 각 카드 한 장 한 장의 상태를 유연하게 추적하고 관리할 수 있게 되었으며, 복잡한 거래 로직을 효율적으로 처리할 수 있는 기반을 마련했다.문제 상황
→ 개발 환경에서 .env
파일에 설정된 환경 변수가 특정 노트북에서는 정상적으로 인식되어 애플리케이션이 작동했지만, 다른 컴퓨터에서는 환경 변수를 인식하지 못해 401 Unauthorized
에러가 발생하는 문제가 발생했다. 백엔드 app.js
파일에 dotenv
를 사용하여 환경 변수를 로드하도록 설정했음에도 불구하고 이러한 차이가 발생하여 원인 파악에 어려움을 겪었다.
원인 분석
→ package.json
의 dev
스크립트가 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 에러를 해결할 수 있었다.
이번 중급 프로젝트은 초급때보다는 훨씬 난이도 있는 핵심 기능을 구현하며 복잡한 시스템 설계와 유저 플로우 고민의 중요성을 깊이 체감할 수 있었다. 특히 '최애의 포토'는 거래 시스템을 중심으로 한 포인트 차감, 교환 제안 등 다양한 상호작용이 있어, 개발 초기부터 팀원들과의 협의를 통해 API 명세와 DB 스키마를 선제적으로 설계하는 것이 얼마나 중요한지 깨달을 수 있었다.
초급 프로젝트에서 부족했던 부분들을 보완하고자, 이번에는 코드 컨벤션, Git 브랜치 전략, 커밋 컨벤션 등을 프로젝트 초기에 더 명확하게 정의하고 공유하는 데 집중했다. 이를 통해 불필요한 merge conflict를 줄이고, 코드 리뷰 과정에서 더욱 생산적인 피드백을 주고받을 수 있었다. 백엔드와 프론트엔드 간의 데이터 흐름을 명확히 함으로써 각자의 개발 효율을 높일 수 있었다.
프로젝트 전반에 걸쳐 발생한 문제점들을 함께 논의하고 해결하며, 기술적인 역량뿐만 아니라 협업 역량도 한층 더 성장시킬 수 있었던 소중한 경험이었다. 특히, 맡은 파트의 대부분이 모달과 바텀시트 구현이었는데, 모달 구현에 익숙하지 않아 예상보다 많은 시간이 소요되어 아쉬움이 남았다. 하지만 이 과정을 통해 코드에 대한 확신과 해석 능력이 초급 프로젝트 때보다 훨씬 성장했다고 느낄 수 있었다.
확실히 난이도가 높아지니 한 명의 코드에 문제가 생겼을 때 문제가 파급되는 기능이 많아지는 것을 체감할 수 있었다. 앞으로는 코드를 더욱 꼼꼼하게 다시 한번 살펴보며 협업에 임해야겠다는 강한 책임감을 느끼게 되었다.
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를 포함)가 변경될 때만 재계산
}