이번에는 스프링부트에서 JWT를 활용한 인증 방식을 구현해보고자 한다.
이때 Spring Security의 필터단에 JWT를 사용하는 커스텀 필터를 끼워넣어 구현하고자 한다.
JWT의 구조에 관한 글은 이전에 작성한 글을 참고하자.
그럼 바로 시작하겠다.
01. 전체 동작 과정
JWT를 구현하는 코드를 설명하기 전에 먼저 Spring Security를 활용하여 JWT를 동작시키는 과정을 살펴보고자 한다.
하나씩 살펴보자
회원가입 과정
회원가입은 기존 회원가입과 다르지 않게, DTO로 회원가입 정보를 전달받으면 Controller
가 이를 Service
에 전달 이후 비즈니스 로직을 실행한다.
위 그림에는 나오지 않지만 회원 정보를 저장할때 비밀번호를 암호화 하여 저장하는 부분만 유의하자.
최초인증(로그인) 과정
원래 스프링 시큐리티를 이용한 기존의 로그인 과정에서는 UsernamePasswordAuthenticationFilter
에서 AuthenticationManager
에게 인증을 위임하여, 만약 성공적으로 인증되었을 경우 해당 세션 정보를 서버에 저장, 세션 아이디를 클라이언트 쿠키에 저장 하는 과정이 이루어졌다.
하지만, JWT
를 사용하는 해당 예제에서는 세션 정보를 저장하는 대신, 인증 토큰을 새로 발급하고 이를 클라이언트에게 전달해주는 순서로 로그인 과정이 동작한다.
이를 위해 기존의 UsernamePasswordAuthenticationFilter
를 커스텀하여 등록한다.
인가 및 jwt 토큰 검증 과정
클라이언트가 권한이 필요한 페이지에 접근할 때, 요청 헤더에 포함된 토큰이 올바른지 검증을 해줄 필터를 등록해준다. 위 이미지에서는 JWTFilter
이다.
만약 토큰이 유효한 토큰이라면 SecurityContextHolder
에다가 임시로 세션 정보를 저장해준다.
기존 세션 방식과의 차이점
분명 세션 방식에서도 SecurityContextHolder
에다가 세션 정보를 저장하고, jwt에서도 SecurityContextHolder
에다가 세션 정보를 저장한다.
하지만 jwt에서는 세션 정보를 임시로 저장하여, 해당 요청에 대해서만 해당 세션 정보가 사용되고 이후에는 초기화 된다는 차이점이 있다.
이를 위하여 Security Configuration 클래스에서 세션 저장 정책을 Stateless
로 설정해야한다.
간단하게 전체 동작 과정을 살펴봤으면 직접 코드로 구현해보자.
02. 의존성 및 초기 설정(properties)
build.gradle - 의존성 설정
dependencies {
// MySQL
runtimeOnly 'com.mysql:mysql-connector-j'
// JPA
implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
// Spring Security
implementation 'org.springframework.boot:spring-boot-starter-security'
testImplementation 'org.springframework.security:spring-security-test'
// Web
implementation 'org.springframework.boot:spring-boot-starter-web'
// Lombok
compileOnly 'org.projectlombok:lombok'
annotationProcessor 'org.projectlombok:lombok'
// JWT
implementation 'io.jsonwebtoken:jjwt:0.12.3'
testImplementation 'org.springframework.boot:spring-boot-starter-test'
testRuntimeOnly 'org.junit.platform:junit-platform-launcher'
}
해당 코드에서는 위와 같이 의존성을 추가해 주었다.
또한 MySQL
을 사용하여 코드를 작성했다.
주의할 점은 jjwt
의 버전이 0.12.3
으로 업데이트 하면서 사용 방법이 많이 달라졌으므로 버전을 유의하여 의존성을 추가하자.
application.properties
spring.application.name=jwt-self
# MySQL
spring.datasource.driver-class-name=com.mysql.cj.jdbc.Driver
spring.datasource.url=jdbc:mysql://localhost:3306/test?useSSL=false&useUnicode=true&serverTimezone=Asia/Seoul&allowPublicKeyRetrieval=true
spring.datasource.username=testuser
spring.datasource.password=1234
# JPA-ddl
spring.jpa.hibernate.ddl-auto=update
spring.jpa.hibernate.naming.physical-strategy=org.hibernate.boot.model.naming.PhysicalNamingStrategyStandardImpl
# JWT KEY
spring.jwt.key = asvnklhs11gWEUFAS1HGadbva8ksdgfaky543idgaskljdfh1321\
sasjdghawfasd2gasgasdg5g4a6s4g6465sdgag4sadgasg ansfnhl4iup4yi6sfd4g6sfb4nhkd
- DB 경로와 Username, Password를 설정해준다.
- JPA-ddl 설정을 해주어, 테이블을 자동 생성되도록 하였다.
- 토큰을 서명할때 사용될
key
를 별도로 빼두었다.- 이때 키값은 임의로 복잡하고 길게 만들어 주었다. 사용하는 서명 알고리즘에 따라 키값이 특정 길이보다는 길어야 하므로 유의하자.
03. 기본 클래스 정의(Entity, Repository, Service, Contorller, DTO)
이제 기본 클래스들을 간단하게 정의해보자.
CustomUserDetails(Entity)
@Entity
@Getter
@Setter
@Table(name = "user")
public class CustomUserDetails implements UserDetails {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private int id;
private String username;
private String password;
private String role;
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
return List.of(new GrantedAuthority() {
@Override
public String getAuthority() {
return role;
}
});
}
@Override
public String getPassword() {
return this.password;
}
@Override
public String getUsername() {
return this.username;
}
}
먼저 User Entity를 정의해준다. 이때 UserDetails
를 상속받는 클래스를 따로 구현하지 않고, Entity클래스가 상속하도록 구현했다.
- 스프링 시큐리티를 사용하여 인가를 처리하기 위해서는
UserDetails
를 상속받는 클래스를 별도로 구현해야한다. UserDetails
를 상속받았기 때문에getAuthorities()
,getPassword()
,getUsername()
메소드를 엔티티 안에 구현해주었다.
UserDetailsRepository(Repository)
package com.example.jwt_self.repository;
@Repository
public interface UserDetailsRepository extends JpaRepository<CustomUserDetails, Integer> {
CustomUserDetails findByUsername(String username);
Boolean existsByUsername(String username);
}
JpaRepository
를 구현하는 인터페이스로 간단하게 작성했다.
CustomUserDetailsService(Service)
@Service
public class CustomUserDetailsService implements UserDetailsService {
@Autowired
UserDetailsRepository userDetailsRepository;
@Autowired
BCryptPasswordEncoder bCryptPasswordEncoder;
public boolean joinProcess(JoinDto joinDto){
// null 검증
if (joinDto == null || joinDto.getUsername() == null || joinDto.getPassword() == null){
System.out.println("[JOIN] empty join form");
return false;
}
// username 중복 검사
if (userDetailsRepository.existsByUsername(joinDto.getUsername())){
System.out.println("[JOIN] username is already exist");
return false;
}
// join process
CustomUserDetails newUser = new CustomUserDetails();
newUser.setUsername(joinDto.getUsername());
newUser.setPassword(bCryptPasswordEncoder.encode(joinDto.getPassword()));
newUser.setRole("ROLE_USER");
userDetailsRepository.save(newUser);
return true;
}
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
return userDetailsRepository.findByUsername(username);
}
}
- 엔티티와 마찬가지로 서비스 클래스가
UserDetailsService
도 한번에 상속받도록 구현했다.UserDetailsService
를 상속하면서loadByUsername()
메소드를 구현해주었다.
- 서비스 클래스에서는 회원가입 로직을 구현했다.
- Username이 기존에 중복되는지 검사 후, 회원 정보를 저장한다.
- 이때 Password를 암호화하여 저장한다.
- 여기에 사용된
bCryptPasswordEncoder
는 이후에 후술할SecurityConfig
클래스에서Bean
으로 등록해주었다.
- 여기에 사용된
MainController(Controller)
@Controller
@ResponseBody
public class MainController {
@Autowired
CustomUserDetailsService customUserDetailsService;
@GetMapping("/")
public String getMain(){
return "main page";
}
@PostMapping("/signin")
public String postLogin(){
return "success";
}
@PostMapping("/join")
public String postJoin(@RequestBody JoinDto joinDto){
if (customUserDetailsService.joinProcess(joinDto))
return "success";
else
return "fail";
}
@GetMapping("/onlyuser")
public String getOnlyuser(){
return "success";
}
}
각 경로별로 알맞은 값을 리턴해준다.
- 여기서
/onlyuser
경로는 회원만 접근 가능한 경로이다.- 해당 내용은 후술할
SecurityConfig
에서 설정해준다.
- 해당 내용은 후술할
JoinDto, LoginDto, JwtDto
- JoinDto
@Getter
@Setter
public class JoinDto {
private String username;
private String password;
}
- 회원가입에서 사용되는 dto
- LoginDto
@Getter
@Setter
public class LoginDto {
private String username;
private String password;
}
- 로그인 과정에서 사용되는 dto
- JwtDto
@Getter
@Setter
public class JwtDto {
public JwtDto(){}
public JwtDto(String authentication) {
this.authentication = authentication;
}
String authentication;
}
- 로그인 성공 후 jwt 토큰을 응답하기 위해 사용되는 DTO
04. JWTUtil
@Component
public class JWTUtil {
private final SecretKey secretKey;
// 비밀키 값을 SecretKey 객체로 반환
public JWTUtil(@Value("${spring.jwt.key}") String key) {
this.secretKey = Keys.hmacShaKeyFor(key.getBytes());
}
// 토큰 생성
public String createJwt(String username, String role, int expiredMinute){
// iat, exp를 위한 Date 및 Calendar
Calendar expCalendar = Calendar.getInstance();
expCalendar.add(Calendar.MINUTE, Math.toIntExact(expiredMinute));
Date iatDate = new Date();
Date expDate = expCalendar.getTime();
return Jwts.builder()
.claim("username", username)
.claim("role", role)
.issuedAt(iatDate)
.expiration(expDate)
.signWith(secretKey)
.compact();
}
// 토큰 검증 - 아이디
public String getUsername(String token){
Jws<Claims> jws = Jwts.parser().verifyWith(secretKey).build().parseSignedClaims(token);
return jws.getPayload().get("username", String.class);
}
// 토큰 검증 - role
public String getRole(String token){
Jws<Claims> jws = Jwts.parser().verifyWith(secretKey).build().parseSignedClaims(token);
return jws.getPayload().get("role", String.class);
}
// 토큰 검증 - 토큰 유효기간 비교
public Boolean isExpired(String token){
try{
Jws<Claims> jws = Jwts.parser().verifyWith(secretKey).build().parseSignedClaims(token);
Date expDate = jws.getPayload().getExpiration();
// 현재 날짜가 exp 날짜보다 뒤에 있으면, 만료됨
return new Date().after(expDate);
} catch (ExpiredJwtException e){
e.printStackTrace();
return true;
}
}
}
jwt token의 생성과 검증을 위해 사용되는 클래스이다.jjwt
패키지를 사용하여 해당 과정을 구현했다.
- 해당 버전은
0.12.3
버전임을 유의하자. private final SecretKey secretKey
: 토큰을 서명할 때 사용될 비밀키를 담는 객체- 생성자에서
application.properties
에 작성한 키값을 사용하여 해당 객체를 생성한다.
- 생성자에서
createJwt()
: 새로운 토큰을 생성하는 메소드이다.username
,role
에 대한 클래임을 등록해 주었다.- 때문에 해당 토큰을 읽으면, 해당 토큰을 가진 클라이언트의 username과 role을 알 수 있다.
issuedAt()
메소드로 토큰이 생성된 날짜를 포함시킨다.expiration()
메소드로 토큰의 만료 시간을 설정한다.
getUsername()
,getRole()
: 토큰에서 username과 role을 파싱하는 메소드isExpired()
: 토큰의 유효 시간이 만료되었는지 검증하는 메소드, 토큰의 만료 여부를boolean
형태로 반환한다.- 그런데 사실 이미 만료된 토큰에서 페이로드를 파싱하는 과정에서 에러가 발생하기 때문에 예외 처리를 해주었다.
05. CustomUsernamePasswordAuthenticationFilter
public class CustomUsernamePasswordAuthenticationFilter extends UsernamePasswordAuthenticationFilter {
ObjectMapper objectMapper = new ObjectMapper();
AuthenticationManager authenticationManager;
JWTUtil jwtUtil;
public CustomUsernamePasswordAuthenticationFilter(AuthenticationManager authenticationManager, JWTUtil jwtUtil) {
this.authenticationManager = authenticationManager;
this.jwtUtil = jwtUtil;
}
@Override
public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) throws AuthenticationException {
// request body GET
ServletInputStream inputStream;
String requestBody;
try {
inputStream = request.getInputStream();
requestBody = StreamUtils.copyToString(inputStream, StandardCharsets.UTF_8);
} catch (IOException e) {
throw new RuntimeException(e);
}
// Json data parsing
LoginDto loginDto;
try {
loginDto = objectMapper.readValue(requestBody, LoginDto.class);
} catch (JsonProcessingException e) {
throw new RuntimeException(e);
}
UsernamePasswordAuthenticationToken authToken = new UsernamePasswordAuthenticationToken(loginDto.getUsername(), loginDto.getPassword(), null);
return authenticationManager.authenticate(authToken);
}
@Override
protected void successfulAuthentication(HttpServletRequest request, HttpServletResponse response, FilterChain chain, Authentication authResult) throws IOException, ServletException {
// 토큰 생성
CustomUserDetails userDetails = (CustomUserDetails) authResult.getPrincipal();
String jwtToken = jwtUtil.createJwt(userDetails.getUsername(), userDetails.getRole(), 1);
// 토큰을 JSON 형태로 변경
JwtDto jwtDto = new JwtDto("Bearer " + jwtToken);
String jsonResponse = objectMapper.writeValueAsString(jwtDto);
// JSON 타입 객체 응답
response.setContentType("application/json");
response.getWriter().write(jsonResponse);
}
}
클라이언트의 로그인 요청을 처리해주는 필터를 커스텀한 클래스이다.
- 기존 세션 방식의 로그인 처리를 담당하는
UsernamePasswordAuthenticationFilter
를 커스텀해서 구현했다. - 이렇게 구현된 필터는 후술할
SecurityConfig
에서 등록해준다. AuthenticationManager authenticationManager
: 실질적으로 인증을 담당해주는 객체이다.- 생성자 주입 방식으로 주입받는다.
attemptAuthentication()
: 로그인 요청이 들어올때 인증 과정을 처리하는 메소드- 해당 예제에서는
JSON
타입으로 로그인 정보를 전달받는다고 가정한다. 이를 위해 먼저RequestBody
를 읽어오고, 파싱하는 과정이 필요하다.request
에서inputStream
을 얻은 후,inputStream
을 통해requestBody
값을 문자열로 넘겨받는다.- 이렇게 넘겨받은
requestBody
값은JSON
형태이다. 이를ObjectMapper
를 사용하여LoginDto
객체로 파싱한다.
- 로그인 정보를 넘겨받은 후,
AuthenticationManager
에게 인증 과정을 위임한다.- 이때
AuthenticationManager
가 인증을 처리하기 위해서는UsernamePasswordAuthenticationToken
타입의 인증 객체를 넘겨주어야한다.
이를 위해 앞에서 얻은 로그인 정보로 새로운 인증 객체를 생성후AuthenticationManager
에게 넘겨준다.
- 이때
- 해당 예제에서는
successfulAuthentication()
: 인증이 성공적으로 완료되면 실행되는 메소드- 만약 인증이 성공적으로 완료 되었으면, 새로운 JWT 토큰을 발급 후 리턴한다.
- 이때 토큰 생성은 앞에서 정의한
JWTUtil
을 사용하여 발급받는다. - 이후
JSON
타입으로 응답하기 위해ObjectMapper
를 사용하여JSON
형태의 문자열로 인코딩 한 후 해당 값을ResponseBody
에 추가한다.
- 이때 토큰 생성은 앞에서 정의한
- 만약 인증이 성공적으로 완료 되었으면, 새로운 JWT 토큰을 발급 후 리턴한다.
06. JwtVertificationFilter
public class JwtVerificationFilter extends OncePerRequestFilter {
ObjectMapper objectMapper = new ObjectMapper();
JWTUtil jwtUtil;
public JwtVerificationFilter(JWTUtil jwtUtil) {
this.jwtUtil = jwtUtil;
}
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
// get token
String token = request.getHeader("Authentication");
// 토큰 존재 여부 검증
if (token == null || !token.startsWith("Bearer ")){
filterChain.doFilter(request, response);
return;
}
token = token.split(" ")[1];
// 토큰 만료 검증
if (jwtUtil.isExpired(token)){
filterChain.doFilter(request, response);
return;
}
// 임시 세션 추가
String username = jwtUtil.getUsername(token);
String role = jwtUtil.getRole(token);
CustomUserDetails customUserDetails = new CustomUserDetails();
customUserDetails.setRole(role);
customUserDetails.setUsername(username);
// 세션 인증 토큰 생성
Authentication authToken = new UsernamePasswordAuthenticationToken(username, null, customUserDetails.getAuthorities());
SecurityContextHolder.getContext().setAuthentication(authToken);
System.out.println("Success");
filterChain.doFilter(request, response);
}
}
클라이언트의 요청에 대한 인가 작업을 처리할 때, 해당 요청의 jwt 토큰이 유효한지 검증하는 필터를 구현한 클래스이다.OncePerRequestFilter
를 상속받아 필터를 구현하였다.
doFilterInternal()
: 요청이 해당 필터를 거치면 실행되는 메소드이다. 여기서 토큰 검증 작업을 수행해준다.- 먼저
request
의 헤더에서 토큰 값을 가져온다.- 클라이언트는 토큰값을 헤더에 담아 전송해야한다. 이때 헤더의 키 값은
Authentication
으로 설정한다.
- 클라이언트는 토큰값을 헤더에 담아 전송해야한다. 이때 헤더의 키 값은
- 이후 그렇게 가져온 값이 null값이 아닌지, 또는 유효한 토큰 형식인지 판별한다.
- 모든 토큰은 앞에
Bearer
를 붙여서 전달해야 한다.- 때문에 전달받은 토큰 앞에
Bearer
이 붙는지를 검사하여, 해당 값이 토큰 값인지 검사한다.
- 때문에 전달받은 토큰 앞에
- 모든 토큰은 앞에
- 그 후에는
JWTUtil
에서 정의한isExpired()
메소드를 사용하여 토큰의 유효 기간을 검사한다. - 만약 토큰 검증이 무사히 완료되었으면 임시 세션 정보를 생성 후
SecurityContextHolder
에 저장해준다.- 이렇게 저장된 세션 정보는, 이후에 통과될 Security Filter에 의한 사용자 권한별 인가 처리에 사용된다.
- 후술할
SecurityConfig
에서 세션 저장 정책을Stateless
로 설정해주었기 때문에, 해당 세션 정보는 요청에 대한 처리 이후 초기화된다.
- 후술할
- 이렇게 저장된 세션 정보는, 이후에 통과될 Security Filter에 의한 사용자 권한별 인가 처리에 사용된다.
- 먼저
07. SecurityConfig
@EnableWebSecurity
@Configuration
@RequiredArgsConstructor
public class SecurityConfig {
@Bean
public BCryptPasswordEncoder bCryptPasswordEncoder(){
return new BCryptPasswordEncoder();
}
@Autowired
JWTUtil jwtUtil;
// authManager Bean을 얻기 위한 authConfiguration 객체
private final AuthenticationConfiguration authenticationConfiguration;
// get authManager
@Bean
public AuthenticationManager authenticationManager(AuthenticationConfiguration authenticationConfiguration) throws Exception {
return authenticationConfiguration.getAuthenticationManager();
}
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity httpSecurity) throws Exception {
// CSRF Disable
httpSecurity.csrf(AbstractHttpConfigurer::disable);
// Session login disable
httpSecurity.formLogin(AbstractHttpConfigurer::disable); // UsernamePasswordAuthenticationFilter disable
httpSecurity.httpBasic(AbstractHttpConfigurer::disable); // 기본 로그인창 disable
// 세션 정보를 저장하지 않음(jwt에서는 임시 세션 정보 사용, 사용된 세션은 이후 초기화)
httpSecurity.sessionManagement(httpSecuritySessionManagementConfigurer -> {
httpSecuritySessionManagementConfigurer.sessionCreationPolicy(SessionCreationPolicy.STATELESS);
});
// 커스텀 필터 등록
// 로그인 경로 설정 후, 로그인 필터 등록
CustomUsernamePasswordAuthenticationFilter customUsernamePasswordAuthenticationFilter
= new CustomUsernamePasswordAuthenticationFilter(authenticationManager(authenticationConfiguration), jwtUtil);
customUsernamePasswordAuthenticationFilter.setFilterProcessesUrl("/signin");
httpSecurity.addFilterAt(customUsernamePasswordAuthenticationFilter, UsernamePasswordAuthenticationFilter.class);
// jwt 검증 필터 등록
JwtVerificationFilter jwtVerificationFilter = new JwtVerificationFilter(jwtUtil);
httpSecurity.addFilterAfter(jwtVerificationFilter, CustomUsernamePasswordAuthenticationFilter.class);
// 경로별 인가 설정
httpSecurity.authorizeHttpRequests(auth -> {
auth.requestMatchers("/", "/login", "/join", "/signin").permitAll()
.requestMatchers("/onlyuser").hasRole("USER");
});
return httpSecurity.build();
}
}
bCryptPasswordEncoder()
: 사용자의password
암호화에 사용되는 Bean 객체를 등록한다.AuthenticationConfiguration authenticationConfiguration
:AuthenticationManager
를 얻기 위해, 해당 변수를 참조한다.- 이렇게 얻어진
AuthenticationManager
는 앞에서 구현한CustomUsernamePasswordAuthenticationFilter
객체를 생성하는데 사용된다.
- 이렇게 얻어진
authenticationManager()
: 바로 위의authenticationConfiguration
를 사용하여authenticationManager
를 얻기 위한 메소드securityFilterChain()
: 필터 체인에 관련된 부분을 설정한다.httpSecurity.csrf(AbstractHttpConfigurer::disable);
: 임시로 CSRF 공격 방어를 해제한다.httpSecurity.formLogin(AbstractHttpConfigurer::disable);
: formLogin 방식을 사용하지 않도록 설정한다.httpSecurity.httpBasic(AbstractHttpConfigurer::disable);
: 시큐리티 기본 로그인 창을 사용하지 않도록 설정한다.httpSecurity.sessionManagement( ~~ )
: 세션 저장 정책을Stateless
로 설정한다.- 이후 앞에서 정의한 커스텀 필터를 등록한다.
customUsernamePasswordAuthenticationFilter.setFilterProcessesUrl("/signin");
: 로그인 필터가 작동될 경로를 설정한다.- 기존의
UsernamePasswordAuthenticationFilter
에서는 기본 경로로/login'이 설정되어있었는데 이를
/signin`으로 변경한다.
- 기존의
- 각각 기존의
UsernamePasswordAuthenticationFilter
의 위치, 그 뒤의 위치에 필터를 등록해준다.
httpSecurity.authorizeHttpRequests( ~ )
: 각 경로별 인가를 설정해준다./onlyuser
에는 로그인한 유저만 접근할 수 있도록 설정해 주었다.
08. 실행 결과
로그인 - 실패
임의의 username과 password로 로그인 요청을 하면 403 에러 발생
로그인 - 성공
올바른 정보로 로그인을 수행하면 jwt 토큰 발급
인가 요청 - 성공
발급받은 토큰을 헤더에 추가 후 요청하면, 성공적으로 응답.
인가 요청 - 실패
토큰을 헤더에 추가하지 않고 요청하면, 403 에러 응답
토큰의 앞에 Bearer를 붙이지 않고 요청하면 403 에러 응답
임의의 토큰을 전송하면 403 에러 응답
'Back End > Spring && Spring Boot' 카테고리의 다른 글
[Spring Boot] CORS 설정하기 (0) | 2024.08.05 |
---|---|
[Spring boot / JWT] Spring security를 사용해서 Refresh Token 구현하기 (0) | 2024.07.13 |
[Spring Security] UsernamePasswordAuthenticationFilter 내부 동작 파해치기 (0) | 2024.07.05 |
[Spring boot / h2] Embedded VS In-Memory VS Server (0) | 2024.06.26 |
[Spring boot / JPA] 스프링 부트 데이터베이스 초기화 (0) | 2024.06.26 |