JPA

뚝딱이·2022년 8월 27일
0

JPA

목록 보기
1/11

현대적인 언어로 개발할 때 대부분 객체 지향 언어를 사용한다. java, scala등.
관계형 DB도 사용하는데, 이 데이터베이스 세계의 헤게모니는 관계형 DB가 가지고 있다.

지금 시대는 객체를 관계형 DB에 관리해야한다.

이때 문제는 코드를 까보면 다 SQL이다. 결국 SQL 중심적인 개발이 된다.

SQL 중심적인 개발의 문제점

무한 반복, 지루한 코드

CRUD는 INSERT, UPDATE, SELECT, DELETE등 과 자바 객체를 SQL로, SQL를 자바 객체로 바꾸는 과정이다.
항상 테이블을 하나 만들 때마다 이러한 CRUD, 즉 똑같은 과정을 반복해야한다. 물론 JDBC Template이나 MyBatis를 통해 어느정도 개선된 부분이 있으나, 결국 개발자가 SQL을 다 짜야되기 때문에 힘들다.

예시

객체 CURD

회원을 아래와 같이 설계했을 때 ,

public class Member{
private String memberId;
private String name;
}

SQL문을 짠다면, 어떻게 될까.

INSERT INTO MEMBER(MEMBER_ID, NAME) VALUES
SELECT MEMBER_ID, NAME FROM MEMBER M
UPDATE MEMBER SET …

위와 같은 형식이 될 것이다.
이때 Member의 필드가 하나 늘어난다면, 우리는 SQL문을 모두 수정해야한다.

변경 전

public class Member{
private String memberId;
private String name;
}
INSERT INTO MEMBER(MEMBER_ID,NAME) VALUES 
SELECT MEMBER_ID, NAME MEMBER M
UPDATE MEMBER SET ...

변경 후

Member에 tel이 추가되었다.

public class Member{
private String memberId;
private String name;
private String tel;
}
INSERT INTO MEMBER(MEMBER_ID,NAME,TEL) VALUES 
SELECT MEMBER_ID, NAME,TEL MEMBER M
UPDATE MEMBER SET ...TEL=?

Member에 tel이 추가되었기 때문에 SQL문에도 tel을 모두 추가해야된다. 이런식으로 수정하게 되면 나중에 놓치는게 있을 수도 있다. 예를 들어 UPDATE문에 TEL을 누락시키는 증의 문자가 생길 수 있다.

따라서 SQL에 의존적인 개발을 피하기 어렵다.

패러다임의 불일치

객체 VS 관계형 데이터베이스

둘의 사상이 다르다.

관계형 데이터 베이스 : 데이터를 잘 정규화해서 보관하는 것이 목표다.
객체 : 필드와 메서드를 잘 묶어서 캡슐화해서 사용하는것이 목표다

따라서 둘의 패러다임이 다른데 객체를 관계형 데이터베이스에 넣으려 하니 문제가 생긴다.

객체지향 프로그래밍은 추상화, 캡슐화, 정보은닉, 상속, 다형성 등 시스템의 복잡성을 제어할 수 있는 다양한 장치들을 제공한다.

객체를 RDB에 저장하는게 문제라면, 다른 곳에 저장할수는 없는걸까 ?
객체의 입장에서 생각했을 때 객체는 RDB, NoSQL, File등 다양한 곳에 저장될 수 있다.
하지만, 현실적으로 생각했을 때 가장 적합한것은 RDB이다.

예를 들어 File에 저장한다면, 검색을 하지 못한다. 따라서 검색을 하려면 객체가 있는 만큼, 즉 100만건이 있다면 100만건을 모두 객체로 변환해서 검색해야하는 것이다.

NoSql도 대안이 될 수 있으나, 아직 메인이 되지 못한다.
따라서 RDB를 써야한다.

RDB를 쓸 땐 객체를 RDB에 저장하기 위해 SQL로 변환해야한다.
이때 이 변환하는 작업을 누가할 것인가. 즉 SQL을 짜는 것을 누가 하는가.
개발자가 한다. 개발자가 SQL 매퍼의 역할을 하는 것이다.

객체와 관계형 데이터 베이스의 차이

상속

객체엔 상속이 있으나 데이터 베이스엔 상속이 없다.

따라서 상속이 객체엔 있는데 데이터 베이스엔 없으니, 객체의 상속관계를 어떻게 데이터베이스에서 표현할 것인지 고민해봐야 한다.
유사한 모델로는 슈퍼타입 서브타입의 관계가 있다.

예를 들어

앨범 저장을 한다고 해보자. 그렇다면 INSERT문을 두번써야한다. 왜냐 ? ITEM과 ALBUM으로 테이블이 두개로 쪼개져 있으므로 객체를 분해해서 INSERT INTO ITEM, INSERT INTO ALBUM과 같이 각각 INSERT해야한다.

저장은 어찌어찌 한다 해도 조회가 문제다.
앨범을 조회하고 싶으면 ITEM과 ALBUM을 JOIN한 다음 각각의 객체를 생성해서 필요한것을 넣는다.
따라서 너무 복잡하다. 이러한 이유로 DB에 저장할 객체에는 상속관계를 쓰지 않는다.

DB가 아닌 자바 컬렉션에 저장한다고 생각해보자.

list.add(album)
그냥 add를 사용해서 저장하고 get을 사용해서 id로 조회하면 된다.

Album album = list.get(albumId);

또한 Item와 Album은 상속관계에 있으니 부모타입으로도 조회가 가능하다. -> 다형성 활용

Item item = list.get(albumId);

이렇게 쉽지만 RDB를 사용하면 복잡해지고 그 작업을 개발자가 모두 다 해야된다.

연관관계

객체는 참조를 가지고 연관관계를 사용할 수 있고, 데이터 베이스는 PK와 FK로 조인을 사용하여 찾을 수 있다.

객체는 참조를 사용한다 GETTER를 이용해서 member에서 team을 찾을 수 있다. member.getTeam()
테이블은 외래키를 사용한다. : JOIN NO M.TEAM_ID = T.TEAM_ID

객체의 연관관계에선 Member -> Team일 때 member에서 team으론 갈수 있지만 team에서는 member로 갈 수 없다. 참조가 없기 때문이다.
하지만 테이블에선 양방향으로 갈 수 있다.

상속과 연관관계외에도 아래와 같은 차이점이 있다.

  • 데이터 타입
  • 데이터 식별 방법

객체를 테이블에 맞춰 모델링

보통 이 방식으로 많이 한다. 연관관계 페이지의 예제 처럼

class Member {
 String id; //MEMBER_ID 컬럼 사용
 Long teamId; //TEAM_ID FK 컬럼 사용 //**
 String username;//USERNAME 컬럼 사용
}
class Team {
 Long id; //TEAM_ID PK 사용
 String name; //NAME 컬럼 사용
}
INSERT INTO MEMBER(MEMBER_ID, TEAM_ID, USERNAME)

객체다운 모델링

위처럼 테이블에 맞춰 객체를 저장하도록 설계를 해보았다. 하지만 이는 객체지향 스럽지 않은듯 보인다. 그래서 Member가 외래키 값(teamId)을 가지는게 아니라 참조값을 가져야하는 것 아닐까 한 것이다.

class Member {
 String id; //MEMBER_ID 컬럼 사용
 Team team; //참조로 연관관계를 맺는다. //**
 String username;//USERNAME 컬럼 사용
 
 Team getTeam() {
 return team;
 }
}
class Team {
 Long id; //TEAM_ID PK 사용
 String name; //NAME 컬럼 사용
}
INSERT INTO MEMBER(MEMBER_ID, TEAM_ID, USERNAME) VALUES …

이때 TEAM_ID는 member.getTeam().getId()를 통해 가져온다.
그럼 insert할 때 외래키 값을 넣어야하는데 외래키가 없고 참조값만 있다. 그럼 참조값의 id를 뽑아와서 쓰면 될까 해서 pk값을 가져와서 fk로 넣는다면 어떻게 될까. 이렇게 설계하면 조회할 때 문제가 생긴다.

member와 team을 join해서 데이터랑 sql을 한번에 다 불러온다. 근데 이 데이터엔 team과 member가 섞여있다. 그래서 member부분 team부분 각각 꺼내서 값을 세팅해서 넣은 다음 연관관계를 설정한 후 반환하면 된다. 말로만 들어도 번거로운게 느껴진다. 따라서 생산성도 매우 떨어진다.

SELECT M.*, T.*
 FROM MEMBER M
 JOIN TEAM T ON M.TEAM_ID = T.TEAM_ID 
public Member find(String memberId) {
 //SQL 실행 ...
 Member member = new Member();
 //데이터베이스에서 조회한 회원 관련 정보를 모두 입력
 Team team = new Team();
 //데이터베이스에서 조회한 팀 관련 정보를 모두 입력
 //회원과 팀 관계 설정
 member.setTeam(team); //**
 return member;
}

하지만 이러한것들을 자바 컬렉션을 통해 관리한다고 생각하면 ? 괜찮다. 그냥 add로 member를 넣고 get으로 가져오면 된다. team 또한 member.getTeam으로 가져오면 된다.

list.add(member);
Member member = list.get(memberId);
Team team = member.getTeam();

객체 그래프 탐색

객체는 자유롭게 객체 그래프를 탐색할 수 있어야한다.

모두 참조가 있다는 가정하에 모두 탐색할 수 있어야한다.
옆에 객체들 탐색하는 것을 말한다.

member에 order가 있어서 member.order나 member.delivery를 마음대로 호출할 수 있는가 ?
아니다. 왜냐면 처음 실행하는 SQL에따라 탐색 범위가 결정되어 제한되기 때문이다.
처음 SQL을 날릴 때 Team과 member만 가져와서 채워서 반환했다. 그러면 member.getTeam을 하면 되지만 비즈니스 로직에 의해 member.getOrder를 쓰면 null값이 반환된다. 왜냐 처음 SQL을 실행할 땐 member와 team만 채웠기 때문이다.

SELECT M.*, T.*
 FROM MEMBER M
 JOIN TEAM T ON M.TEAM_ID = T.TEAM_ID 
member.getTeam(); //OK
member.getOrder(); //null

이렇게 되면 엔티티에 대한 신뢰문제가 생긴다.

class MemberService {
 ...
 public void process() {
 Member member = memberDAO.find(memberId);
 member.getTeam(); //???
 member.getOrder().getDelivery(); // ???
 }
}

memberDAO에서 member를 반환하는 것을 보고 그럼 member에서 team과 order, delivery등 마음대로 가져올 수 있겠다고 생각하면 안된다. DAO의 개발자와 process를 개발하는 개발자가 다를 때 위의 코드를 짜는 개발자는 memberDAO의 안의 어떤 쿼리가 날아갔고 어떤 객체가 조립되었는지 눈으로 확인하지 않는 이상 반환되는 엔티티를 신뢰하고 사용할 수 없다.

Layered Architecture : 그 다음 계층에서 신뢰를 하고 사용해야하는데 위에서 보듯이 엔티티를 신뢰하지 못함

따라서 물리적으론 나뉘어져있지만 논리적으로는 어느정도 엮여있는 것이다 따라서 dependency가 좋지 못하다.

그렇다고해서 모든 객체를 미리 로딩 할 수는 없다.

따라서 대안으로 상황에 따라 동일한 회원 조회 메서드를 여러개 생성해서 조회한다.

계층형 아키텍쳐

진정한 의미의 계층 분할이 어렵다. 논리적으로 어느정도 엮여있기 때문이다.

String memberId = "100";
Member member1 = memberDAO.getMember(memberId);
Member member2 = memberDAO.getMember(memberId);
member1 == member2; //다르다.
class MemberDAO {
 
 public Member getMember(String memberId) {
 String sql = "SELECT * FROM MEMBER WHERE MEMBER_ID = ?";
 ...
 //JDBC API, SQL 실행
 return new Member(...);
 }
}

식별자가 같아도 조회할때 member를 new로 생성하기 때문에 member1과 member2를 비교했을 때 false가 반환된다.

자바 컬렉션에서 조회하는 것은 member1과 member2의 참조값이 같기 때문에 같다고 나온다.

String memberId = "100";
Member member1 = list.get(memberId);
Member member2 = list.get(memberId);
member1 == member2; //같다.

객체 답게 모델링 할수록 매핑 작업만 늘어나서 힘들다. 오히려 번잡하게 설계된다.

그렇다면 객체를 자바 컬렉션에 저장하듯이 DB에 저장할 수 없을까라는 고민을 하게 되어 1980년대 부터 이러한 고민을 해왔다. 이러한 고민의 결과가 바로 JPA이다.


JPA란

Java Persistence API
자바 진영의 ORM 기술 표준

그렇다면 ORM이란 무엇인지 알아볼 필요가 있다.

ORM이란

  • Object-relaional mapping으로 객체 관계 매핑이다.

  • 객체는 객체대로 설계하고 관계형 데이터 베이스는 관계형 데이터베이스대로 설계한 다음
    ORM 프레임워크가 중간에서 매핑해주는 것이다. 따라서 개발자는 객체지향 대로 객체를 개발하고,
    관 관계형 데이터베이스는 관계형 데이터베이스 답게 설계를 한 다음 중간에 사이들은 ORM 프레임 워크가 해결해주는 것이다.

  • 대중적인 언어에는 대부분 ORM 기술이 존재한다. TYPESCRIPT같은 경우도 ORM이 존재한다.

JPA 동작

JPA는 애플리케이션과 JDBC사이에서 동작한다. 개발자가 직접 JDBC API를 사용하는 것이아닌 JPA에게 명령을 하면 JPA가 JDBC API를 호출하고, SQL을 통해 DB에서 결과를 반환해 그걸 받아 동작을 한다.

저장

MemberDAO에서 객체를 저장하고 싶을 땐, 과거엔 직접 JDBC Template이나 mybatis를 썼다면 지금은 JPA에게 member객체를 넘긴다. 그럼 jpa가 member객체를 분석하고 쿼리를 알아서 생성한다. 그리고 JDBC API를 사용해 db와 통신한다. 중요한 것은 쿼리를 개발자가 짜는 것이 아닌 jpa가 해준다는 것이다.

근데 정말 중요한 것은 패러다임의 불일치를 해결해준다는 것이다.

조회

조회할 때도 마찬가지이다.

pk를 넘기면 매핑 정보를 바탕으로 쿼리를 생성한다.
결과가 나오면 (Result Set) 객체에 매핑한다.
따라서 jpa가 복잡한 일들 다 해줌

역사

정말 과거에는 EJB 를 자바 표준으로 사용하고 있었다. ORM이었는데, 문제는 너무 아마추어적이었다. 인터페이스도 엄청 많이 상속, 구현해야되고 속도도 느렸다. 심지어 기능도 잘 동작하지 않았고 성능이 너무 좋지 않아 쓰이지 않게 되었다.

EJB를 쓰던 개빈 킹이 ORM 프레임워크를 만들게 되었는데 이게 하이버네이트다. 하이버네이트가 등장하고, 하이버네이트의 점유율이 폭등했다.

그래서 JAVA에선 개빈킹을 데려와 하이버네이트 복붙해서 JPA만들었다.
자바 진영의 표준이 되기 때문에 거친 오픈소스인 하이버네이트를 다듬어 표준화 시켰다.

JPA는 표준 명세

JPA는 인터페이스의 모음이다. 까보면 실제로 다 인터페이스이다.
JPA를 구현한 3가지 구현체로 하이버네이트, EclipseLink, DataNucleus가 있다.
하지만 하이버네이트를 8-90퍼센트 쓰므로 하이버네이트를 쓰는 것이 좋다.

EJB 시절 컨테이너 역할을 하는 것이 있었는데, 이게 너무 복잡하고 느린데다가 장비도 비싸 책을 썼고 이게 기폭제가 되어 스프링이 나오게 되었다.

JPA를 왜 사용해야하는가

생산성 - JPA와 CRUD

JPA로 CRUD는 코드가 다 만들어져 있어서 간편하다.

저장: jpa.persist(member)
조회: Member member = jpa.find(memberId)
수정: member.setName(“변경할 이름”)
삭제: jpa.remove(member)

제일 확연한 차이가 보이는것은 수정이다.
member.setName()만 쓰면 되기 때문이다.
이게 어떻게 가능한걸까? JPA의 사상자체가 JAVA 컬렉션을 사용하는 것처럼 데이터를 관리하는 것이기 때문이다.
자바 컬렉션에 객체를 넣으면, 값을 변경할 때 변경 후에 값을 다시 집어넣어야 하는지 생각해보자.아니었다. 그저 수정만 하면 됐다. JPA도 마찬 가지이다.

유지보수 - 기존 : 필드 변경시 모든 sql 수정

위에서 얘기 했듯이 필드를 하나 추가할 경우 모든 쿼리에 필드를 추가해야한다. 이 경우 누락이 있을 수 있다.

하지만 jpa는 쿼리를 일일히 다 변경할 필요가 없다. 필드만 추가하면 JPQ가 알아서 SQL을 처리하기 때문이다.

JPA와 패러다임의 불일치 해결

JPA와 상속 - 저장

jpa.persist(album)하면 jpa가 알아서 쿼리를 둘로 나눠서 insert한다. item 테이블에도, album테이블에도 쿼리를 날린다.
따라서 개발자는 데이터베이스의 구조에 대해 고민하지 않아도 된다.

JPA와 상속-조회

알아서 join해서 데이터를 가져온다.

jpa와 연관관계, 객체 그래프 탐색

연관관계 저장
persist : 영구적으로 저장하다라는 뜻을 가진다.
member.setTeam(team)해서 persist로 저장하면 객체 그래프 탐색에서 get으로 Team을 가져올 수 있다.

신뢰할 수 있는 엔티티 , 계층

객체 그래프 탐색이 자유롭지 못했는데, jpa를 사용하면 자유롭게 탐색할 수 있다.
jpa는 지연로딩이라는 기능이 있기 때문에 member.getTeam이나 member.getOrder로 객체를 조회해서 사용하는 시점에 sql이 나가서 데이터가 채워진다.
따라서 자유로운 객체 그래프 탐색이 가능하고, find로 조회한 member객체를 신뢰할 수 있다.

데이터가 다 있다는 과정하이다.

JPA와 비교하기

String memberId = "100";
Member member1 = jpa.find(Member.class, memberId);
Member member2 = jpa.find(Member.class, memberId);
member1 == member2; //같다.

member1과 member2는 같다. 동일한 트랜잭션에서 조회한 엔티티는 같음을 보장한다.

JPA의 성능 최적화 기능

jpa를 쓰면 성능이 더 떨어지지 않을까라는 고민이 들 수 있다.
계층사이에 중간계층이 있으면 할 수 있는게 있다. 모아서 쏘는 버퍼링과 읽을 때 캐싱등이다.
cpu나 메모리도 마찬가지이다. jpa도 중간계층이기 때문에 이런걸 최적화한다. 정말 잘 다룬다면 단순한 sql 쓰는 것 보다 오히려 성능을 더 끌어올릴 수 있다.

1차 캐시와 동일성 보장

  1. 같은 트랜잭션 안에서는 같은 엔티티를 반환 -> 약간의 조회성능 향상
    같은 객체를 많이 조회하는 비즈니스 로직이 있을 때 도움이 된다.
String memberId = "100";
Member m1 = jpa.find(Member.class, memberId); //sql
Member m2 = jpa.find(Member.class, memberId); // 캐시

캐싱을 하고 있기 때문에 같은 객체가 반환된다.
따라서 결과적으로 sql은 한번만 실행된다.

고객의 트래픽 요청이 오면 한번 실행하고 쭉 빠져나간다. 그동안 트랜잭션이 시작하고 빠져나가면 고객이 한명 빠져나가면 그 트랜잭션이 끝난다.
그 사이에서의 동일성을 보장하는 것이기 때문에 사실상 굉장히 짧은 시간의 캐싱이다. 따라서 실무에서 그렇게 큰 도움은 안된다.

트랜잭션을 지원하는 쓰기 지연 - INSERT

버퍼링에 대한 것이다.

  1. 트랜잭션을 커밋할 때까지 INSERT SQL을 모음
transaction.begin(); // [트랜잭션] 시작
em.persist(memberA);
em.persist(memberB);
em.persist(memberC);
//여기까지 INSERT SQL을 데이터베이스에 보내지 않는다.
//커밋하는 순간 데이터베이스에 INSERT SQL을 모아서 보낸다.
transaction.commit(); // [트랜잭션] 커밋

똑같은 쿼리가 3개 일때 member1,2,3을 동시에 넣고 싶다면 각각의 쿼리에서 db가 나가서 네트워크를 3번 타게 된다.
그렇다 보면 아무래도 네트워크가 왔다갔다해야하니 느리다. 그래서 이걸 한번에 모아서 db에 보내는 기능이 jdbc batch이다.
그러나 jdbc batch가 굉장히 복잡하다 하지만 jpa에선 옵션하나만 켜주면 이걸 할 수 있다.

  1. JDBC BATCH SQL 기능을 사용해서 한번에 SQL 전송

jpa는 일단 커밋전에 쿼리들을 메모리에 쌓고 커밋시에 메모리에 있는 쿼리를 확인, 동일한 쿼리는 한번에 네트워크를 통해 보낸다.

트랜잭션을 지원하는 쓰기 지연 - UPDATE

  1. UPDATE, DELETE로 인한 로우(ROW)락 시간 최소화
  2. 트랜잭션 커밋 시 UPDATE, DELETE SQL 실행하고, 바로 커밋
transaction.begin(); // [트랜잭션] 시작
changeMember(memberA); 
deleteMember(memberB); 
비즈니스_로직_수행(); //비즈니스 로직 수행 동안 DB 로우 락이 걸리지 않는다. 
//커밋하는 순간 데이터베이스에 UPDATE, DELETE SQL을 보낸다.
transaction.commit(); // [트랜잭션] 커밋

지연 로딩과 즉시 로딩

지연로딩 : 객체가 실제 사용될 때 로딩

 Member member = memberDAO.find(memberId);
 Team team = member.getTeam();
 String teamName = team.getName();
find -> SELECT * FROM MEMBER
getName -> SELECT * FROM TEAM

MEMBER객체를 find를 통해 가져오면 JPA는 MEMBER와 TEAM이 연관되어 있지만 MEMBER만 가져온다. 따라서 MEMBER만 SELECT 쿼리가 나간다.
getTeam을 통해 TEAM객체를 가져오고 TEAM객체의 어떤 값(예제에선 NAME)을 건드린다면 실제 그 값이 필요한 시점에 JPA가 DB 에 TEAM에 대한 쿼리를 날려서 값을 가져와 데이터를 채워서 반환한다.

지연 로딩은 쿼리가 두번나가 네트워크를 두번 탄다. 따라서 쿼리가 너무 많이 나간다.

즉시 로딩 : JOIN SQL로 한번에 연관된 객체까지 미리 조회

Member member = memberDAO.find(memberId);
Team team = member.getTeam();
String teamName = team.getName();
find 
->
SELECT M.*, T.* 
FROM MEMBER
JOIN TEAM …

MEMBER를 쓸 때 TEAM을 많이 쓴다면 MEMBER를 조회할 때 TEAM을 같이 조회하는게 좋다.
그래서 JPA의 옵션을 키면 MEMBER를 조회할 때 TEAM까지 같이 조회된다.따라서 find이후에 get을 하면 다시 쿼리를 날릴 필요가 없다.

하지만 MEMBER를 쓸 때 TEAM을 자주 사용하지 않고 가끔 사용한다면 지연로딩을 사용하는 것이 좋다.

개발시에는 지연로딩으로 개발하고 최적화시에 즉시로딩으로 바꿀것만 바꾼다.

ORM은 객체와 RDB 두 기둥위에 있는 기술

JPA만 잘 안다고 해서 잘할수 있는게 아니다. RDB도 잘해야한다.
둘의 밸런스를 잘 맞춰야한다.
그래도 둘중에 더 중요한것은 ? RDB

SQL 중심적인 개발에서 객체 중심으로 개발


출처 : 자바 ORM 표준 JPA 프로그래밍 - 기본편

profile
백엔드 개발자 지망생

0개의 댓글