Java

[ Java ] 자바에서 구현 해볼 수 있는 로그인 기법-3 ( JWT 로그인 기법 )

YBin's 2024. 10. 29. 16:31

이전 뽀-스트

https://udangtang-dev.tistory.com/7

 

[ Java ] 자바에서 구현 해볼 수 있는 로그인 기법-2 ( 스프링 시큐리티 )

지난 뽀-스트https://udangtang-dev.tistory.com/6 [ Java ] 자바에서 구현 해볼 수 있는 로그인 기법-1 ( 세션 기반 로그인 )이번 포스팅에서는 자바에서 써먹을 수 있는 로그인 방법을 끄적여 볼까 한다.1. Se

udangtang-dev.tistory.com

 

이번 포스팅에는 현재 대중적으로 많이 쓰이는 Json Web Token을 다루어 보려고 한다.

 

자바 로그인 시리즈를 작성하게 된 이유는 사실 이 JWT를 정리하기 위함이었다.. 평소에 자주 쓰지만, 왜 이걸 쓰는지 몰랐기에 이론을 정리해보고자 한다.

 

JWT란 무엇인가?

JWT(Json Web Token)는 사용자 인증과 정보 교환에 널리 사용되는 JSON 기반의 토큰으로, 안전하고 간편하게 데이터를 주고받기 위한 목적으로 사용된다. 주로 API 인증에 활용되며, 세션 기반 인증보다 효율적이라고 한다.

 

JWT의 장점

 

1. 상태 비저장(Stateless): 서버는 세션을 유지할 필요가 없어서 분산 환경 구성 등 다양한 방면에서 유리하다.

2. 보안성: 서명된 토큰으로 변조 여부를 검증할 수 있어 안전하다.

 

JWT만 썼을 때의 단점과 주의사항

 

토큰 탈취 위험: JWT가 유출되면 재발급 전까지는 무효화할 수 없으므로 주의가 필요하다.

크기 문제: JWT는 기본적으로 JSON 형식이므로 세션 쿠키보다 크기가 커질 수 있다.

 

앞선 문제를 해결하기 위한 JWT와 Redis와의 결합

 

필자는 Redis과 같이 사용하는 방법을 써보겠다 ! ( 사실 이 방법밖에 안써봄! )

 

JWT는 상태를 서버에 저장하지 않고 클라이언트에서 유지하는 방식이지만, Redis를 사용하면 JWT의 만료 및 무효화 작업을 쉽게 관리할 수 있다 !!

 

특히 로그아웃 또는 토큰 무효화와 같은 기능을 구현할 때 Redis가 더욱 빛난다!

Redis는 매우 빠르고 확장성이 뛰어난 인메모리 데이터 저장소로, JWT를 캐싱하거나 블랙리스트를 관리할 때 사용할 수 있다.

 

잠깐 ! 블랙리스트란?

JWT는 클라이언트가 로그인한 후 발급된 토큰을 통해 인증을 유지 및 갱신을 한다.

 

기본적으로 토큰은 클라이언트가 만료될 때까지 사용할 수 있는데, 토큰 자체는 상태를 서버에 저장하지 않기 때문에, 서버가 토큰의 사용을 강제로 무효화할 수 없다는 단점이라면 단점이 있다.

 

이를 해결하기 위한 방법 중 하나가 블랙리스트이다.

 

한 줄로 정리하자면, 다음과 같다.

 

로그아웃 혹은 비정상적인 접근이 발생되었을 때, 토큰을 신뢰할 수 없게 만드는 것.

 

 

Reids와 Jwt의 사용 흐름

 

1. 로그인 요청: 사용자가 클라이언트에서 로그인 시도.

2. 서버 인증: 서버에서 사용자 정보를 검증한 후 JWT를 생성.

3. Redis에 토큰 저장: 생성된 JWT와 만료 시간을 Redis에 저장

4. JWT 발급: 서버는 JWT를 클라이언트에게 응답으로 전송합니다.

5. 인증된 요청: 클라이언트는 이후 요청 시 JWT를 HTTP 헤더에 담아 서버에 전송합니다.

6. Redis에서 토큰 검증: 서버는 Redis에 저장된 JWT를 확인하여 유효한지 검증한 후 요청을 처리합니다.

7. 로그아웃 처리: 로그아웃 시, JWT를 Redis에서 삭제하거나 블랙리스트에 추가하여 더 이상 사용할 수 없게 만듭니다.

 

 

예시 코드

1. 요청을 받는 Controller 작성

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.*;

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

    @Autowired
    private AuthenticationService authService;

    @PostMapping("/login")
    public String login(@RequestParam String username) {
        return authService.login(username);
    }

    @GetMapping("/validate")
    public boolean validateToken(@RequestHeader("Authorization") String token) {
        return authService.validateToken(token.replace("Bearer ", ""));
    }

    @PostMapping("/logout")
    public String logout(@RequestHeader("Authorization") String token) {
        authService.logout(token.replace("Bearer ", ""));
        return "Logged out";
    }
}

 

- login 요청이 들어오면, service에 작성한 토큰 발급 등의 로직을 처리한다.

- validate로 유효성 검증이 요청이 들어온다면, Bearer 타입으로 넘어와서 앞에 붙은 부분을 전처리 한 후에 유효성 검증 로직을 호출한다.

- logout 또한 전처리 과정을 거치고, 로그아웃을 호출한다.

 

 

2. Service 작성

@Service
public class AuthenticationService {

    @Autowired
    private JwtUtil jwtUtil;

    @Autowired
    private RedisTemplate<String, String> redisTemplate;

    public String login(String username) {
        String token = jwtUtil.generateToken(username);
        redisTemplate.opsForValue().set(token, username, 1, TimeUnit.HOURS);
        return token;
    }

    public boolean validateToken(String token) {
        return jwtUtil.validateToken(token) && redisTemplate.hasKey(token);
    }

    public void logout(String token) {
        redisTemplate.delete(token);
    }
}

 

- Bean 설정 및 의존성 주입을 하고, 토큰 생성 후 만료 시간등을 설정한다. 유효성 검증같은 경우에는 Redis에 저장되어있는 키와 비교를 한다. 

redisTemplate.opsForValue().set(token, username, 1, TimeUnit.HOURS); //토큰을 키로 Redis에 저장하며 1시간 유효 기간 설정

 

 

3. 실질적으로 JWT 관련 로직을 다루는 JwtUtil 작성

import io.jsonwebtoken.Claims;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.SignatureAlgorithm;
import org.springframework.stereotype.Component;

import java.util.Date;

@Component // 스프링이 관리하는 Bean으로 등록
public class JwtUtil {

    private final String secretKey = "secret"; // JWT 서명을 위한 비밀 키 (안전한 키로 교체 필요)
    private final long expirationTime = 3600000; // 토큰의 유효 기간, 1시간(밀리초 단위)

    // JWT 토큰을 생성하는 메서드
    public String generateToken(String username) {
        return Jwts.builder()
                .setSubject(username) // 토큰의 주체를 username으로 설정
                .setExpiration(new Date(System.currentTimeMillis() + expirationTime)) // 만료 시간 설정
                .signWith(SignatureAlgorithm.HS512, secretKey) // 서명 알고리즘과 비밀 키를 사용해 서명
                .compact();
    }

    // 토큰의 유효성을 검증하는 메서드
    public boolean validateToken(String token) {
        try {
            Jwts.parser().setSigningKey(secretKey).parseClaimsJws(token); // 서명을 검증
            return true; // 검증 성공 시 true 반환
        } catch (Exception e) {
            return false; // 실패 시 false 반환
        }
    }

    // 토큰에서 사용자 이름을 추출하는 메서드
    public String extractUsername(String token) {
        Claims claims = Jwts.parser().setSigningKey(secretKey).parseClaimsJws(token).getBody();
        return claims.getSubject(); // 주체 정보(username)를 추출하여 반환
    }
}

 

- 이번에는 내용이 좀 많아서 주석으로 설명하였다.

 

 

4. 마지막으로 Redis를 사용하기 위한 설정 추가

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.connection.RedisConnectionFactory;
import org.springframework.data.redis.connection.RedisStandaloneConfiguration;
import org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.serializer.StringRedisSerializer;

@Configuration
public class RedisConfig {

    private final String redisHost = "레디스만들고나온엔드포인트";  // Redis 서버 엔드포인트
    private final int redisPort = 6379;  // Redis 포트 번호
    private final String redisUsername = "유저명 적으시면 되는데 기본은 default입니다"; // Redis 사용자 이름
    private final String redisPassword = "레디스 패스워드!!"; // Redis 비밀번호

    @Bean
    public RedisConnectionFactory redisConnectionFactory() {
        // RedisStandaloneConfiguration 객체에 Redis 서버의 설정을 입력
        RedisStandaloneConfiguration config = new RedisStandaloneConfiguration();
        config.setHostName(redisHost);  // 엔드포인트 설정
        config.setPort(redisPort);      // 포트 설정
        config.setUsername(redisUsername);  // 사용자 이름 설정
        config.setPassword(redisPassword);  // 비밀번호 설정

        return new LettuceConnectionFactory(config); // RedisStandaloneConfiguration을 LettuceConnectionFactory에 설정
    }

    @Bean
    public RedisTemplate<String, String> redisTemplate() {
        RedisTemplate<String, String> redisTemplate = new RedisTemplate<>();
        redisTemplate.setConnectionFactory(redisConnectionFactory());
        redisTemplate.setKeySerializer(new StringRedisSerializer());
        redisTemplate.setValueSerializer(new StringRedisSerializer());
        return redisTemplate;
    }
}

 

 

JWT와 Redis를 활용한 인증 시스템은 단순히 JWT만 쓰는 것 보다 효율적이고 확장성이 뛰어난 인증 방식을 제공한다. 예시 코드에서도 볼 수 있듯이, JWT를 사용하면 클라이언트와 서버 간에 인증 정보를 간편하게 주고받을 수 있었고, 서버는 세션 정보를 유지하지 않아도 된다. 그래서 분산 환경에서 특히 유리하고, 서버의 부하를 줄일 수 있는 장점이 있다.

 

여기에 Redis를 결합한 이유는 JWT의 유효성을 관리하고 무효화할 수 있는 기능을 더해 보안을 강화하기 위해서다. Redis는 빠른 조회 속도를 자랑하기 때문에, 요청마다 Redis에 저장된 토큰을 확인하고 필요할 때 무효화함으로써 로그아웃 처리가 가능해진다. 그래서 JWT의 무상태 특성을 유지하면서도, 세션 관리에 준하는 보안을 제공할 수 있다.

 

물론 이 방식에도 주의할 점이 있다. JWT가 유출되면 토큰 자체를 무효화하기 어려울 수 있기 때문에, HTTPS로 전송하고 Redis에서 검증하는 방식으로 보안을 강화해야 한다. 결국, 서비스의 특성과 요구사항에 맞게 JWT와 Redis를 적절히 결합하여 인증 시스템을 설계하는 것이 핵심이다.

 

이처럼 JWT와 Redis를 활용하면 기본적인 인증을 넘어서, 확장성과 보안이 중요한 환경에서 큰 장점을 발휘한다.

 

외에도 다양한 인증 기법들이 있다. 어느 것이 정답이라고 말할 수 없다. 기호에 맞게, 상황에 맞게 사용하는 것이 중요하다고 생각한다!!

 

흔하게 JWT와 같이 사용하는 RTR기법이 있는데, 이는 다른 포스트에서 좀 더 자세하게 다루어보도록 하겠다.