[Spring MVC] IoC, 빈 스코프, JPA 영속성 컨텍스트 정리
이전 포스팅에서 클라이언트의 요청이 DispatcherServlet을 거쳐 Controller → Service → Repository로 흘러가는 큰 그림을 그렸습니다. 이번에는 그 이면에서 동작하는 스프링의 핵심 마법(IoC, Bean, JPA)을 현업 개발자 관점에서 꼭 알아야 할 내용만 압축해 정리해 봅니다.
1. IoC와 DI: 왜 더 이상 new를 쓰지 않을까?
불편함의 시작
전통적인 자바 개발에서는 개발자가 직접 new MemberRepository()를 통해 객체를 생성하고 조립했습니다. 하지만 프로젝트가 커지고 의존성(MailSender, PasswordEncoder 등)이 늘어나면, 객체를 생성하고 주입하는 코드가 걷잡을 수 없이 복잡해집니다.
제어의 역전 (IoC, Inversion of Control)
스프링은 "객체의 생성과 연결 관리를 나(Spring)에게 맡겨라"라고 제안합니다. 이것이 IoC입니다. 개발자가 쥐고 있던 제어권을 스프링 컨테이너(ApplicationContext)에게 넘긴 것입니다.
의존성 주입 (DI, Dependency Injection)
가장 권장되는 방식은 '생성자 주입'입니다. 롬복(Lombok)과 결합하면 아래처럼 코드가 매우 깔끔해집니다.
@Service
@RequiredArgsConstructor
public class MemberService {
// Spring이 알아서 구현체를 찾아 주입해줌
private final MemberRepository memberRepository;
private final PasswordEncoder passwordEncoder;
}
@RequiredArgsConstructor가 final 필드의 생성자를 자동으로 만들어주고, 스프링은 이를 보고 알맞은 빈(Bean)을 조립해줍니다.
2. 빈 스코프 (Bean Scope)와 싱글톤 주의사항
스프링 컨테이너가 관리하는 객체를 빈(Bean)이라고 부릅니다. 스프링은 기본적으로 이 빈들을 싱글톤(Singleton)으로 생성합니다. 즉, 애플리케이션 전체에 딱 1개만 만들어서 돌려 씁니다.
- 장점: 객체를 매번 생성하지 않아 메모리와 성능을 크게 아낍니다.
모든 요청이 하나의 객체를 공유하므로, Service나 Controller의 필드 변수에 특정 사용자의 데이터를 저장하면 절대 안 됩니다. A 사용자의 정보가 B 사용자에게 노출되는 심각한 동시성 버그가 발생합니다. 데이터는 반드시 메서드 내부의 지역 변수로 다뤄야 한다.
3. JPA와 영속성 컨텍스트의 마법
과거에는 JDBC를 통해 직접 SQL을 문자열로 작성하고 파라미터를 세팅하는 중노동을 했습니다. 이를 해결하기 위해 등장한 ORM 기술의 자바 표준이 바로 JPA입니다.
영속성 컨텍스트 (Persistence Context)
JPA가 DB와 애플리케이션 사이에서 데이터를 관리하는 '가상의 작업대'입니다. 여기서 실무적으로 가장 많이 체감하는 기능은 단연 변경 감지(Dirty Checking)입니다.
@Transactional
public void updateName(Long id, String newName) {
User user = userRepository.findById(id).orElseThrow();
user.setName(newName);
// userRepository.save(user); ← 호출하지 않아도 UPDATE SQL이 날아감!
}
JPA는 객체를 영속성 컨텍스트에 불러올 때 최초 상태(스냅샷)를 기록한다.
그리고 트랜잭션이 끝나는 시점에 현재 객체 상태와 스냅샷을 비교하여, 변경된 부분이 있으면 알아서
UPDATE 쿼리를 만들어 DB에 쏴줍니다.4. @Transactional 심화와 가장 많이 파는 함정


데이터베이스의 상태를 변경하는 작업(비즈니스 로직) 단위인 Service 계층의 메서드에 주로 붙입니다. 실패 시 모든 작업을 원래대로 돌려놓는(Rollback) 중요한 역할을 합니다.
실무 팁: 읽기 전용 트랜잭션
클래스 레벨에 @Transactional(readOnly = true)를 기본으로 걸어두고, CUD(생성/수정/삭제)가 일어나는 메서드에만 @Transactional을 덮어씌우는 패턴이 성능 최적화에 유리합니다. (Dirty Checking 등을 생략하여 리소스 절약)
스프링의 트랜잭션은 '프록시(대리자) 객체'를 통해 동작합니다. 이 구조 때문에 아래 상황에서는 트랜잭션이 전혀 먹히지 않습니다.
1. private 메서드에 붙일 때: 프록시 객체는 외부에서 접근 가능한 public 메서드만 감쌀 수 있습니다.
2. 동일 클래스 내에서 this로 호출할 때: 같은 클래스 안에서
this.메서드()를 호출하면, 프록시를 거치지 않고 원본 객체를 직접 호출하므로 트랜잭션이 발동하지 않습니다.[JPA] 플러시(Flush)와 커밋(Commit)의 확실한 차이점
JPA의 영속성 컨텍스트(Persistence Context)를 공부하다 보면 반드시 마주치는 두 가지 개념이 있다.
바로 플러시(Flush)와 커밋(Commit)입니다.
• 영속성 컨텍스트: 나의 장바구니
• 플러시(Flush): 장바구니에 담은 물건들의 '주문서'를 작성해서 카운터에 넘기는 것 (아직 결제는 안 함, 취소 가능)
• 커밋(Commit): 카드를 긁어서 최종 결제를 완료하는 것 (돌이킬 수 없음, 영구 확정)
"동기화 작업"
- 영속성 컨텍스트의 변경 내용(등록, 수정, 삭제)을 데이터베이스에 동기화합니다.
- 즉, 모아두었던 SQL 쿼리(INSERT, UPDATE, DELETE)를 데이터베이스로 전송합니다.
- 중요: 데이터베이스에 쿼리는 전송되지만, 트랜잭션이 끝난 것은 아닙니다. 따라서 DB에 완벽히 저장(확정)된 상태가 아니며 롤백(Rollback)이 가능합니다.
"최종 확정 작업"
- 데이터베이스 트랜잭션을 종료하고 변경 사항을 영구적으로 확정합니다.
- 커밋이 완료되면 다른 사용자가 데이터베이스에서 변경된 데이터를 조회할 수 있습니다.
- 중요: JPA에서 트랜잭션을 커밋하면, 내부적으로 커밋 직전에 플러시(Flush)가 먼저 자동으로 호출됩니다. (주문서를 넘겨야 결제를 할 수 있으니까요!)
핵심 요약 표
| 구분 | 플러시(Flush) | 커밋(Commit) |
|---|---|---|
| 목적 | 영속성 컨텍스트 변경 사항을 DB에 전송 | 트랜잭션을 종료하고 DB에 영구 반영 |
| SQL 쿼리 전송 | O (보냄) | O (내부적으로 Flush를 먼저 호출하므로) |
| DB 데이터 확정 | X (아직 안 됨, 롤백 가능) | O (완전 확정, 롤백 불가) |
| 영속성 컨텍스트 비워짐? | X (비워지지 않음, 동기화만 할 뿐) | O (트랜잭션 종료 시 함께 종료/초기화됨) |
💡 현업 개발자의 팁
실무에서 개발자가 직접 em.flush()를 호출하는 일은 드뭅니다. 보통 @Transactional 애노테이션을 사용하면, 메서드가 성공적으로 끝날 때 스프링이 알아서 트랜잭션을 커밋하고, 그 직전에 JPA가 자동으로 플러시를 수행해주기 때문입니다.
다만, 트랜잭션 도중에 생성된 ID(Auto Increment) 값이 당장 필요하거나, 대량의 데이터를 처리할 때 메모리를 비우기 위해 명시적으로 플러시를 호출하는 경우가 있다.
정리
new 키워드가 사라진 이유(IoC/DI), 딱 하나만 만들어지는 객체(Singleton), save() 없이도 값이 수정이 되는(Dirty Checking), 그리고 동작하지 않는 @Transactional의 (Proxy)까지.
실무에서 마주치는 수많은 에러의 원인을 쉽게 추척 할 수 있다.
'스파르타 심화 과정' 카테고리의 다른 글
| 모니터링과 부하테스트 (0) | 2026.06.02 |
|---|---|
| CQRS (0) | 2026.05.26 |
| spring 과제 (0) | 2026.04.21 |
| spring 특강 1 (0) | 2026.04.20 |
| 4월 15일 과제 (0) | 2026.04.15 |
