좋은 프로젝트예요! Java 백엔드 개발자로서 AI반 친구의 프로젝트를 이해하고 향후 협업이나 확장 아이디어를 떠올리는 데 도움이 되도록, 내용을 리마인드용으로 요약해드릴게요.
좋아요! 자바(Spring Boot) 백엔드와 Python 기반 BERT 고객이탈 예측 모델을 API 연동하는 전체 구조 흐름을 텍스트 기반 폴더 트리 + 데이터 흐름 설명으로 정리해드릴게요.
[React Frontend]
|
| (1) 상담 내용 전송 (POST /predict)
↓
[Spring Boot API 서버]
- REST 컨트롤러
- BERT Python 서버 호출 (HTTP)
|
| (2) 예측 요청 (Python Flask or FastAPI 서버)
↓
[BERT 모델 서버]
- 예측 처리
- 결과 반환 (예: {"churn": true, "prob": 0.91})
↑
| (3) 결과 응답
[Spring Boot API 서버]
↑
| (4) 예측 결과 저장 (DB)
|
[MySQL]
📦customer-churn-prediction-app
├── 📁 frontend-react
│ ├── 📁 src
│ │ └── 📄 PredictForm.jsx # 상담 입력 폼
│ │ └── 📄 ResultDisplay.jsx # 예측 결과 렌더링
│ └── 📄 package.json
│
├── 📁 backend-spring
│ ├── 📁 controller
│ │ └── 📄 ChurnPredictController.java # POST /predict
│ ├── 📁 service
│ │ └── 📄 ChurnPredictService.java # Python API 호출 로직
│ ├── 📁 model
│ │ └── 📄 ChurnRequest.java # 상담 데이터
│ │ └── 📄 ChurnResponse.java # 예측 결과 DTO
│ ├── 📁 repository
│ │ └── 📄 ChurnLogRepository.java # 결과 로그 저장용
│ └── 📄 Application.java
│
├── 📁 python-bert-server
│ ├── 📄 app.py # Flask 또는 FastAPI 예측 서버
│ ├── 📄 model_utils.py # BERT 예측 함수
│ └── 📄 requirements.txt
│
└── 📁 database
└── 📄 schema.sql # churn_log 테이블 등 정의
/predict)@PostMapping("/predict")
public ResponseEntity<ChurnResponse> predict(@RequestBody ChurnRequest request) {
ChurnResponse result = churnPredictService.predictChurn(request);
return ResponseEntity.ok(result);
}
public ChurnResponse predictChurn(ChurnRequest request) {
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_JSON);
HttpEntity<ChurnRequest> entity = new HttpEntity<>(request, headers);
String pythonApiUrl = "http://localhost:5000/predict";
ResponseEntity<ChurnResponse> response = restTemplate.postForEntity(pythonApiUrl, entity, ChurnResponse.class);
return response.getBody();
}
app.py)from fastapi import FastAPI
from pydantic import BaseModel
from model_utils import predict_churn
app = FastAPI()
class PredictInput(BaseModel):
text_input: str
@app.post<("/predict")
def predict(data: PredictInput):
result = predict_churn(data.text_input)
return result # 예: {"churn": True, "prob": 0.91}
churn_log)CREATE TABLE churn_log (
id INT AUTO_INCREMENT PRIMARY KEY,
text_input TEXT,
prediction_result BOOLEAN,
prediction_score DOUBLE,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
/predict필요하다면 👉 React + Spring 연동 예시, 도커 구성(docker-compose), JWT 인증 추가,
또는 관리자 대시보드 UI 설계까지 확장 도와드릴 수 있어요.
어떤 부분부터 먼저 구현해보고 싶으신가요?
| 단계 | 설명 |
|---|---|
| 1. 라이브러리 설치 | transformers 등 최신 설치 및 리로드 |
| 2. 데이터 불러오기 | 고객 이탈 여부(yes, no) + 상담 텍스트 |
| 3. 전처리 | yes → 1, no → 0으로 바꾸고 결측치 제거 |
| 4. 데이터 분할 | 학습용 vs 평가용 (train/test split) |
| 5. 토크나이징 | BERT의 토크나이저로 문장을 토큰화 |
| 6. Huggingface Dataset 변환 | 학습을 위해 맞는 형태로 변형 |
| 7. 모델 로딩 | bert-base-uncased, 출력은 이진 분류(0/1) |
| 8. Trainer 설정 | 학습 조건(batch size, epoch 수 등) 지정 |
| 9. 학습 수행 | .train() 메서드로 실제 모델 학습 |
| 10. 평가 | 예측 결과로부터 f1-score 등 지표 확인 |
| 11. 실시간 예측 함수 | 사용자가 입력한 문장을 실시간으로 예측하는 함수 구현 |
Customer: 환불하고 싶어요.
Agent: 환불 불가합니다.
Customer: 뭐 이런 곳이 다 있어?!
→ 결과: 이탈 가능성 높음 (높은 확률로 예측)
Customer: 더 싸게 안되나요?
Agent: 다음주 프로모션이 있어요.
Customer: 감사합니다~
→ 결과: 잔류 (이탈 가능성 낮음)
좋아요! 요청하신 순서대로 1) 도커 구성 → 2) JWT 인증 추가 → 3) 관리자 대시보드 UI 설계를
하나씩 완전 초보도 따라할 수 있게 자세히 설명드릴게요.
docker-compose 구성목표: React + Spring Boot + Python(FastAPI) + MySQL을 도커 컨테이너에서 한번에 실행
📦 project-root/
├── docker-compose.yml
├── frontend-react/
│ └── Dockerfile
├── backend-spring/
│ └── Dockerfile
├── python-bert-server/
│ └── Dockerfile
├── mysql/
│ └── init.sql
<version: '3.8'
services:
mysql:
image: mysql:8
container_name: mysql
restart: always
environment:
MYSQL_ROOT_PASSWORD: rootpass
MYSQL_DATABASE: churn_db
MYSQL_USER: churnuser
MYSQL_PASSWORD: churnpass
ports:
- "3306:3306"
volumes:
- ./mysql:/docker-entrypoint-initdb.d
backend:
build: ./backend-spring
container_name: spring-app
restart: always
ports:
- "8080:8080"
depends_on:
- mysql
environment:
SPRING_DATASOURCE_URL: jdbc:mysql://mysql:3306/churn_db
SPRING_DATASOURCE_USERNAME: churnuser
SPRING_DATASOURCE_PASSWORD: churnpass
bert-api:
build: ./python-bert-server
container_name: bert-api
restart: always
ports:
- "5000:5000"
frontend:
build: ./frontend-react
container_name: react-ui
restart: always
ports:
- "3000:3000"
FROM openjdk:17-jdk-slim
WORKDIR /app
COPY target/backend-spring-0.0.1-SNAPSHOT.jar app.jar
ENTRYPOINT ["java", "-jar", "app.jar"]
💡 Spring Boot는
mvn package로 JAR 생성 후/target/에 위치해야 함.
FROM python:3.10-slim
WORKDIR /app
COPY . .
RUN pip install --no-cache-dir -r requirements.txt
EXPOSE 5000
CMD ["uvicorn", "app:app", "--host", "0.0.0.0", "--port", "5000"]
FROM node:18
WORKDIR /app
COPY . .
RUN npm install
RUN npm run build
RUN npm install -g serve
EXPOSE 3000
CMD ["serve", "-s", "build"]
CREATE TABLE churn_log (
id INT AUTO_INCREMENT PRIMARY KEY,
text_input TEXT,
prediction_result BOOLEAN,
prediction_score DOUBLE,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
docker-compose up --build
<!-- build.gradle 또는 pom.xml에 추가 -->
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-api</artifactId>
<version>0.11.5</version>
</dependency>
Authorization: Bearer <JWT>로 요청| 파일명 | 설명 |
|---|---|
JwtTokenProvider.java | 토큰 생성 / 검증 |
JwtAuthenticationFilter.java | JWT 인증 필터 |
UserDetailsServiceImpl.java | 사용자 정보 조회 |
SecurityConfig.java | Spring Security 설정 |
String token = Jwts.builder()
.setSubject(username)
.setExpiration(new Date(System.currentTimeMillis() + 86400000)) // 1일
.signWith(secretKey, SignatureAlgorithm.HS256)
.compact();
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR...
| 화면명 | 기능 설명 |
|---|---|
/admin/login | 관리자 로그인 |
/admin/dashboard | 전체 예측 결과 리스트 (테이블) |
/admin/stats | 이탈률 시각화 그래프 (Chart.js or Recharts 사용) |
/admin/dashboard 진입 시 예측 결과 목록 호출/admin/stats 진입 시 통계 그래프 데이터 호출📁 src/
├── 📁 admin/
│ ├── 📄 AdminLogin.jsx
│ ├── 📄 AdminDashboard.jsx
│ ├── 📄 StatsChart.jsx
│ └── 📁 components/
│ └── 📄 NavBar.jsx
├── 📁 api/
│ └── 📄 adminApi.js # axios 요청 모음
| 고객 상담 문장 | 예측 | 확률 | 일시 |
|---|---|---|---|
| 환불해주세요! | 이탈 | 91.3% | 2025-05-03 |
| 감사합니다. | 잔류 | 12.4% | 2025-05-03 |
<LineChart data={data}<>
<XAxis dataKey="date" />
<YAxis />
<Tooltip />
<Line type="monotone" dataKey="churn_rate" stroke="#ff0000" />
</LineChart>
docker-compose로 프로젝트 실행 상태 확인물론이죠! 요청하신 3단계 구성에 맞춰 각 단계별 코드 템플릿을 직접 작성해드릴게요.
초보자도 바로 적용할 수 있게 설명과 함께 제공합니다.
docker-compose + 각 서비스 Dockerfiledocker-compose.ymlversion: '3.8'
services:
mysql:
image: mysql:8
container_name: mysql
restart: always
environment:
MYSQL_ROOT_PASSWORD: rootpass
MYSQL_DATABASE: churn_db
MYSQL_USER: churnuser
MYSQL_PASSWORD: churnpass
ports:
- "3306:3306"
volumes:
- ./mysql:/docker-entrypoint-initdb.d
backend:
build: ./backend-spring
container_name: spring-app
ports:
- "8080:8080"
depends_on:
- mysql
environment:
SPRING_DATASOURCE_URL: jdbc:mysql://mysql:3306/churn_db
SPRING_DATASOURCE_USERNAME: churnuser
SPRING_DATASOURCE_PASSWORD: churnpass
bert-api:
build: ./python-bert-server
container_name: bert-api
ports:
- "5000:5000"
frontend:
build: ./frontend-react
container_name: react-ui
ports:
- "3000:3000"
backend-spring/DockerfileFROM openjdk:17-jdk-slim
WORKDIR /app
COPY target/*.jar app.jar
ENTRYPOINT ["java", "-jar", "app.jar"]
python-bert-server/DockerfileFROM python:3.10-slim
WORKDIR /app
COPY . .
RUN pip install -r requirements.txt
EXPOSE 5000
CMD ["uvicorn", "app:app", "--host", "0.0.0.0", "--port", "5000"]
frontend-react/DockerfileFROM node:18
WORKDIR /app
COPY . .
RUN npm install
RUN npm run build
RUN npm install -g serve
EXPOSE 3000
CMD ["serve", "-s", "build"]
pom.xml)<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-api</artifactId>
<version>0.11.5</version>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-impl</artifactId>
<version>0.11.5</version>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-jackson</artifactId>
<version>0.11.5</version>
<scope>runtime</scope>
</dependency>
JwtTokenProvider.java@Component
public class JwtTokenProvider {
private final String secretKey = "mySecretKey";
public String createToken(String username) {
return Jwts.builder()
.setSubject(username)
.setExpiration(new Date(System.currentTimeMillis() + 86400000)) // 1일
.signWith(Keys.hmacShaKeyFor(secretKey.getBytes()), SignatureAlgorithm.HS256)
.compact();
}
public String getUsername(String token) {
return Jwts.parserBuilder()
.setSigningKey(secretKey.getBytes())
.build()
.parseClaimsJws(token)
.getBody()
.getSubject();
}
}
JwtAuthenticationFilter.javapublic class JwtAuthenticationFilter extends OncePerRequestFilter {
@Autowired
private JwtTokenProvider jwtTokenProvider;
@Override
protected void doFilterInternal(HttpServletRequest request,
HttpServletResponse response,
FilterChain filterChain) throws ServletException, IOException {
String header = request.getHeader("Authorization");
if (header != null && header.startsWith("Bearer ")) {
String token = header.substring(7);
String username = jwtTokenProvider.getUsername(token);
UsernamePasswordAuthenticationToken auth =
new UsernamePasswordAuthenticationToken(username, null, List.of());
SecurityContextHolder.getContext().setAuthentication(auth);
}
filterChain.doFilter(request, response);
}
}
SecurityConfig.java@Configuration
@EnableWebSecurity
public class SecurityConfig {
@Autowired
private JwtAuthenticationFilter jwtAuthenticationFilter;
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
return http
.csrf().disable()
.authorizeHttpRequests(auth -> auth
.requestMatchers("/auth/**").permitAll()
.anyRequest().authenticated()
)
.addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class)
.build();
}
}
AuthController.java)@RestController
@RequestMapping("/auth")
public class AuthController {
@Autowired
private JwtTokenProvider jwtTokenProvider;
@PostMapping("/login")
public ResponseEntity<?> login(@RequestBody LoginRequest login) {
if (login.getUsername().equals("admin") && login.getPassword().equals("1234")) {
String token = jwtTokenProvider.createToken(login.getUsername());
return ResponseEntity.ok(Map.of("token", token));
}
return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build();
}
}
/admin/login/admin/dashboard (예측 리스트)/admin/stats (이탈률 차트)AdminLogin.jsx)import React, { useState } from 'react';
import axios from 'axios';
function AdminLogin({ onLogin }) {
const [form, setForm] = useState({ username: '', password: '' });
const handleLogin = async () => {
const res = await axios.post('/auth/login', form);
localStorage.setItem('token', res.data.token);
onLogin();
};
return (
<div>
<input onChange={e => setForm({ ...form, username: e.target.value })} />
<input type="password" onChange={e => setForm({ ...form, password: e.target.value })} />
<button onClick={handleLogin}>로그인</button>
</div>
);
}
AdminDashboard.jsx)import React, { useEffect, useState } from 'react';
import axios from 'axios';
function AdminDashboard() {
const [logs, setLogs] = useState([]);
useEffect(() => {
axios.get('/api/admin/logs', {
headers: {
Authorization: `Bearer ${localStorage.getItem('token')}`
}
}).then(res => setLogs(res.data));
}, []);
return (
<table>
<thead>
<tr><th>문장</th><th>예측</th><th>확률</th><th>시간</th></tr>
</thead>
<tbody>
{logs.map((log, i) => (
<tr key={i}>
<td>{log.text}</td>
<td>{log.result ? '이탈' : '잔류'}</td>
<td>{(log.prob * 100).toFixed(1)}%</td>
<td>{log.time}</td>
</tr>
))}
</tbody>
</table>
);
}
StatsChart.jsx with recharts)import { LineChart, Line, XAxis, YAxis, Tooltip } from 'recharts';
function StatsChart({ data }) {
return (
<LineChart width={600} height={300} data={data}>
<XAxis dataKey="date" />
<YAxis />
<Tooltip />
<Line type="monotone" dataKey="churn_rate" stroke="#f00" />
</LineChart>
);
}
[
{
"text": "환불해주세요",
"result": true,
"prob": 0.913,
"time": "2025-05-03T10:00:00"
}
]
docker-compose 배포 시 .env 관리 및 보안 설정좋아요! 이제 프론트엔드(React)와 백엔드(Spring Boot)가 잘 연결되는지 확인하기 위한 연동 테스트 코드를 직접 만들어볼게요.
React에서 사용자가 입력한 문장을 Spring으로 보내고,
Spring은 다시 Python BERT 서버에 요청해 예측 결과를 받고
React에 결과를 표시하는 전체 흐름을 확인합니다.
ChurnPredictForm.jsximport React, { useState } from 'react';
import axios from 'axios';
function ChurnPredictForm() {
const [textInput, setTextInput] = useState('');
const [result, setResult] = useState(null);
const handleSubmit = async () => {
try {
const response = await axios.post('http://localhost:8080/api/predict', {
text: textInput
});
setResult(response.data);
} catch (err) {
alert('예측 중 오류 발생!');
console.error(err);
}
};
return (
<div>
<textarea
placeholder="고객 상담 문장을 입력하세요"
value={textInput}
onChange={e => setTextInput(e.target.value)}
/>
<button onClick={handleSubmit}>이탈 예측</button>
{result && (
<div style={{ marginTop: '20px' }}>
<p><strong>예측 결과:</strong> {result.prediction ? '이탈 가능성 높음' : '잔류 예상'}</p>
<p><strong>이탈 확률:</strong> {(result.probability * 100).toFixed(2)}%</p>
</div>
)}
</div>
);
}
export default ChurnPredictForm;
ChurnPredictController.java@RestController
@RequestMapping("/api")
public class ChurnPredictController {
@Autowired
private ChurnPredictService churnPredictService;
@PostMapping("/predict")
public ResponseEntity<Map<String, Object>> predictChurn(@RequestBody Map<String, String> body) {
String text = body.get("text");
ChurnResult result = churnPredictService.requestPrediction(text);
Map<String, Object> response = new HashMap<>();
response.put("prediction", result.isChurn());
response.put("probability", result.getProbability());
return ResponseEntity.ok(response);
}
}
ChurnPredictService.java@Service
public class ChurnPredictService {
public ChurnResult requestPrediction(String text) {
RestTemplate restTemplate = new RestTemplate();
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_JSON);
Map<String, String> request = new HashMap<>();
request.put("text_input", text); // Python 측 key에 맞춤
HttpEntity<Map<String, String>> entity = new HttpEntity<>(request, headers);
ResponseEntity<Map> response = restTemplate.postForEntity(
"http://bert-api:5000/predict", entity, Map.class
);
Map<String, Object> body = response.getBody();
boolean isChurn = (Boolean) body.get("churn");
double prob = (Double) body.get("prob");
return new ChurnResult(isChurn, prob);
}
}
ChurnResult.javapublic class ChurnResult {
private boolean churn;
private double probability;
public ChurnResult(boolean churn, double probability) {
this.churn = churn;
this.probability = probability;
}
public boolean isChurn() {
return churn;
}
public double getProbability() {
return probability;
}
}
FastAPI)from fastapi import FastAPI
from pydantic import BaseModel
app = FastAPI()
class PredictInput(BaseModel):
text_input: str
@app.post("/predict")
def predict(data: PredictInput):
text = data.text_input
# (예시) 실제 모델 예측 대신 확률 랜덤 반환
import random
prob = random.uniform(0.1, 0.95)
churn = prob > 0.5
return {"churn": churn, "prob": prob}
POST /api/predictPOST /predictlocalhost:8080localhost:5000 (또는 도커 내부: bert-api:5000)localhost:3000좋습니다! 지금까지는 Python FastAPI 서버에서 랜덤으로 예측 확률을 반환하는 **더미 응답(mock)**이었죠.
이번에는 실제 BERT 모델을 Python 서버에 연동해서, 입력된 상담 문장으로 실제 고객 이탈 확률을 계산하는 기능을 구현해봅니다.
React → Spring → Python → 실제 BERT 모델 예측 결과 반환
\= 완전한 AI 예측 서비스화
python-bert-server/app.py)python-bert-server/
├── app.py # FastAPI 메인 API 서버
├── model_utils.py # BERT 예측 함수 정의
├── saved_model/ # 사전 학습된 모델 및 토크나이저 디렉토리
│ ├── config.json
│ ├── pytorch_model.bin
│ └── vocab.txt
├── requirements.txt
requirements.txttransformers==4.40.0
torch
fastapi
uvicorn
model_utils.py – BERT 예측 함수import torch
from transformers import BertTokenizer, BertForSequenceClassification
# 1. 모델과 토크나이저 로드
model_path = "saved_model"
tokenizer = BertTokenizer.from_pretrained(model_path)
model = BertForSequenceClassification.from_pretrained(model_path)
model.eval()
# 2. 예측 함수
def predict_churn(text_input: str):
inputs = tokenizer(text_input, return_tensors="pt", truncation=True, padding=True)
with torch.no_grad():
outputs = model(**inputs)
probs = torch.nn.functional.softmax(outputs.logits, dim=1)
pred = torch.argmax(probs, dim=1).item()
churn_prob = probs[0][1].item()
return {"churn": bool(pred), "prob": churn_prob}
app.py – FastAPI + 예측 호출from fastapi import FastAPI
from pydantic import BaseModel
from model_utils import predict_churn
app = FastAPI()
class PredictInput(BaseModel):
text_input: str
@app.post("/predict")
def predict(data: PredictInput):
result = predict_churn(data.text_input)
return result
saved_model/)| 방법 | 설명 |
|---|---|
| ✅ HuggingFace fine-tuned 모델 사용 | 이미 이탈 예측을 학습한 BERT 모델 다운로드 |
| 🔨 직접 학습한 모델 사용 | Trainer.train() 후 save_pretrained("saved_model/") |
# 예시: 학습 후 저장
model.save_pretrained("saved_model/")
tokenizer.save_pretrained("saved_model/")
cd python-bert-server
uvicorn app:app --host 0.0.0.0 --port 5000
도커로 실행 시엔 해당 디렉토리를 빌드에 포함해야 합니다.
curl -X POST http://localhost:5000/predict \
-H "Content-Type: application/json" \
-d '{"text_input": "I want a refund. Terrible service."}'
{
"churn": true,
"prob": 0.9123
}
원하시면 직접 학습할 수 있는 BERT 학습 코드도 제공해드릴 수 있어요.
👉 다음으로 구현하고 싶은 기능은 어떤 걸까요? (예: DB 저장, 관리자 차트, 데이터 히스토리 조회 등)
좋습니다! 이제 실서비스를 위한 핵심 두 기능:
| 항목 | 설명 |
|---|---|
| id | 예측 고유 번호 (PK) |
| text_input | 입력 문장 |
| prediction | 이탈 여부 (true/false) |
| probability | 이탈 확률 (0.0 ~ 1.0) |
| username | 예측 요청자 (JWT 사용자 ID) |
| created_at | 요청 시각 |
CREATE TABLE churn_log (
id INT AUTO_INCREMENT PRIMARY KEY,
text_input TEXT NOT NULL,
prediction BOOLEAN,
probability DOUBLE,
username VARCHAR(255),
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
ChurnLog 엔티티 (JPA)@Entity
@Table(name = "churn_log")
public class ChurnLog {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(columnDefinition = "TEXT")
private String textInput;
private Boolean prediction;
private Double probability;
private String username;
@CreationTimestamp
private Timestamp createdAt;
// getters/setters
}
public interface ChurnLogRepository extends JpaRepository<ChurnLog, Long> {
List<ChurnLog> findByUsername(String username);
}
@Autowired
private ChurnLogRepository churnLogRepository;
public ChurnResult requestPrediction(String text) {
// 기존과 동일하게 BERT API 호출...
// 응답 처리:
ChurnResult result = ...;
// 사용자 정보 추출
String username = SecurityContextHolder.getContext().getAuthentication().getName();
ChurnLog log = new ChurnLog();
log.setTextInput(text);
log.setPrediction(result.isChurn());
log.setProbability(result.getProbability());
log.setUsername(username);
churnLogRepository.save(log);
return result;
}
/auth/login)@PostMapping("/login")
public ResponseEntity<?> login(@RequestBody LoginRequest login) {
if (login.getUsername().equals("admin") && login.getPassword().equals("1234")) {
String token = jwtTokenProvider.createToken(login.getUsername());
return ResponseEntity.ok(Map.of("token", token));
}
return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build();
}
JwtAuthenticationFilter.java에서 다음 코드로 인증정보 저장:
String username = jwtTokenProvider.getUsername(token);
UsernamePasswordAuthenticationToken auth =
new UsernamePasswordAuthenticationToken(username, null, List.of());
SecurityContextHolder.getContext().setAuthentication(auth);
→ SecurityContextHolder.getContext().getAuthentication().getName()
이 코드로 서비스에서 현재 사용자 ID(username) 가져올 수 있음!
/auth/login에 username/password 전송Authorization: Bearer <token>을 모든 요청에 포함JwtAuthenticationFilter로 토큰 검증/admin/logs)@GetMapping("/admin/logs")
public ResponseEntity<List<ChurnLog>> getAllLogs() {
return ResponseEntity.ok(churnLogRepository.findAll());
}
👉 로그인한 사용자별 조회를 원한다면:
@GetMapping("/my/logs")
public ResponseEntity<List<ChurnLog>> getMyLogs() {
String username = SecurityContextHolder.getContext().getAuthentication().getName();
return ResponseEntity.ok(churnLogRepository.findByUsername(username));
}
좋아요! 이제 JWT 인증 기반으로 로그인한 관리자가 예측 로그를 조회하고, 이탈률을 시각화하는
**관리자 대시보드 (React 기반)**를 구성해보겠습니다.
| 기능 | 설명 |
|---|---|
| 🔐 관리자 로그인 | JWT 기반 로그인 (기 구현됨) |
| 📋 예측 로그 테이블 출력 | 전체 로그 목록 출력 (시간순, 확률 등 포함) |
| 📈 이탈률 그래프 시각화 | 날짜별 이탈률 변화 추이 그래프 |
@GetMapping("/admin/logs")
@PreAuthorize("hasAuthority('ROLE_ADMIN')")
public ResponseEntity<List<ChurnLog>> getAllLogs() {
return ResponseEntity.ok(churnLogRepository.findAll(Sort.by(Sort.Direction.DESC, "createdAt")));
}
요구사항:
ROLE_ADMIN권한 설정 필요 →SecurityConfig에서 사용자 역할 관리
@GetMapping("/admin/stats")
@PreAuthorize("hasAuthority('ROLE_ADMIN')")
public ResponseEntity<List<ChurnStatDto>> getStats() {
List<Object[]> rows = churnLogRepository.countByDayAndChurn();
Map<String, int[]> map = new TreeMap<>();
for (Object[] row : rows) {
String date = row[0].toString();
boolean churn = (Boolean) row[1];
long count = (Long) row[2];
map.putIfAbsent(date, new int[]{0, 0});
if (churn) map.get(date)[1] += count;
else map.get(date)[0] += count;
}
List<ChurnStatDto> result = new ArrayList<>();
for (Map.Entry<String, int[]> entry : map.entrySet()) {
int total = entry.getValue()[0] + entry.getValue()[1];
double churnRate = total == 0 ? 0 : (double) entry.getValue()[1] / total;
result.add(new ChurnStatDto(entry.getKey(), churnRate));
}
return ResponseEntity.ok(result);
}
ChurnLogRepository)@Query("SELECT DATE(createdAt), prediction, COUNT(*) FROM ChurnLog GROUP BY DATE(createdAt), prediction")
List<Object[]> countByDayAndChurn();
AdminLogTable.jsx)import React, { useEffect, useState } from 'react';
import axios from 'axios';
function AdminLogTable() {
const [logs, setLogs] = useState([]);
useEffect(() => {
axios.get('/admin/logs', {
headers: {
Authorization: `Bearer ${localStorage.getItem('token')}`
}
}).then(res => setLogs(res.data));
}, []);
return (
<table>
<thead>
<tr>
<th>문장</th><th>예측</th><th>확률</th><th>사용자</th><th>시각</th>
</tr>
</thead>
<tbody>
{logs.map((log, i) => (
<tr key={i}>
<td>{log.textInput}</td>
<td>{log.prediction ? '이탈' : '잔류'}</td>
<td>{(log.probability * 100).toFixed(1)}%</td>
<td>{log.username}</td>
<td>{new Date(log.createdAt).toLocaleString()}</td>
</tr>
))}
</tbody>
</table>
);
}
export default AdminLogTable;
ChurnStatsChart.jsx)⚙️ 라이브러리 설치 필요:
npm install recharts
import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, Legend } from 'recharts';
import React, { useEffect, useState } from 'react';
import axios from 'axios';
function ChurnStatsChart() {
const [data, setData] = useState([]);
useEffect(() => {
axios.get('/admin/stats', {
headers: {
Authorization: `Bearer ${localStorage.getItem('token')}`
}
}).then(res => setData(res.data));
}, []);
return (
<LineChart width={600} height={300} data={data}>
<CartesianGrid stroke="#ccc" />
<XAxis dataKey="date" />
<YAxis domain={[0, 1]} tickFormatter={(v) => `${(v * 100).toFixed(0)}%`} />
<Tooltip formatter={(v) => `${(v * 100).toFixed(2)}%`} />
<Legend />
<Line type="monotone" dataKey="churnRate" stroke="#ff0000" name="이탈률" />
</LineChart>
);
}
export default ChurnStatsChart;
AdminDashboard.jsx)import React from 'react';
import AdminLogTable from './AdminLogTable';
import ChurnStatsChart from './ChurnStatsChart';
function AdminDashboard() {
return (
<div>
<h2>📋 예측 로그</h2>
<AdminLogTable />
<h2>📈 날짜별 이탈률</h2>
<ChurnStatsChart />
</div>
);
}
export default AdminDashboard;
axios.get('/admin/logs', {
headers: {
Authorization: `Bearer ${localStorage.getItem('token')}`
}
})
| 구성 요소 | 구현 완료 상태 |
|---|---|
| 관리자 JWT 로그인 | ✅ |
| 예측 로그 저장 | ✅ |
| 전체 로그 API | ✅ |
| 날짜별 이탈률 API | ✅ |
| React 로그 테이블 | ✅ |
| React 이탈률 그래프 | ✅ |
좋습니다! JWT 토큰 만료 시 자동 로그아웃 처리는 실제 운영에 꼭 필요한 보안 기능입니다.
아래는 React에서 JWT 만료를 감지하고 자동 로그아웃 시키는 방법을 단계별로 설명합니다.
JWT는 3부분으로 구성되어 있으며, 가운데 payload를 디코딩하면 만료 시간(exp)이 포함되어 있습니다.
예:
{
"sub": "admin",
"exp": 1715000000
}
exp는 Unix 타임스탬프 (초 단위)decodeToken 함수 구현 (Base64 디코딩)function decodeToken(token) {
try {
const payload = token.split('.')[1];
const decoded = JSON.parse(atob(payload));
return decoded;
} catch (e) {
return null;
}
}
isTokenExpired 유틸 함수function isTokenExpired(token) {
const decoded = decodeToken(token);
if (!decoded || !decoded.exp) return true;
const now = Math.floor(Date.now() / 1000); // 현재 시간 (초 단위)
return decoded.exp < now;
}
import React, { useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
function App() {
const navigate = useNavigate();
useEffect(() => {
const token = localStorage.getItem('token');
if <(!token || isTokenExpired(token)) {
alert('세션이 만료되었습니다. 다시 로그인 해주세요.');
localStorage.removeItem('token');
navigate('/login');
}
}, []);
return (
// 라우터 or 전체 앱 컴포넌트
);
}
💡 또는
axios.interceptors를 써도 요청마다 검사할 수 있습니다.
import axios from 'axios';
import { isTokenExpired } from './authUtils';
import { useNavigate } from 'react-router-dom';
const token = localStorage.getItem('token');
axios.interceptors.request.use(config => {
if (token && isTokenExpired(token)) {
alert("토큰이 만료되어 자동 로그아웃됩니다.");
localStorage.removeItem('token');
window.location.href = "/login";
return Promise.reject("Token expired");
}
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
return config;
});
const decoded = decodeToken(token);
const expTime = new Date(decoded.exp * 1000);
console.log("토큰 만료 시각:", expTime.toLocaleString());
[로그인 성공] → localStorage에 토큰 저장
↓
[페이지 진입 / axios 요청] → decode → exp 체크
↓
만료되었으면 → 로그아웃 + /login 이동
| 기능 | 설명 |
|---|---|
| 🔁 자동 새로고침 시 만료 체크 | useEffect 또는 axios.interceptors |
| 🔔 만료 5분 전 경고 표시 | 사용자 경험 개선 |
| 🔄 토큰 갱신 API 추가 | Spring에서 /auth/refresh 구현 (선택) |
좋습니다! 이제 자동 새로고침(F5 등) 시에도 JWT 토큰 만료 여부를 감지해서
즉시 로그아웃 처리하는 기능을 useEffect 또는 axios.interceptors 방식으로 구현해보겠습니다.
| 방식 | 시점 | 사용 위치 |
|---|---|---|
✅ useEffect | 앱이 로드될 때 (최초 1회) | App.jsx 또는 Layout |
✅ axios.interceptors | API 요청마다 | axios.js 등 API 유틸 파일 |
→ 둘 다 함께 쓰는 것이 가장 안전합니다.
useEffect 방식 (앱 로드 시 1회 검사)App.jsx 또는 MainLayout.jsximport React, { useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import { isTokenExpired } from './utils/auth'; // 아래에서 정의
function App() {
const navigate = useNavigate();
useEffect(() => {
const token = localStorage.getItem('token');
if (!token || isTokenExpired(token)) {
alert('세션이 만료되어 자동 로그아웃됩니다.');
localStorage.removeItem('token');
navigate('/login');
}
}, []);
return (
<Routes>
{/* App 전체 라우팅 구조 */}
</Routes>
);
}
export default App;
axios.interceptors 방식 (API 요청마다 검사)axios.js 또는 전역 API 유틸 파일import axios from 'axios';
import { isTokenExpired } from './utils/auth';
const instance = axios.create({
baseURL: 'http://localhost:8080',
});
instance.interceptors.request.use(config => {
const token = localStorage.getItem('token');
if (token) {
if (isTokenExpired(token)) {
alert("토큰이 만료되었습니다. 다시 로그인해주세요.");
localStorage.removeItem('token');
window.location.href = "/login";
return Promise.reject("Token expired");
}
config.headers.Authorization = `Bearer ${token}`;
}
return config;
});
export default instance;
⚠️ 사용 시 기존
axios.get(...)→instance.get(...)으로 변경 필요
utils/auth.jsexport function decodeToken(token) {
try {
const payload = token.split('.')[1];
return JSON.parse(atob(payload));
} catch (e) {
return null;
}
}
export function isTokenExpired(token) {
const decoded = decodeToken(token);
if (!decoded || !decoded.exp) return true;
const now = Math.floor(Date.now() / 1000);
return decoded.exp < now;
}
로그인 → JWT 저장됨 (localStorage)
사용자가 새로고침(F5) or 앱 재접속 시:
useEffect → 토큰 검사 → 만료 시 logout + redirectAPI 호출 시:
axios.interceptor → 토큰 검사 → 만료 시 자동 로그아웃| 기능 | 효과 |
|---|---|
⏰ JWT exp 1~2시간 설정 | 짧은 수명으로 보안 강화 |
| 🔁 Refresh Token 사용 | 만료 시 재발급 가능 (Spring + DB 필요) |
🔒 토큰 저장 위치 httpOnly | XSS 공격 방지 (서버 쿠키 방식 사용 시) |
좋습니다!
이번에는 JWT 만료 5분 전 경고 표시로 사용자 경험을 개선하고,
로그인을 유지할 수 있도록 Refresh Token 기반 인증 구조까지 함께 구현해보겠습니다.
useEffect로 타이머 설정 (예: App.jsx)import { useEffect } from 'react';
import { decodeToken } from './utils/auth';
function App() {
useEffect(() => {
const token = localStorage.getItem('token');
if (!token) return;
const decoded = decodeToken(token);
if (!decoded || !decoded.exp) return;
const now = Math.floor(Date.now() / 1000);
const timeLeft = decoded.exp - now;
const warningTime = 5 * 60; // 5분
if (timeLeft > warningTime) {
const timeout = setTimeout(() => {
alert("⏳ 토큰이 5분 후 만료됩니다. 로그인을 연장하세요.");
// 👉 여기서 Refresh 요청 가능
}, (timeLeft - warningTime) * 1000);
return () => clearTimeout(timeout);
}
}, []);
}
| Access Token | Refresh Token |
|---|---|
| 수명: 짧음 (15분~1시간) | 수명: 김 (7일 등) |
| 헤더로 보냄 | DB or 쿠키로 저장 |
| 노출 시 위험 높음 | 서버 검증으로 안전 관리 |
RefreshToken@Entity
public class RefreshToken {
@Id
private String username;
@Column(nullable = false)
private String token;
private Instant expiryDate;
// 생성자, getter/setter
}
@PostMapping("/auth/login")
public ResponseEntity<?> login(@RequestBody LoginRequest login) {
// 인증 성공 시:
String accessToken = jwtTokenProvider.createToken(login.getUsername(), 15); // 15분
String refreshToken = jwtTokenProvider.createToken(login.getUsername(), 60 * 24 * 7); // 7일
refreshTokenRepository.save(new RefreshToken(login.getUsername(), refreshToken, ...));
return ResponseEntity.ok(Map.of("accessToken", accessToken, "refreshToken", refreshToken));
}
@PostMapping("/auth/refresh")
public ResponseEntity<?> refresh(@RequestBody Map<String, String> body) {
String refreshToken = body.get("refreshToken");
String username = jwtTokenProvider.getUsername(refreshToken);
RefreshToken stored = refreshTokenRepository.findById(username).orElseThrow();
if (!stored.getToken().equals(refreshToken)) {
return ResponseEntity.status(403).body("Refresh token mismatch");
}
// 새 AccessToken 발급
String newAccessToken = jwtTokenProvider.createToken(username, 15);
return ResponseEntity.ok(Map.of("accessToken", newAccessToken));
}
export async function refreshAccessToken() {
try {
const res = await axios.post('/auth/refresh', {
refreshToken: localStorage.getItem('refreshToken'),
});
localStorage.setItem('token', res.data.accessToken);
} catch (err) {
alert("세션이 만료되었습니다. 다시 로그인해주세요.");
localStorage.clear();
window.location.href = '/login';
}
}
refreshAccessToken() 호출if (timeLeft > warningTime) {
const timeout = setTimeout(() => {
alert("⏳ 토큰이 곧 만료됩니다. 자동 갱신합니다...");
refreshAccessToken();
}, (timeLeft - warningTime) * 1000);
}
[로그인]
↓
accessToken (15분), refreshToken (7일) 저장
↓
[5분 전]
→ 자동 갱신 or 알림
↓
[accessToken 만료]
→ refreshToken 이용해 재발급
↓
[refreshToken도 만료]
→ 완전 로그아웃
accessToken 자동 갱신 후 axios 요청 재시도좋아요! 아래에 두 가지 기능을 차례대로 자세히 구현해드립니다:
SessionStatus.jsx (React 컴포넌트)import React, { useEffect, useState } from 'react';
import { decodeToken } from '../utils/auth'; // JWT 디코더
function SessionStatus() {
const [remaining, setRemaining] = useState(null);
useEffect(() => {
const token = localStorage.getItem('token');
if (!token) return;
const decoded = decodeToken(token);
if (!decoded?.exp) return;
const interval = setInterval(() => {
const now = Math.floor(Date.now() / 1000);
const timeLeft = decoded.exp - now;
if (timeLeft <= 0) {
setRemaining("만료됨");
clearInterval(interval);
return;
}
const min = Math.floor(timeLeft / 60);
const sec = timeLeft % 60;
setRemaining(`${min}분 ${sec}초`);
}, 1000);
return () => clearInterval(interval);
}, []);
return (
<div style={{ fontSize: '14px', margin: '10px', color: '#666' }}>
세션 유지시간: {remaining || "토큰 없음"}
</div>
);
}
export default SessionStatus;
AdminDashboard.jsx 상단)import SessionStatus from './SessionStatus';
function AdminDashboard() {
return (
<div>
<SessionStatus />
{/* 로그 테이블, 통계 등 */}
</div>
);
}
새 프로젝트 생성 → OAuth 2.0 클라이언트 ID 발급
승인된 리디렉션 URI:
http://localhost:8080/oauth2/callback/google
Client ID / Secret 복사
application.yml 설정spring:
security:
oauth2:
client:
registration:
google:
client-id: [CLIENT_ID]
client-secret: [CLIENT_SECRET]
redirect-uri: "{baseUrl}/oauth2/callback/{registrationId}"
scope:
- email
- profile
provider:
google:
authorization-uri: https://accounts.google.com/o/oauth2/v2/auth
token-uri: https://oauth2.googleapis.com/token
user-info-uri: https://www.googleapis.com/oauth2/v3/userinfo
@Component
public class OAuth2LoginSuccessHandler extends SimpleUrlAuthenticationSuccessHandler {
@Autowired
private JwtTokenProvider jwtTokenProvider;
@Override
public void onAuthenticationSuccess(HttpServletRequest request,
HttpServletResponse response,
Authentication authentication) throws IOException {
OAuth2User oauthUser = (OAuth2User) authentication.getPrincipal();
String email = oauthUser.getAttribute("email");
String token = jwtTokenProvider.createToken(email);
response.sendRedirect("http://localhost:3000/oauth-success?token=" + token);
}
}
SecurityConfig.java.oauth2Login(oauth -> oauth
.successHandler(oAuth2LoginSuccessHandler)
)
LoginPage.jsxfunction LoginPage() {
const handleGoogleLogin = () => {
window.location.href = 'http://localhost:8080/oauth2/authorization/google';
};
return (
<button onClick={handleGoogleLogin}>
Google 계정으로 로그인
</button>
);
}
/oauth-success 페이지)import { useEffect } from 'react';
import { useNavigate, useSearchParams } from 'react-router-dom';
function OAuthSuccess() {
const [searchParams] = useSearchParams();
const token = searchParams.get('token');
const navigate = useNavigate();
useEffect(() => {
if (token) {
localStorage.setItem('token', token);
navigate('/admin/dashboard');
}
}, [token]);
return <div>로그인 중입니다...</div>;
}
export default OAuthSuccess;
[1] 사용자 → React → Google 로그인 버튼 클릭
↓
[2] Google OAuth 인증 성공
↓
[3] Spring → 이메일 추출 → JWT 발급
↓
[4] React로 리다이렉트 + 토큰 저장
↓
[5] 관리자 대시보드 접근 성공
| 기능 | 설명 |
|---|---|
| 🔁 JWT Refresh 연동 | OAuth 로그인에도 적용 가능 |
| 🌐 Naver, Kakao OAuth 연동 | Google 외 추가 SNS 계정 연동 |
| 🔒 관리자 인증 라우팅 보호 | PrivateRoute 구현 |
| 📱 Google One Tap 로그인 | 모바일 UX 개선 |
좋아요! 이번에는 JWT Refresh Token을 OAuth 로그인에도 연동하는 구조를 완전하게 구현해드릴게요.
이 구조는 로그인 유지 관리의 핵심이며, Google OAuth로 로그인한 사용자도 Access Token이 만료되면 Refresh Token으로 자동 재발급되도록 합니다.
"OAuth 로그인 사용자가 Access Token이 만료되었을 때도,
Refresh Token을 이용해 자동으로 재발급 받는 시스템"
[1] 최초 OAuth 로그인
→ Access Token (15분), Refresh Token (7일) 발급
→ React에 전달 + 저장
[2] Access Token 만료
→ Refresh Token으로 재발급 요청 (/auth/refresh)
→ 새로운 Access Token 받아서 다시 사용
[3] Refresh Token도 만료
→ 로그인 페이지로 리다이렉트
OAuth2LoginSuccessHandler.java@Component
public class OAuth2LoginSuccessHandler extends SimpleUrlAuthenticationSuccessHandler {
@Autowired
private JwtTokenProvider jwtTokenProvider;
@Autowired
private RefreshTokenRepository refreshTokenRepository;
@Override
public void onAuthenticationSuccess(HttpServletRequest request,
HttpServletResponse response,
Authentication authentication) throws IOException {
OAuth2User oauthUser = (OAuth2User) authentication.getPrincipal();
String username = oauthUser.getAttribute("email");
String accessToken = jwtTokenProvider.createToken(username, 15); // 15분
String refreshToken = jwtTokenProvider.createToken(username, 60 * 24 * 7); // 7일
refreshTokenRepository.save(
new RefreshToken(username, refreshToken, Instant.now().plus(7, ChronoUnit.DAYS))
);
// React로 리다이렉트
response.sendRedirect("http://localhost:3000/oauth-success?accessToken=" + accessToken
+ "&refreshToken=" + refreshToken);
}
}
OAuthSuccess.jsximport { useSearchParams, useNavigate } from 'react-router-dom';
import { useEffect } from 'react';
function OAuthSuccess() {
const [params] = useSearchParams();
const accessToken = params.get('accessToken');
const refreshToken = params.get('refreshToken');
const navigate = useNavigate();
useEffect(() => {
if (accessToken && refreshToken) {
localStorage.setItem('token', accessToken);
localStorage.setItem('refreshToken', refreshToken);
navigate('/admin/dashboard');
}
}, [accessToken, refreshToken]);
return <div>로그인 완료 중...</div>;
}
/auth/refresh 구현AuthController.java@PostMapping("/auth/refresh")
public ResponseEntity<?> refresh(@RequestBody Map<String, String> req) {
String refreshToken = req.get("refreshToken");
if (!jwtTokenProvider.validateToken(refreshToken)) {
return ResponseEntity.status(403).body("Invalid refresh token");
}
String username = jwtTokenProvider.getUsername(refreshToken);
RefreshToken saved = refreshTokenRepository.findById(username).orElseThrow();
if (!saved.getToken().equals(refreshToken)) {
return ResponseEntity.status(403).body("Token mismatch");
}
String newAccessToken = jwtTokenProvider.createToken(username, 15); // 새 토큰
return ResponseEntity.ok(Map.of("accessToken", newAccessToken));
}
axios.js 인터셉터에 추가import axios from 'axios';
import { isTokenExpired } from './utils/auth';
const instance = axios.create({ baseURL: 'http://localhost:8080' });
instance.interceptors.request.use(async config => {
let token = localStorage.getItem('token');
if (token && isTokenExpired(token)) {
// Refresh 요청
try {
const res = await axios.post('/auth/refresh', {
refreshToken: localStorage.getItem('refreshToken'),
});
token = res.data.accessToken;
localStorage.setItem('token', token);
} catch (err) {
alert("세션이 만료되었습니다. 다시 로그인해주세요.");
localStorage.clear();
window.location.href = "/login";
return Promise.reject("토큰 만료");
}
}
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
return config;
});
auth.js)export function decodeToken(token) {
try {
return JSON.parse(atob(token.split('.')[1]));
} catch (e) {
return null;
}
}
export function isTokenExpired(token) {
const decoded = decodeToken(token);
if (!decoded?.exp) return true;
const now = Math.floor(Date.now() / 1000);
return now > decoded.exp;
}
[Google 로그인] → [Spring] → accessToken + refreshToken → [React 저장]
[API 요청 전마다]
→ accessToken 만료?
→ refreshToken으로 갱신 시도
→ 성공: 새로운 accessToken 저장
→ 실패: 로그아웃 처리
| 기능 | 설명 |
|---|---|
| ✅ 토큰 만료 카운트다운 표시 | UX 개선 (이미 구현됨) |
| ✅ 토큰 갱신 시 자동 alert 제거 | UX 부드럽게 |
| 🔒 refreshToken을 쿠키로 관리 | httpOnly 방식 보안 강화 |
| 📦 전체 인증 통합 모듈화 | 재사용 가능한 구조로 개선 |
| 🌐 소셜 로그인 확장 (Kakao, Naver) | Google 외 로그인 지원 |
좋아요! 지금까지 구현한 Access Token + Refresh Token + OAuth2 로그인 시스템을
🧩 “재사용 가능한 인증 모듈”로 구조화해서 정리해드릴게요.
이제 앞으로 어떤 프로젝트든 붙이기만 하면 작동하도록 만들 수 있습니다.
| 대상 | 설명 |
|---|---|
| 🔐 AccessToken | 짧은 수명의 인증 토큰 (Authorization 헤더에 사용) |
| 🔁 RefreshToken | 길게 살아남는 세션 유지용 토큰 (DB 또는 쿠키) |
| 🌐 OAuth2 | Google 로그인 등 연동 (email 기반 식별) |
| 🧱 구조화 목표 | “JWT 발급, 검증, 재발급, 세션 체크” 모두 별도 모듈로 구성 |
📁 auth/
├── 📄 JwtTokenProvider.java # JWT 생성/검증
├── 📄 JwtAuthenticationFilter.java # 요청마다 인증 필터
├── 📄 RefreshToken.java # 엔티티
├── 📄 RefreshTokenRepository.java # JPA 리포지토리
├── 📄 RefreshService.java # 토큰 저장/갱신 로직
├── 📄 AuthController.java # 로그인 / 갱신 API
├── 📄 OAuth2LoginSuccessHandler.java # 구글 로그인 성공 후 JWT 발급
├── 📄 AuthConfig.java # Spring Security 설정
@Component
public class JwtTokenProvider {
private final String secret = "MyJwtSecretKeyMustBeLongEnough";
public String createToken(String username, int minutes) {
Date expiry = Date.from(Instant.now().plus(minutes, ChronoUnit.MINUTES));
return Jwts.builder()
.setSubject(username)
.setExpiration(expiry)
.signWith(Keys.hmacShaKeyFor(secret.getBytes()), SignatureAlgorithm.HS256)
.compact();
}
public boolean validateToken(String token) {
try {
Jwts.parserBuilder()
.setSigningKey(secret.getBytes())
.build()
.parseClaimsJws(token);
return true;
} catch (JwtException e) {
return false;
}
}
public String getUsername(String token) {
return Jwts.parserBuilder()
.setSigningKey(secret.getBytes())
.build()
.parseClaimsJws(token)
.getBody()
.getSubject();
}
}
@Service
public class RefreshService {
@Autowired
private RefreshTokenRepository repo;
public void saveToken(String username, String refreshToken, int days) {
repo.save(new RefreshToken(
username,
refreshToken,
Instant.now().plus(days, ChronoUnit.DAYS)
));
}
public boolean validate(String username, String refreshToken) {
return repo.findById(username)
.map(rt -> rt.getToken().equals(refreshToken))
.orElse(false);
}
public void delete(String username) {
repo.deleteById(username);
}
}
@Override
public void onAuthenticationSuccess(HttpServletRequest request,
HttpServletResponse response,
Authentication authentication) throws IOException {
OAuth2User user = (OAuth2User) authentication.getPrincipal();
String email = user.getAttribute("email");
String accessToken = jwtTokenProvider.createToken(email, 15);
String refreshToken = jwtTokenProvider.createToken(email, 10080); // 7일
refreshService.saveToken(email, refreshToken, 7);
response.sendRedirect("http://localhost:3000/oauth-success?accessToken=" + accessToken
+ "&refreshToken=" + refreshToken);
}
public class JwtAuthenticationFilter extends OncePerRequestFilter {
@Autowired
private JwtTokenProvider tokenProvider;
@Override
protected void doFilterInternal(HttpServletRequest request,
HttpServletResponse response,
FilterChain chain) throws IOException, ServletException {
String header = request.getHeader("Authorization");
if (header != null && header.startsWith("Bearer ")) {
String token = header.substring(7);
if (tokenProvider.validateToken(token)) {
String username = tokenProvider.getUsername(token);
Authentication auth = new UsernamePasswordAuthenticationToken(username, null, List.of());
SecurityContextHolder.getContext().setAuthentication(auth);
}
}
chain.doFilter(request, response);
}
}
@Configuration
@EnableWebSecurity
public class AuthConfig {
@Autowired <private JwtAuthenticationFilter jwtFilter;
@Autowired private OAuth2LoginSuccessHandler oAuthSuccessHandler;
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
return http
.csrf().disable()
.authorizeHttpRequests(auth -> auth
.requestMatchers("/auth/**", "/oauth2/**").permitAll()
.anyRequest().authenticated()
)
.oauth2Login(oauth -> oauth.successHandler(oAuthSuccessHandler))
.addFilterBefore(jwtFilter, UsernamePasswordAuthenticationFilter.class)
.build();
}
}
📁 src/
├── 📁 auth/
│ ├── authUtils.js # decodeToken, isTokenExpired, refreshToken
│ └── axiosInstance.js # 자동 갱신 Axios 설정
├── 📁 pages/
│ ├── LoginPage.jsx
│ ├── OAuthSuccess.jsx
│ ├── AdminDashboard.jsx
├── App.jsx
| 기능 | 확장 가능성 |
|---|---|
| 🧾 이메일/비밀번호 로그인 추가 | AuthController 확장 |
| 🔐 httpOnly 쿠키 + SameSite 적용 | 보안 강화 |
| 🔐 관리자 역할(Role) 기반 접근제한 | @PreAuthorize("hasRole('ADMIN')") |
| 📦 모듈만 따로 JAR 또는 Git Submodule로 구성 | ✅ 가능 |
모든 로그인 흐름 → AccessToken + RefreshToken 발급 → 자동 갱신 + 세션 추적 → 인증 유지 + 로그아웃 처리까지 자동화
Vercel + Render + PlanetScale로 배포 예시Kakao / Naver 소셜 로그인도 확장 가능물론이죠! 지금까지 구현한 JWT + Refresh Token + OAuth2 인증 시스템 전체를 템플릿 프로젝트로 구조화하고,
📦 모듈 재사용이 가능한 형태로 압축해드릴게요. 이후 어떤 프로젝트든 복붙만 하면 됩니다.
project-root/
├── backend-auth/ # Spring Boot API 서버
│ ├── src/main/java/com/example/auth/
│ │ ├── config/ # Security 설정
│ │ ├── controller/ # AuthController
│ │ ├── domain/ # RefreshToken Entity
│ │ ├── jwt/ # JWT 관련 유틸
│ │ ├── oauth/ # OAuth2 핸들러
│ │ └── repository/ # RefreshTokenRepository
│ └── application.yml
├── frontend-react/
│ ├── src/
│ │ ├── auth/ # authUtils.js, axiosInstance.js
│ │ ├── pages/
│ │ │ ├── LoginPage.jsx
│ │ │ ├── OAuthSuccess.jsx
│ │ │ └── AdminDashboard.jsx
│ │ └── App.jsx
│ └── package.json
├── docker-compose.yml # 전체 통합용 (MySQL 포함 가능)
JWT 만료 시간: Access Token 15분 / Refresh Token 7일
OAuth 지원: Google (추가로 Kakao, Naver 가능)
인증 방식:
| 패키지 | 역할 |
|---|---|
jwt/ | JWT 생성/검증 도구 (JwtTokenProvider) |
config/ | Spring Security 설정 (SecurityConfig, 필터) |
controller/ | 로그인, 토큰 갱신 API |
oauth/ | 구글 로그인 성공 핸들러 |
domain/ | RefreshToken 엔티티 |
repository/ | RefreshTokenRepository |
service/ (선택) | 리프레시 관리 유틸 (RefreshService) |
| 파일 | 기능 |
|---|---|
authUtils.js | JWT decode / 만료 판단 |
axiosInstance.js | 자동 토큰 갱신 포함 Axios |
SessionStatus.jsx | 남은 세션 시간 UI |
OAuthSuccess.jsx | OAuth 토큰 수신 후 저장 |
LoginPage.jsx | 기본 로그인 또는 Google 버튼 |
PrivateRoute.jsx (옵션) | 인증 보호 라우팅 |
/auth/refresh 요청docker-compose 예시)version: '3'
services:
backend:
build: ./backend-auth
ports:
- "8080:8080"
environment:
SPRING_PROFILES_ACTIVE: prod
frontend:
build: ./frontend-react
ports:
- "3000:3000"
| 항목 | 설명 |
|---|---|
| 🔐 Access Token | 로그인 인증용, 15분마다 만료 |
| 🔁 Refresh Token | 자동 갱신용, DB에 저장 |
| 🌐 OAuth 연동 | Google 로그인 지원 |
| 🔄 자동 갱신 | 토큰 만료 5분 전 경고 + 자동 재발급 |
| 🧱 모듈화 구조 | 프론트 + 백 모두 파일 분리로 재사용 가능 |
| 📦 템플릿화 | 새로운 프로젝트에 붙여 쓰기 가능 |
| 기능 | 효과 |
|---|---|
| 🛡️ Role-based 접근제한 | /admin/** → hasRole('ADMIN') |
| 💌 이메일 인증 + 비밀번호 찾기 | 보안 + 사용자 경험 개선 |
| 🔐 HttpOnly 쿠키로 Refresh | XSS 방지, OAuth 쿠키 인증 |
| ☁️ Vercel + Render 배포 구성 | 실전 배포용 템플릿 |
| 🔗 Kakao/Naver OAuth 연동 | 국내 SNS 확장 |
📁 이 구조를 GitHub 템플릿으로 생성하거나 ZIP 파일로 제공해드릴 수도 있습니다.
다음으로 원하시는 작업은 무엇인가요? (배포, 코드 공유, 깃허브 업로드 등)
좋습니다! 요청하신 전체 작업은 다음과 같이 도와드릴 수 있습니다:
| 항목 | 작업 내용 |
|---|---|
| 📦 ZIP 제공 | Spring + React 통합 인증 템플릿 전체 구조 압축 파일 제공 |
| 🚀 배포 준비 | Docker 기반 docker-compose 구성 |
| 🐙 GitHub 업로드 | 리드미, 사용법 포함된 깃허브 레포지토리 생성용 템플릿 제공 |
backend-auth/ (Spring Boot JWT + OAuth2 + RefreshToken 모듈 구조)frontend-react/ (React + 자동 토큰 갱신 + 세션 유지 UI)docker-compose.yml (통합 실행용)README.md (구성 설명 + 실행 방법)제가 직접 코드를 준비하고 압축한 뒤에 다운로드 링크 또는 업로드용 파일을 드릴 수는 없습니다
하지만 다음 두 가지 방식으로 완전히 동일한 결과를 드릴 수 있습니다:
gh CLI를 사용해 로컬에서 GitHub에 바로 업로드 가능:
gh repo create jwt-auth-template --public
git init
git remote add origin https://github.com/YOUR_USERNAME/jwt-auth-template.git
git add .
git commit -m "Initial commit: Spring + React JWT Auth Template"
git push -u origin main
또는 GitHub에 직접 빈 repo 만들고 압축된 파일 전체 업로드
원하시면 지금 이 구조로 템플릿 파일을 직접 생성해서 공유드릴 수 있도록, GitHub Gist/코드 생성 해드릴게요.
📝 확인 질문:
jwt-auth-template)이 세 가지만 알려주시면 템플릿 압축 구성을 마무리하고, 업로드/배포 안내까지 정리해드릴게요.