반응형
1. 인증 시스템 아키텍처 개요
인증 시스템은 상태 비저장(Stateless) 아키텍처를 기반으로 한다.
서버는 사용자의 세션을 유지하지 않으며, 모든 요청은 암호화된 토큰(JWT)을 통해 인증된다.
이는 서버의 확장성을 높이고 클라이언트-서버 간의 의존성을 낮출 수 있다.
- 주요 기술 스택:
- Spring Boot 3: 애플리케이션의 기반 프레임워크이다.
- Spring Security: 인증(Authentication)과 인가(Authorization)를 담당하는 핵심 보안 프레임워크이다.
- JWT (JSON Web Token): 인증 정보를 안전하게 전송하기 위한 토큰 기술이다. io.jsonwebtoken 라이브러리를 사용한다.
- JPA (Java Persistence API): 데이터베이스와 상호작용하며 User 및 RefreshToken 엔티티를 관리한다.
2. 로그인 처리 상세 흐름
사용자가 아이디와 비밀번호를 입력하여 로그인을 시도하는 과정은 다음과 같은 단계를 거친다.
1단계: 클라이언트 요청 및 컨트롤러 수신
- 사용자는 로그인 페이지에서 아이디(uid)와 비밀번호를 입력하고 로그인 버튼을 클릭한다.
- 클라이언트는 /v1/auth/login 엔드포인트로 HTTP POST 요청을 전송한다. 요청의 본문(Body)에는 JSON 형식의 사용자 자격 증명 정보가 포함된다.
- AuthController의 login 메서드가 이 요청을 수신한다. @RequestBody 어노테이션은 HTTP 요청 본문의 JSON 데이터를 LoginRequest DTO 객체로 변환(역직렬화)한다. LoginRequest DTO는 uid와 password 필드를 가지고 있으며, @NotBlank와 같은 유효성 검사 어노테이션을 통해 값이 비어있지 않은지 확인한다.
- AuthController는 실제 인증 로직을 직접 수행하지 않고, AuthService 인터페이스에 의존하여 authenticateAndCreateToken 메서드를 호출함으로써 인증 처리를 위임한다. 이는 계층 간 역할 분리원칙을 따른 것이다.
//로그인
@PostMapping("/login")
public ApiResponse<JwtResponse> login(@RequestBody @Valid LoginRequest dto){
JwtResponse token = authService.authenticateAndCreateToken(dto);
return ApiResponse.success(token, "200", "success");
}
2단계: 서비스 계층에서의 인증 처리
AuthServiceImpl 클래스는 실질적인 인증 로직을 담당한다.
- 사용자 조회: UserRepository를 사용하여 LoginRequest에 담긴 uid로 데이터베이스에서 User 엔티티를 조회한다. 사용자를 찾을 수 없으면 RuntimeException을 발생시켜 응답을 중단한다.
- 비밀번호 검증: PasswordConfig에 빈으로 등록된 BCryptPasswordEncoder를 사용하여, 클라이언트로부터 받은 평문 비밀번호와 데이터베이스에 저장된 해시된 비밀번호를 matches() 메서드로 비교한다. 일치하지 않을 경우, RuntimeException을 발생시킨다.
- 토큰 생성 위임: 인증이 성공하면, JwtUtils를 호출하여 Access Token을 생성하고, RefreshTokenService를 호출하여 Refresh Token을 생성 및 저장한다.
public JwtResponse authenticateAndCreateToken(LoginRequest req){
User user = userRepository.findByUid(req.getUid())
.orElseThrow(() -> new RuntimeException("유저를 찾을 수 없음"));
if(!passwordEncoder.matches(req.getPassword(), user.getPassword())){
throw new RuntimeException("비밀번호가 일치하지 않음");
}
String accesstoken = jwtUtils.generateJwtToken(user);
Long expiresIn = jwtUtils.getJwtExpirationMs();
RefreshToken refreshTokenEntity = refreshTokenService.createRefreshToken(user);
return new JwtResponse(accesstoken, "Bearer", expiresIn, refreshTokenEntity.getToken() ,user.getUid());
}
3단계: JWT 생성 및 관리
- Access Token 생성: JwtUtils의 generateJwtToken 메서드는 인증된 User 객체를 받아 Access Token을 생성한다. 토큰에는 주제(subject)로 사용자의 uid가, 추가적인 정보로 nickName이 포함된다. 발급 시간과 만료 시간(jwt.expiration-access 값)이 설정되며, HMAC-SHA 알고리즘과 비밀 키(jwt.secret)를 사용하여 서명된다.
- Refresh Token 생성 및 저장: RefreshTokenServiceImpl의 createRefreshToken 메서드는 먼저 해당 사용자의 기존 Refresh Token을 데이터베이스에서 삭제하여 한 번에 하나의 유효한 Refresh Token만 유지하도록 보장한다. 이후 UUID를 사용하여 무작위의 토큰 문자열을 생성하고, 만료 시간(jwt.expiration-refresh 값)을 설정하여 새로운 RefreshToken 엔티티를 생성한 후, RefreshTokenRepository를 통해 데이터베이스에 저장한다.
public String generateJwtToken(User user) {
byte[] keyBytes = Decoders.BASE64.decode(jwtSecret);
Key signingKey = Keys.hmacShaKeyFor(keyBytes);
return Jwts.builder()
.subject(user.getUid())
.claim("nickName", user.getNickName())
.issuedAt(new Date(System.currentTimeMillis()))
.expiration(new Date(System.currentTimeMillis() + jwtExpirationMs))
.signWith(signingKey)
.compact();
}
@Override
@Transactional
public RefreshToken createRefreshToken(User user){
refreshTokenRepository.deleteByUser(user);
RefreshToken token = new RefreshToken(
user,
UUID.randomUUID().toString(),
Instant.now().plusMillis(refreshTokenDurationMs)
);
return refreshTokenRepository.save(token);
}
4단계: 클라이언트로 응답
- 생성된 Access Token, Refresh Token, 토큰 만료 시간, 사용자 uid 등의 정보를 JwtResponse DTO에 담는다.
- 이 JwtResponse 객체를 다시 ApiResponse DTO로 감싸서, 표준화된 응답 형식(성공 여부, 메시지, 데이터)으로 만들어 클라이언트에게 최종적으로 반환한다.
3. JWT 검증 및 인가 (요청 처리)
로그인 이후, 클라이언트는 보호된 API에 접근하기 위해 모든 요청의 Authorization 헤더에 Bearer 접두사와 함께 Access Token을 담아 전송한다.
- 필터 체인과 AuthTokenFilter: SecurityConfig에 정의된 보안 필터 체인에 따라, 모든 수신 요청은 UsernamePasswordAuthenticationFilter 이전에 위치한 AuthTokenFilter를 통과한다.
- 토큰 파싱 및 검증: AuthTokenFilter는 parseJwt 메서드를 통해 헤더에서 'Bearer ' 접두사를 제거하고 순수한 토큰 문자열을 추출한다. 추출된 토큰은 jwtUtils.isValidToken 메서드로 전달되어 서명의 유효성과 만료 여부를 검증받는다.
- SecurityContext 설정: 토큰이 유효하면, jwtUtils.getUsernameFromJwt를 통해 토큰에서 사용자 uid를 추출한다. UserDetailsServiceImpl은 이 uid를 사용하여 데이터베이스에서 사용자 정보를 조회하고, Spring Security가 이해할 수 있는 UserDetails 객체를 생성한다. 이 UserDetails 객체를 기반으로 UsernamePasswordAuthenticationToken이 생성되고, 최종적으로 SecurityContextHolder에 저장된다. 이제 해당 요청은 인증된 것으로 간주되며, 이후의 인가(Authorization) 결정에 사용된다.
public boolean isValidToken(String Token){
try{
Jws<Claims> jws = Jwts.parser()
.verifyWith(getSigningKey())
.build()
.parseSignedClaims(Token);
return true;
}
catch (JwtException | IllegalArgumentException e){
return false;
}
}
4. 토큰 재발급 메커니즘
Access Token의 유효 기간은 짧기 때문에 만료 시 새로운 토큰을 발급받는 과정이 필요하다.
- Access Token이 만료되면 클라이언트는 /v1/auth/refresh 엔드포인트로 Refresh Token을 담아 요청을 보낸다.
- AuthController는 RefreshTokenRequest DTO로 토큰을 수신하고, RefreshTokenService의 findByToken 메서드를 호출하여 데이터베이스에 해당 Refresh Token이 존재하는지 확인한다.
- verifyExpiration 메서드를 통해 DB에 저장된 Refresh Token의 만료 시간을 현재 시간과 비교한다. 만약 만료되었다면, 토큰을 DB에서 삭제하고 TokenRefreshException을 발생시킨다.
- 토큰이 유효하다면, JwtUtils는 Refresh Token에 포함된 사용자 정보로 새로운 Access Token을 생성하고, RefreshTokenService는 새로운 Refresh Token을 생성하여 DB를 갱신한다.
- 새롭게 발급된 토큰들을 JwtResponse에 담아 클라이언트에게 반환한다.
//Jwt 재발급
@PostMapping("/refresh")
public ResponseEntity<JwtResponse> refresh(@Valid @RequestBody RefreshTokenRequest req){
RefreshToken stored = refreshTokenService.findByToken(req.getRefreshToken())
.orElseThrow(() -> new TokenRefreshException(req.getRefreshToken(), "DB에 존재하지 않음"));
refreshTokenService.verifyExpiration(stored);
//DB에서 얻은 사용자로 새 AccessToken 생성
String newAccess = jwtUtils.generateJwtToken(stored.getUser());
RefreshToken newRefresh = refreshTokenService.createRefreshToken(stored.getUser());
JwtResponse res = new JwtResponse(newAccess, "Bearer", jwtUtils.getJwtExpirationMs(),newRefresh.getToken(),stored.getUser().getUid());
return ResponseEntity.ok(res);
}
5. 예외 처리
체계적인 예외 처리는 안정적인 서비스를 위해 필수적이다.
- AuthEntryPointJwt: 인증되지 않은 사용자가 보호된 리소스에 접근을 시도할 때 commence 메서드가 호출된다. 이는 클라이언트에게 HTTP 상태 코드 401(Unauthorized)과 함께 에러 메시지를 담은 ApiResponse를 반환한다.
- RestExceptionHandler: @RestControllerAdvice를 통해 전역적으로 예외를 처리한다.
- TokenRefreshException : Refresh Token 관련 오류 발생 시 403(Forbidden)을 반환한다.
- JwtException : JWT 서명 오류, 형식 오류 등 발생 시 401(Unauthorized)을 반환한다.
- AccessDeniedException : 인증은 되었으나 특정 리소스에 대한 권한이 없을 때 403(Forbidden)을 반환한다.
- MethodArgumentNotValidException: @Valid 어노테이션을 사용한 DTO 유효성 검증에 실패했을 때 400(Bad Request)과 함께 실패한 필드와 이유를 상세히 반환한다.
/** 리프레시 토큰 검증 실패 → 403 */
@ExceptionHandler(TokenRefreshException.class)
public ResponseEntity<ApiResponse<Void>> handleTokenRefresh(TokenRefreshException ex) {
ApiResponse<Void> body = ApiResponse.error("403", ex.getMessage());
return ResponseEntity
.status(HttpStatus.FORBIDDEN)
.body(body);
}
/** JWT 서명/만료 등 검증 실패 → 401 */
@ExceptionHandler(JwtException.class)
public ResponseEntity<ApiResponse<Void>> handleJwt(JwtException ex) {
ApiResponse<Void> body = ApiResponse.error(
"401",
"Invalid or expired JWT: " + ex.getMessage()
);
return ResponseEntity
.status(HttpStatus.UNAUTHORIZED)
.body(body);
}
/** 권한 부족 → 403 */
@ExceptionHandler(AccessDeniedException.class)
public ResponseEntity<ApiResponse<Void>> handleAccessDenied(AccessDeniedException ex) {
ApiResponse<Void> body = ApiResponse.error(
"403",
"Access denied: " + ex.getMessage()
);
return ResponseEntity
.status(HttpStatus.FORBIDDEN)
.body(body);
}
/** @Valid 검증 실패 → 400 (Request DTO) */
@Override
protected ResponseEntity<Object> handleMethodArgumentNotValid(
MethodArgumentNotValidException ex,
HttpHeaders headers,
HttpStatusCode status,
WebRequest request) {
// 필드별 에러 메시지만 추출
String msg = ex.getBindingResult()
.getFieldErrors()
.stream()
.map(fe -> fe.getField() + ": " + fe.getDefaultMessage())
.collect(Collectors.joining("; "));
ApiResponse<Void> body = ApiResponse.error("400", "Validation failed: " + msg);
return ResponseEntity
.status(HttpStatus.BAD_REQUEST)
.headers(headers)
.body(body);
}
/** 그 외 예외 → 500 */
@ExceptionHandler(Exception.class)
public ResponseEntity<ApiResponse<Void>> handleAll(Exception ex) {
ApiResponse<Void> body = ApiResponse.error(
"500",
"Internal error: " + ex.getMessage()
);
return ResponseEntity
.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body(body);
}728x90
반응형
'IT 프로젝트 공모전(백엔드)' 카테고리의 다른 글
| [Spring boot] 회원가입 기능구현 4(Service, Controller, Security 설정) (0) | 2025.07.23 |
|---|---|
| [Spring boot] 회원가입 기능구현 3(Repository, DTO) (1) | 2025.07.23 |
| [Spring boot] 회원가입 기능구현 2(Domain) (1) | 2025.07.15 |
| [Spring boot] 회원가입 기능구현 1 (설계, 계층, ERD, SQL 스키마) (0) | 2025.07.15 |
| IT 프로젝트 기획 시작 (1) | 2025.06.20 |