Skip to content

Commit 4946e95

Browse files
authored
Merge pull request #327 from DevKor-github/feature/apple-login-latency-evidence-323
[P1] Reduce Apple login provider round trips
2 parents 020e52b + 2fc2c6c commit 4946e95

18 files changed

Lines changed: 1002 additions & 19 deletions

File tree

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
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 Users: `social_type=APPLE`, `social_id=bench-apple-user-<vu>`
13+
- Stub delay:
14+
- `GET /auth/keys`: 80 ms
15+
- `POST /auth/token`: 300 ms
16+
- Quick run: default harness settings, 2 minutes warmup and 5 minutes measurement, 1 run per scenario
17+
- Full evidence run: bounded local run, 30 seconds warmup and 60 seconds measurement, 3 runs per scenario
18+
- Before checkout: `origin/main` (`ab5fc7f`) plus benchmark harness only (`85b2b18`)
19+
- After checkout: same benchmark harness plus the #323 fix under test
20+
21+
The benchmark harness lives in `scripts/benchmarks/apple-login/`.
22+
23+
## Acceptance Gate
24+
25+
Primary scenario: returning Apple User, warm cache, concurrency 1.
26+
27+
- JWKS network calls/request: `before 1.0 -> after 0.0`
28+
- Apple token exchange calls/request: `before 1.0 -> after 0.0`
29+
- Error rate: `0%`
30+
- p95 latency: `after <= before * 0.70`
31+
32+
Secondary scenarios: returning Apple User, concurrency 10 and 20.
33+
34+
- Error rate: `0%`
35+
- Apple token exchange calls/request: `after 0.0`
36+
- p95 latency: `after <= before`
37+
38+
## Results
39+
40+
The full rows aggregate 3 runs per scenario by summing requests/provider calls and averaging per-run p50/p95 latency.
41+
42+
| mode | scenario | version | runs | requests | error_rate | p50_ms | p95_ms | jwks_calls/request | token_exchange_calls/request |
43+
| --- | --- | --- | ---: | ---: | ---: | ---: | ---: | ---: | ---: |
44+
| quick | c1 returning warm-cache | before | 1 | 590 | 0.000 | 405.6 | 425.5 | 1.000 | 1.000 |
45+
| quick | c1 returning warm-cache | after | 1 | 2,327 | 0.000 | 26.0 | 40.1 | 0.000 | 0.000 |
46+
| quick | c10 returning warm-cache | before | 1 | 5,773 | 0.000 | 417.9 | 435.1 | 1.000 | 1.000 |
47+
| quick | c10 returning warm-cache | after | 1 | 22,366 | 0.000 | 33.7 | 46.5 | 0.000 | 0.000 |
48+
| quick | c20 returning warm-cache | before | 1 | 11,745 | 0.000 | 408.0 | 431.2 | 1.000 | 1.000 |
49+
| quick | c20 returning warm-cache | after | 1 | 47,844 | 0.000 | 20.7 | 46.8 | 0.000 | 0.000 |
50+
| full | c1 returning warm-cache | before | 3 | 346 | 0.000 | 413.6 | 435.8 | 1.000 | 1.000 |
51+
| full | c1 returning warm-cache | after | 3 | 1,407 | 0.000 | 23.1 | 46.5 | 0.000 | 0.000 |
52+
| full | c10 returning warm-cache | before | 3 | 3,492 | 0.000 | 415.1 | 429.1 | 1.000 | 1.000 |
53+
| full | c10 returning warm-cache | after | 3 | 13,425 | 0.000 | 29.3 | 58.5 | 0.000 | 0.000 |
54+
| full | c20 returning warm-cache | before | 3 | 6,829 | 0.000 | 420.4 | 506.9 | 1.000 | 1.000 |
55+
| full | c20 returning warm-cache | after | 3 | 27,824 | 0.000 | 19.9 | 60.4 | 0.000 | 0.000 |
56+
57+
## Result Files
58+
59+
- Quick before: `scripts/benchmarks/apple-login/results/20260628T043649Z-before-quick/summary.csv`
60+
- Quick after: `scripts/benchmarks/apple-login/results/20260628T045808Z-after-quick/summary.csv`
61+
- Full before: `scripts/benchmarks/apple-login/results/20260628T051949Z-before-full/summary.csv`
62+
- Full after: `scripts/benchmarks/apple-login/results/20260628T053451Z-after-full/summary.csv`
63+
64+
## Decision
65+
66+
The #323 fix passes the acceptance gate in both quick and bounded full runs:
67+
68+
- c1 p95 improved from 435.8 ms to 46.5 ms in the full run, an 89.3% reduction.
69+
- Returning Apple User login used 0.0 JWKS network calls/request after warmup.
70+
- Returning Apple User login used 0.0 Apple token exchange calls/request after the fix.
71+
- Error rate stayed at 0% across c1, c10, and c20.
72+
73+
## Notes
74+
75+
- Real Apple network calls are intentionally excluded from the primary benchmark because provider and network variability would obscure backend request-path changes.
76+
- The script labels results as `before` or `after` but does not change git refs. Select the checkout explicitly before running.
77+
- The original single-user concurrency harness caused c10 failures unrelated to #323 because concurrent logins for one **User** raced on single-active-session token rotation. The harness now seeds one returning Apple User per VU so c10/c20 measure Apple provider round trips rather than same-user session contention.

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

Lines changed: 25 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -74,8 +74,9 @@ public Authentication attemptAuthentication(HttpServletRequest request, HttpServ
7474
}
7575

7676
try {
77-
// Apple Identity Token 검증
77+
long startedAt = System.nanoTime();
7878
Claims tokenClaims = appleLoginService.verifyIdentityToken(oAuthAppleRequestDto.getIdToken());
79+
long verifiedAt = System.nanoTime();
7980
if (tokenClaims.getSubject() == null) {
8081
throw new IllegalStateException("Apple 로그인 검증 실패");
8182
}
@@ -85,18 +86,21 @@ public Authentication attemptAuthentication(HttpServletRequest request, HttpServ
8586
oAuthAppleRequestDto.getEmail(),
8687
tokenClaims.get("email", String.class)
8788
);
88-
String appleRefreshToken = exchangeAppleRefreshToken(oAuthAppleRequestDto);
89-
9089
OAuthAppleUserDto oAuthAppleUserDto = new OAuthAppleUserDto(appleUserId, email, oAuthAppleRequestDto.getFullName());
9190

9291
Optional<User> existingUser = userRepository.findBySocialTypeAndSocialId(SocialType.APPLE, appleUserId);
92+
long userLookupAt = System.nanoTime();
9393

9494
if (existingUser.isPresent()) {
95-
return appleLoginService.handleLogin(appleRefreshToken, existingUser.get(), response);
96-
} else {
97-
return appleLoginService.handleRegister(appleRefreshToken, oAuthAppleUserDto, response);
95+
logAppleLoginTiming("existing_user", startedAt, verifiedAt, userLookupAt, userLookupAt);
96+
return appleLoginService.handleLogin(null, existingUser.get(), response);
9897
}
9998

99+
String appleRefreshToken = exchangeAppleRefreshToken(oAuthAppleRequestDto);
100+
long credentialExchangeAt = System.nanoTime();
101+
logAppleLoginTiming("new_user", startedAt, verifiedAt, userLookupAt, credentialExchangeAt);
102+
return appleLoginService.handleRegister(appleRefreshToken, oAuthAppleUserDto, response);
103+
100104
} catch (Exception e) {
101105
log.error("Apple login failed", e);
102106
throw new AppleLoginException();
@@ -115,6 +119,21 @@ private String exchangeAppleRefreshToken(OAuthAppleRequestDto oAuthAppleRequestD
115119
}
116120
}
117121

122+
private void logAppleLoginTiming(String result, long startedAt, long verifiedAt, long userLookupAt, long credentialExchangeAt) {
123+
log.info(
124+
"Apple login stage timing result={} verifyMs={} dbLookupMs={} credentialExchangeMs={} totalMs={}",
125+
result,
126+
elapsedMillis(startedAt, verifiedAt),
127+
elapsedMillis(verifiedAt, userLookupAt),
128+
elapsedMillis(userLookupAt, credentialExchangeAt),
129+
elapsedMillis(startedAt, credentialExchangeAt)
130+
);
131+
}
132+
133+
private long elapsedMillis(long from, long to) {
134+
return (to - from) / 1_000_000;
135+
}
136+
118137
private String firstNonBlank(String first, String second) {
119138
if (first != null && !first.isBlank()) {
120139
return first;

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

Lines changed: 35 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@
4343
import java.security.PrivateKey;
4444
import java.security.PublicKey;
4545
import java.security.spec.PKCS8EncodedKeySpec;
46+
import java.time.Duration;
4647
import java.time.Instant;
4748
import java.util.*;
4849

@@ -53,7 +54,12 @@ public class AppleLoginService {
5354

5455
private static final String APPLE_KEYS_URL = "https://appleid.apple.com/auth/keys";
5556
private static final String APPLE_TOKEN_URL = "https://appleid.apple.com/auth/token";
57+
private static final Duration APPLE_PUBLIC_KEY_CACHE_TTL = Duration.ofHours(24);
5658
private String issuer = "https://appleid.apple.com";
59+
@Value("${apple.keys-url:" + APPLE_KEYS_URL + "}")
60+
private String appleKeysUrl = APPLE_KEYS_URL;
61+
@Value("${apple.token-url:" + APPLE_TOKEN_URL + "}")
62+
private String appleTokenUrl = APPLE_TOKEN_URL;
5763
@Value("${apple.client.id}")
5864
private String clientId;
5965
@Value("${apple.team.id}")
@@ -76,6 +82,9 @@ public class AppleLoginService {
7682
private final AuthTokenService authTokenService;
7783

7884
private final RestTemplate restTemplate = new RestTemplate();
85+
private ApplePublicKeyResponse cachedApplePublicKeys;
86+
private Instant cachedApplePublicKeysAt;
87+
7988
public Authentication handleLogin(String appleRefreshToken, User user, HttpServletResponse response) throws IOException {
8089
log.info("handleLogin");
8190
if (appleRefreshToken != null && !appleRefreshToken.isBlank()) {
@@ -166,9 +175,7 @@ public Claims verifyIdentityToken(String identityToken) throws
166175
Exception {
167176
log.info("Verify Apple identity credential");
168177
Map<String, String> headers = jwtUtils.parseHeaders(identityToken);
169-
// apple publickey
170-
ApplePublicKeyResponse applePublicKeyResponse = restTemplate.getForObject(APPLE_KEYS_URL, ApplePublicKeyResponse.class);
171-
PublicKey publicKey = applePublicKeyGenerator.generatePublicKey(headers, applePublicKeyResponse);
178+
PublicKey publicKey = resolveApplePublicKey(headers);
172179
// claim
173180
Claims tokenClaims = jwtUtils.getTokenClaims(identityToken, publicKey);
174181
// iss 확인
@@ -188,6 +195,30 @@ public Claims verifyIdentityToken(String identityToken) throws
188195
return tokenClaims;
189196
}
190197

198+
private PublicKey resolveApplePublicKey(Map<String, String> headers) throws Exception {
199+
ApplePublicKeyResponse applePublicKeys = getCachedApplePublicKeys();
200+
try {
201+
return applePublicKeyGenerator.generatePublicKey(headers, applePublicKeys);
202+
} catch (IllegalArgumentException e) {
203+
log.info("Apple public key cache miss for signed credential header; refreshing key set");
204+
return applePublicKeyGenerator.generatePublicKey(headers, refreshApplePublicKeys());
205+
}
206+
}
207+
208+
private synchronized ApplePublicKeyResponse getCachedApplePublicKeys() {
209+
if (cachedApplePublicKeys == null || cachedApplePublicKeysAt == null ||
210+
cachedApplePublicKeysAt.plus(APPLE_PUBLIC_KEY_CACHE_TTL).isBefore(Instant.now())) {
211+
return refreshApplePublicKeys();
212+
}
213+
return cachedApplePublicKeys;
214+
}
215+
216+
private synchronized ApplePublicKeyResponse refreshApplePublicKeys() {
217+
cachedApplePublicKeys = restTemplate.getForObject(appleKeysUrl, ApplePublicKeyResponse.class);
218+
cachedApplePublicKeysAt = Instant.now();
219+
return cachedApplePublicKeys;
220+
}
221+
191222
// apple 서버로부터 accesstoken, refreshtoken 발급
192223
public AppleTokenResponseDto getAppleAccessTokenAndRefreshToken(String authCode) throws Exception {
193224
// clientSecret
@@ -201,7 +232,7 @@ public AppleTokenResponseDto getAppleAccessTokenAndRefreshToken(String authCode)
201232
HttpEntity<MultiValueMap<String, String>> requestEntity = new HttpEntity<>(requestBody, headers);
202233

203234
ResponseEntity<JsonNode> responseEntity = restTemplate.exchange(
204-
APPLE_TOKEN_URL, HttpMethod.POST, requestEntity, JsonNode.class);
235+
appleTokenUrl, HttpMethod.POST, requestEntity, JsonNode.class);
205236

206237
JsonNode response = responseEntity.getBody();
207238

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=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

ontime-back/src/test/java/devkor/ontime_back/global/oauth/OAuthLoginFilterValidationTest.java

Lines changed: 8 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@
4242
import static org.assertj.core.api.Assertions.assertThatThrownBy;
4343
import static org.mockito.Mockito.times;
4444
import static org.mockito.Mockito.verify;
45+
import static org.mockito.Mockito.never;
4546
import static org.mockito.Mockito.verifyNoInteractions;
4647
import static org.mockito.Mockito.when;
4748

@@ -330,7 +331,7 @@ void appleLoginFilterRejectsInvalidRequest() throws Exception {
330331
}
331332

332333
@Test
333-
@DisplayName("애플 로그인 필터가 기존 유저를 Apple refresh token으로 로그인 처리한다")
334+
@DisplayName("애플 로그인 필터가 기존 유저는 Apple token 교환 없이 로그인 처리한다")
334335
void appleLoginFilterLogsInExistingUser() throws Exception {
335336
AppleLoginFilter filter = new AppleLoginFilter(
336337
"/oauth2/apple/login",
@@ -341,27 +342,26 @@ void appleLoginFilterLogsInExistingUser() throws Exception {
341342
MockHttpServletResponse response = new MockHttpServletResponse();
342343
Claims claims = Jwts.claims().setSubject("apple-id");
343344
claims.put("email", "user@example.com");
344-
AppleTokenResponseDto tokenResponse = appleTokenResponse("apple-refresh-token");
345345
User existingUser = user(1L, "user@example.com", Role.USER);
346346

347347
when(appleLoginService.verifyIdentityToken("apple-id-token")).thenReturn(claims);
348-
when(appleLoginService.getAppleAccessTokenAndRefreshToken("auth-code")).thenReturn(tokenResponse);
349348
when(userRepository.findBySocialTypeAndSocialId(SocialType.APPLE, "apple-id"))
350349
.thenReturn(Optional.of(existingUser));
351-
when(appleLoginService.handleLogin("apple-refresh-token", existingUser, response)).thenReturn(
350+
when(appleLoginService.handleLogin(null, existingUser, response)).thenReturn(
352351
new org.springframework.security.authentication.UsernamePasswordAuthenticationToken(existingUser, null)
353352
);
354353

355354
assertThat(filter.attemptAuthentication(
356355
request("/oauth2/apple/login", validAppleBody()),
357356
response).getPrincipal()).isSameAs(existingUser);
358357

359-
verify(appleLoginService).handleLogin("apple-refresh-token", existingUser, response);
358+
verify(appleLoginService, never()).getAppleAccessTokenAndRefreshToken("auth-code");
359+
verify(appleLoginService).handleLogin(null, existingUser, response);
360360
}
361361

362362
@Test
363-
@DisplayName("애플 로그인 필터가 Apple refresh token 교환 실패 시 identity token 검증만으로 기존 유저를 로그인 처리한다")
364-
void appleLoginFilterLogsInExistingUserWhenTokenExchangeFails() throws Exception {
363+
@DisplayName("애플 로그인 필터가 기존 유저 경로에서는 실패 가능한 Apple token 교환을 시도하지 않는다")
364+
void appleLoginFilterSkipsTokenExchangeForExistingUser() throws Exception {
365365
AppleLoginFilter filter = new AppleLoginFilter(
366366
"/oauth2/apple/login",
367367
objectMapper,
@@ -373,8 +373,6 @@ void appleLoginFilterLogsInExistingUserWhenTokenExchangeFails() throws Exception
373373
User existingUser = user(1L, "user@example.com", Role.USER);
374374

375375
when(appleLoginService.verifyIdentityToken("apple-id-token")).thenReturn(claims);
376-
when(appleLoginService.getAppleAccessTokenAndRefreshToken("auth-code"))
377-
.thenThrow(new RuntimeException("invalid_client"));
378376
when(userRepository.findBySocialTypeAndSocialId(SocialType.APPLE, "apple-id"))
379377
.thenReturn(Optional.of(existingUser));
380378
when(appleLoginService.handleLogin(null, existingUser, response)).thenReturn(
@@ -385,6 +383,7 @@ void appleLoginFilterLogsInExistingUserWhenTokenExchangeFails() throws Exception
385383
request("/oauth2/apple/login", validAppleBody()),
386384
response).getPrincipal()).isSameAs(existingUser);
387385

386+
verify(appleLoginService, never()).getAppleAccessTokenAndRefreshToken("auth-code");
388387
verify(appleLoginService).handleLogin(null, existingUser, response);
389388
}
390389

0 commit comments

Comments
 (0)