junyeokk
Blog
Auth·2025. 04. 27

OAuth Provider 인터페이스 패턴

OAuth 소셜 로그인을 구현할 때, 처음에는 보통 하나의 서비스 클래스 안에 모든 로직을 넣는다. Google 로그인만 지원하면 문제가 없다. 그런데 GitHub를 추가하고, 나중에 Kakao나 Naver까지 붙이려고 하면 서비스 클래스가 if-else의 늪에 빠진다.

typescript
// ❌ 이런 코드가 만들어진다
class AuthService {
  getAuthUrl(provider: string) {
    if (provider === 'google') {
      // Google OAuth URL 생성 로직 30줄
    } else if (provider === 'github') {
      // GitHub OAuth URL 생성 로직 25줄
    } else if (provider === 'kakao') {
      // Kakao OAuth URL 생성 로직 20줄
    }
  }

  getTokens(provider: string, code: string) {
    if (provider === 'google') {
      // Google 토큰 교환 로직
    } else if (provider === 'github') {
      // ...
    }
  }

  getUserInfo(provider: string, token: string) {
    // 같은 패턴 반복...
  }
}

이 방식의 문제는 명확하다. 새 Provider를 추가할 때마다 모든 메서드를 수정해야 하고, 각 Provider의 로직이 하나의 클래스에 뒤섞여서 테스트도 어렵다. 이건 전형적인 OCP(Open-Closed Principle) 위반이다 — 확장에는 열려 있어야 하고 수정에는 닫혀 있어야 하는데, 매번 기존 코드를 수정해야 한다.

OAuth Provider 인터페이스 패턴은 이 문제를 다형성으로 해결한다. 각 Provider를 독립된 클래스로 분리하고, 공통 인터페이스를 통해 동일한 방식으로 다룬다.


핵심 구조

패턴의 구조는 세 가지 요소로 구성된다.

1. Provider 인터페이스 정의

모든 OAuth Provider가 반드시 구현해야 할 계약을 인터페이스로 정의한다.

typescript
// oauth-provider.interface.ts

export interface OAuthTokenResponse {
  access_token: string;
  refresh_token?: string;
  expires_in?: number;
  scope?: string;
  id_token?: string;
}

export interface UserInfo {
  id: string;
  email: string;
  name: string;
  picture?: string;
}

export interface OAuthProvider {
  getAuthUrl(): string;
  getTokens(code: string): Promise<OAuthTokenResponse>;
  getUserInfo(tokenResponse: OAuthTokenResponse): Promise<UserInfo>;
}

이 인터페이스가 정의하는 건 OAuth 인증의 핵심 3단계다.

  1. getAuthUrl() — 사용자를 리다이렉트할 인증 URL 생성
  2. getTokens() — Authorization Code를 Access Token으로 교환
  3. getUserInfo() — Access Token으로 사용자 정보 조회

모든 OAuth Provider는 이 세 단계를 거친다. Google이든 GitHub든 Kakao든 결국 같은 흐름이다. 다만 URL이 다르고, 요청 파라미터가 조금씩 다르고, 응답 형식이 다를 뿐이다.

2. Provider별 구현체

각 Provider는 인터페이스를 구현하는 독립된 클래스로 만든다.

typescript
// github-oauth.provider.ts
import * as querystring from 'node:querystring';
import axios from 'axios';
import { OAuthProvider, OAuthTokenResponse, UserInfo } from './oauth-provider.interface';

export class GithubOAuthProvider implements OAuthProvider {
  private readonly AUTH_URL = 'https://github.com/login/oauth/authorize';
  private readonly TOKEN_URL = 'https://github.com/login/oauth/access_token';
  private readonly USER_INFO_URL = 'https://api.github.com/user';

  getAuthUrl(): string {
    const state = Buffer.from(
      JSON.stringify({ provider: 'github' })
    ).toString('base64');

    const params = {
      redirect_uri: `${process.env.BASE_URL}/api/oauth/callback`,
      client_id: process.env.GITHUB_CLIENT_ID,
      scope: ['user:email', 'read:user'].join(','),
      state,
    };

    return `${this.AUTH_URL}?${querystring.stringify(params)}`;
  }

  async getTokens(code: string): Promise<OAuthTokenResponse> {
    const response = await axios.post(
      this.TOKEN_URL,
      querystring.stringify({
        code,
        client_id: process.env.GITHUB_CLIENT_ID,
        client_secret: process.env.GITHUB_CLIENT_SECRET,
      }),
      { headers: { Accept: 'application/json' } },
    );

    return response.data;
  }

  async getUserInfo(tokenResponse: OAuthTokenResponse): Promise<UserInfo> {
    const response = await axios.get(this.USER_INFO_URL, {
      headers: { Authorization: `Bearer ${tokenResponse.access_token}` },
    });

    const { id, email, name, avatar_url } = response.data;
    return { id, email, name, picture: avatar_url };
  }
}

GitHub은 avatar_url이라는 필드로 프로필 이미지를 주지만, 인터페이스에서는 picture로 통일한다. 이런 Provider별 차이를 각 구현체가 흡수하는 게 핵심이다.

typescript
// google-oauth.provider.ts
import * as querystring from 'node:querystring';
import axios from 'axios';
import { OAuthProvider, OAuthTokenResponse, UserInfo } from './oauth-provider.interface';

export class GoogleOAuthProvider implements OAuthProvider {
  private readonly AUTH_URL = 'https://accounts.google.com/o/oauth2/v2/auth';
  private readonly TOKEN_URL = 'https://oauth2.googleapis.com/token';
  private readonly USER_INFO_URL = 'https://www.googleapis.com/oauth2/v1/userinfo';

  getAuthUrl(): string {
    const state = Buffer.from(
      JSON.stringify({ provider: 'google' })
    ).toString('base64');

    const params = {
      redirect_uri: `${process.env.BASE_URL}/api/oauth/callback`,
      client_id: process.env.GOOGLE_CLIENT_ID,
      access_type: 'offline',
      response_type: 'code',
      prompt: 'consent',
      scope: ['email', 'profile'].join(' '),
      state,
    };

    return `${this.AUTH_URL}?${querystring.stringify(params)}`;
  }

  async getTokens(code: string): Promise<OAuthTokenResponse> {
    const response = await axios.post(
      this.TOKEN_URL,
      querystring.stringify({
        code,
        client_id: process.env.GOOGLE_CLIENT_ID,
        client_secret: process.env.GOOGLE_CLIENT_SECRET,
        redirect_uri: `${process.env.BASE_URL}/api/oauth/callback`,
        grant_type: 'authorization_code',
      }),
      { headers: { 'Content-Type': 'application/x-www-form-urlencoded' } },
    );

    return response.data;
  }

  async getUserInfo(tokenResponse: OAuthTokenResponse): Promise<UserInfo> {
    const response = await axios.get(
      `${this.USER_INFO_URL}?alt=json&access_token=${tokenResponse.access_token}`,
      { headers: { Authorization: `Bearer ${tokenResponse.id_token}` } },
    );

    return response.data; // Google은 이미 { id, email, name, picture } 형태
  }
}

Google과 GitHub을 비교하면 차이가 보인다.

항목GitHubGoogle
scope 구분자, (쉼표) (공백)
토큰 요청 시 redirect_uri불필요필수
토큰 요청 시 grant_type불필요authorization_code 필수
프로필 이미지 필드avatar_urlpicture
ID Token없음있음 (id_token)
prompt 파라미터없음consent (매번 동의 화면)

이런 차이들이 각 구현체 내부에 캡슐화되고, 서비스 계층에서는 인터페이스만 보기 때문에 이런 디테일을 몰라도 된다.

3. Provider Map (DI 기반 디스패치)

가장 핵심적인 부분이다. Provider 타입 문자열을 받아서 해당 구현체로 라우팅하는 메커니즘이 필요하다. NestJS에서는 커스텀 Provider를 useFactory로 등록해서 이를 깔끔하게 처리할 수 있다.

typescript
// app.module.ts (또는 해당 모듈)
@Module({
  providers: [
    GoogleOAuthProvider,
    GithubOAuthProvider,
    {
      provide: 'OAUTH_PROVIDERS',
      useFactory: (
        google: GoogleOAuthProvider,
        github: GithubOAuthProvider,
      ) => ({
        google,
        github,
      }),
      inject: [GoogleOAuthProvider, GithubOAuthProvider],
    },
  ],
})
export class AppModule {}

'OAUTH_PROVIDERS'라는 토큰으로 Record<string, OAuthProvider> 형태의 맵을 등록한다. useFactory를 사용하면 NestJS가 GoogleOAuthProviderGithubOAuthProvider를 먼저 인스턴스화하고, 그 인스턴스들을 factory 함수의 인자로 넘겨준다.

결과적으로 'OAUTH_PROVIDERS'를 주입받으면 이런 객체를 얻는다.

typescript
{
  google: GoogleOAuthProvider 인스턴스,
  github: GithubOAuthProvider 인스턴스,
}

서비스에서는 이 맵을 주입받아서 문자열 키로 Provider를 꺼내 쓴다.

typescript
@Injectable()
export class AuthService {
  constructor(
    @Inject('OAUTH_PROVIDERS')
    private readonly providers: Record<string, OAuthProvider>,
  ) {}

  getAuthUrl(providerType: string) {
    const provider = this.providers[providerType];
    if (!provider) {
      throw new BadRequestException('지원하지 않는 인증 제공자입니다.');
    }
    return provider.getAuthUrl();
  }

  async handleCallback(providerType: string, code: string) {
    const provider = this.providers[providerType];
    const tokens = await provider.getTokens(code);
    const userInfo = await provider.getUserInfo(tokens);
    // 사용자 저장/업데이트 로직...
    return userInfo;
  }
}

if-else가 완전히 사라졌다. this.providers[providerType]으로 해당 Provider를 꺼내서 메서드를 호출할 뿐이다.


새 Provider 추가 흐름

Kakao 로그인을 추가한다고 하면 이렇게 된다.

1단계: 구현체 작성

typescript
// kakao-oauth.provider.ts
@Injectable()
export class KakaoOAuthProvider implements OAuthProvider {
  private readonly AUTH_URL = 'https://kauth.kakao.com/oauth/authorize';
  private readonly TOKEN_URL = 'https://kauth.kakao.com/oauth/token';
  private readonly USER_INFO_URL = 'https://kapi.kakao.com/v2/user/me';

  getAuthUrl(): string {
    const params = {
      client_id: process.env.KAKAO_CLIENT_ID,
      redirect_uri: `${process.env.BASE_URL}/api/oauth/callback`,
      response_type: 'code',
      state: Buffer.from(JSON.stringify({ provider: 'kakao' })).toString('base64'),
    };
    return `${this.AUTH_URL}?${querystring.stringify(params)}`;
  }

  async getTokens(code: string): Promise<OAuthTokenResponse> {
    const response = await axios.post(this.TOKEN_URL, querystring.stringify({
      grant_type: 'authorization_code',
      client_id: process.env.KAKAO_CLIENT_ID,
      client_secret: process.env.KAKAO_CLIENT_SECRET,
      redirect_uri: `${process.env.BASE_URL}/api/oauth/callback`,
      code,
    }));
    return response.data;
  }

  async getUserInfo(tokenResponse: OAuthTokenResponse): Promise<UserInfo> {
    const response = await axios.get(this.USER_INFO_URL, {
      headers: { Authorization: `Bearer ${tokenResponse.access_token}` },
    });
    const { id, kakao_account } = response.data;
    return {
      id: String(id),
      email: kakao_account.email,
      name: kakao_account.profile.nickname,
      picture: kakao_account.profile.profile_image_url,
    };
  }
}

Kakao는 사용자 정보 응답 구조가 완전히 다르다. kakao_account 안에 profile이 중첩되어 있다. 하지만 구현체 내부에서 UserInfo 형태로 변환하기 때문에 서비스 계층은 전혀 영향을 받지 않는다.

2단계: 모듈에 등록

typescript
{
  provide: 'OAUTH_PROVIDERS',
  useFactory: (
    google: GoogleOAuthProvider,
    github: GithubOAuthProvider,
    kakao: KakaoOAuthProvider,  // 추가
  ) => ({
    google,
    github,
    kakao,  // 추가
  }),
  inject: [GoogleOAuthProvider, GithubOAuthProvider, KakaoOAuthProvider],
}

끝이다. AuthService는 한 줄도 수정하지 않는다. 이것이 OCP가 지켜진 설계의 결과다.


state 파라미터와 Provider 라우팅

OAuth 콜백은 보통 하나의 엔드포인트로 받는다 (/api/oauth/callback). 그런데 여러 Provider를 지원하면, 이 콜백이 어떤 Provider에서 온 건지 구분해야 한다.

state 파라미터가 이 역할을 한다. OAuth 스펙에서 state는 CSRF 방지용으로 설계됐지만, 추가 데이터를 실어 보내는 용도로도 활용할 수 있다.

typescript
// 인증 URL 생성 시 state에 provider 정보를 인코딩
const stateData = { provider: 'github' };
const state = Buffer.from(JSON.stringify(stateData)).toString('base64');
// → eyJwcm92aWRlciI6ImdpdGh1YiJ9

// 콜백에서 state를 디코딩하여 provider 타입 추출
const decoded = JSON.parse(Buffer.from(state, 'base64').toString());
// → { provider: 'github' }

콜백 핸들러에서는 이렇게 사용한다.

typescript
async handleCallback(code: string, state: string) {
  const stateData = JSON.parse(
    Buffer.from(state, 'base64').toString()
  );
  const providerType = stateData.provider; // 'google' | 'github' | 'kakao'

  const provider = this.providers[providerType];
  const tokens = await provider.getTokens(code);
  const userInfo = await provider.getUserInfo(tokens);
  // ...
}

state를 Base64로 인코딩하는 건 URL-safe하게 만들기 위해서다. JSON 문자열에는 {, " 같은 URL 특수문자가 포함되는데, Base64로 변환하면 안전하게 URL 파라미터로 전달할 수 있다.


DI Map vs Strategy Map 상수

Provider 맵을 구성하는 방법은 크게 두 가지다.

방법 1: DI 컨테이너의 useFactory (권장)

위에서 본 방식이다. NestJS의 DI 시스템이 Provider 인스턴스의 생명주기를 관리하고, 의존성 주입도 자동으로 처리한다.

typescript
{
  provide: 'OAUTH_PROVIDERS',
  useFactory: (google, github) => ({ google, github }),
  inject: [GoogleOAuthProvider, GithubOAuthProvider],
}

장점은 각 Provider가 NestJS의 DI 컨테이너가 관리하는 빈이 되므로, Logger나 Repository 같은 의존성을 자유롭게 주입받을 수 있다는 것이다.

방법 2: 단순 객체 맵

DI 프레임워크 없이도 같은 패턴을 쓸 수 있다.

typescript
const OAUTH_PROVIDERS: Record<string, OAuthProvider> = {
  google: new GoogleOAuthProvider(),
  github: new GithubOAuthProvider(),
};

function getAuthUrl(providerType: string): string {
  const provider = OAUTH_PROVIDERS[providerType];
  if (!provider) throw new Error('Unsupported provider');
  return provider.getAuthUrl();
}

간단하지만, Provider 내부에서 다른 서비스를 사용해야 할 때 직접 인스턴스를 만들어 넘겨야 하는 불편함이 있다.


이 패턴이 Strategy 패턴인 이유

GoF 디자인 패턴 중 Strategy 패턴과 정확히 일치한다.

Strategy 패턴 요소OAuth Provider 패턴 대응
Strategy 인터페이스OAuthProvider 인터페이스
ConcreteStrategyGoogleOAuthProvider, GithubOAuthProvider
ContextAuthService
Strategy 선택providers[providerType] (맵 기반 디스패치)

Strategy 패턴의 핵심은 알고리즘을 캡슐화하고 교체 가능하게 만드는 것이다. 여기서 "알고리즘"은 각 Provider별 OAuth 인증 흐름이고, 이를 인터페이스 뒤에 숨김으로써 서비스 계층은 구체적인 인증 흐름을 몰라도 된다.

전통적인 Strategy 패턴에서는 Context가 하나의 Strategy를 들고 있지만, OAuth 시나리오에서는 여러 Strategy를 맵으로 들고 런타임에 선택한다. 이건 Strategy + Registry(또는 Dispatch Map)의 조합이라고 볼 수 있다.


에러 처리 전략

각 Provider의 외부 API 호출은 실패할 수 있다. 네트워크 에러, 잘못된 클라이언트 ID, 만료된 Authorization Code 등 다양한 이유가 있다. 이런 외부 서비스 에러를 어떻게 처리할지도 인터페이스 설계에서 고려해야 한다.

typescript
// Provider 내부에서 외부 에러를 적절한 HTTP 예외로 변환
async getTokens(code: string): Promise<OAuthTokenResponse> {
  try {
    const response = await axios.post(this.TOKEN_URL, /* ... */);
    return response.data;
  } catch (error) {
    this.logger.error(`Failed to fetch token: ${error}`);
    throw new BadGatewayException(
      '현재 외부 서비스와의 연결에 실패했습니다.'
    );
  }
}

핵심 포인트:

  • 외부 API 에러 → BadGatewayException (502): 우리 서버가 아닌 외부 서비스의 문제이므로 502가 적절하다
  • 지원하지 않는 Provider → BadRequestException (400): 클라이언트가 잘못된 Provider 타입을 보낸 것이므로 400
  • 에러 로깅: Provider 내부에서 상세 에러를 로깅하고, 클라이언트에게는 일반적인 메시지만 반환

각 Provider가 자신의 에러를 처리하기 때문에, 서비스 계층에서 Provider별 에러 분기를 할 필요가 없다.


테스트 용이성

인터페이스 기반 설계의 가장 큰 실용적 이점은 테스트다. 서비스 로직을 테스트할 때 실제 Google이나 GitHub API를 호출할 수 없으니, Mock Provider를 만들어 주입하면 된다.

typescript
// 테스트용 Mock Provider
class MockOAuthProvider implements OAuthProvider {
  getAuthUrl(): string {
    return 'https://example.com/auth';
  }

  async getTokens(code: string): Promise<OAuthTokenResponse> {
    return {
      access_token: 'mock-access-token',
      refresh_token: 'mock-refresh-token',
    };
  }

  async getUserInfo(tokenResponse: OAuthTokenResponse): Promise<UserInfo> {
    return {
      id: '12345',
      email: 'test@example.com',
      name: 'Test User',
      picture: 'https://example.com/avatar.png',
    };
  }
}

// 테스트에서 사용
describe('AuthService', () => {
  let authService: AuthService;

  beforeEach(() => {
    const mockProviders = {
      google: new MockOAuthProvider(),
      github: new MockOAuthProvider(),
    };

    authService = new AuthService(mockProviders);
  });

  it('should return auth URL for valid provider', () => {
    const url = authService.getAuthUrl('google');
    expect(url).toBe('https://example.com/auth');
  });

  it('should throw for unsupported provider', () => {
    expect(() => authService.getAuthUrl('naver')).toThrow(BadRequestException);
  });
});

인터페이스가 없었다면 실제 Provider 클래스를 직접 Mock해야 하고, 내부 구현에 의존하는 깨지기 쉬운 테스트가 된다. 인터페이스 덕분에 계약만 맞추면 어떤 구현이든 끼워 넣을 수 있다.


정리

OAuth Provider 인터페이스 패턴의 핵심을 요약하면:

  1. 인터페이스로 계약 정의getAuthUrl(), getTokens(), getUserInfo() 세 메서드
  2. Provider별 독립 구현 — 각 Provider의 URL, 파라미터, 응답 차이를 내부에서 흡수
  3. 맵 기반 디스패치Record<string, OAuthProvider>로 문자열 키 → 구현체 매핑
  4. DI 활용 — 프레임워크의 DI 컨테이너로 구현체 생명주기 관리

이 패턴을 쓰면 새 Provider 추가가 "구현체 작성 + 맵에 등록" 두 단계로 끝나고, 기존 서비스 코드는 한 줄도 수정하지 않아도 된다. OAuth뿐 아니라 결제(Stripe/Toss/Kakao Pay), 알림(Email/SMS/Slack), 스토리지(S3/GCS/로컬) 등 여러 외부 서비스를 동일 인터페이스로 다뤄야 하는 상황이면 어디든 적용할 수 있다.


관련 문서