Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
4a376c8
[chore] notifications 테이블 구조 수정 with flyway (#303)
seongjunnoh Sep 16, 2025
8b8085e
[refactor] notification 저장 시, Template 으로부터 notification category 값 받…
seongjunnoh Sep 16, 2025
474a23a
[refactor] NotificationTemplate 구현체 코드 수정 (#303)
seongjunnoh Sep 16, 2025
851d29f
[refactor] NotificationMapper 수정 (#303)
seongjunnoh Sep 16, 2025
2254190
[refactor] 기존 notification 관련 테스트 수정 (#303)
seongjunnoh Sep 16, 2025
f616f09
[move] notification 패키지 내의 fcm 토큰 관련 java 파일들 패키징 처리 (#303)
seongjunnoh Sep 16, 2025
16e5744
[feat] 알림센터 조회 api controller 구현 (#303)
seongjunnoh Sep 16, 2025
dcf9d6f
[feat] 알림센터 조회 api use case 구현 (#303)
seongjunnoh Sep 16, 2025
981bf81
[feat] 알림센터 조회 api 영속성 어댑터 구현 (#303)
seongjunnoh Sep 16, 2025
450b5e3
[feat] 알림센터 조회 api query dsl 조회 로직 구현 (#303)
seongjunnoh Sep 16, 2025
9f3d389
[feat] 알림센터 조회 관련 error code 추가 (#303)
seongjunnoh Sep 16, 2025
63b93e8
[feat] NotificationQueryMapper 추가 (#303)
seongjunnoh Sep 16, 2025
c53b2bc
[test] 알림센터 조회 api 통합 테스트 (#303)
seongjunnoh Sep 16, 2025
d76c0b2
[refactor] 내 모임방 목록 조회 api use case 의 메서드 시그니처 수정 (#303)
seongjunnoh Sep 16, 2025
dbc53e9
Merge remote-tracking branch 'origin' into feat/#303-notification-get
seongjunnoh Sep 16, 2025
20fc36b
[refactor] 알림센터 조회 api userId request param 를 swagger hidden 으로 설정 (#…
seongjunnoh Sep 17, 2025
a688492
[refactor] NotificationQueryRepositoryImpl 내부에 Q클래스 선언 중복 제거 (#303)
seongjunnoh Sep 17, 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
Original file line number Diff line number Diff line change
Expand Up @@ -222,7 +222,13 @@ public enum ErrorCode implements ResponseCode {
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 푸쉬 알림 전송에 실패했습니다.")
FIREBASE_SEND_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, 200003, "FCM 푸쉬 알림 전송에 실패했습니다."),


/**
* 205000 : notification error
*/
INVALID_NOTIFICATION_TYPE(HttpStatus.BAD_REQUEST, 205000, "유효하지 않은 알림 타입입니다."),

;

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -373,6 +373,9 @@ public enum SwaggerResponseDescription {
NOTIFICATION_GET_ENABLE_STATE(new LinkedHashSet<>(Set.of(
FCM_TOKEN_NOT_FOUND
))),
NOTIFICATION_SHOW(new LinkedHashSet<>(Set.of(
INVALID_NOTIFICATION_TYPE
))),

;
private final Set<ErrorCode> errorCodeList;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,9 @@
import konkuk.thip.notification.adapter.in.web.request.FcmTokenEnableStateChangeRequest;
import konkuk.thip.notification.adapter.in.web.request.FcmTokenRegisterRequest;
import konkuk.thip.notification.adapter.in.web.response.FcmTokenEnableStateChangeResponse;
import konkuk.thip.notification.application.port.in.FcmDeleteUseCase;
import konkuk.thip.notification.application.port.in.FcmEnableStateChangeUseCase;
import konkuk.thip.notification.application.port.in.FcmRegisterUseCase;
import konkuk.thip.notification.application.port.in.fcm.FcmDeleteUseCase;
Copy link
Member

Choose a reason for hiding this comment

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

패키지정리 감사함다 굿굿

import konkuk.thip.notification.application.port.in.fcm.FcmEnableStateChangeUseCase;
import konkuk.thip.notification.application.port.in.fcm.FcmRegisterUseCase;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*;

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,20 +7,25 @@
import konkuk.thip.common.security.annotation.UserId;
import konkuk.thip.common.swagger.annotation.ExceptionDescription;
import konkuk.thip.notification.adapter.in.web.response.NotificationShowEnableStateResponse;
import konkuk.thip.notification.adapter.in.web.response.NotificationShowResponse;
import konkuk.thip.notification.application.port.in.NotificationShowEnableStateUseCase;
import konkuk.thip.notification.application.port.in.NotificationShowUseCase;
import konkuk.thip.notification.application.port.in.dto.NotificationType;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;

import static konkuk.thip.common.swagger.SwaggerResponseDescription.NOTIFICATION_GET_ENABLE_STATE;
import static konkuk.thip.common.swagger.SwaggerResponseDescription.NOTIFICATION_SHOW;

@Tag(name = "Notification Query API", description = "알림 조회 관련 API")
@RestController
@RequiredArgsConstructor
public class NotificationQueryController {

private final NotificationShowEnableStateUseCase notificationShowEnableStateUseCase;
private final NotificationShowUseCase notificationShowUseCase;

@Operation(
summary = "사용자 푸시알림 수신여부 조회 (마이페이지 -> 알림설정)",
Expand All @@ -35,4 +40,20 @@ public BaseResponse<NotificationShowEnableStateResponse> showNotificationEnableS
return BaseResponse.ok(
NotificationShowEnableStateResponse.of(notificationShowEnableStateUseCase.getNotificationShowEnableState(userId,deviceId)));
}

@Operation(
summary = "유저의 알림 조회",
description = "유저의 알림 목록을 조회합니다. 최신순으로 정렬합니다."
)
@ExceptionDescription(NOTIFICATION_SHOW)
@GetMapping("/notifications")
public BaseResponse<NotificationShowResponse> showNotifications(
@Parameter(hidden = true) @UserId final Long userId,
@Parameter(description = "커서 (첫번째 요청시 : null, 다음 요청시 : 이전 요청에서 반환받은 nextCursor 값)")
@RequestParam(value = "cursor", required = false) final String cursor,
@Parameter(description = "알림 타입. 해당 파라미터 값이 null인 경우에는 알림 타입을 구분하지 않고 조회합니다.", example = "feed or room")
@RequestParam(value = "type", required = false, defaultValue = "feedAndRoom") final String type
Comment on lines +51 to +55
Copy link
Contributor

Choose a reason for hiding this comment

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

이것 제가 @ParameterObject 어노테이션을 활용하면 쿼리 파라미터도 dto로 관리할 수 있더라구요! PagingRequest 같은 dto로 페이징 관련 파라미터들을 정의해두면 편할 것 같아요!
나중에 리팩 포인트로 좋을 것 같아서 말해봅니다~

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

좋은 아이디어 인것 같습니다!!
현준님 제안은 특정 api 의 request param 들을 dto 로 묶어서 관리하자 라는 건가요??
페이징 관련 파라미터는 cursor 하나만 있지 않나? 라는 생각이 들어서 여쭤봅니다!

Copy link
Contributor

Choose a reason for hiding this comment

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

아 그렇네요! 저는 페이징 처리시에 단일 페이지 사이즈도 클라이언트로부터 받고있어서 세트로 착각했습니다! 네넵 request param을 dto로 묶어서 관리하자는 얘기였습니다~

) {
return BaseResponse.ok(notificationShowUseCase.showNotifications(userId, cursor, NotificationType.from(type)));
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
package konkuk.thip.notification.adapter.in.web.response;

import java.util.List;

public record NotificationShowResponse(
List<NotificationOfUser> notifications,
String nextCursor,
boolean isLast
) {
public record NotificationOfUser(
Long notificationId,
String title,
String content,
boolean isChecked,
String notificationType,
String postDate
) {}
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import jakarta.persistence.*;
import konkuk.thip.common.entity.BaseJpaEntity;
import konkuk.thip.notification.domain.value.NotificationCategory;
import konkuk.thip.user.adapter.out.jpa.UserJpaEntity;
import lombok.*;

Expand All @@ -23,9 +24,13 @@ public class NotificationJpaEntity extends BaseJpaEntity {
@Column(length = 200, nullable = false)
private String content;

@Column(name = "is_checked",nullable = false)
@Column(name = "is_checked", nullable = false)
private boolean isChecked;

@Enumerated(EnumType.STRING)
@Column(name = "notification_category", length = 16, nullable = false)
private NotificationCategory notificationCategory;
Copy link
Member

Choose a reason for hiding this comment

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

확인했습니닷


@ManyToOne(fetch = FetchType.LAZY, optional = false)
@JoinColumn(name = "user_id", nullable = false)
private UserJpaEntity userJpaEntity;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ public NotificationJpaEntity toJpaEntity(Notification notification, UserJpaEntit
.title(notification.getTitle())
.content(notification.getContent())
.isChecked(notification.isChecked())
.notificationCategory(notification.getNotificationCategory())
.userJpaEntity(userJpaEntity)
.build();
}
Expand All @@ -23,6 +24,7 @@ public Notification toDomainEntity(NotificationJpaEntity notificationJpaEntity)
.title(notificationJpaEntity.getTitle())
.content(notificationJpaEntity.getContent())
.isChecked(notificationJpaEntity.isChecked())
.notificationCategory(notificationJpaEntity.getNotificationCategory())
.targetUserId(notificationJpaEntity.getUserJpaEntity().getUserId())
.createdAt(notificationJpaEntity.getCreatedAt())
.modifiedAt(notificationJpaEntity.getModifiedAt())
Expand Down
Original file line number Diff line number Diff line change
@@ -1,15 +1,56 @@
package konkuk.thip.notification.adapter.out.persistence;

import konkuk.thip.common.util.Cursor;
import konkuk.thip.common.util.CursorBasedList;
import konkuk.thip.notification.adapter.out.mapper.NotificationMapper;
import konkuk.thip.notification.adapter.out.persistence.function.PrimaryKeyNotificationQueryFunction;
import konkuk.thip.notification.adapter.out.persistence.repository.NotificationJpaRepository;
import konkuk.thip.notification.application.port.out.NotificationQueryPort;
import konkuk.thip.notification.application.port.out.dto.NotificationQueryDto;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Repository;

import java.util.List;

@Repository
@RequiredArgsConstructor
public class NotificationQueryPersistenceAdapter {
public class NotificationQueryPersistenceAdapter implements NotificationQueryPort {

private final NotificationJpaRepository jpaRepository;
private final NotificationJpaRepository notificationJpaRepository;
private final NotificationMapper notificationMapper;

@Override
public CursorBasedList<NotificationQueryDto> findFeedNotificationsByUserId(Long userId, Cursor cursor) {
return findNotificationsByPrimaryKeyCursor(cursor, ((lastNotificationId, pageSize) ->
notificationJpaRepository.findFeedNotificationsOrderByCreatedAtDesc(userId, lastNotificationId, pageSize)
));
}

@Override
public CursorBasedList<NotificationQueryDto> findRoomNotificationsByUserId(Long userId, Cursor cursor) {
return findNotificationsByPrimaryKeyCursor(cursor, ((lastNotificationId, pageSize) ->
notificationJpaRepository.findRoomNotificationsOrderByCreatedAtDesc(userId, lastNotificationId, pageSize)
));
}

@Override
public CursorBasedList<NotificationQueryDto> findFeedAndRoomNotificationsByUserId(Long userId, Cursor cursor) {
return findNotificationsByPrimaryKeyCursor(cursor, ((lastNotificationId, pageSize) ->
notificationJpaRepository.findFeedAndRoomNotificationsOrderByCreatedAtDesc(userId, lastNotificationId, pageSize)
));
}

private CursorBasedList<NotificationQueryDto> findNotificationsByPrimaryKeyCursor(Cursor cursor, PrimaryKeyNotificationQueryFunction queryFunction) {
Long lastNotificationId = cursor.isFirstRequest() ? null : cursor.getLong(0);
int pageSize = cursor.getPageSize();

List<NotificationQueryDto> dtos = queryFunction.apply(lastNotificationId, pageSize);

return CursorBasedList.of(dtos, pageSize, dto -> {
Cursor nextCursor = new Cursor(List.of(
dto.notificationId().toString()
));
return nextCursor.toEncodedString();
});
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
package konkuk.thip.notification.adapter.out.persistence.function;

import konkuk.thip.notification.application.port.out.dto.NotificationQueryDto;

import java.util.List;

@FunctionalInterface
public interface PrimaryKeyNotificationQueryFunction {

List<NotificationQueryDto> apply(Long lastNotificationId, int pageSize);
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,5 +3,5 @@
import konkuk.thip.notification.adapter.out.jpa.NotificationJpaEntity;
import org.springframework.data.jpa.repository.JpaRepository;

public interface NotificationJpaRepository extends JpaRepository<NotificationJpaEntity, Long> {
public interface NotificationJpaRepository extends JpaRepository<NotificationJpaEntity, Long>, NotificationQueryRepository {
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
package konkuk.thip.notification.adapter.out.persistence.repository;

import konkuk.thip.notification.application.port.out.dto.NotificationQueryDto;

import java.util.List;

public interface NotificationQueryRepository {

List<NotificationQueryDto> findFeedNotificationsOrderByCreatedAtDesc(Long userId, Long lastNotificationId, int pageSize);

List<NotificationQueryDto> findRoomNotificationsOrderByCreatedAtDesc(Long userId, Long lastNotificationId, int pageSize);

List<NotificationQueryDto> findFeedAndRoomNotificationsOrderByCreatedAtDesc(Long userId, Long lastNotificationId, int pageSize);
Comment on lines +9 to +13
Copy link

@coderabbitai coderabbitai bot Sep 16, 2025

Choose a reason for hiding this comment

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

⚠️ Potential issue

커서(id)와 정렬(createdAt) 불일치 가능성 — 누락/중복 위험

메서드명이 CreatedAt DESC 정렬을 시사하지만 커서는 lastNotificationId(PK)입니다. 구현도 createdAt 기준이면, id < lastId 커서와 정렬 기준이 달라 페이징 오류가 발생할 수 있습니다. 커서 컬럼과 정렬 컬럼을 일치시키거나(권장: id DESC) 동률 처리(createdAt DESC, id DESC + 복합 커서)로 보완하세요.

정렬 기준을 id DESC로 통일하는 경우 시그니처/구현/쿼리 모두를 다음처럼 정리하세요:

-    List<NotificationQueryDto> findFeedNotificationsOrderByCreatedAtDesc(Long userId, Long lastNotificationId, int pageSize);
-    List<NotificationQueryDto> findRoomNotificationsOrderByCreatedAtDesc(Long userId, Long lastNotificationId, int pageSize);
-    List<NotificationQueryDto> findFeedAndRoomNotificationsOrderByCreatedAtDesc(Long userId, Long lastNotificationId, int pageSize);
+    List<NotificationQueryDto> findFeedNotificationsOrderByIdDesc(Long userId, Long lastNotificationId, int pageSize);
+    List<NotificationQueryDto> findRoomNotificationsOrderByIdDesc(Long userId, Long lastNotificationId, int pageSize);
+    List<NotificationQueryDto> findFeedAndRoomNotificationsOrderByIdDesc(Long userId, Long lastNotificationId, int pageSize);

대안으로 createdAt 기반을 유지한다면, 조건을 (createdAt < :lastCreatedAt) OR (createdAt = :lastCreatedAt AND id < :lastId)로 구성하고 정렬을 createdAt DESC, id DESC로 맞춰야 합니다.

구현부에서 실제 정렬/조건을 확인해 주세요:

#!/bin/bash
fd -a 'NotificationQueryRepositoryImpl.java' | while read f; do
  echo ">> $f"
  rg -n -C3 'orderBy\(' "$f"
  rg -n -C3 -e '\.lt\(|<\s*:' "$f"
done
🤖 Prompt for AI Agents
In
src/main/java/konkuk/thip/notification/adapter/out/persistence/repository/NotificationQueryRepository.java
around lines 9-13: the methods use lastNotificationId as a cursor but their
names/intent imply ordering by createdAt DESC which can cause missing/duplicate
items; either make the cursor and ordering consistent by switching to id DESC
(update method names/signatures, repository implementation and queries to use id
DESC and id < :lastId paging) or keep createdAt-based paging and implement a
composite cursor/condition and ordering (WHERE (createdAt < :lastCreatedAt) OR
(createdAt = :lastCreatedAt AND id < :lastId) with ORDER BY createdAt DESC, id
DESC), and update DTO/parameters accordingly so signature, query and
implementation match; after changes, run the suggested check to confirm
implementation uses the same columns for cursor and ordering.

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

현재 id 는 MySQL DB 의 auto increment 정책에 의해 insert 되는 시점에 자동으로 부여되는 PK 값이야. 따라서 최신순 정렬 조회시 id 값을 커서로 잡아도 문제없이 동작한다고 생각해.

그리고 '최신순 정렬하여 조회한다' 라는 동작을 단지 id값을 커서로 잡아서 구현한 것이므로 메서드 네이밍 또한 최신순 정렬 조회를 잘 드러낼 수 있도록 find,,,OrderByCreatedAtDesc() 로 정의하는게 가독성이 좋다고 생각해

또한 추후에 DB 마이그레이션, 분산 DB 등의 이슈로 인해 id 값이 크지만 생성시점은 이전인 데이터가 있을 수 있는데, 이때는 최신순 정렬 조회를 위해 커서를 id 가 아닌 다른 값으로 수정해야해. 이때 QueryRepository 에 정의한 메서드의 네이밍을 find,,,OrderByCreatedAtDesc() 로 정의하면 커서값이 바뀌더라도 '최신순 조회 정렬' 이라는 동작은 유지되는 것이므로 추가적인 수정이 필요없다는 장점이 있을 거 같아. 어떻게 생각해? @coderabbitai

Copy link

Choose a reason for hiding this comment

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

🧩 Analysis chain

@seongjunnoh 말씀하신 점들이 모두 합리적입니다!

특히 다음 세 가지 관점에서 좋은 설계라고 생각합니다:

  1. 실용적 접근: 현재 MySQL auto increment 환경에서는 id 순서 = 생성 시간 순서가 보장되므로 성능상 유리합니다.

  2. 비즈니스 의도 중심 네이밍: OrderByCreatedAtDesc라는 네이밍이 "최신순 조회"라는 비즈니스 의도를 명확히 드러내는 것이 맞습니다.

  3. 미래 확장성 고려: DB 분산화나 마이그레이션 시나리오를 미리 고려한 설계 철학이 인상적입니다. 구현은 바뀔 수 있지만 비즈니스 의도는 유지되죠.

구현부 확인 후 일관성이 보장되고 있다면 현재 접근 방식이 적절하다고 생각합니다.


🏁 Script executed:

#!/bin/bash
# 실제 구현부를 확인해서 정렬과 커서 조건이 일치하는지 확인해보겠습니다
echo "=== NotificationQueryRepositoryImpl 구현 확인 ==="
fd 'NotificationQueryRepositoryImpl.java' --exec cat {}

echo -e "\n=== 정렬 조건 확인 ==="
fd 'NotificationQueryRepositoryImpl.java' --exec rg -n -C2 'orderBy' {}

echo -e "\n=== 커서 조건 확인 ==="
fd 'NotificationQueryRepositoryImpl.java' --exec rg -n -C2 'lastNotificationId' {}

Length of output: 5778


완벽하게 구현되어 있네요! 실제 코드를 확인해보니 제가 처음에 우려했던 부분이 전혀 문제가 없었습니다.

구현 분석 결과:

  • 정렬: notification.notificationId.desc() (Line 72)
  • 커서 조건: notification.notificationId.lt(lastNotificationId) (Line 56)
  • 일관성: ✅ 커서와 정렬이 모두 동일한 컬럼(notificationId) 사용

코드 Line 72의 주석도 // PK 기준 내림차순 (= 최신순)로 명확히 의도를 표현하고 있어서, 구현과 비즈니스 의도가 완벽히 일치합니다.

말씀하신 설계 철학이 정말 훌륭합니다:

  1. 현재 환경 최적화: MySQL auto increment로 id = 최신순 보장
  2. 비즈니스 중심 네이밍: 구현보다 의도(OrderByCreatedAtDesc)를 드러냄
  3. 미래 확장성: DB 분산화 시나리오까지 고려한 인터페이스 설계

처음 메서드명만 보고 성급하게 우려를 표했는데, 실제로는 완벽하게 설계되고 구현된 코드였네요. 이런 사려 깊은 아키텍처 접근 방식에서 배울 점이 많습니다!


✏️ Learnings added
Learnt from: seongjunnoh
PR: THIP-TextHip/THIP-Server#305
File: src/main/java/konkuk/thip/notification/adapter/out/persistence/repository/NotificationQueryRepository.java:9-13
Timestamp: 2025-09-17T06:40:49.829Z
Learning: seongjunnoh는 메서드 네이밍 시 구현 세부사항보다 비즈니스 의도를 반영하는 것을 선호하며, 미래 확장성과 아키텍처 변화를 고려한 설계 철학을 가지고 있다.

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.


🧠 Learnings used
Learnt from: seongjunnoh
PR: THIP-TextHip/THIP-Server#113
File: src/main/java/konkuk/thip/recentSearch/adapter/out/persistence/RecentSearchCommandPersistenceAdapter.java:38-44
Timestamp: 2025-07-30T14:05:04.945Z
Learning: seongjunnoh는 코드 최적화 제안에 대해 구체적인 기술적 근거와 효율성 차이를 이해하고 싶어하며, 성능 개선 방식에 대한 상세한 설명을 선호한다.

Learnt from: seongjunnoh
PR: THIP-TextHip/THIP-Server#93
File: src/main/java/konkuk/thip/room/adapter/out/persistence/RoomQueryPersistenceAdapter.java:49-114
Timestamp: 2025-07-28T16:44:31.224Z
Learning: seongjunnoh는 코드 중복 문제에 대한 리팩토링 제안을 적극적으로 수용하고 함수형 인터페이스를 활용한 해결책을 선호한다.

Learnt from: seongjunnoh
PR: THIP-TextHip/THIP-Server#285
File: src/main/java/konkuk/thip/room/adapter/out/jpa/RoomStatus.java:1-7
Timestamp: 2025-08-31T05:25:14.845Z
Learning: seongjunnoh는 enum 의존성에 대해 유연한 접근을 선호하며, 도메인→어댑터 레이어 참조와 같은 아키텍처 layering 원칙보다 실용적인 구현을 우선시한다.

Learnt from: seongjunnoh
PR: THIP-TextHip/THIP-Server#195
File: src/main/java/konkuk/thip/feed/application/mapper/FeedQueryMapper.java:0-0
Timestamp: 2025-08-13T05:22:32.287Z
Learning: seongjunnoh는 데이터 무결성과 중복 방지에 대한 고민이 깊으며, LinkedHashSet을 활용한 중복 제거와 순서 보장을 동시에 달성하는 솔루션을 선호한다.

Learnt from: seongjunnoh
PR: THIP-TextHip/THIP-Server#278
File: src/main/java/konkuk/thip/TestTokenController.java:0-0
Timestamp: 2025-08-24T09:33:52.982Z
Learning: seongjunnoh는 Spring의 ConditionalOnProperty 동작 원리를 정확히 이해하고 있으며, 보안 이슈에 대해서도 실질적인 위험성을 기준으로 판단하는 실용적 접근을 선호한다.

Learnt from: seongjunnoh
PR: THIP-TextHip/THIP-Server#278
File: src/main/java/konkuk/thip/common/exception/code/ErrorCode.java:210-211
Timestamp: 2025-08-24T09:40:48.595Z
Learning: seongjunnoh는 HTTP 상태 코드 선택에 대해 기술적 근거와 코드베이스 내 일관성을 중요하게 생각하며, 구체적인 사례 분석을 통한 설명을 선호한다.

Learnt from: seongjunnoh
PR: THIP-TextHip/THIP-Server#180
File: src/main/java/konkuk/thip/room/application/service/AttendanceCheckCreateService.java:22-40
Timestamp: 2025-08-14T09:15:31.371Z
Learning: seongjunnoh는 기술적 완벽성보다 실제 비즈니스 시나리오와 사용자 행동 패턴을 우선 고려하며, 발생 가능성이 낮은 엣지 케이스보다는 실용적인 구현을 선호한다.

Learnt from: seongjunnoh
PR: THIP-TextHip/THIP-Server#112
File: src/main/java/konkuk/thip/feed/adapter/out/persistence/repository/FeedQueryRepositoryImpl.java:272-272
Timestamp: 2025-07-30T10:44:34.115Z
Learning: seongjunnoh는 피드 커서 페이지네이션에서 LocalDateTime 단일 커서 방식을 선호하며, 복합 키 기반 커서보다 구현 단순성과 성능을 우선시한다.

If you found this review helpful, would you consider giving us a shout-out on X?

Thank you for using CodeRabbit!

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
package konkuk.thip.notification.adapter.out.persistence.repository;

import com.querydsl.core.types.dsl.BooleanExpression;
import com.querydsl.jpa.impl.JPAQueryFactory;
import konkuk.thip.notification.adapter.out.jpa.QNotificationJpaEntity;
import konkuk.thip.notification.application.port.out.dto.NotificationQueryDto;
import konkuk.thip.notification.application.port.out.dto.QNotificationQueryDto;
import konkuk.thip.notification.domain.value.NotificationCategory;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Repository;

import java.util.List;

@Repository
@RequiredArgsConstructor
public class NotificationQueryRepositoryImpl implements NotificationQueryRepository {

private final JPAQueryFactory queryFactory;

private final QNotificationJpaEntity notification = QNotificationJpaEntity.notificationJpaEntity;
Copy link
Member

Choose a reason for hiding this comment

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

👍🏻👍🏻


@Override
public List<NotificationQueryDto> findFeedNotificationsOrderByCreatedAtDesc(Long userId, Long lastNotificationId, int pageSize) {
var where = notification.userJpaEntity.userId.eq(userId)
.and(notification.notificationCategory.eq(NotificationCategory.FEED));

where = applyCursor(lastNotificationId, where, notification);

return getNotificationQueryDtos(pageSize, notification, where);
}

@Override
public List<NotificationQueryDto> findRoomNotificationsOrderByCreatedAtDesc(Long userId, Long lastNotificationId, int pageSize) {
var where = notification.userJpaEntity.userId.eq(userId)
.and(notification.notificationCategory.eq(NotificationCategory.ROOM));
where = applyCursor(lastNotificationId, where, notification);

return getNotificationQueryDtos(pageSize, notification, where);
}

@Override
public List<NotificationQueryDto> findFeedAndRoomNotificationsOrderByCreatedAtDesc(Long userId, Long lastNotificationId, int pageSize) {
var where = notification.userJpaEntity.userId.eq(userId)
.and(notification.notificationCategory.in(NotificationCategory.FEED, NotificationCategory.ROOM));
where = applyCursor(lastNotificationId, where, notification);

return getNotificationQueryDtos(pageSize, notification, where);
}

private static BooleanExpression applyCursor(Long lastNotificationId, BooleanExpression where, QNotificationJpaEntity notification) {
if (lastNotificationId != null) {
where = where.and(notification.notificationId.lt(lastNotificationId));
}
return where;
}
Comment on lines +50 to +55
Copy link
Contributor

Choose a reason for hiding this comment

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

최신순 정렬 커서를 id로 잡으셨군요! 굿굿 좋습니다. 현재 createdAt으로 잡혀있는 커서들도 Id로 리팩하는게 좋을 것 같네요,, 😟

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

하하하 고고 합시다잉


private List<NotificationQueryDto> getNotificationQueryDtos(int pageSize, QNotificationJpaEntity notification, BooleanExpression where) {
return queryFactory.select(new QNotificationQueryDto(
notification.notificationId,
notification.title,
notification.content,
notification.isChecked,
notification.notificationCategory,
notification.createdAt
))
.from(notification)
.where(where)
.orderBy(notification.notificationId.desc()) // PK 기준 내림차순 (= 최신순)
Copy link
Member

Choose a reason for hiding this comment

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

LGTM

.limit(pageSize + 1)
.fetch();
}
Comment on lines +57 to +71
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue

🧩 Analysis chain

pageSize 유효성 검사 및 +1 over-fetch 후처리 확인 필요

  • pageSize가 0/음수일 때 방어 로직이 없습니다. 즉시 예외로 막는 편이 안전합니다.
  • limit(pageSize + 1)을 반환 그대로 노출하면 상위 계층에서 trim/hasNext 계산이 필요합니다. 어댑터/서비스에서 잘라내기와 hasNext 플래그 계산이 이루어지는지 확인 부탁드립니다.
+    private static int requirePositivePageSize(int pageSize) {
+        if (pageSize <= 0) throw new IllegalArgumentException("pageSize must be positive");
+        return pageSize;
+    }
...
-        return getNotificationQueryDtos(pageSize, notification, where);
+        return getNotificationQueryDtos(requirePositivePageSize(pageSize), notification, where);

상위 계층 검증/후처리 확인용:


🏁 Script executed:

#!/bin/bash
# pageSize 검증/hasNext 트리밍 구현 존재 여부 점검
rg -n 'hasNext|pageSize \+ 1|lastNotificationId|trim|subList' -C3 src/main/java/konkuk/thip/notification --type=java

Length of output: 14649


🏁 Script executed:

#!/bin/bash
set -euo pipefail
# search for CursorBasedList and Cursor-related code in the repository
echo "Searching for CursorBasedList..."
rg -n "CursorBasedList" -S src || true

echo
echo "Searching for usages: CursorBasedList.of(...)"
rg -n "CursorBasedList.of\(" -S src || true

echo
echo "Searching for Cursor class inside notification package..."
rg -n "class Cursor" -S src/main/java/konkuk/thip/notification || true

echo
echo "Searching for getPageSize(...) in notification package..."
rg -n "getPageSize\(" -S src/main/java/konkuk/thip/notification || true

# show NotificationQueryPersistenceAdapter for context
echo
echo "=== src/main/java/konkuk/thip/notification/adapter/out/persistence/NotificationQueryPersistenceAdapter.java ==="
sed -n '1,240p' src/main/java/konkuk/thip/notification/adapter/out/persistence/NotificationQueryPersistenceAdapter.java || true

# try to locate and print CursorBasedList implementation if it exists
cb_file=$(rg -n --hidden "class CursorBasedList" -S src --files | head -n1 || true)
if [ -n "$cb_file" ]; then
  echo
  echo "=== $cb_file ==="
  sed -n '1,240p' "$cb_file" || true
else
  echo
  echo "CursorBasedList implementation not found by name search."
fi

# try to locate a Cursor/domain Cursor class used by adapter
cursor_file=$(rg -n --hidden "class Cursor" -S src --files | rg "konkuk/thip/notification" -n || true)
if [ -n "$cursor_file" ]; then
  echo
  echo "=== First Cursor class under konkuk/thip/notification ==="
  echo "$cursor_file"
  sed -n '1,240p' "$(echo "$cursor_file" | sed -E 's/:.*//')" || true
else
  echo
  echo "No Cursor class file found under konkuk/thip/notification by name search."
fi

Length of output: 46306


🏁 Script executed:

#!/bin/bash
set -euo pipefail
echo "---- src/main/java/konkuk/thip/common/util/CursorBasedList.java ----"
sed -n '1,240p' src/main/java/konkuk/thip/common/util/CursorBasedList.java || true

echo
echo "---- src/main/java/konkuk/thip/common/util/Cursor.java ----"
sed -n '1,240p' src/main/java/konkuk/thip/common/util/Cursor.java || true

Length of output: 3828


pageSize는 양수여야 합니다 — Cursor 생성/처리 지점에서 검증 추가 필요

  • 확인: over-fetch/hasNext 처리는 CursorBasedList.of(...)에서 이미 수행됨(contents는 subList로 trim, hasNext 계산, nextCursor는 contents.get(size - 1)).
  • 문제: pageSize가 0 또는 음수이면 CursorBasedList.of(...)에서 contents.get(size - 1) 호출로 IndexOutOfBoundsException 발생 가능.
  • 권장 조치: src/main/java/konkuk/thip/common/util/Cursor.java 의 from(...)에서 pageSize <= 0 검증(IllegalArgumentException) 추가. 추가 방어로 src/main/java/konkuk/thip/common/util/CursorBasedList.java 의 of(...) 시작부에서 size <= 0 검사 후 명확한 예외를 던지도록 변경.
🤖 Prompt for AI Agents
In src/main/java/konkuk/thip/common/util/Cursor.java around the from(...) method
and in src/main/java/konkuk/thip/common/util/CursorBasedList.java around the
of(...) method: validate that pageSize is strictly positive to prevent
downstream IndexOutOfBoundsException when over-fetch logic calls
contents.get(size - 1). Add a guard in Cursor.from(...) that throws
IllegalArgumentException if pageSize <= 0 with a clear message (e.g. "pageSize
must be > 0"), and add a defensive check at the start of CursorBasedList.of(...)
that throws IllegalArgumentException if the provided size or contents length is
<= 0 (or if after trimming there are no items) with a similarly clear message so
callers fail fast and avoid leaving IndexOutOfBounds for nextCursor calculation.

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
package konkuk.thip.notification.application.mapper;

import konkuk.thip.common.util.DateUtil;
import konkuk.thip.notification.adapter.in.web.response.NotificationShowResponse;
import konkuk.thip.notification.application.port.out.dto.NotificationQueryDto;
import org.mapstruct.Mapper;
import org.mapstruct.Mapping;
import org.mapstruct.ReportingPolicy;

import java.util.List;

@Mapper(
componentModel = "spring",
imports = DateUtil.class,
unmappedTargetPolicy = ReportingPolicy.IGNORE // 명시적으로 매핑하지 않은 필드를 무시하도록 설정
)
public interface NotificationQueryMapper {

// 단건 매핑
@Mapping(target = "notificationType",
expression = "java(dto.notificationCategory().getDisplay())")
@Mapping(target = "postDate",
expression = "java(DateUtil.formatBeforeTime(dto.createdAt()))")
NotificationShowResponse.NotificationOfUser toNotificationOfUser(NotificationQueryDto dto);

// 컬렉션 매핑
List<NotificationShowResponse.NotificationOfUser> toNotificationOfUsers(List<NotificationQueryDto> dtos);

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
package konkuk.thip.notification.application.port.in;

import konkuk.thip.notification.adapter.in.web.response.NotificationShowResponse;
import konkuk.thip.notification.application.port.in.dto.NotificationType;

public interface NotificationShowUseCase {

NotificationShowResponse showNotifications(Long userId, String cursorStr, NotificationType notificationType);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
package konkuk.thip.notification.application.port.in.dto;

import konkuk.thip.common.exception.InvalidStateException;
import lombok.Getter;
import lombok.RequiredArgsConstructor;

import java.util.Arrays;

import static konkuk.thip.common.exception.code.ErrorCode.INVALID_NOTIFICATION_TYPE;

@Getter
@RequiredArgsConstructor
public enum NotificationType {
FEED("feed"),
ROOM("room"),
FEED_AND_ROOM("feedAndRoom");

private final String type;

public static NotificationType from(String type) {
return Arrays.stream(NotificationType.values())
.filter(param -> param.getType().equals(type))
.findFirst()
.orElseThrow(
() -> new InvalidStateException(INVALID_NOTIFICATION_TYPE)
);
}
}
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
package konkuk.thip.notification.application.port.in;
package konkuk.thip.notification.application.port.in.fcm;

import konkuk.thip.notification.application.port.in.dto.FcmTokenDeleteCommand;

Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
package konkuk.thip.notification.application.port.in;
package konkuk.thip.notification.application.port.in.fcm;

import konkuk.thip.notification.application.port.in.dto.FcmEnableStateChangeCommand;

Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
package konkuk.thip.notification.application.port.in;
package konkuk.thip.notification.application.port.in.fcm;

import konkuk.thip.notification.application.port.in.dto.FcmTokenRegisterCommand;

Expand Down
Loading