💻 뚝딱뚝딱/북북클럽
[개발일지 #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