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
1 change: 1 addition & 0 deletions build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ dependencies {
implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
implementation 'org.springframework.boot:spring-boot-starter-security'
implementation 'org.springframework.boot:spring-boot-starter-actuator'
implementation 'org.springframework.boot:spring-boot-starter-aop'
implementation 'org.springframework.retry:spring-retry'
implementation 'org.springframework.boot:spring-boot-starter-data-redis'

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
import gg.agit.konect.domain.user.dto.UserInfoResponse;
import gg.agit.konect.domain.user.service.RefreshTokenService;
import gg.agit.konect.domain.user.service.SignupTokenService;
import gg.agit.konect.domain.user.service.UserActivityService;
import gg.agit.konect.domain.user.service.UserService;
import gg.agit.konect.global.auth.jwt.JwtProvider;
import gg.agit.konect.global.auth.annotation.PublicApi;
Expand All @@ -29,6 +30,7 @@ public class UserController implements UserApi {
private final SignupTokenService signupTokenService;
private final JwtProvider jwtProvider;
private final RefreshTokenService refreshTokenService;
private final UserActivityService userActivityService;
private final AuthCookieService authCookieService;

@Override
Expand Down Expand Up @@ -75,6 +77,7 @@ public ResponseEntity<Void> logout(HttpServletRequest request, HttpServletRespon
public ResponseEntity<UserAccessTokenResponse> refresh(HttpServletRequest request, HttpServletResponse response) {
String refreshToken = authCookieService.getCookieValue(request, AuthCookieService.REFRESH_TOKEN_COOKIE);
RefreshTokenService.Rotated rotated = refreshTokenService.rotate(refreshToken);
userActivityService.updateLastLoginAt(rotated.userId());

String accessToken = jwtProvider.createToken(rotated.userId());
authCookieService.setRefreshToken(request, response, rotated.refreshToken(), refreshTokenService.refreshTtl());
Expand Down
17 changes: 17 additions & 0 deletions src/main/java/gg/agit/konect/domain/user/model/User.java
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@
import static jakarta.persistence.GenerationType.IDENTITY;
import static lombok.AccessLevel.PROTECTED;

import java.time.LocalDateTime;

import gg.agit.konect.domain.university.model.University;
import gg.agit.konect.domain.user.enums.Provider;
import gg.agit.konect.domain.user.enums.UserRole;
Expand Down Expand Up @@ -90,6 +92,12 @@ public class User extends BaseEntity {
@Column(name = "apple_refresh_token", length = 1024)
private String appleRefreshToken;

@Column(name = "last_login_at", columnDefinition = "TIMESTAMP")
private LocalDateTime lastLoginAt;

@Column(name = "last_activity_at", columnDefinition = "TIMESTAMP")
Comment on lines +95 to +98
Copy link

Copilot AI Feb 25, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Flyway 마이그레이션에서는 last_login_at/last_activity_at 컬럼 타입이 DATETIME인데, 엔티티는 columnDefinition="TIMESTAMP"로 선언되어 있어 spring.jpa.hibernate.ddl-auto=validate 환경에서 스키마 검증 실패가 발생할 수 있습니다. DB 타입과 엔티티 매핑을 동일하게 맞춰 주세요(마이그레이션을 TIMESTAMP로 바꾸거나, 엔티티의 columnDefinition을 DATETIME/기본 매핑으로 변경).

Suggested change
@Column(name = "last_login_at", columnDefinition = "TIMESTAMP")
private LocalDateTime lastLoginAt;
@Column(name = "last_activity_at", columnDefinition = "TIMESTAMP")
@Column(name = "last_login_at")
private LocalDateTime lastLoginAt;
@Column(name = "last_activity_at")

Copilot uses AI. Check for mistakes.
private LocalDateTime lastActivityAt;

@Builder
private User(
Integer id,
Expand Down Expand Up @@ -171,4 +179,13 @@ public boolean isAdmin() {
public void updateAppleRefreshToken(String appleRefreshToken) {
this.appleRefreshToken = appleRefreshToken;
}

public void updateLastLoginAt(LocalDateTime lastLoginAt) {
this.lastLoginAt = lastLoginAt;
this.lastActivityAt = lastLoginAt;
}

public void updateLastActivityAt(LocalDateTime lastActivityAt) {
this.lastActivityAt = lastActivityAt;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
package gg.agit.konect.domain.user.service;

import java.time.LocalDateTime;

import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import gg.agit.konect.domain.user.repository.UserRepository;
import lombok.RequiredArgsConstructor;

@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
public class UserActivityService {

private final UserRepository userRepository;

@Transactional
public void updateLastLoginAt(Integer userId) {
if (userId == null) {
return;
}

userRepository.getById(userId).updateLastLoginAt(LocalDateTime.now());
}
Comment on lines +19 to +25
Copy link

Copilot AI Feb 25, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

updateLastLoginAt가 userRepository.getById()로 엔티티를 먼저 조회한 뒤 dirty checking으로 UPDATE를 발생시키므로, 불필요한 SELECT가 추가됩니다. 단순 타임스탬프 갱신이면 @Modifying UPDATE 쿼리로 1쿼리 처리(SELECT 없이)하는 방식이 더 적합합니다.

Copilot uses AI. Check for mistakes.

@Transactional
public void updateLastActivityAt(Integer userId) {
if (userId == null) {
return;
}

userRepository.getById(userId).updateLastActivityAt(LocalDateTime.now());
}
Comment on lines +30 to +34
Copy link

Copilot AI Feb 25, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

updateLastActivityAt도 매 요청마다 getById()로 SELECT 후 UPDATE가 발생합니다. 트래픽이 늘면 users 테이블에 대한 쓰기/락 경합이 커질 수 있으니, @Modifying UPDATE로 조회 없이 갱신하거나(필요 시) 일정 주기 이상 지난 경우에만 갱신하는 방식도 고려해 주세요.

Copilot uses AI. Check for mistakes.
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
package gg.agit.konect.global.auth.aop;

import org.aspectj.lang.annotation.After;
import org.aspectj.lang.annotation.Aspect;
import org.springframework.stereotype.Component;
import org.springframework.web.context.request.RequestContextHolder;
import org.springframework.web.context.request.ServletRequestAttributes;

import gg.agit.konect.domain.user.service.UserActivityService;
import gg.agit.konect.global.auth.web.LoginCheckInterceptor;
import jakarta.servlet.http.HttpServletRequest;
import lombok.RequiredArgsConstructor;

@Aspect
@Component
@RequiredArgsConstructor
public class UserActivityUpdateAspect {

private final UserActivityService userActivityService;

@After("execution(* gg.agit.konect..controller..*(..))")
public void updateLastActivity() {
Comment on lines +21 to +22
Copy link

Copilot AI Feb 25, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

현재 @After 어드바이스는 컨트롤러 메서드가 예외를 던져도 실행되므로, 5xx/4xx 등 실패 응답에서도 last_activity_at이 갱신됩니다. "인증된 API 요청 이후"를 성공 요청으로 한정하려면 @AfterReturning으로 변경하는 쪽이 의도에 더 맞습니다.

Copilot uses AI. Check for mistakes.
ServletRequestAttributes attributes = (ServletRequestAttributes)RequestContextHolder.getRequestAttributes();
if (attributes == null) {
return;
}

HttpServletRequest request = attributes.getRequest();
Comment on lines +23 to +28
Copy link

Copilot AI Feb 25, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

RequestContextHolder.getRequestAttributes() 결과를 바로 ServletRequestAttributes로 캐스팅하고 있어(현재는 null만 체크) 비-서블릿 컨텍스트에서 호출되면 ClassCastException이 날 수 있습니다. instanceof ServletRequestAttributes 확인 후 캐스팅하도록 방어 코드를 추가해 주세요.

Suggested change
ServletRequestAttributes attributes = (ServletRequestAttributes)RequestContextHolder.getRequestAttributes();
if (attributes == null) {
return;
}
HttpServletRequest request = attributes.getRequest();
Object attributes = RequestContextHolder.getRequestAttributes();
if (attributes == null || !(attributes instanceof ServletRequestAttributes)) {
return;
}
HttpServletRequest request = ((ServletRequestAttributes) attributes).getRequest();

Copilot uses AI. Check for mistakes.
Object userId = request.getAttribute(LoginCheckInterceptor.AUTHENTICATED_USER_ID_ATTRIBUTE);
if (userId instanceof Integer authenticatedUserId) {
userActivityService.updateLastActivityAt(authenticatedUserId);
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
ALTER TABLE users
ADD COLUMN last_login_at DATETIME NULL,
ADD COLUMN last_activity_at DATETIME NULL;