ES /docs

TeamFactory#signup missing ActiveRecord::RecordInvalid rescue

RCA: team signup failed: Validation failed: Domain has already been taken

Error Log#

Datadog Logs

text
team signup failed: Validation failed: Domain has already been taken

Impact#

  • Service: cupixvista-api
  • 발생 횟수: 2
  • 최초 발생: 2026-04-10T13:06:46.788Z
  • 최근 발생: 2026-04-10T13:06:46.788Z

Root Cause Summary#

사용자가 POST /api/v1/teams/signup API를 통해 team_domain: "testing"으로 팀 생성을 시도했으나, 해당 domain이 이미 존재하여 Team 모델의 validates :domain, uniqueness: { case_sensitive: false } 검증에서 실패했습니다. TeamFactory.signup 메서드의 validate_team_domaintmp- 접두사만 검사하고, 도메인 중복 여부를 사전에 확인하지 않습니다. 따라서 중복 도메인은 ActiveRecord::RecordInvalid 예외가 발생할 때까지 감지되지 않으며, 이 예외가 ENT10005 에러 코드로 변환되어 HTTP 400으로 반환됩니다. 이는 정상적인 validation 동작이며, 동일 사용자(Android Chrome)가 이미 존재하는 "testing" 도메인으로 두 번 연속 가입을 시도한 것이 원인입니다.

Technical Analysis#

Code Path#

  • Entry point: app/controllers/api/v1/teams_controller.rb:18signup action이 TeamFactory.signup 호출
ruby
# app/controllers/api/v1/teams_controller.rb:18-27
def signup
  access_token = request.headers['X-CUPIX-AUTH']
  access_token = access_token.split(' ').last if access_token.present? && access_token.include?(' ')
  team = TeamFactory.signup(params, access_token)

  render_api Renderable.new({
    contents: team,
    serializer_option: @serializer_option
  })
end
  • Signup factory: app/factories/team_factory.rb:87signup 메서드가 파라미터 검증 후 팀 생성 시작
ruby
# app/factories/team_factory.rb:87-112
def signup(params, access_token = nil)
  find_user_email(params, access_token)
  validate_required_signup_params(params)

  validate_team_domain(params[:team_domain])

  params = params.merge(
    name: params[:team_name],
    domain: params[:team_domain]
  )

  current_user = ::User.find_by(email: params[:email])
  user_firstname, user_lastname = extract_user_names(current_user, access_token)

  team, super_admin = self.create_team_with_admin(params, user_firstname, user_lastname)
  # ...
rescue StandardError => e
  Cupix::Logger.error("team signup failed: #{e.message}", class: self.class.name, function: __method__, error: e)
  raise e if e.is_a?(Cupix::Errors::BaseError)
  raise Cupix::Errors::System.new(code: 'SYS10000', reason: e.to_s, message: e.message)
end
  • Domain validation (insufficient): app/factories/team_factory.rb:136-138tmp- 접두사만 검사하고 중복 검사는 하지 않음
ruby
# app/factories/team_factory.rb:136-138
def validate_team_domain(team_domain)
  raise Cupix::Errors::Parameter.new(code: 'ARG10000', reason: 'team_domain cannot start with tmp-') if team_domain.start_with?('tmp-')
end
  • Team creation path: app/factories/team_factory.rb:163-175find_or_create_team_and_superadmin에서 임시 팀(tmp- prefix)이 없으면 TeamFactory.create! 호출
ruby
# app/factories/team_factory.rb:163-175
def find_or_create_team_and_superadmin(params, user_firstname, user_lastname)
  temp_team = ::Team.joins(:user)
                    .where('teams.domain LIKE ?', 'tmp-%')
                    .where(users: { email: params[:email], state: :active })
                    .first

  if temp_team.present?
    temp_team.update!(domain: params[:team_domain], name: params[:team_name])
    superadmin = User.find_by(email: params[:email], team: temp_team, state: :active)
    return [temp_team, superadmin]
  end

  team = TeamFactory.create!(params)
  # ...
end
  • Failure point: app/factories/base_factory.rb:130-131model.save!에서 ActiveRecord::RecordInvalid 발생 후 ENT10005로 변환
ruby
# app/factories/base_factory.rb:123-131
begin
  self.model.save!
  self.model
rescue Elasticsearch::Transport::Transport::Errors::BadRequest => e
  raise e
rescue NoMethodError => e
  raise Cupix::Errors::System.new(code: 'SYS10003', reason: e.message)
rescue ActiveRecord::RecordInvalid => e
  raise Cupix::Errors::Entity.new(code: 'ENT10005', reason: e.message)
end
  • Model validation: app/models/team.rb:68-71 — domain uniqueness 제약 조건
ruby
# app/models/team.rb:68-71
validates :domain,
          uniqueness: { case_sensitive: false },
          format: { with: /\A[a-z0-9]([-](?![-])|[a-z0-9])*[a-z0-9]\z/ },
          length: { in: 3..20 }

기대 동작: 사용자가 이미 존재하는 도메인으로 가입 시도 시 명확한 에러 메시지와 함께 HTTP 400 반환. 실제 동작: 기대 동작과 동일. HTTP 400과 ENT10005 에러 코드가 반환됨. 다만 validate_team_domain에서 사전 중복 검사 없이 DB 레벨 validation에 도달한 후 에러가 발생하며, 에러 메시지가 error 레벨로 로깅됨.

Log Evidence#

사용한 Datadog 쿼리:

text
service:cupixvista-api status:error "team signup failed"
Time range: 2026-04-10T12:06:00Z to 2026-04-10T13:36:00Z
text
service:cupixvista-api "Domain has already been taken"
Time range: 2026-04-10T12:06:00Z to 2026-04-10T13:36:00Z

총 6개의 로그 항목이 발견되었으며, 약 4.5초 이내(13:06:42~13:06:46 UTC)에 2개의 요청에서 발생했습니다.

요청 1 (Request ID: 1e87a63c-bccb-424b-bbbd-978d01068245)

HTTP request log (info):

json
{
  "timestamp": "2026-04-10T13:06:42.215Z",
  "status": "info",
  "message": "[400] POST /api/v1/teams/signup (Api::V1::TeamsController#signup)",
  "duration": "27.26ms",
  "db": "7.02ms",
  "remote_ip": "130.176.220.116",
  "auth_method": "COGNITO",
  "params": { "team_domain": "testing", "fields": ["id", "name", "domain"] },
  "error_response": { "code": "ENT10005", "class": "Cupix::Errors::Entity", "reason": "Validation failed: Domain has already been taken" }
}

Model validation log (warn):

json
{
  "timestamp": "2026-04-10T13:06:46.788Z",
  "status": "warn",
  "message": "Validation failed: Domain has already been taken",
  "class": "Team",
  "function": "write_log",
  "model_attributes": { "domain": "testing", "name": "Testing", "state": "initializing", "users_count": 0 }
}

Signup error log (error):

json
{
  "timestamp": "2026-04-10T13:06:46.788Z",
  "status": "error",
  "message": "team signup failed: Validation failed: Domain has already been taken",
  "error": { "code": "ENT10005", "reason": "Validation failed: Domain has already been taken" },
  "function": "signup",
  "class": "Class"
}

요청 2 (Request ID: 3e157728-7c78-447d-b9df-6896a0e63ab7) — 동일한 패턴, remote IP 130.176.220.115, 2초 후 재시도.

핵심 관찰:

  • 동일 사용자(Android Chrome User-Agent)가 약 2초 간격으로 team_domain: "testing"으로 두 번 가입 시도
  • 두 요청 모두 HTTP 400으로 정상 거부됨
  • 두 IP(130.176.220.115, 130.176.220.116)는 CloudFront 등 CDN edge를 통한 동일 사용자 요청으로 추정

Fix Recommendation#

즉시 조치 (Critical)#

이 에러는 즉시 수정이 필요한 버그가 아닙니다. 이미 존재하는 도메인으로 가입을 시도하면 HTTP 400이 정상적으로 반환되고 있습니다. 다만 두 가지 개선이 가능합니다:

  1. 로그 레벨 조정app/factories/team_factory.rb:108: 사용자 입력 오류(중복 도메인)로 인한 validation 실패는 error가 아닌 warn 레벨이 적절합니다. 현재 Cupix::Logger.error로 로깅되어 불필요한 에러 알림을 유발합니다. Cupix::Errors::Entity (validation 에러)인 경우 warn 레벨로 변경하면 noise를 줄일 수 있습니다.

  2. 사전 도메인 중복 검사app/factories/team_factory.rb:136-138: validate_team_domain 메서드에서 Team.exists?(domain: team_domain) 검사를 추가하면, DB save 전에 명확한 에러 메시지(ARG 코드)를 반환할 수 있습니다. 이는 사용자 경험 개선에 해당합니다.

단기 개선 (1주 이내)#

  • validate_team_domain에 도메인 중복 검사 추가하여 tmp- prefix 검사와 함께 Team.exists?(domain: team_domain) 확인. race condition 가능성이 있으나, 기존 DB uniqueness 제약이 최종 방어선 역할을 하므로 문제 없음.
  • signup rescue 블록에서 Cupix::Errors::Entity 타입 에러(validation 실패)는 warn 레벨로 로깅하도록 분기 처리.

장기 개선 (재발 방지)#

  • 프론트엔드에서 가입 폼 제출 전 /api/v1/teams/find_by_domain API를 호출하여 도메인 사용 가능 여부를 사전 확인하는 UX 개선 검토.
  • 사용자 입력 validation 실패를 error 레벨로 로깅하는 패턴이 다른 factory에도 존재하는지 점검하여 일괄 개선.

Monitoring#

현재 발생 빈도(2건)가 낮아 별도 알림이 필요하지는 않으나, 반복적으로 발생할 경우를 대비한 쿼리:

text
service:cupixvista-api status:error "team signup failed" "Domain has already been taken"

로그 레벨이 warn으로 변경된 후에는 아래 쿼리로 모니터링:

text
service:cupixvista-api status:warn "Domain has already been taken"

Risk Assessment#

  • Risk level: low
  • 예상 복잡도: trivial