
1. 들어가며
스프링을 쓰다 보면 "프록시 객체"라는 말을 자주 듣는다.
특히 JPA 엔티티를 다룰 때, fetch = FetchType.LAZY 같은 옵션을 주면…
"아니 왜 이게 진짜 객체가 아니라 프록시야?" 하는 순간이 꼭 온다.
나도 처음엔 단순히 @NoArgsConstructor 정도 붙이는 게 관례라 생각했는데,
알고 보니 그 뒤엔 스프링과 Hibernate가 프록시 객체를 만들어내는 이유가 있었다.
이번 글에서는:
- 프록시 객체가 뭔지,
- 스프링에서 왜 쓰는지,
- 엔티티 코드에서 실제로 어떻게 쓰이고,
- 개발자가 어떤 점을 조심해야 하는지
차근차근 풀어보려고 한다.
2. 프록시 객체란 무엇인가?
프록시(Proxy)라는 단어 자체는 “대리인”이라는 뜻이다.
그러니까 진짜 객체 대신 앞에 서 있는 가짜 객체라고 보면 된다.
예를 들어, 우리가 JPA에서 Post 엔티티를 조회할 때, 항상 실제 DB에서 다 끌어와서 객체로 만드는 게 아니다.
- fetch = FetchType.LAZY 옵션이 붙어 있으면,
처음에는 진짜 User나 Comment 객체가 아니라 프록시 객체만 딱 만들어둔다. - 그러다 실제로 post.getUser() 같은 메서드를 호출하는 순간, 그제야 DB에서 조회 쿼리가 나가고 진짜 객체가 채워진다.
[ Post 엔티티 ] -------------------+
|
(프록시 껍데기 User 객체)
|
post.getUser() 호출 ─────────────> DB 조회
|
(진짜 User 객체 채워짐)
즉, 프록시 객체는 이렇게 말할 수 있다:
- 겉모습은 실제 객체처럼 보인다.
- 하지만 속을 열어보면 아직 “껍데기”일 뿐이고, 실제 데이터가 필요할 때 진짜 객체를 대신 불러오는 역할을 한다.
왜 이렇게 할까?
이유는 간단하다.
불필요하게 DB에서 전부 다 가져오는 걸 막고, 필요한 순간에만 최소한의 쿼리를 날리기 위해서다.
3. 스프링에서 프록시가 필요한 이유
프록시 객체가 왜 필요할까?
단순히 “DB 조회를 늦춘다” 말고도, 스프링에서는 정말 다양한 이유로 프록시를 적극적으로 쓴다.
(1) AOP (관점 지향 프로그래밍)
예를 들어, @Transactional 같은 어노테이션을 달면 메서드 실행 전에 트랜잭션을 열고, 끝나면 닫아야 한다.
이걸 개발자가 직접 try/catch로 매번 감싸기엔 너무 번거롭다.
그래서 스프링이 프록시 객체를 만들어서, 메서드 호출 전에 트랜잭션 로직을 끼워 넣는 거다.
즉, 내가 orderService.placeOrder()를 호출한다고 생각했지만,
사실은 프록시 객체가 먼저 가로채서 트랜잭션을 열고 → 진짜 orderService를 호출하는 구조다.
(2) 지연 로딩 (Lazy Loading)
앞에서 본 JPA 예시처럼, 연관관계가 걸려 있는 엔티티를 전부 한 번에 가져오면 성능이 망한다.
특히 게시글 1개 조회했는데 댓글 1만 개가 따라오면... DB 부하에 걸릴 수 있다.
그래서 JPA는 처음엔 프록시만 두고, 진짜 필요할 때만 쿼리를 날려서 데이터를 가져온다.
(3) 성능과 메모리 최적화
필요 없는 객체를 미리 다 만들면 서버 메모리만 잡아먹는다.
프록시를 쓰면 “필요할 때만 초기화” 하기 때문에 성능 최적화에도 도움이 된다.
정리하면,
스프링은 AOP, 트랜잭션, JPA 지연 로딩 같은 핵심 기능을 전부 프록시 기반으로 구현한다.
프록시가 없다면?
스프링이 제공하는 많은 편리한 기능들이 사실상 불가능했을 거다.
4. 스프링 프록시의 동작 원리
스프링이 프록시 객체를 만드는 방법은 크게 두 가지다.
(1) JDK Dynamic Proxy
- 인터페이스 기반으로 프록시 객체를 만든다.
- 스프링이 서비스 레이어에서 @Transactional 같은 기능을 붙일 때, 인터페이스가 있다면 이 방식을 먼저 쓴다.
- 내부적으로는 java.lang.reflect.Proxy라는 클래스를 이용해서, 메서드 호출을 가로채고 부가 기능을 넣는다.
- 인터페이스가 있으면 JDK 동적 프록시를, 없으면 다음 방식을 쓴다.
OrderService proxy = (OrderService) Proxy.newProxyInstance(
OrderService.class.getClassLoader(),
new Class[]{OrderService.class},
new InvocationHandler() {
@Override
public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
System.out.println("트랜잭션 시작");
Object result = method.invoke(realService, args);
System.out.println("트랜잭션 종료");
return result;
}
}
);
(2) CGLIB Proxy
- 클래스를 상속해서 프록시 객체를 만든다.
- 만약 서비스 클래스가 인터페이스를 구현하지 않았다면, 스프링은 CGLIB 라이브러리를 이용해서 해당 클래스를 상속받은 프록시를 만든다.
- 그래서 프록시 객체의 클래스명을 보면 $$EnhancerByCGLIB$$ 같은 게 붙어 있는 걸 자주 볼 수 있다.
class OrderService$$EnhancerByCGLIB$$12345 extends OrderService {
@Override
public void placeOrder() {
// 트랜잭션 열기
super.placeOrder();
// 트랜잭션 닫기
}
}
(3) 실제 호출 흐름
- 클래스를 상속해서 프록시 객체를 만든다.
- 만약 서비스 클래스가 인터페이스를 구현하지 않았다면, 스프링은 CGLIB 라이브러리를 이용해서 해당 클래스를 상속받은 프록시를 만든다.
- 그래서 프록시 객체의 클래스명을 보면 $$EnhancerByCGLIB$$ 같은 게 붙어 있는 걸 자주 볼 수 있다.
클라이언트
↓ (메서드 호출)
프록시 객체
↓ (부가 기능: 트랜잭션, 로깅, 지연 로딩 등)
실제 타깃 객체
정리하면,
스프링은 JDK Dynamic Proxy와 CGLIB Proxy라는 두 가지 무기를 가지고, 상황에 맞게 프록시 객체를 만들어낸다.
개발자가 “내가 호출하는 게 프록시일까? 진짜 객체일까?” 고민할 필요는 없지만,
내부 동작을 이해하면 디버깅할 때 한결 수월해진다.
5. 프록시 객체의 실제 예시
프록시라는 게 추상적으로만 들리면 감이 안 온다.
근데 우리가 매일 쓰는 JPA 엔티티 코드 안에도 이미 프록시가 숨어 있다.
나도 작업을 하면서 @Entity를 만들 때, 왜 @NoArgsConstructor(기본 생성자) 가 필요한지 이해를 못했었지만,
이게 모두 프록시객체와 연관이 되어있었다.
(1) 엔티티 예시
여기서 user와 comments에 fetch = FetchType.LAZY가 붙어 있다.
즉, 처음부터 User와 Comment를 다 가져오지 않고,
프록시 객체만 만들어 두었다가 필요할 때 쿼리를 날린다.
@Entity
@Table(name = "posts")
@Getter
@NoArgsConstructor
public class Post {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@ManyToOne(fetch = FetchType.LAZY) // ← 여기가 핵심
@JoinColumn(name = "user_id", nullable = false)
private User user;
@OneToMany(mappedBy = "post", fetch = FetchType.LAZY) // ← 여기도 마찬가지
private List<Comment> comments = new ArrayList<>();
private String title;
}
(2) 실제 동작 흐름
- findById()로 Post를 가져올 때, user는 진짜 User 객체가 아니다.
- Hibernate가 만들어둔 User 프록시 객체다.
- post.getUser()를 호출하는 순간, 프록시가 DB 조회를 실행하고 진짜 User로 교체된다.
Post post = postRepository.findById(1L).get();
// 아직 user는 프록시 상태
User user = post.getUser();
// 이 시점에서 쿼리 실행 → 진짜 User 객체 가져옴
System.out.println(user.getName());
(3) 기본 생성자가 필요한 이유
여기서 중요한 포인트 하나.
우리는 엔티티 클래스마다 @NoArgsConstructor를 붙여서 기본 생성자를 만들어주곤 한다.
이유는 단순하다.
Hibernate가 프록시 객체를 생성할 때 리플렉션을 사용하기 때문에,
기본 생성자가 없으면 객체를 만들 수 없다.
즉, @NoArgsConstructor는 그냥 관례가 아니라,
프록시 객체가 제대로 만들어지도록 보장해주는 최소 조건이다.
이걸 이해하는데 2주가 넘게 걸렸다. 나는 바보다.
(4) 그림으로 이해하기
- 처음에는 UserProxy라는 껍데기가 들어 있다.
- 메서드 호출 시점에 DB를 조회하고, 그제서야 진짜 User가 채워진다.
Post 객체
└─ user → UserProxy (껍데기)
↓ post.getUser() 호출
DB 조회 → 실제 User 객체
6. 정리
스프링에서 프록시 객체는 선택이 아니라 필수적인 기술 기반이다.
AOP로 트랜잭션을 관리하고, JPA에서 지연 로딩을 구현하고, 성능 최적화를 챙기는 거의 모든 과정이 프록시 위에서 돌아간다.
이번 글에서 정리한 걸 다시 한 번 보면:
- 프록시는 진짜 객체 대신 앞에 서 있는 대리인이다.
- 필요할 때만 DB를 조회하거나, 메서드를 가로채서 트랜잭션을 열고 닫는다.
- 그래서 우리가 흔히 쓰는 @Transactional, fetch = FetchType.LAZY 같은 기능들이 다 프록시 덕분에 가능하다.
- 하지만 == 비교, LazyInitializationException, 캐스팅 문제처럼 함정도 있으니 주의가 필요하다.
- 특히 영속성 컨텍스트의 생명주기와 맞물려서 언제 프록시가 초기화되는지 감을 잡는 게 중요하다.
결론적으로,
개발자가 “이게 지금 프록시일까 진짜 객체일까?”를 매번 신경 쓰면서 코드를 짤 필요는 없다.
하지만 프록시의 존재와 원리를 알고 있으면,
디버깅할 때 한 발 앞서 문제를 이해하고 해결할 수 있다는 게 가장 큰 장점이다.
'JAVASPRING > study' 카테고리의 다른 글
| Spring Bean — 왜 만들어야 하고, 왜 써야 할까? (0) | 2025.10.07 |
|---|---|
| N+1 문제 (3) | 2025.09.26 |
| H2 database, 개념 잡기 (0) | 2024.06.27 |
| GlobalException (0) | 2024.06.21 |
| JAVA SPRING / SMTP 이메일 인증하기 (0) | 2024.06.07 |