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
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
.DS_Store
.idea/
.codex/
scripts/benchmarks/*/results/*
!scripts/benchmarks/*/results/.gitkeep
54 changes: 54 additions & 0 deletions docs/performance/apple-login-323.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
# 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 User: `social_type=APPLE`, `social_id=bench-apple-user`
- Stub delay:
- `GET /auth/keys`: 80 ms
- `POST /auth/token`: 300 ms
- Warmup: 2 minutes
- Measurement: 5 minutes
- Full run count: 10 per scenario

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

Populate this table from `scripts/benchmarks/apple-login/results/*/summary.csv` after running both before and after measurements.

| scenario | version | runs | requests | error_rate | p50_ms | p95_ms | p99_ms | jwks_calls/request | token_exchange_calls/request |
| --- | --- | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: |
| c1 returning warm-cache | before | TBD | TBD | TBD | TBD | TBD | TBD | TBD | TBD |
| c1 returning warm-cache | after | TBD | TBD | TBD | TBD | TBD | TBD | TBD | TBD |
| c10 returning warm-cache | before | TBD | TBD | TBD | TBD | TBD | TBD | TBD | TBD |
| c10 returning warm-cache | after | TBD | TBD | TBD | TBD | TBD | TBD | TBD | TBD |
| c20 returning warm-cache | before | TBD | TBD | TBD | TBD | TBD | TBD | TBD | TBD |
| c20 returning warm-cache | after | TBD | TBD | TBD | TBD | TBD | TBD | TBD | TBD |

## 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.
- Quick mode is for development feedback. Full mode is the acceptance evidence.
68 changes: 68 additions & 0 deletions docs/performance/jwt-auth-325.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
# JWT Authentication Performance Evidence for Issue #325

## Measurement Contract

This document records before/after evidence for removing duplicate `User` reads from the protected API JWT authentication path.

Primary benchmark environment:

- Backend: local Spring Boot process with `bench` profile
- Database: isolated Docker MySQL database `ontime_jwt_bench`
- Protected endpoint: `GET /users/me/punctuality-score`
- Benchmark User: created through `POST /sign-up`
- Warmup: 20 protected requests
- Measurement: 5 minutes
- Full run count: 10 per scenario

The benchmark harness lives in `scripts/benchmarks/jwt-auth/`.

Recorded quick evidence run:

- before: `scripts/benchmarks/jwt-auth/results/20260628T042635Z-before-quick/summary.csv`
- after: `scripts/benchmarks/jwt-auth/results/20260628T044205Z-after-quick/summary.csv`
- mode: `quick`
- runs: 1 per scenario

## Acceptance Gate

Primary correctness gate:

- Authentication `User` lookup count: `before 2 -> after 1`
- `GET /users/me/punctuality-score` whole-request SQL budget: `after <= 5`
- Invalid, expired, and replaced access token behavior remains compatible
- Refresh token rotation behavior remains compatible
- `./gradlew check` passes

Benchmark evidence:

- Error rate: `0%`
- p95 latency: `after <= before`
- Whole-request SQL budget sample: `after <= before - 1`

## Results

Populate this table from `scripts/benchmarks/jwt-auth/results/*/summary.csv` and `sql-budget-*.txt` after running both before and after measurements.

| scenario | version | runs | requests | error_rate | p50_ms | p95_ms | p99_ms | whole_request_sql_sample |
| --- | --- | ---: | ---: | ---: | ---: | ---: | ---: | ---: |
| c1 protected request | before | 1 | 2273 | 0 | 28.190 | 46.770 | 53.554 | 7 |
| c1 protected request | after | 1 | 2404 | 0 | 21.664 | 38.093 | 48.579 | 5 |
| c10 protected request | before | 1 | 21804 | 0 | 35.879 | 52.873 | 62.045 | 7 |
| c10 protected request | after | 1 | 23409 | 0 | 26.412 | 40.129 | 48.728 | 5 |
| c20 protected request | before | 1 | 47625 | 0 | 23.957 | 36.560 | 72.784 | 7 |
| c20 protected request | after | 1 | 49550 | 0 | 19.597 | 29.620 | 39.054 | 5 |

## Quick Run Delta

| scenario | p95_delta_ms | p95_delta_pct | sql_sample_delta |
| --- | ---: | ---: | ---: |
| c1 protected request | -8.678 | -18.6% | -2 |
| c10 protected request | -12.744 | -24.1% | -2 |
| c20 protected request | -6.940 | -19.0% | -2 |

## Notes

- Query-count correctness is intentionally enforced by automated tests because latency is sensitive to JVM warmup, local machine load, and database state.
- The protected endpoint includes existing API logging work, so the whole-request SQL budget is higher than the authentication-only `User` lookup count.
- The script labels results as `before` or `after` but does not change git refs. Select the checkout explicitly before running.
- Quick mode is for development feedback. Full mode is the acceptance evidence.
Original file line number Diff line number Diff line change
Expand Up @@ -136,7 +136,7 @@ public CustomJsonUsernamePasswordAuthenticationFilter customJsonUsernamePassword

@Bean
public JwtAuthenticationFilter jwtAuthenticationProcessingFilter() {
JwtAuthenticationFilter jwtAuthenticationFilter = new JwtAuthenticationFilter(jwtTokenProvider, userRepository, authTokenService);
JwtAuthenticationFilter jwtAuthenticationFilter = new JwtAuthenticationFilter(jwtTokenProvider, authTokenService);
return jwtAuthenticationFilter;
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@

import com.fasterxml.jackson.databind.ObjectMapper;
import devkor.ontime_back.entity.User;
import devkor.ontime_back.repository.UserRepository;
import devkor.ontime_back.response.*;
import devkor.ontime_back.service.AuthTokenService;
import jakarta.servlet.DispatcherType;
Expand Down Expand Up @@ -36,7 +35,6 @@ public class JwtAuthenticationFilter extends OncePerRequestFilter {
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");

private final JwtTokenProvider jwtTokenProvider;
private final UserRepository userRepository;
private final AuthTokenService authTokenService;

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

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

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

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

filterChain.doFilter(request, response);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,10 @@ 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 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 Down Expand Up @@ -167,7 +171,7 @@ public Claims verifyIdentityToken(String identityToken) throws
log.info("Verify Apple identity credential");
Map<String, String> headers = jwtUtils.parseHeaders(identityToken);
// apple publickey
ApplePublicKeyResponse applePublicKeyResponse = restTemplate.getForObject(APPLE_KEYS_URL, ApplePublicKeyResponse.class);
ApplePublicKeyResponse applePublicKeyResponse = restTemplate.getForObject(appleKeysUrl, ApplePublicKeyResponse.class);
PublicKey publicKey = applePublicKeyGenerator.generatePublicKey(headers, applePublicKeyResponse);
// claim
Claims tokenClaims = jwtUtils.getTokenClaims(identityToken, publicKey);
Expand Down Expand Up @@ -201,7 +205,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
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
import devkor.ontime_back.global.jwt.JwtTokenProvider;
import devkor.ontime_back.repository.UserRefreshTokenRepository;
import devkor.ontime_back.repository.UserRepository;
import devkor.ontime_back.response.InvalidAccessTokenException;
import devkor.ontime_back.response.InvalidRefreshTokenException;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
Expand All @@ -19,6 +20,20 @@ public class AuthTokenService {
private final UserRefreshTokenRepository userRefreshTokenRepository;
private final UserRepository userRepository;

@Transactional(readOnly = true)
public User validateActiveAccessToken(String accessToken) {
Long userId = jwtTokenProvider.extractUserId(accessToken)
.orElseThrow(() -> new InvalidAccessTokenException("유효하지 않은 엑세스 토큰입니다."));
User user = userRepository.findById(userId)
.orElseThrow(() -> new InvalidAccessTokenException("유효하지 않은 엑세스 토큰입니다."));

if (!accessToken.equals(user.getAccessToken())) {
throw new InvalidAccessTokenException("유효하지 않은 엑세스 토큰입니다.");
}

return user;
}

@Transactional
public AuthTokens issueLoginTokens(User user, HttpServletResponse response) {
userRefreshTokenRepository.deleteByUser(user);
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=${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
@@ -0,0 +1,5 @@
ALTER TABLE preparation_user
MODIFY COLUMN preparation_name VARCHAR(50) NOT NULL;

ALTER TABLE preparation_schedule
MODIFY COLUMN preparation_name VARCHAR(50) NOT NULL;
Loading
Loading