Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
77 changes: 77 additions & 0 deletions docs/performance/apple-login-323.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
# Apple Login Performance Evidence for Issue #323

## Measurement Contract

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.

Primary benchmark environment:

- Backend: local Spring Boot process with `bench` profile
- Database: isolated Docker MySQL database `ontime_bench`
- Apple provider: local stub
- Returning Apple Users: `social_type=APPLE`, `social_id=bench-apple-user-<vu>`
- Stub delay:
- `GET /auth/keys`: 80 ms
- `POST /auth/token`: 300 ms
- Quick run: default harness settings, 2 minutes warmup and 5 minutes measurement, 1 run per scenario
- Full evidence run: bounded local run, 30 seconds warmup and 60 seconds measurement, 3 runs per scenario
- Before checkout: `origin/main` (`ab5fc7f`) plus benchmark harness only (`85b2b18`)
- After checkout: same benchmark harness plus the #323 fix under test

The benchmark harness lives in `scripts/benchmarks/apple-login/`.

## Acceptance Gate

Primary scenario: returning Apple User, warm cache, concurrency 1.

- JWKS network calls/request: `before 1.0 -> after 0.0`
- Apple token exchange calls/request: `before 1.0 -> after 0.0`
- Error rate: `0%`
- p95 latency: `after <= before * 0.70`

Secondary scenarios: returning Apple User, concurrency 10 and 20.

- Error rate: `0%`
- Apple token exchange calls/request: `after 0.0`
- p95 latency: `after <= before`

## Results

The full rows aggregate 3 runs per scenario by summing requests/provider calls and averaging per-run p50/p95 latency.

| mode | scenario | version | runs | requests | error_rate | p50_ms | p95_ms | jwks_calls/request | token_exchange_calls/request |
| --- | --- | --- | ---: | ---: | ---: | ---: | ---: | ---: | ---: |
| quick | c1 returning warm-cache | before | 1 | 590 | 0.000 | 405.6 | 425.5 | 1.000 | 1.000 |
| quick | c1 returning warm-cache | after | 1 | 2,327 | 0.000 | 26.0 | 40.1 | 0.000 | 0.000 |
| quick | c10 returning warm-cache | before | 1 | 5,773 | 0.000 | 417.9 | 435.1 | 1.000 | 1.000 |
| quick | c10 returning warm-cache | after | 1 | 22,366 | 0.000 | 33.7 | 46.5 | 0.000 | 0.000 |
| quick | c20 returning warm-cache | before | 1 | 11,745 | 0.000 | 408.0 | 431.2 | 1.000 | 1.000 |
| quick | c20 returning warm-cache | after | 1 | 47,844 | 0.000 | 20.7 | 46.8 | 0.000 | 0.000 |
| full | c1 returning warm-cache | before | 3 | 346 | 0.000 | 413.6 | 435.8 | 1.000 | 1.000 |
| full | c1 returning warm-cache | after | 3 | 1,407 | 0.000 | 23.1 | 46.5 | 0.000 | 0.000 |
| full | c10 returning warm-cache | before | 3 | 3,492 | 0.000 | 415.1 | 429.1 | 1.000 | 1.000 |
| full | c10 returning warm-cache | after | 3 | 13,425 | 0.000 | 29.3 | 58.5 | 0.000 | 0.000 |
| full | c20 returning warm-cache | before | 3 | 6,829 | 0.000 | 420.4 | 506.9 | 1.000 | 1.000 |
| full | c20 returning warm-cache | after | 3 | 27,824 | 0.000 | 19.9 | 60.4 | 0.000 | 0.000 |

## Result Files

- Quick before: `scripts/benchmarks/apple-login/results/20260628T043649Z-before-quick/summary.csv`
- Quick after: `scripts/benchmarks/apple-login/results/20260628T045808Z-after-quick/summary.csv`
- Full before: `scripts/benchmarks/apple-login/results/20260628T051949Z-before-full/summary.csv`
- Full after: `scripts/benchmarks/apple-login/results/20260628T053451Z-after-full/summary.csv`

## Decision

The #323 fix passes the acceptance gate in both quick and bounded full runs:

- c1 p95 improved from 435.8 ms to 46.5 ms in the full run, an 89.3% reduction.
- Returning Apple User login used 0.0 JWKS network calls/request after warmup.
- Returning Apple User login used 0.0 Apple token exchange calls/request after the fix.
- Error rate stayed at 0% across c1, c10, and c20.

## Notes

- Real Apple network calls are intentionally excluded from the primary benchmark because provider and network variability would obscure backend request-path changes.
- The script labels results as `before` or `after` but does not change git refs. Select the checkout explicitly before running.
- 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.
Original file line number Diff line number Diff line change
Expand Up @@ -74,8 +74,9 @@ public Authentication attemptAuthentication(HttpServletRequest request, HttpServ
}

try {
// Apple Identity Token 검증
long startedAt = System.nanoTime();
Claims tokenClaims = appleLoginService.verifyIdentityToken(oAuthAppleRequestDto.getIdToken());
long verifiedAt = System.nanoTime();
if (tokenClaims.getSubject() == null) {
throw new IllegalStateException("Apple 로그인 검증 실패");
}
Expand All @@ -85,18 +86,21 @@ public Authentication attemptAuthentication(HttpServletRequest request, HttpServ
oAuthAppleRequestDto.getEmail(),
tokenClaims.get("email", String.class)
);
String appleRefreshToken = exchangeAppleRefreshToken(oAuthAppleRequestDto);

OAuthAppleUserDto oAuthAppleUserDto = new OAuthAppleUserDto(appleUserId, email, oAuthAppleRequestDto.getFullName());

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

if (existingUser.isPresent()) {
return appleLoginService.handleLogin(appleRefreshToken, existingUser.get(), response);
} else {
return appleLoginService.handleRegister(appleRefreshToken, oAuthAppleUserDto, response);
logAppleLoginTiming("existing_user", startedAt, verifiedAt, userLookupAt, userLookupAt);
return appleLoginService.handleLogin(null, existingUser.get(), response);
}

String appleRefreshToken = exchangeAppleRefreshToken(oAuthAppleRequestDto);
long credentialExchangeAt = System.nanoTime();
logAppleLoginTiming("new_user", startedAt, verifiedAt, userLookupAt, credentialExchangeAt);
return appleLoginService.handleRegister(appleRefreshToken, oAuthAppleUserDto, response);

} catch (Exception e) {
log.error("Apple login failed", e);
throw new AppleLoginException();
Expand All @@ -115,6 +119,21 @@ private String exchangeAppleRefreshToken(OAuthAppleRequestDto oAuthAppleRequestD
}
}

private void logAppleLoginTiming(String result, long startedAt, long verifiedAt, long userLookupAt, long credentialExchangeAt) {
log.info(
"Apple login stage timing result={} verifyMs={} dbLookupMs={} credentialExchangeMs={} totalMs={}",
result,
elapsedMillis(startedAt, verifiedAt),
elapsedMillis(verifiedAt, userLookupAt),
elapsedMillis(userLookupAt, credentialExchangeAt),
elapsedMillis(startedAt, credentialExchangeAt)
);
}

private long elapsedMillis(long from, long to) {
return (to - from) / 1_000_000;
}

private String firstNonBlank(String first, String second) {
if (first != null && !first.isBlank()) {
return first;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@
import java.security.PrivateKey;
import java.security.PublicKey;
import java.security.spec.PKCS8EncodedKeySpec;
import java.time.Duration;
import java.time.Instant;
import java.util.*;

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

private static final String APPLE_KEYS_URL = "https://appleid.apple.com/auth/keys";
private static final String APPLE_TOKEN_URL = "https://appleid.apple.com/auth/token";
private static final Duration APPLE_PUBLIC_KEY_CACHE_TTL = Duration.ofHours(24);
private String issuer = "https://appleid.apple.com";
@Value("${apple.keys-url:" + APPLE_KEYS_URL + "}")
private String appleKeysUrl = APPLE_KEYS_URL;
@Value("${apple.token-url:" + APPLE_TOKEN_URL + "}")
private String appleTokenUrl = APPLE_TOKEN_URL;
@Value("${apple.client.id}")
private String clientId;
@Value("${apple.team.id}")
Expand All @@ -76,6 +82,9 @@ public class AppleLoginService {
private final AuthTokenService authTokenService;

private final RestTemplate restTemplate = new RestTemplate();
private ApplePublicKeyResponse cachedApplePublicKeys;
private Instant cachedApplePublicKeysAt;

public Authentication handleLogin(String appleRefreshToken, User user, HttpServletResponse response) throws IOException {
log.info("handleLogin");
if (appleRefreshToken != null && !appleRefreshToken.isBlank()) {
Expand Down Expand Up @@ -166,9 +175,7 @@ public Claims verifyIdentityToken(String identityToken) throws
Exception {
log.info("Verify Apple identity credential");
Map<String, String> headers = jwtUtils.parseHeaders(identityToken);
// apple publickey
ApplePublicKeyResponse applePublicKeyResponse = restTemplate.getForObject(APPLE_KEYS_URL, ApplePublicKeyResponse.class);
PublicKey publicKey = applePublicKeyGenerator.generatePublicKey(headers, applePublicKeyResponse);
PublicKey publicKey = resolveApplePublicKey(headers);
// claim
Claims tokenClaims = jwtUtils.getTokenClaims(identityToken, publicKey);
// iss 확인
Expand All @@ -188,6 +195,30 @@ public Claims verifyIdentityToken(String identityToken) throws
return tokenClaims;
}

private PublicKey resolveApplePublicKey(Map<String, String> headers) throws Exception {
ApplePublicKeyResponse applePublicKeys = getCachedApplePublicKeys();
try {
return applePublicKeyGenerator.generatePublicKey(headers, applePublicKeys);
} catch (IllegalArgumentException e) {
log.info("Apple public key cache miss for signed credential header; refreshing key set");
return applePublicKeyGenerator.generatePublicKey(headers, refreshApplePublicKeys());
}
}

private synchronized ApplePublicKeyResponse getCachedApplePublicKeys() {
if (cachedApplePublicKeys == null || cachedApplePublicKeysAt == null ||
cachedApplePublicKeysAt.plus(APPLE_PUBLIC_KEY_CACHE_TTL).isBefore(Instant.now())) {
return refreshApplePublicKeys();
}
return cachedApplePublicKeys;
}

private synchronized ApplePublicKeyResponse refreshApplePublicKeys() {
cachedApplePublicKeys = restTemplate.getForObject(appleKeysUrl, ApplePublicKeyResponse.class);
cachedApplePublicKeysAt = Instant.now();
return cachedApplePublicKeys;
}

// apple 서버로부터 accesstoken, refreshtoken 발급
public AppleTokenResponseDto getAppleAccessTokenAndRefreshToken(String authCode) throws Exception {
// clientSecret
Expand All @@ -201,7 +232,7 @@ public AppleTokenResponseDto getAppleAccessTokenAndRefreshToken(String authCode)
HttpEntity<MultiValueMap<String, String>> requestEntity = new HttpEntity<>(requestBody, headers);

ResponseEntity<JsonNode> responseEntity = restTemplate.exchange(
APPLE_TOKEN_URL, HttpMethod.POST, requestEntity, JsonNode.class);
appleTokenUrl, HttpMethod.POST, requestEntity, JsonNode.class);

JsonNode response = responseEntity.getBody();

Expand Down
53 changes: 53 additions & 0 deletions ontime-back/src/main/resources/application-bench.properties
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
# Database Configuration
spring.datasource.url=${SPRING_DATASOURCE_URL}
spring.datasource.username=${SPRING_DATASOURCE_USERNAME}
spring.datasource.password=${SPRING_DATASOURCE_PASSWORD}
spring.datasource.driver-class-name=${SPRING_DATASOURCE_DRIVER_CLASS_NAME:com.mysql.cj.jdbc.Driver}

# JPA / Hibernate
spring.jpa.database=mysql
spring.jpa.database-platform=org.hibernate.dialect.MySQL8Dialect
spring.jpa.hibernate.ddl-auto=validate
spring.jpa.show-sql=false
spring.jpa.properties.hibernate.format_sql=false

# Flyway
spring.flyway.enabled=true
spring.flyway.baseline-on-migrate=false

# JWT Configuration
jwt.secret.key=${JWT_SECRET_KEY}
jwt.access.expiration=${JWT_ACCESS_EXPIRATION:3600000}
jwt.refresh.expiration=${JWT_REFRESH_EXPIRATION:1209600000}
jwt.access.header=${JWT_ACCESS_HEADER:Authorization}
jwt.refresh.header=${JWT_REFRESH_HEADER:Authorization-refresh}

# OAuth
google.web.client-id=${GOOGLE_WEB_CLIENT_ID:bench-google-web-client-id}
google.app.client-id=${GOOGLE_APP_CLIENT_ID:bench-google-app-client-id}
apple.client.id=${APPLE_CLIENT_ID}
apple.team.id=${APPLE_TEAM_ID}
apple.login.key=${APPLE_LOGIN_KEY}
apple.client.secret=${APPLE_CLIENT_SECRET:}
apple.private-key.base64=${APPLE_PRIVATE_KEY_BASE64}
apple.keys-url=${APPLE_KEYS_URL:https://appleid.apple.com/auth/keys}
apple.token-url=${APPLE_TOKEN_URL:https://appleid.apple.com/auth/token}

# Firebase
firebase.credentials.base64=${FIREBASE_CREDENTIALS_BASE64:}

# Logging
logging.level.root=WARN
logging.level.devkor.ontime_back=INFO

# Feature flags
feature.apple-login.enabled=true
analytics.preference.default-enabled=${ANALYTICS_PREFERENCE_DEFAULT_ENABLED:false}

# Actuator
management.endpoint.health.probes.enabled=true
management.endpoints.web.exposure.include=health
management.health.readinessstate.enabled=true
management.health.livenessstate.enabled=true
server.shutdown=graceful
spring.lifecycle.timeout-per-shutdown-phase=30s
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verifyNoInteractions;
import static org.mockito.Mockito.when;

Expand Down Expand Up @@ -330,7 +331,7 @@ void appleLoginFilterRejectsInvalidRequest() throws Exception {
}

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

when(appleLoginService.verifyIdentityToken("apple-id-token")).thenReturn(claims);
when(appleLoginService.getAppleAccessTokenAndRefreshToken("auth-code")).thenReturn(tokenResponse);
when(userRepository.findBySocialTypeAndSocialId(SocialType.APPLE, "apple-id"))
.thenReturn(Optional.of(existingUser));
when(appleLoginService.handleLogin("apple-refresh-token", existingUser, response)).thenReturn(
when(appleLoginService.handleLogin(null, existingUser, response)).thenReturn(
new org.springframework.security.authentication.UsernamePasswordAuthenticationToken(existingUser, null)
);

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

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

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

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

verify(appleLoginService, never()).getAppleAccessTokenAndRefreshToken("auth-code");
verify(appleLoginService).handleLogin(null, existingUser, response);
}

Expand Down
Loading
Loading