로그인 기능 구현

TNFUDS·2025년 11월 26일

FinTrack 프로젝트

목록 보기
8/14

이전 포스팅에서 JWT에 대해 언급했으니
이제 이걸 프로젝트에 적용한 내용에 대해 작성하고자 한다.

의존성 주입

jwt 라이브러리에 여러 종류가 있으나, 주로 JJWT와 Auth0이 사용된다.

JJWT vs Auth0 비교

비교 항목JJWT (io.jsonwebtoken)Auth0 (com.auth0.jwt)
개발사Stormpath (오픈소스, 현 jwtk)Auth0
서명 알고리즘HS256, HS384, HS512, RS256 등 다양동일
JSON 파싱Jackson 또는 Gson (추가 모듈 필요)내장
주요 장점직관적이고 Spring에 많이 쓰임가볍고 체이닝 문법 깔끔
주요 단점0.12.x 이전 버전에서는 Java 9+ 호환성 이슈Claims 타입이 단순 Map 형태
실무 사용 비율Spring Boot 프로젝트에서 압도적경량 앱, Kotlin, Android에서 선호

어떤 걸 선택해야 할까?

상황추천
Spring Boot 백엔드 + JPA + Rest APIJJWT (io.jsonwebtoken)
Kotlin/Android, 가벼운 인증 모듈🔹 Auth0
RSA 비대칭키 기반 인증 필요JJWT(0.12.x 이상) 또는 Auth0 둘 다 OK

FinTrack처럼 Spring Boot 기반 백엔드 프로젝트에서는
JJWT가 훨씬 자연스럽고, 시큐리티 필터와 통합도 쉽기 때문에 JJWT를 선택했다.

build.gradle에 다음과 같이 의존성을 주입한다.

// JWT (jjwt)
	implementation 'io.jsonwebtoken:jjwt-api:0.11.5'
	runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.11.5'
	runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.11.5' // JSON 직렬화용

JwtProvider 작성

jjwt를 이용하여 signed jwt를 만들기

  • Jwts.builder()를 사용하여 JwtBuilder instance를 만든다.
  • JwtBuilder 메서드를 호출하여 헤더 파라미터와 claims(Body)를 등록한다.
  • JWT를 서명하는 데 사용할 SecretKey 또는 비대칭 PrivateKey를 지정한다.
  • 마지막으로, compact() 메서드를 호출하여 설정된 signed jwt를 생성한다.

Access Token 만들기

public String generateAccessToken(String email){
        return Jwts.builder()
                .setSubject(email)
                .setIssuedAt(new Date())
                .setExpiration(new Date(System.currentTimeMillis() + ACCESS_TOKEN_VALIDITY))
                .signWith(key, SignatureAlgorithm.HS512)
                .compact();

    }

Refresh Token 만들기

public String generateRefreshToekn(String email){
        return Jwts.builder()
                .setSubject(email)
                .setIssuedAt(new Date())
                .setExpiration(new Date(System.currentTimeMillis() + REFRESH_TOKEN_VALIDITY))
                .signWith(key, SignatureAlgorithm.HS512)
                .compact();
    }

jwt 검증하기

jwt를 만들고 parsing까지 완료했으니 jwt를 검증하고자 한다.
jjwt를 이용하면 아래의 프로세스로 검증이 가능하다.

  • Jwts.parserBuilder() 메서드를 사용해서 JwtParserBuilder 인스턴스를 만든다.
  • JWS 서명을 확인하는 데 사용할 SecretKey 또는 비대칭 공개키를 지정한다.
  • build() 를 호출하면 thread-safe JwtParser가 반환된다.
  • 마지막으로, parseClaimsJws(jwtString) 메서드를 호출하면 오리지널 signed JWT가 반환된다.
  • 추가적으로 만약 jwt를 검증하면서 검증에 실패하면 예외가 발생한다.
// 토큰 유효성 검증
    public boolean validateToken(String token){
        try{
            Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(token);
            return true;
        } catch(ExpiredJwtException e){
            System.out.println("JWT 만료됨");
        } catch (MalformedJwtException e){
            System.out.println("JWT 형식이 올바르지 않음");
        } catch (SignatureException e){
            System.out.println("JWT 서명 검증 실패");
        } catch (Exception e) {
            System.out.println("JWT 검증 중 예외 발생");
        }
        return false;
    }

전체 코드

JwtProvider

@Component
public class JwtProvider {

    @Value("${JWT_SECRET}")
    private String secretKey;
    private Key key;

    // Access / Refresh 토큰 만료 시간 -> 추후 서비스 특성 고려하여 수정하기
    private final long ACCESS_TOKEN_VALIDITY = 1000L * 60 * 30; // 30분
    private final long REFRESH_TOKEN_VALIDITY = 1000L * 60 * 60 * 24 * 7; // 7일

    @PostConstruct
    protected void init(){
        byte [] keyBytes = Base64.getDecoder().decode(secretKey);
        this.key = Keys.hmacShaKeyFor(keyBytes);

    }
    // Access Token 생성
    public String generateAccessToken(String email){
        return Jwts.builder()
                .setSubject(email)
                .setIssuedAt(new Date())
                .setExpiration(new Date(System.currentTimeMillis() + ACCESS_TOKEN_VALIDITY))
                .signWith(key, SignatureAlgorithm.HS512)
                .compact();

    }

    // Refresh Token 생성
    public String generateRefreshToekn(String email){
        return Jwts.builder()
                .setSubject(email)
                .setIssuedAt(new Date())
                .setExpiration(new Date(System.currentTimeMillis() + REFRESH_TOKEN_VALIDITY))
                .signWith(key, SignatureAlgorithm.HS512)
                .compact();
    }

    // 이메일 추출
    public String getEmailFromToken(String token){
        return Jwts.parserBuilder()
                .setSigningKey(key)
                .build()
                .parseClaimsJws(token)
                .getBody()
                .getSubject();
    }

    // 토큰 유효성 검증
    public boolean validateToken(String token){
        try{
            Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(token);
            return true;
        } catch(ExpiredJwtException e){
            System.out.println("JWT 만료됨");
        } catch (MalformedJwtException e){
            System.out.println("JWT 형식이 올바르지 않음");
        } catch (SignatureException e){
            System.out.println("JWT 서명 검증 실패");
        } catch (Exception e) {
            System.out.println("JWT 검증 중 예외 발생");
        } 
        return false;
    }

}

@PostConstruct란?

@PostConstruct는 의존성 주입이 이루어진 후 초기화를 수행하는 메서드이다. @PostConstruct가 붙은 메서드는 클래스가 service를 수행하기 전에 발생한다.

왜 사용하는가?

secretKey를 Base64 디코딩하여 Key 객체로 변환

@PostConstruct
    protected void init(){
        byte [] keyBytes = Base64.getDecoder().decode(secretKey);
        this.key = Keys.hmacShaKeyFor(keyBytes);
    }

LoginRequest

@Getter @Setter
public class LoginRequest {
    @Email(message = "유효한 이메일 형식이어야 합니다.")
    @NotBlank(message = "이메일은 필수 입력값입니다.")
    private String email;

    @NotBlank(message = "비밀번호는 필수 입력값입니다.")
    private String password;
}

LoginResponse

@Getter
@AllArgsConstructor
public class LoginResponse {
    private String name;
    private String email;
    private String accessToken;
    private String refreshToken;
}

AuthService

@Service
@AllArgsConstructor
public class AuthService {

    private final UserRepository userRepository;
    private final JwtProvider jwtProvider;
    private final BCryptPasswordEncoder passwordEncoder = new BCryptPasswordEncoder();

    public LoginResponse login (LoginRequest request){
        // 이메일 확인
        User user = userRepository.findByEmail(request.getEmail())
                .orElseThrow(() -> new IllegalArgumentException("존재하지 않는 이메일입니다."));

        // 비밀번호 일치 확인
        if (!passwordEncoder.matches(request.getPassword(), user.getPassword())){
            throw new IllegalArgumentException("비밀번호가 일치하지 않습니다.");
        }

        // JWT 발급
        String accessToken = jwtProvider.generateAccessToken(user.getEmail());
        String refreshToken = jwtProvider.generateRefreshToken(user.getEmail());

        // 응답 반환
        return new LoginResponse(user.getName(), user.getEmail(), accessToken, refreshToken);

    }
}

AuthController

@RestController
@RequestMapping("/api/auth")
@RequiredArgsConstructor
public class AuthController {

    private final AuthService authService;

    // 로그인
    @PostMapping("/login")
    public ResponseEntity<ApiResponse<LoginResponse>> login(@Valid @RequestBody LoginRequest request){
        LoginResponse response = authService.login(request);
        return ResponseEntity.ok(ApiResponse.success("로그인 성공", response));
    }

    // 로그아웃
    @PostMapping("/logout")
    public ResponseEntity<ApiResponse<Void>> logout(){
        // 클라이언트 측에서 토큰 삭제
        return ResponseEntity.ok(ApiResponse.success("로그아웃 완료", null));
    }
}
profile
내 세상을 넓혀가는 중

0개의 댓글