fix(backend): 붙잡은 직후 들어온 메일 하나에 알림이 두 번 가지 않게 한다

엔진 연결을 붙잡자마자 메일이 오면 따라잡기와 그 메일의 새 메일 신호가 함께 보내 알림이 두 번 갔다(커밋 전 다시 리뷰 N1).
붙잡은 뒤 신호를 이미 받았으면 따라잡기를 건너뛰고, 따라잡기 뒤 간격 끝의 보내기는 따라잡기가 센 뒤에 받은 메일이 있을 때만 보낸다
(기록 저장이 실패해도 겹치지 않게 메모리로 본다). 옛 주석의 "로그인이 저절로 끝남"을 끝난 까닭 표로 고치고 계약 · 스킬의 문장도 맞췄다.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-11-08 01:22:58 +09:00
parent b77f95fdff
commit 3f66f39f9d
8 changed files with 202 additions and 47 deletions
@@ -124,7 +124,17 @@ function setup(
isDeliverable: jest.fn((sessionId: string, fid: string) =>
Promise.resolve(!moved.has(`${sessionId}/${fid}`)),
),
recordPushed: jest.fn(() => Promise.resolve()),
// 표처럼 — 보낸 기기의 last_pushed_at 을 그 N 을 센 때로 (따라잡기 뒤 첫 보내기가 이것을 본다)
recordPushed: jest.fn((sessionIds: readonly string[], at: Date) => {
for (const list of Object.values(targets)) {
list.forEach((entry, index) => {
if (sessionIds.includes(entry.sessionId)) {
list[index] = { ...entry, lastPushedAt: at };
}
});
}
return Promise.resolve();
}),
forgetDevice: jest.fn(() => Promise.resolve()),
onChanged: jest.fn((next: (user: string) => void) => {
listener = next;
@@ -553,7 +563,8 @@ describe('NewMailPushService (앱 새 메일 알림 — 붙잡기 · 간격 · N
{ kind: 'new-mail', count: 1 },
]);
current.unread[ARA].push(NOW);
// 따라잡기가 센 다음 초에 받은 메일 — 간격 끝 보내기는 따라잡기 뒤 새로 받은 메일이 있을 때만이다(리뷰 N1)
current.unread[ARA].push(NOW + 1_000);
current.newMail(ARA);
await flush();
expect(current.sent).toHaveLength(1);
@@ -564,6 +575,70 @@ describe('NewMailPushService (앱 새 메일 알림 — 붙잡기 · 간격 · N
]);
});
it('붙잡은 직후 따라잡기가 세는 사이 그 메일의 새 메일 신호가 오면 한 번만 울린다 — 따라잡기는 건너뛰고 신호 쪽이 그때의 N 으로 (리뷰 N1)', async () => {
current = setup();
// 붙잡는 순간에 막 들어온 메일 — 따라잡기 창 안 · 본 뒤
current.unread[ARA] = [NOW - 1_000];
const counting =
current.provider.countInboxUnreadSince.getMockImplementation();
if (!counting) throw new Error('세기 대역이 있어야 한다');
let open!: () => void;
const gate = new Promise<void>((resolve) => {
open = resolve;
});
// 따라잡기의 세기만 문이 열릴 때까지 붙잡는다
current.provider.countInboxUnreadSince.mockImplementationOnce(
async (user: string, since: readonly Date[]) => {
await gate;
return counting(user, since);
},
);
current.service.onApplicationBootstrap();
await flush();
expect(current.fake.holds).toHaveLength(1);
// 세기가 엔진에서 돌아오기 전에 그 메일의 신호가 온다(배달 → 신호 0.8초 남짓)
current.newMail(ARA);
open();
await flush();
expect(current.sent).toEqual([
{ fid: 'fid-s1', notice: { kind: 'new-mail', count: 1 } },
]);
// 간격이 지나도 더 울리지 않는다
await advance(NEW_MAIL_PUSH_INTERVAL_MS);
expect(current.sent).toHaveLength(1);
});
it('따라잡기로 보낸 뒤 그 메일의 신호가 늦게 오면 간격 끝에 다시 보내지 않는다 — 그사이 새로 받은 메일이 있을 때만, 보낸 때를 표에 적지 못해도 (리뷰 N1)', async () => {
current = setup();
current.unread[ARA] = [NOW - 1_000];
// 표의 last_pushed_at 이 바뀌지 않은 것처럼 — 따라잡기가 보낸 기기 · 센 때는 서비스가 메모리에 둔다
current.registrations.recordPushed.mockImplementation(() =>
Promise.resolve(),
);
current.service.onApplicationBootstrap();
await flush();
expect(current.sent).toEqual([
{ fid: 'fid-s1', notice: { kind: 'new-mail', count: 1 } },
]);
// 따라잡기가 센 메일의 신호가 따라잡기 뒤에 온다
jest.setSystemTime(NOW + 800);
current.newMail(ARA);
await advance(NEW_MAIL_PUSH_INTERVAL_MS);
expect(current.sent).toHaveLength(1);
// 그 뒤의 신호는 보통대로 — 새로 받은 메일이 있으면 (건너뛴 보내기도 간격에 들어) 다음 간격 끝에 그때의 N 으로
current.unread[ARA].push(Date.now());
current.newMail(ARA);
await advance(NEW_MAIL_PUSH_INTERVAL_MS);
expect(current.sent.map(({ notice }) => notice)).toEqual([
{ kind: 'new-mail', count: 1 },
{ kind: 'new-mail', count: 2 },
]);
});
it('보내기 직전 그 휴대폰이 다른 세션으로 옮겨 갔으면(isDeliverable false) 그 기기에는 보내지 않는다 — 보낸 때도 적지 않는다 (리뷰 L1)', async () => {
const env = await start({
targets: { [ARA]: [target('phone'), target('moved')] },
+117 -40
View File
@@ -60,6 +60,20 @@ interface Pacing {
lastFlushAt: number | null;
/** 간격 끝에 보낼 차례 */
timer: NodeJS.Timeout | null;
/**
* 따라잡기가 보냈고 그 뒤 신호 보내기가 아직 없다 — 다음 보내기 한 번은 새로 받은 메일이 있는 기기에만(리뷰 N1). 없으면 null.
* 따라잡기가 센 메일의 새 메일 신호가 뒤늦게 와서 같은 메일로 한 번 더 울리지 않게
*/
afterCatchUp: CatchUpPushed | null;
}
/**
* 따라잡기가 보낸 기기(세션)와 그 N 을 센 때 — 메모리에 둔다(표의 last_pushed_at 저장이 실패해도 같은 메일로 다시 울리지 않게).
* 보내지 못한 기기는 넣지 않는다(그 기기에는 그 메일이 아직 가지 않았다)
*/
interface CatchUpPushed {
readonly sessions: ReadonlySet<string>;
readonly countedAt: Date;
}
/** 붙잡기에 실패한 직원 — 다음 시도 */
@@ -110,7 +124,8 @@ const RECEIVED_AT_RESOLUTION_MS = 1_000;
* 그 시각의 다음 초(ms) — 따라잡기가 "새로 온 것"을 볼 시작. 없으면 0.
* 세기는 그 초부터 같은 초를 넣으므로(MailProvider.countInboxUnreadSince — N 을 덜 세지 않게) 그대로면 그 초에 받은 메일
* — 그 알림의 N 에 이미 들었거나(last_pushed_at) 이미 본(seen_at) — 이 "새로 온 것"으로 다시 세져 따라잡기가 한 번 더 울린다(2026-11-07 리뷰 M1).
* 센 · 본 뒤 같은 초에 들어온 메일은 붙잡은 연결이 따로 새 메일 신호를 냈으므로 놓치는 것은 그 1초 안에 서버가 멈춘 경우뿐이다
* 센 · 본 뒤 같은 초에 들어온 메일은 따로 새 메일 신호를 낸다 — 그 알림이 나가기 전(그 1초 안 · 30초 간격의 끝을 기다리는 사이)에 서버가 멈추면 놓친다.
* 따라잡기 뒤 첫 보내기(Pacing.afterCatchUp)도 같은 규칙이라, 따라잡기가 센 그 초에 센 뒤 들어온 메일은 그 보내기에서 빠진다 — 다음 알림의 N 에는 든다
*/
function nextSecondOf(time: Date | null): number {
if (time === null) return 0;
@@ -138,7 +153,8 @@ function timer(callback: () => void, ms: number): NodeJS.Timeout {
* N(받은편지함의 안 읽은 메일 가운데 그 기기에서 마지막으로 앱을 본 뒤 받은 수 — 엔진에 센다. 웹에서 읽은 메일은 빠진다) → N 이 1 이상인 기기에 "새 메일 N통".
* FCM 이 받지 않는 기기는 등록을 지운다(FID 가 그대로일 때만). 같은 직원의 보내기는 차례로(KeyedQueue)
* - **따라잡기**: 새로 붙잡을 때마다(서버를 다시 띄움 · 다시 붙잡음) 그 전 CATCH_UP_WINDOW_MS 안에 온 · 마지막으로 보낸 뒤 · 본 뒤의 안 읽은 메일이 있으면
* 그 기기에 그때의 N 으로 한 번 — 이미 알린 메일은 다시 알리지 않는다(last_pushed_at)
* 그 기기에 그때의 N 으로 한 번 — 이미 알린 메일은 다시 알리지 않는다(last_pushed_at). 붙잡은 직후의 메일 하나에 두 번 울리지 않게(리뷰 N1):
* 붙잡은 뒤 새 메일 신호가 먼저 왔으면 따라잡기는 건너뛰고(신호 쪽이 그때의 N 으로), 따라잡기로 보낸 뒤 첫 신호 보내기는 그 뒤 새로 받은 메일이 있는 기기에만
* - 보내는 글에는 개수만 — 새 메일의 보낸 사람 · 제목 · id 는 이 서비스에 오지도 않는다(붙잡은 쪽은 신호만 받는다)
* 로그: 보낸 것은 debug(가린 주소 · 기기 수 · N), 붙잡기가 이어서 실패하면 warn 한 번 · 다시 되면 info 한 번. 메일 글 · FID · 토큰은 남기지 않는다
*
@@ -158,6 +174,11 @@ export class NewMailPushService
private wanted = new Set<string>();
private readonly retries = new Map<string, HoldRetry>();
private readonly pacing = new Map<string, Pacing>();
/**
* 이번 붙잡기 뒤 새 메일 신호를 받은 직원 — 따라잡기는 건너뛴다(신호 쪽이 그때의 N 으로 보낸다 — 붙잡은 직후의 메일 하나에 두 번 울리지 않게, 리뷰 N1).
* 붙잡기를 시작할 때 지운다(그 붙잡기의 연결이 낸 신호만 센다)
*/
private readonly signaledSinceHold = new Set<string>();
private readonly holdGate = new ConcurrencyGate(HOLD_CONCURRENCY);
/** 같은 직원의 보내기 · 따라잡기는 차례로 */
private readonly sends = new KeyedQueue();
@@ -269,6 +290,7 @@ export class NewMailPushService
this.retries.clear();
for (const release of this.held.values()) release();
this.held.clear();
this.signaledSinceHold.clear();
}
private isCompany(user: string): boolean {
@@ -298,6 +320,8 @@ export class NewMailPushService
}
private async hold(user: string): Promise<void> {
// 이 붙잡기의 연결이 낸 신호만 센다 (holder 는 hold 가 끝나기 바로 전에 켜진다 — 그 전에 지운다)
this.signaledSinceHold.delete(user);
let release: () => void;
try {
release = await this.events.hold(user, this.holderFor(user));
@@ -314,7 +338,7 @@ export class NewMailPushService
}
this.held.set(user, release);
this.recovered(user);
// 붙잡기 전에 온 메일 — 서버를 다시 띄운 사이 · 다시 붙잡기까지의 사이
// 붙잡기 전에 온 메일 — 서버를 다시 띄운 사이 · 다시 붙잡기까지의 사이 (그사이 신호가 오면 건너뛴다 — catchUp)
void this.sends.run(user, () => this.catchUp(user));
}
@@ -332,6 +356,7 @@ export class NewMailPushService
*/
private onHoldEnded(user: string, cause: MailEventsHoldEnd): void {
this.held.delete(user);
this.signaledSinceHold.delete(user);
this.clearPacing(user);
if (this.stopping || !this.wanted.has(user)) return;
this.scheduleRetry(
@@ -350,6 +375,7 @@ export class NewMailPushService
private release(user: string): void {
const release = this.held.get(user);
this.held.delete(user);
this.signaledSinceHold.delete(user);
release?.();
this.clearPacing(user);
}
@@ -419,7 +445,7 @@ export class NewMailPushService
private pacingOf(user: string): Pacing {
let pacing = this.pacing.get(user);
if (!pacing) {
pacing = { lastFlushAt: null, timer: null };
pacing = { lastFlushAt: null, timer: null, afterCatchUp: null };
this.pacing.set(user, pacing);
}
return pacing;
@@ -428,6 +454,7 @@ export class NewMailPushService
/** 새 메일 신호 — 첫 알림은 바로, 간격 안이면 끝에 한 번 (이미 기다리는 차례가 있으면 그것이 그때의 N 으로 보낸다) */
private onNewMail(user: string): void {
if (this.stopping) return;
this.signaledSinceHold.add(user);
const pacing = this.pacingOf(user);
if (pacing.timer) return;
const wait =
@@ -446,19 +473,30 @@ export class NewMailPushService
private flush(user: string, pacing: Pacing): void {
pacing.lastFlushAt = Date.now();
void this.sends.run(user, () => this.pushNewMail(user));
const afterCatchUp = pacing.afterCatchUp;
pacing.afterCatchUp = null;
void this.sends.run(user, () => this.pushNewMail(user, afterCatchUp));
}
/** 보낼 기기마다 N 을 세어 보낸다 (던지지 않는다) */
private async pushNewMail(user: string): Promise<void> {
/**
* 보낼 기기마다 N 을 세어 보낸다 (던지지 않는다).
* 따라잡기 뒤 첫 보내기(`afterCatchUp`)면 따라잡기처럼 새로 온 것이 있는 기기에만 — 따라잡기가 보낸 기기는 그 따라잡기가 센 초 다음부터 본다.
* 따라잡기가 센 메일의 신호가 뒤늦게 와서 간격 끝에 같은 메일로 한 번 더 울리지 않게(리뷰 N1)
*/
private async pushNewMail(
user: string,
afterCatchUp: CatchUpPushed | null,
): Promise<void> {
try {
const targets = await this.registrations.targetsOf(user);
if (targets.length === 0) return;
const countedAt = new Date();
const counts = await this.provider.countInboxUnreadSince(
user,
targets.map((target) => target.seenAt),
);
const counts = afterCatchUp
? await this.freshCounts(user, targets, 0, afterCatchUp)
: await this.provider.countInboxUnreadSince(
user,
targets.map((target) => target.seenAt),
);
await this.deliver(user, targets, counts, countedAt);
} catch (error) {
this.reportFailure(user, error);
@@ -473,44 +511,82 @@ export class NewMailPushService
*/
private async catchUp(user: string): Promise<void> {
try {
// 붙잡은 뒤 새 메일 신호를 이미 받았다 — 신호 쪽이 그때의 N 으로 보낸다(같은 메일로 두 번 울리지 않게 — 리뷰 N1)
if (this.signaledSinceHold.has(user)) return;
const targets = await this.registrations.targetsOf(user);
if (targets.length === 0) return;
const countedAt = new Date();
const windowStart = countedAt.getTime() - CATCH_UP_WINDOW_MS;
const freshSince = targets.map((target) =>
Math.max(
nextSecondOf(target.seenAt),
nextSecondOf(target.lastPushedAt),
windowStart,
),
);
const times = [
...new Set([
...freshSince,
...targets.map((target) => target.seenAt.getTime()),
]),
];
const counted = await this.provider.countInboxUnreadSince(
const counts = await this.freshCounts(
user,
times.map((time) => new Date(time)),
);
const countAt = new Map(
times.map((time, index) => [time, counted[index] ?? 0]),
);
const counts = targets.map((target, index) =>
(countAt.get(freshSince[index]) ?? 0) > 0
? (countAt.get(target.seenAt.getTime()) ?? 0)
: 0,
targets,
countedAt.getTime() - CATCH_UP_WINDOW_MS,
);
// 세는 사이 신호가 왔다 — 위와 같다
if (this.signaledSinceHold.has(user)) return;
if (counts.every((count) => count < 1)) return;
// 따라잡기도 간격에 든다 — 바로 뒤의 새 메일은 간격 끝에 한 번
this.pacingOf(user).lastFlushAt = Date.now();
await this.deliver(user, targets, counts, countedAt);
// 따라잡기도 간격에 든다 — 바로 뒤의 새 메일은 간격 끝에 한 번, 그 보내기는 이 따라잡기가 센 뒤 새로 받은 메일이 있는 기기에만.
// 보내는 동안의 신호도 그 규칙을 타게 보낼 기기 모두로 먼저 적고, 보낸 뒤 실제로 보낸 기기로 좁힌다(그사이 보내기가 가져갔으면 그대로)
const pacing = this.pacingOf(user);
pacing.lastFlushAt = Date.now();
const planned: CatchUpPushed = {
sessions: new Set(
targets
.filter((_, index) => (counts[index] ?? 0) >= 1)
.map((target) => target.sessionId),
),
countedAt,
};
pacing.afterCatchUp = planned;
const pushed = await this.deliver(user, targets, counts, countedAt);
if (this.pacing.get(user) === pacing && pacing.afterCatchUp === planned) {
pacing.afterCatchUp =
pushed.length > 0 ? { sessions: new Set(pushed), countedAt } : null;
}
} catch (error) {
this.reportFailure(user, error);
}
}
/**
* 기기마다 N — 단 "새로 온 것"(본 때 · 마지막으로 보낸 때의 다음 초부터, `windowStart` 뒤 — 따라잡기가 보낸 기기는 그 센 때의 다음 초부터도)이
* 없는 기기는 0 (따라잡기 · 따라잡기 뒤 첫 보내기). 엔진에는 기기마다 두 시각(새로 온 것을 볼 시작 · N 의 시작)을 한 번에 묻는다(같은 시각은 한 번만)
*/
private async freshCounts(
user: string,
targets: readonly PushTarget[],
windowStart: number,
afterCatchUp: CatchUpPushed | null = null,
): Promise<number[]> {
const freshSince = targets.map((target) =>
Math.max(
nextSecondOf(target.seenAt),
nextSecondOf(target.lastPushedAt),
afterCatchUp?.sessions.has(target.sessionId)
? nextSecondOf(afterCatchUp.countedAt)
: 0,
windowStart,
),
);
const times = [
...new Set([
...freshSince,
...targets.map((target) => target.seenAt.getTime()),
]),
];
const counted = await this.provider.countInboxUnreadSince(
user,
times.map((time) => new Date(time)),
);
const countAt = new Map(
times.map((time, index) => [time, counted[index] ?? 0]),
);
return targets.map((target, index) =>
(countAt.get(freshSince[index]) ?? 0) > 0
? (countAt.get(target.seenAt.getTime()) ?? 0)
: 0,
);
}
/**
* N 이 1 이상인 기기에 보낸다 — 보낸 기기는 그 N 을 센 때를 적고, 받지 않는 기기는 등록을 지운다.
* 보내기 직전(다시 하기마다) 그 세션의 등록이 아직 그 FID 이고 세션이 살아 있는지 다시 본다 — 그사이 그 휴대폰이 다른 직원에게 옮겨 갔으면 보내지 않는다
@@ -520,7 +596,7 @@ export class NewMailPushService
targets: readonly PushTarget[],
counts: readonly number[],
countedAt: Date,
): Promise<void> {
): Promise<string[]> {
const pushed: string[] = [];
const pushedCounts: number[] = [];
await Promise.all(
@@ -545,11 +621,12 @@ export class NewMailPushService
}
}),
);
if (pushed.length === 0) return;
if (pushed.length === 0) return pushed;
await this.registrations.recordPushed(pushed, countedAt);
this.logger.debug(
`앱 알림: user=${maskEmail(user)}, 기기 ${pushed.length}, N ${pushedCounts.join('·')}`,
);
return pushed;
}
private async forget(user: string, target: PushTarget): Promise<void> {
@@ -63,7 +63,7 @@ describe('fcmMessageOf (앱 알림의 FCM 메시지 — 계약 1절 "푸시 모
).toBe(true);
});
it('로그인이 저절로 끝남 — 같은 tag · 채널 · collapse_key 로 옛 "새 메일 N통"을 바꿔 쓴다, 제목 · 본문은 초안 글, 개수 칸 없음', () => {
it('세션 끝 알림(SESSION_ENDED_NOTICE 의 까닭) — 같은 tag · 채널 · collapse_key 로 옛 "새 메일 N통"을 바꿔 쓴다, 제목 · 본문은 확정 글(push-text.ts), 개수 칸 없음', () => {
expect(fcmMessageOf(FID, { kind: 'session-ended' })).toEqual({
fid: FID,
notification: {
+1 -1
View File
@@ -67,7 +67,7 @@ export function fcmMessageOf(fid: string, notice: PushNotice): FcmMessage {
},
};
}
// 로그인이 저절로 끝났다 — 같은 tag · 채널로 옛 "새 메일 N통"을 바꿔 쓴다. 급하지 않아 보통 우선순위
// 그 휴대폰을 건드리지 않았는데 앱 세션이 끝났다(까닭은 push.constants.ts 의 SESSION_ENDED_NOTICE) — 같은 tag · 채널로 옛 "새 메일 N통"을 바꿔 쓴다. 급하지 않아 보통 우선순위
return {
fid,
notification: {
+1 -1
View File
@@ -4,7 +4,7 @@
*/
export const PUSH_SENDER = Symbol('PUSH_SENDER');
/** 보낼 알림 — 새 메일 N통 · 로그인이 저절로 끝남 */
/** 보낼 알림 — 새 메일 N통 · 그 휴대폰을 건드리지 않았는데 앱 세션이 끝남(까닭은 push.constants.ts 의 SESSION_ENDED_NOTICE) */
export type PushNotice =
| { readonly kind: 'new-mail'; readonly count: number }
| { readonly kind: 'session-ended' };
+1 -1
View File
@@ -14,7 +14,7 @@ export function newMailTitle(count: number): string {
/**
* 그 휴대폰을 직원이 건드리지 않았는데 앱 세션이 끝났을 때(저절로 끝남 · 다른 곳에서 비밀번호를 바꿈 · 앱 갱신 유예 · 토큰 재사용 감지)
* 그 휴대폰에 한 번 보내는 알림 — 초안이다(보드 `App1Push` ⑦ 에서 확정되면 바꾼다). 보내는 까닭은 push.constants.ts 의 SESSION_ENDED_NOTICE 한 곳.
* 그 휴대폰에 한 번 보내는 알림 — 글은 보드 `App1Push` ⑧(2026-11-07 확정) 그대로다. 보내는 까닭은 push.constants.ts 의 SESSION_ENDED_NOTICE 한 곳.
* 직원 · 운영자가 끊은 세션 · 계정을 쓸 수 없는 세션 · 제한된 세션에는 보내지 않는다
*/
export const SESSION_ENDED_TEXT = {