[JPA] SQL을 직접 다룰 때의 문제점과 ORM 프레임워크 JPA의 등장
애플리케이션을 개발하다 보면 많은 양의 데이터를 저장해야 하는 경우가 많다. 이 경우, 관계형 데이터베이스 저장소를 사용하는 것이 필수적이다. 관계형 데이터베이스는 많은 양의 데이터를 효율적으로 관리하도록 도와주지만 동시에 SQL 작성의 부담도 따른다. 이번 포스팅에서는 SQL을 직접 작성하는 것이 어떠한 문제를 일으키는지 알아보고 궁극적으로 JPA가 어떻게 이러한 문제를 해결해 줄 수 있는지에 대해 알아보자.
자바 ORM 표준 JPA 프로그래밍 - 교보문고
스프링 데이터 예제 프로젝트로 배우는 전자정부 표준 데이터베이스 프레임 | ★ 이 책에서 다루는 내용 ★■ JPA 기초 이론과 핵심 원리■ JPA로 도메인 모델을 설계하는 과정을 예제 중심으로
www.kyobobook.co.kr
유지보수의 어려움
자바 어플리케이션을 기준으로 애플리케이션에서 쿼리를 전송하고 데이터를 받는 과정에서 JDBC API의 도움을 받게 된다. 이때 JDBC API를 통해 DB와 데이터를 주고 받는 과정은 다음과 같다.
- 드라이버 매니저를 통해 JDBC Driver를 등록
- Connection 구현 객체 생성
- Connection 객체를 통해 쿼리를 담을 statement 객체를 생성
- statement 객체의 메서드를 통해 쿼리 실행
- 데이터를 리턴 받음
// 1
DriverManager.registerDriver(new com.mysql.cj.jdbc.Driver());
// 2
Connection conn = Connection conn = DriverManager.getConnection(
"jdbc:mysql://localhost/dbname?useUnicode=True&serverTimezone=UTC",
"username",
"password"
);
// 3
Statement stmt = conn.createStatement();
// 4
ResultSet rs = stmt.executeQuery("SELECT * FROM MEMBERS");
...
같은 코드의 무한 반복
Member 객체의 데이터를 DB에 저장하는 메서드와 조회하는 메서드를 작성한다고 생각해보자.
public class MemberDAO {
public void save(Member member) {
Connection conn = Connection conn = DriverManager.getConnection(
"jdbc:mysql://localhost/dbname?useUnicode=True&serverTimezone=UTC",
"username",
"password"
);
PreparedStatement pstmt = conn.preparedStatement(
"INSERT INTO MEMBERS (NAME, AGE) VALUES (?, ?);"
)
pstmt.setString(1, member.getName());
pstmt.setInt(2, member.getAge());
pstmt.executeUpdate();
}
public void find(Long id) {
Connection conn = Connection conn = DriverManager.getConnection(
"jdbc:mysql://localhost/dbname?useUnicode=True&serverTimezone=UTC",
"username",
"password"
);
PreparedStatement pstmt = conn.preapredStatement(
"SELECT NAME, AGE FROM MEMBERS WHERE ID=?"
);
pstmt.setInt(1, id);
ResultSet rs = pstmt.executeQuery();
if (rs.next()) {
Member member = new Member();
member.setName(rs.getString("NAME"));
member.setAge(rs.getInt("AGE"));
return member;
} else {
return null;
}
}
}
public class static Member {
Long id;
String name;
int age;
}
save() 메서드와 find() 메서드를 비교해보면 Connection 객체를 생성하고, Statement 객체에 쿼리를 넣어주고 실행하는 과정이 실행하는 쿼리의 내용만 바뀔 뿐 반복적으로 실행되는 것을 볼 수 있다. 또한 그 코드의 양이 꽤나 방대하고 복잡하다.
반복적인 코드를 작성하는 일을 개발자를 굉장히 피로하게 만들고, 생산성을 떨어뜨린다.
SQL 의존적인 코드
사실 쿼리를 실행하기 위해 계속 반복 작성되는 코드는 커넥션 풀이나 마이바티스(myBatis) 등을 사용하면 충분히 해결할 수 있다. 더 큰 문제는 자바 코드에 '직접' SQL 쿼리문을 작성한다는 것이다.
만약 Member 객체의 새로운 필드 city가 추가되었다고 생각해보자. 그러면 당연히 save(), find() 메서드의 쿼리도 수정되어야 한다.
// save() 수정 전
INSERT INTO MEMBERS (NAME, AGE) VALUES (?, ?)
// save() 수정 후
INSERT INTO MEMBERS (NAME, AGE, CITY) VALUES (?, ?, ?)
// find() 수정 전
SELECT NAME, AGE FROM MEMBERS WHERE ID=?
// find() 수정 후
SELECT NAME, AGE, CITY FROM MEMBERS WHERE ID=?
만약 이러한 메서드가 데이터 로직에 2개가 아닌 10개 또는 100개가 있으면 어떻게 될까? 개발자가 일일히 모든 메서드에 작성된 쿼리를 수정해주어야 한다.
JPA로 해결
JPA는 이러한 문제들을 어떻게 해결할까? JPA는 DB 저장/조회/수정 관련 메서드를 제공하여 마치 컬랙션에 데이터를 관리하듯이 DB 데이터를 관리하도록 도와준다, 즉, 개발자는 직접 SQL 쿼리를 입력할 필요가 없어진다.
em.persist(member);
save() 메서드를 작성하기 위해서는 JDBC API를 이용해 직접 쿼리문을 작성하여 DB를 처리할때는 굉장히 많은 양의 코드가 필요했지만, JPA를 사용하면 위의 한줄이면 충분하다. 보다시피 쿼리문도 없다.
만약 member 클래스에 새로운 필드가 추가되면 어떻게 될까? 그래도 상관없다. 그냥 persist() 메서드를 사용하면 된다. 추후 포스팅에서 설명하겠지만, persist() 메서드는 객체를 스캔하여 JPQL이라는 JPA 전용 SQL 쿼리를 생성하게 된다. 그리고 이 쿼리는 다시 SQL로 변경되어 DB에 커밋된다.
이외에도 JPA는 다양한 메서드와 변경 감지, 쓰기 지연 등의 기능을 통해 개발자가 마치 자바 컬렉션을 관리하듯 DB를 관리할 수 있도록 도와준다. 그리고 이는 곧 유지보수성의 향상으로 이어지고, 개발자는 SQL 지옥에서 탈출 할 수 있게 된다.
패러다임의 불일치 (OOP vs RDB)
개발자는 좋은 객체지향 프로그래밍(OOP)을 하기위해 노력한다. 좋은 객체 지향 프로그램은 애플리케이션이 발전하고 복잡성이 높아져도 높은 유지보수성을 가진다. 객체 지향 프로그래밍은 추상화, 캡슐화, 상속, 다형성과 같은 특징을 갖는데, 문제는 관계형 데이터베이스에서 이를 그대로 구현하는 것이 불가능하다는 것이다. 이를 객체지향 프로그래밍과 관계형 데이터베이스간의 패러다임 불일치라고 한다.
상속
객체는 상속이라는 기능을 가지고 있다.
abstract class Item {
Long id;
String name;
int price;
}
class Book extends Item {
String author;
}
class Music extends Item {
String producer;
String singer;
}
class Movie extends Item {
String director;
String actor;
}
위와 같이 Item 추상 클래스를 Book/Music/Movie가 상속한다. 이를 DB에 저장하려면 어떻게 해야 할까? DB는 상속이라는 개념이 존재하지 않는다. 상속과 유사한 슈퍼타입 서브타입을 이용해 Item 객체의 필드를 슈퍼타입의 데이터로 저장하고, 각각의 상속받은 객체를 서브타입으로 저장한다.
이때 Book 객체를 DB에 INSERT 하게 되면 Item 클래스의 공통필드를 먼저 ITEM 테이블에 삽입하고, Book 객체만 가진 필드를 BOOK 테이블의 삽입 후, ITEM 테이블의 DTYPE 값을 지정해주어야 한다.
INSERT INTO ITEM ...
INSERT INTO BOOK ...
이는 Movie, Music 객체를 DB에 저장할때도 마찬가지다.
Book 객체를 DB에서 조회할때는 더 복잡하다. 일단 ITEM 테이블과 BOOK 테이블을 조인한 뒤 ID를 통해 조회해야 한다.
SELECT I.*, B.*
FORM ITEM I
JOIN BOOK B
ON I.ITEM_ID = B.ITEM_ID
또한 쿼리문을 통해 DB 조회 후 객체에 매칭해주는 작업까지 실행해야 한다.
JPA를 사용하면?
JPA를 통해 Item 객체를 상속받은 Book 객체를 저장하려면 어떻게 해야할까? JPA는 상속여부와 관계없이 무엇인가를 저장하는 경우 persist() 메서드를 사용한다.
em.persist(book);
컬렉션에 객체를 저장하듯, 저장할 객체 그 자체만을 저장하면 된다. 상속여부는 신경 쓸 필요가 없다.
JPA를 통해 객체를 조회하는 것도 간단하다. find() 메서드를 사용하면 된다.
em.find(Book.class, id);
find() 메서드는 객체의 상속여부와 관계없이 매핑된 객체에 해당하는 데이터를 테이블에서 조회한다.
연관관계
객체간의 연관관계는 참조(reference)를 통해 이루어지지만 DB는 외래키(foreign key)를 통해 이루어진다.
class Order {
Long id;
User user;
string item;
int price;
}
class User {
Long id;
int name;
String address
}
위 코드를 보면 객체에서는 Order가 User를 참조함으로써 연관관계가 형성되는데 이를 DB에 옮기면 어떻게 될까?
DB는 참조를 통해 연관관계 표현이 불가능하다. 또한 만약 객체를 외래키를 이용해 설계를 하면 객체 지향이 깨지게 된다.
JPA를 사용하면?
신경 쓸 필요가 없다! 객체에서 참조를 통해 연관관계가 형성되면 persist() 메서드를 통해 저장되면, DB에 외래키를 통해 저장된다.
마무리
위에서 지적했던 직접 쿼리 작성의 문제점을 한마디로 말하자면 데이터 로직과 비즈니스 로직이 분리되지 않는다는 점이다. 직접 쿼리를 작성하면 데이터 로직에서의 문제가 비즈니스 로직으로 그대로 전파된다. 개발자는 항상 데이터 로직까지 찾아가 문제를 수정해야 한다. 또한 데이터 테이블과 객체간의 패러다임의 불일치 문제로 이를 매칭하기 위한 추가적인 작업이 항상 필수적이다.
JPA는 이러한 문제를 해결한다. JPA는 개발자에게 쿼리 대신 객체의 메서드를 사용하게 함으로써 비즈니스 로직과 데이터 로직을 완전히 분리한다. 또한 이를 통해 개발자는 객체의 패러다임만으로 개발을 할 수 있다. 밑단의 데이터계층의 로직이나 패러다임을 고민할 필요는 없어진다.
JPA가 이렇다고 장점만 있는 것은 아니다. 대형 쿼리를 사용할 경우 성능저하 문제가 있다(이 경우 직접 쿼리를 작성하여 사용하는 경우가 많음) 또한 조금만 잘 못 설계할 경우, 일관성이 무너지기 쉽다. 쉽게 말해 모르고 쓸 경우, 사용 난이도가 매우 높다.
즉, 멋 모르고 사용하면 피보기 쉽다, 제대로 알고 사용해야 한다.
과거의 한 스프링 컨퍼런스에서 JPA에 대해 어떤 개발자가 한 말로 마무리 하겠다.
당신이 JPA를 사용하는 것이 불편한 이유는, 당신이 JPA를 잘 모르기 때문이다.
끝.