Github 기여도 분석기(2)

주순태·2025년 3월 7일

Mini_Project

목록 보기
2/3
post-thumbnail

Contribase에 GitHub OAuth 도입하기

시작하기에 앞서 기여도 분석기의 프로젝트명은 Contribase로 결정하였습니다!
기여하다라는 contribution와 기여를 바탕으로(base) 분석하겠다는 의미를 담아 Contribase로 명명하게 되었습니다.

Contribase 깃허브 링크 ✔️

먼저 이번글에는 NextJS에 별도의 백엔드 서버없이 Github Oauth를 도입한 과정을 작성해보겠습니다.


1. GitHub OAuth 인증이 필요한 이유

가장 먼저 Contribase는 이용자가 Github에 커밋한 데이터를 기반으로 분석을 진행하기 때문에 우선적으로 Github 소셜로그인 기능을 구현하지 않는다면 개발이 진행되지 않기 때문에 가장 먼저 개발한 기능입니다.

Github OAuth를 도입하게 되면 우리프로젝트가 얻게되는 장점을 알아보자면,

  • 보안성: 사용자 비밀번호를 직접 저장하지 않고 토큰 기반 인증 (사용자 정보를 직접 저장하지 않아 보안 위험 감소)
  • 범위 제한: 필요한 권한만 요청하여 최소 권한 원칙 준수 (사용자의 모든 정보가 아닌 필요한 정보만 접근)
  • 사용자 경험: 익숙한 GitHub 로그인 흐름으로 신뢰성 제공 (사용자들이 이미 알고 있는 GitHub 로그인 방식 활용)
  • 인증 유지: 세션 관리와 토큰 갱신의 자동화 (사용자가 매번 로그인할 필요 없음)

이러한 장점이 있습니다.


2. 환경 변수 설정하기

가장 먼저 해야 할 일은 환경 변수를 설정하는 것입니다. 프로젝트 루트에 .env.local 파일을 만들고 다음 내용을 입력합니다.

GITHUB_ID=깃허브_애플리케이션_ID # GitHub에서 발급받은 OAuth App의 Client ID
GITHUB_SECRET=깃허브_애플리케이션_시크릿 # GitHub에서 발급받은 Client Secret
NEXTAUTH_URL=애플리케이션_URL # 개발 중이라면 http://localhost:3000
NEXTAUTH_SECRET=시크릿_키 # 보안을 위한 임의의 복잡한 문자열
[NEXTAUTH_SECRET?]
NEXTAUTH_SECRET은 Next-Auth 라이브러리에서 사용하는 핵심 보안 키로, 다음과 같은 중요한 용도로 사용됩니다.
JWT 토큰 서명 및 검증:
사용자 인증 후 생성되는 JWT(JSON Web Token)의 서명에 사용됩니다.
이 서명을 통해 토큰이 변조되지 않았음을 확인할 수 있습니다.
쿠키 암호화:
세션 정보가 포함된 쿠키를 암호화하는 데 사용됩니다.
CSRF 보호:
Cross-Site Request Forgery 공격을 방지하기 위한 토큰 생성에 활용됩니다.
세션 보안:
전반적인 인증 세션의 보안을 강화하는 역할을 합니다.
이 키는 아무도 알 수 없는 복잡한 문자열로 설정해야 하며, 프로덕션 환경에서는 절대 노출되어서는 안 됩니다.
개발 환경에서는 간단한 문자열을 사용할 수 있지만, 프로덕션에서는 최소 32자 이상의 랜덤한 문자열
(예: openssl rand -base64 32로 생성)을 사용하는 것이 권장됩니다.
만약 이 키가 노출되면 공격자가 유효한 인증 토큰을 만들어 사용자를 가장할 수 있으므로,
환경 변수나 비밀 관리 서비스를 통해 안전하게 보관해야 합니다.

OAuth Apps key 발급에 관한 포스트는 따로 올리도록 하겠습니다!
Github OAuth Apps 등록 포스트


3. Next-Auth 타입 정의 생성

Next-Auth를 사용하기 위해 먼저 타입 정의 파일을 만들어줍니다. src/types/next-auth.d.ts 파일을 생성합니다.

// src/types/next-auth.d.ts
import NextAuth from "next-auth"

declare module "next-auth" {
  interface Session {
    accessToken?: string
  }
}

declare module "next-auth/jwt" {
  interface JWT {
    accessToken?: string
  }
}

이 파일은 세션과 JWT 객체에 GitHub 액세스 토큰 타입을 추가하는 역할을 합니다.
이렇게 하면 TypeScript 타입 검사가 제대로 작동하게 됩니다.


4. Next-Auth 설정 파일 작성

이제 Next-Auth 설정 파일을 작성합니다. src/lib/auth.ts 파일을 만들어 다음 내용을 입력합니다.

// src/lib/auth.ts
import { NextAuthOptions } from 'next-auth'
import GithubProvider from 'next-auth/providers/github'

export const authOptions: NextAuthOptions = {
  providers: [
    GithubProvider({
      clientId: process.env.GITHUB_CLIENT_ID as string,
      clientSecret: process.env.GITHUB_CLIENT_SECRET as string,
      authorization: {
        params: {
          scope: 'read:user user:email repo'
        }
      }
    })
  ],
  callbacks: {
    async jwt({ token, account }) {
      // 액세스 토큰을 JWT에 포함
      if (account) {
        token.accessToken = account.access_token
      }
      return token
    },
    async session({ session, token }: { session: any, token: any }) {
      // 세션에 액세스 토큰 추가
      session.accessToken = token.accessToken
      return session
    }
  },
  pages: {
    signIn: '/auth/github',
    error: '/auth/error',
    signOut: '/',
    newUser: '/dashboard'
  },
  session: {
    strategy: 'jwt',
    maxAge: 30 * 24 * 60 * 60, // 30일 (초 단위)
  },
  // 환경에 따른 URL 설정
  useSecureCookies: process.env.NODE_ENV === 'production',
  cookies: {
    sessionToken: {
      name: `${process.env.NODE_ENV === 'production' ? '__Secure-' : ''}next-auth.session-token`,
      options: {
        httpOnly: true,
        sameSite: 'lax',
        path: '/',
        secure: process.env.NODE_ENV === 'production',
      },
    },
  },
}

이 파일에서는 GitHub 인증 공급자를 설정하고, JWT 토큰과 세션에 액세스 토큰을 포함시키는 콜백을 정의합니다.
또한 사용자 지정 페이지 경로와 세션 전략, 쿠키 설정 등도 정의합니다.


5. Next-Auth API 라우트 생성

이제 Next.js 앱 라우터를 사용해 인증 API 엔드포인트를 생성합니다. src/app/api/auth/[...nextauth]/route.ts 파일을 만들고 다음 내용을 입력합니다.

// src/app/api/auth/[...nextauth]/route.ts
import NextAuth from 'next-auth'
import GithubProvider from 'next-auth/providers/github'
import { OAuthConfig } from 'next-auth/providers'

// 환경에 따른 URL 설정
const baseUrl = process.env.VERCEL_URL 
  ? `https://${process.env.VERCEL_URL}` 
  : process.env.NEXTAUTH_URL || 'http://localhost:3000';

const handler = NextAuth({
  providers: [
    GithubProvider({
      clientId: process.env.GITHUB_ID || '',
      clientSecret: process.env.GITHUB_SECRET || '',
      // 동적으로 scope를 처리할 수 있도록 설정
      authorization: {
        url: "https://github.com/login/oauth/authorize",
        params: {
          // 기본 스코프는 사용자 정보와 이메일, 저장소 읽기 권한만 포함
          scope: 'read:user user:email repo'
        }
      },
    }),
  ],
  callbacks: {
    async jwt({ token, account }) {
      // 사용자가 로그인하면 GitHub 액세스 토큰을 JWT에 저장
      if (account) {
        token.accessToken = account.access_token
      }
      return token
    },
    async session({ session, token }) {
      // 세션에 액세스 토큰 추가
      session.accessToken = token.accessToken
      return session
    },
    async redirect({ url, baseUrl }) {
      // state 매개변수에서 callbackUrl 추출 시도
      try {
        // 상대 경로인 경우 baseUrl과 결합
        const fullUrl = url.startsWith('/') ? `${baseUrl}${url}` : url;
        const urlObj = new URL(fullUrl);
        const state = urlObj.searchParams.get('state');
        
        if (state) {
          try {
            const stateObj = JSON.parse(decodeURIComponent(state));
            if (stateObj.callbackUrl) {
              return `${baseUrl}${stateObj.callbackUrl}`;
            }
          } catch (e) {
            console.error('state 파싱 오류:', e);
          }
        }
      } catch (e) {
        console.error('URL 파싱 오류:', e);
      }
      
      // 기타 경우, URL이 baseUrl로 시작하면 그대로 사용
      if (url.startsWith(baseUrl)) {
        return url;
      }
      
      // 상대 경로인 경우 baseUrl과 결합
      if (url.startsWith('/')) {
        return `${baseUrl}${url}`;
      }
      
      // GitHub 인증 후 항상 대시보드로 리다이렉트
      return `${baseUrl}/dashboard?t=${Date.now()}`;
    },
  },
  pages: {
    signIn: '/auth/github', // 커스텀 로그인 페이지
    error: '/auth/error',   // 에러 페이지
    signOut: '/',           // 로그아웃 후 리디렉션 페이지
    newUser: '/dashboard',  // 새 사용자 등록 후 리디렉션 페이지
  },
  secret: process.env.NEXTAUTH_SECRET,
  // 환경에 따른 쿠키 설정
  useSecureCookies: process.env.NODE_ENV === 'production',
  cookies: {
    sessionToken: {
      name: `${process.env.NODE_ENV === 'production' ? '__Secure-' : ''}next-auth.session-token`,
      options: {
        httpOnly: true,
        sameSite: 'lax',
        path: '/',
        secure: process.env.NODE_ENV === 'production',
      },
    },
  },
})

export { handler as GET, handler as POST }

이 파일은 실제로 인증 요청을 처리하는 API 라우트를 정의합니다.
Next.js의 앱 라우터 구조에 맞게 GET과 POST 핸들러를 노출시킵니다.


6. SessionProvider 추가

모든 페이지에서 인증 상태를 사용할 수 있도록 SessionProvider를 추가합니다. src/components/AuthProvider.tsx 파일을 생성합니다.

// src/components/AuthProvider.tsx
'use client'

import { SessionProvider } from 'next-auth/react'
import { ReactNode } from 'react'

interface AuthProviderProps {
  children: ReactNode
}

export default function AuthProvider({ children }: AuthProviderProps) {
  return <SessionProvider>{children}</SessionProvider>
}

이 컴포넌트를 루트 레이아웃에 추가하여 모든 페이지에서 인증 상태를 사용할 수 있게 합니다.


7. GitHub 인증 페이지 구현

이제 사용자가 실제로 로그인하는 페이지를 만들어 봅시다. src/app/auth/github/page.tsx 파일을 생성합니다.

// src/app/auth/github/page.tsx
'use client'

import { useEffect, useState, Suspense } from 'react'
import { useRouter, useSearchParams } from 'next/navigation'
import Link from 'next/link'
import { signIn, useSession } from 'next-auth/react'

// 검색 매개변수를 사용하는 컴포넌트를 분리
function GitHubAuthContent() {
  const [isLoading, setIsLoading] = useState(true)
  const [error, setError] = useState<string | null>(null)
  const router = useRouter()
  const searchParams = useSearchParams()
  const callbackUrl = searchParams.get('callbackUrl') || '/dashboard'
  const { status } = useSession()

  useEffect(() => {
    if (status === 'loading') {
      return // 세션 로딩 중이면 기다림
    }

    if (status === 'authenticated') {
      // 이미 인증된 경우 대시보드로 이동
      router.push('/dashboard')
      return
    }

    const errorParam = searchParams.get('error')
    if (errorParam) {
      setIsLoading(false)
      setError(`GitHub 인증 중 오류가 발생했습니다: ${errorParam}`)
      return
    }

    // 인증 시작
    const startAuth = async () => {
      try {
        setIsLoading(true)
        // callbackUrl에서 http://가 있는지 확인하여 전체 URL인지 상대 경로인지 판단
        let finalCallbackUrl = callbackUrl;
        if (!callbackUrl.startsWith('http')) {
          // 상대 경로인 경우에만 baseUrl 추가
          const baseUrl = process.env.NEXT_PUBLIC_VERCEL_URL 
            ? `https://${process.env.NEXT_PUBLIC_VERCEL_URL}` 
            : process.env.NEXTAUTH_URL || 'http://localhost:3000';
          finalCallbackUrl = baseUrl + (callbackUrl.startsWith('/') ? callbackUrl : `/${callbackUrl}`);
        }
        
        // 일반 로그인 처리
        await signIn('github', { callbackUrl: finalCallbackUrl, redirect: false });
      } catch (err) {
        setIsLoading(false)
        setError('GitHub 인증을 시작하는 중 오류가 발생했습니다.')
      }
    }

    startAuth()
  }, [router, searchParams, callbackUrl, status])

  return (
    <div className="bg-white shadow-md rounded-lg max-w-md w-full p-8 text-center">
      <div className="mb-6">
        <svg className="mx-auto h-12 w-12 text-primary-500" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor">
          <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
        </svg>
      </div>
      
      <h1 className="text-2xl font-bold text-primary-900 mb-2">GitHub 인증</h1>
      
      {isLoading ? (
        <div className="mt-6">
          <div className="flex justify-center mb-4">
            <div className="animate-spin rounded-full h-10 w-10 border-t-2 border-b-2 border-primary-500"></div>
          </div>
          <p className="text-primary-600">GitHub 계정에 연결 중입니다...</p>
        </div>
      ) : error ? (
        <div className="mt-6">
          <div className="bg-red-50 p-4 rounded-md mb-4">
            <p className="text-red-700">{error}</p>
          </div>
          <button 
            onClick={() => signIn('github', { callbackUrl })}
            className="mt-4 inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md shadow-sm text-primary-50 bg-primary-700 hover:bg-primary-600 gap-2"
          >
            <img 
              src="/images/github_login.png" 
              alt="GitHub 로고" 
              className="w-5 h-5"
            />
            다시 시도하기
          </button>
        </div>
      ) : (
        <div className="mt-6">
          <div className="bg-green-50 p-4 rounded-md mb-4">
            <p className="text-green-700">연결 중... 대시보드로 이동합니다</p>
          </div>
        </div>
      )}
      
      <p className="mt-8 text-sm text-primary-500">
        GitHub 계정을 연결하면 Contribase가 공개 저장소에 접근할 수 있게 됩니다. 
        저희는 귀하의 개인 정보를 보호하며, 모든 분석은 클라이언트 측에서 처리됩니다.
      </p>
    </div>
  )
}

// 로딩 상태를 표시할 폴백 컴포넌트
function LoadingFallback() {
  return (
    <div className="bg-white shadow-md rounded-lg max-w-md w-full p-8 text-center">
      <div className="mb-6">
        <svg className="mx-auto h-12 w-12 text-primary-500" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor">
          <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
        </svg>
      </div>
      
      <h1 className="text-2xl font-bold text-primary-900 mb-2">GitHub 인증</h1>
      
      <div className="mt-6">
        <div className="flex justify-center mb-4">
          <div className="animate-spin rounded-full h-10 w-10 border-t-2 border-b-2 border-primary-500"></div>
        </div>
        <p className="text-primary-600">로딩 중...</p>
      </div>
    </div>
  )
}

// 메인 컴포넌트
export default function GitHubAuth() {
  return (
    <div className="min-h-[70vh] flex flex-col items-center justify-center px-4 bg-primary-50">
      <Suspense fallback={<LoadingFallback />}>
        <GitHubAuthContent />
      </Suspense>
    </div>
  )
}

8. 로그인 필요 페이지 구현

로그인이 필요한 페이지에 접근했을 때 표시할 페이지도 만들어 봅시다. src/app/auth/login-required/page.tsx 파일을 생성합니다.

// src/app/auth/login-required/page.tsx
'use client'

import Link from 'next/link'

export default function LoginRequired() {
  return (
    <div className="min-h-[80vh] flex flex-col items-center justify-center px-4">
      <div className="bg-white shadow-md rounded-lg max-w-md w-full p-8 text-center">
        <div className="mb-6">
          <svg className="mx-auto h-12 w-12 text-primary-500" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor">
            <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
          </svg>
        </div>
        
        <h1 className="text-2xl font-bold text-primary-900 mb-2">로그인 필요</h1>
        
        <div className="mt-6">
          <div className="bg-yellow-50 p-4 rounded-md mb-4">
            <p className="text-yellow-700">이 페이지에 접근하려면 로그인이 필요합니다.</p>
          </div>
          <Link
            href="/auth/github"
            className="mt-6 text-primary-700 rounded-lg font-medium text-lg inline-flex items-center justify-center gap-2 dark:bg-gray-800 dark:hover:bg-gray-700 dark:text-primary-300"
          >
            <img 
              src="/images/github_login.webp" 
              alt="GitHub 로고" 
              className="w-60 rounded-lg"
            />
          </Link>
        </div>
        <p className="mt-8 text-sm text-primary-500">
          GitHub 계정을 연결하면 Contribase가 공개 저장소에 접근할 수 있게 됩니다. 
          저희는 귀하의 개인 정보를 보호하며, 모든 분석은 클라이언트 측에서 처리됩니다.
        </p>
      </div>
    </div>
  )
}

9. GitHub API 통신 기능 구현

인증된 사용자의 GitHub 데이터에 접근하기 위한 API 통신 기능을 구현합니다. src/lib/github.ts 파일을 생성합니다.

// src/lib/github.ts
import { Session } from 'next-auth'

// GitHub 저장소 인터페이스
export interface Repository {
  id: number
  name: string
  full_name: string
  owner: {
    login: string
    avatar_url: string
  }
  description: string
  html_url: string
  updated_at: string
  language: string
  stargazers_count: number
  forks_count: number
}

// GitHub API 호출 기본 함수
async function fetchGitHubAPI<T>(
  accessToken: string,
  endpoint: string,
  options: RequestInit = {}
): Promise<T> {
  const response = await fetch(`https://api.github.com${endpoint}`, {
    ...options,
    headers: {
      Authorization: `Bearer ${accessToken}`,
      Accept: 'application/vnd.github.v3+json',
      ...options.headers,
    },
  })

  if (!response.ok) {
    const error = await response.json().catch(() => ({ message: 'Unknown error' }))
    throw new Error(`GitHub API 오류 (${response.status}): ${error.message}`)
  }

  return response.json()
}

// GitHub 조직 인터페이스
interface GitHubOrg {
  login: string;
  id: number;
  url: string;
  repos_url: string;
  events_url: string;
  hooks_url: string;
  issues_url: string;
  members_url: string;
  public_members_url: string;
  avatar_url: string;
  description: string;
}

/**
 * 사용자의 GitHub 저장소 목록을 가져옵니다.
 * 조직 저장소 권한이 있다면 조직 저장소도 함께 가져옵니다.
 */
export async function getUserRepositories(accessToken: string): Promise<any[]> {
  try {
    console.log('GitHub API 호출: 저장소 목록 가져오기')
    
    // 1. 사용자 저장소 가져오기
    const userReposResponse = await fetch('https://api.github.com/user/repos?per_page=100&sort=updated', {
      headers: {
        'Authorization': `Bearer ${accessToken}`,
        'Accept': 'application/vnd.github.v3+json'
      },
    })
    
    if (!userReposResponse.ok) {
      console.error('GitHub API 오류 (사용자 저장소):', userReposResponse.status)
      throw new Error(`GitHub API 요청 실패: ${userReposResponse.status}`)
    }
    
    const userRepos = await userReposResponse.json()
    console.log(`사용자 저장소 ${userRepos.length}개 로드됨`)
    
    // 2. 사용자가 속한 조직 목록 가져오기 (권한이 있는 경우)
    let orgRepos: any[] = []
    try {
      const orgsResponse = await fetch('https://api.github.com/user/orgs', {
        headers: {
          'Authorization': `Bearer ${accessToken}`,
          'Accept': 'application/vnd.github.v3+json'
        },
      })
      
      if (orgsResponse.ok) {
        const orgs: GitHubOrg[] = await orgsResponse.json()
        console.log(`조직 ${orgs.length}개 로드됨`)
        
        // 조직이 있을 경우 각 조직의 저장소 가져오기
        if (orgs.length > 0) {
          const orgReposPromises = orgs.map(async (org: GitHubOrg) => {
            try {
              const orgReposResponse = await fetch(
                `https://api.github.com/orgs/${org.login}/repos?per_page=100&sort=updated`, 
                {
                  headers: {
                    'Authorization': `Bearer ${accessToken}`,
                    'Accept': 'application/vnd.github.v3+json'
                  },
                }
              )
              
              if (orgReposResponse.ok) {
                return await orgReposResponse.json()
              }
              return []
            } catch (err) {
              console.error(`조직 ${org.login} 저장소 가져오기 오류:`, err)
              return []
            }
          })
          
          const orgReposArray = await Promise.all(orgReposPromises)
          orgRepos = orgReposArray.flat()
          console.log(`조직 저장소 ${orgRepos.length}개 로드됨`)
        }
      }
    } catch (err) {
      console.error('조직 정보 가져오기 오류:', err)
      // 조직 정보를 가져오는데 실패해도 사용자 저장소는 계속 사용
    }
    
    // 사용자 저장소와 조직 저장소 합치기
    const allRepos = [...userRepos, ...orgRepos]
    console.log(`${allRepos.length}개 저장소 로드 완료`)
    
    return allRepos
  } catch (error) {
    console.error('저장소 목록 가져오기 오류:', error)
    throw error
  }
}

10. 조직 접근 권한 요청 기능 구현

대시보드 페이지에서 사용자가 GitHub 조직에 접근하려면 추가 권한이 필요합니다. src/app/dashboard/page.tsx 파일에 다음과 같은 코드를 추가합니다.

// src/app/dashboard/page.tsx (일부 발췌)
const requestOrgAccess = () => {
  console.log('조직 추가하기 버튼 클릭됨');
  
  // 이미 로딩 중이면 중복 요청 방지
  if (isAuthLoading) return;
  
  // 쓰로틀링 체크: 마지막 인증 요청 시간 확인
  if (typeof window !== 'undefined') {
    const lastAuthTime = localStorage.getItem('lastAuthRequestTime')
    const currentTime = Math.floor(Date.now() / 1000) // 현재 시간(초)
    
    if (lastAuthTime) {
      const timeSinceLastAuth = currentTime - parseInt(lastAuthTime)
      
      // 마지막 인증 요청 후 MIN_AUTH_INTERVAL초가 지나지 않았으면 요청 차단
      if (timeSinceLastAuth < MIN_AUTH_INTERVAL) {
        setIsAuthThrottled(true)
        const remaining = MIN_AUTH_INTERVAL - timeSinceLastAuth
        setThrottleTimeRemaining(remaining)
        
        // 남은 시간 카운트다운
        const countdownInterval = setInterval(() => {
          setThrottleTimeRemaining(prev => {
            if (prev <= 1) {
              clearInterval(countdownInterval)
              setIsAuthThrottled(false)
              return 0
            }
            return prev - 1
          })
        }, 1000)
        
        return
      }
    }
    
    // 현재 시간 저장
    localStorage.setItem('lastAuthRequestTime', currentTime.toString())
  }
  
  // 로딩 상태 활성화
  setIsAuthLoading(true);
  
  // 추가 권한이 필요함을 표시하는 URL 파라미터
  const callbackUrlWithParams = `/dashboard?t=${Date.now()}&requireOrgAccess=true`;
  
  // GitHub 인증 페이지로 강제 리다이렉트
  // prompt=consent를 사용하여 항상 권한 확인 화면 표시
  window.location.href = `https://github.com/login/oauth/authorize?` +
    `client_id=${process.env.NEXT_PUBLIC_GITHUB_ID}` +
    `&redirect_uri=${encodeURIComponent(`${window.location.origin}/api/auth/callback/github`)}` +
    `&scope=read:user user:email repo read:org admin:org` +
    `&prompt=consent` +
    `&state=${encodeURIComponent(JSON.stringify({ callbackUrl: callbackUrlWithParams }))}`;
  
  // 10초 후 로딩 상태 자동 해제 (페이지 이동이 안 된 경우)
  setTimeout(() => {
    setIsAuthLoading(false);
  }, 10000);
}

사용자가 짧은 시간 내에 여러 번 권한 요청을 하지 않도록 쓰로틀링 기능을 구현했습니다. 이는 사용자 경험을 해치지 않으면서도 GitHub API 요청 제한에 걸리는 것을 방지합니다!


11. 저장소 목록 가져오기 기능 구현

대시보드 페이지에서 사용자의 GitHub 저장소 목록을 가져오는 부분도 구현합니다.

// src/app/dashboard/page.tsx (일부 발췌)
// 저장소 목록 가져오기
useEffect(() => {
  const fetchRepositories = async () => {
    if (status !== 'authenticated' || !session?.accessToken) {
      return
    }
    
    try {
      setIsLoading(true)
      const repos = await getUserRepositories(session.accessToken)
      
      // 중복된 저장소 제거 (ID 기준)
      const uniqueRepos = Array.from(
        new Map(repos.map(repo => [repo.id, repo])).values()
      );
      
      setRepositories(uniqueRepos)
      
      // 조직 목록 추출
      const orgNames = uniqueRepos
        .filter(repo => repo.owner.login !== session.user?.name)
        .map(repo => repo.owner.login)
        .filter((value, index, self) => self.indexOf(value) === index);
      
      setOrganizations(orgNames);
      
      // 조직/저장소가 로드되었음을 알림
      console.log(`조직 ${orgNames.length}개, 저장소 ${uniqueRepos.length}개 로드 완료`);
      
      setIsLoading(false)
    } catch (err) {
      console.error('저장소 목록 로드 오류:', err)
      setError('저장소 목록을 가져오는 중 오류가 발생했습니다.')
      setIsLoading(false)
    }
  }
  
  fetchRepositories()
}, [status, session, reloadKey])

12. 보안 설정 최적화

보안은 인증 시스템에서 가장 중요한 부분입니다. 개발 환경과 프로덕션 환경에서 다른 보안 설정을 적용했습니다.

// 환경에 따른 쿠키 설정 (Next-Auth 설정의 일부)
useSecureCookies: process.env.NODE_ENV === 'production',
cookies: {
  sessionToken: {
    name: `${process.env.NODE_ENV === 'production' ? '__Secure-' : ''}next-auth.session-token`,
    options: {
      httpOnly: true,
      sameSite: 'lax',
      path: '/',
      secure: process.env.NODE_ENV === 'production',
    },
  },
},

이 설정은,

  • 프로덕션 환경에서만 보안 쿠키 사용
  • httpOnly: JavaScript에서 쿠키에 접근 불가능하게 설정해 XSS 공격 방지
  • sameSite: 'lax': CSRF 공격 방지
  • secure: HTTPS에서만 쿠키 전송 (프로덕션 환경에서만)

또한 JWT 세션 설정을 통해 토큰 수명과 관리 전략을 설정했습니다.

session: {
  strategy: 'jwt',
  maxAge: 30 * 24 * 60 * 60, // 30일 (초 단위)
},

개발 환경과 프로덕션 환경에서는 다른 보안 설정이 필요합니다.
예를 들어, 로컬 개발 환경에서는 HTTP를 사용하지만 프로덕션에서는 HTTPS를 사용해야 합니다.
이러한 차이를 고려하여 환경별로 다른 설정을 적용했습니다.


13. 구현 결과

아래는 GitHub OAuth 인증을 도입한 후의 실제 결과 페이지입니다!

먼저 사이트의 Home 입니다!
상단의 Github 로그인을 누르면 Github 인증페이지로 넘어가게 되고
Repository나 Organizations에 대한 접근권한을 부여하게 됩니다!

또는 대시보드 탭으로 들어가게 되면 위와같은 로그인 창이 뜨고 마찬가지로 로그인을 실행하면 권한부여를 하는 Github 페이지로 이동하게 됩니다

각각의 Repository와 Organizations 권한을 부여하면 저장소 목록에 리스트로 볼수 있게 구현 되었습니다.


마치며

이렇게 Next.js와 NextAuth를 활용해 별도의 백엔드 서버 없이도 GitHub OAuth 인증을 성공적으로 구현해보았습니다.

Spring을 활용한 소셜 로그인에 비해 상대적으로 간단하게 구현할 수 있었지만, 두 방식 모두 각자의 장점이 있어 프로젝트 환경과 목적에 맞는 방식을 선택하는 것이 중요하다고 생각합니다!

이번 구현을 통해 Contribase는 사용자들에게 안전하면서도 편리한 로그인 경험을 제공할 수 있게 되었습니다.
특히 보안과 사용자 경험 사이의 균형을 맞추는 과정은 서비스 개발에서 얼마나 중요한 요소인지 다시 한번 깨닫는 계기가 되었습니다.

인증 시스템은 애플리케이션의 기반이 되는 핵심 요소입니다. 초기에 시간을 들여 신중하게 설계하고 구현한다면, 이후 개발 과정에서 발생할 수 있는 많은 문제를 미리 예방할 수 있습니다.

다음 포스트에서는 Contribase의 핵심 기능인 GitHub 저장소 분석 기능 구현에 대해 소개해드리겠습니다.

많은 관심 부탁드립니다!

profile
항상 고민하고 최선의 방법을 찾아내는 개발자가 되도록 노력하겠습니다.

0개의 댓글