OAuth2 회원 인증 — Spring Boot SNS 3편

2026-05-04Spring Boot SNS 마이크로서비스 포트폴리오

Spring Boot SNS 포트폴리오 시리즈 3편. User Service의 회원가입(BCrypt 자동 솔트)·로그인·Access/Refresh 두 토큰 발급·SHA-256으로 RefreshToken 해싱 저장·로그아웃 블랙리스트·구독 관계·OAuth2(Google·GitHub) 소셜 로그인 흐름까지 한 글에 정리합니다.

📚 Spring Boot SNS 마이크로서비스 포트폴리오 · 3편 — User Service · OAuth2

이 글은 Spring Boot SNS 마이크로서비스 포트폴리오 시리즈의 3편입니다. 1편에서 큰 그림을, 2편에서 정문 보안실(API Gateway)을 봤다면, 3편에서는 회원 관리부 — User Service 한 곳을 줌인합니다. 회원가입에서 BCrypt로 비밀번호를 안전하게 저장하고, 로그인 후 Access·Refresh 두 종류의 토큰을 발급하고, RefreshToken은 SHA-256 해시로 DB에 박고, 로그아웃 시 jti를 블랙리스트에 등재하고, OAuth2(Google·GitHub)로 비밀번호 없이도 로그인되게 하는 — 회원 관리부의 하루 일과를 처음부터 끝까지 따라가 봅니다.

비유는 1·2편을 그대로 이어 가요 — 회사 인사부 한 곳에서 사원증 발급·재발급·분실 신고·외부 협력사 연동을 다 처리하는 그림. 신입 사원 등록(회원가입)·출근 카드 발급(로그인)·임시 출입증(Access Token)·장기 신원 보증서(Refresh Token)·외부사 사원증으로 일일 출근(OAuth2) 같은 식으로 모든 건이 인사부 한 곳에서 시작됩니다.

프로젝트 SNS는 이메일·비밀번호와 구글·깃허브 OAuth2 로그인, 게시글·댓글·좋아요·랭킹·구독·알림·한국어 검색까지 흔한 기능을 한 번씩 다 다뤄 보면서, 그 평범한 기능들을 마이크로서비스 패턴(Database-per-Service · API Gateway · Outbox + CDC · Redisson 분산 락)과 인프라(Kafka·Redis·Elasticsearch·LocalStack S3)로 어떻게 묶어 내는가를 직접 손으로 짜 보는 학습용 포트폴리오입니다.

왜 User Service가 처음엔 어렵게 느껴질까

처음 회원 관리 코드를 보면 막히는 지점이 두 가지예요.

첫째, 인증·인가의 추상도가 한 번에 두 단계 올라갑니다. "비밀번호를 평문으로 저장하면 안 됨" → "그래서 해시를 저장" → "그런데 같은 비밀번호가 매번 다른 해시여야 함" → "솔트를 붙여서 해싱" → "솔트는 어디에 저장하나" 같은 사슬이 한꺼번에 나타나거든요. BCrypt가 솔트까지 자동으로 처리해 준다는 사실 하나가 안 박히면 코드가 이상하게 보입니다.

둘째, JWT 발급 흐름에 토큰이 두 종류예요. Access·Refresh를 왜 둘로 나누는지, 만료 시간을 왜 한쪽은 짧고 한쪽은 긴지, RefreshToken은 왜 또 DB에 저장하는지 — 이게 한 번에 다 나타나면 머리가 복잡해집니다.

해결법은 단순해요. 먼저 비밀번호 한 줄의 생애 — 사용자가 입력한 평문이 어떻게 BCrypt 해시로 저장되고, 다시 어떻게 검증되는가 — 를 따라가고, 그다음 토큰 한 쌍의 생애 — 왜 Access·Refresh 두 종류인지, 어디서 발급되고 언제 어디서 검증되는지 — 를 한 번 그려 봅니다. 이 두 줄이 통과되면 User Service 코드는 그냥 "사용자 한 명의 일생을 따라가는 함수들"의 묶음이에요.

회원 관리부의 일과 — 회원가입부터 로그아웃까지

User Service의 핵심 API를 한 표로 정리하면 이렇게 됩니다.

MethodPath인증설명
POST/v1/users/register불필요회원가입
POST/v1/users/login불필요로그인
POST/v1/users/refresh불필요토큰 갱신
POST/v1/users/logout필요로그아웃 (블랙리스트 등재)
GET/v1/users/{id}필요사용자 정보 조회
POST/v1/users/{targetId}/subscribe필요구독
GET/v1/users/oauth2/google불필요OAuth2 진입점 (구글)
GET/internal/users/{userId}내부사용자 정보 (서비스 간)

/internal/** 경로가 따로 있는 게 눈에 띄어요. 이건 다른 마이크로서비스(Notification Service 등)가 사용자 정보를 조회할 때 쓰는 내부 API예요. 게이트웨이 라우팅 규칙에 없으니 외부에서 직접 못 부릅니다. 부서 간 통신만 허용하는 통로인 셈이죠.

회원가입 — BCrypt가 솔트까지 자동으로 처리

public TokenResponse register(RegisterRequest request) {
    // 1. 이메일 중복 확인
    if (userRepository.existsByEmail(request.getEmail())) {
        throw new IllegalArgumentException("이미 사용 중인 이메일입니다.");
    }

    // 2. 비밀번호 BCrypt 해싱
    User user = User.builder()
            .email(request.getEmail())
            .passwordHash(passwordEncoder.encode(request.getPassword()))  // BCrypt
            .nickname(request.getNickname())
            .provider(AuthProvider.LOCAL)
            .build();

    // 3. 저장 후 JWT 발급
    return issueTokens(userRepository.save(user));
}

핵심은 passwordEncoder.encode(...) 한 줄이에요. BCrypt는 자동으로 솔트(salt)를 생성해 해시 안에 함께 묻어 둡니다. 같은 비밀번호 "password123"을 두 번 등록해도 매번 다른 해시가 나와요 — 솔트가 다르기 때문에. 레인보우 테이블 공격(미리 계산된 해시 사전으로 비밀번호를 역추적)이 사실상 무력화됩니다.

검증할 때는 passwordEncoder.matches(평문, 저장된해시) 한 줄로 끝나요. BCrypt가 해시 안에 묻어 둔 솔트를 다시 꺼내서, 입력받은 평문에 같은 솔트로 해싱해 비교합니다. 솔트를 따로 저장하거나 관리할 필요가 없는 거죠.

비밀번호: "password123"
저장된 해시: "$2a$10$N9qo8uLOickgx2ZMRZoMye..." (솔트 + 해시 결과가 한 문자열에 다 들어 있음)
                ↑       ↑
              BCrypt   솔트(22자)
              파라미터

여기서 시험 함정이 하나 있어요. passwordHash 컬럼은 NULL을 허용해야 합니다. 이메일로 가입한 사람은 비밀번호 해시가 있지만, OAuth2(구글·깃허브)로 가입한 사람은 우리 쪽에 비밀번호가 없어요 — 그쪽 OAuth2 제공자가 인증을 대신해 주니까. 그래서 password_hash VARCHAR(255) 가 NULL 허용이고, provider 컬럼(LOCAL · GOOGLE · GITHUB)으로 어느 방식인지 구분합니다.

로그인 — User Enumeration 공격 방어

로그인 코드는 단순한데, 한 가지 보안 디테일이 숨어 있어요.

public TokenResponse login(LoginRequest request) {
    User user = userRepository.findByEmail(request.getEmail())
            .orElseThrow(() -> new IllegalArgumentException("이메일 또는 비밀번호가 올바르지 않습니다."));

    if (!passwordEncoder.matches(request.getPassword(), user.getPasswordHash())) {
        throw new IllegalArgumentException("이메일 또는 비밀번호가 올바르지 않습니다.");
    }

    return issueTokens(user);
}

여기서 정말 중요한 시험 함정 — 두 실패 메시지를 정확히 같은 문구로 통일합니다. "이메일이 존재하지 않습니다" / "비밀번호가 틀렸습니다" 처럼 친절하게 분리해 두면, 공격자가 이메일을 하나씩 던져 보면서 어느 이메일이 가입돼 있는지 확인할 수 있어요. 이게 User Enumeration 공격이에요. 가입자 명단이 새는 건 그 자체가 보안 사고이고, 가입자 이메일을 가지고 표적 피싱이 가능해집니다.

이메일이 없든, 이메일은 있는데 비밀번호가 틀렸든 — 사용자 입장에선 똑같이 "둘 중 뭔가가 잘못됐다" 라는 답만 받아야 합니다. UX 관점에서는 친절도가 살짝 떨어지지만, 보안이 더 무거운 가치예요.

🎯 한 줄 정리

로그인 실패 메시지는 "이메일 또는 비밀번호가 올바르지 않습니다" 한 가지로 통일. 친절하게 분리하면 User Enumeration 공격에 노출.

토큰 발급 — Access·Refresh 두 쌍 + SHA-256 저장

issueTokens() 가 User Service의 핵심 메서드예요. 회원가입·로그인·토큰 갱신 모두 이걸 호출합니다.

private TokenResponse issueTokens(User user) {
    // 기존 RefreshToken 삭제 (로그인할 때마다 새 토큰 발급)
    refreshTokenRepository.deleteByUserId(user.getId());

    String accessToken  = jwtProvider.generateAccessToken(user.getId(), user.getEmail());
    String refreshToken = jwtProvider.generateRefreshToken(user.getId());

    // RefreshToken을 SHA-256 해시로 DB에 저장
    RefreshToken tokenEntity = RefreshToken.builder()
            .userId(user.getId())
            .tokenHash(hashToken(refreshToken))   // SHA-256(refreshToken)
            .expiresAt(LocalDateTime.now().plusDays(7))
            .build();
    refreshTokenRepository.save(tokenEntity);

    return TokenResponse.builder()
            .accessToken(accessToken)
            .refreshToken(refreshToken)
            .userId(user.getId())
            .nickname(user.getNickname())
            .build();
}

토큰을 두 종류로 나누는 이유가 시험에 자주 나와요. 한 줄로 정리하면 — 권한이 큰 토큰일수록 짧게, 갱신용 토큰은 길게. 둘로 나누면 한 토큰이 노출돼도 피해를 최소화할 수 있거든요.

토큰만료포함 정보어디서 검증
Access Token15분jti, sub(userId), email게이트웨이 (매 요청마다)
Refresh Token7일userId만User Service /v1/users/refresh
// Access Token: 15분 만료, userId + email 포함
public String generateAccessToken(Long userId, String email) {
    long now = System.currentTimeMillis();
    return Jwts.builder()
            .id(UUID.randomUUID().toString())   // jti: 블랙리스트 키
            .subject(String.valueOf(userId))
            .claim("email", email)
            .issuedAt(new Date(now))
            .expiration(new Date(now + accessTokenExpiry))  // 15분
            .signWith(key)                       // HMAC-SHA256
            .compact();
}

// Refresh Token: 7일 만료, userId만 포함
public String generateRefreshToken(Long userId) { ... }

여기서 정말 중요한 시험 함정 — RefreshToken은 원문이 아니라 SHA-256 해시로 DB에 저장합니다. DB가 유출되더라도 RefreshToken 원문을 알 수 없게 하기 위함이에요. 검증 시에는 사용자가 보낸 토큰을 다시 SHA-256 해싱해 DB의 해시와 비교합니다.

사용자 발급 시점:
  refreshToken 원문 = "eyJhbGciOiJIUzI1NiJ9..."
  DB 저장: tokenHash = SHA-256("eyJhbGciOiJIUzI1NiJ9...")
                     = "9c1b3..."

토큰 갱신 시점:
  사용자가 보낸 refreshToken = "eyJhbGciOiJIUzI1NiJ9..."
  서버: SHA-256(보낸 토큰) → DB의 tokenHash와 비교

DB가 유출돼도 공격자는 해시만 가지고 원본 RefreshToken을 만들 수 없어요. BCrypt가 아니라 SHA-256인 이유는 — RefreshToken은 이미 충분히 긴 랜덤 값(JWT 서명 포함)이라 레인보우 테이블이 무의미하고, BCrypt의 의도적 느림이 매 요청마다 부담이 되기 때문이에요. 비밀번호(짧고 사람이 만든 값)에는 BCrypt, 긴 랜덤 토큰에는 SHA-256 — 이 트레이드오프가 자주 시험에 나옵니다.

또 한 가지 작은 디테일 — deleteByUserId(user.getId()) 한 줄로 로그인 시마다 기존 RefreshToken을 삭제합니다. 한 사용자당 RefreshToken이 항상 한 개만 있도록 보장해, 토큰 회수가 단순해져요.

로그아웃 — 2편의 블랙리스트와 연결

로그아웃 코드는 2편에서 한 번 본 것과 같아요. 핵심은 jti를 Redis 블랙리스트에 등재하는 것.

public void logout(String accessToken) {
    String jti = jwtProvider.getJti(accessToken);
    Date expiration = jwtProvider.getExpiration(accessToken);
    long ttl = expiration.getTime() - System.currentTimeMillis();

    if (ttl > 0) {
        redisTemplate.opsForValue().set(
            "session:blacklist:" + jti,
            "logout",
            ttl,
            TimeUnit.MILLISECONDS
        );
    }

    // RefreshToken도 DB에서 삭제 (이후 토큰 갱신 차단)
    refreshTokenRepository.deleteByUserId(currentUserId());
}

흐름은 이렇게 끝나요 — Access Token의 jti가 블랙리스트에 들어가면 게이트웨이가 다음 요청부터 거부, RefreshToken도 DB에서 지워지면 새 Access Token도 못 받음. 사용자가 다시 로그인할 때까지 사실상 모든 통로가 막힙니다.

구독 — 누가 누구를 따르는가

SNS의 핵심 기능 중 하나인 구독. 구조는 단순해요 — subscriber_id가 구독하는 사람, target_id가 구독 대상.

@PostMapping("/{targetId}/subscribe")
public ResponseEntity<Void> subscribe(@PathVariable Long targetId,
                                       @AuthenticationPrincipal CustomUserDetails userDetails) {
    subscriptionService.subscribe(userDetails.getUser().getId(), targetId);
    return ResponseEntity.status(HttpStatus.CREATED).build();
}

// 내부 서비스용 — 알림 부서가 호출
@GetMapping("/{userId}/subscribers")  // /internal/users/{userId}/subscribers
public ResponseEntity<List<SubscriberResponse>> getSubscribers(@PathVariable Long userId) {
    List<Long> subscriberIds = subscriptionRepository.findSubscriberIdsByTargetId(userId);
    List<SubscriberResponse> subscribers = userRepository.findAllById(subscriberIds).stream()
            .map(u -> SubscriberResponse.builder()
                    .userId(u.getId())
                    .nickname(u.getNickname())
                    .build())
            .toList();
    return ResponseEntity.ok(subscribers);
}

테이블에 UNIQUE(subscriber_id, target_id) 제약이 걸려 있어 중복 구독이 DB 차원에서 차단됩니다. 같은 사람을 두 번 구독해 봐도 두 번째 INSERT가 실패하니, 따로 코드에서 체크 안 해도 안전해요.

여기서 시험 함정이 하나 있어요. /internal/users/{userId}/subscribers는 외부에서 절대 부를 수 없어야 합니다. 누가 누구를 구독하는지가 외부에 노출되면 SNS의 사회적 그래프가 통째로 새는 거예요. 게이트웨이의 라우팅 규칙에 이 경로가 없으니 자연히 외부에서 못 부르고, Spring Security 설정에서 /internal/**permitAll()로 열려 있지만 그건 "내부 서비스끼리는 토큰 없이도 부른다"는 의미일 뿐, 외부 노출은 부서 포트(8081)를 외부에 안 여는 걸로 막습니다.

OAuth2 — 구글·깃허브로 비밀번호 없이 로그인

소셜 로그인 흐름은 한 번 그림으로 보면 외우기 쉬워요.

1. 브라우저 → /v1/users/oauth2/google
                  ↓
2. Spring Security OAuth2가 구글 로그인 페이지로 리다이렉트
                  ↓
3. 사용자가 구글에서 로그인 + 권한 승인
                  ↓
4. 구글이 우리 콜백 URL로 리다이렉트 (인가 코드 첨부)
                  ↓
5. CustomOAuth2UserService — 인가 코드로 구글 API 호출 → 사용자 정보 받음
                  ↓
6. DB에서 그 사용자 찾기, 없으면 자동 생성 (provider=GOOGLE)
                  ↓
7. OAuth2SuccessHandler — JWT 발급
                  ↓
8. 프론트엔드로 리다이렉트 (토큰을 쿼리스트링에 첨부)
   → http://localhost:3000/oauth2/callback?token=eyJhbGciOiJIUzI1NiJ9...

핵심 클래스 두 개 — CustomOAuth2UserService가 구글에서 받은 사용자 정보로 DB에 사용자를 찾거나 새로 만들고, OAuth2SuccessHandler가 그 사용자에게 JWT를 발급해 프론트엔드로 리다이렉트합니다. Spring Security가 콜백·세션·redirect_uri 검증 같은 잡일을 다 알아서 해 주니, 우리가 작성하는 코드는 이 두 클래스뿐이에요.

여기서 정말 중요한 시험 함정 — OAuth2 사용자도 우리 DB의 users 테이블에 같이 저장합니다. provider 컬럼(LOCAL · GOOGLE · GITHUB)으로 구분하고, password_hash는 NULL. 이렇게 묶어 두면 게시글·구독·알림 같은 다른 기능에서 "OAuth2 사용자냐 일반 사용자냐"를 신경 안 써도 돼요. 같은 사용자 ID 하나로 통일됩니다.

또 한 가지 — 같은 이메일로 LOCAL과 GOOGLE이 동시에 가입되는 케이스를 어떻게 처리하느냐가 설계 포인트예요. 이 프로젝트는 단순화를 위해 첫 가입 방식이 유지되도록 했는데, 운영 서비스에서는 "이미 같은 이메일로 가입돼 있어요. 로그인 후 연동하시겠어요?" 같은 흐름이 더 안전해요.

// 공개 경로에 OAuth2 콜백 포함
.requestMatchers(
    "/v1/users/register", "/v1/users/login", "/v1/users/refresh",
    "/v1/users/oauth2/**", "/login/oauth2/**",   // ← OAuth2
    "/internal/**"                                // 서비스 간 통신
).permitAll()

/v1/users/oauth2/**/login/oauth2/** 두 경로 모두 공개로 열려야 OAuth2 흐름이 끊기지 않아요. 하나만 빠뜨리면 콜백에서 401이 떨어져 로그인이 실패합니다.

🎯 한 줄 정리

OAuth2 = Spring Security가 콜백·세션 처리, 우리는 CustomOAuth2UserService(사용자 매핑)와 OAuth2SuccessHandler(JWT 발급) 두 클래스만 짠다. provider 컬럼으로 LOCAL·GOOGLE·GITHUB 구분.

시리즈 다음 편 — Post Service 줌인

여기까지가 3편입니다. 회원 관리부의 일과 — 회원가입·로그인·토큰 두 쌍·로그아웃·구독·OAuth2 — 를 코드 한 줄씩 따라가며 봤어요. BCrypt 자동 솔트, User Enumeration 방어, RefreshToken SHA-256 해시, OAuth2 7단계 같은 디테일이 자주 시험에 나오는 부분이에요.

4편에서는 Post Service를 줌인합니다. 게시글 생성에 Redisson 분산 락이 왜 필요한지, 동시에 두 좋아요 요청이 들어왔을 때 DB UNIQUE 제약을 어떻게 활용하는지, 캐시·랭킹·조회수가 트랜잭션 커밋 후에야 갱신돼야 하는 이유 — 콘텐츠부 일과의 흥미로운 부분들을 한 줄씩 풀어 갑니다.

공식 문서: Spring Security 공식 가이드Spring Security OAuth2 가이드에 이 글의 코드가 어디서 왔는지가 자세히 나와 있어요.

시리즈 다른 편

시험 직전 한 번 더 — 인증·OAuth2 함정 압축 노트

  • BCrypt = 해싱 + 자동 솔트 → 같은 비밀번호도 매번 다른 해시
  • BCrypt 검증 = passwordEncoder.matches(평문, 해시) 한 줄
  • BCrypt 해시 형식 = $2a$10$<22자솔트><31자해시>
  • password_hash 컬럼 NULL 허용 — OAuth2 사용자는 비밀번호 없음
  • provider 컬럼 = LOCAL · GOOGLE · GITHUB 구분
  • 로그인 실패 메시지 통일 — "이메일 또는 비밀번호가 올바르지 않습니다" 한 가지
  • 분리된 메시지 = User Enumeration 공격 노출 (가입자 명단 누출)
  • Access Token 15분, Refresh Token 7일
  • Access Token 클레임 = jti(UUID) · sub(userId) · email · iat · exp
  • Refresh Token 클레임 = userId만 (claim 최소화)
  • RefreshToken은 SHA-256 해시로 DB 저장 (BCrypt 아님 — 이미 긴 랜덤값)
  • BCrypt vs SHA-256 = 비밀번호(짧음·사람이 만듦) BCrypt / 토큰(랜덤·이미 김) SHA-256
  • deleteByUserId() 로 로그인마다 기존 RefreshToken 삭제 — 한 사용자당 1개만 유지
  • 로그아웃 = jti 블랙리스트 등재 + RefreshToken DB 삭제 (양쪽 모두 차단)
  • 구독 = subscriptions(subscriber_id, target_id) + UNIQUE 제약
  • 중복 구독 차단 = DB UNIQUE 제약으로 자동 (코드 체크 불필요)
  • /internal/users/{userId}/subscribers 외부 노출 금지 — 사회적 그래프 누출 방지
  • Spring Security 설정 = CSRF disable, SessionCreationPolicy.STATELESS
  • /internal/** = permitAll() (서비스 간 토큰 없이 통신) + 게이트웨이 라우팅에서 제외
  • OAuth2 흐름 8단계 = 진입점 → 구글 리다이렉트 → 사용자 동의 → 콜백 → CustomOAuth2UserService → DB 매핑 → SuccessHandler → 프론트 리다이렉트
  • OAuth2 핵심 클래스 두 개 = CustomOAuth2UserService(사용자 매핑) + OAuth2SuccessHandler(JWT 발급)
  • 공개 경로 = /v1/users/oauth2/** + /login/oauth2/** 둘 다 필요
  • OAuth2 사용자도 같은 users 테이블에 저장 — provider 로 구분
  • 같은 이메일 LOCAL + GOOGLE 충돌 = 운영 서비스는 "기존 계정 연동" 흐름이 안전
  • 토큰 갱신 = /v1/users/refresh 공개 경로에서 SHA-256 비교 후 새 Access·Refresh 두 쌍 재발급

다음 글(4편)에서는 Post Service의 게시글 생성 분산 락 + 좋아요 동시성 + 캐시·랭킹의 트랜잭션 일관성 흐름을 코드 한 줄씩 따라가며 풀어 갑니다.

※ 이 포스팅은 쿠팡 파트너스 활동의 일환으로, 이에 따른 일정액의 수수료를 제공받습니다.

답글 남기기

error: Content is protected !!