OAuth 자체는 boilerplate, 발목 잡는 건 account linking. callback 3가지 케이스, 무비밀번호 사용자, 레이스 컨디션, Turbo가 302 삼킴, credentials 가드—전부 한 편에.
how2claude에 OAuth 붙이는 일, 진짜 힘든 건 OAuth 자체가 아니었다.
omniauth-google-oauth2와 omniauth-github 깔고, initializer 쓰고, callback 쓰고, 버튼 두 개 추가하는 것—이건 10분짜리 boilerplate, 어떤 AI agent도 제대로 한다. 어려운 건 account linking이다: 같은 이메일로 이미 비밀번호 계정이 있으면 Google 로그인 오면 합칠 건가? Google을 먼저 연결한 사용자가 나중에 GitHub도 연결하고 싶으면 binding 로직 어떻게 쓰지? 두 사람이 같은 이메일로 동시에 처음 Google 로그인하면 User 두 개 생기는 거 아닌가?
이 글은 진짜 밟은 구덩이 얘기다. 초기 구현은 Amp(91e4f48)에서 했다—Amp 백엔드도 Claude고, 큰 기반을 한 번에 깔기 좋은 상호작용 모델이다. 며칠 후 0112888에서 Claude Code로 Turbo와 OAuth 버튼 충돌을 수정했다. 두 도구가 자연스럽게 이어진다.
Amp이 91e4f48에서 boilerplate 부분을 한 번에 배달:
Gemfile: omniauth, omniauth-google-oauth2, omniauth-githubOauthAccount 모델: provider / uid / email / name / avatar_url, [provider, uid]에 unique indexAuth::OmniauthController의 callback action/auth/:provider/callback + /auth/failuresessions/new.html.erb에 button_to 두 개(Google + GitHub) 추가omniauth initializer에 callback_path 설정User 모델은 한 줄 바뀜, 그 한 줄이 핵심(아래 설명). 14개 파일, 257줄 증가.
OAuth의 기계적 부분은 여기서 끝.
사용자가 "Continue with Google" 누르면, 결국 /auth/google_oauth2/callback로 돌아온다. request.env["omniauth.auth"]에 provider, uid, email, name, avatar가 있다. 판단해야 할 것:
OauthAccount 행 있음) → 이미 바인딩된 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줄, 평범해 보이지만 분기마다 함정이 숨어 있다.
Rails 8 기본 has_secure_password는 password_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 사이클 하나 덜 돈다.
세 번째 케이스의 이 줄:
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 조회 엉망.
수정:
add_index :users, :email_address, unique: truefind_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는 기본적으로 싱글 사용자 직렬을 가정, 테스트도 직렬 경로만 커버.
두 번째 케이스—로그인된 사용자가 새 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를 거칠게 바인딩할 뿐. 이건 의도적 단순화—문제 실제로 생기면 보완. 근데 너는 인간으로서 구멍 있는 걸 알고 있어야 한다.
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 } 필요. 하나가 아니라 한 부류.
신선하게 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 없으면 버튼 안 보임. 이런 자잘한 거 할 가치:
agent한테 처음부터 이런 "credentials 보호" 가드 쓰라고 시키는 건 자동이 아님—config가 다 채워져 있다고 가정. feature 초판 + credentials guard 보완은 두 페이즈, 두 번에 나눠서 agent한테 시키기.
이번 작업은 두 페이즈:
페이즈 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 로그인 만들게 하기, 완전한 체크리스트:
has_secure_password validations: false + 조건부 길이 검증. password 인증 제거하지 마.button_to에 data-turbo=false. Stripe와 같은 가족의 버그.OAuth 자체는 어렵지 않다. 정말 어려운 건 agent가 자연스럽게 떠올리지 않는 경계 케이스—동시성, 고아 데이터, 로컬 credentials 누락—의 존재를 인정하는 것. 인간으로서 네 역할은 그 질문을 하는 것: "동시성은?" "account 고아는?" "로컬 credentials 없을 때는?"
질문은 네가, 답은 agent가. 이게 이 워크플로의 진짜 분업이다.