Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[6주차](김수환, 오형석, 김동하) #4

Open
sunwootest opened this issue Aug 1, 2023 · 3 comments
Open

[6주차](김수환, 오형석, 김동하) #4

sunwootest opened this issue Aug 1, 2023 · 3 comments
Assignees
Labels

Comments

@sunwootest
Copy link
Collaborator

sunwootest commented Aug 1, 2023

  • 6주차
    • 4장
    • 5장
    • 6.1 ~ 6.4장
@d11210920
Copy link

d11210920 commented Aug 5, 2023

토비의 스프링 4장 예외 정리

안좋은 예외처리

  1. 단순 try-catch
    처리되지 않은 예외를 단순히 try-catch 블록으로 감싸주는 것은 위험한 일이다.
    이렇게 처리를 해주게 되면 감싸주기만 할 뿐 아무런 처리를 해주지 않는 것이기 때문이다.
    아무런 처리를 해주지 않게 된다면 결국 비정상적인 동작 등 예기치 못한 다른 문제를 일으키고, 나아가 이러한 문제의 원인이 무엇인지를 찾아내기가 매우 힘들어진다.

  2. 무의미하고 무책임한 throws
    1번 방법보다 조금 낫기는 하지만 이 방법도 심각한 문제점이 있다.
    내가 사용할 메소드에 throws Exception 이 선언되어 있다고 생각해보자.
    이렇게 선언되어있는 예외가 습관적으로 복사 붙혀넣기인지 아니면 정말 실행중에 무엇인가 예외적인 상황이 발생할 수 있다는 것인지 우리는 알 수가 없다.
    따라서 사용하는 메소드에서도 똑같이 throws를 붙이는 수 밖에 없다.

그렇다면 예외를 어떻게 다뤄야 할까?

예외의 종류와 특징

예외는 크게 두가지로 나뉜다.

  1. Error
    Error는 시스템에 뭔가 비정상적인 상황이 발생했을 경우에 사용된다. 따라서 애플리케이션 코드에서 잡으려고 하면 안 된다.
    따라서 시스템 레벨에서 특별한 작업을 하는 게 아니라면 애플리케이션에서는 이 Error 에 대한 처리는 신경쓰지 않아도 된다.
  2. Exception
    Error와 달리 개발자가 만든 애필리케이션 코드의 작업 중에 예외상황이 발생했을 경우에 사용된다.
    1. Checked Exception
    • RuntimeException을 상속하지 않음
    • 반드시 예외처리를 하는 코드를 함께 작성해야 하고 그렇지 않으면 컴파일 에러가 발생한다
    1. Unchecked Exception
    • RuntimeException을 상속함
    • 예외처리를 강제하지 않지만, 처리를 해줘도 상관은 없음

Checked Exception이 예외처리를 강제하는 것 때문에 위에 언급한 안좋은 예외처리들이 남발되고 있다.
따라서 최근에는 예상 가능한 예외상황을 다루는 예외를 Checked Exception으로 만들지 않는 경향이 있다.

예외처리 방법

  1. 예외 복구
    예외상황을 파악하고 문제를 해결해서 정상 상태로 돌려놓는 방법.

  2. 예외처리 회피
    자신이 처리하지 않고 자신을 호출한 쪽으로 예외를 던지는 방법.

    • throws 문으로 선언하여 알아서 던져지게 한다.
    • try-catch로 잡아서 로그를 남기든 처리를 한 후 다시 예외를 던지는 방법
  3. 예외 전환
    예외 회피와 비슷하게 메소드 밖으로 던지는 방법. 하지만 다른점은 적절한 예외로 변경하여 던진다.
    보통 전환하는 예외에 원래 발생한 예외를 담아서 중첩 예외(nested exception)으로 만드는 것이 좋다.

catch(SQLException e){
        . . . 
        throw DuplicateUserIdException(e);
}
  • 던져진 예외가 예외상황에 적절한 의미를 부여해주지 못하는 경우
  • 처리하기 쉽고 단순하게 만들기 위해 포장하는 경우 (보통 Checked Exception을 Unchecked Exception 으로)

JDBC의 한계

스프링의 JdbcTemplate은 기계적인 throws선언이 등장하지 않도록 가능한 한 빨리 Unchecked Exception으로 전환하는 예외처리 전략을 따른다.
스프링의 JdbcTemplate이 던지는 DataAccessException은 일단 런타임 예외로 SQLException을 포장해 준다.
따라서 대부분 복구가 불가능한 SQLException에 대해 애플리케이션 레벨에서는 신경 쓰지 않도록 해주는 것이다.

JDBC는 자바 표준 JDK에서도 가장 많이 사용되는 기능 중 하나이다. 하지만 이러한 JDBC에도 한계가 있다.

  1. 비표준 SQL
    SQL은 어느정도 표준화된 언어이고, 표준 규약이 있긴 하지만 대부분의 DB에서 비표준 문법과 기능도 제공한다.
  2. 호환성 없는 SQLException의 DB 에러정보
    JDBC는 SQL 사용중 발생하는 다양한 예외를 그냥 SQLException 하나에 모두 담아버린다. 따라서 SQL 상태 코드를 받아와서 사용하는데,
    문제는 DB의 JDBC 드라이버에서 SQLException을 담을 상태 코드를 정확히 만들어 주지 않는다.

따라서 해결하기 위해서는 DB전용 에러코드를 사용하는 방법이 있다.
DB별 에러코드를 참고해서 발생한 예외의 원인이 무엇인지 해석해주는 기능을 만드는 것이다.

DAO 인터페이스 구현의 분리

DAO를 굳이 따로 만들어서 사용하는 이유는 Data Access 로직을 다른 성격의 코드에서 분리해놓기 위해서다.
그를 위해선 다음과 같이 정의해야 한다.

public interface UserDao{
    public void add(User user);
   . . .
}

하지만 DAO에서 사용하는 데이터 기술으 API가 예외를 던지기 때문에 이렇게 사용할 수 없다.
만약 JDBC API를 사용하는 UserDao 클래스의 add() 구현 메소드라면 SQLException을 던진다.
인터페이스의 메소드 선언에는 없는 예외를 구현 클래스 메소드의 throws에 넣을수 없다.
따라서 코드는 다음과 같이 선언되어야 한다.

public void add(User user) throws SQLException;

그러나 이렇게 정의한 인터페이스는 다른 Data Access 기술로 전환하면 사용할 수 없다.
Hibernate는 HibernateException, JDO는 JdoException, JPA는 PersistentException으로 던져야 한다.
이를 해결하는 가장 단순한 방법은 모든 예외를 다 받아주는 Exception을 던지는 것이다.
하지만 무책임한 선언이다.
JDBC보다 늦게 등장한 나머지 기술들은 다행히 Runtime Exception을 사용한다. 따라서 throws에 선언을 해주지 않아도 된다.
남은 것은 SQLException을 던지는 JDBC API를 직접 사용하는 경우인데, 이 경우에는 DAO 메소드 내에서 런타임 예외로 포장해서 던져주면 된다.
이렇게 한다면 처음 의도한 코드대로 선언이 가능하다.

DataAccess 예외 추상화와 DataAccessException 계층구조

스프링은 자바의 Data Access 기술을 사용할 때 발생하는 예외들을 추상화해서 DataAccessException 계층구조 안에 정리해놓았다.
Data Access 기술에 상관없이 공통적인 예외도 있지만 일부 기술에서만 발생하는 예외도 있다.
스프링의 DataAccessAccessException은 이런 일부 기술에서만 공통적으로 나타나는 예외를 포함해서 Data Access 기술에서 발생 가능한 대부분의 예외를 계층구조로 분류해놓았다.

(예시) 낙관적인 락킹
  • 낙관적인 락킹 : 두 명 이상의 사용자가 동시에 조회하고 순차적으로 업데이트 할 때, 뒤늦게 업데이트한 것이 먼저 업데이트한 것을 덮어쓰지 않도록 막아주는 데 쓸 수 있는 편리한 기능이다.

ORM 기술이 아니지만 JDBC 등을 이용해 낙관적인 락킹 기능을 수현했다고 해보자.
이때는 아래 그림처럼 ObjectOptimisticLockingFailureException의 슈퍼클래스인 OptimisticLockingFailureException을 상속해서 JdbcOptimisticLockingFailureException을 정의해 사용할 수도 있다.
기술에 상관없이 낙관적인 락킹이 발생했을 때 일관된 방식으로 예외처리를 해주려면 OptimisticLockingFailureException을 잡도록 만들면 된다.

image

@genius00hwan
Copy link

genius00hwan commented Aug 6, 2023

[5장] 서비스 추상화

  • 자바에 적용되는 기술은 다양하다.
  • 상황에 따라 기술이 바뀌고 이에 적용하는 API가 달라 그때 그때 방식을 익혀야하는 번거로운 상황이 발생한다.
  • 스프링은 이를 일관된 방식으로 사용할 수 있도록 지원한다.

추상화란?

추상화

  • 하위 레벨 에서의 공통점을 뽑아내서 분리시키는 것을 말한다.
  • 하위 레벨의 동작 방식을 몰라도, 또는 하위 레벨이 바뀌더라도 같은 방법으로 접근할 수가 있다.

서비스 추상화

  • 기능은 유사하나 사용 방법이 다른, 하위 레벨의 여러 기술에 대해 인터페이스와 일관된 접근을 제공해 주는 것을 말한다.

사용자 레벨 관리 기능 추가

userDAO에 추가할 기능

  • 사용자 레벨은 BASIC, SILVER, GOLD 중 하나
  • basic : 최초 가입
  • silver : 50회 이상 로그인
  • gold : silver 레벨에서 추천 30회 이상
  • 일정 주기로 레벨업 작업 수행

필드 추가

  1. Level enum class 추가

    • 안전한 제공을 위해 enum 클래스를 추가한다.
    • basic(1), silver(2), gold(3)
  2. User 필드 추가

    • Level 타입 변수 level 추가
    • int 형 변수 login, recommend 추가
      • login : 로그인 횟수
      • recommend : 추천수
    • DB에 User 테이블에 필드 추가
      • level, login, recommend
  3. UserDaoTest 수정

    • 바꾼 코드에 따라 수정
    • level, login, recommend 필드 값 검증 로직 추가
  4. UserDaoJdbc 수정

    • User 테이블에 추가한 필드에 맞게 로직을 추가
      • insert 문에 필드 추가
      • User 객체 매핑용 콜백에 필드 추가
추가할 필드에 오타가 있었다면?
- 기능을 실제 사용해 보지 않고는 찾기 힘듦
- 테스트코드가 있어 미리 잡을 수 있다!!!

사용자 수정 기능 추가

사용자 정보에서 id를 제외한 다른 정보는 모두 수정 될 수 있다.

  • update 메소드 추가

    • UserDao 인터페이스에 update 메소드를 추가한다.
    • UserDaoJdbc에 update를 구현한다.
    • JdbcTemplate의 update() 메소드를 사용해 쿼리문 UPDATE 문과 파라미터를 전달해준다.
  • 수정 기능 테스트

    • update() 메소드가 정상적으로 동작하는지 확인한다.
    • 쿼리에 WHERE 문을 빠뜨릴 수 있다.
      • 두명의 사용자를 등록 -> 한명의 정보만 수정 ->
        두 사용자 모두 정보 확인

UserService.upgradeLevels()

비즈니스 로직을 다루기 위해 UserService 클래스를 만든다.

  • UserService는 UserDao에 의존한다.
  • DI를 하기 위해서 UserService도 스프링 빈으로 등록한다.

upgradeLevels()

  • 사용자 정보를 DAO에서 가져와 한명씩 레벨 변경 작업을 수행한다.
    • Basic 로그인 조건 달성 -> Silver 로 승급
    • Silver 추천 조건 달성 -> Gold로 승급
    • Gold 변화 없음
public class UserService {
	UserDao userDao;
    
    public void setUserDao(UserDao userDao){
    	this.userDao = userDao;
    }
    
    public void upgradeLevels() {
    List<User> users = userDao.getAll(); 
    for(User user : users) {
        Boolean changed = null; // 레벨의 변화가 있는지를 확인하는 플래그 
        
        if (user.getLevel() == Level.BASIC && user.getLogin() >= 50) { 
        	// basic 업그레이드
            user.setLevel(Level.SILVER); 
            changed = true; // 레벨 변경 플래그
        } 
        else if (user.getLevel() == Level.SILVER && user.getRecommend() >= 30) {
        	// silver 업그레이드
            user.setLevel(Level. GOLD) 
            changed = true; // 레벨 변경 플래그
        } 
        else if (user.getLevel() == Level.GOLD) { 
        	// gold 변경 없음
            changed = false; // 레벨 변경 플래그
        } 
        else { 
            changed = false; // 레벨 변경 플래그
        } 
        
        if (changed) { userDao.update(user); }
    }   
}

UserService.add()

회원가입을 하면 최초의 레벨은 basic 이다.
이는 비즈니스 로직이기 때문에 UserService에서 설정해준다.

public void add(User user){
	if(user.getLevel() == null) // user의 레벨이 주어지면 그냥 그대로 저장한다.
    	user.setLevel(Level.BASIC);
    UserDao.add(user);
}

코드 개선

기존 코드를 수정하여 테스트하기에 앞서 우리는 다음과 같은 질문을 해볼 필요가 있다.

  • 코드에 중복된 부분은 없는가?
  • 코드가 무엇을 하는 것인지 이해하기 불편하지 않은가?
  • 코드가 자신이 있어야할 자리에 있는가?
  • 앞으로 변경이 일어난다면 어떤 것이 있을 수 있고, 그 변화에 쉽게 대응할 수 있게 작성되어 있는가?

upgradeLevels() 리팩토링

  • if/elseif/else 블록들의 가독성이 떨어진다.
  • 성격이 다른 여러 가지 로직이 섞여 있다.
if-else 체인에서 나는 냄새
- if-else 체인에는 어떤 상태의 변화, 조건, 조건 만족 시 작업이 같이 있음
- 논리의 흐름을 이해하기 어렵다.
이는 성격이 다른 여러 가지 로직이 한데 섞여 있기 때문이다.

-> 관심사의 분리를 통해 이를 해결하자
public void upgradeLevels() {
    List<User> users = userDao.getAll(); 
    for(User user : users) {
        if (canUpgradeLevel(user)) { 
            upgradeLevel(user);
        }
    }
}
private void upgradeLevel(User user){
        user.upgradeLevel();
        userDao.update(user);
}
// User.upgradeLevel
private void upgradeLevel() {
      Level currentLevel = this.getLevel();
            if(currentLevel == null) 
                    throw new IllegalStateException("유저의 레벨이 존재하지 않습니다.");
      try {
      		Level nextLevel = Level.valueOf(++currentLevel.intValue());
            this.setLevel(nextLevel);
          }
            catch(AssertionError e){
           	throw new IllegalStateException(this.level + " 업그레이드 불가. 이미 최고레벨입니다.");
          }
  }

트랜잭션 서비스 추상화

레벨 관리 작업중 네트워크나 DB의 문제로 작업 완료가 어려워 졌다.
롤백을 통해 레벨 관리 작업 전으로 돌려놓자.

트랜잭션
: 레벨 관리 작업

  • 여러 작업이 하나의 트랜잭션으로 묶이면 하나의 작업으로 취급한다.
  • 트랜잭션은 모두 성공하던지 모두 실패하여야 한다.
  • 만약 트랜잭션을 완료할 수 없다면, 아예 작업이 시작조차 안한 것 처럼 감쪽같이 돌려놓아야 한다.

롤백
: 레벨 관리 작업 전으로 돌려놓자

  • 트랜잭션이 완전히 실행되지 못하면 실행된 작업을 취소시켜야 한다.
  • 이를 Transaction Rollback이라고 한다.

커밋
: 한 사용자의 레벨 관리

  • 트랜잭션은 여러 작업(커밋)으로 이루어진다.
  • 커밋 이후 실행되는 rollback은 가장 최근의 커밋 상태로 돌아온다.
  • 커밋에 이름을 붙이고 해당 커밋으로 되돌릴 수 있다.

JDBC의 트랜잭션

  • autoCommit 옵션이 디폴트가 true이다.
  • 하나의 쿼리당 커밋이 자동으로 이뤄져 트랜잭션을 지정하고 싶으면 autoCommit 옵션을 false로 지정해 줘야 한다.
  • commit(), rollback()으로 트랜잭션 종료를 설정하는 것을 Transaction Demarcation(트랜잭션 경계설정)이라고 부른다.
Connection c = dataSource.getConnection(); 

c.setAutoCommit(false); // 트랜잭션 시작 
try { 
        PreparedStatement st1 = c.prepareStatement("(update sql)"); 
        st1.executeUpdate(); 
        PreparedStatement st2 = c.prepareStatement("(delete sql)"); 
        st2.executeUpdate(); 
        c.commit(); // 트랜잭션 커밋 
} catch (Exception e) { 
        c.rollback(); // 트랜잭션 롤백 
} 

c.close();
  • 트랜잭션은 커넥션이 만들어지고 닫히는 범위에 존재한다.
    DB 커넥션 내부에서 만들어지는 트랜잭션을 로컬 트랜잭션이라 한다.

  • JDBC의 트랜잭션 경계 설정은 Connection 오브젝트를 이용한다.

  • UserDao를 구현하면서 JdbcTemplate의 메소드를 사용하면서 Connection을 직접 조작하지 않았다.

    • 트랜잭션을 설정하지 않았다.
  • 트랜잭션은 일반적으로 커넥션 보다 존재 범위가 짧다.

  • JdbcTemplate의 메소드를 사용하는 UserDao는 메소드 마다 하나씩의 독립적인 트랜잭션으로 실행된다.

  • userDao.update()마다 커넥션이 열고 닫히면서 하나의 트랜잭션이 생겨나는 것이다.

    트랜잭션 결과는 db에 그대로 남는다.

트랜잭션의 범위설정을 해야한다.

1. 커넥션 관리를 UserService에 맡길 수 있다.

  • upgradeLevels() 의 로직이 하나의 트랜잭션이 되어야 한다.

  • 데이터 접근 코드를 그대로 두고 트랜잭션 부분만 가져오면 책임이 다른 코드를 분리 할 수 있다.

  • 트랜잭션을 유지하기 위해선 UserDao의 메소드를 호출 할 때 Connection 객체를 파라미터로 넘겨 줘야 한다.

    • 매번 새로운 Connection을 만들면 또 별개의 트랜잭션이 만들어진다.
class UserService{
	public void upgradeLevels() throws Exception{
    	Connection c = ... ;
        ...
        try{
        	...
            upgradeLevel(c,user);
            ...
        }
        ...
    }
    void upgradeLevel(Connection c, User user){
    	user.upgradeLevel();
        userDao.update(c,user);
    }
}
interface UserDao(){
	public update(Connection c, User user);
    ...
}

DB연결을 깔끔하게 처리를 하기위해 JdbcTemplate을 만들었는데, 이 관심사의 분리가 깨졌다.
UserService 메소드에서 넘겨주는 Connection객체로 인해 코드가 지저분해진다.

2. 독립적인 트랜잭션 동기화

JdbcTemplate이 트랜잭션 동기화 방식을 이용하도록 한다.

  • UserService에서 트랜잭션을 시작하기 위해 만든 Connection 객체를 따로 저장하고 DAO 메소드가 이 Connection을 사용하도록 한다.
    -트랜잭션이 종료되면 그때 동기화를 마치면 된다.
  1. Connection을 생성한다.
  2. Connection을 트랜잭션 동기화 저장소에 저장한다.
    autoCommit옵션을 false로 두어 트랜잭션을 시작한다.
  3. update 호출
  4. 동기화 저장소에서 트랜잭션을 가진 Connection이 있는지 확인한다.
  5. Connection을 가져와 SQL을 실행한다.
    이때 Connection을 닫지않고, 트랜잭션은 진행중인 채로 동기화 저장소에 있다.
  6. 68,911 : 3,4,5 반복
  7. Connection의 commit()을 호출해 트랜잭션을 종료 시킨다.
  8. 트랜잭션 동기화 저장소가 Connection객체를 저장하지 않도록 이 객체를 제거한다.

스프링은 멀티쓰레드 환경에서도 안전한 트랜잭션을 구성할 수 있게 유틸리티 메소드를 제공한다 : TransactionSynchronizationManager

private DataSource dataSource;

public void setDataSource(DataSource dataSource) {  
//  Connection을 생성할 때 사용할 DataSource를 DI받는다.
    this.dataSource = dataSource;
}

public void upgradeLevels() throws Exception {  //  
    TransactionSynchronizationManager.initSynchronization();    
    //  트랜잭션 동기화 작업을 초기화
    Connection c = DataSourceUtils.getConnection(dataSource);   
    //  DB 커넥션을 생성하고 트랜잭션을 시작. 이후 DAO 작업은 모두 여기서 시작한 트랜잭션 안에서 진행
    c.setAutoCommit(false);     
    //  트랜잭션의 시작 선언
    //  DB 커넥션 생성과 동기화를 함께 해주는 유틸리티 메소드

    try {
        List<User> users = userDao.getAll();
        for (User user : users) {
            if (canUpgradeLevel(user)) {
                upgradeLevel(user);
            }
        }
        c.commit();     //  정상적으로 작업을 마치면 트랜잭션 커밋
    } catch (Exception e) {
        c.rollback();   //  예외가 발생하면 롤백
        throw e;
    } finally {
        DataSourceUtils.releaseConnection(c, dataSource);   
        //  스프링 유틸리티 메소드를 이용해 DB 커넥션을 안전하게 닫는다.
        TransactionSynchronizationManager.unbindResource(this.dataSource);  
        //  동기화 작업 종료
        TransactionSynchronizationManager.clearSynchronization();           
        //  동기화 작업 정리
    }
}
  • DataSourceUtils에서 제공하는 getConnection()을 이용해 DB 커넥션을 생성
    • DataSourceUtil.getConnection() :
      Connection을 생성해줄 뿐 아니라 트랜잭션 동기화에 사용하도록 Connection을 저장소에 바인딩해줌
  • 작업 정상 종료시, 트랜잭션 커밋 후 Connection닫고 동기화 작업도 끝냄
  • 예외가 발생하면 트랜잭션을 롤백함
트랜잭션 동기화 저장소? 
- TransactionSynchronizationManager 객체
- 트랜잭션의 범위 내에서 여러 작업을 관리
- 트랜잭션의 커밋 또는 롤백 시에 필요한 작업을 수행할 수 있도록 지원

트랜잭션 서비스 추상화

어떤 회사에서 다수의 DB를 백업 용도로 쓰고 있다고 하자.

  • 하나의 트랜잭션에서 여러 DB에 데이터를 넣는 작업을 할 수 있어야 한다.
  • 하나의 DB Connection에 종속되는 Connection을 이용한 로컬 트랜잭션으로는 불가능 하다.

글로벌 트랜잭션 :
별도의 트랜잭션 관리자를 이용해 트랜잭션을 관리할 수 있다.
JMS와 같이 트랜잭션 기능을 지원하는 서비스도 트랜잭션에 참여 시킬 수 있다.

글로벌 트랜잭션을 제공하는 JTA(Java Transaction API)를 사용한다.

  • 애플리케이션에서는 DB는 jdbc, 메시징 서버라면 JMS같은 API를 사용해 필요한 작업을 수행한다.
  • 트랜잭션은 API를 사용하지 않고 트랜잭션 매니저가 관리하게 한다.
  • 트랜잭션 매니저는 DB와 각 서버를 제어하고 관리하는 리소스 매니저와 XA 프로토콜을 이용해 연결된다.
  • 애플리케이션은 JTA를 통해 다수 DB와 서버를 관리할 수 있다.
메시징 서버?
- 컴퓨터 시스템 간에 데이터나 메시지를 교환하기 위한 시스템 
- 분산 시스템에서 데이터나 정보를 안정적으로 전달하고 처리하기 위해 사용
- 다양한 애플리케이션 간 통신을 지원

JMS : 메시징 시스템을 위한 API
	  분산 시스템에서 데이터를 안전하고 신뢰성 있게 교환하고 처리하는 데 사용

UserDao는 데이터 접근 기술을 유연하게 바꿔 사용 할 수 있게 구현되어 있었다.
UserService에서 트랜잭션의 경계설정을 하면서 데이터 접근 기술에 종속되는 구조가 되었다.

트랜잭션 처리 코드에 추상화를 도입할 수 있다.

�API들의 트랜잭션 경계설정 방법에서 공통적인 특징을 찾아 추상화된 트랝개션 관리 계층을 만들 수 있다.

-> 스프링의 트랜잭션 서비스 추상화
PlatformTransactionManager : 스프링에서 제공하는 트랜잭션 경계설정을 위한 인터페이스

public class UserService{
	...
	private PlatformTransactionManager 	transactionManager; 
    // 변수 이름이 transactionManager인 것은 컨벤션이다.
	
    public void setTransactionManager(PlatformTransactionManager transactionManager){
    	this.transactionManager = transactionManager;
        // 어떤 API를 사용하냐에 따라 의존주입을 통해 transactionManger 객체를 설정 할 수 있다.
    } 

	public void upgradeLevels() {
    	TransactionStatus status = 
        			this.transactionManager.getTransaction(new DefaultTransactionDefinition());
        	// getTransaction() 메서드를 호출하기만 하면 트랜잭션 시작 뿐 아니라 커넥션까지 가져올 수 있다
        	// 트랜잭션은 TransactionStatus 타입의 변수에 저장된다.
      	  	// 파라미터로 넘기는 객체는 트랜잭션의 속성을 담고있다.
    	
        try {
        	List<User> users = userDao.getAll();
        	for (User user : users) {
            	if (canUpgradeLevel(user)) {
                upgradeLevel(user);
            	}
        	}
        	this.transactionManager.commit(status);
    	} catch (RuntimeException e) {
        	this.transactionManager.rollback(status);
        	throw e;
    	}
	}
}
  • 트랜잭션은 시작하면서 TransactionStatus 변수에 저장된다.
  • TransactionStatus는 트랜잭션에 대한 조작이 필요할 때 PlatformTransactionManager 메소드의 파라미터로 전달해주면 된다.
  • TransactionManager을 스프링 빈으로 등록하고, UserService 가 DI방식으로 사용하게 한다.

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

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

  • UserDao와 UserService의 관심사의 분리
    서로 불필요한 영향을 주지 않으면서 독자적으로 확장이 가능하도록 만든 것이다.

  • 트랜잭션의 추상화
    애플리케이션의 비즈니스 로직과 그 하위에서 동작하는 트랜잭션 기술이라는 아예 다른 계층의 특성을 갖는 코드를 분리한 것이다.

DI

  • 애플리케이션 로직의 종유에 따른 수평적인 구분, 로직과 기술이라는 수직적인 구분 모두 결합도가 낮다.
  • 서로 영향을 주지 않고 자유롭게 확장될 수 있는 구조를 만들 수 있는 데는 스프링의 DI가 중요한 역할을 하고 있다.

DI의 가치는 이렇게 관심, 책임, 성격이 다른 코드를 깔끔하게 분리하는 데 있다.

단일 책임의 원칙

하나의 모듈은 한 가지 책임을 가져야 한다. 하나의 모듈이 바뀌는 이유는 하나여야 한다.

단일 책임 원칙의 장점 :

  • 여러 모듈들이 각자의 책임에 충실함으로 코드를 이해하기 쉽다.
  • 변경이 필요할 때 수정 대상이 명확해 진다.
  • 높은 응집성, 낮은 결합도 :
    높은 응집성, 낮은 결합도를 유지함으로 성격이 다른 모듈의 코드가 서로 영향을 주지 않게 되고, 서로 독립적으로 확장될 수 있게 된다.

단일 책임원칙을 돕는 DI :
DI를 통해 성격이 다른 모듈은 외부에서 제어하고 주입받아 사용할 수 있도록 한다.


메일 서비스 추상화

레벨이 업그레이드 되는 고객에겐 안내 메일을 발송해야 한다고 하자.
User에 Email 필드를 추가하고, UserService의 upgradeLevel()에 메일 발송 기능을 추가해야 한다.

자바에서 메일을 발송할 때는, Java 표준인 JavaMail을 사용할 수 있다.

JavaMail 테스트

  • 메일 발송은 부하가 많이 가는 작업인데, 테스트마다 실제 메일이 발송되는 것이 옳은가?
  • 실제로 메일을 고객들에게 발송한다면 혼란을 겪을텐데 테스트 메일 주소를 두어야 할까?
  • 메일이 실제로 잘 동작하는지 어떻게 테스트 해야할까?

JavaMail은 오랜 기간 널리 사용된 기술이다.

  • 검증된 기술이며 안정성도 보장 되어있다.
  • JavaMail API가 요청을 올바르게 통신한다는 보장만 있으면 된다.

테스트용 메일 서버를 따로 두고, 메일 서버가 실제 메일을 전송하는 대신 SMTP 프로토콜 요청이 JavaMail로부터 잘 도착하는 지만 확인한다.
(메일 서버는 요청을 잘 수행한다고 가정하자)

JavaMai이 받은 요청을 올바르게 메일 서버로 전달한다는 가정도 포함하면 직접 JavaMail을 구동하는 대신 테스트용 껍데기 JavaMail을 사용, JavaMail이 메일 서버와 통신함으로써 생기는 부하와 리스크도 줄일 수 있다.

어려운 JavaMail 설계

JavaMail과 같은 인터페이스를 갖는 오브젝트를 만들면 된다.

  • Session 객체를 만들어야 메일 메세지를 생성할 수 있고, 전송할 수 있다.
  • Session은 인터페이스가 아닌 클래스다.
  • 생성자가 private라서 직접 생성도 불가능하다.
  • 상속도 불가능하게 final 한정자가 박혀있다.

➡️ Spring은 JavaMail을 추상화하는 기능을 제공하고 있다.

테스트와 서비스 추상화

  • 일반적으로 추상화는 수행하는 기능은 같지만 다양한 기술에 대해 일관적인 사용법을 제공하기 위한 접근 방법을 의미한다.
  • JavaMail처럼 테스트를 어렵게 만드는 API를 사용할 때도 유용하게 쓸 수 있다.

  • MailSender를 구현한 추상화 클래스를 이용하면, JavaMail이 아닌 다른 메세징 서버 API를 이용할 때도 MailSender를 구현한 클래스를 만들어 의존주입을 할 수 있다.

  • UserService에서는 MailSender 인터페이스를 통해 메일을 보낸다고만 알면 된다.

  • MailSender 구체 클래스가 바뀌더라도 의존주입만 새로 해주면 된다.

만약 DB의 트랜젝션 개념을 메일에 적용한다면?

  • 메일을 보낼 유저 목록을 따로 저장해, 업그레이드 작업이 성공적으로 끝마쳤다면 한 번에 전송하도록 한다
  • 유사 트랜잭션을 구현한다. send() 메서드를 호출하더라도 실제로 메일을 발송하지 않고 있다가, 작업이 끝나야 메일을 발송하는 방법으로 구현한다.
  • MailSender 인터페이스를 이용하면 변경이 자유롭고 다양하게 사용할 수 있다.
  • 외부 리소스와 연동하는 모든 작업은 추상화의 대상이 될 수 있다.

테스트 대역

업로드중..

DummyMailSender는 하는 것 없이 테스트를 편히 하기 위해 작성했다.

  • 테스트 때 발송용 메소드를 제거했다가 테스트 끝나고 추가하는 것은 불가능한 일이기 때문에 DummyMailSender를 주입함으로써 간단하게 해결했다.

  • 우리가 테스트하고자 하는 것은 UserService였는데, 이 UserService도 여러 객체에 의존하고 있다.

  • 테스트 환경에서 테스트 대상이 되는 객체의 기능을 충실하게 수행하며 테스트를 자주 실행할 수 있도록 하는 객체를 테스트 대역(test double)이라고 한다.

  • DummyMailSender가 없다면, 메일 서버가 구비되지 않은 상황에서 실제 메일을 발송할 주소가 존재하지 않아 예외 가 발생해 테스트를 진행하기 어렵다.

Mock 객체

DummyMailSender는 테스트에 큰 영향을 끼치지 않는다.
테스트 대상 객체가 의존 객체에 넘기는 값과 행위를 검증하고 싶다면, mock 객체를 사용해야 한다.

  • Mock 객체는 테스트 대상이 되는 객체와 Mock객체 사이에서 일어나는 커뮤니케이션을 추후 검증에 사용할 수 있도록 저장한다.
  • input, output이 될 수도 있고, 얼마나 자주 커뮤니케이션이 일어나는지 통계적인 수치가 될 수도 있다.

Ex ) upgradeLevels() 테스트에 실제 메일 발송을 제대로 요청했는지 알고 싶다면, MailSender를 구현한 MockMailSender를 만들고 Request 횟수를 저장하면 된다.

@kuk6933
Copy link

kuk6933 commented Aug 7, 2023

6장

AOP?

Aop는 IoC/DI, 서비스 추상화와 더불어 스프링 3대 기반기술의 하나.

Aspect Oriented Programming의 약자로 핵심관점, 부가적인 관점으로 나눠서 보고 그 관점을 기준으로 모듈화 하는 프로그래밍 기법

6.1 트랜잭션 코드의 분리

6.1.1 메소드 분리

public void upgradeLevels() throws Exception { 
    Transactionstatus status = this.transactionManager.getTransaction(new DefaultTransactionDefinition());
    try {
				// 비지니스 로직 구간
        List<User> users = userDao.getAll(); 
				for (User user : users) {
            if (canUpgradeLevel(user)) { upgradeLevel(user); } 
        }
        this.transactionManager.commit(status); 
				// 비지니스 로직 구간
    } catch (Exception e) {
        this.transactionManager.rollback(status) ;
        throw e; 
    }
}
  • 비즈니스 로직 코드를 사이에 두고 트랜잭션 시작과 종료를 담당하는 코드가 앞 뒤에 위치하고 있음.
  • 트랜잭션 경계설정의 코드와 비즈니스 로직 코드 간에 서로 주고 받는 정보가 없음

→ 성격이 다른 코드를 두 개의 메소드로 분리할 수 있지 않을까?

public void upgradeLevels() throws Exception {
    TransactionStatus status = this.transactionManager
                        .getTransaction(new DefaultTransactionDefinition());
    try {
        upgradeLevelsInternal();
        this.transactionManager.commit(status);
    } catch (Exception e) {
        this.transactionManager.rollback(status);
        throw e;
    }
}

private void upgradeLevelsInternal(){
        List<User> users = userDao.getAll();
    for (User user : users) {
        if (canUpgradeLevel(user)) {
            upgradeLevel(user);
        }
    }
}
  • 비즈니스 로직을 담당하는 코드와 트랜잭션 관련 코드가 깔끔하게 분리됨
  • 그러나 여전히 트랜잭션을 담당하는 기술적인 코드가 UserService안에 존재

6.1.2 DI를 이용한 클래스의 분리

DI 적용을 이용한 트랜잭션 분리

  • 우리는 인터페이스를 통해 클라이언트와 구현 클래스의 직접 결합을 막고 유연한 확장을 가능하게 해왔음

  • 만약 한 번에 두 개의 구현 클래스를 동시에 이용한다면?
    스크린샷 2023-08-08 오전 1 14 08

  • 트랜잭션 관련 책임을 맡는 UserServiceTx를 구현

  • UserServiceTx는 비즈니스 로직을 담고 있지 않기 때문에 UserServiceImpl에 실제적인 로직 처리 작업은 위임함.

  • 결국 클라이언트가 볼 때 결국 트랜잭션이 적용된 비즈니스 로직의 구현이라는 기대하는 동작이 일어남

UserService 인터페이스 도입

  • UserServiceImpl은 비즈니스 로직만 가지는 구현 클래스
@Service
public class UserServiceImpl implements UserService {

    private UserDao userDao;
    private MailSender mailSender;
        //...

    @Override
    public void upgradeLevels() {
                List<User> users = userDao.getAll();
                for(User user : users)
                        if(canUpgradeLevel(user)) {
                                upgradeLevel(user);
                        }
                }                
    }

}
public class UserServiceTx implements UserService {

    private UserService userService;
    private PlatformTransactionManager transactionManager;

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

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

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

    public void upgradeLevels() {
        TransactionStatus transaction = transactionManager.getTransaction(new DefaultTransactionDefinition());
        try {
            userService.upgradeLevels();
            transactionManager.commit(transaction);
        } catch (RuntimeException e) {
            transactionManager.rollback(transaction);
            throw e;
        }
    }
}
  • 사용자 관리라는 비즈니스 로직을 전혀 갖지 않고 UserService 구현 오브젝트에 기능을 위임함.
  • 이를 위해 UserService 오브젝트를 DI받을 수 있어야 함.

트랜잭션을 적용을 위한 DI 설정

  • 클라이언트는 UserServiceTx 빈을 호출해서 사용해야함.

트랜잭션 경계설정 코드 분리의 장점

  • 비즈니스 로직을 담당하고 있는 UserServiceImpl의 코드를 작성할 때는 트랜잭션과 같은 기술적인 내용에는 전혀 신경쓰지 않아도 됨.
  • 비즈니스 로직에 대한 테스트를 손쉽게 만들어 낼 수 있음

6.2 고립된 단위 테스트

  • 가장 편하고 좋은 테스트 방법은 가능한 작은 단위로 쪼개서 테스트 하는것
    • 테스트 실패 시 원인을 찾기 쉽기 때문
    • 테스트 의도가 분명해지고 만들기도 쉬워짐

6.2.1 복잡한 의존관계 속의 테스트

현재 UserService는 UserDao, MailSender, PlatformTransactionManager 세 가지 타입의 의존 오브젝트가 필요함.
스크린샷 2023-08-08 오전 1 14 42

  • UserServiceTest는 UserService의 코드가 바르게 작성되어 있으면 성공, 아니면 실패여야함.
  • 하지만 세가지 의존 관계를 갖는 오브젝트들이 테스트가 진행되는 동안에 같이 실행됨
  • 따라서 UserService를 테스트 하는 것 처럼 보이지만 사실 그 뒤에 존재하는 훨씬 더 많은 오브젝트와 환경, 서비스, 서버, 심지어 네트워크까지 함께 테스트하는 셈
    • 테스트 준비도 힘들고 수행속도 느리고 오류 찾기도 힘듦

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

  • 위와 같은 이유 때문에 테스트의 대상이 환경이나 외부 서버, 다른 클래스 코드에 종속되고 영향받지 않도록 고립시킬 필요가 있음. → 대역을 사용

테스트를 위한 UserServiceImpl 고립

스크린샷 2023-08-06 오전 1.13.52.png

  • 고립된 테스트가 가능하도록 UserService를 재구성하면 이러한 구조가 됨

  • 하지만 의존 오브젝트나 외부 서비스에 의존하지 않는 고립된 테스트 방식으로 만든 UserServiceImpl은 결과를 확인하기 힘듦. → UserDao에게 어떤 요청을 했는지 확인하는 작업 필요

  • 그래서 UserDao와 같은 역할을 하면서 UserServiceImpl과의 사이에서 주고받은 정보를 저장해뒀다가, 테스트의 검증에 사용할 수 있게 하는 목 오브젝트를 만들 필요가 있음.

6.2.3 단위 테스트와 통합 테스트

  • 단위 테스트: 테스트 대상 클래스를 목 오브젝트 등의 테스트 대역을 이용해 의존 오브젝트나 외부의 리소스를 사용하지 않도록 고립시켜서 테스트 하는 것
  • 통합 테스트: 두 개 이상의 성격이나 계층이 다른 오브젝트가 연동하도록 만들어 테스트하거나, 또는 외부의 DB나 파일, 서비스 등의 리소스가 참여하는 테스트

6.2.4 목 프레임워크

  • 단위 테스트를 만들기 위해서는 스텁이나 목 오브젝트의 사용이 필수적

Mockito 프레임워크

  • Mockito는 목 오브젝트를 편리하게 작성하도록 도와주는 프레임워크
  • 사용도 편리하고 코드도 직관적

6.3

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

스크린샷 2023-08-06 오전 2.00.26.png

  • 단순히 확장성을 고려해서 한 가지 기능을 분리한다면 전형적인 전략 패턴을 사용하면 됨.

  • 트랜잭션 기능에는 추상화 작업을 통해 이미 전략 패턴이 적용되어 있지만 전략 패턴으로는 트랜잭션 기능의 구현 내용을 분리해냈을 뿐 트랜잭션을 적용한다는 사실은 코드에 남아있음

  • 구체적인 코드는 제거했을지라도 위임을 통해 기능을 사용하는 코드는 핵심 코드와 함께 남아있음
    스크린샷 2023-08-08 오전 1 15 04

  • 핵심기능은 부가기능을 가진 클래스 존재 자체를 모름. 따라서 부가기능이 핵심기능을 사용하는 구조가 되는 것

  • 부가 기능은 자신이 핵심기능을 가진 클래스인 것처럼 꾸며서 클라이언트가 자신을 거쳐 핵심기능을 사용하도록 만들어야함.

  • 그러기 위해 클라이언트는 인터페이스를 통해서만 핵심기능을 사용하고 부가기능 자신도 같은 인터페이스를 구현한 뒤 자신이 그 사이에 끼어들어야함.
    스크린샷 2023-08-08 오전 1 15 22

  • 이렇게 자신이 실제 대상인 것처럼 위장해서 클라이언트의 요청을 받아주는 것을 프록시라고 부름.

  • 프록시를 통해 최종적으로 요청을 위임받아 처리하는 실제 오브젝트를 타깃, 실체라고 부름

스크린샷 2023-08-08 오전 1 15 44

  • 프록시의 특징
    • 타깃과 같은 인터페이스를 구현
    • 타깃을 제어할 수 있는 위치에 있음
  • 프록시는 사용 목적에 따라 두 가지로 구분할 수 있음
    • 클라이언트가 타깃에 접근하는 방법을 제어하기 위함 → 프록시 패턴
    • 타깃에 부가적인 기능을 부여해주기 위함 → 데코레이터 패턴

데코레이터 패턴

  • 데코레이터 패턴은 타깃에 부가적인 기능을 런타임 시 다이나믹하게 부여해주기 위해 프록시를 사용하는 패턴을 말함.

  • 다이나믹하게 기능을 부가한다는 의미는 컴파일 시점에 어떤 방법과 순서로 프록시와 타겟이 연결되어 사용되는지 정해져있지 않다는 뜻.

  • 데코레이터 패턴이라 불리는 이유는 마치 제품이나 케익 등을 여러 겹으로 포장하고 그 위에 장식을 붙이는 것처럼 실제 내용물은 동일하지만 부가적인 효과를 부여해줄 수 있기 때문.

  • 프록시가 여러개라면 순서를 정해서 단계적으로 위임하는 구조로 만들면 됨.
    스크린샷 2023-08-08 오전 1 15 57

  • 프록시로서 동작하는 각 데코레이터는 위임하는 대상에도 인터페이스로 접근하기 때문에 타깃으로 위임하는지, 아니면 다음 단계의 프록시로 위임하는지 알지 못함.

    • → 생성자나 수정자 메소드를 통해 위임 대상을 외부에서 런타임 시에 주입받을 수 있도록 만들어야함.
  • UserServiceTx도 데코레이터 패턴을 적용한 것

프록시 패턴

  • 프록시를 사용하는 방법 중 타깃에 대한 접근 방법을 제어하려는 목적을 가진 경우 사용하는 패턴.
  • 프록시 패턴의 프록시는 타깃의 기능을 확장하거나 추가하지 않는 대신 클라이언트가 타깃에 접근하는 방식을 변경해줌.
  • 타깃 오브젝트를 생성하기가 복잡하거나 당장 필요하지 않은 경우에는 꼭 필요한 시점까지 오브젝트를 생성하지 않는 편이 좋지만 타깃 오브젝트에 대한 레퍼런스가 미리 필요할 수 있음 → 프록시 패턴 적용
  • 클라이언트에게 타깃에 대한 레퍼런스 대신 프록시를 넘겨준 후 이후 타깃을 사용하려 하면 그때 프록시가 타깃 오브젝트를 생성하고 요청을 위임해주는 방식
  • 특별한 상황에서 타깃에 대한 접근권한을 제어하기 위해 프록시 패턴을 사용할 수 있음 ex)readonly
  • 구조적으로는 프록시와 데코레이터 패턴이 비슷하나 프록시는 코드에서 자신이 만들거나 접근할 타깃 클래스 정보를 알고 있는 경우가 많음. 생성을 지연하는 프록시라면 구체적인 생성 방법을 알아야 하기 때문에 타깃 클래스에 대한 직접적인 정보를 알아야함.

6.3.2 다이내믹 프록시

  • 프록시는 기존 코드에 영향을 주지 않으면서 타깃의 기능을 확정하거나 접근 방법을 제어할 수 있는 유용한 방법
  • 하지만 프록시를 만드는 일이 상당히 번거롭게 느껴짐
  • 자바에는 이런 부분을 해결하기 위해 java.lang.reflect 패키지 안에 프록시를 손쉽게 만들 수 있도록 지원해주는 클래스들이 존재

프록시 구성과 프록시 작성의 문제점

  • 프록시는 다음 두 가지 기능으로 구성됨
    • 타깃과 같은 메소드를 구현하고 있다가 메소드가 호출되면 타깃 오브젝트로 위임
    • 지정된 요청에 대해 부가기능을 수행
  • 프록시를 만들기가 번거로운 이유
    • 타깃의 인터페이스를 구현하고 위임하는 코드를 작성하기가 번거로움.
    • 부가기능 코드가 중복될 가능성이 많음
  • JDK의 다이내믹 프록시를 사용하면 이러한 문제를 해결할 수 있음

리플렉션

  • 컴파일 시간이 아닌 실행 시간에 동적으로 특정 클래스의 정보를 추출할 수 있는 프로그래밍 기법
  • 구체적인 클래스 타입을 알지 못하더라도 그 클래스의 메서드, 타입, 변수들에 접근할 수 있도록 해주는 자바 API
  • 다이내믹 프록시는 리플렉션 기능을 이용해 프록시를 만들어줌
  • 리플렉션은 자바의 코드 자체를 추상화해서 접근하도록 만든것
Method lengthMethod = String.class.getMethod("length");
int length = lengthMethod.invoke(name); // int length = name.length();

다이내믹 프록시 적용

  • 다이내믹 프록시는 프록시 팩토리에 의해 런타임시 다이내믹하게 만들어지는 오브젝트
  • 다이내믹 프록시 오브젝트는 타깃의 인터페이스와 같은 타입으로 만들어짐
  • 클라이언트는 다이내믹 프록시 오브젝트를 타깃 인터페이스를 통해 사용할 수 있음
    • → 프록시를 만들 때 인터페이스를 모두 구현해가면서 클래스를 정의하는 수고를 덜 수 있음
    • 프록시 팩토리에게 인터페이스 정보만 제공해주면 해당 인터페이스를 구현한 클래스의 오브젝트를 자동으로 만들어주기 때문
  • 부가기능 제공 코드는 직접 작성해야함.
  • 부가기능은 프록시 오브젝트와 독립적으로 InvocationHandler를 구현한 오브젝트에 담음
public Object invoke(Object proxy, Method method, Object[] args)
  • 다이내믹 프록시 오브젝트는 클라이언트의 모든 요청을 리플렉션 정보로 변환해서 InvocationHandler 구현 오브젝트의 invoke() 메소드로 넘김
    • → 모든 메소드 요청이 하나의 메소드로 집중되기 때문에 중복되는 기능을 효과적으로 제공
      스크린샷 2023-08-08 오전 1 16 18
public class UppercaseHandler implements InvocationHandler {
    Hello target;

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

    public Object invoke(Object proxy, Method method, Object!] args) throws Throwable {
        String ret = (String)method.invoke(target, args); //형변환?
        return ret.toUpperCase();
    }
}
  • 다이내믹 프록시를 통해 요청이 전달되면 리플렉션 API를 이용해 타깃 오브젝트의 메소드를 호출
    • 이때 타깃 오브젝트는 생성자를 통해 미리 전달받음
//프록시 생성하는 코드 
Hello proxiedHello = (Hello)Proxy.newProxyInstance( 
    getClass().getClassLoader(), // 동적으로 생성되는 다이내믹 프록시 클래스의 로딩에 사용할 클래스 로더
    new Class[] {Hello.class}, // 구현할 인터페이스
    new UppercaseHandler (new HelloTarget())); //부가기능과 위임 코드를 담은 invocationHandler

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

  • 트랜잭션 부가기능을 제공하는 다이내믹 프록시를 만들면 효율적으로 트랜잭션 기능 제공 가능
public class TransactionHandler implements InvocationHandler {
    private Object target;
    private PlatformTransactionManager transactionManager;
    private String pattern; //트랜잭션을 적용할 메소드 이름 패턴
    
    public void setTarget(Object target) {
        this.target = target;
    }
    
    public void setTransactionManager(PlatormTransactionManager transactionManager) {
        this.transactionManager = transactionManager;
    }
    
    public void setPattern(String pattern) {
        this.pattern = pattern;
    }
    
    public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
        if(method.getName().startsWith(pattern)) {
            return invokeInTransaction(method, args);
        } else {
            return method.invoke(target, args);
        }
    }
    
    private Object invokeInTransaction(Method method, Object[] args) throws Throwable {
        TransactionStatus status = this.transactionManager.getTransaction(new DefaultTransactionDefinition());
        try {
            Object ret = method.invoke(target, args);
            this.transactionManager.commit(status); //타깃 호출후 예외가 발생하지 않으면 commit
            return ret;
        } catch (InvocationTargetException e) { //예외가 발생하면 트랜잭션 롤백
            this.transactionManager.rollback(status);
            return e.getTargetException();
        }
    }
}
  • 요청을 위임할 타깃을 DI로 제공받음
  • 타깃을 저장할 변수는 Obect로 선언해 트랜잭션 적용이 필요한 어떤 타깃 오브젝트에도 적용 가능
  • 롤백을 적용하기 위한 예외는 RuntimeException 대신 InvocationTargetException 사용해야함
    • 리플렉션 메소드인 Method.invoke()를 이용해 타깃 오브젝트의 메소드를 호출할 때는 타깃 오브젝트에서 발생하는 예외가 InvocationTargetException으로 한번 포장돼서 전달받기 때문.
    • 따라서 일단 InvocationTargetException으로 받은 후 getTargetException() 메소드로 중첩되어 있는 예외를 가져와야함.

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

  • TransactionHandler와 다이내믹 프록시를 스프링의 DI를 통해 사용할 수 있도록 만들어야함
  • 하지만 다이내믹 프록시 오브젝트는 일반적인 스프링의 빈으로는 등록할 방법이 없음
  • 다이내믹 프록시는 Proxy 클래스의 newProxyInstance()라는 스태틱 팩토리 메서드를 통해서만 만들 수 있음

팩토리 빈

  • 스프링은 팩토리 빈을 이용한 빈 생성도 지원함.
  • 팩토리 빈은 스프링을 대신해서 오브젝트의 생성로직을 담당하도록 만들어진 특별한 빈을 의미
  • 팩토리 빈 클래스의 오브젝트의 getObect()메소드를 이용해 오브젝트를 가져오고, 이를 빈 오브젝트로 사용할 수 있음
public interface FactoryBean<T> {
    T getObject() throws Exception; //빈 오브젝트를 생성해서 돌려준다.
    Class<? extends T> getObjectType(); //생성되는 오브젝트 타입을 알려준다.
    boolean isSingleton(); //getObject가 돌려주는 오브젝트가 항상 같은 싱글톤 오브젝트인지 알려준다.
}

다이내믹 프록시를 만들어주는 팩토리 빈

  • 팩토리 빈 방식을 통해 아래 그림과 같은 구조로 관계가 설정됨
  • 팩토리 빈은 다이내믹 프록시가 위임할 타깃 오브젝트(UserServiceImpl)에 대한 레퍼런스를 프로퍼티를 통해 DI 받아둬야함. 다이내믹 프록시와 함께 생성할 TransactionHandler에게 타깃 오브젝트를 전달해줘야 하기 때문.

스크린샷 2023-08-08 오전 1 16 46

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

프록시 팩토리 빈의 재사용

  • 한번 부가기능을 가진 프록시를 생성하는 팩토리 빈을 만들어두면 타깃의 타입에 상관없이 재사용할 수 있음

프록시 패토리 빈 방식의 장점

  • 인터페이스 구현 클래스 만들지 않아도 됨
  • 부가기능 코드 중복 제거
  • 다이내믹 프록시에 팩토리 빈을 이용한 DI까지 더해주면 번거로운 다이내믹 프록시 생성 코드도 제거 가능
  • 이 과정에서 스프링 DI는 매우 중요한 역할

프록시 팩토리 빈의 한계

  • 프록시를 통해 타깃에 부가기능을 제공하는 것은 메소드 단위
  • 한 번에 여러개의 클래스에 공통적인 부가기능을 제공하는 일은 지금까지 살펴본 방법으로는 불가능함.
  • 하나의 타깃에 여러 개의 부가기능을 적용하는 것도 문제.
  • TransactionHandler 오브젝트가 프록시 팩토리 빈 개수만큼 만들어진다는 점도 문제임
  • TransactionHandler는 타깃 오브젝트를 프로퍼티를 갖고 있음. 따라서 트랜잭션 부가기능을 제공하는 동일한 코드임에도 불구하고 타깃 오브젝트가 달라지면 새로운 TransactionHandler 오브젝트를 만들어야함

6.4 스프링 프록시 팩토리 빈

6.4.1 ProxyFactoryBean

  • 스프링은 프록시 오브젝트를 생성해주는 기술을 추상화한 팩토리 빈을 제공
  • 스프링의 ProxyFactoryBean은 프록시를 생성해서 빈 오브젝트로 등록하게 해주는 팩토리 빈
  • 이전 챕터에서 사용했떤 FactoryBean과 달리, 순수하게 프록시를 생성하는 작업만 담당하고 프록시를 통해 부가기능은 별도의 빈에 둘 수있음
  • ProxyFactoryBean이 생성하는 프록시에서 사용할 부가기능은 MethodInterceptor 인터페이스를 구현해서 만든다
  • MethodInterceptor는 InvocationHandler와 비슷하지만 한 가지 다른 점이 있음
    • InvocationHandler의 invoke() 메소드는 타깃 오브젝트에 대한 정보를 제공하지 않음
      • → 타깃은 InvocationHandler를 구현한 클래스가 직접 알고 있어야함.
    • MethodInterceptor의 invoke() 메소드는 ProxyFactoryBean으로부터 타깃 오브젝트에 대한 정보까지도 함께 제공받음.
      • → 타깃 오브젝트에 상관 없이 독립적으로 만들어질 수 있음 → MethodInterceptor 오브젝트는 타깃이 다른 여러 프록시에서 함께 사용할 수 있고 싱글톤 빈으로 등록 가능
public void simpleProxy() {
    Hello proxiedHello = (Hello)Proxy.newProxyInstance( //  JDK 다아내믹 프록시 생성
        getClass().getClassLoader(),
        new Class[] {Hello.class},
        new UppercaseHandler(new HelloTarget())
    );
}

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

    Hello proxiedHello = (Hello) pfBean.getObject();    //  FactoryBean이므로 getObject()로 생성된 프록시를 가져온다.
    System.out.println(proxiedHello.sayHello("JaeDoo"));
    System.out.println(proxiedHello.sayHi("JaeDoo"));
    System.out.println(proxiedHello.sayThankYou("JaeDoo"));
}

static class UppercaseAdvice implements MethodInterceptor {

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

public interface Hello {            //  타깃과 프록시가 구현할 인터페이스
    String sayHello(String name);
    String sayHi(String name);
    String sayThankYou(String name);
}

static class HelloTarget implements Hello { //  타깃 클래스

    @Override
    public String sayHello(String name) { return "Hello " + name;}

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

    @Override
    public String sayThankYou(String name) {return "Thank You " + name;}
}

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

  • MethodInterceptor로는 메소드 정보와 함께 타깃 오브젝트가 담긴 MethodInvocation 오브젝트가 전달됨.
  • MethodInterceptor은 타깃 오브젝트의 메소드를 실행할 수 있기 때문에 MethodInterceptor는 부가기능을 제공하는 데만 집중할 수 있음
  • MethodInvocation은 일종의 콜백 오브젝트로 타깃 오브젝트의 메소드를 내부적으로 실행해주는 기능이 있음. → MethodInvocation 구현 클래스는 일종의 공유 가능한 템플릿처럼 동작하는 것.
  • ProxyFactoryBean은 작은 단위의 템플릿/콜백 구조를 응용해 적용했기 때문에 템플릿 역할을 하는 MethodInvocation을 싱글톤으로 두고 공유할 수 있음
  • MethodInterceptor처럼 타깃 오브젝트에 적용하는 부가기능을 담은 오브젝트를 스프링에서는 어드바이스라고 부름

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

  • MethodInterceptor는 타깃에 대한 정보를 들고 있지 않기에 싱글톤 빈으로 등록할 수 있었다. 따라서 확장성까지 고려하면 적용 대상 메소드를 선정하는 로직은 분리하는 것이 올바름.
  • MethodInterceptor에는 재사용 가능한 순수한 부가기능 코드만 남기고 프록시에 부가기능 적용 메소드를 선택하는 기능을 넣자!

스크린샷 2023-08-08 오전 1 17 10

  • ProxyFactoryBean 방식은 부가기능과 메소드 선정 알고리즘을 활용하는 유연한 구조를 제공
  • 메소드 선정 알고리즘을 담은 오브젝트를 포인트컷(PointCut)이라고 함.
  • 어드바이스와 포인트컷은 모두 프록시에 DI로 주입돼서 사용됨.
  • 두 가지 모두 여러 프록시에서 공유가 가능하도록 만들어지기 때문에 스프링의 싱글톤 빈으로 등록이 가능
  • 어드바이스와 포인트컷을 묶은 오브젝트를 어드바이저라고함.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
Projects
None yet
Development

No branches or pull requests

4 participants