From 9d237ab0b7a328145482f543470b96fcbd7f4929 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=98=A4=EC=A4=80=EC=98=81?= Date: Tue, 25 Aug 2026 20:23:11 +0900 Subject: [PATCH 1/9] =?UTF-8?q?:sparkles:=20feat:=20=EC=A1=B0=EC=A7=81=20?= =?UTF-8?q?=EC=82=AD=EC=A0=9C,=20=ED=9A=8C=EC=9B=90=20=EC=82=AD=EC=A0=9C?= =?UTF-8?q?=20=EC=8B=9C=20=EC=97=B0=EA=B4=80=EB=90=9C=20Project=20?= =?UTF-8?q?=EC=97=94=ED=8B=B0=ED=8B=B0=20=EC=82=AD=EC=A0=9C=EB=A5=BC=20?= =?UTF-8?q?=EC=9C=84=ED=95=9C=20Repository=20=EB=A9=94=EC=84=9C=EB=93=9C?= =?UTF-8?q?=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../project/persistence/repository/ProjectRepository.java | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/main/java/com/whereyouad/WhereYouAd/domains/project/persistence/repository/ProjectRepository.java b/src/main/java/com/whereyouad/WhereYouAd/domains/project/persistence/repository/ProjectRepository.java index 92545875..05058228 100644 --- a/src/main/java/com/whereyouad/WhereYouAd/domains/project/persistence/repository/ProjectRepository.java +++ b/src/main/java/com/whereyouad/WhereYouAd/domains/project/persistence/repository/ProjectRepository.java @@ -24,4 +24,9 @@ public interface ProjectRepository extends JpaRepository { @Modifying @Query("UPDATE Project p SET p.status = :status WHERE p.organization.id = :orgId") void updateStatusByOrganizationId(@Param("orgId") Long orgId, @Param("status") Status status); + + // 조직 Hard Delete 시 소속 Project 엔티티 일괄 삭제 + @Modifying(clearAutomatically = true, flushAutomatically = true) + @Query("DELETE FROM Project p WHERE p.organization.id = :orgId") + void deleteByOrganizationId(@Param("orgId") Long orgId); } From c6e82cbf210009f5d18313931e0225ac3dac4224 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=98=A4=EC=A4=80=EC=98=81?= Date: Tue, 25 Aug 2026 20:36:32 +0900 Subject: [PATCH 2/9] =?UTF-8?q?Revert=20":sparkles:=20feat:=20=EC=A1=B0?= =?UTF-8?q?=EC=A7=81=20=EC=82=AD=EC=A0=9C,=20=ED=9A=8C=EC=9B=90=20?= =?UTF-8?q?=EC=82=AD=EC=A0=9C=20=EC=8B=9C=20=EC=97=B0=EA=B4=80=EB=90=9C=20?= =?UTF-8?q?Project=20=EC=97=94=ED=8B=B0=ED=8B=B0=20=EC=82=AD=EC=A0=9C?= =?UTF-8?q?=EB=A5=BC=20=EC=9C=84=ED=95=9C=20Repository=20=EB=A9=94?= =?UTF-8?q?=EC=84=9C=EB=93=9C=20=EC=B6=94=EA=B0=80"?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This reverts commit 9d237ab0b7a328145482f543470b96fcbd7f4929. --- .../project/persistence/repository/ProjectRepository.java | 5 ----- 1 file changed, 5 deletions(-) diff --git a/src/main/java/com/whereyouad/WhereYouAd/domains/project/persistence/repository/ProjectRepository.java b/src/main/java/com/whereyouad/WhereYouAd/domains/project/persistence/repository/ProjectRepository.java index 05058228..92545875 100644 --- a/src/main/java/com/whereyouad/WhereYouAd/domains/project/persistence/repository/ProjectRepository.java +++ b/src/main/java/com/whereyouad/WhereYouAd/domains/project/persistence/repository/ProjectRepository.java @@ -24,9 +24,4 @@ public interface ProjectRepository extends JpaRepository { @Modifying @Query("UPDATE Project p SET p.status = :status WHERE p.organization.id = :orgId") void updateStatusByOrganizationId(@Param("orgId") Long orgId, @Param("status") Status status); - - // 조직 Hard Delete 시 소속 Project 엔티티 일괄 삭제 - @Modifying(clearAutomatically = true, flushAutomatically = true) - @Query("DELETE FROM Project p WHERE p.organization.id = :orgId") - void deleteByOrganizationId(@Param("orgId") Long orgId); } From f8a8f54cff75e6f14f9e31a5d96b148833a3372c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=98=A4=EC=A4=80=EC=98=81?= Date: Wed, 26 Aug 2026 16:33:05 +0900 Subject: [PATCH 3/9] =?UTF-8?q?:sparkles:=20feat:=20ClickAnomalyEvent,=20C?= =?UTF-8?q?lickBaselineStat=20=EC=97=94=ED=8B=B0=ED=8B=B0=EC=9D=98=20?= =?UTF-8?q?=EC=97=B0=EA=B4=80=20=EC=82=AD=EC=A0=9C=20=EB=B3=B4=EC=9E=A5?= =?UTF-8?q?=EC=9D=84=20=EC=9C=84=ED=95=9C=20Repository=20=EB=A9=94?= =?UTF-8?q?=EC=84=9C=EB=93=9C=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../ClickAnomalyEventRepository.java | 25 +++++++++++++++++++ .../ClickBaselineStatRepository.java | 17 +++++++++++++ 2 files changed, 42 insertions(+) diff --git a/src/main/java/com/whereyouad/WhereYouAd/domains/click/persistence/repository/ClickAnomalyEventRepository.java b/src/main/java/com/whereyouad/WhereYouAd/domains/click/persistence/repository/ClickAnomalyEventRepository.java index 059a87dc..ff0a0433 100644 --- a/src/main/java/com/whereyouad/WhereYouAd/domains/click/persistence/repository/ClickAnomalyEventRepository.java +++ b/src/main/java/com/whereyouad/WhereYouAd/domains/click/persistence/repository/ClickAnomalyEventRepository.java @@ -2,6 +2,31 @@ import com.whereyouad.WhereYouAd.domains.click.persistence.entity.ClickAnomalyEvent; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; public interface ClickAnomalyEventRepository extends JpaRepository { + + // 광고계정 연동 해제 정리용 + // ad_content_id 는 FK 가 아니라 순수 컬럼이므로 조인 경로로 대상을 좁힌다 + // AdContent 가 cascade 로 삭제되기 전에 호출되어야 한다 + @Modifying + @Query(value = "DELETE FROM click_anomaly_event " + + "WHERE ad_content_id IN (" + + " SELECT ac.ad_content_id FROM ad_content ac " + + " JOIN ad_group ag ON ac.ad_group_id = ag.ad_group_id " + + " JOIN ad_campaign camp ON ag.ad_campaign_id = camp.ad_campaign_id " + + " WHERE camp.platform_account_id = :platformAccountId" + + ") " + + "LIMIT :batchSize", + nativeQuery = true) + int deleteByPlatformAccountIdInBatch(@Param("platformAccountId") Long platformAccountId, + @Param("batchSize") int batchSize); + + // 조직 Hard Delete 정리용 안전망 + // org_id 도 FK 가 아니라 순수 컬럼이므로 조직 삭제로는 자동 정리되지 않는다 + @Modifying + @Query("DELETE FROM ClickAnomalyEvent e WHERE e.orgId = :orgId") + void deleteByOrgId(@Param("orgId") Long orgId); } diff --git a/src/main/java/com/whereyouad/WhereYouAd/domains/click/persistence/repository/ClickBaselineStatRepository.java b/src/main/java/com/whereyouad/WhereYouAd/domains/click/persistence/repository/ClickBaselineStatRepository.java index 042e1bd8..c84b8bc1 100644 --- a/src/main/java/com/whereyouad/WhereYouAd/domains/click/persistence/repository/ClickBaselineStatRepository.java +++ b/src/main/java/com/whereyouad/WhereYouAd/domains/click/persistence/repository/ClickBaselineStatRepository.java @@ -2,6 +2,9 @@ import com.whereyouad.WhereYouAd.domains.click.persistence.entity.ClickBaselineStat; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; import java.util.Collection; import java.util.List; @@ -11,4 +14,18 @@ public interface ClickBaselineStatRepository extends JpaRepository findByAdContentIdInAndWeekdayAndHourOfDay( Collection adContentIds, int weekday, int hourOfDay); + + // 광고계정 연동 해제 정리용 (조직 정보가 없어 조인 경로로만 접근 가능) + @Modifying + @Query(value = "DELETE FROM click_baseline_stat " + + "WHERE ad_content_id IN (" + + " SELECT ac.ad_content_id FROM ad_content ac " + + " JOIN ad_group ag ON ac.ad_group_id = ag.ad_group_id " + + " JOIN ad_campaign camp ON ag.ad_campaign_id = camp.ad_campaign_id " + + " WHERE camp.platform_account_id = :platformAccountId" + + ") " + + "LIMIT :batchSize", + nativeQuery = true) + int deleteByPlatformAccountIdInBatch(@Param("platformAccountId") Long platformAccountId, + @Param("batchSize") int batchSize); } From 6c015bded0c252be139821578048be1ab5fc654b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=98=A4=EC=A4=80=EC=98=81?= Date: Wed, 26 Aug 2026 16:34:32 +0900 Subject: [PATCH 4/9] =?UTF-8?q?:sparkles:=20feat:=20=EC=A1=B0=EC=A7=81=20?= =?UTF-8?q?=EC=82=AD=EC=A0=9C=20=EC=8B=9C=20ClickAnomalyEvent=20=EC=97=B0?= =?UTF-8?q?=EA=B4=80=20=EC=82=AD=EC=A0=9C=20=EB=B3=B4=EC=9E=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domains/organization/domain/service/OrgServiceImpl.java | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/main/java/com/whereyouad/WhereYouAd/domains/organization/domain/service/OrgServiceImpl.java b/src/main/java/com/whereyouad/WhereYouAd/domains/organization/domain/service/OrgServiceImpl.java index 224882fc..eec20832 100644 --- a/src/main/java/com/whereyouad/WhereYouAd/domains/organization/domain/service/OrgServiceImpl.java +++ b/src/main/java/com/whereyouad/WhereYouAd/domains/organization/domain/service/OrgServiceImpl.java @@ -1,6 +1,7 @@ package com.whereyouad.WhereYouAd.domains.organization.domain.service; import com.whereyouad.WhereYouAd.domains.ai.persistence.repository.AIInsightReportRepository; +import com.whereyouad.WhereYouAd.domains.click.persistence.repository.ClickAnomalyEventRepository; import com.whereyouad.WhereYouAd.domains.organization.application.dto.request.OrgRequest; import com.whereyouad.WhereYouAd.domains.organization.application.dto.response.OrgResponse; import com.whereyouad.WhereYouAd.domains.organization.application.mapper.OrgConverter; @@ -47,6 +48,7 @@ public class OrgServiceImpl implements OrgService { private final AIInsightReportRepository aiInsightReportRepository; private final UserRepository userRepository; private final PlatformConnectionRepository platformConnectionRepository; + private final ClickAnomalyEventRepository clickAnomalyEventRepository; private final RedisUtil redisUtil; private final EmailService emailService; @@ -268,6 +270,7 @@ public void removeOrganization(Long userId, Long orgId) { timelineRepository.deleteByOrganizationId(orgId); aiInsightReportRepository.deleteByOrganizationId(orgId); orgInvitationRepository.deleteByOrganizationId(orgId); + clickAnomalyEventRepository.deleteByOrgId(orgId); String logoUrl = organization.getLogoUrl(); @@ -364,6 +367,7 @@ public void removeOrganizationsOwnedBySoftDeletedUser(Long userId) { timelineRepository.deleteByOrganizationId(orgId); aiInsightReportRepository.deleteByOrganizationId(orgId); orgInvitationRepository.deleteByOrganizationId(orgId); + clickAnomalyEventRepository.deleteByOrgId(orgId); // 조직 Hard Delete orgRepository.delete(organization); From 287dace9021fc2cd7d9c088fc393f00b84ffb680 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=98=A4=EC=A4=80=EC=98=81?= Date: Wed, 26 Aug 2026 16:35:48 +0900 Subject: [PATCH 5/9] =?UTF-8?q?:sparkles:=20feat:=20=EA=B4=91=EA=B3=A0=20?= =?UTF-8?q?=EA=B3=84=EC=A0=95=20=EC=97=B0=EB=8F=99=20=ED=95=B4=EC=A0=9C=20?= =?UTF-8?q?=EC=8B=9C=20ClickAnomalyEvent,=20ClickBaselineStat=20=EC=97=B0?= =?UTF-8?q?=EA=B4=80=20=EC=82=AD=EC=A0=9C=20=EB=B3=B4=EC=9E=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/service/PlatformServiceImpl.java | 20 ++++++++++++++++++- .../PlatformDataCleanupExecutor.java | 16 +++++++++++++++ 2 files changed, 35 insertions(+), 1 deletion(-) diff --git a/src/main/java/com/whereyouad/WhereYouAd/domains/platform/domain/service/PlatformServiceImpl.java b/src/main/java/com/whereyouad/WhereYouAd/domains/platform/domain/service/PlatformServiceImpl.java index 67445641..3ce66779 100644 --- a/src/main/java/com/whereyouad/WhereYouAd/domains/platform/domain/service/PlatformServiceImpl.java +++ b/src/main/java/com/whereyouad/WhereYouAd/domains/platform/domain/service/PlatformServiceImpl.java @@ -223,12 +223,15 @@ public void disconnectAccountBySystem(Long accountId) { } // 계정 단위 데이터 정리 메서드화 - // ClickLog / MetricFact 청크 삭제 → AdCampaign + PlatformConnection + PlatformAccount 삭제 → 빈 Project 삭제 + // 1. ClickLog / MetricFact / ClickAnomalyEvent / ClickBaselineStat 청크 삭제 + // 2. AdCampaign + PlatformConnection + PlatformAccount 삭제 -> 3. 빈 Project 삭제 // 대규모 엔티티 삭제를 위해 별도 처리 클래스 (PlatformDataCleanupExecutor) 에서 Chunk 단위 삭제 처리 private void cleanupAccount(Long accountId, List projectIds) { int chunkDeleted; // 하나의 청크 당 삭제 갯수 long totalClickLogDeleted = 0L; // ClickLog 전체 삭제 갯수 long totalMetricFactDeleted = 0L; // MetricFact 전체 삭제 갯수 + long totalAnomalyEventDeleted = 0L; + long totalBaselineStatDeleted = 0L; // ClickLog 청크 정리 (REQUIRES_NEW) do { @@ -237,6 +240,21 @@ private void cleanupAccount(Long accountId, List projectIds) { } while (chunkDeleted > 0); log.info("ClickLog 삭제 완료 - platformAccountId={}, totalCount={}", accountId, totalClickLogDeleted); + // ClickAnomalyEvent 청크 정리 (REQUIRES_NEW) + // ad_content 가 cascade 삭제되기 전에 수행해야 조인으로 대상을 특정할 수 있다 + do { + chunkDeleted = platformDataCleanupExecutor.deleteClickAnomalyEventChunk(accountId); + totalAnomalyEventDeleted += chunkDeleted; + } while (chunkDeleted > 0); + log.info("ClickAnomalyEvent 삭제 완료 - platformAccountId={}, totalCount={}", accountId, totalAnomalyEventDeleted); + + // ClickBaselineStat 청크 정리 (REQUIRES_NEW) + do { + chunkDeleted = platformDataCleanupExecutor.deleteClickBaselineStatChunk(accountId); + totalBaselineStatDeleted += chunkDeleted; + } while (chunkDeleted > 0); + log.info("ClickBaselineStat 삭제 완료 - platformAccountId={}, totalCount={}", accountId, totalBaselineStatDeleted); + // MetricFact 청크 정리 (REQUIRES_NEW) — Project 삭제 단계에서 FK 위반 방지 do { chunkDeleted = platformDataCleanupExecutor.deleteMetricFactChunk(accountId); diff --git a/src/main/java/com/whereyouad/WhereYouAd/domains/platform/domain/service/scheduler/PlatformDataCleanupExecutor.java b/src/main/java/com/whereyouad/WhereYouAd/domains/platform/domain/service/scheduler/PlatformDataCleanupExecutor.java index 4bd4931d..4b2cce2c 100644 --- a/src/main/java/com/whereyouad/WhereYouAd/domains/platform/domain/service/scheduler/PlatformDataCleanupExecutor.java +++ b/src/main/java/com/whereyouad/WhereYouAd/domains/platform/domain/service/scheduler/PlatformDataCleanupExecutor.java @@ -3,6 +3,8 @@ import com.whereyouad.WhereYouAd.domains.advertisement.persistence.entity.AdCampaign; import com.whereyouad.WhereYouAd.domains.advertisement.persistence.repository.AdCampaignRepository; import com.whereyouad.WhereYouAd.domains.advertisement.persistence.repository.MetricFactRepository; +import com.whereyouad.WhereYouAd.domains.click.persistence.repository.ClickAnomalyEventRepository; +import com.whereyouad.WhereYouAd.domains.click.persistence.repository.ClickBaselineStatRepository; import com.whereyouad.WhereYouAd.domains.click.persistence.repository.ClickLogRepository; import com.whereyouad.WhereYouAd.domains.platform.persistence.entity.PlatformAccount; import com.whereyouad.WhereYouAd.domains.platform.persistence.entity.PlatformConnection; @@ -30,6 +32,8 @@ public class PlatformDataCleanupExecutor { private final ProjectRepository projectRepository; private final ClickLogRepository clickLogRepository; private final MetricFactRepository metricFactRepository; + private final ClickAnomalyEventRepository clickAnomalyEventRepository; + private final ClickBaselineStatRepository clickBaselineStatRepository; // 삭제에 영향받는 projectId 수집 (수동 연동 해제 정리 / 회원 탈퇴 스케줄러 등 시스템 내부 호출용) @Transactional(readOnly = true) @@ -37,6 +41,18 @@ public List collectProjectIds(Long accountId) { return adCampaignRepository.findDistinctProjectIdsByPlatformAccountId(accountId); } + // 청크 단위로 ClickAnomalyEvent 삭제 — ad_content_id 가 FK 가 아니라 자동 정리되지 않는다 + @Transactional(propagation = Propagation.REQUIRES_NEW) + public int deleteClickAnomalyEventChunk(Long platformAccountId) { + return clickAnomalyEventRepository.deleteByPlatformAccountIdInBatch(platformAccountId, BATCH_SIZE); + } + + // 청크 단위로 ClickBaselineStat 삭제 + @Transactional(propagation = Propagation.REQUIRES_NEW) + public int deleteClickBaselineStatChunk(Long platformAccountId) { + return clickBaselineStatRepository.deleteByPlatformAccountIdInBatch(platformAccountId, BATCH_SIZE); + } + // 청크 단위로 ClickLog 삭제 — 메인 트랜잭션과 분리 @Transactional(propagation = Propagation.REQUIRES_NEW) public int deleteClickLogChunk(Long platformAccountId) { From 9c78fa726e7964833d5415e91b153cfeef923e05 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=98=A4=EC=A4=80=EC=98=81?= Date: Wed, 26 Aug 2026 16:36:30 +0900 Subject: [PATCH 6/9] =?UTF-8?q?:sparkles:=20feat:=20=ED=85=8C=EC=8A=A4?= =?UTF-8?q?=ED=8A=B8=20=ED=99=98=EA=B2=BD=EC=9A=A9=20application-test.yml?= =?UTF-8?q?=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/test/resources/application-test.yml | 11 +++++++++++ 1 file changed, 11 insertions(+) create mode 100644 src/test/resources/application-test.yml diff --git a/src/test/resources/application-test.yml b/src/test/resources/application-test.yml new file mode 100644 index 00000000..7aefa406 --- /dev/null +++ b/src/test/resources/application-test.yml @@ -0,0 +1,11 @@ +spring: + datasource: + driver-class-name: com.mysql.cj.jdbc.Driver + url: jdbc:mysql://localhost:3306/whereyouad_test?createDatabaseIfNotExist=true&serverTimezone=Asia/Seoul&characterEncoding=UTF-8 + # createDatabaseIfNotExist=true 이 필드를 통해 실행되는 환경에 whereyouad_test 라는 데이터베이스를 없으면 자동 생성 + username: ${DB_USERNAME} + password: ${DB_PASSWORD} + jpa: + hibernate: + ddl-auto: create-drop # 테스트를 위해 DB 테이블 생성 후, 테스트 종료시 모든 테이블 삭제 + show-sql: true \ No newline at end of file From 9327c0cb4d5ddd7f6d3b818889e1565f4920cc70 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=98=A4=EC=A4=80=EC=98=81?= Date: Wed, 26 Aug 2026 16:37:25 +0900 Subject: [PATCH 7/9] =?UTF-8?q?:white=5Fcheck=5Fmark:=20test:=20=EC=97=94?= =?UTF-8?q?=ED=8B=B0=ED=8B=B0=20=EC=B6=94=EA=B0=80=20=EC=8B=9C=20FK=20?= =?UTF-8?q?=EC=9C=84=EB=B0=98=20or=20=EC=97=94=ED=8B=B0=ED=8B=B0=20?= =?UTF-8?q?=EC=82=AD=EC=A0=9C=20=EC=98=A4=EB=A5=98=20=EA=B0=80=EB=8A=A5?= =?UTF-8?q?=EC=84=B1=20=ED=99=95=EC=9D=B8=EC=9D=84=20=EC=9C=84=ED=95=9C=20?= =?UTF-8?q?=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../common/DeletionSafetyGuardTest.java | 147 ++++++++++++++++++ 1 file changed, 147 insertions(+) create mode 100644 src/test/java/com/whereyouad/WhereYouAd/global/common/DeletionSafetyGuardTest.java diff --git a/src/test/java/com/whereyouad/WhereYouAd/global/common/DeletionSafetyGuardTest.java b/src/test/java/com/whereyouad/WhereYouAd/global/common/DeletionSafetyGuardTest.java new file mode 100644 index 00000000..26359d6d --- /dev/null +++ b/src/test/java/com/whereyouad/WhereYouAd/global/common/DeletionSafetyGuardTest.java @@ -0,0 +1,147 @@ +package com.whereyouad.WhereYouAd.global.common; + +import jakarta.persistence.EntityManager; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase; +import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; +import org.springframework.test.context.ActiveProfiles; + +import java.util.List; +import java.util.Set; +import java.util.stream.Collectors; + +import static org.assertj.core.api.Assertions.assertThat; + +// 삭제 안전망: 조직/회원을 참조하는 스키마 구조가 바뀌면 실패한다. +// 새 엔티티를 추가한 사람이 "이건 조직/회원 삭제 시 어떻게 정리되지?" 를 반드시 한 번 생각하게 만드는 게 목적. +// 검토를 마쳤다면 아래 상수에 새 항목을 추가하고 삭제 로직도 함께 반영 필요 +@DataJpaTest +@ActiveProfiles("test") +@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE) +class DeletionSafetyGuardTest { + + @Autowired + private EntityManager em; + + // organization 을 참조하는 FK 전체 - "테이블.컬럼:삭제규칙" + private static final Set EXPECTED_ORG_FK = Set.of( + // (1) 조직 Hard Delete 가 명시적으로 삭제 + "ai_insight_report.org_id:NO ACTION", // aiInsightReportRepository.deleteByOrganizationId + "org_invitation.org_id:NO ACTION", // orgInvitationRepository.deleteByOrganizationId + "org_member.org_id:NO ACTION", // orgMemberRepository.deleteAll + "timeline.org_id:NO ACTION", // timelineRepository.deleteByOrganizationId + + // (2) DB FK 의 ON DELETE CASCADE 가 정리 + "notification.org_id:CASCADE", + "org_notification_setting.org_id:CASCADE", + + // (3) "조직 삭제 전 광고 계정 연동이 해제되어 있다"는 전제에 의존 + // 연동 해제(PlatformDataCleanupExecutor.deleteAccountAndRelations)가 미리 지워준다 + "ad_campaign.org_id:NO ACTION", + "platform_account.org_id:NO ACTION", + "project.org_id:NO ACTION" + ); + + // users 를 참조하는 FK 전체 + private static final Set EXPECTED_USER_FK = Set.of( + // (1) 회원 Hard Delete 가 명시적으로 삭제 + "auth_provider_account.user_id:NO ACTION", // authProviderAccountRepository.deleteByUserId + "org_member.user_id:NO ACTION", // orgMemberRepository.deleteByUserId + + // (2) DB FK 의 ON DELETE CASCADE 가 정리 + "user_notification.user_id:CASCADE", + + // (3) 회원 삭제 전 스케줄러가 광고 계정 연동을 해제하며 정리 + // 정리 실패 시 UserDeleteScheduler 가 해당 회원 삭제를 보류한다 + "platform_connection.user_id:NO ACTION" + ); + + // org 를 가리키지만 FK 가 아닌 컬럼 - DB 가 정리해주지 않으므로 코드가 책임져야 한다 + private static final Set EXPECTED_ORG_PLAIN_REFERENCES = Set.of( + "click_anomaly_event.org_id", // 조직 Hard Delete 시 deleteByOrgId 로 직접 삭제 + "users.current_org_id" // 조직 삭제 시 해당 멤버들의 값을 null 로 초기화 + ); + + // user 를 가리키지만 FK 가 아닌 컬럼 + private static final Set EXPECTED_USER_PLAIN_REFERENCES = Set.of( + "organization.owner_user_id", // 회원 Hard Delete 시 소유 조직을 함께 삭제 + "project.created_by", // 감사 필드 - 회원 삭제 후에도 남는다 (의도된 잔존) + "timeline.created_by" // 감사 필드 - 위와 동일 + ); + + + @Test + @DisplayName("조직을 참조하는 FK 목록이 변하지 않았는가 - 변했다면 조직 삭제 로직 검토 필요") + void organizationReferencesUnchanged() { + assertThat(foreignKeysReferencing("organization")) + .as("organization 참조 FK 가 변경됨. 조직 Hard Delete 경로를 검토하고 기준선을 갱신할 것") + .isEqualTo(EXPECTED_ORG_FK); + } + + @Test + @DisplayName("회원을 참조하는 FK 목록이 변하지 않았는가 - 변했다면 회원 삭제 로직 검토 필요") + void userReferencesUnchanged() { + assertThat(foreignKeysReferencing("users")) + .as("users 참조 FK 가 변경됨. 회원 Hard Delete 경로를 검토하고 기준선을 갱신할 것") + .isEqualTo(EXPECTED_USER_FK); + } + + @Test + @DisplayName("FK 없이 조직을 가리키는 컬럼 목록이 변하지 않았는가 - DB 가 정리해주지 않는 대상") + void orgPlainReferencesUnchanged() { + assertThat(plainReferenceColumns("c.COLUMN_NAME LIKE '%org_id%'")) + .as("FK 없이 조직을 가리키는 컬럼이 추가됨. 조직 삭제 시 명시적 처리 코드가 반드시 필요하다") + .isEqualTo(EXPECTED_ORG_PLAIN_REFERENCES); + } + + @Test + @DisplayName("FK 없이 회원을 가리키는 컬럼 목록이 변하지 않았는가 - DB 가 정리해주지 않는 대상") + void userPlainReferencesUnchanged() { + assertThat(plainReferenceColumns("c.COLUMN_NAME LIKE '%user_id%' OR c.COLUMN_NAME = 'created_by'")) + .as("FK 없이 회원을 가리키는 컬럼이 추가됨. 회원 삭제 시 처리 방침을 결정할 것") + .isEqualTo(EXPECTED_USER_PLAIN_REFERENCES); + } + + @SuppressWarnings("unchecked") + private Set foreignKeysReferencing(String referencedTable) { + List rows = em.createNativeQuery( + "SELECT kcu.TABLE_NAME, kcu.COLUMN_NAME, rc.DELETE_RULE " + + "FROM information_schema.REFERENTIAL_CONSTRAINTS rc " + + "JOIN information_schema.KEY_COLUMN_USAGE kcu " + + " ON rc.CONSTRAINT_NAME = kcu.CONSTRAINT_NAME " + + " AND rc.CONSTRAINT_SCHEMA = kcu.CONSTRAINT_SCHEMA " + + "WHERE rc.CONSTRAINT_SCHEMA = DATABASE() " + + " AND rc.REFERENCED_TABLE_NAME = :referencedTable") + .setParameter("referencedTable", referencedTable) + .getResultList(); + + return rows.stream() + .map(row -> row[0] + "." + row[1] + ":" + row[2]) + .collect(Collectors.toSet()); + } + + // FK 도 PK 도 아닌 참조성 컬럼을 찾는다. + // namePredicate 는 이 클래스 안의 상수 문자열만 넘어오므로 주입 위험은 없다. + @SuppressWarnings("unchecked") + private Set plainReferenceColumns(String namePredicate) { + List rows = em.createNativeQuery( + "SELECT c.TABLE_NAME, c.COLUMN_NAME FROM information_schema.COLUMNS c " + + "WHERE c.TABLE_SCHEMA = DATABASE() " + + " AND (" + namePredicate + ") " + + " AND NOT EXISTS (" + // FK 컬럼 제외 - DB 가 알아서 정리하거나 막아준다 + " SELECT 1 FROM information_schema.KEY_COLUMN_USAGE k " + + " WHERE k.TABLE_SCHEMA = c.TABLE_SCHEMA AND k.TABLE_NAME = c.TABLE_NAME " + + " AND k.COLUMN_NAME = c.COLUMN_NAME AND k.REFERENCED_TABLE_NAME IS NOT NULL) " + + " AND NOT EXISTS (" + // PK 컬럼 제외 - organization.org_id, users.user_id 자기 자신 + " SELECT 1 FROM information_schema.KEY_COLUMN_USAGE k " + + " WHERE k.TABLE_SCHEMA = c.TABLE_SCHEMA AND k.TABLE_NAME = c.TABLE_NAME " + + " AND k.COLUMN_NAME = c.COLUMN_NAME AND k.CONSTRAINT_NAME = 'PRIMARY')") + .getResultList(); + + return rows.stream() + .map(row -> row[0] + "." + row[1]) + .collect(Collectors.toSet()); + } +} From 649f8c20904faeabda573c4eeb424ffc2e291d76 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=98=A4=EC=A4=80=EC=98=81?= Date: Thu, 27 Aug 2026 14:17:34 +0900 Subject: [PATCH 8/9] =?UTF-8?q?:art:=20style:=20=EC=84=A4=EB=AA=85=20?= =?UTF-8?q?=EC=A3=BC=EC=84=9D=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../common/DeletionSafetyGuardTest.java | 27 +++++++++---------- 1 file changed, 13 insertions(+), 14 deletions(-) diff --git a/src/test/java/com/whereyouad/WhereYouAd/global/common/DeletionSafetyGuardTest.java b/src/test/java/com/whereyouad/WhereYouAd/global/common/DeletionSafetyGuardTest.java index 26359d6d..ae9c0c42 100644 --- a/src/test/java/com/whereyouad/WhereYouAd/global/common/DeletionSafetyGuardTest.java +++ b/src/test/java/com/whereyouad/WhereYouAd/global/common/DeletionSafetyGuardTest.java @@ -14,9 +14,9 @@ import static org.assertj.core.api.Assertions.assertThat; -// 삭제 안전망: 조직/회원을 참조하는 스키마 구조가 바뀌면 실패한다. -// 새 엔티티를 추가한 사람이 "이건 조직/회원 삭제 시 어떻게 정리되지?" 를 반드시 한 번 생각하게 만드는 게 목적. -// 검토를 마쳤다면 아래 상수에 새 항목을 추가하고 삭제 로직도 함께 반영 필요 +// 엔티티 삭제 오류 방지 : Organization/User 엔티티를 참조하는 스키마 구조가 바뀌면 실패 +// 새 엔티티를 추가한 시점에 "이건 조직/회원 삭제 시 어떻게 정리되지?" 를 생각하게 만드는 목적. +// 검토, 처리 이후 아래 상수에 새 항목을 추가하고 삭제 로직도 함께 반영 필요 @DataJpaTest @ActiveProfiles("test") @AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE) @@ -27,18 +27,18 @@ class DeletionSafetyGuardTest { // organization 을 참조하는 FK 전체 - "테이블.컬럼:삭제규칙" private static final Set EXPECTED_ORG_FK = Set.of( - // (1) 조직 Hard Delete 가 명시적으로 삭제 + // 조직 Hard Delete 가 명시적으로 삭제 "ai_insight_report.org_id:NO ACTION", // aiInsightReportRepository.deleteByOrganizationId "org_invitation.org_id:NO ACTION", // orgInvitationRepository.deleteByOrganizationId "org_member.org_id:NO ACTION", // orgMemberRepository.deleteAll "timeline.org_id:NO ACTION", // timelineRepository.deleteByOrganizationId - // (2) DB FK 의 ON DELETE CASCADE 가 정리 + // DB FK 의 ON DELETE CASCADE 가 정리 "notification.org_id:CASCADE", "org_notification_setting.org_id:CASCADE", - // (3) "조직 삭제 전 광고 계정 연동이 해제되어 있다"는 전제에 의존 - // 연동 해제(PlatformDataCleanupExecutor.deleteAccountAndRelations)가 미리 지워준다 + // "조직 삭제 전 광고 계정 연동이 해제되어 있다"는 전제에 의존 + // 연동 해제(PlatformDataCleanupExecutor.deleteAccountAndRelations)가 미리 지워준다 "ad_campaign.org_id:NO ACTION", "platform_account.org_id:NO ACTION", "project.org_id:NO ACTION" @@ -46,19 +46,19 @@ class DeletionSafetyGuardTest { // users 를 참조하는 FK 전체 private static final Set EXPECTED_USER_FK = Set.of( - // (1) 회원 Hard Delete 가 명시적으로 삭제 + // 회원 Hard Delete 가 명시적으로 삭제 "auth_provider_account.user_id:NO ACTION", // authProviderAccountRepository.deleteByUserId "org_member.user_id:NO ACTION", // orgMemberRepository.deleteByUserId - // (2) DB FK 의 ON DELETE CASCADE 가 정리 + // DB FK 의 ON DELETE CASCADE 가 정리 "user_notification.user_id:CASCADE", - // (3) 회원 삭제 전 스케줄러가 광고 계정 연동을 해제하며 정리 - // 정리 실패 시 UserDeleteScheduler 가 해당 회원 삭제를 보류한다 + // 회원 삭제 전 스케줄러가 광고 계정 연동을 해제하며 정리 + // 정리 실패 시 UserDeleteScheduler 가 해당 회원 삭제를 보류한다 "platform_connection.user_id:NO ACTION" ); - // org 를 가리키지만 FK 가 아닌 컬럼 - DB 가 정리해주지 않으므로 코드가 책임져야 한다 + // org 를 가리키지만 FK 가 아닌 컬럼 private static final Set EXPECTED_ORG_PLAIN_REFERENCES = Set.of( "click_anomaly_event.org_id", // 조직 Hard Delete 시 deleteByOrgId 로 직접 삭제 "users.current_org_id" // 조직 삭제 시 해당 멤버들의 값을 null 로 초기화 @@ -122,8 +122,7 @@ private Set foreignKeysReferencing(String referencedTable) { .collect(Collectors.toSet()); } - // FK 도 PK 도 아닌 참조성 컬럼을 찾는다. - // namePredicate 는 이 클래스 안의 상수 문자열만 넘어오므로 주입 위험은 없다. + // FK 도 PK 도 아닌 참조성 컬럼을 찾기 @SuppressWarnings("unchecked") private Set plainReferenceColumns(String namePredicate) { List rows = em.createNativeQuery( From 3c482f6cfa89fad5d4811e3d61ba21a3aa006d21 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=98=A4=EC=A4=80=EC=98=81?= Date: Thu, 27 Aug 2026 14:19:29 +0900 Subject: [PATCH 9/9] =?UTF-8?q?:art:=20style:=20=EC=A3=BC=EC=84=9D=20?= =?UTF-8?q?=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../persistence/repository/ClickAnomalyEventRepository.java | 4 ++-- .../persistence/repository/ClickBaselineStatRepository.java | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/main/java/com/whereyouad/WhereYouAd/domains/click/persistence/repository/ClickAnomalyEventRepository.java b/src/main/java/com/whereyouad/WhereYouAd/domains/click/persistence/repository/ClickAnomalyEventRepository.java index ff0a0433..b8a48cef 100644 --- a/src/main/java/com/whereyouad/WhereYouAd/domains/click/persistence/repository/ClickAnomalyEventRepository.java +++ b/src/main/java/com/whereyouad/WhereYouAd/domains/click/persistence/repository/ClickAnomalyEventRepository.java @@ -9,7 +9,7 @@ public interface ClickAnomalyEventRepository extends JpaRepository { // 광고계정 연동 해제 정리용 - // ad_content_id 는 FK 가 아니라 순수 컬럼이므로 조인 경로로 대상을 좁힌다 + // ad_content_id 는 FK 가 아니라 순수 컬럼이므로 JOIN 사용 // AdContent 가 cascade 로 삭제되기 전에 호출되어야 한다 @Modifying @Query(value = "DELETE FROM click_anomaly_event " + @@ -25,7 +25,7 @@ int deleteByPlatformAccountIdInBatch(@Param("platformAccountId") Long platformAc @Param("batchSize") int batchSize); // 조직 Hard Delete 정리용 안전망 - // org_id 도 FK 가 아니라 순수 컬럼이므로 조직 삭제로는 자동 정리되지 않는다 + // org_id 도 FK 가 아니라 순수 컬럼이므로 조직 삭제로는 자동 정리 X @Modifying @Query("DELETE FROM ClickAnomalyEvent e WHERE e.orgId = :orgId") void deleteByOrgId(@Param("orgId") Long orgId); diff --git a/src/main/java/com/whereyouad/WhereYouAd/domains/click/persistence/repository/ClickBaselineStatRepository.java b/src/main/java/com/whereyouad/WhereYouAd/domains/click/persistence/repository/ClickBaselineStatRepository.java index c84b8bc1..52ebf376 100644 --- a/src/main/java/com/whereyouad/WhereYouAd/domains/click/persistence/repository/ClickBaselineStatRepository.java +++ b/src/main/java/com/whereyouad/WhereYouAd/domains/click/persistence/repository/ClickBaselineStatRepository.java @@ -15,7 +15,7 @@ public interface ClickBaselineStatRepository extends JpaRepository findByAdContentIdInAndWeekdayAndHourOfDay( Collection adContentIds, int weekday, int hourOfDay); - // 광고계정 연동 해제 정리용 (조직 정보가 없어 조인 경로로만 접근 가능) + // 광고계정 연동 해제 정리용 (조직 정보가 없어 JOIN 사용) @Modifying @Query(value = "DELETE FROM click_baseline_stat " + "WHERE ad_content_id IN (" +