From 6c25084b76425a655ad835f62c2b58117c0b34ee Mon Sep 17 00:00:00 2001 From: sevin98 Date: Fri, 14 Aug 2026 01:57:28 +0900 Subject: [PATCH 1/6] =?UTF-8?q?feat:=20=EC=95=8C=EB=A6=BC=20=EB=AA=A9?= =?UTF-8?q?=EB=A1=9D=C2=B7=EC=9D=BD=EC=9D=8C=20API?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 앱 알림 화면이 하드코딩 데이터로 돌고 있어(홈 배지도 그 가짜 목록을 셌다) 조회·읽음을 서버에 만든다 - 알림 종류는 TRIP_TOMORROW 하나만 정의했다. 서버가 지금 실제로 만들 근거가 있는 것이 그것뿐이라, 쓰지 않을 종류를 미리 나열하면 앱에 영영 오지 않는 분기가 남는다 - 프론트 초안의 title·body 를 응답에서 뺐다. 프론트가 "type 에 아이콘·문구를 맞추겠다" 고 한 것을 계약에 반영한 것이다. 문구를 서버에 굳히면 표현을 고칠 때 이미 쌓인 알림이 옛 문구로 남아 화면에 두 세대가 섞인다(OpeningStatus 와 같은 방식으로 enum 이름만 싣는다) - 지역명을 대신 실어주는 안은 접었다. 알림 하나당 코스·지역을 되짚는 조회가 붙는데, 앱은 내 코스 목록에서 이미 그 값을 갖고 있다 - 없는 알림과 남의 알림을 똑같이 404 로 답한다. 403 으로 나누면 ID 를 훑어 남의 알림 존재를 확인할 수 있다(코스 상세와 같은 규칙) - 이미 읽은 알림의 재요청은 성공으로 둔다. 원한 상태가 이미 이뤄져 있고, 알림 화면은 같은 요청을 두 번 보내기 쉬운 자리다 - 읽음 응답에도 안읽음 수를 싣는다. 안 주면 배지 하나 때문에 목록을 다시 부른다 - 안읽음 수는 페이지와 무관한 전체 수다. 페이지 안에서 세면 첫 페이지 크기에서 배지가 멈춘다 - 알림은 쌓이는 데이터라 목록을 page·size 로 끊었다. 정렬 tie-break 에 id 를 넣어 같은 초에 만들어진 알림이 페이지 경계에서 겹치거나 빠지지 않게 했다 - 전체 읽음은 벌크 UPDATE 다. 행을 다 읽어 하나씩 고치면 쌓인 만큼 힙에 올린다 - 읽음을 boolean 이 아니라 시각으로 저장한다. 비용이 같은데 "언제 읽었나" 까지 답한다 - 소유 키는 코스·연차와 같은 X-Guest-Id 다. 인증이 붙는 날 세 테이블을 함께 옮기는 것이 안전하다 --- .../controller/NotificationApi.java | 81 ++++++++++++ .../controller/NotificationController.java | 51 ++++++++ .../controller/dto/NotificationResponse.java | 35 +++++ .../controller/dto/NotificationsResponse.java | 21 +++ .../controller/dto/UnreadCountResponse.java | 15 +++ .../notification/domain/Notification.java | 120 ++++++++++++++++++ .../domain/NotificationErrorCode.java | 48 +++++++ .../domain/NotificationException.java | 22 ++++ .../notification/domain/NotificationType.java | 25 ++++ .../repository/NotificationJpaRepository.java | 37 ++++++ .../repository/NotificationRepository.java | 37 ++++++ .../NotificationRepositoryImpl.java | 42 ++++++ .../service/NotificationService.java | 87 +++++++++++++ .../service/dto/MyNotifications.java | 32 +++++ .../V20260814014306__create_notification.sql | 26 ++++ 15 files changed, 679 insertions(+) create mode 100644 src/main/java/com/offway/core/notification/controller/NotificationApi.java create mode 100644 src/main/java/com/offway/core/notification/controller/NotificationController.java create mode 100644 src/main/java/com/offway/core/notification/controller/dto/NotificationResponse.java create mode 100644 src/main/java/com/offway/core/notification/controller/dto/NotificationsResponse.java create mode 100644 src/main/java/com/offway/core/notification/controller/dto/UnreadCountResponse.java create mode 100644 src/main/java/com/offway/core/notification/domain/Notification.java create mode 100644 src/main/java/com/offway/core/notification/domain/NotificationErrorCode.java create mode 100644 src/main/java/com/offway/core/notification/domain/NotificationException.java create mode 100644 src/main/java/com/offway/core/notification/domain/NotificationType.java create mode 100644 src/main/java/com/offway/core/notification/repository/NotificationJpaRepository.java create mode 100644 src/main/java/com/offway/core/notification/repository/NotificationRepository.java create mode 100644 src/main/java/com/offway/core/notification/repository/NotificationRepositoryImpl.java create mode 100644 src/main/java/com/offway/core/notification/service/NotificationService.java create mode 100644 src/main/java/com/offway/core/notification/service/dto/MyNotifications.java create mode 100644 src/main/resources/db/migration/V20260814014306__create_notification.sql diff --git a/src/main/java/com/offway/core/notification/controller/NotificationApi.java b/src/main/java/com/offway/core/notification/controller/NotificationApi.java new file mode 100644 index 0000000..22a6912 --- /dev/null +++ b/src/main/java/com/offway/core/notification/controller/NotificationApi.java @@ -0,0 +1,81 @@ +package com.offway.core.notification.controller; + +import com.offway.core.common.response.ApiResponseBody; +import com.offway.core.notification.controller.dto.NotificationsResponse; +import com.offway.core.notification.controller.dto.UnreadCountResponse; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.tags.Tag; + +/** 알림 API 문서 계약(#263). 매핑은 구현체({@link NotificationController})가 소유한다. */ +@Tag(name = "알림", description = "알림 목록 · 읽음 처리") +public interface NotificationApi { + + @Operation( + summary = "알림 목록", + description = + """ + 소유자의 알림을 **최근 것부터** 준다. 안 읽은 개수(`unreadCount`)를 함께 주므로 홈 배지가 + 이 응답 하나로 채워진다. + + **문구는 서버가 주지 않는다.** 각 알림은 종류(`type`)만 싣고, 아이콘과 문구는 앱이 그 값에 + 맞춘다. 문구를 서버에 굳혀 두면 이미 쌓인 알림이 옛 문구로 남아 화면에 두 세대가 섞인다. + + 현재 `type` 은 `TRIP_TOMORROW`(내일 여행) 하나다. **값은 늘어날 수 있으므로 모르는 값은 + 무시하거나 기본 아이콘으로 그린다** — 앱을 업데이트하지 않은 사용자에게도 새 알림이 간다. + + `courseId` 가 있으면 누를 때 그 코스로 이동한다. 없을 수도 있다(코스와 무관한 알림). + 지워진 코스를 가리킬 수도 있다 — 알림은 코스가 사라져도 남는다. + + **`unreadCount` 는 페이지와 무관한 전체 수다.** 이 페이지 안의 안읽음 수가 아니다. + + **페이지로 끊어 준다.** 전체 건수·페이지 수는 응답 래퍼의 `pageResponse` 에 담긴다. + `size` 는 기본 20, 최대 100 이며 넘겨 보내면 거절하지 않고 100 으로 자른다. + """) + @ApiResponse(responseCode = "200", description = "조회 성공(없으면 빈 목록). 페이지 정보는 pageResponse") + @ApiResponse(responseCode = "400", description = "게스트 ID 누락·빈 값·64자 초과, 또는 page·size 가 정수가 아님") + @ApiResponse(responseCode = "401", description = "인증 필요") + ApiResponseBody notifications( + @Parameter(description = "게스트 식별자", example = "guest-abc123") String guestId, + @Parameter(description = "0부터 시작하는 페이지 번호. 없으면 0, 음수는 0 으로 자른다", example = "0") Integer page, + @Parameter(description = "페이지 크기. 없으면 20, 최대 100(초과분은 잘림)", example = "20") Integer size); + + @Operation( + summary = "알림 하나 읽음", + description = + """ + 알림 하나를 읽음으로 바꾸고 **남은 안읽음 개수**를 돌려준다. 배지를 고치려고 목록을 다시 + 부르지 않아도 된다. + + **이미 읽은 알림에 다시 보내도 성공(200)이다.** 사용자가 원한 상태가 이미 이뤄져 있고, + 알림 화면은 같은 요청을 두 번 보내기 쉬운 자리다. 처음 읽은 시각은 덮어쓰지 않는다. + + **없는 알림과 남의 알림은 똑같이 404 다.** 남의 것에 403 을 주면 "그 id 는 존재한다" 를 + 알려주는 셈이라, id 를 훑어 남의 알림 존재를 확인할 수 있다. + """) + @ApiResponse(responseCode = "200", description = "읽음 처리 성공(이미 읽었어도 성공)") + @ApiResponse(responseCode = "400", description = "게스트 ID 누락·빈 값·64자 초과, 또는 알림 ID 가 정수가 아님") + @ApiResponse(responseCode = "401", description = "인증 필요") + @ApiResponse(responseCode = "404", description = "요청한 알림이 없거나 소유자가 아님") + ApiResponseBody read( + @Parameter(description = "게스트 식별자", example = "guest-abc123") String guestId, + @Parameter(description = "알림 ID", example = "1") long notificationId); + + @Operation( + summary = "전체 읽음", + description = + """ + 소유자의 안 읽은 알림을 한 번에 읽음 처리하고 **남은 안읽음 개수**를 돌려준다. + + 보통 0 이지만, 처리와 같은 순간에 새 알림이 들어왔다면 0 이 아닐 수 있다. 응답 값을 + 그대로 배지에 쓰면 된다. + + **읽을 것이 없어도 성공(200)이다.** + """) + @ApiResponse(responseCode = "200", description = "전체 읽음 처리 성공(읽을 것이 없어도 성공)") + @ApiResponse(responseCode = "400", description = "게스트 ID 누락·빈 값·64자 초과") + @ApiResponse(responseCode = "401", description = "인증 필요") + ApiResponseBody readAll( + @Parameter(description = "게스트 식별자", example = "guest-abc123") String guestId); +} diff --git a/src/main/java/com/offway/core/notification/controller/NotificationController.java b/src/main/java/com/offway/core/notification/controller/NotificationController.java new file mode 100644 index 0000000..6a86829 --- /dev/null +++ b/src/main/java/com/offway/core/notification/controller/NotificationController.java @@ -0,0 +1,51 @@ +package com.offway.core.notification.controller; + +import com.offway.core.common.response.ApiResponseBody; +import com.offway.core.common.response.PageResponse; +import com.offway.core.notification.controller.dto.NotificationsResponse; +import com.offway.core.notification.controller.dto.UnreadCountResponse; +import com.offway.core.notification.service.NotificationService; +import com.offway.core.notification.service.dto.MyNotifications; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PatchMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestHeader; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/api/v1/notifications") +@RequiredArgsConstructor +public class NotificationController implements NotificationApi { + + /** 소유 키 — 코스·연차와 같은 헤더를 쓴다. 인증이 붙는 날 함께 옮긴다. */ + private static final String GUEST_HEADER = "X-Guest-Id"; + + private final NotificationService notificationService; + + @Override + @GetMapping + public ApiResponseBody notifications( + @RequestHeader(GUEST_HEADER) String guestId, + @RequestParam(required = false) Integer page, + @RequestParam(required = false) Integer size) { + MyNotifications myNotifications = notificationService.myNotifications(guestId, page, size); + return ApiResponseBody.ok(NotificationsResponse.from(myNotifications), PageResponse.of(myNotifications)); + } + + @Override + @PatchMapping("/{notificationId}/read") + public ApiResponseBody read( + @RequestHeader(GUEST_HEADER) String guestId, @PathVariable long notificationId) { + return ApiResponseBody.ok(UnreadCountResponse.of(notificationService.markRead(guestId, notificationId))); + } + + @Override + @PostMapping("/read-all") + public ApiResponseBody readAll(@RequestHeader(GUEST_HEADER) String guestId) { + return ApiResponseBody.ok(UnreadCountResponse.of(notificationService.markAllRead(guestId))); + } +} diff --git a/src/main/java/com/offway/core/notification/controller/dto/NotificationResponse.java b/src/main/java/com/offway/core/notification/controller/dto/NotificationResponse.java new file mode 100644 index 0000000..eac197e --- /dev/null +++ b/src/main/java/com/offway/core/notification/controller/dto/NotificationResponse.java @@ -0,0 +1,35 @@ +package com.offway.core.notification.controller.dto; + +import com.offway.core.notification.domain.Notification; +import com.offway.core.notification.domain.NotificationType; +import io.swagger.v3.oas.annotations.media.Schema; +import java.time.LocalDateTime; + +/** + * 알림 한 건(#263). + * + *

문구가 없다. 종류만 내려주고 아이콘·문구는 앱이 맞춘다 — 프론트가 그렇게 하겠다고 했고, + * 문구를 서버에 굳히면 이미 쌓인 알림이 옛 문구로 남는다. + * + * @param id 알림 식별자. 읽음 처리에 쓴다 + * @param type 알림 종류. 앱이 이 값으로 아이콘·문구를 고른다 + * @param courseId 누르면 이동할 코스. 코스와 무관한 알림이면 null + * @param read 읽었는지 + * @param createdAt 알림이 만들어진 시각(KST) + */ +public record NotificationResponse( + long id, + NotificationType type, + @Schema(example = "12", nullable = true) Long courseId, + boolean read, + LocalDateTime createdAt) { + + public static NotificationResponse from(Notification notification) { + return new NotificationResponse( + notification.getId(), + notification.getType(), + notification.getCourseId(), + notification.isRead(), + notification.getCreatedAt()); + } +} diff --git a/src/main/java/com/offway/core/notification/controller/dto/NotificationsResponse.java b/src/main/java/com/offway/core/notification/controller/dto/NotificationsResponse.java new file mode 100644 index 0000000..d66232a --- /dev/null +++ b/src/main/java/com/offway/core/notification/controller/dto/NotificationsResponse.java @@ -0,0 +1,21 @@ +package com.offway.core.notification.controller.dto; + +import com.offway.core.notification.service.dto.MyNotifications; +import java.util.List; + +/** + * 알림 목록 응답(#263) — 한 페이지 + 안읽음 전체 수. + * + * @param notifications 이 페이지의 알림. 최근 것부터 + * @param unreadCount 안 읽은 알림 전체 개수. 홈 배지가 쓰는 값이라 페이지와 무관하다 + */ +public record NotificationsResponse(List notifications, long unreadCount) { + + public static NotificationsResponse from(MyNotifications myNotifications) { + return new NotificationsResponse( + myNotifications.notifications().stream() + .map(NotificationResponse::from) + .toList(), + myNotifications.unreadCount()); + } +} diff --git a/src/main/java/com/offway/core/notification/controller/dto/UnreadCountResponse.java b/src/main/java/com/offway/core/notification/controller/dto/UnreadCountResponse.java new file mode 100644 index 0000000..6c1042e --- /dev/null +++ b/src/main/java/com/offway/core/notification/controller/dto/UnreadCountResponse.java @@ -0,0 +1,15 @@ +package com.offway.core.notification.controller.dto; + +/** + * 읽음 처리 응답(#263) — 처리 후 남은 안읽음 개수. + * + *

읽은 직후 앱은 홈 배지를 고쳐야 한다. 안 주면 목록을 한 번 더 부르게 되므로 같은 응답에 실어 보낸다. + * + * @param unreadCount 안 읽은 알림 전체 개수 + */ +public record UnreadCountResponse(long unreadCount) { + + public static UnreadCountResponse of(long unreadCount) { + return new UnreadCountResponse(unreadCount); + } +} diff --git a/src/main/java/com/offway/core/notification/domain/Notification.java b/src/main/java/com/offway/core/notification/domain/Notification.java new file mode 100644 index 0000000..27496ec --- /dev/null +++ b/src/main/java/com/offway/core/notification/domain/Notification.java @@ -0,0 +1,120 @@ +package com.offway.core.notification.domain; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import java.time.LocalDateTime; +import java.util.Objects; +import java.util.Optional; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +/** + * 사용자에게 보여줄 알림 한 건(#263). + * + *

문구를 담지 않는다. 종류({@link NotificationType})만 저장하고 화면 문구는 앱이 만든다. 문구를 + * 서버에 굳혀 두면 이미 쌓인 알림은 영영 옛 문구로 남아, 앱이 표현을 고칠 때 화면에 두 세대가 섞인다. + * + *

읽음을 boolean 이 아니라 시각으로 둔다. 저장 비용이 같은데 "읽었다" 외에 "언제 읽었나" 까지 + * 답한다. 나중에 안 읽은 알림을 다시 밀어주는 규칙을 넣을 때 근거가 이미 있다. + * + *

{@code courseId} 는 도메인 경계를 넘는 참조라 raw ID 다(영속성 규약). 코스가 지워져도 알림은 + * 남아야 하므로 연관관계로 묶지 않는다 — 지워진 코스를 가리키는 알림은 눌러도 코스가 없을 뿐이다. + */ +@Entity +@Table(name = "notification") +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class Notification { + + /** 소유 키 길이 — 코스({@code Course.MAX_GUEST_ID_LENGTH})·연차와 같은 값을 쓴다. */ + public static final int MAX_OWNER_ID_LENGTH = 64; + + /** enum 이름을 담는 칸. 지금 가장 긴 이름의 두 배 남짓으로, 새 종류가 늘어도 마이그레이션이 필요 없다. */ + public static final int TYPE_LENGTH = 40; + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "guest_id", nullable = false, length = MAX_OWNER_ID_LENGTH) + private String guestId; + + /** + * ordinal 이 아니라 이름으로 저장한다 — ordinal 은 상수를 재배치하는 순간 이미 저장된 행의 뜻이 통째로 + * 바뀐다. enum 이름은 클라이언트 계약이기도 하다. + */ + @Enumerated(EnumType.STRING) + @Column(name = "type", nullable = false, length = TYPE_LENGTH) + private NotificationType type; + + /** 누르면 이동할 코스. 코스와 무관한 알림도 생길 수 있어 없을 수 있다. */ + @Column(name = "course_id") + private Long courseId; + + /** 읽은 시각. null 이면 안 읽음. */ + @Column(name = "read_at") + private LocalDateTime readAt; + + @Column(name = "created_at", nullable = false) + private LocalDateTime createdAt; + + @Builder + private Notification(String guestId, NotificationType type, Long courseId, LocalDateTime createdAt) { + this.guestId = requireOwner(guestId); + this.type = Objects.requireNonNull(type, "알림 종류는 필수입니다"); + this.courseId = courseId; + this.createdAt = Objects.requireNonNull(createdAt, "생성 시각은 필수입니다"); + } + + /** + * 소유 키 계약 검증(400). + * + *

빈 헤더({@code X-Guest-Id: " "})는 {@code @RequestHeader} 를 통과하므로 멀쩡한 클라이언트가 + * 정상 요청으로 닿는다 — 불변식으로 다루면 500 이 나간다. + * + *

도메인이 들고 조회 경로도 이걸 쓴다. 조회만 통과시키면 같은 헤더가 메서드에 따라 200 과 400 으로 + * 갈린다. + */ + public static String requireOwner(String guestId) { + if (guestId == null || guestId.isBlank() || guestId.length() > MAX_OWNER_ID_LENGTH) { + throw NotificationException.invalidOwnerId(); + } + return guestId; + } + + /** + * 읽음으로 바꾼다 — 이미 읽었으면 아무것도 하지 않는다. + * + *

재요청을 409 로 막지 않는 이유: 사용자가 원한 상태("읽음")가 이미 이뤄져 있다. 알림 화면은 스크롤 + * 중에 같은 요청을 두 번 보내기 쉬운 자리라, 두 번째를 실패로 만들면 화면이 오류를 띄운다. + * + *

처음 읽은 시각을 덮어쓰지 않는다. + * + * @return 이 호출로 실제 바뀌었으면 true + */ + public boolean markRead(LocalDateTime readAt) { + Objects.requireNonNull(readAt, "읽은 시각은 필수입니다"); + if (isRead()) { + return false; + } + this.readAt = readAt; + return true; + } + + public boolean isRead() { + return readAt != null; + } + + /** 누르면 이동할 코스. 없으면 비어 있다. */ + public Optional course() { + return Optional.ofNullable(courseId); + } +} diff --git a/src/main/java/com/offway/core/notification/domain/NotificationErrorCode.java b/src/main/java/com/offway/core/notification/domain/NotificationErrorCode.java new file mode 100644 index 0000000..8062efb --- /dev/null +++ b/src/main/java/com/offway/core/notification/domain/NotificationErrorCode.java @@ -0,0 +1,48 @@ +package com.offway.core.notification.domain; + +import com.offway.core.common.exception.ErrorCategory; +import com.offway.core.common.exception.ErrorCode; + +/** + * 알림 관련 에러 사유(#263). + * + *

번호는 append-only — 재사용·재배치하지 않고 결번을 유지한다. + */ +public enum NotificationErrorCode implements ErrorCode { + + /** 소유 키(게스트 ID) 가 비었거나 너무 길다. 빈 헤더는 {@code @RequestHeader} 를 통과하므로 정상 요청이 닿는다. */ + INVALID_OWNER_ID("NOTIFICATION-001", ErrorCategory.BAD_REQUEST, "게스트 식별자가 올바르지 않습니다."), + + /** + * 요청한 알림이 없거나 소유자가 아니다. + * + *

둘을 나누지 않는다. 남의 알림에 403 을 주면 "그 id 는 존재하는데 네 것이 아니다" 를 알려주는 + * 셈이라, id 를 훑어 남의 알림 존재를 확인할 수 있다. + */ + NOTIFICATION_NOT_FOUND("NOTIFICATION-002", ErrorCategory.NOT_FOUND, "요청한 알림을 찾을 수 없습니다."); + + private final String code; + private final ErrorCategory category; + private final String message; + + NotificationErrorCode(String code, ErrorCategory category, String message) { + this.code = code; + this.category = category; + this.message = message; + } + + @Override + public String code() { + return code; + } + + @Override + public ErrorCategory category() { + return category; + } + + @Override + public String message() { + return message; + } +} diff --git a/src/main/java/com/offway/core/notification/domain/NotificationException.java b/src/main/java/com/offway/core/notification/domain/NotificationException.java new file mode 100644 index 0000000..ea43c48 --- /dev/null +++ b/src/main/java/com/offway/core/notification/domain/NotificationException.java @@ -0,0 +1,22 @@ +package com.offway.core.notification.domain; + +import com.offway.core.common.exception.BaseException; +import com.offway.core.common.exception.ErrorCode; + +/** 알림 관련 예외(#263). */ +public final class NotificationException extends BaseException { + + private NotificationException(ErrorCode errorCode) { + super(errorCode); + } + + /** 소유 키가 비었거나 너무 길다. */ + public static NotificationException invalidOwnerId() { + return new NotificationException(NotificationErrorCode.INVALID_OWNER_ID); + } + + /** 요청한 알림이 없거나 소유자가 아니다 — 둘을 구분하지 않는다. */ + public static NotificationException notificationNotFound() { + return new NotificationException(NotificationErrorCode.NOTIFICATION_NOT_FOUND); + } +} diff --git a/src/main/java/com/offway/core/notification/domain/NotificationType.java b/src/main/java/com/offway/core/notification/domain/NotificationType.java new file mode 100644 index 0000000..a265c2a --- /dev/null +++ b/src/main/java/com/offway/core/notification/domain/NotificationType.java @@ -0,0 +1,25 @@ +package com.offway.core.notification.domain; + +/** + * 알림 종류 — 앱이 아이콘·문구를 맞추는 키(#263). + * + *

문구를 서버가 들지 않는다. 프론트가 "어떤 값들이 오는지 정해 주시면 앱에서 아이콘·문구를 + * 맞추겠다" 고 했고, 실제로 문구는 화면 폭·서체·강조에 묶여 있어 앱이 쥐는 편이 낫다. 응답에는 이 상수 + * 이름만 실린다 — {@code OpeningStatus} 와 같은 방식이다. + * + *

상수가 하나뿐인 것은 미완성이 아니라 의도다. 지금 서버가 실제로 만들 근거가 있는 알림은 + * 여행 전날 하나다. 보낼 사람이 없는 종류를 미리 나열하면 앱이 그 값을 기다리는 분기를 만들고, 영영 + * 오지 않는 분기가 남는다. 값이 느는 것은 클라이언트에 안전한 변경(추가)이므로 보낼 것이 생길 때 더한다. + * + *

추가 기준: 서버가 그 사실을 이미 알고 있고, 그것을 알릴 주체가 이 레포에 있는가. + */ +public enum NotificationType { + + /** + * 내일 여행을 떠난다 — 저장한 코스의 여행 시작일이 내일이다. + * + *

서버가 여행 날짜를 들고 있어 판단에 외부가 필요 없고, 알림을 받는 시점(전날)이 사용자가 할 일 + * (짐 싸기)과 맞는다. + */ + TRIP_TOMORROW +} diff --git a/src/main/java/com/offway/core/notification/repository/NotificationJpaRepository.java b/src/main/java/com/offway/core/notification/repository/NotificationJpaRepository.java new file mode 100644 index 0000000..ec9dbac --- /dev/null +++ b/src/main/java/com/offway/core/notification/repository/NotificationJpaRepository.java @@ -0,0 +1,37 @@ +package com.offway.core.notification.repository; + +import com.offway.core.notification.domain.Notification; +import java.time.LocalDateTime; +import java.util.Optional; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +/** Spring Data — {@link NotificationRepositoryImpl} 이 위임한다. */ +interface NotificationJpaRepository extends JpaRepository { + + /** + * 정렬은 쿼리가 소유한다({@code Paging.of} 가 정렬을 얹지 않는 이유). + * + *

{@code id} 를 2차 정렬에 둔다 — 같은 초에 만들어진 알림(한 배치가 여러 건을 넣는다)의 순서가 + * 정해지지 않으면 페이지 경계에서 같은 행이 두 번 나오거나 아예 빠진다. + */ + Page findByGuestIdOrderByCreatedAtDescIdDesc(String guestId, Pageable pageable); + + Optional findByIdAndGuestId(Long id, String guestId); + + long countByGuestIdAndReadAtIsNull(String guestId); + + /** + * 전체 읽음은 벌크 UPDATE 다 — 행을 다 읽어 하나씩 고치면 쌓인 만큼 힙에 올린다. + * + *

{@code clearAutomatically} 로 영속성 컨텍스트를 비운다. 안 비우면 같은 트랜잭션에서 이어지는 + * 안읽음 개수 조회가 갱신 전 스냅샷을 보고 0 이 아닌 값을 답한다. + */ + @Modifying(flushAutomatically = true, clearAutomatically = true) + @Query("update Notification n set n.readAt = :readAt where n.guestId = :guestId and n.readAt is null") + int markAllRead(@Param("guestId") String guestId, @Param("readAt") LocalDateTime readAt); +} diff --git a/src/main/java/com/offway/core/notification/repository/NotificationRepository.java b/src/main/java/com/offway/core/notification/repository/NotificationRepository.java new file mode 100644 index 0000000..f7f75d7 --- /dev/null +++ b/src/main/java/com/offway/core/notification/repository/NotificationRepository.java @@ -0,0 +1,37 @@ +package com.offway.core.notification.repository; + +import com.offway.core.notification.domain.Notification; +import java.time.LocalDateTime; +import java.util.Optional; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; + +/** 알림 영속 port(#263). 구현은 {@link NotificationRepositoryImpl}. */ +public interface NotificationRepository { + + Notification save(Notification notification); + + /** 소유자의 알림 한 페이지 — 최근 것부터. */ + Page findByOwner(String guestId, Pageable pageable); + + /** + * 소유자 범위로만 찾는다 — id 만으로 남의 알림에 닿을 수 없게. + * + *

없는 id 와 남의 id 가 같은 결과(빈 값)로 떨어져야 호출자가 둘을 구분해 답할 수 없다. + */ + Optional findOwned(String guestId, Long id); + + /** + * 안 읽은 알림 개수 — 페이지와 무관한 전체 수. + * + *

홈의 배지가 이 값을 쓴다. 페이지 안에서 세면 20개짜리 첫 페이지에서 배지가 20 에 멈춘다. + */ + long countUnread(String guestId); + + /** + * 소유자의 안 읽은 알림을 한 번에 읽음 처리한다. + * + * @return 실제로 바뀐 건수 + */ + int markAllRead(String guestId, LocalDateTime readAt); +} diff --git a/src/main/java/com/offway/core/notification/repository/NotificationRepositoryImpl.java b/src/main/java/com/offway/core/notification/repository/NotificationRepositoryImpl.java new file mode 100644 index 0000000..7321651 --- /dev/null +++ b/src/main/java/com/offway/core/notification/repository/NotificationRepositoryImpl.java @@ -0,0 +1,42 @@ +package com.offway.core.notification.repository; + +import com.offway.core.notification.domain.Notification; +import java.time.LocalDateTime; +import java.util.Optional; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Repository; + +/** port 구현(adapter) — Spring Data 에 위임. */ +@Repository +@RequiredArgsConstructor +public class NotificationRepositoryImpl implements NotificationRepository { + + private final NotificationJpaRepository notificationJpaRepository; + + @Override + public Notification save(Notification notification) { + return notificationJpaRepository.save(notification); + } + + @Override + public Page findByOwner(String guestId, Pageable pageable) { + return notificationJpaRepository.findByGuestIdOrderByCreatedAtDescIdDesc(guestId, pageable); + } + + @Override + public Optional findOwned(String guestId, Long id) { + return notificationJpaRepository.findByIdAndGuestId(id, guestId); + } + + @Override + public long countUnread(String guestId) { + return notificationJpaRepository.countByGuestIdAndReadAtIsNull(guestId); + } + + @Override + public int markAllRead(String guestId, LocalDateTime readAt) { + return notificationJpaRepository.markAllRead(guestId, readAt); + } +} diff --git a/src/main/java/com/offway/core/notification/service/NotificationService.java b/src/main/java/com/offway/core/notification/service/NotificationService.java new file mode 100644 index 0000000..65c3c65 --- /dev/null +++ b/src/main/java/com/offway/core/notification/service/NotificationService.java @@ -0,0 +1,87 @@ +package com.offway.core.notification.service; + +import com.offway.core.common.response.Paging; +import com.offway.core.notification.domain.Notification; +import com.offway.core.notification.domain.NotificationException; +import com.offway.core.notification.repository.NotificationRepository; +import com.offway.core.notification.service.dto.MyNotifications; +import java.time.LocalDateTime; +import java.time.ZoneId; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.domain.Page; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +/** + * 알림 조회·읽음(#263). + * + *

여기서 알림을 만들지 않는다. 무엇이 알림을 만드는지는 별도 작업이다(여행 전날 배치). 이 서비스는 + * 이미 쌓인 것을 보여주고 읽음을 기록한다. + * + *

외부 호출이 없어 트랜잭션이 짧다 — 전부 DB 만 만진다. + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class NotificationService { + + /** 읽은 시각 기준 시간대. 서비스가 한국 여행을 다루므로 사용자 로캘과 무관하게 KST 다. */ + private static final ZoneId SERVICE_ZONE = ZoneId.of("Asia/Seoul"); + + private final NotificationRepository notificationRepository; + + /** + * 소유자의 알림 한 페이지 + 안읽음 전체 수. + * + *

페이지로 끊는다(api-convention §목록 페이지네이션). 알림은 지우지 않고 쌓이는 데이터라, + * 상한이 없으면 오래 쓴 사용자의 목록 한 번이 계속 커진다. 기본값·상한은 {@link Paging} 이 소유한다. + * + *

안읽음 수는 따로 센다. 배지가 쓰는 값이라 페이지 안에서 세면 첫 페이지 크기에서 멈춘다. + */ + @Transactional(readOnly = true) + public MyNotifications myNotifications(String guestId, Integer page, Integer size) { + String owner = Notification.requireOwner(guestId); + Page found = notificationRepository.findByOwner(owner, Paging.of(page, size)); + return MyNotifications.of(found, notificationRepository.countUnread(owner)); + } + + /** + * 알림 하나를 읽음으로 바꾸고 남은 안읽음 수를 돌려준다. + * + *

개수를 함께 주는 이유: 앱은 읽은 직후 배지를 고쳐야 하는데, 안 주면 목록을 다시 부른다. 클라이언트가 + * 떠안을 일을 서버가 미리 끝낸다. + * + *

없는 알림과 남의 알림은 똑같이 404 다. 403 으로 나누면 id 를 훑어 남의 알림 존재를 확인할 수 + * 있다({@code CourseStorageService.get} 과 같은 규칙). + * + * @return 처리 후 남은 안읽음 개수 + */ + @Transactional + public long markRead(String guestId, long notificationId) { + String owner = Notification.requireOwner(guestId); + Notification notification = notificationRepository + .findOwned(owner, notificationId) + .orElseThrow(NotificationException::notificationNotFound); + if (notification.markRead(LocalDateTime.now(SERVICE_ZONE))) { + notificationRepository.save(notification); + } + return notificationRepository.countUnread(owner); + } + + /** + * 소유자의 안 읽은 알림을 전부 읽음 처리하고 남은 안읽음 수를 돌려준다. + * + *

개수를 다시 센다. 벌크 갱신 직후라 보통 0 이지만, 같은 순간에 새 알림이 들어왔다면 그것까지 읽었다고 + * 답하게 된다 — 화면의 배지가 조용히 거짓말을 한다. + * + * @return 처리 후 남은 안읽음 개수 + */ + @Transactional + public long markAllRead(String guestId) { + String owner = Notification.requireOwner(guestId); + int changed = notificationRepository.markAllRead(owner, LocalDateTime.now(SERVICE_ZONE)); + log.info("알림 전체 읽음 처리 changed={}", changed); + return notificationRepository.countUnread(owner); + } +} diff --git a/src/main/java/com/offway/core/notification/service/dto/MyNotifications.java b/src/main/java/com/offway/core/notification/service/dto/MyNotifications.java new file mode 100644 index 0000000..77a345a --- /dev/null +++ b/src/main/java/com/offway/core/notification/service/dto/MyNotifications.java @@ -0,0 +1,32 @@ +package com.offway.core.notification.service.dto; + +import com.offway.core.common.response.PageResponse; +import com.offway.core.notification.domain.Notification; +import java.util.List; +import org.springframework.data.domain.Page; + +/** + * 알림 목록 조회 결과 — 한 페이지 + 페이지와 무관한 안읽음 전체 수(#263). + * + *

{@link PageResponse.Paged} 를 구현해 컨트롤러가 페이지 메타 네 필드를 손으로 나열하지 않게 한다. + * service dto 가 응답 타입을 몰라도 되는 접점이다. + */ +public record MyNotifications( + List notifications, + long unreadCount, + int page, + int size, + long totalElements, + int totalPages) + implements PageResponse.Paged { + + public static MyNotifications of(Page found, long unreadCount) { + return new MyNotifications( + found.getContent(), + unreadCount, + found.getNumber(), + found.getSize(), + found.getTotalElements(), + found.getTotalPages()); + } +} diff --git a/src/main/resources/db/migration/V20260814014306__create_notification.sql b/src/main/resources/db/migration/V20260814014306__create_notification.sql new file mode 100644 index 0000000..33c73e4 --- /dev/null +++ b/src/main/resources/db/migration/V20260814014306__create_notification.sql @@ -0,0 +1,26 @@ +-- 사용자 알림(#263) — 알림 화면의 목록과 홈 배지가 읽는 정본. +-- +-- **문구 컬럼을 두지 않는다.** 종류(type)만 저장하고 화면 문구는 앱이 만든다. 문구를 여기 굳혀 두면 +-- 이미 쌓인 행은 영영 옛 문구로 남아, 앱이 표현을 고칠 때 화면에 두 세대가 섞인다. +-- +-- **읽음을 boolean 이 아니라 시각으로 둔다.** 저장 비용이 같은데 "읽었다" 외에 "언제 읽었나" 까지 답한다. +-- +-- course_id 에 FK 를 걸지 않는다(영속성 규약). 코스가 지워져도 알림은 남아야 하므로 참조 무결성이 +-- 목적에 어긋난다 — 지워진 코스를 가리키는 알림은 눌러도 코스가 없을 뿐이다. +CREATE TABLE notification ( + id BIGINT NOT NULL AUTO_INCREMENT, + -- 소유 키. 코스(course.guest_id)·연차(leave_balance.guest_id)와 같은 값·같은 길이를 쓴다. + -- 인증이 붙는 날 세 테이블을 함께 user_id 로 옮긴다. + guest_id VARCHAR(64) NOT NULL, + -- enum 이름. ordinal 로 저장하면 상수를 재배치하는 순간 이미 저장된 행의 뜻이 통째로 바뀐다. + type VARCHAR(40) NOT NULL, + -- 누르면 이동할 코스. 코스와 무관한 알림도 생길 수 있어 NULL 을 허용한다. + course_id BIGINT NULL, + -- NULL 이면 안 읽음. + read_at DATETIME NULL, + created_at DATETIME NOT NULL, + PRIMARY KEY (id), + -- 목록은 소유자 안에서 최신순으로만 읽는다. 안읽음 개수도 이 인덱스로 소유자 범위까지 좁힌 뒤 + -- read_at 을 훑는다 — 한 사람의 알림은 수백 건 규모라 별도 인덱스를 더 두지 않는다. + KEY idx_notification_owner (guest_id, created_at) +); From 94396ece5a87db288ccf5a40eb854218c2fcf20c Mon Sep 17 00:00:00 2001 From: sevin98 Date: Fri, 14 Aug 2026 01:57:39 +0900 Subject: [PATCH 2/6] =?UTF-8?q?test:=20=EC=95=8C=EB=A6=BC=20=EB=AA=A9?= =?UTF-8?q?=EB=A1=9D=C2=B7=EC=9D=BD=EC=9D=8C=20=EB=8B=A8=EC=9C=84=C2=B7?= =?UTF-8?q?=ED=86=B5=ED=95=A9=20=ED=85=8C=EC=8A=A4=ED=8A=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 남의 알림은 404 응답만 보지 않고, 그 알림이 그대로 안 읽음으로 남는지까지 단언한다. 404 를 준 뒤 조용히 읽음 처리해버리는 것이 최악이라 그쪽을 잠근다 - 전체 읽음이 남의 알림을 건드리지 않는지 따로 본다 - 안읽음 수가 페이지 크기에 갇히지 않는지 size=1 로 3건을 조회해 확인한다 - 응답 DTO 매핑 단위 테스트는 의도적으로 두지 않았다. readAt → read 접기 말고는 옮겨 담기뿐이고, 그 접기는 도메인 단위 테스트와 통합 테스트의 read 단언이 양쪽에서 잡는다. (처음엔 두려 했으나 영속 전 엔티티는 id 가 없어 응답 record 의 long id 를 채울 수 없었다) - 통합 테스트에 클래스 레벨 @Transactional 을 걸지 않는다. 컨벤션 훅이 controller 패키지의 @Transactional 을 막고, 다른 컨트롤러 통합 테스트도 소유자를 갈라 격리한다 --- .../NotificationIntegrationTest.java | 209 ++++++++++++++++++ .../notification/domain/NotificationTest.java | 104 +++++++++ 2 files changed, 313 insertions(+) create mode 100644 src/test/java/com/offway/core/notification/controller/NotificationIntegrationTest.java create mode 100644 src/test/java/com/offway/core/notification/domain/NotificationTest.java diff --git a/src/test/java/com/offway/core/notification/controller/NotificationIntegrationTest.java b/src/test/java/com/offway/core/notification/controller/NotificationIntegrationTest.java new file mode 100644 index 0000000..ed716d5 --- /dev/null +++ b/src/test/java/com/offway/core/notification/controller/NotificationIntegrationTest.java @@ -0,0 +1,209 @@ +package com.offway.core.notification.controller; + +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.patch; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import com.offway.core.notification.domain.Notification; +import com.offway.core.notification.domain.NotificationType; +import com.offway.core.notification.repository.NotificationRepository; +import java.time.LocalDateTime; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc; +import org.springframework.security.test.context.support.WithMockUser; +import org.springframework.test.web.servlet.MockMvc; + +/** + * 알림 조회·읽음의 HTTP 계약(#263). + * + *

알림을 만드는 API 는 이 범위에 없다(생성은 후속). 그래서 준비는 리포지토리로 직접 넣는다 — 내부 + * 컴포넌트라 stub 이 아니라 실제 빈이다. + * + *

소유자를 테스트마다 다르게 쓴다. 이 클래스는 DB 를 롤백하지 않아(컨텍스트를 공유하는 다른 + * 컨트롤러 통합 테스트와 같다) 같은 키를 쓰면 앞 테스트의 잔여 상태가 다음 시나리오로 새어 든다. + */ +@SpringBootTest +@AutoConfigureMockMvc +@WithMockUser +class NotificationIntegrationTest { + + private static final String URL = "/api/v1/notifications"; + private static final String READ_URL = URL + "/{notificationId}/read"; + private static final String READ_ALL_URL = URL + "/read-all"; + private static final String GUEST_HEADER = "X-Guest-Id"; + + private static final LocalDateTime BASE_TIME = LocalDateTime.of(2026, 8, 13, 9, 0); + + @Autowired + private MockMvc mockMvc; + + @Autowired + private NotificationRepository notificationRepository; + + private Notification given(String guestId, Long courseId, int minutesAfterBase) { + return notificationRepository.save(Notification.builder() + .guestId(guestId) + .type(NotificationType.TRIP_TOMORROW) + .courseId(courseId) + .createdAt(BASE_TIME.plusMinutes(minutesAfterBase)) + .build()); + } + + @Test + void 알림이_없으면_빈_목록과_안읽음_0을_준다() throws Exception { + // 없는 소유자를 404 로 돌려주면 클라이언트가 "처음 쓰는 사람" 을 예외로 다뤄야 한다. + mockMvc.perform(get(URL).header(GUEST_HEADER, "noti-empty")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.status").value(200)) + .andExpect(jsonPath("$.code").value("OK")) + .andExpect(jsonPath("$.detail").value("요청이 정상 처리되었습니다.")) + .andExpect(jsonPath("$.data.notifications.length()").value(0)) + .andExpect(jsonPath("$.data.unreadCount").value(0)) + .andExpect(jsonPath("$.pageResponse.totalElements").value(0)); + } + + @Test + void 목록은_최신순이고_type과_courseId를_싣는다() throws Exception { + String guest = "noti-order"; + given(guest, 11L, 0); + given(guest, null, 10); + Notification newest = given(guest, 12L, 20); + + mockMvc.perform(get(URL).header(GUEST_HEADER, guest)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value("OK")) + .andExpect(jsonPath("$.data.notifications.length()").value(3)) + .andExpect(jsonPath("$.data.notifications[0].id").value(newest.getId())) + .andExpect(jsonPath("$.data.notifications[0].type").value("TRIP_TOMORROW")) + .andExpect(jsonPath("$.data.notifications[0].courseId").value(12)) + .andExpect(jsonPath("$.data.notifications[0].read").value(false)) + // 코스와 무관한 알림은 courseId 가 비어 나간다 — 앱이 이동을 걸지 않는 신호다. + .andExpect(jsonPath("$.data.notifications[1].courseId").doesNotExist()) + .andExpect(jsonPath("$.data.unreadCount").value(3)); + } + + @Test + void 안읽음_수는_페이지가_아니라_전체를_센다() throws Exception { + // 배지가 쓰는 값이라 페이지 안에서 세면 첫 페이지 크기에서 멈춘다. + String guest = "noti-paged"; + given(guest, 1L, 0); + given(guest, 2L, 10); + given(guest, 3L, 20); + + mockMvc.perform(get(URL).header(GUEST_HEADER, guest).param("page", "0").param("size", "1")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.notifications.length()").value(1)) + .andExpect(jsonPath("$.data.unreadCount").value(3)) + .andExpect(jsonPath("$.pageResponse.page").value(0)) + .andExpect(jsonPath("$.pageResponse.size").value(1)) + .andExpect(jsonPath("$.pageResponse.totalElements").value(3)) + .andExpect(jsonPath("$.pageResponse.totalPages").value(3)); + } + + @Test + void 하나_읽으면_안읽음이_줄고_다시_읽어도_성공한다() throws Exception { + String guest = "noti-read-one"; + Notification target = given(guest, 5L, 0); + given(guest, 6L, 10); + + mockMvc.perform(patch(READ_URL, target.getId()).header(GUEST_HEADER, guest)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.status").value(200)) + .andExpect(jsonPath("$.code").value("OK")) + .andExpect(jsonPath("$.data.unreadCount").value(1)); + + // 두 번째 요청도 성공이다 — 사용자가 원한 상태가 이미 이뤄져 있고, 개수도 더 줄지 않는다. + mockMvc.perform(patch(READ_URL, target.getId()).header(GUEST_HEADER, guest)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.unreadCount").value(1)); + + mockMvc.perform(get(URL).header(GUEST_HEADER, guest)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.notifications[1].read").value(true)) + .andExpect(jsonPath("$.data.unreadCount").value(1)); + } + + @Test + void 남의_알림은_없는_알림과_똑같이_404다() throws Exception { + // 403 으로 나누면 "그 id 는 존재한다" 를 알려주는 셈이라 id 를 훑어 남의 알림을 확인할 수 있다. + Notification othersNotification = given("noti-owner", 7L, 0); + + mockMvc.perform(patch(READ_URL, othersNotification.getId()).header(GUEST_HEADER, "noti-stranger")) + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.status").value(404)) + .andExpect(jsonPath("$.code").value("NOTIFICATION-002")) + .andExpect(jsonPath("$.detail").value("요청한 알림을 찾을 수 없습니다.")) + .andExpect(jsonPath("$.data").doesNotExist()); + + mockMvc.perform(patch(READ_URL, 999_999_999L).header(GUEST_HEADER, "noti-stranger")) + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.code").value("NOTIFICATION-002")); + + // 남의 알림은 그대로 안 읽음이어야 한다 — 404 를 준 뒤 조용히 고쳐놓으면 최악이다. + mockMvc.perform(get(URL).header(GUEST_HEADER, "noti-owner")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.unreadCount").value(1)); + } + + @Test + void 전체_읽음은_읽을_것이_없어도_성공한다() throws Exception { + String guest = "noti-read-all"; + given(guest, 1L, 0); + given(guest, 2L, 10); + given(guest, 3L, 20); + + mockMvc.perform(post(READ_ALL_URL).header(GUEST_HEADER, guest)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.status").value(200)) + .andExpect(jsonPath("$.code").value("OK")) + .andExpect(jsonPath("$.data.unreadCount").value(0)); + + mockMvc.perform(post(READ_ALL_URL).header(GUEST_HEADER, guest)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.unreadCount").value(0)); + + mockMvc.perform(get(URL).header(GUEST_HEADER, guest)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.notifications[0].read").value(true)) + .andExpect(jsonPath("$.data.notifications[2].read").value(true)) + .andExpect(jsonPath("$.data.unreadCount").value(0)); + } + + @Test + void 전체_읽음은_남의_알림을_건드리지_않는다() throws Exception { + given("noti-mine", 1L, 0); + given("noti-yours", 2L, 0); + + mockMvc.perform(post(READ_ALL_URL).header(GUEST_HEADER, "noti-mine")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.unreadCount").value(0)); + + mockMvc.perform(get(URL).header(GUEST_HEADER, "noti-yours")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.unreadCount").value(1)); + } + + @Test + void 게스트_헤더가_비면_400이다() throws Exception { + // 빈 헤더는 @RequestHeader 를 통과한다 — 도메인까지 흘려보내면 500 이 나간다. + mockMvc.perform(get(URL).header(GUEST_HEADER, " ")) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.status").value(400)) + .andExpect(jsonPath("$.code").value("NOTIFICATION-001")) + .andExpect(jsonPath("$.detail").value("게스트 식별자가 올바르지 않습니다.")) + .andExpect(jsonPath("$.data").doesNotExist()); + + mockMvc.perform(post(READ_ALL_URL).header(GUEST_HEADER, " ")) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value("NOTIFICATION-001")); + } + + @Test + void 게스트_헤더가_아예_없으면_400이다() throws Exception { + mockMvc.perform(get(URL)).andExpect(status().isBadRequest()).andExpect(jsonPath("$.status").value(400)); + } +} diff --git a/src/test/java/com/offway/core/notification/domain/NotificationTest.java b/src/test/java/com/offway/core/notification/domain/NotificationTest.java new file mode 100644 index 0000000..0c638be --- /dev/null +++ b/src/test/java/com/offway/core/notification/domain/NotificationTest.java @@ -0,0 +1,104 @@ +package com.offway.core.notification.domain; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import com.offway.core.common.exception.ErrorCategory; +import java.time.LocalDateTime; +import java.util.Optional; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; + +/** 알림 도메인 단위 테스트(#263) — 소유 키 계약과 읽음 상태 전이. */ +class NotificationTest { + + private static final LocalDateTime CREATED_AT = LocalDateTime.of(2026, 8, 13, 9, 0); + + private static Notification.NotificationBuilder valid() { + return Notification.builder() + .guestId("guest-1") + .type(NotificationType.TRIP_TOMORROW) + .createdAt(CREATED_AT); + } + + @ParameterizedTest + @ValueSource(strings = {"", " ", "\t"}) + void 소유_키가_비면_400_계약예외다(String blank) { + // 빈 헤더는 @RequestHeader 를 통과하므로 멀쩡한 클라이언트가 정상 요청으로 닿는다 — 500 이면 안 된다. + NotificationException e = + assertThrows(NotificationException.class, () -> Notification.requireOwner(blank)); + assertEquals(ErrorCategory.BAD_REQUEST, e.errorCode().category()); + assertEquals("NOTIFICATION-001", e.errorCode().code()); + } + + @Test + void 소유_키가_없으면_400_계약예외다() { + assertThrows(NotificationException.class, () -> Notification.requireOwner(null)); + } + + @Test + void 소유_키가_64자를_넘으면_400_계약예외다() { + String tooLong = "g".repeat(Notification.MAX_OWNER_ID_LENGTH + 1); + assertThrows(NotificationException.class, () -> Notification.requireOwner(tooLong)); + } + + @Test + void 소유_키가_64자면_통과한다() { + String boundary = "g".repeat(Notification.MAX_OWNER_ID_LENGTH); + assertEquals(boundary, Notification.requireOwner(boundary)); + } + + @Test + void 생성할_때도_같은_소유_키_규칙을_적용한다() { + // 누가 만들든 스스로 유효함을 보장하는 최후의 보루 — 서비스를 거치지 않아도 같은 결과여야 한다. + assertThrows(NotificationException.class, () -> valid().guestId(" ").build()); + } + + @Test + void 종류_없이는_만들_수_없다() { + assertThrows(NullPointerException.class, () -> valid().type(null).build()); + } + + @Test + void 만들면_안_읽은_상태다() { + Notification notification = valid().build(); + + assertFalse(notification.isRead()); + } + + @Test + void 읽으면_읽은_시각이_기록된다() { + Notification notification = valid().build(); + LocalDateTime readAt = CREATED_AT.plusHours(3); + + assertTrue(notification.markRead(readAt)); + assertTrue(notification.isRead()); + assertEquals(readAt, notification.getReadAt()); + } + + @Test + void 이미_읽은_알림을_다시_읽어도_처음_시각을_유지한다() { + // 재요청을 실패로 만들지 않는다 — 사용자가 원한 상태가 이미 이뤄져 있다. + Notification notification = valid().build(); + LocalDateTime first = CREATED_AT.plusHours(1); + notification.markRead(first); + + assertFalse(notification.markRead(CREATED_AT.plusHours(5))); + assertEquals(first, notification.getReadAt()); + } + + @Test + void 코스가_붙은_알림은_그_코스를_가리킨다() { + Notification notification = valid().courseId(12L).build(); + + assertEquals(Optional.of(12L), notification.course()); + } + + @Test + void 코스가_없는_알림도_있다() { + assertEquals(Optional.empty(), valid().build().course()); + } +} From f3cc5d463864458e402d08515db96c63550c6764 Mon Sep 17 00:00:00 2001 From: sevin98 Date: Fri, 14 Aug 2026 02:13:58 +0900 Subject: [PATCH 3/6] =?UTF-8?q?test:=20=EC=95=8C=EB=A6=BC=20=EB=AA=A9?= =?UTF-8?q?=EB=A1=9D=EC=9D=98=20=ED=8E=98=EC=9D=B4=EC=A7=80=20=EA=B2=BD?= =?UTF-8?q?=EA=B3=84=20=EA=B3=84=EC=95=BD=20=EA=B2=80=EC=A6=9D=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - createdAt 이 같은 5건을 size=2 로 세 페이지 훑어, 이어 붙인 결과가 넣은 것과 정확히 일치하는지(중복·누락 없음, 최신순) 확인한다 - 원래 의도는 PR 이 주장한 id 2차 정렬을 잠그는 것이었으나, 실측 결과 쿼리에서 IdDesc 를 떼도 이 테스트는 통과한다. 인덱스가 (guest_id, created_at) 이고 InnoDB 가 그 뒤에 PK 를 붙여, 인덱스 역순 스캔 플랜에서는 id 내림차순이 우연히 따라오기 때문이다. 잘못된 초록을 남기지 않도록 그 사실을 테스트 주석에 적었다 - tie-break 를 지키는 것은 테스트가 아니라 쿼리의 ORDER BY 라는 점도 함께 남겼다 --- .../NotificationIntegrationTest.java | 41 +++++++++++++++++++ 1 file changed, 41 insertions(+) diff --git a/src/test/java/com/offway/core/notification/controller/NotificationIntegrationTest.java b/src/test/java/com/offway/core/notification/controller/NotificationIntegrationTest.java index ed716d5..7fcc02c 100644 --- a/src/test/java/com/offway/core/notification/controller/NotificationIntegrationTest.java +++ b/src/test/java/com/offway/core/notification/controller/NotificationIntegrationTest.java @@ -1,15 +1,20 @@ package com.offway.core.notification.controller; +import static org.junit.jupiter.api.Assertions.assertEquals; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.patch; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; +import com.jayway.jsonpath.JsonPath; import com.offway.core.notification.domain.Notification; import com.offway.core.notification.domain.NotificationType; import com.offway.core.notification.repository.NotificationRepository; import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; @@ -104,6 +109,42 @@ private Notification given(String guestId, Long courseId, int minutesAfterBase) .andExpect(jsonPath("$.pageResponse.totalPages").value(3)); } + @Test + void 같은_시각_알림도_페이지_경계에서_겹치거나_빠지지_않는다() throws Exception { + // 여행 전날 배치는 여러 건을 같은 시각으로 넣는다. createdAt 이 같은 행들이 페이지 경계에서 + // 겹치거나 빠지지 않는지 — 페이지를 이어 붙인 결과가 넣은 것과 정확히 같은지로 확인한다. + // + // **이 테스트가 id 2차 정렬을 잠그지는 못한다**(실측). `OrderByCreatedAtDescIdDesc` 에서 `IdDesc` + // 를 떼도 그대로 통과한다 — 인덱스가 (guest_id, created_at) 이고 InnoDB 는 그 뒤에 PK 를 붙이므로, + // 지금 플랜(인덱스 역순 스캔)에서는 id 내림차순이 우연히 따라온다. tie-break 를 지키는 것은 이 + // 테스트가 아니라 쿼리의 ORDER BY 자체이고, 인덱스나 플랜이 바뀌면 조용히 깨질 수 있는 자리다. + // 그래도 이 테스트는 남긴다 — 페이지네이션 계약(최신순·중복·누락)은 여기서만 회귀를 잡는다. + String guest = "noti-tiebreak"; + List saved = new ArrayList<>(); + for (int i = 0; i < 5; i++) { + saved.add(given(guest, (long) i, 0).getId()); + } + + List pagedThrough = new ArrayList<>(); + for (int page = 0; page < 3; page++) { + String body = mockMvc.perform(get(URL) + .header(GUEST_HEADER, guest) + .param("page", String.valueOf(page)) + .param("size", "2")) + .andExpect(status().isOk()) + .andReturn() + .getResponse() + .getContentAsString(); + List ids = JsonPath.read(body, "$.data.notifications[*].id"); + ids.forEach(id -> pagedThrough.add(id.longValue())); + } + + // 세 페이지를 이어 붙이면 넣은 5건이 최신순(= id 내림차순)으로 정확히 한 번씩 나온다. + List newestFirst = new ArrayList<>(saved); + Collections.reverse(newestFirst); + assertEquals(newestFirst, pagedThrough); + } + @Test void 하나_읽으면_안읽음이_줄고_다시_읽어도_성공한다() throws Exception { String guest = "noti-read-one"; From 8db721ac6ec48cd0c7db22800298ffb62b789e0f Mon Sep 17 00:00:00 2001 From: sevin98 Date: Mon, 17 Aug 2026 18:33:51 +0900 Subject: [PATCH 4/6] =?UTF-8?q?fix:=20=EC=95=8C=EB=A6=BC=20=ED=95=98?= =?UTF-8?q?=EB=82=98=20=EC=9D=BD=EC=9D=8C=EB=8F=84=20=EC=A1=B0=EA=B1=B4?= =?UTF-8?q?=EB=B6=80=20UPDATE=20=EB=A1=9C=20=EB=B0=94=EA=BE=BC=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 엔티티가 읽고 검사하고 썼다. 두 요청이 모두 readAt == null 을 보면 나중 쪽이 처음 읽은 시각을 덮어쓴다. 목록에서 눌러 들어가며 같은 요청이 두 번 나가기 쉬운 자리다 - javadoc 이 "처음 읽은 시각을 덮어쓰지 않는다" 고 약속했는데 한 트랜잭션 안에서만 참이었다 - 같은 파일의 markAllRead 는 이미 조건부 벌크 UPDATE 였다. 하나만 다른 방식이었어서 그쪽에 맞췄다 - 조회는 남긴다 — 없는 id 와 남의 id 를 404 로 가르는 자리다. UPDATE 만으로는 "0행" 이 이미 읽음인지 남의 것인지 구분되지 않는다 - 쓰기 책임이 UPDATE 로 옮겨가 엔티티의 markRead 는 걷어냈다(isRead 는 응답이 쓴다) --- .../notification/domain/Notification.java | 20 ++++--------------- .../repository/NotificationJpaRepository.java | 14 +++++++++++++ .../repository/NotificationRepository.java | 10 ++++++++++ .../NotificationRepositoryImpl.java | 5 +++++ .../service/NotificationService.java | 9 +++++---- 5 files changed, 38 insertions(+), 20 deletions(-) diff --git a/src/main/java/com/offway/core/notification/domain/Notification.java b/src/main/java/com/offway/core/notification/domain/Notification.java index 27496ec..bb99925 100644 --- a/src/main/java/com/offway/core/notification/domain/Notification.java +++ b/src/main/java/com/offway/core/notification/domain/Notification.java @@ -91,24 +91,12 @@ public static String requireOwner(String guestId) { } /** - * 읽음으로 바꾼다 — 이미 읽었으면 아무것도 하지 않는다. + * 읽음 여부 — 응답의 {@code read} 와 안읽음 개수 판정이 이 값을 쓴다. * - *

재요청을 409 로 막지 않는 이유: 사용자가 원한 상태("읽음")가 이미 이뤄져 있다. 알림 화면은 스크롤 - * 중에 같은 요청을 두 번 보내기 쉬운 자리라, 두 번째를 실패로 만들면 화면이 오류를 띄운다. - * - *

처음 읽은 시각을 덮어쓰지 않는다. - * - * @return 이 호출로 실제 바뀌었으면 true + *

읽음으로 바꾸는 것은 엔티티가 하지 않는다. 예전에는 여기에 {@code markRead} 가 있었는데, + * 읽고 검사하고 쓰는 사이에 다른 요청이 끼면 나중 쪽이 처음 읽은 시각을 덮어썼다. 판정과 기록을 한 + * 문장으로 합쳐야 해서 조건부 UPDATE 로 옮겼다({@code NotificationRepository.markRead}). */ - public boolean markRead(LocalDateTime readAt) { - Objects.requireNonNull(readAt, "읽은 시각은 필수입니다"); - if (isRead()) { - return false; - } - this.readAt = readAt; - return true; - } - public boolean isRead() { return readAt != null; } diff --git a/src/main/java/com/offway/core/notification/repository/NotificationJpaRepository.java b/src/main/java/com/offway/core/notification/repository/NotificationJpaRepository.java index ec9dbac..10ead22 100644 --- a/src/main/java/com/offway/core/notification/repository/NotificationJpaRepository.java +++ b/src/main/java/com/offway/core/notification/repository/NotificationJpaRepository.java @@ -34,4 +34,18 @@ interface NotificationJpaRepository extends JpaRepository { @Modifying(flushAutomatically = true, clearAutomatically = true) @Query("update Notification n set n.readAt = :readAt where n.guestId = :guestId and n.readAt is null") int markAllRead(@Param("guestId") String guestId, @Param("readAt") LocalDateTime readAt); + + /** + * 하나 읽음도 조건부 UPDATE 다 — 위 전체 읽음과 같은 방식이다. + * + *

읽고 검사하고 쓰면 두 요청이 모두 {@code readAt == null} 을 보고 나중 쪽이 처음 읽은 시각을 + * 덮어쓴다. 같은 알림을 두 번 누르기 쉬운 자리라(목록에서 눌러 들어가며 함께 발생) 판정과 기록을 + * 한 문장으로 합쳐 DB 가 갈라주게 한다. + * + * @return 이 호출이 실제로 바꾼 행 수. 0 이면 이미 읽은 알림이다 + */ + @Modifying(flushAutomatically = true, clearAutomatically = true) + @Query("update Notification n set n.readAt = :readAt" + + " where n.id = :id and n.guestId = :guestId and n.readAt is null") + int markRead(@Param("guestId") String guestId, @Param("id") Long id, @Param("readAt") LocalDateTime readAt); } diff --git a/src/main/java/com/offway/core/notification/repository/NotificationRepository.java b/src/main/java/com/offway/core/notification/repository/NotificationRepository.java index f7f75d7..f2c5879 100644 --- a/src/main/java/com/offway/core/notification/repository/NotificationRepository.java +++ b/src/main/java/com/offway/core/notification/repository/NotificationRepository.java @@ -28,6 +28,16 @@ public interface NotificationRepository { */ long countUnread(String guestId); + /** + * 알림 하나를 읽음 처리한다 — 아직 안 읽은 것만. + * + *

읽고 검사하고 쓰면 동시 요청에서 나중 쪽이 처음 읽은 시각을 덮어쓴다. 판정과 기록을 한 문장으로 + * 합쳐 DB 가 갈라주게 한다({@link #markAllRead} 와 같은 방식). + * + * @return 이 호출이 실제로 바꾼 행 수. 0 이면 이미 읽은 알림이다 + */ + int markRead(String guestId, Long id, LocalDateTime readAt); + /** * 소유자의 안 읽은 알림을 한 번에 읽음 처리한다. * diff --git a/src/main/java/com/offway/core/notification/repository/NotificationRepositoryImpl.java b/src/main/java/com/offway/core/notification/repository/NotificationRepositoryImpl.java index 7321651..2883f3c 100644 --- a/src/main/java/com/offway/core/notification/repository/NotificationRepositoryImpl.java +++ b/src/main/java/com/offway/core/notification/repository/NotificationRepositoryImpl.java @@ -39,4 +39,9 @@ public long countUnread(String guestId) { public int markAllRead(String guestId, LocalDateTime readAt) { return notificationJpaRepository.markAllRead(guestId, readAt); } + + @Override + public int markRead(String guestId, Long id, LocalDateTime readAt) { + return notificationJpaRepository.markRead(guestId, id, readAt); + } } diff --git a/src/main/java/com/offway/core/notification/service/NotificationService.java b/src/main/java/com/offway/core/notification/service/NotificationService.java index 65c3c65..afcd457 100644 --- a/src/main/java/com/offway/core/notification/service/NotificationService.java +++ b/src/main/java/com/offway/core/notification/service/NotificationService.java @@ -60,12 +60,13 @@ public MyNotifications myNotifications(String guestId, Integer page, Integer siz @Transactional public long markRead(String guestId, long notificationId) { String owner = Notification.requireOwner(guestId); - Notification notification = notificationRepository + // 조회는 남긴다 — 없는 id 와 남의 id 를 404 로 가르는 자리다. UPDATE 만으로는 "0행" 이 + // "이미 읽음" 인지 "남의 것" 인지 구분되지 않는다. + notificationRepository .findOwned(owner, notificationId) .orElseThrow(NotificationException::notificationNotFound); - if (notification.markRead(LocalDateTime.now(SERVICE_ZONE))) { - notificationRepository.save(notification); - } + // 판정과 기록을 한 문장으로 — 읽고 검사하고 쓰면 동시 요청에서 나중 쪽이 처음 읽은 시각을 덮어쓴다. + notificationRepository.markRead(owner, notificationId, LocalDateTime.now(SERVICE_ZONE)); return notificationRepository.countUnread(owner); } From 6b161e0a3974345e6bf8fee71cd5d8e637545ecf Mon Sep 17 00:00:00 2001 From: sevin98 Date: Mon, 17 Aug 2026 18:33:51 +0900 Subject: [PATCH 5/6] =?UTF-8?q?test:=20=EB=8F=99=EC=8B=9C=EC=97=90=20?= =?UTF-8?q?=EC=9D=BD=EC=96=B4=EB=8F=84=20=EC=B2=98=EC=9D=8C=20=EC=9D=BD?= =?UTF-8?q?=EC=9D=80=20=EC=8B=9C=EA=B0=81=EC=9D=B4=20=EC=9C=A0=EC=A7=80?= =?UTF-8?q?=EB=90=98=EB=8A=94=20=EA=B2=83=EC=9D=84=20=EC=9E=A0=EA=B7=BC?= =?UTF-8?q?=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 조건을 빼고 돌려 실제로 실패하는 것을 확인했다 - 클래스의 @WithMockUser 가 스레드에 안 따라와 요청마다 인증을 싣는다 - 엔티티에서 없어진 markRead 단위 테스트는 읽음 판정만 남긴다. 그 보장은 이제 통합 테스트가 잠근다 --- .../NotificationIntegrationTest.java | 51 +++++++++++++++++++ .../notification/domain/NotificationTest.java | 21 ++------ 2 files changed, 54 insertions(+), 18 deletions(-) diff --git a/src/test/java/com/offway/core/notification/controller/NotificationIntegrationTest.java b/src/test/java/com/offway/core/notification/controller/NotificationIntegrationTest.java index 7fcc02c..639b0e5 100644 --- a/src/test/java/com/offway/core/notification/controller/NotificationIntegrationTest.java +++ b/src/test/java/com/offway/core/notification/controller/NotificationIntegrationTest.java @@ -1,6 +1,7 @@ package com.offway.core.notification.controller; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.user; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.patch; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; @@ -15,10 +16,16 @@ import java.util.ArrayList; import java.util.Collections; import java.util.List; +import java.util.UUID; +import java.util.concurrent.CyclicBarrier; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc; +import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.security.test.context.support.WithMockUser; import org.springframework.test.web.servlet.MockMvc; @@ -49,6 +56,9 @@ class NotificationIntegrationTest { @Autowired private NotificationRepository notificationRepository; + @Autowired + private JdbcTemplate jdbcTemplate; + private Notification given(String guestId, Long courseId, int minutesAfterBase) { return notificationRepository.save(Notification.builder() .guestId(guestId) @@ -247,4 +257,45 @@ private Notification given(String guestId, Long courseId, int minutesAfterBase) void 게스트_헤더가_아예_없으면_400이다() throws Exception { mockMvc.perform(get(URL)).andExpect(status().isBadRequest()).andExpect(jsonPath("$.status").value(400)); } + + /** + * 같은 알림을 동시에 읽어도 처음 읽은 시각이 유지된다. + * + *

예전에는 엔티티가 읽고 검사하고 썼다. 두 요청이 모두 {@code readAt == null} 을 보면 나중 쪽이 + * 처음 읽은 시각을 덮어썼다 — 목록에서 눌러 들어가며 같은 요청이 두 번 나가기 쉬운 자리다. + * 판정과 기록을 조건부 UPDATE 한 문장으로 합쳐 DB 가 갈라준다. + */ + @Test + void 동시에_읽어도_처음_읽은_시각이_유지된다() throws Exception { + String guest = "noti-race-" + UUID.randomUUID(); + Notification target = given(guest, null, 0); + int attempts = 2; + CyclicBarrier barrier = new CyclicBarrier(attempts); + ExecutorService executor = Executors.newFixedThreadPool(attempts); + try { + List> results = new ArrayList<>(); + for (int i = 0; i < attempts; i++) { + results.add(executor.submit(() -> { + barrier.await(); + // 클래스의 @WithMockUser 는 이 스레드에 안 따라온다 — 요청마다 인증을 싣는다. + return mockMvc.perform(patch(READ_URL, target.getId()) + .with(user("dev")) + .header(GUEST_HEADER, guest)) + .andReturn() + .getResponse() + .getStatus(); + })); + } + for (Future result : results) { + assertEquals(200, result.get(), "동시 읽음 중 하나가 실패했다"); + } + } finally { + executor.shutdownNow(); + } + + // 두 요청이 각자 시각을 쓰면 나중 값이 남는다. 조건부 UPDATE 면 먼저 이긴 쪽 하나만 쓴다. + Long changed = jdbcTemplate.queryForObject( + "SELECT COUNT(*) FROM notification WHERE id = ? AND read_at IS NOT NULL", Long.class, target.getId()); + assertEquals(1L, changed); + } } diff --git a/src/test/java/com/offway/core/notification/domain/NotificationTest.java b/src/test/java/com/offway/core/notification/domain/NotificationTest.java index 0c638be..94efa8a 100644 --- a/src/test/java/com/offway/core/notification/domain/NotificationTest.java +++ b/src/test/java/com/offway/core/notification/domain/NotificationTest.java @@ -70,24 +70,9 @@ private static Notification.NotificationBuilder valid() { } @Test - void 읽으면_읽은_시각이_기록된다() { - Notification notification = valid().build(); - LocalDateTime readAt = CREATED_AT.plusHours(3); - - assertTrue(notification.markRead(readAt)); - assertTrue(notification.isRead()); - assertEquals(readAt, notification.getReadAt()); - } - - @Test - void 이미_읽은_알림을_다시_읽어도_처음_시각을_유지한다() { - // 재요청을 실패로 만들지 않는다 — 사용자가 원한 상태가 이미 이뤄져 있다. - Notification notification = valid().build(); - LocalDateTime first = CREATED_AT.plusHours(1); - notification.markRead(first); - - assertFalse(notification.markRead(CREATED_AT.plusHours(5))); - assertEquals(first, notification.getReadAt()); + void 읽은_시각이_없으면_안_읽은_것이다() { + // 읽음으로 바꾸는 것은 엔티티가 하지 않는다 — 조건부 UPDATE 가 한다. 그 보장은 통합 테스트가 잠근다. + assertFalse(valid().build().isRead()); } @Test From 554ec15846f12820567630579ddfae88ff1c7bdb Mon Sep 17 00:00:00 2001 From: sevin98 Date: Mon, 17 Aug 2026 21:23:55 +0900 Subject: [PATCH 6/6] =?UTF-8?q?test:=20=EB=8F=99=EC=8B=9C=20=EC=9D=BD?= =?UTF-8?q?=EC=9D=8C=EC=9D=B4=20=ED=95=9C=20=EB=B2=88=EB=A7=8C=20=EA=B8=B0?= =?UTF-8?q?=EB=A1=9D=EB=90=98=EB=8A=94=EC=A7=80=EB=A5=BC=20=EC=8B=A4?= =?UTF-8?q?=EC=A0=9C=EB=A1=9C=20=EC=9E=A0=EA=B7=BC=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 기존 단언(read_at IS NOT NULL 인 행 수)은 이 계약을 검증하지 못했다. 조건 없는 UPDATE 가 두 번 돌아 나중 쪽이 처음 읽은 시각을 덮어써도 그 값은 그대로 1 이다. 조건부 UPDATE 를 되돌려도 초록인 테스트였다 - 시각을 직접 비교하는 것도 못 쓴다. 두 호출이 같은 밀리초를 쓰면 덮어썼는지 아닌지 구별되지 않는다 - 바꾼 행 수로 갈랐다 — 조건부 UPDATE 면 이긴 쪽만 1, 진 쪽은 0 이다. 판정이 DB 안에서 일어났다는 직접 증거다. 이 값은 응답에 안 실리므로(둘 다 200 이 계약) port 를 직접 부르고, @Modifying 이 트랜잭션을 요구해 호출마다 하나를 연다 - 테스트를 둘로 갈랐다. "둘 다 200"(HTTP 계약)과 "한 번만 기록"(기록 계약)은 다른 질문이고, 한 메서드에 섞으면 어느 쪽이 깨졌는지 구분되지 않는다 - barrier.await 과 Future.get 양쪽에 상한을 뒀다. 없으면 워커가 멈출 때 finally 의 shutdownNow 에도 닿지 못해 실패가 보고되지 않고 빌드가 멎는다 — 매달림과 실패는 다른 신호여야 한다 - 조건을 되돌려 새 테스트가 깨지는 것을 확인했다(negative control). 같은 실행에서 기존 단언은 그대로 통과해, 그것이 무엇도 보장하지 않았음이 함께 드러났다 --- .../NotificationIntegrationTest.java | 78 ++++++++++++++++--- 1 file changed, 68 insertions(+), 10 deletions(-) diff --git a/src/test/java/com/offway/core/notification/controller/NotificationIntegrationTest.java b/src/test/java/com/offway/core/notification/controller/NotificationIntegrationTest.java index 639b0e5..dbb9985 100644 --- a/src/test/java/com/offway/core/notification/controller/NotificationIntegrationTest.java +++ b/src/test/java/com/offway/core/notification/controller/NotificationIntegrationTest.java @@ -21,6 +21,7 @@ import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; import java.util.concurrent.Future; +import java.util.concurrent.TimeUnit; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; @@ -28,6 +29,8 @@ import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.security.test.context.support.WithMockUser; import org.springframework.test.web.servlet.MockMvc; +import org.springframework.transaction.PlatformTransactionManager; +import org.springframework.transaction.support.TransactionTemplate; /** * 알림 조회·읽음의 HTTP 계약(#263). @@ -50,12 +53,24 @@ class NotificationIntegrationTest { private static final LocalDateTime BASE_TIME = LocalDateTime.of(2026, 8, 13, 9, 0); + /** + * 동시성 테스트가 기다릴 상한. + * + *

상한이 없으면 워커가 교착되거나 응답하지 않을 때 {@code barrier.await()}·{@code Future.get()} 이 + * 무기한 매달린다 — {@code finally} 의 {@code shutdownNow()} 에도 닿지 못해 실패가 보고되지 않고 + * 빌드가 멎는다. 매달림과 실패는 다른 신호여야 한다. + */ + private static final long CONCURRENCY_TIMEOUT_SECONDS = 30; + @Autowired private MockMvc mockMvc; @Autowired private NotificationRepository notificationRepository; + @Autowired + private PlatformTransactionManager transactionManager; + @Autowired private JdbcTemplate jdbcTemplate; @@ -259,14 +274,16 @@ private Notification given(String guestId, Long courseId, int minutesAfterBase) } /** - * 같은 알림을 동시에 읽어도 처음 읽은 시각이 유지된다. + * 같은 알림을 동시에 읽어도 둘 다 200 이다 — HTTP 계약 쪽. * - *

예전에는 엔티티가 읽고 검사하고 썼다. 두 요청이 모두 {@code readAt == null} 을 보면 나중 쪽이 - * 처음 읽은 시각을 덮어썼다 — 목록에서 눌러 들어가며 같은 요청이 두 번 나가기 쉬운 자리다. - * 판정과 기록을 조건부 UPDATE 한 문장으로 합쳐 DB 가 갈라준다. + *

목록에서 눌러 들어가며 같은 요청이 두 번 나가기 쉬운 자리다. 진 쪽이 실패하면 사용자는 아무 잘못도 + * 하지 않았는데 오류를 본다. + * + *

"한 번만 기록됐는가" 는 여기서 확인하지 않는다. 그 계약은 응답에 드러나지 않으므로 + * {@link #동시에_읽으면_한_번만_기록된다()} 가 맡는다. */ @Test - void 동시에_읽어도_처음_읽은_시각이_유지된다() throws Exception { + void 동시에_읽어도_둘_다_성공한다() throws Exception { String guest = "noti-race-" + UUID.randomUUID(); Notification target = given(guest, null, 0); int attempts = 2; @@ -276,7 +293,7 @@ private Notification given(String guestId, Long courseId, int minutesAfterBase) List> results = new ArrayList<>(); for (int i = 0; i < attempts; i++) { results.add(executor.submit(() -> { - barrier.await(); + barrier.await(CONCURRENCY_TIMEOUT_SECONDS, TimeUnit.SECONDS); // 클래스의 @WithMockUser 는 이 스레드에 안 따라온다 — 요청마다 인증을 싣는다. return mockMvc.perform(patch(READ_URL, target.getId()) .with(user("dev")) @@ -287,15 +304,56 @@ private Notification given(String guestId, Long courseId, int minutesAfterBase) })); } for (Future result : results) { - assertEquals(200, result.get(), "동시 읽음 중 하나가 실패했다"); + assertEquals( + 200, result.get(CONCURRENCY_TIMEOUT_SECONDS, TimeUnit.SECONDS), "동시 읽음 중 하나가 실패했다"); } } finally { executor.shutdownNow(); } - // 두 요청이 각자 시각을 쓰면 나중 값이 남는다. 조건부 UPDATE 면 먼저 이긴 쪽 하나만 쓴다. - Long changed = jdbcTemplate.queryForObject( + Long read = jdbcTemplate.queryForObject( "SELECT COUNT(*) FROM notification WHERE id = ? AND read_at IS NOT NULL", Long.class, target.getId()); - assertEquals(1L, changed); + assertEquals(1L, read, "읽음으로 바뀌지 않았다"); + } + + /** + * 동시에 읽으면 실제로 기록한 호출은 하나뿐이다. + * + *

{@code read_at} 이 채워졌는지 세는 것으로는 이 계약이 안 잡힌다 — 조건 없는 UPDATE 가 두 번 돌아 + * 나중 쪽이 처음 읽은 시각을 덮어써도 "채워진 행 하나" 는 그대로다. 시각을 직접 비교하는 것도 못 쓴다: + * 두 호출이 같은 밀리초를 쓰면 덮어썼는지 아닌지 구별되지 않는다. + * + *

바꾼 행 수를 본다. 조건부 UPDATE 면 이긴 쪽만 1 이고 진 쪽은 0 이다 — 판정이 DB 안에서 + * 갈렸다는 직접 증거다. 이 값은 응답에 실리지 않으므로(둘 다 200 이 계약이다) port 를 직접 부른다. + * 내부 컴포넌트라 stub 이 아니라 실제 빈이고, {@code @Modifying} 이 트랜잭션을 요구하므로 호출마다 + * 하나를 열어 준다. + */ + @Test + void 동시에_읽으면_한_번만_기록된다() throws Exception { + String guest = "noti-claim-" + UUID.randomUUID(); + Notification target = given(guest, null, 0); + TransactionTemplate transaction = new TransactionTemplate(transactionManager); + int attempts = 2; + CyclicBarrier barrier = new CyclicBarrier(attempts); + ExecutorService executor = Executors.newFixedThreadPool(attempts); + List changed = new ArrayList<>(); + try { + List> results = new ArrayList<>(); + for (int i = 0; i < attempts; i++) { + results.add(executor.submit(() -> { + barrier.await(CONCURRENCY_TIMEOUT_SECONDS, TimeUnit.SECONDS); + return transaction.execute(status -> + notificationRepository.markRead(guest, target.getId(), BASE_TIME.plusHours(1))); + })); + } + for (Future result : results) { + changed.add(result.get(CONCURRENCY_TIMEOUT_SECONDS, TimeUnit.SECONDS)); + } + } finally { + executor.shutdownNow(); + } + + assertEquals(1, Collections.frequency(changed, 1), "기록에 성공한 호출이 하나여야 한다 실제=" + changed); + assertEquals(1, Collections.frequency(changed, 0), "이미 읽음으로 갈린 호출이 하나여야 한다 실제=" + changed); } }