들어가며: "Spring은 왜 IoC와 DI를 핵심 철학으로 선택했을까?"
많은 개발자는 Spring을 처음 접할 때 @Component, @Service, @Autowired 같은 애노테이션부터 배우기 시작한다. 하지만 이러한 기능들은 Spring의 본질이 아니다. Spring이 해결하려 했던 문제를 이해하지 못하면 IoC(Inversion of Control, 제어의 역전)와 DI(Dependency Injection, 의존성 주입)는 그저 "객체를 대신 생성해 주는 단순 편의 기능" 정도로만 받아들이게 된다.
Spring을 제대로 이해하려면 먼저 객체지향 프로그래밍(OOP)이 무엇을 목표로 했는지, 그리고 왜 현실에서는 그 목표를 달성하기 어려웠는지를 살펴봐야 한다. Spring은 새로운 프로그래밍 패러다임을 제창한 프레임워크가 아니다. 오히려 객체지향이 지향하던 이상을 현실에서 순수하게 실천할 수 있도록 판을 깔아준 프레임워크이다. 이번 Part 1에서는 객체지향 설계의 핵심 원리부터 시작하여, Spring이 어떤 난제를 해결하기 위해 등장했는지를 차근차근 파헤쳐본다.
Chapter 1. 객체지향 프로그래밍은 무엇을 해결하려 했는가?
1.1 객체지향 프로그래밍의 등장 배경
객체지향 프로그래밍은 단순히 프로그램을 클래스(Class) 단위로 나누어 작성하는 기법이 아니다. OOP 이전의 절차지향 프로그래밍(Procedural Programming)은 프로그램을 "무엇을 어떤 순서대로 실행할 것인가"라는 명령어의 흐름을 중심으로 설계했다. 예를 들어 온라인 쇼핑몰의 주문 처리 시스템을 작성한다면 다음과 같은 선형적인 흐름을 가진다.
- [주문 데이터 수집] → [재고 수량 확인] → [결제 진행] → [배송 요청] → [처리 완료]
프로그램은 하나의 거대한 절차(Procedure)나 함수들의 집합으로 구성되었고, 데이터는 이 절차들이 순차적으로 요리하고 조작하는 수동적인 대상에 불과했다. 이 방식은 규모가 작은 토이 프로젝트에서는 직관적이고 이해하기 쉽다. 하지만 시스템의 규모가 커지고 비즈니스 요구사항이 복잡해질수록 치명적인 문제점들을 드러낸다.
- 연쇄적 수정 파급력: 단 하나의 로직이나 데이터 구조가 변경되어도 이를 참조하는 수많은 함수를 동시에 수정해야 한다.
- 데이터 무결성 파괴: 데이터가 여러 함수에 무방비로 노출되어 있어, 어디서 어떻게 값이 변경되었는지 추적하기 어렵다.
- 스파게티 의존성: 기능 간의 결합도가 기하급수적으로 증가하여, 새로운 기능을 하나 추가할 때마다 기존 코드를 파괴하게 된다.
결국 절차지향 방식은 "변화에 극도로 취약한 구조"라는 한계에 봉착했다. 객체지향 프로그래밍은 바로 이 문제를 해결하기 위해 등장했다. 프로그램을 단방향 명령어 흐름이 아니라, 자율적인 권한을 가진 객체(Object)들이 서로 메시지를 주고받으며 협력하는 작은 사회로 바라보자는 것이 OOP의 핵심 패러다임이다.
1.2 객체란 무엇인가?
객체지향 생태계에서 가장 중요한 실체는 클래스가 아니라 객체(Object)다. 클래스는 단지 객체를 틀에 맞춰 찍어내기 위한 도면에 불과하다. 객체는 단순 데이터 덩어리가 아니다. 객체는 다음 세 가지 필수 요소를 완벽하게 갖춘 "자율적인 인격체"에 가깝다.
- 상태(State): 객체가 내부적으로 보유하고 관리하는 데이터
- 행위(Behavior): 객체가 자신의 상태를 바탕으로 수행할 수 있는 기능 및 로직
- 책임(Responsibility): 객체가 시스템 전체 시스템 속에서 맡고 있는 명확한 역할과 의무
은행 계좌를 표현한 BankAccount 코드 예시를 살펴보자.
public class BankAccount {
// 1. 상태(State): 내부 데이터는 외부에서 함부로 주무를 수 없다.
private long balance;
// 2. 행위(Behavior): 오직 격리된 메서드를 통해서만 상태 변화를 허용한다.
public void deposit(long amount) {
if (amount <= 0) {
throw new IllegalArgumentException("입금액은 0보다 커야 합니다.");
}
this.balance += amount;
}
public void withdraw(long amount) {
if (this.balance < amount) {
throw new IllegalArgumentException("잔액이 부족합니다.");
}
this.balance -= amount;
}
}
- balance는 객체 고유의 상태다.
- deposit(), withdraw()는 객체의 행위다.
- 이 객체의 책임은 "계좌 잔액의 무결성을 스스로 안전하게 보호하고 유지하는 것"이다.
여기서 가장 중요한 지점은 외부 코드에서 `account.balance = -1000;`처럼 데이터에 직접 접근해 수정하는 행위를 엄격히 금지한다는 사실이다. 객체는 자신의 상태를 외부 도움 없이 스스로 판단하고 제어한다. 이처럼 데이터와 이를 다루는 로직을 하나로 묶고, 외부로부터 내부 구현을 숨기는 메커니즘을 캡슐화(Encapsulation)라고 부른다.
1.3 객체는 혼자 존재하지 않는다
현실 세상의 시스템에서 단 하나의 객체만으로 구성된 프로그램은 존재하지 않는다. 객체들은 저마다의 전공 분야를 가지고 서로에게 끊임없이 메시지를 던지며 협력한다. 쇼핑몰 시스템의 내부를 확장해 보면 다음과 같은 구조를 띤다.
- Client(사용자): 주문 처리 메시지
- OrderService(주문 흐름 제어)
- PaymentService(결제 승인 처리)
- PaymentGateway(외부 PG사 통신)
OrderService 객체는 주문의 전체 흐름을 관장하지만, 카드 결제 연동이나 계좌 이체 처리라는 정교한 세부 작업까지 직접 수행하지 않는다. 자신이 모든 일을 끌어안으면 책임이 너무 무거워지기 때문이다. 따라서 OrderService는 결제 전문 객체인 PaymentService에게 "이 주문 건에 대해 결제를 진행해 달라"는 메시지를 전송(호출)한다.
💡 관점의 전환:
객체지향에서는 "A 객체가 B 객체의 메서드를 불러서 사용한다"라고 표현하기보다는, "A 객체가 B 객체에게 특정 책임을 수행하라고 메시지를 요청한다"고 바라보는 것이 타당하다. 이러한 관점의 차이는 추후 인터페이스 설계와 다형성을 이해할 때 정밀한 시각을 제공한다.
1.4 역할과 구현의 분리
객체들이 서로 메시지를 주고받으며 협력할 때, 상대방 객체의 핏줄까지 세세하게 알아야 할까? 만약 OrderService가 PaymentService 내부의 1,000줄짜리 알고리즘을 전부 알아야만 메시지를 보낼 수 있다면, 작은 로직 변화에도 전체 시스템이 무너지는 재앙이 발생한다. 객체지향 설계는 역할(Role)과 구현(Implementation)을 엄격하게 분리함으로써 이 문제를 깔끔하게 해결한다.
- 역할(Role): 객체가 무엇(What)을 할 수 있는지 정의한 명세서 (Java의 `interface`)
- 구현(Implementation): 그 역할을 실제로 어떻게(How) 수행하는지 작성한 실체 (Java의 구현 `class`)
먼저 결제라는 역할을 인터페이스로 선언해 본다.
public interface PaymentService {
void pay(Order order);
}
이제 이 인터페이스 명세서(역할)를 바탕으로 다양하고 구체적인 구현체들이 탄생할 수 있다.

OrderService 입장에서 필요한 것은 오직 "결제를 수행할 수 있는 PaymentService 역할"뿐이다. 실제 내부에서 카드를 긁는지, 카카오페이 API를 호출하는지 등의 구체적 구현 방식은 OrderService의 관심사가 아니다. 이처럼 역할과 구현을 분리하면 객체 간의 결합도(Coupling)를 유의미하게 낮출 수 있다.
1.5 다형성, 객체지향의 핵심
역할과 구현의 분리를 가능하게 만드는 일등 공신이 바로 다형성(Polymorphism)이다. 다형성이란 "동일한 메시지를 전송하더라도, 메시지를 수신하는 실제 객체의 종류에 따라 동작 결과가 다양하게 변하는 성질"을 의미한다. OrderService 내부의 결제 실행 코드를 살펴보자.
// 협력 대상에게 결제 실행 메시지를 보낸다.
paymentService.pay(order);
호출하는 라인은 단 한 줄로 완전히 동일하다. 하지만 paymentService라는 변수에 실제 어떤 객체가 연결되어 있는지에 따라 프로그램의 동작은 완전히 뒤바뀐다.

호출하는 측의 코드는 단 한 줄도 건드리지 않은 채, 실행 시점에 주입되는 객체만 바꿔치기하면 시스템의 전체적인 동작을 손쉽게 확장할 수 있다. 이것이 바로 객체지향 프로그래밍이 그토록 강조하는 유연성과 확장성의 핵심 메커니즘이다.
1.6 좋은 객체지향 설계란 무엇인가? (SOLID 원칙)
좋은 객체지향 설계는 단순히 클래스를 많이 쪼개서 파일 수만 늘리는 작업이 아니다. 각 객체가 자신의 명확한 책임에만 집중하고, 타 객체와는 느슨하게 결합(Loose Coupling)된 상태로 매끄럽게 협력하도록 구조를 잡는 과정이다. 소프트웨어 거장 로버트 C. 마틴(Robert C. Martin)은 이러한 우수한 객체지향 설계를 도출하기 위한 5가지 핵심 원칙을 SOLID 원칙으로 체계화했다.
- SRP (Single Responsibility Principle, 단일 책임 원칙)
- 하나의 클래스는 단 하나의 변경 이유(책임)만을 가져야 한다.
- OCP (Open-Closed Principle, 개방-폐쇄 원칙)
- 소프트웨어 요소는 확장에는 열려(Open) 있어야 하나,
기존 코드의 변경에는 닫혀(Closed) 있어야 한다.
- 소프트웨어 요소는 확장에는 열려(Open) 있어야 하나,
- LSP (Liskov Substitution Principle, 리스코프 치환 원칙)
- 하위 타입 객체는 상위 타입 객체를 언제나 완전하게 대체할 수 있어야 한다.
- ISP (Interface Segregation Principle, 인터페이스 분리 원칙)
- 자신이 사용하지 않는 메서드에 의존하지 않도록,
거대한 인터페이스보다 명확한 목적의 단일 인터페이스 여러 개가 우월하다.
- 자신이 사용하지 않는 메서드에 의존하지 않도록,
- DIP (Dependency Inversion Principle, 의존관계 역전 원칙)
- 변하기 쉬운 구체적인 것(구현 클래스)에 의존하지 말고,
변하지 않는 것(추상화/인터페이스)에 의존해야 한다.
- 변하기 쉬운 구체적인 것(구현 클래스)에 의존하지 말고,
이 SOLID 원칙들은 향후 파악할 Spring의 IoC와 DI를 떠받치는 유일무이한 거울이자 지침서가 된다.
1.7 그런데 현실에서는 잘 되지 않는다!
문제는 이상과 현실 사이에 거대한 장벽이 존재한다는 점이다. 우리가 아무리 역할과 구현을 깔끔히 분리하고 인터페이스를 적극적으로 도출해 두어도, 자바 언어로 실제 개발을 진행하다 보면 어김없이 다음과 같은 비극적인 코드가 작성되곤 한다.
public class OrderService {
// 역할(PaymentService)에 의존하는 것처럼 보이지만...
// 결국 구체적인 구현체(KakaoPayment)를 직접 생성(new)하고 있다!
private final PaymentService paymentService = new KakaoPayment();
public void createOrder(Order order) {
// ...
paymentService.pay(order);
}
}
위 코드는 명면상으로 PaymentService라는 인터페이스를 활용하고 있다. 하지만 코드를 깊이 들여다보면 OrderService 스스로가 KakaoPayment라는 구체 클래스를 직접 `new` 키워드로 인스턴스화하고 있다. 이 작은 `new KakaoPayment()`라는 한 줄 때문에 객체지향의 이상적인 구조가 모조리 깨지고 만다.
- OCP 위반: 결제 수단을 카카오페이에서 토스페이(TossPayment)로 변경하려고 할 때, 클라이언트 코드인 OrderService 내부를 직접 열어서 수정해야 한다. (확장에는 열렸으나, 변경에도 열려버림)
- DIP 위반: OrderService는 추상화인 PaymentService뿐만 아니라, 구체 클래스인 KakaoPayment에도 동시에 강하게 의존하게 된다.
- 단위 테스트의 불가능: OrderService만 순수하게 테스트하고 싶어도, 내부에서 실제 PG사와 통신하는 KakaoPayment 객체를 강제로 만들어내므로 가짜 객체(Mock)로 대체할 방법이 차단된다.
결국 인터페이스를 열심히 만들어 두었음에도 불구하고, 객체가 구체적인 구현체를 직접 생성하고 의존관계를 맺는 순간 강한 결합(Tight Coupling)의 늪에 빠지게 된다. 문제의 본질은 "인터페이스의 부재"가 아니었다. "객체를 생성하고 연결하는 주체와 책임"이 객체 자신에게 남아있었다는 점이다.
1.8 Spring은 무엇을 해결했는가?
객체지향 패러다임은 역할과 구현을 깨끗이 분리하고, 다형성을 적극 활용하여 세련된 소프트웨어를 만드는 길을 제안했다.
그러나 실제 개발 환경에서는 객체가 구체 클래스의 인스턴스를 직접 생성하면서 이 유연한 장점들을 상당 부분 갉아먹었다.
Spring은 바로 이 거대한 이상과 현실의 간극을 메우기 위해 세상에 등장했다. Spring 프레임워크는 개발자가 작성한 객체에게서 객체 생성과 제어 권한을 완전히 빼앗아 온다..

Spring 프레임워크(컨테이너)는 다음 세 가지를 전담한다.
- 객체를 직접 생성한다.
- 생성된 객체들 간의 필요한 의존관계를 알아서 연결(주입)해 준다.
- 객체의 전체 생명주기(Lifecycle)를 관리한다.
이 메커니즘 덕분에 애플리케이션 내부의 객체들은 더 이상 `new` 키워드로 어떤 구현체를 가져올지 고민할 필요가 없어진다. 그저 본연의 책임인 비즈니스 로직 실행에만 모든 집중을 다할 수 있게 된 것이다. 이것이 바로 객체가쥐고 있던 제어권이 외부 프레임워크로 넘어갔음을 뜻하는 제어의 역전(IoC, Inversion of Control)의 위대한 출발점이다.
Chapter 1 핵심 정리
- 객체지향의 목표: 절차지향의 한계를 넘어, 자율적인 객체 간의 협력을 통해 변화에 손쉽게 대처하는 유연한 소프트웨어를 구축하는 것이다.
- 역할과 구현의 분리: 객체는 무엇을 하는지(역할/인터페이스)와 어떻게 하는지(구현/클래스)를 엄격히 나누어야 결합도가 낮아진다.
- 다형성: 동일한 메시지 호출에도 실제 연결된 객체에 따라 다르게 동작할 수 있도록 지원하는 객체지향의 핵심 무기다.
- 현실의 한계: 객체 내부에서 직접 new 키워드로 구체 구현체를 생성하면, DIP와 OCP 원칙이 무너지며 강하게 결합되는 문제가 발생한다.
- Spring의 해법: 객체의 생성·연결·생명주기 관리 책임을 객체 자신이 아닌 외부의 '스프링 컨테이너'로 넘김으로써, Pure 한 객체지향 설계를 현실에서 구현 가능하게 만들어준다.
'🍃SpringBoot' 카테고리의 다른 글
| Spring IoC & DI 완전 정복: Chapter 3: "제어를 프레임워크에게 넘긴다는 것은 무슨 의미일까?" (0) | 2026.07.22 |
|---|---|
| Spring IoC & DI 완전 정복: Chapter 2: "객체는 왜 스스로 필요한 객체를 만들면 안 될까?" (1) | 2026.07.22 |
| JPA [2/4] : 엔티티 매핑과 연관관계 (0) | 2026.06.23 |
| JPA [1/4] : JPA 시작과 영속성 컨텍스트 (0) | 2026.06.23 |
| JUnit [2/2]: Service, Repository, Controller, 동시성 테스트 (0) | 2026.06.22 |
