Spring Security - JWT 기반 로그인 완벽 이해하기

정다운·2026년 5월 7일

완벽 이해하기

목록 보기
5/5

1단계: 이론

인증(Authentication) vs 인가(Authorization) 개념

인증(Authentication) : "너 누구야?"
사용자가 자신이 누구인지 증명하는 과정입니다
예시) 로그인 시 이메일 + 비밀번호를 입력 => 맞으면 인증 성공

인가(Authorization) : "너 이거 해도 돼?"
인증된 사용자가 특정 기능을 사용할 권한이 있는지 확인하는 과정입니다
예시) 일반 유저가 관리가 페이지 접근 시도 -> 차단
인증 없이는 불가능 (로그인 먼저, 권한 체크는 그 다음)

쿠기/세션/토큰

쿠키 (Cookie)
브라우저에 저장되는 작은 데이터
요청마다 자동으로 서버에 전송됨
예시: sessionId=abc123

세션 (Session)
로그인 상태를 서버 메모리에 저장
쿠키에는 세션 ID만 저장하고, 실제 정보는 서버가 보관
서버가 기억하는 방식

토큰 (Token)
로그인 상태를 토큰 자체에 저장
서버는 토큰을 발급만 하고 저장하지 않음
클라이언트가 보관하고 요청마다 직접 전송

세션 방식 vs JWT 방식 비교

그럼 왜 JWT 방식에 대해 배울까요?

세션 방식 흐름
1. 로그인 요청
2. 서버: 세션 저장 (메모리에 {sessionId: "abc", userId: 1})
3. 클라이언트: 쿠키에 sessionId 저장
4. 다음 요청마다 sessionId 쿠키 자동 전송
5. 서버: sessionId로 메모리 조회 → 사용자 확인

JWT 방식 흐름
1. 로그인 요청
2. 서버: JWT 토큰 발급 (토큰 안에 userId 포함)
3. 클라이언트: 토큰 저장 (localStorage 등)
4. 다음 요청마다 Header에 토큰 직접 전송
Authorization: Bearer <토큰>
5. 서버: 토큰 서명만 검증 → 사용자 확인 (DB/메모리 조회 없음)

따라서 JWT는 세션 방식에 비해
서버에 상태 저장을 하지 않기 때문에 메모리 부담이 없고,
토큰만 검증하면 되기에 서버가 여러 대여도 문제 없다는 장점을 갖고 있습니다

JWT 구조

구성요소내용예시
Header어떤 알고리즘으로 서명했는지{ "alg": "HS256" }
Payload실제 데이터 (userId, role, 만료시간 등){ "userId": 1, "exp": 1234567890 }
SignatureHeader + Payload를 Secret Key로 서명한 값위변조 방지용

(payload는 base64로 인코딩된 것이라 누구나 디코딩 가능합니다 따라서 비밀번호 같은 민감한 정보는 절대 넣으면 안 됩니다)

Access Token / Refresh Token 개념

Access Token
실제 API 인증에 사용하는 토큰
만료시간 짧게 설정 (30분 ~ 1시간)
탈취당해도 빨리 만료되므로 피해 최소화

Refresh Token
Access Token이 만료됐을 때 새로 발급받기 위한 토큰
만료시간 길게 설정 (7일 ~ 30일)
보통 DB에 저장해서 관리
Access Token 만료
→ Refresh Token으로 새 Access Token 요청
→ 서버: Refresh Token 검증 후 새 Access Token 발급

JWT 기반 로그인 구현 강의자료

(추가된 코드를 구별하기 어렵다면 여길 클릭하여 코드 참고해 주세요!)

2단계: 프로젝트 세팅

의존성 추가

plugins {
    id 'java'
    id 'org.springframework.boot' version '3.5.13'
    id 'io.spring.dependency-management' version '1.1.7'
}

group = 'com.likelion'
version = '0.0.1-SNAPSHOT'
description = 'likelion-crud'

java {
    toolchain {
        languageVersion = JavaLanguageVersion.of(17)
    }
}

configurations {
    compileOnly {
        extendsFrom annotationProcessor
    }
}

repositories {
    mavenCentral()
}

dependencies {
    implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
    implementation 'org.springframework.boot:spring-boot-starter-web'
    compileOnly 'org.projectlombok:lombok'
    developmentOnly 'org.springframework.boot:spring-boot-devtools'
    runtimeOnly 'com.mysql:mysql-connector-j'
    annotationProcessor 'org.projectlombok:lombok'
    testImplementation 'org.springframework.boot:spring-boot-starter-test'
    testRuntimeOnly 'org.junit.platform:junit-platform-launcher'

    //swagger세팅
    implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.8.12'

    // JWT 관련 의존성 세팅
    implementation 'org.springframework.boot:spring-boot-starter-security'
    implementation 'io.jsonwebtoken:jjwt-api:0.12.6'
    runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.12.6'
    runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.12.6'
}

tasks.named('test') {
    useJUnitPlatform()
}

3단계: 핵심 컴포넌트 구현

Secret Key 생성

이 토큰은 우리 서버가 만든 게 맞아! 라는 걸 증명하기 위해 서버만 아는 비밀번호를 secret key라고 합니다

따라서 토큰 발급 시 secret key로 서명을 만들어서 토큰에 포함시킵니다

서명 검증 과정

클라이언트가 토큰을 보냄
↓
서버: Secret Key로 signature 재계산
↓
토큰의 signature와 비교
↓
일치 → 인증 성공   /   불일치  → 위변조 감지, 요청 거부

터미널에서 아래 명령어를 실행해서 Secret Key를 생성해 주세요

openssl rand -base64 64

만든 secret key를 아래와 같이 yml 파일에 넣어주세요

spring:
  datasource:
    url: jdbc:mysql://localhost:3306/likelion   # 연결할 MySQL 데이터베이스의 주소
    username: root                                   # 본인 MySQL 사용자 이름
    password: mysql0!!                                  # 본인 MySQL 비밀번호
    driver-class-name: com.mysql.cj.jdbc.Driver # MySQL 드라이버 클래스

  jpa:
    hibernate:
      ddl-auto: create     # 테이블 자동 생성 (create, update, validate, none 중 택 1)
    show-sql: true         # 실행되는 SQL을 콘솔에 출력
    properties:
      hibernate:
        format_sql: true   # SQL 쿼리를 보기 좋게 출력 (정렬됨)

logging:
  level:
    org.hibernate.SQL: debug                  # 실행되는 SQL 로그 출력
    org.hibernate.type.descriptor.sql: trace  # 바인딩된 파라미터 값 로그 출력

jwt:
  secret: nvuhAejTCtXEIlnsuSpPtLXzTY+KaCZmXxzuAzu/pyHhL5m1cqe8f2ELsUy1wYpsFXrtJxam67eH2w6i5ue+IQ==
  expiration: 1800000  # Access Token 만료시간 (30)

JwtUtil

이제 본격적으로 토큰을 생성하고, 파싱과 검증하는 코드를 먼저 구현해볼게요

package com.likelion.likelioncrud.auth;

import io.jsonwebtoken.Claims;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.io.Decoders;
import io.jsonwebtoken.security.Keys;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;

import javax.crypto.SecretKey;
import java.util.Date;

@Component
public class JwtUtil {

    // application.yml의 jwt.secret 값을 자동으로 주입
    @Value("${jwt.secret}")
    private String secretKey;

    // application.yml의 jwt.expiration 값을 자동으로 주입
    @Value("${jwt.expiration}")
    private long expiration;

    // 토큰 생성
    public String generateToken(Long userId) {
        return Jwts.builder()
                .subject(String.valueOf(userId))                                       // payload에 userId 저장 (subject는 String 타입)
                .issuedAt(new Date())                                                  // 토큰 발급 시간
                .expiration(new Date(System.currentTimeMillis() + expiration))         // 토큰 만료 시간
                .signWith(getSigningKey())                                             // Secret Key로 서명
                .compact();                                                       // 토큰 문자열로 변환
    }

    // 토큰 파싱
    public Long getUserId(String token) {
        // subject에 저장된 userId를 String → Long으로 변환해서 반환
        return Long.parseLong(parseClaims(token).getSubject());
    }

    // 토큰 유효성 검증
    // 유효하면 true, 만료 or 위변조 등이면 false
    public boolean validateToken(String token) {
        try {
            parseClaims(token); // 파싱 시 만료, 위변조 등이면 예외 발생
            return true;
        } catch (Exception e) {
            return false;
        }
    }

    //payload 파싱 (userId를 꺼내서 누가 보낸 요청인지 알기 위해서)
    private Claims parseClaims(String token) {
        return Jwts.parser()
                .verifyWith(getSigningKey()) // Secret Key로 서명 검증
                .build()
                .parseSignedClaims(token)   // 토큰 파싱
                .getPayload();              // payload(Claims) 반환
    }

     // application.yml의 secret 문자열을 SecretKey 객체로 변환
    private SecretKey getSigningKey() {
        // Base64로 인코딩된 secret 문자열을 디코딩해서 SecretKey 객체 생성
        byte[] keyBytes = Decoders.BASE64.decode(secretKey);
        return Keys.hmacShaKeyFor(keyBytes);
    }
}

JwtFilter

만든 JwtUtil를 이용해 요청마다 토큰 검증 후 SecurityContext 저장하는 코드를 구현해 볼게요

package com.likelion.likelioncrud.auth;

import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.web.authentication.WebAuthenticationDetailsSource;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;

import java.io.IOException;
import java.util.Collections;

// OncePerRequestFilter: 하나의 요청에 대해 딱 한 번만 실행되는 필터
@Component
@RequiredArgsConstructor
public class JwtFilter extends OncePerRequestFilter {

    private final JwtUtil jwtUtil;

     // 토큰을 검증하고 SecurityContext에 인증 정보를 저장하는 역할
    @Override
    protected void doFilterInternal(HttpServletRequest request,
                                    HttpServletResponse response,
                                    FilterChain filterChain) throws ServletException, IOException {

        // 1. 요청 헤더에서 Authorization 값 추출
        // 클라이언트는 "Authorization: Bearer <토큰>" 형태로 요청을 보냄
        String authHeader = request.getHeader("Authorization");

        // 2. Authorization 헤더가 없거나 "Bearer "로 시작하지 않으면 토큰 검증 생략
        // (로그인, 회원가입 등 인증이 필요 없는 요청은 그냥 통과)
        if (authHeader == null || !authHeader.startsWith("Bearer ")) {
            filterChain.doFilter(request, response); // 다음 필터로 넘김
            return;
        }

        // 3. 토큰 값만 추출 (앞 7글자 "Bearer " 제거)
        String token = authHeader.substring(7);

        // 4. 토큰 유효성 검증 (만료 여부, 위변조 여부 확인)
        if (jwtUtil.validateToken(token)) {

            // 5. 토큰에서 userId 추출
            Long userId = jwtUtil.getUserId(token);

            // 6. userId로 Authentication 객체 생성
            // - 첫 번째 인자(principal): 현재 로그인한 사용자 정보 (userId)
            // - 두 번째 인자(credentials): 비밀번호 (토큰 방식에서는 불필요하므로 null)
            // - 세 번째 인자(authorities): 권한 목록 (현재는 빈 리스트)
            UsernamePasswordAuthenticationToken authentication =
                    new UsernamePasswordAuthenticationToken(userId, null, Collections.emptyList());

            // 7. 요청 정보(IP, 세션 등)를 Authentication에 추가
            authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));

            // 8. SecurityContext에 인증 정보 저장
            SecurityContextHolder.getContext().setAuthentication(authentication);
        }

        // 9. 다음 필터로 넘김
        filterChain.doFilter(request, response);
    }
}

SecurityConfig

Filter 등록, 경로별 인가 설정을 하기 위한 코드를 구현해 봅시다

package com.likelion.likelioncrud.auth;

import lombok.RequiredArgsConstructor;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.AuthenticationEntryPoint;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;

import jakarta.servlet.http.HttpServletResponse;

// @EnableWebSecurity: Spring Security 활성화
@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class SecurityConfig {

    private final JwtFilter jwtFilter;

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http
                // CSRF(Cross-Site Request Forgery) 보호 비활성화
                // JWT는 stateless 방식이라 세션/쿠키를 사용하지 않으므로 CSRF 불필요
                .csrf(AbstractHttpConfigurer::disable)

                // JWT 방식은 서버에 세션을 저장하지 않으므로 세션 사용 안 함
                .sessionManagement(session ->
                        session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))

                // 경로별 인가(Authorization) 설정
                .authorizeHttpRequests(auth -> auth

                        // 아래 경로들은 토큰 없이 누구나 접근 가능
                        .requestMatchers(
                                "/auth/signup",      // 회원가입 API
                                "/auth/login",       // 로그인 API
                                "/swagger-ui/**",    // Swagger UI 페이지
                                "/v3/api-docs/**"    // Swagger API 문서
                        ).permitAll()

                        // 위에서 허용한 경로 외 나머지는 모두 인증(토큰) 필요
                        .anyRequest().authenticated()
                )

                // JwtFilterUsernamePasswordAuthenticationFilter 앞에 등록
                // → 모든 요청에서 컨트롤러 도달 전에 JWT 검증이 먼저 실행됨
                .addFilterBefore(jwtFilter, UsernamePasswordAuthenticationFilter.class);

        return http.build();
    }

    @Bean
    public PasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder();
    }
}

4단계: 회원가입 / 로그인 API 구현

Member

package com.likelion.likelioncrud.member.domain;

import com.likelion.likelioncrud.member.api.dto.request.MemberUpdateRequestDto;
import com.likelion.likelioncrud.post.domain.Post;
import jakarta.persistence.*;
import lombok.AccessLevel;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;

import java.util.ArrayList;
import java.util.List;

@Entity
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Member {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    @Column(name = "member_id")
    private Long memberId;

    private String name;

    private int age;

    @Enumerated(EnumType.STRING)
    @Column(length = 20)
    private Part part;

    // 로그인 시 사용하는 이메일 (중복 불가)
    @Column(unique = true)
    private String email;

    // BCrypt로 암호화된 비밀번호 저장
    private String password;

    @OneToMany(mappedBy = "member", cascade = CascadeType.ALL, orphanRemoval = true)
    private List<Post> posts = new ArrayList<>();

    @Builder
    private Member(String name, int age, Part part, String email, String password) {
        this.name = name;
        this.age = age;
        this.part = part;
        this.email = email;
        this.password = password;
    }

    public void update(MemberUpdateRequestDto memberUpdateRequestDto) {
        this.name = memberUpdateRequestDto.name();
        this.age = memberUpdateRequestDto.age();
    }
}

MemberRepository

package com.likelion.likelioncrud.member.domain.repository;

import com.likelion.likelioncrud.member.domain.Member;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.JpaRepository;

import java.util.Optional;

public interface MemberRepository extends JpaRepository<Member, Long> {
    Page<Member> findAll(Pageable pageable);

    // 이메일로 회원 조회 (로그인 시 사용)
    Optional<Member> findByEmail(String email);

    // 이메일 중복 체크 (회원가입 시 사용)
    boolean existsByEmail(String email);
}

ErrorCode

package com.likelion.likelioncrud.common.response.code;

import lombok.AccessLevel;
import lombok.AllArgsConstructor;
import lombok.Getter;
import org.springframework.http.HttpStatus;

@Getter // getter 메소드 자동 생성 lombok 어노테이션
@AllArgsConstructor(access = AccessLevel.PRIVATE) // 모든 필드를 파라미터로 받는 생성자 자동 생성 어노테이션
public enum ErrorCode {

    /**
     * 404 NOT FOUND (찾을 수 없음)
     */
    MEMBER_NOT_FOUND_EXCEPTION(HttpStatus.NOT_FOUND, "해당 사용자가 없습니다. memberId = "),
    POST_NOT_FOUND_EXCEPTION(HttpStatus.NOT_FOUND, "해당 게시글이 없습니다. postId = "),

    /**
     * 400 BAD REQUEST
     */
    VALIDATION_EXCEPTION(HttpStatus.BAD_REQUEST, "유효성 검사에 실패하였습니다 - "),

    /**
     * 500 INTERNAL SERVER ERROR (내부 서버 에러)
     */
    INTERNAL_SERVER_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "내부 서버 에러가 발생했습니다"),

    // 로그인
    DUPLICATE_EMAIL_EXCEPTION(HttpStatus.BAD_REQUEST, "이미 사용중인 이메일입니다."),
    INVALID_PASSWORD_EXCEPTION(HttpStatus.BAD_REQUEST, "비밀번호가 일치하지 않습니다."),
    MEMBER_NOT_FOUND_BY_EMAIL_EXCEPTION(HttpStatus.UNAUTHORIZED, "이메일을 찾을 수 없습니다.");

    private final HttpStatus httpStatus;    // HTTP 상태 코드를 스프링에서 쉽게 작성하기 위한 enum값들의 모임
    private final String message;           // 에러 메세지

    public int getHttpStatusCode() {        // HTTP 상태 코드에서 404와 같은 숫자 값만 반환해 주기 위한 메소드
        return httpStatus.value();
    }
}

SucessCode

package com.likelion.likelioncrud.common.response.code;

import lombok.AccessLevel;
import lombok.AllArgsConstructor;
import lombok.Getter;
import org.springframework.http.HttpStatus;

@Getter
@AllArgsConstructor(access = AccessLevel.PRIVATE) // 모든 필드를 파라미터로 받는 생성자 자동 생성 어노테이션
public enum SuccessCode {

    /**
     * 200 OK (성공)
     */
    GET_SUCCESS(HttpStatus.OK, "성공적으로 조회했습니다."),
    MEMBER_UPDATE_SUCCESS(HttpStatus.OK, "사용자가 성공적으로 수정되었습니다."),
    POST_UPDATE_SUCCESS(HttpStatus.OK, "글이 성공적으로 수정되었습니다."),
    MEMBER_DELETE_SUCCESS(HttpStatus.OK, "사용자가 성공적으로 삭제되었습니다."),
    POST_DELETE_SUCCESS(HttpStatus.OK, "글이 성공적으로 삭제되었습니다."),

    /**
     * 201 CREATED (생성 성공)
     */
    MEMBER_SAVE_SUCCESS(HttpStatus.CREATED, "사용자가 성공적으로 생성되었습니다."),
    POST_SAVE_SUCCESS(HttpStatus.CREATED, "글이 성공적으로 생성되었습니다."),
    SIGNUP_SUCCESS(HttpStatus.CREATED, "회원가입이 성공적으로 완료되었습니다."),

    // 로그인
    LOGIN_SUCCESS(HttpStatus.OK, "로그인이 성공적으로 완료되었습니다.");

    private final HttpStatus httpStatus;   // HTTP 상태 코드를 스프링에서 쉽게 작성하기 위한 enum값들의 모임
    private final String message;          // 응답 메세지

    public int getHttpStatusCode() {       // HTTP 상태 코드에서 404와 같은 숫자 값만 반환해 주기 위한 메소드
        return httpStatus.value();
    }
}

SignupRequestDto

package com.likelion.likelioncrud.auth.api.dto.request;

// 회원가입 요청 시 클라이언트에서 받는 데이터
public record SignupRequestDto(
        String name,
        String email,
        String password
) {
}

LoginRequestDto

package com.likelion.likelioncrud.auth.api.dto.request;

// 로그인 요청 시 클라이언트에서 받는 데이터
public record LoginRequestDto(
        String email,
        String password
) {
}

LoginResponseDto

package com.likelion.likelioncrud.auth.api.dto.response;

// 로그인 성공 시 클라이언트에게 반환하는 데이터
public record LoginResponseDto(
        String accessToken  // 발급된 JWT Access Token
) {
}

AuthService

package com.likelion.likelioncrud.auth.application;

import com.likelion.likelioncrud.auth.JwtUtil;
import com.likelion.likelioncrud.auth.api.dto.request.LoginRequestDto;
import com.likelion.likelioncrud.auth.api.dto.request.SignupRequestDto;
import com.likelion.likelioncrud.auth.api.dto.response.LoginResponseDto;
import com.likelion.likelioncrud.common.exception.BusinessException;
import com.likelion.likelioncrud.common.response.code.ErrorCode;
import com.likelion.likelioncrud.member.domain.Member;
import com.likelion.likelioncrud.member.domain.repository.MemberRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)  // 기본적으로 읽기 전용 트랜잭션 적용 (조회 성능 최적화)
public class AuthService {

    private final MemberRepository memberRepository;
    private final PasswordEncoder passwordEncoder;  // SecurityConfig에서 빈으로 등록한 BCryptPasswordEncoder
    private final JwtUtil jwtUtil;

    // 회원가입
    @Transactional  // DB에 저장하는 작업이므로 쓰기 트랜잭션 적용
    public void signup(SignupRequestDto request) {

        // 1. 이메일 중복 체크
        if (memberRepository.existsByEmail(request.email())) {
            throw new BusinessException(ErrorCode.DUPLICATE_EMAIL_EXCEPTION, ErrorCode.DUPLICATE_EMAIL_EXCEPTION.getMessage());
        }

        // 2. 비밀번호 BCrypt 암호화 후 Member 생성
        Member member = Member.builder()
                .name(request.name())
                .email(request.email())
                .password(passwordEncoder.encode(request.password()))  // 비밀번호 암호화
                .build();

        // 3. DB 저장
        memberRepository.save(member);
    }

    // 로그인
    public LoginResponseDto login(LoginRequestDto request) {

        // 1. 이메일로 회원 조회 (없으면 예외 처리)
        Member member = memberRepository.findByEmail(request.email())
                .orElseThrow(() -> new BusinessException(ErrorCode.MEMBER_NOT_FOUND_BY_EMAIL_EXCEPTION, ErrorCode.MEMBER_NOT_FOUND_BY_EMAIL_EXCEPTION.getMessage()));

        // 2. 입력한 비밀번호와 암호화된 비밀번호 비교
        if (!passwordEncoder.matches(request.password(), member.getPassword())) {
            throw new BusinessException(ErrorCode.INVALID_PASSWORD_EXCEPTION, ErrorCode.INVALID_PASSWORD_EXCEPTION.getMessage());
        }

        // 3. 인증 성공 → userId로 JWT 토큰 발급
        String accessToken = jwtUtil.generateToken(member.getMemberId());

        return new LoginResponseDto(accessToken);
    }
}

AuthController

package com.likelion.likelioncrud.auth.api;

import com.likelion.likelioncrud.auth.api.dto.request.LoginRequestDto;
import com.likelion.likelioncrud.auth.api.dto.request.SignupRequestDto;
import com.likelion.likelioncrud.auth.api.dto.response.LoginResponseDto;
import com.likelion.likelioncrud.auth.application.AuthService;
import com.likelion.likelioncrud.common.response.code.SuccessCode;
import com.likelion.likelioncrud.common.template.ApiResTemplate;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

@RestController
@RequiredArgsConstructor
@RequestMapping("/auth")
@Tag(name = "로그인 API", description = "회원가입, 로그인 관련 API")
public class AuthController {

    private final AuthService authService;

    // 회원가입
    @PostMapping("/signup")
    public ApiResTemplate<Void> signup(@RequestBody SignupRequestDto request) {
        authService.signup(request);
        return ApiResTemplate.successWithNoContent(SuccessCode.SIGNUP_SUCCESS);
    }

    // 로그인
    @PostMapping("/login")
    public ApiResTemplate<LoginResponseDto> login(@RequestBody LoginRequestDto request) {
        LoginResponseDto response = authService.login(request);
        return ApiResTemplate.successResponse(SuccessCode.LOGIN_SUCCESS, response); // 200 OK + accessToken
    }
}

5단계: 인증이 필요한 API 구현

토큰 있고 없고의 차이를 직접 눈으로 확인하기 위해 실제로 적용해 봅시다!

MemberService

package com.likelion.likelioncrud.member.application;

import com.likelion.likelioncrud.common.exception.BusinessException;
import com.likelion.likelioncrud.common.response.code.ErrorCode;
import com.likelion.likelioncrud.member.api.dto.request.MemberSaveRequestDto;
import com.likelion.likelioncrud.member.api.dto.request.MemberUpdateRequestDto;
import com.likelion.likelioncrud.member.api.dto.response.MemberInfoResponseDto;
import com.likelion.likelioncrud.member.domain.Member;
import com.likelion.likelioncrud.member.domain.repository.MemberRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
public class MemberService {

    private final MemberRepository memberRepository;

    // 내 정보 조회 (JWT 토큰에서 추출한 userId로 조회)
    public MemberInfoResponseDto memberFindMe(Long userId) {
        Member member = memberRepository.findById(userId)
                .orElseThrow(() -> new BusinessException(
                        ErrorCode.MEMBER_NOT_FOUND_EXCEPTION,
                        ErrorCode.MEMBER_NOT_FOUND_EXCEPTION.getMessage() + userId
                ));
        return MemberInfoResponseDto.from(member);
    }

    // 사용자 정보 저장
    @Transactional
    public void memberSave(MemberSaveRequestDto memberSaveRequestDto) {
        Member member = Member.builder()
                .name(memberSaveRequestDto.name())
                .age(memberSaveRequestDto.age())
                .part(memberSaveRequestDto.part())
                .build();
        memberRepository.save(member);
    }

    // 사용자 모두 조회
    public Page<MemberInfoResponseDto> memberFindAll(Pageable pageable) {
        Page<Member> members = memberRepository.findAll(pageable);
        return members.map(MemberInfoResponseDto::from);
    }

    // 단일 사용자 조회
    public MemberInfoResponseDto memberFindOne(Long memberId) {
        Member member = memberRepository.findById(memberId)
                .orElseThrow(() -> new BusinessException(
                        ErrorCode.MEMBER_NOT_FOUND_EXCEPTION,
                        ErrorCode.MEMBER_NOT_FOUND_EXCEPTION.getMessage() + memberId
                ));

        return MemberInfoResponseDto.from(member);
    }

    // 사용자 정보 수정
    @Transactional
    public void memberUpdate(Long memberId, MemberUpdateRequestDto memberUpdateRequestDto) {
        Member member = memberRepository.findById(memberId)
                .orElseThrow(() -> new BusinessException(
                        ErrorCode.MEMBER_NOT_FOUND_EXCEPTION,
                        ErrorCode.MEMBER_NOT_FOUND_EXCEPTION.getMessage() + memberId));
        member.update(memberUpdateRequestDto);
    }

    // 사용자 정보 삭제
    @Transactional
    public void memberDelete(Long memberId) {
        Member member = memberRepository.findById(memberId)
                .orElseThrow(() -> new BusinessException(
                        ErrorCode.MEMBER_NOT_FOUND_EXCEPTION,
                        ErrorCode.MEMBER_NOT_FOUND_EXCEPTION.getMessage() + memberId));
        memberRepository.delete(member);
    }
}

MemberController

package com.likelion.likelioncrud.member.api;

import com.likelion.likelioncrud.common.response.code.SuccessCode;
import com.likelion.likelioncrud.common.template.ApiResTemplate;
import com.likelion.likelioncrud.member.api.dto.request.MemberSaveRequestDto;
import com.likelion.likelioncrud.member.api.dto.request.MemberUpdateRequestDto;
import com.likelion.likelioncrud.member.api.dto.response.MemberInfoResponseDto;
import com.likelion.likelioncrud.member.application.MemberService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springdoc.core.annotations.ParameterObject;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.domain.Sort;
import org.springframework.data.web.PageableDefault;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.*;

@RestController
@RequiredArgsConstructor
@RequestMapping("/member")
@Tag(name = "멤버 API", description = "멤버 관리하는 api ")
public class MemberController {

    private final MemberService memberService;

    // 내 정보 조회 (토큰 필요)
    // JwtFilter에서 SecurityContext에 저장한 userId를 꺼냄
    // 토큰 없이 요청하면 401 Unauthorized 반환
    @GetMapping("/me")
    @Operation(summary = "내 정보 조회", description = "JWT 토큰으로 현재 로그인한 사용자의 정보를 조회합니다.")
    public ApiResTemplate<MemberInfoResponseDto> memberFindMe(
            @AuthenticationPrincipal Long userId) {
        MemberInfoResponseDto response = memberService.memberFindMe(userId);
        return ApiResTemplate.successResponse(SuccessCode.GET_SUCCESS, response);
    }

    // 사용자 저장
    @PostMapping()
    @Operation(summary = "멤버 회원가입", description = "멤버 회원가입 설명란입니다.")
    public ApiResTemplate<Void> memberSave(@RequestBody @Valid MemberSaveRequestDto memberSaveRequestDto) {
        memberService.memberSave(memberSaveRequestDto);
        return ApiResTemplate.successWithNoContent(SuccessCode.MEMBER_SAVE_SUCCESS);
    }

    // 사용자 전체 조회
    @GetMapping("/all")
    @Operation(summary = "멤버 전체조회", description = "멤버 전체조회")
    public ApiResTemplate<Page<MemberInfoResponseDto>> memberFindAll(
            @ParameterObject
            @PageableDefault(
                    size = 10,
                    sort = "memberId",
                    direction = Sort.Direction.ASC
            ) Pageable pageable
    ) {
        Page<MemberInfoResponseDto> members = memberService.memberFindAll(pageable);
        return ApiResTemplate.successResponse(SuccessCode.GET_SUCCESS,members);
    }

    // 회원 id를 통해 특정 사용자 조회
    @GetMapping("/{memberId}")
    @Operation(summary = "멤버 1명 조회", description = "멤버 id로 멤버조회")
    public ApiResTemplate<MemberInfoResponseDto> memberFindOne(@PathVariable("memberId") Long memberId) {
        MemberInfoResponseDto memberInfoResponseDto = memberService.memberFindOne(memberId);
        return ApiResTemplate.successResponse(SuccessCode.GET_SUCCESS, memberInfoResponseDto);
    }

    // 회원 id를 통한 사용자 수정
    @PatchMapping("/{memberId}")
    @Operation(summary = "멤버 업데이트", description = "멤버 업데이트")
    public ApiResTemplate<Void> memberUpdate(@PathVariable("memberId") Long memberId,
                                             @RequestBody MemberUpdateRequestDto memberUpdateRequestDto) {
        memberService.memberUpdate(memberId, memberUpdateRequestDto);
        return ApiResTemplate.successWithNoContent(SuccessCode.MEMBER_UPDATE_SUCCESS);
    }

    // 회원 id를 통한 사용자 삭제
    @DeleteMapping("/{memberId}")
    @Operation(summary = "멤버 삭제", description = "멤버 삭제")
    public ApiResTemplate<Void> memberDelete(@PathVariable("memberId") Long memberId) {
        memberService.memberDelete(memberId);
        return ApiResTemplate.successWithNoContent(SuccessCode.MEMBER_DELETE_SUCCESS);
    }
}

6단계: Swagger 테스트

이제 테스트를 해봅시다!

서버 실행하고 스웨거 접속해 주세요

http://localhost:8080/swagger-ui/index.html

로그인을 하지 않고 내 정보 조회를 해보면 토큰이 없어 당연히 401 에러가 뜹니다

이제 회원가입을 해주세요

그 다음 로그인 후 토큰을 발급 받아주세요

발급받은 accessToken를 상단 왼쪽 Authorize 버튼 클릭 후 붙여 넣어주세요

성공했다면 아래와 같이 내 정보 조회가 성공적으로 이뤄집니다!

과제 😆

1. 구현할 내용

강의에서 다룬 JWT 기반 로그인에 Refresh Token 기능을 추가로 구현하세요!

뼈대 코드가 이미 제공되어 있으니, // TODO 주석이 달린 부분만 채우면 됩니다!

수정할 부분은 제가 보내드린 과제 프로젝트에서 JwtUtil.java와 AuthService.java 두 개뿐입니다~

(이런식으로 추가되어있습니다!)

⚠️ 시작 전 필수 설정
src/main/resources/application.yml 파일을 본인이 쓰던 파일로 수정하세요!!

AI 사용은 허용하지만 AI가 작성한 코드에는 반드시 본인이 직접 주석을 달아야 합니다! 주석 없이 제출한 경우 가이드라인 미준수입니다ㅠㅠ

// 주석 예시
// 기존에 저장된 이 사용자의 refresh token을 삭제
// 재로그인 시 이전 refresh token이 남아있으면 안 되기 때문
refreshTokenRepository.deleteByMemberId(member.getMemberId());

2. PR Description 작성 항목

  1. Refresh Token이 필요한 이유
    Access Token만 사용할 때의 문제점과 Refresh Token이 이를 어떻게 해결하는지 설명해 주세요!
  1. 구현 흐름 설명
    로그인 시 Refresh Token이 어떻게 발급되고 저장되는지 설명해 주세요!
  1. Swagger 테스트 스크린샷
    POST /auth/login 응답에 accessToken, refreshToken 둘 다 포함되는지
    POST /auth/refresh refresh token 전달 시 새 accessToken 발급되는지
    POST /auth/refresh (실패 케이스) 잘못된 토큰 전달 시 401 응답이 오는지

⏰ 제출 기한
5월 23일 오후 11시 59분까지 PR 올려주세요~

0개의 댓글