토비의 스프링 정리 프로젝트 #3.2 변하는 것과 변하지 않는 것

Jake Seo·2021년 7월 19일
0

토비의 스프링

목록 보기
18/29

이전 JDBC try/catch/finally 코드의 문제점

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 {
        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) {
            }
        }
    }
}

복잡한 try/catch/finally 블록이 2중으로 중첩돼서 나오며, 모든 메소드마다 반복된다. 보통 이렇게 반복되는 코드에 대한 나쁜 습관은 따로 분리하지 않고 복사 붙여넣기로 처리하는 것이다.

그러다가 어느 순간 한 줄을 빼먹고 복사하거나 몇 줄을 잘못 삭제하면 어떻게 될까? 괄호를 잘못 닫은 게 아니라면 당장에 컴파일 에러가 나지는 않는다. 하지만 이렇게 별 문제 없어보이면서 리소스를 반환하지 않으면 더욱 심각한 문제가 된다.

정상 동작하는 것처럼 보이는 서버가 며칠 주기로 리소스가 꽉찼다는 에러를 내면서 중지되는 상황이 올 수 있다.

누군가 DAO 로직을 수정하려고 했을 때, 복잡한 try/catch/finally 블록 안에서 필요한 부분을 찾아서 수정해야 하고, 언젠가 필요한 부분을 잘못 삭제했는데 코드가 정상적으로 동작하면 역시 같은 문제가 반복될 것이다.

언제 터질지 모르는 폭탄과 같은 코드가 될 수 있다. 우리는 이전에 1장에서 이러한 코드를 효과적으로 분리하는 방법을 배웠었다.

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

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 리소스 반환
    }
}

deleteAll()에서 변경될 수 있는 부분은 단 한줄이다.

ps = 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");
    }

일단 나누긴 했는데, 별다른 이득이 없어보인다. 보통 메소드 추출 리팩토링을 적용하는 경우에는 분리시킨 메소드를 다른 곳에서 재사용할 수 있어야 하는데, 이건 반대로 분리시키고 남은 메소드가 재사용이 필요한 부분이고, 분리된 메소드는 DAO 로직마다 새롭게 만들어서 확장돼야 하는 부분이기 때문이다.

템플릿 메소드 패턴의 적용

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

public abstract class UserDao {
...
...
abstract protected PreparedStatement makeStatement(Connection c) throws SQLException;
...
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개의 서브 클래스가 필요해진다.

또, 확장구조가 이미 클래스를 설계하는 시점에서 고정된다. 변하지 않는 코드를 가진 UserDao의 JDBC try/catch/finally 블록과 변하는 PreparedStatement를 담고 있는 서브 클래스들이 이미 클래스 레벨에서 컴파일 시점에 그 관계가 결정되어 있다. 따라서 관계에 대한 유연성이 떨어져버린다.

전략 패턴의 적용

개방 폐쇄 원칙(OCP)을 잘 지키면서도 템플릿 메소드 패턴보다 유연하고 확장성이 뛰어난 것이, 오브젝트를 아예 둘로 분리하고 클래스 레벨에서는 인터페이스를 통해서만 의존하도록 만드는 전략 패턴이다. 전략 패턴은 OCP 관점에서 보면 확장에 해당하는 변하는 부분을 별도의 클래스로 만들어 추상화된 인터페이스를 통해 위임하는 방식이다.

좌측에 있는 ContextcontextMethod()에서 일정한 구조를 가지고 동작하다가 특정 확장 기능은 Strategy 인터페이스를 통해 외부의 독립된 전략을 클래스에 위임하는 것이다.

deleteAll()에서 변하지 않는 부분이라고 명시한 것이 바로 이 contextMethod()가 된다. deleteAll()은 JDBC를 이용해 DB를 업데이트하는 작업이라는 변하지 않는 컨텍스트를 갖는다.

  • DB 커넥션 가져오기
  • PreparedStatement를 만들어줄 외부 기능 호출하기
  • 전달받은 PreparedStatement 실행하기
  • 예외가 발생하면 이를 다시 메소드 밖으로 던지기
  • 모든 경우에 만들어진 PreparedStatementConnection을 적절히 닫아주기

PreparedStatement를 생성하는 전략을 호출할 때는 이 컨텍스트 내에서 만들어둔 DB 커넥션을 전달해야 한다는 점을 눈여겨봐야 한다.

StatementStrategy 인터페이스

public interface StatementStrategy {
    PreparedStatement makePreparedStatement(Connection c) throws SQLException;
}

DelteAllStatement 클래스

public class DeleteAllStatement implements StatementStrategy{
    @Override
    public PreparedStatement makePreparedStatement(Connection c) throws SQLException {
        return c.prepareStatement("delete from users");
    }
}

UserDao.deleteAll() 메소드

    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가 어떤 전략을 사용하게 할 것인가는 앞단의 Client가 결정하는 것이 일반적이다.

이전 ConnectionMaker에 전략 패턴을 적용했을 때와 동일한 그림이 나왔다. 결국 이 구조에서 전략 오브젝트 생성과 컨텍스트로의 전달을 담당하는 책임을 분리 시킨 것이 ObjectFactory이며, 이를 일반화한 것이 앞에서 살펴봤던 의존관계 주입(DI)이었다. 결국 DI란 이러한 전략 패턴의 장점을 일반적으로 활용할 수 있도록 만든 구조라고 볼 수 있다.

StatementStrategy strategy = new DeleteAllStatement();

컨텍스트에 해당하는 부분은 별도의 메소드로 독립시켜보자. 클라이언트는 DeleteAllStatement 오브젝트 같은 전략 클래스의 오브젝트를 컨텍스트 메소드로 전달해야 한다. 이를 위해 전략 인터페이스인 StatementStrategy를 컨텍스트 메소드 파라미터로 지정할 필요가 있다.

메소드로 분리한 try/catch/finally 컨텍스트 코드

public void jdbcContextWithStatementStrategy(StatementStrategy stmt) throws SQLException {
    Connection c = null;
    PreparedStatement ps = null;

    try {
        c = dataSource.getConnection();
        ps = stmt.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() 메소드

public void deleteAll() throws SQLException {
    StatementStrategy strategy = new DeleteAllStatement(); // 선정한 전략 클래스의 오브젝트 생성
    jdbcContextWithStatementStrategy(strategy); // 컨텍스트 호출, 전략 오브젝트 전달
}

이제 구조로 볼 때, 완벽한 전략 패턴의 모습을 갖췄다. 비록 클라이언트와 컨텍스트를 분리하진 않았지만, 의존관계와 책임으로 볼 때는 이상적인 클라이언트/컨텍스트 관계를 갖고 있다. 특히 클라이언트가 컨텍스트가 사용할 전략을 정해서 전달한다는 면에서 DI 구조라고 이해할 수도 있다. 아직까지는 이렇게 분리한 것에서 크게 장점이 보이지 않지만, 지금까지 해온 관심사 분리와 유연한 확장관계를 유지하도록 만드는 작업은 매우 중요하다.

마이크로 DI

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

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

DI는 꼭 클래스 단위로 일어나지 않고, 매우 작은 단위의 코드와 메소드 사이에서 일어나기도 한다. 얼핏보면 DI같아보이지 않지만, 세밀하게 관찰해보면 작은 단위지만 엄연히 DI가 이뤄지고 있음을 알 수 있다. 이렇게 DI의 장점을 단순화해서 IoC 컨테이너의 도움 없이 코드 내에서 적용한 경우를 마이크로 DI라고도 한다. 또는 코드에 의한 DI라는 의미로 수동 DI라고 부를 수도 있다.

profile
풀스택 웹개발자로 일하고 있는 Jake Seo입니다. 주로 Jake Seo라는 닉네임을 많이 씁니다. 프론트엔드: Javascript, React 백엔드: Spring Framework에 관심이 있습니다.

0개의 댓글