JWT
- 인증에 필요한 정보들을 암호화시킨 JSON 토큰. 유저를 인증하고 식별하기 위한 Token 기반 인증
- JSON 데이터를 Base64 URL-safe Encode를 통해 인코딩하여 직렬화
- 토큰 내부에는 개인키를 통한 전자서명이 들어있음
- 토큰 기반 인증
- 토큰 자체에 사용자들의 정보들이 포함 ⇒ Stateless하게 설계 가능
- Stateless(무상태성) 란?
- 서버가 클라이언트의 상태를 보존하지 않음. 오직 클라이언트의 요청에 대한 응답만 주는 것.
- HTTP통신은 무상태성을 지향. 그래야 서버를 무한 확장 가능하기 때문
- 고객이나 요청이 갑자기 증가할 때 서버 추가가 쉬워짐
- Stateful 하다면? 서버 하나 고장시 서버와 연결된 클라이언트가 기존 작업을 처음부터 다시 해야 하는 상황 발생(결제 튕기면 처음부터 다시!)
- Stateless 하다면 ? 클라이언트가 고객의 작업정보를 보관하므로 한 서버가 고장나도 다른 서버에 같은 요청을 얼마든지 처리 가능
- 단점 : 클라이언트가 서버에 요청할 때 전송할 정보가 늘어남 → 요청, 응답 시 오가는 payload가 커지므로 모든 경우를 무상태성으로 설계하지는 않음
JWT 토큰의 구조
- Header : 토큰의 타입, 전자 서명시 사용한 알고리즘 저장
- Payload : 보통 Claim이라는 토큰에서 사용할 정보들이 담김. 인증 시, 토큰에서 실제로 사용될 정보들. 개발자가 어떤 Claim을 넣을지 정한 후 마음대로 넣을 수 있음
- 표준 스펙에 정의된 7가지 Claim
- iss(Issuer) : 토큰 발급자
- sub(Subjec) : 토큰 제목 - 토큰에서 사용자에 대한 식별값이 된다.
- aud(Audience) : 토큰 대상자
- exp(Expiration Time) : 토큰 만료 시간
- nbf(Not Before) : 토큰 활성 날짜 (이 날짜 이전의 토큰은 활성화 되지 않음을 보장)
- iat(Issued At) : 토큰 발급 시간
- jti(JWT Id) : JWT 토큰 식별자 (issuer가 여러 명일 때 구분하기 위한 값)
- 필요하다면 개발자가 추가로 작성해도 문제 없지만, payload는 암호화가 되지 않기 때문에 식별을 위한 정보만 저장해둘 것.
- 표준 스펙에 정의된 7가지 Claim
- Signature :암호화 되어 있음.
- Decoding을 해도 실제 서명부가 나오지 않고 암호화된 구조만 나타남
- 서버가 가지고 있는 개인키를 통해 암호화되어 있기 때문에 외부에서 Signature를 복호화 불가
- AccessToken & RefreshToken
- 해커가 AccessToken 탈취시 모든 접근이 가능해지므로 유효기간을 짧게 하기.
- RefreshToken을 이용해 AccessToken을 재발급 ⇒ 짧아진 유효기간 문제 해결
- RefreshToken은 클라이언트가 아닌 서버DB에 저장되기 때문에 해커 탈취 위험이 적음
- RefreshToken의 만료기간은 보통 2주로 많이 잡는다고 함.
- JWT 설정 파일 : application-jwt.yml
- 이 속성에서 정한 값은 아래와 같이 값을 넣어서 JwtService에서 사용
jwt:
secretKey: //시크릿키 적으면됨
//base64로 인코딩된 암호 키, HS512를 사용할 것이기 때문에, 512비트(64바이트) 이상이 되어야 함.
access:
expiration: 3600000 //1시간(60분) (1000L(ms -> s) * 60L(s -> m) * 60L(m -> h))
header: Authorization
refresh:
expiration: 1209600000 //(1000L(ms -> s) * 60L(s -> m) * 60L(m -> h) * 24L(h -> 하루) * 14(2주))
header: Authorization-refresh
- jwt: secretKey: //base64로 인코딩된 암호 키, HS512를 사용할 것이기 때문에, // 512비트(64바이트) 이상이 되어야 합니다. 영숫자 조합으로 아무렇게나 길게 써주세요! access: expiration: 3600000 //1시간(60분) (1000L(ms -> s) * 60L(s -> m) * 60L(m -> h)) header: Authorization refresh: expiration: 1209600000 //(1000L(ms -> s) * 60L(s -> m) * 60L(m -> h) * 24L(h -> 하루) * 14(2주)) header: Authorization-refresh
- JWT 주요 라이브러리 차이
- okta의 jjwt 라이브러리
- 별도의 decode 메서드 지원 x
- JWT 생성 : Jwts.builder()
- JWT 검증 : JWT.parserBuilder() 메서드 사용
- JWT 클레임 추가 : addClaims() 사용 또는 set~~() 메서드 사용
- 예시코드(jwt 생성)
/** * JWT TokenDto 생성 **/ public TokenDto createTokenDto(Long userId) { Optional<User> user = userRepository.findByUserId(userId); if (user.isEmpty()) throw new NoSuchElementException(NoSuchElementException.NO_SUCH_USER); Date now = new Date(); String accessToken = JWT.create() .withSubject(ACCESS_TOKEN_SUBJECT) .withIssuedAt(now) .withExpiresAt(new Date(now.getTime() + accessTokenExpirationPeriod)) .withClaim(USER_ID_CLAIM, userId) .sign(Algorithm.HMAC512(secretKey)); String refreshToken = JWT.create() .withSubject(REFRESH_TOKEN_SUBJECT) .withIssuedAt(now) .withExpiresAt(new Date(now.getTime() + refreshTokenExpirationPeriod)) .withClaim(USER_ID_CLAIM, user.get().getUserId()) .withClaim(USER_EMAIL_CLAIM, user.get().getEmail()) .sign(Algorithm.HMAC512(secretKey)); //redis에 refreshToken 객체 생성하여 저장 RefreshTokenDto refreshTokenDto = RefreshTokenDto.builder(). email(user.get().getEmail()). refreshToken(refreshToken).build(); tokenRepository.saveRefreshToken(refreshTokenDto); log.info("Access Token 발급 완료 : {}", accessToken); log.info("Refresh Token 발급 완료 : {}", refreshToken); return TokenDto.builder().accessToken(accessToken).refreshToken(refreshToken).build(); }
- auth0의 java-jwt라이브러리
- 사용 쉽고 가독성 높음
- Base64로 encoding된 값을 decoding할 수 있는 JWT.decode() 메서드 지원
- JWT 생성 : JWT.create()
- JWT 검증 : JWT.require()
- JWT 클레임 추가 : withClaim() 또는 with~~~() 사용
- 예시코드(jwt생성)
// Jwt 토큰 생성 public TokenDto createToken(String userPk, List<String> role) { //claims에 회원을 구분할 수 있는 값을 세팅 Claims claims = Jwts.claims().setSubject(userPk); // 사용자 구분을 위해 pk넣어줌 claims.put("role", role); Date now = new Date(); String accessToken = Jwts.builder() //JwtBuilder객체 생성 .setClaims(claims) // 데이터 .setIssuedAt(now) // 토큰 발행일자 .setExpiration(new Date(now.getTime() + accessTokenValidMillisecond)) // 토큰 유효시간 설정 .signWith(SignatureAlgorithm.HS256, secretKey) // 암호화 알고리즘, 암호키 .compact(); //압축 및 서명 String refreshToken = Jwts.builder() .setClaims(claims) // 정보 저장 .setIssuedAt(now) // 토큰 발행 시간 정보 .setExpiration(new Date(now.getTime() + refreshTokenValidMillisecond)) // set Expire Time .signWith(SignatureAlgorithm.HS256, secretKey) // 사용할 암호화 알고리즘과 // signature 에 들어갈 secret값 세팅 .compact(); return TokenDto.builder().accessToken(accessToken).refreshToken(refreshToken).key(userPk).build(); }
- okta의 jjwt 라이브러리
- JWT 관련 클래스 생성
- JwtService
package dream.security.jwt.service;
import com.auth0.jwt.JWT;
import com.auth0.jwt.algorithms.Algorithm;
import dream.common.exception.InvalidAccessTokenException;
import dream.common.exception.InvalidRefreshTokenException;
import dream.common.exception.NoSuchElementException;
import dream.security.jwt.dto.RefreshTokenDto;
import dream.security.jwt.dto.TokenDto;
import dream.security.jwt.domain.TokenRepository;
import dream.user.domain.User;
import dream.user.domain.UserRepository;
import lombok.Getter;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Service;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.util.Date;
import java.util.Optional;
@Service
@RequiredArgsConstructor
@Getter
@Slf4j
public class JwtService {
@Value("${jwt.secretKey}")
private String secretKey;
@Value("${jwt.access.expiration}")
private Long accessTokenExpirationPeriod;
@Value("${jwt.refresh.expiration}")
private Long refreshTokenExpirationPeriod;
@Value("${jwt.access.header}")
private String accessHeader;
@Value("${jwt.refresh.header}")
private String refreshHeader;
private static final String ACCESS_TOKEN_SUBJECT = "AccessToken";
private static final String REFRESH_TOKEN_SUBJECT = "RefreshToken";
private static final String USER_ID_CLAIM = "userId";
private static final String BEARER = "Bearer ";
private static final String USER_EMAIL_CLAIM = "email";
private final RedisTemplate redisTemplate;
private final TokenRepository tokenRepository;
private final UserRepository userRepository;
/**
* JWT TokenDto 생성
**/
public TokenDto createTokenDto(Long userId) {
Optional<User> user = userRepository.findByUserId(userId);
if (user.isEmpty()) throw new NoSuchElementException(NoSuchElementException.NO_SUCH_USER);
Date now = new Date();
String accessToken = JWT.create()
.withSubject(ACCESS_TOKEN_SUBJECT)
.withIssuedAt(now)
.withExpiresAt(new Date(now.getTime() + accessTokenExpirationPeriod))
.withClaim(USER_ID_CLAIM, userId)
.sign(Algorithm.HMAC512(secretKey));
String refreshToken = JWT.create()
.withSubject(REFRESH_TOKEN_SUBJECT)
.withIssuedAt(now)
.withExpiresAt(new Date(now.getTime() + refreshTokenExpirationPeriod))
.withClaim(USER_ID_CLAIM, user.get().getUserId())
.withClaim(USER_EMAIL_CLAIM, user.get().getEmail())
.sign(Algorithm.HMAC512(secretKey));
//redis에 refreshToken 객체 생성하여 저장
RefreshTokenDto refreshTokenDto = RefreshTokenDto.builder().
email(user.get().getEmail()).
refreshToken(refreshToken).build();
tokenRepository.saveRefreshToken(refreshTokenDto);
log.info("Access Token 발급 완료 : {}", accessToken);
log.info("Refresh Token 발급 완료 : {}", refreshToken);
return TokenDto.builder().accessToken(accessToken).refreshToken(refreshToken).build();
}
/**
* refreshToken을 redis에서 삭제
*/
public void removeRefreshToken(String refreshToken) {
Optional<String> email = extractEmailFromRefreshToken(refreshToken);
if (email.isEmpty()) throw new NoSuchElementException(NoSuchElementException.NO_SUCH_EMAIL_IN_REFRESH_TOKEN);
tokenRepository.findByEmail(email.get())
.ifPresent(refreshTokenDto -> {
tokenRepository.deleteByEmail(email.get());
log.info("Refresh Token 삭제 : {} ", refreshToken);
});
}
/**
* Token 헤더에 넣어 보내기
*/
public void sendTokenDto(HttpServletResponse response, TokenDto tokenDto) {
response.setStatus(HttpServletResponse.SC_OK);
response.setHeader(accessHeader, BEARER+tokenDto.getAccessToken());
response.setHeader(refreshHeader, BEARER+tokenDto.getRefreshToken());
log.info("Access Token, Refresh Token 헤더 설정 완료");
}
/**
* 토큰에서 userId 정보 꺼내기
*/
public Optional<Long> extractUserIdFromAccessToken(String accessToken) {
log.info("token in extractUserIdFromAccessToken : {}", accessToken);
try {
Optional<Long> userId = Optional.ofNullable(JWT.require(Algorithm.HMAC512(secretKey))
.build().verify(accessToken.replace(BEARER, ""))
.getClaim(USER_ID_CLAIM).asLong());
if (userId.isEmpty()) throw new NoSuchElementException(NoSuchElementException.NO_SUCH_USERID_IN_ACCESS_TOKEN);
return userId;
} catch (Exception e) {
throw new InvalidAccessTokenException(InvalidAccessTokenException.INVALID_ACCESS_TOKEN);
}
}
public Optional<String> extractEmailFromRefreshToken(String refreshToken) {
Optional<String> email = Optional.ofNullable(JWT.require(Algorithm.HMAC512(secretKey))
.build().verify(refreshToken.replace(BEARER, ""))
.getClaim(USER_EMAIL_CLAIM).asString());
if (email.isEmpty()) throw new NoSuchElementException(NoSuchElementException.NO_SUCH_EMAIL_IN_REFRESH_TOKEN);
return email;
}
public Optional<Long> extractUserIdFromRefreshToken(String refreshToken) {
Optional<Long> userId = Optional.ofNullable(JWT.require(Algorithm.HMAC512(secretKey))
.build().verify(refreshToken.replace(BEARER, ""))
.getClaim(USER_ID_CLAIM).asLong());
if (userId.isEmpty()) throw new NoSuchElementException(NoSuchElementException.NO_SUCH_USERID_IN_REFRESH_TOKEN);
return userId;
}
/**
* 헤더에서 RefreshToken 추출
*/
public Optional<String> extractRefreshToken(HttpServletRequest request) {
log.info("extractRefreshToken 동작");
return Optional.ofNullable(request.getHeader(refreshHeader))
.filter(refreshToken -> refreshToken.startsWith(BEARER))
.map(refreshToken -> refreshToken.replace(BEARER, ""));
}
/**
* 헤더에서 AccessToken 추출
*/
public Optional<String> extractAccessToken(HttpServletRequest request) {
log.info("extract AccessToken");
log.info("accessHeader Token in Header: {} ",request.getHeader(accessHeader));
Optional<String> accessToken = Optional.ofNullable(request.getHeader(accessHeader))
.map(token -> token.replace(BEARER, ""));
if (accessToken.isEmpty())
throw new NoSuchElementException(dream.common.exception.NoSuchElementException.NO_SUCH_ACCESSTOKEN_IN_HEADER);
return accessToken;
}
public Long getExpiration(String accessToken) {
Optional<Date> expiration = Optional.ofNullable(JWT.require(Algorithm.HMAC512(secretKey))
.build().verify(accessToken.replace(BEARER, ""))
.getExpiresAt());
if (expiration.isEmpty())
throw new NoSuchElementException(NoSuchElementException.NO_SUCH_EXPIREAT_IN_ACCESS_TOKEN);
Long now = new Date().getTime();
return expiration.get().getTime() - now;
}
/**
* Access Token 유효성 검증
*/
public boolean isAccessTokenValid(String accessToken) {
log.info("isAccessTokenValid 동작");
//REDIS의 블랙리스트에 해당 AccessToken이 존재하는지 확인
Optional<String> isLogoutOption = tokenRepository.findByKey(accessToken);
//있다면 유효하지 않은 accessToken 예외 반환
if (isLogoutOption.isPresent())
throw new InvalidAccessTokenException(InvalidAccessTokenException.INVALID_ACCESS_TOKEN);
try {
JWT.require(Algorithm.HMAC512(secretKey)).build().verify(accessToken);
return true;
} catch (Exception e) {
throw new InvalidAccessTokenException(InvalidAccessTokenException.INVALID_ACCESS_TOKEN);
}
}
/**
* Refresh Token 유효성 검증
*/
public boolean isRefreshTokenValid(String refreshToken) {
log.info("isRefreshTokenValid 동작");
Optional<String> email = extractEmailFromRefreshToken(refreshToken);
log.info("refresh에서 추출한 email : {} " , email.get());
if (email.isEmpty()) throw new NoSuchElementException(NoSuchElementException.NO_SUCH_EMAIL_IN_REFRESH_TOKEN);
Optional<RefreshTokenDto> savedRefreshTokenDto = tokenRepository.findByEmail(email.get());
if (!savedRefreshTokenDto.isPresent()) {
throw new InvalidRefreshTokenException(InvalidRefreshTokenException.INVALID_REFRESH_TOKEN);
}
// 여기서 JWT 라이브러리를 사용해 리프레시 토큰의 서명을 검증할 수 있습니다.
try {
JWT.require(Algorithm.HMAC512(secretKey)).build().verify(savedRefreshTokenDto.get().getRefreshToken());
} catch (Exception e) {
throw new InvalidRefreshTokenException(InvalidRefreshTokenException.INVALID_REFRESH_TOKEN);
}
return true;
}
public void saveBlackList(String accessToken) {
if (isAccessTokenValid(accessToken)) {
tokenRepository.saveBlackList(accessToken, getExpiration(accessToken));
}
}
public boolean isTokenValid(String token) {
try {
JWT.require(Algorithm.HMAC512(secretKey)).build().verify(token);
return true;
} catch (Exception e) {
log.error("유효하지 않은 토큰입니다. {}", e.getMessage());
return false;
}
}
}
- E-mail 추출(필요한 claim 추출)
/**
* AccessToken에서 Email 추출
* 추출 전에 JWT.require()로 검증기 생성
* verify로 AceessToken 검증 후
* 유효하다면 getClaim()으로 이메일 추출
* 유효하지 않다면 빈 Optional 객체 반환
*/
public Optional<String> extractEmail(String accessToken) {
try {
// 토큰 유효성 검사하는 데에 사용할 알고리즘이 있는 JWT verifier builder 반환
return Optional.ofNullable(JWT.require(Algorithm.HMAC512(secretKey))
.build() // 반환된 빌더로 JWT verifier 생성
.verify(accessToken) // accessToken을 검증하고 유효하지 않다면 예외 발생
.getClaim(EMAIL_CLAIM) // claim(Emial) 가져오기
.asString());
} catch (Exception e) {
log.error("액세스 토큰이 유효하지 않습니다.");
return Optional.empty();
}
✅ accssToken에서 유저의 Email을 추출하는 메소드 extractEmail()
**JWT.require()**로 토큰 유효성을 검사하는 로직이 있는 JWT verifier builder를 반환한다.
그 후 반환된 builder를 사용하여 **.verify(accessToken)**로 Token을 검증한다.
이때, 토큰이 유효하지 않다면 예외가 발생하여 catch로 잡아 빈 값을 반환한다.
유효하다면, Token 생성 시 Claim으로 설정했던 Email Claim을 꺼내어
**.asString()**으로 String으로 변환 후 유저 Email을 반환한다.
- 토큰 검증 다른 예제(jjwt)
/**
* 토큰검증
*/
public Claims verifyToken(String token) throws UnsupportedEncodingException{
String secretKeyEncodeBase64 = Encoders.BASE64.encode(secretKey.getBytes());
Claims claims = null;
try {
claims = Jwts.parserBuilder()
.setSigningKey(secretKeyEncodeBase64)
.build()
.parseClaimsJws(token)
.getBody();
} catch (ExpiredJwtException e) { // 토큰 만료
claims = null;
}catch (Exception e) { //그외 오류
claims = null;
}
return claims;
}
- Bearer란?
- JWT (JSON Web Token)을 사용할 때 "Bearer"는 인증 스키마의 일종이다. HTTP에서의 "Authorization" 헤더는 다양한 타입의 인증 메커니즘을 지원하기 위해 설계되었고, "Bearer"는 그 중 하나입니다. 다른 인증 메커니즘에는 "Basic", "Digest" 등이 있다
- Bearer의 의미:
- Bearer: 이 단어는 "보유자"라는 의미입니다. "Bearer" 인증 스키마는 토큰을 "보유"하고 있는 클라이언트에 대한 접근을 허용한다는 의미로 사용된다.
- 명확성: "Bearer"를 명시함으로써, 서버와 클라이언트는 이 토큰이 Bearer 토큰임을 명확하게 알 수 있다. 이렇게 해서 다른 종류의 인증 메커니즘이 혼동되는 것을 방지한다.
- 표준화: "Bearer"는 OAuth 2.0 표준에서도 정의되어 있다. 따라서, 표준을 따르는 여러 시스템과 라이브러리에서 쉽게 인식하고 처리할 수 있다.
- 간결성: "Bearer" 다음에 공백을 하나 두고 토큰을 붙이는 것으로 간결하고 명확한 표현이 가능하다. 이는 파싱을 쉽게 해주고, 오류의 여지를 줄여준다다.
- 확장성: 추후에 다른 인증 메커니즘을 도입하더라도, "Bearer" 스키마는 그대로 유지될 수 있으므로 확장성이 좋다.
UserId를 추출하는 애노테이션 만들기
JWT 토큰을 해석하여 사용자 정보를 추출하는 Spring Boot 어노테이션을 만들어, 백엔드 코드를 간편하게 만들어보려고 한다.
우선, **UserId**라는 이름의 어노테이션을 정의한다.
javaCopy code
// UserId.java
import java.lang.annotation.*;
@Target(ElementType.PARAMETER)
@Retention(RetentionPolicy.RUNTIME)
public @interface UserId {
}
이어서, JWT를 파싱하여 사용자 ID를 얻는 서비스를 작성합니다.
javaCopy code
// JwtService.java
import io.jsonwebtoken.Jwts;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
@Service
public class JwtService {
@Value("${jwt.secretKey}")
private String secretKey;
public String extractUserId(String token) {
return Jwts.parser()
.setSigningKey(secretKey)
.parseClaimsJws(token.replace("Bearer ", ""))
.getBody()
.getSubject();
}
}
그 다음에는 HandlerMethodArgumentResolver 인터페이스를 구현하여 어노테이션을 처리하는 로직을 추가합니다.
javaCopy code
// UserIdArgumentResolver.java
import javax.servlet.http.HttpServletRequest;
import org.springframework.core.MethodParameter;
import org.springframework.stereotype.Component;
import org.springframework.web.bind.support.WebDataBinderFactory;
import org.springframework.web.context.request.NativeWebRequest;
import org.springframework.web.method.support.HandlerMethodArgumentResolver;
import org.springframework.web.method.support.ModelAndViewContainer;
@Component
public class UserIdArgumentResolver implements HandlerMethodArgumentResolver {
private final JwtService jwtService;
public UserIdArgumentResolver(JwtService jwtService) {
this.jwtService = jwtService;
}
@Override
public boolean supportsParameter(MethodParameter parameter) {
return parameter.hasParameterAnnotation(UserId.class);
}
@Override
public Object resolveArgument(MethodParameter parameter,
ModelAndViewContainer mavContainer,
NativeWebRequest webRequest,
WebDataBinderFactory binderFactory) {
HttpServletRequest request = (HttpServletRequest) webRequest.getNativeRequest();
String token = request.getHeader("Authorization");
return jwtService.extractUserId(token);
}
}
마지막으로, 이 **HandlerMethodArgumentResolver**를 Spring에 등록합니다.
javaCopy code
// WebConfig.java
import org.springframework.context.annotation.Configuration;
import org.springframework.web.method.support.HandlerMethodArgumentResolver;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
import java.util.List;
@Configuration
public class WebConfig implements WebMvcConfigurer {
private final JwtService jwtService;
public WebConfig(JwtService jwtService) {
this.jwtService = jwtService;
}
@Override
public void addArgumentResolvers(List<HandlerMethodArgumentResolver> argumentResolvers) {
argumentResolvers.add(new UserIdArgumentResolver(jwtService));
}
}
이제, 어느 컨트롤러 메소드에서든 @UserId 어노테이션을 사용하여 사용자 ID를 얻을 수 있다.
javaCopy code
// SomeController.java
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
public class SomeController {
@GetMapping("/someEndpoint")
public String someEndpoint(@UserId String userId) {
return "The user ID is: " + userId;
}
}
- 참고블로그
'Learning-log > Spring & JPA' 카테고리의 다른 글
JPA vs Hibernate vs Spring JPA (2) | 2024.05.09 |
---|---|
[Spring Boot] Spring Security oauth2login 설정으로 Kakao 소셜로그인 API 구현하기 (DeafaultOAuth2User 확장) (0) | 2023.09.19 |
[Spring Boot] SpringSecurity 이해하기 (0) | 2023.09.02 |
(DB2편) 섹션5-3. JPA 소개 (0) | 2023.07.10 |
(스프링MVC1편-백엔드 웹 개발 핵심 기술) 3-(3)JSP로 회원관리 웹 애플리케이션 만들기, (4)MVC패턴 - 개요 (0) | 2023.05.11 |