토비의 스프링 3.1 Vol 1 (317p ~ 751p)

5장 서비스 추상화

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

  • 요구사항
    • 사용자 레벨은 BASIC, SILVER, GOLD 세 가지 중 하나다.
    • 사용자가 처음 가입하면 BASIC, 이후 활동에 따라 한 단계씩 업그레이드 가능
    • 가입 후 50회 이상 로그인을 하면 BASIC -> SILVER
    • SILVER 레벨이면서 30번 이상 추천을 받으면 GOLD
    • 사용자 레벨 변경 작업은 일정한 주기를 가지고 일괄 진행.
      • 변경 작업 전에는 조건을 충족하더라도 레벨 변경이 일어나지 않는다.
  • Level Enum 추가
    public enum Level {
      BASIC(1), SILVER(2), GOLD(3);
    
      private final int value;
    
      Level(int value) {
          this.value = value;
      }
    
      public int intValue() {
          return this.value;
      }
    
      public static Level valueOf(int value) {
          switch (value) {
              case 1 : return BASIC;
              case 2 : return SILVER;
              case 3 : return GOLD;
              default: throw new AssertionError("Unknown value: " + value);
          }
      }
    }
    
  • User Class field 추가
    public class User {
    ...
      @Column(name = "level", nullable = false)
      private Level level;
    
      @Column(name = "login", nullable = false)
      private int login;
    
      @Column(name = "recommend", nullable = false)
      private int recommend;
    ...
    }
    
  • UserJdbcDao 수정
    public class UserJdbcDao implements UserDao {
      ...
    
      private RowMapper<User> userMapper = new RowMapper<User>() {
          @Override
          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"));
              user.setLevel(Level.valueOf(rs.getInt("level")));
              user.setLogin(rs.getInt("login"));
              user.setRecommend(rs.getInt("recommend"));
              return user;
          }
      };
    
      ...
      @Override
      public void add(final User user) {
          this.jdbcTemplate.update("insert into user (id, name, password, level, login, recommend) values (?,?,?,?,?,?)",
                  user.getId(), user.getName(), user.getPassword(), user.getLevel().intValue(), user.getLogin(), user.getRecommend());
      }
    
      @Override
      public User get(String id) {
          return this.jdbcTemplate.queryForObject("select * from user where id = ?", new Object[]{id}, this.userMapper);
      }
      ...
    }
    

5.1.2 사용자 수정 기능 추가

  • Test Code 먼저 추가
    public class UserDaoTest {
      ...
    
      @Test
      public void update() {
          dao.deleteAll();
    
          dao.add(user1);
    
          user1.setName("name");
          user1.setPassword("5678");
          user1.setLevel(Level.GOLD);
          user1.setLogin(1000);
          user1.setRecommend(999);
    
          dao.update(user1);
    
          User updateUser1 = dao.get(user1.getId());
          checkSameUser(user1, updateUser1);
      }
    }
    
  • UserJdbcDao update method 추가 (interface 에도 추가)
    public class UserJdbcDao implements UserDao {
    ...
    
      @Override
      public void update(User user) {
          this.jdbcTemplate.update(
                  "update user " +
                          "set name = ?, password = ?, level = ?, login = ?, recommend = ?" +
                          "where id = ?",
                  user.getName(), user.getPassword(), user.getLevel().intValue(), user.getLogin(), user.getRecommend(), user.getId());
      }
    }
    

5.1.3 UserService.upgradeLevels()

  • 드디어 service layer 등장
    • dao 는 데이터 액세스가 목적이므로, 비지니스 로직은 해당 클래스에 어울리지 않는다.
    • 비지니스 로직을 담을 service 클래스를 추가한다.
  • UserService class
    public class UserService {
    
      private UserDao userDao;
    
      public UserService(UserDao userDao) {
          this.userDao = userDao;
      }
    
      public void upgradeLevels() {
          List<User> users = userDao.getAll();
    
          for (User user : users) {
              boolean changed = false;
    
              if (user.getLevel() == Level.BASIC && user.getLogin() >= 50) {
                  user.setLevel(Level.SILVER);
                  changed = true;
              } else if (user.getLevel() == Level.SILVER && user.getRecommend() >= 30) {
                  user.setLevel(Level.GOLD);
                  changed = true;
              }
    
              if (changed) {
                  userDao.update(user);
              }
          }
      }
    }
    
  • Test
    class UserServiceTest {
    
      @Autowired
      private UserService userService;
    
      @Autowired
      private UserDao userDao;
    
      private List<User> users;
    
      @BeforeEach
      public void setUp() {
          users = Arrays.asList(
                  new User("id1", "name1", "p1", Level.BASIC, 49, 0),
                  new User("id2", "name2", "p2", Level.BASIC, 50, 10),
                  new User("id3", "name3", "p3", Level.SILVER, 60 ,29),
                  new User("id4", "name4", "p4", Level.SILVER, 60 ,30),
                  new User("id5", "name5", "p5", Level.GOLD, 100 ,100)
          );
      }
    
      @Test
      public void upgradeLevels() {
          userDao.deleteAll();
    
          for (User user : users) {
              userDao.add(user);
          }
    
          userService.upgradeLevels();
    
          checkLevel(users.get(0), Level.BASIC);
          checkLevel(users.get(1), Level.SILVER);
          checkLevel(users.get(2), Level.SILVER);
          checkLevel(users.get(3), Level.GOLD);
          checkLevel(users.get(4), Level.GOLD);
      }
    
      private void checkLevel(User user, Level expectedLevel) {
          User userUpdate = userDao.get(user.getId());
          Assertions.assertEquals(userUpdate.getLevel(), expectedLevel);
      }
    }
    

5.1.4 UserService.add()

  • 처음 가입하는 사용자의 레벨을 BASIC 으로 설정하고 싶다.
    • DAO 에 담기에는 비지니스 로직이 개입되는 행위라 적절하지 않다.
    • 엔티티인 User 클래스에서 필드를 BASIC 으로 초기화 하는 것은 어떨까?
      • 난 좋은 것 같은데 필자는 처음 외에 무의미한 정보인데 굳이? 라는 입장
    • 비지니스 로직을 담고있는 service 에서 해보자
      • level 이 없는 경우 basic, 있는 경우 유지.
  • 테스트 클래스
    class UserServiceTest {
      ...
      @Test
      public void add() {
          userDao.deleteAll();
    
          User userWithLevel = users.get(4); // gold
          User userWithoutLevel = users.get(0);
          userWithoutLevel.setLevel(null);
    
          userService.add(userWithLevel);
          userService.add(userWithoutLevel);
    
          User userWithLevelRead = userDao.get(userWithLevel.getId());
          User userWithoutLevelRead = userDao.get(userWithoutLevel.getId());
    
          assertEquals(userWithLevelRead.getLevel(), userWithLevel.getLevel());
          assertEquals(userWithoutLevelRead.getLevel(), Level.BASIC);
      }
      ...
    }
    
  • UserService 클래스
    public class UserService {
    
      ...
    
      public void add(User user) {
          if (user.getLevel() == null) {
              user.setLevel(Level.BASIC);
          }
    
          userDao.add(user);
      }
    
      ...
    }
    

5.1.5 코드 개선

  • 작성된 코드를 살펴볼 때 아래와 같은 질문
    • 중복은 없는가
    • 코드의 행위가 이해하기 편한가
    • 코드가 자신이 있어야할 자리에 있는가
    • 변경에 유연한 구조인가
  • upgradeLevels() 리팩토링
  • upgradeLevel

  • 1 : 현재 레벨 파악
  • 2 : 업그레이드 조건 판단
  • 3 : 레벨 업그레이드
  • 4 : DB 저장
  • 성격이 다른 로직이 한데 섞여 있다.

  • 추상적으로, upgradeLevels() 에는 loop 을 돌며 업그레이드가 가능하면 업그레이드 한다. 라는 작업 흐름만 명시
List<User> users = userDao.getAll();

for (User user : users) {
    if (canUpgradeLevel(user)) {
        upgradeLevel(user);
    }
}
  • canUpgradeLevel() 메소드
private boolean canUpgradeLevel(User user) {
    Level currentLevel = user.getLevel();
    switch (currentLevel) {
        case BASIC: return (user.getLogin() >= 50);
        case SILVER: return (user.getRecommend() >= 30);
        case GOLD: return false;
        default: throw new IllegalArgumentException("Unknow Level: " + currentLevel);
    }
}
  • upgradeLevel() 메소드도 추상적인 작업흐름(업데이트, DB저장)만 명시
private void upgradeLevel(User user) {
    user.upgradeLevel();
    userDao.update(user);
}
  • User Class
public class User {
    ...
    public void upgradeLevel() {
        Level nextLevel = this.level.nextLevel();
        if (nextLevel == null) {
            throw new IllegalStateException(this.level + " is cannot upgrade.");
        }

        this.level = nextLevel;
    }
    ...
}
  • Level Enum
public enum Level {
    GOLD(3, null), SILVER(2, GOLD), BASIC(1, SILVER);

    private final int value;

    private final Level next;
    
    ...
}
  • 오브젝트와 메소드가 각각 자기 몫의 책임을 맡아 일을 하는 구조로 설계하는 것이 바람직하다.
    • 책임 분리, 변경/확장 유연성 확보
    • 데이터를 가져와서 작업하지 않고, 다른 오브젝트에 작업을 요청
  • 추가적으로, 업그레이드 정책을 유연하게 변경할 수 있도록 개선할 수 있다.
    • 연말이벤트, 홍보기간 레벨 업그레이드 정책을 다르게 적용할 수도 있기 때문
  • 업그레이드 정책 인터페이스화
public interface UserLevelUpgradePolicy {

    boolean canUpgradeLevel(User user);

    void upgradeLevel(User user);
}
  • 구현체 class
public class UserLevelUpgradePolicyDefault implements UserLevelUpgradePolicy {

    public static final int MIN_LOGIN_COUNT_FOR_SILVER = 50;
    public static final int MIN_RECOMMEND_FOR_GOLD = 30;

    private UserDao userDao;

    public UserLevelUpgradePolicyDefault(UserDao userDao) {
        this.userDao = userDao;
    }

    @Override
    public boolean canUpgradeLevel(User user) {
        Level currentLevel = user.getLevel();
        switch (currentLevel) {
            case BASIC: return (user.getLogin() >= MIN_LOGIN_COUNT_FOR_SILVER);
            case SILVER: return (user.getRecommend() >= MIN_RECOMMEND_FOR_GOLD);
            case GOLD: return false;
            default: throw new IllegalArgumentException("Unknow Level: " + currentLevel);
        }
    }

    @Override
    public void upgradeLevel(User user) {
        user.upgradeLevel();
        userDao.update(user);
    }
}
  • 서비스 class 에 DI 하여 적용
public class UserService {
    
    ...

    private UserLevelUpgradePolicy userLevelUpgradePolicy;

    ...
        public void upgradeLevels() {
        List<User> users = userDao.getAll();

        for (User user : users) {
            if (userLevelUpgradePolicy.canUpgradeLevel(user)) {
                userLevelUpgradePolicy.upgradeLevel(user);
            }
        }
    }
}

5.2 트랜잭션 서비스 추상화

  • 사용자 레벨 조정 작업 중간에 문제가 발생하면, 현재 코드는 롤백이 될까 커밋이 될까.

  • 장애상황을 연출하려 할 때, 애플리케이션 코드를 수정하는 것은 가장 쉽지만 바람직하지 않다.

  • 테스트용 클래스를 만들고, DI 하여 테스트
    class UserServiceTest {
      ...
    
      @Test
      public void upgradeAllOrNothing() {
          userService.setUserLevelUpgradePolicy(new UserLevelUpgradePolicyTest(userDao, users.get(3).getId()));
          userDao.deleteAll();
    
          for (User user : users) {
              userDao.add(user);
          }
          Assertions.assertThrows(TestUserServiceException.class, () -> {
              userService.upgradeLevels();
          });
    
          checkLevel(users.get(1), false);    // 앞에 업그레이드 한 것이 롤백이 되면 테스트가 성공.
      }
    
      static class UserLevelUpgradePolicyTest extends UserLevelUpgradePolicyDefault {
    
          private String id;
    
          public UserLevelUpgradePolicyTest(UserDao userDao, String id) {
              super(userDao);
              this.id = id;
          }
    
          @Override
          public void upgradeLevel(User user) {
              if (user.getId().equals(this.id)) {
                  throw new TestUserServiceException();
              }
    
              super.upgradeLevel(user);
          }
      }
    
      static class TestUserServiceException extends RuntimeException {
    
      }
    }
    

    테스트는 실패한다. 중간에 실패하더라도, 이미 업그레이드가 되었다.

  • 테스트 실패의 원인은 upgradLevels() 메소드가 하나의 트랜잭션 안에서 동작하기 않았기 때문

5.2.2 트랜잭션 경계 설정

  • DB는 완벽한 트랜잭션을 제공한다.
    • SQL을 이용해 다중 로우 수정, 삭제 시 부분성공/실패 하는 경우는 없다.
    • 즉, 하나의 SQL 명령을 처리하는 경우 DB가 트랜잭션을 보장한다.
  • 하지만, 여러 SQL 을 하나의 트랜잭션으로 처리해야 하는 경우
    • 첫번째 SQL 이 성공하고 두번째 SQL 이 실패하면 Rollback 이 필요.
    • 모든 SQL이 성공하였을 때 Commit.
  • JDBC 를 이용해 트랜잭션을 적용하는 간단한 예제
...
Connection c = datasource.getConnection();

c.setAutoCommit(false); // Transaction Start

try {
    PreparedStatement st1 = c.prepareStatement("update users ...");
    st1.executeUpdate();
    
    PreparedStatement st2 = c.prepareStatement("update users ...");
    st2.executeUpdate();

    c.commit(); // Transaction commit
} catch(Exception e) {
    c.rollback(); // Transaction rollback
}

c.close();
  • 애플리케이션에서 트랜잭션이 시작되고 종료되는 위치를 트랜잭션의 경계 라 한다.
    • setAutoCommit(false) 로 트랜잭션 시작을 선언하고 commit() 또는 rollback() 으로 트랜잭션을 종료하는 작업을 트랜잭션의 경계설정(transaction demarcation) 이라 한다.
    • 트랜잭션의 경계는 하나의 Connection이 만들어지고 닫히는 범위 안에 존재한다.
      • 이렇게 하나의 DB 커넥션 안에서 만들어지는 트랜잭션을 로컬 트랜잭션 이라고도 한다.
  • 현재는 DAO 메소드를 호출할 때마다 하나의 새로운 트랜잭션이 만들어지는 구조
    • DAO 메소드 안으로 upgradeLevels() 메소드 내용을 옮긴다면 비지니스 로직과 데이터 로직이 한데 묶여 섞이는 결과를 초래한다.
    • 위와 같은 방식이 아닌, 트랜잭션의 경계설정 작업을 UserService 쪽으로 가져와야 한다.
    • Connection 을 Service 에서 생성하여 파라미터로 전달한다면 문제는 해결할 수 있다.
      • 하지만 DB 커넥션을 포함한 리소스 처리를 Service 레이어에서 해야하고,
      • Connection 파라미터가 세상 끝까지 추가되어야 하고,
      • Service 레이어가 Data Access 기술에 종속적이 된다.
      • 스프링은 이 딜레마를 해결할 수 있는 멋진 방법을 제공한다.
  • 트랜잭션 동기화 방식
    1. UserService 는 Connection 생성
    2. 이를 트랜잭션 동기화 저장소에 저장해두고 Connection의 setAutoCommit(false)를 호출해 트랜잭션을 시작시키고 DAO 의 기능을 이용하기 시작.
    3. DAO 의 첫번째 update() 메소드가 호출되고, update() 메소드 내부에서 이용하는 JdbcTemplate 메소드에서는 가장먼저
    4. 트랜잭션 동기화 저장소에 현재 시작된 트랜잭션을 가진 Connection 오브젝트가 존재하는지 확인한다. (2)에서 저장해둔 Connection 을 발견하고 이를 가져온다.
    5. 가져온 Connection 을 이용하여 PreparedStatement를 만들어 수정 SQL 을 실행, 트랜잭션 동기화 저장소에서 DB 커넥션을 가져왔을 때는 JdbcTemplate은 Connection 을 닫지 않은 채로 작업을 마친다. (첫 번째 DB 작업 완료, Connection 은 열려있고, 트랜잭션은 진행 중, 트랜잭션 동기화 저장소에 저장)
    6. N번째 update()가 호출되면 이 때도 마찬가지로 작업을 한다.
    7. 트랜잭션 내 모든 작업이 정상적으로 끝났다면 UserService 는 Connection commit() 을 호출하여 트랜잭션을 완료시킨다.
    8. 마지막으로 트랜잭션 저장소가 더 이상 Connection 오브젝트를 저장해두지 않도록 이를 제거한다.
      • 어느 작업 중에라도 예외 발생 시 UserService는 즉시 Connection rollback()을 호출하고 트랜잭션을 종료할 수 있다. (이 때도 트랜잭션 저장소의 Connection 오브젝트 제거)
  • 트랜잭션 동기화 저장소는 작업 스레드마다 독립적으로 Connection 오브젝트를 저장/관리한다.
    • 멀티스레드 가능
  • 트랜잭션 동기화 방식을 적용한 upgradeLevels()
...
    public void upgradeLevels() throws Exception {
        TransactionSynchronizationManager.initSynchronization();    // 트랜잭션 동기화 관리자를 이용해 동기화 작업 초기화
        Connection c = DataSourceUtils.getConnection(dataSource);   // 커넥션 생성, 동기화
        c.setAutoCommit(false); // 트랜잭션 시작
        try {
            ...
            c.commit(); // 정상적으로 작업을 마치면 커밋
        } catch (Exception e) {
            c.rollback(); // 예외 시 롤백
            throw e;
        } finally {
            DataSourceUtils.releaseConnection(c, dataSource);   // DB 커넥션 세이프 종료
            TransactionSynchronizationManager.unbindResource(dataSource);   // 동기화 작업 종료
            TransactionSynchronizationManager.clearSynchronization();   // 정리
        }
    }
  • 한 개 이상의 DB로 작업을 하나의 트랜잭션으로 만들기 위해서는 로컬 트랜잭션 으로는 불가능하다.
    • 별도의 트랜잭션 관리자를 통해 트랜잭션을 관리하는 글로벌 트랜잭션 방식을 사용해야 한다.
    • 자바는 글로벌 트랜잭션을 지원하는 트랜잭션 매니저를 지원하기 위한 API 인 JTA(Java Transaction API) 를 제공한다.
    • 11장에서 자세히
  • JTA를 이용한 트랜잭션 코드 구조
InitialContext ctx = new InitialContext();
UserTransaction tx = (UserTransaction)ctx.lookup(USER_TX_JNDI_NAME);
tx.begin();
Connection c = dataSource.getConnection();
try {
    ...
    tx.commit();
} catch (Exception e) {
    tx.rollback();
    throw e;
} finally {
    c.close();
}
  • 또 문제는 하이버네이트를 이용한 트랜잭션 관리는 JDBC나 JTA의 코드와는 또 다르다.
    • Connection 을 직접 사용하지 않고 Session 을 사용하며 독자적인 트랜잭션 관리 API를 사용한다.

트랜잭션 API의 의존관계 문제와 해결책

  • 트랜잭션의 경계설정 필요가 생기면서 특정 데이터 액세스 기술에 종속되는 구조가 된다.
  • 트랜잭션의 경계설정을 담당하는 코드는 일정한 패턴을 갖는 유사한 구조다.
    • 이렇게 여러 기술의 사용방법에 공통점이 있다면 추상화 를 생각해볼 수 있다.
    • 추상화 란 하위 시스템의 공통점을 뽑아내서 분리시키는 것을 말한다.
      • DB에서 제공하는 DB 클라이언트, 라이브러리, API는 서로 전혀 호환이 되지 않는 독자적인 방식으로 만들어져9 있다. 하지만 모두 SQL 을 이용하는 방식이라는 공통점이 있다.
      • 이 공통점을 추출하여 추상화 한 것이 JDBC
      • JDBC 라는 추상화 기술이 있기 때문에 자바는 DB의 종류와 상관없이 일관된 방법으로 데이터 액세스 코드를 작성할 수 있다.
  • 스프링의 트랜잭션 추상화 API 를 적용
public void upgradeLevels() {
    PlatformTransactionManager transactionManager = new DataSourceTransactionManager(dataSource);
    TransactionStatus status = transactionManager.getTransaction(new DefaultTransactionDefinition());

    try {
        List<User> users = userDao.getAll();

        for (User user : users) {
            if (userLevelUpgradePolicy.canUpgradeLevel(user)) {
                userLevelUpgradePolicy.upgradeLevel(user);
            }
        }
        transactionManager.commit(status);
    } catch (RuntimeException e) {
        transactionManager.rollback(status);
        throw e;
    }
}
  • 트랜잭션 경계설정을 위한 추상 인터페이스 PlatformTransactionManager
    • JDBC의 로컬 트랜잭션을 이용한다면 구현체 DataSourceTransactionManager 사용
    • 필요에 따라 트랜잭션 매니저가 커넥션을 가져오는 작업도 함께 수행한다.
    • 트랜잭션을 가져올 때 transactionManager.getTransaction(new DefaultTransactionDefinition()); 일단은 트랜잭션을 시작한다는 의미라고 이해하면 된다고 한다.
    • 이렇게 시작된 트랜잭션은 TransactionStatus status 변수에 저장된다.
  • JTA를 이용한 글로벌 트랜잭션으로 변경하려면 구현체를 JTATransactionManager 로 변경하기만 하면 된다.
    • 하이버네이트를 사용했다면 HibernateTransactionManager, JPA를 사용했다면 JPATransactionManager
    • 구현체가 여러가지이고, 그걸 service 에서 직접 구현하는 걸 보니 DI 하고싶다!
      • 스프링의 빈으로 등록할 때 먼저 검토해야 할 것은 싱글톤으로 멀티스레드에 사용해도 괜찮은가 이다.
      • 스프링에서 제공하는 모든 PlatformTransactionManager 의 구현체는 싱글톤으로 사용이 가능하다.

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

  • 기술과 서비스에 추상화 기법을 이용하면 특정 기술환경에 종속되지 않는 포터블한 코드를 만들 수 있다.

  • UserDao와 UserService는 각각 담당하는 코드의 기능적인 관심에 따라 분리되고, 서로 불필요한 영향을 주지 않으면서 독자적으로 확장이 가능하도록 만든 것이다.
    • 같은 애플리케이션 로직을 담은 코드지만, 내용에 따라 분리했다.
    • 같은 계층에서 수평적인 분리라고 볼 수 있다.
  • 트랜잭션의 추상화는 이와 조금 다르다.
    • 애플리케이션의 비지니스 로직과 그 하위에서 동작하는 로우레벨의 트랜잭션 기술이라는 아예 다른 계층의 특성을 갖는 코드를 분리한 것이다.
  • 애플리케이션 로직의 종류에 따른 수평적인 구분이든, 로직과 기술이라는 수직적인 구분이든 모두 결합도가 낮으며, 서로 영향을 주지 않고 자유롭게 확장할 수 있는 구조를 만들 수 있는데는 스프링의 DI가 중요한 역할을 하고 있다.
    • DI의 가치는 이렇게 관심, 책임, 성격이 다른 코드를 깔끔하게 분리하는 데 있다.

단일 책임 원칙

  • 이러한 적절한 분리가 가져오는 특징은 객체지향 설계의 원칙 중의 하나인 단일 책임 원칙(SRP) 으로 설명할 수 있다.
    • 모듈은 한 가지 책임을 가져야 한다.
    • 모듈이 변경되는 이유는 한 가지다.
  • UserService 에 JDBC Connection의 메소드를 직접 사용하는 트랜잭션 코드가 들어있었을 때
    • 사용자 레벨을 어떻게 관리할 것인가
    • 트랜잭션을 어떻게 관리할 것인가
    • 이렇게 두 가지 책임을 가지고 있었다.
      • 사용자 레벨 관리 방식의 변경이 없어도, 트랜잭션 기술을 변경해야 한다면 해당 클래스가 변경되어야 한다. 즉, 변경의 이유가 두 가지
  • 단일 책임 원칙의 장점
    • 어떤 변경이 필요할 때 수정 대상이 명확해진다.
    • 기술이 바뀌면, 기술계층과의 연동을 담당하는 기술 추상화 계층의 설정만 바꿔주면 된다.
  • 객체지향 설계와 프로그래밍의 원칙은 서로 긴밀하게 연결되어 있다.
    • 단일 책임 원칙을 잘 지키는 코드를 만들려면 interface를 도입하고 이를 DI로 연결해야 하며, 그 결과로 단일 책임 원칙 뿐 아니라 개방폐쇄 원칙도 잘 지키고, 모듈 간 결합도가 낮아 서로의 변경이 영향을 주지 않고, 같은 이유로 변경이 단일 책임에 집중되는 응집도 높은 코드가 나온다.

5.4. 메일 서비스 추상화

  • Spring 이 직접 제공하는 MailSender 를 구현한 추상화 클래스는 JavaMailServiceImple 하나뿐이다.
    • 그럼에도 불구하고 이 추상화된 메일 전송 기능을 사용해 애플리케이션을 작성함으로써 얻을 수 있는 장점은 크다.
      • 테스트 시 실제 메일이 발송되지 않게
      • JavaMail 이 아닌 다른 메시징 서버의 API 를 이용
      • 메일을 바로 전송하지 않고 큐에 담아뒀다가 정해진 시간에 발송
      • 다양한 추가/변경 작업 시 MailSender로 추상화된 메일 전송 추상화 계층이 도움이된다.
  • 기술이나 환경이 바뀔 가능성이 있음에도, JavaMail 처럼 확장이 불가능하게 설계해놓은 API를 사용해야 하는 경우라면 추상화 계층의 도입을 적극 고려해볼 필요가 있다.
    • 외부 리소스와 연동하는 대부분 작업은 추상화 대상이 될 수 있다.
  • 테스트용으로 사용되는 특별한 오브젝트들이 있다.
    • 대부분 테스트 대상인 오브젝트의 의존 오브젝트가 되는 것들
    • 테스트환경을 만들어주기 위해, 테스트 대상이 되는 오브젝트의 기능에만 충실하게 수행하면서 빠르게, 자주 테스트를 실행할 수 있도록 사용하는 이런 오브젝트들을 통틀어서 테스트 대역(test double) 이라고 부른다.
    • 대표적인 테스트 대역은 테스트 스텁(test stub) 이다.
      • 테스트 스텁은 테스트 대상 오브젝트의 의존객체로서 존재하면서 테스트 동안에 코드가 정상적으로 수행할 수 있도록 돕는 것을 말한다.
      • 간접적인 입력값을 제공
    • Mock Object
      • 테스트 대상 오브젝트와 의존 오브젝트 사이에서 일어나는 일을 검증할 수 있도록 특별히 설계
      • 테스트 오브젝트와 자신의 사이에서 일어나는 커뮤니케이션을 저장해뒀다가 테스트 결과를 검증하는 데 활용할 수 있게 해준다.
      • 간접적인 출력 값까지 확인이 가능

6장 AOP

6.1 트랜잭션 코드의 분리

6.1.1 메소드 분리

  • UserService 의 트랜잭션 경계설정 코드와 비즈니스 로직 코드가 복잡하게 얽혀 있는 듯 보이지만, 뚜렷하게 두 가지 종류의 코드가 구분되어 있다.

  • 비즈니스 로직 코드를 사이에 두고 트랜잭션의 시작과 종료를 담당

  • 트랜잭션 경계설정 코드와 비즈니스 로직 코드 간에 서로 주고받는 정보가 없이 독립적이다.

  • 비즈니스 로직만 추출한 예

public void upgradeLevels() {
    TransactionStatus status = transactionManager.getTransaction(new DefaultTransactionDefinition());

    try {
        upgradeLevelsInternal();    // here!!
        transactionManager.commit(status);
    } catch (RuntimeException e) {
        transactionManager.rollback(status);
        throw e;
    }
}

private void upgradeLevelsInternal() {
    List<User> users = userDao.getAll();

    for (User user : users) {
        if (userLevelUpgradePolicy.canUpgradeLevel(user)) {
            userLevelUpgradePolicy.upgradeLevel(user);
        }
    }
}

6.1.2 DI를 이용한 클래스 분리

  • 여전히 트랜잭션을 담당하는 기술적 코드가 UserService 클래스에 있다.

  • 여태 DI를 적용했던 이유는 일반적으로 구현 클래스를 바꿔가면서 사용하기 위해서
    • 테스트에 따라 테스트 구현클래스 등
    • 하지만 꼭 이것 때문에 DI를 쓰는건 아님
  • UserService 의 책임을 나눈 구현 클래스를 두 개 만든다.
    • UserServiceTx
  • UserService interface
public interface UserService {
    void add(User user);
    void upgradeLevels();
}
  • 비즈니스 로직만 들고 있는 UserServiceImpl
public class UserServiceImpl implements UserService {

    ...

    @Override
    public void upgradeLevels() {
        List<User> users = userDao.getAll();

        for (User user : users) {
            if (userLevelUpgradePolicy.canUpgradeLevel(user)) {
                userLevelUpgradePolicy.upgradeLevel(user);
            }
        }
    }

    @Override
    public void add(User user) {
        if (user.getLevel() == null) {
            user.setLevel(Level.BASIC);
        }

        userDao.add(user);
    }

    ...
}
  • 트랜잭션 경계설정 + 작업 위임하는 UserServiceTx
public class UserServiceTx implements UserService {

    private UserService userService;

    ...

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

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

6.2. 고립된 단위 테스트

  • 가장 편하고 좋은 테스트 방법은 가능한 한 작은 단위로 쪼개서 테스트 하는 것.
    • 좋은 이유는, 테스트가 실패했을 때 그 원인을 찾기 쉽기 때문
    • 의도나 내용이 분명, 만들기도 쉬워짐
  • asis upgradeLevels() 테스트
    • upgradeLevelsTest
      1. 테스트용 정보를 DB에 넣는다.
      2. 메일 발송 여부를 확인하기 위해 MailSender Mock Object 를 DI
      3. 실제 테스트 대상인 userService 메소드 실행
      4. DB에서 데이터를 가져와 결과 확인
      5. Mock 오브젝트에서 데이터를 가져와 UserService 에 의한 메일 발송이 있었는지 확인
  • mock userDao
static class MockUserDao implements UserDao {
    private List<User> users;

    private List<User> updated = new ArrayList<>();

    private MockUserDao(List<User> users) {
        this.users = users;
    }

    public List<User> getUpdated() {
        return this.updated;
    }

    public List<User> getAll() {
        return this.users;
    }

    public void update(User user) {
        updated.add(user);
    }

    public void add(User user) { throw new UnsupportedOperationException(); };
    public void deleteAll() { throw new UnsupportedOperationException(); };
    public User get(String id) { throw new UnsupportedOperationException(); };
    public int getCount() { throw new UnsupportedOperationException(); };
}
  • upgradeLevels test use mock userDao
public void upgradeLevels() {
    UserServiceImpl userServiceImpl = new UserServiceImpl(new MockUserDao(this.users));

    ...

    userServiceImpl.upgradeLevels();

    List<User> updated = mockUserDao.getUpdated();
    assertEquals(updated.size(), 2);
    assertEquals(updated.get(0).getLevel(), Level.SILVER);
    assertEquals(updated.get(1).getLevel(), Level.GOLD);

    ...
}
  • 테스트 대역 오브젝트를 이용해 완전히 고립된 테스트를 만듬
    • 스프링 컨테이너에서 빈을 가져올 필요가 없다.
    • 테스트 수행 성능이 향상된다.
      • 테스트가 빨리 돌아가면 부담 없이 자주 테스트를 돌려볼 수 있다.
    • 의존 대상 영향을 고려하지 않아도 된다.
  • 고립된 테스트를 만들려면 목 오브젝트 작성과 같은 약간의 수고가 더 필요할지 모르나, 그 보상은 충분히 기대할 만하다.

Mockito 프레임워크

  • mock 생성
UserDao mockUserDao = mock(UserDao.class);
  • 메소드 리턴값 정의
when(mockUserDao.getAll()).thenReturn(this.users);
  • 메소드 호출 횟수 확인
verify(mockUserDao, times(2)).update(any(User.class));
  • 메소드 파라미터 캡쳐
ArgumentCaptor<SimpleMailMessage> mailMessageArg = ArgumentCaptor.forClass(SimpleMailMessage.class);
verify(mockMailSender, times(2)).send(mailMessageArg.capture())
List<SimpleMailMessage> mailMessages = mailMessageArg.getAllValues();

// assert

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

  • 트랜잭션이라는 부가기능을 UserServiceTx, 핵심기능을 UserServiceImpl 로 분리했다.
  • 중요한 특징이 있다.
    • 부가기능 외에 나머지 모든 기능은 원래 핵심 기능을 가진 클래스로 위임.
    • 즉, 부가기능이 핵심기능을 사용하는 구조가 된다.
    • 클라이언트가 핵심기능을 가진 클래스를 직접 사용해버리면 부가기능 적용 기회가 없다.
    • 그래서 부가기능은 마치 자신이 핵심기능을 가진 클래스인 것처럼 꾸며서, 클라이언트가 자신을 사용하도록 만들어야 한다.
  • 이렇게 마치 자신이 클라이언트가 사용하려고 하는 실제 대상인 것 처럼 위장해서 클라이언트의 요청을 받아주는 것을 프록시(proxy) 라 부른다.
    • 그리고 프록시를 통해 최종적으로 요청을 위임받아 처리하는 실제 오브젝트를 타깃(target), 실체(real object) 라 부른다.
  • 프록시의 특징은 타깃과 같은 인터페이스를 구현했다는 것과, 프록시가 타깃을 제어할 수 있는 위치에 있다는 것.

  • 프록시의 사용 목적
    • 클라이언트가 타깃에 접근하는 방법을 제어
    • 타깃에 부가적인 기능을 부여하기 위해
데코레이터 패턴
  • 타깃에 부가적인 기능을 런타임 시 다이내믹하게 부여해주기 위해 프록시를 사용하는 패턴
    • 다이내믹하게 <- 의 의미는 컴파일 시점(코드 상) 에는 어떤 방법과 순서로 프록시와 타깃이 연결되어 사용되는지 정해져 있지 않다는 뜻
  • 실제 내용물은 동일하지만 부가적인 효과를 부여

  • 프록시가 꼭 한개로 제한되지는 않는다.

  • 예를들어 소스코드를 출력하는 기능을 가진 핵심 기능
    • 데코레이터 개념을 부여, 타깃과 같은 인터페이스를 구현, 런타임 시 이를 적절한 순서로 조합해서 사용
      • 소스코드에 라인넘버를 붙여주는 데코레이터 -> 신텍스 하이리팅 하는 데코레이터 -> 페이징 데코레이터 -> 핵심 기능 (타깃)
  • 데코레이터의 다음 위임 대상은 인터페이스로 주입 받아야 한다.
    • 다음 대상이 최종 타깃인지, 다음단계 데코레이터 프록시인지 알지 못하기 때문
  • 데코레이터 패턴은 타깃의 코드를 손대지 않고, 클라이언트가 호출하는 방법도 변경하지 않은 채로 새로운 기능을 추가할 때 유용한 방법이다.
프록시 패턴
  • 프록시라는 용어와 디자인 패턴의 프록시 패턴은 다르다.

  • 프록시 패턴은 타깃에 대한 접근방법을 제어하려는 목적을 가진 경우를 가리킨다.
    • 타깃의 기능을 확장하거나 추가하지 않는다.
    • 대신 클라이언트가 타깃에 접근하는 방식을 변경해준다.
  • 타깃 오브젝트를 생성하기가 복잡하거나 당장 필요하지 않으나 오브젝트의 레퍼런스가 미리 필요한 경우, 실제 타깃 오브젝트 대신 프록시를 넘겨주는 것.
    • 프록시의 메소드를 통해 타깃을 사용하려고 시도하면, 그때 프록시가 타깃 오브젝트를 생성하고 요청을 위임해주는 식
  • 레퍼런스를 갖고 있지만 끝까지 사용하지 않거나, 많은 작업이 진행된 후에 사용되는 경우라면 생성을 최대한 늦춤으로써 얻는 장점이 많다.

  • 원격 오브젝트를 이용하는 경우에도 프록시를 사용하면 편리하다.
    • 다른 서버에 존재하는 원격 오브젝트에 대한 프록시를 만들어두고, 클라이언트는 마치 로컬에 존재하는 오브젝트를 쓰는 것처럼
  • 타깃에 대한 접근권한을 제어하기 위해 프록시 패턴 사용
    • 특정 레이어에서 읽기전용으로 동작하는 경우
  • 이렇게 프록시 패턴은 타깃의 기능 자체에는 관여하지 않으면서 접근하는 방법을 제어해주는 프록시를 이용하는 것이다.

  • 타깃과 동일한 인터페이스를 구현하고 클라이언트와 타깃 사이에 존재하면서 부가 기능 또는 접근 제어를 담당하는 오브젝트는 모두 프록시

6.3.2 다이내믹 프록시

  • 프록시는 다음 두 가지 기능으로 구성된다.
    • 타깃과 같은 메소드를 구현하고 있다가 메소드가 호출되면 타깃 오브젝트로 위임.
    • 지정된 요청에 대해서는 부가기능 수행.
  • 프록시가 만들기 번거로운 이유
    1. 타깃의 인터페이스를 구현하고 위임하는 코드 작성이 번거롭다.
      • 부가기능이 필요없는 메소드도 구현, 위임 필요
    2. 부가기능 코드의 중복 가능성
  • 목 오브젝트와 비슷하게 프록시를 손쉽게 만드는 것을 도와주는 API 가 존재한다
    • JDK 의 다이내믹 프록시
  • 다이내믹 프록시는 리플렉션 기능을 이용해서 프록시를 만들어준다.
    • 리플렉션은 자바의 코드 자체를 추상화해서 접근하도록 만든 것.
    • 학습 테스트
      @Test
      public void invokeMethod() throws Exception {
          String name = "Spring";
    
          assertEquals(name.length(), 6);
    
          Method lengthMethod = String.class.getMethod("length");
          assertEquals((Integer)lengthMethod.invoke(name), 6);
    
          assertEquals(name.charAt(0), 'S');
    
          Method charAtMethod = String.class.getMethod("charAt", int.class);
          assertEquals((Character)charAtMethod.invoke(name, 0), 'S');
      }
    

    invoke() 메소드는 메소드를 실행시킬 대상 오브젝트와 파라미터 목록을 받아 메소드를 호출한 뒤 그 결과를 Object 타입으로 돌려준다.

  • 다이내믹 프록시 학습테스트 예제

    • Hello Interface
      public interface Hello {
    
          String sayHello(String name);
    
          String sayHi(String name);
    
          String sayThankYou(String name);
      }
    
    • 타깃 클래스
      public 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;
          }
      }
    
    • 프록시
      public class UppercaseHandler implements InvocationHandler {
    
          Object target;
    
          public UppercaseHandler(Object target) {
              this.target = target;
          }
    
          @Override
          public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
              Object ret = method.invoke(target, args);
              if (ret instanceof  String && method.getName().startsWith("say")) {
                  return ((String)ret).toUpperCase();
              } else {
                  return ret;
              }
          }
      }
    
    • 클라이언트 (학습테스트)
      @Test
      public void simpleProxy() {
          Hello hello = new HelloTarget();
          assertEquals(hello.sayHello("Toby"), "Hello Toby");
          assertEquals(hello.sayHi("Toby"), "Hi Toby");
          assertEquals(hello.sayThankYou("Toby"), "Thank You Toby");
    
          Hello proxiedHello = (Hello) Proxy.newProxyInstance(getClass().getClassLoader(), new Class[] {
                  Hello.class
          }, new UppercaseHandler(new HelloTarget()));
    
          assertEquals(proxiedHello.sayHello("Toby"), "HELLO TOBY");
          assertEquals(proxiedHello.sayHi("Toby"), "HI TOBY");
          assertEquals(proxiedHello.sayThankYou("Toby"), "THANK YOU TOBY");
      }
    

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

  • UserServiceTx 를 다이내믹 프록시 방식으로 변경해보자.
@Test
@DirtiesContext
public void upgradeAllOrNothing() {
    UserService target = new UserServiceImpl(userDao, new UserLevelUpgradePolicyTest(userDao, users.get(3).getId()));
    TransactionHandler txHandler = new TransactionHandler(target, new DataSourceTransactionManager(dataSource), "upgradeLevels");
    UserService txUserService = (UserService) Proxy.newProxyInstance(getClass().getClassLoader()
            , new Class[] {UserService.class}, txHandler);

    userDao.deleteAll();

    for (User user : users) {
        userDao.add(user);
    }
    Assertions.assertThrows(TestUserServiceException.class, txUserService::upgradeLevels);

    checkLevelUpgraded(users.get(1), false);
}

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

  • 사전에 프록시 오브젝트의 클래스 정보를 미리 알아내서 스프링 빈에 정의할 방법이 없다.

  • 다이내믹 프록시는 Proxy 클래스의 newProxyInstance() 라는 스태틱 팩토리 메소드를 통해서만 만들 수 있다.

  • 팩토리 빈
    • 스프링을 대신해서 오브젝트의 생성로직을 담당하도록 만들어진 특별한 빈
    • 팩토리 빈을 만드는 여러 방법 중 스프링의 FactoryBean 인터페이스를 구현할 수도 있다.
    • FactoryBaen 인터페이스
        public interface FactoryBean<T> {
      
            String OBJECT_TYPE_ATTRIBUTE = "factoryBeanObjectType";
      
            @Nullable
            T getObject() throws Exception; // 빈 오브젝트 생성, return
      
            @Nullable
            Class<?> getObjectType();   //  생성되는 오브젝트 타입
      
            default boolean isSingleton() {  // getObject()가 항상 같은 오브젝트를 리턴하는가
                return true;
            }
        }
      
  • 학습 테스트
    • private 생성자 클래스
      public class Message {
    
          private String text;
    
          private Message(String text) {
              this.text = text;
          }
    
          public String getText() {
              return text;
          }
    
          public static Message newMessage(String text) {
              return new Message(text);
          }
      }
    
    • 사실 스프링은 private 생성자를 가진 클래스도 빈으로 등록하면 리플렉션을 사용해 오브젝트를 만들 수 있다.
    • 하지만 private 로 선언되었다는 것은 스테틱 메소드를 통해 오브젝트가 만들어져야 하는 중요한 이유가 있을 수 있으므로 이를 무시하고 강제로 오브젝트 생성하는 것은 위험하다.
    • Message 클래스의 오브젝트를 생성해주는 팩토리 빈 클래스
      public class MessageFactoryBean implements FactoryBean<Message> {
    
          private String text;
    
          public MessageFactoryBean(String text) {
              this.text = text;
          }
    
          @Override
          public Message getObject() throws Exception {
              return Message.newMessage(this.text);
          }
    
          @Override
          public Class<?> getObjectType() {
              return Message.class;
          }
    
          @Override
          public boolean isSingleton() {
              return false;
          }
      }
    
  • 다이내믹 프록시를 만들어주는 팩토리 빈
public class TxProxyFactoryBean implements FactoryBean<Object> {

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

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

    public TxProxyFactoryBean(Object target, PlatformTransactionManager transactionManager, String pattern, Class<?> serviceInterface) {
        this.target = target;
        this.transactionManager = transactionManager;
        this.pattern = pattern;
        this.serviceInterface = serviceInterface;
    }

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

    @Override
    public Class<?> getObjectType() {
        return serviceInterface;
    }
}
  • 예외 발생 시 트랜잭션 롤백됨을 확인하려면 테스트 코드에서 FactoryBean 을 DI 받아 TestUserService 오브젝트를 타깃 오브젝트로 Object를 생성하면 된다.

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

  • 장점
    • 한 번 부가기능을 가진 프록시를 생성하는 팩토리 빈을 만들어두면 타깃의 타입에 상관없이 재사용할 수 있다.
      • TransactionHandler 를 이용하는 다이내믹 프록시를 생성해주는 TxProxytFactoryBean 은 코드의 수정 없이도 다양한 클래스에 적용할 수 있다. 타깃 오브젝트만 맞도록 변경하여 빈으로 등록해주면 된다.
    • 다이내믹 프록시를 사용하면 타깃 인터페이스를 구현하는 클래스를 일일이 만드는 번거로움을 제거할 수 있다.
    • 하나의 핸들러 메소드를 구현하는 것만으로도 수많은 메소드에 부가기능을 부여해줄 수 있으니 부가기능 코드 중복 문제도 해결된다.
    • DI 덕분이다. DI 짱.
  • 한계
    • 한 번에 여러 개의 클래스에 공통적인 부가기능을 제공할 수 없다.
      • 타깃 오브젝트는 하나.
    • 하나의 타깃에 여러 개의 부가기능을 적용하려고 할 때 문제다.
      • 프록시 팩토리 빈 설정(xml, 코드 등)이 부가기능의 개수만큼 늘어난다.
      • 설정파일이 복잡해진다.
      • TransactionHandler 가 프록시 팩토리 빈 개수만큼 만들어진다.

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

  • 스프링은 프록시 오브젝트를 생성해주는 기술을 추상화한 팩토리 빈을 제공한다.
  • 스프링의 ProxyFactoryBean 은 프록시를 생성해서 빈 오브젝트로 등록하게 해주는 팩토리 빈이다. 기존에 만들었던 TxProxyFactoryBean과 달리, ProxyFactoryBean은 순수하게 프록시를 생성하는 작업만 담당하고, 프록시를 통해 제공해줄 부가기능은 별도의 빈에 둘 수 있다.
    • 부가기능은 MethodInterceptor 인터페이스를 구현
      • InvocationHandler 와 비슷하지만, MethodInterceptor 의 invoke() 메소드는 ProxyFactoryBean 으로부터 타깃 오브젝트에 대한 정보까지 함께 제공받는다.
        • 덕분에 타깃 오브젝트에 상관없이 독립적으로 만들어질 수 있다.
@Test
public void proxyFactoryBean() {
    ProxyFactoryBean pfBean = new ProxyFactoryBean();
    pfBean.setTarget(new HelloTarget());
    pfBean.addAdvice(new UppercaseAdvice());

    Hello proxiedHello = (Hello) pfBean.getObject();
    assertEquals(proxiedHello.sayHello("Toby"), "HELLO TOBY");
    assertEquals(proxiedHello.sayHi("Toby"), "HI TOBY");
    assertEquals(proxiedHello.sayThankYou("Toby"), "THANK YOU TOBY");
}

static class UppercaseAdvice implements MethodInterceptor {

    @Override
    public Object invoke(MethodInvocation invocation) throws Throwable {
        String ret = (String)invocation.proceed();
        return ret.toUpperCase();
    }
}
  • 어드바이스: 타깃이 필요없는 순수한 부가기능
    • MethodInvocation 은 타깃오브젝트가 등장하지 않는다. 타깃오브젝트의 메소드를 실행할 수 있는 기능이 있기 때문에 부가기능 제공하는 데만 집중할 수 있다.
      • MethodInvocation 의 구현체는 일종의 공유 가능한 템플릿 처럼 동작한다. (템플릿/콜백 패턴)
    • 어드바이스는 타깃 오브젝트에 적용되지만 종속되지 않는 순수한 부가기능을 담은 오브젝트
  • 포인트컷: 부가기능 적용 대상 메소드 선정 방법
    • TxProxyFactoryBean 에서는 pattern 이라는 String 으로 메소드를 선정했었다.
    • MethodInterceptor 는 재사용 가능한 순수한 부가기능 제공 코드만 남기기 위해, 다이내믹프록시가 MethodInterceptor 를 호출하기 전 메소드 선정 알고리즘을 담은 오브젝트를 호출하는 구조를 제공한다. 이를 포인트컷이라고 한다.
    • 프록시는 클라이언트로부터 요청을 받으면 먼저 포인트컷에게 부가기능을 부여할 메소드인지 확인 요청을 한다. 적용 대상 메소드라면 MethodInterceptor 타입의 어드바이스를 호출한다.
      @Test
      public void proxyFactoryBean() {
          ProxyFactoryBean pfBean = new ProxyFactoryBean();
          pfBean.setTarget(new HelloTarget());
    
          NameMatchMethodPointcut pointcut = new NameMatchMethodPointcut();
          pointcut.setMappedName("sayH*");
    
          pfBean.addAdvisor(new DefaultPointcutAdvisor(pointcut, new UppercaseAdvice()));
    
          Hello proxiedHello = (Hello) pfBean.getObject();
          assertEquals(proxiedHello.sayHello("Toby"), "HELLO TOBY");
          assertEquals(proxiedHello.sayHi("Toby"), "HI TOBY");
          assertEquals(proxiedHello.sayThankYou("Toby"), "Thank You Toby");
      }
    
    • addAdvice() 메소드를 호출하지않고 Advisor 타입으로 addAdvisor() 메소드를 호출했다.
    • 여러 개의 어드바이스와 포인트 컷이 추가 될 때, 어떤 어드바이스에 어떤 포인트컷을 적용할지 명확하게 하기 위해 이런 방식을 사용.
    • 이렇게 어드바이스와 포인트컷을 묶은 오브젝트를 어드바이저 라 한다.
    • 어드바이저 = 포인트컷(메소드 선정 알고리즘) + 어드바이스(부가기능)
  • TransactionAdvice
public class TransactionAdvice implements MethodInterceptor {

    private PlatformTransactionManager transactionManager;

    public TransactionAdvice(PlatformTransactionManager transactionManager) {
        this.transactionManager = transactionManager;
    }

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

        try {
            Object ret = invocation.proceed();
            this.transactionManager.commit(status);
            return ret;
        } catch (RuntimeException e) {
            this.transactionManager.rollback(status);
            throw e;
        }
    }
}
  • Config
@Configuration
public class WebConfiguration {
    ...

    @Bean
    public ProxyFactoryBean proxyFactoryBean() {
        ProxyFactoryBean pfBean = new ProxyFactoryBean();
        pfBean.setTarget(this.userServiceImpl());

        NameMatchMethodPointcut pointcut = new NameMatchMethodPointcut();
        pointcut.setMappedName("upgrade*");

        pfBean.addAdvisor(new DefaultPointcutAdvisor(pointcut, new TransactionAdvice(new DataSourceTransactionManager(this.dataSource()))));
        return pfBean;
    }

    @Bean
    public UserService userService() {
        return (UserService) this.proxyFactoryBean().getObject();
    }

    ...
}
  • UserService 외에도 이미 만들어둔 TransactionAdvice 를 재사용할 수 있다.

6.5 스프링 AOP

  • 중복도 제거하고 적절하게 잘 만들어 왔지만, 일정한 타깃 목록이 있을 때 ProxyFactoryBean 타입 빈 설정을 매번 추가해서 프록시를 만들어야 하는게 마음에 안든다.

  • 빈 후처리기를 사용하면 이를 해결가능하다.
    • DefaultAdvisorAutoProxyCreator 빈 후처리기를 등록하면, 스프링은 빈 오브젝트를 만들 때마다 후처리기에게 빈을 보낸다.
    • DefaultAdvisorAutoProxyCreator 는 빈으로 등록된 모든 어드바이저 내의 포인트컷을 이용해 전달받은 빈이 프록시 적용대상인지 확인한다.
      • 포인트컷은 두 가지 기능이 있다.
        • MethodMatcher : 어드바이스를 적용할 메소드인지 확인
        • ClassFilter : 프록시를 적용할 클래스인지 확인
  • 포인트컷 테스트
public class DynamicProxyTest {

    @Test
    public void classNamePointcutAdvisor() {
        NameMatchMethodPointcut classMethodPointcut = new NameMatchMethodPointcut() {
            public ClassFilter getClassFilter() {
                return new ClassFilter() {
                    @Override
                    public boolean matches(Class<?> clazz) {
                        return clazz.getSimpleName().startsWith("HelloT");
                    }
                };
            }
        };

        classMethodPointcut.setMappedName("sayH*");
        checkAdviced(new HelloTarget(), classMethodPointcut, true);

        class HelloWorld extends HelloTarget {};
        checkAdviced(new HelloWorld(), classMethodPointcut, false);

        class HelloToby extends HelloTarget {};
        checkAdviced(new HelloToby(), classMethodPointcut, true);
    }

    private void checkAdviced(Object target, Pointcut pointcut, boolean adviced) {
        ProxyFactoryBean pfBean = new ProxyFactoryBean();
        pfBean.setTarget(target);
        pfBean.addAdvisor(new DefaultPointcutAdvisor(pointcut, new UppercaseAdvice()));
        Hello proxiedHello = (Hello) pfBean.getObject();

        if (adviced) {
            assertEquals(proxiedHello.sayHello("Toby"), "HELLO TOBY");
            assertEquals(proxiedHello.sayHi("Toby"), "HI TOBY");
            assertEquals(proxiedHello.sayThankYou("Toby"), "Thank You Toby");
        } else {
            assertEquals(proxiedHello.sayHello("Toby"), "Hello Toby");
            assertEquals(proxiedHello.sayHi("Toby"), "Hi Toby");
            assertEquals(proxiedHello.sayThankYou("Toby"), "Thank You Toby");
        }
    }
}
  • DefaultAdvisorAutoProxyCreator 적용
public class NameMatchClassMethodPointcut extends NameMatchMethodPointcut {

    public void setMappedClassName(String mappedClassName) {
        this.setClassFilter(new SimpleClassFilter(mappedClassName));
    }

    static class SimpleClassFilter implements ClassFilter {
        private String mappedName;

        public SimpleClassFilter(String mappedName) {
            this.mappedName = mappedName;
        }

        @Override
        public boolean matches(Class<?> clazz) {
            return PatternMatchUtils.simpleMatch(mappedName, clazz.getSimpleName());
        }
    }
}
@Configuration
public class WebConfiguration {
    ...

    @Bean
    public DefaultAdvisorAutoProxyCreator defaultAdvisorAutoProxyCreator() {
        return new DefaultAdvisorAutoProxyCreator();
    }

    @Bean
    public Pointcut nameMatchClassMethodPointcut() {
        NameMatchClassMethodPointcut pointcut = new NameMatchClassMethodPointcut();
        pointcut.setMappedName("upgrade*");
        pointcut.setMappedClassName("*ServiceImpl");
        return pointcut;
    }

    @Bean
    public DefaultPointcutAdvisor testTransactionAdvisor() {
        return new DefaultPointcutAdvisor(this.nameMatchClassMethodPointcut(), new TransactionAdvice(new DataSourceTransactionManager(this.dataSource())));
    }

    ...
}
  • 이렇게 하면 DefaultAdvisorAutoProxyCreator 친구가 Advisor 인터페이스를 구현한 것을 모두 찾아 생성되는 빈에 어드바이저의 포인트컷을 적용해보면서 프록시 적용 대상을 선정한다.

  • 하지만 특정 상황에 exception 을 발생하는 테스트를 만들기가 어려워져서, TestUserService 를 구현했다.

public class TestUserLevelUpgradePolicy extends UserLevelUpgradePolicyDefault {

    private String id;

    public TestUserLevelUpgradePolicy(UserDao userDao) {
        super(userDao);
        this.id = "id4";
    }

    @Override
    public void upgradeLevel(User user) {
        if (user.getId().equals(this.id)) {
            throw new TestUserServiceException();
        }

        super.upgradeLevel(user);
    }
}
@Configuration
public class WebConfiguration {
    @Bean
    public UserService testUserService() {
        return new UserServiceImpl(this.userDao(), new TestUserLevelUpgradePolicy(this.userDao()));
    }
}
@SpringBootTest(classes = Application.class)
class UserServiceTest {

    ...

    @Autowired
    private UserService testUserService;
    
    ...

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

        Assertions.assertThrows(TestUserServiceException.class, testUserService::upgradeLevels);
        checkLevelUpgraded(users.get(1), false);
    }
}

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

  • 포인트컷 지시자 중 가장 대표적으로 사용되는 것은 execution()
    • excution([접근제한자 패턴] 리턴값타입패턴 [클래스타입패턴.]메소드이름패턴 (파라미터타입패턴 | "..", ...)) [throws 예외 패턴])
      public class PointcutTest {
    
          @Test
          public void methodSignaturePointcut() throws SecurityException, NoSuchMethodException {
              AspectJExpressionPointcut pointcut = new AspectJExpressionPointcut();
              System.out.println(Target.class.getMethod("minus", int.class, int.class));
              pointcut.setExpression("execution(public int "
                      + "learningtest.pointcut.Target.minus(int,int) "
                      + "throws java.lang.RuntimeException)");
    
              assertTrue(pointcut.getClassFilter().matches(Target.class)
                      && pointcut.getMethodMatcher().matches(Target.class.getMethod("minus", int.class, int.class), null));
    
              assertFalse(pointcut.getMethodMatcher().matches(Target.class.getMethod("plus", int.class, int.class), null));
    
              assertFalse(pointcut.getClassFilter().matches(TestBean.class));
    
          }
      }
    
    • 적용된 포인트컷 표현식은
      • execution(public int learningtest.pointcut.Target.minus(int,int) throws java.lang.RuntimeException)
    • 필수가 아닌 항목인 접근제어자, 클래스타입, 예외 패턴을 생략하면
      • execution(int minus(int, int))
      • 대신 생략한 부분은 모든 경우를 허용하도록 되어있기에 느슨한 포인트컷이 된다.
      • 더 생략하면
        • execution(* minus(int, int))
          • minus 메소드 이름에 파라미터가 int int 인 애들
        • execution(* minus(..))
          • minus 메소드 이름인 애들
        • execution(* *(..))
          • 모든 메소드
          • 가장 느슨
  • execution() 외에도 몇 가지 표현식 스타일이 있다
    • 대표적으로
      • bean()
      • @annotaion()
  • 적용해보기
@Configuration
public class WebConfiguration {
    ...

    @Bean
    public Pointcut pointcut() {
        AspectJExpressionPointcut pointcut = new AspectJExpressionPointcut();
        pointcut.setExpression("execution(* *..*ServiceImpl.upgrade*(..))");
        return pointcut;
    }

    ...
}
  • 포인트컷 표현식을 사용하면 코드와 설정이 모두 단순화되지만, 문자열로 표현되기에 런타임 시점까지 문법의 검증이나 기능 확인이 어렵다.
    • 다양한 테스트를 활용하여 검증이 필요하다.
    • Vol 2 에서 스프링 지원툴로 포인트컷이 선정한 빈을 확인할 수 있는 도구를 알려줄듯
  • 포인트컷 클래스 타입 패턴은 상속 클래스/구현 인터페이스 모두 바라보고 선정한다.

6.5.4 AOP란 무엇인가?

  • 애스펙트 지향 프로그래밍
    • 관점 지향 프로그래밍
    • aspect(애스펙트) 란 부가기능 모듈
      • 그 자체로 애플리케이션의 핵심기능을 담고 있지는 않지만, 중요한 한 가지 요소이며 핵심기능에 부가되어 의미를 갖는 특별한 모듈
    • 부가기능을 정의한 어드바이스와 어드바이스를 어디에 적용할 지 결정하는 포인트컷을 함께 갖고 있다.
      • 어드바이저는 단순한 형태의 애스펙트

6.5.5. AOP 적용기술

  • 프록시를 이용한 AOP
    • 스프링 AOP 는 프록시 AOP 방식을 사용한다고 볼 수 있다.
  • 바이트코드 생성과 조작을 통한 AOP
    • 타겟 오브젝트를 뜯어고쳐서 부가기능을 직접 넣어준다.
    • 컴파일된 타깃의 클래스 파일 자체를 수정하거나 JVM 로딩 시점을 가로채서 바이트코드를 조작한다.

6.5.6 AOP 용어

  • 타깃
    • 부가기능을 부여할 대상
  • 어드바이스
    • 부가기능을 담은 모듈
  • 조인 포인트
    • 어드바이스가 적용될 수 있는 위치
    • 스프링 프록시 AOP 에서는 메소드 실행단계
  • 포인트컷
    • 어드바이스를 적용할 조인 포인트를 선별하는 작업 또는 그 기능을 정의한 모듈
  • 프록시
    • 클라이언트와 타깃 사이에 투명하게 존재하면서 부가기능을 제공하는 오브젝트
  • 어드바이저
    • 포인트컷과 어드바이스를 갖고 있는 오브젝트
    • 스프링 AOP 에서만 사용되는 용어
  • 애스펙트
    • AOP의 기본 모듈
    • 한 개 또는 그 이상의 포인트컷과 어드바이스의 조합

6.6 트랜잭션 속성

  • propagation, isolation level, timeout, readonly ..

트랜잭션 전파 (transaction propagation)

  • 이미 진행 중인 트랜잭션이 있을 때 또는 없을 때 동작 결정 방식

  • PROPAGATION_REQUIRED
    • 진행 중인 트랜잭션이 없으면 새로 시작하고, 이미 시작된 트랜잭션이 있으면 이에 참여한다.
    • default
  • PROPAGATION_REQUIRES_NEW
    • 항상 새로운 트랜잭션을 시작한다.
  • PROPAGATION_NOT_SUPPORTED
    • 트랜잭션 없이 동작, 진행 중인 트랜잭션이 있어도 무시

격리 수준 (isolation level)

  • 기본적으로 DB에 설정되어 있지만, JDBC 드라이버나 DataSource 등에서 재설정 할 수 있다.
    • 필요하다면 트랜잭션 단위로도 가능
    • Default 는 ISOLATION_DEFAULT
      • 이는 DataSource에 설정되어있는 디폴트 격리수준을 따른다.

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

  • 트랜잭션 포인트컷 표현식은 타입 패턴이나 빈 이름을 이용한다.
  • 공통된 메소드 이름 규칙을 통해 최소한의 트랜잭션 어드바이스와 속성을 정의한다.
  • 프록시 방식 AOP 는 같은 타깃 오브젝트 내의 메소드 호출할 때는 적용되지 않는다.

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

  • 스프링은 @Transactional을 적용할 때 4단계의 대체(fallback) 정책을 이용한다.
    • 메소드 속성을 확인할 때 타깃 메소드, 타깃 클래스, 선언 메소드, 선언 타입의 순서에 따라서 @Transactional이 적용됐는지 차례로 확인하고, 가장 먼저 발견되는 속성정보를 사용하게 하는 방법.
    • 예를들어 가장 먼저 타깃의 메소드에 @Transactional이 있는지 확인하고, 있으면 이를 사용하고 없으면 다음 대체 후보인 타깃 클래스에 부여된 @Transaction 애노테이션을 찾는다.
      • 끝까지 발견되지 않으면 트랜잭션 적용대상이 아닌 것.

6.8 트랜잭션 지원 테스트

  • AOP를 이용해 코드 외부에서 트랜잭션의 기능을 부여해주고 속성을 지정할 수 있게 하는 방법을 선언적 트랜잭션 declarative transaction 이라고 한다.

  • 반대로, TransactionTemplate 나 개별 데이터 기술의 트랜잭션 API를 사용해 직접 코드안에서 사용하는 방법은 프로그램에 의한 트랜잭션 programmatic transaction 이라고 한다.

  • 특별한 경우가 아니라면 선언적 트랜잭션

6.8.2 트랜잭션 동기화와 테스트

@Test
public void transactionSync() {
    userService.deleteAll();

    userService.add(users.get(0));
    userService.add(users.get(1));
}
  • 위와 같다면 3개의 트랜잭션이 만들어 졌을 것이다.
    • 전파속성이 REQUIRED 이므로
@Test
public void transactionSync() {

    DefaultTransactionDefinition defaultTransactionDefinition = new DefaultTransactionDefinition();
    TransactionStatus txStatus = transactionManager.getTransaction(defaultTransactionDefinition);

    userService.deleteAll();
    userService.add(users.get(0));
    userService.add(users.get(1));

    transactionManager.commit(txStatus);
}
  • 세 개의 메소드 속성이 REQUIRED 므로 미리 시작된 트랜잭션이 있다면 하나의 트랜잭션에서 수행될 것이다.

  • 트랜잭션 동기화/롤백 검증 테스트

@Test
public void transactionSync() {
    userDao.deleteAll();
    assertEquals(userDao.getCount(), 0);

    DefaultTransactionDefinition defaultTransactionDefinition = new DefaultTransactionDefinition();
    TransactionStatus txStatus = transactionManager.getTransaction(defaultTransactionDefinition);

    userService.deleteAll();
    userService.add(users.get(0));
    userService.add(users.get(1));

    assertEquals(userDao.getCount(), 2);

    transactionManager.rollback(txStatus);

    assertEquals(userDao.getCount(), 0);
}
  • 롤백테스트는 DB 작업이 포함된 테스트가 수행돼도 DB에 영향을 주지 않기 때문에 장점이 많다.

  • 테스트코드의 @Transactional 은 디폴트 속성이 동일하나, 테스트가 끝나면 자동으로 롤백된다.
    • 강제롤백을 원하지 않을 경우 @Rollback 이라는 어노테이션을 사용해야 한다.
      • 롤백 여부를 지정 가능. @Rollback(false)
  • @TransactionalConfiguration 을 사용하면 롤백에 대한 공통 속성을 지정할 수 있다.
    • 클래스레벨에서 디폴트 롤백 속성을 false 로 해두고, 테스트 메소드 중 일부 롤백을 적용하고 싶을 때 @Rollback 을 부여해줘도 된다.
  • @NotTransactional 을 테스트 메소드에 부여하면 클래스 레벨의 @Transactional 설정이 있어도 트랜잭션을 시작하지 않는다.
    • deprecated 되어서 이제는 전파속성을 사용한다.
    • @Transaction(propagation=Propagation.NEVER)
  • 테스트 내에서 트랜잭션을 제어할 수 있는 애노테이션을 잘 활용하면 DB가 사용되는 통합테스트를 만들 때 매우 편리하다.

7장 스프링 핵심 기술의 응용

  • 현재는 SQL 변경이 필요할 경우 DAO 코드가 수정되어야 한다.

  • SQL을 적절히 분리해 DAO 코드와 다른 파일이나 위치에 두고 관리할 수 있다면 더 좋을 것 같다.

  • 스프링 설정파일을 이용한 분리도 가능하나, SQL과 DI가 섞이게 된다.

  • SQL 서비스를 만들어보자.

  • SqlService 의 책임은 2가지
    • SQL 정보를 외부의 리소스로부터 읽어오는 것
    • 읽어온 SQL 을 보관하고 있다가 필요할 때 제공해 주는 것
  • 부가적인 기능으로 SQL을 필요에 따라 수정할 수 있는 기능

  • SqlService
  • SqlRegistry,SqlReader

  • SqlService interface
public interface SqlService {
    String getSql(String key) throws SqlRetrievalFailureException;
}
  • SqlReader interface
public interface SqlReader {
    void read(SqlRegistry sqlRegistry);
}
  • SqlRegistry interface
public interface SqlRegistry {

    void registerSql(String key, String sql);

    String findSql(String key) throws SqlRetrievalFailureException;
}
  • OXM : ObjectXMLMapping
    • XML과 자바오브젝트를 맵핑하여 상호변환해주는 기술

7.4 인터페이스 상속을 통한 안전한 기능확장

  • DI란 미래를 프로그래밍 하는 것

7.4.2 인터페이스 상속

  • SqlRegistry interface
public interface SqlRegistry {

    void registerSql(String key, String sql);

    String findSql(String key) throws SqlRetrievalFailureException;
}
  • UpdatableSqlRegistry interface
public interface UpdatableSqlRegistry extends SqlRegistry {

    void updateSql(String key, String sql) throws SqlUpdateFailureException;

    void updateSql(Map<String ,String> sqlMap) throws SqlUpdateFailureException;
}
  • SQL 업데이트 기능을 가진 새로운 인터페이스를 만들었을 때 기존에 SqlRegistry 를 사용하던 BaseSqlService 도 새로운 UpdatableSqlRegistry 인터페이스를 이용해야 할까?
    • 그렇지 않다.
  • 새로운 SQL 업데이트 기능까지 구현한 SQL 레지스트리 클래스를 MyUpdatableSqlRegistry 라고 할 때, 동일한 오브젝트를 DI 받지만 설계와 코드에서는 각각 필요한 인터페이스에 의존하고 있으면 된다.

  • InterfaceInheritance

7.5 DI를 이용해 다양한 구현 방법 적용하기

  • ConcurrentHashMap
    • HashMap 은 스레드 세이프 하지 않다.
    • ConcurrentHashMap 은 안전하면서도 성능이 보장되는 동기화된 HashMap
  • Test Class
class ConcurrentHashMapSqlRegistryTest {

    UpdatableSqlRegistry sqlRegistry;

    @BeforeEach
    public void setUp() {
        sqlRegistry = new ConcurrentHashMapSqlRegistry();
        sqlRegistry.registerSql("KEY1", "SQL1");
        sqlRegistry.registerSql("KEY2", "SQL2");
        sqlRegistry.registerSql("KEY3", "SQL3");
    }

    @Test
    public void find() {
        checkFindResult("SQL1", "SQL2", "SQL3");
    }

    private void checkFindResult(String expected1, String expected2, String expected3) {
        assertEquals(sqlRegistry.findSql("KEY1"), expected1);
        assertEquals(sqlRegistry.findSql("KEY2"), expected2);
        assertEquals(sqlRegistry.findSql("KEY3"), expected3);
    }

    @Test
    public void unknownKey() {
        Assertions.assertThrows(SqlRetrievalFailureException.class,
                () -> sqlRegistry.findSql("SQL9999!@#$"));
    }

    @Test
    public void updateSingle() {
        sqlRegistry.updateSql("KEY2", "Modified2");
        checkFindResult("SQL1", "Modified2", "SQL3");
    }

    @Test
    public void updateMulti() {
        Map<String, String> sqlmap = new HashMap<>();
        sqlmap.put("KEY1", "Modified1");
        sqlmap.put("KEY3", "Modified3");

        sqlRegistry.updateSql(sqlmap);
        checkFindResult("Modified1", "SQL2", "Modified3");
    }

    @Test
    public void updateWithNotExistingKey() {
        Assertions.assertThrows(SqlUpdateFailureException.class,
                () -> sqlRegistry.updateSql("SQL9999!@#$", "Modified"));
    }
}
  • interface 구현 클래스
public class ConcurrentHashMapSqlRegistry implements UpdatableSqlRegistry {

    private Map<String, String> sqlMap = new ConcurrentHashMap<>();

    @Override
    public void updateSql(String key, String sql) throws SqlUpdateFailureException {
        if (sqlMap.containsKey(key)) {
            sqlMap.put(key, sql);
            return;
        }

        throw new SqlUpdateFailureException(key + "is empty");
    }

    @Override
    public void updateSql(Map<String, String> sqlMap) throws SqlUpdateFailureException {
        for (Map.Entry<String, String> entry : sqlMap.entrySet()) {
            updateSql(entry.getKey(), entry.getValue());
        }
    }

    @Override
    public void registerSql(String key, String sql) {
        sqlMap.put(key, sql);
    }

    @Override
    public String findSql(String key) throws SqlRetrievalFailureException {
        if (sqlMap.containsKey(key)) {
            return sqlMap.get(key);
        }

        throw new SqlRetrievalFailureException(key + "is empty");
    }
}

컨테이너의 빈 등록 정보 확인

@Autowired DefaultListableBeanFactory bf;

@Test
public void beans() {
    for (String n : bf.getBeanDefinitionNames()) {
        System.out.println(n + " \t " + bf.getBean(n).getClass().getName());
    }
}

8장 스프링이란 무엇인가?

8.1 스프링의 정의

  • 자바 엔터프라이즈 개발을 편하게 해주는 오픈소스 경량급 애플리케이션 프레임워크

  • 애플리케이션 프레임워크
    • 일반적으로 라이브러리나 프레임워크는 특정 업무 분야나 한 가지 기술에 특화된 목표를 가지고 만들어진다.
    • 스프링은 애플리케이션 프레임워크 라는 특징을 갖고 있고, 이는 특정 계층/기술/업무분야에 국한되지 않고 애플리케이션의 전 영역을 포괄하는 범용적인 프레임워크를 말한다.
  • 경량급
    • 스프링은 20여 개의 모듈로 세분화되고 수십만 라인에 달하는 코드를 가진 매우 복잡하고 방대한 규모의 프레임워크이다.
    • 그럼에도 스프링이 가볍다고 하는 이유는, 불필요하게 무겁지 않기 때문 (<-> EJB)
  • 자바 엔터프라이즈 개발을 편하게
    • 단순히 편리한 몇가지 도구나 기능을 제공하는 차원이 아니라, 근본적인 문제점에 도전해서 해결책을 제시한다는 것이 스프링의 특장점.
    • 편리한 애플리케이션 개발이란 개발자가 복잡하고 실수하기 쉬운 로우레벨 기술에 많은 신경을 쓰지 않으면서도 애플리케이션의 핵심인 사용자의 요구사항, 즉 비즈니스 로직을 빠르고 효과적으로 구현하는 것을 말한다.
  • 오픈소스
    • 소스가 모두에게 공개되고, 특별한 라이선스를 취득할 필요가 없이 얼마든지 가져다 자유롭게 이용해도 된다는 뜻
    • 아파치 라이선스 버전 2.0
      • 상업적인 목적의 제품에 포함시키거나 비공개 프로젝트에 자유롭게 이용가능
      • 스프링을 사용한다는 점과 원 저작자를 밝히고, 제품을 패키징 할 때 라이선스 정보를 포함시키는 등 기본적인 의무사항을 따르면 된다.
    • 오픈소스의 장점
      • 개방된 커뮤니티 공간 안에서 투명한 방식으로 다양한 참여를 통해 개발되기 때문에 매우 빠르고 유연한 개발이 가능하다는 것.
      • 잠재적인 버그와 문제점이 빠르게 발견되고 해결될 수 있다.
      • 기업/사용자 입장에서 라이선스 비용에 대한 부담이 없다.
    • 오픈소스의 단점
      • 지속적이고 안정적인 개발이 계속될지 불확실하다.
      • 스프링은 이런 오픈소스의 문제점과 한계를 잘 알기에, 개발을 책임지고 진행할 수 있는 전문기업을 만들었다.

8.2 스프링의 목적

  • 엔터프라이즈 애플리케이션 개발을 편하게!

8.2.1 엔터프라이즈 개발의 복잡함

  • 엔터프라이즈 시스템이란 서버에서 동작하며 기업과 조직의 업무를 처리해주는 시스템

  • 첫번째는 기술적인 제약조건과 요구사항이 늘어가기 때문
    • 엔터프라이즈 시스템을 개발하는 데는 순수한 비즈니스 로직을 구현하는 것 외에도 기술적으로 고려할 사항이 많다. (성능, 보안, 안정성, 확장성)
    • 타 시스템 연계, 분산 트랜잭션 등 기술적인 요구는 점차 심화되고 그에 따른 복잡도는 증가하여 개발자 개개인이 져야 할 기술적인 부담은 점점 더 커진다.
  • 두번째는 엔터프라이즈 애플리케이션이 구현해야 할 핵심기능인 비즈니스 로직의 복잡함이 증가하기 때문

  • 또, 업무 프로세스를 변경하고 조종하는 것을 상시화할 만큼 변화의 속도가 빨라졌다.
    • 기능 요구사항, 업무 정책 등이 바뀌기 때문에 애플리케이션을 자주 수정해야 한다.
  • 복잡함을 가증시키는 원인
    • 단지 양이 많고 어렵다는 뜻이 아님.
    • 세부 요소가 이해하기 힘든 방식으로 얽혀있고, 그 때문에 쉽게 다루기 어렵다.
    • 근본적인 비즈니스 로직과 엔터프라이즈 기술이라는 두 가지 복잡함이 한데 얽혀 있기 때문

8.2.2 복잡함을 해결하려는 도전

  • 제거될 수 없는 근본적인 복잡함
    • 그 복잡함을 효과적으로 상대할 수 있는 전략과 기법이 필요.
  • 실패한 해결책 : EJB
    • 애플리케이션 로직을 담은 핵심코드에서 일부 기술적인 코드가 제거된 것은 사실이지만, 오히려 EJB 환경과 스펙에 종속되는 코드로 만들어져야 하는 부담감이 가증
    • 복잡함을 덜으려다 오히려 더 큰 복잡함을 추가
  • 비침투적인 방식을 통한 효과적인 해결책 : 스프링
    • EJB의 실패를 교훈삼아 출발.
    • 기술적인 복잡함을 애플리케이션 핵심 로직의 복잡함에서 제거하는 것을 목표로 둔 것은 동일하나
    • EJB 처럼 개발자의 코드에 난입해서 지저분하고 복잡한 코드를 만들어버리는 실수를 하지 않았다.
    • 어떤 기술을 적용했을 때 그 기술과 관련된 코드나 규약 등이 코드에 등장하는 경우를 침투적인(invasive) 기술이라고 한다.
    • 비침투적인(non-invasive) 기술은 기술의 적용 사실이 코드에 직접 반영되지 않는다. 어딘가에는 기술의 적용에 따라 필요한 작업을 해줘야 하겠지만, 애플리케이션 코드 여기저기에 불쑥 등장하거나, 코드의 설계와 구현 방식을 제한하지 않는다.

8.2.3 복잡함을 상대하는 스프링의 전략

  • 기본 전략은 비즈니스 로직을 담은 애플리케이션 코드와 엔터프라이즈 기술을 처리하는 코드를 분리시키는 것.

  • 기술적 복잡함을 상대하는 전략
    • 첫번째 문제 : 기술에 대한 접근방식이 일관성 없고, 특정환경에 종속적이다.
      • 공략방법은 추상화
        • 로우레벨 기술 구현 부분과 기술을 사용하는 인터페이스를 분리하고, 환경과 세부 기술에 독립적인 접근 인터페이스를 제공하는 것이 가장 좋은 해결책이다.
    • 두번째 문제 : 기술적인 처리를 담당하는 코드가 성격이 다른 코드에 섞여 등장한다.
      • 공략방법은 AOP
  • 비즈니스와 애플리케이션 로직의 복잡함을 상대하는 전략
    • 객체지향 기술
  • 핵심도구 : 객체지향과 DI

8.3 POJO 프로그래밍

8.3.1 스프링의 핵심: POJO

8.3.2 POJO란 무엇인가?

  • 단순한 Java 오브젝트

8.3.3 POJO의 조건

  • 특정 규약에 종속되지 않는다.
    • 특정 규약을 따라 비즈니스 컴포넌트를 만들어야 하는 경우 POJO 가 아니다.
  • 특정 환경에 종속되지 않는다.
    • 특정 벤더나 서버의 특정 기업의 프레임워크 안에서만 동작 가능한 경우 POJO 가 아니다.
    • 비즈니스 로직을 담은 코드에 HttpServletRequestHttpSession, 캐시와 같은 API 가 등장하거나 웹 프레임워크의 클래스를 직접 이용하는 부분이 있다면 그것은 진정한 POJO 라고 볼 수 없다.
  • 진정한 POJO란 객체지향적인 원리에 충실하면서, 환경과 기술에 종속되지 않고 필요에 따라 재활용될 수 있는 방식으로 설계된 오브젝트

8.3.4 POJO의 장점

  • 기술과 환경에 종속되지 않는다.
    • 검증 및 테스트 작성이 수월하다.
    • 자동화된 테스트에 유리하다.
  • 객체지향 설계가 자유롭다.

8.3.5 POJO 프레임워크

  • 스프링은 POJO를 이용한 엔터프라이즈 애플리케이션 개발을 목적으로 하는 프레임워크

  • 스프링은 개발자들이 복잡한 엔터프라이즈 기술보다는 객체지향적인 설계와 개발의 원리에 좀 더 집중할 수 있도록 돕는다.

8.4 스프링의 기술

  • 스프링에는 POJO 프로그래밍을 손쉽게 할 수 있도록 지원하는 세 가지 가능기술을 제공한다.
    • IoC/DI, AOP, PSA

8.4.1 IoC/DI

  • 직접 자신이 사용할 오브젝트를 new 키워드로 생성해서 사용하는 강한 결합을 쓰는 방법보다 나은 점은 무엇일까?
    • 유연한 확장이 가능하게 하기 위해서
  • DI의 활용 방법
    • 핵심기능의 변경
    • 핵심기능의 동적인 변경
    • 부가기능의 추가
    • 인터페이스의 변경
    • 프록시
    • 템플릿/콜백
    • 싱클톤과 오브젝트 스코프
    • 테스트

8.4.2 AOP

  • AOP와 OOP는 서로 배타적이 아니다.

  • AOP의 적용 기법
    • 스프링과 같이 다이내믹 프록시 사용
    • 자바 언어의 한계를 넘어서는 언어의 확장을 이용하는 방법
      • e.g.) AspectJ 사용
  • AOP 적용 단계
    • 미리 준비된 AOP 이용
      • e.g.) @Transactional
    • 전담팀을 통한 정책 AOP 적용
      • 보안, 로깅, 트레이싱, 성능 모니터링 등
    • AOP의 자유로운 이용

8.4.3 PSA (포터블 서비스 추상화)

  • 환경과 세부 기술의 변화에 관계없이 일관된 방식으로 기술에 접근할 수 있게 해주는 PSA