Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,11 @@ subprojects {
// compileOnly 어노테이션 전용 — 경계 검증은 bootstrap 테스트가 수행한다.
"compileOnly"(platform(rootProject.libs.springModulithBom))
"compileOnly"(rootProject.libs.springModulithApi)

// 로깅은 도메인 모듈을 포함한 모든 모듈이 Spring Boot 기본 구성(SLF4J 파사드 + Logback)을 쓰도록 공통 적용.
// 버전은 Boot BOM이 관리한다
"implementation"(platform(rootProject.libs.springBootDependenciesBom))
"implementation"(rootProject.libs.springBootStarterLogging)
}

tasks.withType<Test> {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
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}에 값을 차례로 넣고, 템플릿에 적어 둔 "을(를)"·"이(가)"·"은(는)"·"과(와)"를
* 앞 단어의 받침에 맞는 조사 하나로 바꾼다.
* <p>
* 앞 단어는 따옴표·괄호·문장부호·공백을 건너뛴 마지막 글자로 판단한다("'봄 축제!'을(를)" → "'봄 축제!'를").
* 한글 음절과 숫자만 판단하고, 영문처럼 판단할 수 없으면 "을(를)" 표기를 그대로 둔다.
* 값(행사명 등) 안에 같은 표기가 있어도 바꾸지 않도록 템플릿 부분만 바꾼다.
*/
@NoArgsConstructor(access = AccessLevel.PRIVATE)
public final class JosaUtil {

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;

public static String format(String template, List<String> 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;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
Original file line number Diff line number Diff line change
@@ -1,28 +1,48 @@
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;
// 알림을 눌렀을 때 이동할 대상의 ID. 무엇의 ID인지는 type이 정한다
private Long targetId;
private boolean read;

public static Notification create(Long memberId, NotificationType type, List<String> formatValues, Long targetId) {
String joined = formatValues.isEmpty() ? null : String.join(FORMAT_VALUE_DELIMITER, formatValues);
return new Notification(null, memberId, type, joined, targetId, false);
}

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

값만 이어 붙여 저장해서 문구를 고치면 이전 알림들도 바뀌는 것으로 이해했는데, 오히려 오타나 잘못된 정보를 수정할 수 있다는 점에서 좋은것 같습니다 !!

public static Notification of(
Long id,
Long memberId,
String type,
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<String> getFormatValueList() {
return formatValues == null ? List.of() : List.of(formatValues.split(FORMAT_VALUE_DELIMITER, -1));
}
}
Original file line number Diff line number Diff line change
@@ -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)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
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;

/**
* 알림 종류와 문구. 제목은 문구표의 굵은 첫 문장, 본문은 둘째 문장이다. 한 문장짜리는 본문이 없다.
* <p>
* 문구의 {@code %s}에는 알림을 만들 때 넘긴 값 목록이 앞에서부터 들어간다. 제목과 본문은 같은 값 목록을 각각 처음부터 쓴다.
* 조사는 "을(를)"처럼 적어 두면 앞 단어의 받침에 맞춰 바뀐다({@link JosaUtil}).
* 상수 이름이 {@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<String> formatValues) {
return JosaUtil.format(title, formatValues);
}

// 본문이 없는 알림은 null
public String formatBody(List<String> formatValues) {
return body == null ? null : JosaUtil.format(body, formatValues);
}
}
Original file line number Diff line number Diff line change
@@ -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);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
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;

/**
* 알림 수신자 조회. 알림함 대상은 활성 회원이고, 푸시 대상은 그중 토큰과 알림 설정이 있는 회원이다.
* FCM이 무효라고 응답한 토큰 정리도 여기서 한다.
*/
public interface NotificationRecipientRepository {
List<Long> findAllActiveMemberIds();
List<PushTarget> findPushTargets(Collection<Long> memberIds);
List<PushTarget> findAllPushTargets();
int clearFcmTokens(Collection<String> fcmTokens);
}
Original file line number Diff line number Diff line change
@@ -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<Notification> notifications);
}
Original file line number Diff line number Diff line change
@@ -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;

/**
* 알림 한 건을 수신자에게 보낸다. 알림함에는 수신자 전원에게 저장하고, 푸시는 그 카테고리를 켜 두고 토큰이 있는 회원에게만 보낸다.
* <p>
* 아웃박스 릴레이가 부르는 리스너에서 트랜잭션 없이 호출된다. 알림함 저장은 {@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<String> formatValues, long targetId, Collection<Long> memberIds) {
List<Long> recipients = List.copyOf(new LinkedHashSet<>(memberIds));
List<PushTarget> pushTargets = notificationRecipientRepository.findPushTargets(recipients);
send(type, formatValues, targetId, recipients, pushTargets);
}

/** 활성 회원 전체에서 제외할 회원(예: 이미 신청한 회원)을 뺀 나머지에게 보낸다. */
void dispatchToAll(NotificationType type, List<String> formatValues, long targetId, Collection<Long> excludedMemberIds) {
Set<Long> excluded = Set.copyOf(excludedMemberIds);
List<Long> recipients = notificationRecipientRepository.findAllActiveMemberIds().stream()
.filter(memberId -> !excluded.contains(memberId))
.toList();
List<PushTarget> pushTargets = notificationRecipientRepository.findAllPushTargets().stream()
.filter(target -> !excluded.contains(target.memberId()))
.toList();
send(type, formatValues, targetId, recipients, pushTargets);
}

private void send(
NotificationType type,
List<String> formatValues,
long targetId,
List<Long> recipients,
List<PushTarget> pushTargets
) {
if (recipients.isEmpty()) {
return;
}
notificationRepository.saveAll(recipients.stream()
.map(memberId -> Notification.create(memberId, type, formatValues, targetId))
.toList());

// 한 기기의 토큰이 여러 회원 행에 남아 있을 수 있어 토큰 단위로 중복을 지운다. 그대로 보내면 같은 기기에 여러 번 간다
List<String> tokens = pushTargets.stream()
.filter(target -> target.accepts(type.getCategory()))
.map(PushTarget::fcmToken)
.distinct()
.toList();

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

수신자들의 알림함에는 모두 저장하고 푸시는 알림을 켠 사람한테만 보내는 규칙을 send에서 지키게 하신 점 이해했습니다!

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<String> formatValues, long targetId) {
Map<String, String> 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<String> invalidTokens) {
if (invalidTokens.isEmpty()) {
return 0;
}
try {
notificationRecipientRepository.clearFcmTokens(invalidTokens);
} catch (RuntimeException e) {
log.warn("무효 FCM 토큰 정리 실패: tokenCount={}", invalidTokens.size(), e);
}
return invalidTokens.size();
}
}
2 changes: 2 additions & 0 deletions docs/conventions/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -374,6 +374,8 @@ Data Access {Domain}Repository / {Domain}Client 인터페이스 → core:dom

## 7. 모듈별 허용 의존성

모든 모듈은 루트 `build.gradle.kts`의 `subprojects`에서 Lombok(compileOnly), `spring-modulith-api`(compileOnly), `spring-boot-starter-logging`(SLF4J 파사드 + Logback)을 공통으로 받는다. 아래 표는 그 밖의 의존성이다. 모듈별 `build.gradle.kts`에 공통 의존성을 다시 선언하지 않는다.

| 모듈 | 허용 의존성 |
| --- | --- |
| `bootstrap` | 모든 `api:*` + `gateway:*` + `infrastructure:*` + `core:common` + Spring Boot 실행(Actuator, 스케줄링) + `spring-modulith-core`·ArchUnit(**test scope**) |
Expand Down
1 change: 0 additions & 1 deletion gateway/logging/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,6 @@ dependencies {
implementation(libs.springWeb)
implementation(libs.springContext)
implementation(libs.jakartaServletApi)
implementation(libs.slf4jApi)

testImplementation(platform(libs.springBootDependenciesBom))
testImplementation(libs.springBootStarterTest)
Expand Down
Loading