From c5bf9579188412053d50cf58c974a8b7cf1266be Mon Sep 17 00:00:00 2001 From: 1Seob Date: Sun, 6 Sep 2026 10:51:55 +0900 Subject: [PATCH 1/5] =?UTF-8?q?feat(db):=20=ED=83=88=ED=87=B4=20=EC=82=AC?= =?UTF-8?q?=EC=9C=A0=20=EB=8F=85=EB=A6=BD=20=EB=B3=B4=EC=A1=B4=20=EC=8A=A4?= =?UTF-8?q?=ED=82=A4=EB=A7=88=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit withdrawal_events와 withdrawal_event_reasons를 추가해 계정 하드 삭제 시 사용자 연결만 제거하고 탈퇴 이벤트를 보존하도록 함. 기존 user_withdrawal_reasons를 이벤트 단위로 이관하고 1년 만료일, 중복 사유 및 유효기간 제약을 PostgreSQL 통합 테스트로 검증함 --- ...0906_1200__IS_create_withdrawal_events.sql | 88 +++++++++ .../WithdrawalEventsMigrationTest.java | 168 ++++++++++++++++++ 2 files changed, 256 insertions(+) create mode 100644 src/main/resources/db/migration/V20260906_1200__IS_create_withdrawal_events.sql create mode 100644 src/test/java/com/devkor/ifive/nadab/domain/auth/core/repository/WithdrawalEventsMigrationTest.java diff --git a/src/main/resources/db/migration/V20260906_1200__IS_create_withdrawal_events.sql b/src/main/resources/db/migration/V20260906_1200__IS_create_withdrawal_events.sql new file mode 100644 index 00000000..d0a651f1 --- /dev/null +++ b/src/main/resources/db/migration/V20260906_1200__IS_create_withdrawal_events.sql @@ -0,0 +1,88 @@ +CREATE TABLE withdrawal_events ( + id BIGSERIAL PRIMARY KEY, + user_id BIGINT, + withdrawn_at TIMESTAMPTZ NOT NULL, + anonymized_at TIMESTAMPTZ, + expires_at TIMESTAMPTZ NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + + CONSTRAINT fk_withdrawal_events_user + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE SET NULL, + CONSTRAINT uq_withdrawal_events_user_withdrawn_at + UNIQUE (user_id, withdrawn_at), + CONSTRAINT chk_withdrawal_events_expiration + CHECK (expires_at > withdrawn_at), + CONSTRAINT chk_withdrawal_events_anonymization + CHECK ( + anonymized_at IS NULL + OR (user_id IS NULL AND anonymized_at >= withdrawn_at) + ) +); + +CREATE TABLE withdrawal_event_reasons ( + id BIGSERIAL PRIMARY KEY, + event_id BIGINT NOT NULL, + reason VARCHAR(50) NOT NULL, + custom_reason VARCHAR(200), + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + + CONSTRAINT fk_withdrawal_event_reasons_event + FOREIGN KEY (event_id) REFERENCES withdrawal_events(id) ON DELETE CASCADE, + CONSTRAINT uq_withdrawal_event_reasons_event_reason + UNIQUE (event_id, reason), + CONSTRAINT chk_withdrawal_event_reasons_other_custom_reason + CHECK ( + (reason = 'OTHER' AND custom_reason IS NOT NULL AND LENGTH(BTRIM(custom_reason)) > 0) + OR + (reason <> 'OTHER' AND custom_reason IS NULL) + ) +); + +CREATE INDEX idx_withdrawal_events_withdrawn_at + ON withdrawal_events (withdrawn_at DESC); + +CREATE INDEX idx_withdrawal_events_expires_at + ON withdrawal_events (expires_at); + +CREATE INDEX idx_withdrawal_event_reasons_reason_event_id + ON withdrawal_event_reasons (reason, event_id); + +COMMENT ON TABLE withdrawal_events IS + 'Withdrawal request events retained independently from user accounts for up to one year'; +COMMENT ON COLUMN withdrawal_events.user_id IS + 'Temporary user link removed when the event is anonymized or the user is hard-deleted'; +COMMENT ON COLUMN withdrawal_events.expires_at IS + 'Retention deadline calculated from the withdrawal request time'; + +-- LEGACY BACKFILL START +INSERT INTO withdrawal_events ( + user_id, + withdrawn_at, + expires_at, + created_at +) +SELECT + uwr.user_id, + uwr.withdrawn_at, + uwr.withdrawn_at + INTERVAL '1 year', + MIN(uwr.created_at) +FROM user_withdrawal_reasons uwr +GROUP BY uwr.user_id, uwr.withdrawn_at; + +INSERT INTO withdrawal_event_reasons ( + event_id, + reason, + custom_reason, + created_at +) +SELECT DISTINCT ON (we.id, uwr.reason) + we.id, + uwr.reason, + uwr.custom_reason, + uwr.created_at +FROM user_withdrawal_reasons uwr +JOIN withdrawal_events we + ON we.user_id = uwr.user_id + AND we.withdrawn_at = uwr.withdrawn_at +ORDER BY we.id, uwr.reason, uwr.created_at DESC, uwr.id DESC; +-- LEGACY BACKFILL END diff --git a/src/test/java/com/devkor/ifive/nadab/domain/auth/core/repository/WithdrawalEventsMigrationTest.java b/src/test/java/com/devkor/ifive/nadab/domain/auth/core/repository/WithdrawalEventsMigrationTest.java new file mode 100644 index 00000000..c4a612d3 --- /dev/null +++ b/src/test/java/com/devkor/ifive/nadab/domain/auth/core/repository/WithdrawalEventsMigrationTest.java @@ -0,0 +1,168 @@ +package com.devkor.ifive.nadab.domain.auth.core.repository; + +import com.devkor.ifive.nadab.domain.user.core.entity.User; +import com.devkor.ifive.nadab.infra.builder.UserBuilder; +import com.devkor.ifive.nadab.infra.db.PostgresIntegrationTestSupport; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; +import org.springframework.boot.test.autoconfigure.orm.jpa.TestEntityManager; +import org.springframework.core.io.Resource; +import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.test.context.ActiveProfiles; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.time.OffsetDateTime; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +@DataJpaTest +@ActiveProfiles("test") +class WithdrawalEventsMigrationTest extends PostgresIntegrationTestSupport { + + private static final String BACKFILL_START = "-- LEGACY BACKFILL START"; + private static final String BACKFILL_END = "-- LEGACY BACKFILL END"; + + @Autowired + private JdbcTemplate jdbcTemplate; + + @Autowired + private TestEntityManager em; + + @Value("classpath:db/migration/V20260906_1200__IS_create_withdrawal_events.sql") + private Resource migration; + + @Test + void backfills_one_event_with_all_reasons_and_one_year_expiration() throws IOException { + User user = new UserBuilder(em).build(); + em.flush(); + + OffsetDateTime withdrawnAt = OffsetDateTime.parse("2026-09-01T12:30:00+09:00"); + insertLegacyReason(user.getId(), withdrawnAt, "DAILY_LOGGING_BURDEN", null); + insertLegacyReason(user.getId(), withdrawnAt, "OTHER", "기타 의견"); + + jdbcTemplate.execute(extractBackfillSql()); + + Long eventId = jdbcTemplate.queryForObject( + "SELECT id FROM withdrawal_events WHERE user_id = ?", + Long.class, + user.getId() + ); + OffsetDateTime expiresAt = jdbcTemplate.queryForObject( + "SELECT expires_at FROM withdrawal_events WHERE id = ?", + OffsetDateTime.class, + eventId + ); + Long reasonCount = jdbcTemplate.queryForObject( + "SELECT COUNT(*) FROM withdrawal_event_reasons WHERE event_id = ?", + Long.class, + eventId + ); + + assertThat(expiresAt).isEqualTo(withdrawnAt.plusYears(1)); + assertThat(reasonCount).isEqualTo(2L); + } + + @Test + void preserves_event_and_reasons_when_user_is_hard_deleted() { + User user = new UserBuilder(em).build(); + em.flush(); + + OffsetDateTime withdrawnAt = OffsetDateTime.parse("2026-09-01T12:30:00+09:00"); + Long eventId = insertEvent(user.getId(), withdrawnAt); + jdbcTemplate.update(""" + INSERT INTO withdrawal_event_reasons (event_id, reason) + VALUES (?, 'DAILY_LOGGING_BURDEN') + """, eventId); + + jdbcTemplate.update("DELETE FROM users WHERE id = ?", user.getId()); + + Long linkedUserId = jdbcTemplate.queryForObject( + "SELECT user_id FROM withdrawal_events WHERE id = ?", + Long.class, + eventId + ); + Long reasonCount = jdbcTemplate.queryForObject( + "SELECT COUNT(*) FROM withdrawal_event_reasons WHERE event_id = ?", + Long.class, + eventId + ); + + assertThat(linkedUserId).isNull(); + assertThat(reasonCount).isEqualTo(1L); + } + + @Test + void rejects_duplicate_reason_for_same_event() { + User user = new UserBuilder(em).build(); + em.flush(); + + OffsetDateTime withdrawnAt = OffsetDateTime.parse("2026-09-01T12:30:00+09:00"); + Long eventId = insertEvent(user.getId(), withdrawnAt); + jdbcTemplate.update(""" + INSERT INTO withdrawal_event_reasons (event_id, reason) + VALUES (?, 'DAILY_LOGGING_BURDEN') + """, eventId); + + assertThatThrownBy(() -> jdbcTemplate.update(""" + INSERT INTO withdrawal_event_reasons (event_id, reason) + VALUES (?, 'DAILY_LOGGING_BURDEN') + """, eventId)) + .isInstanceOf(DataIntegrityViolationException.class); + } + + @Test + void rejects_expiration_not_after_withdrawal() { + User user = new UserBuilder(em).build(); + em.flush(); + + OffsetDateTime withdrawnAt = OffsetDateTime.parse("2026-09-01T12:30:00+09:00"); + + assertThatThrownBy(() -> insertEvent(user.getId(), withdrawnAt, withdrawnAt)) + .isInstanceOf(DataIntegrityViolationException.class); + } + + private void insertLegacyReason( + Long userId, + OffsetDateTime withdrawnAt, + String reason, + String customReason + ) { + jdbcTemplate.update(""" + INSERT INTO user_withdrawal_reasons ( + user_id, + reason, + custom_reason, + withdrawn_at + ) + VALUES (?, ?, ?, ?) + """, userId, reason, customReason, withdrawnAt); + } + + private Long insertEvent(Long userId, OffsetDateTime withdrawnAt) { + return insertEvent(userId, withdrawnAt, withdrawnAt.plusYears(1)); + } + + private Long insertEvent(Long userId, OffsetDateTime withdrawnAt, OffsetDateTime expiresAt) { + return jdbcTemplate.queryForObject(""" + INSERT INTO withdrawal_events ( + user_id, + withdrawn_at, + expires_at + ) + VALUES (?, ?, ?) + RETURNING id + """, Long.class, userId, withdrawnAt, expiresAt); + } + + private String extractBackfillSql() throws IOException { + String migrationSql = migration.getContentAsString(StandardCharsets.UTF_8); + int start = migrationSql.indexOf(BACKFILL_START) + BACKFILL_START.length(); + int end = migrationSql.indexOf(BACKFILL_END); + return migrationSql.substring(start, end); + } +} From 3408c9adab0e552fa506f49d3278a944085e9d23 Mon Sep 17 00:00:00 2001 From: 1Seob Date: Sun, 6 Sep 2026 12:47:42 +0900 Subject: [PATCH 2/5] =?UTF-8?q?feat(auth):=20=ED=83=88=ED=87=B4=20?= =?UTF-8?q?=EC=82=AC=EC=9C=A0=20=EC=9D=B4=EB=B2=A4=ED=8A=B8=20=EC=9D=B4?= =?UTF-8?q?=EC=A4=91=20=EC=A0=80=EC=9E=A5=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 기존 통계 호환을 위한 레거시 사유와 신규 탈퇴 이벤트·사유의 트랜잭션 단위 이중 저장, 신규 저장 실패 시 사용자 탈퇴 상태와 전체 사유 기록 롤백 검증 --- .../auth/application/AuthServiceV2.java | 18 ++- .../auth/core/entity/WithdrawalEvent.java | 52 ++++++ .../core/entity/WithdrawalEventReason.java | 49 ++++++ .../WithdrawalEventReasonRepository.java | 7 + .../repository/WithdrawalEventRepository.java | 7 + .../AuthServiceV2PersistenceTest.java | 148 ++++++++++++++++++ .../auth/application/AuthServiceV2Test.java | 41 ++++- 7 files changed, 318 insertions(+), 4 deletions(-) create mode 100644 src/main/java/com/devkor/ifive/nadab/domain/auth/core/entity/WithdrawalEvent.java create mode 100644 src/main/java/com/devkor/ifive/nadab/domain/auth/core/entity/WithdrawalEventReason.java create mode 100644 src/main/java/com/devkor/ifive/nadab/domain/auth/core/repository/WithdrawalEventReasonRepository.java create mode 100644 src/main/java/com/devkor/ifive/nadab/domain/auth/core/repository/WithdrawalEventRepository.java create mode 100644 src/test/java/com/devkor/ifive/nadab/domain/auth/application/AuthServiceV2PersistenceTest.java diff --git a/src/main/java/com/devkor/ifive/nadab/domain/auth/application/AuthServiceV2.java b/src/main/java/com/devkor/ifive/nadab/domain/auth/application/AuthServiceV2.java index be4ba74d..15d751d5 100644 --- a/src/main/java/com/devkor/ifive/nadab/domain/auth/application/AuthServiceV2.java +++ b/src/main/java/com/devkor/ifive/nadab/domain/auth/application/AuthServiceV2.java @@ -1,8 +1,12 @@ package com.devkor.ifive.nadab.domain.auth.application; import com.devkor.ifive.nadab.domain.auth.core.entity.UserWithdrawalReason; +import com.devkor.ifive.nadab.domain.auth.core.entity.WithdrawalEvent; +import com.devkor.ifive.nadab.domain.auth.core.entity.WithdrawalEventReason; import com.devkor.ifive.nadab.domain.auth.core.entity.WithdrawalReasonType; import com.devkor.ifive.nadab.domain.auth.core.repository.UserWithdrawalReasonRepository; +import com.devkor.ifive.nadab.domain.auth.core.repository.WithdrawalEventReasonRepository; +import com.devkor.ifive.nadab.domain.auth.core.repository.WithdrawalEventRepository; import com.devkor.ifive.nadab.domain.user.core.entity.User; import com.devkor.ifive.nadab.domain.user.core.repository.UserRepository; import com.devkor.ifive.nadab.global.core.response.ErrorCode; @@ -27,6 +31,8 @@ public class AuthServiceV2 { private final WithdrawalService withdrawalService; private final UserRepository userRepository; private final UserWithdrawalReasonRepository userWithdrawalReasonRepository; + private final WithdrawalEventRepository withdrawalEventRepository; + private final WithdrawalEventReasonRepository withdrawalEventReasonRepository; public void withdrawUser(Long userId, List reasons, String customReason) { List validatedReasons = validateReasons(reasons); @@ -41,17 +47,23 @@ public void withdrawUser(Long userId, List reasons, String OffsetDateTime effectiveWithdrawnAt = user.getDeletedAt() != null ? user.getDeletedAt() : OffsetDateTime.now(); - List entities = new ArrayList<>(validatedReasons.size()); + WithdrawalEvent event = withdrawalEventRepository.save( + WithdrawalEvent.create(user, effectiveWithdrawnAt) + ); + List legacyReasons = new ArrayList<>(validatedReasons.size()); + List eventReasons = new ArrayList<>(validatedReasons.size()); for (WithdrawalReasonType reason : validatedReasons) { String detail = reason == WithdrawalReasonType.OTHER ? normalizedCustomReason : null; - entities.add(UserWithdrawalReason.create( + legacyReasons.add(UserWithdrawalReason.create( user, reason, detail, effectiveWithdrawnAt )); + eventReasons.add(WithdrawalEventReason.create(event, reason, detail)); } - userWithdrawalReasonRepository.saveAll(entities); + userWithdrawalReasonRepository.saveAll(legacyReasons); + withdrawalEventReasonRepository.saveAll(eventReasons); } private List validateReasons(List reasons) { diff --git a/src/main/java/com/devkor/ifive/nadab/domain/auth/core/entity/WithdrawalEvent.java b/src/main/java/com/devkor/ifive/nadab/domain/auth/core/entity/WithdrawalEvent.java new file mode 100644 index 00000000..2189a731 --- /dev/null +++ b/src/main/java/com/devkor/ifive/nadab/domain/auth/core/entity/WithdrawalEvent.java @@ -0,0 +1,52 @@ +package com.devkor.ifive.nadab.domain.auth.core.entity; + +import com.devkor.ifive.nadab.domain.user.core.entity.User; +import com.devkor.ifive.nadab.global.shared.entity.CreatableEntity; +import jakarta.persistence.*; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.time.OffsetDateTime; + +@Entity +@Table( + name = "withdrawal_events", + uniqueConstraints = { + @UniqueConstraint( + name = "uq_withdrawal_events_user_withdrawn_at", + columnNames = {"user_id", "withdrawn_at"} + ) + } +) +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class WithdrawalEvent extends CreatableEntity { + + private static final int RETENTION_YEARS = 1; + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "user_id") + private User user; + + @Column(name = "withdrawn_at", nullable = false) + private OffsetDateTime withdrawnAt; + + @Column(name = "anonymized_at") + private OffsetDateTime anonymizedAt; + + @Column(name = "expires_at", nullable = false) + private OffsetDateTime expiresAt; + + public static WithdrawalEvent create(User user, OffsetDateTime withdrawnAt) { + WithdrawalEvent event = new WithdrawalEvent(); + event.user = user; + event.withdrawnAt = withdrawnAt; + event.expiresAt = withdrawnAt.plusYears(RETENTION_YEARS); + return event; + } +} diff --git a/src/main/java/com/devkor/ifive/nadab/domain/auth/core/entity/WithdrawalEventReason.java b/src/main/java/com/devkor/ifive/nadab/domain/auth/core/entity/WithdrawalEventReason.java new file mode 100644 index 00000000..ef743792 --- /dev/null +++ b/src/main/java/com/devkor/ifive/nadab/domain/auth/core/entity/WithdrawalEventReason.java @@ -0,0 +1,49 @@ +package com.devkor.ifive.nadab.domain.auth.core.entity; + +import com.devkor.ifive.nadab.global.shared.entity.CreatableEntity; +import jakarta.persistence.*; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Table( + name = "withdrawal_event_reasons", + uniqueConstraints = { + @UniqueConstraint( + name = "uq_withdrawal_event_reasons_event_reason", + columnNames = {"event_id", "reason"} + ) + } +) +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class WithdrawalEventReason extends CreatableEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY, optional = false) + @JoinColumn(name = "event_id", nullable = false) + private WithdrawalEvent event; + + @Enumerated(EnumType.STRING) + @Column(name = "reason", nullable = false, length = 50) + private WithdrawalReasonType reason; + + @Column(name = "custom_reason", length = 200) + private String customReason; + + public static WithdrawalEventReason create( + WithdrawalEvent event, + WithdrawalReasonType reason, + String customReason + ) { + WithdrawalEventReason eventReason = new WithdrawalEventReason(); + eventReason.event = event; + eventReason.reason = reason; + eventReason.customReason = customReason; + return eventReason; + } +} diff --git a/src/main/java/com/devkor/ifive/nadab/domain/auth/core/repository/WithdrawalEventReasonRepository.java b/src/main/java/com/devkor/ifive/nadab/domain/auth/core/repository/WithdrawalEventReasonRepository.java new file mode 100644 index 00000000..94dfaec3 --- /dev/null +++ b/src/main/java/com/devkor/ifive/nadab/domain/auth/core/repository/WithdrawalEventReasonRepository.java @@ -0,0 +1,7 @@ +package com.devkor.ifive.nadab.domain.auth.core.repository; + +import com.devkor.ifive.nadab.domain.auth.core.entity.WithdrawalEventReason; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface WithdrawalEventReasonRepository extends JpaRepository { +} diff --git a/src/main/java/com/devkor/ifive/nadab/domain/auth/core/repository/WithdrawalEventRepository.java b/src/main/java/com/devkor/ifive/nadab/domain/auth/core/repository/WithdrawalEventRepository.java new file mode 100644 index 00000000..3642d6b0 --- /dev/null +++ b/src/main/java/com/devkor/ifive/nadab/domain/auth/core/repository/WithdrawalEventRepository.java @@ -0,0 +1,7 @@ +package com.devkor.ifive.nadab.domain.auth.core.repository; + +import com.devkor.ifive.nadab.domain.auth.core.entity.WithdrawalEvent; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface WithdrawalEventRepository extends JpaRepository { +} diff --git a/src/test/java/com/devkor/ifive/nadab/domain/auth/application/AuthServiceV2PersistenceTest.java b/src/test/java/com/devkor/ifive/nadab/domain/auth/application/AuthServiceV2PersistenceTest.java new file mode 100644 index 00000000..65bc41ff --- /dev/null +++ b/src/test/java/com/devkor/ifive/nadab/domain/auth/application/AuthServiceV2PersistenceTest.java @@ -0,0 +1,148 @@ +package com.devkor.ifive.nadab.domain.auth.application; + +import com.devkor.ifive.nadab.domain.auth.core.entity.UserWithdrawalReason; +import com.devkor.ifive.nadab.domain.auth.core.entity.WithdrawalEvent; +import com.devkor.ifive.nadab.domain.auth.core.entity.WithdrawalEventReason; +import com.devkor.ifive.nadab.domain.auth.core.entity.WithdrawalReasonType; +import com.devkor.ifive.nadab.domain.auth.core.repository.SocialAccountRepository; +import com.devkor.ifive.nadab.domain.auth.core.repository.UserWithdrawalReasonRepository; +import com.devkor.ifive.nadab.domain.auth.core.repository.WithdrawalEventReasonRepository; +import com.devkor.ifive.nadab.domain.auth.core.repository.WithdrawalEventRepository; +import com.devkor.ifive.nadab.domain.auth.infra.oauth.client.AppleOAuth2Client; +import com.devkor.ifive.nadab.domain.user.core.entity.SignupStatusType; +import com.devkor.ifive.nadab.domain.user.core.entity.User; +import com.devkor.ifive.nadab.domain.user.core.repository.UserRepository; +import com.devkor.ifive.nadab.global.security.crypto.DataCryptoService; +import com.devkor.ifive.nadab.infra.db.PostgresIntegrationTestSupport; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; +import org.springframework.context.annotation.Import; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.test.context.bean.override.mockito.MockitoSpyBean; +import org.springframework.transaction.PlatformTransactionManager; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.transaction.support.TransactionTemplate; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.anyList; +import static org.mockito.Mockito.doThrow; + +@DataJpaTest +@ActiveProfiles("test") +@Transactional(propagation = Propagation.NOT_SUPPORTED) +@Import({AuthServiceV2.class, WithdrawalService.class}) +class AuthServiceV2PersistenceTest extends PostgresIntegrationTestSupport { + + @MockitoBean + SocialAccountRepository socialAccountRepository; + + @MockitoBean + TokenService tokenService; + + @MockitoBean + PasswordEncoder passwordEncoder; + + @MockitoBean + AppleOAuth2Client appleOAuth2Client; + + @MockitoBean + DataCryptoService dataCryptoService; + + @MockitoSpyBean + WithdrawalEventReasonRepository withdrawalEventReasonRepository; + + @Autowired + AuthServiceV2 authServiceV2; + + @Autowired + UserRepository userRepository; + + @Autowired + UserWithdrawalReasonRepository userWithdrawalReasonRepository; + + @Autowired + WithdrawalEventRepository withdrawalEventRepository; + + @Autowired + JdbcTemplate jdbcTemplate; + + private Long userId; + + @AfterEach + void tearDown() { + if (userId == null) { + return; + } + jdbcTemplate.update("DELETE FROM withdrawal_events WHERE user_id = ?", userId); + jdbcTemplate.update("DELETE FROM user_withdrawal_reasons WHERE user_id = ?", userId); + jdbcTemplate.update("DELETE FROM users WHERE id = ?", userId); + } + + @Test + void withdrawal_commits_user_state_and_both_reason_stores_together() { + userId = createUser(); + + authServiceV2.withdrawUser( + userId, + List.of(WithdrawalReasonType.DAILY_LOGGING_BURDEN, WithdrawalReasonType.OTHER), + "기타 의견" + ); + + User withdrawnUser = userRepository.findById(userId).orElseThrow(); + WithdrawalEvent event = withdrawalEventRepository.findAll().getFirst(); + List eventReasons = withdrawalEventReasonRepository.findAll(); + List legacyReasons = userWithdrawalReasonRepository.findAll(); + + assertThat(withdrawnUser.getDeletedAt()).isNotNull(); + assertThat(withdrawnUser.getSignupStatus()).isEqualTo(SignupStatusType.WITHDRAWN); + assertThat(event.getWithdrawnAt()).isEqualTo(withdrawnUser.getDeletedAt()); + assertThat(event.getExpiresAt()).isEqualTo(withdrawnUser.getDeletedAt().plusYears(1)); + assertThat(eventReasons) + .extracting(WithdrawalEventReason::getReason) + .containsExactlyInAnyOrder( + WithdrawalReasonType.DAILY_LOGGING_BURDEN, + WithdrawalReasonType.OTHER + ); + assertThat(legacyReasons) + .extracting(UserWithdrawalReason::getReason) + .containsExactlyInAnyOrder( + WithdrawalReasonType.DAILY_LOGGING_BURDEN, + WithdrawalReasonType.OTHER + ); + } + + @Test + void new_reason_store_failure_rolls_back_withdrawal_and_legacy_reasons() { + userId = createUser(); + doThrow(new IllegalStateException("신규 탈퇴 사유 저장 실패")) + .when(withdrawalEventReasonRepository).saveAll(anyList()); + + assertThatThrownBy(() -> authServiceV2.withdrawUser( + userId, + List.of(WithdrawalReasonType.APP_ERROR_OR_SLOWNESS), + null + )).isInstanceOf(IllegalStateException.class); + + User activeUser = userRepository.findById(userId).orElseThrow(); + assertThat(activeUser.getDeletedAt()).isNull(); + assertThat(activeUser.getSignupStatus()).isEqualTo(SignupStatusType.PROFILE_INCOMPLETE); + assertThat(withdrawalEventRepository.count()).isZero(); + assertThat(withdrawalEventReasonRepository.count()).isZero(); + assertThat(userWithdrawalReasonRepository.count()).isZero(); + } + + private Long createUser() { + User user = User.createUser("withdrawal+" + System.nanoTime() + "@test.com", "hashed_password"); + user.updateNickname("wd" + System.nanoTime()); + return userRepository.save(user).getId(); + } +} diff --git a/src/test/java/com/devkor/ifive/nadab/domain/auth/application/AuthServiceV2Test.java b/src/test/java/com/devkor/ifive/nadab/domain/auth/application/AuthServiceV2Test.java index 8d2112db..42cf9491 100644 --- a/src/test/java/com/devkor/ifive/nadab/domain/auth/application/AuthServiceV2Test.java +++ b/src/test/java/com/devkor/ifive/nadab/domain/auth/application/AuthServiceV2Test.java @@ -1,8 +1,12 @@ package com.devkor.ifive.nadab.domain.auth.application; import com.devkor.ifive.nadab.domain.auth.core.entity.UserWithdrawalReason; +import com.devkor.ifive.nadab.domain.auth.core.entity.WithdrawalEvent; +import com.devkor.ifive.nadab.domain.auth.core.entity.WithdrawalEventReason; import com.devkor.ifive.nadab.domain.auth.core.entity.WithdrawalReasonType; import com.devkor.ifive.nadab.domain.auth.core.repository.UserWithdrawalReasonRepository; +import com.devkor.ifive.nadab.domain.auth.core.repository.WithdrawalEventReasonRepository; +import com.devkor.ifive.nadab.domain.auth.core.repository.WithdrawalEventRepository; import com.devkor.ifive.nadab.domain.user.core.entity.User; import com.devkor.ifive.nadab.domain.user.core.repository.UserRepository; import com.devkor.ifive.nadab.global.core.response.ErrorCode; @@ -19,6 +23,7 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyList; import static org.mockito.Mockito.doAnswer; import static org.mockito.Mockito.never; @@ -37,6 +42,12 @@ class AuthServiceV2Test { @Mock UserWithdrawalReasonRepository userWithdrawalReasonRepository; + @Mock + WithdrawalEventRepository withdrawalEventRepository; + + @Mock + WithdrawalEventReasonRepository withdrawalEventReasonRepository; + AuthServiceV2 authServiceV2; @BeforeEach @@ -44,7 +55,9 @@ void setUp() { authServiceV2 = new AuthServiceV2( withdrawalService, userRepository, - userWithdrawalReasonRepository + userWithdrawalReasonRepository, + withdrawalEventRepository, + withdrawalEventReasonRepository ); } @@ -58,6 +71,8 @@ void withdrawUser_saves_selected_reasons_with_effective_withdrawn_at() { return null; }).when(withdrawalService).withdrawUser(userId); when(userRepository.getReferenceById(userId)).thenReturn(user); + when(withdrawalEventRepository.save(any(WithdrawalEvent.class))) + .thenAnswer(invocation -> invocation.getArgument(0)); // when authServiceV2.withdrawUser( @@ -69,10 +84,17 @@ void withdrawUser_saves_selected_reasons_with_effective_withdrawn_at() { // then @SuppressWarnings("unchecked") ArgumentCaptor> captor = ArgumentCaptor.forClass(List.class); + ArgumentCaptor eventCaptor = ArgumentCaptor.forClass(WithdrawalEvent.class); + @SuppressWarnings("unchecked") + ArgumentCaptor> eventReasonsCaptor = ArgumentCaptor.forClass(List.class); verify(withdrawalService).withdrawUser(userId); verify(userWithdrawalReasonRepository).saveAll(captor.capture()); + verify(withdrawalEventRepository).save(eventCaptor.capture()); + verify(withdrawalEventReasonRepository).saveAll(eventReasonsCaptor.capture()); List savedReasons = captor.getValue(); + WithdrawalEvent savedEvent = eventCaptor.getValue(); + List savedEventReasons = eventReasonsCaptor.getValue(); OffsetDateTime deletedAt = user.getDeletedAt(); assertThat(savedReasons).hasSize(2); @@ -90,6 +112,21 @@ void withdrawUser_saves_selected_reasons_with_effective_withdrawn_at() { ); assertThat(savedReasons.get(0).getCustomReason()).isNull(); assertThat(savedReasons.get(1).getCustomReason()).isEqualTo("custom reason"); + assertThat(savedEvent.getUser()).isEqualTo(user); + assertThat(savedEvent.getWithdrawnAt()).isEqualTo(deletedAt); + assertThat(savedEvent.getExpiresAt()).isEqualTo(deletedAt.plusYears(1)); + assertThat(savedEvent.getAnonymizedAt()).isNull(); + assertThat(savedEventReasons) + .extracting(WithdrawalEventReason::getEvent) + .containsOnly(savedEvent); + assertThat(savedEventReasons) + .extracting(WithdrawalEventReason::getReason) + .containsExactly( + WithdrawalReasonType.DAILY_LOGGING_BURDEN, + WithdrawalReasonType.OTHER + ); + assertThat(savedEventReasons.get(0).getCustomReason()).isNull(); + assertThat(savedEventReasons.get(1).getCustomReason()).isEqualTo("custom reason"); } @Test @@ -150,5 +187,7 @@ private void assertValidationFailure( verify(withdrawalService, never()).withdrawUser(1L); verify(userRepository, never()).getReferenceById(1L); verify(userWithdrawalReasonRepository, never()).saveAll(anyList()); + verify(withdrawalEventRepository, never()).save(any(WithdrawalEvent.class)); + verify(withdrawalEventReasonRepository, never()).saveAll(anyList()); } } From 661da0db888ef1aa4ab4f4018a11923a8d89874d Mon Sep 17 00:00:00 2001 From: 1Seob Date: Sun, 6 Sep 2026 14:04:38 +0900 Subject: [PATCH 3/5] =?UTF-8?q?refactor(stats):=20=ED=83=88=ED=87=B4=20?= =?UTF-8?q?=ED=86=B5=EA=B3=84=EB=A5=BC=20=EB=8F=85=EB=A6=BD=20=EC=9D=B4?= =?UTF-8?q?=EB=B2=A4=ED=8A=B8=20=EA=B8=B0=EC=A4=80=EC=9C=BC=EB=A1=9C=20?= =?UTF-8?q?=EC=A0=84=ED=99=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 탈퇴 통계 조회를 withdrawal_events와 withdrawal_event_reasons 기준으로 전환하고 만료 이벤트 제외, 이벤트 단위 최신 목록, 계정 연결 제거 시 custom_reason 비노출을 적용함. 하드 삭제 이후 집계 유지와 화면의 최대 1년 보존 범위 안내를 PostgreSQL 및 MVC 테스트로 검증함 --- .../application/WithdrawalStatsService.java | 14 +-- .../repository/WithdrawalStatsRepository.java | 39 +++--- .../resources/templates/stats/withdrawal.html | 12 +- .../WithdrawalStatsServiceTest.java | 12 +- .../StatsControllerTemplateTest.java | 32 +++++ .../WithdrawalStatsRepositoryTest.java | 119 ++++++++++++++++++ 6 files changed, 188 insertions(+), 40 deletions(-) create mode 100644 src/test/java/com/devkor/ifive/nadab/domain/stats/core/repository/WithdrawalStatsRepositoryTest.java diff --git a/src/main/java/com/devkor/ifive/nadab/domain/stats/application/WithdrawalStatsService.java b/src/main/java/com/devkor/ifive/nadab/domain/stats/application/WithdrawalStatsService.java index 2674a93a..725348a5 100644 --- a/src/main/java/com/devkor/ifive/nadab/domain/stats/application/WithdrawalStatsService.java +++ b/src/main/java/com/devkor/ifive/nadab/domain/stats/application/WithdrawalStatsService.java @@ -24,23 +24,22 @@ public class WithdrawalStatsService { private final WithdrawalStatsRepository repo; public WithdrawalStatsViewModel getWithdrawalStats() { - List rows = repo.findLatestWithdrawalReasonRows(RECENT_WITHDRAWAL_EVENT_LIMIT); - List totalReasonRows = repo.countAllWithdrawalReasons(); + List rows = repo.findLatestRetainedWithdrawalReasonRows(RECENT_WITHDRAWAL_EVENT_LIMIT); + List totalReasonRows = repo.countRetainedWithdrawalReasons(); - Map eventMap = new LinkedHashMap<>(); + Map eventMap = new LinkedHashMap<>(); Map reasonCountMap = new EnumMap<>(WithdrawalReasonType.class); for (Object[] row : rows) { - long userId = toLong(row[0]); + long eventId = toLong(row[0]); OffsetDateTime withdrawnAt = toOffsetDateTime(row[1]); String reasonCode = String.valueOf(row[2]); WithdrawalReasonType reasonType = parseReasonType(reasonCode); String customReason = row[3] == null ? null : String.valueOf(row[3]).trim(); OffsetDateTime normalizedWithdrawnAt = withdrawnAt.truncatedTo(ChronoUnit.SECONDS); - EventKey key = new EventKey(userId, normalizedWithdrawnAt); EventAccumulator accumulator = eventMap.computeIfAbsent( - key, + eventId, k -> new EventAccumulator(formatDateTime(normalizedWithdrawnAt)) ); @@ -136,9 +135,6 @@ private String toReasonLabel(WithdrawalReasonType reasonType, String rawReasonCo return toReasonLabel(reasonType); } - private record EventKey(long userId, OffsetDateTime withdrawnAt) { - } - private static class EventAccumulator { private final String withdrawnAt; private final List reasons = new ArrayList<>(); diff --git a/src/main/java/com/devkor/ifive/nadab/domain/stats/core/repository/WithdrawalStatsRepository.java b/src/main/java/com/devkor/ifive/nadab/domain/stats/core/repository/WithdrawalStatsRepository.java index c9a6fcf9..903851ef 100644 --- a/src/main/java/com/devkor/ifive/nadab/domain/stats/core/repository/WithdrawalStatsRepository.java +++ b/src/main/java/com/devkor/ifive/nadab/domain/stats/core/repository/WithdrawalStatsRepository.java @@ -12,39 +12,40 @@ public class WithdrawalStatsRepository { private final EntityManager em; - public List findLatestWithdrawalReasonRows(int limitEvents) { + public List findLatestRetainedWithdrawalReasonRows(int limitEvents) { return em.createNativeQuery(""" with ranked_events as ( select - uwr.user_id, - uwr.withdrawn_at, - row_number() over (order by uwr.withdrawn_at desc, uwr.user_id desc) as rn - from user_withdrawal_reasons uwr - group by uwr.user_id, uwr.withdrawn_at + we.id, + we.withdrawn_at, + row_number() over (order by we.withdrawn_at desc, we.id desc) as rn + from withdrawal_events we + where we.expires_at > current_timestamp ) select - uwr.user_id, - uwr.withdrawn_at, - uwr.reason, - uwr.custom_reason - from user_withdrawal_reasons uwr - join ranked_events re - on re.user_id = uwr.user_id - and re.withdrawn_at = uwr.withdrawn_at + re.id, + re.withdrawn_at, + wer.reason, + case when we.user_id is null then null else wer.custom_reason end + from ranked_events re + join withdrawal_events we on we.id = re.id + join withdrawal_event_reasons wer on wer.event_id = re.id where re.rn <= :limitEvents - order by uwr.withdrawn_at desc, uwr.user_id desc, uwr.reason asc + order by re.withdrawn_at desc, re.id desc, wer.reason asc """) .setParameter("limitEvents", limitEvents) .getResultList(); } - public List countAllWithdrawalReasons() { + public List countRetainedWithdrawalReasons() { return em.createNativeQuery(""" select - uwr.reason, + wer.reason, count(*) as cnt - from user_withdrawal_reasons uwr - group by uwr.reason + from withdrawal_event_reasons wer + join withdrawal_events we on we.id = wer.event_id + where we.expires_at > current_timestamp + group by wer.reason """) .getResultList(); } diff --git a/src/main/resources/templates/stats/withdrawal.html b/src/main/resources/templates/stats/withdrawal.html index ca313194..9941db50 100644 --- a/src/main/resources/templates/stats/withdrawal.html +++ b/src/main/resources/templates/stats/withdrawal.html @@ -224,8 +224,8 @@
-
탈퇴 사유 분포
-
전체 탈퇴 사유 집계
+
탈퇴 요청 사유 분포
+
계정 완전 삭제와 분리해 보존 중인 사유를 집계하며, 탈퇴 요청 시점부터 최대 1년 범위입니다.
@@ -234,15 +234,15 @@
-
탈퇴 이벤트 목록 (최신순)
-
표는 최신 100개 이벤트를 표시합니다.
+
탈퇴 요청 이벤트 목록 (최신순)
+
계정 상태와 무관하게 보존 기간이 남은 요청 중 최신 100개를 표시합니다. 계정 연결이 제거된 이벤트의 자유 입력 내용은 표시하지 않습니다.
- + @@ -256,7 +256,7 @@
탈퇴 시각탈퇴 요청 시각 선택 사유 기타 사유
-
표시할 탈퇴 이벤트가 없습니다.
+
표시할 탈퇴 요청 이벤트가 없습니다.
diff --git a/src/test/java/com/devkor/ifive/nadab/domain/stats/application/WithdrawalStatsServiceTest.java b/src/test/java/com/devkor/ifive/nadab/domain/stats/application/WithdrawalStatsServiceTest.java index a87dc158..04b695c0 100644 --- a/src/test/java/com/devkor/ifive/nadab/domain/stats/application/WithdrawalStatsServiceTest.java +++ b/src/test/java/com/devkor/ifive/nadab/domain/stats/application/WithdrawalStatsServiceTest.java @@ -19,7 +19,7 @@ class WithdrawalStatsServiceTest { @Test - void getWithdrawalStats_groups_latest_rows_by_user_and_withdrawn_at() { + void getWithdrawalStats_groups_latest_rows_by_event_id() { // given WithdrawalStatsRepository repo = mock(WithdrawalStatsRepository.class); WithdrawalStatsService service = new WithdrawalStatsService(repo); @@ -27,12 +27,12 @@ void getWithdrawalStats_groups_latest_rows_by_user_and_withdrawn_at() { OffsetDateTime withdrawnAt = OffsetDateTime.of( 2026, 6, 1, 12, 30, 5, 900_000_000, ZoneOffset.UTC ); - when(repo.findLatestWithdrawalReasonRows(100)).thenReturn(List.of( - row(1L, withdrawnAt, "DAILY_LOGGING_BURDEN", null), - row(1L, withdrawnAt, "OTHER", " custom reason "), - row(2L, Timestamp.valueOf(LocalDateTime.of(2026, 6, 2, 10, 0, 0)), "UNKNOWN_REASON", null) + when(repo.findLatestRetainedWithdrawalReasonRows(100)).thenReturn(List.of( + row(101L, withdrawnAt, "DAILY_LOGGING_BURDEN", null), + row(101L, withdrawnAt, "OTHER", " custom reason "), + row(102L, Timestamp.valueOf(LocalDateTime.of(2026, 6, 2, 10, 0, 0)), "UNKNOWN_REASON", null) )); - when(repo.countAllWithdrawalReasons()).thenReturn(List.of( + when(repo.countRetainedWithdrawalReasons()).thenReturn(List.of( row("DAILY_LOGGING_BURDEN", 2L), row("OTHER", 1L), row("UNKNOWN_REASON", 99L) diff --git a/src/test/java/com/devkor/ifive/nadab/domain/stats/controller/StatsControllerTemplateTest.java b/src/test/java/com/devkor/ifive/nadab/domain/stats/controller/StatsControllerTemplateTest.java index 116a221b..c2a4e619 100644 --- a/src/test/java/com/devkor/ifive/nadab/domain/stats/controller/StatsControllerTemplateTest.java +++ b/src/test/java/com/devkor/ifive/nadab/domain/stats/controller/StatsControllerTemplateTest.java @@ -37,6 +37,8 @@ import com.devkor.ifive.nadab.domain.stats.core.dto.type.TypeStatsViewModel; import com.devkor.ifive.nadab.domain.stats.core.dto.weekly.WeeklyPeriodStatsViewModel; import com.devkor.ifive.nadab.domain.stats.core.dto.weekly.WeeklyStatsViewModel; +import com.devkor.ifive.nadab.domain.stats.core.dto.withdrawal.WithdrawalEventRowViewModel; +import com.devkor.ifive.nadab.domain.stats.core.dto.withdrawal.WithdrawalStatsViewModel; import com.devkor.ifive.nadab.domain.user.core.entity.InterestCode; import com.devkor.ifive.nadab.global.security.filter.JwtAuthenticationFilter; import org.junit.jupiter.api.Test; @@ -289,6 +291,36 @@ void monthlyStats_renders_monthly_peak_period() throws Exception { .isLessThan(html.indexOf("선택한 월간 통계")); } + @Test + void withdrawalStats_renders_retention_scope_and_request_event_labels() throws Exception { + when(withdrawalStatsService.getWithdrawalStats()).thenReturn(new WithdrawalStatsViewModel( + 100, + 1L, + List.of("매일 기록이 부담", "기타(직접 입력)"), + List.of(2L, 1L), + List.of(new WithdrawalEventRowViewModel( + "2026-09-01 12:30:00", + "매일 기록이 부담, 기타(직접 입력)", + "-" + )), + "2026-09-06 12:00:00" + )); + + mockMvc.perform(get("/stats/withdrawal")) + .andExpect(status().isOk()) + .andExpect(content().string(containsString("탈퇴 요청 사유 분포"))) + .andExpect(content().string(containsString("계정 완전 삭제와 분리"))) + .andExpect(content().string(containsString("탈퇴 요청 시점부터 최대 1년"))) + .andExpect(content().string(containsString("탈퇴 요청 이벤트 목록"))) + .andExpect(content().string(containsString("계정 상태와 무관하게 보존 기간이 남은 요청"))) + .andExpect(content().string(containsString("계정 연결이 제거된 이벤트의 자유 입력 내용은 표시하지 않습니다."))) + .andExpect(content().string(containsString("탈퇴 요청 시각"))) + .andExpect(content().string(containsString("2026-09-01 12:30:00"))) + .andExpect(content().string(containsString("매일 기록이 부담, 기타(직접 입력)"))); + + verify(withdrawalStatsService).getWithdrawalStats(); + } + @Test void statsPages_reject_invalid_or_future_period_parameters() throws Exception { mockMvc.perform(get("/stats/daily").param("date", "2999-01-01")) diff --git a/src/test/java/com/devkor/ifive/nadab/domain/stats/core/repository/WithdrawalStatsRepositoryTest.java b/src/test/java/com/devkor/ifive/nadab/domain/stats/core/repository/WithdrawalStatsRepositoryTest.java new file mode 100644 index 00000000..eaae0af8 --- /dev/null +++ b/src/test/java/com/devkor/ifive/nadab/domain/stats/core/repository/WithdrawalStatsRepositoryTest.java @@ -0,0 +1,119 @@ +package com.devkor.ifive.nadab.domain.stats.core.repository; + +import com.devkor.ifive.nadab.domain.user.core.entity.User; +import com.devkor.ifive.nadab.infra.builder.UserBuilder; +import com.devkor.ifive.nadab.infra.db.PostgresIntegrationTestSupport; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; +import org.springframework.boot.test.autoconfigure.orm.jpa.TestEntityManager; +import org.springframework.context.annotation.Import; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.test.context.ActiveProfiles; + +import java.time.OffsetDateTime; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +import static org.assertj.core.api.Assertions.assertThat; + +@DataJpaTest +@ActiveProfiles("test") +@Import(WithdrawalStatsRepository.class) +class WithdrawalStatsRepositoryTest extends PostgresIntegrationTestSupport { + + @Autowired + WithdrawalStatsRepository repository; + + @Autowired + TestEntityManager em; + + @Autowired + JdbcTemplate jdbcTemplate; + + @Test + void keeps_hard_deleted_user_event_in_stats_without_custom_reason() { + User user = new UserBuilder(em).build(); + em.flush(); + + OffsetDateTime withdrawnAt = OffsetDateTime.now().minusDays(30); + Long eventId = insertEvent(user.getId(), withdrawnAt, withdrawnAt.plusYears(1)); + insertReason(eventId, "DAILY_LOGGING_BURDEN", null); + insertReason(eventId, "OTHER", "자유 입력 사유"); + + jdbcTemplate.update("DELETE FROM users WHERE id = ?", user.getId()); + em.clear(); + + List latestRows = repository.findLatestRetainedWithdrawalReasonRows(100); + Map reasonCounts = toReasonCountMap(repository.countRetainedWithdrawalReasons()); + + assertThat(latestRows).hasSize(2); + assertThat(latestRows).extracting(row -> ((Number) row[0]).longValue()).containsOnly(eventId); + assertThat(latestRows).extracting(row -> String.valueOf(row[2])) + .containsExactly("DAILY_LOGGING_BURDEN", "OTHER"); + assertThat(latestRows).extracting(row -> row[3]).containsOnlyNulls(); + assertThat(reasonCounts).containsExactlyInAnyOrderEntriesOf(Map.of( + "DAILY_LOGGING_BURDEN", 1L, + "OTHER", 1L + )); + } + + @Test + void limits_latest_rows_by_event_and_excludes_expired_events() { + OffsetDateTime now = OffsetDateTime.now(); + Long olderRetainedEventId = insertEvent(null, now.minusDays(30), now.plusDays(30)); + insertReason(olderRetainedEventId, "DAILY_LOGGING_BURDEN", null); + + Long latestRetainedEventId = insertEvent(null, now.minusDays(1), now.plusDays(364)); + insertReason(latestRetainedEventId, "OTHER", "최근 자유 입력 사유"); + insertReason(latestRetainedEventId, "PRIVACY_RECORD_CONCERN", null); + + Long expiredEventId = insertEvent(null, now.minusDays(3), now.minusDays(2)); + insertReason(expiredEventId, "APP_ERROR_OR_SLOWNESS", null); + + List latestRows = repository.findLatestRetainedWithdrawalReasonRows(1); + Map reasonCounts = toReasonCountMap(repository.countRetainedWithdrawalReasons()); + + assertThat(latestRows).hasSize(2); + assertThat(latestRows) + .extracting(row -> ((Number) row[0]).longValue()) + .containsOnly(latestRetainedEventId); + assertThat(reasonCounts).containsExactlyInAnyOrderEntriesOf(Map.of( + "DAILY_LOGGING_BURDEN", 1L, + "OTHER", 1L, + "PRIVACY_RECORD_CONCERN", 1L + )); + assertThat(reasonCounts).doesNotContainKey("APP_ERROR_OR_SLOWNESS"); + } + + private Long insertEvent(Long userId, OffsetDateTime withdrawnAt, OffsetDateTime expiresAt) { + return jdbcTemplate.queryForObject(""" + INSERT INTO withdrawal_events ( + user_id, + withdrawn_at, + expires_at + ) + VALUES (?, ?, ?) + RETURNING id + """, Long.class, userId, withdrawnAt, expiresAt); + } + + private void insertReason(Long eventId, String reason, String customReason) { + jdbcTemplate.update(""" + INSERT INTO withdrawal_event_reasons ( + event_id, + reason, + custom_reason + ) + VALUES (?, ?, ?) + """, eventId, reason, customReason); + } + + private Map toReasonCountMap(List rows) { + return rows.stream().collect(Collectors.toMap( + row -> String.valueOf(row[0]), + row -> ((Number) row[1]).longValue() + )); + } +} From e1587a503844be2e6c523e2f1884e614679fdb0a Mon Sep 17 00:00:00 2001 From: 1Seob Date: Sun, 6 Sep 2026 16:53:05 +0900 Subject: [PATCH 4/5] =?UTF-8?q?feat(auth):=20=ED=83=88=ED=87=B4=20?= =?UTF-8?q?=EC=9D=B4=EB=B2=A4=ED=8A=B8=20=EB=B3=B4=EC=A1=B4=20=EA=B8=B0?= =?UTF-8?q?=EA=B0=84=20=EC=A0=95=EB=A6=AC=20=EC=9E=90=EB=8F=99=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 탈퇴 요청 14일 경과 시 withdrawal_events의 user_id와 withdrawal_event_reasons의 custom_reason을 제거하고 anonymized_at을 기록하며, 전환기 user_withdrawal_reasons와 1년 만료 이벤트를 정리하도록 추가함. 기존 데이터 정리 migration과 멱등 실행·중간 실패 전체 롤백을 검증하는 PostgreSQL 테스트를 추가함 --- .../UserWithdrawalReasonRepository.java | 14 ++ .../WithdrawalEventReasonRepository.java | 18 ++ .../repository/WithdrawalEventRepository.java | 25 ++ .../WithdrawalEventCleanupScheduler.java | 56 +++++ ...add_withdrawal_event_retention_cleanup.sql | 40 +++ ...WithdrawalEventRetentionMigrationTest.java | 205 ++++++++++++++++ .../WithdrawalEventCleanupSchedulerTest.java | 230 ++++++++++++++++++ 7 files changed, 588 insertions(+) create mode 100644 src/main/java/com/devkor/ifive/nadab/domain/auth/infra/scheduler/WithdrawalEventCleanupScheduler.java create mode 100644 src/main/resources/db/migration/V20260906_1300__IS_add_withdrawal_event_retention_cleanup.sql create mode 100644 src/test/java/com/devkor/ifive/nadab/domain/auth/core/repository/WithdrawalEventRetentionMigrationTest.java create mode 100644 src/test/java/com/devkor/ifive/nadab/domain/auth/infra/scheduler/WithdrawalEventCleanupSchedulerTest.java diff --git a/src/main/java/com/devkor/ifive/nadab/domain/auth/core/repository/UserWithdrawalReasonRepository.java b/src/main/java/com/devkor/ifive/nadab/domain/auth/core/repository/UserWithdrawalReasonRepository.java index 745450ea..9bfbc000 100644 --- a/src/main/java/com/devkor/ifive/nadab/domain/auth/core/repository/UserWithdrawalReasonRepository.java +++ b/src/main/java/com/devkor/ifive/nadab/domain/auth/core/repository/UserWithdrawalReasonRepository.java @@ -2,6 +2,20 @@ import com.devkor.ifive.nadab.domain.auth.core.entity.UserWithdrawalReason; 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.time.OffsetDateTime; public interface UserWithdrawalReasonRepository extends JpaRepository { + + @Modifying(clearAutomatically = true, flushAutomatically = true) + @Query(""" + delete from UserWithdrawalReason reason + where reason.withdrawnAt < :anonymizationDate + """) + int deleteReasonsWithdrawnBefore( + @Param("anonymizationDate") OffsetDateTime anonymizationDate + ); } diff --git a/src/main/java/com/devkor/ifive/nadab/domain/auth/core/repository/WithdrawalEventReasonRepository.java b/src/main/java/com/devkor/ifive/nadab/domain/auth/core/repository/WithdrawalEventReasonRepository.java index 94dfaec3..546fe517 100644 --- a/src/main/java/com/devkor/ifive/nadab/domain/auth/core/repository/WithdrawalEventReasonRepository.java +++ b/src/main/java/com/devkor/ifive/nadab/domain/auth/core/repository/WithdrawalEventReasonRepository.java @@ -2,6 +2,24 @@ import com.devkor.ifive.nadab.domain.auth.core.entity.WithdrawalEventReason; 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.time.OffsetDateTime; public interface WithdrawalEventReasonRepository extends JpaRepository { + + @Modifying(clearAutomatically = true, flushAutomatically = true) + @Query(value = """ + update withdrawal_event_reasons reason + set custom_reason = null + from withdrawal_events event + where event.id = reason.event_id + and event.withdrawn_at < :anonymizationDate + and reason.custom_reason is not null + """, nativeQuery = true) + int clearCustomReasonsWithdrawnBefore( + @Param("anonymizationDate") OffsetDateTime anonymizationDate + ); } diff --git a/src/main/java/com/devkor/ifive/nadab/domain/auth/core/repository/WithdrawalEventRepository.java b/src/main/java/com/devkor/ifive/nadab/domain/auth/core/repository/WithdrawalEventRepository.java index 3642d6b0..e94177b4 100644 --- a/src/main/java/com/devkor/ifive/nadab/domain/auth/core/repository/WithdrawalEventRepository.java +++ b/src/main/java/com/devkor/ifive/nadab/domain/auth/core/repository/WithdrawalEventRepository.java @@ -2,6 +2,31 @@ import com.devkor.ifive.nadab.domain.auth.core.entity.WithdrawalEvent; 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.time.OffsetDateTime; public interface WithdrawalEventRepository extends JpaRepository { + + @Modifying(clearAutomatically = true, flushAutomatically = true) + @Query(""" + delete from WithdrawalEvent event + where event.expiresAt <= :expirationDate + """) + int deleteExpiredEvents(@Param("expirationDate") OffsetDateTime expirationDate); + + @Modifying(clearAutomatically = true, flushAutomatically = true) + @Query(""" + update WithdrawalEvent event + set event.user = null, + event.anonymizedAt = :anonymizedAt + where event.withdrawnAt < :anonymizationDate + and (event.user is not null or event.anonymizedAt is null) + """) + int anonymizeEventsWithdrawnBefore( + @Param("anonymizationDate") OffsetDateTime anonymizationDate, + @Param("anonymizedAt") OffsetDateTime anonymizedAt + ); } diff --git a/src/main/java/com/devkor/ifive/nadab/domain/auth/infra/scheduler/WithdrawalEventCleanupScheduler.java b/src/main/java/com/devkor/ifive/nadab/domain/auth/infra/scheduler/WithdrawalEventCleanupScheduler.java new file mode 100644 index 00000000..2efddaa1 --- /dev/null +++ b/src/main/java/com/devkor/ifive/nadab/domain/auth/infra/scheduler/WithdrawalEventCleanupScheduler.java @@ -0,0 +1,56 @@ +package com.devkor.ifive.nadab.domain.auth.infra.scheduler; + +import com.devkor.ifive.nadab.domain.auth.core.repository.UserWithdrawalReasonRepository; +import com.devkor.ifive.nadab.domain.auth.core.repository.WithdrawalEventReasonRepository; +import com.devkor.ifive.nadab.domain.auth.core.repository.WithdrawalEventRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +import java.time.OffsetDateTime; + +@Slf4j +@Component +@RequiredArgsConstructor +public class WithdrawalEventCleanupScheduler { + + private static final int ACCOUNT_RECOVERY_PERIOD_DAYS = 14; + + private final WithdrawalEventRepository withdrawalEventRepository; + private final WithdrawalEventReasonRepository withdrawalEventReasonRepository; + private final UserWithdrawalReasonRepository userWithdrawalReasonRepository; + + @Scheduled(cron = "0 10 0 * * *", zone = "Asia/Seoul") + @Transactional + public void cleanupWithdrawalEvents() { + OffsetDateTime now = OffsetDateTime.now(); + OffsetDateTime anonymizationDate = now.minusDays(ACCOUNT_RECOVERY_PERIOD_DAYS); + + int deletedEventCount = withdrawalEventRepository.deleteExpiredEvents(now); + int deletedLegacyReasonCount = userWithdrawalReasonRepository + .deleteReasonsWithdrawnBefore(anonymizationDate); + int clearedCustomReasonCount = withdrawalEventReasonRepository + .clearCustomReasonsWithdrawnBefore(anonymizationDate); + int anonymizedEventCount = withdrawalEventRepository + .anonymizeEventsWithdrawnBefore(anonymizationDate, now); + + if (deletedEventCount == 0 + && deletedLegacyReasonCount == 0 + && clearedCustomReasonCount == 0 + && anonymizedEventCount == 0) { + log.debug("정리할 탈퇴 이벤트가 없습니다."); + return; + } + + log.info( + "탈퇴 이벤트 정리 완료: 만료 이벤트 {}건 삭제, 레거시 사유 {}건 삭제, " + + "자유 입력 {}건 제거, 이벤트 {}건 비식별화", + deletedEventCount, + deletedLegacyReasonCount, + clearedCustomReasonCount, + anonymizedEventCount + ); + } +} diff --git a/src/main/resources/db/migration/V20260906_1300__IS_add_withdrawal_event_retention_cleanup.sql b/src/main/resources/db/migration/V20260906_1300__IS_add_withdrawal_event_retention_cleanup.sql new file mode 100644 index 00000000..7fb966bc --- /dev/null +++ b/src/main/resources/db/migration/V20260906_1300__IS_add_withdrawal_event_retention_cleanup.sql @@ -0,0 +1,40 @@ +ALTER TABLE withdrawal_event_reasons + DROP CONSTRAINT chk_withdrawal_event_reasons_other_custom_reason; + +ALTER TABLE withdrawal_event_reasons + ADD CONSTRAINT chk_withdrawal_event_reasons_other_custom_reason + CHECK ( + ( + reason = 'OTHER' + AND ( + custom_reason IS NULL + OR LENGTH(BTRIM(custom_reason)) > 0 + ) + ) + OR + (reason <> 'OTHER' AND custom_reason IS NULL) + ); + +COMMENT ON COLUMN withdrawal_event_reasons.custom_reason IS + 'Optional free-text detail removed after the 14-day account recovery period'; + +-- RETENTION CLEANUP START +DELETE FROM withdrawal_events +WHERE expires_at <= CURRENT_TIMESTAMP; + +DELETE FROM user_withdrawal_reasons +WHERE withdrawn_at < CURRENT_TIMESTAMP - INTERVAL '14 days'; + +UPDATE withdrawal_event_reasons wer +SET custom_reason = NULL +FROM withdrawal_events we +WHERE we.id = wer.event_id + AND we.withdrawn_at < CURRENT_TIMESTAMP - INTERVAL '14 days' + AND wer.custom_reason IS NOT NULL; + +UPDATE withdrawal_events +SET user_id = NULL, + anonymized_at = COALESCE(anonymized_at, CURRENT_TIMESTAMP) +WHERE withdrawn_at < CURRENT_TIMESTAMP - INTERVAL '14 days' + AND (user_id IS NOT NULL OR anonymized_at IS NULL); +-- RETENTION CLEANUP END diff --git a/src/test/java/com/devkor/ifive/nadab/domain/auth/core/repository/WithdrawalEventRetentionMigrationTest.java b/src/test/java/com/devkor/ifive/nadab/domain/auth/core/repository/WithdrawalEventRetentionMigrationTest.java new file mode 100644 index 00000000..0b00dbe4 --- /dev/null +++ b/src/test/java/com/devkor/ifive/nadab/domain/auth/core/repository/WithdrawalEventRetentionMigrationTest.java @@ -0,0 +1,205 @@ +package com.devkor.ifive.nadab.domain.auth.core.repository; + +import com.devkor.ifive.nadab.domain.user.core.entity.User; +import com.devkor.ifive.nadab.infra.builder.UserBuilder; +import com.devkor.ifive.nadab.infra.db.PostgresIntegrationTestSupport; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; +import org.springframework.boot.test.autoconfigure.orm.jpa.TestEntityManager; +import org.springframework.core.io.Resource; +import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.test.context.ActiveProfiles; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.time.OffsetDateTime; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +@DataJpaTest +@ActiveProfiles("test") +class WithdrawalEventRetentionMigrationTest extends PostgresIntegrationTestSupport { + + private static final String CLEANUP_START = "-- RETENTION CLEANUP START"; + private static final String CLEANUP_END = "-- RETENTION CLEANUP END"; + + @Autowired + JdbcTemplate jdbcTemplate; + + @Autowired + TestEntityManager em; + + @Value("classpath:db/migration/V20260906_1300__IS_add_withdrawal_event_retention_cleanup.sql") + Resource migration; + + @Test + void cleans_existing_events_by_anonymization_and_expiration_deadlines() throws IOException { + User oldUser = new UserBuilder(em).build(); + User recentUser = new UserBuilder(em).build(); + User expiredUser = new UserBuilder(em).build(); + em.flush(); + + OffsetDateTime now = OffsetDateTime.now(); + Long oldEventId = insertEvent( + oldUser.getId(), + now.minusDays(30), + now.plusDays(335) + ); + insertReason(oldEventId, "OTHER", "오래된 자유 입력 사유"); + insertLegacyReason(oldUser.getId(), now.minusDays(30), "OTHER", "오래된 레거시 사유"); + + Long recentEventId = insertEvent( + recentUser.getId(), + now.minusDays(13), + now.plusDays(352) + ); + insertReason(recentEventId, "OTHER", "최근 자유 입력 사유"); + insertLegacyReason(recentUser.getId(), now.minusDays(13), "OTHER", "최근 레거시 사유"); + + Long expiredEventId = insertEvent( + expiredUser.getId(), + now.minusYears(2), + now.minusYears(1) + ); + insertReason(expiredEventId, "APP_ERROR_OR_SLOWNESS", null); + + jdbcTemplate.execute(extractCleanupSql()); + + assertThat(queryUserId(oldEventId)).isNull(); + assertThat(queryAnonymizedAt(oldEventId)).isNotNull(); + assertThat(queryCustomReason(oldEventId)).isNull(); + assertThat(countReasons(oldEventId)).isEqualTo(1L); + assertThat(countLegacyReasons(oldUser.getId())).isZero(); + + assertThat(queryUserId(recentEventId)).isEqualTo(recentUser.getId()); + assertThat(queryAnonymizedAt(recentEventId)).isNull(); + assertThat(queryCustomReason(recentEventId)).isEqualTo("최근 자유 입력 사유"); + assertThat(countLegacyReasons(recentUser.getId())).isEqualTo(1L); + + assertThat(countEvents(expiredEventId)).isZero(); + assertThat(countReasons(expiredEventId)).isZero(); + } + + @Test + void allows_other_reason_without_custom_text_after_anonymization() { + OffsetDateTime withdrawnAt = OffsetDateTime.now().minusDays(30); + Long eventId = insertEvent(null, withdrawnAt, withdrawnAt.plusYears(1)); + jdbcTemplate.update( + "UPDATE withdrawal_events SET anonymized_at = ? WHERE id = ?", + OffsetDateTime.now(), + eventId + ); + + insertReason(eventId, "OTHER", null); + + assertThat(countReasons(eventId)).isEqualTo(1L); + } + + @Test + void still_rejects_blank_other_reason() { + OffsetDateTime withdrawnAt = OffsetDateTime.now(); + Long eventId = insertEvent(null, withdrawnAt, withdrawnAt.plusYears(1)); + + assertThatThrownBy(() -> insertReason(eventId, "OTHER", " ")) + .isInstanceOf(DataIntegrityViolationException.class); + } + + private Long insertEvent(Long userId, OffsetDateTime withdrawnAt, OffsetDateTime expiresAt) { + return jdbcTemplate.queryForObject(""" + INSERT INTO withdrawal_events ( + user_id, + withdrawn_at, + expires_at + ) + VALUES (?, ?, ?) + RETURNING id + """, Long.class, userId, withdrawnAt, expiresAt); + } + + private void insertReason(Long eventId, String reason, String customReason) { + jdbcTemplate.update(""" + INSERT INTO withdrawal_event_reasons ( + event_id, + reason, + custom_reason + ) + VALUES (?, ?, ?) + """, eventId, reason, customReason); + } + + private void insertLegacyReason( + Long userId, + OffsetDateTime withdrawnAt, + String reason, + String customReason + ) { + jdbcTemplate.update(""" + INSERT INTO user_withdrawal_reasons ( + user_id, + reason, + custom_reason, + withdrawn_at + ) + VALUES (?, ?, ?, ?) + """, userId, reason, customReason, withdrawnAt); + } + + private Long queryUserId(Long eventId) { + return jdbcTemplate.queryForObject( + "SELECT user_id FROM withdrawal_events WHERE id = ?", + Long.class, + eventId + ); + } + + private OffsetDateTime queryAnonymizedAt(Long eventId) { + return jdbcTemplate.queryForObject( + "SELECT anonymized_at FROM withdrawal_events WHERE id = ?", + OffsetDateTime.class, + eventId + ); + } + + private String queryCustomReason(Long eventId) { + return jdbcTemplate.queryForObject( + "SELECT custom_reason FROM withdrawal_event_reasons WHERE event_id = ?", + String.class, + eventId + ); + } + + private Long countEvents(Long eventId) { + return jdbcTemplate.queryForObject( + "SELECT COUNT(*) FROM withdrawal_events WHERE id = ?", + Long.class, + eventId + ); + } + + private Long countReasons(Long eventId) { + return jdbcTemplate.queryForObject( + "SELECT COUNT(*) FROM withdrawal_event_reasons WHERE event_id = ?", + Long.class, + eventId + ); + } + + private Long countLegacyReasons(Long userId) { + return jdbcTemplate.queryForObject( + "SELECT COUNT(*) FROM user_withdrawal_reasons WHERE user_id = ?", + Long.class, + userId + ); + } + + private String extractCleanupSql() throws IOException { + String migrationSql = migration.getContentAsString(StandardCharsets.UTF_8); + int start = migrationSql.indexOf(CLEANUP_START) + CLEANUP_START.length(); + int end = migrationSql.indexOf(CLEANUP_END); + return migrationSql.substring(start, end); + } +} diff --git a/src/test/java/com/devkor/ifive/nadab/domain/auth/infra/scheduler/WithdrawalEventCleanupSchedulerTest.java b/src/test/java/com/devkor/ifive/nadab/domain/auth/infra/scheduler/WithdrawalEventCleanupSchedulerTest.java new file mode 100644 index 00000000..95f46439 --- /dev/null +++ b/src/test/java/com/devkor/ifive/nadab/domain/auth/infra/scheduler/WithdrawalEventCleanupSchedulerTest.java @@ -0,0 +1,230 @@ +package com.devkor.ifive.nadab.domain.auth.infra.scheduler; + +import com.devkor.ifive.nadab.domain.auth.core.repository.WithdrawalEventRepository; +import com.devkor.ifive.nadab.domain.user.core.entity.User; +import com.devkor.ifive.nadab.domain.user.core.repository.UserRepository; +import com.devkor.ifive.nadab.infra.db.PostgresIntegrationTestSupport; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; +import org.springframework.context.annotation.Import; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.bean.override.mockito.MockitoSpyBean; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; + +import java.time.OffsetDateTime; +import java.util.ArrayList; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.doThrow; + +@DataJpaTest +@ActiveProfiles("test") +@Transactional(propagation = Propagation.NOT_SUPPORTED) +@Import(WithdrawalEventCleanupScheduler.class) +class WithdrawalEventCleanupSchedulerTest extends PostgresIntegrationTestSupport { + + @MockitoSpyBean + WithdrawalEventRepository withdrawalEventRepository; + + @Autowired + WithdrawalEventCleanupScheduler scheduler; + + @Autowired + UserRepository userRepository; + + @Autowired + JdbcTemplate jdbcTemplate; + + private final List eventIds = new ArrayList<>(); + private final List userIds = new ArrayList<>(); + + @AfterEach + void tearDown() { + eventIds.forEach(eventId -> jdbcTemplate.update( + "DELETE FROM withdrawal_events WHERE id = ?", + eventId + )); + userIds.forEach(userId -> jdbcTemplate.update( + "DELETE FROM users WHERE id = ?", + userId + )); + } + + @Test + void anonymizes_old_events_deletes_expired_events_and_is_idempotent() { + OffsetDateTime now = OffsetDateTime.now(); + Long oldUserId = createUser(); + Long recentUserId = createUser(); + Long expiredUserId = createUser(); + + Long oldEventId = insertEvent(oldUserId, now.minusDays(15), now.plusDays(350)); + insertReason(oldEventId, "OTHER", "오래된 자유 입력 사유"); + insertLegacyReason(oldUserId, now.minusDays(15), "OTHER", "오래된 레거시 사유"); + + Long recentEventId = insertEvent(recentUserId, now.minusDays(13), now.plusDays(352)); + insertReason(recentEventId, "OTHER", "최근 자유 입력 사유"); + insertLegacyReason(recentUserId, now.minusDays(13), "OTHER", "최근 레거시 사유"); + + Long expiredEventId = insertEvent(expiredUserId, now.minusYears(2), now.minusDays(1)); + insertReason(expiredEventId, "DAILY_LOGGING_BURDEN", null); + + scheduler.cleanupWithdrawalEvents(); + + OffsetDateTime firstAnonymizedAt = queryAnonymizedAt(oldEventId); + assertThat(queryUserId(oldEventId)).isNull(); + assertThat(firstAnonymizedAt).isNotNull(); + assertThat(queryCustomReason(oldEventId)).isNull(); + assertThat(countLegacyReasons(oldUserId)).isZero(); + + assertThat(queryUserId(recentEventId)).isEqualTo(recentUserId); + assertThat(queryAnonymizedAt(recentEventId)).isNull(); + assertThat(queryCustomReason(recentEventId)).isEqualTo("최근 자유 입력 사유"); + assertThat(countLegacyReasons(recentUserId)).isEqualTo(1L); + + assertThat(countEvents(expiredEventId)).isZero(); + assertThat(countReasons(expiredEventId)).isZero(); + + scheduler.cleanupWithdrawalEvents(); + + assertThat(queryAnonymizedAt(oldEventId)).isEqualTo(firstAnonymizedAt); + assertThat(countEvents(oldEventId)).isEqualTo(1L); + assertThat(countReasons(oldEventId)).isEqualTo(1L); + assertThat(countLegacyReasons(recentUserId)).isEqualTo(1L); + } + + @Test + void failure_rolls_back_expiration_and_anonymization_changes_together() { + OffsetDateTime now = OffsetDateTime.now(); + Long oldUserId = createUser(); + Long expiredUserId = createUser(); + + Long oldEventId = insertEvent(oldUserId, now.minusDays(15), now.plusDays(350)); + insertReason(oldEventId, "OTHER", "롤백할 자유 입력 사유"); + insertLegacyReason(oldUserId, now.minusDays(15), "OTHER", "롤백할 레거시 사유"); + + Long expiredEventId = insertEvent(expiredUserId, now.minusYears(2), now.minusDays(1)); + insertReason(expiredEventId, "APP_ERROR_OR_SLOWNESS", null); + + doThrow(new IllegalStateException("탈퇴 이벤트 비식별화 실패")) + .when(withdrawalEventRepository) + .anonymizeEventsWithdrawnBefore(any(OffsetDateTime.class), any(OffsetDateTime.class)); + + assertThatThrownBy(scheduler::cleanupWithdrawalEvents) + .isInstanceOf(IllegalStateException.class); + + assertThat(countEvents(expiredEventId)).isEqualTo(1L); + assertThat(countReasons(expiredEventId)).isEqualTo(1L); + assertThat(queryUserId(oldEventId)).isEqualTo(oldUserId); + assertThat(queryAnonymizedAt(oldEventId)).isNull(); + assertThat(queryCustomReason(oldEventId)).isEqualTo("롤백할 자유 입력 사유"); + assertThat(countLegacyReasons(oldUserId)).isEqualTo(1L); + } + + private Long createUser() { + User user = User.createUser( + "withdrawal-cleanup+" + System.nanoTime() + "@test.com", + "hashed_password" + ); + user.updateNickname("wcu" + System.nanoTime()); + Long userId = userRepository.save(user).getId(); + userIds.add(userId); + return userId; + } + + private Long insertEvent(Long userId, OffsetDateTime withdrawnAt, OffsetDateTime expiresAt) { + Long eventId = jdbcTemplate.queryForObject(""" + INSERT INTO withdrawal_events ( + user_id, + withdrawn_at, + expires_at + ) + VALUES (?, ?, ?) + RETURNING id + """, Long.class, userId, withdrawnAt, expiresAt); + eventIds.add(eventId); + return eventId; + } + + private void insertReason(Long eventId, String reason, String customReason) { + jdbcTemplate.update(""" + INSERT INTO withdrawal_event_reasons ( + event_id, + reason, + custom_reason + ) + VALUES (?, ?, ?) + """, eventId, reason, customReason); + } + + private void insertLegacyReason( + Long userId, + OffsetDateTime withdrawnAt, + String reason, + String customReason + ) { + jdbcTemplate.update(""" + INSERT INTO user_withdrawal_reasons ( + user_id, + reason, + custom_reason, + withdrawn_at + ) + VALUES (?, ?, ?, ?) + """, userId, reason, customReason, withdrawnAt); + } + + private Long queryUserId(Long eventId) { + return jdbcTemplate.queryForObject( + "SELECT user_id FROM withdrawal_events WHERE id = ?", + Long.class, + eventId + ); + } + + private OffsetDateTime queryAnonymizedAt(Long eventId) { + return jdbcTemplate.queryForObject( + "SELECT anonymized_at FROM withdrawal_events WHERE id = ?", + OffsetDateTime.class, + eventId + ); + } + + private String queryCustomReason(Long eventId) { + return jdbcTemplate.queryForObject( + "SELECT custom_reason FROM withdrawal_event_reasons WHERE event_id = ?", + String.class, + eventId + ); + } + + private Long countEvents(Long eventId) { + return jdbcTemplate.queryForObject( + "SELECT COUNT(*) FROM withdrawal_events WHERE id = ?", + Long.class, + eventId + ); + } + + private Long countReasons(Long eventId) { + return jdbcTemplate.queryForObject( + "SELECT COUNT(*) FROM withdrawal_event_reasons WHERE event_id = ?", + Long.class, + eventId + ); + } + + private Long countLegacyReasons(Long userId) { + return jdbcTemplate.queryForObject( + "SELECT COUNT(*) FROM user_withdrawal_reasons WHERE user_id = ?", + Long.class, + userId + ); + } +} From 2d94502c4bd7f18cc56e3fef0452c32e61231338 Mon Sep 17 00:00:00 2001 From: 1Seob Date: Sun, 6 Sep 2026 19:35:14 +0900 Subject: [PATCH 5/5] =?UTF-8?q?refactor(auth):=20=EB=A0=88=EA=B1=B0?= =?UTF-8?q?=EC=8B=9C=20=ED=83=88=ED=87=B4=20=EC=82=AC=EC=9C=A0=20=EC=9D=B4?= =?UTF-8?q?=EC=A4=91=20=EC=A0=80=EC=9E=A5=20=EC=A4=91=EB=8B=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 신규 탈퇴 요청의 저장 경로를 withdrawal_events와 withdrawal_event_reasons로 단일화함. 롤백 호환성과 잔여 데이터 정리를 위해 기존 테이블은 유지하고 DB 설명에 레거시 상태와 대체 저장소를 명시함 --- .../auth/application/AuthServiceV2.java | 11 ------- ...IS_mark_user_withdrawal_reasons_legacy.sql | 2 ++ .../AuthServiceV2PersistenceTest.java | 13 ++------ .../auth/application/AuthServiceV2Test.java | 26 --------------- ...hdrawalReasonLegacyTableMigrationTest.java | 32 +++++++++++++++++++ 5 files changed, 37 insertions(+), 47 deletions(-) create mode 100644 src/main/resources/db/migration/V20260906_1400__IS_mark_user_withdrawal_reasons_legacy.sql create mode 100644 src/test/java/com/devkor/ifive/nadab/domain/auth/core/repository/WithdrawalReasonLegacyTableMigrationTest.java diff --git a/src/main/java/com/devkor/ifive/nadab/domain/auth/application/AuthServiceV2.java b/src/main/java/com/devkor/ifive/nadab/domain/auth/application/AuthServiceV2.java index 15d751d5..a22d2715 100644 --- a/src/main/java/com/devkor/ifive/nadab/domain/auth/application/AuthServiceV2.java +++ b/src/main/java/com/devkor/ifive/nadab/domain/auth/application/AuthServiceV2.java @@ -1,10 +1,8 @@ package com.devkor.ifive.nadab.domain.auth.application; -import com.devkor.ifive.nadab.domain.auth.core.entity.UserWithdrawalReason; import com.devkor.ifive.nadab.domain.auth.core.entity.WithdrawalEvent; import com.devkor.ifive.nadab.domain.auth.core.entity.WithdrawalEventReason; import com.devkor.ifive.nadab.domain.auth.core.entity.WithdrawalReasonType; -import com.devkor.ifive.nadab.domain.auth.core.repository.UserWithdrawalReasonRepository; import com.devkor.ifive.nadab.domain.auth.core.repository.WithdrawalEventReasonRepository; import com.devkor.ifive.nadab.domain.auth.core.repository.WithdrawalEventRepository; import com.devkor.ifive.nadab.domain.user.core.entity.User; @@ -30,7 +28,6 @@ public class AuthServiceV2 { private final WithdrawalService withdrawalService; private final UserRepository userRepository; - private final UserWithdrawalReasonRepository userWithdrawalReasonRepository; private final WithdrawalEventRepository withdrawalEventRepository; private final WithdrawalEventReasonRepository withdrawalEventReasonRepository; @@ -50,19 +47,11 @@ public void withdrawUser(Long userId, List reasons, String WithdrawalEvent event = withdrawalEventRepository.save( WithdrawalEvent.create(user, effectiveWithdrawnAt) ); - List legacyReasons = new ArrayList<>(validatedReasons.size()); List eventReasons = new ArrayList<>(validatedReasons.size()); for (WithdrawalReasonType reason : validatedReasons) { String detail = reason == WithdrawalReasonType.OTHER ? normalizedCustomReason : null; - legacyReasons.add(UserWithdrawalReason.create( - user, - reason, - detail, - effectiveWithdrawnAt - )); eventReasons.add(WithdrawalEventReason.create(event, reason, detail)); } - userWithdrawalReasonRepository.saveAll(legacyReasons); withdrawalEventReasonRepository.saveAll(eventReasons); } diff --git a/src/main/resources/db/migration/V20260906_1400__IS_mark_user_withdrawal_reasons_legacy.sql b/src/main/resources/db/migration/V20260906_1400__IS_mark_user_withdrawal_reasons_legacy.sql new file mode 100644 index 00000000..1ed58a22 --- /dev/null +++ b/src/main/resources/db/migration/V20260906_1400__IS_mark_user_withdrawal_reasons_legacy.sql @@ -0,0 +1,2 @@ +COMMENT ON TABLE user_withdrawal_reasons IS + 'LEGACY: rollback compatibility only. Do not write new records; use withdrawal_events and withdrawal_event_reasons.'; diff --git a/src/test/java/com/devkor/ifive/nadab/domain/auth/application/AuthServiceV2PersistenceTest.java b/src/test/java/com/devkor/ifive/nadab/domain/auth/application/AuthServiceV2PersistenceTest.java index 65bc41ff..8bf53def 100644 --- a/src/test/java/com/devkor/ifive/nadab/domain/auth/application/AuthServiceV2PersistenceTest.java +++ b/src/test/java/com/devkor/ifive/nadab/domain/auth/application/AuthServiceV2PersistenceTest.java @@ -1,6 +1,5 @@ package com.devkor.ifive.nadab.domain.auth.application; -import com.devkor.ifive.nadab.domain.auth.core.entity.UserWithdrawalReason; import com.devkor.ifive.nadab.domain.auth.core.entity.WithdrawalEvent; import com.devkor.ifive.nadab.domain.auth.core.entity.WithdrawalEventReason; import com.devkor.ifive.nadab.domain.auth.core.entity.WithdrawalReasonType; @@ -88,7 +87,7 @@ void tearDown() { } @Test - void withdrawal_commits_user_state_and_both_reason_stores_together() { + void withdrawal_commits_user_state_and_new_reason_store_without_legacy_write() { userId = createUser(); authServiceV2.withdrawUser( @@ -100,7 +99,6 @@ void withdrawal_commits_user_state_and_both_reason_stores_together() { User withdrawnUser = userRepository.findById(userId).orElseThrow(); WithdrawalEvent event = withdrawalEventRepository.findAll().getFirst(); List eventReasons = withdrawalEventReasonRepository.findAll(); - List legacyReasons = userWithdrawalReasonRepository.findAll(); assertThat(withdrawnUser.getDeletedAt()).isNotNull(); assertThat(withdrawnUser.getSignupStatus()).isEqualTo(SignupStatusType.WITHDRAWN); @@ -112,16 +110,11 @@ void withdrawal_commits_user_state_and_both_reason_stores_together() { WithdrawalReasonType.DAILY_LOGGING_BURDEN, WithdrawalReasonType.OTHER ); - assertThat(legacyReasons) - .extracting(UserWithdrawalReason::getReason) - .containsExactlyInAnyOrder( - WithdrawalReasonType.DAILY_LOGGING_BURDEN, - WithdrawalReasonType.OTHER - ); + assertThat(userWithdrawalReasonRepository.count()).isZero(); } @Test - void new_reason_store_failure_rolls_back_withdrawal_and_legacy_reasons() { + void new_reason_store_failure_rolls_back_withdrawal_and_event() { userId = createUser(); doThrow(new IllegalStateException("신규 탈퇴 사유 저장 실패")) .when(withdrawalEventReasonRepository).saveAll(anyList()); diff --git a/src/test/java/com/devkor/ifive/nadab/domain/auth/application/AuthServiceV2Test.java b/src/test/java/com/devkor/ifive/nadab/domain/auth/application/AuthServiceV2Test.java index 42cf9491..4632c4d2 100644 --- a/src/test/java/com/devkor/ifive/nadab/domain/auth/application/AuthServiceV2Test.java +++ b/src/test/java/com/devkor/ifive/nadab/domain/auth/application/AuthServiceV2Test.java @@ -1,10 +1,8 @@ package com.devkor.ifive.nadab.domain.auth.application; -import com.devkor.ifive.nadab.domain.auth.core.entity.UserWithdrawalReason; import com.devkor.ifive.nadab.domain.auth.core.entity.WithdrawalEvent; import com.devkor.ifive.nadab.domain.auth.core.entity.WithdrawalEventReason; import com.devkor.ifive.nadab.domain.auth.core.entity.WithdrawalReasonType; -import com.devkor.ifive.nadab.domain.auth.core.repository.UserWithdrawalReasonRepository; import com.devkor.ifive.nadab.domain.auth.core.repository.WithdrawalEventReasonRepository; import com.devkor.ifive.nadab.domain.auth.core.repository.WithdrawalEventRepository; import com.devkor.ifive.nadab.domain.user.core.entity.User; @@ -39,9 +37,6 @@ class AuthServiceV2Test { @Mock UserRepository userRepository; - @Mock - UserWithdrawalReasonRepository userWithdrawalReasonRepository; - @Mock WithdrawalEventRepository withdrawalEventRepository; @@ -55,7 +50,6 @@ void setUp() { authServiceV2 = new AuthServiceV2( withdrawalService, userRepository, - userWithdrawalReasonRepository, withdrawalEventRepository, withdrawalEventReasonRepository ); @@ -82,36 +76,17 @@ void withdrawUser_saves_selected_reasons_with_effective_withdrawn_at() { ); // then - @SuppressWarnings("unchecked") - ArgumentCaptor> captor = ArgumentCaptor.forClass(List.class); ArgumentCaptor eventCaptor = ArgumentCaptor.forClass(WithdrawalEvent.class); @SuppressWarnings("unchecked") ArgumentCaptor> eventReasonsCaptor = ArgumentCaptor.forClass(List.class); verify(withdrawalService).withdrawUser(userId); - verify(userWithdrawalReasonRepository).saveAll(captor.capture()); verify(withdrawalEventRepository).save(eventCaptor.capture()); verify(withdrawalEventReasonRepository).saveAll(eventReasonsCaptor.capture()); - List savedReasons = captor.getValue(); WithdrawalEvent savedEvent = eventCaptor.getValue(); List savedEventReasons = eventReasonsCaptor.getValue(); OffsetDateTime deletedAt = user.getDeletedAt(); - assertThat(savedReasons).hasSize(2); - assertThat(savedReasons) - .extracting(UserWithdrawalReason::getUser) - .containsOnly(user); - assertThat(savedReasons) - .extracting(UserWithdrawalReason::getWithdrawnAt) - .containsOnly(deletedAt); - assertThat(savedReasons) - .extracting(UserWithdrawalReason::getReason) - .containsExactly( - WithdrawalReasonType.DAILY_LOGGING_BURDEN, - WithdrawalReasonType.OTHER - ); - assertThat(savedReasons.get(0).getCustomReason()).isNull(); - assertThat(savedReasons.get(1).getCustomReason()).isEqualTo("custom reason"); assertThat(savedEvent.getUser()).isEqualTo(user); assertThat(savedEvent.getWithdrawnAt()).isEqualTo(deletedAt); assertThat(savedEvent.getExpiresAt()).isEqualTo(deletedAt.plusYears(1)); @@ -186,7 +161,6 @@ private void assertValidationFailure( verify(withdrawalService, never()).withdrawUser(1L); verify(userRepository, never()).getReferenceById(1L); - verify(userWithdrawalReasonRepository, never()).saveAll(anyList()); verify(withdrawalEventRepository, never()).save(any(WithdrawalEvent.class)); verify(withdrawalEventReasonRepository, never()).saveAll(anyList()); } diff --git a/src/test/java/com/devkor/ifive/nadab/domain/auth/core/repository/WithdrawalReasonLegacyTableMigrationTest.java b/src/test/java/com/devkor/ifive/nadab/domain/auth/core/repository/WithdrawalReasonLegacyTableMigrationTest.java new file mode 100644 index 00000000..6f1979c7 --- /dev/null +++ b/src/test/java/com/devkor/ifive/nadab/domain/auth/core/repository/WithdrawalReasonLegacyTableMigrationTest.java @@ -0,0 +1,32 @@ +package com.devkor.ifive.nadab.domain.auth.core.repository; + +import com.devkor.ifive.nadab.infra.db.PostgresIntegrationTestSupport; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.test.context.ActiveProfiles; + +import static org.assertj.core.api.Assertions.assertThat; + +@DataJpaTest +@ActiveProfiles("test") +class WithdrawalReasonLegacyTableMigrationTest extends PostgresIntegrationTestSupport { + + @Autowired + JdbcTemplate jdbcTemplate; + + @Test + void marks_legacy_table_as_rollback_only_without_removing_it() { + String tableComment = jdbcTemplate.queryForObject( + "SELECT obj_description('user_withdrawal_reasons'::regclass)", + String.class + ); + + assertThat(tableComment) + .contains("LEGACY") + .contains("rollback compatibility only") + .contains("withdrawal_events") + .contains("withdrawal_event_reasons"); + } +}