Repository navigation
[feat] 정책 변경 이메일 고지 시스템 구현 #121
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
fc6ce63
53ac45f
22785c2
211551f
3c542e0
f88ab4e
ce9ce77
6e11f2a
3850776
9a482e6
2c3aff1
a3cf08c
623a633
70c3843
d5a1245
0831dd9
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,54 @@ | ||
| package com.Coming.Backend.policy.batch; | ||
|
|
||
| import com.Coming.Backend.policy.entity.PolicyNotificationTarget; | ||
| import lombok.RequiredArgsConstructor; | ||
| import org.springframework.batch.core.job.Job; | ||
| import org.springframework.batch.core.job.builder.JobBuilder; | ||
| import org.springframework.batch.core.job.parameters.RunIdIncrementer; | ||
| import org.springframework.batch.core.repository.JobRepository; | ||
| import org.springframework.batch.core.step.Step; | ||
| import org.springframework.batch.core.step.builder.StepBuilder; | ||
| import org.springframework.context.annotation.Bean; | ||
| import org.springframework.context.annotation.Configuration; | ||
| import org.springframework.transaction.PlatformTransactionManager; | ||
|
|
||
| @Configuration | ||
| @RequiredArgsConstructor | ||
| public class PolicyNotificationBatchConfig { | ||
|
|
||
| private static final int CHUNK_SIZE = 20; | ||
|
|
||
| private final JobRepository jobRepository; | ||
| private final PlatformTransactionManager transactionManager; | ||
| private final PolicyNotificationTargetCreationTasklet policyNotificationTargetCreationTasklet; | ||
| private final PolicyNotificationPendingTargetReader policyNotificationPendingTargetReader; | ||
| private final PolicyNotificationMailProcessor policyNotificationMailProcessor; | ||
| private final PolicyNotificationTargetWriter policyNotificationTargetWriter; | ||
|
|
||
| @Bean | ||
| public Job policyNotificationJob() { | ||
| return new JobBuilder("policyNotificationJob", jobRepository) | ||
| .incrementer(new RunIdIncrementer()) | ||
| .start(targetCreationStep()) | ||
| .next(mailSendStep()) | ||
| .build(); | ||
| } | ||
|
|
||
| @Bean | ||
| public Step targetCreationStep() { | ||
| return new StepBuilder("targetCreationStep", jobRepository) | ||
| .tasklet(policyNotificationTargetCreationTasklet, transactionManager) | ||
| .build(); | ||
| } | ||
|
|
||
| @Bean | ||
| public Step mailSendStep() { | ||
| return new StepBuilder("mailSendStep", jobRepository) | ||
| .<PolicyNotificationTarget, PolicyNotificationTarget>chunk(CHUNK_SIZE) | ||
| .reader(policyNotificationPendingTargetReader) | ||
| .processor(policyNotificationMailProcessor) | ||
| .writer(policyNotificationTargetWriter) | ||
| .transactionManager(transactionManager) | ||
| .build(); | ||
|
Comment on lines
+41
to
+52
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy lift 🔎 Supported by static analysis🏁 Script executed: sed -n '35,80p' src/main/java/com/Coming/Backend/policy/batch/PolicyNotificationBatchConfig.java
sed -n '1,80p' src/main/java/com/Coming/Backend/policy/batch/PolicyNotificationMailProcessor.java
sed -n '1,70p' src/main/java/com/Coming/Backend/policy/batch/PolicyNotificationSender.java
sed -n '1,50p' src/main/java/com/Coming/Backend/policy/batch/PolicyNotificationTargetWriter.javaRepository: Cominggg/Backend Length of output: 5636 🏁 Script executed: #!/bin/bash
set -eu
for f in $(fd -i 'PolicyNotificationJobTrigger.java|PolicyNotificationRetryScheduler.java|PolicyNotificationTarget.java|PolicyNotificationTargetRepository.java' src/main/java); do
echo "### $f"
sed -n '1,180p' "$f"
done
echo '### batch config header'
sed -n '1,45p' src/main/java/com/Coming/Backend/policy/batch/PolicyNotificationBatchConfig.java
for f in pom.xml build.gradle build.gradle.kts; do
if [ -f "$f" ]; then
echo "### $f"
rg -n -C 2 'spring-boot|spring-batch|spring-framework|transaction' "$f" || true
fi
done
echo '### relevant tests'
for f in $(fd -i 'PolicyNotification.*Test.java' src/test/java); do
echo "### $f"
rg -n -C 2 'runJob|onPolicyRegistered|sendAndMark|mailSendStep|PENDING|SENT|FAILED' "$f" || true
doneRepository: Cominggg/Backend Length of output: 16420 🏁 Script executed: #!/bin/bash
set -eu
echo '### PolicyRegisteredEvent declarations/usages'
rg -n -C 4 'PolicyRegisteredEvent|publishEvent' src/main/java
echo '### build version'
sed -n '1,25p' build.gradleRepository: Cominggg/Backend Length of output: 6112 Separate initial SMTP delivery from the chunk transaction.
If a later processor call, Reserve targets in a short database transaction, send mail outside that transaction, and record 🤖 Prompt for AI Agents |
||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,51 @@ | ||
| package com.Coming.Backend.policy.batch; | ||
|
|
||
| import com.Coming.Backend.policy.event.PolicyRegisteredEvent; | ||
| import lombok.RequiredArgsConstructor; | ||
| import lombok.extern.slf4j.Slf4j; | ||
| import org.springframework.batch.core.job.Job; | ||
| import org.springframework.batch.core.job.parameters.JobParameters; | ||
| import org.springframework.batch.core.job.parameters.JobParametersBuilder; | ||
| import org.springframework.batch.core.launch.JobOperator; | ||
| import org.springframework.stereotype.Component; | ||
| import org.springframework.transaction.event.TransactionPhase; | ||
| import org.springframework.transaction.event.TransactionalEventListener; | ||
|
|
||
| /** | ||
| * 정책 등록 트랜잭션이 커밋된 후, 해당 정책에 대한 알림 대상 생성·메일 발송 배치를 실행한다. | ||
| * AFTER_COMMIT 콜백과 같은 스레드에서 배치(자체 트랜잭션 포함)를 직접 실행하면 커밋 중이던 | ||
| * 트랜잭션 동기화 상태와 충돌해 JobInterruptedException이 발생하므로, 별도 스레드에서 실행하고 | ||
| * join으로 대기해 호출자 입장에서는 동기로 완료를 기다리게 한다. | ||
| */ | ||
| @Slf4j | ||
| @Component | ||
| @RequiredArgsConstructor | ||
| public class PolicyNotificationJobTrigger { | ||
|
|
||
| private final JobOperator jobOperator; | ||
| private final Job policyNotificationJob; | ||
|
|
||
| @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) | ||
| public void onPolicyRegistered(PolicyRegisteredEvent event) { | ||
| Thread jobThread = Thread.ofVirtual().start(() -> runJob(event.policyId())); | ||
| try { | ||
| jobThread.join(); | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🩺 Stability & Availability | 🟠 Major | ⚡ Quick win Do not wait for batch completion in the event listener. Line 32 blocks the policy-registration caller until target creation and all email sends complete. A large recipient set or slow SMTP server can keep the registration operation blocked long enough to time out. Keep 🤖 Prompt for AI AgentsSource: Learnings |
||
| } catch (InterruptedException e) { | ||
| Thread.currentThread().interrupt(); | ||
| log.error("정책 알림 배치 대기 중 인터럽트 발생 — policyId: {}", event.policyId(), e); | ||
| } | ||
| } | ||
|
|
||
| private void runJob(Long policyId) { | ||
| try { | ||
| JobParameters jobParameters = policyNotificationJob.getJobParametersIncrementer().getNext( | ||
| new JobParametersBuilder() | ||
| .addLong("policyId", policyId) | ||
|
Comment on lines
+34
to
+43
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🩺 Stability & Availability | 🟠 Major | 🏗️ Heavy lift 🔎 Supported by static analysis🏁 Script executed: sed -n '1,100p' src/main/java/com/Coming/Backend/policy/batch/PolicyNotificationJobTrigger.java
rg -n 'PolicyRegisteredEvent|policyNotificationJob|JobOperator|targetCreationStep|runJob|restart|retry' src/main/java src/test/javaRepository: Cominggg/Backend Length of output: 10375 🏁 Script executed: printf '%s\n' '--- PolicyService ---'
sed -n '1,120p' src/main/java/com/Coming/Backend/policy/service/PolicyService.java
printf '%s\n' '--- Batch configuration ---'
sed -n '1,180p' src/main/java/com/Coming/Backend/policy/batch/PolicyNotificationBatchConfig.java
printf '%s\n' '--- Target creation tasklet ---'
sed -n '1,140p' src/main/java/com/Coming/Backend/policy/batch/PolicyNotificationTargetCreationTasklet.java
printf '%s\n' '--- Retry scheduler ---'
sed -n '1,140p' src/main/java/com/Coming/Backend/policy/batch/PolicyNotificationRetryScheduler.java
printf '%s\n' '--- Target repository ---'
sed -n '1,100p' src/main/java/com/Coming/Backend/policy/repository/PolicyNotificationTargetRepository.java
printf '%s\n' '--- Policy event publication and trigger references ---'
rg -n -C 4 'publishEvent|PolicyRegisteredEvent|onPolicyRegistered|PolicyNotificationJobTrigger' src/main/java src/test/javaRepository: Cominggg/Backend Length of output: 32735 🏁 Script executed: printf '%s\n' '--- Target creation and batch launch references ---'
rg -n -C 3 'PolicyNotificationTarget\.builder|PolicyNotificationTargetCreationTasklet|policyNotificationJob\(|JobLauncher|JobOperator|`@Scheduled`|EnableBatch|spring\.batch|job\.enabled|targetCreationStep' src/main/java src/main/resources src/test/java pom.xml build.gradle settings.gradle 2>/dev/nullRepository: Cominggg/Backend Length of output: 16960 🏁 Script executed: printf '%s\n' '--- Main-source batch launchers and target creation ---'
rg -n -C 2 'PolicyNotificationTargetCreationTasklet|PolicyNotificationTarget\.builder|JobLauncher|JobOperator|policyNotificationJob|targetCreationStep|`@EnableBatchProcessing`|`@EnableScheduling`|`@Scheduled`' src/main/java
printf '%s\n' '--- Batch-related configuration ---'
rg -n -C 3 'spring\.batch|batch:|job\.enabled|EnableScheduling|scheduling' src/main/resources/application.yaml src/main/resources 2>/dev/nullRepository: Cominggg/Backend Length of output: 9163 Make policy notification launch durable. Persist a notification launch/outbox record in the policy transaction and retry the job launch from a durable worker until target creation succeeds. 🤖 Prompt for AI Agents |
||
| .toJobParameters() | ||
| ); | ||
| jobOperator.run(policyNotificationJob, jobParameters); | ||
| } catch (Exception e) { | ||
| log.error("정책 알림 배치 실행 실패 — policyId: {}", policyId, e); | ||
| } | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,45 @@ | ||
| package com.Coming.Backend.policy.batch; | ||
|
|
||
| import com.Coming.Backend.auth.entity.User; | ||
| import com.Coming.Backend.auth.repository.UserRepository; | ||
| import com.Coming.Backend.policy.entity.PolicyDocument; | ||
| import com.Coming.Backend.policy.entity.PolicyNotificationTarget; | ||
| import com.Coming.Backend.policy.exception.PolicyNotFoundException; | ||
| import com.Coming.Backend.policy.repository.PolicyDocumentRepository; | ||
| import lombok.RequiredArgsConstructor; | ||
| import org.springframework.batch.core.configuration.annotation.StepScope; | ||
| import org.springframework.batch.infrastructure.item.ItemProcessor; | ||
| import org.springframework.beans.factory.annotation.Value; | ||
| import org.springframework.stereotype.Component; | ||
|
|
||
| /** | ||
| * PENDING 상태의 PolicyNotificationTarget에 대해 정책 변경 고지 메일을 발송하고 상태(SENT/FAILED)를 갱신한다. | ||
| */ | ||
| @Component | ||
| @StepScope | ||
| @RequiredArgsConstructor | ||
| public class PolicyNotificationMailProcessor implements ItemProcessor<PolicyNotificationTarget, PolicyNotificationTarget> { | ||
|
|
||
| private final UserRepository userRepository; | ||
| private final PolicyDocumentRepository policyDocumentRepository; | ||
| private final PolicyNotificationSender policyNotificationSender; | ||
|
|
||
| @Value("#{jobParameters['policyId']}") | ||
| private Long policyId; | ||
|
|
||
| private PolicyDocument policyDocument; | ||
|
|
||
| @Override | ||
| public PolicyNotificationTarget process(PolicyNotificationTarget target) { | ||
| User user = userRepository.findById(target.getUserId()).orElse(null); | ||
| policyNotificationSender.sendAndMark(target, user, resolvePolicyDocument()); | ||
| return target; | ||
| } | ||
|
|
||
| private PolicyDocument resolvePolicyDocument() { | ||
| if (policyDocument == null) { | ||
| policyDocument = policyDocumentRepository.findById(policyId).orElseThrow(PolicyNotFoundException::new); | ||
| } | ||
| return policyDocument; | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,49 @@ | ||
| package com.Coming.Backend.policy.batch; | ||
|
|
||
| import com.Coming.Backend.policy.entity.NotificationStatus; | ||
| import com.Coming.Backend.policy.entity.PolicyNotificationTarget; | ||
| import com.Coming.Backend.policy.repository.PolicyNotificationTargetRepository; | ||
| import java.util.Iterator; | ||
| import java.util.List; | ||
| import lombok.RequiredArgsConstructor; | ||
| import org.springframework.batch.core.configuration.annotation.StepScope; | ||
| import org.springframework.batch.infrastructure.item.ItemReader; | ||
| import org.springframework.beans.factory.annotation.Value; | ||
| import org.springframework.data.domain.PageRequest; | ||
| import org.springframework.stereotype.Component; | ||
|
|
||
| /** | ||
| * PENDING 상태의 정책 알림 대상을 id 오름차순 커서 기반으로 읽는다. | ||
| * offset 페이징을 사용하면 처리된 항목이 PENDING에서 벗어나며 결과 집합이 줄어들어 | ||
| * 다음 페이지 조회 시 대상을 건너뛰는 문제가 있어, 마지막으로 읽은 id를 커서로 다음 배치를 조회한다. | ||
| */ | ||
| @Component | ||
| @StepScope | ||
| @RequiredArgsConstructor | ||
| public class PolicyNotificationPendingTargetReader implements ItemReader<PolicyNotificationTarget> { | ||
|
|
||
| private static final int PAGE_SIZE = 20; | ||
|
|
||
| private final PolicyNotificationTargetRepository policyNotificationTargetRepository; | ||
|
|
||
| @Value("#{jobParameters['policyId']}") | ||
| private Long policyId; | ||
|
|
||
| private Long lastId = 0L; | ||
| private Iterator<PolicyNotificationTarget> currentBatch = List.<PolicyNotificationTarget>of().iterator(); | ||
|
|
||
| @Override | ||
| public PolicyNotificationTarget read() { | ||
| if (!currentBatch.hasNext()) { | ||
| List<PolicyNotificationTarget> batch = policyNotificationTargetRepository | ||
| .findByPolicyIdAndStatusAndIdGreaterThanOrderByIdAsc( | ||
| policyId, NotificationStatus.PENDING, lastId, PageRequest.of(0, PAGE_SIZE)); | ||
| if (batch.isEmpty()) { | ||
| return null; | ||
| } | ||
| lastId = batch.get(batch.size() - 1).getId(); | ||
| currentBatch = batch.iterator(); | ||
| } | ||
| return currentBatch.next(); | ||
| } | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🚀 Performance & Scalability | 🟠 Major | 🏗️ Heavy lift
활성 사용자 조회를 페이지 또는 키셋 조회로 변경하세요.
findByStatus는 모든 활성 사용자를 한 번에 메모리로 로드합니다. 정책 등록 후 대상 생성 작업이 이 메서드를 호출하므로, 사용자 수가 증가하면 쿼리 결과와 대상 생성 작업의 메모리 사용량이 제한 없이 증가합니다.Page또는 키셋 기반 조회를 반환하고, 대상 생성 작업이 페이지 단위로 저장하도록 변경하세요.🤖 Prompt for AI Agents