diff --git a/AGENTS.md b/AGENTS.md
index 78b6a81..c1bff7c 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -5,8 +5,8 @@
| 영역 | 기술 |
|------|------|
| Frontend | Vue 3, Vite, Pinia, Axios, 네이버지도 SDK, Tailwind CSS |
-| Backend | Spring Boot 3, Spring Security (자체 JWT), PostgreSQL |
-| AI Backend | Python 3.11, FastAPI, LangGraph, Codex API (OpenAI) |
+| Backend | Spring Boot 3, Spring Security (자체 JWT), PostgreSQL, Redis, Gmail SMTP |
+| AI Backend | Python 3.11, FastAPI, LangGraph, Claude API (Anthropic) |
| DB | Supabase (PostgreSQL + pgvector) |
| Infra | Cloud Run (backend + backend-ai 각각 독립 배포) |
| Batch | Spring Scheduler (국토교통부·생활안전지도 공공 API) |
diff --git a/CLAUDE.md b/CLAUDE.md
index 4b065e8..ab80cf3 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -5,7 +5,7 @@
| 영역 | 기술 |
|------|------|
| Frontend | Vue 3, Vite, Pinia, Axios, 네이버지도 SDK, Tailwind CSS |
-| Backend | Spring Boot 3, Spring Security (자체 JWT), PostgreSQL |
+| Backend | Spring Boot 3, Spring Security (자체 JWT), PostgreSQL, Redis, Gmail SMTP |
| AI Backend | Python 3.11, FastAPI, LangGraph, Claude API (Anthropic) |
| DB | Supabase (PostgreSQL + pgvector) |
| Infra | Cloud Run (backend + backend-ai 각각 독립 배포) |
@@ -51,5 +51,11 @@ cd backend-ai && uvicorn app.main:app --reload # 개발 서버
cd backend-ai && pytest tests/ # 테스트
```
+## 로컬 개발 사전 조건
+
+- Redis 실행 필수: `redis-server` (brew) 또는 `docker run -p 6379:6379 redis`
+- `backend/.env` 파일 필요 (`.gitignore`에 포함, 팀원에게 별도 공유)
+- `frontend/.env` 파일 필요 (`.gitignore`에 포함, 팀원에게 별도 공유)
+
## 문서
-`docs/` 폴더에 01~12 번호 순서로 정렬되어 있습니다.
+`docs/` 폴더에 01~13 번호 순서로 정렬되어 있습니다.
diff --git a/backend/.env.example b/backend/.env.example
deleted file mode 100644
index 5ef0e9f..0000000
--- a/backend/.env.example
+++ /dev/null
@@ -1,10 +0,0 @@
-# Spring Boot datasource
-# Use these values when running the backend locally.
-# Example:
-# SUPABASE_DB_URL=jdbc:postgresql://aws-0-ap-northeast-2.pooler.supabase.com:6543/postgres?sslmode=require
-SUPABASE_DB_PASSWORD=
-SUPABASE_DB_URL=
-SUPABASE_DB_USERNAME=
-
-# Comma-separated frontend origins allowed to call /api/** from browsers.
-APP_CORS_ALLOWED_ORIGINS=http://localhost:5173,http://127.0.0.1:5173
diff --git a/backend/pom.xml b/backend/pom.xml
index 615b0cb..47be218 100644
--- a/backend/pom.xml
+++ b/backend/pom.xml
@@ -71,6 +71,25 @@
true
+
+
+ me.paulschwarz
+ spring-dotenv
+ 3.0.0
+
+
+
+
+ org.springframework.boot
+ spring-boot-starter-data-redis
+
+
+
+
+ org.springframework.boot
+ spring-boot-starter-mail
+
+
org.springframework.boot
diff --git a/backend/src/main/java/com/ssafy/salmanhae/common/exception/ErrorCode.java b/backend/src/main/java/com/ssafy/salmanhae/common/exception/ErrorCode.java
index 9ad4aea..020bf0e 100644
--- a/backend/src/main/java/com/ssafy/salmanhae/common/exception/ErrorCode.java
+++ b/backend/src/main/java/com/ssafy/salmanhae/common/exception/ErrorCode.java
@@ -5,9 +5,13 @@
public enum ErrorCode {
INVALID_REQUEST(HttpStatus.BAD_REQUEST, "요청 파라미터가 올바르지 않습니다."),
INVALID_BOUNDS(HttpStatus.BAD_REQUEST, "지도 범위 파라미터가 올바르지 않습니다."),
+ PROPERTY_NOT_FOUND(HttpStatus.NOT_FOUND, "해당 매물을 찾을 수 없습니다."),
UNAUTHORIZED(HttpStatus.UNAUTHORIZED, "이메일 또는 비밀번호가 올바르지 않습니다."),
EMAIL_ALREADY_EXISTS(HttpStatus.CONFLICT, "이미 가입된 이메일입니다."),
- PROPERTY_NOT_FOUND(HttpStatus.NOT_FOUND, "해당 매물을 찾을 수 없습니다."),
+ INVALID_TOKEN(HttpStatus.UNAUTHORIZED, "유효하지 않은 토큰입니다."),
+ EXPIRED_TOKEN(HttpStatus.UNAUTHORIZED, "만료된 토큰입니다."),
+ EMAIL_NOT_VERIFIED(HttpStatus.FORBIDDEN, "이메일 인증이 필요합니다."),
+ INVALID_VERIFICATION_CODE(HttpStatus.BAD_REQUEST, "인증 코드가 올바르지 않습니다."),
AI_SERVICE_UNAVAILABLE(HttpStatus.BAD_GATEWAY, "AI 서비스 응답이 없습니다."),
INTERNAL_SERVER_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "서버 내부 오류가 발생했습니다.");
diff --git a/backend/src/main/java/com/ssafy/salmanhae/config/SecurityConfig.java b/backend/src/main/java/com/ssafy/salmanhae/config/SecurityConfig.java
index 9fe6e45..bff7503 100644
--- a/backend/src/main/java/com/ssafy/salmanhae/config/SecurityConfig.java
+++ b/backend/src/main/java/com/ssafy/salmanhae/config/SecurityConfig.java
@@ -37,8 +37,9 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
.sessionManagement(session -> session
.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.authorizeHttpRequests(auth -> auth
- // 인증 없이 허용
- .requestMatchers("/api/v1/auth/signup", "/api/v1/auth/login", "/api/v1/auth/refresh").permitAll()
+ // 인가 없이 허용
+ .requestMatchers("/api/v1/auth/signup", "/api/v1/auth/login", "/api/v1/auth/refresh",
+ "/api/v1/auth/email/send", "/api/v1/auth/email/verify").permitAll()
.requestMatchers(HttpMethod.GET, "/api/v1/properties/**", "/api/v1/map/**", "/api/v1/safety/**", "/api/v1/price-analysis").permitAll()
// 나머지는 로그인 필요
.anyRequest().authenticated()
diff --git a/backend/src/main/java/com/ssafy/salmanhae/controller/auth/AuthController.java b/backend/src/main/java/com/ssafy/salmanhae/controller/auth/AuthController.java
index 0238aba..b99fd26 100644
--- a/backend/src/main/java/com/ssafy/salmanhae/controller/auth/AuthController.java
+++ b/backend/src/main/java/com/ssafy/salmanhae/controller/auth/AuthController.java
@@ -1,12 +1,13 @@
package com.ssafy.salmanhae.controller.auth;
-import com.ssafy.salmanhae.model.dto.auth.LoginRequest;
-import com.ssafy.salmanhae.model.dto.auth.LoginResponse;
-import com.ssafy.salmanhae.model.dto.auth.SignupRequest;
+import com.ssafy.salmanhae.model.dto.auth.*;
import com.ssafy.salmanhae.common.response.ApiResponse;
import com.ssafy.salmanhae.service.auth.AuthService;
+import com.ssafy.salmanhae.service.auth.EmailVerificationService;
+import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity;
+import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
@@ -18,6 +19,7 @@
public class AuthController {
private final AuthService authService;
+ private final EmailVerificationService emailVerificationService;
@PostMapping("/signup")
public ResponseEntity> signup(@RequestBody SignupRequest request) {
@@ -32,7 +34,26 @@ public ResponseEntity> login(@RequestBody LoginReques
}
@PostMapping("/logout")
- public ResponseEntity> logout() {
+ public ResponseEntity> logout(@AuthenticationPrincipal User user) {
+ authService.logout(user.getEmail());
+ return ResponseEntity.ok(ApiResponse.ok());
+ }
+
+ @PostMapping("/refresh")
+ public ResponseEntity> refresh(@Valid @RequestBody RefreshRequest request) {
+ RefreshResponse response = authService.refresh(request.getRefreshToken());
+ return ResponseEntity.ok(ApiResponse.ok(response));
+ }
+
+ @PostMapping("/email/send")
+ public ResponseEntity> sendVerificationEmail(@Valid @RequestBody EmailSendRequest request) {
+ emailVerificationService.sendCode(request.getEmail());
+ return ResponseEntity.ok(ApiResponse.ok());
+ }
+
+ @PostMapping("/email/verify")
+ public ResponseEntity> verifyEmail(@Valid @RequestBody EmailVerifyRequest request) {
+ emailVerificationService.verifyCode(request.getEmail(), request.getCode());
return ResponseEntity.ok(ApiResponse.ok());
}
}
diff --git a/backend/src/main/java/com/ssafy/salmanhae/model/dto/auth/EmailSendRequest.java b/backend/src/main/java/com/ssafy/salmanhae/model/dto/auth/EmailSendRequest.java
new file mode 100644
index 0000000..754858f
--- /dev/null
+++ b/backend/src/main/java/com/ssafy/salmanhae/model/dto/auth/EmailSendRequest.java
@@ -0,0 +1,12 @@
+package com.ssafy.salmanhae.model.dto.auth;
+
+import jakarta.validation.constraints.Email;
+import jakarta.validation.constraints.NotBlank;
+import lombok.Getter;
+
+@Getter
+public class EmailSendRequest {
+ @NotBlank
+ @Email
+ private String email;
+}
diff --git a/backend/src/main/java/com/ssafy/salmanhae/model/dto/auth/EmailVerifyRequest.java b/backend/src/main/java/com/ssafy/salmanhae/model/dto/auth/EmailVerifyRequest.java
new file mode 100644
index 0000000..1e5be16
--- /dev/null
+++ b/backend/src/main/java/com/ssafy/salmanhae/model/dto/auth/EmailVerifyRequest.java
@@ -0,0 +1,17 @@
+package com.ssafy.salmanhae.model.dto.auth;
+
+import jakarta.validation.constraints.Email;
+import jakarta.validation.constraints.NotBlank;
+import jakarta.validation.constraints.Pattern;
+import lombok.Getter;
+
+@Getter
+public class EmailVerifyRequest {
+ @NotBlank
+ @Email
+ private String email;
+
+ @NotBlank
+ @Pattern(regexp = "^\\d{6}$")
+ private String code;
+}
diff --git a/backend/src/main/java/com/ssafy/salmanhae/model/dto/auth/RefreshRequest.java b/backend/src/main/java/com/ssafy/salmanhae/model/dto/auth/RefreshRequest.java
new file mode 100644
index 0000000..7d0e474
--- /dev/null
+++ b/backend/src/main/java/com/ssafy/salmanhae/model/dto/auth/RefreshRequest.java
@@ -0,0 +1,10 @@
+package com.ssafy.salmanhae.model.dto.auth;
+
+import jakarta.validation.constraints.NotBlank;
+import lombok.Getter;
+
+@Getter
+public class RefreshRequest {
+ @NotBlank
+ private String refreshToken;
+}
diff --git a/backend/src/main/java/com/ssafy/salmanhae/model/dto/auth/RefreshResponse.java b/backend/src/main/java/com/ssafy/salmanhae/model/dto/auth/RefreshResponse.java
new file mode 100644
index 0000000..0462d78
--- /dev/null
+++ b/backend/src/main/java/com/ssafy/salmanhae/model/dto/auth/RefreshResponse.java
@@ -0,0 +1,11 @@
+package com.ssafy.salmanhae.model.dto.auth;
+
+import lombok.AllArgsConstructor;
+import lombok.Getter;
+
+@Getter
+@AllArgsConstructor
+public class RefreshResponse {
+ private String accessToken;
+ private String refreshToken;
+}
diff --git a/backend/src/main/java/com/ssafy/salmanhae/service/auth/AuthService.java b/backend/src/main/java/com/ssafy/salmanhae/service/auth/AuthService.java
index 6bc82cc..154b025 100644
--- a/backend/src/main/java/com/ssafy/salmanhae/service/auth/AuthService.java
+++ b/backend/src/main/java/com/ssafy/salmanhae/service/auth/AuthService.java
@@ -4,12 +4,19 @@
import com.ssafy.salmanhae.common.exception.ErrorCode;
import com.ssafy.salmanhae.model.dao.auth.UserDao;
import com.ssafy.salmanhae.model.dto.auth.LoginResponse;
+import com.ssafy.salmanhae.model.dto.auth.RefreshResponse;
import com.ssafy.salmanhae.model.dto.auth.User;
import com.ssafy.salmanhae.util.JwtUtil;
+import io.jsonwebtoken.ExpiredJwtException;
+import io.jsonwebtoken.JwtException;
import lombok.RequiredArgsConstructor;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service;
+import java.time.Duration;
+
@Service
@RequiredArgsConstructor
public class AuthService {
@@ -17,8 +24,18 @@ public class AuthService {
private final UserDao userDao;
private final PasswordEncoder passwordEncoder;
private final JwtUtil jwtUtil;
+ private final StringRedisTemplate redisTemplate;
+
+ @Value("${jwt.refresh-token-expiration}")
+ private long refreshTokenExpiration;
+
+ private static final String REFRESH_PREFIX = "refresh:";
public void signup(String email, String password, String nickname) {
+ if (!Boolean.TRUE.toString().equals(redisTemplate.opsForValue().get("email:verified:" + email))) {
+ throw new ApiException(ErrorCode.EMAIL_NOT_VERIFIED);
+ }
+
User user = User.builder()
.email(email)
.password(passwordEncoder.encode(password))
@@ -33,9 +50,36 @@ public LoginResponse login(String email, String password) {
throw new ApiException(ErrorCode.UNAUTHORIZED);
}
- return new LoginResponse(
- jwtUtil.generateAccessToken(email),
- jwtUtil.generateRefreshToken(email)
- );
+ String accessToken = jwtUtil.generateAccessToken(email);
+ String refreshToken = jwtUtil.generateRefreshToken(email);
+ redisTemplate.opsForValue().set(REFRESH_PREFIX + email, refreshToken, Duration.ofMillis(refreshTokenExpiration));
+
+ return new LoginResponse(accessToken, refreshToken);
+ }
+
+ public RefreshResponse refresh(String refreshToken) {
+ String email;
+ try {
+ email = jwtUtil.getEmail(refreshToken);
+ } catch (ExpiredJwtException e) {
+ throw new ApiException(ErrorCode.EXPIRED_TOKEN);
+ } catch (JwtException | IllegalArgumentException e) {
+ throw new ApiException(ErrorCode.INVALID_TOKEN);
+ }
+
+ String stored = redisTemplate.opsForValue().get(REFRESH_PREFIX + email);
+ if (!refreshToken.equals(stored)) {
+ throw new ApiException(ErrorCode.INVALID_TOKEN);
+ }
+
+ String newAccessToken = jwtUtil.generateAccessToken(email);
+ String newRefreshToken = jwtUtil.generateRefreshToken(email);
+ redisTemplate.opsForValue().set(REFRESH_PREFIX + email, newRefreshToken, Duration.ofMillis(refreshTokenExpiration));
+
+ return new RefreshResponse(newAccessToken, newRefreshToken);
+ }
+
+ public void logout(String email) {
+ redisTemplate.delete(REFRESH_PREFIX + email);
}
-}
+}
\ No newline at end of file
diff --git a/backend/src/main/java/com/ssafy/salmanhae/service/auth/EmailVerificationService.java b/backend/src/main/java/com/ssafy/salmanhae/service/auth/EmailVerificationService.java
new file mode 100644
index 0000000..fb1227a
--- /dev/null
+++ b/backend/src/main/java/com/ssafy/salmanhae/service/auth/EmailVerificationService.java
@@ -0,0 +1,50 @@
+package com.ssafy.salmanhae.service.auth;
+
+import com.ssafy.salmanhae.common.exception.ApiException;
+import com.ssafy.salmanhae.common.exception.ErrorCode;
+import lombok.RequiredArgsConstructor;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.data.redis.core.StringRedisTemplate;
+import org.springframework.mail.SimpleMailMessage;
+import org.springframework.mail.javamail.JavaMailSender;
+import org.springframework.stereotype.Service;
+
+import java.security.SecureRandom;
+import java.time.Duration;
+
+@Service
+@RequiredArgsConstructor
+public class EmailVerificationService {
+
+ private final StringRedisTemplate redisTemplate;
+ private final JavaMailSender mailSender;
+
+ @Value("${spring.mail.username}")
+ private String senderEmail;
+
+ private static final String VERIFY_PREFIX = "email:verify:";
+ private static final String VERIFIED_PREFIX = "email:verified:";
+
+ public void sendCode(String email) {
+ String code = String.format("%06d", new SecureRandom().nextInt(1_000_000));
+ redisTemplate.opsForValue().set(VERIFY_PREFIX + email, code, Duration.ofMinutes(5));
+
+ SimpleMailMessage message = new SimpleMailMessage();
+ message.setFrom(senderEmail);
+ message.setTo(email);
+ message.setSubject("[살만해] 이메일 인증 코드");
+ message.setText("인증 코드: " + code + "\n5분 내에 입력해주세요.");
+ mailSender.send(message);
+ }
+
+ public void verifyCode(String email, String code) {
+ String saved = redisTemplate.opsForValue().get(VERIFY_PREFIX + email);
+
+ if (saved == null || !saved.equals(code)) {
+ throw new ApiException(ErrorCode.INVALID_VERIFICATION_CODE);
+ }
+
+ redisTemplate.delete(VERIFY_PREFIX + email);
+ redisTemplate.opsForValue().set(VERIFIED_PREFIX + email, "true", Duration.ofMinutes(10));
+ }
+}
diff --git a/backend/src/main/resources/application.properties b/backend/src/main/resources/application.properties
index 2c4be5e..9e94ce0 100644
--- a/backend/src/main/resources/application.properties
+++ b/backend/src/main/resources/application.properties
@@ -1,8 +1,11 @@
spring.application.name=salmanhae
-spring.profiles.active=local
# DB
+# Required: SUPABASE_DB_URL, SUPABASE_DB_USERNAME, SUPABASE_DB_PASSWORD
spring.datasource.driver-class-name=org.postgresql.Driver
+spring.datasource.url=${SUPABASE_DB_URL}
+spring.datasource.username=${SUPABASE_DB_USERNAME}
+spring.datasource.password=${SUPABASE_DB_PASSWORD}
# MyBatis
mybatis.mapper-locations=classpath:mappers/**/*.xml
@@ -10,16 +13,24 @@ mybatis.type-aliases-package=com.ssafy.salmanhae.model.dto
mybatis.configuration.map-underscore-to-camel-case=true
# JWT
+jwt.secret=${JWT_SECRET}
jwt.access-token-expiration=900000
jwt.refresh-token-expiration=604800000
-# Required environment variables:
-# SUPABASE_DB_URL - PostgreSQL JDBC connection string
-# SUPABASE_DB_USERNAME - database username
-# SUPABASE_DB_PASSWORD - database password
-spring.datasource.url=${SUPABASE_DB_URL:}
-spring.datasource.username=${SUPABASE_DB_USERNAME:}
-spring.datasource.password=${SUPABASE_DB_PASSWORD:}
-spring.datasource.driver-class-name=org.postgresql.Driver
+
+# CORS
+app.cors.allowed-origins=${APP_CORS_ALLOWED_ORIGINS:http://localhost:5173}
+
+# Redis
+spring.data.redis.host=${REDIS_HOST:localhost}
+spring.data.redis.port=${REDIS_PORT:6379}
+
+# Mail
+spring.mail.host=smtp.gmail.com
+spring.mail.port=587
+spring.mail.username=${MAIL_USERNAME}
+spring.mail.password=${MAIL_PASSWORD}
+spring.mail.properties.mail.smtp.auth=true
+spring.mail.properties.mail.smtp.starttls.enable=true
# Backend AI internal API
ai.agent.base-url=${AI_AGENT_BASE_URL:http://localhost:8000}
diff --git a/backend/src/test/java/com/ssafy/salmanhae/controller/auth/AuthControllerTest.java b/backend/src/test/java/com/ssafy/salmanhae/controller/auth/AuthControllerTest.java
index 2ef1cf3..f1e49d8 100644
--- a/backend/src/test/java/com/ssafy/salmanhae/controller/auth/AuthControllerTest.java
+++ b/backend/src/test/java/com/ssafy/salmanhae/controller/auth/AuthControllerTest.java
@@ -1,17 +1,26 @@
package com.ssafy.salmanhae.controller.auth;
import static org.hamcrest.Matchers.nullValue;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest;
+import org.springframework.boot.test.mock.mockito.MockBean;
+import org.springframework.data.redis.core.StringRedisTemplate;
+import org.springframework.data.redis.core.ValueOperations;
import org.springframework.http.MediaType;
+import org.springframework.mail.javamail.JavaMailSender;
import org.springframework.test.context.ActiveProfiles;
import org.springframework.test.web.servlet.MockMvc;
+import org.springframework.test.web.servlet.MvcResult;
import org.springframework.transaction.annotation.Transactional;
@SpringBootTest
@@ -23,12 +32,36 @@ class AuthControllerTest {
@Autowired
private MockMvc mockMvc;
+ @Autowired
+ private ObjectMapper objectMapper;
+
+ @MockBean
+ private StringRedisTemplate redisTemplate;
+
+ @MockBean
+ private JavaMailSender mailSender;
+
+ private ValueOperations valueOps;
+
private static final String EMAIL = "test@example.com";
private static final String PASSWORD = "password123";
private static final String NICKNAME = "테스터";
+ @BeforeEach
+ @SuppressWarnings("unchecked")
+ void setUp() {
+ valueOps = mock(ValueOperations.class);
+ when(redisTemplate.opsForValue()).thenReturn(valueOps);
+ }
+
+ private void setEmailVerified(String email) {
+ when(valueOps.get("email:verified:" + email)).thenReturn("true");
+ }
+
@Test
void signupReturnsOk() throws Exception {
+ setEmailVerified(EMAIL);
+
mockMvc.perform(post("/api/v1/auth/signup")
.contentType(MediaType.APPLICATION_JSON)
.content("""
@@ -39,8 +72,20 @@ void signupReturnsOk() throws Exception {
.andExpect(jsonPath("$.message").value("OK"));
}
+ @Test
+ void signupFailsWhenEmailNotVerified() throws Exception {
+ mockMvc.perform(post("/api/v1/auth/signup")
+ .contentType(MediaType.APPLICATION_JSON)
+ .content("""
+ {"email": "%s", "password": "%s", "nickname": "%s"}
+ """.formatted(EMAIL, PASSWORD, NICKNAME)))
+ .andExpect(status().isForbidden())
+ .andExpect(jsonPath("$.code").value("EMAIL_NOT_VERIFIED"));
+ }
+
@Test
void loginReturnsAccessTokenAndRefreshToken() throws Exception {
+ setEmailVerified(EMAIL);
signup(EMAIL, PASSWORD, NICKNAME);
mockMvc.perform(post("/api/v1/auth/login")
@@ -65,6 +110,45 @@ void loginRejectsInvalidCredentials() throws Exception {
.andExpect(jsonPath("$.code").value("UNAUTHORIZED"));
}
+ @Test
+ void refreshReturnsNewTokens() throws Exception {
+ setEmailVerified(EMAIL);
+ signup(EMAIL, PASSWORD, NICKNAME);
+
+ MvcResult loginResult = mockMvc.perform(post("/api/v1/auth/login")
+ .contentType(MediaType.APPLICATION_JSON)
+ .content("""
+ {"email": "%s", "password": "%s"}
+ """.formatted(EMAIL, PASSWORD)))
+ .andExpect(status().isOk())
+ .andReturn();
+
+ String refreshToken = objectMapper.readTree(loginResult.getResponse().getContentAsString())
+ .path("data").path("refreshToken").asText();
+
+ when(valueOps.get("refresh:" + EMAIL)).thenReturn(refreshToken);
+
+ mockMvc.perform(post("/api/v1/auth/refresh")
+ .contentType(MediaType.APPLICATION_JSON)
+ .content("""
+ {"refreshToken": "%s"}
+ """.formatted(refreshToken)))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.data.accessToken").isNotEmpty())
+ .andExpect(jsonPath("$.data.refreshToken").isNotEmpty());
+ }
+
+ @Test
+ void refreshFailsWithInvalidToken() throws Exception {
+ mockMvc.perform(post("/api/v1/auth/refresh")
+ .contentType(MediaType.APPLICATION_JSON)
+ .content("""
+ {"refreshToken": "invalid.token.value"}
+ """))
+ .andExpect(status().isUnauthorized())
+ .andExpect(jsonPath("$.code").value("INVALID_TOKEN"));
+ }
+
private void signup(String email, String password, String nickname) throws Exception {
mockMvc.perform(post("/api/v1/auth/signup")
.contentType(MediaType.APPLICATION_JSON)
diff --git a/backend/src/test/java/com/ssafy/salmanhae/controller/auth/AuthHttpIntegrationTest.java b/backend/src/test/java/com/ssafy/salmanhae/controller/auth/AuthHttpIntegrationTest.java
index 7a19df8..9ede5ed 100644
--- a/backend/src/test/java/com/ssafy/salmanhae/controller/auth/AuthHttpIntegrationTest.java
+++ b/backend/src/test/java/com/ssafy/salmanhae/controller/auth/AuthHttpIntegrationTest.java
@@ -1,18 +1,25 @@
package com.ssafy.salmanhae.controller.auth;
import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
import java.util.Map;
+import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
+import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.boot.test.web.client.TestRestTemplate;
+import org.springframework.data.redis.core.StringRedisTemplate;
+import org.springframework.data.redis.core.ValueOperations;
import org.springframework.http.HttpEntity;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
+import org.springframework.mail.javamail.JavaMailSender;
import org.springframework.test.context.ActiveProfiles;
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@@ -22,8 +29,25 @@ class AuthHttpIntegrationTest {
@Autowired
private TestRestTemplate restTemplate;
+ @MockBean
+ private StringRedisTemplate redisTemplate;
+
+ @MockBean
+ private JavaMailSender mailSender;
+
+ private ValueOperations valueOps;
+
+ @BeforeEach
+ @SuppressWarnings("unchecked")
+ void setUp() {
+ valueOps = mock(ValueOperations.class);
+ when(redisTemplate.opsForValue()).thenReturn(valueOps);
+ }
+
@Test
void signupLoginFlow() {
+ when(valueOps.get("email:verified:flow@example.com")).thenReturn("true");
+
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_JSON);
diff --git a/backend/src/test/java/com/ssafy/salmanhae/service/auth/AuthServiceTest.java b/backend/src/test/java/com/ssafy/salmanhae/service/auth/AuthServiceTest.java
new file mode 100644
index 0000000..aa44325
--- /dev/null
+++ b/backend/src/test/java/com/ssafy/salmanhae/service/auth/AuthServiceTest.java
@@ -0,0 +1,109 @@
+package com.ssafy.salmanhae.service.auth;
+
+import com.ssafy.salmanhae.common.exception.ApiException;
+import com.ssafy.salmanhae.common.exception.ErrorCode;
+import com.ssafy.salmanhae.model.dao.auth.UserDao;
+import com.ssafy.salmanhae.model.dto.auth.RefreshResponse;
+import com.ssafy.salmanhae.model.dto.auth.User;
+import com.ssafy.salmanhae.util.JwtUtil;
+import io.jsonwebtoken.ExpiredJwtException;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.InjectMocks;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+import org.springframework.data.redis.core.StringRedisTemplate;
+import org.springframework.data.redis.core.ValueOperations;
+import org.springframework.security.crypto.password.PasswordEncoder;
+import org.springframework.test.util.ReflectionTestUtils;
+
+import java.time.Duration;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.*;
+import org.mockito.quality.Strictness;
+import org.mockito.junit.jupiter.MockitoSettings;
+
+@ExtendWith(MockitoExtension.class)
+@MockitoSettings(strictness = Strictness.LENIENT)
+class AuthServiceTest {
+
+ @Mock private UserDao userDao;
+ @Mock private PasswordEncoder passwordEncoder;
+ @Mock private JwtUtil jwtUtil;
+ @Mock private StringRedisTemplate redisTemplate;
+ @Mock private ValueOperations valueOps;
+
+ @InjectMocks
+ private AuthService authService;
+
+ private static final String EMAIL = "test@example.com";
+ private static final String REFRESH_TOKEN = "refresh-token";
+ private static final String NEW_ACCESS_TOKEN = "new-access-token";
+ private static final String NEW_REFRESH_TOKEN = "new-refresh-token";
+ private static final long REFRESH_EXPIRATION = 604800000L;
+
+ @BeforeEach
+ void setUp() {
+ when(redisTemplate.opsForValue()).thenReturn(valueOps);
+ ReflectionTestUtils.setField(authService, "refreshTokenExpiration", REFRESH_EXPIRATION);
+ }
+
+ @Test
+ void login_savesRefreshTokenToRedis() {
+ User user = User.builder().email(EMAIL).password("encoded").nickname("테스터").build();
+ when(userDao.findByEmail(EMAIL)).thenReturn(user);
+ when(passwordEncoder.matches("password", "encoded")).thenReturn(true);
+ when(jwtUtil.generateAccessToken(EMAIL)).thenReturn("access-token");
+ when(jwtUtil.generateRefreshToken(EMAIL)).thenReturn(REFRESH_TOKEN);
+
+ authService.login(EMAIL, "password");
+
+ verify(valueOps).set(eq("refresh:" + EMAIL), eq(REFRESH_TOKEN), eq(Duration.ofMillis(REFRESH_EXPIRATION)));
+ }
+
+ @Test
+ void refresh_validToken_rotatesTokens() {
+ when(jwtUtil.getEmail(REFRESH_TOKEN)).thenReturn(EMAIL);
+ when(valueOps.get("refresh:" + EMAIL)).thenReturn(REFRESH_TOKEN);
+ when(jwtUtil.generateAccessToken(EMAIL)).thenReturn(NEW_ACCESS_TOKEN);
+ when(jwtUtil.generateRefreshToken(EMAIL)).thenReturn(NEW_REFRESH_TOKEN);
+
+ RefreshResponse response = authService.refresh(REFRESH_TOKEN);
+
+ assertThat(response.getAccessToken()).isEqualTo(NEW_ACCESS_TOKEN);
+ assertThat(response.getRefreshToken()).isEqualTo(NEW_REFRESH_TOKEN);
+ verify(valueOps).set(eq("refresh:" + EMAIL), eq(NEW_REFRESH_TOKEN), eq(Duration.ofMillis(REFRESH_EXPIRATION)));
+ }
+
+ @Test
+ void refresh_tokenNotInRedis_throwsException() {
+ when(jwtUtil.getEmail(REFRESH_TOKEN)).thenReturn(EMAIL);
+ when(valueOps.get("refresh:" + EMAIL)).thenReturn(null);
+
+ assertThatThrownBy(() -> authService.refresh(REFRESH_TOKEN))
+ .isInstanceOf(ApiException.class)
+ .satisfies(e -> assertThat(((ApiException) e).getErrorCode())
+ .isEqualTo(ErrorCode.INVALID_TOKEN));
+ }
+
+ @Test
+ void refresh_expiredToken_throwsException() {
+ when(jwtUtil.getEmail(REFRESH_TOKEN)).thenThrow(ExpiredJwtException.class);
+
+ assertThatThrownBy(() -> authService.refresh(REFRESH_TOKEN))
+ .isInstanceOf(ApiException.class)
+ .satisfies(e -> assertThat(((ApiException) e).getErrorCode())
+ .isEqualTo(ErrorCode.EXPIRED_TOKEN));
+ }
+
+ @Test
+ void logout_deletesRefreshTokenFromRedis() {
+ authService.logout(EMAIL);
+
+ verify(redisTemplate).delete("refresh:" + EMAIL);
+ }
+}
diff --git a/backend/src/test/java/com/ssafy/salmanhae/service/auth/EmailVerificationServiceTest.java b/backend/src/test/java/com/ssafy/salmanhae/service/auth/EmailVerificationServiceTest.java
new file mode 100644
index 0000000..b4f034c
--- /dev/null
+++ b/backend/src/test/java/com/ssafy/salmanhae/service/auth/EmailVerificationServiceTest.java
@@ -0,0 +1,84 @@
+package com.ssafy.salmanhae.service.auth;
+
+import com.ssafy.salmanhae.common.exception.ApiException;
+import com.ssafy.salmanhae.common.exception.ErrorCode;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.InjectMocks;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+import org.springframework.data.redis.core.StringRedisTemplate;
+import org.springframework.data.redis.core.ValueOperations;
+import org.springframework.mail.SimpleMailMessage;
+import org.springframework.mail.javamail.JavaMailSender;
+import org.springframework.test.util.ReflectionTestUtils;
+
+import java.time.Duration;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+import static org.mockito.ArgumentMatchers.*;
+import static org.mockito.Mockito.*;
+
+@ExtendWith(MockitoExtension.class)
+class EmailVerificationServiceTest {
+
+ @Mock
+ private StringRedisTemplate redisTemplate;
+
+ @Mock
+ private JavaMailSender mailSender;
+
+ @Mock
+ private ValueOperations valueOps;
+
+ @InjectMocks
+ private EmailVerificationService emailVerificationService;
+
+ private static final String EMAIL = "test@example.com";
+
+ @BeforeEach
+ void setUp() {
+ when(redisTemplate.opsForValue()).thenReturn(valueOps);
+ ReflectionTestUtils.setField(emailVerificationService, "senderEmail", "sender@gmail.com");
+ }
+
+ @Test
+ void sendCode_savesCodeToRedisAndSendsEmail() {
+ emailVerificationService.sendCode(EMAIL);
+
+ verify(valueOps).set(eq("email:verify:" + EMAIL), anyString(), eq(Duration.ofMinutes(5)));
+ verify(mailSender).send(any(SimpleMailMessage.class));
+ }
+
+ @Test
+ void verifyCode_validCode_savesVerifiedKey() {
+ when(valueOps.get("email:verify:" + EMAIL)).thenReturn("123456");
+
+ emailVerificationService.verifyCode(EMAIL, "123456");
+
+ verify(redisTemplate).delete("email:verify:" + EMAIL);
+ verify(valueOps).set(eq("email:verified:" + EMAIL), eq("true"), eq(Duration.ofMinutes(10)));
+ }
+
+ @Test
+ void verifyCode_invalidCode_throwsException() {
+ when(valueOps.get("email:verify:" + EMAIL)).thenReturn("123456");
+
+ assertThatThrownBy(() -> emailVerificationService.verifyCode(EMAIL, "999999"))
+ .isInstanceOf(ApiException.class)
+ .satisfies(e -> assertThat(((ApiException) e).getErrorCode())
+ .isEqualTo(ErrorCode.INVALID_VERIFICATION_CODE));
+ }
+
+ @Test
+ void verifyCode_codeNotFound_throwsException() {
+ when(valueOps.get("email:verify:" + EMAIL)).thenReturn(null);
+
+ assertThatThrownBy(() -> emailVerificationService.verifyCode(EMAIL, "123456"))
+ .isInstanceOf(ApiException.class)
+ .satisfies(e -> assertThat(((ApiException) e).getErrorCode())
+ .isEqualTo(ErrorCode.INVALID_VERIFICATION_CODE));
+ }
+}
diff --git a/backend/src/test/resources/application-test.properties b/backend/src/test/resources/application-test.properties
index 56eaae2..3192124 100644
--- a/backend/src/test/resources/application-test.properties
+++ b/backend/src/test/resources/application-test.properties
@@ -6,6 +6,10 @@ spring.sql.init.mode=always
jwt.secret=salmanhae-test-secret-key-32bytes!!
jwt.access-token-expiration=900000
jwt.refresh-token-expiration=604800000
+spring.mail.host=localhost
+spring.mail.port=25
+spring.mail.username=test@test.com
+spring.mail.password=test
ai.agent.base-url=http://localhost:8000
ai.agent.internal-api-key=change-me
ai.agent.connect-timeout-ms=2000
diff --git a/docs/02_ARCHITECTURE.md b/docs/02_ARCHITECTURE.md
index cac70af..5397d5d 100644
--- a/docs/02_ARCHITECTURE.md
+++ b/docs/02_ARCHITECTURE.md
@@ -46,10 +46,10 @@ salmanhae/
│ │ │ ├── auth/ # UserDao (MyBatis @Mapper)
│ │ │ └── property/ # PropertyDao
│ │ └── dto/
-│ │ ├── auth/ # User (implements UserDetails), LoginRequest, LoginResponse, SignupRequest
+│ │ ├── auth/ # User (implements UserDetails), LoginRequest, LoginResponse, SignupRequest, RefreshRequest, RefreshResponse, EmailSendRequest, EmailVerifyRequest
│ │ └── property/ # PropertyRow, PropertySummaryResponse, PropertyDetailResponse 등
│ ├── service/
-│ │ ├── auth/ # AuthService, CustomUserDetailsService
+│ │ ├── auth/ # AuthService, CustomUserDetailsService, EmailVerificationService
│ │ └── property/ # PropertyService, PropertyServiceImpl
│ ├── util/ # JwtUtil
│ └── batch/ # Spring Scheduler 배치 작업 (예정)
@@ -72,8 +72,9 @@ salmanhae/
### Spring Boot (Cloud Run)
-- 회원가입/로그인/로그아웃 API (Spring Security 자체 구현)
-- JWT 발급 및 검증 (Spring Security Filter)
+- 회원가입/로그인/로그아웃/토큰 갱신 API (Spring Security 자체 구현)
+- 회원가입 전 이메일 인증 (Gmail SMTP + Redis TTL 5분)
+- JWT 발급 및 검증 (Spring Security Filter), 리프레시 토큰 Redis 저장 및 Token Rotation
- 공공데이터 배치 수집 → PostgreSQL 저장
- F-1 MVP 샘플 매물 및 운영 매물 데이터 저장/조회 API 제공
- 실거래가 건물 anchor 지오코딩 결과를 DB에 저장하고 지도 API에서는 저장 좌표만 조회
@@ -91,6 +92,13 @@ salmanhae/
- `analyze_safety` → Spring Boot API 호출
- Claude API로 최종 자연어 응답 생성
+### Redis
+
+- 이메일 인증 코드 (`email:verify:{email}`, TTL 5분)
+- 이메일 인증 완료 플래그 (`email:verified:{email}`, TTL 10분)
+- 리프레시 토큰 (`refresh:{email}`, TTL 7일)
+- 로컬 개발: `localhost:6379` / 운영: 환경변수 `REDIS_HOST`, `REDIS_PORT`
+
### Supabase
- DB (PostgreSQL): 매물, 실거래가, 안전시설, 찜하기, 대화 기록, 사용자
diff --git a/docs/03_ADR.md b/docs/03_ADR.md
index be0f007..ff448ec 100644
--- a/docs/03_ADR.md
+++ b/docs/03_ADR.md
@@ -23,7 +23,7 @@
### ADR-005: 인증을 Spring Security + JWT로 자체 구현
**결정**: 회원가입·로그인은 Spring Security가 직접 처리하고, JWT로 액세스 토큰·리프레시 토큰을 발급한다. 이후 모든 요청에서 Spring Security JWT 필터가 토큰을 검증한다.
**이유**: 외부 Auth 서비스 의존성 없이 토큰 정책(만료 시간, 클레임 구조)을 프로젝트 내에서 완전히 제어 가능하고 장애 지점이 줄어든다.
-**트레이드오프**: 비밀번호 해싱(BCrypt), 토큰 발급·검증 로직을 직접 관리해야 한다. 리프레시 토큰 무효화는 DB 저장 또는 별도 블랙리스트로 처리한다.
+**트레이드오프**: 비밀번호 해싱(BCrypt), 토큰 발급·검증 로직을 직접 관리해야 한다. 리프레시 토큰은 Redis에 저장하며 Token Rotation 방식으로 관리한다 (ADR-011 참고).
### ADR-006: LangGraph 의도 분류를 단일 노드에서 처리
**결정**: 사용자 입력을 4가지 의도(매물 추천/법률 상담/시세 분석/안전 분석)로 분류하는 노드를 LangGraph 그래프 진입점에 배치하고, 의도에 따라 엣지가 분기된다.
@@ -45,6 +45,11 @@
**이유**: WMS는 프론트 지도 렌더링, 레이어 정합성, 범례 해석, 성능 검증 범위가 커서 MVP 일정 리스크가 높다.
**트레이드오프**: 범죄주의구간 같은 면 단위 위험 정보는 초기 버전에서 제공하지 못한다.
+### ADR-011: 이메일 인증 코드와 리프레시 토큰을 Redis로 관리한다
+**결정**: 이메일 인증 코드(`email:verify:{email}`, TTL 5분), 인증 완료 플래그(`email:verified:{email}`, TTL 10분), 리프레시 토큰(`refresh:{email}`, TTL 7일)을 모두 Redis에 저장한다. 리프레시 토큰 갱신 시 Token Rotation(새 액세스 + 새 리프레시 동시 발급)을 적용한다.
+**이유**: 이메일 인증 코드는 단기 TTL과 자동 삭제가 핵심이라 RDB보다 Redis가 적합하다. 리프레시 토큰을 Redis에 저장하면 로그아웃 시 즉시 무효화가 가능하고, Token Rotation으로 탈취된 토큰 재사용을 탐지할 수 있다.
+**트레이드오프**: Redis가 로컬 인프라에 추가된다. Redis 장애 시 로그인·회원가입 불가. 로컬 개발 환경에서 Redis 실행이 필수(`redis-server` 또는 Docker).
+
### ADR-010: 지도 줌 레벨별 표시 데이터를 서버에서 결정한다
**결정**: 프론트는 네이버지도 bounds와 zoom을 Spring Boot에 전달하고, 서버는 시/도·시/군/구·읍/면/동 평균 또는 매물/클러스터 데이터를 선택해 반환한다.
**이유**: 지도 표시 정책과 집계 기준을 백엔드에서 일관 관리하면 프론트 구현이 단순해지고, 실거래가 평균 계산을 DB 캐시와 함께 최적화할 수 있다.
diff --git a/docs/08_API_SPEC.md b/docs/08_API_SPEC.md
index 6728f14..d86ad6d 100644
--- a/docs/08_API_SPEC.md
+++ b/docs/08_API_SPEC.md
@@ -52,8 +52,12 @@
| --- | --- | --- |
| 400 | `INVALID_REQUEST` | 요청 파라미터 누락 또는 형식 오류 |
| 400 | `INVALID_BOUNDS` | 지도 범위 파라미터 오류 (west/east/south/north) |
-| 401 | `UNAUTHORIZED` | 인증 토큰 없음 또는 만료 |
+| 400 | `INVALID_VERIFICATION_CODE` | 이메일 인증 코드가 올바르지 않음 |
+| 401 | `UNAUTHORIZED` | 인증 토큰 없음 또는 자격증명 불일치 |
+| 401 | `INVALID_TOKEN` | 서명 검증 실패 또는 Redis 불일치 리프레시 토큰 |
+| 401 | `EXPIRED_TOKEN` | 만료된 리프레시 토큰 |
| 403 | `FORBIDDEN` | 권한 없음 (다른 사용자 리소스 접근 등) |
+| 403 | `EMAIL_NOT_VERIFIED` | 이메일 인증 미완료 상태에서 회원가입 시도 |
| 404 | `PROPERTY_NOT_FOUND` | 매물 없음 |
| 404 | `WISHLIST_NOT_FOUND` | 찜 항목 없음 |
| 404 | `SESSION_NOT_FOUND` | 대화 세션 없음 |
@@ -407,6 +411,33 @@ Authorization: Bearer {token}
Spring Boot가 Spring Security + JJWT로 자체 구현한 인증 API입니다. 프론트엔드는 이 API만 호출하며, 외부 Auth 서비스를 직접 호출하지 않습니다.
+회원가입 전 이메일 인증이 필수입니다. 인증 코드는 Gmail SMTP로 발송되고 Redis에 5분간 보관됩니다.
+
+### 이메일 인증 코드 발송
+```http
+POST /api/v1/auth/email/send
+```
+```json
+{ "email": "user@example.com" }
+```
+**Response**
+```json
+{ "data": null, "message": "OK" }
+```
+
+### 이메일 인증 코드 검증
+```http
+POST /api/v1/auth/email/verify
+```
+```json
+{ "email": "user@example.com", "code": "123456" }
+```
+**Response**
+```json
+{ "data": null, "message": "OK" }
+```
+**Error** — 코드 불일치 또는 만료 시: `400 INVALID_VERIFICATION_CODE`
+
### 회원가입
```http
POST /api/v1/auth/signup
@@ -414,13 +445,13 @@ POST /api/v1/auth/signup
```json
{ "email": "user@example.com", "password": "password123", "nickname": "홍길동" }
```
+이메일 인증(`/email/verify`) 완료 후 10분 이내에 호출해야 합니다.
+
**Response**
```json
-{
- "data": null,
- "message": "OK"
-}
+{ "data": null, "message": "OK" }
```
+**Error** — 인증 미완료 시: `403 EMAIL_NOT_VERIFIED`
### 로그인
```http
@@ -440,28 +471,38 @@ POST /api/v1/auth/login
}
```
-### 토큰 갱신
+### 토큰 갱신 (Token Rotation)
```http
POST /api/v1/auth/refresh
```
```json
{ "refreshToken": "jwt-refresh-token" }
```
+갱신 시 액세스 토큰과 리프레시 토큰을 **모두 새로 발급**합니다 (Token Rotation). 프론트는 두 토큰을 모두 교체해야 합니다.
+
**Response**
```json
{
"data": {
- "accessToken": "new-jwt-access-token"
+ "accessToken": "new-jwt-access-token",
+ "refreshToken": "new-jwt-refresh-token"
},
"message": "OK"
}
```
+**Error** — 서명 불일치·Redis 불일치: `401 INVALID_TOKEN` / 만료: `401 EXPIRED_TOKEN`
### 로그아웃
```http
POST /api/v1/auth/logout
Authorization: Bearer {token}
```
+Redis에서 리프레시 토큰을 삭제하여 이후 갱신을 차단합니다.
+
+**Response**
+```json
+{ "data": null, "message": "OK" }
+```
---
diff --git a/docs/10_SECURITY_POLICY.md b/docs/10_SECURITY_POLICY.md
index 44b2b89..2db5e4d 100644
--- a/docs/10_SECURITY_POLICY.md
+++ b/docs/10_SECURITY_POLICY.md
@@ -10,20 +10,73 @@ F-1(지도 탐색)과 지도 기반 시세·안전 분석 조회는 비로그인
## 인증 흐름
+### 회원가입 (이메일 인증 포함)
+
+```
+① POST /api/v1/auth/email/send (email)
+ → EmailVerificationService가 6자리 코드 생성 (SecureRandom)
+ → Redis에 email:verify:{email} = code 저장 (TTL 5분)
+ → Gmail SMTP로 인증 코드 발송
+
+② POST /api/v1/auth/email/verify (email + code)
+ → Redis에서 email:verify:{email} 조회 후 비교
+ → 일치하면 email:verified:{email} = "true" 저장 (TTL 10분)
+ → email:verify:{email} 키 삭제
+
+③ POST /api/v1/auth/signup (email + password + nickname)
+ → email:verified:{email} 키 확인 → 없으면 403 EMAIL_NOT_VERIFIED
+ → BCrypt로 비밀번호 해시 후 DB 저장
+```
+
+### 로그인 및 토큰 발급
+
```
-사용자 로그인 요청
-→ POST /api/v1/auth/login (email + password)
-→ Spring Security AuthenticationManager로 자격증명 검증
+POST /api/v1/auth/login (email + password)
→ BCrypt로 저장된 password_hash 비교
→ JWT로 액세스 토큰(15분) + 리프레시 토큰(7일) 발급
+→ Redis에 refresh:{email} = refreshToken 저장 (TTL 7일)
→ 프론트 Axios Interceptor에 저장
-→ API 요청 시 Authorization: Bearer {accessToken} 헤더 포함
+```
+
+### 인증된 요청
+
+```
+API 요청 시 Authorization: Bearer {accessToken} 헤더 포함
→ Spring Security JwtAuthenticationFilter에서 토큰 서명·만료 검증
→ CustomUserDetailsService.loadUserByUsername(email)로 User 객체 조회
→ UsernamePasswordAuthenticationToken으로 감싸 SecurityContext에 저장
→ 컨트롤러에서 @AuthenticationPrincipal User로 사용
```
+### 토큰 갱신 (Token Rotation)
+
+```
+POST /api/v1/auth/refresh (refreshToken)
+→ JWT 서명·만료 검증
+→ Redis refresh:{email} 값과 일치 여부 확인 (불일치 시 401)
+→ 새 액세스 토큰 + 새 리프레시 토큰 발급 (rotation)
+→ Redis refresh:{email} 갱신
+→ 두 토큰 모두 응답
+```
+
+### 로그아웃
+
+```
+POST /api/v1/auth/logout
+→ SecurityContext에서 email 추출
+→ Redis refresh:{email} 삭제 → 이후 해당 리프레시 토큰으로 갱신 불가
+```
+
+---
+
+## Redis 키 구조
+
+| 키 | 값 | TTL | 용도 |
+| --- | --- | --- | --- |
+| `email:verify:{email}` | 6자리 코드 | 5분 | 이메일 인증 코드 |
+| `email:verified:{email}` | `"true"` | 10분 | 인증 완료 상태 (회원가입 허용 플래그) |
+| `refresh:{email}` | JWT 리프레시 토큰 | 7일 | 리프레시 토큰 저장 (rotation·무효화) |
+
---
## 토큰 정책
@@ -33,8 +86,9 @@ F-1(지도 탐색)과 지도 기반 시세·안전 분석 조회는 비로그인
| 액세스 토큰 만료 | 15분 |
| 리프레시 토큰 만료 | 7일 |
| 서명 알고리즘 | HS256 (서버 시크릿 키) |
-| JWT 시크릿 키 | Spring 프로퍼티 `jwt.secret`으로 주입. 로컬: `application-local.properties`, 운영: 환경변수 `JWT_SECRET` (Spring relaxed binding으로 자동 매핑). 코드에 하드코딩 금지 |
-| 리프레시 토큰 저장 | MVP에서는 발급만 하고 DB에 저장하지 않음. 1.5차에서 `refresh_token` 테이블 추가 예정 |
+| JWT 시크릿 키 | Spring 프로퍼티 `jwt.secret`으로 주입. 로컬: `backend/.env`의 `JWT_SECRET`, 운영: 환경변수 `JWT_SECRET`. 코드에 하드코딩 금지 |
+| 리프레시 토큰 저장 | Redis (`refresh:{email}`, TTL 7일). 갱신 시 rotate 발급. 로그아웃 시 삭제로 무효화. |
+| 이메일 인증 | 회원가입 전 필수. Gmail SMTP + Redis로 6자리 코드 검증 (TTL 5분). |
---
@@ -49,9 +103,11 @@ F-1(지도 탐색)과 지도 기반 시세·안전 분석 조회는 비로그인
| `GET /api/v1/properties/{id}/safety-summary` | 안전 요약은 공개 데이터 기반 |
| `GET /api/v1/safety/facilities` | 안전시설은 공개 데이터 기반 |
| `GET /api/v1/price-analysis` | 지도·매물 상세에 포함되는 시세 분석 |
-| `POST /api/v1/auth/signup` | 회원가입 |
+| `POST /api/v1/auth/email/send` | 이메일 인증 코드 발송 |
+| `POST /api/v1/auth/email/verify` | 이메일 인증 코드 검증 |
+| `POST /api/v1/auth/signup` | 회원가입 (이메일 인증 필수) |
| `POST /api/v1/auth/login` | 로그인 |
-| `POST /api/v1/auth/refresh` | 토큰 갱신 (1.5차 구현 예정) |
+| `POST /api/v1/auth/refresh` | 토큰 갱신 (Token Rotation) |
---
@@ -60,7 +116,7 @@ F-1(지도 탐색)과 지도 기반 시세·안전 분석 조회는 비로그인
| API | 이유 |
| --- | --- |
| `POST /api/v1/chat` | AI 에이전트 (F-2~F-5) |
-| `POST /api/v1/auth/logout` | 로그아웃 (리프레시 토큰 무효화) |
+| `POST /api/v1/auth/logout` | 로그아웃 (Redis 리프레시 토큰 무효화) |
| `GET /api/v1/wishlist` | 찜 목록 조회 |
| `POST /api/v1/wishlist` | 찜하기 |
| `DELETE /api/v1/wishlist/{id}` | 찜 삭제 |
@@ -73,7 +129,8 @@ F-1(지도 탐색)과 지도 기반 시세·안전 분석 조회는 비로그인
- 백엔드는 클라이언트가 보낸 userId를 직접 신뢰하지 않습니다.
- 사용자 식별은 반드시 JWT 서명 검증 후 SecurityContext에서 가져옵니다.
-- 리프레시 토큰은 MVP에서 발급만 하며, 1.5차에서 DB 저장 및 로그아웃 시 무효화를 구현합니다.
+- 리프레시 토큰은 Redis에 저장하며, 갱신 시 rotate 발급하고 로그아웃 시 삭제합니다.
+- 이메일 인증 코드와 리프레시 토큰은 Redis TTL로 자동 만료합니다.
- 국토부, 생활안전지도, 재난안전, 크롤링 관련 키와 시크릿은 절대 프론트에 노출하지 않습니다.
- 네이버지도 SDK처럼 브라우저에서 직접 쓰는 공개 클라이언트 키는 도메인 제한을 걸고, 서버용 시크릿과 분리합니다.
- 비밀번호는 BCrypt로 해시하여 DB에 저장합니다. 평문 저장 금지.
diff --git a/frontend/.env.example b/frontend/.env.example
deleted file mode 100644
index 3d08556..0000000
--- a/frontend/.env.example
+++ /dev/null
@@ -1,5 +0,0 @@
-# Browser-safe Naver Maps SDK key. Do not put the secret here.
-VITE_NAVER_MAP_CLIENT_ID=
-
-# Spring Boot API base URL. Keep this pointing at the backend when verifying map markers.
-VITE_API_BASE_URL=http://localhost:8080