개발 공부중

[사이드 프로젝트 회고 #4] 구독 기능 설계하면서 고민했던 DB 구조 본문

카테고리 없음

[사이드 프로젝트 회고 #4] 구독 기능 설계하면서 고민했던 DB 구조

개발자 leelee 2026. 7. 19. 14:40

 

3편에서 알림이 두 번 오던 문제의 원인이 정리되지 않은 fcm_tokens였다는 걸 찾아냈다.

이번 글은 그 과정에서 다시 들여다보게 된 구독 관련 DB 구조 이야기다.

"구독"이 하나가 아니었다

처음엔 "구독"이라는 개념을 단순하게 생각했다. 게시판 하나를 구독하면, 그 게시판에 올라오는 모든 글에 대한 알림을 받는 것.

그런데 막상 알림 종류를 하나씩 나열해보니, 실제로 필요한 구독은 두 가지 결이 달랐다.

  1. 게시판 구독 — "이 게시판에 새 글이 올라오면 알려줘" (게시판 단위)
  2. 글 구독 — "내가 댓글 단 이 글에 다른 댓글이 더 달리면 알려줘" (개별 게시글 단위)

처음에는 이 둘을 하나의 테이블로 운영할까 고민했다. 하나의 테이블에서 target_type('board' 또는 'post') 컬럼으로 분류하여 모든 구독 데이터를 관리하면 테이블 개수를 줄여 관리를 단순화할 수 있다고 생각했다. 

하지만 고민해보니 테이블을 분리하는 게 나았다.

 

첫번째, 추후에 게시판이 늘어날 것을 생각하여 board_type과 board_id 컬럼을 모두 생성하고 상황에 따라 하나를 비워두는(NULL) 방식을 했으나 너무 비효율적이라고 판단했다. 

두번째, 데이터 조회 및 알림 발송 로직이 복잡해진다. 통합 테이블을 사용할 경우 쿼리마다 WHERE target_type = '?' 조건을 필수적으로 써야했고, 백엔드(PHP) 코드에서도 알림을 처리할 때마다 if나 switch 문을 통해 대상을 분류하는 분기 처리를 해야한다.

 

그래서 처음부터 테이블을 분리했다.

-- 게시판 단위 구독 (새 글 알림용)
CREATE TABLE board_subscriptions (
  id INT AUTO_INCREMENT PRIMARY KEY,
  user_id INT NOT NULL,
  board_type VARCHAR(50) NOT NULL,
  created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
  UNIQUE KEY uniq_board_sub (user_id, board_type)
);

-- 게시글 단위 구독 (댓글 알림용)
CREATE TABLE post_subscriptions (
  id INT AUTO_INCREMENT PRIMARY KEY,
  user_id INT NOT NULL,
  board_id INT NOT NULL,
  created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
  UNIQUE KEY uniq_post_sub (user_id, board_id)
);

UNIQUE KEY를 (user_id, board_type) / (user_id, board_id) 조합으로 걸어둬서 같은 사용자가 같은 게시판이나 같은 글을 실수로 여러 번 구독 신청해도, 중복 행이 쌓이지 않게 했다.

이게 없으면 "구독 취소를 눌렀는데도 여전히 알림이 온다"거나 "알림이 구독 횟수만큼 여러 번 온다" 같은 문제가 생길 수 있었다.

 

 

새 글 알림에서는 게시판 구독자만 조회한다

새 글이 등록됐을 때는 그 게시판을 구독한 사람만 걸러서 알림을 보낸다. 근데 작성자 본인은 제외 시켜야된다.

$stmt = $pdo->prepare("
    SELECT user_id
    FROM board_subscriptions
    WHERE board_type = :type
      AND user_id != :writer_id
");
$stmt->execute([':type' => $board_type, ':writer_id' => $writer_id]);
$subscribers = $stmt->fetchAll(PDO::FETCH_COLUMN);

 

 

댓글 알림은 두 갈래로 나뉜다

댓글 알림은 새 글 알림보다 조금 더 복잡했다. 대상이 두 그룹이기 때문이다.

  • 글쓴이 — 자기 글에 댓글이 달렸을 때 (작성자와 댓글 작성자가 다를 때만)
  • 그 글을 구독 중인 다른 사람들 — 글쓴이가 아니어도, 이전에 댓글을 달았거나 구독을 걸어둔 사람들

이 중에서 글쓴이도 아니고, 이번에 댓글을 단 사람도 아닌 나머지 구독자만 추려야 했다. 그래서 제외 대상을 배열로 만들고, NOT IN 조건으로 걸러내는 방식을 썼다.

$exclude = array_filter([
    (int)($board['user_id'] ?? 0), // 글쓴이
    $commenter_id,                 // 이번 댓글 작성자
]);

$placeholders = implode(',', array_fill(0, count($exclude), '?'));

$stmt = $pdo->prepare("
    SELECT user_id FROM post_subscriptions
    WHERE board_id = ?
      AND user_id NOT IN ({$placeholders})
");
$stmt->execute(array_merge([$board_id], $exclude));

 

fcm_tokens는 조금 다르게 다시 설계했다

3편에서 겪었던 문제 — 옛날 토큰이 정리되지 않고 계속 쌓이는 것 — 를 겪고 나서, fcm_tokens 테이블도 그대로 둘 수 없었다.

원래는 그냥 INSERT만 하던 구조였는데, 이걸 "같은 사용자의 같은 토큰이 다시 들어오면 갱신만 하고, 새로 쌓이지는 않게" 바꿨다.

CREATE TABLE fcm_tokens (
  id INT AUTO_INCREMENT PRIMARY KEY,
  user_id INT NOT NULL,
  token VARCHAR(255) NOT NULL,
  created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
  UNIQUE KEY uniq_token (token)
);

token 컬럼 자체에 유니크 제약을 걸어두고, 저장할 때는 INSERT ... ON DUPLICATE KEY UPDATE로 처리해서 같은 토큰이 다시 들어와도 새 행이 생기지 않고 created_at만 갱신되도록 했다. 구독 테이블에 걸어둔 유니크 제약과 결이 같은 방식이다. 되돌아보면 "구독이든 토큰이든, 사용자가 같은 걸 중복으로 등록할 수 있는 지점에는 전부 유니크 제약을 걸어야 한다"는 게 이번 프로젝트에서 깨달은 것이다.

 

돌아보며

구독 기능을 설계하면서 배운 건, 처음부터 완벽한 스키마를 그리려고 하기보다는 실제로 발생하는 이벤트 종류를 먼저 나열하고, 그 이벤트마다 "누구에게 보내야 하는가"를 구체적으로 따져본 다음에 테이블을 쪼개는 게 훨씬 수월하다는 것이었다. 게시판 구독과 글 구독을 처음부터 하나의 테이블로 억지로 합쳤다면, 지금보다 훨씬 복잡한 조건문을 매번 짜야 했을 거다.

 

 

 

Comments