NoModel 회원·보안 아키텍처
01. Member 관리 개요
member모듈은application·domain·infrastructure3계층으로 구성되어 가입, 로그인, 사용자 정보 조회 여정을 담당합니다.- 영속 계층은 JPA(
member_tb,login_history_tb)와 Redis(refresh:*,firstLogin:*)를 함께 사용해 토큰과 최초 로그인 캐시를 분리 관리합니다. - Spring Security 필터 체인과 감사 설정은
_core/config에서 통합되어 로그인 성공 시createdBy·modifiedBy에memberId문자열이 자동 기록됩니다. - 스케줄러는 90일이 지난 로그인 이력을 삭제하고, 도메인 서비스는 IP 차단 여부를 Redis에서 곧바로 확인합니다.
JWT기반 토큰을 선택해 API 게이트웨이 없이도 무상태 인증을 유지하며, Refresh Token으로 장기 세션을 안전하게 연장합니다.- 토큰은
ResponseCookie에 담아httpOnly·secure·sameSite옵션을 강제하므로 XSS·CSRF 공격면을 최소화하고 브라우저 자동 전송을 이용합니다.
02. 도메인 모델 & 검증
Member엔티티는 값 객체Email,Password와 역할/상태 enum으로 구성되며 팩토리 메서드가 기본 권한과 상태를 고정합니다.1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18
@Builder private Member(String username, Email email, Password password, Role role, Status status) { this.username = username; this.email = email; this.password = password; this.role = role; this.status = status; } public static Member createMember(String username, Email email, Password password) { return Member.builder() .username(username) .email(email) .password(password) .role(Role.USER) .status(Status.ACTIVE) .build(); }
- 빌더는 프라이빗 생성자에 연결되어 있고, 팩토리는
Role.USER·Status.ACTIVE를 강제해 신규 가입자가 항상 일반 사용자/활성 상태로 시작하게 합니다.MemberAuthService는 DTO를 값 객체로 변환한 뒤 이 팩토리를 호출해 생성 로직을 단순화합니다. - 추가적으로
validatePassword와isActive헬퍼로 인증 로직에 필요한 검증을 도메인 내부에서 수행합니다.1 2 3 4 5 6 7
public boolean validatePassword(String rawPassword, PasswordEncoder passwordEncoder) { return this.password.matches(rawPassword, passwordEncoder); } public boolean isActive() { return this.status == Status.ACTIVE; }
Email,Password는 생성 시 자체 검증을 수행해 잘못된 값이 엔티티에 주입되지 않도록 합니다. 정규식·길이 검증을 통과한 값만 팩토리(Email.of,Password.encode)에서 반환되며, 암호는PasswordEncoder로 즉시 해싱됩니다.1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26
@Embeddable public class Email { public static Email of(String value) { return new Email(value); } private static void validateEmail(String value) { if (value == null || !value.matches("^[\\w.-]+@[\\w.-]+\\.\\w{2,}$")) { throw new ApplicationException(ErrorCode.INVALID_REQUEST); } } } @Embeddable public class Password { public static Password encode(String value, PasswordEncoder encoder) { validatePassword(value); return new Password(encoder.encode(value)); } private static void validatePassword(String password) { if (password == null || password.length() < 4) { throw new ApplicationException(ErrorCode.INVALID_REQUEST); } } }
LoginHistory는 성공/실패 여부와 해시된 IP를 저장하며, 실패 이력에도member_id를 선택적으로 연결해 공격 패턴을 추적합니다.RefreshToken은 TTL 3일짜리RedisHash로 저장되어 JWT 재발급과 권한을 묶어 유지합니다.MemberDomainService는 이메일 중복 검사와 향후 활성/탈퇴 규칙 확장 지점을 확보하고, DTO 레이어는 Bean Validation으로 1차 입력 검증을 수행합니다.
03. 보안 & 감사
CustomUserDetailsService는 이메일로 회원을 조회해CustomUserDetails를 생성하고, 계정 상태를 확인합니다.1 2 3 4 5 6 7 8
public UserDetails loadUserByUsername(String email) { Member member = memberJpaRepository.findByEmail(Email.of(email)) .orElseThrow(() -> new ApplicationException(ErrorCode.MEMBER_NOT_FOUND)); if (!member.getStatus().equals(Status.ACTIVE)) throw new ApplicationException(ErrorCode.MEMBER_NOT_ACTIVE); return new CustomUserDetails(member.getId(), member.getEmail().getValue(), member.getPassword().getValue(), List.of(new SimpleGrantedAuthority(member.getRole().getKey()))); }
LoginSecurityDomainService는 실패 횟수를 Redis에 누적하고 지수형 차단 시간을 계산하며, 성공 시에는 즉시 초기화합니다.AuditConfig와SecurityConfig가 결합되어 감사 컬럼과 JWT 기반 무상태 세션 정책을 동시에 적용합니다.
03-1. 로그인 이벤트 & 관찰 가능성
LoginEventListener는 인증 성공/실패 이벤트를 비동기로 처리해 감사 로그, 실패 누적, 이상 로그인 감지를 동시에 수행합니다.1 2 3 4 5 6 7 8 9 10 11
@Async @EventListener public void handleAuthenticationSuccess(AuthenticationSuccessEvent event) { CustomUserDetails user = (CustomUserDetails) event.getAuthentication().getPrincipal(); Member member = memberRepository.findById(user.getMemberId()).orElseThrow(...); String ip = extractIpFromAuthentication(event.getAuthentication()); String hashedIp = loginSecurityDomainService.hashIpAddress(ip); loginSecurityDomainService.clearLoginFailures(ip); loginSecurityDomainService.detectAnomalousLogin(member.getId(), hashedIp); loginHistoryRepository.save(LoginHistory.createSuccessHistory(member, hashedIp)); }
- 실패 이벤트도 동일한 리스너에서 처리하여 실패 횟수 증가와 로그인 이력 저장을 자동화합니다.
- 감사 로그는
_core/logging/StructuredLogger와ControllerAspect가 구조화하여 추적성을 확보합니다.
03-2. IP 차단 정책 (Redis)
LoginSecurityDomainService.recordLoginFailure는 Redis에 실패 횟수를 누적하고, 차단 이력에 따라 지수형 차단 시간을 계산합니다.1 2 3 4 5 6 7 8 9 10 11 12
public void recordLoginFailure(String ipAddress) { String hashedIp = hashIpAddress(ipAddress); String failureKey = FAILURE_KEY_PREFIX + hashedIp; long failures = Optional.ofNullable(redisTemplate.opsForValue().increment(failureKey)).orElse(1L); if (failures == 1) redisTemplate.expire(failureKey, Duration.ofMinutes(CHECK_MINUTES)); if (failures >= MAX_FAILED_ATTEMPTS) { int blocks = ...; long blockMinutes = calculateBlockDuration(blocks); redisTemplate.opsForValue().set(historyKey, String.valueOf(blocks), Duration.ofHours(24)); redisTemplate.expire(failureKey, Duration.ofMinutes(blockMinutes)); } }
clearLoginFailures는 성공 시 카운트와 차단 이력을 즉시 제거합니다.- IP 해시화, 프록시 헤더 기반 IP 추출, 현재 IP가 차단되었는지 검사하는 메서드로 운영 중 보안 정책을 강화합니다.
04. 애플리케이션 서비스 & 컨트롤러
MemberAuthService는 회원가입 시 값 객체 생성 → 중복 검사 → 암호화 → 저장 → 첫 로그인 캐시 기록을 한 플로우로 묶습니다.- 로그인 플로우는 IP 차단 검증 →
AuthenticationManager인증 → JWT 발급 → 리프레시 토큰 Redis 저장까지 연결됩니다.1 2 3 4 5 6
public AuthTokenDTO login(LoginRequestDto request) { loginSecurityDomainService.validateCurrentIpNotBlocked(); Member member = memberJpaRepository.findByEmail(Email.of(request.email())) .orElseThrow(() -> new ApplicationException(ErrorCode.MEMBER_NOT_FOUND)); return generateAuthTokens(member, request.email(), request.password()); }
MemberAuthController는ResponseCookie로 httpOnly, secure, sameSite 옵션을 적용해 토큰을 반환합니다.- 토큰 재발급/로그아웃도 동일 서비스에서 쿠키 추출 → 검증 → Redis 확인 → JWT 재발급/삭제 순으로 처리합니다.
05. 스케줄링 & 개인정보 보호
LoginHistoryCleanupScheduler가 90일 이전 로그인 기록을 주기적으로 삭제해 개인정보 보존 정책과 보안 감사 요구를 동시에 충족합니다.- 정리 작업은
StructuredLogger를 통해 삭제된 건수를 남겨 데이터 거버넌스에 필요한 추적성을 확보합니다.
06. 한계 및 향후 개선
- 이메일 중복 검증이
MemberDomainService선행 조회에 의존하므로, 트래픽이 몰릴 때 동시 가입 시나리오에서 경합이 발생할 여지가 있습니다. 데이터베이스 유니크 제약과 애플리케이션 락 전략을 조합해 중복 허용 가능성을 차단해야 합니다. - 동시 가입 경쟁으로 DB 유니크 제약(
DataIntegrityViolationException)이 발생할 때EMAIL_ALREADY_EXISTS로 매핑하는 전역 예외 처리기를 마련해 사용자 메시지를 일관되게 유지해야 합니다. - Redis가 로그인 보안 로직의 단일 장애 지점으로 남아 있어 장애 시 IP 차단·리프레시 토큰 검증이 무력화됩니다. 멀티 AZ 배포와 복제본 승격 자동화, 장애 시 폴백 정책을 마련해 가용성을 높여야 합니다.
관련 레포지토리
이 글은 저작권자의 CC BY 4.0 라이센스를 따릅니다.