토비의 스프링 정리 프로젝트 #1.3 DAO의 확장 (원칙과 패턴, 개방 폐쇄 원칙, 객체지향 SOLID 원칙, 높은 응집도와 낮은 결합도, 전략 패턴)

DAO를 확장하기 전 현재까지의 상황

현재의 상황은 두가지 관심사를 분리한 상태이다.

  • DB에 연결하는 Connection 부분을 다른 관심사로 분리
  • DAO의 행위를 다른 관심사로 분리

이 두개의 관심사가 분리된 이유는 변화의 성격이 다르기 때문이다. 변화의 성격이 다르다는 것은 변화의 이유, 시기, 주기 등이 다르다는 뜻이다.

  • Connection 부분은 어떤 종류의 DB에 연결할 것인지, 접속 정보가 어떻게 되는지에 대한 내용이다.
  • Statement에 대한 부분은 어떤 테이블에 접근하여 어떤 쿼리를 날리게 될지에 대한 내용이다.

현재는 추상 클래스를 만들어 서브 클래스에서 변화가 필요한 부분을 바꿔서 쓸 수 있도록 작성해놓은 상태이다. 변화의 성격이 다른 것을 분리해서 서로 영향을 주지 않은 채로 각각 필요한 시점에 독립적으로 변경할 수 있게 만드는 것이 핵심이다.

하지만, 상속을 이용했더니 단점이 명확했다. 상하위 관계가 너무나 명확하여 슈퍼클래스를 맘대로 변화시킬 수도 없을 뿐더러 다른 DAO에 해당 슈퍼 클래스를 또 상속시키면 중복 코드가 발생하는 문제가 있었다.

클래스의 분리

이번엔 아예 상속 관계로 이어지는 것이 아니라 독립적인 클래스로 만들어보면 어떨까? DB 커넥션과 관련된 부분을 아예 별도의 클래스에 담아보자. UserDao는 새롭게 만들어진 별도의 클래스를 이용하여 커넥션을 맺게 될 것이다.

public class UserDao {

    // 상속의 단점을 해결하고자, 상속을 피하고 클래스를 나누는 방식으로 해결 시도.
    // 커넥션 때문에 상속을 적용하면 추후에 다른 이유로 상속을 할 수 없음. (다중상속을 지원 안 함)
    // 슈퍼 클래스가 변경되면 하위 클래스를 모두 변경해야 함.
    // 애초에 변화가 있어도 독립적으로 서로 영향을 주지 않는 디자인을 원했는데, 상속 자체가 변화를 불편하게 만듦.
    // 위와 같은 이유 때문에 클래스를 분리함
    SimpleConnectionMaker simpleConnectionMaker;

    public UserDao() {
        this.simpleConnectionMaker = new SimpleConnectionMaker();
    }

    public void add(User user) throws SQLException, ClassNotFoundException {
        Connection c = simpleConnectionMaker.makeNewConnection();

        PreparedStatement ps = c.prepareStatement(
                "insert into users(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();
    }

    public User get(String id) throws SQLException, ClassNotFoundException {
        Connection c = simpleConnectionMaker.makeNewConnection();

        PreparedStatement ps = c.prepareStatement(
                "select * from users where id = ?"
        );
        ps.setString(1, id);

        ResultSet rs = ps.executeQuery();
        rs.next();

        User user = new User();
        user.setId(rs.getString("id"));
        user.setName(rs.getString("name"));
        user.setPassword(rs.getString("password"));

        rs.close();
        ps.close();
        c.close();

        return user;
    }
}
public class SimpleConnectionMaker {
    public Connection makeNewConnection() throws ClassNotFoundException, SQLException {
        Class.forName("org.postgresql.Driver");

        String user = "postgres";
        String password = "iwaz123!@#";

        Connection c = DriverManager.getConnection(
                "jdbc:postgresql://localhost/toby_spring"
                , user
                , password
        );

        return c;
    }
}

이번에도 기능적인 변경사항은 없고 내부 디자인만 수정하여 좀 더 나은 코드로 리팩토링 하였다. 기능이 변하지 않는 선에서 내부 코드를 변경했을 때는 항상 테스트를 통하여 해당 코드가 여전히 올바르게 동작하는지 확인해보아야 한다.

그러나 위와 같이 변경하면 이전에 만났던 문제를 다시 한 번 만나게 된다. 우리는 경우에 따라 다른 방법으로 Connection을 주고 싶었는데, makeNewConnection()의 구현은 하나이다. 이 문제에 대한 근본적인 원인은 UserDao가 바뀔 수 있는 구체적인 정보(구현체)에 대해 너무 많이 알고 있기 때문이다.

UserDao는 구체적인 방법은 알기 싫고, 어떤 방법이든 그저 Connection만 가져오면 작성된 코드를 실행할 수 있다.

인터페이스의 도입

UserDaoConnection을 얻는 클래스에 대해 자세한 정보를 알지 않아도 되도록 인터페이스를 사용하자.

// 1.3.2 인터페이스의 사용, 1.3.3 관계 설정의 책임의 분리를 이용하여
// UserDao 는 생성자에서 해당 인터페이스를 주입 받는 방식으로 변경됨
// 어떤 Connection 을 이용할 것인지는 UserDao 의 관심사가 아니라 클라이언트의 관심사가 됨
// UserDao 는 SimpleConnectionMaker 라는 인터페이스에 맞는 오브젝트로 Connection 을 생성하는 것만이 관심사
// 구체적으로 어떤 SimpleConnectionMaker 가 들어올 것인지에 대해서는 관심이 없다.
// 이렇게 구현함으로써 훨씬 유연해진다.
public interface SimpleConnectionMaker {
    Connection makeNewConnection() throws ClassNotFoundException, SQLException;
}

SimpleConnectionMaker를 인터페이스화 시켰다. 이제 UserDao에게는 오직 SimpleConnectionMaker라는 인터페이스가 .makeNewConnection()이라는 메소드를 실행시키면 Connection 객체가 반환되는 것만 관심사다. 내부적으로 어떻게 구현되었는지는 UserDao의 관심사가 아니다.

생성자 사용 불가능

UserDao에는 한가지 문법 오류가 생겼는데, 이제는 SimpleConnectionMaker는 인터페이스가 되어 아래와 같이 생성자를 사용할 수 없다는 것이다. 그렇다고 new DConnectionMaker()new NConnectionMaker()와 같이 구체적인 클래스명을 넣으면 이전과 다를 게 없다.

    public UserDao() {
        this.simpleConnectionMaker = new SimpleConnectionMaker();
    }

세부적인 Connection 책임은 클라이언트에게 넘기기

UserDao의 생성자를 다음과 같이 변경하자.

    public UserDao(SimpleConnectionMaker simpleConnectionMaker) {
        this.simpleConnectionMaker = simpleConnectionMaker;
    }

위와 같이 설정하면, UserDao를 생성하여 이용하는 클라이언트가 SimpleConnectionMaker 객체를 생성하고 주입해주는 책임을 갖는다. UserDao는 비로소 Connection에 대한 모든 책임에서 벗어날 수 있게 된다.

UserDao는 이제 SQL을 작성하고 이를 실행하는 데만 집중할 수 있다.

이제 Connection이 어떻게 변하든지 간에 UserDao 코드는 건드릴 필요가 없다.

위와 같이 작성하면, 오브젝트의 관계는 설계 시에 결정되는 것이 아니라 런타임에 결정이 된다. 클라이언트에서 UserDao가 사용할 오브젝트의 레퍼런스를 갖고 있다가 UserDao를 생성할 때 넘겨주게 된다.

이러한 관계 설정을 다이내믹한 관계라고 부른다. 객체지향 프로그램에 있는 다형성이라는 특징을 이용하여 이러한 형태의 코드를 작성할 수 있다. 또한 이러한 관계를 의존관계라고 하기도 한다.

인터페이스를 도입하고 클라이언트의 도움을 얻어 런타임에 동적인 관계를 맺는 방법은 상속을 사용했을 때에 비해 훨씬 유연하다.

원칙과 패턴

앞서 변화한 코드는 사실 잘 알려진 원칙과 패턴을 적용한 것이다. 원칙과 패턴에 대해 공부해보자.

개방 폐쇄 원칙 (OCP, Open-Closed Principle)

'클래스나 모듈은 확장에는 열려 있어야 하고 변경에는 닫혀 있어야 한다.' 를 기본으로 하는 원칙이다.

UserDao는 DB 연결 방법이라는 기능을 확장하는데는 열려있다.

  • UserDao 단순히 클라이언트에서 주입해주는 객체만 변경를으로써, DB 연결 방법을 바꿀 수 있는 점이 확장에는 열려 있어야 한다는 개방 폐쇄 원칙 중 '개방'에 대한 원칙을 지켰다. 다양한 DB 연결 방법을 확장할 수 있다.
  • UserDao 코드를 전혀 수정할 필요 없이 DB 연결 방법을 바꿀 수 있다는 특징은 변경에 닫혀있어야 한다는 개방 폐쇄의 원칙 중 '폐쇄'에 대한 원칙을 잘 지킨 사례이다.

가장 처음에 작성했던, 초난감 DAO에서 연결 방법을 확장한다고 생각하면, 내용을 전부 뜯어 고쳐야 하므로 확장에는 닫혀있고 변경에는 열려있게 된다.

잘 설계된 객체지향 클래스의 구조를 살펴보면 이 개방 폐쇄의 원칙을 아주 잘 지키고 있다.

사실 인터페이스를 사용해 확장 기능을 정의한 대부분의 API는 개방 폐쇄 원칙을 잘 따른다고 봐도 무방하다.

객체지향 SOLID 원칙

원칙이라는 건 어떤 상황에서든 100% 지켜져야 하는 절대적인 기준이라기보다, 예외는 있겠지만 대부분의 상황에서 좀 더 들어맞는 가이드라인과 같은 것이다.

디자인 패턴은 특수한 상황에서 발생하는 문제에 대한 좀 더 구체적인 솔루션이라고 본다면, 객체지향 설계 원칙은 좀 더 일반적인 상황에서 적용 가능한 설계 기준이라고 볼 수 있다.

당연한 일이지만, 객체지향 디자인 패턴은 대부분 객체지향 설계 원칙을 잘 지켜서 만들어져 있다.

스프링 핵심 원리 - 기본편 #5 좋은 객체지향 설계의 5가지 원칙(SOLID)에 자세한 설명이 있다.

높은 응집도와 낮은 결합도

개방 폐쇄 원칙은 높은 응집도와 낮은 결합도라는 소프트웨어 개발의 고전적인 원리로 설명 가능한 원칙이다.

높은 응집도란?

하나의 모듈, 클래스가 하나의 책임 또는 관심사에 집중되어 있다는 얘기다. 불필요하거나 직접 관련이 없는 외부의 관심과 책임이 얽혀있지 않으며, 하나의 공통 관심사는 한 클래스에 모여있다. 높은 응집도는 클래스 레벨 뿐 아니라, 패키지, 컴포넌트, 모듈에 이르기까지 그 대상의 크기가 달라도 동일한 원리로 적용될 수 있다.

변화가 일어날 때 해당 모듈에서만 변하는 부분이 크다는 것으로 설명할 수 있다. 변경이 일어날 때 해당 모듈에서 많은 부분이 함께 변경되어야 한다면, 높은 응집도를 갖고 있다고 볼 수 있다.

다른 모듈도 같이 변해야 한다면 그건 책임이 나누어져 버렸다는 이야기일 것이다.

모듈의 일부분에만 변경이 일어나도 된다면, 모듈 전체에서 어떤 부분이 바뀌어야 하는지 파악해야 하는 부담이 생기고, 그 변경으로 인해 바뀌지 않는 부분에는 다른 영향을 미치지는 않는지 확인해야 하는 이중적인 부담이 생긴다.

작업은 항상 모듈 내부에서 전체적으로 일어나고, 무엇을 변경할지 명확하며 다른 클래스의 수정을 요구하지 말아야 하고, 기능에 영향을 주지 않는다는 사실을 손쉽게 확인할 수 있어야 한다.

이렇게 응집도를 높이면 응집도가 높은 모듈 하나만 직접 테스트해보고 기능에 영향을 주지 않았다는 사실을 쉽게 알 수 있다.

낮은 결합도란?

낮은 결합도는 높은 응집도보다 더 민감한 원칙이다. '책임과 관심사가 다른 오브젝트 또는 모듈과는 낮은 결합도, 즉 느슨하게 연결된 형태를 유지하는 것이 바람직하다.' 는 것이 원칙이다.

느슨한 연결은 관계를 유지하는데 꼭 필요한 최소한의 방법만 간접적인 형태로 제공하고, 나머지는 서로 독립적이고 알 필요도 없게 만들어주는 것이다. 결합도가 낮아지면 변화에 대응하는 속도가 높아지고 구성이 깔끔해지고 확장하기에도 편리해진다.

결합도란 '하나의 오브젝트에서 변경이 일어날 때 관계를 맺고 있는 다른 오브젝트에게 변화를 요구하는 정도' 로 정의할 수 있다. 낮은 결합도란 결국, 하나의 변경이 발생할 때 마치 파문이 이는 것처럼 여타 모듈과 객체로 변경에 대한 요구가 전파되지 않는 상태를 말한다.

코드 예제에서는 ConnectionMaker 인터페이스를 도입함으로써, DB 연결에 대한 변경사항이 있더라도, UserDao는 코드에 변경이 필요 없게 되었다. 사용할 ConnectionMaker 구현체가 바뀌더라도, 클라이언트 코드만 수정하면 된다.

이는 UserDaoConnectionMaker가 인터페이스를 통한 느슨한 관계를 맺고 있기 때문에 가능한 것이다.

전략 패턴

개선한 UserDaoTest-UserDao-ConnectionMaker는 디자인 패턴의 시각에서는 전략 패턴(Strategy Pattern) 에 해당한다고 볼 수 있다.

전략 패턴은 자신의 기능 컨텍스트에서 필요에 따라 변경이 필요한 '알고리즘'을 인터페이스를 통해 통째로 외부로 분리시키고, 이를 구현한 구체적인 알고리즘 클래스를 필요에 따라 바꿔서 사용할 수 있게 해주는 디자인 패턴이다.

이를 대체 가능한 '전략'이라고 보기 때문에 이름이 전략 패턴이다.

위 설명에서 '알고리즘'이라는 부분은 단순히 독립적인 책임으로 분리가 가능한 기능을 뜻한다.

클래스 이름을 통한 전략 패턴 설명

컨텍스트(UserDao)를 사용하는 클라이언트(UserDaoTest)는 컨텍스트가 사용할 전략(ConnectionMaker를 구현한 클래스)을 컨텍스트의 생성자 등을 통해 제공해주는 것이 일반적인 패턴이다.

좋은 웹페이지 즐겨찾기