개발스토리지😃

[스프링] 스프링시큐리티 + JWT 연동 (2) 로그인 구현 본문

프레임워크/스프링

[스프링] 스프링시큐리티 + JWT 연동 (2) 로그인 구현

believekim 2025. 6. 11. 17:14

 

시스템 환경

IDE IntelliJ IDEA
Language Java 17
Framework Spring Boot 3.4.5
Build Tool Gradle
ORM JPA (Hibernate)
Database MySQL
Security Spring Security + JWT (jjwt 0.11.5)
API 테스트 도구 Postman
환경 변수 관리 .env 파일 (spring-dotenv:4.0.0)
의존성 관리 io.spring.dependency-management 1.1.7
로깅/편의성 Lombok

 

 

https://github.com/pleasebelieveme/security-jwt-template

 

GitHub - pleasebelieveme/security-jwt-template

Contribute to pleasebelieveme/security-jwt-template development by creating an account on GitHub.

github.com

 


 

0. 패키지 구조

 

1. User생성

  • SQL문으로 User를 넣어줘도 되고 컨트롤러를 만들고 실행시켜도 된다.
  • 필자는 email, password, name, nickname, userRole("USER", "ADMIN") 필드를 선언하였다.
public record UserCreateRequest(

	@NotBlank(message = "이메일은 필수 입력값입니다.")
	@Email(message = "올바른 이메일 형식이 아닙니다.")
	String email,

	@NotBlank(message = "비밀번호는 필수 입력값입니다.")
	@Pattern(
		regexp = "^(?=.*[a-z])(?=.*[A-Z])(?=.*\\d)(?=.*[!@#$%^&*()\\-_=+{};:,<.>]).{8,}$",
		message = "비밀번호는 최소 8자 이상이며, 대문자, 소문자, 숫자, 특수문자를 모두 포함해야 합니다"
	)
	String password,

	@NotBlank(message = "이름은 필수 입력값입니다.")
	@Size(min = 2, max = 20, message = "이름 최대 20글자가 넘지 않도록 해주십시오.")
	String name,

	@NotBlank(message = "닉네임은 필수 입력값입니다.")
	@Size(min = 2, max = 30, message = "이름 최대 30글자가 넘지 않도록 해주십시오.")
	String nickname,

	@NotNull
	UserRole userRole

) {

}

 

2. LoginRequest

  • email과 password를 입력받아 스프링으로 전달하는 DTO를 아래와 같이 선언하였다.
public record UserCreateRequest(

	@NotBlank(message = "이메일은 필수 입력값입니다.")
	@Email(message = "올바른 이메일 형식이 아닙니다.")
	String email,

	@NotBlank(message = "비밀번호는 필수 입력값입니다.")
	@Pattern(
		regexp = "^(?=.*[a-z])(?=.*[A-Z])(?=.*\\d)(?=.*[!@#$%^&*()\\-_=+{};:,<.>]).{8,}$",
		message = "비밀번호는 최소 8자 이상이며, 대문자, 소문자, 숫자, 특수문자를 모두 포함해야 합니다"
	)
	String password,

	@NotBlank(message = "이름은 필수 입력값입니다.")
	@Size(min = 2, max = 20, message = "이름 최대 20글자가 넘지 않도록 해주십시오.")
	String name,

	@NotBlank(message = "닉네임은 필수 입력값입니다.")
	@Size(min = 2, max = 30, message = "이름 최대 30글자가 넘지 않도록 해주십시오.")
	String nickname,

	@NotNull
	UserRole userRole

) {

}

 

3. TokenResponse

  • 로그인 성공시 accessToken과 refreshToken을 전달할 DTO를 아래와 같이 선언하였다.
@Getter
public class TokenResponse {
	private final String accessToken;
	private final String refreshToken;

	public TokenResponse(String accessToken, String refreshToken) {
		this.accessToken = accessToken;
		this.refreshToken = refreshToken;
	}
}

 

 

4. AuthController

  • domain/auth/controller/AuthController에 위치하였으며 로그인, 로그아웃, 리프레쉬토큰 재발급 메서드를 작성하였다.
@RestController
@RequiredArgsConstructor
@RequestMapping("/api/auth")
public class AuthController {

	private final AuthService authService;

	@PostMapping("/login")
	public ResponseEntity<TokenResponse> login(@Valid @RequestBody LoginRequest request) {
		TokenResponse tokenResponse = authService.login(request);
		return ResponseEntity.status(HttpStatus.OK).body(tokenResponse);
	}

	@PostMapping("/logout")
	public ResponseEntity<Void> logout(HttpServletRequest request) {
		authService.logout(request);
		return ResponseEntity.status(HttpStatus.OK).build();
	}

	@PostMapping("/reissue")
	public ResponseEntity<TokenResponse> reissue(@RequestHeader("Authorization") String refreshToken) {
		TokenResponse tokenResponse = authService.reissue(refreshToken);
		return ResponseEntity.ok(tokenResponse);
	}

}

 

5. AuthService

  • 컨트롤러와 맵핑되는 서비스로직을 작성하였고 토큰생성부분은 TokenService로 분리하여 작성하였다.
@Service
@Transactional // AuthService의 모든 public 메서드에만 트랜잭션 적용
@RequiredArgsConstructor
public class AuthService {

	private final UserRepository userRepository;
	private final RedisRepository redisRepository;
	private final PasswordEncoder passwordEncoder;
	private final TokenService tokenService;
	private final JwtUtil jwtUtil;

	public TokenResponse login(LoginRequest request) {
		User user = userRepository.findByEmailOrElseThrow(request.email());

		if (!passwordEncoder.matches(request.password(), user.getPassword())) {
			throw new BizException(UserErrorCode.INVALID_PASSWORD);
		}

		return tokenService.createTokens(user.getId(), user.getUserRole());
	}

	public void logout(HttpServletRequest request) {
		String token = jwtUtil.extractToken(request);

		// 토큰이 유효한 경우만 블랙리스트에 등록
		if (token != null && jwtUtil.validateToken(token)) {
			long expiration = jwtUtil.getExpiration(token);
			redisRepository.saveBlackListToken(token, expiration);
		}
	}

	public TokenResponse reissue(String bearerToken) {
		// 1. Bearer 제거
		if (bearerToken == null || !bearerToken.startsWith("Bearer ")) {
			throw new BizException(AuthErrorCode.MISMATCHED_REFRESH_TOKEN);
		}
		String refreshToken = bearerToken.substring(7);

		// 2. 토큰 유효성 검증
		if (!jwtUtil.validateToken(refreshToken)) {
			throw new BizException(AuthErrorCode.MISMATCHED_REFRESH_TOKEN);
		}

		// 3. 유저 정보 추출
		UserAuth userAuth = jwtUtil.extractUserAuth(refreshToken);

		// 4. Redis에 저장된 Refresh Token과 일치하는지 확인
		if (!redisRepository.validateRefreshToken(userAuth.getId(), refreshToken)) {
			throw new BizException(AuthErrorCode.REUSED_REFRESH_TOKEN);
		}

		redisRepository.deleteRefreshToken(userAuth.getId());

		return tokenService.createTokens(userAuth.getId(), userAuth.getUserRole());
	}
}

 

 

6. TokenService

@Service
@RequiredArgsConstructor
public class TokenService {

    private final JwtUtil jwtUtil;
    private final RedisRepository redisRepository;

    public TokenResponse createTokens(Long userId, UserRole userRole) {
       String accessToken = jwtUtil.createToken(userId, userRole);
       String refreshToken = jwtUtil.createRefreshToken(userId, userRole);

       long refreshExpiration = jwtUtil.getRefreshExpiration(refreshToken);
       redisRepository.saveRefreshToken(userId, refreshToken, refreshExpiration);

       return new TokenResponse(accessToken, refreshToken);
    }
}