✅ XML 파일을 이용하는 SQL 서비스
UserDaoJdbc에서 사용했던 SQL문을 이전에 학습했던 JAXB를 통하여 매핑하여 사용할 수 있도록 해보겠습니다. SQL문을 UserDao와 동일한 패키지에서 xml 파일로 작성합니다.
- sqlmap.xml
<?xml version="1.0" encoding="UTF-8"?> <sqlmap xmlns="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 "> <sql key="userAdd">insert into users(id, name, password, email, level, login, recommend) values(?,?,?,?,?,?,?)</sql> <sql key="userGet">select * from users where id = ?</sql> <sql key="userGetAll">select * from users order by id</sql> <sql key="userDeleteAll">delete from users</sql> <sql key="userGetCount">select count(*) from users</sql> <sql key="userUpdate">update users set name = ?, password = ?, email = ?, level = ?, login = ?, recommend = ? where id = ?</sql> </sqlmap>
JAXB를 통하여 언제 sqlmap.xml 파일을 읽어서 DAO가 사용할 수 있도록 해야할까요? DAO에서 요청이 들어올 때마다 xml 파일을 읽는 것은 너무 비효율적으로 보입니다. 특별한 이유가 없는 한 xml 파일은 한 번만 읽도록 해야합니다. 따라서 Map 타입의 객체에 저장해두고 SQL의 key와 value를 각각 Map의 key와 value로 저장합니다.
- UserDaoJdbc 변경
@Override public void add(User user) { this.jdbcTemplate.update( this.sqlService.getSql("userAdd"), user.getId(), user.getName(), user.getPassword(), user.getEmail(), user.getLevel().intValue(), user.getLogin(), user.getRecommend()); }
기존의 사용하던 UserDaoJdbc와 비교했을 때 sql문을 선택하는 key 값이 바뀌었기 때문에 UserDaoJdbc로 가서 모두 변경해야 합니다. add → userAdd, get → userGet 등으로 key 값에 맞추어 변경합니다.
- XmlSqlService
package com.jhcode.spring.ch7.user.slqservice; import java.io.InputStream; import java.util.HashMap; import java.util.Map; import javax.xml.bind.JAXBContext; import javax.xml.bind.JAXBException; import javax.xml.bind.Unmarshaller; import com.jhcode.spring.ch7.user.dao.UserDao; import com.jhcode.spring.ch7.user.slqservice.jaxb.SqlType; import com.jhcode.spring.ch7.user.slqservice.jaxb.Sqlmap; public class XmlSqlService implements SqlService { // 읽어온 SQL을 저장해 둘 객체 private Map<String, String> sqlMap = new HashMap<String, String>(); public XmlSqlService() { String contextPath = Sqlmap.class.getPackage().getName(); try { JAXBContext context = JAXBContext.newInstance(contextPath); Unmarshaller unmarshaller = context.createUnmarshaller(); InputStream is = UserDao.class.getResourceAsStream("sqlmap.xml"); Sqlmap sqlmap = (Sqlmap)unmarshaller.unmarshal(is); for (SqlType sql : sqlmap.getSql()) { sqlMap.put(sql.getKey(), sql.getValue()); } } catch (JAXBException e) { // JAXB는 복구 불가능한 예외로 Reuntime 예외로 포장해서 던진다 throw new RuntimeException(e); } } @Override public String getSql(String key) throws SqlRetrievalFailureException { String sql = sqlMap.get(key); if (sql == null) throw new SqlRetrievalFailureException(key + "를 찾을 수 없습니다."); else return sql; } }
UserDaoTest를 통해서 sqlmap.xml을 잘 읽고 테스트가 정상적으로 수행되는지 확인합니다. 이때 테스트가 잘 수행되지 않는다면 각 파일의 위치,
import
를 확인하여 정확한 클래스가import
되었는지 확인해 보는게 좋습니다.
✅ 빈 초기화 작업
생성자에서 예외가 발생할 수도 있는 복잡한 초기화 작업을 다루는 것은 좋지 않습니다. 상속하기도 불편하며 보안에 문제가 생일 수 있습니다. 또한 코드 속에 파일의 이름이나 위치가 함께 있는 것은 다른 여러가지 이유로 바뀔 수 있는 정보이기 때문에 외부에서 DI로 설정해주는 것이 좋습니다.
- XmlSqlService
// setter을 사용한 주입 private String sqlmapFile; public void setSqlmapFile(String sqlmapFile) { this.sqlmapFile = sqlmapFile; } // 생성자 대신 초기화에 사용할 메소드 public void loadSql() { String contextPath = Sqlmap.class.getPackage().getName(); try { JAXBContext context = JAXBContext.newInstance(contextPath); Unmarshaller unmarshaller = context.createUnmarshaller(); InputStream is = UserDao.class.getResourceAsStream(this.sqlmapFile); Sqlmap sqlmap = (Sqlmap)unmarshaller.unmarshal(is); for (SqlType sql : sqlmap.getSql()) { sqlMap.put(sql.getKey(), sql.getValue()); } } catch (JAXBException e) { // JAXB는 복구 불가능한 예외로 Reuntime 예외로 포장해서 던진다 throw new RuntimeException(e); } }
- TestDaoFactory
@Bean public SqlService sqlService() { XmlSqlService xmlSqlService = new XmlSqlService(); xmlSqlService.setSqlmapFile("sqlmap.xml"); xmlSqlService.loadSql(); return xmlSqlService; }
그러나 XmlSqlService 오즈벡트는 빈이므로 제어권이 스프링에 있습니다. 생성은 물론 초기화도 스프링에게 맡길 수밖에 없습니다. 그래서 직접 메소드를 호출하는 것이 아니라, @PostConstruct 어노테이션을 통하여 DI 작업을 수행해 프로퍼티를 모두 주입해준 뒤에 미리 지정한 초기화 메소드를 실행시킬 수 있도록 하겠습니다.
- pom.xml
<!-- For @PostConstruct annotation!!!--> <dependency> <groupId>jakarta.annotation</groupId> <artifactId>jakarta.annotation-api</artifactId> <version>1.3.5</version> </dependency>
@PostConstruct 어노테이션을 사용하기 위해서 의존성을 추가합니다.
- XmlSqlService
// 생성자 대신 초기화에 사용할 메소드 @PostConstruct public void loadSql() { String contextPath = Sqlmap.class.getPackage().getName();
@PostConstruct 어노테이션을 작성합니다. @PostConstruct 어노테이션을 초기화 작업을 수행할 메소드에 부여해주면 스프링은 XmlSqlService 클래스로 등록된 빈의 오브젝트를 생성하고 DI 작업을 마친 뒤에 @PostConstruct가 붙은 메소드를 자동으로 실행합니다.
✅ 인터페이스 분리
현재 XmlSqlService는 특정 포맷의 XML에서 SQL 데이터를 가져오고, 이를 HashMap 타입의 맵 오브젝트에 저장하고 있습니다. SQL을 가져오는 방법에 있어서 특정 기술, XML 파일 형식에만 국한되어 있습니다. XML 대신 다른 파일 포맷에서 SQL을 가지고 오려고 한다면 어떻게 해야할까요?
➡️ 현재 XmlSqlService는 두 가지 관심사항을 가지고 있습니다.
- SQL 정보를 외부의 리소스로부터 읽어오는 것
- 읽어온 SQL 정보를 필요할 때 제공해주는 것
또한 서비스를 위해서 어플리케이션을 종료하지 않고 SQL을 수정할 수 있도록 하고 싶습니다. 그렇기 위해서는 기본적으로 SqlService를 구현해 DAO에 서비스를 제공해주는 오브젝트가 1번과 2번의 책임을 가진 오브젝트와 협력하여 동작되게 해야 합니다. 이때 변경 가능한 기능들은 전략 패턴으로 구현하여 1번과 같은 기능은 파일의 유형에 맞게 전략적으로 바뀔 수 있도록 설계해야 합니다. 이러한 구조는 아래와 같은 그림이 될 것입니다.
어플리케이션이 처음 실행되면 SqlService가 SqlReader에게 SQL 리소스( XML 형식 등)의 파일을 읽어 SqlRegistry에게 전달하여 필요에 따라 SQL을 검색하고 사용할 수 있게 합니다. 하지만 SQL 리소스로부터 읽어온 정보를 SqlRegistry에게 전달하기 위해 Map 객체를 사용한다면 SqlRegistry는 이를 다시 2차원 배열에 저장하여 읽게 되는 번거로운 상황이 생깁니다.
그래서 두 객체 간의 데이터를 주고 받는 과정에서 SqlService 객체가 빠지고, SqlReader 객체가 SqlRegistry의 전략을 제공 받아, SQL 정보를 SqlRegistry에 저장하라고 요청하는 편이 낫습니다. 따라서 아래와 같은 의존관계가 생깁니다.
- SqlRegistry interface
package com.jhcode.spring.ch7.user.slqservice.jaxb; import com.kitec.springframe.ch7.study2_6.springframe.sqlservice.SqlNotFoundException; public interface SqlRegistry { void registerSql(String key, String sql); String findSql(String key) throws SqlNotFoundException; }
- SqlReader interface
package com.jhcode.spring.ch7.user.slqservice.jaxb; public interface SqlReader { // SQL을 외부에서 가져와 SqlRegistry에 등록하여 사용한다 void read(SqlRegistry sqlRegistry); }
어떤 타입의 파일이든 상관없이 구현체에 따라서 파일을 읽을 수 있도록 확장하는 단계입니다. SqlReader은 파일의 SQL을 읽고, SqlRegistry에 등록함으로써 SQL을 사용할 수 있도록합니다. SqlRegistry는 등록된 SQL을 검색하여 사용할 수 있도록 합니다.
✅ 자기참조 빈, 다중 인터페이스 구현
인터페이스는 한 개 이상을 상속하는 것이 허용됩니다. 명확하게는 implements 키워드를 통해서 구현한다고 이야기하지만 일종의 상속의 개념이라고 볼 수도 있습니다. 따라서 하나의 클래스가 여러 개의 인터페이스를 상속해서 여러 종류의 타입으로서 존재할 수 있는 것입니다.
여러 개의 인터페이스를 통해서 XmlSqlService 클래스를 세분화하여 책임을 구분한 인터페이스를 구현하도록 만드는 것입니다. 그래서 같은 클래스의 코드이지만 책임이 다른 코드는 직접 접하지 않고 인터페이스를 통해 간접적으로 사용하는 코드를 사용하게 됩니다. 이제 SqlReader와 SqlRegistry, SqlService를 구현한 XmlSqlService 클래스를 작성하고, 이 구현 클래스를 XmlSqlService에서 인터페이스의 타입으로 DI 받아 책임이 다른 코드를 사용할 수 있도록 만들겠습니다.
- XmlSqlService
package com.jhcode.spring.ch7.user.slqservice; import java.io.InputStream; import java.util.HashMap; import java.util.Map; import javax.annotation.PostConstruct; import javax.xml.bind.JAXBContext; import javax.xml.bind.JAXBException; import javax.xml.bind.Unmarshaller; import com.jhcode.spring.ch7.user.dao.UserDao; import com.jhcode.spring.ch7.user.slqservice.exception.SqlNotFoundException; import com.jhcode.spring.ch7.user.slqservice.exception.SqlRetrievalFailureException; import com.jhcode.spring.ch7.user.slqservice.jaxb.SqlType; import com.jhcode.spring.ch7.user.slqservice.jaxb.Sqlmap; public class XmlSqlService implements SqlService, SqlRegistry, SqlReader { //== SqlService 구현부 ==// private SqlReader sqlReader; private SqlRegistry sqlRegistry; public void setSqlReader(SqlReader sqlReader) { this.sqlReader = sqlReader; } public void setSqlRegistry(SqlRegistry sqlRegistry) { this.sqlRegistry = sqlRegistry; } @Override public String getSql(String key) throws SqlRetrievalFailureException { try { return this.sqlRegistry.findSql(key); } catch (SqlNotFoundException e) { throw new SqlRetrievalFailureException(e); } } //== SqlRegistry 구현부 ==// private Map<String, String> sqlMap = new HashMap<String, String>(); @Override public String findSql(String key) throws SqlNotFoundException { String sql = sqlMap.get(key); if (sql == null) throw new SqlNotFoundException(key + "에 대한 SQL을 찾을 수 없습니다."); else return sql; } @Override public void registerSql(String key, String sql) { sqlMap.put(key, sql); } //== SqlReader 구현부 ==// private String sqlmapFile; public void setSqlmapFile(String sqlmapFile) { this.sqlmapFile = sqlmapFile; } @Override public void read(SqlRegistry sqlRegistry) { String contextPath = Sqlmap.class.getPackage().getName(); try { JAXBContext context = JAXBContext.newInstance(contextPath); Unmarshaller unmarshaller = context.createUnmarshaller(); InputStream is = UserDao.class.getResourceAsStream(this.sqlmapFile); Sqlmap sqlmap = (Sqlmap)unmarshaller.unmarshal(is); for (SqlType sql : sqlmap.getSql()) { sqlRegistry.registerSql(sql.getKey(), sql.getValue()); } } catch (JAXBException e) { throw new RuntimeException(e); } } //== XmlSqlService 구현 방법에 따른 초기화 메소드 ==// @PostConstruct public void loadSql() { this.sqlReader.read(this.sqlRegistry); } }
하나의 클래스에 여러 개의 인터페이스를 상속 받아 구현했지만, 언제라도 클래스를 분리할 수 있습니다. 따라서 각각의 기능들은 인터페이스의 구현부에 맞게 사용되어야 합니다. DI 설정을 할 때에도 하나의 SqlService 빈을 등록했지만, 마치 세 개의 빈이 등록된 것처럼 DI 받도록 해주어야 합니다.
- TestDaoFactory
@Bean public SqlService sqlService() { XmlSqlService xmlSqlService = new XmlSqlService(); xmlSqlService.setSqlReader(xmlSqlService); xmlSqlService.setSqlRegistry(xmlSqlService); xmlSqlService.setSqlmapFile("sqlmap.xml"); return xmlSqlService; }
Java-based-Configuration에서 자기 참조 빈을 설정하는 방법입니다. XmlSqlService에서 자기 자신을 setter으로 주입받고 있지만, 인터페이스 타입으로 DI 받기 때문에 XmlSqlService에서 인터페이스의 정의된 메소드를 통해서 해당 메소드를 호출하여 사용할 수 있게 됩니다.
✅ 디폴트 의존관계
인터페이스에 따라 메소드를 구분해서 DI가 가능하도록 코드를 재구성했습니다. 다음은 이를 완전히 분리해두고 DI로 조합해서 사용하게 만들도록 하겠습니다.
- BaseSqlService
package com.jhcode.spring.ch7.user.slqservice; import javax.annotation.PostConstruct; import com.jhcode.spring.ch7.user.slqservice.exception.SqlRetrievalFailureException; public class BaseSqlService implements SqlService{ // XmlSqlService에서 SqlService 구현부를 분리 private SqlReader sqlReader; private SqlRegistry sqlRegistry; public void setSqlReader(SqlReader sqlReader) { this.sqlReader = sqlReader; } public void setSqlRegistry(SqlRegistry sqlRegistry) { this.sqlRegistry = sqlRegistry; } @PostConstruct public void loadSql() { this.sqlReader.read(this.sqlRegistry); } @Override public String getSql(String key) throws SqlRetrievalFailureException { try { return this.sqlRegistry.findSql(key); } catch (Exception e) { throw new SqlRetrievalFailureException(e); } } }
- HashMapSqlRegistry
package com.jhcode.spring.ch7.user.slqservice; import java.util.HashMap; import java.util.Map; import com.jhcode.spring.ch7.user.slqservice.exception.SqlNotFoundException; import com.kitec.springframe.ch7.study2_6.springframe.sqlservice.SqlRetrievalFailureException; public class HashMapSqlRegistry implements SqlRegistry { //== SqlRegistry 구현부를 분리 ==// private Map<String, String> sqlMap = new HashMap<String, String>(); @Override public String findSql(String key) throws SqlNotFoundException { String sql = sqlMap.get(key); if (sql == null) throw new SqlRetrievalFailureException(key + ": SQL을 찾을 수 없습니다."); else return sql; } @Override public void registerSql(String key, String sql) { sqlMap.put(key, sql); } }
- JaxbXmlSqlReader
package com.jhcode.spring.ch7.user.slqservice; import java.io.InputStream; import javax.xml.bind.JAXBContext; import javax.xml.bind.JAXBException; import javax.xml.bind.Unmarshaller; import com.jhcode.spring.ch7.user.dao.UserDao; import com.jhcode.spring.ch7.user.slqservice.jaxb.SqlType; import com.jhcode.spring.ch7.user.slqservice.jaxb.Sqlmap; public class JaxbXmlSqlReader implements SqlReader { //== SqlReader 구현부를 분리 ==// private String sqlmapFile; //sqlmap은 SqlReader의 특정 구현 방식에 종속됨 -> 외부에서 주입 public void setSqlmapFile(String sqlmapFile) { this.sqlmapFile =sqlmapFile; } @Override public void read(SqlRegistry sqlRegistry) { String contextPath = Sqlmap.class.getPackage().getName(); try { JAXBContext context = JAXBContext.newInstance(contextPath); Unmarshaller unmarshaller = context.createUnmarshaller(); InputStream is = UserDao.class.getResourceAsStream(this.sqlmapFile); Sqlmap sqlmap = (Sqlmap)unmarshaller.unmarshal(is); for (SqlType sql : sqlmap.getSql()) { sqlRegistry.registerSql(sql.getKey(), sql.getValue()); } } catch (JAXBException e) { throw new RuntimeException(e); } } }
- TestDaoFactory
@Bean public SqlService sqlService() { BaseSqlService sqlService = new BaseSqlService(); sqlService.setSqlReader(sqlReader()); sqlService.setSqlRegistry(sqlRegistry()); return sqlService; } @Bean public SqlRegistry sqlRegistry() { HashMapSqlRegistry sqlRegistry = new HashMapSqlRegistry(); return sqlRegistry; } @Bean public SqlReader sqlReader() { JaxbXmlSqlReader sqlReader = new JaxbXmlSqlReader(); sqlReader.setSqlmapFile("sqlmap.xml"); return sqlReader; }
BaseSqlService는 SqlReader와 SqlRegistry를 구현한 클래스들을 인터페이스 타입으로 DI 받습니다. 유연성을 보장하기 위해서 이런 구조가 꼭 필요하지만 빈을 3개나 등록해야 한다는 점에서 번거롭기 때문에 특정 오브젝트가 대부분의 환경에서 기본적으로 사용될 의존관계가 있다면, 이러한 의존관계를 기본적으로 가지는 클래스를 만들어 사용하면 보다 간결하게 사용할 수 있습니다. 이를 디폴트 의존관계를 갖는 빈이라고 합니다.
- DefaultSqlService
package com.jhcode.spring.ch7.user.slqservice; public class DefaultSqlService extends BaseSqlService { // 생성자를 통해서 디폴트로 사용할 객체를 생성하고 의존관계를 설정한다 public DefaultSqlService() { setSqlReader(new JaxbXmlSqlReader()); setSqlRegistry(new HashMapSqlRegistry()); } }
- TestDaoFactory
@Bean public SqlService sqlService() { DefaultSqlService sqlService = new DefaultSqlService(); return sqlService; }
이렇게 Bean 설정을 간단하게 바꿀 수 있습니다. 하지만 여기서 문제는 JaxbXmlSqlReader가 가지고 있는 sqlMap 프로퍼티에 대해서 외부에서 주입해주었지만, 지금은 기본적으로 JaxbXmlSqlReader을 생성하고 있기 때문에 외부에서 주입하고 있지 않습니다. 그래서 JaxbXmlSqlReader가 기본적으로 사용할 xml 파일에 대한 정보를 가지고 있어야 합니다. 또한 확장성을 위해서 외부에서 주입한 정보를 가지고 해당 file의 xml 정보를 읽어올 수 있도록 만들어 보겠습니다.
- JaxbXmlSqlReader
public class JaxbXmlSqlReader implements SqlReader { //== SqlReader 구현부를 분리 ==// private static final String DEFAULT_SQLMAP_FILE = "sqlmap.xml"; private String sqlmapFile = DEFAULT_SQLMAP_FILE; public void setSqlmapFile(String sqlmapFile) { this.sqlmapFile =sqlmapFile; }
상수로 정의하는 이유는 코드의 가독성과 의미를 명확하게 하기 위해서입니다. sqlmapFile은 기본적으로 상수값인 DEFAULT_SQLMAP_FILE에 대한 정보를 사용하지만 setter을 통해 주입 받으면 외부로부터 주입 받은 정보를 통하여 사용하게 됩니다.
👉 이렇게 디폴트 의존 오브젝트를 사용하는 방법은 한 가지 단점이 존재하게 되는데 DI 설정을 통해서 다른 구현 오브젝트를 주입하여 사용하더라도 DefaultSqlService는 생성자를 통해서 기존에 생성하고 주입하던 디폴트 의존 오브젝트를 모두 만들어버린다는 점입니다. 이때 @PostConstruct를 사용해서 초기화 메소드를 통해서 해당 객체가 생성되었는지 확인하고 없다면 디폴트 의존 오브젝트를 생성하는 방법을 사용할 수 있습니다.
// 기존에 생성자를 통해서 만들던 Default 의존 관계를 제거
// BasSqlService의 필드를 Protected로 변경하여 DefaultSqlServcie에서 사용할 수 있도록 변경
@PostConstruct
public void loadDefault() {
if (super.sqlReader == null) {
setSqlReader(new JaxbXmlSqlReader());
}
if (super.sqlRegistry == null) {
setSqlRegistry(new HashMapSqlRegistry());
}
}
📖 토비 스프링 3.1 -p573~596
🚩jhcode33의 toby-spring-study.git으로 이동하기
Uploaded by N2T