1차 프로젝트📚 전체 맵3차 프로젝트
2차 프로젝트 · React + JWT

02. JWT AUTH

프론트(React)와 백엔드(Spring)를 분리하고 JWT 토큰으로 인증하는 방식

이 파일의 학습 목적
왜 1차에서 2차로 바뀌었나?

1차는 서버가 HTML을 직접 만들고, 로그인 인증 코드를 모든 메서드마다 반복해야 했다. 2차는 이 두 가지 문제를 동시에 해결한다 — React로 화면을 분리하고, JWT + Filter로 인증 반복을 없앤다.

📍 전체 프로젝트 진화 흐름 — 지금 여기
✅ 완료 · 1차
Thymeleaf + Session
서버가 HTML 직접 생성
HttpSession으로 로그인
문제: 인증 코드 반복
📌 지금 여기 · 2차
React + JWT
React가 화면 담당 (분리)
JWT 토큰으로 인증
JwtFilter가 인증 일괄 처리
→ 인증 반복 해결!
3차
Spring Security 도입
Security FilterChain 완성
ROLE 기반 권한 분리
→ 보안 체계화
❌ 1차의 문제점 (해결 대상)
모든 Controller 메서드마다 session.getAttribute("loginUser") 반복
서버가 HTML 생성 → 프론트/백 분리 불가
세션은 서버 메모리에 저장 → 서버 여러 대면 세션 공유 문제
✅ 2차의 해결책
JwtFilter가 모든 요청에서 토큰 검증 → 인증 반복 제거
React 분리 → 백엔드는 JSON만 반환
JWT는 서버에 아무것도 저장 안 함 (Stateless)
Overview
1차와 무엇이 달라졌나
항목1차 (Session)2차 (JWT)
인증 방식HttpSession — 서버 메모리 저장JWT 토큰 — 클라이언트 localStorage 저장
컨트롤러@Controller — String(뷰이름) 반환@RestController — ResponseEntity(JSON) 반환
데이터 수신@ModelAttribute (HTML form)@RequestBody (JSON)
프론트엔드Thymeleaf (서버 렌더링)React (클라이언트 렌더링)
로그인 체크모든 메서드마다 session.getAttribute()JwtFilter가 요청마다 자동으로 토큰 검증
포트8081 (통합)백엔드 8082 / 프론트 3001 (분리)
💡 JWT(JSON Web Token) — 구조와 동작 원리

로그인 성공 시 서버가 발급하는 암호화된 문자열. 3부분이 점(.)으로 연결된 구조다.

eyJhbGciOiJIUzI1NiJ9 · eyJzdWIiOiJob25nIn0 · SflKxwRJSMeKKF2QT4fw
   Header (알고리즘)          Payload (실제 데이터)        Signature (위변조 검증)

Header — 서명 알고리즘 종류 (예: HS256)
Payload — 실제 담긴 데이터. username(sub), nickname, 만료시간(exp) 등. Base64로 인코딩된 것이지 암호화가 아님 → 민감한 정보(비밀번호 등) 넣으면 안 됨!
Signature — Header + Payload를 서버만 아는 secret 키로 서명. 이 부분 덕분에 위변조를 감지할 수 있음

클라이언트가 localStorage에 보관하고, 이후 모든 API 요청 시 Authorization: Bearer {토큰} 헤더에 담아 전송. 서버는 Signature만 검증하면 됨 — 서버는 아무것도 저장하지 않음 (Stateless).

Flow — Login
로그인 전체 흐름
React → Spring → JWT 발급 → localStorage 저장
React
Login.jsx — axios POST 요청
입력값을 JSON으로 변환해서 백엔드로 전송
api.post("/api/user/login", {username, password})
Filter
JwtFilter.doFilterInternal()
로그인 요청엔 토큰이 없으므로 통과 — 그냥 다음으로 넘김
Authorization 헤더 없음 filterChain.doFilter()
Controller
UserController.login()
@RequestBody로 JSON 수신 → userService.login() 호출
@RestController @RequestBody UserDto
Service
UserService.login() → JWT 발급
DB 조회 → 비밀번호 확인 → JwtUtil로 토큰 생성 → String 반환
jwtUtil.createToken(username, nickname)
React
localStorage에 토큰 저장 → 이동
서버로부터 받은 JWT를 브라우저에 저장 → 게시판으로 이동
localStorage.setItem("token", response.data.token)
게시글 작성 전체 흐름 — 토큰 자동 첨부
api.js interceptor가 모든 요청에 토큰 자동 첨부
React
BoardWrite.jsx — 글쓰기 폼 제출
api.post()로 제목, 내용 JSON 전송
api.post("/api/board/write", {title, content})
api.js
axios interceptor — 토큰 자동 첨부
모든 요청 전에 localStorage에서 토큰을 꺼내 헤더에 자동 삽입
config.headers.Authorization = `Bearer ${token}`
JwtFilter
JwtFilter — 토큰 검증 → loginUser 저장
Authorization 헤더에서 토큰 추출 → 유효성 검증 → username 추출 → DB에서 User 조회 → request.setAttribute
jwtUtil.validateToken(token) request.setAttribute("loginUser", user)
Controller
BoardController.write()
request.getAttribute()로 loginUser 꺼냄 → boardService.write() 호출
User loginUser = (User) request.getAttribute("loginUser")
Service
BoardService.write() → DB 저장 → JSON 응답
boardRepository.save(board) ResponseEntity.ok(Map.of("message","성공"))
⚠️ 2차의 남은 문제 — 3차로 넘어가는 이유

JwtFilter를 직접 만들어서 CorsConfig에 수동 등록해야 함. Controller에서 여전히 request.getAttribute("loginUser")로 직접 꺼내야 함. 역할(role) 기반 권한 관리가 없음. 이 문제들을 Spring Security가 체계적으로 해결한다.

Project Structure
2차 프로젝트 파일 구조 — 1차 대비 추가/변경

1차에서 Entity, Repository는 그대로다. 새로 생긴 폴더와 파일이 핵심이다.

📁 변경된 파일 구조
project-root/
├─ build.gradle ← JWT 의존성 추가
│
├─ src/main/java/com/example/
│  ├─ entity/ ← 1차와 동일
│  ├─ repository/ ← 1차와 동일
│  │
│  ├─ config/ ← 신규 폴더
│  │  ├─ AppConfig.java (BCrypt Bean)
│  │  └─ CorsConfig.java (CORS + JwtFilter 등록)
│  │
│  ├─ util/ ← 신규 폴더
│  │  ├─ JwtUtil.java (토큰 생성/검증)
│  │  └─ JwtFilter.java (요청마다 토큰 체크)
│  │
│  ├─ dto/
│  │  └─ BoardDto.java ← 필드 추가
│  ├─ service/
│  │  ├─ UserService.java ← 반환타입 변경
│  │  └─ BoardService.java ← toDto() 추가
│  └─ controller/
│     ├─ UserController.java ← @RestController
│     └─ BoardController.java ← @RestController
│
└─ src/main/resources/
   └─ application.properties ← jwt.secret 추가

React (별도 프로젝트)
src/
├─ api/api.js (axios 공통 + interceptor)
├─ App.jsx (PrivateRoute)
└─ pages/
   ├─ Login.jsx
   ├─ BoardList.jsx
   ├─ BoardDetail.jsx
   └─ ...
🔑 2차에서 핵심적으로 이해해야 할 개념 3가지
① @Value — application.properties 값 주입

@Value("${jwt.secret}") 를 필드 위에 붙이면 Spring이 application.properties의 jwt.secret 값을 자동으로 넣어준다. 코드에 비밀 키를 직접 하드코딩하지 않아도 되고, 환경마다 다른 값을 쓸 수 있다.

② OncePerRequestFilter — 요청당 딱 한 번만 실행

JwtFilter가 OncePerRequestFilter를 상속하는 이유 — 일반 Filter는 같은 요청에서 여러 번 실행될 수 있다. OncePerRequestFilter하나의 HTTP 요청에서 정확히 한 번만 실행됨을 보장한다. JWT 검증 같은 작업이 중복 실행되면 안 되므로 이걸 사용한다.

③ FilterRegistrationBean — JwtFilter 수동 등록

Spring Boot는 @Component가 붙은 Filter를 자동으로 모든 URL에 등록한다. 하지만 JwtFilter는 /api/** 에만 적용해야 한다. FilterRegistrationBean으로 수동 등록하면 적용 URL 패턴을 직접 지정할 수 있다.
3차에서는? Spring Security가 FilterChain을 완전히 관리하므로 FilterRegistrationBean이 필요 없어진다.

Full Code — 변경/추가 파일
전체 코드 (1차 대비 변경/추가된 파일)

Entity, Repository는 1차와 동일. 아래는 변경되거나 새로 추가된 파일만 표시.

build.gradle 수정
build.gradle — 1차 대비 변경사항
dependencies {
    implementation "org.springframework.boot:spring-boot-starter-data-jpa"
    // thymeleaf 제거!
    implementation "org.springframework.boot:spring-boot-starter-webmvc"
    implementation "org.springframework.security:spring-security-crypto" // BCrypt만 (Security 전체 X)
    // ↓ JWT 관련 3개 추가
    implementation "io.jsonwebtoken:jjwt-api:0.12.3"
    runtimeOnly "io.jsonwebtoken:jjwt-impl:0.12.3"
    runtimeOnly "io.jsonwebtoken:jjwt-jackson:0.12.3"
    compileOnly "org.projectlombok:lombok"
    runtimeOnly "org.mariadb.jdbc:mariadb-java-client"
    annotationProcessor "org.projectlombok:lombok"
    testImplementation "org.springframework.boot:spring-boot-starter-test"
}
application.properties 수정
application.properties
spring.datasource.url=jdbc:mariadb://localhost:3306/boarddb
spring.datasource.username=root
spring.datasource.password=1234
spring.datasource.driver-class-name=org.mariadb.jdbc.Driver
spring.jpa.hibernate.ddl-auto=update
spring.jpa.show-sql=true

# JWT 설정 추가 (1차에 없던 것)
jwt.secret=mySecretKeyForJWTTokenGenerationAndValidation2024
jwt.expiration=86400000  # 24시간 (밀리초)
util/JwtUtil.java 신규
util/JwtUtil.java
@Component
public class JwtUtil {
    @Value("${jwt.secret}") private String secret;
    @Value("${jwt.expiration}") private Long expiration;

    private SecretKey getSigningKey() {
        return Keys.hmacShaKeyFor(secret.getBytes());
    }

    public String createToken(String username, String nickname) {
        return Jwts.builder()
            .subject(username)
            .claim("nickname", nickname)
            .issuedAt(new Date())
            .expiration(new Date(System.currentTimeMillis() + expiration))
            .signWith(getSigningKey())
            .compact();
    }

    public String getUsername(String token) {
        return getClaims(token).getSubject();
    }

    public boolean validateToken(String token) {
        try { getClaims(token); return true; }
        catch (Exception e) { return false; }
    }

    private Claims getClaims(String token) {
        return Jwts.parser().verifyWith(getSigningKey()).build()
            .parseSignedClaims(token).getPayload();
    }
}
util/JwtFilter.java 신규
util/JwtFilter.java
@Component @RequiredArgsConstructor
public class JwtFilter extends OncePerRequestFilter {
    private final JwtUtil jwtUtil;
    private final UserRepository userRepository;

    @Override
    protected void doFilterInternal(HttpServletRequest request,
            HttpServletResponse response, FilterChain filterChain)
            throws ServletException, IOException {

        String authHeader = request.getHeader("Authorization");
        if (authHeader != null && authHeader.startsWith("Bearer ")) {
            String token = authHeader.substring(7); // "Bearer " 7글자 제거
            if (jwtUtil.validateToken(token)) {
                String username = jwtUtil.getUsername(token);
                // DB에서 User 조회 → request에 저장
                userRepository.findByUsername(username).ifPresent(user ->
                    request.setAttribute("loginUser", user)
                );
            }
        }
        filterChain.doFilter(request, response); // 다음 필터/컨트롤러로
    }
}
config/CorsConfig.java 신규
config/CorsConfig.java
@Configuration @RequiredArgsConstructor
public class CorsConfig implements WebMvcConfigurer {
    private final JwtFilter jwtFilter;

    @Override
    public void addCorsMappings(CorsRegistry registry) {
        registry.addMapping("/api/**")
            .allowedOrigins("http://localhost:3000")
            .allowedMethods("GET","POST","PUT","DELETE")
            .allowedHeaders("*")
            .allowCredentials(true);
    }

    // JwtFilter를 수동 등록 — 3차에서는 SecurityConfig가 대신 처리
    @Bean
    public FilterRegistrationBean<JwtFilter> jwtFilterRegistration() {
        FilterRegistrationBean<JwtFilter> registration = new FilterRegistrationBean<>(jwtFilter);
        registration.addUrlPatterns("/api/*");
        return registration;
    }
}
dto/BoardDto.java 수정 — 필드 추가
dto/BoardDto.java
@Getter @Setter
public class BoardDto {
    private Long id;           // 추가 (상세/수정/삭제에 필요)
    private String title;
    private String content;
    private String nickname;   // 추가 (목록에 작성자 표시)
    private String username;   // 추가 (본인 글 여부 확인)
    private int viewCount;     // 추가
    private String createdAt;  // 추가 (LocalDateTime → String 변환)
    private Long userId;       // 추가
}
service/UserService.java 수정
service/UserService.java
@Service @RequiredArgsConstructor
public class UserService {
    private final UserRepository userRepository;
    private final BCryptPasswordEncoder bCryptPasswordEncoder;
    private final JwtUtil jwtUtil; // 추가!

    // register()는 1차와 동일

    // 반환타입 변경: User → String (JWT 토큰)
    public String login(String username, String password) {
        User user = userRepository.findByUsername(username)
            .orElseThrow(() -> new IllegalArgumentException("아이디 또는 비밀번호가 틀렸습니다."));
        if (!bCryptPasswordEncoder.matches(password, user.getPassword()))
            throw new IllegalArgumentException("아이디 또는 비밀번호가 틀렸습니다.");
        return jwtUtil.createToken(user.getUsername(), user.getNickname()); // JWT 반환!
    }
}
service/BoardService.java 수정 — toDto 추가
service/BoardService.java
@Service @RequiredArgsConstructor
public class BoardService {
    private final BoardRepository boardRepository;

    // 반환타입 변경: Page<Board> → Page<BoardDto>
    public Page<BoardDto> getBoardList(int page) {
        Pageable pageable = PageRequest.of(page, 10, Sort.by("createdAt").descending());
        Page<Board> boardPage = boardRepository.findAllByOrderByCreatedAtDesc(pageable);
        List<BoardDto> dtoList = boardPage.getContent().stream()
            .map(this::toDto).collect(Collectors.toList());
        return new PageImpl<>(dtoList, pageable, boardPage.getTotalElements());
    }

    // write, update, delete 는 1차와 동일

    // toDto() 추가 — Entity를 JSON으로 변환할 때 사용
    private BoardDto toDto(Board board) {
        BoardDto dto = new BoardDto();
        dto.setId(board.getId());
        dto.setTitle(board.getTitle());
        dto.setContent(board.getContent());
        dto.setViewCount(board.getViewCount());
        dto.setUserId(board.getUser().getId());
        dto.setNickname(board.getUser().getNickname());
        dto.setUsername(board.getUser().getUsername());
        dto.setCreatedAt(board.getCreatedAt()
            .format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm")));
        return dto;
    }
}
controller/UserController.java 수정 — @RestController
controller/UserController.java
@RestController @RequiredArgsConstructor   // @Controller → @RestController
@RequestMapping("/api/user")               // /user → /api/user
public class UserController {
    private final UserService userService;

    @PostMapping("/register")
    public ResponseEntity<?> register(@RequestBody UserDto userDto) {  // @RequestBody!
        try {
            userService.register(userDto);
            return ResponseEntity.ok(Map.of("message", "회원가입 성공"));
        } catch (IllegalArgumentException e) {
            return ResponseEntity.badRequest().body(Map.of("message", e.getMessage()));
        }
    }

    @PostMapping("/login")
    public ResponseEntity<?> login(@RequestBody UserDto userDto) {
        try {
            String token = userService.login(userDto.getUsername(), userDto.getPassword());
            return ResponseEntity.ok(Map.of("token", token)); // {"token":"eyJ..."}
        } catch (IllegalArgumentException e) {
            return ResponseEntity.badRequest().body(Map.of("message", e.getMessage()));
        }
    }
}
프론트엔드 React 핵심 파일
src/api/api.js — axios 공통 설정 (신규)
import axios from "axios";

const api = axios.create({ baseURL: "http://localhost:8080" });

// 모든 요청에 JWT 토큰 자동 첨부
api.interceptors.request.use((config) => {
    const token = localStorage.getItem("token");
    if (token) config.headers.Authorization = `Bearer ${token}`;
    return config;
});

// 401 응답 시 자동 로그아웃
api.interceptors.response.use(
    (response) => response,
    (error) => {
        if (error.response?.status === 401) {
            localStorage.removeItem("token");
            window.location.href = "/login";
        }
        return Promise.reject(error);
    }
);

export default api;
src/App.jsx — PrivateRoute (신규)
import { BrowserRouter, Routes, Route, Navigate } from "react-router-dom";

// 토큰 없으면 로그인 페이지로 강제 이동
const PrivateRoute = ({ children }) => {
    const token = localStorage.getItem("token");
    return token ? children : <Navigate to="/login" />;
};

function App() {
    return (
        <BrowserRouter>
            <Routes>
                <Route path="/login" element={<Login />} />
                <Route path="/register" element={<Register />} />
                <Route path="/board/list"
                    element={<PrivateRoute><BoardList /></PrivateRoute>} />
                <Route path="/board/write"
                    element={<PrivateRoute><BoardWrite /></PrivateRoute>} />
                <Route path="/board/detail/:id"
                    element={<PrivateRoute><BoardDetail /></PrivateRoute>} />
                <Route path="/board/edit/:id"
                    element={<PrivateRoute><BoardEdit /></PrivateRoute>} />
                <Route path="/" element={<Navigate to="/login" />} />
            </Routes>
        </BrowserRouter>
    );
}
export default App;
💡 PrivateRoute — 토큰 없으면 자동으로 로그인 페이지로 튕김

PrivateRoute는 React에서 만드는 인증 보호 컴포넌트다. 동작 원리는 단순하다.

localStorage에 token이 있으면 → 자녀 컴포넌트(BoardList 등)를 그대로 렌더링
localStorage에 token이 없으면 → <Navigate to="/login" /> 로 강제 이동

이렇게 하면 URL을 직접 입력해서 /board/list에 접근하려 해도, 토큰이 없으면 자동으로 로그인 페이지로 튕겨진다. 백엔드의 JwtFilter와 쌍을 이루는 프론트엔드 인증 방어선이다.

src/pages/Login.jsx (핵심)
function Login() {
    const [username, setUsername] = useState("");
    const [password, setPassword] = useState("");
    const navigate = useNavigate();

    const handleLogin = async () => {
        try {
            const res = await api.post("/api/user/login",
                { username, password });
            // 토큰을 localStorage에 저장
            localStorage.setItem("token", res.data.token);
            navigate("/board/list");
        } catch (e) {
            setError(e.response?.data?.message);
        }
    };
}
src/pages/BoardDetail.jsx (핵심)
import { jwtDecode } from "jwt-decode";

function BoardDetail() {
    const [isOwner, setIsOwner] = useState(false);

    const fetchBoard = async () => {
        const res = await api.get(`/api/board/detail/${id}`);
        // JWT 디코딩으로 본인 글 여부 확인
        const token = localStorage.getItem("token");
        const decoded = jwtDecode(token);
        setIsOwner(decoded.sub === res.data.username);
    };
    // isOwner true일 때 수정/삭제 버튼 표시
}
💡 jwtDecode — 프론트에서 토큰 내용을 읽는 방법

백엔드의 jwtUtil.getUsername(token)은 서버에서 서명(Signature)을 검증하며 토큰을 파싱한다.
프론트의 jwtDecode(token)은 서명 검증 없이 Payload 부분만 Base64 디코딩해서 읽는다.

BoardDetail에서 본인 글인지 확인하는 용도로만 쓴다 — 수정/삭제 버튼을 보여줄지 결정할 때.
실제 수정/삭제 권한은 백엔드에서 다시 검증한다. 프론트 디코딩만으로는 보안이 되지 않는다.

decoded.sub → username (JWT의 subject 필드)
decoded.nickname → nickname (createToken 시 claim으로 담은 값)

Next — 2차 → 3차 변경 포인트
3차에서 무엇이 왜 바뀌나
핵심 변경 포인트 4가지
직접 만든 JwtFilter → Spring Security의 JwtAuthenticationFilter
2차의 JwtFilter는 직접 CorsConfig에 수동 등록. 3차에서는 Spring Security Filter Chain에 통합. SecurityContext에 인증 정보를 저장하는 표준 방식 사용.
request.getAttribute("loginUser") → @AuthenticationPrincipal
2차에서는 Controller에서 직접 request에서 꺼냄. 3차에서는 Spring Security가 SecurityContext에서 자동으로 꺼내줌. 코드가 훨씬 깔끔해짐.
SecurityConfig 도입 — 권한 별 URL 접근 제어
2차에서는 로그인 여부만 확인. 3차에서는 ROLE_USER / ROLE_ADMIN 구분. /api/admin/** 은 관리자만 접근 가능하도록 SecurityConfig에서 한 번에 설정.
UserDetailsService / UserDetails 인터페이스 구현
Spring Security가 인증을 처리하려면 UserDetails 방식이 필요. CustomUserDetails, CustomUserDetailsService를 구현해서 Security에 등록.
1차 프로젝트📚 전체 맵3차 프로젝트