책에서는 개선할 점이 있는 코드를 리팩토링하는 순서로 쓰여져있다. 이 글에서는 < 중복 기능 메소드 추출 / 클래스 상속 / 컴포지션 / 인터페이스 / 생성자에서 인터페이스 주입 > 를 각각 사용하는 이유를 알아가는 관점으로 써보았다. 각각을 사용하는 이유에 초점을 맞추어 목차를 새로 만들어 정리하였다.

리팩토링 뿐만 아니라 객체지향적인 설계, SOLID 5원칙을 지키는 이유는,

클래스 내부의 응집도는 높이고, 다른 클래스들과의 의존성은 낮추기 위해 한다.

이를 지켜 기존 코드는 바꾸지 않으면서도 기능 확장에 열려있고 따라서 유지보수가 쉬워지게 하기 위함이다. 아래부터는 처음의 안 좋은 코드를 하나씩 개선(리팩토링)해 나가는 과정이며, 각 단계마다 왜 사용하는 지에 초점을 맞추어 정리해보았다.

DAO 리팩토링

아래 코드는 DB에 접근하는 DAO 클래스이다. 여기서 add(), get() 메소드를 보면 중복되는 코드가 있다.

public class UserDao {

    public void add(User user) throws SQLException, ClassNotFoundException {
        Class.forName("org.postgresql.Driver"); // DB 커넥션

        String user = "postgres";
        String password = "password";

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

        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 {
        Class.forName("org.postgresql.Driver");

        String user = "postgres";
    			// ... 중복
    		}
}

add(), get() 의 DB 커넥션 생성 파트가 중복됨을 알 수 있다. 이렇게 중복되는 코드를 메소드로 추출하면, 관심사를 분리하여 효율적으로 관리할 수 있다. 필요 시 마다 해당 메소드를 재사용하고, 수정 시에도 해당 메소드만 변경해주면 된다.

1. 중복 코드로 메소드 추출

중복 코드는 메소드로 추출하여 재사용할 수 있도록 리팩토링한다.
예제 코드에서는 DB Connection을 생성하는 과정이 반복되므로 해당 부분을 메소드화 하였다.
    // 중복 코드 메소드화
    public Connection getConnection() throws ClassNotFoundException, SQLException {
    Class.forName("org.postgresql.Driver");

        String user = "postgres";
        String password = "password";

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

    }

public void add(User user) throws SQLException, ClassNotFoundException {

    Connection c = getConnection(); // 메소드로 해결!

    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();

}

중복되는 코드가 DB 커넥션 생성보다 훨씬 많고 복잡한 범위가 중복될 수 있다. 특히 중복 파트의 일부는 자주 추가/변경되고, 일부는 변하지 않는 기능일 수 있다. 이때는 상속을 통해 해결한다.

2. 상속으로 확장 - 템플릿 메소드 패턴 + 팩토리 메소드 패턴

앞서 중복 코드를 메소드로 추출하였으나, 단순 하나의 메소드로 묶기엔 복잡한 상황이 있을 수 있다. 예를 들어 DB 커넥션 생성 코드에서도 DB 연결코드는 변하지 않지만, DB 종류 선택 코드는 DB 종류마다 달라진다. 이처럼 추가/변경되는 기능과 변하지 않는 기능을 분리하여 관리할 수 있도록 상속을 사용한다.

중복되는 코드를 메소드로 추출할 때, 자주 변경될 기능과 변하지 않는 기능을 분리하여 관리할 수 있다. 변경되지 않는 부분은 추상 클래스로, 추가/변경될 부분은 구현 클래스로 구현하여 상속받는다.

상속과 아래 두가지 디자인 패턴을 함께 사용하였다.

  • 템플릿 메소드 패턴
    • 변하지 않는 기능은 추상 클래스에 정의하고,
    • 자주 변경되는 기능은 하위 클래스에 구현한다.
  • 팩토리 메소드 패턴 → 참고 링크
    • 어떤 DB 의 커넥션을 사용할 지는 서브 클래스에서 결정하도록,
    • 객체 생성을 서브 클래스에게 위임하였다. (객체 생성 코드를 기존 클래스에서 분리)

아래 예제에서는 DB 커넥션 생성(변하지 X)은 추상 클래스에, 구체적으로 어떤 DB를 생성할 지(변경O)는 서브 클래스에 구현하였다. 그리고 객체 생성을 서브 클래스에게 위임하였다.

public abstract class UserDao {
public void add(User user) throws Exception {
Connection c = getConnection();
...
}
 public abstract Connection getConnection() throws Exception {
SQLException;
}
}

// 상속을 통한 확장
public class NUserDao extends UserDao {
public Connection getConnection() throws ClassNotFoundException, SQLException {
//N사 DB connection 생성 코드
}
}

public class EUserDao extends UserDao {
public Connection getConnection() throws ClassNotFoundException, SQLException {
//E사 DB connection 생성 코드
}
}

처음 코드에서부터 다시 리뷰해보면, DB 커넥션 코드가 모든 메소드마다 중복되어 있었다. 우선 중복코드를 메소드로 추출하였다. 그리고 이보다 더 세밀하게 관심사를 분리하기 위해 중복 코드를 클래스로 추출하고, 상속을 사용하였다.

특히 상속을 사용하면 추가/변경되는 기능와 변하지 않는 기능을 분리하여 유지보수가 편하고 확장성이 있다. 그러나 자바에서는 다중 상속이 되지 않기 때문에, 확장에 한계가 있다. 이를 보완하기 위해 컴포지션(객체를 속성으로 사용)을 사용한다.

3. 컴포지션 사용

앞서 기능을 분리하여 재사용하기 위해 상속을 사용하면, 다중 상속이 안된다. 하나의 부모클래스만 상속받을 수 있기 때문에 확장에 한계가 있었다.

컴포지션을 통해 기능을 아예 별도의 클래스로 분리하여 필요한 여러 클래스들을 속성으로 사용한다. 즉, 객체를 속성으로 사용하는 방식이다.

이로 인해 다중 상속의 문제점(다이아몬드 상속 문제)을 해결하고, 여러 클래스를 재사용할 수 있다. 확장성을 가진다.

아래 코드를 보면 UserDao 클래스가 SimpleConnectionMaker 클래스를 상속받지 않고, 속성으로 사용하였다.

public abstract class UserDao {
private SimpleConnectionMaker simpleConnectionMaker; // 객체를 속성으로 사용

    public UserDao() {
        simpleConnectionMaker = new SimpleConnectionMaker(); // Here..객체 직접 생성
    }

    public void add(User user) throws Exception {
        Connection c = simpleConnectionMaker.makeNewConnection(); //Here..메소드 직접 참조
        ...
    }
}

public class SimpleConnectionMaker { //재사용이 필요한 코드를 클래스화
    public Connection makeNewConnection() throws Exception {
    Class.forName("com.mysql.jdbc.Driver");
    ...
    }
}

위 코드에서는 컴포지션을 통해 여러 클래스들을 재사용할 수 있게 되었다. 다만, 여기에도 개선할 점이 남아있다. UserDao 클래스 내부에서 SimpleConnectionMaker 객체(컴포지션 객체)를 직접 생성하고 있다. 그리고 해당 객체의 메소드를 직접 참조하여 사용하고 있다.

이렇게 되면, UserDao 클래스에서 해당 클래스의 정보를 알고 있어야 하고, 해당 클래스에 종속되게 된다. 또한 해당 클래스에서 변경사항이 있으면, UserDao 클래스에서도 다 수정해주어야 한다. 이를 해결하기 위해 인터페이스를 사용한다.

4. 인터페이스 - 전략 패턴

앞서 기존 클래스에서 다른 클래스를 직접 생성/참조하면 해당 클래스에 종속되게 되고, 수정하기도 번거로웠다. 인터페이스를 통해 이러한 의존성을 깨줄 수 있다. → 여기서는 참조에서의 문제만 해결한다.

인터페이스를 사용하여, 메소드명과 해당 메소드의 기능을 정해둔다. 그리고 구현체들이 각각에 맞게 오버라이딩한다. 이렇게 되면 기존 클래스에서는 통일된 메소드명을 사용해주면 된다. 구현체가 달라지더라도 인터페이스가 같으므로 참조하는 메소드명을 수정할 필요가 없다.

  • 전략 패턴
    • 재사용하려던 기능을 클래스가 아닌 인터페이스로 만든다.
    • 기존 클래스에서 (같은 인터페이스의) 다른 구현체로 바꾸더라도, 참조하는 코드는 바뀌지 않는다.
public interface ConnectionMaker { // 인터페이스
Connection makeNewConnection() throws Exception; // 메소드 통일
}

public class EConnectionMaker implements ConnectionMaker { // 구현체
...
public Connection makeConnection() throws Exception {
//E사의 코드
}
}

public abstract class UserDao {
private ConnectionMaker connectionMaker;

    public UserDao() {
        connectionMaker = new EConnectionMaker();    } // 문제..직접 생성

    public void add(User user) throws Exception {
        Connection c = connectionMaker.makeNewConnection(); // 메소드명 통일
        ...
    }
}

이처럼 인터페이스를 사용하면(전략 패턴), 구현체가 바뀌더라도 기존 클래스의 코드가 바뀌지 않게 된다. 단, 위에서 “기존 클래스에서 다른 클래스를 직접 생성/참조하면 해당 클래스에 종속되게 되고, 수정하기도 번거로웠다” 라고 했었다. 전략 패턴을 사용하여 참조할 때의 문제는 해결하였다. 하지만 여전히 기존 클래스에서 다른 클래스를 생성하고 있다.

public abstract class UserDao {
private ConnectionMaker connectionMaker;

    public UserDao() {
        connectionMaker = new EConnectionMaker();    } // 문제..직접 생성
    	// ...

}

이렇게 기존 클래스(UserDao)에서 다른 클래스(EConnectionMaker)를 직접 생성하고 있으면 어떤 문제가 생길까? 기존 클래스(UserDao)가 다른 EConnectionMaker이 아닌, ConnectionMaker의 다른 구현체들(NConnectionMaker, DConnectionMaker)을 생성할 때 문제가 생긴다.

다른 구현체로 변경할 때마다 기존 클래스(UserDao)의 코드를 변경해주어야 하기 때문이다. 다른 클래스에 의존하게 되고, 유연하지 못한 설계가 된다.

이를 해결하기 위해 생성자를 통해 인터페이스를 주입받는 방식을 사용한다. 이를 통해 기존 클래스(UserDao)에서는 어떤 구현체가 들어올 지 관여하지 않는다. 구현체가 바뀌더라도 기존 클래스의 코드는 변하지 않는다.

5. 생성자를 통해 인터페이스를 주입받기 - OCP

기존 클래스에서 다른 클래스(구현 클래스)를 직접 생성하는 경우, 클래스간 의존도가 높다. 새로운 구현 클래스를 생성하고 싶은 경우, 기존 클래스 내부 코드를 변경해주어야 하기 때문이다. 때문에 생성자를 통해 인터페이스를 주입받는 방식을 사용한다.

생성자에서 인터페이스를 주입받는다. 어떤 구현체일지는 기존 클래스 내부에서 결정하지 않고, 클라이언트 코드에서 결정한다.

기존 클래스에서는 추상화(인터페이스)에 의존하고, 클라이언트 코드에서 의존성을 주입받는다. 이를 통해 구현체가 바뀌거나 새로운 구현체가 생기더라도 UserDao 코드는 바뀌지 않고 클라이언트 코드에서 해결하게 된다. 즉, SOLID OCP를 지킨다.

  • SOLID OCP
    • (기존 코드) 변경에는 닫혀있으면서 (기능) 확장에는 열려 있어야 한다.
    public abstract class UserDao {
    // 생성자
    public UserDao(SimpleConnectionMaker simpleConnectionMaker) { // 인터페이스로 주입
    this.simpleConnectionMaker = simpleConnectionMaker;
    }
    }

// 클라이언트 코드(UserDaoTest)에서 구현체를 결정한다.
ConnectionMaker e = new EConnectionMaker();
UserDao dao1 = new UserDao(e);

ConnectionMaker n = new NConnectionMaker();
UserDao dao2 = new UserDao(n);

여기까지의 리팩토링을 정리하면 < 중복 기능 메소드 추출 / 클래스 상속 / 컴포지션 / 인터페이스 / 생성자에서 인터페이스 주입 > 이었다. 여기서 클라이언트 코드(UserDaoTest)를 보면, UserDao와 ConnectionMaker가 함께 섞어있다. 즉, 객체를 생성하는 부분과 객체를 사용하는 부분이 나눠지지 않고 있다. 클래스 당 각자가 맡은 기능에만 충실할 수 있도록 이를 분리하기 위해 IoC 개념을 사용한다.

다음 글에서는 IoC의 개념과 스프링의 IoC에 대해 정리해보았다. 📝