1. Spring Boot 4 + JWT 인증 구현기 (feat. PostgreSQL, AWS)

yoon·2026년 3월 28일

스프링 인증/인가

목록 보기
1/6

개요

Spring Boot 4.0.4 기반 프로젝트에서 JWT 인증/인가를 구현한 과정을 기록합니다.

  • Spring Boot: 4.0.4
  • Java: 17
  • DB: PostgreSQL (AWS RDS)
  • JWT 라이브러리: jjwt 0.13.0

1. 의존성 추가

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

// PostgreSQL
runtimeOnly 'org.postgresql:postgresql'

jjwt 버전 주의사항

Spring Boot 4.0은 Spring Framework 7 + Jakarta EE 11 기반이에요. jjwt는 0.13.0 버전을 써야 해요.

그리고 0.12.x 이상부터 API가 크게 바뀌었어요.

구버전 (0.11.x)신버전 (0.12.x+)
Jwts.parserBuilder()Jwts.parser()
.setSigningKey(key).verifyWith(secretKey)
.parseClaimsJws(token).parseSignedClaims(token)
.getBody().getPayload()
.setSubject().subject()
.setIssuedAt().issuedAt()
.setExpiration().expiration()

2. YAML 설정

application.yaml

spring:
  application:
    name: replan
  datasource:
    url: jdbc:postgresql://${DB_HOST}:${DB_PORT}/${DB_NAME}
    username: ${DB_USER}
    password: ${DB_PASSWORD}
    driver-class-name: org.postgresql.Driver
  jpa:
    hibernate:
      ddl-auto: update
    show-sql: false
    properties:
      hibernate:
        dialect: org.hibernate.dialect.PostgreSQLDialect

management:
  endpoints:
    web:
      exposure:
        include: health
  endpoint:
    health:
      show-details: never

jwt:
  secret: ${JWT_SECRET}
  access-expiration: 900000    # 15분
  refresh-expiration: 604800000 # 7일

민감한 정보는 모두 환경변수로 관리해요. 실제 값은 절대 yaml에 넣지 않아요.

application-local.yaml

spring:
  datasource:
    url: jdbc:postgresql://localhost:5432/replan
    username: postgres
    password: 1234
    driver-class-name: org.postgresql.Driver
  jpa:
    hibernate:
      ddl-auto: create-drop
    show-sql: true
    properties:
      hibernate:
        dialect: org.hibernate.dialect.PostgreSQLDialect

로컬 개발 시 Docker로 PostgreSQL을 띄워요.

docker run -d -p 5432:5432 \
  -e POSTGRES_PASSWORD=1234 \
  -e POSTGRES_DB=replan \
  postgres:16

테스트 환경 (CI/CD)

GitHub Actions에서 빌드할 때 PostgreSQL이 없어서 테스트가 실패해요. src/test/resources/application.yaml을 만들어서 H2로 테스트하도록 설정해요.

spring:
  datasource:
    url: jdbc:h2:mem:testdb
    driver-class-name: org.h2.Driver
  jpa:
    hibernate:
      ddl-auto: create-drop
    properties:
      hibernate:
        dialect: org.hibernate.dialect.H2Dialect

jwt:
  secret: testSecretKeyForTestingPurposeOnly1234
  access-expiration: 900000
  refresh-expiration: 604800000

3. User 엔티티

@Entity
@Table(name = "users")
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class User {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

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

    @Column
    private String password; // OAuth 로그인 시 null 가능

    @Column(nullable = false)
    private String nickname;

    @Enumerated(EnumType.STRING)
    @Column(nullable = false)
    private Role role;

    @Enumerated(EnumType.STRING)
    @Column(nullable = false)
    private Provider provider;

    // 이메일 로그인용
    public static User createLocalUser(String email, String encodedPassword, String nickname) {
        Objects.requireNonNull(email, "이메일은 필수입니다.");
        Objects.requireNonNull(encodedPassword, "비밀번호는 필수입니다.");
        Objects.requireNonNull(nickname, "닉네임은 필수입니다.");

        User user = new User();
        user.email = email;
        user.password = encodedPassword;
        user.nickname = nickname;
        user.role = Role.ROLE_USER;
        user.provider = Provider.LOCAL;
        return user;
    }

    // OAuth 로그인용
    public static User createOAuthUser(String email, String nickname, Provider provider) {
        Objects.requireNonNull(email, "이메일은 필수입니다.");
        Objects.requireNonNull(nickname, "닉네임은 필수입니다.");
        Objects.requireNonNull(provider, "소셜 제공자는 필수입니다.");

        User user = new User();
        user.email = email;
        user.password = null;
        user.nickname = nickname;
        user.role = Role.ROLE_USER;
        user.provider = provider;
        return user;
    }
}

설계 포인트

@Builder 대신 정적 팩토리 메서드를 쓰는 이유

@Builder는 필수값을 강제할 수 없어요. 정적 팩토리 메서드는 이름으로 의도를 표현하고, Objects.requireNonNull()로 필수값을 강제할 수 있어요.

password가 nullable인 이유

OAuth 로그인 유저는 비밀번호가 없어요. 단, 이메일 로그인 시 서비스 레이어에서 passwordEncoder.encode()로 암호화해서 저장해야 해요.


4. JWT 유틸 클래스

@Component
public class JwtUtil {

    private final SecretKey key;
    private final long accessExpiration;
    private final long refreshExpiration;

    public JwtUtil(
            @Value("${jwt.secret}") String secret,
            @Value("${jwt.access-expiration}") long accessExpiration,
            @Value("${jwt.refresh-expiration}") long refreshExpiration
    ) {
        this.key = Keys.hmacShaKeyFor(secret.getBytes());
        this.accessExpiration = accessExpiration;
        this.refreshExpiration = refreshExpiration;
    }

    public String generateAccessToken(String email, String role) {
        return Jwts.builder()
                .subject(email)
                .claim("role", role)
                .issuedAt(new Date())
                .expiration(new Date(System.currentTimeMillis() + accessExpiration))
                .signWith(key)
                .compact();
    }

    public String generateRefreshToken(String email) {
        return Jwts.builder()
                .subject(email)
                .issuedAt(new Date())
                .expiration(new Date(System.currentTimeMillis() + refreshExpiration))
                .signWith(key)
                .compact();
    }

    public String getEmail(String token) {
        return getClaims(token).getSubject();
    }

    public String getRole(String token) {
        return getClaims(token).get("role", String.class);
    }

    public boolean validateToken(String token) {
        getClaims(token);
        return true;
    }

    private Claims getClaims(String token) {
        try {
            return Jwts.parser()
                    .verifyWith(key)
                    .build()
                    .parseSignedClaims(token)
                    .getPayload();
        } catch (ExpiredJwtException e) {
            throw new CustomException(JwtErrorCode.EXPIRED_TOKEN);
        } catch (JwtException e) {
            throw new CustomException(JwtErrorCode.INVALID_TOKEN);
        } catch (IllegalArgumentException e) {
            throw new CustomException(JwtErrorCode.EMPTY_TOKEN);
        }
    }
}

설계 포인트

만료시간을 필드로 관리하는 이유

호출할 때마다 만료시간을 파라미터로 넘기면 여러 곳에서 제각각이 될 수 있어요. yaml에서 한 번만 설정하고 필드로 관리하면 중앙에서 일관되게 관리할 수 있어요.

getClaims()를 private으로 분리한 이유

getEmail(), getRole(), validateToken() 모두 토큰 파싱이 필요해요. 중복 코드를 없애고 예외처리를 한 곳에서 관리하기 위해 분리했어요.

Keys.hmacShaKeyFor() vs SecretKeySpec

Keys.hmacShaKeyFor()는 키 길이를 체크해서 256비트 이상이면 HS256, 384비트 이상이면 HS384, 512비트 이상이면 HS512를 자동으로 선택해요. SecretKeySpec으로 직접 알고리즘을 지정하면 jjwt 내부에서 키 길이 기반으로 다시 결정해버리는 버그가 있어요.

JWT ErrorCode

@Getter
@RequiredArgsConstructor
public enum JwtErrorCode implements ErrorCode {

    INVALID_TOKEN(401, "유효하지 않은 토큰입니다."),
    EXPIRED_TOKEN(401, "만료된 토큰입니다."),
    UNSUPPORTED_TOKEN(401, "지원하지 않는 토큰입니다."),
    EMPTY_TOKEN(401, "토큰이 없습니다.");

    private final int status;
    private final String message;
}

5. 패키지 구조

src/main/java/plana/replan
├── domain
│   └── user
│       └── entity
│           ├── User.java
│           ├── Role.java
│           └── Provider.java
└── global
    ├── config
    │   ├── SecurityConfig.java
    │   └── SwaggerConfig.java
    ├── exception
    │   ├── ErrorCode.java
    │   ├── CustomException.java
    │   └── GlobalErrorCode.java
    └── jwt
        ├── JwtUtil.java
        └── JwtErrorCode.java

마치며

다음 포스팅에서는 JwtFilter, SecurityConfig, 로그인/회원가입 API 구현을 다룰 예정이에요.

0개의 댓글