// 에러 식별을 위한 열겨형 클래스
enum class ErrorCode {
DUPLICATED_EMAIL_ADDRESS,
DUPLICATED_NICKNAME,
MEMBER_NOT_FOUND,
REFRESH_TOKEN_EXPIRED,
INVALID_ACCESS_TOKEN,
LOGIN_FAIL
}
// 반환 클래스 타입이 두가지임을 해결하기 위한 인터페이스
interface ResponseDto {}
// 예외 발생시 반환할 객체
data class ErrorResponseDto(
val errorCode: String,
val message: String?
) : ResponseDto
// LoginController
@RestController
@RequestMapping("/api/login")
class LoginApiController(
private val loginService: LoginService
) {
@GetMapping("/success")
fun loginSuccess(@RequestParam access: String, @RequestParam refresh: String): ResponseEntity<ResponseDto?> {
loginService.saveTokenAtCache(access, refresh)
return ResponseEntity.ok(LoginResponseDto(access, refresh))
}
@GetMapping("/fail")
fun loginFail(): ResponseEntity<ResponseDto?>{
return ResponseEntity(ErrorResponseDto(
ErrorCode.LOGIN_FAIL.name, "Login Fail"
), HttpStatus.UNAUTHORIZED)
}
@PostMapping("/auth/reissue")
fun reissueAuthenticationToken(@RequestBody requestBody: ReissueAuthTokenRequestDto): ResponseEntity<ResponseDto?> {
return try {
val result = loginService.reissueAccessToken(requestBody.accessToken, requestBody.refreshToken)
ResponseEntity.ok(ReissueAuthTokenResponseDto(result))
} catch (e: ExpiredRefreshTokenException) {
ResponseEntity(
ErrorResponseDto(
ErrorCode.REFRESH_TOKEN_EXPIRED.name, e.message
), HttpStatus.FORBIDDEN
)
} catch (e: InvalidAccessTokenException) {
ResponseEntity(
ErrorResponseDto(
ErrorCode.INVALID_ACCESS_TOKEN.name, e.message
), HttpStatus.FORBIDDEN
)
} catch (e: Exception) {
ResponseEntity.internalServerError().body(null)
}
}
}
// LoginService
@Service
class LoginService(
private val jwtTokenProvider: JwtTokenProvider,
private val redisTemplate: RedisTemplate<String, String>
) {
private val hashOps = redisTemplate.opsForHash<String, String>()
fun saveTokenAtCache(accessToken: String, refreshToken: String) {
val key = "auth:login:${jwtTokenProvider.getEmailAddress(accessToken)}"
val tokenData = hashMapOf(
"accessToken" to accessToken,
"refreshToken" to refreshToken
)
hashOps.putAll(key, tokenData)
redisTemplate.expire(key, 14, TimeUnit.DAYS)
}
fun validateAccessToken(accessToken: String, refreshToken: String, key: String) {
val storedAccessToken = hashOps.get(key, "accessToken")
val storedRefreshToken = hashOps.get(key, "refreshToken")
if (!jwtTokenProvider.validateToken(storedRefreshToken ?: "")) {
throw ExpiredRefreshTokenException()
}
if (accessToken != storedAccessToken) {
throw InvalidAccessTokenException()
}
}
fun reissueAccessToken(accessToken: String, refreshToken: String): String? {
val key = "auth:login:${jwtTokenProvider.getEmailAddress(accessToken)}"
validateAccessToken(accessToken, refreshToken, key)
val authentication = jwtTokenProvider.getAuthentication(accessToken)
val newAccessToken = jwtTokenProvider.createToken(authentication, TokenType.ACCESS_TOKEN)
hashOps.put(key, "accessToken", newAccessToken)
return newAccessToken
}
}
기존 로직은 서비스 레이어에서 커스텀한 예외가 발생하면 컨트롤러 레이어에서 잡아 try-catch 를 통해 성공 응답과 에러 응답을 보냈다. 이로 인해 생기는 문제점은 아래와 같다.
@RestControllerAdvice
어노테이션은 @ExceptionHandler
, @ModelAttribute
, @InitBinder
가 적용된 메서드들에 AOP를 적용해 Controller 레이어에 적용하기 위해 고안된 어노테이션이다
즉, 모든 @RestController
에 대한, 전역적으로 발생할 수 있는 예외를 잡아서 처리할 수 있다.
자매품으로 @ControllerAdvice
가 존재하는데 @Controller
에 적용된다.
@RestControllerAdvice
를 사용하면 컨트롤러 내에서는 에러처리를 할 필요가 없어지기 때문에 자연스럽게 ResponseEntity<T>
의 제네릭 타입은 성공 응답 객체 한 개이므로 1번 문제가 해결된다. 마찬가지로 에러 처리를 @RestControllerAdvice
로 전역적으로 처리하므로 2번 문제도 해결된다.
3번 문제는 새로운 CustomException
을 정의하고 에러 내용을 구체적으로 정의한ErrorCode
열거형 객체를 예외의 필드로 넘겨 하나의 예외만으로 모든 커스텀 에러를 처리하게 구현하여 해결하였다.
// GlobalExceptionHandler - @RestControllerAdvice
@RestControllerAdvice
class GlobalExceptionHandler {
@ExceptionHandler(value = [CustomException::class])
fun handlingCustomException(ex: CustomException): ResponseEntity<ErrorResponseDto> {
val errorCode: ErrorCode = ex.errorCode
val errorDto = ErrorResponseDto(errorCode = errorCode.name, message = errorCode.message)
return ResponseEntity(errorDto, errorCode.status)
}
}
// CustomException
class CustomException(
val errorCode: ErrorCode
) : RuntimeException()
// ErrorCode - 내용 구체화
enum class ErrorCode(val status: HttpStatus, val message: String) {
// 400 - Bad Request
INVALID_FILE_EXTENSION(HttpStatus.BAD_REQUEST, "업로드한 파일의 확장자가 올바르지 않습니다"),
// 401 - Unauthorized
LOGIN_FAIL(HttpStatus.UNAUTHORIZED, "로그인에 실패했습니다"),
// 403 - Forbidden
REFRESH_TOKEN_EXPIRED(HttpStatus.FORBIDDEN, "리프레시 토큰이 만료되었습니다"),
INVALID_ACCESS_TOKEN(HttpStatus.FORBIDDEN, "유효하지 않은 엑세스 토큰입니다"),
// 404 - Not Found
MEMBER_NOT_FOUND(HttpStatus.NOT_FOUND,"유저 정보를 찾을 수 없습니다"),
// 409 - Conflict
DUPLICATED_EMAIL_ADDRESS(HttpStatus.CONFLICT,"이미 가입된 이메일 주소입니다"),
DUPLICATED_NICKNAME(HttpStatus.CONFLICT,"이미 존재하는 별명입니다"),
// 500 - Internal Server Error
FILE_UPLOAD_FAIL(HttpStatus.INTERNAL_SERVER_ERROR,"파일 업로드에 실패했습니다")
}
// LoginService
@Service
class LoginService(
private val jwtTokenProvider: JwtTokenProvider,
private val redisTemplate: RedisTemplate<String, String>
) {
private val hashOps = redisTemplate.opsForHash<String, String>()
fun saveTokenAtCache(accessToken: String, refreshToken: String) {
val key = "auth:login:${jwtTokenProvider.getEmailAddress(accessToken)}"
val tokenData = hashMapOf(
"accessToken" to accessToken,
"refreshToken" to refreshToken
)
hashOps.putAll(key, tokenData)
redisTemplate.expire(key, 14, TimeUnit.DAYS)
}
fun validateAccessToken(accessToken: String, refreshToken: String, key: String) {
val storedAccessToken = hashOps.get(key, "accessToken")
val storedRefreshToken = hashOps.get(key, "refreshToken")
if (!jwtTokenProvider.validateToken(storedRefreshToken ?: "")) {
throw CustomException(ErrorCode.REFRESH_TOKEN_EXPIRED)
}
if (accessToken != storedAccessToken) {
throw CustomException(ErrorCode.INVALID_ACCESS_TOKEN)
}
}
fun reissueAccessToken(accessToken: String, refreshToken: String): String {
val key = "auth:login:${jwtTokenProvider.getEmailAddress(accessToken)}"
validateAccessToken(accessToken, refreshToken, key)
val authentication = jwtTokenProvider.getAuthentication(accessToken)
val newAccessToken = jwtTokenProvider.createToken(authentication, TokenType.ACCESS_TOKEN)
hashOps.put(key, "accessToken", newAccessToken)
return newAccessToken
}
}
// LoginApiController
@RestController
@RequestMapping("/api/login")
class LoginApiController(
private val loginService: LoginService
) {
@GetMapping("/success")
fun loginSuccess(@RequestParam access: String, @RequestParam refresh: String): ResponseEntity<LoginResponseDto> {
loginService.saveTokenAtCache(access, refresh)
return ResponseEntity(LoginResponseDto(access, refresh), HttpStatus.OK)
}
@GetMapping("/fail")
fun loginFail(): ResponseEntity<ErrorResponseDto> {
val errorCode = ErrorCode.LOGIN_FAIL
return ResponseEntity(
ErrorResponseDto(
errorCode.name, errorCode.message
), errorCode.status
)
}
@PostMapping("/auth/reissue")
fun reissueAuthenticationToken(@RequestBody requestBody: ReissueAuthTokenRequestDto): ResponseEntity<ReissueAuthTokenResponseDto> {
val result = loginService.reissueAccessToken(requestBody.accessToken, requestBody.refreshToken)
return ResponseEntity(ReissueAuthTokenResponseDto(result), HttpStatus.OK)
}
}