[Spring] 토비의 스프링 3장 : 템플릿

헌치·2022년 11월 8일
1

Spring

목록 보기
9/13

템플릿/콜백 패턴은 템플릿 메소드 패턴과 다르다.

3장에서 다루는 패턴은 템플릿/콜백 패턴이다.

들어가며

이번 장에서 처음으로 템플릿/콜백 패턴을 접했다.
올해 2월 초, 우테코 프리코스 미션 구현 도중 try-catch문에 대한 반복을 줄이고 싶었던 적이 있었다. 이에 대한 답을 3장에서 찾을 수 있어 기뻤다.

책에서는 익명클래스 기준으로 설명하지만, 자바 8부터 도입된 람다를 통해 더 간결한 코드 작성이 가능하다.
또한 Try-with-resources를 통해 AutoCloseable 인터페이스를 구현한 클래스의 객체를 자동으로 닫아줄 수 있다.

💬
관련해 토프링 읽기모임에서 나왔던 얘기들을 노션 링크로 공유한다.

패턴들 각각의 목적, “의도”를 알아야 한다.

단순히 패턴을 공부하는 것을 넘어, 어떤 상황에서 어떤 디자인 패턴을 적용할 수 있을 지 고민해봐야겠다.

3.1 다시 보는 초난감 DAO

용어 정리

개방 폐쇄 원칙

  • 확장에는 자유롭게 열려 있고 변경에는 굳게 닫혀 있다는 객체지향 설계의 핵심 원칙
  • 개방 폐쇄의 원칙에서의 전제조건은 코드의 성질을 두 부류로 나눌 수 있다는 가정에서 출발한다.

변화의 특성이 다른 코드

  • 변경을 통해 그 기능이 다양해지며 확장되는 성질을 가진 코드가 있다.
  • 고정되어 있고 변하지 않으려는 성질을 가진 코드가 있다.

변화의 특성이 다른 부분을 구분해주고, 각각 다른 목적과 다른 이유에 의해 다른 시점에 독립적으로 변경될 수 있는 효율적인 구조를 만들어주는 것개방 폐쇄의 원칙이다.

템플릿이란?

코드 중

  • 변경이 덜하고, 일정한 패턴으로 유지되는 특성을 가진 부분
  • 자유롭게 변경되는 성질을 가진 부분으로부터 독립시킨다!

초난감 DAO는 예외상황에 대한 처리를 하지 않았다.

3.1.1 예외처리 기능을 갖춘 DAO

DB 커넥션은 DB의 소중한 자원이므로, 어떤 이유로든 예외가 발생하더라도 리소스를 반드시 반환해야 한다. 그렇지 않으면 시스템에 큰 문제를 일으킬 수 있다.

예외처리 없는 JDBC 코드

public void deleteAll() throws SQLException {
    Connection c = dataSource.getConnection();

    // 여기서 예외 발생 시 실행이 중단된다.
    PreparedStatement ps = c.prepareStatement("delete from users");
    ps.executeUpdate();

    ps.close();
    c.close();
}

위 메소드에서는 정상적인 흐름의 경우에는 ps.close()
c.close()가 잘 호출되어 리소스를 반환한다.

PreparedStatement를 처리하는 중에 예외가 발생하면?

  • 메소드 실행을 끝마치지 못하고 바로 메소드를 빠져나가게 되어
  • ConnectionPreparedStatementclose() 메소드가 실행되지 않아 제대로 리소스가 반환되지 않을 수 있다.

예외처리를 한 JDBC 코드

public void deleteAll() throws SQLException {
    Connection c = null;
    PreparedStatement ps = null;

    try {
        // 예외가 발생할 수 있는 부분은 전부 try 블록에 넣어준다.
        c = dataSource.getConnection();
        ps = c.prepareStatement("delete from users");
        ps.executeUpdate();
    } catch (SQLException e) {
        // 예외가 발생하면 던져준다.
        throw e;
    } finally {
        if(ps != null) {
            try {
                ps.close();
                // `ps.close()` 메소드에서도 `SQLException` 이 발생할 수 있다.
                // 이를 잡아주지 않으면, 아래 `Connection (c)`을 반환하는 로직이 수행되지 않을 수 있다.
            } catch (SQLException e) {
            }
        }

        if(c != null) {
            try {
                c.close(); // Connection 반환
            } catch (SQLException e) {
            }
        }
    }
}

JDBC 수정 기능의 예외처리 코드

조회를 위한 JDBC 코드는 더 복잡해진다. ResultSet이 더 추가되기 때문이다.

getCount()의 예외처리 후 코드

public int getCount() throws SQLException {

    Connection c = null;
    PreparedStatement ps = null;
    ResultSet rs = null;

    try {
        c = dataSource.getConnection();

        ps = c.prepareStatement("select count(*) from users");

        rs = ps.executeQuery();
        rs.next();
        return rs.getInt(1);
    }
    catch (SQLException e) {
        throw e;
    } finally {
        // `ResultSet`의 `null`을 체크하고 닫아주는 부분 추가
        if(rs != null) {
            try {
                rs.close();
            } catch (SQLException e) {
            }
        }

        if(ps != null) {
            try {
                ps.close();
            } catch (SQLException e) {
            }
        }

        if(c != null) {
            try {
                c.close();
            } catch (SQLException e) {
            }
        }
    }
}

JDBC 조회 기능의 예외처리

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

3.2.1 JDBC try/catch/finally 코드의 문제점

  • 보다시피, 복잡한 try/catch/finally 블록이 2중 중첩된다.
  • 누군가 DAO 로직을 수정하려고 했을 때, 복잡한 try/catch/finally 블록 안에서 필요한 부분을 찾아서 수정해야 한다!
  • 언젠가 필요한 부분을 잘못 삭제했는데 코드가 정상적으로 동작할 수도 있다.

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

new UserDao().deleteAll() 메소드를 먼저 개선해보자.

메소드 추출

public void deleteAll() throws SQLException {
    Connection c = null;
    PreparedStatement ps = null;

    try {
        c = dataSource.getConnection();

        ps = c.prepareStatement("delete from users"); // 변할 수 있는 부분

        ps.executeUpdate();
    } catch (SQLException e) {
        throw e;
    } finally {
        if(ps != null) { try { ps.close(); } catch (SQLException e) { } } // ps 리소스 반환
        if(c != null) { try { c.close(); } catch (SQLException e) { } } // c 리소스 반환
    }
}

s = c.prepareStatement("delete from users");

저 부분만 유연하게 변경될 수 있으면 ResultSet이 필요 없는 어떠한 쿼리도 같은 템플릿으로 처리할 수 있게 될 것이다.

메소드를 추출하면 될까?

public void deleteAll() throws SQLException {
    Connection c = null;
    PreparedStatement ps = null;

    try {
        c = dataSource.getConnection();

        ps = makeStatement(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) { } }
    }
}

private PreparedStatement makeStatement(Connection c) throws SQLException {
    return c.prepareStatement("delete from users");
}

일단 나누긴 했는데, 별다른 이득이 없어보인다.

  • 보통 메소드 추출 리팩토링을 적용하는 경우에는 분리시킨 메소드를 다른 곳에서 재사용할 수 있어야 한다.

이 경우, 반대이다!

  • 분리 후 남은 메소드(getConnection, executeUpdate)가 재사용이 필요한 부분이고,
  • 분리된 메소드(makeStatement)는 DAO 로직마다 새롭게 만들어서 확장돼야 하는 부분이다.

템플릿 메소드 패턴의 적용

이번엔 템플릿 메소드 패턴을 이용해서 분리해보자. 템플릿 메소드 패턴은 상속을 통해 기능을 확장해서 사용하는 부분이다.

public abstract class UserDao {
    PreparedStatement makeStatement(Connection c) throws SQLException {
        return null;
    }
public class UserDaoDeleteAll extends UserDao {
    @Override
    protected PreparedStatement makeStatement(Connection c) throws SQLException {
        return c.prepareStatement("delete from users");
    }
}

템플릿 메소드 패턴?

  • 추상 클래스를 만들고 변화가 필요한 부분을 추상 메소드로 빼서,
  • 해당 추상 클래스를 상속받은 뒤 추상 메소드를 구현하는 방식이었다.

확장 때문에 기존의 상위 DAO 클래스에 불필요한 변화는 생기지 않도록 할 수 있으니 객체지향 설계의 핵심 원리인 개방 폐쇄 원칙(OCP)은 그럭저럭 지킬 수 있다.

그러나!

  • 각 DAO 로직마다 상속을 통해 새로운 클래스를 만들어야 한다.
  • UserDao의 JDBC 메소드가 4개일 경우, 4개의 서브 클래스가 필요해진다?!

전략 패턴의 적용

전략 패턴?

  • 개방 폐쇄 원칙을 잘 지키는 구조이면서도 템플릿 메소드 패턴보다 유연하고 확장성이 뛰어나다.
  • 오브젝트를 아예 둘로 분리하고 클래스 레벨에서는 인터페이스를 통해서만 의존하도록 만든다.
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 users");
    }
}
public void deleteAll() throws SQLException {
        Connection c = null;
        PreparedStatement ps = null;

        try {
            c = dataSource.getConnection();

            StatementStrategy strategy = new DeleteAllStatement();
            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) { } }
        }
    }

이제 그럭저럭 전략 패턴은 적용했는데 이상하다.

  • 전략 패턴은 필요에 따라 컨텍스트는 그대로 유지되면서(OCP 폐쇄 원칙) 전략을 바꿔 쓸 수 있다는 것(OCP 개방 원칙)이다.
  • 이렇게 컨텍스트 안에서 이미 구체적인 전략 클래스 DeleteAllStatement를 사용하도록 고정되어 있다.
  • 컨텍스트가 StatementStrategy 인터페이스 뿐만 아니라 특정 구현 클래스인 DeleteAllStatement를 직접 알고있다는 건, 전략 패턴에도 OCP에도 잘 들어맞는다고 볼 수 없다.

DI 적용을 위한 클라이언트/컨텍스트 분리

전략 패턴에 따르면 Context가 어떤 전략을 사용하게 할 것인가는 Context를 사용하는 앞단의 Client가 결정하는 게 일반적이다.

Client가 구체적인 전략의 하나를 선택하고 오브젝트로 만들어서 Context에 전달하는 것이다.

이전 ConnectionMaker에 전략 패턴을 적용했을 때와 동일한 그림이 나왔다.

결국 이 구조에서 전략 오브젝트 생성과 컨텍스트로의 전달을 담당하는 책임을 분리 시킨 것이 ObjectFactory이며, 이를 일반화한 것이 앞에서 살펴봤던 의존관계 주입(DI)이었다.

결국 DI란 이러한 전략 패턴의 장점을 일반적으로 활용할 수 있도록 만든 구조라고 볼 수 있다.

StatementStrategy strategy = new DeleteAllStatement();

컨텍스트에 해당하는 부분은 별도의 메소드로 독립시켜보자!

  • 클라이언트는 DeleteAllStatement 오브젝트 같은 전략 클래스의 오브젝트를 컨텍스트 메소드로 전달해야 한다.
  • 전략 인터페이스인 StatementStrategy를 컨텍스트 메소드 파라미터로 지정하자~!
public void jdbcContextWithStatementStrategy(StatementStrategy statementStrategy) throws SQLException {
    Connection c = null;
    PreparedStatement ps = null;

    try {
        c = dataSource.getConnection();
        ps = statementStrategy.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) { } }
    }
}

마이크로 DI

의존관계 주입(DI)은 다양한 형태로 적용할 수 있다. DI의 가장 중요한 개념은 제3자의 도움을 통해 두 오브젝트 사이의 유연한 관계가 설정되도록 만든다는 것이다. 이 개념만 따른다면 DI를 이루는 오브젝트와 구성요소의 구조나 관계는 다양하게 만들 수 있다.

  • 일반적으로 DI는 의존관계에 있는 두 개의 오브젝트와 이 관계를 다이내믹하게 설정해주는 오브젝트 팩토리(DI 컨테이너), 그리고 이를 사용하는 클라이언트라는 4개의 오브젝트 사이에서 일어난다.
  • 하지만 때로는 원시적인 전략 패턴 구조를 따라 클라이언트가 오브젝트 팩토리의 책임을 함께 지고 있을 수도 있다.
  • 또는 클라이언트와 전략(의존 오브젝트)이 결합될 수도 있다.
  • 심지어는 클라이언트와 DI 관계에 있는 두 개의 오브젝트가 모두 하나의 클래스 안에 담길 수도 있다.

얼핏 보면 DI 같아 보이지 않지만, 세밀하게 관찰해보면 작은 단위지만 엄연히 DI가 이뤄지고 있음을 알 수 있다.

  • 이렇게 DI의 장점을 단순화해서 IoC 컨테이너의 도움 없이 코드 내에서 적용한 경우를 마이크로 DI라고도 한다.
  • 또는 코드에 의한 DI라는 의미로 수동 DI라고 부를 수도 있다.

3.5 템플릿과 콜백

템플릿

템플릿은 어떤 목적을 위해 미리 만들어둔 모양이 있는 틀을 가리킨다. 고정된 틀 안에 바꿀 수 있는 부분을 넣어서 사용하는 경우 템플릿이라고 부른다.

  • 이를테면 JSP는 HTML이라는 고정된 부분에 EL과 스크립릿이라는 변하는 부분을 넣은 일종의 템플릿 파일이다.
  • 템플릿 메소드 패턴은 고정된 틀의 로직을 가진 템플릿 메소드를 슈퍼 클래스에 두고, 바뀌는 부분을 서브 클래스의 메소드에 두는 구조로 이뤄진다.

콜백

콜백은 실행되는 것을 목적으로 다른 오브젝트의 메소드에 전달되는 오브젝트를 말한다. 파라미터로 전달되지만 값을 참조하기 위한 것이 아니라 특정 로직을 담은 메소드를 실행시키기 위해 사용한다.

자바에선 메소드 자체를 파라미터로 전달할 방법은 없기 때문에 메소드가 담긴 오브젝트를 전달해야 한다. (자바 1.8부터 람다로 가능) 그래서 펑셔널 오브젝트(functional object)라고도 한다.

3.5.1 템플릿/콜백의 동작원리

템플릿/콜백의 특징

여러 메소드를 가질 수 있는 전략 패턴의 전략과 달리 템플릿/콜백 패턴의 콜백은 보통 단일 메소드 인터페이스를 사용한다. 템플릿의 작업 흐름 중 특정 기능을 위해 한 번 호출되는 경우가 일반적이기 때문이다.

  • 물론 하나 이상의 콜백 오브젝트를 사용하는 것도 가능하다.
  • 콜백은 일반적으로는 하나의 메소드를 가진 인터페이스를 익명 내부 클래스로 구현한다.

구현 코드

토비의 스프링 3장 중반 이후 부분은 우아한테크코스 레벨 4 미션에 적용 가능한 부분이라 판단해, 책 속 코드를 참고해 미션에 적용해보았다.

[우아한테크코스 백엔드 4기] 레벨4 - "JDBC 라이브러리 구현하기" 회고

profile
🌱 함께 자라는 중입니다 🚀 rerub0831@gmail.com

0개의 댓글