Skip to content

feat(S15P11A705-152): 소셜 로그인 이메일 필수화 - #103

Merged
minyongP merged 1 commit into
devfrom
feat/S15P11A705-152-email-required
Jul 30, 2026
Merged

feat(S15P11A705-152): 소셜 로그인 이메일 필수화#103
minyongP merged 1 commit into
devfrom
feat/S15P11A705-152-email-required

Conversation

@cherry-go-round

@cherry-go-round cherry-go-round commented Jul 30, 2026

Copy link
Copy Markdown
Contributor

요약

프론트에 이메일을 표시하는 화면이 있어 값 없는 계정을 둘 수 없다. core.social_account.emailNOT NULL로 바꾸고, 공급자가 이메일을 주지 않으면 가입 자체가 되지 않게 한다.

Jira (필수)

관련 GitHub Issue (선택)

변경 사항

  • V6__social_account_email_not_null.sqlemailNOT NULL
  • OAuthUserInfo — 세 공급자 분기 모두 이메일을 required(...)로 끊는다. record 컴포넌트에서 @Nullable 제거
  • SocialAccount@Column(nullable = false), 팩토리 파라미터에서 @Nullable 제거
  • 이메일 null을 계약으로 고정하던 테스트를 반대 방향으로 뒤집고, FlywayMigrationTests에 스키마 제약 단언 추가

테스트 / 검증

  • ./gradlew clean check --no-daemon
  • DB 변경 시 PostgreSQL 통합 테스트
  • migration 변경 시 빈 DB migration 테스트
  • API 계약 변경 시 관련 문서 갱신
  • 되돌리기 어려운 결정을 포함하면 docs/backend/decisions/에 BD 추가 또는 기존 BD 링크

RED — 기존 계약을 반대로 고정하는 작업이라 뒤집는 것 자체가 RED다. 착수 시점 실패 7건:

OAuthUserInfoTest            이메일 정규화 4건
KakaoNaverLoginCallbackTests 이메일 없는 콜백 2건
GoogleLoginCallbackTests     이메일 없는 콜백 1건

GREEN + Regression#98(개인 자연어 검색)·#100(운영 Secret 전달 workflow) 머지분 위로 리베이스한 뒤 재실행했다.

$ ./gradlew clean check --no-daemon
BUILD SUCCESSFUL in 1m 26s — 378 tests, 0 failures, 0 errors

뮤테이션 검사 2종 (각각 확인 후 원복)

되돌린 것 실패하는 테스트
마이그레이션의 ALTER 주석 처리 FlywayMigrationTests.socialAccountEmailIsNotNull
정규화의 required(...) OAuthUserInfoTest 이메일 4건

배경

06 §2.2가 *"미동의·미제공 시 null일 수 있다"*로 정하고 있어 구현만 바꾸면 공용 계약 위반이다(CLAUDE.md 9번). docs#28을 먼저 병합했고, 그 PR에서 두 가지를 함께 정리했다. 이 구현의 전제다.

  • 1차 보장은 공급자 콘솔의 필수 동의 설정이다. 사용자가 이메일만 거절하고 진행하는 선택지가 동의 화면에 없다. 이 구현이 막는 것은 일상 흐름이 아니라 방어선이다.
  • 마스킹은 치환이며 NULL이 아니다. 적지 않으면 탈퇴 구현(feat(S15P11A705-65): 회원 탈퇴 구현 (소프트 삭제·마스킹·세션 무효화) #34)이 NULL을 넣어 이 제약과 부딪힌다. 같은 테이블의 provider_user_id가 이미 NOT NULL이면서 마스킹 대상이라 전제는 원래 있었고, 글로 옮긴 것이다.

리뷰 포인트

  1. 두 방어선이 독립임을 확인해 주면 좋겠다. 정규화의 required(...)만 되돌리면 단위 테스트 4건은 실패하지만 콜백 테스트 3건은 통과한다 — DB NOT NULL이 대신 잡아 같은 OAUTH_FAILED로 귀결하기 때문이다. HTTP 경계에서는 어느 층이 막았는지 구별되지 않는다. 결함이 아니라 층이 갈린 결과로 보고, 콜백 테스트는 관측 가능한 계약을, 단위 테스트는 어느 층이 막는가를 고정하도록 나눠 뒀다.

  2. 백필을 넣지 않았다. 운영 DB에 NULL 행이 없고, 있었다면 채울 값이 없다 — 이메일은 공급자가 주는 값이고 서버가 만들 수 없다. 임의 값을 넣으면 "표시할 이메일"이라는 목적이 깨지므로 그런 환경에서는 마이그레이션이 실패하는 편이 맞다고 보고 SQL 주석에 남겼다. 다른 판단이 있으면 알려 주면 좋겠다.

  3. 뒤집을 테스트가 예상보다 많았다. 사전 조사로 3건을 찾았는데 clean check에서 SocialAccountPersistenceTests가 걸렸다. emailIsOptional이 영속성 층에서 null 저장을 고정하고 있었고, 같은 파일의 조회 테스트도 준비 코드에 null 이메일이 섞여 함께 깨졌다 — useEmail(null)·isNull() 검색으로는 안 걸리는 형태다.

  4. 기록 번호가 BI-27로 확정됐다. 세 PR이 BI-25를 동시에 선점했고 머지 순서대로 갈렸다 — #98이 BI-25, #100BI-26, 이 기록이 BI-27이다. #100 머지 전에 BI-27을 미리 배정해 재번호를 한 번만 했다. 색인의 BI-22 행 누락은 이 PR과 무관한 기존 누락이라 건드리지 않았다.

미결 / 후속

  • 실패 사유가 OAUTH_FAILED로 뭉개진다. 프론트에서 "이메일 동의가 필요합니다"를 안내할 수 없다. 사유별로 error 값을 가르지 않는 것은 기존 결정이고, 이 실패는 사용자가 우리 화면에서 고칠 수 있는 것이 아니라 유지했다. 프론트에 확인해 분기 추가가 필요 없다는 답을 받았고 08 §3.2에 명시했다.
  • 사용자가 공급자 쪽에서 동의를 철회하면 이후 재로그인이 실패한다. 이미 저장된 값은 남아 기존 세션·화면 표시는 영향받지 않는다. 콘솔이 필수 동의라 재동의를 요구할 것으로 보지만 실측하지 않았다.
  • 탈퇴 시 마스킹 치환값은 #34가 정한다. 계약이 정한 것은 NULL이 아니라는 것과 원본을 되돌릴 수 없어야 한다는 것까지다.

@cherry-go-round
cherry-go-round force-pushed the feat/S15P11A705-152-email-required branch 2 times, most recently from 1feb90b to b6cfa36 Compare July 30, 2026 04:39

@minyongP minyongP left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

PR 브랜치를 로컬로 받아 dev 기준으로 봤습니다. 방향과 층 구성은 맞다고 봅니다. 아래 1번만 머지 전에 확인해 주면 좋겠습니다.

확인한 것 (문제 없음)

  • 공급자 scope가 셋 다 이메일을 요청한다application.ymlopenid,email / account_email / email. 설정 누락으로 특정 공급자 로그인이 통째로 죽는 경로는 없습니다.
  • 빈 문자열도 막힌다required()value.isBlank()까지 봅니다. "email": ""로 오는 경우가 javadoc에는 안 적혀 있는데 코드는 이미 맞습니다.
  • V6 번호 충돌 없음 — 열린 PR은 #100 하나뿐이고 마이그레이션이 없습니다.
  • 리뷰 포인트 1(두 방어선 독립)은 사실입니다. 다만 정확히는 DB가 아니라 Hibernate가 먼저 잡습니다 — Bean Validation이 classpath에 없고 check_nullability 재정의도 없으므로 @Column(nullable = false)가 flush 시점에 PropertyValueException을 던지고 Spring이 이걸 DataIntegrityViolationException으로 옮깁니다. SQL은 Postgres까지 가지 않습니다. HTTP 경계에서 어느 층이 막았는지 구별되지 않는다는 판단은 그대로 유효합니다.

머지 전 확인이 필요한 것

1. 운영 외 환경의 NULL 행 — 실질적으로 유일한 위험

PR은 "운영 DB에 NULL 행이 없다"까지만 말합니다. 그런데 이 컬럼에 NULL을 넣을 수 있었던 경로가 실제로 실행된 기록이 있습니다 — BI-24에서 실제 Kakao·Naver 계정으로 수동 검증했고, 그때 Kakao 이메일은 선택 동의였습니다. 그 행이 dev/staging DB에 남아 있으면 V6가 실패하고 Flyway 실패 = 애플리케이션 기동 실패입니다. 배포가 나가서야 드러납니다.

머지 전에 dev(그리고 각자 로컬)에 한 줄 돌려 보면 끝나는 문제입니다.

SELECT count(*) FROM core.social_account WHERE email IS NULL;

0이 아니면 대응(그 행 정리 또는 백필 판단)이 마이그레이션보다 먼저입니다. 결과를 PR에 적어 주면 "백필을 넣지 않았다"의 근거가 운영 한 곳에서 세 환경으로 넓어집니다.

고치면 좋은 것

2. domain/auth/dto/package-info.java가 뒤집힌 계약을 그대로 들고 있습니다

OAuthUserInfo·SocialAccount javadoc은 꼼꼼히 갱신했는데 같은 패키지의 package-info가 남았습니다.

공급자가 이메일을 주지 않는 경우가 정상이라 OAuthUserInfo.email이 실제로 null이 될 수 있는데, 마킹 전에는 그 사실이 javadoc에만 있고 타입에는 없었다.

@NullMarked를 붙인 이유 자체가 이 PR로 거짓이 됩니다. docs/ai의 낡은 줄을 위임 범위 밖이라 남긴 것과 달리 이건 변경 대상 타입과 같은 패키지라 범위 안입니다. 마킹은 유지하고 근거만 바꾸면 됩니다 — 식별자 쪽 @Nullable은 여전히 살아 있으니 마킹의 값은 남습니다.

3. emailIsRequired의 주석과 단언이 다른 층을 가리킵니다

주석은 "그 층이 뚫려도 여기서 막힌다 — 마지막 방어선이다"(DB)라고 하는데, 위에 적은 대로 DataIntegrityViolationException은 엔티티 애노테이션만으로도 나옵니다. PR 본문의 뮤테이션 검사 1번이 이미 이걸 보여 줍니다ALTER를 주석 처리했을 때 실패한 것은 socialAccountEmailIsNotNull 하나고 emailIsRequired는 초록이었습니다. 즉 이 테스트는 스키마를 지키지 않습니다.

스키마 쪽은 FlywayMigrationTests가 이미 덮고 있어 커버리지 구멍은 아닙니다. 주석을 "엔티티 층"으로 낮추거나, DB를 겨냥하려면 원인 사슬(SQLState 23502)까지 단언하는 쪽이 맞습니다.

4. Google 테스트가 Kakao·Naver 쌍보다 약합니다

KakaoNaverLoginCallbackTests.callbackWithoutEmailDoesNotSignUp은 다섯을 단언합니다(리다이렉트 · error=OAUTH_FAILED · 인증 쿠키 없음 · social_account 없음 · member 없음). 새로 넣은 Google 쪽은 리다이렉트와 회원 수만 봅니다. "값 없는 계정을 남기지 않는다"가 이 PR의 핵심 단언인데 Google 경로에서는 그게 지켜지지 않습니다. 공급자와 무관한 계약이라고 주석에 적어 둔 만큼 단언도 같은 무게로 가면 좋겠습니다.

참고 (이 PR 밖)

loginTolerantOfFirstLoginRaceDataIntegrityViolationException을 전부 첫 로그인 경쟁으로 보고 한 번 재시도합니다. 정규화가 뚫려 두 번째 방어선이 실제로 동작하는 상황에서는 가입을 두 번 시도하고 "social account created concurrently, retrying once"라는 틀린 원인을 남깁니다. 다만 이건 AuthTokenContractTests.callbackFailureRedirectsInsteadOfServerError(255자 초과 이메일)가 이미 밟고 있는 기존 동작이라 이 PR이 만든 것이 아닙니다. 유니크 위반으로 좁히는 건 별도 티켓 감으로 봅니다.

잘한 것

  • 뮤테이션 검사 두 방향(마이그레이션·정규화)을 각각 관측하고 원복한 기록이 본문에 남아 있어서, 두 방어선이 독립이라는 주장을 코드만 보고 확인할 수 있었습니다.
  • SocialAccountPersistenceTests의 **준비 코드에 섞인 null**을 찾아낸 부분 — useEmail(null)·isNull() 검색으로는 안 걸리는 형태라는 관찰이 정확하고 다음 사람에게 쓸모 있는 기록입니다.
  • BI-26 공번을 미리 배정하고 이유를 적은 처리. 재번호를 두 번 하지 않으려는 판단이 합리적입니다.

프론트에 이메일을 표시하는 화면이 있어 값 없는 계정을 둘 수 없다.

공용 계약이 먼저 바뀌어야 했다. 06 §2.2가 "미동의·미제공 시 null일 수 있다"로 정하고
있어 구현만 바꾸면 계약 위반이다(CLAUDE.md 9번). docs#28을 먼저 병합했고, 그 PR에서
두 가지를 함께 정리했다.

- 1차 보장은 공급자 콘솔의 필수 동의 설정이다. 사용자가 이메일만 거절하고 진행하는
  선택지가 동의 화면에 없으므로, 이 구현이 막는 것은 일상 흐름이 아니라 방어선이다
- 마스킹은 치환이며 NULL이 아니다. 적지 않으면 탈퇴 구현이 NULL을 넣어 이 제약과
  부딪힌다. provider_user_id가 이미 NOT NULL이면서 마스킹 대상이라 전제는 원래 있었다

RED — 정규화 4건 + 콜백 3건 실패(기존 계약을 반대로 고정하는 것이라 뒤집는 것 자체가 RED)

GREEN
- V6__social_account_email_not_null.sql
- OAuthUserInfo 세 분기 모두 이메일을 required(...)로 끊음, @nullable 제거
- SocialAccount @column(nullable = false), 팩토리 파라미터 @nullable 제거
- FlywayMigrationTests에 스키마 제약 단언 추가

드러난 것

- 뒤집을 테스트가 예상보다 많았다. 사전 조사로 3건을 찾았는데 clean check에서
  SocialAccountPersistenceTests가 걸렸다. emailIsOptional이 영속성 층에서 null 저장을
  고정하고 있었고, 같은 파일의 조회 테스트도 준비 코드에 null 이메일이 섞여 함께
  깨졌다 — useEmail(null)·isNull() 검색으로는 안 걸리는 형태다
- 두 방어선이 독립임을 뮤테이션이 보여 줬다. 정규화의 required(...)만 되돌리면 단위
  테스트 4건은 실패하지만 콜백 테스트 3건은 통과한다 — DB NOT NULL이 대신 잡아 같은
  OAUTH_FAILED로 귀결하기 때문이다. 콜백 테스트는 관측 가능한 계약을, 단위 테스트는
  어느 층이 막는가를 고정한다. 반대 방향은 FlywayMigrationTests가 잡는다
- 백필을 넣지 않았다. 운영 DB에 NULL 행이 없고, 있었다면 채울 값이 없다 — 이메일은
  공급자가 주는 값이다. 임의 값을 넣으면 "표시할 이메일"이라는 목적이 깨지므로 그런
  환경에서는 마이그레이션이 실패하는 편이 맞다고 보고 SQL 주석에 남겼다

프론트 질문(실패 사유를 별도 error 값으로 가르는지)에는 기존 결정대로 OAUTH_FAILED로
묶인다고 답하고 08 §3.2에 명시했다.

기록은 BI-27이다. 세 PR이 BI-25를 동시에 선점했고 머지 순서대로 확정됐다 — #98이 BI-25,
했다.

clean check 378개 통과, 실패 0 (dev의 #98·#100 머지분 위에서 재실행)

Refs #97

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@minyongP
minyongP force-pushed the feat/S15P11A705-152-email-required branch from b6cfa36 to 4baa7cb Compare July 30, 2026 06:34
@minyongP
minyongP merged commit 7ec4890 into dev Jul 30, 2026
2 checks passed
@minyongP
minyongP deleted the feat/S15P11A705-152-email-required branch July 30, 2026 06:38
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

feat(S15P11A705-152): 소셜 로그인에서 이메일을 필수로 강제하고 미제공 시 가입 거절

2 participants