※ 해당 내용은 토비의 스프링3을 보고 정리한 내용이다.

 

6.5 스프링 AOP

  직전 장까지 어드바이스와 포인트컷을 한번만 생성해서 이를 재사용할 수 있는 구조를 만들었지만 추가적으로 개선할 포인트가 남아있다.

어드바이스 적용이 필요한 타겟 오브젝트마다 비슷한 내용의 xml 빈 설정정보를 추가해줘야하는 부분이다. 코드의 수정은 발생하지 않지만 새로운 서비스 클래스에 어드바이스를 적용하고자 할때마다 중복된 내용의 설정이 추가되어야한다.

 

자동 프록시 생성

  현재까지는 코드 반복에 대한 해결책으로 바뀌는 부분과 바뀌지 않는 부분을 구분해서 분리하고 템플릿, 콜백, 클라이언트로 나누는 방식으로 해결했다. (전략패턴과 DI활용)

 

  하지만 반복적인 위임이 필요한 프록시 클래스 코드의 경우 위와는 다른 방식으로 문제를 해결했다. 다이나믹 프록시를 이용해서 런타임시에 필요한 코드를 가지는 프록시 클래스를 만들어서 변하지 않는 부분(위임, 부가기능 적용)은 다이나믹 프록시에 맡기고, 변하는 부분(부가기능)은 별도로 만들어서 다니아믹 프록시 생성 팩토리에 DI로 제공하는 방법이다.

  • 빈 후처리기를 사용한 자동 프록시 생성
  • 스프링에서는 DefaultAdvisorAutoProxyCreator라는 어드바이저를 이용한 자동 프록시 생성이 가능한 구조를 제공한다. 이를 통해서 스프링 컨테이너에서 빈 생성 이후, 후처리기에서 해당 빈 객체의 일부를 프록시로 감싸고 해당 프록시를 빈으로 대신 등록할 수 있다.

 

  위 구조를 기준으로 프로세스를 살펴보면, 빈 후처리기가 빈 등록되어 있으면 스프링에서는 빈 생성시점마다 해당 후처리기에 빈을 보낸다. 이후 DefaultAdvisorAutoProxyCreator에서는 빈으로 등록된 모든 어드바이저 내의 포인트컷을 사용해서 전달받은 빈의 프록시 적용대상여부를 판단하고, 적용 대상 빈이라면 프록시를 생성해서 스프링 컨테이너에 해당 프록시를 전달해준다.

 

  이렇게 빈 후처리기를 사용함으로써 기존에 ProxyFactoryBean 빈을 수기로 등록하는 작업을 자동으로 프록시가 적용되도록 할 수 있다.

 

DefaultAdvisorAutoProxyCreator의 적용

실제 후처리기를 이용한 자동 프록시 적용을 하기위한 프로세스를 세분화해서 확인해볼 수 있다.

  1. 클래스 필터를 적용한 포인트컷 작성
  2. 포인트컷 인터페이스에는 클래스 필터, 메서드 매처 두 가지 메서드가 있고 이를 통해서 특정 조건을 만족하는 클래스, 메서드를 판별할 수 있다. 작성된 포인트컷은 빈으로 등록이 필요하다.

 

  1. 어드바이저를 이용하는 자동 프록시 생성기 등록1에서 등록된 포인트컷에 의해서
    ServiceImpl로 끝나는 클래스의 upgrade
    메서드에 대해서는 등록된 transactionAdvisor 어드바이저 빈에 의해서 트랜잭션 관련 부가기능을 수행하게 된다.
  2. DefaultAdvisorAutoProxyCreator를 통해서 등록된 빈 중에서 어드바이저 인터페이스를 구현한 것을 모두 찾고, 어드바이저의 포인트컷을 적용해보면서 프록시 적용 대상을 선정한다. 적용 대상인 경우 프록시를 생성해서 원래의 빈 객체와 바꿔치기한다.

 

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

  이전에 구현한 포인트컷은 클래스명, 메서드명을 각각 클래스 필터와 메서드 매처를 통해서 비교하는 방식이었기 때문에 이를 수기로 구현하거나 스프링에서 제공하는 값을 가져와서 프로퍼티에 설정해야하는 방식이었다.

 

스프링에서는 이를 간단하게 정규식 같이 일종의 표현식 언어를 사용해서 작성할 수 있도록 포인트컷 표현식을 지원한다.

  • AspectJExpressionPointcut
  • 포인트컷 표현식을 사용하려면 해당 클래스를 사용하고 이는 AspectJ 프레임워크에서 제공하는 클래스이다.
  • 포인트컷 표현식 문법
  • 구조는 아래와 같으며, 관련 내용은 실제 사용하는 시점에 좀 더 구체적으로 그때그때 알아보면 될 것 같다.

  위와 같은 포인트컷 표현식을 통해서 기존에 클래스, 메서드별로 따로 프로퍼티를 셋팅했던 포인트컷을 하나의 프로퍼티값으로 명시해줄 수 있다.

 

  다만 주의해야할 점은 표현식이 문자열이기 때문에 컴파일 시점에서는 오류 검증이 불가하기 때문에 다양한 테스트를 미리 만들어서 검증된 표현식을 사용하는 것이 중요하다.

<!-- ASIS -->
<property name="mappedClassName" value="*ServiceImpl" />
<property name="mappedName" value="upgrade*" />

<!-- TOBE -->
<property name="expression" value="execution(* *..*ServiceImpl.upgrade*(..))" />

 

AOP란 무엇인가?

비즈니스 로직을 담은 UserService에 트랜잭션 기능을 적용해온 과정을 정리해보면 다음과 같다.

  1. 트랜잭션 서비스 추상화
      최초로 인지한 문제는 비즈니스로직이 특정 트랜잭션 기술에 종속된다는 것이었다. 이를 타개하기 위해서 트랜잭션 적용 관련 처리를 추상화하고 런타임 시점에 구체 구현을 다이나믹하게 연결함으로써(DI) 비즈니스 로직에 영향없이 독립적으로 변경할 수 있게 되었다.

  2. 프록시와 데코레이터 패턴
      트랜잭션 기술에 대한 종속은 끊어냈지만 비즈니스로직과 트랜잭션 적용 코드가 여전히 하나의 메서드내에 섞여있다. 이를 해결하기 위해 데코레이터 패턴을 사용해서 클라이언트가 인터페이스와 DI를 통해서 비즈니스로직에 접근하도록 하고, 트랜잭션 처리 부분은 그 사시에 존재시킴으로써 일종의 프록시 역할을 하는 트랜잭션 데코레이터를 거쳐서 타겟(비즈니스 로직)에 접근할 수 있도록 했다.
    이러한 분리를 통해서 비즈니스로직과 트랜잭션 처리가 분리되고 단위 테스트를 작성하기에도 용이해진다.

  3. 다이나믹 프록시와 프록시 팩토리 빈
      비즈니스 로직 인터페이스의 모든 메서드에 대해서 트랜잭션 사용여부에 상관없이 프록시로서의 위임 기능을 넣어서 프록시 클래스를 만드는 작업이 복잡하다.
      JDK의 다이나믹 프록시를 통해서 부가기능을 메서드별로 중복 구현해줘야하는 문제는 해결했으나, 동일 기능의 프록시를 여러 오브젝트에 적용하는 경우 오브젝트 단위의 중복문제는 해결되지 못했다.
      최종적으로 Spring에서 제공하는 프록시 팩토리 빈을 이용해서 다이나믹 프록시의 생성을 DI를 통해서 처리하도록 했고, 어드바이스와 포인트컷을 프록시에서 분리하고 여러 프록시에서 공유해서 사용할 수 있는 구조로 개선했다.

  4. 자동 프록시 생성 방법과 포인트컷
      소스상으로는 문제가 없으나 스프링 설정상으로 트랜잭션 적용이 필요한 모든 빈 마다 프록시 팩토리 빈 설정을 해줘야하는 문제가 생겼다.
      이에 대한 해결을 위해서 스프링의 빈 생성 후처리 기법을 활용해서 스프링 컨테이너 초기화 시점에 자동으로 프록시를 생성해주도록 개선했다. 그리고 실제 프록시를 생성할 대상 빈을 일일이 설정파일에서 지정하지 않고 포인트컷이라는 독립적인 정보를 통해서 조건에 맞는 빈을 자동으로 선택하도록 했다.

  5. 부가기능(어드바이스)의 모듈화
      최종적으로 기능적으로는 독립될 수 없는 트랜잭션 경계설정 부가기능을 TransactionAdvice라는 이름으로 모듈화 시킬 수 있었고, 위의 모든 작업은 최종적으로 핵심기능에 부여되는 부가기능을 효과적으로 모듈화하는 방법을 찾는 프로세스였다고 볼 수 있다.

그래서 AOP를 어떻게 정의할 수 있을까?

 

  전통적인 객체지향 설계로는 독립적인 모듈화가 불가능한 트랜잭션 경계설정과 같은 부가기능을 어떻게 모듈화할지에 대한 고민이 있었고, 이러한 부가기능 모듈화 작업은 기존 객체지향 설계 패러다임과 구분되기 때문에 이 모듈을 객체가 아닌 aspect라고 명명한다.

 

  객체지향적 설계의 핵심인 추상화를 적용해도 aspect는 6-21의 왼쪽 그림처럼 기존 구조와 분리되지 않는다. 이를 해결하기 위해서 aspect를 기존구조와는 아예 분리되는 측면으로 분리/설계 하는 방법을 AOP라고 정의할 수 있다. 이는 OOP를 대체할 새로운 패러다임이 아닌, OOP를 더 잘 사용하기 위한 보조 수단으로 보는게 맞다.

 

스프링의 AOP 적용기술

  • 프록시를 사용하는 AOP 적용어드바이스가 구현하는 MethodInteceptor 인터페이스는 프록시로부터 요청정보를 전달받고 타겟 오브젝트 메서드를 호출하게 되는데, 이 호출 전후로 다양한 부가기능을 제공할 수 있다.

  • AOP의 적용 핵심을 프록시의 사용이다. 프록시로 만들어서 DI로 연결된 클라이언트→타겟 사이에서 부가기능을 제공해주는것이 골자이다.

  • 바이트코드 생성/조작을 통한 AOP 적용

  • 이 외에 바이트코드 생성/조작을 통한 AOP 적용 방식도 존재한다. AspectJ 프레임워크는 프록시처럼 간접적인 방식이 아니라 실제 타겟 오브젝트를 수정해서 부가기능을 직접 넣어주는 방법을 사용한다. 이를 소스코드 수정으로는 해결이 불가하니 컴파일된 class파일을 수정하거나 JVM 로딩시점에 가로채서 바이트코드를 조작하는 방식을 사용한다.

      이렇게 AOP 적용 시, 스프링 컨테이너가 사용되지 않는 환경에서도 AOP적용이 가능하고 타겟 오브젝트가 생성되는 시점에 부가기능을 적용시켜줄수도 있다.

 

6.6 트랜잭션 속성

TransactionAdvice에서는 타겟 메서드 호출이전에 트랜잭션을 가져오는 작업을 하고 있는데, 여기서 사용하는 DefaultTransactionDefinition에 대해서 좀 더 알아본다.

 

트랜잭션 정의

  기본적으로 트랜잭션은 작업의 최소단위를 뜻하고 진행된 작업은 commit을 해서 모두 성공하든지 rollback을 통해서 모두 취소가 되어야 한다. TransactionDefinition 인터페이스를 구현하고 있는 구현체의 셋팅을 통해서 이외에 추가로 하기 트랜잭션 동작방식을 제어할 수 있다.

  • 트랜잭션 전파
      특정 트랜잭션 A가 진행중에 있고 중간에 B에서 새로운 트랜잭션이 생성된다면 B의 동작은 트랜잭션 전파 속성에 따라서 다르게 처리될 수 있다.
    • PROPAGATION_REQUIRED
      진행중인 트랜잭션이 없으면 새로 시작하고, 있다면 이에 참여한다.

    • PROPAGATION_REQUIRES_NEW
      항상 새로운 트랜잭션을 시작한다.

    • PROPAGATION_NOT_SUPPORTED
      트랜잭션 처리를 하지 않도록 동작한다. AOP의 포인트컷중에서 일부 메서드는 이를 미적용 시키고 싶은 경우 사용한다.
  • 격리수준
    • 모든 DB 트랜잭션은 격리수준 관련 설정을 가지고 있고, 이는 메서드 레벨로 재정의할 수도 있다.
  • 제한시간
  • 읽기전용

 

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

  • 메서드 이름 패턴을 이용한 트랜잭션 속성 지정
  • 트랜잭션 인터셉터를 통해서 트랜잭션 속성을 메서드별로 다르게 가져갈수도 있다. 스프링에서 제공하는 TransactionInterceptor를 아래와 같이 설정함으로써 특정 메서드 명에 대한 조건으로 적용되는 트랜잭션 속성을 다르게 가져갈 수 있다.

 

  • tx 네임스페이스를 이용한 설정 방법
    설정파일내에서 비슷해보이는 태그 대신, 용도를 명확히 드러내주는 태그를 사용함으로써 가독성 측면에서 더 좋다.

 

 

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

  • 트랜잭션 포인트컷 표현식은 타입 패턴이나 빈 이름을 사용
    ex) executon(**..*ServiceImpl.(..))

  • 공통된 메서드명을 사용해서 최소한의 트랜잭션 어드바이스와 속성을 정의
    ex) insert, get 등 특정 동작에 대한 공통 접두어를 정의

  • 프록시 방식의 AOP는 동일 타겟 객체내의 메서드 호출시에는 적용 불가
    아래 구조와 같이 1,3은 프록시를 통해서 타겟 메서드가 호출됐기 때문에 트랜잭션 적용이 되지만 2의 경우는 트랜잭션 적용이 불가하다.

 

 

6.7 어노테이션 트랜잭션 속성과 포인트컷

@Transactional 어노테이션

  직관적으로 사용이 가능하며 메서드 ,클래스, 인터페이스에 사용이 가능하다. 해당 어노테이션이 붙은 대상을 스프링은 자동으로 타겟 객체로 인식하며, 이때 TransactionAttributeSourcePointcut 포인트컷이 사용된다. 또한 어드바이저의 동작방식 또한 해당 어노테이션별로 구분해서 가져올 수 있는 AnnotationTransactionAttributeSource를 사용하기 때문에 메서드별로 유연한 트랜잭션 속성이 가능하다.

 

트랜잭션 어노테이션 적용

  UserServie에 트랜잭션 어노테이션을 적용한다고 하면, 구체클래스가 아닌 인터페이스에 @Transactional 적용을 해두고, 읽기전용 설정이 필요한 단순 조회 메서드는 메서드 레벨에 @Transactional를 다시 적용하는 것이 일반적이다.

 

  스프링에서는 @Transactional 적용 시 메서드 → 타겟 클래스 → 인터페이스 순서로 확인하기 때문에 아래와 같은 적용이 가능하다.

 

6.8 트랜잭션 지원 테스트

선언적 트랜잭션과 트랜잭션 전파 속성

  UserService의 add() 메서드의 트랜잭션 전파 방식을 디폴트값이 REQUIRED 라고 봤을때, 이를 사용하는 영역에서는 좀 더 큰 단위의 트랜잭션을 이미 시작했을 수 있다. 예를 들어서 EventService의 processDailyEventRegistration() 메서드에서 이미 트랜잭션이 시작되었다면 add() 가 독립적인 트랜잭션으로 시작되는대신 이미 진행되는 트랜잭션의 일부로 참여하게 된다.

 

  스프링에서는 이러한 트랜잭션의 전파 속성을 어노테이션을 통해서 선언적으로 간편하게 적용할 수 있기 때문에 불필요한 add() 메서드 코드 중복이 발생하지 않는다.

 

  AOP를 통해서 외부에서 트랜잭션의 기능을 부여하고 속성을 지정할 수 있는 선언적 트랜잭션 방식이 가능하다.

 

 

트랜잭션 동기화와 테스트

  디폴트 트랜잭션 전파속성인 REQUIRED의 경우 이미 진행중인 트랜잭션이 있으면 그 트랜잭션에 참여할 수 있돋록 하고, 이는 트랜잭션 동기화 기술에 의해서 가능하다. 보통 서비스로직에서는 선언적 트랜잭션을 통해서 이를 관리하는게 편하지만 테스트코드 작성의 경우, 트랜잭션 매니저를 통해서 이를 제어한다.

 

  userService내의 메서드는 모두 전파속성이 REQUIRED 로 되어있기 때문에 해당 테스트 메서드 실행시 트랜잭션이 독립적으로 3개 생성되는게 맞다. 하지만 트랜잭션 매니저를 통해서 임의로 userService 메서드 호출이전에 트랜잭션 시작시 전파속성에 의해서 테스트 메서드내에서 1개의 트랜잭션만 생성된다.

 

  테스트에서 트랜잭션을 조작할 수 있다면 트랜잭션 결과나 상태를 조작하면서 테스트하는게 가능하고 트랜잭션 전파속성에 따라서 여러 메서드를 조합해서 어떤 결과가 나오는지 미리 검증이 가능하다.

 

테스트를 위한 트랜잭션 어노테이션

  • @Transactional
  • @Rollback
    테스트용 트랜잭션은 기본적으로 테스트가 종료되면 자동으로 롤백된다. 하지만 이러한 강제 롤백을 하지않고 테스트에서 진행한 작업을 그대로 DB에 반영하고 싶으면 @Rollback(false) 어노테이션을 사용한다.
  • @TransactionConfiguration

'Spring' 카테고리의 다른 글

[토비 스프링3] 6. AOP (1)  (0) 2023.03.04
[토비 스프링3] 5. 서비스 추상화  (0) 2023.02.13
[토비 스프링3] 4. 예외  (0) 2023.01.23
[토비 스프링 3] 3. 템플릿  (0) 2023.01.08
[토비 스프링 3] 2. 테스트  (0) 2020.06.09

※ 해당 내용은 토비의 스프링3을 보고 정리한 내용이다.

6.0

  AOP는 Ioc/DI, 서비스 추상화와 같이 스프링 3대 기반 기술중 하나이다. 스프링에서 AOP가 도입된 이유, 장점 등에 대한 이해가 필요하다.

 

6.1. 트랜잭션 코드의 분리

  현재까지 서비스 추상화를 통해서 UserService에 트랜잭션 기술에 종속적이지 않은 코드를 작성했지만, 트랜잭션 경계설정을 하는 부분과 비즈니스 로직과 아직 분리되지 않았기 때문에, 메서드를 이해하는데 있어서 복잡도가 있다.

  트랜잭션 경계설정 코드와 비즈니스 로직 코드는 서로 주고받는 데이터가 없고 구조상 완벽하게 분리되어 있지만 결국 트랜잭션의 경계설정은 특정 비즈니스 로직의 전후에 처리되어야 하므로, 이를 설정하는 코드가 비즈니스로직과 공존할 수 밖에 없다. 이 같은 상황을 타개하고 이 두 부분의 코드를 어떻게 분리할 수 있을까?

 

메서드 분리

  결합되어 있지 않은 두 부분 중에서 비즈니스로직을 따로 메서드로 분리해서 관리해도 문제가 없다. 하지만 이렇게 분리한다고 해도 결국 트랜잭션 관리에 대한 코드가 UserService내에 있으므로 분리 이전과 큰 차이가 없다.

 

DI를 이용한 클래스 분리

  현재 구조에서는 UserService 자체가 구체클래스이고 이 클래스를 참조하는 영역에서는 직접적으로 해당 클래스를 호출하기 때문에 클래스를 분리해서 트랜잭션관련 처리를 보도록 할 수 없는 구조이다.

 

이를 대응하기 위해서 UserService를 인터페이스로 변환하고 이를 구현하는 구체클래스를 생성한다.

 

해당 구조에서 UserServiceTx는 비즈니스 로직을 처리하지는 않고 UserServiceImple에 위임한다. 다만 전후로 트랜잭션 관련 처리를 수행한다.

  위와 같이 UserSertiveTx 내부에 UserService 인터페이스에 대한 멤버변수를 두고 이를 통해서 실제 비즈니스로직을 가지고 있는 UserServiceImpl의 메서드를 호출 할 수 있다.

 

  다만 실제 트랜잭션 처리 적용을 하려면 DI설정 변경이 필요하다. 따라서 UserService 의 대표 빈을 UserServiceTx로 설정하고 UserServiceTx내의 userService에는 UserServiceImpl을 주입받도록 설정해야한다.

 

  이렇게 트랜잭션 로직과 비즈니스 로직을 분리함으로써, 비즈니스 로직 작성시에 불필요한 코드를 추가할 필요가 없어졌다. 또한 책임이 다른 부분의 클래스 분리를 통해서 실제 비즈니스 로직에 대한 테스트 작성이 용이해졌다.

 

6.2 고립된 단위 테스트

  단위테스트를 작성하기 가장 좋은 방법은 테스트 대상을 가장 작은 단위로 쪼개서 테스트하는 것이다. 여러 의존성을 가지는 로직은 동작하는 테스트를 만들고 이를 실행하는 것이 어려워지고 테스트 실패 시, 원인을 찾는 것도 어렵다.

 

  현재 UserService에는 단순 로직밖에 없지만 테스트 코드를 작성하려면 DB, MailSender, TxManager 와 같은 의존성을 주입해줘야한다. 여기서 끝이 아니라 특정 특정 DB의 경우, DB드라이버, 네트워크 통신 등 내부에 추가로 가지는 의존성이 많다. 이러한 부차적인 코드들 때문에 테스트를 작성하기 어려워진다.

 

테스트 대상 오브젝트 고립시키기

  위의 원인들 때문에 테스트 대상은 환경, 외부 서버, 다른 클래스 코드 등에 영향을 받지 않도록 고립시킬 필요가 있다. 예시로 테스트 대역을 사용할 수 있다. TxManager는 이전 단계에서 클래스 분리를 통해서 의존성을 제거했었고 DAO나 MailSender 같은 경우는 Mock객체를 사용할 수 있다.

 

  테스트 대역 오브젝트를 이용하기 이전에는 스프링 컨테이너에서 가져온 UserService 빈을 이용해 테스트 코드를 실행했지만, 이는 외부 환경에 많은 의존성을 가지고 있으므로 테스트를 용이하게 하기 위해서 Mock객체를 사용해서 의존성을 제거한 테스트 대상을 수기로 직접 생성해서 사용한다.

 

  이렇게 외부 의존성을 제거함으로써 테스트는 간결해지고 빨라진다.

 

6.3 다이나믹 프록시와 팩토리 빈

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

  이전 트랜잭션 경계설정 코드와 비즈니스 로직을 분리했을때 프록시 패턴을 이용해서 아래와 같은 구조를 사용했었다.

 

  이러한 구조를 통해서 핵심기능을 가지는 타겟 접근 이전에 프록시 객체에서 부가적인 기능을 처리할 수 있고, 클라이언트의 타겟 접근방법을 제어할 수 있다.

 

  데코레이터 패턴은 타겟의 부가적인 기능을 런타임시에 다이나믹하게 부여하기 위해서 프록시를 사용하는 패턴을 말하고 여기서 데코레이터 객체는 N개를 가질 수 있다. 자바 IO패키지의 InputStream, OutputStream이 데코레이터 패턴 사용의 대표적 예로 볼 수 있다.

아래의 코드는 타겟(FileInputStream)에 버퍼 읽기 기능을 제공해주는 BufferedInputStream 데코레이터를 적용한 예시이다.

new BufferedInputStream(new FileInputStream("a.txt"));

  이전에 작성했던 예시 기준으로, UserServiceImpl에 트랜잭션 관련 데코레이터 UserServiceTx를 적용시킨 구조로도 이를 볼 수 있다. 스프링 DI를 이용하면 인터페이스를 통해서 런타임에 적절한 데코레이터를 붙이는 작업이 간편해진다. 이전에도 봤듯이, 데코레이터 빈의 프로퍼티로 같은 인터페이스를 구현한 데코레이터, 혹은 타겟을 빈 설정 하면 된다.

  이후부터는 타겟과 같은 인터페이스를 구현해두고 위임하는 방식을 취하는 모든 객체를 프록시라고 지칭하되, 사용의 목적이 기능의 부가(데코레이터)인지, 접근 제어(프록시)인지를 구분이 가능하다.

 

다이나믹 프록시

  프록시는 기존 코드에 영향을 주지 않으면서 타겟의 기능을 확장하거나 접근방법을 제어할 수 있지만, 구조의 복잡성이 늘어난다는 단점을 가지고 있다. 이를 해결하기 위해서 자바에서는 reflect 패키지 안에 이를 손쉽게 해결할 수 있도록 지원해주는 클래스를 제공한다.

프록시는 부가기능 수행, 타겟으로 위임 두 가지 기능으로 구성된다. 이러한 구성을 지키기 위해서 프록시는 다음과 같은 단점이 생긴다.

 

  • 타겟의 인터페이스를 구현하고 위임하는 코드 작성이 번거로움 (부가기능을 필요로 하지 않는 메서드도 구현해서 타겟 위임 코드를 모두 생성해줘야함)
  • 부가기능 코드가 중복될 가능성이 많음

이러한 단점을 다이나믹 프록시를 추가함으로써 해결할 수 있다.

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

  다이나믹 프록시는 런타임 시점에 프록시 팩토리를 통해서 만들어지고, 클라이언트의 모든 요청을 리플렉션 정보로 변환해서 InvocationHandler의 invoke() 메서드로 넘긴다. 이때 핸들러가 타겟의 오브젝트 레퍼런스를 가지고 있다면 리플렉션을 통해서 타겟 메서드에 처리를 위임할 수 있다. 즉 인터페이스의 모든 메서드를 구현할 필요 없이 InvocationHandler.invoke() 메서드 하나를 통해서 처리할 수 있게 된다. 또한 InvocationHandler 방식을 차용하면 리플렉션을 사용해서 invoke 호출하기 때문에 타겟의 종류에 상관없이 적용이 가능하다.

  아래와 같이 다이나믹 프록시를 생성하고 이때 넘겨주는 생성자에서 InvocationHandler 구현 클래스를 같이 넘겨준다.

 

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

  현재 작업된 UserServiceTx는 UserService 인터페이스 메서드를 모두 구현해야하고 트랜잭션이 필요한 메서드마다 처리코드 중복이 발생하는 비효율적인 구조이다. 이런 경우, 트랜잭션 부가기능은 다이나믹 프록시를 만들어서 적용시킴으로써 개선할 수 있다.

 

  InvocationHandler를 구현하는 TransactionHandler를 만들고 구현 내부에 아래와 같이 invoke메서드와 트랜잭션 처리를 구현한다. 요청을 위임할 타겟 객체는 DI로 받고, Object 로 선언함으로써 어떤 타겟도 처리할 수 있고 따로 pattern변수를 만듦으로써 특정 메서드명 (ex. insert, update)을 가지는 메서드 수행 시 트랜잭션을 적용시킬 수 있다.

 

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

  이전까지 TransactionHandler와 다이나믹 프록시를 사용해서 트랜잭션 처리를 할 수 있도록 개선했지만 추가로 스프링의 DI를 통해서 해당 객체를 받아서 사용할 수 있도록 해야한다.

 

  하지만 다이나믹 프록시 객체의 경우 name, property로 구성되는 스프링빈으로 등록이 불가하다. 다이나믹 프록시의 경우, Proxy 클래스의 newProxyInstance() 스태틱 팩토리 메서드를 통해서만 생성이 가능하다.

 

  팩토리 빈은 스프링을 대신해서 객체 생성로직을 담당하는 빈으로 FactoryBean 인터페이스를 구현해서 사용할 수 있다. FactoryBean의 getObject() 메서드에서 반환받고자 하는 빈 객체를 리턴하도록 구현하고, 스프링 빈 설정에서 해당 팩토리 빈 구현체를 등록하면 된다. 즉 다이나믹 프록시 객체의 경우, 이를 생성해주는 팩토리 빈을 구현해서 관리하면 된다.

  위 구조에서 TransactionHandler를 생성하기 위한 팩토리 빈의 property에 실제 타겟 객체인 UserServiceImpl을 DI받을 수 있도록 설정해야한다.

 

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

직전까지의 작업은 장점도 있지만 한계점도 존재한다.

  • 장점
    • 부가기능을 가진 프록시를 생성하는 팩토리빈을 한번 만들어두면 타겟의 타입에 상관없이 재사용할 수 있다.
    • 프록시를 적용할 대상이 구현하고 있는 인터페이스를 모두 구현하지 않아도 된다.
    • 부가기능 코드 중복 문제가 해결된다
  • 한계
    • 프록시를 통해 타겟에 부가기능을 제공하는건 메서드 단위로 발생하기 때문에, 한번에 여러개의 클래스에 공통적인 부가기능을 제공할 수 없다.
    • 하나의 타겟에 여러개의 부가기능을 제공하고자 할 때, 팩토리빈 설정파일에 부가기능 개수 만큼 XML 설정 코드가 증가한다.
    • TransactionHandler 객체가 프록시 팩토리 빈 개수만큼 생성된다. 동일 부가기능을 제공하지만 타겟오브젝트가 변경되면 새로운 객체 생성이 불가피하다.

 

6.4 스프링의 프록시 팩토리 빈

  직전 장에서는 다이나믹 프록시를 사용하기 위한 팩토리 빈 방식의 한계를 확인할 수 있었고 이에 대한 개선점을 스프링을 통해서 제공받을 수 있다.

 

ProxyFactoryBean

  스프링은 서비스 추상화를 프록시 기술에도 적용하고 있기 때문에, 일관된 방법으로 프록시를 만들 수 있도록 해주는 추상 레이어(추상화한 팩토리 빈)를 제공한다.

 

  스프링 ProxyFactoryBean은 프록시를 생성해서 빈으로 등록하게 해주는 팩토리 빈이다. 직전에 직접 생성했던 프록시 팩토리 빈과 다른점은 ProxyFactoryBean에서는 프록시를 생성하는 작업만 담당하고, 실제 프록시를 통해서 제공해주는 부가기능은 MethodInterceptor를 구현한 별도의 빈에 둔다.

 

  MethodInterceptor는 직전 장의 InvocationHandler와 비슷하지만 타겟 객체에 대한 정보를 ProxyFactoryBean을 통해서 같이 전달받기 때문에 타겟 객체 정보에 상관업싱 독립적으로 만들 수 있다. 따라서 MethodInterceptor는 타겟이 다른 여러 프록시에서 같이 사용할 수 있고 싱글톤 빈으로 등록이 가능하다.

 

  아래 코드는 직전에 봤던 JDK 다이나믹 프록시 생성과 ProxyFactoryBean을 통한 프록시 생성을 구분한 것이다.

//ASIS JDK 다이나믹 프록시 생성
Target proxiedTarget = (Target)Proxy.newProxyInstance(
    getClass().getClassLoader(),
    new Class[] { Target.class},
    new Advice1(new Target()));

public Object invoke(Object proxy, Method method, Object[] args){
    String ret = (String)method.invoke(target, args); // target객체를 알고 있어야함
    return ret.doAddtFunction(); // 부가기능 처리
}

//TOBE 스프링 ProxyFactoryBean 사용
ProxyFactoryBean pfBean = new ProxyFactoryBean();
pfBean.setTarget(new Target());  // 타겟 설정
pfBean.addAdvice(new Advice1()); // 부가기능을 담은 어드바이스 추가
pfBean.addAdvice(new Advice2());

Target proxiedTarget = (Target) pfBean.getObject();

static class Advice1 implements MethodInterceptor{ // 부가기능 어드바이스 객체
    public Object invoke(MethodInvocation invocation){ // target객체를 몰라도 됌
        String ret = (String)invocation.proceed();
        return ret.doAddtFunction();
    }
}
  • 어드바이스는 타겟의 정보를 알 필요없는 순수한 부가기능으로 이해하면 된다. 위의 코드에서 MethodInvocation은 타겟 오브젝트의 메서드를 실행할 수 있는 기능이 있기 때문에, Advice1 객체는 실제 처리해주고자하는 부가기능만을 구현할 수 있다.
  • 포인트컷을 처리하기 위해서 MethodInterceptor에는 순수한 어드바이스만 넣어두고 이를 적용할 메서드를 선택하는 기능은 전략패턴을 통해서 구현한다.
  • 포인트컷은 부가기능의 적용대상 메서드를 선정하는 방법이다. 기존 MethodInterceptor는 타겟 객체에 대한 정보를 가지고 있을 필요가 없기 때문에 여러 프록시가 공유해서 사용할 수 있으므로 여기서 메서드명을 pattern을 통해서 식별해서 사용하는 방식은 불가하다.
  • 스프링의 ProxyFactoryBean는 OCP 원칙을 지키도록 아래 구조의 다이나믹 프록시를 제공한다. 어드바이스와 포인트컷 모두 프록시에 DI로 주입되어서 사용된다.

  다이나믹 프록시 생성 후, 프록시는 클라이언트로부터 요청을 받으면 포인트컷에 부가기능을 부여해야하는 메서드인지 체크한다. 이후 선정된 메서드만 어드바이스를 호출한다. 어드바이스는 타겟 객체정보를 들고 있지 않고, 프록시로부터 전달받은 MethodInterceptor 타입의 콜백 객체의 proceed() 메서드를 호출함으로써 타겟 객체 메서드 호출이 가능하다.

 

 위 구조는 재사용 가능한 기능을 만들어두고 바뀌는 부분만 외부에서 주입해서 이를 사용하는 전형적인 템플릿/콜백 구조로 볼 수 있다.

 

ProxyFactoryBean 적용

이전에 JDK 다이나믹 프록시를 이용해서 구현한 TxProxyFactoryBean을 스프링이 제공하는 ProxyFactoryBean으로 대체할 수 있다.

  1. 어드바이스 생성
    부가 기능을 담당하는 TransactionAdvice를 MethodInterceptor 인터페이스를 구현해서 만든다. 타겟 객체에 대한 정보를 알 필요가 없고 MethodInvocation의 proceed()를 통해서 타겟 객체 메서드 호출이 가능하다.

2. 스프링 XML 설정파일

  1. TransactionManager에 해당 어드바이스를 DI하도록 수정해준다.
  2. 포인트컷 빈을 등록한다.
  3. a,b에서의 어드바이스와 포인트컷을 담을 어드바이저 빈을 등록한다.
  4. 최종적으로 ProxyFactoryBean설정에 어드바이저를 property로 등록한다.

ProxyFactoryBean을 이용하도록 개선하면서 어드바이스와 포인트컷의 재사용이 가능해지고 아래의 확장 가능한 구조를 얻을 수 있었다.

 

'Spring' 카테고리의 다른 글

[토비 스프링3] 6. AOP (2)  (0) 2023.03.04
[토비 스프링3] 5. 서비스 추상화  (0) 2023.02.13
[토비 스프링3] 4. 예외  (0) 2023.01.23
[토비 스프링 3] 3. 템플릿  (0) 2023.01.08
[토비 스프링 3] 2. 테스트  (0) 2020.06.09

※ 해당 내용은 토비의 스프링3을 보고 정리한 내용이다.

 

5.0

자바의 표준 스펙, 상용 제품, 오픈소스 중에서는 사용방법과 형식이 다르지만 기능과 목적이 유사한 기술이 존재한다. 이에 대해서 모두 다른 API를 사용하고 이에 대해서 숙지하는 것은 비효율적이다.

스프링이 어떻게 비슷한 기술을 추상화해서 관리하고 이를 일괄된 방법으로 사용하는지 알아보도록 한다.

 

5.1. 사용자 레벨 관리 기능 추가

이전까지 다뤘던 UserDao 기본적인 CRUD의 기능만 가지고 있다. 서비스로직을 추가해서 사용자의 레벨(basic, silver gold)를 관리하도록 기능을 추가한다.

  • 최초 가입시 basic
  • 가입 후 50회 이상 로그인시 silver
  • silver에서 30번 이상 추천을 받으면 gold
  • 사용자 레벨 변경작업은 일정한 주기를 통해서 일괄 진행

 

필드 추가

Level을 관리하는 Enum을 추가한다. 직접 User객체 내에서 level을 int나 String 값으로 관리할 수 있지만, 상수 관리는 휴먼에러를 발생시킬 확률이 높으므로 이에 대해서는 Enum을 통해서 관리할 수 있다.

Enum을 사용함으로써 오타 발생이나 잘못된 level값에 대한 매핑을 컴파일 시점에 체크할 수 있다.

public enum Level{
    BASIC(1), SILVER(2), GOLD(3);

    private final int value;

    // constructor, getter, setter

위의 신규 값에 대한 DB컬럼 신설, USER객체 필드값 추가, 관련 코드(ex.생성자) 수정 작업이 일괄 필요하다. DB저장/조회의 경우, 자바의 Level Enum 타입에 대해서 알 수 없으므로, value값(여기서는 integer)과 매칭시켜서 저장/조회를 수행한다.

 

사용자 수정 기능 추가

성능이슈로 인해서 수정되는 컬럼을 종류에 따라서 SQL 을 분리할수도 있지만 간단하게 USER의 id 기준으로 update문을 수행하는 쿼리 하나로 관리한다고 하자.

  1. 수정 기능 테스트 추가
  2. UserDao, UserDaoJdbc 수정
    1. update 테스트코드에서 update()의 반환값이 1인지 체크
  3. 서비스 로직(사용자 레벨 변경)을 관리할 UserService을 생성하고 레벨 관리 기능 구현
    1. UserDao 빈을 DI 받아서 사용
    2. UserService 클래스 빈 등록
    3. 서비스 로직 upgradeLevels() 메서드 구현/테스트
    새로운 UserService 서비스로직을 개발할때에 주의할 점이 있다. 객체지향적인 코드를 작성하기 위해서는 수행해야하는 책임에 따라서 객체를 식별하고, 객체들끼리는 메시지를 통해서 협력하며 자율적으로 본인의 책임을 수행해야 한다.위와 같이 설계함으로써 각 객체간의 결합도는 줄어들고 새로운 요구사항을 대응하기 위한 소스의 수정 영햠범위가 줄어든다.
  4. 즉 UserService클래스에서 모든 객체(ex. User, Level)에 대해서 액세스, 수정을 주관하면 안되고 필요한 작업을 적절한 객체에 요청해서 받아오도록 구조를 설계해야한다.

 

5.2 트랜잭션 서비스 추상화

만약 사용자 레벨 관리 일괄 업데이트 작업중에 오류 발생으로 작업완료가 불가할때, 직전까지 진행된 작업도 모두 취소시키도록 정책상 정의됐다고 하자.

이러한 상황에 대응하기 위해서 트랜잭션 처리가 필요하다.

 

현재 상황

만들어둔 사용자 레벨 업그레이드 코드는 위와 같은 상황에서 어떻게 동작할까? 테스트를 위해서 기존 사용 어플리케이션코드에서 강제로 익셉션을 발생시킬 수 있지만, 실제 서비스로직을 수정하는 것은 사고발생 가능성이 있기 때문에 테스트용으로 UserService의 대역을 사용하는 방법이 좋다. (상속받아서 테스트가 필요한 메서드만 오버라이딩)

static class TestUserService extends UserService{
    private String id;

    private TestUserServie(String id){
        this.id = id; // 테스트에서 예외를 발생시킬 ID를 지정할 수 있음
    }

    protected void upgradeLevel(User user){
        if(user.getId().equals(this.id)) throw new TestUserServiceException();
        super.upgradeLevel(user);
    }
}

TestUserService는 테스트 목적으로 생성하는 클래스이므로 따로 빈으로 등록하지 않아도 된다. 하지만 현재상황으로는 업그레이드 작업 실패 시, 전체 롤백이라는 정책을 만족시키지 못한다. upgradeLevel() 메서드가 하나의 트랜잭션 범위로 설정되어 있지 않기 때문이다.

 

트랜잭션 경계 설정

단일 SQL에 대해서 DB는 트랜잭션을 지원하지만 서비스로직에서 여러 DB작업에 대해서 트랜잭션을 보장받으려면 트랜잭션 범위를 설정해야한다.

JDBC의 트랜잭션은 Connection 오브젝트를 통해서 이뤄지기 때문에(로컬 트랜잭션) DB 커넥션의 시작-종료 사이에 일어난다. 기본적으로 위에서는 단일 UPDATE에 대해서 트랜잭션 범위가 설정되어 있기 때문에 작업중간에 오류가 발생한다고 해도 직전 작업들에 대해서는 이미 commit이 완료된 상태이다.

특정 작업들에 대해서 트랜잭션 단위를 셋팅하려면, 해당 작업들이 하나의 DB커넥션에서 처리가 되어야한다.

  • 비즈니스 로직 내의 트랜잭션 경계설정
    위에서 봤듯이 트랜잭션 경계설정을 위해서는 단일 커넥션내에서 작업들이 이뤄져야하지만, UserService에서는 DB커넥션에 대한 참조가 불가하다.
        이를 해결하기 위해서 아래와 같은 구조가 필요하고 서비스로직에서 생성된 DB커넥션을 DAO 레벨에서도 사용해야한다.

 

하지만 위와 같은 구조를 채택했을때 하기 문제점들이 발생한다.

  • 기존에 DB 커넥션에 대해서 깔끔하게 처리가 가능했던 JdbcTemplate을 활용 할 수 없음
  • DAO에서 서비스로직의 Connection객체를 받기 위해서 파라미터가 추가되어야함
  • UserDao인터페이스에 Connection객체 파라미터가 추가됨으로써, UserDao는 더이상 데이터 액세스 기술에 독립적일 수 없음
    • JPA, 하이버네이트 등에서는 Connection대신 EntityManager, Session 등을 사용
  • 테스트 코드에서 Connection 객체를 수동으로 생성해서 DAO에 주입해줘야함

 

트랜잭션 동기화

트랜잭션을 사용하기 위해서 서비스로직 레벨에서 이에 대한 처리가 필요한 것은 사실이나 단순히 Connection객체를 서비스로직 레벨에서 생성, 관리했을때는 상기 문제점들에 의해서 관리가 어려워진다. 하지만 스프링에서는 이에 대한 해결책이 존재한다.

  • Connection 파라미터 제거
    많은 문제가 Connection 객체를 파라미터로 직접 전달해야 한다는 것에서 기인한다. 이를 해결하기 위해서 스프링에서는 트랜잭션 동기화 방식을 사용한다. 이는 UserService에서 트랜잭션 시작을 위해서 만들어둔 Connection 객체를 특정 저장소에 보관해두고, 이후 호출되는 DAO 메서드에서 해당 객체를 사용하도록 하는 방식이다.
    트랜잭션 동기화를 사용하는 작업 흐름도는 아래와 같다.

  • 트랜잭션 동기화 적용
    스프링 JdbcTemplate에서는 멀티쓰레드 환경에서도 안전한 트랜잭션 동기화 기능을 유틸리티 메서드로 지원한다.
    트랜잭션 동기화가 필요한 서비스로직에서 DataSource를 DI 받고, DataSourceUtils를 통해서 간단하게 Connection을 받아올 수 있다.

 

트랜잭션 서비스 추상화

위의 작업을 통해서 UserDao가 Connection 파라미터를 가지지 않아도 되고 데이터 액세스 기술에 종속되지 않는 인터페이스 메서드를 유지할 수 있다.

그러나 만약 N개의 다른 DB작업에 대한 트랜잭션 단위를 생성하려고 한다면 직전에 봤던 로컬 트랜잭션에 대한 처리 방식으로는 불가하다. 로컬 트랜잭션은 DB connection에 종속되기 때문에 별도의 트랜잭션 관리자를 통한 글로벌 트랜잭션을 관리할 수 있어야한다.

자바는 글로벌 트랜잭션(분산 트랜잭션)을 지원하는 트랜잭션 매니저를 관리하기 위해서 Java Transaction API(JTA)를 제공한다.

  • 기술과 환경에 종속되는 트랜잭션 경계설정 코드
    JTA를 사용하는 트랜잭션 코드 구조는 아래와 같고 이는 Connection의 메서드 대신 UserTransaction의 메서드를 사용한다는 점을 제외하고는 기존 로컬 트랜잭션 처리와 크게 다르지 않다.
    하지만 JTA를 사용하기 위해서 UserService의 코드 수정이 필요하고 이는 서비스로직은 변경되지 않았지만 기술환경에 따라서 코드 수정이 불가피하게 되는 상황을 초래한다.

 

  • 트랜잭션 API의 의존관계 문제와 해결책
    직전 장들을 통해서 UserDao가 서비스로직의 데이터 액세스 기술 종속성을 없앴지만 UserService에서 트랜잭션 범위 설정을 해야할 필요가 생기면서 다시 특정 데이터 액세스 기술에 종속되는 구조가 되었다.마찬가지로 특정 기술에 의존적이지 않고 여러 기술의 공통점을 뽑아내어서 추상화된 인터페이스에만 의존할 수 있다면 다시 OCP원칙을 지키고 특정 기술에 의존적이지 않은 서비스 로직을 구현할 수 있다.
  • 기존 구현에서는 UserService가 추상적인 DAO 인터페이스에만 의존하는 구조였기 때문에 OCP원칙도 지켜졌었다.
  • 스프링 트랜잭션 서비스 추상화
    스프링에서는 트랜잭션 기술의 공통점을 담은 추상화 기술을 제공하고 있고 이를 통해서 기술에 의존적이지 않은 일관된 구조를 가져갈 수 있다.

 

  • 트랜잭션 기술 설정의 분리
    트랜잭션 추상화 API는 공통으로 사용하는 PlatformTransactionManger 인터페이스를 가지고 이에 대한 각 기술별 구체클래스를 넘겨줌으로써 트랜잭션 매니저/서비스 연동이 가능하다.
  • PlatformTransactionManager txManager = new JTATransactionManager(); PlatformTransactionManager txManager = new HibernateTransactionManager(); // 실제 소스에서는 이렇게 특정 구체클래스 스스로 정하지 않도록 DI를 활용

최종적으로 UserService는 아래와 같은 구조를 가지고 이는 특정 데이터 액세스 기술, 트랜잭션 처리 기술에 독립적으로 작동한다.

 

5.3 서비스의 추상화와 단일 책임 원칙

추상화를 통해서 다양한 트랜잭션 기술을 일괄된 방식으로 제어할 수 있고, 내부 소드 코드는 캡슐화 시키고 설정만 고치는 것으로 DB연결 기술, 데이터 액세스 기술, 트랜잭션 기술을 자유롭게 바꿔서 사용할 수 있다.

 

수직, 수평 계층구조와 의존관계

UserDao, UserService 같이 각 코드의 기능, 관심에 따라서 클래스가 분리되었기 때문에 서로 불필요한 영향을 주지 않으면서 독자적으로 확장이 가능한 구조가 되었다. 이를 같은 계층에서의 수평적인 분리로 볼 수 있다.

트랜잭션의 추상화는 비즈니스로직과 그 하위에서 동작하는 트랜잭션 기술이라는 아예 다른 계층의 특성을 갖는 코드의 수직적 계층구조 분리를 했다.

 

위의 계층구조에서는 수직, 수평 모든 계층 기준으로 모두 객체를 주입(DI) 받아서 필요한 상황에 필요한 객체를 사용할 수 있고 이를 사용하는 곳은 주입받은 객체가 코드에 영향을 주지 않는다. 즉 추상화를 통해서 결합도가 낮아졌고 이를 통해서 비즈니스 로직을 로우레벨의 기술과 환경에서 독립시킬 수 있다.

 

단일 책임 원칙(SRP)

위와 같은 분리는 SRP로 설명이 가능하다. 하나의 모듈은 하나의 책임을 가져야하며, 모듈의 수정 이유도 한 가지여야 한다. 최초의 UserService에서는 비즈니스로직과 JDBC Connection 관리라는 두 가지 책임을 가지고 있었다. 그러나 DB관련 기술에 대한 책임을 타 객체에 위임하고 이를 DI를 통해서 의존성을 외부에서 제어하도록 함으로써 UserService에는 비즈니스로직에 대한 책임만 가질 수 있었다.

 

결국 SRP를 지킴으로써 변경이 발생할 때, 수정할 대상이 명확해지고(응집도가 높음) 이를 통해서 수정에 취약하지 않은 소프트웨어 구조를 가질 수 있다.

 

5.4 메일 서비스 추상화

해당 챕터는 직전 트랜잭셔 서비스 추상화와 비슷한 흐름을 가지므로, 간략하게만 정리. 요구사항은 레벨 업그레이드 대상 사용자에게 안내 메일 발송을 하는 것이다.

  • JavaMail을 사용한 메일 발송 기능 개발
  • 개발환경에서의 메일 발송 기능 테스트에 대한 고민
    • 메일 발송 자체가 부가적인 기능이므로, 운영 메일 서버를 통해서 테스트 시마다 메일을 발송하는 것보다는 테스트용 메일 서버를 두고 해당 서버와의 통신까지만 확인

 

테스트를 위한 서비스 추상화

위와 같이 개발환경에서는 테스트를 위한 서버들이 구축되어 있지 않기도 하다. 또한 JavaMail이라는 자바 제공 라이브러리나, SMTP 프로토콜 같은 경우는 이미 검증이 완료된 기술이기 때문에 이를 검증하는 테스트 코드는 불필요하다.

결국 빠르고 효율적인 테스트를 위해서는 실제 메일전송을 수행하는 JavaMail 대신 테스트용 JavaMail 짝퉁 오브젝를 만들어서 사용할 수 있다. JavaMail에서는 메일 발송에 대한 추상화 인터페이스인 MailSender를 제공하고 있으므로 이에 대한 구체클래스를 테스트용 만들어서 발송 테스트를 할 수 있다.

 

테스트 대역

DAO 를 테스트하기 위해서는 실제로 DB 연동을 통한 테스트가 필요하다. 하지만 테스트를 실행하기 위해서 운영DB에 관련된 설정들, 커넥션 등에 대한 관리가 필요하다면, 테스트를 자주 실행하지 못하게 되고 테스트의 목적을 흐리게 된다. 이를 대처하기 위해서 간단한 테스트용 DataSource를 사용하고 이를 테스트코드에 추상화시켜 놓음으로써(스프링 DI), 아래와 같이 실행 환경 별로 적절한 구조를 가져다가 사용하게 할 수 있다.

 

테스트 환경을 만들고 테스트를 자주 실행할 수 있게 하기 위해서 사용하는 오브젝트를 통틀어서 테스트 대역(test double)이라고 한다.

  • 테스트 스텁(stub)
    • 테스트의 의존성 단순화를 위한 단순 객체
    • 테스트 대상 객체가 의존하는 객체로, 실제 테스트 코드 내부에서 간접적으로 사용된다. 실제 의존성을 주입할 수도 있지만 테스트용 객체를 주입받고 특정 상황에서 특정 값을 반환하도록 임의로 정해줄 수 있다.
  • Mock 객체
    • 좀 더 많은 기능을 가진 test double로, 테스트 코드 수행 시, 해당 객체가 의도한대로 행동하는지, 다른 객체와 의도대로 소통하는지 등을 체크할 수 있다.
    • 테스트 대상으로부터 전달받은 정보를 검증할 수 있다.

 

'Spring' 카테고리의 다른 글

[토비 스프링3] 6. AOP (2)  (0) 2023.03.04
[토비 스프링3] 6. AOP (1)  (0) 2023.03.04
[토비 스프링3] 4. 예외  (0) 2023.01.23
[토비 스프링 3] 3. 템플릿  (0) 2023.01.08
[토비 스프링 3] 2. 테스트  (0) 2020.06.09

1. 가독성과 유연성을 개선하는 리팩터링

계속해서 언급되는 람다표현식의 장점 중 하나는 익명 클래스보다 코드를 좀 더 간결하게 만든다는 점이다. 또한 가독성 뿐만 아니라, 동작 파라미터화의 형식을 지원하기 때문에 다양한 요구사항 변화에 대응할 수 있다.

 

 

1.1 코드 가독성 개선

코드의 가독성을 개선한다는 것은 구현한 코드를 다른 사람이 쉽게 이해하고 유지보수 할 수 있게 만드는 것을 의미한다. 문서화, 코드 컨벤션 준수 등의 노력이 필요하다.

다음과 같은 리팩터링 포인트가 있다.

  • 익명클래스를 람다 표현식으로 리팩터링
  • 람다 표현식을 메서드 참조로 리팩터링
  • 명령형 데이터 처리를 스트림으로 리팩터링

 

1.2 익명 클래스를 람다 표현식으로 리팩터링

하나의 추상메서드를 구현하는 익명 클래스는 람다 표현식으로 리팩터링이 가능하다.

Runnable r1 = new Runnable(){
    public void run(){
        System.out.println("hi!");
    }
}

Runnable r2 = () -> System.out.println("hi!");

하지만 람다 표현식으로 표현 불가능한 케이스도 존재한다.

  1. 익명 클래스와 람다식 내에서 사용되는 this, super는 다른 의미를 가진다. 익명클래스에서의 this는 자기자신을 가리키지만 람다식 내에서는 람다를 감싸는 클래스를 가리킨다.
  2. 익명 클래스는 감싸고 있는 클래스의 변수를 가릴 수 있지만 람다 표현식에서는 불가능하다.
int a = 10;

Runnable r1 = () -> {
    int a = 2;      //컴파일 에러 발생
  doSomething();
};

Runnable r2 = new Runnable(){
    public void run(){
        int a = 2;     //가능
        doSomething();
    }
};
  1. 익명 클래스를 람다 표현식으로 변경 시, 형식의 모호함이 발생한다. 예를 들어서 Runnable과 같은 시그니처를 가지는 함수형 인터페이스 Task가 있다고 해보자.
public static void doSomething(Runnable r){ r.run(); }
public static void doSomething(Task t){ r.execute(); }

위와 같은 경우, doSomething( () → 액션 )을 실행하는 경우, Runnable, Task의 팩토리 메서드 중, 어떤 메서드를 실행하는지 알 수 없다. 다음과 같이 명시적인 형변환을 통해서 오류를 해결한다.

doSomething((Task)() -> 액션);

 

 

1.3 람다 표현식을 메서드 참조로 리팩터링하기

람다 표현식 대신 메서드 참조를 이용하면 메서드명을 통해서 코드의 의도를 좀 더 명확하게 파악할 수 있다.

//람다 표현식
menu.stream().collect(groupingBy(dish -> {
    if(dish.getCalories() <= 400) return CaloricLevel.DIET;
    else if(dish.getCalories() <= 700) return CaloricLevel.NORMAL;
    else return CaloricLevel.FAT;
});

//메서드 참조
menu.stream().collect(groupingBy(Dish::getCaloricLevel));

다만 Dish클래스에 위의 선별 작업을 통한 CaloricLevel을 return 하는 메서드 선언이 필요하다.

자주 사용하는 sum, maximum같은 리듀싱 연산은 메서드 참조와 함께 사용할 수 있는 내장 헬퍼 메서드를 제공한다. 저수준의 리듀싱 연산을 람다 표현식과 조합하는 것보다 Collectors API를 사용하면 수식이 아닌 함수명으로 표현되기 때문에 코드의 의도가 더 명확하게 파악된다.

//람다 + 리듀스 연산
menu.stream().map(Dish::getCalories).reduce(0, (c1, c2) -> c1+c2);

//Collectors API
menu.stream().collect(summingInt(Dish::getCalories));

 

 

1.4 명령형 데이터 처리를 스트림으로 리팩터링하기

이론적으로 기존 반복자 대신 스트림 API로 컬렉션을 처리하는 것의 장점은 명확하다.

  • 스트림 API는 처리하고자하는 액션들의 파이프라이닝을 통해서 의도를 더 명확하게 표현한다.
  • 쇼트서킷(&&, || 같이 앞 조건식의 결과에 따라 뒤 조건식의 실행여부를 결정하는 논리연산자)과 lazy evaluation을 통해서 처리를 최적화한다.
  • 멀티코어 아키텍처를 활용한 병렬처리를 쉽게 할 수 있도록 지원한다.
  • 하지만 컬렉션에 대한 처리를 모두 스트림 API를 사용해서 처리하도록 리팩터링 하려면 기존의 반복자를 이용한 처리 프로세스를 정확히 숙지해야하고, 잘 모르고 사용하는 스트림 API는 오히려 실수에 의해서 성능저하를 초래할 가능성이 있다. (참고 : https://okky.kr/article/329818)

 

 

1.5 코드 유연성 개선

동작 파라미터화에 동작으로 다양한 람다 표현식을 넘겨줌으로써 변화하는 요구사항에 대응이 가능하다. 람다 표현식을 사용하려면 함수형 인터페이스 구현이 선행되어야 한다. 조건부 연기 실행, 실행 어라운드 두 가지 자주 사용하는 패턴을 통한 람다 표현식 리팩터링이 가능하다.

  • 조건부 연기 실행
  • 실제 작업을 처리하는 코드 내부에 제어 흐름문이 혼재되어있는 코드가 많다. 다음은 보안 검사나 로깅 관련 코드에서 자주 보이는 형태이다.
if(logger.isLoggable(Log.FINER)){ // logger의 상태 확인
    logger.finer("Problem: " + generateDiagnostic());
}

위와 같은 코드는 두 가지 문제점이 있다.

  1. logger의 상태가 isLoggable이라는 메서드에 의해서 클라이언트 코드에 노출 (캡슐화 위반)
  2. 메세지 로깅할 때마다 logger 객체의 상태를 매번 체크Supplier를 인수로 갖는 오버로드된 log메서드를 제공함으로써 if문을 통해서 logger의 상태를 매번 확인할 필요가 없고 클라이언트 코드에 상태에 대한 정보를 노출하지도 않는다.
  3. 특정 조건(logger 로깅수준이 FINER인 경우)에서만 로그를 생성하도록 메시지 생성 과정을 연기할 수 있어야하고, 이는 람다를 사용함으로써 쉽게 해결 가능하다.
// 서버 코드
public void log(Level level, Supplier<String> msgSupplier){
    if(logger.isLogabble(level)){
        log(level, msgSupplier.get()); // 람다 실행
    }
}

// 클라이언트 코드
logger.log(Level.FINER, () -> "Problem: " + generateDiagnostic());
  • 실행 어라운드해당 예제에서는 파일을 열고 닫을때 같은 로직을 사용했지만 람다를 이용해서 다양한 방식으로 파일을 처리할 수 있도록 파라미터화 했다.
  • 3장에서 잠깐 나왔던 내용으로 매번 같은 준비, 종료 과정을 반복적으로 수행하는 경우에 이를 람다로 변환하고 재사용함으로써 코드 중복을 줄일 수 있다.
// 함수형 인터페이스
public interface BufferedReaderProcess {
    String process(BufferedReader b) throws IOException;
}

public static String processFile(BufferedReaderProcessor p) throws IOException {
    //공통적인 파일 열고 닫는 로직 수행
    //인수로 받은 파라미터화된 p를 통해서 동작 수행 
}

String oneLine = processFile((BufferedReader b) -> b.readLine());
String twoLines = processFile((BufferedReader b) -> b.readLine() + b.readLine());

 


2. 람다로 객체지향 디자인 패턴 리팩터링하기

2.1 전략 패턴

전략패턴은 Strategy 인터페이스를 두고 런타임에 적절한 알고리즘(인터페이스 구현)을 선택하는 패턴이다. 예시로 특정 문자열에 대한 검증을 하는 로직을 처리할 때, ValidationStrategy 인터페이스를 두고 여러개의 validation 전략을 추가할 수 있다.

public interface ValidationStrategy{
    boolean execute(String s);
}

// 전략1
public class IsAllLowerCase implements ValidationStrategy{
    public boolean execute(String s){
        return s.matches("[a-z]+");
    }
}

// 전략2
public class IsNumeric implements ValidationStrategy{
    public boolean execute(String s){
        return s.matches("\\d+");
    }
}

위 예시에서 볼 수 있듯이, ValidationStrategy는 함수형 인터페이스이고 Predicate<String>과 같은 함수 디스크립터를 가지고 있다. 즉 ValidationStrategy을 클래스변수로 가지는 Validator 클래스가 있다고 했을 때, 전략클래스를 추가하지 않고 아래와 같이 람다를 직접 전달해서 전략패턴을 대체할 수 있다.

Validator numericValidator = new Validator((String s) -> s.matches("\\d+"));

Validator lowerCaseValidator = new Validator((String s) -> s.matches("[a-z]+"));

람다로 해당 패턴을 대체했을 때 아래 장점이 있다.

  • 신규 클래스 생성 필요없이 코드가 간결해짐
  • 람다 표현식 자체가 코드 조각(전략)을 캡슐화함

 

2.2 템플릿 메서드

템플릿 메서드 패턴은 작업을 처리하는 일부분을 서브 클래스로 캡슐화해서 전체 구조는 바꾸지 않고 일부만 수정할 수 있도록 유연함을 제공해야 할 때 사용한다.
예시로 JdbcTemplate같이 DB연결, 커넥션 생성 등 전체적인 프로세스는 동일하면서 부분적(ex. 쿼리)으로는 다른 구문으로 구성된 메서드의 코드 중복을 최소화 할 때 유용하다.

abstract class OnlineBanking{
    public void processCustomer(int id){
        Customer c = Database.getCustomerWithId(id);
        bankingProcess(c);
    }

    abstract void bankingProcess(Customer c);
}

위와 같이 추상 메서드로 표현한 bankingProcess 메서드를 아래와 같이 람다 표현식으로 수정할 수 있다. 아래와 같이 수정함으로써 OnlineBanking 클래스를 상속받지 않고 원하는 처리를 할 수 있다.

public void processCustomer(int id, Consumer<Customer> bankingProcess){
    Customer c = Database.getCustomerWithId(id);
    bankingProcess.accept(c);
}

 

 

2.3 옵저버

특정 이벤트 발생 시, subject(publisher)가 다수의 observer(subscriber)객체에 자동으로 알림을 보내야 할 때 사용하는 패턴이다. subject가 observer 인터페이스를 참조하고 이를 알림을 보낼 객체리스트로 관리하도록 한다.

interface Observer{
    void notify(String tweet);
}

class concreteObserver implements Observer{
    void notify(String tweet){
        System.out.println(tweet);
    }
}

class Subject{
    private final List<Observer> observers = new ArrayList<>();
    void registerObserver(Observer o){
        this.observers.add(o);
    }
    void notifyObservers(String tweet){
        observers.forEach(o -> o.notify(tweet);
    }
}

public static void main(String[] args){
    Subject subject = new Subject();
    subject.registerObserver(new concreteObserver());
    subject.notifyObservers("noti!");
}

위와 같은 구조에서 Subject는 observer 리스트를 관리하고 등록된 Observer객체는 지속적으로 Subject객체의 상태를 볼 필요 없이 특정 이벤트의 발생을 notify() 메서드를 호출받음으로써 알 수 있다.

람다 표현식을 사용함으로써 Observer를 명시적으로 인스턴스화하지 않고 처리할 수 있지만 무조건 람다로 표현해야 좋은 것은 아니다. Observer 객체가 상태를 가지고 여러개의 메서드를 정의해야한다고 하면 기존의 클래스로 구현하는 방식이 더 좋다.

subject.registerObserver((String tweet) -> {
    // todo
});

 

 

2.4 의무 체인(chain of responsibility)

요청을 보내는 쪽과 처리하는 쪽을 분리하고 특정 책임에 대한 처리 주체를 하나의 객체에 국한시키지 않고 여러 객체의 체인을 통해서 처리할 수 있도록 하는 패턴이다.

클라이언트는 구체 작업처리객체를 모르고 작업처리객체 내부에는 다음 작업처리객체로 결과를 전달시킬수있도록 객체정보를 필드에 포함한다.

public abstract class ProcessingObject<T> {
    protected ProcessingObject<T> successor; // 다음 작업을 처리할 객체

    public void setSucessor(ProcessingObject<T> successor){
        this.successor = successor;
    }

    public T handle(T input){
        T r = handleWork(input);
        if(successor != null){
            return succesor.handle(r);
        }
        return r; // 추가 체인이 없으면 결과값 반환
    }

    abstract protected T handleWork(T input); // 작업처리객체의 작업 추상화
}

// 사용예시
public static void main(String[] args){
    ProcessingObject<String> process1 = new ConcreteProcessing1(); // ProcessingObejct 상속
    ProcessingObject<String> process2 = new ConcreteProcessing2(); // ProcessingObejct 상속

    process1.setSucessor(process2);
    String result = p1.handle("target string");
}

위 구조는 자바8의 함수형 인터페이스 UnaryOperator를 이용하면 거의 동일한 구조로 구현이 가능하다. UnaryOperator는 Type T 인자를 하나 받아서 처리결과로 T를 반환하고, andThen() 메서드를 통해서 다수의 UnaryOperator를 체이닝 시킬 수 있다.

UnaryOperator<String> process1 = (String text) -> dosomething1...;
UnaryOperator<String> process2 = (String text) -> dosomething2...;

Functon<String, String> pipeline = process1.andThen(process2);

String result = pipeline.apply("target string");

 

 

2.5 팩토리

인스턴스화 로직을 클라이언트에 노출시키지 않고 객체생성할때 해당 패턴을 사용한다. 특정 객체의 인스턴스 생성 시, 생성자와 관련 설정을 외부로 노출시키지 않음으로써 클라이언트는 단순하고 실수없이 원하는 인스턴스를 얻을 수 있게된다.

public class ProductFactory{
    public static Product createProduct(String name){
        switch(name){
            case "loan" : return new Loan(); // Product 구현체
            case "stock" : return new Stock(); // Product 구현체
        }
    }
}

생성자를 메서드 참조로 접근할 수 있기 때문에 name(key)별 Supplier<Product>를 가지는 map을 만들어서 관리함으로써 createProduct메서드를 case문 분기 없이 깔끔하게 관리할 수 있다.

final static Map<String, Supplier<Product>> map = new HashMap<>();
static{
    map.put("loan", Loan::new);
    map.put("stock", Stock::new);
}

public static Product createProduct(String name){
    Supplier<Product> p = map.get(name);
    return p.get();
}

다만 위의 방식은 Product의 생성자로 여러 인수를 받는 상황이라면 위와 같이 Supplier 함수형 인터페이스로 해결이 불가하다.

 


3. 람다 테스팅

다음과 같은 경우에 moveRightBy()는 public이기 때문에 문제 없이 작동한다. 하지만 람다의 경우는 익명함수이므로 테스트 코드에서 함수명으로 호출을 하는 것이 불가하다. 이를 어떻게 해결하는가?

class Point {
    private final int x;
    private final int y;

    Point(int x, int y){
        this.x = x;
        this.y = y;
    }

    public int getX(){ return x; }
    public int getY(){ return y; }
    public Point moveRightBy(int x){
        return new Point(this.x + x, this.y);
    }
}
@Test
public void testMoveRightBy() throws Exception{
    Point p1 = new Point(5,5);
    Point p2 = p1.moveRightBy(10);
    assertEquals(15, p2.getX());
    assertEquals(5, p2.getY());
}

 

 

3.1 보이는 람다 표현식의 동작 테스팅

익명인 람다를 테스트를 하기 위해서는 람다가 함수형 인터페이스 인스턴스를 생성하기 때문에 Point클래스의 필드에 이 함수형 인터페이스 인스턴스를 저장하고 해당 인스턴스의 동작으로 테스트를 할 수 있다.

public class Point {
    public final static Comparator<Point> compareByXAndThenY =
            comparing(Point::getX).thenComparing(Point::getY);
  ...
}

위와 같이 compareByXAndThenY 정적 클래스 필드로 람다 표현식을 저장했고 해당 인스턴스의 동작으로 아래와 같이 테스트 할 수 있다.

@Test
public void testComparingTwoPoints() throws Exception{
    Point p1 = new Point(10, 15);
    Point p2 = new Point(10, 20);

    int result = Point.compareByXAndThenY.compare(p1, p2);
    System.out.println("result: " + result);
    assertTrue(result < 0);
}

 

 

3.2 람다를 사용하는 메서드의 동작에 집중하라

람다의 목표는 정해진 동작을 다른 메서드에서 사용할 수 있도록 하나의 조각을 캡슐화하는 것이다. 람다 표현식을 사용하는 메서드의 동작을 테스팅 함으로써 람다 표현식을 검증할 수 있다.

public static List<Point> moveAllPointsRightBy(List<Point> points, int x){
    return points.stream().map(p -> new Point(p.getX() + x, p.getY()))
                             .collect(toList());
}

위의 코드에서 사용되는 람다 표현식을 직접적으로 테스트 코드에 반영하는 것이 아니라 구현된 메서드 테스트를 통해서 람다를 테스팅 한다.

@Test
public void testMoveAllPointsRightBy() throws Exception{
    List<Point> points = Arrays.asList(new Point(5,5), new Point(10,5));
    List<Point> expectedPoints = Arrays.asList(new Point(15,5), new Point(20,5));

    List<Point> movedPoints = Point.moveAllPointsRightBy(points, 10);
    assertEquals(expectedPoints, movedPoints);
}

assertEquals를 통해서 테스트를 진행하기 위해서 Point클래스 내부에 eqauls()메서드를 오버라이드 해서 정의한다.

@Override
public boolean equals(Object obj) {
    if((obj instanceof Point) && obj != null){
        return (this.getX() == ((Point)obj).getX() && this.getY() == ((Point)obj).getY());
    }else{
        return false;
    }
}

 

 

3.3 복잡한 람다는 개별 메서드로 분리하기

테스트 코드에서 람다 표현식(익명)을 참조할 수 없기 때문에, 복잡한 람다 표현식은 테스트하기가 쉽지 않다. 이런 경우에는 8장에서 정리했던 내용처럼 람다 표현식을 메서드 참조로 바꾸는 것이 한 가지 방법이다.

 

 

3.4 고차원 함수 테스팅

여기서 고차원 함수를 함수를 인수로 받거나 다른 함수를 반환하는 메서드라고 정의한다.

  • 메서드가 람다를 인수로 받는 경우
    다양한 Predicate으로 구현된 filter메서드를 테스트하는 경우, 다음과 같이 새로 람다 표현식을 정의해서 테스트를 진행할 수 있다.
@Test
public void testFilter() throws Exception{
    List<Integer> numbers = Arrays.asList(1,2,3,4);
    List<Integer> even = filter(numbers, i -> i%2 ==0);
    List<Integer> smallerThanThree = filter(numbers, i -> i< 3);
    assertEquals(Arrays.asList(2,4), even);
    assertEquals(Arrays.asList(1,2), smallerThanThree);
}
  • 메서드가 다른 함수를 반환하는 경우
  • 위의 3.1 보이는 람다 표현식의 동작 테스팅에서 봤듯이, 함수형 인터페이스의 인스턴스로 간주하고 함수의 동작을 테스트하면 된다.

4. 디버깅

  • 스택 트레이스
  • 로깅

4.1 람다와 스택 트레이스

List<Point> points = Arrays.asList(new Point(12,2), null);
points.stream().map(p -> p.getX()).forEach(System.out::println);

//Exception in thread "main" java.lang.NullPointerException
//at me.heesu.asyncwebapp.test.lambda$main$0(test.java:16)

getX()를 호출하는 부분에서 넘겨받은 p가 null이기 때문에 NPE가 발생했다. 하지만 스택 트레이스에 문제가 되는 메서드명은 보이지 않는다.

메서드 참조를 사용하는 클래스와 같은 곳에 선언되어 있는 메서드를 참조할 경우에만 스택 트레이스에서 메서드 명을 확인할 수 있다.

결국 람다 표현식이 복잡할수록 스택 트레이스를 통해서 문제가 발생한 부분을 캐치하는 것이 어려워진다는 것을 인지하고 적재적소에 람다를 사용할 수 있도록 해야한다.

 

 

4.2 정보 로깅

스트림 파이프라인 연산을 디버깅 한다고 했을때, 최종 연산이 호출되는 시점에는 이미 전체 스트림 파이프라인이 소비되기 때문에 파이프라인에 적용된 중간 연산(ex. map, filter, limit)의 결과를 로깅하는 것이 어렵다.

이 때문에 스트림의 각 파이프라인 요소를 소비한 것처럼 동작하는 peek라는 스트림 연산을 이용해서 로깅 처리를 할 수 있다.

numbers.stream()
             .peek(x -> System.out.println("from stream: " + x))
             .map(x -> x + 17)
       .peek(x -> System.out.println("after map: " + x))
             .filter(x -> x%2 ==0)
           .peek(x -> System.out.println("after filter: " + x))
           .collect(toList());

0.

자바1.0 부터 제공됐던 Date 클래스는 특정 시점을 날짜가 아닌 밀리초 단위로 표현하고 직관적이지 않은 사용법에 의해서 사용자에 의해서 문제제기가 됐었고, 이를 해결하기 위한 자바1.1 버전의 Calendar객체도 비슷한 설계상의 문제를 가지고 있었다.

  • 불변 객체가 아니기 때문에 특정 Date객체의 값 변환이 다른 영역에서 영향이 있을 수 있다
  • 월 지정 인덱스가 0부터 시작으로 헷갈리고 Calendar와 Date의 요일 상수값이 다르다. (Calendar = 1, Date = 0)
  • 시간대(TimeZone)이 틀려도 컴파일 시점에 확인이 불가하다.
  • Date, Calendar가 해주는 공통적인 작업이 있기 때문에 두 객체가 혼용되어서 사용된다.

 


1. LocalDate, LocalTime, Instant, Duration, Period 클래스

해당 시간 관련 클래스는 모두 불변 객체이므로 함수형 프로그래밍, 스레드 안정성과 도메인 모델의 일관성을 유지하기에 좋다.

 

 

1.1 LocalDate, LocalTime

// LocalDate ex.
LocalDate date = LocalDate.of(2023, 1, 1); // 정적 팩토리 메서드
LocalDate nowDate = LocalDate.now();
int year = date.getYear();
Month month = date.getMonth();
int day = date.getDayOfMonth();
int len = date.lengthOfMonth(); // 특정 월의 일 수
boolean leap = date.isLeapYear(); // 윤년 판단
  • TemporalField 를 이용해서 LocalDate 값 읽기
    • TemporalField는 시간객체에서 어떤 필드 값에 접근할지 정의하는 인터페이스
    • int month = date.get(ChronoField.MONTH_OF_YEAR)
  • 문자열로 LocalDate, LocalTime 인스턴스 생성
    • parse() 메서드를 이용해서 내부 formatter를 사용한 문자열 파싱
    • LocalDate date = LocalDate.parse(”2017-09-21”)
    • LocalTime tiem = LocalTime.parse(”13:45:20”)

 

 

1.2 날짜와 시간 조합

LocalDateTime은 LocalDate와 LocalTime을 모두 가지는 복합 클래스이다.

// LocalDateTime ex
LocalDateTime dt1 = LocalDateTime.of(2022, Month.SEPTEMBER, 21, 13, 45, 20); 
LocalDateTime dt2 = LocalDateTime.of(date, time); // LocalDate + LocalTime
LocalDateTime dt3 = date.atTime(13, 45, 20;); // LocalDate에 time정보 추가

LocalDateTime 인스턴스에서 LocalDate나 LocalTime 인스턴스를 추출 할 수 있다.

  • LocalDate date = dt1.toLocalDate();

 

 

1.3 Instant 클래스 : 기계의 날짜와 시간

주, 날짜, 시분초가 아닌 연속된 시간에서 특정 지점을 표현하는 기계적인 관점에서의 시간표현을 Instant 클래스를 통해서 정의할 수 있다. 특점 시점 (Unix epoch tiem)을 기준으로 특정 지점까지의 시간을 초로 표현한다.

// Instant ex
Instant.ofEpochSecond(3);
Instant.ofEpochSecond(2, 1000000000); // 두번째 인자를 통해서 나노초 표현까지 가능

// error
Instant.now().get(ChronoFiield.DAY_OF_MONTH); // Instant는 사람이 읽을 수 있는 시간정보를 제공하지 않음

 

 

1.4 Duration과 Period

이전까지 본 클래스는 모두 Temporal 인터페이스를 구현하고 이는 특정시간을 가지는 객체 값을 어떻게 읽고 조작할지 정의한다. Duration과 Period는 이 시간 객체 간의 연산처리를 할 수 있다.

Instant와 그 외의 클래스는 표현방식이 다르므로 두 인스턴스를 혼합할 수는 없다. 또 Duration은 초, 나노초 단위로 시간을 표현하기 때문에 LocalDate는 사용 할 수 없다.

// Duration, Period ex
Duration threeMin = Duration.ofMinutes(3);

Period tenDays = Period.ofDays(10);
  • Duration
    • between() 메서드를 통해서 두 시점 사이의 지속시간을 구할 수 있다
      • Duration d1 = Duration.between(t1, t2);
  • Period
    • 년,월,일로 시간을 표현하는 경우 Period를 사용
      • Period tenDays = Period.between(LocalDate.of(2017,9,11), LocalDate.of(2017,9,21));


 

2. 날짜 조정, 파싱, 포매팅

기존 날짜 정보를 특정 시간으로 수정하거나, 지정된 시간을 추가하거나 뺄 수 있다.

  • get~ / with~
    • Temporal객체의 필드값을 읽거나 수정할 수 있음 → 새로운 날짜객체 반환
  • plus~ / minus~
    • Temporal객체의 필드값을 특정 시점만큼 앞뒤로 이동시킬 수 있음
LocalDate dt1 = LocalDate.of(2017,9,21);

LocalDate dt2 = dt1.withYear(2011); // 2011-09-21
LocalDate dt3 = dt2.plusYears(6); // 2017-09-21

 

 

2.1 TemporalAdjusters 사용

돌아오는 주 월요일, 이번 달 마지막날 등.. TemporalAdjusters의 정적 팩토리 메서드를 통해서 이미 정의된 날짜 등을 가져올 수 있고 해당 인터페이스를 구현해서 커스텀하게 사용할 수도 있다.

TemporalAdjusters 인터페이스의 구현은 Temporal 객체를 어떻게 다른 Temporal로 변환할지 정의하면 된다.

// TemporalAdjusters ex
LodalDate dt1 = LocalDate.of(2022,3,14);

LodcalDate dt2 = dt1.with(lastDayOfMonth());

 

 

2.2 날짜와 시간 객체 출력과 파싱

DateTimeFormatter를 이용해서 특정 시간/날짜 객체를 문자열을 통해서 만들 수 있고 날짜형식을 변환할 수 있다. parse()를 통해서 문자열을 시간정보로 변환하고, format()을 통해서 시간정보를 문자열로 변환한다.

// DateTimeFormatter ex
LocalDate dt1 = LocalDate.of(2022,3,14);
DateTimeFormatter fmt1 = DateTimeFormatter.ofPattern("dd/MM/yyyy"); // 패턴으로 생성
DateTimeFormatter fmt2 = DateTimeFormatter.ofPattern("yyyydd", Locale.KOREAN); // 특정 지역 locale 설정 가능

String formattedDate = dt1.format(fmt1); // 14/03/2022
  • DateTimeFormatterBuilder
    • 해당 빌더를 통해서 formatter의 세부적인 옵션을 정의해서 커스텀하게 사용할 수 있다.

 


3. 다양한 시간대와 캘린더 활용 방법

직전까지 봤던 클래스에서 시간대에 관련한 정보는 없었다. TimeZone, ZoneId 클래스를 통해서 서머타임 같은 복잡한 사항은 자동으로 처리가 가능하다.

 

 

3.1 시간대 사용하기

표준시간이 같은 지역을 묶어서 시간대(timeZone) 규칙 집합을 정의한다. 40개 정도의 시간대가 존재하고 아래와 같이 특정 시간대의 규정을 가져올 수 있다.

ZoneId romeZone = ZoneId.of(”Europe/Rome”);

ZoneId 객체를 얻은 다음, LocalDate, LocalDateTime, Instant 를 이용해서 지정한 시간대에 상대적인 시점을 ZonedDateTime으로 표현할 수 있다.

// ex
LocalDate dt1 = LocalDate.of(2022, 3, 14);
ZoneId romeZone = ZoneId.of(”Europe/Rome”);

ZonedDateTime zdt1 = dt1.atStartOfDay(romeZone);
ZonedDateTime zdt2 = dt1.atZone(romeZone);

추가로 기존 Date 클래스와의 호환을 생각한다면 Instant객체를 사용하는 것이 좋고, 기존/신규 API 간 동작에 도움이 되는 Date클래스의 toInstant(), from() 메서드를 활용할 수 있다.

※ 해당 내용은 토비의 스프링3을 보고 정리한 내용이다.

 

4.1. 사라진 SQLException

3장까지 JDBC API 사용 방식, 예외처리, 리소스 반납, connection 관리 등에 대한 반복적인 작업에 대한 롤을 JdbcTemplate에 위임했다. 하지만 최종 JdbcTemplate 적용 코드 기준으로 예외를 처리해주거나 상위 메서드로 던져주는 부분이 없다.

public void deleteAll(){
    this.jdbcTemplate.update("delete from users");
}

 

 

초난감 예외처리 (안좋은 예)

  1. 예외를 try/catch 블럭으로 잡고 아무런 처리해주지 않는 경우
    1. 시스템의 문제 (ex.메모리 누수)에 대해 별다른 처리없이 넘어가기 때문에 초기에 조치할 수 없음
    2. 마찬가지로 stacktrace만 로그로 찍거나 println으로 예외를 콘솔에 찍고 넘어가는 것도 나쁜 예
  2. 무책임한 예외 throws
    1. 메서드 시그니처에 throws Exception을 붙임으로써 예외에 대한 핸들링 없이 무조건 상위 메서드로 던지는 경우

 

 

예외의 종류와 특징

자바에서 throw를 통해서 발생시킬 수 있는 예외는 크게 3종류로 나눌 수 있다.

  1. Error (java.lang.Error)
    1. 시스템 레벨에서 발생하는 예외
    2. OOM 등 애플리케이션 레벨에서는 예외처리가 불가
  2. Checked Exception (java.lang.Exception)
    1. 체크 예외가 발생할 수 있는 메서드 호출 시 무조건 예외에 대한 처리를 해줘야함
    2. 애플리케이션 레벨에서의 예외조건, 상황 발생 시 처리가 강제되는 체크 예외를 발생
  3. Unchecked Exception (java.lang.RuntimeException)
    1. Exception 중에서 RuntimeException을 상속받은 예외
    2. 프로그램의 오류 발생 시, 발생되도록 의도된 예외
      ex) NullPointerException

 

 

예외처리 방법

  • 예외 복구
    • 예외를 파악하고 예외 때문에 불가능한 작업흐름을 가능한 작업흐름으로 안내하는 등, 문제를 해결
      • 읽으려는 파일이 없어서 IOException 발생 시, 대체할 수 있는 파일을 이용하도록 안내
      • 네트워크 이슈로 SQLException 발생 시, 일정 시간 이후에 동일 쿼리 재호출 처리
        → failover, retry
  • 예외처리 회피
    • 해당 메서드에서 예외 처리가 불가한 경우, 상위 메서드로 이를 위임
      • JdbcTemplate(템플릿-콜백) 에서는 콜백에서 익셉션이 발생하는 경우, 템플릿 레벨에서 이를 처리하도록 예외를 던짐
        예예외
  • 예외 전환
    • 예외에 대한 처리를 상위 메서드로 위임한다는 점에서 예외처리 회피와 비슷하나, 발생 예외 그대로를 던지지 않고 처리하기 적절한 예외로 변경해서 던짐
      • 만약 useres DB테이블에 동일 userId를 insert처리하다가 SQLException이 발생했다면, 이를 상위 메서드가 이해할 수 있는 DuplicatedUserIdException으로 전환
    • 전환한 예외에 원인 예외를 담아서 중첩으로 전달하는 것이 좋음
      • throw DuplicatedUserIdException(e);
      • throw DuplicatedUserIdException().initCause(e);

 

 

예외처리 전략

예외를 효과적으로 사용하고 예외처리 코드를 일관성있게 관리하는 전략을 이전에 봤던 예외 종류, 처리 방법을 기준으로 정리해본다.

  • 런타임 예외의 보편화
    어차피 애플리케이션에서 처리하지 못할 예외의 경우, 굳이 체크 예외로 발생시켜서 불필요한 예외처리코드를 남발하는 것 보다는 런타임 예외를 발생시켜서 시스템의 오류 축적을 미리 차단하고 이를 관리자에게 노티해주는게 중요
    • 자바 초창기 독립형 애플리케이션이 많았을때는 애플리케이션 레벨에서 처리가 불가능한 예외가 발생한다고 해도 최대한 애플리케이션 종료없이 이를 상황을 복구해야했음
    • 최근 자바 서버환경 기준으로는 여러 사용자가 동시에 요청을 보내고 이를 처리하는 형태이기 때문에 독립형 애플리케이션처럼 예외 발생에 대해서 사용자와 커뮤니케이션을 하며 예외를 복구하기 어려움
      • 예외가 발생할 수 있는 부분에 대해서 미리 파악하고 이를 방지
      • 예외 발생 시, 해당 요청을 빠르게 종료하고 관리자에게 이를 통보
  • 애플리케이션 예외
    애플리케이션 자체 로직에 의해 의도적으로 발생시키고 처리하는 예외
    • 특정 케이스에 대해서 애플리케이션에서 실패/에외 케이스로 판단을 한다고 할때 비즈니스적 의미를 가지는 체크 예외를 발생
      • 예외 상황에 대한 처리가 강제되므로, 비즈니스 예외 상황에 대해서 놓치지 않고 처리 가능
      • 서비스로직과 예외처리에 대한 로직을 분리할 수 있음

 

 

JdbcTemplate의 SQLException은 어디로 갔는가?

해당 챕터 서두에서 제기된 이슈는 JdbcTemplate을 적용한 코드에 SQLException에 대한 예외처리가 사라졌다는 점이다.

기본적으로 SQLException은 하기 문제들로 인해서 발생하고 이는 애플리케이션 레벨에서 복구가 불가능하다.

  • SQL 문법 오류
  • DB서버 다운 혹은 네트워크 이슈
  • DB커넥션풀 오류
  • JdbcTemplate은 위에서 얘기한 예외전략대로 애플리케이션 레벨에서 처리가 불가능한 예외에 대해서는 DataAccessException 런타임 예외로 포장해서 던지고 있기 때문에 SQLExcetion에 대한 예외 처리 구현을 강제하지 않는다

4.2. 예외 전환

예외 전환의 목적을 정리하면 아래와 같다.

  • 발생 예외를 런타임 예외로 포장해서 필요하지 않는 try/catch를 줄임
  • 로우레벨의 예외를 좀 더 의미있고 추상화된 예외로 포장

 

 

JDBC의 한계

JDBC는 DB를 이용해서 데이터를 핸들링하는 부분을 추상화된 API 형태로 제공하고, 각기 다른 종류의 DBMS는 이 추상화 API를 구현한 driver를 사용한다. 즉 JDBC API에 익숙해지면 DBMS에 상관없이 일관된 방식으로 개발이 가능하다.

하지만 현실적으로 DBMS를 자유롭게 바꿔서 서비스에 적용하는것에는 두 가지 어려움이 있다

  1. 비표준 SQL
    SQL 자체가 어느정도 표준화된 문법을 가지고 있지만 각 DBMS 별 제공하는 비표준 문법을 사용하는 경우, 해당 DAO는 특정 DBMS에 종속적이게 된다.
  2. 호환성 없는 SQLException의 DB에러 정보
    DB에서 발생할 수 있는 오류의 종류는 많으나 JDBC에서는 공통적으로 SQLException으로 예외를 만들고 원인에 대한 DB 에러코드는 DBMS 별로 호환되지 않는다.

 

 

DB 에러 코드 매핑을 통한 전환

위의 두 가지 문제는 JDBC를 사용하면서 DBMS를 유연하게 바꾸기 힘들게 하는 원인이 된다. 이를 해결하기 위해서 JDBC의 SQLException에 의존하지 않고 각 DB별 발생 에러코드를 참고해서 발생 원인을 해석하는 기능을 만들 수 있다.

Spring은 DataAccessException의 서브클래스로써 BadSqlGrammarException, DuplicatedKeyException 등 DB오류에 대한 세분화된 예외를 관리하고 있고 특정 DB별 에러코드와 스프링 예외의 매핑을 관리하고 있다.

 

 

DAO 인터페이스와 DataAccessException 계층구조

DataAccessException의 경우 JDBC 뿐만 아니라, 자바에서 데이터 액세스를 위한 JPA 등에도 사용된다. 즉 Spring에서는 데이터 액세스 기술의 종류와 상관없이 일관된 예외를 발생시키고 DataAccessException 계층구조를 이용해서 어떻게 기술에 독립적인 예외를 사용하게 할까?

 

  먼저 스프링에서는 여러 데이터액세스 기술을 일관된 방식으로 처리하기 위해서 DAO의 구현과 인터페이스를 분리한다.  인터페이스를 이용함으로써 아래의 장점을 얻을 수 있다.

  • 데이터 액세스 로직을 타 서비스로직과 분리
  • 분리된 DAO 인터페이스를 통해서 전략패턴을 적용해 구현 방법을 변경해서 사용 가능

  인터페이스를 사용함으로써 추상화가 가능하지만 데이터액세스 기술 별 API에서 각 기술에 종속된 예외를 던지기 때문에 인터페이스의 시그니처에서는 기술별로 다른 예외를 던지는 표현이 필요하다. (ex. JDBC의 경우, throws SQLException 이 붙는다)

  이렇게 인터페이스가 정의된다면 해당 인터페이스는 특정 데이터액세스 기술에 종속적이게 되기 때문에 하나의 인터페이스로 통합해서 DAO를 사용하려면 공통적인 예외를 발생시켜야한다. 공통적인 예외는 최상위 Exception을 던져도 되지만 이는 아무런 정보도 명시해줄수 없기 때문에 결국 DB별로 DAO 인터페이스를 구분해야하는 불상사를 막기 위해서 다양한 데이터 액세스 기술 사용시 발생하는 예외를 추상화해서 DataAccessException 계층구조를 만들었다.

  이를 통해서 인터페이스는 일관되게 가져갈 수 있고, 좀 더 세부적인 익셉션정보를 알고 싶으면 DBMS별 오류코드와 매핑된 스프링의 예외를 가져올 수 있다. 또한 특정 케이스에 대해서 DBMS 별 공통로직을 가져가고 싶은 경우, 각 DBMS에서 발생시키는 예외를 통합하는 상위 익셉션을 정의하고 이에 대한 처리를 공통 로직으로 관리할 수 있다.

 

  결국 정리하자면, 결국 인터페이스의 사용, DataAccessException 예외 추상화를 이용해서 데이터 액세스 기술과 구현방법에 독립적인 DAO를 만들 수 있다.

 

 

기술에 독립적인 UserDao 만들기

여러 종류의 데이터액세스 기술에 독립적인 DAO를 만들기 위해서 기존에 사용했던 구현 객체 UserDao를 인터페이스로 변경하는 작업이 필요하다.

각 기술 별로 UserDaoJdbc, UserDaoJpa 등으로 구분된 구현 객체를 생성해서 관리할 수 있다.

※ 해당 내용은 토비의 스프링3을 보고 정리한 내용이다.

3.1. UserDao 개선

  1장에서는 DAO코드에 DI를 적용하면서 서로 관심이 다른 코드를 분리하고, 확장과 변경에 대응할 수 있는 설계구조로 변경했다. 객체지향 설계의 핵심 원칙 중 하나인 개방폐쇄원칙(OCP)는 코드에서 어떤 부분은 변경을 통해서 기능 확장을 목표로 하고, 또 어떤 코드는 변경되지 않는 부분이 있음을 설명한다.

  템플릿은 이때 변경이 거의 일어나지 않고 특정한 패턴을 가지는 코드를 독립적으로 분리시켜서 효과적으로 사용하는 기법이다.

 

예외처리 기능을 갖춘 DAO

  DB 커넥션이라는 제한적인 리소스를 공유해서 사용하는 서버에서 동작하는 JDBC 코드는 반드시 예외처리가 필요하다. 리소스 사용 도중 비정상적으로 예외가 발생한 경우 해당 리소스를 반환받아야하기 때문이다. 보통 서버에서는 제한된 개수의 DB 커넥션을 관리하고 예외발생으로 인해서 커넥션 생성시마다 새로 리소스를 할당하면 이후에 서버 자체가 죽을 수도 있다.

  그래서 JDBC코드에서는 try/catch 블럭을 통해서 예외처리를 해주도록 한다. 아래의 코드에서 커넥션을 받아오는 부분, PreparedStatement, ResultSet에 대한 부분을 try블럭 안에서 처리해주고 최종적으로 finally구문에서 리소스를 반환해야 하는 인스턴스의 close() 메서드를 호출한다.

 

3.2. 변하는 것과 변하지 않는 것

  직전에는 try/catch 블럭을 사용해서 더 완성도 높은 UserDao를 개선했다. 하지만 예외처리 부분을 보면 try/catch 블럭이 이중으로 나오고 모든 메서드마다 반복해서 나오게 된다. 반복되는 코드가 많아진다면 소스를 수정하거나 실수가 발생할 확률이 높아진다.

그렇다면 반복되지만 변하지 않는 코드를 어떻게 관리하는것이 효율적인가?

 

분리와 재사용을 위한 디자인패턴 적용

  UserDao를 개선하기 위해서 변하는 코드와 변하지 않는 코드를 하나의 DAO클래스에서 분리하는 것이 필요하다. 아래와 같은 코드가 있다고 했을 때, 다음과 같이 분리할 수 있다. 실제 쿼리를 통해서 prepareStatement를 생성하는 부분 이외에는 변경되지 않고, 반복되는 로직이다.

그렇다면 위의 코드에서 변하는 부분 / 변하지 않는 부분을 어떻게 분리할 수 있을까?

 

1. 메서드 추출
  변하는 부분을 독립된 메서드로 분리한다. 하지만 정작 재사용되어야 하는 부분은 변하지 않는 부분, 즉 커넥션을 가져오는 부분이기 때문에 이렇게 분리가 되면 별 이득이 없다.

 

2. 템플릿 메서드 패턴 적용
  템플릿 메서드 패턴은 상속을 통해서 기능을 확장해서 사용하는 디자인 패턴이다. 변하지 않는 슈퍼크래스를 두고 변하는 부분은 추상메서드로 정의해서 서브클래스에서 오버라이드해서 사용하도록 한다.


  위의 예시에서는 prepareStatement를 만드는 부분을 추상메서드로 정의해서 사용하도록 한다. 그리고 추상메서드를 가지고 있는 UserDao는 당연히 추상클래스로 정의되어야 될 것이다.

  하지만 이렇게 구현했을 때 DAO로직마다 상속을 통해서 새로운 서브클래스를 만들어야한다는 문제가 있다. 만약 statement 종류가 4개라면 서브클래스 개수도 4개가 나와야하는 구조이므로 좋지 않다. 또한 이렇게 상속을 통해서 설계했을경우 컴파일 타임에 이미 관계가 지정되어버리기 때문에 클래스 관계에 대한 유연성이 떨어진다.

 

3. 전략 패턴 적용

  개방폐쇄원칙(OCP)을 잘 지키면서 위의 템플릿 메서드 패턴보다 유연하고 확장성이 뛰어난 패턴이 전략패턴이다. 이는 오브젝트를 아예 분리하고 클래스 레벨에서는 인터페이스를 통해서만 의존하도록 만든다.

 

  deleteAll 메서드를 수행한다고 했을때, 변하지 않는 부분은 아래의 contextMethod 메서드에 정의되고 특정 확장 기능은 Strategy 인터페이스를 통해 외부의 독립된 전략 클래스에 위임된다.

  최종적으로 위의 Strategy 인터페이스에는 PrepareStatement를 생성하는 메서드가 정의되어 있고 이를 구현하는 클래스별로 변하는 부분(statement를 만드는 부분) 을 다르게 구현할 수 있다. 그리고 실제 클래스에 구현된 PrepareStatement 를 생성하는 메서드를 contextMethod 메서드에서 사용함으로써 전략패턴 구현이 완료된다.

 

  전략패턴은 필요에 따라서 컨텍스트는 그대로 유지(OCP 폐쇄 원칙)하면서 전략을 바꿔서 사용(OCP개방)할 수 있다. 전략패턴을 적용한 코드는 다음과 같다.

 

  전략패턴을 적용했지만 문제가 있다. 위의 코드에서는 contextMethod 메서드에 구체적인 전략 클래스인 DeleteAllStatement를 사용하도록 고정되어 있기 때문에, 사용하는 부분에서 인터페이스가 아닌 특정 구현 클래스를 알고 있다는 건 OCP에 잘 맞는다고 볼 수 없다. 그렇다면 해당 문제는 어떻게 해결하는가?

 

DI 적용을 위한 클라이언트 / 컨텍스트 분리 (전략 패턴 개선)

  전략 패턴에서 Context가 어떤 전략을 사용하게 할 것인가는 Context 앞단의 클라이언트에서 결정하는 것이 일반적이다. 즉 전체적인 프로세스는 다음과 같다.

  즉 위에서 try, catch블럭 내부에서 정의되었던 strategy = new DeleteAllStatement(); 해당 코드는 클라이언트 쪽에서 호출해서 Context로 전달해줘야한다. 그리고 Context에서는 클라이언트에서 전달해준 전략 구체 클래스를 파라미터로 받아서 특정 Statement를 생성하는 작업을 수행한다. 최종적인 Context 코드와 클라이언트 쪽 코드는 다음과 같다.

  • Context 코드

 

  • 클라이언트 코드

  이제 구조적으로 전략 패턴의 구현이 완료되었고, Context가 사용할 전략을 정해서 전달한다는 면에서 DI구조로 이해할 수 있다. 이를 기반으로 UserDao의 본격적인 개선 작업을 수행할 수 있다.

 

3.3. JDBC 전략 패턴의 최적화

  위에서 한 작업은 deleteAll 메서드에 있는 변하는 부분과 변하지 않는 부분을 전략 패턴을 이용해서 분리했다. 해당 구조에 새로운 전략 구체 클래스를 추가해보도록 한다.

 

전략 클래스의 추가 정보

  신규로 add 메서드를 추가한다고 할 때 전략 패턴을 적용해보려면 먼저 add 메서드 내에서 변하는 부분과 변하지 않는 부분을 분리해야 한다. PreparedStatement를 만들기 위해서 전략 인터페이스를 구현하는 구체 클래스 AddStatement를 구현하도록 한다. 하지만deleteAll 메서드와는 다르게 add 메서드는 DB에 넘겨줘야하는 INSERT 구문의 쿼리 파라미터가 있기 때문에 이를 같이 넘겨줘야한다.

deleteAll 메서드에서는 전략 구체 클래스를 생성할 때 별다른 생성자 없이 생성했지만 add 메서드에서는 필요한 정보를 담고있는 User인스턴스를 파라미터로 넘기도록 한다. 그리고 구체클래스의 생성자에서 필요한 정보를 가져와서 사용하면 된다.

 

  이렇게 전략패턴을 통해서 DB커넥션을 가져오고 리소스를 반환하는 부분 등은 코드를 반복하지 않고 add, delete등 과 같은 구체적인 전략에 대한 코드만 변경함으로써 DAO 코드를 간결하게 관리할 수 있고, 추가되는 전략은 클라이언트에서 호출할 전략 구체 클래스 생성만 하면 된다.

 

전략과 클라이언트의 동거

현재까지의 작업만으로도 많은 문제점을 해결하고 코드를 간결하게 했지만 추가적으로 개선할 부분이 있는가?

  • DAO 메서드마다 새로운 전략 구체 클래스를 만들어야 한다는 점 (클래스 파일이 많아진다.)
  • add 메서드같이 전략 구체 클래스에 전달해야하는 부가적인 정보가 있는 경우, 이를 전달받는 생성자와 저장해둘 인스턴스 변수를 만들어야 한다는 점
  • 위의 두 가지 개선 포인트를 해결할 수 있는 방법을 찾는다.

먼저 클래스 파일이 많아지는 문제를 어떻게 해결할 수 있는 방법을 확인한다.

 

1. 로컬 클래스

  제일 간단한 방법으로, 전략 구체화 클래스를 독립된 파일로 관리하지 않고 UserDao클래스의 내부 클래스로 정의하는 것이다. 어차피 deleteAll, add 메서드가 UserDao 밖에서 사용되지 않는 메서드임을 감안한다면 아래 방법과 같이 메서드 내에서 정의되는 로컬 클래스로 정의하는 것도 방법이 될 수 있겠다.

  로컬 클래스는 로컬 변수와 비슷하게 해당 클래스가 선언된 메서드 내부(add)에서만 사용할 수 있다. 또한 내부 메서드는 자신이 정의된 메서드의 로컬 변수에 직접 접근할 수 있기 때문에 이전에 생성자를 통해서 받았던 User인스턴스에 대한 정보에 직접 접근할 수 있다. (다만 final로 선언해줘야한다.)

  이렇게 함으로써 클래스 개수를 하나 줄일 수 있고 로컬 변수를 인자로 받지않고 바로 사용할 수 있다.

 

2. 익명 내부 클래스

  위와 같이 로컬 클래스로 정의해서 add 메서드에 붙일 수도 있지만 어차피 AddStatement 전략 구체 클래스를 add 메서드를 호출할 용도로만 사용한다면 더 간결하게 클래스 이름도 제거할 수 있다.

  익명 내부 클래스는 다음과 같이 구현한다. new 인터페이스이름() { 클래스 본문 } ; deleteAll() 메서드도 동일한 이유에서 다음과 같이 익명 내부 클래스로 구현할 수 있다.

 

3.4. 컨텍스트와 DI

JdbcContext의 분리

위의 익명 내부 클래스까지 적용한 버전의 코드를 전략 패턴의 구조로 보면 아래와 같다.

  • UserDao메서드 ⇒ 클라이언트
  • 익명내부 클래스 ⇒ 개별 전략
  • jdbcContextWithStatementStrategy 메서드 ⇒ 컨텍스트
  • 여기서 컨텍스트는 다른 DAO에서도 재사용이 가능한 부분이기 때문에 이를 UserDao 에서 독립시킬 수 있다. 다음과 같은 프로세스로 분리하도록 한다.
  • 클래스 분리
    DataSource 를 DI 받는 부분과, 이전에 UserDao에 구현되어 있던 컨텍스트 메서드(JDBC의 일반적인 작업 흐름 = 공통 사용 가능) 를 새로운 클래스인 JdbcContext 클래스에 구현한다.
    그리고 UserDao에서는 분리된 JdbcContext를 DI받아서 사용할 수 있도록 처리한다.
    • UserDao → JdbcContext 빈을 DI 받는다
    • JdbcContext → dataSource 빈을 DI 받는다
    • 빈 의존관게 변경
      위의 구현 이후에 오브젝트간의 의존관계는 다음과 같다.위의 의존관계에 대한 DI설정등은 applicationContext.xml 에서 수정하도록 한다

 

JdbcContext의 특별한 DI

  JdbcContext를 UserDao에서 분리하면서 둘 사이에 인터페이스를 거치지 않고 applicationContext에서 설정한 의존관계대로 DI를 적용했다. 이렇게 인터페이스 사용없이 DI를 적용하는 것에 문제는 없는가?

  • 스프링 빈으로 등록해서 DI의존관계 주입(DI)를 제대로 사용하기 위해서는 인터페이스를 사이에 둬서 클래스 레벨에서 의존관계가 고정되지 않게 하고, 런타임 시에 의존할 오브젝트와의 관계를 다이나믹하게 주입하는 것이 맞다.
    하지만 인터페이스를 사용하지 않았어도 스프링 DI의 관점으로 봤을때는 객체의 생성, 관계설정에 대한 제어권한이 오브젝트에 있지 않고 외부(applicationContext)로 위임했기 때문에 IoC의 개념을 포함하고 있다고 볼 수 있다.
    그렇다면 왜 JdbcContext를 UserDao와 DI 구조로 만들어야 하는가?

  • 분리된 JdbcContext를 DI받기 위해서 UserDao와 JdbcContext 사이에 인터페이스를 따로 두지 않고 구체 클래스인 JdbcContext를 DI 받도록 설정했기 때문에 두 클래스 사이에는 클래스 레벨에서 의존관계가 결정되고, 의존 오브젝트의 구현 클래스를 변경할 수 없게 되었다.
  1. JdbcContext가 스프링 컨테이너에 의해서 싱글톤 빈으로 관리되기 때문이다.
  2. JdbcContext가 DI를 통해서 DataSource 오브젝트를 주입받기 때문이다. 스프링에서는 주입받는 오브젝트와 주입되는 오브젝트가 모두 스프링 빈으로 등록되어 있어야 DI가 가능하다.

  인터페이스를 사용하지 않은 이유는 무엇일까? 이는 UserDao와 JdbcContext의 응집도가 높음을 의미하며, 현 시점에서 UserDao 에서 다른 context(ex. hibernate)로 교체될 가능성이 없기 때문이다.

  • 코드를 이용하는 수동 DI하지만 위의 두 번째 조건에 해당하는 부분은 어떻게 만족시켜야 하는가? JdbcContext 자신이 스프링 빈이 아니기 때문에 DI 컨테이너를 통해서 DI 받을 수 없고 UserDao에 해당 클래스의 DI를 위임하도록 하는 방법이 있다. JdbcContext에서 사용하는 DataSource객체는 UserDao에서 스프링을 통해서 DI받도록 한다.
  • 결국 전체적인 구조와 구현코드는 다음과 같다.
  • 스프링 빈으로 등록해서 DI하는 방식처럼 싱글톤으로 관리하는 것이 불가능하다. 또한 빈으로 등록되어 있지 않기 때문에 JdbcContext의 생성과 초기화를 담당하는 객체가 있어야 한다.

  이 방식은 굳이 인터페이스를 두지 않아도 될 만큼의 긴밀한 관계를 가지는 두 클래스 UserDao와 JdbcContext를 어색하게 따로 빈으로 분리하지 않고 내부적으로 직접 DI할 수 있다. 하지만 이는 DI의 근본적인 원칙에 부합하지 않고 구체 클래스끼리의 의존관계가 생긴다는 단점이 있다.

 

3.5. 템플릿과 콜백

  이전까지의 UserDao, StatementStrategy, JdbcContext를 이용한 코드는 전략 패턴의 적용으로 볼 수 있다. 복잡하지만 바뀌지 않는 일정한 패턴을 갖는 작업 흐름이 존재하고 그 중 일부만 바꿔서 사용해야하는 경우에 적합한 패턴이다. 위의 예제코드에서는 이를 익명 내부 클래스를 이용해서 구현했는데 이런 방식을 스프링에서는 템플릿/콜백 패턴으로 부른다.

  • 전략 패턴의 컨텍스트 ⇒ 템플릿
  • 익명 내부 클래스로 만들어지는 전략객체 ⇒ 콜백

템플릿/콜백의 동작원리

  보통 템플릿의 작업 흐름 중, 특정 기능을 위해 한 번 호출되는 경우가 많기 때문에 템플릿/콜백 패턴의 콜백은 단일 메서드 인터페이스를 사용한다. (콜백은 일반적으로 하나의 메서드를 가진 인터페이스를 구현한 익명 내부 클래스로 생각하면 된다.)

 

  콜백 인터페이스의 메서드는 보통 템플릿의 작업 흐름 중에 만들어지는 컨텍스트 정보 전달용 파라미터가 있다. 템플릿/콜백 패턴의 전체적인 작업흐름은 다음과 같다.

  위의 구조는 DI방식의 전략 패턴 구조로 생각할 수 있다. 클라이언트가 템플릿 메서드를 호출하면서 알맞은 콜백 오브젝트를 생성, 전달하는 것은 메서드 레벨에서 일어나는 DI다. 다만 일반적인 DI와는 다른 점이 있다.

  1. DI작업이 클라이언트가 템플릿 메서드를 호출하는 시점과 동시에 일어난다.
  2. 템플릿 메서드 호출시마다 의존성이 주입되는 콜백 오브젝트를 새롭게 전달받는다.
  3. 콜백 오브젝트가 내부 클래스로서 자신을 생성한 클라이언트 메서드 내의 정보를 직접 참조한다.위의 템플릿/콜백의 작업흐름을 JdbcContext에 적용된 템플릿/콜백 페턴에 맞게 보여주면 다음과 같다.

 

편리한 콜백의 재활용

  템플릿/콜백 패턴을 적용함으로써 클라이언트인 UserDao의 메서드는 간결해지고 최소한의 데이터 로직만 갖고 있게 된다. 하지만 위의 예제코드에서는 익명 내부 클래스로 콜백을 구현하기 때문에 상대적으로 코드 가독성이 떨어지게 된다. 이를 개선하기 위해서 콜백을 분리하고 재활용하도록 코드를 개선한다.

 

  익명 내부 클래스에서 정의하는 내용은 결국 고정된 SQL 쿼리를 담은 PreparedStatement 객체를 생성하는 것이다.

 

  위의 코드에서 바뀔 수 있는 부분은 쿼리 delete from users뿐 이므로 해당 부분과 변하지 않는 부분을 분리 하면 아래와 같이 관리할 수 있다. 제일 처음 getCount() 메서드를 클래스 분리 없이 구현했을때와는 코드의 길이나 가독성 측면에서 훨씬 뛰어나다.

 

  변하는 것과 변하지 않는 것을 분리하고 변하지 않는 건 유연하게 재활용할 수 있게 만듦으로써 단순하고 재활용 가능한 JDBC활용 코드를 구현할 수 있었다. 여기서 executeSql메서드를 UserDao만 사용하는 것이 아니라 전체적인 DAO클래스가 공유할 수 있는 템플릿 클래스 안으로 옮겨서 사용할 수 있다.

 

  executeSql메서드를 JdbcContext안에 public 접근자로 옮기고 해당 메서드가 필요한 부분에서는 아래와 같이 사용할 수 있다. 이는 JdbcContext를 주입받는 모든 클래스 내에서 재사용이 가능하다.

public void deleteAll() SQLException{
    this.jdbcContext.executeSql("delete from users");
}

최종적으로 콜백을 재활용할 수 있는 JdbcContext와 UserDao의 관계는 다음과 같다.

 

3.6. 스프링의 JdbcTemplate

  스프링에서 제공하는 템플릿/콜백 패턴으로 JdbcTemplate이 있다. 이를 사용하기 위해서는 DI 컨테이너를 굳이 필요로 하지 않고 사용하고자 클래스에서 JdbcTemplate 오브젝트를 생성하고 필요한 DataSource를 전달하기만 하면 된다.

update()

  JdbcTemplate은 콜백으로 PreparedStatementCreator 인터페이스의 createPreparedStatement() 메서드 구현체를 전달받는다. deleteAll() 메서드를 기준으로 jdbcTemplate을 사용하는 코드는 아래와 같다.

public void deleteAll(){
    this.jdbcTemplate.update(
        new PreparedStatementCreator(){
            public PreparedStatement createPreparedStatement(Connection con) throws SQLException{
                return con.preparedStatement("delete from users");
            }
        }
    );
}

  하지만 위와 같은 복잡한 구조말고 JdbcTemplate에서는 update()메서드의 인자로 SQL을 받아서 처리하는 내장 콜백을 사용할 수 있다.

public void deleteAll(){
    this.jdbcTemplate.update("delete from users");
}

  add() 메서드가 필요하다고 하면 정적 쿼리 이외에 클라이언트에서 전달받는 쿼리 파라미터가 필요한데 이를 아래와 같이 처리할 수 있다.

this.jdbcTemplate.update("intser into users(id, name, password) values(?,?,?)",
    user.getId(), user.getName(), user.getPassword());

 

queryForInt()

  SQL 쿼리를 실행하고 결과 ResultSet을 통해서 데이터를 가져오는 getCount() 메서드가 있다고 할때 이에 대해서도 JdbcTemplate을 적용할 수 있다.

 

  query() 메서드를 통해서 PreparedStatementCreator콜백과 ResultSetExtractor콜백을 통해서 실행된 SQL결과 ResultSet을 이용해서 원하는 작업을 할 수 있다. 하지만 콜백을 2개 넘기기 위해서 익명내부클래스를 두 번 작성해야하는 복잡한 구조이므로, 이를 대체할 수 있는 queryForInt()메서드를 통해서 아래와 같이 간소화해서 처리할 수 있다.

public int getCount(){
    return this.jdbcTemplate.queryForInt("select count(*) from users");
}

 

queryForObject()

  이전 장에서 만든 get() 메서드에 JdbcTemplate을 적용할 수 있다.

특정 id에 해당하는 객체데이터를 조회하기 위해서 SQL 바인딩 변수가 필요하고 getCount()처럼 ResultSet으로 단순한 값을 조회하는 것이 아닌 User 객체로 만들어줘야한다.

 

  ResultSet → User객체 생성, 프로퍼티 셋팅 작업을 해주기 위해서 ResultSetExtractor콜백 대신 RowMapper콜백을 사용한다. 여기서 queryForObejct()는 조회되는 로우 개수가 하나라는 것을 전제한다.

public User get(String id){
    return this.jdbcTemplate.queryForObject("select * from users where id = ?",
        new Object[] {id}, // sql에 바인딩되는 파라미터 (배열 사용)
        new RowMapper<User>(){
            public User mapRow(ResultSet rs, int rowNum) throws SQLException}
                User user = new User();
                user.setId(rs.getString("id");
                user.setName(rs.getString("name");
                user.setPassword(rs.getString("password");
                return user;
            }
        }
    );
}

 

query()

  users테이블의 모든 사용자를 id 오름차순으로 가져오는 getAll() 메서드를 JdbcTemplate을 사용해서 만든다고 할때 query()메서드를 통해서 만들 수 있다.

 

  직전 queryForObject() 는 단일 객체조회에서만 사용가능하기 때문에 리턴타입이 List인 query() 메서드를 사용한다. query()는 실행된 SQL의 ResultSet의 모든 로우마다 RowMapper 콜백을 호출하기 때문에 아래와 같이 사용할 수 있다.

 

재사용 가능한 콜백의 분리

  위와 같이 JdbcTemplate을 사용하게 되면서 템플릿성 코드, 반복적인 콜백 코드를 거의 제거했으나 get(), getAll()메서드에서 사용하는 RowMapper의 내용이 중복된다. users테이블의 조회 쿼리가 생길 때 마다 RowMapper를 정의하는 코드를 반복할 수 없으니 이를 userMapper라는 명칭의 인스턴스 변수로 생성해두고 userDao 내부에서 공통으로 사용하도록 개선한다.

 

  최종적으로 UserDao 내부에서는 User 정보에 대한 DB처리에 대해서 응집도가 높고 JDBC API 사용 방식, 예외처리, 리소스 반납, connection 등에 대한 책임은 모두 JdbcTemplate에 있기 때문에 책임이 다른 코드와의 결합도는 낮아졌다고 볼 수 있다.

추가적으로 개선해볼만한 내용은 어떤 것이 있을까?

  • JdbcTemplate 클래스를 직접 이용한다는 면에서 특정 템플릿/콜백 구현에 강한 결합을 가지고 있다고 볼 수 있으므로, JdbcTemplate을 독립적인 bean으로 등록하고 이를 구현하는 JdbcOperations를 통해서 DI받아서 사용하도록 할 수도 있음
  • userMapper가 인스턴스 변수로 지정되어있으나 1회 생성시 변경되지 않는 property의 성격을 가지고 있으므로 이를 독립된 bean으로 만들고 UserDao에서 분리할 수 있다.
  • DAO 메서드에서 사용하는 SQL문장을 UserDao내부가 아닌 외부 리소스에 담아두고 필요할때 읽어오는 방식으로 분리할 수 있다.

 

3.7 정리

  • 공유 리소스에 대한 반환은 항상 신경쓰고 try/catch/finally block으로 관리
  • 일정 작업 흐름이 반복되면 그중에서 바뀌는 것/ 바뀌지 않는 것을 정의하고 바뀌지 않는 부분은 컨텍스트, 바뀌는 부분은 전략으로 보고 인터페이스를 통해서 유연하게 전략을 변경할 수 있는 구조로 설계해야한다
    → 전략패턴

단일 전략을 가지면서 익명 내부 클래스를 사용해서 매번 전략을 새로 만들어서 사용하고 컨텍스트 호출과 동시에 전략 DI를 수행하는 방식을 템플릿/콜백 패턴이라고 정의한다.
→ 템플릿/콜백 패턴

의도

원형이 되는 객체를 만들어 놓고 필요할때 이를 복사해서 새로운 객체를 생성한다.

 

기존에 존재하는 객체와 비슷하거나 일부만 변경된 객체가 필요한 경우에 사용된다. new를 통해서 계속 새로운 인스턴스를 생성할 수 있지만, 비슷한 어트리뷰트를 가지는 경우, clone을 통해서 인스턴스를 복제하는 것이 더 간편하다.

 

 ex) DB에서 필요한 정보를 가져오는 경우, 거의 비슷한 객체가 반복적으로 사용된다면 요청이 올때마다 DB에 붙는거보다 한번만 붙어서 정보를 가져오고 이후부터는 해당 정보를 가지고 있는 객체를 복사해서 사용하는 것이 효율적이다.

 

프로토타입 패턴을 사용할 때 필수적인 내용은 프로토타입 인터페이스의 구현체 서브클래스에서 Clone() 연산을 구현해야 한다는 점이다.

 

활용성

이미 존재하는 인스턴스의 정보를 복제해서 새로운 인스턴스를 생성하기 때문에 Product의 생성, 복합, 표현 방법에 독립적인 신규 Product를 생성하고자 할 때 사용한다.

  • 인스턴스화할 클래스를 런타임에 지정할 때 (동적 로딩)
  • 클래스의 인스턴스들이 서로 다른 상태 조합 중에 어느 하나의 값을 가질 때, 해당 상태에 대한 프로토타입을 미리 생성해두고 나중에는 이를 복제해서 사용하고 싶을 때

 

구조

  • Client : Prototype인터페이스에 복제를 요청해서 새로운 객체를 전달 받는다
  • Prototype : 자신을 복제하는데 필요한 인터페이스를 정의한다.
  • ConcretePrototypeA, B : clone()을 구현하는 객체

 

장단점

  • (+) 복잡한 객체를 생성하는 과정을 clone() 메서드에 숨길 수 있다
  • (+) 새로운 인스턴스르 생성하는것보다 비용적인 측면에서 효율적일 수 있다
  • (+) 서브클래스의 수를 줄일 수 있다.
  • (+) 팩토리 메서드처럼 Creator클래스를 구현하는 ConcreteCreator가 제품 수 만큼 나와야 하는 것이 아니라, 프로토타입의 복제를 통해서 create액션을 처리하므로 Creator를 따로 상속받아서 구현하는 서브 클래스가 필요없다. Creator객체가 이미 프로토타입의 역할을 하기 때문이다.
  • (-) 복잡한 객체는 만드는 과정 자체가 복잡할 수 있다 (특히, 순환참조의 경우)

 

구현

  • 자바에서는 Object 클래스에서 제공하는 clone() 메서드를 사용할 수 있음
  • Cloneable interface를 구현한 (clone() 메서드 구현) ConcreateProtoType 객체
public class GithubIssue implements Cloneable {
  private Integer id;
  private String title;

  private GithubRepository repository;

  public GithubIssue(GithubRepository repository){
      this.repository = repository;
  }

  public Integer getId() {
      return id;
  }

  public void setId(Integer id) {
      this.id = id;
  }

  public String getTitle() {
      return title;
  }

  public void setTitle(String title) {
      this.title = title;
  }

  public String getUrl(){
      return String.format("httsp://github.com/%s/%s/issues/%s", this.repository.getUser(), this.repository.getName(), this.id);
  }

  @Override
  public GithubIssue clone() {
      try {
          // 기본적인 clone()은 shallow copy지원을 하기 때문에 deep copy를 원하면 여기에 추가로 구현
          GithubIssue clone = (GithubIssue) super.clone();
          return clone;
      } catch (CloneNotSupportedException e) {
          throw new AssertionError();
      }
  }
}

 

  • Client (main)
public class PrototypeMain {
  public static void main(String[] args) {

      GithubRepository repository = new GithubRepository();
      repository.setUser("user1");
      repository.setName("dev-repo");

      GithubIssue issue1 = new GithubIssue(repository);
      issue1.setId(1);
      issue1.setTitle("issue1 title here");
      // issue1
      System.out.println(issue1.getUrl());

            // issue1을 복제한 객체
      GithubIssue clone = (GithubIssue) issue1.clone();
      System.out.println(clone.getUrl());

  }
}

+ Recent posts