| 일 | 월 | 화 | 수 | 목 | 금 | 토 |
|---|---|---|---|---|---|---|
| 1 | 2 | 3 | 4 | 5 | 6 | |
| 7 | 8 | 9 | 10 | 11 | 12 | 13 |
| 14 | 15 | 16 | 17 | 18 | 19 | 20 |
| 21 | 22 | 23 | 24 | 25 | 26 | 27 |
| 28 | 29 | 30 |
- spring boot
- 코드카타
- Effective Java
- 계산기
- 로드밸런서
- 토스
- Spring
- 스케줄러
- 빌더 패턴
- 배치
- GoF 23
- java
- 디자인 패턴
- 스프링
- 스프링 배치
- lv1
- redis
- 추상클래스
- 프록시 패턴
- Til
- DB
- 트러블슈팅
- 김영한
- 이펙티브 자바
- 자바
- 템플릿 메서드 패턴
- Spring Batch
- 성능 개선
- 백엔드
- 프로그래머스
- Today
- Total
김코딩
프록시 패턴(Proxy Pattern) 본문
디자인 패턴 시리즈의 여덟 번째 주제는 프록시 패턴(Proxy Pattern)이다.
데코레이터 패턴이 "기능을 추가"하는 거였다면, 프록시 패턴은 "접근을 제어"하는 것이다.
예를 들어, 회사에 중요한 서버가 있다고 생각해보자. 모든 사람이 직접 서버에 접근하면 위험하다. 그래서 보안 담당자가 중간에서 신원을 확인하고, 권한이 있는 사람만 서버에 접근하게 한다.
프록시 패턴은 이처럼 실제 객체 앞에 "대리인(Proxy)"을 두어 접근을 제어하는 패턴이다.
이번 글에서는 프록시 패턴이 무엇인지, 왜 필요한지, 어떻게 구현하는지 알아보자.
프록시 패턴의 정의
프록시 패턴(Proxy Pattern)은 다른 객체에 대한 접근을 제어하기 위해 대리자(Proxy)를 제공하는 패턴이다.
실제 객체를 직접 호출하는 대신, 프록시를 통해 간접적으로 호출한다.
핵심 특징:
- 실제 객체 앞에 대리인을 둔다
- 실제 객체와 동일한 인터페이스를 제공한다
- 접근을 제어하거나 추가 작업을 수행한다
- 클라이언트는 프록시인지 구분하지 못한다
현실 비유:
- 연예인의 매니저 (팬이 연예인에게 직접 접근 못함)
- 부동산 중개인 (집주인과 구매자 사이)
- VPN (실제 서버와 사용자 사이)
왜 프록시 패턴이 필요할까?
스프링 부트 애플리케이션을 개발한다고 가정해보자. 여러 서비스 클래스가 있고, 각각 데이터베이스 연결이 필요하다.
// 데이터베이스 연결 클래스
public class DatabaseConnection {
private String host;
private int port;
public DatabaseConnection(String host, int port) {
this.host = host;
this.port = port;
connect(); // 생성 즉시 연결
}
private void connect() {
System.out.println("🔌 " + host + ":" + port + " DB 연결 중...");
try {
Thread.sleep(2000); // 연결 시간 시뮬레이션
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("✅ DB 연결 완료!");
}
public String query(String sql) {
System.out.println("🔍 쿼리 실행: " + sql);
return "결과 데이터";
}
}
// 사용자 서비스
public class UserService {
private DatabaseConnection connection;
public UserService() {
connection = new DatabaseConnection("localhost", 5432);
}
public String getUser(Long id) {
return connection.query("SELECT * FROM users WHERE id = " + id);
}
}
// 상품 서비스
public class ProductService {
private DatabaseConnection connection;
public ProductService() {
connection = new DatabaseConnection("localhost", 5432);
}
public String getProduct(Long id) {
return connection.query("SELECT * FROM products WHERE id = " + id);
}
}
// 주문 서비스
public class OrderService {
private DatabaseConnection connection;
public OrderService() {
connection = new DatabaseConnection("localhost", 5432);
}
public String getOrder(Long id) {
return connection.query("SELECT * FROM orders WHERE id = " + id);
}
}
// 애플리케이션 시작
public class Application {
public static void main(String[] args) {
System.out.println("🚀 애플리케이션 시작\n");
// 모든 서비스 초기화
UserService userService = new UserService();
ProductService productService = new ProductService();
OrderService orderService = new OrderService();
System.out.println("\n✨ 애플리케이션 준비 완료\n");
// 실제로는 UserService만 사용
System.out.println("--- 사용자 조회 ---");
userService.getUser(1L);
}
}
문제점:
- 앱 시작하는 데 6초나 걸림 (DB 연결 3개 * 2초)
- ProductService, OrderService는 사용하지 않는데도 DB 연결됨
- DB 커넥션 풀 리소스 낭비
- 서비스가 100개라면? 200초 대기...
프록시 패턴은 "실제로 필요할 때만 연결하자 (Lazy Loading)"로 이 문제를 해결한다.
순수 Java로 구현하기
1단계: 공통 인터페이스 정의
public interface Image {
void display();
}
2단계: 실제 객체 (Real Subject)
public class RealImage implements Image {
private String fileName;
public RealImage(String fileName) {
this.fileName = fileName;
loadFromDisk();
}
private void loadFromDisk() {
System.out.println(fileName + " 디스크에서 로딩 중...");
try {
Thread.sleep(2000); // 로딩 시뮬레이션
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println(fileName + " 로딩 완료!");
}
@Override
public void display() {
System.out.println(fileName + " 화면에 표시\n");
}
}
3단계: 프록시 객체
public class ImageProxy implements Image {
private String fileName;
private RealImage realImage; // 실제 객체 (처음엔 null)
public ImageProxy(String fileName) {
this.fileName = fileName;
// 실제 이미지는 생성하지 않음!
}
@Override
public void display() {
// 실제로 필요할 때만 로딩 (Lazy Loading)
if (realImage == null) {
realImage = new RealImage(fileName);
}
realImage.display();
}
}
4단계: 사용 예시
public class Main {
public static void main(String[] args) {
System.out.println("=== 프록시 사용 ===");
System.out.println("앱 시작");
// 프록시 객체 생성 (즉시 로딩 안 함)
Image image1 = new ImageProxy("사진1.jpg");
Image image2 = new ImageProxy("사진2.jpg");
Image image3 = new ImageProxy("사진3.jpg");
System.out.println("앱 준비 완료 (즉시!)\n");
// 실제로 사용할 때만 로딩
System.out.println("--- 사진1 보기 ---");
image1.display();
System.out.println("--- 사진1 다시 보기 ---");
image1.display(); // 이미 로딩됨, 즉시 표시
System.out.println("--- 사진3 보기 ---");
image3.display();
// 사진2는 끝까지 로딩 안 됨!
}
}

장점:
- 앱이 즉시 시작됨
- 필요한 이미지만 로딩됨
- 한 번 로딩한 이미지는 재사용
프록시 패턴의 종류
1. 가상 프록시 (Virtual Proxy)
위 예시처럼 무거운 객체의 생성을 지연시킨다.
// 비용이 큰 객체
public class ExpensiveObject implements Service {
public ExpensiveObject() {
System.out.println("무거운 객체 생성 중... (시간 소요)");
// 복잡한 초기화 작업
}
@Override
public void process() {
System.out.println("작업 처리");
}
}
// 가상 프록시
public class VirtualProxy implements Service {
private ExpensiveObject expensiveObject;
@Override
public void process() {
if (expensiveObject == null) {
expensiveObject = new ExpensiveObject(); // 처음 사용 시 생성
}
expensiveObject.process();
}
}
2. 보호 프록시 (Protection Proxy)
접근 권한을 제어한다.
// 문서 인터페이스
public interface Document {
void read();
void write(String content);
}
// 실제 문서
public class SecretDocument implements Document {
private String content = "극비 문서 내용";
@Override
public void read() {
System.out.println("문서 읽기: " + content);
}
@Override
public void write(String content) {
this.content = content;
System.out.println("문서 수정 완료");
}
}
// 보호 프록시
public class DocumentProxy implements Document {
private SecretDocument document;
private String userRole;
public DocumentProxy(String userRole) {
this.userRole = userRole;
this.document = new SecretDocument();
}
@Override
public void read() {
// 모든 사용자가 읽기 가능
document.read();
}
@Override
public void write(String content) {
// 관리자만 쓰기 가능
if (userRole.equals("ADMIN")) {
document.write(content);
} else {
System.out.println("❌ 권한 없음: 관리자만 수정 가능합니다");
}
}
}
// 사용
public class Main {
public static void main(String[] args) {
// 일반 사용자
Document userDoc = new DocumentProxy("USER");
userDoc.read();
userDoc.write("변경 시도");
System.out.println();
// 관리자
Document adminDoc = new DocumentProxy("ADMIN");
adminDoc.read();
adminDoc.write("관리자가 수정한 내용");
}
}
3. 원격 프록시 (Remote Proxy)
다른 서버에 있는 객체를 로컬처럼 사용한다.
// 서비스 인터페이스
public interface PaymentService {
boolean pay(int amount);
}
// 실제 원격 서비스 (다른 서버에 있다고 가정)
public class RemotePaymentService implements PaymentService {
@Override
public boolean pay(int amount) {
System.out.println("원격 서버에서 " + amount + "원 결제 처리");
return true;
}
}
// 원격 프록시
public class PaymentServiceProxy implements PaymentService {
private RemotePaymentService remoteService;
@Override
public boolean pay(int amount) {
System.out.println("네트워크 연결 중...");
if (remoteService == null) {
remoteService = new RemotePaymentService();
}
// 원격 호출
boolean result = remoteService.pay(amount);
System.out.println("연결 종료");
return result;
}
}
4. 캐싱 프록시 (Caching Proxy)
결과를 캐싱하여 성능을 개선한다.
// 데이터베이스 서비스
public interface Database {
String query(String sql);
}
// 실제 데이터베이스
public class RealDatabase implements Database {
@Override
public String query(String sql) {
System.out.println("🔍 DB 쿼리 실행: " + sql + " (느림)");
try {
Thread.sleep(1000); // DB 조회 시간
} catch (InterruptedException e) {
e.printStackTrace();
}
return "결과 데이터";
}
}
// 캐싱 프록시
public class CachingDatabaseProxy implements Database {
private RealDatabase database;
private Map<String, String> cache;
public CachingDatabaseProxy() {
this.database = new RealDatabase();
this.cache = new HashMap<>();
}
@Override
public String query(String sql) {
// 캐시에 있으면 즉시 반환
if (cache.containsKey(sql)) {
System.out.println("✨ 캐시에서 반환: " + sql + " (빠름)");
return cache.get(sql);
}
// 없으면 DB 조회 후 캐싱
String result = database.query(sql);
cache.put(sql, result);
return result;
}
}
// 사용
public class Main {
public static void main(String[] args) {
Database db = new CachingDatabaseProxy();
System.out.println("첫 번째 조회:");
db.query("SELECT * FROM users");
System.out.println("\n두 번째 조회 (같은 쿼리):");
db.query("SELECT * FROM users");
System.out.println("\n세 번째 조회 (다른 쿼리):");
db.query("SELECT * FROM products");
}
}
실전 예제: 로깅 프록시
// 사용자 서비스 인터페이스
public interface UserService {
void createUser(String name);
void deleteUser(String name);
}
// 실제 사용자 서비스
public class UserServiceImpl implements UserService {
@Override
public void createUser(String name) {
System.out.println("✅ 사용자 생성: " + name);
}
@Override
public void deleteUser(String name) {
System.out.println("🗑️ 사용자 삭제: " + name);
}
}
// 로깅 프록시
public class LoggingUserServiceProxy implements UserService {
private UserService userService;
public LoggingUserServiceProxy(UserService userService) {
this.userService = userService;
}
@Override
public void createUser(String name) {
System.out.println("📝 [로그] createUser 호출됨");
long startTime = System.currentTimeMillis();
userService.createUser(name);
long endTime = System.currentTimeMillis();
System.out.println("⏱️ [로그] 실행 시간: " + (endTime - startTime) + "ms\n");
}
@Override
public void deleteUser(String name) {
System.out.println("📝 [로그] deleteUser 호출됨");
long startTime = System.currentTimeMillis();
userService.deleteUser(name);
long endTime = System.currentTimeMillis();
System.out.println("⏱️ [로그] 실행 시간: " + (endTime - startTime) + "ms\n");
}
}
// 사용
public class Main {
public static void main(String[] args) {
UserService userService = new UserServiceImpl();
UserService proxy = new LoggingUserServiceProxy(userService);
proxy.createUser("김도균");
proxy.deleteUser("이철수");
}
}
프록시 패턴의 장점
- 개방-폐쇄 원칙: 실제 객체를 수정하지 않고 제어 로직 추가
- 단일 책임 원칙: 접근 제어를 별도 클래스로 분리
- 성능 향상: Lazy Loading, 캐싱으로 성능 개선
- 보안: 접근 권한 제어
- 로깅/모니터링: 추가 작업 수행 가능
프록시 패턴의 단점
- 복잡도 증가: 새로운 클래스가 추가됨
- 응답 지연: 프록시를 거치면서 약간의 지연 발생
- 코드 복잡: 프록시 체인이 길어질 수 있음
Spring에서의 프록시 패턴
Spring에서 프록시 패턴이 가장 많이 사용되는 곳은 바로 JPA다.
JPA의 지연 로딩 (Lazy Loading)
// 엔티티
@Entity
public class User {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String name;
// 지연 로딩 설정
@OneToMany(mappedBy = "user", fetch = FetchType.LAZY)
private List<Order> orders; // 프록시 객체!
}
@Entity
public class Order {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String productName;
private int price;
@ManyToOne
@JoinColumn(name = "user_id")
private User user;
}
// 서비스
@Service
public class UserService {
@Autowired
private UserRepository userRepository;
public void printUser(Long userId) {
// User 조회 시 orders는 프록시 객체로 들어옴
User user = userRepository.findById(userId).orElseThrow();
System.out.println("사용자: " + user.getName());
// 여기까지는 orders 테이블 조회 안 함!
// 실제로 사용할 때 쿼리 실행
System.out.println("주문 개수: " + user.getOrders().size());
// 이 시점에 SELECT * FROM orders WHERE user_id = ? 실행
}
}
실행 로그:
-- User 조회
SELECT * FROM user WHERE id = 1
-- user.getName() 호출 (orders는 아직 조회 안 함)
-- user.getOrders().size() 호출 시점에 쿼리 실행
SELECT * FROM orders WHERE user_id = 1
장점:
- User만 필요하면 Order 조회 안 함 (성능 향상)
- 필요할 때만 쿼리 실행 (Lazy Loading)
즉시 로딩 vs 지연 로딩
@Entity
public class User {
@Id
private Long id;
private String name;
// 즉시 로딩 - User 조회 시 Order도 함께 조회
@OneToMany(fetch = FetchType.EAGER)
private List<Order> orders;
}
@Entity
public class User {
@Id
private Long id;
private String name;
// 지연 로딩 - 프록시 패턴 사용!
@OneToMany(fetch = FetchType.LAZY)
private List<Order> orders; // HibernateProxy 객체
}
// 테스트
@SpringBootTest
public class LazyLoadingTest {
@Autowired
private UserRepository userRepository;
@Test
void lazyLoadingTest() {
User user = userRepository.findById(1L).orElseThrow();
// orders는 실제 List가 아닌 프록시 객체
System.out.println(user.getOrders().getClass());
// 출력: class org.hibernate.collection.internal.PersistentBag
// 실제로 사용할 때 쿼리 실행
for (Order order : user.getOrders()) {
System.out.println(order.getProductName());
}
}
}
N+1 문제와 프록시
// N+1 문제가 발생하는 코드
@Service
public class UserService {
@Autowired
private UserRepository userRepository;
public void printAllUsersOrders() {
// 1. User 전체 조회 (1번 쿼리)
List<User> users = userRepository.findAll();
// 2. 각 User마다 orders 조회 (N번 쿼리)
for (User user : users) {
System.out.println(user.getName());
System.out.println("주문 개수: " + user.getOrders().size());
// User가 100명이면 100번의 추가 쿼리 발생!
}
}
}
해결책: Fetch Join, @EntityGraph, batch size 조절하기
// Repository
public interface UserRepository extends JpaRepository<User, Long> {
// Fetch Join으로 한 번에 조회
@Query("SELECT u FROM User u JOIN FETCH u.orders")
List<User> findAllWithOrders();
}
Spring의 프록시 종류
1. JDK Dynamic Proxy (인터페이스 기반)
public interface UserService {
void createUser(String name);
}
@Service
public class UserServiceImpl implements UserService {
@Override
public void createUser(String name) {
System.out.println("사용자 생성: " + name);
}
}
// Spring이 JDK Dynamic Proxy 생성
// UserService(인터페이스) → Proxy → UserServiceImpl
2. CGLIB Proxy (클래스 기반)
@Service
public class ProductService { // 인터페이스 없이 바로 클래스
public void createProduct(String name) {
System.out.println("상품 생성: " + name);
}
}
// Spring이 CGLIB Proxy 생성
// ProductService → CGLIBProxy → 실제 ProductService
// 프록시 확인
@SpringBootTest
public class ProxyTest {
@Autowired
private UserRepository userRepository;
@Test
void checkProxy() {
User user = userRepository.findById(1L).orElseThrow();
// orders는 프록시 객체
System.out.println("Orders 타입: " + user.getOrders().getClass());
// 출력: class org.hibernate.collection.internal.PersistentBag
// 프록시 여부 확인
System.out.println("프록시인가? " +
Hibernate.isInitialized(user.getOrders())); // false
// 사용 후
user.getOrders().size();
System.out.println("프록시인가? " +
Hibernate.isInitialized(user.getOrders())); // true
}
}
실무에서 언제 사용할까?
사용하면 좋은 경우:
- 무거운 객체의 생성을 지연시킬 때
- 접근 권한을 제어해야 할 때
- 원격 객체를 로컬처럼 사용할 때
- 캐싱이 필요할 때
- 로깅, 모니터링이 필요할 때
사용하지 않아도 되는 경우:
- 객체가 가벼울 때
- 추가 제어가 필요 없을 때
- 성능이 중요하고 오버헤드를 피해야 할 때
핵심 원칙:
"객체 접근을 제어하거나 추가 작업이 필요하다면 프록시 패턴을 고려하라"
데코레이터 vs 프록시
| 구분 | 데코레이터 | 프록시 |
| 목적 | 기능 추가 | 접근 제어 |
| 생성 시점 | 클라이언트가 조합 | 프록시가 생성 관리 |
| 개수 | 여러 개 조합 가능 | 보통 하나 |
| 초점 | 무엇을 할까? | 언제 할까? |
예시:
- 데코레이터: 커피에 토핑 추가 (기능 추가)
- 프록시: 이미지 필요할 때만 로딩 (접근 제어)
결론
프록시 패턴은 실제 객체에 대한 접근을 제어하는 패턴이다.
기억해야 할 포인트:
- 실제 객체 앞에 대리인을 둔다
- Lazy Loading으로 성능 개선
- 접근 제어로 보안 강화
- 캐싱으로 효율성 향상
- Spring AOP의 핵심 기술
- 데코레이터와 비슷하지만 목적이 다르다
다음 글에서는 알고리즘을 캡슐화하는 전략 패턴을 알아볼 것이다.
다음 글 예고: 전략 패턴 - 알고리즘을 캡슐화하라
'디자인 패턴' 카테고리의 다른 글
| 템플릿 메서드 패턴(Template Method Pattern) (1) | 2026.01.14 |
|---|---|
| 전략 패턴(Strategy Pattern) (0) | 2026.01.14 |
| 데코레이터 패턴(Decorator Pattern) (0) | 2026.01.13 |
| 어댑터 패턴(Adapter Pattern) (0) | 2026.01.13 |
| 프로토타입 패턴(Prototype Pattern) (0) | 2026.01.13 |