이 글은 “토비의 스프링” 책 내용을 정리한 글입니다.

만약 저작권 관련 문제가 있다면 “gunjuko92@gmail.com”로 메일을 보내주시면, 바로 삭제하도록 하겠습니다.

스프링 핵심 기술의 응용 (1)

1. SQL과 DAO의 분리

  • 기존 DAO의 문제점 : SQL이 DAO와 분리 되지 않았기 때문에, SQL이 변경이 필요한 상황이 발생하면 SQL을 담고 있는 DAO 코드가 수정될 수 밖에 없다.

1.1 XML 설정을 이용한 분리

  • 가장 손쉽게 생각해볼 수 있는 SQL 분리 방법은 SQL을 스프링의 XML 설정파일로 빼내는 것이다.

개별 SQL 프로퍼티 방식

  • 메소드의 사용할 SQL문을 외부로부터 DI 받는다. DAO에서는 DI 받는 SQL문을 이용해서 쿼리를 수행한다.
  • SQL은 코드의 수정 없이 XML 설정을 바꾸는 것만으로도 자유롭게 수정이 가능해진다.

SQL 맵 프로퍼티 방식

  • 개별 SQL 프로퍼티 방식의 단점은 새로운 SQL이 필요할 때마다 프로퍼티를 추가해줘야한다는 것이다.
  • 맵을 이용하면 키 값을 이용해 SQL 문장을 가져올 수 있다. 맵을 이용하면 프로퍼티는 하나만 만들어도 되기 때문에 DAO의 코드는 더 간결해진다. SQL이 더 필요하더라도 프로퍼티 추가 없이 설정파일의 맵 정보만 변경하면 된다.
public class UserDaoJdbc implements UserDao {
	private Map<String, String> sqlMap;
    
    public void setSqlMap(Map<String, String> sqlMap) {
    	this.sqlMap = sqlMap;
    }
    
    ...
}
  • 각 메소드는 미리 정해진 키 값을 이용해 sqlMap으로부터 SQL을 가져와 사용하도록 만든다.
  • XML을 수정할 때는 스프링이 제공하는 <map> 태그를 사용하면 된다.

1.2 SQL 제공 서비스

  • SQL과 DI 설정정보가 섞여 있으면 보기에도 지저분하고 관리하기에도 좋지 않다.
  • 데이터 액세스 로직의 일부인 SQL 문장을 애플리케이션의 구성정보를 가진 설정정보와 함께 두는건 바람직하지 못하다.
  • DAO가 사용할 SQL을 제공해주는 기능을 독립시킬 필요가 있다.
  • SQL 제공 기능을 본격적으로 분리해서 다양한 SQL 정보 소스를 사용할 수 있고, 운영 중에 동적으로 갱신도 가능한 유연하고 확장성이 뛰어난 SQL 서비스를 만들어ㄴ보자.

SQL 서비스 인터페이스

  • SQL에 대한 키 값을 전달하면 그에 해당하는 SQL을 돌려주는 인터페이스를 정의한다. 주어진 키를 이용해 어떤 식으로 SQL을 가져오는지 SQL은 어디에 저장되어 있었는지, 어떻게 검색하도록 했는지는 DAO의 관심사항이 아니다.
  • 인터페이스의 이름은 SqlService라고 하자. UserDaoJdbc는 SqlService 인터페이스를 통해 필요한 SQL을 가져와 사용할 수 있게 만들어준다.
public interface SqlService {
    String getSql(String key) throws SqlRetrievalFailureException;
}
public class SqlRetrievalFailureException extends RuntimeException {
    public SqlRetrievalFailureException() {
    }

    public SqlRetrievalFailureException(String message) {
        super(message);
    }

    public SqlRetrievalFailureException(String message, Throwable cause) {
        super(message, cause);
    }
}

스프링 설정을 사용하는 단순 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>을 이용해 등록한다.
  • UserDao를 포함한 모든 DAO는 SQL을 어디에 저장해두고 가져오는지에 대해서는 전혀 신경 쓰지 않아도 된다.
  • sqlService 빈에는 DAO에는 전혀 영향을 주지 않은 채로 다양한 방법으로 구현된 SqlService 타입 클래스를 적용할 수 있다.

2 인터페이스의 분리와 자기 참조 빈

2.1 XML 파일 매핑

  • SQL을 저장해두는 전용 포맷을 가진 독립적인 파일을 이용하는 편이 바람직하다.
  • 검색용 키와 SQL 문장 두 가지를 담을 수 있는 간단한 XML 문서를 설계해보고, 이 XML 파일에서 SQL을 읽어뒀다가 DAO에게 제공해주는 SQL 서비스 구현 클래스를 만들어보자.

JAXB

  • JAXB는 XML에 담긴 정보를 파일에서 읽어오는 방법 중 하나이다.
    • XML 문서정보를 거의 동일한 구조의 오브젝트로 직접 매핑해준다
    • XML 정보를 오브젝트처럼 다룰 수 있어 편리하다.
  • JAXB는 XML 문서의 구조를 정의한 스키마를 이용해서 매핑할 오브젝트의 클래스까지 자동으로 만들어주는 컴파일러도 제공해준다. 스키마 컴파일러를 통해 자동생성 된 오브젝트에는 매핑정보가 애노태이션으로 담겨 있다. JAXB API는 애노테이션에 담긴 정보를 이용해서 XML과 매핑된 오브젝트 트리 사이의 자동변환 작업을 수행해준다.

JAXB-1

SQL 맵을 위한 스키마 작성과 컴파일

  • SQL 정보는 키와 SQL의 목록으로 구성된 맵 구조로 만들어두면 편리하다. 키와 SQL 정보를 담은 <sql> 태그를 가진 XML 문서를 사용하자.
<sqlMap>
    <sql key="userGet">select * from user where user.id = ?</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>
  • <element name=”sqlmap”> : <sqlmap> 엘리먼트를 정의한다.
    • <element name=”sql” maxOccurs=”unbounded” type=”tns:sqlType”/> : <sql>을 필요한 개수만큼 포함할 수 있다.
  • <complexType name=”sqlType”> : <sql> 엘리먼트를 정의한다.
    • <attribute name=”key” use=”required” type=”string”/> : 검색을 위한 키값은 <sql>에 key 애트리뷰트에 넣는다. 반드시 입력해야하는 필수 값이다.
  • JAXB 컴파일러로 컴파일 한다.
    • 컴파일하면 두 개의 바인딩용 자바 클래스와 팩토리 클래스가 만들어진다.
xjc -p {패키지이름} {변환할 스키마 파일} -d {생성된 파일이 저장될 위치}
  • 아래는 컴파일러가 만들어준 XML 문서 바인딩용 클래스이다.
@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;
    }
}
  • Sqlmap
    • <sql> 태그의 정보를 담고 있는 SqlType 오브젝트를 리스트로 갖는다.
  • 아래는 <sql> 태그의 정보를 담을 SqlType 클래스다
    • sqlType : <sql> 태그 한 개당 SqlType 오브젝트가 하나씩 만들어진다.
    • key : key 애트리뷰트에 담긴 검색용 키 값을 위한 스트링 타입의 필드
    • value : SOL 값을 저장할 스트링 타입의 필드
@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;
    }
}

언마샬링

  • JAXB API의 사용법을 익힐 수 있도록 간단한 학습 테스트를 만들어보자.
<?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 ../../../../sqlmap.xsd">
    <sql key="add">insert</sql>
    <sql key="get">get</sql>
    <sql key="delete">delete</sql>
</sqlmap>
  • 언마샬링 : XML 문서를 읽어서 자바의 오브젝트로 변환하는 것
  • 마샬링 : 바인딩 오브젝트를 XML 문서로 변환하는것
@Test
public void readSqlmap() throws JAXBException {
    String contextPath = Sqlmap.class.getPackage().getName();
    JAXBContext context = JAXBContext.newInstance(contextPath);

    Unmarshaller unmarshaller = context.createUnmarshaller();
    Sqlmap sqlmap = (Sqlmap) unmarshaller.unmarshal(getFile("sqlmap.xml"));

    List<SqlType> sqls = sqlmap.getSql();

    assertThat(sqls.size()).isEqualTo(3);

    assertXmlValue(sqls, "add", "insert");
    assertXmlValue(sqls, "get", "select");
    assertXmlValue(sqls, "delete", "delete");
}

private void assertXmlValue(List<SqlType> sqls, String key, String value) {
    SqlType sql = sqls.stream()
                      .filter(s -> s.getKey().equals(key))
                      .findFirst()
                      .orElseThrow(IllegalArgumentException::new);
    assertThat(sql.getValue()).isEqualTo(value);
}
  • JAXBContext context = JAXBContext.newInstance(contextPath) : 바인딩될 클래스들 위치를 가지고 JAXB 컨텍스트를 만든다.
  • 언마샬을 하면 매핑된 오브젝트 트리의 루트인 Sqlmap을 돌려준다.

2.2 XML 파일을 이용하는 SQL 서비스

SQL 맵 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 ../../../../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>
    ...
</sqlmap>

XML SQL 서비스

  • sqlmap.xml에 있는 SQL을 가져와 DAO에 제공해주는 SqlService 인터페이스의 구현 클래스를 만들어보자.
  • DAO가 SQL을 요청할 때마다 매번 XML 파일을 다시 읽어서 SQL을 찾는 건 너무 비효율적인 방법이다. 특별한 이유가 없는 한 XML 파일은 한 번만 읽도록 해야 한다.
  • 생성자에서 SQL을 읽어와 내부에 저장해두는 초기 작업을 하자.
    • SqlType 오브젝트 정보를 검색 속도가 빠르고 사용하기 간편한 Map 타입 오브젝트에 저장해두자
    • Sql의 key와 value 프로퍼티를 각각 맵의 키와 값으로 저장하면 된다.
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 파일만 제공해주면 된다.

2.3 빈의 초기화 작업

  • 생성자에서 예외가 발생할 수도 있는 복잡한 초기화 작업을 다루는 것은 좋지 않다.
    • 생성자에서 발생하는 예외는 다루기 힘들다.
    • 상속하기 불편하다.
    • 보안에 문제가 생길 수 있다.
  • 읽어들일 파일의 위치와 이름이 코드에 고정되어 있다.
    • 변경될 수 있는 부분이므로 외부로 부터 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);
    }
}
  • XmlSqlService 오브젝트는 빈이므로 제어권이 스프링에 있다. 생성은 물론이고 초기화도 스프링에게 맡길 수밖에 없다. 그래서 스프링은 빈 오브젝트를 생성하고 DI 작업을 수행해서 프로퍼티를 모두 주입해준 뒤에 미리 지정한 초기화 메소드를 호출해주는 기능을 갖고 있다.
  • 프록시 자동생성기 외에도 스프링이 제공하는 여러 가지 빈 후처리기가 존재한다.
  • <context:annotation-config> 태그를 만들어 설정파일에 넣어주면 빈 설정 기능에 사용할 수 있는 특별한 애노테이션 기능을 부여해주는 빈 후처리기들이 등록된다.
    • 태그에 의해 등록되는 빈 후처리기는 몇 가지 특별한 빈 설정에 사용되는 애노테이션을 제공한다.
  • @PostConstruct
    • java.lang.annotation 패키지에 포함된 공통 애노테이션의 한가지로 JavaEE 5나 JDK6에 포함된 표준 애노테이션이다.
    • 스프링은 @PostConstruct 애노테이션을 빈 오브젝트의 초기화 메소드를 지정하는 데 사용한다.
  • @PostConstruct를 초기화 작업을 수행할 메소드에 부여해주면 스프링은 빈 오브젝트를 생성하고 DI 작업을 마친 뒤에 @PostConstruct가 붙은 메소드를 자동으로 실행해준다.
@PostConstruct
public void loadSql() {
    ...
}
  • @PostConstruct 애노태이션은 빈 오브젝트가 생성되고 의존 오브젝트와 설정 값을 넣어주는 DI 작업까지 마친 후에 호출된다.

PostConstruct

2.4 변화를 위한 준비 : 인터페이스 분리

  • XmlSqlService가 변경되는 이유
    • SQL을 가져오는 것 : XML에서 SQL 데이터를 가져온다.
    • SQL을 보관해두고 사용하는 것 : HashMap 타입의 맵 오브젝트에 저장해둔다
    • 변경되는 이유가 두 가지라면 이는 단일 책임 원칙을 위반
  • 서로 변하는 시기와 성질이 다른 것, 변하는 것과 변하지 않는 것을 함께 두는 건 바람직한 설계구조가 아니다.

책임에 따른 인터페이스 정의

  • 분리 가능한 관심사를 구분해보는 것이다.
    • 첫 째 : SQL 정보를 외부의 리소스로부터 읽어오는 것이다.
    • 두 번째 : 읽어온 SQL을 보관해두고 있다가 필요할 때 제공해주는 것이다.
  • 부가적인 책임
    • 한 번 가져온 SQL을 필요에 따라 수정할 수 있게 하는것
  • sqlService의 구현 클래스가 변경 가능한 책임을 가진 SqlReader와 SqlRegistry 두 가지 타입의 오브젝트를 사용하도록 만든다.

SqlService

  • SqlReader가 읽어오는 SQL 정보는 다시 SqlRegistry에 전달해서 등록되게 해야 한다.
    • SQL을 SqlReader에서 SqlRegistry로 전달하는 과정과 전달되는 형식을 어떻게 할지 생각해보자.
  • SqlReader가 제공하는 메소드의 리턴 타입은 무엇으로 해야 할까?
    • SQL을 임의의 리소스로부터 가져올 수 있기 때문에 JAXB에서 만들어준 Sql 클래스를 사용하는건 곤란하다.
    • 맵을 리턴하는 경우 : 둘 사이에서 정보를 전달하기 위해 일시적으로 Map 타입의 형식을 갖도록 만들어야 한다는 건 불편하다.
Map<String, String> sqls = sqlReader.readSql();
sqlRegistry.addSqls(sqls);
  • 두 오브젝트 사이의 정보를 전달하는 것이 전부라면 SqlService가 중간 과정에서 아예 빠지는 방법을 생각해볼 수도 있다.
  • SqlReader에게 SqlRegistry 전략을 제공해주면서 이를 이용해 SQL 정보를 SqlRegistry에 저장하라고 요청하는 편이 낫다.
sqlReader.readSql(sqlRegistry); 
  • 불필요하게 SqlService 코드를 통해 특정 포맷으로 변환한 SQL 정보를 주고받을 필요없이 SqlReader가 직접 SqlRegistry에 SQL 정보를 등록한다.
  • 자바의 오브젝트는 데이터를 가질 수 있다. 자신이 가진 데이터를 이용해 어떻게 작업해야 할지도 가장 잘 알고 있다. 그렇다면 오브젝트 스스로 자신의 데이터로 충실히 작업하게 만들면 되지, 쓸데없이 오브젝트 내부의 데이터를 외부로 노출시킬 필요는 없다.
  • SqlReader가 사용할 SqlRegistry 오브젝트를 제공해주는 건 SqlService의 코드가 담당한다.

SqlService2

SqIRegistry 인터페이스

public interface SqlRegistry {
    void registerSql(String key, String vale);

    String findSql(String key) throws SqlNotFoundException;
}

SqlReqder 인터페이스

public interface SqlReader {
    void read(SqlRegistry sqlRegistry);
}
  • SqlRegistry가 일종의 콜백 오브젝트처럼 사용된다.
  • SqlReader 입장에서는 SqlRegistry 인터페이스를 구현한 오브젝트를 런타임 시에 메소드 파라미터로 제공받아서 사용하는 구조이니 일종의 코드에 의한 수동 DI라고 볼 수도 있다.

2.5 자기 참조 빈으로 시작하기

다중 인터페이스 구현과 간접 참조

  • XmlSqlService를 SqlRegistry와 SqlReader를 사용하도록 변경

SqlService3

  • 모든 클래스는 인터페이스에만 의존하고 있다.
  • 세 개의 인터페이스를 하나의 클래스가 모두 구현한다면 어떨까? 인터페이스는 한 클래스에서 하나 이상을 구현할 수 있다. 하나의 클래스가 여러 개의 인터페이스를 상속해서 여러 종류의 타입으로서 존재할 수 있는것이다.
  • XmlSqlService 클래스 하나가 SqlService, SqlReader, SqlRegistry라는 세 개의 인터페이스를 구현하게 만들어도 상관없다.

SqlService4

인터페이스를 이용한 분리

  • SqlReader와 SqlRegistry 두 개의 인터페이스 타입 오브젝트에 의존하는 구조로 만들어야 한다.
public class XmlSqlService implements SqlService {
    private SqlReader sqlReader;
    private SqlRegistry sqlRegistry;

    public void setSqlReader(SqlReader sqlReader) {
        this.sqlReader = sqlReader;
    }

    public void setSqlRegistry(SqlRegistry sqlRegistry) {
        this.sqlRegistry = sqlRegistry;
    }  
}    
  • XmlSqlService 클래스가 SqlRegistry를 구현하도록 만들자.
    • sqlMap은 이제 SqlRegistry 구현의 일부가 됐으므로 SqlRegistry 구현 메소드가 아닌 메소드에서는 직접 사용해선 안 된다. 독립적인 오브젝트라고 생각하고 SqlRegistry의 메소드를 통해 접근해야 한다.
public class XmlSqlService implements SqlService, SqlRegistry {
    
    private final Map<String, String> sqlMap = new HashMap<>();

    @Override
    public void registerSql(String key, String vale) {
        sqlMap.put(key, vale);
    }

    @Override
    public String findSql(String key) throws SqlNotFoundException {
        String sql = sqlMap.get(key);
        if (sql == null) {
            throw new SqlRetrievalFailureException(key + "를 이용해서 SQL을 찾을 수 없습니다");
        }
        return sql;
    }
}    
  • XmlSqlService 클래스가 SqlReader를 구현하도록 만든다.
    • SqlReader를 구현한 코드에서 XmlSqlService 내의 변수와 메소드를 직접 참조하거나 사용하면 안된다. 필 요한 경우에만 적절한 인터페이스를 통해 접근하도록 한다.
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();
        Sqlmap sqlmap = (Sqlmap) unmarshaller.unmarshal(getXmlFile(this.sqlMapFile));

        for (SqlType sql : sqlmap.getSql()) {
            sqlRegistry.registerSql(sql.getKey(),  sql.getValue());
        }
    } catch (JAXBException e) {
        throw new RuntimeException(e);
    }
}

private File getXmlFile(String fileName) {
    ClassLoader classLoader = getClass().getClassLoader();
    return new File(classLoader.getResource(fileName).getFile());
}
  • @PostConstruct가 달린 빈 초기화 메소드와 SqlService 인터페이스에 선언된 메소드인 getSql()를 sqlReader와 sqlRegistry를 이용하도록 변경한다
@Override
public String getSql(String key) throws SqlRetrievalFailureException {
    try {
        return sqlRegistry.findSql(key);
    } catch (SqlNotFoundException e) {
        throw new SqlRetrievalFailureException(e);
    }
}

@PostConstruct
public void loadSql() {
    sqlReader.read(this.sqlRegistry);
}
  • 같은 클래스 안에 구현된 내용이기는 하지만 SqlService의 메소드에서 SQL을 읽을 때는 SqlReader 인터페이스를 통해, SQL을 찾을 때는 SqlRegistry 인터페이스를 통해 간접적으로 접근하게 한다.
  • 자기 자신을 참조하는 빈은 사실 흔히 쓰이는 방법은 아니다. 책임이 다르다면 클래스를 구분하고 각기 다른 오브젝트로 만드는것이 더 자연스럽다.

2.6 디폴트 의존관계

확장 가능 기반 클래스

  • SqlRegistry와 SqlReader를 이용하는 가장 간단한 SqlService 구현 클래스를 만들어보자.
public class BaseSqlService implements SqlService {
    private SqlReader sqlReader;
    private SqlRegistry sqlRegistry;

    public void setSqlReader(SqlReader sqlReader) {
        this.sqlReader = sqlReader;
    }

    public void setSqlRegistry(SqlRegistry sqlRegistry) {
        this.sqlRegistry = sqlRegistry;
    }

    // implement SqlSerivce
    @Override
    public String getSql(String key) throws SqlRetrievalFailureException {
        try {
            return sqlRegistry.findSql(key);
        } catch (SqlNotFoundException e) {
            throw new SqlRetrievalFailureException(e);
        }
    }

    @PostConstruct
    public void loadSql() {
        sqlReader.read(this.sqlRegistry);
    }
}
  • HashMap을 이용해 SQL을 저장해두고 찾아주는 기능을 담당했던 코드를 SqlRegistry를 구현하는 독립 클래스로 분리한다.
public class HashMapSqlRegistry implements SqlRegistry {

    private final Map<String, String> sqlMap = new HashMap<>();

    @Override
    public void registerSql(String key, String vale) {
        sqlMap.put(key, vale);
    }

    @Override
    public String findSql(String key) throws SqlNotFoundException {
        String sql = sqlMap.get(key);
        if (sql == null) {
            throw new SqlRetrievalFailureException(key + "를 이용해서 SQL을 찾을 수 없습니다");
        }
        return sql;
    }
}
  • JAXB를 이용해 XML 파일에서 SQL 정보를 읽어오는 코드를 SqlReader 인터페이스의 구현 클래스로 독립시킨다.
public class JaxbXmlSqlReader implements 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();
            Sqlmap sqlmap = (Sqlmap) unmarshaller.unmarshal(getXmlFile(this.sqlMapFile));

            for (SqlType sql : sqlmap.getSql()) {
                sqlRegistry.registerSql(sql.getKey(), sql.getValue());
            }
        } catch (JAXBException e) {
            throw new RuntimeException(e);
        }
    }

    private File getXmlFile(String fileName) {
        ClassLoader classLoader = getClass().getClassLoader();
        return new File(classLoader.getResource(fileName).getFile());
    }
}

디폴트 의존관계를 갖는 빈 만들기

  • 확장을 고려해서 기능을 분리하고, 인터페이스와 전략 패턴을 도입하고, DI를 적용한다면 늘어난 클래스와 인터페이스 구현과 의존관계 설정에 대한 부담은 감수해야 한다.
  • 특정 의존 오브젝트가 대부분의 환경에서 거의 디폴트라고 해도 좋을 만큼 기본적으로 사용될 기능성이 있다면, 디폴트 의존관계를 갖는 빈을 만드는 것을 고려해볼 필요가 있다.
    • 디폴트 의존관계 : 외부에서 DI 받지 않는 경우 기본적으로 자동 적용되는 의존관계를 말한다.
public class DefaultSqlService extends BaseSqlService {

    private static final String DEFAULT_SQLMAP_FILE = "mapper/sqlMapper.xml";
    private String sqlmapFile = DEFAULT_SQLMAP_FILE;

    public DefaultSqlService() {
        super();
        JaxbXmlSqlReader sqlReader = new JaxbXmlSqlReader();
        sqlReader.setSqlMapFile(sqlmapFile);
        setSqlReader(sqlReader);
        setSqlRegistry(new HashMapSqlRegistry());
    }
}
  • DefaultSqlService는 BaseSqlService의 sqlReader와 sqlRegistry 프로퍼티를 그대로 갖고 있고, 이를 이용해서 원한다면 언제든지 일부 또는 모든 프로퍼티를 변경할 수 있다.
  • DI 설정이 없을 경우 디폴트로 적용하고 싶은 의존 오브젝트를 생성자에서 넣어준다. DI란 클라이언트 외부에서 의존 오브젝트를 주입해주는 것이지만 이렇게 자신이 사용할 디폴트 의존 오브젝트를 스스로 DI 하는 방법도 있다.
  • DI를 사용한다고 해서 항상 모든 프로퍼티 값을 설정에 넣고, 모든 의존 오브젝트를 빈으로 일일이 지정할 필요는 없다. 먼저 BaseSqlService와 같이 의존 오브젝트를 DI 해줌으로써 기능의 일부를 자유롭게 확장해줄 수 있는 기반을 만들어둬야 하지만, DefaultSqlService처럼 자주 사용되는 의존 오브젝트는 미리 지정한 디폴트 의존 오브젝트를 설정 없이도 사용할 수 있게 만드는 것도 좋은 방법이다.

3. 서비스 추상화 적용

JaxbXmlSqlReader는 좀 더 개선하고 발전시킬 부분이 있다. 크게 다음 두 가지 과제를 생각해볼 수 있다.

  • 자바에는 JAXB 외에도 다양한 XML과 자바 오브젝트를 매핑하는 기술이 있다. 필요에 따라 다른 기술로 손쉽게 바꿔서 사용할 수 있게 해야 한다.
  • XML 파일을 좀 더 다양한 소스에서 가져올 수 있게 만든다.

3.1 OXM 서비스 추상화

  • OXM : XML과 자바 오브젝트를 매핑해서 상화 변환해주는 기술
  • 스프링은 트랜잭션, 메일 전송뿐 아니라 OXM에 대해서도 서비스 추상화 기능을 제공한다.
  • 스프링이 제공하는 OXM 추상 계층의 API를 이용해 XML 문서와 오브젝트 사이의 변환을 처리하게 하면, 코드 수정 없이도 OXM 기술을 자유롭게 바꿔서 적용할 수 있다.

OXM 서비스 인터페이스

  • Marshaller : 자바오브젝트를 XML로 변환
  • Unmarshaller : XML을 자바오브젝트로 변환
    • XML 파일에 대한 정보를 담은 Source 타입의 오브젝트를 주면, 설정에서 지정한 OXM 기술을 이용해 자바오브젝트 트리로 변환하고, 루트 오브젝트를 돌려준다.
public interface Unmarshaller {
	boolean supports(Class<?> clazz);
    
    Object unmarshal(Source source) throws IOException, XmlMappingException;
}

JAXB 구현 테스트

  • JAXB를 이용하도록 만들어진 Unmarshaller 구현 클래스는 Jaxb2Marshaller다. (Marshaller 인터페이스와 Unmarshaller 인터페이스를 모두 구현하고 있다)
  • Jaxb2Marshaller 클래스를 빈으로 등록하고, @Autowired를 이용해 Unmarshaller 타입의 인스턴스 변수를 선언해주면 빈을 가져올 수 있다.
  • OXM 기술을 변경하더라도 Unmarshaller 타입의 변수를 사용하는 코드들은 수정될 필요가 없다.

서비스 추상화는 로우레벨의 기술을 필요에 따라 변경해서 사용하더라도 일관된 애플리케이션 코드를 유지할 수 있다.

3.2 OXM 서비스 추상화 적용

  • 스프링의 OXM 추상화 기능을 이용하는 SqlService를 만들어보자.
  • SqlReader와 SqlRegistry라는 두 개의 전략을 활용하는 구조를 적용해봤으므로, 이를 그대로 유지하되 SqlReader 구현 오브젝트에 대한 의존관계를 고정시켜버리는 방법을 생각해볼 수 있다.

멤버 클래스를 참조하는 통합 클래스

  • OxmSqlService는 BaseSqlService와 유사하게 SqlReader 타입의 의존 오브젝트를 사용하되 이를 스태틱 멤버 클래스로 내장하고 자신만이 사용할 수 있도록 만들자
    • 밖에서 볼 때는 하나의 오브젝트로 보이지만 내부에서는 의존관계를 가진 두 개의 오브젝트가 깔끔하게 결합돼서 사용된다.
  • OxmSqlService와 OxmSqlReader는 구조적으로는 강하게 결합되어 있지만 논리적으로 명확하게 분리되는 구조다. 자바의 스태틱 멤버 클래스는 이런 용도로 쓰기에 적합하다.
public class OxmSqlService implements SqlService {
    
    private final OxmSqlReader sqlReader = new OxmSqlReader();
    
    @Override
    public String getSql(String key) throws SqlRetrievalFailureException {
        // TODO : implementation
        return null;
    }

    private static class OxmSqlReader implements SqlReader {
        @Override
        public void read(SqlRegistry sqlRegistry) {
            // TODO : implementation
        }
    }
}
  • 클래스의 기본 구조는 위와 같다. OxmSqlService는 OxmSqlReader와 강하게 결합되어 있기 때문에 확장이나 변경에 제한이 생긴다. 하지만 OXM을 이용하는 서비스 구조로 최적화가 되기 때문에 하나의 클래스로 만들어두면 빈의 등록과 설정이 단순해지고 쉽게 사용할 수 있다.
  • 편리한 확장과 유연한 변경을 위해서 클래스를 분리하고 빈을 따로 등록해 DI 할 수 있도록 기본 구조를 가져간 것은 좋지만, 자꾸 늘어나는 빈의 개수와 반복되는 비슷한 DI 구조가 불편하게 느껴질 수도 있다.
  • 위처럼 OxmSqlService와 OxmSqlReader가 강하게 결합되면, 하나의 빈 설정으로 두 개의 오브젝트를 설정할 수 있다. OxmSqlService로 등록한 빈의 프로퍼티 일부는 OxmSqlService 내부의 OxmSqlReader 프로퍼티를 설정해주기 위한 창구 역할을 한다.
  • 아래는 OxmSqlService의 프로퍼티를 통해 내장된 멤버 클래스의 프로퍼티를 설정해주는 코드다.
public class OxmSqlService implements SqlService {

    private final OxmSqlReader sqlReader = new OxmSqlReader();

    public void setUnmarshaller(Unmarshaller unmarshaller) {
        this.sqlReader.setUnmarshaller(unmarshaller);
    }

    public void setSqlmapFile(String sqlmapFile) {
        this.sqlReader.setSqlmapFile(sqlmapFile);
    }

    @Override
    public String getSql(String key) throws SqlRetrievalFailureException {
        // TODO : implementation
        return null;
    }

    private static class OxmSqlReader implements SqlReader {
        private Unmarshaller unmarshaller;
        private String sqlmapFile;

        public void setUnmarshaller(Unmarshaller unmarshaller) {
            this.unmarshaller = unmarshaller;
        }

        public void setSqlmapFile(String sqlmapFile) {
            this.sqlmapFile = sqlmapFile;
        }

        @Override
        public void read(SqlRegistry sqlRegistry) {
            // TODO : implementation
        }
    }
}
  • 아래 코드는 OxmSqlService의 완성된 코드이다. OXM을 적용했음에도 빈 설정은 단순하게 유지할 수 있게 됐다.
    • OXM 기술을 지정하고 그에 따른 설정이 필요한 언마살러 빈은 따로 필요한 것이고, 그 외의 SqlService와 OXM 언마살러를 사용히는 SqlReader 그리고 SqlRegistry는 하나의 빈을 등록하는 것으로 충분하기 때문이다. SqlRegistry는 필요에 따라 다른 구현으로 교체할 수도 있다.

위임을 이용한 BaseSqlService의 재사용

  • OxmSqlService와 BaseSqlService의 loadSql() 메소드와 getSql() 메소드가 중복이 생긴다. (물론 이정도의 중복은 무시해도 괜찮다)
  • 이런 경우에는 위임 구조를 이용해 코드의 중복을 제거할 수도 있다. loadSql()과 getSql()의 구현 로직은 BaseSqlService에만 두고, OxmSqlService는 일종의 설정과 기본 구성을 변경해주기 위한 어댑터 같은 개념으로 BaseSqlService의 앞에 두는 설계가 가능하다.

OxmSqlService

  • 아래 코드는 위임 구조를 이용한 OxmSqlService 코드이다.
public class OxmSqlService implements SqlService {

    private final OxmSqlReader sqlReader = new OxmSqlReader();
    private final BaseSqlService sqlService = new BaseSqlService();
    private SqlRegistry sqlRegistry = new HashMapSqlRegistry();

    public void setSqlRegistry(SqlRegistry sqlRegistry) {
        this.sqlRegistry = sqlRegistry;
    }

    public void setUnmarshaller(Unmarshaller unmarshaller) {
        this.sqlReader.setUnmarshaller(unmarshaller);
    }

    public void setSqlmapFile(String sqlmapFile) {
        this.sqlReader.setSqlmapFile(sqlmapFile);
    }

    @PostConstruct
    public void loadSql() {
        this.sqlService.setSqlReader(this.sqlReader);
        this.sqlService.setSqlRegistry(this.sqlRegistry);

        this.sqlService.loadSql();
    }

    @Override
    public String getSql(String key) throws SqlRetrievalFailureException {
        return sqlService.getSql(key);
    }
    ...
}        

3.3 리소스 추상화

리소스에 접근할 수 있는 통일된 방법이 있다면 좋을 것이다.

리소스

  • 스프링은 자바에 존재하는 일관성 없는 리소스 접근 API를 추상화해서 Resource라는 추상화 인터페이스를 정의했다.
public interface Resource extends InputStreamSource {
	boolean exists();
	default boolean isReadable() {
		return true;
	}
	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(getInputStream());
	}
	long contentLength() throws IOException;
	long lastModified() throws IOException;
	Resource createRelative(String relativePath) throws IOException;
	@Nullable
	String getFilename();
	String getDescription();
}
  • 애플리케이션 컨텍스트가 사용할 설정정보 파일을 지정하는 것부터 시작해서 스프링의 거의 모든 API는 외부의 리소스 정보가 필요할 때는 항상 이 Resource 추상화를 이용한다.
  • Resource는 스프링에서 빈이 아니라 값으로 취급된다. 리소스는 OXM이나 트랜잭션처럼 서비스를 제공해주는 것이 아니라 단순한 정보를 가진 값으로 지정된다.

리소스 로더

  • 스프링에는 URL 클래스와 유사하게 접두어를 이용해 Resource 오브젝트를 선언하는 방법이 있다. 문자열 안에 리소스의 종류와 리소스의 위치를 함께 표현하게 해주는 것이다. 그리고 이렇게 문자열로 정의된 리소스를 실제 Resource 타입 오브젝트로 변환해주는 ResourceLoader를 제공한다.
public interface ResourceLoader {
	String CLASSPATH_URL_PREFIX = ResourceUtils.CLASSPATH_URL_PREFIX;
	Resource getResource(String location);
	@Nullable
	ClassLoader getClassLoader();
}

  • 접두어가 없는 경우에는 리소스 로더의 구현 방식에 따라 리소스를 가져오는 방식이 달라진다. 하지만 접두어를 붙여주면 리소스 로더의 종류와 상관없이 접두어가 의미하는 위치와 방법을 이용해 리소스를 읽어온다.
  • 아래는 ResourceLoader가 인식하는 접두어이다.
    • file:
    • classpath:
    • http:
  • ResourceLoader의 대표적인 예는 바로 스프링의 애플리케이션 컨텍스트다. 애플리케이션 컨텍스트가 구현해야 하는 인터페이스인 ApplicationContext는 ResourceLoader 인터페이스를 상속하고 있다. 따라서 모든 애플리케이션 컨텍스트는 리소스로더이기도 하다.

Resource를 이용해 XML 파일 가져오기

  • OxmSqlService에 Resource를 적용해서 SQL 매핑정보가 담긴 파일을 다양한 위치에서 가져올 수 있게 만들어보자.
  • 아래 코드는 Resource를 적용한 OxmSqlService 코드이다. String 타입의 sqlmapFile 멤버 변수 대신에 Resource 타입의 sqlmap을 사용한다.
public class OxmSqlService implements SqlService {

   ...
       
    public void setSqlmap(Resource sqlmap) {
        this.sqlReader.setSqlmap(sqlmap);
    }
    
    ...
        
    private static class OxmSqlReader implements SqlReader {
        private Resource sqlmap;

        public void setSqlmap(Resource sqlmap) {
            this.sqlmap = sqlmap;
        }

        @Override
        public void read(SqlRegistry sqlRegistry) {
            try {
                Source source = new StreamSource(this.sqlmap.getInputStream());
                
                ...
            } catch (IOException | JAXBException e) {
                throw new IllegalStateException(this.sqlmap.getFilename() + "을 가져올 수 없습니다.", e);
            }
        }
    }
}

스프링의 리소스 추상화를 이용하면 리소스의 위치와 접근 방법에 독립적인 코드를 쉽게 만들 수 었다. 스프링 애플리케이션에서 파일을 읽거나 참조하는 기능을 만들 때는 Resource 타입의 추상화 기능을 사용하자.