MyBatis

[MyBatis] 03. 조회 결과의 매핑

  • -
반응형

조회 결과의 매핑

이번 포스트에서는 database에서 select로 조회한 내용을 java object 즉  DTO에 매핑하는 내용에 대해 다양하게 살펴보자.

 

MyBatis의 조회 결과 매핑 방식

MyBatis는 다양한 방식으로 조회 결과를 DTO에 매핑시킬 수 있다.

기본 매핑

데이터베이스 테이블의 컬럼명과 DTO의 속성 명이 일치하면 자동으로 매핑이 된다. 이때 대소문자는 구별하지 않는다.

select code, name, continent, region, surfacearea from country
@Data
public class Country {
    private String code;
    private String name;
    private String continent;
    private String region = "";
    private Double surfaceArea = 0.0;
}

즉 위와 같은 경우는 신경쓸 부분이 없다. 아주 행복한 케이스이다.

 

alias를 이용한 컬럼 명 변경

하지만 위와 같은 경우는 좀 드문 경우이다.

일반적으로 DB는 naming rule로 _로 연결되는 snake case를 사용하지만 자바에서는 camel case를 사용한다. 예를 들어 surfaceArea에 대한 컬럼명 또는 속성 명을 잡을 때 DB는 전통적으로 surface_area라고 쓰고 자바는 전통적으로 surfaceArea라고 쓰기 때문에 기본 매핑이 되지 않는다.

MySql의 world 처럼 공식적으로 제공되는 샘플 데이터베이스에서 snake case를 사용하지 않는 경우는 처음 본다.

world와 마찬가지로 MySql의 샘플 데이터베이스인 sakila의 actor 테이블을 살펴보자. 컬럼 이름은 snake case로 작성되어있다.

 

이에 대한 DTO는 아래 처럼 camel case로 작성된다면 컬럼 명과 속성명이 불일치해서 매핑되지 않는다.

@Data
public class Actor {
    private Integer actorId;
    private String firstName;
    private String lastName;
    private Date lastUpdate;
}

 

이때 가장 쉽게 생각할 수 있는 방법은 조회 시 alias를 이용해서 컬럼명과 속성명을 맞춰주는 것이다.

select actor_id actorId, first_name firstName, last_name lastName, last_update lastUpdate
from actor

 

그런데 만약 select 쿼리가 여기 저기 엄청 많다면 어떨까? 쿼리마다 쫒아다니면서 alias를 달아줘야 할 것이다. 더군다나 DTO의 속성명이 변경되기라도 한다면.. 퇴근은 요원하다.

 

naming rule을 모두 잘 지켰다면

DB 개발자와 자바 개발자가 모두 명명 규칙을 완벽히 잘 지켰다면 매핑은 간단히 처리가 가능하다. 다음 속성을 application.properties에 추가시켜서 한방에 매핑 시킬 수 있다. 더 이상 고민하지 않아도 된다. 집에 제일 빨리 갈 수 있는 길이다.

mybatis.configuration.map-underscore-to-camel-case=true

주의할 점은 이 설정에는 눈이 없기 때문에 무조건 변경한다는 점이다. 만약 DTO의 속성 이름이 firstName이었다면 테이블의 컬럼 이름은 반드시 first_name이어야 한다.

하지만 이미 컬럼명이 제멋대로 만들어진 테이블을 이용해야 하거나 DTO에서 전혀 의외의 속성 명을 사용한다면 위 설정으로 매핑되지 않기 때문에 매우 곤란하다.

 

resultMap 활용

궁긍적으로 안정적인 매핑을 위해서는 resultMap을 이용해서 매핑 룰을 작성해 놓는 것이다. 

다음은 Country 테이블에 대한 resultMap의 예이다.

<resultMap type="Country" id="countryBase">
	<id column="Code" property="code"/>
	<result column="Name" property="name"/>
	<result column="Continent" property="continent"/>
	<result column="Region" property="region"/>
	<result column="SurfaceArea" property="surfaceArea"/>
	<result column="IndepYear" property="indepYear"/>
	<result column="Population" property="population"/>
	<result column="LifeExpectancy" property="lifeExpectancy"/>
	<result column="GNP" property="GNP"/>
	<result column="GNPOld" property="GNPOld"/>
	<result column="LocalName" property="localName"/>
	<result column="GovernmentForm" property="governmentForm"/>
	<result column="HeadOfState" property="headOfState"/>
	<result column="Capital" property="capital"/>
	<result column="Code2" property="code2"/>
</resultMap>

 

  • <resultMap>은 하나의 매핑 정보를 작성한다.
    • type은 매핑 결과가 담길 DTO의 타입이다.
    • id는 resultMap을 구별하는 이름으로 unique 해야 한다.
  • <id>는 primary key에 해당하는 컬럼을 설정하는 엘리먼트이다.
    • column 속성은 테이블의 컬럼 명을 적는다.
    • property 속성은 조회 결과가 매핑될 DTO의 속성 이름이다.
  • <result>는 primary key가 아닌 일반 컬럼에 대한 매핑을 처리하며 속성은 <id>와 동일하다.

 

resultMap을 사용할 때는 returnType 대신 resultMap 속성에 사용하려는 resultMap의 id를 넘겨주면 된다.

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

위 내용처럼 returnType을 resultMap으로 변경하고 단위 테스트를 수행해보자.

 

City 테이블에 대한 Repo 작성

이제까지의 학습 내용을 바탕으로 City 테이블에 대한 Data Access layer를 작성해보자.

mapper.xml

먼저 mapper.xml이다. 여기서는 단순히 조회만 처리할 예정이기 때문에 P.K를 통한 조회와  국가 코드별 조회 두 <select>만 구성해보자. 결과의 매핑을 위해서는 resultMap을 사용한다.

<mapper namespace="com.eshome.db.model.repo.CityRepo">
	<select id="select" resultMap="cityBase" parameterType="int">
		select * from city where id=#{id}
	</select>

	<select id="selectByCountry" resultMap="cityBase" parameterType="string">
		select * from city where countryCode=#{code}
	</select>
	
	<resultMap type="City" id="cityBase">
		<id column="id" property="id"/>
		<result column="Name" property="name"/>
		<result column="CountryCode" property="countryCode"/>
		<result column="District" property="district"/>
		<result column="Population" property="population"/>
	</resultMap>
</mapper>

 

mapper interface

다음은 mapper interface이다.

public interface CityRepo {
    City select(Integer id);

    List<City> selectByCountry(String code);
}

 

단위테스트

단위테스트로 동작을 점검해보자.

@SpringBootTest
@Slf4j
public class CityRepoTest {

    @Autowired
    CityRepo cRepo;

    @Test
    public void selectTest() {
        City city = cRepo.select(2331);
        assertEquals(city.getName(), "Seoul");
    }

    @Test
    public void selectByCountryTest() {
        List<City> selected = cRepo.selectByCountry("KOR");
        assertEquals(selected.size(), 70);
    }
}

참고로 KOR로 등록된 자료의 개수는 총 70개이다.


이제 좀 더 복잡한 Join 문장에서의 매핑에 대해 알아보자.

has-one 관계의 처리

City는 반드시 하나의 Country에 연결된다. 즉 City와 Country는 has-one의 관계이다. City와 함께 City가 속한 Country의 정보를 조회하기 위해서는 Country와 City를 Join 해서 조회해야 한다. 이 결과를 City에 매핑하기 위한 방법을 살펴보자.

DTO의 수정

기존의 City는 CountryCode를 통해서 Country를 연결할 수는 있었지만 상세 정보를 갖지는 못했다. City가 Country 정보를 가지려면 어떻게 하면 좋을까? 

방법은 매우 간단하다. 이미 Country에 대한 모델링 결과로 Country DTO가 만들어졌기 때문에 Country를 맴버로 갖기만 하면 된다.

아래 처럼 City에 Country를 추가해주자.

@Data
@NoArgsConstructor
@RequiredArgsConstructor
public class City {
    @NonNull
    private Integer id;
    @NonNull
    private String name;
    @NonNull
    private String countryCode;
    private String district;
    private Integer population;
    // Country의 상세 정보를 저장하기 위함
    private Country country;
}

 

mapper.xml

join 쿼리의 결과를 City에 연결해주기 위해서도 resultMap을 사용한다. 이 과정에서 has-one 관계에 대한 처리를 위해 <association> 엘리먼트를 사용할 수 있다.

  • <association>: has-one 관계를 설정하기 위한 <resultMap>의 하위 엘리먼트
    • property 속성: DTO에서 관계에 사용되는 속성 이름
    • javaType: 연동될 객체의 타입
    • columnPrefix: 두 테이블의 컬럼 이름이 동일해서 구별해야 할 경우 prefix를 적용해서 alias 구성
    • resultMap: 다른 resultMap을 재사용할 때 그 resultMap의 id(다른 mapper일 경우 namespace 포함)
<resultMap type="City" id="cityDetail">
	<id column="id" property="id" />
	<result column="Name" property="name" />
	<result column="CountryCode" property="countryCode" />
	<result column="District" property="district" />
	<result column="Population" property="population" />
	<association property="country" javaType="Country" columnPrefix="co_" >
		<id column="Code" property="code" />
		<result column="Name" property="name" />
		<result column="Continent" property="continent" />
		<result column="Region" property="region" />
		<result column="SurfaceArea" property="surfaceArea" />
		<result column="IndepYear" property="indepYear" />
		<result column="Population" property="population" />
		<result column="LifeExpectancy" property="lifeExpectancy" />
		<result column="GNP" property="GNP" />
		<result column="GNPOld" property="GNPOld" />
		<result column="LocalName" property="localName" />
		<result column="GovernmentForm" property="governmentForm" />
		<result column="HeadOfState" property="headOfState" />
		<result column="Capital" property="capital" />
		<result column="Code2" property="code2" />
	</association>
</resultMap>

 

그런데 resultMap을 구성하고 있는 매핑 정보들은 앞서 xxBase 형태로 이미 만든 경험이 있다. 이들을 재사용하면 좋지 않을까?

<resultMap type="City" id="cityDetail" extends="cityBase">
    <association property="country" javaType="Country"  columnPrefix="co_" 
                 resultMap="com.eshome.db.model.repo.CountryRepo.countryDetail"></association>
</resultMap>

<resultMap>의 extends 속성은 다른 resultMap을 확장할 수 있게 해준다. extends에 할당하는 값은 다른 resultMap의 id이다.

<association>의 resultMap은 다른 resultMap을 id를 이용해서 참조할 수 있는데 만약 참조하려는 resultMap이 다른 mapper.xml에 있을 경우 해당 mapper의 namespace 까지 추가해준다.

이제 매핑을 위한 resultMap이 준비되었으니 <select>에서 join 쿼리의 결과로 사용해보자.

<select id="selectDetail" resultMap="cityDetail" parameterType="int">
    select ci.*, 
        co.Code as co_Code,
        co.Name as co_Name,
        co.Continent as co_Continent,
        co.Region as co_Region,
        co.SurfaceArea as co_SurfaceArea,
        co.IndepYear as co_IndepYear,
        co.Population as co_Population,
        co.LifeExpectancy as co_LifeExpectancy,
        co.GNP as co_GNP,
        co.GNPOld as co_GNPOld,
        co.LocalName as co_LocalName,
        co.GovernmentForm as co_GovernmentForm,
        co.HeadOfState as co_HeadOfState,
        co.Capital as co_Capital,
        co.Code2 as co_Code2
    from city ci join country co on ci.countryCode=co.code
    where ci.id=#{id}
</select>

 

mapper interface

mapper interface인 CityRepo에는 selectDetail을 추가한다.

public interface CityRepo {
    City select(Integer id);

    List<City> selectByCountry(String code);
    // join 결과 처리
    City selectDetail(Integer id);
}

 

단위테스트

마지막 단위테스트를 통해 조회된 결과를 살펴보자.

@Test
public void selectDetailTest() {
    City city = cRepo.selectDetail(2331);
    assertEquals(city.getName(), "Seoul");
    log.trace("city: {}", city);
}

 

City의 출력 결과에 Country 정보까지 잘 담긴것을 확인할 수 있다.

10:09:51 [DEBUG] [c.e.d.m.r.C.selectDetail.debug-137] > ==>  Preparing: select ci.*, co.Code as co_Code, co.Name as co_Name, co.Continent as co_Continent, co.Region as co_Region, co.SurfaceArea as co_SurfaceArea, co.IndepYear as co_IndepYear, co.Population as co_Population, co.LifeExpectancy as co_LifeExpectancy, co.GNP as co_GNP, co.GNPOld as co_GNPOld, co.LocalName as co_LocalName, co.GovernmentForm as co_GovernmentForm, co.HeadOfState as co_HeadOfState, co.Capital as co_Capital, co.Code2 as co_Code2 from city ci join country co on ci.countryCode=co.code where ci.id=?
10:09:51 [DEBUG] [c.e.d.m.r.C.selectDetail.debug-137] > ==> Parameters: 2331(Integer)
10:09:51 [TRACE] [c.e.d.m.r.C.selectDetail.trace-143] > <==    Columns: ID, Name, CountryCode, District, Population, co_Code, co_Name, co_Continent, co_Region, co_SurfaceArea, co_IndepYear, co_Population, co_LifeExpectancy, co_GNP, co_GNPOld, co_LocalName, co_GovernmentForm, co_HeadOfState, co_Capital, co_Code2
10:09:51 [TRACE] [c.e.d.m.r.C.selectDetail.trace-143] > <==        Row: 2331, Seoul, KOR, Seoul, 9981619, KOR, South Korea, Asia, Eastern Asia, 99434.0, 1948, 46844000, 74.4, 320749.0, 442544.0, Taehan MinÂ’guk (Namhan), Republic, Kim Dae-jung, 2331, KR
10:09:51 [DEBUG] [c.e.d.m.r.C.selectDetail.debug-137] > <==      Total: 1
10:09:51 [TRACE] [c.e.r.CityRepoTest.selectDetailTest- 39] > city: City(id=2331, name=Seoul, countryCode=KOR, district=Seoul, population=9981619, country=Country(code=KOR, name=South Korea, continent=Asia, region=Eastern Asia, surfaceArea=99434.0, indepYear=1948, population=46844000, lifeExpectancy=74.4, GNP=320749.0, GNPOld=442544.0, localName=Taehan MinÂ’guk (Namhan), governmentForm=Republic, headOfState=Kim Dae-jung, capital=2331, code2=KR, cities=[]))

 

has-many 관계의 처리

이제 has-many의 관계 처리에 대해 알아보자. 하나의 Country에는 여러 개의 City 정보들이 담겨있다. has-one 관계처럼 하나 하나 풀어보자.

DTO 수정

Country에 City 정보를 담기 위해서는 has-one 관계에서 처럼 City를 속성으로 추가하면 된다. 단 여러 개의 City가 연결될 수 있기 때문에 List<City>처럼 목록이 되어야 할 것이다.

@Data
@NoArgsConstructor
@RequiredArgsConstructor
public class Country {
    @NonNull
    private String code;
    @NonNull
    private String name;
    @NonNull
    private String continent;
    ...
    // has-many 관계의 처리
    private List<City> cities;
}

 

mapper.xml

has-many 관계를 표현하기 위해서는 resultMap의 하위 엘리먼트로 <collection>을 사용한다.

  • <collection>: has-many 관계를 처리하기 위한 <resultMap>의 하위 엘리먼트
    • property 속성: DTO에서 관계에 사용되는 속성 이름
    • ofType: List에 담길 N에 해당하는 객체의 타입
    • resultMap: 다른 resultMap을 재사용할 때 그 resultMap의 id(다른 mapper일 경우 namespace 포함)
    • columnPrefix: 두 테이블의 컬럼 이름이 동일해서 구별해야 할 경우 prefix를 적용해서 alias 구성

 

기본적인 내용은 <association>과 유사한데 객체를 연동할 때 javaType이 아닌 ofType임을 주의하자.

다음은 <collection>의 사용 예이다.

<resultMap type="Country" id="countryDetail">
	<id column="Code" property="code" />
	<result column="Name" property="name" />
	<result column="Continent" property="continent" />
	<result column="Region" property="region" />
	<result column="SurfaceArea" property="surfaceArea" />
	<result column="IndepYear" property="indepYear" />
	<result column="Population" property="population" />
	<result column="LifeExpectancy" property="lifeExpectancy" />
	<result column="GNP" property="GNP" />
	<result column="GNPOld" property="GNPOld" />
	<result column="LocalName" property="localName" />
	<result column="GovernmentForm" property="governmentForm" />
	<result column="HeadOfState" property="headOfState" />
	<result column="Capital" property="capital" />
	<result column="Code2" property="code2" />
	<collection property="cities"  ofType="City" columnPrefix="ci_" >
		<id column="id" property="id" />
		<result column="Name" property="name" />
		<result column="CountryCode" property="countryCode" />
		<result column="District" property="district" />
		<result column="Population" property="population" />
	</collection>
</resultMap>

당연히 이 코드도 <association>에서 처럼 기존 resultMap의 재사용 형태로 변경할 수 있다.

<resultMap type="Country" id="countryDetail" extends="countryBase">
  <collection property="cities"  ofType="City" columnPrefix="ci_" 
              resultMap="com.eshome.db.model.repo.CityRepo.cityBase"></collection>
</resultMap>

 

이제 countryDetail을 이용해서 selectDetail을 위한 <select>를 작성해보자.

<select id="selectDetail" resultMap="countryDetail" parameterType="string">
    select 
        co.*, 
        ci.id as ci_id, 
        ci.name as ci_name, 
        ci.countrycode as ci_countrycode, 
        ci.district as ci_district, 
        ci.population as ci_population
    from country co left join city ci on co.code=ci.countryCode
    where co.code=#{code}
</select>

 

mapper interface

mapper interface인 CountryRepo에는 selectDetail을 추가한다.

Country selectDetail(String code);

 

단위테스트

마지막 단위테스트를 통해 조회된 결과를 살펴보자.

@Test
public void selectDetailTest() {
    Country selected = cRepo.selectDetail("KOR");
    log.trace("selected: {}", selected);
    assertEquals(selected.getCities().size(), 70);
}

 

Country에 속한 도시 70개의 정보를 확인할 수 있다.

반응형

'MyBatis' 카테고리의 다른 글

[MyBatis] 05. 기타  (0) 2023.06.18
[MyBatis] 04. 동적 쿼리  (0) 2023.06.18
[MyBatis] 02. CRUD  (0) 2023.06.18
[MyBatis] 01. 소개 및 환경 설정  (2) 2023.06.18
Spring + mybatis + PageHelper  (4) 2020.06.23
Contents

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

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