ReadMates를 로컬에서 실행하는 최소 절차입니다. 운영 배포는 배포 문서를 봅니다.
- 완료 기준: backend health와 frontend dev server가 응답하고, 필요한 로그인 경로(dev-login 또는 Google OAuth)를 확인했습니다.
- 운영 secret, private endpoint, 실제 멤버 데이터는 Git에 남기지 않습니다.
- placeholder를 운영값으로 바꿔 커밋하거나,
docker compose down -v처럼 데이터를 지우거나, 외부 provider 설정이 불확실하면 먼저 멈추고 확인합니다.
JDK 25 LTSNode.js 24(.node-version과 CI 기준)pnpm@11.13.1(루트package.json의packageManager, Corepack으로 활성화)Docker Compose또는MySQL 8compatible database
루트 pnpm workspace가 front, design/system, design/docs를 관리합니다. 백엔드는 Gradle wrapper와 Java 25 toolchain을 씁니다. 전역 Gradle 설치는 필요 없습니다.
corepack pnpm install --frozen-lockfilecorepack이 PATH에 없으면 아래 fallback을 씁니다.
npx --yes corepack@0.35.0 pnpm install --frozen-lockfile이 문서의 corepack pnpm ... 명령은 같은 방식으로 npx --yes corepack@0.35.0 pnpm ...로 바꿔 실행할 수 있습니다.
저장소의 compose.yml은 로컬 개발용 MySQL 8.4 컨테이너를 제공합니다.
docker compose up -d mysql상태 확인:
docker compose ps기본 접속 정보는 아래와 같습니다.
database: readmates
username: readmates
password: readmates
port: 3306
Root .env는 Git에서 무시됩니다. 기본값을 바꾸려면 .env.example의 Local Docker Compose MySQL section 중 필요한 변수만 .env에 복사해 수정합니다.
이미 별도 MySQL 8 compatible database를 사용한다면 같은 database를 만들고, backend 실행 시 SPRING_DATASOURCE_URL, SPRING_DATASOURCE_USERNAME, SPRING_DATASOURCE_PASSWORD를 해당 값으로 바꿉니다.
이 database는 로컬 backend 실행과 E2E용입니다. Backend integrationTest는 Testcontainers가 MySQL을 직접 띄우므로 먼저 실행할 필요가 없습니다. E2E schema, CREATE DATABASE 권한, Flyway checksum mismatch는 test-guide.md를 봅니다.
Redis는 선택 의존성이며 기본값은 비활성화입니다. 세션, 멤버십, 발행 기록, 노트 데이터의 최종 권위는 계속 MySQL입니다.
로컬 MySQL과 Redis를 함께 띄웁니다.
docker compose up -d mysql redisRedis-backed 기능은 명시적으로 켭니다.
READMATES_REDIS_ENABLED=true \
READMATES_RATE_LIMIT_ENABLED=true \
READMATES_AUTH_SESSION_CACHE_ENABLED=true \
READMATES_PUBLIC_CACHE_ENABLED=true \
READMATES_NOTES_CACHE_ENABLED=true \
./server/gradlew -p server bootRun기본 Redis URL은 redis://localhost:6379입니다. 다른 Redis를 쓰면 READMATES_REDIS_URL에 placeholder-safe 값을 넣고, 실제 secret이나 private endpoint는 Git에 남기지 않습니다.
Kafka 알림 relay/consumer를 로컬에서 확인할 때는 Compose의 kafka service를 MySQL과 함께 띄웁니다. 현재 Compose service는 Redpanda를 사용하지만 Spring에서는 Kafka bootstrap server로만 바라봅니다.
docker compose up -d mysql kafkaRedpanda healthcheck가 통과했는지는 docker compose ps kafka의 healthy 상태로 확인합니다.
로컬 bootstrap server는 localhost:9092입니다. Kafka integration test만 돌릴 때는 backend를 띄우거나 SMTP를 설정할 필요가 없습니다. Testcontainers가 Kafka를 띄우고 test configuration이 mail sender를 대체합니다.
./server/gradlew -p server integrationTest --tests 'com.readmates.notification.kafka.*'로컬 backend에서 relay/consumer와 실제 이메일 발송까지 켤 때는 Kafka와 Spring mail property를 함께 넣습니다. READMATES_NOTIFICATIONS_ENABLED=true는 SmtpMailDeliveryAdapter를 선택하므로 SPRING_MAIL_HOST 같은 mail 설정이 필요합니다.
READMATES_NOTIFICATIONS_ENABLED=true \
READMATES_KAFKA_ENABLED=true \
READMATES_KAFKA_BOOTSTRAP_SERVERS=localhost:9092 \
READMATES_KAFKA_NOTIFICATION_EVENTS_TOPIC=readmates.notification.events.v1 \
READMATES_KAFKA_NOTIFICATION_DLQ_TOPIC=readmates.notification.events.dlq.v1 \
READMATES_KAFKA_NOTIFICATION_CONSUMER_GROUP=readmates-notification-dispatcher \
READMATES_KAFKA_NOTIFICATION_RELAY_BATCH_SIZE=50 \
READMATES_KAFKA_NOTIFICATION_MAX_PUBLISH_ATTEMPTS=5 \
READMATES_NOTIFICATION_RETRY_DELAY_MINUTES=5,15,60,240 \
READMATES_NOTIFICATION_MAX_DELIVERY_ATTEMPTS=5 \
SPRING_MAIL_HOST='<smtp-host>' \
SPRING_MAIL_PORT='<smtp-port>' \
SPRING_MAIL_USERNAME='<smtp-username>' \
SPRING_MAIL_PASSWORD='<smtp-password>' \
READMATES_NOTIFICATION_SENDER_EMAIL='no-reply@example.com' \
READMATES_NOTIFICATION_SENDER_NAME='ReadMates' \
./server/gradlew -p server bootRunSMTP provider가 auth 또는 TLS를 요구하면 SPRING_MAIL_PROPERTIES_MAIL_SMTP_AUTH=true, SPRING_MAIL_PROPERTIES_MAIL_SMTP_STARTTLS_ENABLE=true 같은 Spring mail property도 로컬 환경에만 추가합니다. 예시에는 실제 credential이나 private endpoint를 남기지 않습니다.
OCI 운영과 같은 Docker Compose 런타임(Caddy + Spring + MySQL + Redis + Redpanda)에서 로컬 검증이 필요하면 deploy/local/compose.override.yml을 함께 띄웁니다. .env는 위 MySQL section의 READMATES_LOCAL_MYSQL_* 값을 사용합니다 (READMATES_LOCAL_MYSQL_ROOT_PASSWORD는 필수).
cp .env.example .env # READMATES_LOCAL_MYSQL_ROOT_PASSWORD 등 편집
docker compose -f deploy/oci/compose.yml -f deploy/local/compose.override.yml --env-file .env upoverride는 readmates-api를 dev profile로 띄우고 8080/3306/6379 포트를 host에 엽니다. 운영 VM에서는 쓰지 않습니다.
로컬 개발은 dev profile을 사용합니다. 이 profile은 sample seed data와 dev-login fixture를 켭니다.
SPRING_PROFILES_ACTIVE=dev \
SPRING_DATASOURCE_URL='jdbc:mysql://localhost:3306/<local-db-name>?serverTimezone=UTC' \
SPRING_DATASOURCE_USERNAME='<local-db-user>' \
SPRING_DATASOURCE_PASSWORD='<local-db-password>' \
READMATES_APP_BASE_URL=http://localhost:5173 \
READMATES_AUTH_BASE_URL=http://localhost:5173 \
READMATES_AUTH_RETURN_STATE_SECRET='<local-return-state-signing-secret>' \
READMATES_ALLOWED_ORIGINS=http://localhost:5173 \
READMATES_BFF_SECRET='<local-bff-secret>' \
READMATES_IP_HASH_BASE_SECRET='<local-ip-hash-base-secret>' \
READMATES_AUTH_SESSION_COOKIE_SECURE=false \
./server/gradlew -p server bootRun주요 backend 환경 변수는 다음과 같습니다.
| 변수 | 용도 |
|---|---|
SPRING_PROFILES_ACTIVE |
dev profile을 켜면 dev seed migration과 dev-login fixture가 활성화됩니다. |
SPRING_DATASOURCE_URL |
MySQL JDBC URL입니다. |
SPRING_DATASOURCE_USERNAME |
MySQL 사용자입니다. |
SPRING_DATASOURCE_PASSWORD |
MySQL 비밀번호입니다. |
READMATES_APP_BASE_URL |
로그인 성공 후 돌아갈 frontend origin입니다. |
READMATES_AUTH_BASE_URL |
Google OAuth callback redirect_uri에 쓰는 auth origin입니다. 로컬에서는 보통 READMATES_APP_BASE_URL과 같은 값을 둡니다. |
READMATES_AUTH_RETURN_STATE_SECRET |
OAuth returnTo target 서명 secret입니다. 로컬에서도 placeholder-safe 값을 명시하면 production return-state 경계를 더 가깝게 확인할 수 있습니다. |
READMATES_ALLOWED_ORIGINS |
mutating API 요청의 Origin 또는 Referer 허용 origin입니다. 쉼표로 여러 값을 줄 수 있습니다. |
READMATES_BFF_SECRET |
BFF가 Spring API로 전달하는 공유 secret입니다. 로컬에서도 frontend proxy와 같은 값을 쓰면 production boundary를 비슷하게 테스트할 수 있습니다. |
READMATES_BFF_SECRET_REQUIRED |
기본 true, application-dev.yml은 false입니다. 그래도 READMATES_BFF_SECRET을 설정하면 /api/**는 X-Readmates-Bff-Secret을 요구합니다. |
READMATES_IP_HASH_BASE_SECRET |
rate limit/client IP hash의 base secret입니다. 빈 값은 dev에서도 startup 실패이므로 placeholder 값을 넣습니다. 특수한 경우에만 readmates.security.ip-hash.allow-empty-secret=true를 씁니다. |
READMATES_AUTH_SESSION_COOKIE_SECURE |
로컬 HTTP에서 readmates_session cookie를 테스트하려면 false로 둡니다. |
READMATES_FLYWAY_LOCATIONS |
Flyway migration 위치입니다. 기본은 classpath:db/mysql/migration이고, dev profile은 classpath:db/mysql/dev를 추가합니다. |
READMATES_NOTIFICATIONS_ENABLED |
알림 relay/consumer bean을 켜는 상위 flag입니다. 기본은 false입니다. |
READMATES_KAFKA_ENABLED |
Kafka notification relay/consumer를 켭니다. 기본은 false입니다. |
READMATES_KAFKA_BOOTSTRAP_SERVERS |
Kafka bootstrap server 목록입니다. 로컬 Compose 기본값은 localhost:9092입니다. |
READMATES_KAFKA_NOTIFICATION_EVENTS_TOPIC |
notification event topic입니다. 기본은 readmates.notification.events.v1입니다. |
READMATES_KAFKA_NOTIFICATION_DLQ_TOPIC |
consumer error handler가 사용하는 DLQ topic입니다. 기본은 readmates.notification.events.dlq.v1입니다. |
READMATES_KAFKA_NOTIFICATION_CONSUMER_GROUP |
notification consumer group입니다. 기본은 readmates-notification-dispatcher입니다. |
READMATES_NOTIFICATION_RETRY_DELAY_MINUTES |
email delivery dispatch/worker retry delay 목록입니다. 기본은 5,15,60,240분입니다. |
SPRING_SECURITY_OAUTH2_CLIENT_REGISTRATION_GOOGLE_CLIENT_ID |
Google OAuth를 로컬에서 직접 시험할 때 필요한 client id입니다. dev-login만 쓰면 필요하지 않습니다. |
SPRING_SECURITY_OAUTH2_CLIENT_REGISTRATION_GOOGLE_CLIENT_SECRET |
Google OAuth를 로컬에서 직접 시험할 때 필요한 client secret입니다. dev-login만 쓰면 필요하지 않습니다. |
SPRING_SECURITY_OAUTH2_CLIENT_REGISTRATION_GOOGLE_SCOPE |
Google OAuth scope입니다. 기본 운영 예시는 openid,email,profile입니다. |
실제 Google 로그인을 로컬에서 확인할 때는 운영 OAuth client를 재사용하지 않고 별도 Web client를 만듭니다. Google Cloud의 승인된 redirect URI에는 다음 callback만 추가합니다.
http://localhost:5174/login/oauth2/code/google
http://localhost:5173/login/oauth2/code/google
Client ID와 client secret은 .env, 프런트 VITE_*, shell history에 기록하지 않습니다. macOS에서는 아래 명령이 각 값을 대화형으로 요청해 Keychain에 저장합니다. -w를 마지막 인자로 유지해야 값이 명령행 인자에 들어가지 않습니다.
security add-generic-password -U -a "$USER" -s readmates.local.google-oauth.client-id -w
security add-generic-password -U -a "$USER" -s readmates.local.google-oauth.client-secret -w아래 한 줄 실행기로 backend + frontend를 함께 띄우고 포트 충돌, health readiness, signal 정리를 처리합니다.
./scripts/run-local-google-oauth-stack.sh권장 실행 예시:
READMATES_LOCAL_GOOGLE_OAUTH_FRONTEND_PORT=5174 \
READMATES_LOCAL_GOOGLE_OAUTH_BACKEND_PORT=28080 \
READMATES_LOCAL_GOOGLE_OAUTH_MANAGEMENT_PORT=28081 \
READMATES_LOCAL_GOOGLE_OAUTH_STARTUP_TIMEOUT_SECONDS=180 \
READMATES_LOCAL_GOOGLE_OAUTH_OPEN_BROWSER=false \
./scripts/run-local-google-oauth-stack.sh다음 검증 명령은 실제 redirect 계약만 점검합니다.
./scripts/verify-local-google-oauth-stack.sh수동 검증(브라우저):
- stack 실행 완료 후 출력된 Login URL로 이동해 Google 화면 진입을 확인합니다.
- 등록된 테스트 계정으로 로그인 후 ReadMates로 복귀하는지 확인합니다.
chooseAccount=true시작 URL로 동일 계정 재선택 흐름을 확인합니다.Ctrl+C종료 후 기존 서비스와 기존 포트가 유지되는지 확인합니다.
수동 확인은 5174 callback을 Google Cloud에 등록한 경우에만 합니다. 다른 포트는 자동 redirect 검증만 합니다.
| 이슈 | 대응 |
|---|---|
| Keychain 누락/포맷 오류 | security find-generic-password -a "$USER" -s readmates.local.google-oauth.client-id -w로 존재 여부를 점검하고, 필요하면 대화형 security add-generic-password ... -w로 재등록합니다. |
invalid_client |
client ID를 교체하지 말고, Google Cloud 승인 callback과 현재 localhost callback을 동일하게 등록했는지 확인합니다. |
redirect_uri_mismatch |
등록한 callback(http://localhost:5174/login/oauth2/code/google 또는 5173)과 정확히 일치하는지 확인합니다. |
| 포트 충돌 | lsof -nP -iTCP:5174 -sTCP:LISTEN로 listener만 조회해 현재 충돌 포트를 파악하고 오버라이드 포트를 설정해 다시 실행합니다. 기존 서비스는 종료하지 않습니다. |
| readiness timeout | READMATES_LOCAL_GOOGLE_OAUTH_STARTUP_TIMEOUT_SECONDS를 조정하고 management health + /login 응답을 확인해 선행 실패 원인을 좁힙니다. |
로그/출력은 client ID, client secret, OAuth code/state, 쿠키를 노출하지 않습니다.
backend와 frontend를 따로 띄워야 할 때만 씁니다.
./scripts/run-local-google-oauth.sh
VITE_ENABLE_GOOGLE_LOGIN=true corepack pnpm --dir front devVITE_ENABLE_GOOGLE_LOGIN은 표시/숨김 switch일 뿐 credential이 프론트엔드로 전달되지 않습니다. 기존 dev-login은 VITE_ENABLE_DEV_LOGIN로 별도 제어합니다.
운영 migration은 server/src/main/resources/db/mysql/migration에만 추가합니다. server/src/main/resources/db/migration은 사용하지 않으며, 새 파일을 그 위치에 만들면 운영 Flyway가 읽지 않습니다.
Server 로그는 JSON이므로 ./server/gradlew -p server bootRun 2>&1 | jq '.'로 볼 수 있습니다.
Vite 개발 서버는 front/vite.config.ts의 proxy를 사용합니다. 로컬에서는 Cloudflare Pages Functions 대신 Vite proxy가 /api/bff/**, /oauth2/authorization/**, /login/oauth2/code/**를 Spring backend로 전달합니다.
READMATES_API_BASE_URL=http://localhost:8080 \
READMATES_BFF_SECRET='<local-bff-secret>' \
VITE_ENABLE_DEV_LOGIN=true \
corepack pnpm --dir front dev브라우저에서 http://localhost:5173을 엽니다.
주요 frontend 환경 변수는 다음과 같습니다.
| 변수 | 용도 |
|---|---|
READMATES_API_BASE_URL |
Vite dev proxy와 Cloudflare Pages Functions가 바라볼 Spring API origin입니다. 문서 예시는 로컬 http://localhost:8080 또는 placeholder https://api.example.com만 사용합니다. |
READMATES_BFF_SECRET |
Vite dev proxy 또는 Pages Functions가 Spring으로 보낼 X-Readmates-Bff-Secret 값입니다. 브라우저 bundle에 들어가는 VITE_ 변수로 만들지 않습니다. |
VITE_ENABLE_DEV_LOGIN |
로컬 dev-login 버튼 표시를 명시적으로 켭니다. import.meta.env.DEV에서도 표시되지만, 로컬 의도를 분명히 하기 위해 설정할 수 있습니다. |
VITE_ENABLE_GOOGLE_LOGIN |
실제 Google credential을 backend에 안전하게 주입한 로컬 환경에서만 Google action을 표시하는 비민감 switch입니다. credential 자체를 넣지 않습니다. |
NEXT_PUBLIC_ENABLE_DEV_LOGIN |
이전 로컬 env 파일 호환을 위한 legacy 변수입니다. 새 설정에는 VITE_ENABLE_DEV_LOGIN을 사용합니다. |
Dev-login은 로컬 개발과 E2E fixture를 위한 흐름입니다. production auth가 아닙니다.
- Backend endpoint는
POST /api/dev/login입니다. - Controller는
@Profile("!prod & !production")와readmates.dev.login-enabled=true조건에서만 등록됩니다. application-dev.yml은readmates.dev.login-enabled=true를 설정합니다.- Frontend login 화면은 production build에서는 dev-login 버튼을 숨깁니다.
- Fixture 계정은
host@example.com,member1@example.com같은example.com주소를 사용합니다. - Dev seed는 기존 account alias인
users.short_name과 현재 표시 이름 저장소인memberships.short_name을 모두 채웁니다. 화면 표시와 프로필 수정 API는displayName필드를 주고받고 membership 단위memberships.short_name을 갱신하므로,/app/me와/app/host/members에서 표시 이름 변경 흐름을 로컬로 확인할 수 있습니다. - Dev seed와 E2E fixture에는
OPEN세션,CLOSED/PUBLISHED기록, 새DRAFT예정 세션 흐름이 있습니다. 호스트는/app/host에서 예정 세션의 게스트 접근(HOST_ONLY↔GUEST_READABLE)을 바꾸고DRAFT를OPEN으로 시작한 뒤,/app/host/sessions/:sessionId/edit에서 닫기와 기록 발행을 확인할 수 있습니다.
운영 로그인은 Google OAuth 흐름입니다. 브라우저는 /oauth2/authorization/google로 시작하고, callback은 /login/oauth2/code/google로 돌아오며, Spring은 성공 시 readmates_session cookie를 발급합니다. 로컬 route guard, loader, API 401 흐름도 같은 origin의 안전한 relative returnTo만 /login?returnTo=...와 OAuth start에 전달하므로, 복귀 경로를 확인할 때 absolute URL이나 login/reset/invite/OAuth path를 fixture로 쓰지 않습니다.
MySQL 컨테이너를 멈추려면 아래 명령을 사용합니다.
docker compose down로컬 database volume까지 삭제하려면 아래 명령을 사용합니다. 기존 seed와 개발 중 데이터가 사라집니다.
docker compose down -v