feat(configuration): S3 및 files 테이블 어댑터와 유스케이스 구현 #26 - #57
Hidden character warning
Conversation
One-Version Rule에 따라 kotlin.MODULE.bazel에 버전을 고정하고 configuration-adapter-out에서만 S3를 참조한다. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
ERD의 files 스키마에 대응하는 JPA 엔티티와 FileDocumentRepository 아웃바운드 포트 구현을 추가한다. object_key에 유니크 제약을 둔다. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
StoragePort를 AWS SDK v2로 구현한다. 업로드 시 SHA-256 체크섬을 받아 files 메타데이터에 남기고, 다운로드는 presigned URL로 발급한다. SdkException을 STORAGE_UPLOAD_FAILED / PRESIGN_FAILED 도메인 예외로 변환해 어댑터 밖으로 AWS 타입이 새지 않게 한다. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
FileDocumentService가 UploadFileUseCase, IssueDownloadUrlUseCase, ReadFileUseCase를 구현한다. 검증(형식/용량) → S3 업로드 → 메타데이터 저장 순서를 지키고, 메타데이터 저장이 실패하면 업로드된 객체를 보상 삭제한다. 보상 삭제 실패는 로깅만 하고 원래 예외를 덮지 않는다. 파일명은 API마다 결정 방식이 달라 커맨드의 fileName을 필수로 바꾸고 컨트롤러가 결정하도록 한다. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
📝 Walkthrough사용자는 파일 바이너리를 S3에 저장하고 메타데이터를 DB에서 관리할 수 있습니다. 다운로드 요청은 presigned URL을 반환합니다. 아키텍처 변경
위험 영역
마이그레이션 및 호환성
검증 체크리스트 및 롤아웃
WalkthroughS3 기반 파일 저장소와 presigned URL 발급을 추가했다. 파일 메타데이터의 JPA 영속화 계층을 추가했다. Changes파일 저장 및 문서 관리
Estimated code review effort: 3 (Moderate) | ~25 minutes Suggested labels: Suggested reviewers: Sequence Diagram(s)sequenceDiagram
participant UploadFileCommand
participant FileDocumentService
participant S3StorageAdapter
participant FileDocumentPersistenceAdapter
UploadFileCommand->>FileDocumentService: 업로드 명령 전달
FileDocumentService->>S3StorageAdapter: 파일 객체 업로드
S3StorageAdapter-->>FileDocumentService: StoredObject 반환
FileDocumentService->>FileDocumentPersistenceAdapter: 파일 메타데이터 저장
FileDocumentPersistenceAdapter-->>FileDocumentService: FileDocument 반환
🚥 Pre-merge checks | ✅ 8 | ❌ 2❌ Failed checks (2 warnings)
✅ Passed checks (8 passed)
✨ Finishing Touches 💡 1📝 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 |
|
@coderabbitai review |
✅ Action performedReview finished.
|
There was a problem hiding this comment.
Actionable comments posted: 7
🤖 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
`@systems/configuration/configuration-adapter-out/src/main/kotlin/hs/kr/entrydsm/configuration/adapterout/entity/FileDocumentJpaEntity.kt`:
- Around line 12-38: FileDocumentJpaEntity and FileDocumentPersistenceAdapter
require the files table schema to exist in the same deployment. Add a migration
defining all FileDocumentJpaEntity columns, the unique constraint on object_key,
and the required indexes, or prevent the upload functionality from being exposed
until that migration is applied.
In
`@systems/configuration/configuration-adapter-out/src/main/kotlin/hs/kr/entrydsm/configuration/adapterout/repository/FileDocumentJpaRepository.kt`:
- Around line 6-9: Update FileDocumentJpaRepository.deleteByObjectKey or the
corresponding FileDocumentService deletion method to explicitly declare a
write-enabled `@Transactional` boundary. Ensure the annotation overrides
FileDocumentService’s class-level readOnly = true setting so the derived delete
query executes within a transaction.
In
`@systems/configuration/configuration-adapter-out/src/main/kotlin/hs/kr/entrydsm/configuration/adapterout/S3StorageAdapter.kt`:
- Around line 74-94: Update S3StorageAdapter.exists and delete to translate
storage failures into the adapter’s domain exception, preserving the original
SdkException as the cause. In exists, return false for both NoSuchKeyException
and S3Exception responses with status code 404, while translating other
SdkException failures; translate deleteObject SdkException failures as well. Add
adapter-module tests covering success, 404, and other exception paths for both
methods.
In
`@systems/configuration/configuration-application/src/main/kotlin/hs/kr/entrydsm/configuration/application/FileDocumentService.kt`:
- Around line 18-29: Remove the direct Spring annotations and `@Value`
configuration binding from FileDocumentService, keeping it as a plain Kotlin
application service. Move bean registration, presignExpirySeconds configuration,
and the read-only transaction boundary to the bootstrap or adapter composition
layer, then inject the resolved expiry value through the constructor while
preserving existing behavior.
- Around line 36-105: Add comprehensive tests for the changed file-storage flow:
in
systems/configuration/configuration-application/src/main/kotlin/hs/kr/entrydsm/configuration/application/FileDocumentService.kt:36-105,
test validation, successful upload, metadata-save compensation, and download URL
flows; in
systems/configuration/configuration-adapter-out/src/main/kotlin/hs/kr/entrydsm/configuration/adapterout/S3StorageAdapter.kt:30-94,
mock upload, presign, existence, deletion, and SDK exception translation; in
systems/configuration/configuration-adapter-out/src/main/kotlin/hs/kr/entrydsm/configuration/adapterout/FileDocumentPersistenceAdapter.kt:14-29,
test mapping and persistence contracts; in
systems/configuration/configuration-adapter-out/src/main/kotlin/hs/kr/entrydsm/configuration/adapterout/entity/FileDocumentJpaEntity.kt:12-61,
verify schema constraints and Instant mapping; in
systems/configuration/configuration-adapter-out/src/main/kotlin/hs/kr/entrydsm/configuration/adapterout/config/S3Config.kt:15-25,
verify the configured region reaches both AWS clients; and in
systems/configuration/configuration-domain/src/main/kotlin/hs/kr/entrydsm/configuration/domain/document/command/UploadFileCommand.kt:5-10,
test uploads using the required fileName contract.
- Around line 43-58: Update the upload flow around FileDocumentService and
storagePort.upload so an existing objectKey is never overwritten or deleted on
metadata-save failure. Generate a unique object key or use conditional upload
semantics with explicit collision handling, and ensure orphan cleanup only
removes an object created by the current request; also cover object-key
collisions and metadata persistence failures with deterministic tests.
- Around line 46-60: FileDocumentService의 save 처리에서 JpaRepository.save의
flush·commit 실패까지 감지하도록 트랜잭션 완료 콜백을 등록하고, 완료 상태가 STATUS_COMMITTED가 아니면 S3 객체를
삭제하십시오. deleteOrphan 실패는 재시도 가능한 영속 보상 작업으로 기록하도록 보상 흐름을 확장하고, flush 실패·commit
실패·보상 실패를 검증하는 통합 테스트를 추가하십시오.
🪄 Autofix
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: Path: .coderabbit.yaml
Review profile: ASSERTIVE
Plan: Pro Plus
Run ID: bbaff4be-a60c-49db-9790-fd8528e6c20d
⛔ Files ignored due to path filters (1)
kotlin.MODULE.bazelis excluded by none and included by none
📒 Files selected for processing (8)
systems/configuration/configuration-adapter-out/deps.bzlsystems/configuration/configuration-adapter-out/src/main/kotlin/hs/kr/entrydsm/configuration/adapterout/FileDocumentPersistenceAdapter.ktsystems/configuration/configuration-adapter-out/src/main/kotlin/hs/kr/entrydsm/configuration/adapterout/S3StorageAdapter.ktsystems/configuration/configuration-adapter-out/src/main/kotlin/hs/kr/entrydsm/configuration/adapterout/config/S3Config.ktsystems/configuration/configuration-adapter-out/src/main/kotlin/hs/kr/entrydsm/configuration/adapterout/entity/FileDocumentJpaEntity.ktsystems/configuration/configuration-adapter-out/src/main/kotlin/hs/kr/entrydsm/configuration/adapterout/repository/FileDocumentJpaRepository.ktsystems/configuration/configuration-application/src/main/kotlin/hs/kr/entrydsm/configuration/application/FileDocumentService.ktsystems/configuration/configuration-domain/src/main/kotlin/hs/kr/entrydsm/configuration/domain/document/command/UploadFileCommand.kt
📜 Review details
🧰 Additional context used
📓 Path-based instructions (7)
**/{BUILD.bazel,*.bzl}
📄 CodeRabbit inference engine (Custom checks)
In BUILD.bazel and .bzl files, require buildifier-compatible formatting and stable target naming
Files:
systems/configuration/configuration-adapter-out/deps.bzl
**/*.bzl
⚙️ CodeRabbit configuration file
**/*.bzl: Apply Bazel Starlark (.bzl) style guidance.Readability and docs:
- Keep file/module docstrings and docstrings for public functions/macros.
- Use descriptive parameter names and document attribute intent.
API design:
- Macros should take a
nameargument and derive generated target names from it.- Prefer keyword arguments when calling macros for clarity and stability.
- Keep macro side effects predictable and visible.
Encapsulation:
- Use private visibility for helper targets created by macros unless explicitly public.
- Avoid exposing internal implementation targets unintentionally.
Tooling:
- Enforce buildifier formatting and lint compliance.
Files:
systems/configuration/configuration-adapter-out/deps.bzl
**/*.{kt,go}
📄 CodeRabbit inference engine (Custom checks)
If production logic is changed in Kotlin or Go files, require corresponding test updates in the same subsystem unless the PR description explicitly justifies why tests are unnecessary
Files:
systems/configuration/configuration-adapter-out/src/main/kotlin/hs/kr/entrydsm/configuration/adapterout/repository/FileDocumentJpaRepository.ktsystems/configuration/configuration-adapter-out/src/main/kotlin/hs/kr/entrydsm/configuration/adapterout/config/S3Config.ktsystems/configuration/configuration-adapter-out/src/main/kotlin/hs/kr/entrydsm/configuration/adapterout/FileDocumentPersistenceAdapter.ktsystems/configuration/configuration-adapter-out/src/main/kotlin/hs/kr/entrydsm/configuration/adapterout/entity/FileDocumentJpaEntity.ktsystems/configuration/configuration-adapter-out/src/main/kotlin/hs/kr/entrydsm/configuration/adapterout/S3StorageAdapter.ktsystems/configuration/configuration-domain/src/main/kotlin/hs/kr/entrydsm/configuration/domain/document/command/UploadFileCommand.ktsystems/configuration/configuration-application/src/main/kotlin/hs/kr/entrydsm/configuration/application/FileDocumentService.kt
**/*.{java,kt,scala,groovy,go,js,ts,tsx,jsx,py,rb,rs,cpp,c,h,hpp,cs}
📄 CodeRabbit inference engine (Custom checks)
Flag TODO/FIXME comments introduced by this PR that do not include an issue reference in the form
#123or a full tracker key like PROJ-123
Files:
systems/configuration/configuration-adapter-out/src/main/kotlin/hs/kr/entrydsm/configuration/adapterout/repository/FileDocumentJpaRepository.ktsystems/configuration/configuration-adapter-out/src/main/kotlin/hs/kr/entrydsm/configuration/adapterout/config/S3Config.ktsystems/configuration/configuration-adapter-out/src/main/kotlin/hs/kr/entrydsm/configuration/adapterout/FileDocumentPersistenceAdapter.ktsystems/configuration/configuration-adapter-out/src/main/kotlin/hs/kr/entrydsm/configuration/adapterout/entity/FileDocumentJpaEntity.ktsystems/configuration/configuration-adapter-out/src/main/kotlin/hs/kr/entrydsm/configuration/adapterout/S3StorageAdapter.ktsystems/configuration/configuration-domain/src/main/kotlin/hs/kr/entrydsm/configuration/domain/document/command/UploadFileCommand.ktsystems/configuration/configuration-application/src/main/kotlin/hs/kr/entrydsm/configuration/application/FileDocumentService.kt
**/*.kt
⚙️ CodeRabbit configuration file
**/*.kt: Apply Kotlin Official Coding Conventions.Formatting and structure:
- Use 4 spaces for indentation; no tabs.
- Keep files focused and readable; avoid horizontal alignment for spacing.
- Place related declarations together and keep overloads adjacent.
- Keep implementation member order stable and logical for readability.
Naming:
- Package names are lowercase and do not use underscores.
- Class/object names use UpperCamelCase.
- Functions/properties/local variables use lowerCamelCase.
- Constants use UPPER_SNAKE_CASE only for true constants.
API and null-safety:
- Avoid platform type leakage in public APIs.
- Use explicit types in public APIs when inference obscures meaning.
- Prefer immutable values (
val) over mutable values (var) unless mutation is required.- Flag nullable flows that can be replaced with safer modeling.
Imports and idioms:
- Avoid wildcard imports unless justified by language/tooling conventions.
- Prefer expression bodies for short, clear functions.
- Prefer standard library idioms over custom utility wrappers when equivalent.
Architecture and tests:
- Respect module boundaries (domain/application/adapter/bootstrap layering).
- Highlight behavior-changing code that lacks corresponding unit/integration tests.
- Ask for deterministic tests and meaningful assertions, not only happy-path checks.
Files:
systems/configuration/configuration-adapter-out/src/main/kotlin/hs/kr/entrydsm/configuration/adapterout/repository/FileDocumentJpaRepository.ktsystems/configuration/configuration-adapter-out/src/main/kotlin/hs/kr/entrydsm/configuration/adapterout/config/S3Config.ktsystems/configuration/configuration-adapter-out/src/main/kotlin/hs/kr/entrydsm/configuration/adapterout/FileDocumentPersistenceAdapter.ktsystems/configuration/configuration-adapter-out/src/main/kotlin/hs/kr/entrydsm/configuration/adapterout/entity/FileDocumentJpaEntity.ktsystems/configuration/configuration-adapter-out/src/main/kotlin/hs/kr/entrydsm/configuration/adapterout/S3StorageAdapter.ktsystems/configuration/configuration-domain/src/main/kotlin/hs/kr/entrydsm/configuration/domain/document/command/UploadFileCommand.ktsystems/configuration/configuration-application/src/main/kotlin/hs/kr/entrydsm/configuration/application/FileDocumentService.kt
**/*-domain/**/*.{java,kt,scala,groovy}
📄 CodeRabbit inference engine (Custom checks)
For files under *-domain modules, fail if imports reference adapter or bootstrap packages
Files:
systems/configuration/configuration-domain/src/main/kotlin/hs/kr/entrydsm/configuration/domain/document/command/UploadFileCommand.kt
**/*-application/**/*.{java,kt,scala,groovy}
📄 CodeRabbit inference engine (Custom checks)
For files under *-application modules, flag direct dependency on infrastructure-specific framework classes unless justified
Files:
systems/configuration/configuration-application/src/main/kotlin/hs/kr/entrydsm/configuration/application/FileDocumentService.kt
🪛 detekt (1.23.8)
systems/configuration/configuration-adapter-out/src/main/kotlin/hs/kr/entrydsm/configuration/adapterout/S3StorageAdapter.kt
[warning] 83-83: The caught exception is swallowed. The original exception could be lost.
(detekt.exceptions.SwallowedException)
systems/configuration/configuration-application/src/main/kotlin/hs/kr/entrydsm/configuration/application/FileDocumentService.kt
[warning] 57-57: The caught exception is too generic. Prefer catching specific exceptions to the case that is currently handled.
(detekt.exceptions.TooGenericExceptionCaught)
🔇 Additional comments (1)
systems/configuration/configuration-adapter-out/deps.bzl (1)
4-4: LGTM!
| @Entity | ||
| @Table(name = "files") | ||
| class FileDocumentJpaEntity( | ||
| @Id | ||
| @GeneratedValue(strategy = GenerationType.IDENTITY) | ||
| val id: Long? = null, | ||
|
|
||
| @Column(name = "original_name", nullable = false, length = 255) | ||
| val originalName: String, | ||
|
|
||
| @Column(name = "object_key", unique = true, nullable = false, length = 255) | ||
| val objectKey: String, | ||
|
|
||
| @Column(name = "bucket", nullable = false, length = 100) | ||
| val bucket: String, | ||
|
|
||
| @Column(name = "content_type", nullable = false, length = 100) | ||
| val contentType: String, | ||
|
|
||
| @Column(name = "size_bytes", nullable = false) | ||
| val sizeBytes: Long, | ||
|
|
||
| @Column(name = "checksum", nullable = false, length = 64) | ||
| val checksum: String, | ||
|
|
||
| @Column(name = "created_at", nullable = false) | ||
| val createdAt: Instant, |
There was a problem hiding this comment.
🩺 Stability & Availability | 🟠 Major | ⚡ Quick win
files 테이블 마이그레이션을 이 변경과 함께 추가해야 합니다.
이 엔티티와 FileDocumentPersistenceAdapter는 즉시 files 테이블을 읽고 씁니다. 현재 PR 목표대로 마이그레이션을 후속 PR로 분리하면, 배포된 애플리케이션의 업로드 경로는 테이블 없음 오류로 실패합니다.
이 엔티티의 컬럼, object_key 고유 제약, 인덱스를 포함한 마이그레이션을 같은 배포 단위에 추가하거나, 마이그레이션 적용 전까지 기능을 노출하지 마십시오.
🤖 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
`@systems/configuration/configuration-adapter-out/src/main/kotlin/hs/kr/entrydsm/configuration/adapterout/entity/FileDocumentJpaEntity.kt`
around lines 12 - 38, FileDocumentJpaEntity and FileDocumentPersistenceAdapter
require the files table schema to exist in the same deployment. Add a migration
defining all FileDocumentJpaEntity columns, the unique constraint on object_key,
and the required indexes, or prevent the upload functionality from being exposed
until that migration is applied.
| interface FileDocumentJpaRepository : JpaRepository<FileDocumentJpaEntity, Long> { | ||
| fun findByObjectKey(objectKey: String): FileDocumentJpaEntity? | ||
| fun deleteByObjectKey(objectKey: String) | ||
| } |
There was a problem hiding this comment.
🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win
🧩 Analysis chain
🏁 Script executed:
#!/bin/bash
set -e
file='systems/configuration/configuration-adapter-out/src/main/kotlin/hs/kr/entrydsm/configuration/adapterout/repository/FileDocumentJpaRepository.kt'
printf '%s\n' '--- repository ---'
cat -n "$file"
printf '%s\n' '--- related files ---'
rg -n --glob '*.kt' 'FileDocumentJpaRepository|deleteByObjectKey|findByObjectKey|class FileDocumentPersistenceAdapter|interface .*Persistence' systems/configuration
printf '%s\n' '--- configuration module files ---'
git ls-files 'systems/configuration' | sed -n '1,160p'Repository: EntryDSM/entrydsm-platform
Length of output: 10692
🏁 Script executed:
#!/bin/bash
set -e
printf '%s\n' '--- persistence adapter ---'
cat -n systems/configuration/configuration-adapter-out/src/main/kotlin/hs/kr/entrydsm/configuration/adapterout/FileDocumentPersistenceAdapter.kt
printf '%s\n' '--- application service ---'
cat -n systems/configuration/configuration-application/src/main/kotlin/hs/kr/entrydsm/configuration/application/FileDocumentService.kt
printf '%s\n' '--- transaction annotations and dependency declarations ---'
rg -n --glob '*.kt' --glob '*.bzl' --glob '*.xml' --glob '*.yaml' --glob '*.yml' \
'`@Transactional`|spring-tx|spring-data-jpa|JpaRepository|deleteBy' systems/configurationRepository: EntryDSM/entrydsm-platform
Length of output: 13435
🌐 Web query:
Spring Data JPA reference transactionality declared query methods default transaction configuration derived delete query method
💡 Result:
In Spring Data JPA, the default transaction configuration applies only to methods inherited from repository interfaces like CrudRepository or JpaRepository [1][2]. Custom query methods, whether they are derived query methods (method name-based) or declared query methods (using @Query), do not receive any default transactional configuration [1][3][4]. To run these custom methods transactionally, you must explicitly annotate the method or the repository interface with @Transactional [1][2][5]. Key details regarding transactionality and delete operations: 1. Default Transaction Configuration: - Inherited methods (e.g., save, findById, delete) inherit transactional attributes from SimpleJpaRepository [1][2][5]. - Methods marked as read-only (like findAll) have the readOnly flag set to true [1][6]. Other inherited operations use a plain @Transactional [1][2]. - Custom methods (derived or declared) are not transactional by default to avoid unnecessary overhead for simple select operations [1][4]. 2. Derived Delete Query Methods: - These methods (e.g., void deleteByRoleId(long roleId)) are not inherently transactional [1][4]. If you need them to participate in a transaction, you must apply @Transactional [1]. - Behaviorally, derived delete methods perform a search first to identify the entities, then call the persistence provider's remove method for each, ensuring JPA lifecycle callbacks (such as @PreRemove) are invoked [7][3][8]. This process can be memory-intensive if many entities are deleted [7]. 3. Declared Query Methods: - Methods annotated with @Query (especially those with @Modifying) also lack default transaction configuration [1][2]. - When using @Modifying for bulk updates or deletes, it is required to annotate the method with @Transactional to ensure the operation executes with readOnly=false, allowing the database modification to persist [1][2][5]. To ensure consistency, it is recommended to explicitly declare transaction boundaries for custom repository methods that perform write operations [3][6].
Citations:
- 1: https://docs.spring.io/spring-data/jpa/reference/jpa/transactions.html
- 2: https://docs.spring.io/spring-data/jpa/reference/3.5/jpa/transactions.html
- 3: https://docs.spring.io/spring-data/jpa/docs/current-SNAPSHOT/reference/html/
- 4: Unclear way of TX management in Spring Data JPA repository methods spring-projects/spring-data-jpa#3319
- 5: https://stackoverflow.com/questions/79764413/doesnt-jpa-has-transactional-by-default
- 6: https://docs.spring.io/spring-data/jpa/reference/4.1-SNAPSHOT/jpa/transactions.html
- 7: https://docs.spring.io/spring-data/jpa/reference/jpa/query-methods.html
- 8: Query method DSL generates SQL select statement for deleteBy clause [DATAJPA-1147] spring-projects/spring-data-jpa#1488
🏁 Script executed:
#!/bin/bash
set -e
printf '%s\n' '--- analogous repository and service deletion ---'
cat -n systems/configuration/configuration-adapter-out/src/main/kotlin/hs/kr/entrydsm/configuration/adapterout/repository/EnvironmentVariableJpaRepository.kt
cat -n systems/configuration/configuration-application/src/main/kotlin/hs/kr/entrydsm/configuration/application/EnvironmentVariableService.kt
printf '%s\n' '--- all FileDocumentRepository call sites ---'
rg -n --glob '*.kt' 'FileDocumentRepository|deleteByObjectKey' .Repository: EntryDSM/entrydsm-platform
Length of output: 5805
🏁 Script executed:
#!/bin/bash
set -e
python3 - <<'PY'
from pathlib import Path
import re
repo = Path("systems/configuration/configuration-adapter-out/src/main/kotlin/hs/kr/entrydsm/configuration/adapterout/repository/FileDocumentJpaRepository.kt")
adapter = Path("systems/configuration/configuration-adapter-out/src/main/kotlin/hs/kr/entrydsm/configuration/adapterout/FileDocumentPersistenceAdapter.kt")
service = Path("systems/configuration/configuration-application/src/main/kotlin/hs/kr/entrydsm/configuration/application/FileDocumentService.kt")
repo_text = repo.read_text()
adapter_text = adapter.read_text()
service_text = service.read_text()
method = re.search(r'(?P<annotations>(?:\s*@\w+(?:\([^)]*\))?\s*)*)\s*fun deleteByObjectKey', repo_text)
adapter_method = re.search(r'(?P<annotations>(?:\s*@\w+(?:\([^)]*\))?\s*)*)\s*override fun deleteByObjectKey', adapter_text)
print("repository_delete_method_has_transactional=",
bool(method and "`@Transactional`" in method.group("annotations")))
print("adapter_delete_method_has_transactional=",
bool(adapter_method and "`@Transactional`" in adapter_method.group("annotations")))
print("service_delete_call_count=",
len(re.findall(r'\bdeleteByObjectKey\s*\(', service_text)))
print("adapter_delete_call_count=",
len(re.findall(r'\bdeleteByObjectKey\s*\(', adapter_text)))
print("service_class_transactional_read_only=",
bool(re.search(r'`@Transactional`\s*\(\s*readOnly\s*=\s*true\s*\)', service_text)))
PY
printf '%s\n' '--- focused diff summary ---'
git diff --stat -- systems/configuration/configuration-adapter-out/src/main/kotlin/hs/kr/entrydsm/configuration/adapter-out systems/configuration/configuration-adapter-out/src/main/kotlin/hs/kr/entrydsm/configuration/adapterout systems/configuration/configuration-application/src/main/kotlin/hs/kr/entrydsm/configuration/applicationRepository: EntryDSM/entrydsm-platform
Length of output: 392
deleteByObjectKey에 쓰기 트랜잭션 경계를 추가하세요.
파생 쿼리 메서드는 기본 트랜잭션을 받지 않습니다. 트랜잭션 없이 호출하면 삭제가 TransactionRequiredException으로 실패할 수 있습니다. FileDocumentJpaRepository.deleteByObjectKey 또는 애플리케이션 서비스의 삭제 메서드에 @Transactional을 선언하세요. FileDocumentService의 클래스 기본값은 readOnly = true이므로 삭제 메서드에 명시적으로 선언해야 합니다.
🤖 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
`@systems/configuration/configuration-adapter-out/src/main/kotlin/hs/kr/entrydsm/configuration/adapterout/repository/FileDocumentJpaRepository.kt`
around lines 6 - 9, Update FileDocumentJpaRepository.deleteByObjectKey or the
corresponding FileDocumentService deletion method to explicitly declare a
write-enabled `@Transactional` boundary. Ensure the annotation overrides
FileDocumentService’s class-level readOnly = true setting so the derived delete
query executes within a transaction.
| override fun exists(objectKey: String): Boolean = | ||
| try { | ||
| s3Client.headObject( | ||
| HeadObjectRequest.builder() | ||
| .bucket(bucket) | ||
| .key(objectKey) | ||
| .build() | ||
| ) | ||
| true | ||
| } catch (e: NoSuchKeyException) { | ||
| false | ||
| } | ||
|
|
||
| override fun delete(objectKey: String) { | ||
| s3Client.deleteObject( | ||
| DeleteObjectRequest.builder() | ||
| .bucket(bucket) | ||
| .key(objectKey) | ||
| .build() | ||
| ) | ||
| } |
There was a problem hiding this comment.
🩺 Stability & Availability | 🟠 Major | ⚡ Quick win
🧩 Analysis chain
🏁 Script executed:
#!/bin/bash
set -eu
file="systems/configuration/configuration-adapter-out/src/main/kotlin/hs/kr/entrydsm/configuration/adapterout/S3StorageAdapter.kt"
printf '%s\n' '--- outline ---'
ast-grep outline "$file" || true
printf '%s\n' '--- source ---'
cat -n "$file"
printf '%s\n' '--- related storage symbols ---'
rg -n --glob '*.kt' 'interface .*Storage|class .*Storage|StorageException|NoSuchKeyException|headObject|deleteObject|exists\(objectKey|delete\(objectKey' systems
printf '%s\n' '--- related tests ---'
rg --files | rg '(^|/)(test|tests)(/|$)|S3StorageAdapter|Storage' | head -200
printf '%s\n' '--- diff summary ---'
git diff --stat -- systems/configuration/configuration-adapter-out/src/main/kotlin/hs/kr/entrydsm/configuration/adapterout/S3StorageAdapter.ktRepository: EntryDSM/entrydsm-platform
Length of output: 10445
🏁 Script executed:
#!/bin/bash
set -eu
printf '%s\n' '--- StoragePort ---'
cat -n systems/configuration/configuration-domain/src/main/kotlin/hs/kr/entrydsm/configuration/domain/document/port/out/StoragePort.kt
printf '%s\n' '--- storage exceptions ---'
for f in systems/configuration/configuration-domain/src/main/kotlin/hs/kr/entrydsm/configuration/domain/document/exception/*.kt; do
printf '\n--- %s ---\n' "$f"
cat -n "$f"
done
printf '%s\n' '--- FileDocumentService relevant methods ---'
cat -n systems/configuration/configuration-application/src/main/kotlin/hs/kr/entrydsm/configuration/application/FileDocumentService.kt | sed -n '45,120p'
printf '%s\n' '--- adapter-out test files and contents ---'
find systems/configuration/configuration-adapter-out/src/test -type f -maxdepth 20 -print -exec sh -c 'echo "--- $1 ---"; cat -n "$1"' _ {} \;
printf '%s\n' '--- repository state and recent commit summary ---'
git status --short
git diff --name-status
git log -5 --oneline -- systems/configuration/configuration-adapter-out/src/main/kotlin/hs/kr/entrydsm/configuration/adapterout/S3StorageAdapter.ktRepository: EntryDSM/entrydsm-platform
Length of output: 6923
🌐 Web query:
AWS SDK for Java 2.x S3 HeadObject NoSuchKeyException 404 behavior deleteObject SdkException
💡 Result:
When using the AWS SDK for Java 2.x, calling headObject on a non-existent S3 object does not always throw a NoSuchKeyException as might be expected [1][2][3]. Because the S3 HEAD operation does not return a response body, the service cannot provide the detailed XML error message typically used to map specific error types [1][4]. As a result, the SDK often throws a generic S3Exception containing a 404 status code instead of the more specific NoSuchKeyException [1][2][3]. To reliably handle this behavior in your code, you should catch S3Exception and inspect the status code rather than relying exclusively on catching NoSuchKeyException [1]. Example approach: try { s3Client.headObject(request); } catch (S3Exception e) { if (e.statusCode == 404) { // Handle the case where the object does not exist } else { // Handle other S3 errors throw e; } } Regarding your mention of deleteObject, this operation also throws an SdkException (or a subclass) if the request fails [5][6]. However, note that S3 deleteObject is designed to be idempotent; if you attempt to delete a key that does not exist, S3 typically returns a 204 No Content success response, meaning it will not throw an exception solely because the key was already missing. If an exception is thrown during deleteObject, it is usually due to network issues, permission errors, or other service-side failures, which are captured as an SdkException [5][6].
Citations:
- 1: HeadObjectRequest exception aws/aws-sdk-java-v2#297
- 2: S3Client.headObject does not throw NoSuchKeyException as documented aws/aws-sdk-java-v2#544
- 3: S3 HeadObject throws S3Exception: null instead of NoSuchKeyException when no object exists. aws/aws-sdk-java-v2#1941
- 4: https://docs.aws.amazon.com/AmazonS3/latest/API/API_HeadObject.html
- 5: https://javadoc.io/static/software.amazon.awssdk/s3/2.10.27/software/amazon/awssdk/services/s3/S3Client.html
- 6: https://docs.aws.amazon.com/java/api/latest/software/amazon/awssdk/services/s3/S3Client.html
🏁 Script executed:
#!/bin/bash
set -eu
python3 - <<'PY'
from pathlib import Path
import re
adapter = Path("systems/configuration/configuration-adapter-out/src/main/kotlin/hs/kr/entrydsm/configuration/adapterout/S3StorageAdapter.kt").read_text()
port = Path("systems/configuration/configuration-domain/src/main/kotlin/hs/kr/entrydsm/configuration/domain/document/port/out/StoragePort.kt").read_text()
exceptions = sorted(Path("systems/configuration/configuration-domain/src/main/kotlin/hs/kr/entrydsm/configuration/domain/document/exception").glob("*.kt"))
print("adapter methods:")
for method in ("upload", "issueDownloadUrl", "exists", "delete"):
match = re.search(rf"override fun {method}\b.*?(?=\n override fun |\n}})", adapter, re.S)
body = match.group(0) if match else ""
catches = re.findall(r"catch\s*\(\s*\w+\s*:\s*([A-Za-z0-9_.<>?]+)", body)
throws = re.findall(r"throw\s+([A-Za-z0-9_]+)", body)
print(f"{method}: catches={catches or []}, throws={throws or []}, sdk_calls={re.findall(r's3(?:Client|Presigner)\.\w+', body)}")
print("StoragePort operations:", re.findall(r"^\s*fun\s+(\w+)\s*\(", port, re.M))
print("domain exception classes:", [
re.search(r"class\s+([A-Za-z0-9_]+)", p.read_text()).group(1)
for p in exceptions
])
print("adapter tests:", [
str(p) for p in Path("systems/configuration/configuration-adapter-out/src/test").rglob("*.kt")
])
PY
printf '%s\n' '--- exception handling references ---'
rg -n --glob '*.kt' 'PresignFailedException|StorageUploadFailedException|FileDocumentNotFoundException|`@ExceptionHandler`|ResponseStatusException|RuntimeException' systems/configuration
printf '%s\n' '--- implementing adapters ---'
rg -n --glob '*.kt' ': StoragePort|implements StoragePort|StoragePort' systems/configurationRepository: EntryDSM/entrydsm-platform
Length of output: 521
🏁 Script executed:
#!/bin/bash
set -eu
commit="$(git log -1 --format=%H -- systems/configuration/configuration-adapter-out/src/main/kotlin/hs/kr/entrydsm/configuration/adapterout/S3StorageAdapter.kt)"
printf '%s\n' "--- commit $commit ---"
git show --stat --oneline "$commit" -- systems/configuration
printf '%s\n' '--- S3 adapter commit diff ---'
git show --format= --no-ext-diff "$commit" -- systems/configuration/configuration-adapter-out/src/main/kotlin/hs/kr/entrydsm/configuration/adapterout/S3StorageAdapter.ktRepository: EntryDSM/entrydsm-platform
Length of output: 4609
🏁 Script executed:
#!/bin/bash
set -eu
python3 - <<'PY'
from pathlib import Path
import re
adapter = Path("systems/configuration/configuration-adapter-out/src/main/kotlin/hs/kr/entrydsm/configuration/adapterout/S3StorageAdapter.kt").read_text()
port = Path("systems/configuration/configuration-domain/src/main/kotlin/hs/kr/entrydsm/configuration/domain/document/port/out/StoragePort.kt").read_text()
exception_dir = Path("systems/configuration/configuration-domain/src/main/kotlin/hs/kr/entrydsm/configuration/domain/document/exception")
print("adapter methods:")
for method in ("upload", "issueDownloadUrl", "exists", "delete"):
match = re.search(rf"override fun {method}\b.*?(?=\n override fun |\n}})", adapter, re.S)
body = match.group(0) if match else ""
catches = re.findall(r"catch\s*\(\s*\w+\s*:\s*([A-Za-z0-9_.<>?]+)", body)
throws = re.findall(r"throw\s+([A-Za-z0-9_]+)", body)
sdk_calls = re.findall(r"s3(?:Client|Presigner)\.\w+", body)
print(f"{method}: catches={catches or []}, throws={throws or []}, sdk_calls={sdk_calls}")
print("StoragePort operations:", re.findall(r"^\s*fun\s+(\w+)\s*\(", port, re.M))
print("domain exception classes:", sorted(
re.search(r"class\s+([A-Za-z0-9_]+)", p.read_text()).group(1)
for p in exception_dir.glob("*.kt")
))
print("adapter tests:", sorted(
str(p) for p in Path("systems/configuration/configuration-adapter-out/src/test").rglob("*.kt")
))
PY
printf '%s\n' '--- configuration exception handling references ---'
rg -n --glob '*.kt' 'PresignFailedException|StorageUploadFailedException|FileDocumentNotFoundException|`@ExceptionHandler`|ResponseStatusException|RuntimeException' systems/configuration || true
printf '%s\n' '--- StoragePort implementations ---'
rg -n --glob '*.kt' ': StoragePort|implements StoragePort|StoragePort' systems/configurationRepository: EntryDSM/entrydsm-platform
Length of output: 5862
exists와 delete의 스토리지 예외를 도메인 예외로 변환하십시오.
headObject의 객체 없음 응답은 NoSuchKeyException이 아닌 상태 코드 404의 S3Exception으로 발생할 수 있습니다. 404를 false로 처리하고, 그 외 SdkException은 원인을 보존한 도메인 예외로 변환하십시오. deleteObject의 SdkException도 도메인 예외로 변환하십시오. 두 메서드의 성공, 404, 기타 예외 경로를 검증하는 테스트를 같은 어댑터 모듈에 추가하십시오.
🧰 Tools
🪛 detekt (1.23.8)
[warning] 83-83: The caught exception is swallowed. The original exception could be lost.
(detekt.exceptions.SwallowedException)
🤖 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
`@systems/configuration/configuration-adapter-out/src/main/kotlin/hs/kr/entrydsm/configuration/adapterout/S3StorageAdapter.kt`
around lines 74 - 94, Update S3StorageAdapter.exists and delete to translate
storage failures into the adapter’s domain exception, preserving the original
SdkException as the cause. In exists, return false for both NoSuchKeyException
and S3Exception responses with status code 404, while translating other
SdkException failures; translate deleteObject SdkException failures as well. Add
adapter-module tests covering success, 404, and other exception paths for both
methods.
| import org.slf4j.LoggerFactory | ||
| import org.springframework.beans.factory.annotation.Value | ||
| import org.springframework.stereotype.Service | ||
| import org.springframework.transaction.annotation.Transactional | ||
| import java.io.InputStream | ||
|
|
||
| @Service | ||
| @Transactional(readOnly = true) | ||
| class FileDocumentService( | ||
| private val storagePort: StoragePort, | ||
| private val fileDocumentRepository: FileDocumentRepository, | ||
| @Value("\${aws.s3.presign-expiry-seconds:600}") private val presignExpirySeconds: Long, |
There was a problem hiding this comment.
📐 Maintainability & Code Quality | 🟠 Major | 🏗️ Heavy lift
애플리케이션 계층에서 Spring 의존성을 제거해야 합니다.
FileDocumentService가 @Service, @Transactional, @Value를 직접 사용합니다. 애플리케이션 서비스는 일반 Kotlin 객체로 유지하십시오. Spring 빈 등록, 설정 바인딩, 트랜잭션 경계는 bootstrap 또는 adapter 계층의 구성 요소로 이동하십시오.
As per coding guidelines, “For files under *-application modules, flag direct dependency on infrastructure-specific framework classes unless justified”.
🤖 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
`@systems/configuration/configuration-application/src/main/kotlin/hs/kr/entrydsm/configuration/application/FileDocumentService.kt`
around lines 18 - 29, Remove the direct Spring annotations and `@Value`
configuration binding from FileDocumentService, keeping it as a plain Kotlin
application service. Move bean registration, presignExpirySeconds configuration,
and the read-only transaction boundary to the bootstrap or adapter composition
layer, then inject the resolved expiry value through the constructor while
preserving existing behavior.
Source: Coding guidelines
| @Transactional | ||
| override fun upload(command: UploadFileCommand, content: InputStream): FileDocument { | ||
| val extension = resolveExtension(command) | ||
| if (command.category.exceedsMaxSize(command.sizeBytes)) { | ||
| throw FileTooLargeException(command.sizeBytes, command.category.maxSizeBytes) | ||
| } | ||
|
|
||
| val objectKey = command.category.objectKeyOf(FileNaming.requireSafeFileName(command.fileName)) | ||
| val stored = storagePort.upload(objectKey, extension.contentType, command.sizeBytes, content) | ||
|
|
||
| return try { | ||
| fileDocumentRepository.save( | ||
| FileDocument( | ||
| originalName = command.originalName, | ||
| objectKey = stored.objectKey, | ||
| bucket = stored.bucket, | ||
| contentType = extension.contentType, | ||
| sizeBytes = command.sizeBytes, | ||
| checksum = stored.checksum, | ||
| ) | ||
| ) | ||
| } catch (e: RuntimeException) { | ||
| deleteOrphan(objectKey) | ||
| throw e | ||
| } | ||
| } | ||
|
|
||
| override fun issueByCommand(command: IssueDownloadUrlCommand): DownloadUrl { | ||
| val fileName = FileNaming.requireSafeFileName(command.fileName) | ||
| val objectKey = command.category.objectKeyOf(fileName) | ||
| if (!storagePort.exists(objectKey)) throw FileDocumentNotFoundException(objectKey) | ||
| return DownloadUrl( | ||
| fileName = fileName, | ||
| downloadUrl = storagePort.issueDownloadUrl(objectKey, presignExpirySeconds), | ||
| expiresIn = presignExpirySeconds, | ||
| ) | ||
| } | ||
|
|
||
| override fun issueById(id: Long): DownloadUrl { | ||
| val fileDocument = findById(id) | ||
| return DownloadUrl( | ||
| fileName = fileDocument.originalName, | ||
| downloadUrl = storagePort.issueDownloadUrl(fileDocument.objectKey, presignExpirySeconds), | ||
| expiresIn = presignExpirySeconds, | ||
| ) | ||
| } | ||
|
|
||
| override fun findById(id: Long): FileDocument = | ||
| fileDocumentRepository.findById(id) ?: throw FileDocumentNotFoundException("id=$id") | ||
|
|
||
| override fun findByFileName(category: FileCategory, fileName: String): FileDocument? = | ||
| fileDocumentRepository.findByObjectKey( | ||
| category.objectKeyOf(FileNaming.requireSafeFileName(fileName)) | ||
| ) | ||
|
|
||
| override fun existsById(id: Long): Boolean = | ||
| fileDocumentRepository.existsById(id) | ||
|
|
||
| private fun resolveExtension(command: UploadFileCommand): FileExtension { | ||
| val extension = FileExtension.fromFileName(command.originalName) | ||
| ?: throw InvalidFileFormatException(command.originalName, command.category) | ||
| if (!command.category.supports(extension)) { | ||
| throw InvalidFileFormatException(command.originalName, command.category) | ||
| } | ||
| return extension | ||
| } | ||
|
|
||
| private fun deleteOrphan(objectKey: String) { | ||
| runCatching { storagePort.delete(objectKey) } | ||
| .onFailure { log.warn("Failed to delete orphaned object: {}", objectKey, it) } |
There was a problem hiding this comment.
📐 Maintainability & Code Quality | 🟠 Major | 🏗️ Heavy lift
변경된 파일 저장 흐름의 결정적 테스트를 추가해야 합니다.
PR 목표는 신규 테스트를 포함하지 않았다고 명시합니다. 이 변경은 외부 S3 호출, DB 영속화, 보상 삭제, 공개 명령 계약을 함께 변경합니다. 병합 전 테스트가 필요합니다.
systems/configuration/configuration-application/src/main/kotlin/hs/kr/entrydsm/configuration/application/FileDocumentService.kt#L36-L105: 형식·크기 검증, 성공 저장, 메타데이터 실패 보상, 다운로드 URL 흐름을 테스트하십시오.systems/configuration/configuration-adapter-out/src/main/kotlin/hs/kr/entrydsm/configuration/adapterout/S3StorageAdapter.kt#L30-L94: 업로드, presign, 존재 확인, 삭제와 SDK 예외 변환을 mock 기반으로 테스트하십시오.systems/configuration/configuration-adapter-out/src/main/kotlin/hs/kr/entrydsm/configuration/adapterout/FileDocumentPersistenceAdapter.kt#L14-L29: 도메인-엔티티 변환과 저장·조회·삭제 계약을 테스트하십시오.systems/configuration/configuration-adapter-out/src/main/kotlin/hs/kr/entrydsm/configuration/adapterout/entity/FileDocumentJpaEntity.kt#L12-L61:files스키마의 제약과Instant매핑을 영속성 테스트로 검증하십시오.systems/configuration/configuration-adapter-out/src/main/kotlin/hs/kr/entrydsm/configuration/adapterout/config/S3Config.kt#L15-L25: 설정 리전이 두 AWS 클라이언트에 적용되는지 테스트하십시오.systems/configuration/configuration-domain/src/main/kotlin/hs/kr/entrydsm/configuration/domain/document/command/UploadFileCommand.kt#L5-L10: 필수fileName계약을 사용하는 업로드 호출을 테스트하십시오.
As per coding guidelines, “If production logic is changed in Kotlin or Go files, require corresponding test updates in the same subsystem unless the PR description explicitly justifies why tests are unnecessary”.
🧰 Tools
🪛 detekt (1.23.8)
[warning] 57-57: The caught exception is too generic. Prefer catching specific exceptions to the case that is currently handled.
(detekt.exceptions.TooGenericExceptionCaught)
📍 Affects 6 files
systems/configuration/configuration-application/src/main/kotlin/hs/kr/entrydsm/configuration/application/FileDocumentService.kt#L36-L105(this comment)systems/configuration/configuration-adapter-out/src/main/kotlin/hs/kr/entrydsm/configuration/adapterout/S3StorageAdapter.kt#L30-L94systems/configuration/configuration-adapter-out/src/main/kotlin/hs/kr/entrydsm/configuration/adapterout/FileDocumentPersistenceAdapter.kt#L14-L29systems/configuration/configuration-adapter-out/src/main/kotlin/hs/kr/entrydsm/configuration/adapterout/entity/FileDocumentJpaEntity.kt#L12-L61systems/configuration/configuration-adapter-out/src/main/kotlin/hs/kr/entrydsm/configuration/adapterout/config/S3Config.kt#L15-L25systems/configuration/configuration-domain/src/main/kotlin/hs/kr/entrydsm/configuration/domain/document/command/UploadFileCommand.kt#L5-L10
🤖 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
`@systems/configuration/configuration-application/src/main/kotlin/hs/kr/entrydsm/configuration/application/FileDocumentService.kt`
around lines 36 - 105, Add comprehensive tests for the changed file-storage
flow: in
systems/configuration/configuration-application/src/main/kotlin/hs/kr/entrydsm/configuration/application/FileDocumentService.kt:36-105,
test validation, successful upload, metadata-save compensation, and download URL
flows; in
systems/configuration/configuration-adapter-out/src/main/kotlin/hs/kr/entrydsm/configuration/adapterout/S3StorageAdapter.kt:30-94,
mock upload, presign, existence, deletion, and SDK exception translation; in
systems/configuration/configuration-adapter-out/src/main/kotlin/hs/kr/entrydsm/configuration/adapterout/FileDocumentPersistenceAdapter.kt:14-29,
test mapping and persistence contracts; in
systems/configuration/configuration-adapter-out/src/main/kotlin/hs/kr/entrydsm/configuration/adapterout/entity/FileDocumentJpaEntity.kt:12-61,
verify schema constraints and Instant mapping; in
systems/configuration/configuration-adapter-out/src/main/kotlin/hs/kr/entrydsm/configuration/adapterout/config/S3Config.kt:15-25,
verify the configured region reaches both AWS clients; and in
systems/configuration/configuration-domain/src/main/kotlin/hs/kr/entrydsm/configuration/domain/document/command/UploadFileCommand.kt:5-10,
test uploads using the required fileName contract.
Source: Coding guidelines
| val objectKey = command.category.objectKeyOf(FileNaming.requireSafeFileName(command.fileName)) | ||
| val stored = storagePort.upload(objectKey, extension.contentType, command.sizeBytes, content) | ||
|
|
||
| return try { | ||
| fileDocumentRepository.save( | ||
| FileDocument( | ||
| originalName = command.originalName, | ||
| objectKey = stored.objectKey, | ||
| bucket = stored.bucket, | ||
| contentType = extension.contentType, | ||
| sizeBytes = command.sizeBytes, | ||
| checksum = stored.checksum, | ||
| ) | ||
| ) | ||
| } catch (e: RuntimeException) { | ||
| deleteOrphan(objectKey) |
There was a problem hiding this comment.
🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy lift
🧩 Analysis chain
🏁 Script executed:
#!/bin/bash
set -eu
printf '%s\n' '--- target service ---'
cat -n systems/configuration/configuration-application/src/main/kotlin/hs/kr/entrydsm/configuration/application/FileDocumentService.kt | sed -n '1,130p'
printf '%s\n' '--- entity definitions and schema references ---'
rg -n -C 5 'class FileDocumentJpaEntity|objectKey|object_key|unique|`@Column`' systems/configuration
printf '%s\n' '--- storage port and adapters ---'
rg -n -C 5 'interface .*Storage|upload\(|deleteOrphan|delete\(' systems/configuration
printf '%s\n' '--- transaction and repository usage ---'
rg -n -C 4 '`@Transactional`|FileDocumentRepository|save\(' systems/configuration/configuration-application systems/configuration/configuration-adapter systems/configuration 2>/dev/null || true
printf '%s\n' '--- related tests ---'
git ls-files | rg '(^|/)(test|src/test)/|FileDocument|configuration' | rg 'Test|test' || trueRepository: EntryDSM/entrydsm-platform
Length of output: 50383
🏁 Script executed:
#!/bin/bash
set -eu
printf '%s\n' '--- persistence adapter ---'
cat -n systems/configuration/configuration-adapter-out/src/main/kotlin/hs/kr/entrydsm/configuration/adapterout/FileDocumentPersistenceAdapter.kt
printf '%s\n' '--- repository declarations ---'
rg -n -C 4 'interface FileDocumentJpaRepository|findByObjectKey|deleteByObjectKey' systems/configuration/configuration-adapter-out/src/main
printf '%s\n' '--- key generation and commands ---'
cat -n systems/configuration/configuration-domain/src/main/kotlin/hs/kr/entrydsm/configuration/domain/document/FileNaming.kt
cat -n systems/configuration/configuration-domain/src/main/kotlin/hs/kr/entrydsm/configuration/domain/document/FileCategory.kt
cat -n systems/configuration/configuration-domain/src/main/kotlin/hs/kr/entrydsm/configuration/domain/document/command/UploadFileCommand.kt
printf '%s\n' '--- schema/migrations ---'
rg -n -C 3 'create table.*files|object_key|files' --glob '*.sql' --glob '*.xml' --glob '*.yaml' --glob '*.yml' .
printf '%s\n' '--- focused tests ---'
git ls-files | rg 'FileDocument|configuration.*test|test.*configuration' | head -100Repository: EntryDSM/entrydsm-platform
Length of output: 19776
🏁 Script executed:
#!/bin/bash
set -eu
python3 - <<'PY'
from pathlib import Path
roots = [
Path("systems/configuration"),
]
terms = ("FileDocumentService", "storagePort.upload", "fileDocumentRepository.save",
"FileDocumentPersistenceAdapter", "S3StorageAdapter")
for root in roots:
for path in root.rglob("*"):
if path.is_file() and path.suffix in {".kt", ".java", ".sql", ".yml", ".yaml", ".xml"}:
try:
text = path.read_text()
except UnicodeDecodeError:
continue
if any(term in text for term in terms):
print(path)
for i, line in enumerate(text.splitlines(), 1):
if any(term in line for term in terms):
print(f" {i}: {line.strip()}")
PY
PYTHONUNBUFFERED=1Repository: EntryDSM/entrydsm-platform
Length of output: 796
🌐 Web query:
AWS S3 PutObject existing object same key overwrites object conditional If-None-Match documentation
💡 Result:
When performing a PutObject operation in Amazon S3, you can use the If-None-Match header to prevent the overwriting of an existing object with the same key [1][2]. To use this conditional write, you must set the If-None-Match header value to the asterisk (*) character [1][2]. The behavior of this operation is as follows: - If no object with the same key currently exists in the bucket, the upload succeeds with a 200 OK response [1]. - If an object with the same key already exists, the operation fails and returns a 412 Precondition Failed error [1][2]. - In cases where a conflicting operation occurs during the upload process (such as a concurrent write), S3 may return a 409 ConditionalRequestConflict error, in which case you should retry the request [1][2]. Note that this functionality is not supported for S3 on Outposts [2]. For buckets with versioning enabled, the operation evaluates against the current object version; if no current version exists or the current version is a delete marker, the upload succeeds [1].
Citations:
- 1: https://docs.aws.amazon.com/AmazonS3/latest/userguide/conditional-writes.html
- 2: https://docs.aws.amazon.com/AmazonS3/latest/API/API_PutObject.html
🏁 Script executed:
#!/bin/bash
set -eu
python3 - <<'PY'
from pathlib import Path
service = Path("systems/configuration/configuration-application/src/main/kotlin/hs/kr/entrydsm/configuration/application/FileDocumentService.kt").read_text()
adapter = Path("systems/configuration/configuration-adapter-out/src/main/kotlin/hs/kr/entrydsm/configuration/adapterout/S3StorageAdapter.kt").read_text()
persistence = Path("systems/configuration/configuration-adapter-out/src/main/kotlin/hs/kr/entrydsm/configuration/adapterout/FileDocumentPersistenceAdapter.kt").read_text()
entity = Path("systems/configuration/configuration-adapter-out/src/main/kotlin/hs/kr/entrydsm/configuration/adapterout/entity/FileDocumentJpaEntity.kt").read_text()
checks = {
"key_is_category_plus_filename": 'command.category.objectKeyOf(FileNaming.requireSafeFileName(command.fileName))' in service,
"upload_precedes_save": service.index("storagePort.upload") < service.index("fileDocumentRepository.save"),
"compensation_deletes_same_key": 'storagePort.delete(objectKey)' in service,
"s3_upload_uses_put_object": 's3Client.putObject(' in adapter,
"s3_upload_has_no_if_none_match": "ifNoneMatch" not in adapter and "If-None-Match" not in adapter,
"object_key_unique": '`@Column`(name = "object_key", unique = true' in entity,
"save_does_not_flush": '.save(' in persistence and '.saveAndFlush(' not in persistence,
}
for name, result in checks.items():
print(f"{name}={result}")
# Show the transaction-sensitive structure without executing repository code.
try_start = service.index("return try")
try_end = service.index(" } catch", try_start)
print("try_scope_contains_save=", "fileDocumentRepository.save" in service[try_start:try_end])
print("try_scope_contains_delete=", "deleteOrphan(objectKey)" in service[try_start:try_end])
print("transaction_annotation_present=", "`@Transactional`" in service)
PYRepository: EntryDSM/entrydsm-platform
Length of output: 475
객체 키 충돌 시 기존 객체를 삭제하지 않도록 수정하십시오.
FileNaming.requireSafeFileName은 고유성을 보장하지 않습니다. 동일한 카테고리와 파일명은 동일한 objectKey를 생성하며, S3StorageAdapter의 putObject는 기존 객체를 덮어씁니다. 이후 저장 예외가 발생하면 deleteOrphan(objectKey)가 기존 객체까지 삭제할 수 있습니다. 트랜잭션 커밋 시 발생하는 제약 조건 예외는 이 try/catch 범위 밖에서 발생할 수도 있습니다.
고유 키를 생성하거나 If-None-Match: * 조건부 업로드와 충돌 처리를 사용하십시오. 기존 키 정책을 유지하면 충돌 및 메타데이터 저장 실패를 검증하는 결정적 테스트도 추가하십시오.
🧰 Tools
🪛 detekt (1.23.8)
[warning] 57-57: The caught exception is too generic. Prefer catching specific exceptions to the case that is currently handled.
(detekt.exceptions.TooGenericExceptionCaught)
🤖 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
`@systems/configuration/configuration-application/src/main/kotlin/hs/kr/entrydsm/configuration/application/FileDocumentService.kt`
around lines 43 - 58, Update the upload flow around FileDocumentService and
storagePort.upload so an existing objectKey is never overwritten or deleted on
metadata-save failure. Generate a unique object key or use conditional upload
semantics with explicit collision handling, and ensure orphan cleanup only
removes an object created by the current request; also cover object-key
collisions and metadata persistence failures with deterministic tests.
| return try { | ||
| fileDocumentRepository.save( | ||
| FileDocument( | ||
| originalName = command.originalName, | ||
| objectKey = stored.objectKey, | ||
| bucket = stored.bucket, | ||
| contentType = extension.contentType, | ||
| sizeBytes = command.sizeBytes, | ||
| checksum = stored.checksum, | ||
| ) | ||
| ) | ||
| } catch (e: RuntimeException) { | ||
| deleteOrphan(objectKey) | ||
| throw e | ||
| } |
There was a problem hiding this comment.
🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy lift
🧩 Analysis chain
🏁 Script executed:
#!/bin/bash
set -eu
target='systems/configuration/configuration-application/src/main/kotlin/hs/kr/entrydsm/configuration/application/FileDocumentService.kt'
printf '%s\n' '--- target outline ---'
if command -v ast-grep >/dev/null 2>&1; then
ast-grep outline "$target" || true
fi
printf '%s\n' '--- target source ---'
cat -n "$target"
printf '%s\n' '--- related files ---'
git ls-files | grep -Ei '(^|/)(FileDocument|file.?document|configuration).*' | head -200
printf '%s\n' '--- transaction annotations and symbols ---'
rg -n -C 3 'FileDocumentService|fileDocumentRepository|`@Transactional`|deleteOrphan|objectKey|class FileDocument' systems/configuration systems 2>/dev/null | head -400Repository: EntryDSM/entrydsm-platform
Length of output: 50382
🏁 Script executed:
#!/bin/bash
set -eu
files=(
'systems/configuration/configuration-adapter-out/src/main/kotlin/hs/kr/entrydsm/configuration/adapterout/FileDocumentPersistenceAdapter.kt'
'systems/configuration/configuration-adapter-out/src/main/kotlin/hs/kr/entrydsm/configuration/adapterout/entity/FileDocumentJpaEntity.kt'
'systems/configuration/configuration-adapter-out/src/main/kotlin/hs/kr/entrydsm/configuration/adapterout/repository/FileDocumentJpaRepository.kt'
'systems/configuration/configuration-domain/src/main/kotlin/hs/kr/entrydsm/configuration/domain/document/port/out/FileDocumentRepository.kt'
'systems/configuration/configuration-domain/src/main/kotlin/hs/kr/entrydsm/configuration/domain/document/StoredObject.kt'
'systems/configuration/configuration-adapter-out/src/main/kotlin/hs/kr/entrydsm/configuration/adapterout/S3StorageAdapter.kt'
)
for file in "${files[@]}"; do
printf '\n--- %s ---\n' "$file"
test -f "$file" && cat -n "$file" || printf 'MISSING\n'
done
printf '\n--- application tests and upload callers ---\n'
git ls-files 'systems/configuration/*/src/test/**' | sort
rg -n -C 4 'upload\(|UploadFileUseCase|ConfigurationGrpcService|FileDocumentService' \
systems/configuration/configuration-adapter-in \
systems/configuration/configuration-application \
systems/configuration/configuration-bootstrap \
systems/configuration/configuration-adapter-out 2>/dev/null | head -300
printf '\n--- transaction configuration ---\n'
rg -n -C 3 'PlatformTransactionManager|JpaTransactionManager|`@EnableTransactionManagement`|spring.jpa|flush-mode|ddl-auto|transaction' \
systems/configuration build.gradle.kts settings.gradle.kts gradle.properties 2>/dev/null | head -300Repository: EntryDSM/entrydsm-platform
Length of output: 24951
🌐 Web query:
Spring Framework official TransactionSynchronization afterCompletion STATUS_COMMITTED STATUS_ROLLED_BACK STATUS_UNKNOWN transaction synchronization documentation
💡 Result:
In the Spring Framework, the TransactionSynchronization interface provides callback methods for transaction lifecycle events [1][2]. The afterCompletion(int status) method is invoked after a transaction has been committed or rolled back [1][2]. The status parameter indicates the outcome of the transaction using the following constant integer values defined in the TransactionSynchronization interface [1][2]: STATUS_COMMITTED (value: 0): Indicates the transaction completed via a proper commit [1][2]. STATUS_ROLLED_BACK (value: 1): Indicates the transaction completed via a proper rollback [1][2]. STATUS_UNKNOWN (value: 2): Indicates the transaction completion state is unknown, such as in cases of heuristic mixed completion or system errors [1][2]. Important Considerations: Transactional resources may still be active and accessible when afterCompletion is called [1][2]. However, as the transaction has already finished, data access code executed at this stage will typically participate in the original transaction context, meaning no further commit will occur for those operations [1][2]. If you need to perform transactional operations within this callback that require their own transaction, you must use propagation behavior PROPAGATION_REQUIRES_NEW [1][2]. Exceptions thrown within afterCompletion are logged by the framework but are not propagated to the caller [1][2]. Note that throwing TransactionException subclasses should be avoided in this method [1][2].
Citations:
- 1: https://docs.spring.io/spring-framework/docs/current/javadoc-api/org/springframework/transaction/support/TransactionSynchronization.html
- 2: https://github.com/spring-projects/spring-framework/blob/master/spring-tx/src/main/java/org/springframework/transaction/support/TransactionSynchronization.java
🏁 Script executed:
#!/bin/bash
set -eu
python3 - <<'PY'
from pathlib import Path
import re
service = Path("systems/configuration/configuration-application/src/main/kotlin/hs/kr/entrydsm/configuration/application/FileDocumentService.kt").read_text()
adapter = Path("systems/configuration/configuration-adapter-out/src/main/kotlin/hs/kr/entrydsm/configuration/adapterout/FileDocumentPersistenceAdapter.kt").read_text()
entity = Path("systems/configuration/configuration-adapter-out/src/main/kotlin/hs/kr/entrydsm/configuration/adapterout/entity/FileDocumentJpaEntity.kt").read_text()
tests = list(Path("systems/configuration/configuration-application/src/test").rglob("*.kt"))
checks = {
"class_transactional_read_only": "`@Transactional`(readOnly = true)" in service,
"upload_transactional": bool(re.search(r"`@Transactional`\s+override fun upload", service)),
"upload_before_save": service.indexOf("storagePort.upload") if False else service.index("storagePort.upload") < service.index("fileDocumentRepository.save"),
"save_inside_try": bool(re.search(r"return try\s*\{.*?fileDocumentRepository\.save", service, re.S)),
"delete_only_inside_catch": bool(re.search(r"catch\s*\(e: RuntimeException\)\s*\{\s*deleteOrphan\(objectKey\)", service, re.S)),
"adapter_uses_save_not_flush": "fileDocumentJpaRepository.save(" in adapter and "saveAndFlush" not in adapter,
"object_key_unique": "object_key" in entity and "unique = true" in entity,
"application_behavior_tests": len(tests) > 1,
}
for name, result in checks.items():
print(f"{name}={result}")
print("application_test_files=" + ",".join(str(p) for p in tests))
PYRepository: EntryDSM/entrydsm-platform
Length of output: 503
🏁 Script executed:
#!/bin/bash
set -eu
python3 - <<'PY'
from pathlib import Path
import re
service = Path("systems/configuration/configuration-application/src/main/kotlin/hs/kr/entrydsm/configuration/application/FileDocumentService.kt").read_text()
adapter = Path("systems/configuration/configuration-adapter-out/src/main/kotlin/hs/kr/entrydsm/configuration/adapterout/FileDocumentPersistenceAdapter.kt").read_text()
entity = Path("systems/configuration/configuration-adapter-out/src/main/kotlin/hs/kr/entrydsm/configuration/adapterout/entity/FileDocumentJpaEntity.kt").read_text()
tests = list(Path("systems/configuration/configuration-application/src/test").rglob("*.kt"))
checks = {
"class_transactional_read_only": "`@Transactional`(readOnly = true)" in service,
"upload_transactional": bool(re.search(r"`@Transactional`\s+override fun upload", service)),
"upload_before_save": service.index("storagePort.upload") < service.index("fileDocumentRepository.save"),
"save_inside_try": bool(re.search(r"return try\s*\{.*?fileDocumentRepository\.save", service, re.S)),
"delete_only_inside_catch": bool(re.search(r"catch\s*\(e: RuntimeException\)\s*\{\s*deleteOrphan\(objectKey\)", service, re.S)),
"adapter_uses_save_not_flush": "fileDocumentJpaRepository.save(" in adapter and "saveAndFlush" not in adapter,
"object_key_unique": "object_key" in entity and "unique = true" in entity,
"application_behavior_tests": len(tests) > 1,
}
for name, result in checks.items():
print(f"{name}={result}")
print("application_test_files=" + ",".join(str(p) for p in tests))
PYRepository: EntryDSM/entrydsm-platform
Length of output: 503
트랜잭션 완료 후 DB 실패에도 S3 보상을 실행하십시오.
JpaRepository.save의 flush 또는 commit 실패는 현재 catch 범위 밖에서 발생할 수 있습니다. 트랜잭션 완료 콜백에서 STATUS_COMMITTED가 아닌 경우 S3 삭제를 실행하고, 삭제 실패는 재시도 가능한 영속 보상 작업으로 기록하십시오. 해당 flush·commit 실패와 보상 실패를 검증하는 통합 테스트도 추가하십시오.
🧰 Tools
🪛 detekt (1.23.8)
[warning] 57-57: The caught exception is too generic. Prefer catching specific exceptions to the case that is currently handled.
(detekt.exceptions.TooGenericExceptionCaught)
🤖 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
`@systems/configuration/configuration-application/src/main/kotlin/hs/kr/entrydsm/configuration/application/FileDocumentService.kt`
around lines 46 - 60, FileDocumentService의 save 처리에서 JpaRepository.save의
flush·commit 실패까지 감지하도록 트랜잭션 완료 콜백을 등록하고, 완료 상태가 STATUS_COMMITTED가 아니면 S3 객체를
삭제하십시오. deleteOrphan 실패는 재시도 가능한 영속 보상 작업으로 기록하도록 보상 흐름을 확장하고, flush 실패·commit
실패·보상 실패를 검증하는 통합 테스트를 추가하십시오.
Summary
StoragePort를 AWS S3로,FileDocumentRepository를 JPA로 구현합니다.FileDocumentService유스케이스를 추가합니다.Related Issue
Scope
configuration-adapter-out,configuration-application,kotlin.MODULE.bazelImplementation
바이너리는 S3에, 메타데이터는 DB(
files)에 둡니다. 응답으로 바이너리를 스트리밍하지 않고 presigned URL을 반환해 서버 부하를 줄입니다.어댑터
S3StorageAdapter— 업로드 시 SHA-256 체크섬 수신, presigned URL 발급, 존재 확인, 삭제S3Config—S3Client/S3Presigner빈. 자격증명은 기본 provider chainFileDocumentJpaEntity/JpaRepository/PersistenceAdapter— ERDfiles스키마 대응,object_key에 유니크 제약SdkException을STORAGE_UPLOAD_FAILED/PRESIGN_FAILED도메인 예외로 변환해 AWS 타입이 어댑터 밖으로 새지 않게 합니다유스케이스 — 순서가 핵심입니다
S3 업로드는 DB 트랜잭션에 참여하지 않습니다. 순서를 잘못 잡으면 검증 실패인데 S3를 호출하거나, 메타데이터 없이 객체만 남습니다.
보상 삭제가 또 실패하면 로깅만 하고 원래 예외를 덮지 않습니다. 삭제 실패로 진짜 원인을 가리지 않기 위해서입니다.
Testing
컴파일과 기존 테스트 통과만 확인했습니다.
S3StorageAdapter는 실제 S3에 대해 검증되지 않았습니다.Deployment Notes
files테이블이 필요합니다.ddl-auto: validate이므로 없으면 기동 실패합니다. DDL은 PR 4에 포함되어 있습니다S3_BUCKET,AWS_REGION환경변수 주입 필요 (PR 4)Checklist
리뷰 시 봐주셨으면 하는 것
RequestBody.fromInputStream+checksumAlgorithm(SHA256)조합 — 스트리밍 체크섬이 실제로 동작하는지 목으로는 확인이 안 됩니다. 통합 테스트를 후속 이슈로 잡아두었습니다. 체크섬이 null이면 eTag로 폴백합니다.2.31.0— One-Version Rule에 따라kotlin.MODULE.bazel에만 기재했습니다. 팀에서 쓰는 버전이 따로 있으면 알려주세요.