From 20fef27fbfb4f11df24127a0ce01a494bf257bb4 Mon Sep 17 00:00:00 2001
From: Sumin Hwang <163857590+tnals0924@users.noreply.github.com>
Date: Wed, 14 Oct 2026 14:25:28 +0900
Subject: [PATCH 1/9] =?UTF-8?q?feat:=20=EC=95=8C=EB=A6=BC=20=EC=A2=85?=
=?UTF-8?q?=EB=A5=98=EC=99=80=20=EB=AC=B8=EA=B5=AC=20=ED=85=9C=ED=94=8C?=
=?UTF-8?q?=EB=A6=BF=20=EC=B6=94=EA=B0=80?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../domain/notification/domain/Josa.java | 79 +++++++++++++++++++
.../domain/MemberNotificationSetting.java | 9 +++
.../domain/NotificationCategory.java | 11 +++
.../notification/domain/NotificationType.java | 52 ++++++++++++
4 files changed, 151 insertions(+)
create mode 100644 core/domain/member/src/main/java/kr/ac/kookmin/stream/member/domain/notification/domain/Josa.java
create mode 100644 core/domain/member/src/main/java/kr/ac/kookmin/stream/member/domain/notification/domain/NotificationCategory.java
create mode 100644 core/domain/member/src/main/java/kr/ac/kookmin/stream/member/domain/notification/domain/NotificationType.java
diff --git a/core/domain/member/src/main/java/kr/ac/kookmin/stream/member/domain/notification/domain/Josa.java b/core/domain/member/src/main/java/kr/ac/kookmin/stream/member/domain/notification/domain/Josa.java
new file mode 100644
index 0000000..053f840
--- /dev/null
+++ b/core/domain/member/src/main/java/kr/ac/kookmin/stream/member/domain/notification/domain/Josa.java
@@ -0,0 +1,79 @@
+package kr.ac.kookmin.stream.member.domain.notification.domain;
+
+import java.util.List;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+
+/**
+ * 문구 템플릿의 {@code %s}에 값을 차례로 넣고, 템플릿에 적어 둔 "을(를)"·"이(가)"·"은(는)"·"과(와)"를
+ * 앞 단어의 받침에 맞는 조사 하나로 바꾼다.
+ *
+ * 앞 단어는 따옴표·괄호·문장부호·공백을 건너뛴 마지막 글자로 판단한다("'봄 축제!'을(를)" → "'봄 축제!'를").
+ * 한글 음절과 숫자만 판단하고, 영문처럼 판단할 수 없으면 "을(를)" 표기를 그대로 둔다.
+ * 값(행사명 등) 안에 같은 표기가 있어도 바꾸지 않도록 템플릿 부분만 바꾼다.
+ */
+final class Josa {
+
+ private static final String PLACEHOLDER = "%s";
+ private static final Pattern PARTICLE = Pattern.compile("(을|이|은|과)\\((를|가|는|와)\\)");
+ // 0(영)·1(일)·3(삼)·6(육)·7(칠)·8(팔)은 받침이 있다
+ private static final String DIGITS_WITH_FINAL_CONSONANT = "013678";
+
+ private static final char HANGUL_SYLLABLE_FIRST = '가';
+ private static final char HANGUL_SYLLABLE_LAST = '힣';
+ private static final int FINAL_CONSONANT_COUNT = 28;
+
+ private Josa() {
+ }
+
+ static String format(String template, List values) {
+ String[] segments = template.split(PLACEHOLDER, -1);
+ StringBuilder formatted = new StringBuilder();
+ for (int i = 0; i < segments.length; i++) {
+ appendResolved(formatted, segments[i]);
+ if (i < segments.length - 1) {
+ formatted.append(values.get(i));
+ }
+ }
+ return formatted.toString();
+ }
+
+ // 조사 표기 앞의 단어는 이미 붙여 둔 앞부분과 이 조각에서 표기 앞까지를 이어서 본다
+ private static void appendResolved(StringBuilder formatted, String segment) {
+ Matcher matcher = PARTICLE.matcher(segment);
+ int copiedUntil = 0;
+ while (matcher.find()) {
+ formatted.append(segment, copiedUntil, matcher.start());
+ Boolean hasFinalConsonant = hasFinalConsonant(lastLetterOrDigit(formatted));
+ formatted.append(hasFinalConsonant == null
+ ? matcher.group()
+ : hasFinalConsonant ? matcher.group(1) : matcher.group(2));
+ copiedUntil = matcher.end();
+ }
+ formatted.append(segment, copiedUntil, segment.length());
+ }
+
+ private static Character lastLetterOrDigit(CharSequence text) {
+ for (int i = text.length() - 1; i >= 0; i--) {
+ char c = text.charAt(i);
+ if (Character.isLetterOrDigit(c)) {
+ return c;
+ }
+ }
+ return null;
+ }
+
+ // 판단할 수 없으면 null
+ private static Boolean hasFinalConsonant(Character c) {
+ if (c == null) {
+ return null;
+ }
+ if (c >= HANGUL_SYLLABLE_FIRST && c <= HANGUL_SYLLABLE_LAST) {
+ return (c - HANGUL_SYLLABLE_FIRST) % FINAL_CONSONANT_COUNT != 0;
+ }
+ if (Character.isDigit(c)) {
+ return DIGITS_WITH_FINAL_CONSONANT.indexOf(c) >= 0;
+ }
+ return null;
+ }
+}
diff --git a/core/domain/member/src/main/java/kr/ac/kookmin/stream/member/domain/notification/domain/MemberNotificationSetting.java b/core/domain/member/src/main/java/kr/ac/kookmin/stream/member/domain/notification/domain/MemberNotificationSetting.java
index 3e0ac35..866b842 100644
--- a/core/domain/member/src/main/java/kr/ac/kookmin/stream/member/domain/notification/domain/MemberNotificationSetting.java
+++ b/core/domain/member/src/main/java/kr/ac/kookmin/stream/member/domain/notification/domain/MemberNotificationSetting.java
@@ -34,6 +34,15 @@ public static MemberNotificationSetting of(
return new MemberNotificationSetting(id, memberId, rentalEnabled, eventEnabled, lockerEnabled, noticeEnabled);
}
+ public boolean isEnabled(NotificationCategory category) {
+ return switch (category) {
+ case BILLILGE -> rentalEnabled;
+ case EVENT -> eventEnabled;
+ case LOCKER -> lockerEnabled;
+ case NOTICE -> noticeEnabled;
+ };
+ }
+
// 바뀐 값이 있으면 true를 돌려준다
public boolean update(MemberNotificationSettingUpdateCommand command) {
if (this.rentalEnabled == command.rentalEnabled()
diff --git a/core/domain/member/src/main/java/kr/ac/kookmin/stream/member/domain/notification/domain/NotificationCategory.java b/core/domain/member/src/main/java/kr/ac/kookmin/stream/member/domain/notification/domain/NotificationCategory.java
new file mode 100644
index 0000000..f3497d9
--- /dev/null
+++ b/core/domain/member/src/main/java/kr/ac/kookmin/stream/member/domain/notification/domain/NotificationCategory.java
@@ -0,0 +1,11 @@
+package kr.ac.kookmin.stream.member.domain.notification.domain;
+
+/**
+ * 알림 카테고리. 알림 설정의 수신 여부를 고르는 기준이고, 푸시 data의 {@code category} 값으로 앱에 그대로 내려간다.
+ */
+public enum NotificationCategory {
+ BILLILGE, // 빌릴게 (rental_enabled)
+ EVENT, // 행사 (event_enabled)
+ LOCKER, // 사물함 (locker_enabled)
+ NOTICE // 게시판: 새 공지, 열린피드백 답변 (notice_enabled)
+}
diff --git a/core/domain/member/src/main/java/kr/ac/kookmin/stream/member/domain/notification/domain/NotificationType.java b/core/domain/member/src/main/java/kr/ac/kookmin/stream/member/domain/notification/domain/NotificationType.java
new file mode 100644
index 0000000..3c8e9e6
--- /dev/null
+++ b/core/domain/member/src/main/java/kr/ac/kookmin/stream/member/domain/notification/domain/NotificationType.java
@@ -0,0 +1,52 @@
+package kr.ac.kookmin.stream.member.domain.notification.domain;
+
+import java.util.List;
+import lombok.AllArgsConstructor;
+import lombok.Getter;
+
+/**
+ * 알림 종류와 문구. 제목은 문구표의 굵은 첫 문장, 본문은 둘째 문장이다. 한 문장짜리는 본문이 없다.
+ *
+ * 문구의 {@code %s}에는 알림을 만들 때 넘긴 값 목록이 앞에서부터 들어간다. 제목과 본문은 같은 값 목록을 각각 처음부터 쓴다.
+ * 조사는 "을(를)"처럼 적어 두면 앞 단어의 받침에 맞춰 바뀐다({@link Josa}).
+ * 상수 이름이 {@code notifications.type}에 저장되므로 이름을 바꾸면 쌓인 알림함 행을 읽지 못한다.
+ */
+@Getter
+@AllArgsConstructor
+public enum NotificationType {
+
+ // 값: 행사명
+ EVENT_APPLIED(NotificationCategory.EVENT, "'%s'을(를) 신청했어요.", "신청 내역에서 자세한 내용을 확인할 수 있어요."),
+ EVENT_APPLICATION_CANCELED(NotificationCategory.EVENT, "'%s' 참여 신청을 취소했어요.", null),
+ EVENT_STARTING_TOMORROW(NotificationCategory.EVENT, "'%s'이(가) 내일 열려요.", "행사 일정과 장소를 확인해 주세요."),
+ EVENT_APPLY_CLOSING(NotificationCategory.EVENT, "'%s' 신청이 곧 마감돼요.", "참여하려면 기간 내에 신청해 주세요."),
+
+ // 값: 없음
+ LOCKER_ASSIGNED(NotificationCategory.LOCKER, "사물함을 신청했어요.", "배정된 사물함 번호를 확인해 주세요."),
+ LOCKER_APPLY_CLOSING(NotificationCategory.LOCKER, "사물함 신청이 내일 마감돼요.", "이용하려면 기간 내에 신청해 주세요."),
+ LOCKER_USAGE_ENDING(NotificationCategory.LOCKER, "사물함 이용 기간이 얼마 남지 않았어요.", "만료 전에 개인 물품을 미리 정리해 주세요."),
+
+ // 값: 물품명
+ RENTAL_DUE_SOON(NotificationCategory.BILLILGE, "반납까지 1시간 남았어요.", "늦지 않게 %s을(를) 반납해 주세요."),
+ RENTAL_OVERDUE(NotificationCategory.BILLILGE, "반납 기한이 지났어요.", "학생회실에 방문해 %s을(를) 반납해 주세요."),
+
+ // 값: 없음
+ NOTICE_CREATED(NotificationCategory.NOTICE, "새로운 공지사항이 올라왔어요.", "자세한 내용을 확인해 주세요.");
+
+ private final NotificationCategory category;
+ private final String title;
+ private final String body;
+
+ public String formatTitle(List formatValues) {
+ return format(title, formatValues);
+ }
+
+ // 본문이 없는 알림은 null
+ public String formatBody(List formatValues) {
+ return body == null ? null : format(body, formatValues);
+ }
+
+ private static String format(String template, List formatValues) {
+ return Josa.format(template, formatValues);
+ }
+}
From cd7c412380cc518be3d263d9f7532369c8155124 Mon Sep 17 00:00:00 2001
From: Sumin Hwang <163857590+tnals0924@users.noreply.github.com>
Date: Wed, 14 Oct 2026 14:25:54 +0900
Subject: [PATCH 2/9] =?UTF-8?q?feat:=20=EC=95=8C=EB=A6=BC=ED=95=A8=20?=
=?UTF-8?q?=EC=A0=80=EC=9E=A5=EC=86=8C=20=EC=B6=94=EA=B0=80?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../notification/domain/Notification.java | 21 +++++++++++++++++--
.../repository/NotificationRepository.java | 8 +++++++
.../db/member/NotificationJpaEntity.java | 6 +++++-
.../db/member/NotificationJpaRepository.java | 6 ++++++
.../db/member/NotificationRepositoryImpl.java | 21 +++++++++++++++++++
5 files changed, 59 insertions(+), 3 deletions(-)
create mode 100644 core/domain/member/src/main/java/kr/ac/kookmin/stream/member/domain/notification/repository/NotificationRepository.java
create mode 100644 infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/member/NotificationJpaRepository.java
create mode 100644 infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/member/NotificationRepositoryImpl.java
diff --git a/core/domain/member/src/main/java/kr/ac/kookmin/stream/member/domain/notification/domain/Notification.java b/core/domain/member/src/main/java/kr/ac/kookmin/stream/member/domain/notification/domain/Notification.java
index dff51e4..ace847a 100644
--- a/core/domain/member/src/main/java/kr/ac/kookmin/stream/member/domain/notification/domain/Notification.java
+++ b/core/domain/member/src/main/java/kr/ac/kookmin/stream/member/domain/notification/domain/Notification.java
@@ -1,28 +1,45 @@
package kr.ac.kookmin.stream.member.domain.notification.domain;
+import java.util.List;
import lombok.AccessLevel;
import lombok.AllArgsConstructor;
import lombok.EqualsAndHashCode;
import lombok.Getter;
+/**
+ * 알림함의 알림 한 건. 문구는 저장하지 않고 종류와 문구 값으로 다시 만든다({@link NotificationType}).
+ */
@Getter
@EqualsAndHashCode
@AllArgsConstructor(access = AccessLevel.PRIVATE)
public class Notification {
+ // 문구 값을 한 컬럼에 순서대로 이어 붙일 때 쓰는 구분자. 행사명에 쉼표가 들어갈 수 있어 쉼표 대신 단위 구분 문자(US)를 쓴다
+ private static final String FORMAT_VALUE_DELIMITER = "\u001F";
+
private Long id;
private Long memberId;
- private String type;
+ private NotificationType type;
+ // 문구 값을 순서대로 이어 붙인 문자열. 값이 없으면 null
private String formatValues;
private boolean read;
+ public static Notification create(Long memberId, NotificationType type, List formatValues) {
+ String joined = formatValues.isEmpty() ? null : String.join(FORMAT_VALUE_DELIMITER, formatValues);
+ return new Notification(null, memberId, type, joined, false);
+ }
+
public static Notification of(
Long id,
Long memberId,
- String type,
+ NotificationType type,
String formatValues,
boolean read
) {
return new Notification(id, memberId, type, formatValues, read);
}
+
+ public List getFormatValueList() {
+ return formatValues == null ? List.of() : List.of(formatValues.split(FORMAT_VALUE_DELIMITER, -1));
+ }
}
diff --git a/core/domain/member/src/main/java/kr/ac/kookmin/stream/member/domain/notification/repository/NotificationRepository.java b/core/domain/member/src/main/java/kr/ac/kookmin/stream/member/domain/notification/repository/NotificationRepository.java
new file mode 100644
index 0000000..17a64b1
--- /dev/null
+++ b/core/domain/member/src/main/java/kr/ac/kookmin/stream/member/domain/notification/repository/NotificationRepository.java
@@ -0,0 +1,8 @@
+package kr.ac.kookmin.stream.member.domain.notification.repository;
+
+import java.util.List;
+import kr.ac.kookmin.stream.member.domain.notification.domain.Notification;
+
+public interface NotificationRepository {
+ void saveAll(List notifications);
+}
diff --git a/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/member/NotificationJpaEntity.java b/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/member/NotificationJpaEntity.java
index 66571a9..302ba5d 100644
--- a/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/member/NotificationJpaEntity.java
+++ b/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/member/NotificationJpaEntity.java
@@ -2,6 +2,8 @@
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
+import jakarta.persistence.EnumType;
+import jakarta.persistence.Enumerated;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
@@ -9,6 +11,7 @@
import jakarta.persistence.Table;
import kr.ac.kookmin.stream.db.common.BaseCreatedTimeEntity;
import kr.ac.kookmin.stream.member.domain.notification.domain.Notification;
+import kr.ac.kookmin.stream.member.domain.notification.domain.NotificationType;
import lombok.AccessLevel;
import lombok.NoArgsConstructor;
@@ -30,8 +33,9 @@ public class NotificationJpaEntity extends BaseCreatedTimeEntity {
@Column(name = "member_id", nullable = false)
private Long memberId;
+ @Enumerated(EnumType.STRING)
@Column(nullable = false, length = 50)
- private String type;
+ private NotificationType type;
@Column(name = "format_values")
private String formatValues;
diff --git a/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/member/NotificationJpaRepository.java b/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/member/NotificationJpaRepository.java
new file mode 100644
index 0000000..e40cc46
--- /dev/null
+++ b/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/member/NotificationJpaRepository.java
@@ -0,0 +1,6 @@
+package kr.ac.kookmin.stream.db.member;
+
+import org.springframework.data.jpa.repository.JpaRepository;
+
+public interface NotificationJpaRepository extends JpaRepository {
+}
diff --git a/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/member/NotificationRepositoryImpl.java b/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/member/NotificationRepositoryImpl.java
new file mode 100644
index 0000000..8a54a2a
--- /dev/null
+++ b/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/member/NotificationRepositoryImpl.java
@@ -0,0 +1,21 @@
+package kr.ac.kookmin.stream.db.member;
+
+import java.util.List;
+import kr.ac.kookmin.stream.member.domain.notification.domain.Notification;
+import kr.ac.kookmin.stream.member.domain.notification.repository.NotificationRepository;
+import lombok.RequiredArgsConstructor;
+import org.springframework.stereotype.Repository;
+
+@Repository
+@RequiredArgsConstructor
+public class NotificationRepositoryImpl implements NotificationRepository {
+
+ private final NotificationJpaRepository notificationJpaRepository;
+
+ @Override
+ public void saveAll(List notifications) {
+ notificationJpaRepository.saveAll(notifications.stream()
+ .map(NotificationJpaEntity::from)
+ .toList());
+ }
+}
From 905d30ec3df4bb9a59e839fc12b05b15c8c66390 Mon Sep 17 00:00:00 2001
From: Sumin Hwang <163857590+tnals0924@users.noreply.github.com>
Date: Wed, 14 Oct 2026 14:26:15 +0900
Subject: [PATCH 3/9] =?UTF-8?q?feat:=20=ED=91=B8=EC=8B=9C=20=EB=8C=80?=
=?UTF-8?q?=EC=83=81=20=EC=A1=B0=ED=9A=8C=20=EC=B6=94=EA=B0=80?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../notification/domain/PushTarget.java | 11 ++++++
.../NotificationRecipientRepository.java | 14 +++++++
.../stream/db/member/MemberJpaRepository.java | 24 ++++++++++++
.../NotificationRecipientRepositoryImpl.java | 39 +++++++++++++++++++
.../db/member/PushTargetProjection.java | 3 ++
5 files changed, 91 insertions(+)
create mode 100644 core/domain/member/src/main/java/kr/ac/kookmin/stream/member/domain/notification/domain/PushTarget.java
create mode 100644 core/domain/member/src/main/java/kr/ac/kookmin/stream/member/domain/notification/repository/NotificationRecipientRepository.java
create mode 100644 infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/member/NotificationRecipientRepositoryImpl.java
create mode 100644 infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/member/PushTargetProjection.java
diff --git a/core/domain/member/src/main/java/kr/ac/kookmin/stream/member/domain/notification/domain/PushTarget.java b/core/domain/member/src/main/java/kr/ac/kookmin/stream/member/domain/notification/domain/PushTarget.java
new file mode 100644
index 0000000..969a7e9
--- /dev/null
+++ b/core/domain/member/src/main/java/kr/ac/kookmin/stream/member/domain/notification/domain/PushTarget.java
@@ -0,0 +1,11 @@
+package kr.ac.kookmin.stream.member.domain.notification.domain;
+
+/**
+ * 푸시를 받을 수 있는 회원(토큰이 있는 활성 회원)과 그 회원의 알림 설정.
+ */
+public record PushTarget(Long memberId, String fcmToken, MemberNotificationSetting setting) {
+
+ public boolean accepts(NotificationCategory category) {
+ return setting.isEnabled(category);
+ }
+}
diff --git a/core/domain/member/src/main/java/kr/ac/kookmin/stream/member/domain/notification/repository/NotificationRecipientRepository.java b/core/domain/member/src/main/java/kr/ac/kookmin/stream/member/domain/notification/repository/NotificationRecipientRepository.java
new file mode 100644
index 0000000..08fac7c
--- /dev/null
+++ b/core/domain/member/src/main/java/kr/ac/kookmin/stream/member/domain/notification/repository/NotificationRecipientRepository.java
@@ -0,0 +1,14 @@
+package kr.ac.kookmin.stream.member.domain.notification.repository;
+
+import java.util.Collection;
+import java.util.List;
+import kr.ac.kookmin.stream.member.domain.notification.domain.PushTarget;
+
+/**
+ * 알림 수신자 조회. 알림함 대상은 활성 회원이고, 푸시 대상은 그중 토큰과 알림 설정이 있는 회원이다.
+ */
+public interface NotificationRecipientRepository {
+ List findAllActiveMemberIds();
+ List findPushTargets(Collection memberIds);
+ List findAllPushTargets();
+}
diff --git a/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/member/MemberJpaRepository.java b/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/member/MemberJpaRepository.java
index 0fd828a..715589b 100644
--- a/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/member/MemberJpaRepository.java
+++ b/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/member/MemberJpaRepository.java
@@ -1,5 +1,6 @@
package kr.ac.kookmin.stream.db.member;
+import java.util.Collection;
import java.util.List;
import java.util.Optional;
import org.springframework.data.jpa.repository.JpaRepository;
@@ -24,6 +25,29 @@ public interface MemberJpaRepository extends JpaRepository searchIdsByKeyword(@Param("keyword") String keyword);
+ @Query("SELECT m.id FROM MemberJpaEntity m WHERE m.isDeleted = false")
+ List findAllActiveIds();
+
+ // 알림 설정이 없는 회원(로그인만 하고 가입을 마치지 않은 회원)은 푸시 대상에서 빠진다
+ @Query("""
+ SELECT new kr.ac.kookmin.stream.db.member.PushTargetProjection(m.id, m.fcmToken, s)
+ FROM MemberJpaEntity m
+ JOIN MemberNotificationSettingJpaEntity s ON s.memberId = m.id
+ WHERE m.isDeleted = false
+ AND m.fcmToken IS NOT NULL
+ AND m.id IN :memberIds
+ """)
+ List findPushTargetsByIdIn(@Param("memberIds") Collection memberIds);
+
+ @Query("""
+ SELECT new kr.ac.kookmin.stream.db.member.PushTargetProjection(m.id, m.fcmToken, s)
+ FROM MemberJpaEntity m
+ JOIN MemberNotificationSettingJpaEntity s ON s.memberId = m.id
+ WHERE m.isDeleted = false
+ AND m.fcmToken IS NOT NULL
+ """)
+ List findAllPushTargets();
+
// 탈퇴 회원에게는 어차피 보내지 않으므로 is_deleted와 관계없이 지운다
@Modifying(clearAutomatically = true)
@Query("""
diff --git a/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/member/NotificationRecipientRepositoryImpl.java b/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/member/NotificationRecipientRepositoryImpl.java
new file mode 100644
index 0000000..6a4c99c
--- /dev/null
+++ b/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/member/NotificationRecipientRepositoryImpl.java
@@ -0,0 +1,39 @@
+package kr.ac.kookmin.stream.db.member;
+
+import java.util.Collection;
+import java.util.List;
+import kr.ac.kookmin.stream.member.domain.notification.domain.PushTarget;
+import kr.ac.kookmin.stream.member.domain.notification.repository.NotificationRecipientRepository;
+import lombok.RequiredArgsConstructor;
+import org.springframework.stereotype.Repository;
+
+@Repository
+@RequiredArgsConstructor
+public class NotificationRecipientRepositoryImpl implements NotificationRecipientRepository {
+
+ private final MemberJpaRepository memberJpaRepository;
+
+ @Override
+ public List findAllActiveMemberIds() {
+ return memberJpaRepository.findAllActiveIds();
+ }
+
+ @Override
+ public List findPushTargets(Collection memberIds) {
+ if (memberIds.isEmpty()) {
+ return List.of();
+ }
+ return toPushTargets(memberJpaRepository.findPushTargetsByIdIn(memberIds));
+ }
+
+ @Override
+ public List findAllPushTargets() {
+ return toPushTargets(memberJpaRepository.findAllPushTargets());
+ }
+
+ private List toPushTargets(List projections) {
+ return projections.stream()
+ .map(p -> new PushTarget(p.memberId(), p.fcmToken(), p.setting().toDomain()))
+ .toList();
+ }
+}
diff --git a/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/member/PushTargetProjection.java b/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/member/PushTargetProjection.java
new file mode 100644
index 0000000..fb00581
--- /dev/null
+++ b/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/member/PushTargetProjection.java
@@ -0,0 +1,3 @@
+package kr.ac.kookmin.stream.db.member;
+
+public record PushTargetProjection(Long memberId, String fcmToken, MemberNotificationSettingJpaEntity setting) {}
From 1324dab9c89716278062c00c6fcb54c8f7462fdb Mon Sep 17 00:00:00 2001
From: Sumin Hwang <163857590+tnals0924@users.noreply.github.com>
Date: Wed, 14 Oct 2026 14:26:44 +0900
Subject: [PATCH 4/9] =?UTF-8?q?feat:=20=EB=AC=B4=ED=9A=A8=20FCM=20?=
=?UTF-8?q?=ED=86=A0=ED=81=B0=20=EC=9D=BC=EA=B4=84=20=EC=A0=95=EB=A6=AC=20?=
=?UTF-8?q?=EC=B6=94=EA=B0=80?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../repository/NotificationRecipientRepository.java | 2 ++
.../stream/db/member/MemberJpaRepository.java | 12 ++++++++++++
.../member/NotificationRecipientRepositoryImpl.java | 8 ++++++++
3 files changed, 22 insertions(+)
diff --git a/core/domain/member/src/main/java/kr/ac/kookmin/stream/member/domain/notification/repository/NotificationRecipientRepository.java b/core/domain/member/src/main/java/kr/ac/kookmin/stream/member/domain/notification/repository/NotificationRecipientRepository.java
index 08fac7c..1872a99 100644
--- a/core/domain/member/src/main/java/kr/ac/kookmin/stream/member/domain/notification/repository/NotificationRecipientRepository.java
+++ b/core/domain/member/src/main/java/kr/ac/kookmin/stream/member/domain/notification/repository/NotificationRecipientRepository.java
@@ -6,9 +6,11 @@
/**
* 알림 수신자 조회. 알림함 대상은 활성 회원이고, 푸시 대상은 그중 토큰과 알림 설정이 있는 회원이다.
+ * FCM이 무효라고 응답한 토큰 정리도 여기서 한다.
*/
public interface NotificationRecipientRepository {
List findAllActiveMemberIds();
List findPushTargets(Collection memberIds);
List findAllPushTargets();
+ int clearFcmTokens(Collection fcmTokens);
}
diff --git a/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/member/MemberJpaRepository.java b/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/member/MemberJpaRepository.java
index 715589b..287eeb7 100644
--- a/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/member/MemberJpaRepository.java
+++ b/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/member/MemberJpaRepository.java
@@ -7,6 +7,7 @@
import org.springframework.data.jpa.repository.Modifying;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
+import org.springframework.transaction.annotation.Transactional;
public interface MemberJpaRepository extends JpaRepository {
@@ -57,4 +58,15 @@ public interface MemberJpaRepository extends JpaRepository :memberId
""")
void clearFcmTokenOfOthers(@Param("memberId") Long memberId, @Param("fcmToken") String fcmToken);
+
+ // 발송 이후 새로 등록된 토큰까지 지우지 않도록 회원 ID가 아니라 FCM이 무효라고 한 토큰 값으로 지운다.
+ // 알림 발송 처리는 트랜잭션 밖에서 돌기 때문에 여기서 연다
+ @Transactional
+ @Modifying(clearAutomatically = true)
+ @Query("""
+ UPDATE MemberJpaEntity m
+ SET m.fcmToken = NULL
+ WHERE m.fcmToken IN :fcmTokens
+ """)
+ int clearFcmTokens(@Param("fcmTokens") Collection fcmTokens);
}
diff --git a/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/member/NotificationRecipientRepositoryImpl.java b/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/member/NotificationRecipientRepositoryImpl.java
index 6a4c99c..46c8931 100644
--- a/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/member/NotificationRecipientRepositoryImpl.java
+++ b/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/member/NotificationRecipientRepositoryImpl.java
@@ -31,6 +31,14 @@ public List findAllPushTargets() {
return toPushTargets(memberJpaRepository.findAllPushTargets());
}
+ @Override
+ public int clearFcmTokens(Collection fcmTokens) {
+ if (fcmTokens.isEmpty()) {
+ return 0;
+ }
+ return memberJpaRepository.clearFcmTokens(fcmTokens);
+ }
+
private List toPushTargets(List projections) {
return projections.stream()
.map(p -> new PushTarget(p.memberId(), p.fcmToken(), p.setting().toDomain()))
From 6f834cc2e0c7ff9f88199548db2a82050c28b5db Mon Sep 17 00:00:00 2001
From: Sumin Hwang <163857590+tnals0924@users.noreply.github.com>
Date: Wed, 14 Oct 2026 14:35:45 +0900
Subject: [PATCH 5/9] =?UTF-8?q?chore:=20slf4j-api=EB=A5=BC=20=EB=AA=A8?=
=?UTF-8?q?=EB=93=A0=20=EB=AA=A8=EB=93=88=20=EA=B3=B5=ED=86=B5=20=EC=9D=98?=
=?UTF-8?q?=EC=A1=B4=EC=84=B1=EC=9C=BC=EB=A1=9C=20=EC=A0=81=EC=9A=A9?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
build.gradle.kts | 5 +++++
docs/conventions/architecture.md | 2 ++
gateway/logging/build.gradle.kts | 1 -
3 files changed, 7 insertions(+), 1 deletion(-)
diff --git a/build.gradle.kts b/build.gradle.kts
index 42a6f52..44f5e25 100644
--- a/build.gradle.kts
+++ b/build.gradle.kts
@@ -39,6 +39,11 @@ subprojects {
// compileOnly 어노테이션 전용 — 경계 검증은 bootstrap 테스트가 수행한다.
"compileOnly"(platform(rootProject.libs.springModulithBom))
"compileOnly"(rootProject.libs.springModulithApi)
+
+ // 로깅 API(SLF4J)는 도메인 모듈을 포함한 모든 모듈이 같은 것을 쓰도록 공통 적용.
+ // 구현(Logback)은 bootstrap의 Spring Boot가 런타임에 붙인다. 버전은 Boot BOM이 관리한다
+ "implementation"(platform(rootProject.libs.springBootDependenciesBom))
+ "implementation"(rootProject.libs.slf4jApi)
}
tasks.withType {
diff --git a/docs/conventions/architecture.md b/docs/conventions/architecture.md
index fa3b17c..63cfe42 100644
--- a/docs/conventions/architecture.md
+++ b/docs/conventions/architecture.md
@@ -374,6 +374,8 @@ Data Access {Domain}Repository / {Domain}Client 인터페이스 → core:dom
## 7. 모듈별 허용 의존성
+모든 모듈은 루트 `build.gradle.kts`의 `subprojects`에서 Lombok(compileOnly), `spring-modulith-api`(compileOnly), `slf4j-api`를 공통으로 받는다. 아래 표는 그 밖의 의존성이다. 모듈별 `build.gradle.kts`에 공통 의존성을 다시 선언하지 않는다.
+
| 모듈 | 허용 의존성 |
| --- | --- |
| `bootstrap` | 모든 `api:*` + `gateway:*` + `infrastructure:*` + `core:common` + Spring Boot 실행(Actuator, 스케줄링) + `spring-modulith-core`·ArchUnit(**test scope**) |
diff --git a/gateway/logging/build.gradle.kts b/gateway/logging/build.gradle.kts
index 50b588d..ca9a095 100644
--- a/gateway/logging/build.gradle.kts
+++ b/gateway/logging/build.gradle.kts
@@ -11,7 +11,6 @@ dependencies {
implementation(libs.springWeb)
implementation(libs.springContext)
implementation(libs.jakartaServletApi)
- implementation(libs.slf4jApi)
testImplementation(platform(libs.springBootDependenciesBom))
testImplementation(libs.springBootStarterTest)
From a2c12f571e659912258d003ecba63155228f4ea7 Mon Sep 17 00:00:00 2001
From: Sumin Hwang <163857590+tnals0924@users.noreply.github.com>
Date: Wed, 14 Oct 2026 14:35:45 +0900
Subject: [PATCH 6/9] =?UTF-8?q?feat:=20=EC=95=8C=EB=A6=BC=20=EB=B0=9C?=
=?UTF-8?q?=EC=86=A1=20=EC=B2=98=EB=A6=AC=20=EC=B6=94=EA=B0=80?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../service/impl/NotificationDispatcher.java | 107 ++++++++++++++++++
1 file changed, 107 insertions(+)
create mode 100644 core/domain/member/src/main/java/kr/ac/kookmin/stream/member/domain/notification/service/impl/NotificationDispatcher.java
diff --git a/core/domain/member/src/main/java/kr/ac/kookmin/stream/member/domain/notification/service/impl/NotificationDispatcher.java b/core/domain/member/src/main/java/kr/ac/kookmin/stream/member/domain/notification/service/impl/NotificationDispatcher.java
new file mode 100644
index 0000000..a8caab8
--- /dev/null
+++ b/core/domain/member/src/main/java/kr/ac/kookmin/stream/member/domain/notification/service/impl/NotificationDispatcher.java
@@ -0,0 +1,107 @@
+package kr.ac.kookmin.stream.member.domain.notification.service.impl;
+
+import java.util.Collection;
+import java.util.LinkedHashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import kr.ac.kookmin.stream.member.domain.notification.client.PushNotificationClient;
+import kr.ac.kookmin.stream.member.domain.notification.domain.Notification;
+import kr.ac.kookmin.stream.member.domain.notification.domain.NotificationType;
+import kr.ac.kookmin.stream.member.domain.notification.domain.PushMessage;
+import kr.ac.kookmin.stream.member.domain.notification.domain.PushSendResult;
+import kr.ac.kookmin.stream.member.domain.notification.domain.PushSendStatus;
+import kr.ac.kookmin.stream.member.domain.notification.domain.PushTarget;
+import kr.ac.kookmin.stream.member.domain.notification.repository.NotificationRecipientRepository;
+import kr.ac.kookmin.stream.member.domain.notification.repository.NotificationRepository;
+import lombok.RequiredArgsConstructor;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.stereotype.Component;
+
+/**
+ * 알림 한 건을 수신자에게 보낸다. 알림함에는 수신자 전원에게 저장하고, 푸시는 그 카테고리를 켜 두고 토큰이 있는 회원에게만 보낸다.
+ *
+ * 아웃박스 릴레이가 부르는 리스너에서 트랜잭션 없이 호출된다. 알림함 저장은 {@code saveAll}의 트랜잭션으로 한꺼번에 반영하고,
+ * 저장이 끝난 뒤 트랜잭션 밖에서 푸시를 보낸다. 알림함 저장이 실패하면 예외가 리스너까지 올라가 아웃박스가 다시 발행한다.
+ */
+@Component
+@RequiredArgsConstructor
+class NotificationDispatcher {
+
+ private static final Logger log = LoggerFactory.getLogger(NotificationDispatcher.class);
+
+ private final NotificationRepository notificationRepository;
+ private final NotificationRecipientRepository notificationRecipientRepository;
+ private final PushNotificationClient pushNotificationClient;
+
+ /** 지정한 회원에게 보낸다. */
+ void dispatch(NotificationType type, List formatValues, long targetId, Collection memberIds) {
+ List recipients = List.copyOf(new LinkedHashSet<>(memberIds));
+ List pushTargets = notificationRecipientRepository.findPushTargets(recipients);
+ send(type, formatValues, targetId, recipients, pushTargets);
+ }
+
+ /** 활성 회원 전체에서 제외할 회원(예: 이미 신청한 회원)을 뺀 나머지에게 보낸다. */
+ void dispatchToAll(NotificationType type, List formatValues, long targetId, Collection excludedMemberIds) {
+ Set excluded = Set.copyOf(excludedMemberIds);
+ List recipients = notificationRecipientRepository.findAllActiveMemberIds().stream()
+ .filter(memberId -> !excluded.contains(memberId))
+ .toList();
+ List pushTargets = notificationRecipientRepository.findAllPushTargets().stream()
+ .filter(target -> !excluded.contains(target.memberId()))
+ .toList();
+ send(type, formatValues, targetId, recipients, pushTargets);
+ }
+
+ private void send(
+ NotificationType type,
+ List formatValues,
+ long targetId,
+ List recipients,
+ List pushTargets
+ ) {
+ if (recipients.isEmpty()) {
+ return;
+ }
+ notificationRepository.saveAll(recipients.stream()
+ .map(memberId -> Notification.create(memberId, type, formatValues))
+ .toList());
+
+ // 한 기기의 토큰이 여러 회원 행에 남아 있을 수 있어 토큰 단위로 중복을 지운다. 그대로 보내면 같은 기기에 여러 번 간다
+ List tokens = pushTargets.stream()
+ .filter(target -> target.accepts(type.getCategory()))
+ .map(PushTarget::fcmToken)
+ .distinct()
+ .toList();
+ int invalidTokenCount = 0;
+ if (!tokens.isEmpty()) {
+ PushSendResult result = pushNotificationClient.send(tokens, toPushMessage(type, formatValues, targetId));
+ invalidTokenCount = clearInvalidTokens(result.tokensWith(PushSendStatus.INVALID_TOKEN));
+ }
+ log.info("알림 발송: type={}, targetId={}, recipientCount={}, pushTokenCount={}, invalidTokenCount={}",
+ type, targetId, recipients.size(), tokens.size(), invalidTokenCount);
+ }
+
+ private PushMessage toPushMessage(NotificationType type, List formatValues, long targetId) {
+ Map data = Map.of(
+ "type", type.name(),
+ "category", type.getCategory().name(),
+ "targetId", String.valueOf(targetId)
+ );
+ return new PushMessage(type.formatTitle(formatValues), type.formatBody(formatValues), data);
+ }
+
+ // 푸시는 이미 나갔으므로 정리가 실패해도 예외를 던지지 않는다. 던지면 아웃박스가 다시 발행해 같은 알림이 한 번 더 간다
+ private int clearInvalidTokens(List invalidTokens) {
+ if (invalidTokens.isEmpty()) {
+ return 0;
+ }
+ try {
+ notificationRecipientRepository.clearFcmTokens(invalidTokens);
+ } catch (RuntimeException e) {
+ log.warn("무효 FCM 토큰 정리 실패: tokenCount={}", invalidTokens.size(), e);
+ }
+ return invalidTokens.size();
+ }
+}
From 46e9d7e6a7f6506a492c798e32d33ebe738cc772 Mon Sep 17 00:00:00 2001
From: Sumin Hwang <163857590+tnals0924@users.noreply.github.com>
Date: Wed, 14 Oct 2026 14:38:40 +0900
Subject: [PATCH 7/9] =?UTF-8?q?refactor:=20=EC=A1=B0=EC=82=AC=20=EC=B2=98?=
=?UTF-8?q?=EB=A6=AC=EB=A5=BC=20common=20=EC=9C=A0=ED=8B=B8=EB=A1=9C=20?=
=?UTF-8?q?=EB=B6=84=EB=A6=AC?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../kr/ac/kookmin/stream/common/JosaUtil.java} | 14 +++++++-------
.../notification/domain/NotificationType.java | 11 ++++-------
2 files changed, 11 insertions(+), 14 deletions(-)
rename core/{domain/member/src/main/java/kr/ac/kookmin/stream/member/domain/notification/domain/Josa.java => common/src/main/java/kr/ac/kookmin/stream/common/JosaUtil.java} (87%)
diff --git a/core/domain/member/src/main/java/kr/ac/kookmin/stream/member/domain/notification/domain/Josa.java b/core/common/src/main/java/kr/ac/kookmin/stream/common/JosaUtil.java
similarity index 87%
rename from core/domain/member/src/main/java/kr/ac/kookmin/stream/member/domain/notification/domain/Josa.java
rename to core/common/src/main/java/kr/ac/kookmin/stream/common/JosaUtil.java
index 053f840..8252adc 100644
--- a/core/domain/member/src/main/java/kr/ac/kookmin/stream/member/domain/notification/domain/Josa.java
+++ b/core/common/src/main/java/kr/ac/kookmin/stream/common/JosaUtil.java
@@ -1,18 +1,21 @@
-package kr.ac.kookmin.stream.member.domain.notification.domain;
+package kr.ac.kookmin.stream.common;
import java.util.List;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
+import lombok.AccessLevel;
+import lombok.NoArgsConstructor;
/**
- * 문구 템플릿의 {@code %s}에 값을 차례로 넣고, 템플릿에 적어 둔 "을(를)"·"이(가)"·"은(는)"·"과(와)"를
+ * 조사 유틸. 문구 템플릿의 {@code %s}에 값을 차례로 넣고, 템플릿에 적어 둔 "을(를)"·"이(가)"·"은(는)"·"과(와)"를
* 앞 단어의 받침에 맞는 조사 하나로 바꾼다.
*
* 앞 단어는 따옴표·괄호·문장부호·공백을 건너뛴 마지막 글자로 판단한다("'봄 축제!'을(를)" → "'봄 축제!'를").
* 한글 음절과 숫자만 판단하고, 영문처럼 판단할 수 없으면 "을(를)" 표기를 그대로 둔다.
* 값(행사명 등) 안에 같은 표기가 있어도 바꾸지 않도록 템플릿 부분만 바꾼다.
*/
-final class Josa {
+@NoArgsConstructor(access = AccessLevel.PRIVATE)
+public final class JosaUtil {
private static final String PLACEHOLDER = "%s";
private static final Pattern PARTICLE = Pattern.compile("(을|이|은|과)\\((를|가|는|와)\\)");
@@ -23,10 +26,7 @@ final class Josa {
private static final char HANGUL_SYLLABLE_LAST = '힣';
private static final int FINAL_CONSONANT_COUNT = 28;
- private Josa() {
- }
-
- static String format(String template, List values) {
+ public static String format(String template, List values) {
String[] segments = template.split(PLACEHOLDER, -1);
StringBuilder formatted = new StringBuilder();
for (int i = 0; i < segments.length; i++) {
diff --git a/core/domain/member/src/main/java/kr/ac/kookmin/stream/member/domain/notification/domain/NotificationType.java b/core/domain/member/src/main/java/kr/ac/kookmin/stream/member/domain/notification/domain/NotificationType.java
index 3c8e9e6..7c16e36 100644
--- a/core/domain/member/src/main/java/kr/ac/kookmin/stream/member/domain/notification/domain/NotificationType.java
+++ b/core/domain/member/src/main/java/kr/ac/kookmin/stream/member/domain/notification/domain/NotificationType.java
@@ -1,6 +1,7 @@
package kr.ac.kookmin.stream.member.domain.notification.domain;
import java.util.List;
+import kr.ac.kookmin.stream.common.JosaUtil;
import lombok.AllArgsConstructor;
import lombok.Getter;
@@ -8,7 +9,7 @@
* 알림 종류와 문구. 제목은 문구표의 굵은 첫 문장, 본문은 둘째 문장이다. 한 문장짜리는 본문이 없다.
*
* 문구의 {@code %s}에는 알림을 만들 때 넘긴 값 목록이 앞에서부터 들어간다. 제목과 본문은 같은 값 목록을 각각 처음부터 쓴다.
- * 조사는 "을(를)"처럼 적어 두면 앞 단어의 받침에 맞춰 바뀐다({@link Josa}).
+ * 조사는 "을(를)"처럼 적어 두면 앞 단어의 받침에 맞춰 바뀐다({@link JosaUtil}).
* 상수 이름이 {@code notifications.type}에 저장되므로 이름을 바꾸면 쌓인 알림함 행을 읽지 못한다.
*/
@Getter
@@ -38,15 +39,11 @@ public enum NotificationType {
private final String body;
public String formatTitle(List formatValues) {
- return format(title, formatValues);
+ return JosaUtil.format(title, formatValues);
}
// 본문이 없는 알림은 null
public String formatBody(List formatValues) {
- return body == null ? null : format(body, formatValues);
- }
-
- private static String format(String template, List formatValues) {
- return Josa.format(template, formatValues);
+ return body == null ? null : JosaUtil.format(body, formatValues);
}
}
From 5fbcda2fb0b7e5c83d1f74ab24c7362a76e2e69f Mon Sep 17 00:00:00 2001
From: Sumin Hwang <163857590+tnals0924@users.noreply.github.com>
Date: Wed, 14 Oct 2026 14:41:35 +0900
Subject: [PATCH 8/9] =?UTF-8?q?feat:=20=EC=95=8C=EB=A6=BC=ED=95=A8?=
=?UTF-8?q?=EC=97=90=20=EC=9D=B4=EB=8F=99=20=EB=8C=80=EC=83=81=20ID=20?=
=?UTF-8?q?=EC=B6=94=EA=B0=80?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../member/domain/notification/domain/Notification.java | 9 ++++++---
.../service/impl/NotificationDispatcher.java | 2 +-
.../kookmin/stream/db/member/NotificationJpaEntity.java | 6 +++++-
.../db/migration/V19__add_target_id_to_notifications.sql | 4 ++++
4 files changed, 16 insertions(+), 5 deletions(-)
create mode 100644 infrastructure/db/src/main/resources/db/migration/V19__add_target_id_to_notifications.sql
diff --git a/core/domain/member/src/main/java/kr/ac/kookmin/stream/member/domain/notification/domain/Notification.java b/core/domain/member/src/main/java/kr/ac/kookmin/stream/member/domain/notification/domain/Notification.java
index ace847a..2c1dd00 100644
--- a/core/domain/member/src/main/java/kr/ac/kookmin/stream/member/domain/notification/domain/Notification.java
+++ b/core/domain/member/src/main/java/kr/ac/kookmin/stream/member/domain/notification/domain/Notification.java
@@ -22,11 +22,13 @@ public class Notification {
private NotificationType type;
// 문구 값을 순서대로 이어 붙인 문자열. 값이 없으면 null
private String formatValues;
+ // 알림을 눌렀을 때 이동할 대상의 ID. 무엇의 ID인지는 type이 정한다
+ private Long targetId;
private boolean read;
- public static Notification create(Long memberId, NotificationType type, List formatValues) {
+ public static Notification create(Long memberId, NotificationType type, List formatValues, Long targetId) {
String joined = formatValues.isEmpty() ? null : String.join(FORMAT_VALUE_DELIMITER, formatValues);
- return new Notification(null, memberId, type, joined, false);
+ return new Notification(null, memberId, type, joined, targetId, false);
}
public static Notification of(
@@ -34,9 +36,10 @@ public static Notification of(
Long memberId,
NotificationType type,
String formatValues,
+ Long targetId,
boolean read
) {
- return new Notification(id, memberId, type, formatValues, read);
+ return new Notification(id, memberId, type, formatValues, targetId, read);
}
public List getFormatValueList() {
diff --git a/core/domain/member/src/main/java/kr/ac/kookmin/stream/member/domain/notification/service/impl/NotificationDispatcher.java b/core/domain/member/src/main/java/kr/ac/kookmin/stream/member/domain/notification/service/impl/NotificationDispatcher.java
index a8caab8..7a93f98 100644
--- a/core/domain/member/src/main/java/kr/ac/kookmin/stream/member/domain/notification/service/impl/NotificationDispatcher.java
+++ b/core/domain/member/src/main/java/kr/ac/kookmin/stream/member/domain/notification/service/impl/NotificationDispatcher.java
@@ -65,7 +65,7 @@ private void send(
return;
}
notificationRepository.saveAll(recipients.stream()
- .map(memberId -> Notification.create(memberId, type, formatValues))
+ .map(memberId -> Notification.create(memberId, type, formatValues, targetId))
.toList());
// 한 기기의 토큰이 여러 회원 행에 남아 있을 수 있어 토큰 단위로 중복을 지운다. 그대로 보내면 같은 기기에 여러 번 간다
diff --git a/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/member/NotificationJpaEntity.java b/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/member/NotificationJpaEntity.java
index 302ba5d..231b689 100644
--- a/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/member/NotificationJpaEntity.java
+++ b/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/member/NotificationJpaEntity.java
@@ -40,6 +40,9 @@ public class NotificationJpaEntity extends BaseCreatedTimeEntity {
@Column(name = "format_values")
private String formatValues;
+ @Column(name = "target_id")
+ private Long targetId;
+
@Column(name = "is_read", nullable = false)
private boolean read;
@@ -48,6 +51,7 @@ private NotificationJpaEntity(Notification notification) {
this.memberId = notification.getMemberId();
this.type = notification.getType();
this.formatValues = notification.getFormatValues();
+ this.targetId = notification.getTargetId();
this.read = notification.isRead();
}
@@ -56,6 +60,6 @@ public static NotificationJpaEntity from(Notification notification) {
}
public Notification toDomain() {
- return Notification.of(id, memberId, type, formatValues, read);
+ return Notification.of(id, memberId, type, formatValues, targetId, read);
}
}
diff --git a/infrastructure/db/src/main/resources/db/migration/V19__add_target_id_to_notifications.sql b/infrastructure/db/src/main/resources/db/migration/V19__add_target_id_to_notifications.sql
new file mode 100644
index 0000000..432a561
--- /dev/null
+++ b/infrastructure/db/src/main/resources/db/migration/V19__add_target_id_to_notifications.sql
@@ -0,0 +1,4 @@
+-- 알림함에서 알림을 눌렀을 때 이동할 대상의 ID. 무엇의 ID인지(행사·사물함 회차·공지·대여 이력)는 type이 정한다.
+-- 대상이 없는 알림도 생길 수 있어 NULL을 허용한다.
+ALTER TABLE notifications
+ ADD COLUMN target_id BIGINT NULL AFTER format_values;
From a5c9eb5c49a799e55190ebc4db6ede6ec80aa8ca Mon Sep 17 00:00:00 2001
From: Sumin Hwang <163857590+tnals0924@users.noreply.github.com>
Date: Wed, 14 Oct 2026 15:33:42 +0900
Subject: [PATCH 9/9] =?UTF-8?q?chore:=20=EA=B3=B5=ED=86=B5=20=EB=A1=9C?=
=?UTF-8?q?=EA=B9=85=20=EC=9D=98=EC=A1=B4=EC=84=B1=EC=9D=84=20spring-boot-?=
=?UTF-8?q?starter-logging=EC=9C=BC=EB=A1=9C=20=EB=B3=80=EA=B2=BD?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
build.gradle.kts | 6 +++---
docs/conventions/architecture.md | 2 +-
gradle/libs.versions.toml | 2 +-
3 files changed, 5 insertions(+), 5 deletions(-)
diff --git a/build.gradle.kts b/build.gradle.kts
index 44f5e25..4d75fad 100644
--- a/build.gradle.kts
+++ b/build.gradle.kts
@@ -40,10 +40,10 @@ subprojects {
"compileOnly"(platform(rootProject.libs.springModulithBom))
"compileOnly"(rootProject.libs.springModulithApi)
- // 로깅 API(SLF4J)는 도메인 모듈을 포함한 모든 모듈이 같은 것을 쓰도록 공통 적용.
- // 구현(Logback)은 bootstrap의 Spring Boot가 런타임에 붙인다. 버전은 Boot BOM이 관리한다
+ // 로깅은 도메인 모듈을 포함한 모든 모듈이 Spring Boot 기본 구성(SLF4J 파사드 + Logback)을 쓰도록 공통 적용.
+ // 버전은 Boot BOM이 관리한다
"implementation"(platform(rootProject.libs.springBootDependenciesBom))
- "implementation"(rootProject.libs.slf4jApi)
+ "implementation"(rootProject.libs.springBootStarterLogging)
}
tasks.withType {
diff --git a/docs/conventions/architecture.md b/docs/conventions/architecture.md
index 63cfe42..e0dabab 100644
--- a/docs/conventions/architecture.md
+++ b/docs/conventions/architecture.md
@@ -374,7 +374,7 @@ Data Access {Domain}Repository / {Domain}Client 인터페이스 → core:dom
## 7. 모듈별 허용 의존성
-모든 모듈은 루트 `build.gradle.kts`의 `subprojects`에서 Lombok(compileOnly), `spring-modulith-api`(compileOnly), `slf4j-api`를 공통으로 받는다. 아래 표는 그 밖의 의존성이다. 모듈별 `build.gradle.kts`에 공통 의존성을 다시 선언하지 않는다.
+모든 모듈은 루트 `build.gradle.kts`의 `subprojects`에서 Lombok(compileOnly), `spring-modulith-api`(compileOnly), `spring-boot-starter-logging`(SLF4J 파사드 + Logback)을 공통으로 받는다. 아래 표는 그 밖의 의존성이다. 모듈별 `build.gradle.kts`에 공통 의존성을 다시 선언하지 않는다.
| 모듈 | 허용 의존성 |
| --- | --- |
diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
index 685b5e6..d2d1d62 100644
--- a/gradle/libs.versions.toml
+++ b/gradle/libs.versions.toml
@@ -26,6 +26,7 @@ springBootStarterSecurity = { module = "org.springframework.boot:spring-boot-sta
springBootStarterDataJpa = { module = "org.springframework.boot:spring-boot-starter-data-jpa" }
springBootStarterFlyway = { module = "org.springframework.boot:spring-boot-starter-flyway" }
springBootStarterJackson = { module = "org.springframework.boot:spring-boot-starter-jackson" }
+springBootStarterLogging = { module = "org.springframework.boot:spring-boot-starter-logging" }
springBootStarterTest = { module = "org.springframework.boot:spring-boot-starter-test" }
springBootStarterSecurityTest = { module = "org.springframework.boot:spring-boot-starter-security-test" }
springBootStarterDataJpaTest = { module = "org.springframework.boot:spring-boot-starter-data-jpa-test" }
@@ -48,7 +49,6 @@ springJdbc = { module = "org.springframework:spring-jdbc" }
jakartaServletApi = { module = "jakarta.servlet:jakarta.servlet-api" }
jacksonDatabind = { module = "com.fasterxml.jackson.core:jackson-databind" }
-slf4jApi = { module = "org.slf4j:slf4j-api" }
flywayMysql = { module = "org.flywaydb:flyway-mysql" }
mysqlConnectorJ = { module = "com.mysql:mysql-connector-j" }