이 글은 “토비의 스프링” 책 내용을 정리한 글입니다.

만약 저작권 관련 문제가 있다면 “gunjuko92@gmail.com”로 메일을 보내주시면, 바로 삭제하도록 하겠습니다.

토비의 스프링 - 스프링 핵심 기술의 응용 2

4. 인터페이스 상속을 통한 안전한 기능확장

애플리케이션을 새로 시작하지 않고 특정 SQL의 내용만을 변경하고 싶다면 어떻게 해야 할지 생각해보자. 기능의 변경 또는 추가에 대응하는 방법은 상황이나 조건에 따라 달라질 수 있다. 여기서는 기존에 설계하고 개발했던 기능이 발전돼야 할 경우에 스프링 답게 접근하는 방법이 무엇인지를 살펴볼 것이다.

4.1 DI와 기능의 확장

스프링과 같은 DI 프레임워크를 적용하고 빈 설정파일을 이용해 애플리케이션을 구성했다고 해서 DI를 바르게 활용하고 있다고 볼 수는 없다. DI의 가치를 제대로 얻으려면 먼저 DI에 적합한 오브젝트 설계가 필요하다.

DI를 의식하는 설계

  • DI는 오브젝트들이 서로의 세부적인 구현에 얽매이지 않고 유연한 방식으로 의존관계를 맺으며 독립적으로 발전할 수 있게 해준다. 결국 유연하고 확장 가능한 좋은 오브젝트 설계와 DI 프로그래밍 모델은 서로 상승작용을 한다.
  • DI의 가치를 제대로 누리기가 쉽진 않다. DI에 필요한 유연하고 확장성이 뛰어난 오브젝트 설계를 하려면 많은 고민과 학습, 훈련, 경험이 필요하다.
  • 객체지향 설계를 잘하는 방법 중 하나는 바로 DI를 의식하면서 설계하는 방식이다.
  • DI는 런타임시에 의존 오브젝트를 다이내믹하게 연결해줘서 유연한 확장을 꾀하는게 목적이기 때문에 항상 확장을 염두에 두고 오브젝트 사이의 관계를 생각해야 한다.
  • DI는 확장을 위해 필요한 것이므로 항상 미래에 일어날 변화를 예상하고 고민해야 적합한 설계가 가능해진다. DI란 결국 미래를 프로그래밍하는 것이다.

DI와 인터페이스 프로그래밍

  • DI를 적용할 때는 가능한 한 인터페이스를 사용하게 해야 한다.
    • 첫 번째 이유 : 다형성을 얻기 위해서다.
    • 두 번째 이유 : 인터페이스 분리 원칙을 통해 클라이언트와 의존 오브젝트 사이의 관계를 명확하게 해줄 수 있기 때문이다. 클라이언트는 인터페이스라는 창을 통해 클라이언트를 바라본다.
    • 인터페이스를 클라이언트의 종류에 따라 적절하게 분리해서 오브젝트가 구현하게 하면 매우 유용하다.
  • 인터페이스 분리 원칙 : 오브젝트가 그 자체로 충분히 응집도가 높은 작은 단위로 설계됐더라도, 목적과 관심이 각기 다른 클라이언트가 있다면 인터페이스를 통해 이를 적절하게 분리해줄 필요가 있다.
  • 인터페이스를 사용하지 않고 클래스를 직접 참조하는 방식으로 DI를 했다면, 인터페이스 분리 원칙과 같은 클라이언트에 특화된 의존관계를 만들어낼 방법 자체가 없는 것이다.

DI는 특별한 이유가 없는 한 항상 인터페이스를 사용한다고 기억해두자.

4.2 인터페이스 상속

  • 인터페이스 분리 원칙이 주는 장점은 모든 클라이언트가 자신의 관심에 따른 접근 방식을 불필요한 간섭 없이 유지할 수 있다는 점이다.
  • BaseSqlService는 SqlReader와 SqlRegistry라는 두 개의 인터페이스를 통해 의존 오브젝트들을 DI 하도록 되어 있다. 아래 그림은 인터페이스와 DI가 적용된 전형적인 구조를 보여준다.

인터페이스분리

  • 인터페이스를 사용해 DI 관계를 만들었기 때문에 얻을 수 있는 또 다른 장점은 SqlRegistry의 구현 클래스인 MySqlRegistry의 오브젝트가 또 다른 제3의 클라이언트를 위한 인터페이스를 가질 수 있다는 점이다.
public interface SqlRegistry {
    void registerSql(String key, String vale);

    String findSql(String key) throws SqlNotFoundException;
}
  • 이미 등록된 SQL을 변경할 수 있는 기능을 넣어서 확장하고 싶다고 생각해보자. SqlRegistry 인터페이스를 사용하는 BaseSqlService가 존재하기 때문에, SqlRegistry에 이 기능을 추가하는건 좋지 않다.
  • BaseSqlService 입장에서 SQL을 업데이트하는 기능을 이용하는 클라이언트가 될 이유가 없다. 따라서 클라이언트의 목적과 용도에 적합한 인터페이스만을 제공한다는 인터페이스 분리 원칙을 지키기 위해서라도 이미 적용한 SqlRegistry는 건드리면 안된다.
  • 대신 새롭게 추가할 기능을 사용하는 클라이언트를 위해 새로운 인터페이스를 정의하거나 기존 인터페이스를 확장하는게 바람직하다.
  • 새로운 클라이언트를 위한 인터페이스는 SqlRegistry 인터페이스의 기능과 함께 새로운 SQL 수정 기능도 갖고 있어야 한다. 그러기 위해서는 기존의 SqlRegistry 인터페이스를 상속하고 메소드를 추가해서 아래와 같이 새로운 인터페이스로 정의돼야 한다.
public interface UpdatableSqlRegistry extends SqlRegistry {
    void updateSql(String key, String value) throws SqlUpdateFailureException;
    void updateSql(Map<String, String> sqls) throws SqlUpdateFailureException;
}
  • SQL 변경 요청을 담당하는 SQL 관리용 오브젝트가 있다고 하고, 클래스 이름을 SqlAdminService라고 하자. 그렇다면 SqlAdminService는 UpdatableSqlRegistry라는 인터페이스를 통해 SQL 레지스트리 오브젝트에 접근해야 한다.
  • 새로운 SQL 업데이트 기능까지 구현한 SQL 레지스트리 클래스를 MyUpdatableSqlRegistry하자. DI의 결과만 보자면 BaseSqlService와 SqlAdminService 오브젝트는 동일한 MyUpdatableSqlRegistry 오브젝트를 DI 받아서 사용한다.
  • 오브젝트의 의존관계를 보자면 DI를 통해 동일한 오브젝트에 의존하고 있지만 설계와 코드에서는 각각 SqlRegistry와 UpdatableSqlRegistry라는 인터페이스에 의존하고 있을 뿐이다.
  • BaseSqlService와 SqlAdminService는 동일한 오브젝트에 의존하고 있지만 각자의 관심과 필요에 따라서 다른 인터페이스를 통해 접근한다. 인터페이스를 사용하는 DI이기 때문에 가능한 일이다.
  • 중요한 것은 클라이언트가 정말 필요한 기능을 가진 인터페이스를 통해 오브젝트에 접근하도록 만들었는가이다.

잘 적용된 DI는 결국 잘 설계된 오브젝트 의존관계에 달려 있다. 인터페이스를 적절하게 분리하고 확장하는 방법을 통해 오브젝트 사이의 의존관계를 명확하게 해주고, 기존 의존관계에 영향을 주지 않으면서 유연한 확장성을 얻는 방법이 무엇인지 항상 고민해야 한다. 다시 말하지만, DI와 객체지향 설계는 서로 밀접한 관계를 맺고 있다.

5. DI를 이용해 다양한 구현 방법 적용하기

  • 운영 중인 시스템에서 사용하는 정보를 실시간으로 변경하는 작업을 만들 때 가장 먼저 고려해야 할 사항은 동시성 문 제다.

5.1 ConcurrentHashMap을 이용한 수정 가능 SQL 레지스트리

  • 멀티스레드 환경에서 안전하게 HashMap을 조작하려면 Collections.synchronizedMap() 등을 이용해 외부에서 동기화해줘야한다. 하지만 이렇게 HashMap에 대한 전 작업을 동기화하면 요청이 많은 고성능 서비스에서는 성능에 문제가 생긴다.
  • 동기화된 해시 데이터 조작에 최적화되도록 만들어진 ConcurrentHashMap을 사용하는 방법이 일반적으로 권장된다.

수정 가능 SQL 레지스트리 테스트

  • 기능을 검증할 만한 단위 테스트를 먼저 만들어보자.
public class ConcurrentHashMapSqlRegistryTest {

    UpdatableSqlRegistry updatableSqlRegistry;

    @Before
    public void setUp() throws Exception {
        updatableSqlRegistry = new ConcurrentHashMapSqlRegistry();

        updatableSqlRegistry.registerSql("KEY1", "SQL1");
        updatableSqlRegistry.registerSql("KEY2", "SQL2");
        updatableSqlRegistry.registerSql("KEY3", "SQL3");
    }

    @Test
    public void find() {
        assertSqls("SQL1", "SQL2", "SQL3");
    }

    @Test
    public void should_throwSqlNotFoundException_when_cannot_find_sql() {
        Throwable t = Assertions.catchThrowable(() -> updatableSqlRegistry.findSql("SQL4"));
        assertThat(t).isInstanceOf(SqlNotFoundException.class);
    }

    @Test
    public void updateSingle() {
        updatableSqlRegistry.updateSql("KEY1", "MODIFIED");
        assertSqls("MODIFIED", "SQL2", "SQL3");
    }

    @Test
    public void updateMulti() {
        Map<String, String> updateSqls = new HashMap<>();
        updateSqls.put("KEY1", "MODIFIED");
        updateSqls.put("KEY3", "MODIFIED");

        updatableSqlRegistry.updateSql(updateSqls);

        assertSqls("MODIFIED", "SQL2", "MODIFIED");
    }

    @Test
    public void updateWithoutExistingKey() {
        Throwable t = Assertions.catchThrowable(() -> updatableSqlRegistry.updateSql("KEY4", "MODIFIED"));
        assertThat(t).isInstanceOf(SqlUpdateFailureException.class);
    }

    private void assertSqls(String expected1, String expected2, String expected3) {
        assertFindResult("KEY1", expected1);
        assertFindResult("KEY2", expected2);
        assertFindResult("KEY3", expected3);
    }

    private void assertFindResult(String key, String expectedValue) {
        String actualValue = updatableSqlRegistry.findSql(key);
        assertThat(actualValue).isEqualTo(expectedValue);
    }
}
  • 테스트를 만들어놨으니 이제 테스트 조건을 모두 충족하는 ConcurrentHashMap을 작성한다.

수정 가능 SQl 레지스트리 구현

public class ConcurrentHashMapSqlRegistry implements UpdatableSqlRegistry {

    private final ConcurrentHashMap<String, String> sqls = new ConcurrentHashMap<>();

    @Override
    public void updateSql(String key, String newValue) throws SqlUpdateFailureException {
        String oldValue = sqls.get(key);
        if (oldValue == null || oldValue.equals(newValue)) {
            throw new SqlUpdateFailureException();
        }
        sqls.put(key, newValue);
    }

    @Override
    public void updateSql(Map<String, String> sqls) throws SqlUpdateFailureException {
        sqls.forEach(this::updateSql);
    }

    @Override
    public void registerSql(String key, String value) {
        sqls.put(key, value);
    }

    @Override
    public String findSql(String key) throws SqlNotFoundException {
        String value = sqls.get(key);
        if (value == null) {
            throw new SqlNotFoundException();
        }
        return value;
    }
}
  • ConcurrentHashMapSqlRegistry 구현 클래스에 대한 단위 테스트를 만들어서 새로운 기능을 검증하긴 했지만 여타 오브젝트와 협력해서 작업할 때 고려해야 할 부분을 빼먹었을 수도 있으니, 통합 테스트를 이용해서 새로 만든 클래스의 빈이 다른 빈 오브젝트와 함께 사용될 때도 문제없이 동작하는지 반드시 확인해야 한다.

5.2 내장형 데이터베이스를 이용한 SQL 레지스트리 서비스 만들기

  • 인덱스를 이용한 최적화된 검색을 지원하고 동시에 많은 요청을 처리하면서 안정적인 변경 작업이 가능한 기술은 바로 데이터베이스다. 그래서 이번에는 SQL 레지스트리를 DB를 이용해 만들어보려고 한다.
  • 내장형 DB는 애플리케이션에 내장돼서 애플리케이션과 함께 시작되고 종료되는 DB를 말한다. 데이터는 메모리에 저장되기 때문에 IO로 인해 발생하는 부하가 적어서 성능이 뛰어나다.
  • 데이터를 내장형 DB의 테이블에 분산해서 넣은 후 SQL 문을 이용해 검색하고 조작하는 편이 훨씬 코드도 깔끔하고 활용하기도 편리하다. 복잡한 데이터를 효과적으로 분석하고 조작하는데는 관계형 DB와 SQL만 한 것이 없다

스프링의 내장형 DB 지원 기능

  • 자바에서 많이 사용되는 내장형 데이터베이스는 Derby, HSQL, H2를 꼽을 수가 있다
  • 애플리케이션과 그 생명주기를 같이하는 내장형 메모리 DB는 외부에서 DB를 생성하고 테이블을 만들고 초기화하는 작업을 하는 것이 아니라, 애플리케이션 내에서 DB를 기동시키고 초기화 SQL 스크립트 등을 실행시키는 등의 초기화 작업이 별도로 필요하다.
  • 스프링은 내장형 DB를 손쉽게 이용할 수 있도록 내장형 DB 지원 기능을 제공하고있다. 일종의 내장형 DB를 위한 서비스 추상화 기능이다.
  • 스프링은 내장형 DB를 초기화하는 작업을 지원하는 편리한 내장형 DB 빌더를 제공한다. 내장형 DB 인스턴스는 보통 고유한 JDBC 접속 URL을 통해 연결을 시도하면 JDBC 드라이버 내에서 이를 생성해준다.
  • 내장형 DB는 애플리케이션 안에서 직접 DB 종료를 요청할 수도 있어야 한다. 이를 위해 스프링은 DataSource 인터페이스를 상속해서 shutdown()이라는 내장형 DB용 메소드를 추가한 EmbeddedDatabase 인터페이스를 제공한다.
public interface EmbeddedDatabase extends DataSource {
	/**
	 * Shut down this embedded database.
	 */
	void shutdown();
}

내장형 DB 빌더 학습테스트

  • 내장형 DB는 애플리케이션을 통해 DB가 시작될 때마다 매번 테이블을 새롭게 생성한다. 따라서 지속적으로 사용 가능한 테이블 생성 SQL 스크립트를 준비해둬야 한다.
  • 애플리케이션에서 필요로 하는 초기 데이터가 있다면 이것도 SQL로 미리 준비해뒀다가 DB가 시작될 때 실행되게 해야한다.
CREATE TABLE sqlmaps(sqlid INT PRIMARY KEY AUTO_INCREMENT NOT NULL,
                     sql_key VARCHAR(100) NOT NULL,
                     sql_value VARCHAR(100) NOT NULL);
  • 애플리케이션에서 필요로 하는 초기 데이터가 있다면 이것도 SQL로 미리 준비해뒀다가 DB가 시작될 때 실행되게 해야한다.초기 데이터를 위한 SQL을 담은 data.sql을 준비한다.
INSERT INTO sqlmaps(sqlid, sql_key, sql_value) VALUES (1, 'KEY1', 'SQL1')
INSERT INTO sqlmaps(sqlid, sql_key, sql_value) VALUES (2, 'KEY2', 'SQL2')
  • 내장형 DB가 시작될 때 준비해둔 두 개의 스크립트가 실행돼야 한다. 내장형 DB 빌더는 DB 엔진을 생성하고 초기화 스크립트를 실행해서 태이블과 초기 데이터를 준비한 뒤에 DB에 접근할 수 있는 Connection을 생성해주는 DataSource 오브젝트를 돌려주게 된다. 정확히는 DB 셧다운 기능을 가진 EmbeddedDatabase 타입 오브젝트다.
  • 스프링이 제공하는 내장형 DB 빌더는 EmbeddedDatabaseBuilder다. 다음은 EmbeddedDatabaseBuilder를 사용하는 전형적인 방법을 보여준다.
new EmbeddedDatabaseBuilder()
    .setType(내장형 DB종류)
    .setScript(초기화에 사용할 DB 스크립트의 리소스)
    ...
    .build()
  • 비록 애플리케이션이 시작된 뒤에 메모리에만 만들어졌다가 사라지는 DB이지만 DataSource를 통해 DB 커넥션을 가져오는 것부터 JdbcTemplate을 활용하는 것까지 사용 방법은 일반 DB와 거의 동일하다.
  • 아래는 내장형 DB를 이용한 간단한 학습테스트이다.
public class EmbeddedDbTest {
    private EmbeddedDatabase db;
    private JdbcTemplate jdbcTemplate;

    @Before
    public void setUp() throws Exception {
        db = new EmbeddedDatabaseBuilder()
            .setType(EmbeddedDatabaseType.H2)
            .addScript(new ClassPathResource("schema.sql").getFilename())
            .addScript(new ClassPathResource("data.sql").getFilename())
            .build();
        jdbcTemplate = new JdbcTemplate(db);
    }

    @After
    public void tearDown() throws Exception {
        db.shutdown();
    }

    @Test
    public void initData() {
        assertThat(jdbcTemplate.queryForObject("select count(*) from sqlmaps", Integer.class)).isEqualTo(2);
    }

    @Test
    public void insert() {
        jdbcTemplate.update("INSERT INTO sqlmaps(sqlid, sql_key, sql_value) VALUES (?, ?, ?)", "0", "KEY3", "SQL3");
        assertThat(jdbcTemplate.queryForObject("select count(*) from sqlmaps", Integer.class)).isEqualTo(3);
    }
}

내장형 DB를 이용한 SqlRegistry 만들기

  • EmbeddedDatabaseBuilder는 직접 빈으로 등록한다고 바로 사용할 수 있는 게 아니다. 적절한 메소드를 호출해주는 초 기화 코드가 필요하다. 초기화 코드가 필요하다면 팩토리 빈으로 만드는 것이 좋다.
  • EmbeddedDatabaseBuilder 오브젝트는 한 번 초기화를 거쳐서 내장형 DB를 기동하고 이에 접근할 수 있는 EmbeddedDatabase를 만들어주면 그 이후로는 사용할 일은 없다. 따라서 EmbeddedDatabaseBuilder를 활용해서 EmbeddedDatabase 타입의 오브젝트를 생성해주는 팩토리 빈을 만들어야한다.
  • 스프링에는 팩토리 빈을 만드는 번거로운 작업을 대신해주는 전용 태그가 있다.
<jdbc:embedded-database id="embeddedDatabase" type='HSQL'> 
    <jdbc:script location='classpath:schema.sql'/>
</jdbc:embedded-database>
  • 위처럼 설정하면 embeddedDatabase 아이디를 가진 빈이 등록되며, 빈의 타입은 EmbeddedDatabase다.
  • 내장형 DB의 DataSource를 DI 받아서 UpdatableSqlRegistry를 구현한다.

UpdatableSqlRegistry 테스트 코드의 재사용

  • EmbeddedDbSqlRegistry도 간단한 구현이 아니므로 테스트를 만들어 검증해야 한다. ConcurrentHashMapSqlRegistryTest와 비슷하게 단위 테스트를 만드는 것이 좋다.
  • ConcurrentHashMapSqlRegistry와 EmbeddedDbSqlRegistry 둘 다 UpdatableSqlRegistry 인터페이스를 구현하고 있기 때문에 테스트 내용이 중복될 가능성이 높다.
  • 기존에 만들었던 ConcurrentHashMapSqlRegistryTest의 테스트 코드를 EmbeddedDbSqlRegistry를 위한 테스트가 공유히는 방법을 찾으면 좋을 것이다.
  • 구현된 코드를 공유하는 가장 쉬운 방법은 상속이다. JUnit4.x를 사용하는 테스트 클래스는 간단히 상속구조로 만들 수 있다.
  • UpdatableSqlRegistry 구현 클래스의 오브젝트를 생성하는 부분만 분리한다면, 나머지 테스트 코드는 모두 공유 가능하다. (생성하는 부분에서만 ConcurrentHashMapSqlRegistry에 의존하고 나머지 테스트 코드는 UpdatableSqlRegistry 인터페이스에만 의존하고 있기 때문)
  • 아래와 같이 바뀌는 부분을 별도의 메소드로 분리하고 아예 추상 메소드로 전환한다.
public abstract class AbstractUpdatableSqlRegistryTest {

    UpdatableSqlRegistry updatableSqlRegistry;

    @Before
    public void setUp() throws Exception {
        updatableSqlRegistry = createUpdatableSqlRegistry();

        updatableSqlRegistry.registerSql("KEY1", "SQL1");
        updatableSqlRegistry.registerSql("KEY2", "SQL2");
        updatableSqlRegistry.registerSql("KEY3", "SQL3");
    }

    protected abstract UpdatableSqlRegistry createUpdatableSqlRegistry();
    
    ...
}
public class ConcurrentHashMapSqlRegistryTest extends AbstractUpdatableSqlRegistryTest {

    @Override
    protected UpdatableSqlRegistry createUpdatableSqlRegistry() {
        return new ConcurrentHashMapSqlRegistry();
    }
}
  • ConcurrentHashMapSqlRegistryTest 클래스 안에는 @Test가 붙은 메소드가 하나도 보이지 않지만 슈퍼클래스인 AbstractUpdatableSqlRegistryTest의 @Test 테스트 메소드를 모두 상속받아서 자신의 테스트로 활용하게 된다.
  • EmbeddedDbSqlRegistry를 테스트 하려면 createUpdatableSqlRegistry() 메소드에서 EmbeddedDbSqlRegistry 객체를 리턴해주면 된다. EmbeddedDbSqlRegistryTest에서는 @After 애노테이션이 붙은 메소드에서 EmbeddedDatabase를 shutdown() 해줘야한다.

EmbeddedDatabase 타입 빈은 스프링 컨테이너가 종료될 때 자동으로 shutdown() 메소드가 호출되도록 설정되어 있 다. 따라서 내장형 DB를 종료시키기 위한 별도의 코드나 설정은 필요하지 않다.