이번 편은 백엔드를 어떻게 짰고 왜 그렇게 골랐는지입니다.
┌─────────────────┐ HTTPS / REST ┌──────────────────────┐
│ 앱 │ ─────────────────▶ │ 백엔드 (Spring) │
│ React Native │ ◀───────────────── │ Spring Boot 4 │
└─────────────────┘ JSON + JWT │ + WebSocket │
└──────────┬───────────┘
│
┌──────────────────────────────┼──────────────┐
▼ ▼ ▼
┌────────────────┐ ┌──────────────┐ ┌────────────────┐
│ PostgreSQL │ │ FCM (푸시) │ │ 파일 저장 │
│ users │ └──────────────┘ │ 로컬 디스크 │
│ places │ └────────────────┘
│ check_ins ... │
└───────▲────────┘
│ 적재
┌───────┴────────┐
│ 공공데이터 ETL │ Python 스크립트 (백엔드와 분리)
└────────────────┘

모노레포입니다. 저장소 하나 안에 backend/ · app/ · docs/ · etl/ 이 다 있습니다.
혼자 하니까 앱과 서버를 같은 커밋에서 바꾸는 일이 많았고, 저장소를 나누면 그때마다 두 곳을 오가야 해서요.
클라이언트 → Controller → Service → Repository → DB
| 층 | 하는 일 | 예시 |
|---|---|---|
| Controller | HTTP 요청 받기, 입력값 검사, 응답 만들기 | CheckInController |
| Service | 실제 로직, 트랜잭션 시작·끝 | CheckInService |
| Repository | DB에 쿼리 보내기 | CheckInRepository |
| Entity | DB 테이블과 짝이 되는 클래스 | CheckIn, User |
| DTO | 층 사이에 오가는 데이터 상자 | CheckInRequest |
원칙은 두 개였습니다.
2번이 중요한 이유가 있습니다.
Entity를 그대로 내보내면 DB 컬럼이 늘어날 때마다 API 응답이 저절로 바뀝니다.
예를 들어 User에 phone 컬럼을 추가하면, 아무 코드도 안 건드렸는데 전화번호가 API로 새어 나갑니다.
DTO를 따로 두면 "내보낼 것"을 손으로 적어야 하니까 그런 일이 안 생깁니다.
폴더를 나누는 방식이 두 가지가 있습니다.
(A) 계층별로 나누기 (B) 기능별로 나누기
controller/ checkin/
CheckInController CheckInController
ReviewController CheckInService
service/ CheckInRepository
CheckInService review/
ReviewService ReviewController
ReviewService
(B)를 골랐습니다. 실제 폴더는 이렇습니다.
com.honjeong
├── auth 로그인 · 토큰
├── user 프로필
├── place 식당
├── checkin ★체크인 (이 앱의 핵심)
├── meal 같이먹기
├── chat 채팅
├── review 리뷰
├── mate 친구
├── favorite 즐겨찾기
├── notification 알림함
├── push 푸시 발송
├── badge 뱃지
├── block 차단
├── report 신고
├── notice 공지
├── file 이미지 업로드
└── global 공통 (예외 처리 · 설정 · 보안)
17개입니다.
(B)를 고른 이유는 단순합니다. 기능 하나를 만들 때 한 폴더만 열면 되기 때문입니다.
(A)로 하면 체크인 기능 하나 고치는데 controller/, service/, repository/ 세 폴더를 왔다 갔다 해야 합니다.
다만 정직하게 적으면 — 나중에 리뷰해보니 폴더끼리 서로를 참조하는 곳이 22쌍 있었습니다.
예를 들어checkin이meal의 Repository를 직접 가져다 씁니다.
이걸 막아주는 장치(ArchUnit 같은 것)를 안 넣어서 그냥 쌓였습니다. 지금은 알고만 있는 상태입니다.
프로젝트를 시작한 게 2026년 6월인데, Spring Boot 4가 막 나온 시점이었습니다.
"최신 버전이 이력서에 낫겠지" 라는 이유로 골랐고, 딱 그만큼 고생했습니다.
Boot 4는 JSON을 다루는 기본 라이브러리가 Jackson 3로 바뀌었습니다.
그런데 제가 쓰려던 jjwt(JWT를 만들고 검증해주는 라이브러리)는 Jackson 2에 의존하고 있었습니다.
그래서 서버를 띄우면 실행 중에 터졌습니다.
선택지는 둘이었습니다.
두 번째로 갔습니다. Nimbus라는 라이브러리로 직접 발급하게 바꿨습니다.
결과적으로는 이게 잘한 선택이 됐습니다. (다음 파트에서 이어집니다)
Boot 4가 테스트 관련 기능을 모듈별로 쪼개면서 클래스 위치가 이동했습니다.
| 애노테이션 | Boot 4에서의 위치 |
|---|---|
@WebMvcTest | org.springframework.boot.webmvc.test.autoconfigure.* |
@DataJpaTest | ...boot.data.jpa.test.autoconfigure |
TestEntityManager | org.springframework.boot.jpa.test.autoconfigure |
검색해도 안 나왔습니다. 나온 지 얼마 안 돼서 블로그도 스택오버플로도 없었습니다.
결국 이렇게 찾았습니다.
# 다운로드된 라이브러리 파일(jar) 안을 직접 뒤져서 클래스가 어디 있는지 찾는다
find ~/.gradle/caches -name "*.jar" | xargs -I{} sh -c \
'unzip -l {} | grep -q TestEntityManager.class && echo {}'
배운 것: 최신 버전을 쓰면 "검색하면 나오는 답"이 없습니다.
그럴 때는 라이브러리 파일을 직접 열어보는 게 제일 빠릅니다.
솔직히 초반 2주는 손해였습니다. 그런데 지나고 보니 괜찮았습니다.
여기가 백엔드에서 제일 신경 쓴 부분입니다.
로그인하면 토큰(신분증 같은 문자열)을 두 개 줍니다.
| 토큰 | 유효기간 | 하는 일 |
|---|---|---|
| access token | 1시간 | API 요청할 때마다 헤더에 붙임 |
| refresh token | 14일 | access가 만료되면 새로 받아오는 용도 |
왜 하나로 안 하나?
access 토큰은 요청마다 왔다 갔다 하니까 노출될 기회가 많습니다.
그래서 짧게(1시간) 만들어서, 새어 나가도 1시간 뒤엔 쓸모없게 만듭니다.
대신 1시간마다 다시 로그인하라고 하면 사용자가 화나니까,
14일짜리 refresh 토큰으로 조용히 새 access를 받아오게 합니다.
DB에 이렇게 저장합니다.
토큰 원문 : mZ8fK2p... (사용자에게만 준다)
DB에 저장하는 값 : SHA-256으로 해시한 값
비밀번호를 저장하는 방식과 똑같습니다.
이렇게 하면 DB가 통째로 털려도 그 값으로는 로그인을 못 합니다.
해시는 되돌릴 수 없거든요. 서버는 사용자가 보낸 토큰을 다시 해시해서 비교만 합니다.
① 앱이 refresh 토큰 A를 보낸다
② 서버가 확인하고 → 새 access + 새 refresh 토큰 B를 준다
③ ★A는 DB에서 삭제한다
이걸 토큰 회전(rotation) 이라고 합니다.
이유는 이렇습니다.
누가 refresh 토큰을 훔쳐서 썼다고 해봅시다. 그러면 원래 주인의 토큰이 무효가 돼서 다음번에 로그인이 튕깁니다.
도난 사실이 드러납니다. 회전을 안 하면 훔친 사람과 주인이 14일 동안 사이좋게 같이 씁니다.
가입 절차가 이렇습니다.
휴대폰 인증 → (여기서 계정이 생김) → 닉네임·프로필 입력 → 가입 완료
↑ 이 중간 상태에서도 API를 불러야 한다
중간 상태에서도 "닉네임 중복 확인", "프로필 사진 업로드" 같은 API를 불러야 합니다.
그런데 아직 가입이 안 끝났으니 정식 토큰을 주면 안 됩니다.
그래서 온보딩 전용 토큰을 따로 만들었습니다. 이 토큰으로는 정해진 몇 개 API만 부를 수 있습니다.
// 정식 사용자만 통과
.anyRequest().hasRole("USER")
authenticated()(로그인만 했으면 통과) 대신 hasRole("USER")(정식 회원만 통과)를 썼습니다.
이 한 글자 차이로 온보딩 토큰이 아무 API나 부르는 걸 막습니다.
여기서 한 번 크게 틀렸습니다.
문제 상황은 이랬습니다.
어떤 사용자를 정지(SUSPENDED) 시켰습니다.
그런데 그 사람은 이미 발급받은 access 토큰으로 계속 앱을 씁니다.
JWT는 한 번 발급하면 서버가 회수할 방법이 없습니다.
토큰 안에 "이 사람은 누구다"가 적혀 있고 서명만 맞으면 통과하거든요. 서버는 그 토큰을 기억하지도 않습니다.
그래서 필터를 하나 만들었습니다.
요청이 들어옴 → JWT 확인 → ★DB에서 users.status를 확인 → 통과 or 차단
매 요청마다 DB를 한 번 더 보는 거라 비용이 들지만, 이게 없으면 정지가 아무 의미가 없습니다.
★그리고 여기서 실수를 하나 했습니다.
// 처음에 짠 것
if (status == SUSPENDED || status == WITHDRAWN) 차단;
// 고친 것
if (status != PENDING && status != ACTIVE) 차단;
처음 건 나중에 상태가 하나 늘면 조용히 새어 나갑니다.
"막을 것을 나열"하는 대신 "통과시킬 것을 나열" 하면, 모르는 상태는 자동으로 막힙니다.
이런 걸 fail-closed(모르면 막는다)라고 부릅니다. 이 프로젝트에서 계속 쓴 원칙입니다.
카카오 로그인, 문자 인증, 파일 저장 — 이런 건 실제로 붙이려면 준비물이 많습니다.
(앱 키 발급, 사업자 등록, 비용 결제…)
그래서 껍데기는 진짜로 만들고 속만 가짜로 채웠습니다.
public interface OAuthVerifier {
OAuthUser verify(String idToken); // ← 이 약속(인터페이스)은 처음부터 진짜
}
// 개발 중: 아무 문자열이나 통과시키고 고정 사용자 반환
class MockOAuthVerifier implements OAuthVerifier { ... }
// 실제 배포: 카카오 서버에 물어보고 진짜 검증
class KakaoOAuthVerifier implements OAuthVerifier { ... }
설정값 하나로 둘 중 뭘 쓸지 정합니다.
honjeong:
oauth:
mode: mock # 또는 real
효과가 컸습니다. 나중에 카카오 로그인을 실제로 붙일 때 백엔드에서 새로 만든 게 클래스 하나였습니다.
API 주소도, DB 테이블도, 신규/재방문 분기 로직도 전부 이미 있었으니까요.
docker-compose.yml에 이렇게 적혀 있었습니다.
OAUTH_MODE: ${OAUTH_MODE:-mock} # ← "값이 없으면 mock을 쓴다"
배포용 설정 파일에는 real이라고 적어뒀는데, compose의 이 기본값이 그걸 덮어썼습니다.
즉 docker compose up -d 하면 가짜 인증으로 서버가 뜹니다.
가짜 검증기는 아무 문자열이나 통과시키니까, 아무 문자열로 아무 계정이나 만들 수 있었습니다.
이건 코드 리뷰에서 잡혔습니다. 그것도 1차 지적을 고치다가 새로 만든 문제를 재리뷰가 잡은 거였습니다.
배포용 설정에 문자 인증과 주소 변환을 real로 적어놨는데, real 구현 클래스가 아예 없었습니다.
스프링이 그 클래스를 못 찾아서 AuthService를 못 만들고, 서버 시작이 100% 실패했습니다.
| 상황 | 결과 | 위험도 |
|---|---|---|
| 진짜가 있는데 가짜로 떨어짐 | 인증이 뚫림 | 아주 위험 |
| 진짜가 없는데 진짜라고 적음 | 서버가 안 뜸 | 시끄럽지만 안전 |
방향은 반대인데 원인은 하나입니다 — 설정이 사실과 달랐습니다.
그래서 지금은 이렇게 합니다.
이 이야기는 7편(배포와 운영)에서 더 나옵니다. 이 프로젝트에서 제일 비싼 사고는 전부 설정에서 났습니다.
지도는 카카오 지도를 씁니다. 그런데 식당 목록은 카카오에서 안 가져옵니다.
이유가 둘입니다.
① 약관에서 막습니다.
카카오 로컬 API로 받은 검색 결과는 오래 저장하면 안 됩니다(1~2시간 캐싱까지만 허용).
그런데 이 앱은 식당마다 체크인·리뷰·즐겨찾기가 붙습니다. 식당 데이터가 영구적으로 있어야 합니다.
② 우리가 만드는 데이터가 핵심이라서요.
"이 식당에서 몇 명이 혼밥했나"는 어느 외부 API도 안 줍니다. 우리가 쌓는 데이터입니다.
그러려면 식당 목록을 우리가 갖고 있어야 합니다.
그래서 이렇게 정리했습니다.
카카오 지도 = 배경 그림만 그려주는 역할
마커(핀) = 우리 DB의 식당 id로 우리가 찍는다
식당 목록 = 공공데이터포털 "전국일반음식점 표준데이터" (655,163개)
Python 스크립트로 따로 넣습니다(etl/load_places.py).
places 테이블을 읽기만 합니다. 외부 API를 부르는 코드가 없습니다.ON CONFLICT — 이미 있으면 넘어가기)★그리고 이 65만 행이 나중에 부하 테스트에서 결정적이었습니다.
데이터가 적으면 느린 쿼리가 안 느려 보이거든요. (6편)
이 시리즈는 백엔드 중심이라 앱 이야기는 줄이겠습니다. 두 가지만 적습니다.
처음엔 "지금 내가 체크인 중인가"를 앱에서 직접 들고 있었습니다. 그랬더니 계속 문제가 났습니다.
① 체크인을 종료했는데 다른 화면엔 아직 진행 중으로 보임
② 버튼을 두 번 누르면 요청이 두 번 감
③ 서버가 바뀌었는데 화면이 모름
React Query라는 라이브러리가 이 세 가지를 표준으로 막아줍니다.
그래서 직접 만든 전역 상태를 지우고 이걸로 옮겼습니다.
| 앱이 꺼져 있으면 | 서버가 상태를 들고 있나 | |
|---|---|---|
| 폴링 (주기적으로 물어보기) | ❌ 안 됨 | 안 들고 있음 |
| WebSocket (계속 연결해두기) | ❌ 끊김 | 들고 있음 |
| 푸시 (OS가 대신 배달) | ✅ 유일한 방법 | 안 들고 있음 |
제가 처음에 오해했던 두 가지입니다.
그래서 데이터마다 다르게 골랐습니다.
| 데이터 | 방식 | 이유 |
|---|---|---|
| 통계 · 지도 마커 · 주변 목록 | 폴링 15초 | 누구에게 보낼지 특정할 수 없음 (반경 기준이라) |
| 같이먹기 신청 · 알림 | 폴링 + 푸시 | 앱이 꺼져 있어도 알려야 함 |
| 채팅 | WebSocket + 폴링 + 푸시 | 대화는 바로바로 떠야 함 |
★푸시를 붙인 뒤에도 폴링을 안 없앴습니다.
푸시가 안 올 수 있으니까 폴링을 안전망으로 남겨둔 겁니다.
다음 편은 3개월 동안 무슨 순서로 만들었는지입니다.