[BE 테크스펙] 알림 도메인 - 100-hours-a-week/KTB4-3rd-wiki GitHub Wiki
알림 도메인 테크 스펙
목차
배경 (Background)
프로젝트 목표 (Objective)
카풀·정산·커뮤니티 등 각 도메인에서 발생하는 중요 이벤트를 사용자가 알림함에서 빠짐없이 확인할 수 있도록 한다.
핵심 결과 (Key Results)
- 승인·요청·정산 요청처럼 유실되면 안 되는 알림을 카풀 수락 등의 핵심 기능과 함께 원자적으로 저장한다.
- 사용자가 알림함을 열었을 때 지금까지 발생한 알림을 누락 없이 최신순으로 확인할 수 있도록 한다.
- 알림이 무한정 쌓여 조회 성능이 저하되지 않도록 보관 기간과 건수 정책을 적용한다.
- 외부 발송 단계는 비동기로 처리하여 핵심 기능의 응답 스레드가 장시간 점유되지 않도록 한다.
문제 정의 (Problem)
- 알림 저장과 실제 발송인 향후 Push 등의 외부 호출 책임이 섞이면 외부 요인의 실패가 핵심 기능으로 전파될 수 있다.
- 초기 버전은 실시간 Push 인프라 없이 출시한다. 사용자가 알림함을 직접 조회하는 Polling 방식으로 시작하고 Push는 추후 개선 과제로 남긴다.
가설 (Hypothesis)
알림 저장을 이벤트가 발생한 도메인의 트랜잭션과 동기로 묶어 원자성을 확보한다.
초기에는 클라이언트 Polling만으로도 “중요 알림을 놓치지 않는다”는 핵심 가치를 충분히 전달할 수 있을 것이다. Push 필요성은 사용자 반응을 바탕으로 다시 판단한다.
관련 자료
목표가 아닌 것 (Non-goals)
이번 프로젝트에서 다루지 않는 내용:
- 각 트리거 도메인 내부의 구체적인 트랜잭션 구현은 다루지 않는다.
- 현재 테크 스펙에서는 알림 API와 Polling 방식의 조회를 중심으로 다루며, Push 및 비동기 기술의 구체적인 구현은 후속 과제로 남긴다.
- 카풀 요청 거절(
REJECTED)에는 알림을 발송하지 않는다. 따라서notifications.typeENUM에도REJECTED계열 값을 추가하지 않는다.
설계 및 기술 자료 (Architecture and Technical Documentation)
데이터베이스 스키마 (ERD)
notifications: id, recipient_id, type(ENUM), event_id, content, read_at, created_atevent_id는 알림을 발생시킨 원본 이벤트의 ID이고,target_id는 알림 카드 선택 시 이동할 화면에 필요한 ID이다.- 예를 들어 댓글 알림의
event_id는 댓글 ID지만, 이동할 화면은 게시글 상세이므로target_id에 게시글 ID를 저장한다.
- 예를 들어 댓글 알림의
DDL
CREATE TABLE notifications (
id BIGINT NOT NULL AUTO_INCREMENT,
recipient_id BIGINT NOT NULL,
type ENUM('CARPOOL_REQUEST_RECEIVED','CARPOOL_REQUEST_ACCEPTED','SETTLEMENT_REQUESTED','COMMENT_CREATED') NOT NULL,
event_id BIGINT NOT NULL,
target_id BIGINT NULL,
content VARCHAR(200) NOT NULL,
read_at DATETIME(6) NULL,
created_at DATETIME(6) NOT NULL,
PRIMARY KEY (id),
KEY idx_recipient_created (recipient_id, id),
CONSTRAINT fk_notification_recipient FOREIGN KEY (recipient_id) REFERENCES users (id)
);
target_id를 추가한 이유
target_id는 ERD 원본에 없던 컬럼이다. event_id는 알림의 출처를, target_id는 사용자가 이동할 목적지를 나타내므로 용도가 다르다.
댓글 알림의 event_id는 댓글 ID지만, 이동할 화면은 게시글 상세이므로 target_id에는 게시글 ID가 필요하다. 정산 요청처럼 출처와 이동 대상이 같으면 중복 저장을 피하기 위해 target_id를 NULL로 둘 수 있다.
확인 필요: 출처와 이동 대상이 같은 경우
target_id를NULL로 둘 것인지 정책 확정 필요
API 명세 (API Specifications)
- 목차:
- 알림 목록 조회
- 알림 개별 읽음 처리
- 알림 모두 읽음 처리
알림 목록 조회 API
- API 명세:
GET /notifications?tab={all|read|unread}&cursor={cursor}- 쿼리 파라미터:
tab(읽음처리 여부 필터),cursor(마지막으로 읽은 알림 id)tab=unread→ 안 읽은 것만tab=read→ 읽은 것만tab=all→ 읽었든 안 읽었든 상관없이 전부 (필터링 자체를 안 함)
- API 문서
- 권한:
- 로그인한 사용자
- 구현 상세:
-
응답 예시 (
200 OK):{ "message": "조회에 성공했습니다", "data": { "notifications": [ { "id": 900, "type": "COMMENT_CREATED", "event_id": 12, "target_id": 88, "content": "우림님이 댓글을 남겼어요", "read_at": null, "created_at": "2026-09-04T09:00:00" } ], "next_cursor": "881" } } -
클래스 배치:
클래스 메서드 NotificationControllerlist(NotificationListRequest request)NotificationServicefindList(Long userId, NotificationListCommand command)NotificationRepositoryfindByRecipientWithCursor(Long recipientId, ReadFilter filter, Long cursor, int size) -
요청 DTO:
NotificationListQuery(tab, cursor)-@ModelAttribute로 바인딩,tab은@Pattern(regexp = "all|read|unread")로@Valid검증 -
tab값에 따라 조회 시점에 필터링:all=필터 없음read=read_at IS NOT NULLunread=read_at IS NULL
-
정렬:
id내림차순, 커서 기반, 페이지 크기 20건 고정(클라이언트 조절 불가) -
보관 정책: 미확정
-
알림 개별 읽음 처리 API
-
API 명세:
PATCH /notifications/{notification_id}/read_at- API 문서
-
권한:
- 로그인한 사용자: 자신의 알림 목록만 조회 가능
-
구현 상세:
- 클래스 배치:
클래스 메서드 NotificationControllermarkRead(Long notificationId)NotificationServicemarkRead(Long userId, Long notificationId)NotificationRepositorymarkReadIfUnread(Long id, Long recipientId)(조건부 UPDATE)- 요청 DTO: 없음(path variable만 사용)
- 본인 알림이 아니면 403이 아니라 404 (NOT_FOUND) - 존재 여부 자체를 노출하지 않기 위함(3장 채팅방 조회와 동일한 패턴)
- 조건부 UPDATE — 본인 소유이면서 아직 안 읽은 행만 갱신,
read_at이 NULL이면 현재 시각으로 갱신
UPDATE notifications SET read_at = NOW(6) WHERE id = :notificationId AND recipient_id = :myUserId AND read_at IS NULL;
알림 모두 읽음 처리 API
-
API 명세:
PATCH /notifications/read-all- API 문서
-
권한:
- 로그인한 사용자
-
구현 상세:
- 클래스 배치:
클래스 메서드 NotificationControllermarkAllRead()NotificationServicemarkAllRead(Long userId)NotificationRepositoryfindMaxIdByRecipient(Long recipientId)·markAllReadUpTo(Long recipientId, Long snapshotMaxId)- 요청 DTO: 없음
- 요청 시점의 최상단 알림 ID를 먼저 조회하여 스냅샷으로 고정한다.
- 해당 ID 이하의 알림만 대상으로
read_at을NULL에서 현재 시각으로 일괄 갱신한다.
-
트랜잭션 관리: 최상단 ID 조회와 일괄 UPDATE를 같은 트랜잭션으로 묶는다.
WHERE recipient_id = :me조건만 사용하면 처리 도중 생성된 알림까지 읽음 처리되어 “요청 시점 기준” 정책이 깨질 수 있다. 반드시WHERE id <= :snapshotMaxId조건으로 스냅샷 범위를 고정한다.
기술 스택 (Technology Stack)
- Backend: Java / Spring Boot
- Database: MySQL 8
- 외부 연동: 없음. 현재 범위는 알림 저장 및 조회까지이며 Push 서비스 연동은 후속 과제이다.
알림 저장과 발송 아키텍처
알림은 **저장(DB Insert)**과 **발송(사용자 단말에 실제 Push)**의 두 단계로 나누어 설계한다.
저장 단계 (트리거 도메인 코드 내부)
| 트리거 | 소유 도메인 | 저장 방식 |
|---|---|---|
카풀 요청 접수 (CARPOOL_REQUEST_RECEIVED) |
카풀 | 동기 — 본 트랜잭션과 같은 트랜잭션으로 insert |
카풀 요청 수락 (CARPOOL_REQUEST_ACCEPTED) |
카풀 | 동기 |
정산 요청 (SETTLEMENT_REQUESTED) |
택시팟·정산 | 동기 |
댓글 작성 (COMMENT_CREATED) |
커뮤니티 | 동기 |
네 가지 트리거의 저장 방식을 모두 동기로 통일한다.
notifications Insert는 외부 API 호출이 아니라 같은 DB에 쓰는 내부 작업이므로 핵심 기능의 트랜잭션에 포함해도 응답 속도에 미치는 영향이 작다.
댓글 알림처럼 유실을 허용할 수 있는 사례만 트랜잭션을 분리하는 방안도 검토했다. 그러나 미세한 응답 속도 개선보다 알림별 저장 방식이 달라져 발생하는 구현·유지보수 복잡성이 더 크다고 판단하여 모두 동기로 단순화한다.
향후 Push 발송 단계
Push를 도입하면 네 가지 트리거 모두 저장 트랜잭션이 커밋된 후 외부 발송을 비동기로 호출한다.
@Async
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
public void onNotificationCreated(NotificationCreatedEvent event) {
pushClient.send(event.getRecipientDeviceToken(), event.getContent()); // 실제 사용할 push 서비스는 미정
}
- 비동기 발송이 필요한 이유: 외부 서버의 처리 시간과 성공 여부를 통제할 수 없기 때문이다. 발송 전에 저장이 완료되므로 발송에 실패하더라도 알림 자체는 유실되지 않으며, 사용자는 알림함 API(
GET /notifications)에서 확인할 수 있다. - 초기 제공 방식: 클라이언트가 Polling으로 알림함 API를 조회한다.
- 후속 검토: 사용자 반응과 트래픽을 바탕으로 SSE 또는 Push 도입을 검토한다.
- 비동기 기술 스택: Push 발송을 구현할 때 기존 인메모리 저장소인 Redis 활용을 검토한다.