우당탕탕 개발일지
[Spring] JWT 서비스 본문
프로젝트를 진행하면서 가상 API 서버가 필요하여 해당 서버에 JWT 서비스를 적용하게 되었다.
< 의존성 추가하기 >
dependencies {
implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
implementation 'org.springframework.boot:spring-boot-starter-web'
implementation 'org.springframework.boot:spring-boot-starter-security'
compileOnly 'org.projectlombok:lombok'
annotationProcessor 'org.projectlombok:lombok'
//H2 -> MariaDB (예정)
runtimeOnly 'com.h2database:h2'
//JWT
implementation 'io.jsonwebtoken:jjwt-api:0.12.3'
runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.12.5'
runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.12.5'
testImplementation 'org.springframework.boot:spring-boot-starter-test'
testImplementation 'org.springframework.security:spring-security-test'
}
build.gradle에 기본적으로 Spring Web, Lombok, JPA, MariaDB, Security 의존성과 JWT의 가장 최신 버전인 0.12.3 버전 의존성도 추가해주었다.
< applicationl.yml 설정 추가 >
jwt:
header: Authorization
secret: YWJjZGVmZ2hpamtsbW5vcHFyc3R1dnd6eXoxMjMxMjMxMjMxMjMxMjMxMzEyMzEyMzEzMTIzMTIzMTIzMTMxMjMxMzEzMTMxMjM
accessTokenValidityInSeconds: 3600
* secret: Base64로 인코딩한 값을 사용하고, 일정한 길이 이상이 되지 않으면 예외가 발생
시크릿 키는 랜덤한 시크릿 키를 생성하는 사이트를 참고하면 편리하다.
< Jwt 설정 >
import lombok.Data;
import org.springframework.boot.context.properties.ConfigurationProperties;
@Data
@ConfigurationProperties(prefix = "jwt")
public class JwtProperties {
private String header;
private String secret;
private Long accessTokenValidityInSeconds;
}
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@Configuration
@EnableConfigurationProperties(JwtProperties.class)
public class JwtConfig {
@Bean
public TokenProvider tokenProvider(JwtProperties jwtProperties) {
return new TokenProvider(jwtProperties.getSecret(), jwtProperties.getAccessTokenValidityInSeconds());
}
}
JWT 설정파일로 TokenProvider에 의존성을 주입하고 빈을 생성한다.
< Security Config >
Spring Security는 기본적으로 순서가 있는 Security Filter 들을 제공하고, Spring Security가 제공하는 Filter를 구현한게 아니라면 필터의 순서를 정해줘야 한다.
spring security는 spring 기반 애플리케이션의 보안을 담당하는 프레임워크이다. 인증과 권한에 대해 filter 흐름에 따라 처리하며 보안과 관련해서 많은 옵션을 제공해주고 있다.
@Configuration
@EnableWebSecurity // 기본 웹 보안 활성화
@EnableMethodSecurity // @PreAuthorize 애노테이션 활성화
public class SecurityConfig {
@Autowired
private CorsFilter corsFilter;
@Autowired
private CustomJwtFilter customJwtFilter;
@Autowired
private JwtAuthenticationEntryPoint jwtAuthenticationEntryPoint;
@Autowired
private JwtAccessDeniedHandler jwtAccessDeniedHandler;
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http.csrf(c -> c.disable())
.addFilterBefore(corsFilter, UsernamePasswordAuthenticationFilter.class)
.addFilterBefore((Filter) customJwtFilter, UsernamePasswordAuthenticationFilter.class)
.sessionManagement(c -> c.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.exceptionHandling(c -> {
c.authenticationEntryPoint(jwtAuthenticationEntryPoint).accessDeniedHandler(jwtAccessDeniedHandler);
})
.authorizeHttpRequests(c -> {
c.requestMatchers("/api/v1/organization",
"/api/v1/organizationtoken",
"/api/v1/organization/exists/**").permitAll()
.anyRequest().authenticated();
});
return http.build();
}
}
1) CSRF(Cross-Site Request Forgery) 보호 기능을 비활성화한다.
2) corsFilter와 customJwtFilter를 추가하고, 세션 관리 설정을 한다.
3) HTTP 요청 권한 설정을 한다.
< TokenProvider 생성 >
package com.example.apiServer.auth.jwt;
import com.example.apiServer.entity.RefreshToken;
import com.example.apiServer.repository.RefreshTokenRepository;
import io.jsonwebtoken.*;
import io.jsonwebtoken.io.Decoders;
import io.jsonwebtoken.security.Keys;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
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 java.security.Key;
import java.util.Arrays;
import java.util.Date;
import java.util.List;
import java.util.Optional;
import java.util.stream.Collectors;
@Slf4j
public class TokenProvider {
private static final String AUTHORITIES_KEY = "auth";
private final String secret;
private Key key;
private final long validityInSeconds;
// 어세스 토큰 유효시간 (1시간)
private long accessTokenValidTime = 1 * 60 * 60 * 1000L;
// 리프레시 토큰 유효시간 (1일)
private long refreshTokenValidTime = 24 * 60 * 60 * 1000L;
. . .
}
1) Authentication을 이용한 JWT 생성
1. Jwts.builder() 메서드를 사용하여 JwtBuilder 인스턴스를 생성한다.
2. 페이로드 옵션으로 content 또는 claim을 설정한다.
3. signWith 또는 encryptWith 메서드를 통해 JWT를 디지털 방식으로 서명하거나 암호화한다.
4. 압축 JWT 문자열을 생성하는 compact() 메서드를 호출한다
String jwt = Jwts.builder() // (1)
.header() // (2) optional
.keyId("aKeyId")
.and()
.subject("Bob") // (3) JSON Claims, or
//.content(aByteArray, "text/plain") // any byte[] content, with media type
.signWith(signingKey) // (4) if signing, or
//.encryptWith(key, keyAlg, encryptionAlg) // if encrypting
.compact(); // (5)
public String createToken(Authentication authentication, long validityInSeconds) {
String authorities = authentication.getAuthorities().stream()
.map(GrantedAuthority::getAuthority)
.collect(Collectors.joining(","));
Date validity = new Date(validityInSeconds + this.validityInSeconds * 1000);
return Jwts.builder()
.setSubject(authentication.getName())
.claim(AUTHORITIES_KEY, authorities)
.signWith(key, SignatureAlgorithm.HS512) // HMAC + SHA512
.setExpiration(validity)
.compact();
}
토큰을 생성하고 검증하며 토큰에서 정보를 꺼내 스프링 시큐리티 Authentication 객체를 생성하는 역할을 수행한다.
// Access Token 생성.
public String createAccessToken(Authentication authentication){
return this.createToken(authentication, accessTokenValidTime);
}
// Refresh Token 생성.
public String createRefreshToken(Authentication authentication) {
return this.createToken(authentication, refreshTokenValidTime);
}
** JWT Payload
- content: 텍스트, 이미지, 문서 등과 같이 바이트 배열인 경우
- claim: JSON 객체로 지정하는 경우
** TokenProvider 생성자
- secret: JWT 서명에 사용되는 시크릿 키. Base64로 인코딩되어 있으며, 디코딩되어 key 변수에 할당된다.
- tokenValidityInSeconds: 생성된 토큰의 유효성 기간.
2) JWT를 이용한 Authentication 생성
1. Jwts.parser() 메서드를 사용하여 JwtParserBuilder 인스턴스를 생성한다.
2. keyLocator 또는 verifyWith 메서드를 통해 JWS 서명을 확인한다.
3. build() 메서드를 호출한다.
4. parseSignedClaims(String) 메서드를 호출하여 원본 JWS를 생성한다.
Jws<Claims> jws;
try {
jws = Jwts.parser() // (1)
.keyLocator(keyLocator) // (2) dynamically lookup verification keys based on each JWS
//.verifyWith(key) // or a static key used to verify all encountered JWSs
.build() // (3)
.parseSignedClaims(jwsString); // (4) or parseSignedContent(jwsString)
// we can safely trust the JWT
catch (JwtException ex) { // (5)
// we *cannot* use the JWT as intended by its creator
}
public Authentication getAuthentication(String token) {
Claims claims = Jwts.parser()
.setSigningKey(key)
.build()
.parseClaimsJws(token)
.getPayload();
List<? extends GrantedAuthority> authorities =
Arrays.stream(claims.get(AUTHORITIES_KEY).toString().split(","))
.map(SimpleGrantedAuthority::new)
.collect(Collectors.toList());
return new UsernamePasswordAuthenticationToken(token, authorities);
}
주어진 토큰을 사용하여 클레임(claim)을 추출한다. 클레임에는 JWT의 페이로드에 저장된 사용자명 및 권한 정보가 포함되어 있다. 클레임에서 사용자의 권한을 가져와 SimpleGrantedAuthority 객체로 매핑하고, 사용자의 토큰과 권한을 사용하여 UsernamePasswordAuthenticationToken 객체를 생성하고 반환한다.
* setSigningKey(secretKey): 서명을 확인하기 위한 비밀키 설정
3) JWT 유효성 체크
Key key = getSigningKey(); // or getEncryptionKey() for JWE
String keyId = getKeyId(key); //any mechanism you have to associate a key with an ID is fine
String jws = Jwts.builder()
.header().keyId(keyId).and() // <--- add `kid` header
.signWith(key) // for JWS
//.encryptWith(key, keyAlg, encryptionAlg) // for JWE
.compact();
public boolean validateToken(String token) {
try {
Claims claims = Jwts.parser().setSigningKey(key).build().parseClaimsJws(token).getBody();
return true;
} catch (io.jsonwebtoken.security.SecurityException | MalformedJwtException e) {
log.info("잘못된 JWT 서명입니다.");
} catch (ExpiredJwtException e) {
log.info("만료된 JWT 토큰입니다.");
} catch (UnsupportedJwtException e) {
log.info("지원되지 않는 JWT 토큰 입니다.");
} catch (IllegalArgumentException e) {
log.info("JWT 토큰이 잘못되었습니다.");
e.printStackTrace();
}
return false;
}
< JWT Filter 생성 >
package com.example.apiServer.auth.jwt;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.ServletRequest;
import jakarta.servlet.ServletResponse;
import jakarta.servlet.http.HttpServletRequest;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;
import java.io.IOException;
@Slf4j
@Component
@RequiredArgsConstructor
public class CustomJwtFilter {
public static final String AUTHORIZATION_HEADER = "Authorization";
private final TokenProvider tokenProvider;
. . .
}
1) Servlet Filter를 이용한 JWT 유효성 검사
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {
HttpServletRequest req = (HttpServletRequest) request;
String jwt = resolveToken(req);
String requestURI = req.getRequestURI();
// 토큰 유효성 검사
if (StringUtils.hasText(jwt) && tokenProvider.validateToken(jwt)) { // 토큰에 이상이 없는 경우
// 토큰에서 사용자명, 권한을 추출하여 스프링 시큐리티 사용자를 만들어 Authentication 반환
Authentication authentication = tokenProvider.getAuthentication(jwt);
SecurityContextHolder.getContext().setAuthentication(authentication);
log.debug("Security Context에 %s 인증 정보를 저장했습니다. URI : %s", authentication.getName(), requestURI);
} else {
log.debug("유효한 JWT 토큰이 없습니다. URI: %s", requestURI);
}
//다음 필터로 넘기기
chain.doFilter(request, response);
}
2) HttpServletRequest에서 JWT 추출
private String resolveToken(HttpServletRequest request) {
String bearerToken = request.getHeader(AUTHORIZATION_HEADER);
if (StringUtils.hasText(bearerToken) && bearerToken.startsWith("Bearer ")) {
return bearerToken.substring(7);
}
return null;
}
HttpServletRequest에서 Authorization 헤더를 받는다. 헤더에 'Bearer'로 시작하는 토큰이 있으면 'Bearer' 부분을 제거하고 토큰 값을 반환하고, 없으면 null 값을 반환한다.
< 예외 처리 설정 >
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.security.access.AccessDeniedException;
import org.springframework.security.web.access.AccessDeniedHandler;
import org.springframework.stereotype.Component;
import java.io.IOException;
@Component
public class JwtAccessDeniedHandler implements AccessDeniedHandler {
@Override
public void handle(HttpServletRequest request, HttpServletResponse response,
AccessDeniedException accessDeniedException) throws IOException {
response.sendError(HttpServletResponse.SC_FORBIDDEN); // 403에러
}
}
package com.example.apiServer.auth.jwt;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.web.AuthenticationEntryPoint;
import org.springframework.stereotype.Component;
import java.io.IOException;
@Component
public class JwtAuthenticationEntryPoint implements AuthenticationEntryPoint {
@Override
public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException, ServletException {
// 자격증명 없이 페이지 접근시 접근권한 없음(401)
response.sendError(HttpServletResponse.SC_UNAUTHORIZED);
}
}
< RefreshToken 생성 >
access token 은 유효 기간이 짧기 때문에 탈취하더라도 오랫동안 사용할 수 없다. 하지만 access token 보다 유효기간이 긴 refresh token 을 탈취당한다면 유효 기간동안 무제한적으로 access token 발급이 가능하게 된다. 이를 해결하기 위해 RTR(Refresh Token Rotation)을 사용한다. RTR이란, access token을 재발급할 때 refresh token도 재발급하는 방식이다.
import jakarta.persistence.*;
import lombok.*;
import java.time.Instant;
@Entity
@Data
@AllArgsConstructor
@NoArgsConstructor
@Builder
@Table(name = "token")
@Inheritance(strategy = InheritanceType.SINGLE_TABLE)
public class Token {
@Id
@GeneratedValue (strategy = GenerationType.IDENTITY)
@Column(name = "userId")
private String userId;
private String refreshToken;
private Long expiration;
public boolean isExpired() {
return Instant.now().isAfter(Instant.ofEpochSecond(expiration));
}
}
import com.example.apiServer.entity.Token;
import org.springframework.data.repository.CrudRepository;
public interface TokenRepository extends CrudRepository<Token, String> {
}
< Entity 생성 >
[ organization Entity ] ( = user와 같은 역할 )
organization은 user와 비슷한 개념이며, repository와 service 부분은 생략하도록 하겠다.
import jakarta.persistence.*;
import lombok.Data;
import lombok.NoArgsConstructor;
@Entity
@Table(name = "organization")
@Data
@NoArgsConstructor
public class Organization { // 인증된 기관들을 저장해놓은 테이블
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "organization_id")
private Long id;
@Column(name = "organization_name")
private String organizationName;
@Column(name = "organization_email", unique = true)
private String organizationEmail;
@Column(name = "organization_password")
private String password;
// OrganizationService에서 사용
public void setPassword(String password) {
this.password = password;
}
}
[ token Entity ]
import jakarta.persistence.*;
import lombok.*;
@Entity
@Table(name = "token")
@Data
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Token {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "token_id")
private Long id;
@Column(name = "token_organizationName", nullable = false)
private String organizationName; // 기관 이름
@Column(name = "token_refreshToken", nullable = false, unique = true)
private String refreshToken;
@Column(name = "token_createdAt", nullable = false)
private Long createdAt;
public Token(String organizationName, String refreshToken, Long createdAt) {
this.organizationName = organizationName;
this.refreshToken = refreshToken;
this.createdAt = createdAt;
}
public Token update(String refreshToken) {
this.refreshToken = refreshToken;
return this;
}
}
< DTO 작성 >
[ access token DTO ]
import lombok.*;
@Data
@NoArgsConstructor
@AllArgsConstructor
public class TokenRequest {
private String organizationName;
private String organizationEmail;
private String password;
}
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class TokenResponse {
private String accessToken;
private String refreshToken;
}
[ refresh token DTO ]
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class RefreshTokenRequest {
private String organizationName;
private String refreshToken;
}
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class RefreshTokenResponse {
private String refreshToken;
}
< Repository 생성 >
import com.example.apiServer.entity.Organization;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.transaction.annotation.Transactional;
import java.util.Optional;
public interface OrganizationRepository extends JpaRepository<Organization, Long> {
Optional<Organization> findByOrganizationName(String organizationName);
}
import com.example.apiServer.entity.Token;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.repository.CrudRepository;
import org.springframework.transaction.annotation.Transactional;
import java.util.Optional;
@Transactional(readOnly = true)
public interface TokenRepository extends JpaRepository<Token, Long> {
Optional<Token> findById(Long id);
Optional<Token> findByRefreshToken(String refreshToken);
@Transactional
int deleteByOrganizationName(String organizationName);
}
< Service 작성 >
import com.example.apiServer.api.status.ErrorStatus;
import com.example.apiServer.dto.token.AccessTokenResponse;
import com.example.apiServer.entity.Token;
import com.example.apiServer.jwt.TokenProvider;
import com.example.apiServer.dto.token.TokenResponse;
import com.example.apiServer.entity.Organization;
import com.example.apiServer.exception.GeneralException;
import com.example.apiServer.repository.OrganizationRepository;
import com.example.apiServer.repository.TokenRepository;
import io.jsonwebtoken.*;
import lombok.RequiredArgsConstructor;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder;
import org.springframework.security.core.Authentication;
import org.springframework.stereotype.Service;
import java.security.Key;
import java.util.Date;
@Service
@RequiredArgsConstructor
public class TokenService {
private final TokenProvider tokenProvider;
private final OrganizationRepository organizationRepository;
private final AuthenticationManagerBuilder authenticationManagerBuilder;
private final AuthenticationManager authenticationManager;
private final TokenRepository tokenRepository;
private final Logger logger = LoggerFactory.getLogger(this.getClass());
. . .
}
1) 등록된 기관에게 토큰 발급
public TokenResponse getAuthToken(String organizationName, String organizationEmail, String password) {
try {
logger.info("Authenticating organization with ID: " + organizationName + " and email: " + organizationEmail + " and password" + password);
Organization organization = organizationRepository.findByOrganizationName(organizationName)
.orElseThrow(() -> new GeneralException(ErrorStatus._USER_NOT_FOUND));
logger.info("organization 정보 " + organization.getOrganizationEmail()+ " and " + organization.getOrganizationName() + "and " +organization.getPassword());
// 권한 생성 (기관, 공식 메일)
UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken(organization.getOrganizationName(), password);
Authentication authentication = authenticationManager.authenticate(authenticationToken);
//Authentication authentication = authenticationManagerBuilder.getObject().authenticate(authenticationToken);
Long created_At = new Date().getTime();
// Token 발급
String accessToken = tokenProvider.createAccessToken(authentication, created_At);
String refreshToken = tokenProvider.createRefreshToken(authentication, created_At);
// RefreshToken 저장
Token buildtoken = new Token(organizationName, refreshToken, created_At);
tokenRepository.save(buildtoken);
return new TokenResponse(accessToken, refreshToken);
} catch (Exception e) {
logger.error("Error occurred while generating auth token for organization ID: " + organizationName + " and email: " + organizationEmail, e);
throw new GeneralException(ErrorStatus._UNKNOWN);
}
}
organization 이름으로 등록되어 있는 기관인지 확인하고 현재 시점 기준으로 토큰을 발급해준다.
2) 토큰 만료 시 새 accessToken 발급
public AccessTokenResponse getAccessToken(String organizationName, String refreshToken) {
Authentication authentication = tokenProvider.getAuthentication(refreshToken);
Long nowTime = new Date().getTime();
// AccessToken 발급
String newAccessToken = tokenProvider.createAccessToken(authentication, nowTime);
return new AccessTokenResponse(newAccessToken);
}
ISSUE. Decode argument cannot be null
Caused by: org.springframework.beans.factory.UnsatisfiedDependencyException: Error creating bean with name 'customJwtFilter' defined in file [C:\GitHub\apiServer\build\classes\java\main\com\example\apiServer\auth\jwt\CustomJwtFilter.class]: Unsatisfied dependency expressed through constructor parameter 0: Error creating bean with name 'tokenProvider' defined in class path resource [com/example/apiServer/auth/config/JwtConfig.class]: Failed to instantiate [com.example.apiServer.auth.jwt.TokenProvider]: Factory method 'tokenProvider' threw exception with message: Decode argument cannot be null.
JwtConfig 클래스에서 JwtProperties 빈을 주입하는 과정에서 secret 값 설정이 잘못되어 오류 발생
1) application.yml 파일 수정
2) 어노테이션 import 수정
@Value 어노테이션으로 사용하고자 했지만 여전히 정의한 값을 인식하지 못하고 있었다. lombok의 @Value 어노테이션을 import 해서 발생한 것이었고, import org.springframework.beans.factory.annotation.Value 로 변경
3) 시크릿 키 수정
Exception encountered during context initialization - cancelling refresh attempt: org.springframework.beans.factory.UnsatisfiedDependencyException: Error creating bean with name 'securityConfig': Unsatisfied dependency expressed through field 'customJwtFilter': Error creating bean with name 'customJwtFilter' defined in file [C:\SKU\dev\back-end-workspace\apiServer\build\classes\java\main\com\example\apiServer\auth\jwt\CustomJwtFilter.class]: Unsatisfied dependency expressed through constructor parameter 0: Error creating bean with name 'tokenProvider' defined in class path resource [com/example/apiServer/auth/config/JwtConfig.class]: Failed to instantiate [com.example.apiServer.auth.jwt.TokenProvider]: Factory method 'tokenProvider' threw exception with message: Illegal base64 character: '!'
디버깅을 했을 때 "Illegal base64 character: '!'"라는 메시지를 받았다. 시크릿 키에 올바르지 않은 문자가 포함되어 있어서 발생한 문제로, JWT에서는 Base64 인코딩된 형태로 사용되기 때문에 특수문자나 공백은 시크릿 키에 포함시키지 않아야 한다.
'Spring' 카테고리의 다른 글
[Spring] API 공통 응답 (0) | 2024.09.20 |
---|---|
[Spring] Swagger - API 명세서 작성 (1) | 2024.09.15 |
JPA - 데이터 모델링 (0) | 2024.05.20 |
AWS Cognito를 활용한 회원가입 (0) | 2024.05.20 |
SpringBoot - MVC (0) | 2023.06.16 |