해당 포스팅은 인프런의 무료강의를 참고하여 작성되었습니다.
Link: https://www.inflearn.com/course/%EC%8A%A4%ED%94%84%EB%A7%81%EB%B6%80%ED%8A%B8-jwt/dashboard
이해하기 쉽게 설명되어 있으니 참고하시면 좋을 것 같습니다.
Link: https://jwt.io/
1. JWT (Json Web Tokens) 란?
JWT는 RFC 7519 웹 표준으로 지정된
JSON 객체를 사용해서 토큰 자체에 정보를 저장하는 Web Token 입니다.
다른 인증 방식들에 비해 가볍고 간편해서 유용한 인증방식 입니다.
2.JWT의 구조
JWT는 Header, Payload, Signature 로 구성되어 있습니다.
Header : Signature를 해싱하기위한 알고리즘 정보.
Payload : 서버와 클라이언트가 주고받는 시스템에서 실제 사용될 정보.
Signature : 토큰의 유효성 검증을 위한 문자열.
3. JWT의 장/단점
장점
- 중앙의 인증서버, 데이터 스토어에 대한 의존성 없음. 시스템 수평 확장 용이.
- Base64 URL Safe Encoding을 사용하기 때문에 URL, Cookie, Header 모두 사용 가능.
단점
- Payload의 정보가 많아지면 네트워크 사용량 증가, 데이터 설계 고려 필요.
- Token이 클리이언트에 저장되기 때문에 서버에서 클라이언트의 토큰을 조작할 수 없음.
4.JWT 적용
java
- common
- jwt
JwtAccessDeniedHandler.java
JwtAuthenticationEntryPoint.java
JwtFilter.java
JwtSecurityConfig.java
TokenProvider.java
- response
ResVO.java
- config
SecurityConfig.java
위와 같은 파일을 작성할 예정입니다. 구조를 확인하세요.
4.1 dependency 추가
JWT를 사용하기 위해 JWT 와 Spring security 관련 Dependency를 추가해줍니다.
/**jwt */
implementation 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'
/**spring security*/
implementation group: 'org.springframework.boot', name: 'spring-boot-starter-security', version: '2.7.5'
4.2 yml 에 설정 추가
header, secret, token-validity-in-seconds를 설정합니다.
secret은 사용할 알고리즘에 따라 길이를 맞추어 base64로 인코딩된 임의 값을 설정하게 됩니다.
SignatureAlgorithm.HS512 알고리즘을 사용할 예정이기 때문에 512자 이상의 값을 입력합니다.
token-validity-in-seconds 는 발행한 키의 유효성 시간 설정 입니다.
jwt:
header: Authorization
secret: and0LXRlc3QtYWxqamFiYWVnaS1qd3QtdGVzdC1hbGpqYWJhZWdpLWp3dC10ZXN0LWFsamphYmFlZ2ktand0LXRlc3QtYWxqamFiYWVnaS1qd3QtdGVzdC1hbGpqYWJhZWdpLWp3dC10ZXN0LWFsamphYmFlZ2ktand0LXRlc3QtYWxqamFiYWVnaS1qd3QtdGVzdC1hbGpqYWJhZWdp
token-validity-in-seconds: 86400
link : https://www.convertstring.com/ko/EncodeDecode/Base64Encode
4.3 SecurityConfig 클래스 생성
SpringSecurty 버전이 올라가면서 WebSecurityConfigurerAdapter 클래스가 Deprecated 되었습니다.
@Bean을 통해 추가하는 방식으로 변경되었고, 해당 내용에 맞춰 구현되었습니다.
[SecurityConfig]
import org.springframework.context.annotation.Bean;
import org.springframework.security.config.annotation.method.configuration.EnableGlobalMethodSecurity;
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.http.SessionCreationPolicy;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.SecurityFilterChain;
import lombok.RequiredArgsConstructor;
@EnableWebSecurity
@EnableGlobalMethodSecurity(prePostEnabled = true)
@RequiredArgsConstructor
public class SecurityConfig {
private final TokenProvider tokenProvider;
private final JwtAuthenticationEntryPoint jwtAtuthenticationEntryPoint;
private final JwtAccessDeniedHandler jwtAccessDeniedHandler;
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
@Bean
public WebSecurityCustomizer webSecurityCustomizer(){
return (web) -> web.ignoring()
.antMatchers("/favicon.ico");
}
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
return http
.csrf().disable()
/**401, 403 Exception 핸들링 */
.exceptionHandling()
.authenticationEntryPoint(jwtAtuthenticationEntryPoint)
.accessDeniedHandler(jwtAccessDeniedHandler)
/**세션 사용하지 않음*/
.and()
.sessionManagement()
.sessionCreationPolicy(SessionCreationPolicy.STATELESS)
/** HttpServletRequest를 사용하는 요청들에 대한 접근 제한 설정*/
.and()
.authorizeRequests()
.antMatchers("/authenticate").permitAll()
/**JwtSecurityConfig 적용 */
.and()
.apply(new JwtSecurityConfig(tokenProvider))
.and().build();
}
}
4.4. JwtSecurityConfig 클래스 생성
아래 생성할 TokenProvider를 주입 받아서 JwtFilter 를 Securty 로직에 등록하기 위한 클래스
[JwtSecurityConfig]
import org.springframework.security.config.annotation.SecurityConfigurerAdapter;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.web.DefaultSecurityFilterChain;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
import lombok.RequiredArgsConstructor;
@RequiredArgsConstructor
public class JwtSecurityConfig extends SecurityConfigurerAdapter<DefaultSecurityFilterChain, HttpSecurity> {
private final TokenProvider tokenProvider;
@Override
public void configure(HttpSecurity http){
JwtFilter customFilter = new JwtFilter(tokenProvider);
http.addFilterBefore(customFilter, UsernamePasswordAuthenticationFilter.class);
}
}
4.5 JwtFilter 생성
Jwt의 인증정보를 SecurityContext에 저장하는 역할을 합니다.
JwtFilter의 doFilter 메소드에서 Reqeust가 들어올 때 SecurityContext에 Authentication 객체를 저장해 사용하게 됩니다.
[JwtFilter]
import java.io.IOException;
import javax.servlet.FilterChain;
import javax.servlet.ServletException;
import javax.servlet.ServletRequest;
import javax.servlet.ServletResponse;
import javax.servlet.http.HttpServletRequest;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.util.StringUtils;
import org.springframework.web.filter.GenericFilterBean;
import lombok.RequiredArgsConstructor;
@RequiredArgsConstructor
public class JwtFilter extends GenericFilterBean {
private static final Logger LOGGER = LoggerFactory.getLogger(JwtFilter.class);
public static final String AUTHORIZATION_HEADER = "Authorization";
private final TokenProvider tokenProvider;
@Override
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
throws IOException, ServletException {
HttpServletRequest httpServletRequest = (HttpServletRequest) request;
String jwt = resolveToken(httpServletRequest);
String requestURI = httpServletRequest.getRequestURI();
if(StringUtils.hasText(jwt) && tokenProvider.validateToken(jwt)){
Authentication authentication = tokenProvider.getAuthentication(jwt);
SecurityContextHolder.getContext().setAuthentication(authentication);
LOGGER.info("Security Context에 '{}' 인증 정보를 저장했습니다, uri: {}", authentication.getName(), requestURI);
}else{
LOGGER.info("유효한 JWT 토큰이 없습니다., uri: {}", requestURI);
}
chain.doFilter(httpServletRequest, response);
}
/**토큰 정보 추출 */
private String resolveToken(HttpServletRequest request){
String bearerToken = request.getHeader(AUTHORIZATION_HEADER);
if(StringUtils.hasText(bearerToken) && bearerToken.startsWith("Bearer ")){
return bearerToken.substring(7);
}
return null;
}
}
4.6 TokenProvider 클래스 생성
Token의 생성, 인증정보 조회, 유효성 검증, 암호화 설정 등의 역할을 하는 클래스 입니다.
InitializingBean을 implemnets 받아 afterPropertiesSet을 Override 하는 이유는 TokenProvider Bean이 생성되고, 주입을
받은 후에 secret 값을 Base64 Decode해서 key 변수에 할당하기 위함입니다.
[TokenProvider]
import java.security.Key;
import java.util.Arrays;
import java.util.Base64;
import java.util.Collection;
import java.util.Date;
import java.util.stream.Collectors;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.InitializingBean;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.userdetails.User;
import org.springframework.stereotype.Component;
import io.jsonwebtoken.Claims;
import io.jsonwebtoken.ExpiredJwtException;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.MalformedJwtException;
import io.jsonwebtoken.SignatureAlgorithm;
import io.jsonwebtoken.UnsupportedJwtException;
import io.jsonwebtoken.security.Keys;
@Component
public class TokenProvider implements InitializingBean {
private final Logger LOGGER = LoggerFactory.getLogger(TokenProvider.class);
private static final String AUTHORITIES_KEY = "NeighborAPI";
private final String secret;
private final long tokenValidityInMilliseconds;
private Key key;
public TokenProvider(@Value("${jwt.secret}") String secret,
@Value("${jwt.token-validity-in-seconds}") long tokenValidityInMilliseconds){
this.secret = secret;
this.tokenValidityInMilliseconds = tokenValidityInMilliseconds;
}
@Override
public void afterPropertiesSet() throws Exception {
byte[] keyBytes = Base64.getDecoder().decode(secret);
this.key = Keys.hmacShaKeyFor(keyBytes);
}
/**token 생성 algorithm */
public String createToken(Authentication authentication){
String authorities = authentication.getAuthorities().stream()
.map(GrantedAuthority::getAuthority)
.collect(Collectors.joining(","));
long now = (new Date()).getTime();
Date validity = new Date(now + this.tokenValidityInMilliseconds);
return Jwts.builder()
.setSubject(authentication.getName())
.claim(AUTHORITIES_KEY, authorities)
.signWith(key, SignatureAlgorithm.HS512)
.setExpiration(validity)
.compact();
}
/**인증 정보 조회 */
public Authentication getAuthentication(String token) {
Claims claims = Jwts.parserBuilder()
.setSigningKey(key)
.build()
.parseClaimsJws(token)
.getBody();
Collection<? extends GrantedAuthority> authorities =
Arrays.stream(claims.get(AUTHORITIES_KEY).toString().split(","))
.map(SimpleGrantedAuthority::new)
.collect(Collectors.toList());
User principal =new User(claims.getSubject(), "", authorities);
return new UsernamePasswordAuthenticationToken(principal, token, authorities);
}
/**token 유효성 검증 */
public boolean validateToken(String token){
try{
Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(token);
return true;
}catch(io.jsonwebtoken.security.SecurityException | MalformedJwtException e){
LOGGER.info("잘못된 JWT 서명입니다.");
}catch(ExpiredJwtException e){
LOGGER.info("만료된 JWT 토큰입니다.");
}catch(UnsupportedJwtException e){
LOGGER.info("지원하지 않는 JWT 토큰입니다.");
}catch(IllegalArgumentException e){
LOGGER.info("JWT 토큰이 잘못되었습니다.");
}
return false;
}
}
4.7 JwtAccessDeniedHandler 생성
403 Fobidden Exception 처리를 위한 클래스 입니다.
공통적인 응답을 위한 ResVO 는 아래 작성하였고,
Object to Json을 위한 CmmnVar.GSON은 공통 스태틱 클래스에 생성해 놓은 Gson 입니다.
[JwtAccessDeniedHandler]
import java.io.IOException;
import java.io.PrintWriter;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.http.MediaType;
import org.springframework.security.access.AccessDeniedException;
import org.springframework.security.web.access.AccessDeniedHandler;
import org.springframework.stereotype.Component;
@Component
public class JwtAccessDeniedHandler implements AccessDeniedHandler{
private final Logger LOGGER = LoggerFactory.getLogger(JwtAccessDeniedHandler.class);
@Override
public void handle(HttpServletRequest request, HttpServletResponse response,
AccessDeniedException accessDeniedException) throws IOException, ServletException {
PrintWriter writer = response.getWriter();
ErrorCode errorCode = CommonErrorCode.FORBIDDEN;
ResVO res = ResVO.builder()
.status(errorCode.getResultCode())
.message(errorCode.getResultMsg()).build();
try{
response.setContentType(MediaType.APPLICATION_JSON_VALUE);
writer.write(CmmnVar.GSON.toJson(res));
}catch(NullPointerException e){
LOGGER.error("응답 메시지 작성 에러", e);
}finally{
if(writer != null) {
writer.flush();
writer.close();
}
}
response.getWriter().write(CmmnVar.GSON.toJson(res));
}
}
4.8 JwtAuthenticationEntryPoint 생성
401 Unauthorized Exception 처리를 위한 클래스
[JwtAthenticationEntryPoint]
import java.io.IOException;
import java.io.PrintWriter;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.http.MediaType;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.web.AuthenticationEntryPoint;
import org.springframework.stereotype.Component;
@Component
public class JwtAuthenticationEntryPoint implements AuthenticationEntryPoint {
private final Logger LOGGER = LoggerFactory.getLogger(JwtAuthenticationEntryPoint.class);
@Override
public void commence(HttpServletRequest request, HttpServletResponse response,
AuthenticationException authException) throws IOException, ServletException {
PrintWriter writer = response.getWriter();
ErrorCode errorCode = CommonErrorCode.UNAUTHORIZED;
ResVO res = ResVO.builder()
.status(errorCode.getResultCode())
.message(errorCode.getResultMsg()).build();
try{
response.setContentType(MediaType.APPLICATION_JSON_VALUE);
writer.write(CmmnVar.GSON.toJson(res));
}catch(NullPointerException e){
LOGGER.error("응답 메시지 작성 에러", e);
}finally{
if(writer != null) {
writer.flush();
writer.close();
}
}
response.getWriter().write(CmmnVar.GSON.toJson(res));
}
}
4.9 기타 ResVO
공통 응답 처리를 위한 클래스 입니다.
import java.util.ArrayList;
import lombok.Builder;
import lombok.ToString;
@ToString
public class ResVO {
private final int status;
private final String message;
private final Integer size;
private final ArrayList<?> items;
@Builder
public ResVO(int status, String message, Integer size, ArrayList<?> items) {
this.status = status;
this.message = message;
this.size = size;
this.items = items;
}
}
5. 인증 구현
사용자 인증 요청에 대해 저장된 사용자 정보와 비교해 각 로직에 따른 처리를 위한 클래스들을 구현합니다.
5.1 AuthController
/authntication 요청이 왔을 때 인증 처리를 하는 클래스 입니다.
전달받은 정보로 권한을 조회하고 SpringContext에 해당 권한정보를 저장합니다.
그리고 이상없이 진행될 경우 Token을 생성하여 전달합니다.
import javax.validation.Valid;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RestController;
import lombok.RequiredArgsConstructor;
@RestController
@RequiredArgsConstructor
public class AuthController {
private final TokenProvider tokenProvider;
private final AuthenticationManagerBuilder authenticationManagerBuilder;
@PostMapping("/authenticate")
public ResponseEntity<TokenDTO> authorize(@Valid @RequestBody OperatorDTO operatorDTO) {
UsernamePasswordAuthenticationToken authenticationToken =
new UsernamePasswordAuthenticationToken(operatorDTO.getLoginId(), operatorDTO.getOprrPswd());
//authenticationManagerBuilder가 호출되면서 CustomUserDetailService가 로드됨.
Authentication authentication = authenticationManagerBuilder.getObject().authenticate(authenticationToken);
SecurityContextHolder.getContext().setAuthentication(authentication);
String jwt = tokenProvider.createToken(authentication);
HttpHeaders httpHeaders = new HttpHeaders();
httpHeaders.add(JwtFilter.AUTHORIZATION_HEADER, "Bearer " + jwt);
return new ResponseEntity<>(new TokenDTO(jwt), httpHeaders, HttpStatus.OK);
}
}
5.2 CustomUserDetailsService 구현
spring security의 UserDetailService를 implements 받아 구현합니다.
전달받은 User 정보로 권한을 조회하고 존재여부, 있다면 활성화 여부에 따라 User 객체를 생성하여 전달합니다.
import java.util.List;
import java.util.stream.Collectors;
import javax.transaction.Transactional;
import org.springframework.security.authentication.BadCredentialsException;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.userdetails.User;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.stereotype.Service;
import lombok.RequiredArgsConstructor;
@Service
@RequiredArgsConstructor
public class CustomUserDetailsService implements UserDetailsService{
private final UserRepository userRepository;
@Override
@Transactional
public UserDetails loadUserByUsername(String loginId) throws UsernameNotFoundException {
return userRepository.findOneWithAuthoritiesByLoginId(loginId)
.map(user -> createUser(loginId, user))
.orElseThrow(() -> new UsernameNotFoundException(loginId + " -> 존재 하지 않음."));
}
/**Security User 정보를 생성한다. */
private User createUser(String loginId, MOpOperator operatorDTO) {
System.out.println(operatorDTO.getIsUse());
if(!"Y".equals(operatorDTO.getIsUse())){
throw new BadCredentialsException(loginId + " -> 활성화 되어있지 않습니다.");
}
List<GrantedAuthority> grantedAuthorities = operatorDTO.getAuthorities().stream()
.map(authority -> new SimpleGrantedAuthority(authority.getAthrNm()))
.collect(Collectors.toList());
return new org.springframework.security.core.userdetails.User(
operatorDTO.getLoginId(),
operatorDTO.getOprrPswd(),
grantedAuthorities);
}
}
5.3 OperatorRespository
운영자 정보와 권한 정보를 조회하는 Repository.
EntityGraph는 쿼리가 수행이 될 때 Lazy 조회가 아니고, 호출되는 시점에 인증정보를 조회하기 위해 사용합니다.
loginId로 권한정보를 조회하여 Entity를 리턴합니다.
import java.util.Optional;
import org.springframework.data.jpa.repository.EntityGraph;
import org.springframework.data.jpa.repository.JpaRepository;
public interface UserRepository extends JpaRepository<MOpOperator, MOpOperatorKey>{
@EntityGraph(attributePaths = "authorities")
Optional<MOpOperator> findOneWithAuthoritiesByLoginId(String loginId);
}
5.4 OperatorDTO
운영자 정보 DTO 입니다.
import java.util.Set;
import lombok.Data;
@Data
public class OperatorDTO {
private String oprrId;
private String loginId;
private String oprrNm;
private String oprrPswd;
private String isUse;
private Set<AuthDTO> authorities;
}
5.5 AuthDTO
권한정보 DTO입니다.
import lombok.Data;
@Data
public class AuthDTO {
private String athrId;
private String athrNm;
}
5.6 TokenDTO
토큰정보 DTO 입니다.
import lombok.AllArgsConstructor;
import lombok.Data;
@Data
@AllArgsConstructor
public class TokenDTO {
private String token;
}
6. 테스트
이제 서버로 인증정보를 요청해 보겠습니다.
Postman을 사용합니다.
6.1 활성화가 안되어 있을 경우
우선 활성화 되어 있지 않은, CustomUserDetailsService에서 아래 조건에 걸리는 결과 입니다.
isUse가 "N" 일 때 겠죠?
if(!"Y".equals(operatorDTO.getIsUse())){
throw new BadCredentialsException(loginId + " -> 활성화 되어있지 않습니다.");
}
사용자에겐 "자격 증명에 실패하였습니다." 를 전달하고 Log에는 활성화 에러 로그를 추가합니다.
[2022-11-14] [09:38:09.682] [ERROR] : geonlee -> 활성화 되어있지 않습니다.
org.springframework.security.authentication.InternalAuthenticationServiceException:
geonlee -> 활성화 되어있지 않습니다.
6.2 로그인정보가 잘못 되었을 경우
id나 pw가 다를 경우입니다.
사용자에겐 같은 자격 증명 실패 메시지를 전달합니다.
[2022-11-14] [09:42:25.779] [ERROR] : 자격 증명에 실패하였습니다.
org.springframework.security.authentication.BadCredentialsException:
자격 증명에 실패하였습니다.
Log는 BadCredenticalsException 이 출력됩니다.
6.3 정상 처리의 경우
정상적으로 수행된 경우 token 정보가 리턴됩니다.
이제 다른 요청에는 해당 token 정보를 인증 정보에 넣어 전달해 줍니다.
Bearer Token을 선택하고 Token에 전달받은 Token 정보를 입력하여 요청합니다.
정상적으로 데이터를 받을 수 있게 됩니다.
공통 Exception 처리와 관련해서는 아래의 Link를 참고하세요.
Link : https://aljjabaegi.tistory.com/657
Refresh Token 관련 내용은 아래의 Link를 참고하세요.
https://aljjabaegi.tistory.com/708
7. 정리
간단하게 로직을 정리하자면,
Spring Security에 TokenProvider를 주입받은 JwtFilter를 등록합니다.
사용자가 사용자정보(loginId, oprrPswd) 로 인증을 요청(/authenticate) 하면 JwtFilter에서는 토큰이 있는지, 유효한지를 판단하여, 유효하다면 SpringContext에 인증정보를 저장하여 사용합니다.
유효하지 않다면(인증정보가 없거나 만료, 잘못된 정보일 경우) CustomUserDetailsService에서 사용자정보(loginId, oprrPswd)로 다시 확인을 하고, 유효하다면 User 정보를 조회하여 새로운 토큰정보를 생성하여 전달을 하고, 유요하지 않다면 Exception을 발생시키게 됩니다.
'Programing > Springboot' 카테고리의 다른 글
Spring boot 실행 시 경고 문구 해결 방법 (0) | 2023.08.18 |
---|---|
[API documentation 자동화] spring boot swagger UI 적용 방법 (0) | 2023.01.26 |
SpringBoot RestAPI 404 Not Found message Custom, @ControllerAdvice @ExceptionHandler (1) | 2022.11.09 |
Spring Boot Banner 변경하기, 배너 변경하기 (1) | 2022.11.08 |
gradle project 배포 시 compiler error 해결 방법 (1) | 2022.09.26 |
댓글