-
토비의 스프링[7] 스프링 핵심기술과 응용Spring 2021. 4. 14. 22:18
SQL과 DAO의 분리
현재 기능이 변경되어 SQL이 변경될 경우 SQL을 담고 있는 DAO 코드가 수정될 수밖에 없다.
XML 설정을 이용한 분리
가장 손쉽게 SQL을 분리하는 방법으로는 SQL을 스프링의 XML 설정파일로 빼내는 것이다.
개별 SQL 프로퍼티 방식
UserDao 메소드의 SQL문장을 제거하고 외부로부터 DI 받은 SQL문장을 이용해서 쿼리를 실행한다.
SQL 맵 프로퍼티 방식
SQL이 점점 많아지면 그때마다 DAO에 DI용 프로퍼티를 추가하기가 상당히 귀찮기에 스프링 XML 설정을 통한 컬렉션으로 담아주는 방법을 시도하여 SQL을 담을 XML 설정 프로퍼티 타입을 스프링이 제공하는 map과 entry를 이용하여 userDao빈의 프로퍼티를 수정해보자.
SQL 제공 서비스
스프링의 설정파일 안에 SQL을 두고 이를 DI 해서 DAO가 사용하게 하면 손 쉽게 SQL을 분리할 수 있지만 적용하기까지 몇 가지 문제점이 존재한다.
- SQL과 DI 설정정보가 섞여 보기에 지저분하며, 관리하기 힘들다.
- 굳이 SQL을 스프링 빈 설정 방법을 사용해 XML에 담아둘 이유가 없다. SQL을 편집하고 관리할 수 있는 툴에서 생성해주는 SQL정보 파일이 있다면, 그런 파일 포맷그대로 사용할 수 있어야 편할 것이다.
SQL 서비스 인터페이스
가장 먼저 할 일은 SQL 서비스 인터페이스를 설계하는 것이다. 클라이언트인 DAO를 SQL 서비스의 구현에서 독립적으로 만들도록 인터페이스를 사용하고, DI로 구현 클래스의 오브젝트를 주입해주어야 한다는 사실쯤은 바로 떠올릴 수 있을 것이다. DAO가 사용할 SQL 서비스의 기능은 SQL에 대한 키 값을 전달하면 그에 해당하는 SQL을 돌려주는 것이다.
public interface SqlService { String getSql(String key) throws SqlRetrievalFailureException; } public class SqlRetrievalFailureException extends RuntimeException { public SqlRetrievalFailureException(String message) { super(message); } public SqlRetrievalFailureException(String message, Throwable cause) { super(message, cause); } }
주어진 키를 가지고 SQL을 가져오다가 어떤 이유든 실패한 경우 예외를 던지도록 정의한다. 물론 예외를 복구할 수 없기에 런타임 예외로 정의한다.
이제 UserDaoJdbc는 SqlService 인터페이스를 통해 필요한 SQL을 가져와 사용할 수 있도록 만들어준다.
public UserDaoJdbc(SqlService sqlService, DataSource dataSource) { this.jdbcTemplate = new JdbcTemplate(dataSource); this.sqlService = sqlService; } @Override public void add(final User user) throws DuplicateUserIdException { this.jdbcTemplate.update(this.sqlService.getSql("userAdd"), user.getId(), user.getName(), user.getPwd(), user.getLevel().intValue(), user.getLogin(), user.getRecommend(), user.getEmail()); // add method none throw SQLException }
스프링 설정을 사용하는 단순 SQL 서비스
SqlService 인터페이스에는 어떤 기술적인 조건이나 제약사항도 담겨 있지 않다. 어떤 방법이든 상관없이 DAO가 요구하는 SQL을 돌려주기만 하면 된다.
public class SimpleSqlService implements SqlService { private final Map<String, String> sqlMap; public SimpleSqlService(Map<String, String> sqlMap) { this.sqlMap = sqlMap; } @Override public String getSql(String key) throws SqlRetrievalFailureException { String sql = sqlMap.get(key); if (sql == null) { throw new SqlRetrievalFailureException(key + "에 대한 SQL을 찾을수 없습니다"); } return sql; } }
SimpleSqlService 클래스를 빈으로 등록하고 UserDao가 DI받도록 설정해준다. SQL 정보는 이 빈 프로퍼티에 map 태그를 이용하여 등록하면 된다.
인터페이스의 분리와 자기참조 빈
XML 파일 매핑
스프링의 XML 설정파일에서 <bean> 태그 안에 SQL 정보를 넣어놓고 활용하는 건 좋은 방법이 아니다. 그보다 SQL을 저장해두는 전용 포맷을 가진 독립적인 파일을 이용하는 편이 바람직하다. 독립적이라 해도 가장 편리한 포맷은 XML이다.
JAXB
XML의 담긴 정보를 가져오는 방법은 여러가지 이지만 여기서는 Java Archbitecture for XML Binding을 시용해보자. (JDK6)
JAXB는 XML 문서의 구조를 정의한 스키마를 이용해서 매핑할 오브젝트의 클래스까지 자동으로 만들어주는 컴파일러도 제공해준다. 스키마 컴파일러를 통해 자동생성 된 오브젝트에는 매핑정보가 애노태이션으로 담겨 있다. JAXB API는 애노테이션에 담긴 정보를 이용해서 XML과 매핑된 오브젝트 트리 사이의 자동변환 작업을 수행해준다.
SQL 맵을 위한 스키마 작성과 컴파일
SQL 정보는 키와 SQL의 목록으로 구성된 맵 구조로 만들어두면 편리하다. 키와 SQL 정보를 담은 <sql> 태그를 가진 XML 문서를 사용하자.
<sqlMap> <sql key="userAdd">insert into users(id, name, pwd, level, login, recommend, email) values(?, ?, ?, ?, ?, ?, ?)</sql> ... </sqlMap>
이 XML 문서의 구조를 정의하는 스키마를 만들어보자
<?xml version="1.0" encoding="UTF-8" ?> <schema xmlns="http://www.w3.org/2001/XMLSchema" targetNamespace="http://www.epril.com/sqlmap" xmlns:tns="http://www.epril.com/sqlmap" elementFormDefault="qualified"> <element name="sqlmap"> <complexType> <sequence> <element name="sql" maxOccurs="unbounded" type="tns:sqlType"/> </sequence> </complexType> </element> <complexType name="sqlType"> <simpleContent> <extension base="string"> <attribute name="key" use="required" type="string"/> </extension> </simpleContent> </complexType> </schema>
이렇게 만든 스키마를 sqlmap.xsd라는 이름으로 저장하고, JAXB 컴파일러로 컴파일해보자. 컴파일할 때는 자동으로 생성되는 바인딩용 클래스들이 위치할 패키지 이름을 제공해줘야 한다.
xjc -p {패키지이름} {변환할 스키마 파일} -d {생성된 파일이 저장될 위치}
package com.example.toby.jiw.dao.sql.jaxb; import java.util.ArrayList; import java.util.List; import javax.xml.bind.annotation.XmlAccessType; import javax.xml.bind.annotation.XmlAccessorType; import javax.xml.bind.annotation.XmlElement; import javax.xml.bind.annotation.XmlRootElement; import javax.xml.bind.annotation.XmlType; @XmlAccessorType(XmlAccessType.FIELD) @XmlType(name = "", propOrder = { "sql" }) @XmlRootElement(name = "sqlmap") public class Sqlmap { @XmlElement(required = true) protected List<SqlType> sql; public List<SqlType> getSql() { if (sql == null) { sql = new ArrayList<SqlType>(); } return this.sql; } } @XmlAccessorType(XmlAccessType.FIELD) @XmlType(name = "sqlType", propOrder = { "value" }) public class SqlType { @XmlValue protected String value; @XmlAttribute(name = "key", required = true) protected String key; public String getValue() { return value; } public void setValue(String value) { this.value = value; } public String getKey() { return key; } public void setKey(String value) { this.key = value; } } @XmlRegistry public class ObjectFactory { /** * Create a new ObjectFactory that can be used to create new instances of schema derived classes for package: jaxb * */ public ObjectFactory() { } /** * Create an instance of {@link Sqlmap } * */ public Sqlmap createSqlmap() { return new Sqlmap(); } /** * Create an instance of {@link SqlType } * */ public SqlType createSqlType() { return new SqlType(); } }
명령어를 실행하면 다음과 같은 두 개의 바인딩용 자바 클래스와 팩토리 클래스가 만들어 진다.
언마샬링
JAXB API를 이용하여 XML 문서를 읽어 자바 오브젝트로 변환한느 것을 언마샬링이라 부르며
반대는 마샬링이라 부른다(java object-> xml)
JAXB 학습테스트
public class JaxbTest { @Test public void readSqlmap() throws JAXBException, IOException { String contextPath = Sqlmap.class.getPackage().getName(); JAXBContext context = JAXBContext.newInstance(contextPath); Unmarshaller unmarshaller = context.createUnmarshaller(); // dir : resource/sql/sqlmap.xml Sqlmap sqlmap = (Sqlmap) unmarshaller.unmarshal(getClass().getResourceAsStream("/sql/sqlmap.xml")); List<SqlType> sqlList = sqlmap.getSql(); Assertions.assertEquals(sqlList.size(), 6); Assertions.assertEquals(sqlList.get(0).getKey(), "userAdd"); Assertions.assertEquals(sqlList.get(0).getValue(), "insert into users(id, name, pwd, level, login, recommend, email) values(?, ?, ?, ?, ?, ?, ?)"); Assertions.assertEquals(sqlList.get(1).getKey(), "userGet"); Assertions.assertEquals(sqlList.get(1).getValue(), "select * from users where id = ?"); Assertions.assertEquals(sqlList.get(2).getKey(), "userGetAll"); Assertions.assertEquals(sqlList.get(2).getValue(), "select * from users order by id"); Assertions.assertEquals(sqlList.get(3).getKey(), "userDeleteAll"); Assertions.assertEquals(sqlList.get(3).getValue(), "delete from users"); Assertions.assertEquals(sqlList.get(4).getKey(), "userGetCount"); Assertions.assertEquals(sqlList.get(4).getValue(), "select count(*) from users"); Assertions.assertEquals(sqlList.get(5).getKey(), "userUpdate"); Assertions.assertEquals(sqlList.get(5).getValue(), "update users set name = ?, pwd = ?, level = ?, login = ?, recommend = ?, email = ? where id = ?"); } }
XML 파일을 이용하는 SQL 서비스
SqlService에서 사용할 sqlmap.xml을 제작한 이후 sqlmap.xml에 있는 SQL을 가져와 DAO에 제공해주는 SqlService 인터페이스의 구현 클래스를 제작해보자.
- XML에서 SQL을 가져오는 방법은 학습테스트에서 사용한 JAXB API를 사용하면 된다.
- DAO가 SQL을 요청할 때 마다 XML 파일을 다시 읽어서 SQL을 찾는건 너무 비효율적이므로 한번만 읽도록 해야한다. XML 파일로 부터 읽은 내용은 어딘가에 저장해두고 DAO에서 요청이 올 떄 사용해야 한다.
- 일단 간단하게 생성자에서 SQL을 읽어 내부에 저장해두는 초기 작업을 하자.
public class XmlSqlService implements SqlService { private final Map<String, String> sqlMap = new HashMap<>(); public XmlSqlService() { String contextPath = Sqlmap.class.getPackage().getName(); try { JAXBContext context = JAXBContext.newInstance(contextPath); Unmarshaller unmarshaller = context.createUnmarshaller(); Sqlmap sqlmap = (Sqlmap) unmarshaller.unmarshal(getXmlFile("mapper/sqlmap.xml")); for (SqlType sql : sqlmap.getSql()) { sqlMap.put(sql.getKey(), sql.getValue()); } } catch (JAXBException e) { throw new RuntimeException(e); } } @Override public String getSql(String key) throws SqlRetrievalFailureException { String sql = sqlMap.get(key); if (sql == null) { throw new SqlRetrievalFailureException(key + "를 이용해서 SQL을 찾을 수 없습니다"); } return sql; } private File getXmlFile(String fileName) { ClassLoader classLoader = getClass().getClassLoader(); return new File(classLoader.getResource(fileName).getFile()); } }
SQL 문장을 스프링의 빈 설정에서 완벽하게 분리하는데 성공했다. 독자적인 스키마를 갖는 깔끔한 XML 문서이므로 작성하고 검증하기에도 편리하고, 필요하다면 다른 툴에서도 불러서 사용할 수 있다.
SQL 리뷰나 튜닝이 필요하다면 sqlmap.xml 파일만 제공해주면 된다.
빈의 초기화 작업
아무래도 생성자에서 예외가 발생할 수도 있는 복잡한 초기화 작업을 다루는 것은 좋지 않다. 오브젝트를 생성하는 중에 생성자에서 발생하는 예외는 다루기 힘들고, 상속하기 불편하며, 보안에도 문제가 생길 수 있다. 일단 초기 상태를 가진 오브젝트를 만들어 놓고 별도의 초기화 메소드를 사용하는 방법이 바람직하다.
또 다른 문제점으로는 읽어들일 파일의 위치와 이름이 하드코딩 되어 있는점이다. 코드의 로직과 여타 이유로 바뀔 가능성이 있는 내용은 외부에서 DI로 설정해줄 수 있게 만들어야 한다.
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(); Sqlmap sqlmap = (Sqlmap) unmarshaller.unmarshal(getXmlFile(this.sqlMapFile)); for (SqlType sql : sqlmap.getSql()) { sqlMap.put(sql.getKey(), sql.getValue()); } } catch (JAXBException e) { throw new RuntimeException(e); } }
이제 외부에서 사용할 XML파일을 지정해줄 수 있고, 이를 이용해 SQL을 읽어들이는 초기화 작업을 담당할 메소드도 별도로 제작하였다. 이 XmlSqlService 오브젝트의 대한 제어권한이 우리가 만든 코드에 있다면 생성과 동시에 초기화 메소드를 호출하면 되지만 XmlSqlService는 빈이므로 제어권이 스프링에 있다. 생성은 물론 초기화도 스프링에게 맡길 수 밖에 없다.
@PostConstruct 어노테이션은 공통 어노테이션으로 빈 오브젝트 초기화 메소드를 지정하는 데 사용한다.
초기화 작업을 진행할 메소드에 어노테이션을 붙일경우 스프링은 XmlSqlService 클래스로 등록된 빈의 오브젝트를 생성 및 DI 작업을 마친 뒤 어노테이션이 붙은 메소드를 자동으로 실행해준다.
변화를 위한 준비: 인터페이스 분리
잘 만들었다고 볼 수 있겠지만, SQL을 가져오는 방법에 있어서는 특정 기술에 고정되어 있다. 또한 가져온 SQL 정보를 HashMap 타입 컬렉션이 아닌 다른 방식으로 저장해두고 이를 검색해서 가져오려면 지금까지 만든 코드를 직접 고치거나 새롭개 만들어야 한다.
XMLSqlService가 변경되는 이유가 두 가지라면 이는 단일 책임 원칙을 위반하는 셈이다. 그렇다고 한 가지 기술의 변화 때문에 아예 새로운 클래스를 만들면 상당 부분의 코드가 중복되는 결과를 초래할 것이다.
책임에 따른 인터페이스 정의
가장 먼저 할 일은 분리 가능한 관심사를 구분해보는 것이다. XmlSqlService 구현을 참고해서 독립적으로 변경 가능한 책임을 뽑아보자. 두 가지를 생각해볼 수 있다.
- SQL 정보를 외부의 리소스로부터 읽어오는 것이다.
- 읽어온 SQL을 보관해두고 있다가 필요할 때 제공해주는 것이다.
- 또한 부가적인 책임으로는서비스를 위해서한번 가져온 SQL을 필요에 따라 수정할 수 있게 하는 것이다.
DAO관점에서는 SqlService라는 인터페이스를 구현한 오브젝트에만 의존하고 있으므로 달라질 것은 없다. 대신 SqlService의 구현 클래스가 변경 가능ㅎ나 책임을 가진 SqlReader, SqlRegistry 두 가지 타입의 오브젝트를 사용하도록 만든다.
SqlReader가 읽어오는 SQL 정보는 다시 SqlRegistry에 전달해서 등록되게 해야 한다. 그렇다면 SqlReader가 제공하는 메소드의 리턴 타입은 무엇으로 해야 할까?
// Map이라는 구체적인 전송 타입을 강제하게 된다. Map<String, String> sqls = sqlReader.readSql(); sqlRegistry.addSqls(sqls);
- SQL을 임의의 리소스로부터 가져올 수 있기 때문에 JAXB에서 만들어준 Sql 클래스를 사용하는건 곤란하다.
- 맵을 리턴하는 경우 : 둘 사이에서 정보를 전달하기 위해 일시적으로 Map 타입의 형식을 갖도록 만들어야 한다는 건 조금 불편하다.
SqlService에서 가져온 SQL 정보를 활용한다면 몰르까, 두 오브젝트 사이의 정보를 전달하는 것이 전부라면 SqlService가 중간 과정에서 아예 빠지는 방법을 생각해볼 수 있다. SqlService가 SqlReader에게 데이터를 달라고 요청하고, 다시 SqlRegistry에게 이 데이터를 사용하라고 하는 것보다는 SqlReader에게 SqlRegistry 전략을 제공해주면서 이를 이용해 SQL 정보를 SqlRegistry에 저장하라고 요청하는 편이 낫다.
sqlReader.readSql(sqlRegistry); // SQL을 저장할 대상인 SqlRegistry 오브젝트를 전달한다.
- 불필요하게 SqlService 코드를 통해 특정 포맷으로 변환한 SQL 정보를 주고받을 필요없이 SqlReader가 직접 SqlRegistry에 SQL 정보를 등록한다.
- 자바의 오브젝트는 데이터를 가질 수 있다. 자신이 가진 데이터를 이용해 어떻게 작업해야 할지도 가장 잘 알고 있다. 그렇다면 오브젝트 스스로 자신의 데이터로 충실히 작업하게 만들면 되지, 쓸데없이 오브젝트 내부의 데이터를 외부로 노출시킬 필요는 없다.
- SqlReader가 사용할 SqlRegistry 오브젝트를 제공해주는 건 SqlService의 코드가 담당한다.
public interface SqlRegistry { void registerSql(String key, String vale); String findSql(String key) throws SqlNotFoundException; // 복구 불가능 RunTime예외 } public interface SqlReader { void read(SqlRegistry sqlRegistry); }
자기참조 빈으로 시작하기
SqlService의 구현 클래스는 이제 SqlReader와 SqlRegistry 두 개의 프로퍼티를 DI받을 수 있는 구조로 만들어야 한다. 일단 기존에 만들었던 XmlSqlprovider를 이 구조에 맞게 변경하자. 인터페이스가 총 3개이므로 각 인터페이스를 구현한 클래스 3개를 만들어야 한다.
이제 이것을 각 책임에 따라 구분할 차례이다.
그런데 의존관계를 가만히 들여다보면 모든 클래스는 인터페이스에만 의존하고 있음을 알 수 있다. 당연히 인터페이스에만 의존하도록 만들어야 스프링의 DI를 적용할 수 있다. 굳이 DI를 적용하지 않았더라도 자신이 사용하는 오브젝트의 클래스가 어떤 것인지를 알지 못하게 만드는 것이 좋다. 그래야 구현 클래스를 바꾸고 의존 오브젝트를 변경해서 자유롭게 확장할 기회를 제공하는 것이다.
그렇다면 이 세 개의 인터페이스를 하나의 클래스가 모두 구현하게 해보자(다중상속)
package com.example.toby.jiw.dao.sql; import com.example.toby.jiw.dao.UserDao; import com.example.toby.jiw.dao.sql.jaxb.SqlType; import com.example.toby.jiw.dao.sql.jaxb.Sqlmap; import javax.annotation.PostConstruct; import javax.xml.bind.JAXBContext; import javax.xml.bind.JAXBException; import javax.xml.bind.Unmarshaller; import java.io.InputStream; import java.util.HashMap; import java.util.Map; public class XmlSqlService implements SqlService, SqlRegistry, SqlReader { private Map<String, String> sqlMap = new HashMap<>(); private String sqlmapFile; private SqlReader sqlReader; private SqlRegistry sqlRegistry; public XmlSqlService() { } public XmlSqlService(String sqlmapFile, SqlRegistry sqlRegistry, SqlReader sqlReader) { this.sqlmapFile = sqlmapFile; this.sqlRegistry = sqlRegistry; this.sqlReader = sqlReader; } @PostConstruct public void loadSql() { this.sqlReader.read(this.sqlRegistry); } public String getSql(String key) throws SqlRetrievalFailureException { try { return this.sqlRegistry.findSql(key); } catch (SqlNotFoundException e) { throw new SqlRetrievalFailureException(e.getMessage(), e.getCause()); } } @Override public void registerSql(String key, String sql) { sqlMap.put(key, sql); } @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 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) { e.printStackTrace(); throw new RuntimeException(e); } } }
- 같은 클래스 안에 구현된 내용이기는 하지만 SqlService의 메소드에서 SQL을 읽을 때는 SqlReader 인터페이스를 통해, SQL을 찾을 때는 SqlRegistry 인터페이스를 통해 간접적으로 접근하게 한다.
- 자기 자신을 참조하는 빈은 사실 흔히 쓰이는 방법은 아니다. 책임이 다르다면 클래스를 구분하고 각기 다른 오브젝트로 만드는것이 더 자연스럽다.
자기참조빈
자기참조빈은 책임과 관심사가 복잡하게 얽혀 있어서 확장이 힘들고 변경에 취약한 구조의 클래스를 유연한 구조로 만들려고 할 때 처음 시도해볼 수 있는 방법이다. 이를 통해 기존의 복잡하게 얽혀 있던 코드를 채깅ㅁ을 가진 단위로 구분해낼 수 있다.
디폴트 의존관계
확장 가능한 기반 클래스
SqlRegistry와 SqlReader를 이용하는 가장 간단한 SqlService 구현 클래스를 만들어보자. 앞에서 자기참조가 가능한 빈으로 만들었던 XmlSqlService 코드에서 의존 인터페이스와 그 구현 코드를 제거하기만 하면 된다.
public class BaseSqlService implements SqlService { protected SqlReader sqlReader; protected 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 (SqlNotFoundException e) { throw new SqlRetrievalFailureException(e.getMessage(), e.getCause()); } } }
public class HashMapSqlRegistry implements SqlRegistry { private Map<String, String> sqlMap = new HashMap<>(); @Override public void registerSql(String key, String sql) { sqlMap.put(key, sql); } @Override public String findSql(String key) throws SqlNotFoundException { String sql = sqlMap.get(key); if (sql == null) { throw new SqlNotFoundException(key + "를 이용해서 SQL을 찾을 수 없습니다."); } else { return sql; } } } public class JaxbXmlSqlReader implements SqlReader { private static final String DEFAULT_SQLMAP_FILE = "/sql/sqlmap.xml"; private String sqlmapFile = DEFAULT_SQLMAP_FILE; 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) { e.printStackTrace(); throw new RuntimeException(e); } } }
- HashMap을 이용해 SQL을 저장해두고 찾아주는 기능을 담당했던 코드를 SqlRegistry를 구현하는 독립 클래스로 분리한다.
- JAXB를 이용해 XML 파일에서 SQL 정보를 읽어오는 코드를 SqlReader 인터페이스의 구현 클래스로 독립시킨다.
디폴트 의존관계를 갖는 빈 만들기
확장을 고려해서 기능을 분리하고, 인터페이스와 전략 패턴을 도입하고, DI를 적용한다면 늘어난 클래스와 인터페이스 구현과 의존관계 설정에 대한 부담은 감수해야 한다. 특정 의존 오브젝트가 대부분의 환경에서 거의 디폴트라고 해도 좋을 만큼 기본적으로 사용될 기능성이 있다면, 디폴트 의존관계를 갖는 빈을 만드는 것을 고려해볼 필요가 있다.
- 디폴트 의존관계 : 외부에서 DI 받지 않는 경우 기본적으로 자동 적용되는 의존관계를 말한다.
서비스 추상화 적용
JaxbXmlSqlReader는 좀 더 개선하고 발전시킬 부분이 있다. 크게 다음 두 가지 과제를 생각해볼 수 있다.
- 자바에는 JAXB 외에도 다양한 XML과 자바 오브젝트를 매핑하는 기술이 있다. 필요에 따라 다른 기술로 손쉽게 바꿔서 사용할 수 있게 해야 한다.
- XML 파일을 좀 더 다양한 소스에서 가져올 수 있게 만든다.
JAXB외에도 실전에서 자주 사용하는 XML과 자바 오브젝트 매핑 기술이 있다.
- CastorXML
- JiBX
- XmlBeans
- Xstream
OXM 서비스 추상화 적용
OXM(Object-XML Mapping) : XML과 자바 객체를 매핑해서 상호 변환해주는 기술
스프링의 OXM 추상화 기능을 이용하는 SqlService를 만들어보자.
멤버 클래스를 참조하는 통합 클래스
OxmSqlService는 BaseSqlService와 유사하게 SqlReader 타입의 의존 오브젝트를 사용하되 이를 스태틱 멤버 클래스로 내장하고 자신만이 사용할 수 있도록 만들자
- 밖에서 볼 때는 하나의 오브젝트로 보이지만 내부에서는 의존관계를 가진 두 개의 오브젝트가 깔끔하게 결합돼서 사용된다.
- OxmSqlService와 OxmSqlReader는 구조적으로는 강하게 결합되어 있지만 논리적으로 명확하게 분리되는 구조다. 자바의 스태틱 멤버 클래스는 이런 용도로 쓰기에 적합하다.
public class OxmSqlService implements SqlService { private final OxmSqlReader oxmSqlReader = new OxmSqlReader(); private class OxmSqlReader implements SqlReader { ... } }
OxmSqlReader는 private 멤버 클래스이므로 외부에서 접근하거나 사용할 수 없다. 또한 final로 선언하고 직접 오브젝트를 생성하기에 OxmSqlReader를 DI하거나 변경할 수 없다. 이렇게 두 클래스를 강하게 결합하여 확장과 변경에 제한을 두는 이유로는 OXM을 이용하는 서비스 구조를 최적화하기 위해서이다. 하나의 클래스로 만들어구기 떄문에 빈의 등로고가 설정은 단순해지고 쉽게 사용할 수 있다.
리소스 추상화
지금까지 만든 OxmlSqlReader나 XmlSqlReader 모두 SQL 정보가 담긴 XML 파일 이름을 프로퍼티로 외부에서 지정할 수는 있지만 UserDao 클래스와 같이 클래스패스에 존재하는 파일로 제한된다는 점이다.
리소스
스프링은 자바의 존재하는 일관성 없는 리소스 접근 API를 추상화하여 Resource라는 추상화 인터페이스를 정의했다.
public interface Resource extends InputStreamSource { boolean exists(); default boolean isReadable() { return this.exists(); } default boolean isOpen() { return false; } default boolean isFile() { return false; } URL getURL() throws IOException; URI getURI() throws IOException; File getFile() throws IOException; default ReadableByteChannel readableChannel() throws IOException { return Channels.newChannel(this.getInputStream()); } long contentLength() throws IOException; long lastModified() throws IOException; Resource createRelative(String var1) throws IOException; @Nullable String getFilename(); String getDescription(); }
Resource를 이용해 XML 파일 가져오기
이제 OxmSqlService에 Resource를 적용해서 SQL 매핑정보가 담긴 파일을 다양한 위치에서 가져올 수 있게 만들어보자.
기존 sqlmapFile 프로퍼티를 모두 Resource 타입으로 바꾼 뒤 이름도 sqlmap으로 변경한다. 꼭 파일에서 읽어오는 것은 아닐 수도 있기 떄문이다. Resource 타입은 실제 소스가 어떤 것이든 상관없이 getInputStrema() 메소드를 이용해 스트림으로 가져올 수 있다. 이를 StreamSource 클래스를 이용해서 OXM 언마셜러가 필요로 하는 Source 타입으로 만들어주면 된다.
public class OxmSqlService implements SqlService { private final BaseSqlService baseSqlService = new BaseSqlService(); private final OxmSqlReader oxmSqlReader = new OxmSqlReader(); private SqlRegistry sqlRegistry = new HashMapSqlRegistry(); public void setSqlmap(Resource sqlmap) { this.oxmSqlReader.setSqlmap(sqlmap); } public void setSqlRegistry(SqlRegistry sqlRegistry) { this.sqlRegistry = sqlRegistry; } public void setUnmarshaller(Unmarshaller unmarshaller) { this.oxmSqlReader.setUnmarshaller(unmarshaller); } @PostConstruct public void loadSql() { this.baseSqlService.setSqlReader(this.oxmSqlReader); this.baseSqlService.setSqlRegistry(this.sqlRegistry); this.baseSqlService.loadSql(); } @Setter private class OxmSqlReader implements SqlReader { private Unmarshaller unmarshaller; private Resource sqlmap = new ClassPathResource("/sql/sqlmap.xml", UserDao.class); @Override public void read(SqlRegistry sqlRegistry) { try { Source source = new StreamSource(sqlmap.getInputStream()); Sqlmap sqlmap = (Sqlmap) this.unmarshaller.unmarshal(source); for(SqlType sql : sqlmap.getSql()) { sqlRegistry.registerSql(sql.getKey(), sql.getValue()); } } catch (IOException e) { throw new IllegalArgumentException(this.sqlmap.getFilename() + "을 가져올 수 없습니다."); } } } @Override public String getSql(String key) throws SqlRetrievalFailureException { return this.baseSqlService.getSql(key); } }
Reosurce를 사용할 때는 오브젝트가 실제 리소스가 아니라 단지 리소스에 접근할 수 있는 추상화된 핸들러일 뿐이다. 따라서 Resource 타입의 오브젝트가만들어졌다고 해도 실제로 리소스가 존재하지 않을 수 있다.
인터페이스 상속을 통한 안전한 기능확장
원칙적으로는 권장되지 않지만 운영시간 중 예상치 못한 오류발생, 특별한 이유로 인한 SQL 변경 및 테이블 변경등의 이유로 서버가 운영 중인 상태에서 서버의 재시작 없이 긴급하게 애플리케이션이 사용 중인 SQL을 변경해야 할 수 도 있다.
지금까지 제작한 SqlService 구현 클래스들은 초기에 리소스부터 SQL 정보를 읽어 이를 메모리에 두고 그대로 사용한다. SQL 매핑정보 파일을 변경했다고 해서 메모리상의 SQL 정보가 갱신되지 않는다.
굳이 방법이 있다면 서버를 재시작 혹은 웹애플리케이션을 리로딩해서 다시 SqlService 구현 빈을 초기화하는 것이다.DI와 기능의 확장
지금까지 적용한 DI는 특별한 기술이라기 보다는 일종의 디자인 패턴 또는 프로그래밍 모델이라는 고나점에서 이해하는 것이 더 자연스럽다. 그래서 단지 스프링과 같이 DI 프레임워크를 적용하고 빈 설정파일을 이용해 애플리케이션을 구성했다고 해서 DI를 바르게 활용하고 있다고 볼 수 없다. DI의 가치를 제대로 얻으려면 먼저 DI에 적합한 오브젝트 설계가 필요하다.DI를 의식하는 설계
모든 기능을 클래스 하나 안에 마구 섞어서 구현 클래스를 만들었다면, 지금처럼 다양한 기능에 대한 확장이 가능했을까? 매번 클래스를 전부 뜯어고치는 비효율적인 작업에 시달렸어야 한다. 하지만 초기부터 내부 기능을 적절하게 역활에 따라 분리하여 인터페이스를 정의해 느슨하게 연결해주고, DI를 통해 유연하게 의존관계를 지정하도록 설계했기에 그 뒤의 작업은 매우 쉬워졌다.
스프링을 이용해 DI를 적용하는 것은 사용할 오브젝트를 직접 만드는 대신 프로퍼티로 정의하고 XML, Config 설정을 이용해 주입하도록 설정하는건 몇 시간이면 배울 수 있고 비교적 간단하다고 할 수 있다. 하지만 실제 DI의 가치를 제대로 누리기가 어렵다. DI에 필요한 유연하고 확장성이 뛰어난 오브젝트 설계를 하려면 많은 고민과 학습, 훈련, 경험이 필요하다.객체지향 설계를 잘하는 방법은 다양하지만, 그중 추천하는 한 가지는 바로 DI를 의식하며 설계하는 것이다. DI를 적용하려면 커다란 오브젝트 하나만 존재해선 안되고 최소 두 개 이상의 의존관계를 가지며 서로 협력해서 일하는 오브젝트가 필요하다.
그리고 가장 중요한 유연한 확장과 책임에 따른 오브젝트 분리를 염두하며 오브젝트를 설계해야 한다.DI와 인터페이스 프로그래밍
DI를 적용할 때는 가능한 인터페이스를 사용해야 한다.
- 다형성을 얻기 위함
- 각각 구현체에 목적에 맞게 인터페이스를 통해 다형성이 활용된다.
- 인터페이스 분리 원칙을 통해 클라이언트와 의존 오브젝트 사이의 관계를 명확하게 해줄 수 있기 때문이다.
- 인터페이스는 하나의 오브젝트가 여러 개를 구현할 수 있으므로, 하나의 오브젝트를 바라보는 창이 여러 가지일 수 있다는 의미이다.
오브젝트가 그 자체로 충분히 응집도가 높은 작은 단위로 설계됐더라도, 목적과 관심이 각기 다른 클라이언트가 있다면 인터페이스를 통해 이를 적절하게 분리해줄 필요가 있고, 이를 객체지향 설계 원칙에서는 인터페이스 분리 원칙이라고 부른다.
인터페이스 상속
하나의 오브젝트가 구현하는 인터페이스를 여러 개 만들어서 구분하는 이유 중의 하나는 오브젝트의 기능이 발전하는 과정에서 다른 종류의 클라이언트가 등장하기 때문이다. 때로는 인터페이스를 여러 개 만드는 대신 기존 인터페이스를 상속을 통해 확장하는 방법도 사용된다.
장점
- 모든 클라이언트가 자신의 관심에 따른 접근 방식을 불필요한 간섭 없이 유지할 수 있다.
- 기존 클라이언트에 영향을 주지 않은 채로 오브젝트 기능응 확장하거나 수정 가능
- 인터페이스의 구현 클래스의 오브젝트가 또 다른 제 3의 클라인터를 위한 인터페이스를 가질 수 있다.
'Spring' 카테고리의 다른 글
Spring Security[1] (0) 2021.07.19 토비의 스프링[8] 스프링이란 무엇일까? (0) 2021.07.19 토비의 스프링[6] AOP (0) 2021.03.08 토비의 스프링[5] 서비스 추상화 (0) 2021.02.25 Spring Boot Test (0) 2021.02.09