728x90

결국 스프링이 원하는 방향에 대해 생각해 보자.

지금까지 느낀 스프링은 OOP를 제대로 적용하기 위해, IOC/DI 기술을 활용한 프레임워크라고 느껴진다.

 

스프링의 정의는 이렇다고 한다.

"자바 엔터프라이즈 개발을 편하게 해주는 오픈소스 경량급 애플리케이션 프레임워크"

 

이거에 대해서 생각해 보면

 

  • 애플리케이션 프레임워크

스프링은 어느 분야에만 사용되는 프레임워크가 아니다.

MVC, Log, AOP, 인증, ORM 등등 웹 개발에 필요한 대부분의 기술을 가지고 있다.

스프링의 일차적인 존재 이유는 웹 기술에 담긴 프로그래밍 모델을 일관되게 적용해 애플리케이션 개발을 편리하게 만들어주는 것이다.

 

  • 경량급

솔직히 스프링이 가볍다고 생각해 본 적은 없다.

nest... 와 같은 프레임워크가 훨씬 가볍다고 생각했었다.

그럼에도 스프링이 가볍다고 말하는 이유는 불필요한 부분이 없기 때문이라고 한다.

 

스프링이 처음 등장하던 시기에는 EJB가 개발환경과 운용서버, 개발과 빌드, 테스트 과정 등등... 코드들을 엄청 무겁게 만들었다고 한다.

그렇기에 서버를 동작하기 위해서는 고성능의 무거운 자바 서버가 필요했다고 한다.

그렇기에 스프링은 가장 단순한 톰캣에서도 동작할 수 있도록 등장했으며, 이것만으로도 충분하게 애플리케이션을 개발할 수 있었다고 한다.

 

  • 자바 엔터프라이즈 개발을 편하게

스프링이 배우기 쉬웠던 기술이라고는 생각되지 않는다.

하지만 여기서 말하는 편하다라는 뜻은 그런 의미는 아니고, 개발의 비즈니스 로직에 집중하다록 만들어주었다는 것이다.

 

기존에 개발자가 로우레벨의 트랜잭션, 상태관리, 멀티쓰레딩과 같은 부분까지 신경을 쓰며 개발을 해야했다면 스프링은 그런 문제를 해결해주었다는 것이다.

물론 여기서 이런 기술을 아예 무시하고 개발한다는 것이 아니다.

이런 요구들을 스프링 엔터프라이즈 시스템에서 최대한 해결해준다는 것이다.

 

  • 오픈소스

스프링은 처음부터 지금까지 오픈소스로 개발되고 있다.

그렇기에 공개된 커뮤티니의 공간 안에서 다양한 사람이 투명하게 참여하여 개발하기에 빠르고 유연하게 성장이 가능하다.

사용자의 피드백도 빠르게 반영되고 있다.

 

라이센스에 대한 비용도 없기 때문에, 부담없이 큰 기업에서도 사용해 개발이 가능하다.

 

728x90

스프링의 현재와 미래 같은 챕터이다.

스프링의 핵심인 객체지향은 그대로지만, 이 객체지향을 지키기 위한 기술들이 많이 바뀌었다고 한다.

 

  • 어노테이션의 메타정보 활용

자바는 소스코드를 컴파일 후 클래스 파일로 만들었다가, JVM에 의해 메모리에서 로딩되어 실행된다.

이런 자바코드를 리플렉션등과 같은 방법으로 클래스내의 인터페이스, 필드 등의 메타정보를 확인 할 수 있다.

 

자바5에 어노테이션이 등장했다.

자바의 클래스나 인터페이스, 필드와 메서드들은 그 자체로 직접 이용이 가능하지만 어노테이션은 그런 방식으로는 이용이 불가능하다.

리플렉션을 이용해 메타정보를 조회하고, 어노테이션에 설정된 값을 가져와서 참고하는 방법이 전부라고 한다.

그럼에도 이렇게 직접 영향을 주지 못하는 어노테이션은 빠르게 발전했다고 한다.

어노테이션이 IOC 방식의 프레임워크, 프레임워크가 참조하는 메타정보라는 이 구성방식에 잘 어울리기 때문이라고 한다.

 

기존에 작성하던 이 긴 xml을 어노테이션으로 교체가 가능하다.

@Seungkyu
public class SK{}

이렇게만 작성해도 클래스의 정보와 해당 클래스가 어떤 인터페이스를 구현하고 있는지 알아 올 수 있기 때문이다.

 

이런 방식을 사용하여 XML을 최대한 작성하지 않도록 만들 수 있었고, 스프링 3.1에서는 대부분의 기능을 이 어노테이션으로 대체가 가능하다고 한다.

 

기존에 작성했던 xml을 어노테이션을 사용하는 방법으로 리펙토링 해보도록 하자.

 

자바 코드를 이용한 빈 설정

우선 test-applicationContext.xml을 사용하는 테스트를 타깃으로 리펙토링 해보자.

테스트가 작성되어 있기에, 바로 확인이 가능하기 때문이다.

해당 xml은 @ContextConfiguration(locations="/test-applicationContext.xml")을 붙인 클래스들이 사용 중이다.

 

이거를 그대로 가져올 클래스를 먼저 만든다.

@Configuration
public class TestApplicationContext {
}

이제 이게 xml이다.

 

@ExtendWith(SpringExtension.class)
@ContextConfiguration(classes = TestApplicationContext.class)
class UserDaoTest {

이제 테스트 클래스의 configuration을 위치가 아닌, 해당 클래스로 변경해준다.

 

일단 이거도 단계적으로 나아가기 위해 처음에는 해당 xml을 불러오는 것으로 시작한다.

@Configuration
@ImportResource("/test-applicationContext.xml")
public class TestApplicationContext {
}

 

이렇게하고 테스트를 실행하면 당연히 성공한다.

하나씩 고치면서 계속 테스트를 실행해보자.

 

우선 step1은 context:annotation-config라고 한다.

사실 이거

@PostConstruct를 위해서 작성했던 것인데, @Configuration을 사용하면 자동으로 등록해주기 때문에 바로 삭제해도 문제 없다고 한다.

 

step2는 가장 많을 bean이다.

현재 이 DataSource를 SimpleDriverDataSource 구현체로 주입받고 있다.

이거를 클라이언트가 SimpleDriverDataSource로 주입받는 것이 아니라, DataSource 인터페이스로 받도록 해야한다.

 

그렇기에 이런식으로 Bean을 생성해야 한다.

    @Bean
    public DataSource dataSource() {
        return null;
    }

 

이제 내용과 리턴 타입을 채워보자.

 

xml의 내용을 setter를 사용해 그대로 넣으면 된다.

    @Bean
    public DataSource dataSource() {

        SimpleDriverDataSource dataSource = new SimpleDriverDataSource();
        dataSource.setDriverClass(Driver.class);
        dataSource.setUrl("jdbc:mysql://localhost:3306/test");
        dataSource.setUsername("root");
        dataSource.setPassword("1204");
        
        return dataSource;
    }

 

이렇게 작성하게 테스트를 돌려보면

비록 DataSource를 찾지 못해 intellij의 xml에서는

이렇게 에러가 발생한다고 나오지만, 실제 실행하면 @Bean의 정보를 불러오기에 테스트는 정상적으로 동작한다.

 

 

마찬가지로 transactionManager도 아래와 같이 옮긴다.

    @Bean
    public PlatformTransactionManager transactionManager() {
        DataSourceTransactionManager transactionManager = new DataSourceTransactionManager();
        transactionManager.setDataSource(dataSource());
        return transactionManager;
    }

 

만약 다른 빈의 주입이 필요하면 이렇게 빈의 메서드를 직접 호출해서 넣어주면 된다.

 

여기서 현재 sqlService()를 만들지 않아서 에러가 발생 중이다.

그럴 때는 다른 곳에 있는 SqlService 빈을 불러와야 하기에, Configuration 자체에 주입 받으면 된다.

 

이렇게 주입받아서 사용하고, 테스트를 수행해보니 정상적으로 수행되는 것을 볼 수 있었다.

 

    <jdbc:embedded-database id="embeddedDatabase" type="H2">
        <jdbc:script location="classpath:schema.sql"/>
    </jdbc:embedded-database>

 

이것도 옮겨야 하는데 이 친구는 <bean>이 아니다.

하지만 저번에 EmbeddedDatabaseBuilder를 사용했던 것을 떠올리며, 결국 해당 타입으로 주입을 하게 되는 것이기에 그렇게 만들어서 넣어준다.

 

    @Bean
    public DataSource embeddedDataSource() {
        return new EmbeddedDatabaseBuilder()
                .setName("embeddedDatabase")
                .setType(EmbeddedDatabaseType.H2)
                .addScript("/schema.sql").build();
    }

 

메서드의 이름은 우리가 등록했던 bean의 id로 해준다.

 

마지막으로 트랜잭션 어노테이션을 위해 해당 어노테이션까지 TestApplicationContext에 붙여준다.

@EnableTransactionManagement

 

이렇게해서 모든 bean을 옮겼다.

초기 테스트를 위해서 붙였던 @ImportResource를 지금은 제거가 가능하다.

 

그렇게 수정하고 테스트를 돌려보니 성공하는 것을 볼 수 있었다.

 

빈 스캐닝과 자동와이어링

이렇게 만든 빈들을 생성자나 setter를 통해 주입해야한다.

 

스프링은 @Autowired가 붙은 수정자 메서드가 있으면 타입을 보고 주입 할 수 있는 빈을 주입한다.

만약 타입으로 주입 가능한 빈이 2개 이상이면, 그 중에서 해당 프로퍼티와 동일한 이름의 빈으로 주입한다.

 

그렇기에 이렇게 동일한 타입이 2개라도 각각의 이름을 통해서 빈을 주입 가능한 것이다.

@Bean
public DataSource dataSource() {}

@Bean
public DataSource embeddedDataSource() {}

 

@Autowired
public UserDaoImpl(
            DataSource dataSource,
            SqlService sqlService) {
        this.jdbcTemplate = new JdbcTemplate(dataSource);
        this.sqlService = sqlService;
    }

이렇게 DataSource로 받지만, 실제 사용하는건 JdbcTemplate과 같은 경우 JdbcTemplate을 주입받을 수는 없으니 반드시 setter나 생성자를 통해 주입 받아야 한다.

 

@Component를 통해서도 빈 등록이 가능하다.

이 @Component는 클래스에 사용하는 어노테이션이고, 이 어노테이션이 부여된 클래스는 자동으로 빈으로 등록된다.

 

현재 작성한 UserDaoImpl을 보면

@Component
public class UserDaoImpl implements UserDao { 
	//
}

 

이렇게 해당 클래스에 @Component를 붙이는 것 만으로도

    @Bean
    public UserDao userDao() {
        return new UserDaoImpl(dataSource(), sqlService());
    }

이거를 대체할 수 있다는 것이다.

 

대신에 @ComponentScan을 통해 @Component들을 검색할 범위를 지정해야 한다.

@Configuration
@EnableTransactionManagement
@ComponentScan(basePackages = "seungkyu")
public class TestApplicationContext { }

이렇게 범위를 지정해서 그 패키지들의 하위 클래스들을 검색하도록 해야한다.

 

@Component에서 클래스는 해당 클래스로 등록하고, 빈의 아이디는 특별하게 지정하지 않으면 클래스의 이름을 첫글자 소문자로 바꿔서 사용한다.

 

현재 UserDaoImpl이기에 빈의 아이디는 userDaoImpl로 지정이 되겠지만, UserDao의 구현체이기에 해당 UserDao의 타입으로도 주입이 되게 된다.

 

@Target({ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
@Component
public @interface SeungkyuComponent {
}

이런 식으로 @Component 어노테이션을 가지고 있는 어노테이션은 빈으로 등록이 된다.

 

그런 빈 중에서도 @Service, @Repository와 같은 특별한 역할을 하는 빈들이 있기에 맞게 달아주도록 하자.

여기는 현재 데이터베이스에 접근하는 코드이니 @Repository를 붙여주도록 하겠다.

 

빈을 등록하다보면 다음과 같은 상황이 생길 수 있다.

타입으로 주입을 시도하려고 했지만, 같은 타입이 2개 있고 이름으로도 주입할 수 없는 상황이다.

기존에는 UserServiceImpl을 UserService로 id를 지정해서 빈을 등록했지만, 지금은 클래스의 이름으로 등록이 되었기에 저 중 어떤 id도 userService와 맞지 않아 어떤 빈을 주입할지 에러가 생긴 것이다.

 

이 문제를 해결해주기 위해서는 다시 UserServiceImpl을 UserService로 등록해주면 된다.

@Component("userService")
@RequiredArgsConstructor
public class UserServiceImpl implements UserService{}

 

이 방법으로 빈을 등록해주도록 하자.

 

그러면 나머지 이 testUserServiceImpl은 어떻게 사용하게 될까?

 

컨텍스트 분리와 @Import

이렇게 DI를 사용해왔던 이유는 언젠가 사용할 테스트 때문이다.

그리고 지금 이 testUserServiceImpl도 테스트 때에만 사용하기에 어떻게 처리를 할지 고민하는 것이다.

다른 애플리케이션 빈에서는 분리하는 것이 더 좋아보인다.

 

일단 테스트에서 사용할 빈들을 따로 빼두는 Configuration을 만들어보자.

@Configuration
public class TestAppContext {

    @Autowired UserDao userDao;

    @Bean
    public UserService testUserService(){
        return new TestUserServiceImpl(userDao, mailSender());
    }

    @Bean
    public MailSender mailSender() {
        return new MailSenderTest();
    }
}

 

이제 테스트 환경에서는 이거를 주입하면 될 거 같다.

그렇다고 이거를 ComponentScan의 범위로 넣을 수는 없을 것이기에, 해당 테스트 환경에서만 주입해주도록 해야한다.

 

@ExtendWith(SpringExtension.class)
@ContextConfiguration(classes = {TestApplicationContext.class, TestAppContext.class})
class UserDaoTest {}

 

마지막으로 데이터베이스 연결과 같은 부분도 분리하도록 하겠다.

결국 Configuration에도 같은 관심사끼리 모아두는 것이 유지보수에는 유리할 것이다.

@Configuration
public class SqlServiceContext {

    @Bean
    public SqlRegistry sqlRegistry() {
        return new ConcurrentHashMapSqlRegistry();
    }

    @Bean
    public SqlReader sqlReader() {
        return new YmlSqlReader("/sqlmap.yml");
    }

    @Bean
    public SqlService sqlService() {
        return new YmlSqlService(sqlReader(), sqlRegistry());
    }

    @Bean
    public DataSource dataSource() {
        return new EmbeddedDatabaseBuilder()
                .setName("embeddedDatabase")
                .setType(EmbeddedDatabaseType.H2)
                .addScript("/schema.sql")
                .build();
    }
}

 

이렇게 일단 지금 가지고 있는건 SqlServiceContext, TestAppContext, TestApplicationContext 이렇게 3개이다.

일단 혼동되지 않게 TestApplicationContext를 AppContext로 바꿔주자.

 

이거를 모두 배열로 @ContextConfiguration에 넣어줘야 할까?

하지만 여기서 의존관계를 살펴보면 SqlServiceContext를 AppContext가 필요로하고, 솔직히 테스트 클래스나 TestAppContext는 관심이 없다.

그렇기에 SqlServiceContext를 AppContext에만 직접 넣어주도록 만들어주는 것이 좋을 것 같다.

 

@Import(SqlServiceContext.class)
public class AppContext {}

이 방법으로 직접 넣어주고 다른 부분은 수정하지 않도록 만들자.

 

프로파일

현재 방법은 다음과 같은 문제가 있다.

현재 운영, 테스트가 공통으로 사용하는 AppContext와 TestAppContext 이렇게 2개가 있고 여기 모두에서 UserService 타입의 빈이 정의되어있다.

하나는 MailSenderImpl 타입이고 다른 하나는 MailSenderTest이다.

 

그럼 MailSender를 주입 받을 때, 어떤 타입을 주입받게 될까.

지금도 이렇게 충돌이 일어나고 있고, 우리는 테스트 환경에서는 testUserService를 데리고 오고 싶은 것이다.

이렇게 테스트 환경과 운영환경에서 각각 다른 빈을 가져와야 하는 경우가 많다.

 

스프링은 이렇게 환경에 따라서 빈 설정정보가 달라져야 하는 경우에 간단하게 해당 설정들을 만들 수 있도록 방법을 제공한다.

 

프로파일은 클래스 단위로 지정한다.

@Profile 어노테이션을 사용해서 프로파일의 이름을 넣어주면 된다.

 

@Configuration
@Profile("test")
public class TestAppContext {
}

이제 이렇게 하고 실행을 해보면, 당연히 실패한다.

 

MailSender를 가져오지 못해 실패하는데, 따로 저 profile을 활성화해주지 않아 에러가 발생하는 것이다.

 

@ExtendWith(SpringExtension.class)
@ContextConfiguration(classes = {AppContext.class, TestAppContext.class, ProductionAppContext.class})
@ActiveProfiles("test")
class UserDaoTest {}

이렇게 ActiveProfiles로 test를 활성화 해줘야, test의 profile들을 불러오게 된다.

 

이렇게 설정을 하고 다시 테스트를 돌려보니, 다음과 같이 성공하는 것을 볼 수 있다.

 

 

프로퍼티 소스

현재 데이터베이스의 정보가 이렇게 코드로 들어가있다.

이런 정보가 깃에 올라가면 안되기도 하고, 배포서버와 개발서버에서 다른 데이터베이스를 연결하기에 이런 정보는 코드로 작성해서는 안된다.

 

프로퍼티 파일로 빼도록 하자.

database.yml라는 파일을 만들고 데이터베이스의 정보는 그곳에다가 저장하도록 하겠다.

 

 

그리고 AppContext 내의 코드를 다음과 같이 수정해준다.

 

    @Autowired
    Environment environment;

    @Bean
    public DataSource dataSource() {

        SimpleDriverDataSource dataSource = new SimpleDriverDataSource();

        try
        {
            dataSource.setDriverClass((Class<? extends java.sql.Driver>)Class.forName(environment.getProperty("db.driverClass")));
        }
        catch (ClassNotFoundException e)
        {
            throw new RuntimeException(e);
        }

        dataSource.setUrl(environment.getProperty("db.url"));
        dataSource.setUsername(environment.getProperty("db.username"));
        dataSource.setPassword(environment.getProperty("db.password"));

        return dataSource;
    }

Enviroment를 주입받고, 거기서 하나씩 값을 빼서 사용하는 것이다.

 

이건 뭐랄까, 한번에 많이 가져오는 방법이고 하나씩 가져오는 방법도 있다.

 

    @Value("${db.driverClass}")
    private Class<? extends Driver> driverClassName;

    @Value("${db.url}")
    private String url;

    @Value("${db.username}")
    private String username;

    @Value("${db.password}")
    private String password;

 

이렇게 @Value로 값을 명시하고 값을 가져올 수 있다.

 

이 중 원하는 방법으로 변수들을 빼오면 된다.

나는 아마 @Value...?

728x90

저번에 말했던 실시간으로 수정이 가능한 UpdatableSqlRegistry를 만들어보고자 한다.

 

Sql을 업데이트 할 때, 쿼리를 호출하게 된다면 깨진 Sql이 나타날 수 있기 때문에 동시성 문제에 주의해서 구현해야 한다.

그렇기에 자바에서 동시성 문제를 도와주는 라이브러리를 사용해 만들어보도록 하겠다.

 

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

HashMap에서는 역시 멀티스레드 환경에서 동시성 문제가 발생할 수 있기 때문에, 이런 문제에 최적화된 ConcurrentHashMap을 사용하도록 하겠다.

ConcurrentHashMap은 업데이트시에 해당 부분에만 락을 걸기에, 성능이 많이 떨어지지 않는다.

 

우선 다음과 같은 테스트를 작성한다.

이 테스트가 통과해야 할 것이다.

public class ConcurrentHashMapSqlRegistryTest {

    UpdatableSqlRegistry sqlRegistry;

    @BeforeEach
    public void setUp() {

        sqlRegistry.registerSql("KEY1", "SQL1");
        sqlRegistry.registerSql("KEY2", "SQL2");
        sqlRegistry.registerSql("KEY3", "SQL3");
    }

    @Test
    public void find(){
        Assertions.assertTrue(
                sqlRegistry.findSql("KEY1").equals("SQL1") &&
                        sqlRegistry.findSql("KEY2").equals("SQL2") &&
                        sqlRegistry.findSql("KEY3").equals("SQL3")
        );
    }

    @Test
    public void notFoundKey(){
        Assertions.assertThrows(ChangeSetPersister.NotFoundException.class, () -> sqlRegistry.findSql(UUID.randomUUID().toString()));
    }
    
    @Test
    public void updateSql(){
        sqlRegistry.updateSql("KEY1", "U_SQL1");
        Assertions.assertTrue(
                sqlRegistry.findSql("KEY1").equals("U_SQL1") &&
                        sqlRegistry.findSql("KEY2").equals("SQL2") &&
                        sqlRegistry.findSql("KEY3").equals("SQL3")
        );
    }
    
    @Test
    public void updateMulti(){
        Map<String, String> sqlmap = new HashMap<>();
        
        sqlmap.put("KEY1", "U_SQL1");
        sqlmap.put("KEY3", "U_SQL3");
        
        sqlRegistry.updateSql(sqlmap);
        Assertions.assertTrue(
                sqlRegistry.findSql("KEY1").equals("U_SQL1") &&
                        sqlRegistry.findSql("KEY2").equals("SQL2") &&
                        sqlRegistry.findSql("KEY3").equals("U_SQL3")
        );
    }
}

동시성 관련해서는 멀티쓰레드를 만들어야 하고, 해당 환경에서도 문제가 발생할지 안할지를 잘 모르기에 테스트가 어렵다.

우선은 자바에서 제공해주는 라이브러리를 믿어보도록 하자.

 

그리고 ConcurrentHashMap을 사용하는 Registry를 다음과 같이 구현한다.

public class ConcurrentHashMapSqlRegistry implements UpdatableSqlRegistry {

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

    @Override
    @SneakyThrows
    public void updateSql(String key, String sql) {
        if(sqlMap.get(key) == null)
            throw new ChangeSetPersister.NotFoundException();

        sqlMap.put(key, sql);
    }

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

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

    @SneakyThrows
    @Override
    public String findSql(String key) {
        String sql = sqlMap.get(key);
        if(sql == null) throw new ChangeSetPersister.NotFoundException();
        return sql;
    }
}

 

이거를 바탕을 테스트를 돌려보면

역시 성공하는 것을 볼 수 있다.

 

현재 SqlService에 해당 빈으로 등록 하도록 하자.

<bean id="sqlRegistry" class="seungkyu.ConcurrentHashMapSqlRegistry"/>

 

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

SqlRegsitry를 ConcurrentHashMap 말고도 데이터베이스로도 만들어 볼 수 있다.

외부로 트래픽이 필요한 외장 데이터베이스보다 애플리케이션에 내장되어 애플리케이션과 함께 실행되고 종료되는 내장 데이터베이스를 사용하는 것이 더 편리하고 빠를 것이다.

 

많이 사용되는 내장 데이터베이스는 Derby, HSQL, H2가 있다고 한다.

이 친구들은 스프링에서 돌아가기에 따로 SQL 스크립트를 만들어서 같이 실행해야 한다고 한다.

초기 Sql을 통해 데이터를 삽입하고 나면 그 뒤에는 JDBC와 같은 기술들을 동일하게 사용 할 수 있다고 한다.

 

일단 이 내장 데이터베이스를 간단하게 사용해보도록 하자.

내장 데이터베이스는 DB가 실행될때마다 테이블을 새로 생성하기에, 테이블을 생성하는 SQL을 같이 넣어줘야 한다.

 

schema.sql

CREATE TABLE SQLMAP(
    KEY_ VARCHAR(100) PRIMARY KEY,
    SQL_ VARCHAR(100) NOT NULL
);

 

그리고 초기에 데이터도 넣어주기 위해 insert로 구성된 data.sql을 작성한다.

INSERT INTO SQLMAP(KEY_, SQL_) values('KEY1', 'SQL1');
INSERT INTO SQLMAP(KEY_, SQL_) values('KEY2', 'SQL2');

 

간단하게 테스트를 작성해보니

package seungkyu;

import org.junit.jupiter.api.*;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.datasource.embedded.EmbeddedDatabase;
import org.springframework.jdbc.datasource.embedded.EmbeddedDatabaseBuilder;
import org.springframework.jdbc.datasource.embedded.EmbeddedDatabaseType;

import java.util.concurrent.atomic.AtomicInteger;

public class EmbeddedDbTest {

    EmbeddedDatabase db;
    JdbcTemplate jdbcTemplate;

    @BeforeEach
    public void setUp() {

        db = new EmbeddedDatabaseBuilder()
                .setType(EmbeddedDatabaseType.H2)
                .addScript("/schema.sql")
                .addScript("/data.sql")
                .build();

        jdbcTemplate = new JdbcTemplate(db);
    }

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

    @Test
    public void initData(){
        Assertions.assertEquals(2, jdbcTemplate.queryForObject("select count(*) from sqlmap", Integer.class));

        AtomicInteger index = new AtomicInteger();

        jdbcTemplate.queryForList("SELECT * FROM sqlmap").forEach(
                (mapEntry) -> {
                    Assertions.assertEquals("KEY" + (index.get() + 1), mapEntry.get("key_"));
                    Assertions.assertEquals("SQL" + (index.get() + 1), mapEntry.get("sql_"));
                    index.getAndIncrement();
                }
        );
    }

}

 

 통과하는 것을 볼 수 있었다.

 

이제 이 내장 데이터베이스를 사용해서 SqlRegistry를 수정해보자.

 

일단 이 내장 데이터베이스를 사용하기 위해서 빈으로 등록하도록 하자.

 

    <jdbc:embedded-database id="embeddedDatabase" type="H2">
        <jdbc:script location="classpath:schema.sql"/>
    </jdbc:embedded-database>

schema는 실행하면서 생성만 하면 되기에 여기에 넣어준다.

 

이제 embeddedDatabase 빈이 등록되어 그냥 이 데이터베이스를 사용하면 된다.

 

이 빈을 사용해서 UpdatableSqlRegistry를 만들어보자.

 

주입받은 내장 데이터베이스의 DataSource를 통해 저장하고 업데이트 하도록 만든다.

package seungkyu;

import org.springframework.dao.EmptyResultDataAccessException;
import org.springframework.data.crossstore.ChangeSetPersister;
import org.springframework.jdbc.core.JdbcTemplate;

import javax.sql.DataSource;
import java.util.Map;

public class EmbeddedDbSqlRegistry implements UpdatableSqlRegistry {

    private final JdbcTemplate jdbcTemplate;

    public EmbeddedDbSqlRegistry(DataSource dataSource) {
        this.jdbcTemplate = new JdbcTemplate(dataSource);
    }

    @Override
    public void registerSql(String key, String sql) {
        jdbcTemplate.update("INSERT INTO sqlmap (key_, sql_) values (?, ?)", key, sql);
    }

    @Override
    public String findSql(String key) {
        try
        {
            return jdbcTemplate.queryForObject("SELECT sql_ FROM sqlmap WHERE key_ = ?", String.class, key);
        }
        catch(EmptyResultDataAccessException e)
        {
            throw new RuntimeException();
        }
    }

    @Override
    public void updateSql(String key, String sql) {
        int changed = jdbcTemplate.update("UPDATE sqlmap SET sql_ = ? WHERE key_ = ?", sql, key);
    }

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

 

 

이제 이 EmbeddedDbSqlRegistry를 테스트 해봐야 한다.

근데 재미있게도 ConcurrentHashMapSqlRegistry와 구현한 인터페이스가 같다.

그리고 우리는 그 인터페이스의 내용만 테스트하면 끝이다.

 

그렇기에 해당 테스트 코드를 공유해보자.

 

이런식으로 추상 클래스를 만들고

public abstract class AbstractUpdatableSqlRegistryTest {

    UpdatableSqlRegistry sqlRegistry;

    abstract protected UpdatableSqlRegistry injectSqlRegistry();

    @BeforeEach
    public void setUp() {
        sqlRegistry = injectSqlRegistry();

        sqlRegistry.registerSql("KEY1", "SQL1");
        sqlRegistry.registerSql("KEY2", "SQL2");
        sqlRegistry.registerSql("KEY3", "SQL3");
    }
    //.....
}

 

기존의 ConcurrentHashMapSqlRegistryTest도 이렇게 간단하게 변경했다.

public class ConcurrentHashMapSqlRegistryTest extends AbstractUpdatableSqlRegistryTest{

    @Override
    protected UpdatableSqlRegistry injectSqlRegistry() {
        return new ConcurrentHashMapSqlRegistry();
    }
}

이렇게만 작성하고 테스트를 해봐도

 

기존에 작성한 4개의 테스트를 수행하는 것을 볼 수 있다.

 

이거를 그대로 상속받아 EmbeddedDbSqlRegistryTest를 만들어보자.

public class EmbeddedDbSqlRegistryTest extends AbstractUpdatableSqlRegistryTest{

    EmbeddedDatabase db;

    @Override
    protected UpdatableSqlRegistry injectSqlRegistry() {
        db = new EmbeddedDatabaseBuilder()
                .setType(EmbeddedDatabaseType.H2)
                .addScript("/schema.sql")
                .build();

        return new EmbeddedDbSqlRegistry(db);
    }

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

 

이렇게 작성하고 테스트를 실행해보니, 다행하게 통과하는 것을 볼 수 있었다.

 

이제 저 EmbeddedDbSqlRegistry에 DataSource를 주입해줘야 한다.

아무생각없이 DataSource를 찾아 넣으면 MySql로 들어가기에 내장 데이터베이스를 잘 찾아서 넣어줘야 한다.

 

    <bean id="sqlRegistry" class="seungkyu.EmbeddedDbSqlRegistry">
        <constructor-arg ref="embeddedDatabase"/>
    </bean>

    <jdbc:embedded-database id="embeddedDatabase" type="H2">
        <jdbc:script location="classpath:schema.sql"/>
    </jdbc:embedded-database>

이렇게 embeddedDatabase의 빈을 또 만들고 그 빈으로 주입을 해줘야 내장데이터베이스로 설정이 된다.

 

트랜잭션 적용

만약 쿼리들이 등록되고 있는 중간에, 해당 쿼리를 조회하면 어떻게 될까?

반복문을 통해 등록하는 중간에 아직 등록되지 않은 쿼리를 조회할 수도 있을 것이다.

이런 이유로 각 메서드에 알맞은 트랜잭션 설정을 해줘야 한다.

 

우선 테스트 먼저 작성한다.


    @Test
    public void transactionTest(){
        Map<String, String> sqlmap = new HashMap<>();
        sqlmap.put("KEY1", "U_SQL1");
        sqlmap.put(UUID.randomUUID().toString(), UUID.randomUUID().toString());

        try
        {
            sqlRegistry.updateSql(sqlmap);
            Assertions.fail();
        }
        catch (RuntimeException e)
        {
            Assertions.assertTrue(
                    sqlRegistry.findSql("KEY1").equals("SQL1") &&
                            sqlRegistry.findSql("KEY2").equals("SQL2") &&
                            sqlRegistry.findSql("KEY3").equals("SQL3")
            );
        }
    }

 

중간에 없는 key로 조회를 하면 에러가 발생하는지 테스트이다.

그리고 만약 에러가 발생했다면 롤백도 되는지 테스트한다.

 

당연히 지금은 롤백이 되지 않아 실패한다.

 

트랜잭션은 마지막 부분에만 필요하기에

package seungkyu;

import org.jetbrains.annotations.NotNull;
import org.springframework.dao.EmptyResultDataAccessException;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.datasource.DataSourceTransactionManager;
import org.springframework.transaction.TransactionStatus;
import org.springframework.transaction.support.TransactionCallbackWithoutResult;
import org.springframework.transaction.support.TransactionTemplate;

import javax.sql.DataSource;
import java.util.Map;

public class EmbeddedDbSqlRegistry implements UpdatableSqlRegistry {

    private final JdbcTemplate jdbcTemplate;
    private final TransactionTemplate transactionTemplate;

    public EmbeddedDbSqlRegistry(DataSource dataSource) {
        this.jdbcTemplate = new JdbcTemplate(dataSource);
        this.transactionTemplate = new TransactionTemplate(new DataSourceTransactionManager(dataSource));
    }

    @Override
    public void updateSql(Map<String, String> sqlmap) {
        transactionTemplate.execute(new TransactionCallbackWithoutResult() {
            protected void doInTransactionWithoutResult(@NotNull TransactionStatus status) {
                for(Map.Entry<String, String> entry : sqlmap.entrySet()) {
                    updateSql(entry.getKey(), entry.getValue());
                }
            }
        });
    }
}

해당 부분만 트랜잭션으로 감싸준다.

 

그리고 테스트를 수행해보면 다음과 같이 성공하는 것을 볼 수 있다.

728x90

권장되지는 않지만 서버 운영 중에 SQL을 변경해야 할 수도 있다고 한다.

스프링답게 이 방법을 만들어보도록 하자.

 

DI와 기능의 확장

DI는 기술이 아니다.

DI 자체를 공부한다는 느낌보다는 최대한 이용하며 설계하는 방법을 배우고 있는 중이다.

단순히 Controller, Service, Repository를 사용하는 것이 아니라 DI를 통해 유연하게 의존관계를 지정하도록 설계해야 제대로 DI를 사용하는 것이다.

제대로 DI를 사용할 수 있도록 의식하면서 프로그래밍을 하는 습관이 필요하다.

하나의 커다란 오브젝트를 적절한 책임에 따라 분리해주는 습관을 들여야 한다.

물론 당장 사용하지는 않겠지만, DI를 미래에 대한 보험이라고 생각하면 될 것이다.

 

DI를 사용할 때에는 최대한 인터페이스를 사용해야 한다.

물론 클래스를 직접 주입해서 사용하는 것도 가능은 하다.

하지만 그러면 그 클래스에 직접적으로 의존하게 된다.

만약 구현코드가 변경된다면, 해당 클래스를 의존하고 있는 모든 오브젝트도 변경해야 한다.

 

이렇게 인터페이스를 사용하는 첫번째 이유는 다형성을 얻기 위해서다.

하나의 인터페이스로 여러개의 구현체를 바꿔가며 사용하도록 하는 것이 DI의 첫번째 목표이다.

그리고 또 인터페이스를 사용해야 하는 이유가 있다면 인터페이스 분리 원칙을 통해 클라이언트와 의존 오브젝트 사이의 관계를 명확하게 해줄 수 있기 때문이다.

인터페이스는 하나의 오브젝트가 여러 개를 구현할 수 있으므로, 하나의 오브젝트를 바라보는 관점이 여러가지일 수도 있다.

하나의 인터페이스를 하나의 관심사라고 한다면, 하나의 오브젝트가 다양한 관심사를 구현할 수도 있다는 것이다.

여기서 본인의 관심사에만 의존한다면 의존받는 오브젝트의 다른 부분이 변경되더라도, 의존하는 오브젝트는 관심이 없기 때문에 변경될 필요도 없다.

이런 부분을 잘 이용해서 관심사에 따라 오브젝트를 구현하도록 만드는 것도 DI를 잘 이용하는 방법이다.

 

오브젝트가 그 자체로 응집도가 높게 설계되었더라도, 목적과 관심이 각기 다른 클라이언트가 있다면 이를 적절하게 분리해주는 것이 좋다.

이를 객체지향의 5대 원칙 중 하나인 인터페이스 분리 원칙(Interface Segregation Principle)이라고 한다.

 

인터페이스 상속

인터페이스 분리 원칙이 주는 장점은 모든 클라이언트가 자신의 관심에 따른 접근 방식을 간섭없이 유지할 수 있다는 점이다.

어차피 클라이언트는 본인이 의존하고 있는 인터페이스의 메서드만 신경쓰기에, 다른 부분의 변경은 영향을 주지 않기 때문이다.

 

다음과 같은 구조에서

BaseSqlService는 어떤 클래스가 SqlReader를 구현했던, 신경쓰지 않고 그저 인터페이스만 사용해서 변경없이 코드를 유지할 수 있다.

 

그리고 또 하나의 장점은, 2개의 인터페이스를 구현해서 각각 다른 클라이언트가 사용할 수 있다는 점이다.

 

현재 SqlRegistry는

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

 

이렇게 2가지를 가지고 있다. 그리고 이 인터페이스를 MySqlRegistry가 다른 인터페이스를 추가로 구현하고 싶다고 한다.

당연히 현재 BaseSqlService는 이런 등록하는 과정이 필요가 없기 때문에 SqlRegistry 인터페이스에 그런 메서드를 추가하면 안된다.

 

이런 부분은 분리를 해야한다.

public interface UpdatableSqlRegistry extends SqlRegistry{
    
    public void updateSql(String key, String sql);
    
    public void updateSql(Map<String, String> sqlmap);
}

 

 

이렇게 SqlRegistry에 업데이트를 하는 기능을 추가한다.

 

이러면 현재 BaseSqlService는 저 위의 메서드들만 사용하고 있기에, 아래의 메서드들에는 영향을 전혀 받지 않는다.

 

이런 Sql의 Update를 담당하는 클래스를 SqlManager라고 하자.

이런 식으로 BaseSqlService는 결국 SimpleUpdatableSqlRegistry를 주입받지만, 그 중 SqlRegistry의 메서드만 사용하게 된다.

그리고 이런 업데이트들은 다시 UpdatableSqlRegistry의 메서드들을 통해서 일어나게 되며, 해당 인터페이스의 구현체 또한 SimpleUpdatableSqlRegistry 이기 때문에 이런 업데이트가 결국 BaseSqlService에도 반영이 되는 것이다.

 

이렇게 인터페이스를 추가하거나 상속을 통해 확장하는 방식을 잘 이용하면 기존의 인터페이스를 사용하는 클라이언트가 있더라도 유연한 확장이 가능해진다.

 

잘 적용된 DI는 결국 잘 설계된 오브젝트 의존관계에 달려있다.

인터페이스를 적절하게 분리하고 확장하는 방법을 항상 고민하고, 의존관계를 명확하게 해주는 방법을 항상 고민하며 개발하자.

728x90

JAVB외에도 XML과 클래스의 멤버에 데이터를 매핑하는 기술들이 있다고 한다.

그리고 XML을 더 자유롭게 읽어올 수도 있다고 한다.

알아보자.

 

OXM 서비스 추상화

OXM은 Object-XML Mapping으로 xml과 자바 오브젝트를 매핑해주는 기술이라고 한다.

JAXB는 자바 표준에 포함되어 있어서 사용했지만, 이 외에도 다음과 같은 XML 기술들이 있다.

Castor XML, JiBX, XmlBeans, Xstream...

 

이 모든 기술들의 목적은 XML에서 데이터를 가져오는 것이다.

그 중 어떤 기술을 사용할지 모르기에 추상화로 만들어두는 것도 좋은 방법이라고 생각된다.

자바 -> XML을 Marshaller라고 하고

XML -> 자바를 Unmarshaller라고 한다.

 

'우리는 XML에서 값을 불러와야 하기에 Unmarshaller를 사용해야 한다'가 목표였지만, xml에서 무언가 조작하기가 힘들어서 yml에서 가져와서 만드는 것으로 바꿨다...

 

기존에 작성했던 SqlMap으로 쿼리들을 불러와보자.

 

@Getter
@Setter
@ToString
@Configuration
@ConfigurationProperties(prefix = "sqlmap")
public class SqlMap {

    private List<SqlType> queries;
}

 

@Getter
@Setter
@ToString
public class SqlType {
    private String value;
    private String key;
}

 

이렇게 해두고 우선 테스트를 돌려보자.

 

@ExtendWith(SpringExtension.class)
@ContextConfiguration(locations = {"/test-applicationContext.xml"})
public class OxmTest {

    @Test
    public void unmarshallSqlMap() throws Exception {
        ObjectMapper mapper = new ObjectMapper(new YAMLFactory());

        try (InputStream inputStream = getClass().getResourceAsStream("/sqlmap.yml")) {
            assert inputStream != null;

            SqlMap sqlMap = mapper.readValue(inputStream, SqlMap.class);
            System.out.println(sqlMap);
            
            
        }
    }
}

 

우선 이렇게 작성해두면

 

이렇게 제대로 출력되는 것을 볼 수 있다.

 

OXM 서비스 추상화 적용

yml을 읽어오는 기능을 바탕으로 SqlService를 만들어보자.

기존에 사용하던 SqlRegistry를 그대로 사용하면 될테니, Reader만 yml을 읽어오도록 추가해서 등록해주면 된다.

@RequiredArgsConstructor
public class YmlSqlService implements SqlService {

    private final SqlReader sqlReader;

    private final SqlRegistry sqlRegistry;

    @PostConstruct
    public void loadSql(){
        this.sqlReader.read(this.sqlRegistry);
    }

    public String getSql(String key){
        return this.sqlRegistry.findSql(key);
    }
}

 

이렇게 껍데기를 만들어두고, SqlReader를 구현해보자.

@RequiredArgsConstructor
public class XmlSqlReader implements SqlReader {

    private final String path;

    @Override
    public void read(SqlRegistry sqlRegistry) {
        ObjectMapper mapper = new ObjectMapper(new YAMLFactory());

        try (InputStream inputStream = getClass().getResourceAsStream(path)) {
            assert inputStream != null;

            SqlMap sqlMap = mapper.readValue(inputStream, SqlMap.class);

            for(SqlType sqlType: sqlMap.getQueries())
            {
                sqlRegistry.registerSql(sqlType.getKey(), sqlType.getValue());
            }
        } catch (IOException e) {
            throw new RuntimeException(e);
        }
    }
}

 

이러면 저번에 만들었던 xml과는 동일하게 동작할 수 있을 것이다.

 

이렇게 서비스 자체를 추상화로 만들어두니 테스트도 성공했다.

728x90

이제 저번에 만들었던 sqlService를 더 제대로 사용해보자.

 

XML 파일매핑

저번에 sql을 분리해서 xml에 이렇게 넣기는 했지만

사실 여기에 sql이 있는 것도 좀 아쉽다.

차라리 다른 파일에서 sql만 불러오도록 만들고 싶어진다.

 

JAXB를 사용하면 xml에서 정보를 가져올 수 있다고 한다.

 

xml에서 sql들을 가져오는 방법은 과정만 작성하도록 하겠다.

    implementation("javax.xml.bind:jaxb-api:2.3.1")
    runtimeOnly("org.glassfish.jaxb:jaxb-runtime:2.3.3")
    implementation("javax.activation:activation:1.1.1")

 

이렇게 일단 xml을 읽을 수 있는 라이브러리를 가져오고

 

<?xml version="1.0" encoding="UTF-8" ?>
<xs:schema xmlns:xs="http://www.w3.org/2001/XMLSchema"
           targetNamespace="http://www.epril.com/sqlmap"
           xmlns:tns="http://www.epril.com/sqlmap"
           elementFormDefault="qualified">

    <xs:element name="sqlmap">
        <xs:complexType>
            <xs:sequence>
                <xs:element name="sql" type="tns:sqlType" maxOccurs="unbounded"/>
            </xs:sequence>
        </xs:complexType>
    </xs:element>

    <xs:complexType name="sqlType">
        <xs:simpleContent>
            <xs:extension base="xs:string">
                <xs:attribute name="key" type="xs:string" use="required"/>
            </xs:extension>
        </xs:simpleContent>
    </xs:complexType>

</xs:schema>

xsd와

 

<?xml version="1.0" encoding="UTF-8"?>
<sqlmap xmlns="http://www.epril.com/sqlmap">
    <sql key="findAll">SELECT * FROM users</sql>
    <sql key="findById">SELECT * FROM users WHERE id = ?</sql>
    <sql key="insertUser">INSERT INTO users (name, email) VALUES (?, ?)</sql>
</sqlmap>

xml을 작성해준다.

 

그리고 해당 데이터를 저장하고 있는 자바 클래스를 정의해준다.

@XmlType(name = "", propOrder = {"sql"})
@XmlRootElement(name = "sqlmap", namespace = "http://www.epril.com/sqlmap")
@XmlAccessorType(XmlAccessType.FIELD)
@Getter
@Setter
public class SqlMap {

    @XmlElement(namespace = "http://www.epril.com/sqlmap", required = true)
    private List<SqlType> sql;

    @Override
    public String toString() {
        return "SqlMap{" +
                "sql=" + sql +
                '}';
    }
}

 

@Setter
@Getter
@XmlAccessorType(XmlAccessType.FIELD)
@XmlType(name = "sqlType", namespace = "http://www.epril.com/sqlmap")
public class SqlType {

    @XmlValue
    private String value;

    @XmlAttribute(name = "key", required = true)
    private String key;

    @Override
    public String toString() {
        return "SqlType{" +
                "value='" + value + '\'' +
                ", key='" + key + '\'' +
                '}';
    }
}

 

이러고 테스트를 통해 출력해보면 다음과 같이 나오게 된다.

public class JaxbTest {

    @Test
    public void readSqlMap() throws JAXBException {
        JAXBContext context = JAXBContext.newInstance(SqlMap.class);

        Unmarshaller unmarshaller = context.createUnmarshaller();

        SqlMap sqlmap = (SqlMap) unmarshaller.unmarshal(
                getClass().getResourceAsStream("/sqlmap.xml")
        );

        System.out.println(sqlmap);
    }

}

 

 

이렇게 xml에서 데이터를 가져오는 방법을 언마샬링이라고 한다고 한다.

 

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

이 sqlmap.xml을 통해서 UserDao를 사용해보도록 하자.

우선 sql을 모두 작성해준다.

<?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
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>
    <sql key="userGetAll">SELECT * FROM users ORDER BY ID</sql>
    <sql key="userDeleteAll">DELETE FROM users</sql>
    <sql key="userGetCount">SELECT COUNT(*) FROM users</sql>
    <sql key="userUpdate">UPDATE users SET name = ?, password = ?, email = ?, level = ?, login = ?, recommend = ? where id = ?</sql>
</sqlmap>

 

이 xml을 읽어서 sql을 전달해주는 SqlService를 작성해보자.

생성자를 통해 해당 Bean이 생성되는 순간에 sqlMap을 불러와서 Map으로 저장한다.

package seungkyu;

import javax.xml.bind.JAXBContext;
import javax.xml.bind.JAXBException;
import javax.xml.bind.Unmarshaller;
import java.util.HashMap;
import java.util.Map;

public class XmlSqlService implements SqlService {

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

    public XmlSqlService() {
        String contextPath = SqlMap.class.getPackage().getName();
        try
        {
            JAXBContext jaxbContext = JAXBContext.newInstance(SqlMap.class);
            Unmarshaller unmarshaller = jaxbContext.createUnmarshaller();

            SqlMap sqlmap = (SqlMap) unmarshaller.unmarshal(
                    getClass().getResourceAsStream("/sqlmap.xml")
            );

            for(SqlType sqlType: sqlmap.getSql()) {
                sqlMap.put(sqlType.getKey(), sqlType.getValue());
            }

        } catch (JAXBException e) {
            throw new RuntimeException(e);
        }
    }

    @Override
    public String getSql(String key) {
        String sql = sqlMap.get(key);
        if(sql == null) {
            throw new RuntimeException("SQL Key " + key + " not found");
        }
        else 
            return sql;
    }
}

이 XmlSqlService를 sqlService로 등록해준다.

 

빈의 초기화 작업

일단 위와 같이 만들긴 했지만, 생성하는 과정에서 몇가지 수정할 부분이 있다고 한다.

우선 생성 중 예외가 발생할 수 있고, 가져오는 파일이 sqlmap.xml로 고정이 되어있다.

 

우선 간단하게 파일부터 동적으로 받을 수 있도록 수정해보자.

이렇게 그냥 생성자와 빈만 수정한다.

public XmlSqlService(String fileName) {
        try
        {
            JAXBContext jaxbContext = JAXBContext.newInstance(SqlMap.class);
            Unmarshaller unmarshaller = jaxbContext.createUnmarshaller();

            SqlMap sqlmap = (SqlMap) unmarshaller.unmarshal(
                    getClass().getResourceAsStream(fileName)
            );

            for(SqlType sqlType: sqlmap.getSql()) {
                sqlMap.put(sqlType.getKey(), sqlType.getValue());
            }

        } catch (JAXBException e) {
            throw new RuntimeException(e);
        }
    }

 

그리고 이 생성로직을 loadSql()로 추출하려고 하는데

    public void loadSql(){
        try
        {
            JAXBContext jaxbContext = JAXBContext.newInstance(SqlMap.class);
            Unmarshaller unmarshaller = jaxbContext.createUnmarshaller();

            SqlMap sqlmap = (SqlMap) unmarshaller.unmarshal(
                    getClass().getResourceAsStream(fileName)
            );

            for(SqlType sqlType: sqlmap.getSql()) {
                sqlMap.put(sqlType.getKey(), sqlType.getValue());
            }

        } catch (JAXBException e) {
            throw new RuntimeException(e);
        }
    }

문제는 이 클래스를 스프링이 빈으로 등록해주는 것이기 때문에, 우리가 이 메서드를 호출할 방법이 없다.

 

그렇기에 이 클래스에 @PostConstruct를 사용하겠다.

AOP에 대해 알아볼 때, 스프링의 빈 후처리기가 있다고 했었다.

빈 후처리기는 스프링 컨테이너가 빈을 생성한 뒤에 추가적인 작업을 해줄 수 있다고 했는데, 여기서 @PostConstruct는 스프링이 DI 작업을 마친 뒤 해당 메서드를 자동으로 실행하도록 해주는 어노테이션이다.

 

	@PostConstruct
    public void loadSql(){}

 

이렇게 @PostConstruct 어노테이션을 붙이고

 

<beans xmlns="http://www.springframework.org/schema/beans"
       xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
       xmlns:aop="http://www.springframework.org/schema/aop"
       xmlns:context="http://www.springframework.org/schema/context"
       xmlns:tx="http://www.springframework.org/schema/tx"
       xsi:schemaLocation="
           http://www.springframework.org/schema/beans
           http://www.springframework.org/schema/beans/spring-beans.xsd
           http://www.springframework.org/schema/aop
           http://www.springframework.org/schema/aop/spring-aop.xsd
           http://www.springframework.org/schema/context
           http://www.springframework.org/schema/context/spring-context.xsd
           http://www.springframework.org/schema/tx
           http://www.springframework.org/schema/tx/spring-tx-2.5.xsd">
           
           <context:annotation-config/>
           
</beans>

이렇게 어노테이션 설정을 붙여주면

 

 

이렇게 스프링을 실행하면 PostConstruct가 실행되는 것을 볼 수 있다.

 

스프링 컨테이너는 빈을 다음과 같은 순서로 생성한다.

 

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

솔직히 이정도면 끝났다고 생각은 했지만, 아직도 수정할 부분이 남아있다고 한다.

현재 XmlSqlService가 xml을 통해서 sql을 불러오고, 요청에 따른 sql을 반환하는 2가지의 임무를 수행하고 있다.

 

현재는 xml에서 map을 통해 sql을 주고 있지만, 이 방법이 엑셀로 그리고 list로 변경될 수도 있다는 것이다.

 

SqlService를 리펙토링하면 다음과 같은 구조로 만들어진다.

당연히 읽는 부분과 등록하는 부분은 인터페이스로 구현해야 할 것이다.

 

SqlService는 생성시 혹은 사용 전에 SqlReader를 통해 SqlRegistry에 sql을 등록해야 한다.

SqlService가 사실은 Sql을 이용하는 것은 아니기 때문에 Map<String, String> 타입으로 의존성이 생기게 넘겨줄 필요는 없고

SqlRegistry를 받아서 하나하나 등록해주는 방법이 더 좋다.

사실은 잘 모르겠고, 일단 만들고나서 확인해보자.

 

우선 각각 인터페이스부터 만들어보자.

우선 간단하게 registry부터

public interface SqlRegistry {

    void registerSql(String key, String sql);

    String findSql(String key);
}

 

그리고 Reader에서는 읽자마자 바로 넘겨주기에

public interface SqlReader {
    void read(SqlRegistry sqlRegistry);
}

이렇게 SqlRegistry를 넘겨주는 read 메서드 하나만 두도록 한다.

 

자기참조 빈으로 시작하기

이제 각각의 인터페이스를 구현해보자.

어렵게 되었지만, 결국 의존관계를 그려보면 다음과 같다.

 

하지만 너무 구조가 복잡하기에 XmlSqlService가 다른 3개의 인터페이스를 구현하도록 만드는 방법도 있다.

그럼 구조가 다음과 같아질것이다.

 

사실 자기참조 빈을 사용해서 만든다고는 하는데, 이해가 잘 되지 않는다.

바로 만들어보고 생각하자.

 

우선 내부에서 사용할 멤버 변수를 다음과 같이 만들어준다.

@RequiredArgsConstructor
public class XmlSqlService implements SqlService, SqlRegistry, SqlReader{

    @Setter
    private SqlReader sqlReader;

    @Setter
    private SqlRegistry sqlRegistry;

    private final String path;

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

SqlService, SqlRegistry, SqlReader를 구현하지만 내부적으로 SqlReader와 SqlRegistry를 사용하도록 만들어준다.

 

    @Override
    public String findSql(String key) {
        return sqlMap.getOrDefault(key, "NOT FOUND");
    }

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

이제 인터퍼이스들을 구현해주는데, SqlRegistry 먼저 구현해준다.

 

내부적으로 가지고 있는 sqlMap을 통해 간단하게 등록하고 조회한다.

 

    @Override
    public void read(SqlRegistry sqlRegistry) {
        JAXBContext context;
        try {
            context = JAXBContext.newInstance(SqlMap.class);
        } catch (JAXBException e) {
            throw new RuntimeException(e);
        }

        Unmarshaller unmarshaller;
        try {
            unmarshaller = context.createUnmarshaller();
        } catch (JAXBException e) {
            throw new RuntimeException(e);
        }

        try {
            SqlMap sqlmap = (SqlMap) unmarshaller.unmarshal(
                    getClass().getResourceAsStream(path)
            );
            for(SqlType sqlType : sqlmap.getSql()) {
                sqlRegistry.registerSql(sqlType.getKey(), sqlType.getValue());
            }
        } catch (JAXBException e) {
            throw new RuntimeException(e);
        }
    }

이제는 그냥 SqlRegistry를 넘겨서 해당 인터페이스의 registrySql을 사용해 sql을 등록하도록 만든다.

 

이렇게 만들면 타입을 정해 SqlReader에서 SqlRegistry를 등록해줄 필요가 없어진다.

 

이제 SqlService는 다음과 같이 @PostConstruct와 getSql() 메서드를 구현해서 만들어준다.

    @PostConstruct
    public void loadSql(){
        this.sqlReader.read(this.sqlRegistry);
    }

    public String getSql(String key){
        return this.findSql(key);
    }

 

@RequiredArgsConstructor
public class XmlSqlService implements SqlService, SqlRegistry, SqlReader{

    private final SqlReader sqlReader;

    private final SqlRegistry sqlRegistry;

    private final String path;

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

    @PostConstruct
    public void loadSql(){
        this.sqlReader.read(this.sqlRegistry);
    }

    public String getSql(String key){
        return this.findSql(key);
    }

    @Override
    public String findSql(String key) {
        return sqlMap.getOrDefault(key, "NOT FOUND");
    }

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

    @Override
    public void read(SqlRegistry sqlRegistry) {
        JAXBContext context;
        try {
            context = JAXBContext.newInstance(SqlMap.class);
        } catch (JAXBException e) {
            throw new RuntimeException(e);
        }

        Unmarshaller unmarshaller;
        try {
            unmarshaller = context.createUnmarshaller();
        } catch (JAXBException e) {
            throw new RuntimeException(e);
        }

        try {
            SqlMap sqlmap = (SqlMap) unmarshaller.unmarshal(
                    getClass().getResourceAsStream(path)
            );
            for(SqlType sqlType : sqlmap.getSql()) {
                sqlRegistry.registerSql(sqlType.getKey(), sqlType.getValue());
            }
        } catch (JAXBException e) {
            throw new RuntimeException(e);
        }
    }
}

XmlSqlService는 SqlService와 SqlRegistry라는 전략을 사용하도록 구성되었기에, 이 2가지를 최대한 활용하여 만들면 된다.

 

사실 이게 핵심은 아니고, 이거를 xml을 통해 빈으로 등록하는 과정이 핵심이다.

    <bean id="sqlService" class="seungkyu.XmlSqlService">
        <constructor-arg value="/sqlmap.xml"/>
        <property name="sqlReader" ref="sqlService"/>
        <property name="sqlRegistry" ref="sqlService"/>
    </bean>

순환참조가 발생하지 않도록 setter로 자기자신을 주입해준다.

이렇게 하면 된다고는 하지만, 나는 이렇게 만들면 이해하기가 어려울 거 같아 그냥 인터페이스들을 분리하려고 한다.

 

    <bean id="sqlReader" class="seungkyu.XmlSqlReader">
        <constructor-arg value="/sqlmap.xml"/>
    </bean>

    <bean id="sqlRegistry" class="seungkyu.MapSqlRegistry"/>

    <bean id="sqlService" class="seungkyu.XmlSqlService">
        <constructor-arg ref="sqlReader"/>
        <constructor-arg ref="sqlRegistry"/>
    </bean>

한동안 에러가 발생해서 수정했더니, 테스트를 모두 통과하는 것을 볼 수 있다.

 

728x90

UserDao를 꾸준하게 리팩토링 했지만, 그럼에도 남은 욕심은 Dao에서 SQL을 분리하는 것이다.

add() 메서드가 유저를 추가하는 메서드가 된다는 것은 변함없겠지만, column등이 추가 될 수 있고 그에 따라 Dao에 담은 SQL도 변환해줘야 한다.

이렇게 만드는 것 보다는 SQL을 분리해 다른 위치에 두고 작업하는 것이 더 좋을 것 같다.

 

XML 설정을 이용한 분리

가장 쉬운 방법은 XML로 추출하는 것이다.

XML을 통해서 빈으로 값을 주입할 수 있기 때문이다.

 

우선 Dao add() 메서드부터 작업을 해보자.

 

private final String sqlAdd;

 

일단 이렇게 add() 메서드를 주입받을 수 있도록 만든다.

 

그리고 이 sqlAdd를 활용해 add()에서 sql을 사용하도록 하고

    public void add(User user){
        this.jdbcTemplate.update(
                this.sqlAdd,
                user.getId(),
                user.getName(),
                user.getPassword(),
                user.getLevel().getValue(),
                user.getLogin(),
                user.getRecommend(),
                user.getEmail()
        );
    }

 

xml에 다음 쿼리를 주입해준다.

    <bean id="userDao" class="seungkyu.UserDaoImpl">
        <constructor-arg ref="dataSource" />
        <constructor-arg value = "insert into users(id, name, password, email, level, login, recommend) values(?, ?, ?, ?, ?, ?, ?)"/>
    </bean>

 

일단 이런 식으로 쿼리를 추출할 수 있을 것이다.

 

하지만 이 방법은 SQL이 많아질수록 DAO에 DI용 속성들을 하나하나 추가해주기가 부담일 것이다.

 

그러면 이번에는 SQL을 컬렉션으로 담아두는 방법을 사용해보자.

Map 자료구조를 사용한다.

 

주입을 Map으로 바꾸고

public class UserDaoImpl implements UserDao {
    
    private final JdbcTemplate jdbcTemplate;
    
    private final Map<String, String> sqlMap;

    public UserDaoImpl(
            DataSource dataSource,
            Map<String, String> sqlMap) {
        this.jdbcTemplate = new JdbcTemplate(dataSource);
        this.sqlMap = sqlMap;
    }
}

 

주입을 위한 xml도 수정해준다.

    <bean id="userDao" class="seungkyu.UserDaoImpl">
        <constructor-arg ref="dataSource" />
        <constructor-arg name="sqlMap">
            <map>
                <entry key="add" value="INSERT INTO USERS(id, name, password, email, level, login, recommend) values(?, ?, ?, ?, ?, ?, ?)"/>
            </map>
        </constructor-arg>
    </bean>

 

우선 add 메서드만 작업해보았다.

 

SQL 제공 서비스

sql이 분리되기는 했지만, 현재 dao만 봐도 DI의 내용과 SQL이 혼합되어 있다.

sql을 코드랑은 분리했지만, DI의 내용과 섞인 것이다.

 

외부 파일에서 SQL을 가져오도록 하는 방법을 찾아보자.

 

우선 SQL 서비스의 인터페이스를 설계해보자.

어쨌든 이제는 분리하기 위해서는 인터페이스를 만들어야 한다는 것을 바로 알 수 있을 것이다.

 

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

 

메서드를 여러개 만들지 말고, 이렇게 검색해서 가져올 수 있도록 만들었다.

 

이거를 바탕으로 UserDao도 다시 모두 수정해주자.

 

    public void add(User user){
        this.jdbcTemplate.update(
                this.sqlService.getSql("userAdd"),
                user.getId(),
                user.getName(),
                user.getPassword(),
                user.getLevel().getValue(),
                user.getLogin(),
                user.getRecommend(),
                user.getEmail()
        );
    }

    public User get(String id){
        return jdbcTemplate.queryForObject(
                this.sqlService.getSql("userGet"), userRowMapper, id);
    }

    public List<User> getAll() {
        return this.jdbcTemplate.query(
                this.sqlService.getSql("userGetAll"), userRowMapper);
    }

    public void update(User user) {
        this.jdbcTemplate.update(
                this.sqlService.getSql("userUpdate"),
                user.getName(),
                user.getPassword(),
                user.getLevel().getValue(),
                user.getLogin(),
                user.getRecommend(),
                user.getEmail(),
                user.getId()
        );
    }

    public void deleteAll(){
        this.jdbcTemplate.update(this.sqlService.getSql("userDeleteAll"));
    }

    public int getCount() {
        Integer result = jdbcTemplate.queryForObject(this.sqlService.getSql("userGetCount"), Integer.class);
        return result == null ? 0 : result;
    }

이렇게 SQL만 모두 수정해도 보기가 편해진다.

 

이제 SqlService를 구현해보자.

@RequiredArgsConstructor
public class SimpleSqlService implements SqlService {

    private final Map<String, String> sqlMap;

    @Override
    public String getSql(String key) {
        String sql = sqlMap.get(key);
        if (sql == null)
            throw new RuntimeException("sql not found: " + key);
        return sql;
    }
}

이제 이거도 빈으로 등록하고, sql들을 주입해줘야 한다.

 

    <bean id="userDao" class="seungkyu.UserDaoImpl">
        <constructor-arg ref="dataSource" />
        <constructor-arg ref="sqlService"/>
    </bean>

    <bean id="sqlService" class="seungkyu.SimpleSqlService">
        <constructor-arg 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>
        </constructor-arg>
    </bean>

 

사실 대단한 기술이 추가된 것은 아니기에 성공하겠지만, 그럼에도 불구하고 테스트는 돌려보자.

솔직히 최근에도 쿼리 자체를 잘 만들지는 않지만, 만약 jdbc를 사용하게 된다면 이렇게 추출할 수는 있을 것 같다.

728x90

선언적 트랜잭션과 트랜잭션 전파 속성

트랜잭션의 속성에서 트랜잭션 전파 속성은 매우 핵심적인 기능이다.

REQUIRED로 설정한다면 앞의 트랜잭션에 참여하기에 다양한 크기의 트랜잭션을 만들 수 있기 때문이다.

 

보통 update()와 같은 메서드들은 update 하나의 단위로 트랜잭션이 생성되지만, 다음과 같은 경우에는 참여하도록 만들어야 한다.

 

이렇게 일괄적으로 update를 수행하는 메서드가 있다면, 트랜잭션을 윗 단계인 updateScheduler()로 설정해서 하나만 에러가 발생하더라도 전체가 rollback되도록 만들어야 한다.

이런 경우에 트랜잭션 전파가 유용하게 사용된다.

 

이렇게 AOP를 통해 코드 외부에서 트랜잭션을 적용하는 것을 '선언적 트랜잭션'이라고 한다.

반대로 처음에 TransactionTemplate을 통해 코드 내부에서 트랜잭션을 적용하는 방법을 '프로그래밍에 의한 트랜잭션'이라고 한다.

 

트랜잭션 동기화와 테스트

    @Test
    public void transactionSync(){

        userService.deleteAll();

        userService.add(User.builder()
                .id(new Date().toString()).build());
        userService.add(User.builder()
                .id(new Date().toString()).build());
    }

이러한 테스트가 있다고 해보자.

service에는 각각의 메서드에 트랜잭션이 적용되기 때문에, 현재는 3개의 트랜잭션이 만들어질것이다.

현재 전파 속성은 REQUIRED이기 때문에, 해당 트랜잭션들을 하나로 묶을 수 있을 것 같다.

 

    @Test
    public void transactionSync(){

        DefaultTransactionDefinition transactionDefinition = new DefaultTransactionDefinition();
        TransactionStatus tx = transactionManager.getTransaction(transactionDefinition);

        userService.deleteAll();

        userService.add(User.builder()
                .id(new Date().toString()).build());
        userService.add(User.builder()
                .id(new Date().toString()).build());

        transactionManager.commit(tx);
    }

그냥 간단하게 묶어 보았다.

 

해당 메서드들이 트랜잭션에 참여했는지 확인해보기 위해, 잠깐 readOnly 속성을 추가해보았다.

이렇게 readOnly를 true로 설정하고 테스트를 수행해보니, 역시 다음과 같은 에러가 발생한다.

이로써 일단 트랜잭션 안으로 들어왔다는 것은 알 수 있었다.

 

이 방법을 통해, 테스트의 메서드에서 트랜잭션을 생성한 후 테스트 내에서 만들어지는 모든 트랜잭션을 참여하게 한 후 롤백하는 편리한 방법을 사용 할 수 있을 것 같다.

이렇게 테스트 내의 모든 DB 작업을 하나의 트랜잭션 안에서 동작하게 하고 무조건 롤백하는 방법을 '롤백 테스트'라고 한다고 한다.

 

    @Test
    public void transactionSync(){
    
        DefaultTransactionDefinition transactionDefinition = new DefaultTransactionDefinition();
        TransactionStatus transactionStatus = transactionManager.getTransaction(transactionDefinition);
        
        try
        {
            userService.deleteAll();
            userService.add(User.builder()
                .id(new Date().toString()).build());
            userService.add(User.builder()
                .id(new Date().toString()).build());
        }
        finally
        {
            transactionManager.rollback(transactionStatus);
        }
    }

 

finally를 통해, 무조건 rollback하도록 만드는 것이다.

 

현재 테스트에서 deleteAll()을 사용하기에 기존의 데이터베이스에 큰 영향을 준다.

물론 테스트 데이터베이스에서 테스트를 수행하겠지만, 이런 테스트는 문제가 될 수 있기 때문에 기존의 데이터베이스를 유지하도록 해야한다.

그리고 보통은 데이터베이스에 데이터를 삽입한 후 일괄삭제하는 방법보다 롤백하는 작업이 더 빠르기에 테스트의 수행속도를 높일 수 있다.

 

테스트를 위한 트랜잭션 어노테이션

테스트에서도 @Transactional 어노테이션이 동작한다.

진짜로 동작하는지 확인해보기 위해, 한 번 적용해보도록 하자.

    @Test
    @Transactional(readOnly = true)
    public void transactionSync(){
        
        userService.deleteAll();

        userService.add(User.builder()
                .id(new Date().toString()).build());
        userService.add(User.builder()
                .id(new Date().toString()).build());
    }

이렇게 readOnly를 적용하고, 진짜로 only readOnly 에러가 발생하는지 확인해보자.

 

여기서도 @Transactional이 적용되는 것을 볼 수 있었다.

 

뭔가 이 방법을 통해 데이터베이스의 결과와 상관없이 무조건 rollback하는 어노테이션을 만들 수 있을 것 같다.

바로 @Rollback이다.

이름으로 바로 알 수 있지만, 해당 어노테이션은 기본적으로 트랜잭션을 강제 롤백한다.

 

바로 직접 확인해보도록 하자.

현재 데이터베이스의 데이터는 비어있다.

 

거기에 다음과 같은 테스트 메서드를 실행해보자.

    @Test
    @Rollback
    @Transactional
    public void addUser(){
        userService.add(
                User.builder()
                        .id(UUID.randomUUID().toString().substring(0, 5))
                        .name("test")
                        .password("password")
                        .build()
        );
    }

 

이렇게 하니 테스트가 끝나도 데이터가 추가되지 않은 것을 볼 수 있었다.

@Transactional을 붙여야만 롤백이 적용된다.

 

테스트용 데이터베이스에도 데이터를 지워야하는 경우가 많았는데, rollback을 활용해 데이터를 관리 할 수 있을 것 같다.

 

+ Recent posts