토비의 스프링 - 1. 오브젝트와 의존관계
오브젝트와 의존관계
[토비의 스프링 3.1] 의 첫 장인 오브젝트와 의존관계입니다. 첫 장에서는 클래스가 아닌 오브젝트의 생성과 의존관계 설정에 대한 내용을, 여러 문제를 안고 있는 초난감 DAO 를 등장시키고 이를 개선해 가며 설명합니다. 또 다음과 같이 스프링의 핵심 철학을 짚어보며 시작합니다.
스프링은 자바를 기반으로 한 기술이며, 스프링이 자바에서 가장 중요하게 여기는 것은 바로 객체지향 프로그래밍이 가능한 언어라는 점이다. 자바 엔터프라이즈의 기술적 혼란 속에서 잃어버린 "객체지향 언어로서 자바가 가지는 진정한 가치를 지키고, 그로부터 객체지향 프로그래밍의 다양한 이점을 누릴 수 있도록 기본으로 돌아가자"는 것이 스프링의 핵심 철학이다.
1. 오브젝트와 의존관계
1 - 1. 초난감 DAO
문제가 많은 코드, 초난감 DAO 입니다.
public class UserDao {
public UserDao() {
}
public void add(User user) throws ClassNotFoundException, SQLException {
Class.forName("com.mysql.jdbc.Driver");
Connection c = DriverManager.getConnection("jdbc:mysql://localhost/springbook?characterEncoding=UTF-8", "spring", "book");
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 ClassNotFoundException, SQLException {
Class.forName("com.mysql.jdbc.Driver");
Connection c = DriverManager.getConnection("jdbc:mysql://localhost/springbook?characterEncoding=UTF-8", "spring", "book");
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;
}
}
Dao 는 Data Access Object 로, UserDao 의 본래 관심은 데이터 저장소에 데이터를 저장하거나, 저장소로부터 데이터를 조회하여 반납하는 것입니다. 위 UserDao 클래스에는 그에 필요한 작업을 적절히 수행하는 코드들이 있는 것 같지만, 자세히 보면 지나치게 많은 관심사를 가지고 있습니다.
Class.forName("com.mysql.jdbc.Driver");
Connection c = DriverManager.getConnection("jdbc:mysql://localhost/springbook?characterEncoding=UTF-8", "spring", "book");
UserDao에 포함된 위의 코드는 어떤 데이터 저장소를 사용할지, 어떤 DB 커넥션을 사용할지 등에 대한 관심을 가지고 있습니다. 하지만 이것은 UserDao 가 가지고 있어야 할 본연의 관심이 아니므로, 위 부분들에 대한 변경이 발생할 경우 UserDao 의 확장성을 심각하게 저해합니다. 지금의 UserDao 코드는 '데이터를 저장하고 조회한다' 라는 핵심 관심과는 별개로 mySql 이라는 데이터베이스와 JDBC 드라이버에 종속되어 있으며, 이 부분에 변경이 발생할 경우 UserDao 코드 전체를 수정해야 하는 심각한 문제를 안고 있는 코드입니다.
1 - 2. 상속을 통한 확장
책에서는 UserDao 의 문제를 해결하기 위해 상속을 사용한 템플릿 메서드, 팩토리 메서드 패턴을 사용합니다. 중복된 코드 또는 관심이 다른 코드를 메서드로 추출하고, 서브 클래스는 슈퍼 클래스에서 추상 메서드로 정의된 부분을 구현하여 사용할 수 있게 하여 확장성을 추가했다고 볼 수 있습니다. 바로 아래 코드처럼, 데이터베이스 커넥션을 얻는 부분을 추상 메서드로 추출하고 이를 상속받아 사용하는 서브클래스에서 정확한 연결 방법을 구현하여 사용할 수 있도록 수정한 것입니다.
public abstract class UserDao {
public void add(User user) throws ClassNotFoundException, SQLException {
Connection c = getConnection();
// add logic ...
}
public User get(String id) throws ClassNotFoundException, SQLException {
Connection c = getConnection();
// get logic ...
return user;
}
abstract protected Connection getConnection() throws ClassNotFoundException, SQLException;
}
public class NUserDao extends UserDao {
protected Connection getConnection() throws ClassNotFoundException,
SQLException {
Class.forName("com.mysql.jdbc.Driver");
Connection c = DriverManager.getConnection(
"jdbc:mysql://localhost/springbook?characterEncoding=UTF-8",
"spring", "book");
return c;
}
}
public class DUserDao extends UserDao {
protected Connection getConnection() throws ClassNotFoundException,
SQLException {
Class.forName("com.mysql.jdbc.Driver");
Connection c = DriverManager.getConnection(
"jdbc:mysql://localhost/springbook?characterEncoding=UTF-8",
"spring", "book");
return c;
}
}
중복된 코드를 제거하고, 별도의 관심에 대한 변경에 유연하게 대응할 수 있는 코드가 되었습니다. 그러나 이 방법은 상속을 사용했다는 점에서 명확하게 한계가 드러납니다.
Java 는 다중상속을 허용하지 않기 때문에, UserDao 가 다른 목적으로 이미 상속을 사용하고 있다면 후에 다른 목적으로 상속을 사용하기 어렵습니다. 또 상속을 통한 슈퍼클래스, 서브클래스의 관계는 생각보다 강하게 연결되어 있습니다. 슈퍼클래스의 변경이 생겼을 때 모든 서브클래스 또한 변경 내용을 전파받게 되고, 반대로 이를 방지하기 위해 슈퍼클래스에 변경에 대한 제약을 가해야 하는 상황이 생길 수도 있습니다.
그렇다면 상속이라는 불완전한 방법 외에, 어떤 방법으로 관심사가 다른 코드를 분리하고 확장성을 높일 수 있을까요? 떠오르는 다음 방법은 관심사가 다른 코드를 아예 다른 클래스로 분리해 버리는 것입니다.
1 - 3. 클래스 분리
public abstract class UserDao {
private SimpleConnectionMaker simpleConnectionMaker;
public UserDao() {
this.simpleConnectionMaker = new SimpleConnectionMaker();
}
public void add(User user) throws ClassNotFoundException, SQLException {
Connection c = this.simpleConnectionMaker.getConnection();
// add logic ..
}
public User get(String id) throws ClassNotFoundException, SQLException {
Connection c = this.simpleConnectionMaker.getConnection();
// get logic ..
return user;
}
}
public class SimpleConnectionMaker {
public Connection getConnection() throws ClassNotFoundException,
SQLException {
Class.forName("com.mysql.jdbc.Driver");
Connection c = DriverManager.getConnection(
"jdbc:mysql://localhost/springbook?characterEncoding=UTF-8", "spring", "book");
return c;
}
}
관심이 다른 코드(커넥션 생성 부분)를 다른 클래스(SimpleConnectionMaker)로 완벽하게 분리해 낸 코드입니다. 다른 관심사의 코드를 완전히 분리해 버리고, 기존 코드에서 해당 클래스 타입의 객체를 생성하여 사용하는 것으로 관심사 자체는 명확하게 분리되었습니다.
그러나 UserDao 코드에 특정 클래스 타입의 객체를 생성하여 사용하는 부분이 포함되어 있습니다. 이로 인해 여전히 확장이 어려울 뿐만 아니라 오히려 상속을 사용할때만 못한 것 같습니다. 간단하게 말해, UserDao 와 SimpleConnectionMaker 객체간의 결합도가 매우 높은 상황입니다. 이를 해소하기 위해 인터페이스를 사용할 수 있을 것 같습니다.
인터페이스는 객체 간의 연결다리를 추상화하여 결합을 느슨하게 해 주는 자바의 유용한 도구입니다. 특정 객체가 아닌 인터페이스와 관계를 맺음으로써, 우리는 의존 객체에 대해 알 필요가 없는 내용을 알지 않아도 되고 최소한의 정보로 이루어진 추상적인 연결고리를 가지게 됩니다. 이는 객체 간의 결합도를 낮추어 유연하고 확장성 있는 설계를 가능하게 합니다.
public class UserDao {
private ConnectionMaker connectionMaker; // 인터페이스 타입
public UserDao() {
connectionMaker = new DConnectionMaker(); // ?
}
public void add(User user) throws ClassNotFoundException, SQLException {
Connection c = this.connectionMaker.makeConnection(); // 인터페이스에 정의된 메서드 사용
// ...
}
public User get(String id) throws ClassNotFoundException, SQLException {
Connection c = this.connectionMaker.makeConnection();
// ...
return user;
}
}
위 코드는 특정 객체와 관계를 맺고 있던 코드를 인터페이스와 관계를 맺도록 수정한 것입니다. 이제 ConnectionMaker 타입의 객체라면 어떻게 구현되었는가에 상관 없이 인터페이스가 제공하는 메서드를 사용하면 됩니다.
그런데 UserDao 의 생성자 부분을 보면, 아직 특정 클래스에 대한 의존이 드러나는 것을 확인할 수 있습니다. 바로 "어떤 구현체를 사용할 건지" 에 대한 정보가 UserDao 에 남아있는 것입니다.
public UserDao() {
connectionMaker = new DConnectionMaker(); // ?
}
관심이 다른 코드도 다른 클래스로 분리했고, 객체가 아닌 인터페이스와 관계를 가짐으로써 결합도도 떨어뜨린 것 같은데 왜 이런 문제가 발생하는 걸까요? 그것은 ConnectionMaker 와 UserDao 간의 관계설정의 책임이 아직 UserDao 안에 있기 때문입니다. 좀 더 풀어 말하면, "ConnectionMaker 의 어떤 구현체를 사용할지를 결정하는 관심"이 UserDao 안에 남아있기 때문입니다.
UserDao 가 사용할 ConnectionMaker 의 구현체를 결정하는 것은 UserDao 가 가져야 하는 것과는 완전히 별개인 또 하나의 독립적인 관심입니다. 이 책임을 가지기에 적절한 것은 UserDao 자신이 아니라, UserDao 를 사용하는 클라이언트 객체입니다. 즉, 외부에서 UserDao 가 어떤 구현체를 사용할 지 결정해 주는 것이지요.
제가 놓쳤던 부분 중 하나입니다. 스프링을 사용하여 여러 번 개발을 해 봤고, 자연스럽게 의존성을 외부에서 주입받아 사용하며 그 이유도 알고 있다고 생각했지만 정확히 관계설정의 책임을 외부에서 가져간다 라는 의미가 있었구나 라고 다시 생각하게 되었습니다. 자연스레 컨테이너의 역할과 존재 의미에 대해서도 더 와닿게 되었습니다.
public class UserDao {
private ConnectionMaker connectionMaker;
public UserDao(ConnectionMaker simpleConnectionMaker) {
this.connectionMaker = simpleConnectionMaker;
}
public void add(User user) throws ClassNotFoundException, SQLException {
Connection c = this.connectionMaker.makeConnection();
// ...
}
public User get(String id) throws ClassNotFoundException, SQLException {
Connection c = this.connectionMaker.makeConnection();
// ...
return user;
}
}
public class UserDaoTest { // UserDao 의 클라이언트
public static void main(String[] args) throws ClassNotFoundException, SQLException {
ConnectionMaker connectionMaker = new DConnectionMaker();
UserDao dao = new UserDao(connectionMaker);
// ...
}
}
위 코드는 UserDao 의 클라이언트 객체인 UserDaoTest 에서 ConnectionMaker 의 특정 구현체(DConnectionMaker) 를 생성하고, UserDao 에게 생성자 파라미터로 connectionMaker 의 정보를 넘겨주면서 관계설정의 책임을 가져가도록 수정한 코드입니다.
이제 다른 관심을 가진 코드는 다른 클래스로 분리되었고, 분리된 클래스는 인터페이스를 통해 관계를 맺음으로써 약하게 결합되어 있는 상태입니다. 서로 다른 관심 영역에 변경이 발생해도, 해당 부분을 담고 있는 코드만을 수정하는 것이 가능하며 변경이 다른 객체로 전파되지 않습니다.
1 - 4. 제어의 역전(IoC)
UserDao 는 현재 생성과 어떤 구현 클래스를 사용할지에 대한 책임을 외부에서 떠안고 있습니다. 바로 UserDaoTest 입니다. 당장 UserDao 를 외부에서 사용하는 객체가 UserDaoTest 밖에 없는 바람에 UserDao 의 생성과 관계설정의 책임까지 UserDaoTest 에서 떠맡고 있는 상황입니다. UserDaoTest 의 본연의 관심은 UserDao 가 올바르게 동작하는지 테스트하는 것 뿐일텐데 말입니다.
따라서 이 책임을 가져갈 객체를 만들어 줘야 합니다. 지금 상황과 같이, 객체의 생성 방법을 결정하고 만들어진 객체를 돌려주는 역할을 하는 객체를 일반적으로 팩토리(factory) 라고 부릅니다. 그렇다면 UserDao 를 생성하는 책임을 맡는 UserDaoFactory 클래스를 다음과 같이 작성할 수 있을 것 같습니다.
public class UserDaoFactory {
public UserDao userDao() {
UserDao dao = new UserDao(connectionMaker());
return dao;
}
public ConnectionMaker connectionMaker() {
ConnectionMaker connectionMaker = new DConnectionMaker();
return connectionMaker;
}
}
이제 UserDaoTest 는 본연의 테스트라는 책임만을 가지게 됐고, UserDao 객체의 생성과 관계 설정의 책임은 이를 담당하는 UserDaoFactory 객체가 맡게 됐습니다. 이렇게 만들어진 팩토리 객체는 핵심 로직을 담고 있는 UserDao 와 같은 객체들과는 조금 다른 성격을 띠게 됩니다.
UserDao 와 ConnectionMaker 등의 객체가 각각 어플리케이션의 핵심 비즈니스 로직 또는 기술 로직을 담고 있는 컴포넌트라면, 팩토리 객체는 어플리케이션을 구성하는 컴포넌트들의 구조와 관계를 정의하는 일종의 '설계도' 같은 역할을 한다고 볼 수 있습니다. 설계도가 와닿지 않는다면 간단하게 '어떤 컴포넌트가 어떤 컴포넌트를 어떻게 사용하는지 정의해 놓은 코드' 처럼 생각할 수도 있겠습니다. 팩토리를 분리하여 얻을 수 있는 이점은 다양하지만, 그 중에서도 어플리케이션의 컴포넌트 역할을 하는 객체와 어플리케이션의 구조를 결정하는 객체를 분리했다는 것에 가장 큰 의미가 있습니다.
지금 살펴본 UserDao 의 개선 과정 중에, 스프링의 가장 중요한 철학 중 하나가 나왔습니다. 바로 제어의 역전(IoC) 입니다. IoC 는 스프링에 국한된 개념만이 아니라, 객체의 제어 흐름을 사용하는 쪽(자기 자신) 이 아닌 외부에 맡기는 제어 권한의 역전 그 자체를 말합니다. IoC 개념은 비단 스프링 뿐만 아니라 서블릿, JSP 등 다양한 곳에서 찾아볼 수 있으며, 프레임워크 자체도 IoC 가 적용된 대표적인 기술이라고 할 수 있습니다. 라이브러리와 프레임워크를 비교할 때 라이브러리를 능동적, 프레임워크를 수동적이라고 표현하는 예를 많이 찾아 볼 수 있습니다. 프레임워크에 의해 수동적으로 생성되고 사용되는 오브젝트 그 자체가 IoC 개념이 깊게 적용되었다고 볼 수 있습니다.
위에서 작성한 팩토리도 같은 예입니다. 컴포넌트 역할을 하는 객체의 생성과 사용을 자신이 결정하는 것이 아닌, 외부(팩토리 객체)가 결정하고 있는 현재의 구조 역시 IoC 개념이 적용된 상태로 볼 수 있습니다.
그렇다면 스프링에서 제어의 역전은 어떻게 이루어지는지, 직접 객체로 작성한 팩토리가 제공하는 기능과 어떻게 다른지 등을 알아볼 차례입니다. 스프링은 IoC 를 어떻게 제공할까요? 다음 글에서는 스프링이 제공하는 IoC 와 싱글톤, 그리고 DI 에 대해 다뤄 보도록 하겠습니다.