Skip to content

푸시 알림 내역

작성: 0chil

관리자 캠페인 알림 발송은 CAMPAIGN 타입과 pushNotificationCampaignId로 발송 건을 구분한다. 캠페인 ID는 사용자 내역의 nullable push_notification_campaign_id 컬럼과 조회 응답에 기록하며 기존 타입은 null이다. 내역 JSON 버전은 1을 유지한다. 캠페인 원본은 별도로 보관하고 사용자별 내역은 기존 30일 보관 정책을 따른다.

하위 항목이 ON인 알림은 FCM 전송 가능 여부와 관계없이 사용자별 내역 한 건을 생성한다. 설정 대상이 아닌 일반·옷장정리 알림도 같은 발송 내역 처리를 따른다. 실제 발송 직전 항목이 OFF이면 FCM 전송과 내역 생성을 모두 생략한다. 예약 시점에는 내역을 생성하지 않는다.

FCMPushChannel이 발송 처리 시작 시 sentDeviceCount = 0인 내역을 주입받은 기본 TransactionTemplate으로 먼저 저장하고 같은 트랜잭션에서 회원의 미확인 개수를 1 증가시킨다. 둘 중 하나가 실패하면 함께 롤백하며 FCM 네트워크 호출은 템플릿 블록 밖에서 수행한다. 기본 REQUIRED 전파를 사용하므로 호출자 트랜잭션이 있으면 참여하고, 작업 큐처럼 호출자 트랜잭션이 없으면 별도 트랜잭션을 시작한다. 허용된 기기가 없으면 FCM을 호출하지 않고 0건 내역을 유지한다. 작업 큐 경로에서는 전부 실패하거나 FCM 호출 자체가 오류여도 먼저 커밋한 내역은 유지하며, 호출 오류는 기존 작업 실패 처리로 전달한다. 호출자 트랜잭션에 참여한 경우에는 호출자의 롤백과 함께 내역·미확인 개수도 롤백한다.

응답을 받으면 BatchResponse.successCount를 실제 발송 개수로 저장한다. 이는 FCM 성공 응답 기기 수이며 실제 기기 표시·수신·읽음을 보장하는 값은 아니다. FCMPushChannel은 같은 기본 TransactionTemplate을 다시 실행하여 성공 기기별 push_message_log 저장과 내역의 성공 건수 갱신을 Repository에 직접 요청한다. 성공 로그는 notification_history_id로 최초 내역에 연결한다. messageId가 누락돼 로그를 남기지 못해도 성공 개수는 FCM 응답 값을 사용한다. 결과 저장 실패는 성공 개수와 로그를 함께 롤백한다. 호출자 트랜잭션이 없는 작업 큐 경로에서는 먼저 커밋한 최초 0건 내역은 보존한다.

sentAt은 실제 발송 처리 시작 시 계산하는 시각이며 FCM 성공 여부나 실제 기기 수신 시각을 의미하지 않는다. 엔티티의 createdAt·updatedAt은 BaseEntityKt와 JPA auditing으로 관리한다. JDBC로 발송 개수·읽음 상태·클릭 횟수·미확인 개수를 갱신할 때 updated_at도 함께 갱신한다.

MemberPushNotificationCount는 회원 ID를 PK로 사용하여 unreadCount를 저장한다. 직접 할당 PK 규칙에 따라 Persistable<Long>과 BaseEntityKt를 사용한다. 첫 내역 생성 시 JdbcClient의 원자적 upsert로 집계를 생성·증가시킨다. 집계 행이 없으면 조회 결과는 0이다. 기기 수나 FCM 성공 수가 아닌 사용자 내역 한 건당 1 증가하며 예약 단계와 설정 OFF로 생략한 알림은 집계하지 않는다.

미확인 개수 조회도 JdbcClient를 사용한다. 같은 영속성 컨텍스트에서 집계 엔티티를 미리 읽었더라도 JDBC 갱신 후의 값을 반환한다.

내역은 markAsRead와 pushClickCount를 저장한다. 새 내역은 각각 false, 0으로 시작한다. 최초 읽음이 기기 푸시 클릭이면 pushClickCount도 1 증가시키고, 내역 목록에서의 읽음은 별도 클릭 횟수 없이 markAsRead만 변경한다. 두 경로 모두 최초 읽음 전환에서만 미확인 개수를 1 차감한다. 이미 읽은 내역의 푸시 클릭은 클릭 수를 추가로 늘리지 않는다.

readCount는 엔티티와 테이블에 저장하지 않는다. 기존 응답 필드는 유지하되 markAsRead=false이면 0, true이면 1을 반환한다. 반복 클릭 횟수와 관계없이 읽음 여부를 표현한다. 사용자별 pushClickCount는 응답에 노출하지 않는다. 모두 읽음 기능은 제공하지 않는다.

푸시 발송 시 내역 저장·미확인 개수 증가와 같은 트랜잭션에서 증가 직후의 개수를 읽는다. 이 값을 iOS의 apns.payload.aps.badge와 Android의 android.notification.notification_count에 설정하고, 같은 회원의 발송 대상 기기에 동일하게 전달한다. data.badgeCount는 추가하지 않으며 기존 linkTo·extras와 iOS의 mutable-content 설정은 유지한다. SDK의 정수 범위에 맞춰 배지 전송 값만 0..Int.MAX_VALUE로 제한하고 DB 집계는 보정하지 않는다.

배지는 해당 발송의 집계 시점 값을 전달한다. 푸시 도착 순서나 이후 읽음 처리까지 동기화하는 값은 아니므로, 앱에서 읽음 처리 후 아이콘 배지를 갱신할 때는 미확인 개수 조회 API를 사용한다. Android의 실제 숫자 표시는 런처의 배지 지원에 따라 달라진다.

두 클릭 경로 모두 본인 소유와 mark_as_read = false 조건으로 읽음 상태를 먼저 UPDATE한다. 이미 읽었거나 본인 내역이 없으면 추가 조회나 예외 없이 성공 처리한다. 읽음 상태가 실제 변경된 경우에만 미확인 개수를 차감하고 캠페인 통계를 갱신한다. 푸시 클릭 경로라면 같은 내역 행 잠금을 유지한 채 클릭 수도 증가시킨다. 모든 변경은 한 트랜잭션에서 처리하며 내역을 먼저 잠그고 집계를 갱신한다. 반복 클릭과 경로 간 동시 클릭도 최초 한 번만 차감한다. 집계 행이 없거나 차감량보다 작으면 실패시켜 트랜잭션 전체를 롤백한다.

예약 발송의 당일 중복 방지는 사용자 내역만 확인한다. 기기별 로그는 중복 판단에 사용하지 않는다. 따라서 기기가 없거나 발송 성공이 0건이어도 예약 작업 재실행으로 내역을 다시 만들지 않는다.

설정·발송·내역은 push.core.domain.PushNotificationType을 공유한다. 기존 설정 6종에 GENERAL 및 다음 옷장정리 상태 알림을 추가한다.

  • CLOSET_CLEANOUT_OUTBOUND_IN_TRANSIT
  • CLOSET_CLEANOUT_OUTBOUND_DELIVERED
  • CLOSET_CLEANOUT_INBOUND_PICKED_UP
  • CLOSET_CLEANOUT_SETTLEMENT_COMPLETED

장바구니 품절 위험과 다시 알림은 모두 CART_REMINDER다. 관리자 요청 및 기존 예약·작업 큐 JSON에 type이 없으면 GENERAL로 처리한다. 제목으로 타입을 추정하지 않는다. 사용자 설정은 PushNotificationDefaults.typeToItemDefaults에 포함된 기존 6종만 지원한다. 추가 타입은 설정 응답과 가입 시 항목 초기화에서 제외하고, 항목 ON/OFF·빈도 변경은 저장 전 400으로 거부한다. 구형 신상품 발송 작업은 제거했으며 발송 판단은 새 하위 설정만 사용한다.

내역의 rawMessage만 {"schemaVersion":1,"message":{...}} 형태로 저장한다. message에는 발송한 PushMessage의 JSON을 넣고, 바깥의 schemaVersion은 내역 저장 형식의 버전이다. 기기 로그·예약·작업 큐의 JSON과 FCM data payload에는 이 wrapper를 적용하지 않는다. PushNotificationHistoryMessageSerializer는 바깥의 버전을 먼저 확인한 뒤, V1의 message를 발송 DTO와 분리된 PushNotificationHistoryMessageV1으로 읽는다. 버전과 메시지가 있는 wrapper만 지원하며, 버전이 누락되거나 잘못된 형식이면 유효하지 않은 내역으로 처리한다. 발송 DTO에 필드가 추가되거나 변경돼도 V1 조회 형식은 유지하고, 저장 형식의 호환성이 깨질 때 새 버전을 추가한다. JSON을 읽을 수 없거나 지원하지 않는 버전인 항목은 경고 로그를 남기고 조회 응답에서 제외한다.

모든 API는 인증된 본인만 대상으로 한다.

메서드경로동작
GET/push-notifications/history?lastCursor.notificationHistoryId=123&pageSize=20보관 중인 본인 내역의 커서 조회
GET/push-notifications/history/count본인의 미확인 개수 조회
POST/push-notifications/history/{notificationHistoryId}/opens최초 읽음 처리
POST/notifications/opens기존 messageId로 기기 로그 열기 및 연결 내역의 기기 클릭 수 증가·최초 읽음 처리

조회는 ID 내림차순이다. 첫 페이지는 커서 파라미터를 생략한다. pageSize는 필수이며 1~100이다. 공용 요청은 CompositeCursorPaginationRequest<C>, 응답은 기존 CompositeCursorScrollResponse<T, C>를 사용한다. 알림 내역 패키지의 PushNotificationHistoryCursor가 {notificationHistoryId} 형식을 정의한다. 컨트롤러의 PushNotificationHistoryPaginationRequest는 공용 Generic 요청을 상속하며, 생성자에 구체 커서 타입을 명시해 Spring의 기본 바인딩을 사용한다. 서비스는 공용 Generic 요청을 받는다. 응답의 lastCursor는 숫자 대신 {"notificationHistoryId":123} 객체이며, elements의 각 항목은 다음 필드를 가진다.

id, type, title, content, linkTo, extras, imageUrl, sentAt, sentDeviceCount, markAsRead, readCount.

미확인 개수 API는 200 {"unreadCount": 0} 형태로 반환한다. 회원 ID는 인증 정보에서만 가져온다. markAsRead는 내역의 읽음 여부이며 readCount는 같은 상태를 숫자 0 또는 1로 표현한다.

sentDeviceCount는 새 내역에서 0 이상의 정수다. 과거 내역은 sentDeviceCount: null을 명시적으로 반환한다. 과거 기기 로그로 실제 성공 개수를 완전히 복원할 수 없으므로 백필하지 않는다.

hasNext와 lastCursor는 읽기에 실패한 항목을 포함해 조회한 행을 기준으로 계산한다. 따라서 elements가 비어 있어도 hasNext가 true이면 lastCursor로 다음 페이지를 조회할 수 있다.

내역 ID로 읽으면 조건부 읽음 UPDATE를 수행하고, 최초 읽음인 경우에만 집계를 차감한다. 본문 없는 200을 반환한다. 이미 읽었거나 미존재·타인 소유인 내역은 변경 없이 성공 처리한다. 기존 /notifications/opens의 요청 본문 {"messageId":"..."}와 본문 없는 200 응답을 유지한다. PushService.recordPushClick에서 인증 사용자와 messageId로 로그를 조회한다. 로그 Repository는 해당 로그의 최초 열기 시각만 기록하고, 내역 서비스는 연결된 본인 내역을 최초 읽음 처리한 경우에만 기기 클릭 수와 집계를 갱신한다. 로그 조회·로그 열기·내역 변경·집계 차감은 서비스의 한 트랜잭션으로 처리하며 Repository 간 JOIN은 사용하지 않는다. 연결 내역이 삭제됐어도 기기 로그의 열기는 기록하고 내역 집계는 건너뛴다. 읽음 전환 실패는 반복 클릭과 내역 삭제를 구분하지 않으며 관리 경고를 남기지 않는다. 로그가 없거나 타인의 로그이면 아무 것도 변경하지 않고 기존처럼 200을 반환한다.

기기 푸시 클릭 수는 최초 읽음이 푸시 클릭인 경우에만 증가한다. 내역 목록에서의 읽음 횟수는 저장하지 않는다. 이미 읽은 내역을 다시 클릭해도 클릭 수와 미확인 개수는 변경하지 않는다. 푸시 클릭은 기존 messageId 기반 API를 그대로 사용하고, 알림 목록에서 열기는 새 내역 ID 기반 API를 사용한다. 한 번의 열기에 두 API를 함께 호출하지 않는다. FCM payload와 푸시 클릭 요청에 새 ID를 추가할 필요는 없다.

보관 기간은 삭제 작업에서만 관리한다. 조회와 읽음에는 별도 기간 조건을 적용하지 않는다. PushNotificationHistoryCleanupScheduler는 batchTaskScheduler에서 매일 03:00 Asia/Seoul 기준으로 PushNotificationHistoryCleanupJob을 enqueue한다. payload에 enqueue 시점의 30일 전 시각을 고정한다. 핸들러는 내역 서비스에 이 시각보다 오래된 사용자 내역을 1,000건씩 정리하도록 요청한다. 각 청크는 서비스의 별도 REQUIRES_NEW 트랜잭션에서 실행한다. 삭제 대상은 sent_at 인덱스를 사용해 발송 시각과 ID 순으로 잠금 조회한다. 미확인 내역을 회원별로 집계한 뒤 삭제하고, 회원 ID 순으로 미확인 개수를 차감한다. 삭제와 차감은 함께 커밋하거나 롤백한다. 재실행과 클릭·삭제 경합에서도 중복 차감하지 않는다.

만료에 따른 미확인 개수 차감은 실제 삭제 시점에 수행한다. 30일이 지났어도 다음 정리 작업까지 남아 있는 내역은 조회·읽음 대상이며 미확인 집계에도 포함한다.

조회와 삭제는 기기 로그를 참조하지 않는다. 기기 로그와 그 안의 내역 ID는 계속 보관한다. 연결 컬럼은 nullable scalar ID이며 DB 외래 키나 cascade 삭제를 사용하지 않는다. 내역이 삭제된 이후에도 기존 messageId 기반 API는 기기 로그의 최초 열기 시각을 기록할 수 있다.

제어판의 모바일 앱 → 알림 내역 집계 갱신에서 전체 회원의 미확인 개수를 다시 계산할 수 있다. 실행 확인 후 POST /admin/push-notifications/history/count-refresh-jobs로 작업을 등록하며, ADMIN 권한을 요구하고 본문 없는 202를 반환한다. 화면의 등록 성공은 작업 완료를 의미하지 않는다.

PushNotificationCountRefreshJob은 회원 ID 오름차순 커서로 1,000명씩 조회한다. 각 청크는 별도 트랜잭션에서 현재 남아 있는 mark_as_read = false 내역을 세어 집계를 생성하거나 덮어쓴다. 내역이 없거나 모두 읽은 회원은 0으로 갱신한다. 만료됐지만 아직 삭제되지 않은 내역도 포함하고, 내역의 읽음 상태와 클릭 횟수는 변경하지 않는다. 실패 후 재시도하거나 수동으로 다시 실행해도 같은 방식으로 재계산한다. 진행한 회원 수와 마지막 회원 ID는 작업 로그에 남긴다.

집계 갱신은 REPEATABLE_READ 트랜잭션의 INSERT ... SELECT ... ON DUPLICATE KEY UPDATE를 사용한다. 원본 내역의 shared next-key lock을 유지하면서 집계를 갱신해 발송·클릭·삭제와의 경합에서 이전 개수로 덮어쓰지 않는다. 이 잠금 동작은 MySQL 문서를 따르며 MySQL 통합 테스트에서 검증한다.

일반 차감에서 집계 부족·누락을 발견하면 기존처럼 롤백한다. 불일치를 0으로 감추지 않고 재집계 작업으로 복구한 뒤 실패한 작업을 재실행한다.

운영은 ddl-auto=validate이다. 기존 미확인 집계 스키마에서 내역 클릭 횟수 컬럼 삭제 SQL을 적용한다. 알림 발송·읽음·만료 정리 작업을 잠시 중단하고 SQL과 새 API·워커 배포를 완료한 뒤 재개한다. 기존 push_history_click_count가 NOT NULL이므로 이 컬럼을 매핑하지 않는 새 버전으로 쓰기를 재개하기 전에 삭제해야 한다. 구버전과 새 스키마를 함께 사용하지 않는다.

이 SQL은 push_history_click_count만 삭제한다. 기존 mark_as_read, push_click_count, 미확인 개수와 발송 결과는 변경하지 않는다. 응답 readCount는 기존 읽음 상태를 기준으로 즉시 0 또는 1이 되며 누적 클릭 횟수를 이관하거나 정규화하지 않는다. 내역 JSON 형식·30일 보관 정책과 캠페인 통계는 유지한다. 설정 전환과 전체 설정 테이블 삭제 순서는 푸시 알림 설정의 배포 절차를 따른다.

Repository 테스트는 최초 집계의 동시 upsert, 차감 부족 시 실패, 엔티티를 먼저 조회한 트랜잭션에서도 JDBC 갱신 후 개수를 읽는 동작을 검증한다. 내역 서비스 테스트는 경로별 클릭·반복 및 동시 클릭·과거 내역·접근 제한·정리 청크·클릭과 삭제의 경합·롤백을 검증한다. 발송 테스트는 기기 없음·발송 실패·다중 기기에서도 사용자 내역당 한 번 증가하는지와 트랜잭션 경계를 확인한다. API 테스트는 인증·본인 집계·읽음 여부와 0/1 읽음 수 제공·내부 클릭 횟수 미노출을 검증한다.

FCM 전송 JSON 테스트는 최초·기존 미확인 개수에 새 알림이 포함된 배지 값, 배지 전송 값의 음수·정수 상한 처리와 DB 원본 개수 보존, iOS의 mutable-content와 기존 data payload 보존을 검증한다.

재집계 Repository 테스트는 누락 집계 생성·과다 집계의 0 보정·반복 실행·만료 미삭제 내역 포함·대상 외 회원 보존·회원 커서를 검증한다. MySQL에서는 진행 중인 발송·읽음·삭제를 기다린 뒤 최신 개수로 반영하는지도 검증한다. 이 잠금 테스트는 H2에서 건너뛴다. Job 테스트는 1,000명 단위 처리와 ID가 연속하지 않는 마지막 청크까지 진행하는 동작을, 관리자 API 테스트는 권한과 비동기 등록 응답을 검증한다.