로그인 구현 마지막 기능으로 로그아웃과 내 개인적인 보안 강화?를 적용해 보겠다.
우선 내 로그인 로직은
- 클라이언트가 로그인을 한다.
- 로그인 성공하면 accessToken, refreshToken을 건네준다.
- accessToken이 만료되면 로그인 유지를 위해 refreshToken으로 재발급 요청을 보낸다.
- refreshToken이 유효하면 accessToken을 발급한다.
- refreshToken이 유효하지 않다면 토큰을 만료시키고 강제 로그아웃 처리를 한다.
크게 이런 흐름이다.
내가 생각한 나름의 보안 강화는 4번에 적용시켰고, refreshToken이 유효해도 accessToken이 유효하지 않다면 비정상적인 접근으로 판단하여 강제 로그아웃 처리를 하였다. 이후 해당 accessToken은 따로 저장하여 해당 토큰으로 접근 시 접근이 불가능하게 하였다.
이렇게 따로 저장하려면 저장 공간이 필요하고 DB가 생각날 것이다.
하지만 accessToken의 경우 유효 기간을 짧게 하여 업데이트가 자주 일어나기 때문에 속도가 빠르면 좋다고 생각했고, key-value로 토큰 값만 저장할 계획이기에 여기에 알맞은 Redis를 사용하기로 했다.
Redis의 경우 인메모리 데이터베이스로 컴퓨터의 주 메모리(ROM,RAM)에 데이터를 저장하는 방식으로 보조 기억장치(HDD,SDD)를 사용하는 데이터베이스에 비해 빠른 장점이 있다. 유효 기간이 짧아 자주 발급받는 토큰을 저장하기에 좋다고 생각이 들었다. 또한 다양한 데이터형태를 지원하는 장점이 있다. 그리고 처음 구상한 어떤 key의 값이 없으면 사용하거나 못하게하거나 할 계획이기 때문에 NoSQL인 점도 Redis를 사용할 이유가 충분했다.
다른 사람들의 로그인 로직을 보니 요청마다 accessToken, refreshToken 모두 보내서 검증하고 재발급 시에도 둘 다 재발급 받으면서 자주 발급받는 여러가지 방식이 있던데 그러면 refreshToken이 그만큼 노출이 많이 되고, refreshToken을 같이 발급 받으면 유효 기간을 길게 설정해서 발급 받는 의미도 없고 accessToken과의 차이가 크게 느껴지지 않다고 생각해서 refreshToken만으로 accessToken 재발급을 진행했다.
Redis 설치 및 실행
우선 그냥 설치하려다가 실제 배포를 하면 Docker로 올리기도 했고, 시간이 된다면 Redis sentinel를 적용하여 서비스 운영에 영향이 없게도 만들어 보고 싶었기에 우선 Docker로 설치 및 실행했다.
Docker Desktop이 있다면 간단하게 Redis를 찾아서 run을 누르면 알아서 실행된다.
아무것도 설정하지 않았기에

이렇게 기본 비밀번호와 로컬호스트와 포트로 접근하라고 알려준다.
여기서 내가 삽질을 좀 했는데 Spring Boot와 연결에서 삽질을 좀 했다.
spring:
...
data:
redis:
host: ${redis.host}
port: ${redis.port}
password: ${redis.password}
...
우선 이렇게 설정하면 되는데 주의점은 제일 앞에 띄어쓰기가 있으면 안되는 것이다.
나같은 경우 env.properties에서 redis.host로 읽어왔는데 redis.host = localhost 이렇게 값 앞에 공백이 있으면 공백까지 같이 인식하는지 에러가 난다.
RedisConnectionFailureException라는 에러가 발생한다.
우선 위의 사진대로라면 host엔 localhost, port엔 55000, password엔 redispw가 들어가면 된다.
다르게 설정하면 다른 내용으로 연결하라고 알려줄테니 그렇게 하면 된다.
참고로 이렇게 도커로 실행한 레디스에 접속하려면
docker exec -it {컨테이너 ID} redis-cli
이렇게 입력하면 redis에 접속할 수 있다.
혹시 연결이 되었는데 redis 명령어가 듣질 않는다면 위의 비밀번호를 입력하면 redis 명령어를 사용할 수 있다. 나의 경우, AUTH redispw 라고 입력하니 되었다.
Redis Spring Boot에 적용
찾아보니 Spring Boot 2.0 이전에는 Config파일을 하나 작성해서 Bean등록을 해주어야 했다.
하지만 난 2.0 이상이기 때문에 이런 설정할 파일을 작성하지 않아도 Redis 관련 메소드를 사용할 수 있었다.
또한 JPA로 사용할 수 있는 방법이 있어 이걸 사용했다.
먼저 의존성을 추가해준다.
build.gradle (auth-api 모듈)
implementation 'org.springframework.boot:spring-boot-starter-data-redis' //Redis
그 다음, Entity와 같이 Redis객체를 담을 클래스를 하나 생성해준다.
redis/LoginTokenDto (auth-api 모듈)
package com.seungh1024.redis;
import jakarta.persistence.Id;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import org.springframework.data.redis.core.RedisHash;
import org.springframework.data.redis.core.TimeToLive;
import org.springframework.data.redis.core.index.Indexed;
@Getter
@RedisHash(value = "refreshToken") // ---1
@AllArgsConstructor
@Builder
public class LoginTokenDto {
@Id //-------------------------------2
private String id;
@Indexed //--------------------------3
private String accessToken;
@TimeToLive //-----------------------4
private Long expiration
public void updateAccessToken(String accessToken){
this.accessToken = accessToken;
}
}
- @RedisHash
@RedisHash는 Hash Collection을 명시하는 것으로 Redis에 HashMap형태로 저장된다. key-value로 저장되는 것이 아니라 key - HashMap<key,value> 이런 방식으로 저장된다. 특정 key에 해당하는 HashMap이 여기 작성한 LoginTokenDto가 되는 것이다. value값을 주면 위의 경우 refreshToken:{id} 의 형태로 key값을 가진다. - @Id
key-HashMap에서 key에 해당하는 것으로 @RedisHash의 value와 결합하여 key값이 된다. 이 어노테이션이 붙은 변수 명은 무조건 id이어야 한다. - @Index
CRUD Repository를 사용할 때 이 값으로 조회하기 위해 사용하는 것이다. Redis의 경우 key로 value를 조회하는 형식인데 이 어노테이션을 달아주면 key에 등록되어 이 어노테이션이 붙은 변수를 key처럼 사용할 수 있다. MySQL에서 Index가 등록된 것과 비슷하다. - @TimeToLive
유효시간을 설정하는 것으로 초 단위이다. 밀리초로 바꾸고 싶다면 @TimeToLive(unit = TimeUnit.MILLISECONDS )처럼 사용하면 된다.
repository/LoginTokenRepository (auth-api 모듈)
package com.seungh1024.repository;
import com.seungh1024.redis.LoginTokenDto;
import org.springframework.data.repository.CrudRepository;
import java.util.Optional;
public interface LoginTokenRepository extends CrudRepository<LoginTokenDto,String> {
Optional<LoginTokenDto> findLoginTokenDtoByAccessToken(String accessToken);
}
얘가 반환 형식이 Optional<LoginTokenDto> 이렇게 나오는데 내가 이것 저것 만져보니 LoginTokenDto를 저장하는 형식이라 위에서 말한 key-HashMap 형태로 저장되어서 그런 것 같다. LoginTokneDto를 사용하지 않고 key-value 형태인 String,String 형식으로 저장하려고 해봤지만 JPA로 사용시 유효기간 설정을 어떻게 하는지 못찾아서 그냥 이렇게 했다. 그리고 이렇게 Hash형태로 저장되는걸 알기 전까지 상당한 삽질을 했다.
Redis에 직접 접속하여 데이터 확인할 때도 get {key}로 검색하면
"WrongType operation against a key holding the wrong kind of value" 이런 에러가 나오는데 이거는 Hash 값을 읽어오는 명령어가 아니라서 그렇다. hget {key} 명령어를 사용하면 정상적으로 읽어올 수 있다.
이렇게 기본 틀은 잡혔고 내가 구상한 방식은 id에다가 refresh token을 저장하고 accessToken에는 access token을, 그리고 만료기한을 설정해서 저장할 것이다. 위와 같이 하는 이유는 refresh token을 조회해서 있다면 토큰을 재발급 할 것이고 조회 했을 때 access token이 만료되지 않았다면 불필요한 접근으로 판단하여 토큰이 탈취되었다고 가정하고 저장한 LoginTokenDto를 Redis에서 삭제 후 로그아웃 처리를 할 것이다.
또한 access token의 유효기간이 남았으니 해당 유효기간 만큼 id에 access token을, accessToken에는 아무것도 넣지 않고 남은 유효기간을 계산하여 저장하였다. Spring Security의 Filter에서 토큰 검증 과정 시 요청을 보낸 access token이 Redis에 등록되어 있다면 서비스를 사용하지 못하도록 block할 것이다.
이제 차례로 만들어보자
utils/JwtUtil (common-authentication 모듈)
public String createRefreshJwt(String memberEmail, String jwtSecret, Long refreshExpired){
Claims claims = Jwts.claims();
claims.put("memberEmail",memberEmail);
return Jwts.builder()
.setClaims(claims)
.setIssuedAt(new Date(System.currentTimeMillis()))
.setExpiration(new Date(System.currentTimeMillis()+refreshExpired))
.signWith(SignatureAlgorithm.HS256, jwtSecret)
.compact();
}
public Date getExpired(String token, String jwtSecret){
return Jwts.parser().setSigningKey(jwtSecret).parseClaimsJws(token)
.getBody().getExpiration();
}
JwtUtil에 refresh token 생성 메소드와 유효 기간을 가진 Date를 반환하는 getExpired를 추가했다.
createRefreshJwt의 경우 토큰 생성과 다를바 없지만 나중에 구조가 바뀔 수도 있다고 생각하여 따로 생성했다.
아마 구조가 바뀐다면 사용자 정보는 없고 유효 기간만 있는 토큰이 될 것 같다.
application/AuthApplication (auth-api 모듈)
package com.seungh1024.application;
import com.seungh1024.dto.MemberDto;
import com.seungh1024.member.Member;
import com.seungh1024.redis.LoginTokenDto;
import com.seungh1024.repository.LoginTokenRepository;
import com.seungh1024.repository.MemberRepository;
import com.seungh1024.service.AuthService;
import com.seungh1024.utils.JwtUtil;
import lombok.RequiredArgsConstructor;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.security.authentication.AccountExpiredException;
import org.springframework.security.authentication.BadCredentialsException;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.stereotype.Service;
import java.util.Date;
import java.util.HashMap;
import java.util.Optional;
@Service
@RequiredArgsConstructor
public class AuthApplication {
private final AuthService authService;
private final MemberRepository memberRepository;
private final LoginTokenRepository loginTokenRepository;
private final JwtUtil jwtUtil;
@Value("${jwt.secret}")
private String jwtSecret;
@Value("${jwt.accessExpired}")
private long accessExpired;
@Value("${jwt.refreshExpired}")
private long refreshExpired;
public void signup(MemberDto.JoinForm memberDto){
authService.signup(memberDto);
}
// 1. 로그인
public HashMap<String,String> signin(MemberDto.LoginForm memberDto) {
String memberEmail = memberDto.getMemberEmail();
Member member = memberRepository.findMemberByMemberEmail(memberEmail);
if(member == null) throw new UsernameNotFoundException(memberEmail);
if (!memberDto.getMemberPassword().equals(member.getMemberPassword())) throw new BadCredentialsException("");
HashMap<String,String> tokens = new HashMap<>();
String accessToken = jwtUtil.createAccessJwt(memberEmail,jwtSecret,accessExpired);
String refreshToken = jwtUtil.createRefreshJwt(memberEmail,jwtSecret,refreshExpired);
tokens.put("accessToken",accessToken);
tokens.put("refreshToken",refreshToken);
LoginTokenDto loginTokenDto = new LoginTokenDto(refreshToken,accessToken,refreshExpired/1000);
loginTokenRepository.save(loginTokenDto);
return tokens;
}
// 2. access token 재발급
public String refreshAccessToken(String refreshToken){
Optional<LoginTokenDto> loginTokenDto = loginTokenRepository.findById(refreshToken);
if(loginTokenDto.isEmpty()){
throw new AccountExpiredException("토큰이 만료되어 ");
}
String memberEmail = jwtUtil.getMemberEmail(refreshToken,jwtSecret);
String accessToken = loginTokenDto.get().getAccessToken();
String newAccessToken = null;
try{
if(!jwtUtil.isExpired(accessToken,jwtSecret)){
loginTokenRepository.delete(loginTokenDto.get());
long remainingTime = jwtUtil.getExpired(accessToken,jwtSecret).getTime() - System.currentTimeMillis();
LoginTokenDto blackList = new LoginTokenDto(memberEmail,accessToken,remainingTime/1000);
loginTokenRepository.save(blackList);
}
}catch(Exception e){
newAccessToken = jwtUtil.createAccessJwt(memberEmail,jwtSecret,accessExpired);
loginTokenDto.get().updateAccessToken(newAccessToken);
loginTokenRepository.save(loginTokenDto.get());
}
if(newAccessToken == null){
throw new AccountExpiredException("잘못된 접근으로 ");
}
return newAccessToken;
}
// 3. 로그 아웃
public void signOut(String accessToken){
Optional<LoginTokenDto> loginTokenDto = loginTokenRepository.findLoginTokenDtoByAccessToken(accessToken);
loginTokenRepository.delete(loginTokenDto.get());
}
}
JPA사용을 위해 만든 LoginTokenRepository를 주입하고 refresh token은 만료 기간을 길게 할 것이므로 새로운 환경 변수를 등록하여 사용했다.
이제부터 로그인을 하면 access, refresh token 두 가지를 발급할 것이기 때문에 로그인 메소드부터 추가한 내용을 보겠다.
1. 로그인
HashMap<String,String> tokens = new HashMap<>();
String accessToken = jwtUtil.createAccessJwt(memberEmail,jwtSecret,accessExpired);
String refreshToken = jwtUtil.createRefreshJwt(memberEmail,jwtSecret,refreshExpired);
tokens.put("accessToken",accessToken);
tokens.put("refreshToken",refreshToken);
LoginTokenDto loginTokenDto = new LoginTokenDto(refreshToken,accessToken,refreshExpired/1000);
loginTokenRepository.save(loginTokenDto);
응답할 tokens에 refreshToken을 생성하여 추가해 주었고, 이를 Redis에 저장하기 위해 loginTokenDto를 생성하여 save() 메소드로 저장했다.
refreshExpired/1000 을 한 이유는 초 단위의 변수이기 때문에 1000을 나눠 밀리초로 바꿔주었다.
2. access token 재발급
public String refreshAccessToken(String refreshToken){
Optional<LoginTokenDto> loginTokenDto = loginTokenRepository.findById(refreshToken);
if(loginTokenDto.isEmpty()){
throw new AccountExpiredException("토큰이 만료되어 ");
}
String memberEmail = jwtUtil.getMemberEmail(refreshToken,jwtSecret);
String accessToken = loginTokenDto.get().getAccessToken();
String newAccessToken = null;
try{
if(!jwtUtil.isExpired(accessToken,jwtSecret)){
loginTokenRepository.delete(loginTokenDto.get());
long remainingTime = jwtUtil.getExpired(accessToken,jwtSecret).getTime() - System.currentTimeMillis();
LoginTokenDto blackList = new LoginTokenDto(accessToken, null,remainingTime/1000);
loginTokenRepository.save(blackList);
}
}catch(Exception e){
newAccessToken = jwtUtil.createAccessJwt(memberEmail,jwtSecret,accessExpired);
loginTokenDto.get().updateAccessToken(newAccessToken);
loginTokenRepository.save(loginTokenDto.get());
}
if(newAccessToken == null){
throw new AccountExpiredException("잘못된 접근으로 ");
}
return newAccessToken;
}
JPA를 사용하여 loginTokenRepository.findById()로 refreshToken을 key값으로 저장된 객체를 불러온다.
실제로는 refreshToken:{token value} 이렇게 저장되는데 JPA는 앞의 refreshToken: 은 추가해주지 않아도 되었다.
해당 토큰 객체가 없다면 refreshToken을 key로 하는 객체가 없다는 것이고 로그인 유지 시간이 만료된 것이니 Exception을 발생시켜 처리하였다.
이후에는 jwtUtil에서 acces token의 유효기간이 남았는지 확인하고 남았다면 불순한 의도로 접근했다고 판단하여 로그아웃 처리를 하였는데, 이게 common-authentication 모듈에 있어서 직접 try/catch를 사용하여 처리하였다. 만료되었는지 확인하는 과정에서 만료가 되어 Exception이 발생한다면 새로운 access token을 발급하고 해당 사용자의 access token을 Redis에 업데이트 해주었다.
access token이 유효한데 재발급한 경우 해당 사용자의 토큰 정보를 삭제하고, access token의 경우, 잔여 시간을 계산하여 블랙리스트로 등록하였다. 이때는 refresh token을 key값으로 하지 않고, access token을 key값으로 하여 토큰 검증 과정에서 요청 헤더에 넣은 토큰이 검색이 된다면 서비스를 사용하지 못하게 할 것이다.
3. 로그아웃
public void signOut(String accessToken){
Optional<LoginTokenDto> loginTokenDto = loginTokenRepository.findLoginTokenDtoByAccessToken(accessToken);
loginTokenRepository.delete(loginTokenDto.get());
}
@Indexed로 등록했기 때문에 access token으로 검색이 가능하다. 해당 사용자의 토큰 정보를 모두 제거한다.
Redis에 여러번 접근하면 Transaction처리를 해줘야 하지 않나 생각을 했다.
하지만 코드 구조 상 Redis에서 읽기만 하고 에러가 나서 토큰 재발급을 못해도 다시 요청하면 그만이고, 갱신한 토큰 정보를 업데이트 하기 전에 에러가 발생해도 사용자 입장에선 토큰을 받아보지 못했으니 다시 요청하면 계좌 이체에서 필요한 Transaction처리가 여기서도 필요하다는 생각이 들지 않아 application에서 처리했다.
이제 완성한 기능을 컨트롤러에 붙여보았다.
controller/AuthController
@GetMapping("/refresh")
public Response<?> refreshToken(HttpServletRequest request){
String refreshToken = request.getHeader(HttpHeaders.AUTHORIZATION).split(" ")[1];
HashMap<String,String> accessToken = new HashMap<>();
accessToken.put("accessToken",authApplication.refreshAccessToken(refreshToken));
return success(accessToken);
}
@GetMapping("/signout")
public Response<?> signOut(HttpServletRequest request){
String accessToken = request.getHeader(HttpHeaders.AUTHORIZATION).split(" ")[1];
authApplication.signOut(accessToken);
return success();
}
refresh token을 추출하여 넘겨주었고 refreshAccessToken을 호출하여 재발급한 후 응답을 넘겨주었다.
로그아웃 같은 경우 해당 메소드에서 에러가 발생하지 않는 한 무조건 성공한 응답을 보내므로 호출만 하고 바로 성공한 응답을 넘겨주었다.
마지막으로 Filter에 access token이 black list로 등록되었는지 확인하는 과정만 거치면 성공이다.
config/JwtFilter (common-authentication)
@Slf4j
@AllArgsConstructor
public class JwtFilter extends OncePerRequestFilter {
private String jwtSecret;
private JwtUtil jwtUtil;
private StringRedisTemplate redisTemplate;
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
final String authorization = request.getHeader(HttpHeaders.AUTHORIZATION);
...
String token = authorization.split(" ")[1];
final HashOperations<String, Object, Object> valueOperations = redisTemplate.opsForHash();
try{
if(!jwtUtil.isExpired(token,jwtSecret)){
String memberEmail = jwtUtil.getMemberEmail(token,jwtSecret);
String blackListToken = (String)valueOperations.get("refreshToken:"+token,"id");
if(blackListToken != null && blackListToken.equals(token)){
throw new AccountExpiredException("");
}
UsernamePasswordAuthenticationToken authenticationToken =
new UsernamePasswordAuthenticationToken(memberEmail,null, List.of(new SimpleGrantedAuthority("USER")));
...
}
}
추가된 코드만 살펴보면 redisTemplate를 주입해 주었다. 이걸 주입하려면 build.gradle에 의존성을 추가해 주어야 한다. auth-api에 추가한 Redis 의존성을 추가해주어야 한다.
그리고 JPA까지 추가하면 따로 모듈화를 시키는 장점이 조금씩 없어진다고 생각하여 여기서는 이전에 말한 redisTemplate을 사용해 보았다. Config 파일에서 Bean을 등록하지 않아도 자동으로 등록해 주어 위처럼 바로 사용할 수 있다.
당연히 생성자 주입으로 redisTemplate를 추가해 주었으니 AuthenticationConfig 파일에서 Filter추가하는 부분에도 redisTemplate를 넣어줘야 한다. 이건 간단하니 각자 추가해서 사용하면 된다.
그리고 내가 객체로 저장하여 key-HashMap 구조로 저장이 되었기 때문에 HashOperations<String,Object,Object>로 데이터를 읽어와야만 한다. 만약 redisTemplate.opsForValue() 같은걸로 읽어와서 왜 안되지? 라고 하면 내가 저장할 때 어떻게 저장했는지 다시 확인해 보고 알맞은 것을 사용하면 된다.
저렇게 읽어온 데이터를 토큰의 유효성 검증을 거친 후 객체가 있고 블랙리스트에 등록된 토큰과 같다면 토큰이 만료되었다는 예외 처리를 해주었다. AccountExpiredException()을 날려주었지만 request객체에는 ExpiredJwtException과 같은 종류의 에러를 넣어주었다. 마지막으로 AuthenticationConfig에서 refresh API를 열어줘야 한다.
지금 black list도 id에 토큰이 저장되어 열어주지 않으면 토큰 검증 단계에서 refresh token을 blakc list로 인식하고 block처리를 한다.
이제 내가 구상한대로 잘 흘러가는지 테스트만 해보면 된다.
테스트는 다음과 같이 진행했다.
- 로그인 하고 토큰 2개 발급 받기
- refresh token으로 access token 유효기간 남았을 때 재발급 요청 보내고 access token 재발급이 되지 않는지 확인하기
- 처음 로그인 할 때 받았던 access token으로 Spring Security Filter에 걸리는 요청 헤더에 넣어 요청을 보낸 후 예외처리가 잘 되는지 확인하기
- refresh token으로 access token 유효기간 지난 후 재발급 요청 보내서 재발급 되는지 확인하기
1. 로그인 하고 토큰 2개 발급받기

2. refresh token으로 access token 유효기간 남았을 때 재발급 요청 보내기

3. 처음 로그인 할 때 받았던 access token으로 Spring Security Filter에 걸리는 요청 헤더에 넣어 요청을 보낸 후 예외처리가 잘 되는지 확인하기

4. refresh token으로 access token 유효기간 지난 후 재발급 요청 보내서 재발급 되는지 확인하기


4번 같은 경우는 access token의 유효기간을 1초로 설정하고 했다.
나머지 테스트들도 테스트 할 땐 유효기간을 적절하게 맞춰서 계속 기다리지 않고 테스트하길 추천한다.
위의 과정들이 잘 처리되었나 확인하니
1~3번에서 사용한 refresh token의 경우 토큰 탈취로 간주하여 refresh token은 삭제되었고 access token의 경우 블랙 리스트로 등록되었다.
4번의 경우 refresh token값이 등록되었다.

4번의 경우 access token이 재발급 된 것으로 잘 들어갔는지도 확인해보면

키 값을 토큰으로 해서 눈 아프지만 재발급이 잘 되어 업데이트도 잘 된 것 같다.
나는 최대한 Redis에 저장되는 개수를 줄이려고 토큰 값을 key값으로 두었지만 더 좋은 방법이 있는지도 고민해 봐야 할 것 같다.
그리고 이미 로그인 해서 토큰 발급 받았을 때 새로 로그인 요청이 들어온다면 이것도 불순한 의도로 접근했다고 생각하고 처리를 해야할 것 같다. 그럼 LoginTokenDto를 수정해야 하는지 그런 고민도 해봐야겠다.
그리고 현재 refresh token을 id로 하는 객체와 access token을 id로 하는 객체를 같이 사용하여 혼동이 있다. 또한 Filter처리에서도 위와 같은 요소 때문에 requestHttpMatchers에 refresh API를 열어주었다.
조금 이따 다시 리팩토링을 해야겠다.
'Java > Spring boot 프로젝트' 카테고리의 다른 글
[리팩토링 1] nGrinder삽질 + JMeter 부하 테스트 해보기 - 게시판 만들기(14) (0) | 2023.08.09 |
---|---|
@EntityListener로 생성일, 수정일 자동으로 넣기 + 프록시 활용(연관관계 Insert) - 게시판 만들기(13) (0) | 2023.06.29 |
Spring Security + JWT 적용하기(2) - 게시판 만들기(11) (0) | 2023.04.06 |
Spring Security + JWT 적용하기(1) - 게시판 만들기(10) (0) | 2023.04.06 |
Entity 모듈 분리(멀티 모듈 프로젝트) - 게시판 만들기(9) (0) | 2023.03.22 |