토비의 스프링 정리
토비의 스프링 - 7.5 DI를 이용해 다양한 구현 방법 적용하기
ksb-dev
2022. 10. 25. 23:38
7.5.1 ConcurrentHashMap을 이용한 수정 가능 SQL 레지스트리
- 동시 접속자가 많은 대형 시스템의 DAO라면 수시로 접근하는 SQL 레지스트리 정보를 잘못 접근하면 깨진 SQL이 나타날 수 있음
- 지금까지 디폴트로 써 왔던 HashMapRegistry는 JDK의 HashMap을 사용함
- HashMap은 멀티스레드 환경의 동시성에 문제가 있음
- 멀티스레드 환경에서 HashMap의 동시성을 위해 Collections.synchronizedMap() 등을 이용해 동기화를 하면 고성능 서비스 성능에 많은 문제가 발생함
- 때문에, 동기화된 해시 데이터 조작에 최적화된 ConcurrentHashMap을 사용하도록 권장됨
7.5.2 수정 가능 SQL 레지스트리
- ConcurrentHashMap을 이용해 UpdatableSqlRegistry를 구현할 것임
- SQL을 변경하는 기능을 검증하는 것은 기존의 UserDaoTest로는 불가능함
- 별도의 SQL 조회 및 수정 기능을 검증하는 단위 테스트를 만들어해서 해야함
//ConcurrentHashMapSqlRegistryTest.class
public class ConcurrentHashMapSqlRegistryTest {
UpdatableSqlRegistry sqlRegistry;
@Before
public void setUp(){
sqlRegistry = new ConcurrentHashMapSqlRegistry();
//사용할 정보 미리 등록
sqlRegistry.registrySql("KEY1", "SQL1");
sqlRegistry.registrySql("KEY2", "SQL2");
sqlRegistry.registrySql("KEY3", "SQL3");
}
@Test
public void find(){
checkFindResult("SQL1", "SQL2", "SQL3");
}
private void checkFindResult(String expected1, String expected2, String expected3) {
assertThat(sqlRegistry.findSql("KEY1"), is(expected1));
assertThat(sqlRegistry.findSql("KEY2"), is(expected2));
assertThat(sqlRegistry.findSql("KEY3"), is(expected3));
}
@Test(expected = SqlNotFoundException.class)
public void unknownKey(){
sqlRegistry.findSql("SQLERROR");
}
@Test
public void updateSingle(){
sqlRegistry.updateSql("KEY2", "modify2");
checkFindResult("SQL1", "modify2", "SQL3");
}
@Test
public void updateMulti(){
Map<String, String> sqlmap = new HashMap<>();
sqlmap.put("KEY1", "modify1");
sqlmap.put("KEY3", "modify3");
sqlRegistry.updateSql(sqlmap);
checkFindResult("modify1", "SQL2", "modify3");
}
@Test(expected = SqlUpdateFailureException.class)
public void updateWithNotExistingKey(){
sqlRegistry.updateSql("SQLERROR", "modify2");
}
}
//ConcurrentHashMapSqlRegistry.class
public class ConcurrentHashMapSqlRegistry implements UpdatableSqlRegistry {
private Map<String, String> sqlMap = new ConcurrentHashMap<>();
@Override
public void registrySql(String key, String sql) {
sqlMap.put(key, sql);
}
@Override
public String findSql(String key) throws SqlRetrievalFailException {
String sql = sqlMap.get(key);
if (sql == null)
throw new SqlNotFoundException(key +
"에 대한 SQL을 찾을 수 없습니다.");
else
return sql;
}
@Override
public void updateSql(String key, String sql) throws SqlUpdateFailureException{
if(sqlMap.get(key) == null){
throw new SqlUpdateFailureException(key +
"에 대한 SQL을 찾을 수 없습니다.");
}
sqlMap.put(key, sql);
}
@Override
public void updateSql(Map<String, String> sqlmap)
throws SqlUpdateFailureException{
for(Map.Entry<String, String> entry : sqlmap.entrySet()){
updateSql(entry.getKey(), entry.getValue());
}
}
}
<beans>
<bean id="sqlService" class="com.ksb.spring.OxmSqlService">
<property name="unmarshaller" ref="unmarshaller"/>
<property name="sqlmap" value="classpath:/sqlmap.xml"/>
<!--디폴트인 HashMapSqlRegistry 대신 사용 할 레지스트리 등록-->
<property name="sqlRegistry" ref="sqlRegistry"/>
</bean>
<bean id="sqlRegistry" class="com.ksb.spring.ConcurrentHashMapSqlRegistry">
</bean>
</beans>
7.5.3 내장형 데이터베이스를 이용한 SQL 레지스트리 만들기
- ConcurrentHashMap 대신 내장형 DB(Embedded DB)를 이용할 것임
- ConcurrentHashMap는 데이터가 많아지고, 조회 및 수정이 빈번히 발생하면 성능의 한계가 있음
- SQL 때문에 새로운 외부 DB에 사용하기에는 많은 부담이 있기 때문에 내장형 DB를 사용하는 것이 좋음
- 내장형 DB는 애플리케이션과 함께 시작되고 종료되는 DB이기 때문에, 데이터는 메모리에 저장됨
- 내장형 DB는 컬렉션에 비해 동시성 및 안정적인 CRUD를 할 수 있음
- 최적화된 락킹, 격리수준, 트랜잭션을 적용할 수도 있음
- 자바에서 사용되는 대표적 데이터베이스는 세 가지가 있음
- Derby
- HSQL
- H2
- 세 가지 데이터베이스는 JDBC 드라이버를 제공하고, 표준 DB와 호환되는 기능을 제공하기 때문에 JDBC 프로그래밍 모델을 그대로 사용할 수 있음
- 하지만, 내장형 DB는 애플리케이션 생명주기와 같이하기 때문에, 애플리케이션 내에서 DB를 가동시키고 초기화하는 SQL 스크립트 등의 초기화 작업이 필요함
- 이러한 이유 때문에 JDBC 프로그래밍 모델을 사용하는 것은 적절하지 않음
- 스프링은 내장형 DB를 손쉽게 사용하도록 내장형 DB 지원 기능을 제공하고 있음
- 스프링은 내장형 DB를 초기화하는 작업을 지원하는 내장형 DB 빌더를 제공함
- 내장형 DB 빌더는 드라이버 초기화 및 테이블 생성과 데이터를 삽입하는 SQL 실행을함
- 내장형 DB 빌더가 작업을 마치면 DataSource 오브젝트를 반환함
- 정확히는, 스프링은 애플리케이션 내부에서 DB 종료 요청을 할 수 있는 DataSource를 상속한 EmbeddedDatabase 인터페이스를 제공함
- 종료 요청은 shutdown() 메소드를 통해 할 수 있음
7.5.4 내장형 DB 빌더 학습 테스트
- 내장형 DB 지원 기능에 대한 학습 테스트임
- 내장형 DB는 애플리케이션을 통해 테이블을 매번 생성하기 때문에 생성 SQL 스크립트가 필요함
- 해당 생성 SQL 스크립트 파일 이름을 schema.sql이라 지정함
- 또한, 초기 데이터 등록을 위한 SQL 문도 추가
testImplementation group: 'hsqldb', name: 'hsqldb', version: '1.8.0.7'
-- schema.sql
CREATE TABLE SQLMAP(
KEY_ VARCHAR(100) PRIMARY KEY,
SQL_ VARCHAR(100) NOT NULL
);
--data.sql
INSERT INTO SQLMAP(KEY_, SQL_) VALUE('KEY1', 'SQL1');
INSERT INTO SQLMAP(KEY_, SQL_) VALUE('KEY2', 'SQL2');
import static org.springframework.jdbc.datasource.embedded.EmbeddedDatabaseType.HSQL;
...
public class EmbeddedDbTest {
EmbeddedDatabase db;
NamedParameterJdbcTemplate template;
@Before
public void setUp(){
db= new EmbeddedDatabaseBuilder()
.setType(HSQL)
.addScript("/schema.sql")
.addScript("/data.sql")
.build();
template = new NamedParameterJdbcTemplate(db);
}
@After
public void tearDown(){
db.shutdown();
}
@Test
public void initData(){
String sql = "select count(*) from sqlmap";
Map<String, String> params = Collections.singletonMap(":null", "null");
// 두 번째 값은 쓰레기 값임
assertThat(template.queryForObject(sql, params,Integer.class), is(2));
sql = "select * from sqlmap order by key_";
params = Collections.singletonMap(":key_", "key_");
List<Map<String, Object>> list = template.queryForList(sql, params);
assertThat((String)list.get(0).get("key_"), is("KEY1"));
assertThat((String)list.get(0).get("sql_"), is("SQL1"));
assertThat((String)list.get(1).get("key_"), is("KEY2"));
assertThat((String)list.get(1).get("sql_"), is("SQL2"));
}
@Test
public void insert(){
String sql = "insert into sqlmap(key_, sql_) values(:key_, :sql_)";
Map<String,String> params = new HashMap<>();
params.put("key_", "KEY3");
params.put("sql_", "SQL3");
template.update(sql, params);
sql = "select count(*) from sqlmap";
params = Collections.singletonMap(":null", "null");
assertThat(template.queryForObject(sql, params,Integer.class), is(3));
}
}
💡 책에 나온 SimpleJdbcTemplate는 Deprecated 되었기 때문에, NamedParameterJdbcTemplate를 사용했음
7.5.5 내장형 DB를 이용한 SqlRegistry 만들기
- 스프링에서 간단히 내장형 DB를 이용하려면 EmbeddedDatabaseBuilder를 사용하면 됨
- EmbeddedDatabaseBuilder는 초기화 작업이 동반되야 하기 때문에, 빈으로 등록한다고 바로 사용할 수 있는 것이 아님
- 초기화 코드가 필요하면 팩토리 빈으로 만드는 것이 좋음
- 💡 팩토리 빈은 XML을 통한 빈으로 등록하지 못하고, newInstance()처럼 스태틱 메소드로 오브젝트를 만들어야 하는 것을 스프링 빈으로 등록하게 해 주는 것임. [6.3 다이내믹 프록시와 팩토리빈 참고]
- 스프링에서 팩토리 빈을 만드는 번거로운 작업을 대신해주는 전용 태그가 있음
- 전용 태그는 jdbc 스키마에 정의되어 있음
- 💡 전용 태그는 7.5.8에서 확인
public class EmbeddedDbSqlRegistry implements UpdatableSqlRegistry{
NamedParameterJdbcTemplate jdbc;
/*
내장 DB 빌더가 Datasource의 서브 인스턴스인 EmbeddedDatebase를 반환 해도
Datasource로 인자를 받는 이유는, 인터페이스 분리 원칙을 지키기 위함임.
클라이언트는 자신이 필요한 기능을 가진 인터페이스를 DI 받아야함.
SQL 레지스트리는 JDBC를 이용해 DB에 접근만 하면 되므로 Datasource가 가장 적합함
*/
public void setDataSource(DataSource dataSource){
jdbc = new NamedParameterJdbcTemplate(dataSource);
}
@Override
public void registrySql(String key, String sql){
Map<String,String> params = new HashMap<>();
params.put("key_", key);
params.put("sql_", sql);
jdbc.update("insert into sqlmap(key_, sql_) values(:key_, :sql_)", params);
}
@Override
public String findSql(String key) throws SqlRetrievalFailException {
try{
Map<String, String> params = Collections.singletonMap("key_", key);
return jdbc.queryForObject("select sql_ from sqlmap where key_ = :key_",
params, String.class);
}catch (EmptyResultDataAccessException e){
throw new SqlNotFoundException(key + "에 해당하는 SQL을 찾을 수 없습니다.");
}
}
@Override
public void updateSql(String key, String sql) {
Map<String,String> params = new HashMap<>();
params.put("sql_", sql);
params.put("key_", key);
int affected = jdbc.update("update sqlmap set sql_ = :sql_ where key_ = :key_",
params);
if(affected == 0){
throw new SqlUpdateFailureException(key+"에 해당하는 SQL을 찾을 수 없습니다.");
}
}
@Override
public void updateSql(Map<String, String> sqlmap) {
for(Map.Entry<String, String> entry : sqlmap.entrySet()){
updateSql(entry.getKey(), entry.getValue());
}
}
}
7.5.6 UpdatableSqlRegistry 테스트 코드의 재사용
- EmbeddedDbSqlRegistry도 검증을 해야 함
- 근데, 이전에 만든 ConcurrentHashMapSqlRegistry의 테스트 코드와 대부분 중복됨
- 따라서, 테스트 코드 상속을 통해 공유할 수 있게 함
- ConcurrentHashMapSqlRegistryTest의 코드 중에서 ConcurrentHashMapSqlRegistry에 의존하는 부분은 한 줄임
public class ConcurrentHashMapSqlRegistryTest {
UpdatableSqlRegistry sqlRegistry;
@Before
public void setUp(){
sqlRegistry = new ConcurrentHashMapSqlRegistry(); //의존
...
}
}
- 나머지 코드는 UpdatableSqlRegistry 인터페이스에만 의존하고 있음
- 따라서 오브젝트 생성 부분만 분리를 하면 됨
- 또한, 오브젝트 생성 부분을 분리하면 UpdatableSqlRegistry 인터페이스를 구현한 모든 클래스의 테스트 코드를 작성할 수 있는 추상 테스트 클래스로 변경됨
- 때문에, ConcurrentHashMapSqlRegistryTest를 AbstractUpdateSqlRegistryTest로 변경
//AbstractUpdateSqlRegistryTest.class
public abstract class AbstractUpdateSqlRegistryTest {
UpdatableSqlRegistry sqlRegistry;
@Before
public void setUp(){
sqlRegistry = createUpdatableSqlRegistry();
...
}
abstract protected UpdatableSqlRegistry createUpdatableSqlRegistry();
//상속 가능하게 접근 지시자 변경
protected void checkFindResult(String expected1, String expected2, String expected3) {
...
}
...
}
//ConcurrentHashMapSqlRegistryTest.class
public class ConcurrentHashMapSqlRegistryTest extends AbstractUpdateSqlRegistryTest{
@Override
protected UpdatableSqlRegistry createUpdatableSqlRegistry() {
return new ConcurrentHashMapSqlRegistry();
}
}
7.5.7 EmbeddedDbSqlRegistryTest
- EmbeddedDbSqlRegistry에 대한 테스트 클래스
public class EmbeddedDbSqlRegistryTest extends AbstractUpdateSqlRegistryTest{
EmbeddedDatabase db;
@Override
protected UpdatableSqlRegistry createUpdatableSqlRegistry() {
db = new EmbeddedDatabaseBuilder()
.setType(HSQL)
.addScript("/schema.sql")
.build();
EmbeddedDbSqlRegistry embeddedDbSqlRegistry = new EmbeddedDbSqlRegistry();
embeddedDbSqlRegistry.setDataSource(db);
return embeddedDbSqlRegistry;
}
@After
public void tearDown(){
db.shutdown();
}
}
7.5.8 XML 설정을 통한 내장형 DB 생성과 적용
- jdbc 스키마 전용 태그를 사용한 EmbeddedDbSqlRegistry 적용
- jdbc 전용 태그에 의해 만들어지는 EmbeddedDatabase 타입 빈은 컨테이너가 종료될 때, 자동으로 shutdown() 메소드가 호출됨
- UserDaoTest가 성공되야 함
<beans ...
xmlns:jdbc="http://www.springframework.org/schema/jdbc"
xsi:schemaLocation="http://www.springframework.org/schema/jdbc
http://www.springframework.org/schema/jdbc/spring-jdbc-3.0.xsd">
...
<jdbc:embedded-database id="embeddedDatabase" type="HSQL">
<jdbc:script location="schema.sql"/>
</jdbc:embedded-database>
<bean id="sqlRegistry" class="com.ksb.spring.EmbeddedDbSqlRegistry">
<property name="dataSource" ref="embeddedDatabase"/>
</bean>
</beans>
7.5.9 트랜잭션 적용
- 내장형 DB는 안전하게 SQL을 수정하도록 보장해줌
- 하지만, 트랜잭션 적용을 하지 않아 SQL 수정 도중 에러가 발생하면, 수정 성공한 것만 적용되어 큰 문제를 발생할 수 있음
- 때문에 트랜잭션을 적용해야 함
- 스프링에서 트랜잭션을 적용할 때, 트랜잭션 경계가 DAO 밖에 있고, 범위가 넓은 경우 AOP를 이용하는 것이 편리함
- 하지만, SQL 레지스트리라는 제한된 오브젝트 내에서 특화되고 간단한 트랜잭션이 필요한 경우 트랜잭션 API를 직접 이용하는 것이 좋음
7.5.10 다중 SQL 수정에 대한 트랜잭션 테스트
- 트랜잭션의 적용은 수동 테스트 따위로 검증하기 매우 어려움
- 트랜잭션 도중에 강제로 에러를 발생하기는 매우 어렵기 때문임
- 그러므로 트랜잭션 적용이 성공하고 아니라면 실패하는 테스트 코드를 먼저 만들어야 함
- 이 테스트는 실패하도록 만드는 것이 목적임
public class EmbeddedDbSqlRegistryTest extends AbstractUpdateSqlRegistryTest{
...
@Test
public void transactionalUpdate(){
checkFindResult("SQL1", "SQL2", "SQL3");
Map<String,String> sqlmap = new HashMap<>();
sqlmap.put("KEY1", "modify1");
sqlmap.put("keyError", "modify2"); //키를 못찾기 때문에 실패할 것임
try{
sqlRegistry.updateSql(sqlmap);
}catch (SqlUpdateFailureException e){
}
//트랜잭션 적용 되면 롤백될 것이기 때문에 원래 상태로 돌아와야 함
//만약 트랜잭션 적용되지 않으면 첫 번째가 modify1으로 될 것임
//때문에 checkFindResult() 실패
checkFindResult("SQL1", "SQL2", "SQL3");
}
}
7.5.11 코드를 이용한 트랜잭션 적용
- PlatformTransactionManager를 직접 사용해서 트랜잭션 코드를 만드는 것 보다, 템플릿/콜백 패턴을 적용한 TransactionTemplate를 쓰는것이 좋음
- 트랜잭션 매니저를 공유할 필요가 없기 때문에 번거롭게 빈으로 만드는 대신, 직접 만듦
public class EmbeddedDbSqlRegistry implements UpdatableSqlRegistry {
//멀티스레드 환경에서 공유 가능
TransactionTemplate transactionTemplate;
...
public void setDataSource(DataSource dataSource) {
jdbc = new NamedParameterJdbcTemplate(dataSource);
transactionTemplate = new TransactionTemplate(
new DataSourceTransactionManager(dataSource)
);
}
//익명 내부 클래스에서 인자를 사용하기 때문에 final을 붙여야 함
@Override
public void updateSql(final Map<String, String> sqlmap) {
//트랜잭션 템플릿이 만드는 트랜잭션 경계 안에서 동작할 코드를 콜백 형태로 만들고
//TransactionTemplate의 execute() 메소드에 전달해야 함
transactionTemplate.execute(new TransactionCallbackWithoutResult() {
@Override
protected void doInTransactionWithoutResult(TransactionStatus status) {
for (Map.Entry<String, String> entry : sqlmap.entrySet()) {
updateSql(entry.getKey(), entry.getValue());
}
}
});
}
}