본문 바로가기

기술 서적/토비의 스프링 3.1

1장 오브젝트와 의존관계(1) 초난감 DAO, DAO의 분리

1장. 오브젝트와 의존관계

  • 초난감 DAO
  • DAO의 분리
  • DAO의 확장
  • 제어의 역전(IoC)
  • 스프링의 IoC
  • 싱글톤 레지스트리와 오브젝트 스코프
  • 의존관계 주입(DI)
  • 정리

 

초난감 DAO

1장 오브젝트와 의존관계에서는 스프링이 어떤 것이고, 무엇을 제공하는지보다는 스프링이 관심을 갖는 대상인 오브젝트의 설계와 구현, 동작원리를 살펴보겠습니다. 오브젝트에 대한 관심은 결국 객체지향 설계로 이어지게 됩니다. 객체지향 프로그래밍이 제공하는 해택을 누릴 수 있도록 만드는 것이 스프링의 핵심 철학입니다.

 

사용자 정보를 저장하고 조회하는 DAO(Data Access Object)를 구현하고 코드를 리팩터링 하는 과정 속에서 관심사 분리, 클래스 분리, 인터페이스 도입, 관계 설정 책임의 분리 등 다양한 개선점들을 살펴봅시다.

 

사용자 정보를 저장하는 User 클래스를 먼저 구현합니다.

package springbook.user.domain;

import lombok.Getter;
import lombok.Setter;

@Getter
@Setter
public class User {
    String id;
    String name;
    String password;
}

 

이번에는 사용자 정보를 DB에 넣고 관리할 수 있는 DAO 클래스를 만듭니다. DAO는 DB를 사용해 데이터를 조회하거나 조작하는 기능을 전담하는 오브젝트로 JDBC API를 사용하여 구현합니다.

사용자 정보를 DB에 저장하고 이 정보를 읽어오는 간단한 기능을 가진 DAO 클래스이지만, JDBC API를 이용하는 작업은 일반적으로 아래와 같은 순서로 진행됩니다.

  • DB 연결을 위한 Connection을 가져옵니다.
  • SQL을 담은 Statement를 만듭니다.
  • 만들어진 Statement를 실행합니다.
  • 조회의 경우 SQL 쿼리의 실행 결과를 ResultSet으로 받아서 정보를 저장할 오브젝트에 옮겨줍니다.
  • 작업 중에 생성된 Connection, Statement, ResultSet 같은 리소스는 작업을 마친 후 반드시 닫아줍니다.
  • JDBC API가 만들어내는 예외를 잡아서 직접 처리하거나, 메소드에 throws를 선언해서 발생한 예외를 메소드 밖으로 던지게 합니다.

위와 같은 작업을 포함하여 사용자 정보를 저장, 조회할 있는 DAO 클래스를 아래와 같이 구현했습니다.

package springbook.user.dao;
...
public class UserDao {
    public void add(User user) throws ClassNotFoundException, SQLException {
        // 1. DB 연결을 위한 커넥션을 가져온다.
        Class.forName("com.mysql.jdbc.Driver");
        Connection c = DriverManager.getConnection(
       	    "jdbc:mysql://localhost/springbook", "spring", "book");
        
        // 2. SQL을 담을 Statement를 만든다.
        PreparedStatement ps = c.prepareStatement(
            "inset into users(id, name, password) values(?,?,?)");
        ps.setString(1, user.getId());
        ps.setString(2, user.getName());
        ps.setString(3, user.getPassword());
        
        // 3. 만든 Statement를 실행한다.
        ps.executeUpdate();
        
        // 4. Connection, Statement를 닫아준다.
        ps.close();
        c.close();
    }
    
    public User get(String id) throws ClassNotFoundException, SQLException {
        // 1. DB 연결을 위한 커넥션을 가져온다.
        Class.forName("com.mysql.jdbc.Driver");
        Connection c = DriverManager.getConnection(
       	    "jdbc:mysql://localhost/springbook", "spring", "book");
        
        // 2. SQL을 담을 Statement를 만든다.
        PreparedStatement ps = c.prepareStatement(
            "select * from users where id = ?");
        ps.setString(1, id);
        
        // 3. 만든 Statement를 실행한다.
        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;
    }
}

 

이렇게 UserDao 만들었고 UserDao 동작하는지 확인하기 위해 아래와 같이 main 메소드에 테스트 코드를 작성했습니다. 모든 클래스에는 자신을 엔트리 포인트로 설정해 직접 실행이 가능한 스태틱 메소드인 main 메소드가 있습니다. 구현한 코드를 검증하고자 사용할 있는 가장 간단한 방법이 main 메소드에서 검증하는 방법입니다.

public static void main(String[] args) throws ClassNotFoundException, SQLException {
    UserDao dao = new UserDao();
    
    User user = new User();
    user.setId("whiteship");
    user.setName("hong");
    user.setPassword("june");
    
    dao.add(user);
    
    System.out.println(user.getId() + " 등록 성공");
    
    User user2 = dao.get(user.getId());
    System.out.println(user2.getName());
    System.out.println(user2.getPassword());
    
    System.out.println(user2.getId() + " 조회 성공");
}

// 출력 결과:
// whiteship 등록 성공
// hong
// june
// whiteship 조회 성공

 

위와 같이 UserDao의 출력을 보면 테스트가 성공했음을 알 수 있습니다. 

 

DAO의 분리 / 관심사의 분리

하지만 UserDao는 사실 여러 문제가 있습니다. 가장 큰 문제는 UserDao에 여러 관심사가 섞였다는 점입니다.

 

객체지향의 세계에서는 모든 것이 변합니다. 따라서 개발자는 끊임없는 변화에 대비해야 합니다. 변화에 대비하는 가장 좋은 방법은 변화의 폭을 최소한으로 줄여주는 것입니다.

변경이 일어날 때 필요한 작업을 최소화하고, 그 변경이 다른 곳에 문제를 일으키지 않게 하기 위해서는 분리와 확장을 고려한 설계가 필요합니다. 

 

여기서 분리란 관심사의 분리를 뜻합니다. DB 커넥션을 가져오는 작업, SQL을 담을 Statement를 만드는 작업, 사용한 리소스를 닫는 작업 모두 각각의 관심사입니다. 모든 변경과 발전은 한 번에 한 가지 관심사항에 집중해서 일어납니다.

하지만 변화가 발생한 관심사가 한곳에 집중되지 않을 경우 여러 부분에 수정 작업이 필요합니다. 

트랜잭션 기술을 다른 것으로 바꿨다고 비즈니스 로직이 담긴 코드의 구조를 모두 변경해야 한다면? 끔찍합니다...

 

변화가 한 번에 한 가지 관심에 집중돼서 일어난다면, 우리는 한 가지 관심이 한 군데에 집중되게 하면 됩니다. 이를 우리는 관심사의 분리하고 볼 수 있습니다. 관심사의 분리를 객체지향에 적용해 보면, 관심이 같은 것끼리는 하나의 객체 안으로 또는 친한 객체로 모이게 하고, 관심이 다른 것은 가능한 한 따로 떨어져서 서로 영향을 주지 않도록 분리하는 것입니다.

 

커넥션 만들기의 추출

그렇다면 위에서 구현한 UserDao의 add() 메소드에 몇 가지 관심사가 있는지 살펴봅시다.

먼저 DB와 연결을 위한 커넥션을 어떻게 가져올까라는 관심이 있고 사용자 등록을 위해 DB에 보낼 SQL 문장을 담을 Statement를 만들고 실행하는 관심 그리고 사용한 리소스를 닫아주는 관심이 add()에 함께 있습니다.

DB 커넥션에 변경이 생겨도 add() 메소드를 수정해야 하고 SQL 문장이 달라져도, 닫을 리소스에 변경이 생겨도 add() 메소드를 수정해야 합니다. 

 

심지어 DB 커넥션을 가져오는 코드는 add() 메소드와 get() 메소드에 중복해서 나타납니다. 아직은 두 개의 메소드 밖에 만들지 않았지만, 앞으로 수백, 수천 개의 DAO 메소드를 만들게 될지 모르는데, 그렇게 되면 DB 커넥션을 가져오는 코드가 여기저기에 계속 중복됩니다.

위에서 구현한 UserDao는 하나의 관심사가 중복되어 있고, 여기저기 흩어져 있어서 다른 관심의 대상과 얽혀 있습니다.

 

먼저 DB 커넥션을 가져오는 중복된 코드를 별도의 함수로 분리하겠습니다.

public void add(User user) throws ClassNotFoundException, SQLExcetion {
    Connection c = getConnection();
    ...
}

public User get(String id) throws ClassNotFoundException, SQLExcetion {
    Connection c = getConnection();
    ...
}

// DB 커넥션을 가져오는 코드를 분리함
private Connection getConnection() throws ClassNotFoundException, SQLException {
    Class.forName("com.mysql.jdbc.Driver");
    Connection c = DriverManager.getConnection(
        "jdbc:mysql://localhost/springbook", "spring", "book");
    return c;
}

 

DB 커넥션을 가져오는 코드를 getConnection() 메소드로 분리하여 DB 연결에 변경사항이 생기면 getConnection() 메소드만 살펴보고 수정하면 됩니다. 

 

DB 커넥션 만들기의 독립

메소드로 관심사를 분리하여 중복을 제거하였습니다.

이번엔 더 나아가서 변화에 대응하는 수준이 아니라, 아예 변화를 반기는 DAO를 만들어봅시다.

 

위에서 만든 UserDao를 N사와 D사에 납품한다고 가정해 봅시다. 이때 N사와 D사는 각기 다른 종류의 DB를 사용하고 있고, DB 커넥션을 가져오는 데 있어 독자적으로 만든 방법을 적용하고 싶어 합니다. 심지어 UserDao를 납품한 뒤에도 DB 커넥션을 가져오는 방법이 변경될 가능성이 있습니다.

물론 UserDao의 소스코드를 N사와 D사에 공개하여 DB 커넥션을 가져오는 방법이 변경되면 getConnection() 메소드를 직접 수정하도록 할 수 있습니다. 하지만 우리는 UserDao 소스코드 자체를 공개하고 싶진 않고 미리 컴파일된 클래스 바이너리 파일만 제공하고 싶습니다.

 

결국 UserDao 소스코드를 제공하지 않으면서 고객 스스로 DB 커넥션 생성 방식을 변경하기 위해서는 DB 커넥션을 만드는 코드를 독립시켜야 합니다. 

 

UserDao의 getConnection()을 추상 메소드로 만들고 추상 클래스가 된 UserDao를 N사와 D사에 제공하면 됩니다.

UserDao 납품받은 N사와 D사는 UserDao 추상 클래스를 상속해서 각각 NUserDao DUserDao라는 서브클래스를 만듭니다. 그러고 본인의 회사가 원하는 DB 커넥션 생성 방식을 서브클래스에서 getConnection() 메소드에 구현합니다.

 

기존에는 같은 클래스에 다른 메소드로 분리됐던 DB 커넥션 연결이라는 관심을 이번에는 상속을 통해 서브클래스로 분리해버리는 것입니다. 아래 코드로 살펴봅시다.

public abstract class UserDao {
    public void add(User user) throws ClassNotFoundException, SQLException {
        Connection c = getConnection();
        ....
    }
    
    public User get(String id) throws ClassNotFoundException, SQLException {
        Connection c = getConnection();
        ....
    }
    
    public abstract Connection getConnection() throws ClassNotFoundException, SQLException;
}

 

UserDao 추상 클래스를 상속해서 N사와 D사에서 각각 구현한 NUserDao DUserDao 아래와 같을 것입니다.

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

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

 

DB 커넥션에 대한 관심사를 추상 메소드와 상속을 통해 서브클래스로 분리하면서 DAO의 핵심 기능인 어떻게 데이터를 넣고, 가져올지에 대한 관심은 UserDao 추상 클래스가 책임지고, DB 연결 방법에 대한 관심은 NUserDao, DUserDao가 책임지며 클래스 레벨로 관심사를 분리했습니다.

클래스 계층구조를 통해 두 개의 관심이 독립적으로 분리되면서 변경에 더 쉽게 대응할 수 있습니다. 또한 UserDao의 수정 없이 DB 연결 기능을 새롭게 정의한 클래스를 만들 수 있습니다. 이제 UserDao는 변경이 용이함과 동시에 확장도 쉽게 가능해졌습니다.

 

결론적으로 상속구조를 통해 성격이 다른 관심사항을 분리한 코드를 만들었고, 서로 영향을 덜 주도록 만들었습니다.

 

이렇게 슈퍼클래스에 기본적인 로직의 흐름을 만들고, 기능의 일부를 추상 메소드나 오버라이딩이 가능한 protected 메소드 등으로 만든 서브클래스에서 메소드를 필요에 맞게 구현해서 사용하는 디자인 패턴을 템플릿 메소드 패턴이라고 합니다. 템플릿 메소드 패턴은 변하지 않는 기능을 슈퍼클래스에 두고, 자주 변경되며 확장될 기능은 서브클래스에서 만들도록 합니다. 아래 코드와 같이 슈퍼 클래스에서는 기본 알고리즘을 담고 있는 템플릿 메소드를 만들고 서브클래스에서 선택적 또는 필수적으로 함수들을 오버라이드하도록 만듭니다.

public abstract class Super {
    // 템플릿 메소드는 기본 알고리즘 골격을 담고 있다. 
    // 서브클래스에서 오버라이드하거나 구현할 메소드를 사용한다.
    public void templateMethod() {
        // 기본 알고리즘 코드
        hookMethod();
        abstractMethod();
        ...
    }
    
    // 선택적으로 오버라이드 가능한 훅 메소드
    protected void hookMethod() {}
    // 서브클래스에서 반드시 구현해야 하는 추상 메소드
    public abstract void abstractMethod();
}

// 슈퍼클래스의 메소드를 오버라이드하거나 구현해서 기능을 확장한다.
// 다양한 확장 클래스를 만들 수 있다.
public class Sub1 extends Super {
    protected void hookMethod() {
        ...
    }
    
    public void abstractMethod() {
        ...
    }
}

 

그리고 UserDao의 서브클래스의 getConnection() 메소드는 어떤 Connection 클래스의 오브젝트를 어떻게 생성할 것인지를 결정하는 방법입니다. 이렇게 서브클래스에서 구체적인 오브젝트 생성 방법을 결정하는 것을 팩토리 메소드 패턴이라고 부릅니다. 서브클래스는 다양한 방법으로 오브젝트를 생성하는 메소드를 재정의할 수 있습니다. 

 

상속을 통해 기능을 확장하는 팩토리 메소드 패턴과 템플릿 메소드 패턴을 잘 아는 개발자라면 "초기 UserDao에 팩토리 메소드 패턴을 적용해서 getConnection()을 분리합시다"라는 말로 지금까지의 리팩터링 과정을 한 번에 표현할 수 있습니다.

 

이와 같이 상속을 통한 개선으로 변경과 확장에 용이한 UserDao가 되었습니다.

하지만 상속을 사용했다는 단점이 존재합니다.

 

자바는 클래스의 다중상속을 허용하지 않기 때문에 커넥션 객체를 가져오는 방법을 분리하기 위해 상속구조로 만들어버리면, 다른 목적으로 UserDao에 상속을 적용하기 어렵습니다.

그리고 상속을 통한 상하위 클래스의 관계는 생각보다 밀접합니다. 서브클래스는 슈퍼클래스의 기능을 직접 사용할 수 있습니다. 그래서 슈퍼클래스 내부의 변경이 있을 때 모든 서브클래스를 함께 수정해야 할 수 있습니다. 

심지어 서브클래스로 확장한 DB 커넥션을 생성하는 코드는 다른 DAO 클래스에 적용할 수 없습니다. 만약 UserDao 외의 DAO 클래스들이 계속 만들어진다면 그때는 상속을 통해서 만들어진 getConnection()의 구현 코드가 매 DAO 클래스마다 중복돼서 나타나는 문제가 발생할 것입니다.

 

상속을 통해 DB 커넥션을 생성하는 코드를 분리하는 방법 말고도 클래스 차원 또는 인터페이스 차원으로 분리하는 방법이 존재합니다. 클래스 차원과 인터페이스 차원으로 분리하는 방법은 다음 글에서 살펴봅시다.