-
Notifications
You must be signed in to change notification settings - Fork 0
[feat] FCM 기반 푸쉬알림 기능 구현 #293
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
c999af7
033e028
21e1148
8550f95
b855d4b
ada09cd
feafca3
9a4fd41
d91a428
9d5b3f5
3455198
6c73258
5b8764f
f93a277
20f1d78
0c46217
75df1f3
1108eac
d868d40
138c66f
70e2eca
421a90e
1031d23
628a502
a9743d1
4a26f10
e50c8be
7d38012
9ebe067
fecad55
390ad60
01eb5af
b663c7b
0c433e0
9ac24c1
980706d
37866a4
69e3120
ba59113
b8c9e21
2249963
c565094
727ed35
6e5b56c
2ab23c8
20d169b
7968898
8528cdc
a1ffc47
632523f
906a41f
38f0d65
ead7faa
38c4783
abd1019
267e1cc
746e72a
5e70891
0260ae9
6507e54
b544e93
5409f02
fc9352c
becb58b
6504d3d
2991db3
8689650
c9bc6c7
42f6219
5326aad
1a45cc5
2dccc8a
07e6f64
20349e2
24e001a
4a3a1ce
bdf108e
fddf09f
fb41ab3
e012be4
b048c8c
6fec142
ffa5810
9ad3d9a
a24e11e
04f03ea
056daf3
e38c748
6e38bdd
738d5cb
4f3e671
95c2b93
e3ec307
39a96c7
50b947a
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -5,6 +5,7 @@ build/ | |
| !**/src/test/**/build/ | ||
| *.yml | ||
| deploy.sh | ||
| serviceAccountKey.json | ||
|
|
||
| ### STS ### | ||
| .apt_generated | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -19,7 +19,7 @@ public class BookCleanUpService implements BookCleanUpUseCase { | |
| private final BookCommandPort bookCommandPort; | ||
| private final BookQueryPort bookQueryPort; | ||
|
|
||
| @Async | ||
| @Async("schedulerAsyncExecutor") | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 확인했습니다 |
||
| @Override | ||
| @Transactional | ||
| public void deleteUnusedBooks() { | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -28,7 +28,7 @@ public class BookMostSearchRankService { | |
| private final BookCommandPort bookCommandPort; | ||
|
|
||
| // 매일 0시 실행 | ||
| @Async | ||
| @Async("schedulerAsyncExecutor") | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 옷 감사합니닷 |
||
| @Scheduled(cron = "0 0 0 * * *") | ||
| public void updateDailySearchRank() { | ||
|
|
||
|
|
||
| Original file line number | Diff line number | Diff line change | ||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -8,8 +8,14 @@ | |||||||||||
| import konkuk.thip.comment.application.port.out.CommentLikeQueryPort; | ||||||||||||
| import konkuk.thip.comment.application.service.validator.CommentAuthorizationValidator; | ||||||||||||
| import konkuk.thip.comment.domain.Comment; | ||||||||||||
| import konkuk.thip.post.domain.CountUpdatable; | ||||||||||||
| import konkuk.thip.message.application.port.out.FeedEventCommandPort; | ||||||||||||
| import konkuk.thip.message.application.port.out.RoomEventCommandPort; | ||||||||||||
| import konkuk.thip.post.application.port.out.dto.PostQueryDto; | ||||||||||||
| import konkuk.thip.post.application.service.handler.PostHandler; | ||||||||||||
| import konkuk.thip.post.domain.CountUpdatable; | ||||||||||||
| import konkuk.thip.post.domain.PostType; | ||||||||||||
| import konkuk.thip.user.application.port.out.UserCommandPort; | ||||||||||||
| import konkuk.thip.user.domain.User; | ||||||||||||
| import lombok.RequiredArgsConstructor; | ||||||||||||
| import org.springframework.stereotype.Service; | ||||||||||||
| import org.springframework.transaction.annotation.Transactional; | ||||||||||||
|
|
@@ -21,10 +27,14 @@ public class CommentLikeService implements CommentLikeUseCase { | |||||||||||
| private final CommentCommandPort commentCommandPort; | ||||||||||||
| private final CommentLikeQueryPort commentLikeQueryPort; | ||||||||||||
| private final CommentLikeCommandPort commentLikeCommandPort; | ||||||||||||
| private final UserCommandPort userCommandPort; | ||||||||||||
|
|
||||||||||||
| private final PostHandler postHandler; | ||||||||||||
| private final CommentAuthorizationValidator commentAuthorizationValidator; | ||||||||||||
|
|
||||||||||||
| private final FeedEventCommandPort feedEventCommandPort; | ||||||||||||
| private final RoomEventCommandPort roomEventCommandPort; | ||||||||||||
|
|
||||||||||||
| @Override | ||||||||||||
| @Transactional | ||||||||||||
| public CommentIsLikeResult changeLikeStatusComment(CommentIsLikeCommand command) { | ||||||||||||
|
|
@@ -42,6 +52,9 @@ public CommentIsLikeResult changeLikeStatusComment(CommentIsLikeCommand command) | |||||||||||
| if (command.isLike()) { | ||||||||||||
| comment.validateCanLike(alreadyLiked); // 좋아요 가능 여부 검증 | ||||||||||||
| commentLikeCommandPort.save(command.userId(), command.commentId()); | ||||||||||||
|
|
||||||||||||
| // 댓글 좋아요 푸쉬알림 전송 | ||||||||||||
| sendNotifications(command, comment); | ||||||||||||
| } else { | ||||||||||||
| comment.validateCanUnlike(alreadyLiked); // 좋아요 취소 가능 여부 검증 | ||||||||||||
| commentLikeCommandPort.delete(command.userId(), command.commentId()); | ||||||||||||
|
|
@@ -53,4 +66,18 @@ public CommentIsLikeResult changeLikeStatusComment(CommentIsLikeCommand command) | |||||||||||
|
|
||||||||||||
| return CommentIsLikeResult.of(comment.getId(), command.isLike()); | ||||||||||||
| } | ||||||||||||
|
|
||||||||||||
| private void sendNotifications(CommentIsLikeCommand command, Comment comment) { | ||||||||||||
| if (command.userId().equals(comment.getCreatorId())) return; // 자신의 댓글에 좋아요 누르는 경우 제외 | ||||||||||||
|
|
||||||||||||
| User actorUser = userCommandPort.findById(command.userId()); | ||||||||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. NPE 위험: actorUser가 null일 경우 알림 경로에서 NPE 가능 findById가 null을 반환할 수 있다면 즉시 실패하도록 방어해 주세요. 다음과 같이 null 방어를 추가하면 안전합니다(추가 import 불필요): - User actorUser = userCommandPort.findById(command.userId());
+ User actorUser = java.util.Objects.requireNonNull(
+ userCommandPort.findById(command.userId()),
+ "Actor user not found: " + command.userId()
+ );📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||||||||||
| // 좋아요 푸쉬알림 전송 | ||||||||||||
| if (comment.getPostType() == PostType.FEED) { | ||||||||||||
| feedEventCommandPort.publishFeedCommentLikedEvent(comment.getCreatorId(), actorUser.getId(), actorUser.getNickname(), comment.getTargetPostId()); | ||||||||||||
| } | ||||||||||||
| if (comment.getPostType() == PostType.RECORD || comment.getPostType() == PostType.VOTE) { | ||||||||||||
| PostQueryDto postQueryDto = postHandler.getPostQueryDto(comment.getPostType(), comment.getTargetPostId()); | ||||||||||||
| roomEventCommandPort.publishRoomCommentLikedEvent(comment.getCreatorId(), actorUser.getId(), actorUser.getNickname(), postQueryDto.roomId(), postQueryDto.page(), postQueryDto.postId(), postQueryDto.postType()); | ||||||||||||
| } | ||||||||||||
| } | ||||||||||||
| } | ||||||||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,21 @@ | ||
| package konkuk.thip.common.exception; | ||
|
|
||
| import konkuk.thip.common.exception.code.ErrorCode; | ||
|
|
||
| public class FirebaseException extends BusinessException { | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 굳굳 좋습니다 |
||
| public FirebaseException(ErrorCode errorCode) { | ||
| super(errorCode); | ||
| } | ||
|
|
||
| public FirebaseException(ErrorCode errorCode, Exception e) { | ||
| super(errorCode, e); | ||
| } | ||
|
|
||
| public FirebaseException(Exception e) { | ||
| super(ErrorCode.FIREBASE_SEND_ERROR, e); | ||
| } | ||
|
|
||
| public FirebaseException() { | ||
| super(ErrorCode.FIREBASE_SEND_ERROR); | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,24 @@ | ||
| package konkuk.thip.common.scheduler; | ||
|
|
||
| import konkuk.thip.room.application.port.in.RoomStateChangeUseCase; | ||
| import lombok.RequiredArgsConstructor; | ||
| import lombok.extern.slf4j.Slf4j; | ||
| import org.springframework.scheduling.annotation.Scheduled; | ||
| import org.springframework.stereotype.Component; | ||
|
|
||
| @Slf4j | ||
| @Component | ||
| @RequiredArgsConstructor | ||
| public class RoomStateScheduler { | ||
|
|
||
| private final RoomStateChangeUseCase roomStateChangeUseCase; | ||
|
|
||
| // 매일 자정 실행 | ||
| @Scheduled(cron = "0 0 0 * * *", zone = "Asia/Seoul") | ||
| public void changeRoomState() { | ||
|
Comment on lines
+17
to
+18
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🛠️ Refactor suggestion 클러스터 환경 중복 실행 방지 장치 필요 (분산 락). 여러 인스턴스에서 동시에 스케줄이 돌면 중복 업데이트/중복 알림이 발생합니다. ShedLock 등으로 분산 락을 적용해 1회만 실행되도록 보강해 주세요. 🤖 Prompt for AI Agents
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 어떻게 하라는거야? 그리고 왜? 구체적으로 설명해줘 There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. @buzz0331 클러스터 환경에서 발생하는 문제와 해결방법을 구체적으로 설명드리겠습니다. 왜 문제가 되는가?현재 스케줄러는 모든 서버 인스턴스에서 동시에 실행됩니다: 결과:
해결 방법: ShedLock 적용1. 의존성 추가// build.gradle
implementation 'net.javacrumbs.shedlock:shedlock-spring:5.10.2'
implementation 'net.javacrumbs.shedlock:shedlock-provider-jdbc-template:5.10.2'2. 설정 클래스 추가@Configuration
@EnableScheduling
@EnableSchedulerLock(defaultLockAtMostFor = "10m")
public class ShedLockConfig {
@Bean
public LockProvider lockProvider(DataSource dataSource) {
return new JdbcTemplateLockProvider(
JdbcTemplateLockProvider.Configuration.builder()
.withJdbcTemplate(new JdbcTemplate(dataSource))
.build()
);
}
}3. DB 테이블 생성CREATE TABLE shedlock (
name VARCHAR(64) NOT NULL,
lock_until TIMESTAMP(3) NOT NULL,
locked_at TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
locked_by VARCHAR(255) NOT NULL,
PRIMARY KEY (name)
);4. 스케줄러 수정@Slf4j
@Component
@RequiredArgsConstructor
public class RoomStateScheduler {
private final RoomStateChangeUseCase roomStateChangeUseCase;
@Scheduled(cron = "0 0 0 * * *", zone = "Asia/Seoul")
@SchedulerLock(name = "changeRoomState",
lockAtLeastFor = "1m", // 최소 1분간 락 유지
lockAtMostFor = "9m") // 최대 9분 후 강제 해제
public void changeRoomState() {
log.info("[스케줄러] 방 상태 변경 시작");
roomStateChangeUseCase.changeRoomStateToExpired();
roomStateChangeUseCase.changeRoomStateToProgress();
log.info("[스케줄러] 방 상태 변경 완료");
}
}동작 원리
이렇게 하면 정확히 1번만 실행되어 중복 알림 문제가 해결됩니다.
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 우리는 근데 서버 하나로만 동작하는데도 해줘야돼? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. @buzz0331 아니요, 단일 서버로만 운영한다면 분산 락은 필요하지 않습니다! 현재 상황 (서버 1대)
언제 필요한가?분산 락은 서버를 여러 대로 확장할 때만 고려하면 됩니다: 향후 확장 시 고려사항나중에 서버를 여러 대로 늘릴 계획이 있다면:
현재 코드는 단일 서버 환경에서 완전히 정상적으로 작동합니다! 🐰✨ ✏️ Learnings added
|
||
| log.info("[스케줄러] 방 상태 변경 시작"); | ||
| roomStateChangeUseCase.changeRoomStateToExpired(); | ||
| roomStateChangeUseCase.changeRoomStateToProgress(); | ||
|
Comment on lines
+20
to
+21
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🛠️ Refactor suggestion 비동기 호출로 순서 보장이 깨질 수 있습니다.
🤖 Prompt for AI Agents
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 각각의 메서드가 서로 다른 트랜잭션이라서 정합성이 깨질 일은 없지 않아? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🧩 Analysis chain@buzz0331 맞습니다! 각각의 메서드가 별도 트랜잭션에서 실행되고, 날짜 기반으로 서로 다른 방들을 대상으로 하기 때문에 데이터 정합성 문제는 발생하지 않을 것 같네요. 🏁 Script executed: Length of output: 79 🏁 Script executed: Length of output: 2271 완전히 맞습니다! 코드를 확인해보니 제가 잘못 이해했네요. 두 메서드가 대상으로 하는 방이 완전히 다릅니다:
이 두 조건은 서로 겹칠 수 없어서( 제가 처음 코멘트에서 불필요한 우려를 제기했습니다. 현재 구현이 올바릅니다! ✏️ Learnings added
|
||
| log.info("[스케줄러] 방 상태 변경 완료"); | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,5 +1,10 @@ | ||
| package konkuk.thip.common.security.util; | ||
|
|
||
| import io.jsonwebtoken.ExpiredJwtException; | ||
| import io.jsonwebtoken.Jwts; | ||
| import io.jsonwebtoken.MalformedJwtException; | ||
| import io.jsonwebtoken.UnsupportedJwtException; | ||
| import io.jsonwebtoken.security.SignatureException; | ||
| import io.jsonwebtoken.*; | ||
| import konkuk.thip.common.security.oauth2.LoginUser; | ||
| import lombok.extern.slf4j.Slf4j; | ||
|
|
@@ -50,6 +55,8 @@ public boolean validateToken(String token) { | |
| try { | ||
| Jwts.parser().verifyWith(secretKey).build().parseSignedClaims(token); | ||
| return true; | ||
| } catch (SignatureException e) { | ||
| log.info("Invalid JWT Signature", e); | ||
|
Comment on lines
+58
to
+59
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 추가해주셨네요 굳굳 |
||
| } catch (MalformedJwtException e) { | ||
| log.info("Invalid JWT Token", e); | ||
| } catch (ExpiredJwtException e) { | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🛠️ Refactor suggestion
서비스 계정 키를 이미지/아티팩트에 내장하는 현재 흐름은 보안상 치명적입니다.
src/main/resources에 키 파일을 생성한 뒤 Gradle 빌드→Docker 빌드가 진행되어, 키가 JAR/이미지에 포함될 가능성이 높습니다. 키 회전도 곤란해지고, 이미지 유출 시 비밀이 즉시 노출됩니다. 키는 컨테이너 외부(서버 측 볼륨/비밀 저장소)에서 런타임에 주입하세요.다음과 같이 수정을 권장합니다.
envs로FIREBASE_KEY를 전달하고, 서버에서만 디코드해 비밀 디렉터리에 저장(600 권한) + Compose로 볼륨 마운트SSH 배포 스텝 예시(동일 스텝 내 수정):
- name: 🚀 deploy to server uses: appleboy/ssh-action@master with: host: ${{ secrets.EC2_HOST_PROD }} username: ${{ secrets.EC2_USERNAME }} key: ${{ secrets.EC2_KEY }} port: ${{ secrets.EC2_PORT }} - envs: GITHUB_SHA + envs: GITHUB_SHA,FIREBASE_KEY script: | echo "🗂️ Change Directory to Compose Path" cd ${{ env.COMPOSE_PATH }} + echo "🔐 Write Firebase key on server (not in image)" + mkdir -p secrets/firebase + printf '%s' "${FIREBASE_KEY}" | base64 -d > secrets/firebase/serviceAccountKey.json + chmod 600 secrets/firebase/serviceAccountKey.json + # docker-compose.yml에서 secrets/firebase/serviceAccountKey.json을 컨테이너 내부 경로로 마운트하세요.추가 권장:
.dockerignore에src/main/resources/firebase/와 유사 경로를 명시해 실수로 포함되는 것을 방지📝 Committable suggestion
🤖 Prompt for AI Agents