diff --git a/topics/06-event/members/minseo/.gitkeep b/topics/06-event/members/minseo/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/topics/06-event/members/minseo/build.gradle b/topics/06-event/members/minseo/build.gradle new file mode 100644 index 0000000..4879cfd --- /dev/null +++ b/topics/06-event/members/minseo/build.gradle @@ -0,0 +1,49 @@ +// CS 스터디 — Spring Boot 학습 템플릿 +// 본인 폴더에 복사해서 사용. 의존성은 주차/필요에 맞춰 추가하면 된다. + +plugins { + id 'java' + id 'org.springframework.boot' version '3.4.1' + id 'io.spring.dependency-management' version '1.1.6' +} + +group = 'com.example' +version = '0.0.1-SNAPSHOT' + +java { + toolchain { + languageVersion = JavaLanguageVersion.of(21) + } +} + +repositories { + mavenCentral() +} + +dependencies { + // 기본 (Phase 2부터) + implementation 'org.springframework.boot:spring-boot-starter-web' + implementation 'org.springframework.boot:spring-boot-starter-data-jpa' + implementation 'org.springframework.boot:spring-boot-starter-data-redis' + runtimeOnly 'org.postgresql:postgresql' + + // 인증 (Phase 4 11주차에 주석 해제) + // implementation 'org.springframework.boot:spring-boot-starter-security' + + // 관측성 (Phase 4 12주차에 주석 해제) + // implementation 'org.springframework.boot:spring-boot-starter-actuator' + // implementation 'io.micrometer:micrometer-registry-prometheus' + + // 유틸 + compileOnly 'org.projectlombok:lombok' + annotationProcessor 'org.projectlombok:lombok' + developmentOnly 'org.springframework.boot:spring-boot-devtools' + + // 테스트 + testImplementation 'org.springframework.boot:spring-boot-starter-test' + testRuntimeOnly 'org.junit.platform:junit-platform-launcher' +} + +tasks.named('test') { + useJUnitPlatform() +} diff --git a/topics/06-event/members/minseo/gradle/wrapper/gradle-wrapper.jar b/topics/06-event/members/minseo/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000..d64cd49 Binary files /dev/null and b/topics/06-event/members/minseo/gradle/wrapper/gradle-wrapper.jar differ diff --git a/topics/06-event/members/minseo/gradle/wrapper/gradle-wrapper.properties b/topics/06-event/members/minseo/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..1af9e09 --- /dev/null +++ b/topics/06-event/members/minseo/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,7 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-8.5-bin.zip +networkTimeout=10000 +validateDistributionUrl=true +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/topics/06-event/members/minseo/measurements.md b/topics/06-event/members/minseo/measurements.md new file mode 100644 index 0000000..d8a9fc0 --- /dev/null +++ b/topics/06-event/members/minseo/measurements.md @@ -0,0 +1,60 @@ +# 측정 및 관찰 기록 + +- [06-08 04:26] STAGE 1-1 — publishEvent + @EventListener + - **관찰 결과**: `[publisher] 시작 -> [listener] 실행 -> [publisher] 끝` 순서로 호출됨. + - **결론**: `publishEvent`는 기본적으로 **동기(Synchronous)** 방식이며, 발행자와 리스너가 **동일한 스레드**(`restartedMain`)에서 실행됨을 확인. + +- [06-08 04:41] STAGE 1-2 — 리스너 3 개 + @Order + - **관찰 결과**: L1(order=1) -> L2(order=2) -> L3(order=3) 순서로 리스너가 실행된 후 발행자가 종료됨. + - **결론**: 한 이벤트에 여러 리스너가 붙을 수 있으며, `@Order`를 통해 실행 순서를 명시적으로 제어할 수 있음(숫자가 작을수록 우선순위 높음). + +- [06-08 04:55] STAGE 1-3 — 리스너 예외 전파 확인 + - **관찰 결과**: L1 실행 -> L2에서 예외 발생 -> **L3 실행 안 됨** -> 발행자가 예외를 Catch 함. + - **결론**: 동기(Synchronous) 리스너의 경우, 중간 리스너에서 예외가 발생하면 후속 리스너는 호출되지 않으며 예외가 발행자에게 전파됨. + +- [06-08 05:10] STAGE 1-4 — ApplicationListener 인터페이스 vs @EventListener + - **관찰 결과**: 두 가지 방식(인터페이스 구현, 어노테이션 사용) 리스너가 모두 정상 호출됨. + - **결론**: 스프링 이벤트는 과거 방식(`ApplicationListener` 인터페이스)과 현대 방식(`@EventListener`)을 모두 지원하며, 내부적으로는 동일한 메커니즘으로 동작함. 유연성이 높은 `@EventListener`가 권장됨. + +- [06-08 05:25] STAGE 1-5 — payload-only 이벤트 (Spring 4.2+) + - **관찰 결과**: `record` 뿐만 아니라 `String`과 같은 일반 객체(POJO)도 이벤트를 발행하고 구독할 수 있음. + - **결론**: `ApplicationEvent`를 상속받지 않아도 어떤 객체든 이벤트로 사용 가능함. 내부적으로는 `PayloadApplicationEvent`로 감싸져 처리됨. 도메인 의미를 명확히 하기 위해 전용 `record` 사용이 권장됨. + + + +- [06-08 04:49] STAGE 1-5 — payload-only 이벤트 (Spring 4.2+) +- [06-10 21:21] STAGE 2-1 — 트랜잭션 롤백 함정 시연 + - 최종 재고 (100에서 10 차감 시도 후 롤백): 100 +- [06-10 21:22] STAGE 2-2 — @TransactionalEventListener(AFTER_COMMIT) + - **(1) 정상 커밋 시** + - 재고 확인: 90 + - **(2) 롤백 시** + - 재고 확인 (롤백되어 90 유지되어야 함): 90 +- [06-10 21:32] STAGE 2-1 — AFTER_COMMIT (재고 도메인) + - **정상 커밋 상황** + - 현재 재고: 80 + - **롤백 상황 (알림 로그가 찍히지 않아야 함)** + - 현재 재고 (롤백되어 90 유지): 80 +- [06-10 21:35] STAGE 2-2 — 4 Phase 관찰 (재고 도메인) + - **정상 커밋 (amount=10)** + - **롤백 상황 (amount=0)** +- [06-10 22:29] STAGE 2-3 — fallbackExecution (트랜잭션 밖에서 발행) +- [06-10 23:19] STAGE 2-4 — AFTER_COMMIT 리스너에서의 DB 쓰기 함정 + - **decrement(1, 10) 실행** + - 최종 히스토리 카운트: 2 +- [06-11 00:25] STAGE 3-1 — 동기 리스너의 블록킹 현상 (재고 도메인) +- [06-11 00:38] STAGE 3-2 — @Async 비동기 리스너 도입 (재고 도메인) +- [06-11 00:45] STAGE 3-3 — @Async Self-Invocation 함정 (재고 도메인) + - **(1) 클래스 내부 @Async 호출 (this)** + - **(2) 주입된 다른 빈의 @Async 호출** + - **(3) publishEvent -> @Async @EventListener** +- [06-11 00:54] STAGE 3-4 — 비동기 void 예외 핸들링 (재고 도메인) +- [06-11 01:05] STAGE 3-5 — Virtual Thread 활용 (재고 도메인) +- [06-11 01:21] STAGE 4-1 — AOP Audit vs Event Audit + - **(1) Old AOP — 정상 케이스** + - **(2) Old AOP — 롤백 케이스 (AOP 로그는 남음)** + - **(3) New Event — 정상 케이스** + - **(4) New Event — 롤백 케이스 (이벤트 로그 안 남음)** +- [06-11 01:38] STAGE 4-2 — AOP + Event 혼합 전략 (재고 도메인) + - **Case 1: 정상 처리 (amount=10)** + - **Case 2: 롤백 처리 (amount=0)** diff --git a/topics/06-event/members/minseo/src/main/java/infra/MeasurementLog.java b/topics/06-event/members/minseo/src/main/java/infra/MeasurementLog.java new file mode 100644 index 0000000..1d86d87 --- /dev/null +++ b/topics/06-event/members/minseo/src/main/java/infra/MeasurementLog.java @@ -0,0 +1,79 @@ +package infra; + +import java.io.IOException; +import java.net.URL; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardOpenOption; +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; + +/** 측정 결과 println 헬퍼 + 자동 파일 저장 기능 추가 */ +public class MeasurementLog { + + private static final Path FILE = resolveFile(); + private static final DateTimeFormatter TIME = DateTimeFormatter.ofPattern("MM-dd HH:mm"); + + public static void title(String s) { + System.out.println(); + System.out.println("=== " + s + " ==="); + append("- [" + LocalDateTime.now().format(TIME) + "] " + s + "\n"); + } + + public static void row(String label, Object value) { + String line = String.format(" %-40s %s", label, value); + System.out.println(line); + append(" - " + label + ": " + value + "\n"); + } + + public static void section(String s) { + System.out.println(); + System.out.println("--- " + s + " ---"); + append(" - **" + s + "**\n"); + } + + public static void save(String stage, String note) { + String line = String.format("- [%s] %s · %s%n", + LocalDateTime.now().format(TIME), stage, note); + System.out.print(line); + append(line); + } + + private static void append(String content) { + try { + if (!Files.exists(FILE)) { + Files.writeString(FILE, "# 측정 및 관찰 기록\n\n", StandardOpenOption.CREATE); + } + Files.writeString(FILE, content, StandardOpenOption.APPEND); + } catch (IOException e) { + System.err.println("⚠️ measurements.md 기록 실패: " + e.getMessage()); + } + } + + public static String thread() { + return Thread.currentThread().getName(); + } + + private static Path resolveFile() { + try { + var domain = MeasurementLog.class.getProtectionDomain(); + if (domain == null) return Path.of("measurements.md"); + var codeSource = domain.getCodeSource(); + if (codeSource == null) return Path.of("measurements.md"); + URL location = codeSource.getLocation(); + if (location == null) return Path.of("measurements.md"); + + Path start = Path.of(location.toURI()); + if (Files.isRegularFile(start)) start = start.getParent(); + + Path current = start; + for (int i = 0; i < 10 && current != null; i++) { + if (Files.exists(current.resolve("build.gradle"))) { + return current.resolve("measurements.md"); + } + current = current.getParent(); + } + } catch (Exception ignored) {} + return Path.of("measurements.md"); + } +} diff --git a/topics/06-event/members/minseo/src/main/java/stage/s1/Stage1_1_HelloEvent.java b/topics/06-event/members/minseo/src/main/java/stage/s1/Stage1_1_HelloEvent.java new file mode 100644 index 0000000..f98a7bf --- /dev/null +++ b/topics/06-event/members/minseo/src/main/java/stage/s1/Stage1_1_HelloEvent.java @@ -0,0 +1,56 @@ +package stage.s1; + +import infra.MeasurementLog; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.EnableAutoConfiguration; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.context.ConfigurableApplicationContext; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.event.EventListener; + +@Configuration +@EnableAutoConfiguration +public class Stage1_1_HelloEvent { + + public record HelloEvent(String message) {} + + @Bean + public HelloService helloService(ApplicationEventPublisher publisher) { + return new HelloService(publisher); + } + + @Bean + public HelloListener helloListener() { + return new HelloListener(); + } + + public static class HelloService { + private final ApplicationEventPublisher publisher; + public HelloService(ApplicationEventPublisher publisher) { + this.publisher = publisher; + } + public void sayHello(String name) { + System.out.println("[publisher] sayHello — name=" + name + + " thread=" + MeasurementLog.thread()); + publisher.publishEvent(new HelloEvent("hello " + name)); + System.out.println("[publisher] return — thread=" + MeasurementLog.thread()); + } + } + + public static class HelloListener { + @EventListener + public void onHello(HelloEvent event) { + System.out.println("[listener] received — message=" + event.message() + + " thread=" + MeasurementLog.thread()); + } + } + + public static void main(String[] args) { + ConfigurableApplicationContext ctx = SpringApplication.run(Stage1_1_HelloEvent.class, args); + MeasurementLog.title("STAGE 1-1 — publishEvent + @EventListener"); + ctx.getBean(HelloService.class).sayHello("world"); + ctx.close(); + } +} diff --git a/topics/06-event/members/minseo/src/main/java/stage/s1/Stage1_2_MultipleListeners.java b/topics/06-event/members/minseo/src/main/java/stage/s1/Stage1_2_MultipleListeners.java new file mode 100644 index 0000000..c367b9f --- /dev/null +++ b/topics/06-event/members/minseo/src/main/java/stage/s1/Stage1_2_MultipleListeners.java @@ -0,0 +1,75 @@ +package stage.s1; + +import infra.MeasurementLog; +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.EnableAutoConfiguration; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.context.ConfigurableApplicationContext; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.event.EventListener; +import org.springframework.core.annotation.Order; + +@Configuration +@EnableAutoConfiguration +public class Stage1_2_MultipleListeners { + + public record GreetEvent(String name) {} + + @Bean + public GreetService greetService(ApplicationEventPublisher publisher) { + return new GreetService(publisher); + } + + @Bean public Listener1 l1() { return new Listener1(); } + @Bean public Listener2 l2() { return new Listener2(); } + @Bean public Listener3 l3() { return new Listener3(); } + + public static class GreetService { + private final ApplicationEventPublisher publisher; + public GreetService(ApplicationEventPublisher publisher) { + this.publisher = publisher; + } + public void greet(String name) { + System.out.println("[publisher] greet — " + name); + publisher.publishEvent(new GreetEvent(name)); + System.out.println("[publisher] return"); + } + } + + public static class Listener1 { + @EventListener + @Order(1) + public void on(GreetEvent e) { + System.out.println(" [L1 order=1] " + e.name()); + } + } + + public static class Listener2 { + @EventListener + @Order(2) + public void on(GreetEvent e) { + System.out.println(" [L2 order=2] " + e.name()); + } + } + + public static class Listener3 { + @EventListener + @Order(3) + public void on(GreetEvent e) { + System.out.println(" [L3 order=3] " + e.name()); + } + } + + public static void main(String[] args) { + ConfigurableApplicationContext ctx = SpringApplication.run(Stage1_2_MultipleListeners.class, args); + MeasurementLog.title("STAGE 1-2 — 리스너 3 개 + @Order"); + ctx.getBean(GreetService.class).greet("world"); + + System.out.println(); + System.out.println("[학습 포인트]"); + System.out.println(" · @Order 숫자 작은 게 먼저 (1 → 2 → 3)"); + System.out.println(" · @Order 없으면 임의 순서 — 면접 / 실무에서는 명시 권장"); + ctx.close(); + } +} diff --git a/topics/06-event/members/minseo/src/main/java/stage/s1/Stage1_3_ListenerException.java b/topics/06-event/members/minseo/src/main/java/stage/s1/Stage1_3_ListenerException.java new file mode 100644 index 0000000..8df2e11 --- /dev/null +++ b/topics/06-event/members/minseo/src/main/java/stage/s1/Stage1_3_ListenerException.java @@ -0,0 +1,75 @@ +package stage.s1; + +import infra.MeasurementLog; +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.EnableAutoConfiguration; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.context.ConfigurableApplicationContext; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.event.EventListener; +import org.springframework.core.annotation.Order; + +@Configuration +@EnableAutoConfiguration +public class Stage1_3_ListenerException { + + public record Event(String payload) {} + + @Bean + public Publisher publisher(ApplicationEventPublisher p) { + return new Publisher(p); + } + + @Bean public L1 l1() { return new L1(); } + @Bean public L2Failing l2() { return new L2Failing(); } + @Bean public L3 l3() { return new L3(); } + + public static class Publisher { + private final ApplicationEventPublisher publisher; + public Publisher(ApplicationEventPublisher publisher) { this.publisher = publisher; } + public void publish(String s) { + System.out.println("[publisher] publish — " + s); + try { + publisher.publishEvent(new Event(s)); + System.out.println("[publisher] return (정상)"); + } catch (RuntimeException ex) { + System.out.println("[publisher] caught — " + ex.getMessage()); + } + } + } + + public static class L1 { + @EventListener + @Order(1) + public void on(Event e) { System.out.println(" [L1] " + e.payload()); } + } + + public static class L2Failing { + @EventListener + @Order(2) + public void on(Event e) { + System.out.println(" [L2] received — throwing"); + throw new RuntimeException("일부러 실패"); + } + } + + public static class L3 { + @EventListener + @Order(3) + public void on(Event e) { System.out.println(" [L3] " + e.payload()); } + } + + public static void main(String[] args) { + ConfigurableApplicationContext ctx = SpringApplication.run(Stage1_3_ListenerException.class, args); + MeasurementLog.title("STAGE 1-3 — 리스너 예외 — 다음 리스너 호출 안 됨"); + ctx.getBean(Publisher.class).publish("hello"); + + System.out.println(); + System.out.println("[학습 포인트]"); + System.out.println(" · 동기 + 트랜잭션 무관 — L2 예외로 L3 호출 안 됨 + publisher 에 전파"); + System.out.println(" · @TransactionalEventListener — 각 phase 별로 다른 격리"); + System.out.println(" · @Async — 예외가 publisher 에 전파 안 됨 (AsyncUncaughtExceptionHandler 자리)"); + ctx.close(); + } +} diff --git a/topics/06-event/members/minseo/src/main/java/stage/s1/Stage1_4_OldStyleListener.java b/topics/06-event/members/minseo/src/main/java/stage/s1/Stage1_4_OldStyleListener.java new file mode 100644 index 0000000..8a1d9a5 --- /dev/null +++ b/topics/06-event/members/minseo/src/main/java/stage/s1/Stage1_4_OldStyleListener.java @@ -0,0 +1,73 @@ +package stage.s1; + +import infra.MeasurementLog; +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.EnableAutoConfiguration; +import org.springframework.context.ApplicationEvent; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.context.ApplicationListener; +import org.springframework.context.ConfigurableApplicationContext; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.event.EventListener; + +@Configuration +@EnableAutoConfiguration +public class Stage1_4_OldStyleListener { + + /** 옛 방식 호환용 — ApplicationEvent 상속. */ + public static class HelloEvent extends ApplicationEvent { + private final String message; + public HelloEvent(Object source, String message) { + super(source); + this.message = message; + } + public String message() { return message; } + } + + @Bean + public HelloService helloService(ApplicationEventPublisher publisher) { + return new HelloService(publisher); + } + + @Bean public NewStyleListener newStyle() { return new NewStyleListener(); } + @Bean public OldStyleListener oldStyle() { return new OldStyleListener(); } + + public static class HelloService { + private final ApplicationEventPublisher publisher; + public HelloService(ApplicationEventPublisher publisher) { + this.publisher = publisher; + } + public void sayHello(String name) { + publisher.publishEvent(new HelloEvent(this, "hello " + name)); + } + } + + /** 새 방식 — @EventListener 메서드 */ + public static class NewStyleListener { + @EventListener + public void onHello(HelloEvent event) { + System.out.println(" [@EventListener] " + event.message()); + } + } + + /** 옛 방식 — ApplicationListener 인터페이스 직접 구현 (E extends ApplicationEvent 제약) */ + public static class OldStyleListener implements ApplicationListener { + @Override + public void onApplicationEvent(HelloEvent event) { + System.out.println(" [ApplicationListener] " + event.message()); + } + } + + public static void main(String[] args) { + ConfigurableApplicationContext ctx = SpringApplication.run(Stage1_4_OldStyleListener.class, args); + MeasurementLog.title("STAGE 1-4 — ApplicationListener 인터페이스 vs @EventListener"); + ctx.getBean(HelloService.class).sayHello("world"); + + System.out.println(); + System.out.println("[학습 포인트]"); + System.out.println(" · 둘 다 호출됨 — 내부적으로 같은 ApplicationListener 메커니즘"); + System.out.println(" · @EventListener 가 권장 (여러 이벤트 / condition / @Order 메서드별)"); + ctx.close(); + } +} diff --git a/topics/06-event/members/minseo/src/main/java/stage/s1/Stage1_5_PayloadOnly.java b/topics/06-event/members/minseo/src/main/java/stage/s1/Stage1_5_PayloadOnly.java new file mode 100644 index 0000000..1da9481 --- /dev/null +++ b/topics/06-event/members/minseo/src/main/java/stage/s1/Stage1_5_PayloadOnly.java @@ -0,0 +1,65 @@ +package stage.s1; + +import infra.MeasurementLog; +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.EnableAutoConfiguration; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.context.ConfigurableApplicationContext; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.event.EventListener; + +@Configuration +@EnableAutoConfiguration +public class Stage1_5_PayloadOnly { + + public record OrderPlacedEvent(Long orderId, String name) {} + + @Bean + public Publisher publisher(ApplicationEventPublisher p) { + return new Publisher(p); + } + + @Bean public RecordListener recordListener() { return new RecordListener(); } + @Bean public StringListener stringListener() { return new StringListener(); } + + public static class Publisher { + private final ApplicationEventPublisher publisher; + public Publisher(ApplicationEventPublisher publisher) { this.publisher = publisher; } + + public void publishAll() { + System.out.println("[publisher] record 발행"); + publisher.publishEvent(new OrderPlacedEvent(1L, "라면")); + + System.out.println("[publisher] String 발행 — payload-only"); + publisher.publishEvent("그냥 문자열"); + } + } + + public static class RecordListener { + @EventListener + public void on(OrderPlacedEvent e) { + System.out.println(" [record] " + e.orderId() + " / " + e.name()); + } + } + + public static class StringListener { + @EventListener + public void on(String message) { + System.out.println(" [String] " + message); + } + } + + public static void main(String[] args) { + ConfigurableApplicationContext ctx = SpringApplication.run(Stage1_5_PayloadOnly.class, args); + MeasurementLog.title("STAGE 1-5 — payload-only 이벤트 (Spring 4.2+)"); + ctx.getBean(Publisher.class).publishAll(); + + System.out.println(); + System.out.println("[학습 포인트]"); + System.out.println(" · ApplicationEvent 상속 의무 X — record / POJO / String 다 OK"); + System.out.println(" · 내부적으로 PayloadApplicationEvent 로 감싸짐"); + System.out.println(" · 도메인 이벤트는 전용 record 권장 (타입 충돌 회피)"); + ctx.close(); + } +} diff --git a/topics/06-event/members/minseo/src/main/java/stage/s2/Stage2_1_AfterCommit.java b/topics/06-event/members/minseo/src/main/java/stage/s2/Stage2_1_AfterCommit.java new file mode 100644 index 0000000..28d9b88 --- /dev/null +++ b/topics/06-event/members/minseo/src/main/java/stage/s2/Stage2_1_AfterCommit.java @@ -0,0 +1,94 @@ +package stage.s2; + +import infra.MeasurementLog; +import jakarta.annotation.PostConstruct; +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.EnableAutoConfiguration; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.context.ConfigurableApplicationContext; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.transaction.event.TransactionPhase; +import org.springframework.transaction.event.TransactionalEventListener; + +/** + * STAGE 2-1 — @TransactionalEventListener(AFTER_COMMIT) 해결책 시연. + * (재고 도메인 + example 정석 구조) + */ +@Configuration +@EnableAutoConfiguration +public class Stage2_1_AfterCommit { + + public record InventoryEvent(Long id, int amount) {} + + @Bean + public InventoryService inventoryService(JdbcTemplate jdbc, ApplicationEventPublisher publisher) { + return new InventoryService(jdbc, publisher); + } + + @Bean + public InventoryEventListener listener() { return new InventoryEventListener(); } + + public static class InventoryService { + private final JdbcTemplate jdbc; + private final ApplicationEventPublisher publisher; + + public InventoryService(JdbcTemplate jdbc, ApplicationEventPublisher publisher) { + this.jdbc = jdbc; + this.publisher = publisher; + } + + @PostConstruct + public void init() { + jdbc.execute("CREATE TABLE IF NOT EXISTS inventory (id BIGINT PRIMARY KEY, stock INT)"); + jdbc.update("INSERT INTO inventory (id, stock) VALUES (1, 100) ON CONFLICT DO NOTHING"); + } + + @Transactional + public void decrementSuccess(Long id, int amount) { + System.out.println(" [SERVICE] 재고 차감(성공) id=" + id); + jdbc.update("UPDATE inventory SET stock = stock - ? WHERE id = ?", amount, id); + publisher.publishEvent(new InventoryEvent(id, amount)); + } + + @Transactional + public void decrementWithFailure(Long id, int amount) { + System.out.println(" [SERVICE] 재고 차감(실패) id=" + id); + jdbc.update("UPDATE inventory SET stock = stock - ? WHERE id = ?", amount, id); + publisher.publishEvent(new InventoryEvent(id, amount)); + throw new RuntimeException("의도적 롤백"); + } + + public int getStock(Long id) { + return jdbc.queryForObject("SELECT stock FROM inventory WHERE id = ?", Integer.class, id); + } + } + + public static class InventoryEventListener { + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) + public void onAfterCommit(InventoryEvent e) { + System.out.println(" [AFTER_COMMIT] ✅ 알림 발송 (id=" + e.id() + ")"); + } + } + + public static void main(String[] args) { + ConfigurableApplicationContext ctx = SpringApplication.run(Stage2_1_AfterCommit.class, args); + InventoryService svc = ctx.getBean(InventoryService.class); + + MeasurementLog.title("STAGE 2-1 — AFTER_COMMIT (재고 도메인)"); + + MeasurementLog.section("정상 커밋 상황"); + svc.decrementSuccess(1L, 10); + MeasurementLog.row("현재 재고", svc.getStock(1L)); + + System.out.println(); + MeasurementLog.section("롤백 상황 (알림 로그가 찍히지 않아야 함)"); + try { svc.decrementWithFailure(1L, 10); } + catch (Exception ignored) {} + MeasurementLog.row("현재 재고 (롤백되어 90 유지)", svc.getStock(1L)); + + ctx.close(); + } +} diff --git a/topics/06-event/members/minseo/src/main/java/stage/s2/Stage2_1_BeforeCommitTrap.java b/topics/06-event/members/minseo/src/main/java/stage/s2/Stage2_1_BeforeCommitTrap.java new file mode 100644 index 0000000..4ae96ae --- /dev/null +++ b/topics/06-event/members/minseo/src/main/java/stage/s2/Stage2_1_BeforeCommitTrap.java @@ -0,0 +1,94 @@ +package stage.s2; + +import infra.MeasurementLog; +import jakarta.annotation.PostConstruct; +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.context.ConfigurableApplicationContext; +import org.springframework.context.annotation.Bean; +import org.springframework.context.event.EventListener; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@SpringBootApplication(scanBasePackages = {"infra"}) +public class Stage2_1_BeforeCommitTrap { + + // 1. 이벤트 정의 (재고 변경 사실을 담음) + public record InventoryChangedEvent(Long id, int amount) { + } + + @Bean + public InventoryEventListener inventoryEventListener() { + return new InventoryEventListener(); + } + + @Service + public static class InventoryService { + private final JdbcTemplate jdbc; + private final ApplicationEventPublisher publisher; + + public InventoryService(JdbcTemplate jdbc, ApplicationEventPublisher publisher) { + this.jdbc = jdbc; + this.publisher = publisher; + } + + @PostConstruct + public void init() { + // 테스트용 테이블 및 데이터 준비 + jdbc.execute("CREATE TABLE IF NOT EXISTS inventory (id BIGINT PRIMARY KEY, stock INT)"); + jdbc.update("INSERT INTO inventory (id, stock) VALUES (1, 100) ON CONFLICT DO NOTHING"); + } + + @Transactional + public void decrement(Long id, int amount) { + System.out.println("[Service] 재고 차감 시작 — id=" + id); + + // (A) DB 업데이트: 재고 감소 + jdbc.update("UPDATE inventory SET stock = stock - ? WHERE id = ?", amount, id); + + // (B) 이벤트 발행: "재고가 줄어들었으니 알림을 보내라!" + publisher.publishEvent(new InventoryChangedEvent(id, amount)); + + // (C) 일부러 예외 발생! -> 트랜잭션 롤백 유도 + System.out.println("[Service] 💥 의도적 예외 발생 (롤백 예정)"); + throw new RuntimeException("DB 장애 발생으로 롤백됨!"); + } + + public int getStock(Long id) { + return jdbc.queryForObject("SELECT stock FROM inventory WHERE id = ?", Integer.class, id); + } + } + + public static class InventoryEventListener { + @EventListener // ★ 일반 이벤트 리스너 (트랜잭션 무관) + public void onInventoryChanged(InventoryChangedEvent event) { + System.out.println(" [Listener] 🔔 알림 발송 완료: 재고 변경됨(id=" + event.id() + ")"); + } + } + + // 메인 로직은 제가 실행 결과를 관찰하기 좋게 구성해 드릴게요. + public static void main(String[] args) { + ConfigurableApplicationContext ctx = SpringApplication.run(Stage2_1_BeforeCommitTrap.class, args); + InventoryService service = ctx.getBean(InventoryService.class); + + MeasurementLog.title("STAGE 2-1 — 트랜잭션 롤백 함정 시연"); + + try { + service.decrement(1L, 10); + } catch (Exception e) { + System.out.println("[Main] 예외 캐치: " + e.getMessage()); + } + + // 결과 확인: DB 재고가 정말 롤백되었는지 확인 + int finalStock = service.getStock(1L); + MeasurementLog.row("최종 재고 (100에서 10 차감 시도 후 롤백)", finalStock); + + System.out.println("\n[관찰 포인트]"); + System.out.println(" 1. DB 재고는 100으로 유지되었는가? (롤백 성공 여부)"); + System.out.println(" 2. '🔔 알림 발송 완료' 로그가 찍혔는가? (이벤트 실행 여부)"); + + ctx.close(); + } +} diff --git a/topics/06-event/members/minseo/src/main/java/stage/s2/Stage2_2_AllPhases.java b/topics/06-event/members/minseo/src/main/java/stage/s2/Stage2_2_AllPhases.java new file mode 100644 index 0000000..3c165dd --- /dev/null +++ b/topics/06-event/members/minseo/src/main/java/stage/s2/Stage2_2_AllPhases.java @@ -0,0 +1,92 @@ +package stage.s2; + +import infra.MeasurementLog; +import jakarta.annotation.PostConstruct; +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.EnableAutoConfiguration; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.context.ConfigurableApplicationContext; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.transaction.event.TransactionPhase; +import org.springframework.transaction.event.TransactionalEventListener; + +/** + * STAGE 2-2 — @TransactionalEventListener 의 4가지 Phase 확인. + * (재고 도메인 + example 정석 구조) + */ +@Configuration +@EnableAutoConfiguration +public class Stage2_2_AllPhases { + + public record InventoryEvent(Long id, int amount) {} + + @Bean + public InventoryService inventoryService(JdbcTemplate jdbc, ApplicationEventPublisher publisher) { + return new InventoryService(jdbc, publisher); + } + + @Bean + public AllPhaseListener listener() { return new AllPhaseListener(); } + + public static class InventoryService { + private final JdbcTemplate jdbc; + private final ApplicationEventPublisher publisher; + + public InventoryService(JdbcTemplate jdbc, ApplicationEventPublisher publisher) { + this.jdbc = jdbc; + this.publisher = publisher; + } + + @PostConstruct + public void init() { + jdbc.execute("CREATE TABLE IF NOT EXISTS inventory (id BIGINT PRIMARY KEY, stock INT)"); + jdbc.update("INSERT INTO inventory (id, stock) VALUES (1, 100) ON CONFLICT DO NOTHING"); + } + + @Transactional + public void changeStock(Long id, int amount) { + System.out.println(" [SERVICE] 재고 작업 시작 id=" + id); + jdbc.update("UPDATE inventory SET stock = stock + ? WHERE id = ?", amount, id); + + System.out.println(" [SERVICE] publishEvent"); + publisher.publishEvent(new InventoryEvent(id, amount)); + + if (amount == 0) throw new RuntimeException("변경량이 0이라 실패"); + System.out.println(" [SERVICE] 메서드 종료 → commit 예정"); + } + } + + public static class AllPhaseListener { + @TransactionalEventListener(phase = TransactionPhase.BEFORE_COMMIT) + public void onBefore(InventoryEvent e) { System.out.println(" [BEFORE_COMMIT] id=" + e.id()); } + + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) + public void onAfterCommit(InventoryEvent e) { System.out.println(" [AFTER_COMMIT] id=" + e.id()); } + + @TransactionalEventListener(phase = TransactionPhase.AFTER_ROLLBACK) + public void onAfterRollback(InventoryEvent e) { System.out.println(" [AFTER_ROLLBACK] id=" + e.id()); } + + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMPLETION) + public void onAfterCompletion(InventoryEvent e) { System.out.println(" [AFTER_COMPLETION] id=" + e.id()); } + } + + public static void main(String[] args) { + ConfigurableApplicationContext ctx = SpringApplication.run(Stage2_2_AllPhases.class, args); + InventoryService svc = ctx.getBean(InventoryService.class); + + MeasurementLog.title("STAGE 2-2 — 4 Phase 관찰 (재고 도메인)"); + + MeasurementLog.section("정상 커밋 (amount=10)"); + svc.changeStock(1L, 10); + + System.out.println(); + MeasurementLog.section("롤백 상황 (amount=0)"); + try { svc.changeStock(1L, 0); } + catch (Exception ignored) {} + + ctx.close(); + } +} diff --git a/topics/06-event/members/minseo/src/main/java/stage/s2/Stage2_3_FallbackExecution.java b/topics/06-event/members/minseo/src/main/java/stage/s2/Stage2_3_FallbackExecution.java new file mode 100644 index 0000000..af0fd4f --- /dev/null +++ b/topics/06-event/members/minseo/src/main/java/stage/s2/Stage2_3_FallbackExecution.java @@ -0,0 +1,74 @@ +package stage.s2; + +import infra.MeasurementLog; +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.EnableAutoConfiguration; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.context.ConfigurableApplicationContext; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.transaction.event.TransactionPhase; +import org.springframework.transaction.event.TransactionalEventListener; + +/** + * STAGE 2-3 — fallbackExecution — 트랜잭션 밖에서 publishEvent 시 동작. + * (재고 도메인 + example 정석 구조) + */ +@Configuration +@EnableAutoConfiguration +public class Stage2_3_FallbackExecution { + + public record InventoryQueryEvent(Long id) {} + + @Bean + public InventoryQueryService inventoryQueryService(ApplicationEventPublisher publisher) { + return new InventoryQueryService(publisher); + } + + @Bean public NoFallbackListener noFallback() { return new NoFallbackListener(); } + @Bean public WithFallbackListener withFallback() { return new WithFallbackListener(); } + + public static class InventoryQueryService { + private final ApplicationEventPublisher publisher; + public InventoryQueryService(ApplicationEventPublisher publisher) { this.publisher = publisher; } + + // ★ @Transactional 없음 — 단순 조회나 트랜잭션이 없는 상황 + public void queryStock(Long id) { + System.out.println(" [SERVICE] 재고 조회 (트랜잭션 없음) id=" + id); + publisher.publishEvent(new InventoryQueryEvent(id)); + System.out.println(" [SERVICE] return"); + } + } + + public static class NoFallbackListener { + // 기본 fallbackExecution = false + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) + public void on(InventoryQueryEvent e) { + System.out.println(" [NoFallback] id=" + e.id() + " ← 호출 안 됨 (트랜잭션이 없으므로)"); + } + } + + public static class WithFallbackListener { + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT, fallbackExecution = true) + public void on(InventoryQueryEvent e) { + System.out.println(" [WithFallback] id=" + e.id() + " ✅ 트랜잭션 없어도 호출됨"); + } + } + + public static void main(String[] args) { + ConfigurableApplicationContext ctx = SpringApplication.run(Stage2_3_FallbackExecution.class, args); + + MeasurementLog.title("STAGE 2-3 — fallbackExecution (트랜잭션 밖에서 발행)"); + + ctx.getBean(InventoryQueryService.class).queryStock(1L); + + System.out.println(); + System.out.println("[학습 포인트]"); + System.out.println(" · @TransactionalEventListener 는 기본적으로 트랜잭션이 있을 때만 동작함"); + System.out.println(" · NoFallback (기본값) — 트랜잭션 밖에서 발행하면 리스너가 무시됨 (경고도 없음)"); + System.out.println(" · WithFallback (true) — 트랜잭션이 없어도 즉시 리스너를 실행함"); + System.out.println(" · 트랜잭션 안/밖 모두에서 호출될 가능성이 있는 이벤트라면 true 설정이 안전함"); + + ctx.close(); + } +} diff --git a/topics/06-event/members/minseo/src/main/java/stage/s2/Stage2_4_AfterCommitDbWrite.java b/topics/06-event/members/minseo/src/main/java/stage/s2/Stage2_4_AfterCommitDbWrite.java new file mode 100644 index 0000000..0b40432 --- /dev/null +++ b/topics/06-event/members/minseo/src/main/java/stage/s2/Stage2_4_AfterCommitDbWrite.java @@ -0,0 +1,115 @@ +package stage.s2; + +import infra.MeasurementLog; +import jakarta.annotation.PostConstruct; +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.EnableAutoConfiguration; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.context.ConfigurableApplicationContext; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.transaction.event.TransactionPhase; +import org.springframework.transaction.event.TransactionalEventListener; + +/** + * STAGE 2-4 — AFTER_COMMIT 리스너에서 DB 쓰기 함정 시연. + * (재고 도메인 + example 정석 구조) + */ +@Configuration +@EnableAutoConfiguration +public class Stage2_4_AfterCommitDbWrite { + + public record InventoryChangedEvent(Long id, int amount) {} + + @Bean + public InventoryService inventoryService(JdbcTemplate jdbc, ApplicationEventPublisher p) { + return new InventoryService(jdbc, p); + } + + @Bean public NoTxListener noTxListener(JdbcTemplate jdbc) { return new NoTxListener(jdbc); } + @Bean public RequiresNewListener reqNewListener(JdbcTemplate jdbc) { return new RequiresNewListener(jdbc); } + + public static class InventoryService { + private final JdbcTemplate jdbc; + private final ApplicationEventPublisher publisher; + + public InventoryService(JdbcTemplate jdbc, ApplicationEventPublisher publisher) { + this.jdbc = jdbc; + this.publisher = publisher; + } + + @PostConstruct + public void init() { + jdbc.execute("CREATE TABLE IF NOT EXISTS inventory (id BIGINT PRIMARY KEY, stock INT)"); + jdbc.execute("CREATE TABLE IF NOT EXISTS inventory_history (id SERIAL PRIMARY KEY, inventory_id BIGINT, amount INT)"); + jdbc.update("INSERT INTO inventory (id, stock) VALUES (1, 100) ON CONFLICT DO NOTHING"); + } + + @Transactional + public void decrement(Long id, int amount) { + System.out.println(" [SERVICE] 재고 차감 id=" + id); + jdbc.update("UPDATE inventory SET stock = stock - ? WHERE id = ?", amount, id); + publisher.publishEvent(new InventoryChangedEvent(id, amount)); + } + + public Integer countHistory() { + return jdbc.queryForObject("SELECT COUNT(*) FROM inventory_history", Integer.class); + } + } + + /** 함정 리스너: @Transactional 없음 */ + public static class NoTxListener { + private final JdbcTemplate jdbc; + public NoTxListener(JdbcTemplate jdbc) { this.jdbc = jdbc; } + + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) + public void on(InventoryChangedEvent e) { + System.out.println(" [NoTx] 히스토리 INSERT 시도 (no @Transactional)"); + jdbc.update("INSERT INTO inventory_history(inventory_id, amount) VALUES (?, ?)", e.id(), e.amount()); + System.out.println(" [NoTx] update 호출 종료"); + } + } + + /** 해결 리스너: REQUIRES_NEW 사용 */ + public static class RequiresNewListener { + private final JdbcTemplate jdbc; + public RequiresNewListener(JdbcTemplate jdbc) { this.jdbc = jdbc; } + + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) + @Transactional(propagation = Propagation.REQUIRES_NEW) // ★ 새 트랜잭션 시작 + public void on(InventoryChangedEvent e) { + System.out.println(" [ReqNew] 히스토리 INSERT 시도 (REQUIRES_NEW)"); + jdbc.update("INSERT INTO inventory_history(inventory_id, amount) VALUES (?, ?)", e.id(), e.amount()); + System.out.println(" [ReqNew] update 호출 종료"); + } + } + + public static void main(String[] args) { + ConfigurableApplicationContext ctx = SpringApplication.run(Stage2_4_AfterCommitDbWrite.class, args); + InventoryService svc = ctx.getBean(InventoryService.class); + + MeasurementLog.title("STAGE 2-4 — AFTER_COMMIT 리스너에서의 DB 쓰기 함정"); + + System.out.println(" 초기 히스토리 카운트: " + svc.countHistory()); + + MeasurementLog.section("decrement(1, 10) 실행"); + svc.decrement(1L, 10); + + System.out.println(); + Integer finalCount = svc.countHistory(); + MeasurementLog.row("최종 히스토리 카운트", finalCount); + + System.out.println(); + System.out.println("[학습 포인트]"); + System.out.println(" · AFTER_COMMIT 리스너는 이미 '본 트랜잭션'이 물리적으로 커밋된 후임"); + System.out.println(" · 하지만 스레드에는 여전히 이전 트랜잭션 동기화 정보가 남아있을 수 있음"); + System.out.println(" · NoTx: @Transactional 이 없으면 기존(끝난) 커넥션을 재사용하려다 commit 없이 닫혀 데이터가 '유실'될 위험이 큼"); + System.out.println(" · ReqNew: @Transactional(REQUIRES_NEW) 를 쓰면 확실히 '새 커넥션/새 트랜잭션'을 열어 안전하게 저장함"); + System.out.println(" · 결론: AFTER_COMMIT 리스너에서 DB 쓰기가 필요하다면 반드시 REQUIRES_NEW 를 사용하자"); + + ctx.close(); + } +} diff --git a/topics/06-event/members/minseo/src/main/java/stage/s3/Stage3_1_SyncSlow.java b/topics/06-event/members/minseo/src/main/java/stage/s3/Stage3_1_SyncSlow.java new file mode 100644 index 0000000..1793ecb --- /dev/null +++ b/topics/06-event/members/minseo/src/main/java/stage/s3/Stage3_1_SyncSlow.java @@ -0,0 +1,84 @@ +package stage.s3; + +import infra.MeasurementLog; +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.EnableAutoConfiguration; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.context.ConfigurableApplicationContext; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.event.EventListener; + +/** + * STAGE 3-1 — 동기 리스너의 한계 (Sync Slow) + * : 리스너가 느리면 publisher(InventoryService)도 그만큼 블록됨을 재현합니다. + */ +@Configuration +@EnableAutoConfiguration +public class Stage3_1_SyncSlow { + + public record StockLowEvent(Long id, int currentStock) {} + + @Bean + public InventoryService inventoryService(ApplicationEventPublisher publisher) { + return new InventoryService(publisher); + } + + @Bean public NotificationListener l1() { return new NotificationListener(); } + @Bean public StatisticsListener l2() { return new StatisticsListener(); } + @Bean public ErpSyncListener l3() { return new ErpSyncListener(); } + + public static class InventoryService { + private final ApplicationEventPublisher publisher; + public InventoryService(ApplicationEventPublisher publisher) { this.publisher = publisher; } + + public void checkStock(Long id, int stock) { + System.out.println("[Service] 재고 확인 — id=" + id + " stock=" + stock); + + long t1 = System.nanoTime(); + if (stock < 10) { + publisher.publishEvent(new StockLowEvent(id, stock)); + } + long elapsedMs = (System.nanoTime() - t1) / 1_000_000; + + System.out.println("[Service] 리턴 완료 — 총 소요시간=" + elapsedMs + "ms " + MeasurementLog.thread()); + } + } + + private static void slowWork(String label) throws InterruptedException { + System.out.println(" [" + label + "] 처리 중... " + MeasurementLog.thread()); + Thread.sleep(200); + System.out.println(" [" + label + "] 완료"); + } + + public static class NotificationListener { + @EventListener + public void on(StockLowEvent e) throws InterruptedException { + slowWork("Notification"); + } + } + + public static class StatisticsListener { + @EventListener + public void on(StockLowEvent e) throws InterruptedException { + slowWork("Statistics"); + } + } + + public static class ErpSyncListener { + @EventListener + public void on(StockLowEvent e) throws InterruptedException { + slowWork("ERP Sync"); + } + } + + public static void main(String[] args) { + ConfigurableApplicationContext ctx = SpringApplication.run(Stage3_1_SyncSlow.class, args); + InventoryService service = ctx.getBean(InventoryService.class); + + MeasurementLog.title("STAGE 3-1 — 동기 리스너의 블록킹 현상 (재고 도메인)"); + service.checkStock(1L, 5); + + ctx.close(); + } +} diff --git a/topics/06-event/members/minseo/src/main/java/stage/s3/Stage3_2_AsyncFast.java b/topics/06-event/members/minseo/src/main/java/stage/s3/Stage3_2_AsyncFast.java new file mode 100644 index 0000000..0e0a78c --- /dev/null +++ b/topics/06-event/members/minseo/src/main/java/stage/s3/Stage3_2_AsyncFast.java @@ -0,0 +1,118 @@ +package stage.s3; + +import infra.MeasurementLog; +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.EnableAutoConfiguration; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.context.ConfigurableApplicationContext; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.event.EventListener; +import org.springframework.scheduling.annotation.Async; +import org.springframework.scheduling.annotation.EnableAsync; +import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor; + +/** + * STAGE 3-2 — @EnableAsync + @Async → 별 스레드. (재고 도메인) + * : 비동기 처리를 통해 리스너가 publisher(InventoryService)를 블록하지 않도록 개선합니다. + */ +@Configuration +@EnableAutoConfiguration +@EnableAsync // ★ 비동기 기능 활성화 +public class Stage3_2_AsyncFast { + + public record StockLowEvent(Long id, int currentStock) {} + + /** + * @Async 가 사용할 스레드풀 설정 + * - core: 4 (기본 유지 스레드) + * - max: 8 (부하 시 최대 확장) + * - queue: 100 (큐가 가득 차야 max까지 확장됨) + */ + @Bean(name = "applicationTaskExecutor") + public ThreadPoolTaskExecutor taskExecutor() { + ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor(); + executor.setCorePoolSize(4); + executor.setMaxPoolSize(8); + executor.setQueueCapacity(100); + executor.setThreadNamePrefix("event-"); + executor.initialize(); + return executor; + } + + @Bean + public InventoryService inventoryService(ApplicationEventPublisher publisher) { + return new InventoryService(publisher); + } + + @Bean public AsyncNotificationListener l1() { return new AsyncNotificationListener(); } + @Bean public AsyncStatisticsListener l2() { return new AsyncStatisticsListener(); } + @Bean public AsyncErpSyncListener l3() { return new AsyncErpSyncListener(); } + + public static class InventoryService { + private final ApplicationEventPublisher publisher; + public InventoryService(ApplicationEventPublisher publisher) { this.publisher = publisher; } + + public void checkStock(Long id, int stock) { + System.out.println("[Service] 재고 확인 — id=" + id + " stock=" + stock + " " + MeasurementLog.thread()); + + long t1 = System.nanoTime(); + if (stock < 10) { + // 비동기 리스너인 경우, 이 호출은 즉시 리턴됩니다. + publisher.publishEvent(new StockLowEvent(id, stock)); + } + long elapsedMs = (System.nanoTime() - t1) / 1_000_000; + + System.out.println("[Service] 리턴 완료 — 총 소요시간=" + elapsedMs + "ms " + MeasurementLog.thread()); + } + } + + private static void slowWork(String label) throws InterruptedException { + System.out.println(" [" + label + "] 처리 시작... " + MeasurementLog.thread()); + Thread.sleep(200); + System.out.println(" [" + label + "] 완료"); + } + + public static class AsyncNotificationListener { + @Async // ★ 별도 스레드에서 실행 + @EventListener + public void on(StockLowEvent e) throws InterruptedException { + slowWork("Notification"); + } + } + + public static class AsyncStatisticsListener { + @Async + @EventListener + public void on(StockLowEvent e) throws InterruptedException { + slowWork("Statistics"); + } + } + + public static class AsyncErpSyncListener { + @Async + @EventListener + public void on(StockLowEvent e) throws InterruptedException { + slowWork("ERP Sync"); + } + } + + public static void main(String[] args) throws InterruptedException { + ConfigurableApplicationContext ctx = SpringApplication.run(Stage3_2_AsyncFast.class, args); + InventoryService service = ctx.getBean(InventoryService.class); + + MeasurementLog.title("STAGE 3-2 — @Async 비동기 리스너 도입 (재고 도메인)"); + + service.checkStock(1L, 5); + + // 비동기 로그 확인을 위해 메인 스레드 잠시 대기 + Thread.sleep(500); + + System.out.println("\n[관찰 포인트]"); + System.out.println(" 1. 서비스 로직의 리턴 시간이 0~5ms 내외로 비약적으로 단축되었는가?"); + System.out.println(" 2. 리스너들의 로그에 찍힌 스레드 이름이 'event-1', 'event-2' 등으로 바뀌었는가?"); + System.out.println(" 3. 리스너 3개가 거의 동시에(병렬로) 처리를 시작하는가?"); + + ctx.close(); + } +} diff --git a/topics/06-event/members/minseo/src/main/java/stage/s3/Stage3_3_SelfInvocation.java b/topics/06-event/members/minseo/src/main/java/stage/s3/Stage3_3_SelfInvocation.java new file mode 100644 index 0000000..d072e35 --- /dev/null +++ b/topics/06-event/members/minseo/src/main/java/stage/s3/Stage3_3_SelfInvocation.java @@ -0,0 +1,114 @@ +package stage.s3; + +import infra.MeasurementLog; +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.EnableAutoConfiguration; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.context.ConfigurableApplicationContext; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.event.EventListener; +import org.springframework.scheduling.annotation.Async; +import org.springframework.scheduling.annotation.EnableAsync; + +/** + * STAGE 3-3 — @Async self-invocation 함정 (5주차 @Transactional 복습) + * + * [시나리오] + * 재고가 부족할 때 '내부적으로' 비동기 처리를 하려고 시도합니다. + * 1. 내부 메서드 직접 호출 (this) → 프록시 우회로 인해 비동기 작동 안 함 + * 2. 외부 빈 메서드 호출 → 정상 비동기 + * 3. 이벤트를 통한 호출 → 정상 비동기 (6주차 권장 방식) + */ +@Configuration +@EnableAutoConfiguration +@EnableAsync +public class Stage3_3_SelfInvocation { + + public record StockLowEvent(Long id) {} + + @Bean + public InventoryService inventoryService(ApplicationEventPublisher p, OtherWorker o) { + return new InventoryService(p, o); + } + + @Bean + public OtherWorker otherWorker() { return new OtherWorker(); } + + @Bean + public AsyncEventListener asyncEventListener() { return new AsyncEventListener(); } + + public static class InventoryService { + private final ApplicationEventPublisher publisher; + private final OtherWorker otherWorker; + + public InventoryService(ApplicationEventPublisher publisher, OtherWorker otherWorker) { + this.publisher = publisher; + this.otherWorker = otherWorker; + } + + /** (1) 함정: 같은 클래스 내의 @Async 메서드 호출 */ + public void checkWithSelfInvocation(Long id) { + System.out.println(" [Service] checkWithSelfInvocation id=" + id + " " + MeasurementLog.thread()); + this.internalAsyncWork(); // ← 프록시를 거치지 않고 직접 호출됨 + } + + @Async + public void internalAsyncWork() { + System.out.println(" [Internal] 비동기 작업 시도... " + MeasurementLog.thread() + " ← 함정! (비동기 X)"); + } + + /** (2) 해결: 외부 주입된 빈의 @Async 메서드 호출 */ + public void checkWithOtherWorker(Long id) { + System.out.println(" [Service] checkWithOtherWorker id=" + id + " " + MeasurementLog.thread()); + otherWorker.doWork("ExternalWorker"); + } + + /** (3) 권장: 이벤트를 발행하여 외부 @Async 리스너에서 처리 */ + public void checkWithEvent(Long id) { + System.out.println(" [Service] checkWithEvent id=" + id + " " + MeasurementLog.thread()); + publisher.publishEvent(new StockLowEvent(id)); + } + } + + public static class OtherWorker { + @Async + public void doWork(String label) { + System.out.println(" [" + label + "] 비동기 작업 성공! " + MeasurementLog.thread()); + } + } + + public static class AsyncEventListener { + @Async + @EventListener + public void on(StockLowEvent e) { + System.out.println(" [EventListener] 비동기 리스너 성공! " + MeasurementLog.thread()); + } + } + + public static void main(String[] args) throws InterruptedException { + ConfigurableApplicationContext ctx = SpringApplication.run(Stage3_3_SelfInvocation.class, args); + InventoryService service = ctx.getBean(InventoryService.class); + + MeasurementLog.title("STAGE 3-3 — @Async Self-Invocation 함정 (재고 도메인)"); + + MeasurementLog.section("(1) 클래스 내부 @Async 호출 (this)"); + service.checkWithSelfInvocation(1L); + Thread.sleep(100); + + MeasurementLog.section("(2) 주입된 다른 빈의 @Async 호출"); + service.checkWithOtherWorker(2L); + Thread.sleep(100); + + MeasurementLog.section("(3) publishEvent -> @Async @EventListener"); + service.checkWithEvent(3L); + Thread.sleep(100); + + System.out.println("\n[학습 포인트]"); + System.out.println(" · @Async도 프록시 기반이므로 같은 클래스 내 호출(this)은 비동기로 동작하지 않음"); + System.out.println(" · 5주차 @Transactional 함정과 기술적으로 동일한 원인"); + System.out.println(" · 이벤트를 사용하면 자연스럽게 클래스가 분리되어 비동기 우회가 해결됨"); + + ctx.close(); + } +} diff --git a/topics/06-event/members/minseo/src/main/java/stage/s3/Stage3_4_AsyncException.java b/topics/06-event/members/minseo/src/main/java/stage/s3/Stage3_4_AsyncException.java new file mode 100644 index 0000000..bcf1c3b --- /dev/null +++ b/topics/06-event/members/minseo/src/main/java/stage/s3/Stage3_4_AsyncException.java @@ -0,0 +1,104 @@ +package stage.s3; + +import infra.MeasurementLog; +import java.lang.reflect.Method; +import org.springframework.aop.interceptor.AsyncUncaughtExceptionHandler; +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.EnableAutoConfiguration; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.context.ConfigurableApplicationContext; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.event.EventListener; +import org.springframework.scheduling.annotation.Async; +import org.springframework.scheduling.annotation.AsyncConfigurer; +import org.springframework.scheduling.annotation.EnableAsync; + +/** + * STAGE 3-4 — @Async void 메서드 예외 처리 (재고 도메인) + * : 비동기 리스너(void 반환)에서 발생한 예외가 어떻게 처리되는지 확인합니다. + */ +@Configuration +@EnableAutoConfiguration +@EnableAsync +public class Stage3_4_AsyncException implements AsyncConfigurer { + + /** + * ★ 핵심: 비동기 작업 중 발생하는 예외를 잡기 위한 핸들러 등록 + */ + @Override + public AsyncUncaughtExceptionHandler getAsyncUncaughtExceptionHandler() { + return new InventoryAsyncExceptionHandler(); + } + + public record StockLowEvent(Long id) {} + + @Bean + public InventoryService inventoryService(ApplicationEventPublisher publisher) { + return new InventoryService(publisher); + } + + @Bean + public FailingErpSyncListener failingListener() { + return new FailingErpSyncListener(); + } + + public static class InventoryService { + private final ApplicationEventPublisher publisher; + public InventoryService(ApplicationEventPublisher publisher) { this.publisher = publisher; } + + public void processStock(Long id) { + System.out.println("[Service] 재고 처리 중... " + MeasurementLog.thread()); + // 비동기 예외가 발생할 이벤트를 발행합니다. + publisher.publishEvent(new StockLowEvent(id)); + System.out.println("[Service] 호출 완료 (예외 전파 안 됨)"); + } + } + + public static class FailingErpSyncListener { + @Async + @EventListener + public void on(StockLowEvent e) { + System.out.println(" [ERP Listener] 비동기 동기화 시도... " + MeasurementLog.thread()); + // 의도적인 런타임 예외 발생 + throw new RuntimeException("ERP 시스템 서버 응답 없음!"); + } + } + + /** + * 비동기 예외 전용 핸들러 + */ + public static class InventoryAsyncExceptionHandler implements AsyncUncaughtExceptionHandler { + @Override + public void handleUncaughtException(Throwable ex, Method method, Object... params) { + System.err.println(" [ASYNC ERROR LOG]"); + System.err.println(" - Method: " + method.getName()); + System.err.println(" - Message: " + ex.getMessage()); + System.err.println(" - Thread: " + MeasurementLog.thread()); + } + } + + public static void main(String[] args) throws InterruptedException { + ConfigurableApplicationContext ctx = SpringApplication.run(Stage3_4_AsyncException.class, args); + InventoryService service = ctx.getBean(InventoryService.class); + + MeasurementLog.title("STAGE 3-4 — 비동기 void 예외 핸들링 (재고 도메인)"); + + try { + service.processStock(1L); + } catch (Exception e) { + // 이 catch 문은 작동하지 않습니다. 비동기 예외는 이미 다른 스레드로 분기되었기 때문입니다. + System.out.println("[Main] 이곳에서는 예외를 잡을 수 없습니다: " + e.getMessage()); + } + + // 비동기 핸들러가 로그를 남길 때까지 잠시 대기 + Thread.sleep(500); + + System.out.println("\n[학습 포인트]"); + System.out.println(" 1. @Async void 메서드는 예외가 발생해도 호출자(Service)에게 전파되지 않습니다."); + System.out.println(" 2. 핸들러를 등록하지 않으면 예외가 로그 없이 사라질 수 있어 위험합니다."); + System.err.println(" 3. AsyncConfigurer를 구현하여 전역 비동기 예외 핸들러를 설정하는 것이 안전합니다."); + + ctx.close(); + } +} diff --git a/topics/06-event/members/minseo/src/main/java/stage/s3/Stage3_5_VirtualThread.java b/topics/06-event/members/minseo/src/main/java/stage/s3/Stage3_5_VirtualThread.java new file mode 100644 index 0000000..151445b --- /dev/null +++ b/topics/06-event/members/minseo/src/main/java/stage/s3/Stage3_5_VirtualThread.java @@ -0,0 +1,84 @@ +package stage.s3; + +import infra.MeasurementLog; +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.EnableAutoConfiguration; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.context.ConfigurableApplicationContext; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.event.EventListener; +import org.springframework.scheduling.annotation.Async; +import org.springframework.scheduling.annotation.EnableAsync; + +/** + * STAGE 3-5 — Java 21 Virtual Thread + Spring Boot 3.2 (재고 도메인) + * + * [시나리오] + * 대량의 재고 연동 작업(I/O Bound)이 발생했을 때 가상 스레드의 효율성을 확인합니다. + * application.yml에 spring.threads.virtual.enabled=true 설정이 필요합니다. + */ +@Configuration +@EnableAutoConfiguration +@EnableAsync +public class Stage3_5_VirtualThread { + + public record InventorySyncEvent(Long id) {} + + @Bean + public InventoryService inventoryService(ApplicationEventPublisher publisher) { + return new InventoryService(publisher); + } + + @Bean public IoBoundListener l1() { return new IoBoundListener("L1"); } + @Bean public IoBoundListener l2() { return new IoBoundListener("L2"); } + @Bean public IoBoundListener l3() { return new IoBoundListener("L3"); } + + public static class InventoryService { + private final ApplicationEventPublisher publisher; + public InventoryService(ApplicationEventPublisher publisher) { this.publisher = publisher; } + + public void syncAll(Long id) { + System.out.println("[Service] 재고 동기화 시작 " + MeasurementLog.thread()); + publisher.publishEvent(new InventorySyncEvent(id)); + System.out.println("[Service] 이벤트 발행 완료"); + } + } + + public static class IoBoundListener { + private final String name; + public IoBoundListener(String name) { this.name = name; } + + @Async + @EventListener + public void on(InventorySyncEvent e) throws InterruptedException { + Thread current = Thread.currentThread(); + // ★ 핵심: isVirtual()을 통해 가상 스레드 여부 확인 + System.out.println(" [" + name + "] 처리 시작 | Thread: " + current.getName() + + " | isVirtual: " + current.isVirtual()); + + Thread.sleep(200); // I/O 블로킹 시뮬레이션 + System.out.println(" [" + name + "] 처리 완료"); + } + } + + public static void main(String[] args) throws InterruptedException { + ConfigurableApplicationContext ctx = SpringApplication.run(Stage3_5_VirtualThread.class, args); + InventoryService service = ctx.getBean(InventoryService.class); + + MeasurementLog.title("STAGE 3-5 — Virtual Thread 활용 (재고 도메인)"); + + service.syncAll(1L); + + // 비동기 작업 완료 대기 + Thread.sleep(500); + + System.out.println("\n[학습 포인트]"); + System.out.println(" 1. application.yml의 spring.threads.virtual.enabled=true 설정으로 가상 스레드 활성화"); + System.out.println(" 2. 리스너 로그에서 isVirtual: true가 나오는지 확인"); + System.out.println(" 3. 가상 스레드는 수천 개를 생성해도 오버헤드가 적어 I/O Bound 작업에 최적입니다."); + System.out.println(" 4. ThreadPoolTaskExecutor 빈을 직접 등록하면 가상 스레드 자동 설정이 무효화되니 주의하세요."); + + ctx.close(); + } +} diff --git a/topics/06-event/members/minseo/src/main/java/stage/s4/Stage4_1_AopToEvent.java b/topics/06-event/members/minseo/src/main/java/stage/s4/Stage4_1_AopToEvent.java new file mode 100644 index 0000000..e9056f0 --- /dev/null +++ b/topics/06-event/members/minseo/src/main/java/stage/s4/Stage4_1_AopToEvent.java @@ -0,0 +1,132 @@ +package stage.s4; + +import infra.MeasurementLog; +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; +import org.aspectj.lang.ProceedingJoinPoint; +import org.aspectj.lang.annotation.Around; +import org.aspectj.lang.annotation.Aspect; +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.EnableAutoConfiguration; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.context.ConfigurableApplicationContext; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.core.annotation.Order; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.transaction.event.TransactionPhase; +import org.springframework.transaction.event.TransactionalEventListener; + +/** + * STAGE 4-1 — 5주차 @Audited (AOP) → 6주차 @TransactionalEventListener 전환 + * + * [시나리오] + * 재고 변경 내역을 감사 로그(Audit)로 남깁니다. + * 1. OldAopService: 5주차 방식. AOP가 트랜잭션 안쪽에서 실행됨 -> 롤백되어도 로그가 남음 (시도 기록) + * 2. NewEventService: 6주차 방식. 커밋 후에만 리스너가 실행됨 -> 롤백되면 로그가 남지 않음 (확정 기록) + */ +@Configuration +@EnableAutoConfiguration +public class Stage4_1_AopToEvent { + + @Target(ElementType.METHOD) + @Retention(RetentionPolicy.RUNTIME) + public @interface Audited { + String action() default ""; + } + + public record StockChangedEvent(Long id, int amount, String action) {} + + @Bean public AuditAspect auditAspect() { return new AuditAspect(); } + @Bean public OldAopInventoryService oldService(JdbcTemplate jdbc) { return new OldAopInventoryService(jdbc); } + @Bean public NewEventInventoryService newService(JdbcTemplate jdbc, ApplicationEventPublisher p) { + return new NewEventInventoryService(jdbc, p); + } + @Bean public AuditListener auditListener() { return new AuditListener(); } + + // ── [5주차 패턴] @Audited AOP ────────────────────────── + @Aspect + @Order(2) // Transaction(1) 보다 안쪽 + public static class AuditAspect { + @Around("@annotation(audited)") + public Object audit(ProceedingJoinPoint pjp, Audited audited) throws Throwable { + try { + Object result = pjp.proceed(); + System.out.println(" [AOP AUDIT] action=" + audited.action() + " | result=SUCCESS (TX 안쪽)"); + return result; + } catch (Throwable t) { + System.out.println(" [AOP AUDIT] action=" + audited.action() + " | result=FAIL (TX 안쪽, 롤백 예정)"); + throw t; + } + } + } + + public static class OldAopInventoryService { + private final JdbcTemplate jdbc; + public OldAopInventoryService(JdbcTemplate jdbc) { this.jdbc = jdbc; } + + @Transactional + @Audited(action = "STOCK_DECREMENT") + public void changeStock(Long id, int amount) { + jdbc.update("UPDATE inventory SET stock = stock - ? WHERE id = ?", amount, id); + if (amount <= 0) throw new RuntimeException("잘못된 수량"); + } + } + + // ── [6주차 패턴] Event + AFTER_COMMIT ────────────────── + public static class NewEventInventoryService { + private final JdbcTemplate jdbc; + private final ApplicationEventPublisher publisher; + public NewEventInventoryService(JdbcTemplate jdbc, ApplicationEventPublisher publisher) { + this.jdbc = jdbc; + this.publisher = publisher; + } + + @Transactional + public void changeStock(Long id, int amount) { + jdbc.update("UPDATE inventory SET stock = stock - ? WHERE id = ?", amount, id); + // 이벤트 발행 (리스너가 커밋 후에만 돌도록 설정됨) + publisher.publishEvent(new StockChangedEvent(id, amount, "STOCK_DECREMENT")); + + if (amount <= 0) throw new RuntimeException("잘못된 수량"); + } + } + + public static class AuditListener { + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) + public void on(StockChangedEvent e) { + System.out.println(" [EVENT AUDIT] action=" + e.action() + " | id=" + e.id() + " (커밋 확정 후 기록)"); + } + } + + public static void main(String[] args) { + ConfigurableApplicationContext ctx = SpringApplication.run(Stage4_1_AopToEvent.class, args); + OldAopInventoryService old = ctx.getBean(OldAopInventoryService.class); + NewEventInventoryService neu = ctx.getBean(NewEventInventoryService.class); + + MeasurementLog.title("STAGE 4-1 — AOP Audit vs Event Audit"); + + MeasurementLog.section("(1) Old AOP — 정상 케이스"); + old.changeStock(1L, 10); + + MeasurementLog.section("(2) Old AOP — 롤백 케이스 (AOP 로그는 남음)"); + try { old.changeStock(1L, -1); } catch (Exception ignored) {} + + MeasurementLog.section("(3) New Event — 정상 케이스"); + neu.changeStock(1L, 10); + + MeasurementLog.section("(4) New Event — 롤백 케이스 (이벤트 로그 안 남음)"); + try { neu.changeStock(1L, -1); } catch (Exception ignored) {} + + System.out.println(); + System.out.println("[학습 포인트]"); + System.out.println(" · AOP 방식: TX 안쪽에서 실행되므로 '시도(Attempt)' 자체를 기록하기 좋음"); + System.out.println(" · Event 방식: AFTER_COMMIT을 통해 '성공(Finalized)'한 기록만 남기기 좋음"); + System.out.println(" · 비즈니스 요구사항에 따라 어떤 기록 정책을 가져갈지 선택하는 것이 핵심"); + + ctx.close(); + } +} diff --git a/topics/06-event/members/minseo/src/main/java/stage/s4/Stage4_2_AopPlusEvent.java b/topics/06-event/members/minseo/src/main/java/stage/s4/Stage4_2_AopPlusEvent.java new file mode 100644 index 0000000..c074d56 --- /dev/null +++ b/topics/06-event/members/minseo/src/main/java/stage/s4/Stage4_2_AopPlusEvent.java @@ -0,0 +1,128 @@ +package stage.s4; + +import infra.MeasurementLog; +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; +import org.aspectj.lang.ProceedingJoinPoint; +import org.aspectj.lang.annotation.Around; +import org.aspectj.lang.annotation.Aspect; +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.EnableAutoConfiguration; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.context.ConfigurableApplicationContext; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.core.annotation.Order; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.transaction.event.TransactionPhase; +import org.springframework.transaction.event.TransactionalEventListener; + +/** + * STAGE 4-2 — AOP + Event 같이 쓰기 (실제 프로젝트 패턴) + * + * [시나리오] + * 재고 관리 시스템에서 AOP와 이벤트를 동시에 활용합니다. + * 1. AOP (@Audited): 트랜잭션 시도 자체를 기록 (성공/실패 무관하게 진입/종료 기록) + * 2. Event (AFTER_COMMIT): 트랜잭션 성공 시 확정 알림 및 통계 갱신 + * 3. Event (AFTER_ROLLBACK): 트랜잭션 실패 시 사후 분석을 위한 상세 로그 기록 + */ +@Configuration +@EnableAutoConfiguration +public class Stage4_2_AopPlusEvent { + + @Target(ElementType.METHOD) + @Retention(RetentionPolicy.RUNTIME) + public @interface Audited { + String action() default ""; + } + + public record InventoryChangeEvent(Long id, int amount, String action) {} + + @Bean public AuditAspect auditAspect() { return new AuditAspect(); } + @Bean public InventoryService inventoryService(JdbcTemplate jdbc, ApplicationEventPublisher p) { + return new InventoryService(jdbc, p); + } + @Bean public InventoryListeners listeners() { return new InventoryListeners(); } + + @Aspect + @Order(2) // @Transactional(1) 보다 안쪽 + public static class AuditAspect { + @Around("@annotation(audited)") + public Object audit(ProceedingJoinPoint pjp, Audited audited) throws Throwable { + System.out.println(" [AOP Audit] >>> 시도: " + audited.action()); + try { + Object r = pjp.proceed(); + System.out.println(" [AOP Audit] <<< 종료: SUCCESS"); + return r; + } catch (Throwable t) { + System.out.println(" [AOP Audit] <<< 종료: FAIL (Rollback 예정)"); + throw t; + } + } + } + + public static class InventoryService { + private final JdbcTemplate jdbc; + private final ApplicationEventPublisher publisher; + public InventoryService(JdbcTemplate jdbc, ApplicationEventPublisher publisher) { + this.jdbc = jdbc; + this.publisher = publisher; + } + + @Transactional + @Audited(action = "STOCK_CHANGE") + public void changeStock(Long id, int amount) { + jdbc.update("UPDATE inventory SET stock = stock - ? WHERE id = ?", amount, id); + + // 이벤트 발행 (성공/실패 리스너가 각각 반응함) + publisher.publishEvent(new InventoryChangeEvent(id, amount, "STOCK_CHANGE")); + + if (amount <= 0) { + throw new RuntimeException("수량 오류 (음수/0 불가)"); + } + } + } + + public static class InventoryListeners { + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) + public void onConfirmed(InventoryChangeEvent e) { + System.out.println(" [EVENT:확정] ✅ 재고 변경 완료 (id=" + e.id() + ") -> 담당자 알림 발송"); + } + + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) + public void onStatistics(InventoryChangeEvent e) { + System.out.println(" [EVENT:통계] 📊 글로벌 재고 현황 업데이트"); + } + + @TransactionalEventListener(phase = TransactionPhase.AFTER_ROLLBACK) + public void onFailureAnalysis(InventoryChangeEvent e) { + System.out.println(" [EVENT:실패] ❌ 작업 취소됨 (id=" + e.id() + ") -> 실패 원인 분석 로그 기록"); + } + } + + public static void main(String[] args) { + ConfigurableApplicationContext ctx = SpringApplication.run(Stage4_2_AopPlusEvent.class, args); + InventoryService svc = ctx.getBean(InventoryService.class); + + MeasurementLog.title("STAGE 4-2 — AOP + Event 혼합 전략 (재고 도메인)"); + + MeasurementLog.section("Case 1: 정상 처리 (amount=10)"); + svc.changeStock(1L, 10); + + System.out.println(); + MeasurementLog.section("Case 2: 롤백 처리 (amount=0)"); + try { svc.changeStock(1L, 0); } + catch (Exception ex) { System.out.println(" [Main] Exception Caught: " + ex.getMessage()); } + + System.out.println(); + System.out.println("[학습 포인트]"); + System.out.println(" · AOP (advice 안-밖): 메서드 생명주기(진입/종료)에 관여. 트랜잭션 성공 여부와 무관한 '행위 자체' 기록"); + System.out.println(" · Event (시간축): 트랜잭션 결과(Commit/Rollback)에 따라 다른 부수 효과를 유연하게 배치"); + System.out.println(" · 두 메커니즘을 섞어 쓰면 '언제(시점)'와 '어떻게(방법)'를 완벽하게 분리한 아키텍처가 가능함"); + + ctx.close(); + } +} diff --git a/topics/06-event/members/minseo/src/main/resources/application.yml b/topics/06-event/members/minseo/src/main/resources/application.yml new file mode 100644 index 0000000..2b55391 --- /dev/null +++ b/topics/06-event/members/minseo/src/main/resources/application.yml @@ -0,0 +1,31 @@ +spring: + threads: + virtual: + enabled: true + application: + name: cs-study + + datasource: + url: jdbc:postgresql://localhost:5433/csstudy + username: csstudy + password: csstudy1234 + driver-class-name: org.postgresql.Driver + + jpa: + hibernate: + ddl-auto: create-drop # 매주 깨끗하게 시작 (테이블 자동 생성) + properties: + hibernate: + format_sql: true + show_sql: true + open-in-view: false # 트랜잭션 범위 학습용 + + data: + redis: + host: localhost + port: 6379 + +logging: + level: + org.hibernate.SQL: debug + org.hibernate.orm.jdbc.bind: trace