Published on

유랑마켓

Authors
  • avatar
    Name
    이지영

| 해외 거주 한인 대상 중고거래 및 커뮤니티 플랫폼

🗓️ 기간: 2025.03.24 ~

🛠️ 사용 기술: HTML5, CSS, TailwindCSS, JavaScript, React.js, Next.js, Zustand, Tanstack Query, MongoDB, Firebase

💐 번들링 도구: Webpack 5

🌿 깃허브:

🖥️ 배포 도구: Vercel move to site

  • 구현 방식 : Next.js(Page Router) 기반 SSG + SSR + CSR을 혼합 적용한 풀스택 플랫폼

  • 프로젝트 개요 : 당근마켓을 모티브로 한 해외 거주 한인을 위한 중고거래 웹 서비스입니다. 워킹홀리데이 기간 동안 직접 겪은 불편함을 바탕으로, 해외에 거주하는 한인들이 쉽고 안전하게 중고거래와 정보 교류를 할 수 있는 플랫폼을 직접 기획하고 개발했습니다.

    이 프로젝트는 다섯 개의 주요 페이지로 구성되어 있습니다.

    • 홈 (/): 공지사항·중고거래 검색 기능·커뮤니티 게시글을 섹션별로 한눈에 확인할 수 있도록 구성했습니다.

    • 사고팔고 (/market): 사용자가 선택한 동네의 게시글만 노출되도록 지역코드를 활용한 필터링 기능을 구현했으며, 게시글 등록 시에는 위치 인증을 통해 사용자가 실제로 위치한 법정동(행정동)에 해당하는 지역에만 게시글을 올릴 수 있도록 제한했습니다.

    • 커뮤니티 (/community): 사용자가 ‘공지사항’, ‘해외살이’, ‘워킹홀리데이’, ‘해외취업’의 네 가지 메인 카테고리와 그 하위의 세부 카테고리를 선택해 글을 작성하고 자신의 경험을 공유할 수 있으며, 댓글과 좋아요 기능도 함께 제공합니다.

    • 채팅 (/chat): 중고물품 판매자와의 대화 기록이 담긴 채팅방 목록이 렌더링되고, 사용자는 채팅 상세 페이지에서 해당 상품을 바로 구매할 수 있습니다.

    • 프로필 (/profile): 간단한 내비게이션 메뉴와 함께 위시리스트, 구매 내역 등의 사용자 활동 기록을 확인할 수 있으며, 로그아웃과 회원 탈퇴 기능을 제공합니다.


🚀 핵심 기능 구현

  • NextAuth.js + JWT 기반의 stateless 인증 구조 구현으로 세션리스 아키텍처 적용 및 서버 부하 감소 및 응답 속도 개선
  • Cloudinary API를 활용한 이미지 업로드 API 구축으로 클라우드 스토리지 적용 및 저장 용량 절감 및 전송 성능 향상
  • CSR·SSR 모두 대응 가능한 에디터 중 커스터마이징 범위가 넓은 Tiptap을 채택하여, 커뮤니티 게시글 작성·등록 API 구현
  • 지역별 커뮤니티 게시글 필터링을 위해 카카오 법정동 데이터를 기반으로 한 지역 검색 API 개발
  • 내 위치 버튼 기능 개발 시, 브라우저 Geolocation API와 Kakao 역지오코딩 오픈 API를 연동하여 사용자의 현재 좌표를 주소 및 지역 코드로 실시간 변환하는 로직 구현
  • Redis DB와 CoolSMS 라이브러리를 활용한 전화번호 인증 API 개발로, 인증번호를 메모리 DB에 저장하여 서버 재부팅 후에도 인증 상태 안정적으로 관리
  • Socket.IO 기반의 실시간 채팅 기능 구현 시, UUID 기반 낙관적 렌더링을 적용하여 메시지 전송 시 UI 반응성 및 사용자 경험 개선

❗NOTICE️

🚧 1. 현재 서비스는 국내 사용자 기준의 중고거래 플랫폼 형태로 운영되고 있습니다.
    - 국가별 UI 전환 및 위치 기반 검색 기능은 아직 개발 단계에 있으며, 로그인 후 사용자가 위치한 국가(또는 선택한 국가)에 따라 현지화된 마켓 콘텐츠와 거래 서비스를 제공할 예정입니다.
    - 따라서 현재는 국내 사용자 중심의 일반 중고거래 서비스만 이용 가능합니다.
    - 추후 워킹홀리데이 주요 국가(영국, 캐나다, 호주)를 대상으로 공공 데이터 및 오픈 API를 선별하여 지역 기반 검색 및 스타일링 기능을 단계적으로 확장할 계획입니다.

⚠️ 2. 현재 CoolSMS의 요금제 이슈로 인해, 회원가입 시 문자 인증은 테스트용 번호에서만 가능합니다.
     허용된 테스트 번호: 01000000000
    ✅ 인증번호: 123456
    🚫 실제 휴대폰 번호로는 인증이 불가하며, 추후 요금제 변경 시 제한이 해제될 예정입니다.
  > 따라서 회원가입 시 휴대폰 번호는 '01000000000'으로 진행해 주시고, 비밀번호는 '123456'을 입력해주세요.
  > 또한, 추후 요금제 변경 및 개발이 완료되는 대로 로그인 절차에도 문자 인증 로직을 포함할 예정입니다.

레이아웃 UI 토글 상태를 Context로 지역화하여 상태 스코프를 구조적으로 제한

유랑마켓을 개발하면서 가장 고민이 컸던 지점 중 하나는 사이드바·검색·설정·알림처럼 페이지 전체를 덮는 레이아웃 UI 토글 상태를 어떻게 관리할 것인가였습니다. 프로젝트 초반에는 이 UI 상태들을 Zustand 전역 스토어에서 관리했는데, selector 기반 부분 구독 덕분에 성능 면에서는 충분히 효율적이었습니다.

그러나 개발을 진행하면서 해당 UI 토글 상태들은 로그인 정보처럼 여러 페이지 간에 공유되거나 복잡한 도메인 로직을 수반하는 상태가 아니라, 특정 레이아웃에 종속되고 페이지 전환 시 초기화되어도 무방한 UI 상태라는 점이 분명해졌습니다. 이에 전역 상태 관리보다는, 상태의 존재 범위와 접근 가능 범위를 코드 구조로 명확히 드러낼 수 있는 방식이 더 적합하다고 판단했고, Context API를 활용한 지역화 전략을 선택했습니다.

1. 스코프 제한: Provider를 Layout에 두어 상태의 존재 범위 명시

Zustand의 selector 기반 구독을 활용하면 UI 토글 상태를 전역 스토어로 관리하더라도 성능상 문제는 크지 않습니다. 하지만 저는 단순한 렌더링 성능 최적화보다, 해당 상태가 어디에서만 의미를 갖는지를 코드 구조만으로도 명확히 드러내는 것이 DX와 UI 아키텍처 이해 측면에서 더 중요하다고 판단했습니다.

Context는 상태를 사용하기 위해 반드시 Provider 내부에 위치해야 한다는 특성이 있기 때문에, Provider의 위치를 통해 상태의 스코프를 구조적으로 제한할 수 있습니다.

이에 최상위 _app이 아닌, 로그인·회원가입 페이지(/auth)를 제외한 모든 페이지의 공통 부모인 Layout 컴포넌트 내부에 Provider를 배치하여, 해당 UI 토글 상태가 레이아웃 내부에서만 접근 가능한 상태임을 명확히 표현했습니다. 이를 통해 UI 토글 상태가 전역적으로 공유되어야 하는 상태가 아니라는 점을 분명히 하고, 해당 UI가 필요 없는 페이지에서의 의도치 않은 접근이나 의존을 구조적으로 차단할 수 있었습니다.

2. 전파 범위 제한: Layout은 구독하지 않고 Layer만 구독하도록 분리

두 번째로 고려한 포인트는 컨텍스트를 누가 구독하느냐였습니다. 만약 Layout 컴포넌트에서 UI 토글 상태를 직접 구독할 경우, 사이드바를 여닫는 단순한 동작만으로도 Layout 전체와 그 하위 컴포넌트들이 불필요하게 리렌더될 수 있습니다.

이를 방지하기 위해 Layout에서는 UI 토글 상태를 직접 구독하지 않고, SidebarLayer, SearchLayer와 같은 UI 레이어 컴포넌트들을 기능별로 분리하여 각 레이어가 자신의 Context만 구독하도록 설계했습니다. 그 결과, 사이드바 토글 시에는 SidebarLayer만 리렌더되고, 검색·설정·알림 등 다른 영역으로는 렌더링 전파가 확산되지 않도록 전파 범위를 효과적으로 제한할 수 있었습니다.

3. value 참조 안정화: useMemo / useCallback으로 불필요한 Context 업데이트 억제

Context를 사용할 때 발생할 수 있는 대표적인 문제 중 하나는, Provider의 value가 객체 형태로 전달되기 때문에 부모 컴포넌트가 리렌더될 때마다 참조가 새로 생성될 수 있다는 점입니다. 이 경우 실제 UI 토글 값이 변경되지 않았더라도, React는 컨텍스트 값이 변경되었다고 판단하여 해당 Context를 구독하는 컴포넌트들의 불필요한 리렌더를 유발할 수 있습니다.

이를 해결하기 위해 Provider 내부에서 토글 핸들러 함수는 useCallback으로 고정하고, value 객체는 useMemo로 메모이제이션하여 부모(Layout)가 라우트 변경 등 다른 이유로 리렌더되더라도 토글 값이 실제로 변경되지 않았다면 value 참조가 유지되도록 구현했습니다. 그 결과, 컨텍스트 구독 컴포넌트들은 UI 토글 값이 실제로 변경된 경우에만 업데이트를 받도록 제어할 수 있었고, 불필요한 Context 업데이트 전파를 효과적으로 줄일 수 있었습니다.


실시간 채팅 (WebSocket)

(1) 로컬 임시 ID(localId)로 낙관적 렌더링

WebSocket 기반의 실시간 통신을 사용하더라도, 클라이언트가 메시지를 전송한 뒤 서버가 이를 다시 브로드캐스트해 줄 때까지는 네트워크 왕복 지연(RTT)이 필연적으로 발생합니다. 이로 인해 메시지를 전송했음에도 화면에 즉시 반영되지 않아, 사용자는 전송이 실패했다고 인식할 수 있는 UX 문제가 발생했습니다.

이를 해결하기 위해, 메시지 전송 시 서버 응답을 기다리지 않고 클라이언트에서 uuid(Universally Unique Identifier) 라이브러리를 이용해 임시 로컬 ID(localId) 를 생성하여 메시지를 즉시 UI에 반영하는 낙관적 렌더링(Optimistic Rendering) 전략을 적용했습니다. 서버로부터 실제 메시지가 도착하면, localId를 기준으로 서버 메시지와 매칭하여 상태를 확정하는 방식으로 데이터 정합성을 유지했습니다.

그 결과, 메시지 전송 직후 UI가 즉시 업데이트되어 사용자 입장에서는 체감 지연이 거의 없는 실시간 채팅 경험을 제공할 수 있었고, 동시에 서버 기준 메시지와의 일관성도 함께 보장할 수 있었습니다.

(2) 채팅 GET API 요청의 최신성을 보장하기 위해 조건부 요청 차단

브라우저는 기본적으로 GET 200 응답을 저장하고, 이후 동일 URL 호출 시 명시적 지시가 없으면 자동으로 조건부 요청(If-None-Match/If-Modified-Since) 흐름을 탑니다. 이때 서버·프록시가 반환한 304(Not Modified) 는 오류가 아니라 “재검증 성공 → 캐시 본문 재사용” 신호인데, 채팅처럼 쓰기 직후 읽기가 빈번하고(폴링/재연결/탭 전환), 동일 URL을 자주 두드리는 시나리오에선 이것이 최신성 결손을 초래할 수 있습니다. 이유는 검증자의 품질과 시각 정밀도(약한 ETag, Last-Modified 초 단위), 중간 프록시의 개입 등으로 실제 메시지가 늘어났어도 304가 과도하게 발생해 옛 본문을 재사용하는 일이 생기기 때문입니다.

이 문제를 제거하기 위해 채팅·알림 등 실시간/개인화 API에는 Cache-Control: no-store 를 적용했습니다. no-store는 응답을 저장 자체를 금지하므로 검증자(ETag/Last-Modified)를 보유할 수 없고, 따라서 브라우저가 조건부 요청을 보낼 수 없습니다. 결과적으로 매 호출이 항상 200 + 최신 본문으로 귀결되어, 쓰기 직후 읽기 패턴에서도 UI가 일관된 최신 스냅샷을 받도록 보장했습니다.


트러블슈팅

🔹 유저의 좋아요/싫어요 여부가 포함된 목록 렌더링 시, 시간복잡도를 고려한 데이터 구조 선택

📌 구현 배경 및 해결 전략

중고마켓 커뮤니티 목록과 중고물품 목록에서, 각 게시글에 대해 사용자가 “좋아요/싫어요”를 눌렀는지 또는 위시리스트에 추가했는지를 판별해야 했습니다. 이를 위해 초기에는 API Routes에서 게시글 목록을 불러온 뒤, 유저의 likes, dislikes, wishlist 배열을 순회하며 includes() 메소드를 사용해 해당 게시글의 ID가 포함되어 있는지를 매번 확인하는 방식으로 구현하였습니다.

api/posts/index.js
// 좋아요/싫어요 "배열" 생성
const likesArr = (user.likes ?? []).map(String);
const dislikesArr = (user.dislikes ?? []).map(String);

// 게시글에 userHasLiked / userHasDisliked 계산해서 붙이기
const postsWithUserStatus = documents.map((post) => {
  const id = String(post._id);
  return {
    ...post,
    userHasLiked: likesArr.includes(id),       // O(L)
    userHasDisliked: dislikesArr.includes(id), // O(L)
  };
});

📌 어떤 고민이 있었는지?

이 방식은 소규모 데이터에서는 문제없이 동작했으나, 데이터가 많아질수록 성능 저하가 두드러졌습니다. 실제로 게시글과 사용자 데이터가 일정 규모를 넘어가자 목록 API 응답 지연과 클라이언트 렌더링 지연이 동시에 발생하였고, 체감 속도가 눈에 띄게 떨어졌습니다.

문제의 원인을 살펴보니, 배열에서 “includes()”를 사용할 경우 특정 요소가 존재하는지를 확인하기 위해 매번 배열의 처음부터 끝까지 순차 탐색을 수행하게 되어 시간복잡도가 O(n)에 해당한다는 점이 성능 저하의 핵심 요인이었습니다.

📌 어떻게 해결했는지?

문제를 해결하기 위해 자료구조의 선택이 성능에 큰 영향을 준다는 점에 주목했습니다.

시간복잡도를 표현할 때에는 일반적으로 빅 오 표기법(Big O notation)을 사용하는데, 이는 입력 크기 n이 무한히 커질 때 실행 시간이 증가하는 추세를 점근적 상한으로 나타낸 것입니다.

여기서 점근적 상한이란, 입력하는 n이 무한대로 커진다고 생각했을 때, 일반적으로 n에 따라 실행시간도 점점 계속해서 증가할 것입니다. 하지만 실행시간이 증가하는 데에도 한계가 있는데, 이 한계를 점근적 상한이라고 부릅니다. 즉, 실제 실행 시간이 경우에 따라 조금 더 빠를 수도 있지만, 최악의 경우를 기준으로 했을 때 반드시 넘지 못하는 시간 증가의 한계선을 수학적으로 표현한 개념입니다.

배열에서 includes() 메소드를 사용할 경우, 특정 데이터의 존재 여부를 확인하기 위해 앞에서부터 끝까지 순차적으로 탐색해야 합니다. 이때 최악의 경우에는 n개의 요소를 모두 검사해야 하므로, 실행 시간은 입력 크기 n에 비례하여 증가하게 됩니다. 이러한 성능을 빅 오 표기법(점근적 상한)으로 표현하면 O(n), 즉 선형시간 복잡도를 갖습니다.

반면 Set은 내부적으로 해시테이블을 기반으로 동작합니다. 해시 함수가 곧바로 해당 요소의 주소를 찾아가기 때문에, 입력 크기와 상관없이 일정한 시간 내에 탐색이 가능합니다. 이러한 경우 실행 시간은 데이터가 아무리 많아져도 일정한 상한에 머물게 되고, 이를 빅 오 표기법으로 표현하면 O(1), 즉 상수시간 복잡도를 갖습니다.

다시 말해 배열은 원하는 인덱스를 알고 있을 때는 O(1)로 접근할 수 있지만, 특정 데이터의 존재 여부를 확인하는 연산에서는 O(n)의 시간복잡도를 가집니다. 반면 Set은 내부적으로 해시테이블 기반으로 구현되어 있어, 요소의 존재 여부를 확인하는 연산에서 기대 시간복잡도가 O(1)에 해당합니다.

따라서, 유저의 likes, dislikes, wishlist 배열을 API 응답 단계에서 모두 Set으로 변환한 뒤, 각 게시글에 대해 set.has(id)로 존재 여부를 확인하도록 리팩토링하였습니다. 그 결과, 데이터가 많아져도 일정한 속도로 결과를 반환할 수 있었고, API 응답 지연과 클라이언트 렌더링 지연 문제를 동시에 해소할 수 있었습니다.

  • Array.prototype.includes()O(n): 배열을 앞에서부터 끝까지 훑음
  • Set.prototype.has()O(1) 기대: 해시 조회(주소 찾아가기)에 해당'
api/posts/index.js
// 좋아요/싫어요 Set 생성
const likeSet = new Set((user.likes ?? []).map(String));
const dislikeSet = new Set((user.dislikes ?? []).map(String));

// 각 게시글에 userHasLiked / userHasDisliked 계산해서 붙이기
const postsWithUserStatus = documents.map((post) => {
  const id = String(post._id);
  return {
    ...post,
    userHasLiked: likeSet.has(id),       // O(1) 기대
    userHasDisliked: dislikeSet.has(id), // O(1) 기대
  };
});

📌 무엇을 배웠는지?

  • 시간복잡도의 개념과 이를 빅 오(Big O) 표기법으로 나타내는 방법을 명확히 이해했습니다.
  • 배열과 Set의 시간복잡도를 비교하며, 특정 데이터의 존재 여부를 확인하는 연산에는 Set이 훨씬 효율적임을 학습했습니다.
  • 해시테이블 기반 자료구조는 속도가 빠른 만큼 메모리 공간을 더 많이 차지하긴 하지만, 순차 탐색을 수행하는 배열보다 일반적으로 우수한 성능을 제공한다는 점을 확인했습니다.
  • 기능을 구현할 때는 단순히 동작 가능 여부뿐 아니라, 데이터 규모가 커졌을 때의 성능까지 고려하여 자료구조를 적절히 선택하는 것이 중요하다는 점을 배웠습니다.

현재 미구현 기능 (추후 개발 예정)

  • 로그인 시 국가 설정 기능
  • 메인 홈 페이지 UI 및 콘텐츠 구성
  • 알림 기능
  • 회원가입/로그인 시 인증 방식 리팩토링 예정