Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
95 commits
Select commit Hold shift + click to select a range
c999af7
[chore] Firebase key 추가 (#289)
buzz0331 Sep 3, 2025
033e028
[chore] Firebase 의존성 주입 (#289)
buzz0331 Sep 3, 2025
21e1148
[infra] cd에 Firebase Key 주입 로직 추가 (#289)
buzz0331 Sep 3, 2025
8550f95
[chore] Async 설정 분리 (#289)
buzz0331 Sep 3, 2025
b855d4b
[fix] SignatureException catch 수정 (#289)
buzz0331 Sep 3, 2025
ada09cd
[chore] Firebase 초기화 설정 (#289)
buzz0331 Sep 3, 2025
feafca3
[chore] Fcm 관련 테이블 정의 (#289)
buzz0331 Sep 3, 2025
9a4fd41
[chore] Fcm 토큰 등록 서비스 로직 구현 (#289)
buzz0331 Sep 3, 2025
d91a428
[feat] Fcm 토큰 등록 관련 dto 정의 (#289)
buzz0331 Sep 3, 2025
9d5b3f5
[feat] Fcm 토큰 등록 api 핸들러 (#289)
buzz0331 Sep 3, 2025
3455198
[feat] Fcm 토큰 관련 필요한 쿼리 (#289)
buzz0331 Sep 3, 2025
6c73258
[feat] 푸쉬알림 여부 활성/비활성화 설정 api dto 정의 (#289)
buzz0331 Sep 3, 2025
5b8764f
[feat] 푸쉬알림 여부 활성/비활성화 설정 api 서비스 구현 (#289)
buzz0331 Sep 3, 2025
f93a277
[feat] 푸쉬알림 여부 활성/비활성화 설정 api 서비스 구현 (#289)
buzz0331 Sep 3, 2025
20f1d78
[feat] FCM 토큰 삭제 api 핸들러 정의 (#289)
buzz0331 Sep 3, 2025
0c46217
[feat] FCM 토큰 삭제 dto 정의 (#289)
buzz0331 Sep 3, 2025
75df1f3
[feat] FCM 토큰 삭제 서비스 로직 구현 (#289)
buzz0331 Sep 3, 2025
1108eac
[feat] 푸쉬알림 여부 활성/비활성화 설정 dto 정의 (#289)
buzz0331 Sep 3, 2025
d868d40
[refactor] dto 네이밍 수정, port default 메서드 정의 (#289)
buzz0331 Sep 3, 2025
138c66f
[feat] fcm token 삭제 쿼리 추가 (#289)
buzz0331 Sep 3, 2025
70e2eca
[docs] 스웨거 명세 추가 (#289)
buzz0331 Sep 3, 2025
421a90e
[refactor] 안쓰는 클래스 삭제 (#289)
buzz0331 Sep 3, 2025
1031d23
[chore] FcmToken 테이블 추가 ddl 쿼리 (#289)
buzz0331 Sep 4, 2025
628a502
[feat] EventListener 정의 (#289)
buzz0331 Sep 4, 2025
a9743d1
[feat] 푸쉬알림 이벤트 발행 adapter 정의 (#289)
buzz0331 Sep 4, 2025
4a26f10
[feat] Event 정의 (#289)
buzz0331 Sep 4, 2025
e50c8be
[feat] 이벤트 핸들러 서비스 정의 (#289)
buzz0331 Sep 4, 2025
7d38012
[feat] Firebase Adapater 정의 (#289)
buzz0331 Sep 4, 2025
9ebe067
[feat] 필요한 에러 정의 (#289)
buzz0331 Sep 4, 2025
fecad55
[feat] 푸쉬알림 카테고리 정의 (#289)
buzz0331 Sep 4, 2025
390ad60
[feat] 화면 내비게이션 라우트 (#289)
buzz0331 Sep 4, 2025
01eb5af
[docs] 스웨거 명세 수정 (#289)
buzz0331 Sep 4, 2025
b663c7b
[refactor] 애매한 메서드 네이밍 수정 (#289)
buzz0331 Sep 4, 2025
0c433e0
[refactor] 애매한 메서드 네이밍 수정 + 이벤트 케이스 추가 (#289)
buzz0331 Sep 4, 2025
9ac24c1
[feat] 팔로우 푸쉬알림 전송 이벤트 발행 (#289)
buzz0331 Sep 4, 2025
980706d
[feat] 방 조기마감 푸쉬알림 전송 이벤트 발행 (#289)
buzz0331 Sep 4, 2025
37866a4
[feat] 댓글 관련 이벤트 발행을 위한 PostQueryDto 및 조회 쿼리 정의 (#289)
buzz0331 Sep 4, 2025
69e3120
[feat] 팔로워에게 새로운 피드 등록 푸쉬알림 전송 이벤트 발행 (#289)
buzz0331 Sep 4, 2025
ba59113
[feat] 댓글 좋아요 푸쉬알림 전송 이벤트 발행 (#289)
buzz0331 Sep 4, 2025
b8c9e21
[feat] 댓글 및 답글 등록 푸쉬알림 전송 이벤트 발행 (#289)
buzz0331 Sep 4, 2025
2249963
[feat] 게시글 좋아요 푸쉬알림 전송 이벤트 발행 (#289)
buzz0331 Sep 4, 2025
c565094
[feat] 게시글 좋아요 푸쉬알림 전송 이벤트 발행 (#289)
buzz0331 Sep 4, 2025
727ed35
[chore] 주석 수정 (#289)
buzz0331 Sep 4, 2025
6e5b56c
[refactor] 이벤트 adapter 변수명 수정 (#289)
buzz0331 Sep 4, 2025
2ab23c8
[feat] 방 참여 푸쉬알림 전송 이벤트 발행 (#289)
buzz0331 Sep 4, 2025
20d169b
[feat] 방 상태 변경 스케줄러 정의 및 방 모임방 활동 시작 푸쉬알림 전송 이벤트 발행 (#289)
buzz0331 Sep 4, 2025
7968898
[feat] 기록/투표 생성 푸쉬알림 전송 이벤트 발행 (#289)
buzz0331 Sep 4, 2025
8528cdc
[fix] 잘못된 팔로워 조회 쿼리 수정 (#289)
buzz0331 Sep 4, 2025
a1ffc47
[fix] 단위 테스트 로직 수정 (#289)
buzz0331 Sep 4, 2025
632523f
[fix] 참여하기 시에만 알람 이벤트 발행하도록 수정 (#289)
buzz0331 Sep 4, 2025
906a41f
[test] 이벤트 리스너 단위 테스트 (#289)
buzz0331 Sep 4, 2025
38f0d65
[chore] Firebase key 주입 (#289)
buzz0331 Sep 4, 2025
ead7faa
[fix] 잘못된 메서드 네이밍 수정 (#289)
buzz0331 Sep 5, 2025
38c4783
[fix] feedId npe 해결 (#289)
buzz0331 Sep 5, 2025
abd1019
[refactor] 자신이 좋아요 누른경우 이벤트 발행 제거 (#289)
buzz0331 Sep 5, 2025
267e1cc
[refactor] 에러코드 수정 (#289)
buzz0331 Sep 5, 2025
746e72a
[refactor] ThraedPool 설정 (#289)
buzz0331 Sep 5, 2025
5e70891
[refactor] InputStream 메모리 해제를 위한 try-with-resources 패턴 적용 (#289)
buzz0331 Sep 5, 2025
0260ae9
[docs] 스웨거 dto 스키마 추가 (#289)
buzz0331 Sep 5, 2025
6507e54
[feat] 토큰 관련 dto 유효성 검증 추가 (#289)
buzz0331 Sep 5, 2025
b544e93
[refactor] 푸쉬알림 허용 시 자신이 소유한 토큰이 아닐 경우 예외처리 (#289)
buzz0331 Sep 5, 2025
5409f02
[fix] 책 상태 업데이트 쿼리 오류 수정 (#289)
buzz0331 Sep 5, 2025
fc9352c
[refactor] Param 바인딩 추가 (#289)
buzz0331 Sep 5, 2025
becb58b
[rename] 값 객체 모두 domain 하위 패키지로 이동 (#289)
buzz0331 Sep 5, 2025
6504d3d
[fix] 방 시작 이벤트 발행시 잘못된 대상 id 사용 수정 (#289)
buzz0331 Sep 5, 2025
2991db3
[refactor] 불필요한 switch문 제거 (#289)
buzz0331 Sep 5, 2025
8689650
[refactor] Param 바인딩 추가 (#289)
buzz0331 Sep 5, 2025
c9bc6c7
[refactor] 모임방 댓글 좋아요 시 postType 라우트 추가 (#289)
buzz0331 Sep 5, 2025
42f6219
[feat] 푸쉬알림 네비게이션 라우트 분기 구체화 (#289)
buzz0331 Sep 5, 2025
5326aad
[fix] 답글 생성 푸쉬알림 대상 오류 수정 (#289)
buzz0331 Sep 5, 2025
1a45cc5
[refactor] FB key path hidden (#289)
buzz0331 Sep 5, 2025
2dccc8a
[refactor] 토큰 관련 api path 수정 (#289)
buzz0331 Sep 5, 2025
07e6f64
[refactor] Fcm 토큰 삭제시 소유자 검증 로직 추가 (#289)
buzz0331 Sep 5, 2025
20349e2
[docs] 토큰 삭제 api 스웨거 명세 추가 (#289)
buzz0331 Sep 5, 2025
24e001a
[refactor] thread 설정 테스트 환경에서는 동기적으로 수정 (#289)
buzz0331 Sep 5, 2025
4a3a1ce
[refactor] 박싱 객체 사용 (#289)
buzz0331 Sep 5, 2025
bdf108e
[refactor] 비동기 백업 전략 적용 (#289)
buzz0331 Sep 5, 2025
fddf09f
[refactor] 본인이 작성한 게시글의 댓글, 댓글의 답글에 대한 푸쉬알림 제외 (#289)
buzz0331 Sep 5, 2025
fb41ab3
[refactor] 책 랭킹 설정 스케줄러도 ThreadPoolExececutor 명시 (#289)
buzz0331 Sep 5, 2025
e012be4
[refactor] TestAsyncConfig에 EnableAsync 어노테이션 명시 (#289)
buzz0331 Sep 5, 2025
b048c8c
[refactor] ManyToOne option에 optional = false 추가 (#289)
buzz0331 Sep 6, 2025
6fec142
[refactor] 함수 시그니처에서 애매한 파라미터 삭제 (#289)
buzz0331 Sep 6, 2025
ffa5810
[fix] param 바인딩 오류 수정 (#289)
buzz0331 Sep 6, 2025
9ad3d9a
[refactor] Fcm 영속성 port 네이밍 수정 (#289)
buzz0331 Sep 6, 2025
a24e11e
[refactor] 쿼리 네이밍 수정 (#289)
buzz0331 Sep 7, 2025
04f03ea
[refactor] Spring Profile 별 로그 출력 (#289)
buzz0331 Sep 8, 2025
056daf3
[refactor] Feed 도메인에서 PostQueryDto 조회 로직 삭제 (#289)
buzz0331 Sep 8, 2025
e38c748
[refactor] RoomPost 도메인에서 PostQueryDto 조회 로직 삭제 (#289)
buzz0331 Sep 8, 2025
6e38bdd
[refactor] Post 패키지에 PostQueryDto 조회 로직 추가 (#289)
buzz0331 Sep 8, 2025
738d5cb
[refactor] PostLikeJpaRepository 패키지 이동 (#289)
buzz0331 Sep 8, 2025
4f3e671
[fix] 오타 수정 (#289)
buzz0331 Sep 8, 2025
95c2b93
[merge] 머지 (#289)
buzz0331 Sep 8, 2025
e3ec307
[merge] 머지 conflict 해결 (#289)
buzz0331 Sep 8, 2025
39a96c7
[merge] 클래스 패키지 이동한거 반영 (#289)
buzz0331 Sep 8, 2025
50b947a
[merge] 클래스 패키지 이동한거 반영 (#289)
buzz0331 Sep 8, 2025
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
7 changes: 7 additions & 0 deletions .github/workflows/cd-workflow-dev.yml
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ permissions:
env:
RESOURCE_PATH: src/main/resources
COMPOSE_PATH: /home/ec2-user/compose
FIREBASE_KEY_PATH: ${{ secrets.FIREBASE_KEY_PATH }}
APP_PORT: 8000

jobs:
Expand All @@ -33,6 +34,12 @@ jobs:
echo "${{ secrets.APPLICATION_YML_DEV }}" | base64 --decode > ${{ env.RESOURCE_PATH }}/application.yml
shell: bash

- name: 🔐 Create Firebase Key from secret (base64)
run: |
mkdir -p ${{ env.FIREBASE_KEY_PATH }}
echo "${{ secrets.FIREBASE_KEY }}" | base64 --decode > ${{ env.FIREBASE_KEY_PATH }}/serviceAccountKey.json
shell: bash

- name: 🐘 Cache Gradle dependencies
uses: actions/cache@v3
with:
Expand Down
7 changes: 7 additions & 0 deletions .github/workflows/cd-workflow-prod.yml
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ permissions:
env:
RESOURCE_PATH: src/main/resources
COMPOSE_PATH: /home/ec2-user/compose
FIREBASE_KEY_PATH: ${{ secrets.FIREBASE_KEY_PATH }}
APP_PORT: 8000

jobs:
Expand All @@ -33,6 +34,12 @@ jobs:
echo "${{ secrets.APPLICATION_YML_PROD }}" | base64 --decode > ${{ env.RESOURCE_PATH }}/application.yml
shell: bash

- name: 🔐 Create Firebase Key from secret (base64)
run: |
mkdir -p ${{ env.FIREBASE_KEY_PATH }}
echo "${{ secrets.FIREBASE_KEY }}" | base64 --decode > ${{ env.FIREBASE_KEY_PATH }}/serviceAccountKey.json
shell: bash
Comment on lines +37 to +41
Copy link

Choose a reason for hiding this comment

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

🛠️ Refactor suggestion

⚠️ Potential issue

서비스 계정 키를 이미지/아티팩트에 내장하는 현재 흐름은 보안상 치명적입니다.

src/main/resources에 키 파일을 생성한 뒤 Gradle 빌드→Docker 빌드가 진행되어, 키가 JAR/이미지에 포함될 가능성이 높습니다. 키 회전도 곤란해지고, 이미지 유출 시 비밀이 즉시 노출됩니다. 키는 컨테이너 외부(서버 측 볼륨/비밀 저장소)에서 런타임에 주입하세요.

다음과 같이 수정을 권장합니다.

  • CI 단계에서 키 파일 생성 제거
  • 배포 SSH 단계에서 envsFIREBASE_KEY를 전달하고, 서버에서만 디코드해 비밀 디렉터리에 저장(600 권한) + Compose로 볼륨 마운트
-      - name: 🔐 Create Firebase Key from secret (base64)
-        run: |
-          mkdir -p ${{ env.FIREBASE_KEY_PATH }}
-          echo "${{ secrets.FIREBASE_KEY }}" | base64 --decode > ${{ env.FIREBASE_KEY_PATH }}/serviceAccountKey.json
-        shell: bash
+      # 키는 이미지에 넣지 않습니다. 서버에서만 생성/마운트합니다.

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을 컨테이너 내부 경로로 마운트하세요.

추가 권장:

  • .dockerignoresrc/main/resources/firebase/와 유사 경로를 명시해 실수로 포함되는 것을 방지
  • 서버 재배포 스크립트에서 키 파일 퍼미션 점검/회전 로직 포함
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
- name: 🔐 Create Firebase Key from secret (base64)
run: |
mkdir -p ${{ env.FIREBASE_KEY_PATH }}
echo "${{ secrets.FIREBASE_KEY }}" | base64 --decode > ${{ env.FIREBASE_KEY_PATH }}/serviceAccountKey.json
shell: bash
# 키는 이미지에 넣지 않습니다. 서버에서만 생성/마운트합니다.
🤖 Prompt for AI Agents
.github/workflows/cd-workflow-prod.yml lines 37-41: the workflow currently
decodes the FIREBASE_KEY into src/main/resources during CI which risks baking
secrets into the JAR/image; remove the CI step that writes the key into the
repo/build context, and instead pass FIREBASE_KEY as an environment variable to
the SSH deploy step and decode it on the target server only (write to a secure
directory with 600 permissions), then mount that file into the container at
runtime via Docker Compose or a secrets/volume mechanism; additionally add the
firebase key path (e.g. src/main/resources/firebase/) to .dockerignore to
prevent accidental inclusion and ensure the server-side deploy script checks
file permissions and supports key rotation.


- name: 🐘 Cache Gradle dependencies
uses: actions/cache@v3
with:
Expand Down
7 changes: 7 additions & 0 deletions .github/workflows/ci-workflow.yml
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ permissions:
env:
RESOURCE_PATH: src/main/resources
TEST_RESOURCE_PATH: src/test/resources
FIREBASE_KEY_PATH: ${{ secrets.FIREBASE_KEY_PATH }}

jobs:
build:
Expand All @@ -36,6 +37,12 @@ jobs:
echo "${{ secrets.APPLICATION_YML_DEV }}" | base64 --decode > ${{ env.RESOURCE_PATH }}/application.yml
echo "${{ secrets.APPLICATION_YML_TEST }}" | base64 --decode > ${{ env.TEST_RESOURCE_PATH }}/application-test.yml

- name: 🔐 Create Firebase Key from secret (base64)
run: |
mkdir -p ${{ env.FIREBASE_KEY_PATH }}
echo "${{ secrets.FIREBASE_KEY }}" | base64 --decode > ${{ env.FIREBASE_KEY_PATH }}/serviceAccountKey.json
shell: bash

- name: 👏🏻 grant execute permission for gradlew
run: chmod +x gradlew

Expand Down
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ build/
!**/src/test/**/build/
*.yml
deploy.sh
serviceAccountKey.json

### STS ###
.apt_generated
Expand Down
2 changes: 2 additions & 0 deletions build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,8 @@ dependencies {
implementation "org.flywaydb:flyway-core"
implementation "org.flywaydb:flyway-mysql"

// Firebase
implementation 'com.google.firebase:firebase-admin:9.3.0'
}

def querydslDir = layout.buildDirectory.dir("generated/querydsl").get().asFile
Expand Down
2 changes: 0 additions & 2 deletions src/main/java/konkuk/thip/ThipServerApplication.java
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,10 @@
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.boot.context.properties.ConfigurationPropertiesScan;
import org.springframework.data.jpa.repository.config.EnableJpaAuditing;
import org.springframework.scheduling.annotation.EnableAsync;
import org.springframework.scheduling.annotation.EnableScheduling;

@EnableJpaAuditing
@EnableScheduling
@EnableAsync
@ConfigurationPropertiesScan
@SpringBootApplication
public class ThipServerApplication {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ public class BookCleanUpService implements BookCleanUpUseCase {
private final BookCommandPort bookCommandPort;
private final BookQueryPort bookQueryPort;

@Async
@Async("schedulerAsyncExecutor")
Copy link
Collaborator

Choose a reason for hiding this comment

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

확인했습니다

@Override
@Transactional
public void deleteUnusedBooks() {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ public class BookMostSearchRankService {
private final BookCommandPort bookCommandPort;

// 매일 0시 실행
@Async
@Async("schedulerAsyncExecutor")
Copy link
Member

Choose a reason for hiding this comment

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

옷 감사합니닷

@Scheduled(cron = "0 0 0 * * *")
public void updateDailySearchRank() {

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@

import static konkuk.thip.book.adapter.out.api.naver.NaverApiUtil.PAGE_SIZE;
import static konkuk.thip.common.exception.code.ErrorCode.*;
import static konkuk.thip.recentSearch.adapter.out.jpa.RecentSearchType.BOOK_SEARCH;
import static konkuk.thip.recentSearch.domain.value.RecentSearchType.BOOK_SEARCH;

@Service
@RequiredArgsConstructor
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,14 +11,20 @@
import konkuk.thip.comment.application.service.validator.CommentAuthorizationValidator;
import konkuk.thip.comment.domain.Comment;
import konkuk.thip.common.exception.InvalidStateException;
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.domain.CountUpdatable;
import konkuk.thip.post.application.service.handler.PostHandler;
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;

import static konkuk.thip.common.exception.code.ErrorCode.INVALID_COMMENT_CREATE;
import static konkuk.thip.post.domain.PostType.*;


@Service
Expand All @@ -29,26 +35,31 @@ public class CommentCreateService implements CommentCreateUseCase {
private final CommentQueryPort commentQueryPort;
private final CommentLikeQueryPort commentLikeQueryPort;
private final CommentQueryMapper commentQueryMapper;

private final UserCommandPort userCommandPort;

private final PostHandler postHandler;
private final CommentAuthorizationValidator commentAuthorizationValidator;

private final FeedEventCommandPort feedEventCommandPort;
private final RoomEventCommandPort roomEventCommandPort;

@Override
@Transactional
public CommentCreateResponse createComment(CommentCreateCommand command) {

// 1. 댓글/답글 생성 선행검증 및 작성하려는 게시글 타입 검증
Comment.validateCommentCreate(command.isReplyRequest(), command.parentId());
PostType type = PostType.from(command.postType());
PostType type = from(command.postType());

// 2. 게시물 타입에 맞게 조회
CountUpdatable post = postHandler.findPost(type, command.postId());
// 2-1. 게시글 타입에 따른 댓글 생성 권한 검증
commentAuthorizationValidator.validateUserCanAccessPostForComment(type, post, command.userId());

// TODO 피드: 내 게시글의 댓글, 내 댓글에 대한 답글 알림 전송
// TODO 기록 및 투표: 모임방의 내 게시글에 대한 댓글, 내 댓글에 대한 답글 알림 전송
// 2-2. 댓글 생성 푸쉬 알림 전송 (게시글 작성자에게)
PostQueryDto postQueryDto = postHandler.getPostQueryDto(type, post.getId());
User actorUser = userCommandPort.findById(command.userId());
sendNotificationsToPostWriter(postQueryDto, actorUser);

// 3. 댓글 생성
Long savedCommentId = createCommentDomain(command);
Expand All @@ -65,6 +76,10 @@ public CommentCreateResponse createComment(CommentCreateCommand command) {
if (command.isReplyRequest()) {
// 부모 댓글 조회
CommentQueryDto parentCommentDto = commentQueryPort.findRootCommentById(command.parentId());

// 답글 생성 푸쉬 알림 전송 (부모 댓글 작성자에게)
sendNotificationsToParentCommentWriter(postQueryDto, parentCommentDto, actorUser);

// 사용자 부모 댓글 좋아요 여부 조회
boolean isLikedParentComment = commentLikeQueryPort.isLikedCommentByUser(command.userId(),parentCommentDto.commentId());

Expand All @@ -74,7 +89,30 @@ public CommentCreateResponse createComment(CommentCreateCommand command) {
CommentQueryDto savedCommentDto = commentQueryPort.findRootCommentById(savedCommentId);
return commentQueryMapper.toRoot(savedCommentDto, false, command.userId());
}
}

private void sendNotificationsToPostWriter(PostQueryDto postQueryDto, User actorUser) {
if (postQueryDto.creatorId().equals(actorUser.getId())) return; // 자신이 작성한 게시글 제외

if (postQueryDto.postType().equals(FEED.getType())) {
// 피드 댓글 알림 이벤트 발행
feedEventCommandPort.publishFeedCommentedEvent(postQueryDto.creatorId(), actorUser.getId(), actorUser.getNickname(), postQueryDto.postId());
} else if (postQueryDto.postType().equals(RECORD.getType()) || postQueryDto.postType().equals(VOTE.getType())) {
// 모임방 게시글 댓글 알림 이벤트 발행
roomEventCommandPort.publishRoomPostCommentedEvent(postQueryDto.creatorId(), actorUser.getId(), actorUser.getNickname(), postQueryDto.roomId(), postQueryDto.page(), postQueryDto.postId(), postQueryDto.postType());
}
}

private void sendNotificationsToParentCommentWriter(PostQueryDto postQueryDto, CommentQueryDto parentCommentDto, User actorUser) {
if (parentCommentDto.creatorId().equals(actorUser.getId())) return; // 자신이 작성한 댓글 제외

if (postQueryDto.postType().equals(FEED.getType())) {
// 피드 답글 알림 이벤트 발행
feedEventCommandPort.publishFeedRepliedEvent(parentCommentDto.creatorId(), actorUser.getId(), actorUser.getNickname(), postQueryDto.postId());
} else if (postQueryDto.postType().equals(RECORD.getType()) || postQueryDto.postType().equals(VOTE.getType())) {
// 모임방 게시글 답글 알림 이벤트 발행
roomEventCommandPort.publishRoomPostCommentRepliedEvent(parentCommentDto.creatorId(), actorUser.getId(), actorUser.getNickname(), postQueryDto.roomId(), postQueryDto.page(), postQueryDto.postId(), postQueryDto.postType());
}
}

private Long createCommentDomain(CommentCreateCommand command) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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) {
Expand All @@ -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());
Expand All @@ -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());
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue

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

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
User actorUser = userCommandPort.findById(command.userId());
User actorUser = java.util.Objects.requireNonNull(
userCommandPort.findById(command.userId()),
"Actor user not found: " + command.userId()
);
🤖 Prompt for AI Agents
In src/main/java/konkuk/thip/comment/application/service/CommentLikeService.java
around line 73, the call User actorUser =
userCommandPort.findById(command.userId()); can return null and lead to an NPE
later; add an immediate null-check after this line and fail fast by throwing an
appropriate runtime exception (e.g., IllegalArgumentException or a
domain-specific NotFoundException) with a clear message referencing the userId
so callers see why it failed.

// 좋아요 푸쉬알림 전송
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());
}
}
}
21 changes: 21 additions & 0 deletions src/main/java/konkuk/thip/common/exception/FirebaseException.java
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 {
Copy link
Collaborator

Choose a reason for hiding this comment

The 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
Expand Up @@ -215,6 +215,13 @@ public enum ErrorCode implements ResponseCode {
ATTENDANCE_CHECK_NOT_FOUND(HttpStatus.NOT_FOUND, 195001, "존재하지 않는 ATTENDANCE CHECK 입니다."),
ATTENDANCE_CHECK_CAN_NOT_DELETE(HttpStatus.FORBIDDEN, 195002, "오늘의 한마디는 본인만 삭제할 수 있습니다."),

/**
* 200000 : Fcm error
*/
FCM_TOKEN_NOT_FOUND(HttpStatus.NOT_FOUND, 200000, "존재하지 않는 FCM TOKEN 입니다."),
FCM_TOKEN_ENABLED_STATE_ALREADY(HttpStatus.BAD_REQUEST, 200001, "요청한 상태로 이미 푸쉬 알림 여부가 설정되어 있습니다."),
FCM_TOKEN_ACCESS_FORBIDDEN(HttpStatus.FORBIDDEN, 200002, "토큰을 소유하고 있는 계정이 아닙니다."),
FIREBASE_SEND_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, 200003, "FCM 푸쉬 알림 전송에 실패했습니다.")

;

Expand Down
24 changes: 24 additions & 0 deletions src/main/java/konkuk/thip/common/scheduler/RoomStateScheduler.java
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
Copy link

@coderabbitai coderabbitai bot Sep 4, 2025

Choose a reason for hiding this comment

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

🛠️ Refactor suggestion

클러스터 환경 중복 실행 방지 장치 필요 (분산 락).

여러 인스턴스에서 동시에 스케줄이 돌면 중복 업데이트/중복 알림이 발생합니다. ShedLock 등으로 분산 락을 적용해 1회만 실행되도록 보강해 주세요.

🤖 Prompt for AI Agents
In src/main/java/konkuk/thip/common/scheduler/RoomStateScheduler.java around
lines 17-18, the scheduled method changeRoomState() runs on every instance in a
cluster and needs a distributed lock to prevent duplicate updates/notifications;
apply a distributed lock (e.g., add ShedLock support) by annotating the method
with @SchedulerLock (or equivalent), configure and wire a LockProvider
(JDBC/Redis/Zookeeper) in your Spring context, set an appropriate
lockAtLeastFor/lockAtMostFor values to cover the job duration, and ensure
exceptions still release the lock; update project dependencies and configuration
so only one instance executes the method each cron run.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

어떻게 하라는거야? 그리고 왜? 구체적으로 설명해줘

Copy link

Choose a reason for hiding this comment

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

@buzz0331 클러스터 환경에서 발생하는 문제와 해결방법을 구체적으로 설명드리겠습니다.

왜 문제가 되는가?

현재 스케줄러는 모든 서버 인스턴스에서 동시에 실행됩니다:

서버 A: 자정에 changeRoomState() 실행 → DB 업데이트 + 푸시 알림 전송
서버 B: 자정에 changeRoomState() 실행 → 같은 DB 업데이트 + 중복 푸시 알림 전송  
서버 C: 자정에 changeRoomState() 실행 → 또 같은 작업 반복

결과:

  • 사용자가 같은 알림을 3번 받음 (서버 3대 기준)
  • 불필요한 DB 쿼리 중복 실행
  • 리소스 낭비

해결 방법: 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. 자정이 되면 모든 서버에서 스케줄러 시작
  2. 첫 번째 서버shedlock 테이블에 락 획득
  3. 나머지 서버들은 락 획득 실패로 스케줄 건너뜀
  4. 첫 번째 서버만 실제 작업 실행
  5. 작업 완료 후 자동으로 락 해제

이렇게 하면 정확히 1번만 실행되어 중복 알림 문제가 해결됩니다.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

우리는 근데 서버 하나로만 동작하는데도 해줘야돼?

Copy link

Choose a reason for hiding this comment

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

@buzz0331 아니요, 단일 서버로만 운영한다면 분산 락은 필요하지 않습니다!

현재 상황 (서버 1대)

  • 스케줄러가 1번만 실행되므로 중복 실행 위험 없음
  • 현재 코드 그대로 사용해도 문제없음
  • 불필요한 복잡성 추가할 필요 없음

언제 필요한가?

분산 락은 서버를 여러 대로 확장할 때만 고려하면 됩니다:

단일 서버 → 분산 락 불필요 ✅
멀티 서버 (로드밸런싱) → 분산 락 필요 ⚠️

향후 확장 시 고려사항

나중에 서버를 여러 대로 늘릴 계획이 있다면:

  • 그때 ShedLock 등 분산 락 도입
  • 지금은 현재 코드 그대로 유지

현재 코드는 단일 서버 환경에서 완전히 정상적으로 작동합니다!

🐰✨


✏️ Learnings added
Learnt from: buzz0331
PR: THIP-TextHip/THIP-Server#293
File: src/main/java/konkuk/thip/common/scheduler/RoomStateScheduler.java:17-0
Timestamp: 2025-09-05T04:09:42.889Z
Learning: THIP 프로젝트는 현재 단일 서버로 운영되고 있어 스케줄러에서 분산 락이 불필요함.

Note: Learnings are effective only in the context of similar code segments. To apply general coding guidelines, please configure review instructions. You can manage existing learnings in the app.

log.info("[스케줄러] 방 상태 변경 시작");
roomStateChangeUseCase.changeRoomStateToExpired();
roomStateChangeUseCase.changeRoomStateToProgress();
Comment on lines +20 to +21
Copy link

@coderabbitai coderabbitai bot Sep 4, 2025

Choose a reason for hiding this comment

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

🛠️ Refactor suggestion

비동기 호출로 순서 보장이 깨질 수 있습니다.

RoomStateChangeService.changeRoomStateToExpired()@Async인 경우, 여기서 호출 직후 changeRoomStateToProgress()가 먼저 완료될 수 있어 상태 전이가 뒤섞일 위험이 있습니다. 두 연산은 동일 트랜잭션/쓰레드에서 순차 실행되도록 조정하거나 서비스에 순차 실행용 단일 메서드를 노출해 스케줄러에서는 하나만 호출하는 쪽을 권장합니다.

🤖 Prompt for AI Agents
In src/main/java/konkuk/thip/common/scheduler/RoomStateScheduler.java around
lines 20-21, the scheduler calls
roomStateChangeUseCase.changeRoomStateToExpired() and then
changeRoomStateToProgress() but if changeRoomStateToExpired() is annotated
@Async the calls may complete out of order; either expose a single synchronous
service method (e.g., changeRoomStatesSequentially()) that executes both
transitions in the same thread/transaction and call only that from the
scheduler, or make the scheduler wait for the async completion (e.g., have the
async method return a Future/CompletableFuture and join/get it) or remove @Async
so both operations run sequentially; implement one of these fixes and update
scheduler to call only the synchronous/awaiting variant.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

각각의 메서드가 서로 다른 트랜잭션이라서 정합성이 깨질 일은 없지 않아?

Copy link

Choose a reason for hiding this comment

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

🧩 Analysis chain

@buzz0331 맞습니다! 각각의 메서드가 별도 트랜잭션에서 실행되고, 날짜 기반으로 서로 다른 방들을 대상으로 하기 때문에 데이터 정합성 문제는 발생하지 않을 것 같네요.


🏁 Script executed:

#!/bin/bash
# RoomStateChangeService의 두 메서드가 실제로 어떤 조건으로 방을 업데이트하는지 확인
rg -nA5 -B5 "changeRoomStateToExpired\|changeRoomStateToProgress" --type=java

Length of output: 79


🏁 Script executed:

#!/bin/bash
# RoomStateChangeService 파일에서 두 메서드의 구현 내용 확인
cat src/main/java/konkuk/thip/room/application/service/RoomStateChangeService.java

Length of output: 2271


완전히 맞습니다! 코드를 확인해보니 제가 잘못 이해했네요.

두 메서드가 대상으로 하는 방이 완전히 다릅니다:

  1. changeRoomStateToExpired(): end_date < 오늘인 방들을 EXPIRED로 변경
  2. changeRoomStateToProgress(): start_date <= 오늘 AND end_date >= 오늘인 방들을 IN_PROGRESS로 변경

이 두 조건은 서로 겹칠 수 없어서(end_date < 오늘end_date >= 오늘는 상호 배타적), 비동기 실행 순서가 바뀌어도 전혀 문제가 되지 않습니다. 또한 각각 별도 트랜잭션에서 실행되므로 데이터 정합성에도 문제없고요.

제가 처음 코멘트에서 불필요한 우려를 제기했습니다. 현재 구현이 올바릅니다!


✏️ Learnings added
Learnt from: buzz0331
PR: THIP-TextHip/THIP-Server#293
File: src/main/java/konkuk/thip/common/scheduler/RoomStateScheduler.java:20-0
Timestamp: 2025-09-05T04:11:01.419Z
Learning: RoomStateScheduler에서 changeRoomStateToExpired()와 changeRoomStateToProgress()는 각각 다른 트랜잭션에서 실행되며, 날짜 기반으로 서로 다른 방들을 대상으로 하므로 실행 순서가 바뀌어도 데이터 정합성에 문제가 없다.

Learnt from: buzz0331
PR: THIP-TextHip/THIP-Server#293
File: src/main/java/konkuk/thip/common/scheduler/RoomStateScheduler.java:20-0
Timestamp: 2025-09-05T04:11:01.419Z
Learning: RoomStateScheduler의 changeRoomStateToExpired()와 changeRoomStateToProgress()는 서로 다른 날짜 조건으로 완전히 다른 방들을 대상으로 하므로(end_date < today vs start_date <= today AND end_date >= today), 비동기 실행 순서와 관계없이 데이터 정합성에 문제가 없다.

Note: Learnings are effective only in the context of similar code segments. To apply general coding guidelines, please configure review instructions. You can manage existing learnings in the app.

log.info("[스케줄러] 방 상태 변경 완료");
}
}
7 changes: 7 additions & 0 deletions src/main/java/konkuk/thip/common/security/util/JwtUtil.java
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;
Expand Down Expand Up @@ -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
Copy link
Collaborator

Choose a reason for hiding this comment

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

추가해주셨네요 굳굳

} catch (MalformedJwtException e) {
log.info("Invalid JWT Token", e);
} catch (ExpiredJwtException e) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -334,6 +334,22 @@ public enum SwaggerResponseDescription {
ATTENDANCE_CHECK_CAN_NOT_DELETE
))),

// Notiification
FCM_TOKEN_REGISTER(new LinkedHashSet<>(Set.of(
USER_NOT_FOUND,
FCM_TOKEN_NOT_FOUND
))),
FCM_TOKEN_ENABLE_STATE_CHANGE(new LinkedHashSet<>(Set.of(
USER_NOT_FOUND,
FCM_TOKEN_NOT_FOUND,
FCM_TOKEN_ENABLED_STATE_ALREADY,
FCM_TOKEN_ACCESS_FORBIDDEN
))),
FCM_TOKEN_DELETE(new LinkedHashSet<>(Set.of(
FCM_TOKEN_NOT_FOUND,
FCM_TOKEN_ACCESS_FORBIDDEN
)))

;
private final Set<ErrorCode> errorCodeList;
SwaggerResponseDescription(Set<ErrorCode> errorCodeList) {
Expand Down
Loading