자바, 스프링 개발자들의 종착역이자, 주기적으로 다시 돌아오는 그곳, <토비의 스프링 3.1>을 드디어 읽는다.
한창 개발 걸음마를 막 떼고 스프링으로 아장아장 기어다니며 CRUD를 할 때즈음, 토비의 스프링을 찬양하고 있는 많은 개발자들을 보았고, 역시 지식이 깊어지려면, 책을 통해서 지식을 정리하고 깊이를 다지는 시간이 필요하겠구나, 생각했다. 하지만, 개발을 이제 막 배운 그때의 나에게 토비 책은 너무나도 무시무시했고, 토비책을 읽다가 개발을 포기한 사람들의 증언도 여럿 읽고 나니, 이 책은 개발자로서 레베루업이 필요할 때즈음, 다시 꺼내봐야겠다고 생각했다.
그리고 지금이다. 퇴사 후, 이직을 준비하는 동안, 지식의 깊이를 좀 더 다져놓고 싶다는 생각이 들었고, 집앞 도서관에서 (아무도 빌려가지 않는) 토비님의 책을 빌려서 읽기 시작했다. 처음에는 가볍게 읽기 시작했지만, 읽을 수록 인사이트가 쌓여, 기록하면서 제대로 읽고 싶어졌다. 그래서 나의 세컨 브레인인 이곳 블로그에 짧은 글들로 그 내용을 적어보며, 토비님으로부터 얻은 인사이트를 내것으로 만들어보고자 한다.
스프링의 3대 핵심 기술 : IoC/DI, 서비스 추상화, AOP
스프링이 가장 가치를 두고 적극적으로 활용하고자 하는 것은 결국 자바 언어 기반인 객체지향 기술!
스프링은 객체지향적인 언어의 장점을 적극 활용하여 코드를 작성하도록 도와준다.
UserDao 에서는 더 개선할 점이 없나?
SQL을 DAO 에서 분리하는 작업
현재 DAO는 반복적인 JDBC 작업 흐름도 템플릿을 이용해서 제거하고, 트랜잭션과 예외처리도 서비스 추상화와 AOP 를 이용하여 처리해서 깔끔한 상태.
하지만 여전히 DB 테이블과 필드정보를 그대로 담고 있는 SQL 이 존재한다.
현재 구조대로라면 데이터베이스의 테이블 구조나 필드 정보가 변경될 떄마다 UserDao 코드가 변경되어야 한다. SQL 이 변경되기 때문이다.
이 경우, 변경될 필드나 테이블 정보를 일괄적으로 수정할 필요가 생기게 되는데, 사람이 직접 하는 작업이라 실수가 발생할 여지가 높고 문제가 생기면 지금 운영중인 어플리케이션의 쿼리가 어떤 것인지 직접 자바 소스코드에서 찾아야 한다.
따라서 SQL 문을 DAO 에서 분리하여 다른 파일이나 위치에 두고 관리할 수 있도록 분리하는 작업을 해보려고 한다.
방법 1. XML 설정을 이용한 분리
사용할 SQL 문장을 XML 설정파일에 세팅해두고 스프링이 빈에 값을 주입해도록 하는 방법
UserDao 코드 수정없이 XML 설정를 바꾸기만해도 자유롭게 수정이 가능하다.
하지만 SQL 이 점점 많아지면, DAO 에 SQL 용 프로퍼티를 추가하는 것이 귀찮아진다. sqlAdd 이외에 sqlUpgradeLevels 프로퍼티를 추가하려면 결국 DAO내부에도 이를 DI 받기 위한 변수과 수정자 메소드 setter 를 추가해주어야 한다. 상당히 귀찮다.
<beanid="userDao"class="springbook.user.dao.UserDaoJdbc"> <propertyname="dataSource"ref="dataSource" /> <property name="sqlAdd" value ="insert into users(id, name, password, email, level, login, recommend) values(?,?,?,?,?,?,?)" />
</bean>
publicclassUserDaoJdbcimplementsUserDao {privateString sqlAdd;...publicvoidsetSqlAdd(String sqlAdd) {this.sqlAdd= sqlAdd; // DI 주입받는 SQL 문장 }...publicvoidadd(User user) {this.jdbcTemplate.update(this.sqlAdd,//사용할 SQL 문장 user.getId(), user.getName(), user.getPassword(), user.getEmail(), user.getLevel().intValue(), user.getLogin(), user.getRecommend()
); } }
방법 2. SQL 맵 프로퍼티 방식
SQL 이 늘어날때마다 프로퍼티를 추가하기 귀찮으니, 아예 Map 컬렉션 내부에 담아주는 방법을 생각해본다.
새로운 SQL 이 늘어나면, 이제 entry 만 추가해줘도 좋다.
다만 이 방법은 SQL 문장 자체에 오류가 있을 경우, 직접 메소드 실행 전에는 오류를 발견하기가 어렵다는 단점이 있다. test 를 철저하게 작성하여 미리미리 검증할 필요가 있는 것이다.
<!-- application components --><beanid="userDao"class="springbook.user.dao.UserDaoJdbc"> <propertyname="dataSource"ref="dataSource" /> <propertyname="sqlMap"> <map> <entry key="add" value="insert into users(id, name, password, email, level, login, recommend) values(?,?,?,?,?,?,?)" />
<entrykey="get"value="select * from users where id = ?" /> <entrykey="getAll"value="select * from users order by id" /> <entrykey="deleteAll"value="delete from users" /> <entrykey="getCount"value="select count(*) from users" /> <entry key="update" value="update users set name = ?, password = ?, email = ?, level = ?, login = ?, recommend = ? where id = ?" />
</map> </property></bean>
그 결과, SqlType.java, Sqlmap.Java 등 두개의 바인딩용 자바 클래스와 팩토리 클래스가 생성된다.
<?xml version="1.0" encoding="UTF-8"?><sqlmapxmlns="<http://www.epril.com/sqlmap>"xmlns:xsi="<http://www.w3.org/2001/XMLSchema-instance>"xsi:schemaLocation="<http://www.epril.com/sqlmap> <http://www.epril.com/sqlmap/sqlmap.xsd> "> <sqlkey="userAdd">insert into users(id, name, password, email, level, login, recommend) values(?,?,?,?,?,?,?)</sql> <sqlkey="userGet">select * from users where id = ?</sql> <sqlkey="userGetAll">select * from users order by id</sql> <sqlkey="userDeleteAll">delete from users</sql> <sqlkey="userGetCount">select count(*) from users</sql> <sql key="userUpdate">update users set name = ?, password = ?, email = ?, level = ?, login = ?, recommend = ? where id = ?</sql>
</sqlmap>
기존의 OxmTest 의 코드는 전혀 바꾸지 않고, xml 설정만 변경하여 완벽하게 같은 테스트를 통과한다!
OXM 추상화 서비스 인터페이스를 이용한다면, 테스트용 Unmarchaller 로 대체하기도 쉽다. 아직 매핑 파일이 준비되지 않았거나 OXM 기술을 못정한 경우에도 일단 테스트 용으로 Unmarchaller 인터페이스를 구현해서 미리 준비된 오브젝트를 돌려주도록 할 수도 있다. Mockito 로 만들어서 대체해줄수도 있고!
지금까지 행한 모든 것들은 DAO 에서 SQL 문을 빼내어 데이터베이스의 테이블 구조나 필드 변경 등에 영향을 받아 코드를 수정하지 않기 위함이다.
XML 파일에 SQL 문들을 모아서 관리하고 이를 자바 오브젝트로 매핑시켜서 사용하는 여러가지 방법(OXM)을 사용해왔는데, 그 과정에서 다양한 설계와 구조에 대한 고민이 있었고 최적화의 상태로 변환시켜갔다.
스프링이 제공해주는 OXM 서비스 추상화 방법을 이용하여 이제 XML-자바오브젝트 매핑 기술에 상관없이 코드 변경이 일어나지 않는 구조가 완성되었다.
OXM 서비스 추상화 개선하기
지금 구조로 보면 한 가지 문제점이 존재한다.
SqlService 의 구현체인 OxmSqlService 와 BaseSqlService 에 loadSql() 과 getSql() 등의 메소드 작업에 대한 코드가 거의 똑같다는 것이다.
해결방법
OxmlSqlService → BaseSqlService 로 작업을 위임한다.
아래와 같이 코드를 수정하면 중복코드도 사라지게 된다.
publicclassOxmSqlServiceimplementsSqlService {privatefinalBaseSqlService baseSqlService =newBaseSqlService(); private final OxmSqlService.OxmSqlReader oxmSqlReader = new OxmSqlService.OxmSqlReader((OxmSqlService.OxmSqlReader)null);
@PostConstructpublicvoidloadSql() {this.baseSqlService.setSqlReader(this.oxmSqlReader);this.baseSqlService.setSqlRegistry(this.sqlRegistry);this.baseSqlService.loadSql(); }publicStringgetSql(String key) throwsSqlRetrievalFailureException {returnthis.baseSqlService.getSql(key); }}
💡 이해 안되는 점
BaseSqlService 는 코드 개선 이전에 구현된 레거시이고, 이제부터는 OxmSqlServie 만 사용하면 되지 않나. XmlSqlService 는 그대로 두고 사용하면서, 왜 BaseSqlService 는 코드가 중복된다는 이유로 이를 해결하려고 고민하는지 이해가 되지 않았다.
SQL이 담긴 XML 파일 소스 다양화하기
리소스 추상화
같은 목적이지만 사용법이 다른 기술들이므로 리소스를 가져오는 것 자체도 추상화할 수 있지 않을까? → 리소스 추상화의 필요성
스프링은 자바에 존재하는 일관성 없는 리소스 접근 API 를 추사오하해서 Resource 추상화 인터페이스를 정의한다.
어플리케이션 컨텍스트가 사용할 설정정보 파일을 지정하는 것부터 스프링의 거의 모든 API 는 외부의 리소스 정보가 필요할 떄, 항상 이 Resource 추상화를 이용한다.
이 Resource 는 빈 타입이 아니라 값으로 간주되기 때문에 추상화를 적용하는데 고민이 필요하다.
스프링에서는 이를 해결하기 위해 접두어를 이용한 Resource 오브젝트를 선언하는 방법이 있다. 그리고 ResourceLoader 를 통해 문자열로 정의된 리소스를 실제 Resource 타입 오브젝트로 변환해준다.
ResourceLoader 는 접두어(file:, classpath:, http: 등)을 이용해서 리소스 로더의 종류와 상관없이 접두어가 의미하는 위치와 방법을 이용하여 리소스를 읽어온다. 만약에 접두어가 없다면 리소스 로더의 구현방식에 따라 리소스를 가져오는 방식이 달라진다.
가장 대표적인 ResourceLoader 는 바로 스프링의 어플리케이션 컨텍스트이다. ApplicationContext 는 ResourceLoader 인터페이스를 상속하고 있다. 따라서 어플리케이션 컨텍스트는 리소스 로더이기도 하다.
스프링 컨테이너가 리소스 로더를 사용하는 목적들 예시
어플리케이션 컨텍스트가 사용할 스프링 설정정보가 담긴 XML 파일
컨텍스트 외부에서 읽어오는 모든 정보들
빈의 프로퍼티 값을 변환할 때에도 value 문자열로 정의된 프로퍼티를 본인 스스로 리소스 로더로써 Resource 오브젝트로 변환하여 프로퍼티에 주입시킨다.
Resource 이용해서 XML 파일 가져오기
string type 으로 정의되었던 sqlMapFile 모두 Resource 타입으로 변경하기
이름도 sqlMap 으로 변경
getInputStream() 을 이용하여 리소스 소스 상관없이 스트림으로 가져오기
StreamSource 클래스 이용하여 OXM 언마샬러가 필요로 하는 Source 타입으로 만들어주기
<!-- sql service --><beanid="sqlService"class="springbook.user.sqlservice.OxmSqlService"> <propertyname="unmarshaller"ref="unmarshaller" /> <propertyname="sqlmap"value="classpath:/springbook/user/dao/sqlmap.xml" /></bean>
운영중인 앱의 SQL 을 동적으로 변경해야한다면
원칙적으로는 발생하면 안되는 일이지만, 운영 중인 서비스에 심각한 SQL 오류가 있거나 해서 운영중인 서비스의 SQL 을 급히 변경해야한다면, 어떻게 해야할까?
지금까지 방법들은 모두 스프링 어플리케이션이 시작되면서 초기화 될 때, 리소스로부터 SQL 정보를 읽어와서 아예 앱 메모리 내부에 두고 그대로 사용하는 방법이었다. 즉, 어플리케이션이 일단 시작되었으면, SQL 매핑 정보를 변경한다고 해서 그게 적용되지는 않았다. 변경된 SQL 문을 적용시키려면 앱을 재시작해서 SqlService 구현 빈을 다시 초기화하는 방법밖에는 없었다.
DI 와 기능의 확장
스프링에서 XML 설정정보를 통해 DI 를 해주는 것은 어렵지 않다. 하지만 이것은 진정으로 DI 를 사용하고 있다고는 할 수 없다.
DI 의 가치를 제대로 누리려면, 유연하고 확장 가능한 좋은 오브젝트 설계가 함께 이루어져야한다.
객체지향을 잘 하는 방법 중 하나는 바로 DI 를 의식하면서 설계하는 방식이다.
DI 를 적용하려면 커다란 오브젝트 하나가 있어서는 안된다. 최소한 두 개 이상의 의존관계를 가지고 서로 협력해서 일하는 오브젝트가 필요하다.
그렇게 되면 적절한 책임에 따라 오브젝트를 분리하게 된다.
DI 와 인터페이스 프로그래밍
인터페이스를 쓰는 이유 1. 다형성을 얻기 위해서
하나의 인터페이스를 통해 여러개의 구현을 바꿔가면서 사용할 수 있게 하기 위해서이다.
사실 꼭 DI 의 목적이 오직 다형성을 편리하게 적용하기 위해서라면 굳이 인터페이스를 쓰지 않아도 된다. 귀찮더라도 상속 방식으로 구현을 확장할 수 있다.
인터페이스를 쓰는 이유 2. 인터페이스 분리 원칙을 통해 클라이언트와 의존 오브젝트 사이의 관계를 명확하게 해줄 수 있기 때문에
A와 B가 인터페이스로 연결되어 있다는 것은 A가 B를 바라볼 때, 해당 인터페이스라는 창을 통해서 본다는 것을 의미한다. 따라서 A에게 B는 그냥 B1 인터페이스를 구현한 임의의 오브젝트일 뿐이다. B가 C, D, E, F 등으로 바뀌어도 A에게는 DI 가 가능해진다.
하나의 오브젝트는 여러개의 인터페이스를 구현할 수 있다. 하나의 오브젝트를 바라보는 창이 여러가지 일 수 있다는 뜻이다.
인터페이스 분리 원칙
목적과 관심이 각기 다른 클라이언트가 있다면 인터페이스를 통해 적절하게 분리해준다.
웬만하면 DI 는 인터페이스를 통해 한다고 생각해도 좋다.
인터페이스 상속
인터페이스를 꼭 여러 개 만들 필요는 없다. 대신 기존 인터페이스를 상속을 통해 확장하는 방법도 있다.
예를 들어 지금 SqlRegistry 에 SQL 수정 기능을 추가한다고 생각해보자. 그렇게 되면 SqlRegistry 에 의존하고 있던 기존의 BaseSqlService 는 굳이 필요없는 기능에 노출되게 된다. 이 경우는 아래와 같이 해결할 수 있다.
오브젝트간 관계
SqlRegistry
UpdatableSqlRegistry : SqlRegistry 상속한 interface
MyUpdatableSqlRegistry : UpdatableSqlRegistry 상속한 구현 클래스
BaseSqlService : SqlRegistry 인터페이스 타입을 주입받는 클래스
SqlAdminService : UpdatableSqlRegistry 타입을 주입받는 클래스
위와 같이 인터페이스를 상속하는 방식으로 진행하게 되면, BaseSqlService와 SqlAdminService는 모두 MyUpdatableSqlRegistry 클래스만 DI 받아도 각각 받는 인터페이스 타입에 따라 접근할 수 있는 메소드가 달라지게 되며, 원하는 기능은 접근할 수 있는 상태가 된다.
publicinterfaceUpdatableSqlRegistryextendsSqlRegistry {voidupdateSql(String var1,String var2) throwsSqlUpdateFailureException;voidupdateSql(Map<String,String> var1) throwsSqlUpdateFailureException;}publicclassConcurrentHashMapSqlRegistryimplementsUpdatableSqlRegistry {privateMap<String,String> sqlMap =newConcurrentHashMap(); //동시성 문제 해결을 위한 ConcurrentHashMap 타입 사용 publicConcurrentHashMapSqlRegistry() { }publicStringfindSql(String key) throwsSqlNotFoundException {String sql = (String)this.sqlMap.get(key);if (sql ==null) {thrownewSqlNotFoundException(key +"를 이용해서 SQL을 찾을 수 없습니다"); } else {return sql; } }publicvoidregisterSql(String key,String sql) {this.sqlMap.put(key, sql); }publicvoidupdateSql(String key,String sql) throwsSqlUpdateFailureException {if (this.sqlMap.get(key) ==null) {thrownewSqlUpdateFailureException(key +"에 해당하는 SQL을 찾을 수 없습니다"); } else {this.sqlMap.put(key, sql); } }publicvoidupdateSql(Map<String,String> sqlmap) throwsSqlUpdateFailureException {Iterator var3 =sqlmap.entrySet().iterator();while(var3.hasNext()) {Entry<String,String> entry = (Entry)var3.next();this.updateSql((String)entry.getKey(), (String)entry.getValue()); } }}
<!-- sql service --><beanid="sqlService"class="springbook.user.sqlservice.OxmSqlService"> <propertyname="unmarshaller"ref="unmarshaller" /> <propertyname="sqlRegistry"ref="sqlRegistry" /></bean><beanid="sqlRegistry"class="springbook.user.sqlservice.updatable.ConcurrentHashMapSqlRegistry"></bean>
2. ConcurrentHashMap 대신에 내장형 DB 사용하여 SQL 저장하고 수정하기
concurrentHashMap의 한계
성능상 그리 나쁘지는 않지만 데이터 양이 많아지고 잦은 조회와 변경이 일어나는 환경에는 한계가 있다.
해결방법
인덱스를 이용한 최적화 검색 + 동시에 많은 요청을 처리 + 안정적인 변경 작업이 가능한 기술
바로 데이터베이스를 이용하는 것이다.
그러나 자칫하면 SQL 저장하자고 DB 구축을 엄청나게 하면 배보다 배꼽이 더 커질 수 있음. 내장형 DB 로 깔삼하게 구축하는 것이 이득이다.
내장형 데이터베이스의 특징
어플리케이션과 함께 시작되고 종료됨
메모리에 저장되기 때문에 IO 로 발생하는 부하가 적어서 성능이 뛰어나다
Map 같은 컬랙션보다 매우 효과적인 방법으로 등록, 수정, 삭제, 검색이 가능하다.
최적화된 락킹, 격리수준, 트랜잭션을 적용하는 것도 가능하다.
검색, 통계 등 데이터 조작이 많이 필요한 경우, 관계형 DB와 SQL 문이 가장 좋은 방법이다.
종류
Derby, HSQL, H2
모두 JDBC 드라이버를 제공하고 표준 DB 와 호환되는 기능을 제공한다.
스프링에서는 내장형 DB 를 초기화하는 작업을 지원하는 편리한 내장형 DB 빌더를 제공한다.
JDBC 접속 URL 을 이용하여 연결을 시도하면 JDBC 드라이버 내에서 이를 생성해준다.
어플리케이션 내에서 직접 종료할 수 있어야 한다.
내장형 데이터베이스 학습테스트
CREATETABLESQLMAP ( KEY_ VARCHAR(100) PRIMARY KEY, SQL_ VARCHAR(100) NOT NULL);
INSERT INTO SQLMAP(KEY_, SQL_) values('KEY1', 'SQL1');INSERT INTO SQLMAP(KEY_, SQL_) values('KEY2', 'SQL2');
publicclassEmbeddedDbTest {EmbeddedDatabase db;SimpleJdbcTemplate template;publicEmbeddedDbTest() { } @BeforepublicvoidsetUp() {this.db= (newEmbeddedDatabaseBuilder()).setType(EmbeddedDatabaseType.HSQL) //H2, Derby 등 데이터베이스에 맞게 교체 .addScript("classpath:/springbook/learningtest/spring/embeddeddb/schema.sql").addScript("classpath:/springbook/learningtest/spring/embeddeddb/data.sql").build();this.template=newSimpleJdbcTemplate(this.db); } @AfterpublicvoidtearDown() {this.db.shutdown(); } @TestpublicvoidinitData() {Assert.assertThat(this.template.queryForInt("select count(*) from sqlmap",newObject[0]),CoreMatchers.is(2)); List<Map<String, Object>> list = this.template.queryForList("select * from sqlmap order by key_", new Object[0]);
Assert.assertThat((String)((Map)list.get(0)).get("key_"),CoreMatchers.is("KEY1"));Assert.assertThat((String)((Map)list.get(0)).get("sql_"),CoreMatchers.is("SQL1"));Assert.assertThat((String)((Map)list.get(1)).get("key_"),CoreMatchers.is("KEY2"));Assert.assertThat((String)((Map)list.get(1)).get("sql_"),CoreMatchers.is("SQL2")); } @Testpublicvoidinsert() {this.template.update("insert into sqlmap(key_, sql_) values(?,?)",newObject[]{"KEY3","SQL3"});Assert.assertThat(this.template.queryForInt("select count(*) from sqlmap",newObject[0]),CoreMatchers.is(3)); }}
내장형 데이터베이스를 이용하는 Registry 신규 생성 및 테스트
스프링에서 내장형 데이터베이스를 만들기 위해서는 EmbeddedDatabaseBuilder 를 사용해야한다.
그런데 이는 직접 빈으로 등록한다고 사용할 수 있는 것이 아니다.
직접 적절한 메소드를 호출해주는 초기화 코드가 필요하다.
이런 초기화 코드는 팩토리 빈으로 만드는 것이 좋다. EmbeddedDatabaseBuilder 오브젝트는 한 번 초기화를 거쳐서 내장형 DB 를 기동하고 여기에 접근할 수 있는 EmbeddDatabase 를 만들어주면 그 이후로는 사용할 일이 없기 때문이다.
스프링에는 팩토리 빈을 대신 만들어주는 전용 태그가 있다.
아래와 같이 설정하면 embeddedDatabase 아이디를 가진 빈이 등록되며, type 은 EmbeddedDatabase 이다.
CREATETABLESQLMAP ( KEY_ VARCHAR(100) PRIMARY KEY, SQL_ VARCHAR(100) NOT NULL);
publicclassEmbeddedDbSqlRegistryimplementsUpdatableSqlRegistry {SimpleJdbcTemplate jdbc;publicEmbeddedDbSqlRegistry() { }publicvoidsetDataSource(DataSource dataSource) {this.jdbc=newSimpleJdbcTemplate(dataSource); }publicvoidregisterSql(String key,String sql) {this.jdbc.update("insert into sqlmap(key_, sql_) values(?,?)",newObject[]{key, sql}); }publicStringfindSql(String key) throwsSqlNotFoundException {try { return (String)this.jdbc.queryForObject("select sql_ from sqlmap where key_ = ?", String.class, new Object[]{key});
} catch (EmptyResultDataAccessException var3) {thrownewSqlNotFoundException(key +"에 해당하는 SQL을 찾을 수 없습니다", var3); } }publicvoidupdateSql(String key,String sql) throwsSqlUpdateFailureException {int affected =this.jdbc.update("update sqlmap set sql_ = ? where key_ = ?",newObject[]{sql, key});if (affected ==0) {thrownewSqlUpdateFailureException(key +"에 해당하는 SQL을 찾을 수 없습니다"); } }publicvoidupdateSql(Map<String,String> sqlmap) throwsSqlUpdateFailureException {Iterator var3 =sqlmap.entrySet().iterator();while(var3.hasNext()) {Entry<String,String> entry = (Entry)var3.next();this.updateSql((String)entry.getKey(), (String)entry.getValue()); } }}
내장형 데이터베이스를 이용하기 위해서 dataSource 를 DI 받았다.
위의 xml 설정에서 정의하기를, jdbc 스키마의 태그를 이용해서 내장형 데이터베이스의 팩토리 빈을 생성했을 때는 EmbeddedDatabase 타입이었다. 그런데 주입받을 때에는 DataSource 인터페이스 타입으로 주입을 받고 있다. 그 이유는 무엇일까?
인터페이스 분리 원칙을 지키기 위해서이다.
현재 SQL 레지스트리에게 필요한 기능은 이미 DataSource 인터페이스에 정의된 메소드로 충분히 해결 가능하다. 데이터베이스 종료 기능을 가진 EmbbeddedDatabase 타입은 과하다. 따라서 최소한의 필요한 기능을 가진 DataSource 타입으로 주입을 받게 한 것이다.
현재까지 인터페이스 - 클래스 구조 정리
SqlService - getSql()
SqlReader
JaxbXmlSqlReader
SqlRegistry
HashMapSqlRegistry
UpdatableRegistry
ConcurrentHashMapSqlRegistry
EmbeddedDbSqlRegistry
SimpleSqlService
BaseSqlService
OxmSqlService
OxmSqlReader
XmlSqlService
테스트 코드 합치기
UpdatableSqlRegistry 인터페이스를 구현하고 있는 ConcurrentHashMapSqlRegistry와 EmbeddedDbSqlRegistry 는 서로 구현하고 있는 코드가 비슷하므로 테스트용 코드도 중복될 가능성이 높다. 따라서 두 개의 테스트를 합쳐보려고 한다.
publicabstractclassAbstractUpdatableSqlRegistryTest {UpdatableSqlRegistry sqlRegistry;publicAbstractUpdatableSqlRegistryTest() { } @BeforepublicvoidsetUp() {this.sqlRegistry=this.createUpdatableSqlRegistry();this.sqlRegistry.registerSql("KEY1","SQL1");this.sqlRegistry.registerSql("KEY2","SQL2");this.sqlRegistry.registerSql("KEY3","SQL3"); }//test 픽스처를 생성하는 부분만 추상 메소드로 만들어두고 서브 클래스에서 이를 구현하도록 만든다. protectedabstractUpdatableSqlRegistrycreateUpdatableSqlRegistry(); @Testpublicvoidfind() {this.checkFind("SQL1","SQL2","SQL3"); } @Test( expected =SqlNotFoundException.class )publicvoidunknownKey() {this.sqlRegistry.findSql("SQL9999!@#$"); }//서브클래스에서 테스트를 추가할 수 잇도록, protected 로 접근 제어자 변경한다. protectedvoidcheckFind(String expected1,String expected2,String expected3) {Assert.assertThat(this.sqlRegistry.findSql("KEY1"),CoreMatchers.is(expected1));Assert.assertThat(this.sqlRegistry.findSql("KEY2"),CoreMatchers.is(expected2));Assert.assertThat(this.sqlRegistry.findSql("KEY3"),CoreMatchers.is(expected3)); } @TestpublicvoidupdateSingle() {this.sqlRegistry.updateSql("KEY2","Modified2");this.checkFind("SQL1","Modified2","SQL3"); } @TestpublicvoidupdateMulti() {Map<String,String> sqlmap =newHashMap();sqlmap.put("KEY1","Modified1");sqlmap.put("KEY3","Modified3");this.sqlRegistry.updateSql(sqlmap);this.checkFind("Modified1","SQL2","Modified3"); } @Test( expected =SqlUpdateFailureException.class )publicvoidupdateWithNotExistingKey() {this.sqlRegistry.updateSql("SQL9999!@#$","Modified2"); }}
내장 데이터베이스 이용한 EmbbeddedDbSqlRegistry 적용하기
jdbc 네임스페이스 + 스키마 위치선언으로 내장 데이터베이스 팩토리 빈 등록
데이터베이스 초기화 스크립트를 지정해주자.
XML 설정에서 embeddedDatabase 빈을 dataSource 프로퍼티로 참조하는 EmbeddedDbSqlRegistry 타입의 sqlRegistry 빈을 정의해주면 된다.
embeddedDatabase 타입의 빈은 스프링 컨테이너가 종료될 때 자동으로 shutdown() 메소드 호출되도록 설정되어있다. 내장형 데이터베이스를 종료시키기 위해서 별도 설정이 필요없다는 뜻!
아래의 설정을 완료한 뒤, UserDaoTest 가 문제없이 실행되는지 확인한다.
<?xml version="1.0" encoding="UTF-8"?><beansxmlns="http://www.springframework.org/schema/beans" ...xmlns:jdbc="http://www.springframework.org/schema/jdbc"xsi:schemaLocation=" ... http://www.springframework.org/schema/tx http://www.springframework.org/schema/tx/spring-tx-3.0.xsd http://www.springframework.org/schema/jdbc http://www.springframework.org/schema/jdbc/spring-jdbc-3.0.xsd"> ...<!-- EmbeddedDbSqlRegistry 클래스를 이용한 빈 등록 --> <beanid="sqlService"class="springbook.user.sqlservice.OxmSqlService"> <propertyname="unmarshaller"ref="unmarshaller" /> <propertyname="sqlRegistry"ref="sqlRegistry" /> </bean> <beanid="sqlRegistry"class="springbook.user.sqlservice.updatable.EmbeddedDbSqlRegistry"> <propertyname="dataSource"ref="embeddedDatabase" /> </bean><!-- 내장형 데이터베이스 등록 --> <jdbc:embedded-databaseid="embeddedDatabase"type="HSQL"> <jdbc:scriptlocation="classpath:springbook/user/sqlservice/updatable/sqlRegistrySchema.sql"/> </jdbc:embedded-database> ...</beans>
3. 트랜잭션 적용하기
여러개의 SQL 문을 동시에 업데이트 하는 코드가 있다. 만약에 이 업데이트 중간에 오류가 발생하면 어떻게 될까?
정상적인 상태라면, 그동안 정상적으로 업데이트 되었던 SQL 문들도 모두 롤백하여 처음의 상태로 돌아가는 것이 맞다. 하지만, 지금의 EmbeddedDbSqlRegistry 의 경우는 SimpleJdbcTemplate 을 사용해서 SQL 을 실행하고 있으므로 트랜잭션이 적용되어있지 않다.
AOP 등 여러가지 방법으로 트랜잭션을 적용할 수 있겠지만, 지금 이 경우는 SQL 레지스트리라는 제한적인 오브젝트 내에서 서비스에 특화된 간단한 트랜잭션이 필요하므로, 그냥 트랜잭션 추상화 API 를 직접 사용하는 것으로 만족할 수 있다.
진행
우선 여러개의 SQL 문을 업데이트 하는 테스트를 만드는데, 두 번째 SQL 문 업데이트에서 반드시 exception 을 만들도록 존재하지 않는 키값을 넣는다.
테스트 내용은 처음 SQL 문들의 상태와 업데이트가 실패한 뒤의 SQL 문들의 상태가 정확하게 일치하는 것이다. 트랜잭션이 제대로 적용되었다면, 중간에 예외가 발생했을 때, 모두 롤백되어야 맞기 때문이다.
결과는? 당연히 실패다. 트랜잭션이 적용되지 않은 상태이므로 업데이트 성공한 곳까지는 바뀐 SQL 문이 그대로 남아있게 된다.
트랜잭션 메니저는 AOP 등으로 트랜잭션 프록시가 같이 공유하기 위해서 싱글톤 빈으로 등록해서 만들어지는 경우가 보통인데, 지금 상황에서는 그럴 필요가 없으므로, 별도의 변수로 저장하지 않고 1회성으로만 사용한다.
transactionTemplate 은 멀티 쓰레드 환경에서 공유해도 안전하도록 만들어져 있으니, 인스턴스 변수로 지정하여 공유한다.
매우 바쁜 서버환경이라면, 트랜잭션 작업의 격리수준에도 신경써야 한다.
내장 데이터베이스 중 하나인 HSQL 는 트랜잭션 격리수준에서 READ_UNCOMMITED 인 0 단계만 지원한다. 이 단계는 변경이 진행되는 중에 다른 트랜잭션이 변경된 내용을 읽을 가능성이 있다. 그런데 변경되던 중에 롤백되어버리게 되면, 변경된 정보를 읽어간 트랜잭션은 잘못된 정보를 참조한 꼴이 된다.
특히 SQL 문 변경과 같은 매우 중요하고 민감한 변경의 경우는 0단계로는 부족하다. READ_COMMITTED 격리수준을 지원하는 버전이나 다른 DB 를 사용하는 것이 방법이다.
publicclassEmbeddedDbSqlRegistryimplementsUpdatableSqlRegistry {SimpleJdbcTemplate jdbc;TransactionTemplate transactionTemplate;publicEmbeddedDbSqlRegistry() { }publicvoidsetDataSource(DataSource dataSource) {this.jdbc=newSimpleJdbcTemplate(dataSource);this.transactionTemplate=newTransactionTemplate(new DataSourceTransactionManager(dataSource));this.transactionTemplate.setIsolationLevel(2); //commit 된 데이터만 읽을 수 있도록 격리수준 강화 }...publicvoidupdateSql(finalMap<String,String> sqlmap) throwsSqlUpdateFailureException {this.transactionTemplate.execute(newTransactionCallbackWithoutResult() {protectedvoiddoInTransactionWithoutResult(TransactionStatus status) {Iterator var3 =sqlmap.entrySet().iterator();while(var3.hasNext()) {Entry<String,String> entry = (Entry)var3.next();EmbeddedDbSqlRegistry.this.updateSql((String)entry.getKey(), (String)entry.getValue()); } } }); }}
💡 지금까지 우리가 한 작업들의 의미
SQL 문을 XML 파일로 일관되게 관리하여 데이터베이스 수정에 DAO가 독립적이도록 만듬
XML파일로 관리하던 SQL 문들을 자바 오브젝트로 가져오는 방법으로 다양한 OXM 서비스를 이용할 수 있는데, 이러한 기술들을 한 단계 위로 추상화한 스프링 OXM 추상화 서비스를 이용하여 기술에 구애받지 않도록 자유도를 높인 것
XML 파일 리소스를 가져오는 방법을 프로젝트 내부 뿐만 아니라, 프로젝트 내 다른 디렉토리, 외부폴더, 웹 등 다양한 소스로부터 가져올 수 있도록 자유도를 높이는 작업
스프링 3.1의 DI
스프링은 지금까지 1.0부터 3.1 까지 발전하는 동안 놀라울 정도의 구 버전 호환성을 보여주었다. 모두 스프링이 견지하고 있는 핵심 철학을 고수한 덕부닝다.
객체지향적인 코드의 장점인 유연성과 확장성을 스프링 스스로가 충분히 지켜왔기 때문에 스프링 프레임워크 자체도 DI 원칙을 충분히 따라서 만들어졌기 때문에 기존 설계와 코드에 영향을 주지 않고도 꾸준히 새로운 기능을 추가하고 확장해나갈 수 있었다.
자바 언어 변화와 스프링
자바 언어의 변화에 따라 스프링도 영향을 받았다. 대표적으로는 아래의 두 가지
에노테이션의 메타정보 활용
정책과 관례를 이용한 프로그래밍
에노테이션의 메타정보 활용
자바 5부터 등장한 에노테이션 기법은 점차 XML 메타정보 설정 방법을 대체해가기 시작한다.
에노테이션은 사실 기존의 자바 프로그래밍 방식으로 활용하기가 어렵다.
자바 코드가 실행되는 과정에서 직접 참여하지 못하고
그 자체로 상속이나 오버라이딩이 가능하지도 않다.
동작하는 코드도 넣을 수 없고
코드에서 간단히 참조하거나 활용할 수 없다.
복잡한 리플렉션 API 를 이용해 에노테이션의 메타정보를 조회하고
에노테이션 내에 설정된 값을 가져와서 참고하는 방법이 전부이다.
클래스 타입에 영향을 주지도 못하고 일반코드에서도 활용되지 못하기 때문에 일반적인 객체지향 프로그래밍 스타일의 코드나 패턴 등에 적용할 수도 없다.
그럼에도 불구하고 에노테이션 기법은 왜 널리 퍼지게 되었을까?
다음과 같은 방식으로 구성되는 어플리케이션 방식에 잘 어울리기 때문이다.
어플리케이션 = 핵심로직을 담은 자바코드 + 이를 지원하는 IoC 방식의 프레임워크 + 프레임워크가 참조하는 메타정보
장점
에노테이션 하나를 자바 코드에 넣는 것만으로도 이를 참고하는 코드에서 다양한 부가 정보를 얻을 수 있다.
XML로 표현하려면 모든 내용을 명시적으로 길게 작성해야한다.
XML 방식은 텍스트를 기입하는 것이기 때문에 오타의 가능성도 많다.
리펙토링도 에노테이션 방식은 참조하는 관련 코드를 일괄적으로 바꿔주기가 훨씬 쉽다.
단점
자바코드에 존재하기 때문에 변경시 다시 컴파일 하는 과정이 필요하다. (XML 은 필요없음)
외부에 설정정보 변경을 위해 소스코드를 제공해야한다.
DI 패턴의 변화과정
초기에는 객체지향 언어의 기본에 충실하게 작성된 자바 코드 형태
프레임워크의 발전과 함께 자바 코드 + 프레임워크 + XML 메타정보
스프링 3.1부터는 핵심로직을 담은 자바코드 + DI 프레임워크 + DI 를 위한 메타데이터로서의 자바코드
정책과 관례를 이용한 프로그래밍
명시적으로 동작내용을 기술하는 것 대신에 코드 없이도 미리 약속한 규칙이나 관례를 따라서 프로그램이 동작하도록 만드는 프로그래밍 스타일이 유행하기 시작했다. -> 에노테이션도 그의 일종.
예를 들어 @Transactional을 살펴보자.
만약에 한 오브젝트가 클래스, 인터페이스, 메소드 를 포함해 네 군데에 달려있다면, 트랜잭션 적용은 어떻게 되는 걸까?
명확하게 하려면 중첩 설정이 되어있는 경우, @Transactional(order=1) 과 같이 적용 우선순위를 직접 지정할 수 있다.
하지만 스프링에서는 미리 4단계의 우선순위를 가진 대체 정책을 정해두었다. 이 정책을 기억하고 코드를 작성해야한다. 그렇지 않으면, 코드 레벨에서는 이를 알 수 있는 방법이 없다.
이런 문제의 경우는 디버깅도 너무 어렵다 ㅠㅠㅠ
이후부터는 그동안의 코드를 스프링 3.1 버전의 DI 스타일로 바꾸는 과정을 보여주고 설명한다.
1) 자바 코드를 이용한 빈 설정
DI 관련 정보를 스프링 3.1로 바꾸는 작업
테스트 용으로 만들어진 기존의 XML 에서 어플리케이션이 운영환경에서 동작할 때 필요로 하는 DI 정보를 분리해내는 일
1-1. 테스트 코드에서 DI 정보가 XML 에 담겨있다고 생각되는 부분 모두 자바코드로 설정 바꾸기
//Configuration 어노테이션으로 DI 설정정보 세팅할 클래스 만들기 @Configuration@ImportResource("/test-applicationContext.xml")publicclassTestApplicationContext { ... }//@ContextConfiguration(locations="/test-applicationContext.xml")@ContextConfiguration(classes=TestApplicationContext.class)publicclassUserDaoTest { ... }
1-2. annotation-config 제거
@PostConstruct 어노테이션이 붙은 매소드를 빈 후처리기에 등록해주어 초기화 이후에 바로 실행될 수 있도록 하기 위한 설정이었다.
그러니 @Configuration 으로 설정 방식이 변경되면서, 자동으로 등록할 수 있게 되었다.
1-3. bean 전환하기, 전용 태그 전환하기
dataSource, transactionManager 등을 포함하여 모든 bean 태그를 자바 코드로 변환해준다.
스프링은 @Autowired 가 붙은 수정자 메소드가 있으면 파라미터의 타입을 보고 주입 가능한 타입의 빈을 모두 찾는다.
만약 주입 가능한 타입의 빈이 1개라면, 수정자 메소드를 호출해서 넣어준다.
두개 이상이 나오면, 그 중에서 프로퍼티와 동일한 이름의 빈이 있는지 찾는다.
DataSource 의 경우는 빈이 2개이다. 하나는 userDao 빈이 사용하는 dataSource, 다른 하나는 SQL 서비스 용으로 만든 embeddedDataSource 이다.
그 중에서 dataSource 빈이 프로퍼티 이름과 일치하므로 이를 넣어준다.
타입과 이름을 모두 비교해도 찾아낼 수 없다면 주입할 빈이 없다는 에러가 난다.
어차피 목 오브젝트를 이용해서 테스트를 하기 어려운 경우에는 스프링 컨테이너 안에서 DI 이루어진 후에 테스트를 수행하는 방법이 적절하니까, 필드에 바로 @Autowired 를 적용해도 좋다. 하지만 비즈니스 로직을 가지고 있어서 목 오브젝트를 이용하여 적절하게 테스트 간으한 것들은 어노테이션을 이용하더라도 수정자 메소드를 남겨주는 것이 좋다.
3) @Component 를 이용한 자동 빈 등록
@Component 는 빈으로 등록될 후보 클래스에 붙여주는 일종의 마커이다.
@ComponentScan 을 이용하여 특정 패키지 아래에서만 @Component 에노테이션이 달린 클래스를 찾겠다고 범위 설정을 해준다. 전체 범위를 스캔하려면 부담이 많이 가기 때문이다.
@ComponentScan(basePackages="springbook.user")
basePackage 는 여러 개를 넣어도 된다.
새로운 어노테이션을 만들어 사용할수도 있다. @SnsConnector 라는 에노테이션을 정의할 때, @Component 어노테이션을 붙이면, 클래스마다 @Component 를 따로 붙여주지 않아도 자동으로 빈 등록 대상으로 만들 수 있다.
@Repository : @Component 를 메타 어노테이션으로 가지고 있다. DAO 기능을 제공하는 클래스에는 이를 사용하도록 권장되고 있다.
다시 한 번 말하지만 @Autowired 는 타입을 기준으로 먼저 찾고, 이름을 기준으로 다시 최종 후보를 찾는다.
@Service : @Repository 처럼 비즈니스 로직을 담고 있는 서비스 계층의 빈을 구분하기 위해 사용된다.
보통 서비스 계층은 트랜잭션의 경계가 되는 곳이라서 @Transactional 과 함께 사용되는 경우가 많다.
만약에 클래스 이름과 다르게 빈을 등록하고 싶다면, @Service("userService") 와 같이 이름을 명확하게 지정해주면 된다.
4) 컨텍스트 분리와 @Import
성격이 다른 DI 정보를 분리할 필요가 있다.
테스트용 컨텍스트 분리
테스트 용은 TestAppContext, 운영용은 AppContext 로 클래스를 분리한다.
@Import(value=SqlServiceContext.class)
public @interface EnableSqlService {
}
@Configuration
public class SqlServiceContext { ... }
...
@EnableSqlService
public class AppContext implements SqlMapConfig { ... }
8) 최종본
@Configuration
@EnableTransactionManagement
@ComponentScan(basePackages="springbook.user")
@EnableSqlService
@PropertySource("/database.properties")
public class AppContext implements SqlMapConfig {
@Value("${db.driverClass}") Class<? extends Driver> driverClass;
@Value("${db.url}") String url;
@Value("${db.username}") String username;
@Value("${db.password}") String password;
@Override
public Resource getSqlMapResouce() {
return new ClassPathResource("sqlmap.xml", UserDao.class);
}
@Bean
public static PropertySourcesPlaceholderConfigurer placeholderConfigurer() {
return new PropertySourcesPlaceholderConfigurer();
}
@Bean
public DataSource dataSource() {
SimpleDriverDataSource ds = new SimpleDriverDataSource();
ds.setDriverClass(this.driverClass);
ds.setUrl(this.url);
ds.setUsername(this.username);
ds.setPassword(this.password);
return ds;
}
@Bean
public PlatformTransactionManager transactionManager() {
DataSourceTransactionManager tm = new DataSourceTransactionManager();
tm.setDataSource(dataSource());
return tm;
}
@Configuration
@Profile("production")
public static class ProductionAppContext {
@Bean
public MailSender mailSender() {
JavaMailSenderImpl mailSender = new JavaMailSenderImpl();
mailSender.setHost("localhost");
return mailSender;
}
}
@Configuration
@Profile("test")
public static class TestAppContext {
@Bean
public UserService testUserService() {
return new TestUserService();
}
@Bean
public MailSender mailSender() {
return new DummyMailSender();
}
}
}
정리
스프링 사용자라면 객체지향적인 설계와 DI 를 효과적으로 활용하는 방법이 익숙해야한다. 스프링이 제공해주지 않는 기능도 직접 구현할 수 있어야 하고, 그때에도 적극적으로 DI 와 서비스 추상화, AOP 등을 활용할 수 있어야 한다.
SQL 처럼 변경될 수 있는 텍스트 정보는 외부 리소스에 담아두고 가져오게 만들면 편리하다.
성격이 다른 코드가 한데 섞여있는 클래스라면, 먼저 인터페이스를 정의해서 코드를 각 인터페이스별로 분리하는게 좋다. 다른 인터페이스에 속한 기능은 인터페이스를 통해 접근하게 만들고, 간단히 자기 참조 빈으로 의존관계를 만들어 검증한다. 검증을 마쳤으면 아예 클래스를 분리해도 좋다.
자주 사용되는 의존 오브젝트는 디폴트로 미리 정의해두면 편리하다.
XML 과 오브젝트 매핑은 스프링의 OXM 추상화 기능을 활용한다.
특정 의존 오브젝트를 고정시켜 기능을 특화하려면 멤버 클래스로 만드는 것이 편리하다. 기존에 만들어진 기능과 중복되는 부분은 위임을 통해 중복을 제거하는게 좋다.
외부 파일이나 리소스를 사용하는 코드에서는 스프링 리소스 추상화와 리소스 로더를 사용한다.
DI 를 의식하면서 코드를 작성하면 객체지향 설계에 도움이 된다.
DI 에는 인터페이스를 사용한다. 인터페이스를 사용하면 인터페이스 분리 원칙을 잘 지키는데도 도움이 된다.
클라이언트에 따라서 인터페이스를 분리할 때, 새로운 인터페이스를 만드는 방법과 인터페이스를 상속하는 방법 두가지를 사용할 수 있다.
어플리케이션에 내장하는 DB 를 사용할 때에는 스프링 내장형 DB 추상화 기능과 전용태그를 사용하면 편리하다.