프론트(React)와 백엔드(Spring)를 분리하고 JWT 토큰으로 인증하는 방식
1차는 서버가 HTML을 직접 만들고, 로그인 인증 코드를 모든 메서드마다 반복해야 했다. 2차는 이 두 가지 문제를 동시에 해결한다 — React로 화면을 분리하고, JWT + Filter로 인증 반복을 없앤다.
session.getAttribute("loginUser") 반복| 항목 | 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 (분리) |
로그인 성공 시 서버가 발급하는 암호화된 문자열. 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).
JwtFilter를 직접 만들어서 CorsConfig에 수동 등록해야 함. Controller에서 여전히 request.getAttribute("loginUser")로 직접 꺼내야 함. 역할(role) 기반 권한 관리가 없음. 이 문제들을 Spring Security가 체계적으로 해결한다.
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 └─ ...
@Value("${jwt.secret}") 를 필드 위에 붙이면 Spring이 application.properties의 jwt.secret 값을 자동으로 넣어준다. 코드에 비밀 키를 직접 하드코딩하지 않아도 되고, 환경마다 다른 값을 쓸 수 있다.
JwtFilter가 OncePerRequestFilter를 상속하는 이유 — 일반 Filter는 같은 요청에서 여러 번 실행될 수 있다. OncePerRequestFilter는 하나의 HTTP 요청에서 정확히 한 번만 실행됨을 보장한다. JWT 검증 같은 작업이 중복 실행되면 안 되므로 이걸 사용한다.
Spring Boot는 @Component가 붙은 Filter를 자동으로 모든 URL에 등록한다. 하지만 JwtFilter는 /api/** 에만 적용해야 한다. FilterRegistrationBean으로 수동 등록하면 적용 URL 패턴을 직접 지정할 수 있다.
3차에서는? Spring Security가 FilterChain을 완전히 관리하므로 FilterRegistrationBean이 필요 없어진다.
Entity, Repository는 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"
}
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시간 (밀리초)
@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(); } }
@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); // 다음 필터/컨트롤러로 } }
@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; } }
@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 @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 @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; } }
@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())); } } }
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;
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는 React에서 만드는 인증 보호 컴포넌트다. 동작 원리는 단순하다.
localStorage에 token이 있으면 → 자녀 컴포넌트(BoardList 등)를 그대로 렌더링
localStorage에 token이 없으면 → <Navigate to="/login" /> 로 강제 이동
이렇게 하면 URL을 직접 입력해서 /board/list에 접근하려 해도, 토큰이 없으면 자동으로 로그인 페이지로 튕겨진다. 백엔드의 JwtFilter와 쌍을 이루는 프론트엔드 인증 방어선이다.
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); } }; }
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일 때 수정/삭제 버튼 표시 }
백엔드의 jwtUtil.getUsername(token)은 서버에서 서명(Signature)을 검증하며 토큰을 파싱한다.
프론트의 jwtDecode(token)은 서명 검증 없이 Payload 부분만 Base64 디코딩해서 읽는다.
BoardDetail에서 본인 글인지 확인하는 용도로만 쓴다 — 수정/삭제 버튼을 보여줄지 결정할 때.
실제 수정/삭제 권한은 백엔드에서 다시 검증한다. 프론트 디코딩만으로는 보안이 되지 않는다.
decoded.sub → username (JWT의 subject 필드)
decoded.nickname → nickname (createToken 시 claim으로 담은 값)