본문 바로가기

Backend/Security & Auth

[Security & Auth] Java/Spring으로 OAuth2 Client 기반 SSO 구현 설계

🔔 본 글은 Spring Boot를 활용한 SSO 구현 코드를 다룹니다. 만약 SSO의 기본 개념 및 쿠팡의 실제 인증 URL 파라미터 분석 과정이 궁금하시다면 1편 개념 편을 먼저 읽고 오시는 것을 추천합니다!

SSO(Single Sign-On) 개념 완벽 정리 및 기업 실무 사례 분석 보러가기

 

목차
1. Spring Boot OAuth2 Client의 기본 규칙과 코드 연결 원리
2. 설정
3. Java SecurityConfig 구현
4. 1:N 회원 및 로그인 수단 매핑 구조 설계
5. 이 구조를 채택하는 이유와 실무적 고려사항

 

대규모 회원 서비스를 운영하는 기업에서는 서비스가 늘어날 때마다 독립된 인증 시스템을 구축하지 않는다. 하나의 중앙 인증 서버를 통해 통합 로그인 환경을 제공하는 SSO(Single Sing-On) 체계를 구축하는 것이 표준이다. 특히 대규모 트래픽을 견디며 수많은 패밀리 서비스와 외부 채널을 연계해야하는 환경에서는 안정적이고 확장성 있는 OAuth2 기반 인증 구조 설계 능력이 백엔드 개발자에게 필수적이다.

이번 글에서는 Spring Security 6.x를 기반으로 OAuth2 Client SSO를 구현할 때, 스프링이 자동 처리해 주는 영역과 코드가 연결되는 원리, 그리고 이를 뒷받침하는 확장성 있는 DB 설계까지 정리해 볼것이다.

 

1. Spring Boot OAuth2 Client의 기본 규칙과 코드 연결 원리

Spring Boot OAuth2 Client의 가장 큰 장점은 추상화이다. 개발자가 직접 인가 코드(Authorization Code)를 받고 이를 다시 Access Token으로 교환하기 위해 복잡한 HTTP 통신 코드를 일일이 짤 필요가 없다.

 

🔄 인가 코드 흐름이 코드로 연결되는 매커니즘

네트워크 상에서 데이터가 오가는 흐름(Authorization Code Grant)은 스프링 시큐리티 내부의 전용 문지기 필터와 정확히 매핑된다.

인가 코드 흐름 매커니즘 시퀀스 다이어그램

 

Spring Security는 OAuth2LoginAuthenticationFilter를 필두로 위 흐름을 알아서 처리한다.  이때, 백엔드 개발자가 구현해야하는 것은 딱 3가지로 압축된다.

  • 첫번째: 인프라 정보 명시 (ClientRegistration)
    • 인증 서버의 주소(URI)와 클라이언트 자격증명(ID/Secret)을 스프링에게 알려주는 작업이다. (application.yml)
  • 두번째: 보안 필터 체인 구성 (SecurityFilterChain)
    • 어떤 요청을 보호하고, 어떤 인증 방식을 채택할 지 HTTP 요청 흐름을 제어하는 설계이다. (SecurityConfig.java)
  • 세번째: 후속 비즈니스 로직 구현 (OAuth2UserService)
    • 인증 서버로부터 사용자 정보를 성공적으로 가져온 뒤, 우리 서비스의 DB와 연동(회원가입/로그인/권한 부여)하는 핵심 도메인 로직이다.

2. applicatin.yml 환경 설정 인프라 설계

첫 번째인 인프라 설정이다. 범용적인 기업형 SSO 시스템을 모킹(Mocking)한 mega-sso 프로바이더 예시로, Spring Boot가 내부적으로 ClientRegistration 객체를 빌드할 수 있도록 표준 스펙에 맞춰 작성했다.

spring:
  security:
    oauth2:
      client:
        # 1. registration: 내 프로그램이 외부 인증 서버에 접근하기 위한 로그인 자격증명 정보
        registration:
          mega-sso: # 우리가 지정한 인증 서버의 고유 식별자
            client-id: ${SSO_CLIENT_ID}         # 인증 서버가 내 프로그램에 발급해 준 ID (환경변수 처리)
            client-secret: ${SSO_CLIENT_SECRET} # 인증 서버가 내 프로그램에 발급해 준 비밀번호
            client-name: Mega Integrated SSO   # 로그인 화면에 표시될 서비스의 이름
            authorization-grant-type: authorization_code # 표준 인가 코드 방식을 준수
            # 흐름도 5번에 해당: 인증 성공 후 사용자가 돌아올 콜백 주소 (스프링이 내부 필터로 자동 매핑)
            redirect-uri: "{baseUrl}/login/oauth2/code/{registrationId}" 
            scope:
              - openid  # OIDC 표준 신원 보증서(ID Token)를 요청하여 유저 고유 키를 확보
              - profile # 사용자의 프로필 정보
              - email   # 사용자의 이메일 주소
            client-authentication-method: client_secret_basic # 인증 서버에 토큰을 요청할 때 HTTP Basic 방식을 사용

        # 2. provider: 외부 인증 서버가 도대체 인터넷 어디에 살고 있는지 주소를 알려주는 설정
        provider:
          mega-sso: 
            authorization-uri: https://mega-anchor.net # 흐름도 1, 2번의 로그인 페이지 주소
            token-uri: https://mega-anchor.net             # 흐름도 6번의 토큰 교환 주소
            user-info-uri: https://mega-anchor.net       # 토큰을 제시하고 최종 사용자 정보를 받아올 주소
            user-name-attribute: sub # OIDC 표준에 따라 사용자를 구별하는 불변의 고유 식별자(Subject)를 대장 키로 지정

 

3. Java 기반 SecurityConfig 필터 구현 설계

두번째, 세번째 엮어내는 단계이다.

Spring Security 6.x의 표준인 람다 DSL(Lambda DSL) 형식을 사용하여 가시성을 높이고, 인증 성공 후의 후속 조치를 담당할 커스텀 서비스를 연동했다.

package com.mega.anchor.sso.config;

import com.mega.anchor.sso.service.CustomOAuth2UserService;
import lombok.RequiredArgsConstructor;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer;
import org.springframework.security.web.SecurityFilterChain;

@Configuration // 스프링에게 "이 클래스는 보안 설정을 담당하는 방이야"라고 알려주는 표지판
@EnableWebSecurity // "스프링 시큐리티의 웹 보안 기능을 활성화하겠다"는 선언
@RequiredArgsConstructor // 자바 문법: 아래의 customOAuth2UserService 부품을 자동으로 조립(생성자 주입)
public class SecurityConfig {

    // [퍼즐 3] 인증 완료 후, 유저 데이터를 우리 DB에 가입시키거나 로그인 처리하는 커스텀 비즈니스 로직 부품
    private final CustomOAuth2UserService customOAuth2UserService;

    @Bean // "내가 만든 이 securityFilterChain 보안 필터 부품을 상자에 등록해줘"라는 뜻
    public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
        http
            // Stateless(JWT/Redis 세션) 아키텍처를 지향하므로 CSRF 보안 기능은 비활성화
            .csrf(AbstractHttpConfigurer::disable)
            
            // HTTP 요청에 대한 '문지기(권한) 규칙'을 정합니다.
            .authorizeHttpRequests(auth -> auth
                // 메인, 로그인 관련 주소, 에러 페이지, 정적 리소스(CSS/JS)들은 로그인 없이 패스!
                .requestMatchers("/", "/login/**", "/error", "/css/**", "/js/**").permitAll()
                // 그 외 모든 마이페이지나 대시보드 요청은 반드시 "로그인(인증)"을 해야만 접근 가능!
                .anyRequest().authenticated()
            )
            
            // [퍼즐 2] OAuth2 로그인 기능을 활성화하고 문지기 세부 규칙을 세팅합니다.
            .oauth2Login(oauth2 -> oauth2
                .loginPage("/login") // 내 프로그램의 커스텀 로그인 페이지 주소
                .userInfoEndpoint(userInfo -> userInfo
                    // 흐름도 8번에 해당: 유저 정보를 받아온 직후, 미리 준비한 '퍼즐 3'로 데이터를 넘겨서 
                    // 회원가입을 시키거나 DB를 업데이트하라고 명령하는 핵심 연결 고리입니다.
                    .userService(customOAuth2UserService) 
                )
                .defaultSuccessUrl("/dashboard", true) // 모든 로그인 과정이 성공하면 이동할 메인 화면
            );

        return http.build();
    }
}

 

4. 1:N 회원 및 로그인 수단 매핑 구조 설계

세번째 CustomOAuth2UserService 내부에서 비즈니스 로직을 온전하게 수행하려면 데이터베이스 구조가 이를 받쳐주어야 한다. 초보자는 보통 하나의 회원 테이블에 모든 소셜 ID를 컬럼으로 추가하지만, 대규모 시스템에서는 계정 통합(Account Linking)과 확장성을 위해 테이블을 1:N 구조로 분리 설계한다.

 

📌1:N 회원 및 로그인 수단 매핑 구조도

회원 테이블과 로그인 수단 테이블의 관계 ERD Diagram

  • users (기본 테이블): 어떤 경로로 가입했든 우리 서비스가 관리하는 유저의 고유한 논리적 식별자(id)와 마케팅 정보 등을 관리한다.
  • user_providers (연동 테이블): 사용자가 어떤 로그인 방식을 사용하는지 저장한다. provider_user_id 컬럼에 application.yml에서 지정한 OIDC의 고유 키인 sub 값이 저장된다.

5. 이 구조를 채택하는 이유와 실무적 고려사항

스프링이 인증 흐름을 대신 해준다고 해서 개발자가 고민할 영역이 사라지는 것은 아니다. 대규모 회원 관리를 담당하는 백엔드 개발자라면 세번째( OAuth2UserService )을 구현할 때 반드시 다음과 같은 실무적 트레이드오프를 고려해야 한다.

 

1) Account Linking (기존 계정과의 통합 전략)

이미 연동 전부터 자체 DB에 가입되어 있던 일반 유저가 SSO를 통해 처음 로그인할 때 어떻게 처리할 것인가의 문제이다.

  • 고민 포인트: 이메일이 같다고 해서 무조건 계정을 자동 연동하면 보안상 위험할 수 있다. (예: 타인 이메일 도용 계정)
  • 설계 방향
    • 이미 기존에 일반 이메일로 가입한 유저가 SSO 버튼을 통해 처음 진입할 때, 이메일이 같다는 이유만으로 계정을 자동 연동(Auto Linking)하면 도용 위험이 있다.
    • 따라서 CustomOAuth2UserService에서는 먼저 users에 이메일이 있는지 찾고, 존재한다면 최초 1회 추가 본인인증(또는 기존 비밀번호 검증)을 거친 도록 유도해야한다. 검증이 완료된 후 user_providers에 새로운 행(MEGA, sub_xxx)을 추가해 주는 방식으로 다대일(N:1) 계정 연동을 안전하게 처리해야 무결성이 깨지지 않는다.

2) 가입/로그인 시점의 쓰기(Write) 트래픽 최적화와 DB Lock 방지

마케팅 이벤트나 패밀리 앱 통합 직후에는 순간적으로 수만 명의 유저가 SSO 인증을 통해 동시에 유입될 수 있다.

  • 고민 포인트: 사용자가 로그인할 때마다 OAuth2UserService 내에서 매번 DB에 UPDATE(최근 로그인 시간, 프로필 이미지 동기화 등)를 수행하거나 과도한 SELECT를 날리면 회원 DB에 심각한 병목(Lock)이 생긴다. 대형 이벤트 시 순간적으로 수만 명이 동시에 로그인을 시도하면, 로그인 시점마다 last_login_at을 UPDATE 하느라 DB에 행 잠금(Row Lock)이 걸려 시스템이 마비될 수 있다.
  • 설계 방향
    • 이를 방지하기 위해 CustomOAuth2UserService에서는 필수적인 회원 생성 및 조회만 수행하는 Just-In-Time(JIT) 프로비저닝 전략을 고수해야한다.
    • 로그인 이력 적재와 같은 부가적인 쓰기 작업은 Redis 같은 캐시를 활용하거나 비동기 이벤트(Spring Event 등)로 분리하여 회원 Core DB의 부하를 최소화하는 아키텍쳐를 고민해야 한다.
    •  

✍ 글을 마치며

Spring Security의 OAuth2 Client는 복잡한 인증 프로토콜을 설정 몇 줄과 프레임워크 기능만으로 끝낼 수 있게 돕는다. 하지만 대규모 트래픽과 안정성이 최우선인 기업형 회원 시스템을 설계할 때는 "에 대한 고민이 백엔드 개발자의 진짜 역량임을 배울 수 있었다. "프레임워크 뒤에서 일어나는 데이터의 흐름을 통제하고, 다대일 테이블 매핑을 통해 데이터 무결성을 유지하며, DB 부하를 어떻게 최소화할 것인가"에 대한 고민이 백엔드 개발자의 진짜 역량임을 배울 수 있었다.