hyeonga_code
Project_03_Spring Security, JWT 구현 본문
Project_04_Spring Web Layer
Spring Web Layer1. Web Layer뷰 템플릿 영역입니다.( @Controller, JSP, Freemarker ) 외부 요청과 응답에 대한 전반적인 영역입니다.( @Filter, 인터셉터, @ControllerAdvice ) 2. Service Layer@Service에 사용되는 서비스 영
hyeonga493.tistory.com
요약
User 테이블 설정(Entity, Dto, Repository)
Service, Controller 구현
SpringSecurity + JWT 구현
TokenProviderTest
AddUser() / UserController
Login() / AuthController
1. api 모듈에 프로젝트 패키지 생성하기
현재 사용할 패키지들을 먼저 생성한다. (controller, dto, entity, error, repository, service)
2. User Entity
package com.hyeongarl.entity;
import jakarta.persistence.*;
import jakarta.validation.constraints.Email;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
import java.time.LocalDateTime;
/*
@Table(name="")
데이터베이스의 name에 지정된 이름의 테이블과 매핑된다.
@Entity
JPA 엔티티임을 명시하여 데이터베이스 테이블의 레코드와 매핑
@Getter, @Setter
롬복 라이브러리의 어노테이션으로 모든 필드에 대해 자동으로 getter/setter를 생성
@NoArgsConstructor
롬복 라이브러리의 어노테이션으로 매개변수가 없는 기본 생성자를 자동으로 생성
*/
@Table(name="users")
@Entity
@Getter
@Setter
@NoArgsConstructor
public class User {
/*
@Id
테이블의 기본 키
@GeneratedValue(strategy = GenerationType.IDENTITY)
기본 피의 생성 전략을 지정
IDENTITY : 데이터베이스가 기본 키 생성을 담당
기본적으로 AUTO_INCREMENT를 사용
@Column(name="user_id", updatable = false)
데이터베이스의 name과 일치하는 컬럼과 매핑된다.
필드가 수정될 수 없음을 의미
*/
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name="user_id", updatable = false)
private Long userId;
/*
@Email
유효한 이메일 형식을 규정
@Column(name="user_email", nullable = false, unique = true)
데이터베이스의 name과 일치하는 컬럼과 매핑된다.
null 값을 가질 수 없음을 의미
유일한 값을 가져야 함을 의미
*/
@Email
@Column(name="user_email", nullable = false, unique = true)
private String userEmail;
@Column(name = "user_password", nullable = false, length=25)
private String password;
@Column(name="user_regdate", updatable = false)
private LocalDateTime userRegdate;
@Builder
public User(String userEmail, String password) {
this.userEmail = userEmail;
this.password = password;
this.userRegdate = LocalDateTime.now();
} //end User()
}
서버 실행 시 테이블 생성 쿼리가 실행되는 것을 확인할 수 있다.
3. UserRepository /interface
package com.hyeongarl.repository;
import com.hyeongarl.entity.User;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
@Repository
public interface UserRepository extends JpaRepository<User, Long> {
}
4. UserService
package com.hyeongarl.service;
import com.hyeongarl.repository.UserRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
/*
@Service
Spring Framework에 의해 관리되는 서비스 클래스임을 명시
@RequiredArgsConstructor
롬복 라이브러리에서 final 필드에 대한 생성자를 자동으로 생성
*/
@Service
@RequiredArgsConstructor
public class UserService {
private final UserRepository userRepository;
}
@RequiredArgsConstructor 의 역할
public UserService(UserRepository userRepository) {
this.userRepository = userRepository;
}
5. DTO (UserRequestDto/UserResponseDto)
엔티티는 데이터베이스에 값을 저장하고 불러오는 역할이고 DTO는 클라이언트에서 서버로 데이터를 가지고 이동하는 역할이다.
이전 프로젝트에서는 dto를 하나만 작성해서 작업했었는데 클라이언트에게 입력받는 값과 클라이언트에게 반환하는 값이 다를 수 있어 request/response로 나누어 작업한다.
클라이언트에게 입력을 받고 반환하는 값을 데이터베이스에서 사용하기 위해 엔티티로 변환해야 하는 코드를 각 dto에 작업하여 dto 내에서 수정사항이 있는 경우 바로 수정할 수 있도록 작업했다.
// RequestDto
package com.hyeongarl.dto;
import com.hyeongarl.entity.User;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
@Builder
@Getter
@NoArgsConstructor
@AllArgsConstructor
public class UserRequestDto {
private String userEmail;
private String password;
public User toEntity() {
return User.builder()
.userEmail(userEmail)
.password(password)
.build();
} //end toEntity()
} //end class
// ResponseDto
package com.hyeongarl.dto;
import com.hyeongarl.entity.User;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
import java.time.LocalDateTime;
@Getter
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class UserResponseDto {
private String userEmail;
private String password;
private LocalDateTime userRegdate;
public static UserResponseDto fromEntity(User user) {
return UserResponseDto.builder()
.userEmail(user.getUserEmail())
.password(user.getPassword())
.userRegdate(user.getUserRegdate())
.build();
} //end fromEntity()
} //end class
6. UserController
package com.hyeongarl.controller;
import com.hyeongarl.service.UserService;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
@RequiredArgsConstructor
@RequestMapping("/user")
public class UserController {
private final UserService userService;
}
현재는 토큰 기반 인증 서비스를 구현하지 않았다.
SpringSecurity + JWT 적용하기
1. build.gradle 의존성 추가
> 기존 코드
plugins { // Gradle에서 지원하는 플러그인
id 'java'
id 'org.springframework.boot' version '3.2.5'
id 'io.spring.dependency-management' version '1.1.5'
id "io.freefair.lombok" version "8.6"
/*
1) id 'java'
java 코드 컴파일, 테스트, 애플리케이션 패키징
2) id 'org.springframework.boot' version '3.2.5'
SpringBoot 애플리케이션을 실행, 실행 가능한 JAR 또는 WAR패키징
3) id 'io.spring.dependency-management' version '1.1.5'
일관된 방식으로 종속성을 관리하는 데 사용
섹션을 사용하여 프로젝트의 모든 종속성에 적용해야 하는 종속성 버전을 정의하여 충돌을 방지
4) id "io.freefair.lombok" version "8.6"
롬복 사용 (gradle에서 관리하므로 플러그인으로 설정 가능)
*/
}
// 프로젝트 그룹 ID 설정 (패키지 이름 지정 및 종속성 관리에 사용)
group = 'com.heyongarl'
// 프로젝트의 버전을 설정
version = '1.0-SNAPSHOT'
java {
toolchain {
languageVersion = JavaLanguageVersion.of(17)
}
/*
toolchain VS sourceCompatibility
toolchain
Gradle 자체를 실행하는 데 사용되는 JDK와 별개로
프로젝트를 컴파일, 테스트, 실행하는 데 사용할 JDK 버전을 지정할 수 있다.
sourceCompatibility
소스 코드의 Java 버전 호환성을 명시하는 데 사용
*/
}
repositories {
mavenCentral()
}
dependencies {
implementation 'org.springframework.boot:spring-boot-starter-web'
implementation 'org.springframework.boot:spring-boot-starter-web-services'
implementation 'org.springframework.boot:spring-boot-starter-validation'
// JPA
implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
// MySQL JPA
runtimeOnly 'com.mysql:mysql-connector-j'
testImplementation 'org.springframework.boot:spring-boot-starter-test'
/*
1) implementation 'org.springframework.boot:spring-boot-starter-web'
Spring MVC를 사용하여 웹 애플리케이션을 빌드하기 위한 종속성
2) implementation 'org.springframework.boot:spring-boot-starter-web-services'
웹 서비스 제작을 지원
3) implementation 'org.springframework.boot:spring-boot-starter-validation'
Java Bean의 유효성을 검사하기 위한 종속성 (@NotNull, @Size)
4) implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
Spring Data JPA를 포함한 JPA 기반 데이터 엑세스에 대한 지원을 제공
5) runtimeOnly 'com.mysql:mysql-connector-j'
MySQL 데이터베이스에 연결하는 데 필요한 MySQL JDBC 드라이버
6) testImplementation 'org.springframework.boot:spring-boot-starter-test'
JUnit, Jamcrest, Mockito를 포함한 Spring Boot 애플리케이션 테스트를 위한 종속성
*/
}
tasks.named('test') {
useJUnitPlatform()
}
Spring Security 관련 라이브러리와 JWT 관련 라이브러리를 추가한다.
dependencies {
// Spring Security
implementation 'org.springframework.boot:spring-boot-starter-security:2.7.17'
testImplementation 'org.springframework.security:spring-security-test:5.7.11'
// JWT Token
implementation 'io.jsonwebtoken:jjwt:0.9.1'
/*
7) implementation 'org.springframework.boot:spring-boot-starter-security:2.7.17'
보안과 관련된 기능을 제공하는 스프링 보안 모듈에 대한 스타터 의존성
인증(Authentication)과 권한 부여(Authorization)을 위한 기능을 제공
웹 애플리케이션의 보안을 강화하는데 사용
8) testImplementation 'org.springframework.security:spring-security-test:5.7.11'
Spring Security 테스트에 필요한 라이브러리를 포함하는 의존성
인증 및 권한 부여를 테스트하거나 보안 관련 기능을 검증할 수 있음
9) implementation 'io.jsonwebtoken:jjwt:0.9.1'
JWT를 생성하고 처리하기 위한 라이브러리
*/
}
> 전체 코드
plugins { // Gradle에서 지원하는 플러그인
id 'java'
id 'org.springframework.boot' version '3.2.5'
id 'io.spring.dependency-management' version '1.1.5'
id "io.freefair.lombok" version "8.6"
/*
1) id 'java'
java 코드 컴파일, 테스트, 애플리케이션 패키징
2) id 'org.springframework.boot' version '3.2.5'
SpringBoot 애플리케이션을 실행, 실행 가능한 JAR 또는 WAR패키징
3) id 'io.spring.dependency-management' version '1.1.5'
일관된 방식으로 종속성을 관리하는 데 사용
섹션을 사용하여 프로젝트의 모든 종속성에 적용해야 하는 종속성 버전을 정의하여 충돌을 방지
4) id "io.freefair.lombok" version "8.6"
롬복 사용 (gradle에서 관리하므로 플러그인으로 설정 가능)
*/
}
// 프로젝트 그룹 ID 설정 (패키지 이름 지정 및 종속성 관리에 사용)
group = 'com.heyongarl'
// 프로젝트의 버전을 설정
version = '1.0-SNAPSHOT'
java {
toolchain {
languageVersion = JavaLanguageVersion.of(17)
}
/*
toolchain VS sourceCompatibility
toolchain
Gradle 자체를 실행하는 데 사용되는 JDK와 별개로
프로젝트를 컴파일, 테스트, 실행하는 데 사용할 JDK 버전을 지정할 수 있다.
sourceCompatibility
소스 코드의 Java 버전 호환성을 명시하는 데 사용
*/
}
repositories {
mavenCentral()
}
dependencies {
implementation 'org.springframework.boot:spring-boot-starter-web'
implementation 'org.springframework.boot:spring-boot-starter-web-services'
implementation 'org.springframework.boot:spring-boot-starter-validation'
// JPA
implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
// MySQL JPA
runtimeOnly 'com.mysql:mysql-connector-j'
testImplementation 'org.springframework.boot:spring-boot-starter-test'
// Spring Security
implementation 'org.springframework.boot:spring-boot-starter-security:2.7.17'
testImplementation 'org.springframework.security:spring-security-test:5.7.11'
// JWT Token
implementation 'io.jsonwebtoken:jjwt:0.9.1'
/*
1) implementation 'org.springframework.boot:spring-boot-starter-web'
Spring MVC를 사용하여 웹 애플리케이션을 빌드하기 위한 종속성
2) implementation 'org.springframework.boot:spring-boot-starter-web-services'
웹 서비스 제작을 지원
3) implementation 'org.springframework.boot:spring-boot-starter-validation'
Java Bean의 유효성을 검사하기 위한 종속성 (@NotNull, @Size)
4) implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
Spring Data JPA를 포함한 JPA 기반 데이터 엑세스에 대한 지원을 제공
5) runtimeOnly 'com.mysql:mysql-connector-j'
MySQL 데이터베이스에 연결하는 데 필요한 MySQL JDBC 드라이버
6) testImplementation 'org.springframework.boot:spring-boot-starter-test'
JUnit, Jamcrest, Mockito를 포함한 Spring Boot 애플리케이션 테스트를 위한 종속성
7) implementation 'org.springframework.boot:spring-boot-starter-security:2.7.17'
보안과 관련된 기능을 제공하는 스프링 보안 모듈에 대한 스타터 의존성
인증(Authentication)과 권한 부여(Authorization)을 위한 기능을 제공
웹 애플리케이션의 보안을 강화하는데 사용
8) testImplementation 'org.springframework.security:spring-security-test:5.7.11'
Spring Security 테스트에 필요한 라이브러리를 포함하는 의존성
인증 및 권한 부여를 테스트하거나 보안 관련 기능을 검증할 수 있음
9) implementation 'io.jsonwebtoken:jjwt:0.9.1'
JWT를 생성하고 처리하기 위한 라이브러리
*/
}
tasks.named('test') {
useJUnitPlatform()
}
2. 다시 빌드하기
3. JWT 관련 파일을 생성할 패키지를 생성
src / main / java / com / hyeongarl / config / jwt
4. application.properties
Jwt 발급자 정보와 시크릿 키를 설정
# JwtProperties
jwt.issuer=hyeongarl_issuer
jwt.secretKey=my_jwt_secretKey_202478451236978512
5. TokenProvider
package com.hyeongarl.config.jwt;
import com.hyeongarl.entity.User;
import io.jsonwebtoken.Claims;
import io.jsonwebtoken.Header;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.SignatureAlgorithm;
import lombok.RequiredArgsConstructor;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.stereotype.Service;
import java.time.Duration;
import java.util.Date;
import java.util.Set;
/**
* JWT 토큰 생성
* 유효성 검사
* 인증 정보 추출 및 사용자 ID 추출
*/
@RequiredArgsConstructor
@Service
public class TokenProvider {
private final JwtProperties jwtProperties;
public String generatedToken(User user, Duration expiredAt) {
Date now = new Date();
return Jwts.builder()
.setHeaderParam(Header.TYPE, Header.JWT_TYPE) // 헤더 typ: JWT
.setIssuer(jwtProperties.getIssuer()) // properties에서 설정한 issuer
.setIssuedAt(now) // iat : 현재 시간
.setExpiration(new Date(now.getTime() + expiredAt.toMillis())) // exp : expiry 값
.setSubject(user.getUserEmail()) // sub : 사용자 이메일
.claim("id", user.getUserId()) // 클레임 id : 사용자 id
// 서명 : 비밀값, 해시값을 HS256방식으로 암호화
.signWith(SignatureAlgorithm.HS256, jwtProperties.getSecretKey())
.compact();
} //end generatedToken()
// JWT 토큰 유효성 검증 메소드
public boolean validatedToken(String token) {
try {
Jwts.parser()
.setSigningKey(jwtProperties.getSecretKey()) // 비밀값으로 복호화
.parseClaimsJws(token);
return true;
} catch (Exception e) { // 복호화 과정 중 에러 발싱 시 유효하지 않은 토큰
return false;
}
} //end validToken()
// 토큰 기반 인증 정보 가져오는 메소드
public Authentication getAuthentication(String token) {
Claims claims = getClaims(token);
Set<SimpleGrantedAuthority> authorities = Set.of(new SimpleGrantedAuthority("ROLE_USER"));
return new UsernamePasswordAuthenticationToken(
new org.springframework.security.core.userdetails.User(claims.getSubject(), "", authorities), token, authorities);
} //end getAuthentication()
// 토큰 기반 사용자 ID 가져오는 메소드
public Long getUserId(String token) {
Claims claims = getClaims(token);
return claims.get("id", Long.class);
} //end getUserId()
private Claims getClaims(String token) {
return Jwts.parser()
.setSigningKey(jwtProperties.getSecretKey())
.parseClaimsJws(token)
.getBody();
} //end getClaims()
}
6. SecurityFilterChain
package com.hyeongarl.config;
import com.hyeongarl.config.jwt.TokenProvider;
import lombok.RequiredArgsConstructor;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.authentication.ProviderManager;
import org.springframework.security.authentication.dao.DaoAuthenticationProvider;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityCustomizer;
import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
import org.springframework.security.web.util.matcher.AntPathRequestMatcher;
/**
configure : 스프링 시큐리티 기능 비활성화
filterChain : 특정 HTTP 요청에 대한 웹 기반 보안 구성
authenticationManager : 인증 관리자 관련 설정
bCryptPasswordEncoder : 비밀번호 인코더로 사용할 빈 등록
*/
@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class SecurityFilterChain {
private final TokenProvider tokenProvider;
private static String secretKey = "my-secret-key-777";
@Bean // 스프링 시큐리티 기능 비활성화(인증, 인가 서비스를 모든 곳에 적용하지 않는다.)
public WebSecurityCustomizer configure() {
return (web) -> web.ignoring()
.requestMatchers("h2-console/**")
.requestMatchers(new AntPathRequestMatcher("/static/**"));
} //end configure()
@Bean // 특정 HTTP 요청에 대한 웹 기반 보안 구성 (인증, 인가, 로그인, 로그아웃 관련 설정)
public org.springframework.security.web.SecurityFilterChain filterChain(HttpSecurity http)
throws Exception {
TokenAuthenticationFilter tokenFilter = new TokenAuthenticationFilter(tokenProvider);
return http.authorizeHttpRequests(
// 특정 경로에 대한 엑세스 설정
auth -> auth.requestMatchers( // 특정 요청과 일치하는 url에 대한 엑세스 설정
new AntPathRequestMatcher("/login"),
new AntPathRequestMatcher("/signup"),
new AntPathRequestMatcher("/user")
).permitAll() // 누구나 접근 가능하게 설정
.anyRequest() // 위에서 설정한 url 이외의 요청에 대해 설정
.authenticated() // 별도의 인가는 필요하지 않으나 인증이 성공한 상태만 접근이 가능
)
// .formLogin(formLogin -> formLogin // 폼 기반 로그인 설정
// .loginPage("/login") // 로그인 페이지 경로 설정
// .defaultSuccessUrl("/") // 로그인시 이동할 경로 설정
// )
// .logout(logout -> logout // 로그아웃 설정
// .logoutSuccessUrl("/login") // 로그아웃 성공 시 이동할 경로 설정
// .invalidateHttpSession(true) // 로그아웃 이후 세션 삭제 여부 결정
// )
.csrf(AbstractHttpConfigurer::disable) // CSRF 공격 방지를 위해서는 활성화하는게 좋음
.addFilterBefore(tokenFilter, UsernamePasswordAuthenticationFilter.class) // JWT 필터 추가
.build();
} //end filterChain()
@Bean // 인증 관리자 관련 설정 (사용자 정보를 가져올 서비스를 재정의, 인증 방법을 설정할 때 사용)
public AuthenticationManager authenticationManager(HttpSecurity http,
BCryptPasswordEncoder bCryptPasswordEncoder,
UserDetailsService userDetailsService) throws Exception {
DaoAuthenticationProvider authProvider = new DaoAuthenticationProvider();
authProvider.setUserDetailsService(userDetailsService);
// 사용자 정보 서비스 설정(userDetailService를 상속받은 클래스여야 한다.)
authProvider.setPasswordEncoder(bCryptPasswordEncoder);
// 비밀번호를 암호화하기 위한 인코더를 설정
return new ProviderManager(authProvider);
} //end authenticationManager()
@Bean // 비밀번호 인코더로 사용할 빈 등록
public BCryptPasswordEncoder bCryptPasswordEncoder() {
return new BCryptPasswordEncoder();
} //end bCryptPasswordEncoder()
} //end class
7. TokenAuthenticationFiler
package com.hyeongarl.config;
import com.hyeongarl.config.jwt.TokenProvider;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.web.filter.OncePerRequestFilter;
import java.io.IOException;
/**
* 들어오는 요청을 가로채 Authorization 헤더에서
* JWT 토큰 추출
* 유효성 검사
* 토큰이 유효한 경우 인증 정보를 사용하여 Spring Security 컨텍스트를 설정
*/
@RequiredArgsConstructor
public class TokenAuthenticationFilter extends OncePerRequestFilter {
private final TokenProvider tokenProvider;
// JwtProperties 대신
private final static String HEADER_AUTHORIZATION = "Authorization";
private final static String TOKEN_PREFIX = "Bearer";
@Override
protected void doFilterInternal(HttpServletRequest request,
HttpServletResponse response,
FilterChain filterChain) throws ServletException, IOException {
//요청 헤더의 Authorization 키 값 조회
final String authorizationHeader = request.getHeader(HEADER_AUTHORIZATION);
// 가져온 값에서 접두사 제거
String token = getAccessToken(authorizationHeader);
// 가져온 토큰이 유효한지 확인하고 유효한 경우 인증 정보 설정
if(tokenProvider.validatedToken(token)) {
Authentication authentication = tokenProvider.getAuthentication(token);
SecurityContextHolder.getContext().setAuthentication(authentication);
}
filterChain.doFilter(request, response);
} //end doFilterInternal()
private String getAccessToken(String authorizationHeader) {
if(authorizationHeader != null && authorizationHeader.startsWith(TOKEN_PREFIX)) {
return authorizationHeader.substring(TOKEN_PREFIX.length());
}
return null;
} //end getAccessToken()
} //end class
8. User Entity 수정
> 기존 코드
package com.hyeongarl.entity;
import jakarta.persistence.*;
import jakarta.validation.constraints.Email;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
import java.time.LocalDateTime;
/*
@Table(name="")
데이터베이스의 name에 지정된 이름의 테이블과 매핑된다.
@Entity
JPA 엔티티임을 명시하여 데이터베이스 테이블의 레코드와 매핑
@Getter, @Setter
롬복 라이브러리의 어노테이션으로 모든 필드에 대해 자동으로 getter/setter를 생성
@NoArgsConstructor
롬복 라이브러리의 어노테이션으로 매개변수가 없는 기본 생성자를 자동으로 생성
*/
@Table(name="users")
@Entity
@Getter
@Setter
@NoArgsConstructor
public class User {
/*
@Id
테이블의 기본 키
@GeneratedValue(strategy = GenerationType.IDENTITY)
기본 피의 생성 전략을 지정
IDENTITY : 데이터베이스가 기본 키 생성을 담당
기본적으로 AUTO_INCREMENT를 사용
@Column(name="user_id", updatable = false)
데이터베이스의 name과 일치하는 컬럼과 매핑된다.
필드가 수정될 수 없음을 의미
*/
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name="user_id", updatable = false)
private Long userId;
/*
@Email
유효한 이메일 형식을 규정
@Column(name="user_email", nullable = false, unique = true)
데이터베이스의 name과 일치하는 컬럼과 매핑된다.
null 값을 가질 수 없음을 의미
유일한 값을 가져야 함을 의미
*/
@Email
@Column(name="user_email", nullable = false, unique = true)
private String userEmail;
@Column(name = "user_password", nullable = false, length=25)
private String password;
@Column(name="user_regdate", updatable = false)
private LocalDateTime userRegdate;
@Builder
public User(String userEmail, String password) {
this.userEmail = userEmail;
this.password = password;
this.userRegdate = LocalDateTime.now();
} //end User()
}
UserDetails 클래스를 상속받고 메소드를 오버라이드한다.
> 수정코드
package com.hyeongarl.entity;
import jakarta.persistence.*;
import jakarta.validation.constraints.Email;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.userdetails.UserDetails;
import java.time.LocalDateTime;
import java.util.Collection;
import java.util.List;
/*
@Table(name="")
데이터베이스의 name에 지정된 이름의 테이블과 매핑된다.
@Entity
JPA 엔티티임을 명시하여 데이터베이스 테이블의 레코드와 매핑
@Getter, @Setter
롬복 라이브러리의 어노테이션으로 모든 필드에 대해 자동으로 getter/setter를 생성
@NoArgsConstructor
롬복 라이브러리의 어노테이션으로 매개변수가 없는 기본 생성자를 자동으로 생성
*/
@Table(name="users")
@Entity
@Getter
@Setter
@NoArgsConstructor
public class User implements UserDetails {
/*
@Id
테이블의 기본 키
@GeneratedValue(strategy = GenerationType.IDENTITY)
기본 피의 생성 전략을 지정
IDENTITY : 데이터베이스가 기본 키 생성을 담당
기본적으로 AUTO_INCREMENT를 사용
@Column(name="user_id", updatable = false)
데이터베이스의 name과 일치하는 컬럼과 매핑된다.
필드가 수정될 수 없음을 의미
*/
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name="user_id", updatable = false)
private Long userId;
/*
@Email
유효한 이메일 형식을 규정
@Column(name="user_email", nullable = false, unique = true)
데이터베이스의 name과 일치하는 컬럼과 매핑된다.
null 값을 가질 수 없음을 의미
유일한 값을 가져야 함을 의미
*/
@Email
@Column(name="user_email", nullable = false, unique = true)
private String userEmail;
@Column(name = "user_password", nullable = false, length=25)
private String password;
@Column(name="user_regdate", updatable = false)
private LocalDateTime userRegdate;
@Builder
public User(String userEmail, String password) {
this.userEmail = userEmail;
this.password = password;
this.userRegdate = LocalDateTime.now();
} //end User()
@Override // 권한 반환
public Collection<? extends GrantedAuthority> getAuthorities() {
return List.of();
}
@Override // 사용자의 id 반환
public String getUsername() {
return userEmail;
}
@Override // 계정 만료 여부 반환
public boolean isAccountNonExpired() {
// 만료되었는지 확인하는 로직 필요
return true; // 만료 x
}
@Override // 계정 잠금 여부 반환
public boolean isAccountNonLocked() {
// 잠금되었는지 확인하는 로직 필요
return true; // 잠금 x
}
@Override // 비밀번호 만료 여부 반환
public boolean isCredentialsNonExpired() {
// 만료되었는지 확인하는 로직 필요
return true; // 만료 x
}
@Override // 계정 사용 가능 여부 반환
public boolean isEnabled() {
// 사용 가능한지 확인하는 로직 필요
return true; // 사용 가능
}
}
9. UserService 수정
UserDetailsService를 상속
> 기존 코드
package com.hyeongarl.service;
import com.hyeongarl.repository.UserRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
/*
@Service
Spring Framework에 의해 관리되는 서비스 클래스임을 명시
@RequiredArgsConstructor
롬복 라이브러리에서 final 필드에 대한 생성자를 자동으로 생성
*/
@Service
@RequiredArgsConstructor
public class UserService {
private final UserRepository userRepository;
}
> 수정 코드
package com.hyeongarl.service;
import com.hyeongarl.entity.User;
import com.hyeongarl.error.UserAlreadyExistException;
import com.hyeongarl.error.UserNotFoundException;
import com.hyeongarl.repository.UserRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.stereotype.Service;
/*
@Service
Spring Framework에 의해 관리되는 서비스 클래스임을 명시
@RequiredArgsConstructor
롬복 라이브러리에서 final 필드에 대한 생성자를 자동으로 생성
*/
/**
save() : 신규 등록
findById() : 아이디로 사용자 조회
loadUserByUsername : 사용자 이메일을 사용하여 정보 가져오기
*/
@Service
@RequiredArgsConstructor
public class UserService implements UserDetailsService {
private final UserRepository userRepository;
// BCrypt 해시 알고리즘을 사용하여 비밀번호 암호화
private final BCryptPasswordEncoder bCryptPasswordEncoder;
public User save(User user) {
if(userRepository.existsByUserEmail(user.getUserEmail())) {
throw new UserAlreadyExistException();
}
return userRepository.save(User.builder()
.userEmail(user.getUserEmail())
.password(bCryptPasswordEncoder.encode(user.getPassword()))
.build());
} //end save()
public User findById(Long userId) {
return userRepository.findById(userId)
.orElseThrow(UserNotFoundException::new);
} //end findById()
@Override
public UserDetails loadUserByUsername(String userEmail) throws UsernameNotFoundException {
return userRepository.findByUserEmail(userEmail)
.orElseThrow(UserNotFoundException::new);
} //end loadUserByUsername()
} //end class
UserAlreadyExistException, UserNotFoundException은 이후 작성
10. UserRepository 수정
> 기존 코드
package com.hyeongarl.repository;
import com.hyeongarl.entity.User;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
@Repository
public interface UserRepository extends JpaRepository<User, Long> {
}
> 수정 코드
package com.hyeongarl.repository;
import com.hyeongarl.entity.User;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.stereotype.Repository;
import java.util.Optional;
@Repository
public interface UserRepository extends JpaRepository<User, Long> {
Optional<User> findByUserEmail(String userEmail);
@Query("SELECT CASE " +
"WHEN COUNT(u) > 0 THEN true " +
"ELSE false END FROM User u " +
"WHERE u.userEmail = :userEmail")
boolean existsByUserEmail(String userEmail);
}
@Query로 직접 쿼리문을 작성할 수 있다.
SELECT CASE
WHEN COUNT(u) > 0 THEN true
ELSE false
END
FROM User u
WHERE u.userEmail = "userEmail";
이미 등록된 이메일 정보가 있다면 true를 반환
11. UserController 수정
package com.hyeongarl.controller;
import com.hyeongarl.dto.UserRequestDto;
import com.hyeongarl.dto.UserResponseDto;
import com.hyeongarl.service.UserService;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
@RequiredArgsConstructor
@RequestMapping("/user")
public class UserController {
private final UserService userService;
@PostMapping
public UserResponseDto addUser(UserRequestDto userRequest) {
return UserResponseDto.fromEntity(userService.save(userRequest.toEntity()));
}
}
12. UserNotFoundException
package com.hyeongarl.error;
import org.springframework.http.HttpStatus;
public class UserNotFoundException extends BaseException {
private static final String DEFAULT_MESSAGE = "사용자를 찾을 수 없습니다.";
private static final String DEFAULT_CODE = "400005";
public UserNotFoundException() {
super(new ErrorResponse(DEFAULT_MESSAGE, DEFAULT_CODE), HttpStatus.NOT_FOUND);
} //end UserNotFoundException()
public UserNotFoundException(String message) {
super(new ErrorResponse(message, DEFAULT_CODE), HttpStatus.NOT_FOUND);
} //end UserNotFoundException()
} //end class
13. UserAlreadyExistsException
package com.hyeongarl.error;
import org.springframework.http.HttpStatus;
public class UserAlreadyExistException extends BaseException {
private static final String DEFAULT_MESSAGE = "이미 사용중인 이메일입니다.";
private static final String DEFAULT_CODE = "400006";
public UserAlreadyExistException() {
super(new ErrorResponse(DEFAULT_MESSAGE, DEFAULT_CODE), HttpStatus.CONFLICT);
}
public UserAlreadyExistException(String message) {
super(new ErrorResponse(message, DEFAULT_CODE), HttpStatus.CONFLICT);
}
}
JwtFactory
package com.hyeongarl.config.jwt;
import io.jsonwebtoken.Header;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.SignatureAlgorithm;
import lombok.Builder;
import lombok.Getter;
import java.time.Duration;
import java.util.Date;
import java.util.Map;
import static java.util.Collections.emptyMap;
/**
* Jwt 토큰 서비스를 테스트하는데 사용할 모킹용 객체
*/
@Getter
public class JwtFactory {
private String subject = "test@email.com";
private Date issuedAt = new Date();
private Date expiration = new Date(issuedAt.getTime() + Duration.ofDays(14).toMillis());
private Map<String, Object> claims = emptyMap();
@Builder
public JwtFactory(String subject, Date issuedAt, Date expiration, Map<String, Object> claims) {
this.subject = subject;
this.issuedAt = issuedAt;
this.expiration = expiration;
this.claims = claims;
} // end JwtFactory()
public static JwtFactory withDefaultValues() {
return JwtFactory.builder().build();
} //end withDefaultValues()
public String createToken(JwtProperties jwtProperties) {
return Jwts.builder()
.setSubject(subject)
.setHeaderParam(Header.TYPE, Header.JWT_TYPE)
.setIssuer(jwtProperties.getIssuer())
.setIssuedAt(issuedAt)
.setExpiration(expiration)
.addClaims(claims)
.signWith(SignatureAlgorithm.HS256, jwtProperties.getSecretKey())
.compact();
}
}
TokenProviderTest
package com.hyeongarl.config.jwt;
import com.hyeongarl.entity.User;
import com.hyeongarl.repository.UserRepository;
import io.jsonwebtoken.Jwts;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.userdetails.UserDetails;
import java.time.Duration;
import java.util.Date;
import java.util.Map;
import static org.assertj.core.api.Assertions.assertThat;
/**
* TokenProvider 클래스 테스트
*/
@SpringBootTest
public class TokenProviderTest {
@Autowired
private TokenProvider tokenProvider;
@Autowired
private UserRepository userRepository;
@Autowired
private JwtProperties jwtProperties;
@Test
@DisplayName("generateToken()")
void generateTokenTest() {
User testUser = userRepository.save(User.builder()
.userEmail("user12345@email.com")
.password("1234")
.build());
String token = tokenProvider.generatedToken(testUser, Duration.ofDays(14));
Long userId = Jwts.parser()
.setSigningKey(jwtProperties.getSecretKey())
.parseClaimsJws(token)
.getBody()
.get("id", Long.class);
assertThat(userId).isEqualTo(testUser.getUserId());
}
@Test
@DisplayName("validateToken()")
void validateToken_invalidTest() {
String token = JwtFactory.builder()
.expiration(new Date(new Date().getTime() - Duration.ofDays(7).toMillis()))
.build()
.createToken(jwtProperties);
boolean result = tokenProvider.validatedToken(token);
assertThat(result).isFalse();
}
@Test
@DisplayName("validateToken()")
void validateToken_validTest() {
String token = JwtFactory.withDefaultValues().createToken(jwtProperties);
boolean result = tokenProvider.validatedToken(token);
assertThat(result).isTrue();
}
@Test
@DisplayName("getAuthentication()")
void getAuthenticationTest() {
String userEmail = "user@email.com";
String token = JwtFactory.builder()
.subject(userEmail)
.build()
.createToken(jwtProperties);
Authentication authentication = tokenProvider.getAuthentication(token);
assertThat(((UserDetails) authentication.getPrincipal()).getUsername()).isEqualTo(userEmail);
}
@Test
@DisplayName("getUserId()")
void getUserIdTest() {
Long userId = 1L;
String token = JwtFactory.builder()
.claims(Map.of("id", userId))
.build()
.createToken(jwtProperties);
Long userIdByToken = tokenProvider.getUserId(token);
assertThat(userIdByToken).isEqualTo(userId);
}
}
> TokenProviderTest 실행
UserControllerTest
Project_07_Controller Test (TestRestTemplate)
목표.Controller 코드를 테스트하는 ControllerTest 코드를 작성하고자했다. 문제점.유사한 환경의 다른 사람의 작업을 참고하려다 보니, 이리저리 혼합되어 이도저도 아닌 코드가 되어버렸다.결국 @
hyeonga493.tistory.com
package com.hyeongarl.controller;
import com.hyeongarl.dto.UserRequestDto;
import com.hyeongarl.dto.UserResponseDto;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.web.client.TestRestTemplate;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import static org.assertj.core.api.Assertions.assertThat;
/*
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
SpringBoot 애플리케이션을 테스트하기 위한 통합 테스트를 설정한다.
테스트시 무작위 포트가 할당된다.
*/
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
public class UserControllerTest {
// RESTful 서비스 호출을 위한 의존성 주입
@Autowired
private TestRestTemplate restTemplate;
@Test
@DisplayName("사용자 등록")
void testAddUser() {
UserRequestDto userRequest = new UserRequestDto("addUser@email.com", "addUserPassword");
ResponseEntity<UserResponseDto> responseEntity
= restTemplate.postForEntity("/user", userRequest, UserResponseDto.class);
assertThat(responseEntity.getStatusCode()).isEqualTo(HttpStatus.CREATED);
UserResponseDto responseBody = responseEntity.getBody();
assertThat(responseBody).isNotNull();
assertThat(responseBody.getUserEmail()).isEqualTo("addUser@email.com");
}
}
> UserControllerTest / testAddUser() 실행
UserServiceTest
package com.hyeongarl.service;
import com.hyeongarl.dto.UserRequestDto;
import com.hyeongarl.entity.User;
import com.hyeongarl.error.UserAlreadyExistException;
import com.hyeongarl.repository.UserRepository;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.*;
/*
@ExtendWith(MockitoExtension.class)
JUnit5의 확장 모델을 활용하여 Mockito를 사용할 수 있다.
MockExtension : Mockito에 대한 설정을 초기화 및 @Mockito 처리
*/
@ExtendWith(MockitoExtension.class)
public class UserServiceTest {
/*
@Mock
Mockito를 사용해 가짜 객체를 생성
*/
@Mock
private UserRepository userRepository;
@Mock
private BCryptPasswordEncoder bCryptPasswordEncoder;
/*
@InjectMocks
실제 객체로 만들어 필요한 의존성 객체를 주입
*/
@InjectMocks
private UserService userService;
@Test
@DisplayName("save_success")
void saveUser_success() {
UserRequestDto userRequest = new UserRequestDto("adduser@email.com", "adduserPassword");
when(userRepository.existsByUserEmail(userRequest.toEntity().getUserEmail())).thenReturn(false);
when(userRepository.save(any(User.class))).thenReturn(userRequest.toEntity());
User user = userService.save(userRequest.toEntity());
assertNotNull(user);
assertEquals(userRequest.toEntity().getUserEmail(), user.getUserEmail());
verify(userRepository, times(1)).save(any(User.class));
}
@Test
@DisplayName("save_fail_invalid")
void saveUser_fail_invalidUser() {
UserRequestDto userRequest = new UserRequestDto("adduser_invalid", "adduserPassword");
User user = userService.save(userRequest.toEntity());
assertNull(user);
verify(userRepository, times(1)).save(any(User.class));
}
@Test
@DisplayName("save_fail_alreadyExist")
void saveUser_fail_alreayExist() {
UserRequestDto userRequest = new UserRequestDto("exist@email.com", "existPassword");
when(userRepository.existsByUserEmail(userRequest.toEntity().getUserEmail())).thenReturn(true);
assertThrows(UserAlreadyExistException.class, () -> userService.save(userRequest.toEntity()));
verify(userRepository, never()).save(any(User.class));
}
}
> UserServiceTest 실행
AuthControllerTest
package com.hyeongarl.controller;
import com.hyeongarl.dto.UserRequestDto;
import com.hyeongarl.dto.UserResponseDto;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.web.client.TestRestTemplate;
import org.springframework.http.*;
import static org.assertj.core.api.Assertions.assertThat;
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
public class AuthControllerTest {
@Autowired
private TestRestTemplate restTemplate;
@Test
@DisplayName("login_success")
void testLogin_success() {
UserRequestDto userRequest = new UserRequestDto("login@email.com", "loginPassword");
ResponseEntity<UserResponseDto> saveUser
= restTemplate.postForEntity("/user", userRequest, UserResponseDto.class);
ResponseEntity<String> responseEntity = restTemplate.postForEntity("/login", userRequest, String.class);
assertThat(responseEntity.getStatusCode()).isEqualTo(HttpStatus.OK);
assertThat(responseEntity.getBody()).isNotNull();
}
}
testLogin_success()가 동작하는 흐름을 알고 싶어서 System.out.println()으로 찍어봤다.
>>> SecurityFilterChain > bCryptPasswordEncoder
>>> SecurityFilterChain > filterChain
>>> SecurityFilterChain > configure
>>> SecurityFilterChain > authenticationManager
>>> TokenAuthenticationFilter > doFilterInternal
>>> TokenAuthenticationFilter > getAccessToken
>>> TokenProvider > validatedToken
>>> UserController > addUser
>>> UserResponseDto > fromEntity
>>> TokenAuthenticationFilter > doFilterInternal
>>> TokenAuthenticationFilter > getAccessToken
>>> TokenProvider > validatedToken
>>> AuthController > login
>>> TokenProvider > generatedToken
> 실행
실제 서버를 돌려서 postman으로 실행 시 인증 토큰이 발급되는 것을 확인할 수 있다.
-- 실패
package com.hyeongarl.controller;
import com.hyeongarl.config.jwt.TokenProvider;
import com.hyeongarl.dto.UserRequestDto;
import com.hyeongarl.dto.UserResponseDto;
import com.hyeongarl.service.UserService;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.MockitoAnnotations;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.web.client.TestRestTemplate;
import org.springframework.http.*;
import org.springframework.security.core.userdetails.User;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.test.context.junit.jupiter.SpringExtension;
import java.util.ArrayList;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
@ExtendWith(SpringExtension.class)
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
public class AuthControllerTest {
@Autowired
private TestRestTemplate restTemplate;
@Mock
private UserService userService;
@Mock
private TokenProvider tokenProvider;
@Mock
private BCryptPasswordEncoder bCryptPasswordEncoder;
@InjectMocks
private AuthController authController;
@BeforeEach
public void setup() {
MockitoAnnotations.openMocks(this);
when(bCryptPasswordEncoder.matches(anyString(), anyString())).thenReturn(true);
}
@Test
@DisplayName("login_fail_not_match_password")
void testLogin_fail_not_match_password() {
UserRequestDto userRequest = new UserRequestDto("test@example.com", "password");
User user = new User("test@example.com",
"$2a$10$D9aU8XQ6QXphF9PL5bR73ODNjZxK9UwGtJsYx.3vM8/7qZ1RzN.kC",
new ArrayList<>());
ResponseEntity<UserResponseDto> saveUser
= restTemplate.postForEntity("/user", userRequest, UserResponseDto.class);
when(userService.loadUserByUsername(anyString())).thenReturn(user);
when(bCryptPasswordEncoder.matches(user.getPassword(),
userRequest.getPassword())).thenReturn(false);
UserRequestDto wrongUser = new UserRequestDto("test@example.com", "123454");
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_JSON);
HttpEntity<UserRequestDto> request = new HttpEntity<>(userRequest, headers);
ResponseEntity<String> response
= restTemplate.postForEntity("/login", wrongUser, String.class);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED);
assertThat(response.getBody()).isEqualTo("비밀번호가 일치하지 않습니다.");
}
}
testLogin_fail_not_match_password
비밀번호가 일치하지 않는 경우를 테스트하기 위해 계속 찾아봤는데 계속해서 오류가 발생했다.
안되는 코드를 가지고 몇시간을..
I/O error on POST request for "http://localhost:50295/login": cannot retry due to server authentication, in streaming mode org.springframework.web.client.ResourceAccessException: I/O error on POST request for "http://localhost:50295/login": cannot retry due to server authentication, in streaming mode
실제 서버를 돌려서 테스트하면 잘 동작한다.
'Project_HYEONGARL' 카테고리의 다른 글
Project_05_Url 조회 기능 구현 /TestCode (0) | 2024.06.01 |
---|---|
Project_04_Url 등록 기능 구현 (0) | 2024.05.31 |
Project_02_예외 처리하기 @ControllerAdvice, @RestControllerAdvice, @ExceptionHandler (0) | 2024.05.30 |
Project_01_Spring Boot Gradle Multi Module Project 생성하기 (0) | 2024.05.28 |
PreProject_9_JWT 서비스 구현하기(TestCode 작성) (0) | 2024.05.27 |