Free

Claude에게 SaaS 로그인 만들게 하기: Google + GitHub OAuth + account linking

OAuth 자체는 boilerplate, 발목 잡는 건 account linking. callback 3가지 케이스, 무비밀번호 사용자, 레이스 컨디션, Turbo가 302 삼킴, credentials 가드—전부 한 편에.


how2claude에 OAuth 붙이는 일, 진짜 힘든 건 OAuth 자체가 아니었다.

omniauth-google-oauth2omniauth-github 깔고, initializer 쓰고, callback 쓰고, 버튼 두 개 추가하는 것—이건 10분짜리 boilerplate, 어떤 AI agent도 제대로 한다. 어려운 건 account linking이다: 같은 이메일로 이미 비밀번호 계정이 있으면 Google 로그인 오면 합칠 건가? Google을 먼저 연결한 사용자가 나중에 GitHub도 연결하고 싶으면 binding 로직 어떻게 쓰지? 두 사람이 같은 이메일로 동시에 처음 Google 로그인하면 User 두 개 생기는 거 아닌가?

이 글은 진짜 밟은 구덩이 얘기다. 초기 구현은 Amp(91e4f48)에서 했다—Amp 백엔드도 Claude고, 큰 기반을 한 번에 깔기 좋은 상호작용 모델이다. 며칠 후 0112888에서 Claude Code로 Turbo와 OAuth 버튼 충돌을 수정했다. 두 도구가 자연스럽게 이어진다.


10분짜리 작업

Amp이 91e4f48에서 boilerplate 부분을 한 번에 배달:

  • Gemfile: omniauth, omniauth-google-oauth2, omniauth-github
  • OauthAccount 모델: provider / uid / email / name / avatar_url, [provider, uid]에 unique index
  • Auth::OmniauthController의 callback action
  • 라우트: /auth/:provider/callback + /auth/failure
  • sessions/new.html.erb에 button_to 두 개(Google + GitHub) 추가
  • 테스트 파일: controller test 72줄, 5개 시나리오 커버
  • omniauth initializer에 callback_path 설정

User 모델은 한 줄 바뀜, 그 한 줄이 핵심(아래 설명). 14개 파일, 257줄 증가.

OAuth의 기계적 부분은 여기서 끝.

핵심 문제: callback이 3가지 케이스를 처리해야 함

사용자가 "Continue with Google" 누르면, 결국 /auth/google_oauth2/callback로 돌아온다. request.env["omniauth.auth"]에 provider, uid, email, name, avatar가 있다. 판단해야 할 것:

  1. 이 Google 계정 전에 로그인한 적 있나?(해당 OauthAccount 행 있음) → 이미 바인딩된 User로 바로 로그인
  2. 지금 누가 로그인 중인가?(session에 user 있음) → Google을 현재 User에 바인딩
  3. 둘 다 아닌가?(Google 계정 첫 등장, 로그인 세션도 없음) → 이메일로 기존 User 찾거나 새로 만들기

코드:

class Auth::OmniauthController < ApplicationController
  allow_unauthenticated_access only: [:callback, :failure]
  skip_forgery_protection only: :callback

  def callback
    auth = request.env["omniauth.auth"]
    oauth_account = OauthAccount.find_by(provider: auth.provider, uid: auth.uid)

    if oauth_account
      start_new_session_for(oauth_account.user)
    elsif (resume_session; Current.user)
      Current.user.oauth_accounts.create!(oauth_params(auth))
      redirect_to root_path, notice: I18n.t("auth.oauth_linked", provider: auth.provider.titleize) and return
    else
      user = User.find_by(email_address: auth.info.email) || User.create!(
        email_address: auth.info.email,
        password_digest: ""
      )
      user.oauth_accounts.create!(oauth_params(auth))
      start_new_session_for(user)
    end

    redirect_to after_authentication_url
  end

  private

  def oauth_params(auth)
    {
      provider: auth.provider,
      uid: auth.uid,
      email: auth.info.email,
      name: auth.info.name,
      avatar_url: auth.info.image
    }
  end
end

이 46줄, 평범해 보이지만 분기마다 함정이 숨어 있다.

함정 #1: OAuth-only 사용자는 비밀번호가 없음

Rails 8 기본 has_secure_passwordpassword_digest 필수를 검증한다. Google로 로그인하는 신규 사용자는 비밀번호가 없다—어떻게 해야 하나?

하지 말 것: has_secure_password 제거(비밀번호 로그인이 망한다).

할 것: 기본 검증 끄고, 조건부 검증 직접 작성:

class User < ApplicationRecord
  has_secure_password validations: false
  validates :password, length: { minimum: 8 }, if: -> { password.present? }
  # ...
end
  • validations: false: has_secure_password에 딸려오는 "비밀번호 필수" 규칙을 끔
  • validates :password, length:, if: password.present?: 사용자가 비밀번호를 설정하면 길이 ≥ 8; 안 설정하면 신경 안 씀

OAuth-only 사용자는 password_digest: ""로 생성. 나중에 비밀번호 설정 가능(8자 이상만 지키면). 비밀번호 로그인 사용자에게는 영향 0.

부작용: 기존 "password reset" 테스트가 깨짐—너무 짧은 비밀번호를 쓰고 있었을 수도. Amp이 같은 커밋에서 고침(test/controllers/passwords_controller_test.rb). agent에게 feature 시키면서 테스트 영향 범위 한 번 훑는 것, re-run 사이클 하나 덜 돈다.

함정 #2: 이메일로 사용자 찾기의 race condition

세 번째 케이스의 이 줄:

user = User.find_by(email_address: auth.info.email) || User.create!(...)

싱글 스레드에선 문제 없다. 프로덕션에선 두 요청이 거의 동시에 들어옴(드물지만 발생)—둘 다 find_by가 nil 리턴, 둘 다 create!, 하나 성공, 다른 하나는 email_address의 unique index(있다는 전제)에 걸린다.

더 나쁨: email_address에 unique index가 없으면, 같은 이메일 User 두 개 만들어진다. 이후: Stripe customer 바인딩 모호, subscription 조회 엉망.

수정:

  • DB 레벨 unique index: add_index :users, :email_address, unique: true
  • 앱 레벨은 find_or_create_by! 또는 rescue 감싸기:
user = User.find_by(email_address: auth.info.email) ||
       User.create_with(password_digest: "").find_or_create_by!(email_address: auth.info.email)

Amp 원본은 안 감쌌다—agent에게 고동시성 안전 DB 조작 시키려면, "여기 동시성은 어떻게 되나?" 직접 물어야 한다. agent는 기본적으로 싱글 사용자 직렬을 가정, 테스트도 직렬 경로만 커버.

함정 #3: account binding의 귀속 문제

두 번째 케이스—로그인된 사용자가 새 OAuth 바인딩—단순해 보이지만 경계가 있다.

사용자 A가 이메일 [email protected]으로 로그인 중, Continue with GitHub 클릭. GitHub 콜백이 [email protected] 이메일 반환(A의 GitHub는 회사 이메일 사용).

지금 시스템에 존재 가능:
- User A: email [email protected]
- User B: email [email protected](A가 몇 달 전 회사 이메일로 따로 가입)

현재 로직 Current.user.oauth_accounts.create!(...)로 가면 GitHub는 A 아래에 바인딩. 근데 User B도 살아 있고, B의 Purchase가 있을 수도. B는 비밀번호 로그인 여전히 되지만, GitHub는 이제 A가 가져감.

진짜 완전한 binding 로직은 "상대 계정의 고아 데이터를 어떻게 할지"를 처리해야 함—agent가 기본으로 생각해주지 않는 것. 최소한 UI 경고: "이 GitHub 계정은 이메일 [email protected]에 연결되어 있습니다. 계속하면 그 계정은 더 이상 GitHub로 로그인 못합니다."

현재 버전은 이걸 안 다루고, Current.user와 OAuth를 거칠게 바인딩할 뿐. 이건 의도적 단순화—문제 실제로 생기면 보완. 근데 너는 인간으로서 구멍 있는 걸 알고 있어야 한다.

구덩이 #1: Turbo가 Google의 302를 삼킴

OAuth 다 붙이고 배포, Continue with Google 클릭—아무 일도 안 일어남. Network 탭: POST /auth/google_oauth2 → 302 accounts.google.com/o/oauth2/auth?.... 브라우저는 안 움직임.

전 글 Claude에게 두 결제 통합시키기: Stripe + x402에서 정확히 같은 메커니즘 설명했다: button_to<form method="post"> 생성, Turbo가 form 제출을 가로채서 응답을 TURBO_STREAM으로 처리, 그리고 TURBO_STREAM은 cross-origin 302를 따라가지 않음.

수정은 0112888(Claude Code에서 진행):

 <%= button_to "/auth/google_oauth2", method: :post,
+      form: { data: { turbo: false } },
       class: "..." do %>
   Continue with Google
 <% end %>

두 OAuth 버튼 모두 추가. 규칙: button_to의 타겟이 외부 도메인으로 302하는 거(Stripe Checkout, Stripe Portal, Google OAuth, GitHub OAuth, Apple Sign In…), 전부 data: { turbo: false } 필요. 하나가 아니라 한 부류.

구덩이 #2: GitHub 버튼이 credentials 없어도 render됨

신선하게 clone한 레포, GitHub credentials 설정 안 함—로그인 페이지는 여전히 "Continue with GitHub" 렌더링. 누르면 Invalid client_id.

같은 0112888에서 추가한 가드:

<% if Rails.application.credentials.dig(:github, :client_id).present? %>
  <%= button_to "/auth/github", ... %>
<% end %>

GitHub credentials 없으면 버튼 안 보임. 이런 자잘한 거 할 가치:

  1. 개발자가 프로젝트 clone해서 띄울 때 깨진 버튼 안 봄
  2. 프로덕션에서 credentials가 실수로 없어지면, 최소한 깨진 버튼이 안 보여서 누를 수 없음
  3. 로컬과 프로덕션 credentials 싱크 안 맞을 때, 에러 안 내고 기능만 저하

agent한테 처음부터 이런 "credentials 보호" 가드 쓰라고 시키는 건 자동이 아님—config가 다 채워져 있다고 가정. feature 초판 + credentials guard 보완은 두 페이즈, 두 번에 나눠서 agent한테 시키기.

Amp + Claude Code 협업

이번 작업은 두 페이즈:

페이즈 1(Amp, 91e4f48): 한 세션에 OAuth 전체 boilerplate 깔기—gem, 모델, migration, controller, view, 라우트, tests, i18n. 14 파일 257줄, 20분. Amp의 상호작용 체험은 "한 번에 큰 덩어리 진행"에 맞음, thread 기록이 선형적으로 깔끔.

페이즈 2(Claude Code, 0112888): 며칠 뒤 런칭 직전 Turbo + OAuth 충돌 발견. 여기선 정확한 진단 + 1줄 수정 + 회귀 테스트. Claude Code는 프로젝트 디렉토리 내에서 완전한 git/session 컨텍스트 보유, 거기에 이 프로젝트의 hook(session을 docs/notes/pro/raw.md에 자동 기록, Claude에게 자기 session을 기록하는 hook 짜게 하기 참조)로 디버깅 과정이 글 소재로 남음.

두 도구는 충돌 안 함—Amp는 0부터 feature 구축, 긴 thread의 큰 논의에 맞음; Claude Code는 기존 프로젝트의 지속적 증분 작업 + 엔지니어링 자동화에 맞음. 뒤에는 같은 Claude Opus 4.7(1M 컨텍스트), 상호작용 모델과 도구셋만 다름.

같은 모델을 두 도구에서 번갈아 쓰는 건, 같은 엔지니어가 때로는 IDE, 때로는 터미널 쓰는 것처럼—각자의 자리가 있음.


Claude(또는 Amp)에게 OAuth 로그인 만들게 하기, 완전한 체크리스트:

  1. OAuth 자체는 boilerplate라는 걸 인정. 초점은 callback의 3 케이스에.
  2. OAuth-only 사용자는 has_secure_password validations: false + 조건부 길이 검증. password 인증 제거하지 마.
  3. 이메일로 사용자 찾는 줄에 DB 레벨 unique index + 앱 레벨 동시성 보호 추가. agent는 기본으로 안 함.
  4. Account binding의 크로스 사용자 고아 문제는 최소 UI 경고. 없는 척하지 마.
  5. 모든 OAuth button_todata-turbo=false. Stripe와 같은 가족의 버그.
  6. credentials 없는 OAuth 버튼은 render하지 마. 로컬과 프로덕션 양쪽 보험.
  7. 초판 feature와 credentials guard는 두 번에 나눠서. 한 번에 요구하면 빠뜨리기 쉬움.

OAuth 자체는 어렵지 않다. 정말 어려운 건 agent가 자연스럽게 떠올리지 않는 경계 케이스—동시성, 고아 데이터, 로컬 credentials 누락—의 존재를 인정하는 것. 인간으로서 네 역할은 그 질문을 하는 것: "동시성은?" "account 고아는?" "로컬 credentials 없을 때는?"

질문은 네가, 답은 agent가. 이게 이 워크플로의 진짜 분업이다.