https://wooncloud.tistory.com/161
Spring 기본 시작 - 세팅부터 CRUD API 까지
1. 자바 설치 (Java JDK 17 이상 설치)Java 8 (2014년 출시)여전히 많은 기업에서 레거시 시스템으로 사용 중람다 표현식, Stream API 등 중요한 기능이 도입된 버전장기적으로는 점차 마이그레이션하는 추
wooncloud.tistory.com
https://wooncloud.tistory.com/162
Spring 기본 - Service 계층
이전 내용https://wooncloud.tistory.com/161 Spring 기본 시작 - 세팅부터 CRUD API 까지1. 자바 설치 (Java JDK 17 이상 설치)Java 8 (2014년 출시)여전히 많은 기업에서 레거시 시스템으로 사용 중람다 표현식, Stream
wooncloud.tistory.com
https://wooncloud.tistory.com/163
Spring 기본 - DTO (Data Transfer Object) 패턴
https://wooncloud.tistory.com/161 Spring 기본 - Service 계층이전 내용https://wooncloud.tistory.com/161 Spring 기본 시작 - 세팅부터 CRUD API 까지1. 자바 설치 (Java JDK 17 이상 설치)Java 8 (2014년 출시)여전히 많은 기업에
wooncloud.tistory.com
https://wooncloud.tistory.com/165
Spring 기본 - 게시판 만들기
https://wooncloud.tistory.com/161 Spring 기본 - DTO (Data Transfer Object) 패턴https://wooncloud.tistory.com/161 Spring 기본 - Service 계층이전 내용https://wooncloud.tistory.com/161 Spring 기본 시작 - 세팅부터 CRUD API 까지1. 자바
wooncloud.tistory.com

JWT 인증 시스템 만들기
JWT가 무엇인가?
https://wooncloud.tistory.com/166
JWT (JSON Web Token) 설명
JWT 기본 구조JWT는 점(.)으로 구분된 3개 부분으로 이루어져 있다.xxxxx.yyyyy.zzzzz각 부분은 Base64URL로 인코딩되어 있다.1. Header (헤더)역할: 토큰의 타입과 서명 알고리즘 정보{ "alg": "HS256", "typ": "JWT"}a
wooncloud.tistory.com
JWT에 대한 정보는 여기에 적어놨다.
JWT 의존성 추가
JWT 관련
1. io.jsonwebtoken:jjwt-api:0.11.5
- JWT(JSON Web Token) 처리를 위한 핵심 API
- JWT 토큰 생성, 파싱, 검증을 위한 인터페이스와 클래스들을 제공
- 실제 구현체는 포함하지 않고 API만 제공하는 추상화 레이어
2. io.jsonwebtoken:jjwt-impl:0.11.5 (runtimeOnly)
- JJWT API의 실제 구현체
- JWT 토큰의 실제 생성, 파싱, 서명 검증 로직이 포함
- runtimeOnly로 설정되어 컴파일 시점에는 필요하지 않고 런타임에만 필요
3. io.jsonwebtoken:jjwt-jackson:0.11.5 (runtimeOnly)
- JWT 페이로드의 JSON 직렬화/역직렬화를 위한 Jackson 바인딩
- JWT 클레임을 JSON으로 변환하거나 JSON에서 객체로 변환할 때 사용
- 마찬가지로 런타임에만 필요
Jwts (JJWT 라이브러리)
JSON Web Token을 생성/파싱하는 Java 라이브러리입니다.
- 공식 GitHub: https://github.com/jwtk/jjwt
- API 문서: https://javadoc.io/doc/io.jsonwebtoken/jjwt-api/latest/index.html
Spring Security 관련
4. org.springframework.boot:spring-boot-starter-security
- Spring Boot의 보안 기능을 위한 스타터 패키지
- 인증(Authentication), 인가(Authorization), CSRF 보호 등 웹 보안 기능 제공
- 기본적인 로그인 폼, 세션 관리, 보안 필터 체인 등이 자동 구성됨
5. org.springframework.security:spring-security-crypto
- 암호화 및 해싱 기능을 제공하는 Spring Security 모듈
- 비밀번호 해싱(BCrypt, SCrypt, Pbkdf2 등), 암호화, 키 생성 등의 기능
- 주로 사용자 비밀번호를 안전하게 저장하기 위한 해싱에 사용
dependencies {
// 기존 의존성들...
// JWT 관련
implementation 'io.jsonwebtoken:jjwt-api:0.11.5'
runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.11.5'
runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.11.5'
// Spring Security
implementation 'org.springframework.boot:spring-boot-starter-security'
// 비밀번호 암호화
implementation 'org.springframework.security:spring-security-crypto'
}
그래서 지금은 이렇게 의존성이 구성되어 있다.
dependencies {
// Spring Boot Core - 웹 애플리케이션 개발 기본 설정
implementation 'org.springframework.boot:spring-boot-starter-web' // REST API, MVC
implementation 'org.springframework.boot:spring-boot-starter-thymeleaf' // 템플릿 엔진
developmentOnly 'org.springframework.boot:spring-boot-devtools' // 개발 도구 (Hot Reload)
// Database - 데이터베이스 연동 및 ORM
implementation 'org.springframework.boot:spring-boot-starter-data-jpa' // JPA, Hibernate ORM
runtimeOnly 'org.postgresql:postgresql' // PostgreSQL 드라이버
// Object Mapping - 객체 변환
implementation 'org.modelmapper:modelmapper:3.1.1' // DTO ↔ Entity 변환
// Security - 인증 및 보안
implementation 'org.springframework.boot:spring-boot-starter-security' // Spring Security
implementation 'org.springframework.security:spring-security-crypto' // 비밀번호 암호화
// JWT - JSON Web Token 인증
implementation 'io.jsonwebtoken:jjwt-api:0.11.5' // JWT API
runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.11.5' // JWT 구현체
runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.11.5' // JWT JSON 처리
// Testing - 테스트 프레임워크
testImplementation 'org.springframework.boot:spring-boot-starter-test' // Spring Boot 테스트
testRuntimeOnly 'org.junit.platform:junit-platform-launcher' // JUnit 플랫폼
}
User Entity 수정
package com.example.demo_api.users.entity;
import jakarta.persistence.*;
import java.time.LocalDateTime;
@Entity
@Table(name = "users")
public class User {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false)
private String name;
@Column(nullable = false, unique = true)
private String email;
@Column(nullable = false, unique = true)
private String userId; // 추가
@Column(nullable = false)
private String password; // 추가
@Enumerated(EnumType.STRING)
private UserRole role; // 추가
@Column(name = "created_at")
private LocalDateTime createdAt;
@Column(name = "updated_at")
private LocalDateTime updatedAt;
public User() {
}
public User(String name, String email, String userId, String password) {
this.name = name;
this.email = email;
this.userId = userId;
this.password = password;
this.role = UserRole.USER;
this.createdAt = LocalDateTime.now();
this.updatedAt = LocalDateTime.now();
}
// getter, setter...
}
package com.example.demo_api.entity;
public enum Role {
USER, ADMIN
}
JWT 관련 DTO
LoginRequestDto.java
package com.example.demo_api.dto;
public class LoginRequestDto {
private String userId;
private String password;
// 기본 생성자
public LoginRequestDto() {}
// 생성자
public LoginRequestDto(String userId, String password) {
this.userId = userId;
this.password = password;
}
// getter, setter
public String getUserId() { return userId; }
public void setUserId(String userId) { this.userId = userId; }
public String getPassword() { return password; }
public void setPassword(String password) { this.password = password; }
}
RegisterRequestDto.java
package com.example.demo_api.dto;
public class RegisterRequestDto {
private String name;
private String email;
private String userId;
private String password;
// 기본 생성자
public RegisterRequestDto() {}
// 생성자, getter, setter
}
AuthResponseDto.java
package com.example.demo_api.dto;
public class AuthResponseDto {
private String token;
private String userId;
private String name;
private String message;
// 생성자
public AuthResponseDto(String token, String userId, String name, String message) {
this.token = token;
this.userId = userId;
this.name = name;
this.message = message;
}
// getter, setter
}
JWT 유틸리티 만들기
JwtUtil.java
util 패키지를 만들고 JwtUtil.java 생성
package com.example.demo_api.util;
import io.jsonwebtoken.*;
import io.jsonwebtoken.security.Keys;
import org.springframework.stereotype.Component;
import java.security.Key;
import java.util.Date;
@Component
public class JwtUtil {
@Value("${jwt.secret-key}")
private String secretKey;
@Value("${jwt.expiration-time}")
private long expirationTime;
private Key getSigningKey() {
return Keys.hmacShaKeyFor(secretKey.getBytes());
}
// JWT 토큰 생성
public String generateToken(String userId, String name) {
return Jwts.builder()
.setSubject(userId)
.claim("name", name)
.setIssuedAt(new Date())
.setExpiration(new Date(System.currentTimeMillis() + expirationTime))
.signWith(getSigningKey(), SignatureAlgorithm.HS256)
.compact();
}
// JWT 토큰에서 사용자 ID 추출
public String getUserIdFromToken(String token) {
return getClaimsFromToken(token).getSubject();
}
// JWT 토큰에서 이름 추출
public String getNameFromToken(String token) {
return getClaimsFromToken(token).get("name", String.class);
}
// JWT 토큰 유효성 검증
public boolean isTokenValid(String token) {
try {
getClaimsFromToken(token);
return true;
} catch (JwtException e) {
return false;
}
}
// JWT 토큰이 만료되었는지 확인
public boolean isTokenExpired(String token) {
return getClaimsFromToken(token).getExpiration().before(new Date());
}
private Claims getClaimsFromToken(String token) {
return Jwts.parserBuilder()
.setSigningKey(getSigningKey())
.build()
.parseClaimsJws(token)
.getBody();
}
}
application.properties에 JWT 관련 키 추가
아래 키는 제가 대충 아무글자 막 넣어서 만들었는데, 다른 키로 직접 만드시기 바랍니다.
아래 사이트에서 256bits로 만들면 적당합니다.
JWT Secret Free Key Generator | Secure JWT Tokens
Generate secure JWT secret keys with our free online tool. Create strong, random keys for your JWT tokens with customizable length and security options.
jwtsecrets.com
# JWT 설정
jwt.secret-key=mySecretKeyForJWTTokenGenerationAndValidation1234567890
jwt.expiration-time=86400000
JwtUtil 주요 메소드
- generateToken(): 로그인 성공시 JWT 생성
- getUserIdFromToken(): 토큰에서 사용자 ID 추출
- isTokenValid(): 토큰 유효성 검증 (서명, 만료시간)
- getClaimsFromToken(): 토큰 파싱해서 정보 추출
getClaimsFromToken 함수 동작
private Claims getClaimsFromToken(String token) {
return Jwts.parserBuilder() // JWT 파서 빌더 생성
.setSigningKey(getSigningKey()) // 서명 검증용 키 설정
.build() // 파서 완성
.parseClaimsJws(token) // 토큰 파싱 및 서명 검증
.getBody(); // Claims 객체 반환
}
토큰을 파싱하고 서명을 검증한 후 Claims 객체를 반환합니다.
만료시간 체크
getClaimsFromToken(token).getExpiration().before(new Date());
- getExpiration(): JWT의 exp 클레임 (만료시간) 가져오기
- before(new Date()): 만료시간이 현재시간보다 이전인지 확인
- true면 만료됨, false면 아직 유효함
HMAC 키 생성
Keys.hmacShaKeyFor(secretKey.getBytes());
- HMAC-SHA256 알고리즘용 암호화 키 생성
- 문자열 시크릿키를 바이트 배열로 변환 후 Key 객체 생성
- JWT 서명 생성/검증에 사용
@Value 어노테이션
@Value 어노테이션은 application.properties 파일의 설정값을 Java 변수에 주입하는 Spring의 기능.
- ${jwt.secret-key}: application.properties의 jwt.secret-key 값을 secretKey 변수에 주입
- ${jwt.expiration-time}: 토큰 만료시간을 expirationTime 변수에 주입
이렇게 설정값을 외부화하면 환경별로 다른 값을 사용할 수 있다.
AuthService
AuthService.java
package com.example.demo_api.service;
import com.example.demo_api.dto.*;
import com.example.demo_api.entity.User;
import com.example.demo_api.entity.UserRole;
import com.example.demo_api.repository.UserRepository;
import com.example.demo_api.util.JwtUtil;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service;
import java.time.LocalDateTime;
@Service
public class AuthService {
@Autowired
private UserRepository userRepository;
@Autowired
private PasswordEncoder passwordEncoder;
@Autowired
private JwtUtil jwtUtil;
// 회원가입
public AuthResponseDto register(RegisterRequestDto registerRequest) {
// userId 중복 체크
if (userRepository.existsByUserId(registerRequest.getUserId())) {
return new AuthResponseDto(null, null, null, "이미 존재하는 사용자 ID입니다.");
}
// 이메일 중복 체크
if (userRepository.existsByEmail(registerRequest.getEmail())) {
return new AuthResponseDto(null, null, null, "이미 존재하는 이메일입니다.");
}
// 비밀번호 암호화
String encodedPassword = passwordEncoder.encode(registerRequest.getPassword());
// 사용자 생성
User user = new User(
registerRequest.getName(),
registerRequest.getEmail(),
registerRequest.getUserId(),
encodedPassword
);
User savedUser = userRepository.save(user);
// JWT 토큰 생성
String token = jwtUtil.generateToken(savedUser.getUserId(), savedUser.getName());
return new AuthResponseDto(token, savedUser.getUserId(), savedUser.getName(), "회원가입이 완료되었습니다.");
}
// 로그인
public AuthResponseDto login(LoginRequestDto loginRequest) {
// 사용자 조회
User user = userRepository.findByUserId(loginRequest.getUserId()).orElse(null);
if (user == null) {
return new AuthResponseDto(null, null, null, "존재하지 않는 사용자입니다.");
}
// 비밀번호 검증
if (!passwordEncoder.matches(loginRequest.getPassword(), user.getPassword())) {
return new AuthResponseDto(null, null, null, "비밀번호가 일치하지 않습니다.");
}
// JWT 토큰 생성
String token = jwtUtil.generateToken(user.getUserId(), user.getName());
return new AuthResponseDto(token, user.getUserId(), user.getName(), "로그인 성공");
}
}
UserRepository 수정
// UserRepository.java에 추가할 메소드들
// userId로 사용자 조회
Optional<User> findByUserId(String userId);
// userId 중복 체크
boolean existsByUserId(String userId);
// 이메일 중복 체크
boolean existsByEmail(String email);
AuthController
AuthController.java
package com.example.demo_api.controller;
import com.example.demo_api.dto.*;
import com.example.demo_api.service.AuthService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.*;
@RestController
@RequestMapping("/api/auth")
public class AuthController {
@Autowired
private AuthService authService;
// 회원가입
@PostMapping("/register")
public AuthResponseDto register(@RequestBody RegisterRequestDto registerRequest) {
return authService.register(registerRequest);
}
// 로그인
@PostMapping("/login")
public AuthResponseDto login(@RequestBody LoginRequestDto loginRequest) {
return authService.login(loginRequest);
}
// 토큰 유효성 검증 (테스트용)
@GetMapping("/validate")
public String validateToken(@RequestHeader("Authorization") String authHeader) {
if (authHeader != null && authHeader.startsWith("Bearer ")) {
String token = authHeader.substring(7);
// JwtUtil로 토큰 검증 로직 추가 예정
return "Token validation endpoint";
}
return "Invalid token format";
}
}
Spring Security 설정
@Configuration
@EnableWebSecurity
public class SecurityConfig {
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http.csrf(csrf -> csrf.disable())
.sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.authorizeHttpRequests(auth -> auth
.requestMatchers("/api/auth/**").permitAll()
.requestMatchers("/api/users/**", "/api/posts/**", "/api/comments/**").permitAll()
.anyRequest().authenticated()
);
return http.build();
}
}
config 패키지를 만들고 SecurityConfig.java 생성
http.csrf(csrf -> csrf.disable())
CSRF 비활성화
JWT를 사용하는 Stateless 환경에서는 CSRF 공격에 대한 보호가 필요 없어 비활성화
SessionCreationPolicy.STATELESS: 서버에서 세션을 생성하지 않고 모든 요청을 독립적으로 처리
차후 프론트엔드와 연동 시 CORS 설정이 필요하다.
http.cors(cors -> cors.configurationSource(corsConfigurationSource()))
JwtAuthenticationFilter
package com.example.demo_api.config;
import com.example.demo_api.util.JwtUtil;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;
import java.io.IOException;
import java.util.ArrayList;
/**
* JWT 토큰을 검증하여 사용자 인증을 처리하는 필터
* 모든 HTTP 요청에 대해 한 번씩 실행됨
*/
@Component
public class JwtAuthenticationFilter extends OncePerRequestFilter {
@Autowired
private JwtUtil jwtUtil;
/**
* 요청마다 실행되는 필터 메소드
* JWT 토큰을 검증하고 인증 정보를 SecurityContext에 설정
*/
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response,
FilterChain filterChain) throws ServletException, IOException {
// HTTP 헤더에서 Authorization 값을 가져옴 (Bearer 토큰 형식)
String authHeader = request.getHeader("Authorization");
// Authorization 헤더가 존재하고 "Bearer "로 시작하는지 확인
if (authHeader != null && authHeader.startsWith("Bearer ")) {
// "Bearer " 부분을 제거하고 실제 JWT 토큰만 추출
String token = authHeader.substring(7);
// JWT 토큰이 유효하고 만료되지 않았는지 검증
if (jwtUtil.isTokenValid(token) && !jwtUtil.isTokenExpired(token)) {
// 토큰에서 사용자 ID 추출
String userId = jwtUtil.getUserIdFromToken(token);
// Spring Security 인증 객체 생성 (권한은 빈 리스트로 설정)
UsernamePasswordAuthenticationToken authentication =
new UsernamePasswordAuthenticationToken(userId, null, new ArrayList<>());
// SecurityContext에 인증 정보 저장 (이후 Controller에서 사용 가능)
SecurityContextHolder.getContext().setAuthentication(authentication);
}
}
// 다음 필터로 요청을 전달 (필터 체인 계속 진행)
filterChain.doFilter(request, response);
}
}
- 요청 헤더에서 JWT 토큰 추출
- 토큰 유효성 검증
- 토큰에서 사용자 정보 추출
- Spring Security 인증 객체 생성
- SecurityContext에 인증 정보 저장
- 다음 필터로 진행
Spring Security 필터 체인에서의 동작 순서
- 요청 들어옴 → JwtAuthenticationFilter 실행
- JWT 토큰 검증 → 유효하면 SecurityContext에 인증 정보 저장
- 다음 필터로 진행 → Spring Security의 다른 필터들 실행
- Controller 도달 → Authentication 객체로 사용자 정보 접근 가능
이 필터는 OncePerRequestFilter를 상속받아 요청당 한 번만 실행된다.
SecurityConfig에 JWT 필터를 추가
package com.example.demo_api.config;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
@Configuration
@EnableWebSecurity
public class SecurityConfig {
@Autowired
private JwtAuthenticationFilter jwtAuthenticationFilter;
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http.csrf(csrf -> csrf.disable())
.sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.authorizeHttpRequests(auth -> auth
.requestMatchers("/api/auth/**").permitAll() // 인증 API는 누구나 접근
.requestMatchers("GET", "/api/posts/**").permitAll() // 게시글 조회는 누구나
.requestMatchers("GET", "/api/comments/**").permitAll() // 댓글 조회는 누구나
.requestMatchers("/api/users/**").permitAll() // 임시로 허용
.anyRequest().authenticated() // 나머지는 인증 필요
)
// JWT 필터를 UsernamePasswordAuthenticationFilter 앞에 추가
.addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class);
return http.build();
}
}
- JWT 필터를 필터 체인에 추가
- GET 요청은 인증 없이 허용 (조회만 가능)
- POST, PUT, DELETE는 인증 필요
Controller에서 인증된 사용자 정보 가져오기
PostController 수정
@PostMapping
public PostDto createPost(@RequestBody PostCreateDto postCreateDto,
Authentication authentication) {
String userId = authentication.getName(); // JWT의 userId (문자열)
return postService.createPost(postCreateDto, userId);
}
public PostDto createPost(PostCreateDto postCreateDto, String userId) {
// Service에서 userId로 User 조회
User author = userRepository.findByUserIdAndDeletedAtIsNull(userId).orElse(null);
if (author == null) {
return null; // 사용자가 존재하지 않음
}
Post post = new Post(postCreateDto.getTitle(), postCreateDto.getContent(), author);
Post savedPost = postRepository.save(post);
return convertToDto(savedPost);
}
CommentService도 동일하게 수정
public CommentDto createComment(CommentCreateDto commentCreateDto, String userId) {
User author = userRepository.findByUserIdAndDeletedAtIsNull(userId).orElse(null);
Post post = postRepository.findByIdAndNotDeleted(commentCreateDto.getPostId()).orElse(null);
if (author == null || post == null) {
return null;
}
Comment comment = new Comment(commentCreateDto.getContent(), author, post);
Comment savedComment = commentRepository.save(comment);
return convertToDto(savedComment);
}
테스트
1. 로그인해서 토큰 받기
curl -X POST http://localhost:8080/api/auth/login \
-H "Content-Type: application/json" \
-d '{"userId": "hong123", "password": "password123"}'
2. 받은 토큰으로 게시글 작성
curl -X POST http://localhost:8080/api/posts \
-H "Content-Type: application/json" \
-H "Authorization: Bearer YOUR_JWT_TOKEN" \
-d '{"title": "인증된 사용자의 글", "content": "JWT 토큰으로 작성한 글입니다!"}'
3. 토큰 없이 게시글 작성 시도 (실패해야 함)
curl -X POST http://localhost:8080/api/posts \
-H "Content-Type: application/json" \
-d '{"title": "실패할 글", "content": "토큰이 없어서 실패"}'
4. 게시글 조회 (토큰 없어도 가능)
curl http://localhost:8080/api/posts
이정도면 게시판 완성인것 같습니다. 휴..