[API] 프로젝트 개요가 전체 diff 를 읽던 것을 97MB→20KB 로 · 목록에 커서 페이지네이션 [ #341 ] - #353
Merged
Conversation
배포 이력 조회 세 곳이 프로젝트의 전체 이력을 엔티티(28컬럼, TEXT 2개)로 읽은 뒤 메모리에서 골라 썼다. 정작 쓰는 것은 각각 11·6·7 컬럼이고, 두 곳은 "version_label 이 있는 LIVE" 한 줌뿐이다. 사용 기간에 비례해 선형으로 무거워지던 자리다. - 목록(getDeploymentHistories): DeploymentHistoryListView 11컬럼만. description TEXT 가 응답에 없는데도 매 행 실려 오던 것이 사라진다 - 버전/배포후보(getVersions·getDeploymentCandidates): version_label·LIVE 필터를 SQL 로 내렸다. 버전별 최신 1건 추리기는 이미 좁아진 행 위의 Map 한 번이라 그대로 둔다 - DomainBindingCommandService.resolveDeploymentUrl: 전체 이력 로드 → "최근 LIVE 1건" 전용 쿼리. 첫 LIVE 의 URL 이 비면 다음 LIVE 로 넘어가지 않는 기존 동작을 유지하려고 공백 판정은 호출부에 남겼다 응답 JSON 은 바뀌지 않는다. 상태·타깃·실패코드는 DB 도 응답도 문자열이라 중간의 enum 왕복만 없앴다. 정렬에 id 를 tiebreaker 로 붙인 것은 동작 고정이다 — triggered_at 이 DATETIME(초) 라 같은 초의 행 순서가 비결정적이었고, 프로젝트 개요가 그 첫 건을 "최신 배포" 로 쓰면서 findLatestByProjectId 와 어긋날 수 있었다. 전체 엔티티를 읽던 findByProjectIdOrderByTriggeredAtDesc 는 호출부가 없어져 지웠다 — 남겨두면 다음 사람이 같은 함정을 다시 밟는다. Claude-Session: https://claude.ai/code/session_01APAyBZVYxUZzZsVyEXy6Qr
6-3·6-4 의 무제한 목록에 상한을 두기 위한 공통 조각. 계약 호환이 설계의 전부다.
- 응답 본문은 손대지 않는다. 기존 목록 엔드포인트는 전부 JSON 배열을 그대로 내보내므로
{items, nextCursor} 로 감싸는 순간 FE 가 즉시 깨진다. 커서는 X-Qeploy-Next-Cursor
응답 헤더로만 싣고, 헤더를 모르는 기존 FE 는 예전과 똑같이 동작한다
- 브라우저는 노출 목록에 없는 응답 헤더를 JS 에 보여주지 않으므로 CORS 에
setExposedHeaders 한 줄이 필요하다(allowedHeaders 는 요청 헤더 쪽이라 무관)
- "더 있는지" 는 count 쿼리로 묻지 않고 limit+1 건을 읽어 판단한다 — 같은 조건을 두 번
훑는 비용인데, 이 작업의 목적 자체가 읽는 양을 줄이는 것이다
- 잘못된 커서는 400 으로 끊는다. 조용히 첫 페이지로 되돌리면 클라이언트는 자기 페이지
루프가 끝나지 않는 이유를 알 수 없다
Claude-Session: https://claude.ai/code/session_01APAyBZVYxUZzZsVyEXy6Qr
프로젝트 개요를 한 번 열면 그 프로젝트의 모든 diff 를 DB 에서 읽고 있었다.
getProjectChanges 가 ChangeEntity 를 통째로 로드하는데 diff_text 는 MEDIUMTEXT,
행당 최대 1MB 다(ChangeService 가 그 상한으로 잘라 저장한다). 그런데 응답 DTO
ChangeResult 에는 diff 가 없다 — 읽어서 그대로 버렸다. 개요·활동로그가 이 목록을 부른다.
ChangeSummaryView 프로젝션으로 바꿨다. 뷰의 컬럼 = ChangeResult 의 필드 13개이고,
diff_text 는 목록 경로에서 아예 SELECT 되지 않는다. 단건 조회(getDiff)는 그대로다.
6-4 의 상한·커서도 같은 두 메서드를 건드리므로 함께 넣는다:
GET /api/v1/projects/{projectId}/changes 에 ?limit=(1~500, 기본 200)·?after= 를 옵션으로
받는다. 파라미터가 없으면 최신 200건 + X-Qeploy-Next-Cursor 헤더다. 개요·활동로그가
부르는 내부 경로도 같은 200 상한을 쓴다 — 두 화면 모두 최신순 목록을 합쳐 보여주므로
잘리는 쪽은 활동로그 맨 아래고, 변경 건은 사용자 요청 1회당 최대 1건이라 200 이면
최근 200번의 작업을 덮는다.
정렬에 id 를 tiebreaker 로 붙였다. created_at 이 DATETIME(초) 라 같은 초의 행 순서가
비결정적이었는데, 커서 페이지네이션에서 그건 행을 건너뛰거나 두 번 주는 버그가 된다.
Claude-Session: https://claude.ai/code/session_01APAyBZVYxUZzZsVyEXy6Qr
세 목록이 응답에 실리지도 않는 비밀 컬럼을 행마다 AES 복호화하고 있었다. 전부 MEDIUMTEXT + @convert(AesEncryptor) 다. - 클라우드 연결 목록: secret_access_key·session_01APAyBZVYxUZzZsVyEXy6Qr·service_account_key_json 세 개를 읽어 평문을 얻고는 != null 세 개의 boolean 으로 바꿔 버렸다. 이제 쿼리가 is not null 만 묻는다 — 값이 아니라 널 여부만 보므로 off-page TEXT 본문을 읽지 않는다 - 환경변수 목록: 조회는 secret 변수의 값을 언제나 null 로 내보내는데(design D4) 그 평문을 읽어와 버렸다. 메타데이터를 env_value 없이 읽고, 평문은 secret=false 인 행에 대해서만 따로 한 번 더 읽는 두 단계로 나눴다 - 프로비저닝 DB 목록: password 는 조회 응답에 계약상 없는데(생성 직후 1회 노출만 별도 경로) 매 행 복호화됐다. 읽기 모델에 그 필드 자체를 두지 않았다 **기존 보장은 약해지지 않고 강해진다.** 예전에는 "평문을 읽어 응답 매핑에서 지운다" 였고, 매핑 한 줄이 틀리면 그게 유출이었다. 이제 비밀 평문은 목록 경로의 메모리에 올라오지 않는다 — 읽기 모델에 담을 필드가 없어 컴파일 단계에서 막힌다. 각 읽기 모델 javadoc 에 "여기에 비밀 필드를 추가하지 말 것" 을 근거와 함께 남겼고, EnvironmentVariableQueryServiceTest 에 "읽지 않는다" 와 "설령 읽혀도 안 내보낸다" 두 방어선을 각각 테스트로 못박았다. 응답 JSON 은 바뀌지 않는다. 상태·타깃류 문자열 컬럼은 DB 도 응답도 문자열이라 중간의 enum 왕복만 없앴다. 6-4 의 환경변수 목록 상한도 같은 메서드를 건드리므로 함께 넣는다: ?limit=(기본 200, 최대 500). 환경변수는 사람이 직접 정의하는 값이라 200 이면 정상 사용을 덮고, 정렬 키가 (scope, key) 라 id 커서와 맞지 않아 커서로 이어 받는 경로는 두지 않았다(상한 도달만 헤더로 알린다). Claude-Session: https://claude.ai/code/session_01APAyBZVYxUZzZsVyEXy6Qr
TaskStore#findActiveTask 는 (taskId, status) 두 값만 쓰는데 findActiveRuns 가 AgentRunEntity 를 통째로 읽었다. 그 엔티티에는 plan_json LONGTEXT 와 TEXT 8개 (summary·error·question·clarification_json·answered_clarification_json·input_value· failure_log·suggested_fix)가 붙어 있다. 대화를 열 때마다 도는 조회다. 프로젝션 인터페이스(ActiveRunView) 로 바꿨다. 쿼리 조건·정렬·Pageable 은 그대로고 반환 타입만 좁아진다 — 같은 파일의 다른 부분은 건드리지 않았다. Claude-Session: https://claude.ai/code/session_01APAyBZVYxUZzZsVyEXy6Qr
휴지통 대화마다 표시할 프로젝트를 개별 조회했다. 한 대화당 최대 3번이다 — 활성 프로젝트 조회, 없으면 원본 조회, 그래도 없으면 같은 저장소의 활성 프로젝트 조회. 대화 N 건이면 최대 3N 번이었다. 프로젝트 id 를 모아 한 번(삭제 여부를 가리지 않고 읽어 isDeleted 로 갈라 쓴다), 보정이 필요한 저장소를 모아 한 번, 총 2번으로 줄였다. 보정할 저장소가 없으면 두 번째 조회는 돌지 않는다 — 원본이 전부 살아 있는 흔한 경우다. 판정 순서는 예전 대화별 로직과 같다: 원본이 살아 있으면 그것 → 삭제됐으면 같은 저장소의 활성 프로젝트(최신) → 그것도 없으면 삭제된 원본 → 아예 없으면 "삭제된 프로젝트". 저장소 키를 소문자로 맞추는 것은 source_repository 컬레이션이 utf8mb4_unicode_ci 라 DB 가 대소문자 다른 값끼리 매칭해 주기 때문이다(기존 IgnoreCase 계약과 같은 근거). 대체 프로젝트 후보 정렬에 id tiebreaker 를 붙인 것은 updated_at 이 DATETIME(초) 라 같은 초의 순서가 비결정적이었기 때문이다. 응답 JSON 은 바뀌지 않는다. "조회 횟수가 대화 수에 비례하지 않는다" 와 세 갈래 판정을 각각 테스트로 못박았다. Claude-Session: https://claude.ai/code/session_01APAyBZVYxUZzZsVyEXy6Qr
세 곳이 대화를 N 건 읽어와 한 건씩 save/deleteById 했다. 프로젝트 삭제 한 번에 SELECT 1 + UPDATE N, 만료 청소 한 번에 SELECT 1 + DELETE N 이다. - trashConversationsForProject: 벌크 UPDATE 1회. softDelete 의 "이미 삭제됐으면 무시" 가드는 쿼리의 deleted = false 조건이 대신한다 - deleteConversationsForProject: 벌크 DELETE 1회 - purgeExpiredConversations: 벌크 DELETE 1회. 돌려주는 값은 지운 행 수로, 예전의 "찾은 행 수" 와 같다 동작이 같은 근거를 두 가지 확인했다. - updated_at: chat_sessions.updated_at 컬럼이 ON UPDATE CURRENT_TIMESTAMP 라 벌크 UPDATE 에서도 DB 가 채운다(@UpdateTimestamp 는 벌크 문장에서 돌지 않는다). 휴지통 목록이 updated_at 순이므로 이게 유지돼야 순서가 같다 - 연관 삭제: 벌크 DELETE 도 실제 SQL DELETE 이므로 chat_messages 의 ON DELETE CASCADE (V19)와 approvals·agent_runs 등의 ON DELETE SET NULL(이력 보존)이 예전과 똑같이 돈다 clearAutomatically 는 켜지 않았다. 영속성 컨텍스트를 비우면 같은 트랜잭션의 ProjectRepositoryAdapter#save 가 L1 캐시 히트에 기대는 낙관적 잠금 경로(그쪽 javadoc 의 Case A)를 잃는다 — version 이 null 인 Project 에서는 갱신 분실 방어가 실제로 약해진다. 이 세 메서드는 대화를 읽지 않으므로 비울 이유도 없다. 호출부가 없어진 findAllByUserIdAndProjectId·findAllByDeletedTrueAndDeletedAtLessThanEqual 는 지웠다. Claude-Session: https://claude.ai/code/session_01APAyBZVYxUZzZsVyEXy6Qr
한 대화의 메시지를 전부 내려주고 있었다. content 가 TEXT 라 대화가 길어질수록 한 번의
조회가 선형으로 무거워진다. 사용자 발화 1건마다 어시스턴트 메시지가 함께 쌓이는 구조라
(appendAssistant 호출 지점이 20곳 넘는다 — 계획 시작·승인 안내·스텝 진행·결과·배포 결과
등) 한 요청이 대략 5~9행을 만든다.
계약 변화(옵션 파라미터만 추가, 본문 모양 불변):
GET /api/v1/conversations/{conversationId}/messages?limit=&after=
- limit: 1~1000, 없으면 500. 500 이면 사용자 턴 55~100회를 덮는다 — 프로젝트 하나의 작업
세션으로는 넉넉하고, TEXT 한 페이지의 크기도 여기서 묶인다
- after: 직전 페이지의 마지막 message id(배타적). 정렬은 예전과 같은 message_id 오름차순
- 상한에 걸리면 응답 헤더 X-Qeploy-Next-Cursor 에 다음 커서. 헤더가 없으면 마지막 페이지
- 본문은 예전과 똑같은 JSON 배열이다. 헤더를 모르는 기존 FE 는 예전처럼 동작한다
커서를 created_at 이 아니라 message_id 로 잡은 이유는 #338 과 같다 — created_at 이
DATETIME(0) 이라 같은 초의 순서가 비결정적이고, 커서에서 그건 행을 건너뛰거나 두 번 주는
버그가 된다.
**남는 위험 한 가지**(FE 이슈로 올려야 한다): 파라미터 없는 호출은 오래된 것부터 500건을
준다. 한 대화가 500행을 넘기면 커서를 쓰지 않는 화면은 최신 메시지를 못 본다. 기존 동작
유지(오름차순)를 택한 결과이고, 기본값을 500 으로 잡은 것이 그 완충이다.
Claude-Session: https://claude.ai/code/session_01APAyBZVYxUZzZsVyEXy6Qr
무제한이던 두 목록에 상한을 둔다. 변경·환경변수 목록은 각각 6-1·6-5 커밋에 함께 들어갔고
(같은 메서드를 건드린다) 여기는 남은 둘이다.
계약 변화(옵션 파라미터만 추가, 본문 모양 불변):
- GET /api/v1/projects/{projectId}/approvals?limit=&after= (기본 200, 최대 500)
- GET /api/v1/projects/{projectId}/domains?limit=&after= (기본 200, 최대 500)
- 둘 다 최신순이므로 after 는 "그 항목보다 오래된 것" 을 뜻한다(id 내림차순 커서)
- 상한에 걸리면 응답 헤더 X-Qeploy-Next-Cursor. 본문은 예전과 똑같은 JSON 배열이다
기본값 근거. 승인은 사용자 요청 1회당 0~2건이라 200 이면 최근 100회 이상의 작업을 덮는다.
도메인은 사람이 직접 연결하는 것이라 한 프로젝트에 수십 개면 이미 비정상이고 200 은 그
훨씬 위다. 개요·활동로그가 부르는 내부 경로도 같은 상한을 쓰는데, 두 화면 모두 최신순
목록을 합쳐 보여주므로 잘리는 쪽은 활동로그 맨 아래다. 개요의 "현재 도메인" 선택도 최신순
목록에서 고르므로 상한에 걸려도 고르는 결과가 같다.
도메인 쪽은 무제한 조회(findByProjectIdOrderByCreatedAtDesc)를 그대로 남겼다 — 배포·도메인
로직 네 곳이 그걸 "프로젝트의 전체 도메인" 으로 쓰고 있고 남의 구역이다. 사용자에게
내보내는 목록만 새 메서드를 쓴다.
Claude-Session: https://claude.ai/code/session_01APAyBZVYxUZzZsVyEXy6Qr
U6 의 완료 기준을 테스트로 옮긴 것이다. ListProjectionResponseIdentityTest — 실 MySQL 에 행을 심고, 예전 경로(엔티티를 통째로 읽어 매핑)와 새 경로(프로젝션/전용 쿼리)의 결과를 각각 JSON 으로 직렬화해 문자열 비교한다. 예전 매핑은 테스트 안에 그대로 옮겨 적어 계약을 리터럴로 고정했다 — 본문 코드가 바뀌면 테스트가 같이 바뀌어 버리는 것을 막는 것이 요점이다. 6-1·6-2(3개 응답)·6-5(3개 목록)· 6-6·6-4(상한 아래에서는 커서가 안 붙는다)를 덮는다. 비밀 컬럼을 다루는 목록에는 동일성에 더해 "평문이 응답 JSON 어디에도 없다" 를 붙였다. 프로젝션이 실수로 비밀 컬럼을 포함하면 그건 성능 회귀가 아니라 유출이므로, 그렇게 잡히게 한다. 비밀이 아닌 값은 예전처럼 그대로 나가는 것도 같이 확인한다. ConversationBulkCleanupTest — 6-8 의 벌크 문장은 JPA 생애주기를 타지 않으므로, 예전과 같게 남아야 하는 두 가지를 DB 에 직접 물어본다. ① updated_at 이 여전히 갱신되는지(근거는 ON UPDATE CURRENT_TIMESTAMP 뿐이고, 휴지통 목록 정렬이 거기 달려 있다) ② 메시지가 ON DELETE CASCADE 로 지워지고 승인 이력은 ON DELETE SET NULL 로 보존되는지. 정렬 tiebreaker 때문에 심는 행마다 타임스탬프를 벌려 둔다 — 바뀐 쿼리들은 (created_at desc, id desc) 인데 원래는 created_at 만이었고 그 컬럼이 DATETIME(초) 라 같은 초의 "예전 순서" 라는 것이 애초에 하나로 정해지지 않았다. 비교가 성립하는 조건을 테스트가 직접 만든다. Claude-Session: https://claude.ai/code/session_01APAyBZVYxUZzZsVyEXy6Qr
커서 쿼리는 `(:after is null or x.id > :after)` 꼴인데, 파라미터가 null 인지 SQL 에서 묻는 이 형태는 Hibernate 가 바인딩 타입을 정하지 못해 **실행 시점에만** 깨질 수 있다. Spring Data 의 부팅 시 JPQL 검증은 문법만 보므로 그건 못 잡는다. 그래서 커서를 안 준 첫 페이지와 커서를 준 다음 페이지를 각각 실제로 실행한다(메시지=오름차순, 도메인=내림차순 양방향). 동시에 "페이지를 이어 받았을 때 행이 빠지거나 겹치지 않는다" 를 확인한다. 메시지 쪽은 created_at 을 전부 같은 초로 심는데, 그게 id tiebreaker 를 붙인 이유 그대로다 — tiebreaker 가 없으면 이 조건에서 커서가 깨진다. Claude-Session: https://claude.ai/code/session_01APAyBZVYxUZzZsVyEXy6Qr
#340 5-9 와 #341 6-8 이 같은 메서드를 각자 벌크 DELETE 로 바꿔 리베이스 충돌이 났다. 양쪽 단언을 합치면서 예전 테스트의 꼬리 세 줄이 메서드 밖에 남아 컴파일이 깨졌다. Claude-Session: https://claude.ai/code/session_01APAyBZVYxUZzZsVyEXy6Qr
이 단위가 findAllByDeletedTrueAndDeletedAtLessThanEqual 을 제거했는데, #340 에서 온 단언 한 줄이 그 메서드로 "로드 후 삭제하지 않는다" 를 지키고 있었다. 메서드 자체가 없어졌으니 그 단언은 더 지킬 것이 없다 — 같은 성질은 바로 위 deleteById never() 가 본다. Claude-Session: https://claude.ai/code/session_01APAyBZVYxUZzZsVyEXy6Qr
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
요약
목록 조회가 필요 없는 대형 컬럼까지 통째로 읽고 있었다(#341). 프로젝트 개요를 한 번 열면 그 프로젝트의 모든 diff 를 DB 에서 읽었다.
실측 (신규 스키마
qp_meas_u6, MySQL 세션Bytes_sent)6-7 은 3N→≤2회, 6-8 은 1+N→1문장.
6-2 ① 이 2.2MB 남는 건
error_message TEXT가 응답 DTO 에 실제로 있어서다(description만 빠졌다).응답 동일성 — 이 PR 의 안전장치
ListProjectionResponseIdentityTest(실 MySQL): 예전 매핑을 테스트 안에 리터럴로 옮겨 적고, 예전 경로와 새 경로를 각각 JSON 직렬화해 문자열 비교한다. 6-1·6-2(3응답)·6-5(3목록)·6-6·6-4 커버.6-5 는 평문이 응답 JSON 어디에도 없다를 추가로 검사한다(유출이면 테스트가 잡는다). 이 항목은 예전(읽어서 매핑에서 마스킹)보다 강해진다 — 평문이 메모리에 아예 올라오지 않는다.
ConversationBulkCleanupTest는 6-8 의updated_at갱신(ON UPDATE CURRENT_TIMESTAMP)·CASCADE/SET NULL 을 DB 에서 직접 확인한다.CursorPaginationAgainstRealDbTest는:after is null이 런타임에만 깨질 수 있어 실제 실행 + 행 누락/중복 없음을 본다.id desc/asc를 덧붙였다. 원래는created_at만이었고DATETIME(0)이라 같은 초의 "예전 순서" 가 애초에 하나로 정해지지 않았다(개요의 "최신 배포" 가 조회마다 달라질 수 있었다). 명확한 개선이지만 엄밀히는 tie 케이스에서 달라질 수 있는 유일한 지점이다.6-3·6-4 계약 변화
본문은 전부 예전과 같은 JSON 배열. 커서는 응답 헤더
X-Qeploy-Next-Cursor로만 나간다(없으면 마지막 페이지). 브라우저가 노출 목록에 없는 헤더를 JS 에 안 보여줘서SecurityConfigCORS 에setExposedHeaders한 줄을 추가했다(공유 파일 · append-only 1줄).GET /conversations/{id}/messageslimit,afterGET /projects/{id}/changeslimit,afterGET /projects/{id}/approvalslimit,afterGET /projects/{id}/domainslimit,afterGET /projects/{id}/environment-variableslimit만잘못된 커서는 400.
기본값 근거 — 메시지 500: 사용자 발화 1건당 어시스턴트 메시지가 함께 쌓여 요청당 대략 5
9행 → 사용자 턴 55100회를 덮는다. 변경 200: 요청 1회당 최대 1건 → 최근 200작업. 승인 200: 요청당 0~2건 → 100작업 이상. 도메인·환경변수 200: 사람이 직접 만드는 값이라 수십 개면 이미 비정상.🔴 FE 후속 필요: 메시지 목록은 파라미터가 없으면 오래된 것부터 500건이다(기존 오름차순 유지). 500행 넘는 대화에서 커서를 안 쓰는 화면은 최신 메시지를 못 본다. 기본값 500 이 완충이지만 근본 해결은 FE 채택이다.
검증
안 한 것
currentUrl/currentVersion이 null 로 바뀐다. 남은 유일한 무한 성장 지점이다.after없음 — 정렬 키가 (scope, key) 라 id 커서와 안 맞는다. 복합 커서는 과잉이라 상한만 뒀다.ApprovalQueryService.inputFor의 승인건당 프로젝트 조회 — PENDING + REPOSITORY_BINDING 에만 걸리고 6-7 범위가 아니다.DomainBindingCommandService의 무제한 도메인 조회 4곳 — 다른 작업자 구역. 사용자 목록만 새 메서드를 쓰고 기존 메서드는 남겼다.호출부가 없어진
findByProjectIdOrderByTriggeredAtDesc(전체 엔티티 로드)는 삭제했다 — 남기면 다음 사람이 같은 함정을 밟는다.Closes #341
https://claude.ai/code/session_01APAyBZVYxUZzZsVyEXy6Qr