728x90

자바스크립트에서의 배열은 이렇게 선언했었다.

 

타입이 없기 때문에 그냥 아무 값이나 배열에 추가했었다.

 

타입스크립트에서는 배열에도 어떤 타입의 값들을 넣을지 지정해준다.

let lang: string[] = ["c", "c++"," java"]

 

이런식으로 타입[]을 사용해 해당 타입이 배열임을 알려준다.

그리고 당연히 다른 타입을 값을 넣으려고 하면, 이렇게 에러가 발생한다.

 

만약 한 배열에 string과 number를 같이 넣고 싶다면

let lang: (string | number)[] = ["c", "c++"," java", 1]

이렇게 | 를 사용해서 string 혹은 number가 있다는 것을 알려준다.

하지만 배열이라는게 같은 타입의 객체들을 모으기 위해 사용하는 친구이기에, 이런 식으로 다양한 타입의 배열을 피하도록 하자.

 

이렇게 배열에 타입을 사용하는 이유가 있을까?

이렇게 배열 안의 원소들에 대해 타입을 지정해놔야.

해당 배열의 원소들에 접근할 때, 해당 원소들의 메서드를 가져올 수 있다.

 

그리고 해당 map에서 리턴타입을 빼도

이렇게 만들어지는 배열에 대한 타입 추론을 알아서 할 수 있다.

 

타입스크립트를 사용하는만큼 최대한 타입을 지정해서 사용할 수 있도록 하자.

'Node > TypeScript' 카테고리의 다른 글

TypeScript에서 any 타입  (0) 2025.06.29
TypeScript의 타입 어노테이션 및 타입 추론  (0) 2025.06.29
TypeScript를 쓰는 이유  (0) 2025.06.28
728x90

이러한 객체 리터럴이 존재한다고 해보자.

let seungkyu: {name: string, age: number} = {
    name: "seungkyu",
    age: 27
}

 

이거를 보내는 쪽에서는 어떤 타입들인지 알고 있겠지만, 만약 이거를 json으로 받는 상황이라고 해보면

 

그냥 이렇게 올것이고 이거를 JSON.parse로 파싱을 해도 안에 value들의 타입을 알 수 없다.

 

그렇기에 타입스크립트는 해당 타입을 그냥 any로 추론한다.

 

이렇게 any는 진짜 모든것이다.

여기에는 어떤 타입도 들어갈 수 있다.

let a: any = 1;

a = "seungkyu"

애초에 타입을 any로 선언하면 number를 넣었다가, string을 넣는 이런 방법도 가능해진다.

 

또한 이런 원시타입 말고도

객체도 any로 지정이 가능하다.

let seungkyu_info: {name: string, age: number, lived: any} = {
    name: "seungkyu",
    age: 27,
    lived: {
        country: "korea"
    }
}

 

이렇게 any 타입은 편리해보이지만, 역시 사용을 자제하는 것이 좋다.

사실 json을 response로 받더라도, 안의 형식은 이미 공식문서로 알고 있는 경우가 많다.

 

let seungkyu_info: {name: string, age: number, lived: any} = {
    name: "seungkyu",
    age: 27,
    lived: {
        country: "korea"
    }
}

 

어쩔 수 없는 경우에만 any 타입을 사용하고, 최대한 타입을 지정해서 사용하도록 하자.

'Node > TypeScript' 카테고리의 다른 글

TypeScript에서 배열  (0) 2025.06.29
TypeScript의 타입 어노테이션 및 타입 추론  (0) 2025.06.29
TypeScript를 쓰는 이유  (0) 2025.06.28
728x90

이렇게 TypeScript가 JavaScript에 타입을 입히는 언어라고 알게 되었는데, 어떠한 방식으로 타입을 부여하는지 알아보자.

 

크게 타입 어노테이션과 타입 추론 방법이 있다.

 

타입 어노테이션

개발자가 해당 변수의 타입을 같이 작성해주는 것이다.

let number_variable: number = 1
let string_variable: string = "seungkyu";
let boolean_variable: boolean = true;

이렇게 변수 옆에 :를 사용해서 객체의 타입을 지정해주는 방식이다.

 

배열은 다음과 같이 선언한다.

let boolean_variables: boolean[] = [true, true, false];

 

 

당연히 객체의 타입으로 타입을 지정 할 수도 있다.

class Person{
    private readonly _name: string;
    constructor(name: string){
        this._name = name;
    }

    sayName(){
        return `${this._name} - hello`;
    }
}

let seungkyu: Person = new Person("seungkyu")

이렇게 타입을 지정해주면, IDE에서 해당 객체들의 메서드를 찾아서 보여줄 수 있다.

 

 

리터럴 객체는 키-밸류 사이에 지정할 수 없으니, 변수명 옆에서 지정한다.

let seungkyu1: {age: number, name: string} = {
    age: 24,
    name: "sk"
}

 

마지막으로 함수의 타입 어노테이션이다.

function a(name: string): string {
    return `${this._name} - hello`;
}

다음과 같이 파라미터의 타입은 파라미터의 변수명 옆, 리턴 타입은 파라미터 선언 후에 명시한다.

 

타입 추론

타입 추론은 말 그대로 추론하는 방식이다.

변수의 타입을 어노테이션으로 지정해주지 않아도, 타입스크립트가 알아서 타입을 찾아가도록 한다.

let a = 1;

이렇게 선언을 하면 a의 타입은 누가봐도 number이다.

 

그렇기에 IDE에서도 타입을 바로 찾아준다.

 

let a = 1;

a = "hello";

그렇기에 이렇게 number에다가 string을 대입하는 위의 코드는 에러가 발생한다고 한다.

 

 

타입추론도 정말 편리한 기능이다.

 

하지만 모두 타입추론으로 만들어버린다면, TypeScript를 사용하는 의미가 없어진다고 생각한다.

 

바로 값을 초기화하는 거 아니면 최대한 타입 어노테이션을 통해 타입을 명시적으로 알려주면서 개발하도록 해야겠다.

'Node > TypeScript' 카테고리의 다른 글

TypeScript에서 배열  (0) 2025.06.29
TypeScript에서 any 타입  (0) 2025.06.29
TypeScript를 쓰는 이유  (0) 2025.06.28
728x90

개발을 하는 동안 Nest.js로 서버를 구축할 일이 생길 거 같아 우선 TypeScript를 공부해두려고 한다.

우선 TypeScript를 사용하는 이유부터 한 번 살펴보도록 하자.

 

JavaScript는 정말 자유로운 언어이다.

그렇기에 빌드가 되지 않는 에러가 존재하더라도, 개발자는 개발 과정에서 찾지 못하는 경우가 많다.

이런 문제를 해결하기 위해 TypeScript를 사용한다고 한다.

 

우선 json을 가져올 수 있는 해당 url을 먼저 살펴보자.

https://jsonplaceholder.typicode.com/todos/1

해당 url로 GET 요청을 보내면 다음과 같은 응답을 받을 수 있다.

굉장히 평범한 json format이다.

이런 json을 자바스크립트에서 처리하기가 굉장히 유리하다.

 

import axios from "axios";

const url = "https://jsonplaceholder.typicode.com/todos/1";

axios.get(url)
    .then(response => {
        const todo = response.data;

        const userId = todo.UserId;
        const id = todo.Id;
        const title = todo.Title;
        const completed = todo.Completed;

        console.log(userId, id, title, completed);
    })

그리고 다음과 같이 요청을 했다고 해보자.

 

그럼 다음과 같이 출력된다.

사실 코드만 봐도 알 수 있지만, 변수명의 시작을 대문자로 했기에 undefined가 출력되게 된 것이다.

 

그렇기에 우선 TypeScript에서는 타입들을 미리 선언하고 해당 타입을 바탕으로 값을 가져올 수 있도록 한다.

 

interface Todo{
    userId: number;
    id: number;
    title: string;
    completed: boolean;
}

이렇게 interface로 이름과 멤버의 타입들을 미리 선언하고

 

import axios from "axios";

interface Todo{
    userId: number;
    id: number;
    title: string;
    completed: boolean;
}

const url = "https://jsonplaceholder.typicode.com/todos/1";

axios.get(url)
    .then(response => {
        const todo = response.data as Todo;

        const userId = todo.userId;
        const id = todo.id;
        const title = todo.title;
        const completed = todo.completed;

        logTodo(userId, id, title, completed)
    })

그 타입들을 바탕으로 값을 가져온다.

이 때 없는 변수명으로 접근을 하게 된다면, IDE에서부터 바로 에러가 발생한다.

 

또한 함수를 호출 할 때, 타입에 대한 실수를 할 수도 있다.

const logTodo = (
    userId: number,
    id: number,
    title: string,
    completed: boolean) => {
    console.log(`${userId}, ${id}, ${title}, ${completed}`);
}

그렇기에 이렇게 변수들의 타입을 함수에서 미리 지정하고, 해당 타입을 바탕으로 함수를 사용할 수 있도록 해준다.

 

사실 이렇게 타입을 지정하지 않고 개발하는 것이, 알고리즘에서는 굉장히 편리했다.

내가 처음에 파이썬으로 알고리즘을 시작했던 이유도 그 이유였으니...

 

하지만 개발을 하는 과정에는 타입을 맞추는게 굉장히 중요하다고 점점 생각이 되었고, 이후에는 파이썬으로 개발하더라도 타입을 꼭 지정해가며 개발을 했었다.

 

타입스크립트를 사용해 더욱 안전하게 서버를 개발 할 수 있도록 해보자.

'Node > TypeScript' 카테고리의 다른 글

TypeScript에서 배열  (0) 2025.06.29
TypeScript에서 any 타입  (0) 2025.06.29
TypeScript의 타입 어노테이션 및 타입 추론  (0) 2025.06.29
728x90

기존에 PlatformTransactionManager를 다음과 같이 사용했었다.

TransactionStatus transactionStatus = this.transactionManager.getTransaction(new DefaultTransactionDefinition());

이렇게 트랜잭션을 적용했었는데, 좀 더 자세히 알아보자.

 

트랜잭션 정의

우선 DefaultTransactionDefinition이 구현하는 TransactionDefinition 인터페이스를 살펴보자.

public interface TransactionDefinition {
    int PROPAGATION_REQUIRED = 0;
    int PROPAGATION_SUPPORTS = 1;
    int PROPAGATION_MANDATORY = 2;
    int PROPAGATION_REQUIRES_NEW = 3;
    int PROPAGATION_NOT_SUPPORTED = 4;
    int PROPAGATION_NEVER = 5;
    int PROPAGATION_NESTED = 6;
    int ISOLATION_DEFAULT = -1;
    int ISOLATION_READ_UNCOMMITTED = 1;
    int ISOLATION_READ_COMMITTED = 2;
    int ISOLATION_REPEATABLE_READ = 4;
    int ISOLATION_SERIALIZABLE = 8;
    int TIMEOUT_DEFAULT = -1;

    default int getPropagationBehavior() {
        return 0;
    }

    default int getIsolationLevel() {
        return -1;
    }

    default int getTimeout() {
        return -1;
    }

    default boolean isReadOnly() {
        return false;
    }

    @Nullable
    default String getName() {
        return null;
    }

    static TransactionDefinition withDefaults() {
        return StaticTransactionDefinition.INSTANCE;
    }
}

여기서 4개의 메서드가 좀 중요해보인다.

 

  • getPropagationBehavior

트랜잭션 전파는 트랜잭션 경계에서 만약 이미 진행 중인 트랜잭션이 있을 때, 어떻게 동작할 것인지를 결정하는 방법을 말한다.

트랜잭션 안에 부분적으로 트랜잭션이 포함되는 경우가 많다.

2가지 상황이 바로 떠오른다.

B가 A의 트랜잭션에 속해 B에서 예외가 발생한다면, A와 B의 작업이 롤백되는 경우

B가 별도의 트랜잭션을 시작해, B가 종료되면 독자적으로 커밋되고 그 이후의 A 예외에 대해서는 A만 롤백되고 B는 롤백되지 않는 경우

 

propagation behavior를 통해 이런 방법을 제어할 수 있다.

대표적으로 다음과 같은 트랜잭션 전파 속성이 있다.

  • REQUIRED

가장 많이 사용되는 전파 속성이다.

기존에 트랜잭션이 존재하면 참여하고, 없으면 새로 생성한다.

  • SUPPORTS

기존에 트랜잭션이 존재하면 참여하고, 없으면 트랜잭션 없이 수행한다.

  • MANDATORY

반드시 기존 트랜잭션이 존재해야 하며, 존재하지 않으면 예외가 발생한다.

  • REQUIRES_NEW

항상 새로운 트랜잭션을 시작하며, 기존 트랜잭션은 잠시 보류한다.

  • NOT_SUPPORTED

트랜잭션이 존재하면 일시 중단하고, 트랜잭션 없이 수행한다.

  • NEVER

기존 트랜잭션이 존재하면 예외가 발생한다.

  • NESTED

기존 트랜잭션이 존재하면 중첩 트랜잭션을 생성하고, 존재하지 않으면 새로운 트랜잭션을 생성한다.

 

public enum Propagation {
    REQUIRED(0),
    SUPPORTS(1),
    MANDATORY(2),
    REQUIRES_NEW(3),
    NOT_SUPPORTED(4),
    NEVER(5),
    NESTED(6);

    private final int value;

    private Propagation(int value) {
        this.value = value;
    }

    public int value() {
        return this.value;
    }
}

 

이렇게 다양한 트랜잭션 전파 속성이 존재한다.

그렇기에 getTransaction()을 사용하면 무조건 트랜잭션이 시작되는 것은 아니다.

 

  • getIsolationLevel

트랜잭션의 격리 수준을 나타낸다.

격리수준을 높이면 동시성 문제가 줄지만, 병렬작업이 느려진다.

자세한 내용은 다른글을 참고

  • getTimeout

트랜잭션의 제한시간이다.

DefaultTransactionDefinition의 기본 설정은 제한시간이 없으며, 트랜잭션을 직접 시작하는 REQUIRED, REQUIRES_NEW등의 옵션만 의미가 있다.

  • isReadOnly

readOnly로 설정하면 트랜잭션 내의 데이터 조작을 막아준다.

또한 격리 레벨에 따라 성능이 향상되기도 한다.

 

트랜잭션 인터셉터와 트랜잭션 속성

이전에 TransactionHandler에서처럼 메서드의 이름을 이용해 트랜잭션 적용 여부를 판단했던 방식으로 메세드마다 다른 트랜잭션 속성을 부여할 수 있다.

스프링의 TransactionInterceptor 어드바이스를 사용하면 된다.

 

TransactionInterceptor는 PlatformTransactionManager와 Properties 타입의 두 가지 속성을 가지고 있다.

여기서 Propertoes의 이름은 transactionAttributes로, 트랜잭션 속성을 정의한 프로퍼티다.

트랜잭션 속성은 TransactionDefinition의 4가지 기본 항목과 어떤 예외에 대하여 롤백 할 것인지 결정하는 rollbackOn() 메서드를 가지고 있다.

롤백할 예외를 지정하는 이유는 특정 런타임 예외에 대해서는 커밋을 해야하는 경우가 있기 때문이다.

public interface TransactionAttribute extends TransactionDefinition {
    @Nullable
    String getQualifier();

    Collection<String> getLabels();

    boolean rollbackOn(Throwable ex);
}

 

이런 transactionAttributes 속성은 다음과 같이 정의할 수 있다.

PROPAGATION_NAME, ISOLATION_NAME, readOnly, timeout_NNNN, -Exception1, +Exception

timeout_NNNN에서 N으로 원하는 시간을 초 단위로 설정한다.

-Exception1은 체크 예외 중 롤백 대상으로 추가할 것을 넣는다.

+Exception2는 런타임 예외지만 롤백시키지 않을 예외들을 넣는다.

 

예시로 작성해보면 다음과 같다.

    <bean id="transactionAdvice" class="org.springframework.transaction.interceptor.TransactionInterceptor">
        <property name="transactionManager" ref="transactionManager"/>
        <property name="transactionAttributes">
            <props>
                <prop key="get*">PROPAGATION_REQUIRED, readOnly, timeout_30</prop>
                <prop key="upgrade*">PROPAGATION_REQUIRES_NEW, ISOLATION_SERIALIZABLE</prop>
                <prop key="*">PROPAGATION_REQUIRED</prop>
            </props>
        </property>
    </bean>

 

하지만 이것도 aop처럼 독립적으로 트랜잭션을 xml에 정의할 수 있다.

지금처럼 bean과 같이 작성하면 코드도 길어지고 보기 어려우니 추출해보도록 하자.

    <tx:advice id="transactionAdvice" transaction-manager="transactionManager">
        <tx:attributes>
            <tx:method name="get*" propagation="REQUIRED" read-only="true" timeout="30" isolation="READ_COMMITTED"/>
        </tx:attributes>
    </tx:advice>

 

포인트컷과 트랜잭션 속성의 적용 전략

대부분의 add() 메서드에도 트랜잭션을 적용해야한다.

add() 개별적으로 동작하면 저장하는 부분에 대해서는 원자성을 가지기에 굳이 적용할 필요가 없을 수도 있지만, 대부분의 add() 메서드는 다른 트랜잭션에 참여하기 때문이다.

또한, 단순한 조회의 경우에도 다른 트랜잭션에 참여하기 때문에 readOnly=true 정도의 속성은 설정해주는 것이 좋다.

 

이러한 이유로 트랜잭션이 적용되는 메서드는 네이밍에 일정한 규칙을 가지는 것이 좋다.

만약 읽는 메서드라면 get, find와 같은 방법으로 시작하도록 말이다.

 

그리고 주의할 점으로 AOP는 본인의 메서드를 호출할 때에는 프록시가 적용되지 않는다.

1번 경우처럼 클라이언트가 메서드를 호출하는 경우에는 프록시를 타고 타깃 오브젝트의 메서드가 호출되지만, 타깃오브젝트가 직접 본인의 메서드를 호출하면 프록시를 거치는 것이 아니라 본인의 메서드를 바로 호출하기 때문이다.

이런 방법은 AspectJ와 같이 직접 바이트코드르 수정하는 방법으로 해결 할 수 있다고 하지만, 어려운 방법이기에 나중에 알아보도록 하자.

 

트랜잭션 속성 적용

이제 만들었던 트랜잭션 전략을 UserService에 적용해보자.

우선 현재 서비스 레이어에서 Dao를 사용하며 트랜잭션을 적용하고 있다.

그렇기에 다른 레이어에서 Dao를 직접 사용하는 것은 트랜잭션이 적용되지 못하기에 막아야 한다.

 

우선 UserService에 트랜잭션을 적용해보기 위해, 다음과 같은 메서드들을 추가한다.

public interface UserService {

    void add(User user);

    User get(String id);

    List<User> getAll();

    void deleteAll();

    void update(User user);

    void upgradeLevels();
}

 

이제 이 UserService의 각각의 메서드에 트랜잭션을 적용해보자.

 

우선 aop가 적용되도록 한다.

xml에서 aop 태그 부분을 수정해보자.

    <aop:config>
        <aop:advisor advice-ref="transactionAdvice" pointcut="bean(*Service)"/>
    </aop:config>

이렇게 작성해서 Service로 만들어지는 모든 빈에 advice가 적용되도록 했다.

 

그리고 transaction advice를 통해 조회하는 메서드에는 readOnly만 적용하고 나머지 메서드에는 트랜잭션을 설정하도록 한다.

    <tx:advice id="transactionAdvice">
        <tx:attributes>
            <tx:method name="get*" read-only="true"/>
            <tx:method name="*"/>
        </tx:attributes>
    </tx:advice>

 

이렇게 트랜잭션이 모두 적용되었다고 생각하고, 테스트를 진행해보자.

 

테스트 할 메서드는 get이다.

현재 get 메서드에는 readOnly 속성을 부여하고 있는데, 정말 업데이트가 되지 않을까 한 번 확인해보자.

    @Override
    public List<User> getAll() {
        super.add(
                User.builder()
                        .id(UUID.randomUUID().toString())
                        .build()
        );
        return super.getAll();
    }

이렇게 테스트 클래스를 만들어서 getAll() 과정에서 새로운 데이터를 삽입하려고 시도해보았다.

    @Test
    public void readOnlyTest(){
        userService.getAll();
    }

그리고 getAll()을 바로 호출해보니

 

이런 식으로 트랜잭션 내에서 데이터의 변경은 허용되지 않는다고 나온다.

readOnly 트랜잭션이 적용된 것을 알 수 있다.

 

 

728x90

자동 프록시 생성

우선 타깃코드에서 부가기능의 코드는 모두 제거했다.

타깃에서 어떤 메서드에 부가기능을 적용할지도, 포인트컷을 사용해서 각 타깃마다 다르게 적용할 수도 있었다.

이제 마지막으로 ProxyFactoryBean을 적용해주기 위해 빈 설정을 계속 복사해야 한다는 문제만 해결하면 될 것 같다.

 

BeanPostProcessor 인터페이스는 빈 후처리기로, 스프링 빈 오브젝트로 만들어지고 난 후에, 빈 오브젝트를 다시 가공할 수 있게 해 준다.

스프링은 빈 후처리기가 빈으로 등록되어 있으면, 빈 오브젝트가 생성될 때마다 빈 후처리기에 보내서 후처리 작업을 요청한다.

빈 후처리기는 빈 오브젝트의 프로퍼티를 강제로 수정할 수도 있고, 별도의 초기화 작업을 수행할 수도 있다.

심지어는 만들어진 빈 오브젝트 자체를 바꿔치기할 수도 있다.

이를 이용하면 스프링이 생성하는 빈 오브젝트 중 일부를 프록시로 감싸거나, 프록시를 빈으로 대신 등록할 수도 있다.

프록시 적용 대상이면 그때는 내장된 프록시 생성기에 현재 빈에 대한 프록시를 만들게하고, 만들어진 프록시를 어드바이저에 연결해준다.

빈후처리기는 프록시가 생성되면 컨테이너가 전달해준 빈 오브젝트 대신 프록시 오브젝트를 컨테이너에게 돌려준다.

컨테이너는 최종적으로 빈 후처리기가 돌려준 오브젝트를 빈으로 등록하고 사용한다.

 

이 빈 후처리기를 사용하면 일일이 빈을 저번처럼 등록하지 않아도 타깃 오브젝트에 자동으로 프록시가 등록되도록 할 수 있다.

 

그리고 그림을 보면 빈 후처리기도 어드바이스를 통해 대상을 확인하는 것을 볼 수 있다.

우리는 기존에 getMethodMatcher() 메서드만 사용했지만, 사실 포인트컷 인터페이스에는 getClassFilter() 메서드도 존재한다.

이 메서드를 사용하면 모든 클래스에 프록시를 적용하는 것이 아닌, 원하는 클래스에만 프록시를 적용할 수도 있게 된다.

 

Class 필터 기능을 사용해 보자.

기존에 사용했던 NameMatchMethodPointcut은 모든 클래스를 통과시켜 주는 알고리즘을 가지고 있기 때문에 해당 클래스를 상속받아 메서드를 재정의해야 한다.

package seungkyu;

import jakarta.annotation.Nonnull;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.Test;
import org.springframework.aop.ClassFilter;
import org.springframework.aop.Pointcut;
import org.springframework.aop.framework.ProxyFactoryBean;
import org.springframework.aop.support.DefaultPointcutAdvisor;
import org.springframework.aop.support.NameMatchMethodPointcut;

public class HelloTest {

    @Test
    public void classNamePointCutAdvisor(){
        NameMatchMethodPointcut classMethodPointCut = new NameMatchMethodPointcut(){
            @Override
            @Nonnull
            //클래스 필터를 재정의
            public ClassFilter getClassFilter() {
                //해당 이름으로 시작하는 클래스만 적용
                return clazz -> clazz.getSimpleName().startsWith("Hi");
            }
        };

        //해당 클래스에서도 해당 메서드에만 프록시를 적용
        classMethodPointCut.setMappedName("say*");

        class HiChild extends DynamicProxyTest.HelloImpl{}
        checkAdvice(new HiChild(), classMethodPointCut, true);

        class HelloChild extends DynamicProxyTest.HelloImpl{}
        checkAdvice(new HelloChild(), classMethodPointCut, false);
    }

    private void checkAdvice(Object target, Pointcut pointcut, boolean isAdvice){
        ProxyFactoryBean proxyFactoryBean = new ProxyFactoryBean();
        proxyFactoryBean.setTarget(target);
        proxyFactoryBean.addAdvisor(new DefaultPointcutAdvisor(pointcut, new DynamicProxyTest.UpperCaseAdvisor()));
        DynamicProxyTest.Hello proxyHello = (DynamicProxyTest.Hello) proxyFactoryBean.getObject();

        if(isAdvice){
            Assertions.assertEquals("HELLO SEUNGKYU", proxyHello.sayHello("seungkyu"));
            Assertions.assertEquals("HI SEUNGKYU", proxyHello.sayHi("seungkyu"));
            Assertions.assertEquals("BYE SEUNGKYU", proxyHello.sayBye("seungkyu"));
        }
        else{
            Assertions.assertEquals("Hello seungkyu", proxyHello.sayHello("seungkyu"));
            Assertions.assertEquals("Hi seungkyu", proxyHello.sayHi("seungkyu"));
            Assertions.assertEquals("Bye seungkyu", proxyHello.sayBye("seungkyu"));
        }
    }
}

ClassFilter를 사용해 특정 이름으로 시작하는 클래스만 적용하도록 했다.

이렇게 포인트컷을 사용하면 원하는 클래스의 원하는 메서드에만 프록시를 적용할 수 있게 되는 것이다.

 

DefaultAdvisorAutoProxyCreator

이제 실제로 적용해 보도록 하자.

public class NameMatchClassMethodPointcut extends NameMatchMethodPointcut {

    public NameMatchClassMethodPointcut(String mappedClassName) {
        this.setClassFilter(clazz -> PatternMatchUtils.simpleMatch(mappedClassName, clazz.getSimpleName()));
    }
}

간단하게 사용하던 NameMatchMethodPointCut에 클래스 필터를 넣어보자.

 

이제 'DefaultAdvisorAutoProxyCreator'를 사용해 보자.

이 DefaultAdvisorAutoProxyCreator는 등록된 빈 중에서 Advisor 인터페이스를 구현한 것을 모두 찾는다.

그리고 생성되는 모든 빈에 대해 어드바이저의 포인트컷을 적용해 보며 프록시 적용 대상을 선정한다.

아래와 같이 DefaultAdvisorAutoProxyCreator를 등록한다.

<bean class="org.springframework.aop.framework.autoproxy.DefaultAdvisorAutoProxyCreator"/>

 

어차피 다른 빈에서 사용하지 않기 때문에 class만 등록해서 사용한다.

 

그리고는 기존의 포인트컷 설정을 삭제하고 새로운 포인트컷을 빈으로 등록한다.

<bean id="transactionPointcut"
          class="seungkyu.NameMatchClassMethodPointcut">
        <constructor-arg value="say"/>
    </bean>

이제 UserService는 진짜 프록시 설정을 아예 신경 쓰지 않고 빈으로 등록해주기만 하면 된다.

    <bean id="userService" class="seungkyu.UserServiceImpl">
        <constructor-arg ref="userDao"/>
        <constructor-arg ref="mailSender"/>
    </bean>

 

이제 프록시에 관한 테스트이다.

중간에 예외가 발생해야 하기 때문에 예외가 발생하는 Service인 TestUserServiceImpl를 만들어주었다.

public class TestUserServiceImpl extends UserServiceImpl {

    public TestUserServiceImpl(UserDao userDao, MailSender mailSender) {
        super(userDao, mailSender);
    }

    @Override
    protected void upgradeLevel(User user) {
        String name = "user3";
        if(user.getName().equals(name)){
            throw new IllegalArgumentException("test");
        }
        super.upgradeLevel(user);
    }
}

 

그리고 해당 테스트용 서비스를 빈으로 등록하자.

    <bean id="dataSource"
          class = "org.springframework.jdbc.datasource.SimpleDriverDataSource">
        <property name="driverClass" value="com.mysql.cj.jdbc.Driver"/>
        <property name="url" value="jdbc:mysql://localhost:3306/test"/>
        <property name="username" value="root"/>
        <property name="password" value="1204"/>

    </bean>

    <bean id="userDao" class="seungkyu.UserDaoImpl">
        <constructor-arg ref="dataSource" />
    </bean>

    <bean id="transactionManager" class="org.springframework.jdbc.datasource.DataSourceTransactionManager">
        <property name="dataSource" ref="dataSource"/>
    </bean>

    <bean class="org.springframework.aop.framework.autoproxy.DefaultAdvisorAutoProxyCreator"/>

    <bean id="transactionAdvice" class="seungkyu.TransactionAdvice">
        <constructor-arg ref="transactionManager"/>
    </bean>

    <bean id="transactionAdvisor" class="org.springframework.aop.support.DefaultPointcutAdvisor">
        <constructor-arg ref="transactionAdvice"/>
        <constructor-arg ref="transactionPointcut"/>
    </bean>

    <bean id="transactionPointcut"
          class="seungkyu.NameMatchClassMethodPointcut">
        <constructor-arg value="*TestUserService*"/>
    </bean>

    <bean id="mailSender" class="seungkyu.MailSenderImpl"/>

    <bean id="userService" class="seungkyu.UserServiceImpl">
        <constructor-arg ref="userDao"/>
        <constructor-arg ref="mailSender"/>
    </bean>

    <bean id="testUserService" class="seungkyu.TestUserServiceImpl" parent="userService"/>

 

작성한 test-applicationContext.xml은 위와 같다.

 

그리고 아래와 같은 테스트를 수행하니 통과하는 것을 볼 수 있었다.

    @Test
    public void upgradeAllOrNothing(){
        userDao.deleteAll();
        for(User user: users) userDao.add(user);

        try
        {
            this.testUserService.upgradeLevels();
        }
        catch(IllegalArgumentException ignored)
        {

        }

        User user1 = userDao.get(users.get(1).getId());
        Assertions.assertEquals(Level.BRONZE, user1.getLevel());
    }

user3에서 에러가 발생했기에 user1이 롤백된 것을 볼 수 있다.

 

포인트컷 표현식을 이용한 포인트컷

지금까지는 클래스와 메서드를 선정하기 위해, 이름을 사용해 문자열로 비교하며 선정했었다.

이런 방법으로도 필터링은 가능하지만, 포인트컷 표현식을 사용하면 더욱 섬세한 필터링은 가능하다고 한다.

우선 해당 코드의 출력을 확인해 보자.

System.out.println(Target.class.getMethod("plus", int.class, int.class));

 

이렇게 메서드에 대한 정보를 리플랙션을 통해 출력하고 있다.

포인트컷 표현식을 통해 적용할 메서드를 등록할 수 있다.

execution([접근 제한자 패턴] 타입패턴(리턴 값의 타입 패턴) [타입패턴.(패키지와 클래스 이름에 대한 패턴)]이름패턴(메서드 이름 패턴) (타입패턴(파라미터 타입 패턴) | "..", ...)

 

 

  • 접근제한자

public, protected 등이 오며, 포인트컷 표현식에서는 생략이 가능하다.

  • 리턴 타입

리턴 값의 타입을 나타내는 패턴이다. 필수항목이며 *를 써서 모든 타입을 지정 가능하다.

  • 클래스의 타입

패키지와 타입 이름을 포함한 클래스의 타입 패턴이다. *를 사용가능하며,.. 을 사용하면 여러 개의 패키지를 선택할 수 있다.

 

  • 메서드 이름

매머드의 이름이며, 필수항목이고 * 사용 가능하다.

  • 파라미터 타입

파리미터의 타입을 , 로 구분하면서 넣으면 된다. 파라미터와 관계없이 지정하려면.. 을 넣고,... 을 사용해서 뒷부분의 파리미터 조건만 생략할 수도 있다.

  • 예외

예외에 대한 타입 패턴이며 생략 가능하다.

 

그렇기에 AspectJExpressionPointcut을 테스트해 보면

    @Test
    public void methodSignaturePointcut() throws SecurityException, NoSuchMethodException {
        AspectJExpressionPointcut pointcut = new AspectJExpressionPointcut();
        pointcut.setExpression("execution(public int seungkyu.Target.minus(int, int) throws java.lang.RuntimeException)");


        Assertions.assertTrue(pointcut.getClassFilter().matches(Target.class));
        Assertions.assertTrue(pointcut.getMethodMatcher().matches(Target.class.getMethod("minus", int.class, int.class), null));
    }

다음과 같이 작성한 표현식에 작성한 Target.class의 minus 메서드가 통과하는 것을 볼 수 있다.

xml에는 포인트컷 표현식을 사용하는 포인트컷을 다음과 같이 등록한다.

    <bean id="transactionPointcut"
          class="org.springframework.aop.aspectj.AspectJExpressionPointcut">
        <property name="expression" value="execution(* *..*ServiceImpl.upgrade*(..))"/>
    </bean>

 

AOP란 무엇인가?

트랜잭션을 처리했던 과정을 살펴보자.

 

  • 그냥 만들기

그냥 만들어서 트랜잭션을 적용했었다.

하지만 트랜잭션이 특정한 트랜잭션 기술에 종속된다는 문제가 있었다.

  • 트랜잭션 서비스 추상화

트랜잭션의 인터페이스를 만들어서 어떻게 사용하는지만 서비스에 남기고 기술은 별도로 구현했다.

하지만 여전히 서비스에서 비즈니스 로직과 상관없는 트랜잭션 설정이 노출되고 있다.

  • 프록시와 데코레이트 패턴

스프링의 DI를 통해 데코레이터 패턴으로 클라이언트가 데코레이터 패턴이 적용된 빈을 사용하도록 했다.

하지만 이런 패턴을 적용하기 위해서는 기존의 메서드를 하나하나 데코레이터 패턴으로 재정의 해야했다.

  • 다이내믹 프록시와 프록시 팩토리 빈

프록시 오브젝트를 런타임시에 만들어주는 JDK 다이나믹 프록시 기술을 적용했다.

프록시 클래스의 작성 부담을 줄었지만, 이런 빈을 하나하나 xml에 등록해주어야 했다.

  • 자동 프록시 생성 방법과 포인트컷

스프링 컨테이너의 빈 생성 후처리기를 사용해 생성된 빈 중에서 프록시 설정이 필요하면 프록시 설정을 한 클래스로 교체한 후 빈을 변경해줄 수 있도록 했다.

 

관심사가 같은 코드는 분리하여 모아서 한 번에 관리하는 게 개발자의 기본 원칙이다.

트랜잭션 설정처럼 공통부분은 AOP를 적용해서 모아보도록 하자.

 

이렇게 비즈니스 로직은 유지하고, 부가기능만 뽑아서 더 독립적으로 객체지향적인 개발을 할 수 있도록 한다.

 

AOP 적용기술

스프링은 다양한 기술을 통해 AOP를 지원하고 있다.

프록시가 설정된 클래스를 빈으로 주입하는 것이기 때문에, 스프링 컨테이너와 JVM 외에는 필요한 게 없다.

 

하지만 대표적인 AOP인 AspectJ는 프록시를 사용하지 않는다고 한다.

AspectJ는 진짜 타깃 오브젝트를 고친다고 한다.

방법은 모르겠지만, 컴파일된 타깃의 클래스 파일 자체를 수정하거나 클래스가 JVM에 로딩되는 시점을 가로채 바이트코드를 조작한다고 한다.

이렇게 직접 변경하기에 스프링 컨테이너가 없어도 AOP를 적용할 수 있으며, 기존에는 적용 불가능한 private 메서드에도 AOP를 적용할 수 있다고 한다.

 

AOP의 용어

  • 타깃

부가기능을 부여할 대상이다.

  • 어드바이스

부가기능을 담은 모듈이다.

  • 조인 포인트

어드바이스가 실행되는 시점이다.

스프링의 프록시 AOP에서 조인 포인트는 메서드의 실행 단계 뿐이다.

  • 포인트컷

어드바이스를 적용할 조인 포인트를 선별하는 알고리즘을 담은 모듈

  • 프록시

클라이언트와 타깃 사이에서 부가기능을 제공하는 오브젝트이다.

  • 어드바이저

포인트컷과 어드바이스를 세트로 가지고 있는 오브젝트이다.

  • 애스팩트

AOP의 기본 모듈이다.

한 개 또는 그 이상의 포인트컷과 어드바이스의 조합으로 만들어진다.

어드바이저도 애스팩트의 한 종류이다.

 

AOP 네임스페이스

기존의 빈들과는 다르게 스프링의 프록시 방식 AOP를 사용하려면 4가지를 등록해야 한다.

  • 자동 프록시 생성기

저번에는 DefaultAdvisorAutoProxyCreator 클래스를 빈으로 등록했다.

다른 빈을 DI하지 않고, 자신도 DI하지 않으며 독립적으로 존재한다.

그냥 작성하기만 하면 프록시를 자동으로 생성하는 기능을 담당한다.

  • 어드바이스

부가기능을 구현한 클래스를 빈으로 등록한다.

  • 포인트컷

스프링의 AspectJExpressionPointcut을 빈으로 등록하고, expression 프로퍼티에 포인트컷 표현식을 넣어주면 된다.

  • 어드바이저

스프링의 DefaultPointcutAdvisor 클래스를 빈으로 등록한다.

자동 프록시 생성기에 의해 찾아지고 사용된다.

 

스프링에서는 이렇게 많이 사용하는 AOP 기능을 간편하게 등록할 수 있도록 한다.

기존에 빈을 작성하던 xml에서 aop 영역을 새로 만들어서 등록한다.

 

하나하나 적용하던 설정을 다음과같이 간편하게 적용이 가능하다.

    <aop:config>
        <aop:pointcut id="transactionPointcut"
                      expression="execution(* *..*ServiceImpl.upgrade*(..))"/>
        <aop:advisor advice-ref="transactionAdvice" pointcut-ref="transactionPointcut"/>
    </aop:config>

 

아래가 기존에 적용하던 방법이다.

    <bean class="org.springframework.aop.framework.autoproxy.DefaultAdvisorAutoProxyCreator"/>

    <bean id="transactionAdvice" class="seungkyu.TransactionAdvice">
        <constructor-arg ref="transactionManager"/>
    </bean>

    <bean id="transactionAdvisor" class="org.springframework.aop.support.DefaultPointcutAdvisor">
        <constructor-arg ref="transactionAdvice"/>
        <constructor-arg ref="transactionPointcut"/>
    </bean>

    <bean id="transactionPointcut"
          class="org.springframework.aop.aspectj.AspectJExpressionPointcut">
        <property name="expression" value="execution(* *..*ServiceImpl.upgrade*(..))"/>
    </bean>

코드의 양이 줄어든 것 뿐만 아니라, 기존의 빈과 aop를 분리해서 작성이 가능하다.

분리해서 관리 할 수 있도록, aop 영역에 별도로 작성하도록 하자.

728x90

이제 스프링에서 이런 프록시 설정을 어떻게 효율적으로 처리했는지 알아보자.

 

ProxyFactoryBean

스프링에서는 일관된 방법으로 프록시를 만들 수 있게 도와주는 추상 레이어를 제공한다.

스프링의 ProxyFactoryBean은 프록시를 생성해서 빈 오브젝트로 등록하게 해주는 팩토리 빈이다.

ProxyFactoryBean이 생성하는 프록시에서 사용할 부가기능은 MethodInterceptor 인터페이스를 사용해 만든다.

기존에 사용했던 InvocationHandler과 비교하면 MethodInterceptor의 invoke() 메서드는 파리미터를 통해 타깃 오브젝트의 정보도 함께 받는다.

그렇기 때문에 InvocationHandler처럼 타깃 오브젝트를 미리 받아서 그거로 실행하는 것이 아닌, invoke()로 정보를 넘겨 받아서 실행이 가능하다.

 

public class DynamicProxyTest {

    @Test
    public void proxyFactoryBean(){
        ProxyFactoryBean proxyBean = new ProxyFactoryBean();
        proxyBean.setTarget(new HelloImpl());
        proxyBean.addAdvice(new UpperCaseAdvisor());

        Hello proxy = (Hello) proxyBean.getObject();

        Assertions.assertEquals("HELLO SEUNGKYU", proxy.sayHello("seungkyu"));
    }

    static interface Hello {
        String sayHello(String name);
        String sayHi(String name);
        String sayBye(String name);
    }

    static class HelloImpl implements Hello {
        @Override
        public String sayHello(String name) {
            return "Hello " + name;
        }

        @Override
        public String sayHi(String name) {
            return "Hi " + name;
        }

        @Override
        public String sayBye(String name) {
            return "Bye " + name;
        }
    }

    static class UpperCaseAdvisor implements MethodInterceptor {
        @Override
        public Object invoke(MethodInvocation invocation) throws Throwable {
            if(invocation.getMethod().getReturnType().equals(String.class)){
                String returnValue = (String) invocation.proceed();
                if (returnValue != null) {
                    return returnValue.toUpperCase();
                }
                else
                    return null;
            }
            else
                return invocation.proceed();
        }
    }
}

기존의 InvocationHandler와 비교하면

이렇게 실행을 위한 target 오브젝트가 존재하지 않는다.

오브젝트 정보 자체도 MethodInvocation에 존재하기 때문에 이런 부분을 신경쓰지 않아도 된다.

그리고 이 MethodInvocation의 proceed() 메서드로 호출된 메서드를 실행하기에, 기존의 InvocationHandler가 템플릿이 되었다고 생각하면 된다.

그리고 오브젝트에 독립적이기 때문에 JdbcTemplate처럼 싱글톤으로 만들어서 공유가 가능하다.

또한, 이렇게 어떤 인터페이스를 구현했는지의 정보를 넘기지 않는다.

타깃만 등록해도 인터페이스의 정보를 알아내고, 그 모든 인터페이스를 구현하는 프록시를 만들어준다고 한다.

 

기존에는 이렇게 pattern을 사용해, 패턴에 일치하는 메서드만 프록시를 적용했다.

이런 식으로 일단 호출된 모든 메서드를 invoke()에서 패턴을 확인해 실행할지 말지 결정하는 것이다.

(중간 그림은 Dynamic proxy가 아니라 InvocationHandler입니다...)

 

하지만 이번에는 메서드를 선별하는 어떤 정보도 넘기고 있지 않다.

MethodInterceptor는 이렇게 모든 메서드를 invoke()로 넘기지 않고 메서드 선정 알고리즘을 통해 선정한 후, 통과한 메서드만 프록시로 요청하도록 만든다.

이렇게 프록시 과정에서 메서드를 선정하는 오브젝트를 포인트컷이라고 하고, 부가기능을 제공하는 오브젝트를 어드바이스라고 한다.

 

이런식으로 모두 넘기는 것이 아니라 point cut에서 체크한 후 invocation callback을 호출한다.

해당 프록시는 공유되기 때문에 기존처럼 target을 가질 수 없다.

그렇기에 methodInvocation으로 타깃에 대한 정보를 같이 넘기고 callback으로 메서드를 호출하는 것이다.

그렇기에 해당 어드바이스를 템플릿으로 사용하는 것이다.

 

이렇게 사용하는 포인트컷과 어드바이스는 모두 DI로 주입해서 사용한다.

    @Test
    public void pointCutAdvisor(){
        ProxyFactoryBean proxyFactoryBean = new ProxyFactoryBean();
        proxyFactoryBean.setTarget(new HelloImpl());

        NameMatchMethodPointcut nameMatchMethodPointcut = new NameMatchMethodPointcut();
        nameMatchMethodPointcut.setMappedName("say*");

        proxyFactoryBean.addAdvisor(new DefaultPointcutAdvisor(nameMatchMethodPointcut, new DynamicProxyTest.UpperCaseAdvisor()));

        Hello proxyHello = (Hello) proxyFactoryBean.getObject();

        Assertions.assertEquals("HELLO SEUNGKYU", proxyHello.sayHello("seungkyu"));
        Assertions.assertEquals("HI SEUNGKYU", proxyHello.sayHi("seungkyu"));
        Assertions.assertEquals("BYE SEUNGKYU", proxyHello.sayBye("seungkyu"));
    }

NameMatchMethodPointCut을 이용해서 필터링했다.

setMappedName에 prefix와 *을 붙여서 적용할 메서드를 선정하고, proxyFactoryBean으로 넘겨준다.

이미 PointCut 자체는 많이 있어서, 그 중에서 골라서 사용하라고 하긴 한다.

 

또한 현재 어드바이스를 등록하기 위해 포인트컷과 어드바이스를 같이 등록하고 있다.

proxyFactoryBean에는 여러개의 어드바이스를 등록 할 수 있기 때문에, 그에 맞는 포인트컷을 조합해서 올리도록 하는 것이다.

이렇게 부가기능이 어드바이스, 포인트컷이 선정 알고리즘이고 이 둘을 합친 것이 어드바이저가 된다.

 

ProxyFactoryBean 적용

이거까지만 본다면 InvocationHandler와 MethodInterceptor가 뭐가 더 나은지 잘 모르겠다.

직접 만들어서 적용해보도록 하자.

타깃과 메서드를 정해주는 부분을 제거하면 된다.

 

@RequiredArgsConstructor
public class TransactionAdvice implements MethodInterceptor {

    private final PlatformTransactionManager transactionManager;

    @Override
    public Object invoke(@Nonnull MethodInvocation invocation) throws Throwable {
        TransactionStatus transactionStatus = this.transactionManager.getTransaction(new DefaultTransactionDefinition());

        try
        {
            Object ret = invocation.proceed();
            this.transactionManager.commit(transactionStatus);
            return ret;
        }
        catch (Exception e)
        {
            transactionStatus.setRollbackOnly();
            throw e;
        }
    }
}

우선 이렇게 작성 할 수 있으며, 타깃과 메서드 선정 알고리즘이 사라진 것을 볼 수 있다.

 

이렇게만 해주고, xml만 변경하면 끝이다.

    <!--transaction manager를 가지고 있는 어드바이스를 생성-->
    <bean id="transactionAdvice" class="seungkyu.TransactionAdvice">
        <constructor-arg ref="transactionManager"/>
    </bean>

    <!--메서드 선정 알고리즘을 가지는 point cut 생성-->
    <bean id="transactionPointCut" class="org.springframework.aop.support.NameMatchMethodPointcut">
        <property name="mappedName" value="upgrade*"/>
    </bean>

    <!--어드바이스와 포인트컷을 사용해 어드바이저 생성-->
    <bean id="transactionAdvisor" class="org.springframework.aop.support.DefaultPointcutAdvisor">
        <constructor-arg ref="transactionAdvice"/>
        <constructor-arg ref="transactionPointCut"/>
    </bean>

    <!--ProxyFactoryBean을 사용해서 트랜잭션이 적용된 UserService 빈을 생성-->
    <bean id="userService" class="org.springframework.aop.framework.ProxyFactoryBean">
        <property name="target" ref="userServiceImpl"/>
        <property name="interceptorNames">
            <list>
                <value>transactionAdvisor</value>
            </list>
        </property>
    </bean>

 

이렇게 ProxyFactoryBean은 스프링의 DI와 템플릿/콜백 패턴, 서비스 추상화의 기법이 모두 적용된 것이다.

그렇기에 독립적으로 동작하며, 여러 프록시가 공유할 수 있는 어드바이스와 포틴으컷으로 확장 기능을 분리 할 수 있었다.

 

 

728x90

프록시와 프록시 패턴, 데코레이터 패턴

트랜잭션을 적용하는 단계애서 비즈니스 로직과 트랜잭션 로직을 분리했었다.

그리고 분리하는 과정에서 인터페이스를 통한 추상화를 적용했다.

UserServiceTx에서 UserService를 받아서 사용하는 방법으로, UserService의 비즈니스 로직을 구현한 UserServiceImpl에는 비즈니스 로직이 남지 않게 되었다.

이렇게 개발을 하게 된다면 부가기능 외의 모든 기능은 원래의 핵심기능을 가진 클래스로 위임해줘야 한다.

따라서 부가기능이 핵심기능을 사용하는 구조가 되며, 핵심 기능은 부가기능의 존재조차 신경쓰지 않아도 되는 것이다.

 

하지만 이런 방법은 문제가 있다.

만약 클라이언트가 핵심기능 클래스를 직접 사용해버린다면 부가기능이 적용되지 않는다는 것이다.

현재 클라이언트가 UserSerive를 UserServiceTx로 주입받아서 사용하고는 있지만, 만약 UserServiceImpl를 바로 사용해버리면 트랜잭션이 적용되지 않는다는 것이다.

 

그렇기에 할수만 있다면 가장 좋은 방법은 다음과 같을 것이다.

이렇게 중간에 무언가 부가기능을 감싸주는 부분이 있는게 가장 좋은 방법일 것이다.

이런 역할을 하는 코드를 프록시라고 하자.

그리고 프록시를 통해 최종적으로 요청을 받아 처리하는 실제를 타깃 혹은 실체라고 부른다.

여기서는 중간에 무언가가 프록시고, 핵심 기능 코드가 타깃 인 것이다.

 

이런 프록시는 크게 2가지 이유로 사용한다.

1. 클라이언트가 타깃에게 접근 하는 방법을 제어

2. 타깃 자체에 부가적인 기능을 부여

물론 프록시를 사용한다는 것은 동일하지만, 역할이 너무 다르기 때문에 디자인 패턴에서는 아예 다른 패턴으로 생각한다고 한다.

 

우선 데코레이터 패턴에 대해 알아보자.

테코레이터 패턴은 말 그대로 장식이다.

런타임 중 타깃에게 동적으로 부가적인 기능을 부여하기 위해 사용한다.

가장 쉽게 생각나는 부분이 로깅이다.

 

컨트롤러에서 서비스의 메서드를 호출 할 때, 사용자의 정보를 로깅하기 위해 프록시를 사용 할 수 있다.

하지만 여기서 사용자의 정보만이 아니라, 추가적으로 메서드의 정보도 남기기 위해 프록시를 중복으로 사용 할 수 있다.

중복 사용 뿐만 아니라, 프록시간에 단계적으로 위임하는 구조로 프록시를 만들 수도 있다.

 

저번에 만들었던 UserServiceTx도 데코레이터 패턴을 사용한 것이다.

UserServiceImpl에 트랙잭션 기능을 감싸서 부여하였기 때문이다.

여기에 추가적인 부가기능이 필요하다면, UserServiceLog와 같은 데코레이터를 추가해서 만들 수도 있는 것이다.

 

기본적으로 소프트웨에서 말하는 프록시 패턴과 지금 말하는 프록시는 좀 다르다.

프록시 패턴은 기능을 확장하는 것이 아닌, 접근하는 방법을 변경해주는 것이다.

지금부터 클라이언트와 타깃 사이에서 무언가 동작하는 모든 것을 프록시라고 할테니, 프록시 패턴과 잘 구별할 수 있도록 하자.

 

다이나믹 프록시

기존에 만들었던 UserServiceTx를 다시 생각해보자.

분명 비즈니스 로직과 트랜잭션을 분리 할 수 있는 아주 좋은 방법이었다.

 

    @Override
    public void upgradeLevels() {
        TransactionStatus transactionStatus = this.transactionManager.getTransaction(new DefaultTransactionDefinition());

        try
        {
            userService.upgradeLevels();
            transactionManager.commit(transactionStatus);

            this.transactionManager.commit(transactionStatus);
        }
        catch(Exception e)
        {
            this.transactionManager.rollback(transactionStatus);
            throw e;
        }
    }

하지만 이 코드를 살펴보면 현재 userService의 메서드를 트랜잭션으로 위 아래로 감싸고 있으며, 이 코드는 거의 모든 코드에서 중복적으로 발생한다.

그럼 이런 코드를 계속 작성해서 복사해야 하는 것인가?

대부분의 개발과정에서 이런 방법은 많은 문제를 가져왔으니, 무언가 해결 방법이 있을 것 같다.

 

우선 자바의 리플랙션 기능을 알아보자.

리플랙션은 자바 코드 자체를 추상화해서 접근하는 방법을 말한다.

모든 자바 클래스는 구성정보를 담은 getClass() 메서드를 가지고 있다.

여기에는 클래스의 이름, 상속받은 정보, 정의된 메서드의 정보가 담겨있다.

Method lengthMethod = String.class.getMethod("length");

이렇게 getMethod()와 같은 메서드로 Method 타입의 클래스 메서드를 가져올 수 있다.

 

그리고 Method 클래스의 Invoke() 메서드를 사용해 실제로 실행 할 수도 있다.

대상 오브젝트와 파라미터 목록을 넘겨줘서 실행하면 된다.

lengthMethod.invoke("hello");

 

이 리플렉션을 이용해서 다이나믹 프록시를 적용한다.

다이나믹 프록시가 동작하는 방식은 다음과 같다.

다이나믹 프록시는 요청마다 해당 메서드를 감싸는게 아니라, 프록시 팩토리에 의해 런타임시에 다이나믹하게 만들어지는 오브젝트이다.

이렇게 만들어지는 다이내믹 프록시 오브젝트를 클라이언트가 사용하는 것이다.

다이나믹 프록시 오브젝트는 클라이언트의 요청을 리플렉션으로 변환해서 InvocationHandler 구현 오브젝트의 invoke() 메서드로 넘기는 것이다.

이런 방법을 사용하면 부가적인 기능들을 프록시 클래스에 하나하나 추가해야 하는 번거로움이 사라진다.

InvocationHandler 인터페이스를 구현한 오브젝트를 제공해주면 다이나믹 프록시가 받는 모든 요청을 InvocationHandler의 Invoke()로 보내준다.

그렇게 처리하기 때문에 타깃에 얼마나 많은 메서드가 있어도 하나의 메서드(invoke)로 처리할 수 있다.

 

public interface Hello {
    String sayHello(String name);;
    String sayHi(String name);
    String sayBye(String name);
}

 

public class HelloImpl implements Hello {

    public String sayHello(String name) {
        return "hello " + name;
    }

    public String sayHi(String name) {
        return "hi " + name;
    }

    public String sayBye(String name) {
        return "bye " + name;
    }
}

이런 추상 인터페이스와 구현체가 존재한다.

HelloImpl을 건드리지 않고 반환되는 값을 모두 대문자로 변경하고 싶다면 이렇게 HelloImpl을 멤버로 가져와서 사용하는 다음과 같은 클래스를 만들어야 한다.

@RequiredArgsConstructor
public class HelloUpperCase implements Hello {

    private final Hello hello;

    @Override
    public String sayHello(String name) {
        return this.hello.sayHello(name).toUpperCase();
    }

    @Override
    public String sayHi(String name) {
        return this.hello.sayHi(name).toUpperCase();
    }

    @Override
    public String sayBye(String name) {
        return this.hello.sayBye(name).toUpperCase();
    }
}

 

이거를 InvocationHandler를 사용해 바꿔보자.

@RequiredArgsConstructor
public class HelloUpperCase implements InvocationHandler {

    private final Hello target;

    @Override
    public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
        if (method.getReturnType().equals(String.class)) {
            String returnValue = (String)method.invoke(target, args);
            return returnValue.toUpperCase();
        }
        else return method.invoke(target, args);
    }
}

이렇게 호출받은 메서드를 실행하면서 전후로 무언가 추가적인 작업을 할 수 있다.

나는 여기서 리턴의 타입이 String이면 대문자로 넘겨줄 수 있도록 만들었다.

 

    @Test
    public void simpleProxy2(){
        Hello proxyHello = (Hello) Proxy.newProxyInstance(
                getClass().getClassLoader(),
                //구현할 인터페이스
                new Class[]{Hello.class},
                //부가기능을 담은 InvocationHandler 구현체
                new HelloUpperCase(new HelloImpl())
        );
        Assertions.assertEquals("HELLO SEUNGKYU", proxyHello.sayHello("seungkyu"));
        Assertions.assertEquals("HI SEUNGKYU", proxyHello.sayHi("seungkyu"));
        Assertions.assertEquals("BYE SEUNGKYU", proxyHello.sayBye("seungkyu"));
    }

Proxy.newProxyInstance를 통해 프록시가 설정된 클래스를 생성한다.

 

파라미터를 하나씩 살펴보자.

  • 첫번째 파라미터

그냥 클래스로더이다. 다이나믹 프록시가 적용되는 클래스 로더를 지정하는 것이라고 한다.

  • 두번째 파라미터

다이나믹 프록시가 구현할 인터페이스이다. 여기서 배열을 사용하는 이유는 하나 이상의 인터페이스를 구현하기 때문이다. 

  • 세번째 파라미터

부가기능을 가지고 있는 InvocationHandler 구현 오브젝트이다.

 

여기서 invoke 메서드는 요청받은 클래스의 메서드만 사용 가능하다.

만약 이렇게 FakeHello가 존재하고, FakeHello에도 동일한 메서드들이 존재한다면

@RequiredArgsConstructor
public class HelloUpperCase implements InvocationHandler {

    private final Hello target;
    private final FakeHello fakeTarget;

    @Override
    public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
        if (method.getReturnType().equals(String.class)) {
            String returnValue = (String)method.invoke(target, args);
            return returnValue.toUpperCase();
        }
        else return method.invoke(target, args);
    }
}

FakeHello를 통해서 invoke 하려고 해도, 에러가 발생한다는 것이다.

 

이렇게 해서 simpleProxy2를 테스트해보니 다음과 같이 성공하는 것을 볼 수 있었다.

 

그리고 또 궁금한 점이 생겼다.

만약 타입에 맞지 않는 값을 반환한다면?

 

UserService에 정수를 반환하는 메서드를 추가하고, invocationHandler를 아래와 같이 바꾸고

@RequiredArgsConstructor
public class HelloUpperCase implements InvocationHandler {

    private final Hello target;

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

 

다음의 테스트를 실행해보았다.

    @Test
    public void return1Test(){
        Hello proxyHello = (Hello) Proxy.newProxyInstance(
                getClass().getClassLoader(),
                //구현할 인터페이스
                new Class[]{Hello.class},
                //부가기능을 담은 InvocationHandler 구현체
                new HelloUpperCase(new HelloImpl())
        );
        proxyHello.return1();
    }

 

그러니 다음과 같은 에러가 발생했다.

리플렉션을 사용할 때는 반환값의 타입을 꼭 확인하도록 하자.

 

이거 뿐만 아니라 메서드 이름을 보고 실행을 결정할 수도 있다고 한다.

    @Override
    public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
        if(method.getName().startsWith("say")) {
            String returnValue = (String)method.invoke(target, args);
            return returnValue.toUpperCase();
        }
        return method.invoke(target, args);
    }

이렇게 메서드의 이름을 가져와서 문자열로 비교가 가능하다.

 

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

이전부터 굉장히 편하게 사용하던 기능이다.

정말 부끄럽지만 AOP가 뭔지도 모르던 시절부터 아무생각도 안하고 그냥 따라쓰던 방법이다.

이번 기회에 자세히 알아보도록 하자.

 

우선 UserServiceTx를 프록시 방식으로 변경해보도록 하자.

지금은 UserService에 메서드가 추가될 때마다, UserServiceTx에도 하나씩 추가해줘야 하는 문제가 있었다.

@RequiredArgsConstructor
public class TransactionHandler implements InvocationHandler {

    private final Object target;
    private final PlatformTransactionManager platformTransactionManager;
    private final String pattern;

    @Override
    public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
        if(method.getName().startsWith(pattern)) {
            return this.invocationTransaction(method, args);
        }
        else{
            return method.invoke(target, args);
        }
    }

    private Object invocationTransaction(Method method, Object[] args) throws Throwable {
        TransactionStatus status = this.platformTransactionManager.getTransaction(new DefaultTransactionDefinition());
        try
        {
            Object returnValue = method.invoke(target, args);
            this.platformTransactionManager.commit(status);
            return returnValue;
        }
        catch(InvocationTargetException e)
        {
            this.platformTransactionManager.rollback(status);
            throw e.getTargetException();
        }
    }
}

모든 메서드에 트랜잭션 설정을 할 수는 없으니, 메서드의 이름을 확인하며 해당 패턴에 맞는 경우에만 트랜잭션 설정을 할 수 있도록 했다.

그리고 invoke로 발생한 모든 예외는 InvocationTargetException으로 전환되는 것 같다.

그렇기에 호출과정에서 발생하는 예외를 rollback하기 위해 InvocationTargetException을 catch한다.

필요한 PlatformTransactionManager는 생성자를 통해 받도록 했다.

 

다이나믹 프록시를 위한 팩토리 빈

일단은 프록시를 설정했는데, 지금으로서 이걸 쓰기에는 계속 proxyInstance로 생성해주며 사용해야 한다.

원래 목적으로라면 DI를 통해 프록시 설정된 빈을 받도록 해야하는데, 프록시로 감싼 빈을 주입받을 수 없기 때문이다.

 

그렇기에 xml을 통해 빈을 만들지 말고 팩토리 빈을 사용해 만들어보자.

package org.springframework.beans.factory;

import org.jspecify.annotations.Nullable;

public interface FactoryBean<T> {

	String OBJECT_TYPE_ATTRIBUTE = "factoryBeanObjectType";
	@Nullable T getObject() throws Exception;
	@Nullable Class<?> getObjectType();
	default boolean isSingleton() {
		return true;
	}
}

일단 스프링 공식문서에서 정리된 FactoryBean은 다음과 같다.

 

이 FactoryBean을 구현하는 것도 자바 빈을 만드는 방법의 한가지라고 한다.

예시로 간단한 클래스를 만들어보자.

@Getter
@Builder
public class Person {
    
    String name;
    
    private Person(String name) {
        this.name = name;
    }
}

이렇게 name이라는 필드를 가지지만, 생성자가 private이기 때문에 외부에서는 만들지 못하는 클래스가 있다.

물론 Builder로 생성이 가능하지만, 일단은 생성자가 private이다.

그렇기에 현재는 직접 스프링 빈으로 등록이 불가능하다.

 

그렇기에 FactoryBean을 사용해 등록한다.

@AllArgsConstructor
public class PersonFactoryBean implements FactoryBean<Person> {

    String name;

    @Override
    public Person getObject() throws Exception {
        return Person.builder()
                .name(this.name)
                .build();
    }

    @Override
    public Class<?> getObjectType() {
        return Person.class;
    }

    public boolean isSingleton() {
        return true;
    }
}

getObject()를 통해 사용될 오브젝트를 직접 생성해준다. 코드를 사용해 등록하기 때문에 Builder를 사용해서도 등록이 가능하다.

해당 클래스의 생성자나 setter를 통해 대신 DI를 받을 수 있도록 한다.

이렇게 주입된 정보로 getObject()와 같은 곳에서 객체 생성에 사용한다.

 

isSingleton()을 사용해 싱글톤으로 등록하는지 설정한다.

싱글톤이 아니라면 요청마다 오브젝트를 생성해주며, 대부분의 경우는 싱글톤이고 그렇기에 default도 싱글톤이다.

이 팩토리빈은 딱 오브젝트를 생성하는 과정에서만 사용한다.

 

그럼 해당 팩토리 빈은 어떻게 등록하는 것일까?

    <bean id="person" class="seungkyu.PersonFactoryBean">
        <constructor-arg value="seungkyu"/>
    </bean>

이렇게 등록해두고 테스트 코드를 사용해 클래스의 정보를 찍어보자.

 

    @Test
    public void getTypeFromFactoryBean(){
        Object person = applicationContext.getBean("person");
        System.out.println(person.getClass());
    }

해당 메서드로 타입을 확인해보니

 

이렇게 Person 클래스로 확인된다.

타입을 가져오더라도 PersonFactoryBean으로 등록했지만, 자바 제네릭으로 등록한 Person 클래스로 가져오는 것을 볼 수 있다.

 

기존에 트랜잭션 작업을 하는 TransactionHandler 클래스를 구현했었다.

해당 클래스는 InvocationHandler를 구현한 상태였다.

 

이 방식으로 프록시를 구현한다면

    UserService proxyUser = (UserService) Proxy.newProxyInstance(
                getClass().getClassLoader(),
                new Class[]{UserService.class},
                new TransactionHandler(
                        new UserServiceImpl(userDao, mailSender),
                        platformTransactionManager,
                        "tx"
                )
        );

이렇게 생성하고 있다.

 

FactoryBean의 getObject()에 저렇게 클래스를 만들고 반환하게 해준다면 프록시가 적용된 클래스로 빈을 적용할 수 있을 것 같다.

 

@RequiredArgsConstructor
public class TxProxyFactoryBean implements FactoryBean<Object> {

    private final Object target;
    private final PlatformTransactionManager platformTransactionManager;
    private final String pattern;
    private final Class<?> serviceInterface;

    @Override
    public Object getObject() throws Exception {
        TransactionHandler transactionHandler = new TransactionHandler(target, platformTransactionManager, pattern);
        return Proxy.newProxyInstance(
                getClass().getClassLoader(),
                new Class[] {serviceInterface},
                transactionHandler
        );
    }

    @Override
    public Class<?> getObjectType() {
        return serviceInterface;
    }

    @Override
    public boolean isSingleton() {
        return false;
    }
}

트랜잭션 프록시를 현재는 UserService만 적용하겠지만, 이후에는 다른 서비스들에도 적용 할 수 있으니 제네릭으로 클래스를 생성하도록 만들었다.

대신 이 방법으로 만든다면 클래스의 getObjectType()을 위해 해당 클래스가 어떤 인터페이스를 구현한지의 정보도 생성자를 통해 받을 수 있도록 했다.

 

이제 이 팩토리 빈을 xml을 통해 빈으로 등록해보자.

    <bean id="userService" class="seungkyu.TxProxyFactoryBean">
        <constructor-arg ref="userServiceImpl"/>
        <constructor-arg ref="transactionManager"/>
        <constructor-arg value="tx"/>
        <constructor-arg value="seungkyu.UserService"/>
    </bean>

    <bean id="userServiceImpl" class="seungkyu.UserServiceImpl">
        <constructor-arg ref="userDao"/>
        <constructor-arg ref="mailSender"/>
    </bean>

여기서 다른 대부분의 class들은 빈으로 주입했지만, serviceInterface는 value로 주입하였다.

이렇게 주입하더라도 스프링 자체에서 Class.forName()을 호출해 맞는 타입으로 mapping 해준다고 한다.

 

프록시 팩토리 빈 방식의 장점과 한계

일단 TxProxyFactoryBean을 제네릭으로 받도록 해두었기에 다양한 타입의 클래스에 코드의 수정없이 프록시를 설정 할 수 있다.

 

만약 AuthService를 개발하고 있다고 하면 우선 트랜잭션 없이 개발 할 수 있다.

이 클래스를 빈으로 등록한다면

    <bean id="authService" class="seungkyu.AuthServiceImpl">
        <constructor-arg ref="userDao"/>
    </bean>

이렇게 개발하고 있다가 트랜잭션이 필요한 순간에 다음과 같이 트랜잭션 프록시를 추가할 수 있다.

    <bean id="authService" class="seungkyu.TxProxyFactoryBean">
        <constructor-arg ref="authServiceImpl"/>
        <constructor-arg ref="transactionManager"/>
        <constructor-arg value="tx"/>
        <constructor-arg value="seungkyu.AuthService"/>
    </bean>

    <bean id="authServiceImpl" class="seungkyu.AuthServiceImpl">
        <constructor-arg ref="userDao"/>
    </bean>

 

기존에 트랜잭션을 적용하는 방법보다 훨씬 빠른 방법이다.

트랜잭션 프록시를 적용하겠다는 이유로 새로운 클래스를 작성할 필요가 없기 때문이다.

 

기존에 비해 너무 편리해졌지만, 아직도 아주 작은 불편함이 남아있다.

우선 대부분의 프록시 설정은 사실 클래스 단위가 아닌, 클래스 속의 메서드 단위에서 이루어진다.

그렇기에 하나의 클래스에서 하나의 메서드에만 프록시를 적용해야 한다면, 그 하나의 메서드를 위해 모든 클래스에 프록시 설정을 해두고 패턴을 찾아서 매칭시켜야 한다는 문제가 있다.

지금은 프로젝트가 크지 않지만, 프로젝트가 커진다면 xml에 하나씩 등록해주는 것조차 큰 문제가 될 것이다.

또한, 지금은 isSingleton()의 리턴이 false이다.

사실 여기서 사용하는 TransactionHandler는 모든 트랜잭션 설정에서 사용하지만, 공유하지 못하고 계속 생성해야한다.

이런 것도 싱글톤으로 만들고 관리하고 싶다.

 

이런 사소한 문제까지 최대한 스프링에서 해결해보도록 하겠다.

+ Recent posts