이전 게시글에 이어 간단한 예시로 만들어보려고 한다.
Menu, Category, MenuAndCategory 클래스를 만들어서 할 것이다.
Category
public class Category {
private int categoryCode;
private String categoryName;
// 기본 생성자, 매개변수 생성자, getter, setter, toString() 만들기
}
Menu
public class Menu {
private int menuCode;
private String menuName;
private int menuPrice;
private int categoryCode;
private String orderableStatus;
// 기본 생성자, 매개변수 생성자, getter, setter, toString() 만들기
}
MenuAndCategory
public class MenuAndCategory {
private int menuCode;
private String menuName;
private int menuPrice;
private Category category;
private String orderableStatus;
// 기본 생성자, 매개변수 생성자, getter, setter, toString() 만들기
}
JPA 예제는 테스트 코드에서 해볼 것이다.
JDBC API를 이용해 쿼리를 직접 쓰게 되면 생기는 문제들에 대해 먼저 살펴볼 것이기 때문에 테스트 클래스를 만들어보자.
ProblemOfUsingDirectSQLTests
class ProblemOfUsingDirectSQLTests {
private Connection con;
@BeforeEach
void setConnection() throws SQLException {
String driver = "com.mysql.cj.jdbc.Driver";
String url = "jdbc:mysql://localhost:3306/menudb";
String user = "swcamp";
String password = "swcamp";
con = DriverManager.getConnection(url, user, password);
con.setAutoCommit(false);
}
@AfterEach
void closeConnection() throws SQLException {
con.rollback(); // 직접 Java에 영향을 주지 않기 위해 롤백
con.close();
}
}
일단은 직접 SQL을 작성해서 메뉴를 조회하면서 문제를 확인해보자.
ProblemOfUsingDirectSQLTests에 추가
@DisplayName("직접 SQL을 작성하여 메뉴를 조회할 때 발생하는 문제 확인")
@Test
void testDirectSelectSql() throws SQLException {
// given
String query = "SELECT MENU_CODE, MENU_NAME, MENU_PRICE, CATEGORY_CODE, ORDERABLE_STATUS"
+ " FROM TBL_MENU";
// when
Statement stmt = con.createStatement();
ResultSet rset = stmt.executeQuery(query);
List<Menu> menuList = new ArrayList<>();
while(rset.next()) {
Menu menu = new Menu();
menu.setMenuCode(rset.getInt("MENU_CODE"));
menu.setMenuName(rset.getString("MENU_NAME"));
menu.setMenuPrice(rset.getInt("MENU_PRICE"));
menu.setCategoryCode(rset.getInt("CATEGORY_CODE"));
menu.setOrderableStatus(rset.getString("ORDERABLE_STATUS"));
menuList.add(menu);
}
// then
Assertions.assertTrue(!menuList.isEmpty());
menuList.forEach(System.out::println); // 로그 개념으로 출력해보기
}
왜 jpa를 쓰는지 알려줄 수 있는 구간이when 부분이다.
Statement, ResultSet을 직접 작성해야 하고 set, get을 이용해서 하는 것부터 굉장히 불편하다.
전체가 아닌 일부일 때도 새로 만들거나 해야 해서 sql부터 새로 짜야 하는 번거로움이 있다.
데이터 변환, SQL 작성, JDBC API 코드 등을 중복 작성(개발시간 증가, 유지보수 저하)
이전에 했던 것과 다른 컴퓨터로 옮기면서 데이터 스크립트를 새로 등록했었고,


잘 되는 것을 볼 수 있었다.
2번째 문제를 한 번 예제로 봐보자.
ProblemOfUsingDirectSQLTests에 추가
@DisplayName("직접 SQL을 작성하여 신규 메뉴를 추가할 때 발생하는 문제 확인")
@Test
void testDirectInsertSQL() throws SQLException {
// given
Menu menu = new Menu();
menu.setMenuName("민트초코짜장면");
menu.setMenuPrice(12000);
menu.setCategoryCode(1);
menu.setOrderableStatus("Y");
String query = "INSERT INTO TBL_MENU(MENU_NAME, MENU_PRICE, CATEGORY_CODE, "
+ "ORDERABLE_STATUS) VALUES (?, ?, ?, ?)";
// when
PreparedStatement pstmt = con.prepareStatement(query);
pstmt.setString(1, menu.getMenuName());
pstmt.setInt(2, menu.getMenuPrice());
pstmt.setInt(3, menu.getCategoryCode());
pstmt.setString(4, menu.getOrderableStatus());
int result = pstmt.executeUpdate();
// then
Assertions.assertEquals(1, result);
pstmt.close();
}
? 을 활용해서 1, 2, 3, 4를 넣으면서 한다는 것을 보면 SQL에 의존해서 넣어서 개발하는 것을 볼 수 있는데 이것도 문제이다.
특정 변수에 담아서 넣는다면 그나마 괜찮지만 저렇게 값을 넣는 과정은 좋지 않다.
SQL에 의존하여 개발
3번째 문제를 한 번 예제로 봐보자.
ProblemOfUsingDirectSQLTests에 추가
@DisplayName("연관된 객체 문제 확인")
@Test
void testAssociationObject() throws SQLException {
// given
String query = "SELECT A.MENU_CODE, A.MENU_NAME, A.MENU_PRICE, B.CATEGORY_CODE, "
+ "B.CATEGORY_NAME, A.ORDERABLE_STATUS "
+ "FROM TBL_MENU A "
+ "JOIN TBL_CATEGORY B ON (A.CATEGORY_CODE = B.CATEGORY_CODE)";
// when
Statement stmt = con.createStatement();
ResultSet rset = stmt.executeQuery(query);
List<MenuAndCategory> menuAndCategories = new ArrayList<>();
while(rset.next()) {
MenuAndCategory menuAndCategory = new MenuAndCategory();
menuAndCategory.setMenuCode(rset.getInt("MENU_CODE"));
menuAndCategory.setMenuName(rset.getString("MENU_NAME"));
menuAndCategory.setMenuPrice(rset.getInt("MENU_PRICE"));
menuAndCategory.setCategory(new Category(rset.getInt("CATEGORY_CODE"),
rset.getString("CATEGORY_NAME")));
menuAndCategory.setOrderableStatus(rset.getString("ORDERABLE_STATUS"));
menuAndCategories.add(menuAndCategory);
}
// then
Assertions.assertTrue(!menuAndCategories.isEmpty());
menuAndCategories.forEach(System.out::println);
}
패러다임 불일치(상속, 연관관계, 객체 그래프 탐색, 방향성)
위의 문제점들 중 코드에서 다룬건 연관관계이다.
Menu 와 Category가 연관돼있을 때 위와 같이 구현해야 한다.
이후 JPA를 배우는데 @ManyToOne을 통해서 해줄 수 있긴 하지만, 그것도 많이 쓰지 않는 것이 좋다. (여러 문제를 일으킬 수 있기 때문이다.)
상속, 연관관계 등 개념에 대해 보자.
상속 문제
객체 지향 언어의 상속 개념과 유사한 것이 데이터베이스의 서브타입 엔티티이다.(서브 타입을 별도의 테이블로 나누었을 때)
슈퍼타입의 모든 속성을 서브타입이 공유하지 못하여 물리적으로 다른 테이블로 분리가 된 형태이다. (설계에 따라서는 다를 수 있다.)
하지만 객체지향의 상속은 슈퍼타입의 속성을 공유해서 사용하므로 여기에서 패러다임의 불일치가 발생한다.
연관 관계
객체지향에서 말하는 가지고 있는(ASSOCIATION 연관관계 혹은 COLLECTION 연관관계) 경우 데이터베이스 저장 구조와 다른 형태이다.
public class Menu {
private int menuCode;
private String menuName;
private int menuPrice;
private int categoryCode;
private String orderableStatus;
}
public class Menu {
private int menuCode;
private String menuName;
private int menuPrice;
private Category category;
private String orderableStatus;
}
객체 지향 언어에 맞춘 객체 모델은 JPA에서 추구하는 방식이다.
애초에 고민하는 것 자체가 조인을 염두에 둘 것인가를 두고 하는 것이다.
4번째 문제
ProblemOfUsingDirectSQLTests에 추가
@DisplayName("조회한 두 개의 행을 담은 객체의 동일성 비교 테스트")
@Test
void testEquals() throws SQLException {
// given
String query = "SELECT MENU_CODE, MENU_NAME FROM TBL_MENU WHERE MENU_CODE = 12";
// when
Statement stmt1 = con.createStatement();
ResultSet rset1 = stmt1.executeQuery(query);
Menu menu1 = null;
if(rset1.next()) {
menu1 = new Menu();
menu1.setMenuCode(rset1.getInt("MENU_CODE"));
menu1.setMenuName(rset1.getString("MENU_NAME"));
}
Statement stmt2 = con.createStatement();
ResultSet rset2 = stmt2.executeQuery(query);
Menu menu2 = null;
if(rset2.next()) {
menu2 = new Menu();
menu2.setMenuCode(rset2.getInt("MENU_CODE"));
menu2.setMenuName(rset2.getString("MENU_NAME"));
}
// then
Assertions.assertNotEquals(menu1, menu2);
}
보면 JPA를 사용하지 않고 한다면 계속 객체가 생성되고 낭비가 된다.
JPA를 이용하면 동일 비교가 가능하다.
영속성 컨텍스트에 담겨있는 객체를 계속 entityManager가 가져다주기 때문에 객체도 낭비되지 않는다.
Menu menu1 = entityManager.find(Menu.class, 12);
Menu menu2 = entityManager.find(Menu.class, 12);
System.out.println(menu1 == menu2) // true(동일성이 보장된다. 같은 객체)
Java 환경에서 JDK 17버전으로 프로젝트를 만들고
TEST 패키지 속 resources/META-INF 안에 persistence.xml 을 작성하자.
여러 패키지를 만들고 이름은 같지만 다른 클래스인 Menu도 만들 것이다.
<?xml version="1.0" encoding="UTF-8"?>
<persistence xmlns="http://xmlns.jcp.org/xml/ns/persistence" version="2.2">
<!-- 설명. 엔티티 매니저 팩토리를 식별하기 위한 이름 설정 -->
<persistence-unit name="jpatest">
<!-- 설명. 엔티티는 설정에 따로 추가할 것 -->
<class>com.jehun.section02.crud.Menu</class>
<class>com.jehun.section03.persistencecontext.Menu</class>
<properties>
<!-- 설명. 데이터베이스 연결 정보 -->
<property name="jakarta.persistence.jdbc.driver" value="com.mysql.cj.jdbc.Driver"/>
<property name="jakarta.persistence.jdbc.url" value="jdbc:mysql://localhost:3306/menudb"/>
<property name="jakarta.persistence.jdbc.user" value="{username}"/>
<property name="jakarta.persistence.jdbc.password" value="{username}"/>
<!-- 설명. hibernate 설정(실행되는 sql 구문을 괜찮은 format 형태로 보여주기) -->
<property name="hibernate.show_sql" value="true"/>
<property name="hibernate.format_sql" value="true"/>
<property name="hibernate.dialect" value="org.hibernate.dialect.MariaDB103Dialect"/>
</properties>
</persistence-unit>
</persistence>
com.jehun.section01.A_EntityManagerLifeCycleTests
import jakarta.persistence.EntityManager;
import jakarta.persistence.EntityManagerFactory;
import jakarta.persistence.Persistence;
import org.junit.jupiter.api.*;
public class A_EntityManagerLifeCycleTests {
private static EntityManagerFactory emf;
private static EntityManager em;
@BeforeAll
public static void initFactory() {
emf = Persistence.createEntityManagerFactory("jpatest");
}
@BeforeEach
public void initManager() {
em = emf.createEntityManager();
}
@Test
public void 엔티티_매니저_팩토리와_엔티티_매니저_생명주기_확인1() {
System.out.println("factory의 hashCode1: " + emf.hashCode());
System.out.println("manager의 hashCode1: " + em.hashCode());
}
@Test
public void 엔티티_매니저_팩토리와_엔티티_매니저_생명주기_확인2() {
System.out.println("factory의 hashCode2: " + emf.hashCode());
System.out.println("manager의 hashCode2: " + em.hashCode());
}
@AfterEach
public void closeManager() {
em.close();
}
@AfterAll
public static void closeFactory() {
emf.close();
}
}
생명주기를 알아보면서 개념도 함께 알아보자.
엔티티 매니저 팩토리(EntityManagerFactory)란?
따라서 데이터베이스를 사용하는 애플리케이션 당 한 개의 EntityManagerFactory를 생성한다.
persistence.xml 속 엔티티 매니저 팩토리를 식별하기 위한 이름 설정
<persistence-unit name="jpatest">
엔티티 매니저(EntityManager)란?
따라서 스레드 간 공유를 하지 않고, web의 경우 일반적으로 request scope와 일치시킨다.
영속성 컨텍스트(PersistenceContext)란?
다시 위의 코드와 함께 보자.
private static EntityManagerFactory emf;
private static EntityManager em;
@BeforeAll
public static void initFactory() {
emf = Persistence.createEntityManagerFactory("jpatest");
}
@BeforeEach
public void initManager() {
em = emf.createEntityManager();
}
시작하기 전 팩토리는 식별해뒀던 jpatest로 만든다.
그 다음 jpa 담당 매니저라고 볼 수 있는 EntityManager를 만든 것이다.
매니저라고는 하지만 실제로는 스트림이기 때문에 테스트 케이스가 끝나면 닫아줘야 한다.
@AfterEach
public void closeManager() {
em.close();
}
@AfterAll
public static void closeFactory() {
emf.close();
}
위의 어노테이션이 달린 부분은 하나의 틀이라고 생각하면 된다.
이제 테스트 코드의 메소드이다.
@Test
public void 엔티티_매니저_팩토리와_엔티티_매니저_생명주기_확인1() {
System.out.println("factory의 hashCode1: " + emf.hashCode());
System.out.println("manager의 hashCode1: " + em.hashCode());
}
@Test
public void 엔티티_매니저_팩토리와_엔티티_매니저_생명주기_확인2() {
System.out.println("factory의 hashCode2: " + emf.hashCode());
System.out.println("manager의 hashCode2: " + em.hashCode());
}


싱글톤한지 확인하기 위해 출력해보았다.
A_EntityManagerCRUDTests 라는 이름으로 해볼 것이다.
위의 코드 중 @BeforeEach, @BeforeAll, @AfterEach, @AfterAll
메소드들은 가져오자.
이제 CRUD를 만들어 볼 것이다.
A_EntityManagerCRUDTests
@Test
public void 메뉴코드로_메뉴_조회_테스트() {
// given
int menuCode = 2;
// when
Menu foundMenu = em.find(Menu.class, menuCode);
// then
Assertions.assertNotNull(foundMenu);
Assertions.assertEquals(menuCode, foundMenu.getMenuCode());
System.out.println("foundMenu = " + foundMenu);
}
menuCode에 맞는 Menu.class 타입을 보고 Menu에 담아서 반환된 것이 foundMenu이다.
메뉴 클래스는 아래와 같다.
Menu
@Entity(name="section02_menu")
@Table(name="tbl_menu")
public class Menu {
@Id
@Column(name="menu_code")
@GeneratedValue(strategy=GenerationType.IDENTITY)
private int menuCode;
@Column(name="menu_name")
private String menuName;
@Column(name="menu_price")
private int menuPrice;
@Column(name="category_code")
private int categoryCode;
@Column(name="orderable_status")
private String orderableStatus;
// 기본 생성자, 매개변수 있는 생성자, getter, setter, toString()
}
테스트 코드 출력 결과

READ는 잘 됐다. 다음은 INSERT를 해보자.
A_EntityManagerCRUDTests
@Test
public void 새로운_메뉴_추가_테스트() {
// given
Menu menu = new Menu();
menu.setMenuName("꿀발린추어탕");
menu.setMenuPrice(7000);
menu.setCategoryCode(4);
menu.setOrderableStatus("Y");
// when
EntityTransaction tx = em.getTransaction();
tx.begin();
try {
em.persist(menu);
tx.commit();
} catch(Exception e) {
tx.rollback();
}
// then
Assertions.assertTrue(em.contains(menu)); // 영속성 컨텍스트에 menu가 존재하는지 확인
}


INSERT도 잘 된 것을 알 수 있다.
A_EntityManagerCRUDTests
@Test
public void 메뉴_이름_수정_테스트() {
// given
Menu menu = em.find(Menu.class, 2);
System.out.println("menu = " + menu);
String menuNameToChange = "문어스무디";
// when
// EntityTransaction tx = em.getTransaction();
// tx.begin();
try {
menu.setMenuName(menuNameToChange);
// tx.commit();
} catch(Exception e) {
// tx.rollback();
}
// then
Assertions.assertEquals(menuNameToChange, em.find(Menu.class, 2).getMenuName());
}
위의 코드들 다 지금 전부 새로운 EntityManager를 만들고 사용한다.
그래서 일단 비어있기 때문에 Update 코드에서도 2번 Menu를 가져온다.
즉, 영속 상태의 객체인 것이다. (EntityManager 가 가져다주니까)
EntityManager이 현재 보고 있는 상태인데 변경을 한다면, update문이 쓰기 지연 저장소에 쌓이게 된다.
그리고 그 후에 변화가 없이 commit이 일어난다면 쓰기 지연 저장소에 쌓인 것들을 flush() 하면서 DB에 덮어씌운다.
영속 상태인 객체를 가져와 변경만 한 뒤 DB에 덮어씌우지 않기 위해서 트랜잭션 부분만 주석처리했다.

바꾸긴 했지만 DB 상에는 변화가 없다. (출력문도 쿼리 보내기 전이다.)

A_EntityManagerCRUDTests
@Test
public void 메뉴_삭제하기_테스트() {
// given
Menu menuToRemove = em.find(Menu.class, 2);
// when
EntityTransaction tx = em.getTransaction();
tx.begin();
try {
em.remove(menuToRemove);
tx.commit();
} catch(Exception e) {
tx.rollback();
}
// then
Menu removeMenu = em.find(Menu.class, 2);
Assertions.assertEquals(null, removeMenu);
}
삭제도 잘 될 것이다.