[토비의 스프링 3.1] 7장. 스프링 핵심 기술의 응용

대연.

·

2021. 12. 6. 20:41

728x90

Source : yes24

  1. 해당 포스팅은 책읽기 스터디의 활동을 통해 작성된 포스팅입니다.
  2. 공부하면서 블로그를 참고하였는데, 책 내용을 그대로 정리하는데 그치는 글들이 절반이었습니다. 스스로 새롭게 알게 된 내용이거나 책의 설명이 너무 불친절한 경우 부가설명을 작성하거나 또는 새롭게 쓰고자 노력했습니다. 공부하다 생기는 의문들은 레포지토리 이슈에서 질의응답을 주고 받았으니 학습하다 궁금한 점이 생기면 검색 해보시기를 권장드립니다.
  3. 틀린 내용이 있다면 댓글로 알려주시면 감사하겠습니다.
  4. 코드나 책 내용 캡쳐 내용들은 다른 블로그의 캡쳐본이나 텍스트를 가져와 작성하였습니다. 대부분 출처를 표기하였으나 누락된 경우 원하시는 조치 내용을 댓글로 남겨주시면 시정하겠습니다.

스프링의 3대 핵심 기술인 IoC/DI, 서비스 추상화, AOP의 목적은 자바의 객체지향 기술이다.

7.1 SQL과 DAO의 분리

UserDao에서 JDBC 반복적인 작업 흐름은 템플릿으로 DAO에서 제거하고, 트랜잭션/예외 처리 등도 AOP를 이용해 제거했다. 데이터 엑세스를 하는 코드를 책임과 역할이 다른 부분끼리 분리하고, 확장이 용이하도록 인터페이스로 연결하고, DI로 런타임에 연결하도록 했다. 그리고 DB 테이블과 필드 정보를 담고 있는 SQL 문장까지 분리하는게 목적이다.

SQL이 어떤 이유로든 변하기 마련인데, DAO코드까지 변하는 상황을 막고자 한다.

XML에 SQL 분리하기

XML 설정파일에 SQL을 분리한다. 빈에 SQL 문장을 주입하고, DAO에 주입하여 사용할 수 있다.

<bean id="userDao" class="~~">
    <property name="sqlAdd" value="{SQL Statement here}" />
public class UserDaoJdbc implements UserDao {
    private String sqlAdd;

        public void setSqlAdd(String sqlAdd) {
                this.sqlAdd = sqlAdd;
        }

        public void add(User user){
                this jdbcTemplate.update( **this.sqlAdd, .....);**
        }
}

큰 문제없이 작동하겠지만 SQL이 추가될 때마다 프로퍼티를 추가하고 DI 변수와 Setter 메서드를 만들어 주는 것이 번거롭게 느껴질 것이다.

SQL 맵 프로퍼티 방식

SQL을 하나의 컬렉션으로 담도록 해보자. Key에 대응되는 Value는 SQL 문장이 될 것이다. 프로퍼티를 추가하거나 하지 않고 맵 정보에만 추가해주면 된다.

<property name="sqlMap">
    <map>
        <entry key="userAdd" value="{sql statement}"/>
        <entry key="userGet" value="{sql statement}" />
        ...
    </map>
</property>
public class UserDaoJdbc implements UserDao {
    private Map<String, String> sqlMap;

        public void setSqlMap(Map<String, String> sqlMap) {
                this.sqlMap = sqlMap;
        }
}

SQL을 맵으로 전환할 때는 setter 설정 필요없이 만 추가해주면 된다.

SQL 제공 서비스

SQL과 DI 정보가 섞여있으면 지저분하고 관리가 쉽지 않다. 데이터 엑세스 로직을 담당하는 SQL문은 XML 설정정보에 두면 단일 책임 원칙을 위반하기도 한다.

꼭 SQL은 XML에 담아둘 필요도 없다. 엑셀, 프로퍼티 파일, 임의의 포맷의 파일에서 가져와야 할 수도, DB에서 가져올 수도 있지 않을까?

XML은 런타임엔 변경이 어렵다는 점(동시성 문제와 더불어 싱글톤 인스턴스에 접근하여 실시간으로 내용을 수정하는 일은 쉽지 않다)을 감안하면, SQL을 제공하는 기능을 담당하는 별도의 서비스로 독립시킬 필요가 있다. 동적으로 갱신도 가능하고 확장성이 뛰어난 서비스를 만드는 것이 목표다.

SQL 서비스 인터페이스

public interface SqlService {
    String getSql(String key) throws SqlRetrievalFailureException;
}

Key로 SQL을 가져오는게 실패하는 경우는 복구 불가능하니 런타임 예외로 설정한다.

public class SqlRetrievalFailuruException extends RuntimeException{
        //...
}
public class UserDaoJdbc implements UserDao{
        private SqlService sqlService;

        public void setSqlService(SqlService sqlService){
                this.sqlService = sqlService;
        }

        public void add(User user) {
                this.jdbcTemplate.update(this.sqlService.getSql("userAdd"), ...);
        }
}
public class SimpleSqlService implements SqlService {
        //fields and setters

        public String getSql(String key) throws SqlRetrievalFailureException{
                String sql = sqlMap.get(key);
                if(sql == null){
                        throw new SqlRetrievalFailureException("........");
                }
                else return sql;
        }
}

이제 이 SqlService에 XML로 주입을 하던, 다른 방법으로 주입을 하던 빈을 DI해주기만 하면 된다.

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

XML 설정 파일에 SQL 문을 넣는 대신, SQL을 담는 전용 XML을 만들어보자.

자기참조 빈은 책임이 다른 코드들을 내부에 생성하고 다수의 인터페이스를 구현하면서 이 인터페이스를 자기자신을 참조하여 사용하는 것을 부릅니다.

JAXB(Java Architecture for XML Binding)

JAXB는 java.xml.bind 패키지 안에 있다.

  • XML 정보를 오브젝트 트리 구조로 만들어 주기 때문에 객체처럼 다룰 수 있다.
  • XML 문서의 구조를 정의한 스키마로 객체를 만들어주는 스키마 컴파일러를 제공한다. 매핑정보가 애너테이션으로 표시되어 있다.
  • 애너테이션의 정보를 이용하여 XML 원본을 오브젝트 트리로 만들어준다. (Unmarchalling) 그 반대도 가능하다. (Marchalling)

SQL 맵을 위하여 스키마로 XML의 구조를 아래와 같이 작성한다.

<!--sqlmap.xsd-->
<?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>

SQL 맵 XML

<sqlmap>
  <sql key="userAdd">insert into .... </sql>
  <sql key="userGet">select * from ...</sql>
    // ... 
</sqlmap>

JAXB 컴파일러는 다음과 같이 사용한다.

xjc -p {{생성할 클래스가 담길 패키지}} {{ㅅ키마 파일}} -d src

아래는 JAXB 컴파일러가 생성한 클래스들이다.

이 바인딩될 SqlmapType 클래스

@XmlAccessorType(XmlAccessType.FIELD)
@XmlType(name = "sqlmapType", 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;
    }
}

태그가 바인딩될 SqlType 클래스

@XmlAccessorType(XmlAccessType.FIELD)
@XmlType(name = "sqlType", propOrder = { "value" })

public class SqlType {
    @XmlValue
    protected String value;
    @XmlAttribute(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;
    }
}
public class XmlSqlService implements SqlService {
        // 이미 불러온 SQL 캐싱함
    private Map<String, String> sqlMap = new HashMap<String, String>(); // 읽어온 SQL을 저장해둘 맵 

        public XmlSqlService() { //생성자. 오브젝트 생성시점에 SQL 읽어옴
        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);
                        // JAXB API를 이용한 XML 문서 처리

            for(SqlType sql : sqlmap.getSql()) {
                sqlMap.put(sql.getKey(), sql.getValue()); // 읽어온 SQL을 맵으로 저장해둔다.
            }
                        // 읽어온 sql을 저장해둔다.
        } catch (JAXBException e) {
            throw new RuntimeException(e);
                        // 만약 예외가 발생한다면 파일이 잘못되었을 가능성이 있다.
                        // 런타임 예외로 던질 것.
        }
    }

    public String getSql(String key) throws SqlRetrievalFailureException {
        // 파일을 읽어들이며 map에 캐싱했던 것들을 반환한다.
                String sql = sqlMap.get(key);
        if (sql == null) {
            throw new SqlRetrievalFailureException(key + "에 해당하는 SQL을 찾을수 없습니다.");
        }
        return sql;
    }
}

똑같이 XML 파일에 작성하지만, 어플리케이션의 빈 설정 파일에 SQL이 섞이지 않고 별도의 XML 파일에 작성하고, 그를 읽어들이기 위하 JAXB를 이용한다. SQL이 바뀌더라도 빈 설정 파일을 건드는 일은 없게 되었다.

파일 이름을 하드코딩하지 않고 외부에서 주입 받기

public class XmlSqlService implements SqlService {

        private String sqlMapFile;

        public void setSqlMapFile(String sqlMapFile) {
            this.sqlMapFile = sqlMapFile;
        }
        public void loadSql()
                try {
                        //....
                InputStream is = UserDao.class.getResourceAsStream(this.sqlmapFile);
            }
                //....
        }
}

코드를 개선하다 보니 loadSql()은 초기화 메서드에 해당한다. 일반적으론 제어권이 스프링에 있는데, 객체가 생성된 뒤에 지정한 초기화 메서드를 호출하는 기능은 AOP의 빈 후처리기에서 구현한다.

복습 겸 되짚어보자면 AOP 빈 후처리기는 빈이 생성된 이후 부가적인 작업을 수행하게 해주는 기능이다.

태그로 하나씩 지정해줄 수도 있지만, 를 xml에 등록하면 빈 후처리 설정에 사용할 수 있는 애너테이션을 사용할 수 있다.

<beans xmlns:context="http://www.springframework.org/schema/context"
        <!-- .... -->
    >
    <context:annotation-config />
public class XmlSqlService implements SqlService {

        @PostConstruct // 빈생성 후 초기화 메서드 등록
        public void loadSql()

                //....
        }
}

https://gunju-ko.github.io/toby-spring/2019/01/06/스프링핵심기술의응용1.html

 

인터페이스 분리

현재 XmlSqlService는 JAXB에 의존적이다. 만약 다른 기술으로 변경하고자 한다거나, HashMap이 아닌 다른 컬렉션에 두고자 한다면 모두 XmlSqlService 코드를 직접 수정하는 것을 강제한다. 변경되는 이유가 2가지이기 때문에 단일 책임 원칙(SRP)를 위반한다.

그렇다고 새 클래스를 만드자니 대부분의 코드가 중복된다.

  • 변하는 시기와 성질이 다른 것
  • 변하는 것과 변하지 않는 것

을 같이 두는건 좋은 설계가 아니다.

SQL을 가져오는 것/ SQL을 보관하고 사용하는 것은 분리해야 한다.

추가기능으로 SQL을 런타임에 변경할 수 있도록 하는 책임도 고려할 수 있다.

이 각자의 책임을 오브젝트로 분리하고, SqlService는 이 오브젝트들과 통신하도록 할 수 있다.

https://iyoungman.github.io/spring/스프링핵심기술의응용/

 

SqlReader에서 읽어온 값을 SqlRegistry로 전달하기

SqlReader는 SqlRegistry에게 전달할 기술과 독립적인 리턴 타입을 반환하여야 한다. JAXB가 만든 클래스 같은 것들에 의존하지 않아야 한다. 현재 SqlService에서 오가는 내용들엔 Map 컬렉션이 적합할 것이다.

그러나 JAXB가 오브젝트 트리를 받아서 Map으로 다시 만들어 주어야 하는건 번거로운 일이 될 수가 있다.

발상의 전환을 통해 SqlService를 거쳐서 통신하는 대신

  • SqlReader는 SqlRegistry를 필요에 따라 등록을 요청할 때만 활용하도록 함
  • SqlReader에게 제공할 SqlRegistry는 SqlService가 제공
  • 파일을 읽어서 매 SQL 엔트리마다 SqlRegistry에 등록 요청
//SqlService 

sqlReader.readSql(sqlRegistry);
// SQL 파일을 읽을 때 마다 SqlRegistry를 콜백처럼 활용
public interface SqlRegistry {
    //sql을 등록함
        void registerSql(String key, String value);
        //sql을 Key를 통해 가져올 수 있도록 함
    String findSql(String key) throws SqlNotFoundException;
}
public interface SqlReader {
        // 외부에서 sql을 읽어와 registry를 통해 등록
    void read(SqlRegistry sqlRegistry);
}

https://iyoungman.github.io/spring/스프링핵심기술의응용/

 

XmlSqlService 클래스는 세 가지 책임을 한 곳에 모두 구현하여 문제가 있던 케이스였다. 이젠 그 책임을 3가지 서비스 인터페이스로 분리한다.

https://gunju-ko.github.io/toby-spring/2019/01/06/스프링핵심기술의응용1.html

 

만약 이 3가지 인터페이스를 하나의 클래스가 구현한다면? XmlSqlService가 이를 구현한다면 같은 클래스에서 나온 코드일지라도 책임이 다른 코드는 직접 접근하지 않고 인터페이스를 통해 간접적으로 사용할 수 있는 코드로 변모한다.

public class XmlSqlService implements SqlService, SqlRegistry, SqlReader {

    private SqlReader sqlReader;
    private SqlRegistry sqlRegistry;

        //-------------------------------- SqlService
        //DI 받기 위한 Setter 작성
        public void setSqlReader(SqlReader sqlReader){
                this.sqlReader = sqlReader;
        }

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

        // XmlSqlService 구현에 따른 메서드.
        @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);
        }
    }
        //-------------------------------- SqlRegistry
    private Map<String, String> sqlMap = new HashMap<>();
        // sqlRegistry 구현의 일부이므로 외부 인터페이스에서 접근 불가

    @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을 찾을수 없습니다");
        } 
        return sql;
    }
        //-------------------------------- SqlReader
    private String sqlmapFile;
        public void setSqlmapFile(String sqlmapFile){
                this.sqlmapFile = sqlmapFile;
        }
        // sqlReader 구현의 일부이므로 외부 인터페이스에서 접근 불가

    @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(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);
        }
    }
}
<bean id="sqlService" class="{{classpath}}">
  <property name="sqlReader" ref="sqlService" /> // 자기 자신을 참조한다!
  <property name="sqlRegistry" ref="sqlService" /> 
    <property name="sqlmapFile" value="sqlmap.xml" />
</bean>

자기 자신을 참조하는 빈은 사실 왜 쓰는지 책 읽는 내내 의문이 들었지만, 기존처럼 하나의 클래스에서 다양한 책임을 지는 경우, 다수의 인터페이스를 구현하며 자기 자신을 참조하도록 만들면 복잡하게 얽힌 코드를 책임 단위로 구분할 수 있다.

디폴트 의존관계

확장 가능한 인터페이스를 정의하고, DI가 가능하도록 코드를 재구성하였다. SqlRegistry와 SqlReader를 DI받는 가장 간단한 SqlService 구현 클래스는 XmlSqlService 코드에서 SqlReader와 SqlRegistry 같이 의존 인터페이스 구현 선언을 빼버리고, 그것을 구현하는 코드들을 제거하도록 만든다.

public class BaseSqlService implements SqlService{

        // 상속을 통해 확장할 수 있도록 한다. 서브클래스에서 사용할 수 있도록 protected 선언
        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);
        }

        public String getSql(String key) throws SqlRetrievalFailureException{
                try {
                        return this.sqlRegistry.findSql(key);
                } catch (SqlNotFoundException e) {
                        throw new SqlRetrievalFailureException(e);
                }
        }
}

유연성을 위해서 책임을 분리하는 작업은 필요하지만, 매번 3개의 빈을 등록해줘야 하는건 귀찮게 느껴진다. 충분히 감수할 만한 가치가 있는 일이지만, 대부분의 경우에서 디폴트라고 여겨지는 의존 오브젝트가 있다면, 디폴트 의존관계를 선언하자.

public class DefaultSqlService extends BaseSqlService /*SqlService가 아닌 BaseSqlService임이 중요*/{
        public DefaultSqlService(){
                setSqlReader(new JaxbXmlSqlReader());
                setSqlRegistry(new HashMapSqlRegistry());
        }
}

미리 디폴트로 설정하고 싶은 오브젝트를 기본 생성자에 선언한다.

문제는 이것만으로는 작동하지 않는다. sqlmapFile 프로퍼티가 비어있기 때문인데, sqlmapFile을 DefaultSqlService 프로퍼티로 정의하고, 생성자나setter로 받으면서 JaxbXmlSqlReader를 만들면서 프로퍼티로 제공하는 방법이 있다. 그러나 디폴트의 의미를 생각하면, JaxbXmlSqlReader를 사용할지 안할지도 모르는데 여기에만 필요한 sqlmapFile의 프로퍼티를 등록하는건 바람직하지 않다.

그 대시, JaxbXmlSqlReader가 디폴트 sqlmapFile로 쓸만한 이름을 지정하는 방법이 있다.

디폴트 의존 오브젝트 정리가 끝났고, 다른 구현 오브젝트를 주입하고 싶다면 빈에다 선언하면 그만이다.

사소한 문제 하나는 디폴트로 설정한 오브젝트들이, 특정 오브젝트를 프로퍼티로 설정해 주입하더라도 생성되는 문제가 있다. 이를 해결하기 위해선 @PostConstruct 초기화를 이용해 프로퍼티가 설정되었는지 확인 후, 디폴트 오브젝트를 만드는 방법을 사용할 수 있다.

7.3 서비스 추상화 적용

JAXB가 표준에 포함된 라이브러리지만 그 외에도 다양한 XML - 자바오브젝트 매핑 기술이 있다. 이를 OXM이라 부른다. 모두 같은 역할을 하기 때문에 유사한 API를 제공한다. 그래서 스프링에서는 트랜잭션, 메일 전송과 유사하게 OXM에 대해서도 서비스 추상화를 제공한다.

OXM 서비스 인터페이스

스프링이 제공하는 OXM 추상화 서비스 인터페이스에서는

자바오브젝트→XML (Marshaller)qwewqqqeeeeee

XML → 자바오브젝트 (Unmarshaller)

가 존재한다.

package org.springframework.oxm;

public interface Unmarshaller {
        boolean supports(Class<?> clazz); // 해당 클래스로 언마샬이 되는지 확인한다.

    Object unmarshal(Source source) throws IOException, XmlMappingException;
        // 매핑이 실패하면 추상화된 예외를 던진다. source로 제공받은 XML을 자바 오브젝트 트리로 변환하고
        // 루트 오브젝트를 돌려준다.
}

OXM 기술에 따라 위 인터페이스를 구현한 클래스가 있다. 필요로 하는 추가 정보를 빈 프로퍼티로 지정할 수 있도록 만들어져 있다. JAXB를 위한 클래스는 Jaxb2Marshaller다. 이 클래스는 Unmarshaller와 Marshaller 인터페이스 모두를 구현하고 있다.

프로퍼티에 contextPath를 지정해 바인딩 클래스의 패키지를 지정하여 사용한다. 그저 빈을 만들어 주입하고, unmarshal() 메서드를 호출하면 끝이다. oxm 기술이 바뀌더라도 Unmarshaller 타입의 변수를 주입받는 입장에선 코드가 바뀔 일이 없다.

OXM 서비스 추상화 적용하기

스프링의 OXM 추상화 기능을 이용하는 SqlService를 만든다면, SqlRegistry는 DI받을 수 있도록 만들지만 SqlReader는 OXM의 언마샬러를 이용하도록 고정시킨다. SQL을 읽는 방법을 OXM으로 제한하는 효과가 있다. 또한 SqlReader 구현을 외부에서 사용하지 못하도록 제한하게 되면,

  • 최적화된 구조로 만들 수 있다. 유연성을 포기하면서 응집도를 높일 수 있다.
  • 외부에선 마치 하나의 오브젝트처럼 보이도록 결합된다.
public class OxmSqlService implements SqlService {
    private final OxmSqlReader oxmSqlReader = new OxmSqlReader();
        // final이므로 변경 불가하고 강하게 결합되어서 OxmSqlService를 빈으로 등록하면
        // OxmSqlReader가 포함된다.
        // private이므로 외부에서 접근불가

    private class OxmSqlReader implements SqlReader {
            //...
    }

}

OXM 추상화를 사용하면 Unmarshaller 빈을 등록해야 한다. 점점 빈을 등록해야 할 갯수가 점점 늘어나는데, 이 구조가 불편할 수도 있다. OxmSqlReader와 같이 내부에서 만드는 객체는 외부 프로퍼티를 받기도 어렵다. 그래서 하나의 빈 설정으로 두 개의 오브젝트를 설정하는 구조를 만들어야 한다.

public class OxmSqlService implements SqlService {

    private final OxmSqlReader oxmSqlReader = new OxmSqlReader();

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

    public void setSqlmapFile(String sqlmapFile) {
        this.oxmSqlReader.setSqlmapFile(sqlmapFile);
                // oxmSqlReader로 전달한다.
    }

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

OXM을 적용했지만 단순한 빈 설정을 유지할 수 있게 되었다.

중복되는 코드 위임으로 줄여보기

loadSql()과 getSql()의 핵심 코드가 BaseSql과 중복되는 문제가 있다. 간단한 코드에서는 어느정도 중복은 큰 문제가 없지만 복잡해지는 경우 문제가 된다.

loadSql과 getSql의 구현 로직은 BaseSqlService에만 두고, OxmSqlService는 설정과 기본 구성을 변경하는 어댑터 개념으로 BaseSqlService 앞에 두는 설계가 방법이 될 수 있다.

 

public class OxmSqlService implements SqlService{
    private final BaseSqlService baseSqlService = new BaseSqlService();
        // SqlService의 구현 부분을 위임할 대상을 만들어둔다.

    @PostConstruct
    public void loadSql() {
        // OxmSqlService의 프로퍼티를 통해서 초기화된 SqlReader와 SqlRegistry를 실제 작업을 위임할 대상인 baseSqlService에 주입한다.
        this.baseSqlService.setSqlReader(this.oxmSqlReader);
        this.baseSqlService.setSqlRegistry(this.sqlRegistry);

        // SQL을 등록하는 초기화 작업을 baseSqlService에 위임한다.
        this.baseSqlService.loadSql();
    }

리소스 추상화

자바에서는 http, ftp, file, 클래스패스 내 리소스 등 다양한 리소스에 일관성 있게 접근할 수 있는 방법이 없다. 그래서 스프링에선 Resource라는 추상화 인터페이스를 정의했다.

public interface Resource extends InputStreamSource {
        boolean exists();
        default boolean isReadable();
        default boolean isOpen();

        URL getURL() throws IOException;
        URI getURI() throws IOException;
        File getFile() throws IOException;

        Resource createRelative(String relativePath) throws IOException;    

        long lastModified() throws IOException;    
        String getFilename();
        String getDescription();
}

외부의 리소스 정보가 필요할 때는 Resource 추상화를 이용한다. 이 Resource 인터페이스는 빈이 아니라 값으로 취급이 된다. 그래서 추상화를 적용하는데 어려움이 있다. 스프링에서는 리소스의 종류와 위치를 함께 표현하여 이 문자열 리소스 주소를 실제 Resource 타입 오브젝트로 반환하는 ResourceLoader를 제공한다.

public interface ResourceLoader {
        Resource getResource(String location); // String 정보를 바탕으로 Resource로 반환해준다.

}

 

ResourceLoader의 대표적인 예는 바로 스프링의 애플리케이션 컨텍스트다. 애플리케이션 컨텍스트가 구현해야 하는 인터페이스인 ApplicationContext는 ResourceLoader 인터페이스를 상속하고 있다. 따라서 모든 애플리케이션 컨텍스트는 리소스로더이기도 하다.

public class OxmSqlService implements SqlService {

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

    private static class OxmSqlReader implements SqlReader {
        private Resource sqlmap = new ClassPathResource("sqlmap.xml");
                // ClassPathResource는 Resource를 구현한 클래스이다.

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

        @Override
        public void read(SqlRegistry sqlRegistry) {
            try {
                    Source source = new StreamSource(this.sqlmap.getInputStream());
                                // Resource의 종류와 관계없이 스트림으로 가져올 수 있다.
                                // Resource는 리소스에 접근할 수 있는 추상화된 핸들러이다.

              // ...
            } catch (IOException | JAXBException e) {
                            // ...
            }
        }
    }
}

7.4 인터페이스 상속을 통한 안전한 기능확장

서버를 재시작하지 않고 SQL은 어떻게 바꿀 수 있을까?

DI를 의식하는 설계

DI를 잘 활요하기 위해선 DI에 적합한 설계가 필요하다. 거대한 객체 하나 대신, 의존관계를 가지고 적절하게 책임과 역할에 따라 분리하고, 인터페이스를 정의해 느슨하게 연결하고, 런타임에 유연하게 의존관계를 지정하도록 작성해왔다.

인터페이스를 사용하는 이유

  1. 다형성을 얻기 위해서. 하나의 인터페이스를 통해 여러 구현을 바꿔가면서 쉽게 사용할 수 있도록 하는 것이 DI가 추구하는 가치다.
  2. 인터페이스 분리 원칙을 통해 클라이언트와 의존 오브젝트 사이의 관계를 명확히 정의한다. 인터페이스는 어떤 객체를 보는 창이라고 생각할 수 있다. 하나의 오브젝트도 여러가지 다른 관심과 목적으로 다른 오브젝트가 의존하고 있을 수 있다. 홈페이지의 예를 들면, 일반 사용자에게 관리자 페이지의 기능을 제공하는 메서드는 의미가 없을 것이다. 목적과 관심이 다르면 인터페이스를 분리하는 것이 인터페이스 분리 원칙이다.

인터페이스 상속

인터페이스 분리 원칙은 모든 클라이언트가 자신의 관심에 따른 접근방식을 간섭없이 유지할 수 있다는 점이다. 그래서 인터페이스를 통해 의존하는 객체가 변하더라도, 클라이언트는 그에 영향을 받지 않을 수 있다.

만약 새로운 클라이언트가 등장한다면? 기존 인터페이스와 별개의 인터페이스를 만들 수 있지만, 기존 인터페이스를 상속해 더 큰 기능을 제공할 수도 있다.

7.5 DI를 이용해 다양한 구현 방법 적용하기

SqlRegistry는 동시성 문제가 발생할 일이 없다. 초기화하면서 쓰기 작업을 마친 후, 변경될 일이 없이 읽기 전용처럼 작동하기 때문이다. 하지만 SQL을 수정할 수 있도록 만드려면 어느 정도 쓰레드 안전을 보장할 수 있어야 한다.

ConcurrentHashMap 사용하기

일반적인 HashMap은 멀티쓰레드 환경에서 의도치 않은 결과가 발생할 수 있다. Collections.synchronizedMap()과 같이 외부에서 동기화해주는 메서드가 개발되어 있지만 모든 작업을 동기화하게 되면 많은 요청이 몰릴 때 성능 하락은 피할 수 없다. 대신 동기화된 해시데이터 조작에 최적화된 ConcurrentHashMap이 대안이 될 수 있다.

  • 전체 데이터에 락을 걸지 않는다.
  • 읽기 작업엔 락을 사용하지 않는다.

동시성에 대한 테스트는 작성하기가 매우 어렵다. 대신 ConcurrentHashMap을 이용한 SqlRegistry 구현을 우선 만들고 테스트할 수 있다.

내장형 데이터베이스

그러나 ConcurrentHashMap은 변경이 자주 일어나는 환경에선 동기화의 성능하락에서 자유로울 수 없다. 그래서 SQL을 담는 DB같은 것을 설계해볼 순 있지만, 관계형 데이터베이스 스키마를 구현하는 것은 배보다 배꼽이 더 커질 수가 있다. 그래서 내장형 DB를 고려해볼 수 있다.

내장형 DB(Embedded DB)는 인메모리 DB라고 생각하면 좋다. Persistence는 보장되지 않지만 메모리에 저장되어 빠른 IO가 가능하다. 또한 등록, 수정, 검색, 격리수준, 트랜잭션, 최적화된 락킹 등 DB가 제공할 수 있는 것들은 모두 제공할 수 있다. SQL문으로 질의가 가능한 것은 덤이다.

스프링의 내장형 DB 지원

자바에서는 Derby, HSQL, H2등의 내장형 데이터베이스가 널리 쓰인다.

  • JDBC 드라이버를 사용하기 때문에 JDBC 프로그래밍 모델을 그대로 사용할 수 있다.
  • 표준 DB와 호환된다.
  • 어플리케이션 내에서 DB를 초기화하는 스크립트의 실행 등의 초기화 작업이 별도로 필요하다.
  • 스프링에서 서비스 추상화처럼 별도의 레이어나 인터페이스를 제공하지는 않지만, 초기화 작업이 끝난 이후에는 JDBC나 DataSource등을 이용하여 접근이 가능하다.

스프링에선 초기화를 지원하는 내장형 DB 빌더를 제공한다. 이 DB 빌더에는 내장형 DB를 위한 URL과 드라이버를 초기화해주는 기능이 있다. 그리고 데이터 초기화를 위해 테이블을 생성하거나 초기 데이터를 삽입하는 SQL 초기화를 실행해주기도 한다. 이 모든 작업이 끝나면 DataSource 타입 오브젝트(정확하겐 DataSource를 extend한 EmbeddedDatabase)를 반환한다. 이 때부터 일반적인 DB와 똑같은 사용법으로 내장 DB에 접근이 가능하다.

특이한 기능으로는 어플리케이션 내에서 DB 종료를 요청하는 shutdown() 메서드도 제공을 하는 EmbeddedDatabase 인터페이스를 제공한다.

학습 테스트 작성하기

-- 테이블을 생성하는 schema.sql
CREATE TABLE SQLMAP (
    KEY_ VARCHAR(100) PRIMARY KEY,
    SQL_ VARCHAR(100) NOT NULL
    -- KEY와 SQL은 SQL의 키워드이기 때문에 _를 하나 붙여준다.
);

-- DB를 초기화하는 data.sql
INSERT INTO SQLMAP(KEY_, SQL_) values('KEY1', 'SQL1');
INSERT INTO SQLMAP(KEY_, SQL_) values('KEY2', 'SQL2');

내장형 DB가 시작될 때, 테이블을 생성하고 초기화 해주는 위 두 개의 파일이 실행되어야 한다.

내장형 DB 빌더는 EmbeddedDatabaseBuilder()이다.

new EmbeddedDatabaseBuilder() // 빌더오브젝트 생성
        .setType(/*내장형 DB 종류. HSQL, DERBY, H2 중 택*/)
        .addScript(/*테이블 생성과 데이터 초기화를 맡는 SQL 스크립트 위치 지정*/)
        //...
        .build(); 
public class EmbeddedDbTest {
        EmbeddedDatabase db;
        SimpleJdbcTemplate template;

        @Before
        public void setUp(){
                db = new EmbeddedDatabaseBuilder()
                        .setType(HSQL)
                        .addScript("classpath:schema.sql")
                        .addScript("classpath:data.sql")
                        .build();

                template = new SimpleJdbcTemplate(db);
        }

        @After
        public void tearDown(){
                db.shutdown();
        }

        @Test
        public void initData(){
                assertThat(template.queryForInt("select count(*) from sqlmap"),is(2));

                List<Map<String,Object>> list = template.queryForList("select * from sqlmap order by key_");
                assertThat(list.get(0).get("key_"),is("KEY1"));
                assertThat(list.get(0).get("sql_"),is("SQL1"));
                assertThat(list.get(1).get("key_"),is("KEY2"));
                assertThat(list.get(1).get("sql_"),is("SQL2"));
                // LIST의 원소는 각 ROW에 대응되고 MAP의 원소는 각 ROW의 COLUMN에 대응된다.
        }

        @Test
        public void insert(){
                template.update("insert into sqlmap(key_, sql_) values(?,?)", "KEY3", "SQL3");

                assertThat(template.queryForInt("select count(*) from sqlmap"),is(3));
        }
}

내장형 DB를 이용한 SqlRegistry 만들기

내장형 DB는 초기화가 필요하기에, 단순히 빈으로 등록해 사용할 수 없다. 그래서 팩토리 빈으로 만들어 초기화한 뒤 빈을 반환할 수 있도록 작성해야한다.

스프링에는 이 번거로운 작업을 대신하는 jdbc 태그가 존재한다.

<jdbc:embedded-database id=”embeddedDatabase” type=”HSQL”>
    <jdbc:script location=”classpath:schema.sql”/> <!-- 초기화 SQL 스크립트 등록 -->
</jdbc:embedded-database>

빈을 등록했으면 DI받아 SqlRegistry를 구현하도록 한다.

public class EmbeddedDbSqlRegistry implements UpdatableSqlRegistry {
    SimpleJdbcTemplate jdbc;

    public void setDataSource(DataSource dataSource) {
            jdbc = new SimpleJdbcTemplate(dataSource);
            //DataSource를 주입받도록 한 이유는, 인터페이스 분리 원칙을 지키기 위함이다.
            //EMbeddedDatabase는 DataSource를 상속받아 shutdown() 메서드를 추가했다.
            //그러나 이 클래스에선 조회만을 담당할 것이기 때문에, DataSource만으로 충분하다.
    }

    public void registerSql(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(EmptyResultDataAccessException e){
          throw new SqlNotFoundException(key+"에 해당하는 SQL을 찾을 수 없습니다.".e);
          }
       }

    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(key+"에 해당하는 SQL을 찾을 수 없습니다.");
        }
      }

    public void updateSql(Map<String, String> sqlmap) throws SqlUpdateFailureException {
        for (Map.Entry<String, String> entry : sqlmap.entrySet()) {
            updateSql(entry.getKey(), entry.getValue());
        }
    }
}

테스트 상속하기

UpdatableSqlRegistry를 구현한 다른 클래스의 테스트 코드를 작성했고, 새로 만든 EmbeddedSqlRegisry의 테스트 코드도 많은 부분이 비슷할 거라면, JUnit의 상속 기능을 이용할 수 있다.

트랜잭션 적용하기

public class EmbeddedDbSqlRegistryTest extends AbstractUpdatableSqlRegitstryTest {
    //    …
    @Test
    public void transactionlUpdate(){
      checkFind(“SQL1”,”SQL2”,”SQL3”);  
        // 초기상태 확인

        Map<String, String> sqlmap = new HashMap<String,String>();
        sqlmap.put(“KEY1”,”Modified1”);
        sqlmap.put(“KEY9999!@#$”,”Modified9999”); 
        // 존재하지 않는 키를 수정하도록 하면 예외가 발생할 것이다/
        // 트랜잭션 테스트를 위해 일부러 실패하도록 설정했다. 롤백을 체크하기 위한 테스트코드.

        try{
            sqlRegistry.updateSql(sqlmap);
            fail();   
        }
        catch(SqlUpdateFailureException e){}
        checkFind(“SQL1”,”SQL2”,”SQL3”);  // 초기상태와 동일한지 검증한다.
    }
}
public class EmbeddedDbSqlRegistry implements UpdateSqlRegistry{
    SimpleJdbcTemplate jdbc;
    TransactionTemplate transactionTemplate;  // 템플릿/콜백 패턴을 적용

    public void setDataSource(DataSource dataSource){
        jdbc = new SimpleJdbcTemplate(dataSource);
        transactionTemplate = new TransactionTemplate( new DataSourceTransactionManager(dataSource));
    }

    // 일반적으로는 트랜잭션 매니져는 여러 AOP를 통해 만들어지는 트랜잭션 프록시가 같은 트랜잭션 매니져를 공유하여야 한다.
    // 그러나 여기선 트랜잭션 매니저를 공유할 필요가 없다.
    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());
                }
        }
        });
    }
}

7.6 스프링 3.1의 DI

스프링은 꾸준히 발전해왔지만, 1.0부터 3.1까지 완벽한 구버젼 호환성을 가지고 있다. 그리고 객체지향 언어의 장점을 극대화하도록 프로그래밍을 유도하는 정체성을 유지하고, 자기 스스로도 잘 지켰기 때문이다. 그렇기에 기존 설계에 영향을 주지 않고 기능을 확장해나갈 수 있는 것이다.

DI의 원리는 변하지 않았지만 DI를 적용하는 방법은 많이 변해왔다. 대표적인 두 가지는 아래와 같다.

애너테이션의 메타정보 활용

자바 코드는 바이트코드로 컴파일되어 JVM에 의해 로딩되어 실행되지만, 때론 코드가 다른 코드에 의해 데이터 취급을 받기도 한다. 자바 코드의 일부를 리플렉션 API를 이용해 코드를 분석하고 그에 따라 동작하도록 하는 스타일이 자바 5 이후로 확산되었다.

이러한 프로그래밍의 정점은 애너테이션이라고 할 수 있다. 애너테이션은 코드 실행에 직접적으로 영향을 끼치지 못하고, 인터페이스처럼 타입을 부여하는 것도 아니며, 오버라이드나 상속이 불가능하다. 대신 프레임워크가 코드의 특성을 분석할 수 있는 메타정보로써 활용된다.

에너테이션이 부여된 클래스나 메서드의 패키지, 클래스 이름, 메서드 시그니쳐, 접근제한자, 상속한 클래스나 구현 인터페이스 등을 알 수 있다. 반면에 XML은 모든 내용을 명시적으로 작성해야 하기에 번거롭다.

리팩토링의 경우, 변수 이름 하나가 바뀌어도 XML은 모두 찾아 바꿔주어야 하지만, 애너테이션은 IDE가 이를 지원해주는 장점이 있다.

하지만 애너테이션은 변경이 발생하면 새로 컴파일을 해야하지만, XML은 그렇지 않다.

정책과 관례를 이용한 프로그래밍

애너테이션 같은 메타정보를 활용하는 이유는 코드로 동작 내용을 구체적으로 구현하는 대신, 미리 약속한 규칙이나 관례를 따라 구현했다면, 애너테이션으로 쉽게 부가적인 것들을 부여할 수 있다. 반복되는 부분을 줄여주고 빠르게 구현할 수 있다는 장점이 있다. 반면에 이 모든 규칙들을 익히는 러닝 커브와 방대한 분량이 문제가 된다.

xml을 애너테이션과 자바코드로 대체하기

가장 먼저 DI 정보가 XML에 있음을 선언하는 정의를 바꾸는 것이다.

// UserDaoTest.java
@ContextConfiguration(locations="/test-applicationContext.xml")

@ContextConfiguration은 스프링 테스트가 DI 정보를 어디서 가져와야할 지 지정하는 애너테이션이다. location에 XML을 지정하는 대신 DI 정보를 담고있는 자바 클래스를 이용하도록 하자.

DI 정보로 사용될 자바 클래스를 만들 때 @Configuration 애너테이션을 사용한다. 해당 애너테이션이 붙은 클래스는 앞으로 XML을 대신하여 DI 설정정보로 이용될 것이다.

@Configuration
public class TestAppicationContext{

}

// UserDaoTest.java
//...
@ContextConfiguration(classes=TestApplicationContext.class) // 이제 XML 대신 클래스에서 DI 설정을 찾는다.
public class UserDaoTest{
        //...
}

자바 클래스로 만들어진 DI 설정에 XML의 설정정보를 가져올 수도 있다.

@Configuration
@ImportResource("/test-applicationContext.xml") // xml 설정정보를 가져온다.
public class TestAppicationContext{

}

<context:annotation-config />로 살펴보는 XML과 @Configuration의 차이

<context:annotation-config />는 @PostConstruct를 붙인 메서드가 빈 후처리기에 의해 빈 생성이후 실행되도록 처리하기 위해 설정했었다. XML로 설정할 때는 필요했지만, @Configuration이 붙은 클래스를 컨테이너가 사용하면, 알아서 @PostConstruct를 애너테이션을 처리하는 빈 후처리기를 등록하게 된다.

전환하기

으로 정의된 DI 정보는 @Bean이 붙은 메서드와 1:1로 매핑된다. @Bean은 @Configuration이 붙은 DI 설정용 클래스에서 사용된다. 메서드를 이용해 빈 오브젝트의 생성과 의존관계 주입을 코드로 직접 작성할 수 있도록 한다.

@Bean
// 반드시 public으로 만들어줄 필요는 없다. 리플렉션 API로 메서드를 참조하는데, private라도 문제없이 사용이 가능하다.
// 메서드의 리턴값 설정은 신중해야 한다. dataSource의 구현 클래스가 바뀌더라도 주입받는 쪽의 코드가 바뀌지 않도록 신중히 선택한다.
// 메서드 이름은 <bean>의 id에 대응된다.

public DataSource dataSource() {
        // 리턴값이 DataSource이지만 SimpleDriverDataSource로 지역변수 타입을 두는 이유는
        // DataSource에 setUserName 등의 메서드가 없기 때문이다.
        // 하지만 SimpleDriverDataSource는 DataSource를 상속하였기 때문에 반환하는데는 아무 문제가 없다.
        SimpleDriverDataSource dataSource = new SimplerDriverDataSource();

        // driverClass 프로퍼티는 Class<? extends Driver> 타입이라 드라이버 클래스를 사용해야 한다.
        // XML에선 com.mysql.jdbc.Driver를 com.mysql.jdbc.Driver.class로 알아서 바꿔줬지만
        // 자바코드로 작성할 때는 신경써서 주입하여야 한다.
        dataSource.setDriverClass(Driver.class);
        dataSource.setUrl("jdbc:mysql://localhost:port/....");
        dataSource.setUsername("myid");
        dataSource.setPassword("1q2w3e!");
        // 단순히 의존관계 빈을 주입하는 것 이외에도 다른 작업들도 가능하다.
        return dataSource;
}

@Autowired

XML의 프로퍼티를 이용해 자바 코드로 작성한 DI 정보를 참조할 수 있었지만, XML에 작성된 DI 정보를 자바 코드에선 어떻게 참조할까? @Autowired를 이용할 수 있다.

만약 @Autowired가 붙은 필드의 타입과 같은 타입의 빈이 있다면 자동으로 필드에 주입한다.

@Autowired
SqlService sqlService;

@Bean
public UserDao userDao() {
        //...
        dao.setSqlService(this.sqlService);
        //..
}

전용 태그 전환하기

<jdbc:embedded-database id=”embeddedDatabase” type=”HSQL”>
    <jdbc:script location=”classpath:schema.sql”/> <!-- 초기화 SQL 스크립트 등록 -->
</jdbc:embedded-database>

<tx:annotation-driven />

지금까지 전환엔 문제가 없었지만, 특수한 태그를 이용한 위 두 개의 빈은 내부에서 어떤 과정을 거쳐 빈이 만들어지는지 파악하기 어렵다.

내장형 DB

내장형 DB는

  • type에 지정한 기술을 사용하는 내장형 DB를 init하고
  • jdbc:script로 지정한 초기화 스크립트를 실행하여
  • 내장형 DB에 연결가능한 DB 커넥션 오브젝트를 돌려준다.

jdbc:embedded-database 태그는 위와 같은 복잡한 과정을 거치는데, 자바 코드에선 EmbeddedDatabaseBuilder가 비슷한 역할을 한다.

@Bean
public DataSOurce embeddedDatabase() {
        return new EmbeddedDatabaseBuilder()
                .setName("embeddedDatabase")
                .setType(HSQL)
                .addScript("classpath:schema.sql")
                .build();
}

tx:annotation-driven

트랜잭션 AOP를 적용하려면 수많은 빈이 필요한데, tx:annotation-driven/ 태그는 기본적으로 아래 4가지 클래스를 빈으로 등록한다.

  • InfrastructureAdvisorAutoProxyCreator
  • AnnotationTransactionAttributeSource
  • TransactionInterceptor
  • BeanFactoryTransactionAttributeSourceAdvisor

위 네 개의 클래스를 빈으로 등록해 적절히 프로퍼티 값을 넣어주면 tx:annotation-driven을 대체할 수 있다. 근데 위 4가지를 어떻게 기억하고 매번 번거롭게 만들어준단 말인가?

간단한 해법은 @EnableTransactionManagement 애너테이션을 TestApplicationContext에 붙여줌으로써 해결할 수 있다.

@Autowired를 이용한 자동 와이어링

@Autowired는 필드의 타입과 빈의 타입이 일치하면 빈을 자동으로 주입해준다. 빈의 프로퍼티 설정을 직접 해주는 자바 코드나 XML의 양을 줄일 수 있다.

@Autowired는 필드, Setter, 생성자에 부착할 수 있다.

참고하기 1 : @Autowired로 빈을 특정하여 주입받는 과정

  1. Context 파일을 로딩하고 빈으로 생성되어아 햐는 객체들을 전부 로드
  2. 빈간 의존관계를 포함해 생성할 빈을 트리를 통해 목록화
  3. Bean 설정 정보 생성하고 의존성 주입
  4. @Autowired가 붙은 필드와 타입이 일치하는 Bean이 있으면 주입
  5. 타입이 일치하는 빈이 없다면 속성명이 일치하는 Bean을 주입
  6. @Qualifier 애너테이션이 붙은 속성에 의존성 주입
  7. 그럼에도 주입받을 빈을 결정하지 못하면 에러 발생

참고자료 : https://engkimbs.tistory.com/682

참고하기 2 : 필드에 @Autowired 주입을 권장하지 않는 이유

IntelliJ등 최신 IDE에선 필드에 빈을 주입하는 것에 노란 경고를 띄운다.

생성자 주입을 @Autowired를 사용하는 필드 주입보다 권장하는 하는 이유 by Kimtaeng

@Autowired에서 이름을 이용한 의존 설정을 선호하지 않는 이유? by 자바캔두잇

참고하기 3 : @Resource VS @Autowired

Autowired는 필드의 타입을 기준으로 빈을 찾는데 비해, Resource는 필드 이름을 기준으로 한다. 주의할 것은, 스프링의 Resource 애너테이션이 이름으로 빈을 주입받는 것이 실패할 경우, Autowired와 유사하게 타입 기반의 주입을 시도한다는 점이다.

@Resource VS @Autowired in StackOverFlow

@Component를 이용한 자동 빈 등록

클래스에 부여되는 애너테이션으로, 부착된 클래스는 자동으로 빈으로 등록된다. 그러나 모든 클래스패스의 클래스를 검사하여 등록하는건 부담이 큰 일이기에, 특정 패키지 하위 주소에서만 찾도록 범위를 좁혀줄 필요가 있다.

자바로 작성된 DI 설정파일에 @ComponentScan(basePackages="{스캔 대상의 패키지}")를 부착한다. @Component가 부착된 클래스를 찾으면 자동으로 Bean으로 등록하며, 특별히 Bean의 아이디를 지정하지 않았다면(@Component("{Bean의 아이디}")와 같이 지정할 수 있다) 클래스의 첫 글자를 소문자로 바꾸어 아이디로 사용한다. 이렇게 자동 빈 등록을 이용하는 경우에는 의존 프로퍼티를 설정할 수 없으므므로, @Autowired를 이용하도록 한다.

메타 애너테이션

위에서 살펴본 @Component 애너테이션은 다음과 같이 정의되어 있다.

public @interface Component{  // @interface가 붙으면 애너테이션 정의이다.
        //...
}

그런데 스프링에선 @Component 이외의 애너테이션을 부착하여도 자동으로 빈 등록이 가능하다. 빈 스캔 검색 대상으로 만들 뿐 아니라 다른 의미의 마커로도 사용할 수 있도록 하기 위함이다.

무슨 얘기냐면, AOP 적용대상 포인트컷을 패키지나 클래스로도 지정할 수 있지만, 애너테이션 기준으로 부가기능을 부여하는 것도 가능하다. @Transactional이 가장 대표적인 예이다. 이 때 편의상 AOP 적용 대상에 부착할 애너테이션을 만들고 싶은데 이 애너테이션은 빈 자동등록 대상임을 알리는 용도로도 쓰고 싶다. 애너테이션은 상속도 불가능하고 인터페이스 구현도 불가능하다. 그러면 포기해야할까?

다행히 애너테이션에 부착하는 애너테이션, 즉 메타 애너테이션을 이용한다.

@Component
public @interface MyAnnotation{
        //...
}

이렇게 커스텀 애너테이션을 작성하고 클래스에 부착하면 자동 빈 등록 대상이 된다.

유사한 다른 예로는, DAO 기능을 제공하는 클래스에 부착하는 @Repository 애너테이션이 존재한다. 이를 권장하기도 한다. 마찬가지로 자동 빈 등록대상이 된다.

컨텍스트 분리와 @import

지금까지는 테스트용 testUserService 빈과 userService빈을 한 곳의 XML로 담아 놓았다. 성격이 다른 DI 정보는 따로 관리되어야 하기 때문에, DI 설정 클래스를 추가하고 관련된 빈 설정과 애너테이션, 필드, 메서드를 옮긴다.

기존의 하나였던 설정을

  • 테스트에만 쓰이는 TestAppContext(DummyMailSender()등을 포함한)
  • 실제 앱의 동작에 쓰이는 AppContext

로 분리하였으면, 테스트에는 두 개 모두 Import를, 실제 로직에는 AppContext만 Import 하도록 한다.

@ContextConfiguration(classes={TestAppContext.class, AppContext.class})
public class UserDaoTest {
        //...
}

지금까지 만들어왔던 SqlService는 다른 데에서도 충분히 쓰일 수 있고, AppContext 내의 빈들과 구분되는 특징이 있으므로 SqlServiceContext 클래스로 분리하여 모듈처럼 관리하고, 그래도 여전히 실 서비스와 깊은 연관이 있으므로 AppContext와 @Import를 통해 연관을 지어준다.

@Configuration
@EnableTransactionManagement

@ComponentScan(basePackages="springbook.user")

@Import(SqlServiceContext.class) 
// AppContext에 접근할 수 있다면 여전히 SqlServiceContext의 빈을 사용할 수 있다.
public class AppContext {
        // ...
}

프로파일

테스트 환경과 운영환경에서 각각 다른 빈 정의가 필요한 경우가 있다. 예를 들면 테스트와 운영환경에서 양쪽 모두에 필요한 Bean이지만 내용이 달라져야 하는 경우다. 파일을 분리하는 방법으로는 복잡한 개발환경에서 문제가 있을 수 있다.

그래서 @Profile과 @ActiveProfiles를 사용할 수 있다.

환경에 따라 빈 구성이 달라지는 내용을 프로파일로 정의하고, 실행 시점에 어떤 프로파일의 빈을 사용할 지 지정한다.

@Configuration
@Profile("test")
public class TestAppContext {
        //테스트 환경에 사용될 빈
}

@Configuration
@Profile("production")
public class ProductionAppContext {
        //운영환경에 사용될 빈
}

@Configuration
@EnableTransactionManagement
@ComponentScan(basePackages="springbook.user")
@Import({SqlServiceContext.class, TestAppContext.class, ProductionAppContext.class}) 
// AppContext에 접근할 수 있다면 여전히 SqlServiceContext의 빈을 사용할 수 있다.
public class AppContext {
        // ...
}

@RunWith(SpringJUnit4ClassRunner.class)
@ActiveProfiles("test") // 프로파일 사용
@ContextConfiguration(classes=AppContext.class)
public class UserServiceTest {
        // test 프로파일을 가지고 있는 TestAppContext에 정의된 빈이 사용된다.
        // ProductionAppContext의 빈들은 적용되지 않는다.
}

실제 ProductionAppContext에 정의된 Bean이 무시되고 있는지 알고싶다면, 아래와 같은 코드를 작성할 수 있다.

@Autowired 
DefaultListableBeanFactory bf;
// 스프링 컨테이너는 BeanFactory 인터페이스를 구현한다.
// 그 중 DefaultListableBeanFactory 구현 클래스는 대부분의 스프링 컨테이너에서 사용된다.
// getBeanDefinitionNames()는 컨테이너에 모든 빈 이름을 가져올 수 있다.

@Test
public void beans(){
        for(String str : bf.getBeanDefinitionNames()){
                System.out.println(str + "\t" + bf.getBean(str).getClass().getName());
        }
}

중첩 클래스를 이용한 프로파일 적용

거대한 하나의 빈 설정을 @Import를 이용해 나누고, 프로파일을 적용하여 상황에 따른 빈 설정이 가능하게 했다. 근데 파일이 많아지면 이 모든 것 들을 한 눈에 보기 어려워 분리했던 설정정보를 중첩 클래스를 이용해 하나로 모아보자.

기껏 열심히 분리해놨더니 모으면 허무하지 않을까 싶지만 프로파일 설정과, 목적이 다른 빈을 다른 클래스로 분리해 놓은것은 여전히 유효하도록 하면서, 가독성을 높이는 방법이다.

@Configuration
@EnableTransactionManagement
@ComponentScan(basePackages="springbook.user")
@Import({SqlServiceContext.class, 
                    /*AppContext.TestAppContext.class, // 이젠 AppContext의 스태틱 클래스가 되어, 이 부분이 바뀐다. 
                    AppContext.ProductionAppContext.class*/ // Import 애너테이션으로 지정해주지 않아도 작동이 잘 된다.
                }) 
public class AppContext {
        // ...

        @Configuration
        @Profile("test")
        public static class TestAppContext {
                //테스트 환경에 사용될 빈
        }

        @Configuration
        @Profile("production")
        public static class ProductionAppContext {
                //운영환경에 사용될 빈
        }
}

프로퍼티 소스

AppContext에는 여전히 테스트 환경에 종속되는 정보가 남아있다. dataSource의 DB 연결 정보다. 드라이버 클래스, URL, 계정 정보는 환경에 따라 달라진다.

그래서 이런 정보들은 자바 코드에 직접 하드코딩 하는 대신, XML이나 프로퍼티 파일같은 텍스트 파일에 저장해 두는 것이 좋다.

# 빈 설정에 필요한 프로퍼티를 외부 정보로 부터 가져올 수 있다.
# 이렇게 프로퍼티 값을 가져오는 대상을 프로퍼티 소스라고 부른다.
db.driverClass=com.mysql.jdbc.Driver
db.url=jdbc:mysql://....
db.username=spring
db.password=book
@PropertySource("/database.properties") // 해당 프로퍼티 소스에서 프로퍼티를 읽어온다.
public class AppContext {
}
@Autowired Environment env;
@Bean
public DataSource dataSource {
    ...
    try {
        ds.setDriverClass(Class<? extends java.sql.Driver>) // Class 타입 캐스팅이 필요
                                Class.forName(env.getProperty("db.driverClass"));
    } catch (ClassNotFoundException e) {
        ...
    }
    ds.setUrl(env.getProperty("db.url"));
        ds.setUsername(env.getProperty("db.username"));
        ds.setPassword(env.getProperty("db.password"));

        return ds;
}

코드가 좀 지저분하다. Environment 오브젝트를 주입받아 귀찮게 코드를 작성하는 대신 PropertySourcesPlaceholderConfigurer에서 @Value 애너테이션을 통해 치환자로 프로퍼티를 소스로부터 직접 주입받을 수 있다.

//...
public class AppContext{
        @Value("${db.driverClass}") Class<? extends Driver> driverClass;
        @Value("${db.url}") String url;

        //....
}

@Value와 치환자로 프로퍼티 값을 필드에 주입하려면 PropertySourcesPlaceholderConfigurer 빈을 정의해주어야 한다.

@Bean
public static PropertySourcesPlaceholderConfigurer placeholderConfigurer() {
    return new PropertySourcesPlaceholderConfigurer();
}

빈 설정의 재사용과 @Enable*

SqlServiceContext는 조금 특별한 특징이 있어 AppContext와 분리하도록 했다. 그래서 여러 프로젝트에서 재사용이 쉽고, 빈 설정을 깔끔하게 유지할 수 있었다.

그러나 여전히 SQL 서비스는 특정 위치의 특정 파일에 의존적인 문제가 있다.

private class OxmSqlReader implements SqlReader {
    private Unmarshaller unmarshaller;
    private Resource sqlmap = new ClassPathResource("sqlmap.xml", UserDao.class);
        //UserDao의 클래스 패스의 sqlmap.xml로 고정되어있다.
        //..
}
// SQL 매핑파일의 리소스를 돌려주는 메서드를 구현한다.
public class UserSqlMapConfig implements SqlMapConfig{
    @Override
    public Resource getSqlMapResource() {
        return new ClassPathResource("/sqlmap.xml", UserDao.class);
    }
}
        @Bean
    public SqlService sqlService() throws IOException {
                @Autowired
                SqlMapConfig sqlMapConfig
                //....
                sqlService.setSqlRegistry(sqlRegistry());       
        sqlService.setSqlmap(this.sqlMapConfig.getSqlMapResource());
                // 외부에서 주입받은 sqlMapConfig에서 xml파일을 주입받는다.
                // 이제 이 서비스를 사용하고자 하는 개발자는 UserSqlMapConfig의 메서드를 구현하여
                // 원하는 위치의 xml 파일을 읽을 수 있도록 하였다.
        return sqlService;
    }

SQL 매핑파일 리소스 위치도 빈 설정에 관련된 정보인데, 새로운 클래스를 추가한게 영 아쉽다.조금 더 간결하게 만들어 보고 싶다.

AppContext도 빈이라서 @Autowired를 통해 DI받을 수 있다. AppContext가 SqlMapConfig 인터페이스를 직접 구현하도록 한다.

빈을 DI받아서 사용하는 쪽에서는 특정 인터페이스를 구현하는지에만 관심이 있기 때문에, SqlMapConfig 인터페이스를 직접 구현하고, 그 안에 메서드를 Override하면 굳이 클래스 하나를 더 만들 필요가 없어진다.

public class AppContext implements SqlMapConfig{
        //...

        @Override
        public Resource getSqlMapResource(){
                return //...
        }
}

@Enable* 애너테이션

SqlServiceContext는 모듈화가 되어 빈 설정에 재사용될 수 있다. @Import를 이용해야 하지만, 애너테이션을 보다 가독성을 높이는 방향으로 쓰기 위해 @Enable* 애너테이션을 구현하자.

@Import(value = SqlServiceContext.class) // 메타 애너테이션을 @Enable*로 감쌌다.
public @interface EnableSqlService {
}
@Configuration
@EnableTransactionManagement
@ComponentScan(basePackages="springbook.user")
@EnableSqlService // 보다 더 가독성이 좋아졌다.
@PropertySource("/database.properties")
public class AppContext implements SqlMapConfig{
        //...

}

0개의 댓글