DEV&OPS/Java

스프링 부트 JWT 인증 구현 가이드 1편: 로그인과 액세스 토큰 발급

ALEPH.GEM 2026. 7. 23. 16:48
728x90

스프링 시큐리티

스프링 부트 REST API에서 사용할 JWT 인증을 구현해 보겠습니다.

먼저 로그인 요청을 검증하고, 인증된 사용자를 식별할 수 있는 액세스 토큰을 발급해야 합니다.

Spring Security가 아이디와 비밀번호를 확인하는 과정부터 구현한 뒤, 인증에 성공했을 때 JWT를 발급해야 합니다.

전체 흐름은 다음과 같습니다.

1. 클라이언트가 이메일과 비밀번호로 로그인 요청
2. AuthenticationManager가 인증 처리
3. UserDetailsService가 사용자 조회
4. PasswordEncoder가 비밀번호 비교
5. 인증 성공 시 JWT 생성
6. 클라이언트에 액세스 토큰 반환

Spring Security에서 AuthenticationManager는 인증 요청을 처리하는 중심 인터페이스입니다.

일반적인 아이디·비밀번호 인증에서는 DaoAuthenticationProviderUserDetailsService로 사용자를 조회하고 PasswordEncoder로 비밀번호를 검증합니다.

실제 프로젝트에서는 회원 상태, 이메일 인증, 로그인 실패 횟수 제한, 감사 로그 등의 정책을 추가해야 합니다.

 

1. JWT 인증에서 로그인의 필요성

JWT를 사용하더라도 사용자의 아이디와 비밀번호를 확인하는 로그인 과정은 필요합니다.

세션 기반 인증에서는 서버가 로그인 상태를 세션에 저장합니다.

반면 API를 제공할 때는 로그인 성공 시 액세스 토큰을 발급하고, 이후 요청마다 클라이언트가 토큰을 전달하도록 구성합니다.

POST /api/auth/login
        ↓
아이디와 비밀번호 검증
        ↓
JWT 발급
        ↓
Authorization: Bearer {accessToken}

JWT는 일반적으로 다음 세 부분으로 구성됩니다.

Header.Payload.Signature
  • Header: 토큰 유형과 서명 알고리즘
  • Payload: 사용자 식별값, 권한, 발급 시각, 만료 시각 등의 클레임
  • Signature: 토큰이 변경되지 않았는지 확인하기 위한 서명

JWT Payload는 암호화된 비밀 공간이 아닙니다.

토큰을 가진 사람은 Payload 내용을 확인할 수 있으므로 비밀번호, 주민등록번호, 결제정보 같은 민감정보를 넣어서는 안 됩니다.

 

2. 예제 프로젝트 준비

예제 환경은 다음과 같습니다.

  • Java 17 이상
  • Spring Boot 3.x
  • Spring Web
  • Spring Security
  • Spring Data JPA
  • Validation
  • H2 Database
  • JJWT 0.13.0

Gradle을 사용한다면 다음 의존성을 추가합니다.

dependencies {
    implementation 'org.springframework.boot:spring-boot-starter-web'
    implementation 'org.springframework.boot:spring-boot-starter-security'
    implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
    implementation 'org.springframework.boot:spring-boot-starter-validation'

    implementation 'io.jsonwebtoken:jjwt-api:0.13.0'
    runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.13.0'
    runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.13.0'

    runtimeOnly 'com.h2database:h2'

    testImplementation 'org.springframework.boot:spring-boot-starter-test'
    testImplementation 'org.springframework.security:spring-security-test'
}

JJWT는 API 모듈과 런타임 구현 모듈, JSON 처리를 위한 Jackson 모듈을 분리해서 제공합니다.

공식 저장소의 설치 안내와 프로젝트의 최신 릴리스를 확인한 뒤 버전을 적용하는 것이 안전합니다.

예제 패키지 구조는 다음처럼 구성됩니다.

com.example.jwt
├── auth
│   ├── AuthController.java
│   ├── AuthService.java
│   ├── LoginRequest.java
│   └── TokenResponse.java
├── member
│   ├── Member.java
│   ├── MemberRepository.java
│   ├── MemberRole.java
│   └── CustomUserDetailsService.java
├── security
│   ├── JwtTokenProvider.java
│   └── SecurityConfig.java
└── JwtApplication.java

패키지는 인증, 회원, 보안 설정의 책임을 구분하기 위해 분리했습니다.

 

3. 회원 엔티티 만들기

먼저 로그인 대상이 되는 회원 엔티티를 작성합니다.

엔티티는 JPA와 DB에서 사용할 객체입니다.

package com.example.jwt.member;

import jakarta.persistence.*;

@Entity
@Table(name = "members") //DB 테이블 이름을 지정합니다.
public class Member {

    @Id	
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id; //PK 값으로 사용합니다.

    @Column(nullable = false, unique = true, length = 100)
    private String email;

    @Column(nullable = false)
    private String password;

    @Enumerated(EnumType.STRING)
    @Column(nullable = false, length = 20)
    private MemberRole role; //권한은 String 열거형입니다.

    protected Member() {
    }

    public Member(String email, String password, MemberRole role) {
        this.email = email;
        this.password = password;
        this.role = role;
    }

    public Long getId() {
        return id;
    }

    public String getEmail() {
        return email;
    }

    public String getPassword() {
        return password;
    }

    public MemberRole getRole() {
        return role;
    }
}

보통 권한은 엔티티와 따로 열거형으로 분리합니다.

package com.example.jwt.member;

public enum MemberRole {
    USER,
    ADMIN
}

JPA에서 쿼리를 실행할 저장소도 작성합니다.

package com.example.jwt.member;

import java.util.Optional;
import org.springframework.data.jpa.repository.JpaRepository;

public interface MemberRepository extends JpaRepository<Member, Long> {

    Optional<Member> findByEmail(String email);
}

예제를 단순하게 유지하기 위해 이메일을 로그인 아이디로 사용합니다.

 

4. 비밀번호는 반드시 단방향으로 변환하기

Spring Security의 PasswordEncoder는 비밀번호를 안전하게 저장할 수 있도록 단방향 해시 알고리즘을 사용합니다.

따라서 로그인 시에는 저장된 값을 복호화하는 것이 아니라 사용자가 입력한 비밀번호가 저장된 해시와 일치하는지 비교합니다.

이번 예제에서는 BCryptPasswordEncoder를 빈으로 등록합니다.

package com.example.jwt.security;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;

@Configuration
public class PasswordConfig {

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

회원가입이나 초기 데이터 생성 시 반드시 encode()를 사용해야 합니다.

String encodedPassword = passwordEncoder.encode("test1234!");

로그인 코드에서 직접 문자열을 비교해서는 안 됩니다.

// 잘못된 방식
member.getPassword().equals(request.password());

비밀번호 비교는 AuthenticationManager와 PasswordEncoder가 담당하도록 구성합니다.

 

5. UserDetailsService로 사용자 조회하기

Spring Security는 UserDetailsService를 통해 인증에 필요한 사용자 이름, 비밀번호, 권한 등을 조회할 수 있습니다.

package com.example.jwt.member;

import org.springframework.security.core.userdetails.User;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.stereotype.Service;

@Service
public class CustomUserDetailsService implements UserDetailsService {

    private final MemberRepository memberRepository;  //사용자 정보를 얻어올 저장소

    public CustomUserDetailsService(MemberRepository memberRepository) {
        this.memberRepository = memberRepository;
    }

    @Override
    public UserDetails loadUserByUsername(String email) {
        //메일 주소로 저장소에서 일치하는지 찾아 봅니다.
        Member member = memberRepository.findByEmail(email)
            .orElseThrow(() ->
                new UsernameNotFoundException("사용자를 찾을 수 없습니다.")
            );
		//사용자를 찾으면 이름과 비밀번호 권한을 User 빈 객체에다가 넣어줍니다.
        return User.builder()
            .username(member.getEmail())
            .password(member.getPassword())
            .roles(member.getRole().name())
            .build();
    }
}

roles("USER")를 사용하면 Spring Security 내부 권한은 ROLE_USER 형태로 만들어집니다.

따라서 이후 권한 검사에서 다음 두 표현을 혼동하지 않아야 합니다.

.hasRole("USER")
.hasAuthority("ROLE_USER")

위 두 설정(Role, Authority)은 같은 권한입니다.

 

6. JWT 서명 키 설정하기

JWT를 발급하려면 토큰에 서명할 키가 필요합니다.

설정 파일에 다음 값을 추가합니다.

설정 파일은 프로젝트의 resources 경로에 넣으시면 됩니다. 

YAML을 사용하는 경우:

jwt:
  secret: ${JWT_SECRET}
  access-token-expiration: 1800000

properties 파일을 사용하는 경우:

jwt.secret=${JWT_SECRET}
jwt.access-token-expiration=1800000

1800000밀리 초는 30분입니다.

운영 환경의 서명 키를 Git 저장소에 올리지 않도록 환경변수를 사용합니다.

. gitignore에 등록하더라도 실수로 push 될 수 있으므로 공개 git 저장소를 사용할 때는 주의합니다.

예제에서는 HMAC SHA 계열 알고리즘에 사용할 Base64 인코딩 키를 가정합니다.

로컬 개발 환경에서는 충분한 길이의 무작위 키를 생성한 뒤 환경변수로 등록해야 합니다.

export JWT_SECRET="Base64로_인코딩한_충분히_긴_무작위_키"

짧고 예측 가능한 문자열을 사용하면 안 됩니다.

JJWT는 키 길이가 선택한 서명 알고리즘의 보안 요구사항보다 짧으면 예외를 발생시킬 수 있습니다.

임의의 문장을 억지로 늘리기보다 암호학적으로 안전한 무작위 키를 생성하는 편이 좋습니다.

적당한 길이의 아무 문자열을 만든 뒤 Base64 인코딩해서 사용하면 됩니다.

 

7. JWT 생성 클래스 구현하기

위 비밀키 설정 값을 읽기 위한 프로퍼티 클래스를 작성합니다.

package com.example.jwt.security;

import org.springframework.boot.context.properties.ConfigurationProperties;

@ConfigurationProperties(prefix = "jwt")
public record JwtProperties(
    String secret,
    long accessTokenExpiration
) {
}

메인 애플리케이션이나 설정 클래스에서 위의 프로퍼티(설정 값)를 읽기 위해 스캔을 활성화합니다.

package com.example.jwt;

import com.example.jwt.security.JwtProperties;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.boot.context.properties.EnableConfigurationProperties;

@SpringBootApplication
@EnableConfigurationProperties(JwtProperties.class)
public class JwtApplication {

    public static void main(String[] args) {
        SpringApplication.run(JwtApplication.class, args);
    }
}

이제 JWT를 생성하는 클래스를 작성합니다.

package com.example.jwt.security;

import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.io.Decoders;
import io.jsonwebtoken.security.Keys;
import java.time.Instant;
import java.util.Date;
import javax.crypto.SecretKey;
import org.springframework.stereotype.Component;

@Component
public class JwtTokenProvider {

    private final SecretKey secretKey; //환경변수에서 가져온 Base64인코딩된 키
    private final long accessTokenExpiration; //만료 시간

    public JwtTokenProvider(JwtProperties properties) {
        //환경변수에서 전달한 비밀키를 Base64디코딩한뒤 byte 배열로 변환합니다.
        byte[] keyBytes = Decoders.BASE64.decode(properties.secret());
        //jwt를 생성하는데 쓸 비밀키로 변환
        this.secretKey = Keys.hmacShaKeyFor(keyBytes);
        this.accessTokenExpiration = properties.accessTokenExpiration();
    }

	//사용자의 메일 주소를 아이디로 삼아서 JWT 토큰을 생성해 String으로 리턴해줍니다.
    public String createAccessToken(String email) {
    	//토큰 만료 시간 검사 후
        Instant now = Instant.now();
        Instant expiration = now.plusMillis(accessTokenExpiration);
		//jwt 생성
        return Jwts.builder()
            .subject(email)
            .issuedAt(Date.from(now))
            .expiration(Date.from(expiration))
            .signWith(secretKey)
            .compact();
    }
}

각 설정의 역할은 다음과 같습니다.

  • subject(email): 토큰의 주체를 이메일로 설정
  • issuedAt(...): 발급 시각 설정
  • expiration(...): 만료 시각 설정
  • signWith(secretKey): 비밀 키로 토큰 서명
  • compact(): 최종 JWT 문자열 생성

예제에서는 이해를 돕기 위해 이메일을 sub 클레임에 넣었습니다.

실무에서는 변경 가능성이 낮은 회원 ID를 넣는 방식을 검토할 수 있습니다.

이메일 변경 기능이 있는 서비스라면 토큰 발급 후 이메일이 변경되었을 때 사용자 조회 정책을 따로 정해야 하기 때문입니다.

 

8. Spring Security 설정하기

다음으로 로그인 API는 허용하고, 나머지 요청에는 인증을 요구하도록 설정합니다.

package com.example.jwt.security;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.config.annotation.authentication.configuration.AuthenticationConfiguration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.web.SecurityFilterChain;

@Configuration
public class SecurityConfig {

    @Bean
    public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {

        http.csrf(csrf -> csrf.disable())  //csrf 토큰을 사용하지 않습니다.
            .sessionManagement(session -> session
                .sessionCreationPolicy(SessionCreationPolicy.STATELESS)
            )
            .authorizeHttpRequests(auth -> auth
                .requestMatchers("/api/auth/login").permitAll()  //로그인용 주소는 누구나 접근 가능하게 합니다.
                .anyRequest().authenticated()
            );

        return http.build();
    }

    @Bean
    public AuthenticationManager authenticationManager(
        AuthenticationConfiguration configuration
    ) throws Exception {
        return configuration.getAuthenticationManager();
    }
}

SessionCreationPolicy.STATELESS는 Spring Security가 인증 상태를 HTTP 세션에 저장하는 방식에 의존하지 않도록 구성할 때 사용합니다.

다만 csrf.disable()을 JWT 사용 여부만 보고 무조건 적용해서는 안 됩니다.

위 예제에서는 Authorization 헤더에 토큰을 담고 서버가 세션이나 인증 쿠키를 사용하지 않는 단순 REST API를 가정했기 때문에 비활성화했습니다.

브라우저가 자동으로 전송하는 쿠키에 인증정보를 저장한다면 CSRF 위협과 방어 정책을 다시 검토해야 합니다.

Spring Security는 기본적으로 안전하지 않은 HTTP 메서드에 대해 CSRF 보호를 적용합니다.

 

9. 로그인 요청과 응답 DTO 작성하기

로그인 요청용 DTO입니다.

package com.example.jwt.auth;

import jakarta.validation.constraints.Email;
import jakarta.validation.constraints.NotBlank;

public record LoginRequest(

    @NotBlank
    @Email
    String email,

    @NotBlank
    String password
) {
}

토큰 응답 용 DTO입니다.

package com.example.jwt.auth;

public record TokenResponse(
    String tokenType,
    String accessToken,
    long expiresIn
) {
}

tokenType은 기본 Bearer로 반환합니다.

expiresIn은 클라이언트가 만료 시간을 계산하기 쉽도록 초 단위로 반환하는 예시입니다.

 

10. 로그인 서비스 구현하기

로그인 서비스에서는 AuthenticationManager에 인증을 위임합니다.

package com.example.jwt.auth;

import com.example.jwt.security.JwtTokenProvider;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.stereotype.Service;

@Service
public class AuthService {

    private static final long ACCESS_TOKEN_EXPIRES_IN_SECONDS = 1800;

    private final AuthenticationManager authenticationManager;
    private final JwtTokenProvider jwtTokenProvider;

    public AuthService(
        AuthenticationManager authenticationManager,
        JwtTokenProvider jwtTokenProvider
    ) {
        this.authenticationManager = authenticationManager;
        this.jwtTokenProvider = jwtTokenProvider;
    }

    public TokenResponse login(LoginRequest request) {
        var authenticationToken =
            UsernamePasswordAuthenticationToken.unauthenticated(
                request.email(),
                request.password()
            );

        var authentication =
            authenticationManager.authenticate(authenticationToken);

        String accessToken =
            jwtTokenProvider.createAccessToken(authentication.getName());

        return new TokenResponse(
            "Bearer",
            accessToken,
            ACCESS_TOKEN_EXPIRES_IN_SECONDS
        );
    }
}

인증 과정은 다음과 같습니다.

AuthService
→ AuthenticationManager
→ DaoAuthenticationProvider
→ CustomUserDetailsService
→ PasswordEncoder

비밀번호가 일치하지 않거나 사용자를 찾을 수 없으면 AuthenticationException 예외가 발생합니다.

현재는 기본 오류 응답이 반환될 수 있습니다.

 

11. 로그인 컨트롤러 구현하기

package com.example.jwt.auth;

import jakarta.validation.Valid;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;

@RestController
@RequestMapping("/api/auth")
public class AuthController {

    private final AuthService authService;

    public AuthController(AuthService authService) {
        this.authService = authService;
    }

    @PostMapping("/login")
    public ResponseEntity<TokenResponse> login(
        @Valid @RequestBody LoginRequest request
    ) {
        return ResponseEntity.ok(authService.login(request));
    }
}

12. 테스트 회원 준비하기

로컬 테스트용 회원을 애플리케이션 시작 시 생성할 수 있습니다.

package com.example.jwt.member;

import org.springframework.boot.CommandLineRunner;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.crypto.password.PasswordEncoder;

@Configuration
public class TestDataConfig {

    @Bean
    CommandLineRunner createTestUser(
        MemberRepository memberRepository,
        PasswordEncoder passwordEncoder
    ) {
        return args -> {
            if (memberRepository.findByEmail("user@example.com").isEmpty()) {
                memberRepository.save(
                    new Member(
                        "user@example.com",
                        passwordEncoder.encode("test1234!"),
                        MemberRole.USER
                    )
                );
            }
        };
    }
}

테스트 계정을 생성해서 DB에 저장합니다.

패스워드는 passwordEncoder로 인코딩한 값이 저장되는지 DB의 members 테이블에서 확인해 보십시오.

운영 환경에서 고정 비밀번호를 가진 테스트 계정을 자동 생성해서는 안 됩니다.

테스트 데이터 설정은 개발 프로필에서만 실행되도록 분리하는 것이 안전합니다.

 

13. 로그인 API 호출하기

REST Client나 API 테스트 도구에서 다음 요청을 json으로 보냅니다.

POST /api/auth/login
Content-Type: application/json

{
  "email": "user@example.com",
  "password": "test1234!"
}

인증에 성공하면 다음과 비슷한 응답을 받습니다.

{
  "tokenType": "Bearer",
  "accessToken": "eyJhbGciOiJIUzI1NiJ9...",
  "expiresIn": 1800
}

지금 발급된 JWT만으로는 보호된 API에 접근할 수 없습니다.

아직 서버가 요청 헤더에서 토큰을 읽고 검증한 뒤 Spring Security의 인증정보로 변환하는 기능을 만들지 않았기 때문입니다.

이 과정은 다음 2편에서 구현합니다.

 

14. 이번 구현에서 확인할 점

JWT Payload에 비밀번호를 넣지 않았는가?

Payload는 암호화된 저장공간이 아닙니다.

사용자 식별과 권한 판단에 필요한 최소한의 값만 넣어야 합니다.

서명 키를 소스 코드에 넣지 않았는가?

운영 키는 환경변수, 시크릿 관리자 또는 배포 플랫폼의 비밀 설정 기능으로 관리해야 합니다.

비밀번호를 평문으로 저장하지 않았는가?

회원가입과 초기 데이터 생성 과정에서 PasswordEncoder.encode()가 적용됐는지 확인해야 합니다.

토큰 만료 시간을 정했는가?

만료 시간이 길거나 만료되지 않는 액세스 토큰은 유출됐을 때 장기간 악용될 수 있습니다.

로그인 실패 원인을 지나치게 자세히 노출하지 않았는가?

“가입되지 않은 이메일”과 “잘못된 비밀번호”를 외부 응답에서 명확히 구분하면 계정 존재 여부를 추측하는 데 악용될 수 있습니다.

 

자주 묻는 질문

JWT를 사용하면 비밀번호 인증이 필요 없나요?

아닙니다. 일반적인 로그인에서는 먼저 아이디와 비밀번호를 검증한 다음 JWT를 발급합니다.

이후 요청에서 비밀번호 대신 토큰을 사용합니다.

JWT에 권한을 반드시 넣어야 하나요?

반드시 넣어야 하는 것은 아닙니다.

요청마다 데이터베이스에서 사용자의 현재 권한을 조회할 수도 있고, 토큰에 권한을 포함할 수도 있습니다.

권한 변경이 즉시 반영되어야 하는지 등을 고려해 선택해야 합니다.

이메일과 회원 ID 중 무엇을 subject로 사용해야 하나요?

이메일이 변경될 수 있는 서비스라면 변하지 않는 내부 회원 ID가 관리하기 편할 수 있습니다.

다만 토큰을 읽은 뒤 회원 조회에 사용할 식별 방식까지 함께 설계해야 합니다.

JWT 서명 키를 application.yml에 저장해도 되나요?

공개 저장소에 올라가지 않는 로컬 설정에서는 사용할 수 있지만, 운영 키를 일반 설정 파일이나 Git 저장소에 포함해서는 안 됩니다.

CSRF를 비활성화하면 안전한가요?

인증 토큰을 Authorization 헤더로만 전달하고 브라우저가 자동 전송하는 인증 쿠키를 사용하지 않는 API라면 일반적으로 세션 기반 웹 애플리케이션과 위험 구조가 다릅니다.

그러나 쿠키 기반 인증을 사용한다면 CSRF 방어를 별도로 검토해야 합니다.

 

 

내용 정리

이번 글에서는 사용자 조회, 비밀번호 검증, 로그인 성공 처리, JWT 액세스 토큰 발급을 구현했습니다.

  • CustomUserDetailsService: 사용자 조회
  • PasswordEncoder: 비밀번호 해시와 비교
  • AuthenticationManager: 로그인 인증 처리
  • JwtTokenProvider: JWT 생성
  • AuthService: 로그인 흐름 조정
  • SecurityConfig: 로그인 API 접근 정책 설정

2편에서는 발급된 토큰을 Authorization 헤더로 전달하고, JWT 인증 필터가 토큰을 검증해 보호된 API에 접근시키는 과정을 구현합니다. 

https://aacii.tistory.com/503

 

 

 

 

 

 

 

 

 

더 이상의 자세한 설명은 생략

728x90