Iyoungman Back-end Developer

토비의 스프링 - 스프링 핵심 기술의 응용

2020-07-25

SQL과 DAO의 분리

  • 데이터 액세스 로직은 바뀌지 않더라도 SQL 문장은 바뀔 수 있기 때문에 분리한다.


XML 설정을 이용한 분리

  • SQL을 XML 설정파일로 분리한다.
public class UserDaoJdbc implements UserDao {
    private Map<String, String> sqlMap;//UserDAO DI 할때 Property 값으로 주입

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


SQL 제공 서비스

  • SQL과 DI 설정정보가 섞여 있으면 관리하기 좋지 않다.
  • 이를 위해 DAO가 사용할 SQL을 독립시켜야한다.
public interface SqlService {
    String getSql(String key) throws SqlRetrievalFailureException;
}

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;
    }
}
<bean id="sqlService" class="springbook.user.sqlservice.SimpleSqlService">
    <property name="sqlMap">
        <map>
            <entry key="userAdd" value="insert into users(id, name, password, email, level, login, recommend) values(?,?,?,?,?,?,?)" />
            <entry key="userGet" value="select * from users where id = ?" />
            <entry key="userGetAll" value="select * from users order by id" />
            <entry key="userDeleteAll" value="delete from users" />
            <entry key="userGetCount" value="select count(*) from users" />
            <entry key="userUpdate" value="update users set name = ?, password = ?, email = ?, level = ?, login = ?, recommend = ? where id = ?"  />
        </map>
    </property>
</bean>
  • UserDAO 이외에 모든 DAO는 SQL을 어디에 저장해두고 가져오는지 신경 쓸 필요가 없어졌다.
  • SqlService 빈만 DI 받아서 필요한 SQL을 가져다 쓰면 된다.
  • SqlService의 내부 구현이 변경되어도 DAO에 영향이 없다.


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

JAXB(Java Architecture for XML Binding)

  • JAXB의 장점은 XML 문서정보를 거의 동일한 구조의 오브젝트로 매핑한다.


XML SQL 서비스

  • DAO가 SQL을 요청할 때마다 매번 XML 파일을 읽어서 SQL을 찾는것은 비효율적이다.
  • 따라서 XML 파일은 한번 읽고 해당 내용은 어딘가에 저장해둬야한다.


생성자에서 SQL을 읽어와 내부에 저장

@Service
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파일(sqlmap.xml)과 스프링 빈 설정을 분리했다.
  • [단점 1] 생성자에서 복잡한 초기화 작업을 다루고 있다.
    초기 상태를 가진 오브젝트를 만들어놓고 별도의 초기화 메소드를 사용하는 방법이 바람직하다.
  • [단점 2] 읽어들일 파일의 위치와 이름이 코드에 고정되어있다.


초기화 메소드 이용

@Service
public class XmlSqlService implements SqlService {

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

    private String sqlMapFile;

    public void setSqlMapFile(String sqlMapFile) {
        this.sqlMapFile = sqlMapFile;
    }

    @PostConstruct
    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);
        }
    }

   ...
}
  • @PostConstruct를 사용함으로써 sqlMapFile을 setter로 주입할 수 있다.


인터페이스 분리

  • 현재 SQL을 가져오는 방법에 있어서 특정 기술에 고정되어있다.(XML)
  • 가져온 SQL을 저장할 타입이 고정되어있다.(HashMap)


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

  • SqlReader와 SqlRegistry로 분리.

image

//SqlService 구현 클래스
Map<String, String> sqls = sqlReader.readSql();
sqlRegistry.addSqls(sqls);
  • readSql의 내부 데이터 타입이 외부로 노출되며 강제된다.


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

    String findSql(String key) throws SqlNotFoundException;
}

public interface SqlReader {
    //SqlReader가 직접 SqlRegistry에 SQl 정보를 등록
    void read(SqlRegistry sqlRegistry);
}

image


자기참조 빈으로 시작하기

image

  • SqlService 클래스는 SqlReader와 SqlRegistry를 DI 받아야한다.


image

  • SqlService의 구현 클래스가 SqlReader, SqlService, SqlRegistry 세개의 인터페이스를 구현하게 할 수도있다.
public class XmlSqlService implements SqlService, SqlRegistry, SqlReader {
    // --------- SqlProvider ------------
    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);
    }

    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>();

    public String findSql(String key) throws SqlNotFoundException {
        String sql = sqlMap.get(key);
        if (sql == null)
            throw new SqlRetrievalFailureException(key + "를 이용해서 SQL을 찾을 수 없습니다");
        else
            return sql;

    }

    public void registerSql(String key, String sql) {
        sqlMap.put(key, sql);
    }

    // --------- SqlReader ------------
    private String sqlmapFile;

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

    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);
        }
    }
}
  • SqlReader와 SqlRegistry는 서로의 구현 코드에 직접 접근하지 않고 인터페이스를 통해 접근해야한다.


자기참조 빈 설정

<bean id="sqlService" class="springbook.user.sqlservice.XmlSqlService">
	<property name="sqlReader" ref="sqlService" />
	<property name="sqlRegistry" ref="sqlService" />
	<property name="sqlmapFile" ref="sqlmap.xml" />
</bean>
  • 일반적으로 다른 오브젝트를 주입한다.
  • 하지만 여기서는 자기참조 빈을 만들었다.


디폴트 의존관계

  • 디폴트 의존관계는 외부에서 DI 받지 않는 경우 자동 적용되는 의존관계를 말한다.
//BaseSqlService
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);
    }

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

//HashMapSqlRegistry
public class HashMapSqlRegistry implements SqlRegistry {
    private Map<String, String> sqlMap = new HashMap<String, String>();

    public String findSql(String key) throws SqlNotFoundException {
        String sql = sqlMap.get(key);
        if (sql == null)  throw new SqlRetrievalFailureException(key + "를 이용해서 SQL을 찾을 수 없습니다");
        else return sql;
    }

    public void registerSql(String key, String sql) {
        sqlMap.put(key, sql);
    }
}

//JaxbXmlSqlReader
public class JaxbXmlSqlReader implements SqlReader {
    private String sqlmapFile;

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

    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); }
    }
}
public class DefaultSqlService extends BaseSqlService {
    public DefaultSqlService() {
        setSqlReader(new JaxbXmlSqlReader());
        setSqlRegistry(new HashMapSqlRegistry());
    }
}


서비스 추상화 적용

OXM(Object-XML Mapping)

  • XML/자바오브젝트 바인딩 기술.

  • 다양한 구현체가 있다.
  • JAXB
  • Castor XML
  • JiBX
  • XmlBeans
  • Xstream


OXM 서비스 추상화 적용

public class OxmSqlService implements SqlService {
    private final OxmSqlReader oxmSqlReader = new OxmSqlReader();//[1]
    private SqlRegistry sqlRegistry = new HashMapSqlRegistry();

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

    public void setUnmarshaller(Unmarshaller unmarshaller) {//[2]
        this.oxmSqlReader.setUnmarshaller(unmarshaller);
    }

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

    private class OxmSqlReader implements SqlReader {
        private Unmarshaller unmarshaller;
        private final static String DEFAULT_SQLMAP_FILE = "sqlmap.xml";
        private String sqlmapFile = DEFAULT_SQLMAP_FILE;

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

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

        public void read(SqlRegistry sqlRegistry) {
            try {
                Source source = new StreamSource(UserDao.class.getResourceAsStream(this.sqlmapFile));
                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.sqlmapFile + "을 가져올 수 없습니다", e);
            }
        }
    }
}
<bean id="unmarshaller" class="org.springframework.oxm.castor.CastorMarshaller">
    <property name="mappingLocation" value="springbook/learningtest/spring/oxm/mapping.xml" />
</bean>
  • SQL을 읽는 방식을 OXM으로 고정하기 위해 외부에서 DI받지 않고 내부에서 객체를 직접 만들었다.
  • OXM의 구현체를 바꾸고 싶으면 Unmarshaller을 외부에서 DI 받는다.


리소스 추상화

  • Resource : 스프링은 리소스 접근 API를 추상화해서 Resource라는 인터페이스를 정의.
  • ResourceLoader : 위치를 지정하면 실제 Resource 타입의 객체로 변환해주는 기능 제공.
  • 예를 들어 ApplicationContext 역시 ResourceLoader를 상속하고 있다.
    ApplicationContext가 사용할 스프링 설정정보를 가져와야하기 때문이다.


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

인터페이스 상속

image

  • 위를 현재 구조라고 하자.
  • 여기에 이미 등록된 SQL을 변경할 수 있는 새로운 기능을 넣어서 확장하고 싶다.
    이미 SqlRegistry 인터페이스를 접근하는 클라이언트와 그 서브 클래스가 존재한다.
    따라서 SqlRegistry 자체를 수정하는 것은 바람직하지 않다.


  • 이때 인터페이스 상속을 이용한다.
public interface UpdatableSqlRegistry extends SqlRegistry {
    void updateSql(String key, String value) throws SqlUpdateFailureException;

    void updateSql(Map<String, String> sqls) throws SqlUpdateFailureException;
}
  • BaseSqlService와 SqlAdminService는 동일한 오브젝트에 의존하고 있지만
    각자의 관심과 필요에 따라서 다른 인터페이스를 통해 접근한다.


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

ConcurrentHashMap을 이용한 수정 가능 SQL 레지스트리

  • 멀티 스레드에 안전한 SQL 수정이 필요하다.
  • Collections.synchronizedMap() 등을 이용할 수 있지만 HashMap에 대한 전 작업을 동기화하면 성능에 문제가 생길 수 있다.
  • ConcurrentHashMap을 사용하면 데이터 조작시 전체 데이터에 대해 락을 걸지 않고 조회시에는 락을 아예 사용하지 않는다.
public class ConcurrentHashMapSqlRegistry implements UpdatableSqlRegistry {

    private ConcurrentHashMap<String, String> sqlMap = new ConcurrentHashMap<>();

    @Override
    public String findSql(String key) throws SqlNotFoundException {
        String value = sqlMap.get(key);
        if (value == null) {
            throw new SqlNotFoundException();
        }
        return value;
    }

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

    @Override
    public void updateSql(String key, String value) throws SqlUpdateFailureException {
        if(sqlMap.get(key) == null) {
            throw new SqlUpdateFailureException();
        }

        sqlMap.put(key, value);
    }

    @Override
    public void updateSql(Map<String, String> sqlMap) throws SqlUpdateFailureException {
        sqlMap.forEach(this::updateSql);
    }
}


내장형 데이터베이스를 이용한 SQL 레지스트리 만들기

  • ConcurrentHashMap 대신 내장형 DB를 이용해본다.
  • 스프링은 내장형 DB를 위한 서비스 추상화를 제공하고 있다.
  • 스프링이 제공하는 내장형 DB 빌더는 EmbeddedDatabaseBuilder다.
new EmbeddedDatabaseBuilder()
    .setType(내장형 DB종류)
    .setScript(초기화에 사용할 DB 스크립트의 리소스)
    ...
    .build();


  • EmbeddedDbSqlRegistry
public class EmbeddedDbSqlRegistry implements UpdatableSqlRegistry {
    SimpleJdbcTemplate jdbc;

    public void setDataSource(DataSource dataSource) {
        jdbc = new SimpleJdbcTemplate(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);
        }
    }

    ...
}


트랜잭션 적용

  • 이전 HashMap의 경우 트랜잭션을 적용하기 힘들다.
  • 반면 내장형 DB를 사용하는 경우 트랜잭션 적용이 쉽다.


스프링 3.1의 DI

  • 자바 언어의 변화에 따라 DI 프레임워크로서 스프링의 사용 방식에도 여러 가지 영향을 줬다.
  • 대표적으로 두 가지가 있다.


[1] 애노테이션의 메타정보 활용

  • 자바 코드가 실행의 목적보다 다른 자바 코드에 의해 데이터처럼 취급되기도 한다.
  • 코드를 리플렉션 API 등을 이용해 살펴보고 그에 따라 동작하는 기능이 많이 사용되고 있다.

  • 대표적으로 애노테이션이 있다.
  • 리플렉션 API를 이용해 애노테이션의 메타정보를 조회하고, 애노테이션 내에 설정된 값을 가져와 참고한다.
  • 애노테이션을 이용하면 코드의 양을 간략화 할 수 있다.


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

  • 코드를 이용해 명시적으로 동작 내용을 기술하는 대신
    미리 약속한 규칙에 따라 프로그램이 동작하도록 만든다.
  • ex1) XML에 bean 태그를 정의해두면 하나의 오브젝트가 만들어진다.
  • ex2) @Transactional의 우선순위.


@Import

  • @Configuration을 이용한 설정 파일이 여러개 있다.
  • 설정 파일을 추가할때마다 @ContextConfiguration의 내용을 수정해야할까?
  • @Import 애노테이션을 이용한다.
@Configuration
@Import(SqlServiceContext.class)
public class AppContext {
}


@Enable*

  • SqlServiceContext처럼 모듈화된 빈 설정을 가져올 때 @Import를 사용한다.
  • 이를 대체하는 용도로 @Enable* 이 사용된다.
  • @Import를 메타 애노테이션으로 넣어 만든다.
@Import(value=SqlServiceContext)
public @interface EnableSqlService {
}


  • [장점 1] 의미가 더 잘 드러난다.
  • [장점 2] 애노테이션에 엘리먼트를 넣어서 옵션을 지정할 수 잇다.
@EnableSqlService("classpath:/springbook/user/sqlmap.xml")


Reference


Comments

Content