diff --git a/.dockerignore b/.dockerignore index 22d81916..13c49650 100644 --- a/.dockerignore +++ b/.dockerignore @@ -19,6 +19,7 @@ local-storage/ .env .env.* !.env.example +*firebase-adminsdk*.json # 문서·기타 docs/ diff --git a/.env.example b/.env.example index a255ec55..3a2f8b41 100644 --- a/.env.example +++ b/.env.example @@ -43,3 +43,11 @@ KCONNECT_USER_INFO_STUDENT_ID_FIELD=replace-with-field-name KCONNECT_USER_INFO_NAME_FIELD=replace-with-field-name KCONNECT_USER_INFO_MAJOR_FIELD=replace-with-field-name KCONNECT_USER_INFO_ACADEMIC_STATUS_FIELD=replace-with-field-name + +### infrastructure:client — 푸시 알림 (log | fcm) ### +# log는 실제 발송 없이 로그만 남긴다 (로컬 기본값) +PUSH_TYPE=log + +# type=fcm일 때. Firebase 콘솔 > 프로젝트 설정 > 서비스 계정에서 받은 비공개 키 JSON을 base64로 인코딩한 값 +# 예: base64 < firebase-adminsdk.json | tr -d '\n' +FIREBASE_CREDENTIALS_BASE64=replace-with-base64-encoded-service-account-json diff --git a/.gitignore b/.gitignore index 4364c239..da63ff01 100644 --- a/.gitignore +++ b/.gitignore @@ -38,4 +38,7 @@ out/ .DS_Store -docs/plans \ No newline at end of file +docs/plans + +# Firebase 서비스 계정 키 (env로 주입하고 파일은 커밋하지 않는다) +*firebase-adminsdk*.json diff --git a/core/domain/member/src/main/java/kr/ac/kookmin/stream/member/domain/notification/client/PushNotificationClient.java b/core/domain/member/src/main/java/kr/ac/kookmin/stream/member/domain/notification/client/PushNotificationClient.java new file mode 100644 index 00000000..04da8e3e --- /dev/null +++ b/core/domain/member/src/main/java/kr/ac/kookmin/stream/member/domain/notification/client/PushNotificationClient.java @@ -0,0 +1,9 @@ +package kr.ac.kookmin.stream.member.domain.notification.client; + +import java.util.List; +import kr.ac.kookmin.stream.member.domain.notification.domain.PushMessage; +import kr.ac.kookmin.stream.member.domain.notification.domain.PushSendResult; + +public interface PushNotificationClient { + PushSendResult send(List tokens, PushMessage message); +} diff --git a/core/domain/member/src/main/java/kr/ac/kookmin/stream/member/domain/notification/domain/PushMessage.java b/core/domain/member/src/main/java/kr/ac/kookmin/stream/member/domain/notification/domain/PushMessage.java new file mode 100644 index 00000000..5d24264e --- /dev/null +++ b/core/domain/member/src/main/java/kr/ac/kookmin/stream/member/domain/notification/domain/PushMessage.java @@ -0,0 +1,5 @@ +package kr.ac.kookmin.stream.member.domain.notification.domain; + +import java.util.Map; + +public record PushMessage(String title, String body, Map data) {} diff --git a/core/domain/member/src/main/java/kr/ac/kookmin/stream/member/domain/notification/domain/PushSendOutcome.java b/core/domain/member/src/main/java/kr/ac/kookmin/stream/member/domain/notification/domain/PushSendOutcome.java new file mode 100644 index 00000000..616e0278 --- /dev/null +++ b/core/domain/member/src/main/java/kr/ac/kookmin/stream/member/domain/notification/domain/PushSendOutcome.java @@ -0,0 +1,3 @@ +package kr.ac.kookmin.stream.member.domain.notification.domain; + +public record PushSendOutcome(String token, PushSendStatus status) {} diff --git a/core/domain/member/src/main/java/kr/ac/kookmin/stream/member/domain/notification/domain/PushSendResult.java b/core/domain/member/src/main/java/kr/ac/kookmin/stream/member/domain/notification/domain/PushSendResult.java new file mode 100644 index 00000000..cf168174 --- /dev/null +++ b/core/domain/member/src/main/java/kr/ac/kookmin/stream/member/domain/notification/domain/PushSendResult.java @@ -0,0 +1,23 @@ +package kr.ac.kookmin.stream.member.domain.notification.domain; + +import java.util.List; + +/** + * 토큰별 발송 결과. 멤버와의 매핑은 토큰을 넘긴 호출 측이 갖는다. + * INVALID_TOKEN 정리는 memberId가 아니라 토큰 값으로 해야 발송 이후 새로 등록된 토큰을 지우지 않는다. + */ +public record PushSendResult(List outcomes) { + + public static PushSendResult of(List tokens, PushSendStatus status) { + return new PushSendResult(tokens.stream() + .map(token -> new PushSendOutcome(token, status)) + .toList()); + } + + public List tokensWith(PushSendStatus status) { + return outcomes.stream() + .filter(outcome -> outcome.status() == status) + .map(PushSendOutcome::token) + .toList(); + } +} diff --git a/core/domain/member/src/main/java/kr/ac/kookmin/stream/member/domain/notification/domain/PushSendStatus.java b/core/domain/member/src/main/java/kr/ac/kookmin/stream/member/domain/notification/domain/PushSendStatus.java new file mode 100644 index 00000000..84c264e6 --- /dev/null +++ b/core/domain/member/src/main/java/kr/ac/kookmin/stream/member/domain/notification/domain/PushSendStatus.java @@ -0,0 +1,8 @@ +package kr.ac.kookmin.stream.member.domain.notification.domain; + +public enum PushSendStatus { + SUCCESS, + INVALID_TOKEN, // 만료·삭제되어 다시 보내도 성공할 수 없는 토큰. fcm_token 정리 대상 + RETRYABLE, // 일시적 장애로 실패. 해당 토큰만 재시도 대상 + FAILED // 설정·요청 문제(자격증명, APNs 키, 다른 Firebase 프로젝트, 잘못된 페이로드 등). 원인을 고치기 전에는 재시도해도 실패 +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index dfd660e9..6b967646 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -7,6 +7,7 @@ junitBomVersion = "6.0.0" lombokVersion = "1.18.36" springdocVersion = "3.1.1" awsSdkVersion = "2.54.15" +firebaseAdminVersion = "9.11.0" [libraries] # BOMs — import as `platform(...)` in modules that don't apply the Boot plugin directly @@ -47,6 +48,8 @@ mysqlConnectorJ = { module = "com.mysql:mysql-connector-j" } awsS3 = { module = "software.amazon.awssdk:s3" } +firebaseAdmin = { module = "com.google.firebase:firebase-admin", version.ref = "firebaseAdminVersion" } + jjwtApi = { module = "io.jsonwebtoken:jjwt-api", version.ref = "jjwtVersion" } jjwtImpl = { module = "io.jsonwebtoken:jjwt-impl", version.ref = "jjwtVersion" } jjwtJackson = { module = "io.jsonwebtoken:jjwt-jackson", version.ref = "jjwtVersion" } diff --git a/infrastructure/client/build.gradle.kts b/infrastructure/client/build.gradle.kts index ed5bd55e..c1744853 100644 --- a/infrastructure/client/build.gradle.kts +++ b/infrastructure/client/build.gradle.kts @@ -8,6 +8,7 @@ dependencies { implementation(project(":core:common")) implementation(project(":core:domain:auth")) implementation(project(":core:domain:internal")) + implementation(project(":core:domain:member")) implementation(platform(libs.springBootDependenciesBom)) implementation(libs.springBootStarter) @@ -17,6 +18,8 @@ dependencies { implementation(platform(libs.awsSdkBom)) implementation(libs.awsS3) + implementation(libs.firebaseAdmin) + testImplementation(platform(libs.springBootDependenciesBom)) testImplementation(libs.springBootStarterTest) testRuntimeOnly(libs.junitPlatformLauncher) diff --git a/infrastructure/client/src/main/java/kr/ac/kookmin/stream/client/push/fcm/FcmConfig.java b/infrastructure/client/src/main/java/kr/ac/kookmin/stream/client/push/fcm/FcmConfig.java new file mode 100644 index 00000000..fbfab250 --- /dev/null +++ b/infrastructure/client/src/main/java/kr/ac/kookmin/stream/client/push/fcm/FcmConfig.java @@ -0,0 +1,52 @@ +package kr.ac.kookmin.stream.client.push.fcm; + +import com.google.auth.oauth2.ServiceAccountCredentials; +import com.google.firebase.FirebaseApp; +import com.google.firebase.FirebaseOptions; +import com.google.firebase.messaging.FirebaseMessaging; +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.util.Base64; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.util.StringUtils; + +/** + * {@link FcmPushNotificationClient}가 쓰는 Firebase SDK 빈 설정. + * 서비스 계정 JSON은 파일 마운트 없이 env 하나로 주입하도록 base64 인코딩 값으로 받는다. + */ +@Configuration +@ConditionalOnProperty(prefix = "push", name = "type", havingValue = "fcm") +public class FcmConfig { + + // FirebaseApp에는 close()가 없어 스프링이 종료 메서드를 추론하지 못하므로 delete를 직접 지정한다 + @Bean(destroyMethod = "delete") + public FirebaseApp firebaseApp(FcmProperties properties) { + // FcmProperties는 로그 모드에서도 바인딩되므로 필수값 검사는 fcm 모드에서만 뜨는 이곳에서 한다 + if (!StringUtils.hasText(properties.credentialsBase64())) { + throw new IllegalStateException("push.type=fcm이면 FIREBASE_CREDENTIALS_BASE64가 필요하다"); + } + FirebaseOptions options = FirebaseOptions.builder() + .setCredentials(parseCredentials(properties.credentialsBase64())) + .build(); + return FirebaseApp.initializeApp(options); + } + + @Bean + public FirebaseMessaging firebaseMessaging(FirebaseApp firebaseApp) { + return FirebaseMessaging.getInstance(firebaseApp); + } + + // MIME 디코더는 base64가 아닌 문자를 건너뛰어 placeholder도 예외 없이 디코딩되고, 실패는 JSON 파싱에서 난다. + // 어느 단계에서 실패하든 SDK 예외만으로는 어떤 설정이 문제인지 알 수 없으므로 환경변수 오류로 바꿔 던진다 + private ServiceAccountCredentials parseCredentials(String credentialsBase64) { + try { + // Linux base64는 76자마다 줄바꿈을 넣으므로 줄바꿈을 무시하는 MIME 디코더를 쓴다 + byte[] credentialsJson = Base64.getMimeDecoder().decode(credentialsBase64); + return ServiceAccountCredentials.fromStream(new ByteArrayInputStream(credentialsJson)); + } catch (IllegalArgumentException | IOException e) { + throw new IllegalStateException("FIREBASE_CREDENTIALS_BASE64가 올바른 서비스 계정 JSON의 base64 값이 아니다", e); + } + } +} diff --git a/infrastructure/client/src/main/java/kr/ac/kookmin/stream/client/push/fcm/FcmErrorClassifier.java b/infrastructure/client/src/main/java/kr/ac/kookmin/stream/client/push/fcm/FcmErrorClassifier.java new file mode 100644 index 00000000..5c4f6818 --- /dev/null +++ b/infrastructure/client/src/main/java/kr/ac/kookmin/stream/client/push/fcm/FcmErrorClassifier.java @@ -0,0 +1,56 @@ +package kr.ac.kookmin.stream.client.push.fcm; + +import com.google.firebase.ErrorCode; +import com.google.firebase.messaging.FirebaseMessagingException; +import com.google.firebase.messaging.MessagingErrorCode; +import java.net.SocketException; +import java.util.EnumSet; +import java.util.HashSet; +import java.util.Set; +import kr.ac.kookmin.stream.member.domain.notification.domain.PushSendStatus; +import lombok.AccessLevel; +import lombok.NoArgsConstructor; + +/** + * FCM 발송 예외를 토큰별 결과 상태로 분류한다. + */ +@NoArgsConstructor(access = AccessLevel.PRIVATE) +final class FcmErrorClassifier { + + // FCM 에러 코드가 없는 실패(타임아웃·호스트 조회 실패 등 네트워크 오류)의 일시적 장애 판정 기준 + private static final Set TRANSIENT_PLATFORM_ERRORS = + EnumSet.of(ErrorCode.UNAVAILABLE, ErrorCode.INTERNAL, ErrorCode.DEADLINE_EXCEEDED); + + static PushSendStatus classify(FirebaseMessagingException e) { + MessagingErrorCode code = e.getMessagingErrorCode(); + if (code == null) { + return isTransientNetworkError(e) ? PushSendStatus.RETRYABLE : PushSendStatus.FAILED; + } + // default를 두지 않는다. SDK에 새 에러 코드가 생기면 업그레이드 시 컴파일 에러로 분류 누락이 드러난다 + return switch (code) { + // 토큰이 만료·삭제됐다는 신호. 토큰 정리 대상은 이것뿐이다 + case UNREGISTERED -> PushSendStatus.INVALID_TOKEN; + // UNAVAILABLE(503)은 SDK가 이미 최대 4회 재시도하고도 남은 실패다 + case UNAVAILABLE, INTERNAL, QUOTA_EXCEEDED -> PushSendStatus.RETRYABLE; + // 아래는 전 토큰에 한꺼번에 날 수 있어 토큰 정리 대상으로 보면 멀쩡한 토큰까지 지운다. + // INVALID_ARGUMENT는 잘못된 토큰뿐 아니라 페이로드 오류에도 나고, + // SENDER_ID_MISMATCH는 서비스 계정이 다른 프로젝트를 가리켜도 난다 + case INVALID_ARGUMENT, THIRD_PARTY_AUTH_ERROR, SENDER_ID_MISMATCH -> PushSendStatus.FAILED; + }; + } + + // 연결 거부·재설정(ConnectException 등)은 SDK가 자격증명 오류와 같은 UNKNOWN으로 내리므로 원인 예외로 가린다 + private static boolean isTransientNetworkError(FirebaseMessagingException e) { + return TRANSIENT_PLATFORM_ERRORS.contains(e.getErrorCode()) || causedBy(e, SocketException.class); + } + + private static boolean causedBy(Throwable e, Class type) { + Set visited = new HashSet<>(); + for (Throwable cause = e; cause != null && visited.add(cause); cause = cause.getCause()) { + if (type.isInstance(cause)) { + return true; + } + } + return false; + } +} diff --git a/infrastructure/client/src/main/java/kr/ac/kookmin/stream/client/push/fcm/FcmProperties.java b/infrastructure/client/src/main/java/kr/ac/kookmin/stream/client/push/fcm/FcmProperties.java new file mode 100644 index 00000000..7a9fb49d --- /dev/null +++ b/infrastructure/client/src/main/java/kr/ac/kookmin/stream/client/push/fcm/FcmProperties.java @@ -0,0 +1,8 @@ +package kr.ac.kookmin.stream.client.push.fcm; + +import org.springframework.boot.context.properties.ConfigurationProperties; + +@ConfigurationProperties(prefix = "push.fcm") +public record FcmProperties( + String credentialsBase64 +) {} diff --git a/infrastructure/client/src/main/java/kr/ac/kookmin/stream/client/push/fcm/FcmPushNotificationClient.java b/infrastructure/client/src/main/java/kr/ac/kookmin/stream/client/push/fcm/FcmPushNotificationClient.java new file mode 100644 index 00000000..b240aa98 --- /dev/null +++ b/infrastructure/client/src/main/java/kr/ac/kookmin/stream/client/push/fcm/FcmPushNotificationClient.java @@ -0,0 +1,125 @@ +package kr.ac.kookmin.stream.client.push.fcm; + +import com.google.firebase.messaging.BatchResponse; +import com.google.firebase.messaging.FirebaseMessaging; +import com.google.firebase.messaging.FirebaseMessagingException; +import com.google.firebase.messaging.MulticastMessage; +import com.google.firebase.messaging.Notification; +import com.google.firebase.messaging.SendResponse; +import java.util.ArrayList; +import java.util.EnumMap; +import java.util.List; +import java.util.Map; +import kr.ac.kookmin.stream.member.domain.notification.client.PushNotificationClient; +import kr.ac.kookmin.stream.member.domain.notification.domain.PushMessage; +import kr.ac.kookmin.stream.member.domain.notification.domain.PushSendOutcome; +import kr.ac.kookmin.stream.member.domain.notification.domain.PushSendResult; +import kr.ac.kookmin.stream.member.domain.notification.domain.PushSendStatus; +import lombok.RequiredArgsConstructor; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.stereotype.Component; + +/** + * Firebase Admin SDK 기반 FCM 발송 구현체. 푸시 실패가 호출 측의 본 흐름을 깨지 않도록, + * 발송 요청 자체가 실패해도 예외를 던지지 않고 로그를 남긴 뒤 토큰별 실패 상태로 반환한다. + *

+ * 알림은 notification 페이로드(title/body)로 보내 백그라운드 표시를 OS에 맡긴다. data-only는 iOS에서 무음 푸시로 + * 취급돼 전달이 제한되기 때문이다. 앱이 백그라운드 핸들러에서 로컬 알림을 또 띄우면 알림이 두 번 뜨므로, + * 앱은 포그라운드에서만 직접 표시한다. + */ +@Component +@ConditionalOnProperty(prefix = "push", name = "type", havingValue = "fcm") +@RequiredArgsConstructor +public class FcmPushNotificationClient implements PushNotificationClient { + + private static final Logger log = LoggerFactory.getLogger(FcmPushNotificationClient.class); + + // FCM 멀티캐스트 한 번에 담을 수 있는 최대 토큰 수 + private static final int MAX_TOKENS_PER_REQUEST = 500; + + private final FirebaseMessaging firebaseMessaging; + + @Override + public PushSendResult send(List tokens, PushMessage message) { + List outcomes = new ArrayList<>(tokens.size()); + for (int from = 0; from < tokens.size(); from += MAX_TOKENS_PER_REQUEST) { + List chunk = tokens.subList(from, Math.min(from + MAX_TOKENS_PER_REQUEST, tokens.size())); + outcomes.addAll(sendChunk(chunk, message).outcomes()); + } + return new PushSendResult(outcomes); + } + + private PushSendResult sendChunk(List tokens, PushMessage message) { + BatchResponse response; + try { + response = firebaseMessaging.sendEachForMulticast(toMulticastMessage(tokens, message)); + } catch (FirebaseMessagingException e) { + PushSendStatus status = FcmErrorClassifier.classify(e); + logFailure(status, tokens.size(), e); + return PushSendResult.of(tokens, status); + } catch (RuntimeException e) { + // data의 null 값처럼 메시지 구성 중 나는 예외도 호출 측 본 흐름을 깨지 않도록 발송 실패로 돌려준다 + log.error("FCM 메시지 구성·발송 중 예외: tokenCount={}", tokens.size(), e); + return PushSendResult.of(tokens, PushSendStatus.FAILED); + } + PushSendResult result = toResult(tokens, response.getResponses()); + logFailures(result.outcomes(), response.getResponses()); + return result; + } + + // FCM이 등록 토큰에서 FID(Firebase Installation ID)로 전환 중이라 addAllTokens가 deprecated다(firebase-admin 9.10.0~). + // 종료일 공지 전까지는 완전히 지원되고, 전환 기간에는 tokens 필드가 FID도 받으므로 클라이언트 전환과 무관하게 그대로 쓴다. + // 종료일이 공지되면 addAllFids로 바꾼다 + @SuppressWarnings("deprecation") + private MulticastMessage toMulticastMessage(List tokens, PushMessage message) { + MulticastMessage.Builder builder = MulticastMessage.builder() + .addAllTokens(tokens) + .setNotification(Notification.builder() + .setTitle(message.title()) + .setBody(message.body()) + .build()); + if (message.data() != null) { + builder.putAllData(message.data()); + } + return builder.build(); + } + + // BatchResponse의 응답 순서는 요청 토큰 순서와 같다 + private PushSendResult toResult(List tokens, List responses) { + List outcomes = new ArrayList<>(tokens.size()); + for (int i = 0; i < responses.size(); i++) { + SendResponse response = responses.get(i); + PushSendStatus status = response.isSuccessful() + ? PushSendStatus.SUCCESS + : FcmErrorClassifier.classify(response.getException()); + outcomes.add(new PushSendOutcome(tokens.get(i), status)); + } + return new PushSendResult(outcomes); + } + + // 자격증명 오류처럼 토큰과 무관한 실패도 예외가 아니라 토큰별 실패로 돌아오므로, 묶음 안에서 상태별로 한 번씩 남긴다 + private void logFailures(List outcomes, List responses) { + Map counts = new EnumMap<>(PushSendStatus.class); + Map samples = new EnumMap<>(PushSendStatus.class); + for (int i = 0; i < outcomes.size(); i++) { + PushSendStatus status = outcomes.get(i).status(); + if (status != PushSendStatus.SUCCESS) { + counts.merge(status, 1, Integer::sum); + samples.putIfAbsent(status, responses.get(i).getException()); + } + } + counts.forEach((status, count) -> logFailure(status, count, samples.get(status))); + } + + // 설정 문제(FAILED)는 사람이 고쳐야 하므로 ERROR, 일시적 장애(RETRYABLE)는 WARN으로 남긴다. + // INVALID_TOKEN은 정상적인 토큰 만료이고 호출 측이 정리하므로 남기지 않는다 + private void logFailure(PushSendStatus status, int tokenCount, FirebaseMessagingException e) { + if (status == PushSendStatus.FAILED) { + log.error("FCM 발송 실패(설정 확인 필요): tokenCount={}, errorCode={}", tokenCount, e.getErrorCode(), e); + } else if (status == PushSendStatus.RETRYABLE) { + log.warn("FCM 발송 일시 실패(재시도 가능): tokenCount={}, errorCode={}", tokenCount, e.getErrorCode(), e); + } + } +} diff --git a/infrastructure/client/src/main/java/kr/ac/kookmin/stream/client/push/log/LogPushNotificationClient.java b/infrastructure/client/src/main/java/kr/ac/kookmin/stream/client/push/log/LogPushNotificationClient.java new file mode 100644 index 00000000..63f39332 --- /dev/null +++ b/infrastructure/client/src/main/java/kr/ac/kookmin/stream/client/push/log/LogPushNotificationClient.java @@ -0,0 +1,28 @@ +package kr.ac.kookmin.stream.client.push.log; + +import java.util.List; +import kr.ac.kookmin.stream.member.domain.notification.client.PushNotificationClient; +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 org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.stereotype.Component; + +/** + * Firebase 자격증명 없이 기동하기 위한 로컬용 구현체. 실제로 발송하지 않고 로그만 남긴 뒤 전부 성공으로 반환한다. + */ +@Component +@ConditionalOnProperty(prefix = "push", name = "type", havingValue = "log", matchIfMissing = true) +public class LogPushNotificationClient implements PushNotificationClient { + + private static final Logger log = LoggerFactory.getLogger(LogPushNotificationClient.class); + + @Override + public PushSendResult send(List tokens, PushMessage message) { + log.info("푸시 발송(로그 모드): tokenCount={}, title={}, body={}, data={}", + tokens.size(), message.title(), message.body(), message.data()); + return PushSendResult.of(tokens, PushSendStatus.SUCCESS); + } +} diff --git a/infrastructure/client/src/main/resources/application-infrastructure-client.yml b/infrastructure/client/src/main/resources/application-infrastructure-client.yml index 6b8b40ba..bc315e73 100644 --- a/infrastructure/client/src/main/resources/application-infrastructure-client.yml +++ b/infrastructure/client/src/main/resources/application-infrastructure-client.yml @@ -29,3 +29,8 @@ oauth: academic-status-field: ${KCONNECT_USER_INFO_ACADEMIC_STATUS_FIELD:} connect-timeout: 3s read-timeout: 5s + +push: + type: ${PUSH_TYPE:log} + fcm: + credentials-base64: ${FIREBASE_CREDENTIALS_BASE64:}