Files

56 KiB

공지사항 예약 공개 구현 계획

실행자: 아래 Goal을 순서대로 하나씩 실행하고 각 체크박스와 실제 검증 결과를 누적한다. 구현 Task는 RED → RED 확인 → GREEN → GREEN 확인 → REFACTOR를 따른다. 사용자 후속 요청으로 SQL/source/test 구현이 승인됐다.

Goal: ADMIN이 예약 공지를 안전하게 관리하고 모든 공개 consumer가 공개 시점·정렬·날짜 계약을 지킨다.

Architecture: 현재 notice domain/repository와 ServiceNoticeController를 확장하고 v2 ADMIN 목록 조립 endpoint를 추가한다. 공개 query는 역할과 무관한 단일 visibility predicate를 사용하며 reservation write는 기존 notice row의 pessimistic lock에서 검증한다.

Tech Stack: Kotlin, Spring Boot 2.7.14, Spring Data JPA, QueryDSL, MySQL, 기존 H2/JUnit 5 테스트 구성. 신규 dependency 없음.

Spec: prd.md

Global Constraints

  • PRD §3~§7의 사용자 확정 권한·공개 정책·응답 호환성을 변경하지 않는다.
  • P1-T1은 사용자 확인(서버 UTC·DB UTC·신규 publish_at 초 단위 TIMESTAMP)으로 완료한다. 기존 created_at 정밀도 복제·변경은 하지 않는다.
  • 운영 rollout은 additive schema → 전체 read-aware app 교체 → 예약 쓰기/admin 사용 순서이며 feature flag는 추가하지 않는다.
  • DB/source/test 변경은 승인된 구현 Task에서 수행한다. 운영 DB 적용·접속 및 배포는 제외한다.
문서 항목 내용
상태 기능 구현·관련 검증·리뷰 완료, 전체 회귀 timeout으로 최종 Gate 보류
작성일 2026-10-30
요구사항 기준 prd.md
API 기준 prd.md §7
현재 Phase Phase 3, 전체 회귀 완료 증거 대기
현재 활성 Goal 없음, P3-GATE blocked

목표

관리자는 활성 공지 예약을 안전하게 관리하고, 공개 클라이언트는 공개 경계에 도달한 공지만 승인된 순서와 날짜로 조회한다.

현재 상태 및 실행 규칙

Phase 상태 완료 Task 활성/다음 Goal 차단 조건
1. 저장 계약과 쓰기 완료 3/3 없음 사용자 승인 계약·104개 회귀·단계 리뷰 통과, 운영 적용은 별도
2. 공개 조회와 관리자 목록 완료 1/1 없음 관련 HTTP·DB 122개 및 최종 리뷰 승인
3. 통합 회귀 (Gate only) blocked 해당 없음 P3-GATE 전체 test 20분 timeout, 전체 회귀 성공 미확인
  • 동시에 하나의 미완료 Goal만 실행한다. 아래 순서대로 앞 Goal의 완료 증거를 남긴 뒤 다음으로 진행한다.
  • 신규 컬럼 초 정밀도·서버/DB UTC는 사용자 승인값이다. 기존 컬럼의 물리 정밀도는 추정하지 않으며 운영 DB에 접속하지 않는다.
  • 완료 증거가 기록된 항목만 체크한다. 미완료 테스트 명령과 수동 HTTP 확인은 계획값이다.

범위

포함

  • §7 계약을 기준으로 기존 notice entity, 입력·service, query, 새 ADMIN 목록 API와 security를 변경한다.
  • 예약 쓰기, 공개 필터·정렬·표시 날짜, 시각 정밀도와 동시 변경 보호를 테스트한다.
  • 기존 공개 v1/v2 응답·권한·오류 assertion을 보존하며 실제 HTTP에서 검증한다.

제외

  • 관리자/클라이언트 UI, scheduler, catch-up 작업, cache, 신규 role/dependency, 검색/추가 필터, 복원, bulk schedule.
  • 운영 DDL 자동 적용/실제 DB 접근, BaseEntity/global timezone 리팩터링, 과거 레코드 backfill.

기술 제약과 파일 책임 지도

책임 현재 파일 또는 계획 파일
상태/기존 쓰기와 입력 src/main/kotlin/kr/co/vividnext/sodalive/notice/ServiceNotice.kt, ServiceNoticeService.kt, CreateNoticeRequest.kt, UpdateNoticeRequest.kt
기존 쓰기 controller boundary src/main/kotlin/kr/co/vividnext/sodalive/notice/ServiceNoticeController.kt
공개 query 및 ADMIN query src/main/kotlin/kr/co/vividnext/sodalive/notice/ServiceServiceNoticeRepository.kt
admin query projection 신규 src/main/kotlin/kr/co/vividnext/sodalive/v2/api/admin/notice/dto/AdminNoticeItem.kt (@QueryProjection, createdAt/publishAt KST formatted AdminNoticeItem 최소 projection). 기존 query 구현 위치는 유지하고 신규 API DTO는 v2에 둔다.
기존 public latest 조립 src/main/kotlin/kr/co/vividnext/sodalive/v2/api/notice/application/LatestNoticeService.kt
ADMIN 목록 endpoint/application/DTO 새 파일 src/main/kotlin/kr/co/vividnext/sodalive/v2/api/admin/notice/adapter/in/web/AdminNoticeController.kt, application/AdminNoticeService.kt, dto/AdminNoticeResponse.kt (추가 DTO는 필요 최소로 구현)
보안 src/main/kotlin/kr/co/vividnext/sodalive/configs/SecurityConfig.kt
admin response DTO 신규 src/main/kotlin/kr/co/vividnext/sodalive/v2/api/admin/notice/dto/AdminNoticeResponse.kt; {totalCount: Int, noticeList: List<AdminNoticeItem>} wrapper와 item 필드만 정의
통합 HTTP 회귀 src/test/kotlin/kr/co/vividnext/sodalive/v2/api/notice/LatestNoticeControllerIntegrationTest.kt
쓰기/admin HTTP 통합 신규 src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/notice/AdminNoticeControllerIntegrationTest.kt
query/DB 회귀 신규 src/test/kotlin/kr/co/vividnext/sodalive/notice/ServiceNoticeQueryRepositoryTest.kt
entity 회귀 신규 src/test/kotlin/kr/co/vividnext/sodalive/notice/ServiceNoticeTest.kt
홈 latest 연결 회귀 신규 src/test/kotlin/kr/co/vividnext/sodalive/content/main/tab/home/AudioContentMainTabHomeServiceIntegrationTest.kt; production linkage는 src/main/kotlin/kr/co/vividnext/sodalive/content/main/tab/home/AudioContentMainTabHomeService.kt의 noticeService.getLatestNotice()다. 기존 api/home/HomeServiceTest.kt는 다른 서비스를 검사하므로 수정하지 않는다.

Review Focus

  • 경계와 role 격리: 예약 Nov 1, fixed now Oct 30; 익명·일반·ADMIN의 public endpoints 모두 미공개 제외, boundary exact instant 포함 (LatestNoticeControllerIntegrationTest).
  • 제한 적용 순서와 동률: eligible 4건 뒤 더 최신 id의 미공개 예약들을 추가해도 top 3가 유지되고, 같은 publishAt이면 id DESC (ServiceNoticeQueryRepositoryTest).
  • 시간 캡처와 날짜 분리: count/list가 nowProvider를 한 번 호출; scheduled→immediate가 KST 자정을 넘어도 date는 original createdAt, 정렬은 conversion instant (ServiceNoticeQueryRepositoryTest, AdminNoticeControllerIntegrationTest).
  • 기존 소비자 연결: v1 object/null과 홈 latestNotice가 같은 filtered getLatestNotice()를 사용하고 기존 full-field assertion 유지 (LatestNoticeControllerIntegrationTest, AudioContentMainTabHomeServiceIntegrationTest).
  • 경계 입력/DB 정밀도/동시 수정: non-string/non-null publishAt 거부, exact DB round-trip nanos 0/123456789/999999999, lock 대기 중 publishAt 경과 시 update 거부, schedule→immediate와 competing re-reserve 결과가 순차화됨 (AdminNoticeControllerIntegrationTest).

실제 기존 스키마는 미조회다. docs/20261030_공지사항예약공개/alter-service-notice-publication.sql에 사용자 승인된 초 단위 신규 컬럼만 추가한다. 기존 created_at은 변경하지 않는다. 배포 순서는 additive schema 및 연결 UTC 확인 → 공개 query filter가 포함된 새 앱 버전으로 전체 instance 교체 → 운영 조정으로 예약 쓰기/admin 사용 활성화다. 이전 앱 instance가 남아 있는 동안 예약을 쓰면 기존 공개 reader가 이를 노출할 수 있으므로 rollout 순서와 관리자 사용 통제로 쓰기를 막는다.

전역 API·데이터 인터페이스

구현 기준은 prd.md §7이며 이 계획은 별도 API contract 파일을 만들지 않는다.

  • 공개 응답 NoticeItem(id: Long, title: String, content: String, date: String) 및 v1 object-or-null, v2 최대 3개 array, 목록의 totalCount/noticeList envelope를 보존한다.
  • ADMIN GET /api/v2/admin/notices?page={0-based}&size={pageSize}는 ADMIN 전용이며 ApiResponse<{totalCount: Int, noticeList: AdminNoticeItem[]}>를 반환한다. item 필드: id, title, content, isActive, isScheduled, publishAt, createdAt; 시각은 KST yyyy-MM-dd HH:mm:ss, publishAt nullable.
  • POST /notice: 기존 {title,content} + optional nullable publishAt; 생략/null 즉시, 미래 문자열 예약. 기존 생성 ID 응답 보존.
  • PUT /notice: 기존 id,title?,content? + optional nullable publishAt; 필드 부재 보존, explicit null 즉시 전환, 미래 시각 재예약. 공개된 공지의 publishAt 변경 거부. 기존 수정 응답 보존.
  • 오류는 기존 SodaException(common.error.invalid_request)와 ApiResponse 처리. 보안 HTTP status는 현재 SecurityConfig/handler를 따르며 통합 테스트로 확인한다.
  • 저장 제안: publishAt: LocalDateTime? UTC와 isScheduled: Boolean=false. 실제 타입·물리 정밀도는 DDL 확인 후 확정한다. 새 publishAt의 업무/API 계약은 초 단위이며 즉시 전환 now를 저장 경계에서 한 번만 초 절삭해 엔티티/응답에 같은 값을 사용한다. 물리 컬럼이 (3)/(6)이어도 이 새 필드의 나노초는 0을 유지한다. 기존 createdAt은 절삭하지 않는다. 예약의 미래 여부와 공개 경계 판정에는 캡처한 원본 UTC now를 사용한다.
  • 공개 repository 계약은 getNoticeTotalCount(now: LocalDateTime): Int, getNoticeList(pageable: Pageable, now: LocalDateTime): List<NoticeItem>, getLatestNotice(now: LocalDateTime): NoticeItem?다. ServiceNoticeService.getNoticeList(pageable, timezone) 및 getLatestNotice() 외부 진입 시그니처는 유지하고 각 메서드에서 now를 한 번 캡처한다. count/list는 같은 캡처값을 전달한다. v2 LatestNoticeService.getLatestNotices()도 내부 nowProvider로 한 번 캡처해 repo에 전달한다.
  • 공개 조건은 isActive AND (publishAt IS NULL OR publishAt <= now), 순서는 COALESCE(publishAt, createdAt) DESC, id DESC. 표시 날짜 source는 scheduled일 때 publishAt, 그 외 createdAt다.
  • ServiceServiceNoticeRepository에 @Lock(LockModeType.PESSIMISTIC_WRITE) @Query("select notice from ServiceNotice notice where notice.id = :id") fun findByIdForUpdate(@Param("id") id: Long): ServiceNotice?를 추가한다. ServiceNoticeService.update(request: UpdateNoticeRequest, publishAtPresent: Boolean = false)는 transaction에서 이 lock으로 row를 읽고 난 뒤 now를 캡처해 검사한다. 공개된 공지에 명시된 publishAt 변경은 거부한다. 동시 요청의 선형화 지점은 잠금 후 eligibility 검사이며 시간 경계가 이후 통과하는 것은 query-time 공개 정책에 맡긴다.
  • AdminNoticeItem projection은 src/main/kotlin/kr/co/vividnext/sodalive/v2/api/admin/notice/dto/AdminNoticeItem.kt 한 곳에 두고, repository admin methods는 getAdminNoticeTotalCount(): Int, getAdminNoticeList(pageable: Pageable): List<AdminNoticeItem>다. AdminNoticeService.getNoticeList(pageable: Pageable): AdminNoticeResponse와 ADMIN controller output은 이 응답 타입을 사용한다. 별도 중복 projection은 만들지 않는다.

Phase 1: schema 확인 및 예약 쓰기

Phase 결과: 안전한 저장 정밀도를 확인하고 생성·예약 변경·즉시 전환이 잘못된 공개 상태를 만들지 않는다.

선행조건: 없음. P1-T1 차단 해제 전 구현 Task를 시작하지 않는다.

Phase 완료 조건: P1-T1~P1-T3와 P1-GATE 완료.

  • Task 1.1: 사용자 UTC·신규 컬럼 계약 확인 (P1-T1)
  • Task 1.2: 모델·생성·시각 검증 (P1-T2)
  • Task 1.3: 수정과 경합 방지 (P1-T3)
  • Phase 1 Gate (P1-GATE)

Task 1.1 운영 schema와 시간 계약 확인

Goal 실행 P1-T1: 사용자 확인을 근거로 서버·DB UTC와 신규 컬럼 초 정밀도를 확정한다. 기존의 운영 DDL 선행 조건은 NS-D5로 정정됐다.

  • 시작 조건: 사용자 후속 승인 및 PRD NS-D5.
  • 완료 증거: 사용자 서버 UTC·DB UTC 확인, 새 publish_at 초 단위 TIMESTAMP 승인과 기존 created_at 미변경 결정 기록.
  • 범위 밖: 운영 DB 접속/변경, 기존 물리 컬럼 정의를 확인한 것으로 주장하기.
  • Files: 확인 src/main/kotlin/kr/co/vividnext/sodalive/notice/ServiceNotice.kt, src/main/kotlin/kr/co/vividnext/sodalive/common/BaseEntity.kt; 기록은 이 문서와 PRD NS-D5.
  • Interfaces: 신규 MySQL publish_at TIMESTAMP NULL DEFAULT NULL, H2 검증 TIMESTAMP(0); 기존 created_at 미변경. is_scheduled BOOLEAN NOT NULL DEFAULT FALSE, 두 컬럼 COMMENT 및 기존 NULL/FALSE 유지.
  • TDD 예외 사유: 사용자 확인과 계약 결정 기록이며 동작 코드 변경이 아니다.
  • 대체 검증 방법: 엔티티·상속 시각 필드 재확인 및 사용자 승인 내용 대조.
    • 서버·DB UTC 사용자 확인을 기록했다.
    • 신규 publish_at은 초 정밀도, 기존 created_at은 변경하지 않기로 확정했다.
    • 운영 연결 설정 실확인과 DDL 적용은 배포 전 확인으로 분리했다.

Task 1.2 모델·생성·시각 검증

Goal 실행 P1-T2: schema gate 결과에 맞춘 SQL/entity 상태 모델로 즉시 생성과 미래 예약을 정확히 저장한다.

  • 시작 조건: P1-T1 완료 및 정밀도/timezone 계약 기록.
  • 완료 증거: 생성 API에서 생략/null 즉시, 미래 KST 예약 UTC 저장, 과거/현재·strict format 오류, entity 상태, actual DB flush/clear/reload 정밀도 검증 통과.
  • 범위 밖: 수정·관리자 목록·공개 필터.
  • Files: Create docs/20261030_공지사항예약공개/alter-service-notice-publication.sql; Modify src/main/kotlin/kr/co/vividnext/sodalive/notice/ServiceNotice.kt, src/main/kotlin/kr/co/vividnext/sodalive/notice/ServiceNoticeController.kt, src/main/kotlin/kr/co/vividnext/sodalive/notice/CreateNoticeRequest.kt, src/main/kotlin/kr/co/vividnext/sodalive/notice/ServiceNoticeService.kt; Create/Test src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/notice/AdminNoticeControllerIntegrationTest.kt, src/test/kotlin/kr/co/vividnext/sodalive/notice/ServiceNoticeTest.kt, src/test/kotlin/kr/co/vividnext/sodalive/notice/ServiceNoticeQueryRepositoryTest.kt. Entity 기본값은 unit test, 실제 DB 왕복은 repository test가 담당한다.
  • Interfaces: Consumes: P1-T1 정밀도 계약. Create controller signature createNotice(@RequestBody fields: JsonNode) accepts object with optional publishAt string/null only, maps to existing CreateNoticeRequest, then calls existing service.save(request). Produces: publishAt nullable UTC, isScheduled default false; creation is scheduled=false & publishAt null or scheduled=true & validated future UTC.
  • TDD:
    • RED: entity test에서 기본값 isScheduled=false,publishAt=null assertion 및 HTTP create assertion 작성. publishAt 미입력/null은 즉시; 미래 KST 2026-11-01 09:00:00은 UTC 2026-11-01T00:00:00; 과거/현재·strict invalid calendar·non-string/non-null JSON은 common.error.invalid_request assertion.
    • RED 확인: ./gradlew test --tests 'kr.co.vividnext.sodalive.v2.api.admin.notice.AdminNoticeControllerIntegrationTest' --tests 'kr.co.vividnext.sodalive.notice.ServiceNoticeTest' --tests 'kr.co.vividnext.sodalive.notice.ServiceNoticeQueryRepositoryTest'에서 미구현 assertion 실패 확인.
    • GREEN: SQL에 초 단위 publish_at TIMESTAMP NULL DEFAULT NULL COMMENT '예약 공개 시각(UTC)' 및 is_scheduled BOOLEAN NOT NULL DEFAULT FALSE COMMENT '예약 공개 여부'를 추가한다. 기존 row는 publish_at=NULL,is_scheduled=FALSE로 유지하고 backfill 금지. Create JSON object boundary도 ServiceNoticeController에서 publishAt string/null만 허용하고 ObjectMapper로 CreateNoticeRequest 변환 후 service 호출하도록 한다. 이어서 최소 entity·validation 구현. Parser pattern은 uuuu-MM-dd HH:mm:ss + ResolverStyle.STRICT, wire 표기는 yyyy-MM-dd HH:mm:ss; nowProvider는 SubBannerService 패턴으로 필요한 service에만 주입.
    • GREEN 확인: ./gradlew test --tests 'kr.co.vividnext.sodalive.v2.api.admin.notice.AdminNoticeControllerIntegrationTest' --tests 'kr.co.vividnext.sodalive.notice.ServiceNoticeTest' --tests 'kr.co.vividnext.sodalive.notice.ServiceNoticeQueryRepositoryTest' 통과. ServiceNoticeQueryRepositoryTest.shouldPersistAndReloadScheduledPublishAt는 KST 2026-11-01 09:00:00 예약을 실제 DB flush/clear/reload한 값이 UTC 2026-11-01T00:00:00과 정확히 같음을 검증한다. 나노초 now 입력의 즉시 전환 검증은 해당 기능을 만드는 P1-T3가 소유한다.
    • REFACTOR: 이번 Task 중복만 정리하고 같은 focused test, 직접 영향 notice regression, ktlint 결과 기록.
  • SQL metadata check: SQL은 ALTER TABLE service_notice에 nullable publish_at과 is_scheduled NOT NULL DEFAULT FALSE를 추가하고 두 컬럼에 COMMENT를 포함해야 한다. 기존 rows는 NULL/FALSE 유지, backfill 및 live 실행은 없어야 한다. SQL 정밀도는 P1-T1 evidence와 일치해야 한다.

Task 1.3 수정과 경합 방지

Goal 실행 P1-T3: 예약 미공개 공지의 시각 변경과 즉시 전환을 지원하며 공개된 공지를 다시 감추지 못하게 한다.

  • 실행 보완: 기존 delete도 같은 findByIdForUpdate로 읽는다. 삭제/예약 변경이 같은 엔티티를 오래된 값으로 flush하여 다른 필드를 덮거나 삭제를 되돌리는 경합을 막기 위한 최소 변경이며, 기존 삭제 API·응답·soft delete 정책은 유지한다. 삭제와 즉시 공개/재예약 경합의 최종 isActive=false 및 최신 예약 필드 보존을 테스트한다.

  • 시작 조건: P1-T2 완료 및 생성 contract 확정.

  • 완료 증거: update 생략/명시 null/future 및 title/content 기존 규칙, schedule-only update, 공개 경계 거부, 동시 공개 race 보호 테스트 통과.

  • 범위 밖: 제목/본문 정책 변경, 복원, 사용자 제공 시각을 기준으로 권한 판정.

  • Files: Modify src/main/kotlin/kr/co/vividnext/sodalive/notice/ServiceNoticeController.kt, src/main/kotlin/kr/co/vividnext/sodalive/notice/UpdateNoticeRequest.kt, src/main/kotlin/kr/co/vividnext/sodalive/notice/ServiceNoticeService.kt, src/main/kotlin/kr/co/vividnext/sodalive/notice/ServiceServiceNoticeRepository.kt; Test src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/notice/AdminNoticeControllerIntegrationTest.kt, 신규 src/test/kotlin/kr/co/vividnext/sodalive/notice/ServiceNoticeConcurrencyIntegrationTest.kt. HTTP와 DB 정밀도는 전자, 독립 transaction의 결정적 row-lock 경합은 후자가 담당한다.

  • Interfaces: Consumes: P1-T2 create model. Controller signature updateNotice(@RequestBody fields: JsonNode) checks fields.isObject and if present accepts publishAt only when isTextual || isNull; maps objectMapper.treeToValue(fields, UpdateNoticeRequest::class.java) then calls service.update(request, publishAtPresent = fields.has("publishAt")). Service signature update(request: UpdateNoticeRequest, publishAtPresent: Boolean = false) retains existing callers. Produces: repository findByIdForUpdate(id: Long): ServiceNotice? with @Lock(PESSIMISTIC_WRITE); locked/rechecked update, reservation-only time edits, immediate conversion publishAt=serverNow,isScheduled=false.

  • TDD:

    • RED: update HTTP tests pin shouldPreserveScheduleWhenPublishAtIsOmitted, shouldConvertScheduledNoticeToImmediateWhenPublishAtIsNull, shouldRescheduleUnpublishedNotice, shouldAllowScheduleOnlyUpdate, shouldRejectRescheduleAfterPublishBoundary, shouldRejectInvalidUpdatePublishAtWithoutPartialChanges, and title/content-only behavior. Existing blank/null validation expectations stay unchanged. shouldRejectUpdateWhenLockWaitCrossesPublishAt and competing shouldSerializeImmediateConversionAndReschedule exercise lock scheduling. 즉시 전환 now에 nanos 0/123456789/999999999와 UTC 23:59:59 경계를 주입하고 DB flush/clear/reload한 publishAt의 나노초가 0이며 다음 초·날짜로 반올림되지 않는지 검증한다. 저장값을 assertion에서 다시 절삭해 비교하지 않는다.
    • RED 확인: ./gradlew --no-daemon test --tests 'kr.co.vividnext.sodalive.v2.api.admin.notice.AdminNoticeControllerIntegrationTest' exit 1, 80개 중 41개 assertion 실패. 상태 전이 최소 구현 뒤 이 class와 --tests 'kr.co.vividnext.sodalive.notice.ServiceNoticeConcurrencyIntegrationTest' 실행에서 HTTP 80개 통과, 잠금 부재로 동시성 9개 실패를 확인했다. compiler 오류를 RED로 계산하지 않았다.
    • GREEN: transaction 안에서 findByIdForUpdate(id) row lock을 수정 필드 변경 전에 얻고 fresh now를 잠금 뒤 한 번 캡처해 active·미공개 예약 eligibility를 재검사한다. 원본 now로 미래 여부를 비교하고 즉시 전환 저장값만 now.truncatedTo(SECONDS)로 만든다. title/content-only update와 delete도 같은 lock을 쓴다.
    • GREEN 확인: ./gradlew --no-daemon test --tests 'kr.co.vividnext.sodalive.notice.ServiceNoticeConcurrencyIntegrationTest' --tests 'kr.co.vividnext.sodalive.v2.api.admin.notice.AdminNoticeControllerIntegrationTest' exit 0, 89개 통과. H2 INFORMATION_SCHEMA.SESSIONS.BLOCKER_ID와 실행 중 SELECT FOR UPDATE를 확인한 뒤 old publishAt 경계로 now를 옮겨 거부됨을 검증했다. 즉시 전환→재예약, 제목↔재예약, 삭제↔즉시 전환/재예약 경합의 최종 DB를 재조회했다. 선형화는 lock 이후 eligibility check이며 wall-clock boundary 통과를 차단한다고 주장하지 않는다.
    • REFACTOR: 추가한 테스트의 출력 포맷만 정리한 뒤 아래 Phase 1 명령으로 직접 영향 notice 5개 class 104개 및 ktlintCheck 통과. 기존 테스트 삭제·완화·skip은 없다.

Phase 1 Gate

Goal 실행 P1-GATE: 시간 저장 계약, 생성·수정 상태 전이를 판정한다.

  • 시작 조건: 모든 Phase 1 task 완료, schema gate 근거 포함.
  • 완료 증거: P1 focused tests와 정밀도 대조표 통과, Progress에 실행 결과 및 차단 여부 기록.
  • 범위 밖: schema 확인 없이 구현 진행 또는 실패 test 완화.
  • TDD 예외 사유: 통합 검증 gate이며 자체 production 기능을 추가하지 않는다.
  • 대체 검증 방법: 해당 focused test 명령과 저장소 문서/DDL 대조표 확인.
  • ./gradlew --no-daemon test --tests 'kr.co.vividnext.sodalive.notice.ServiceNoticeConcurrencyIntegrationTest' --tests 'kr.co.vividnext.sodalive.v2.api.admin.notice.AdminNoticeControllerIntegrationTest' --tests 'kr.co.vividnext.sodalive.notice.ServiceNoticeQueryRepositoryTest' --tests 'kr.co.vividnext.sodalive.notice.ServiceNoticeTest' --tests 'kr.co.vividnext.sodalive.v2.api.notice.LatestNoticeControllerIntegrationTest' ktlintCheck exit 0, 104개 통과.
  • 사용자 승인 신규 MySQL TIMESTAMP 초 단위 계약과 H2 fixture TIMESTAMP(0)이 일치하고 exact DB 왕복 입력 nanos 0/123456789/999999999 및 UTC 23:59:59 결과를 Progress와 stage report에 기록했다. 기존 created_at의 물리 정밀도를 추정·변경하지 않았다.
  • 실제 JWT/HTTP에서 익명 write 401, 일반 회원의 기존 HTTP 200 권한 오류, ADMIN 생성 ID 및 PUT/DELETE data={} 응답과 soft delete를 보존했다.
  • 후속 검증 대기: 관리자 GET 응답의 publishAt을 KST→UTC 복원하여 DB와 비교하는 검증은 P2 endpoint 구현 후 수행한다. 이 단계의 DB 왕복 통과를 응답 시각 비교 통과로 대체하지 않는다. 상단 전체 Phase 상태와 Gate 완료 판정은 본 단계에서 변경하지 않는다.

Phase 2: 공개 조회와 관리자 목록

Phase 결과: 공개 query는 예약 미공개를 역할 관계없이 차단하고 ADMIN의 별도 목록은 활성 예약을 모두 보여 준다.

선행조건: P1-GATE 완료, 외부 schema gate 충족.

  • Task 2.1: 공유 query, 관리자 endpoint, 공개 HTTP 회귀 (P2-T1)
  • Phase 2 Gate (P2-GATE)

Task 2.1 공유 query, 관리자 endpoint, 공개 HTTP 회귀

Goal 실행 P2-T1: 모든 notice 조회가 승인된 가시성·정렬·날짜 정책을 따르고 ADMIN 목록은 예약을 포함한다.

  • 시작 조건: P1-GATE 완료, PRD §7 계약 확인.
  • 완료 증거: 공개 count/list/v1 latest/v2 latest와 관리자 페이지의 query/HTTP assertion 통과, auth matrix 확인.
  • 범위 밖: UI, 공개 역할 우회, 추가 검색/filter, 스케줄러.
  • Files: Modify src/main/kotlin/kr/co/vividnext/sodalive/notice/ServiceServiceNoticeRepository.kt, src/main/kotlin/kr/co/vividnext/sodalive/notice/ServiceNoticeService.kt, src/main/kotlin/kr/co/vividnext/sodalive/v2/api/notice/application/LatestNoticeService.kt, src/main/kotlin/kr/co/vividnext/sodalive/configs/SecurityConfig.kt, src/test/kotlin/kr/co/vividnext/sodalive/v2/api/notice/LatestNoticeControllerIntegrationTest.kt, src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/notice/AdminNoticeControllerIntegrationTest.kt, src/test/kotlin/kr/co/vividnext/sodalive/notice/ServiceNoticeQueryRepositoryTest.kt; Create src/main/kotlin/kr/co/vividnext/sodalive/v2/api/admin/notice/dto/AdminNoticeItem.kt, src/main/kotlin/kr/co/vividnext/sodalive/v2/api/admin/notice/adapter/in/web/AdminNoticeController.kt, src/main/kotlin/kr/co/vividnext/sodalive/v2/api/admin/notice/application/AdminNoticeService.kt, src/main/kotlin/kr/co/vividnext/sodalive/v2/api/admin/notice/dto/AdminNoticeResponse.kt, src/test/kotlin/kr/co/vividnext/sodalive/content/main/tab/home/AudioContentMainTabHomeServiceIntegrationTest.kt.
  • Interfaces: Consumes: P1 model/write contract. Preserve ServiceNoticeService.getNoticeList(pageable: Pageable, timezone: String) and getLatestNotice() public signatures. Repository methods: getNoticeTotalCount(now: LocalDateTime): Int, getNoticeList(pageable: Pageable, now: LocalDateTime): List<NoticeItem>, getLatestNotice(now: LocalDateTime): NoticeItem?; admin methods getAdminNoticeTotalCount(): Int, getAdminNoticeList(pageable: Pageable): List<AdminNoticeItem>. Each public service captures now once; list and count receive same now. AdminNoticeController.getNotices(pageable: Pageable): ApiResponse<AdminNoticeResponse> delegates to AdminNoticeService.getNoticeList(pageable): AdminNoticeResponse, whose exact output is {totalCount: Int, noticeList: List<AdminNoticeItem>}.
  • TDD:
    • RED: repository/HTTP tests pin named cases: shouldExcludeScheduledNoticeBeforeBoundaryForAdminOnPublicRoutes uses Nov 1 scheduled + fixed Oct 30 now for anonymous, member, ADMIN; shouldIncludeNoticeAtExactPublishBoundary; shouldApplyPublicFilterBeforeLatestLimit adds newer unpublished ids beyond 3 eligible items and still expects eligible top 3; shouldOrderEqualPublishTimesByIdDescending uses two distinct rows with equal publishAt; shouldCaptureNowOnceForCountAndList expects provider call count 1 and identical now arguments; shouldUseRegistrationDateButConversionTimeForImmediateScheduleConversion has registration Oct 31 23:50 KST, conversion Nov 1 00:10 KST, competitor effective time Nov 1 00:05 KST, and expects conversion notice first in order but its displayed date 2026-10-31. Assert v1 latest object/null and preserve every full-field assertion. Admin HTTP verifies active schedule inclusion/inactive exclusion. Rewrite existing id-only order fixtures while retaining all full-field/auth/format assertions.
    • RED 확인: ./gradlew test --tests 'kr.co.vividnext.sodalive.v2.api.notice.LatestNoticeControllerIntegrationTest' --tests 'kr.co.vividnext.sodalive.v2.api.admin.notice.AdminNoticeControllerIntegrationTest' --tests 'kr.co.vividnext.sodalive.notice.ServiceNoticeQueryRepositoryTest' --tests 'kr.co.vividnext.sodalive.content.main.tab.home.AudioContentMainTabHomeServiceIntegrationTest'에서 요구 동작 부재로 실패 확인.
    • GREEN: count/list/latest에 동일 가시성 predicate, effective time/id ordering, 표시 날짜 source 적용. ServiceNoticeService public methods preserve signatures and capture now once; v2 latest captures once and passes to repository. Admin projection uses the exact AdminNoticeItem.kt path above. Add separate ADMIN-only route authorization without role-dependent public visibility.
    • GREEN 확인: ./gradlew test --tests 'kr.co.vividnext.sodalive.v2.api.notice.LatestNoticeControllerIntegrationTest' --tests 'kr.co.vividnext.sodalive.v2.api.admin.notice.AdminNoticeControllerIntegrationTest' --tests 'kr.co.vividnext.sodalive.notice.ServiceNoticeQueryRepositoryTest' --tests 'kr.co.vividnext.sodalive.content.main.tab.home.AudioContentMainTabHomeServiceIntegrationTest' 통과. 익명/비관리자/ADMIN으로 API를 호출해 익명 정상 공개 조회, 모든 역할에서 예약 비공개, ADMIN 목록만 예약 포함을 확인. P1-T3의 세 나노초 입력으로 생성한 즉시 전환 DB publishAt과 관리자 GET의 publishAt을 KST에서 UTC로 복원한 값을 정확히 비교한다.
    • REFACTOR: 새 코드에서 생긴 중복만 정리하고 위 네 focused test, ktlintCheck 및 영향 범위 notice regression 결과 기록.
  • Compatibility check: AudioContentMainTabHomeService.fetchData at src/main/kotlin/kr/co/vividnext/sodalive/content/main/tab/home/AudioContentMainTabHomeService.kt already calls ServiceNoticeService.getLatestNotice(). Keep that entrypoint filtered; verify AudioContentMainTabHomeServiceIntegrationTest asserts latestNotice uses expected visible item and excludes an unpublished scheduled notice. Preserve /notice/latest object/null and current complete response fields.
  • Admin timestamp display check: createdAt is formatted in KST yyyy-MM-dd HH:mm:ss for output only. Preserve the source DB value/precision; do not change existing created_at DDL or truncate stored values to seconds.
  • 실행 증거: 아래 Progress의 P2 실행 기록 및 소유 임시 루트 stage-report.md에 실제 명령·exit·HTTP·DB 결과를 기록했다. SecurityConfig 수정 없이 기존 authenticated 정책과 새 controller의 ADMIN method guard를 사용했다.

Phase 2 Gate

Goal 실행 P2-GATE: 공개 query와 ADMIN 조회가 서버 API 표면에서 계약대로 동작하는지 판정한다.

  • 시작 조건: P2-T1 완료.
  • 완료 증거: 최신 full-field HTTP, auth, 페이지네이션 전 filter, 정렬/표시 날짜 확인.
  • 범위 밖: frontend 및 운영배포/실 DB 수행.
  • TDD 예외 사유: 구현 Task 결과 종합 확인.
  • 대체 검증 방법: 아래 HTTP 시나리오의 실제 응답 상태/envelope와 focused integration 결과를 기록한다.
  • ./gradlew test --tests 'kr.co.vividnext.sodalive.v2.api.notice.LatestNoticeControllerIntegrationTest' --tests 'kr.co.vividnext.sodalive.v2.api.admin.notice.AdminNoticeControllerIntegrationTest' --tests 'kr.co.vividnext.sodalive.notice.ServiceNoticeQueryRepositoryTest' --tests 'kr.co.vividnext.sodalive.content.main.tab.home.AudioContentMainTabHomeServiceIntegrationTest' 성공.
  • 실행 서버에서 anonymous GET /api/v2/notices/latest, non-admin/anonymous GET /api/v2/admin/notices?page=0&size=20, ADMIN 같은 관리자 GET의 실제 status와 ApiResponse/data fields 기록.
  • ADMIN 토큰을 붙인 GET /notice, /notice/latest, /api/v2/notices/latest 모두 boundary 이전 예약 공지를 제외하고 관리자 GET만 이를 반환함을 확인.

Phase 3: 회귀와 최종 증거

Phase 결과: 예약 공개 변경이 기존 notice contract를 유지하고 완료 여부가 검증 기록과 함께 판정된다. 이 phase는 P3-GATE 하나뿐이며 별도 Task goal은 없다.

선행조건: P2-GATE 완료.

  • Phase 3 Gate (P3-GATE)

Goal 실행 P3-GATE: 기존 public/admin notice 동작의 영향 범위를 검증하고 남은 schema/배포 의존성을 분명히 한다.

  • 시작 조건: Phase 1~2 완료 및 focused HTTP 검증 통과.
  • 완료 증거: 선택된 package regression, ktlintCheck, conditional full regression 결과와 미실행 근거를 기록. DB 운영 확인 및 수동 migration/rollout은 별도 승인·절차가 완료되기 전 미완료로 남긴다.
  • 범위 밖: unrelated test 수정, 운영 migration 실행, 배포 선언.
  • Files: Verify only src/test/kotlin/kr/co/vividnext/sodalive/v2/api/notice/LatestNoticeControllerIntegrationTest.kt, src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/notice/AdminNoticeControllerIntegrationTest.kt, src/test/kotlin/kr/co/vividnext/sodalive/notice/ServiceNoticeQueryRepositoryTest.kt, src/test/kotlin/kr/co/vividnext/sodalive/notice/ServiceNoticeTest.kt, src/test/kotlin/kr/co/vividnext/sodalive/content/main/tab/home/AudioContentMainTabHomeServiceIntegrationTest.kt; 변경 필요한 실패가 생기면 이 gate에서 범위를 넓히지 않고 새 regression task를 추가.
  • Interfaces: Consumes: P1/P2 API와 data contract. Produces: 검증 결과/미확인 목록이며 API 계약 변경 없음.
  • TDD 예외 사유: release-level 검증 gate이며 별도 기능 구현 없음.
  • 대체 검증 방법: focused commands, 직접 영향 notice package 회귀, lint, 실제 HTTP role matrix. 전체 test는 아래 조건에서만 실행한다.
  • 실제 실행: ./gradlew --offline --no-daemon test --tests 'kr.co.vividnext.sodalive.notice.*' --tests 'kr.co.vividnext.sodalive.v2.api.notice.*' --tests 'kr.co.vividnext.sodalive.v2.api.admin.notice.*' --tests 'kr.co.vividnext.sodalive.content.main.tab.home.AudioContentMainTabHomeServiceIntegrationTest' ktlintCheck bootJar --continue exit 0, 4분 9초, 122개 통과.
  • 직접 영향 notice/home regression, ktlintCheck, bootJar 성공. build/libs/sodalive-0.0.1-SNAPSHOT.jar 생성 확인.
  • 사용자 ULW 전체 suite 검증 요청에 따라 ./gradlew --offline --no-daemon test ktlintCheck bootJar --continue를 실행했으나 test 단계 20분 timeout으로 미완료다. 전체 test 성공으로 표시하지 않는다. 장시간 정체 원인은 미확인이고 관련 코드 수정이나 test skip 없이 후속 확인 대상으로 남긴다.
  • 기존 v1 object/null, v2 최대 3개 array, 목록 응답 shape, create ID 및 update/delete 기존 success response assertion 유지 확인.
  • 독립 최종 리뷰 APPROVED. 요구사항을 위반하는 구체적 결함이 없다고 판정했으며 전체 suite 미완료와 운영 DB 미검증은 별도로 남겼다.

실행 순서

순서 Goal 선행조건 상태/차단 시 행동
1 P1-T1 없음 schema 근거 없음이면 외부 조건 대기, 코드 구현 금지
2 P1-T2 P1-T1 저장 precision과 시간 contract 고정
3 P1-T3 P1-T2 상태 전이 및 race guard
4 P1-GATE Phase 1 tasks 실패 task로 회귀 추가
5 P2-T1 P1-GATE 공개/Admin query와 API 구현
6 P2-GATE P2-T1 실제 HTTP auth matrix 수행
7 P3-GATE P2-GATE 관련 test/lint 및 조건부 full regression 기록

변경 금지 및 중단 규칙

  • 승인되지 않은 역할·UI·scheduler·복원·bulk 기능을 추가하지 않는다. 공개 API는 role-aware visibility bypass를 두지 않는다.
  • 기존 ID-only test 기대는 승인된 새 공개 순서에 맞춰 fixture를 수정하되 response, 날짜 format, 권한 assertion은 지우거나 완화하지 않는다.
  • 신규 TIMESTAMP 초 정밀도와 서버·DB UTC는 NS-D5 승인값을 사용한다. 기존 created_at 및 BaseEntity는 변경하지 않는다. 운영 직접 확인은 수행했다고 주장하지 않는다.
  • 공통 security/error handling을 변경할 필요가 생기면 먼저 계획을 갱신하고 실제 기존 동작과 요구를 재검토한다.
  • 검증 실패는 원인 test를 삭제/skip/허용오차로 감추지 않는다. scope 밖 issue는 기록만 한다.
  • 커밋은 필수 단계가 아니며, 본 요청은 커밋을 승인하지 않는다.

Progress

현재 문서 작성 시점에는 향후 구현을 실행하지 않았다. 각 Goal 완료 후 실제 실행 명령, 핵심 결과, 다음 행동을 아래에 누적한다. 미실행 검증을 성공으로 표시하지 않는다.

P1-T1 1차 실행: 2026-10-30

  • 상태: blocked, 운영 스키마·배포 시간대 자료 대기. 체크박스는 완료 처리하지 않는다.
  • 무엇을: 구현 승인 후 저장소 SQL/DDL·관련 문서·시각 처리 코드와 설정의 비민감 항목을 재확인했다. 실제 service_notice 정의는 발견하지 못했다.
  • 근거: src/main/resources/application.yml:126은 ddl-auto: validate이며, src/main/kotlin/kr/co/vividnext/sodalive/common/BaseEntity.kt:31은 LocalDateTime.now()로 등록 시각을 설정한다. 이 정보는 운영 created_at 타입·소수초 정밀도 또는 운영 JVM/JDBC/DB 시간대를 증명하지 않는다.
  • 확인 방법: 저장소 SQL/DDL 후보와 service_notice 정의 검색, serverTimezone, connectionTimeZone, hibernate.jdbc.time_zone, user.timezone, TimeZone.setDefault 설정 검색. 운영 DB·네트워크에는 접속하지 않았다. 비밀값은 기록하지 않는다.
  • 재개 조건·담당: 운영 DB/배포 담당자가 SHOW CREATE TABLE service_notice 결과, 같은 환경의 DB 세션/전역 시간대 및 운영 JVM/JDBC 시간대 설정을 제공하면 확인 결과를 기록하고 P1-T1을 재판정한다. JDBC URL 전체나 자격증명 대신 시간대 옵션만 받는다.
  • 미실행: P1-T2 이후 소스·SQL·테스트 구현과 Gradle·HTTP 검증. 선행 근거 없이 스키마를 추정하지 않는 승인 계획을 따른다.

P1-T2 실행: 2026-10-30

  • 상태: P1-T2 완료. 생성·모델·additive SQL만 구현했다. P1-T3, 모든 Gate, 관리자 목록과 공개 query는 변경하거나 완료 처리하지 않았다.
  • 증거 루트: /var/folders/yh/8xsbvpsj5wg2qnxzxdp11_gm0000gn/T/opencode/notice-p1t2.lPSBOW. 상세 명령·파일 목록은 stage-report.md, 실행 로그와 JUnit XML은 같은 디렉터리에 보관한다.
  • 기준 검증: 운영 코드 변경 전 ./gradlew --no-daemon test --tests 'kr.co.vividnext.sodalive.v2.api.notice.LatestNoticeControllerIntegrationTest' exit 0, 11개 통과 (baseline.log, baseline-latest.xml). 기존 테스트 assertion은 수정하지 않았다.
  • RED: 신규 실제 JWT/HTTP 테스트 단독 실행 exit 1, 29개 중 27개 assertion 실패. publishAt=42가 HTTP 200 success=true와 생성 ID로 수락됐다 (red-http.log, red-http.xml). 컴파일 가능한 모델/DTO 필드와 typed assertion 추가 뒤 본 Task의 세 focused class 실행 exit 1, 38개 중 32개 assertion 실패. 예약 DB flush/clear/reload가 기대 UTC 대신 null, HTTP 예약 상태가 true 대신 false였다 (red-focused.log, red-focused-db.xml, red-focused-http.xml). 컴파일 오류를 RED로 계산하지 않았다. entity 기본값은 이 구조 단계에서 통과했다.
  • GREEN: ./gradlew --no-daemon test --tests 'kr.co.vividnext.sodalive.v2.api.admin.notice.AdminNoticeControllerIntegrationTest' --tests 'kr.co.vividnext.sodalive.notice.ServiceNoticeTest' --tests 'kr.co.vividnext.sodalive.notice.ServiceNoticeQueryRepositoryTest' exit 0, 39개 통과 (green-focused-retry.log, green-focused-*.xml). 첫 GREEN 시도는 도구 120초 제한으로 중단됐으며 성공으로 계산하지 않았다. 해당 프로세스 종료를 확인한 뒤 PID·log·exit 파일을 남기는 소유 runner로 재실행했다.
  • 실제 경계: 생략/null 즉시 생성과 생성 ID/envelope, 엄격한 KST 형식·달력·현재/과거·JSON 타입 거부, MySQL 상한 KST 2038-01-19 12:14:07 수락 및 다음 초 거부, 익명 HTTP 401와 비ADMIN의 기존 HTTP 200 권한 오류, 제목/본문 공백 오류를 확인했다. TIMESTAMP(0)을 명시한 H2에서 KST 2026-11-01 09:00:00 생성값을 flush/clear/reload하여 UTC 2026-11-01T00:00:00, nano=0과 정확히 비교했다.
  • SQL: 작성한 MySQL additive SQL을 로컬 H2 MySQL mode에 실제 실행하여 기존 row의 NULL/FALSE, 두 컬럼 COMMENT·nullability, is_scheduled NOT NULL 및 기존 created_at 소수초 보존을 확인했다. H2 기본 TIMESTAMP 정밀도를 MySQL과 같다고 주장하지 않으며 생성 저장 fixture는 별도로 TIMESTAMP(0)에 고정했다. 운영 DB 접속·SQL 실행·backfill은 하지 않았다.
  • 최종 회귀: ./gradlew --no-daemon test --tests 'kr.co.vividnext.sodalive.notice.*' --tests 'kr.co.vividnext.sodalive.v2.api.notice.*' --tests 'kr.co.vividnext.sodalive.v2.api.admin.notice.AdminNoticeControllerIntegrationTest' ktlintCheck exit 0, 총 50개 통과(HTTP 35, DB 3, entity 1, 기존 latest HTTP 11), failure/error/skip 모두 0 (final-regression.log, final-{http,db,entity,latest}.xml). Kotlin 컴파일 및 ktlintCheck 통과, git diff --check 공백 오류 없음.
  • 미실행: LSP 도구가 없어 compiler/lint로 대체했다. 단일 notice 생성 단계이며 공통 보안·오류·설정 변경이 없어 전체 test/build와 release 감사는 실행하지 않았다. 시간대는 사용자 UTC 확인을 전제로 했으며 운영 연결 실확인·배포는 제외했다. 공개 필터 전 단계이므로 현재 산출물만으로 운영 예약 쓰기를 활성화하면 안 된다.

P1-T3 실행 및 Phase 1 검증 증거: 2026-10-30

  • 상태: P1-T3 완료, Phase 1 검증 항목 충족. 공개 query·관리자 GET·client-prompt.md는 변경하지 않았고 전체 Phase 상태/다음 Goal 전환은 별도 판정으로 남겼다.
  • 기준선: 부모 실행에서 기존 notice 4개 class 50개 통과 및 생성 단계 리뷰 승인을 확인한 채 재개했다. P1-T2 RED는 반복하지 않았다.
  • 증거 루트: /var/folders/yh/8xsbvpsj5wg2qnxzxdp11_gm0000gn/T/opencode/notice-p1t3.FfcAgS. stage-report.md에 변경 파일·명령·실제 HTTP 발췌·DB 정밀도 대조표·경합 결과를 기록했다. 각 phase의 .command.json, .runner.pid, .gradle.pid, .log, .exit.json 및 notice JUnit XML을 보존했다. TMPDIR와 Node/Bun cache 경로를 이 루트로 격리한 단일 detached Gradle을 순차 실행했다.
  • HTTP RED: red-http exit 1, 80개 중 41개 assertion 실패. {"id":22,"title":"반영 금지","content":"반영 금지","publishAt":42}가 HTTP 200 success=true, data={}로 수락됐고 schedule-only/null은 update-required로 거부됐다. 컴파일 오류·timeout을 RED 또는 성공으로 계산하지 않았다.
  • 잠금 RED→GREEN: Task 1.3의 두 class 명령으로 red-lock exit 1(HTTP 80 통과, 동시성 9 실패) → green-focused exit 0(89개 통과). 첫 실패는 SELECT FOR UPDATE 대기 부재 assertion이다. CountDownLatch/TransactionTemplate과 H2 실제 BLOCKER_ID를 사용해 sleep·repository mock 없이 순서를 고정했다. 모든 대기/future를 제한하고 finally에서 barrier/future/executor를 정리했다.
  • 실제 HTTP/DB: 미래 KST 2026-11-02 10:00:00 PUT은 HTTP 200 기존 성공 envelope와 UTC 2026-11-02T01:00:00, isScheduled=true를 저장했다. 명시 null PUT은 HTTP 200 {"success":true,"message":"수정되었습니다.","data":{},"errorProperty":null}이고, 잘못된 시각·이미 공개/즉시/비활성 공지의 시각 변경은 기존 HTTP 200 invalid_request envelope다. 잘못된 예약에 동반한 제목·본문은 부분 반영되지 않았다. omitted 시각과 공개 후 title/content-only 수정 및 기존 ID/수정 필드 누락 오류도 보존했다.
  • 정밀도 대조: UTC 2026-10-30T00:00:00과 2026-10-30T23:59:59 각각에 nanos 0/123456789/999999999를 입력했다. TIMESTAMP(0) metadata=0인 DB에서 flush/clear/reload한 결과는 각각 정확히 2026-10-30T00:00:00과 2026-10-30T23:59:59, nano=0이다. 다음 초·다음 날짜로 반올림하지 않고 original createdAt=2026-10-29T14:59:59.123456을 보존했다. P2 관리자 응답 시각과 DB 비교는 미검증/pending이다.
  • 경합 DB: 대기 중 old publishAt 정각 도달 시 fresh now 검사로 거부, 즉시 전환 선행 후 재예약 거부, 제목/재예약 어느 순서에서도 최신 값 보존, 삭제와 전환/재예약 어느 순서에서도 isActive=false와 마지막 유효 예약 필드를 보존했다. 삭제 후 제목-only 수정도 삭제를 되돌리지 않았다.
  • 최종 회귀: Phase 1에 기재한 5개 class + ktlintCheck 명령 final-regression exit 0, 111.0초. HTTP 80, 동시성 9, DB 3, entity 1, 기존 latest HTTP 11 = 104개, failure/error/skip 0. Kotlin compiler와 main/test/script ktlint 통과. LSP 도구는 없어 compiler/lint로 대체했다.
  • 미실행/제한: 전체 suite/build와 독립 최종 리뷰는 이 단계 범위 밖으로 남겼다. 운영 DB·외부 서비스 접속, migration, commit/worktree, 전역 인증·오류·timezone/BaseEntity 변경 및 신규 dependency는 없다. 공개 필터가 아직 없으므로 이 산출물만으로 운영 예약 쓰기를 활성화하면 안 된다.

P2-T1 및 P2-GATE 실행: 2026-10-30

  • 상태: P2-T1/P2-GATE 검증 충족. 중단된 작업의 기존 테스트·P1 구현을 유지하고 공개 query와 ADMIN GET만 마무리했다. 상단 전체 Phase/활성 Goal 및 P3 판정은 부모 작업이 갱신한다.
  • 증거 루트: /var/folders/yh/8xsbvpsj5wg2qnxzxdp11_gm0000gn/T/opencode/p2-notice.TWFcpR. stage-report.md, red.log/red.exit, red-summary.json, red-TEST-*.xml, green-final.log/green-final.exit, green-final-summary.json, green-final-TEST-*.xml을 보존했다. 모든 명령에서 TMPDIR 및 Node/Bun cache 경로를 이 소유 루트로 지정했고 Gradle은 순차·offline 실행했다.
  • RED: 위 Task 2.1의 4개 class에 --offline --no-daemon을 붙여 실행, exit 1. 112개 중 25개 실패이며 공개 count 4→9, now 호출 1→0, 예약 노출·날짜·정렬 및 ADMIN GET 404 등 24개 assertion 실패를 확인했다. 나머지 홈 1개는 직접 service 호출의 request-scoped LangContext fixture 오류로 RED 증거에서 제외하고 테스트에 request scope를 설정·finally 정리했다. compiler 오류를 RED로 계산하지 않았다.
  • 구현: 공개 predicate를 pagination/limit 전에 적용하고 유효 시각/id 역순과 scheduled 여부별 KST 날짜를 사용한다. public 서비스는 now를 한 번 얻으며 count/list가 공유한다. ADMIN은 별도 active-only projection·read-only service·ADMIN controller로 미래 예약도 조회한다. H2 테스트 formatter에 %s→ss만 추가했다. 전역 auth/timezone/formatter/기존 created_at은 변경하지 않았다.
  • 중간 검증: green.log exit 1은 기존 P2 초안의 인자 줄바꿈 ktlint 위반으로 test task 전에 중단됐다. 이를 고친 뒤 재실행했으며 이 중간 실행은 테스트 통과 증거가 아니다.
  • GREEN: ./gradlew --offline --no-daemon --continue test --tests 'kr.co.vividnext.sodalive.v2.api.notice.LatestNoticeControllerIntegrationTest' --tests 'kr.co.vividnext.sodalive.v2.api.admin.notice.AdminNoticeControllerIntegrationTest' --tests 'kr.co.vividnext.sodalive.notice.ServiceNoticeQueryRepositoryTest' --tests 'kr.co.vividnext.sodalive.content.main.tab.home.AudioContentMainTabHomeServiceIntegrationTest' --tests 'kr.co.vividnext.sodalive.notice.ServiceNoticeConcurrencyIntegrationTest' --tests 'kr.co.vividnext.sodalive.notice.ServiceNoticeTest' ktlintCheck exit 0, 3분 37초. 122개(ADMIN/write HTTP 92, latest HTTP 13, query/DB 6, 실제 홈 1, 동시성 9, entity 1), failure/error/skip 0. Kotlin compiler와 main/test/script ktlint 통과. P1 기존 104개를 포함하며 새 full-field assertions를 완화하지 않았다.
  • 실제 로컬 HTTP: RANDOM_PORT Tomcat + 실제 JWT·H2·embedded Redis로 검증했다. 익명 latest HTTP 200의 array/object/null, 모든 역할에서 future 제외, ADMIN 목록의 active future 포함·0-based page·빈 페이지·전체 DTO를 확인했다. 관리자 GET은 익명 401, USER HTTP 200 success=false,message="You do not have permission.", ADMIN HTTP 200 성공 envelope다. 실제 /v2/audio-content/main/home도 HTTP 200에서 공개 공지 latestNotice={id:1,title:"공개 공지",content:"홈 전체 본문",date:"2026-10-30"}를 반환했다. notice와 홈 의존성 mock은 없다.
  • 정밀도: PUT-null의 UTC 2026-10-30T00:00:00 및 23:59:59 각각 nanos 0/123456789/999999999를 flush/clear/reload했다. DB의 정확한 초 단위 값과 관리자 GET KST 2026-10-30 09:00:00/2026-10-31 08:59:59를 UTC로 복원한 값이 6건 모두 일치했다. 기존 createdAt=2026-10-29T14:59:58.123456 보존과 예약 자연 공개 뒤 isScheduled=true도 검증했다.
  • 제한: Kotlin LSP 미설치·기존 설치 거부 상태여서 compiler/lint로 대체했다. 전체 suite/build·독립 최종 리뷰·P3는 부모 작업 담당이다. 운영 DB/migration/배포·의존성 추가·commit/stage/push/worktree·client-prompt.md 변경은 없다. JVM 목록과 각 환경의 TMPDIR를 대조해 이 소유 루트의 실행 중 Gradle/worker/Kotlin daemon이 없음을 확인했다.

추적성

P1-T1 2차 실행: 사용자 확인으로 완료

  • 사용자 확인: "서버 UTC, DB UTC로 되어 있다. publish_at은 네가 말한대로 처리".
  • 결정: 새 publish_at은 초 단위 TIMESTAMP로 독립 정의하며 기존 created_at 정밀도 복제 조건을 제거했다. 기존 DDL 미조회 사실과 이전 차단 기록은 유지한다.
  • 상태: P1-T1 완료, P1-T2 구현 시작 가능. 운영 DB 적용과 연결 UTC 실검증은 배포 전 별도 확인한다.
요구사항 구현 Goal 자동 검증 수동/외부 검증
NS-001, NS-003 P1-T2, P1-T3, P3-GATE admin integration, 기존 write response anonymous/non-admin/admin mutation auth
NS-004 P1-T3, P3-GATE omitted/null/future/schedule-only 및 공개 경계 concurrency integration 별도 UI 없음
NS-005, NS-006, NS-007 P2-T1, P2-GATE 경계 전/정각/후, sort tie, 표시 날짜와 full fields ADMIN token public query와 경계 이후 재조회
NS-008 P2-T1, P2-GATE admin page와 visibility integration 익명/비ADMIN 거부, ADMIN active scheduled 포함
NS-009 P1-T1, P1-T2, P1-T3, P1-GATE, P2-GATE 운영 정밀도에 맞춘 DB round-trip nanos 0/123456789/999999999 및 관리자 응답 완전 일치 실제 DDL/JDBC/DB timezone 권위 자료 확인

검증 기록

2026-10-30 최종 구현 검증

  • 기능 상태: P1-T2/P1-T3/P2-T1 및 Phase 1~2 완료. 관리자 등록·수정·별도 조회, 공개 목록·최신·홈 필터, 정렬·날짜, UTC/KST·초 정밀도, 동시 수정/삭제를 구현했다. 전체 회귀 성공 증거가 없으므로 P3-GATE는 blocked다.
  • 전체 suite: ./gradlew --offline --no-daemon test ktlintCheck bootJar --continue는 1,200,000ms 제한 초과로 종료됐다. bootJar 실행은 완료됐으나 test 종료 결과가 없어 전체 성공을 주장하지 않는다. timeout 후 잔여 Gradle 29742/test worker 29822의 종료를 확인했고 반복 전체 실행은 하지 않았다. 원인 stack 수집 시에는 이미 프로세스가 종료되어 원인을 특정하지 못했다.
  • 최종 focused: P3-GATE에 적힌 실행 명령이 BUILD SUCCESSFUL in 4m 9s, exit 0. 최신 JUnit XML 기준 ADMIN/write HTTP 92, latest HTTP 13, query/DB 6, 홈 1, 동시성 9, entity 1 = 122개, failure/error/skip 모두 0. 실제 Tomcat·JWT·H2·embedded Redis로 전체 envelope와 DB 재조회 상태를 검증했다. 이는 MockMvc만의 결과가 아니라 실제 localhost HTTP 요청 결과다.
  • 코드 품질: Kotlin 컴파일, ktlintCheck, 패키징 성공. lsp_diagnostics 확인 결과 kotlin-ls 미설치 및 기존 설치 거부로 LSP 진단은 불가했다. 공통 보안 설정·BaseEntity·기존 created_at·dependency는 변경하지 않았다. 테스트용 MySQL 날짜 함수의 %s만 보강했다.
  • 최종 리뷰: Oracle 결과 회수 도구 오류로 사용 가능한 보고서가 없어 단일 대체 reviewer가 전체 diff·신규 파일·계약·테스트 근거를 검토했다. 결과 APPROVED, 차단 코드 결함 없음. 첫 결과를 승인으로 간주하거나 중복 리뷰 성공으로 계산하지 않았다.
  • 관리자 프롬프트: client-prompt.md에 API 요청/응답·생략/null·권한·날짜 형식만 제공했다. 서버 내부 구현·DB 설정은 포함하지 않았다.
  • 배포 주의: 신규 컬럼 수동 적용 및 연결 UTC 확인 → 모든 앱 reader 교체 → 관리자 예약 사용 순서를 지킨다. 운영 DB 접속/SQL 실행/배포/커밋·push는 하지 않았다.
  • 증거 보존: 단계별 임시 루트의 실행 요약은 이 문서에 누적돼 있다. 임시 로그·runner·cache는 정리 대상으로 전환했으며 원래 기록은 삭제하지 않는다. 최신 실제 HTTP·DB 증거는 build/test-results/test/TEST-*.xml, HTML 결과는 build/reports/tests/test/index.html에 있다. 임시 경로는 영구 산출물 링크가 아니다.
  • 정리 완료: 실행 프로세스 종료와 각 루트의 소유 보고서/명령 파일을 확인한 뒤 notice-impl.6a7hk3, notice-p1t2.lPSBOW, notice-p1t3.FfcAgS, notice-p2.8nhNQr, p2-notice.TWFcpR 다섯 임시 루트만 제거하고 각 경로의 부재를 확인했다. 기존 IDE/다른 프로젝트 JVM·공유 cache·다른 임시 디렉터리는 변경하지 않았다.

2026-10-30 문서 작성·검토

  • 무엇을: PRD와 구현 계획 두 파일만 작성했다. 실제 구현·SQL·테스트 파일은 생성하지 않았고, 향후 구현 체크박스는 모두 미완료다.
  • 왜: 인터뷰로 확정한 공개 정책을 API·DB·구현 Goal·수용 테스트로 연결하기 위해 문서 전용 검증을 수행했다.
  • 어떻게: 두 문서 전체와 관련 소스를 대조했다. 독립 builder check 결과 중요한 미결 사항이 없었고, 작성자 검토에서 홈 테스트 대상을 실제 공지 조립 서비스로 수정하고 저장·응답의 초 단위 계약과 검증 소유 Task를 구체화했다.
  • 공백 검사: GIT_MASTER=1 git diff --no-index --check /dev/null docs/20261030_공지사항예약공개/prd.md 및 같은 명령의 plan-task.md 검사에서 공백 오류가 없었다. rg의 완료 체크박스·trailing whitespace·미해결 placeholder 검사도 출력이 없었다.
  • 변경 범위: GIT_MASTER=1 git -c core.quotePath=false status --short --untracked-files=all에서 이 디렉터리의 두 Markdown 파일만 신규 파일로 확인했다. Gradle, 테스트, 빌드, live HTTP, 운영 DB 검증은 문서 전용 요청이므로 실행하지 않았다. 운영 DDL·정밀도·시간대는 P1-T1의 외부 확인 차단 조건이다.

2026-10-30 커밋 전 재검증

  • 사용자 /commit-with-validation 요청으로 기존 변경의 커밋을 승인받았다. 전역 공용 절차와 저장소 커밋 규칙을 확인하고 변경 파일 전체를 점검했다.
  • 실행: ./gradlew --offline --no-daemon test --tests 'kr.co.vividnext.sodalive.notice.*' --tests 'kr.co.vividnext.sodalive.v2.api.notice.*' --tests 'kr.co.vividnext.sodalive.v2.api.admin.notice.*' --tests 'kr.co.vividnext.sodalive.content.main.tab.home.AudioContentMainTabHomeServiceIntegrationTest' ktlintCheck bootJar --continue.
  • 결과: exit 0, BUILD SUCCESSFUL in 1m 31s. test task는 실제 재실행됐으며 최신 JUnit XML의 6개 class 합계 122개, failure/error/skip 모두 0이다. 컴파일·lint·bootJar는 변경 없음으로 UP-TO-DATE였다. git diff --check도 통과했다.
  • 전체 suite는 이번 커밋 검증에서 재실행하지 않았다. 구현 변경 없이 기존 산출물을 커밋하는 범위이므로 직접 영향 notice/home 회귀를 검증했다. 이전 전체 suite timeout과 P3-GATE blocked 상태는 유지한다. 운영 DB 검증·DDL 적용·배포는 수행하지 않았다.