Skip to content
Merged
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
1 change: 1 addition & 0 deletions .dockerignore
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ local-storage/
.env
.env.*
!.env.example
*firebase-adminsdk*.json

# 문서·기타
docs/
Expand Down
8 changes: 8 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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
5 changes: 4 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -38,4 +38,7 @@ out/

.DS_Store

docs/plans
docs/plans

# Firebase 서비스 계정 키 (env로 주입하고 파일은 커밋하지 않는다)
*firebase-adminsdk*.json
Original file line number Diff line number Diff line change
@@ -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<String> tokens, PushMessage message);
}
Original file line number Diff line number Diff line change
@@ -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<String, String> data) {}
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
package kr.ac.kookmin.stream.member.domain.notification.domain;

public record PushSendOutcome(String token, PushSendStatus status) {}
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
package kr.ac.kookmin.stream.member.domain.notification.domain;

import java.util.List;

/**
* 토큰별 발송 결과. 멤버와의 매핑은 토큰을 넘긴 호출 측이 갖는다.
* INVALID_TOKEN 정리는 memberId가 아니라 토큰 값으로 해야 발송 이후 새로 등록된 토큰을 지우지 않는다.
*/
public record PushSendResult(List<PushSendOutcome> outcomes) {

public static PushSendResult of(List<String> tokens, PushSendStatus status) {
return new PushSendResult(tokens.stream()
.map(token -> new PushSendOutcome(token, status))
.toList());
}

public List<String> tokensWith(PushSendStatus status) {
return outcomes.stream()
.filter(outcome -> outcome.status() == status)
.map(PushSendOutcome::token)
.toList();
}
}
Original file line number Diff line number Diff line change
@@ -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 프로젝트, 잘못된 페이로드 등). 원인을 고치기 전에는 재시도해도 실패
}
3 changes: 3 additions & 0 deletions gradle/libs.versions.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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" }
Expand Down
3 changes: 3 additions & 0 deletions infrastructure/client/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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)
Expand Down
Original file line number Diff line number Diff line change
@@ -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);
}
}
}
Original file line number Diff line number Diff line change
@@ -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<ErrorCode> 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;
};
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

// 연결 거부·재설정(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<? extends Throwable> type) {
Set<Throwable> visited = new HashSet<>();
for (Throwable cause = e; cause != null && visited.add(cause); cause = cause.getCause()) {
if (type.isInstance(cause)) {
return true;
}
}
return false;
}
}
Original file line number Diff line number Diff line change
@@ -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
) {}
Original file line number Diff line number Diff line change
@@ -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 발송 구현체. 푸시 실패가 호출 측의 본 흐름을 깨지 않도록,
* 발송 요청 자체가 실패해도 예외를 던지지 않고 로그를 남긴 뒤 토큰별 실패 상태로 반환한다.
* <p>
* 알림은 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<String> tokens, PushMessage message) {
List<PushSendOutcome> outcomes = new ArrayList<>(tokens.size());
for (int from = 0; from < tokens.size(); from += MAX_TOKENS_PER_REQUEST) {
List<String> 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<String> 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;
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

// FCM이 등록 토큰에서 FID(Firebase Installation ID)로 전환 중이라 addAllTokens가 deprecated다(firebase-admin 9.10.0~).
// 종료일 공지 전까지는 완전히 지원되고, 전환 기간에는 tokens 필드가 FID도 받으므로 클라이언트 전환과 무관하게 그대로 쓴다.
// 종료일이 공지되면 addAllFids로 바꾼다
@SuppressWarnings("deprecation")
private MulticastMessage toMulticastMessage(List<String> 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<String> tokens, List<SendResponse> responses) {
List<PushSendOutcome> 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<PushSendOutcome> outcomes, List<SendResponse> responses) {
Map<PushSendStatus, Integer> counts = new EnumMap<>(PushSendStatus.class);
Map<PushSendStatus, FirebaseMessagingException> 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);
}
}
}
Original file line number Diff line number Diff line change
@@ -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<String> tokens, PushMessage message) {
log.info("푸시 발송(로그 모드): tokenCount={}, title={}, body={}, data={}",
tokens.size(), message.title(), message.body(), message.data());
return PushSendResult.of(tokens, PushSendStatus.SUCCESS);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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:}