Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 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: 1 addition & 1 deletion config
2 changes: 2 additions & 0 deletions src/main/java/com/meetup/server/ServerApplication.java
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,13 @@
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.cache.annotation.EnableCaching;
import org.springframework.scheduling.annotation.EnableAsync;
import org.springframework.scheduling.annotation.EnableScheduling;

@SpringBootApplication(exclude = { RateLimiterAutoConfiguration.class })
@EnableCaching
@EnableScheduling
@EnableAsync
public class ServerApplication {

public static void main(String[] args) {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,10 +1,13 @@
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;
Expand All @@ -17,6 +20,8 @@
@Component
public class OAuth2LoginFailureHandler extends SimpleUrlAuthenticationFailureHandler {

private final LogUserLoginWriter logUserLoginWriter;

@Value("${app.oauth2.failureRedirectUri}")
private String redirectUri;

Expand All @@ -27,6 +32,10 @@ public void onAuthenticationFailure(

log.info("OAuth2 login failed: {}", exception.getMessage());

String ipAddress = request.getRemoteAddr();
String userAgent = request.getHeader(HttpHeaders.USER_AGENT);
Copy link

@coderabbitai coderabbitai bot Jan 1, 2026

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

🧩 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.java

Repository: 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 -10

Repository: 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.

Copy link
Member Author

Choose a reason for hiding this comment

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

#172 (comment)

위 방식대로 처리.

Copy link

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 파일에 정의되어 있다.

logUserLoginWriter.save(null, LoginStatus.FAILURE, ipAddress, userAgent, exception.getMessage());

String targetUrl = UriComponentsBuilder.fromUriString(redirectUri)
.queryParam("error", exception.getLocalizedMessage())
.build().toUriString();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,14 @@
import com.meetup.server.auth.dto.CustomOAuth2User;
import com.meetup.server.auth.support.CookieUtil;
import com.meetup.server.global.support.jwt.JwtTokenProvider;
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.Authentication;
import org.springframework.security.web.authentication.SimpleUrlAuthenticationSuccessHandler;
import org.springframework.stereotype.Component;
Expand All @@ -22,6 +25,7 @@ public class OAuth2LoginSuccessHandler extends SimpleUrlAuthenticationSuccessHan

private final JwtTokenProvider tokenProvider;
private final CookieUtil cookieUtil;
private final LogUserLoginWriter logUserLoginWriter;

@Value("${app.oauth2.successRedirectUri}")
private String successRedirectUri;
Expand All @@ -33,6 +37,10 @@ public void onAuthenticationSuccess(

CustomOAuth2User oAuth2User = (CustomOAuth2User) authentication.getPrincipal();

String ipAddress = request.getRemoteAddr();
String userAgent = request.getHeader(HttpHeaders.USER_AGENT);
logUserLoginWriter.save(oAuth2User.getUserId(), LoginStatus.SUCCESS, ipAddress, userAgent, null);
Copy link

@coderabbitai coderabbitai bot Jan 1, 2026

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

find . -type f -name "LogUserLogin*.java" | head -20

Repository: Team-MOISAM/moisam-server

Length of output: 291


🏁 Script executed:

find . -type f -name "*LogUserLoginWriter*.java" | head -20

Repository: Team-MOISAM/moisam-server

Length of output: 141


🏁 Script executed:

rg "class LogUserLogin" --type java -A 5

Repository: Team-MOISAM/moisam-server

Length of output: 1270


🏁 Script executed:

cat src/main/java/com/meetup/server/user/domain/LogUserLogin.java

Repository: Team-MOISAM/moisam-server

Length of output: 1431


🏁 Script executed:

cat src/main/java/com/meetup/server/user/implement/LogUserLoginWriter.java

Repository: Team-MOISAM/moisam-server

Length of output: 1057


비동기 메서드의 에러 처리와 null 값 저장 문제를 해결하세요.

검증 결과:

  • ipAddressuserAgent 필드는 DB 컬럼에 nullable=false 제약이 없어 null 값이 저장됩니다
  • LogUserLoginWriter.save()@Async 어노테이션으로 void 반환하므로 저장 실패 시 예외가 묵묵히 무시됩니다
  • @Transactional 어노테이션이 없어 비동기 작업의 트랜잭션 관리가 부재합니다

로그인 감사 기록(audit log)이 손실될 수 있으므로:

  1. ipAddressuserAgent에 대한 null 처리 및 기본값 설정
  2. 비동기 메서드의 예외 처리를 위한 AsyncUncaughtExceptionHandler 설정 또는 방식 변경
  3. 감사 기록 저장 실패에 대한 로깅/모니터링 추가
🤖 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.

Copy link
Member Author

Choose a reason for hiding this comment

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

ipAddress, userAgent가 혹 빈값으로 들어올 수 있어 우선 nullable하게 처리.

Copy link

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가 누락될 수 있는 엣지 케이스를 고려하신 실용적인 결정입니다. 다만 몇 가지 추가로 고려해보시면 좋을 점들이 있습니다:

  1. 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());
    }
  2. 비동기 에러 핸들링: LogUserLoginWriter.save()@Async로 동작하므로 저장 실패 시 예외가 조용히 무시될 수 있습니다. 로그인 감사 기록은 중요하므로, 필요시 CompletableFuture 반환값을 활용한 에러 핸들링이나 AsyncUncaughtExceptionHandler 설정을 검토해보시는 것을 권장드립니다.

현재 구현으로 진행하시되, 향후 로그인 이력 데이터를 분석하실 때 null 값 비율을 모니터링하시면 프록시 설정이 올바른지 확인하는 데 도움이 될 것 같습니다.


String accessToken = tokenProvider.createAccessToken(oAuth2User);
String refreshToken = tokenProvider.createRefreshToken(oAuth2User);

Expand Down
47 changes: 47 additions & 0 deletions src/main/java/com/meetup/server/user/domain/LogUserLogin.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
package com.meetup.server.user.domain;

import com.meetup.server.global.domain.BaseEntity;
import com.meetup.server.global.util.StringUtil;
import com.meetup.server.user.domain.type.LoginStatus;
import jakarta.persistence.*;
import lombok.AccessLevel;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;

@Entity
@Table(name = "log_user_login")
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Getter
public class LogUserLogin extends BaseEntity {

@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "log_user_login_id")
private Long id;

@Column(name = "user_id")
private Long userId;

@Enumerated(EnumType.STRING)
@Column(name = "login_status", nullable = false)
private LoginStatus loginStatus;

@Column(name = "ip_address", length = 45)
private String ipAddress;

@Column(name = "user_agent")
private String userAgent;

@Column(name = "fail_reason")
private String failReason;

@Builder
public LogUserLogin(Long userId, LoginStatus loginStatus, String ipAddress, String userAgent, String failReason) {
this.userId = userId;
this.loginStatus = loginStatus;
this.ipAddress = ipAddress;
this.userAgent = StringUtil.truncate(userAgent, 255);
this.failReason = StringUtil.truncate(failReason, 255);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
package com.meetup.server.user.domain.type;

public enum LoginStatus {
SUCCESS,
FAILURE,
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
package com.meetup.server.user.implement;

import com.meetup.server.user.domain.LogUserLogin;
import com.meetup.server.user.domain.type.LoginStatus;
import com.meetup.server.user.infrastructure.jpa.LogUserLoginRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Component;

@Component
@RequiredArgsConstructor
public class LogUserLoginWriter {

private final LogUserLoginRepository logUserLoginRepository;

@Async
public void save(Long userId, LoginStatus loginStatus, String ipAddress, String userAgent, String failReason) {
logUserLoginRepository.save(
LogUserLogin.builder()
.userId(userId)
.loginStatus(loginStatus)
.ipAddress(ipAddress)
.userAgent(userAgent)
.failReason(failReason)
.build()
);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
package com.meetup.server.user.infrastructure.jpa;

import com.meetup.server.user.domain.LogUserLogin;
import org.springframework.data.jpa.repository.JpaRepository;

public interface LogUserLoginRepository extends JpaRepository<LogUserLogin, Long> {
}