- Today
- Total
| 일 | 월 | 화 | 수 | 목 | 금 | 토 |
|---|---|---|---|---|---|---|
| 1 | 2 | 3 | 4 | |||
| 5 | 6 | 7 | 8 | 9 | 10 | 11 |
| 12 | 13 | 14 | 15 | 16 | 17 | 18 |
| 19 | 20 | 21 | 22 | 23 | 24 | 25 |
| 26 | 27 | 28 | 29 | 30 | 31 |
- 환경세팅
- dbeaver
- javascript
- Hostinger
- url파라미터
- 이클립스
- MariaDB
- sql
- 오블완
- mysqli
- PDO
- iframe
- 트러블슈팅
- 워스프레스
- 워드프레스
- PLSQL
- PROCEDURE
- 티스토리챌린지
- 백엔드
- 문제해결
- 쿼리개선
- 오라클
- spring boot
- 의존성주입
- 프로시저
- Oracle
- 엘리멘터
- 오류해결
- 클론코딩
- TIL
개발 공부중
[사이드 프로젝트 회고 #4] 구독 기능 설계하면서 고민했던 DB 구조 본문
3편에서 알림이 두 번 오던 문제의 원인이 정리되지 않은 fcm_tokens였다는 걸 찾아냈다.
이번 글은 그 과정에서 다시 들여다보게 된 구독 관련 DB 구조 이야기다.

"구독"이 하나가 아니었다
처음엔 "구독"이라는 개념을 단순하게 생각했다. 게시판 하나를 구독하면, 그 게시판에 올라오는 모든 글에 대한 알림을 받는 것.
그런데 막상 알림 종류를 하나씩 나열해보니, 실제로 필요한 구독은 두 가지 결이 달랐다.
- 게시판 구독 — "이 게시판에 새 글이 올라오면 알려줘" (게시판 단위)
- 글 구독 — "내가 댓글 단 이 글에 다른 댓글이 더 달리면 알려줘" (개별 게시글 단위)
처음에는 이 둘을 하나의 테이블로 운영할까 고민했다. 하나의 테이블에서 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만 갱신되도록 했다. 구독 테이블에 걸어둔 유니크 제약과 결이 같은 방식이다. 되돌아보면 "구독이든 토큰이든, 사용자가 같은 걸 중복으로 등록할 수 있는 지점에는 전부 유니크 제약을 걸어야 한다"는 게 이번 프로젝트에서 깨달은 것이다.
돌아보며
구독 기능을 설계하면서 배운 건, 처음부터 완벽한 스키마를 그리려고 하기보다는 실제로 발생하는 이벤트 종류를 먼저 나열하고, 그 이벤트마다 "누구에게 보내야 하는가"를 구체적으로 따져본 다음에 테이블을 쪼개는 게 훨씬 수월하다는 것이었다. 게시판 구독과 글 구독을 처음부터 하나의 테이블로 억지로 합쳤다면, 지금보다 훨씬 복잡한 조건문을 매번 짜야 했을 거다.
