티스토리 뷰

구체적인 구현 클래스를 직접 참조해서 사용하게 되면 참조하는 클래스의 기능 분리를 위해 외부 클래스를 생성하였을때 이도 참조해야 하는 문제점이 발생한다.
이를 해결하려면 어떻게 해야할까?

 

런타임에 DI를 적용

실제 사용할 오브젝트 클래스 정체를 감춘채 인터페이스를 통해 간접적으로 접근함으로 구현 클래스는 얼마든지 외부에서 변경 가능하고 클라이언트와의 결합이 약해진다.

트랜잭션의 경계 설정이라는 책임과 비즈니스 로직 실행이라는 책임을 가지는 각각의 클래스를 만들고 공통 인터페이스를 구현해 각 클래스 호출시 책임을 위임한다면 클라이언트 입장에서는 이 책임들이 하나의 기능을 하는것 처럼 보일 것이다

 

예시 : 비즈니스 로직 실행 전후에 트랜잭션 경계 설정을 해주면 트랜잭션이 적용된 비즈니스 로직의 구현

public interface UserService {
 void add(User user);
 void upgradeLevels();
}
public class UserServiceImpl implements UserService {
    public void upgradeLevels(){
        List<User> users = userDao.getAll();
        for (User user : users) {
            if (canUpgradeLevel(user))
                upgradeLevel(user);
        }
    }
}
public class UserServiceTx implements UserService {

    UserService userService;

    PlatformTransactionManager transactionManager;

    public void setUserService(UserService userService) {
        this.userService = userService;
    }

    public void setTransactionManager(PlatformTransactionManager transactionManager) {
        this.transactionManager = transactionManager;
    }

    @Override
    public void add(User user) {
        userService.add(user);
    }

    @Override
    public void upgradeLevels() {
        TransactionStatus status = this.transactionManager.getTransaction(new DefaultTransactionDefinition());
        try {
            userService.upgradeLevels();
            this.transactionManager.commit(status);
        } catch (RuntimeException e) {
            this.transactionManager.rollback(status);
            throw e;
        }
    }

}

UserServiceTx는 UserService를 구현하니 클라이언트에 대해 UserService 타입 오브젝트의 하나로 행세 가능하다.

또한, 비즈니스 로직을 가지지 않고도 UserService 오브젝트를 DI 받음으로써 다른 UserService 구현 오브젝트의 기능을 위임할 수 있다

=> 클라이언트는 인터페이스를 통해서만 핵심 기능을 사용하고 이 인터페이스를 구현해서 부가기능을 적용하고 핵심기능으로 요청을 위임하는 것이다. 

 

 

프록시와 패턴

프록시

자신이 클라이언트가 사용하려고 하는 실제 대상인 것처럼 위장해서 클라이언트의 요청을 받아주는 것

타깃과 같은 인터페이스를 구현하여 타깃에 부가적인 기능을 부여하고 이에 접근하는 방법을 제어한다. 

 

타깃/실체

프록시를 통해 최종적으로 요청을 위임받아 처리하는 실제 오브젝트

 

 

위의 그림에서 두가지 패턴이 적용 된 것을 확인 할 수 있다. 

 

데코레이터 패턴

주어진 상황 및 용도에 따라 어떤 객체에 책임을 덧붙이는 패턴으로, 여기에선 타깃에 부가적인 기능을 런타임 시 다이내믹하게 부여해주기 위해 프록시 사용한다는 의미이다. 

 

컴파일 시 즉, 코드상에서 어떤 방법과 순서로 프록시와 타깃이 연결되어 사용되는지 정해져 있지 않고 프록시는 N개 가능하며 프록시로서 동작하는 각 데코레이터는 위임하는 대상에도 인터페이스로 접근함으로 몇번째 단계에 대상 혹은 데코레이터 프록시로 위임하는지 모른다.

또한, 프록시가 직접 타깃을 사용하도록 고정시킬 필요도 없다.

⇒ 다음 위임대상은 인터페이스로 선언하고, 생성자/수정자 메소드를 통해 위임 대상을 외부에서 런타임 시에 주입 받을 수 있도록 해야 한다

 

예시) InputStream 이라는 인터페이스를 구현한 타깃 FileInputStream에 데토레이터인 BufferedInputStream 적용

InputStream is = new BufferedInputStream(new FileInputStream("a.txt");

 

프록시 패턴

여기서 프록시란 타깃에 접근하는 방법을 제어하려는 목적을 가지고 다른 무언가와 이어지는 인터페이스 역할을 하는 클래스를 의미 한다. 

클라이언트에게 타깃에 대한 레퍼런스를 넘겨줄 때 실제 오브젝트를 만드는 대신 프록시를 넘겨주고 프록시의 메소드를 통해 타깃을 사용하려고 시도시, 프록시가 타겟 오브젝트를 생성하고 요청을 위임한다.

 

위의 패턴을 사용해서 필요한 오브젝트를 생성하지 않으면 좋은 경우는 무엇일까?

  • 생성하기 복잡함
  • 당장 필요하지 않음
  • 원격 오브젝트
  • 특별한 상황에서 타깃에 대한 접근 권한 제어
    • 예시 : 수정 가능한 오브젝트를 특정 레이어로 넘어가서는 읽기전용으로 강제하는 경우
Collection<Character> immutableList = Collections
                                    .unmodifiableCollection(mutablrList);

//immutableList.add('X');
//immutableList.remove(1);
//파라미터로 전달된 Collection 오브젝트 프록시를 만들어
//add() / remove()시 UnsupportedOperationException 예외 발생

구조적으로는 데코레이터와 유사하지만, 프록시는 자신이 만들거나 접근할 타겟 클래스 정보를 알고 있는 경우가 많다.

생성 지연 프록시의 경우 구체적인 생성 방법을 알아야 하기 떄문에 타깃 클래스에 대한 직접적인 정보를 알아야 한다. 

프록시 패턴이여도 인터페이스를 통해 위임 하도록 만들어 N개의 프록시들과 데코레이터들 사용 가능하다. 

 

 

👎

1. 코드 작성의 번거롭다.

타깃의 인터페이스를 구현하고 위임하는 코드를 작성하기 번거롭다.

부가기능이 필요 없는 메서드도 구현해서 타깃으로 위임하는 코드를 일일이 만들어줘야 한다.

2.부가기능 코드가 중복될 가능성이 많다.

트랜잭션 외에도 프록시를 활용할 만한 부가기능, 접근제어 기능을 일반적인 성격을 띤 것들이 많다.

따라서 다양한 타깃 클래스와 메서드에 중복돼서 나타날 가능성이 높다.

 

다이내믹 프록시

프록시 팩토리에 의해 런타임에 다이내믹하게 만들어지는 오브젝트

프록시 팩토리에게 인터페이스 정보만 제공하면 이를 구현한 클래스의 오브젝트를 자동으로 만듬으로 클래스 정의 안해도 되며 프록시로서 필요한 부가기능 제공 코드는 프록시 오브젝트와 독립적으로 InvocationHandler를 구현한 오브젝트에 담음

 

InvocationHandler

public Object invoke(Object proxy, Method method, Object[] args)

InvocationHandler 구현 클래스

public class UppercaseHandler implements InvocationHandler {

    private final Object target;

    public UppercaseHandler(Object target) {
        this.target = target;
    }

    @Override
    public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
        Object result =  method.invoke(target, args);
        if (result instanceof String) {
            return ((String) result).toUpperCase();
        }
        return result;
    }
}

순서

  1. 다이내믹 프록스 오브젝트는 클라이언트의 모든 요청을 리플렉션 정보로 변환해서 InvocationHandler 구현 오브젝트의 invoke() 메서드로 넘긴다. 
  2. InvocationHandler 구현 오브젝트가 타깃 오브젝트 레퍼런스를 갖고 있다면 리플렉션을 이용해 간단히 위임 가능하다.
  3. 리턴된 값은 다이내믹 프록시가 받아서 최종적으로 클라이언트에게 전달한다. 

 

장점

인터페이스의 메소드가 늘어도 다이내믹 프록시를 생성해서 사용하는 코드는 수정 할 것 이 없고 타깃의 종류에 상관없이 적용이 가능하다.

타깃 인터페이스의 모든 메소드 요청이 하나의 메소드로 집중됨으로 중복 기능을 효과적으로 제공 가능 하다.

다이내믹 프록시를 통해 요청이 전달 되면 리플렉션 API를 통해 타깃 오브젝트의 메소드를 호출함으로 타입 뿐만 아니라 호출하는 메소드의 이름, 파라미터의 개수와 타입, 리턴 타입 등의 정보를 가지고 부가적인 기능을 적용할 메소드를 선택 가능하다.

 

프록시 생성

Hello proxiedHello = (Hello) Proxy.newProxyInstance(getClass().getClassLoader(), //1)
                                                    new Class[]{Hello.class}, //2)
                                                    new UppercaseHandler(new HelloTarget())); //3)

1) 다이내믹 프록시가 정의되는 클래스 로더를 지정한다. 

2) 다이내믹 프록시가 구현해야 하는 인터페이스(N개)를 지정한다. 

3) 부가기능과 위임 관련 코드를 가지는 InvocationHandler 구현 오브젝트를 지정한다. 

 

 

다이내믹 프록시를 이용한 트랜잭션 부가기능

public class TransactionHandler inplements InvocationHandler {
	//부가 가능을 제공할 타깃 오브젝트
	private Object target;
	private PlatformTransactionManager transactionManager;
	//트랜잭션을 적용할 메소드 이름 패턴
	private String pattern;

	public void setTarget(Object target) {
		this.target = target;
	}

	public void setTransactionManager(PlatformTransactionManager transactionManager) {
		this.transactionManager = transactionManager;
	}

	public void setPattern(String pattern) {
		this.pattern = pattern;
	}

	public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
		//트랜잭션 적용 대상 메소드를 선별해서 트랜잭션 경계설정 기능을 부여함 
		if(method.getName().startsWith(pattern)) {
			return invokeInTransaction(method, args);
		} else {
			return method.invoke(target, args);
		}
	}

	public Object invokeInTransaction(Method method, Object[] args) throws Throwable {
		TransactionStatus status = 
			this.transactionManager.getTransaction(new DefaultTransactionDefinition());
		
		try {
			//트랜잭션을 시작하고 타깃 오브젝트의 메소드를 호출한다
			//예외가 발생하지 않았다면 커밋
			Object ret = method.invoke(target, args);
			this.transactionManager.commit(status);
			return ret;
		} catch (InvocationTargetException e) {
			this.transactionManager.rollback(status);
			throw e.getTargetException();
		}
	}
}

요청을 위임할 타깃, 트랜잭션 추상화 인터페이스, 적용할 메소드 이름을 DI로 제공받는다. 

Method.invoke()를 이용해 타깃 오브젝트의 메소드를 호출할 때는 타깃 오브젝트에서 발생하는 예외가 InvocationTargetException로 한 번 포장되서 전달됨으로 위와 같이 처리한다. 

 

 

👎

스프링은 내부적으로 리플렉션 API를 이용해서 빈 정의에 나온느 클래스 이름을 가지고 빈 오브젝트를 생성한다. 

Date now = (Date) Class.forName("java.util.Date").newInstance();

하지만, 다이내믹 프록시 오브젝트는 클래스를 내부적으로 런타임시 새로 정의해서 사용함으로 사전에 프록시 오브젝트의 클래스 정보를 미리 알아내서 스프링 빈에 정의할 방법이 없다. 

 

 

 팩토리 빈

스프링을 대신해서 오브젝트의 생성로직을 담당하도록 만들어진 특별한 빈

스프링의 FactoryBean Interface를 구현한 클래스는 스프링 빈으로 등록시 팩토리 빈으로 동작한다. 

public interface FactoryBean<T> {
	@Nullable
	T getObject() throws Exception; //1)
	@Nullable
	Class<?> getObjectType(); //2)
	default boolean isSingleton() { //3)
		return true;
	}
}

1) 빈 오브젝트를 생성해서 돌려준다.

2) 생성되는 오브젝트 타입을 알려준다.

3) getObject()가 돌려주는 오브젝트가 항상 같은 싱글톤 오브젝트인지 알려준다.

 

 

예시 ) 

public class TxProxyFactoryBean implements FactoryBean<Object> {
	Object target;
	PlatformTransactionManager transactionManager;
	String pattern;
	//다이내믹 프록시 생성시 사용 
	Class<?> serviceInterface;

	public void setTarget(Object target) {
		this.target = target;
	}

	public void setTransactionManager(PlatformTransactionManager transactionManager) {
		this.transactionManager = transactionManager;
	}

	public void setPattern(String pattern) {
		this.pattern = pattern;
	}

	public void setServiceInterface(Class<?> serviceInterface) {
		this.serviceInterface = serviceInterface; 
	}

	//DI 받은 정보를 이용해서 TransactionHandler를 사용하는 다이내믹 프록시를 생성한다. 
	public Object getObject() throws Exception {
		TransactionHandler txHandler = new TransactionHandler();
		txHandler.setTarget(target);
		txHandler.setTransactionManager(transactionManager);
		txHandler.setPattern(pattern);
		return Proxy.newProxyInstance(
				getClass().getClassLoader(), new Class[]{ serviceInterface },
				txHandler);
	}

	//팩토리 빈이 생성하는 오브젝트 타입은 DI 받은 인터페이스 타입에 따라 달라짐
	//-> 다양한 타입의 프록시 오브젝트 생성에 재사용 가능
	public Class<?> getObjectType() {
		return serviceInterface;
	}

	// getObject()가 매번 같은 오브젝트를 리턴하지 않음(싱글톤 빈 아니다 라는 뜻 아님)
	public boolean isSingleton {
		return false;
	}
}

 

프록시 클래스를 작성하지 않고 자바의 다이내믹 프록시와 스프링의 팩토링 빈을 적용하는 방법으로 한번 부가기능을 가진 프록시를 생성하는 팩토리 빈을 만들어두면 타깃의 타입에 상관없이 재사용 가능하다.

또한, 하나의 핸들러 메소드를 구현하는 것만으로 수많은 메소드에 부가기능 부여가 가능함으로 중복 코드가 줄어든다. 

마지막으로 동일한 프록시 팩토리빈을 여러개의 빈으로 등록해도 빈의 타입은 타깃 인터페이스와 동일함으로 가능하다.

 

 

👎

한 번에 여러개의 클래스에 공통적인 부가기능을 제공 거의 비슷한 프록시 팩토리 빈의 설정이 중복되게 된다. 

하나의 타깃에 여러 개의 부가기능 적용시 프록시 팩토리 빈 설정이 부가 기능 개수 만큼 추가 되어야 한다. 

InvocationHandler 구현 오브젝트가 프록시 팩토리 빈 개수 만큼 만들어지게 된다. 이는 타깃 오브젝트를 프로퍼티로 가지고 있기 때문에 동일한 부가기능을 제공하는 코드임에도 타깃 오브젝트가 달라지면 새로운 InvocationHandler 구현 오브젝트를 만들어야 한다. 

  • 팩토리 빈으로 만들지 않고 스스로 빈으로 등록 될 수 있지만 타겟에 따라 다른 빈으로 등록해 그 만큼의 오브젝트가 생겨난다. 

 

스프링의 프록시 팩토리 빈

ProxyFactoryBean

스프링에서 일관된 방식으로 프록시 오브젝트를 생성해주는 기술을 추상화한 팩토리 빈(추상 레이어) 제공하는 팩토리 빈으로 생성된 프록시는 스프링의 빈으로 등록된다. 

이전 예제와 다른점은 프록시를 생성하는 작업만 담당하고 프록시를 통해 제공할 부가기능은 별도 빈에 두기 가능하다는 것이다.

ProxyFactoryBean에 있는 인터페이스 자동검출 기능으로 타깃 오브젝트가 구현하고 있는 모든 인터페이스 정보 알아 이를 구현하는 프록시를 만들어 줌으로 인터페이스 타입을 제공 받지 않고 이를 구현한 프록시를 만들 수 있다.
(물론, setInterfaces()를 통해 지정 가능, 구현하는 인터페이스의 일부만 프록시 적용시 이를 활용)

기본적으로 JDK가 제공하는 다이내믹 프록시를 만들고 경우에 따라서는 CGLib이라고 하는 오픈소스 바이트코드 생성 프레임워크를 이용해 프록시를 만듬

 

MethodInterceptor

프록시에서 사용할 부가기능은 이 인터페이스를 구현해서 만든다.

invoke() 는 ProxyFactoryBean으로부터 타깃 오브젝트에 대한 정보도 함께 제공 받는다. 타깃 오브젝트에 대한 정보를 제공하지 않고 타깃은 InvocationHandler를 구현한 클래스가 직접 알고 있어야 하는 InvocationHandler와 다른 점이다. 

MethodInterceptor 오브젝트는 타깃 오브젝트에 상관없이 독립적으로 만들어지기 가능해 타깃이 다른 여러 프록시에서 함께 사용 가능 하고 싱글톤 빈으로 등록 가능하다.

Advice 인터페이스를 상속하고 있는 서브인터페이스로 스프링은 메소드 실행을 가로채는 방식 외에 부가기능을 추가하는 여러 방법을 가진다. 

 

@Test
public void simpleProxy() { // JDK 다이내믹 프록시 생성하기
    Hello proxiedHello = (Hello) Proxy.newProxyInstance(
            getClass().getClassLoader(),
            new Class[] { Hello.class },
            new UppercaseHandler(new HelloTarget()));
}

@Test
public void proxyFactoryBean() {
    ProxyFactoryBean pfBean = new ProxyFactoryBean();
    pfBean.setTarget(new HelloTarget()); // 타깃 설정
    //1)
    pfBean.addAdvice(new UppercaseAdvice()); // 부가기능을 담은 어드바이스를 추가한다. 여러 개를 추가할 수도 있다.

    Hello proxiedHello = (Hello) pfBean.getObject(); // FactoryBean이므로 getObject()로 생성된 프록시를 가져온다.

    assertThat(proxiedHello.sayHello("Toby"), is("HELLO TOBY"));
    assertThat(proxiedHello.sayHi("Toby"), is("HI TOBY"));
    assertThat(proxiedHello.sayThankYou("Toby"), is("THANK YOU TOBY"));
}

static class UppercaseAdvice implements MethodInterceptor {
    public Object invoke(MethodInvocation invocation) throws Throwable {
        String ret = (String) invocation.proceed(); // 리플렉션의 Method와 달리 메소드 실행 시 타깃 오브젝트를 전달할 필요가 없다. MethodInvocation은 메소드 정보와 함께 타깃 오브젝트를 알고 있기 때문이다.
        return ret.toUpperCase(); // 부가기능 적용
    }
}

MethodInvocation

일종의 콜백 오브젝트

proceed() 메소드 실행시 타깃 오브젝트의 메소드를 내부적으로 실행할 수 있는 기능이 있기에 MethodInterceptor는 부가기능을 제공하는 데만 집중할 수 있다. 

MethodInvocation 구현 클래스는 일종의 공유 가능한 템플릿처럼 동작하는데 이는 JDK 다이내믹 프록시를 사용하는 경우와 가장 차이나고 ProxyFactoryBean의 큰 장점이다. 

ProxyFactoryBean은 작은 단위의 템플릿/콜백 구조를 응용해서 적용했기 때문에 템플릿 역할을 하는 MethodInvocation을 싱글톤으로 두고 공유 할 수 있다.

 

 1) addAdvice()

ProxyFactoryBean이 MethodInterceptor를 설정해 줄때 일반적인 DI 처럼 수정자를 사용하지 않고 이 방식 사용한다. 

add에서 알수 있듯이 1개의 ProxyFactoryBean에 N개의 MethodInterceptor를 추가 할 수 있음을 의미한다. 

 

 

포인트컷, 어드바이스 그리고 어드바이저

어드바이스: 타깃이 필요 없는 순수한 부가기능

타깃 오브젝트에 적용하는 부가기능을 담은 오브젝트

 

👎 InvocationHandler를 구현시 pattern 스트링 값을 DI 받아 부가기능 적용 대상 메소드를 선정했어야 한다. 

여러 프록시가 공유하는 MethodInterceptor에 특정 프록시에만 적용되는 패턴을 넣으면 문제 되고 이를 프록시마다 따로 등록하도 독립되게 만드는 것도 좋은 방법이 아니다. 

InvocationHandler가 부가기능과 메소드 선정 알고리즘에 종속된다. 

타깃 / 메소드 선정 방식이 다르다면 InvocationHandler 오브젝트를 여러 프록시가 공유할 수 없다. 

타깃과 메소드 선정 알고리즘은 DI를 통해 분리할 수 있지만 한번 빈으로 구성된 InvocationHandler 오브젝트는, 오브젝트 차원에서 특정 타깃을 위한 프록시에 제한된다. 

그래서 InvocationHandler 빈으로 등록하는 대신 TxProxyFactoryBean 내부에서 매번 생성하게 되고 이는 타깃과 메소드 선정 알고리즘 변경시 팩토리 빈 내의 프록시 생성 코드를 직접 변경해야 하는 문제점을 만들고 OCP 원칙(확장에는 유연하게 열려 있고 코드 변경은 없다)을 깬다. 

=> Proxy는 타깃을 대신해서 클라이언트의 요청을 받아 처리하는 오브젝트로서의 존재임으로 메소드 선택 기능 분리하자! 

 

 

포인트컷 : 부가기능 적용 대상 메소드 선정 방법

어드바이스와 포인트컷은 모두 프록시에 DI로 주입되어 사용되고 여러 프록시에서 공유가 가능하도록 만들어 짐으로 스프링의 싱글톤 빈으로 등록 가능하다.

 

순서

  1. 프록시 : 클라이언트로 부터 요청 받으면 포인트컷에게 부가 기능을 부여할 메소드인지 확인 요청
    1. 포인트컷은 Pointcut 인터페이스를 구현 받으면 됨
  2. 프록시 : 확인 받으면 MethodInterceptor 타입의 어드바이스 호출
  3. 어드바이스 : 프록시로 부터 전달 받은 MethodInvocation 타입 콜백 오브젝트의 proceed() 호출
  4. MethodInvocation 타입 콜백 오브젝트 : 실제 위임 대상인 타겟 오브젝트의 레퍼런스를 갖고 있고, 이를 이용해 타깃 메소드를 직접 호출
    1. 프록시가 메소드 호출에 따라 Invocation 콜백을 만듬

 

특징

템플릿 / 콜백 구조

재사용 가능한 기능을 만들어 두고 바뀌는 부분(콜백 오브젝트와 메소드 호출 정보)만 외부에서 주입해서 이를 작업 흐름(부가기능 부여) 중에 사용하도록 하는 구조

어드바이스는 템플릿, MethodInvocation 오브젝트는 콜백을 의미하여 템플릿을 한번 만들어서 재사용 하듯, 어드바이스도 싱글톤 빈으로 등록하고 DI 로 주입해서 여러 프록시가 사용할 수 있도록 만든것이다. 

 

전략 패턴 구조

프록시로부터 어드바이스와 포인트컷을 독립시키고 DI를 사용하게 한 것으로 여러 프록시가 공유해서 사용가능하고 구체적인 부가기능 방식이나 메소드 선정 알고리즘이 바뀌면 구현 클래스만 바꿔 설정 가능하다. 

 

OCP 구조

프록시나 ProxyFactoryBean 등의 변경 없이도 기능을 자유롭게 확장 가능하다. 

@Test
public void pointcutAdvisor() {
    ProxyFactoryBean pfBean = new ProxyFactoryBean();
    pfBean.setTarget(new HelloTarget());

    NameMatchMethodPointcut pointcut = new NameMatchMethodPointcut(); // 메소드 이름을 비교해서 대상을 선정하는 알고리즘을 제공하는 포인트컷 생성
    pointcut.setMappedName("sayH*"); // 이름 비교조건 설정

    pfBean.addAdvisor(new DefaultPointcutAdvisor(pointcut, new UppercaseAdvice())); // 포인트컷과 어드바이스를 Advisor로 묶어서 한 번에 추가

    Hello proxiedHello = (Hello) pfBean.getObject();

    assertThat(proxiedHello.sayHello("Toby"), is("HELLO TOBY"));
    assertThat(proxiedHello.sayHi("Toby"), is("HI TOBY"));
    assertThat(proxiedHello.sayThankYou("Toby"), is("Thank You Toby")); // 포인트컷의 조건에 맞지 않음으로 부가기능 적용 안됨
}

 

어드바이저 = 포인트컷(메소드 선정 알고리즘) + 어드바이스(부가기능)

ProxyFactoryBean에는 여러개의 어드바이스와 포인트컷 추가 가능하고 이를 같이 묶어야 어떤 어드바이스에 대해 어떤 포인트컷을 적용할 지 명확해진다. 

 

 

출저 : 이일민 저, 토비의 스프링 3.1 Vol. 1 스프링의 이해와 원리, 에이콘 출판사

https://ko.wikipedia.org/wiki/%EB%8D%B0%EC%BD%94%EB%A0%88%EC%9D%B4%ED%84%B0_%ED%8C%A8%ED%84%B4

https://ko.wikipedia.org/wiki/%ED%94%84%EB%A1%9D%EC%8B%9C_%ED%8C%A8%ED%84%B4

댓글
공지사항
최근에 올라온 글
최근에 달린 댓글
Total
Today
Yesterday
링크
«   2025/05   »
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 31
글 보관함