MyBatis

[MyBatis] 02. CRUD

  • -
반응형

MyBatis를 이용한 CRUD

이번 포스트에서는 MyBatis를 이용한 CRUD(Create, Read, Update, Delete)쿼리 처리를 통해 기본 사용법을 익혀보자.

MyBatis도 오랜 기간 사용되면서 다양한 방식으로 사용되는데 전체적인 동작을 이해하기 위해 가장 기본적인 형태인  repo interface -> repo interface impl -> mapper.xml의 형태와 naming rule을 적용해서 repo interface impl.을 제외하고 repo interface -> mapper.xml 형태로 간단히 사용하는 형태 두 가지를 살펴볼 계획이다.

참고로 이 외에도 mapper xml 없이 그냥 repo interface에 애너테이션으로 작성하기도 한다.

 

일단 다음의 그림을 다시 한번 기억해두자.

 

먼저 자료 추가에 대해서 상세히 살펴보고 나머지는 동일한 흐름으로 이해해보자.

 

자료 추가를 위한  mapper.xml 작성

mapper는 xml 형태로 sql 문장을 작성하는 파일로 일반적으로 테이블 당 하나의 mapper가 작성된다. mapper 파일의 위치는 application.properties에서 mybatis.mapper-locations에 설정한 경로로 한다.

 

<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
 
<mapper namespace="com.eshome.db.model.repo.CountryRepo">
    <insert id="insert" parameterType="Country">
    	insert into country 
    	  <!-- 테이블에 있는 컬럼 이름들 -->
    	  (Code,Name,Continent,Region,SurfaceArea,IndepYear,Population,LifeExpectancy,
    	  GNP,GNPOld,LocalName,GovernmentForm,HeadOfState,Capital,Code2)
    	<!-- value의 값들은 파라미터인 Country의 속성 명 -->
    	values(#{code},#{name},#{continent},#{region},#{surfaceArea},#{indepYear},#{population},#{lifeExpectancy}
    	  ,#{GNP}, #{GNPOld},#{localName},#{governmentForm},#{headOfState},#{capital},#{code2}
    	 )
    </insert>
</mapper>

 

mapper.xml 구성

xml 파일의 내용에 대해 알아보자.

  • <mapper>
    • mapper xml의 root element이므로 반드시 하나 존재한다.
    • namespace: 각각의 mapper들을 구별하기 위한 이름으로 RepoImpl에서 사용된다.
  • <insert>
    • 처리하려는 작업의 성격에 따라 <insert>, <update>, <delete>, <select> 가 존재한다.
    • id: mapper 내에서 고유한 값으로 namespace + '.' + id가 mapped statement의 이름이 된다.
    • parameterType: sql 문장에서 사용하기 위한 값으로 태그에 전달될 파라미터의 타입이다.

 

파라미터 타입

MyBatis는 Java에서 사용하는 모든 데이터 형을 파라미터로 처리할 수 있으며 이에 대한 별칭을 함께 제공한다.

 

이 외에 사용자 정의 클래스(JavaBeans 형태의 DTO)가 많이 사용된다. DTO를 사용할 때는 패키지 이름을 포함한 fully qualified name을 사용하는데 application.properties에서 type-aliases-package를 지정했기 때문에 간략히 클래스 이름만 사용해도 된다.

mybatis.type-aliases-package=com.eshome.db.model.dto

 

SQL 내부에서의 파라미터 표기법

MyBatis에서 SQL을 작성하면서 전달된 파라미터를 사용할 때 #{parameter_name} 표현식을 사용한다. 이 표현식의 사용은 결과적으로 PreparedStatement에서 ? 를 이용해서 파라미터를 처리하는것과 같다.

#{param_name} 내부에 들어가는 키워드는 parameterType에 따라 다르다.

  • parameterType이 기본형, String 처럼 하나일 경우 param_name에는 아무런 값이나 사용 가능하다.
  • parameterType이 JavaBeans라면  #{JavaBeans의 속성명}형태를 사용한다.
  • parameterType이 Map 계열이면 #{Map의 key}을 사용한다.

 

RepoImpl 클래스에서의 활용

이제 @Repository와 mapper.xml을 연동해서 사용해보자.

@Repository
public class CountryRepoImpl implements CountryRepo {
    // mapper.xml에 선언한 namespace + .
    private static final String ns = "com.eshome.db.model.repo.CountryRepo.";
    
    @Autowired
    // mapper의 sql을 실행하기 위한 객체
    SqlSessionTemplate template;

    @Override
    public int insert(Country country) {
        // ns + mapper.xml의 태그 id로 mapped statement 호출
        return template.insert(ns + "insert", country);
    }
}

 

  • String ns: mapper.xml의 namespace 속성에 '.'을 붙여서 만든다. mapped statement를 호출할 때 사용된다.
  • SqlSessionTemplate: MyBatis의 SqlSession에 대한 빈으로 mapped statement를 호출하는 메서드들을 가진다.
    • SQL 성격에 따라 insert, update, delete, selectOne, selectList등 메서드를 사용할 수 있다.
  • template의 메서드를 호출할 때는 String statement를 넘겨주는데 이것을 이용해서 mapper.xml의 태그를 연결한다.
    • statement의 형태는 namespace + '.' + tag id로 구성된다.

 

단위테스트를 통한 동작 확인

단위테스트를 통해 위 메서드가 잘 동작하는지 확인해보자.

단위테스트 작성

@SpringBootTest
@Slf4j
public class CountryRepoTest {

    @Autowired
    CountryRepo cRepo;

    @Test
    public void insertTest() {
        Country country = new Country("ATL", "Atlantis", "Africa");
        int result = cRepo.insert(country);
        assertEquals(result, 1);
    }
}
참고로 Country 테이블의 인코딩은 라틴으로 한글 데이터를 넣을 수 없다.

 

실행 결과 데이터가 잘 등록된 것을 확인할 수 있다.

08:17:03 [ INFO] [c.z.h.HikariDataSource.getConnection-123] > HikariPool-1 - Start completed.
08:17:03 [DEBUG] [c.e.d.m.r.C.insert.debug-137] > ==>  Preparing: insert into country 
  (Code,Name,Continent,Region,SurfaceArea,IndepYear,Population,LifeExpectancy, GNP,GNPOld,
    LocalName,GovernmentForm,HeadOfState,Capital,Code2) 
  values(?,?,?,?,?,?,?,? ,?, ?,?,?,?,?,? )
08:17:03 [DEBUG] [c.e.d.m.r.C.insert.debug-137] > ==> Parameters: ATL(String), Atlantis(String),
  Africa(String), (String), 0.0(Double), null, 0(Long), null, null, null, 
  (String), (String), null, null, (String)
08:17:03 [DEBUG] [c.e.d.m.r.C.insert.debug-137] > <==    Updates: 1
08:17:03 [ INFO] [c.z.h.HikariDataSource.close-350] > HikariPool-1 - Shutdown initiated...

실행 결과 출력된 로그를 살펴보면 PreparedStatement 형태의 쿼리가 준비되고 넘겨준 파라미터가 잘 설정되는 것을 확인할 수 있다. 최종 실행 결과는 1개의 행이 업데이트 되었다.

 

@Transactional

위 에서 실행했던 단위테스트를 다시한번 실행하면 어떻게 될까?

 

Country 테이블의 Code 컬럼은 Primary key인데 'ATL' 이 존재하는 상황에서 또다시 동일한 Code가 들어오기 때문에 DuplicateKeyException이 발생하는 너무도 당영한 상황이다. 그럼 단위테스트를 실행할 때마다 매번 새로운 값을 넣어줘야한다는 이야기일까?

그런데 우리가 집중할 것은 이번 단위테스트를 통해 데이터가 들어가는지 확인해보는게 목적이었지 데이터를 저장할 계획은 없었다. 즉 유지할 필요는 없는 것이다.

이때 @Transactional 애너테이션을 테스트 메서드에 사용해보자.

@Transactional
public void insertTest() {
    Country country = new Country("ATL", "Atlantis", "Africa");
    int result = cRepo.insert(country);
    assertEquals(result, 1);
}

 

기존에 저장된 데이터를 지운 후 다시 단위테스트를 실행해보자.

 

delete from country where code='atl';
commit;
select * from country where code='atl';

 

출력된 로그를 살펴보면 아까와는 달리 Transaction과 관련된 내용이 추가되며 insert 작업이 끝나고 rollback 시켜버리는 것을 확인할 수 있다.

 

08:28:16 [ INFO] [c.z.h.HikariDataSource.getConnection-123] > HikariPool-1 - Start completed.
08:28:16 [ INFO] [o.s.t.c.t.TransactionContext.startTransaction-107] > Began transaction (1) ~~~
08:28:16 [DEBUG] [c.e.d.m.r.C.insert.debug-137] > ==>  Preparing: insert into country (Code,Name,Continent,Region,SurfaceArea,IndepYear,Population,LifeExpectancy, GNP,GNPOld,LocalName,GovernmentForm,HeadOfState,Capital,Code2) 
                                                       values(?,?,?,?,?,?,?,? ,?, ?,?,?,?,?,? )
08:28:16 [DEBUG] [c.e.d.m.r.C.insert.debug-137] > ==> Parameters: ATL(String), Atlantis(String), Africa(String), (String), 0.0(Double), null, 0(Long), null, null, null, (String), (String), null, null, (String)
08:28:16 [DEBUG] [c.e.d.m.r.C.insert.debug-137] > <==    Updates: 1
08:28:16 [ INFO] [o.s.t.c.t.TransactionContext.endTransaction-139] > Rolled back transaction ~~~
08:28:16 [ INFO] [c.z.h.HikariDataSource.close-350] > HikariPool-1 - Shutdown initiated...

 

이제 아무리 단위테스트를 반복한다고 하더라도 Database의 상태를 변경시키지 않고 우리의 목적을 달성할 수 있게 되었다.

 

RepoImpl의 생략

하나만 만들어보았지만 CountryRepoImpl은 단순히 자바 메서드와 mapper.xml의 태그를 연결하는 역할만을 수행한다.

이에 MyBatis는 CountryRepo(앞으로 mapper interface 라고 부른다.)와 mapper.xml에서 몇 가지 조건만 일치시켜 준다면 구지 CountryRepoImpl 같은 구현체를 만들지 않아도 상관없게 만들어준다.

 

naming rules

  • mapper interface의 package + class 이름은 mapper.xml의 namespace와 일치시킨다.
  • mapper interface의 메서드 이름, 파라미터는 mapper.xml의 태그 아이디, parameterType과 일치시킨다.

 

이렇게 하면 SqlSessionTemplate을 통해서 하던 호출 작업은 적절한 추정을 통해서 MyBatis가 자동으로 처리한다.

 

XXRepoImpl 제거

이제 CountryRepoImpl은 불필요해졌으므로 삭제하거나 뭔가 아깝다면 @Repository를 주석 처리해서 빈으로써의 지위만 박탈해버리자.

// @Repository
public class CountryRepoImpl implements CountryRepo { . . . }

 

@MapperScan

이제 @Repository 클래스 즉 구현체가 없는데 어떻게 동작시킬 것인가? 스프링에게 mapper interface의 위치만 알려주면 이후는 내부적으로 proxy를 만들고 알아서 처리한다. 

mapper interface의 위치를 지정할 때는  @Configuration에 @MapperScan을 통해서 mapper interface의 위치를 지정할 수 있다.

@SpringBootApplication
@MapperScan(basePackageClasses = {CountryRepo.class})
public class BootCh04MybatisApplication {
    public static void main(String[] args) {
        SpringApplication.run(BootCh04MybatisApplication.class, args);
    }
}

 

그리고 스캔될 개별 mapper interface에 @Mapper 를 추가한다.(optional)

@Mapper
public interface CountryRepo {
    int insert(Country country);
    ...
}    

 

이제 단위테스트를 다시 실행해보면 RepoImpl이 있을때와 동일하게 동작함을 확인할 수 있다.

 

장.단점

이처럼 몇 가지 naming rule과 @MapperScan으로 XXRepoImpl 들을 제거할 수 있었는데 장점은 당연히 코드의 간결화이다.  이를 통한 유지 보수성의 향상은 기본이고 불필요한 클래스를 지워버릴 때 오는 일종의 쾌감은 덤이다.

하지만 메서드 호출 과정에서 파라미터가 잘 전달되는지 등 디버깅이 어려워지는 단점도 존재한다.

아무튼 앞으로는 주요 설명은 RepoImpl을 구현하지 않는 형태로 진행하고 RepoImpl에서의 구현은 예제 코드만 첨부할 계획이다.

 

단일건 조회

select는 여러 건의 데이터가 반환되는 목록 조회(0 건 이상)와 Primary Key를 이용하는 단일건 조회(0 or 1건) 두 종류로 나눌 수 있다.

먼저 primary key를 이용하는 단일건 조회에 대해 살펴보자

mapper.xml 

다음은 Country 테이블의 primary key인 Code를 이용한 select 태그의 예이다.

<select id="select" resultType="country" parameterType="map">
    select * from country where code=#{code}
</select>
  • 새로 추가된 속성으로 resultType이 있는데 쿼리 실행 후 결과가 담겨서 반환될 타입을 의미한다. resultType의 종류 및 사용법은 parameterType과 동일하다.
  • parameterType으로는 map이 사용되었다. 당연히 country를 전달해도 되는데 그냥 map의 사용 예를 보여주고 싶었다.
  • 조회 결과를 매핑하기 위한 작업이 없이 바로 DTO에 설정되는 것은 매우 편리한 일이다.

 

mapper interface

mapper interface에는 위 태그와 연결하기 위해 다음의 메서드가 필요하다.

public interface CountryRepo {
    int insert(Country country);
    // PK를 이용한 단일건 조회
    Country select(Map<String, Object> condition);
}

 

만약 CountryRepoImpl을 사용한다면 아래와 같이 select를 override 한다. P.K를 이용한 조회를 처리할 때에는 selectOne을 사용한다.

@Override
public Country select(Map<String, Object> condition) {
    return template.selectOne(ns+"select", condition);
}

 

단위테스트 수행

단위테스트로 결과를 확인해보자.

@Test
public void selectTest() {
    Map<String, Object> condition = new HashMap<>();
    condition.put("code", "KOR");
    Country country = cRepo.select(condition);
    log.trace("조회결과:{}", country);
    assertEquals(country.getName(), "South Korea");
}

select는 transaction을 유발하지 않기 때문에 @Transaction은 사용할 필요가 없다.

 

목록 조회

이번에는 여러 건의 데이터가 반환될 수 있는 목록 조회의 경우를 살펴보자.

mapper.xml

Country 테이블의 limit를 이용해서 from 부터 cnt 개의 데이터를 조회하기 위한 태그를 구성해보자.

<select id="selectRange" resultType="Country" parameterType="map">
    select * from country limit #{from} , #{perPage}
</select>

limit는 mysql에서 paging의 기초가 되는 구문이다.  파라미터로 전달된 map에는 from과 perPage가 키 값으로 설정되어 있어야 한다.

 

mapper interface

위 태그를 사용하기 위한 mapper interface의 메서드는 다음과 같다. 여러 건의 데이터를 반환 받아야 하기 때문에 return type이 List<Country>로 선언된 점을 주목하자.

List<Country> selectRange(Map<String, Object> condition);

CountryRepoImpl을 유지한다면 아래와 같이 selectRange를 override한다. 여러 개의 결과를 가져올 때는 selectList를 사용한다.

@Override
public List<Country> selectRange(Map<String, Object> condition) {
    return template.selectList(ns + "selectRange", condition);
}

 

단위테스트

테스트를 수행하고 원하는 결과가 오는지 확인해보자.

@Test
public void selectRangeTest() {
    Map<String, Object> condition = new HashMap<>();
    condition.put("from", 50);
    condition.put("perPage", 5);
    List<Country> selected = cRepo.selectRange(condition);
    assertEquals(selected.size(), 5);
}

조회 결과 원하는 범위의 값만 반환함을 알 수 있다.

 

자료 수정

다음으로 update 문장을 처리해보자.

mapper.xml

<update id="update" parameterType="Country">
update country
set Name=#{name}, Continent=#{continent}, Region=#{region}, SurfaceArea=#{surfaceArea},
    IndepYear=#{indepYear}, Population=#{population}, LifeExpectancy=#{lifeExpectancy},
    GNP=#{GNP}, GNPOld=#{GNPOld}, LocalName=#{localName}, GovernmentForm=#{governmentForm},
    HeadOfState=#{headOfState}, Capital=#{capital}, Code2=#{code2}
where Code=#{code}
</update>

태그가 자료 수정을 의미하는 update인 점만 주의하면 일반적인 sql의 update 문장이다.

 

mapper interface

insert 때와 유사하게 int 를 반환 받는 구조로 작성한다.

int update(Country country);

 

만약 mapper interface의 impl을 유지한다면 아래처럼 override 한다.

@Override
public int update(Country country) {
    return template.update(ns + "update", country);
}

 

단위테스트

다음으로 단위테스트를 수행한다.

@Test
@Transactional
public void UpdateTest() {
    String newName = "newName";
    Map<String, Object> condition = new HashMap<>();
    condition.put("code", "KOR");
    Country country = cRepo.select(condition);
    // 조회 결과의 name을 newName으로 변경
    country.setName(newName);
    // 업데이트 처리
    cRepo.update(country);
    // 다시 한번 조회 후 name 확인
    country = cRepo.select(condition);
    assertEquals(country.getName(), newName);
}

 

update는 DML 문장이기 때문에 @Transactional 하에서 수행해야 테스트 후 rollback이 수행된다. 테스트 개요는 먼저 code가 KOR인 데이터를 조회 후 name을 newName으로 변경한 후 수정한다. 다시 KOR을 조회했을 때 수정한 name인 newName이 조회된다면 성공이다.

 

자료 삭제

마지막으로  delete를 처리해보자.

mapper.xml

<delete id="delete" parameterType="string">
    delete from country where code=#{code}
</delete>

태그의 이름이 delete인 점만 주의하면 일반적인 sql이다. 여기서는 parameterType이 string이므로 단일 값이다. 따라서 #{} 내부에는 아무런 이름이나 사용할 수 있다.

 

mapper interface

다음은 mapper interface에 선언되는 메서드 이다.

int delete(String code);

mapper interface impl을 유지한다면 아래와 같이 재정의 한다.

@Override
public int delete(String code) {
    return template.delete(ns+"delete", code);
}

 

단위테스트

마지막 단위테스트를 살펴보자.

@Test
@Transactional
public void deleteTest() {
    Country country = new Country("ATL", "Atlantis", "Africa");
    int result = cRepo.insert(country);
    cRepo.delete(country.getCode());
    Map<String, Object> condition = new HashMap<>();
    condition.put("code", country.getCode());
    country = cRepo.select(condition);
    assertNull(country);
}

 

Delete 역시 DML이기 때문에 @Transactional에서 단위테스트를 처리한다.

테스트 시나리오는 먼저 ATL 에 해당하는 데이터를 삽입 후 delete를 호출해서 삭제한다. 다시 ATL에 해당하는 데이터를 조회해보면 null이 나오면 정상이다.(삽입이나 조회는 앞서 테스트 했기 때문에 의심할 필요가 없다.)

반응형

'MyBatis' 카테고리의 다른 글

[MyBatis] 05. 기타  (0) 2023.06.18
[MyBatis] 04. 동적 쿼리  (0) 2023.06.18
[MyBatis] 03. 조회 결과의 매핑  (0) 2023.06.18
[MyBatis] 01. 소개 및 환경 설정  (2) 2023.06.18
Spring + mybatis + PageHelper  (4) 2020.06.23
Contents

포스팅 주소를 복사했습니다

이 글이 도움이 되었다면 공감 부탁드립니다.