토비의 스프링 3장 (템플릿)

템플릿

개방 폐쇄 원칙 (OCP)

어떤 부분은 변경을 통해 그 기능이 다양해지고 확장하려는 성질이 있고, 어떤 부분은 고정되어 있고 변하지 않으려는 성질이 있다. 변화의 특성이 다른 부분을 구분해주고, 각각 다른 목적과 다른 이유에 의해 다른 시점에 독립적으로 변경될 수 있는 효율적인 구조를 만들어주는 것이 개방 폐쇄 원칙이다.

템플릿이란 성질이 다른 코드 중에서 변경이 거의 일어나지 않으며 일정한 패턴으로 유지되는 특성을 가진 부분을 자유롭게 변경되는 성질을 가진 부분으로부터 독립시켜서 효과적으로 활용할 수 있도록 하는 방법이다.

일반적으로 서버에서는 제한된 개수의 DB 커넥션을 만들어서 재사용 가능한 풀로 관리한다. DB 풀은 매번 getConnection()으로 가져간 커넥션을 명시적으로 close()해서 돌려줘야지만 다시 풀에 넣었다가 다음 커넥션 요청이 있을 때 재사용할 수 있다. 그런데 이런 식으로 오류가 날 때마다 미처 반횐되지 못한 Connection이 계속 쌓이면 어느 순간에 커넥션 풀에 여유가 없어지고 리소스가 모자란다는 심각한 오류를 내며 서버가 중단될 수 있다. 그래서 JDBC 코드에서는 어떤 상황에서도 가져온 리소스를 반환하도록 try/catch/finally 구문 사용을 권장하고 있다. (finally는 try 블록을 수행한 후에 예외가 발생하든 정상적으로 처리되든 상관없이 반드시 실행되는 코드를 넣을 때 사용한다.)

어느 시점에서 예외가 발생했는지에 따라서 close()를 사용할 수 있는 변수가 달라질 수 있기 때문에 finally에서는 반드시 c(Connection)와 ps(PreparedStatment)가 null이 아닌지 먼저 확인한 후에 close() 메소드를 호출해야 한다.

변하는 것과 변하지 않는 것

이런 코드를 효과적으로 다룰 수 있는 방법은 없을까? 이 문제의 핵심은 변하지 않는, 그러나 많은 곳에서 중복되는 코드와 로직에 따라 자꾸 확장되고 자주 변하는 코드를 잘 분리해내는 작업이다.

분리와 재사용을 위한 디자인 패턴 적용

메소드 추출

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
public void deleteAll() throws SQLException {
...
try {
c = dataSource.getConnection();

ps = makeStatement(c); // 변하는 부분을 메소드로 추출하고 변하지 않는 부분에서 호출하도록 만들었다.

ps.executeUpdate();
} catch(SQLException e)
...
}

private PreparedStatement makeStatement(Connection c) throws SQLException {
PreparedStatement ps;
ps = c.prepareStatement("delete from users");
return ps;
}

자주 바뀌는 부분을 메소드로 독립시켰는데 별 이득이 없어 보인다. 왜냐하면 보통 메소드 추출 리펙토링을 적용하는 경우에는 분리시킨 메소드를 다른 곳에서 재사용할 수 있어야 하는데, 이건 반대로 분리시키고 남은 메소드가 재사용이 필요한 부분이고, 분리된 메소드는 DAO 로직마다 새롭게 만들어서 확장돼야 하는 부분이기 때문이다. 뭔가 반대로 됐다.

템플릿 메소드 패턴의 적용

템플릿 메소드 패턴은 상속을 통해 기능을 확장해서 사용하는 부분이다. 변하지 않는 부분은 슈퍼클래스에 두고 변하는 부분은 추상 메소드로 정의해둬서 서브클래스에서 오버라이드하여 새롭게 정의해 쓰도록 하는 것이다.

1
abstract protected PreparedStatement makeStatement(Connection c) throws SQLException;
1
2
3
4
5
6
7
public class UserDaoDeleteAll extends UserDao {

protected PreparedStatement makeStatement(Connection C) throws SQLException {
PreparedStatment ps = c.prepareStatement("delete from users");
return ps;
}
}

이제 UserDao 클래스의 기능을 확장하고 싶을 때마다 상속을 통해 자유롭게 확장할 수 있고, 확장 때문에 기존의 상위 DAO 클래스에 불필요한 변화는 생기지 않도록 할 수 있으니 객체지향 설계의 핵심 원리인 개방 폐쇄 원칙(OCP)을 그럭저럭 지키는 구조를 만들어낼 수는 있는것 같다. 그렇지만 아직 문제가 있다. 가장 큰 문제는 DAO 로직마다 상속을 통해 새로운 클래스를 만들어야 한다는 점이다. 이래서는 장점보다 단점이 더 많아 보인다.

변하지 않는 코드를 가진 UserDao의 JDBC try/catch/finally 블록과 변하는 PreparedStatement를 담고 있는 서브클래스들이 이미 클래스 레벨에서 컴파일 시점에 이미 그 관계가 결정되어 있다. 따라서 그 관계에 대한 유연성이 떨어진다.

전략 패턴의 적용

개방 폐쇠 원칙(OCP)을 잘 지키는 구조이면서도 템플릿 메소드 패턴보다 유연하고 확장성이 뛰어난 것이, 오브젝트를 아예 둘로 분리하고 클래스 레벨에서는 인터페이스를 통해서만 의존하도록 만드는 전략 패턴이다. 전략 패턴은 OCP 관점에서 보면 확장에 해당하는 변하는 부분을 별도의 클래스로 만들어 추상화된 인터페이스를 통해 위임하는 방식이다.

deleteAll()은 JDBC를 이용해 DB를 업데이트하는 작업이라는 변하지 않는 맥락(context)을 갖는다. deleteAll()의 컨텍스트를 정리해보면 다음과 같다.

  • DB 커넥션 가져오기
  • PreparedStatement를 만들어줄 외부 기능 호출하기
  • 전달받은 PreparedStatement 실행하기
  • 예외가 발생하면 이를 다시 메소드 밖으로 던지기
  • 모든 경우에 만들어진 PreparedStatement와 Connection을 적절히 닫아주기

두번째 작업에서 사용하는 PreparedStatement를 만들어주는 외부 기능이 바로 전략 패턴에서 말하는 전략이라고 볼 수 있다. 전략 패턴의 구조를 따라 이 기능을 인터페이스로 만들어두고 인터페이스 이 메소드를 통해 PreparedStatement 생성 전략을 호출해주면 된다. 여기서 눈여겨볼 것은 이 PreparedStatement를 생성하는 전략을 호출할 때는 이 컨텍스트 내에서 만들어둔 DB 커넥션을 전달해야 한다는 점이다.

PreparedStatement를 만드는 전략의 인터페이스는 컨텍스트가 만들어준 Connection을 전달받아서, PreparedStatement를 만들고 만들어진 PreparedStatement 오브젝트를 돌려준다. 이 내용을 인터페이스로 정의하면 다음과 같다.

1
2
3
public interface StatementStrategy {
PreparedStatement makePreparedStatement(Connection c) throws SQLException;
}

StatementStrategy 인터페이스를 상속해서 실제 전략 클래스를 만들고 이 전략 클래스를 이용한 전략 패턴을 적용한 코드는 다음과 같다.

1
2
3
4
5
6
7
8
9
10
11
12
13
public void deleteAll() throws SQLException {
...
try {
c = dataSource.getConnection();

StatementStrategy strategy = new DeleteAllStatement();
ps = strategy.makePreparedStatement(c);

ps.executeUpdate();
} catch (SQLException e) {
...
}
}

전략 패턴은 필요에 따라 컨텍스트는 그대로 유지되면서(OCP의 폐쇄 원칙) 전략을 바꿔 쓸 수 있다(OCP의 개방 원칙)는 것인데, 이렇게 컨텍스트 안에서 이미 구체적인 전략 클래스인 DeleteAllStatement를 사용하도록 고정되어 있다면 뭔가 이상하다. 컨텍스트가 StatementStrategy 인터페이스 뿐 아니라 특정 구현 클래스인 DeleteAllStatement를 직접 알고 있다는건, 전략 패턴에도 OCP에도 잘 들어맞는다고 볼 수 없기 때문이다.

DI 적용을 위한 클라이언트/컨텍스트 분리

전략 패턴에 따르면 Context가 어떤 전략을 사용하게 할 것인가는 Context를 사용하는 앞단의 Client가 결정하는게 일반적이다. Client가 구체적인 전략의 하나를 선택하고 오브젝트로 만들어서 Context에 전달하는 것이다.

결국 이 구조에서 전략 오브젝트 생성과 컨텍스트로의 전달을 담당하는 책임을 분리시킨 것이 바로 ObjectFactory이며, 이를 일반화한 것이 앞에서 살펴봤던 의존관계 주입(DI)이었다. 결국 DI란 이러한 전략 패턴의 장점을 일반적으로 활용할 수 있도록 만든 구조라고 볼 수 있다.

아무튼 여기서 이 패턴 구조를 코드에 적용해보자. 중요한 것은 컨텍스트에 해당하는 JDBC try/catch/finally 코드를 클라이언트 코드인 StatementStrategy를 만드는 부분에서 독립시켜야 한다는 것이다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
public void jdbcContextWithStatementStrategy(StatementStrategy stmt) throws SQLException {
connection c = null;
PreparedStatement ps = null;

try {
c = dataSource.getConnection();

ps = stmt.makePreparedStatement(c);

ps.executeUpdate();
} catch (SQLException e) {
throw e;
} finally {
if (ps != null) { try { ps.close(); } catch (SQLException e) {} }
if (c != null) { try { c.close(); } catch (SQLException e) {} }
}
}

이 메소드는 컨텍스트의 핵심적인 내용을 잘 담고 있다. 클라이언트로부터 StatementStrategy 타입의 전략 오브젝트를 제공받고 JDBC try/catch/finally 구조로 만들어진 컨텍스트 내에서 작업을 수행한다.

다음은 클라이언트에 해당하는 부분이다. 컨텍스트를 별도의 메소드로 분리했으니 deleteAll() 메소드가 클라이언트가 된다. deleteAll()은 전략 오브젝트를 만들고 컨텍스트를 호출하는 책임을 지고 있다.

1
2
3
4
public void deleteAll() throws SQLException {
StatementStrategy st = new DeleteAllStatement(); // 선정한 전략 클래스의 오브젝트 생성
jdbcContextWithStatementStrategy(st); // 컨텍스트 호출. 전략 오브젝트 전달
}

클라이언트가 컨텍스트가 사용할 전략을 정해서 전달하는 면에서 DI 구조라고 이해할 수도 있다.

마이크로 DI

의존관계 주입(DI)은 다양한 형태로 적용할 수 있다. DI의 가장 중요한 개념은 제3자의 도움을 통해 두 오브젝트 사이의 유연한 관계가 설정되도록 만든다는 것이다. 이 개념만 따른다면 DI를 이루는 오브젝트와 구성요소의 구조나 관계는 다양하게 만들 수 있다.

일반적으로 DI는 의존관계에 있는 두 개의 오브젝트와 이 관계를 다이내믹하게 설정해주는 오브젝트 팩토리(DI 컨테이너), 그리고 이를 사용하는 클라이언트라는 4개의 오브직트 사이에서 일어난다. 하지만 때로는 원시적인 전략패턴 구조를 따라 클라이언트가 오브젝트 팩토리의 책임을 함께 지고 있을 수도 있다.

이런 경우에는 DI가 매우 작은 단위의 코드와 메소드 사이에서 일어나기도 한다. 이렇게 DI의 장점을 단순화해서 IoC 컨테이너의 도움 없이 코드 내에서 적용한 경우를 마이크로 DI라고도 한다. 또는 코드에 의한 의미로 수동 DI라고 부를 수도 있다.

JDBC 전략 패턴의 최적화

전략과 클라이언트의 동거

현재 구조에 두 가지 불만이 있다.

  • DAO 메소드마다 새로운 StatementStrategy 구현 클래스를 만들어야 한다는 것.
  • DAO 메소드에서 StatementStrategy에 전달할 User와 같은 부가적인 정보가 있는 경우, 이를 위해 오브젝트를 전달받는 생성자와 이를 저장해둘 인스턴스 변수를 번거롭게 만들어야 한다는 것.

이 두가지 문제를 해결할 수 있는 방법을 생각해보자.

로컬 클래스

클래스 파일이 많아지는 문제는 간단한 해결 방법이 있다. StatementStrategy 전략 클래스를 매번 독립된 파일로 만들지 말고 UserDao 클래스 안에 내부 클래스로 정의해버리는 것이다. DeleteAllStatement나 AddStatement는 UserDao 밖에서는 사용되지 않는다. 둘 다 UserDao에서만 사용되고, UserDao의 메소드 로직에 강하게 결합되어 있다.

중첩 클래스의 종류

다른 클래스 내부에 정의되는 클래스를 중첩 클래스(nested class)라고 한다. 중첩 클래스는 독립적으로 오브젝트로 만들어질 수 있는 스태틱 클래스(static class)와 자신이 정의된 클래스의 오브젝트 안에서만 만들어질 수 있는 내부 클래스(inner class)로 구분된다.

내부 클래스는 다시 범위(scope)에 따라 세가지로 구분된다.

  • 멤버 내부 클래스 : 멤버 필드처럼 오브젝트 레벨에 정의된다.
  • 로컬 클래스 : 메소드 레벨에 정의된다.
  • 익명 내부 클래스 : 이름을 갖지 않는 익명 클래스이다. 익명 내부 클래스의 범위는 선언된 위치에 따라서 다르다.

로컬 클래스의 장점은 클래스가 내부 클래스이기 때문에 자신이 선언된 곳의 정보에 접근할 수 있다는 것이다. 내부 메소드는 자신이 정의된 메소드의 로컬 변수에 직접 접근할 수 있기 때문이다. 다만, 내부 클래스에서 외부의 변수를 사용할 때는 외부 변수는 반드시 final로 선언해줘야 한다.

로컬 클래스로 만들어두니, 메소드마다 추가해야 했던 클래스 파일을 하나 줄일 수 있고 내부 클래스의 특징을 이용해 로컬 변수를 바로 가져다 사용할 수 있다는 장점도 생겼다.

익명 내부 클래스

익명 내부 클래스

익명 내부 클래스(anonymous inner class)는 이름을 갖지 않는 클래스다. 클래스 선언과 오브젝트 생성이 결합된 상태로 만들어지며, 상속할 클래스나 구현할 인터페이스를 생성자 대신 사용해서 다음과 같은 형태로 만들어 사용한다. 클래스를 재사용할 필요가 없고, 구현한 인터페이스 타입으로만 사용할 경우에 유용하다.

new 인터페이스이름() { 클래스 본문 };

익명 내부 클래스는 선언과 동시에 오브젝트를 생성한다. 이름이 없기 때문에 클래스 자신의 타입을 가질 수 없고, 구현한 인터페이스 타입의 변수에만 저장할 수 있다.

1
2
3
4
5
6
7
8
9
10
StatementStrategy st = new StatementStrategy() {
public PreparedStatement makePreparedStatement(Connection c) throws SQLException {
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;
}
}

만들어진 익명 내부 클래스의 오브젝트는 딱 한 번만 사용할 테니 굳이 변수에 담아두지 말고 jdbcContextWithStatementStrategy() 메소드의 파라미터에서 바로 생성하는 편이 낫다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
public void add(final User user) throws SQLException {
jdbcContextWithStatementStrategy(
new StatementStrategy() {
public PreparedStatement makePreparedStatement(Connection c) throws SQLException {
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;
}
}
);
}

컨텍스트와 DI

전략 패턴의 구조로 보자면 UserDao의 메소드가 클라이언트이고, 익명 내부 클래스로 만들어지는 것이 개별적인 전략이고, jdbcContextWithStatementStrategy() 메소드는 컨텍스트다. 그런데 JDBC의 일반적인 작업 흐름을 담고 있는 jdbcContextWithStatementStrategy()는 다른 DAO에서도 사용 가능하다. 그러니 jdbcContextWithStatementStrategy()를 UserDao 클래스 밖으로 독립시켜서 모든 DAO가 사용할 수 있게 해야한다.

클래스 분리

분리해서 만들 클래스의 이름을 JdbcContext라고 하자. JdbcContext에 UserDao에 있던 컨텍스트 메소드를 workWithStatementStrategy()라는 이름으로 옮겨놓는다. 그런데, 이렇게 하면 DataSource가 필요한 것은 UserDao가 아니라 JdbcContext가 돼버린다. DB 커넥션을 필요로 하는 코드는 JdbcContext 안에 있기 때문이다. 따라서 JdbcContext가 DataSource에 의존하고 있으므로 DataSource 타입 빈을 DI 받을 수 있게 해줘야 한다.

빈 의존관계 변경

UserDao는 이제 JdbcContext에 의존한다. 그런데 JdbcContext는 인터페이스인 DataSource와는 달리 구체 클래스다. 스프링의 DI는 기본적으로 인터페이스를 사이에 두고 의존 클래스를 바꿔서 사용하도록 하는게 목적이다. 하지만 이 경우 JdbcContext는 그 자체로 독립적인 JDBC 컨텍스트를 제공해주는 서비스 오브젝트로서 의미가 있을 뿐이고 구현 방법이 바뀔 가능성은 없다. 따라서 인터페이스를 구현하지 않고, UserDao와 JdbcContext는 인터페이스를 사이에 두지 않고 DI를 적용하는 특별한 구조가 된다.

스프링 빈으로 DI

인터페이스를 사용해서 클래스를 자유롭게 변경할 수 있게 하지는 않았지만, JdbcContext를 UserDao와 DI 구조로 만들어야 할 이유는 다음과 같다.

  1. JdbcContext가 스프링 컨테이너의 싱글톤 레지스트리에서 관리되는 싱글톤 빈이 되기 때문이다.
  2. JdbcContext가 DI를 통해 다른 빈에 의존하고 있기 때문이다. JdbcContext는 dataSource 프로퍼티를 통해 JdbcContext 오브젝트를 주입받도록 되어 있다. DI를 위해서는 주입되는 오브젝트와 주입받는 오브젝트 양쪽 모두 스프링 빈으로 등록돼야 한다. 스프링이 생성하고 관리하는 IoC 대상이어야 DI에 참여할 수 있기 때문이다.

템플릿과 콜백

전략 패턴은 복잡하지만 바뀌지 않는 일정한 패턴을 갖는 작업 흐름이 존재하고 그중 일부분만 자주 바꿔서 사용해야 하는 경우에 적합한 구조다. 전략 패턴의 기본 구조에 익명 내부 클래스를 활용한 방식이다. 이런 방식을 스프링에서는 템플릿/콜백 패턴이라고 부른다. 전략 패턴의 컨텍스트를 템플릿이라 부르고, 익명 내부 클래스로 만들어지는 오브젝트를 콜백이라고 부른다.

템플릿

템플릿은 어떤 목적을 위해 미리 만들어둔 모양이 있는 틀을 가리킨다. 템플릿 메소드 패턴은 고정된 틀의 로직을 가진 템플릿 메소드를 슈퍼클래스에 두고, 바뀌는 부분을 서브클래스의 메소드에 두는 구조로 이뤄진다.

콜백

콜백은 실행되는 것을 목적으로 다른 오브젝트의 메소드에 전달되는 오브젝트를 말한다. 자바에서는 메소드 자체를 파라미터로 전달할 방법이 없기 때문에 메소드가 담긴 오브젝트를 전달해야 한다. 그래서 펑서녈 오브젝트(functional object)라고도 한다.

템플릿/콜백의 동작원리

템플릿은 고정된 작업 흐름을 가진 코드를 재사용한다는 의미에서 붙인 이름이다. 콜백은 템플릿 안에서 호출되는 것을 목적으로 만들어진 오브젝트를 말한다.

템플릿/콜백의 특징

여러 개의 메소드를 가진 일반적인 인터페이스를 사용할 수 있는 전략 패턴의 전략과 달리 템플릿/콜백 패턴의 콜백은 보통 단일 메소드 인터페이스를 사용한다. 템플릿의 작업 흐름 중 특정 기능을 위해 한 번 호출되는 경우가 일반적이기 때문이다. 콜백은 일반적으로 하나의 메소드를 가진 인터페이스를 구현한 익명 내부 클래스로 만들어진다고 보면된다.

템플릿/콜백 패턴의 일반적인 작업 흐름은 다음과 같다.

  • 클라이언트의 역할은 템플릿 안에서 실행될 로직을 담은 콜백 오브젝트를 만들고, 콜백이 참조할 정보를 제공하는 것이다. 만들어진 콜백은 클라이언트가 템플릿의 메소드를 호출할 때 파라미터로 전달된다.
  • 템플릿은 정해진 작업 흐름을 따라 작업을 진행하다가 내부에서 생성한 참조정보를 가지고 콜백 오브젝트의 메소드를 호출한다. 콜백은 클라이언트 메소드에 있는 정보와 템플릿이 제공한 참조정보를 이용해서 작업을 수행하고 그 결과를 다시 템플릿에 돌려준다.
  • 템플릿은 콜백이 돌려준 정보를 사용해서 작업을 마저 수행한다. 경우에 따라 최종 결과를 클라이언트에 다시 돌려주기도 한다.

템플릿/콜백 방식은 전략 패턴과 DI의 장점을 익명 내부 클래스 사용 전략과 결합한 독특한 활용법이라고 이해할 수 있다. 단순히 전략 패턴으로만 보기엔 독특한 특징이 많으므로 템플릿/콜백을 하나의 고유한 패턴으로 기억해두면 좋다.

편리한 콜백의 재활용

템플릿/콜백 방식에서 한 가지 아쉬운 점이 있다. DAO 메소드에서 매번 익명 내부 클래스를 사용하기 때문에 상대적으로 코드를 작성하고 읽기가 조금 불편하다는 점이다.

1
2
3
4
5
6
7
8
9
10
11
12
13
public void deleteAll() throws SQLException {
executeSql("delete from users"); // 변하는 SQL 문장
}
------------------------------------------------------------
private void executeSql(final String query) throws SQLException {
this.jdbcContext.workWithStatementStrategy(
new StatementStrategy() {
public PreparedStatement makePreparedStatement(Connection c) throws SQLException {
return c.prepareStatement(query);
}
}
)
}

바뀌지 않는 모든 부분을 빼내서 executeSql() 메소드로 만들었다. 바뀌는 부분인 SQL 문장만 파라미터로 받아서 사용하게 만들었다. SQL을 담은 파라미터를 final로 선언해서 익명 내부 클래스인 콜백 안에서 직접 사용할 수 있게 하는 것만 주의하면 된다.

이렇게 재사용 가능한 콜백을 담고 있는 메소드라면 DAO가 공유할 수 있는 템플릿 클래스 안으로 옮겨도 된다. 그 결과 결국 JdbcContext 안에 클라이언트와 템플릿, 콜백이 모두 함께 공존하면서 동작하는 구조가 됐다.

템플릿/콜백의 응용

고정된 작업 흐름을 갖고 있으면서 여기저기서 자주 반복되는 코드가 있다면, 중복되는 코드를 분리할 방법을 생각해보는 습관을 기르자. 중복된 코드는 먼저 메소드로 분리하는 간단한 시도를 해본다. 그중 일부 작업을 필요에 따라 바꾸어 사용해야 한다면 인터페이스를 사이에 두고 분리해서 전략패턴을 적용하고 DI로 의존관계를 관리하도록 만든다. 그런데 바뀌는 부분이 한 애플리케이션 안에서 동시에 여러 종류가 만들어질 수 있다면 이번엔 템플릿/콜백 패턴을 적용하는 것을 고려해볼 수 있다.

가장 전형적인 템플릿/콜백 패턴의 후보는 try/catch/finally 블록을 사용하는 코드다.

템플릿/콜백을 적용할 때는 템플릿과 콜백의 경계를 정하고 템플릿이 콜백에게, 콜백이 템플릿에게 각각 전달하는 내용이 무엇인지 파악하는게 가장 중요하다. 그에 따라 콜백의 인터페이스를 정의해야 하기 때문이다.

클래스 이름이 Template으로 끝나거나 인터페이스 이름이 Callback으로 끝난다면 템플릿/콜백이 적용된 것이라고 보면 된다.

정리

  • JDBC와 같은 예외가 발생할 가능성이 있으며 공유 리소스의 반환이 필요한 코드는 반드시 try/catch/finally 블록으로 관리해야 한다.
  • 일정한 작업 흐름이 반복되면서 그중 일부 기능만 바뀌는 코드가 존재한다면 전략 패턴을 적용한다. 바뀌지 않는 부분을 컨텍스트로, 바뀌는 부분은 전략으로 만들고 인터페이스를 통해 유연하게 전략을 변경할 수 있도록 구성한다.
  • 클라이언트 메소드 안에 익명 내부 클래스를 사용해서 전략 오브젝트를 구현하면 코드도 간결해지고 메소드의 정보를 직접 사용할 수 있어서 편리하다.
  • 컨텍스트가 하나 이상의 클라이언트 오브젝트에서 사용된다면 클래스를 분리해서 공유하도록 만든다.
  • 단일 전략 메소드를 갖는 전략 패턴이면서 익명 내부 클래스를 사용해서 매번 전략을 새로 만들어 사용하고, 컨텍스트 호출과 동시에 전략 DI를 수행하는 방식을 템플릿/콜백 패턴이라고 한다.
  • 콜백의 코드에도 일정한 패천이 반복된다면 콜백을 템플릿에 넣고 재활용하는 것이 편리하다.
  • 템플릿과 콜백의 타입이 다양하게 바뀔 수 있다면 제네릭스를 이용한다.
  • 템플릿은 한 번에 하나 이상의 콜백을 사용할 수도 있고, 하나의 콜백을 여러 번 호출할 수도 있다.
  • 템플릿/콜백을 설계할 때는 템플릿과 콜백 사이에 주고받는 정보에 관심을 둬야한다.

참고

토비의 스프링 3.1 Vol. 1 스프링의 이해와 원리

PR(Pull Request) merge

PR(Pull Request) merge

보통 upstream의 repository를 fork한 후, origin에서 feature branch를 만들어 작업한 후 작업이 완료되면 upstream branch에 반영하기 위해 pull request를 요청해 merge 합니다. (upstream repository에 push 권한이 있는 사람이 merge를 완료할 수 있습니다.)

merge conflict가 없다면 pull request를 GitHub에서 merge 할 수 있습니다. 만약, pull request를 merge 할 때, conflict가 있다거나 merge를 하기전에 테스트를 하고 싶다면 command line을 이용해 local에서 pull request를 check out하고 merge할 수 있습니다.

만약, upstream branch에 merge를 원하지 않는다면 pull request를 merge 하지 않고 종료(close)할 수도 있습니다.

pull request를 merge 할 때는 (1)Feature branch의 모든 커밋을 유지하거나 (2)모든 커밋을 단일 커밋으로 squash하거나 (3)각각의 커밋을 rebase해서 pull request를 merge 할 수 있습니다. 3가지 방법에 대해서 알아보겠습니다.

Merging a pull request on GitHub

GitHub repository에서 사용할 수 있는 merge option은 다음과 같습니다.

  • Create a merge commit
  • Squash and merge
  • Rebase and merge

Create a merge commit

All commits from this branch will be added to the base branch via a merge commit.

default 옵션인 Create a merge commit 은 feature branch의 모든 커밋을 base branch에 merge 커밋으로 추가합니다. 해당 pull request는 —no-ff 옵션을 사용해 merge됩니다.

—no-ff

fast-forward(ff)가 가능하지만 fast-forward를 하지 않고, merge 커밋을 생성합니다.

Squash and merge

The 4 commits from this branch will be combined into one commit in the base branch.

Squash and merge 옵션을 선택하면, pull request의 커밋들이 하나의 커밋으로 squash(압축)됩니다. 즉, topic branch에서 contributor가 작업한 모든 커밋은 하나의 커밋으로 결합되어 base branch로 merge됩니다. 해당 pull request는 –ff 옵션을 사용해 merge합니다.

—ff

fast-forward(ff)로 merge가 된다면, 새로운 merge 커밋을 만들지 않습니다.

Squash and merge는 repository에서 git history를 조금 더 간소화되게 만들 수 있습니다. (Create a merge commit 옵션 과 비교해보면 새로운 merge 커밋이 없습니다.)

Rebase and merge

The 4 commits from this branch will be released and added to the base branch.

Rebase and merge 옵션을 선택하면, pull request의 커밋들을 하나의 커밋으로 병합하지 않고 개별적으로 base branch에 추가됩니다. 해당 pull request는 –ff 옵션을 사용해 merge합니다.

GitHub에서의 Rebase and merge는 git의 rebase와는 조금 다릅니다. GitHub의 Rebase and merge는 항상 committer의 정보를 업데이트하고 새로운 commit SHA를 생성하지만, git의 rebase는 조상 commit 후에 rebase를 할 경우 committer의 정보를 변경하지 않습니다.

만약, conflict로 인해 GitHub에서 자동적으로 Rebase and merge를 할 수 없다면, local에서 직접 command line을 이용해서 rebase를 하고 merge conflict를 해결해야 합니다. 그 후 pull request의 topic branch에 Force-push를 하면됩니다.

참고

tail -f 보다 효율적인 less +F에 대해 알아보자

Stop using tail -f

얼마전 tail -f를사용하며 스크롤 기능을 사용하고 싶어 검색하던 중 less +F를 알게 되었습니다. less +F에 대해 잘 설명한 글이 있어 번역해보려 합니다. 해당 글은 Stop using tail -f (mostly) 에서 볼 수 있습니다.

파일의 변화를 실시간으로 모니터링하기 위해 여전히 많은 사람들이 tail -f를 사용하고 있습니다. tail-f 보다 less +F 를 사용하는게 조금 더 나은 방법이 될 수 있습니다.

less documentation에서 +F에 대해 잘 설명하고 있습니다.

Scroll forward, and keep trying to read when the end of file is reached. Normally this command would be used when already at the end of the file. It is a way to monitor the tail of a file which is growing while it is being viewed. (The behavior is similar to the “tail -f” command.)

tail -f 명령어와 비슷하다고 하는데, 어떤 점에서 더 좋을까요?

간단하게, less +F를 사용하면 navigation과 watching 모드 사이를 쉽게 변경할 수 있습니다. tail -f를 사용하셨던 분들은 아마 file을 실시간으로 모니터링하면서 해당 파일에서 무언가를 검색하거나 위, 아래로 이동하고 싶었던 적이 있을것 입니다. 그럴때마다 tail 명령어를 종료하거나 새로운 shell로 vim을 통해 문제를 해결했을 것입니다. 그러나 less 명령어를 사용하면 더이상 그런 번거로운 수고를 할 필요 없습니다.

production.log 라는 파일을 모니터링한다고 가정해 보겠습니다.

1
2
3
4
5
6
7
8
$ less +F production.log

Important
log
information
here

Waiting for data... (interrupt to abort)

tail 명령어와 결과가 비슷한것 같지만, Ctrl-c를 눌러 normal less 모드로 변경할 수 있습니다. +F 플래그 없이 파일을 오픈한 것과 같은 모드입니다. 그 후, normal less 모드에서 사용할 수 있는 기능들을 똑같이 사용할 수 있습니다. /foo를 검색한다던가, 검색 결과를 n 또는 N을 통해 이동할 수 있습니다. 또한 jk를 통해 라인을 이동할 수도 있습니다. 추가적으로 m을 통해 마크도 생성할 수 있습니다.

필요한 작업을 모두 끝내고 나면, F를 눌러 다시 모니터링 모드로 돌아갈 수 있습니다.

When not to use less

동시에 여러개의 파일을 모니터링 해야하는 경우에는 tail -f가 조금 더 나을 수 있습니다.

1
2
3
4
5
6
7
8
9
10
$ tail -f *.txt

==> file1.txt <==
content for first file

==> file2.txt <==
content for second file

==> file3.txt <==
content for third file

변경이 발생할 때, 파일 이름과 새로운 내용이 보이기 때문에 매우 편리합니다.

그러나, less의 경우에는 다음처럼 출력됩니다.

1
2
3
$ less +F *.txt

content for first file

less는 동시에 오직 하나의 파일 내용만 볼 수 있으며, 2번째 파일을 보고 싶을 경우 Ctrl-c로 normal mode로 변환 후 :n을 눌러 다음 buffer로 이동해야 합니다. 그 후 다시 F를 눌러 모니터링 모드로 돌아가면 됩니다.

필요와 경우에 따라서, lesstail을 잘 사용하면 조금 더 효율적인 작업을 할 수 있을것 입니다.

토비의 스프링 2장 (테스트)

테스트

스프링이 개발자에게 제공하는 가장 중요한 가치는 객체지향과 테스트이다.

테스트란 내가 예상하고 의도했던 대로 코드가 정확히 동작하는지를 확인해서, 만든 코드를 확신할 수 있게 해주는 작업이다. 또한 테스트의 결과가 원하는 대로 나오지 않는 경우에는 코드나 설계에 결함이 있음을 알수 있다. 이를 통해 코드의 결함을 제거해가는 작업, 디버깅을 거치게 되고, 최종적으로 테스트가 성공하면 모든 결함이 제거됐다는 확신을 얻을 수 있다.

보통 웹 프로그램에서 사용하는 DAO를 테스트 하는 방법은 다음과 같다. DAO를 만든 뒤 바로 테스트하지 않고, 서비스 계층, MVC 프레젠테이션 계층까지 포함한 모든 입출력 기능을 대충이라도 코드로 다 만든다. 이렇게 만들어진 테스트 용 웹 애플리케이션을 서버에 배치한 뒤, 웹 화면을 띄워 폼을 열고, 값을 입력한 뒤 버튼을 눌러 등록해본다.

이렇게 웹 화면을 통해 값을 입력하고, 기능을 수행하고, 결과를 확인하는 방법은 가장 흔하게 쓰이는 방법이지만 단점이 너무 많다. 테스트를 하는 중에 에러가 나거나 테스트가 실패했다면, 과연 어디에서 문제가 발생했는지를 찾아내야 하는 수고도 필요하다. 하나의 테스트를 수행하는 데 참여하는 클래스와 코드가 너무 많기 때문이다.

테스트를 하고자 하는 대상이 명확하다면 그 대상에만 집중해서 테스트하는 것이 바람직하다. 테스트는 가능하면 작은 단위로 쪼개서 집중해서 할 수 있어야 한다. 관심사의 분리라는 원리가 여기에도 적용된다. 테스트의 관심이 다르다면 테스트할 대상을 분리하고 집중해서 접근해야한다.

작은 단위의 코드에 대해 테스트를 수행한 것을 단위 테스트(Unit test)라고 한다. 여기서 말하는 단위란 그 크기와 범위가 어느 정도인지 딱 정해진 건 아니다. 충분히 하나의 관심에 집중해서 효율적으로 텧스트할 만한 범위의 단위라고 보면 된다.

일반적으로 단위는 작을수록 좋다. 단위를 넘어서는 다른 코드들은 신경 쓰지 않고, 참여하지도 않고 테스트가 동작할 수 있으면 좋다. 그런 차원에서 통제할 수 없는 외부의 리소스에 의존하는 테스트는 단위 테스트가 아니라고 보기도 한다.

단위 테스트를 하는 이유는 개발자가 설계하고 만든 코드가 원래 의도한 대로 동작하는지를 개발자 스스로 빨리 확인받기 위해서다. 이때 확인의 대상과 조건이 간단하고 명확할수록 좋다.

테스트는 자동으로 수행되도록 코드로 만들어지는 것이 중요하다. 그렇게 되면 자주 반복할 수 있다는 장점을 얻을 수 있다. 테스트 자체가 사람의 수작업을 거치는 방법을 사용하기 보다는 코드로 만들어져서 자동으로 수행될 수 있어야 한다는 건 매우 중요하다. 그런데 애플리케이션을 구성하는 클래스 안에 테스트 코드를 포함시키는 것보다는 별도로 테스트용 클래스를 만들어서 테스트 코드를 넣는 편이 낫다.

테스트를 이용하면 새로운 기능도 기대한 대로 동작하는지 확인할 수 있을 뿐 아니라, 기존에 만들어뒀던 기능들이 새로운 기능을 추가하느라 수정한 코드에 영향을 받지 않고 여전히 잘 동작하는지를 확인할 수도 있다.

테스트 검증의 자동화

모든 테스트는 성공과 실패의 두 가지 결과를 가질 수 있다. 또 테스트의 실패는 테스트가 진행되는 동안에 에러가 발생해서 실패하는 경우와, 테스트 작업 중에 에러가 발생하진 않았지만 그 결과가 기대한 것과 다르게 나오는 경우로 구분해볼 수 있다. 여기서 전자를 테스트 에러, 후자를 테스트 실패로 구분할 수 있다.

테스트 중에 에러가 발생하는 것은 쉽게 확인이 가능하다. 콘솔에 에러 메시지와 긴호출 스택 정보가 출력되기 때문이다. 하지만 테스트가 실패하는 것은 별도의 확인 작업과 그 결과가 있어야만 알 수 있다.

자바에는 단순하면서도 실용적인 테스트를 위한 도구가 여러 가지 존재한다. JUnit은 이름 그대로 자바로 단위 테스트를 만들 때 유용하게 쓸 수 있다.

JUnit은 프레임워크다. 프레임워크는 개발자가 만든 클래스에 대한 제어 권한을 넘겨받아서 주도적으로 애플리케이션의 흐름을 제어한다. 개발자가 만든 클래스의 오브젝트를 생성하고 실행하는 일은 프레임워크에 의해 진행된다. 따라서 프레임워크에서 동작하는 코드는 main() 메소드도 필요 없고 오브젝트를 만들어서 실행시키는 코드를 만들 필요도 없다.

main() 메소드 테스트는 그런면에서 프레임워크에 적용하기엔 적합하지 않다. 테스트가 main() 메소드로 만들어졌다는 건 제어권을 직접 갖는다는 의미이기 때문이다. 그래서 가장 먼저 할 일은 main() 메소드에 있던 테스트 코드를 일반 메소드로 옮기는 것이다. 새로 만들 테스트 메소드는 JUnit 프레임워크가 요구하는 조건 두가지를 따라야 한다. 첫째는 메소드가 public으로 선언돼야 하는 것이고, 다른 하나는 메소드에 @Test라는 애노테이션을 붙여주는 것이다.

JUnit은 하나의 클래스 안에 여러 개의 테스트 메소드가 들어가는 것을 허용한다. @Test가 붙어있고 public 접근자가 있으며 리턴 값이 void 형이고 파라미터가 없다는 조건을 지키기만 하면된다.

1
2
3
4
5
6
7
8
public class Test {
@Test
public void add() {
ApplicationContext context = new GenericXmlApplicationContext("applicationContext.xml");

...
}
}

검증 코드 변환

1
if (!user.getName().equals(user2.getName())) { ... }

이 if 문장의 기능을 JUnit이 제공해주는 assertThat이라는 스태틱 메소드를 이용해 다음과 같이 변경할 수 있다.

1
assertThat(user2.getName(), is(user.getName()));

assertThat() 메소드는 첫 번째 파라미터의 값을 뒤에 나오는 매처(matcher)라고 불리는 조건으로 비교해서 일치하면 다음으로 넘어가고, 아니면 테스트가 실패하도록 만들어 준다. is()는 매처의 일종으로 equals()로 비교해주는 기능을 가졌다.

JUni은 예외가 발생하거나 assertThat()에서 실패하지 않고 테스트 메소드의 실행이 완료되면 테스트가 성공했다고 인식한다.

JUnit 테스트 실행

스프링 컨테이너와 마찬가지로 JUnit 프레임워크도 자바 코드로 만들어진 프로그램이므로 어디선가 한 번은 JUnit 프레임워크를 시작시켜 줘야 한다.

어디에든 main() 메소드를 하나 추가하고, 그 안에 JUnitCore 클래스의 main 메소드를 호출해주는 간단한 코드를 넣어주면 된다. 메소드 파라미터에는 @Test 테스트 메소드를 가진 클래스의 이름을 넣어준다.

1
2
3
4
5
import org.junit.runner.jUnitCore;
...
public static void main(String[] args) {
JUnitCore.main("Springbook.user.dao.UserDaoTest");
}

JUnit은 assertThat()을 이용해 검증을 했을 때 기대한 결과가 아니면 이 **AssertionError**를 던진다. 또한 테스트 수행 중에 일반 예외가 발생한 경우에도 마찬가지로 테스트 수행은 중단되고 테스트는 실패한다.

JUnit

JUnit은 사실상 자바의 표준 테스팅 프레임워크라고 불릴만큼 폭넓게 사용되고 있다. 스프링의 핵심 기능 중 하나인 스프링 테스트 모듈도 JUnit을 이용한다. 또, 테스트 작성시 자주 필요한 편리한 여러 가지 부가기능도 제공한다. 대부분의 자바 IDE는 JUnit 테스트를 손쉽게 실행할 수 있는 JUnit 테스트 지원 기능을 내장하고 있어서 더욱 편리하게 JUnit 테스트를 만들고 활용할 수 있게 해준다.

JUnit 테스트 실행 방법

JUnitCore를 이용해 테스트를 실행하고 콘솔에 출력된 메시지를 보고 결과를 확인하는 방법은 가장 간단하긴 하지만 테스트의 수가 많아지면 관리하기가 힘들어진다는 단점이 있다. 가장좋은 JUnit 테스트 실행 방법은 자바 IDE에 내장된 JUnit 테스트 지원 도구를 사용하는 것이다. IDE를 사용하면 JUnitCore를 이용할 때처럼 main() 메소드를 만들지 않아도 된다.

JUnit은 한 번에 여러 테스트 클래스를 동시에 실행할 수도 있다. 패키지 아래에 있는 모든 JUnit 테스트를 한 번에 실행할 수도 있고, 소스 폴더나 프로젝트 전체를 선택해서 모든 테스트를 한 번에 실행할 수도 있다. 이런 면에서 JUnitCore를 사용해 테스트를 실행하는 것보다 훨씬 편리하다.

주의해야 할 점은 여러개의 테스트가 어떤 순서로 실행될지는 알 수 없다. JUnit은 특정한 테스트 메소드의 실행 순서를 보장해주지 않는다. 테스트의 결과가 테스트 실행 순서에 영향을 받는다면 테스트를 잘못 만든 것이다. 모든 테스트는 실행 순서에 상관없이 독립적으로 항상 동일한 결과를 낼 수 있도록 해야 한다.

빌드툴

프로젝트의 빌드를 위해 ANT메이븐(Maven)같은 빌드 툴과 스크립트를 사용하고 있다면, 빌드 툴에서 제공하는 JUnit 플러그인이나 태스크를 이용해 JUnit 테스트를 실행할 수 있다.

여러 개발자가 만든 코드를 모두 통합해서 테스트를 수행해야 할 때도 있다. 이런 경우에는 서버에서 모든 코드를 가져와 통합하고 빌드한 뒤에 테스트를 수행하는 것이 좋다. 이때는 빌드 스크립트를 이용해 JUnit 테스트를 실행하고 그 결과를 메일 등으로 통보받는 방법을 사용하면 된다.

포괄적인 테스트

예외조건에 대한 테스트

일반적으로는 테스트 중에 예외가 던져지면 테스트 메소드의 실행은 중단되고 테스트는 실패한다. assertThat()을 통한 검증 실패는 아니고 테스트 에러라고 볼 수 있다. 그런데 이번에는 반대로 테스트 진행 중에 특정 예외가 던져지면 테스트가 성공한 것이고, 예외가 던져지지 않고 정상적으로 작업을 마치면 테스트가 실패했다고 판단해야 한다. 문제는 예외 발생 여부는 메소드를 실행해서 리턴 값을 비교하는 방법으로 확인할 수 없다는 점이다.

그런데 바로 이런 경우를 위해 JUnit은 예외조건 테스트를 위한 특별한 방법을 제공해준다. @Test 애노테이션의 expected 엘리먼트다. expected는 메소드 실행 중에 발생하리라 기대하는 예외 클래스를 넣어주면 된다.

@Test에 expected를 추가해놓으면 보통의 테스트와는 반대로, 정상적으로 테스트 메소드를 마치면 테스트가 실패하고, expected에서 지정한 예외가 던져지면 테스트가 성공한다. 예외가 반드시 발생해야 하는 경우를 테스트하고 싶을 때 유용하게 쓸 수 있다.

테스트가 이끄는 개발

테스트 주도 개발

만들고자 하는 기능의 내용을 담고 있으면서 만들어진 코드를 검증도 해줄 수 있도록 테스트 코드를 먼저 만들고, 테스트를 성공하게 해주는 코드를 작성하는 방식의 개발 방법이 있다. 이를 테스트 주도 개발(TDD)이라고 한다. 또는 테스트를 코드보다 먼저 작성한다고 해서 테스트 우선 개발(Test First Development)이라고도 한다.

“실패한 테스트를 성공시키기 위한 목적이 아닌 코드는 만들지 않는다”는 것이 TDD의 기본 원칙이다. TDD는 아예 테스트를 먼저 만들고 그 테스트가 성공하도록 하는 코드만 만드는 식으로 진행하기 때문에 테스트를 빼먹지 않고 꼼꼼하게 만들어낼 수 있다. 또한 TDD를 하면 자연스럽게 단위 테스트를 만들 수 있다.

TDD의 장점 중 하나는 코드를 만들어 테스트를 실행하는 그 사이의 간격이 매우 짧다는 점이다. 개발한 코드의 오류는 빨리 발견할수록 좋다. 빨리 발견된 오류는 쉽게 대응이 가능하기 때문이다. 테스트 없이 오랜 시간 동안 코드를 만들고 나서 테스트를 하면, 오류가 발생했을 때 원인을 찾기가 쉽지 않다.

테스트 코드 개선

JUnit 프레임워크는 테스트 메소드를 실행할 때 부가적으로 해주는 작업이 몇 가지 있다. 그 중에서 테스트를 실행할 때마다 반복되는 준비 작업을 별도의 메소드에 넣게 해주고, 이를 매번 테스트 메소드를 실행하기 전에 먼저 실행시켜주는 기능이다. 이를 알기위해서는 JUnit 프레임워크가 테스트 메소드를 실행하는 과정을 알아야 한다.

JUnit이 하나의 테스트 클래스를 가져와 테스트를 수행하는 방식은 다음과 같다.

  1. 테스트 클래스에서 @Test가 붙은 public이고 void형이며 파라미터가 없는 테스트 메소드를 모두 찾는다.
  2. 테스트 클래스의 오브젝트를 하나 만든다.
  3. @Before가 붙은 메소드가 있으면 실행한다.
  4. @Test가 붙은 메소드를 하나 호출하고 테스트 결과를 저장해둔다.
  5. @After가 붙은 메소드가 있으면 실행한다.
  6. 나머지 테스트 메소드에 대해 2~5번을 반복한다.
  7. 모든 테스트의 결과를 종합해서 돌려준다.

JUnit은 @Test가 붙은 메소드를 실행하기 전과 후에 각각 @Before와 @After가 붙은 메소드를 자동으로 실행한다. 보통 하나의 테스트 클래스 안에 있는 테스트 메소드들은 공통적인 준비작업과 정리 작업이 필요한 경우가 많다. 이런 작업들을 @Before, @After가 붙은 메소드에 넣어두면 JUnit이 자동으로 메소드를 실행해주니 매우 편리하다.

대신 @Before나 @After 메소드를 테스트 메소드에서 직접 호출하지 않기 때문에 서로 주고받을 정보나 오브젝트가 있다면 인스턴스 변수를 이용해야 한다.

또 한가지 기억해야 할 사항은 각 테스트 메소드를 실행할 때마다 테스트 클래스의 오브젝트를 새로 만든다는 것이다. 한번 만들어진 테스트 클래스의 오브젝트는 하나의 테스트 메소드를 사용하고 나면 버려진다. 그렇기 때문에 각 테스트가 서로 영향을 주지 않고 독립적으로 실행됨을 확실히 보장할 수 있다. 덕분에 인스턴스 변수도 부담 없이 사용할 수 있다. 어차피 다음 테스트 메소드가 실행될 때는 새로운 오브젝트가 만들어져서 다 초기화될 것이다.

픽스처

테스트를 수행하는 데 필요한 정보나 오브젝트를 픽스처(fixture)라고 한다. 일반적으로 픽스처는 여러 테스트에서 반복적으로 사용되기 때문에 @Before 메소드를 이용해 생성해두면 편리하다.

스프링 테스트 적용

빈이 많아지고 복잡해지면 애플리케이션 컨텍스트 생성이 적지 않은 시간이 걸린다. 애플리케이션 컨텍스트가 만들어질 때는 모든 싱글톤 빈 오브젝트를 초기화한다. 또 한가지 문제는 애플리케이션 컨텍스트가 초기화될 때 어떤 빈은 독자적으로 많은 리스소를 할당하거나 독립적인 스레드를 띄우기도 한다는 것이다. 이런 경우에는 테스트를 마칠 때마다 애플리케이션 컨텍스트 내의 빈이 할당한 리소스 등을 깔끔하게 정리해주지 않으면 다음 테스트에서 새로운 애플리케이션 컨텍스트가 만들어지면서 문제를 일으킬 수도 있다.

다행히도 애플리케이션 컨텍스트는 초기화되고 나면 내부의 상태가 바뀌는 일은 거의 없다. 빈은 싱글톤으로 만들었기 때문에 상태를 갖지 않는다. 따라서 애플리케이션 컨텍스트는 한 번만 만들고 여러 테스트가 공유해서 사용해도 된다. 스프링이 직접 제공하는 애플리케이션 컨텍스트 지원 기능을 사용하면 애플리케이션을 한 번만 만들어 공유해 사용할 수 있다.

테스트를 위한 애플리케이션 컨텍스트 관리

스프링은 JUnit을 이용하는 테스트 컨택스트 프레임워크를 제공한다. 테스트 컨텍스트의 지원을 받으면 간단한 애노테이션 설정만으로 테스트에서 필요로 하는 애플리케이션 컨텍스트를 만들어서 모든 테스트가 공유하게 할 수 있다.

먼저 ApplicationContext 타입의 인스턴스 변수를 선언하고 스프링이 제공하는 @Autowired 애노테이션을 붙인다. 마지막으로 클래스 레벨에 @RunWith@ContextConfiguration 애노테이션을 추가해준다.

1
2
3
4
5
6
7
8
@RunWith(SpringJUnit4ClassRunner.class)
@ContextConfiguration(location="/applicationContext.xml")
public class Test {
@Autowired
private ApplicationContext context;

...
}

@RunWith는 JUnit 프레임워크의 테스트 실행 방법을 확장할 때 사용하는 어노테이션이다. SpringJUnit4ClassRunner라는 JUnit용 테스트 컨텍스트 프레임워크 확장 클래스를 지정해주면 JUnit이 테스트를 진행하는 중에 테스트가 사용할 애플리케이션 컨텍스트를 만들고 관리하는 작업을 진행해준다.

@ContextConfiguration은 자동으로 만들어줄 애플리케이션 컨텍스트의 설정파일 위치를 지정한 것이다.

context 변수에는 어떻게 애플리케이션 컨텍스트가 들어가 있을까? 스프링의 JUnit 확장기능은 테스트가 실행되기 전에 딱 한 번만 애플리케이션 컨텍스트를 만들어두고, 테스트 오브젝트가 만들어질 때마다 특별한 방법을 이용해 애플리케이션 컨텍스트 자신을 테스트 오브젝트의 특정 필드에 주입해주는 것이다. 일종의 DI라고 볼 수 있는데, 애플리케이션 오브젝트 사이의 관계를 관리하기 위한 DI와는 조금 성격이 다르다.

이렇게 해서 하나의 테스트 클래스 내의 테스트 메소드는 같은 애플리케이션 컨텍스트를 공유해서 사용할 수 있다.

테스트 클래스의 컨텍스트 공유

스프링 테스트 컨텍스트 프레임워크의 기능은 하나의 테스트 클래스 안에서 애플리케이션 컨텍스트를 공유해주는 것이 전부가 아니다. 여러 개의 테스트 클래스가 있는데 모두 같은 설정파일을 가진 애플리케이션 컨텍스트를 사용한다면, 스프링은 테스트 클래스 사이에서도 애플리케이션 컨텍스트를 공유하게 해준다.

테스트 클래스마다 다른 설정파일을 사용하도록 만들어도 되고, 몇 개의 테스트에서만 다른 설정파일을 사용할 수도 있다. 스프링은 설정파일의 종류만큼 애플리케이션 컨텍스트를 만들고, 같은 설정파일을 지정한 테스트에서는 이를 공유하게 해준다.

@Autowired

@Autowired는 스프링의 DI에 사용되는 특별한 애노테이션이다. @Autowired가 붙은 인스턴스 변수가 있으면, 테스트 컨텍스트 프레임워크는 변수 타입과 일치하는 컨텍스트 내의 빈을 찾는다. 타입이 일치하는 빈이 있으면 인스턴스 변수에 주입해준다. 일반적으로는 주입을 위해서는 생성자나 수정자 메소드 같은 메소드가 필요하지만, 이 경우에는 메소드가 없어도 주입이 가능하다. 또 별도의 DI 설정 없이 필드의 타입정보를 이용해 빈을 자동으로 가져올 수 있는데, 이런 방법을 타입에 의한 자동와이어링이라고 한다.

스프링 애플리케이션 컨텍스트는 초기화할 때 자기 자신도 빈으로 등록한다. 따라서 애플리케이션 컨텍스트에는 ApplicationContext 타입의 빈이 존재하는 것이고 DI도 가능하다.

@Autowired를 이용해 애플리케이션 컨텍스트가 갖고 있는 빈을 DI 받을 수 있다면 굳이 컨텍스트를 가져와 getBean()을 사용하는 것이 아니라, 아예 빈을 직접 DI 받을 수도 있다. (@Autowired를 지정하기만 하면 어떤 빈이든 다 가져올 수 있다.)

@Autowired는 변수에 할당 가능한 타입을 가진 빈을 자동으로 찾는다. 단, @Autowired는 같은 타입의 빈이 두 개 이상 있는 경우에는 타입만으로는 어떤 빈을 가져올지 결정할 수 없다. 타입으로 가져올 빈 하나를 선택할 수 없는 경우에는 변수의 이름과 같은 이름의 빈이 있는지 확인한다. 변수 이름으로도 빈을 찾을 수 없는 경우에는 예외가 발생한다.

테스트는 필요하다면 얼마든지 애플리케이션 클래스와 밀접한 관계를 맺고 있어도 상관없다. 개발자가 만드는 테스트는 코드 내부구조와 설정 등을 알고 있고 의도적으로 그 내용을 검증해야 할 필요가 있기 때문이다. 하지만 꼭 필요하지 않다면 테스트에서도 가능한 한 인터페이스를 사용해서 애플리케이션 코드와 느슨하게 연결해두는 편이 좋다.

DI와 테스트

인터페이스를 통해 DI를 적용해야 하는 이유는 다음과 같다.

  • 소프트웨어 개발에서 절대로 바뀌지 않는 것은 없기 때문이다.
  • 클래스의 구현 방식은 바뀌지 않는다고 하더라도 인터페이스를 두고 DI를 적용하게 해두면 다른 차원의 서비스 기능을 도입할 수 있기 때문이다.
  • 테스트 때문이다.

SingleConnectionDataSource

스프링이 제공하는 가장 빠른 DataSource이다. DB 커넥션을 하나만 만들어두고 계속 사용하기 때문에 매우 빠르다. 다중 사용자 환경에서는 사용할 수 없겠지만 순차적으로 진행되는 테스트에서라면 문제없다.

스프링 테스트 컨텍스트 프레임워크를 적용했다면 애플리케이션 컨텍스트는 테스트 중에 딱 한 개만 만들어지고 모든 테스트에서 공유해서 사용한다. 따라서 애플리케이션 컨텍스트의 구성이나 상태를 테스트 내에서 변경하지 않는 것이 원칙이다. 만약 한 번 변경하면 나머지 모든 테스트를 수행하는 동안 변경된 애플리케이션 컨텍스트가 계속 사용될 것이다. 이는 별로 바람직하지 못하다.

그럴때는 @DirtiesContext라는 애노테이션을 추가한다. 이 애노테이션은 스프링의 테스트 컨텍스트 프레임워크에게 해당 클래스의 테스트에서 애플리케이션 컨텍스트의 상태를 변경한다는 것을 알려준다. 테스트 컨텍스트는 이 애노테이션이 붙은 테스트 클래스에는 애플리케이션 컨텍스트 공유를 허용하지 않는다. 테스트 메소드를 수행하고 나면 매번 새로운 애플리케이션 컨텍스트를 만들어서 다음 테스트가 사용하게 해준다. 테스트 중에 변경한 컨텍스트가 뒤의 테스트에 영향을 주지 않게하기 위해서다.

@DirtiesContext는 클래스에만 적용할 수 있는 건 아니다. 하나의 메소드에서만 컨텍스트 상태를 변경한다면 메소드 레벨에 @DirtiesContext를 붙여주는 편이 낫다. 해당 메소드의 실행이 끝나고 나면 이후에 진행되는 테스트를 위해 변경된 애플리케이션 컨텍스트는 폐기되고 새로운 애플리케이션 컨텍스트가 만들어진다.

테스트를 위한 별도의 DI 설정

테스트 코드에서 빈 오브젝트에 수동으로 DI 하는 방법은 장점보다 단점이 많다. 코드가 많아져 번거롭기도 하고 애플리케이션 컨텍스트도 매번 새로 만들어야 하는 부담이 있다.

그래서 테스트 전용 설정파일을 따로 만들어 사용하는 방법을 이용한다. 테스트에서는 항상 테스트 전용 설정파일만 사용하게 해주면 된다.

@ContextConfiguration 애노테이션에 있는 locations 엘리먼트의 값을 새로 만든 테스트용 설정파일로 변경해준다.

1
2
3
@RunWith(SpringJUnit4ClassRunner.class)
@ContextConfiguration(locations="/test-applicationContext.xml")
public class Test {

참고

토비의 스프링 3.1 Vol. 1 스프링의 이해와 원리

토비의 스프링 1장 (오브젝트와 의존관계)

스프링

스프링은 자바 엔터프라이즈 애플리케이션 개발에 사용되는 프레임워크다. 애플리케이션 프레임워크는 애플리케이션 개발을 빠르고 효율적으로 할 수 있도록 애플리케이션의 바탕이 되는 틀과 공통 프로 그래밍 모델, 기술 API 등을 제공해준다.

스프링은 스프링 컨테이너 또는 애플리케이션 컨텍스트라고 불리는 스프링 런타임 엔진을 제공한다. 스프링 컨테이너는 설정정보를 참고로 해서 애플리케이션을 구성하는 오브젝트를 생성하고 관리한다.

스프링은 세 가지 핵심 프로그래밍 모델을 지원한다.

  • IOC/DI : 오브젝트의 생명주기와 의존관계에 대한 프로그래밍 모델
  • 서비스 추상화 : 환경이나 서버, 특정 기술에 종속되지 않고 이식성이 뛰어나며 유연한 애플리케이션을 만들 수 있다.
  • AOP : 애플리케이션 코드에 산재해서 나타나는 부가적인 기능을 독립적으로 모듈화하는 프로그래밍 모델

자바빈

다음과 같은 두 가지 관례를 따라 만들어진 오브젝트를 말한다. 간단히 빈이라고 부르기도 한다.

  • 디폴트 생성자 : 자바빈은 파라미터가 없는 디폴트 생성자를 갖고 있어야 한다. 툴이나 프레임워크에서 리플렉션을 이용해 오브젝트를 생성하기 때문에 필요하다.
  • 프로퍼티 : 자자빈이 노출하는 이름을 가진 속성을 프로퍼티라고 한다. 프로퍼티는 setter와 getter를 이용해 수정 또는 조회할 수 있다.

디자인 패턴 : 소프트웨어 설계 시 특정 상황에서 자주 만나는 문제를 해결하기 위해 사용할 수 있는 재사용 가능한 솔루션. 주로 객체지향 설계에 관한 것이고, 대부분 객체지향적 설계 원칙을 이용해 문제를 해결한다.

  • 템플릿 메소드 패턴
    상속을 통해 슈퍼클래스의 기능을 확장할 때 사용하는 가장 대표적인 방법이다. 변하지 않는 기능은 슈퍼클래스에 만들어두고 자주 변경되며 확장할 기능은 서브클래스에 만들도록 한다. 슈퍼클래스에서는 미리 추상메소드 또는 오버라이드 가능한 메소드를 정의해두고 이를 활용해 코드의 기본 알고리즘을 담고 있는 템플릿 메소드를 만든다.
  • 팩토리 메소드 패턴
    슈퍼클래스 코드에서는 서브클래스에서 구현할 메소드를 호출해서 필요한 타입의 오브젝트를 가져와 사용한다. 이 메소드는 주로 인터페이스 타입으로 오브젝트를 리턴하므로 서브클래스에서 정확히 어떤 클래스의 오브젝트를 만들어 리턴할지는 슈퍼클래스에서는 알지 못한다. 팩토리 메소드와 메소드 패턴의 팩토리 메소드는 의미가 다르므로 혼동하지 않도록 주의해야 한다.

클래스 사이의 관계와 오브젝트 사이의 관계를 구분할 수 있어야 한다.

  • 클래스 사이의 관계 : 코드에 다른 클래스의 이름이 나타나기 때문에 만들어지는 것이다.
  • 오브젝트 사이의 관계 : 코드에서는 특정 클래스를 전혀 알지 못하더라도 해당 클래스가 구현한 인터페이스를 사용했다면, 그 클래스의 오브젝트를 인터페이스 타입으로 받아서 사용할 수 있다.

개방 폐쇄 원칙 (OCP, Open-Closed Principle)

클래스나 모듈은 확장에는 열려 있어야 하고 변경에는 닫혀 있어야 한다. 인터페이스를 사용해 확장 기능을 정의한 대부분의 API는 바로 이 개방 폐쇄 원칙을 따른다.

개방 폐쇄 원칙은 높은 응집도와 낮은 결합도라는 소프트웨어 개발의 고전적인 원리로도 설명이 가능하다.

전략 패턴

개방 폐쇄 원칙의 실현에도 가장 잘 들어맞는 패턴이다. 전략 패턴은 자신의 기능 맥락(context)에서, 필요에 따라 변경이 필요한 알고리즘(독립적인 책임으로 분리가 가능한 기능)을 인터페이스를 통해 통째로 외부로 분리시키고, 이를 구현한 구체적인 알고리즘 클래스를 필요에 따라 바꿔서 사용할 수 있게 하는 디자인패턴이다.

전략 패턴의 적용방법을 보면 클라이언트의 역할이 잘 설명되어 있다. 컨텍스트를 사용하는 클라이언트는 컨텍스트가 사용할 전략을 컨텍스트의 생성자 등을 통해 제공해주는게 일반적이다.

제어의 역전 (IOC, Inversion Of Control)
제어의 역전이라는 건, 간단히 프로그램의 제어 흐름 구조가 뒤바뀌는 것이다. 제어의 역전에서는 오브젝트가 자신이 사용할 오브젝트를 스스로 선택하지 않는다. 당연히 생성하지도 않는다. 또 자신도 어떻게 만들어지고 어디서 사용되는지를 알 수 없다. 모든 제어 권한을 자신이 아닌 다른 대상에게 위임하기 때문이다.

서블릿이나 JSP, EJB처럼 컨테이너 안에서 동작하는 구조는 간단한 방식이긴 하지만 제어의 역전 개념이 적용되어 있다고 볼 수 있다.

프레임워크도 제어의 역전 개념 적용된 대표적인 기술이다. 라이브러리와 프레임워크의 차이점에 대해 설명하면, 라이브러리를 사용하는 애플리케이션 코드는 애플리케이션 흐름을 직접 제어한다. 단지 동작하는 중에 필요한 기능이 있을 때 능동적으로 라이브러리를 사용할 뿐이다. 반면에 프레임워크는 거꾸로 애플리케이션 코드가 프레임워크에 의해 사용된다. 보통 프레임워크 위에 개발한 클래스를 등록해주고, 프레임워크가 흐름을 주도하는 중에 개발자가 만든 애플리케이션 코드를 사용하도록 만드는 방식이다.

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

스프링의 IoC

오브젝트 팩토리를 이용한 스프링 IoC

스프링에서는 스프링이 제어권을 가지고 직접 만들고 관계를 부여하는 오브젝트를 빈(bean)이라고 부른다. 자바빈 또는 엔터프라이즈 자바빈(EJB)에서 말하는 빈과 비슷한 오브젝트 단위의 애플리케이션 컴포넌트를 말한다. 동시에 스프링 빈은 스프링 컨테이너가 생성과 관계설정, 사용 등을 제어해주는 제어의 역전이 적용된 오브젝트를 가리키는 말이다.

스프링에서는 빈의 생성과 관계설정 같은 제어를 담당하는 IoC 오브젝트를 빈 팩토리(bean factory)라고 부른다. 보통 빈 팩토리보다는 이를 좀 더 확장한 애플리케이션 컨텍스트(applcation context)를 주로 사용한다.

애플리케이션 컨텍스트는 그 자체로는 애플리케이션 로직을 담당하지는 않지만 IoC 방식을 이용함으로써, 별도의 정보를 참고해서 빈의 생성과 관계설정 등의 제어 작업을 총괄한다.

  • **@Configuration **: 애플리케이션 컨텍스트 또는 빈 팩토리가 사용할 설정정보라는 표시
  • @Bean : 오브젝트 생성을 담당하는 IoC용 메소드라는 표시

애플리케이션 컨텍스트는 ApplicationContext 타입의 오브젝트다. ApplicationContext를 구현한 클래스는 여러 가지가 있는데 DaoFactory처럼 @Configuration이 붙은 자바 코드를 설정정보로 사용하려면 AnnotationConfigApplicationContext를 이용하면 된다.

getBean() 메소드는 ApplicationContext가 관리하는 오브젝트를 요청하는 메소드다. getBean()은 기본적으로 Object 타입으로 리턴하게 되어 있어서 매번 리턴되는 오브젝트에 다시 캐스팅을 해줘야 하는 부담이 있다. 그러나 자바 5 이상의 제네릭 메소드 방식을 사용해 getBean()의 두 번째 파라미터에 리턴 타입을 주면, 지저분한 캐스팅 코드를 사용하지 않아도 된다.

오브젝트 팩토리에서 사용했던 IoC 원리를 그대로 적용하는데 애플리케이션 컨텍스트를 사용하는 이유는 범용적이고 유연한 방법으로 IoC 기능을 확장하기 위해서다. 애플리케이션 컨텍스트를 사용했을 때 얻을 수 있는 장점은 다음과 같다.

  • 클라이언트는 구체적인 팩토리 메서드를 알 필요가 없다.
  • 애플리케이션 컨텍스트는 종합 IoC 서비스를 제공해준다.
  • 애플리케이션 컨텍스트는 빈을 검색하는 다양한 방법을 제공한다.

스프링 IoC의 용어 정리


  • 빈 또는 빈 오브젝트는 스프링이 IoC 방식으로 관리하는 오브젝트라는 뜻이다. 주의할 점은 스프링을 사용하는 애플리케이션에서 만들어지는 모든 오브젝트가 다 빈은 아니라는 사실이다. 그 중에서 스프링이 직접 그 생성과 제어를 담당하는 오브젝트만을 빈이라고 부른다.
  • 빈 팩토리
    스프링의 IoC를 담당하는 핵심 컨테이너를 말한다. 빈을 등록하고, 생성하고, 조회하고 돌려주고, 그 외에 부가적인 빈을 관리하는 기능을 담당한다.
  • 애플리케이션 컨텍스트
    빈 팩토리를 확장한 IoC.컨테이너다. 스프링이 제공하는 각종 부가 서비스를 추가로 제공한다. 애플리케이션 컨텍스트 오브젝트는 하나의 애플리케이션에서 보통 여러 개가 만들어져 사용된다.
  • 설정정보/설정 메타정보
    스프링의 설정정보란 애플리케이션 컨텍스트 또는 빈 팩토리가 IoC를 적용하기 위해 사용하는 메타정보를 말한다. IoC 컨테이너에 의해 관리되는 애플리케이션 오브젝트를 생성하고 구성할 때 사용된다.
  • 컨테이너 또는 IoC 컨테이너
    IoC 방식으로 빈을 관리한다는 의미에서 애플리케이션 컨텍스트나 빈 팩토리를 컨테이너 또는 IoC 컨테이너라고도 한다. 그냥 컨테이너 또는 스프링 컨테이너라고 할 때는 애플리케이션 컨텍스트를 가리키는 것이라고 보면 된다.

싱글톤 레지스트리로서의 애플리케이션 컨텍스트

오브젝트의 동일성과 동등성

자바에서는 두 개의 오브젝트가 같은가라는 말을 주의해서 써야 한다. 자바에서는 두개의 오브젝트가 완전히 같은 동일한 오브젝트라고 말하는 것(동일성)과, 동일한 정보를 담고 있는 오브젝트라고 말하는 것(동등성)은 분명한 차이가 있다. 물론 동일한 오브젝트는 동등하기도 하다.

동일성은 == 연산자로, 동등성은 equals() 메소드를 이용해 비교한다.

스프링의 애플리케이션 컨텍스트는 기존에 직접 만든 오브젝트 팩토리와는 중요한 차이점이 있다. 스프링은 내부에서 생성하는 빈 오브젝트를 모두 싱글톤으로 만든다는 것이다. 애플리케이션 컨텍스트는 싱글톤을 저장하고 관리하는 싱글톤 레지스트리이기도 하다.

스프링은 기본적으로 별다른 설정을 하지 않으면 내부에서 생성하는 빈 오브젝트를 모두 싱글톤으로 만든다. (디자인 패턴에서 나오는 싱글톤 패턴과 비슷한 개념이지만 그 구현 방법은 확연히 다르다.)

싱글톤 패턴의 한계

일반적인 싱글톤 패턴 구현 방식에는 다음과 같은 문제(한계)가 있다.

  • private 생성자를 갖고 있기 때문에 상속할 수 없다.
  • 싱글톤은 테스트하기가 힘들다.
  • 서버환경에서는 싱글톤이 하나만 만들어지는 것을 보장하지 못한다.
  • 싱글톤의 사용은 전역 상태를 만들 수 있기 때문에 바람직하지 못하다.

싱글톤 레지스트리

스프링은 서버환경에서 싱글톤이 만들어져서 서비스 오브젝트 방식으로 사용되는 것은 적극 지지한다. 그러나 자바의 기본적인 싱글톤 패턴의 구현 방식은 여러 가지 단점이 있기 때문에, 스프링은 직접 싱글톤 형태의 오브젝트를 만들고 관리하는 기능을 제공한다. 그것이 바로 싱글톤 레지스트리

싱글톤 레지스트리의 장점은 스태틱 메소드와 private 생성자를 사용해야 하는 비정상적인 클래스가 아니라 평범한 자바 클래스를 싱글톤으로 활용하게 해준다는 것이다.

스프링의 싱글톤 레지스트리 덕분에 싱글톤 방식으로 사용될 애플리케이션 클래스라도 public 생성자를 가질 수 있다.

싱글톤과 오브젝트의 한계

싱글톤은 멀티스레드 환경이라면 여러 스레드가 동시에 접근해서 사용할 수 있다. 따라서 상태 관리에 주의를 기울여야 한다. 기본적으로 싱글톤이 멀티스레드 환경에서 서비스 형태의 오브젝트로 사용되는 경우에는 상태정보를 내부에 갖고 있지 않은 무상태(stateless) 방식으로 만들어져야 한다.

무상태 방식으로 만들기 위해서는 메소드 안에서 생성되는 로컬 변수를 사용하면 된다. 로컬 변수는 매번 새로운 값을 저장할 독립적인 공간이 만들어지기 때문에 싱글톤이라고 해도 여러 스레드 변수의 값을 덮어쓸 일은 없다.

따라서 스프링의 싱글톤 빈으로 사용되는 클래스를 만들 때는 개별적으로 바뀌는 정보는 로컬 변수로 정의하거나, 파라미터로 주고받으면서 사용하게 해야 한다.

그러나, 자신이 사용하는 다른 싱글톤 빈을 저장하려는 용도라면 인스턴스 변수를 사용해도 좋다. 스프링이 한 번 초기화해주고 나면 이후에는 수정되지 않기 때문에 멀티스레드 환경에서 사용해도 아무런 문제가 없다.

스프링 빈의 스코프

스프링이 관리하는 오브젝트, 즉 빈이 생성되고, 존재하고, 적용되는 범위를 빈의 스코프(scope)라고 한다. 스프링 빈의 기본 스코프는 싱글톤이다.

경우에 따라서는 싱글톤 외의 스코프를 가질 수 있다. 그 예로 웹을 통해 새로운 HTTP 요청이 생길 때마다 생성되는 요청(request) 스코프가 있고, 웹의 세션과 스코프가 유사한 세션(session) 스코프도 있다.

의존관계 주입 (DI)

IoC가 매우 느슨하게 정의돼서 폭넓게 사용되는 용어이기 때문에 스프링을 IoC 컨테이너라고만 해서는 스프링이 제공하는 기능의 특징을 명확하게 설명하지 못한다.

그래서 스프링이 제공하는 IoC 방식을 핵심을 짚어주는 의존관계 주입(Dependency Injection)이라는, 좀 더 의도가 명확한 이름을 사용하기 시작했다.

스프링이 다른 프레임워크와 차별화돼서 제공해주는 기능은 의존관계 주입이라는 새로운 용어를 사용할 때 분명하게 드러난다.

DI는 오브젝트 레퍼런스를 외부로부터 제공(주입)받고 이를 통해 다른 오브젝트와 다이내믹하게 의존관계가 만들어지는 것이 핵심이다.

런타임 의존관계 설정

모델이나 코드에서 클래스와 인터페이스를 통해 드러나는 의존관계 말고, 런타임 시에 오브젝트 사이에서 만들어지는 의존관계도 있다. 설계 시점의 의존관계가 실체화된 것이다.

런타임 시에 의존관계를 맺는 대상, 즉 실제 사용대상인 오브젝트를 의존 오브젝트라고 한다.

의존관계 주입은 구체적인 의존 오브젝트와 그것을 사용할 주체, 보통 클라이언트라고 부르는 오브젝트를 런타임 시에 연결해주는 작업을 말한다.

의존관계 주입이란 다음의 세 가지 조건을 충족하는 작업을 말한다.

  • 클래스 모델이나 코드에는 런타임 시점의 의존관계가 드러나지 않는다. 그러기 위해서는 인터페이스에만 의존하고 있어야 한다.
  • 런타임 시점의 의존관계는 컨테이너나 팩토리 같은 제3의 존재가 결정한다.
  • 이존관계는 사용할 오브젝트에 대한 레퍼런스를 외부에서 제공(주입)해줌으로써 만들어진다.

의존관계 주입의 핵심은 설계 시점에는 알지 못했던 두 오브젝트의 관계를 맺도록 도와주는 제3의 존재가 있다는 것이다. 스프링의 애플리케이션 컨텍스트, 빈 팩토리, IoC 컨테이너 등이 모두 외부에서 오브젝트 사이의 런타임 관계를 맺어주는 책임을 지닌 제 3의 존재이다.

DI는 자신이 사용할 오브젝트에 대한 선택과 생성 제어권을 외부로 넘기고 자신은 수동적으로 주입받은 오브젝트를 사용한다는 점에서 IoC의 개념에 잘 들어맞는다.

의존관계 검색과 주입

스프링이 제공하는 IoC 방법에는 의존관계 주입만 있는 것이 아니다.

의존관계를 맺는 방법이 외부로부터의 주입이 아니라 스스로 검색을 이용하기 때문에 의존관계 검색이라고 불리는 것도 있다. 의존관계 검색은 자신이 필요로 하는 의존 오브젝트를 능동적으로 찾는다.

의존관계 검색은 런타임 시 의존관계를 맺을 오브젝트를 결정하는 것과 오브젝트의 생성작업은 외부 컨테이너에게 IoC로 맡기지만, 이를 가져올 때는 메소드나 생성자를 통한 주입 대신 스스로 컨테이너에게 요청하는 방법을 사용한다.

스프링의 IoC와 DI 컨테이너를 적용했다고 하더라도 애플리케이션의 기동 시점에서 적어도 한 번은 의존관계 검색 방식을 사용해 오브젝트를 가져와야 한다. static 메소드인 main()에서는 DI를 이용해 오브젝트를 주입받을 방법이 없기 때문이다.

의존관계 검색(DL)과 의존관계 주입을 적용할 때 발견할 수 있는 중요한 차이점이 하나 있다. 의존관계 검색 방식에서는 검색하는 오브젝트는 자신이 스프링의 빈일 필요가 없다.

반면에 의존관계 주입에서는 오브젝트 사이에 DI가 적용되려면 반드시 두 오브젝트 모두 컨테이너가 만드는 빈 오브젝트여야 한다.

참고

토비의 스프링 3.1 Vol. 1 스프링의 이해와 원리

Spring - MVC

DispatcherServlet 이란?

Spring MVC는 DispatcherServlet의 등장으로 web.xml의 역할이 축소되었습니다. 이전에는 서블릿을 URL로 활용하기 위해서는 반드시 web.xml에 등록해야 했지만, 이제는 DispatcherServlet이 해당 어플리케이션으로 들어오는 요청을 모두 핸들링 해주기 때문입니다.

web.xml의 역할이 축소되었지만, <servlet>으로 DispatcherServlet을 등록해줘야 하며, 이 객체의 URL 적용범위 또한 web.xml에 설정해야 합니다. 또한 encoding과 관련된 <filter><listener>를 등록하기 위해서 web.xml은 필요합니다.

그러나 web.xml에서 중요하게 사용되었던 <servlet> 매핑은 이제 DispatcherServlet이 대신 맡아서 처리하게 되었습니다. web.xml에 DispatcherServlet의 을 ‘/‘로 설정함으로써 동시에 이제 모든 요청은 DispatcherServlet으로 전달됩니다. 물론 DispatcherServlet을 web.xml에 등록해도 계속 서블릿을 web.xml에 매핑해서 사용할 수 있지만, 이런 옛 방식을 버리고 DispatcherServlet을 이용해 웹 개발을 한다면 앞으로 서블릿 파일을 만들 필요도 없어지고 동시에 놀라운 @MVC의 혜택을 얻을 수 있습니다.

DispatcherServlet을 이용한다는 것은 스프링에서 제공하는 @MVC를 이용하겠단 뜻입니다. @MVC는 그동안 추상적으로 알아오고 발전했던 MVC(Model, View, Controller) 설계 영역을 노골적으로 분할하여 사용자가 무조건 MVC로 어플리케이션을 설계하게끔 유도하는 방식입니다. 즉, @MVC를 이용해 어플리케이션을 개발한다면 MVC 설계의 원칙대로 웹 어플리케이션을 제작할 수 있게 된다는 뜻입니다.

그럼 간단하게 DispatcherServlet이 담당하는 역할이 무엇인지 알아봅시다. 먼저 DispatcherServlet에 대해 간단히 정의해보자면, 각각 분리하여 만든 Model, View, Controller를 조합하여 브라우저로 출력해주는 역할을 수행합니다.

Spring MVC 구조

등장 요소

  • DispatcherServlet : 프런트 컨트롤러 담당, 모든 HTTP 요청을 받아들여 그 밖의 오브젝트 사이의 흐름을 제어, 기본적으로 스프링 MVC의 DispatcherServlet 클래스를 그대로 적용
  • HandlerMapping : 클라이언트의 요청을 바탕으로 어느 컨트롤러를 실행할지 결정
  • Model : 컨트롤러에서 뷰로 넘겨줄 오브젝트를 저장하기 위한 오브젝트, HttpServletRequest와 HttpSession처럼 String 형 키와 오브젝트를 연결해서 오브젝트를 유지
  • ViewResolver : View 이름을 바탕으로 View 오브젝트를 결정
  • View : 뷰에 화명 표시 처리를 의뢰
  • 비즈니스 로직 : 비즈니스 로직을 실행. 애플리케이션 개발자가 비즈니스 처리 사양에 맞게 작성
  • 컨트롤러(Controller) : 클라이언트 요청에 맞는 프레젠테이션 층의 애플리케이션 처리를 실행해야 함. 애플리케이션 개발자가 애플리케이션 처리 사양에 맞게 작성
  • 뷰 / JSP 등 : 클라이언트에 대해 화면 표시 처리. 자바에서는 JSP 등으로 작성하는 일이 많으며, 애플리케이션 개발자가 화면의 사양에 맞게 작성

동작 순서

  1. DispatcherServlet은 브라우저로부터 요청을 받아들입니다.
  2. DispatcherServlet은 요청된 URL을 HandlerMapping 오브젝트에 넘기고 호출 대상의 컨트롤러 오브젝트를 얻어 URL에 해당하는 메서드를 실행합니다.
  3. 컨트롤러 오브젝트는 비즈니스 로직으로 처리를 실행하고, 그 결과를 바탕으로 뷰에 전달할 오브젝트를 Model 오브젝트에 저장합니다. 끝으로 컨트롤러 오브젝트는 처리 결과에 맞는 View 이름을 반환합니다.
  4. DispatcherServlet은 컨트롤러에서 반환된 View 이름을 ViewResolver에 전달해서 View 오브젝트를 얻습니다.
  5. DispatcherServlet은 View 오브젝트에 화면 표시를 의뢰합니다.
  6. View 오브젝트는 해당하는 뷰를 호출해서 화면 표시를 의뢰합니다.
  7. 뷰는 Model 오브젝트에서 화면 표시에 필요한 오브젝트를 가져와 화면 표시 처리를 실행합니다.

참고