1. 개발환경
카테고리 | 도구/기술 |
프로그래밍 언어 | Java 17 |
프레임워크 | Spring Boot 3.4.2 |
빌드 도구 | gradle |
데이터베이스 | MySQL |
ORM 도구 | Spring Data JPA |
IDE | IntelliJ |
버전 관리 | Git, GitHub, GitLab |
패키지 관리 | Maven Central |
API 문서화 | Swagger |
로깅 | SLF4J |
2.build.gradle
plugins {
id 'java'
id 'org.springframework.boot' version '3.4.2'
id 'io.spring.dependency-management' version '1.1.7'
}
group = 'com.example'
version = '0.0.1-SNAPSHOT'
java {
toolchain {
languageVersion = JavaLanguageVersion.of(17)
}
}
configurations {
compileOnly {
extendsFrom annotationProcessor
}
}
repositories {
mavenCentral()
}
dependencies {
// JWT
compileOnly group: 'io.jsonwebtoken', name: 'jjwt-api', version: '0.11.5'
runtimeOnly group: 'io.jsonwebtoken', name: 'jjwt-impl', version: '0.11.5'
runtimeOnly group: 'io.jsonwebtoken', name: 'jjwt-jackson', version: '0.11.5'
// Security
implementation 'org.springframework.boot:spring-boot-starter-security'
// MySQL db
runtimeOnly 'com.mysql:mysql-connector-j'
// JPA 사용
implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
// Web 스타터 - 아파치 톰켓, Web MVC, JSON 직렬화&역직렬화, validation
implementation 'org.springframework.boot:spring-boot-starter-web'
// getter, setter 등의 어노테이션
compileOnly 'org.projectlombok:lombok'
annotationProcessor 'org.projectlombok:lombok'
// 테스트 관련
testImplementation 'org.springframework.boot:spring-boot-starter-test'
testRuntimeOnly 'org.junit.platform:junit-platform-launcher'
}
tasks.named('test') {
useJUnitPlatform()
}
3. 디렉토리 구조
📂 src
├── 📂 main
│ ├── 📂 java
│ │ └── 📂 com.example.springboot3template
│ │ ├── 📄 Springboot3templateApplication.java
│ │ │
│ │ ├── 📂 auth
│ │ │ ├── 📂 application # 🌟 애플리케이션 서비스 계층
│ │ │ │ ├── 📂 dto
│ │ │ │ │ ├── 📂 mapper
│ │ │ │ │ ├── 📂 req
│ │ │ │ │ │ ├── 📄 LoginReq.java
│ │ │ │ │ │ ├── 📄 SignUpReq.java
│ │ │ │ │ ├── 📂 res
│ │ │ │ │ ├── 📄 GetUserInfoRes.java
│ │ │ │ ├── 📂 service
│ │ │ │ ├── 📄 AuthService.java
│ │ │ │ ├── 📄 UserService.java
│ │ │
│ │ │ ├── 📂 domain # 🌟 도메인 계층 (엔티티)
│ │ │ │ ├── 📂 entity
│ │ │ │ │ ├── 📄 User.java
│ │ │ │ │ ├── 📄 UserRoleEnum.java
│ │ │
│ │ │ ├── 📂 infrastructure # 🌟 인프라 계층 (데이터 저장소)
│ │ │ │ ├── 📂 repository
│ │ │ │ │ ├── 📄 UserRepository.java
│ │ │ │ ├── 📂 security
│ │ │ │ ├── 📄 UserDetailsImpl.java
│ │ │ │ ├── 📄 UserDetailsServiceImpl.java
│ │ │
│ │ │ ├── 📂 presentation # 🌟 프레젠테이션 계층 (컨트롤러)
│ │ │ │ ├── 📂 controller
│ │ │ │ │ ├── 📄 AuthController.java
│ │ │ │ │ ├── 📄 UserController.java
│ │ │
│ │ ├── 📂 common
│ │ │ ├── 📂 security
│ │ │ │ ├── 📄 JwtFilter.java
│ │ │ │ ├── 📄 JwtProvider.java
│ │ │ │ ├── 📄 SecurityConfig.java
│ │
│ ├── 📂 resources
│ │ ├── 📄 application-local.yml
│ │ ├── 📄 application.yml
│ │ ├── 📂 static
│ │ ├── 📂 templates
4. 추가된 코드 및 흐름
포스트맨으로 로그인 후(쿠키에 JWT 토큰을 저장해) 다음과 같은 요청을 보낸다.
Get
http://localhost:8080/api/v1/user/info/me
1. SecurityConfig
모든 요청은 SecurityFilterChain이 가로채 허용되지 않은 요청에 대해 인증 과정을 거치는데 이 과정에서 추가된 JwtFilter가 실행된다.
@Configuration
@EnableWebSecurity // Spring Security 지원을 가능하게 함
//@EnableGlobalMethodSecurity(securedEnabled = true, prePostEnabled = true)
@EnableMethodSecurity
@RequiredArgsConstructor
public class SecurityConfig {
private final JwtFilter jwtFilter;
@Bean
public AuthenticationManager authenticationManager(AuthenticationConfiguration configuration) throws Exception {
return configuration.getAuthenticationManager();
}
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
// CSRF 설정
http.csrf((csrf) -> csrf.disable());
// Session 비활성화
http.sessionManagement((sessionManagement) ->
sessionManagement.sessionCreationPolicy(SessionCreationPolicy.STATELESS)
);
// 요청 권한 설정
http.authorizeHttpRequests((authorizeHttpRequests) ->
authorizeHttpRequests
.requestMatchers(PathRequest.toStaticResources().atCommonLocations()).permitAll() // resources 접근 허용 설정
// api 요청 허가
.requestMatchers("/api/v1/auth/login", "/api/v1/auth/sign-up").permitAll()
// HTML 페이지 접근을 인증 없이 허용
// .requestMatchers("/").permitAll()
.anyRequest().authenticated() // 그 외 모든 요청 인증처리
);
// 필터 추가 (JWT 인증 필터를 UsernamePasswordAuthenticationFilter 앞에 추가)
http.addFilterBefore(
jwtFilter,
UsernamePasswordAuthenticationFilter.class
);
return http.build();
}
}
2. JwtFilter
쿠키에 담긴 Jwt 토큰을 추출해 검증하는 필터로 isAuthorizationPassRequest 에 허용된(예를들어 회원가입,로그인) 경로가 아니면 토큰의 유효성 및 변조등을 확인하고 문제가 없다면 userDetailsService 를 통해 해당 유저 정보를 db에서 조회하고 이를 userDetails 객체로 변환해 반환한다.
이렇게 반환된 객체를 기반으로 UsernamePasswordAuthenticationToken 이라는 스프링 시큐리티에서 사용자의 인증 정보를 담는 객체를 생성하고 이렇게 생성된 객체를 SecurityContext 라는 보안 컨텍스트에 저장 이 SecurityContext는 SecurityContextHolder 라는 컨테이너에 저장되 관리된다.(자세한 내용은 너무 길어지니 다음에 따로 설명하겠다.)
이렇게 저장된 현재 사용자의 인증정보는 @AuthenticationPrincipal 라는 어노테이션을 통해 SecurityContextHolder 에 저장된 Authentication 객체에서 principal을 가져와 자동으로 매핑해 사용할 수 있다. (다만 이런 방식은 모놀리식 어플리케이션에서는 사용 가능하지만 JWT의 장점을 살릴 수 있는 Micro-Service-Application 에서는 SecurityContext를 다른 서비스와 공유할 수 없어 사용할 수 없다.)
@Component
@RequiredArgsConstructor
@Slf4j
public class JwtFilter extends OncePerRequestFilter {
private final UserDetailsService userDetailsService;
@Value("${jwt.secret.key}")
private String secretKey;
@Override
protected void doFilterInternal(@NonNull HttpServletRequest request, @NonNull HttpServletResponse response, @NonNull FilterChain filterChain)
throws ServletException, IOException {
String url = request.getRequestURI();
if (isAuthorizationPassRequest(url)) {
log.info("인증 제외 API 요청: {}", url);
filterChain.doFilter(request, response);
return;
}
// 토큰 확인 및 검증 로직 유지
String token = getTokenFromRequest(request);
log.info("요청에서 추출된 토큰: {}", token);
if (StringUtils.hasText(token)) {
SecretKey key = getSecretKey();
if (!validateToken(token, key)) {
throw new IllegalArgumentException("Token Error");
}
Claims claims = getUserInfoFromToken(token, key);
String username = claims.getSubject();
UserDetails userDetails = userDetailsService.loadUserByUsername(username);
UsernamePasswordAuthenticationToken authentication =
new UsernamePasswordAuthenticationToken(userDetails, null, userDetails.getAuthorities());
SecurityContextHolder.getContext().setAuthentication(authentication);
log.info("인증 완료: {}", SecurityContextHolder.getContext().getAuthentication());
} else {
log.warn("토큰이 요청에 포함되지 않았습니다.");
}
filterChain.doFilter(request, response);
}
// 토큰 검증
public boolean validateToken(String token, SecretKey key) {
if (token == null || token.isEmpty()) {
log.error("토큰 값이 null 또는 비어있습니다.");
return false;
}
try {
Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(token);
log.info("토큰 검증 성공");
return true;
} catch (SecurityException | MalformedJwtException | SignatureException e) {
log.error("Invalid JWT signature, 유효하지 않는 JWT 서명 입니다.");
} catch (ExpiredJwtException e) {
log.error("Expired JWT token, 만료된 JWT token 입니다.");
} catch (UnsupportedJwtException e) {
log.error("Unsupported JWT token, 지원되지 않는 JWT 토큰 입니다.");
} catch (IllegalArgumentException e) {
log.error("JWT claims is empty, 잘못된 JWT 토큰 입니다.");
}
return false;
}
// HttpServletRequest 에서 Cookie Value : JWT 가져오기
public String getTokenFromRequest(HttpServletRequest req) {
Cookie[] cookies = req.getCookies();
if (cookies != null) {
for (Cookie cookie : cookies) {
log.info("쿠키 이름: {}, 쿠키 값: {}", cookie.getName(), cookie.getValue());
if (cookie.getName().equals(AUTHORIZATION_HEADER)) {
String cookieValue = cookie.getValue();
if (cookieValue != null) {
// 그냥 토큰을 그대로 사용합니다. URLDecoder.decode는 필요없음
log.info("디코딩된 토큰: {}", cookieValue); // 디코딩 없이 그대로 사용
return cookieValue;
} else {
log.error("쿠키 값이 null 입니다.");
}
}
}
}
log.warn("Authorization 쿠키가 요청에 포함되지 않았습니다.");
return null;
}
// 토큰에서 사용자 정보 가져오기
public Claims getUserInfoFromToken(String token, SecretKey key) {
return Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(token).getBody();
}
// 인증 통과 url
private boolean isAuthorizationPassRequest(String path) {
return path.startsWith("/api/v1/auth/login") || path.startsWith("/api/v1/auth/sign-up");
}
private SecretKey getSecretKey() {
if (secretKey == null || secretKey.isEmpty()) {
throw new IllegalStateException("JwtFilter에 secretKey가 null입니다.");
}
return Keys.hmacShaKeyFor(Decoders.BASE64URL.decode(secretKey));
}
}
3. UserDetailsServiceImpl
username을 통해 db를 조회하고 해당 객체를 userDetailsImpl 객체로 변환(스프링 시큐리티가 사용할 수 있도록)하여 반환
@Service
@RequiredArgsConstructor
public class UserDetailsServiceImpl implements UserDetailsService {
private final UserRepository userRepository;
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
User user = userRepository.findByUsername(username)
.orElseThrow(() -> new UsernameNotFoundException("아이디를 찾을 수 없습니다. " + username));
return new UserDetailsImpl(user);
}
}
4. UserDeatilsImpl
@AllArgsConstructor
public class UserDetailsImpl implements UserDetails {
private final User user;
public User getUser() {
return user;
}
@Override
public String getPassword() {
return user.getPassword();
}
@Override
public String getUsername() {
return user.getUsername();
}
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
UserRoleEnum role = user.getRole();
String authority = role.getAuthority();
SimpleGrantedAuthority simpleGrantedAuthority = new SimpleGrantedAuthority(authority);
Collection<GrantedAuthority> authorities = new ArrayList<>();
authorities.add(simpleGrantedAuthority);
return authorities;
}
@Override
public boolean isAccountNonExpired() {
return true;
}
@Override
public boolean isAccountNonLocked() {
return true;
}
@Override
public boolean isCredentialsNonExpired() {
return true;
}
@Override
public boolean isEnabled() {
return true;
}
}
5. UserController
요청이 들어오면 @AuthenticationPrincipal 어노테이션을 통해 현재 인증된 사용자의 정보를 UserDeatilsImpl을 통해 가져오고 UserService의 해당 비즈니스 로직을 수행한다..
@RequestMapping("/api/v1/user")
@RestController
@RequiredArgsConstructor
public class UserController {
private final UserService userService;
// 본인 정보 조회
@PreAuthorize("hasRole('ADMIN')")
@GetMapping("/info/me")
public ResponseEntity<GeuUserInfo> getMyInfo(@AuthenticationPrincipal UserDetailsImpl userDetails) {
GeuUserInfo myInfo = userService.getMyInfo(userDetails);
return ResponseEntity.ok(myInfo); // 현재 인증된 사용자 정보 반환
}
}
6. UserService
UserDeatilsImpl 를 통해 User객체에서 필요한 정보를 조회하고
@Service
@RequiredArgsConstructor
public class UserService {
private final UserRepository userRepository;
// 본인 정보 조회
@Transactional(readOnly = true)
public ReadUserInfo getMyInfo(UserDetailsImpl userDetails) {
Long userId = userDetails.getUser().getUserId();
User user = userRepository.findById(userId)
.orElseThrow(() -> new RuntimeException("사용자 정보를 찾을 수 없습니다."));
GeuUserInfo getMyInfo = new GeuUserInfo();
getMyInfo.setUsername(user.getUsername());
return getMyInfo;
}
}
7. GetUserInfoRes
DTO에 매핑해 반환한다.
@Data
public class GetUserInfoRes {
private String username;
}
'Spring Boot > Sprng Boot Sample Code' 카테고리의 다른 글
Spring Boot 에서 네이버 클라우드 Object Storage 에 파일 업로드 (1) | 2025.03.03 |
---|---|
[SpringBoot 3.x] 4. @PreAuthorize 어노테이션을 이용한 API 인가 설정 (0) | 2025.03.03 |
[Spring Boot 3.x] 2. JWT 토큰과 쿠키를 이용한 로그인 구현 (0) | 2025.02.24 |
[Spring Boot 3.x] 1. 회원가입 구현 (1) | 2025.02.02 |
build.gradle 이해하기 (1) | 2025.01.30 |