Spring Boot 4.0.4 기반 프로젝트에서 JWT 인증/인가를 구현한 과정을 기록합니다.
// 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'
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() |
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에 넣지 않아요.
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
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
@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()로 암호화해서 저장해야 해요.
@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 내부에서 키 길이 기반으로 다시 결정해버리는 버그가 있어요.
@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;
}
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 구현을 다룰 예정이에요.