Spring Security, OAuth2, JWT 등 계정 관리를 위한 커스텀 기능 테스트용
- OAuth2를 연동한 인가 과정
- Spring Security @PreAuthorize 권한 관리
- 각 기능별 테스트 케이스 작성
todos
- JWT access token 발급
- JWT refresh token 발급
- Redis에 refresh token 저장
- token 유효성 검사
- Redis에서 refresh token 추출
- Secrutiy User 정의 → OAuth2Authentication
- controller까지 인증 과정 테스트
- aws 환경에 redis 올리기
- access 만료 시, refresh 갱신
- cookie 발급
- 예외 추상화 수준 맞추기
- getGitbubIdtoken → github id 추출
- redis 자체에 만료기간 설정
- token 삭제 메서드 (회원 로그아웃)
- (트레이드 오프) accessToken, refreshToken을 K, V로 했을 때
• refreshToken에 아무 값도 저장하지 않아도 됨(만료여부만)
• accessToken에서 모든 정보 추출 가능 - token payload 접근자 메서드 공개화 여부 판단
- JwtTokenProviderImpl 클래스 Thread-safe 체크
- network 작업 exception
- addFilterBefore 순서 확인
- Controller에서 에러났을 때 JwtExceptionFilter에서 처리되는 이유 알아보기
- access와 refresh에 userId, githubId, role 저장
- redisProperties -> yaml에서 주입
- 로그아웃 유저 필터링
- LoginUserIdMethodArgument -> Filter할 거면 나중에 제거
- RoleType 직렬화/역직렬화
- Filter Test case 작성
- JwtProvider Test case 작성
- Redis Test case 작성
- Cookie Test case 작성
- 프로젝트에 맞게 디렉토리 구조 수정
- refreshToken 정보 추가 -> UUID 대신 generate 메서드 등록
- Enum Converter 적용
- 로그인 유저 재로그인시 -> 막을 건지, 재발급할 건지
- Redis -> key:value = userId:refreshToken
• 같은 pk로 불일치 refresh 요청이 들어온 경우 -> 탈취판단
• 해당 userPk 필드 제거 -> 재로그인 요청
• 이미 탈취당한 access token은..어쩔 수 없을 듯 - CustomUserDetails Deserializer를 위해 필드 조정
1️⃣ JwtTokenProvider
public interface JwtTokenProvider {
/**
* 헤더로부터 토큰을 추출하고 유효성을 검사하는 메서드
* @param authHeader : 메시지 헤더
* @return String : 토큰
* @throws AuthErrorException : 토큰이 유효하지 않을 경우
*/
String resolveToken(String authHeader) throws AuthErrorException;
/**
* 사용자 정보 기반으로 액세스 토큰을 생성하는 메서드
* @param user UserDto : 사용자 정보
* @return String : 토큰
*/
String generateAccessToken(JwtUserInfo user);
/**
* 사용자 정보 기반으로 리프레시 토큰을 생성하는 메서드
* @param user UserDto : 사용자 정보
* @return String : 토큰
*/
String generateRefreshToken(JwtUserInfo user);
/**
* token으로 부터 사용자 정보를 추출하는 메서드
* @param token String : 토큰
* @return UserAuthenticateReq : 사용자 정보
* @throws AuthErrorException : 토큰이 유효하지 않을 경우
*/
JwtUserInfo getUserInfoFromToken(String token) throws AuthErrorException;
/**
* 토큰으로 부터 유저 아이디를 추출하는 메서드
* @param token String : 토큰
* @return Long : 유저 아이디
* @throws AuthErrorException : 토큰이 유효하지 않을 경우
*/
Long getUserIdFromToken(String token) throws AuthErrorException;
/**
* 토큰의 만료일을 추출하는 메서드
* @param token String : 토큰
* @return Date : 만료일
* @throws AuthErrorException : 토큰이 유효하지 않을 경우
*/
Date getExpiryDate(String token) throws AuthErrorException;
}
- 토큰을 발급하고, 토큰의 유효성을 검사하는 메서드를 제공합니다.
- userId를 제외하고는 모두 JwtUserInfo 객체를 통해 토큰을 생성합니다.
- JwtUserInfo : userId, githubId, role을 가지고 있습니다.
- 어차피 Authentication으로 로그인 유저 정보를 가져올 수 있으므로, githubId와 role 접근자 메서드는 제외하였습니다.
- 특별한 사유가 없다면
Service 계층
에서 사용하도록 설계하였습니다.
2️⃣ CookieUtil
@Component
public class CookieUtil {
/**
* request에서 cookieName에 해당하는 쿠키를 찾아서 반환합니다.
* @param request HttpServletRequest : 쿠키를 찾을 request
* @param cookieName String : 찾을 쿠키의 이름
* @return Optional<Cookie> : 쿠키가 존재하면 해당 쿠키를, 존재하지 않으면 Optional.empty()를 반환합니다.
*/
public Optional<Cookie> getCookie(HttpServletRequest request, String cookieName);
/**
* cookieName에 해당하는 쿠키를 생성합니다.
* @param cookieName String : 생성할 쿠키의 이름
* @param value String : 생성할 쿠키의 값
* @param maxAge int : 생성할 쿠키의 만료 시간
* @return ResponseCookie : 생성된 쿠키
*/
public ResponseCookie createCookie(String cookieName, String value, int maxAge);
/**
* cookieName에 해당하는 쿠키를 제거합니다.
* @param request HttpServletRequest : 쿠키를 제거할 request
* @param response HttpServletResponse : 쿠키를 제거할 response
* @param cookieName String : 제거할 쿠키의 이름
* @return Optional<ResponseCookie> : 쿠키가 존재하면 제거된 쿠키를, 존재하지 않으면 Optional.empty()를 반환합니다.
*/
public Optional<ResponseCookie> deleteCookie(HttpServletRequest request, HttpServletResponse response, String cookieName);
}
- 쿠키를 생성하고, 쿠키를 제거하는 메서드를 제공합니다.
- 특별한 사유가 없다면,
Controller
계층에서 사용하도록 설계하였습니다.
3️⃣ RefreshTokenService
public interface RefreshTokenService {
/**
* access token을 받아서 refresh token을 발행
* @param accessToken : JwtUserInfo
* @return String : Refresh Token
* @throws AuthErrorException : 토큰이 유효하지 않을 경우
*/
String issueRefreshToken(String accessToken) throws AuthErrorException;
/**
* refresh token을 받아서 refresh token을 재발행
* @param requestRefreshToken : String
* @return RefreshToken
* @throws AuthErrorException : 토큰이 유효하지 않을 경우(REFRESH_TOKEN_EXPIRED), 토큰이 탈취당한 경우(REFRESH_TOKEN_MISMATCH)
*/
RefreshToken refresh(String requestRefreshToken) throws AuthErrorException;
/**
* access token 으로 refresh token을 찾아서 제거 (로그아웃)
* @param requestRefreshToken : String
*/
void logout(String requestRefreshToken);
}
- refresh token 발급, 재발급, 제거 메서드를 제공합니다.
- RTR(Refresh Token Rotation) 방식을 통해 보안성을 높였습니다.
- redis에선 refresh token을 key:value=userId:refreshToken으로 저장합니다.
- refresh token으로 access token을 재발급 받으면, refresh token도 재발급됩니다.
- 재발급된 refresh token 유효기간은 연장되지 않습니다. (기본 7일)
- refresh token이 탈취된 경우(특정 userId로 잘못된 refresh token이 요청된 경우) 해당 userId의 refresh token은 모두 삭제되며, 해당 userId로 재로그인을 요청해야 합니다.
- 탈취된 refresh token으로 이미 재발급된 access token에 대해서는 어쩔 수 없이 유효기간이 만료될 때까지 기다려야 합니다. (현재로썬 보완할 방도를 찾지 못했습니다.)
- 특별한 사유가 없다면,
Service 계층
에서 사용하도록 설계하였습니다.
4️⃣ ForbiddenTokenService
@Slf4j
@RequiredArgsConstructor(access = AccessLevel.PROTECTED)
@Component
public class ForbiddenTokenService {
private final ForbiddenTokenRepository forbiddenTokenRepository;
private final JwtTokenProvider jwtTokenProvider;
/**
* 토큰을 블랙 리스트에 등록합니다.
* @param accessToken : 블랙 리스트에 등록할 토큰
* @param userId : 블랙 리스트에 등록할 사용자 ID
*/
public void register(String accessToken, Long userId);
/**
* 토큰이 블랙 리스트에 등록되어 있는지 확인합니다.
* @param accessToken : 확인할 토큰
* @return : 블랙 리스트에 등록되어 있으면 true, 아니면 false
*/
public boolean isForbidden(String accessToken);
}
- 토큰을 블랙 리스트에 등록하고, 토큰이 블랙 리스트에 등록되어 있는지 확인하는 메서드를 제공합니다.
- 특별한 사유가 없다면,
Service 계층
에서 사용하도록 설계하였습니다.
아래 순서를 따르지 않을 시, 정상 작동 여부를 보장하지 않습니다.
1️⃣ 로그인
🟡 Controller
@PostMapping("/login")
public ResponseEntity<?> loginTest(@RequestBody UserAuthReq dto) {
Map<String, String> tokens = userAuthService.login(dto); // 로그인 유저 정보로 accessToken, refreshToken 발급
ResponseCookie cookie = cookieUtil.createCookie(REFRESH_TOKEN.getValue(), tokens.get(REFRESH_TOKEN.getValue()), 60 * 60 * 24 * 7); // refreshToken 쿠키 생성
return ResponseEntity.noContent()
.header(HttpHeaders.SET_COOKIE, cookie.toString()) // refreshToken 쿠키를 response header에 추가
.header(ACCESS_TOKEN.getValue(), tokens.get(ACCESS_TOKEN.getValue())) // accessToken을 response header에 추가
.build();
}
🟡 Service
public Map<String, String> login(UserAuthReq dto) {
User user = userSearchService.findById(dto.getId()); // 로그인 유저 정보 조회
JwtUserInfo jwtUserInfo = JwtUserInfo.from(user); // 로그인 유저 정보로 JwtUserInfo 객체 생성
String accessToken = jwtTokenProvider.generateAccessToken(jwtUserInfo); // accessToken 발급
String refreshToken = refreshTokenService.issueRefreshToken(accessToken); // refreshToken 발급
return Map.of(ACCESS_TOKEN.getValue(), accessToken, REFRESH_TOKEN.getValue(), refreshToken); // accessToken, refreshToken 반환
}
2️⃣ 로그아웃
🟡 Controller
@GetMapping("/logout")
public ResponseEntity<?> logoutTest(@CookieValue("refreshToken") String refreshToken, HttpServletRequest request, HttpServletResponse response) {
userAuthService.logout(request.getHeader(AUTH_HEADER.getValue()), refreshToken); // 로그아웃을 위해 accessToken, refreshToken 모두 필요
ResponseCookie cookie = cookieUtil.deleteCookie(request, response, REFRESH_TOKEN.getValue()) // refreshToken 쿠키 제거
.orElseThrow(() -> new IllegalArgumentException("존재하지 않는 쿠키입니다.")); // TODO : 공통 예외로 변경
return ResponseEntity.noContent().header(HttpHeaders.SET_COOKIE, cookie.toString()).build(); // 값이 비어있는 쿠키를 response header에 추가
}
🟡 Service
public void logout(String authHeader, String requestRefreshToken) {
String accessToken = jwtTokenProvider.resolveToken(authHeader); // 헤더로부터 accessToken 추출
Long userId = jwtTokenProvider.getUserIdFromToken(accessToken); // accessToken으로 userId 추출
refreshTokenService.logout(requestRefreshToken); // redis에서 refreshToken 제거
forbiddenTokenService.register(accessToken, userId); // accessToken을 블랙 리스트에 등록 (남아있는 유효 시간만큼)
}
3️⃣ refreshToken 재발급
🟡 Controller
@GetMapping("/refresh")
public ResponseEntity<?> refreshTest(@CookieValue("refreshToken") String refreshToken) {
if (refreshToken == null) {
throw new IllegalArgumentException("존재하지 않는 쿠키입니다."); // TODO : 공통 예외로 변경
}
Map<String, String> tokens = userAuthService.refresh(refreshToken); // refreshToken으로 accessToken, refreshToken 재발급
ResponseCookie cookie = cookieUtil.createCookie(REFRESH_TOKEN.getValue(), tokens.get(REFRESH_TOKEN.getValue()), 60 * 60 * 24 * 7); // refreshToken 쿠키 생성
// 클라이언트 측에 쿠키가 남아 있어도 서버 측에서 만료되면 사라질 것이므로 쿠키의 만료 시간은 7일로 고정해도 무방합니다.
return ResponseEntity.noContent()
.header(HttpHeaders.SET_COOKIE, cookie.toString()) // refreshToken 쿠키를 response header에 추가
.header(ACCESS_TOKEN.getValue(), tokens.get(ACCESS_TOKEN.getValue())) // accessToken을 response header에 추가
.build();
}
🟡 Service
public Map<String, String> refresh(String requestRefreshToken) {
RefreshToken refreshToken = refreshTokenService.refresh(requestRefreshToken); // refreshToken으로 새로 발급한 RefreshToken 객체 반환 (refreshToken가 탈취되었다면 예외 발생)
Long userId = refreshToken.getUserId(); // userId 추출
JwtUserInfo dto = JwtUserInfo.from(userSearchService.findById(userId)); // userId로 JwtUserInfo 객체 생성
String accessToken = jwtTokenProvider.generateAccessToken(dto); // accessToken 재발급
return Map.of(ACCESS_TOKEN.getValue(), accessToken, REFRESH_TOKEN.getValue(), refreshToken.getToken()); // accessToken, refreshToken 반환
}
4️⃣ Authentication 객체 받기
@GetMapping("/authentication")
public ResponseEntity<?> authenticationTest(@AuthenticationPrincipal CustomUserDetails securityUser, Authentication authentication) {
log.info("type: {}", authentication.getPrincipal()); // io.oopy.coding.common.security.CustomUserDetails
JwtUserInfo user = securityUser.toJwtUserInfo();
log.info("user: {}", user); // user: JwtUserInfo(id=2, githubId=0, role=ROLE_ADMIN)
return ResponseEntity.ok(user);
}
- Authentication으로 받고 authentication.getPrincipal() : CustomUserDetails
- 혹은, @AuthenticationPrincipal로 받고 securityUser.toJwtUserInfo() : JwtUserInfo
1️⃣ Exception Handler
🟡 JwtExceptionFilter
- AuthErrorException을 상속받은 에러들을 처리합니다.
- AuthErrorException에 대해서는 언제나 같은 에러 포맷으로 응답합니다.
- AuthErrorException으로 처리되지 않은 에러에 대해서는 500 INTERNAL_SERVER_ERROR로 응답합니다.
🟡 AccessDeniedHandler
/**
* 유저 정보는 있으나 자원에 접근할 수 있는 권한이 없는 경우 : 403 Forbidden
*/
@Component
@Slf4j
public class JwtAccessDeniedHandler implements AccessDeniedHandler {
@Override
public void handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException accessDeniedException) throws IOException, ServletException {
log.error("handle error: {}", accessDeniedException.getMessage());
response.sendError(HttpServletResponse.SC_FORBIDDEN);
}
}
- 인증은 됐으나 인가될 수 없는 자원에 접근할 때 403 Forbidden으로 응답합니다.
- hasRole() : 특정 권한을 가지고 있는지 확인합니다.
- hasAnyRole() : 여러 권한 중 하나라도 가지고 있는지 확인합니다.
- hasAuthority() : 특정 권한을 가지고 있는지 확인합니다.
- hasAnyAuthority() : 여러 권한 중 하나라도 가지고 있는지 확인합니다.
- hasIpAddress() : 특정 IP 주소를 가지고 있는지 확인합니다.
- access() : SpEL 표현식을 이용해서 권한을 확인합니다.
- permitAll() : 모든 사용자가 접근할 수 있습니다.
- denyAll() : 모든 사용자의 접근을 거부합니다.
- anonymous() : 익명 사용자만 접근할 수 있습니다.
- rememberMe() : remember-me로 인증된 사용자만 접근할 수 있습니다.
- authenticated() : 인증된 사용자만 접근할 수 있습니다.
🟡 AuthenticationEntryPoint
/**
* 유저 정보가 없는 경우 : 401 Unauthorized
*/
@Component
@Slf4j
public class JwtAuthenticationEntryPoint implements AuthenticationEntryPoint {
@Override
public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException, ServletException {
log.error("commence error: {}", authException.getMessage());
response.sendError(HttpServletResponse.SC_UNAUTHORIZED);
}
}
- 인증이 안 된 사용자가 자원에 접근하려 할 때 401 Unauthorized으로 응답합니다.
2️⃣ AuthErrorCode
@RequiredArgsConstructor(access = AccessLevel.PRIVATE)
@Getter
public enum AuthErrorCode implements ErrorCode {
// 400 BAD_REQUEST: 클라이언트의 요청이 부적절 할 경우
INVALID_HEADER(BAD_REQUEST, "유효하지 않은 헤더 포맷입니다"),
EMPTY_ACCESS_TOKEN(BAD_REQUEST, "토큰이 비어있습니다"),
// 401 UNAUTHORIZED: 인증되지 않은 사용자
TAMPERED_ACCESS_TOKEN(UNAUTHORIZED, "서명이 조작된 토큰입니다"),
EXPIRED_ACCESS_TOKEN(UNAUTHORIZED, "사용기간이 만료된 토큰입니다"),
MALFORMED_ACCESS_TOKEN(UNAUTHORIZED, "비정상적인 토큰입니다"),
WRONG_JWT_TOKEN(UNAUTHORIZED, "잘못된 토큰입니다(default)"),
REFRESH_TOKEN_NOT_FOUND(UNAUTHORIZED, "없거나 삭제된 리프래시 토큰입니다."),
USER_NOT_FOUND(UNAUTHORIZED, "존재하지 않는 유저입니다"),
// 403 FORBIDDEN: 인증된 클라이언트가 권한이 없는 자원에 접근
FORBIDDEN_ACCESS_TOKEN(FORBIDDEN, "해당 토큰에는 엑세스 권한이 없습니다"),
MISMATCHED_REFRESH_TOKEN(FORBIDDEN, "리프레시 토큰의 유저 정보가 일치하지 않습니다");
private final HttpStatus httpStatus;
private final String message;
@Override public String getMessage() {
return this.message;
}
@Override public String getName() {
return this.name();
}
}
JwtExceptionFilter에서 처리하는 에러 응답 포맷은 다음과 같습니다.
// status : 400 BAD_REQUEST
{
"code": "INVALID_HEADER",
"message": "유효하지 않은 헤더 포맷입니다",
}
1️⃣ JwtUserInfo
@Builder
public record JwtUserInfo(
Long id,
Integer githubId,
RoleType role
) {
public static JwtUserInfo of(Long id, Integer githubId, RoleType role) {
return new JwtUserInfo(id, githubId, role);
}
public static JwtUserInfo from(User user) {
return new JwtUserInfo(user.getId(), user.getGithubId(), user.getRole());
}
@Override public String toString() {
return String.format("JwtUserInfo(id=%d, githubId=%d, role=%s)", id, githubId, role);
}
}
- userId, githubId, role을 가지고 있습니다.
- jwt util에서 사용하기 위한 record입니다.
2️⃣ Cache
@Slf4j
@Service
@RequiredArgsConstructor
public class UserDetailServiceImpl implements UserDetailsService {
private final UserRepository userRepository;
@Override
@Cacheable(value = "securityUser", key = "#userId", unless = "#result == null")
public UserDetails loadUserByUsername(String userId) throws UsernameNotFoundException {
log.debug("loadUserByUsername userId : {}", userId);
return userRepository.findById(Long.parseLong(userId))
.map(CustomUserDetails::of)
.orElseThrow(() -> new UsernameNotFoundException("사용자를 찾을 수 없습니다."));
}
}
- userId를 key로 하여 CustomUserDetails를 캐싱합니다.
- 캐싱된 CustomUserDetails는 30초 동안 유지됩니다.
3️⃣ 여러가지 enum 클래스
🟡 RoleType
@RequiredArgsConstructor
public enum RoleType implements LegacyCommonType {
ADMIN("1", "ROLE_ADMIN"),
USER("2", "ROLE_USER");
private final String code;
private final String role;
private static final Map<String, RoleType> stringToEnum =
Stream.of(values()).collect(toMap(Object::toString, e -> e));
@JsonValue
public String getRole() { return role; }
@Override
public String getCode() { return code; }
@JsonCreator
public static RoleType fromString(String role) {
return stringToEnum.get(role.toUpperCase());
}
@Override public String toString() { return role; }
}
- 유저의 권한을 정의합니다.
- Spring Application에서는 role 이름을 사용하고, DB에는 code를 저장합니다.
🟡 AuthConstants
@Getter
public enum AuthConstants {
AUTH_HEADER("Authorization"), TOKEN_TYPE("Bearer "),
ACCESS_TOKEN("accessToken"), REFRESH_TOKEN("refreshToken");
private String value;
AuthConstants(String value) {
this.value = value;
}
@Override public String toString() {
return String.format("AuthConstants(value=%s)", this.value);
}
}
- 헤더에 담길 토큰의 키, 토큰의 타입, 토큰의 이름을 정의합니다.
4️⃣ Converter Util
사용 방법만 아셔도 됩니다.
상세한 로직이 궁금하신 분들은 마지막에 토글바 열어보시면 됩니다.
🟡 Enum 타입 클래스
@RequiredArgsConstructor
public enum RoleType implements LegacyCommonType {
ADMIN("1", "ROLE_ADMIN"),
USER("2", "ROLE_USER");
private final String code;
private final String role;
...
@Override
public String getCode() { return code; }
...
}
- DB에 저장되는 값은 code입니다. (DB 공간 절약)
- DB 조회 시, code를 role로 변환하여 반환합니다.
- (해당 방식을 적용할)Enum 타입 클래스는 반드시 LegacyCommonType 인터페이스를 상속받아야 합니다.
🟡 해당 Enum 타입 클래스 Converter 정의
@Convert
public class RoleTypeConverter extends AbstractLegacyEnumAttributeConverter<RoleType> {
private static final String ENUM_NAME = "유저권한";
public RoleTypeConverter() {
super(RoleType.class, false, ENUM_NAME);
}
}
- AbstractLegacyEnumAttributeConverter를 상속받아 구현합니다.
- AbstractLegacyEnumAttributeConverter의 생성자에는 Enum 타입 클래스, nullable 여부, Enum 타입 클래스의 설명적 이름을 전달합니다.
- 설명적 이름은 예외 발생 시, 예외 메시지에 사용됩니다.
- nullable이 false이면, 변환할 값이 null로 들어왔을 때 예외를 발생시킵니다.
- AbstractLegacyEnumAttributeConverter를 상속받은 클래스는 반드시 @Convert 어노테이션을 붙여야 합니다.
🟡 필드 정의
@Convert(converter = RoleTypeConverter.class)
@Column(name = "role", nullable = false)
private RoleType role;
- @Convert 어노테이션에는 해당 Enum 타입 클래스 Converter를 전달합니다.
구현 내용
🟡 Enum Class에서 상속받는 LegacyCommonType 인터페이스
public interface LegacyCommonType {
/**
* Legacy Super System 공통 코드를 반환한다.
* @return String 공통 코드
*/
String getCode();
}
- DB에 저장되어야 할 code를 반환하는 메서드를 정의합니다.
🟡 enum↔String 상호변환 LegacyEnumValueConvertUtils
/**
* {@link LegacyCommonType} enum을 String과 상호 변환하는 유틸리티 클래스
*/
@NoArgsConstructor(access = AccessLevel.PRIVATE)
public class LegacyEnumValueConvertUtils {
public static <T extends Enum<T> & LegacyCommonType> T ofLegacyCode(Class<T> enumClass, String code) {
if (!StringUtils.hasText(code)) return null;
return EnumSet.allOf(enumClass).stream()
.filter(e -> e.getCode().equals(code))
.findFirst()
.orElseThrow(() -> new IllegalArgumentException(
String.format("enum=[%s], code=[%s]가 존재하지 않습니다.", enumClass.getName(), code))); // TODO : 공통 예외로 변경
}
public static <T extends Enum<T> & LegacyCommonType> String toLegacyCode(T enumValue) {
if (enumValue == null) return "";
return enumValue.getCode();
}
}
- enum↔String 상호변환을 위한 유틸리티 클래스입니다.
- enum↔String 상호변환을 위한 메서드는 다음과 같습니다.
ofLegacyCode(Class<T> enumClass, String code)
: code를 전달받아 enum으로 변환합니다.toLegacyCode(T enumValue)
: enum을 전달받아 code로 변환합니다.
- 반드시
LegacyCommonType
인터페이스를 상속받은 enum 타입 클래스를 전달받아야 합니다.
🟡 AttributeConverter 구현 클래스
@Getter
public class AbstractLegacyEnumAttributeConverter<E extends Enum<E> & LegacyCommonType> implements AttributeConverter<E, String> {
/**
* 대상 Enum 클래스 {@link Class} 객체
*/
private final Class<E> targetEnumClass;
/**
* <code>nullable = false</code>면, 변환할 값이 null로 들어왔을 때 예외를 발생시킨다.<br/>
* <code>nullable = true</code>면, 변환할 값이 null로 들어왔을 때 예외 없이 실행하며,<br/>
* legacy code로 변환 시엔 빈 문자열("")로 변환한다.
*/
private final boolean nullable;
/**
* <code>nullable = false</code>일 때 출력할 오류 메시지에서 enum에 대한 설명을 위해 Enum의 설명적 이름을 받는다.
*/
private final String enumName;
public AbstractLegacyEnumAttributeConverter(Class<E> targetEnumClass, boolean nullable, String enumName) {
this.targetEnumClass = targetEnumClass;
this.nullable = nullable;
this.enumName = enumName;
}
@Override
public String convertToDatabaseColumn(E attribute) {
if (!nullable && attribute == null) {
throw new IllegalArgumentException(String.format("%s을(를) null로 변환할 수 없습니다.", enumName));
}
return LegacyEnumValueConvertUtils.toLegacyCode(attribute);
}
@Override
public E convertToEntityAttribute(String dbData) {
if (!nullable && !StringUtils.hasText(dbData)) {
throw new IllegalArgumentException(String.format("%s(이)가 DB에 null 혹은 Empty로(%s) 저장되어 있습니다.", enumName, dbData));
}
return LegacyEnumValueConvertUtils.ofLegacyCode(targetEnumClass, dbData);
}
}
- AttributeConverter를 상속받아 구현합니다.
- 디렉토리 구조 어떻게 해야 더 깔끔할 지 고민 중
- CustomUserDetails Deserializer 이슈 -> 역직렬화 안 되는 거 전부
@JsonIgnore
처리해버림..^^