
대상 객체에 접근하기 전, 대리자(Proxy 객체)를 통해 간접적으로 접근하도록 만들어 주는 구조적 패턴
"프록시 객체는 원본 객체를 감싸고 있으며, 원본 객체의 메서드를 대신 실행하는 역할을 한다."
// 원본 객체
public class RealService implements Service {
@Override
public String method1(String input) {
return "RealService 실행: " + input;
}
}
// 프록시 객체 - 추가 작업을 해주고 원본 객체의 메소드를 호출
public class ProxyService implements Service {
private final RealService realService;
// Service를 구현한 모든 구현체에 대해 프록시 객체를 만들기 위해 생성자 주입
public ProxyService(RealService realService) {
this.realService = realService;
}
@Override
public String method1(String input) {
System.out.println("🔍 ProxyService: method1 호출 전 로깅");
String result = realService.method1(input);
System.out.println("✅ ProxyService: method1 호출 후 로깅");
return result;
}
}
// 클라이언트 - 프록시 객체를 호출
public class Client {
public static void main(String[] args) {
RealService realService = new RealService();
Service proxy = new ProxyService(realService); // 프록시 객체 사용
String result = proxy.method1("Hello");
System.out.println(result);
}
}
public interface Image {
void displayImage();
}
// 실제 객체 (이미지 로딩)
public class RealImage implements Image {
private String filename;
public RealImage(String filename) {
this.filename = filename;
loadFromDisk(filename);
}
private void loadFromDisk(String filename) {
System.out.println(filename + " 로 이미지를 로딩 중...");
}
@Override
public void displayImage() {
System.out.println(filename + " 이미지를 화면에 표시합니다.");
}
}
// 프록시 객체 (지연 로딩)
public class ProxyImage implements Image {
private String filename;
private RealImage realImage;
public ProxyImage(String filename) {
this.filename = filename;
}
@Override
public void displayImage() {
if (realImage == null) {
realImage = new RealImage(filename); // 실제 객체 생성 (지연 로딩)
}
realImage.displayImage();
}
}
즉시 로딩 : 엔티티를 조회할 때 자신과 연관되는 엔티티를 조인(join)을 통해 함께 조회하는 방식 (쿼리가 한번 나감)
지연 로딩 : 엔티티를 조회할 때, 자신만 로딩하고 연관되는 엔티티는 그 객체가 조회될 때 로딩 (쿼리가 두 번 나간다)
public interface AuthService {
String authenticate(String username, String password);
}
// 프록시 객체
@Service
public class AuthServiceProxy implements AuthService {
private final RestTemplate restTemplate;
public AuthServiceProxy(RestTemplate restTemplate) {
this.restTemplate = restTemplate;
}
@Override
public String authenticate(String username, String password) {
String authServerUrl = "https://oauth2-server.com/authenticate"; // 원격 서버 URL
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_JSON);
String requestBody = String.format("{\"username\": \"%s\", \"password\": \"%s\"}", username, password);
HttpEntity<String> request = new HttpEntity<>(requestBody, headers);
ResponseEntity<String> response = restTemplate.postForEntity(authServerUrl, request, String.class);
return response.getBody(); // 액세스 토큰 반환
}
}
// 클라이언트에서는 프록시 객체로 로컬 메소드를 사용하는 것처럼 authService를 이용하지만, 프록시 객체에 네트워크 연결이 들어있음
@RestController
@RequestMapping("/user")
public class UserController {
private final AuthService authService;
public UserController(AuthService authService) {
this.authService = authService; // 프록시 객체 주입
}
@PostMapping("/login")
public String login(@RequestParam String username, @RequestParam String password) {
return authService.authenticate(username, password);
}
}
=> 스프링에서 AOP를 활영하여 이미 @Aspect로 모듈화 해놓은 기능들이 많다. (@Transactional, @Cacheable, @Async 등)

public class ProxyUserService implements UserService {
private final UserService realUserService;
public ProxyUserService(UserService realUserService) {
this.realUserService = realUserService;
}
@Override
public void someMethod() {
// 추가적인 로직 (예: 캐싱, 로깅, 접근 제어 등)
realUserService.someMethod(); // 원본 객체의 메소드를 호출
}
}
@RestController
public class UserController {
private final UserService userService;
public UserController(UserService proxyUserService) {
this.userService = proxyUserService;
}
}
+--------+ +---------+ +--------------+
| Client | -----> | Proxy | -----> | Target Object|
+--------+ +---------+ +--------------+
↑ |
| |
[Advice (예: 로깅, 트랜잭션 관리)]
@Aspect
@Component
public class ServiceLoggingAspect {
@Before("execution(* com.sprint.mission.springdemo.service.UserServiceImpl.registerUser(..))")
public void beforeRegisterUser() {
System.out.println("Before registerUser");
}
}
@EnableAspectJAutoProxy
public class SpringdemoApplication {
SpringApplication.run(SpringdemoApplication.class, args);
}
@Service
public class UserServiceImpl implements UserService {
@Override
public UUID registerUser(String userName) {
User user = new User(UUID.randomUUID(), userName);
userRepository.save(user);
UserEvent event = new UserEvent(this, UserEventType.REGISTERED, "User registerd: " + userName);
eventPublisher.publishEvent(event);
return user.id();
}
}
getUser할 때, 한 번 호출된 적이 있다면 다음에는 캐싱하여 호출하는 기능
@Aspect
@Component
public class UserServiceCache {
private final Map<UUID, User> cache = new ConcurrentHashMap<>();
@Around("execution(* com.sprint.mission.springdemo.pr1.service.UserServiceImpl.getUser(..))")
public User chcheUser(ProceedingJoinPoint joinPoint) throws Throwable {
// 메소드 호출 파라미터 가져오기
Object[] args = joinPoint.getArgs();
UUID userId = (UUID) args[0];
if (cache.containsKey(userId)) {
System.out.println("Cache hit");
return cache.get(userId); //cache에 있다면 반환
}
Object result = joinPoint.proceed(args); // 아니라면 joinPoint를 실행 (getUser 메소드 실행)
User user = (User) result;
cache.put(userId, user); // 캐시에 넣고
return user;
}
}
: AOP는 공통적인 횡단 관심사를 모듈화한 것, 이벤트리스너는 독립적인 사건 하나에 대해 부가적인 것들을 할 수 있음
위의 예시는 간단히 구현해서 AOP와 이벤트리스너가 비슷한 것처럼 느껴질 수 있음! 하지만 목적이 다르다.
이번에는 AOP를 좀 더 일반적인 관점에서 구현해보자.
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
public @interface ServiceCache {
}
@Aspect
@Component
public class ServiceCacheAspect {
private final Map<String, Object> cache = new ConcurrentHashMap<>();
@Around("@annotation(com.sprint.mission.springdemo.pr1.cache.ServiceCache)") // ServiceCache라는 어노테이션이 달려있는 메소드에만 Aspect 적용
public Object cacheResult(ProceedingJoinPoint joinPoint) throws Throwable {
String cacheKey = generateKey(joinPoint);
if (cache.containsKey(cacheKey)) {
return cache.get(cacheKey);
}
Object result = joinPoint.proceed();
cache.put(cacheKey, result);
return result;
}
// 캐시의 키값을 직접 생성 - 같은 메소드가 같은 인자를 가지면 같은 키값을 가지도록!
private String generateKey(ProceedingJoinPoint joinPoint) {
StringBuilder key = new StringBuilder();
key.append(joinPoint.getSignature().toShortString()); // 메소드 이름을 받아와서 문자열로 바꾸기
for (Object arg : joinPoint.getArgs()) {
key.append("-").append(arg.toString()); // 호출하는 인자들도 하나씩 다 붙여줌
}
return key.toString();
}
}
@Service
public class UserServiceImpl implements UserService {
@Override
@ServiceCache
public User getUser(UUID userId) {
return userRepository.findById(userId);
}
}
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
public @interface ServiceLogging {
}
@Aspect
@Component
@Order(1)
public class ServiceLoggingAspect {
private static final Logger logger = LoggerFactory.getLogger(ServiceLoggingAspect.class); // 로그를 레벨별로 나눠서 출력 가능
@Around("@annotation(com.sprint.mission.springdemo.pr1.logging.ServiceLogging)")
public Object loggingResult(ProceedingJoinPoint joinPoint) throws Throwable {
// before
String methodName = joinPoint.getSignature().toShortString();
logger.info(methodName + " 메소드 실행. 인자값 : " + joinPoint.getArgs()[0]);
Object result = joinPoint.proceed();
// after
logger.info(methodName + " 메소드 결과값 : " + result);
return result;
}
}
@Override
@ServiceCache
@ServiceLogging // 캐싱이 된 상태라면, joinPoint.proceed()가 실행이 안되므로 ServiceLogging도 실행 X
public User getUser(UUID userId) {
return userRepository.findById(userId);
}
=> 이러한 매커니즘 덕에, 비즈니스 로직 코드에 공통 기능을 흩뿌려놓지 않아도 되고, 설정이나 어노테이션만으로 관심사 분리를 할 수 있다.