스프링 소개에서 IoC·DI·AOP가 무엇인지 배웠다. 이 실습에서는 스프링 없이 순수 자바로 작은 애플리케이션을 만들고, 요구사항이 바뀔 때마다 생기는 고통을 직접 겪으며 이 세 원리가 왜 필요한지 몸으로 익힌다.
이 실습의 진행 방식
스프링·Gradle 없이 자바 파일과
main메서드만으로 진행한다. IDE에서src폴더 하나면 충분하다.각 단계는 요구사항 변경 → 고통 → 리팩터링 순서로 이어진다. 코드를 직접 따라 치며 "왜 안 좋은지"를 먼저 느끼는 것이 핵심이다.
각 단계의 미션은
TODO로 제시하고, 해답은 접힌 박스로 둔다. 먼저 스스로 해 본 뒤 펼쳐 보자.
0. 도메인 소개 — 주문과 할인
쇼핑몰의 주문 기능을 만든다. 규칙은 단순하다.
회원에게는 등급(VIP / BASIC)이 있다.
회원이 상품을 주문하면 할인 정책에 따라 할인이 적용된다.
처음 정책: VIP는 1,000원 고정 할인, BASIC은 할인 없음.
먼저 바뀌지 않을 기본 클래스부터 만든다.
// 회원 등급
public enum Grade { BASIC, VIP }
// 회원
public class Member {
private final Long id;
private final String name;
private final Grade grade;
public Member(Long id, String name, Grade grade) {
this.id = id; this.name = name; this.grade = grade;
}
public Long getId() { return id; }
public String getName(){ return name; }
public Grade getGrade(){ return grade; }
}
// 주문 결과
public class Order {
private final Long memberId;
private final String itemName;
private final int itemPrice;
private final int discountPrice;
public Order(Long memberId, String itemName, int itemPrice, int discountPrice) {
this.memberId = memberId; this.itemName = itemName;
this.itemPrice = itemPrice; this.discountPrice = discountPrice;
}
public int payment() { return itemPrice - discountPrice; } // 최종 결제 금액
@Override public String toString() {
return "Order{item=" + itemName + ", price=" + itemPrice +
", discount=" + discountPrice + ", payment=" + payment() + "}";
}
}1단계 — new로 짠 첫 버전
가장 직관적으로, 필요한 객체를 new로 직접 만들어 연결한다.
// 할인 정책 — VIP는 1000원 고정 할인
public class FixDiscountPolicy {
public int discount(Member member, int price) {
return member.getGrade() == Grade.VIP ? 1000 : 0;
}
}
// 주문 서비스 — 할인 정책을 '직접' 생성해서 사용
public class OrderService {
private final FixDiscountPolicy discountPolicy = new FixDiscountPolicy(); // ← 직접 new
public Order createOrder(Member member, String itemName, int price) {
int discount = discountPolicy.discount(member, price);
return new Order(member.getId(), itemName, price, discount);
}
}// 실행
public class AppMain {
public static void main(String[] args) {
OrderService orderService = new OrderService();
Member vip = new Member(1L, "김철수", Grade.VIP);
Order order = orderService.createOrder(vip, "노트북", 1_000_000);
System.out.println(order); // payment=999000
}
}미션 1
위 코드를 그대로 작성해 실행하고, VIP 회원의 주문에서 1,000원이 할인되는지 확인하라. BASIC 회원도 만들어 할인이 0원인지 확인하라.
여기까지는 잘 동작한다. 문제는 요구사항이 바뀌는 순간 드러난다.
2단계 — 요구사항 변경: 정률 할인 (첫 번째 고통)
📢 기획 변경: "VIP 할인을 1,000원 고정이 아니라 주문 금액의 10% 로 바꿔주세요."
지금 구조에서 이걸 적용하려면 어떻게 해야 할까?
새 정책 클래스를 만들고, OrderService가 쓰던 FixDiscountPolicy를 RateDiscountPolicy로 바꿔 끼워야 한다.
// 새 정책 — 10% 정률 할인
public class RateDiscountPolicy {
public int discount(Member member, int price) {
return member.getGrade() == Grade.VIP ? price * 10 / 100 : 0;
}
}그런데 OrderService를 보자. 정책을 바꾸려면 OrderService의 내부 코드를 직접 고쳐야 한다.
public class OrderService {
// private final FixDiscountPolicy discountPolicy = new FixDiscountPolicy();
private final RateDiscountPolicy discountPolicy = new RateDiscountPolicy(); // ← 여기를 수정
...
}무엇이 문제인가
할인 정책(Fix→Rate)이 바뀌었을 뿐인데, 그것을 쓰는 OrderService까지 같이 수정해야 한다. 즉 "기능 확장에는 닫혀 있고 변경에는 열려 있는" 나쁜 상태다(OCP 위반). OrderService가 구체 클래스(FixDiscountPolicy/RateDiscountPolicy)에 직접 의존하기 때문이다.
인터페이스를 도입해도 부족하다
"그럼 인터페이스로 추상화하면 되지 않나?" 맞는 방향이다. 공통 인터페이스를 만들자.
public interface DiscountPolicy {
int discount(Member member, int price);
}
public class FixDiscountPolicy implements DiscountPolicy {
public int discount(Member m, int price) { return m.getGrade()==Grade.VIP ? 1000 : 0; }
}
public class RateDiscountPolicy implements DiscountPolicy {
public int discount(Member m, int price) { return m.getGrade()==Grade.VIP ? price*10/100 : 0; }
}하지만 OrderService가 여전히 구현체를 직접 new 하는 한, 문제는 그대로다.
public class OrderService {
// 선언 타입은 인터페이스지만...
private final DiscountPolicy discountPolicy = new RateDiscountPolicy(); // ← 구현체를 직접 new
}OrderService는 추상(DiscountPolicy)에도 의존하고 구체(RateDiscountPolicy)에도 의존한다. 정책을 바꾸려면 이 new 한 줄 때문에 여전히 OrderService를 열어 고쳐야 한다.
미션 2
DiscountPolicy 인터페이스와 두 구현체를 만들어라. 그리고 OrderService 안의 new RateDiscountPolicy()를 new FixDiscountPolicy()로 바꿔 보며, 정책을 바꿀 때마다 OrderService 코드를 수정하게 된다는 점을 직접 확인하라. 이 "직접 수정"이 왜 위험한지 한 문장으로 적어 보라.
3단계 — DI: 의존성을 외부에서 주입받기
문제의 뿌리는 OrderService가 자기가 쓸 구현체를 스스로 고르는 것이다. 그 결정을 밖으로 빼내자. OrderService는 "어떤 DiscountPolicy인지"는 모른 채, 누군가 넣어 주는 것을 쓰기만 한다.
public class OrderService {
private final DiscountPolicy discountPolicy;
// 생성자로 외부에서 주입받는다 — 더 이상 new 하지 않는다
public OrderService(DiscountPolicy discountPolicy) {
this.discountPolicy = discountPolicy;
}
public Order createOrder(Member member, String itemName, int price) {
int discount = discountPolicy.discount(member, price);
return new Order(member.getId(), itemName, price, discount);
}
}이제 어떤 정책을 쓸지는 호출하는 쪽이 결정한다.
public class AppMain {
public static void main(String[] args) {
DiscountPolicy policy = new RateDiscountPolicy(); // ← 정책 선택은 여기서
OrderService orderService = new OrderService(policy);
Member vip = new Member(1L, "김철수", Grade.VIP);
System.out.println(orderService.createOrder(vip, "노트북", 1_000_000));
}
}이것이 DI(의존성 주입)다
OrderService는 이제 Fix인지 Rate인지 전혀 모른다. 정책을 바꿔도 OrderService 코드는 한 줄도 바뀌지 않는다. 의존 객체를 내부에서 만들지 않고 외부에서 넣어 주는 것 — 이것이 의존성 주입(Dependency Injection)이다.
미션 3
OrderService를 생성자 주입 방식으로 바꿔라. 그런 다음 main에서 new RateDiscountPolicy() ↔ new FixDiscountPolicy()만 바꿔 가며, OrderService는 손대지 않고 정책이 교체되는지 확인하라.
4단계 — IoC: 객체 조립을 한 곳으로 모으기 (직접 만드는 컨테이너)
DI로 결합은 풀렸지만, 새 문제가 보인다. 객체를 생성하고 연결하는 코드가 main 여기저기 흩어진다. 서비스가 수십 개로 늘면 main은 new 범벅이 되고, 정책을 바꿀 위치도 찾기 어렵다.
객체를 만들고 연결하는 책임만 전담하는 클래스를 따로 두자. 이름을 AppConfig라 하자.
public class AppConfig {
// "어떤 구현체를 쓸지" 결정은 전부 여기 모인다
public DiscountPolicy discountPolicy() {
return new RateDiscountPolicy(); // ← 정책 교체는 '이 한 줄'
}
public OrderService orderService() {
return new OrderService(discountPolicy()); // 의존성을 조립해서 완성품을 반환
}
}public class AppMain {
public static void main(String[] args) {
AppConfig config = new AppConfig();
OrderService orderService = config.orderService(); // 완성된 객체를 '받아' 쓴다
Member vip = new Member(1L, "김철수", Grade.VIP);
System.out.println(orderService.createOrder(vip, "노트북", 1_000_000));
}
}이것이 IoC(제어의 역전)다
이전에는 main(사용하는 쪽)이 직접 객체를 만들고 연결을 제어했다. 이제 그 제어권이 AppConfig로 넘어갔다. 사용하는 쪽은 "필요하다"고 요청만 하고, 생성·연결은 AppConfig가 대신한다. 이렇게 제어의 흐름이 뒤집힌 것이 제어의 역전(Inversion of Control) 이고, AppConfig는 우리가 직접 만든 작은 IoC 컨테이너다.
미션 4
AppConfig를 만들고main이config.orderService()로 객체를 받아 쓰도록 바꿔라.AppConfig의 단 한 줄만 바꿔 할인 정책을 교체해 보라.테스트용으로 "항상 500원 할인" 같은 가짜 정책을 즉석에서 주입해 보라(아래 해답 참고).
미션 4 해답 — 테스트에서 가짜 정책 주입
DI 덕분에 테스트에서도 원하는 구현을 자유롭게 끼울 수 있다. AppConfig를 거치지 않고 생성자에 바로 넣으면 된다.
// 테스트: 할인 로직과 무관하게 OrderService 흐름만 검증하고 싶을 때
DiscountPolicy fake = (member, price) -> 500; // 람다로 즉석 구현
OrderService orderService = new OrderService(fake); // 가짜를 주입
Order order = orderService.createOrder(
new Member(1L, "tester", Grade.VIP), "test", 10_000);
assert order.payment() == 9_500; // 500원 할인 적용 확인구체 클래스에 직접 의존했다면 이렇게 갈아끼우기가 어렵다. DI가 테스트를 쉽게 만든다는 점을 보여주는 대목이다.
스프링이라면
우리가 손으로 만든 AppConfig와 조립 과정을, 스프링은 애너테이션만으로 자동화한다.
@Service // "이 클래스를 빈으로 관리해줘"
public class OrderService {
private final DiscountPolicy discountPolicy;
public OrderService(DiscountPolicy discountPolicy) { // 생성자 주입 — 스프링이 자동으로 넣어줌
this.discountPolicy = discountPolicy;
}
...
}
@Component // 빈으로 등록될 구현체
public class RateDiscountPolicy implements DiscountPolicy { ... }AppConfig가 하던 "객체 생성 + 의존성 연결"을 스프링 IoC 컨테이너가 대신한다. 우리가 4단계에서 직접 만든 것이 바로 컨테이너의 축소판이다.
5단계 — AOP: 공통 기능을 핵심 로직에서 떼어내기
📢 새 요구사항: "성능 점검을 위해 모든 서비스 메서드의 실행 시간을 로그로 남겨 주세요."
순진한 방법 — 메서드마다 복붙
가장 먼저 떠오르는 방법은 측정 코드를 메서드 안에 직접 넣는 것이다.
public Order createOrder(Member member, String itemName, int price) {
long start = System.currentTimeMillis(); // ← 공통 코드
try {
int discount = discountPolicy.discount(member, price); // ← 진짜 핵심 로직 (단 한 줄)
return new Order(member.getId(), itemName, price, discount);
} finally {
long end = System.currentTimeMillis(); // ← 공통 코드
System.out.println("createOrder " + (end - start) + "ms");
}
}무엇이 문제인가
핵심 로직(할인 계산)보다 시간 측정 코드가 더 눈에 띈다.
메서드가 10개면 10번 복붙해야 하고, 측정 방식을 바꾸면 10곳을 전부 고쳐야 한다.
로깅·트랜잭션·보안처럼 여러 곳에 똑같이 흩어지는 공통 관심사(cross-cutting concern) 는 전부 이 문제를 겪는다.
해법 — 동적 프록시로 공통 코드를 한 곳에
핵심 로직은 그대로 두고, 그 호출을 가로채는 대리자(프록시) 가 앞뒤로 시간 측정을 끼워 넣게 하자. 자바 표준 java.lang.reflect.Proxy를 쓴다. 프록시를 씌우려면 대상이 인터페이스여야 하므로, OrderService를 인터페이스로 추출한다.
public interface OrderService {
Order createOrder(Member member, String itemName, int price);
}
// 핵심 로직만 남은 순수한 구현 — 시간 측정 코드가 한 줄도 없다
public class OrderServiceImpl implements OrderService {
private final DiscountPolicy discountPolicy;
public OrderServiceImpl(DiscountPolicy discountPolicy) { this.discountPolicy = discountPolicy; }
public Order createOrder(Member member, String itemName, int price) {
int discount = discountPolicy.discount(member, price);
return new Order(member.getId(), itemName, price, discount);
}
}// 공통 관심사(시간 측정)를 단 한 곳에 모은다
public class TimeTraceHandler implements InvocationHandler {
private final Object target; // 진짜 객체
public TimeTraceHandler(Object target) { this.target = target; }
@Override
public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
long start = System.currentTimeMillis();
try {
return method.invoke(target, args); // ← 핵심 로직은 그대로 위임 호출
} finally {
long end = System.currentTimeMillis();
System.out.println(method.getName() + " " + (end - start) + "ms");
}
}
}AppConfig에서 실제 객체 대신 프록시로 감싼 객체를 내보낸다.
public class AppConfig {
public DiscountPolicy discountPolicy() { return new RateDiscountPolicy(); }
public OrderService orderService() {
OrderService target = new OrderServiceImpl(discountPolicy());
// 시간 측정 기능으로 감싼 프록시를 반환
return (OrderService) Proxy.newProxyInstance(
OrderService.class.getClassLoader(),
new Class[]{ OrderService.class },
new TimeTraceHandler(target));
}
}이제 orderService.createOrder(...)를 호출하면, 핵심 로직은 손대지 않았는데도 실행 시간이 자동으로 찍힌다.
이것이 AOP(관점 지향 프로그래밍)의 원리다
핵심 로직(OrderServiceImpl)은 순수하게 비즈니스 코드만 갖고, 공통 관심사(시간 측정)는 한 곳(TimeTraceHandler) 에 모였다. 공통 코드를 핵심 코드 밖에서 둘러싸 적용하는 것 — 이것이 AOP다. 스프링은 이 프록시 생성을 자동화해, @Aspect·@Around나 @Transactional 같은 애너테이션만으로 같은 일을 해 준다.
미션 5
OrderService를 인터페이스 +OrderServiceImpl로 분리하라.TimeTraceHandler를 만들고AppConfig에서 프록시로 감싸 반환하라.OrderServiceImpl에는 측정 코드를 한 줄도 넣지 않은 채 실행 시간이 로그에 찍히는지 확인하라.(생각해보기) 측정 대신 "메서드 호출 전후로
=== 시작 ===/=== 끝 ==="을 출력하도록 바꾸려면 몇 개의 파일을 고쳐야 하는가? 순진한 방법이었다면?
마무리 — 우리가 직접 만든 것 ↔ 스프링
이 실습에서 손으로 만든 것이, 스프링에서는 무엇으로 자동화되는지 정리하면 다음과 같다.
우리가 직접 만든 것 | 해결한 문제 | 스프링에서는 |
|---|---|---|
생성자로 의존성 주입 | 구현체 교체 시 사용처를 안 고침 |
|
| 생성·연결 제어권을 한 곳으로 |
|
| 공통 관심사를 핵심에서 분리 |
|
정리
IoC/DI: 객체가 자기 의존성을 직접 만들지 않게 하면(
new제거), 변경에 강하고 테스트하기 쉬워진다. 그 조립을 컨테이너가 대신한다.AOP: 여러 곳에 흩어지는 공통 코드는 프록시로 핵심 로직 밖에서 둘러싸면, 핵심은 순수해지고 공통 코드는 한 곳에 모인다.
스프링은 이 세 가지 수작업을 프레임워크가 자동으로 해 주는 도구다. 다음 장 프로젝트 생성부터는 실제 스프링으로 이 원리들을 누려 본다.
회고 질문 (스스로 점검)
2단계에서 정책을 바꿀 때 왜
OrderService까지 고쳐야 했는가? 3단계 이후엔 왜 안 고쳐도 되는가?AppConfig(IoC 컨테이너)가 없다면 객체가 늘어날수록 무엇이 불편해지는가?시간 측정을 프록시 없이 넣으면 메서드가 늘어날 때 어떤 문제가 생기는가?
이 셋 중 하나라도 직접 구현해 보니, 스프링이 "대신 해 준다"는 말의 의미가 와닿는가?