좋은 프로젝트예요! Java 백엔드 개발자로서 AI반 친구의 프로젝트를 이해하고 향후 협업이나 확장 아이디어를 떠올리는 데 도움이 되도록, 내용을 리마인드용으로 요약해드릴게요.


좋아요! 자바(Spring Boot) 백엔드와 Python 기반 BERT 고객이탈 예측 모델을 API 연동하는 전체 구조 흐름텍스트 기반 폴더 트리 + 데이터 흐름 설명으로 정리해드릴게요.


✅ 1. 전체 시스템 구성도 요약 (흐름 중심)

[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]

✅ 2. Spring + Python 연동 폴더 트리 구조 예시

📦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 테이블 등 정의

✅ 3. Spring → Python 호출 흐름 예시 (코드 수준 요약)

🔸Spring REST Controller (POST /predict)

@PostMapping("/predict")
public ResponseEntity<ChurnResponse> predict(@RequestBody ChurnRequest request) {
    ChurnResponse result = churnPredictService.predictChurn(request);
    return ResponseEntity.ok(result);
}

🔸Spring Service → Python API 호출

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();
}

🔸Python FastAPI 서버 예시 (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}

✅ 4. MySQL 저장 테이블 예시 (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
);

✅ 5. 전체 흐름 리마인드

  1. 유저가 상담문장 입력 → React → Spring POST /predict
  2. Spring이 Python 서버에 요청 → 결과 응답 받음
  3. 결과를 DB에 저장하고, 사용자에게 리턴
  4. 관리자 대시보드에서 이탈 위험 고객 분석 가능

필요하다면 👉 React + Spring 연동 예시, 도커 구성(docker-compose), JWT 인증 추가,
또는 관리자 대시보드 UI 설계까지 확장 도와드릴 수 있어요.
어떤 부분부터 먼저 구현해보고 싶으신가요?



🧠 프로젝트 개요

  • 목표: 고객 상담 데이터를 분석해서 고객 이탈 가능성을 예측하는 AI 모델 제작
  • 활용: 이커머스 사이트에 적용 → 고객을 세분화하고 마케팅 효율 극대화
  • 핵심 기술: BERT(Transformer 기반 사전학습 모델) 사용

🔧 전체 개발 흐름 요약

단계설명
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. 실시간 예측 함수사용자가 입력한 문장을 실시간으로 예측하는 함수 구현

🧪 예시 입력과 예측 결과

예시 1 (불만 고객)

Customer: 환불하고 싶어요.
Agent: 환불 불가합니다.
Customer: 뭐 이런 곳이 다 있어?!
→ 결과: 이탈 가능성 높음 (높은 확률로 예측)

예시 2 (긍정 고객)

Customer: 더 싸게 안되나요?
Agent: 다음주 프로모션이 있어요.
Customer: 감사합니다~
→ 결과: 잔류 (이탈 가능성 낮음)

💡 이 프로젝트의 의의

  • 실제 콜센터 상담 데이터를 학습시켜서 이탈 위험군을 조기 식별하는 데에 활용 가능
  • 기업 입장에선 고객 유지 비용을 절감하고, 효율적인 대응/마케팅 전략 수립 가능
  • 무엇보다 Transformer(BERT) 기반 NLP 모델을 실전에서 써본 값진 경험

🤝 자바 개발자로서 어떻게 팔로우업할까?

✅ 배울 수 있는 점

  • REST API로 이 모델을 배포하면, 자바 서버와 연동 가능
  • Java(Spring)에서 Python 모델을 호출하려면 → FastAPI, Flask + Java HTTP 통신
  • 이런 AI 모델을 마이크로서비스 구조에서 서빙하는 법도 고민해볼 수 있음
  • 이탈 예측 외에도: 감정 분석, 응답 추천 등 다양한 확장 가능

✅ 함께할 수 있는 아이디어

  • Spring Boot로 고객 상담 기록 관리 시스템 만들고,
  • 상담이 끝날 때마다 해당 텍스트를 → 이 BERT 모델에 보내서 자동 이탈 예측
  • 그 결과를 관리자 대시보드에 표시! (React + Chart.js 연동 가능)

좋아요! 요청하신 순서대로 1) 도커 구성 → 2) JWT 인증 추가 → 3) 관리자 대시보드 UI 설계
하나씩 완전 초보도 따라할 수 있게 자세히 설명드릴게요.


✅ 1단계: Docker + 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

🔸 docker-compose.yml

<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"

🔸 backend-spring/Dockerfile

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/에 위치해야 함.


🔸 python-bert-server/Dockerfile

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"]

🔸 frontend-react/Dockerfile

FROM node:18
WORKDIR /app
COPY . .
RUN npm install
RUN npm run build
RUN npm install -g serve
EXPOSE 3000
CMD ["serve", "-s", "build"]

🔸 mysql/init.sql

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

✅ 2단계: Spring Boot에 JWT 인증 추가

✨ 사용 라이브러리

<!-- build.gradle 또는 pom.xml에 추가 -->
<dependency>
  <groupId>io.jsonwebtoken</groupId>
  <artifactId>jjwt-api</artifactId>
  <version>0.11.5</version>
</dependency>

🔸 사용자 로그인 → JWT 발급 흐름

  1. 사용자가 이메일/비밀번호로 로그인
  2. 로그인 성공 시 JWT 토큰 생성
  3. 이후 요청은 Authorization: Bearer <JWT>로 요청
  4. 필터에서 JWT 검증 → 인증된 사용자만 API 접근 허용

🔸 주요 구성 클래스

파일명설명
JwtTokenProvider.java토큰 생성 / 검증
JwtAuthenticationFilter.javaJWT 인증 필터
UserDetailsServiceImpl.java사용자 정보 조회
SecurityConfig.javaSpring Security 설정

🔸 JWT 토큰 생성 예시

String token = Jwts.builder()
    .setSubject(username)
    .setExpiration(new Date(System.currentTimeMillis() + 86400000))  // 1일
    .signWith(secretKey, SignatureAlgorithm.HS256)
    .compact();

🔸 요청 시 Header 예시

Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR...

✅ 3단계: 관리자 대시보드 UI 설계 (React 기반)

🧩 주요 화면

화면명기능 설명
/admin/login관리자 로그인
/admin/dashboard전체 예측 결과 리스트 (테이블)
/admin/stats이탈률 시각화 그래프 (Chart.js or Recharts 사용)

🔸 상태 관리 흐름 (예: Redux 또는 Context API)

  1. 로그인 성공 시 JWT 저장 (로컬스토리지)
  2. 이후 API 요청 시 JWT 포함
  3. /admin/dashboard 진입 시 예측 결과 목록 호출
  4. /admin/stats 진입 시 통계 그래프 데이터 호출

🔸 컴포넌트 구조 예시

📁 src/
├── 📁 admin/
│   ├── 📄 AdminLogin.jsx
│   ├── 📄 AdminDashboard.jsx
│   ├── 📄 StatsChart.jsx
│   └── 📁 components/
│       └── 📄 NavBar.jsx
├── 📁 api/
│   └── 📄 adminApi.js     # axios 요청 모음

🔸 관리자용 예측 로그 테이블 UI 예시

고객 상담 문장예측확률일시
환불해주세요!이탈91.3%2025-05-03
감사합니다.잔류12.4%2025-05-03

🔸 이탈률 변화 차트 (예: Recharts 사용)

<LineChart data={data}<>
  <XAxis dataKey="date" />
  <YAxis />
  <Tooltip />
  <Line type="monotone" dataKey="churn_rate" stroke="#ff0000" />
</LineChart>

🧭 다음 추천 순서

  1. 먼저 docker-compose로 프로젝트 실행 상태 확인
  2. JWT 로그인 인증 기능을 백엔드에 추가
  3. 그 후 관리자 전용 페이지와 통계 그래프 구현 진행

물론이죠! 요청하신 3단계 구성에 맞춰 각 단계별 코드 템플릿을 직접 작성해드릴게요.
초보자도 바로 적용할 수 있게 설명과 함께 제공합니다.


✅ 1단계: docker-compose + 각 서비스 Dockerfile

🔸 docker-compose.yml

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
    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/Dockerfile

FROM openjdk:17-jdk-slim
WORKDIR /app
COPY target/*.jar app.jar
ENTRYPOINT ["java", "-jar", "app.jar"]

🔸 python-bert-server/Dockerfile

FROM 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/Dockerfile

FROM node:18
WORKDIR /app
COPY . .
RUN npm install
RUN npm run build
RUN npm install -g serve
EXPOSE 3000
CMD ["serve", "-s", "build"]

✅ 2단계: JWT 인증 (Spring Boot)

🔸 의존성 추가 (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.java

public 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();
    }
}

🔸 로그인 Controller (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();
    }
}

✅ 3단계: 관리자 대시보드 UI (React)

🔸 주요 페이지 목록

  • /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>
  );
}

📦 예시 API 응답 (Spring)

[
  {
    "text": "환불해주세요",
    "result": true,
    "prob": 0.913,
    "time": "2025-05-03T10:00:00"
  }
]

  1. React ↔ Spring 연결 테스트 코드 -------------------------------
  2. 관리자 차트용 API 예제 (Spring Controller)
  3. docker-compose 배포 시 .env 관리 및 보안 설정

좋아요! 이제 프론트엔드(React)와 백엔드(Spring Boot)가 잘 연결되는지 확인하기 위한 연동 테스트 코드를 직접 만들어볼게요.


✅ 목표

React에서 사용자가 입력한 문장을 Spring으로 보내고,
Spring은 다시 Python BERT 서버에 요청해 예측 결과를 받고
React에 결과를 표시하는 전체 흐름을 확인합니다.


📍1. React 측: Axios로 Spring API 호출

🔸 ChurnPredictForm.jsx

import 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;

📍2. Spring Boot 측: 예측 요청 컨트롤러

🔸 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.java

public 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;
    }
}

📍3. Python BERT 예측 API (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}

🔄 전체 데이터 흐름

  1. React: 사용자 입력 → POST /api/predict
  2. Spring: Python 서버로 전달 → POST /predict
  3. Python: 예측 결과 → Spring으로 응답
  4. Spring: JSON 결과 → React로 응답
  5. React: 결과 화면에 렌더링

✅ 테스트 전 준비 사항

  • Spring 서버: localhost:8080
  • Python BERT API: localhost:5000 (또는 도커 내부: bert-api:5000)
  • React: localhost:3000

⛳ 다음 추천

  • 이 흐름에서 BERT 모델을 실제로 연동하고 ----------------------
  • React에 관리자 대시보드 추가
  • 예측 결과 DB 저장 + 통계 분석

좋습니다! 지금까지는 Python FastAPI 서버에서 랜덤으로 예측 확률을 반환하는 **더미 응답(mock)**이었죠.
이번에는 실제 BERT 모델을 Python 서버에 연동해서, 입력된 상담 문장으로 실제 고객 이탈 확률을 계산하는 기능을 구현해봅니다.


✅ 목표

React → Spring → Python → 실제 BERT 모델 예측 결과 반환
\= 완전한 AI 예측 서비스화


📍1. BERT 모델 준비 (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.txt

transformers==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

📍2. 사전 학습된 모델 준비 (saved_model/)

💡 선택지

방법설명
✅ HuggingFace fine-tuned 모델 사용이미 이탈 예측을 학습한 BERT 모델 다운로드
🔨 직접 학습한 모델 사용Trainer.train()save_pretrained("saved_model/")
# 예시: 학습 후 저장
model.save_pretrained("saved_model/")
tokenizer.save_pretrained("saved_model/")

📍3. 실행

cd python-bert-server
uvicorn app:app --host 0.0.0.0 --port 5000

도커로 실행 시엔 해당 디렉토리를 빌드에 포함해야 합니다.


📍4. Spring과 연결 확인 (테스트 예)

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
}

✅ 결과

  • Spring → Python 서버에 고객 텍스트 전달
  • Python 서버 → BERT로 예측
  • 예측 결과(이탈 여부, 확률)를 Spring → React로 전달

✅ 다음 단계 추천

  1. 🤖 예측 결과를 MySQL에 저장
  2. 📈 관리자 대시보드에서 이탈률 통계 시각화
  3. 🔒 Spring + JWT 인증 통합 운영
  4. ☁️ 전체 시스템을 Docker로 통합 배포

원하시면 직접 학습할 수 있는 BERT 학습 코드도 제공해드릴 수 있어요.
👉 다음으로 구현하고 싶은 기능은 어떤 걸까요? (예: DB 저장, 관리자 차트, 데이터 히스토리 조회 등)

좋습니다! 이제 실서비스를 위한 핵심 두 기능:


✅ 1. 예측 결과를 MySQL에 저장 (Spring + MySQL)

🧩 저장할 데이터 구성 예시

항목설명
id예측 고유 번호 (PK)
text_input입력 문장
prediction이탈 여부 (true/false)
probability이탈 확률 (0.0 ~ 1.0)
username예측 요청자 (JWT 사용자 ID)
created_at요청 시각

🔸 1-1. SQL 테이블 생성 (초기 스크립트 또는 JPA 자동 생성)

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
);

🔸 1-2. 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
}

🔸 1-3. Repository 생성

public interface ChurnLogRepository extends JpaRepository<ChurnLog, Long> {
    List<ChurnLog> findByUsername(String username);
}

🔸 1-4. 예측 서비스에서 저장 로직 추가

@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;
}

✅ 2. Spring + JWT 인증 통합 운영

🔸 2-1. 로그인 성공 시 토큰 발급 (/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();
}

🔸 2-2. JWT에서 사용자 추출

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) 가져올 수 있음!


🔒 전체 인증 흐름 요약

  1. 사용자는 /auth/login에 username/password 전송
  2. 서버는 JWT 발급 후 React에 반환
  3. React는 Authorization: Bearer <token>을 모든 요청에 포함
  4. Spring은 JwtAuthenticationFilter로 토큰 검증
  5. 예측 API에서는 사용자 이름을 추출해 예측 결과와 함께 저장

💡 관리용 로그 API 예시 (/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));
}

✅ 다음 추천 흐름

  1. 관리자 페이지에서 로그 테이블 & 그래프 시각화 -----------------------------
  2. JWT 만료 체크 & 로그아웃
  3. 결과 PDF 저장 or 친구에게 공유 기능 (선택)

좋아요! 이제 JWT 인증 기반으로 로그인한 관리자가 예측 로그를 조회하고, 이탈률을 시각화하는
**관리자 대시보드 (React 기반)**를 구성해보겠습니다.


✅ 1. 목표 기능 요약

기능설명
🔐 관리자 로그인JWT 기반 로그인 (기 구현됨)
📋 예측 로그 테이블 출력전체 로그 목록 출력 (시간순, 확률 등 포함)
📈 이탈률 그래프 시각화날짜별 이탈률 변화 추이 그래프

✅ 2. 백엔드: API 구성 (Spring Boot)

🔸 2-1. 전체 예측 로그 조회 API

@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에서 사용자 역할 관리


🔸 2-2. 날짜별 이탈률 통계 API

@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();

✅ 3. 프론트엔드: 관리자 페이지 (React)

🔸 3-1. 전체 로그 테이블 컴포넌트 (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;

🔸 3-2. 날짜별 이탈률 그래프 (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;

🔸 3-3. 관리자 메인 대시보드 (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;

✅ 4. 관리자 인증 처리 (React)

axios.get('/admin/logs', {
  headers: {
    Authorization: `Bearer ${localStorage.getItem('token')}`
  }
})

👉 401 Unauthorized 발생 시:

  • 로그인 페이지로 redirect 처리
  • 또는 "세션이 만료되었습니다" 메시지 표시

✅ 요약: 전체 구성

구성 요소구현 완료 상태
관리자 JWT 로그인
예측 로그 저장
전체 로그 API
날짜별 이탈률 API
React 로그 테이블
React 이탈률 그래프

다음 단계 추천

  • 🔐 토큰 만료 시 자동 로그아웃 처리 ----------------------------------

좋습니다! JWT 토큰 만료 시 자동 로그아웃 처리는 실제 운영에 꼭 필요한 보안 기능입니다.
아래는 React에서 JWT 만료를 감지하고 자동 로그아웃 시키는 방법을 단계별로 설명합니다.


✅ 목표

  • 로그인 시 저장된 JWT 토큰의 만료시간(exp) 확인
  • 매 페이지 접근/요청 시 토큰 유효성 검사
  • 만료된 경우 👉 자동 로그아웃 + 로그인 페이지로 리다이렉트

✅ 1단계: JWT 토큰 구조 이해

JWT는 3부분으로 구성되어 있으며, 가운데 payload를 디코딩하면 만료 시간(exp)이 포함되어 있습니다.

예:

{
  "sub": "admin",
  "exp": 1715000000
}
  • expUnix 타임스탬프 (초 단위)
  • 현재 시간이 이보다 크면 만료된 것

✅ 2단계: decodeToken 함수 구현 (Base64 디코딩)

function decodeToken(token) {
  try {
    const payload = token.split('.')[1];
    const decoded = JSON.parse(atob(payload));
    return decoded;
  } catch (e) {
    return null;
  }
}

✅ 3단계: 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;
}

✅ 4단계: 자동 로그아웃 처리 로직

🔸 App 컴포넌트 or Router 진입 시 검사

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를 써도 요청마다 검사할 수 있습니다.


✅ 5단계: Axios 요청 인터셉터로 만료 검사 (선택사항)

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;
});

✅ 6단계: 로그인 시 토큰 만료 시간 표시 (선택)

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 방식으로 구현해보겠습니다.


✅ 1. 방식 요약

방식시점사용 위치
useEffect앱이 로드될 때 (최초 1회)App.jsx 또는 Layout
axios.interceptorsAPI 요청마다axios.js 등 API 유틸 파일

→ 둘 다 함께 쓰는 것이 가장 안전합니다.


✅ 2. useEffect 방식 (앱 로드 시 1회 검사)

🔸 App.jsx 또는 MainLayout.jsx

import 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;

✅ 3. 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(...)으로 변경 필요


✅ 4. 유틸 함수: 토큰 만료 검사

🔸 utils/auth.js

export 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;
}

✅ 테스트 흐름 요약

  1. 로그인 → JWT 저장됨 (localStorage)

  2. 사용자가 새로고침(F5) or 앱 재접속 시:

    • useEffect → 토큰 검사 → 만료 시 logout + redirect
  3. API 호출 시:

    • axios.interceptor → 토큰 검사 → 만료 시 자동 로그아웃

✅ 보안 향상 팁

기능효과
⏰ JWT exp 1~2시간 설정짧은 수명으로 보안 강화
🔁 Refresh Token 사용만료 시 재발급 가능 (Spring + DB 필요)
🔒 토큰 저장 위치 httpOnlyXSS 공격 방지 (서버 쿠키 방식 사용 시)

좋습니다!
이번에는 JWT 만료 5분 전 경고 표시로 사용자 경험을 개선하고,
로그인을 유지할 수 있도록 Refresh Token 기반 인증 구조까지 함께 구현해보겠습니다.


✅ 1. 만료 5분 전 경고 표시 (React)

🔸 목적

  • 사용자의 JWT가 곧 만료되면 "곧 로그아웃됩니다" 알림을 띄워줌
  • 사용자가 원하는 경우 재로그인 또는 토큰 갱신 버튼 제공

🔸 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);
    }
  }, []);
}

✅ 2. Refresh Token 구조 개요

Access TokenRefresh Token
수명: 짧음 (15분~1시간)수명: 김 (7일 등)
헤더로 보냄DB or 쿠키로 저장
노출 시 위험 높음서버 검증으로 안전 관리

✅ 3. Spring Boot 구조

🔸 3-1. 엔티티: RefreshToken

@Entity
public class RefreshToken {
    @Id
    private String username;

    @Column(nullable = false)
    private String token;

    private Instant expiryDate;

    // 생성자, getter/setter
}

🔸 3-2. 로그인 시 RefreshToken 발급 및 저장

@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));
}

🔸 3-3. Refresh 요청 API

@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));
}

✅ 4. React에서 Refresh Token 사용

🔸 토큰 갱신 함수

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);
}

✅ 5. 요약: 전체 흐름

[로그인]
   ↓
accessToken (15분), refreshToken (7일) 저장
   ↓
[5분 전]
→ 자동 갱신 or 알림
   ↓
[accessToken 만료]
→ refreshToken 이용해 재발급
   ↓
[refreshToken도 만료]
→ 완전 로그아웃

다음 단계 추천

  • accessToken 자동 갱신 후 axios 요청 재시도
  • 관리자 토큰/세션 유지시각 UI 표시 ------------------------------------
  • OAuth 연동 구조로 확장 (Google 로그인 등) --------------------------------

좋아요! 아래에 두 가지 기능을 차례대로 자세히 구현해드립니다:


✅ 1. 관리자 토큰/세션 유지시각 UI 표시

🧩 목표

  • JWT 만료 시간(exp)을 디코딩하여 로그인 유지 시간 표시
  • 만료까지 남은 시간 카운트다운
  • 만료 임박 시 경고 or 재로그인 유도

🔸 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>
  );
}

✅ 2. OAuth 연동: Google 로그인 구조 (Spring Boot + React)

🧩 목표

  • Google 계정으로 로그인 → JWT 발급 → 세션 유지
  • 프론트엔드에서 Google 로그인 버튼 클릭 → 백엔드로 코드 전송
  • 백엔드에서 구글에 토큰 요청 → 사용자 정보 받아 JWT 생성

✅ A. Google OAuth 앱 등록

  1. https://console.developers.google.com

  2. 새 프로젝트 생성 → OAuth 2.0 클라이언트 ID 발급

  3. 승인된 리디렉션 URI:

    http://localhost:8080/oauth2/callback/google
  4. Client ID / Secret 복사


✅ B. Spring Boot 설정 (Spring Security + OAuth2)

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

✅ OAuth 설정 및 JWT 발급 핸들러

@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)
)

✅ C. React에서 구글 로그인 버튼

🔸 LoginPage.jsx

function LoginPage() {
  const handleGoogleLogin = () => {
    window.location.href = 'http://localhost:8080/oauth2/authorization/google';
  };

  return (
    <button onClick={handleGoogleLogin}>
      Google 계정으로 로그인
    </button>
  );
}

✅ D. 로그인 성공 시 처리 (/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도 만료
   → 로그인 페이지로 리다이렉트

✅ 1. 스프링 백엔드: OAuth 로그인 시 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);
    }
}

✅ 2. 프론트엔드: 리다이렉트 후 토큰 저장

🔸 OAuthSuccess.jsx

import { 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>;
}

✅ 3. 스프링 API: /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));
}

✅ 4. React: 만료 시 자동 재발급 요청

🔸 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;
});

✅ 5. JWT 유틸 함수 (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;
}

✅ 요약: OAuth + Refresh 통합 인증 흐름

[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 또는 쿠키)
🌐 OAuth2Google 로그인 등 연동 (email 기반 식별)
🧱 구조화 목표“JWT 발급, 검증, 재발급, 세션 체크” 모두 별도 모듈로 구성

📦 모듈화 구조 (Spring Boot 기준)

📁 auth/
├── 📄 JwtTokenProvider.java          # JWT 생성/검증
├── 📄 JwtAuthenticationFilter.java   # 요청마다 인증 필터
├── 📄 RefreshToken.java              # 엔티티
├── 📄 RefreshTokenRepository.java    # JPA 리포지토리
├── 📄 RefreshService.java            # 토큰 저장/갱신 로직
├── 📄 AuthController.java            # 로그인 / 갱신 API
├── 📄 OAuth2LoginSuccessHandler.java # 구글 로그인 성공 후 JWT 발급
├── 📄 AuthConfig.java                # Spring Security 설정

🧩 핵심 모듈 상세

1. 🔐 JwtTokenProvider.java

@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();
    }
}

2. 🔁 RefreshService.java

@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);
    }
}

3. 🌐 OAuth2LoginSuccessHandler.java

@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);
}

4. 🔒 JwtAuthenticationFilter.java

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);
    }
}

5. 🔧 AuthConfig.java (Spring Security 설정)

@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();
    }
}

✅ 프론트엔드 (React) 구조 정리

📁 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 인증 시스템 전체를 템플릿 프로젝트로 구조화하고,
📦 모듈 재사용이 가능한 형태로 압축해드릴게요. 이후 어떤 프로젝트든 복붙만 하면 됩니다.


✅ 📦 통합 인증 템플릿 프로젝트 구성

🔹 1. 기본 폴더 구조 (Spring Boot + React)

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 포함 가능)

🔹 2. 공통 환경 설정

  • JWT 만료 시간: Access Token 15분 / Refresh Token 7일

  • OAuth 지원: Google (추가로 Kakao, Naver 가능)

  • 인증 방식:

    • ✅ 기본 로그인 (이메일/비밀번호)
    • ✅ Google OAuth 로그인
    • ✅ JWT 인증 헤더
    • ✅ Refresh 자동 갱신 (React에서)

🔹 3. 백엔드 모듈 분리

패키지역할
jwt/JWT 생성/검증 도구 (JwtTokenProvider)
config/Spring Security 설정 (SecurityConfig, 필터)
controller/로그인, 토큰 갱신 API
oauth/구글 로그인 성공 핸들러
domain/RefreshToken 엔티티
repository/RefreshTokenRepository
service/ (선택)리프레시 관리 유틸 (RefreshService)

🔹 4. 프론트엔드 모듈 구조

파일기능
authUtils.jsJWT decode / 만료 판단
axiosInstance.js자동 토큰 갱신 포함 Axios
SessionStatus.jsx남은 세션 시간 UI
OAuthSuccess.jsxOAuth 토큰 수신 후 저장
LoginPage.jsx기본 로그인 또는 Google 버튼
PrivateRoute.jsx (옵션)인증 보호 라우팅

🔹 5. 사용 흐름 (전체 시나리오)

  1. 사용자 로그인 (기본 or Google)
  2. Spring이 AccessToken + RefreshToken 발급
  3. React가 두 토큰 저장
  4. 사용 중 AccessToken 만료 → 자동으로 /auth/refresh 요청
  5. RefreshToken 유효하면 새 AccessToken 수신
  6. 유효하지 않으면 로그인 페이지로 리다이렉트

✅ 💾 배포 템플릿 (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 쿠키로 RefreshXSS 방지, OAuth 쿠키 인증
☁️ Vercel + Render 배포 구성실전 배포용 템플릿
🔗 Kakao/Naver OAuth 연동국내 SNS 확장

📁 이 구조를 GitHub 템플릿으로 생성하거나 ZIP 파일로 제공해드릴 수도 있습니다.
다음으로 원하시는 작업은 무엇인가요? (배포, 코드 공유, 깃허브 업로드 등)

좋습니다! 요청하신 전체 작업은 다음과 같이 도와드릴 수 있습니다:


✅ 최종 요청 목록 정리

항목작업 내용
📦 ZIP 제공Spring + React 통합 인증 템플릿 전체 구조 압축 파일 제공
🚀 배포 준비Docker 기반 docker-compose 구성
🐙 GitHub 업로드리드미, 사용법 포함된 깃허브 레포지토리 생성용 템플릿 제공

✅ ZIP 제공 준비

포함 내용:

  • backend-auth/ (Spring Boot JWT + OAuth2 + RefreshToken 모듈 구조)
  • frontend-react/ (React + 자동 토큰 갱신 + 세션 유지 UI)
  • docker-compose.yml (통합 실행용)
  • README.md (구성 설명 + 실행 방법)

다음 단계:

제가 직접 코드를 준비하고 압축한 뒤에 다운로드 링크 또는 업로드용 파일을 드릴 수는 없습니다
하지만 다음 두 가지 방식으로 완전히 동일한 결과를 드릴 수 있습니다:


✅ 1. GitHub 레포 생성 스크립트 제공

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 만들고 압축된 파일 전체 업로드


✅ 2. 내가 바로 사용할 수 있는 ZIP 템플릿 만들기

원하시면 지금 이 구조로 템플릿 파일을 직접 생성해서 공유드릴 수 있도록, GitHub Gist/코드 생성 해드릴게요.

📝 확인 질문:

  1. 어떤 이름으로 프로젝트를 구성할까요? (예: jwt-auth-template)
  2. OAuth는 Google만 포함해도 괜찮을까요? (Kakao/Naver 추가는 선택)
  3. README.md는 영어/한국어 중 어떤 스타일로 원하시나요?

이 세 가지만 알려주시면 템플릿 압축 구성을 마무리하고, 업로드/배포 안내까지 정리해드릴게요.

profile
@mgkick

0개의 댓글