EPguy

[Spring] Spring Security + JWT + 카카오 로그인 구현 본문

개발/Java

[Spring] Spring Security + JWT + 카카오 로그인 구현

EPguy 2023. 10. 5. 09:50

1. 확인 사항

📄 참고사항

해당 포스트는 서버에서 카카오 Access Token을 생성하는게 아닌,

클라이언트에서 카카오 Access Token 을 생성해서 서버에 전달하는 방식입니다.
그래서 이 포스트는 Oauth2 라이브러리를 사용하지 않습니다.

📄 로그인 흐름

1. 클라이언트에서 카카오로그인으로 생성된 AccessToken 을 서버로 전송.
2. 서버는 받은 AccessToken 을 가지고 카카오 API를 사용하여 유저정보를 가져옴.
3. 유저정보를 DB에 저장하고, AccessToken과 Refresh Token을 생성함.
4. Refresh Token 은 유저 테이블에 저장시켜준 다음, 클라이언트에서 사용할 수 있도록 Cookie 에도 저장시켜줌(Json으로 줘도 되지만 여기선 쿠키에 저장시켰습니다.).
5. 서버에서 생성한 AcessToken을 클라이언트에 Json으로 전달함으로써 로그인 프로세스 끝.

6. 클라이언트는 AccessToken이 만료될 때 마다 RefreshToken으로 AccessToken을 새로 갱신받음.

📄 사용 기술

- Spring Boot 3
- Spring Security
- MyBatis
- JWT
- 카카오로그인

📄 dependencies (build.gradle)

    implementation 'org.springframework.boot:spring-boot-starter-security'
    implementation 'org.springframework.boot:spring-boot-starter-web'
    implementation 'org.mybatis.spring.boot:mybatis-spring-boot-starter:3.0.2'
    compileOnly 'org.projectlombok:lombok'
    runtimeOnly 'com.mysql:mysql-connector-j'
    annotationProcessor 'org.projectlombok:lombok'
    testImplementation 'org.springframework.boot:spring-boot-starter-test'
    testImplementation 'org.mybatis.spring.boot:mybatis-spring-boot-starter-test:3.0.2'
    testImplementation 'org.springframework.security:spring-security-test'
    implementation 'org.springframework.boot:spring-boot-starter-webflux'
    implementation 'io.jsonwebtoken:jjwt-api:0.11.5'
    runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.11.5'
    runtimeOnly    'io.jsonwebtoken:jjwt-jackson:0.11.5'

📄 application.properties

# 액세스토큰 만료시간(초)
# 리프레시토큰 만료시간(초)
# JWT 시크릿 키
jwt.access.token.expiration.seconds=3600
jwt.refresh.token.expiration.seconds=604800
jwt.token.secret-key=itsepguyitsepguyitsepgyitsitsits

# DB 및 Mapper 세팅
spring.datasource.url=jdbc:mysql://localhost:3306/duogo?useSSL=false&useUnicode=true&serverTimezone=Asia/Seoul
spring.datasource.username=root
spring.datasource.password=test
mybatis.mapper-locations=classpath:mapper/*.xml

📄 폴더 구조

├── auth
│   ├── config
│   ├── controller
│   ├── dto
│   ├── enums
│   ├── filter
│   ├── models
│   ├── service
│   └── utils
├── exception
├── user
│   ├── controller
│   ├── dto
│   ├── mapper
│   └── service

Github 소스 

https://github.com/EPguy/Spring_Security_JWT_kakao_login

 

GitHub - EPguy/Spring_Security_JWT_kakao_login: Spring Security + JWT + kakao login 예제 프로젝트

Spring Security + JWT + kakao login 예제 프로젝트. Contribute to EPguy/Spring_Security_JWT_kakao_login development by creating an account on GitHub.

github.com

2. Security 및 JWT 관련 설정

🔒 OauthController

com.epguy.auth.controller

로그인 Controller

@RestController
@RequiredArgsConstructor
public class OauthController {
    private final OauthService oauthService;

    @PostMapping("/login/oauth/{provider}")
    public OauthResponseDto login(@PathVariable String provider, @RequestBody OauthRequestDto oauthRequestDto,
                                  HttpServletResponse response) {
        OauthResponseDto oauthResponseDto = new OauthResponseDto();
        switch (provider) {
            case "kakao":
                String accessToken = oauthService.loginWithKakao(oauthRequestDto.getAccessToken(), response);
                oauthResponseDto.setAccessToken(accessToken);
        }
        return oauthResponseDto;
    }

    // 리프레시 토큰으로 액세스토큰 재발급 받는 로직
    @PostMapping("/token/refresh")
    public RefreshTokenResponseDto tokenRefresh(HttpServletRequest request) {
        RefreshTokenResponseDto refreshTokenResponseDto = new RefreshTokenResponseDto();
        Cookie[] list = request.getCookies();
        if(list == null) {
            throw new CustomException(ErrorCode.INVALID_REFRESH_TOKEN);
        }

        Cookie refreshTokenCookie = Arrays.stream(list).filter(cookie -> cookie.getName().equals("refresh_token")).collect(Collectors.toList()).get(0);

        if(refreshTokenCookie == null) {
            throw new CustomException(ErrorCode.INVALID_REFRESH_TOKEN);
        }
        String accessToken = oauthService.refreshAccessToken(refreshTokenCookie.getValue());
        refreshTokenResponseDto.setAccessToken(accessToken);
        return refreshTokenResponseDto;
    }
}

🔒 SecurityConfig

com.epguy.auth.config

Spring Security 설정

@RequiredArgsConstructor
@Configuration
public class SecurityConfig {
    private final JwtTokenService jwtTokenService;
    private final UserService userService;

    @Bean
    public AuthenticationManager authenticationManager(
            final AuthenticationConfiguration authenticationConfiguration) throws Exception {
        return authenticationConfiguration.getAuthenticationManager();
    }

    @Bean
    public SecurityFilterChain configure(final HttpSecurity http) throws Exception {
        return http.cors(withDefaults())
                .csrf((csrf) -> csrf.disable())
                .authorizeHttpRequests((authorize) -> authorize
                        .requestMatchers("/login/**", "/token/refresh").permitAll()
                        .requestMatchers("/user/**").hasAuthority(UserRole.USER.getRole())
                        .anyRequest().authenticated())
                .sessionManagement((session) -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
                .formLogin(httpSecurityFormLoginConfigurer -> httpSecurityFormLoginConfigurer.disable()) // 로그인 폼 미사용
                .httpBasic(httpSecurityHttpBasicConfigurer -> httpSecurityHttpBasicConfigurer.disable()) // http basic 미사용
                .addFilterBefore(new JwtFilter(jwtTokenService, userService), UsernamePasswordAuthenticationFilter.class) // JWT Filter 추가
                .addFilterBefore(new ExceptionHandlerFilter(), JwtFilter.class) // JwtFilter 에서 CustomException 사용하기 위해 추가
                .build();
    }

    @Bean
    public WebSecurityCustomizer webSecurityCustomizer(){
        // 아래 url은 filter 에서 제외
        return web ->
            web.ignoring()
                    .requestMatchers("/login/**", "/token/refresh");
    }
}

🔒 JwtTokenService

com.epguy.auth.service

JWT 토큰 생성, 조회 관련 서비스

@Service
public class JwtTokenService implements InitializingBean {
    private long accessTokenExpirationInSeconds;
    private long refreshTokenExpirationInSeconds;
    private final String secretKey;
    private static Key key;

    public JwtTokenService(
            @Value("${jwt.access.token.expiration.seconds}") long accessTokenExpirationInSeconds,
            @Value("${jwt.refresh.token.expiration.seconds}") long refreshTokenExpirationInSeconds,
            @Value("${jwt.token.secret-key}") String secretKey
    ) {
        this.accessTokenExpirationInSeconds = accessTokenExpirationInSeconds * 1000;
        this.refreshTokenExpirationInSeconds = refreshTokenExpirationInSeconds * 1000;
        this.secretKey = secretKey;
    }

    // 빈 주입받은 후 실행되는 메소드
    @Override
    public void afterPropertiesSet() {
        this.key = getKeyFromBase64EncodedKey(encodeBase64SecretKey(secretKey));
    }


    public String createAccessToken(String payload){
        return createToken(payload, accessTokenExpirationInSeconds);
    }

    public String createRefreshToken(){
        byte[] array = new byte[7];
        new Random().nextBytes(array);
        String generatedString = new String(array, StandardCharsets.UTF_8);
        return createToken(generatedString, refreshTokenExpirationInSeconds);
    }

    public String createToken(String payload, long expireLength){
        Claims claims = Jwts.claims().setSubject(payload);
        Date now = new Date();
        Date validity = new Date(now.getTime() + expireLength);
        return Jwts.builder()
                .setClaims(claims)
                .setIssuedAt(now)
                .setExpiration(validity)
                .signWith(key, SignatureAlgorithm.HS256)
                .compact();
    }

    public String getPayload(String token){
        try{
            return Jwts.parserBuilder()
                    .setSigningKey(key)
                    .build()
                    .parseClaimsJws(token)
                    .getBody()
                    .getSubject();
        }catch (ExpiredJwtException e){
            return e.getClaims().getSubject();
        }catch (JwtException e){
            throw new CustomException(ErrorCode.UNAUTHORIZED);
        }
    }

    public boolean validateToken(String token){
        try{
            Jws<Claims> claimsJws = Jwts.parserBuilder()
                    .setSigningKey(key)
                    .build()
                    .parseClaimsJws(token);
            return !claimsJws.getBody().getExpiration().before(new Date());
        }catch (JwtException | IllegalArgumentException exception){
            return false;
        }
    }

    private String encodeBase64SecretKey(String secretKey) {
        return Encoders.BASE64.encode(secretKey.getBytes(StandardCharsets.UTF_8));
    }

    private Key getKeyFromBase64EncodedKey(String base64EncodedSecretKey) {
        byte[] keyBytes = Decoders.BASE64.decode(base64EncodedSecretKey);

        Key key = Keys.hmacShaKeyFor(keyBytes);

        return key;
    }

    //클라이언트 쿠키에 리프레시토큰 저장 시켜주는 메소드
    public void addRefreshTokenToCookie(String refreshToken, HttpServletResponse response) {
        Long age = refreshTokenExpirationInSeconds;
        Cookie cookie = new Cookie("refresh_token",refreshToken);
        cookie.setPath("/");
        cookie.setMaxAge(age.intValue());
        cookie.setHttpOnly(true);
        response.addCookie(cookie);
    }
}

🔒 JwtFilter

com.epguy.auth.filter

Api 요청 올 때마다 액세스토큰이 유효한지 확인 후 SecurityContext에 유저정보 저장하는 Filter. 

@RequiredArgsConstructor
public class JwtFilter extends GenericFilterBean {
    public static final String AUTHORIZATION_HEADER = "Authorization";
    private final JwtTokenService jwtTokenService;
    private final UserService userService;

    // 요 Filter 에서 액세스토큰이 유효한지 확인 후 SecurityContext에 계정정보 저장
    @Override
    public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain filterChain) throws IOException, ServletException {
        HttpServletRequest httpServletRequest = (HttpServletRequest) servletRequest;
        logger.info("[JwtFilter] : " + httpServletRequest.getRequestURL().toString());
        String jwt = resolveToken(httpServletRequest);

        if (StringUtils.hasText(jwt) && jwtTokenService.validateToken(jwt)) {
            Long userId = Long.valueOf(jwtTokenService.getPayload(jwt)); // 토큰 Payload에 있는 userId 가져오기
            UserDto user = userService.findById(userId); // userId로
            if(user == null) {
                throw new CustomException(ErrorCode.NOT_EXIST_USER);
            }
            UserDetails userDetails = UserPrincipal.create(user);
            UsernamePasswordAuthenticationToken authentication = new UsernamePasswordAuthenticationToken(userDetails, null, userDetails.getAuthorities());
            SecurityContextHolder.getContext().setAuthentication(authentication);
        } else {
            throw new CustomException(ErrorCode.INVALID_ACCESS_TOKEN);
        }

        filterChain.doFilter(servletRequest, servletResponse);
    }

    // Header에서 Access Token 가져오기
    private String resolveToken(HttpServletRequest request) {
        String bearerToken = request.getHeader(AUTHORIZATION_HEADER);

        if (StringUtils.hasText(bearerToken) && bearerToken.startsWith("Bearer ")) {
            return bearerToken.substring(7);
        }

        return null;
    }
}

🔒 UserPrincipal

com.epguy.auth.models

SecurityContext 에 유저정보 저장할 때 사용하기 위한 유저모델

@Getter
@AllArgsConstructor
// SecurityContext authentication에 저장될 유저정보
public class UserPrincipal implements UserDetails {

    private long id;
    private String email;
    private String password;
    private Collection<? extends GrantedAuthority> authorities;
    @Setter
    private Map<String, Object> attributes;

    public static UserPrincipal create(UserDto user) {
        List<GrantedAuthority> authorities =
                Collections.singletonList(new SimpleGrantedAuthority(UserRole.USER.getRole()));
        return new UserPrincipal(
                user.getId(),
                user.getEmail(),
                "",
                authorities,
                null
        );
    }

    @Override
    public boolean isAccountNonExpired() {
        return true;
    }

    @Override
    public boolean isAccountNonLocked() {
        return true;
    }

    @Override
    public boolean isCredentialsNonExpired() {
        return true;
    }

    @Override
    public boolean isEnabled() {
        return true;
    }

    @Override
    public String getUsername() {
        return email;
    }

}

🔒 UserRole

com.epguy.auth.enums

유저 권한 Enum

public enum UserRole {
    USER("user"),
    ADMIN("admin");

    private final String role;

    UserRole(String role) {
        this.role = role;
    }

    public String getRole() {
        return role;
    }
}

🔒 SecurityUtil

com.epguy.auth.utils

SecurityContext Authentication 에 있는 유저정보를 가져와서 userId 만 추출하는 Util

public class SecurityUtil {
    private SecurityUtil() {}

    public static long getCurrentUserId() {

        final Authentication authentication = SecurityContextHolder.getContext().getAuthentication();

        if (authentication == null) {
            throw new CustomException(ErrorCode.UNAUTHORIZED);
        }

        long userId;
        if (authentication.getPrincipal() instanceof UserPrincipal userPrincipal) {
            userId = userPrincipal.getId();
        } else {
            throw new CustomException(ErrorCode.BAD_REQUEST);
        }

        return userId;
    }
}

🔒 OauthRequestDto

com.epguy.auth.dto

로그인 Api RequestBody

@Getter
public class OauthRequestDto {
    private String accessToken;
}

🔒 OauthResponseDto

com.epguy.auth.dto

로그인 Api ResponseBody

@Getter
@Setter
public class OauthResponseDto {
    private String accessToken;
}

🔒 RefreshTokenResponseDto

com.epguy.auth.dto

액세스토큰 갱신 Api ResposnseBody

@Getter
@Setter
public class RefreshTokenResponseDto {
    private String accessToken;
}

3. 카카오 로그인 구현

💬 KakaoOauthService

com.epguy.auth.service

카카오 AccessToken으로 유저정보 가져와서 DB에 저장하는 서비스

@RequiredArgsConstructor
@Service
public class KakaoOauthService {
    private final UserService userService;

    // 카카오Api 호출해서 AccessToken으로 유저정보 가져오기
    public Map<String, Object> getUserAttributesByToken(String accessToken){
        return WebClient.create()
                .get()
                .uri("https://kapi.kakao.com/v2/user/me")
                .headers(httpHeaders -> httpHeaders.setBearerAuth(accessToken))
                .retrieve()
                .bodyToMono(new ParameterizedTypeReference<Map<String, Object>>() {})
                .block();
    }

    // 카카오API에서 가져온 유저정보를 DB에 저장
    public UserDto getUserProfileByToken(String accessToken){
        Map<String, Object> userAttributesByToken = getUserAttributesByToken(accessToken);
        KakaoInfoDto kakaoInfoDto = new KakaoInfoDto(userAttributesByToken);
        UserDto userDto = UserDto.builder()
                .id(kakaoInfoDto.getId())
                .email(kakaoInfoDto.getEmail())
                .platform("kakao")
                .build();
        if(userService.findById(userDto.getId()) != null) {
            userService.update(userDto);
        } else {
            userService.save(userDto);
        }
        return userDto;
    }
}

💬 OauthService

com.epguy.auth.service

토큰생성,갱신 등 로그인 관련 로직 서비스

@RequiredArgsConstructor
@Service
public class OauthService {
    private final UserService userService;
    private final JwtTokenService jwtTokenService;
    private final KakaoOauthService kakaoOauthService;

    //카카오 로그인
    public String loginWithKakao(String accessToken, HttpServletResponse response) {
        UserDto userDto = kakaoOauthService.getUserProfileByToken(accessToken);
        return getTokens(userDto.getId(), response);
    }

    //액세스토큰, 리프레시토큰 생성
    public String getTokens(Long id, HttpServletResponse response) {
        final String accessToken = jwtTokenService.createAccessToken(id.toString());
        final String refreshToken = jwtTokenService.createRefreshToken();

        UserDto userDto = userService.findById(id);
        userDto.setRefreshToken(refreshToken);
        userService.updateRefreshToken(userDto);

        jwtTokenService.addRefreshTokenToCookie(refreshToken, response);
        return accessToken;
    }

    // 리프레시 토큰으로 액세스토큰 새로 갱신
    public String refreshAccessToken(String refreshToken) {
        UserDto userDto = userService.findByRefreshToken(refreshToken);
        if(userDto == null) {
            throw new CustomException(ErrorCode.INVALID_REFRESH_TOKEN);
        }

        if(!jwtTokenService.validateToken(refreshToken)) {
            throw new CustomException(ErrorCode.INVALID_REFRESH_TOKEN);
        }

        return jwtTokenService.createAccessToken(userDto.getId().toString());
    }
}

💬 KakaoInfoDto

com.epguy.auth.dto

카카오Api로 정보 가져와서 담을 Dto

@Getter
@AllArgsConstructor
public class KakaoInfoDto {
    private Long id;
    private String email;

    public KakaoInfoDto(Map<String, Object> attributes) {
        this.id = Long.valueOf(attributes.get("id").toString());
        this.email = attributes.get("email") != null ? attributes.get("email").toString() : "";
    }
}

4. 유저 API 구현

😊 UserController

com.epguy.user.controller

유저 정보 조회하는 API 컨트롤러

@RestController
@RequiredArgsConstructor
@RequestMapping("/user")
public class UserController {
    private final UserService userService;

    // 유저정보 조회 API
    @GetMapping("/info")
    public UserDto info() {
        final long userId = SecurityUtil.getCurrentUserId();
        UserDto userDto = userService.findById(userId);
        if(userDto == null) {
            throw new CustomException(ErrorCode.NOT_EXIST_USER);
        }
        return userDto;
    }
}

😊 UserDto

com.epguy.user.dto

유저 Dto

@Getter
@Setter
@Builder
@AllArgsConstructor
public class UserDto {
    private Long id;
    private String email;
    private String platform;
    private String refreshToken;
}

😊 UserMapper

com.epguy.user.mapper

유저 DB Mapper

@Mapper
public interface UserMapper {
    void save(UserDto userDto);
    UserDto findById(Long id);
    UserDto findByRefreshToken(String refreshToken);
    void update(UserDto userDto);
    void updateRefreshToken(UserDto userDto);
}

😊 UserService

com.epguy.user.service

유저 DB 서비스

@RequiredArgsConstructor
@Service
public class UserService {
    private final UserMapper userMapper;
    public void save(UserDto userDto) {
        userMapper.save(userDto);
    }

    public UserDto findById(Long id) {
        return userMapper.findById(id);
    }
    public UserDto findByRefreshToken(String refreshToken) {
        return userMapper.findByRefreshToken(refreshToken);
    }

    public void update(UserDto userDto) {
        userMapper.update(userDto);
    }

    public void updateRefreshToken(UserDto userDto) {
        userMapper.updateRefreshToken(userDto);
    }
}

😊 UserMapper.xml

resources/mapper

유저 DB XML

<mapper namespace="com.lol.duogo.user.mapper.UserMapper">
    <select id="findById" resultType="com.lol.duogo.user.dto.UserDto" parameterType="java.lang.Long">
        SELECT * FROM tb_user WHERE id=#{id}
    </select>

    <select id="findByRefreshToken" resultType="com.lol.duogo.user.dto.UserDto" parameterType="java.lang.String">
        SELECT * FROM tb_user WHERE refresh_token = #{refreshToken}
    </select>

    <insert id="save" parameterType="com.lol.duogo.user.dto.UserDto">
        INSERT tb_user (id, email, platform, refresh_token) VALUES (#{id}, #{email}, #{platform}, #{refreshToken})
    </insert>

    <update id="update" parameterType="com.lol.duogo.user.dto.UserDto">
        UPDATE tb_user SET email = #{email}, platform = #{platform} WHERE id = #{id}
    </update>

    <update id="updateRefreshToken" parameterType="com.lol.duogo.user.dto.UserDto">
        UPDATE tb_user SET refresh_token = #{refreshToken} WHERE id = #{id}
    </update>
</mapper>

5. Exception 핸들링

GlobalExceptionHandler

com.epguy.exception

@ControllerAdvice
public class GlobalExceptionHandler {
    private static final Logger logger = LoggerFactory.getLogger(GlobalExceptionHandler.class);
    @ExceptionHandler(CustomException.class)
    public ResponseEntity<ErrorResponse> handleCustomException(CustomException ex) {
        logger.error("[CustomException] errCode : " + ex.getErrorCode());
        logger.error("[CustomException] errMsg : " + ex.getMessage());
        return new ResponseEntity(
                new ErrorResponse(ex.getMessage()),
                ex.getErrorCode().getHttpStatus()
        );
    }

    @ExceptionHandler(RuntimeException.class)
    public ResponseEntity<ErrorResponse> handleRuntimeException(RuntimeException ex) {
        logger.error("[RuntimeException] errMsg : " + ex.getMessage());
        return new ResponseEntity(
                new ErrorResponse(ex.getMessage()),
                HttpStatus.INTERNAL_SERVER_ERROR
        );
    }

    @ExceptionHandler(Exception.class)
    public ResponseEntity<ErrorResponse> handleException(RuntimeException ex) {
        logger.error("[Exception] errMsg : " + ex.getMessage());
        return new ResponseEntity(
                new ErrorResponse(ex.getMessage()),
                HttpStatus.INTERNAL_SERVER_ERROR
        );
    }
}

ExceptionHandlerFilter

com.epguy.exception

JwtFilter 내부에서 CustomException 사용 가능하도록 하기위한 Filter.

public class ExceptionHandlerFilter extends OncePerRequestFilter {

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response,
                                    FilterChain filterChain) throws ServletException, IOException {

        try {
            filterChain.doFilter(request, response);
        } catch (CustomException ex) {
            setErrorResponse(ex.getErrorCode().getHttpStatus(), response, ex);
        } catch (Exception ex) {
            setErrorResponse(HttpStatus.INTERNAL_SERVER_ERROR, response, ex);
        }
    }

    public void setErrorResponse(HttpStatus status, HttpServletResponse response, Throwable ex) throws IOException {
        logger.error("[ExceptionHandlerFilter] errMsg : " + ex.getMessage());

        response.setStatus(status.value());
        response.setContentType("application/json; charset=UTF-8");

        response.getWriter().write(
                new ErrorResponse(ex.getMessage())
                        .convertToJson()
        );
    }
}

ErrorResponse

com.epguy.exception

@Getter
@AllArgsConstructor
public class ErrorResponse {
    private static final ObjectMapper objectMapper = new ObjectMapper();
    private String errMsg;

    public String convertToJson() throws JsonProcessingException {
        return objectMapper.writeValueAsString(this);
    }
}

ErrorCode

com.epguy.exception

public enum ErrorCode {
    UNAUTHORIZED("인증되지않은 요청입니다.", HttpStatus.UNAUTHORIZED),
    INVALID_ACCESS_TOKEN("유효하지않은 액세스 토큰입니다.", HttpStatus.UNAUTHORIZED),
    INVALID_REFRESH_TOKEN("유효하지않은 리프레시 토큰입니다.", HttpStatus.UNAUTHORIZED),
    BAD_REQUEST("잘못된 요청입니다.", HttpStatus.BAD_REQUEST),
    NOT_EXIST_USER("존재하지 않는 유저입니다.", HttpStatus.UNAUTHORIZED);

    private final String message;
    private final HttpStatus httpStatus;

    ErrorCode(String message, HttpStatus httpStatus) {
        this.message = message;
        this.httpStatus = httpStatus;
    }

    public String getMessage() {
        return message;
    }

    public HttpStatus getHttpStatus() {
        return httpStatus;
    }
}

CustomException

com.epguy.exception

public class CustomException extends RuntimeException {

    private final ErrorCode errorCode;

    public CustomException(ErrorCode errorCode) {
        super(errorCode.getMessage());
        this.errorCode = errorCode;
    }

    public ErrorCode getErrorCode() {
        return errorCode;
    }
}