Notice
Recent Posts
Recent Comments
반응형
관리 메뉴

HM소프트

공간 대여 매칭 플랫폼 개발기 (1) — PostGIS 위치 검색과 모바일 MVP 본문

외주 개발일지

공간 대여 매칭 플랫폼 개발기 (1) — PostGIS 위치 검색과 모바일 MVP

HM소프트 2026. 7. 4. 13:51
반응형

공간 대여 매칭 플랫폼 MVP를 위치 기반 검색부터 만든 기록. PostGIS geography와 GiST 인덱스로 반경 검색을 풀며 ST_DWithin은 필터, ST_Distance는 표시로 나누고, 좌표 순서 함정과 모바일 JWT 토큰 갱신을 인터셉터 한 곳에 가둔 과정을 담았다.

세 종류의 사용자를 잇는다는 문제

유휴 공간을 가진 사람, 그 공간에 둘 제품을 가진 사람, 그리고 그걸 팔아줄 사람. 한 공간 대여 매칭 플랫폼의 MVP를 만들면서 가장 먼저 부딪힌 건 "이건 흔한 양방향 매칭이 아니다"라는 사실이었다. 보통의 마켓플레이스는 공급자와 수요자, 두 역할만 있으면 된다. 그런데 이 서비스는 공간 제공자 · 제품 제공자 · 판매자라는 세 종류의 사용자가 서로를 향해 요청을 날린다. 공간 주인이 "내 가게에 이 제품을 놓고 싶다"고 제품 쪽에 손을 내밀 수도 있고, 반대로 제품 주인이 "이 공간에 내 물건을 깔고 싶다"고 공간 쪽에 요청할 수도 있다. 방향이 양쪽 다 열려 있는 매칭이다.

도메인이 이렇게 비대칭적이면, MVP에서 욕심을 내는 순간 끝이 없다. 그래서 1차 범위를 칼같이 잘랐다. 매칭의 출발점이 되는 "공간"부터 제대로 만들자. 공간을 등록하고, 지도에서 보고, "내 주변 N km 안의 공간"을 거리순으로 찾는 것 — 여기까지를 1편의 목표로 삼았다. 제품 등록과 실제 3자 매칭 로직, 결제는 뒤로 미뤘다. 다만 미룬다고 데이터 모델까지 미루면 나중에 마이그레이션 지옥에 빠지므로, 테이블 구조와 계층 분리만큼은 처음부터 3자 매칭을 염두에 두고 깔았다.

이 글에서는 그 첫 범위를 만들면서 내린 결정들을 순서대로 풀어본다. 모노레포 구성, PostGIS로 위치 검색을 처리한 방식, 모바일 인증에서 토큰 갱신을 어떻게 한 곳에 가둬뒀는지, 그리고 두 번째 커밋이 왜 하필 "환경 설정 정리"였는지까지.

한눈에 보는 구조 — 모노레포와 계층

코드는 모노레포로 잡았다. apps/mobile은 React Native(Expo) + TypeScript, apps/web은 React(Vite) 웹앱, apps/backend는 FastAPI(Python)다. 클라이언트가 둘(모바일·웹)이지만 백엔드 API는 하나를 공유한다. 이렇게 한 저장소에 묶으면 타입 정의와 API 계약을 한 곳에서 맞출 수 있어서, MVP처럼 빠르게 흔들리는 단계에서는 오히려 관리가 편하다.

백엔드는 처음부터 routers → services → repositories 세 계층으로 나눴다. 라우터는 HTTP 요청을 받아 파라미터를 검증하는 일만 하고, 서비스는 권한 검사와 도메인 규칙을 담고, 리포지토리는 SQLAlchemy 쿼리만 담당한다. MVP라고 한 함수에 다 욱여넣고 싶은 유혹이 있었지만, 곧 들어올 제품 매칭과 결제를 생각하면 그 순간이 가장 싸게 계층을 나눌 수 있는 시점이었다. 실제로 공간(Space)을 만들 때 검증한 패턴을 제품(Product)·매칭(Match)이 그대로 복제하게 된다.

핵심은 "내 주변 N km" — PostGIS에 맡기다

이 서비스에서 가장 자주, 가장 무겁게 돌 쿼리는 하나로 정해져 있었다. "지금 내 위치에서 반경 5km 안의 공간을, 가까운 순서로". 이걸 어떻게 푸느냐가 사실상 백엔드 설계의 절반이었다.

가장 손쉬운 방법은 위도·경도를 컬럼 두 개로 저장하고, 애플리케이션 코드에서 모든 행을 읽어 하버사인(haversine) 공식으로 거리를 계산해 정렬하는 것이다. 공간이 수십 개일 땐 잘 돌아간다. 그러나 공간이 수만 개가 되면 매 요청마다 전체 테이블을 읽어 파이썬에서 거리를 계산하는 풀스캔이 된다. 인덱스를 태울 수가 없다. 그래서 처음부터 PostgreSQL + PostGIS를 박고, 거리 검색을 DB에 통째로 위임했다.

핵심 결정 세 가지였다.

첫째, 위치 컬럼의 타입을 geometry가 아니라 geography로 잡았다. geometry로 두면 거리의 단위가 좌표계 단위(SRID 4326이면 "도")라서 "5km 이내"를 표현하려면 위도에 따라 환산을 해야 한다. 적도 근처와 고위도에서 1도의 실제 거리가 다르기 때문이다. geography는 내부적으로 지구를 타원체로 보고 계산해서, ST_DWithin이나 ST_Distance에 넘기는 거리 인자가 그냥 미터다. 환산 코드가 통째로 사라진다.

둘째, 원시 위도·경도를 location 옆에 일부러 중복 저장했다. PostGIS 포인트 하나면 좌표가 다 들어 있는데 왜 컬럼을 또 두냐 하면, 클라이언트로 응답을 내보낼 때 geography 바이너리에서 좌표를 다시 뽑는 것보다 평범한 숫자 컬럼을 그대로 읽어 보내는 게 단순하고, 디버깅할 때 테이블을 눈으로 봐도 좌표가 바로 읽히기 때문이다. 대신 둘이 어긋나면 안 되므로, 좌표가 바뀌는 경로(생성·수정)에서는 반드시 두 컬럼을 함께 갱신하도록 서비스 계층에서 묶었다. 이 "한 곳에서만 좌표를 만진다"는 규칙이 나중에 수정 로직에서 한 번 더 등장한다.

셋째, GiST 공간 인덱스를 마이그레이션 단계에서 같이 걸었다. PostGIS의 반경 검색이 인덱스를 타려면 위치 컬럼에 GiST 인덱스가 있어야 한다. 없으면 ST_DWithin이 멀쩡한 문법인데도 결국 풀스캔으로 떨어진다.

CREATE EXTENSION IF NOT EXISTS postgis를 마이그레이션 맨 앞에 둔 것도 의도적이다. 이 한 줄이 없으면 그다음 Geography 컬럼을 만드는 순간 "그런 타입 없다"며 마이그레이션이 통째로 깨진다. 로컬 Docker 환경이든 클라우드 관리형 DB든, "DB를 처음 세우는 사람"이 확장 활성화를 빼먹는 건 거의 단골 사고라서, 코드(마이그레이션)에 박아 두는 편이 안전하다.

반경 검색 쿼리 — ST_DWithin과 ST_Distance를 같이 쓴다

이제 실제 검색 쿼리다. 리포지토리의 search_nearby가 이 서비스의 심장이다. 요청 좌표로 포인트를 만들고, ST_DWithin으로 반경 안의 행만 거르고, 같은 거리식을 ORDER BY에도 써서 가까운 순으로 정렬한다.

이 작은 함수에 발이 걸리기 쉬운 함정이 여럿 박혀 있다.

좌표 순서. ST_MakePoint(경도, 위도) 순서로 받는다. 사람은 보통 "위도, 경도"로 말하고 변수도 그 순서로 받기 때문에, 무심코 ST_MakePoint(lat, lng)로 넘기면 좌표가 통째로 뒤집힌다. 그러면 한반도의 한 지점이 엉뚱하게 적도 어딘가로 날아가서, 반경 검색이 늘 0건을 돌려준다. 에러도 안 나고 결과만 빈다 — 가장 찾기 짜증나는 종류의 버그라, 이 순서는 코드 리뷰 때 제일 먼저 눈으로 확인하는 지점이다.

ST_DWithin은 필터, ST_Distance는 표시. 둘 다 거리를 다루지만 역할이 다르다. ST_DWithin은 "반경 안인가?"라는 불리언 판정이라 GiST 인덱스를 타서 빠르게 후보를 줄인다. 반면 ST_Distance는 실제 거리값을 계산하므로, 이걸 WHERE에 쓰면 모든 행에 대해 거리를 계산해야 해서 인덱스를 못 탄다. 그래서 거르는 건 ST_DWithin으로, 정확한 거리는 줄어든 후보에 대해서만 ST_Distance — 이 분업이 핵심이다. 거리값은 응답에 distance_km로 같이 실어 보내서, 클라이언트가 "1.2km 거리" 같은 표시를 따로 계산 없이 쓰게 했다.

상태 필터를 검색 안에 박았다. status == APPROVED 조건을 검색 쿼리에 직접 넣었다. 공간은 등록되면 PENDING 상태로 시작하고, 관리자 승인을 거쳐야 APPROVED가 된다. 검색 결과에 미승인·비활성 공간이 섞여 나오면 안 되므로, 노출 정책을 쿼리 레벨에서 강제한다. 흐름으로 그리면 이렇다.

좌표는 한 곳에서만 만든다 — 생성과 수정의 대칭

쿼리만큼 신경 쓴 게 "좌표를 만드는 길을 하나로 모으는 일"이었다. 공간을 새로 만들 때, 서비스 계층에서 위도·경도로 PostGIS 포인트를 조립한다. 여기서 권한 검사도 같이 한다 — 공간은 공간 제공자만 등록할 수 있다.

async def create_space(self, space_data: SpaceCreate, owner: User) -> SpaceResponse:
    # 공간 제공자만 등록 가능 (도메인 규칙을 서비스 계층에서 강제)
    if owner.user_type != UserType.SPACE_PROVIDER:
        raise HTTPException(status_code=403,
                            detail="공간 제공자만 공간을 등록할 수 있습니다.")

    # WKT 문자열로 포인트 생성 — "POINT(경도 위도)" 순서
    location = WKTElement(
        f"POINT({space_data.longitude} {space_data.latitude})", srid=4326)

    space = Space(
        owner_id=owner.id,
        name=space_data.name,
        space_type=space_data.space_type,
        address=space_data.address,
        location=location,                 # PostGIS 포인트
        latitude=space_data.latitude,      # 원시 좌표도 함께 저장
        longitude=space_data.longitude,
        price_per_day=space_data.price_per_day,
        images=space_data.images or [],
    )
    created = await self.space_repo.create(space)
    return self._to_response(created)

여기서 WKTElement"POINT(경도 위도)" 문자열을 만들어 넘긴다. 검색 쿼리의 ST_MakePoint와 똑같이 경도가 먼저다. 생성과 검색에서 좌표 순서 규칙을 동일하게 맞춰 둬야 머릿속에서 헷갈리지 않는다.

수정 경로는 더 까다롭다. 공간 정보를 부분 수정할 때, 위도나 경도 둘 중 하나만 바뀔 수도 있다. 그러면 location 포인트를 다시 만들어야 하는데, 안 바뀐 쪽 좌표는 기존 값에서 끌어와야 한다. 이걸 놓치면 위도만 고쳤는데 경도가 None으로 들어가 포인트가 깨진다.

update_data = space_data.model_dump(exclude_unset=True)

# 좌표가 하나라도 바뀌면 포인트를 재조립 (안 바뀐 쪽은 기존 값 사용)
if 'latitude' in update_data or 'longitude' in update_data:
    lat = update_data.get('latitude', space.latitude)
    lng = update_data.get('longitude', space.longitude)
    space.location = WKTElement(f"POINT({lng} {lat})", srid=4326)

model_dump(exclude_unset=True)클라이언트가 실제로 보낸 필드만 추려내는 게 포인트다. 부분 수정(PATCH 성격)에서 "보내지 않은 필드"와 "null로 보낸 필드"를 구분하지 않으면, 수정 안 한 항목까지 덮어써 버린다. 좌표 중 하나라도 들어왔으면 포인트를 다시 만들되, 빠진 쪽은 DB에 있던 기존 좌표(space.latitude)로 채운다. 생성·수정 양쪽에서 location을 만드는 코드가 대칭을 이루도록 맞춘 셈이다.

모바일 인증 — 토큰 갱신을 인터셉터 한 곳에 가둔다

백엔드 인증은 이메일/비밀번호 + JWT로 갔다. 액세스 토큰은 30분으로 짧게, 리프레시 토큰은 7일로 길게 잡았다. 토큰을 발급할 때 페이로드에 type을 박아("access" / "refresh"), 갱신 엔드포인트가 액세스 토큰을 리프레시 토큰인 척 들고 오는 걸 막았다.

def create_refresh_token(data: dict) -> str:
    to_encode = data.copy()
    expire = datetime.utcnow() + timedelta(days=settings.REFRESH_TOKEN_EXPIRE_DAYS)
    to_encode.update({"exp": expire, "type": "refresh"})  # 타입을 페이로드에 박음
    return jwt.encode(to_encode, settings.SECRET_KEY, algorithm=settings.ALGORITHM)

# 갱신 시: 리프레시 토큰이 맞는지 type으로 검증
async def refresh_token(self, refresh_token: str) -> Token:
    payload = decode_token(refresh_token)
    if payload.get("type") != "refresh":
        raise HTTPException(status_code=401, detail="Invalid token type")
    user = await self.user_repo.get_by_id(payload.get("sub"))
    if not user or not user.is_active:
        raise HTTPException(status_code=401, detail="User not found or inactive")
    # 액세스·리프레시 토큰을 모두 새로 발급 (리프레시 토큰 회전)
    return Token(access_token=create_access_token(data={"sub": str(user.id)}),
                 refresh_token=create_refresh_token(data={"sub": str(user.id)}))

갱신할 때 액세스 토큰뿐 아니라 리프레시 토큰도 새로 발급한다(토큰 회전). 리프레시 토큰이 한 번 쓰이면 새 것으로 교체되니, 탈취된 옛 토큰의 수명이 제한된다.

진짜 까다로운 쪽은 모바일이었다. "API를 호출하는 도중에 액세스 토큰이 만료되는" 상황을 어떻게 매끄럽게 넘기느냐다. 화면마다 일일이 "401이면 갱신하고 재시도"를 짜 넣으면 빠뜨리는 곳이 반드시 생긴다. 그래서 axios 응답 인터셉터 한 곳에 갱신 로직을 가뒀다. 토큰은 평문 저장이 위험하니 OS의 보안 저장소(SecureStore)에 넣었다.

여기서 두 가지가 핵심이다. 첫째, _retry 플래그. 이게 없으면 리프레시 토큰까지 만료된 상황에서 401 → 갱신 → 그 갱신 요청도 401 → 또 갱신… 무한 루프에 빠진다. 한 요청에 대해 갱신은 딱 한 번만 시도하도록 플래그로 잠근다. 둘째, 갱신 요청을 인터셉터가 걸린 api 인스턴스가 아니라 순수 axios로 날린 점이다. 갱신 호출 자체에까지 인터셉터가 붙으면, 그 호출이 401을 받았을 때 또 인터셉터가 돌면서 재귀가 생긴다. 이 두 장치가 없으면 토큰 만료 시 앱이 조용히 네트워크를 태우다 멈춘다 — 에러 화면도 안 뜨고 그냥 먹통이 되는, MVP에서 제일 무서운 종류의 버그라 먼저 막았다.

상태 관리는 Zustand를 골랐다. 전역 스토어가 가볍기도 하지만, 결정적인 이유는 인터셉터 같은 비-React 코드에서도 getState()로 스토어에 접근할 수 있어서다. 로그인하면 토큰을 SecureStore에 저장하고 사용자 정보를 받아 스토어에 채우고, 갱신이 끝내 실패하면 토큰을 지우고 인증 상태를 내린다. 리덕스의 보일러플레이트 없이 이걸 다 처리할 수 있었다.

지도 화면 — 역할에 따라 다른 화면을 그린다

지도는 모바일에서 가장 까다로운 화면이었다. 네이티브 지도 컴포넌트를 직접 붙이는 대신, WebView 안에 국내 지도 SDK를 띄우는 방식을 택했다. 웹앱과 지도 렌더 코드를 비슷하게 가져갈 수 있고, 지도 SDK가 웹용으로 가장 잘 정리돼 있어서다. React Native에서 검색 결과(공간 목록)를 받아 마커 생성 스크립트를 문자열로 만들고, 그 HTML을 WebView에 주입한다. 마커를 탭하면 WebView가 postMessage로 공간 ID를 네이티브로 올려보내고, 네이티브는 그 ID로 상세 모달을 띄운다.

// 검색된 공간들을 지도 마커 생성 스크립트로 변환해 WebView에 주입
const markersScript = spaces.map((space, index) => `
  var marker${index} = new MapSDK.Marker({
    position: new MapSDK.LatLng(${space.latitude}, ${space.longitude}), map: map
  });
  MapSDK.event.addListener(marker${index}, 'click', function() {
    window.ReactNativeWebView.postMessage(JSON.stringify({
      type: 'markerClick', spaceId: '${space.id}'
    }));
  });
`).join('\n');

// WebView → 네이티브 메시지 수신: 마커 탭 시 해당 공간 모달 열기
const handleWebViewMessage = (event: any) => {
  const data = JSON.parse(event.nativeEvent.data);
  if (data.type === 'markerClick') {
    const space = spaces.find((s) => s.id === data.spaceId);
    if (space) setSelectedSpace(space);
  }
};

이 화면에서 신경 쓴 또 하나는 역할에 따라 완전히 다른 UI를 그린다는 점이다. 3자 매칭이라는 도메인이 화면 분기로 그대로 드러난다. 제품 제공자·판매자는 "내 주변 공간"을 지도에서 찾아야 하니 지도 + 위치 검색을 보고, 공간 제공자는 반대로 "내 가게에 놓을 제품"을 봐야 하니 지도 대신 제품 목록 그리드를 본다. 같은 MapScreenuser.user_type을 보고 두 갈래로 갈린다.

위치를 가져올 때 권한이 거부되거나 실패하면, 에러로 화면을 멈추는 대신 기본 좌표(서울 시청 근처)로 폴백한다. 빈 지도를 보여주느니 일단 어딘가를 중심으로 지도를 띄우고 "이 지역 검색" 버튼을 누를 수 있게 하는 편이, MVP 단계의 사용자에게 덜 당황스럽다. 위치 권한이라는 가장 흔한 거부 지점에서 앱이 죽지 않게 한 것이다.

두 번째 커밋이 환경 설정인 이유

기능을 한 덩어리로 올린 직후의 커밋은 기능 추가가 아니라 환경 설정 파일 정리였다. 순서가 이렇게 된 데에는 이유가 있다.

이 프로젝트는 로컬에서는 Docker로 PostGIS 컨테이너를 띄우고, 운영은 클라우드 관리형 DB를 쓰는 이원 구성이다. 그런데 관리형 DB는 PostGIS 확장이 기본으로 켜져 있지 않은 경우가 많아서, 인스턴스를 만든 직후 확장을 직접 활성화해 줘야 한다. 그래서 확장 활성화 SQL과, 인스턴스를 만드는 셸/PowerShell 스크립트를 함께 묶어 뒀다.

-- 관리형 PostgreSQL 인스턴스를 만든 직후 한 번 실행
CREATE EXTENSION IF NOT EXISTS postgis;
CREATE EXTENSION IF NOT EXISTS postgis_topology;  -- 고급 공간 분석용(선택)

-- 설치 확인
SELECT PostGIS_Version();

그런데 설정을 정리하다 보니 더 급한 문제가 보였다. 초기 커밋에 딸려 있던 설정 스크립트 일부에 DB 호스트·계정·비밀번호가 그대로 하드코딩돼 있었던 것이다. 저장소에 그대로 남으면 커밋 이력에 영구히 박힌다. 그래서 실제 접속 정보를 코드에서 들어내고 환경 변수(.env)로 옮긴 뒤, 저장소에는 값이 빈 .env.example만 남겼다. 백엔드 설정은 Pydantic의 BaseSettings.env를 읽게 돼 있어서, 코드를 거의 안 고치고도 비밀 정보만 분리할 수 있었다.

여기서 database.py가 동기용 URL(postgresql://)을 받아 비동기 드라이버용(postgresql+asyncpg://)으로 치환하는 작은 장치도 같이 넣었다. 설정 파일에는 사람이 읽기 쉬운 평범한 URL을 쓰되, 런타임에서 비동기 드라이버를 붙이는 식이다. "코드보다 설정이 먼저 발목을 잡는다"는 건 여러 번 데인 교훈이라, 비밀 정보가 코드에 박히기 전에, 그리고 첫 배포로 새 환경을 세워보기 전에 설정 경로를 정리해 둔 셈이다. 레거시를 저장소에 처음 올리는 순간이 비밀 정보를 들어낼 마지막 기회이듯, 새 프로젝트도 두 번째 커밋쯤이 설정을 바로잡을 적기다.

정리하며

  • 3자 매칭(공간·제품·판매)이라는 비대칭 도메인이지만, MVP는 공간 등록·검색으로 범위를 좁혔다. 대신 계층 분리(routers/services/repositories)와 테이블 구조는 처음부터 매칭·결제를 염두에 두고 깔았다.
  • 반경 검색은 PostGIS geography + GiST 인덱스 + ST_DWithin으로 풀었다. 단위가 미터 그대로라 환산이 사라지고, ST_DWithin으로 거르고 ST_Distance로 표시 거리를 계산하는 분업이 인덱스를 살린다.
  • PostGIS의 함정은 좌표 순서(경도, 위도)확장 활성화 누락, 그리고 ST_Distance를 WHERE에 쓰면 인덱스를 못 타는 것. 셋 다 에러 없이 결과만 틀리거나 느려져서 더 위험하다.
  • 좌표를 만드는 길은 생성·수정에서 대칭적으로 한 곳에 모았고, 원시 좌표는 표시·디버깅용으로 일부러 중복 저장했다.
  • 모바일 토큰 갱신은 인터셉터 한 곳에 가두고, _retry 플래그와 순수 axios 호출로 무한 루프를 막았다. 상태 관리는 비-React 코드 접근이 쉬운 Zustand로.
  • 지도는 WebView + 국내 지도 SDK에 postMessage 브리지로 붙이고, 역할에 따라 화면 자체를 분기했다. 위치 권한 거부 시엔 죽지 않고 기본 좌표로 폴백.
  • 두 번째 커밋을 환경 설정 분리에 쓴 건 의도된 순서다. 비밀 정보가 이력에 박히기 전, 새 환경을 세우기 전에 설정 경로부터 잡았다.

다음 편에서는 미뤄둔 제품 등록과 진짜 3자 매칭 로직 — 양방향 매칭 요청과 상태 전이, 그리고 결제 연동이 들어올 예정이다.

반응형