
실제 운영 환경에서 검증된 패턴과 기술을 활용해 고품질 인증 시스템을 구현하는 방법을 소개합니다.
현대의 웹 애플리케이션에서 회원 인증 시스템은 단순한 로그인/로그아웃을 넘어 비즈니스의 핵심 인프라가 되었습니다.
특히 B2B 서비스나 엔터프라이즈 환경에서는 다음과 같은 요구사항들이 필수가 되었습니다.
오늘 소개할 시스템은 이 모든 요구사항을 만족하면서도, 유지보수가 용이하고 확장 가능한 아키텍처로 설계된 실무급 솔루션입니다.
많은 프로젝트에서 아래와 같은 구조로 시작합니다
src/main/java/
├── controller/
├── service/
├── repository/
└── entity/
하지만 프로젝트가 커지면서 다음과 같은 문제들이 발생하곤 합니다.
이 구조의 핵심 장점은 다음과 같습니다.
가장 중요한 것은 비즈니스 규칙을 도메인 객체에 캡슐화하는 것입니다
@Entity
@Table(name = "members")
public class Member extends BaseTimeEntity {
@Enumerated(EnumType.STRING)
@Column(nullable = false)
private MemberStatus status;
@Enumerated(EnumType.STRING)
@Column(nullable = false)
private MemberRole role;
private LocalDateTime approvedAt;
private String approvedBy;
// 🎯 핵심: 비즈니스 로직을 도메인 객체에 캡슐화
public void approve(String approver) {
this.status = MemberStatus.APPROVED;
this.approvedAt = LocalDateTime.now();
this.approvedBy = approver;
}
public boolean canLogin() {
return status == MemberStatus.APPROVED && !isAccountLocked();
}
// 상태 변경의 유효성을 도메인에서 검증
public void reject() {
if (this.status != MemberStatus.PENDING) {
throw new BusinessException(ErrorCode.INVALID_MEMBER_STATUS);
}
this.status = MemberStatus.REJECTED;
}
}
public enum MemberStatus {
PENDING("승인 대기"),
APPROVED("승인 완료"),
REJECTED("승인 거부"),
SUSPENDED("계정 정지");
private final String description;
}
이런 설계의 장점은 다음과 같습니다
@Service
@Transactional(readOnly = true)
public class MemberDomainService {
@Transactional
public Member createMember(String username, String password,
String nickname, String email, String phoneNumber) {
// 🎯 비즈니스 규칙: 중복 검사
if (memberRepository.existsByUsername(username)) {
throw new BusinessException(ErrorCode.DUPLICATE_USERNAME);
}
if (memberRepository.existsByEmail(email)) {
throw new BusinessException(ErrorCode.DUPLICATE_EMAIL);
}
// 🎯 핵심: 새 회원은 항상 승인 대기 상태로 시작
Member member = Member.builder()
.username(username)
.password(passwordEncoder.encode(password))
.nickname(nickname)
.email(email)
.phoneNumber(phoneNumber)
.apiKey(generateApiKey())
.status(MemberStatus.PENDING) // 승인 대기
.role(MemberRole.USER)
.failedLoginAttempts(0)
.build();
return memberRepository.save(member);
}
}
단순한 JWT만으로는 부족합니다. 실제 운영 환경에서는 토큰 무효화가 필요하죠.
@Component
public class JwtTokenProvider {
private final SecretKey secretKey;
private final long accessTokenValidityInMs;
private final long refreshTokenValidityInMs;
public String generateAccessToken(Member member) {
Date now = new Date();
Date validity = new Date(now.getTime() + accessTokenValidityInMs);
return Jwts.builder()
.setSubject(member.getId().toString())
.claim("username", member.getUsername())
.claim("nickname", member.getNickname())
.claim("role", member.getRole().name())
.setIssuedAt(now)
.setExpiration(validity)
.signWith(secretKey, SignatureAlgorithm.HS256)
.compact();
}
// 🎯 핵심: 토큰 검증 로직
public boolean validateToken(String token) {
try {
Jwts.parserBuilder()
.setSigningKey(secretKey)
.build()
.parseClaimsJws(token);
return true;
} catch (JwtException | IllegalArgumentException e) {
log.debug("Invalid JWT token: {}", e.getMessage());
return false;
}
}
}
무차별 대입 공격을 방지하는 적응형 보안 시스템을 구축했습니다.
@Transactional
public boolean processLoginAttempt(String username, String password) {
Member member = findByUsername(username)
.orElseThrow(() -> new BusinessException(ErrorCode.INVALID_CREDENTIALS));
// 🎯 1단계: 계정 잠금 상태 확인
if (member.isAccountLocked()) {
throw new BusinessException(ErrorCode.ACCOUNT_LOCKED);
}
// 🎯 2단계: 승인 상태 확인
if (!member.canLogin()) {
throw new BusinessException(ErrorCode.ACCOUNT_NOT_APPROVED);
}
// 🎯 3단계: 비밀번호 검증
if (!passwordEncoder.matches(password, member.getPassword())) {
handleFailedLogin(member); // 실패 처리
throw new BusinessException(ErrorCode.INVALID_CREDENTIALS);
}
// 🎯 4단계: 성공 처리
member.resetFailedLoginAttempts();
member.updateLastLogin();
return true;
}
private void handleFailedLogin(Member member) {
member.incrementFailedLoginAttempts();
// 🎯 핵심: 5회 실패 시 30분 잠금
if (member.getFailedLoginAttempts() >= 5) {
member.lockAccount(30);
log.warn("Account locked due to failed login attempts: {}", member.getUsername());
}
}
public class PasswordUtil {
// 🎯 강력한 비밀번호 정책
private static final String PASSWORD_PATTERN =
"^(?=.*[a-z])(?=.*[A-Z])(?=.*\\d)(?=.*[@$!%*?&])[A-Za-z\\d@$!%*?&]{8,30}$";
public static boolean isValidPassword(String password) {
return pattern.matcher(password).matches();
}
// 🎯 비밀번호 강도 측정 (1-5점)
public static int getPasswordStrength(String password) {
int score = 0;
if (password.length() >= 12) score += 2;
else if (password.length() >= 10) score += 1;
if (password.matches(".*[a-z].*")) score += 1;
if (password.matches(".*[A-Z].*")) score += 1;
if (password.matches(".*\\d.*")) score += 1;
if (password.matches(".*[@$!%*?&].*")) score += 1;
return Math.min(score, 5);
}
}
실제 서비스에서는 다양한 공격 패턴에 대응해야 합니다
@Service
@RequiredArgsConstructor
public class AbusePreventionService {
private final RedisTemplate<String, Object> redisTemplate;
// 🎯 로그인 시도 제한 (IP별 5회/5분)
public boolean isLoginAllowed(String clientIp) {
String key = "login_attempt:" + clientIp;
String countStr = (String) redisTemplate.opsForValue().get(key);
int count = countStr != null ? Integer.parseInt(countStr) : 0;
if (count >= 5) {
log.warn("Login attempt blocked for IP: {}", clientIp);
return false;
}
return true;
}
public void recordLoginAttempt(String clientIp, boolean success) {
String key = "login_attempt:" + clientIp;
if (success) {
redisTemplate.delete(key); // 성공 시 카운터 리셋
} else {
redisTemplate.opsForValue().increment(key);
redisTemplate.expire(key, Duration.ofMinutes(5));
}
}
// 🎯 API 호출 제한 (분당 100회)
public boolean isApiCallAllowed(String clientIp, String endpoint) {
String key = "api_call:" + clientIp + ":" + endpoint;
String countStr = (String) redisTemplate.opsForValue().get(key);
int count = countStr != null ? Integer.parseInt(countStr) : 0;
if (count >= 100) {
log.warn("API call limit exceeded for IP: {} on endpoint: {}",
clientIp, endpoint);
return false;
}
return true;
}
}
@Component
@RequiredArgsConstructor
public class AbusePreventionInterceptor implements HandlerInterceptor {
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response,
Object handler) throws Exception {
String clientIp = getClientIpAddress(request);
String requestURI = request.getRequestURI();
String method = request.getMethod();
// 🎯 로그인 요청 보호
if (isLoginRequest(requestURI, method)) {
if (!abusePreventionService.isLoginAllowed(clientIp)) {
response.setStatus(HttpStatus.TOO_MANY_REQUESTS.value());
response.getWriter().write(
"{\"message\":\"로그인 시도 횟수를 초과했습니다.\"}");
return false;
}
}
// 🎯 API 호출 보호
if (isApiRequest(requestURI)) {
if (!abusePreventionService.isApiCallAllowed(clientIp, requestURI)) {
response.setStatus(HttpStatus.TOO_MANY_REQUESTS.value());
return false;
}
abusePreventionService.recordApiCall(clientIp, requestURI);
}
return true;
}
}
운영팀이 실제로 사용하기 편한 직관적인 API를 설계했습니다.
@RestController
@RequestMapping("/api/v1/members")
@PreAuthorize("hasRole('ADMIN')")
public class MemberApiController {
// 🎯 페이징과 필터링을 지원하는 회원 목록
@GetMapping
public ApiResponse<Page<MemberResponse>> getMembers(
@PageableDefault(size = 20) Pageable pageable,
@RequestParam(required = false) MemberStatus status) {
Page<MemberResponse> response = status != null
? memberApplicationService.getMembersByStatus(status, pageable)
: memberApplicationService.getMembers(pageable);
return ApiResponse.of(HttpStatus.OK, response);
}
// 🎯 원클릭 승인 시스템
@PostMapping("/{memberId}/approve")
public ApiResponse<Void> approveMember(
@PathVariable Long memberId,
@CurrentMember Long currentMemberId) {
MemberResponse currentMember = memberApplicationService.getMember(currentMemberId);
memberApplicationService.approveMember(memberId, currentMember.username());
return ApiResponse.of(HttpStatus.OK, "회원이 승인되었습니다.", null);
}
// 🎯 권한 변경 (실시간 적용)
@PutMapping("/{memberId}/role")
public ApiResponse<Void> changeRole(
@PathVariable Long memberId,
@RequestParam MemberRole role) {
memberApplicationService.changeRole(memberId, role);
return ApiResponse.of(HttpStatus.OK, "권한이 변경되었습니다.", null);
}
}
운영진이 필요로 하는 핵심 지표들을 제공합니다
@Service
public class MemberStatisticsService {
public Map<String, Object> getMemberStatistics() {
Map<String, Object> statistics = new HashMap<>();
// 🎯 상태별 회원 수
Map<String, Long> statusStats = new HashMap<>();
for (MemberStatus status : MemberStatus.values()) {
statusStats.put(status.name(), memberDomainService.countByStatus(status));
}
statistics.put("statusStatistics", statusStats);
// 🎯 역할별 회원 수
Map<String, Long> roleStats = new HashMap<>();
for (MemberRole role : MemberRole.values()) {
roleStats.put(role.name(), memberDomainService.countByRole(role));
}
statistics.put("roleStatistics", roleStats);
return statistics;
}
}
프로덕션 환경에서의 성능 병목 지점을 실시간으로 파악합니다
@Aspect
@Component
public class PerformanceAspect {
@Around("@annotation(org.springframework.web.bind.annotation.RequestMapping)")
public Object measureExecutionTime(ProceedingJoinPoint joinPoint) throws Throwable {
long startTime = System.currentTimeMillis();
try {
Object result = joinPoint.proceed();
long executionTime = System.currentTimeMillis() - startTime;
// 🎯 1초 이상 걸린 요청만 로깅 (성능 이슈 조기 발견)
if (executionTime > 1000) {
log.warn("Slow request detected: {} took {}ms",
joinPoint.getSignature().toShortString(), executionTime);
}
return result;
} catch (Exception e) {
long executionTime = System.currentTimeMillis() - startTime;
log.error("Request failed: {} took {}ms, error: {}",
joinPoint.getSignature().toShortString(), executionTime, e.getMessage());
throw e;
}
}
}
무중단 운영을 위한 시스템 상태 모니터링 기능을 제공합니다
@RestController
@RequestMapping("/api/system")
public class SystemController {
@GetMapping("/health")
public ApiResponse<Map<String, Object>> health() {
Map<String, Object> healthStatus = new HashMap<>();
// 🎯 데이터베이스 연결 상태
healthStatus.put("database", checkDatabase());
// 🎯 Redis 연결 상태
healthStatus.put("redis", checkRedis());
// 🎯 시스템 리소스 상태
healthStatus.put("system", getSystemInfo());
return ApiResponse.of(HttpStatus.OK, "시스템 상태", healthStatus);
}
private Map<String, Object> getSystemInfo() {
Map<String, Object> systemInfo = new HashMap<>();
Runtime runtime = Runtime.getRuntime();
systemInfo.put("processors", runtime.availableProcessors());
systemInfo.put("totalMemory", runtime.totalMemory());
systemInfo.put("freeMemory", runtime.freeMemory());
systemInfo.put("usedMemory", runtime.totalMemory() - runtime.freeMemory());
return systemInfo;
}
}
마이크로서비스 전환을 고려한 설계를 적용했습니다.
// 🎯 도메인 이벤트 정의
public class MemberApprovedEvent extends MemberEvent {
public MemberApprovedEvent(Long memberId, String username) {
super(memberId, username);
}
}
// 🎯 비동기 이벤트 처리
@Component
public class MemberEventHandler {
@EventListener
@Async
public void handleMemberApproved(MemberApprovedEvent event) {
log.info("Member approved: {} (ID: {})", event.getUsername(), event.getMemberId());
// 🎯 여기서 추가 작업 수행:
// - 환영 이메일 발송
// - 외부 시스템 동기화
// - 통계 업데이트
}
}
Redis를 활용한 지능적 캐싱을 구현했습니다
@Configuration
@EnableCaching
public class CacheConfig {
@Bean
public CacheManager cacheManager(RedisConnectionFactory redisConnectionFactory) {
RedisCacheConfiguration config = RedisCacheConfiguration.defaultCacheConfig()
.entryTtl(Duration.ofMinutes(30)) // 30분 TTL
.serializeKeysWith(RedisSerializationContext.SerializationPair
.fromSerializer(new StringRedisSerializer()))
.serializeValuesWith(RedisSerializationContext.SerializationPair
.fromSerializer(new GenericJackson2JsonRedisSerializer()));
return RedisCacheManager.builder(redisConnectionFactory)
.cacheDefaults(config)
.build();
}
}
비즈니스 핵심 로직의 안정성을 보장하기 위한 테스트를 진행합니다.
@ExtendWith(MockitoExtension.class)
@DisplayName("회원 도메인 서비스 테스트")
class MemberDomainServiceTest {
@Test
@DisplayName("로그인 처리 - 승인되지 않은 계정")
void processLoginAttempt_NotApproved() {
// given
Member member = Member.builder()
.username("pendinguser")
.status(MemberStatus.PENDING) // 승인 대기 상태
.build();
given(memberRepository.findByUsername("pendinguser"))
.willReturn(Optional.of(member));
// when & then
assertThatThrownBy(() ->
memberDomainService.processLoginAttempt("pendinguser", "password"))
.isInstanceOf(BusinessException.class)
.extracting("errorCode")
.isEqualTo(ErrorCode.ACCOUNT_NOT_APPROVED);
}
}
FROM openjdk:17-jre-slim
WORKDIR /app
COPY build/libs/*.jar app.jar
# 🎯 JVM 성능 최적화
ENTRYPOINT ["java", "-jar", "app.jar", \
"-Xms512m", "-Xmx2g", \
"-XX:+UseG1GC", \
"-XX:MaxGCPauseMillis=200"]
EXPOSE 8080
# application-prod.yml
spring:
datasource:
hikari:
maximum-pool-size: 20
minimum-idle: 5
connection-timeout: 30000
jpa:
hibernate:
ddl-auto: validate # 🎯 운영에서는 절대 create-drop 금지
show-sql: false
data:
redis:
lettuce:
pool:
max-active: 10
max-idle: 10
logging:
level:
com.antock: WARN
root: ERROR
file:
name: logs/antock-member-system.log
max-size: 100MB
max-history: 30
// ❌ 단일 포인트 보안 (취약)
if (passwordEncoder.matches(password, member.getPassword())) {
return loginSuccess(member);
}
// ✅ 다층 보안 (안전)
if (member.isAccountLocked()) throw new BusinessException(ErrorCode.ACCOUNT_LOCKED);
if (!member.canLogin()) throw new BusinessException(ErrorCode.ACCOUNT_NOT_APPROVED);
if (!passwordEncoder.matches(password, member.getPassword())) {
handleFailedLogin(member);
throw new BusinessException(ErrorCode.INVALID_CREDENTIALS);
}
// ❌ boolean 플래그의 한계
private boolean isActive;
private boolean isApproved;
private boolean isSuspended;
// ✅ 명시적 상태 관리
@Enumerated(EnumType.STRING)
private MemberStatus status; // PENDING, APPROVED, REJECTED, SUSPENDED
// ❌ 서비스 레이어에 분산된 로직
public void approveMember(Long memberId) {
Member member = memberRepository.findById(memberId).orElseThrow();
if (member.getStatus() != MemberStatus.PENDING) {
throw new BusinessException("Invalid status");
}
member.setStatus(MemberStatus.APPROVED);
member.setApprovedAt(LocalDateTime.now());
}
// ✅ 도메인 객체에 캡슐화된 로직
public void approveMember(Long memberId, String approver) {
Member member = memberRepository.findById(memberId).orElseThrow();
member.approve(approver); // 비즈니스 로직은 도메인 객체가 담당
}
이 시스템을 도입한 후 얻은 실질적인 성과는 다음과 같습니다.
단순히 "로그인되면 되지"라는 생각으로는 현대의 복잡한 요구사항을 만족할 수 없습니다.
이 시스템의 진짜 가치는 다음과 같습니다.
특히 B2B 서비스나 엔터프라이즈 환경에서는 이런 수준의 시스템이 필수가 되었습니다.