diff --git a/topics/06-event/members/sujin/.gitkeep b/topics/06-event/members/sujin/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/topics/06-event/members/sujin/build.gradle b/topics/06-event/members/sujin/build.gradle new file mode 100644 index 0000000..7be3c28 --- /dev/null +++ b/topics/06-event/members/sujin/build.gradle @@ -0,0 +1,38 @@ +// 6주차 학습용 — Spring Event (publishEvent / @EventListener / @TransactionalEventListener / @Async) +// 도메인: #2 결제(payment). STAGE 1 은 jdbc 없어도 되지만, STAGE 2 트랜잭션 결합부터 H2 사용. + +plugins { + id 'java' + id 'application' +} + +java { + toolchain { + languageVersion = JavaLanguageVersion.of(21) + } +} + +repositories { + mavenCentral() +} + +dependencies { + implementation 'org.springframework.boot:spring-boot-starter:3.2.0' + + // STAGE 2 트랜잭션 결합 — H2 인메모리 + implementation 'org.springframework.boot:spring-boot-starter-jdbc:3.2.0' + runtimeOnly 'com.h2database:h2:2.2.224' + + // STAGE 4 비교용 — 5 주차 @Audited(AOP) vs 6 주차 이벤트 결과 비교 + implementation 'org.springframework.boot:spring-boot-starter-aop:3.2.0' +} + +application { + // 실행 시 -PmainClass=stage.s1.Stage1HelloEvent 로 덮어씀 + mainClass = providers.gradleProperty('mainClass').orElse('stage.s1.Stage1HelloEvent') +} + +// -parameters: @EventListener(condition = "...") SpEL / 파라미터 바인딩 안전장치 (5 주차에서 익힘) +tasks.withType(JavaCompile).configureEach { + options.compilerArgs << '-parameters' +} diff --git a/topics/06-event/members/sujin/measurements.md b/topics/06-event/members/sujin/measurements.md new file mode 100644 index 0000000..45b740f --- /dev/null +++ b/topics/06-event/members/sujin/measurements.md @@ -0,0 +1,240 @@ +# 측정 기록 (6주차 — Spring Event) + +도메인: **결제(payment)**. `MeasurementLog.save(stage, note)` 로 각 STAGE 관찰을 자동 누적. +아래 `- [시각] sX-Y · ...` 항목은 코드 실행 시 자동 기록되고, 해석 메모는 직접 덧붙인다. + +## STAGE 1 — publishEvent + @EventListener (직접 관찰) + +`ApplicationEventPublisher` 가 자동으로 해주는 발행/분배를 가장 작은 단위부터 손으로 확인. + +### 자동 누적 로그 + +- [06-08 19:50] s1-1 · HelloEvent 발행 → 동기 리스너 호출 순서 println 확인 +- [06-08 19:54] s1-2 · @Order로 리스너 호출 순서 제어 확인 (작은 값 먼저) +- [06-08 20:03] s1-3 · 동기 기준동작: 예외 전파 O, 체인 중단 O — STAGE3 @Async 면 전파 X 예정 +- [06-08 20:10] s1-4 · ApplicationListener vs @EventListener — 동작 동일(같은 메커니즘), + 단 인터페이스는 클래스당 이벤트 1개 고정 +- [06-08 20:24] s1-5 · payload-only — ApplicationEvent 상속없이 String/record 발행, 타입으로 매칭 (내부 PayloadApplicationEvent 래핑) + +| 단계 | 한 줄 결론 | +|---|---| +| 1-1 | 리스너는 publisher 와 **같은 스레드에서 동기 호출** (`return` 이 리스너 뒤에 출력) | +| 1-2 | 호출 순서는 `@Order` 숫자가 결정 (작을수록 먼저, 선언/이름 무관) | +| 1-3 | 동기일 때 리스너 예외 → **다음 리스너 중단 + 호출자까지 전파** (STAGE 3 @Async 면 달라짐) | +| 1-4 | `ApplicationListener` 인터페이스(= `ApplicationEvent` 상속 필요) vs `@EventListener`(불필요) — 동작은 같은 메커니즘 | +| 1-5 | payload-only: 상속 없이 String/record 발행, **payload 타입으로 매칭** (내부 `PayloadApplicationEvent` 래핑) | + + +## STAGE 2 — `@TransactionalEventListener` 4 phase + +5주차 advice 안-밖(@Audited 가 commit 전 실행)의 한계를 코드로 재현하고, +시간축 phase 로 "발행 시점"과 "처리 시점"을 commit 기준으로 분리한다. + +### 자동 누적 로그 + +- [06-10 17:51] s2-1 · 순진한 @EventListener — 롤백돼도 알림 이미 전송됨(함정 재현) +- [06-10 17:55] s2-1 · @TransactionalEventListener(AFTER_COMMIT) — 롤백 시 알림 호출 X 확인 +- [06-10 18:03] s2-2 · 4 phase 매트릭스 — commit/rollback 별 호출 phase 관찰 +- [06-10 18:25] s2-4 · 트랜잭션 밖 publishEvent — fallbackExecution false/true 비교 +- [06-10 18:25] s2-4 · 트랜잭션 밖 publishEvent — fallbackExecution false/true 비교 +- [06-10 18:55] s2-5 · BEFORE_COMMIT 같은 트랜잭션 쓰기 + AFTER_COMMIT DB쓰기 함정/REQUIRES_NEW +- [06-10 18:56] s2-5 · BEFORE_COMMIT 같은 트랜잭션 쓰기 + AFTER_COMMIT DB쓰기 함정/REQUIRES_NEW +- [06-10 18:57] s2-5 · BEFORE_COMMIT 같은 트랜잭션 쓰기 + AFTER_COMMIT DB쓰기 함정/REQUIRES_NEW +- [06-10 18:59] s2-5 · BEFORE_COMMIT 같은 트랜잭션 쓰기 + AFTER_COMMIT DB쓰기 함정/REQUIRES_NEW + +### 2-1 — 순진한 `@EventListener` 함정 → `AFTER_COMMIT` 해결 + +`PaymentService.complete()` 가 INSERT → `publishEvent` → 음수면 예외(롤백). +같은 코드에서 리스너 어노테이션 한 줄만 바꿔 두 버전 비교. + +| 케이스 | `@EventListener` (순진) | `@TransactionalEventListener(AFTER_COMMIT)` | +|---|---|---| +| 정상 commit (id=1) | 알림 ✓ (commit **전**, publishEvent 순간) | 알림 ✓ (commit **후**) | +| 예외 → rollback (id=2) | **알림 ✗ 이미 전송됨** (회수 불가) | 알림 **안 나감** ✓ | +| payment 행 수 | 1 (id=2 INSERT 는 롤백) | 1 (동일) | + +**해석**: +- `@EventListener` 는 트랜잭션을 모름 → `publishEvent` 순간 같은 스레드에서 즉시 실행. + 롤백돼도 "결제 완료" 알림이 이미 나가는 모순 (DB 행은 1개인데 알림은 2번). +- `@TransactionalEventListener(AFTER_COMMIT)` 는 발행 시점엔 현재 트랜잭션에 + **콜백만 등록**(`TransactionSynchronizationManager.registerSynchronization`), + commit 성공 시에만 실행. 롤백 시 콜백 자체가 버려짐 → 자동 취소. +- 정상 케이스에서도 알림 출력 시점이 메서드 return 이후(commit 후)로 밀림 = 시간축 분리. + +### 2-2 — 4 phase 한 이벤트에 다 붙여 commit/rollback 매트릭스 + +한 `complete()` 가 발행한 이벤트 하나를 4개 phase 리스너가 받음. 정상/롤백 각각 호출 phase 관찰. + +| phase | 정상 commit | 예외 → rollback | +|---|---|---| +| `BEFORE_COMMIT` | ✓ | ✗ (commit 시도 자체를 안 함) | +| `AFTER_COMMIT` | ✓ | ✗ (status≠COMMITTED 가드) | +| `AFTER_ROLLBACK` | ✗ | ✓ | +| `AFTER_COMPLETION` | ✓ | ✓ (항상) | + +**phase → 내부 콜백 매핑**: +- `BEFORE_COMMIT` → `TransactionSynchronization.beforeCommit()` (commit 직전에만) +- `AFTER_COMMIT` / `AFTER_ROLLBACK` / `AFTER_COMPLETION` → **모두 같은 `afterCompletion(status)`** 안에서 status 가드로 분기 + +**실측 발견 (★ 문서 반박)**: +- 출력 순서가 시나리오 예상과 다름 — 정상: `BEFORE_COMMIT` → **`AFTER_COMPLETION` → `AFTER_COMMIT`** (예상은 COMMIT 먼저). + 롤백: `AFTER_COMPLETION` → `AFTER_ROLLBACK`. +- 이유: 위 셋이 같은 `afterCompletion()` 콜백 안에서 처리됨 → **상대 순서는 phase 의미가 보장 X**, + synchronization 등록 순서로 결정(=`@Order` 없으면 사실상 비결정적). +- 결론: "phase 는 *언제 그룹이 실행되는지(commit/rollback 기준)* 만 보장. 같은 after-그룹 *내부 순서*는 의존 금지." + +### 2-3 — phase 배치 결정 (결제 도메인) + +| 리스너 | phase | 근거 | +|---|---|---| +| 영수증 발급 | `BEFORE_COMMIT` | 결제와 정합성 묶임(도메인 가정) — 실패 시 결제도 롤백 | +| 포인트 적립 | `AFTER_COMMIT` | 결제 성공과 운명 분리 — 실패해도 결제 유지, 재시도 | +| 결제 실패 보상 | `AFTER_ROLLBACK` | 롤백 시에만 정리/통보 | +| 감사 로그 | `AFTER_COMPLETION` | 성공/실패 무관 항상 기록 | + +**결정 기준 (★)**: "DB 쓰기냐 아니냐"가 아니라 **"이게 실패하면 결제를 무효로 만들어야 하나(=원자성 필요)"**. +- 무효로 만들어야 함 → 같은 트랜잭션 `BEFORE_COMMIT` (실패 시 본 트랜잭션 롤백). 예: 회계 원장 분개 +- 무효로 만들면 안 됨 → `AFTER_COMMIT` (운명 분리, 별도 재시도). 예: 포인트 적립 +- 영수증은 경계 케이스 — 도메인이 "영수증 정합성 필수"로 정의하면 BEFORE_COMMIT. + +### 2-4 — fallbackExecution: 트랜잭션 밖 publishEvent + +`PaymentAuditService.recordAttempt()` 는 `@Transactional` 없음(트랜잭션 밖 발행). + +| 설정 | 결과 | 타이밍 | +|---|---|---| +| `fallbackExecution=false` (기본) | 리스너 **조용히 무시** (WARN 조차 없음 = 함정) | — | +| `fallbackExecution=true` | 즉시 실행 (그냥 `@EventListener` 처럼) | publisher return **전**, 인라인 같은 스레드 | + +**해석**: 트랜잭션이 없으면 "commit 후" 기준점 자체가 없음 → 기본값 false 는 등록할 콜백 자리가 +없어 침묵. 트랜잭션 없는 경로(인증/조회)에서도 리스너를 재사용하려면 `true` 명시 필요. + +### 2-5 — BEFORE_COMMIT 같은 트랜잭션 쓰기 + AFTER_COMMIT DB쓰기 함정 (REQUIRES_NEW) + +`settle()` → `PaymentSettledEvent`. ReceiptListener(BEFORE_COMMIT)=receipt INSERT, +PointListener(AFTER_COMMIT)=point INSERT. + +| 실험 | 결과 | +|---|---| +| ReceiptListener BEFORE_COMMIT | receipt 행 = 1 — 본 트랜잭션과 **같이 commit** (같은 DB 쓰기는 BEFORE_COMMIT 자연) | +| PointListener AFTER_COMMIT, **REQUIRES_NEW 없이** | point 행 = **1** (내 H2 에선 유실 안 됨) | +| PointListener AFTER_COMMIT, **REQUIRES_NEW 있음** | point 행 = 1 (안정) | + +**★ 핵심 발견 — "되던데?"가 제일 위험한 함정**: +- 내 환경(H2 2.2.224 + Boot 3.2 JDBC)에선 REQUIRES_NEW 없이도 point=1 로 살아남음. +- **왜 살아남나(메커니즘)**: 본 트랜잭션 begin 시 커넥션 `autoCommit` 을 true→false 로 바꿔둠. + AFTER_COMMIT 리스너가 아직 바인딩된 같은 커넥션으로 INSERT(묶일 트랜잭션 없음). + 이후 cleanup 이 `autoCommit` 을 false→true 로 복구하는데, **JDBC 스펙상 `setAutoCommit(true)` 는 + pending 트랜잭션을 commit** → orphan INSERT 가 **우발적으로** commit 됨. +- **그래서 REQUIRES_NEW 여전히 필수**: + - 이식성 없음 — 드라이버/풀/버전의 setAutoCommit 구현에 기댄 commit. PostgreSQL/다른 풀이면 유실 가능. + - JPA 면 그냥 유실 — `persist()` 는 staging 만, flush 는 이미 지난 commit 시점 → autoCommit 트릭으로도 안 살아남. + - 관리 안 되는 "유령 commit" — 롤백 불가, 트랜잭션 경계 없음. +- 결론: AFTER_COMMIT DB 쓰기엔 `@Transactional(propagation=REQUIRES_NEW)` 명시 → 새 트랜잭션의 + 자기 commit 으로 환경 무관 안전. + +> 면접 차별점: 대부분 "0 나옵니다"만 앎. "1 나와도 위험한 이유(우발적 commit·비이식성·JPA 유실)"까지가 답. + + + +## STAGE 3 — `@Async` 비동기 + +### 자동 누적 로그 + +- [06-11 01:34] s3-1 · publisher 블록 시간 = 511ms +- [06-11 01:35] s3-1 · publisher 블록 시간 = 4ms +- [06-11 01:37] s3-3 · self-invocation — this.asyncMethod() 는 @Async 무시(동기) +- [06-11 01:40] s3-6 · @Async + AFTER_COMMIT 새 스레드 — 트랜잭션 컨텍스트 유무 +- [06-11 01:40] s3-6 · @Async + AFTER_COMMIT 새 스레드 — 트랜잭션 컨텍스트 유무 + +### 3-1/3-2 — 동기 vs @Async + +리스너 안에 `Thread.sleep(500)`. publisher 시간을 `System.nanoTime()` 으로 측정. + +| 모드 | publisher 총 시간 | `[slow]` 스레드 | +|---|---|---| +| 동기 `@EventListener` | **511ms** | main | +| `@Async` | **4ms** | task-N | + +**해석**: 동기는 리스너가 publisher 와 같은 스레드 → 500ms 가 그대로 더해짐(511). `@Async` 는 +리스너를 별 스레드(task-N)로 던지고 publisher 즉시 반환(4ms) → 리스너 처리시간과 **디커플**. +HTTP 요청 처리면 응답시간에 직결되므로 비동기가 치명적 차이. + +### 3-3 — self-invocation 함정 (5주차 @Transactional 회수) + +`place()` 안에서 `this.asyncNotify()` 호출 → `[asyncNotify] thread=main` (비동기 무시, 동기 실행). + +- **메커니즘**: `@Async` advice 는 프록시에 있음. `this` 는 원본 target(프록시 아님) → 프록시 우회 → advice 안 걸림. 5주차 `@Transactional` self-invocation 과 동일. +- **해결**: (a) self 주입 / (b) 클래스 분리 / (c) ★ 이벤트 발행 → 별도 `@Async` 리스너가 받음 + (publishEvent 는 멀티캐스터→리스너 프록시 경유라 `@Async` 적용 + 자연 분리. 가장 6주차다운 해법). +- 비동기 도구(가상 스레드 포함)가 바뀌어도 프록시 이슈라 그대로 남음. + +### 3-6 — @Async + AFTER_COMMIT 새 스레드 함정 (REQUIRES_NEW) + +`settle()`(main, tx) → 발행 → `@Async @TransactionalEventListener(AFTER_COMMIT)` 리스너. + +| 리스너 트랜잭션 | thread | actualTxActive | syncActive | +|---|---|---|---| +| REQUIRES_NEW 없음 | task-1 | **false** | **false** | +| `REQUIRES_NEW` 있음 | task-1 | **true** | **true** | + +**해석**: `@Async` 가 AFTER_COMMIT 콜백을 별 스레드(task-1)로 던짐 → `TransactionSynchronizationManager` +(ThreadLocal, 5주차 회수)가 그 스레드엔 안 따라옴 → 트랜잭션 컨텍스트 없음(false/false). +JPA 면 영속성 컨텍스트도 없어 Lazy 로딩 시 `LazyInitializationException`. `REQUIRES_NEW` 가 새 스레드에서 +새 트랜잭션을 열어 컨텍스트 확보(true/true). → `@Async + AFTER_COMMIT` 에서 DB/영속성 만지면 REQUIRES_NEW 필수. +2-5 의 REQUIRES_NEW 함정 + 새 스레드 ThreadLocal 손실이 겹치는 자리. 7주차 JPA 단골. + +### 3-4 / 3-5 / 3-7 — 개념 정리 (3-7 만 실측) + +**3-4 스레드풀 함정**: Boot 자동 `applicationTaskExecutor` 기본 = core=8 / queue=MAX / max=MAX. +- `ThreadPoolExecutor` 증설 규칙: core 채움 → **큐 채움** → 큐 다 차야 max 증설 → max 차면 RejectedHandler. +- 큐가 무제한이라 영원히 안 참 → max 증설 불가 → **사실상 8개 고정 + 무한 큐**. 직접 설정 시 queue 유한값(예 100)으로 막아야 max 증설이 진짜 일어남. +- 멀티캐스터 전역 비동기 vs `@Async`: 전역은 모든 리스너 비동기 + phase 콜백 ThreadLocal 동기화와 어긋남 → **per-listener `@Async` 가 정답**. + +**3-5 @Async 예외**: 반환 `void` → 예외 **조용히 소멸**(publisher 전파 X, 1-3 동기와 정반대). +`Future` → `get()` 시 `ExecutionException`. `AsyncUncaughtExceptionHandler` 빈으로 void 예외도 포착 가능. + +**3-7 Virtual Thread** (실측): `spring.threads.virtual.enabled=true` 한 줄 → 자동 executor 가상 스레드化. +- 실측 출력: `[slow] start — thread=task-1 virtual=true` — **이름은 task-1 그대로인데 `isVirtual()=true`**. + → 이름으론 platform/virtual 구분 불가, `Thread.currentThread().isVirtual()` boolean 으로만 확인. +- I/O 바운드면 풀 튜닝(core/max/queue) 고민 소멸(가상 스레드 거의 무제한 생성, 블로킹이 캐리어 점유 X). +- self-invocation 함정은 가상 스레드와 **직교**(프록시 이슈라 그대로). 커스텀 executor 등록 stage 면 자동설정 덮여 isVirtual()=false. + + + +## STAGE 4 — AOP vs Event 통합 + +코드 실행 없는 설계/결정 단계. 5주차 권한검증(AOP) vs 6주차 결제(Event) 대비로 결정 기준 도출. + +### 4-1 — 5주차 AuthAspect 한 aspect 안에 두 관심사 + +`AuthAspect.@Before` 안에 (A)+(B) 가 섞여 있음: +- **(A) `[AUTH] ...` 출력** = 접근 시도 기록(부수효과) +- **(B) `throw AccessDeniedException`** = 권한 없으면 메서드 차단(게이트) + +**(B) 게이트는 이벤트로 이관 불가** — 3가지 이유: +1. 본문 실행 **전**에 끼어들어야 함 (이벤트는 본문 실행 중에야 `publishEvent` 도달) +2. **veto(거부)** 가능해야 함 — 리스너는 publisher 메서드를 막을 수 없음 +3. **동기**여야 함 +→ AOP `@Before` 가 셋 다 충족(메서드 앞 + throw 시 proceed 차단). 게이트는 AOP 고정. + +**거부(denied)는 어떤 phase 로도 못 잡음** — `@Order(1)` AuthAspect 가 최외곽이라 거부 시 +본문 진입 X → 트랜잭션 시작 X → `publishEvent` 도달 X → 이벤트 0개. `AFTER_ROLLBACK` 도 안 불림. +- ★ 구분: **"권한 거부"**(본문 진입 X, 이벤트 불가) ≠ **"권한 통과 후 작업 실패=rollback"**(`AFTER_ROLLBACK` 으로 잡힘). +- → 거부 포함 접근 감사는 게이트 자리(AOP)에만 기록 가능. + +### 4-2 — 결정 매트릭스 + +| 관심사 | AOP / Event | 왜 | +|---|---|---| +| 권한 게이트(차단) | **AOP** | 본문 전 veto + 동기 | +| 거부 포함 접근 감사 | **AOP** | 거부는 본문 진입 X → 이벤트 0개 | +| 권한 통과 후 작업 실패 보상 | **Event** (`AFTER_ROLLBACK`) | 인가됐고 트랜잭션이 돌다 실패한 케이스 | +| 결제 후 영수증/포인트/알림 | **Event** | 인가·성공한 사건이 여러 모듈로 퍼짐 | + +**★ 한 줄 결론**: 메서드 호출을 **가로채 제어**(intercept/wrap/veto)해야 하면 **AOP**, +한 사건의 결과가 **여러 모듈로 퍼져나가야**(fan-out) 하면 **Event**. +- AOP = 메서드와 한 몸, 실행 흐름에 끼어듦. publisher와 결합. +- Event = 끝난 "사실"을 발행, publisher 는 구독자를 모름(decoupled), 책임이 다른 모듈로 분기. +- 두 축은 직교 — 한 메서드에 `@DistributedLock`/`@Transactional`/`@Audited`(AOP, advice 안-밖) + + `publishEvent`(Event, 시간축 phase) 동시 사용 가능. diff --git a/topics/06-event/members/sujin/src/main/java/infra/MeasurementLog.java b/topics/06-event/members/sujin/src/main/java/infra/MeasurementLog.java new file mode 100644 index 0000000..144b1c2 --- /dev/null +++ b/topics/06-event/members/sujin/src/main/java/infra/MeasurementLog.java @@ -0,0 +1,86 @@ +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; + +/** + * 측정 / 관찰 결과를 본인 폴더의 measurements.md 에 자동 누적 기록. + * (1~3 주차 example 과 동일한 자동 위치 감지 패턴) + * + *

자동 위치 감지

+ * IntelliJ Working Directory 설정과 무관하게, 이 클래스의 실제 위치에서 + * 위로 올라가며 build.gradle 가진 폴더를 찾아 그 안에 measurements.md 를 만든다. + * + *

6 주차(Spring Event) 형식

+ * 동시성 측정(누락/실패/ms)이 아니라 자유 텍스트 관찰이 많으므로 {@code save(stage, note)} 사용. + *
+ *   MeasurementLog.save("s1", "HelloEvent 발행 → 동기 리스너 호출 순서 확인 (thread=main)");
+ *   // → measurements.md 에 "- [06-08 14:00] s1 · HelloEvent 발행 → ..." 한 줄 append
+ * 
+ * 시간 측정이 필요한 STAGE 3(@Async) 에선 {@code save(stage, note, millis)} 로 ms 도 기록. + */ +public final class MeasurementLog { + + private static final Path FILE = resolveFile(); + private static final DateTimeFormatter TIME = DateTimeFormatter.ofPattern("MM-dd HH:mm"); + + private MeasurementLog() {} + + /** 자유 텍스트 관찰 (STAGE 1~2 주로 사용). */ + public static void save(String stage, String note) { + String line = String.format("- [%s] %s · %s%n", + LocalDateTime.now().format(TIME), stage, note); + append(line); + } + + /** 시간 측정 포함 (STAGE 3 @Async — publisher 블록 시간 등). */ + public static void save(String stage, String note, double millis) { + String line = String.format("- [%s] %s · %s (%.1fms)%n", + LocalDateTime.now().format(TIME), stage, note, millis); + append(line); + } + + private static void append(String line) { + try { + if (!Files.exists(FILE)) { + Files.writeString(FILE, + "# 측정 기록 (6주차 — Spring Event)\n\n자동 누적. 옆에 해석 메모는 직접 추가하세요.\n\n"); + } + Files.writeString(FILE, line, StandardOpenOption.APPEND); + System.out.println("→ " + FILE.toAbsolutePath() + " 에 기록됨"); + } catch (IOException e) { + System.err.println("⚠️ measurements.md 기록 실패: " + e.getMessage()); + } + } + + 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")) + || Files.exists(current.resolve("build.gradle.kts"))) { + return current.resolve("measurements.md"); + } + current = current.getParent(); + } + } catch (Exception ignored) {} + return Path.of("measurements.md"); + } +} diff --git a/topics/06-event/members/sujin/src/main/java/stage/s1/Stage1HelloEvent.java b/topics/06-event/members/sujin/src/main/java/stage/s1/Stage1HelloEvent.java new file mode 100644 index 0000000..e107c2f --- /dev/null +++ b/topics/06-event/members/sujin/src/main/java/stage/s1/Stage1HelloEvent.java @@ -0,0 +1,56 @@ +package stage.s1; + +import infra.MeasurementLog; +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.context.event.EventListener; +import org.springframework.stereotype.Component; +import org.springframework.stereotype.Service; + +// (1) 이벤트 — record 로 충분 (Spring 4.2+). 완성본. +record HelloEvent(String message) {} + +// (2) Publisher — ApplicationEventPublisher 주입 +@Service +class HelloService { + private final ApplicationEventPublisher publisher; + + HelloService(ApplicationEventPublisher publisher) { + this.publisher = publisher; + } + + void sayHello(String name) { + System.out.println("[publisher] sayHello — " + name + + " / thread=" + Thread.currentThread().getName()); + + publisher.publishEvent(new HelloEvent("hello " + name)); + + System.out.println("[publisher] return" + + " / thread=" + Thread.currentThread().getName()); + } +} + +// (3) Listener — @EventListener 메서드 1 개 +@Component +class HelloListener { + + @EventListener + void onHello(HelloEvent event) { + String message = event.message(); + + System.out.println("[listener] received — " + message + " / threads=" + Thread.currentThread().getName()); + } +} + +// (4) main +@SpringBootApplication +public class Stage1HelloEvent { + public static void main(String[] args) { + var ctx = SpringApplication.run(Stage1HelloEvent.class, args); + ctx.getBean(HelloService.class).sayHello("world"); + + MeasurementLog.save("s1-1", "HelloEvent 발행 → 동기 리스너 호출 순서 println 확인"); + + } +} diff --git a/topics/06-event/members/sujin/src/main/java/stage/s1/Stage1_2_Order.java b/topics/06-event/members/sujin/src/main/java/stage/s1/Stage1_2_Order.java new file mode 100644 index 0000000..7bbd1e6 --- /dev/null +++ b/topics/06-event/members/sujin/src/main/java/stage/s1/Stage1_2_Order.java @@ -0,0 +1,66 @@ +package stage.s1; + +import infra.MeasurementLog; +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.context.event.EventListener; +import org.springframework.core.annotation.Order; +import org.springframework.stereotype.Component; +import org.springframework.stereotype.Service; + +// 1-2 전용 이벤트 — 1-1 의 HelloEvent 와 분리해서 리스너 교차 발화 방지. +// (payload 타입이 곧 라우팅 키 — 1-5 에서 다시 나올 개념) +record OrderDemoEvent(String message) {} + +@Service +class OrderDemoPublisher { + private final ApplicationEventPublisher publisher; + + OrderDemoPublisher(ApplicationEventPublisher publisher) { + this.publisher = publisher; + } + + void publish() { + System.out.println("[publisher] publish"); + publisher.publishEvent(new OrderDemoEvent("hi")); + System.out.println("[publisher] return"); + } +} + +@Component +class ListenerA { + @EventListener + @Order(2) + void on(OrderDemoEvent e) { + System.out.println("[A] " + e.message()); + } +} + +@Component +class ListenerB { + @EventListener + @Order(1) + void on(OrderDemoEvent e) { + System.out.println("[B] " + e.message()); + } +} + +@Component +class ListenerC { + @EventListener + @Order(0) + void on(OrderDemoEvent e) { + System.out.println("[C] " + e.message()); + } +} + +@SpringBootApplication +public class Stage1_2_Order { + public static void main(String[] args) { + var ctx = SpringApplication.run(Stage1_2_Order.class, args); + ctx.getBean(OrderDemoPublisher.class).publish(); + + MeasurementLog.save("s1-2", "@Order로 리스너 호출 순서 제어 확인 (작은 값 먼저)"); + } +} diff --git a/topics/06-event/members/sujin/src/main/java/stage/s1/Stage1_3_Exception.java b/topics/06-event/members/sujin/src/main/java/stage/s1/Stage1_3_Exception.java new file mode 100644 index 0000000..3ae037c --- /dev/null +++ b/topics/06-event/members/sujin/src/main/java/stage/s1/Stage1_3_Exception.java @@ -0,0 +1,71 @@ +package stage.s1; + +import infra.MeasurementLog; +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.context.event.EventListener; +import org.springframework.core.annotation.Order; +import org.springframework.stereotype.Component; +import org.springframework.stereotype.Service; + +record ExceptionDemoEvent(String message) {} + +@Service +class ExceptionDemoPublisher { + private final ApplicationEventPublisher publisher; + + ExceptionDemoPublisher(ApplicationEventPublisher publisher) { + this.publisher = publisher; + } + + void publish() { + System.out.println("[publisher] publish"); + publisher.publishEvent(new ExceptionDemoEvent("hi")); + System.out.println("[publisher] return"); // ← 이 줄이 찍히는지 관찰 포인트 + } +} + +@Component +class FirstListener { + @EventListener + @Order(1) + void on(ExceptionDemoEvent e) { + System.out.println("[L1 order=1] received"); + } +} + +@Component +class FailingListener { + @EventListener + @Order(2) + void on(ExceptionDemoEvent e) { + System.out.println("[L2 order=2] received — throwing"); + + throw new RuntimeException("일부러 실패"); + } +} + +@Component +class ThirdListener { + @EventListener + @Order(3) + void on(ExceptionDemoEvent e) { + System.out.println("[L3 order=3] received"); // ← 이게 찍히나? 관찰 포인트 + } +} + +@SpringBootApplication +public class Stage1_3_Exception { + public static void main(String[] args) { + var ctx = SpringApplication.run(Stage1_3_Exception.class, args); + + try { + ctx.getBean(ExceptionDemoPublisher.class).publish(); + } catch (Exception e) { + System.out.println("[main] caught — " + e.getMessage()); // ← 전파됐나? 관찰 포인트 + } + + MeasurementLog.save("s1-3", "동기 기준동작: 예외 전파 O, 체인 중단 O — STAGE3 @Async 면 전파 X 예정"); + } +} diff --git a/topics/06-event/members/sujin/src/main/java/stage/s1/Stage1_4_OldStyle.java b/topics/06-event/members/sujin/src/main/java/stage/s1/Stage1_4_OldStyle.java new file mode 100644 index 0000000..495c271 --- /dev/null +++ b/topics/06-event/members/sujin/src/main/java/stage/s1/Stage1_4_OldStyle.java @@ -0,0 +1,66 @@ +package stage.s1; + +import infra.MeasurementLog; +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.context.ApplicationEvent; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.context.ApplicationListener; +import org.springframework.context.event.EventListener; +import org.springframework.stereotype.Component; +import org.springframework.stereotype.Service; + +class LegacyDemoEvent extends ApplicationEvent { + private final String message; + + LegacyDemoEvent(Object source, String message) { + super(source); // ApplicationEvent는 source(발행 주체) 필수 + this.message = message; + } + + String message() { return message; } +} + +@Service +class LegacyDemoPublisher { + private final ApplicationEventPublisher publisher; + + LegacyDemoPublisher(ApplicationEventPublisher publisher) { + this.publisher = publisher; + } + + void publish() { + System.out.println("[publisher] publish"); + publisher.publishEvent(new LegacyDemoEvent(this, "hi")); + System.out.println("[publisher] return"); + } +} + +// (A) 옛 방식 — ApplicationListener 인터페이스 직접 구현 +@Component +class OldStyleListener implements ApplicationListener { + @Override + public void onApplicationEvent(LegacyDemoEvent event) { + System.out.println("[OldStyle] " + event.message()); + } +} + +// (B) 비교용 — 같은 이벤트를 @EventListener 로 받기 (완성 제공) +@Component +class AnnotationStyleListener { + @EventListener + void on(LegacyDemoEvent e) { + System.out.println("[Annotation] " + e.message()); + } +} + +@SpringBootApplication +public class Stage1_4_OldStyle { + public static void main(String[] args) { + var ctx = SpringApplication.run(Stage1_4_OldStyle.class, args); + ctx.getBean(LegacyDemoPublisher.class).publish(); + + MeasurementLog.save("s1-4", "ApplicationListener vs @EventListener — 동작 동일(같은 메커니즘), 단\n" + + " 인터페이스는 클래스당 이벤트 1개 고정"); + } +} diff --git a/topics/06-event/members/sujin/src/main/java/stage/s1/Stage1_5_PayloadOnly.java b/topics/06-event/members/sujin/src/main/java/stage/s1/Stage1_5_PayloadOnly.java new file mode 100644 index 0000000..a82dbab --- /dev/null +++ b/topics/06-event/members/sujin/src/main/java/stage/s1/Stage1_5_PayloadOnly.java @@ -0,0 +1,59 @@ +package stage.s1; + +import infra.MeasurementLog; +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.context.event.EventListener; +import org.springframework.stereotype.Component; +import org.springframework.stereotype.Service; + +// payload-only — ApplicationEvent 상속 없는 그냥 record (Spring 4.2+) +record PayloadDemoEvent(String message) {} + +@Service +class PayloadDemoPublisher { + private final ApplicationEventPublisher publisher; + + PayloadDemoPublisher(ApplicationEventPublisher publisher) { + this.publisher = publisher; + } + + void publishAll() { + System.out.println("[publisher] publish String"); + publisher.publishEvent("그냥 발행"); + + System.out.println("[publisher] publish record"); + publisher.publishEvent(new PayloadDemoEvent("record 발행")); + + System.out.println("[publisher] return"); + } +} + +// (A) String 을 직접 받는 리스너 — payload 타입이 String +@Component +class StringPayloadListener { + @EventListener + void on(String message) { + System.out.println("[String payload] " + message); + } +} + +// (B) record 를 받는 리스너 +@Component +class RecordPayloadListener { + @EventListener + void on(PayloadDemoEvent e) { + System.out.println("[Record payload] " + e.message()); + } +} + +@SpringBootApplication +public class Stage1_5_PayloadOnly { + public static void main(String[] args) { + var ctx = SpringApplication.run(Stage1_5_PayloadOnly.class, args); + ctx.getBean(PayloadDemoPublisher.class).publishAll(); + + MeasurementLog.save("s1-5", "payload-only — ApplicationEvent 상속없이 String/record 발행, 타입으로 매칭 (내부 PayloadApplicationEvent 래핑)"); + } +} diff --git a/topics/06-event/members/sujin/src/main/java/stage/s2/Stage2_1_NaiveEventListener.java b/topics/06-event/members/sujin/src/main/java/stage/s2/Stage2_1_NaiveEventListener.java new file mode 100644 index 0000000..45ce3a9 --- /dev/null +++ b/topics/06-event/members/sujin/src/main/java/stage/s2/Stage2_1_NaiveEventListener.java @@ -0,0 +1,90 @@ +package stage.s2; + +import infra.MeasurementLog; +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.context.event.EventListener; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Component; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.transaction.event.TransactionPhase; +import org.springframework.transaction.event.TransactionalEventListener; + +import java.math.BigDecimal; + +// ───────────────────────────────────────────────────────────── +// STAGE 2-1 — 순진한 버전: 그냥 @EventListener (일부러 함정 재현) +// +// 목표: 트랜잭션이 롤백돼도 리스너가 "이미" 실행돼버리는 걸 직접 본다. +// 5주차 advice 안-밖(@Audited 가 commit 전 실행)의 한계를 코드로 재현하는 자리. +// → 함정을 눈으로 본 뒤, 2-1 Step 3 에서 @TransactionalEventListener 로 고친다. +// ───────────────────────────────────────────────────────────── + +// (1) 이벤트 — 과거형 이름, payload-only record. 완성본. +record PaymentCompletedEvent(Long paymentId, BigDecimal amount) {} + +// (2) Publisher — JdbcTemplate(INSERT) + ApplicationEventPublisher(발행) +@Service +class PaymentService { + private final JdbcTemplate jdbc; + private final ApplicationEventPublisher publisher; + + PaymentService(JdbcTemplate jdbc, ApplicationEventPublisher publisher) { + this.jdbc = jdbc; + this.publisher = publisher; + } + + @Transactional + void complete(Long paymentId, BigDecimal amount) { + jdbc.update("INSERT INTO payment(id, amount) VALUES (?, ?)", paymentId, amount); + + // 결제 완료 이벤트 발행 + // 이 한 줄이 "명시적 발행" — AOP 가 자동으로 가로채던 것과 대비된다. + publisher.publishEvent(new PaymentCompletedEvent(paymentId, amount)); + + // 금액이 음수면 예외를 던져 롤백을 유발 + // INSERT 는 이미 했고 이벤트도 이미 발행한 "후"라는 게 함정의 핵심 + if (amount.compareTo(BigDecimal.ZERO) < 0) { + throw new IllegalArgumentException("유효하지 않은 금액 (음수)"); + } + } +} + +// (3) Listener — 순진한 @EventListener (트랜잭션 무관). 이게 함정. +@Component +class NaiveNotificationListener { + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) + void on(PaymentCompletedEvent e) { + // 실제로는 이메일/슬랙/SMS — 한 번 전송하면 회수 불가 + System.out.println("[알림] 결제 완료 전송 — id=" + e.paymentId() + " / amount=" + e.amount()); + } +} + +// (4) main — 정상 케이스 1번 + 롤백 케이스 1번 실행하고 payment 행 수 확인 +@SpringBootApplication +public class Stage2_1_NaiveEventListener { + public static void main(String[] args) { + var ctx = SpringApplication.run(Stage2_1_NaiveEventListener.class, args); + var svc = ctx.getBean(PaymentService.class); + var jdbc = ctx.getBean(JdbcTemplate.class); + + System.out.println("=== 정상 케이스: complete(1, 100) ==="); + svc.complete(1L, BigDecimal.valueOf(100)); + + System.out.println("\n=== 롤백 케이스: complete(2, -100) ==="); + try { + svc.complete(2L, BigDecimal.valueOf(-100)); + } catch (Exception ex) { + System.out.println("(rollback) " + ex.getClass().getSimpleName() + ": " + ex.getMessage()); + } + + // payment 테이블에 실제로 몇 행이 남았는지 확인. + // 기대: 롤백됐으니 1행. 하지만 위 알림은 2번 출력됐을 것이다 — 그게 함정. + Integer count = jdbc.queryForObject("SELECT COUNT(*) FROM payment", Integer.class); + System.out.println("\npayment 행 수 = " + count + " (기대 1)"); + + MeasurementLog.save("s2-1", "순진한 @EventListener — 롤백돼도 알림 이미 전송됨(함정 재현)"); + } +} diff --git a/topics/06-event/members/sujin/src/main/java/stage/s2/Stage2_2_AllPhases.java b/topics/06-event/members/sujin/src/main/java/stage/s2/Stage2_2_AllPhases.java new file mode 100644 index 0000000..85b5d58 --- /dev/null +++ b/topics/06-event/members/sujin/src/main/java/stage/s2/Stage2_2_AllPhases.java @@ -0,0 +1,92 @@ +package stage.s2; + +import infra.MeasurementLog; +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Component; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.transaction.event.TransactionPhase; +import org.springframework.transaction.event.TransactionalEventListener; + +import java.math.BigDecimal; + +// ───────────────────────────────────────────────────────────── +// STAGE 2-2 — 4 phase 를 한 이벤트에 다 붙여서 commit/rollback 매트릭스 관찰 +// +// 한 번의 complete() 가 발행한 이벤트 하나를 4개 phase 리스너가 받는다. +// 정상 commit 일 때 / 롤백일 때 각각 어떤 phase 가 찍히는지 출력으로 확인. +// +// ※ 2-1 의 PaymentCompletedEvent 와 "다른" 이벤트 타입을 써서 +// 2-1 리스너(NaiveNotificationListener)와 교차발화 차단. +// ───────────────────────────────────────────────────────────── + +record PaymentPhaseEvent(Long paymentId, BigDecimal amount) {} + +@Service +class PaymentPhaseService { + private final JdbcTemplate jdbc; + private final ApplicationEventPublisher publisher; + + PaymentPhaseService(JdbcTemplate jdbc, ApplicationEventPublisher publisher) { + this.jdbc = jdbc; + this.publisher = publisher; + } + + @Transactional + void complete(Long paymentId, BigDecimal amount) { + jdbc.update("INSERT INTO payment(id, amount) VALUES (?, ?)", paymentId, amount); + System.out.println("[publisher] publishEvent — id=" + paymentId); + publisher.publishEvent(new PaymentPhaseEvent(paymentId, amount)); + + if (amount.compareTo(BigDecimal.ZERO) < 0) { + throw new IllegalArgumentException("유효하지 않은 금액 (음수)"); + } + } +} + +@Component +class AllPhaseListener { + + @TransactionalEventListener(phase = TransactionPhase.BEFORE_COMMIT) + void onBeforeCommit(PaymentPhaseEvent e) { + System.out.println(" [BEFORE_COMMIT] id=" + e.paymentId()); + } + + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) + void onAfterCommit(PaymentPhaseEvent e) { + System.out.println(" [AFTER_COMMIT] id=" + e.paymentId()); + } + + @TransactionalEventListener(phase = TransactionPhase.AFTER_ROLLBACK) + void onAfterRollback(PaymentPhaseEvent e) { + System.out.println(" [AFTER_ROLLBACK] id=" + e.paymentId()); + } + + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMPLETION) + void onAfterCompletion(PaymentPhaseEvent e) { + System.out.println(" [AFTER_COMPLETION] id=" + e.paymentId()); + } +} + +@SpringBootApplication +public class Stage2_2_AllPhases { + public static void main(String[] args) { + var ctx = SpringApplication.run(Stage2_2_AllPhases.class, args); + var svc = ctx.getBean(PaymentPhaseService.class); + + System.out.println("=== 정상 케이스: complete(1, 100) ==="); + svc.complete(1L, BigDecimal.valueOf(100)); + + System.out.println("\n=== 롤백 케이스: complete(2, -100) ==="); + try { + svc.complete(2L, BigDecimal.valueOf(-100)); + } catch (Exception ex) { + System.out.println("(rollback) " + ex.getClass().getSimpleName()); + } + + MeasurementLog.save("s2-2", "4 phase 매트릭스 — commit/rollback 별 호출 phase 관찰"); + } +} diff --git a/topics/06-event/members/sujin/src/main/java/stage/s2/Stage2_4_FallbackExecution.java b/topics/06-event/members/sujin/src/main/java/stage/s2/Stage2_4_FallbackExecution.java new file mode 100644 index 0000000..63ea19a --- /dev/null +++ b/topics/06-event/members/sujin/src/main/java/stage/s2/Stage2_4_FallbackExecution.java @@ -0,0 +1,60 @@ +package stage.s2; + +import infra.MeasurementLog; +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.stereotype.Component; +import org.springframework.stereotype.Service; +import org.springframework.transaction.event.TransactionPhase; +import org.springframework.transaction.event.TransactionalEventListener; + +// ───────────────────────────────────────────────────────────── +// STAGE 2-4 — fallbackExecution: 트랜잭션 "밖"에서 publishEvent 하면? +// +// PaymentAuditService.recordAttempt() 는 @Transactional 이 없다(검증/조회처럼 +// DB 트랜잭션 없는 작업). 이 상태에서 @TransactionalEventListener 가 어떻게 되나: +// - fallbackExecution=false (기본) → 등록할 트랜잭션이 없어 "조용히 무시" +// - fallbackExecution=true → 트랜잭션 없으면 즉시 실행 (그냥 @EventListener 처럼) +// +// ※ 2-1/2-2/2-5 와 다른 이벤트 타입으로 교차발화 차단. +// ───────────────────────────────────────────────────────────── + +record PaymentAuditEvent(Long paymentId) {} + +@Service +class PaymentAuditService { + private final ApplicationEventPublisher publisher; + PaymentAuditService(ApplicationEventPublisher publisher) { this.publisher = publisher; } + + // ★ @Transactional 없음 — 트랜잭션 밖에서 발행하는 케이스 + void recordAttempt(Long paymentId) { + System.out.println("[publisher] recordAttempt — id=" + paymentId + " (트랜잭션 없음)"); + publisher.publishEvent(new PaymentAuditEvent(paymentId)); + System.out.println("[publisher] return"); + } +} + +@Component +class PaymentAuditListener { + + // 1차로 아래 그대로(fallbackExecution 안 줌 = 기본 false) 돌려 리스너가 안 불리는지(조용히 무시) 확인. + // 그 다음 fallbackExecution = true 를 추가하고 다시 돌려 즉시 실행되는지 비교. + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT, fallbackExecution = true) + void onAudit(PaymentAuditEvent e) { + System.out.println(" [감사] 결제 시도 기록 — id=" + e.paymentId()); + } +} + +@SpringBootApplication +public class Stage2_4_FallbackExecution { + public static void main(String[] args) { + var ctx = SpringApplication.run(Stage2_4_FallbackExecution.class, args); + var svc = ctx.getBean(PaymentAuditService.class); + + System.out.println("=== 트랜잭션 밖에서 recordAttempt(1) ==="); + svc.recordAttempt(1L); + + MeasurementLog.save("s2-4", "트랜잭션 밖 publishEvent — fallbackExecution false/true 비교"); + } +} diff --git a/topics/06-event/members/sujin/src/main/java/stage/s2/Stage2_5_BeforeCommitAndTrap.java b/topics/06-event/members/sujin/src/main/java/stage/s2/Stage2_5_BeforeCommitAndTrap.java new file mode 100644 index 0000000..74691ff --- /dev/null +++ b/topics/06-event/members/sujin/src/main/java/stage/s2/Stage2_5_BeforeCommitAndTrap.java @@ -0,0 +1,94 @@ +package stage.s2; + +import infra.MeasurementLog; +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Component; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.transaction.event.TransactionPhase; +import org.springframework.transaction.event.TransactionalEventListener; + +import java.math.BigDecimal; + +// ───────────────────────────────────────────────────────────── +// STAGE 2-5 — BEFORE_COMMIT(같은 트랜잭션 쓰기) + AFTER_COMMIT DB쓰기 함정 +// +// 실험 1: ReceiptListener(BEFORE_COMMIT) 가 receipt 테이블에 INSERT. +// 본 트랜잭션과 같이 commit 되는지 확인 (행 수로). +// 실험 2: PointListener(AFTER_COMMIT) 가 point 테이블에 INSERT. +// REQUIRES_NEW 없이 → 트랜잭션이 이미 끝난 시점이라 조용히 유실될 수 있음. +// → 함정 확인 후 REQUIRES_NEW 로 고친다. +// +// ※ 2-1/2-2 와 다른 이벤트 타입(PaymentSettledEvent)으로 교차발화 차단. +// ───────────────────────────────────────────────────────────── + +record PaymentSettledEvent(Long paymentId, BigDecimal amount) {} + +@Service +class PaymentSettleService { + private final JdbcTemplate jdbc; + private final ApplicationEventPublisher publisher; + + PaymentSettleService(JdbcTemplate jdbc, ApplicationEventPublisher publisher) { + this.jdbc = jdbc; + this.publisher = publisher; + } + + @Transactional + void settle(Long paymentId, BigDecimal amount) { + jdbc.update("INSERT INTO payment(id, amount) VALUES (?, ?)", paymentId, amount); + publisher.publishEvent(new PaymentSettledEvent(paymentId, amount)); + } +} + +// 실험 1 — BEFORE_COMMIT: 같은 트랜잭션 안에서 영수증 INSERT +@Component +class ReceiptListener { + private final JdbcTemplate jdbc; + ReceiptListener(JdbcTemplate jdbc) { this.jdbc = jdbc; } + + @TransactionalEventListener(phase = TransactionPhase.BEFORE_COMMIT) + void issueReceipt(PaymentSettledEvent e) { + jdbc.update("INSERT INTO receipt(payment_id, issued_at) VALUES (?, CURRENT_TIMESTAMP)", e.paymentId()); + System.out.println(" [BEFORE_COMMIT] 영수증 발급 — id=" + e.paymentId()); + } +} + +// 실험 2 — AFTER_COMMIT: 포인트 적립 INSERT (함정 자리) +@Component +class PointListener { + private final JdbcTemplate jdbc; + PointListener(JdbcTemplate jdbc) { this.jdbc = jdbc; } + + // 처음엔 REQUIRES_NEW "없이" 그대로 돌려서 point 행이 남는지 확인. + // 유실(0행)이 재현되면, 이 메서드에 새 트랜잭션을 여는 어노테이션을 추가. + @Transactional(propagation = Propagation.REQUIRES_NEW) + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) + void accruePoint(PaymentSettledEvent e) { + jdbc.update("INSERT INTO point(payment_id, amount) VALUES (?, ?)", e.paymentId(), e.amount()); + System.out.println(" [AFTER_COMMIT] 포인트 적립 — id=" + e.paymentId()); + } +} + +@SpringBootApplication +public class Stage2_5_BeforeCommitAndTrap { + public static void main(String[] args) { + var ctx = SpringApplication.run(Stage2_5_BeforeCommitAndTrap.class, args); + var svc = ctx.getBean(PaymentSettleService.class); + var jdbc = ctx.getBean(JdbcTemplate.class); + + System.out.println("=== settle(1, 100) ==="); + svc.settle(1L, BigDecimal.valueOf(100)); + + Integer receiptCount = jdbc.queryForObject("SELECT COUNT(*) FROM receipt", Integer.class); + Integer pointCount = jdbc.queryForObject("SELECT COUNT(*) FROM point", Integer.class); + System.out.println("\nreceipt 행 수 = " + receiptCount + " (기대 1 — BEFORE_COMMIT 은 같은 트랜잭션)"); + System.out.println("point 행 수 = " + pointCount + " (REQUIRES_NEW 없으면 0 일 수 있음 = 함정)"); + + MeasurementLog.save("s2-5", "BEFORE_COMMIT 같은 트랜잭션 쓰기 + AFTER_COMMIT DB쓰기 함정/REQUIRES_NEW"); + } +} diff --git a/topics/06-event/members/sujin/src/main/java/stage/s3/Stage3_1_SyncVsAsync.java b/topics/06-event/members/sujin/src/main/java/stage/s3/Stage3_1_SyncVsAsync.java new file mode 100644 index 0000000..df67962 --- /dev/null +++ b/topics/06-event/members/sujin/src/main/java/stage/s3/Stage3_1_SyncVsAsync.java @@ -0,0 +1,61 @@ +package stage.s3; + +import infra.MeasurementLog; +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.context.event.EventListener; +import org.springframework.scheduling.annotation.Async; +import org.springframework.scheduling.annotation.EnableAsync; +import org.springframework.stereotype.Component; +import org.springframework.stereotype.Service; + +// ───────────────────────────────────────────────────────────── +// STAGE 3-1/3-2 — 동기의 한계 → @Async +// +// Run A: 아래 리스너 그대로(동기) → publisher 총 시간 ≈ 500ms (리스너 시간이 그대로 더해짐) +// Run B: 리스너에 @Async 한 줄 추가 → publisher 즉시 반환(≈ 수 ms), 리스너는 task-N 스레드 +// ───────────────────────────────────────────────────────────── + +record PaymentNotifyEvent(Long paymentId) {} + +@Service +class NotifyPublisher { + private final ApplicationEventPublisher publisher; + NotifyPublisher(ApplicationEventPublisher publisher) { this.publisher = publisher; } + + void publish(Long id) { + System.out.println("[publisher] publish — thread=" + Thread.currentThread().getName()); + publisher.publishEvent(new PaymentNotifyEvent(id)); + System.out.println("[publisher] return — thread=" + Thread.currentThread().getName()); + } +} + +@Component +class SlowNotifyListener { + @Async + @EventListener + void on(PaymentNotifyEvent e) throws InterruptedException { + System.out.println(" [slow] start — thread=" + Thread.currentThread().getName()); + Thread.sleep(500); // 외부 알림/이메일이 느리다고 가정 + System.out.println(" [slow] end"); + } +} + +@SpringBootApplication +@EnableAsync +public class Stage3_1_SyncVsAsync { + public static void main(String[] args) { + var ctx = SpringApplication.run(Stage3_1_SyncVsAsync.class, args); + var pub = ctx.getBean(NotifyPublisher.class); + + long t1 = System.nanoTime(); + pub.publish(1L); + long ms = (System.nanoTime() - t1) / 1_000_000; + System.out.println("publisher 총 시간: " + ms + "ms"); + + // 비동기(Run B)면 리스너가 아직 안 끝났을 수 있어 JVM 종료 전 잠깐 대기 + try { Thread.sleep(800); } catch (InterruptedException ignored) {} + + MeasurementLog.save("s3-1", "publisher 블록 시간 = " + ms + "ms"); } +} diff --git a/topics/06-event/members/sujin/src/main/java/stage/s3/Stage3_3_SelfInvocation.java b/topics/06-event/members/sujin/src/main/java/stage/s3/Stage3_3_SelfInvocation.java new file mode 100644 index 0000000..4566252 --- /dev/null +++ b/topics/06-event/members/sujin/src/main/java/stage/s3/Stage3_3_SelfInvocation.java @@ -0,0 +1,43 @@ +package stage.s3; + +import infra.MeasurementLog; +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.scheduling.annotation.Async; +import org.springframework.scheduling.annotation.EnableAsync; +import org.springframework.stereotype.Service; + +// ───────────────────────────────────────────────────────────── +// STAGE 3-3 — self-invocation 함정 (5주차 @Transactional 함정 회수) +// +// @Async 도 프록시 메커니즘. 같은 클래스 안에서 this.asyncMethod() 호출하면 +// 프록시를 우회 → 비동기 무시(동기 실행). 5주차 @Transactional self-invocation 과 동일. +// ───────────────────────────────────────────────────────────── + +@Service +class SelfInvokeService { + + @Async + public void asyncNotify(Long id) { + System.out.println(" [asyncNotify] thread=" + Thread.currentThread().getName()); + } + + public void place(Long id) { + System.out.println("[place] thread=" + Thread.currentThread().getName()); + // self-invocation: this 로 호출 → 프록시 우회 + asyncNotify(id); + } +} + +@SpringBootApplication +@EnableAsync +public class Stage3_3_SelfInvocation { + public static void main(String[] args) { + var ctx = SpringApplication.run(Stage3_3_SelfInvocation.class, args); + ctx.getBean(SelfInvokeService.class).place(1L); + + try { Thread.sleep(300); } catch (InterruptedException ignored) {} + + MeasurementLog.save("s3-3", "self-invocation — this.asyncMethod() 는 @Async 무시(동기)"); + } +} diff --git a/topics/06-event/members/sujin/src/main/java/stage/s3/Stage3_6_AsyncAfterCommit.java b/topics/06-event/members/sujin/src/main/java/stage/s3/Stage3_6_AsyncAfterCommit.java new file mode 100644 index 0000000..7b669fd --- /dev/null +++ b/topics/06-event/members/sujin/src/main/java/stage/s3/Stage3_6_AsyncAfterCommit.java @@ -0,0 +1,84 @@ +package stage.s3; + +import infra.MeasurementLog; +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.scheduling.annotation.Async; +import org.springframework.scheduling.annotation.EnableAsync; +import org.springframework.stereotype.Component; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.transaction.event.TransactionPhase; +import org.springframework.transaction.event.TransactionalEventListener; +import org.springframework.transaction.support.TransactionSynchronizationManager; + +import java.math.BigDecimal; + +// ───────────────────────────────────────────────────────────── +// STAGE 3-6 — @Async + AFTER_COMMIT = 완전히 새 스레드 함정 +// +// AFTER_COMMIT 콜백이 @Async 로 별 스레드에서 실행되면: +// - TransactionSynchronizationManager (ThreadLocal, 5주차 회수) → 날아감 +// - 영속성 컨텍스트(7주차 JPA) → 날아감 → Lazy 로딩 시 LazyInitializationException +// - 활성 트랜잭션 없음 +// +// Run A: REQUIRES_NEW 없이 → actualTxActive=false / syncActive=false (컨텍스트 없음) +// Run B: @Transactional(REQUIRES_NEW) 추가 → actualTxActive=true (새 트랜잭션) +// ───────────────────────────────────────────────────────────── + +record PaymentAsyncEvent(Long paymentId, BigDecimal amount) {} + +@Service +class PaymentAsyncService { + private final JdbcTemplate jdbc; + private final ApplicationEventPublisher publisher; + + PaymentAsyncService(JdbcTemplate jdbc, ApplicationEventPublisher publisher) { + this.jdbc = jdbc; + this.publisher = publisher; + } + + @Transactional + void settle(Long paymentId, BigDecimal amount) { + jdbc.update("INSERT INTO payment(id, amount) VALUES (?, ?)", paymentId, amount); + System.out.println("[settle] thread=" + Thread.currentThread().getName() + + " actualTxActive=" + TransactionSynchronizationManager.isActualTransactionActive()); + publisher.publishEvent(new PaymentAsyncEvent(paymentId, amount)); + } +} + +@Component +class AsyncAfterCommitListener { + private final JdbcTemplate jdbc; + AsyncAfterCommitListener(JdbcTemplate jdbc) { this.jdbc = jdbc; } + + @Transactional(propagation = Propagation.REQUIRES_NEW) + @Async + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) + void onAfterCommit(PaymentAsyncEvent e) { + System.out.println(" [async-after-commit] thread=" + Thread.currentThread().getName()); + System.out.println(" actualTxActive=" + TransactionSynchronizationManager.isActualTransactionActive()); + System.out.println(" syncActive=" + TransactionSynchronizationManager.isSynchronizationActive()); + jdbc.update("INSERT INTO point(payment_id, amount) VALUES (?, ?)", e.paymentId(), e.amount()); + System.out.println(" point INSERT 시도 완료"); + } +} + +@SpringBootApplication +@EnableAsync +public class Stage3_6_AsyncAfterCommit { + public static void main(String[] args) { + var ctx = SpringApplication.run(Stage3_6_AsyncAfterCommit.class, args); + var svc = ctx.getBean(PaymentAsyncService.class); + + System.out.println("=== settle(1, 100) ==="); + svc.settle(1L, BigDecimal.valueOf(100)); + + try { Thread.sleep(500); } catch (InterruptedException ignored) {} + + MeasurementLog.save("s3-6", "@Async + AFTER_COMMIT 새 스레드 — 트랜잭션 컨텍스트 유무"); + } +} diff --git a/topics/06-event/members/sujin/src/main/resources/application.properties b/topics/06-event/members/sujin/src/main/resources/application.properties new file mode 100644 index 0000000..019dd33 --- /dev/null +++ b/topics/06-event/members/sujin/src/main/resources/application.properties @@ -0,0 +1,18 @@ +# 6주차 STAGE 2 — 트랜잭션 결합용 H2 인메모리 datasource +# starter-jdbc + h2 가 classpath 에 있으면 DataSourceTransactionManager 자동 등록 +# → @Transactional 바로 동작 (@EnableTransactionManagement 불필요, Boot 가 자동) + +spring.datasource.url=jdbc:h2:mem:payment;DB_CLOSE_DELAY=-1 +spring.datasource.driver-class-name=org.h2.Driver +spring.datasource.username=sa +spring.datasource.password= + +# schema.sql 을 시작 시 항상 실행 (테이블 생성) +spring.sql.init.mode=always + +# 콘솔 앱 — println 관찰이 핵심이라 배너/로그 소음 제거 +spring.main.banner-mode=off +logging.level.root=WARN + +# commit/rollback 을 프레임워크 로그로 직접 보고 싶으면 아래 주석 해제 +# logging.level.org.springframework.jdbc.datasource.DataSourceTransactionManager=DEBUG diff --git a/topics/06-event/members/sujin/src/main/resources/schema.sql b/topics/06-event/members/sujin/src/main/resources/schema.sql new file mode 100644 index 0000000..1d1dea1 --- /dev/null +++ b/topics/06-event/members/sujin/src/main/resources/schema.sql @@ -0,0 +1,21 @@ +-- 6주차 STAGE 2 — 결제(payment) 도메인 트랜잭션 결합용 스키마 +-- IF NOT EXISTS: 매 실행마다 schema.sql 이 돌아도 안전 (H2 mem 은 어차피 재생성) + +CREATE TABLE IF NOT EXISTS payment ( + id BIGINT PRIMARY KEY, + amount DECIMAL(15, 2) NOT NULL +); + +-- 2-5 BEFORE_COMMIT 영수증 발급 실습용 (같은 트랜잭션 안에서 INSERT). +-- 컬럼/설계는 네 도메인에 맞게 바꿔도 OK. +CREATE TABLE IF NOT EXISTS receipt ( + payment_id BIGINT PRIMARY KEY, + issued_at TIMESTAMP NOT NULL +); + +-- 2-5 AFTER_COMMIT 포인트 적립 실습용. AFTER_COMMIT 리스너에서 DB 쓰기 → +-- REQUIRES_NEW 없으면 조용히 유실되는 함정 확인용. +CREATE TABLE IF NOT EXISTS point ( + payment_id BIGINT PRIMARY KEY, + amount DECIMAL(15, 2) NOT NULL +);