Trim redundant user reads/writes in userService (W5, R6)

W5: getMe synced photoUrl on every read whenever the token URL string
differed, but providers (Google) rotate query params (=sN-c sizing) for
the same image, causing write churn on a read path. Compare only the URL
before '?' so the write fires only on a genuine photo change.

R6: createMe/updateMe re-read the user with getUser right after writing.
Synthesize the response instead — updateMe from the already-read doc plus
the applied patch; createMe from the written inputs (createdAt approximated
as now, since the stored value uses serverTimestamp and later reads reflect
it). Removes one Firestore read per onboarding/profile edit.
This commit is contained in:
윤정민 2026-05-28 17:32:12 +09:00
parent 38a2f78647
commit 92ce4f9153

View File

@ -1,5 +1,5 @@
import type { DecodedIdToken } from "firebase-admin/auth"; import type { DecodedIdToken } from "firebase-admin/auth";
import { FieldValue } from "firebase-admin/firestore"; import { FieldValue, Timestamp } from "firebase-admin/firestore";
import { HttpError } from "../middleware/errors"; import { HttpError } from "../middleware/errors";
import { auth } from "../firebase"; import { auth } from "../firebase";
import { import {
@ -86,11 +86,25 @@ function providerFromToken(token: DecodedIdToken): Provider {
); );
} }
/**
* URL이 .
*
* Google (: `=s96-c` )
* . , `getMe`( )
* photoUrl write를 .
*/
function samePhotoUrl(a: string | undefined, b: string | undefined): boolean {
if (a === b) return true;
if (!a || !b) return false;
return a.split("?")[0] === b.split("?")[0];
}
/** /**
* . 404 + `USER_NOT_FOUND`. * . 404 + `USER_NOT_FOUND`.
* *
* `picture` `photoUrl` * `picture` `photoUrl` ****
* ( ). . * ( write하지 write ).
* .
*/ */
export async function getMe(token: DecodedIdToken): Promise<UserProfile> { export async function getMe(token: DecodedIdToken): Promise<UserProfile> {
const user = await getUser(token.uid); const user = await getUser(token.uid);
@ -99,7 +113,7 @@ export async function getMe(token: DecodedIdToken): Promise<UserProfile> {
} }
const tokenPhoto = token.picture; const tokenPhoto = token.picture;
if (tokenPhoto && tokenPhoto !== user.photoUrl) { if (tokenPhoto && !samePhotoUrl(tokenPhoto, user.photoUrl)) {
await updateUser(token.uid, { photoUrl: tokenPhoto }); await updateUser(token.uid, { photoUrl: tokenPhoto });
user.photoUrl = tokenPhoto; user.photoUrl = tokenPhoto;
} }
@ -159,11 +173,18 @@ export async function createMe(
await deleteReservation(token.uid, displayName); await deleteReservation(token.uid, displayName);
const created = await getUser(token.uid); // 방금 쓴 값으로 응답을 합성한다(쓰기 후 2차 getUser 제거). `createdAt`은 저장본이
if (!created) { // serverTimestamp로 기록되므로 응답에는 근사치(now)를 싣는다 — 이후 getMe가 저장본 반영.
throw new HttpError(500, "failed to read created user"); const profile: UserProfile = {
} displayName,
return toUserProfile(created); email: token.email,
provider,
knowledgeLevel,
createdAt: Timestamp.now(),
};
if (token.picture) profile.photoUrl = token.picture;
if (favoriteTeamCode) profile.favoriteTeamCode = favoriteTeamCode;
return profile;
} }
/** /**
@ -266,8 +287,22 @@ export async function updateMe(
await updateUser(token.uid, patch); await updateUser(token.uid, patch);
const updated = await getUser(token.uid); // 쓰기 후 2차 getUser 제거: 시작 시 읽은 user에 방금 적용한 변경만 반영해 합성한다.
return toUserProfile(updated!); const merged: User = { ...user };
if (typeof patch.displayName === "string") {
merged.displayName = patch.displayName;
}
if (body.favoriteTeamCode !== undefined) {
if (body.favoriteTeamCode === null) {
delete merged.favoriteTeamCode;
} else {
merged.favoriteTeamCode = patch.favoriteTeamCode as TeamCode;
}
}
if (typeof patch.knowledgeLevel === "string") {
merged.knowledgeLevel = patch.knowledgeLevel as KnowledgeLevel;
}
return toUserProfile(merged);
} }
const NOTIFICATION_KEYS = new Set<string>(Object.values(NotificationKey)); const NOTIFICATION_KEYS = new Set<string>(Object.values(NotificationKey));