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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Empty file.
49 changes: 49 additions & 0 deletions topics/06-event/members/minseo/build.gradle
Original file line number Diff line number Diff line change
@@ -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()
}
Binary file not shown.
Original file line number Diff line number Diff line change
@@ -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
60 changes: 60 additions & 0 deletions topics/06-event/members/minseo/measurements.md
Original file line number Diff line number Diff line change
@@ -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)**
Original file line number Diff line number Diff line change
@@ -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");
}
}
Original file line number Diff line number Diff line change
@@ -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();
}
}
Original file line number Diff line number Diff line change
@@ -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();
}
}
Loading