-
Notifications
You must be signed in to change notification settings - Fork 0
[MOISAM-246] 유저의 로그인 로그를 저장하여 일별 로그인 유저 수를 어드민에서 확인한다 #172
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Conversation
Walkthrough인증 성공/실패 시 요청자 IP( Changes
Sequence Diagram(s)sequenceDiagram
participant Client
participant AuthHandler as OAuth2 Auth Handler
participant LogWriter as LogUserLoginWriter
participant Repository as LogUserLoginRepository
participant Database as DB
rect rgb(240,248,255)
note over Client,AuthHandler: 로그인 성공 흐름
Client->>AuthHandler: 인증 요청
activate AuthHandler
AuthHandler->>AuthHandler: IP(`X-Real-IP`) 및 User‑Agent 캡처
AuthHandler->>LogWriter: save(userId, SUCCESS, ip, ua, null) -- async
AuthHandler->>Client: 토큰/쿠키 설정 후 리다이렉트
deactivate AuthHandler
activate LogWriter
LogWriter->>Repository: save(LogUserLogin)
Repository->>Database: INSERT
deactivate LogWriter
end
rect rgb(255,240,240)
note over Client,AuthHandler: 로그인 실패 흐름
Client->>AuthHandler: 인증 요청
activate AuthHandler
AuthHandler->>AuthHandler: 인증 실패 원인 수집
AuthHandler->>AuthHandler: IP 및 User‑Agent 캡처
AuthHandler->>LogWriter: save(null, FAILURE, ip, ua, reason) -- async
AuthHandler->>Client: 실패 리다이렉트
deactivate AuthHandler
activate LogWriter
LogWriter->>Repository: save(LogUserLogin)
Repository->>Database: INSERT
deactivate LogWriter
end
Estimated code review effort🎯 3 (Moderate) | ⏱️ ~20 minutes Possibly related PRs
Suggested labels
Suggested reviewers
Pre-merge checks and finishing touches❌ Failed checks (1 warning)
✅ Passed checks (2 passed)
✨ Finishing touches
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Actionable comments posted: 4
🧹 Nitpick comments (3)
src/main/java/com/meetup/server/ServerApplication.java (1)
7-7: 비동기 실행을 위한 Executor 구성을 권장합니다.
@EnableAsync가 기본 설정으로 활성화되어 있어SimpleAsyncTaskExecutor를 사용합니다. 이는 작업마다 새 스레드를 생성하므로 높은 부하 상황에서 스레드 고갈이 발생할 수 있습니다.다음과 같은 커스텀
ThreadPoolTaskExecutor구성을 권장합니다:
- 코어 풀 크기, 최대 풀 크기, 큐 용량 설정
- 스레드 이름 prefix 설정 (모니터링 용이)
- Rejection policy 구성
🔎 Async Executor 구성 예시
config패키지에 새로운 설정 클래스 추가:package com.meetup.server.global.config; import org.springframework.context.annotation.Configuration; import org.springframework.scheduling.annotation.AsyncConfigurer; import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor; import java.util.concurrent.Executor; @Configuration public class AsyncConfig implements AsyncConfigurer { @Override public Executor getAsyncExecutor() { ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor(); executor.setCorePoolSize(2); executor.setMaxPoolSize(5); executor.setQueueCapacity(100); executor.setThreadNamePrefix("async-login-log-"); executor.initialize(); return executor; } }Also applies to: 13-13
src/main/java/com/meetup/server/user/implement/LogUserLoginWriter.java (1)
16-17: 트랜잭션 관리를 고려해주세요.비동기 메서드에
@Transactional어노테이션이 없습니다. 로그인 이력 저장은 인증 플로우와 독립적인 트랜잭션으로 처리되는 것이 적절하지만, 명시적으로 트랜잭션 경계를 표현하면 의도가 더 명확해집니다.별도 트랜잭션으로 처리하려는 의도가 맞다면 현재 구현이 적절합니다. 다만, 실패 시 재시도가 필요하다면
@Transactional(propagation = Propagation.REQUIRES_NEW)를 고려해보세요.src/main/java/com/meetup/server/user/domain/LogUserLogin.java (1)
33-37: 데이터베이스 스키마와의 일관성을 위해 명시적 길이 제약을 추가하세요.
userAgent와failReason컬럼에@Column애노테이션에서 length를 명시하지 않았지만, 빌더 생성자에서는 255자로 truncate하고 있습니다. 데이터베이스 스키마와 코드의 일관성을 위해 명시적으로 길이 제약을 추가하는 것을 권장합니다.🔎 제안하는 수정
- @Column(name = "user_agent") + @Column(name = "user_agent", length = 255) private String userAgent; - @Column(name = "fail_reason") + @Column(name = "fail_reason", length = 255) private String failReason;
📜 Review details
Configuration used: Path: .coderabbit.yaml
Review profile: CHILL
Plan: Pro
📒 Files selected for processing (8)
configsrc/main/java/com/meetup/server/ServerApplication.javasrc/main/java/com/meetup/server/auth/support/handler/OAuth2LoginFailureHandler.javasrc/main/java/com/meetup/server/auth/support/handler/OAuth2LoginSuccessHandler.javasrc/main/java/com/meetup/server/user/domain/LogUserLogin.javasrc/main/java/com/meetup/server/user/domain/type/LoginStatus.javasrc/main/java/com/meetup/server/user/implement/LogUserLoginWriter.javasrc/main/java/com/meetup/server/user/infrastructure/jpa/LogUserLoginRepository.java
🧰 Additional context used
🧠 Learnings (2)
📚 Learning: 2025-12-25T11:50:19.070Z
Learnt from: anxi01
Repo: Team-MOISAM/moisam-server PR: 170
File: src/main/java/com/meetup/server/admin/application/AdminService.java:70-76
Timestamp: 2025-12-25T11:50:19.070Z
Learning: In the MOISAM project, the server and database operate in the Asia/Seoul timezone, and the service is domestic. Therefore, using LocalDate.now() or LocalDateTime.now() without an explicit ZoneId is acceptable for this codebase. Apply this guideline to Java files under src/main/java, but remain vigilant: for time-sensitive data or if future maintenance requires cross-timezone handling, prefer using ZoneId or ZonedDateTime with an explicitZone.
Applied to files:
src/main/java/com/meetup/server/auth/support/handler/OAuth2LoginSuccessHandler.javasrc/main/java/com/meetup/server/user/implement/LogUserLoginWriter.javasrc/main/java/com/meetup/server/user/domain/LogUserLogin.javasrc/main/java/com/meetup/server/user/infrastructure/jpa/LogUserLoginRepository.javasrc/main/java/com/meetup/server/ServerApplication.javasrc/main/java/com/meetup/server/auth/support/handler/OAuth2LoginFailureHandler.javasrc/main/java/com/meetup/server/user/domain/type/LoginStatus.java
📚 Learning: 2025-08-18T05:41:47.443Z
Learnt from: anxi01
Repo: Team-MOISAM/moisam-server PR: 147
File: src/main/java/com/meetup/server/review/infrastructure/jpa/ReviewRepository.java:1-1
Timestamp: 2025-08-18T05:41:47.443Z
Learning: Spring Data JPA with EnableJpaRepositories(basePackages = "com.meetup.server.*.infrastructure") successfully auto-detects custom repository implementations located in infrastructure.querydsl packages, not just infrastructure.jpa packages. The wildcard pattern in basePackages includes subdirectories like querydsl under infrastructure.
Applied to files:
src/main/java/com/meetup/server/user/infrastructure/jpa/LogUserLoginRepository.java
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (1)
- GitHub Check: build
🔇 Additional comments (10)
config (1)
1-1: 제공되지 않은 구현 파일들의 리뷰가 필요합니다.현재 제공된 파일은 submodule 메타데이터 변경만 포함하고 있습니다. PR 목표인 "유저 로그인 로그를 DB에 저장하는 기능"에 대한 실제 구현 검토를 위해서는 다음 파일들이 필요합니다:
src/main/java/com/meetup/server/user/domain/LogUserLogin.java(새 JPA 엔티티)src/main/java/com/meetup/server/user/domain/type/LoginStatus.java(enum)src/main/java/com/meetup/server/user/implement/LogUserLoginWriter.java(비동기 저장 로직)src/main/java/com/meetup/server/user/infrastructure/jpa/LogUserLoginRepository.java(리포지토리)src/main/java/com/meetup/server/auth/support/handler/OAuth2LoginSuccessHandler.java(성공 로그 저장)src/main/java/com/meetup/server/auth/support/handler/OAuth2LoginFailureHandler.java(실패 로그 저장)src/main/java/com/meetup/server/ServerApplication.java(@EnableAsync 추가)src/main/java/com/meetup/server/auth/support/handler/OAuth2LoginSuccessHandler.java (1)
6-7: 잘 구현되었습니다!Spring의
HttpHeaders상수를 사용한 리팩토링이 적절합니다. 매직 스트링 대신 표준 상수를 사용하여 코드의 가독성과 유지보수성이 향상되었습니다.Also applies to: 13-13, 28-28
src/main/java/com/meetup/server/user/infrastructure/jpa/LogUserLoginRepository.java (1)
1-7: LGTM!표준 JPA 리포지토리 인터페이스가 올바르게 구현되었습니다.
JpaRepository의 기본 CRUD 메서드로 로그인 이력 저장에 충분합니다.src/main/java/com/meetup/server/user/domain/type/LoginStatus.java (1)
1-6: LGTM!로그인 상태를 명확하게 표현하는 간단하고 효과적인 enum입니다. SUCCESS와 FAILURE 두 가지 상태로 로그인 이력 추적에 충분합니다.
src/main/java/com/meetup/server/user/domain/LogUserLogin.java (4)
12-16: LGTM!엔티티 선언과 테이블 매핑이 올바르게 구성되어 있습니다. BaseEntity를 상속하여 생성/수정 시각을 자동으로 관리하고, JPA를 위한 protected 생성자도 적절합니다.
18-24: LGTM!기본 키 설정이 올바르고,
userId를 nullable로 설정한 것은 적절합니다. 인증 실패 시 사용자가 식별되지 않은 경우를 처리할 수 있습니다.
26-31: LGTM!로그인 상태를 STRING으로 저장하여 가독성을 확보했고, IP 주소 길이를 45로 설정하여 IPv6를 지원합니다.
39-46: LGTM!빌더 패턴을 사용하여 객체 생성의 유연성을 제공하고,
StringUtil.truncate를 통해 긴 문자열을 방어적으로 처리하는 것은 좋은 접근입니다.src/main/java/com/meetup/server/auth/support/handler/OAuth2LoginFailureHandler.java (2)
3-4: LGTM!새로운 의존성과 import가 적절하게 추가되었습니다.
HttpHeaders.USER_AGENT상수를 사용하는 것은 좋은 리팩토링입니다.Also applies to: 10-10, 23-23
37-37:LogUserLoginWriter.save메서드가@Async애노테이션으로 올바르게 표시되어 있으므로, 로그 저장이 요청 스레드를 차단하지 않습니다. 비동기 지원이 제대로 구현되었습니다.
| String ipAddress = request.getRemoteAddr(); | ||
| String userAgent = request.getHeader(HttpHeaders.USER_AGENT); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🧩 Analysis chain
🏁 Script executed:
#!/bin/bash
# Search for IP extraction patterns and proxy header handling
echo "=== Searching for IP extraction patterns ==="
rg -n -C 2 --type=java 'X-Forwarded-For|X-Real-IP|getRemoteAddr'
echo ""
echo "=== Checking OAuth2LoginFailureHandler.java ==="
cat -n src/main/java/com/meetup/server/auth/support/handler/OAuth2LoginFailureHandler.javaRepository: Team-MOISAM/moisam-server
Length of output: 3723
🏁 Script executed:
#!/bin/bash
# Search for IP extraction utility classes and proxy handling
echo "=== Searching for utility classes related to IP extraction ==="
rg -n --type=java 'IpAddress|RemoteAddress|getIp|getRemoteIp'
echo ""
echo "=== Searching for proxy configuration ==="
rg -n 'proxy|Proxy|PROXY|forwarded|Forwarded' --type=java --type=yml --type=yaml --type=properties
echo ""
echo "=== Checking if there's a configuration for request wrapper or filter ==="
fd -e java | xargs rg -l 'HttpServletRequestWrapper|RequestContextHolder|ServletRequest' | head -10Repository: Team-MOISAM/moisam-server
Length of output: 1087
🏁 Script executed:
#!/bin/bash
# Search for configuration files and check filters for proxy header handling
echo "=== Looking for configuration files ==="
find . -name "application*" -type f | head -20
echo ""
echo "=== Checking filter implementations for proxy handling ==="
cat -n src/main/java/com/meetup/server/global/filter/CompressFilter.java
echo ""
echo "=== Checking if any filter extends FilterChain or handles RemoteAddr ==="
rg -n --type=java 'extends.*Filter|implements.*Filter' | head -10
echo ""
echo "=== Search for any X-Forwarded or proxy-related config ==="
rg -n 'server\.forward|tomcat\.remoteip|trust.*host'
</parameter>Repository: Team-MOISAM/moisam-server
Length of output: 3403
🏁 Script executed:
#!/bin/bash
# Find configuration files in various locations
echo "=== Searching for all configuration files ==="
find . -name "application*" -o -name "*.properties" -o -name "*.yml" | grep -v target | grep -v ".git" | head -20
echo ""
echo "=== Checking src structure ==="
ls -la src/main/resources/ 2>/dev/null || echo "No resources directory found"
echo ""
echo "=== Search for pom.xml to check Spring versions ==="
find . -name "pom.xml" -type f | head -5
echo ""
echo "=== Check if there are any comments about IP or proxy in handlers ==="
rg -n --type=java -B5 -A5 'getRemoteAddr'Repository: Team-MOISAM/moisam-server
Length of output: 4233
프록시 환경에서의 IP 주소 추출 처리 필요
request.getRemoteAddr()는 프록시나 로드 밸런서 뒤에서 실행될 때 클라이언트의 실제 IP가 아닌 프록시의 IP를 반환합니다. Docker 배포 환경을 고려할 때 X-Forwarded-For 또는 X-Real-IP 헤더를 우선적으로 확인하는 프록시 인식 IP 추출 로직으로 변경하세요. 이 패턴은 OAuth2LoginSuccessHandler에서도 동일하게 나타나므로 두 핸들러 모두 수정이 필요합니다.
🤖 Prompt for AI Agents
In
src/main/java/com/meetup/server/auth/support/handler/OAuth2LoginFailureHandler.java
around lines 35-36, request.getRemoteAddr() is used which returns proxy IPs;
change to proxy-aware extraction by first checking X-Forwarded-For then
X-Real-IP headers (falling back to request.getRemoteAddr()) to obtain the client
IP, and apply the same change to OAuth2LoginSuccessHandler so both handlers use
the same header-priority logic and handle comma-separated X-Forwarded-For values
by taking the first non-empty entry.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
위 방식대로 처리.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🧩 Analysis chain
🏁 Script executed:
#!/bin/bash
# Fetch the specific comment from GitHub to understand the context
gh api repos/Team-MOISAM/moisam-server/issues/comments/3703833941 --jq '.body'
Length of output: 648
@anxi01, 이해했습니다! 인프라 레벨에서 externalTrafficPolicy: Local로 설정하여 클라이언트 IP를 보존하는 방식이군요.
이 경우 request.getRemoteAddr()를 사용하는 것이 적절합니다. Traefik에서 소스 IP를 보존하므로, 애플리케이션 레벨에서 별도로 X-Forwarded-For 헤더를 파싱할 필요가 없어 코드가 더 간결해집니다.
현재 단일 노드 k3s 환경에서는 Local 정책을 사용해도 부하 분산에 문제가 없고, 향후 멀티 노드로 확장할 때 다시 고려하신다는 계획도 합리적입니다. 👍
(ㅇㅅㅇ)ノ✨
🧬 Code Graph Analysis Results
filename:src/main/java/com/meetup/server/auth/support/handler/OAuth2LoginFailureHandler.java
package com.meetup.server.auth.support.handler;
import com.meetup.server.user.domain.type.LoginStatus;
import com.meetup.server.user.implement.LogUserLoginWriter;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.HttpHeaders;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.web.authentication.SimpleUrlAuthenticationFailureHandler;
import org.springframework.stereotype.Component;
import org.springframework.web.util.UriComponentsBuilder;
import java.io.IOException;
@Slf4j
@RequiredArgsConstructor
@Component
public class OAuth2LoginFailureHandler extends SimpleUrlAuthenticationFailureHandler {
private final LogUserLoginWriter logUserLoginWriter;
@Value("${app.oauth2.failureRedirectUri}")
private String redirectUri;
@Override
public void onAuthenticationFailure(
HttpServletRequest request, HttpServletResponse response, AuthenticationException exception
) throws IOException {
log.info("OAuth2 login failed: {}", exception.getMessage());
String ipAddress = request.getHeader("X-Real-IP");
String userAgent = request.getHeader(HttpHeaders.USER_AGENT);
logUserLoginWriter.save(null, LoginStatus.FAILURE, ipAddress, userAgent, exception.getMessage());
String targetUrl = UriComponentsBuilder.fromUriString(redirectUri)
.queryParam("error", exception.getLocalizedMessage())
.build().toUriString();
getRedirectStrategy().sendRedirect(request, response, targetUrl);
}
}
filename:src/main/java/com/meetup/server/auth/support/handler/OAuth2LoginSuccessHandler.java
@Slf4j
@Component
@RequiredArgsConstructor
public class OAuth2LoginSuccessHandler extends SimpleUrlAuthenticationSuccessHandler {
private final JwtTokenProvider tokenProvider;
private final CookieUtil cookieUtil;
private final LogUserLoginWriter logUserLoginWriter;
@Value("${app.oauth2.successRedirectUri}")
private String successRedirectUri;
@Override
public void onAuthenticationSuccess(
HttpServletRequest request, HttpServletResponse response, Authentication authentication
) throws IOException {
CustomOAuth2User oAuth2User = (CustomOAuth2User) authentication.getPrincipal();
String ipAddress = request.getHeader("X-Real-IP");
String userAgent = request.getHeader(HttpHeaders.USER_AGENT);
logUserLoginWriter.save(oAuth2User.getUserId(), LoginStatus.SUCCESS, ipAddress, userAgent, null);
String accessToken = tokenProvider.createAccessToken(oAuth2User);
String refreshToken = tokenProvider.createRefreshToken(oAuth2User);
cookieUtil.setAccessTokenCookie(response, accessToken);
cookieUtil.setRefreshTokenCookie(response, refreshToken);
String targetUrl = createRedirectUrlWithTokens(request);
getRedirectStrategy().sendRedirect(request, response, targetUrl);
}
private String createRedirectUrlWithTokens(HttpServletRequest request) {
StateParams stateParams = parseState(request.getParameter("state"));
String redirectUrl;
if ("visited".equals(stateParams.to) && stateParams.eventId != null && stateParams.placeId != null) {
redirectUrl = buildVisitedRedirectUrl(stateParams.eventId, stateParams.placeId);
} else if ("notvisited".equals(stateParams.to) && stateParams.eventId != null && stateParams.placeId != null) {
redirectUrl = buildNotVisitedRedirectUrl(stateParams.eventId, stateParams.placeId);
} else {
redirectUrl = buildDefaultCallbackUrl(stateParams.eventId, stateParams.to);
}
log.info("[Redirect URI] - {}", redirectUrl);
return redirectUrl;
}
private StateParams parseState(String state) {
StateParams params = new StateParams();
if (state == null || state.isBlank()) return params;
String decodedState = java.net.URLDecoder.decode(state, java.nio.charset.StandardCharsets.UTF_8);
for (String param : decodedState.split("&")) {
String[] keyValue = param.split("=", 2);
if (keyValue.length == 2) {
switch (keyValue[0]) {
case "to" -> params.to = keyValue[1];
case "eventId" -> params.eventId = keyValue[1];
case "placeId" -> params.placeId = keyValue[1];
}
}
}
return params;
}
private String buildDefaultCallbackUrl(String eventId, String to) {
UriComponentsBuilder uriBuilder = UriComponentsBuilder.fromUriString(successRedirectUri).pathSegment("oauth", "kakao", "callback");
if (eventId != null && !eventId.isBlank()) {
uriBuilder.queryParam("eventId", eventId);
}
if (to != null && !to.isBlank()) {
uriBuilder.queryParam("to", to);
}
return uriBuilder.build().toUriString();
}
private String buildVisitedRedirectUrl(String eventId, String placeId) {
return UriComponentsBuilder.fromUriString(successRedirectUri)
.pathSegment("visited", eventId, placeId)
.build()
.toUriString();
}
private String buildNotVisitedRedirectUrl(String eventId, String placeId) {
return UriComponentsBuilder.fromUriString(successRedirectUri)
.pathSegment("notvisited", eventId, placeId)
.build()
.toUriString();
}
private static class StateParams {
String to;
String eventId;
String placeId;
}
}
filename:src/main/java/com/meetup/server/user/implement/LogUserLoginWriter.java
@Slf4j
@Component
@RequiredArgsConstructor
public class LogUserLoginWriter {
private final LogUserLoginRepository logUserLoginRepository;
@Async
public CompletableFuture<Void> save(Long userId, LoginStatus loginStatus, String ipAddress, String userAgent, String failReason) {
try {
logUserLoginRepository.save(
LogUserLogin.builder()
.userId(userId)
.loginStatus(loginStatus)
.ipAddress(ipAddress)
.userAgent(userAgent)
.failReason(failReason)
.build()
);
} catch (Exception e) {
log.error("[LogUserLoginWriter]: 유저 로그인 로그 저장에 실패했습니다. userId: {}", userId, e);
}
return CompletableFuture.completedFuture(null);
}
}
filename:src/main/java/com/meetup/server/auth/presentation/filter/JwtAuthenticationFilter.java
@Slf4j
@Component
@RequiredArgsConstructor
public class JwtAuthenticationFilter extends OncePerRequestFilter {
private final JwtTokenProvider jwtTokenProvider;
private final AuthenticationUtil authenticationUtil;
private final CookieUtil cookieUtil;
private final AuthService authService;
@Override
protected void doFilterInternal(
HttpServletRequest request,
@NonNull HttpServletResponse response,
@NonNull FilterChain filterChain
) throws ServletException, IOException {
String token = cookieUtil.getAccessTokenFromCookie(request);
if (StringUtils.hasText(token) && jwtTokenProvider.validateToken(token)) {
authenticationUtil.setAuthenticationFromRequest(request, token);
} else {
reAuthenticateWithRefreshToken(request, response);
}
filterChain.doFilter(request, response);
}
private void reAuthenticateWithRefreshToken(HttpServletRequest request, HttpServletResponse response) {
String refreshToken = cookieUtil.getRefreshTokenFromCookie(request);
if (refreshToken != null) {
try {
ReissueTokenResponse reissueTokenResponse = authService.reIssueToken(response, refreshToken);
cookieUtil.setAccessTokenCookie(response, reissueTokenResponse.accessToken());
authenticationUtil.setAuthenticationFromRequest(request, reissueTokenResponse.accessToken());
} catch (UserException e) {
log.error("Token is invalid, user check failed: {}", e.getMessage());
cookieUtil.deleteAccessTokenCookie(response);
cookieUtil.deleteRefreshTokenCookie(response);
}
}
}
}
filename:src/main/java/com/meetup/server/auth/support/AuthenticationUtil.java
@Component
@RequiredArgsConstructor
public class AuthenticationUtil {
private final JwtTokenProvider jwtTokenProvider;
public void setAuthenticationFromRequest(HttpServletRequest request, String token) {
Authentication authentication = makeAuthentication(request, token);
SecurityContextHolder.getContext().setAuthentication(authentication);
}
private UserAuthentication makeAuthentication(HttpServletRequest request, String token) {
UserAuthentication authentication = UserAuthentication.from(jwtTokenProvider.getJwtUserDetails(token));
authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
return authentication;
}
}
filename:src/main/java/com/meetup/server/global/presentation/ApiControllerAdvice.java
@Slf4j
@RestControllerAdvice
@RequiredArgsConstructor
public class ApiControllerAdvice{
private final DiscordAlarmSender discordAlarmSender;
@ExceptionHandler(Exception.class)
public ResponseEntity<ApiResponse<?>> handleException(Exception e) {
log.error("Exception : {}", e.getMessage(), e);
discordAlarmSender.sendErrorAlert(e);
return new ResponseEntity<>(ApiResponse.error(GlobalErrorType.INTERNAL_ERROR), GlobalErrorType.INTERNAL_ERROR.getStatus());
}
@ExceptionHandler(NoResourceFoundException.class)
public ResponseEntity<ApiResponse<?>> handleNoResourceFoundException(NoResourceFoundException e) {
return new ResponseEntity<>(ApiResponse.error(GlobalErrorType.NOT_FOUND_RESOURCE), GlobalErrorType.NOT_FOUND_RESOURCE.getStatus());
}
@ExceptionHandler(MethodArgumentNotValidException.class)
public ResponseEntity<ApiResponse<?>> handleMethodArgumentNotValidException(MethodArgumentNotValidException e) {
log.error("MethodArgumentNotValidException : {}", e.getMessage(), e);
return new ResponseEntity<>(ApiResponse.error(GlobalErrorType.FAILED_REQUEST_VALIDATION), GlobalErrorType.FAILED_REQUEST_VALIDATION.getStatus());
}
@ExceptionHandler(IllegalArgumentException.class)
public ResponseEntity<ApiResponse<?>> handleIllegalArgumentException(IllegalArgumentException e) {
log.error("IllegalArgumentException : {}", e.getMessage(), e);
return new ResponseEntity<>(ApiResponse.error(GlobalErrorType.INVALID_REQUEST_ARGUMENT), GlobalErrorType.INVALID_REQUEST_ARGUMENT.getStatus());
}
@ExceptionHandler(GlobalException.class)
public ResponseEntity<ApiResponse<?>> handleGlobalException(GlobalException e) {
log.error("GlobalException : {}", e.getMessage(), e);
return new ResponseEntity<>(ApiResponse.error(e.getErrorType()), e.getErrorType().getStatus());
}
}
✏️ Learnings added
Learnt from: anxi01
Repo: Team-MOISAM/moisam-server PR: 172
File: src/main/java/com/meetup/server/auth/support/handler/OAuth2LoginFailureHandler.java:0-0
Timestamp: 2026-01-03T10:47:54.110Z
Learning: Team-MOISAM/moisam-server 프로젝트는 k3s 환경에서 Traefik Service의 externalTrafficPolicy를 Local로 설정하여 클라이언트 IP를 보존한다. 따라서 request.getRemoteAddr()를 사용하여 실제 클라이언트 IP를 얻을 수 있다.
Note: Learnings are effective only in the context of similar code segments. To apply general coding guidelines, please configure review instructions. You can manage existing learnings in the app.
🧠 Learnings used
Learnt from: anxi01
Repo: Team-MOISAM/moisam-server PR: 150
File: src/main/java/com/meetup/server/global/email/EmailSender.java:17-26
Timestamp: 2025-08-21T10:38:53.847Z
Learning: Team-MOISAM/moisam-server 프로젝트는 config 디렉토리를 git submodule(https://github.com/Team-MOISAM/config.git)로 사용하여 애플리케이션 설정 파일들을 외부 저장소에서 관리하고 있다. 메일 설정 등의 구성은 이 config submodule의 YAML 파일에 정의되어 있다.
| String ipAddress = request.getRemoteAddr(); | ||
| String userAgent = request.getHeader(HttpHeaders.USER_AGENT); | ||
| logUserLoginWriter.save(oAuth2User.getUserId(), LoginStatus.SUCCESS, ipAddress, userAgent, null); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🧩 Analysis chain
🏁 Script executed:
find . -type f -name "LogUserLogin*.java" | head -20Repository: Team-MOISAM/moisam-server
Length of output: 291
🏁 Script executed:
find . -type f -name "*LogUserLoginWriter*.java" | head -20Repository: Team-MOISAM/moisam-server
Length of output: 141
🏁 Script executed:
rg "class LogUserLogin" --type java -A 5Repository: Team-MOISAM/moisam-server
Length of output: 1270
🏁 Script executed:
cat src/main/java/com/meetup/server/user/domain/LogUserLogin.javaRepository: Team-MOISAM/moisam-server
Length of output: 1431
🏁 Script executed:
cat src/main/java/com/meetup/server/user/implement/LogUserLoginWriter.javaRepository: Team-MOISAM/moisam-server
Length of output: 1057
비동기 메서드의 에러 처리와 null 값 저장 문제를 해결하세요.
검증 결과:
ipAddress와userAgent필드는 DB 컬럼에nullable=false제약이 없어 null 값이 저장됩니다LogUserLoginWriter.save()는@Async어노테이션으로 void 반환하므로 저장 실패 시 예외가 묵묵히 무시됩니다@Transactional어노테이션이 없어 비동기 작업의 트랜잭션 관리가 부재합니다
로그인 감사 기록(audit log)이 손실될 수 있으므로:
ipAddress와userAgent에 대한 null 처리 및 기본값 설정- 비동기 메서드의 예외 처리를 위한
AsyncUncaughtExceptionHandler설정 또는 방식 변경 - 감사 기록 저장 실패에 대한 로깅/모니터링 추가
🤖 Prompt for AI Agents
In
src/main/java/com/meetup/server/auth/support/handler/OAuth2LoginSuccessHandler.java
around lines 40-42, the code writes ipAddress and userAgent directly and calls
an @Async void save method which can swallow exceptions and lacks transactional
guarantees; fix by ensuring ipAddress and userAgent are null-safe (replace
null/blank with explicit defaults like "UNKNOWN" or "0.0.0.0" and "UNKNOWN-UA"
before calling save), change the async writer API to return a
CompletableFuture/CompletionStage (or Future) so callers can handle
completion/errors, add @Transactional on the writer side (or ensure the async
executor is transaction-aware) and implement error handling either by
configuring an AsyncUncaughtExceptionHandler or by handling exceptions on the
CompletableFuture (log and increment audit-failure metric), and add explicit
logging/monitoring when save fails so audit-record loss is visible.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
ipAddress, userAgent가 혹 빈값으로 들어올 수 있어 우선 nullable하게 처리.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@anxi01, nullable 처리로 진행하시는 것 이해했습니다.
X-Real-IP 헤더나 User-Agent가 누락될 수 있는 엣지 케이스를 고려하신 실용적인 결정입니다. 다만 몇 가지 추가로 고려해보시면 좋을 점들이 있습니다:
-
Null 값 가시성: ipAddress나 userAgent가 null로 저장될 때를 대비해 로깅을 추가하면 프록시 설정 문제나 이상 트래픽을 감지하는 데 도움이 됩니다.
String ipAddress = request.getHeader("X-Real-IP"); String userAgent = request.getHeader(HttpHeaders.USER_AGENT); if (ipAddress == null || ipAddress.isBlank()) { log.warn("Login log: X-Real-IP header is missing for user {}", oAuth2User.getUserId()); }
-
비동기 에러 핸들링:
LogUserLoginWriter.save()가@Async로 동작하므로 저장 실패 시 예외가 조용히 무시될 수 있습니다. 로그인 감사 기록은 중요하므로, 필요시CompletableFuture반환값을 활용한 에러 핸들링이나AsyncUncaughtExceptionHandler설정을 검토해보시는 것을 권장드립니다.
현재 구현으로 진행하시되, 향후 로그인 이력 데이터를 분석하실 때 null 값 비율을 모니터링하시면 프록시 설정이 올바른지 확인하는 데 도움이 될 것 같습니다.
src/main/java/com/meetup/server/auth/support/handler/OAuth2LoginSuccessHandler.java
Show resolved
Hide resolved
e957eaa to
5fd6a37
Compare
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Actionable comments posted: 1
📜 Review details
Configuration used: Path: .coderabbit.yaml
Review profile: CHILL
Plan: Pro
📒 Files selected for processing (1)
src/main/java/com/meetup/server/auth/support/handler/OAuth2LoginSuccessHandler.java
🧰 Additional context used
🧠 Learnings (1)
📚 Learning: 2025-12-25T11:50:19.070Z
Learnt from: anxi01
Repo: Team-MOISAM/moisam-server PR: 170
File: src/main/java/com/meetup/server/admin/application/AdminService.java:70-76
Timestamp: 2025-12-25T11:50:19.070Z
Learning: In the MOISAM project, the server and database operate in the Asia/Seoul timezone, and the service is domestic. Therefore, using LocalDate.now() or LocalDateTime.now() without an explicit ZoneId is acceptable for this codebase. Apply this guideline to Java files under src/main/java, but remain vigilant: for time-sensitive data or if future maintenance requires cross-timezone handling, prefer using ZoneId or ZonedDateTime with an explicitZone.
Applied to files:
src/main/java/com/meetup/server/auth/support/handler/OAuth2LoginSuccessHandler.java
🧬 Code graph analysis (1)
src/main/java/com/meetup/server/auth/support/handler/OAuth2LoginSuccessHandler.java (1)
src/main/java/com/meetup/server/user/implement/LogUserLoginWriter.java (1)
Component(10-28)
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (1)
- GitHub Check: build
🔇 Additional comments (2)
src/main/java/com/meetup/server/auth/support/handler/OAuth2LoginSuccessHandler.java (2)
6-7: LGTM!새로운 로그인 로깅 기능에 필요한 import문들이 적절하게 추가되었습니다.
Also applies to: 13-13, 20-20
29-29: LGTM!의존성 주입 필드가 적절하게 선언되었습니다.
src/main/java/com/meetup/server/auth/support/handler/OAuth2LoginSuccessHandler.java
Outdated
Show resolved
Hide resolved
5fd6a37 to
39ec7d2
Compare
[Issue]로그인 로그에 실제 유저 IP 대신 k3s 내부 사설 IP(10.x.x.x)가 기록됨. [Cause]기본 네트워크 정책(externalTrafficPolicy: Cluster)에 의한 SNAT(Source Network Address Translation) 발생.
[Solution]Traefik 서비스 설정을
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Actionable comments posted: 1
🧹 Nitpick comments (7)
src/main/java/com/meetup/server/user/implement/LogUserLoginWriter.java (1)
20-36: CompletableFuture 에러 처리 방식 개선을 고려해주세요.이전 리뷰 코멘트를 반영하여 try-catch와 에러 로깅을 추가해주신 점은 좋습니다. 다만 현재 구현은 예외 발생 시에도
CompletableFuture.completedFuture(null)을 반환하여 항상 성공으로 완료됩니다.현재 호출부(OAuth2LoginSuccessHandler, OAuth2LoginFailureHandler)에서 반환값을 대기하지 않는 fire-and-forget 방식이므로 실질적인 문제는 없지만, CompletableFuture의 의미론과 일관성을 위해 다음 중 하나를 고려해보세요:
- 예외 발생 시
CompletableFuture.failedFuture(e)반환 (호출부에서 에러 관찰 가능)- 완전한 fire-and-forget이라면 void 반환으로 단순화
🔎 제안: 예외 발생 시 실패 상태로 완료
@Async public CompletableFuture<Void> save(Long userId, LoginStatus loginStatus, String ipAddress, String userAgent, String failReason) { try { logUserLoginRepository.save( LogUserLogin.builder() .userId(userId) .loginStatus(loginStatus) .ipAddress(ipAddress) .userAgent(userAgent) .failReason(failReason) .build() ); + return CompletableFuture.completedFuture(null); } catch (Exception e) { log.error("[LogUserLoginWriter]: 유저 로그인 로그 저장에 실패했습니다. userId: {}", userId, e); + return CompletableFuture.failedFuture(e); } - return CompletableFuture.completedFuture(null); }src/main/resources/templates/admin/users.html (2)
6-125: 스타일을 외부 CSS 파일로 분리하는 것을 고려하세요.인라인 스타일이 125줄이나 되어 파일 크기가 크고 재사용성이 떨어집니다. 관리자 페이지의 공통 스타일을 외부 CSS 파일로 분리하면 유지보수성과 일관성을 개선할 수 있습니다.
152-173: 사용자 목록이 비어있을 때의 처리를 추가하는 것을 권장합니다.현재
userPages.content가 비어있을 경우 빈 테이블만 표시됩니다. 사용자 경험 개선을 위해 "등록된 사용자가 없습니다"와 같은 메시지를 표시하는 것이 좋습니다.🔎 제안하는 개선
</thead> <tbody> + <tr th:if="${userPages.content.isEmpty()}"> + <td colspan="6" style="text-align: center; padding: 40px;">등록된 사용자가 없습니다.</td> + </tr> <tr th:each="user : ${userPages.content}"> <td th:text="${user.userId}">ID</td>src/main/resources/templates/admin/fragments/common.html (2)
113-117:.main-content스타일이events.html에서 중복 정의됨.이 스타일 블록이
events.html의 13-17번 라인에도 정의되어 있습니다. 프래그먼트가 포함될 때 스타일 충돌이나 유지보수 문제가 발생할 수 있습니다.스타일을 한 곳에서만 정의하거나, 공통 CSS 파일로 분리하는 것을 권장합니다.
120-139: 접근성 개선을 위한 ARIA 속성 추가 고려.스크린 리더 사용자를 위해 네비게이션에 ARIA 속성을 추가하면 좋습니다.
🔎 접근성 개선 제안
- <div class="sidebar" id="sidebar"> + <div class="sidebar" id="sidebar" role="navigation" aria-label="Admin navigation"> <div class="sidebar-header"> <h3>MOISAM Admin</h3> - <button id="toggle-btn" type="button">☰</button> + <button id="toggle-btn" type="button" aria-label="Toggle sidebar" aria-expanded="true">☰</button> </div> - <ul> + <ul role="menubar">src/main/resources/templates/admin/events.html (2)
13-17:.main-content스타일 중복.이 스타일이
common.html프래그먼트(113-117번 라인)에도 정의되어 있습니다. 한 곳에서만 정의하여 유지보수성을 높이세요.
187-201: 이벤트가 없을 때 빈 상태(empty state) 처리 고려.
eventPages.content가 비어있을 경우 테이블 헤더만 표시됩니다. 사용자 경험을 위해 빈 상태 메시지를 추가하면 좋습니다.🔎 빈 상태 처리 제안
<tbody> <tr th:each="event : ${eventPages.content}"> <!-- ... existing code ... --> </tr> + <tr th:if="${#lists.isEmpty(eventPages.content)}"> + <td colspan="7" style="text-align: center; padding: 40px; color: #6c757d;"> + 등록된 이벤트가 없습니다. + </td> + </tr> </tbody>
📜 Review details
Configuration used: Path: .coderabbit.yaml
Review profile: CHILL
Plan: Pro
📒 Files selected for processing (14)
src/main/java/com/meetup/server/admin/application/AdminService.javasrc/main/java/com/meetup/server/admin/dto/response/AdminUserResponse.javasrc/main/java/com/meetup/server/admin/dto/response/DailyEventStatsResponse.javasrc/main/java/com/meetup/server/admin/dto/response/DailyStatsResponse.javasrc/main/java/com/meetup/server/admin/dto/response/DailyUserStatsResponse.javasrc/main/java/com/meetup/server/admin/presentation/AdminController.javasrc/main/java/com/meetup/server/user/implement/LogUserLoginReader.javasrc/main/java/com/meetup/server/user/implement/LogUserLoginWriter.javasrc/main/java/com/meetup/server/user/implement/UserReader.javasrc/main/java/com/meetup/server/user/infrastructure/jpa/LogUserLoginRepository.javasrc/main/java/com/meetup/server/user/infrastructure/jpa/UserRepository.javasrc/main/resources/templates/admin/events.htmlsrc/main/resources/templates/admin/fragments/common.htmlsrc/main/resources/templates/admin/users.html
💤 Files with no reviewable changes (1)
- src/main/java/com/meetup/server/admin/dto/response/DailyStatsResponse.java
🚧 Files skipped from review as they are similar to previous changes (1)
- src/main/java/com/meetup/server/user/infrastructure/jpa/LogUserLoginRepository.java
🧰 Additional context used
🧠 Learnings (1)
📚 Learning: 2025-12-25T11:50:19.070Z
Learnt from: anxi01
Repo: Team-MOISAM/moisam-server PR: 170
File: src/main/java/com/meetup/server/admin/application/AdminService.java:70-76
Timestamp: 2025-12-25T11:50:19.070Z
Learning: In the MOISAM project, the server and database operate in the Asia/Seoul timezone, and the service is domestic. Therefore, using LocalDate.now() or LocalDateTime.now() without an explicit ZoneId is acceptable for this codebase. Apply this guideline to Java files under src/main/java, but remain vigilant: for time-sensitive data or if future maintenance requires cross-timezone handling, prefer using ZoneId or ZonedDateTime with an explicitZone.
Applied to files:
src/main/java/com/meetup/server/user/implement/LogUserLoginReader.javasrc/main/java/com/meetup/server/user/infrastructure/jpa/UserRepository.javasrc/main/java/com/meetup/server/user/implement/UserReader.javasrc/main/java/com/meetup/server/admin/presentation/AdminController.javasrc/main/java/com/meetup/server/admin/application/AdminService.javasrc/main/java/com/meetup/server/admin/dto/response/DailyEventStatsResponse.javasrc/main/java/com/meetup/server/admin/dto/response/AdminUserResponse.javasrc/main/java/com/meetup/server/admin/dto/response/DailyUserStatsResponse.javasrc/main/java/com/meetup/server/user/implement/LogUserLoginWriter.java
🧬 Code graph analysis (3)
src/main/java/com/meetup/server/user/implement/LogUserLoginReader.java (1)
src/main/java/com/meetup/server/user/implement/UserReader.java (1)
Component(17-42)
src/main/java/com/meetup/server/user/implement/UserReader.java (2)
src/main/java/com/meetup/server/event/implement/EventReader.java (1)
Component(18-46)src/main/java/com/meetup/server/startpoint/implement/StartPointReader.java (1)
Component(20-56)
src/main/java/com/meetup/server/user/implement/LogUserLoginWriter.java (2)
src/main/java/com/meetup/server/auth/support/handler/OAuth2LoginFailureHandler.java (1)
Slf4j(18-45)src/main/java/com/meetup/server/auth/support/handler/OAuth2LoginSuccessHandler.java (1)
Slf4j(21-120)
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (1)
- GitHub Check: build
🔇 Additional comments (17)
src/main/java/com/meetup/server/user/infrastructure/jpa/UserRepository.java (1)
17-17: LGTM!Spring Data JPA의 메서드 네이밍 컨벤션을 따르는 표준적인 쿼리 메서드입니다.
src/main/java/com/meetup/server/admin/application/AdminService.java (2)
77-89: LGTM!기존
getAllEvents메서드와 동일한 패턴을 따르는 일관된 구현입니다.
99-105: LGTM!일일 사용자 통계를 조회하는 로직이 명확하고 간결합니다.
LocalDate.now()사용은 Asia/Seoul 타임존에서 운영되는 프로젝트 특성상 적절합니다.src/main/resources/templates/admin/users.html (3)
135-150: 통계 카드 구현이 잘 되어 있습니다.null 체크와 숫자 포맷팅이 적절하게 구현되어 있습니다.
175-191: 페이지네이션 로직이 올바르게 구현되어 있습니다.블록 기반 페이지네이션 계산이 정확하고, Spring Data의 0-based 인덱싱을 올바르게 처리하고 있습니다.
129-193:admin-wrapperdiv의 닫는 태그가 누락되었습니다.129번 라인에서
<div class="admin-wrapper">를 열었지만 닫는</div>태그가 없습니다. 193번 라인 이전에</div>를 추가해야 합니다.🔎 제안하는 수정
</div> </div> +</div> </body> </html>Likely an incorrect or invalid review comment.
src/main/java/com/meetup/server/admin/presentation/AdminController.java (3)
7-9: 새로운 DTO 임포트가 적절합니다.관리자 응답을 위한 DTO들이 올바르게 임포트되었습니다.
77-81: 일일 통계 추가가 적절하게 구현되었습니다.이벤트 페이지에 일일 통계를 추가하는 로직이 올바르게 구현되었으며, 새로 추가된 getAllUsers 메서드와 일관된 패턴을 따릅니다.
85-96: 사용자 목록 엔드포인트가 잘 구현되었습니다.
getAllEvents메서드와 일관된 패턴을 따르고 있으며, 페이지네이션과 통계 조회 로직이 적절합니다.SecurityConfig에서/admins/**경로에 대해ADMIN권한을 요구하는 보안 설정이 적용되어 있으므로 인증/권한 검증이 제대로 처리되고 있습니다.src/main/java/com/meetup/server/user/implement/UserReader.java (2)
8-14: 필요한 임포트가 적절하게 추가되었습니다.페이지네이션과 날짜 처리를 위한 표준 라이브러리가 올바르게 임포트되었습니다.
37-41: 일일 가입자 수 조회 로직이 올바르게 구현되었습니다.날짜 범위 계산이 정확하며, 코드베이스의 다른 Reader들(
EventReader.readDailyEventCount,StartPointReader.readDailyParticipantCount)과 일관된 패턴을 따릅니다.Based on learnings, 프로젝트의 서버와 데이터베이스가 Asia/Seoul 타임존에서 운영되므로 명시적인 ZoneId 없이
LocalDate.now()를 사용하는 것이 적절합니다.src/main/java/com/meetup/server/user/implement/LogUserLoginReader.java (1)
1-22: 로그인 로그 Reader가 깔끔하게 구현되었습니다.구현이 간결하고 명확하며,
UserReader의readDailyRegisterUserCount메서드와 일관된 패턴을 따릅니다. 날짜 범위 계산 로직도 정확합니다.Based on learnings, Asia/Seoul 타임존에서 운영되므로
LocalDate와LocalDateTime사용이 적절합니다.src/main/java/com/meetup/server/admin/dto/response/AdminUserResponse.java (2)
7-14: DTO record 정의가 적절합니다.불변 record를 사용하여 DTO를 정의한 것이 좋으며, 필드 타입과 네이밍이 명확합니다.
15-24: 팩토리 메서드가 올바르게 구현되었습니다.User 도메인 객체에서 AdminUserResponse로의 매핑이 정확하며, static 팩토리 메서드 패턴을 적절하게 사용했습니다.
src/main/resources/templates/admin/fragments/common.html (1)
141-146: 토글 기능 구현 적절함.사이드바 접기/펼치기 로직이 간결하게 구현되었습니다. 위의 접근성 제안을 적용할 경우,
aria-expanded속성도 함께 토글하면 좋습니다.src/main/resources/templates/admin/events.html (2)
203-227: 페이지네이션 구현 적절함.블록 기반 페이지네이션 로직이 올바르게 구현되었습니다. 첫 페이지/마지막 페이지 네비게이션과 조건부 렌더링이 잘 처리되어 있습니다.
152-172: 사이드바 통합 및 통계 표시 구현 적절함.프래그먼트 포함 구문이 올바르고,
dailyEventStats에 대한 null 체크와 숫자 포맷팅이 잘 적용되었습니다.
syjdjr
left a comment
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
고생하셨습니다!
* FEAT: (MOISAM-246) 유저 로그인 로그 엔티티, 리포지토리를 설계한다 * FEAT: (MOISAM-246) 로그인 성공/실패 log를 저장한다 * REFACTOR: (MOISAM-246) UserAgent를 Spring Http 상수로 변경한다 * REFACTOR: (MOISAM-246) X-Real-IP에서 IP주소를 받아오도록 변경한다 * FEAT: (MOISAM-246) 로그인 이력 저장 실패 시 로깅을 수행한다 * FEAT: (MOISAM-246) 사이드바 프래그먼트를 추가한다 * FEAT: (MOISAM-246) 어드민에서 금일 가입자 수, 로그인 유저 수 및 가입 유저 정보를 조회한다 * FEAT: (MOISAM-246) 어드민 User View를 추가한다 * FEAT: (MOISAM-246) 유저 탈퇴 여부 및 탈퇴 일시를 추가한다
🚀 Why - 해결하려는 문제가 무엇인가요?
✅ What - 무엇이 변경됐나요?
LogUserLogin) 추가🛠️ How - 어떻게 해결했나요?
🖼️ Attachment
💬 기타 코멘트
Summary by CodeRabbit
새로운 기능
관리자 UI
성능
✏️ Tip: You can customize this high-level summary in your review settings.