Skip to content

Commit 12a7265

Browse files
committed
Reduce JWT auth user lookup count
1 parent 8500c13 commit 12a7265

16 files changed

Lines changed: 690 additions & 33 deletions

File tree

.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

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/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);

ontime-back/src/main/resources/application-bench.properties

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,7 @@ logging.level.root=WARN
4141
logging.level.devkor.ontime_back=INFO
4242

4343
# Feature flags
44-
feature.apple-login.enabled=true
44+
feature.apple-login.enabled=${FEATURE_APPLE_LOGIN_ENABLED:true}
4545
analytics.preference.default-enabled=${ANALYTICS_PREFERENCE_DEFAULT_ENABLED:false}
4646

4747
# Actuator

ontime-back/src/test/java/devkor/ontime_back/global/jwt/JwtAuthenticationFilterTest.java

Lines changed: 11 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,7 @@ void skipsPublicHtmlPages(String path) throws Exception {
3939
UserRepository userRepository = mock(UserRepository.class);
4040
AuthTokenService authTokenService = mock(AuthTokenService.class);
4141
FilterChain filterChain = mock(FilterChain.class);
42-
JwtAuthenticationFilter filter = new JwtAuthenticationFilter(jwtTokenProvider, userRepository, authTokenService);
42+
JwtAuthenticationFilter filter = new JwtAuthenticationFilter(jwtTokenProvider, authTokenService);
4343
MockHttpServletRequest request = new MockHttpServletRequest("GET", path);
4444
MockHttpServletResponse response = new MockHttpServletResponse();
4545

@@ -56,20 +56,19 @@ void validAccessTokenAuthenticatesUserAndContinuesFilterChain() throws Exception
5656
UserRepository userRepository = mock(UserRepository.class);
5757
AuthTokenService authTokenService = mock(AuthTokenService.class);
5858
FilterChain filterChain = mock(FilterChain.class);
59-
JwtAuthenticationFilter filter = new JwtAuthenticationFilter(jwtTokenProvider, userRepository, authTokenService);
59+
JwtAuthenticationFilter filter = new JwtAuthenticationFilter(jwtTokenProvider, authTokenService);
6060
MockHttpServletRequest request = new MockHttpServletRequest("GET", "/schedules");
6161
MockHttpServletResponse response = new MockHttpServletResponse();
6262
User user = user("user@example.com", "encoded-password");
6363

6464
when(jwtTokenProvider.extractAccessToken(request)).thenReturn(Optional.of("access-token"));
6565
when(jwtTokenProvider.extractRefreshToken(request)).thenReturn(Optional.empty());
66-
when(jwtTokenProvider.isAccessTokenValid("access-token")).thenReturn(true);
67-
when(jwtTokenProvider.extractUserId("access-token")).thenReturn(Optional.of(1L));
68-
when(userRepository.findById(1L)).thenReturn(Optional.of(user));
66+
when(authTokenService.validateActiveAccessToken("access-token")).thenReturn(user);
6967

7068
filter.doFilter(request, response, filterChain);
7169

7270
verify(filterChain).doFilter(request, response);
71+
verify(authTokenService).validateActiveAccessToken("access-token");
7372
assertThat(SecurityContextHolder.getContext().getAuthentication().getName()).isEqualTo("user@example.com");
7473
}
7574

@@ -79,7 +78,7 @@ void validRefreshTokenReissuesAccessTokenWithoutContinuingRequest() throws Excep
7978
UserRepository userRepository = mock(UserRepository.class);
8079
AuthTokenService authTokenService = mock(AuthTokenService.class);
8180
FilterChain filterChain = mock(FilterChain.class);
82-
JwtAuthenticationFilter filter = new JwtAuthenticationFilter(jwtTokenProvider, userRepository, authTokenService);
81+
JwtAuthenticationFilter filter = new JwtAuthenticationFilter(jwtTokenProvider, authTokenService);
8382
MockHttpServletRequest request = new MockHttpServletRequest("GET", "/schedules");
8483
MockHttpServletResponse response = new MockHttpServletResponse();
8584
User user = user("user@example.com", "encoded-password");
@@ -102,7 +101,7 @@ void missingAccessTokenReturnsTokenEmptyErrorEnvelope() throws Exception {
102101
UserRepository userRepository = mock(UserRepository.class);
103102
AuthTokenService authTokenService = mock(AuthTokenService.class);
104103
FilterChain filterChain = mock(FilterChain.class);
105-
JwtAuthenticationFilter filter = new JwtAuthenticationFilter(jwtTokenProvider, userRepository, authTokenService);
104+
JwtAuthenticationFilter filter = new JwtAuthenticationFilter(jwtTokenProvider, authTokenService);
106105
MockHttpServletRequest request = new MockHttpServletRequest("GET", "/schedules");
107106
MockHttpServletResponse response = new MockHttpServletResponse();
108107

@@ -122,7 +121,7 @@ void invalidRefreshTokenReturnsRefreshSpecificErrorEnvelope() throws Exception {
122121
UserRepository userRepository = mock(UserRepository.class);
123122
AuthTokenService authTokenService = mock(AuthTokenService.class);
124123
FilterChain filterChain = mock(FilterChain.class);
125-
JwtAuthenticationFilter filter = new JwtAuthenticationFilter(jwtTokenProvider, userRepository, authTokenService);
124+
JwtAuthenticationFilter filter = new JwtAuthenticationFilter(jwtTokenProvider, authTokenService);
126125
MockHttpServletRequest request = new MockHttpServletRequest("GET", "/schedules");
127126
MockHttpServletResponse response = new MockHttpServletResponse();
128127

@@ -144,13 +143,13 @@ void invalidAccessTokenReturnsAccessSpecificErrorEnvelope() throws Exception {
144143
UserRepository userRepository = mock(UserRepository.class);
145144
AuthTokenService authTokenService = mock(AuthTokenService.class);
146145
FilterChain filterChain = mock(FilterChain.class);
147-
JwtAuthenticationFilter filter = new JwtAuthenticationFilter(jwtTokenProvider, userRepository, authTokenService);
146+
JwtAuthenticationFilter filter = new JwtAuthenticationFilter(jwtTokenProvider, authTokenService);
148147
MockHttpServletRequest request = new MockHttpServletRequest("GET", "/schedules");
149148
MockHttpServletResponse response = new MockHttpServletResponse();
150149

151150
when(jwtTokenProvider.extractAccessToken(request)).thenReturn(Optional.of("access-token"));
152151
when(jwtTokenProvider.extractRefreshToken(request)).thenReturn(Optional.empty());
153-
when(jwtTokenProvider.isAccessTokenValid("access-token"))
152+
when(authTokenService.validateActiveAccessToken("access-token"))
154153
.thenThrow(new InvalidAccessTokenException("bad access"));
155154

156155
filter.doFilter(request, response, filterChain);
@@ -162,7 +161,7 @@ void invalidAccessTokenReturnsAccessSpecificErrorEnvelope() throws Exception {
162161

163162
@Test
164163
void socialLoginUserWithoutPasswordReceivesGeneratedAuthenticationPassword() {
165-
JwtAuthenticationFilter filter = new JwtAuthenticationFilter(mock(JwtTokenProvider.class), mock(UserRepository.class), mock(AuthTokenService.class));
164+
JwtAuthenticationFilter filter = new JwtAuthenticationFilter(mock(JwtTokenProvider.class), mock(AuthTokenService.class));
166165
User user = user("social@example.com", null);
167166

168167
filter.saveAuthentication(user);
@@ -175,7 +174,7 @@ void socialLoginUserWithoutPasswordReceivesGeneratedAuthenticationPassword() {
175174

176175
@Test
177176
void socialLoginUserWithoutEmailUsesUserIdAuthenticationName() {
178-
JwtAuthenticationFilter filter = new JwtAuthenticationFilter(mock(JwtTokenProvider.class), mock(UserRepository.class), mock(AuthTokenService.class));
177+
JwtAuthenticationFilter filter = new JwtAuthenticationFilter(mock(JwtTokenProvider.class), mock(AuthTokenService.class));
179178
User user = user(null, null);
180179

181180
filter.saveAuthentication(user);
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
package devkor.ontime_back.global.jwt;
2+
3+
import devkor.ontime_back.entity.Role;
4+
import devkor.ontime_back.entity.User;
5+
import devkor.ontime_back.repository.UserRepository;
6+
import devkor.ontime_back.service.AuthTokenService;
7+
import jakarta.persistence.EntityManager;
8+
import jakarta.persistence.EntityManagerFactory;
9+
import org.hibernate.SessionFactory;
10+
import org.hibernate.stat.Statistics;
11+
import org.junit.jupiter.api.Test;
12+
import org.springframework.beans.factory.annotation.Autowired;
13+
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
14+
import org.springframework.boot.test.context.SpringBootTest;
15+
import org.springframework.mock.web.MockHttpServletResponse;
16+
import org.springframework.test.web.servlet.MockMvc;
17+
18+
import static org.assertj.core.api.Assertions.assertThat;
19+
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
20+
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
21+
22+
@SpringBootTest(properties = "spring.jpa.properties.hibernate.generate_statistics=true")
23+
@AutoConfigureMockMvc
24+
class JwtAuthenticationQueryCountIntegrationTest {
25+
26+
@Autowired
27+
private MockMvc mockMvc;
28+
29+
@Autowired
30+
private UserRepository userRepository;
31+
32+
@Autowired
33+
private AuthTokenService authTokenService;
34+
35+
@Autowired
36+
private EntityManager entityManager;
37+
38+
@Autowired
39+
private EntityManagerFactory entityManagerFactory;
40+
41+
@Test
42+
void protectedPunctualityScoreRequestStaysWithinThePostFixSqlBudget() throws Exception {
43+
User user = userRepository.saveAndFlush(user());
44+
AuthTokenService.AuthTokens tokens = authTokenService.issueLoginTokens(user, new MockHttpServletResponse());
45+
userRepository.saveAndFlush(user);
46+
entityManager.clear();
47+
48+
Statistics statistics = entityManagerFactory.unwrap(SessionFactory.class).getStatistics();
49+
statistics.clear();
50+
51+
mockMvc.perform(get("/users/me/punctuality-score")
52+
.header("Authorization", "Bearer " + tokens.accessToken()))
53+
.andExpect(status().isOk());
54+
55+
assertThat(statistics.getPrepareStatementCount()).isLessThanOrEqualTo(5);
56+
}
57+
58+
private User user() {
59+
return User.builder()
60+
.email("jwt-query-count@example.com")
61+
.password("password")
62+
.name("jwt-query-count-user")
63+
.punctualityScore(100F)
64+
.role(Role.USER)
65+
.build();
66+
}
67+
}

ontime-back/src/test/java/devkor/ontime_back/service/AuthTokenServiceTest.java

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
import devkor.ontime_back.global.jwt.JwtTokenProvider;
77
import devkor.ontime_back.repository.UserRefreshTokenRepository;
88
import devkor.ontime_back.repository.UserRepository;
9+
import devkor.ontime_back.response.InvalidAccessTokenException;
910
import org.junit.jupiter.api.BeforeEach;
1011
import org.junit.jupiter.api.Test;
1112
import org.junit.jupiter.api.extension.ExtendWith;
@@ -17,6 +18,7 @@
1718
import java.util.Optional;
1819

1920
import static org.assertj.core.api.Assertions.assertThat;
21+
import static org.assertj.core.api.Assertions.assertThatThrownBy;
2022
import static org.mockito.Mockito.times;
2123
import static org.mockito.Mockito.verify;
2224
import static org.mockito.Mockito.when;
@@ -40,6 +42,32 @@ void setUp() {
4042
authTokenService = new AuthTokenService(jwtTokenProvider, userRefreshTokenRepository, userRepository);
4143
}
4244

45+
@Test
46+
void validateActiveAccessTokenReadsUserOnceAndReturnsTheAuthenticatedUser() {
47+
User user = user();
48+
user.updateAccessToken("access-token");
49+
when(jwtTokenProvider.extractUserId("access-token")).thenReturn(Optional.of(1L));
50+
when(userRepository.findById(1L)).thenReturn(Optional.of(user));
51+
52+
User authenticatedUser = authTokenService.validateActiveAccessToken("access-token");
53+
54+
assertThat(authenticatedUser).isSameAs(user);
55+
verify(userRepository, times(1)).findById(1L);
56+
}
57+
58+
@Test
59+
void validateActiveAccessTokenRejectsAReplacedAccessToken() {
60+
User user = user();
61+
user.updateAccessToken("newer-access-token");
62+
when(jwtTokenProvider.extractUserId("access-token")).thenReturn(Optional.of(1L));
63+
when(userRepository.findById(1L)).thenReturn(Optional.of(user));
64+
65+
assertThatThrownBy(() -> authTokenService.validateActiveAccessToken("access-token"))
66+
.isInstanceOf(InvalidAccessTokenException.class);
67+
68+
verify(userRepository, times(1)).findById(1L);
69+
}
70+
4371
@Test
4472
void issueLoginTokensClearsPreviousRefreshCredentialsBeforeSavingTheNewLogin() {
4573
User user = user();

0 commit comments

Comments
 (0)