[토비의 스프링] Week12(7.5~7.6.2)

18 분 소요

토비의 스프링 3.1 Chapter 7.5 ~ 7.6.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를 애너테이션을 처리하는 빈 후처리기를 등록하게 된다.

bean 전환하기

bean 태그에 정의된 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 애너테이션이 존재한다. 이를 권장하기도 한다. 마찬가지로 자동 빈 등록대상이 된다.

댓글남기기