RECENT POSTS

최근에 작성한 글

01 / 03

전체 글

전체 보기

[TIL]Spring Boot 숙련(1)-Spring Security & JWT 인증

 Spring Security를 이용한 인증(Authentication)과 인가(Authorization), JWT 기반 로그인, Validation에 대해 공부했다. 개인 프로젝트 진행할 때에도 Filter와 Spring Security를 사용했었지만 정확한 개념 파악 없이 사용했었기에 이번 강의는 가려운 곳을 잘 긁어주는(?) 강의였다...


1. Bean을 수동으로 등록하는 방법

Spring에서는 일반적으로 @Component, @Service, @Repository 등을 사용하여 Bean을 자동 등록한다.

@Service
public class UserService {
}

하지만 직접 생성 방식을 제어하고 싶다면 @Configuration과 @Bean을 사용할 수 있다.

@Configuration
public class AppConfig {

    @Bean
    public PasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder();
    }

}

자동 등록은 대부분의 프로젝트에서 사용되며, 외부 라이브러리 객체나 직접 생성이 필요한 객체는 수동 등록을 사용한다.

등록된 Bean은 다른 클래스에서 주입을 받는다. 

가장 많이 사용하는 주입 방법은 생성자!

@Service
public class ProductService {

    private final UserService userService;

    public ProductService(UserService userService) {
        this.userService = userService;
    }
}

Lombok을 사용하면 @RequiredArgsConstructor 어노테이션을 사용한다.

@RequiredArgsConstructor
public class UserService {

    private final UserRepository userRepository;
    private final PasswordEncoder passwordEncoder;
    private final TokenProvider tokenProvider;
}

2. 같은 타입의 Bean이 여러 개라면?

Spring은 기본적으로 타입(Type) 으로 Bean을 찾는다.

예를 들어

@Component
public class Chicken implements Food {}

@Component
public class Pizza implements Food {}

두 Bean이 존재하면

@Autowired
private Food food;

처럼 작성했을 때

어떤 Bean을 주입해야 하는지 알 수 없어 오류가 발생한다.

이를 해결하기 위해

  • Bean 이름
  • @Qualifier
  • @Primary

를 사용할 수 있다.


3. 인증(Authentication)과 인가(Authorization)

인증과 인가는 비슷해 보이지만 목적이 다르다.

인증(Authentication)

사용자가 실제 누구인지 확인하는 과정

예시

  • 로그인
  • 비밀번호 확인
  • 지문 인식

인가(Authorization)

인증이 완료된 사용자가 특정 기능을 사용할 권한이 있는지 확인하는 과정

예시

  • 관리자 페이지 접근
  • 게시글 삭제
  • 회원 관리

즉,

인증은 "누구인지 확인"하고, 인가는 "무엇을 할 수 있는지 확인"하는 과정이다.


4. 쿠키와 세션

HTTP는 Stateless 프로토콜이기 때문에 요청마다 사용자 정보를 기억하지 못한다.

이를 해결하기 위해 사용하는 것이 Cookie와 Session이다.

Cookie

  • 브라우저에 저장
  • 요청 시 자동으로 서버에 전달

Session

  • 서버에 사용자 정보 저장
  • 브라우저에는 Session ID만 저장

즉,

Cookie → 클라이언트 저장

Session → 서버 저장

이라는 차이가 있다.


5. JWT(Json Web Token)

JWT는 사용자 정보를 토큰 안에 저장하는 인증 방식이다.

JWT는

  • Header
  • Payload
  • Signature

총 세 부분으로 구성된다.

로그인 성공 시

로그인 성공

↓

JWT 생성

↓

Cookie 저장

↓

이후 모든 요청에서 JWT 전달

세션과 달리 서버가 로그인 정보를 저장하지 않아도 되는 것이 가장 큰 장점이다.


6. JwtUtil

JWT를 쉽게 관리하기 위해 JwtUtil 클래스를 만들었다.

주요 기능은

  • JWT 생성
  • JWT 검증
  • Cookie 저장
  • Cookie에서 JWT 추출
  • JWT에서 사용자 정보 조회

등이다.

 

개인프로젝트에서는 JwtUtil을 TokenProvider 클래스로 구현했다. 다음은 개인프로젝트에서 발췌한 코드 일부이다.

1) JWT 생성

로그인이 성공하면 generatedTokenDTO()를 호출하여 Access Token과 Refresh Token을 생성한다.

 
public TokenDto generatedTokenDTO(User user)
 

Access Token에는

  • 사용자 이메일(Subject)
  • 사용자 권한(ROLE_USER)
  • 만료시간

등의 정보를 저장한다.

 
String accessToken = Jwts.builder()
        .setSubject(user.getEmail())
        .claim(AUTHORITIES_KEY, authorities)
        .setExpiration(accessTokenExpiresIn)
        .signWith(key, SignatureAlgorithm.HS256)
        .compact();
 

또한 Refresh Token도 함께 생성하여 일정 기간 동안 Access Token을 재발급받을 수 있도록 구현하였다.


2) Secret Key 생성

JWT는 위조를 방지하기 위해 Secret Key를 이용하여 서명(Signature)을 생성한다.

 
public TokenProvider(@Value("${jwt.secret}") String secretKey) {
    byte[] keyBytes = Decoders.BASE64.decode(secretKey);
    this.key = Keys.hmacShaKeyFor(keyBytes);
}
 

application.yml에 저장된 Secret Key를 읽어와 HMAC SHA256 알고리즘에서 사용할 Key 객체를 생성하였다.


3) JWT 검증

클라이언트가 요청을 보낼 때 전달한 JWT가 정상적인 토큰인지 검증한다.

 
tokenProvider.validateToken(jwt);
 

검증 과정에서는

  • 잘못된 서명
  • 만료된 토큰
  • 지원하지 않는 토큰
  • 잘못된 형식의 토큰

등을 모두 확인하도록 구현하였다.

 
catch (ExpiredJwtException e)
 

처럼 예외를 구분하여 각각 다른 메시지를 반환하도록 처리하였다.

이를 통해 인증 실패 원인을 명확하게 알 수 있도록 구현하였다.


4) JWT에서 사용자 정보 가져오기

JWT 검증이 완료되면 토큰 내부에 저장된 사용자 정보를 가져온다.

 
public String getAuthentication(String accessToken)
 

먼저 JWT를 Claim 객체로 변환한 뒤

 
Claims claims = parseClaims(accessToken);
 

Subject에 저장되어 있는 사용자 이메일을 반환한다.

 
return claims.getSubject();
 

이 이메일을 이용하여 데이터베이스에서 실제 회원 정보를 조회하고 인증을 완료하였다.


5) Claims란?

Claims는 JWT Payload에 저장되어 있는 정보이다.

이번 프로젝트에서는

  • 사용자 이메일(Subject)
  • 사용자 권한(Role)

등을 Claims에 저장하였다.

JWT를 검증한 뒤 Claims를 읽으면 로그인한 사용자의 정보를 쉽게 가져올 수 있다.


TokenProvider 동작 과정

로그인 성공

↓

Access Token 생성

↓

Refresh Token 생성

↓

Client에게 전달

↓

Client 요청

↓

JWT 검증

↓

Claims 추출

↓

사용자 이메일 조회

↓

인증 완료

 


7. 회원가입 구현

회원가입에서는

  • 아이디 중복 확인
  • 이메일 중복 확인
  • 비밀번호 암호화

과정을 수행하였다.

비밀번호는 그대로 저장하지 않고

String encodedPassword =
        passwordEncoder.encode(password);

처럼 암호화하여 저장한다.

로그인 시에는

passwordEncoder.matches(
    requestPassword,
    encodedPassword
);

를 통해 비밀번호를 비교한다.


8. JWT 로그인

로그인 과정은 다음과 같다.

아이디 조회

↓

비밀번호 검증

↓

JWT 생성

↓

Cookie 저장

↓

로그인 완료

이후에는 Cookie에 저장된 JWT를 이용하여 인증을 수행한다.


9. Filter

Filter는 Controller에 요청이 도달하기 전에 먼저 실행되는 객체이다. (Node.js의 미들웨어와 같은 개념)

Spring Boot에서는 공통적으로 수행해야 하는 작업을 Filter에서 처리한다.

대표적인 역할은 다음과 같다.

  • JWT 인증
  • 요청 로깅
  • 권한 검사
  • 공통 전처리

요청의 전체 흐름은 다음과 같다.

Client

↓

JwtFilter

↓

Controller

↓

Service

↓

Response
 

개인 프로젝트에서는 OncePerRequestFilter를 상속받아 JWT 인증 필터를 직접 구현하였다.

 
public class JwtFilter extends OncePerRequestFilter
 

OncePerRequestFilter를 사용하면 하나의 HTTP 요청당 필터가 한 번만 실행되므로 중복 실행을 방지할 수 있다.


1) Authorization Header에서 JWT 추출

클라이언트는 로그인 이후

Authorization: Bearer {JWT}
 

형태로 토큰을 전송한다.

Filter에서는 먼저 Header에서 JWT를 추출한다.

 
String bearerToken = request.getHeader(AUTHORIZATION_HEADER);

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

Bearer 문자열을 제외한 실제 JWT만 가져와 이후 검증에 사용하였다.


2) JWT 유효성 검사

Header에서 토큰을 가져왔다면 먼저 토큰이 정상적인지 검증한다.

 
tokenProvider.validateToken(jwt);
 

만약

  • 만료된 토큰
  • 위조된 토큰
  • 잘못된 토큰

이라면 AuthException을 발생시켜 요청을 중단하도록 구현하였다.


3) 사용자 정보 조회

JWT 검증이 완료되면 토큰 안에 저장된 사용자 이메일을 가져온다.

 
String authentication =
        tokenProvider.getAuthentication(jwt);
 

이후 Repository를 이용하여 실제 사용자를 조회하였다.

 
User user = userRepository.findByEmail(authentication)
 

토큰은 유효하지만 DB에 사용자가 존재하지 않는 경우에도 인증 실패로 처리하였다.


4) Request에 사용자 저장

인증이 완료되면

 
request.setAttribute("user", user);
 

를 통해 Request 객체에 사용자 정보를 저장하였다.

Controller에서는 Request에 저장된 User 객체를 이용하여 로그인한 사용자 정보를 쉽게 사용할 수 있도록 구현하였다.


5) 인증 실패 처리

토큰 검증 과정에서 예외가 발생하면

 
response.setStatus(HttpStatus.UNAUTHORIZED.value());
 

와 함께 JSON 형태의 에러 메시지를 반환하도록 구현하였다.

이렇게 Filter에서 인증을 처리하면 Controller까지 요청이 전달되지 않아 불필요한 비즈니스 로직 수행을 막을 수 있다.


10. Spring Security

Spring Security는 인증(Authentication)과 인가(Authorization)를 쉽게 구현할 수 있도록 도와주는 보안 프레임워크이다.

Spring Security를 적용하면 모든 요청은 Controller보다 먼저 Spring Security를 통과하게 된다.

Client

↓

Spring Security

↓

JwtFilter

↓

Controller

↓

Service
 

즉, 인증이 완료된 요청만 Controller까지 전달된다.


SecurityConfig 구성

이번 프로젝트에서는 SecurityFilterChain을 이용하여 Security를 설정하였다.

1) CSRF 비활성화

 
http.csrf(csrf -> csrf.disable());
 

JWT 기반 인증은 세션을 사용하지 않기 때문에 CSRF 보호 기능을 비활성화하였다.


2) Stateless 설정

 
.sessionManagement(session ->
    session.sessionCreationPolicy(
        SessionCreationPolicy.STATELESS
    )
)
 

JWT는 서버가 Session을 저장하지 않는 Stateless 방식이므로 Session 생성 자체를 하지 않도록 설정하였다.


3) Form Login과 HTTP Basic 비활성화

 
.formLogin(form -> form.disable())
.httpBasic(basic -> basic.disable())
 

Spring Security가 기본으로 제공하는 로그인 화면을 사용하지 않고, 직접 JWT 로그인 API를 구현했기 때문에 비활성화하였다.


4) JWT Filter 등록

가장 중요한 부분은 직접 구현한 JWT Filter를 Spring Security Filter Chain에 등록하는 것이다.

 
.addFilterBefore(
    new JwtFilter(tokenProvider, userRepository),
    UsernamePasswordAuthenticationFilter.class
)
 

UsernamePasswordAuthenticationFilter보다 먼저 JwtFilter가 실행되도록 설정하였다.

즉,

  1. JWT 존재 여부 확인
  2. JWT 검증
  3. 사용자 조회
  4. Request에 사용자 저장

과정을 먼저 수행한 뒤 Controller가 실행된다.


5) CORS 설정

프론트엔드와 백엔드가 서로 다른 도메인에서 실행되는 경우를 대비하여 CORS도 함께 설정하였다.

 
config.addAllowedOrigin("*");
config.addAllowedHeader("*");
config.addAllowedMethod("*");
 

이를 통해 모든 Origin, Header, HTTP Method 요청을 허용하도록 구성하였다.


11. Spring Security 로그인

Spring Security에서는

  • AuthenticationManager
  • UserDetailsService
  • UsernamePasswordAuthenticationFilter

가 로그인 과정을 자동으로 처리한다.

따라서 Controller에서 로그인 로직을 직접 구현하지 않아도 된다.


12. JWT + Spring Security

JWT 로그인에서는

로그인 시

로그인

↓

JWT 생성

↓

Cookie 저장

이후 요청에서는

JWT 추출

↓

JWT 검증

↓

사용자 정보 저장

↓

Controller 실행

과정을 거친다.


13. 권한(Role)

Spring Security에서는 권한을 이용하여 접근을 제어한다.

대표적으로

ROLE_USER

ROLE_ADMIN

를 사용한다.

Controller에서는

@Secured("ROLE_ADMIN")

처럼 관리자만 접근 가능한 API를 만들 수 있다.


14. Validation

Validation은 입력값 검증 기능이다.

대표적으로

@NotBlank
@Email
@Positive
@Size

등을 사용할 수 있다.

그리고 Controller에서는

public ResponseEntity<?> signup(
        @Valid SignupRequest request
)

처럼 @Valid를 붙이면 자동으로 검증이 수행된다.


15. Validation 예외 처리

Validation 실패 시에는 BindingResult를 이용하여 오류 정보를 확인할 수 있다.

이를 통해

  • 잘못된 입력값 확인
  • 회원가입 페이지로 다시 이동
  • 사용자에게 오류 메시지 출력

등의 처리가 가능하다.


이번 강의를 통해 Spring Security 기반의 인증 구조를 전체적으로 이해할 수 있었다. 특히 인증(Authentication)과 인가(Authorization)의 차이, Cookie/Session/JWT의 동작 방식, 그리고 Spring Security가 Filter를 통해 인증을 처리하는 흐름이 하나의 구조로 연결된다는 점이 가장 인상적이었다.

또한 Bean 등록과 의존성 주입(DI), 비밀번호 암호화, Validation을 활용한 입력값 검증까지 실제 회원가입과 로그인 기능을 구현하는 데 필요한 요소들을 함께 학습했다.

 

이전에 했던 개인 프로젝트에서는 로그인 API, 비밀번호 검증, JWT 생성 및 검증, 토큰에서 사용자 정보 추출 등 인증 과정 자체를 직접 개발을 하다보니 보안 환경과 요청 흐름 정도만 Spring Security에 의존하는 상황이었다. 다음 프로젝트에서는 직접 구현보다는 Spring Security를 100% 활용하는 방향으로 개발을 해봐야겠다 😊