Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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<ClickAnomalyEvent, Long> {

// 광고계정 연동 해제 정리용
// 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);
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -11,4 +14,18 @@ public interface ClickBaselineStatRepository extends JpaRepository<ClickBaseline
// 스케줄러 실행당 1회의 배치 IN 조회 (풀스캔 없음, uk_baseline_ad_slot 인덱스 사용)
List<ClickBaselineStat> findByAdContentIdInAndWeekdayAndHourOfDay(
Collection<Long> 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);
}
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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();

Expand Down Expand Up @@ -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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<Long> projectIds) {
int chunkDeleted; // 하나의 청크 당 삭제 갯수
long totalClickLogDeleted = 0L; // ClickLog 전체 삭제 갯수
long totalMetricFactDeleted = 0L; // MetricFact 전체 삭제 갯수
long totalAnomalyEventDeleted = 0L;
long totalBaselineStatDeleted = 0L;

// ClickLog 청크 정리 (REQUIRES_NEW)
do {
Expand All @@ -237,6 +240,21 @@ private void cleanupAccount(Long accountId, List<Long> 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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -30,13 +32,27 @@ 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)
public List<Long> 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) {
Expand Down
Original file line number Diff line number Diff line change
@@ -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<String> 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<String> 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<String> 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<String> 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<String> foreignKeysReferencing(String referencedTable) {
List<Object[]> 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<String> plainReferenceColumns(String namePredicate) {
List<Object[]> 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());
}
}
11 changes: 11 additions & 0 deletions src/test/resources/application-test.yml
Original file line number Diff line number Diff line change
@@ -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
Loading