본문 바로가기
BackEnd/Spring

Spring - 오브젝트와 의존관계(상속을 통한 확장과 인터페이스의 도입)

by 규난 2023. 3. 19.
728x90

2023.03.19 - [Spring] - Spring - 오브젝트와 의존관계(관심사의 분리)

 

Spring - 오브젝트와 의존관계(관심사의 분리)

Spring 이란? 스프링은 자라를 기반으로 한 기술입니다. 스프링의 철학은 자바 엔터프라이즈 기술의 혼란 속에서 잃어버렸던 객체지향 기술의 진정한 가치를 회복시키고, 그로부터 객체지향 프로

rbsks.tistory.com

이전 포스트에서 리팩토링 메소드 추출 기법을 사용하여 관심사를 분리해 변화에 좀 더 유연하게 대처할 수 있는 코드를 만들어 보았습니다.

이번 포스트에서는 변화를 반기는 DAO를 만들어 보겠습니다.

 

앞에서 만들었던 UserDao가 인기를 끌면서 N 사와 D 사에서 사용자 관리를 위해 UserDao를 구매하겠다는 주문이 들어왔다고 가정해 봅시다.

하지만 납품 과정에서 문제가 발생하였습니다. 문제는 N 사와 D 사가 각기 다른 종류의 DB를 사용하고 있고, DB 커넥션을 가져오는 데 있어 독자적으로 만든 방법을 적용하고 싶어 한다는 점이었습니다.

 

이런 경우에는 UserDao의 소스코드를 N 사와 D 사에게 제공해 주고, 변경이 필요하면 getConnection() 메소드를 수정해서 사용하라고 할 수 있지만 UserDao의 소스코드를 공개하지 않고 컴파일된 클래스 바이너리 파일만 제공하고 싶었습니다.

 

이런 경우에는 상속을 통한 확장을 통해 기존 UserDao 코드를 한 단계 더 분리하면 됩니다.

위에서 만들었던 UserDao를 추상 클래스로 만들고 getConnection() 메소드의 구현 코드를 제거하고 추상 메소드로 만들어 놓습니다.

추상 클래스로 만들어 놓은 UserDao를 N 사와 D 사는 UserDao 클래스를 상속해서 각각 NUserDao, DUserDao라는 서브 클래스를 만든 후 서브 클래스에서 getConnection() 메소드를 원하는 방식으로 구현만하면 UserDao의 소스코드를 수정하지 않고 얼마든지 UserDao의 기능을 쓸 수 있습니다. 기존에는 같은 클래스 안에서 다른 메소드로 분리됐던 DB connection 연결이라는 관심이 이번에는 상속을 통해 서브클래스로 분리를 한 것입니다.

 

서브 클래스에서 구현한 getConnection() 메소드는 JDBC가 정의한 Connection 인터페이스를 구현한 Connection 오브젝트를 각자의 생성 알고리즘을 이용해 만들어냅니다. 이렇게 getConnection() 메소드에서 생성하는 Connection 오브젝트의 구현 클래스는 제각각이겠지만 UserDao는 Connection 오브젝트가 만들어지는 방법과 내부 동작 방식에는 관심을 두지 않고 Connection 인터페이스에 정의된 메소드를 사용할 뿐입니다.

UserDao는 어떤 기능을 사용한다는 데에만 관심이 있고, NUserDao, DUserDao에서는 어떤 방법으로 Connection 오브젝트를 만들고 어떤 식으로 Connection 기능을 제공하는지에 관심을 두고 있는 것입니다. 

 

UserDao의 코드 변경 - 상속을 통한 확장

package one;

import java.sql.*;

public abstract class UserDao {

    public void add(User user) throws ClassNotFoundException, SQLException {
        // db connection
        Connection connection = getConnection();

        // add user
        PreparedStatement ps = connection.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();

        // connection close
        ps.close();
        connection.close();
    }

    public User get(String id) throws ClassNotFoundException, SQLException {
        // db connection
        Connection connection = getConnection();

        // get user
        PreparedStatement ps = connection.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"));

        // connection close
        rs.close();
        ps.close();
        connection.close();

        return user;
    }

    // 상속을 통한 확장
    public abstract Connection getConnection() throws ClassNotFoundException, SQLException;
//     관심사의 분리
//    private Connection getConnection() throws ClassNotFoundException, SQLException {
//        Class.forName("com.mysql.cj.jdbc.Driver");
//        Connection connection = DriverManager.getConnection(
//                "jdbc:mysql://localhost:3306/toby?autoReconnect=True", "toby_use", "db1234");
//        return connection;
//    }
}

 

 

UserDao를 상속받은 NUserDao - getConnection() 구현

package one;

import java.sql.Connection;
import java.sql.DriverManager;
import java.sql.SQLException;

public class NUserDao extends UserDao {

    @Override
    public Connection getConnection() throws ClassNotFoundException, SQLException {
        Class.forName("com.mysql.cj.jdbc.Driver");
        Connection connection = DriverManager.getConnection(
                "jdbc:mysql://localhost:3306/toby?autoReconnect=True", "toby_use", "db1234");
        return connection;
    }
}

 

상속을 통한 확장 방법을 사용한 UML

수정한 코드를 살펴보면 DAO의 핵심 기능인 어떻게 데이터를 등록하고 가져올 것인가라는 관심을 담당하는 UserDao와, DB 연결 방법은 어떻게 할 것인가라는 관심을 담고 있는 NUserDao, DUserDao가 클래스 레벨로 구분되고 있습니다.

클래스 계층구조를 통해 두 개의 관심이 독립적으로 분리되면서 변경 작업은 한층 용이해졌습니다.

 

이제는 UserDao의 코드는 한 줄도 수정할 필요 없이 DB 연결 기능을 새롭게 정의한 클래스를 만들 수 있고, 변경이 용이하다는 수준을 넘어서 손쉽게 확장된다고 말할 수 있게 되었습니다. 만약 새로운 DB 연결 방법을 적용해야 할 때는 UserDao를 상속을 통해 확장해 주기만 하면 됩니다.

 

이렇게 슈퍼 클래스에서 기본적인 로직의 흐름(커넥션 가져오기, SQL 생성 및 실행, 자원 반납)을 만들고 그 기능의 일부를 추상 메소드나 오버라이딩이 가능한 protected 메소드 등으로 만든 뒤 서브 클래스에서 이런 메소드를 필요에 맞게 구현해서 사용하도록 하는 방법을 디자인 패턴에서 템플릿 메소드 패턴(template method pattern)이라고 합니다.

 

또한 UserDao의 getConnection() 메소드는 Connection 타입의 오브젝트를 생성한다는 기능을 정의해놓은 추상 메소드입니다.

그리고 UserDao의 서브 클래스의 getConncetion() 메소드는 어떤 Connection 클래스의 오브젝트를 생성할 것인지를 결정하는 방법이라고도 볼 수 있습니다. 이렇게 슈퍼 클래스의 추상 메소드에서 특정 타입의 오브젝트를 생성하는 기능을 정의해놓고 서브 클래스에서 추상 메소드를 구현해 특정 타입의 오브젝트와 관련된 오브젝트 중 어떤 오브젝트를 생성할 것인지를 결정하는 방법, 즉 서브 클래스에서 구체적인 오브젝트 생성 방법을 결정하게 하는 것을 팩토리 메소드 패턴(factory method pattern)이라고 합니다.

 

이 두 패턴은 관심사항이 다른 코드를 분리해 내고, 서로 독립적으로 변경 또는 확장할 수 있도록 해주는 방법 중 가장 간단하면서도 효과적인 패턴입니다.

 

다시 본론으로 돌아와서 위 코드를 보면 하나의 단점이 있습니다.

바로 상속을 사용했다는 것입니다. UserDao는 커넥션 객체를 가져오는 방법을 분리하기 위해 상속 구조로 만들어 버렸는데 이러면 추후에 UserDao에 다른 목적으로 상속을 적용하기 힘들어집니다. 또 다른 문제는 상속을 통한 상하위 클래스의 관계는 생각보다 밀접하다는 점입니다. 상속을 통해 관심이 다른 기능을 분리하고, 필요에 따라 다양한 변신이 가능하도록 확장성도 줬지만 여전히 상속관계는 두 가지 다른 관심사에 대해 긴밀한 결합을 허용합니다. 서브 클래스는 슈퍼 클래스의 기능을 직접 사용 할 수 있고, 슈퍼 클래스 내부의 변경이 있을 때 모든 서브 클래스를 함께 수정해야하는 일이 발생할 수 있습니다. 그리고 확장된 기능인 DB 커넥션을 생성하는 코드를 다른 DAO 클래스에 적용할 수 없다는 단점이 있습니다. 예를 들어 NUserDao와 NContentDao가 동일한 코드로 DB 커넥션을 가져오는데도 불구하고 NUserDao에 작성한 코드를 그대로 NContentDao에도 작성해줘야합니다. 이렇게 상속을 통해 만들어지 getConnection()의 구현 코드가 매 DAO 클래스마다 중복되는 문제가 발생할 수 있습니다.

인터페이스 도입

위 문제를 해결할 수 있는 가장 좋은 해결책은 두 개의 클래스가 서로 긴밀하게 연결되어 있지 않도록 중간에 추상적인 느슨한 연결고리를 만들어주는 것입니다. 추상화란 어떤 것들의 공통적인 성격을 뽑아내어 이를 따로 분리해 내는 작업입니다. 자바가 추상화를 위해 제공하는 가장 유용한 도구는 바로 인터페이스입니다.

 

인터페이스는 자신을 구현한 클래스에 대한 구체적인 정보는 모두 감춰버립니다. 결국 오브젝트를 만들려면 구체적인 클래스 하나를 선택해야겠지만 인터페이스로 추상화해놓으면 접근하는 쪽에서는 오브젝트를 만들 때 사용할 클래스가 무엇이지 몰라도 같은 타입의 인터페이스를 구현한 오브젝트라면 원하는 기능을 사용할 수 있습니다.

 

인터페이스는 어떤 일을 하겠다고 기능만 정의해놓은 것입니다. 따라서 인터페이스에는 어떻게 하겠다는 구현 방법은 나타나있지 않습니다.

UserDao가 인터페이스를 사용하게 한다면 인터페이스의 메소드를 통해 알 수 있는 기능에만 관심만 가지면 되지, 그 기능을 어떻게 구현했는지에 관심을 둘 필요가 없어집니다.

 

UserDao 수정 코드

package one;

import java.sql.*;

public class UserDao {

    private ConnectionMaker connectionMaker;

    public UserDao() {
        this.connectionMaker = new DConnectionMaker();
    }

    public void add(User user) throws ClassNotFoundException, SQLException {
        // db connection
        Connection connection = connectionMaker.makeConnection();

        // add user
        PreparedStatement ps = connection.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();

        // connection close
        ps.close();
        connection.close();
    }

    public User get(String id) throws ClassNotFoundException, SQLException {
        // db connection
        Connection connection = connectionMaker.makeConnection();

        // get user
        PreparedStatement ps = connection.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"));

        // connection close
        rs.close();
        ps.close();
        connection.close();

        return user;
    }
}

 

인터페이스 도입 코드

package one;

import java.sql.Connection;
import java.sql.SQLException;

public interface ConnectionMaker {
    Connection makeConnection() throws ClassNotFoundException, SQLException;
}

// NConnectionMaker
package one;

import java.sql.Connection;
import java.sql.DriverManager;
import java.sql.SQLException;

public class NConnectionMaker implements ConnectionMaker {

    @Override
    public Connection makeConnection() throws ClassNotFoundException, SQLException {
        Class.forName("com.mysql.cj.jdbc.Driver");
        Connection connection = DriverManager.getConnection(
                "jdbc:mysql://localhost:3306/toby?autoReconnect=True", "toby_use", "db1234");
        return connection;
    }
}


// DConnectionMaker
package one;

import java.sql.Connection;
import java.sql.DriverManager;
import java.sql.SQLException;

public class DConnectionMaker implements ConnectionMaker {

    @Override
    public Connection makeConnection() throws ClassNotFoundException, SQLException {
        Class.forName("com.mysql.cj.jdbc.Driver");
        Connection connection = DriverManager.getConnection(
                "jdbc:mysql://localhost:3306/toby?autoReconnect=True", "toby_use", "db1234");
        return connection;
    }
}

하지만 위 UserDao 코드에서 한 가지 문제점이 있습니다.

문제는 바로 UserDao 생성자를 보시면 DConnectionMaker 오브젝트를 직접 생성하는 코드가 있다는 것입니다.

이렇게 UserDao에서 직접 DB 연결을 관리하는 오브젝트를 직접 생성하게 되면 UserDao의 생성자를 수정하지 않고서는 다른 DB 연결 방법을 도입할 방법이 없습니다. 즉 DB 연결 기능의 확장이 자유롭지 못하게 되는 것입니다.

 

상속의 문제를 해결하기 위해 UserDao와 ConnectionMaker라는 두 개의 관심을 인터페이스를 써가면서까지 분리했는데도 이러한 문제가 왜 발생하는 이유는 UserDao에 어떤 ConnectionMaker 구현 클래스를 사용할지 결정하는 코드(new DConnectionMaker)가 있기 때문입니다. 이 코드는 UserDao의 관심사인 JDBC API와 User 오브젝트를 이용해 DB에 정보를 넣고 가져오는 부분과 별개의 관심사입니다. 이 부분을 분리하지 않으면 UserDao는 결코 독립적으로 확장 가능한 클래스가 될 수 없게 됩니다.

 

 

위 사진을 보시면 UserDao와 DConnectionMaker 클래스 사이에 직접적인 관계가 있다는 사실을 알 수 있습니다.

 

이러한 문제를 해결하기 위해서는 관계 설정 책임을 분리하면 됩니다.

UserDao를 사용하는 클라이언트 오브젝트를 활용하여 UserDao와 ConnectionMaker 구현 클래스의 관계를 결정해 주는 기능을 분리하는 것입니다. 

 

UserDao 생성자에 파라미터로 Connection의 오브젝트를 전달받을 수 있도록 파라미터를 하나 추가합니다.

 

UserDao 변경 코드

package one;

import java.sql.*;

public class UserDao {

    private ConnectionMaker connectionMaker;

    public UserDao(ConnectionMaker connectionMaker) {
        this.connectionMaker = connectionMaker;
//        this.connectionMaker = new DConnectionMaker();
    }
	
    // add ...
    
    // get ...
}

 

그리고 UserDao를 사용하는 클라이언트인 main() 메소드에서 ConnectionMaker의 오브젝트인 DConnectionMaker를 UserDao 생성자 파라미터에 넣어 UserDao와 DConnectionMaker 두 개의 오브젝트를 연결해줍니다. 클라이언트는 UserDao와 Connection Maker 구현 클래스와의 런타임 오브젝트 의존 관계를 성정하는 책임을 담당하게 됩니다.

 

main() 메소드에서의 UserDao와 DConnection 두 개의 오브젝트 관계 설정

package one;

import java.sql.SQLException;

public class Main {

    public static void main(String[] args) throws ClassNotFoundException, SQLException {
        UserDao userDao = new UserDao(new DConnectionMaker());

        // test code ...
    }
}

이렇게 UserDao와 ConnectionMaker의 구현 클래스의 오브젝트와 관계 설정을 UserDao를 사용하는 클라이언트에 넘겨주면 UserDao와 ConnectionMaker의 구현 클래스의 직접적인 관계가 끊기게 됩니다.

 

밑의 사진을 보시면 UserDao와 DConnectionMaker 클래스 사이에 직접적인 관계가 있던 UML과 다르게 UserDao와 DConnectionMaker와의 관계가 끊어졌다는 것을 볼 수 있습니다.

 

IoC - 제어의 역전

UserDao를 사용하는 클라이언트인 main() 메소드 덕분에 UserDao가 ConnectionMaker 구현 클래스로 부터 완벽하게 독립할 수 있었습니다.

 

하지만 main() 메소드도 UserDao의 기능이 잘 동작하는지 테스트하려고 만든 것인데 UserDao와 ConnectionMaker 구현 클래스를 생성해 오브젝트간의 관계를 설정해주는 책임까지 떠맡고 있습니다. 이렇게 성격이 다른 책임이나 관심사는 분리해버리는 것이 지금까지 해왔던 주요한 작업이니 이것도 분리하도록 하겠습니다.

 

분리될 기능은 UserDao와 ConnectionMaker 구현 클래스의 오브젝트를 만드는 것과, 만들어진 두 개의 오브젝가 연결돼서 사용될 수 있도록 관계를 맺어주는 것입니다. 이렇게 분리시킬 기능을 담당할 클래스의 역할은 객체의 생성 방법을 결정하고 그렇게 만들어진 오브젝트를 돌려주는 것인데, 이런 일을 하는 오브젝트를 흔히 팩토리(factory)라고 부릅니다.

이는 디자인 패턴에서 말하는 특별한 문제를 해결하기 위해 사용되는 추상 팩토리 패턴이나 팩토리 메소드 패턴과는 다르니 혼당하지 않도록 주의해야 합니다. 이 팩토리는 단지 오브젝트를 생성하는 쪽과 생성된 오브젝트를 사용하는 쪽의 역할과 책임을 깔끔하게 분리하려는 목적으로 사용하는 것입니다.

 

Object Factory 코드

// DaoFactory
package one;

public class DaoFactory {

    public UserDao userDao() {
        UserDao userDao = new UserDao(getConnectionMaker());
        return userDao;
    }
    
    private ConnectionMaker getConnectionMaker() {
        ConnectionMaker connectionMaker = new DConnectionMaker();
        return connectionMaker;
    }
}

// UserDao client main method
package one;

import java.sql.SQLException;

public class Main {

    public static void main(String[] args) throws ClassNotFoundException, SQLException {
//        UserDao userDao = new UserDao(new DConnectionMaker());
        UserDao userDao = new DaoFactory().userDao();

 		// test code ...
}

팩토리 역할을 맡을 DaoFactory 클래스를 만들고 UserDao를 사용하는 클라이언트인 main() 메소드에서 DaoFactory에 요청해서 미리 만들어진 UserDao 오브젝트 가져와 사용하게 만듭니다.

 

클라이언트인 main() 메소드는 이제 UserDao가 어떻게 만들어지는지 어떻게 초기화되어 있는지에 신경 쓰지 않고 DaoFactory로 부터 UserDao 오브젝트를 받아서 자신의 관심사인 테스트를 위해 활용하기만 하면 됩니다.

 

이제 제어의 역전이라는 개념에 대해 알아봅시다. 제어의 역전이라는 건, 간단히 프로그램의 제어 흐름 구조가 뒤바뀌는 것이라고 설명할 수 있습니다. 일반적인 프로그램의 흐름은 main() 메소드와 같이 프로그램이 시작되는 지점에서 다음에 사용할 오브젝트를 결정하고, 결정한 오브젝트를 생성하고, 만들어진 오브젝트에 있는 메소드를 호출하고, 그 오브젝트 메소드 안에서 다음에 사용할 것을 결정하고 호출하는 식의 작업이 반복됩니다. 이런 프로그램 구조에서는 각 오브젝트는 프로그램의 흐름을 결정하거나 사용할 오브젝트를 구성하는 작업에 능동적으로 참여합니다.

 

제어의 역전이란 이런 제어의 흐름의 개념을 거꾸로 뒤집는 것입니다. 오브젝트가 자신이 사용할 오브젝트를 스스로 선택하지 않고 당연히 생성하지도 않습니다. 또 자신도 어떻게 만들어지고 어디서 사용되는지 알 수 없습니다. 모든 제어의 권한을 자신이 아닌 다른 대상에게 위임하기 때문입니다. 

프레임워크 vs 라이브러리

프레임워크는 애플리케이션 코드가 프레임워크에 의해 사용됩니다. 보통 프레임워크 위에 개발한 클래스를 등록해두고, 프레임워크가 흐름을 주도하는 중에 개발자가 만든 애플리케이션 코드를 사용하도록 만드는 방식인 반면에 라이브러리는 라이브러리를 사용하는 애플리케이션 코드가 동작하는 중에 필요한 기능이 있을 때 능동적으로 라이브러리를 사용합니다. 

 

위에서 만든 UserDao와 DaoFactory에도 제어의 역전이 적용되어 있습니다. 원래 ConnectionMaker의 구현 클래스를 결정하고 오브젝트를 만드는 제어권은 UserDao에게 있었는데 지금은 DaoFactory에 있습니다. 자신이 어떤 ConnectionMaker의 구현 클래스를 만들고 사용할지를 결정할 권한을 DaoFactroty에게 넘기고 UserDao 자기 자신도 DaoFactory에 의해 만들어지니 UserDao는 이제 수동적인 존재가 되었습니다. 이렇게 UserDao가 사용할 오브젝트인 ConnectionMaker 오브젝트와 자기 자신까지 생성하는 책임까지 모두 DaoFactory가 맡고 있습니다. 관심을 분리하고 책임을 나누고 유연하게 확장이 가능한 구조로 만들기 위한 지금까지의 과정이 바로 IoC를 적용하는 작업이라고 볼 수 있습니다. 

 

제어의 역전에서는 프레임워크 또는 컨테이너와 같이 애플리케이션 컴포넌트의 생성과 관계 설정, 사용, 생명주기 관리 등을 관장하는 존재가 필요합니다. 스프링은 IoC를 모든 기능의 기초가 되는 기반기술로 삼고 있으면 IoC를 극한까지 적용하고 있는 프레임워크입니다. 다음 포스테에서는 스프링의 IoC에 대해서 자세히 알아보도록 하겠습니다.

 

출처 - 토비의 스프링 3. 1 Vol 1 스프링의 이해와 원리

728x90