김영한 강사의 스프링 핵심원리 - 기본편 강의 내용을 바탕으로 학습한 정리본입니다
https://www.inflearn.com/course/스프링-핵심-원리-기본편#curriculum
스프링의 진짜 얼굴: “프레임워크”가 아니라 “설계 철학”
스프링을 처음 접하면 보통 이렇게 생각한다.
“아, 웹 만들 때 쓰는 거지?”
“스프링 부트가 스프링 아니야?”
절반만 맞다. 스프링은 웹 프레임워크 이전에 객체 지향 설계를 실현하기 위한 인프라다.
웹, 데이터, 배치, 시큐리티는 그 위에 얹힌 옵션일 뿐이다.
핵심은 딱 하나다.
“객체 지향의 좋은 설계를 현실 세계에서 작동하게 만드는 것”
스프링은 하나가 아니다
스프링은 마치 레고 세트 같다. 하나의 제품이 아니라 여러 박스의 조합이다.
- Spring Framework → 뼈대 (DI, AOP, MVC, 트랜잭션 등)
- Spring Boot → 조립 설명서 + 자동 조립기
- Spring Data / Security / Batch / Cloud → 확장 팩
Spring Boot는 스프링 프레임워크를 **“쉽게 쓰게 해주는 래퍼”**다.
본질은 여전히 Spring Framework + 객체 지향 설계다.
스프링의 핵심 키워드 요약
| DI | 객체를 내가 만들지 않고 외부에서 넣어주는 것 |
| IoC | 객체 생성과 연결의 주도권을 프레임워크에 넘기는 것 |
| Container | 객체를 대신 만들어주고 연결해주는 공장 |
| Bean | 컨테이너가 관리하는 객체 |
| OOP | 스프링이 존재하는 이유 |
객체 지향의 출발점: “협력”
객체 지향의 본질은 클래스가 아니라 협력이다.
혼자 일하는 객체는 없다
모든 객체는 누군가의 요청을 받고, 누군가에게 응답한다
이때 중요한 건 “누가 누구를 쓰느냐”가 아니라
“어떤 역할을 누가 맡느냐”
다형성의 진짜 힘
다형성은 단순히 “인터페이스 + 구현체”가 아니다.
진짜 의미는 이것이다.
“클라이언트는 역할만 알고, 구현은 몰라도 된다”
interface MemberRepository {
void save(Member m);
}
class MemoryMemberRepository implements MemberRepository { ... }
class JdbcMemberRepository implements MemberRepository { ... }
이 구조가 주는 힘은 하나다.
서버(구현체)를 바꿔도 클라이언트는 안 건드린다
그런데 왜 현실에서는 안 되나?
다들 이런 코드를 쓴다.
public class MemberService {
private MemberRepository repository = new JdbcMemberRepository();
}
여기서 모든 게 무너진다.
OCP가 깨진다
- 저장소를 Memory → JDBC로 바꾸려면
- MemberService 코드를 수정해야 한다
“확장을 했는데 기존 코드를 고쳤다”
→ OCP 위반
DIP가 깨진다
- MemberService는 MemberRepository(추상화)에 의존해야 한다
- 하지만 코드에 JdbcMemberRepository(구현체)가 적혀 있다
“추상화에 의존하라더니 구현체에 의존하고 있다”
→ DIP 위반
좋은 객체 지향 설계의 원칙 : SOLID
✔️ SRP 단일 책임 원칙 (Single Responsibility Principle)
한 클래스는 하나의 책임만 가져야 한다. 여기서 중요한 기준은 변경이다. 변경이 파급 효과가 적으면 단일 책임 원칙을 잘 따른 것이다.
ex) UI 변경, 객체의 생성과 사용을 분리
✔️ OCP 개방-폐쇄 원칙 (Open-Closed Principle)
소프트웨어 요소는 확장에는 열려 있으나 변경에는 닫혀 있어야 한다. 기존 코드를 수정하는 것이 아니라 다형성을 활용하여 역할과 구현을 분리하는 것이다.
✔️ LSP 리스코프 치환 원칙
프로그램의 객체는 프로그램의 정확성을 깨뜨리지 않으면서 하위 타입의 인스턴스로 바꿀 수 있어야 한다. 인터페이스를 구현한 구현체를 믿고 사용하기 위한 원칙
예) 자동차 인터페이스는 엑셀은 앞으로 가라 기능, 뒤로 가게 구현하면 LSP 위반, 느리더라도 앞으로 가야함
✔️ ISP 인터페이스 분리 원칙 (Interface Segregation Principle)
특정 클라이언트를 위한 인터페이스 여러 개가 범용 인터페이스 하나보다 낫다. 여러 가지 기능을 적당한 크기로 인터페이스를 만드는 것이 좋다. 인터페이스가 명확해지고, 대체 가능성이 높아진다
ex) 자동차 인터페이스 : 운전 인터페이스, 정비 인터페이스로 분리
ex )사용자 인터페이스 : 운전자 클라이언트, 정비사 클라이언트로 분리
✔️ DIP 의존관계 역전 원칙 (Dependency Inversion Principle)
프로그래머는 추상화에 의존해야지, 구체화에 의존하면 안 된다.의존성 주입은 이 원칙을 따르는 방법 중 하나이다. 즉, 역할에 의존해야 하는 것이다.
다형성의 배신
인터페이스를 썼는데도 OCP, DIP가 깨진 이유는 하나다.
객체를 누가 만들었는가?
new JdbcMemberRepository()를 클라이언트가 직접 호출했다.
즉, 역할을 쓰는 놈이 구현까지 선택했다.
이게 설계 붕괴의 시작이다.
해결책: “조립 담당”을 따로 둔다
해결법은 단순하다.
객체를 사용하는 놈과 객체를 만드는 놈을 분리한다
class AppConfig {
MemberRepository memberRepository() {
return new JdbcMemberRepository();
}
MemberService memberService() {
return new MemberService(memberRepository());
}
}
MemberService는 이제 이렇게 된다.
public class MemberService {
private final MemberRepository repository;
public MemberService(MemberRepository repository) {
this.repository = repository;
}
}
이제 MemberService는
- 구현체 이름을 모른다
- new를 하지 않는다
- 인터페이스만 바라본다
→ DIP 만족, OCP 만족
DI: 의존관계 주입의 본질
DI는 기술이 아니라 권력 이동이다.
기존 방식 DI 방식
| 객체가 직접 new로 상대를 선택 | 외부에서 객체를 만들어 넣어줌 |
| 내가 누구를 쓸지 내가 결정 | 누가 들어올지 나는 모름 |
즉,
의존관계 결정권을 외부로 넘기는 것
IoC: 더 큰 그림
DI는 IoC의 한 종류다.
IoC란
프로그램의 흐름 제어권을 프레임워크가 가진다
- 기존: main() → 내가 객체 만들고 호출
- 스프링: 컨테이너가 객체 만들고 연결하고 실행
그래서 스프링은 라이브러리가 아니라 프레임워크다.
스프링 컨테이너의 정체
스프링에서 AppConfig 역할을 하는 것이 바로
ApplicationContext
스프링 컨테이너는 다음을 한다.
- @Configuration 클래스를 읽는다
- @Bean 메서드를 실행한다
- 반환된 객체를 Bean으로 저장한다
- 필요한 곳에 자동으로 연결한다
@Configuration
public class AppConfig {
@Bean
MemberService memberService() { ... }
}
이 순간부터 MemberService는
“내가 만든 객체” → “스프링이 관리하는 객체”
핵심 요약
- 스프링의 본질은 웹이 아니라 객체 지향 설계
- 다형성만으로는 OCP, DIP를 지킬 수 없다
- 객체 생성과 연결을 외부로 빼야 한다
- 그 역할을 하는 것이 DI 컨테이너
- 스프링은 이 구조를 산업 수준으로 구현한 프레임워크다
싱글톤 컨테이너 — “웹 서버에 맞게 객체를 다루는 법”
스프링이 싱글톤을 쓰는 이유는 기술 취향이 아니라 트래픽 현실 때문이다.
웹 애플리케이션의 기본 전제는 이것이다.
동시에 수백~수천 명이 같은 객체를 호출한다
만약 요청마다 Service 객체를 새로 만든다면?
- 초당 100 요청 → Service 객체 100개 생성
- GC, 메모리 할당, 객체 초기화 비용 폭증
- 서버는 “일”보다 “객체 만들기”에 더 바빠진다
그래서 스프링은 기본 전략을 이렇게 잡는다.
“애플리케이션에서 객체는 하나만 만들고, 다 같이 쓰자”
싱글톤 패턴의 본질
싱글톤 패턴이란
JVM 안에서 어떤 클래스의 인스턴스를 딱 하나만 유지하는 설계
class SingletonService {
private static final SingletonService instance = new SingletonService();
private SingletonService() {}
public static SingletonService getInstance() {
return instance;
}
}
의도는 훌륭하다.
- 객체를 하나만 만든다
- 모두가 공유한다
- 메모리와 성능이 절약된다
문제는 현실이다.
전통적 싱글톤 패턴이 망가지는 이유
싱글톤 패턴은 객체 지향 원칙을 깨기 시작한다.
문제 왜 심각한가
| private 생성자 | 상속 불가, 테스트 불가 |
| static 접근 | DIP 위반 |
| 구현체에 직접 의존 | OCP 위반 |
| 테스트 격리 불가능 | 상태 초기화 지옥 |
| 유연성 붕괴 | 교체, 확장 거의 불가 |
한마디로
“성능 얻고 설계 잃는 패턴”
스프링이 만든 해답: 싱글톤 컨테이너
스프링은 묻는다.
“굳이 클래스가 싱글톤을 관리할 필요가 있을까?”
“컨테이너가 대신 해주면 안 되나?”
그래서 나온 것이 싱글톤 레지스트리다.
스프링 컨테이너는
- 객체를 딱 한 번만 생성하고
- 그 인스턴스를
- 모든 요청에게 공유한다
하지만
- 클래스는 평범한 POJO
- private 생성자 필요 없음
- static 필요 없음
“싱글톤의 이점만 취하고, 단점은 버렸다”
스프링 빈은 기본이 싱글톤이다
@Bean
public MemberService memberService() {
return new MemberService(memberRepository());
}
이 메서드는
- 100번 호출되지 않는다
- 스프링이 딱 1번 호출한다
- 그 결과를 컨테이너에 저장한다
- 이후에는 같은 객체를 계속 돌려준다
그래서
ac.getBean(MemberService.class)
을 100번 호출해도
항상 같은 인스턴스가 나온다.
그런데 이건 위험하다
싱글톤의 가장 큰 함정은 이것이다.
“하나의 객체를 여러 쓰레드가 동시에 쓴다”
이 상태에서 객체 안에 상태가 있다면?
class OrderService {
private int price; // 🚨 공유 필드
}
Thread A가 price = 10000
Thread B가 price = 20000
A는 10000 주문을 넣었는데
계산 결과는 20000이 된다.
실제 대형 장애의 전형적인 패턴이다
싱글톤에서의 절대 규칙: 무상태(Stateless)
스프링 빈은 반드시 이렇게 설계해야 한다.
- 필드에 고객별 데이터 저장 ❌
- 공유 가능한 상수만 허용 ⭕
- 값은 파라미터로 전달 ⭕
- 지역변수 사용 ⭕
- ThreadLocal은 제한적으로 ⭕
즉,
“객체는 로직만 들고, 데이터는 들지 않는다”
컴포넌트 스캔 — “스프링에게 수색 권한을 넘기다”
처음 스프링을 배울 때는 이런 코드를 쓴다.
@Bean
public MemberService memberService() {
return new MemberService(memberRepository());
}
이게 3개면 괜찮다.
30개면 귀찮다.
300개면 재앙이다.
“내가 만든 클래스들인데, 왜 하나하나 다 등록해야 하지?”
그래서 스프링이 만든 기능이 컴포넌트 스캔이다.
@ComponentScan의 역할
컴포넌트 스캔은 한 줄로 요약된다.
“@Component 붙은 애들, 네가 알아서 다 찾아서 빈으로 등록해”
@Component
class OrderService { ... }
이 한 줄만 붙이면
- 스프링이 클래스패스를 뒤진다
- 이 클래스를 발견한다
- 자동으로 Bean으로 등록한다
이제 @Bean 지옥에서 탈출한다.
어디서부터 찾을까?
스프링이 모든 클래스를 다 뒤지게 하면 느리다.
그래서 시작 지점이 필요하다.
가장 좋은 전략은 이것이다.
메인 설정 클래스를 프로젝트 최상위 패키지에 둔다
com.hello
├── AppConfig
├── service
├── repository
└── controller
그리고
@ComponentScan
public class AppConfig {}
그러면 com.hello 하위는 전부 자동 스캔된다.
“루트 패키지를 기준으로 쓸데없는 클래스는 배제한다”
@Component 말고 왜 여러 애노테이션이 있나?
사실 이들은 전부 내부적으로 @Component를 상속한다.
- @Controller
- @Service
- @Repository
- @Configuration
그런데 굳이 나눈 이유는 의미와 기능 때문이다.
애노테이션 스프링이 하는 일
| @Controller | MVC 컨트롤러로 인식 |
| @Service | 의미적 마커 (비즈니스 로직) |
| @Repository | 예외 변환 처리 |
| @Configuration | 싱글톤 보장용 CGLIB 처리 |
즉,
사람에게는 의미를, 스프링에게는 힌트를 준다
자동 주입: @Autowired의 진짜 역할
컴포넌트 스캔이 객체를 찾는 것이라면
@Autowired는 객체를 연결하는 것이다.
@Component
class OrderService {
private final MemberRepository memberRepository;
@Autowired
public OrderService(MemberRepository memberRepository) {
this.memberRepository = memberRepository;
}
}
스프링은 이렇게 생각한다.
“OrderService가 MemberRepository를 원하네?
내가 가진 Bean 중에 그 타입 하나 찾아서 넣어줄게”
이게 의존관계 자동 주입이다.
주입 방식 4종류
스프링이 제공하는 방법은 4가지다.
방식 평가
| 생성자 주입 | ⭐⭐⭐⭐⭐ |
| setter 주입 | ⭐⭐ |
| 필드 주입 | ❌ |
| 일반 메서드 주입 | 거의 안 씀 |
왜 생성자 주입이 압도적으로 좋은가?
1. 불변성
class OrderService {
private final MemberRepository repo;
}
final이 붙으면
생성 시점에만 설정 가능하다.
중간에 누가 바꿀 수 없다.
→ 싱글톤에서 가장 중요한 안정성 확보
2. 컴파일 타임 안전성
생성자에서 안 넣으면 컴파일 에러다.
public OrderService(MemberRepository repo) {
this.repo = repo; // 안 넣으면 오류
}
setter 방식은 런타임에야 터진다.
3. 순수 자바 테스트 가능
new OrderService(new FakeRepository());
스프링 없이도 테스트 가능
→ DI 프레임워크에 종속되지 않는다
필드 주입은 왜 쓰지 말아야 하나?
@Autowired
private MemberRepository repository;
이 방식의 문제는 치명적이다.
- new로 객체 생성 불가
- 테스트 불가능
- 스프링 없으면 아무것도 못 함
“프레임워크가 아니면 존재할 수 없는 객체”
→ 좋은 객체지향이 아니다
선택적 의존성 처리
가끔 이런 경우가 있다.
“있으면 쓰고, 없어도 돌아가야 함”
이때 쓸 수 있는 방식이 3개다.
@Autowired(required=false)
public void setRepo(MemberRepository repo) {}
public void setRepo(@Nullable MemberRepository repo) {}
public void setRepo(Optional<MemberRepository> repo) {}
즉,
의존성도 계약이다. 필수냐 선택이냐를 코드로 표현한다
Lombok과 생성자 주입의 궁합
@RequiredArgsConstructor
@Component
class OrderService {
private final MemberRepository repo;
}
이 한 줄이
public OrderService(MemberRepository repo) {
this.repo = repo;
}
를 자동 생성한다.
DI 설계는 유지하고, 코드는 지운다
빈 생명주기 콜백 — “객체가 깨어나는 순간과 죽는 순간”
스프링에서 객체는 그냥 new로 태어나지 않는다.
컨테이너라는 거대한 생태계 속에서 정해진 순서로 생존한다.
그 흐름은 이렇게 흘러간다.
컨테이너 생성 → 빈 생성 → 의존관계 주입 → 초기화 → 사용 → 소멸 직전 → 컨테이너 종료
여기서 핵심 포인트는 이것이다.
객체는 생성자에서 아직 쓸 준비가 안 되어 있다
왜냐하면
의존관계 주입이 끝나야 진짜 객체가 완성되기 때문이다.
왜 초기화 시점이 필요한가?
예를 들어 이런 객체가 있다.
class NetworkClient {
private String url;
public void connect() {
// 서버 연결
}
}
이 객체는
- 생성자 실행
- url 주입
- connect()
이 순서여야 안전하다.
그런데 생성자에서 connect()를 해버리면?
→ 아직 url이 없다
→ NullPointerException
→ 서비스 폭발
그래서 스프링은 말한다.
“의존관계 다 넣어줬으니 이제 초기화해”
이 시점을 알려주는 것이 초기화 콜백이다.
빈 생명주기 이벤트
단계 의미
| 객체 생성 | new 실행 |
| 의존관계 주입 | @Autowired, 생성자 |
| 초기화 콜백 | 이제 써도 됨 |
| 사용 | 비즈니스 로직 |
| 소멸 전 콜백 | 자원 정리 |
| 컨테이너 종료 | JVM 종료 |
초기화는 “준비 완료” 신호고
소멸 콜백은 “정리하고 떠나라”는 신호다.
스프링이 제공하는 3가지 방법
1. 인터페이스 방식 (옛날 방식)
InitializingBean
DisposableBean
스프링 코드에 의존해야 해서
요즘은 거의 안 쓴다
2. 설정 정보에 메서드 지정
@Bean(initMethod = "init", destroyMethod = "close")
외부 라이브러리에 유용하다.
3. @PostConstruct, @PreDestroy ⭐⭐⭐⭐⭐
@PostConstruct
public void init() {}
@PreDestroy
public void close() {}
- 자바 표준
- 스프링에 종속되지 않음
- 가장 깔끔
실무에서는 이 방식이 정답이다
빈 스코프 — “이 객체는 언제까지 살아야 하나?”
스코프는 객체의 수명이다.
싱글톤 (기본)
- 애플리케이션 시작 → 종료까지
- 대부분의 서비스, 리포지토리
프로토타입
- 요청할 때마다 새 객체
- 스프링은 생성까지만 책임짐
- 소멸 콜백 ❌
“너가 만든 객체니까, 네가 정리해라”
웹 스코프
스코프 수명
| request | HTTP 요청 1번 |
| session | 로그인 세션 |
| application | 서블릿 컨텍스트 |
“로그인 정보”, “요청 추적 ID” 같은 것에 사용한다
스프링이 이렇게까지 관리하는 이유
스프링은 단순한 DI 프레임워크가 아니다.
객체의 탄생부터 죽음까지를 관리하는 운영체제다
이걸 이해하면
- 왜 싱글톤이 안전한지
- 왜 생성자에서 로직을 넣으면 안 되는지
- 왜 @PostConstruct가 중요한지
전부 연결된다.
후기
스프링 부트로 여러 프로젝트를 진행해왔지만, 그동안은 실전에 적용하는 데에만 급급했고 ‘객체지향’ 자체에 대해 깊이 고민해보지 못했다. 이번에 다시 스프링의 시작점부터 정리하면서, 예전에 스쳐 지나갔던 개념들이 왜 필요한지 비로소 이해하게 되었다. 그동안 내가 작성해온 코드와 프로젝트들이 어떤 원리로 동작하고 있었는지, 그리고 그 안에 어떤 설계가 숨어 있었는지를 처음으로 제대로 바라보게 된 경험이었다.