Skip to content

Commit 9792528

Browse files
authored
Merge pull request #326 from DevKor-github/feature/jwt-auth-query-count-325
Reduce JWT auth user lookup count
2 parents 8b62ec8 + 12a7265 commit 9792528

25 files changed

Lines changed: 1485 additions & 27 deletions

.gitignore

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
11
.DS_Store
22
.idea/
33
.codex/
4+
scripts/benchmarks/*/results/*
5+
!scripts/benchmarks/*/results/.gitkeep
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
# Apple Login Performance Evidence for Issue #323
2+
3+
## Measurement Contract
4+
5+
This document records before/after evidence for reducing Apple login latency by removing repeated Apple provider round trips from the returning Apple User login path.
6+
7+
Primary benchmark environment:
8+
9+
- Backend: local Spring Boot process with `bench` profile
10+
- Database: isolated Docker MySQL database `ontime_bench`
11+
- Apple provider: local stub
12+
- Returning Apple User: `social_type=APPLE`, `social_id=bench-apple-user`
13+
- Stub delay:
14+
- `GET /auth/keys`: 80 ms
15+
- `POST /auth/token`: 300 ms
16+
- Warmup: 2 minutes
17+
- Measurement: 5 minutes
18+
- Full run count: 10 per scenario
19+
20+
The benchmark harness lives in `scripts/benchmarks/apple-login/`.
21+
22+
## Acceptance Gate
23+
24+
Primary scenario: returning Apple User, warm cache, concurrency 1.
25+
26+
- JWKS network calls/request: `before 1.0 -> after 0.0`
27+
- Apple token exchange calls/request: `before 1.0 -> after 0.0`
28+
- Error rate: `0%`
29+
- p95 latency: `after <= before * 0.70`
30+
31+
Secondary scenarios: returning Apple User, concurrency 10 and 20.
32+
33+
- Error rate: `0%`
34+
- Apple token exchange calls/request: `after 0.0`
35+
- p95 latency: `after <= before`
36+
37+
## Results
38+
39+
Populate this table from `scripts/benchmarks/apple-login/results/*/summary.csv` after running both before and after measurements.
40+
41+
| scenario | version | runs | requests | error_rate | p50_ms | p95_ms | p99_ms | jwks_calls/request | token_exchange_calls/request |
42+
| --- | --- | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: |
43+
| c1 returning warm-cache | before | TBD | TBD | TBD | TBD | TBD | TBD | TBD | TBD |
44+
| c1 returning warm-cache | after | TBD | TBD | TBD | TBD | TBD | TBD | TBD | TBD |
45+
| c10 returning warm-cache | before | TBD | TBD | TBD | TBD | TBD | TBD | TBD | TBD |
46+
| c10 returning warm-cache | after | TBD | TBD | TBD | TBD | TBD | TBD | TBD | TBD |
47+
| c20 returning warm-cache | before | TBD | TBD | TBD | TBD | TBD | TBD | TBD | TBD |
48+
| c20 returning warm-cache | after | TBD | TBD | TBD | TBD | TBD | TBD | TBD | TBD |
49+
50+
## Notes
51+
52+
- Real Apple network calls are intentionally excluded from the primary benchmark because provider and network variability would obscure backend request-path changes.
53+
- The script labels results as `before` or `after` but does not change git refs. Select the checkout explicitly before running.
54+
- Quick mode is for development feedback. Full mode is the acceptance evidence.

docs/performance/jwt-auth-325.md

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
# JWT Authentication Performance Evidence for Issue #325
2+
3+
## Measurement Contract
4+
5+
This document records before/after evidence for removing duplicate `User` reads from the protected API JWT authentication path.
6+
7+
Primary benchmark environment:
8+
9+
- Backend: local Spring Boot process with `bench` profile
10+
- Database: isolated Docker MySQL database `ontime_jwt_bench`
11+
- Protected endpoint: `GET /users/me/punctuality-score`
12+
- Benchmark User: created through `POST /sign-up`
13+
- Warmup: 20 protected requests
14+
- Measurement: 5 minutes
15+
- Full run count: 10 per scenario
16+
17+
The benchmark harness lives in `scripts/benchmarks/jwt-auth/`.
18+
19+
Recorded quick evidence run:
20+
21+
- before: `scripts/benchmarks/jwt-auth/results/20260628T042635Z-before-quick/summary.csv`
22+
- after: `scripts/benchmarks/jwt-auth/results/20260628T044205Z-after-quick/summary.csv`
23+
- mode: `quick`
24+
- runs: 1 per scenario
25+
26+
## Acceptance Gate
27+
28+
Primary correctness gate:
29+
30+
- Authentication `User` lookup count: `before 2 -> after 1`
31+
- `GET /users/me/punctuality-score` whole-request SQL budget: `after <= 5`
32+
- Invalid, expired, and replaced access token behavior remains compatible
33+
- Refresh token rotation behavior remains compatible
34+
- `./gradlew check` passes
35+
36+
Benchmark evidence:
37+
38+
- Error rate: `0%`
39+
- p95 latency: `after <= before`
40+
- Whole-request SQL budget sample: `after <= before - 1`
41+
42+
## Results
43+
44+
Populate this table from `scripts/benchmarks/jwt-auth/results/*/summary.csv` and `sql-budget-*.txt` after running both before and after measurements.
45+
46+
| scenario | version | runs | requests | error_rate | p50_ms | p95_ms | p99_ms | whole_request_sql_sample |
47+
| --- | --- | ---: | ---: | ---: | ---: | ---: | ---: | ---: |
48+
| c1 protected request | before | 1 | 2273 | 0 | 28.190 | 46.770 | 53.554 | 7 |
49+
| c1 protected request | after | 1 | 2404 | 0 | 21.664 | 38.093 | 48.579 | 5 |
50+
| c10 protected request | before | 1 | 21804 | 0 | 35.879 | 52.873 | 62.045 | 7 |
51+
| c10 protected request | after | 1 | 23409 | 0 | 26.412 | 40.129 | 48.728 | 5 |
52+
| c20 protected request | before | 1 | 47625 | 0 | 23.957 | 36.560 | 72.784 | 7 |
53+
| c20 protected request | after | 1 | 49550 | 0 | 19.597 | 29.620 | 39.054 | 5 |
54+
55+
## Quick Run Delta
56+
57+
| scenario | p95_delta_ms | p95_delta_pct | sql_sample_delta |
58+
| --- | ---: | ---: | ---: |
59+
| c1 protected request | -8.678 | -18.6% | -2 |
60+
| c10 protected request | -12.744 | -24.1% | -2 |
61+
| c20 protected request | -6.940 | -19.0% | -2 |
62+
63+
## Notes
64+
65+
- Query-count correctness is intentionally enforced by automated tests because latency is sensitive to JVM warmup, local machine load, and database state.
66+
- The protected endpoint includes existing API logging work, so the whole-request SQL budget is higher than the authentication-only `User` lookup count.
67+
- The script labels results as `before` or `after` but does not change git refs. Select the checkout explicitly before running.
68+
- Quick mode is for development feedback. Full mode is the acceptance evidence.

ontime-back/src/main/java/devkor/ontime_back/config/SecurityConfig.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -136,7 +136,7 @@ public CustomJsonUsernamePasswordAuthenticationFilter customJsonUsernamePassword
136136

137137
@Bean
138138
public JwtAuthenticationFilter jwtAuthenticationProcessingFilter() {
139-
JwtAuthenticationFilter jwtAuthenticationFilter = new JwtAuthenticationFilter(jwtTokenProvider, userRepository, authTokenService);
139+
JwtAuthenticationFilter jwtAuthenticationFilter = new JwtAuthenticationFilter(jwtTokenProvider, authTokenService);
140140
return jwtAuthenticationFilter;
141141
}
142142

ontime-back/src/main/java/devkor/ontime_back/global/jwt/JwtAuthenticationFilter.java

Lines changed: 6 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,6 @@
22

33
import com.fasterxml.jackson.databind.ObjectMapper;
44
import devkor.ontime_back.entity.User;
5-
import devkor.ontime_back.repository.UserRepository;
65
import devkor.ontime_back.response.*;
76
import devkor.ontime_back.service.AuthTokenService;
87
import jakarta.servlet.DispatcherType;
@@ -36,7 +35,6 @@ public class JwtAuthenticationFilter extends OncePerRequestFilter {
3635
private static final List<String> NO_CHECK_URLS = List.of("/login", "/health", "/actuator/health", "/swagger-ui", "/sign-up", "/account-deletion", "/privacy-policy", "/v3/api-docs", "/oauth2/google/login", "/oauth2/kakao/login", "/oauth2/apple/login");
3736

3837
private final JwtTokenProvider jwtTokenProvider;
39-
private final UserRepository userRepository;
4038
private final AuthTokenService authTokenService;
4139

4240
private GrantedAuthoritiesMapper authoritiesMapper = new NullAuthoritiesMapper();
@@ -71,8 +69,8 @@ protected void doFilterInternal(HttpServletRequest request, HttpServletResponse
7169
// 이제부터는 엑세스 토큰'만' 헤더에 담긴 요청만 생각하면 됨
7270

7371
// 엑세스 토큰이 있고, 유효할 경우 checkAccessTokenAndAuthentication 메서드 호출해 권한정보 저장하고 스프링 시큐리티 필터체인 계속 진행
74-
if (accessToken != null && jwtTokenProvider.isAccessTokenValid(accessToken)) {
75-
checkAccessTokenAndAuthentication(request, response, filterChain);
72+
if (accessToken != null) {
73+
checkAccessTokenAndAuthentication(accessToken, request, response, filterChain);
7674
}
7775

7876
// 엑세스 토큰이 없는 경우 EmptyAccessTokenException 발생
@@ -98,16 +96,12 @@ public void reIssueAccessToken(HttpServletResponse response, String refreshToken
9896
}
9997

10098
// accessToken으로 유저의 권한정보만 저장하고 인증 허가(스프링 시큐리티 필터체인 中 인증체인 통과해 다음 체인으로 이동)
101-
public void checkAccessTokenAndAuthentication(HttpServletRequest request, HttpServletResponse response,
99+
public void checkAccessTokenAndAuthentication(String accessToken, HttpServletRequest request, HttpServletResponse response,
102100
FilterChain filterChain) throws ServletException, IOException {
103101
log.info("Checking access credential authentication");
104-
jwtTokenProvider.extractAccessToken(request)
105-
.ifPresent(accessToken -> jwtTokenProvider.extractUserId(accessToken)
106-
.ifPresent(userId -> {
107-
log.info("Authenticated userId: {}", userId);
108-
userRepository.findById(userId)
109-
.ifPresent(this::saveAuthentication);
110-
}));
102+
User user = authTokenService.validateActiveAccessToken(accessToken);
103+
log.info("Authenticated userId: {}", user.getId());
104+
saveAuthentication(user);
111105

112106
filterChain.doFilter(request, response);
113107
}

ontime-back/src/main/java/devkor/ontime_back/global/oauth/apple/AppleLoginService.java

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,10 @@ public class AppleLoginService {
5454
private static final String APPLE_KEYS_URL = "https://appleid.apple.com/auth/keys";
5555
private static final String APPLE_TOKEN_URL = "https://appleid.apple.com/auth/token";
5656
private String issuer = "https://appleid.apple.com";
57+
@Value("${apple.keys-url:" + APPLE_KEYS_URL + "}")
58+
private String appleKeysUrl = APPLE_KEYS_URL;
59+
@Value("${apple.token-url:" + APPLE_TOKEN_URL + "}")
60+
private String appleTokenUrl = APPLE_TOKEN_URL;
5761
@Value("${apple.client.id}")
5862
private String clientId;
5963
@Value("${apple.team.id}")
@@ -167,7 +171,7 @@ public Claims verifyIdentityToken(String identityToken) throws
167171
log.info("Verify Apple identity credential");
168172
Map<String, String> headers = jwtUtils.parseHeaders(identityToken);
169173
// apple publickey
170-
ApplePublicKeyResponse applePublicKeyResponse = restTemplate.getForObject(APPLE_KEYS_URL, ApplePublicKeyResponse.class);
174+
ApplePublicKeyResponse applePublicKeyResponse = restTemplate.getForObject(appleKeysUrl, ApplePublicKeyResponse.class);
171175
PublicKey publicKey = applePublicKeyGenerator.generatePublicKey(headers, applePublicKeyResponse);
172176
// claim
173177
Claims tokenClaims = jwtUtils.getTokenClaims(identityToken, publicKey);
@@ -201,7 +205,7 @@ public AppleTokenResponseDto getAppleAccessTokenAndRefreshToken(String authCode)
201205
HttpEntity<MultiValueMap<String, String>> requestEntity = new HttpEntity<>(requestBody, headers);
202206

203207
ResponseEntity<JsonNode> responseEntity = restTemplate.exchange(
204-
APPLE_TOKEN_URL, HttpMethod.POST, requestEntity, JsonNode.class);
208+
appleTokenUrl, HttpMethod.POST, requestEntity, JsonNode.class);
205209

206210
JsonNode response = responseEntity.getBody();
207211

ontime-back/src/main/java/devkor/ontime_back/service/AuthTokenService.java

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
import devkor.ontime_back.global.jwt.JwtTokenProvider;
66
import devkor.ontime_back.repository.UserRefreshTokenRepository;
77
import devkor.ontime_back.repository.UserRepository;
8+
import devkor.ontime_back.response.InvalidAccessTokenException;
89
import devkor.ontime_back.response.InvalidRefreshTokenException;
910
import jakarta.servlet.http.HttpServletResponse;
1011
import lombok.RequiredArgsConstructor;
@@ -19,6 +20,20 @@ public class AuthTokenService {
1920
private final UserRefreshTokenRepository userRefreshTokenRepository;
2021
private final UserRepository userRepository;
2122

23+
@Transactional(readOnly = true)
24+
public User validateActiveAccessToken(String accessToken) {
25+
Long userId = jwtTokenProvider.extractUserId(accessToken)
26+
.orElseThrow(() -> new InvalidAccessTokenException("유효하지 않은 엑세스 토큰입니다."));
27+
User user = userRepository.findById(userId)
28+
.orElseThrow(() -> new InvalidAccessTokenException("유효하지 않은 엑세스 토큰입니다."));
29+
30+
if (!accessToken.equals(user.getAccessToken())) {
31+
throw new InvalidAccessTokenException("유효하지 않은 엑세스 토큰입니다.");
32+
}
33+
34+
return user;
35+
}
36+
2237
@Transactional
2338
public AuthTokens issueLoginTokens(User user, HttpServletResponse response) {
2439
userRefreshTokenRepository.deleteByUser(user);
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
# Database Configuration
2+
spring.datasource.url=${SPRING_DATASOURCE_URL}
3+
spring.datasource.username=${SPRING_DATASOURCE_USERNAME}
4+
spring.datasource.password=${SPRING_DATASOURCE_PASSWORD}
5+
spring.datasource.driver-class-name=${SPRING_DATASOURCE_DRIVER_CLASS_NAME:com.mysql.cj.jdbc.Driver}
6+
7+
# JPA / Hibernate
8+
spring.jpa.database=mysql
9+
spring.jpa.database-platform=org.hibernate.dialect.MySQL8Dialect
10+
spring.jpa.hibernate.ddl-auto=validate
11+
spring.jpa.show-sql=false
12+
spring.jpa.properties.hibernate.format_sql=false
13+
14+
# Flyway
15+
spring.flyway.enabled=true
16+
spring.flyway.baseline-on-migrate=false
17+
18+
# JWT Configuration
19+
jwt.secret.key=${JWT_SECRET_KEY}
20+
jwt.access.expiration=${JWT_ACCESS_EXPIRATION:3600000}
21+
jwt.refresh.expiration=${JWT_REFRESH_EXPIRATION:1209600000}
22+
jwt.access.header=${JWT_ACCESS_HEADER:Authorization}
23+
jwt.refresh.header=${JWT_REFRESH_HEADER:Authorization-refresh}
24+
25+
# OAuth
26+
google.web.client-id=${GOOGLE_WEB_CLIENT_ID:bench-google-web-client-id}
27+
google.app.client-id=${GOOGLE_APP_CLIENT_ID:bench-google-app-client-id}
28+
apple.client.id=${APPLE_CLIENT_ID}
29+
apple.team.id=${APPLE_TEAM_ID}
30+
apple.login.key=${APPLE_LOGIN_KEY}
31+
apple.client.secret=${APPLE_CLIENT_SECRET:}
32+
apple.private-key.base64=${APPLE_PRIVATE_KEY_BASE64}
33+
apple.keys-url=${APPLE_KEYS_URL:https://appleid.apple.com/auth/keys}
34+
apple.token-url=${APPLE_TOKEN_URL:https://appleid.apple.com/auth/token}
35+
36+
# Firebase
37+
firebase.credentials.base64=${FIREBASE_CREDENTIALS_BASE64:}
38+
39+
# Logging
40+
logging.level.root=WARN
41+
logging.level.devkor.ontime_back=INFO
42+
43+
# Feature flags
44+
feature.apple-login.enabled=${FEATURE_APPLE_LOGIN_ENABLED:true}
45+
analytics.preference.default-enabled=${ANALYTICS_PREFERENCE_DEFAULT_ENABLED:false}
46+
47+
# Actuator
48+
management.endpoint.health.probes.enabled=true
49+
management.endpoints.web.exposure.include=health
50+
management.health.readinessstate.enabled=true
51+
management.health.livenessstate.enabled=true
52+
server.shutdown=graceful
53+
spring.lifecycle.timeout-per-shutdown-phase=30s
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
ALTER TABLE preparation_user
2+
MODIFY COLUMN preparation_name VARCHAR(50) NOT NULL;
3+
4+
ALTER TABLE preparation_schedule
5+
MODIFY COLUMN preparation_name VARCHAR(50) NOT NULL;

ontime-back/src/main/resources/db/migration/V14__add_preparation_templates_and_modes.sql renamed to ontime-back/src/main/resources/db/migration/V18__add_preparation_templates_and_modes.sql

File renamed without changes.

0 commit comments

Comments
 (0)