← Documents

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). 자동차의 액셀·브레이크·핸들 같은 기능(역할)을 미리 정해 두면, 자동차 종류(구현체)가 바뀌어도 운전자(클라이언트)는 새로 배울 필요가 없다. 로미오·줄리엣이라는 역할은 여러 배우(구현체)로 대체 가능하고, 역량만 다를 뿐 기능은 모두 갖췄다.

다형성 — 역할(인터페이스)과 구현(구현체) DiscountPolicy (역할) FixDiscountPolicy RateDiscountPolicy 클라이언트는 역할(인터페이스)만 알면 되고, 구현체는 실행 시점에 유연하게 교체된다
역할(인터페이스)에 여러 구현체가 대응 — 클라이언트는 구현 내부를 몰라도 되고, 구현이 바뀌어도 영향받지 않는다

다형성의 본질은 "인터페이스를 구현한 객체 인스턴스를 실행 시점에 유연하게 변경"하는 것이다. 단, 역할(인터페이스)이 바뀌면 모든 게 흔들리므로 — 인터페이스를 안정적으로 설계하는 것이 진짜 실력이다. 스프링은 이 다형성을 극대화하도록 도와준다.

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; } }
Before — 직접 new (DIP·OCP 위반) OrderServiceImpl DiscountPolicy RateDiscount 구현체까지 직접 의존 → new 로 생성 After — AppConfig 주입 (DIP 충족) AppConfig (기획자) OrderServiceImpl DiscountPolicy 생성·주입은 AppConfig 가, 서비스는 인터페이스만 의존
구현체 생성·연결의 책임을 AppConfig 로 분리 → 서비스는 인터페이스에만 의존(DIP), 구현 교체 시 서비스 코드 불변(OCP)

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)); }
스프링 빈 컨테이너 AppConfig Spring Container memberServiceorderServicememberRepositorydiscountPolicy Client 등록 getBean
AppConfig 의 @Bean 들이 컨테이너에 등록되고, 클라이언트는 getBean(이름/타입)으로 꺼내 쓴다

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 이 여러 번 호출돼도 같은 인스턴스를 반환하도록(싱글톤) 보장한다.

순수 DI — 호출마다 new 요청마다 다른 객체 (메모리 낭비) 싱글톤 컨테이너 — 1개 공유 단일 인스턴스 누가 호출해도 같은 객체
순수 DI 는 호출마다 새 객체를 만들지만, 스프링 컨테이너는 빈을 싱글톤으로 공유한다

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 로 등록돼 있어야 한다.)

컴포넌트 스캔 & 자동 주입 패키지 내 @Component 클래스 @Component MemberServiceImpl @Component MemoryRepository @Component RateDiscountPolicy @ComponentScan 스캔 → 빈 등록 Spring Container memberServiceImpl memoryMemberRepository rateDiscountPolicy @Autowired
@ComponentScan 이 @Component 클래스를 빈으로 등록하고, @Autowired 가 타입이 맞는 빈을 찾아 자동 주입한다

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 @RequiredArgsConstructorfinal 필드 생성자를 자동 생성한다. 같은 타입 빈이 둘 이상이면 @Qualifier@Primary 로 어떤 빈을 주입할지 정한다.

@Autowired — 타입으로 조회해 주입 Spring Container MemberRepository 빈 DiscountPolicy 빈 OrderServiceImpl private final MemberRepository ... private final DiscountPolicy ... 생성자 주입 (권장) 타입이 맞는 빈
@Autowired 는 컨테이너에서 매개변수 타입과 일치하는 빈을 찾아 주입한다 (생성자 주입 권장)

7 · 빈 생명주기 콜백 (Bean LifeCycle)

DB 커넥션 풀·네트워크 소켓처럼 시작 시점에 연결을 맺고 종료 시점에 정리하려면 객체의 초기화·종료 작업이 필요하다. 스프링 빈의 생명주기는 다음과 같다.

빈 생명주기 컨테이너생성 빈 생성 의존관계주입 초기화 콜백@PostConstruct 사용 소멸전 콜백@PreDestroy 종료
컨테이너 생성 → 빈 생성 → 의존관계 주입 → 초기화 콜백 → 사용 → 소멸전 콜백 → 종료

주입 방식에 따라 "빈 생성 → 의존관계 주입"의 순서가 달라진다 — 생성자 주입은 객체 생성과 주입이 동시(관계가 없으면 객체가 안 만들어짐)에 일어나고, 수정자/필드 주입은 기본 생성자로 빈 객체를 먼저 만든 뒤 주입한다. 콜백을 받는 방법은 세 가지:

// ① @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 는 호출되지 않음)
Singleton 같은 빈 컨테이너가 끝까지 관리 Prototype 요청마다 새 빈 → 이후 클라이언트 관리
싱글톤은 컨테이너가 끝까지 관리(같은 빈), 프로토타입은 생성·주입·초기화까지만 하고 매번 새 빈을 반환

프로토타입을 싱글톤 빈 안에서 쓰면 "싱글톤이 처음 주입받은 프로토타입을 계속 쓰는" 문제가 생긴다 — 매번 새 인스턴스가 필요하면 ObjectProvider/Provider사용 시점에 조회하면 된다. 웹 request 스코프도 같은 이유로 프록시/Provider 로 지연 조회한다.


📓 2022–2023년 제가 백엔드를 준비하며 김영한님의 스프링 기본편 강의 + 개별 학습을 직접 정리한 노트입니다 · 원본 Notion 에서 보기 ↗