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..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 @@ -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 가 아니라 순수 컬럼이므로 JOIN 사용 + // 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 가 아니라 순수 컬럼이므로 조직 삭제로는 자동 정리 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 042e1bd8..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 @@ -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); + + // 광고계정 연동 해제 정리용 (조직 정보가 없어 JOIN 사용) + @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); } 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); 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) { 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..ae9c0c42 --- /dev/null +++ b/src/test/java/com/whereyouad/WhereYouAd/global/common/DeletionSafetyGuardTest.java @@ -0,0 +1,146 @@ +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; + +// 엔티티 삭제 오류 방지 : Organization/User 엔티티를 참조하는 스키마 구조가 바뀌면 실패 +// 새 엔티티를 추가한 시점에 "이건 조직/회원 삭제 시 어떻게 정리되지?" 를 생각하게 만드는 목적. +// 검토, 처리 이후 아래 상수에 새 항목을 추가하고 삭제 로직도 함께 반영 필요 +@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( + // 조직 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 + + // DB FK 의 ON DELETE CASCADE 가 정리 + "notification.org_id:CASCADE", + "org_notification_setting.org_id:CASCADE", + + // "조직 삭제 전 광고 계정 연동이 해제되어 있다"는 전제에 의존 + // 연동 해제(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( + // 회원 Hard Delete 가 명시적으로 삭제 + "auth_provider_account.user_id:NO ACTION", // authProviderAccountRepository.deleteByUserId + "org_member.user_id:NO ACTION", // orgMemberRepository.deleteByUserId + + // DB FK 의 ON DELETE CASCADE 가 정리 + "user_notification.user_id:CASCADE", + + // 회원 삭제 전 스케줄러가 광고 계정 연동을 해제하며 정리 + // 정리 실패 시 UserDeleteScheduler 가 해당 회원 삭제를 보류한다 + "platform_connection.user_id:NO ACTION" + ); + + // 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 로 초기화 + ); + + // 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 도 아닌 참조성 컬럼을 찾기 + @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()); + } +} 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