Skip to content

Commit 4ecdd51

Browse files
authored
Merge pull request #12 from SEMOSAN/feat/#11-fcm
[Feat] FCM 푸시 알림 구현
2 parents a6ae612 + 5f0e28a commit 4ecdd51

26 files changed

Lines changed: 751 additions & 4 deletions

.gitignore

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,4 +45,11 @@ application-local.yaml
4545
k8s/app/secret.yaml
4646
k8s/postgres/secret.yaml
4747

48+
# OMC
4849
.omc
50+
51+
# Firebase
52+
src/main/resources/firebase/
53+
54+
## docs
55+
docs/**

build.gradle

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,9 @@ dependencies {
3939
implementation 'io.jsonwebtoken:jjwt-api:0.12.3'
4040
implementation 'io.jsonwebtoken:jjwt-impl:0.12.3'
4141
implementation 'io.jsonwebtoken:jjwt-jackson:0.12.3'
42+
43+
// FCM
44+
implementation 'com.google.firebase:firebase-admin:9.8.0'
4245
}
4346

4447
tasks.named('test') {
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
package com.semosan.api.common.config;
2+
3+
import org.springframework.context.annotation.Bean;
4+
import org.springframework.context.annotation.Configuration;
5+
import org.springframework.scheduling.annotation.EnableAsync;
6+
import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;
7+
8+
import java.util.concurrent.Executor;
9+
import java.util.concurrent.ThreadPoolExecutor;
10+
11+
@Configuration
12+
@EnableAsync
13+
public class AsyncConfig {
14+
15+
@Bean(name = "notificationTaskExecutor")
16+
public Executor notificationTaskExecutor() {
17+
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
18+
executor.setCorePoolSize(5);
19+
executor.setMaxPoolSize(20);
20+
executor.setQueueCapacity(500);
21+
executor.setThreadNamePrefix("notif-async-");
22+
// 큐 가득 차면 호출 스레드가 직접 실행 → 메시지 유실 방지
23+
executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy());
24+
// executor.initialize() 호출 안 함 → Spring이 afterPropertiesSet()에서 자동 호출
25+
return executor;
26+
}
27+
}
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
package com.semosan.api.common.config;
2+
3+
import com.google.auth.oauth2.GoogleCredentials;
4+
import com.google.firebase.FirebaseApp;
5+
import com.google.firebase.FirebaseOptions;
6+
import jakarta.annotation.PostConstruct;
7+
import lombok.extern.slf4j.Slf4j;
8+
import org.springframework.beans.factory.annotation.Value;
9+
import org.springframework.context.annotation.Configuration;
10+
import org.springframework.core.io.ClassPathResource;
11+
12+
import java.io.IOException;
13+
import java.io.InputStream;
14+
15+
@Slf4j
16+
@Configuration
17+
public class FirebaseConfig {
18+
19+
@Value("${firebase.service-account-path}")
20+
private String serviceAccountPath;
21+
22+
// 이 빈 만들어진 직후에 이 메서드 자동 실행, 앱 시작할 때 딱 한 번 실행됨
23+
@PostConstruct
24+
public void initialize() throws IOException {
25+
if (!FirebaseApp.getApps().isEmpty()) {
26+
log.info("FirebaseApp already initialized, skipping");
27+
return;
28+
}
29+
30+
// new File() 안 쓰는 이유는 jar로 빌드하면 파일들이 jar 안에 들어가는데, new File()은 jar 내부 파일을 못 읽음
31+
// ClassPathResource는 잘 읽는다고 함
32+
try (InputStream serviceAccount = new ClassPathResource(serviceAccountPath).getInputStream()) {
33+
FirebaseOptions options = FirebaseOptions.builder()
34+
.setCredentials(GoogleCredentials.fromStream(serviceAccount))
35+
.build();
36+
37+
FirebaseApp.initializeApp(options);
38+
log.info("FirebaseApp initialized successfully");
39+
}
40+
}
41+
42+
}
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
package com.semosan.api.common.fcm;
2+
3+
import com.google.firebase.messaging.FirebaseMessaging;
4+
import com.google.firebase.messaging.FirebaseMessagingException;
5+
import com.google.firebase.messaging.Message;
6+
import com.google.firebase.messaging.Notification;
7+
import lombok.extern.slf4j.Slf4j;
8+
import org.springframework.stereotype.Service;
9+
10+
import java.util.Map;
11+
12+
@Slf4j
13+
@Service
14+
public class FcmService {
15+
16+
public String sendMessage(String token, String title, String body, Map<String, String> data) throws FirebaseMessagingException {
17+
Notification notification = Notification.builder()
18+
.setTitle(title)
19+
.setBody(body)
20+
.build();
21+
22+
Message.Builder builder = Message.builder()
23+
.setToken(token)
24+
.setNotification(notification);
25+
26+
if (data != null && !data.isEmpty()) {
27+
builder.putAllData(data);
28+
}
29+
30+
String response = FirebaseMessaging.getInstance().send(builder.build());
31+
log.info("FCM 발송 성공: {}", response);
32+
return response;
33+
}
34+
35+
}

src/main/java/com/semosan/api/common/status/ErrorStatus.java

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,11 @@ public enum ErrorStatus implements BaseStatus {
5050
*/
5151
USER_NOT_FOUND(HttpStatus.NOT_FOUND, "USER_404_1", "사용자를 찾을 수 없습니다."),
5252

53+
/**
54+
* Notification
55+
*/
56+
NOTIFICATION_PARAMS_INVALID(HttpStatus.BAD_REQUEST, "NOTIF_400_1", "알림 파라미터가 유효하지 않습니다."),
57+
5358
/**
5459
* Apple OAuth
5560
*/

src/main/java/com/semosan/api/common/status/SuccessStatus.java

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,14 @@ public enum SuccessStatus implements BaseStatus {
2020
/**
2121
* User
2222
*/
23-
WITHDRAW_SUCCESS(HttpStatus.OK, "USER_200_1", "회원 탈퇴가 완료되었습니다.");
23+
WITHDRAW_SUCCESS(HttpStatus.OK, "USER_200_1", "회원 탈퇴가 완료되었습니다."),
24+
25+
/**
26+
* FCM / Notification
27+
*/
28+
FCM_TOKEN_REGISTER_SUCCESS(HttpStatus.OK, "FCM_200_1", "FCM 토큰이 등록되었습니다."),
29+
FCM_TOKEN_DELETE_SUCCESS(HttpStatus.OK, "FCM_200_2", "FCM 토큰이 삭제되었습니다."),
30+
NOTIFICATION_SEND_SUCCESS(HttpStatus.OK, "NOTIF_200_1", "알림 발송 요청에 성공했습니다.");
2431

2532
private final HttpStatus httpStatus;
2633
private final String code;
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
package com.semosan.api.domain.notification.controller;
2+
3+
import com.semosan.api.common.response.ApiResponse;
4+
import com.semosan.api.common.status.SuccessStatus;
5+
import com.semosan.api.domain.notification.dto.FcmTokenDeleteRequest;
6+
import com.semosan.api.domain.notification.dto.FcmTokenRegisterRequest;
7+
import com.semosan.api.domain.notification.service.FcmTokenService;
8+
import jakarta.validation.Valid;
9+
import lombok.RequiredArgsConstructor;
10+
import org.springframework.http.ResponseEntity;
11+
import org.springframework.security.core.annotation.AuthenticationPrincipal;
12+
import org.springframework.web.bind.annotation.DeleteMapping;
13+
import org.springframework.web.bind.annotation.PostMapping;
14+
import org.springframework.web.bind.annotation.RequestBody;
15+
import org.springframework.web.bind.annotation.RequestMapping;
16+
import org.springframework.web.bind.annotation.RestController;
17+
18+
@RestController
19+
@RequestMapping("/api/fcm")
20+
@RequiredArgsConstructor
21+
public class FcmTokenController {
22+
23+
private final FcmTokenService fcmTokenService;
24+
25+
@PostMapping("/tokens")
26+
public ResponseEntity<ApiResponse<Void>> register(
27+
@AuthenticationPrincipal Long userId,
28+
@Valid @RequestBody FcmTokenRegisterRequest request
29+
) {
30+
fcmTokenService.register(userId, request.token(), request.deviceType());
31+
return ApiResponse.success(SuccessStatus.FCM_TOKEN_REGISTER_SUCCESS);
32+
}
33+
34+
@DeleteMapping("/tokens")
35+
public ResponseEntity<ApiResponse<Void>> delete(
36+
@AuthenticationPrincipal Long userId,
37+
@Valid @RequestBody FcmTokenDeleteRequest request
38+
) {
39+
fcmTokenService.delete(userId, request.token());
40+
return ApiResponse.success(SuccessStatus.FCM_TOKEN_DELETE_SUCCESS);
41+
}
42+
}
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
package com.semosan.api.domain.notification.controller;
2+
3+
import com.semosan.api.common.response.ApiResponse;
4+
import com.semosan.api.common.status.SuccessStatus;
5+
import com.semosan.api.domain.notification.dto.NotificationTestRequest;
6+
import com.semosan.api.domain.notification.service.NotificationService;
7+
import jakarta.validation.Valid;
8+
import lombok.RequiredArgsConstructor;
9+
import org.springframework.context.annotation.Profile;
10+
import org.springframework.http.ResponseEntity;
11+
import org.springframework.web.bind.annotation.PostMapping;
12+
import org.springframework.web.bind.annotation.RequestBody;
13+
import org.springframework.web.bind.annotation.RequestMapping;
14+
import org.springframework.web.bind.annotation.RestController;
15+
16+
/**
17+
* 알림 테스트용 컨트롤러. local 프로필에서만 활성화 (운영 배포 시 자동 비활성화).
18+
*/
19+
@Profile("local")
20+
@RestController
21+
@RequestMapping("/api/notifications")
22+
@RequiredArgsConstructor
23+
public class NotificationTestController {
24+
25+
private final NotificationService notificationService;
26+
27+
@PostMapping("/test")
28+
public ResponseEntity<ApiResponse<Void>> send(
29+
@Valid @RequestBody NotificationTestRequest request
30+
) {
31+
notificationService.send(request.receiverId(), request.type(), request.params());
32+
return ApiResponse.success(SuccessStatus.NOTIFICATION_SEND_SUCCESS);
33+
}
34+
}
Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
package com.semosan.api.domain.notification.dispatcher;
2+
3+
import com.fasterxml.jackson.core.JsonProcessingException;
4+
import com.fasterxml.jackson.databind.ObjectMapper;
5+
import com.google.firebase.messaging.FirebaseMessagingException;
6+
import com.google.firebase.messaging.MessagingErrorCode;
7+
import com.semosan.api.common.fcm.FcmService;
8+
import com.semosan.api.domain.notification.service.FcmTokenService;
9+
import lombok.RequiredArgsConstructor;
10+
import lombok.extern.slf4j.Slf4j;
11+
import org.springframework.scheduling.annotation.Async;
12+
import org.springframework.stereotype.Component;
13+
14+
import java.util.HashMap;
15+
import java.util.Map;
16+
17+
@Slf4j
18+
@Component
19+
@RequiredArgsConstructor
20+
public class AsyncNotificationDispatcher implements NotificationDispatcher {
21+
22+
private final FcmService fcmService;
23+
private final FcmTokenService fcmTokenService;
24+
private final ObjectMapper objectMapper;
25+
26+
/**
27+
* @Async 가 같은 클래스에 있는 함수를 호출 할 때는 비동기로 작동하지 않으니 주의해야함
28+
* AOP 프록시를 사용해서 그렇고, 그래서 빈으로 등록된 다른 클래스에서 호출해야함
29+
*/
30+
@Override
31+
@Async("notificationTaskExecutor")
32+
public void dispatch(NotificationDispatchCommand cmd) {
33+
Map<String, String> dataPayload = buildDataPayload(cmd);
34+
35+
for (String token : cmd.tokens()) {
36+
try {
37+
fcmService.sendMessage(token, cmd.title(), cmd.body(), dataPayload);
38+
} catch (FirebaseMessagingException e) {
39+
handleSendError(token, e);
40+
} catch (Exception e) {
41+
log.error("FCM 발송 중 알 수 없는 에러 (token={}): {}", token, e.getMessage(), e);
42+
}
43+
}
44+
}
45+
46+
private Map<String, String> buildDataPayload(NotificationDispatchCommand cmd) {
47+
Map<String, String> data = new HashMap<>();
48+
data.put("type", cmd.type().name());
49+
data.put("notificationId", String.valueOf(cmd.notificationId()));
50+
try {
51+
data.put("extras", objectMapper.writeValueAsString(cmd.extras()));
52+
} catch (JsonProcessingException e) {
53+
log.error("extras 직렬화 실패", e);
54+
data.put("extras", "{}");
55+
}
56+
return data;
57+
}
58+
59+
private void handleSendError(String token, FirebaseMessagingException e) {
60+
MessagingErrorCode code = e.getMessagingErrorCode();
61+
if (code == MessagingErrorCode.UNREGISTERED || code == MessagingErrorCode.INVALID_ARGUMENT) {
62+
log.warn("만료/잘못된 토큰 삭제 (token={}, code={})", token, code);
63+
fcmTokenService.deleteExpired(token);
64+
} else {
65+
log.error("FCM 발송 실패 (token={}, code={}): {}", token, code, e.getMessage());
66+
}
67+
}
68+
}

0 commit comments

Comments
 (0)