Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions docs/backend/WORKLOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -87,3 +87,4 @@
| 2026-07-29 | 운영 런타임 Secret 5개를 `pinlog-secrets-prod` Environment 경계에서만 읽어 SHA 고정 Infra action으로 넘기는 수동 workflow를 추가했다. bridge token을 포함한 참조 6개 집합·최소 권한·`github.sha` checkout/revision은 정적 계약 테스트로 고정했고 일반 `backend-ci`에는 Secret 접근을 추가하지 않았다 (S15P11A705-154) | [BI-26](implements/BI-26-2026-07-29-runtime-secret-workflow.md) |
| 2026-07-30 | back#98 리뷰 반영. `dev`가 `required_conversation_resolution`이라 **미해결 스레드가 그대로 병합 게이트**여서, 판정이 `COMMENTED`·내용이 `nit`인 줄 단위 지적 2건과 리뷰가 지목한 테스트 구멍 3건을 닫았다. 고친 둘은 **javadoc이 약속한 범위와 실제 방어 범위가 어긋난 자리**라는 점에서 성격이 같다 — ① `AiSearchClient` 생성자 javadoc이 "시크릿 검사를 여기 두지 않는 이유"로 든 논거는 "같은 키를 읽는 `AiProcessClient`가 이미 검사한다"인데, `embedding-profile`은 **이 클라이언트만 읽는 새 키**라 그 논거가 적용되지 않는다. 빈 문자열이면 FastAPI가 Profile 대조에서 422를 주므로 결과는 모든 검색이 503이고, `application.yml` 기본값은 변수를 **설정하지 않은** 경우만 막는다(`PINLOG_AI_EMBEDDING_PROFILE=`처럼 빈 값으로 정의하면 빈 문자열이 이긴다 — BT-05로 이미 겪은 형태). `requireSecret`과 같은 기준(운영 기동 실패/그 외 경고)으로 기동 시점에 끊었다. 기각된 (c)(기동 시 FastAPI 조회)가 **아니다** — 상대에게 묻지 않고 우리 값 유무만 본다. ② `distinctByRecord`는 "상대 결함이 우리 500이 되지 않게 한다"고 적어 두고 `match` **자체가 null**인 경우가 빠져 있었다. `AiSearchClient`가 최상위 `results == null`을 이미 방어하는데 그것도 계약상 올 수 없는 형태라 **층이 어긋난** 것이라, 원소 쪽 층을 맞췄다. 테스트 구멍 셋(`SEARCH_QUERY_MAX` 501자 미검증 · `PRIVATE_ONLY` 한 번도 미투입 · `insertPreset`의 `active`가 죽은 파라미터)은 **코드는 이미 맞는데 지키는 단언이 없던** 자리라 평소의 RED가 안 나온다 — 세 가드를 일부러 부순 뒤(화이트리스트를 `IN ('PUBLIC')`으로 좁히고 `is_active` 조건을 지우고 `@Size`를 떼고) **새 테스트만 실패하고 기존 24개는 전부 통과하는 것**을 관측해 RED를 대신했다. 리뷰가 "그렇게 고쳐도 전부 초록"이라고 한 것이 그대로 재현됐다. `match == null`은 보통의 RED였다(대역 `{"results":[null]}` → **500** 관측 → 가드 → 200). `docs/ai/spec/ai-integration.md` §2의 낡은 줄 2개(패키지 위치 · "Bean 하나")는 **위임 범위 밖이라 고치지 않고 남겼다**(CLAUDE.md 9). `clean check` **370개 통과**(checkstyle·jacoco 포함) (S15P11A705-135) | [BD-39](decisions/BD-39-embedding-profile-in-application-config.md) · [BI-25](implements/BI-25-2026-07-29-personal-search-backend-integration.md) |
| 2026-07-30 | 유실·정지된 AI 처리를 복구하는 재스캔 Scheduler와 FAILED Finalizer를 붙였다(S15P11A705-159). **이 저장소에 스케줄링이 처음 들어온다** — `@Scheduled`가 0건이었다. AI 연동의 실패 경로 네 곳이 모두 *"재스캔이 복구한다"*를 안전망으로 전제하고 있었는데 그 재스캔이 없어, 한 번 실패한 Context가 영구히 `PENDING`으로 남았다(상태만 보면 정상과 구별되지 않는다). Bean을 셋으로 가른 것은 **트랜잭션 프록시 때문**이다 — 한 클래스에 두면 자기 메서드 호출이 프록시를 지나지 않아 트랜잭션 없이 돌고, 그러면 `FOR UPDATE SKIP LOCKED`의 잠금이 조회 직후 풀려 중복 방어가 조용히 사라진다. 명세가 근거로 든 것을 **실측으로 뒤집은 지점이 하나 있다**: "Finalize를 먼저 두는 이유는 방금 `retry_count`를 3으로 올린 행이 곧바로 종결되기 때문"이라는데, `runOnce`의 두 줄을 맞바꿔도 테스트가 통과했다 — 증가가 `updated_at`을 함께 갱신해 그 행이 **만료 상태에서 벗어나** Finalizer 후보 조건에 걸리지 않는다. 창을 실제로 확보하는 것은 순서가 아니라 만료 조건 + `updated_at` 갱신이고, 순서는 심층 방어로 남겨 `InOrder` 단위 테스트로 고정했다(그 사실을 BI-28에 적었다). `SKIP LOCKED`는 주장으로 두지 않고 **다른 커넥션이 행을 붙잡은 채 회차를 돌려** 실제로 건너뛰는지 봤다 — 없으면 테스트가 매달리므로 별 스레드 + 15초 타임아웃으로 실패로 드러나게 했다. 함정 둘: `@Scheduled(fixedDelayString)`은 Boot의 완화된 바인딩을 쓰지 않아 `5m`이면 기동이 실패한다(`PT5M`로 두고 `ConfigurationContractTests`가 고정), Spring은 스케줄러를 **작업별로 고르지 않아** 전용 스케줄러라도 Bean 이름 `taskScheduler`를 점유해야 해석이 확정된다(BD-40). RED 6건 확인. `clean check` 386개 통과 | [BD-40](decisions/BD-40-scheduling-with-dedicated-scheduler-and-no-distributed-lock.md) · [BI-28](implements/BI-28-2026-07-30-ai-rescan-scheduler.md) · [패키지 구조](../development/package-structure.md) |
| 2026-07-30 | 소셜 로그인 이메일을 필수로 만들었다(#97). 프론트에 이메일을 표시하는 화면이 있어 값 없는 계정을 둘 수 없다는 결정이고, **공용 계약이 먼저 바뀌어야 했다** — `06 §2.2`가 "미동의·미제공 시 null일 수 있다"로 정하고 있어 구현만 바꾸면 계약 위반이다(`CLAUDE.md` 9번). docs#28을 먼저 병합했고 거기서 두 가지를 함께 정리했다: **1차 보장은 공급자 콘솔의 필수 동의 설정**(사용자가 이메일만 거절하고 진행하는 선택지가 동의 화면에 없으므로 이 구현이 막는 것은 일상 흐름이 아니라 방어선), 그리고 **마스킹은 치환이며 NULL이 아니다**(적지 않으면 탈퇴 구현이 NULL을 넣어 이 제약과 부딪힌다. `provider_user_id`가 이미 NOT NULL이면서 마스킹 대상이라 전제는 원래 있었다). **구현하며 드러난 것 셋.** ① **뒤집을 테스트가 예상보다 많았다** — 사전 조사로 3건을 찾았는데 `clean check`에서 `SocialAccountPersistenceTests`가 걸렸다. `emailIsOptional`이 영속성 층에서 null 저장을 고정하고 있었고, 같은 파일의 조회 테스트도 **준비 코드에 null 이메일**이 섞여 함께 깨졌다 — `useEmail(null)`·`isNull()` 검색으로는 안 걸리는 형태다 ② **두 방어선이 독립임을 뮤테이션이 보여 줬다** — 정규화의 `required(...)`만 되돌리면 단위 테스트 4건은 실패하지만 **콜백 테스트 3건은 통과한다**. DB `NOT NULL`이 대신 잡아 같은 `OAUTH_FAILED`로 귀결하기 때문이다. 결함이 아니라 층이 갈린 결과다 — 콜백 테스트는 관측 가능한 계약을, 단위 테스트는 어느 층이 막는가를 고정한다. 반대 방향(`ALTER` 주석 처리)은 `FlywayMigrationTests`가 잡는다 ③ **백필을 넣지 않았다** — 운영 DB에 NULL 행이 없고, 있었다면 **채울 값이 없다**(이메일은 공급자가 주는 값이다). 임의 값을 넣으면 "표시할 이메일"이라는 목적이 깨지므로 그런 환경에서는 마이그레이션이 실패하는 편이 맞다고 보고 SQL 주석에 남겼다. 프론트 질문(실패 사유를 별도 error 값으로 가르는지)에는 **기존 결정대로 `OAUTH_FAILED`로 묶인다**고 답하고 `08 §3.2`에 명시했다 — 사용자가 우리 화면에서 고칠 수 있는 실패가 아니고, 필수 동의 설정에서는 도달하지 않으므로 값을 가르면 발생하지 않는 분기가 남는다. `clean check` **342개 통과** (S15P11A705-152) | [BI-27](implements/BI-27-2026-07-30-social-login-email-required.md) · [docs#28](https://github.com/Team-PinLog/docs/pull/28) |
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
# BI-27. 소셜 로그인 이메일 필수화

- **상태**: ✅ 완료
- **날짜**: 2026-07-30
- **관련**: S15P11A705-152, [back#97](https://github.com/Team-PinLog/back/issues/97),
[docs#28](https://github.com/Team-PinLog/docs/pull/28)(공용 계약 개정 — 선행),
[BI-24](BI-24-2026-07-29-kakao-naver-login.md)(이메일 `null` 허용을 계약으로 고정했던 기록)

## 산출

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

## 계약이 먼저 바뀌어야 했다

`06 §2.2`가 *"미동의·미제공 시 `null`일 수 있다"*로 정하고 있었다. 구현만 바꾸면 공용 계약을 위반하므로([`CLAUDE.md`](../../../CLAUDE.md) 9번) [docs#28](https://github.com/Team-PinLog/docs/pull/28)을 먼저 올려 병합했다. 그 PR에서 함께 정리한 것이 이 구현의 전제다.

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

## 드러난 것

**① 뒤집을 테스트가 예상보다 많았다.** 착수 전에 찾아 둔 것은 `OAuthUserInfoTest` 2건과 `KakaoNaverLoginCallbackTests` 1건이었는데, `clean check`에서 `SocialAccountPersistenceTests`가 걸렸다. `emailIsOptional`이 *"이메일 없이도 저장되고 컬럼에 null로 남는다"*를 영속성 층에서 고정하고 있었다.

같은 파일의 **무관해 보이던 테스트도 함께 깨졌다** — `findsActiveAccountByProviderAndProviderUserId`가 조회 대상을 만들면서 이메일을 `null`로 넣고 있었다. 계약을 검증하는 테스트가 아니라 **준비 코드에 `null`이 섞여 있던 것**이라, 사전 조사(`useEmail(null)`·`isNull()` 검색)로는 걸리지 않았다.

**② 두 방어선이 실제로 독립이라는 것을 뮤테이션 검사가 보여 줬다.** 정규화의 `required(...)`를 되돌려 DB `NOT NULL`만 남긴 상태에서:

| 테스트 | 결과 |
|---|---|
| `OAuthUserInfoTest` 이메일 4건 | **실패** — 예외를 단언하므로 |
| 콜백 테스트 3건(Google·Kakao·Naver) | **통과** |

콜백 테스트가 통과한 이유는 DB 제약이 대신 잡아 **같은 `OAUTH_FAILED`로 귀결**했기 때문이다. 즉 HTTP 경계에서는 어느 층이 막았는지 구별되지 않는다. 이건 결함이 아니라 층이 갈린 결과다 — **콜백 테스트는 관측 가능한 계약을, 단위 테스트는 어느 층이 막는가를** 고정한다. 반대 방향(마이그레이션의 `ALTER`를 주석 처리)에서는 `FlywayMigrationTests.socialAccountEmailIsNotNull`이 잡는다.

**③ 백필을 넣지 않았다.** 운영 DB에 이 컬럼이 `NULL`인 행이 없다. 있었다면 **채울 값이 없다** — 이메일은 공급자가 주는 값이고 서버가 만들어 낼 수 있는 것이 아니다. 임의 값을 넣으면 "표시할 이메일"이라는 목적 자체가 깨진다. 그런 행이 있는 환경에서는 마이그레이션이 실패하는 편이 맞다고 보고 그 판단을 SQL 주석에 남겼다.

## 감수하는 것

**사용자가 공급자 쪽에서 동의를 철회하면 이후 재로그인이 실패한다.** 이미 저장된 값은 남아 있어 기존 세션과 화면 표시는 영향받지 않는다. 콘솔이 필수 동의라 재동의를 요구할 것으로 보지만 실측하지 않았다.

**실패 사유가 `OAUTH_FAILED`로 뭉개진다.** 프론트에서 "이메일 동의가 필요합니다"를 안내할 수 없다. 사유별로 `error` 값을 가르지 않는 것은 기존 결정이고([`OAuthLoginFailureHandler`](../../../src/main/java/com/pinlog/pinlogback/global/security/oauth/OAuthLoginFailureHandler.java)), 이 실패는 사용자가 우리 화면에서 고칠 수 있는 것이 아니라 유지했다. 프론트에 확인해 분기 추가가 필요하지 않다는 답을 받았고 `08 §3.2`에 명시했다.

## 남은 것

- **탈퇴 구현([S15P11A705-65](https://ssafy.atlassian.net/browse/S15P11A705-65))이 마스킹 치환값을 정한다.** 계약이 정한 것은 `NULL`이 아니라는 것과 원본을 되돌릴 수 없어야 한다는 것까지다. 치환값이 회원끼리 겹쳐도 무해하다(유니크가 활성행만 대상이고 마스킹 시점엔 `deleted_at`이 채워져 인덱스 밖이다).
- **번호가 `BI-25`에서 `BI-27`로 두 칸 밀렸다.** 세 PR이 `BI-25`를 동시에 선점했고 머지 순서대로 확정됐다 — [#98](https://github.com/Team-PinLog/back/pull/98)이 `BI-25`, [#100](https://github.com/Team-PinLog/back/pull/100)이 `BI-26`, 이 기록이 `BI-27`이다. `#100`이 머지되기 전에 `BI-27`을 미리 배정해 재번호를 한 번만 했다. 규약대로 "미머지 브랜치의 파일명 선점은 예약이 아니다"가 실제로 세 번 적용된 사례다.

## 검증

`./gradlew clean check --no-daemon` — **342개 통과, 실패 0.**

뮤테이션 검사 2종 — 마이그레이션의 `ALTER` 주석 처리 시 `socialAccountEmailIsNotNull` 실패, 정규화의 `required(...)` 되돌림 시 `OAuthUserInfoTest` 이메일 4건 실패. 각각 확인 후 원복했다.
1 change: 1 addition & 0 deletions docs/backend/implements/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,4 +41,5 @@
| BI-24 | Kakao·Naver 소셜 로그인 추가 — 공급자별 사용자 정보 정규화 (S15P11A705-64) | ✅ 완료 | [BI-24](BI-24-2026-07-29-kakao-naver-login.md) |
| BI-25 | 개인 자연어 검색 백엔드 연동 — FastAPI 호출·Core 재검증·Record 단위 조립 (S15P11A705-135) | ✅ 완료 | [BI-25](BI-25-2026-07-29-personal-search-backend-integration.md) |
| BI-26 | 운영 Secret 5개를 Infra SealedSecret PR로 전달하는 수동 workflow (S15P11A705-154) | ✅ 완료 | [BI-26](BI-26-2026-07-29-runtime-secret-workflow.md) |
| BI-27 | 소셜 로그인 이메일 필수화 — 컬럼 NOT NULL·정규화 거절 (S15P11A705-152) | ✅ 완료 | [BI-27](BI-27-2026-07-30-social-login-email-required.md) |
| BI-28 | 재스캔 Scheduler와 FAILED Finalizer — 유실·정지된 AI 처리 복구 (S15P11A705-159) | ✅ 완료 | [BI-28](BI-28-2026-07-30-ai-rescan-scheduler.md) |
Original file line number Diff line number Diff line change
Expand Up @@ -12,12 +12,14 @@
*
* <p>식별 기준은 공급자가 발급한 식별자다. 이메일·닉네임은 바뀔 수 있어 쓰지 않는다(06 2.2).
*
* @param email 공급자가 제공하지 않거나 사용자가 동의하지 않으면 null이다.
* <p><b>이메일도 필수다.</b> 설정 화면이 이 값을 반드시 표시해야 하므로 값 없는 계정을 두지
* 않는다(06 §2.2). 공급자 콘솔이 이메일을 필수 동의로 두고 있어 사용자가 이 경로를 밟지는
* 않지만, 그 설정이 깨졌을 때 값 없는 계정이 조용히 만들어지는 것을 여기서 막는다.
*/
public record OAuthUserInfo(
SocialProvider provider,
String providerUserId,
@Nullable String email
String email
) {

public static OAuthUserInfo from(String registrationId, Map<String, Object> attributes) {
Expand All @@ -27,17 +29,17 @@ public static OAuthUserInfo from(String registrationId, Map<String, Object> attr
case GOOGLE -> new OAuthUserInfo(
provider,
required(attributes.get("sub"), "sub"),
stringValue(attributes.get("email")));
required(attributes.get("email"), "email"));
// Kakao는 식별자를 최상위 id로 주고 이메일만 kakao_account 아래에 둔다.
case KAKAO -> new OAuthUserInfo(
provider,
required(attributes.get("id"), "id"),
stringValue(nested(attributes, "kakao_account", "email")));
required(nested(attributes, "kakao_account", "email"), "kakao_account.email"));
// Naver는 본문 전체를 response로 한 겹 감싼다. 최상위에는 resultcode·message만 있다.
case NAVER -> new OAuthUserInfo(
provider,
required(nested(attributes, "response", "id"), "response.id"),
stringValue(nested(attributes, "response", "email")));
required(nested(attributes, "response", "email"), "response.email"));
};
}

Expand All @@ -47,12 +49,16 @@ public static OAuthUserInfo from(String registrationId, Map<String, Object> attr
}

/**
* 식별자는 없으면 로그인을 이어 갈 수 없다. user-name-attribute이므로 Spring이 앞단에서 걸러
* 주지만, 그 보증이 설정에 있고 타입에는 없어 여기서 한 번 더 끊는다. 조용히 통과시키면
* {@code provider_user_id} NOT NULL 위반이 되어 원인이 DB까지 내려간다.
* 없으면 진행할 수 없는 속성을 여기서 끊는다. 조용히 통과시키면 해당 컬럼의 NOT NULL 위반이
* 되어 원인이 DB까지 내려간다.
*
* <p>Naver는 예외다 — user-name-attribute가 감싼 키({@code response})라서 Spring이 검사하는
* 것은 감싼 Map의 존재뿐이고 그 안의 {@code id}는 보지 않는다. 이 검사가 유일한 방어선이다.
* <p><b>식별자</b>는 user-name-attribute이므로 Spring이 앞단에서 걸러 주지만, 그 보증이 설정에
* 있고 타입에는 없어 한 번 더 확인한다. Naver는 예외다 — user-name-attribute가 감싼 키
* ({@code response})라서 Spring이 검사하는 것은 감싼 Map의 존재뿐이고 그 안의 {@code id}는
* 보지 않는다. 그쪽은 이 검사가 유일한 방어선이다.
*
* <p><b>이메일</b>은 세 공급자 모두 Spring이 보지 않는다. 필수 동의 설정이 1차 보장이고
* 이 검사가 그 설정에 의존하는 상태를 코드로 확인하는 자리다(06 §2.2).
*/
private static String required(@Nullable Object attribute, String path) {
String value = stringValue(attribute);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@

import org.hibernate.annotations.SQLDelete;
import org.hibernate.annotations.SQLRestriction;
import org.jspecify.annotations.Nullable;

import com.pinlog.pinlogback.global.common.BaseEntity;

Expand Down Expand Up @@ -50,7 +49,7 @@ public class SocialAccount extends BaseEntity {
@Column(name = "provider_user_id", nullable = false, length = 255)
private String providerUserId;

@Column(name = "email", length = 255)
@Column(name = "email", nullable = false, length = 255)
private String email;

protected SocialAccount() {
Expand All @@ -64,10 +63,10 @@ private SocialAccount(Member member, SocialProvider provider, String providerUse
}

/**
* @param email 공급자가 제공하지 않거나 사용자가 동의하지 않으면 null이다.
* @param email 필수다. 값 없는 계정을 두지 않으므로 정규화 계층이 먼저 끊는다(06 §2.2).
*/
public static SocialAccount create(
Member member, SocialProvider provider, String providerUserId, @Nullable String email) {
Member member, SocialProvider provider, String providerUserId, String email) {
return new SocialAccount(member, provider, providerUserId, email);
}

Expand Down
Loading
Loading