
웹 서비스에서 "로그인 상태를 유지"하는 방법은 크게 두 가지가 있다.
세션 방식 (전통적인 방식)
1. 사용자가 로그인
2. 서버가 "세션"이라는 것을 만들어서 서버 메모리에 저장
3. 서버가 세션 ID를 쿠키에 담아 클라이언트에게 전달
4. 클라이언트는 매 요청마다 쿠키(세션 ID)를 보냄
5. 서버는 세션 ID로 서버 메모리를 조회해서 "이 사용자가 누구인지" 확인
문제점: 서버가 여러 대면 세션 공유가 어렵고, 서버 메모리를 계속 사용한다.
토큰 방식 (JWT)
1. 사용자가 로그인
2. 서버가 "토큰"이라는 문자열을 만들어서 클라이언트에게 전달
3. 클라이언트는 이 토큰을 저장해두고, 매 요청마다 헤더에 담아서 보냄
4. 서버는 토큰 자체를 해석해서 "이 사용자가 누구인지" 확인 (별도 저장소 조회 없음)
장점: 서버에 상태를 저장하지 않아도 되고, 서버가 여러 대여도 문제없다.
JWT(JSON Web Token)는 세 부분으로 구성된 문자열이다:
xxxxx.yyyyy.zzzzz
[헤더].[페이로드].[서명]
실제 JWT 예시:
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxIiwibG9naW5JZCI6InRlc3R1c2VyIiwiaWF0IjoxNzAxMjM0NTY3LCJleHAiOjE3MDEzMjA5Njd9.K7P8m2w9Rt5n3J6x1v2Q4c8F0Y6H3L9M5K2N8P1R4S7
중요한 점: 페이로드는 암호화가 아니라 단순 인코딩이다. 누구나 디코딩해서 내용을 볼 수 있다. 하지만 서명 덕분에 내용을 바꾸면 서버가 알아챌 수 있다.
우리 프로젝트에서 인증과 관련된 파일들을 정리하면 다음과 같다:
src/main/java/com/ssafy/finalproject/
├── config/
│ └── SecurityConfig.java # Spring Security 설정 (어떤 API가 인증 필요한지 등)
│
├── controller/
│ └── AuthController.java # 회원가입, 로그인 API 엔드포인트
│
├── service/
│ └── AuthService.java # 회원가입, 로그인 비즈니스 로직
│
├── security/
│ ├── JwtUtil.java # JWT 토큰 생성/검증 유틸리티
│ └── JwtAuthenticationFilter.java # 모든 요청에서 JWT 검증하는 필터
│
├── util/
│ └── SecurityUtil.java # 현재 로그인된 사용자 ID 가져오는 유틸리티
│
├── model/
│ ├── entity/
│ │ └── User.java # 사용자 정보 엔티티
│ ├── dao/
│ │ └── UserDao.java # 데이터베이스 접근 인터페이스
│ └── dto/
│ ├── request/
│ │ ├── SignupRequest.java # 회원가입 요청 데이터
│ │ └── LoginRequest.java # 로그인 요청 데이터
│ └── response/
│ ├── SignupResponse.java # 회원가입 응답 데이터
│ └── LoginResponse.java # 로그인 응답 데이터
src/main/resources/
└── mapper/user/
└── UserMapper.xml # 실제 SQL 쿼리 정의
각 파일의 역할을 간단히 설명하면:
| 파일 | 역할 |
|---|---|
AuthController | 클라이언트의 HTTP 요청을 받는 문 역할 |
AuthService | 실제 회원가입/로그인 로직을 처리 |
JwtUtil | JWT 토큰을 만들고, 검증하고, 정보 추출 |
JwtAuthenticationFilter | 모든 API 요청을 가로채서 JWT 확인 |
SecurityConfig | 어떤 API가 인증 필요하고 안 필요한지 설정 |
UserDao + UserMapper.xml | 데이터베이스와 실제 통신 |
회원가입의 전체 흐름을 따라가보자.
프론트엔드에서 다음과 같은 HTTP 요청을 보낸다:
POST /api/auth/signup
Content-Type: application/json
{
"loginId": "testuser",
"password": "mypassword123",
"name": "홍길동",
"birthDate": "1990-01-15",
"gender": "M",
"calendarType": "solar"
}
SignupRequest.java가 이 JSON 데이터를 자바 객체로 받아준다:
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class SignupRequest {
@NotBlank(message = "로그인 아이디는 필수입니다.")
@JsonProperty("loginId")
private String loginId;
@NotBlank(message = "비밀번호는 필수입니다.")
@JsonProperty("password")
private String password;
@NotBlank(message = "이름은 필수입니다.")
@JsonProperty("name")
private String name;
@NotNull(message = "생년월일은 필수입니다.")
@JsonProperty("birthDate")
private LocalDate birthDate;
@NotBlank(message = "성별은 필수입니다.")
@Pattern(regexp = "^[MF]$", message = "성별은 M 또는 F여야 합니다.")
@JsonProperty("gender")
private String gender;
@NotBlank(message = "달력 유형은 필수입니다.")
@Pattern(regexp = "^(solar|lunarGeneral|lunarLeap)$",
message = "달력 유형은 solar, lunarGeneral, lunarLeap 중 하나여야 합니다.")
@JsonProperty("calendarType")
private String calendarType;
}
여기서 @NotBlank, @Pattern 같은 어노테이션이 입력값을 검증한다. 만약 loginId가 비어있으면 자동으로 에러가 발생한다.
@Tag(name = "1. 인증 API", description = "회원가입, 로그인, 사용자 정보 관리 API")
@RestController
@RequestMapping("/api/auth")
@RequiredArgsConstructor
public class AuthController {
private final AuthService authService;
@Operation(summary = "1.1 회원가입", description = "새로운 사용자 계정을 생성합니다.")
@PostMapping("/signup")
public ResponseEntity<SignupResponse> signup(@Valid @RequestBody SignupRequest request) {
SignupResponse response = authService.signup(request);
return ResponseEntity.status(HttpStatus.CREATED).body(response);
}
}
코드를 한 줄씩 해석하면:
@RestController: 이 클래스가 REST API 컨트롤러라는 표시@RequestMapping("/api/auth"): 이 컨트롤러의 모든 API는 /api/auth로 시작@RequiredArgsConstructor: authService 필드를 자동으로 주입받음@PostMapping("/signup"): POST 방식의 /api/auth/signup 요청을 처리@Valid: SignupRequest의 검증 어노테이션들을 실행@RequestBody: HTTP 요청 본문(JSON)을 SignupRequest 객체로 변환HttpStatus.CREATED: 성공 시 201 상태 코드 반환 (새로운 리소스 생성됨)@Service
@RequiredArgsConstructor
@Transactional
public class AuthService {
private final UserDao userDao;
private final PasswordEncoder passwordEncoder;
private final JwtUtil jwtUtil;
// 회원가입
public SignupResponse signup(SignupRequest request) {
// 1단계: 로그인 아이디 중복 체크
if (userDao.countByLoginId(request.getLoginId()) > 0) {
throw new ConflictException("이미 존재하는 아이디입니다.");
}
// 2단계: 비밀번호 암호화
String encodedPassword = passwordEncoder.encode(request.getPassword());
// 3단계: User 엔티티 생성
User user = User.builder()
.loginId(request.getLoginId())
.password(encodedPassword) // 암호화된 비밀번호 저장!
.name(request.getName())
.birthDate(request.getBirthDate())
.gender(request.getGender().toUpperCase())
.calendarType(request.getCalendarType())
.build();
// 4단계: 데이터베이스에 저장
userDao.insertUser(user);
// 5단계: 응답 생성
return SignupResponse.builder()
.userId(user.getUserId())
.loginId(user.getLoginId())
.name(user.getName())
.gender(user.getGender())
.birthDate(user.getBirthDate())
.calendarType(user.getCalendarType())
.message("회원가입이 완료되었습니다.")
.build();
}
}
핵심 포인트를 짚어보면:
1단계 - 중복 체크: 이미 같은 아이디가 있는지 확인한다.
2단계 - 비밀번호 암호화: passwordEncoder.encode()를 사용한다. 절대로 비밀번호를 평문으로 저장하면 안 된다. BCryptPasswordEncoder가 비밀번호를 해시값으로 변환한다.
원본: mypassword123
암호화 후: $2a$10$N9qo8uLOickgx2ZMRZoMye... (이런 식의 복잡한 문자열)
3단계 - User 객체 생성: Builder 패턴으로 User 객체를 만든다.
4단계 - DB 저장: MyBatis를 통해 데이터베이스에 저장한다.
UserDao.java는 인터페이스다:
@Mapper
public interface UserDao {
// 회원가입
void insertUser(User user);
// 로그인 아이디 중복 체크
int countByLoginId(@Param("loginId") String loginId);
// ... 다른 메서드들
}
실제 SQL은 UserMapper.xml에 있다:
<!-- 회원가입 -->
<insert id="insertUser" parameterType="com.ssafy.finalproject.model.entity.User"
useGeneratedKeys="true" keyProperty="userId">
INSERT INTO users (login_id, password, name, birth_date, gender, calendar_type, created_date)
VALUES (#{loginId}, #{password}, #{name}, #{birthDate}, #{gender}, #{calendarType}, NOW())
</insert>
<!-- 로그인 아이디 중복 체크 -->
<select id="countByLoginId" parameterType="string" resultType="int">
SELECT COUNT(*)
FROM users
WHERE login_id = #{loginId}
AND deleted_date IS NULL
</select>
useGeneratedKeys="true" keyProperty="userId"는 DB가 자동 생성한 ID를 User 객체의 userId 필드에 넣어달라는 의미다.
클라이언트 서버
│
│ POST /api/auth/signup
│ { loginId, password, ... }
│ ─────────────────────────────► AuthController
│ │
│ ▼
│ AuthService.signup()
│ │
│ ├─► 중복 체크 (UserDao.countByLoginId)
│ │
│ ├─► 비밀번호 암호화 (BCrypt)
│ │
│ └─► DB 저장 (UserDao.insertUser)
│ │
│ ▼
│ 201 Created SignupResponse
│ { userId, loginId, ... }
│ ◄─────────────────────────────
│
로그인은 회원가입보다 조금 더 복잡하다. JWT 토큰을 생성해서 돌려줘야 하기 때문이다.
POST /api/auth/login
Content-Type: application/json
{
"loginId": "testuser",
"password": "mypassword123"
}
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class LoginRequest {
@NotBlank(message = "로그인 아이디는 필수입니다.")
private String loginId;
@NotBlank(message = "비밀번호는 필수입니다.")
private String password;
}
@Operation(summary = "1.2 로그인", description = "사용자 로그인 및 JWT 토큰 발급")
@PostMapping("/login")
public ResponseEntity<LoginResponse> login(@Valid @RequestBody LoginRequest request) {
LoginResponse response = authService.login(request);
return ResponseEntity.ok(response);
}
// 로그인
public LoginResponse login(LoginRequest request) {
// 1단계: 사용자 조회
User user = userDao.findByLoginId(request.getLoginId())
.orElseThrow(() -> new ResourceNotFoundException("사용자를 찾을 수 없습니다."));
// 2단계: 비밀번호 검증
if (!passwordEncoder.matches(request.getPassword(), user.getPassword())) {
throw new UnauthorizedException("비밀번호가 일치하지 않습니다.");
}
// 3단계: JWT 토큰 생성
String token = jwtUtil.generateToken(user.getUserId(), user.getLoginId());
// 4단계: 응답 생성
return LoginResponse.builder()
.userId(user.getUserId())
.loginId(user.getLoginId())
.name(user.getName())
.birthDate(user.getBirthDate())
.gender(user.getGender())
.calendarType(user.getCalendarType())
.token(token) // JWT 토큰을 응답에 포함!
.message("로그인 성공")
.build();
}
핵심 포인트:
1단계 - 사용자 조회: 로그인 아이디로 DB에서 사용자를 찾는다.
2단계 - 비밀번호 검증: passwordEncoder.matches(입력한 비밀번호, DB에 저장된 암호화된 비밀번호)로 비교한다. 직접 비교하는 게 아니라 암호화 방식에 맞게 검증한다.
3단계 - JWT 토큰 생성: 가장 중요한 부분. jwtUtil.generateToken()을 호출해서 토큰을 만든다.
4단계 - 응답: 토큰을 포함한 응답을 클라이언트에게 돌려준다.
{
"userId": 1,
"loginId": "testuser",
"name": "홍길동",
"birthDate": "1990-01-15",
"gender": "M",
"calendarType": "solar",
"token": "eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxIiwibG9naW5JZCI6InRlc3R1c2VyIiwiaWF0IjoxNzAxMjM0NTY3LCJleHAiOjE3MDEzMjA5Njd9.xxxxx",
"message": "로그인 성공"
}
클라이언트는 이 token 값을 저장해두고, 앞으로 인증이 필요한 API를 호출할 때마다 사용한다.
@Component
public class JwtUtil {
private final SecretKey secretKey;
private final long expiration;
public JwtUtil(
@Value("${jwt.secret}") String secret,
@Value("${jwt.expiration}") long expiration) {
this.secretKey = Keys.hmacShaKeyFor(secret.getBytes(StandardCharsets.UTF_8));
this.expiration = expiration;
}
// JWT 토큰 생성
public String generateToken(Long userId, String loginId) {
Date now = new Date();
Date expiryDate = new Date(now.getTime() + expiration);
return Jwts.builder()
.setSubject(String.valueOf(userId)) // 사용자 ID를 subject로
.claim("loginId", loginId) // 추가 정보: 로그인 ID
.setIssuedAt(now) // 토큰 발급 시간
.setExpiration(expiryDate) // 토큰 만료 시간
.signWith(secretKey, SignatureAlgorithm.HS256) // 서명
.compact(); // 최종 문자열로 변환
}
// JWT 토큰에서 사용자 ID 추출
public Long getUserIdFromToken(String token) {
Claims claims = getClaims(token);
return Long.parseLong(claims.getSubject());
}
// JWT 토큰에서 로그인 ID 추출
public String getLoginIdFromToken(String token) {
Claims claims = getClaims(token);
return claims.get("loginId", String.class);
}
// JWT 토큰 유효성 검증
public boolean validateToken(String token) {
try {
getClaims(token);
return true;
} catch (Exception e) {
return false;
}
}
// JWT 토큰에서 Claims 추출
private Claims getClaims(String token) {
return Jwts.parser()
.verifyWith(secretKey)
.build()
.parseSignedClaims(token)
.getPayload();
}
}
public String generateToken(Long userId, String loginId) {
Date now = new Date(); // 현재 시간
Date expiryDate = new Date(now.getTime() + expiration); // 만료 시간 계산
return Jwts.builder()
.setSubject(String.valueOf(userId)) // "1" (사용자 ID)
.claim("loginId", loginId) // "testuser"
.setIssuedAt(now) // 발급 시간
.setExpiration(expiryDate) // 만료 시간
.signWith(secretKey, SignatureAlgorithm.HS256) // 비밀키로 서명
.compact(); // 문자열로 완성
}
만들어진 토큰의 페이로드(Payload)를 디코딩하면:
{
"sub": "1",
"loginId": "testuser",
"iat": 1701234567,
"exp": 1701320967
}
sub (subject): 토큰의 주체, 여기서는 사용자 IDloginId: 커스텀 클레임으로 추가한 로그인 아이디iat (issued at): 토큰 발급 시간 (Unix timestamp)exp (expiration): 토큰 만료 시간 (Unix timestamp)application.yaml 파일에 설정되어 있다:
jwt:
secret: "your-256-bit-secret-key-here-must-be-long-enough"
expiration: 86400000 # 24시간 (밀리초)
이 비밀키는 절대로 외부에 노출되면 안 된다. 노출되면 누구나 유효한 토큰을 만들 수 있다.
public boolean validateToken(String token) {
try {
getClaims(token); // 파싱 시도
return true; // 성공하면 유효한 토큰
} catch (Exception e) {
return false; // 실패하면 유효하지 않은 토큰
}
}
private Claims getClaims(String token) {
return Jwts.parser()
.verifyWith(secretKey) // 비밀키로 서명 검증
.build()
.parseSignedClaims(token) // 토큰 파싱
.getPayload(); // 페이로드(Claims) 반환
}
검증 시 다음 항목들이 자동으로 확인된다:
여기서부터가 진짜 핵심이다. 로그인 후 받은 토큰을 어떻게 사용하고, 서버는 어떻게 확인하는지 알아보자.
@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class SecurityConfig {
private final JwtAuthenticationFilter jwtAuthenticationFilter;
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http
.cors(cors -> {})
.csrf(csrf -> csrf.disable())
.sessionManagement(session ->
session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.authorizeHttpRequests(auth -> auth
// 인증 불필요 API
.requestMatchers("/api/auth/signup", "/api/auth/login").permitAll()
// Swagger UI 경로 허용
.requestMatchers(
"/swagger-ui/**",
"/v3/api-docs/**"
).permitAll()
// 업로드된 이미지 접근 허용
.requestMatchers("/uploads/**").permitAll()
// 나머지 API는 인증 필요
.requestMatchers("/api/**").authenticated()
.anyRequest().permitAll()
)
.addFilterBefore(jwtAuthenticationFilter,
UsernamePasswordAuthenticationFilter.class);
return http.build();
}
}
중요한 설정들:
.csrf(csrf -> csrf.disable()): CSRF 보호 비활성화. JWT를 쓰면 CSRF 공격에 자연스럽게 방어되므로 불필요하다.
.sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)): 세션을 사용하지 않겠다는 설정. JWT는 무상태(stateless)로 동작한다.
permitAll(): 누구나 접근 가능
/api/auth/signup: 회원가입/api/auth/login: 로그인/swagger-ui/**: API 문서/uploads/**: 업로드된 파일.authenticated(): 인증된 사용자만 접근 가능
/api/**: 나머지 모든 API.addFilterBefore(jwtAuthenticationFilter, ...): 모든 요청 전에 JWT 필터를 실행
@Component
@RequiredArgsConstructor
public class JwtAuthenticationFilter extends OncePerRequestFilter {
private final JwtUtil jwtUtil;
@Override
protected void doFilterInternal(HttpServletRequest request,
HttpServletResponse response,
FilterChain filterChain)
throws ServletException, IOException {
try {
// 1단계: 요청에서 JWT 추출
String jwt = getJwtFromRequest(request);
// 2단계: JWT가 있고 유효하면
if (StringUtils.hasText(jwt) && jwtUtil.validateToken(jwt)) {
// 3단계: 토큰에서 사용자 ID 추출
Long userId = jwtUtil.getUserIdFromToken(jwt);
// 4단계: 인증 객체 생성
UsernamePasswordAuthenticationToken authentication =
new UsernamePasswordAuthenticationToken(
userId, // principal: 사용자 ID
null, // credentials: 비밀번호 (필요 없음)
Collections.emptyList() // authorities: 권한 목록
);
authentication.setDetails(
new WebAuthenticationDetailsSource().buildDetails(request)
);
// 5단계: SecurityContext에 인증 정보 저장
SecurityContextHolder.getContext().setAuthentication(authentication);
}
} catch (Exception ex) {
logger.error("Could not set user authentication in security context", ex);
}
// 다음 필터로 요청 전달
filterChain.doFilter(request, response);
}
// Authorization 헤더에서 Bearer 토큰 추출
private String getJwtFromRequest(HttpServletRequest request) {
String bearerToken = request.getHeader("Authorization");
if (StringUtils.hasText(bearerToken) && bearerToken.startsWith("Bearer ")) {
return bearerToken.substring(7); // "Bearer " 제거하고 토큰만 반환
}
return null;
}
}
단계별로 설명하면:
1단계: HTTP 요청의 Authorization 헤더에서 토큰을 꺼낸다.
Authorization: Bearer eyJhbGciOiJIUzI1NiJ9.xxxxx.yyyyy
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
이 부분만 추출
2단계: 토큰이 있고 유효한지 확인한다.
3단계: 토큰에서 사용자 ID를 추출한다.
4단계: Spring Security가 이해할 수 있는 인증 객체를 만든다.
5단계: SecurityContextHolder에 인증 정보를 저장한다. 이게 핵심이다. 이후 컨트롤러나 서비스에서 "현재 로그인한 사용자가 누구인지" 알 수 있게 된다.
public class SecurityUtil {
// 현재 인증된 사용자의 ID를 가져옴
public static Long getCurrentUserId() {
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
if (authentication == null || !authentication.isAuthenticated()) {
throw new RuntimeException("인증되지 않은 사용자입니다.");
}
return (Long) authentication.getPrincipal();
}
}
JWT 필터가 SecurityContextHolder에 저장해둔 인증 정보를 여기서 꺼내온다.
@Operation(summary = "1.4 사용자 정보 조회",
description = "현재 로그인한 사용자 정보를 조회합니다.")
@SecurityRequirement(name = "Bearer Authentication")
@GetMapping("/me")
public ResponseEntity<UserInfoResponse> getUserInfo() {
Long userId = SecurityUtil.getCurrentUserId(); // 현재 로그인된 사용자 ID
UserInfoResponse response = authService.getUserInfo(userId);
return ResponseEntity.ok(response);
}
클라이언트가 이 API를 호출할 때:
GET /api/auth/me
Authorization: Bearer eyJhbGciOiJIUzI1NiJ9.xxxxx.yyyyy
처리 흐름:
1. JwtAuthenticationFilter가 요청 가로챔
2. Authorization 헤더에서 토큰 추출
3. 토큰 검증 및 userId 추출 (예: 1)
4. SecurityContextHolder에 userId 저장
5. AuthController.getUserInfo() 실행
6. SecurityUtil.getCurrentUserId()로 userId(1) 조회
7. authService.getUserInfo(1)로 사용자 정보 조회
8. 응답 반환
토큰이 없거나 유효하지 않으면 SecurityContextHolder에 인증 정보가 저장되지 않는다.
그 상태로 .authenticated()가 설정된 API에 접근하면 Spring Security가 자동으로 401 Unauthorized 에러를 반환한다.
JWT는 서버에 저장되지 않는다. 토큰 자체에 모든 정보가 담겨있고, 서버는 단순히 검증만 한다.
따라서 "이 토큰을 무효화해라"라고 서버에 명령할 방법이 없다. 토큰이 만료될 때까지 계속 유효하다.
@Operation(summary = "1.3 로그아웃",
description = "사용자 로그아웃 (클라이언트에서 토큰 제거)")
@SecurityRequirement(name = "Bearer Authentication")
@PostMapping("/logout")
public ResponseEntity<ApiResponse> logout() {
return ResponseEntity.ok(ApiResponse.builder()
.message("로그아웃 성공")
.build());
}
서버에서는 사실상 아무것도 안 한다. 그냥 "로그아웃 성공"이라는 메시지만 보내준다.
실제 로그아웃은 클라이언트(프론트엔드)에서 처리한다:
만약 더 엄격한 로그아웃이 필요하다면:
하지만 우리 프로젝트는 간단하게 클라이언트 측 삭제 방식을 사용한다.
[클라이언트] [서버]
│
│ POST /api/auth/signup
│ {loginId, password, name, ...}
│ ───────────────────────────────────────────────►
│
│ SecurityConfig: permitAll() - 인증 불필요
│ │
│ ▼
│ AuthController.signup()
│ │
│ ▼
│ AuthService.signup()
│ ├─ 중복 체크
│ ├─ 비밀번호 암호화 (BCrypt)
│ └─ DB 저장
│ │
│ 201 Created ▼
│ {userId, loginId, message: "회원가입 완료"}
│ ◄───────────────────────────────────────────────
│
[클라이언트] [서버]
│
│ POST /api/auth/login
│ {loginId, password}
│ ───────────────────────────────────────────────►
│
│ SecurityConfig: permitAll() - 인증 불필요
│ │
│ ▼
│ AuthController.login()
│ │
│ ▼
│ AuthService.login()
│ ├─ 사용자 조회
│ ├─ 비밀번호 검증
│ └─ JWT 토큰 생성 (JwtUtil)
│ │
│ 200 OK ▼
│ {userId, token: "eyJ...", message: "로그인 성공"}
│ ◄───────────────────────────────────────────────
│
│ * 클라이언트는 token을 localStorage나 메모리에 저장
│
[클라이언트] [서버]
│
│ GET /api/auth/me
│ Authorization: Bearer eyJhbGciOiJIUzI1NiJ9...
│ ───────────────────────────────────────────────►
│
│ JwtAuthenticationFilter
│ ├─ Authorization 헤더에서 토큰 추출
│ ├─ 토큰 검증 (JwtUtil.validateToken)
│ ├─ userId 추출 (JwtUtil.getUserIdFromToken)
│ └─ SecurityContextHolder에 인증 정보 저장
│ │
│ ▼
│ SecurityConfig: authenticated() 통과
│ │
│ ▼
│ AuthController.getUserInfo()
│ └─ SecurityUtil.getCurrentUserId()로 userId 획득
│ │
│ ▼
│ AuthService.getUserInfo(userId)
│ │
│ 200 OK ▼
│ {userId, loginId, name, ...}
│ ◄───────────────────────────────────────────────
│
[클라이언트] [서버]
│
│ GET /api/auth/me
│ (Authorization 헤더 없음 또는 잘못된 토큰)
│ ───────────────────────────────────────────────►
│
│ JwtAuthenticationFilter
│ └─ 토큰 없음/유효하지 않음
│ → SecurityContextHolder에 인증 정보 없음
│ │
│ ▼
│ SecurityConfig: authenticated() 실패!
│ │
│ 401 Unauthorized ▼
│ {error: "인증이 필요합니다"}
│ ◄───────────────────────────────────────────────
│
JWT는 서버에 상태를 저장하지 않고 토큰 자체에 정보를 담는 인증 방식이다.
회원가입 시에는 비밀번호를 BCrypt로 암호화해서 저장한다.
로그인 시에는 비밀번호를 검증하고, 성공하면 JWT 토큰을 생성해서 클라이언트에 전달한다.
인증이 필요한 API를 호출할 때는 Authorization: Bearer {토큰} 헤더를 포함해야 한다.
JwtAuthenticationFilter가 모든 요청을 가로채서 토큰을 검증하고, 유효하면 Spring Security의 SecurityContext에 인증 정보를 저장한다.
컨트롤러나 서비스에서는 SecurityUtil.getCurrentUserId()로 현재 로그인한 사용자의 ID를 가져올 수 있다.
로그아웃은 클라이언트 측에서 토큰을 삭제하는 방식으로 처리한다.