728x90
2025년 3월 16일에 작성됨
현재 내 프로젝트에 로그인 자동 유지 기능을 구현하고 싶었다.
대중화된 다른 웹애플리케이션들을 보면 대부분 갖고 있는 기능이자, 사용자 친화적 입장에서 보면 기본이 된다고 생각한다.
로그인 자동 유지 기능이란?
일반적으로 로그인하면 일정 시간이 지나면 세션이 만료되어 다시 로그인해야한다.
하지만 "로그인 유지" 옵션을 추가하면 사용자가 브라우저를 닫아도 일정 기간 동안 로그인 상태를 유지할 수 있다.
이를 위해 JWT 토큰의 만료 시간을 조절하여 유지 기간을 설정하는 방식으로 구현했다.
로그인 자동 유지 기능 구현 과정
LoginRequest
DTO 수정
사용자가 로그인할 때 "로그인 유지" 옵션을 선택하면 이를rememberMe
필드로 전달하도록LoginRequest
DTO를 수정했다.
package com.example.autofinder.dto;
import lombok.Getter;
import lombok.Setter;
@Getter
@Setter
public class LoginRequest {
private String username;
private String password;
private boolean rememberMe; // 로그인 유지 여부
}
JwtUtil.java
수정
"로그인 유지" 옵션이 활성화되면 토큰 만료 시간을 연장하도록 수정했다.
package com.example.autofinder.util;
import com.example.autofinder.security.CustomUserDetails;
import io.jsonwebtoken.*;
import io.jsonwebtoken.security.Keys;
import org.springframework.security.core.Authentication;
import org.springframework.stereotype.Component;
import java.security.Key;
import java.util.Base64;
import java.util.Date;
@Component
public class JwtUtil {
// Base64 인코딩된 시크릿 키 (환경변수로 저장 권장)
private static final String SECRET_KEY = "your secret key";
// 로그인 토큰 만료 시간 조정
private static final long EXPIRATION_TIME = 1000 * 60 * 60 * 24 * 7; // 7일 (자동 로그인)
private static final long SHORT_EXPIRATION_TIME = 1000 * 60 * 60; // 1시간 (일반 로그인)
// Base64 디코딩하여 키 변환
private final Key key = Keys.hmacShaKeyFor(Base64.getDecoder().decode(SECRET_KEY));
/**
* `Authentication` 객체를 받아서 JWT 생성 (만료 시간 선택 가능)
* @param authentication Spring Security 인증 객체
* @param rememberMe 로그인 유지 여부 (true → 7일, false → 1시간)
* @return JWT 토큰
*/
public String generateToken(Authentication authentication, boolean rememberMe) {
CustomUserDetails userDetails = (CustomUserDetails) authentication.getPrincipal();
String username = userDetails.getUsername();
Long userId = userDetails.getId(); // 사용자 ID 추가
long expirationTime = rememberMe ? EXPIRATION_TIME : SHORT_EXPIRATION_TIME;
return Jwts.builder()
.setSubject(username)
.claim("userId", userId) // 사용자 ID 추가
.claim("role", userDetails.getAuthorities().toString()) // 역할 추가
.setIssuedAt(new Date()) // 발급 시간
.setExpiration(new Date(System.currentTimeMillis() + expirationTime)) // 만료 시간 설정
.signWith(key, SignatureAlgorithm.HS256) // 서명
.compact();
}
/**
* JWT 검증 후 사용자 아이디 반환
* @param token 클라이언트가 보낸 JWT
* @return 사용자 아이디 (유효한 토큰인 경우), 유효하지 않으면 `null`
*/
public String validateToken(String token) {
try {
Claims claims = Jwts.parserBuilder()
.setSigningKey(key)
.build()
.parseClaimsJws(token)
.getBody();
return claims.getSubject(); // 사용자 아이디 반환
} catch (ExpiredJwtException e) {
System.out.println("토큰이 만료되었습니다.");
return null;
} catch (JwtException e) {
System.out.println("토큰이 유효하지 않습니다.");
return null;
}
}
/**
* JWT에서 사용자 ID 추출
* @param token 클라이언트가 보낸 JWT
* @return 사용자 ID (유효한 토큰인 경우), 유효하지 않으면 `null`
*/
public Long extractUserId(String token) {
try {
Claims claims = Jwts.parserBuilder()
.setSigningKey(key)
.build()
.parseClaimsJws(token)
.getBody();
return claims.get("userId", Long.class);
} catch (ExpiredJwtException e) {
System.out.println("토큰이 만료되었습니다.");
return null;
} catch (JwtException e) {
System.out.println("토큰이 유효하지 않습니다.");
return null;
}
}
/**
* JWT에서 역할(Role) 정보 추출
* @param token 클라이언트가 보낸 JWT
* @return 사용자의 역할 (USER or ADMIN), 유효하지 않으면 `null`
*/
public String extractRole(String token) {
try {
Claims claims = Jwts.parserBuilder()
.setSigningKey(key)
.build()
.parseClaimsJws(token)
.getBody();
return claims.get("role", String.class);
} catch (ExpiredJwtException e) {
System.out.println("토큰이 만료되었습니다.");
return null;
} catch (JwtException e) {
System.out.println("토큰이 유효하지 않습니다.");
return null;
}
}
}
AuthController.java
수정
토큰 생성 시rememberMe
값을 백엔드에서 받아서 JWT 생성 시 반영하도록 변경했다.
package com.example.autofinder.controller;
import com.example.autofinder.dto.LoginRequest;
import com.example.autofinder.model.User;
import com.example.autofinder.repository.UserRepository;
import com.example.autofinder.service.UserService;
import com.example.autofinder.util.JwtUtil;
import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.web.bind.annotation.*;
import java.util.HashMap;
import java.util.Map;
@RestController
@RequestMapping("/api/auth")
@RequiredArgsConstructor
public class AuthController {
private final AuthenticationManager authenticationManager;
private final UserService userService;
private final JwtUtil jwtUtil;
private final UserRepository userRepository;
// 회원가입 API
@PostMapping("/register")
public ResponseEntity<?> register(@RequestBody Map<String, String> request) {
String username = request.get("username");
String password = request.get("password");
try {
User newUser = userService.registerUser(username, password); // role 제거
return ResponseEntity.ok(newUser);
} catch (IllegalArgumentException e) {
return ResponseEntity.badRequest().body(e.getMessage());
}
}
// 로그인 API (JWT 토큰 발급)
@PostMapping("/login")
public ResponseEntity<?> login(@RequestBody LoginRequest request) {
boolean rememberMe = request.isRememberMe(); // "로그인 유지" 여부 받기
Authentication authentication = authenticationManager.authenticate(
new UsernamePasswordAuthenticationToken(request.getUsername(), request.getPassword()));
SecurityContextHolder.getContext().setAuthentication(authentication);
String jwt = jwtUtil.generateToken(authentication, rememberMe); // 수정된 `generateToken()` 사용
User user = userRepository.findByUsername(request.getUsername())
.orElseThrow(() -> new UsernameNotFoundException("User Not Found"));
Map<String, Object> response = new HashMap<>();
response.put("token", jwt);
response.put("userId", user.getId());
return ResponseEntity.ok(response);
}
@PostMapping("/logout")
public ResponseEntity<?> logout() {
return ResponseEntity.ok("로그아웃되었습니다. 클라이언트 측에서 토큰을 삭제하세요.");
}
// 로그인한 사용자 정보 조회
@GetMapping("/me")
public ResponseEntity<?> getCurrentUser(@AuthenticationPrincipal UserDetails userDetails) {
if (userDetails == null) {
return ResponseEntity.status(401).body("인증되지 않은 사용자입니다.");
}
// DB에서 사용자 정보 조회
User user = userRepository.findByUsername(userDetails.getUsername()).orElse(null);
if (user == null) {
return ResponseEntity.status(404).body("사용자 정보를 찾을 수 없습니다.");
}
// 사용자 정보 반환
return ResponseEntity.ok(user);
}
// 이메일(아이디) 중복 확인 API
@GetMapping("/check-username")
public ResponseEntity<?> checkUsername(@RequestParam String username) {
boolean exists = userRepository.findByUsername(username).isPresent();
return ResponseEntity.ok(exists ? "이미 사용 중인 아이디입니다." : "사용 가능한 아이디입니다.");
}
}
Login.js
수정
사용자가 "로그인 유지" 체크박스를 선택할 수 있도록 UI를 추가하고, API 요청 시rememberMe
값을 함께 전달하도록 수정했다.
import { useState } from "react";
import { useNavigate } from "react-router-dom";
import axios from "axios";
const Login = ({ setUserId }) => {
const [formData, setFormData] = useState({ username: "", password: "", rememberMe: false });
const [message, setMessage] = useState("");
const navigate = useNavigate();
const handleChange = (e) => {
const { name, value, type, checked } = e.target;
setFormData({ ...formData, [name]: type === "checkbox" ? checked : value });
};
const handleSubmit = async (e) => {
e.preventDefault();
try {
const response = await axios.post("http://localhost:8080/api/auth/login", formData);
const { token, userId } = response.data;
// "로그인 유지" 체크 여부에 따라 저장 방식 변경
if (formData.rememberMe) {
localStorage.setItem("token", token);
localStorage.setItem("userId", userId);
} else {
sessionStorage.setItem("token", token);
sessionStorage.setItem("userId", userId);
}
setUserId(userId);
setMessage("로그인 성공! 차량 목록으로 이동합니다.");
setTimeout(() => navigate("/"), 1000);
} catch (error) {
setMessage("로그인 실패: 아이디 또는 비밀번호를 확인하세요.");
}
};
return (
<div className="max-w-md mx-auto mt-10 p-6 bg-white rounded-lg shadow-lg">
<h2 className="text-2xl font-bold text-center mb-4">로그인</h2>
{message && <p className="text-center text-red-500">{message}</p>}
<form onSubmit={handleSubmit}>
<input
type="text"
name="username"
placeholder="아이디"
value={formData.username}
onChange={handleChange}
required
className="w-full p-2 border rounded mt-2"
/>
<input
type="password"
name="password"
placeholder="비밀번호"
value={formData.password}
onChange={handleChange}
required
className="w-full p-2 border rounded mt-2"
/>
<div className="flex items-center mt-2">
<input
type="checkbox"
name="rememberMe"
checked={formData.rememberMe}
onChange={handleChange}
className="mr-2"
/>
<label>로그인 유지</label>
</div>
<button
type="submit"
className="w-full bg-green-500 text-white p-2 rounded mt-4 hover:bg-green-600"
>
로그인
</button>
</form>
</div>
);
};
export default Login;
결론
생각보다 간단하게 로그인 유지 기능을 구현했다.
이번 기능 구현에서 핵심은 각 파일의 역할과 구조를 알아보는 것이었다.
각 파일이 하는 역할을 보다 더 자세히 알게되었고, 로그인 유지 기능이 어떻게 동작하는지에 대해 보다 더 정확하게 알게 되었다.
위에서 구현한 로그인 유지 기능은 사용자가 로그인 유지를 선택하면 7일동안 로그인 상태가 유지되고, 그렇지 않으면 1시간 후에 로그아웃 되는 로직이다.
'SIDE PROJECT > AUTOFINDER' 카테고리의 다른 글
추천 시스템 관련 로직 (1) | 2025.04.18 |
---|---|
데이터 분석 및 시각화 구현 (0) | 2025.04.18 |
React 프로젝트에서 로그아웃 후 즐겨찾기가 유지되는 문제 해결하기 (0) | 2025.04.18 |
관심 차량(즐겨찾기) 기능 구현 문제 해결 과정 (0) | 2025.04.18 |
관심(즐겨찾기) 자동차 저장 기능 구현 (2) | 2025.04.17 |