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

1장 오브젝트와 의존관계

단어장

  • 리팩토링
    • 기존의 코드를 외부의 동작방식에는 변화 없이 내부 구조를 변경해서 재구성하는 작업 또는 기술
  • 디자인 패턴
    • 소프트웨어 설계 시 특정 상황에서 자주 만나는 문제를 해결하기 위해 사용할 수 있는 재사용 가능한 솔루션
  • 템플릿 메소드 패턴
    • 상속을 통해 슈퍼클래스의 기능을 확장할 때 사용하는 가장 대표적인 방법
    • 자주 변경되며 확장할 기능은 서브클래스에서 만들도록 하는 것
    • 서브클래스에서 선택적으로 오버라이드 할 수 있도록 만들어둔 메소드를 훅(hook) 메소드 라고 한다.
  • 팩토리 메소드 패턴
    • 서브클래스에서 구체적인 오브젝트 생성 방법을 결정
    • 서브클래스에서 오브젝트 생성 방법과 클래스를 결정할 수 있도록 미리 정의해둔 메소드를 팩토리 메소드 라고 한다.
  • 객체지향 설계 원칙
    • SOLID
    • SRP(단일 책임 원칙), OCP(개방 폐쇄 원칙), LSP(리스코프 치환 원칙), ISP(인터페이스 분리 원칙), DIP(의존관계 역전 원칙)
  • 개방 폐쇄 원칙 (OCP)
    • Open-Closed Principle
    • 객체지향 설계 원칙 중 하나
    • 클래스나 모듈은 확장에 열려있어야 하고, 변경에는 닫혀있어야 한다.
  • 전략 패턴
    • 자신의 기능 맥락에서 필요에 따라 변경이 필요한 알고리즘을 인터페이스를 통해 외부로 분리시키고,
    • 이를 구현한 구체적인 알고리즘 클래스를 필요에 따라 바꿔서 사용할 수 있게 하는 디자인 패턴
  • 빈(Bean)
    • 스프링에 제어권을 가지고 직접 만들고 관계를 부여하는 오브젝트
  • 빈 팩토리
    • 스프링 IoC 를 담당하는 핵심 컨테이너
    • 빈 등록, 생성, 조회 등 관리
  • 애플리케이션 컨텍스트
    • 빈 팩토리를 확장한 IoC 컨테이너
    • 빈팩토리 기능 + 부가서비스
  • 싱글톤 패턴
    • 애플리케이션 내에서 클래스를 제한된 인스턴스 개수(주로 하나)만 존재하도록 강제하는 패턴

1.2 DAO의 분리

  • 관심사의 분리
    • Separation of Concerns
    • 관심사가 같은 것끼리 하나의 객체 or 친한 객체
    • 관심사가 다른 것은 가능한 한 서로 영향을 주지 않도록 분리
  • UserDao add() 메소드의 관심사항

      public void add(User user) throws SQLException, ClassNotFoundException {
          Class.forName ("org.h2.Driver");
          Connection c = DriverManager.getConnection("jdbc:h2:mem:toby-spring", "sa", "");
          PreparedStatement ps = c.prepareStatement(
                  "insert into user (id, name, password) values (?,?,?)");
    
          ps.setString(1, user.getId());
          ps.setString(2, user.getName());
          ps.setString(3, user.getPassword());
    
          ps.executeUpdate();
    
          ps.close();
          c.close();
      }
    
    1. DB와 연결(커넥션)
      • 어떤 DB, 드라이버, 로그인 정보, 커넥션 생성방법
    2. DB에 요청할 SQL 문장을 담고 실행
      • 파라미터 바인딩, SQL 실행
    3. 리소스 해제
  • 중복된 DB 연결코드 메소드 추출

      public void add(User user) throws SQLException, ClassNotFoundException {
          Connection c = getConnection();
          ...
          (생략)
      }
    
      private Connection getConnection() throws ClassNotFoundException, SQLException {
          Class.forName("org.h2.Driver");
          return DriverManager.getConnection("jdbc:h2:mem:toby-spring", "sa", "");
      }
    
  • DB 커넥션 생성 방식의 추상화
    • Connection 을 생성하는 구체적인 코드를 상속받은 쪽에서.
      public abstract class UserDao {
          ...
          public abstract Connection getConnection() throws ClassNotFoundException, SQLException;
      }
    
      public class CustomUserDao extends UserDao {
    
          @Override
          public Connection getConnection() throws ClassNotFoundException, SQLException {
              Class.forName("org.h2.Driver");
              return DriverManager.getConnection("jdbc:h2:mem:toby-spring", "sa", "");
          }
      }
    

    이렇게 슈퍼클래스에 기본적인 로직의 흐름을 만들고, 그 기능의 일부를 추상메소드나 오버라이딩이 가능한 메소드로 만든 뒤 서브 클래스에서 이런 메소드를 필요에 맞게 구현해서 사용하도록 하는 방법을 템플릿 메소드 패턴 이라고 한다.

    또한 UserDao의 getConnection() 메소드는 Connection 타입 오브젝트를 생성한다는 기능을 정의해놓은 추상 메소드. UserDao의 서브클래스의 getConnection() 구현이 Connection 오브젝트를 어떻게 생성할 것인지 결정하는 방법. 이렇게 서브클래스에서 구체적인 오브젝트 생성 방법을 결정하게 하는 것을 팩토리 메소드 패턴

    • 단점
      • 상속을 사용했다.
      • 다중상속을 허용하지 않기에, UserDao가 다른 목적을 위해 상속을 사용하고 있었다면?
      • 상속관계는 두 가지 다른 관심사에 대해 긴밀한 결합을 허용함

1.3 DAO의 확장

  • 클래스의 분리
      public class UserDao {
    
          private SimpleConnectionMaker simpleConnectionMaker;
    
          public UserDao() {
              simpleConnectionMaker = new SimpleConnectionMaker();
          }
    
          public void add(User user) throws SQLException, ClassNotFoundException {
              Connection c = simpleConnectionMaker.makeNewConnection();
              ...
          }
      }
    
      public class SimpleConnectionMaker {
    
          public Connection makeNewConnection() throws ClassNotFoundException, SQLException {
              Class.forName("org.h2.Driver");
              return DriverManager.getConnection("jdbc:h2:mem:toby-spring", "sa", "");
          }
      }
    

    DB 커넥션 생성기능을 새로운 클래스로 분리, 독립 시킨다.

    • 단점
      • UserDao가 SimpleConnectionMaker 라는 특정 클래스에 종속되어 있다.
        • UserDao 코드 수정 없이 DB 커넥션 생성 기능을 변경할 방법이 없다.
      • DB 커넥션 제공 클래스의 메소드 명이 다를 수 있다.
  • 인터페이스의 도입
    • 긴밀한 연결 사이 추상적인 느슨한 연결고리를 만들어준다.
      public interface ConnectionMaker {
          Connection makeConnection() throws ClassNotFoundException, SQLException;
      }
    
      public class SimpleConnectionMaker implements ConnectionMaker {
    
          @Override
          public Connection makeConnection() throws ClassNotFoundException, SQLException {
              Class.forName("org.h2.Driver");
              return DriverManager.getConnection("jdbc:h2:mem:toby-spring", "sa", "");
          }
      }
    
      public class UserDao {
    
          private ConnectionMaker connectionMaker;
    
          public UserDao(ConnectionMaker connectionMaker) {
              this.connectionMaker = connectionMaker;
          }
    
          ...
      }
    
      public class Application {
    
          public static void main(String[] args) throws SQLException, ClassNotFoundException {
                
              UserDao dao = new UserDao(new SimpleConnectionMaker());
              ...
          }
      }
    

    UserDao 클래스를 높은 응집도와 낮은 결합도 (high coherence and low coupling) 를 갖도록 리팩토링 높은 응집도 란, 하나의 책임 또는 관심사에만 집중되어있는 형태 낮은 결합도 란, 다른 모듈과 느슨하게 연결된 형태

전략 패턴

  • MainClass - UserDao - ConnectionMaker 구조는 전략(Strategy) 패턴에 해당한다.
  • UserDao 는 전략 패턴의 컨텍스트
    • 컨텍스트는 자신의 기능을 수행하는데 필요한 기능 중 변경 가능한 DB 연결 방식이라는 알고리즘을 ConnectionMaker 라는 인터페이스로 정의하고
    • 이를 구현한 클래스(즉 전략)를 변경하며 사용할 수 있게 분리했다.
  • 컨텍스트(UserDao) 를 사용하는 클라이언트(MainClass) 는 컨텍스트가 사용할 전략(ConnectionMaker를 구현한 클래스)을 컨텍스트의 생성자 등을 통해 선택, 제공

1.4 제어의 역전(IoC)

  • 오브젝트 팩토리
      public class DaoFactory {
    
          public UserDao userDao() {
              return new UserDao(new SimpleConnectionMaker());
          }
      }
    
      public class Application {
    
          public static void main(String[] args) throws SQLException, ClassNotFoundException {
                
              UserDao dao = new DaoFactory().userDao();
              ...
          }
      }
    

    책임의 분리, 어떤 ConnectionMaker 구현 클래스를 사용할 지 결정하는 기능을 팩토리 클래스에게 완벽히 위임한다. 오브젝트를 생허나는 쪽과 생성된 오브젝트를 사용하는 쪽의 역할과 책임을 깔끔하게 분리 어떻게 만들지와 어떻게 사용할지는 분명 다른 관심이다.

1.4.3 제어권 이전을 통한 제어관계 역전

  • 일반적인 프로그램 흐름 상
    • main() 메소드와 같은 프로그램 시작부에서 다음 사용할 오브젝트를 결정하고, 결정한 오브젝트를 생성하고, 만들어진 오브젝트에 있는 메소드를 호출하고, 그 오브젝트 메소드 안에서 다음에 사용할 것을 결정하고 호출하는 식의 작업이 반복된다.
    • 모든 오브젝트가 능동적으로 자신이 사용할 클래스를 결정하고, 언제 어떻게 그 오브젝트를 만들지를 스스로 관장한다.
    • 모든 종류의 작업을 사용하는 쪽에서 제어하는 구조
  • 제어의 역전이란 이러한 제어 흐름의 개념을 거꾸로 뒤집는 것.
    • 오브젝트가 자신이 사용할 오브젝트를 스스로 선택하지 않는다. 생성하지도 않는다. 자신도 어떻게 만들어지고 어디서 사용되는지 알 수 없다.
    • 제어 권한을 갖는 특별한 오브젝트에 의해 결정되고 만들어진다.
  • 제어의 역전의 개념을 쉽게 보면
    • UserDao 개선 작업 시 서브클래스를 만들었을 때.
    • 서브클래스가 getConnection() 을 구현하지만, 메소드가 언제 어떻게 사용될지는 자신은 모른다.
    • 슈퍼클래스의 템플릿 메소드에서 필요시 호출해서 사용하는 것.
    • 이처럼 제어권을 상위에 넘기고 자신은 필요할 때 호출되어 사용되도록 한다는 개념
  • 라이브러리와 프레임워크의 차이
    • 라이브러리를 사용하는 애플리케이션 코드는 애플리케이션 흐름을 직접 제어한다.
    • 동작 중 필요한 기능이 있을 때 능동적으로 라이브러리 사용.
    • 프레임워크는 거꾸로 애플리케이션 코드가 프레임워크에 의해 사용된다.
    • 프레임워크 위에 개발한 클래스를 등록해두고, 프레임워크가 흐름을 주도하는 중에 개발자가 만든 애플리케이션 코드를 사용하도록 만든 방식.
    • 즉 필자는 분명한 제어의 역전개념이 적용되어있어야 프레임워크라고 말하고 있다.

1.5 제어의 역전(IoC)

  • 스프링 IoC
    • 빈 팩토리(빈을 생성하고 관계를 설정) 이자 애플리케이션 컨텍스트(구성요소의 제어) 이다.
      • 사실 똑같은 말
    • 적용
        @Configuration
        public class DaoFactory {
      
        @Bean
        public UserDao userDao() {
            return new UserDao(this.connectionMaker());
        }
      
        @Bean
        public ConnectionMaker connectionMaker() {
            return new SimpleConnectionMaker();
        }
        }
      

    이 클래스는 이제 자바 코드의 탈을 쓴 스프링 전용 설정정보라고 볼 수 있다.

    • 이렇게 스프링을 적용 했을 때 얻을 수 있는 이득을 앞으로 알아보자.
  • 애플리케이션 컨텍스트의 동작방식
    • ApplicationContextProcess
      • 애플리케이션 컨텍스트 DaoFactory 클래스를 설정정보로 등록
      • 애플리케이션 컨텍스트 @Bean이 붙은 메소드의 이름을 가져와 빈 목록 생성
      • 클라이언트애플리케이션 컨텍스트 의 getBean() 메소드를 호출
      • 애플리케이션 컨텍스트 는 빈 목록에서 요청한 이름을 찾아 return.
    • 장점
      1. 클라이언트는 구체적인 팩토리 클래스를 알 필요가 없다.
        • 프로그램이 커져서 팩토리 클래스가 늘어나도 애플리케이션 컨텍스트를 이용하여 일관된 방식으로 원하는 오브젝트를 가져올 수 있다.
      2. 애플리케이션 컨텍스트는 종합 IoC 서비스를 제공해준다.
        • 오브젝트 생성 및 관계설정 뿐 아니라 오브젝트 생성 방식, 시점, 전략, 자동생성, 후처리, 설정 방식 다변화 등 오브젝트를 효과적으로 활용할 수 있는 다양한 기능을 제공한다.
      3. 애플리케이션 컨텍스트는 빈을 검색하는 다양한 방법을 제공한다.
        • 빈의 이름 뿐 아니라 타입, 어노테이션 등으로 빈을 찾을 수도 있다.

1.6 싱글톤 레지스트리와 오브젝트 스코프

  • 오브젝트가 동일하다
    • 하나의 오브젝트만 존재하는 것이고 두 개의 오브젝트 레퍼런스 변수를 갖고 있다.
  • 오브젝트가 동등하다
    • 기준에 따라 두 오브젝트의 정보가 동등하다고 판단하는 것.
  • 스프링은 여러 번에 걸쳐 빈을 요청하더라도 매번 동일한 오브젝트를 돌려준다.

  • 애플리케이션 컨텍스트는 싱글톤을 저장하고 관리하는 싱글톤 레지스트리 이다.

  • 최초 제한된 리소스에서 성능을 확보하기 위해 싱글톤으로 고안
    • 스펙에서 강제하지 않지만, 서블릿도 대부분 멀티스레드 환경에서 싱글톤으로 동작한다.
  • 싱글톤 패턴은 사용하기 까다롭고 여러가지 문제점이 있다.
    • 안티패턴이라고 생각하는 사람도 존재
  • 싱글톤 패턴의 한계
    • 자바의 싱글톤 구현
        public class UserDao {
            private static UserDao INSTANCE;
            ...
            private UserDao(ConnectionMaker connectionMaker) {
                this.connectionMaker = connectionMaker;
            }
                  
            public static synchronized UserDao getInstance() {
                if (INSTANCE == null) INSTANCE = new UserDao(...);
                return INSTANCE;
            }
        }
      

      클래스 밖에서 Instance 를 생성하지 못하도록 private 생성자 자신과 같은 타입의 static field 정의 스테틱 팩토리 메소드를 만들고, 최초 호출되는 시점 한 번만 Instance 생성 이후 이미 만들어져 스태틱 필드에 저장해둔 오브젝트만 넘겨준다

    • 문제점
      1. private 생성자를 갖고 있기에 상속할 수 없다.
      2. 테스트하기 힘들다
        • Mocking 이 힘들고, 오브젝트를 다이내믹하게 주입하기 힘들다.
      3. 서버환경에서 싱글톤이 하나만 만들어지는 것을 보장할 수 없다.
        • 서버 클래스 로더 구성에 따라, 싱글톤 클래스임에도 하나 이상의 오브젝트가 만들어질 수 있다.
        • 여러 JVM 에 분산되어 설치되면 각각 독립적으로 오브젝트가 생성되기에 싱글톤 가치가 떨어진다.
      4. 싱글톤의 사용은 전역 상태를 만들 수 있기에 바람직하지 못하다.
  • 싱글톤 레지스트리
    • 스프링이 직접 싱글톤 형태의 오브젝트를 만들고 관리하는 기능
    • 스태틱 메소드와 private 생성자를 사용하지 않고, 평범한 자바 클래스를 싱글톤으로 활용할 수 있다.
    • 싱글톤 환경이 아니면 자유롭게 오브젝트를 만들 수 있고, 테스트가 용이해진다.
    • 고전적인 싱글톤 패턴을 대신한다.
  • 싱글톤과 오브젝트의 상태
    • 싱글톤은 멀티스레드 환경이라면 여러 스레드가 동시에 접근해서 사용할 수 있다.
    • 때문에 무상태(stateless) 방식으로 만들어져야 한다.
    • 읽기전용 정보 외에 인스턴스 필드를 사용하면 위험하다.
  • 스프링 빈의 스코프
    • 빈이 생성되고, 존재하고, 적용되는 범위
    • 기본은 싱글톤
    • prototype 스코프(매번 새로운 오브젝트 생성), request 스코프(HTTP 요청 시 오브젝트 생성), session 스코프 등이 존재

1.7 의존관계 주입(DI)

  • IoC 컨테이너 = DI 컨테이너

  • 의존하고 있다는 것은 의존대상이 변하면 영향을 미친다는 뜻

  • A에서 B에 정의된 메소드를 호출해서 사용하는 경우, 사용에 대한 의존관계 가 있다.
    • B에 새로운 메소드가 추가되거나, 기존 메소드 형식이 바뀌면 A도 그에따라 수정되어야 한다.
    • 이 경우 B는 A에 의존하지 않는다.
  • UserDao 의 의존관계
    • 작업해왔던 UserDao 는 ConnectionMaker 인터페이스에 의존하고 있다.
        public class UserDao {
      
        private ConnectionMaker connectionMaker;
        ...
        }
      
    • ConnectionMaker 인터페이스가 변한다면 UserDao 가 직접적인 영향을 받게 된다.
    • 하지만 ConnectionMaker 의 구현체인 SimpleConnectionMaker 가 다른 것으로 바뀌거나 내부에서 사용하는 메소드가 변화해도 UserDao에 영향을 주지 않는다.
    • 인터페이스에만 의존관계를 만들어두면 구현 클래스와 관계가 느슨해지면서 변화에 영향을 덜 받는 상태가 된다.
      • 즉, 결합도가 낮다.
    • 설계 관점의 의존관계는 이렇지만, 런타임 시 설계 시점의 의존관계가 실체화 되는데
      • 런타임 의존관계 또는 오브젝트 의존관계
    • 런타임 의존관계는 모델링 시점의 의존관계와는 성격이 분명히 다르다.
    • 프로그램이 시작되고 UserDao 오브젝트가 만들어지고 나서 런타임 시에 의존관계를 맺는 대상, 즉 실제 사용대상인 오브젝트를 의존 오브젝트 라고 말한다.
    • DI는 이렇게 구체적인 의존 오브젝트와 그것을 사용할 주체, 보통 클라이언트라고 부르는 오브젝트를 런타임 시에 연결해주는 작업을 말한다.
    • 정리하면 의존관계의 주입이란 아래 세 가지 조건을 충족한다.
      1. 클래스 모델이나 코드에는 런타임 시점의 의존관계가 드러나지 않는다. 그러기 위해서는 인터페이스에만 의존하고 있어야 한다.
      2. 런타임 시점의 의존관계는 컨테이너나 팩토리 같은 제3의 존재가 결정한다.
      3. 의존관계는 사용할 오브젝트에 대한 레퍼런스를 외부에서 제공(주입)해줌으로써 만들어진다.
    • 핵심은, 설계시점에 알지 못했던 두 오브젝트의 관계를 맺도록 도와주는 제3의 존재가 있다는 것.
  • 의존관계 검색 (DL)
    • 코드에서는 구체적인 클래스에 의존하지 않고, 런타임 시에 의존관계를 결정한다는 점은 의존관계 주입과 비슷
    • 외부로부터 주입이 아니라 스스로 검색을 이용
    • 자신이 필요로 하는 의존 오브젝트를 능동적으로 찾는다. 물론 자신이 어떤 클래스의 오브젝트를 이용할지 결정하지는 않는다.
    • 의존관계를 맺을 오브젝트를 결정하는 것과 오브젝트 생성작업은 외부 컨테이너에게 IoC를 맡기지만, 이를 가져올 때는 메소드나 생성자를 통한 주입 대신 스스로 컨테이너에 요청하는 방법을 사용한다.
        public UserDao() {
        DaoFactory daoFactory = new DaoFactory();
        this.connectionMaker = daoFactory.connectionMaker();
        }
      
    • 이렇게 해도 UserDao는 여전히 자신이 어떤 ConnectionMaker 오브젝트를 사용할지 미리 알지 못한다.
    • 여전히 코드의 의존대상은 ConnectionMaker 인터페이스뿐이다.
    • 이 경우 단순히 요청이지만, 이런 작업을 일반화한 스프링 애플리케이션 컨텍스트라면 미리 정해놓은 이름을 전달해서 그 이름에 해당하는 오브젝트를 찾게 된다.
      public UserDao() {
          ApplicationContext context = new AnnotationConfigApplicationContext(DaoFactory.class);
          this.connectionMaker = context.getBean("connectionMaker", ConnectionMaker.class);
      }
    
    • 단점은 UserDao 에서 스프링이나 오브젝트 팩토리를 만들고 API를 이용하는 코드가 섞여있게 되는 것.
    • 때문에 대개는 의존관계 주입 방식을 사용하는 편이 낫다.
    • 다만 애플리케이션의 기동시점에서 적어도 한 번은 의존관계 검색 방식을 사용해 오브젝트를 가져와야 한다.
      • 스테틱 메소드인 main()에서는 DI를 이용해 오브젝트를 주입받을 방법이 없기 때문이다.
    • 서버에서도 마찬가지다.
      • 서버에는 main()과 같은 기동 메소드는 없지만, 사용자의 요청을 받을 때마다 한 번은 의존관계 검색 방식을 사용해 오브젝트를 가져와야 한다.
    • 의존관계 검색과 의존관계 주입의 중요한 차이점
      • 의존관계 검색 방식에서 검색하는 오브젝트는 자신이 스프링의 빈일 필요가 없다.
    • DI를 원하는 오브젝트는 먼저 자기 자신이 컨테이너가 관리하는 빈이 돼야 한다는 사실을 잊지 말자.
  • DI 응용
    • 기능 구현의 교환
      • 개발용 ConnectionMaker 생성 코드
          @Bean
          public ConnectionMaker connectionMaker() {
          return new LocalDBConnectionMaker();
          }
        
      • 운영용 ConnectionMaker 생성 코드
          @Bean
          public ConnectionMaker connectionMaker() {
          return new ProductionDBConnectionMaker();
          }
        
      • DAO가 100개든 1000개이든 각각 커넥션 메이커를 생성하는게 아니기에, 이렇게 딱 한 줄만 수정하면 된다.
    • 부가기능 추가
      • DAO가 DB를 얼마나 많이 연결해서 사용하는지 파악하고 싶을 경우
      • 부가기능을 추가한 ConnectionMaker 인터페이스를 구현한 클래스를 만들면 된다.
          public class CountingConnectionMaker implements ConnectionMaker {
                    
          private int count = 0;
                    
          private ConnectionMaker realConnectionMaker;
                    
          public CountingConnectionMaker(ConnectionMaker realConnectionMaker) {
              this.realConnectionMaker = realConnectionMaker;
          }
                    
          @Override
          public Connection makeConnection() throws ClassNotFoundException, SQLException {
              this.count++;
              return this.realConnectionMaker.makeConnection();
          }
                    
          public int getCount() {
              return this.count;
          }
          }
        
      • RuntimeDependency
  • 메소드를 이용한 DI
    • setter 메소드를 이용한 주입
      • 세터메소드는 외부에서 오브젝트 내부의 애트리뷰트 값을 변경하려는 용도로 주로 사용
      • 파라미터로 전달된 값을 내부의 인스턴스 변수에 저장
      • 부가적으로 입력값에 대한 검증 등의 작업을 수행할 수 있다.
    • 일반 메소드를 이용한 주입
      • 파라미터를 하나로 제한하는 세터의 제약이 싫다면 일반 메소드를 사용할 수도 있다.
      • 파라미터 개수가 많아지고 타입이 여러개라면 실수의 가능성이 있음.
      • 여러 개의 초기화 메소드로 나눠서 한 번에 모든 필요한 파라미터를(생성자의 한계) 다 받지 않도록 할 수도 있다.
    • 도서에는 setter 메소드를 통한 DI 를 추천하지만, 현재는 constructor DI 를 권장하는 흐름
      public class UserDao {
    
          private ConnectionMaker connectionMaker;
    
          public void setConnectionMaker(ConnectionMaker connectionMaker) {
              this.connectionMaker = connectionMaker;
          }
          ...
      }
    
      @Configuration
      public class CountingDaoFactory {
    
          @Bean
          public UserDao userDao() {
              UserDao userDao = new UserDao();
              userDao.setConnectionMaker(this.connectionMaker());
              return userDao;
          }
      }
    

1.8 XML을 이용한 설정

  • DataSource 인터페이스로 변환
    • 직접 작성한 ConnectionMaker는 DB 커넥션을 생성해주는 기능 하나만 정의한 매우 단순한 인터페이스
    • Java 에서는 DB 커넥션을 포함한 다양한 기능을 추상화 한 DataSource 라는 인터페이스가 존재한다.
    • DataSource 의 구현 Class 에서 Connection 을 만들어 준 후 사용하면 된다.
        @Configuration
        public class DaoFactory {
      
        @Bean
        public UserDao userDao() throws ClassNotFoundException {
            return new UserDao(this.dataSource());
        }
      
        @Bean
        public DataSource dataSource() throws ClassNotFoundException {
            SimpleDriverDataSource dataSource = new SimpleDriverDataSource();
      
            Class driverClass = Class.forName("org.h2.Driver");
            dataSource.setDriverClass(driverClass);
            dataSource.setUrl("jdbc:h2:mem:toby-spring");
            dataSource.setUsername("sa");
            dataSource.setPassword("");
      
            return dataSource;
        }
        }
      

2장 테스트

2.1 UserDaoTest 다시 보기

  • 작은 단위의 테스트
    • 테스트하고자 하는 대상이 명확하다면 그 대상에만 집중해서 테스트하는 것이 바람직하다.
    • 관심사의 분리
    • = Unit Test
    • 통제할 수 없는 외부의 리소스에 의존하는 테스트는 단위 테스트가 아니라고 보기도 한다.
      • 예) DB가 매번 달라지고, 테스트를 위해 DB를 특정상태로 만들 수 없다면
  • 자동수행 테스트 코드
    • 테스트를 자주 수행해도 부담이 없도록 자동으로 수행될 수 있어야 한다.
  • 지속적인 개선과 점진적인 개발을 위한 테스트
    • 지속적인 개선의 작은 단계를 거치는 동안 테스트를 수행해 확신을 가지고 코드를 변경하면, 전체적으로 코드를 개선하는 작업에 속도가 붙고 더 쉬워진다.
    • 새로운 기능이 기대대로 동작하는지 확인할 수 있을 뿐 아니라, 기존 기능들이 수정한 코드에 영향받지 않고 여전히 잘 동작하는지를 확인할 수 있다.
  • UserDaoTest의 문제점
    • 수동 확인 작업의 번거로움
    • 실행 작업의 번거로움

2.2 UserDaoTest 개선

  • 테스트 검증의 자동화
    • 모든 테스트는 성공과 실패의 두 가지 결과를 가질 수 있다.
      • 또 실패는 테스트가 진행되는 동안에 에러가 발생해서 실패하는 경우(테스트 에러)와
      • 테스트 작업 중에 에러가 발생하진 않았지만 그 결과가 기대한 것과 다르게 나오는 경우(테스트 실패)로 구분해볼 수 있다.
  • 테스트에 일부 자동화 코드 추가
      if (!user.getName().equals(user2.getName())) {
          System.out.println("테스트 실패 (name)");
      } else if (!user.getPassword().equals(user2.getPassword())) {
          System.out.println("테스트 실패 (password)");
      } else {
          System.out.println("테스트 성공");
      }
    

    테스트를 돌리고 마지막 출력메시지가 “테스트 성공” 이라고 나오는지 확인하는 것 뿐이다.

  • 테스트란 개발자가 마음 편하게 잠자리에 들 수 있게 해주는 것 - 켄트 벡

  • 개발 및 유지보수 과정에서 코드 수정 시 마음의 평안을 얻고
    • 자신이 만지는 코드에 대해 항상 자신감을 가질 수 있으며
    • 새로 도입한 기술 적용에 문제가 없는 지 확인할 수 있는 가장 좋은 방법은
    • 빠르게 실행 가능한 자동화된 테스트를 만들어두는 것이다.
  • JUnit 테스트로 전환
    • JUnit 은 프레임워크
        @Test
        public void addAndGet() throws SQLException, ClassNotFoundException {
        // given
        ApplicationContext context = new AnnotationConfigApplicationContext(CountingDaoFactory.class);
        UserDao dao = context.getBean("userDao", UserDao.class);
      
        User user = new User();
        user.setId("choising");
        user.setName("seungmin");
        user.setPassword("hello");
      
        dao.add(user);
        User user2 = dao.get(user.getId());
              
        // expect
        assertEquals(user2.getName(), user.getName());
        assertEquals(user2.getPassword(), user.getPassword());
        }
      

2.3 JUnit

  • UserDao 클래스 deleteAll(), getCount() 메소드 추가
      public void deleteAll() throws SQLException, ClassNotFoundException {
          Connection c = connectionMaker.makeConnection();
    
          PreparedStatement ps = c.prepareStatement("delete from user");
          ps.executeUpdate();
    
          ps.close();
          c.close();
      }
    
      public int getCount() throws SQLException, ClassNotFoundException {
          Connection c = connectionMaker.makeConnection();
    
          PreparedStatement ps = c.prepareStatement("select count(*) from user");
    
          ResultSet rs = ps.executeQuery();
          rs.next();
          int count = rs.getInt(1);
    
          rs.clearWarnings();
          ps.close();
          c.close();
    
          return count;
      }
    
  • 기존 테스트코드에 deleteAll(), getCount() 검증 코드 추가
      @Test
      public void addAndGet() throws SQLException, ClassNotFoundException {
          ApplicationContext context = new AnnotationConfigApplicationContext(CountingDaoFactory.class);
          UserDao dao = context.getBean("userDao", UserDao.class);
    
          dao.deleteAll();
          assertEquals(dao.getCount(), 0);    // here
          User user = new User();
          user.setId("choising");
          user.setName("seungmin");
          user.setPassword("hello");
    
          dao.add(user);
          assertEquals(dao.getCount(), 1);    // here
          User user2 = dao.get(user.getId());
    
          assertEquals(user2.getName(), user.getName());
          assertEquals(user2.getPassword(), user.getPassword());
      }
    
  • getCount() 테스트 케이스 추가
      @Test
      public void count() throws SQLException, ClassNotFoundException {
          ApplicationContext context = new AnnotationConfigApplicationContext(CountingDaoFactory.class);
          UserDao dao = context.getBean("userDao", UserDao.class);
    
          User user1 = new User("zingo", "노징고", "1234");
          User user2 = new User("choising", "최싱", "12345");
          User user3 = new User("forever", "포에버", "123456");
    
          dao.deleteAll();
          assertEquals(dao.getCount(), 0);
    
          dao.add(user1);
          assertEquals(dao.getCount(), 1);
    
          dao.add(user2);
          assertEquals(dao.getCount(), 2);
    
          dao.add(user3);
          assertEquals(dao.getCount(), 3);
      }
    
  • addAndGet() 테스트 케이스 보완 및 추가
      @Test
      public void addAndGet() throws SQLException, ClassNotFoundException {
          ApplicationContext context = new AnnotationConfigApplicationContext(CountingDaoFactory.class);
          UserDao dao = context.getBean("userDao", UserDao.class);
    
          User user1 = new User("zingo", "노징고", "1234");
          User user2 = new User("choising", "최싱", "12345");
    
          dao.deleteAll();
          assertEquals(dao.getCount(), 0);
    
          dao.add(user1);
          dao.add(user2);
          assertEquals(dao.getCount(), 2);
    
          User userGet1 = dao.get(user1.getId());
          assertEquals(userGet1.getName(), user1.getName());
          assertEquals(userGet1.getPassword(), user1.getPassword());
    
          User userGet2 = dao.get(user2.getId());
          assertEquals(userGet2.getName(), user2.getName());
          assertEquals(userGet2.getPassword(), user2.getPassword());
      }
    

    파라미터로 주어진 id에 해당하는 사용자를 가져오는 지 검증

  • get() 메소드의 예외상황 테스트
      @Test
      public void getUserFailure() throws SQLException, ClassNotFoundException {
          ApplicationContext context = new AnnotationConfigApplicationContext(CountingDaoFactory.class);
          UserDao dao = context.getBean("userDao", UserDao.class);
            
          dao.deleteAll();
          assertEquals(dao.getCount(), 0);
            
          assertThrows(EmptyResultDataAccessException.class, () -> {
              dao.get("unkown_id");
          });
      }
    

    테스트는 실패한다. get() 메소드에서 쿼리 결과의 첫 번째 row를 가져오게하는 rs.next()를 실행할 때 가져올 row 가 없기 때문. 해당 테스트가 성공하도록 get() 메소드를 수정한다.

      public User get(String id) throws SQLException, ClassNotFoundException {
          // ... (생략)
            
          User user = null;
          if (rs.next()) {
              user = new User();
              user.setId(rs.getString("id"));
              user.setName(rs.getString("name"));
              user.setPassword(rs.getString("password"));
          }
    
          // ... (생략)
    
          if (user == null) {
              throw new EmptyResultDataAccessException(1);
          }
    
          return user;
      }
    
  • 부정적인 케이스를 먼저 만드는 습관을 들이는 게 좋다.
    • 예외적인 상황을 빠뜨리지 않는 꼼꼼한 개발이 가능하다.
  • 테스트 주도 개발(TDD)
    • 이처럼 테스트 코드를 먼저 만들고, 테스트를 성공하게 해주는 코드를 작성하는 방식의 개발
    • = 테스트 우선 개발(TFD)
    • “실패한 테스트를 성공시키기 위한 목적이 아닌 코드는 만들지 않는다” 는 기본 원칙
  • 테스트 코드 개선
    • 세 개의 테스트 메소드에 반복적으로 등장하는 앞의 코드 중복 제거
        ...
        private UserDao dao;
      
        @BeforeEach
        public void setUp() {
            ApplicationContext context = new AnnotationConfigApplicationContext(CountingDaoFactory.class);
            this.dao = context.getBean("userDao", UserDao.class);
        }
        ...
      
    • JUnit 프레임워크의 테스트 메소드 실행 과정
      1. 테스트 클래스에서 @Test 가 붙은 public, void, 파라미터가 없는 테스트 메소드를 모두 찾는다.
      2. 테스트 클래스의 오브젝트를 하나 만든다.
      3. @Before가 붙은 메소드가 있으면 실행한다.
      4. @Test가 붙은 메소드를 하나 호출하고 테스트 결과를 저장해둔다.
      5. @After가 붙은 메소드가 있으면 실행한다.
      6. 나머지 테스트 메소드에 대해 2~5번을 반복한다.
      7. 모든 테스트의 결과를 종합하여 돌려준다.
      • JUnit5 에서는 @BeforeEach@AfterEach 를 사용하면 동일한 기대결과를 볼 수 있다.
      • 테스트 메소드 실행시 마다 새로운 오브젝트를 만들기에, 각 테스트가 서로 영향을 주지 않고 독립적으로 실행됨이 확실히 보장된다.
      • 또한 인스턴스 변수도 부담없이 사용할 수 있다.
      • 테스트 메소드 일부에서만 공통적으로 사용되는 코드가 있다면 @Before 보다 일반적인 메소드 - 추출방법으로 메소드를 분리하는 것이 낫다.
  • 픽스처 (Fixture)
    • 테스트를 수행하는 데 필요한 정보나 오브젝트
    • UserDaoTest 의 dao 가 픽스처

2.4 스프링 테스트 적용

  • 테스트 메소드의 컨텍스트 공유
    • JUnit 확장기능은 테스트가 실행하기 전 딱 한 번 애플리케이션 컨텍스트를 만들어 두고, 테스트 오브젝트가 만들어질 때마다 특별한 방법을 이용해 애플리케이션 컨텍스트 자신을 오브젝트의 특정 필드에 주입한다.
  • 테스트 클래스의 컨텍스트 공유
    • 여러 개의 테스트 클래스가 있는데 모두 같은 설정파일을 가진 애플리케이션 컨텍스트를 사용한다면, 테스트 클래스 사이에서도 애플리케이션 컨텍스트를 공유한다.
  • 컨텍스트를 공유함으로 성능 대폭 향상

  • @DirtiesContext
    • 애플리케이션 컨텍스트 상태를 변경한다는 것을 알려준다.
    • 이 annotation 이 붙은 테스트 클래스(메소드) 에는 애플리케이션 컨텍스트 공유를 하지 않는다.
    • @DirtiesContext 을 사용하면 테스트 성능이 나빠짐으로 테스트를 위한 별도의 DI 설정으로 해결할 수도 있다.
  • 일반적으로 테스트하기 좋은 코드가 좋은 코드일 가능성이 높다.

  • 스프링 컨테이너 없이 테스트할 수 있는 방법을 최우선 고려
    • 가장 수행속도가 빠르고 테스트 자체가 간결하다.

2.5 학습 테스트로 배우는 스프링

  • 학습 테스트
    • 자신이 만들지 않은 프레임워크/라이브러리 등을 테스트하는 행위
    • 목적은 API나 프레임워크의 기능을 테스트로 보면서 사용방법을 익히려는 것
    • 기능검증 목적이 아닌 사용방법 검증 목적
  • 학습 테스트의 장점
    • 다양한 조건에 따른 기능을 손쉽게 확인해볼 수 있다.
    • 학습 테스트 코드를 개발 중에 참고할 수 있다.
    • 프레임워크나 제품을 업그레이드할 때 호환성 검증을 도와준다.
    • 테스트 작성에 대한 좋은 훈련이 된다.
    • 새로운 기술을 공부하는 과정이 즐거워진다.
      • 이건 너무 주관적인 것 아닐까?
  • 버그 테스트
    • 코드에 오류가 있을 때 그 오류를 가장 잘 드러내줄 수 있는 테스트
  • 버그 테스트의 장점
    • 테스트의 완성도를 높여준다.
    • 버그의 내용을 명확하게 분석해준다.
    • 기술적인 문제를 해결하는 데 도움이 된다.

3장 템플릿

  • OCP
    • 코드에서 어떤 부분은 변경을 통해 그 기능이 다양해지고 확장하려는 성질
    • 어떤 부분은 고정되어있고 변하지 않으려는 성질
    • 변화의 특성이 다른 부분을 구분하고, 각각 다른 목적과 다른 이유에 의해 다른 시점에 독립적으로 변경될 수 있는 효율적인 구조를 만들어주는 것이 바로 OCP(개방폐쇄원칙)
  • 템플릿이란, 이렇게 바뀌는 성질이 다른 코드 중에서 변경이 거의 일어나지 않으며 일정한 패턴으로 유지되는 특성을 가진 부분을 독립시켜서 효과적으로 활용할 수 있도록 하는 방법

3.1 다시 보는 초난감 DAO

  • deleteAll() 메소드에서 예외처리가 되어있지 않은 예.
      public void deleteAll() throws SQLException, ClassNotFoundException {
          Connection c = connectionMaker.makeConnection();
    
          PreparedStatement ps = c.prepareStatement("delete from user");
          ps.executeUpdate();             // 이런데서 작업을 하다가 죽으면 리소스가 반환되지 않는다.
    
          ps.close();
          c.close();
      }
    
  • 예외처리
      public void deleteAll() throws SQLException, ClassNotFoundException {
          Connection c = null;
          PreparedStatement ps = null;
            
          try {
              c = connectionMaker.makeConnection();
              ps = c.prepareStatement("delete from user");
              ps.executeUpdate();
          } catch (SQLException e) {
              throw e;
          } finally {
              if (ps != null) {
                  try {
                      ps.close();
                  } catch (SQLException e) {
                        
                  }
              }
                
              if (c != null) {
                  try {
                      c.close();
                  } catch (SQLException e) {
                        
                  }
              }
          }
      }
    
  • 수많은 부분이 변하지 않는 부분이 된다. 위 코드에서 변하는 부분은 ps = c.prepareStatement("delete from user") 이 Line 뿐이다.

  • 변하는 부분을 메소드로 추출
      public void deleteAll() throws SQLException, ClassNotFoundException {
          ...
          ps = this.makeStatement(c);
          ...
      }
    
      private PreparedStatement makeStatement(Connection c) throws SQLException {
          PreparedStatement ps;
          ps = c.prepareStatement("delete from user");
          return ps;
      }
    
  • 해당 부분을 추상메소드로 만들고, 해당 클래스를 추상클래스로 변경 후 구체적인 makeStatement() 정의를 서브클래스에게 위임한다.

      public abstract class UserDao {
          ...
          abstract protected PreparedStatement makeStatement(Connection c) throws SQLException;
          ...
      }
    

    makeStatement() 를 구현한 UserDao 서브클래스

      public class UserDaoDeleteAll extends UserDao {
    
          @Override
          protected PreparedStatement makeStatement(Connection c) throws SQLException {
              PreparedStatement ps;
              ps = c.prepareStatement("delete from user");
              return ps;
          }
      }
    
  • 이처럼 변하지 않는 부분은 슈퍼클래스에 두고 변하는 부분은 추상 메소드로 정의해둬서 서브클래스에서 오버라이드하여 새롭게 정의해 쓰도록 하는 것이 템플릿 메소드 패턴
    • 상속을 통해 기능을 확장해서 사용하는 것
  • 클래스 기능을 확장하고 싶을 때 마다 상속을 통해 확장이 가능한 구조가 되었다.
    • 상위 DAO 클래스에 불필요한 변화는 생기지 않는 구조
    • 하지만 이렇게되면 DAO 로직마다 상속을 통해 새로운 클래스를 만들어야 한다.
    • UserDao 처럼 JDBC 메소드가 4개인 경우
      • TemplateMethod
    • 클래스를 설계하는 시점에서 구조가 고정된다.
      • 관계에 대한 유연성이 떨어진다.
  • 전략패턴을 적용해보자
    • OCP를 잘 지키면서, 템플릿 메소드 패턴보다 유연하고 확장성이 뛰어남
    • 오브젝트를 컨텍스트(context) 와 전략(Strategy) 로 분리하고, 클래스 레벨에서는 인터페이스를 통해서만 의존하도록 만드는 패턴
    • 확장에 해당하는 변하는 부분을 별도의 클래스로 만들어 추상화된 인터페이스를 통해 위임하는 방식
    • Context 의 contextMethod() 에서 일정한 구조를 가지고 동작,
      • 특정 확장 기능은 Strategy 인터페이스를 통해 외부의 독립된 전략 클래스에 위임
    • deleteAll() 메소드에서 변하지 않는 부분이 contextMethod() 가 될 것이다.
      • JDBC를 이용한 DB 업데이트 작업 이라는 변하지 않는 맥락(context) 를 갖는다.
      • deleteAll() 의 Context
        • DB 커넥션 가져오기
        • PreparedStatement 를 만들어줄 외부 기능 호출하기
        • 전달받은 PreparedStatement 실행하기
        • 예외 throw
        • 리소스 close
      • 볼드처리한 해당 부분이 전략에 해당한다.
        public interface StatementStrategy {
            PreparedStatement makePreparedStatement(Connection c) throws SQLException;
        }
      
        public class DeleteAllStatement implements StatementStrategy {
            @Override
            public PreparedStatement makePreparedStatement(Connection c) throws SQLException {
                return c.prepareStatement("delete from user");
            }
        }
      
        public class UserDao {
            ...
            public void deleteAll() throws SQLException, ClassNotFoundException {
                Connection c = null;
                PreparedStatement ps = null;
      
                try {
                    StatementStrategy strategy = new DeleteAllStatement();            
                    ps = strategy.makePreparedStatement(c);
                    ps.executeUpdate();
                    ...
                }
                ...
            }
      

      컨텍스트 안에서 구체적인 전략 클래스인 DeleteAllStatement 를 사용하도록 고정되어 있는 것이 문제

  • DI 적용을 위한 클라이언트/컨텍스트 분리
    • 전략패턴에 따르면 Context가 어떤 전략을 사용하게 할 것인가는 앞단의 Client 가 결정하는 것이 일반적
    • StrategyPattern

    UserDao.class

      public void deleteAll() throws SQLException, ClassNotFoundException {
          jdbcContextWithStatementStrategy(new DeleteAllStatement());
      }
    
      private void jdbcContextWithStatementStrategy(StatementStrategy strategy) throws ClassNotFoundException, SQLException {
          Connection c = null;
          PreparedStatement ps = null;
    
          try {
              c = connectionMaker.makeConnection();
              ps = strategy.makePreparedStatement(c);
              ps.executeUpdate();
          } catch (SQLException e) {
              throw e;
          } finally {
              if (ps != null) {
                  try {
                      ps.close();
                  } catch (SQLException e) {
    
                  }
              }
    
              if (c != null) {
                  try {
                      c.close();
                  } catch (SQLException e) {
    
                  }
              }
          }
      }
    

    deleteAll() 메소드가 Client 클라이언트와 컨텍스트 클래스를 분리하지는 않았음

  • 결국 이 구조에서 오브젝트 생성과 컨텍스트로의 전달을 담당하는 책임을 분리시킨 것이 바로 ObjectFactory 이며, 이를 일반화 한 것이 앞에서 살펴봤던 의존관계 주입(DI)
    • DI란 이러한 전략패턴의 장점을 일반적으로 활용할 수 있도록 만든 구조
    • 일반적으로 DI는 의존관계에 있는 두 개의 오브젝트와 이 관계를 다이내믹하게 설정해주는 오브젝트 팩토리(DI 컨테이너), 그리고 이를 사용하는 클라이언트라는 4개의 오브젝트 사이에서 일어난다.
    • 때로는 구조에 따라 하나의 클래스가 클라이언트와 오브젝트 팩토리의 책임을 함께 지고 있을 수도 있고, 클라이언트와 전략, 클라이언트와 DI 관계에 있는 두 개의 오브젝트가 모두 하나의 클래스에 담길 수도 있다.
    • 이런 경우에는 DI가 매우 작은 단위의 코드와 메소드 사이에서 일어나기도 한다.
      • 이런 케이스를 마이크로 DI 또는 코드에 의한 DI라는 의미로 수동 DI 라 한다.
  • add() 메소드 개선하기
      public class AddStatement implements StatementStrategy {
    
          private User user;
    
          public AddStatement(User user) {
              this.user = user;
          }
    
          @Override
          public PreparedStatement makePreparedStatement(Connection c) throws SQLException {
              PreparedStatement ps = c.prepareStatement(
                      "insert into user (id, name, password) values (?,?,?)");
    
              ps.setString(1, user.getId());
              ps.setString(2, user.getName());
              ps.setString(3, user.getPassword());
    
              return ps;
          }
      }
    
      public class UserDao {
          ...
          public void add(User user) throws SQLException, ClassNotFoundException {
              jdbcContextWithStatementStrategy(new AddStatement(user));
          }
          ...
      }
    
  • 현 구조의 단점은
    • DAO 메소드마다 새로운 StatementStrategy 구현 클래스를 만들어야 한다는 점
    • User 와 같은 부가정보가 필요할 경우 인스턴스 변수를 만들어야 한다는 점
  • 로컬 클래스
    • 클래스 파일이 많아지는 문제를 해결
    • UserDao 클래스 안에 내부 클래스로 정의하는것
        public class UserDao {
      
        ...
        public void add(User user) throws SQLException, ClassNotFoundException {
            class AddStatement implements StatementStrategy {
      
                @Override
                public PreparedStatement makePreparedStatement(Connection c) throws SQLException {
                    PreparedStatement ps = c.prepareStatement(
                            "insert into user (id, name, password) values (?,?,?)");
      
                    ps.setString(1, user.getId());
                    ps.setString(2, user.getName());
                    ps.setString(3, user.getPassword());
      
                    return ps;
                }
            }
                  
            jdbcContextWithStatementStrategy(new AddStatement());
        }
        ...
        }
      

    메소드 안에 클래스를 넣는 건 처음 봄. 신기하당. 클래스 파일이 줄어드는 것과, 내부 클래스의 특징을 이용해 로컬 변수를 바로 가져다 사용할 수 있는 장점

  • 익명 클래스
      public class UserDao {
          ...
          public void add(User user) throws SQLException, ClassNotFoundException {
              jdbcContextWithStatementStrategy(new StatementStrategy() {
                  @Override
                  public PreparedStatement makePreparedStatement(Connection c) throws SQLException {
                      PreparedStatement ps = c.prepareStatement(
                              "insert into user (id, name, password) values (?,?,?)");
    
                      ps.setString(1, user.getId());
                      ps.setString(2, user.getName());
                      ps.setString(3, user.getPassword());
    
                      return ps;
                  }
          });
          ...
      }
    
  • 중첩 클래스
    • 독립적인 오브젝트로 만들어질 수 있는 static class
    • 정의된 클래스의 오브젝트 안에서만 만들어질 수 있는 내부 클래스(inner class)
      • scope 에 따라 세 가지로 구분된다.
      • 멤버 필드 처럼 오브젝트 레벨에 정의되는 member inner class
      • 메소드 레벨에 정의되는 local class
      • 익명 내부 클래스
  • 전략 패턴의 구조로 보자면 UserDao의 메소드가 Client, 익명 내부 클래스가 개별 전략, jdbcContextWithStatementStrategy() 메소드는 컨텍스트.
    • 컨텍스트 메소드는 UserDao 내의 PreparedStatemnet 를 실행하는 기능
    • 이 메소드는 Specific 하게 UserDao 용이 아니라, JDBC의 일반적인 작업 흐름을 담고 있으므로, UserDao 클래스 밖으로 독립시켜 모든 DAO가 사용할 수 있게 할 수 있다.
        public class UserDao {
      
        private ConnectionMaker connectionMaker;
      
        private JdbcContext jdbcContext;
              
        ...
        }
      
    • JdbcContext 를 Bean 으로 등록, UserDao 가 JdbcContext Class 를 DI 받는 방식
      • 인터페이스가 아닌 클래스를 직접 DI
      • 강한 응집도를 갖는 클래스, 다른 구현체 변경 니즈가 없는 경우.
      • 귀찮으니까 클래스를 사용하자는 건 잘못된 생각이지만, 이같은 경우 클래스를 직접 DI 해도 ㄱㅊ
      • 찝찝하면 이또한 인터페이스로 해도 ㄱㅊ
    • UserDao 의 수동 DI
        public class UserDao {
      
            private ConnectionMaker connectionMaker;
      
            private JdbcContext jdbcContext;
      
            public UserDao(ConnectionMaker connectionMaker) {
                this.connectionMaker = connectionMaker;
                this.jdbcContext = new JdbcContext(this.connectionMaker);
            }
        }
      
      • 굳이 인터페이스를 둘 필요 없는 긴밀한 관계는 어색하게 따로 빈으로 분리하지 않고, 내부에서 직접 만들어 사용할 수도 있음.
      • 분명하게 설명할 자신이 없다면 인터페이스를 사이에 둔 평범한 DI 가 좋을지도..?

3.5 템플릿과 콜백

  • 전략 패턴의 기본 구조
    • 복잡하지만 바뀌지 않는 일정한 패턴을 갖는 작업흐름(context)
    • 일부분만 자주 바꿔서 사용해야 하는 경우(strategy)
  • 위에서 적용한 방식은 전략 패턴의 구조에 익명 내부클래스를 활용한 방식
    • 이런 방식을 스프링에서 템플릿/콜백 패턴 이라고 부른다.
    • context = template
    • 익명 내부 클래스 오브젝트 = callback
  • 템플릿
    • 고정된 작업 흐름을 가진 코드를 재사용한다는 의미
  • 콜백
    • 실행되는 것을 목적으로 다른 오브젝트의 메소드에 전달되는 오브젝트
    • 파라미터로 전달되지만 값을 참조하기 위한 것이 아니라 특정 로직을 담은 메소드를 실행시키기 위함
    • functional object
  • 템플릿/콜백의 특징
    • 콜백은 보통 단일 메소드 인터페이스를 구현한 익명 내부 클래스 (전략 패턴의 전략은 여러 메소드 가능)
    • 템플릿의 작업 흐름 중 특정 기능을 위해 한 번 호출되는 경우가 일반적이기 때문
    • 여러 가지 종류의 전략을 사용해야 한다면 하나 이상의 콜백 오브젝트를 사용할 수도?
    • 콜백 인터페이스의 메소드에는 보통 파라미터가 있다.
      • 템플릿 작업 흐름 중에 만들어지는 컨텍스트 정보를 전달
  • TemplateCallback
    • 클라이언트는 템플릿 안에서 실행될 로직을 담은 콜백 오브젝트 생성 , 템플릿 메소드 호출 시 파라미터로 전달
    • 템플릿은 정해진 작업 흐름을 따라 진행, 내부의 참조정보를 가지고 콜백 오브젝트 메소드 호출
      • 콜백은 클라이언트 메소드 정보와 템플릿이 제공한 참조정보를 이용해서 작업을 수행하고 그 결과를 템플릿에 return
    • 템플릿은 콜백 return 정보를 사용하여 작업을 마무리, 경우에 따라 클라이언트에 return
  • 클라이언트가 템플릿 메소드를 호출하면서 콜백 오브젝트를 전달하는 것은 메소드레벨의 DI
  • 전략패턴과 DI의 장점을 익명 내부 클래스 사용 전략과 결합한 독특한 활용법

콜백의 분리와 재활용

  • 익명 내부 클래스 사용을 최소화 해보자
    • 익명 내부 클래스를 사용하는 부분을 메소드로 추출하고, 변경되는 부분 (문자열) 만 전달하도록 변경
        public class UserDao {
        ...
        public void deleteAll() throws SQLException, ClassNotFoundException {
            executeSql("delete from user");
        }
      
        private void executeSql(final String query) throws ClassNotFoundException, SQLException {
            this.jdbcContext.workWithStatementStrategy(new StatementStrategy() {
                @Override
                public PreparedStatement makePreparedStatement(Connection c) throws SQLException {
                    return c.prepareStatement(query);
                }
            });
        }
        ...
        }
      

      모든 고정된 sql 을 실행하는 dao 메소드는 deleteAll() 메소드처럼 executeSql() 을 호출하는 한 줄이면 끝.

  • 이건 UserDao 에만 종속되는 코드가 아니므로 공통으로 다시 추출
    • 클라이언트는 익명 내부 클래스 사용도 없고 깔끔, 단순
    • JdbcContext 안에 클라이언트와 템플릿, 콜백이 모두 함께 공존하면서 동작하는 구조
  • 고정된 작업 흐름을 갖고 있으면서 여기저기서 자주 반복되는 코드가 있다면, 중복되는 코드를 분리할 방법을 생각해보는 습관을 기르자.

    • 먼저 메소드로 분리하는 간단한 시도를 해보고
    • 일부 작업을 필요에 따라 바꾸어 사용해야 한다면 인터페이스를 사이에 두고 분리해서 전략 패턴을 적용하고 DI로 의존관계를 관리하도록
    • 바뀌는 부분이 동시에 여러 종류가 있다면 템플릿/콜백 패턴 적용을 고려

간단한 템플릿/콜백 예제

public interface LineCallback {
    int doSomethingWithLine(String line, int value);
}
public class Calculator {

    public int calcSum(String filepath) throws IOException {
        LineCallback sumCallback = (line, value) -> value + Integer.parseInt(line);
        return this.lineReadTemplate(filepath, sumCallback, 0);
    }

    public int calcMultiply(String filepath) throws IOException {
        LineCallback multiplyCallback = (line, value) -> value * Integer.parseInt(line);
        return this.lineReadTemplate(filepath, multiplyCallback, 1);
    }

    public int lineReadTemplate(String filepath, LineCallback callback, int initVal) throws IOException {
        BufferedReader br = null;
        try {
            br = new BufferedReader(new FileReader(filepath));
            int res = initVal;
            String line = null;

            while ((line = br.readLine()) != null) {
                res = callback.doSomethingWithLine(line, res);
            }
            return res;
        } catch (IOException e) {
            System.out.println(e.getMessage());
            throw e;
        } finally {
            if (br != null) {
                try {
                    br.close();
                } catch (IOException e) {
                    System.out.println(e.getMessage());
                }
            }
        }
    }

}
public class CalcTest {

    Calculator calculator;
    String numFilePath;

    @BeforeEach
    public void setUp() {
        this.calculator = new Calculator();
        this.numFilePath = getClass().getResource("/numbers.txt").getPath();
    }

    @Test
    public void sumOfNumbers() throws IOException {
        assertEquals(calculator.calcSum(this.numFilePath), 10);
    }

    @Test
    public void multiplyOfNumbers() throws IOException {
        assertEquals(calculator.calcMultiply(this.numFilePath), 24);
    }
}
  • 제너릭 추가하기
    public interface LineCallback<T> {
      T doSomethingWithLine(String line, T value);
    }
    
public class Calculator {

    public int calcSum(String filepath) throws IOException {
        LineCallback<Integer> sumCallback = (line, value) -> value + Integer.parseInt(line);
        return this.lineReadTemplate(filepath, sumCallback, 0);
    }

    public int calcMultiply(String filepath) throws IOException {
        LineCallback<Integer> multiplyCallback = (line, value) -> value * Integer.parseInt(line);
        return this.lineReadTemplate(filepath, multiplyCallback, 1);
    }

    public String concatenate(String filepate) throws IOException {
        LineCallback<String> concatenateCallback = (line, value) -> value + line;
        return this.lineReadTemplate(filepate, concatenateCallback, "");
    }

    public <T> T lineReadTemplate(String filepath, LineCallback<T> callback, T initVal) throws IOException {
        BufferedReader br = null;
        try {
            br = new BufferedReader(new FileReader(filepath));
            T res = initVal;
            String line = null;

            while ((line = br.readLine()) != null) {
                res = callback.doSomethingWithLine(line, res);
            }
            return res;
        } catch (IOException e) {
            System.out.println(e.getMessage());
            throw e;
        } finally {
            if (br != null) {
                try {
                    br.close();
                } catch (IOException e) {
                    System.out.println(e.getMessage());
                }
            }
        }
    }

}

3.6 스프링의 JdbcTemplate

public class UserDao {

    private JdbcTemplate jdbcTemplate;

    public UserDao(DataSource dataSource) {
        this.jdbcTemplate = new JdbcTemplate(dataSource);
    }

    public void add(final User user) {
        this.jdbcTemplate.update("insert into user (id, name, password) values (?,?,?)",
                user.getId(), user.getName(), user.getPassword());
    }

    public User get(String id) {
        return this.jdbcTemplate.queryForObject("select * from user where id = ?", new Object[]{id},
                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;
                    }
                });
    }

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

    public int getCount() {
        return this.jdbcTemplate.queryForObject("select count(*) from user", Integer.class);
    }

    public List<User> getAll() {
        return this.jdbcTemplate.query("select * from user order by id",
                new RowMapper<User>() {
                    @Override
                    public User mapRow(ResultSet rs, int rowNum) throws SQLException {
                        return new User(rs.getString("id"), rs.getString("name"), rs.getString("password"));
                    }
                });
    }
}
  • Negative Test
      @Test
      public void getAllNegative() {
          dao.deleteAll();
    
          List<User> users = dao.getAll();
          assertEquals(users.size(), 0);
      }
    
    • 자신이 만든 메소드가 아니더라도, 크기가 0인 리스트 오브젝트를 return 하는지, null 을 return 하는지 확인해 볼 필요가 있다.

4장 예외

4.1

  • 예외가 발생하면 그것을 catch 로 잡고 아무것도 하지않고 넘어가는 것은 정말 위험한 일이다.
    • 원치않는 예외가 발생하는 것보다도 훨씬 더 나쁜 일이다.
    • 예외가 발생했는데 그것을 무시하고 계속 진행해버리기 때문
    • 어떠한 경우에도 이런 코드를 만들면 안된다.
        } catch (SQLException e) {
        System.out.println(e);  // 이것도
        e.printStackTrace();    // 이것도
        }
      
    • 예외 처리의 핵심 원칙은 모든 예외는 적절하게 복구되든지 아니면 작업을 중단시키고 통보되든지.
    • 예외를 무시하거나 잡아먹어 버리는 코드를 만들지 말라.
  • 무의미하고 무책임한 throws
      public void method1() throws Exception {
          method2();
      }
    
      public void method2() throws Exception {
          method3();
      }
    
      public void method3() throws Exception {
          ...
      }
    
    • 예외를 일일이 catch 하기도 귀찮고, 매번 정확하게 예외 이름을 적어서 선언하기도 귀찮아서.
    • 이 경우, 사용 시 throws Exception 으로 얻을 수 있는 정보가 없다.
      • 예외가 진짜 발생하는건지, 어떤 예외가 발생할 수 있는 건지, 습관적으로 복붙한건지…
    • 이것도 당연히 안티패턴

4.1.2 예외의 종류와 특징

자바에서 throw 를 통해 발생시킬 수 있는 예외는 크게 세 가지가 있다.

  1. Error
    • java.lang.Error 클래스의 서브클래스들
    • 주로 JVM 에서 발생 (OOM, ThreadDeath)
    • Appliaction 레벨에서 대응 방법이 없어 처리를 신경쓰지 않아도 된다.
  2. Exception / Checked Exception
    • java.lang.Exception 클래스와 서브클래스
    • Checked Excpetion 은 Exception 클래스의 서브클래스이면서 RuntimeException 클래스를 상속하지 않은 것
    • 반드시 예외처리를 해야한다
      • try/catch, throws
      • 그렇지 않으면 compile error
    • 자바 초기 설계자들은 전부 checked exception 을 사용하고자 했던 듯
      • 대표적으로 IOException, SQLException
  3. RuntimeException / Unchecked Exception
    • RuntimeException 클래스를 상속한 것
    • 예외처리를 강제하지 않는다.
    • 주로 프로그램의 오류
      • NPE, IllegalArgumentException

4.1.3 예외처리 방법

  1. 예외 복구
    • 예외상황을 파악하고 문제를 해결해서 정상 상태로 돌려놓는 것
    • 사용자에게 예외상황으로 비쳐도 애플리케이션에서는 정상적으로 설계된 흐름을 따라 진행되도록
    • 이를테면 네트워크가 불안하여 원격 DB 서버에 접속하다 실패해서 SQLException 이 발생할 경우 재시도 횟수를 정하고 재시도를 해볼 수 있다.
      • 복구가 될 수도 있지만 정해진 횟수를 초과하면 복구를 포기해야 함
    • 예외처리를 강제하는 checked exception 은 어떤식으로든 복구할 가능성이 있는 경우
  2. 예외처리 회피
    • throws, catch -> rethrow
    • 템플릿/콜백 패턴에서 콜백 오브젝트 메소드는 대부분 예외를 회피한다.
      • 콜백 오브젝트의 역할이 아니라고 보기 때문, 템플릿 레벨에서 처리
    • 무책임한 회피는 ㄴㄴ
      • 다른 오브젝트에게 예외처리 책임을 분명히 지게 하거나
      • 사용하는 쪽에서 예외를 다루는 게 최선의 방법이라는 확신이 있어야 한다.
  3. 예외 전환
    • 발생한 예외를 적절한 예외로 전환하여 메소드 밖으로 던지는 것
      • 첫째 목적
        • 예외상황에 대한 의미를 분명하게 해줄 수 있는 예외로 변경하기 위해
        • 예를 들어 회원가입 시 아이디가 같은 사용자가 있어 DB Save 에서 SQLException 이 발생하였다면
          • 서비스 계층에서는 왜 SQLException 이 발생했는지 알 수 없다.
          • 이 때 DupliacateUserIdException 같은 예외로 변경해서 던진다면?
      • 둘째 목적
        • 예외를 처리하기 쉽고 단순하게 만들기 위해 포장(wrap) 하는 것
        • 예를 들어 비즈니스 로직에서 의미 있는 예외거나 복구 가능한 예외가 아닌 checked Exception 을 runtime Exception 으로 wraping 하여 던지는 것
    • 전환하는 예외는 보통 발생한 예외를 담아 중첩 예외로 만드는 것이 좋다.
        catch(SQLException e) {
            ...
            throw Duplication(e);
        }
      

4.1.4 예외처리 전략

  • 자바가 처음 만들어 질 때 많이 사용되던 애플릿이나 AWT, 스윙을 사용한 독립형 애플리케이션에서는 통제 불가능한 시스템 예외여도 애플리케이션 작업이 중단되지 않게 해주고 상황을 복구해야 했다.
    • 예를 들어 워드 파일 열기 기능에서 입력한 이름의 파일을 찾을 수 없다고 애플리케이션이 종료될 수는 없기에
  • 하지만 자바 엔터프라이즈 서버환경은 다르다.
    • 수많은 사용자 각각의 독립적인 요청(작업)의 예외발생 시 작업을 일시 중지하고 사용자와 바로 커뮤니케이션 하여 예외상황을 복구할 수 있는 방법이 없다.
    • 예외가 발생하지 않도록 차단하는 것과, 예외 발생 시 해당 요청의 작업을 취소하고 서버관리자나 개발자에게 통보해주는 편이 낫다.
  • 때문에 checked exception 의 활용도와 가치는 점점 떨어지고 있다.
  • 예전에는 복구할 가능성이 조금만 보이면 checked exception 으로 만들 생각을 했으나, 이제는 항상 복구 가능한 상황이 아니면 unchecked exception 으로 만드는 경향

  • 런타임 예외를 사용하는 경우 컴파일러가 예외처리를 강제하지 않으므로 충분히 신경써야 한다.
    • API 문서나 레퍼런스 문서 등을 통해, 메소드를 사용할 때 발생할 수 있는 예외의 종류와 원인, 활용방법을 자세히 설명해두자.
  • 애플리케이션 예외
    • 비지니스 로직에서 처리되어야 하는 예외
      • 이를테면 잔고 이상의 돈을 출금하려 할 때,
    • 이 때는 반드시 catch 하여 에러처리를 하라는 의미로 checked exception 으로 만드는 것을 권장

4.2 예외 전환

  • JdbcTemplate 이 던지는 DataAccessException은 런타임 예외로 SQLException 을 포장해주는 역할을 한다.
    • 또한 상세한 예외정보를 의미있고 일관성 있는 예외로 전환해서 추상화해주려는 용도로 쓰이기도.
  • Jdbc 의 한계
    • 비표준 SQL
      • 각 벤더사 마다 조금씩 다른 SQL
      • 이건 7장에서 다시 보자.
    • SQLException의 비표준 에러코드
      • 각 벤더사의 전용 코드이다.
      • getSQLState() 로 예외상황에 대한 상태정보를 가져올 수는 있고, 이는 어느정도 표준이 있다.
      • 스프링 JdbcTemplate 은 DataAccessException 으로 SQLException 을 대체하고
        • DB별 에러코드를 분류해서 맵핑해놓음.
        • 때문에 DB의 종류와 상관 없이 중복 키 에러는 DuplicationKeyException 이 발생.
  • DataAccessException
    • DataAccessException 은 JDBC 의 SQLException 을 전환하는 용도로만 만들어 진 것이 아니다.
    • 의미가 같은 예외라면 JDBC 이외 자바 데이터 액세스 기술의 종류와 상관없이 일관된 예외가 발생하도록 만들어준다.
    • 데이터 액세스 기술에 독립적인 추상화된 예외를 제공하는 것.
      • 스프링은 진짜 천잰가..?
  • DAO 인터페이스와 구현과 분리
    • DAO를 따로 사용하는 가장 중요한 이유는 데이터 엑세스 로직을 담은 코드를 성격이 다른 코드에서 분리하기 위함.
    • 분리된 DAO는 전략패턴을 적용해 구현방법을 변경해서 사용할 수도 있고.
    • DAO를 사용하는 쪽에서 DAO 내부에서 어떤 데이터 액세스 기술을 사용하는지 신경쓰지 않아도 된다.
      • 구체적인 클래스 정보와 구현 방법을 감추고, DI를 통해 제공되도록 만드는 것이 바람직하다.
    • DataAccessException 예외 추상화를 잘 적용하면 이상적

4.2.4 기술에 독립적인 UserDao 만들기

  • 인터페이스 네이밍
    • I라는 접두어를 붙이는 방법
    • 인터페이스 이름을 단순화 하고 구현 클래스는 각각의 특징을 명시하는 방법
  • UserDao Interface
      public interface UserDao {
    
          void add(User user);
    
          User get(String id);
    
          List<User> getAll();
    
          void deleteAll();
    
          int getCount();
      }
    
  • UserDao 구현체, UserJdbcDao 클래스
      public class UserJdbcDao implements UserDao {
    
          private JdbcTemplate jdbcTemplate;
    
          public UserJdbcDao(DataSource dataSource) {
              this.jdbcTemplate = new JdbcTemplate(dataSource);
          }
    
          @Override
          public void add(User user) {
              ...
          }
    
          @Override
          public User get(String id) {
              ...
          }
    
          @Override
          public List<User> getAll() {
              ...
          }
    
          @Override
          public void deleteAll() {
              ...
          }
    
          @Override
          public int getCount() {
              ...
          }
      }
    
  • JdbcTemplate 을 사용했을 때 중복 키 테스트
      @Test
      public void duplicateKey() {
          dao.deleteAll();
    
          dao.add(user1);
          assertThrows(DuplicateKeyException.class, () -> {
              dao.add(user1);
          });
      }
    
    • DuplicateKeyException 은 JDBC 를 이용하는 경우에만 발생
      • 하이버네이트는 ConstraintViolationException 등
    • 때문에 DAO에서 사용하는 데이터 액세스 기술과 상관없이 동일한 예외를 얻고 싶다면 직접 예외를 정의해두고 예외전환 이 필요하다
  • 예외전환 학습테스트
      @Test
      public void sqlExceptionTranslate() {
          dao.deleteAll();
    
          try {
              dao.add(user1);
              dao.add(user1);
          } catch (DuplicateKeyException e) {
              SQLException sqlException = (SQLException)e.getRootCause();
              SQLExceptionTranslator sqlExceptionTranslator = new SQLErrorCodeSQLExceptionTranslator(this.dataSource);
              Assertions.assertTrue(sqlExceptionTranslator.translate(null, null, sqlException) instanceof DuplicateKeyException);
          }
      }
    
  • 핵심은 DAO 를 데이터 액세스 기술에서 독립시키려면 인터페이스 도입과 런테임 예외 전환, 기술에 독립적인 추상화된 예외로 전환이 필요하다.