-
7장) 7.4 인터페이스 상속을 통한 안전한 기능확장 ~ 7.5 DI를 이용해 다양한 구현 방법 적용하기Java & Spring/토비의 스프링 3.1 2021. 8. 20. 14:09반응형
7장 스프링 핵심 기술의 응용
7.4 인터페이스 상속을 통한 안전한 기능확장
- 애플리케이션을 새로 시작하지 않고 특정 SQL의 내용만을 변경하고 싶다면 어떻게 해야 할지 생각해보자
7.4.1 DI와 기능의 확장
- 지금까지 적용한 DI는 일종의 디자인 패턴 or 프로그래밍 모델이라는 관점에서 이해하는 것이 자연스러움
- 스프링과 같은 DI 프레임워크를 적용하고 빈 설정파일로 애플리케이션을 구성했다고 해서 DI를 바르게 활용하고 있다고 볼 수 없음
- DI의 장점은 DI에 적합한 오브젝트 설계가 요구됨
- DI를 의식하는 설계
- 다양한 기능 확장이 가능했던 이유
- SqlService 내부 기능을 적절한 책임과 역할에 따라 분리
- 인터페이스 정의로 느슨하게 연결
- DI를 통해 유연하게 의존관계를 지정
- DI 덕분에 오브젝트들이 서로 세부적인 구현에 얽매이지 않고 유연한 의존관계로 독립적으로 발전
- DI 적용 조건
- 최소한 두 개 이상의 의존관계를 가지고 서로 협력해서 일하는 오브젝트가 필요
- 적절한 책임에 따라 오브젝트를 분리
- DI는 런타임 시 의존 오브젝트를 다이내믹하게 연결해줘서 유연한 확장이 목적
- 다양한 기능 확장이 가능했던 이유
- DI와 인터페이스 프로그래밍
- 가능한 인터페이스를 사용하고, 두 개의 오브젝트가 인터페이스를 통해 느슨하게 연결
- 인터페이스 사용 이유
- 다형성을 얻기 위해
- 하나의 인터페이스를 통해 여러 개의 구현을 바꿔가면서 사용할 수 있게 하기 위함
- 인터페이스 분리 원칙을 통해 클라이언트와 의존 오브젝트 사이의 관계를 명확하게 해주기 위해
- 인터페이스 분리 원칙
- 오브젝트가 그 자체로 충분히 응집도가 높은 작은 단위로 설계됐더라도, 목적과 관심이 각기 다른 클라이언트가 있다면 인터페이스를 통해 이를 적절하게 분리
- ex) B오브젝트가 B1과 B2라는 인터페이스를 구현하고 있고, A 오브젝트가 B1을 통해 B 오브젝트를 사용하고 있고있는 경우
- A는 B1에만 관심이 있는데 B2 인터페이스의 메소드까지 모두 노출되어 B 클래스에 직접 의존할 이유가 없음
- 따라서 B1을 통해 B를 의존
- 인터페이스 분리 원칙
- 다형성을 얻기 위해
7.4.2 인터페이스 상속
- 오브젝트 기능이 발전하는 과정에서 다른 종류의 클라이언트가 등장하기 때문에, 하나의 오브젝트가 구현하는 인터페이스를 여러 개 만들어서 구분하여 사용
- 인터페이스를 여러 개 만드는 대신 기존 인터페이스를 상속하여 확장하는 방법이 가끔 사용됨
- 인터페이스 분리 원칙의 장점
- 모든 클라이언트가 자신의 관심에 따른 접근 방식을 불필요한 간섭 없이 유지
- 기존 클라이언트에 영향을 주지 않은 채로 오브젝트의 기능을 확장하거나 수정 가능
- 또 다른 제 3의 클라이언트를 위한 인터페이스를 가질 수 있음
- 앞서 작성한 코드를 보면 SqlRegistry의 구현 클래스인 MySqlRegistry의 오브젝트가 또 다른 제 3의 클라이언트를 위한 인터페이스를 가질 수 있음
- 모든 클라이언트가 자신의 관심에 따른 접근 방식을 불필요한 간섭 없이 유지
- SqlRegistry에 SQL을 변경하는 기능을 넣어 확장하기
- BaseSqlService 클래스와 그 서브클래스가 존재 ~> SqlRegistry 인터페이스 수정은 바람직하지 않음
- SQL 조회 서비스인 BaseSqlService 입장에서 SQL 업데이트 기능을 이용하는 클라이언트가 될 이유가 없음
- 기존의 SqlRegistry 인터페이스를 상속하고 메스드를 추가해서 새로운 인터페이스를 정의하기
-
public interface UpdatableSqlRegistry extends SqlRegistry { public void updateSql(String key, String sql) throws SqlUpdateFailureException; public void updateSql(Map<String, String> sqlmap) throws SqlUpdateFailureException; }
- SQL 변경 요청을 담당하는 SQL 관리용 오브젝트가 있다고 하고, 클래스 이름을 SqlAdminService로 정해 UpdatableSqlRegistry라는 인터페이스를 통해 SQL 레지스트리 오브젝트에 접근하게 하자
-
public class SqlAdminService implements AdminEventListner { private UpdatableSqlRegistry updatableSqlRegistry; public void setUpdatableSqlRegistry(UpdatableSqlRegistry updatableSqlRegistry) { this.updatableSqlRegistry = updatableSqlRegistry; } public void updateEventListener(UpdateEvent event) { this.updatableSqlRegistry.updateSql(event.get(KEY_ID), event.get(SQL_ID)); } }
-
<bean id="sqlService" class="springbook.user.sqlservice.BaseSqlService"> ... <property name="sqlRegistry" ref="sqlRegistry" /> </bean> <bean id="sqlRegistry" class="springbook.user.sqlservice.MyUpdatableSqlRegistry" /> <bean id="sqlAdminService" class="springbook.user.sqlservice.SqlAdminService"> <property name="updatableSqlRegistry" ref="sqlRegistry" /> </bean>
- BaseSqlService와 SqlAdminService는 동일한 오브젝트에 의존하지만, 각자의 관심과 필요에 따라 다른 인터페이스를 통해 접근
- 인터페이스를 사용한 DI라 가능
- BaseSqlService 클래스와 그 서브클래스가 존재 ~> SqlRegistry 인터페이스 수정은 바람직하지 않음
7.5 DI를 이용해 다양한 구현 방법 적용하기
- 운영 중인 시스템에서 사용하는 정보를 실시간 변경하는 작업을 만들 때는, 동시성 문제를 가장 먼저 고려할 것
7.5.1 ConcurrentHashMap을 이용한 수정 가능
- Default로 사용하던 HashMapRegistry는 멀티스레드 환경에서 동시에 수정을 시도하거나 동시에 요청하는 경우 예상치 못한 결과가 발생할 수 있음
- 멀티 스레드 환경에서는 Collections.synchronizedMap() 등을 이용해 외부에서 동기화 해줘야 함
- 하지만, HashMap의 전 작업을 동기화하면 DAO의 요청이 많은 고성능 서비스에서는 성능 문제 존재
- 동기화 해시 데이터 조작에 최적화된 ConcurrentHashMap을 사용 권장
- 데이터 조작 시 전체 데이터에 락을 걸지 않고, 조회는 락을 아예 사용 X
- 수정 가능 SQL 레지스트리 테스트
-
public class ConcurrentHashMapSqlRegistryTest { UpdatableSqlRegistry sqlRegistry; @BeforeEach public void setUp() { sqlRegistry = new ConcurrentHashMapSqlRegistry(); sqlRegistry.registerSql("KEY1", "SQL1"); sqlRegistry.registerSql("KEY2", "SQL2"); sqlRegistry.registerSql("KEY3", "SQL3"); } @Test public void find() { checkFindResult("SQL1", "SQL2", "SQL3"); } private void checkFindResult(String excepted1, String excepted2, String excepted3) { assertThat(sqlRegistry.findSql("KEY1"), is(expected1)); ... } @Test public void updateSingle() { // 하나의 SQL 수정 기능 검증 sqlRegistry.updateSql("KEY2", "Modified2"); checkFindResult("SQL1", "Modified2", "SQL3"); } @Test public void updateMulti() { // 동시에 여러개 SQL 수정 기능 검증 Map<String, String> sqlmap = new HashMap<>(){{ put("KEY1", "Modified1"); put("KEY3", "Modified3"); }}; sqlRegistry.updateSql(sqlmap); checkFindResult("Modified1", "SQL2" ,"Modified3"); } @Test(expected = SqlNotFoundException.class) public void unknownKey() { // 주어진 키로 찾지 못하는 예외 sqlRegistry.findSql("ASDioqwje"); } @Test(expected=SqlUpdateFailureException.class) public void updateWithNotExistingKey() { // 존재하지 않는 키 SQL 변경 예외 sqlRegistry.updateSql("SQL9999!@#$", "Modified2"); } }
-
- 수정 가능 SQL 레지스트리 구현
-
public class ConcurrentHashMapSqlRegistry implements UpdatableSqlRegistry { private Map<String, String> sqlMap = new ConcurrentHashMap<>(); public String findSql(String key) throws SqlNotFOundException { ... } public void registerSql(String key, String sql) { ... } public void updateSql(String key, String sql) throws SqlUpdateFailureException { if (sqlMap.get(key) == null) { throw new SqlUpdateFailureException("not found sql from key : " + key); } sqlMap.put(key, sql); } public void updateSql(Map<String, String> sqlmap) throws SqlUpdateFailureException { for(Map.Entry<String, String> entry : sqlmap.entrySet()) { updateSql(entry.getKey(), entry.getValue()); } } }
-
<bean id="sqlService" class="springbook.user.sqlservice.OxmSqlService"> <property name="unmarshaller" ref="unmarshaller" /> <property name="sqlRegistry" ref="sqlRegistry" /> </bean> <bean id="sqlRegistry" class="springbook.user.sqlservice.updatable.ConcurrentHashMapSqlRegistry" />
-
7.5.2 내장형 데이터베이스를 이용한 SQL 레지스트리 만들기
- 내장형 DB를 이용해 SQL을 저장하고 수정하게 만들기
- ConcurrentHashMap은 저장되는 데이터 양이 많아지고 잦은 조회와 변경이 일어날 때, 한계점이 존재
- 데이터베이스는 인덱스를 이용한 최적화된 검색 지원, 동시에 많은 요청을 처리하면서 안정적인 변경 작업 가능
- 내장형 DB는 IO로 인해 발생하는 부하가 적어 성능이 뛰어남
- 메모리에 데이터를 저장하는 방법에 비해 효과적이고 안정적인 방법으로 CRUD 및 최적화 락킹, 격리수준, 트랜잭션 적용 가능
- 스프링의 내장형 DB 지원 기능
- 자바 내장형 DB
- Derby, HSQL, H2를 가장 많이 사용
- JDBC 드라이버를 제공하고 표준 DB랑 호환 가능
- 내장형 DB를 위해 다른 서비스 추상화처럼 별도의 레이어와 인터페이스를 제공하지 않지만, 내장형 DB 빌더를 제공
- URL과 드라이버 등 초기화 기능
- 초기화 쿼리 실행 기능
- 모든 준비가 끝나면 내장형 DB에 대한 DataSource 오브젝트를 반환
- 이후 DataSource를 통해 일반 DB처럼 사용 가능
- 애플리케이션 안에서 직접 DB를 종료 요청이 가능해야함
- DataSource 인터페이스를 상속해 shutdown() 메소드를 추가한 EmbeddedDatabase 인터페이스 제공
- Derby, HSQL, H2를 가장 많이 사용
- 자바 내장형 DB
- 내장형 DB 빌더 학습 테스트
- 테이블 생성 쿼리를 담은 schema.sql, 초기화 데이터 생성 쿼리를 담은 data.sql이 준비되어있다고 가정
-
EmbeddedDatabase db; SimpleJdbcTemplate template; @BeforeEach public void setUp() { db = new EmbeddedDatabaseBuilder() .setType(HSQL) .addScript("classpath:/springbook/learningtest/spring/embeddeddb/schema.sql") .addScript("classpath:/springbook/learningtest/spring/embeddeddb/data.sql") .build(); template = new SimpleJdbcTemplate(db); } @AfterEach public void tearDown() { db.shutdown(); }
- 테스트 메소드마다 EmbeddedDatabaseBuilder가 최종적으로 DataSource 를 상속한 EmbeddedDatabase 타입의 오브젝트를 리턴하여, template 사용에 적용
- 내장형 DB를 이용한 SqlRegistry 만들기
- EmbeddedDatabaseBuilder는 직접 빈으로 등록한다고 바로 사용할 수 없음
- 적절한 메소드를 호출해주는 초기화 코드 필요
- 초기화 코드가 필요하다면 팩토리 빈으로 만드는 것이 좋음
-
public class EmbeddedDbSqlRegistry implements UpdatableSqlRegistry { SimpleJdbcTemplate jdbc; public void setDataSource(DataSource dataSource) { jdbc = new SImpleJdbcTemplate(dataSource); } public void registrySql(String key, String sql) { jdbc.update("insert into sqlmap(key_, sql_) values(?,?)", key, sql); } public String findSql(String key) throws SqlNotFoundException { try { return jdbc.queryForObject("select sql_ from sqlmap where key_ = ?", String.class, key); } catch (EmptyResultDataAcessException e) { throw new SqlNotFoundException("not found sql from key : " + key); } } public void updateSql(String key, String sql) throws SqlUpdateFailureException { int affected = jdbc.update("update sqlmap set sql_ = ? where key_ = ?", sql, key); if (affected == 0) { throw new SqlUpdateFailureException("not found sql from key : " + key); } } public void updateSql(Map<String, String sqlmap) throws SqlUpdateFailureException { ... } }
- 내장형 DB는 EmbeddedDatabase 타입이라고 했는데, 위에서는 DataSource 타입의 오브젝트로 DI
- 인터페이스 분리 원칙을 지키기 위함
- SQL 레지스트리는 JDBC를 이용해 DB에 접근할 수만 있으면 되기 때문에 DataSource 인터페이스가 적합
- 자신이 필요로 하는 기능만 가진 인터페이스를 의존
- 내장형 DB는 EmbeddedDatabase 타입이라고 했는데, 위에서는 DataSource 타입의 오브젝트로 DI
- EmbeddedDatabaseBuilder는 직접 빈으로 등록한다고 바로 사용할 수 없음
- UpdatableSqlRegistry 테스트 코드의 재사용
- 인터페이스가 같은 클래스라도 구현 방식에 따라 검증 내용 / 테스트 방법이 달라질 수 있고, 의존 오브젝트 구성에 따라 mock이나 stub을 사용
- ConcurrentHashMapSqlRegistry는 의존 오브젝트가 아예 없고, EmbeddedDbSqlRegisry도 내장형 DataSource 빈을 의존하기는 하지만 테스트 대역으로 대체하기 힘듦
- UpdatableSqlRegistry 구현 클래스의 오브젝트 생성 부분을 분리하면, 나머지 테스트 코드가 공유 가능
- XML 설정을 통한 내장형 DB의 생성과 적용
- jdbc 스키마의 전용 태그 사용
-
<!-- 네임스페이스 선언 --> <beans xmlns="http://www.springframework.org/schema/beans" ... xmlns="http://www.springframework.org/schema/jdbc" xsi:schemaLocation="http://www.springframework.org/schema/tx/spring-tx-3.0.xsd http://www.springframework.org/schema/jdbc" ...""></beans>
-
<!-- 내장형 DB 등록 --> <jdbc:embedded-database id="embeddedDatabase" type="HSQL"> <jdbc:script location="classpath:springbook/user/sqlservice/updatable/sqlRegistrySchema.sql" /> </jdbc:embedded-database>
-
<!-- embeddedDbSqlRegistry 클래스를 이용한 빈 등록 --> <bean id="sqlService" class="springbook.user.sqlservice.OxmSqlService"> <property name="unmarshaller" ref="unmarshaller" /> <property name="sqlRegistry" ref="sqlRegistry" /> </bean> <bean id="sqlRegistry" class="springbook.user.sqlservice.updatable.EmbeddedDbSqlRegistry" > <property name="dataSource" ref="embeddedDatabase" /> </bean>
7.5.3 트랜잭션 적용
- 하나의 SQL을 수정할 때는 문제가 없지만 하나 이상의 SQL을 맵으로 전달받아 한 번에 수정해야하는 경우, 심각한 문제 발생
- 존재하지 않는 키에 대한 예외처리는 되어있지만, 트랜잭션이 적용되어있지 않음
- 중간에 예외 발생 ~> 수정 사항은 반영
- HashMap과 같은 컬렉션은 트랜잭션 개념을 적용하기 힘들지만, 내장형 DB는 쉬움
- 트랜잭션 경계가 DAO 밖에 있고 범위가 넓다면 AOP를 이용
- SQL 레지스트리라는 제한된 오브젝트 내에서 서비스에 특화된 경우는 트랜잭션 API를 사용
- 다중 SQL 수정에 대한 트랜잭션 테스트
-
public class EmbeddedDbSqlRegistryTest extends AbstractUpdatableRegistryTest { ... @Test public void transactionalUpdate() { checkFind("SQL1", "SQL2", "SQL3"); Map<String, String> sqlmap = new HashMap<>(){{ put("KEY1", "Modified"); put("KEY9999!@#$", "Modified999"); // 존재하지 않는 키 ~> 실패 }}; try { sqlRegistry.updateSql(sqlmap); fail(); // 예외가 발생하지 않은 경우 실패 } catch (SqlUpdateFailureException e) {} checkFind("SQL1", "SQL2", "SQL3"); // 롤백이 되면 초기 값과 같음 } }
- 트랜잭션을 아직 적용하지 않아서 실패
-
- 코드를 이용한 트랜잭션 적용
-
public class EmbeddedDbSqlRegistry extends AbstractUpdatableRegistryTest { SimpleJdbcTemplate jdbc; TransactionTemplate transactionTemplate; public void setDataSource(DataSource dataSource) { this.jdbc = new SimpleJdbcTemplate(dataSource); transactionTemplate = new TransactionTemplate(new DataSourceTransactionManager(dataSource)); } ... public void updateSql(final Map<String, String> sqlmap) throws SqlUpdateFailureException { transactionTemplate.execute(new TransactionCallbackWithoutResult() { protected void doInTransactionWithoutResult(TransactionStatus status) { for(Map.Entry<String, String> entry : sqlmap.entrySet()) { updateSql(entry.getKey(), entry.getValue()); } } }); } }
- PlatformTransactionManager를 직접 사용하기보다 트랜잭션 적용 코드에 템플릿/콜백 패턴을 적용한 TransactionTemplate을 쓰는 것이 더욱 간결
- 일반적으로는 트랜잭션 매니저를 싱글톤 빈으로 등록
- 여러 AOP를 통해 만들어지는 트랜잭션 프록시가 같은 트랜잭션 매니저를 공유해야 하기 때문
- EmbeddedDbSqlRegistry는 내부에서 직접 만들어 사용하는 것이 좋음
- 내장형 DB에 대한 트랜잭션 매니저를 공유할 필요가 없음
-
반응형'Java & Spring > 토비의 스프링 3.1' 카테고리의 다른 글
8장) 8.1 스프링의 정의 ~ 8.2 스프링의 목적 (0) 2021.09.07 7장) 7.6 스프링 3.1의 DI (2) 2021.08.30 7장) 7.3 서비스 추상화 적용 (0) 2021.08.08 7장) 7.1 SQL과 DAO의 분리 ~ 7.2 인터페이스의 분리와 자기참조 빈 (0) 2021.08.06 6장) 6.6 트랜잭션 속성 (0) 2021.07.26