토비의 스프링 | 7장 스프링 핵심 기술의 응용 (SQL 쿼리문을 동적으로 변경해 보자)

주싱·2022년 11월 7일
0

토비의 스프링

목록 보기
25/30

토비의 스프링 ‘7장 스프링의 핵심 기술의 응용’을 읽고 정리합니다. SqlService에서 SQL 문을 동적으로 변경할 수 있는 기능 구현 과정에 집중합니다.

1. DI와 기능의 확장

미래를 프로그래밍하는 DI

  • DI를 적용하는 건 간단해 보이지만 DI의 가치를 제대로 누리기는 쉽지 않다.
  • DI에 필요한 유연하고 확장성이 뛰어난 오브젝트 설계를 하려면 많은 고민과 학습, 훈련, 경험이 필요하다. 객체지향 설계를 잘하는 방법은 다양하겠지만, 그중에서 추천하고 싶은 한 가지가 있다면 바로 DI를 의식하면서 설계하는 방식이다.
  • DI를 적용하려면 커다란 오브젝트 하나만 존재해서는 안 된다. 최소한 두 개 이상의, 의존관계를 가지고 서로 협력해서 일하는 오브젝트가 필요하다. 그래서 적절한 책임에 따라 오브젝트를 분리해줘야 한다. 그리고 항상 의존 오브젝트는 자유롭게 확장될 수 있다는 점을 염두에 둬야 한다.
  • 확장은 항상 미래에 일어나는 일이다. DI는 확장을 위해 필요한 것이므로 항상 미래에 일어날 변화를 예상하고 고민해야 적합한 설계가 가능해진다. DI란 결국 미래를 프로그래밍하는 것이다.

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

  • DI를 적용할 때는 가능한 한 인터페이스를 사용하게 해야 한다. DI를 DI답게 만들려면 두 개의 오브젝트가 인터페이스를 통해 느슨하게 연결돼야 한다.
  • 왜냐하면 첫 번째는 다형성을 얻기 위해서다. DI를 통해 의존 오브젝트가 가진 핵심 로직을 바꿔서 적용하는 것 외에도 프록시, 데코레이터, 어댑터, 테스트 대역 등의 다양한 목적을 위해 인터페이스를 통한 다형성이 활용된다.
  • 하지만 단지 DI 목적이 다형성을 편리하게 적용하는 것 때문만이라면 제약이 많고 불편한 점이 있다. 상속이 불가능한 final 클래스만 아니라면 상속을 통해서도 여러 가지 방식으로 구현을 확장할 수 있기 때문이다. 그럼에도 인터페이스를 사용해 DI를 하는 두 번째 이유는 인터페이스 분리 원칙 때문이다. 인터페이스 분리를 통해 클라이언트와 의존 오브젝트 사이의 관계를 명확하게 해 줄 수 있기 때문이다.
  • A와 B가 인터페이스로 연결되어 있다는 의미를 다르게 해석하면 A가 B를 바라볼 때 해당 인터페이스라는 창을 통해서만 본다는 뜻이다.
  • 하나의 오브젝트를 바라보는 창이 여러 가지일 수 있다.
  • 오브젝트가 그 자체로 충분히 응집도가 높은 작은 단위로 설계됐더라도, 목적과 관심이 각기 다른 클라이언트가 있다면 인터페이스를 통해 이를 적절하게 분리해줄 필요가 있고, 이를 객체지향 설계 원칙에서는 인터페이스 분리 원칙이라고 부른다. 인터페이스를 사용하지 않고 클래스를 직접 참조하는 방식으로 DI를 했다면, 인터페이스 분리 원칙과 같은 클라이언트에 특화된 의존관계를 만들어낼 방법 자체가 없을 것이다.
  • 분명한 이유가 있어서 인터페이스를 사용하지 않는 경우가 없지는 않지만 단지 인터페이스를 추가하기가 귀찮아서 약간의 게으름을 부리고자 인터페이스를 생략한다면 이후의 개발, 디버깅, 테스트, 기능 추가, 변화 등에서 적지 않은 부담을 안게 될 것이다.

2. 인터페이스의 상속

문제

  • 기존에 구현한 BaseSqlService는 SqlRegistry 인터페이스를 구현하는 오브젝트에 의존하고 있다. 그런데 여기에 이미 등록된 SQL을 변경할 수 있는 기능을 넣어서 확장하고 싶다고 생각해보자.

구현개념

  • SQL 조회 서비스인 BaseSqlService를 사용하는 DAO 입장에서는 SQL을 업데이트하는 기능을 이용하는 클라이언트가 될 필요가 없다. 따라서 클라이언트의 목적과 용도에 적합한 인터페이스만을 제공한다는 인터페이스 분리 원칙을 지키기 위해서라도 이미 적용한 SqlRegistry는 건드리지 않고 새로운 인터페이스로 확장하는 방법을 사용한다.

소스코드

등록과 조회 기능만 가지는 인터페이스

public interface SqlRegistry {
    void registerSql(String key, String sql);
    String findSql(String key) throws SqlRetrievalFailureException;
}

SQL 수정 기능을 가진 확장 인터페이스

public interface UpdatableSqlRegistry extends SqlRegistry {
    void updateSql(String key, String sql) throws SqlRetrievalFailureException;
    void updateSql(Map<String, String> sqlmap) throws SqlRetrievalFailureException;
} 

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

문제

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

구현개념

  • Java 제공하는 ConcurrentHashMap을 사용해 UpdatableSqlRegistry 인터페이스를 구현한 ConcurrentHashMapSqlRegistry를 구현한다.

소스코드

테스트 코드

public class ConcurrentHashMapSqlRegistryTest {
    UpdatableSqlRegistry sqlRegistry;

    @Before
    public void setUp() {
        sqlRegistry = new ConcurrentHashMapSqlRegistry();
        sqlRegistry.registerSql("KEY1", "SQL1");
        sqlRegistry.registerSql("KEY2", "SQL2");
        sqlRegistry.registerSql("KEY3", "SQL3");
    }

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

    @Test(expected = SqlRetrievalFailureException.class)
    public void unknownKey() {
        sqlRegistry.findSql("SQL9999!@#$");
    }

    @Test
    public void updateSingle() {
        sqlRegistry.updateSql("KEY2", "Modified2");
        checkFind("SQL1", "Modified2", "SQL3");
    }

    @Test
    public void updateMulti() {
        Map<String, String> sqlMap = new HashMap<>();
        sqlMap.put("KEY1", "Modified1");
        sqlMap.put("KEY2", "Modified2");

        sqlRegistry.updateSql(sqlMap);
        checkFind("Modified1", "Modified2", "SQL3");
    }

    @Test(expected = SqlRetrievalFailureException.class)
    public void updateWithNotExistingKey() {
        sqlRegistry.updateSql("SQL9999!@#$", "Modified2");
    }

    private void checkFind(String expected1, String expected2, String expected3) {
        assertEquals(expected1, sqlRegistry.findSql("KEY1"));
        assertEquals(expected2, sqlRegistry.findSql("KEY2"));
        assertEquals(expected3, sqlRegistry.findSql("KEY3"));
    }
}

수정 가능한 SQL Registry

public class ConcurrentHashMapSqlRegistry implements UpdatableSqlRegistry {
    private final Map<String, String> sqlMap = new ConcurrentHashMap<>();

    @Override
    public void registerSql(String key, String sql) {
        sqlMap.put(key, sql);
    }

    @Override
    public String findSql(String key) throws SqlRetrievalFailureException {
        String sql = sqlMap.get(key);
        if (sql == null) {
            throw new SqlRetrievalFailureException(key + "에 대한 SQL을 찾을 수 없습니다");
        } else {
            return sql;
        }
    }

    @Override
    public void updateSql(String key, String sql) throws SqlRetrievalFailureException {
        if (sqlMap.get(key) == null) {
            throw new SqlRetrievalFailureException(key + "에 대한 SQL을 찾을 수 없습니다");
        }
        sqlMap.put(key, sql);
    }

    @Override
    public void updateSql(Map<String, String> sqlmap) throws SqlRetrievalFailureException {
        for (Map.Entry<String, String> entry : sqlmap.entrySet()) {
            updateSql(entry.getKey(), entry.getValue());
        }
    }
}

애플리케이선 설정정보

<bean id="sqlRegistry" class="sql.ConcurrentHashMapSqlRegistry"/>
<bean id="sqlService" class="sql.OxmSqlService">
  <property name="unmarshaller" ref="unmarshaller"/>
  <property name="sqlmap" value="classpath:/oxm/sqlmap.xml"/>
  <property name="sqlRegistry" ref="sqlRegistry"/>
</bean>

4. 내장형 DB를 이용한 수정 가능한 SQL 레지스트리

문제

  • ConcurrentHashMap이 멀티쓰레드 환경에서 최소한의 동시성을 보장해주고 성능도 그리 나쁜 편은 아니지만, 저장되는 데이터의 양이 많아지고 잦은 조회와 변경이 일어나는 환경이라면 한계가 있다.
  • 데이터를 컬렉션 객체에 담아서 사용한다면, 조건이 복잡한 검색 하나를 위해서도 꽤나 복잡한 자바 코드가 필요할 것이다. 루프와 if 문이 점철된 코드가 만들어질 것이고 테스트하기도 까다롭다.

구현개념

  • 인덱스를 이용한 최적화된 검색을 지원하고 동시에 많은 요청을 처리하면서 안정적인 변경 작업이 가능한 기술은 바로 데이터베이스다. 그러나 기껏 DAO가 사용할 SQL을 저장해두고 관리할 목적으로 별도의 DB를 구성하면 배보다 배꼽이 더 큰 일이 될 수도 있다. 이런 경우라면 DB의 장점과 특징은 그대로 갖고 있으면서도 애플리케이션 외부에 별도로 설치하고 셋업하는 번거로움은 없는 내장형 DB를 사용하는 것이 적당하다.
  • 애플리케이션에 내장된 DB와 SQL을 사용하면 조건이 복잡한 데이터를 효과적으로 분석하고 조작하기 용이하다.

소스코드

내장형 DB를 사용하는 SQL 레지스트리

public class EmbeddedDbSqlRegistry implements UpdatableSqlRegistry {
    private JdbcTemplate jdbcTemplate;

		// 인터페이스 분리 원칙을 위해 EmbeddedDatabase 타입이 아닌 상위 DataSource를 받는다.
    // EmbeddedDatabase 타입을 통해 DB 종료 기능을 제공해 줄 필요는 없다. 
    public void setDataSource(DataSource dataSource) {
        jdbcTemplate = new JdbcTemplate(dataSource);
    }

    @Override
    public void registerSql(String key, String sql) {
        jdbcTemplate.update("insert into SQLMAP(KEY_, SQL_) values(?, ?)", key, sql);
    }

    @Override
    public String findSql(String key) throws SqlRetrievalFailureException {
        try {
            return jdbcTemplate.queryForObject("select SQL_ from SQLMAP where KEY_= ?", String.class, key);
        } catch (EmptyResultDataAccessException ex) {
            throw new SqlRetrievalFailureException(key + "에 대한 SQL을 찾을 수 없습니다", ex);
        }
    }

    @Override
    public void updateSql(String key, String sql) throws SqlRetrievalFailureException {
        int affected = jdbcTemplate.update("update SQLMAP set SQL_ = ? where KEY_ = ?", sql, key);
        if (affected == 0 ) {
            throw new SqlRetrievalFailureException(key + "에 대한 SQL을 찾을 수 없습니다");
        }
    }

    @Override
    public void updateSql(Map<String, String> sqlmap) throws SqlRetrievalFailureException {
        for (Map.Entry<String, String> entry : sqlmap.entrySet()) {
            updateSql(entry.getKey(), entry.getValue());
        }
    }
}

UpdatableSqlRegistry 테스트 코드의 재활용

public abstract class AbstratUpdatableSqlRegistryTest {
    UpdatableSqlRegistry sqlRegistry;

    @Before
    public void setUp() {
        sqlRegistry = createUpdatableSqlRegistry();
        sqlRegistry.registerSql("KEY1", "SQL1");
        sqlRegistry.registerSql("KEY2", "SQL2");
        sqlRegistry.registerSql("KEY3", "SQL3");
    }

    protected abstract UpdatableSqlRegistry createUpdatableSqlRegistry();
		...
}

ConcurrentHashMapSqlRegistryTest의 재작성

public class ConcurrentHashMapSqlRegistryTest extends AbstratUpdatableSqlRegistryTest {
    @Override
    protected UpdatableSqlRegistry createUpdatableSqlRegistry() {
        return new ConcurrentHashMapSqlRegistry();
    }
}

EmbeddedDbSqlRegistryTest의 작성

public class EmbeddedDbSqlRegistryTest extends AbstratUpdatableSqlRegistryTest {
    EmbeddedDatabase db;
    @Override
    protected UpdatableSqlRegistry createUpdatableSqlRegistry() {
        db = new EmbeddedDatabaseBuilder()
                .setType(EmbeddedDatabaseType.H2)
                .addScript("classpath:/sql/sqlRegistrySchema.sql")
                .build();

        EmbeddedDbSqlRegistry embeddedDbSqlRegistry = new EmbeddedDbSqlRegistry();
        embeddedDbSqlRegistry.setDataSource(db);

        return embeddedDbSqlRegistry;
    }

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

애플리케이션 설정파일

<beans ...
       xmlns:jdbc="http://www.springframework.org/schema/jdbc"
			 ...
			 http://www.springframework.org/schema/jdbc 
			 http://www.springframework.org/schema/jdbc/spring-jdbc.xsd"
>
...
<bean id="sqlRegistry" class="sql.EmbeddedDbSqlRegistry">
  <property name="dataSource" ref="embeddedDatabase"/>
</bean>
... 
<jdbc:embedded-database id="embeddedDatabase" type="H2">
  <jdbc:script location="classpath:/sql/sqlRegistrySchema.sql"/>
</jdbc:embedded-database>

5. 트랜잭션 적용

문제

  • 운영 중인 시스템에서 한 번에 한 개 이상의 SQL을 동시에 수정하는 이유는 무엇일까? 아마도 SQL들이 서로 관련이 있기 때문이 아닐까 싶다. 비즈니스 로직이 급하게 변경됐다면, 그에 영향을 받는 SQL이 모두 변경돼야 하기 때문이다. 그런데 일부는 새로운 SQL이 적용되고 일부는 예외가 발생한 이후에 있는 SQL이어서 반영되지 않은 상태로 남는다면, 매우 위험한 결과를 초래할 수 있다.

구현개념

  • HashMap과 같은 컬렉션은 트랜잭션 개념을 적용하기가 매우 힘들다. 반면에 내장형 DB를 사용하는 경우에는 트랜잭션 적용이 상대적으로 쉽다. DB 자체가 기본적으로 트랜잭션 기반의 작업에 충실하게 설계됐기 때문이다. 조금 번거로운 설정이 뒤따름에도 내장형 DB를 도입한 이유는 바로 이런 트랜잭션과 같은 안전한 수정 작업이 가능하기 때문이다.
  • SQL 레지스트리라는 제한된 오브젝트 내에서 서비스에 특화된 간단한 트랜잭션이 필요한 경우라면 AOP와 같이 거창한 방법보다는 간단히 트랜잭션 추상화 API를 직접 사용하는 게 편리할 것이다.

소스코드

실패하는 트랜잭션 테스트 코드

public void transactionalUpdate() {
    checkFind("SQL1", "SQL2", "SQL3");

    Map<String, String> sqlmap = new HashMap<>();
    sqlmap.put("KEY1", "Modifeid1");
    sqlmap.put("KEY9999!@#$", "Modifeid9999"); // 업데이트에 실패하는 케이스 만들기

    try {
        sqlRegistry.updateSql(sqlmap);
        fail();
    } catch (SqlRetrievalFailureException exception) {}
    checkFind("SQL1", "SQL2", "SQL3");
}

테스트를 성공시키는 트랜잭션 코드 추가

public class EmbeddedDbSqlRegistry implements UpdatableSqlRegistry {
    private JdbcTemplate jdbcTemplate;
    private TransactionTemplate transactionTemplate;

    public void setDataSource(DataSource dataSource) {
        jdbcTemplate = new JdbcTemplate(dataSource);
        transactionTemplate = new TransactionTemplate(
                new DataSourceTransactionManager(dataSource));
    }

    ...
		@Override
    public void updateSql(Map<String, String> sqlmap) throws SqlRetrievalFailureException {
        transactionTemplate.execute(new TransactionCallbackWithoutResult() {
            @Override
            protected void doInTransactionWithoutResult(TransactionStatus status) {
                for (Map.Entry<String, String> entry : sqlmap.entrySet()) {
                    updateSql(entry.getKey(), entry.getValue());
                }
            }
        });
    }
}
profile
소프트웨어 엔지니어, 일상

0개의 댓글