자바스크립트를 활성화 해주세요

[DDD 시작하기] 부록: MyBatis와 JPA 소개

비 Java 개발자들을 위한 MyBatis와 JPA 간단 소개

 ·  ☕ 12 min read

DDD에 대한 학습을 위한 책으로 도메인 주도 개발 시작하기를 추천받았다. 나는 현재 회사에서 Golang으로 개발하고 있지만, 이 책의 경우 예제나 대부분의 설명을 JPA/Spring 개발자라 가정하고 작성한 것으로 보인다. 나는 사람들을 모아 스터디를 진행하면서 모르는 개념에 대한 질문을 할 수 있었고, 직접 찾아본 내용 등을 정리하여 공유하려 한다.

일단 JPA/Spring이 언급되었듯, 책에서는 대상 독자를 웹 개발자, 특히 백엔드 개발자라는 가정하에 시작한다. 하지만 나는 프로그래밍에 대한 어느 정도 경험만 있다면 이해할 수 있도록 작성해 보려고 한다. 이 글에서는 책에서 나오는 Java 개발자만 이해할 내용을 이해하는 것이 핵심이므로 상세한 부분에서 오개념이 존재할 수도 있고, 예시 코드에 오류가 있을 수도 있다.

선 요약

JPA와 MyBatis는 ORM을 지원하는 Java API, Library다.

ORM 개요

이미 이 글을 찾아본다는 점에서 객체지향과 관계형 데이터베이스에 대한 개요는 알고 있겠지만 다시 한번 정리해 보자.

객체지향의 객체

객체지향에서 실세계의 표현 대상(이하 모델이라 칭함)을 필요한 관점에서 중요하다고 생각하는 부분만 추출하여 표현하는 추상화 를 통해 객체 로 표현한다. 이 객체는 모델의 상태나 구분을 위한 속성(ex. 멤버 변수) 외에도 행동(ex. 메서드)을 가지도록 표현할 수 있다.

관계형 데이터베이스의 테이블

프로그램의 생명 주기와 관계 없이 데이터를 보관하고 접근하기 위해 파일에 보관하기도 하지만, 대부분 체계적인 데이터 보관 및 접근을 위해 데이터베이스를 사용한다. 보통 데이터베이스는 CRUD(Create, Read, Update, Delete)를 지원하며 각 회사마다 다른 DBMS(Database Management System)를 제공한다.

데이터베이스의 발전에 따라 객체지향 데이터베이스도 있던 시절이 있지만, 성능상 이점이 없거나, 기존 관계형 모델에서 전환의 필요성을 느끼지 못해 몰락했다고 한다. 그 이후 비정형 데이터베이스가 발전하긴 했지만, 지금 여기서 다룰 부분은 아니니 생략하도록 하겠다.

어쨌든 현재 주로 사용되는 관계형 데이터베이스는 테이블 이란 단위로 데이터를 저장한다. 테이블의 행(row) 은 한 데이터를 뜻하며, 열(column) 은 데이터를 표현하는 데 필요한 속성을 뜻한다.

추가로, 테이블 내에 데이터(행)가 중복되는 것을 방지하기 위해 구분을 위한 고유 값인 기본 키라는 특수한 열을 설정할 수 있으며, 각 테이블을 조합하기 위해 다른 열을 참고할 수도 있다. 더 상세한 내용은 외래 키, JOIN, 정규화 등을 참고하면 된다.

객체지향과 관계형 데이터베이스를 연결하는 것

데이터는 관계형 데이터베이스에 보관되어 있다. 코드를 작성할 때는 객체지향 패러다임에 맞춰 작성하는 편이다. 관계형 데이터베이스에 보관하거나 접근할 때는 SQL(Structured Query Language) 을 통해 읽어오고, 그 결과를 변환해야 한다. 이렇게 객체와 데이터베이스의 관계를 연결하는 것을 ORM(Object Relational Mapping) 이라 한다.

이 ORM이란 개념은 프로그램 외부 데이터베이스와 객체 간 연결을 한다는 의미의 관용어구가 되어, 굳이 외부 데이터베이스가 관계형이 아닌, 비정형 데이터베이스에 접근하는 경우에도 ORM이라고 부르기도 한다. 참고로 Java에서는 좀 더 엄밀히 Persistent Framework라고 표현한다. 하지만 그냥 편의상 ORM이라 표현하도록 하겠다.

Java에서 ORM 기술의 발전

이 ORM이란 개념은 특정 언어에서만 구현된 것이 아니다. 내가 개발하는 Golang만 해도 GORM 등의 모듈(라이브러리)을 사용할 수 있으며, Node.js/TypeScript도 Sequelize 등의 ORM 라이브러리가 존재한다. 당연히 Java에서도 ORM 라이브러리가 여럿 존재하는데, ORM 구현 및 사용 과정의 문제점, 역사 등을 확인해보자.

앞으로 설명할 예시를 간단하게 하기 위해, 아래와 같이 단순하게 상황을 정의하도록 하겠다. 단, 각 기술의 문제점을 설명하기 위해 아래 상황에서 일부가 변경될 수도 있다.

  1. DBMS는 MySQL을 사용한다.
  2. 학생의 학번, 이름, 학년 정보를 보관하는 테이블이 구성되어 있다.
  3. DBMS로부터 조회, 삽입 기능만 사용하며 수정, 삭제는 하지 않는다.

추가 설명을 위한 DBMS 내 구성 및 테이블의 초기 값 입력은 아래 코드를 참고하길 바란다.

USE school;
-- create a table
CREATE TABLE students (
  id INTEGER PRIMARY KEY,
  name VARCHAR(30) NOT NULL,
  grade INTEGER NOT NULL
);
-- insert some values
INSERT INTO students VALUES (1, 'John', 1);
INSERT INTO students VALUES (2, 'Jane', 3);
INSERT INTO students VALUES (3, 'Alice', 3);
INSERT INTO students VALUES (4, 'Bob', 1);
INSERT INTO students VALUES (5, 'Chris', 2);
INSERT INTO students VALUES (6, 'David', 5);
-- Get all 3rd grade students
SELECT * FROM students WHERE grade = 3;
-- Expected result
-- +----+----------+-------+
-- | id | name     | grade |
-- +----+----------+-------+
-- |  2 | Jane     |     3 |
-- |  3 | Alice    |     3 |
-- +----+----------+-------+

-- Get students whose name starts with J
SELECT * FROM students WHERE name LIKE 'J%';
-- Expected result
-- +----+----------+-------+
-- | id | name     | grade |
-- +----+----------+-------+
-- |  1 | John     |     1 |
-- |  2 | Jane     |     3 |
-- +----+----------+-------+

ORM의 기초적 구현 (JDBC)

예전에 데이터베이스로부터 보관, 접근을 하기 위한 방법으로 자바에서는 JDBC(Java Database Connectivity)를 사용했다. 기초적인 원리는 프로그래머가 작성하는 자바 프로그램이 DBMS의 클라이언트로 접속하여, 주어진 SQL을 전송하고 그 결과를 받아와서 각 데이터에 접근하는 방식이었다.

핵심이 되는 코드와 실행 결과를 적으면 다음과 같다.

// Connect DB
Connection conn = DriverManager.getConnection("jdbc:mysql://localhost:3306/school", "admin", "password");

// Write base query
String sql = "SELECT * FROM students WHERE grade = ?";

// Set query parameter
PreparedStatement pstmt = conn.prepareStatement(sql);
pstmt.setInt(1, 3); // final query = "SELECT * FROM students WHERE grade = 3"

// Execute query
ResultSet rs = pstmt.executeQuery();
while (rs.next()) {
    System.out.print(rs.getInt("id") + "\t");
    System.out.print(rs.getString("name") + "\t");
    System.out.print(rs.getInt("grade") + "\n");
}
2	Jane	3
3	Alice	3

DTO, DAO 개념의 등장

앞의 예제에서는 JDBC를 통해 DBMS로부터 학생 정보를 학년 기준으로 필터링해서 조회하는 방법을 확인했다. 하지만 주어진 코드는 아직 기초적인 수준이며, 객체지향적으로 작성하기 위해선 몇 가지 고려해야 할 사항이 있다.

  1. 학생의 속성을 읽어올 때 JDBC의 질의 결과를 확인하기 위한 ResultSet에 종속적이다.
    객체지향적으로 잘 분리된 코드 상태가 아니다.
    (현재 예제가 너무 기초적인 상태라 객체지향적인 문제점을 보여주기 힘든 상황이기도 하다.)
  2. 조회 조건을 다양하게 지원하고, 삽입, 삭제 등의 기능을 확장하려 할 때, DBMS 연결을 전담해 줄 객체가 필요하다.

결국 ORM에서 모델을 추상화한 객체와 JDBC 간의 변환은 필수적이다. 하지만 객체지향에서는 객체 간 결합을 약하게 구성하는 것을 지향한다. 이 결합을 약하게 하려면 중간 변환 객체가 필요하다. 원래 모델의 행동은 정의하지 않고, 속성만 읽고 쓸 수 있는, 데이터 교환을 위한 객체를 중간에 두게 되는데, 이를 DTO(Data Transfer Object) 라 한다.

그리고 DBMS로부터 조회, 삽입, 삭제 등 DBMS와 연결하는 기능을 전담하는 객체를 DAO(Data Access Object) 라 한다.

아래는 DTO, DAO를 사용하여 DBMS로부터 조회, 갱신하는 코드 예시다. (일부 검증이 되지 않은 부분도 있고, import 등은 생략되어있다.)

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
public class StudentDto {
    private int id;
    private String name;
    private int grade;

    public int getId() { return id; }
    public void setId(int id) { this.id = id; }
    public String getName() { return name; }
    public void setName(String name) { this.name = name; }
    public int getGrade() { return grade; }
    public void setGrade(int grade) { this.grade = grade; }
}
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
public class StudentDao {
    private String dbUrl, dbUsername, dbPassword;

    public StudentDao(String dbUrl, dbUsername, dbPassword) {
        this.dbUrl = dbUrl;
        this.dbUsername = dbUsername;
        this.dbPassword = dbPassword;
    }

    public int insert(StudentDto studentDto) throws SQLException {
        int ret = 0;
        Connection conn = null;
        PreparedStatement pstmt = null;
        String baseSql = "INSERT INTO students VALUES (?, ?, ?)";
        
        try {
            conn = DriverManager.getConnection(dbUrl, dbUsername, dbPassword);
            pstmt = conn.prepareStatement(baseSql);
            pstmt.setInt(1, studentDto.getId());
            pstmt.setString(2, studentDto.getName());
            pstmt.setInt(3, studentDto.getGrade());
            
            ret = pstmt.executeUpdate();
        } finally {
            pstmt.close();
            conn.close();
        }
        return ret;
    }

    public List<StudentDto> findByGrade(int grade) throws SQLException {
        List<StudentDto> list = new ArrayList<>();
        Connection conn = null;
        PreparedStatement pstmt = null;
        ResultSet rs = null;
        String baseSql = "SELECT * FROM students WHERE grade = ?";
        
        try {
            conn = DriverManager.getConnection(dbUrl, dbUsername, dbPassword);
            pstmt = conn.prepareStatement(baseSql);
            pstmt.setInt(1, grade);

            rs = pstmt.executeQuery();
            while (rs.next()) {
                StudentDto studentDto = new StudentDto();
                studentDto.setId(rs.getInt("id"));
                studentDto.setName(rs.getString("name"));
                studentDto.setGrade(rs.getInt("grade"));
                list.add(studentDto);
            }
        } finally {
            rs.close();
            pstmt.close();
            conn.close();
        }
        return list;
    }

    public List<StudentDto> findByNamePrefix(string prefix) throws SQLException {
        List<StudentDto> list = new ArrayList<>();
        Connection conn = null;
        PreparedStatement pstmt = null;
        ResultSet rs = null;
        String baseSql = "SELECT * FROM students WHERE name LIKE '?%'";
        
        try {
            conn = DriverManager.getConnection(dbUrl, dbUsername, dbPassword);
            pstmt = conn.prepareStatement(baseSql);
            pstmt.setString(1, prefix);

            rs = pstmt.executeQuery();
            while (rs.next()) {
                StudentDto studentDto = new StudentDto();
                studentDto.setId(rs.getInt("id"));
                studentDto.setName(rs.getString("name"));
                studentDto.setGrade(rs.getInt("grade"));
                list.add(studentDto);
            }
        } finally {
            rs.close();
            pstmt.close();
            conn.close();
        }
        return list;
    }
}
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
public class JdbcDtoDaoTest {
    public static void main(String[] args) {
        StudentDao dao = new StudentDao("jdbc:mysql://localhost:3306/school", "admin", "password");

        List<StudentDto> thirdGrades = dao.findByGrade(3);
        System.out.println("Third grade students:");
        for (StudentDto dto : thirdGrades) {
            System.out.println("\t" + dto.getName());
        }

        StudentDto jason = new StudentDto();
        jason.setId(7);
        jason.setName("Jason");
        jason.grade(4);
        dao.insert(jason);

        List<StudentDto> startsWithJ = dao.findByNamePrefix("J");
        System.out.println("Students who starts name with J:");
        for (StudentDto dto : thirdGrades) {
            System.out.println("\t" + dto.getName());
        }
    }
}
Third grade students:
	Jane
	Alice
Students whose name starts with J:
	John
	Jane
	Jason

이렇게 ORM을 통해 데이터베이스에서 코드로 객체를 가져오는 방법에 필요한 기초를 알아봤다. 이제 왜 JDBC를 그대로 사용하지 않고, MyBatis나 JPA 등이 나왔는지를 알아보자.

문제점 1. 보일러 플레이트 코드 작성의 귀찮음

앞에서 작성한 예시 코드는 고작 3개의 속성을 가진 데이터에 대한 코드인데도 길이가 짧진 않다. 원래는 DAO도 interface로 빼고 따로 구현해야 하고, DB 관리에 대한 정보도 분리해서 여러 객체를 작성해야 하는데 이런 부분을 생략했음에도 이 정도 길이의 코드가 되었다. 조금 더 복잡해진다면 코드의 길이는 급격하게 늘어날 것이다.

Java의 경우, 코드에서 직접 작성해야 할 부분을 annotation으로 표기해놓고, 컴파일-빌드 과정에서 자동으로 코드가 생성되어 프로그래머가 작성할 코드를 간단하게 만드는 방법이 있다.

Lombok

사실 책에서 언급했던 MyBatis, JPA와 관계는 없지만, annotation에 대해 익숙해지기 위해 잠깐 다뤄보자.

현재 예시로 작성한 DTO의 경우 getter/setter가 모두 존재한다. 현재 학생 데이터에 새로운 속성이 추가되면 또 getter/setter가 추가되어야 한다. 예제 코드에서는 현재 속성을 인자로 입력받는 생성자가 없지만, 만약 모든 속성을 초기화하는 생성자가 있었다면 새로운 속성 추가에 따라 생성자 인자와 초기화 과정도 수정되어야 할 것이다.

DTO 외에도 Java 내에서는 POJO(Plain Old Java Object), JavaBeans, DDD에서 등장하는 개념인 VO(Value Object)까지, DTO처럼 보일러 플레이트 코드라 생각되는 데이터 위주의 객체가 여럿 존재한다. (각각의 차이는 여기를 참고하자) 이런 경우 생성자, getter/setter 등을 자동으로 생성하는 방법이 있다. 바로 Lombok이란 라이브러리다. (Lombok annotation으로 인해 POJO의 조건을 위반하게 되는지는 잘 모르겠지만 중요한 부분은 아니니 생략한다.)

객체 자체나 특정 멤버 변수에 @Getter, @Setter 등의 annotation을 추가하면, 알아서 해당 조건에 맞는 메서드를 추가해서 컴파일하게 된다. 아래 코드는 기존 DTO와 동일한 메서드를 제공하지만, Lombok의 annotation으로 더 간단하게 작성한 예시다. (다른 예시는 Lombok 문서나 다른 블로그를 참고해도 될 만큼 단순하다.)

1
2
3
4
5
6
7
@Getter
@Setter
public class StudentDto {
    private int id;
    private String name;
    private int grade;
}

DAO의 코드 작성, MyBatis로 SQL 매핑하기

DAO의 코드를 보면 대단한 일을 하는 것 같지만, 문맥을 자세히 분석해 보면 결국 SQL을 만들어서 DBMS로 요청하고 결과를 가져오는 것뿐이다.

결국 DBMS로부터 조회, 갱신하는 메서드는 그 목적에 맞는 SQL을 실행하게 된다. 이렇게 메서드에 SQL을 매핑하는 기능을 MyBatis가 제공한다. MyBatis를 사용하면 JDBC 관련 코드를 거의 줄일 수 있다. Java에서 이전에 빌드 설정이나 메타데이터를 표현하기 위해 사용되던 XML에 SQL을 기술하면 그 내용에 맞게 코드를 생성하여 컴파일-빌드하게 된다. XML에 기술하는 방법 외에도 annotation을 활용하는 방법이 있다.

MyBatis의 경우 설정 작업부터 복잡한 부분이 많지만, annotation으로 매핑 관계를 정의하고 활용하는 방식에 대한 이해는 아래 예시로 충분하겠다고 생각한다. (DTO는 기존과 동일한 방식으로 사용한다고 가정하고, 결과도 동일할 것으로 예측하여 생략했다.)

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
public interface StudentMapper {
    @Insert("INSERT INTO students VALUES (#{id}, #{name}, #{grade})")
    @Options(useGeneratedKeys = false, keyProperty = "id")
    void insert(StudentDto studentDto);

    @Select("SELECT * FROM students WHERE grade = #{grade}")
    @Results(value = {
      @Result(property = "id", column = "id"),
      @Result(property = "name", column = "name"),
      @Result(property = "grade", column = "grade")
    })
    List findByGrade(int grade);

    @Select("SELECT * FROM students WHERE name LIKE CONCAT(#{prefix}, '%')")
    @Results(value = {
      @Result(property = "id", column = "id"),
      @Result(property = "name", column = "name"),
      @Result(property = "grade", column = "grade")
    })
    List findByNamePrefix(String prefix);
}
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
public class MybatisAnnotationTest {
    public static void main(String[] args) {
        Reader reader = Resources.getResourceAsReader("SqlMapConfig.xml");
        SqlSessionFactory sqlSessionFactory = new SqlSessionFactoryBuilder().build(reader);		
        SqlSession session = sqlSessionFactory.openSession();
        session.getConfiguration().addMapper(StudentMapper.class);
      
        StudentMapper mapper = session.getMapper(StudentMapper.class);   

        List<StudentDto> thirdGrades = mapper.findByGrade(3);
        session.commit();
        System.out.println("Third grade students:");
        for (StudentDto dto : thirdGrades) {
            System.out.println("\t" + dto.getName());
        }

        StudentDto jason = new StudentDto();
        jason.setId(7);
        jason.setName("Jason");
        jason.grade(4);
        mapper.insert(jason);
        session.commit();

        List<StudentDto> startsWithJ = mapper.findByNamePrefix("J");
        System.out.println("Students who starts name with J:");
        for (StudentDto dto : thirdGrades) {
            System.out.println("\t" + dto.getName());
        }
        session.commit();

        session.close();
    }
}

문제점 2. 관계형 데이터베이스 연결에 대한 추상화를 완전 제거할 수 없을까?

앞에서 관계형 데이터베이스와 객체지향을 언급한 이유는 둘의 패러다임이 다르기 때문이다. 이 패러다임의 간극에 대한 이해를 위해 기존 상황에 몇 가지 조건을 더 추가해보자.

  1. 학생이 수강하게 되는 강의는 특정 학년을 대상으로 한다.
  2. 특정 학년은 여러 강의를 수강할 수도 있다.
  3. 강의에 해당하는 학년, 과목명을 보관하는 테이블이 구성되어 있다.
  4. 특정 학생이 어떤 강의를 수강해야 하는지가 궁금하며, 학년은 주요 관심사가 아니다.

추가 설명을 위한 DBMS 내 구성 및 테이블의 초기 값 입력은 아래 코드를 참고하길 바란다.

USE school;
-- create a table
CREATE TABLE lessons (
  grade INTEGER NOT NULL,
  name VARCHAR(30) NOT NULL
);
-- insert some values
INSERT INTO lessons VALUES (1, 'Programming');
INSERT INTO lessons VALUES (1, 'Calculus');
INSERT INTO lessons VALUES (2, 'OOP');
INSERT INTO lessons VALUES (2, 'DataStructure');
INSERT INTO lessons VALUES (2, 'DiscreteMath');
INSERT INTO lessons VALUES (3, 'Database');
-- Get all 2nd grade lessons
SELECT * FROM lessons WHERE grade = 2;
-- Expected result
-- +-------+---------------+
-- | grade | name          |
-- +-------+---------------+
-- |     2 | OOP           |
-- |     2 | DataStructure |
-- |     2 | DiscreteMath  |
-- +-------+---------------+

-- Get all Chris's lessons
SELECT students.id, students.name, lessons.name AS lesson
FROM students
INNER JOIN lessons ON students.grade=lessons.grade
WHERE students.name='Chris';
-- Expected result
-- +----+----------+---------------+
-- | id | name     | lesson        |
-- +----+----------+---------------+
-- |  5 | Chris    | OOP           |
-- |  5 | Chris    | DataStructure |
-- |  5 | Chris    | DiscreteMath  |
-- +----+----------+---------------+

이제 주어진 조건에 맞춰 코드를 작성할 때, 관계형 데이터베이스 중심으로 작성할 때와 객체지향 관점으로 작성할 때의 차이를 확인해보자.

public class StudentDto {
    private int id;
    private String name;
    private int grade;

    // getter & setter
}

public class LessonDto {
    private int grade;
    private String name;

    // getter & setter
}
public class Student {
    private int id;
    private String name;
    private ArrayList<String> lessons;

    // other logic codes
}

위에서 확인할 수 있듯 관계형 데이터베이스 중심으로 작성하다 보면 학생과 강의 간 참조를 위해 JOIN을 고려해야 하며, 테이블을 그대로 옮겨 작성하게 된다. DTO란 개념이 나온 것도 테이블을 그대로 옮겨 작성하던 흔적 중 하나라 볼 수 있다.

코드를 작성할 때 데이터베이스에 대한 관점을 고려하지 않으면서 작성할 수는 없을까? DTO를 비롯한 보일러 플레이트 코드를 줄일 수는 없을까? JPA를 사용하면 상당 부분을 해결할 수 있다. 물론 이런 장점만 있는 것 같지만, 제대로 활용하기 위해선 학습 난이도가 높다는 점이나, SQL 호출 과정 등에 대한 최적화가 불편한 부분도 있어 만능은 아니다.

@Entity
@Table(name = "students")
public class Student {
    @Id
    private int id;
    private String name;

    @OneToMany
    private List<Lesson> lessons = new ArrayList<Lesson>();

    // other logic codes
}

@ValueObjects
@Table(name = "lessons")
public class Lesson {
    private String name;

    @ManyToOne(optional = false)
    @JoinTable(name = "STUDENT_LESSON",
      joinColumns = @JoinColumn(name = "grade"),
      inverseJoinColumns = @JoinColumn(name = "grade"))
    private int grade;
}
@Repository
public class JpaStudentRepository {
    @PersistenceContext
    private EntityManager entityManager;

    @Override
    private Student findById(int id) {
        return entityManager.find(Student.class, id);
    }
}

위 예시에서 나온 @Entity, @ValueObject, @Repository의 의미는 책에서 설명한 것과 동일하다.

참고 링크

위에서 작성한 예시 코드들은 아래 링크를 참고해서 작성했다.

[1] JDBC와 DAO, DTO
[2] 자주 사용되는 Lombok 어노테이선
[3] MYBATIS Tutorial
[4] JPA 조인 컬럼 및 조인 테이블 사용하기
[5] 자바 ORM 표준 JPA 프로그래밍 - 기본편


JaeSang Yoo
글쓴이
JaeSang Yoo
The Programmer

목차