Spring Boot 4를 나온 지 얼마 안 됐을 때 써봤습니다 — 혼정 개발기 ③

윤현우·2026년 9월 4일

혼정

목록 보기
3/9
post-thumbnail

이번 편은 백엔드를 어떻게 짰고 왜 그렇게 골랐는지입니다.


전체 그림

┌─────────────────┐   HTTPS / REST     ┌──────────────────────┐
│  앱             │ ─────────────────▶ │  백엔드 (Spring)       │
│  React Native   │ ◀───────────────── │  Spring Boot 4       │
└─────────────────┘   JSON + JWT       │  + WebSocket         │
                                       └──────────┬───────────┘
                                                  │
                   ┌──────────────────────────────┼──────────────┐
                   ▼                              ▼              ▼
           ┌────────────────┐        ┌──────────────┐  ┌────────────────┐
           │  PostgreSQL    │        │  FCM (푸시)   │  │ 파일 저장         │
           │  users         │        └──────────────┘  │ 로컬 디스크       │
           │  places        │                          └────────────────┘
           │  check_ins ... │
           └───────▲────────┘
                   │ 적재
           ┌───────┴────────┐
           │ 공공데이터 ETL    │  Python 스크립트 (백엔드와 분리)
           └────────────────┘

모노레포입니다. 저장소 하나 안에 backend/ · app/ · docs/ · etl/ 이 다 있습니다.
혼자 하니까 앱과 서버를 같은 커밋에서 바꾸는 일이 많았고, 저장소를 나누면 그때마다 두 곳을 오가야 해서요.


1부 — 백엔드를 어떻게 나눴나

요청 하나가 지나가는 길

클라이언트 → Controller → Service → Repository → DB
층하는 일예시
ControllerHTTP 요청 받기, 입력값 검사, 응답 만들기CheckInController
Service실제 로직, 트랜잭션 시작·끝CheckInService
RepositoryDB에 쿼리 보내기CheckInRepository
EntityDB 테이블과 짝이 되는 클래스CheckIn, User
DTO층 사이에 오가는 데이터 상자CheckInRequest

원칙은 두 개였습니다.

  1. Controller는 얇게, 로직은 Service에. Controller에는 if문을 거의 안 넣었습니다.
  2. Entity를 그대로 응답에 내보내지 않는다. 항상 DTO로 바꿔서 내보냅니다.

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 같은 것)를 안 넣어서 그냥 쌓였습니다. 지금은 알고만 있는 상태입니다.


2부 — Spring Boot 4를 써서 겪은 것

프로젝트를 시작한 게 2026년 6월인데, Spring Boot 4가 막 나온 시점이었습니다.
"최신 버전이 이력서에 낫겠지" 라는 이유로 골랐고, 딱 그만큼 고생했습니다.

① JWT 라이브러리가 깨졌습니다

Boot 4는 JSON을 다루는 기본 라이브러리가 Jackson 3로 바뀌었습니다.
그런데 제가 쓰려던 jjwt(JWT를 만들고 검증해주는 라이브러리)는 Jackson 2에 의존하고 있었습니다.

그래서 서버를 띄우면 실행 중에 터졌습니다.

선택지는 둘이었습니다.

  • Jackson 2를 억지로 같이 넣는다 → 나중에 뭐가 터질지 모름
  • JWT를 직접 만든다

두 번째로 갔습니다. Nimbus라는 라이브러리로 직접 발급하게 바꿨습니다.

결과적으로는 이게 잘한 선택이 됐습니다. (다음 파트에서 이어집니다)

② 테스트용 애노테이션 위치가 다 바뀌었습니다

Boot 4가 테스트 관련 기능을 모듈별로 쪼개면서 클래스 위치가 이동했습니다.

애노테이션Boot 4에서의 위치
@WebMvcTestorg.springframework.boot.webmvc.test.autoconfigure.*
@DataJpaTest...boot.data.jpa.test.autoconfigure
TestEntityManagerorg.springframework.boot.jpa.test.autoconfigure

검색해도 안 나왔습니다. 나온 지 얼마 안 돼서 블로그도 스택오버플로도 없었습니다.

결국 이렇게 찾았습니다.

# 다운로드된 라이브러리 파일(jar) 안을 직접 뒤져서 클래스가 어디 있는지 찾는다
find ~/.gradle/caches -name "*.jar" | xargs -I{} sh -c \
  'unzip -l {} | grep -q TestEntityManager.class && echo {}'

배운 것: 최신 버전을 쓰면 "검색하면 나오는 답"이 없습니다.
그럴 때는 라이브러리 파일을 직접 열어보는 게 제일 빠릅니다.

③ 최신 버전을 쓴 게 잘한 일이었나

솔직히 초반 2주는 손해였습니다. 그런데 지나고 보니 괜찮았습니다.

  • 고생이 초반에 몰려 있었습니다. 위치를 한 번 확정하고 나면 그 뒤로는 평범합니다.
  • jjwt가 깨진 덕에 JWT를 직접 다뤄봤고, 그게 나중에 카카오 로그인에서 그대로 쓰였습니다.

3부 — 로그인을 어떻게 만들었나

여기가 백엔드에서 제일 신경 쓴 부분입니다.

토큰이 두 개인 이유

로그인하면 토큰(신분증 같은 문자열)을 두 개 줍니다.

토큰유효기간하는 일
access token1시간API 요청할 때마다 헤더에 붙임
refresh token14일access가 만료되면 새로 받아오는 용도

왜 하나로 안 하나?

access 토큰은 요청마다 왔다 갔다 하니까 노출될 기회가 많습니다.
그래서 짧게(1시간) 만들어서, 새어 나가도 1시간 뒤엔 쓸모없게 만듭니다.

대신 1시간마다 다시 로그인하라고 하면 사용자가 화나니까,
14일짜리 refresh 토큰으로 조용히 새 access를 받아오게 합니다.

refresh 토큰은 원문을 저장하지 않습니다

DB에 이렇게 저장합니다.

토큰 원문         : mZ8fK2p... (사용자에게만 준다)
DB에 저장하는 값   : SHA-256으로 해시한 값

비밀번호를 저장하는 방식과 똑같습니다.

이렇게 하면 DB가 통째로 털려도 그 값으로는 로그인을 못 합니다.
해시는 되돌릴 수 없거든요. 서버는 사용자가 보낸 토큰을 다시 해시해서 비교만 합니다.

한 번 쓴 refresh 토큰은 버립니다 (회전)

① 앱이 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(모르면 막는다)라고 부릅니다. 이 프로젝트에서 계속 쓴 원칙입니다.


4부 — 외부 연동을 전부 가짜로 먼저 만들었습니다

mock-first가 뭔가

카카오 로그인, 문자 인증, 파일 저장 — 이런 건 실제로 붙이려면 준비물이 많습니다.
(앱 키 발급, 사업자 등록, 비용 결제…)

그래서 껍데기는 진짜로 만들고 속만 가짜로 채웠습니다.

public interface OAuthVerifier {
    OAuthUser verify(String idToken);   // ← 이 약속(인터페이스)은 처음부터 진짜
}
// 개발 중: 아무 문자열이나 통과시키고 고정 사용자 반환
class MockOAuthVerifier implements OAuthVerifier { ... }

// 실제 배포: 카카오 서버에 물어보고 진짜 검증
class KakaoOAuthVerifier implements OAuthVerifier { ... }

설정값 하나로 둘 중 뭘 쓸지 정합니다.

honjeong:
  oauth:
    mode: mock   # 또는 real

효과가 컸습니다. 나중에 카카오 로그인을 실제로 붙일 때 백엔드에서 새로 만든 게 클래스 하나였습니다.
API 주소도, DB 테이블도, 신규/재방문 분기 로직도 전부 이미 있었으니까요.

그런데 함정이 두 개 있었습니다

함정 1 — mock으로 조용히 떨어지면 그건 인증이 뚫린 겁니다

docker-compose.yml에 이렇게 적혀 있었습니다.

OAUTH_MODE: ${OAUTH_MODE:-mock}    # ← "값이 없으면 mock을 쓴다"

배포용 설정 파일에는 real이라고 적어뒀는데, compose의 이 기본값이 그걸 덮어썼습니다.

즉 docker compose up -d 하면 가짜 인증으로 서버가 뜹니다.
가짜 검증기는 아무 문자열이나 통과시키니까, 아무 문자열로 아무 계정이나 만들 수 있었습니다.

이건 코드 리뷰에서 잡혔습니다. 그것도 1차 지적을 고치다가 새로 만든 문제를 재리뷰가 잡은 거였습니다.

함정 2 — 반대로 하면 서버가 아예 안 뜹니다

배포용 설정에 문자 인증과 주소 변환을 real로 적어놨는데, real 구현 클래스가 아예 없었습니다.

스프링이 그 클래스를 못 찾아서 AuthService를 못 만들고, 서버 시작이 100% 실패했습니다.

그래서 원칙을 하나 정했습니다

상황결과위험도
진짜가 있는데 가짜로 떨어짐인증이 뚫림아주 위험
진짜가 없는데 진짜라고 적음서버가 안 뜸시끄럽지만 안전

방향은 반대인데 원인은 하나입니다 — 설정이 사실과 달랐습니다.

그래서 지금은 이렇게 합니다.

  1. 가짜 모드로 뜨면 로그를 찍습니다. 설정 파일 주석은 서버 띄우는 사람이 안 읽는다고 가정합니다.
  2. 위험한 조합이면 아예 못 뜨게 막습니다. 예를 들어 문자 인증이 가짜 모드면 그 API를 통째로 차단합니다. 나중에 진짜 문자를 붙이면 코드를 안 고쳐도 자동으로 열립니다.

이 이야기는 7편(배포와 운영)에서 더 나옵니다. 이 프로젝트에서 제일 비싼 사고는 전부 설정에서 났습니다.


5부 — 식당 데이터를 어디서 가져왔나

카카오 지도는 쓰는데 카카오 검색은 안 씁니다

지도는 카카오 지도를 씁니다. 그런데 식당 목록은 카카오에서 안 가져옵니다.

이유가 둘입니다.

① 약관에서 막습니다.
카카오 로컬 API로 받은 검색 결과는 오래 저장하면 안 됩니다(1~2시간 캐싱까지만 허용).
그런데 이 앱은 식당마다 체크인·리뷰·즐겨찾기가 붙습니다. 식당 데이터가 영구적으로 있어야 합니다.

② 우리가 만드는 데이터가 핵심이라서요.
"이 식당에서 몇 명이 혼밥했나"는 어느 외부 API도 안 줍니다. 우리가 쌓는 데이터입니다.
그러려면 식당 목록을 우리가 갖고 있어야 합니다.

그래서 이렇게 정리했습니다.

카카오 지도   = 배경 그림만 그려주는 역할
마커(핀)      = 우리 DB의 식당 id로 우리가 찍는다
식당 목록     = 공공데이터포털 "전국일반음식점 표준데이터" (655,163개)

적재는 백엔드 밖에서 합니다

Python 스크립트로 따로 넣습니다(etl/load_places.py).

  • 백엔드는 places 테이블을 읽기만 합니다. 외부 API를 부르는 코드가 없습니다.
  • 여러 번 돌려도 결과가 같게 만들었습니다 (ON CONFLICT — 이미 있으면 넘어가기)
  • 공공데이터 좌표가 우리가 쓰는 좌표계와 달라서(EPSG:5174 → WGS84) 변환이 필요했습니다

★그리고 이 65만 행이 나중에 부하 테스트에서 결정적이었습니다.
데이터가 적으면 느린 쿼리가 안 느려 보이거든요. (6편)


6부 — 앱 쪽은 짧게

이 시리즈는 백엔드 중심이라 앱 이야기는 줄이겠습니다. 두 가지만 적습니다.

서버 데이터는 직접 관리하지 않았습니다

처음엔 "지금 내가 체크인 중인가"를 앱에서 직접 들고 있었습니다. 그랬더니 계속 문제가 났습니다.

① 체크인을 종료했는데 다른 화면엔 아직 진행 중으로 보임
② 버튼을 두 번 누르면 요청이 두 번 감
③ 서버가 바뀌었는데 화면이 모름

React Query라는 라이브러리가 이 세 가지를 표준으로 막아줍니다.
그래서 직접 만든 전역 상태를 지우고 이걸로 옮겼습니다.

실시간은 세 가지를 섞어 썼습니다

앱이 꺼져 있으면서버가 상태를 들고 있나
폴링 (주기적으로 물어보기)❌ 안 됨안 들고 있음
WebSocket (계속 연결해두기)❌ 끊김들고 있음
푸시 (OS가 대신 배달)✅ 유일한 방법안 들고 있음

제가 처음에 오해했던 두 가지입니다.

  • "푸시가 폴링보다 좋은 거 아닌가?" → 아닙니다. 푸시는 도착이 보장되지 않습니다. 권한 거부, 토큰 만료, 기기 꺼짐이면 조용히 안 옵니다.
  • "WebSocket 쓰면 푸시 필요 없지 않나?" → 아닙니다. 앱이 백그라운드로 가면 연결이 끊깁니다. 사용자를 불러와야 하는 알림은 푸시만 할 수 있습니다.

그래서 데이터마다 다르게 골랐습니다.

데이터방식이유
통계 · 지도 마커 · 주변 목록폴링 15초누구에게 보낼지 특정할 수 없음 (반경 기준이라)
같이먹기 신청 · 알림폴링 + 푸시앱이 꺼져 있어도 알려야 함
채팅WebSocket + 폴링 + 푸시대화는 바로바로 떠야 함

★푸시를 붙인 뒤에도 폴링을 안 없앴습니다.
푸시가 안 올 수 있으니까 폴링을 안전망으로 남겨둔 겁니다.


정리 — 이 편의 요약

  1. Entity를 그대로 응답에 안 내보낸다. 컬럼이 늘 때 정보가 새는 걸 막습니다.
  2. 폴더는 기능별로 나눴다. 한 폴더만 열면 되니까요. (대신 폴더끼리 얽히는 걸 막는 장치는 못 넣었습니다.)
  3. 토큰은 짧은 것 + 긴 것 두 개, refresh는 해시로 저장하고 쓸 때마다 새로 발급.
  4. JWT는 회수가 안 되니까, 계정 상태는 요청마다 DB에서 확인한다.
  5. 막을 것을 나열하지 말고 통과시킬 것을 나열한다. 모르는 건 자동으로 막히게요.
  6. 설정이 사실과 다르면 인증이 뚫리거나 서버가 안 뜬다. 방향은 반대인데 원인은 같습니다.

다음 편은 3개월 동안 무슨 순서로 만들었는지입니다.

profile
개발자가 되는 그날까지

0개의 댓글