OCP 원칙
OCP 원칙은 확장에는 자유롭게 열려있고 변경에는 닫혀있다는 객체지향 설계 핵심 원칙이다.특정 코드에는 변경을 통해 기능을 확장하고 다양하게 만들려는 성질이 있고 특정 코드는 고정되어 변하지 않을려는 성질을 가진다. 또한 변경을 통해 기능을 다양하게 하려는 와중에도 그 원인과 목적이 다른 경우가 있다.
“변화의 특성이 다른 곳을 구분하고 각각 다른 원인과 목적, 시점에 독립적으로 변경 될 수 있도록 효율적인 구조를 만드는 것이 핵심”
public void deleteAll() throws SQLException {
Connection c = null;
PreparedStatement ps = null;
try {
// DB Connection 가져오기
c = this.dataSource.getConnection();
// SQL 쿼리(statement)를 생성
ps = c.prepareStatement("delete from users");
// statement 를 실행
ps.executeUpdate();
} catch (SQLException e) {
throw e;
} finally {
if (ps != null) {
try {
ps.close();
} catch (SQLException e) {
throw e;
}
}
if (c != null) {
try {
c.close();
} catch (SQLException e) {
throw e;
}
}
}
}
public void add(User user) throws SQLException {
Connection c = null;
PreparedStatement ps = null;
try {
// DB Connection 가져오기
c = this.dataSource.getConnection();
// SQL 쿼리(statement)를 생성
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());
// statement 를 실행
ps.executeUpdate();
} catch (SQLException e) {
throw e;
} finally {
if (ps != null) {
try {
ps.close();
} catch (SQLException e) {
throw e;
}
}
if (c != null) {
try {
c.close();
} catch (SQLException e) {
throw e;
}
}
}
}
공유 리소스인 Connection, PreparedStatment 를 DB Pool 에서 받아와 하기에 반복되는 try-catch-finally 구문이 포함되어 있다. delete, findOne 등등 UserDao 의 모든 DB 엑세스 메서드는 위의 구조가 반복된다.
✏️ 리소스 반환과 close
Conneciton, PreparedStatement, ResultSet 등의 리소스는 Pool 방식으로 운영된다. 제한된 수의 미리 생성된 리소스를 필요시 할당하고 사용 종료시 반환하여 Pool 에 넣어야한다.
리소스 사용이 끝나는 즉시 반환하지 않으면 리소스 고갈 문제가 발생 할 수 있으므로 finally 구문을 통해 에러 발생 유무와 관계없이 무조건 반환하도록 처리한다.
✏️ 템플릿 메서드 패턴
상속을 통해 기능을 확장할 수 있도록 하는 디자인 패턴. 변하지 않는 부분을 슈퍼 클래스에 두고 확장, 변하는 부분을 서브 클래스에 둔다.
변하는 부분은 preparedStatement 를 만들어내는 즉, SQL 쿼리를 생성하는 부분이고 변하지 않는 부분은 공유 자원 사용에 의해 필요한 try-catch-finally 구문이다.
따라서 Connection 을 받아와 preparedStatement 를 생성하는 부분을 서브 클래스에게 위임하고 try-catch-finally 구문은 슈퍼 클래스에서 처리한다.
// UserDaoDeleteAll.java
public class UserDaoDeleteAll extends UserDao {
@Override
PreparedStatement makeStatement(Connection c) throws SQLException {
// SQL 쿼리(statement)를 생성
PreparedStatement ps = c.prepareStatement("delete from users");
return ps;
}
}
// UserDao.java
abstract PreparedStatement makeStatement(Connection c) throws SQLException;
...
public void deleteAll() throws SQLException {
Connection c = null;
PreparedStatement ps = null;
try {
// DB Connection 가져오기
c = this.dataSource.getConnection();
// SQL 쿼리(statement)를 생성
**ps = this.makeStatement(c);**
// statement 를 실행
ps.executeUpdate();
} catch (SQLException e) {
throw e;
} finally {
if (ps != null) {
try {
ps.close();
} catch (SQLException e) {
throw e;
}
}
if (c != null) {
try {
c.close();
} catch (SQLException e) {
throw e;
}
}
}
}
PreparedStatement 를 생성하는 부분을 서브 클래스에서 수행하고, 슈퍼 클래스에서는 try-catch-finally 구문내에서 해당 SQL 쿼리를 실행한다.
동일하게 OCP 원칙을 지키면서 템플릿 메서드 패턴보다 유연하고 확장성이 뛰어난 해결책을 제시한다.
전략 패턴
확장에 해당하는 변하는 부분을 별도의 클래스로 만들어 추상화된 인터페이스를 통해 위임하는 방식.
변하지 않는 요구사항, 기능 맥락이 Context 의 contextMethod 가 된다. contextMetod 는 자신의 기능 맥락 안에서 확장가능하고 변경가능한 부분을 인터페이스로 추출한 Strategy 에 의존하고 이를 구현하는 구체 클래스에서 실질적인 확장과 변경을 책임진다.
UserDao 의 deleteAll 의 실질적인 일련의 과정은 아래와 같다.
deleteAll 이 JDBC 를 통해 DB 상태를 수정한다는 사실은 변하지 않는다. 이는 add 나 deleteOne 등도 마찬가지이다. 그외 커넥션을 가져오고 PS 를 실행하고 공유 리소스를 반환하는 작업들은 공통이며 기존 기능 맥락이 변하지 않는 한, 변하지 않는 부분이다.
하지만 PS 를 생성하는 부분은 메서드의 역할 (CUD), 데이터 베이스 컬럼 변경, 비즈니스 로직 변경에 의해 변경되는 부분이다.(즉, PS 를 생성하는 부분과 앞서 말한 부분은 변경이나 확장의 사유가 다르다)
따라서, “외부 기능 호출을 통해 PreparedStatement 생성” 은 Strategy 인터페이스에서 노출할 기능이 된다.
// UserDao.java
public void deleteAll() throws SQLException {
// DB Connection 가져오기
Connection c = this.dataSource.getConnection();
// SQL 쿼리(statement)를 생성
StatementStrategry strategy = new DeleteAllStrategy(); // 전략 생성 후 사용
PreparedStatement ps = strategy.makePreparedStatement(c);
...
// StatementStrategy.interface
public interface StatementStrategry {
PreparedStatement makePreparedStatement(Connection c) throws SQLException;
}
// DeleteAllStrategy.java
public class DeleteAllStrategy implements StatementStrategry {
@Override
public PreparedStatement makePreparedStatement(Connection c) throws SQLException {
// SQL 쿼리(statement)를 생성
PreparedStatement ps = c.prepareStatement("delete from users");
return ps;
}
}
전략 패턴은 인터페이스를 구현한 다양한 전략들을 바꿔 쓸 수 있는 것이 장점
위의 경우 deleteAll 이 StatementStrategy 인터페이스 뿐만 아니라 구체적 구현 클래스를 알고 있기에 교체 불가능
⇒ 실질적으로 전략을 선택하고 생성하는 클라이언트와 변하지 않는 부분인 Context, context method 가 합쳐져 있어 발생하는 문제! 변하지 않는 부분도 같이 변해야하는 아이러니 발생
⇒ OCP 원칙 위배
전략 패턴에서 Context 가 사용할 전략을 선택하는 것은 Context 를 사용하는 Client 가 선택하는 것이 일반적이다.
Client 의 역할을 포함하는 전략 패턴
DI 를 적용해 Client 가 전략을 선택 및 생성한 객체를 Context 에게 전달하고, Context 는 변하지 않는 로직을 구현한 메서드 안에서 전략을 사용한다.
DI 를 통해 전략 패턴의 장점을 일반적으로 활용한 구조
// JdbcContext.java
public class JdbcContext {
private DataSource dataSource;
public void setDataSource(DataSource dataSource) {
this.dataSource = dataSource;
}
public void workWithStatementStrategy(StatementStrategy stmt) throws SQLException {
Connection c = null;
PreparedStatement ps = null;
try {
c = this.dataSource.getConnection();
ps = stmt.makePreparedStatement(c);
ps.executeUpdate();
} catch (SQLException e) {
throw e;
} finally {
if (ps != null) {
try {
ps.close();
} catch (SQLException e) {
throw e;
}
try {
c.close();
} catch (SQLException e) {
throw e;
}
}
}
}
}
컨택스트인 JdbcContext 는 JDBC 를 통해 DB 상태 수정을 하기 위해 수행되야하는 로직을 구현하고있다. 이는 기존 DAO 클래스의 책임이던 부분을 컨택스트 맥락으로 분리한 것이다.
“해결 : 전략 패턴 (1)” 의 전략을 사용하는 주체가 UserDao 가 아닌 Context 를 추가해 끼워넣는 것으로 이해하면 쉽다.
마이크로 DI
DI 는 다양한 형태로 적용 가능하다. 가장 중요한 핵심은 제 3자를 통해 두 오브젝트간 관계가 유연하게 설정되도록하는 부분이다. 따라서 제 3자는 별도의 클래스일 수도 있지만 클라이언트와 DI 관계에 있는 모든 구성요소가 하나의 클래스안에 담길 수도 있다. 이런 경우 DI 는 클래스내의 메서드 혹은 작은 코드 단위에서 발생 할 수 있다.// UserDao.java ... public void workWithStatementStrategy(StatementStrategy stmt) throws SQLException { Connection c = null; PreparedStatement ps = null; try { c = this.dataSource.getConnection(); ps = stmt.makePreparedStatement(c); ps.executeUpdate(); } catch (SQLException e) { throw e; } finally { if (ps != null) { try { ps.close(); } catch (SQLException e) { throw e; } try { c.close(); } catch (SQLException e) { throw e; } } } } public void deleteAll( ... StatementStrategy stmt = new DeleteAllStrategy(); this.workWithStatementStrategy(stmt);
기존의 DI 가 UserDao 를 클라이언트, DI 가 필요한 JdbcContext 와 DeleteALlStrategy 를 각각의 클래스로 분리한 것과 다르게 클라이언트인 UserDao 의 deleteAll 메서드가 클라이언트면서 전략 클래스를 생성하고 관계를 만드는 책임도 같이 수행한다.
이또한 DI 이다!
// UserDao.java
public class UserDao {
private JdbcContext jdbcContext;
public void setJdbcContext(JdbcContext jdbcContext) {
this.jdbcContext = jdbcContext;
}
public void add(User user) throws SQLException {
this.jdbcContext.workWithStatementStrategy(
new StatementStrategy() {
@Override
public PreparedStatement makePreparedStatement(Connection c) throws SQLException {
// SQL 쿼리(statement)를 생성
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());
return ps;
}
}
);
}
public void deleteAll() throws SQLException {
this.jdbcContext.workWithStatementStrategy(
new StatementStrategy() {
@Override
public PreparedStatement makePreparedStatement(Connection c) throws SQLException {
PreparedStatement ps = c.prepareStatement("delete from users");
return ps;
}
}
);
}
}
클라이언트인 deleteAll, add 메서드는 각자의 기능 맥락을 위해 필요한 PreparedStatement 를 만드는 전략을 생성하고, 생성한 전략을 컨택스트의 메서드 호출 인자로 넘겨주는 역할만을 하고있다.
코드가 아주 간결하고 이해하기 쉽다. 처음 코드로 부터의 변화를 생각하면 소름이 돋는다.
JDBC 를 사용하는 모든 DAO 는 jdbc 사용에 따른 컨택스트를 JdbcContext 를 재사용해 쉽게 자신의 비즈니스 로직에만 집중 할 수 있다. 또한 각자의 비즈니스 로직에 맞는 PreparedStatement 를 생성하는 작업은 makePreparedStatement 하나만 구현하면 되므로 UserDao 는 커넥션이 어떻게 만들어지는지, PreparedStatement 가 어떤 과정에 의해 실행되는지 전혀 알지 못한다.
중첩 클래스
다른 클래스 내부에 정의되는 클래스를 중첩 클래스라고 한다. 중첩 클래스는 독립 유무에 따라 스태틱과 내부 클래스로 구분되며 내부 클래스 중에서도 범위에 따라 3가지로 구분된다.
- 스태틱 클래스
- 내부 클래스
- 멤버 내부 클래스 - 오브젝트 레벨에 정의
- 로컬 클래스 - 메서드 레벨에 정의
public void add(User user) throws SQLException { class AddStatement implements StatementStrategy { public PreparedStatement makePreparedStatement(Connection c) throws SQLException { // SQL 쿼리(statement)를 생성 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()); return ps; } } StatementStrategy stmt = new AddStatement(); this.jdbcContext.workWithStatementStrategy(stmt); }
- 익명 내부 클래스 - 스코프는 선언 위치에 따라 다르나 이름을 갖지 않는다.
익명 내부 클래스는 이름을 갖지 않는 클래스이다. 클래스 선언 + 오브젝트 생성이 결합된 형태로 만들어지는데 상속할 클래스나 구현 인터페이스를 생성자 대신에 사용한다. 위에서는 구현 인터페이스가 StatementStrategy 이므로 해당 인터페이스를 생성자로 사용했다.public void add(User user) throws SQLException { this.jdbcContext.workWithStatementStrategy( new StatementStrategy() { @Override public PreparedStatement makePreparedStatement(Connection c) throws SQLException { // SQL 쿼리(statement)를 생성 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()); return ps; } } ); }
클래스를 재사용 할 필요가 없는 경우
구현한 인터페이스 타입으로만 사용할 경우
위의 2가지 조건이 적용될 경우 사용하기 좋다.
위의 코드에서 의존관계와 관련해 눈에띄게 달라진 점은 기존 UserDao 가 기존 DataSource 에 의존하던 것이 JdbcContext 에 대한 의존으로 변경된 점이다. 의존관계 주입대상이 변경되었으므로 애플리케이션 컨택스트 xml 파일또한 변경되야하는데 한가지 의문이 생긴다.
의존관계 주입 개념을 충실히 따르기 위해서는 아래의 내용이 지켜져야한다.
위의 코드는 인터페이스가 없으므로 런타임이 아닌 설계시점에 의존 관계가 고정되고, 런타임 의존관계를 변경 할 수 없다. 그러나, 아래의 이유로 인터페이스를 사용하지 않는 의존성 주입을 고려해 볼 수 있다.
이런 경우 인터페이스가 아닌 구현체를 주입해도 괜찮다. 적절한 이유가 있기 때문이다. 물론 인터페이스를 두는 것도 나쁜 선택지가 아니다.
항상 이부분이 궁금했는데 좋은 답변을 얻은 것 같다. DI 에 대한 원칙을 엄격히 고수하기 보다 DI 를 사용하는 이유에 대해 보다 정확히 이해하고 사용하는 것이 중요하다는 결론을 내렸다.
템플릿
도형자, 도안, 모양자와 같이 어떤 목적을 위해 미리 만들어둔 모양이 있는 틀을 말한다.
고정된 틀(변하지 않는 부분)에 변할 수 있는 부분을 넣어 사용한다.
템플릿 메서드는 템플릿을 슈퍼 클래스에 정의된 일련의 기능 맥락으로 하고 변하는 부분, 확장 가능한 부분을 서브 클래스에서 재정의한 기능으로 사용한다.
콜백
실행되는 것을 목적으로 다른 오브젝트의 메서드에 전달되는 오브젝트를 말한다.
일반적으로 파라미터로 전달되는 오브젝트는 값을 참조하기 위해 사용하나 콜백의 경우 특정 로직을 담은 메서드 실행을 위해 전달한다. 자바에서 메서드 자체를 파라미터로 전달할 방법이 없어 메서드가 담긴 오브젝트를 전달한다. 따라서 Functional Object 라고도 한다.add(30, 10, (sum : number) => console.log(sum))
위는 타입스크립트의 콜백이다. 메서드 자체를 파라미터로 넘기고있다.
add(30, 10, new Interface() { void printResult(Integer sum){ System.out.println(sum.toString()); } }
반면, 자바의 경우 익명 클래스(내부 클래스)를 사용해 처리한다.
// UserDao.java
public void add(User user)
// JdbcContext.java
public void workWithStatementStrategy(StatementStrategy stmt)
// UserDao.java
new StatementStrategy() {
@Override
public PreparedStatement makePreparedStatement(Connection c)
...
public void add(User user) throws SQLException {
this.jdbcContext.executeSql("insert into users(id, name, password) values(?,?,?)",
user.getId(), user.getName(), user.getPassword());
}
public void deleteAll() throws SQLException {
this.jdbcContext.executeSql("delete from users");
}
이전에 비해 UserDao 의 메서드를 구성하는 코드의 양이 줄었다. StatementStrategy 를 생성하고 전략을 구성하는 코드가 JdbcContext 의 executeSql 메서드로 통합되었기 때문이다.
public void executeSql(final String query, String... args) throws SQLException {
this.workWithStatementStrategy(
new StatementStrategy() {
@Override
public PreparedStatement makePreparedStatement(Connection c) throws SQLException {
PreparedStatement ps = c.prepareStatement(query);
for (int i = 0; i < args.length; i++) {
ps.setString(i + 1, args[i]);
}
return ps;
}
}
);
}
JdbcTemplate
스프링은 JDBC 를 사용하는 DAO 에서 사용가능한 다양한 템플릿과 콜백을 제공한다.
거의 모든 JDBC 코드에서 사용가능한 템플릿, 콜백을 제공하고 반복 사용 패턴을 가지는 콜백은 다시 템플릿에 결합시켜 사용 할 수 있도록 처리되어 있다.
템플릿/콜백 패턴을 이해하고 있다면 매우 쉽게 사용할 수 있다. 아래는 JdbcContext 를 JdbcTemplate 으로 대체한 코드이다.
package springbook.user.dao;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.util.List;
import javax.sql.DataSource;
import org.springframework.dao.EmptyResultDataAccessException;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.core.RowMapper;
import springbook.user.domain.User;
public class UserDao {
private JdbcTemplate jdbcTemplate;
private RowMapper<User> userMapper = new RowMapper<User>() {
@Override
public User mapRow(ResultSet rs, int rowNum) throws SQLException {
User user = new User();
user.setId(rs.getString("id"));
user.setName(rs.getString("name"));
user.setPassword(rs.getString("password"));
return user;
}
};
public void setDataSource(DataSource dataSource) {
this.jdbcTemplate = new JdbcTemplate(dataSource);
}
public void add(User user) throws SQLException {
// (방법 1) 익명 클래스를 생성해 주입
// this.jdbcTemplate.update(
// new PreparedStatementCreator() {
// @Override
// public PreparedStatement createPreparedStatement(Connection con)
// throws SQLException {
// PreparedStatement ps = con.prepareStatement(
// "insert into users(id, name, password) values(?,?,?)");
// ps.setString(1, user.getId());
// ps.setString(2, user.getName());
// ps.setString(3, user.getPassword());
//
// return ps;
// }
// }
// );
// (방법 2) executeSql 과 같이 쿼리와 가변인자만으로 호출
this.jdbcTemplate.update("insert into users(id, name, password) values(?,?,?)",
user.getId(), user.getName(), user.getPassword());
}
public void deleteAll() throws SQLException {
// (방법 1) 익명 클래스를 생성하고 템플릿 호출시 전달
// this.jdbcTemplate.update(
// new PreparedStatementCreator() {
// @Override
// public PreparedStatement createPreparedStatement(Connection con)
// throws SQLException {
// return con.prepareStatement("delete from users");
// }
// }
// );
this.jdbcTemplate.update("delete from users");
}
public int getCount() throws SQLException {
// return this.jdbcTemplate.query(
// new PreparedStatementCreator() {
// @Override
// public PreparedStatement createPreparedStatement(Connection con)
// throws SQLException {
// return con.prepareStatement("select count(*) from users");
// }
// },
// new ResultSetExtractor<Integer>() {
// @Override
// public Integer extractData(ResultSet rs) throws SQLException, DataAccessException {
// rs.next();
// return rs.getInt(1);
// }
// }
// );
return jdbcTemplate.queryForInt("select count(*) from users");
}
public User getOne(String id) throws EmptyResultDataAccessException {
// return (User) this.jdbcTemplate.query(
// new PreparedStatementCreator() {
// @Override
// public PreparedStatement createPreparedStatement(Connection con)
// throws SQLException {
// PreparedStatement ps = con.prepareStatement("select * from users where id = ?");
// ps.setString(1, id);
//
// return ps;
// }
// },
// new RowMapper<User>() {
// @Override
// public User mapRow(ResultSet rs, int rowNum) throws SQLException {
// User user = new User();
// user.setId(rs.getString("id"));
// user.setName(rs.getString("name"));
// user.setPassword(rs.getString("password"));
//
// return user;
// }
// }
// ).get(0);
return this.jdbcTemplate.queryForObject(
"select * from users where id = ?",
new Object[]{id},
this.userMapper
);
}
public List<User> getAll() {
return this.jdbcTemplate.query("select * from users order by id asc",
this.userMapper
);
}
}
userMapper 인스턴스 변수에 RowMapper 인터페이스를 통해 생성한 익명 클래스로 초기화함으로서 getOne, getAll 메서드에서 반복되는 콜백 코드를 제거했다.
해당 글은 토비 스프링 3.1 의 챕터 3를 공부하며 기록한 내용을 담고 있다.
핵심은 변경 주기에 따라 변하지 않는 부분은 컨텍스트로, 변하는 부분은 전략으로 구현하여 OCP 원칙에 따른 애플리케이션 설계를 하는 것이다. 또한 변하는 부분도 변경 사유에 따라 구분하는 것이 더욱 효과적인 설계를 할 수 있도록 한다는 점도..
이 책이 정말 좋은 점은 전략 패턴을 적용하기 앞서 예시 코드에서 어떤 부분이 반복되고, 재활용 할 수 있으며 개선될 여지가 있는지 친절하게 설명해준다는 점이다. 개선될 여지와 개선 후 효과에 대해 이해하고나니 디자인 패턴의 필요성과 효용에 대해 크게 동감하게되어 쉽게 이해 할 수 있게 된다.
사실 오늘 공부한 내용을 실무에서 적용할려하면 가장 큰 허들은 변하는 부분과 변하지 않는 부분을 분석하는 파트인 것 같다. 지금까지는 대부분 데이터베이스 테이블을 중심으로 쌓아나가며 개발을 했는데, 비즈니스 로직을 중심으로 개발과 설계를 하는 습관을 들여야 분석하기가 쉬워질 것 같다.