Files
sodalive-backend-spring-boot/docs/20260311_푸시알림리스트구현.md

180 lines
12 KiB
Markdown

- [x] 요구사항 확정: 푸시 발송 내용을 알림 리스트에 적재하고, 미수신 상황에서도 조회 가능하도록 범위를 고정한다.
- [x] 도메인 모델 설계: 알림 본문/발송자 스냅샷/카테고리/딥링크/언어코드/수신자 청크(JSON 배열) 저장 구조를 JPA 엔티티로 정의한다.
- [x] 푸시 적재 로직 구현: 수신자가 없으면 저장하지 않고, 언어별 데이터로 분리 저장하며 수신자 ID를 청크 단위(JSON 배열)로 기록한다.
- [x] 조회 기간 제한 구현: 알림 조회는 최근 1개월 데이터만 조회하도록 서비스/리포지토리에 공통 조건을 적용한다.
- [x] API 구현: 인증 사용자 기준 알림 목록 조회 API(전체/카테고리별)와 알림 존재 카테고리 조회 API를 구현한다.
- [x] 카테고리 다국어 응답 구현: 카테고리 조회 API 응답을 현재 기기 언어(ko/en/ja) 라벨로 반환한다.
- [x] 페이징 구현: Pageable 파라미터를 사용해 offset/limit 기반 조회를 적용한다.
- [x] 시간 포맷 구현: 발송시간을 UTC 기반 String으로 응답 DTO에 포함한다.
- [x] TDD 구현: 스프링 컨테이너 없이 실행 가능한 단위 테스트를 먼저 작성하고, 구현 후 테스트를 통과시킨다.
- [x] SQL 문서화: 신규 테이블 생성 SQL 및 추가 인덱스 SQL(MySQL, TIMESTAMP NOT NULL)을 문서 하단에 기록한다.
## API 상세 작업 계획
### 1) GET `/push/notification/list`
- 목적: 인증 사용자의 알림 리스트를 현재 기기 언어 기준으로 조회한다.
- 요청 파라미터:
- `page`, `size`, `sort` (Pageable)
- `category` (선택, 없으면 전체 조회)
- 처리 규칙:
- 인증 사용자(`Member?`) null이면 `SodaException(messageKey = "common.error.bad_credentials")`
- 현재 요청 언어(`LangContext.lang.code`)와 일치하는 알림만 조회
- 조회 범위는 `now(UTC) - 1개월` 이후 데이터만 허용
- `category` 미지정 시 전체 카테고리 조회
- `category`는 코드(`live`) 또는 다국어 라벨(`라이브`/`Live`/`ライブ`) 입력을 허용한다
- `category``전체`/`All`/`すべて`이면 전체 카테고리 조회로 처리한다
- 수신자 청크(JSON 배열)에 인증 사용자 ID가 포함된 알림만 조회
- 응답 항목:
- 발송자 스냅샷(닉네임, 프로필 이미지)
- 발송 메시지
- 카테고리
- 딥링크
- 발송시간(UTC String)
- 구현 작업:
- [x] Controller: 인증/파라미터/ApiResponse 처리
- [x] Service: 1개월/언어/카테고리/페이지 조건 조합
- [x] Repository: 수신자 청크 JSON membership + pageable 조회 + totalCount
- [x] DTO: `GetPushNotificationListResponse`, `PushNotificationListItem` 정의
### 2) GET `/push/notification/categories`
- 목적: 인증 사용자 기준으로 알림 데이터가 실제 존재하는 카테고리만 조회한다.
- 요청 파라미터: 없음
- 처리 규칙:
- 인증 필수
- 현재 요청 언어 기준 데이터만 대상
- 최근 1개월 데이터만 대상
- 수신자 청크(JSON 배열)에 인증 사용자 ID가 포함된 데이터만 대상
- 응답 항목:
- 카테고리 목록(현재 기기 언어 라벨)
- 구현 작업:
- [x] Controller: 인증/ApiResponse 처리
- [x] Service: 중복 제거된 카테고리 목록 반환
- [x] Repository: 사용자/언어/기간 기반 카테고리 distinct 조회
- [x] DTO: `GetPushNotificationCategoryResponse` 정의
## 비API 작업 계획
- [x] FCM 이벤트 모델 확장: 알림 리스트 적재에 필요한 카테고리/발송자 스냅샷 정보를 이벤트에 포함한다.
- [x] FCM 전송 리스너 연동: 언어별 푸시 전송 시점에 알림 리스트 저장 서비스를 호출한다.
- [x] 발송자 스냅샷 처리: 이벤트 스냅샷 우선 사용, 없으면 발송자 ID 기반 조회로 보완한다.
- [x] 딥링크 저장 처리: 현재 푸시 딥링크 규칙과 동일한 값으로 저장한다.
- [x] 수신자 청크 저장 처리: 수신자 ID를 고정 크기 청크로 분할해 JSON 배열로 저장한다.
- [x] 수신자 미존재 처리: 최종 수신자 ID가 비어 있으면 알림 자체를 저장하지 않는다.
## 테스트(TDD) 계획
- [x] 단위 테스트: 알림 저장 서비스가 수신자 없음/언어별 분리/청크 분할/스냅샷 저장을 정확히 처리하는지 검증한다.
- [x] 단위 테스트: 조회 서비스가 1개월 제한/언어 필터/카테고리 옵션/pageable 전달을 정확히 적용하는지 검증한다.
- [x] 단위 테스트: 카테고리 조회 서비스가 사용자/언어/기간 기준 distinct 결과를 반환하는지 검증한다.
- [x] 단위 테스트: 컨트롤러가 인증 실패 시 에러 응답을 반환하고, 정상 시 서비스 호출 파라미터를 올바르게 전달하는지 검증한다.
## SQL 초안 (구현 확정)
### 1) 신규 테이블 생성 SQL (MySQL)
```sql
CREATE TABLE push_notification_list
(
id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT 'PK',
sender_nickname_snapshot VARCHAR(255) NOT NULL COMMENT '발송자 닉네임 스냅샷',
sender_profile_image_snapshot VARCHAR(500) NULL COMMENT '발송자 프로필 이미지 스냅샷',
message TEXT NOT NULL COMMENT '발송 메시지',
category VARCHAR(20) NOT NULL COMMENT '발송 카테고리',
deep_link VARCHAR(500) NULL COMMENT '딥링크',
language_code VARCHAR(8) NOT NULL COMMENT '언어 코드',
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '생성 시각(UTC)',
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '수정 시각(UTC)'
) COMMENT ='푸시 알림 리스트';
CREATE TABLE push_notification_recipient_chunk
(
id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT 'PK',
notification_id BIGINT NOT NULL COMMENT '알림 ID',
recipient_member_ids JSON NOT NULL COMMENT '수신자 회원 ID 청크(JSON 배열)',
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '생성 시각(UTC)',
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '수정 시각(UTC)',
CONSTRAINT fk_push_notification_recipient_chunk_notification
FOREIGN KEY (notification_id) REFERENCES push_notification_list (id)
) COMMENT ='푸시 알림 수신자 청크';
```
### 2) 추가 인덱스 SQL (MySQL)
```sql
ALTER TABLE push_notification_list
ADD INDEX idx_push_notification_list_language_created (language_code, created_at, id),
ADD INDEX idx_push_notification_list_category_language_created (category, language_code, created_at, id);
ALTER TABLE push_notification_recipient_chunk
ADD INDEX idx_push_notification_recipient_chunk_notification (notification_id);
-- MySQL 8.0.17+ 환경에서 JSON 배열 membership 최적화가 필요할 때 사용
ALTER TABLE push_notification_recipient_chunk
ADD INDEX idx_push_notification_recipient_chunk_member_ids_mvi ((CAST(recipient_member_ids AS UNSIGNED ARRAY)));
```
#### MVI 조건부 적용 가이드 (짧게)
- MySQL 8.0.17+ 환경이면 인덱스를 먼저 추가해 둔다.
- 실제 사용 여부는 옵티마이저가 쿼리 조건과 비용을 보고 결정하므로 `EXPLAIN`으로 확인한다.
- 현재 조회 조건처럼 `JSON_CONTAINS(JSON컬럼, JSON_ARRAY(값), '$')` 형태일 때 사용 후보가 된다.
- 인덱스가 선택되지 않아도 기능 오동작은 없지만, 쓰기/저장공간 비용은 항상 발생한다.
## 검증 기록
### 1차 구현
- 무엇을: 푸시 발송 시 언어별 메시지를 알림 리스트로 적재하는 `PushNotificationService`와 관련 JPA 엔티티/리포지토리/조회 API 2종(`/push/notification/list`, `/push/notification/categories`)을 추가하고, 기존 `FcmEvent` 발행 지점에 카테고리/발송자 스냅샷 소스를 연결했다.
- 왜: 푸시를 놓친 사용자도 최근 1개월 내 알림을 현재 기기 언어 기준으로 확인하고, 카테고리별 필터/카테고리 존재 여부를 조회할 수 있어야 하기 때문이다.
- 어떻게:
- `./gradlew test --tests "*PushNotification*"` 실행(성공)
- `./gradlew test` 실행(성공)
- `./gradlew build` 실행(초기 실패: ktlint import 정렬 위반)
- `./gradlew ktlintFormat` 실행(성공)
- `./gradlew test` 재실행(성공)
- `./gradlew build` 재실행(성공)
- Kotlin LSP 미구성으로 `lsp_diagnostics`는 실행 불가, Gradle test/build/ktlint로 대체 검증
### 2차 수정
- 무엇을: `PushNotificationRecipientChunk``chunkOrder` 필드를 제거하고, 저장 로직/문서 SQL(컬럼 및 인덱스)을 함께 정리했다.
- 왜: 저장 시점에만 값이 할당되고 조회/정렬/필터에서 실제 사용되지 않아 불필요한 데이터였기 때문이다.
- 어떻게:
- `./gradlew test --tests "*PushNotification*"` 실행(성공)
- `./gradlew build` 실행(성공)
### 3차 수정
- 무엇을: 알림 리스트 조회를 `PushNotificationListRow -> service map` 구조에서 `PushNotificationListItem` 직접 프로젝션 구조로 변경하고, 조회/카운트 쿼리에서 `innerJoin + distinct/countDistinct`를 제거해 `EXISTS` 기반 JSON membership 필터로 최적화했다.
- 왜: 중간 변환 객체가 불필요하고, 조인 기반 중복 제거 비용(distinct/countDistinct)이 커질 수 있어 페이지 조회 성능을 개선하기 위해서다.
- 어떻게:
- `./gradlew test --tests "*PushNotification*"` 실행(성공)
- `./gradlew build` 실행(성공)
### 4차 수정
- 무엇을: `sentAt` 포맷을 DB `DATE_FORMAT` 문자열 생성 방식에서 `PushNotificationListItem` QueryProjection 생성자 기반 UTC Instant 문자열(`...Z`) 생성 방식으로 변경했다.
- 왜: `GetLatestFinishedLiveResponse.dateUtc`와 동일하게 애플리케이션 레이어에서 명시적 UTC 변환을 적용해 포맷/의미 일관성을 맞추기 위해서다.
- 어떻게:
- `./gradlew test --tests "*PushNotification*"` 실행(성공)
- `./gradlew build` 실행(성공)
### 5차 수정
- 무엇을: `getAvailableCategories`가 카테고리 코드를 그대로 반환하던 동작을, 현재 기기 언어(`ko/en/ja`)에 맞는 카테고리 라벨을 반환하도록 변경했다.
- 왜: 카테고리 조회 응답을 조회 기기 언어에 따라 한글/영어/일본어로 내려주어야 하기 때문이다.
- 어떻게:
- `./gradlew test --tests "*PushNotification*"` 실행(성공)
- `./gradlew build` 실행(성공)
### 6차 수정
- 무엇을: `getAvailableCategories` 응답 리스트 맨 앞에 `전체` 항목을 고정 추가하고, `ko/en/ja` 다국어 라벨로 반환하도록 변경했다.
- 왜: 카테고리 필터 UI에서 전체 조회 옵션이 항상 첫 번째로 필요하기 때문이다.
- 어떻게:
- `./gradlew test --tests "*PushNotification*"` 실행(성공)
- `./gradlew build` 실행(성공)
### 7차 수정
- 무엇을: `getNotificationList``category` 입력이 한글/영어/일본어 라벨(`라이브`/`Live`/`ライブ` 등)도 파싱되도록 확장하고, `전체`/`All`/`すべて` 입력은 전체 조회로 처리하도록 수정했다.
- 왜: 카테고리 조회 API가 다국어 라벨을 반환하므로, 목록 조회 API도 동일 라벨 입력을 처리할 수 있어야 하기 때문이다.
- 어떻게:
- `./gradlew test --tests "*PushNotificationServiceTest"` 실행(성공)
- `./gradlew test --tests "*PushNotification*"` 실행(성공)
- `./gradlew build` 실행(성공)
### 8차 수정
- 무엇을: 추가 인덱스 SQL 하단에 MVI 인덱스의 조건부 사용 가이드를 짧게 추가했다.
- 왜: 인덱스는 선반영 가능하지만 실제 사용은 쿼리/옵티마이저 조건에 따라 달라진다는 점을 문서에 명시하기 위해서다.
- 어떻게:
- `./gradlew tasks --all` 실행(성공)