← 프로젝트 목록
PROJECT 02 / SIDE PROJECT · MASH-UP

GGUK비동기 저장과 동시성 처리

Instagram 링크에서 장소를 뽑아 친구와 같이 쓰는 방에 저장하는 서비스입니다.

ROLE
백엔드 개발
PERIOD
2026.02 — 현재
STACK
TypeScript · NestJS · PostgreSQL · Drizzle · Cloud Run · Cloud Tasks

공통 응답·입력 검증·로깅, 링크 접수·워커 API와 저장, 핀·댓글 API를 맡았습니다.AI 추출과 지오코딩은 팀원이 만든 코드를 썼습니다.사례의 테스트 건수와 측정값은 당시 PR·개발 기록 기준입니다.

핵심 성과

  1. 01링크 접수 응답 0.2~0.7초, 분석이 일부 실패해도 성공한 장소는 남기고 나머지만 재시도
  2. 02댓글 작성과 핀 삭제가 같은 행을 잠그게 해 삭제된 핀에 댓글이 남지 않게 함
  3. 03큐 동시 실행 수를 서버 용량에 맞춰 7일간 사용자 API 429 14건 → 0건
CASE 01

링크 접수와 장소 분석을 분리하고, 재시도해도 저장 결과가 남게 하기

링크는 202로 바로 접수하고, 분석이 일부만 성공해도 성공한 장소와 핀은 남긴 채 나머지만 재시도하도록 만들었습니다.

내가 맡은 범위
Cloud Tasks 접수·워커 API, OIDC 인증, 장소·출처·방 핀 저장과 재시도 처리
선택과 이유
성공한 장소는 먼저 커밋하고, 재시도할 실패가 남았을 때만 503을 반환했습니다.같은 작업이 두 번 와도 활성 행 유니크 제약으로 중복 저장을 막았습니다.
트레이드오프
작업 상태 테이블과 폴링 API를 없애서 관리할 상태는 줄었지만, 202 응답만으로는 분석이 끝났는지 알 수 없습니다.
링크 접수와 장소 분석을 분리하고, 재시도해도 저장 결과가 남게 하기 흐름도. 접수 API와 내부 워커는 같은 Cloud Run 서비스에서 실행됩니다. 성공한 장소를 저장한 뒤 재시도할 실패가 남으면 503을 반환합니다.
접수 API와 내부 워커는 같은 Cloud Run 서비스에서 실행됩니다.성공한 장소를 저장한 뒤 재시도할 실패가 남으면 503을 반환합니다.
링크 접수와 장소 분석을 분리하고, 재시도해도 저장 결과가 남게 하기
접수 API와 내부 워커는 같은 Cloud Run 서비스에서 실행됩니다. 성공한 장소를 저장한 뒤 재시도할 실패가 남으면 503을 반환합니다.

접수 API와 내부 워커는 같은 Cloud Run 서비스에서 실행됩니다.성공한 장소를 저장한 뒤 재시도할 실패가 남으면 503을 반환합니다.

문제

GGUK는 Instagram 링크에서 장소를 뽑아 방에 저장합니다. 그 사이에 스크래핑, AI 분석, 지오코딩을 거치는데, 외부 서비스가 느리거나 장소 여러 개 중 일부만 처리되는 경우가 있습니다. 링크를 받았다는 응답을 저장까지 끝났다는 뜻으로 쓰면 사용자가 보는 것과 실제 데이터가 달라집니다.

맡은 일

AI 추출과 지오코딩은 팀원이 만든 코드를 쓰고, 저는 접수 API, 내부 워커, DB 저장을 연결했습니다. 접수할 때 방 멤버십과 URL을 확인하고, Cloud Tasks에 등록이 된 다음에만 202 Accepted를 돌려줍니다. 내부 워커는 OIDC로 인증합니다.

이렇게 한 이유

원래 있던 job 테이블, jobId, 폴링 API를 없애고 접수와 처리를 나눴습니다. 중간 상태를 맞출 일이 없어진 대신, 이 API의 응답은 “받았다”는 뜻일 뿐 결과 알림은 아니게 됐습니다.

워커는 장소마다 지오코더 1순위 후보를 저장합니다. 성공한 장소·출처·핀을 먼저 커밋하고, 일시적인 지오코딩 실패가 남아 있으면 503을 반환해 Cloud Tasks가 다시 보내게 했습니다. 이렇게 하면 같은 작업이 여러 번 들어올 수 있어서, 장소는 upsert하고 출처와 핀은 활성 행 유니크 제약으로 충돌을 처리했습니다.

코드

핀 INSERT 뒤에 붙는 충돌 처리입니다. 같은 방, 같은 장소에 삭제되지 않은 핀이 이미 있으면 넘어갑니다. 삭제된 핀은 제약 대상이 아니라서 나중에 다시 등록할 수 있습니다.

.onConflictDoNothing({
  target: [pins.roomId, pins.placeId],
  where: isNull(pins.deletedAt),
});

원본 코드 · place-result.repository.ts 180–183행

검증 결과

PR #74에서 실제 HTTP 요청과 embedded PostgreSQL로 접수→워커→저장, 부분 실패 후 재시도, 중복 배달, 삭제 후 재등록을 테스트했습니다. 유닛 147건, E2E 28건이 통과했습니다.

운영에서 링크 접수 응답은 0.2~0.7초였고, 분석 작업은 성공 73건 기준 중앙값 6.6초(p90 13.2초)가 걸렸습니다.

CASE 02

삭제된 핀에 댓글이 남지 않게 하기

댓글 작성과 핀 삭제가 같은 핀 행을 잠그도록 순서를 맞춰, 동시에 들어와도 삭제된 핀에 댓글이 남지 않게 했습니다.

내가 맡은 범위
핀 삭제 API, 댓글 생성 시 행 잠금, 삭제 트랜잭션과 권한·재등록 검증
선택과 이유
댓글 작성은 FOR SHARE로 핀을 읽은 뒤 INSERT하고, 삭제는 핀을 먼저 UPDATE한 뒤 댓글을 soft delete합니다.
트레이드오프
같은 핀에 작성과 삭제가 겹치면 한쪽이 기다립니다.댓글 작성끼리는 공유 잠금이라 동시에 진행됩니다.
삭제된 핀에 댓글이 남지 않게 하기 흐름도. 작성이 먼저면 삭제가 커밋을 기다렸다가 새 댓글까지 지웁니다. 삭제가 먼저면 작성이 기다린 뒤 핀이 없는 걸 보고 거절됩니다.
작성이 먼저면 삭제가 커밋을 기다렸다가 새 댓글까지 지웁니다.삭제가 먼저면 작성이 기다린 뒤 핀이 없는 걸 보고 거절됩니다.
삭제된 핀에 댓글이 남지 않게 하기
작성이 먼저면 삭제가 커밋을 기다렸다가 새 댓글까지 지웁니다. 삭제가 먼저면 작성이 기다린 뒤 핀이 없는 걸 보고 거절됩니다.

작성이 먼저면 삭제가 커밋을 기다렸다가 새 댓글까지 지웁니다.삭제가 먼저면 작성이 기다린 뒤 핀이 없는 걸 보고 거절됩니다.

문제

핀과 댓글을 한 트랜잭션에서 지워도, 댓글을 먼저 지우면 그 직후에 다른 요청이 새 댓글을 넣을 수 있습니다. 그러면 핀은 삭제됐는데 댓글은 살아 있게 됩니다. 댓글을 쓰기 전에 핀이 있는지 조회하는 것만으로는 조회와 INSERT 사이에 끼어드는 삭제를 막을 수 없습니다.

맡은 일

핀 삭제 API를 만들면서 “삭제된 핀에는 활성 댓글이 없어야 한다“를 지켜야 할 조건으로 잡았습니다. 삭제는 그 방의 핀과 댓글에만 적용하고, 여러 방이 같이 쓰는 장소 데이터는 건드리지 않았습니다.

이렇게 한 이유

댓글 작성은 트랜잭션 안에서 핀을 SELECT … FOR SHARE로 읽고 나서 댓글을 넣습니다. 핀 삭제는 핀을 먼저 UPDATE해서 잠금을 잡고, 같은 트랜잭션에서 댓글을 soft delete합니다. 삭제 쿼리에는 방 멤버 조건도 넣었습니다.

작성이 먼저 잠금을 잡으면 삭제는 작성이 커밋될 때까지 기다렸다가 방금 생긴 댓글까지 지웁니다. 삭제가 먼저면 작성이 기다린 뒤 핀이 없는 걸 확인하고 실패합니다.

같은 핀에서 작성과 삭제가 겹치면 대기가 생기지만, 댓글 작성끼리는 공유 잠금이라 서로 막지 않습니다. 애플리케이션에서 미리 조회하는 대신 DB 잠금으로 확인과 쓰기 사이를 묶었습니다.

코드

const [pin] = await tx
  .select({ id: pins.id })
  .from(pins)
  .where(and(eq(pins.id, pinId), isNull(pins.deletedAt)))
  .for("share");

원본 코드 · comment.repository.ts 68–72행

검증 결과

PR #132에서 실제 HTTP 요청으로 핀·댓글 삭제, 목록 제외, 중복 삭제, 권한·입력 검증, 삭제 후 같은 장소 재등록을 테스트했습니다. 유닛 266건, E2E 148건이 통과했습니다. 재등록은 활성 행에만 걸리는 partial unique 인덱스 덕분에 가능합니다.

CASE 03

큐 동시 실행 수를 서버 용량에 맞추기

사용자 API와 워커가 같은 Cloud Run을 나눠 쓰는데, 큐가 서버 용량보다 많은 작업을 동시에 보내 사용자 요청이 429로 거절됐습니다.큐 동시 실행 수를 5에서 1로 맞춘 뒤 7일간 사용자 API 429는 14건에서 0건이 됐습니다.

내가 맡은 범위
운영 로그 분석, 큐 동시 dispatch 조정, 장소 추출 단계별 시간 로깅
선택과 이유
maxConcurrentDispatches를 5에서 1로 낮춰 대기는 큐에서 하게 하고, 어느 단계가 느린지 보려고 단계별 시간을 기록했습니다.
트레이드오프
큐 대기가 늘어 추출 결과가 보이기까지 더 오래 걸릴 수 있습니다.같은 서비스를 쓰는 이상 사용자 요청이 먼저 처리되지는 않습니다.
큐 동시 실행 수를 서버 용량에 맞추기 흐름도. 조사 당시 Cloud Run은 인스턴스 1개, 동시 요청 1개였는데 큐는 최대 5개를 동시에 보냈습니다. 큐 동시 실행 수를 서버 용량에 맞췄습니다.
조사 당시 Cloud Run은 인스턴스 1개, 동시 요청 1개였는데 큐는 최대 5개를 동시에 보냈습니다.큐 동시 실행 수를 서버 용량에 맞췄습니다.
큐 동시 실행 수를 서버 용량에 맞추기
조사 당시 Cloud Run은 인스턴스 1개, 동시 요청 1개였는데 큐는 최대 5개를 동시에 보냈습니다. 큐 동시 실행 수를 서버 용량에 맞췄습니다.

조사 당시 Cloud Run은 인스턴스 1개, 동시 요청 1개였는데 큐는 최대 5개를 동시에 보냈습니다.큐 동시 실행 수를 서버 용량에 맞췄습니다.

문제

사용자 API와 내부 워커가 같은 Cloud Run을 나눠 쓰는데, 당시 서버는 인스턴스 1개·동시 요청 1개였고 큐는 최대 5개를 동시에 보내고 있었습니다. 2026년 9월 12일 하루에만 사용자 API 요청 14건이 429로 거절됐습니다.

맡은 일

로그로 요청 거절과 워커 부하를 확인하고 큐의 maxConcurrentDispatches를 5→1로 낮췄습니다. 장소 추출을 스크래핑·이미지 업로드·AI 호출·지오코딩으로 나눠, 단계별 시간을 기록하는 로그도 추가했습니다.

이렇게 한 이유

링크 접수는 큐에 넣고 바로 응답하므로 워커가 조금 늦어도 사용자는 앱을 계속 쓸 수 있습니다. 반면 조회 요청이 거절되면 바로 드러납니다. 그래서 서버를 늘리기 전에 큐를 서버 용량에 맞춰, 기다리는 일은 큐가 맡게 했습니다. 서버 증설이나 워커 분리는 부하와 비용을 보고 정할 다음 단계로 두었습니다.

결과

변경 전후 7일씩 사용자 API 요청을 비교했습니다.

구간 사용자 API 요청 429
변경 전 7일 (9/10–9/17) 5,892건 14건
변경 후 7일 (9/17–9/24) 1,643건 0건

10월 5일까지 관측을 이어가도 2,181건 중 429는 1건이었습니다. 단일 처리 자리로 재현한 로컬 실험에서도 사용자 요청 거절이 2,250건 중 1,023건에서 0건으로 줄었고, 대신 태스크 대기 시간이 늘어나는 것을 확인했습니다.

PR #152는 인프라 코드의 Biome·TypeScript 검사를, PR #153은 유닛 테스트 285건을 통과했습니다.

다음 프로젝트Purple