객체지향 설계와 패턴¶
🎯 이 장에서 배우는 것¶
- 디자인 패턴 8종을 "바뀌는 것 분리" 도구로 사용
- 전략·템플릿·팩토리·싱글톤·옵저버·프록시·어댑터·파사드
- if-else를 전략 패턴으로 리팩터링
단계: 1단계 — Java Core · 앞 장: java-study-ch03 · 다음 장: java-study-ch05
따라 하는 법: 위에서 아래로 읽으며 코드를 직접 쳐본다. 패턴마다 Before/After 코드를 비교하고, 4.9 실전문제로 OCP를 코드로 확인한다. 깊이: concept-design-patterns.
실습 준비 — 이 장의 실습은 java-study-ch01 1.2에서 만든
hello-javaMaven/Gradle 프로젝트를 그대로 사용하고, 패턴마다 패키지com.example.ch04.<패턴>을 새로 만든다. 각 절의 Before는 참고 코드(문제 상황 비교용)라 파일로 만들지 않고, After만 실습 파일로 만든다. 단, 싱글톤·옵저버·프록시(4.3~4.5)의 After는 Spring 기반이라 java-study-ch06 6.1에서 만드는demo프로젝트가 필요하다 — 아직 6장 전이라면 읽기만 하고, 6장을 마친 뒤 돌아와 실행해도 된다.
4.0 전략 패턴¶
🎯 목표: 전략 패턴으로 바뀌는 행위를 분리해 교체 가능하게 만든다.
개요¶
전략 패턴은 행위를 캡슐화하여 실행 시점에 교체할 수 있게 만드는 패턴입니다. 같은 역할을 하는 여러 알고리즘이 있고, 상황에 따라 적절한 구현을 선택해야 할 때 유용합니다.
초중급 Java 개발자에게 이 패턴이 중요한 이유는, 실무에서 가장 자주 만나는 구조적 문제 중 하나가 바로 if-else와 switch의 비대화이기 때문입니다. 전략 패턴은 이 문제를 객체지향적으로 풀어내는 대표적인 방법입니다.
언제 사용하는가¶
- 같은 역할을 하는 여러 구현이 존재할 때
- 정책, 계산 방식, 외부 연동 방식이 자주 바뀔 때
- 분기문이 계속 늘어나 유지보수가 어려워질 때
Before¶
참고 코드 — 문제 상황 비교용입니다. 파일로 만들지 않습니다.
public class PaymentService {
public void processPayment(String paymentMethod, int amount) {
if ("creditCard".equals(paymentMethod)) {
System.out.println("신용카드로 " + amount + "원 결제를 처리합니다.");
} else if ("bankTransfer".equals(paymentMethod)) {
System.out.println("계좌이체로 " + amount + "원 결제를 처리합니다.");
} else {
throw new IllegalArgumentException("지원하지 않는 결제 방식입니다.");
}
}
}
이 구조에서는 새로운 결제 수단이 추가될 때마다 PaymentService를 수정해야 합니다. 정책이 늘어날수록 서비스는 점점 더 비대해집니다.
After¶
인터페이스·전략 구현체·컨텍스트를 non-public으로 한 파일에 모으고, 드라이버 StrategyDemo만 public으로 둡니다.
파일: src/main/java/com/example/ch04/strategy/StrategyDemo.java
package com.example.ch04.strategy;
interface PaymentStrategy {
void pay(int amount);
}
class CreditCardStrategy implements PaymentStrategy {
@Override
public void pay(int amount) {
System.out.println("신용카드로 " + amount + "원 결제를 처리합니다.");
}
}
class BankTransferStrategy implements PaymentStrategy {
@Override
public void pay(int amount) {
System.out.println("계좌이체로 " + amount + "원 결제를 처리합니다.");
}
}
class PaymentContext {
public void processPayment(PaymentStrategy strategy, int amount) {
System.out.println("결제 전략: " + strategy.getClass().getSimpleName());
System.out.println("결제를 시작합니다...");
strategy.pay(amount);
}
}
public class StrategyDemo {
public static void main(String[] args) {
PaymentContext context = new PaymentContext();
context.processPayment(new CreditCardStrategy(), 10000);
context.processPayment(new BankTransferStrategy(), 20000);
}
}
Gradle 프로젝트라면 ./gradlew compileJava 후 java -cp build/classes/java/main com.example.ch04.strategy.StrategyDemo로 실행합니다.
예상 결과
결제 전략: CreditCardStrategy
결제를 시작합니다...
신용카드로 10000원 결제를 처리합니다.
결제 전략: BankTransferStrategy
결제를 시작합니다...
계좌이체로 20000원 결제를 처리합니다.
이제 컨텍스트는 구체 구현이 아니라 PaymentStrategy 인터페이스에만 의존합니다. 정책 추가는 새로운 구현 클래스를 만드는 일로 바뀝니다.
핵심 포인트¶
- 바뀌는 행위를 객체로 분리합니다.
- 컨텍스트는 인터페이스에 의존합니다.
- 정책 추가가 기존 코드 수정으로 바로 이어지지 않게 만듭니다.
장점¶
- 조건 분기를 줄일 수 있습니다.
- OCP에 가까운 구조를 만들 수 있습니다.
- 테스트에서 Mock 전략을 주입하기 쉽습니다.
주의할 점¶
- 단순한 한 번짜리 분기까지 전략으로 만들면 과한 설계가 됩니다.
- 전략 수가 지나치게 많아지면 클래스 수가 빠르게 늘어날 수 있습니다.
실무 연결 포인트¶
Spring에서는 DI를 통해 전략 패턴을 매우 자연스럽게 구현합니다. 예를 들어 결제 수단별 처리기, 알림 채널별 발송기, 파일 포맷별 파서, 외부 API 공급자별 클라이언트는 모두 전략 패턴으로 정리하기 좋은 대상입니다.
✏️ 전략 패턴 직접 해보기¶
결제 수단(카드·현금·포인트)을 전략 인터페이스로 분리해 새 수단 추가가 기존 코드 수정 없이 되게 하라.
실습 순서
- 파일 열기 —
hello-java프로젝트의 위StrategyDemo.java(4.0)를 엽니다. - 수정 —
PaymentStrategy를 구현하는PointPaymentStrategy클래스를 추가하고, main에서context.processPayment(new PointPaymentStrategy(), 5000)을 호출합니다. - 재실행 — 위 절의 실행 명령을 재사용해 포인트 결제 출력이 추가로 나오는지 확인합니다.
한 줄 정리¶
전략 패턴은 같은 역할을 하는 여러 행위를 분리하고, 상황에 따라 교체 가능하게 만드는 구조입니다.
4.1 템플릿 메서드 패턴¶
🎯 목표: 템플릿 메서드로 불변 골격과 가변 단계를 분리한다.
개요¶
템플릿 메서드 패턴은 알고리즘의 전체 흐름은 상위 클래스가 고정하고, 세부 단계는 하위 클래스가 구현하도록 위임하는 패턴입니다. 공통 처리 순서를 유지하면서도 단계별 구현만 다르게 만들고 싶을 때 효과적입니다. 이 패턴은 프레임워크를 이해할 때 특히 중요합니다. Spring의 여러 템플릿 계열 API는 내부 실행 순서를 프레임워크가 잡고, 개발자는 필요한 부분만 채워 넣는 방식으로 동작합니다.
언제 사용하는가¶
- 전체 처리 순서는 같지만 일부 단계만 달라질 때
- 여러 클래스에서 같은 작업 순서를 반복하고 있을 때
- 하위 클래스가 전체 흐름을 임의로 깨지 못하게 하고 싶을 때
Before¶
참고 코드 — 문제 상황 비교용입니다. 파일로 만들지 않습니다 (After의 같은 이름 클래스와 겹칩니다).
public class CsvDataProcessor {
public void process() {
System.out.println("[공통] 데이터를 조회합니다.");
String data = "id,name,role";
String processedData = data.replace(",", ", ");
System.out.println("[저장] " + processedData);
}
}
public class TxtDataProcessor {
public void process() {
System.out.println("[공통] 데이터를 조회합니다.");
String data = "id,name,role";
String processedData = data.replace(",", " ");
System.out.println("[저장] " + processedData);
}
}
After¶
골격(추상 클래스)과 하위 구현을 non-public으로 한 파일에 모으고, 드라이버 TemplateMethodDemo만 public으로 둡니다.
파일: src/main/java/com/example/ch04/template/TemplateMethodDemo.java
package com.example.ch04.template;
abstract class AbstractDataProcessor {
public final void process() {
String data = loadData();
String processedData = transformData(data);
saveData(processedData);
}
private String loadData() {
System.out.println("[공통] 데이터를 조회합니다.");
return "id,name,role";
}
private void saveData(String data) {
System.out.println("[저장] " + data);
}
protected abstract String transformData(String data);
}
class CsvDataProcessor extends AbstractDataProcessor {
@Override
protected String transformData(String data) {
return data.replace(",", ", ");
}
}
class TxtDataProcessor extends AbstractDataProcessor {
@Override
protected String transformData(String data) {
return data.replace(",", " ");
}
}
public class TemplateMethodDemo {
public static void main(String[] args) {
new CsvDataProcessor().process();
new TxtDataProcessor().process();
}
}
Gradle 프로젝트라면 ./gradlew compileJava 후 java -cp build/classes/java/main com.example.ch04.template.TemplateMethodDemo로 실행합니다.
핵심 포인트¶
- 상위 클래스가 처리 순서를 고정합니다.
- 하위 클래스는 바뀌는 단계만 구현합니다.
final메서드로 전체 흐름을 보호할 수 있습니다.
장점¶
- 공통 로직을 한곳에 모을 수 있습니다.
- 하위 클래스 구현이 단순해집니다.
- 처리 순서가 일관되게 유지됩니다.
주의할 점¶
- 상속 기반이라 계층이 깊어지면 복잡해질 수 있습니다.
- 변경 지점이 많다면 템플릿 메서드보다 전략 패턴이 더 적합할 수 있습니다.
실무 연결 포인트¶
JdbcTemplate, 테스트 템플릿, 공통 배치 처리 구조처럼 "흐름은 고정하되 세부 구현만 바꾸는" 지점에서 이 패턴을 자주 볼 수 있습니다.
한 줄 정리¶
템플릿 메서드 패턴은 공통 흐름은 재사용하고, 세부 단계만 교체하고 싶을 때 사용하는 패턴입니다.
✏️ 템플릿 메서드 패턴 직접 해보기¶
음료 제조(끓이기→추출→붓기) 골격을 두고 커피·차로 추출 단계만 다르게 구현하라.
실습 순서
- 파일 생성 —
hello-java프로젝트에src/main/java/com/example/ch04/template/practice/BeverageDemo.java를 만듭니다. -
뼈대 입력 — 아래 뼈대를 그대로 입력합니다.
package com.example.ch04.template.practice; public class BeverageDemo { public static void main(String[] args) { // 1) 추상 클래스 Beverage — prepare()에 끓이기→추출→붓기 골격을 두고 brew()만 추상 메서드로 남긴다 // 2) Coffee — brew()에서 "커피를 내립니다" 출력 // 3) Tea — brew()에서 "찻잎을 우립니다" 출력 // 4) 두 객체의 prepare()를 호출해 골격은 같고 추출 단계만 다른지 확인 } } -
하나씩 구현 — 주석의 과제를 한 항목씩 구현합니다.
- 실행·확인 —
mvn compile exec:java -Dexec.mainClass="com.example.ch04.template.practice.BeverageDemo"— 추가할 때마다 다시 실행해 출력을 확인합니다.
4.2 팩토리 메서드 패턴¶
🎯 목표: 팩토리 메서드로 객체 생성을 서브클래스에 위임한다.
개요¶
팩토리 메서드 패턴은 객체 생성 책임을 별도의 생성 구조로 분리하는 패턴입니다. 클라이언트는 구체 클래스를 직접 생성하지 않고, 생성 메서드를 통해 객체를 받습니다. 초중급 개발자가 이 패턴을 익혀야 하는 이유는, 실무 코드에서 생성과 사용이 한 클래스에 뒤섞이면 테스트와 확장이 급격히 어려워지기 때문입니다.
언제 사용하는가¶
- 생성 대상이 자주 바뀔 수 있을 때
- 클라이언트가 구체 클래스에 직접 의존하지 않게 하고 싶을 때
- 생성 규칙을 한곳에 모으고 싶을 때
Before¶
참고 코드 — 문제 상황 비교용입니다. 파일로 만들지 않습니다.
public class NotificationService {
public void sendNotification(String type, String message) {
Notifier notifier;
if ("EMAIL".equalsIgnoreCase(type)) {
notifier = new EmailNotifier();
} else if ("SMS".equalsIgnoreCase(type)) {
notifier = new SmsNotifier();
} else {
throw new IllegalArgumentException("알 수 없는 알림 타입입니다.");
}
notifier.send(message);
}
}
After¶
인터페이스·팩토리·클라이언트를 non-public으로 한 파일에 모으고, 드라이버 FactoryMethodDemo만 public으로 둡니다. Before에서 쓰던 EmailNotifier·SmsNotifier 구현도 함께 넣어 실행 가능하게 만듭니다.
파일: src/main/java/com/example/ch04/factory/FactoryMethodDemo.java
package com.example.ch04.factory;
interface Notifier {
void send(String message);
}
class EmailNotifier implements Notifier {
@Override
public void send(String message) {
System.out.println("이메일로 " + message + " 메시지를 전송합니다.");
}
}
class SmsNotifier implements Notifier {
@Override
public void send(String message) {
System.out.println("SMS로 " + message + " 메시지를 전송합니다.");
}
}
interface NotifierFactory {
Notifier createNotifier();
}
class EmailNotifierFactory implements NotifierFactory {
@Override
public Notifier createNotifier() {
return new EmailNotifier();
}
}
class SmsNotifierFactory implements NotifierFactory {
@Override
public Notifier createNotifier() {
return new SmsNotifier();
}
}
class Client {
public void send(NotifierFactory factory, String message) {
Notifier notifier = factory.createNotifier();
notifier.send(message);
}
}
public class FactoryMethodDemo {
public static void main(String[] args) {
Client client = new Client();
client.send(new EmailNotifierFactory(), "주문이 완료되었습니다.");
client.send(new SmsNotifierFactory(), "인증번호는 1234입니다.");
}
}
Gradle 프로젝트라면 ./gradlew compileJava 후 java -cp build/classes/java/main com.example.ch04.factory.FactoryMethodDemo로 실행합니다.
핵심 포인트¶
- 객체 생성 책임을 클라이언트 밖으로 분리합니다.
- 클라이언트는 생성 규칙보다 사용에 집중합니다.
- 생성 대상이 늘어나도 기존 사용 코드는 유지하기 쉽습니다.
장점¶
- 생성 로직을 한곳에 모을 수 있습니다.
- 구체 클래스 의존을 줄일 수 있습니다.
- 테스트 시 생성 전략을 바꾸기 쉽습니다.
주의할 점¶
- 단순 생성까지 모두 팩토리로 감싸면 과한 추상화가 됩니다.
- 생성 규칙이 거의 바뀌지 않는다면 이 패턴이 불필요할 수 있습니다.
실무 연결 포인트¶
Spring Bean 생성, 메시지 발송기 선택, 결제 클라이언트 생성, 파일 파서 선택처럼 "무엇을 생성할지"가 자주 달라지는 구간에서 유용합니다.
한 줄 정리¶
팩토리 메서드 패턴은 객체를 직접 만들지 않고 생성 책임을 분리해 결합도를 낮추는 구조입니다.
✏️ 팩토리 메서드 패턴 직접 해보기¶
도형 종류 문자열을 받아 알맞은 Shape 객체를 만드는 팩토리를 작성하라.
실습 순서
- 파일 생성 —
hello-java프로젝트에src/main/java/com/example/ch04/factory/practice/ShapeFactoryDemo.java를 만듭니다. -
뼈대 입력 — 아래 뼈대를 그대로 입력합니다.
-
하나씩 구현 — 주석의 과제를 한 항목씩 구현합니다.
- 실행·확인 —
mvn compile exec:java -Dexec.mainClass="com.example.ch04.factory.practice.ShapeFactoryDemo"— 추가할 때마다 다시 실행해 출력을 확인합니다.
4.3 싱글톤 패턴¶
🎯 목표: 싱글톤으로 인스턴스를 하나로 보장한다(스프링 빈과의 관계 포함).
개요¶
싱글톤 패턴은 클래스의 인스턴스를 하나만 유지하고, 그 인스턴스에 전역적으로 접근할 수 있게 만드는 패턴입니다. 설정 정보, 캐시, 공용 리소스처럼 애플리케이션 전체에서 하나만 있어야 하는 객체를 다룰 때 자주 언급됩니다. 다만 Spring을 사용하는 개발자라면, 전통적인 싱글톤 구현보다 컨테이너가 Bean을 싱글톤 스코프로 관리한다는 점을 먼저 이해하는 편이 실무에 더 가깝습니다.
언제 사용하는가¶
- 애플리케이션 전역에서 하나만 있어야 하는 객체일 때
- 생성 비용이 크고 여러 번 만들 필요가 없을 때
- 상태를 중앙에서 관리해야 할 때
Before¶
참고 코드 — 전통적인 싱글톤 구현의 원리 비교용입니다. 파일로 만들지 않습니다.
public class AppConfig {
private static final AppConfig INSTANCE = new AppConfig();
private AppConfig() {
}
public static AppConfig getInstance() {
return INSTANCE;
}
}
After¶
Spring 기반 실습입니다 — java-study-ch06 6.1에서 만드는 demo 프로젝트에 파일을 만들고 테스트로 확인합니다.
파일: src/main/java/com/example/demo/ch04/singleton/SpringAppConfig.java
package com.example.demo.ch04.singleton;
import jakarta.annotation.PostConstruct;
import org.springframework.stereotype.Component;
@Component
public class SpringAppConfig {
@PostConstruct
public void init() {
System.out.println("SpringAppConfig Bean 초기화 완료");
}
}
파일: src/test/java/com/example/demo/ch04/singleton/SpringSingletonTest.java
package com.example.demo.ch04.singleton;
import static org.junit.jupiter.api.Assertions.assertSame;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.context.ApplicationContext;
@SpringBootTest(classes = SpringAppConfig.class)
class SpringSingletonTest {
@Autowired
private ApplicationContext applicationContext;
@Test
void singletonBeanTest() {
SpringAppConfig config1 = applicationContext.getBean(SpringAppConfig.class);
SpringAppConfig config2 = applicationContext.getBean(SpringAppConfig.class);
assertSame(config1, config2);
}
}
Gradle이라면 ./gradlew test --tests SpringSingletonTest로 실행합니다.
핵심 포인트¶
- 전통적인 싱글톤은 클래스 스스로 인스턴스 수를 제한합니다.
- Spring은 컨테이너 수준에서 싱글톤을 관리합니다.
- 실무에서는 직접 싱글톤을 구현하기보다 Bean 스코프를 이해하는 편이 더 중요합니다.
장점¶
- 자원을 하나만 유지할 수 있습니다.
- 설정 관리나 공용 컴포넌트에 적합합니다.
- Spring에서는 직접 구현 부담 없이 사용할 수 있습니다.
주의할 점¶
- mutable 상태를 가진 싱글톤은 동시성 문제를 일으킬 수 있습니다.
- 전역 상태가 많아지면 테스트가 어려워집니다.
- 싱글톤 패턴과 Spring 싱글톤 Bean은 비슷하지만 관리 주체가 다릅니다.
실무 연결 포인트¶
@Service, @Component, @Repository로 등록한 대부분의 Bean은 기본적으로 싱글톤입니다. 그래서 실무에서는 "싱글톤 패턴을 직접 짜는 법"보다 "싱글톤 Bean에서 상태를 어떻게 다뤄야 하는가"가 더 중요합니다.
한 줄 정리¶
싱글톤 패턴은 하나의 인스턴스를 공유하는 구조이고, Spring은 이 개념을 컨테이너 차원에서 기본 제공한다고 이해하면 됩니다.
✏️ 싱글톤 패턴 직접 해보기¶
스레드 안전한 싱글톤 AppSettings를 만들고 여러 번 호출해도 같은 인스턴스인지 확인하라.
실습 순서
- 파일 생성 —
demo프로젝트에src/main/java/com/example/demo/ch04/singleton/practice/AppSettings.java와src/test/java/com/example/demo/ch04/singleton/practice/AppSettingsTest.java를 만듭니다. -
뼈대 입력 — 아래 뼈대 두 개를 그대로 입력합니다.
파일:
src/main/java/com/example/demo/ch04/singleton/practice/AppSettings.javapackage com.example.demo.ch04.singleton.practice; public class AppSettings { // 1) private static final INSTANCE 필드로 인스턴스를 하나만 생성 // 2) 생성자를 private으로 제한 // 3) public static AppSettings getInstance() 에서 INSTANCE 반환 }파일:
src/test/java/com/example/demo/ch04/singleton/practice/AppSettingsTest.java -
하나씩 구현 — 주석의 과제를 한 항목씩 구현합니다.
- 실행·확인 —
./mvnw test -Dtest=AppSettingsTest— 추가할 때마다 다시 실행해 결과를 확인합니다.
4.4 옵저버 패턴¶
🎯 목표: 옵저버로 상태 변화를 구독자에게 통지한다.
개요¶
옵저버 패턴은 한 객체의 상태 변화가 여러 객체에 자동으로 전파되도록 만드는 패턴입니다. 발행자와 구독자를 느슨하게 연결할 수 있어, 기능이 늘어나는 시스템에서 특히 유용합니다.
Spring에서는 ApplicationEventPublisher와 @EventListener를 통해 이 개념을 비교적 자연스럽게 구현할 수 있습니다.
언제 사용하는가¶
- 한 이벤트에 여러 후속 처리가 연결될 때
- 발행자와 처리 로직을 분리하고 싶을 때
- 기능 확장이 잦아 결합도를 낮춰야 할 때
Before¶
참고 코드 — 문제 상황 비교용입니다. 파일로 만들지 않습니다.
public class OrderService {
private final InventoryService inventoryService = new InventoryService();
private final ShippingService shippingService = new ShippingService();
public void placeOrder(String productId, String address) {
System.out.println("주문 완료");
inventoryService.updateStock(productId);
shippingService.prepareShipping(address);
}
}
OrderService는 점점 더 비대해지고 결합도도 높아집니다.
After¶
Spring 기반 실습입니다 — java-study-ch06 6.1에서 만드는 demo 프로젝트에 파일 4개(이벤트·발행자·리스너 2개)를 만들고 테스트로 발행-구독 흐름을 확인합니다.
파일: src/main/java/com/example/demo/ch04/observer/OrderPlacedEvent.java
package com.example.demo.ch04.observer;
public class OrderPlacedEvent {
private final String productId;
private final String address;
public OrderPlacedEvent(String productId, String address) {
this.productId = productId;
this.address = address;
}
public String getProductId() {
return productId;
}
public String getAddress() {
return address;
}
}
파일: src/main/java/com/example/demo/ch04/observer/OrderService.java
package com.example.demo.ch04.observer;
import org.springframework.context.ApplicationEventPublisher;
import org.springframework.stereotype.Service;
@Service
public class OrderService {
private final ApplicationEventPublisher eventPublisher;
public OrderService(ApplicationEventPublisher eventPublisher) {
this.eventPublisher = eventPublisher;
}
public void placeOrder(String productId, String address) {
System.out.println("주문 완료");
eventPublisher.publishEvent(new OrderPlacedEvent(productId, address));
}
}
파일: src/main/java/com/example/demo/ch04/observer/InventoryService.java
package com.example.demo.ch04.observer;
import org.springframework.context.event.EventListener;
import org.springframework.stereotype.Service;
@Service
public class InventoryService {
@EventListener
public void onOrderPlaced(OrderPlacedEvent event) {
System.out.println("재고 차감: " + event.getProductId());
}
}
파일: src/main/java/com/example/demo/ch04/observer/ShippingService.java
package com.example.demo.ch04.observer;
import org.springframework.context.event.EventListener;
import org.springframework.stereotype.Service;
@Service
public class ShippingService {
@EventListener
public void onOrderPlaced(OrderPlacedEvent event) {
System.out.println("배송 준비: " + event.getAddress());
}
}
파일: src/test/java/com/example/demo/ch04/observer/OrderEventTest.java
package com.example.demo.ch04.observer;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
@SpringBootTest
class OrderEventTest {
@Autowired
private OrderService orderService;
@Test
void orderPlacedEventTest() {
orderService.placeOrder("P-1001", "서울시 강남구");
}
}
Gradle이라면 ./gradlew test --tests OrderEventTest로 실행합니다.
이 순서에서 중요한 점은 주문 서비스가 후속 작업을 직접 호출하지 않아도, 이벤트 발행 뒤에 여러 처리기가 느슨하게 반응할 수 있다는 점입니다. 리스너를 하나 더 추가해도(예: 쿠폰 발급) OrderService는 그대로입니다.
핵심 포인트¶
- 발행자는 이벤트만 발행합니다.
- 후속 작업은 개별 리스너가 담당합니다.
- 새로운 후처리가 필요해도 발행자를 크게 수정하지 않습니다.
장점¶
- 결합도를 낮출 수 있습니다.
- 기능을 수평적으로 확장하기 쉽습니다.
- 도메인 이벤트 구조와 연결하기 좋습니다.
주의할 점¶
- 흐름이 여러 리스너로 분산되면 추적이 어려울 수 있습니다.
- 트랜잭션 경계와 실행 시점을 명확히 이해해야 합니다.
- 모든 후처리를 이벤트로 빼면 디버깅이 어려워질 수 있습니다.
실무 연결 포인트¶
주문 완료 후 알림, 재고 차감, 포인트 적립, 감사 로그 저장처럼 "한 사건 뒤에 여러 후속 작업이 이어지는" 상황에서 효과적입니다. 회원가입 이후 이메일 발송, SMS 발송, 추천인 처리, 슬랙 알림이 차례로 붙는 구조도 같은 문제이며, 이 경우에도 서비스 본문은 이벤트 발행에만 집중하고 후처리는 개별 리스너로 분리하는 편이 훨씬 안정적입니다.
한 줄 정리¶
옵저버 패턴은 상태 변화를 여러 처리로 느슨하게 연결하는 구조이며, Spring 이벤트 모델은 이를 구현하는 좋은 도구입니다.
✏️ 옵저버 패턴 직접 해보기¶
발행자에 구독자 여러 개를 등록해, 발행 시 모두 통지받는지 확인하라.
실습 순서
- 파일 생성 —
demo프로젝트에src/main/java/com/example/demo/ch04/observer/PointService.java를 만듭니다. -
뼈대 입력 — 아래 뼈대를 그대로 입력합니다. 위
InventoryService.java·ShippingService.java와 같은 방식입니다. -
하나씩 구현 — 주석의 과제를 구현합니다.
- 실행·확인 —
./mvnw test -Dtest=OrderEventTest— 다시 실행해, 세 구독자 출력이 모두 나오는지 확인합니다.
4.5 프록시 패턴¶
🎯 목표: 프록시로 접근 제어·부가 기능을 구현한다.
개요¶
프록시 패턴은 실제 객체에 직접 접근하지 않고, 그 앞에 대리 객체를 두어 접근을 제어하거나 부가 기능을 추가하는 패턴입니다. 핵심은 비즈니스 로직을 건드리지 않고 공통 관심사를 분리하는 데 있습니다. Spring AOP, 트랜잭션, 보안, 지연 로딩은 모두 이 프록시 개념과 연결됩니다. 그래서 프록시 패턴은 단순 예제가 아니라 Spring 내부 동작을 이해하는 기반 개념입니다.
언제 사용하는가¶
- 접근 제어가 필요할 때
- 로깅, 트랜잭션, 성능 측정 같은 공통 기능을 분리하고 싶을 때
- 실제 객체 생성을 지연하고 싶을 때
Before¶
참고 코드 — 문제 상황 비교용입니다. 파일로 만들지 않습니다 (After의 EventService와 겹칩니다).
public class EventService {
public void processEvent(String eventName) {
long startTime = System.currentTimeMillis();
System.out.println("이벤트 처리 시작: " + eventName);
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
System.out.println("이벤트 처리 완료.");
long endTime = System.currentTimeMillis();
System.out.println("실행 시간: " + (endTime - startTime) + "ms");
}
}
After¶
Spring 기반 실습입니다 — java-study-ch06 6.1에서 만드는 demo 프로젝트를 사용합니다. 먼저 pom.xml의 <dependencies>에 AOP 스타터를 추가합니다:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-aop</artifactId>
</dependency>
Spring AOP에서 프록시가 끼워 넣는 부가 기능 메서드를 어드바이스, 그것을 어디에 적용할지 지정하는 식(아래 코드의 @Around 값)을 포인트컷이라고 부릅니다. 포인트컷은 실습 패키지를 가리켜야 어드바이스가 적용됩니다 (@Aspect 빈 자신은 프록시 대상에서 제외됩니다).
파일: src/main/java/com/example/demo/ch04/proxy/PerformanceAspect.java
package com.example.demo.ch04.proxy;
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import org.springframework.stereotype.Component;
@Aspect
@Component
public class PerformanceAspect {
@Around("execution(* com.example.demo.ch04.proxy..*(..))")
public Object measureExecutionTime(ProceedingJoinPoint joinPoint) throws Throwable {
long startTime = System.currentTimeMillis();
try {
return joinPoint.proceed();
} finally {
long endTime = System.currentTimeMillis();
System.out.println(joinPoint.getSignature().toShortString()
+ " 실행 시간: " + (endTime - startTime) + "ms");
}
}
}
파일: src/main/java/com/example/demo/ch04/proxy/EventService.java
package com.example.demo.ch04.proxy;
import org.springframework.stereotype.Service;
@Service
public class EventService {
public void processEvent(String eventName) {
System.out.println("이벤트 처리 시작: " + eventName);
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
System.out.println("이벤트 처리 완료.");
}
}
파일: src/test/java/com/example/demo/ch04/proxy/ProxyAspectTest.java
package com.example.demo.ch04.proxy;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
@SpringBootTest
class ProxyAspectTest {
@Autowired
private EventService eventService;
@Test
void measureExecutionTime() {
eventService.processEvent("order-created");
}
}
Gradle이라면 ./gradlew test --tests ProxyAspectTest로 실행합니다.
핵심 포인트¶
- 실제 객체 앞에 대리 객체를 둡니다.
- 공통 관심사를 비즈니스 로직에서 분리합니다.
- 클라이언트는 실제 객체를 직접 다루지 않아도 됩니다.
장점¶
- 코드 중복을 줄일 수 있습니다.
- 트랜잭션, 로깅, 보안 같은 공통 기능을 모듈화할 수 있습니다.
- 핵심 로직이 더 단순해집니다.
주의할 점¶
- 내부 자기 호출에는 프록시가 적용되지 않는 경우가 있습니다.
- 모든 공통 기능을 AOP로 몰아넣으면 흐름 추적이 어려워질 수 있습니다.
- 패턴 자체보다 무엇을 분리할지 판단하는 것이 더 중요합니다.
실무 연결 포인트¶
@Transactional, @Async, 메서드 보안, Hibernate 지연 로딩 프록시는 모두 프록시 개념을 기반으로 이해할 수 있습니다.
한 줄 정리¶
프록시 패턴은 실제 객체 앞에서 공통 기능을 대신 처리하게 하여 핵심 로직을 더 깨끗하게 유지하는 방식입니다.
✏️ 프록시 패턴 직접 해보기¶
실제 객체 호출 전후로 로그를 남기는 프록시를 만들어 보라.
실습 순서
- 파일 생성 —
demo프로젝트에src/main/java/com/example/demo/ch04/proxy/practice/LoggingAspect.java를 만듭니다. -
뼈대 입력 — 아래 뼈대를 그대로 입력합니다. 위
PerformanceAspect.java(4.5)를 참고합니다.package com.example.demo.ch04.proxy.practice; import org.aspectj.lang.ProceedingJoinPoint; import org.aspectj.lang.annotation.Around; import org.aspectj.lang.annotation.Aspect; import org.springframework.stereotype.Component; @Aspect @Component public class LoggingAspect { // 1) @Around("execution(* com.example.demo.ch04.proxy..*(..))") 메서드 추가 // 2) proceed() 호출 전에 "호출 시작: " + 시그니처, 호출 후에 "호출 종료" 로그 출력 } -
하나씩 구현 — 주석의 과제를 한 항목씩 구현합니다.
- 실행·확인 —
./mvnw test -Dtest=ProxyAspectTest— 추가할 때마다 다시 실행해 로그가 찍히는지 확인합니다.
4.6 어댑터 패턴¶
🎯 목표: 어댑터로 호환되지 않는 인터페이스를 연결한다.
개요¶
어댑터 패턴은 서로 호환되지 않는 인터페이스를 연결해 기존 시스템 안에서 함께 동작하게 만드는 패턴입니다. 이미 잘 돌아가는 구조를 크게 건드리지 않고 새로운 라이브러리나 기존 코드를 붙일 때 자주 사용됩니다. 초중급 개발자에게 이 패턴이 중요한 이유는, 실무에서는 새 코드를 작성하는 일보다 기존 시스템과 외부 시스템을 연결하는 일이 훨씬 더 자주 일어나기 때문입니다.
언제 사용하는가¶
- 기존 인터페이스를 유지한 채 새로운 구현을 붙여야 할 때
- 외부 라이브러리의 API가 현재 시스템과 맞지 않을 때
- 기존 코드를 최대한 바꾸지 않고 확장하고 싶을 때
Before¶
참고 코드 — 문제 상황 비교용입니다. 파일로 만들지 않습니다 (필요한 클래스는 After 실습 파일에 포함됩니다).
interface DataProcessor {
void processData();
}
class XmlDataProcessor implements DataProcessor {
@Override
public void processData() {
System.out.println("XML 데이터를 처리합니다.");
}
}
class NewJsonLibrary {
public void parseAndRunJson(String jsonData) {
System.out.println("JSON 데이터를 처리합니다: " + jsonData);
}
}
public class DataService {
public void process(Object processor, String data) {
if (processor instanceof XmlDataProcessor) {
((XmlDataProcessor) processor).processData();
} else if (processor instanceof NewJsonLibrary) {
((NewJsonLibrary) processor).parseAndRunJson(data);
}
}
}
After¶
Before의 표준 인터페이스 DataProcessor와 외부 라이브러리 역할인 NewJsonLibrary를 non-public으로 함께 담고, 드라이버 AdapterDemo만 public으로 둡니다.
파일: src/main/java/com/example/ch04/adapter/AdapterDemo.java
package com.example.ch04.adapter;
interface DataProcessor {
void processData();
}
class NewJsonLibrary {
public void parseAndRunJson(String jsonData) {
System.out.println("JSON 데이터를 처리합니다: " + jsonData);
}
}
class JsonAdapter implements DataProcessor {
private final NewJsonLibrary newJsonLibrary;
private final String jsonData;
public JsonAdapter(NewJsonLibrary newJsonLibrary, String jsonData) {
this.newJsonLibrary = newJsonLibrary;
this.jsonData = jsonData;
}
@Override
public void processData() {
newJsonLibrary.parseAndRunJson(jsonData);
}
}
class DataService {
public void process(DataProcessor processor) {
processor.processData();
}
}
public class AdapterDemo {
public static void main(String[] args) {
DataService service = new DataService();
service.process(new JsonAdapter(new NewJsonLibrary(), "{\"name\":\"Kim\"}"));
}
}
Gradle 프로젝트라면 ./gradlew compileJava 후 java -cp build/classes/java/main com.example.ch04.adapter.AdapterDemo로 실행합니다.
클라이언트(DataService)는 표준 processData()만 호출했지만, 어댑터가 이를 parseAndRunJson() 호출로 변환해 외부 라이브러리가 실행됩니다.
핵심 포인트¶
- 기존 표준 인터페이스는 그대로 유지합니다.
- 호환되지 않는 구현은 어댑터가 감쌉니다.
- 클라이언트는 변환 과정을 몰라도 됩니다.
장점¶
- 기존 코드를 크게 바꾸지 않아도 됩니다.
- 외부 라이브러리 연동이 쉬워집니다.
- 변화의 영향을 어댑터 안으로 가둘 수 있습니다.
주의할 점¶
- 단순 래퍼가 너무 많아지면 구조가 산만해질 수 있습니다.
- 인터페이스 변환이 정말 필요한지 먼저 확인해야 합니다.
실무 연결 포인트¶
Spring MVC의 HandlerAdapter, 외부 API 클라이언트를 감싸는 래퍼, 레거시 서비스 인터페이스 정리 같은 곳에서 자주 등장합니다.
한 줄 정리¶
어댑터 패턴은 기존 인터페이스를 유지하면서 다른 구조를 무리 없이 끼워 넣는 방식입니다.
✏️ 어댑터 패턴 직접 해보기¶
다른 시그니처의 외부 클래스를 어댑터로 감싸 기존 방식으로 호출하게 하라.
실습 순서
- 파일 열기 —
hello-java프로젝트의 위AdapterDemo.java(4.6)를 엽니다. - 수정 — 시그니처가 다른 외부 클래스
LegacyCsvLibrary와 이를DataProcessor에 맞추는CsvAdapter를 추가하고, main에서service.process(...)로 호출합니다. - 재실행 — 위 절의 실행 명령을 재사용해 새 어댑터 출력까지 나오는지 확인합니다.
4.7 파사드 패턴¶
🎯 목표: 파사드로 복잡한 하위 시스템을 단순한 창구로 감싼다.
개요¶
파사드 패턴은 복잡한 서브시스템을 감싸고, 바깥에는 단순한 진입점 하나만 제공하는 패턴입니다. 사용자는 내부 구조를 모두 알 필요 없이, 파사드가 제공하는 메서드만 호출하면 됩니다. 실무에서는 시스템이 커질수록 여러 서비스를 정해진 순서로 묶어 호출해야 하는 상황이 많아집니다. 이때 파사드 패턴은 복잡한 절차를 읽기 쉬운 진입점으로 정리하는 데 큰 도움이 됩니다.
언제 사용하는가¶
- 여러 서비스를 항상 같은 순서로 묶어 호출할 때
- 클라이언트가 내부 구조를 너무 많이 알아야 할 때
- 복잡한 호출 절차를 단순한 진입점으로 감싸고 싶을 때
Before¶
참고 코드 — 문제 상황 비교용입니다. 파일로 만들지 않습니다 (After의 OrderClient와 겹칩니다).
public class OrderClient {
private final InventoryService inventoryService = new InventoryService();
private final PaymentService paymentService = new PaymentService();
private final ShippingService shippingService = new ShippingService();
private final NotificationService notificationService = new NotificationService();
public void placeOrder() {
inventoryService.checkStock("노트북");
paymentService.processPayment("홍길동");
shippingService.arrangeShipping("서울시 강남구");
notificationService.sendNotification("홍길동");
}
}
After¶
서브시스템 4개(재고·결제·배송·알림)와 파사드·클라이언트를 non-public으로 한 파일에 모으고, 드라이버 FacadeDemo만 public으로 둡니다. 예상 결과의 출력을 내도록 서브시스템 구현을 최소로 채웠습니다.
파일: src/main/java/com/example/ch04/facade/FacadeDemo.java
package com.example.ch04.facade;
class InventoryService {
public void checkStock(String item) {
System.out.println("재고 확인: " + item);
}
}
class PaymentService {
public void processPayment(String user) {
System.out.println("결제 처리: " + user);
}
}
class ShippingService {
public void arrangeShipping(String address) {
System.out.println("배송 준비: " + address);
}
}
class NotificationService {
public void sendNotification(String user) {
System.out.println("알림 발송: " + user);
}
}
class OrderFacade {
private final InventoryService inventoryService = new InventoryService();
private final PaymentService paymentService = new PaymentService();
private final ShippingService shippingService = new ShippingService();
private final NotificationService notificationService = new NotificationService();
public void placeOrder(String item, String user, String address) {
inventoryService.checkStock(item);
paymentService.processPayment(user);
shippingService.arrangeShipping(address);
notificationService.sendNotification(user);
}
}
class OrderClient {
private final OrderFacade orderFacade = new OrderFacade();
public void placeOrder() {
orderFacade.placeOrder("노트북", "홍길동", "서울시 강남구");
}
}
public class FacadeDemo {
public static void main(String[] args) {
new OrderClient().placeOrder();
}
}
Gradle 프로젝트라면 ./gradlew compileJava 후 java -cp build/classes/java/main com.example.ch04.facade.FacadeDemo로 실행합니다.
핵심 포인트¶
- 복잡한 내부 절차를 한곳에 모읍니다.
- 클라이언트는 파사드만 알면 됩니다.
- 사용 흐름이 단순해지고 진입점이 명확해집니다.
장점¶
- 클라이언트 코드가 간결해집니다.
- 서브시스템 변경의 영향 범위를 줄일 수 있습니다.
- 서비스 조합 로직을 한곳에서 관리하기 좋습니다.
주의할 점¶
- 파사드가 너무 많은 책임을 가지면 또 다른 거대한 클래스가 될 수 있습니다.
- 모든 조합 로직을 무조건 파사드로 모으는 것은 좋은 설계가 아닙니다.
실무 연결 포인트¶
복합 서비스 조합, 외부 시스템 연계 흐름, 결제-재고-배송 묶음 처리처럼 "단일 진입점이 있으면 더 읽기 쉬워지는" 영역에서 효과적입니다.
한 줄 정리¶
파사드 패턴은 복잡한 내부 구조를 감추고, 바깥에는 단순한 사용 지점을 제공하는 구조입니다.
✏️ 파사드 패턴 직접 해보기¶
주문·결제·배송 여러 단계 호출을 파사드 한 메서드로 묶어 보라.
실습 순서
- 파일 열기 —
hello-java프로젝트의 위FacadeDemo.java(4.7)를 엽니다. - 수정 — 새 단계 클래스(예: 포인트 적립
PointService)를 추가하고OrderFacade.placeOrder()에서 호출합니다. - 재실행 — 위 절의 실행 명령을 재사용해 출력 단계가 하나 늘어나는지 확인합니다.
4.9 전략 패턴 실전문제¶
🎯 목표: 패턴 지식을 실전 설계 문제로 적용한다.
개요¶
이 절은 4.0에서 배운 전략 패턴(바뀌는 행위를 인터페이스로 분리하는 구조)을 읽은 뒤 바로 이어서 푸는 실전문제 모음입니다. 개념을 설명으로 끝내지 않고, 실제 설계 판단 문제로 연결하는 데 목적이 있습니다. 전략 패턴의 핵심은 바뀌는 행위를 별도의 객체로 분리하고, 컨텍스트는 그 구현이 아니라 공통 인터페이스에 의존하도록 만드는 데 있습니다.
어떻게 활용하면 좋은가¶
- 먼저 각 시나리오에서 무엇이 전략인지 스스로 찾아봅니다.
if-else구조를 전략 패턴으로 어떻게 바꿀지 설명해봅니다.- Spring DI와 연결했을 때 어떤 형태로 확장되는지도 함께 생각해봅니다.
문제 구성¶
1. 배송 서비스¶
- 배송 방식이 여러 개일 때 전략·컨텍스트·클라이언트 역할을 구분하는 문제
2. 코드 빈칸 완성¶
- 컨텍스트가 전략을 보관·실행하는 코드의 빈칸을 채우는 문제
3. 소셜 로그인¶
- 로그인 수단별 분기문의 문제점을 짚고 전략으로 분리하는 문제
4. Spring에서의 전략 선택¶
- 전략 Bean 묶음을 주입받는 구조의 장단점을 판단하는 문제
5. 설계 판단¶
- 전략 패턴을 적용할 가치가 높은 상황을 고르는 문제
문제 1. 배송 서비스의 전략 찾기¶
다음 시나리오에서 전략 패턴의 세 역할을 찾아보는 문제입니다.
다음 항목을 구분해보세요.- 전략(Strategy): 무엇이 바뀌는가
- 컨텍스트(Context): 어떤 객체가 전략을 사용해 작업을 수행하는가
- 클라이언트(Client): 누가 전략을 선택하는가
문제 2. 코드 빈칸 완성¶
아래는 배송 컨텍스트 코드에서 두 곳을 비워 둔 것입니다.
public interface DeliveryStrategy {
void deliver(String address);
}
public class DeliveryService {
private DeliveryStrategy strategy;
public void setStrategy(DeliveryStrategy strategy) {
this.strategy = _______;
}
public void execute(String address) {
if (_______ == null) {
throw new IllegalStateException("배송 방식이 선택되지 않았습니다.");
}
strategy.deliver(address);
}
}
- 빈칸 두 곳을 채우세요.
- 컨텍스트가 구체 구현을 직접 알지 않아도 되는 이유를 설명하세요.
문제 3. 소셜 로그인 리팩터링¶
아래는 로그인 수단을 분기문으로 고르는 코드입니다.
public void login(String provider, String token) {
if ("google".equals(provider)) {
// 구글 로그인
} else if ("kakao".equals(provider)) {
// 카카오 로그인
} else if ("naver".equals(provider)) {
// 네이버 로그인
}
}
- 새로운 로그인 수단이 추가될 때 어떤 문제가 생기는가
- 이 메서드가 너무 많은 책임을 갖는 이유는 무엇인가
문제 4. Spring에서의 전략 선택¶
아래는 Spring DI로 전략 구현 Bean 전부를 Map으로 주입받는 코드입니다.
@Service
public class NotificationService {
private final Map<String, NotificationStrategy> strategies;
public NotificationService(Map<String, NotificationStrategy> strategies) {
this.strategies = strategies;
}
}
List<NotificationStrategy>대신Map<String, NotificationStrategy>를 쓸 때의 장점은 무엇인가- 런타임에 전략을 선택해야 할 때 어떤 구조가 더 자연스러운가
문제 5. 설계 판단¶
다음 중 전략 패턴을 적용할 가치가 높은 상황을 고르세요.
- 결제 수단별 처리 방식이 자주 추가되는 결제 서비스
- 한 번만 쓰이는 단순한
if분기 하나 - 외부 API 공급자별 호출 방식이 다른 연동 서비스
- 할인 정책이 계속 추가되는 주문 서비스 그리고 왜 그런지 한 문장으로 설명해보세요.
풀이 전 체크 포인트¶
- 전략과 컨텍스트를 정확히 분리했는가
- 새로운 전략 추가가 기존 코드 수정 없이 가능한가
- 인터페이스가 실제로 공통 계약 역할을 하는가
- Spring DI와 연결했을 때도 구조가 유지되는가
예상 답안 방향¶
이 문서는 문제집이지만, 독자가 전혀 감 없이 멈추지 않도록 최소한의 판단 방향은 잡아 두는 편이 좋습니다.
- 배송 서비스 문제: 바뀌는 것은 배송 방식이고, 배송 서비스 본체는 컨텍스트입니다.
- 소셜 로그인 문제:
provider분기가 늘어날수록 로그인 메서드가 인증 수단 선택 책임까지 떠안게 됩니다. - Spring 전략 선택 문제: 런타임에 이름이나 키로 전략을 고를 때는
Map<String, Strategy>가 더 자연스럽습니다. - 설계 판단 문제: 자주 늘어나는 정책, 공급자, 결제/알림/압축 방식은 전략 패턴 후보입니다.
정리¶
전략 패턴 문제는 정답 클래스 이름을 맞히는 것이 목표가 아닙니다. 어떤 코드가 바뀌는 부분인지, 그 바뀌는 부분을 어떻게 객체로 분리할 수 있는지 판단하는 훈련이 핵심입니다.
한 줄 정리¶
전략 패턴 연습 문제의 핵심은 분기문을 줄이는 것이 아니라, 바뀌는 행위를 독립된 객체로 분리하는 판단력을 기르는 데 있습니다.
자주 나는 에러 → 원인¶
실습 중 막히면 아래부터 확인합니다.
| 증상 | 원인 확인 |
|---|---|
mvn exec:java에서 ClassNotFoundException |
compile 없이 실행하지 않았는지 (mvn compile exec:java), -Dexec.mainClass의 패키지 경로 오타가 없는지 확인합니다. |
duplicate class / 클래스 중복 컴파일 에러 |
Before 참고 코드까지 파일로 만들지 않았는지, 패턴마다 패키지(com.example.ch04.<패턴>)를 분리했는지 확인합니다. |
package com.example.ch04... does not exist |
파일 위치가 리드인 경로와 일치하는지 (src/main/java 아래 패키지 디렉터리 구조) 확인합니다. |
Spring 실습(4.3~4.5)에서 NoSuchBeanDefinitionException |
파일을 demo 프로젝트의 com.example.demo 하위 패키지에 두었는지 확인합니다 — 밖에 두면 컴포넌트 스캔에서 빠집니다. |
| 4.5에서 실행 시간 로그가 안 찍힘 | spring-boot-starter-aop 의존성을 추가했는지, @Around 포인트컷 패키지가 실제 패키지와 일치하는지 확인합니다. |