[BE 테크스펙] 알림 도메인 - 100-hours-a-week/KTB4-3rd-wiki GitHub Wiki

알림 도메인 테크 스펙

목차

  1. 배경
  2. 목표가 아닌 것
  3. 설계 및 기술 자료
  4. 기술 스택
  5. 알림 저장과 발송 아키텍처

배경 (Background)

프로젝트 목표 (Objective)

카풀·정산·커뮤니티 등 각 도메인에서 발생하는 중요 이벤트를 사용자가 알림함에서 빠짐없이 확인할 수 있도록 한다.

핵심 결과 (Key Results)

  • 승인·요청·정산 요청처럼 유실되면 안 되는 알림을 카풀 수락 등의 핵심 기능과 함께 원자적으로 저장한다.
  • 사용자가 알림함을 열었을 때 지금까지 발생한 알림을 누락 없이 최신순으로 확인할 수 있도록 한다.
  • 알림이 무한정 쌓여 조회 성능이 저하되지 않도록 보관 기간과 건수 정책을 적용한다.
  • 외부 발송 단계는 비동기로 처리하여 핵심 기능의 응답 스레드가 장시간 점유되지 않도록 한다.

문제 정의 (Problem)

  • 알림 저장과 실제 발송인 향후 Push 등의 외부 호출 책임이 섞이면 외부 요인의 실패가 핵심 기능으로 전파될 수 있다.
  • 초기 버전은 실시간 Push 인프라 없이 출시한다. 사용자가 알림함을 직접 조회하는 Polling 방식으로 시작하고 Push는 추후 개선 과제로 남긴다.

가설 (Hypothesis)

알림 저장을 이벤트가 발생한 도메인의 트랜잭션과 동기로 묶어 원자성을 확보한다.

초기에는 클라이언트 Polling만으로도 “중요 알림을 놓치지 않는다”는 핵심 가치를 충분히 전달할 수 있을 것이다. Push 필요성은 사용자 반응을 바탕으로 다시 판단한다.

관련 자료


목표가 아닌 것 (Non-goals)

이번 프로젝트에서 다루지 않는 내용:

  • 각 트리거 도메인 내부의 구체적인 트랜잭션 구현은 다루지 않는다.
  • 현재 테크 스펙에서는 알림 API와 Polling 방식의 조회를 중심으로 다루며, Push 및 비동기 기술의 구체적인 구현은 후속 과제로 남긴다.
  • 카풀 요청 거절(REJECTED)에는 알림을 발송하지 않는다. 따라서 notifications.type ENUM에도 REJECTED 계열 값을 추가하지 않는다.

설계 및 기술 자료 (Architecture and Technical Documentation)

데이터베이스 스키마 (ERD)

  • notifications: id, recipient_id, type(ENUM), event_id, content, read_at, created_at
  • event_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_idNULL로 둘 수 있다.

확인 필요: 출처와 이동 대상이 같은 경우 target_idNULL로 둘 것인지 정책 확정 필요

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"
        }
      }
      
    • 클래스 배치:

      클래스 메서드
      NotificationController list(NotificationListRequest request)
      NotificationService findList(Long userId, NotificationListCommand command)
      NotificationRepository findByRecipientWithCursor(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 NULL
      • unread=read_at IS NULL
    • 정렬: id 내림차순, 커서 기반, 페이지 크기 20건 고정(클라이언트 조절 불가)

    • 보관 정책: 미확정

알림 개별 읽음 처리 API

  • API 명세:

    • PATCH /notifications/{notification_id}/read_at
    • API 문서
  • 권한:

    • 로그인한 사용자: 자신의 알림 목록만 조회 가능
  • 구현 상세:

    • 클래스 배치:
    클래스 메서드
    NotificationController markRead(Long notificationId)
    NotificationService markRead(Long userId, Long notificationId)
    NotificationRepository markReadIfUnread(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 명세:

  • 권한:

    • 로그인한 사용자
  • 구현 상세:

    • 클래스 배치:
    클래스 메서드
    NotificationController markAllRead()
    NotificationService markAllRead(Long userId)
    NotificationRepository findMaxIdByRecipient(Long recipientId) · markAllReadUpTo(Long recipientId, Long snapshotMaxId)
    • 요청 DTO: 없음
    • 요청 시점의 최상단 알림 ID를 먼저 조회하여 스냅샷으로 고정한다.
    • 해당 ID 이하의 알림만 대상으로 read_atNULL에서 현재 시각으로 일괄 갱신한다.
  • 트랜잭션 관리: 최상단 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 활용을 검토한다.