토비의 스프링 Chapter 7.3.3 ~ 7.5.2 정리

종명·2021년 5월 15일
0

리소스 추상화

같은 클래스패스 외의 클래스패스 루트 등에 있는 XML 또는 상대적인 클래스 패스가 아니라 서버나 개발 시스템의 특정 폴더에 있는 파일을 읽을 수 없을까? 더 나아가서 웹 상의 리소스 파일을 가져올 수 없을까?

  • 동일한 목적에 사용법이 다른 여러 가지 기술이 존재한다.

리소스

  • 스프링은 자바에 존재하는 일관성 없는 리소스 접근 API를 추상화해서 Resource 인터페이스라는 추상화 인터페이스를 정의했다.
  • 서비스 추상화 오브젝트와 달리 빈으로 등록해서 쓰지는 않고, 값으로 취급한다. 리소스는 OXM이나 트랜잭션처럼 서비스를 제공해주는 것이 아니라 단순한 정보를 가진 값으로 지정된다.
  • 그래서 추상화를 적용하는 방법이 문제다. Resource는 빈으로 등록하지 않는다고 했으니 외부에서 지정한다고 해봐야 <property>의 value 애트리뷰트에 넣는 방법 밖에 없다. 하지만 value에 넣을 수 있는 건 단순한 문자열뿐이다.

리소스 로더

  • 문자열로 정의된 리소스를 실제 Resource 타입으로 변환해주는 ResourceLoader를 제공한다.
public interface ResourceLoader{
    // location에 담긴 스트링 정보를 바탕으로 그에 적절한 Resource로 반환해준다.
    Resource getResource(Stirng location);
}
  • ResourceLoader의 대표적인 예는 바로 스프링의 애플리케이션 컨텍스트다. ApplicationContext는 ResourceLoader를 상속하고 있다.
  • 스프링 설정 정보가 담긴 xml로 리소스 로더를 이용해 Resource 형태로 읽어온다.

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

private class OxmSqlReader implements SqlReader {
        private Resource sqlmap = new ClassPathResource("sqlmap.xml", UserDao.class);
        
        public void setSqlmap(Resource sqlmap) {
            this.sqlmap = sqlmap;
        }
        public void read(SqlRegistry sqlRegistry) {
            try {
                // 리소스 종류에 상관없이 스트림으로 가져올 수 있다.
                Source source = new StreamSource(sqlmap.getInputStream());
            } catch ...
        }
}
  • Resource를 사용할 떄 Resouce 오브젝트가 실제 리소스가 아니라는 점을 주의해야 한다. Resource는 단지 리소스에 접근할 수 있는 추상화 핸들러일 뿐이다. 따라서 Resource타입의 오브젝트가 만들어졌다고 해도 실제로 리소스가 존재하지 않을 수 있다.
  • 문자열로 지정할 때는 리소스 로더가 인식할 수 있는 문자열로 표현해주면 된다. 예를들면 classpath: 접두어를 사용해 클래스패스의 리소르를 표현할 수 있다.
<bean id="sqlService" class="springbook.user.sqlservice.OxmSqlService">
    <property name="sqlMap" value="classpath:springbook/user/dao/sqlmap.xml" />
    ...
</bean>

특정 위치에 있는 파일, Http프로토콜에 접근가능한 웹 리소스도 접두어를 바꿔서 가져올 수 있다. (file:, http:)

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

원칙적으로 권장되진 않지만, 서버 운영중에 SQL을 변경해야할 수도 있다. 애플리케이션을 재시작하지 않고 긴급하게 애플리케이션이 사용중인 SQL을 변경해야 할 수 도 있다.

DI와 기능의 확장

  • DI의 가치를 제대로 얻으려면 먼저 DI에 적합한 오브젝트 설계가 필요하다.
  • DI는 런타임 시에 의존 오브젝트를 다이내믹하게 연결해줘서 유연한 확장을 꾀하는게 목적이기 때문에 항상 확장을 염두에 두고 오브젝트 사이의 관계를 생각해야 한다.

DI와 인테페이스 프로그래밍

  • DI를 DI답게 만들려면 두 개의 오브젝트가 인터페이스를 통해 느슨하게 연결돼야 한다.
    1. 첫 번째 이유는 다형성을 얻기 위해서다. 하나의 인터페이스를 통해 여러 개의 구현을 바꿔가며 사용할 수 있게 하는 것이 첫번째 목적이다.
    2. 두 번째 이유는 인터페이스 분리 원칙을 통해 클라이언트와 의존 오브젝트 사이의 관계를 명확하게 해줄 수 있기 때문이다.

인터페이스 상속

때로는 인터페이스를 여러 개 만드는 대신 기존 인터페이스를 상속을 통해 확장하는 방법도 사용된다.

public interface SqlRegistry {
    void registerSql(String key, String sql);
    Stirng findSql(String key) throws SqlNotFoundException;       
}

여기에 이미 등록된 SQL을 변경할 수 있는 기는을 넣어서 확장하고 싶다고 생각해보자.

  • 이미 SqlRegistry의 클라이언트가 있기 때문에, SqlRegistry를 수정하는건 바람직한 방법이 아니다. BaseSqlService 오브젝트는 SqlRegistry 인터페이스가 제공하는 기능이면 충분하기 때문이다.
  • 새롭게 추가할 기능을 사용하는 클라이언트를 위해 새로운 인터페이스를 정의하거나 기존 인터페이스를 확장하는 바람직하다.
public interface UpdatableSqlRegistry extends SqlRegistry {
    void updateSql(String key, String sql) throws SqlUpdateFailureException;
    void updateSql(Map<String, String> sqlmap) throws SqlUpdateFailureException;
}
  • 이렇게 SQL 업데이트 기능을 가진 새로운 인터페이스를 만들었으니 BaseSqlService도 새로만든 UpdatableSqlRegistry 인터페이스를 이용해야할까? 그렇지 않다.
  • BasqSqlService는 초기화를 통한 Sql등록과 조회만을 목적으로 SQL 레지스트리를 사용할 것이므로, 기존의 SqlRegistry 인터페이스를 통해 접근하면 충분하다.
  • 반면에 SQL 업데이트 작업이 필요한 새로운 클라이언트 오브젝트는 UpdatableSqlRegistry 인터페이스를 통해 Sql 레지스트리에 접근하도록 만들어야한다.
  • SQL 변경에 대한 요청을 담당하는 SLQ 관리용 오브젝트가 있다고 하고, 클래스 이름을 SqlAdminService라고 하자. 그렇다면 SqlAdminService는 UpdatableSqlRegistry라는 인터페이스를 통해 SQL 레지스트리에 접근해야 한다.

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

운영 중인 시스템에서 사용하는 정보를 실시간으로 변경하는 작업을 만들 떄, 가장 먼저 고려해야할 것은 동시성 문제이다.

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

  • 멀티스레드 환경에서 안전하게 HashMap을 조작하려면 Collections.synchronizedMap()등을 이용해 외부에서 동기화해줘야한다. 하지만 이렇게 HashMap의 전 작업을 동기화하려면 고성능 서비스에서는 성능에 문제가 생긴다.
  • 일반적으로 ConcurrentHashMap 사용이 권장된다. ConcurrentHashMap은 데이터 조작 시 전체 데이터에 대해 락을 걸지 않고 조회는 락을 아예 사용하지않는다.
    그래서 어느 정도 안전하면서 성능이 보장되는 동기화된 HashMap으로 이용하기에 적당하다.

수정 가능한 SQL 레지스트리 테스트

public class ConcurrentHashMapSqlRegistryTest {
	UpdatableSqlRegistry sqlRegistry;
	
	@Before
	public void setUp() {
		sqlRegistry = new ConcurrentHashMapSqlRegistry();
		// 테스트 메소드에서 사용할 초기화 SQL 정보를 미리 등록한다.
		sqlRegistry.registerSql("KEY1", "SQL1");
		sqlRegistry.registerSql("KEY2", "SQL2");
		sqlRegistry.registerSql("KEY3", "SQL3");
	}

	@Test
	public void find() {
		checkFindResult("SQL1", "SQL2", "SQL3");
	}

	private void checkFindResult(String expected1, String expected2, String expected3) {
		assertThat(sqlRegistry.findSql("KEY1"), is(expected1));
		assertThat(sqlRegistry.findSql("KEY2"), is(expected2));
		assertThat(sqlRegistry.findSql("KEY3"), is(expected3));
	}
	
	// 주어진 key에 해당하는 sql을 찾을 수 없을때 예외가 발생하는 지 확인하다. 예외상황에 대한 테스트는 빼먹기 쉽기에 항상 의식적으로 넣으려고 노력해야 한다.
	@Test(expected=SqlNotFoundException.class)
	public void unknownKey() {
		sqlRegistry.findSql("SQL9999!@#$");
	}
	
	// 하나의 sql 업데이트 테스트, 검증할 때는 나머지 sql은 그대로인지도 확인해주는 것이 좋다.
	@Test
	public void updateSingle() {
		sqlRegistry.updateSql("KEY2", "Modified2");
		checkFindResult("SQL1", "Modified2", "SQL3");
	}
	
	// 여러개의 sql 수정 테스트
	@Test
	public void updateMulti() {
		Map<String, String> sqlmap = new HashMap<String, String>();
		sqlmap.put("KEY1", "Modified1");
		sqlmap.put("KEY3", "Modified3");
		
		sqlRegistry.updateSql(sqlmap);
		checkFindResult("Modified1", "SQL2", "Modified3");
	}
	
	// 존재하지 않는 key의 sql을 변경하려고 시도할 때 예외가 발생하는 지 검증.
	@Test(expected=SqlUpdateFailureException.class)
	public void updateWithNotExistingKey() {
		sqlRegistry.updateSql("SQL9999!@#$", "Modified2");
	}

}

수정 가능한 SQL 레지스트리 구현

public class ConcurrentHashMapSqlRegistry implements UpdatableSqlRegistry {
	private Map<String, String> sqlMap = new ConcurrentHashMap<String, String>();

	@Override
	public String findSql(String key) {
		String sql = sqlMap.get(key);
		if (sql == null) {
			throw new SqlNotFoundException(key + "에 해당하는 SQL을 찾을 수 없습니다");
		} else {
			return sql;
		}
	}

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

	@Override
	public void updateSql(String key, String sql) {
		if(sqlMap.containsKey(key)) {
			sqlMap.put(key, sql);
		} else {
			throw new SqlUpdateFailureException(key + "에 해당하는 SQL을 찾을 수 없습니다");
		}
	}

	@Override
	public void updateSql(Map<String, String> sqlmap) {
		sqlMap.putAll(sqlmap);
	}
}

내장형 DB를 사용해서 구현하기

  • 저장되는 데이터 양이 많아지고 잦은 조회와 변경이 일어나는 환경이라면 db를 쓰자.
  • 하지만 SQL 저장해두고 관리할 목적이라면 별도의 DB를 구성하면 배보다 배꼽이 더 큰 일이 될 수도 있다. 그래서 이런 경우에는 DB의 장점과 특징을 그대로 갖고 있으면서 애플리케이션 외부에 별도로 설치하고 셋업하는 번거로움이 없는 내장형 DB를 사용하는 것이 적당하다.
  • 내장형 DB는 애플리케이션에 내장되어서 애플리케이션과 함께 시작되고 종료되는 DB를 말한다. 데이터는 메모리에 저장되기 때문에 IO로 인한 부하가 적어 성능이 뛰어나다.

스프링의 내장형 DB 지원

  • 스프링은 내장형 DB를 초기화 작업을 지원하는 편리한 내장형DB 빌더를 제공한다.
new EmbeddedDatabaseBuilder() // 빌더 오브젝트 생성
    .setType(내장형DB종류) // H2, HSQL, DERBY 중에서 하나를 선택한다.
    .addScript(초기화 db script리소스) // 초기화를 위한 SQL문장을 담은 스크립트 위치를 지정한다. 하나 이상을 지정할 수 있다.
    ...
    .build(); // 주어진 조건에 맞는 내장형 DB를 준비하고 초기화 스크립트를 모두 실행한 뒤에 이에 접근할 수 있는 EmbeddedDatabase를 돌려준다.

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

<jdbc:embedded-database id="embeddedDatabase" type="HSQL">
    <jdbc:script location="classpath:schema.sql"/>
</jdbc:embedded-database>
public class EmbeddedDbSqlRegistry implements UpdatableSqlRegistry{
    SimpleJdbcTemplate jdbc;
    public void setDataSource(DataSource dataSource) {
        this.jdbc = new SimpleJdbcTemplate(dataSource); // 내장형 DB의 Datasource를 DI 받는다.
    }
    ...
    @Override
    public void updateSql(String key, String sql) throws SqlUpdateFailureException {
        // update()는 SQL 실행 결과로 영향을 받은 레코드의 개수를 리턴한다. 이를 이용하면 주어진 키(key)를 가진 SQL이 존재했는지를 간단히 확인할 수 있다.
        int affected = jdbc.update("update sqlmap set sql_ = ? where key_ = ?", sql, key);
        if(affected == 0) {
            throw new SqlUpdateFailureException(key + "에 해당하는 SQL을 찾을 수 없습니다.");
        }
    }
}

0개의 댓글