💻 뚝딱뚝딱/북북클럽

[개발일지 #007] 로그인 구현2 (refreshToken 도입) / 로그아웃

뚜루리 2025. 4. 23. 11:06
728x90
320x100

🎯 오늘의 목표

  • 로그인 구현
  • 로그아웃 구현

⚙️ 진행한 작업

  • refreshToken 도입

🛠️ 개발내용

📌  리프레시 토큰을 도입하는 이유

  • 현재 AccessToken만 있고, RefreshToken은 없는 상태.
    • AccessToken의 만료시간이 짧다면, 리프레시 토큰 없이 사용자는 자주 로그인을 다시 해야 하는 상황.

 

✅ AccessToken vs RefreshToken 

항목 Access Token Refresh Token
목적 API 요청 인증 (매 요청마다 사용) AccessToken이 만료되었을 때 새로운 AccessToken을 발급받기 위해 사용
유효 기간 짧음 (보통 15분~1시간) 김 (보통 7일~2주)
저장 위치 클라이언트 (localStorage, sessionStorage, 또는 쿠키) 보안상 서버(DB/Redis) 에 저장하는 것이 권장됨
유출 시 피해 짧은 시간 동안만 악용 가능 매우 심각함 → 서버 저장 및 관리 필수
재발급 기능 없음 → 만료되면 재로그인 필요 AccessToken 재발급 가능
보안 제어 서버는 관리하지 않음 (stateless) 서버에서 직접 관리 가능 (blacklist, 삭제 등)
인증 방식 요청마다 헤더에 담아서 보내기 (Authorization: Bearer 토큰) 주로 재발급 API에만 사용 (/token/refresh)

 

🔄 둘 다 왜 필요한가?

✅ AccessToken만 쓴다면?

  • 유출 시 제한된 시간만 공격 가능 (보안상 안전)
  • BUT, 짧게 설정하면 사용자는 계속 로그인해야 해서 불편

✅ RefreshToken만 쓴다면?

  • 매번 요청마다 RefreshToken 쓰는 건 위험!
  • 유출되면 긴 시간 동안 무한 재발급 가능 → 큰 보안 리스크

🔐 그래서 조합해서 사용

  • AccessToken: API 요청 인증에 사용 (빠르고 가볍게)
  • RefreshToken: AccessToken 만료됐을 때만 사용 (드물게, 안전하게)

AccessToken + RefreshToken 전략은 보안성과 사용자 경험을 둘 다 챙길 수 있음.

 


✅ 전체 구현 순서

1단계. Redis 설정 (yml, build.gradle, RedisTemplate)
2단계. RefreshTokenService 구현 (저장/조회/삭제)
3단계. JwtUtil에 refreshToken 생성 메서드 추가
4단계. UserController.login 수정 → RefreshToken 발급 + 저장
5단계. /token/refresh API 추가
6단계. 로그아웃 API에서 Redis 토큰 삭제

 

📌 Redis 설정

  • 그러나 나는 이미 레디스를 적용해놓은 상태라서 생략 가능.

1) build.gradle 의존성 추가

implementation 'org.springframework.boot:spring-boot-starter-data-redis'

2) application.yml 설정

spring:
  data:
    redis:
      host: localhost
      port: 6379

 

2-3) RedisTemplate 설정

  • Spring Boot는 RedisTemplate을 기본으로 자동 구성해주기 때문에 만들지 않아도 됨. 선택사항임.
@Configuration
public class RedisConfig {
    @Bean
    public RedisTemplate<String, String> redisTemplate(RedisConnectionFactory connectionFactory) {
        RedisTemplate<String, String> template = new RedisTemplate<>();
        template.setConnectionFactory(connectionFactory);
        return template;
    }
}

 

그런데도 직접 설정한 이유

이유 설명
1️⃣ 제네릭 타입 지정 가능 RedisTemplate<String, String>처럼 명확하게 타입 지정하면 형변환 실수 방지
2️⃣ Serializer 커스터마이징 가능 문자열뿐 아니라 객체 저장 시 Jackson, JSON, Jdk 직렬화 등 지정 가능
3️⃣ 커스텀 설정을 분리 관리 나중에 Redis 구성(포트, 커넥션 옵션 등)을 중앙에서 관리 가능

 

 

📌 JwtRefreshTokenService.java

package ddururi.bookbookclub.global.jwt;

import lombok.RequiredArgsConstructor;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Service;

import java.util.concurrent.TimeUnit;

@Service
@RequiredArgsConstructor
public class JwtRefreshTokenService {

    private final RedisTemplate<String, String> redisTemplate;
    private final JwtUtil jwtUtil;
    private final String PREFIX = "refresh:user:";

    public void save(Long userId, String token, long duration, TimeUnit unit) {
        redisTemplate.opsForValue().set(PREFIX + userId, token, duration, unit);
    }

    public String get(Long userId) {
        return redisTemplate.opsForValue().get(PREFIX + userId);
    }

    public void delete(Long userId) {
        redisTemplate.delete(PREFIX + userId);
    }

    public boolean isValid(Long userId, String token) {
        String saved = get(userId);
        return saved != null
                && saved.equals(token)
                && jwtUtil.validateToken(token);
    }
}

 

 

📌 JwtUtil에 RefreshToken 생성 메서드 추가 (JwtUtil.java)

public String createRefreshToken() {
    return Jwts.builder()
        .setIssuedAt(new Date())
        .setExpiration(new Date(System.currentTimeMillis() + 1000L * 60 * 60 * 24 * 7)) // 7일
        .signWith(key, SignatureAlgorithm.HS256)
        .compact();
}

 

 

📌  로그인 시 RefreshToken 발급 및 저장

UserController.java

   private static final long REFRESH_EXPIRATION_DAYS = 7;
   
   //로그인
    @PostMapping("/login")
    public ResponseEntity<ApiResponse<LoginResponse>> login(@RequestBody @Valid UserLoginRequest request) {
        UserResponse user = userService.login(request);
        String accessToken = jwtUtil.createToken(user.getEmail());
        String refreshToken = jwtUtil.createRefreshToken();
        
        refreshTokenService.save(user.getId(), refreshToken, REFRESH_EXPIRATION_DAYS, TimeUnit.DAYS);

        LoginResponse response = new LoginResponse(accessToken, refreshToken, user); // 생성자 변경 필요
        return ResponseEntity.ok(ApiResponse.success(response));
    }

LoginResponse.java

@Getter
@AllArgsConstructor
public class LoginResponse {
    private String accessToken;
    private String refreshToken;
    private UserResponse user;
}

 

 

📌  /token/refresh API 추가

RefreshTokenRequest.java (요청 DTO)

@Getter
public class RefreshTokenRequest {
    private Long userId;
    private String refreshToken;
}

UserController.java

@PostMapping("/token/refresh")
public ResponseEntity<ApiResponse<LoginResponse>> refresh(@RequestBody RefreshTokenRequest request) {
    if (!refreshTokenService.isValid(request.getUserId(), request.getRefreshToken())) {
        return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body(ApiResponse.fail("Refresh Token이 유효하지 않습니다"));
    }

    User user = userService.findById(request.getUserId());
    String newAccessToken = jwtUtil.createToken(user.getEmail());

    return ResponseEntity.ok(ApiResponse.success(new LoginResponse(newAccessToken, null, UserResponse.from(user))));
}

 

 

📌  로그아웃 시 RefreshToken 삭제

@PostMapping("/logout")
public ResponseEntity<ApiResponse<Void>> logout(@AuthenticationPrincipal CustomUserDetails userDetails) {
    refreshTokenService.delete(userDetails.getId());
    return ResponseEntity.ok(ApiResponse.success(null));
}
728x90
320x100