JAVA · Spring Framework 기본
한 줄 소개 — 스프링의 핵심은 결국 좋은 객체지향 설계를 도와주는 것이다. 이 글은 그 출발점인 다형성(역할/구현)과 SOLID 부터, 스프링이 이를 어떻게 구현했는지 — DI 컨테이너 · 싱글톤 · 컴포넌트 스캔 · 의존관계 주입 · 빈 생명주기 · 빈 스코프 — 를 8개 섹션의 코드와 다이어그램으로 정리한 학습 노트다. (김영한님의 스프링 기본편 강의 + 개별 학습 정리)
1 · 스프링의 역사와 객체지향
불편했던 EJB 컨테이너 기술에 대한 반발로 "순수 자바(POJO)로 돌아가자"는 흐름이 생겼고, Rod Johnson 이 Spring 을, Gavin King 이 Hibernate 를 만들었다. EJB 엔티티 빈의 대안으로 Hibernate 가 나왔고, 이를 바탕으로 자바 표준 JPA 가 만들어졌다(JPA 는 표준 인터페이스이고 구현체는 따로 존재).
스프링은 하나로 정의하기 어려운, 여러 기능을 종합한 프레임워크다 — 핵심 기술(DI 컨테이너 · AOP · 이벤트), 웹(MVC · WebFlux), 데이터 접근(트랜잭션·JDBC·ORM), 기술 통합(캐시·메일·스케줄링), 테스트. 스프링 부트는 이를 편리하게 쓰도록 — Tomcat 같은 웹 서버를 내장하고, 버전이 맞는 라이브러리를 "고구마 줄기처럼" 묶어 주는 starter 의존성을 제공한다.
스프링은 자바(객체지향 언어) 기반 프레임워크이고, 객체지향의 강력한 특징을 살려 "좋은 객체지향 애플리케이션"을 개발하도록 도와주는 것이 핵심이다.
객체지향과 다형성
객체지향은 프로그램을 여러 독립된 단위, 즉 객체들의 모임으로 보고 객체끼리 메시지를 주고받으며 협력하게 하는 것이다. 덕분에 유연하고 변경이 쉬워 대규모 개발에 적합하다. 그 핵심이 다형성 — 레고 블록·키보드·컴퓨터 부품을 갈아 끼우듯, 다른 시스템을 건드리지 않고 그 부분만 교체할 수 있게 한다.
다형성 = 역할(Interface) + 구현(Implements). 자동차의 액셀·브레이크·핸들 같은 기능(역할)을 미리 정해 두면, 자동차 종류(구현체)가 바뀌어도 운전자(클라이언트)는 새로 배울 필요가 없다. 로미오·줄리엣이라는 역할은 여러 배우(구현체)로 대체 가능하고, 역량만 다를 뿐 기능은 모두 갖췄다.
다형성의 본질은 "인터페이스를 구현한 객체 인스턴스를 실행 시점에 유연하게 변경"하는 것이다. 단, 역할(인터페이스)이 바뀌면 모든 게 흔들리므로 — 인터페이스를 안정적으로 설계하는 것이 진짜 실력이다. 스프링은 이 다형성을 극대화하도록 도와준다.
2 · 객체지향 원리(SOLID)와 DIP·OCP
| 원칙 | 의미 |
|---|---|
| SRP | 단일 책임 — 한 클래스는 하나의 책임만 |
| OCP | 개방-폐쇄 — 확장엔 열려 있고 변경엔 닫혀 있게(다형성 활용) |
| LSP | 리스코프 치환 — 하위 타입은 상위 타입의 규약을 지켜야 |
| ISP | 인터페이스 분리 — 역할에 맞게 인터페이스를 잘게 |
| DIP | 의존관계 역전 — 구현이 아니라 인터페이스(역할)에 의존 |
기획자가 할인 정책을 바꾸고 싶다고 한다. 아래 코드는 다형성을 쓴 것 같지만 사실 DIP·OCP 를 위반한다 — OrderServiceImpl 이 인터페이스 DiscountPolicy 뿐 아니라 구현체 RateDiscountPolicy 까지 직접 의존하기 때문이다.
public class OrderServiceImpl implements OrderService {
// ❌ 인터페이스 + 구현체를 동시에 의존 → DIP 위반
private final DiscountPolicy discountPolicy = new RateDiscountPolicy();
}"인터페이스를 의존하고 있고, 구현체만 바꿔 끼웠을 뿐인데 뭐가 문제지?" → 문제는 서비스 구현체 안에서 할인 구현체를 직접 new 로 주입한다는 것. 구현체만 바꾸면 끝나야 하는데 서비스 코드까지 손대야 하니 OCP 위반이다. 비유하면, 기름차를 전기차로 바꿨더니 나(주문 서비스)한테 새 운전 면허를 요구하는 격.
new 를 지우고 인터페이스만 두면 구현체가 없어 NPE 가 난다. 그렇다면 — 구현체를 다른 곳에서 생성해 주입하면 된다. "다양한 책임을 한 배우에게 맡기지 않는다. 배우를 섭외하는 건 공연 기획자의 몫"이라는 관심사의 분리 다. 그 기획자가 바로 AppConfig.
// AppConfig — 구현 객체를 생성하고 연결(주입)하는 책임을 가진 설정 클래스 (공연 기획자)
public class AppConfig {
public MemberService memberService() {
return new MemberServiceImpl(new MemoryMemberRepository()); // 생성자 주입
}
public OrderService orderService() {
return new OrderServiceImpl(new MemoryMemberRepository(), new FixDiscountPolicy());
}
}
// OrderServiceImpl 은 이제 인터페이스에만 의존 — 어떤 구현체가 올지 모른다 (DIP 충족)
public class OrderServiceImpl implements OrderService {
private final MemberRepository memberRepository;
private final DiscountPolicy discountPolicy;
public OrderServiceImpl(MemberRepository memberRepository, DiscountPolicy discountPolicy) {
this.memberRepository = memberRepository;
this.discountPolicy = discountPolicy;
}
}3 · 스프링 빈 컨테이너
위의 AppConfig 를 스프링이 대신 관리하게 만든다 — @Configuration + @Bean 으로 등록하고, 스프링 컨테이너(ApplicationContext)가 빈을 생성·관리한다. 이제 객체를 직접 new 하지 않고 컨테이너에서 꺼내 쓴다.
@Configuration
public class AppConfig {
@Bean public MemberService memberService() { return new MemberServiceImpl(memberRepository()); }
@Bean public MemberRepository memberRepository() { return new MemoryMemberRepository(); }
}
// 컨테이너 생성
ApplicationContext ac = new AnnotationConfigApplicationContext(AppConfig.class);
// 조회 — 이름+타입 / 타입만
MemberService ms = ac.getBean("memberService", MemberService.class);
MemberService ms2 = ac.getBean(MemberService.class); // 빈 이름은 @Bean 메서드 이름
// 등록된 빈 전체 출력 (내가 등록한 빈만: ROLE_APPLICATION)
for (String name : ac.getBeanDefinitionNames()) {
if (ac.getBeanDefinition(name).getRole() == BeanDefinition.ROLE_APPLICATION)
System.out.println(name + " = " + ac.getBean(name));
}4 · 싱글톤과 스프링 컨테이너
웹 애플리케이션은 보통 여러 고객이 동시에 요청한다. 순수 DI 컨테이너(AppConfig)는 요청마다 객체를 새로 생성한다.
AppConfig appConfig = new AppConfig();
MemberService a = appConfig.memberService(); // 새 객체
MemberService b = appConfig.memberService(); // 또 새 객체
assertThat(a).isNotSameAs(b); // a != b (참조 주소가 다름)배달의민족은 피크 때 초당 5만 TPS 가 나온 적도 있다 한다. 순수 자바로 짰다면 초당 5만 개(내부 객체까지면 더 많은) 객체를 생성·소멸시키는 셈 — 엄청난 메모리 낭비다. 해결책은 객체를 딱 1개만 만들어 공유하는 것, 즉 싱글톤 패턴이다. (요즘 사양으론 수백 TPS 정도는 순수 자바로도 버티긴 한다.)
public class SingletonService {
private static final SingletonService instance = new SingletonService(); // static 영역에 1개
public static SingletonService getInstance() { return instance; } // 항상 같은 인스턴스 반환
private SingletonService() {} // new 막기 (private 생성자)
}스프링 컨테이너는 곧 싱글톤 컨테이너다 — 빈을 싱글톤으로 관리한다. 그래서 getBean 은 항상 같은 인스턴스를 반환한다. 단 주의: 싱글톤은 공유되므로 무상태(stateless)로 설계해야 한다(특정 클라이언트에 의존하는 가변 필드 금지). 또 @Configuration 이 붙은 설정 클래스는 스프링이 CGLIB 로 바이트코드를 조작해 @Bean 이 여러 번 호출돼도 같은 인스턴스를 반환하도록(싱글톤) 보장한다.
5 · 컴포넌트 스캔 (ComponentScan)
빈이 많아지면 @Bean 으로 일일이 등록하는 게 반복 작업이 된다. 스프링은 설정 정보 없이도 @Component 가 붙은 클래스를 자동으로 빈 등록하는 컴포넌트 스캔과, 의존관계를 자동 주입하는 @Autowired 를 제공한다.
@Configuration
@ComponentScan( // 패키지 내 @Component 를 모두 찾아 빈 등록
excludeFilters = @ComponentScan.Filter(type = FilterType.ANNOTATION, classes = Configuration.class)
) // @Configuration 안에도 @Component 가 있어 제외
public class AutoAppConfig {}
@Component
public class MemberServiceImpl implements MemberService {
private final MemberRepository memberRepository;
@Autowired // 타입에 맞는 빈을 컨테이너에서 찾아 자동 주입 (≈ ac.getBean(MemberRepository.class))
public MemberServiceImpl(MemberRepository memberRepository) {
this.memberRepository = memberRepository;
}
}
@Component public class MemoryMemberRepository implements MemberRepository { /* ... */ }@ComponentScan 은 구현체들을 빈으로 등록해 주지만 의존관계 주입은 어떻게? → 생성자에 @Autowired 를 붙이면 그 매개변수 타입에 맞는 빈을 컨테이너에서 찾아 자동 연결한다. (물론 주입할 구현체도 @Component 로 등록돼 있어야 한다.)
6 · 의존관계 주입 (DI)
의존관계 주입 방법은 4가지 — 생성자 주입 · 수정자(setter) 주입 · 필드 주입 · 일반 메서드 주입.
// ① 생성자 주입 (권장) — 생성자가 1개면 @Autowired 생략 가능
@Component
public class OrderServiceImpl implements OrderService {
private final MemberRepository memberRepository; // final → 불변·필수
private final DiscountPolicy discountPolicy;
public OrderServiceImpl(MemberRepository memberRepository, DiscountPolicy discountPolicy) {
this.memberRepository = memberRepository;
this.discountPolicy = discountPolicy;
}
}
// ② 수정자(setter) 주입 — 선택·변경 가능 (주입 대상이 없어도 되게 하려면 required=false)
@Autowired(required = false)
public void setDiscountPolicy(DiscountPolicy discountPolicy) { this.discountPolicy = discountPolicy; }
// ③ 필드 주입 — 간편하지만 비권장(테스트·변경 어려움)
@Autowired private MemberRepository memberRepository;| 방식 | 특징 | 권장 |
|---|---|---|
| 생성자 | 객체 생성과 동시에 1번 주입 · 불변(final) · 필수 · 테스트 용이 | ★ 기본 |
| 수정자(setter) | 생성 후 주입 · 선택/변경 가능 | 선택적 의존관계 |
| 필드 | 코드는 짧지만 외부 변경/테스트 어려움 | 비권장 |
생성자 주입을 권장한다 — 불변·필수 의존관계를 보장하고(final), 누락 시 즉시 드러나며, 순수 자바 테스트가 쉽다. 실무에선 Lombok @RequiredArgsConstructor 로 final 필드 생성자를 자동 생성한다. 같은 타입 빈이 둘 이상이면 @Qualifier 나 @Primary 로 어떤 빈을 주입할지 정한다.
7 · 빈 생명주기 콜백 (Bean LifeCycle)
DB 커넥션 풀·네트워크 소켓처럼 시작 시점에 연결을 맺고 종료 시점에 정리하려면 객체의 초기화·종료 작업이 필요하다. 스프링 빈의 생명주기는 다음과 같다.
주입 방식에 따라 "빈 생성 → 의존관계 주입"의 순서가 달라진다 — 생성자 주입은 객체 생성과 주입이 동시(관계가 없으면 객체가 안 만들어짐)에 일어나고, 수정자/필드 주입은 기본 생성자로 빈 객체를 먼저 만든 뒤 주입한다. 콜백을 받는 방법은 세 가지:
// ① @PostConstruct / @PreDestroy (권장 — 표준, 간결)
@PostConstruct public void init() { /* 초기화 */ }
@PreDestroy public void close() { /* 정리 */ }
// ② 인터페이스 InitializingBean / DisposableBean (스프링 전용)
// ③ @Bean(initMethod = "init", destroyMethod = "close") (외부 라이브러리에 유용)8 · 빈 스코프 (Bean Scope)
스코프는 빈이 존재할 수 있는 범위(생명주기의 범위)다.
- singleton (기본) — 컨테이너 시작~종료까지 유지되는 가장 넓은 범위. 항상 같은 인스턴스.
- prototype — 컨테이너가 생성 · 의존관계 주입 · 초기화 콜백까지만 해 주고 더는 관리하지 않는다. 요청할 때마다 새 빈을 만들어 반환하고, 이후(소멸 콜백 등)는 클라이언트 책임.
- 웹 스코프 —
request(요청~응답) ·session(세션) ·application(서블릿 컨텍스트).
@Scope("prototype") // 기본은 "singleton"
@Component
public class PrototypeBean { /* ... */ }
// 싱글톤: 같은 인스턴스
ApplicationContext ac = new AnnotationConfigApplicationContext(SingletonBean.class);
assertThat(ac.getBean(SingletonBean.class)).isSameAs(ac.getBean(SingletonBean.class));
// 프로토타입: 매번 새 인스턴스 (init 은 호출되지만 @PreDestroy 는 호출되지 않음)프로토타입을 싱글톤 빈 안에서 쓰면 "싱글톤이 처음 주입받은 프로토타입을 계속 쓰는" 문제가 생긴다 — 매번 새 인스턴스가 필요하면 ObjectProvider/Provider 로 사용 시점에 조회하면 된다. 웹 request 스코프도 같은 이유로 프록시/Provider 로 지연 조회한다.
📓 2022–2023년 제가 백엔드를 준비하며 김영한님의 스프링 기본편 강의 + 개별 학습을 직접 정리한 노트입니다 · 원본 Notion 에서 보기 ↗