
본 포스팅은 프로그래머스 미니 데브 코스를 공부하며
학습을 기록하기 위한 목적으로 작성된 글입니다.
테스트가 외부환경(Database)에 영향을 받으면 테스트 자동화가 어려워진다.
스프링에서는 이를 해결하기 위해 Embedded Database를 제공해준다.
테스트 시 일반적으로 Embedded Database를 사용한다.
H2 대신 Embedded Mysql를 사용한다.
SQL문을 표준 ANSI에 맞춰 작성한다.
@Configuration
    @ComponentScan(
            basePackages = {"org.prgrms.kdt.customer"}
    )
    static class Config {
        @Bean
        public DataSource dataSource() {
            return new EmbeddedDatabaseBuilder()
                    .generateUniqueName(true)
                    .setType(H2) // H2 EmbeddedDatabase 구동
                    .setScriptEncoding("UTF-8")
                    .ignoreFailedDrops(true)
                    .addScript("schema.sql") // resource 폴더에 schema.sql 파일 생성해서 테이블 생성 가능
                    .build();
        }
        // JdbcTemplate 사용을 위한 Bean 설정
        @Bean
        public JdbcTemplate jdbcTemplate(DataSource dataSource) {
            return new JdbcTemplate(dataSource);
        }
    }
<scope>test</scope>	@Configuration
    @ComponentScan(
            basePackages = {"org.prgrms.kdt.customer"}
    )
    static class Config {
        @Bean
        public DataSource dataSource() {
            var dataSource = DataSourceBuilder.create()
                    .url("jdbc:mysql://localhost:2215/test-order_mgmt") // setup()메소드에서 port를 2215로 설정해주었기 때문에 localhost:2215로 변경
                    .username("test")                                   //                 schema이름도 설정해준대로 변경
                    .password("test1234!")
                    .type(HikariDataSource.class) // (기본) HikariDataSource가 pool에 10개의 connection을 채워넣는다.
                    .build();
            dataSource.setMaximumPoolSize(1000); // connection 사이즈를 1000으로 설정
            dataSource.setMinimumIdle(100); // 기본 connection을 100개로 설정
            return dataSource;
        }
        // JdbcTemplate 사용을 위한 Bean 설정
        @Bean
        public JdbcTemplate jdbcTemplate(DataSource dataSource) {
            return new JdbcTemplate(dataSource);
        }
    }
    @Autowired
    CustomerJDBCRepository customerJDBCRepository;
    @Autowired
    DataSource dataSource; // 등록된 bean
    Customer newCustomer;
    EmbeddedMysql embeddedMysql;
    // 실제 DB구동 시작
    @BeforeAll
    void setup() {
        newCustomer = new Customer(UUID.randomUUID(), "test-user", "test1-user@gmail.com", LocalDateTime.now());
        var mysqlConfig = aMysqldConfig(v8_0_11) // aMysqldConfig이 Builder처럼 동작한다. -> db 생성?
                .withCharset(UTF8)
                .withPort(2215) // DB가 떠있는 것을 방지하기 위한 임의 포트 설정
                .withUser("test", "test1234!")
                .withTimeZone("Asia/Seoul") // 타임존 설정 가능
                .build();
        // config 전달 -> EmbeddedMysql 생성
       embeddedMysql = anEmbeddedMysql(mysqlConfig)
                .addSchema("test-order_mgmt", classPathScript("schema.sql")) // 스키마 추가
                .start(); // 서버 시작
        // embedded 사용 시 deleteAll() 사용 필요 x. DB가 오르내리면서 데이터가 리셋됨
    }
    @AfterAll
    void cleanup() {
        embeddedMysql.stop(); // EmbeddedMysql 멈춤
    }
?는 인덱스 기반 palce holder )? 에서 :parameter_name으로 변경된다. @Repository // 컴포넌트 대상이 되기 위해 @Repository 추가
public class CustomerNamedJDBCRepository implements CusotomerRepository {
    private static final Logger logger = LoggerFactory.getLogger(JdbcCustomerRepository.class);
    // private final JdbcTemplate jdbcTemplate; ->
    private final NamedParameterJdbcTemplate jdbcTemplate;
    private static RowMapper<Customer> customerRowMapper = (resultSet, i) -> {
        // RowNum의 이름없는 메소드 구현: (resultSet, 인덱스) return Customer
        var customerName = resultSet.getString("name");
        var email = resultSet.getString("email");
        var customerId = toUUID(resultSet.getBytes("customer_id"));
        var lastLoginAt = resultSet.getTimestamp("last_login_at") != null ?
                resultSet.getTimestamp("last_login_at").toLocalDateTime() : null;
        var createdAt = resultSet.getTimestamp("created_at").toLocalDateTime();
        return new Customer(customerId, customerName, email, lastLoginAt, createdAt);
    };
    public CustomerNamedJDBCRepository(NamedParameterJdbcTemplate jdbcTemplate) {
        this.jdbcTemplate = jdbcTemplate;
    }
    // COUNT
    @Override
    public int count() {
        // jdbcTemplate.queryForObject(sql문, Map<String, ?>, class) retrun 단일 객체
        return jdbcTemplate.queryForObject("select count(*) from customers", Collections.emptyMap(), Integer.class); // count()함수의 return타입을 설정 가능
    }
    // SELECT
    @Override
    public List<Customer> findAll() {
        // jdbcTemplate.query(sql문, RowMapper) return List<>
        return jdbcTemplate.query("select * from customers", customerRowMapper);
    }
    // SELECT
    @Override
    public Optional<Customer> findById(UUID customerId) {
        try {
            // jdbcTemplate.queryForObject(sql문, sql문에 치환될 파라미터 값, RowMapper) return 단일객체
            return Optional.ofNullable(jdbcTemplate.queryForObject("select * from customers WHERE customer_id = UUID_TO_BIN(:customerId)",
                    Collections.singletonMap("customerId", customerId.toString().getBytes()), // customerId.toString().getBytes()))
                    customerRowMapper));
        } catch (EmptyResultDataAccessException e) {
            logger.error("Got empty result", e);
            return Optional.empty();
        }
    }
    // SELECT
    @Override
    public Optional<Customer> findByName(String name) {
        try {
            // jdbcTemplate.queryForObject(sql문, sql문에 치환될 파라미터 값, RowMapper) return 단일객체
            return Optional.ofNullable(jdbcTemplate.queryForObject("select * from customers WHERE name = :name",
                    Collections.singletonMap("name", name), // customerId.toString().getBytes()))
                    customerRowMapper));
        } catch (EmptyResultDataAccessException e) {
            logger.error("Got empty result", e);
            return Optional.empty();
        }
    }
    // SELECT
    @Override
    public Optional<Customer> findByEmail(String email) {
        try {
            // jdbcTemplate.queryForObject(sql문, sql문에 치환될 파라미터 값, RowMapper) return 단일객체
            return Optional.ofNullable(jdbcTemplate.queryForObject("select * from customers WHERE email = :email",
                    Collections.singletonMap("email", email), // customerId.toString().getBytes()))
                    customerRowMapper));
        } catch (EmptyResultDataAccessException e) {
            logger.error("Got empty result", e);
            return Optional.empty();
        }
    }
    // INSERT
    @Override
    public Customer insert(Customer customer) {
        // 테이블에 추가할 row 생성
        HashMap<String, Object> paramMap = paraMap(customer);
        // jdbcTemplate.update(sql문, sql문에 치환될 파라미터 map<String, ?>) return 단일객체
        var update = jdbcTemplate.update("INSERT INTO customers(customer_id, name, email, created_at) VALUES (UUID_TO_BIN(:customerId), :name, :email, :createdAt)", // 파라미터 값을 이름으로 준다. 이름 == Map의 key값
                paramMap);
        if (update != 1) { // 추가 여부 확인
            throw new RuntimeException("Nothing was inserted");
        }
        return customer;
    }
    // UPDATE
    @Override
    public Customer update(Customer customer) {
        // 테이블에 추가할 row 생성
        HashMap<String, Object> paramMap = paraMap(customer);
        // jdbcTemplate.update(sql문, sql문에 치환될 파라미터 map<String, ?>) return 단일객체
        var update = jdbcTemplate.update("UPDATE customers SET name = :name, email = :email, last_login_at = :last_login_at WHERE customer_id = :customerId)", // 파라미터 값을 이름으로 준다. 이름 == Map의 key값
                paramMap
        );
        if (update != 1) {  // 업데이트 여부 확인
            throw new RuntimeException("Nothing was updated");
        }
        return customer;
    }
    // 각 INSERT, UPDATE에서 중복되는 row 값 설정 부분을 메소드로 구현
    private HashMap<String, Object> paraMap(Customer customer) {
        return new HashMap<>() {{
            put("customerId", customer.getCustomerId().toString().getBytes());
            put("name", customer.getName());
            put("email", customer.getEmail());
            put("createdAt", Timestamp.valueOf(customer.getCreatedAt()));
            put("lastLoginAt", customer.getLastLoginAt() != null ?  Timestamp.valueOf(customer.getLastLoginAt()) : null );
        }};
    }
    // DELETE
    @Override
    public void deleteAll() {
        // jdbcTemplate.update(sql문, map<String, ?>) return 단일객체
        jdbcTemplate.update("DELETE FROM customers", Collections.emptyMap());
    }
    static UUID toUUID(byte[] bytes) {
        var byteBuffer = ByteBuffer.wrap(bytes);
        return new UUID(byteBuffer.getLong(), byteBuffer.getLong());
    }
}
    @Configuration
    @ComponentScan(
            basePackages = {"org.prgrms.kdt.customer"}
    )
    static class Config {
        @Bean
        public DataSource dataSource() {
            var dataSource = DataSourceBuilder.create()
                    .url("jdbc:mysql://localhost:2215/test-order_mgmt") // setup()메소드에서 port를 2215로 설정해주었기 때문에 localhost:2215로 변경
                    .username("test")                                   //                 schema이름도 설정해준대로 변경
                    .password("test1234!")
                    .type(HikariDataSource.class) // (기본) HikariDataSource가 pool에 10개의 connection을 채워넣는다.
                    .build();
            dataSource.setMaximumPoolSize(1000); // connection 사이즈를 1000으로 설정
            dataSource.setMinimumIdle(100); // 기본 connection을 100개로 설정
            return dataSource;
        }
        // JdbcTemplate 사용을 위한 Bean 설정
        @Bean
        public JdbcTemplate jdbcTemplate(DataSource dataSource) {
            return new JdbcTemplate(dataSource);
        }
    }
    // JdbcTemplate을 주입받는 NamedParameterJdbcTemplate 설정
    @Bean
    public NamedParameterJdbcTemplate namedParameterJdbcTemplate(JdbcTemplate jdbcTemplate) {
        return new NamedParameterJdbcTemplate(jdbcTemplate);
    }
    // CustomerJDBCRepository -> CustomerNamedJDBCRepository
    @Autowired
    CustomerNamedJDBCRepository customerJDBCRepository;
    @Test
    @Order(2)
    @DisplayName("고객을 추가할 수 있다.")
    public void testInsert() {
        try {
            customerJDBCRepository.insert(newCustomer);
        } catch (BadSqlGrammarException e) {
            logger.error("Got BadSqlGrmmarException error code -> {}", e.getSQLException().getErrorCode(), e);
        }
        var retrievedCustomer = customerJDBCRepository.findById(newCustomer.getCustomerId());
        assertThat(retrievedCustomer.isEmpty(), is(false));
        assertThat(retrievedCustomer.get(), samePropertyValuesAs(newCustomer));
    }
👉 이전에 정리했던 내용 SQL - 트랜잭션
JdbcCustomerRepository
    // JDBC를 이용한 트랜잭션 처리
    public void transactionTest(Customer customer) {
        // name, email 변경 sql
        String updateNameSql = "UPDATE customers SET name = ? WHERE customer_id = UUID_TO_BIN(?)";
        String updateEmailSql = "UPDATE customers SET email = ? WHERE customer_id = UUID_TO_BIN(?)";
        Connection connection = null;
        try {
            connection = DriverManager.getConnection("jdbc:mysql://localhost/order_mgmt", "root", "root1234!");
            connection.setAutoCommit(false); // AutoCommit 설정 off
            try (
                    var updateNameStatement = connection.prepareStatement(updateNameSql);
                    var updateEmailStatement = connection.prepareStatement(updateEmailSql);
            ) {
                // 변경할 이름 설정
                updateNameStatement.setString(1, customer.getName());
                updateNameStatement.setBytes(2, customer.getCustomerId().toString().getBytes());
                updateNameStatement.executeUpdate();
                updateEmailStatement.setString(1, customer.getEmail());
                updateEmailStatement.setBytes(2, customer.getCustomerId().toString().getBytes());
                updateEmailStatement.executeUpdate();
                connection.setAutoCommit(true); // AutoCommit 설정 on
            }
        } catch (SQLException exception) {
            // connection이 null이 아닐 시 rollback, connection close
            if (connection != null) {
                try {
                    connection.rollback();
                    connection.close();
                } catch (SQLException throwable) {
                    logger.error("Got error while closing connection", throwable);
                    throw new RuntimeException(exception);
                }
            }
            logger.error("Got error while closing connection", exception);
            throw new RuntimeException(exception);
        }
    }
    
    
public static void main(String[] args) {
        var customerRepository = new JdbcCustomerRepository();
        // 트랜잭션 테스트 코드 실행
        customerRepository.transactionTest(
                new Customer(UUID.fromString("eba9c904-1c5c-445a-80af-13d00762f804"), "update-user", "new-user2@gmail.com", LocalDateTime.now()));
}
오류
- Embedded Mysql - Wix가
 MySQL 8.0이상에서 windows를 지원하지 않는 문제 ->mysql5.7로 테스트
이전 코드의 수정 필요 (수강생 해결공유)-- Mysql 8 이상 WHERE uuid = UUID_TO_BIN('77dea2ad-3c8c-40c6-a278-7cf1a1ac9384'); -- 이전버전 WHERE uuid = UNHEX(REPLACE('77dea2ad-3c8c-40c6-a278-7cf1a1ac9384', '-', ''));
TIP
- 최대한 function을 사용하지 않고 sql문을 작성한다면 H2만으로 TEST 코드 실행이 가능하다.
 
UUID를 활용한 문자열 저장 등을 활용하자.- 스테이징이나 환경에 접속정보 넣지 않기 (?)
 - 실제 DB가 아닌 Embedded Database에서 쿼리, 레포지토리 동작 테스트코드를 작성하는 습관을 기르자
 - NamedParameterJdbcTemplate 내부에는 JdbcTemplate이 들어있다.
 
NamedParameterJdbcTemplate를 이용한 CRUD 작성 시 update()메소드에 빈 Map을 인자로 주는 작업이 귀찮다면 그냥 JdbcTemplate의 update()를 사용해도 된다.
(👉 JdbcTemplate.getJdbcTemplate().update() )- 엔티티를 Map으로 치환하는 방법은 다양하다. 더 찾아서 공부하면 좋을 듯
 
추가 공부
- EmbeddedDatabaseBuilder의
 generateUniqueName(boolean flag)메소드의 기능? (리더 답변)
- 하나의 JVM안에서 여러 어플리케이션 컨텍스트가 만들어질때 개별로 embedded database를 할당하기 위해 사용
 - 대체로 embedded database는 테스트에서 사용되기 때문에, 테스트 시 동시에 여러 개의 컨텍스트가 만들어지면서 테스트가 되는 환경에서 서로 독립적인 db를 가지게 하기 위함
 
rf