diff --git a/src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/runtime/JpaSchemaSafetyValidator.java b/src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/runtime/JpaSchemaSafetyValidator.java index 6543460..fa9b5cf 100644 --- a/src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/runtime/JpaSchemaSafetyValidator.java +++ b/src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/runtime/JpaSchemaSafetyValidator.java @@ -3,14 +3,16 @@ package dev.caskeleton.bootstrap.runtime; import dev.caskeleton.bootstrap.runtime.startup.StartupFailures; import java.util.Locale; import java.util.Set; -import org.springframework.beans.factory.SmartInitializingSingleton; +import org.springframework.beans.BeansException; +import org.springframework.beans.factory.config.BeanFactoryPostProcessor; +import org.springframework.beans.factory.config.ConfigurableListableBeanFactory; import org.springframework.core.env.Environment; /** * Prevents Hibernate from becoming a production schema writer. Flyway owns the physical schema; * production may only disable Hibernate DDL or validate the schema. */ -public class JpaSchemaSafetyValidator implements SmartInitializingSingleton { +public class JpaSchemaSafetyValidator implements BeanFactoryPostProcessor { static final String DDL_AUTO_KEY = "spring.jpa.hibernate.ddl-auto"; static final String DDL_AUTO_ENV_KEY = "APP_DATASOURCE_DDL_AUTO"; @@ -24,8 +26,18 @@ public class JpaSchemaSafetyValidator implements SmartInitializingSingleton { this.environment = environment; } + /** + * {@code BeanFactoryPostProcessor} 이지 {@code SmartInitializingSingleton} 이 아닌 이유: 후자는 모든 싱글턴이 + * 만들어진 에 돈다. {@code entityManagerFactory} 도 그 싱글턴 중 하나이고, Hibernate 는 그것을 만들면서 {@code + * ddl-auto} 를 이미 적용한다 — 실측으로 확인했다: {@code ddl-auto=update} 로 prod 를 띄우면 로그에 "Initialized JPA + * EntityManagerFactory" 가 먼저, 그 다음에 이 가드의 PROFILE_MISMATCH 가 찍히고, 스키마에는 그 사이에 만들어진 테이블이 남는다. + * + *

즉 가드가 트래픽은 막았지만 스키마 변조는 못 막고 있었다. 잘못 설정된 배포가 운영 DB 를 이미 바꿔 놓고 실패하는 셈이라, 검사를 빈 인스턴스화 이전으로 + * 옮긴다. + */ @Override - public void afterSingletonsInstantiated() { + public void postProcessBeanFactory(ConfigurableListableBeanFactory beanFactory) + throws BeansException { if (!isProdActive()) { return; } diff --git a/src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/runtime/RuntimeSafetyConfig.java b/src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/runtime/RuntimeSafetyConfig.java index 426de3c..8e42909 100644 --- a/src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/runtime/RuntimeSafetyConfig.java +++ b/src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/runtime/RuntimeSafetyConfig.java @@ -29,8 +29,12 @@ public class RuntimeSafetyConfig { return new OpenInViewSafetyValidator(environment); } + /** + * {@code static} 이어야 한다 — {@code BeanFactoryPostProcessor} 는 다른 빈보다 먼저 만들어지므로, 인스턴스 메서드로 두면 이 설정 + * 클래스 전체가 too-early 로 초기화되어 경고가 뜬다. + */ @Bean - JpaSchemaSafetyValidator jpaSchemaSafetyValidator(Environment environment) { + static JpaSchemaSafetyValidator jpaSchemaSafetyValidator(Environment environment) { return new JpaSchemaSafetyValidator(environment); } diff --git a/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/service/UploadStudioAssetUseCase.java b/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/service/UploadStudioAssetUseCase.java index 8fdad45..89bfcc6 100644 --- a/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/service/UploadStudioAssetUseCase.java +++ b/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/service/UploadStudioAssetUseCase.java @@ -86,27 +86,44 @@ public class UploadStudioAssetUseCase implements CommandUseCase { - String storedKey = binaries.store(objectKey, content, mediaType); - return assets.create( - new AssetRepositoryPort.NewAsset( - assetId, - assetKey, - input.kind(), - mediaType, - storedKey, - input.originalFilename(), - input.byteSize(), - null, - null, - sha256(content), - input.altText(), - input.decorative(), - // 내용 판정을 통과했으므로 READY 다. 판정 실패는 위에서 이미 거절했다. - AssetManagementStatusView.READY), - input.principal()); - }); + // 오브젝트 스토리지 호출은 트랜잭션 밖이다. 원래는 inWrite 안에 있었는데, 그러면 네트워크 + // 왕복이 끝날 때까지 DB 커넥션과 행 잠금을 붙잡고 있게 된다 — 스토리지가 느려지면 그대로 + // 커넥션 풀 고갈로 번진다. 게다가 스토리지는 트랜잭션에 참여하지 않으므로 안에 둔다고 + // 원자성이 생기지도 않는다: 커밋이 실패하면 바이트는 이미 저장돼 있고 롤백되지 않는다. + // + // 그래서 순서를 뒤집는다 — 저장 먼저, DB 쓰기는 짧은 트랜잭션으로. DB 쓰기가 실패하면 + // 방금 올린 오브젝트를 지운다(보상). 그 삭제마저 실패하면 orphan 이 남지만, 그건 원래 + // 실패 경로에 있던 위험이고 지금은 최소한 로그로 드러난다. + String storedKey = binaries.store(objectKey, content, mediaType); + try { + return transactions.inWrite( + () -> + assets.create( + new AssetRepositoryPort.NewAsset( + assetId, + assetKey, + input.kind(), + mediaType, + storedKey, + input.originalFilename(), + input.byteSize(), + null, + null, + sha256(content), + input.altText(), + input.decorative(), + // 내용 판정을 통과했으므로 READY 다. 판정 실패는 위에서 이미 거절했다. + AssetManagementStatusView.READY), + input.principal())); + } catch (RuntimeException databaseFailure) { + try { + binaries.delete(objectKey); + } catch (RuntimeException cleanupFailure) { + // 원래 실패를 덮지 않는다 — 호출자가 알아야 하는 것은 업로드가 왜 실패했는지다. + databaseFailure.addSuppressed(cleanupFailure); + } + throw databaseFailure; + } } /** diff --git a/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/service/WorkingCopyInputValidator.java b/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/service/WorkingCopyInputValidator.java index 07c007e..c4a9dca 100644 --- a/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/service/WorkingCopyInputValidator.java +++ b/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/service/WorkingCopyInputValidator.java @@ -58,7 +58,18 @@ public final class WorkingCopyInputValidator { "document.relations must hold at most " + MAX_RELATIONS + " items"); } Set orders = new HashSet<>(); + Set targets = new HashSet<>(); for (RelationView relation : relations) { + if (relation.targetId() != null && !targets.add(relation.targetId().toString())) { + // 같은 대상을 두 번 연결하면 공개 문서에 같은 줄이 두 번 나오고, 관계를 하나 지웠을 때 + // 나머지 하나가 남아 "지웠는데 그대로"로 보인다. 계약에 uniqueItems 가 없어 스키마가 + // 걸러주지 못하므로 여기서 막는다. + throw StudioException.of( + StudioError.REQUEST_VALIDATION_FAILED, + "document.relations[].targetId must be unique; " + + relation.targetId() + + " is repeated"); + } if (relation.order() < 0) { throw StudioException.of( StudioError.REQUEST_VALIDATION_FAILED,