알림을 끄려면 구독을 해지해야 했습니다 (상태 하나를 둘로 나눈 이야기)

"알림이 너무 많아요"라는 피드백에 대한 첫 답이 "구독을 취소하세요"였습니다. 그건 답이 아니었어요. 구독과 알림 수신은 애초에 다른 축이었습니다.

[배경 - 알림이 너무 많다는 말]

아주이벤트에서 게시판을 구독하면 새 공지가 올라올 때마다 푸시가 갑니다. 그게 이 서비스의 전부예요.

그런데 사용자에게 이런 얘기를 들었습니다. 시험 기간에 학사 공지가 몰리는데 알림이 계속 울려서 불편하다고요. 잠깐 알림만 안 받고 싶다는 말이었어요.

그때 제가 안내한 방법은 이거였습니다. 구독을 취소하세요.

말해놓고 이상하다고 느꼈어요. 이 사람은 공지를 안 보겠다는 게 아니라 소리가 울리는 게 싫다는 겁니다. 그런데 제가 준 선택지는 “다 받거나 아예 끊거나” 둘뿐이었어요.

[문제 상황 분석 - 상태 하나가 두 가지 일을 하고 있었다]

구독 여부가 네 가지를 결정하고 있었습니다

당시 구조에서는 topic_members 행이 존재하느냐가 전부를 결정했습니다.

행이 있으면행이 없으면
푸시 알림이 간다안 간다
구독 탭 목록에 뜬다안 뜬다
안 읽은 공지 뱃지가 붙는다안 붙는다
구독 게시판 모아보기에 포함된다안 된다

하나의 불리언이 네 가지 동작을 묶어서 켜고 끕니다. 사용자가 그중 하나만 끄고 싶어도 방법이 없어요.

여기서 알게 된 게 있습니다. 행의 존재 여부는 관계를 표현하는 데는 좋지만 상태를 표현하는 데는 부족합니다. 관계는 있거나 없거나지만, 상태는 여러 축을 가질 수 있으니까요.

두 축은 서로 독립입니다

정리해보니 사용자가 원하는 조합은 네 가지였어요.

                    알림 받음        알림 안 받음
  구독 중         ┌──────────────┬──────────────┐
                  │  기본 상태    │  조용히 보기  │   ← 이게 없었다
                  └──────────────┴──────────────┘
  구독 안 함      ┌──────────────┬──────────────┐
                  │   (불가능)    │   미구독     │
                  └──────────────┴──────────────┘

왼쪽 아래는 애초에 성립하지 않아요. 구독하지 않은 게시판의 알림을 받을 이유가 없으니까요. 그러니 실제로는 세 가지 상태가 필요한데, 저는 두 개만 만들어둔 상태였습니다.

“조용히 보기” 칸이 비어 있었던 거예요. 그리고 사용자가 요청한 건 정확히 그 칸이었습니다.

구독을 취소하면 되돌리기가 비쌉니다

이게 결정적이었어요. 구독 취소는 단순히 상태가 바뀌는 게 아닙니다.

@Transactional
public void unsubscribeFromTopic(TopicUnsubscribeRequest request, Member member) {
    Topic topic = topicQueryService.findByDepartment(request.topic());
    tokenService.unsubscribeFromTopic(topic, member);
    topicCommandService.deleteTopicMember(topic, member);
}

topic_members 행이 지워지고, 연결된 토큰 매핑도 같이 지워집니다. 그러면 last_read_at 도 사라져요. 다시 구독하면 읽음 기록이 초기화됩니다.

즉 알림 하나 끄자고 구독을 취소하면 되돌아올 때 상태가 그대로 복원되지 않아요. 시험 기간 끝나고 다시 켜면 안 읽은 공지가 잔뜩 쌓인 것처럼 보입니다. 사용자 입장에서는 이게 손해예요.

[해결 방법 - 수신 여부를 별도 컬럼으로 분리]

컬럼 하나를 추가했습니다

TopicMember 에 receive_notification 을 뒀습니다.

@Entity
@Table(name = "topic_members")
public class TopicMember {
    // ...
    @Column(name = "is_read", nullable = false, columnDefinition = "TINYINT(1)")
    private boolean isRead;

    @Column(name = "last_read_at", nullable = false)
    private LocalDateTime lastReadAt;

    @Column(name = "receive_notification", nullable = false, columnDefinition = "TINYINT(1)")
    private boolean receiveNotification;

    public void updateReceiveNotification(boolean receiveNotification) {
        this.receiveNotification = receiveNotification;
    }

    public void markAsRead() {
        this.isRead = true;
        this.lastReadAt = LocalDateTime.now();
    }
}

컬럼 하나 추가한 게 전부입니다. 스키마 변경으로 보면 사소해요. 다만 이 컬럼이 생기면서 행의 존재는 관계만 뜻하게 됐고, 알림 여부는 별도 축이 됐습니다.

토글은 상태만 바꿉니다.

@Transactional
public void updateNotificationPreference(Member member, String topicName, boolean receiveNotification) {
    Topic topic = topicRepositoryPort.findByDepartment(topicName)
        .orElseThrow(() -> new TopicException(TopicErrorCode.TOPIC_NOT_FOUND));

    TopicMember topicMember = topicMemberRepositoryPort.findByMemberAndTopic(member, topic)
        .orElseThrow(() -> new TopicException(TopicErrorCode.SUBSCRIPTION_NOT_FOUND));

    topicMember.updateReceiveNotification(receiveNotification);
}

행을 지우지 않으니 last_read_at 이 살아 있어요. 다시 켜도 읽음 기록 그대로입니다.

SUBSCRIPTION_NOT_FOUND 예외를 던지는 것도 의도한 부분이에요. 구독하지 않은 게시판의 알림 설정을 바꾸겠다는 요청은 성립하지 않습니다. 앞의 표에서 “불가능” 칸이 코드에도 반영돼 있어야 한다고 봤어요.

발송 대상 조회에서 한 번만 거릅니다

새 공지가 들어오면 발송 대상을 뽑는데, 이 쿼리에서 걸러집니다.

@Query("SELECT tm FROM TopicMember tm JOIN FETCH tm.member "
     + "WHERE tm.topic = :topic AND tm.receiveNotification = true")
List<TopicMember> findByTopicWithMemberAndReceiveNotificationTrue(@Param("topic") Topic topic);

필터를 여기 한 곳에만 뒀습니다. 발송 파이프라인 뒤쪽 어디에도 이 조건이 없어요.

일부러 그랬습니다. 대상 선정은 한 곳에서 끝나야 나중에 읽는 사람이 헷갈리지 않아요. 조건이 여러 군데 흩어지면 “왜 이 사람에게 안 갔지” 를 추적할 때 전부 뒤져야 합니다.

그리고 이 필터가 발송 쪽에만 있다는 게 중요해요. 목록 조회 쿼리에는 이 조건이 없습니다. 그래서 알림을 꺼도 구독 탭에는 계속 보이고, 앱에 들어와서 공지를 확인할 수 있어요. 원래 사용자가 원했던 게 이거였습니다.

[성과 - 개선 전후 비교]

운영 데이터로 확인한 값입니다.

항목값
전체 구독958건
알림 비활성32건
비율약 3.3%

숫자 자체는 작아요. 그런데 이 32건을 어떻게 읽느냐가 중요합니다.

이 기능이 없었다면 이 32건은 구독 취소로 갔을 가능성이 큽니다. 알림이 부담스러운데 끄는 방법이 그것뿐이었으니까요. 그러면 지표에는 구독 수 감소로 잡혔을 거예요.

즉 이 기능이 만든 건 새로운 사용이 아니라 이탈하지 않은 상태입니다. 이런 건 늘어난 숫자로는 안 보이고, 줄지 않은 숫자로만 보여요.

과장하지 않고 적으면 이렇습니다. 32명이 구독을 유지한 채 알림만 껐고, 그중 몇 명이 나중에 다시 켰는지는 아직 안 봤습니다.

[결론]

정리하면 이렇습니다.

  • 행의 존재는 관계를 뜻하고, 상태는 컬럼으로 따로 표현한다
  • 하나의 불리언이 네 가지 동작을 묶고 있으면 사용자는 부분 제어를 못 한다
  • 되돌리기 비용이 비싼 조작을 유일한 선택지로 두면 안 된다

한계도 적어둘게요.

첫째, 키워드 구독에는 이 토글이 없습니다. 이게 제일 큰 구멍이에요. KeywordMember 를 보면 is_read 와 last_read_at 만 있고 receive_notification 이 없습니다.

@Entity
@Table(name = "keyword_members")
public class KeywordMember {
    // ...
    private boolean isRead;
    private LocalDateTime lastReadAt;
    // receiveNotification 없음
}

키워드 발송 대상을 뽑는 쿼리도 이 조건 없이 전원을 가져와요. 그러니까 게시판 알림은 끌 수 있는데 키워드 알림은 못 끕니다. 같은 문제를 한쪽만 고친 상태예요.

둘째, 끄는 단위가 게시판 하나입니다. “전체 알림 일시정지” 나 “야간에는 안 받기” 같은 건 안 됩니다. 시험 기간에만 조용히 하고 싶은 사람은 구독한 게시판을 하나씩 다 꺼야 해요.

셋째, 토글 이력을 안 남깁니다. 컬럼 값만 바꾸니 언제 껐다 켰는지 알 수 없어요. 위에 측정 필요로 남긴 것도 이 때문입니다.

넷째, 32건이라는 숫자를 해석할 근거가 약합니다. 이 기능이 이탈을 막았다는 건 제 추정이에요. 기능 도입 전후의 구독 취소율을 비교했어야 하는데 그 데이터를 안 모아뒀습니다.

피드백은 “알림이 많다” 였는데 제가 처음 들은 건 “구독을 줄이고 싶다” 였어요. 사용자가 말한 문제와 제가 이해한 문제가 다르면 해법도 어긋납니다.