글 목록으로개발 경험

사진 서비스는 GraphQL, 게임 서비스는 REST로 만들었어요

두 화면에서 API와 데이터베이스로 이어지는 흐름과 테스트 카드를 그린 일러스트

기술 선택을 주제로 만든 일러스트예요. 실제 배포 구성도는 아니에요.

사진 커뮤니티와 Game Mate를 같은 저장소에서 만들지만 API와 캐시까지 맞추지는 않았어요. 웹은 Next.js와 React, 서버는 NestJS, DB는 PostgreSQL을 쓰고, 사진 서비스에는 GraphQL과 Apollo를, Game Mate에는 REST와 TanStack Query를 골랐어요.

여러 목록에 나타나는 같은 게시물을 Apollo의 정규화 캐시로 다뤄 보고 싶었어요. Game Mate에서는 글을 바꾼 뒤 어떤 목록과 상세를 다시 읽을지 쿼리 키로 직접 정해 보고 싶었고요. 같은 설정을 재사용하는 편리함보다 서로 다른 갱신 방식을 다뤄 보는 쪽을 택했어요.

기획과 화면을 바꾼 이야기에서 익명 기록과 투표, 메시지 재전송을 다뤘어요. 그런 동작을 실제로 만들다 보니 화면뿐 아니라 API 응답과 DB 저장, 캐시 갱신에도 정할 게 생겼어요.

웹 화면을 먼저 만들고 앱에서도 같이 쓰기로 했어요

두 서비스의 웹은 Next.js App Router와 React로 만들었어요. 공개 글과 그룹에는 검색에 필요한 서버 HTML을 만들고, 댓글이나 입력 폼처럼 조작이 필요한 부분은 클라이언트 컴포넌트로 두었어요.

공개 글은 브라우저가 데이터를 다시 받을 때까지 빈 화면으로 기다리게 하고 싶지 않았어요. 그래서 첫 본문과 검색 메타데이터를 서버에서 만들어요. 이때 개인의 세션이나 권한이 섞인 결과를 공용 캐시에 넣지 않도록 공개 조회와 개인화 조회를 나눴어요.

모바일은 Expo 안에 WebView를 두고 웹 화면을 공유해요. 화면을 두 벌 만들기보다 웹에서 글을 쓰고 답변을 읽는 흐름부터 다듬으려고 했어요. 푸시 알림과 시스템 브라우저 열기, 공유 같은 기능은 웹과 앱 사이의 메시지 계약으로 연결했어요.

WebView를 쓰면서 화면을 다시 만드는 일은 줄었지만, 뒤로 가기와 키보드, 안전 영역은 앱에서 따로 다뤄야 했어요. 소셜 로그인도 시스템 브라우저를 열고 일회용 코드를 교환하는 방식으로 연결했어요. 앱스토어 배포와 실제 기기에서의 최종 확인은 아직 남아 있어요.

입력 폼은 React Hook Form과 Zod로 관리해요. 필드 검증과 제출 상태를 폼에 모으고, 버튼과 입력의 색과 간격은 공통 디자인 시스템에서 맞췄어요. 메뉴와 대화상자는 Base UI를 기반으로 조합해요. 그래도 포커스 복귀와 닫힘 애니메이션, 뒤로 가기까지 서비스에 맞게 연결하고 확인하는 작업은 남았어요.

브라우저 캐시는 서로 다르게 골랐어요

사진 게시물 하나가 홈 피드와 그룹 목록, 인기 목록에 같이 나와요. 이때 같은 엔티티를 여러 화면에서 읽고 바꾸는 방식을 다뤄 보려고 Apollo Client를 썼어요.

그렇다고 Apollo를 넣으면 모든 목록이 알아서 맞춰지는 건 아니었어요. 글 하나의 제목을 바꾸는 일과 목록에서 그 글을 빼는 일은 달라요. 페이지를 이어 붙이는 규칙이 있으면 삭제한 항목이 다시 섞여 들어올 수도 있었어요. 도메인별 type policy와 캐시 갱신 함수를 두고, 정렬과 그룹, 댓글의 부모 ID를 기준으로 목록을 구분했어요. 필요한 경우에는 활성 쿼리를 다시 받도록 했고요.

Game Mate에서는 REST와 TanStack Query를 골랐어요. 글 목록과 상세, 답변을 수정한 뒤 어떤 조회를 갱신할지 직접 정해 보고 싶었어요. 이쪽은 쿼리 키와 queryOptions를 함께 관리하고, 변경한 기능에서 관련 조회를 무효화해요.

그래서 두 서비스의 서버 상태를 별도의 useState로 다시 복사하지 않도록 했어요. 입력 중인 값은 폼이, 서버에서 받은 값은 Apollo나 TanStack Query가 맡아요. 같은 값을 여러 곳에서 수정할 수 있게 만들면 저장 뒤 어느 값이 최신인지부터 다시 찾아야 했어요.

사진 서비스에서 목록을 고칠 때는 Apollo의 도메인별 갱신 함수를 보고, Game Mate에서는 쿼리 키와 무효화 옵션을 봐요. 두 방식을 같은 API 훅으로 감추면 무엇을 다시 읽는지 찾기 어려울 것 같아 각각의 형태를 남겼어요.

권한 확인과 데이터 처리는 NestJS에서 맡았어요

두 서비스의 서버는 NestJS와 Express, Prisma, PostgreSQL을 써요. Next.js는 화면을 그리고 서버에서 렌더링하거나 같은 출처로 API 요청을 전달해요. 누가 그룹에 가입할 수 있는지, 게시물을 지울 수 있는지, 언제 투표할 수 있는지는 NestJS에서 확인해요.

화면에서 버튼을 숨겨도 API로 직접 요청을 보낼 수 있어요. 그래서 비공개 그룹의 글을 볼 수 있는지, 관리자가 멤버를 내보낼 수 있는지도 API에서 확인하게 했어요.

요청은 컨트롤러가 받고, 서비스에서 권한을 확인하고 데이터를 저장해요. 모듈은 그 기능이 사용할 서비스와 외부 연결을 등록해요. OAuth와 게임 API 호출은 따로 두었어요. 서버 로직을 시험할 때 실제 외부 서비스 응답을 기다리는 대신 준비한 응답으로 연결할 수 있어요.

모듈은 인증, 그룹, 게시물, 채팅처럼 기능별로 나눴어요. 한꺼번에 저장돼야 하는 데이터는 트랜잭션으로 묶었고요. 두 서비스는 서버와 DB를 각각 두었고 계정도 공유하지 않아요. 같은 NestJS를 쓴다는 이유로 두 서비스의 사용자 모델까지 하나로 합칠 필요는 없었어요.

Prisma가 조회 타입을 맞춰 줘도 동시에 온 요청 두 개를 한 번만 저장해 주는 건 아니에요. 그 부분은 DB 제약과 잠금으로 막고 실제 PostgreSQL에 요청을 겹쳐 보내며 확인했어요.

API 타입은 한곳에서 만들고 실제 응답도 검사해요

Game Mate의 API 계약은 서버가 사용하는 Zod 스키마에서 시작해요. 여기서 OpenAPI를 만들고 Orval로 SDK를 생성해요. 응답 필드를 바꿀 때 서버 타입과 프런트 타입을 따로 고치는 일을 줄이려고 했어요.

text
Zod 계약
  → OpenAPI
  → 생성 SDK와 응답 스키마
  → 웹·관리자에서 사용

API와 웹의 배포 시점이 다르거나 서버가 예상하지 않은 값을 보내면 생성된 타입과 실제 응답이 달라질 수 있어요. 그래서 SDK 전송 함수가 응답 스키마를 적용해요. 생성된 요청도 이 검증을 빠뜨리지 않는지 테스트해요.

예상하지 못한 응답을 모두 기본값으로 채우는 방식은 피했어요. 일부 표시용 필드에는 명시적인 보완 정책을 두되, ID나 권한, 세션, 본문과 목록이 잘못된 경우에는 오류로 드러나게 했어요. 화면을 계속 그리기 위해 중요한 값까지 조용히 바꾸고 싶지는 않았어요. SDK의 응답 처리에 이 경계를 뒀어요.

사진 서비스는 API의 GraphQL 스키마와 웹에서 사용하는 문서로 타입과 Document를 생성해요. 쿼리를 고쳤는데 생성 파일을 빼먹은 경우에는 CI에서 재생성한 결과의 차이를 확인해요. 이쪽도 입력 검증과 권한 검사는 서버가 따로 해요. GraphQL 타입이 있어도 가입 권한이나 투표 가능한 시간까지 확인해 주지는 않아요.

두 번 들어온 요청은 DB에서도 막아야 했어요

투표 버튼을 누른 뒤 비활성화하면 중복 클릭은 줄일 수 있어요. 하지만 다른 탭에서 누르거나 요청을 직접 두 번 보내는 경우까지 막을 수는 없어요.

사진 서비스의 대회 투표는 대회와 투표자 조합에 unique 제약을 두었어요. 먼저 조회해서 투표가 없다는 사실을 확인하더라도, 두 요청이 동시에 그 확인을 통과할 수 있어요. 마지막 저장 단계에서도 한 표만 들어가도록 한 거예요. 투표 기간과 그룹 멤버 여부, 자신의 게시물인지도 트랜잭션 안에서 확인해요. 그룹 대회 투표 구현에서 볼 수 있어요.

Game Mate의 채팅에는 클라이언트가 만든 메시지 ID를 보내요. 같은 사람이 같은 ID와 내용으로 재전송하면 이미 저장한 메시지를 돌려주고, 같은 ID를 다른 내용에 쓰면 충돌로 처리해요. 응답을 못 받았다는 이유로 사용자가 재시도했을 때 메시지가 두 개 생기지 않도록 했어요.

메시지 저장에서는 대화 행을 잠그고 접근 권한과 첨부파일을 다시 확인해요. 메시지와 알림, 푸시 대기 기록을 같은 트랜잭션에 넣고, 실시간 이벤트는 커밋 후에 보내요. 이 잠금은 같은 대화의 전송을 순서대로 처리하는 만큼 병렬 처리의 제약도 있어요. 현재는 읽음 경계와 메시지 순서를 일관되게 다루는 쪽을 택했어요. 채팅 전송 구현에 그 순서를 남겼어요.

이런 부분은 DB를 흉내 낸 테스트만으로 확인하기 어려웠어요. 실제 PostgreSQL에 동시에 요청을 보내고 저장된 행과 응답을 함께 보는 검사가 필요했어요.

저장 성공과 캐시 전달 실패를 나눴어요

Game Mate에 공개 서버 캐시를 붙인 뒤에는 브라우저 캐시만 갱신해서는 부족했어요. 새로 조회해도 Next.js가 예전 글을 돌려주면 방금 수정한 내용이 되돌아간 것처럼 보일 수 있었어요.

그래서 DB를 바꾸는 트랜잭션 안에 무효화할 캐시 태그도 기록해요. 커밋 뒤에 Next.js Route Handler로 전달하고, 실패하면 대기 기록을 남겨 다시 시도해요. 저장은 끝났는데 캐시 전달이 실패했다고 글쓰기 오류를 돌려주면 사용자가 같은 글을 다시 등록할 수 있기 때문이에요.

같은 태그에 새 변경이 생기는 경우도 생각해야 했어요. 먼저 보낸 요청이 늦게 끝났다고 더 나중에 쌓인 기록까지 지우면 안 돼요. 전달한 revision과 일치하는 기록만 지우도록 했어요. 재시작 뒤의 재전달과 요청이 겹치는 경우를 통합 테스트에 넣었어요.

캐시 태그 전달이 끝나기 전에는 예전 글을 읽을 수 있어요. 이 처리는 즉시 최신 결과를 보장하려는 것보다, 이미 저장한 글을 실패로 알리지 않고 캐시 전달만 다시 시도하려는 구성이에요.

테스트는 각 앱에서 필요한 경계를 확인해요

정렬이나 검증 규칙은 Vitest로 빠르게 확인해요. DB 제약과 트랜잭션, HTTP 요청은 실제 PostgreSQL과 API를 쓰는 통합 테스트로 확인하고, 화면이 있는 웹은 Playwright로 조작해요. API 테스트를 위해 브라우저를 띄우지는 않아요.

테스트 설정과 러너는 각 앱 안에 두었어요. 저장소 루트는 필요한 검사를 호출하는 역할만 해요. 테스트용 포트와 임시 DB 스키마도 개발 환경과 나누어서, 검사를 돌리다가 쓰고 있던 서버의 데이터를 건드리지 않게 했어요.

OAuth와 게임 API는 외부 제공자의 응답을 대역으로 바꿔요. 미디어 통합 테스트는 실제 S3에 실행마다 다른 객체 경로를 써요. 폼 로직이 통과하는 것과 S3 권한, 서명, 업로드가 맞는 것은 실패하는 지점이 달라서 따로 보게 됐어요.

CI 실패를 보면서 브라우저에서 기다릴 시점도 더 구체적으로 잡았어요. 서버 HTML에 버튼이 있어도 이벤트가 아직 연결되지 않을 수 있고, 삭제가 끝나도 확인창의 닫힘 애니메이션은 진행 중일 수 있어요. 단순히 대기 시간을 늘리는 대신 클릭할 수 있는 상태와 닫힘이 끝난 상태를 나눠 봐야 했어요.

중복 투표나 채팅 재전송은 한 번의 정상 요청만 보는 테스트에서 빠지기 쉬워요. 비공개 글을 읽으려는 요청과 저장 뒤 캐시 전달이 실패하는 경우도 그렇고요. 화면과 DB, 외부 연결을 나눈 테스트가 필요한 이유가 이런 경우에 있었어요.

리뷰에서 반복한 지적은 ESLint로 옮겼어요

화면을 나눠 놓아도 다른 기능의 내부 파일을 바로 가져오면 금방 다시 엉켜요. 리뷰에서 import를 고치고 다음 작업에서 같은 코드를 또 고치는 일이 번거로웠어요. 폴더 배치와 import 규칙은 ESLint로 검사하도록 옮겼어요.

설정은 packages/config/eslint에 두었어요. 공통 TypeScript 규칙에서 시작해 React, DOM 접근성, Next.js, Expo 설정을 나눴어요. 웹의 label 규칙을 React Native까지 적용하지 않도록 하고, 각 앱은 필요한 preset과 자신의 경로를 조합해요.

규칙 구현도 설정 파일 한곳에 몰아넣지 않았어요. rules/fsd, rules/web, rules/common처럼 나누고, 각 규칙 옆에 테스트와 fixture를 두었어요. ESLint 패키지에서 설정과 규칙을 함께 볼 수 있어요.

FSD 검사는 세 규칙으로 나눴어요.

  • layer-imports: 아래 레이어에서 위 레이어를 가져오는지 검사해요.
  • slice-public-api: 다른 slice의 내부 파일을 직접 가져오거나 자기 배럴을 다시 가져오는지 검사해요.
  • no-cross-page: 화면 slice끼리 의존하는지 검사해요.

경로 해석은 fsd.helper.ts에서 함께 써요. @/ 별칭과 상대 경로를 실제 위치로 바꾸고, import한 파일과 대상 파일의 레이어와 slice를 비교해요. types, mocks, schema와 server 파일처럼 허용한 경로도 이곳에서 구분해요.

규칙을 붙이고도 검사할 파일을 못 찾으면 통과할 수 있었어요. srcDir을 잘못 넘긴 경우였어요. 경로가 없거나 인식할 레이어 폴더가 없으면 설정 오류를 내도록 바꿔, 아무것도 검사하지 않은 lint를 성공으로 보지 않게 했어요.

RuleTester에는 허용할 import와 막을 import를 fixture로 넣었어요. 별칭으로 가져올 때만 막히고 상대 경로로 돌아가면 통과하는 식의 구멍이 없는지 확인해요. 배럴 경계 테스트에도 두 경우가 함께 있어요.

컴포넌트에서 직접 fetch를 호출하거나 오버레이의 열림 상태를 다시 복제하는 코드도 규칙으로 검사해요. preset을 공유할 때는 ESLint flat config의 플러그인 등록이 문제였어요. 같은 이름에 서로 다른 객체를 등록할 수 없어서, 공통 규칙과 웹 규칙을 sidePlugin 하나에 넣고 preset마다 쓸 규칙을 켜게 했어요.

이렇게 검사할 수 있는 import와 구현 패턴은 CI가 먼저 잡게 했어요. 권한 확인이 적절한 위치에 있는지, 트랜잭션을 어디까지 묶을지는 계속 코드를 읽고 테스트로 확인해요.

인프라는 초기 비용을 보고 두 서비스와 PostgreSQL을 EC2 한 대에 넣는 쪽으로 정했어요. 대신 장애 범위가 커지고 백업과 복구를 직접 챙겨야 해요. 파일은 S3에 직접 올려 API가 대용량 파일을 받아 처리하는 경로를 줄였고요. 이 비용과 운영 판단은 인프라 글에 따로 적었어요.