저번에 자바의 Garbage Collection(GC)을 정리했었다. (아래 포스트 참고)
Java 프로그래밍 05 : Garbage Collection(GC)
나는 C++을 사용할 때는 소멸자(Destructor)를 정의하거나 상속 받은 것을 재정의(override)해서 쓴 적이 있다. 그러나 Java로 개발하면서 생성자는 잘 써도 소멸자는 사용해 본 기억이 없었다. 처음
buen-camino-developer.tistory.com
자바는 GC가 사용이 끝난 객체를 알아서 회수한다. 그런데 스프링은 Bean이라는 이름으로 프레임워크에서 사용되는 Java 객체의 생성 및 소멸을 직접 관리한다.
그러다보니 한 가지 궁금증이 생겼다. 스프링은 객체를 어떻게 관리하는 걸까? 분명 스프링을 배울 때 무언가를 들었던 것 같긴 한데... 급하게 배우느라 기억이 잘 나지 않는다. ㅎㅎㅠㅠ. 이참에 정확히 알아두면 좋을 것 같다.
Spring은 객체를 어떻게 관리할까?
✅ 1. 스프링이 대신 객체를 만들어 준다.
스프링은 어노테이션(Annotation)이 붙은 클래스를 자동으로 인식하여 객체를 생성한다. 이때, 싱글톤(Singleton) 패턴으로 객체를 생성하여 객체를 재활용한다.
객체를 등록하는 어노테이션은 다음과 같다.
어노테이션 | 설명 |
@Component | 가장 기본적인 스프링 빈 등록 방법 |
@Service | 서비스 계층 빈 등록 (사실은 @Component의 특수화) |
@Repository | DAO 계층에 사용, DB 관련 예외 처리 기능 추가됨 |
@Controller | 웹 요청을 처리하는 클래스에 사용 |
@RestController | @Controller + @ResponseBody |
✅ 2. 스프링이 객체를 관리하는 공간 → 스프링 컨테이너 (ApplicationContext)
스프링에서는 객체를 'Bean(빈)'이라고 부른다. 스프링은 '스프링 컨테이너'에서 객체를 관리하는데, 스프링이 관리하는 객체를 스프링 빈이라고 부른다. 위에서 말한 어노테이션이 붙은 객체들은 스프링 빈으로 등록되어 스프링에서 빈의 생명주기를 관리한다.
@Component
public class MyService {
...
}
이렇게 하면, 스프링이 이 클래스를 객체로 만들어서, 스프링 컨테이너에 등록(Bean으로 등록)한다. 즉, 스프링이 이 객체의 생명주기를 관리한다.
* 빈의 생명주기란?
- 언제 객체를 만들고 (new)
- 언제 초기화하고
- 언제까지 가지고 있다가
- 필요 없으면 언제 정리할지
✅ 3. 의존성 주입(DI: Dependency Injection)
필요한 객체가 있으면 new로 만들지 않고, 스프링이 알아서 넣어주는데, 이것을 의존성 주입(DI)라고 한다.
@Service
public class OrderService {
private final PaymentService paymentService;
public OrderService(PaymentService paymentService) { // 생성자 주입 방식
this.paymentService = paymentService;
}
}
위 코드처럼 생성자 주입 방식을 사용하면 PaymentService도 Bean이면 스프링이 자동으로 만들어서 넣어준다.
의존성 주입
나는 이 '의존성 주입'이라는 말이 유독 와닿지 않았다. 스프링으로 개발하다보면 초기에 에러가 생길 때마다 '의존성 주입이 안 되었다' 혹은 '의존성 주입은 했느냐'라는 말을 몇 번 들었는데 의존성이 뭐길래 이걸 주입하는 거고, 이걸 주입하면 뭘 얻는 걸까?
찾아보니, 빈을 주입한다는 것은 <스프링 컨테이너에 등록된 객체(빈)를 다른 객체에 끼워넣는 것>이다. 즉, 필요한 객체를 자동으로 연결해주는 것이다.
사실 여기까지 봐도 이해가 잘 안 되었다. 그래서 객체를 연결해야하는 이유가 뭔데? 왜 끼워넣어야하는데? 이 질문이 계속 떠나지 않았다.
사실 개발자는 이해가 잘 안 될 때는 역시 코드와 함께 보는 게 최고다.
다음 예시를 같이 보자.
@Component
public class Engine {} // 1. Engine 객체를 빈으로 등록
@Service
public class Car {
private final Engine engine;
@Autowired // 2. Engine 빈을 Car에 "주입"
public Car(Engine engine) {
this.engine = engine;
}
}
위의 코드를 보면 Engine 객체와 Car 객체는 각각 @Component와 @Service 어노테이션으로 인해 스프링 빈으로 등록된 상태다. 즉, 이 둘은 스프링 컨테이너에서 관리하는 녀석들이다.
자동차가 동작하기 위해서는 엔진이 필요하다. 엔진이라는 부품이 없다면 그 자동차는 고물일 것이다.
만약에 '의존성 주입'이라는 것이 없다고 생각해보자. 그렇다면 스프링 컨테이너에는 Car 객체와 Engine 객체가 따로 존재한다. 이 두 객체는 서로 영향을 줄 수 없다. 심지어 서로 존재하는지 조차 모른다. 왜냐하면 Car 객체와 Engine 객체 사이에 연결고리가 존재하지 않기 때문이다.
그러나 우리는 자동차를 만들기 위해서 Car 객체에 Engine을 연결해줘야한다. 이때 필요한 것이 '의존성 주입'이다. 쉽게 말해 두 객체 간에 연결고리를 만들어주는 것이다. Engine 빈을 Car에 의존성 주입을 해주면, 이후로 Car 빈에서는 Engine 빈을 자유롭게 접근해서 쓸 수 있다.
의존성 주입된 빈들의 생명 주기는 같은가?
그렇다면 여기서 또 한가지 궁금점이 생긴다. Car 빈과 Engine 빈 사이에 연결고리가 생긴 것은 확실하다. 그렇다면 Car 빈이 용도를 다 하고 사라질 때 연결고리가 생겼던 Engine 빈도 함께 사라지는 것일까?
정답은 아니다.
의존성이 주입되었다고 해서 빈의 생명주기가 같아지는 것은 아니다.
위에서도 설명했지만, 스프링에서는 기본적으로 모든 빈은 싱글톤(Singleton) 패턴으로 생성된다.
즉, @Component, @Service 등으로 등록된 빈은 기본적으로 스프링 컨테이너가 시작될 때 한 번만 생성되고 컨테이너가 종료될 때까지 계속 살아있다.
Car도 Engine도 둘 다 스프링이 프로그램 실행 처음부터 끝까지 들고 있는 객체다. 스프링 컨테이너가 살아있는 한 빈은 사라지지 않는다. (만약 생명주기를 맞추고 싶다면 @Scope 어노테이션으로 조절하는 법이 있긴 하다. 근데 이렇게 하면 싱글톤이 아니게 되어버림.)
위에서 다룬 내용을 정리하자면,
단계 | 설명 |
@Service, @Component 등 어노테이션 사용 | 스프링이 해당 클래스를 빈으로 인식 |
빈 등록 시점 | 어플리케이션 시작 시에 객체를 생성해서 컨테이너에 보관 (싱글톤) |
의존성 주입 시점 | 객체는 이미 있으니까, 만들어진 객체를 주입만 해줌 |
자바 vs Spring: 객체 생명주기
자, 이제 스프링이 객체를 어떻게 관리하는 지에 대해서는 알았다. 그런데 또 궁금한 게 생겼다. 자바는 GC가 저 이상 참조되지 않은 객체는 자동으로 메모리에서 정리해준다. 그런데 스프링은 직접 객체(빈)를 관리하며, 이 빈은 스프링 컨테이너가 살아있는 한 사라지지 않는다.
그러면 스프링에서는 GC가 동작하지 않는 걸까?
엄밀히 말하자면 스프링도 결국 자바 위에서 돌아가는 프레임워크이기 때문에 스프링에서 관리하던 빈도 결국 GC가 정리해준다.
다만, 스프링 빈은 스프링 컨테이너가 직접 들고 있기 때문에 참조가 끊어지지 않아 GC의 회수 대상은 아니다. 즉, 컨테이너가 종료되어야 참조가 끊기고 GC가 회수할 수 있게 된다.
항목 | Java 객체 | Spring Bean |
생성시점 | new로 생성하는 순간 | 스프링 컨테이너가 띄워질 때 생성됨 (기본은 싱글톤) |
관리 주체 | 개발자가 직접 참조 | 스프링 컨테이너가 관리 |
소멸시점 | 더 이상 참조 안 하면 GC가 회수 | 컨테이너 종료 시까지 유지, 이후 GC가 회수 |
그렇다면 직접 만든 객체는 어떻게 될까?
스프링 빈으로 등록한 객체 외에도 new를 통해 직접 만든 객체는 더 이상 쓰지 않으면 GC가 알아서 회수해준다.
Spring과 DB 연결
자바에서는 DB/파일 연결 같은 자원은 직접 정리해야한다. 생각해보면 예전에 처음 자바로 웹 개발을 배울 때는 직접 파일을 불러오고 닫을 때, DB 연결을 하고 종료할 때 close()를 써서 종료했던 것 같다.
그런데, Spring으로 오면서 DB와 관련된 객체들도 스프링이 빈으로 등록해서 관리해주기 때문에 개발자가 직접 열고 닫을 필요가 없어졌다. 예를 들어 자주 쓰는 DataSource, JdbcTemplate, EntityManager, Repository 들은 전부 스프링이 빈으로 등록해서 애플리케이션이 실행될 때 만들어두고 우리가 필요할 때 자동으로 주입(@Autowired) 해준다.
다음 예시를 보자.
DataSource는 DB 연결을 관리하는 객체이다.
@Configuration
public class DbConfig {
@Bean
public DataSource dataSource() {
return DataSourceBuilder.create()
.url("jdbc:mysql://localhost:3306/test")
.username("root")
.password("1234")
.build();
}
}
현재 @Bean 어노테이션을 통해 빈으로 등록된 것을 알 수 있다.
@Service
public class MyService {
private final DataSource dataSource;
public MyService(DataSource dataSource) {
this.dataSource = dataSource;
}
}
스프링 빈으로 등록된 DataSource는 이후 의존성 주입을 통해 MyService에서 사용할 수 있게 된다.
여기서 한 가지 중요한 점이 있다. DB 연결은 무한하지 않다. 한 번 DB에 연결하는 데 드는 시간이 제법 소요되어 요새는 DB Connection Pool이라는 것을 만들어두고 필요할 때마다 빌려서 시용하는 구조를 채택한다.
스프링 부트에서 DB 연결이 자동으로 관리되는 과정은 다음과 같다. (스프링과 스프링 부트 둘 다 DB Connection Pool을 사용할 수 있으나, 스프링에서는 직접 세세한 설정을 해줘야한다는 차이가 있다. 설명은 내가 주로 사용하는 스프링 부트 기준으로. 스프링 부트가 굉장히 편리하다는 걸 알 수 있다.)
✅ Spring Boot에서 DB 연결이 자동으로 관리되는 과정
1️⃣ DB 연결 풀(Connection Pool) 사용
- Spring Boot는 HikariCP(기본), Tomcat JDBC, C3P0 같은 연결 풀을 사용해서 DB 연결을 재사용
- 즉, 연결을 매번 만들고 끊지 않고, 필요할 때만 빌려서 사용하는 구조
2️⃣ 트랜잭션이 끝나면 자동으로 연결 반환
- Spring이 트랜잭션이 끝나면 자동으로 DB 연결을 반납
- @Transactional을 쓰면 Spring이 알아서 롤백/커밋까지 처리
3️⃣ Spring Container가 종료될 때 자동으로 자원 해제
- Spring 애플리케이션이 종료되면, Spring이 관리하는 모든 Bean의 destroy() 메서드를 호출해서 정리
Spring과 운영체제 리소스
DB처럼 스프링이 빈으로 등록해서 관리하는 자원은 개발자가 신경쓰지 않아도 된다. 그러나, 스프링이 관리하지 않는 자원은 신경쓸 필요가 있다.
예를 들어, 운영체제의 리소스(파일, 네트워크, 소켓)을 사용하는 InputStream, FileReader, BufferedReader, Socket, Connection 같은 객체들을 들 수 있다. 이런 객체들은 스프링이 관리하지 않으므로 GC의 회수 대상이다.
그러나, GC는 '메모리'만 회수하지, 운영체제 리소스는 닫아주지 않는다. 따라서, 직접 close()를 안 해주면 누수(Leak)가 발생한다.
1. try-with-resources로 닫기 (자바 7 이상)
try (FileReader reader = new FileReader("example.txt")) {
// 파일 읽기 작업
} catch (IOException e) {
e.printStackTrace();
}
// 여기서 자동으로 reader.close() 호출됨
➡ try-with-resources 방식은 자바 7 이상에서 사용하는 방식으로 try catch가 끝나면 자동으로 해당 리소스의 close()가 실행된다.
2. try-finally로 직접 닫기
FileReader reader = null;
try {
reader = new FileReader("example.txt");
// 파일 읽기 작업 수행
} catch (IOException e) {
e.printStackTrace();
} finally {
if (reader != null) {
try {
reader.close();
} catch (IOException e) {
e.printStackTrace();
}
}
}
➡ 자원을 사용하고 난 후, finally에서 직접 자원을 닫아주는 과정을 거친다. Java 버전에 상관없이 쓸 수 있다.