[feat][queue-service] program Kafka 연동 + ProgramMeta Aggregate 추가#30
Conversation
📝 Walkthrough워크스루이 PR은 프로그램 취소 시 대기 토큰을 정리하기 위한 도메인 이벤트 기반 아키텍처를 도입합니다. 프로그램 메타 집계를 새로 만들어 프로그램 생명주기를 관리하고, Kafka 소비를 통해 외부 이벤트를 처리하며, 도메인 이벤트로 큐 토큰 정리를 연계합니다. 동시에 큐 토큰 코드베이스를 전용 패키지로 구조화합니다. 변경사항프로그램 메타 도메인 및 취소 기반 토큰 정리
예상 코드 리뷰 노력🎯 4 (Complex) | ⏱️ ~60 minutes 관련 이슈
관련 PR
제안된 라벨
제안된 검토자
🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
Actionable comments posted: 8
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (3)
src/main/java/com/firstticket/queueservice/queuetoken/domain/QueueToken.java (1)
55-68:⚠️ Potential issue | 🟠 Major | ⚡ Quick win
restore/admit에서entryToken불변식 검증이 빠져 있습니다.Line 74-78에서
null/blank entryToken으로ADMITTED전이가 가능하고, Line 55-68restore도 같은 조합을 허용합니다. Aggregate가 스스로 상태 불변식을 보장하도록 검증을 추가해 주세요.제안 수정안
public static QueueToken restore( @@ Objects.requireNonNull(issuedAt, "IssuedAt은 필수입니다"); Objects.requireNonNull(status, "TokenStatus는 필수입니다"); + if (status == TokenStatus.ADMITTED && (entryToken == null || entryToken.isBlank())) { + throw new IllegalArgumentException("ADMITTED 상태의 entryToken은 비어 있을 수 없습니다"); + } return new QueueToken(id, userId, programId, issuedAt, status, entryToken); } @@ public void admit(String entryToken) { ensureWaiting(); + if (entryToken == null || entryToken.isBlank()) { + throw new IllegalArgumentException("entryToken은 비어 있을 수 없습니다"); + } this.status = TokenStatus.ADMITTED; this.entryToken = entryToken; }As per coding guidelines, "도메인 객체가 자신의 불변식(invariant)을 스스로 보장하는지 확인 (생성자/정적 팩토리에서 검증)".
Also applies to: 74-78
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@src/main/java/com/firstticket/queueservice/queuetoken/domain/QueueToken.java` around lines 55 - 68, The restore and admit flows in QueueToken allow a null/blank entryToken when TokenStatus == ADMITTED; add invariant checks inside the QueueToken static factory restore (and the admit method/constructor path) to validate entryToken is non-null and not blank whenever status == TokenStatus.ADMITTED (throw NullPointerException/IllegalArgumentException with a clear message); update the QueueToken class so that both restore(QueueTokenId, UserId, ProgramId, IssuedAt, TokenStatus, String) and the admit transition method enforce the same validation before constructing or mutating the aggregate.src/main/java/com/firstticket/queueservice/queuetoken/config/QueueProperties.java (1)
16-32:⚠️ Potential issue | 🟠 Major | ⚡ Quick win
waitingTtl양수 검증이 없어 잘못된 설정이 그대로 반영됩니다.Line 23은
@NotNull만 적용되어PT0S/음수 TTL이 통과할 수 있습니다. 이 경우 토큰이 즉시 만료되거나 비정상 동작할 수 있어요. 생성자에서 양수 검증을 추가해 주세요.제안 수정안
`@Validated` `@ConfigurationProperties`(prefix = "queue.token") public record QueueProperties( @@ `@NotNull` Duration waitingTtl, @@ `@Min`(1) int admissionBatchSize ) { + public QueueProperties { + if (waitingTtl == null || waitingTtl.isZero() || waitingTtl.isNegative()) { + throw new IllegalArgumentException("queue.token.waiting-ttl must be positive"); + } + } }🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@src/main/java/com/firstticket/queueservice/queuetoken/config/QueueProperties.java` around lines 16 - 32, QueueProperties currently only has `@NotNull` on waitingTtl, allowing Duration.ZERO or negative durations; add a compact record constructor in QueueProperties that validates waitingTtl is non-null and strictly greater than Duration.ZERO (e.g., if (waitingTtl == null || waitingTtl.compareTo(Duration.ZERO) <= 0) throw new IllegalArgumentException("waitingTtl must be positive")), leaving admissionBatchSize validation as-is; place the constructor inside the record to enforce the invariant at creation.src/main/java/com/firstticket/queueservice/queuetoken/domain/vo/ProgramId.java (1)
1-28:⚠️ Potential issue | 🟠 Major | ⚡ Quick win공유 Value Object 패키지로
ProgramId추출 필요
queuetoken과programmeta두 aggregate에서 동일한 내용의ProgramIdVO가 중복 정의되었습니다. 두 파일 모두 record 구조, Compact Constructor 검증, 팩토리 메서드가 동일합니다.현재 구조에서 각 aggregate가 자신의 로컬
ProgramId를 사용하므로 즉각적인 타입 충돌은 없으나, DDD에서 여러 aggregate가 동일한 외부 개념(program-service의 Program)을 참조할 때는 Shared Kernel 패턴을 적용해야 합니다. 이렇게 하면:
- 명시적 공유 경계(Shared Kernel)를 표현
- 한 곳에서 변경하면 모든 aggregate에 자동 반영
- 향후 aggregate 간 통신 시 타입 안정성 확보
src/main/java/com/firstticket/queueservice/shared/vo/ProgramId.java로 추출하고, 각 aggregate의 정의를 제거한 후 shared 버전을 import하도록 수정하세요.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@src/main/java/com/firstticket/queueservice/queuetoken/domain/vo/ProgramId.java` around lines 1 - 28, Extract the duplicated ProgramId record into a shared VO package and update usages: create a single ProgramId record in the shared package (retain the record declaration, compact constructor null check, static factory methods of(UUID) and fromString(String), and asString()), remove the duplicate ProgramId files from queuetoken and programmeta, and change those modules to import the shared ProgramId; ensure references to ProgramId.of(...), ProgramId.fromString(...), and ProgramId#asString() continue to compile and adjust package declarations accordingly.
🧹 Nitpick comments (1)
src/main/resources/db/migration/V1__create_inbox_table.sql (1)
1-7: ⚡ Quick win
processed_at컬럼에 인덱스 추가를 권장합니다.
p_inbox테이블은 Kafka 메시지 중복 제거를 위한 inbox 패턴 구현으로 보입니다. 향후 오래된 레코드를 정리하거나 처리 이력을 조회할 때processed_at기반 쿼리가 필요할 가능성이 높습니다.📊 제안하는 인덱스 추가
CREATE TABLE p_inbox ( message_id UUID NOT NULL, processed_at TIMESTAMP NOT NULL, CONSTRAINT pk_inbox PRIMARY KEY (message_id) ); + +CREATE INDEX idx_inbox_processed_at ON p_inbox (processed_at);이 인덱스는 다음과 같은 쿼리를 효율적으로 지원합니다:
- 주기적인 레코드 정리:
DELETE FROM p_inbox WHERE processed_at < ?- 처리 이력 모니터링:
SELECT COUNT(*) FROM p_inbox WHERE processed_at > ?🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@src/main/resources/db/migration/V1__create_inbox_table.sql` around lines 1 - 7, Add an index on the processed_at column of the p_inbox table to speed time-based deletes and queries: update the migration that creates p_inbox (V1__create_inbox_table.sql) to create an index on processed_at (e.g., CREATE INDEX idx_p_inbox_processed_at ON p_inbox(processed_at)) so DELETE FROM p_inbox WHERE processed_at < ? and SELECT ... WHERE processed_at > ? run efficiently.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In
`@src/main/java/com/firstticket/queueservice/programmeta/application/dto/CancelProgramCommand.java`:
- Around line 15-19: CancelProgramCommand.of currently calls
ProgramStatus.valueOf(status) (and CreateProgramMetaCommand does the same),
which throws IllegalArgumentException on unexpected casing/format from Kafka;
update these constructors to validate and normalize incoming status: either
implement a ProgramStatusParser.parse(String) that normalizes (trim, upper-case,
maps known variants) and returns an Optional/throws a clear domain exception, or
wrap ProgramStatus.valueOf(status) in a try-catch converting
IllegalArgumentException into a domain-level exception (e.g.,
InvalidProgramStatusException) with a clear message; update
CancelProgramCommand.of and CreateProgramMetaCommand to use
ProgramStatusParser.parse(...) or the try-catch path so external message formats
are validated at the application boundary.
In
`@src/main/java/com/firstticket/queueservice/programmeta/application/dto/CreateProgramMetaCommand.java`:
- Around line 21-32: CreateProgramMetaCommand.of (and similarly
CancelProgramCommand) currently calls ProgramStatus.valueOf(status) which throws
on null/blank/lowercase input; replace this with a small parser that normalizes
the input (null-check, trim(), toUpperCase()) and then calls
ProgramStatus.valueOf, and throw an explicit IllegalArgumentException with a
clear message when status is null/blank or not a valid enum; update
CreateProgramMetaCommand.of to call the new parseStatus helper instead of
ProgramStatus.valueOf(status), do the same change in CancelProgramCommand, and
apply the identical normalization/validation in
RedisProgramMetaRepository.deserialize() where status is parsed.
In
`@src/main/java/com/firstticket/queueservice/programmeta/application/ProgramMetaService.java`:
- Around line 29-39: handleCreated currently unconditionally creates and saves a
ProgramMeta, which allows a delayed program.created event to overwrite an
existing CANCELLED (or otherwise final) entity; update handleCreated to first
load existing via programMetaRepository.findById(command.programId()), and if a
ProgramMeta exists then either no-op or enforce your state-transition rule
(e.g., if existing.getStatus() == ProgramStatus.CANCELLED then return/do not
save) instead of overwriting; only call ProgramMeta.of(...) and
programMetaRepository.save(...) when no existing entity is present or when the
transition is allowed.
In
`@src/main/java/com/firstticket/queueservice/programmeta/domain/ProgramMeta.java`:
- Around line 36-43: Add invariant checks to the ProgramMeta factory/ctor so
invalid state cannot be created: in ProgramMeta.of(...) and the ProgramMeta
constructor validate that status is not null and that openAt is not after
closeAt (openAt.compareTo(closeAt) <= 0), and throw an appropriate runtime
exception (e.g., IllegalArgumentException or NullPointerException) with a clear
message when violated; ensure all other overloads/factories that construct
ProgramMeta perform the same checks so events cannot create a ProgramMeta with
null status or openAt > closeAt.
In
`@src/main/java/com/firstticket/queueservice/programmeta/infrastructure/messaging/ProgramKafkaConsumer.java`:
- Around line 45-47: In ProgramKafkaConsumer, replace the broad catch(Exception
e) handlers around the record processing (e.g., the block that logs
"program.created 처리 실패. record={}", and similar catches at the other locations)
with exception classification: detect non-retryable errors (JSON parse/schema
validation exceptions, deserialization errors) and for those call ack/commit or
forward the record to a DLT handler, logging the detailed error; for retryable
transient errors (network/timeouts from external calls) allow the exception to
propagate or trigger controlled retry logic with backoff and then nack/requeue
as appropriate; ensure external calls used in the processing have explicit
timeouts and retry/recovery policies; update the catch sites to log full error
details and either ack+skip/DLT for non-retryable or rethrow/retry for transient
errors.
In
`@src/main/java/com/firstticket/queueservice/programmeta/infrastructure/redis/RedisProgramMetaRepository.java`:
- Around line 131-142: The deserialize method currently only catches
JsonProcessingException, but calls like UUID.fromString, ProgramStatus.valueOf
and parseDateTime can throw runtime exceptions (IllegalArgumentException,
DateTimeParseException, NullPointerException) that would escape; update
deserialize in RedisProgramMetaRepository to catch those parsing/runtime
exceptions as well and rethrow them as the same IllegalStateException (or
another appropriate infra-level exception) with the original exception chained
and a clear message so malformed/partial data does not propagate as unchecked
runtime errors from ProgramMeta.of / parseDateTime.
- Line 133: In RedisProgramMetaRepository replace the unchecked call
objectMapper.readValue(json, Map.class) with a typed readValue using Jackson's
TypeReference to preserve generic information (i.e., readValue(json, new
TypeReference<Map<String,String>>(){})); import
com.fasterxml.jackson.core.type.TypeReference if necessary and update the
Map<String,String> data declaration to remove the unchecked warning.
In
`@src/main/java/com/firstticket/queueservice/queuetoken/infrastructure/redis/RedisQueueTokenRepository.java`:
- Around line 415-447: deleteAllByProgramId() currently collects
userProgramKeysToDelete and calls redisTemplate.delete(...) without verifying
that the stored reverse-index value hasn't changed, which can orphan
concurrently created tokens; fix by recording the expected token id when
scanning (use hashOps.get(tokenKey, FIELD_TOKEN_ID) and build a
Map<String,String> expectedUserProgramValue keyed by the constructed
userProgramKey), then instead of blindly adding userProgramKeysToDelete call a
safe delete: either run a Lua script to atomically DEL only if the key's value
equals the expected token id, or iterate the map and perform a conditional
delete per key (read current value with
redisTemplate.opsForValue().get(userProgramKey) and only delete that
userProgramKey if it equals expected value); update references in this method
(scanKeys, tokenKey loop, userProgramKeysToDelete, programKey,
redisTemplate.delete) or replace the final redisTemplate.delete(...) with the
safe conditional/Lua delete logic.
---
Outside diff comments:
In
`@src/main/java/com/firstticket/queueservice/queuetoken/config/QueueProperties.java`:
- Around line 16-32: QueueProperties currently only has `@NotNull` on waitingTtl,
allowing Duration.ZERO or negative durations; add a compact record constructor
in QueueProperties that validates waitingTtl is non-null and strictly greater
than Duration.ZERO (e.g., if (waitingTtl == null ||
waitingTtl.compareTo(Duration.ZERO) <= 0) throw new
IllegalArgumentException("waitingTtl must be positive")), leaving
admissionBatchSize validation as-is; place the constructor inside the record to
enforce the invariant at creation.
In
`@src/main/java/com/firstticket/queueservice/queuetoken/domain/QueueToken.java`:
- Around line 55-68: The restore and admit flows in QueueToken allow a
null/blank entryToken when TokenStatus == ADMITTED; add invariant checks inside
the QueueToken static factory restore (and the admit method/constructor path) to
validate entryToken is non-null and not blank whenever status ==
TokenStatus.ADMITTED (throw NullPointerException/IllegalArgumentException with a
clear message); update the QueueToken class so that both restore(QueueTokenId,
UserId, ProgramId, IssuedAt, TokenStatus, String) and the admit transition
method enforce the same validation before constructing or mutating the
aggregate.
In
`@src/main/java/com/firstticket/queueservice/queuetoken/domain/vo/ProgramId.java`:
- Around line 1-28: Extract the duplicated ProgramId record into a shared VO
package and update usages: create a single ProgramId record in the shared
package (retain the record declaration, compact constructor null check, static
factory methods of(UUID) and fromString(String), and asString()), remove the
duplicate ProgramId files from queuetoken and programmeta, and change those
modules to import the shared ProgramId; ensure references to ProgramId.of(...),
ProgramId.fromString(...), and ProgramId#asString() continue to compile and
adjust package declarations accordingly.
---
Nitpick comments:
In `@src/main/resources/db/migration/V1__create_inbox_table.sql`:
- Around line 1-7: Add an index on the processed_at column of the p_inbox table
to speed time-based deletes and queries: update the migration that creates
p_inbox (V1__create_inbox_table.sql) to create an index on processed_at (e.g.,
CREATE INDEX idx_p_inbox_processed_at ON p_inbox(processed_at)) so DELETE FROM
p_inbox WHERE processed_at < ? and SELECT ... WHERE processed_at > ? run
efficiently.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Repository: first-ticket/coderabbit/.coderabbit.yaml
Review profile: CHILL
Plan: Pro Plus
Run ID: e2008114-aa38-4490-82bd-560801302c1b
📒 Files selected for processing (47)
build.gradlesrc/main/java/com/firstticket/queueservice/programmeta/application/ProgramMetaService.javasrc/main/java/com/firstticket/queueservice/programmeta/application/dto/CancelProgramCommand.javasrc/main/java/com/firstticket/queueservice/programmeta/application/dto/CreateProgramMetaCommand.javasrc/main/java/com/firstticket/queueservice/programmeta/application/dto/UpdateProgramTimeCommand.javasrc/main/java/com/firstticket/queueservice/programmeta/domain/ProgramMeta.javasrc/main/java/com/firstticket/queueservice/programmeta/domain/ProgramMetaRepository.javasrc/main/java/com/firstticket/queueservice/programmeta/domain/ProgramStatus.javasrc/main/java/com/firstticket/queueservice/programmeta/domain/event/ProgramEvents.javasrc/main/java/com/firstticket/queueservice/programmeta/domain/vo/ProgramId.javasrc/main/java/com/firstticket/queueservice/programmeta/infrastructure/event/ProgramEventsImpl.javasrc/main/java/com/firstticket/queueservice/programmeta/infrastructure/messaging/ProgramKafkaConsumer.javasrc/main/java/com/firstticket/queueservice/programmeta/infrastructure/messaging/payload/ProgramCancelledPayload.javasrc/main/java/com/firstticket/queueservice/programmeta/infrastructure/messaging/payload/ProgramCreatedPayload.javasrc/main/java/com/firstticket/queueservice/programmeta/infrastructure/messaging/payload/ProgramTimeUpdatedPayload.javasrc/main/java/com/firstticket/queueservice/programmeta/infrastructure/redis/RedisProgramMetaRepository.javasrc/main/java/com/firstticket/queueservice/queuetoken/application/QueueTokenService.javasrc/main/java/com/firstticket/queueservice/queuetoken/application/dto/CancelQueueTokenCommand.javasrc/main/java/com/firstticket/queueservice/queuetoken/application/dto/GetQueueTokenQuery.javasrc/main/java/com/firstticket/queueservice/queuetoken/application/dto/IssueQueueTokenCommand.javasrc/main/java/com/firstticket/queueservice/queuetoken/application/dto/QueueTokenResult.javasrc/main/java/com/firstticket/queueservice/queuetoken/config/JwtProperties.javasrc/main/java/com/firstticket/queueservice/queuetoken/config/QueueProperties.javasrc/main/java/com/firstticket/queueservice/queuetoken/domain/QueueToken.javasrc/main/java/com/firstticket/queueservice/queuetoken/domain/QueueTokenRepository.javasrc/main/java/com/firstticket/queueservice/queuetoken/domain/TokenStatus.javasrc/main/java/com/firstticket/queueservice/queuetoken/domain/exception/DuplicateTokenException.javasrc/main/java/com/firstticket/queueservice/queuetoken/domain/exception/InvalidTokenStateException.javasrc/main/java/com/firstticket/queueservice/queuetoken/domain/exception/QueueErrorCode.javasrc/main/java/com/firstticket/queueservice/queuetoken/domain/exception/TokenNotFoundException.javasrc/main/java/com/firstticket/queueservice/queuetoken/domain/vo/IssuedAt.javasrc/main/java/com/firstticket/queueservice/queuetoken/domain/vo/ProgramId.javasrc/main/java/com/firstticket/queueservice/queuetoken/domain/vo/QueueTokenId.javasrc/main/java/com/firstticket/queueservice/queuetoken/domain/vo/UserId.javasrc/main/java/com/firstticket/queueservice/queuetoken/infrastructure/event/ProgramCancelledEventListener.javasrc/main/java/com/firstticket/queueservice/queuetoken/infrastructure/jwt/EntryTokenIssuer.javasrc/main/java/com/firstticket/queueservice/queuetoken/infrastructure/redis/RedisQueueTokenRepository.javasrc/main/java/com/firstticket/queueservice/queuetoken/infrastructure/scheduler/AdmissionScheduler.javasrc/main/java/com/firstticket/queueservice/queuetoken/presentation/QueueSuccessCode.javasrc/main/java/com/firstticket/queueservice/queuetoken/presentation/QueueTokenController.javasrc/main/java/com/firstticket/queueservice/queuetoken/presentation/dto/QueueTokenResponse.javasrc/main/java/com/firstticket/queueservice/shared/event/ProgramCancelledEvent.javasrc/main/resources/application.ymlsrc/main/resources/db/migration/V1__create_inbox_table.sqlsrc/test/java/com/firstticket/queueservice/queuetoken/domain/QueueTokenTest.javasrc/test/java/com/firstticket/queueservice/queuetoken/infrastructure/redis/RedisQueueTokenRepositoryTest.javasrc/test/java/com/firstticket/queueservice/queuetoken/presentation/QueueTokenControllerTest.java
There was a problem hiding this comment.
Actionable comments posted: 1
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@src/test/resources/application-test.yml`:
- Around line 24-28: The YAML uses consumer.auto-startup which is not a
recognized Spring Boot property; change the key from consumer.auto-startup to
listener.auto-startup so the property becomes spring.kafka.listener.auto-startup
(move the auto-startup entry under the listener block alongside
missing-topics-fatal) to ensure the Kafka listener is actually disabled during
tests.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Repository: first-ticket/coderabbit/.coderabbit.yaml
Review profile: CHILL
Plan: Pro Plus
Run ID: ef9ad8f8-00fd-4e2f-894c-d694670ef0dc
📒 Files selected for processing (6)
build.gradlesrc/main/java/com/firstticket/queueservice/config/JpaConfig.javasrc/main/java/com/firstticket/queueservice/programmeta/application/ProgramMetaService.javasrc/main/java/com/firstticket/queueservice/programmeta/domain/ProgramMeta.javasrc/main/java/com/firstticket/queueservice/programmeta/infrastructure/redis/RedisProgramMetaRepository.javasrc/test/resources/application-test.yml
🚧 Files skipped from review as they are similar to previous changes (4)
- build.gradle
- src/main/java/com/firstticket/queueservice/programmeta/domain/ProgramMeta.java
- src/main/java/com/firstticket/queueservice/programmeta/infrastructure/redis/RedisProgramMetaRepository.java
- src/main/java/com/firstticket/queueservice/programmeta/application/ProgramMetaService.java
🌱 설명
program-service 의 도메인 이벤트를 Kafka 로 수신해 queue-service 의 program 상태 캐시 (ProgramMeta) 를 갱신합니다.
ProgramMeta 는 QueueToken 과 다른 생명주기 / 책임을 가지므로 별도 Aggregate 로 분리했으며, 두 Aggregate 는 도메인 이벤트로 통신합니다.
주요 변경
programmeta/Aggregate 신규 추가queuetoken/정정QueueTokenRepository.deleteAllByProgramId추가ProgramCancelledEventListener추가 (programmeta 이벤트 수신)shared/event/ProgramCancelledEvent추가 (두 Aggregate 공유)처리 흐름
📌 관련 이슈
💻 커밋 유형
📝 체크리스트
📚 추가 설명
미구현 / 미래 작업
deleteAllByProgramId의 SCAN 본질을 미래 인덱스 키로 개선 (성능)Summary by CodeRabbit
새로운 기능
개선사항