결론 관계형 데이터 베이스에선 정규화된 테이블 2개로 다대다 관계를 표현할 수 없다. 그렇지만 객체에서는 가능하다.
Member 쪽에서 products쪽과 ManyToMany로 연결해준다.
Product 쪽에서 JoinTable(Member_product)을 선언하여 링크테이블을 생성해주는 어노테이션을 단다.
자동으로 1:n, n:1 관계를 만드는 것과 같은 원리이다.
오! 편리하다. 생각하고 사용하면 안된다. - 연결 테이블이 단순히 연결만 하고 끝나지 않는다. (무슨말이지? 단순히 연결만 하고 끝나지 않고 추가 데이터가 들어간다. 별에별게 들어간다. 또한 중간 테이블이 예상할 수가 없다.) - 주문시간, 수량 같은 데이터가 추가로 들어올 수 있다. (Member_Product에 들어감)
리팩토링
연결 테이블용 엔티티 추가 (연결 테이블을 엔티티로 승격!)
Member : 링크 테이블과 연결
Product : 링크 테이블과 연결
MemberProduct : 링크 테이블 추가
count, price, OrderDateTime을 넣을 수 있음
결론 양방향으로 여러개를 가질 수 있을 때 고민해야된다. 주인 한명에 강아지 2개 반대로 강아지 한마리에 주인이 여러명일 경우 우리가 그렇네! 그럴 경우 링크테이블을 걸어야한다.
도메인 모델과 테이블 설계
회원 <-> 주문 : 회원은 주문을 여러 건 할 수 있기 때문에 1:n의 관계이다.
주문<-> 배송 : 주문과 배송은 1:1 의 관계이다.
주문<->상품 : 주문과 상품은 n:n의 관계이다. 왜냐하면 한 번 고객이 한 번 주문 할때 여러 상품을 선택할 수 있기 때문이다. 이런 다대다 관계는 관계형 데이터베이스는 물론이고 엔티티에서도 거의 사용하지 않는다. 따라서 주문상품을 추가하여 주문 <-> 주문상품 <->상품(물품)으로 1:n, n:1로 풀어냈다.
엔티티 설계
회원(Member) : 이름과 임베디드 타입인 주소(Address), 그리고 주문(orders)리스트를 가진다.
주문(Order) : 한 번 주문시 여러 상품을 주문할 수 있으므로 주문과 주문상품(OrderItem)을 일대다 관계다.
주문상품(OrderItem) : 주문한 상품정보와 주문 금액(OrderPrice), 주문수량(count) 정보를 가지고 있다.
상품(Item) :이름, 가격, 재고수량(stockQuantity)을 가지고 있다. 상품을 주문하면 재고수량이 줄어든다.
다중성(Multiplicity) : 다대일(N:1), 일대다(1:N), 일대일(1:1), 다대다(N:M) 이해
연관관계의 주인(Owner) : 객체 양방향 연관관계는 관리가 필요(가장 까다로움)
연관관계가 필요한 이유
객체지향 설계의 목표는 자율적인 객체들의 협력 공동체를 만드는 것이다.
예제 시나리오
회원과 팀이 있다.
회원은 하나의 팀에만 소속될 수 있다.
회원과 팀은 다대일 관계이다. (회원은 여러명이고, 팀은 1개이다.)
객체를 테이블에 맞추어 모델링
(연관관계가 없는 객체)
Member는 N이며 TEAM은 1이다.
@Entity
public class Member {
@Id
@Column(name = "MEMBER_ID")
private String id;
private String username;
//연관관계 매핑
@ManyToOne
@JoinColumn(name="TEAM_ID")
private Team team;
//연관관계 설정
public void setTeam(Team team) {
this.team = team;
}
//Getter, Setter ...
}
@Entity
public class Team {
@Id
@Column (name = "TEAM_ID")
private String id;
private String name;
//Getter, Setter ...
}
Team team = new Team();
team.setName("TeamA");
em.persist(team);
//Member
Member member = new Member();
member.setUsername("member1");
member.setTeamId(team.getId()); //여기서 이상함
em.persist(member);
//문제가 많은 find하기
Member findMember = em.find(Member.class, member.getId());
Long findTeamId = findMember.getTeamId();
Team findTeam = em.find(Team.class, findTeamId);
위의 코드에서 문제점은 findMember를 memberId로 조회하고 그 조회한 내용 중에 TeamId를 가져와서 Team을 또 조회한다.
Join으로 협력관계가 있는게 아닌 Select를 두 번 하는 꼴
객체를 테이블에 맞추어 데이터 중심으로 모델링하면, 협력 관계를 만들 수 없다.
객체 지향 모델링
위의 모델링과 다른 점은 TeamId가 아닌 Team을 모델링 했다는 점이다.
@Entity
public class Member {
@Id
@Column(name = "MEMBER_ID")
private String id;
private String username;
//연관관계 매핑
@ManyToOne
@JoinColumn(name="TEAM_ID")
private Team team; //가장 중요
//연관관계 설정
public void setTeam(Team team) {
this.team = team;
}
//Getter, Setter ...
}
@ManyToOne
Member 입장 에서 N이고 Team이 1이기 때문에 ManyToOne이다.
Team team = new Team();
team.setName("TeamA");
em.persist(team);
//Member
Member member = new Member();
member.setUsername("member1");
//member.setTeamId(team.getId()); //여기서 이상함
member.setTeam(team);
em.persist(member);
//문제가 많은 find하기
Member findMember = em.find(Member.class, member.getId());
//Long findTeamId = findMember.getTeamId();
//Team findTeam = em.find(Team.class, findTeamId);
Team findTeam = findMember.getTeam();
@Lob에는 지정할 수 있는 속성이 없고, 대신에 매핑하는 필드 타입이 문자면, CLOB으로 매핑하고 나머지는 BLOB로 매핑한다.
@Transient
객체 임시로 어떤 값을 넣고 싶을 때 사용하고 데이터베이스에는 반영이 안된다.
데이터 베이스 스키마 자동 생성
사실상 운영에선 사용하진 않고, 개인적으로 개발할때 정도 사용한다.
DDL을 애플리케이션 실행 시점에 자동 생성
테이블 중심 -> 객체 중심
데이터베이스 방언을 활용해서 데이터베이스에 맞는 적절한 DDL 생성
xml 다음과 같이 입력하면 초기 실행 시 자동으로 테이블을 생성한다.
<property name="hibernate.hbm2ddl.auto" value ="create"/>
위의 value 속성은 개발 단계마다 다르게 생성할 수있다.
개발 초기 단계에는 create 또는 update
테스트 서버는 update 또는 validate(엔티티와 테이블이 정상 매핑되었는지만 확인)
스테이징과 운영서버는 validate 또는 none
운영 장비에는 절대 create, create-drop, update 사용하면 안된다.
기본 키 매핑
기본 키 매핑 어노테이션
@Id
@GeneratedValue
@Id @GeneratedValue(strategy = GenerationType.AUTO)
private Long id;
기본 키 매핑 방법
데이터베이스마다 기본 키를 생성하는 방식이 서로 다르므로 이 문제를 해결하기는 쉽지 않다. JPA는 이런 문제들을 어떻게 해결하는지 알아보자
직접 할당 : @Id만 사용
자동 생성(@GeneratedValue)
IDENTITY : 데이터베이스에 위임(DB에 따라서 알아서(임의로) 해줌), MYSQL, PostgreSQL, SQL Server, DB2에서 사용
MySQL의 AUTO_INCREMENT
SEQUENCE : 데이터베이스 시퀀스 오브젝트 사용, ORACLE
@SequenceGenerator 필요
TABLE : 키 생성용 테이블 사용, 모든 DB에서 사용
@TableGenerator 필요
기본 키 직접 할당 전략
@Id 적용 가능 자바 타입
자바 기본형
자바 래퍼wrapper형
String
java.util.Date
java.sql.Date
java.math.BigDecimal
java.math.BigInteger
SEQUENCE 전략
sequenceName 속성의 이름으로 BOARD_SEQ 를 지정했는데 JPA는 이 시퀸스 생성기를 실제 데이터베이스 BOARD_SEQ 시퀀스와 매핑한다. sequenceName을 따로 설정하지 않으면 hibernate_sequence와 같이 자동으로 설정된다.
@Entity
@SequenceGenerator(
name = "BOARD_SEQ_GENERATOR".
sequenceName = ”BOARD_SEQ”, //매핑할 데이터베이스 시퀀스 이름
initialvalue = 1,
allocationsize = 1)
public class Board {
@IdQGeneratedValue(
strategy = GenerationType.SEQUENCE,
generator = "BOARD_SEQ_GENERATOR")
private Long id;
...
}
call next value for MEMBER_SEQ란? 영속성 컨텍스트에 의해 JPA는 항상 PK값을 알아야한다. 그래서 MEMBER_SEQ값에서 id값을 조회한다. 그러고 나서 em.persist를 해준다.
commit 하는 시점에 insert쿼리가 날라간다.
SquenceGenerator.allocationSize의 기본값이 50인 이유는 최적화 때문이다 allocationSize 값이 50이면 시퀀스를 한 번에 50 증가 시킨 다음에 1~50까지는 메모리에서 식별자를 할당한다. 이 최적화 방법은 시퀀스 값을 선점 하므로 여러 JVM이 동시에 동작 해도 기본 키 값이 충돌하지 않는 장점이 있다. 반면에 데이터베이스에 직접 접근해서 데이터를 등록할 때 시퀀스 값이 한번에 많이 증가한다는 점을 염두해 두어야 한다. 참고로 앞서 설명한 hibernate.id.new_generator_mappings 속성을 true로 설정해야 지금까지 설명한 최적화 방법이 적용된다.
TABLE 전략(운영에서 잘 쓰이지 않음)
TABLE 전략은 키 생성 전용 테이블을 하나 만들고 여기에 이름과 값으로 사용할 컬럼을 만들어 데이터베이스 시퀀스를 흉내내는 전략이다.
장점 : 모든 데이터베이스에 적용 가능
단점 : 성능
@Entity
@TableGenerator(
name = "BOARD_SEQ_GENERATOR",
table = ”MY_SEQUENCES",
pkColumnValue = ”BOARD_SEQ”,
allocationsize = 1)
public class Board {
@Id
@GeneratedValue(
strategy = GenerationType.TABLE,
generator = '' BOARD_SEQ_GENERATOR''
)
private Long id;
...
}
TABLE 전략과 최적화 TABLE 전략은 값을 조회하면서 SELECT 쿼리를 사용하고 다음 값으로 증가시키기 위해 UPDATE 쿼리를 사용한다. 이 전략은 SEQUENCE 전략과 비교해서 데이터베이스와 한번 더 통신하는 단점이 있다. TABLE 전략을 최적화하려면 TableGenerator.allocationSize를 사용하면 된다.
권장하는 식별자 전략
기본 키 제약 조건 : null 아님, 변하면 안된다.
미래까지 이 조건에 만족하는 자연키는 찾기 어렵다. 대리키(대체키)를 사용하자.
예를 들어 주민등록번호도 기본 키로 적절하지 않다.
권장 : Long형 + 대체키 + 키 생성전략 사용
AUTO 전략
GenerationType.AUTO는 선택한 데이터베이스 방언에 따라 IDENTITY, SEQUENCE, TABLE 전략 중 하나를 자동으로 선택한다. AUTO 전략의 장점은 데이터베이스를 변경해도 코드를 수정할 필요가 없다는 것이다. AUTO를 사용할 때 SEQUENCE나 TABLE 전략이 선택되면 시퀀스나 키 생성용 테이블을 미리 만들어 두어야 한다.
IDENTITY 전략
기본 키 생성을 데이터베이스에 위임
IDENTITY 전략은 지금 설명한 AUTO INCREMENT를 사용한 예제처럼 데이터베이스에 값을 저장하고 나서야 기본 키 값을 구할 수 있을 때 사용한다.
문제점 : IDENTITY 전략은 데이터를 데이터베이스에 INSERT한 후에 기본 키 값을 조회할 수 있다. 티티가 영속 상태가 되려면 식별자가 반드시 필요하다. 그런데 IDENTITY 식별자 생성 전략은 엔티티를 데이터베이스에 저장해야 식별자를 구할 수 있으므로 em.persist()를 호출하는 즉시 INSERT SQL 이 데이터베이스에 전달된다. 따라서 이 전략은 트랜잭션을 지원하는 쓰기 지연이 동작하지 않는다. 왜냐하면 JPA는 보통 트랜잭션 커밋 시점에 INSERT SQL 실행합니다. 그리고 AUTO_INCREMENT는 데이터베이스에 INSERT SQL을 실행한 이후에 ID 값을 알 수 있습니다.
우리는 .persist를 해당 entity를 DB에 저장한다고 이해했다. 그렇지않고 우리는 entity를 DB에 저장하는 것이 아닌 영속성 컨텍스트에 저장하는 것이다.
영속성 컨텍스트는 논리적인 개념이다.
눈에 보이지 않는다.
엔티티 매니저를 통해서 영속성 컨텍스트에 접근
영속성 컨텍스트 생성 과정
EntityManasger를 생성하면 위 그림 처럼 영속성 컨텍스트가 생성됩니다.
엔티티의 생성주기
비영속(new/transient)
영속성 컨텍스트와 전혀 관계가 없는 새로운 상태
영속(managed)
영속성 컨텍스트에 관리되는 상태
준영속(detached)
영속성 컨텍스트에 저장되었다가 분리된 상태
삭제(removed)
삭제된 상태
비영속 상태(JPA랑 전혀 관계 없는 상태)
영속
DB에는 언제 저장하는가?
em.persist(member);
위의 상태에서는 DB에 저장한 것이 아닌 영속성 컨텍스트에 저장한 것이다. 그렇다면 언제 DB에 저장하는 것 인가?
확인 코드
System.out.print("=== before ===");
em.persist(member);
System.out.print("=== after ===");
결과 : before와 after사이에 insert문이 날라가지 않는다.
이유
tx.commit;
트랜잭션 커밋 상태에서 결국 query가 날라가게 된다.
준영속, 삭제
회원 엔티티를 영속성 컨텍스트에서 분리, 준영속 상태
em.detach(member);
객체를 삭제한 상태
em.remove(member);
영속성 컨텍스트의 이점
바로 db에 저장하지 않고 중간과정을 거친다. 그래서 얻는 것은 다음과 같다.
1차 캐시
동일성 보장
트랜잭션을 지원하는 쓰기 지연
변경 감지
지연 로딩
엔티티 조회, 1차 캐시
1차 캐시로 저장하고 db에 저장한다.
어느 시점에? 트랜잭션
데이터베이스에서 조회
단, 한 트랜잭션안에서만 동작하기 때문에 큰 이점은 없다. 즉, 트랜잭션이 끝나면 휘발성으로 날라간다. 우리가 아는 캐싱을 적용하려면 2차캐싱을 적용해야한다.
영속 엔티티의 동일성 보장
1차 캐시로 반복 가능한 읽기(Repeatable read) 등급의 트랜잭션 격리 수준을 데이터베이스가 아닌 애플리케이션 차원에서 제공
엔티티 등록 트랜잭션을 지원하는 쓰기 지연
1차 캐시에 저장과 동시에 INSERT SQL 생성하여 쓰기 지연 SQL 저장소에 저장한다.
그리고 transactio.commit();을 만나면 flush가 되면서 db에 날라가고 commit이 된다.
jpa batch 라는 것으로 size를 관리한다.
약간 bulk insert 같은 느낌으로 버퍼를 모으는 느낌
★엔티티 수정 변경 감지(Drity Checking)★
EntityManager em = emf.createENtityManager();
EntityTransaction tr = em.getTransaction();
tr.begin(); //트랜잭션 시작
//영속 엔티티 조회
Member memberA = em.find(Member.class, "memberA");
//영속 엔티티 데이터 수정
MemberA.setUserName("hi");
MemberA.setAge(10);
//em.update(member) 이런 코드가 있어야 하지 않을까?
tr.commit(); //트랜잭션 커밋
위의 코드를 보면 memberA라는 객체에 setName, setAge를 해주고 update 쿼리를 해주지 않았는데도 자동으로 update가 된다. 왜그럴까? 뭔가 set을 해주고 persist를 해줘야 반영이 될 것 같은데 말이다.
1차 캐시안에는 @Id, Entity, 스냅샷이라는 것이 있다. 스냅샷은 최초로 영속성 컨텍스트에 들어온 값을 임시 저장하는 것이다. 그러고 Entity(새로 들어온 값) 스냅샷과 변경이 감지되면 update 쿼리를 쓰기 지연 SQL 저장소에 저장해버리고 DB에 반영한다.
플러시
영속성 컨텍스트의 변경내용을 데이터베이스에 반영
플러시 발생
변경 감지
수정된 엔티티 쓰기 지연 SQL 저장소에 등록
쓰기 지연 SQL 저장소의 쿼리를 데이터베이스에 전송
등록, 수정, 삭제 쿼리
영속성 컨텍스트를 플러시 하는 방법
em.flush() - 직접 호출 트랜잭션 커밋 전에 강제 느낌 (거의 사용x)
트랜잭션 커밋 - 플러시 자동 호출
JPQL 쿼리 실행 - 플러시 자동 호출
em.persist(memberA);
em.persist(memberB);
em.persist(memberC);
// 중간에 JPQL 실행
query = em.createQuery("select m from Member m", member.class);
List<Member> members = query.getResultList();
em.persist의 memberA,B,C는 아직 데이터베이스에 Insert 전이다.
따라서 select 문에서 조회가 안될 것 같지만, JPQL은 flush가 자동으로 된다. 그래서 조회가 된다.
flush 여부 설정 가능
FlushModeType.AUTO... or COMMIT
준영속 상태
영속 -> 준영속
영속 상태의 엔티티가 영속성 컨텍스트에서 분리(detached)
영속성 컨텍스트가 제공하는 기능을 사용 못함
거의 비영속 상태에 가깝다.
영속성 컨텍스트가 관리하지 않으므로 1차 캐시, 쓰기 지연, 변경 감지, 지연 로딩을 포함한 영속성 컨텍스트가 제공하는 어떤한 기능도 동작하지 않는다.
식별자 값을 가지고 있다.
비영속 상태는 식별자 값이 없을 수도 있지만 준영속 상태는 이미 한 번 영속상태였으므로 반드시 식별자 값을 가지고 있다
지연 로딩을 할 수 없다.
//1 : 떼어내기
em.detach(member);
tx.commit();//commit을 해도 em은 반영되지 않는다.
//2 1차 캐시를 조회함 그래서 같은 내용 또 조회해도 select 쿼리가 또 나감
em.clear();
//3. em.close() : 종료
성능 향상을 위해서 Batch Insert를 도입하는 과정 중 JPA, Mysql 환경에서의 Batch Insert에 대한 방법과 제약사항들에 대해서 정리했습니다. 결과적으로는 다른 프레임워크를 도입해서 해결했으며 본 포스팅은 JPA Batch Insert의 정리와, 왜 다른 프레임워크를 도입을 했는지에 대해한 내용입니다.
Batch Insert 란 ?
# 단건 insert
insert into payment_back (amount, order_id) values (?, ?)
# 멀티 insert
insert into payment_back (amount, order_id)
values
(1, 2),
(1, 2),
(1, 2),
(1, 2)
insert rows 여러 개 연결해서 한 번에 입력하는 것을 Batch Insert라고 말합니다. 당연한 이야기이지만 Batch Insert는 하나의 트랜잭션으로 묶이게 됩니다.
Batch Insert With JPA
위 Batch Insert SQL이 간단해 보이지만 실제 로직으로 작성하려면 코드가 복잡해지고 실수하기 좋은 포인트들이 있어 유지 보수하기 어려운 코드가 되기 쉽습니다. 해당 포인트들은 아래 주석으로 작성했습니다.JPA를 사용하면 이러한 문제들을 정말 쉽게 해결이 가능합니다.
// 문자열로 기반으로 SQL을 관리하기 때문에 변경 및 유지 보수에 좋지 않음
val sql = "insert into payment_back (id, amount, order_id) values (?, ?, ?)"
val statement = connection.prepareStatement(sql)!!
fun addBatch(payment: Payment) = statement.apply {
// code 바인딩 순서에 따라 오동작 가능성이 높음
// 매번 자료형을 지정해서 값을 입력해야 함
this.setLong(1, payment.id!!)
this.setBigDecimal(2, payment.amount)
this.setLong(3, payment.orderId)
this.addBatch()
}
// connection & statement 객체를 직접 close 진행, 하지 않을 경우 문제 발생 가능성이 있음
fun close() {
if (statement.isClosed.not())
statement.close()
}
쓰기 지연 SQL 지원 이란 ?
EntityMaanger em = emf.createEnttiyManager();
ENtityTranscation transaction = em.getTransaction();
// 엔티티 매니저는 데이터 변경 시 트랜잭션을 시작해야 한다.
transaction.begin();
em.persist(memberA);
em.persist(memberB);
// 여기까지 Insert SQL을 데이터베이스에 보내지 않는다.
// Commit을 하는 순간 데이터베이스에 Insert SQL을 보낸다
transaction.commit();
엔티티 매니저는 트랜잭션을 커밋 하기 직전까지 데이터베이스에 엔티티를 저장하지 않고 내부 쿼리 저장소에 INSERT SQL을 모아둔다. 그리고 트랜잭션을 커밋 할 때 모아둔 쿼리를 데이터베이스에 보내는데 이것을 트랜잭션을 지원하는 쓰기 지연이라 한다.
회원 A를 영속화했다. 영속성 컨텍스트는 1차 캐시에 회원 엔티티를 저장하면서 동시에 회원 엔티티 정보로 등록 쿼리를 만든다. 그리고 만들어진 등록 쿼리를 쓰기 지연 SQL 저장소에 보관한다.
다음으로 회원 B를 영속화했다. 마찬가지로 회원 엔티티 정보로 등록 쿼리를 생성해서 쓰지 지연 SQL 저장소에 보관한다. 현재 쓰기 지연 SQL 저장소에는 등록 쿼리가 2건이 저장되어 있다.
마지막으로 트랜잭션을 커밋 했다. 트랜잭션을 커밋 하면 엔티티 매니저는 우선 영속성 컨텍스트를 플러시 한다. 플러시는 영속성 컨텍스트의 변경 내용을 데이터베이스에 동기화하는 작업인데 이때 등록, 수정, 삭제한 엔티티를 데이터베이스에 반영한다.이러한 부분은 JPA 내부적으로 이루어지기 때문에 사용하는 코드에서는 코드의 변경 없이 이러한 작업들이 가능하다.
addBatch 구분을 사용하기 위해서는rewriteBatchedStatements=true속성을 지정해야 합니다. 기본 설정은false이며, 해당 설정이 없으면 Batch Insert는 동작하지 않습니다. 정확한 내용은 공식 문서를 참고해 주세요.
MySQL Connector/J 8.0 Developer Guide : 6.3.13 Performance Extensions Stops checking if every INSERT statement contains the “ON DUPLICATE KEY UPDATE” clause. As a side effect, obtaining the statement’s generated keys information will return a list where normally it wouldn’t. Also be aware that, in this case, the list of generated keys returned may not be accurate. The effect of this property is canceled if set simultaneously with ‘rewriteBatchedStatements=true’.
hibernate.jdbc.batch_size: 50Batch Insert의 size를 지정합니다. 해당 크기에 따라서 한 번에 insert 되는 rows가 결정됩니다. 자세한 내용은 아래에서 설명드리겠습니다.
@Entity
@Table(name = "payment_back")
class PaymentBackJpa(
@Column(name = "amount", nullable = false)
var amount: BigDecimal,
@Column(name = "order_id", nullable = false, updatable = false)
val orderId: Long
){
@Id
@Column(name = "id", updatable = false) // @GeneratedValue를 지정하지 않았음
var id: Long? = null
}
interface PaymentBackJpaRepository: JpaRepository<PaymentBackJpa, Long>
엔티티 클래스는 간단합니다. 중요한 부분은@GeneratedValue을 지정하지 않은 부분입니다.
@SpringBootTest
@TestConstructor(autowireMode = TestConstructor.AutowireMode.ALL)
internal class BulkInsertJobConfigurationTest(
private val paymentBackJpaRepository: PaymentBackJpaRepository
) {
@Test
internal fun `jpa 기반 bulk insert`() {
(1..100).map {
PaymentBackJpa(
amount = it.toBigDecimal(),
orderId = it.toLong()
)
.apply {
this.id = it.toLong() // ID를 직접 지정
}
}.also {
paymentBackJpaRepository.saveAll(it)
}
}
}
paymentBackJpaRepository.saveAll()를 이용해서 batch inset를 진행합니다. JPA 기반으로 Batch Insert를 진행할 때 별다른 코드가 필요 없습니다. 컬렉션 객체를saveAll()으로 저장하는 것이 전부입니다.hibernate.show_sql: true으로 로킹 결고를 확인해보겠습니다.
로그상으로는 Batch Insert가 진행되지 않은 것처럼 보입니다. 결론부터 말씀드리면 실제로는 Batch Insert가 진행됐지만hibernate.show_sql: true기반 로그에는 제대로 표시가 되지 않습니다. Mysql의 실제 로그로 확인해보겠습니다.
show variables like 'general_log%'; # general_log 획인
set global general_log = 'ON'; # `OFF` 경우 `ON` 으로 변경
해당 로그 설정은 성능에 지장을 줄 수 있기 때문에 테스트, 개발 환경에서만 지정하는 것을 권장합니다.해당 기능은 실시간으로 변경 가능하기 때문에 설정 완료 이후/var/lib/mysql/0a651fe44d20.log파일에 로그를 확인할 수 있습니다.
실제 mysql 로그에서는 Batch Insert를 확인할 수 있습니다. 그런데 왜 2번에 걸쳐서 Batch Insert가 진행되었을까요?hibernate.jdbc.batch_size: 50설정으로 Batch Insert에 대한 size를 50으로 지정했기 때문에 rows 100를 저장할 때 2번에 걸쳐 insert를 진행하는 것입니다.만약hibernate.jdbc.batch_size: 100이라면 1번의 insert로 저장됩니다.
위 쿼리는hibernate.jdbc.batch_size: 100으로 지정한 결과입니다. 그렇다면 왜batch_size옵션을 주어서 한 번에 insert 할 수 있는 데이터의 크기를 제한하는 것일까요? 아래 코드에서 해답을 찾을 수 있습니다.
When you make new objects persistent, employ methods flush() and clear() to the session regularly, to control the size of the first-level cache.
하이버네이트 공식 가이드의 내용입니다.batchSize값을 기준으로flush();,clear();를 이용해서 영속성 컨텍스트를 초기화 작업을 진행하고 있습니다.batchSize에 대한 제한이 없으면 영속성 컨텍스트에 모든 엔티티가 올라가기 때문에OutOfMemoryException발생할 수 있고, 메모리 관리 측면에서도 효율적이지 않기 때문입니다. 하이버네이트의 공식 가이드에서도 해당 부분의 언급이 있습니다.
Hibernate caches all the newly inserted Customer instances in the session-level cache, so, when the transaction ends, 100 000 entities are managed by the persistence context. If the maximum memory allocated to the JVM is rather low, this example could fail with an OutOfMemoryException. The Java 1.8 JVM allocated either 1/4 of available RAM or 1Gb, which can easily accommodate 100 000 objects on the heap.
long-running transactions can deplete a connection pool so other transactions don’t get a chance to proceed
JDBC batching is not enabled by default, so every insert statement requires a database roundtrip. To enable JDBC batching, set the hibernate.jdbc.batch_size property to an integer between 10 and 50.
쓰기 지연 SQL 제약 사항
batchSize: 50경우PaymentBackJpa객체를 50 단위로 Batch Insert 쿼리가 실행되지만, 중간에 다른 엔티티를 저장하는 경우 아래처럼 지금까지의PaymentBackJpa에 대한 지정하기 때문에 최종적으로batchSize: 50단위로 저장되지 않습니다.
em.persist(new PaymentBackJpa()); // 1
em.persist(new PaymentBackJpa()); // 2
em.persist(new PaymentBackJpa()); // 3
em.persist(new PaymentBackJpa()); // 4
em.persist(new Orders()); // 1-1, 다른 SQL이 추가 되었기 때문에 SQL 배치를 다시 시작 해야 한다.
em.persist(new PaymentBackJpa()); // 1
em.persist(new PaymentBackJpa()); // 2
이러한 문제는hibernate.order_updates: true,hibernate.order_inserts: true값으로 해결 할 수 있습니다.
JPA Batch Insert의 가장 큰 문제…
위에서 설명했던 부분들은 Batch Insert에 필요한 properties 설정, 그리고 내부적으로 JPA에서 Batch Insert에 대한 동작 방식을 설명한 것입니다.실제 Batch Insert를 진행하는 코드는 별다른 부분이 없고 컬렉션 객체를saveAll()메서드로 호출하는 것이 전부입니다.이로써 JPA는 Batch Insert를 강력하게 지원해 주고 있습니다.하지만 가장 큰 문제가 있습니다.@GeneratedValue(strategy = GenerationType.IDENTITY)방식의 경우 Batch Insert를 지원하지 않습니다.
Hibernate disables insert batching at the JDBC level transparently if you use an identity identifier generator.
공식 문서에도 언급이 있듯이@GeneratedValue(strategy = GenerationType.IDENTITY)경우 Batch Insert를 지원하지 않습니다. 정확히 어떤 이유 때문인지에 대해서는 언급이 없고, 관련 내용을 잘 설명한StackOverflow를 첨부합니다.
제가 이해한 바로는 하이버네이트는Transactional Write Behind방식(마지막까지 영속성 컨텍스트에서 데이터를 가지고 있어 플러시를 연기하는 방식)을 사용하기 때문에GenerationType.IDENTITY방식의 경우 JDBC Batch Insert를 비활성화함.GenerationType.IDENTITY방식이란auto_increment으로 PK 값을 자동으로 증분 해서 생성하는 것으로 매우 효율적으로 관리할 수 있다.(heavyweight transactional course-grain locks 보다 효율적). 하지만 Insert를 실행하기 전까지는 ID에 할당된 값을 알 수 없기 때문에Transactional Write Behind을 할 수 없고 결과적으로 Batch Insert를 진행할 수 없다.
Mysql에서는 대부분GenerationType.IDENTITY으로 사용하기 때문에 해당 문제는 치명적입니다. 우선GenerationType.IDENTITY으로 지정하고 다시 테스트 코드를 돌려 보겠습니다.
@Entity
@Table(name = "payment_back")
class PaymentBackJpa(
@Column(name = "amount", nullable = false)
var amount: BigDecimal,
@Column(name = "order_id", nullable = false, updatable = false)
val orderId: Long
){
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY) // GenerationType.IDENTITY 지정
var id: Long? = null
}
internal class BulkInsertJobConfigurationTest(
private val paymentBackJpaRepository: PaymentBackJpaRepository
) {
@Test
internal fun `jpa 기반 bulk insert`() {
(1..100).map {
PaymentBackJpa(
amount = it.toBigDecimal(),
orderId = it.toLong()
)
.apply {
// this.id = it.toLong() // ID를 자동 증가로 변경 했기 때문에 코드 주석
}
}.also {
paymentBackJpaRepository.saveAll(it)
}
}
}
Query insert into payment_back (amount, order_id) values (1, 1)
Query insert into payment_back (amount, order_id) values (2, 2)
Query insert into payment_back (amount, order_id) values (3, 3)
Query insert into payment_back (amount, order_id) values (4, 4)
Query insert into payment_back (amount, order_id) values (5, 5)
Query insert into payment_back (amount, order_id) values (6, 6)
Query insert into payment_back (amount, order_id) values (7, 7)
Query insert into payment_back (amount, order_id) values (8, 8)
Query insert into payment_back (amount, order_id) values (9, 9)
Query insert into payment_back (amount, order_id) values (10, 10)
Query insert into payment_back (amount, order_id) values (11, 11)
Query insert into payment_back (amount, order_id) values (12, 12)
...
GenerationType.IDENTITY의 경우에는 Batch Insert가 진행되지 않습니다.그래서 다른 대안을 찾아야 했습니다. 이 부분부터는 다음 포스팅에서 이어가겠습니다.
JPA 장점 - 우선 CRUD SQL을 작성할 필요가 없다. - JPA가 제공하는 네이티브 SQL 기능을 사용해서 직접 SQL을 작성할 수도 있고, 데이터베이스 쿼리 힌트도 사용할 수 있는 방법이 있다.(성능걱정x) - 개발 단계에서 MySQL DB를 사용하다가 오픈 시점에 갑자기 Oracle로 바꿔도 코드를 거의 수정할 필요가 없다.
목차
강의 : JPA 소개 - SQL 중심적인 개발의 문제점
SQL 직접 다룰 때 발생하는 문제점
반복 또 반복
// 1. 회원 등록용 SQL 작성
String sql = "INSERT INTO MEMBER(MEMBER_ID,NAME) VALUES(?,?)";
//2. 회원 객체의 값을 꺼내서 등록 SQL에 전달한다.
pstmt.setString(1, member.getMemberId();
pstmt.setString(2, member.getName());
//3.JDBC API를 사용해서 SQL을 실행한다.
pstmt.executeUpdate(sql);
객체를 데이터베이스에 CRUD 하려면 너무 많은 SQL과 JDBC API를 코드로 작성해야 한다는 점이다.
그리고 테이블마다 이런 비슷한 일을 반복해야 하는데, 개발하려는 애플리케이션에서 사용하는 데이터베이스 테이블이 100개라면 무수히 많은 SQL을 작성해야 하고 이런 비슷한 일은 100번은 더 반복해야 한다.
데이터 접근 계층 (DAO)을 개발하는 일은 이렇듯 지루함과 반복의 연속이다.
SQL에 의존적인 개발
갑자기 회원의 연락처도 함께 저장해달라는 요구사항이 추가 되었다. = 새롭게 쿼리를 다 짜야한다...
등록코드 변경
public class Member{
private String memberId;
private String name;
private String tel; //추가
...
}
연락처를 저장할 수 있도록 INSERT SQL을 수정했다.
String sql = "INSERT INTO MEMBER (MEMBER_ID, NAME, TEL) VALUE(?,?,?)";
그 다음 회원 객체의 연락처 값을 꺼내서 등록 SQL에 전달했다.
pstmt.setString(3, member.getTel());
조회 코드 변경
다음 처럼 회원 조회용 SQL을 수정한다.
SELECT MEMBER_ID, NAME, TEL FROM MEMBER WHERE MEMBER_ID = ?
//조회 결과를 Member 객체에 추가로 매핑한다.
String tel = rs.getString("TEL");
member.setTel(tel); //추가
JPA와 문제해결
JPA를 사용하면 객체를 데이터베이스에 저장하고 관리할 때, 개발자가 직접 SQL을 작성하는 것이 아니라 JPA가 제공하는 API를 사용하면 된다. 그러면 JPA가 개발자 대신에 적절한 SQL을 생성해서 데이터베이스에 전달한다.
저장 기능
jpa.persist(member); //저장
조회 기능
String memberId = "helloId";
Member member = jpa.find(Member.class, memberId); //조회
수정 기능
Member member = jpa.find(member.class, memberId);
member.setName("이름변경") // 수정
JPA는 별도의 수정 메서드를 제공하지 안흔다. 대신에 객체를 조회해서 값을 변경만 하면 트랜잭션을 커밋할 때 데이터베이스에 적절한 UPDATE SQL이 전달된다. (마법 같은 일-> 추후에 공부)
연관된 객체 조회
Member member = jpa.find(Member.class. memberId);
Team team = member.getTeam(); //연관된 객체 조회
JPA는 연관된 객체를 사용하는 시점에 적절한 SELECT SQL을 실행한다. 따라서 JPA를 사용하면 연관된 객체를 마음껏 조회할 수 있다.(마법 같은 일2->추후에 공부)
패러다임의 불일치
비즈니스 요구사항을 정의한 도메인 모델도 객체로 모델링하면 객체지향 언어가 가진 장점들을 활용할 수 있다. 문제는 이렇게 정의한 도메인 모델을 저장할 때 발생한다.
객체의 기능은 클래스에 정의되어 있으므로 객체 인스턴스의 상태인 속성만 저장 했다가 필요할 때 불러 와서 복구하면 된다.
관계형 데이터베이스는 데이터 중심으로 구조화되어 있고, 집합적인 사고를 요구한다. 그리고 객체 지향에서 이야기하는 추상화, 상속, 다형성 같은 개념이 없다.
객체와 관계형 데이터베이스는 지향하는 목적이 서로 다르므로 둘의 기능과 표현 방법도 다르다. 이것을 객체와 관계형 데이터베이스의 패러다임 불일치 문제라 한다. 따라서 객체 구조를 테이블 구조에 저장하는 데는 한계가 있다.
문제는 이런 객체와 관계형 데이터베이스 사이의 패러다임 불일치 문제를 해결하는 데 너무 많은 시간과 코드를 소비하는 데 있다.
상속
객체는 상속 이라는 기능을 가지고 있지만 테이블은 상속 이라는 기능이 없다. 따라서 패러다임의 불일치가 시작한다.
위의 관계형데이터에서 만약 Album을 조회한다. 1. 각각의 테이블에 따른 조인 SQL 작성 2. 각각의 객체 생성 3. 상상만 해도 복잡... 4. 더 이상 설명은 생략한다. 5. 그래서 DB에 저장할 객체에는 상속 관계 안쓴다!!!
만약 자바 컬렉션에 저장하면?
list.add(album);
자바 컬렉션을 조회하면?
Album album = list.get(albumId);
//부모 타입으로 조회 후 다형성 활용
Item item = list.get(albumId);
JDBC를 이용한 상속 구현
JDBC API를 사용해서 상속 관계의 객체를 저장 하려면 부모 객체에서 부모 데이터만 꺼내서 INSERT SQL을 작성하고 자식 객체에서 자식 데이터만 꺼내서 INSERT SQL을 별도로 작성해야 한다.
class Member {
String id; // MEMBER_ID 컬럼 사용
Long temId; // TEAM_ID FK 컬럼 사용
String username; // USERNAME 컬럼 사용
}
class Team {
Long id; // TEAM_ID PK 사용
String name; // NAME 컬럼 사용
}
그런데 여기서 TEAM_ID 외래 키의 값을 그대로 보관하는 teamId 필드에는 문제가 있다. 객체는 연관된 객체의 참조를 보관해야 다음처럼 참조를 통해 연관된 객체를 찾을 수 있어야 하는데 하지 못하게 된다.
이런 방식을 따르면 좋은 객체 모델링은 기대하기 어렵고 결국 객체 지향의 특징을 잃어버리게 된다.
엔티티를 직접 참조하는 것과 간접 참조하는 것에 대한 장단점이 무엇이 있을까? 직접 참조 장점 : 연관된 데이터를 한번에 추출할 수 있다.단점 : 연관된 데이터에 대한 수정이 발생할 경우 영향의 범위가 커질 수 있다. 간접 참조 장점 : 복잡도를 낮출 수 있고, 응집도를 높이고 결합도를 낮출 수 있다.단점 : 연관된 데이터를 한번에 추출 하려면 구현해야 하는 로직이 복잡하다.
참조를 사용하는 객체 모델
class Member {
String id; // MEMBER_ID 컬럼 사용
Long temId; // TEAM_ID FK 컬럼 사용
Team team; // 참조로 연관관계를 맺는다.
}
class Team {
Long id; // TEAM_ID PK 사용
String name; // NAME 컬럼 사용
}
JDBC를 이용한 연관 관계 구현
만약 JDBC로 저장하는 로직을 만들기 위해서는 team 필드를 TEAM_ID 외래 키 값으로 변환해야 한다.
member.getId(); // MEMBER_ID PK에 저장
member.getTeam().getId(); // TEAM_ID FK에 저장
member.getUsername(); // USERNAME 컬럼에 저장
또는 조회하는 로직에도 객체를 생성하고 연관관계를 설정해서 반환하는 로직이 필요하다.
public Member find(String memberId) {
// SQL 실행
...
Member member = new Member();
...
// 데이터베이스에서 조회한 회원 관련 정보를 모두 입력
Team team = new Team();
...
// 데이터베이스에서 조회한 팀 관련 정보를 모두 입력
// 회원과 팀 관계 설정
member.setTeam(team);
return member;
}
JPA와 연관관계 구현
개발자는 회원과 팀의 관계를 설정하고 회원 객체를 저장하면 된다. JPA는 team의 참조를 외래 키로 변환해서 적절한 INSERT SQL을 데이터베이스에 전달한다.
member.setTeam(team);
jpa.persist(member);
객체를 조회할 때 외래 키를 참조로 변환하는 일도 JPA가 처리 해준다.
Member member = jpa.find(Member.class, memberId);
Team team = member.getTeam();
객체 그래프 탐색
SQL을 직접 다루면 처음 실행하는 SQL에 따라 객체 그래프를 어디까지 탐색할 수 있는지 정해진다. 이것은 객체지향 개발자에겐 너무 큰 제약이다. 왜냐하면 비즈니스 로직에 따라 사용하는 객체 그래프가 다른데 언제 끊어질지 모를 객체 그래프를 함부로 탐색할 수는 없기 때문이다.
결국, 어디까지 객체 그래프 탐색이 가능한지 알아보려면 데이터 접근 계층인 DAO를 열어서 SQL을 직접 확인해야 한다.
class MemberService {
...
public void process() {
Member member = memberDAO.find(memberId);
member.getTeam(); // member->team 객체 그래프 탐색이 가능한가?
member.getOrder().getDelivery(); // ???
}
}
위의 코드에서 memberDAO.find에 주목하자. 그것은 무엇을 조회했을까? 무엇을 조회했길래 '팀 정보(getTeam)', '배송+주문 정보'를 가져올수 있을까? 알수없다. 즉, find구현한 Repo를 가서 무슨 쿼리인지를 까봐야한다. 즉 위의 코드는 신뢰가 안간다.
JPA를 사용하면 연관된 객체를 신뢰하고 마음껏 조회할 수 있다. 이 기능은 실제 객체를 사용하는 기점까지 데이터베이스 조회를 미룬다고 해서 지연 로딩이라 한다.
// 처음 조회 시점에 SELECT MEMBER SQL
Member member = jpa.find(Member.class, memberId);
Order order = member.getOrder();
order.getOrderDate(); // Order를 사용하는 시점에 SELECT ORDER SQL
여기서 마지막 줄의 order.getOrderDate() 같이 실제 Order 객체를 사용하는 시점에 JPA는 데이터베이스에서 ORDER 테이블을 조회한다.
JDBC로 구현한 비교
데이터베이스와 같은 로우로 조회했지만 객체의 동일성 비교에는 실패한다.
class MemberDAO {
public Member getMember(String memberId) {
String sql = "SELECT * FROM MEMBER WHERE MEMBER_ID + ?";
...
// JDBC API, SQL 실행
return new Member(...);
}
}
String memberId = "100";
Member member1 = memberDAO.getMember(memberId);
Member member2 = memberDAO.getMember(memberId);
member1 == member2; // false
JPA로 구현한 비교(=자바 컬렉션)
String memberId = "100";
Member member1 = jpa.find(Member.class,memberId);
Member member2 = jpa.find(Member.class,memberId);
member1 == member2; // true
정리
객체 모델과 관계형 데이터베이스 모델은 지향하는 패러다임이 서로 다르다. 더 어려운 문제는 객체 지향 애플리케이션 답게 정교한 객체 모델링을 할수록 패러다임의 불일치 문제가 더 커진다는 점이다. 이는 결국 객체 모델링은 힘을 잃고 점점 데이터 중심의 모델로 변해간다. JPA는 패러다임의 불일치 문제를 해결 해주고 정교한 객체 모델링 유지하게 도와준다.
데이터 주도 설계와 도메인 주도 설계의 장단점은 무엇일까?
강의 JPA소개 - JPA소개
JPA란 무엇인가?
JPA(Java Persistence API)는 자바 진영의 ORM 기술 표준이다.
그렇다면 ORM이란 무엇일까?
ORM?
ORM(Object-Relational Mapping)은 이름 그대로 객체와 관계형 데이터베이스를 매핑 한다는 뜻이다. ORM 프레임워크는 객체와 테이블을 매핑 해서 패러다임의 불일치 문제를 개발자 대신 해결해준다.
따라서 객체 측면에서는 정교한 객체 모델링을 할 수 있고 관계형 데이터베이스는 데이터베이스에 맞도록 모델링하면 된다. 그리고 둘을 어떻게 매핑 해야 하는지 매핑 방법만 ORM 프레임워크에게 알려주면 된다
왜 JPA를 사용해야 하는가?
생산성
이전에 DAO에서 작업하던 지루하고 반복적인 일은 JPA가 대신 처리 해준다. 이런 기능들을 사용하면 데이터베이스 설계 중심의 패러다임을 객체 설계 중심으로 역전시킬 수 있다.
유지보수
이전엔 엔티티 필드 하나만 수정해도 관련된 DAO 로직의 SQL문을 모두 변경해야 했다. 반면에 JPA는 대신 처리해주므로 필드를 추가하거나 삭제해도 수정해야 할 코드가 줄어든다.
패러다임의 불일치 해결
성능
JPA는 애플리케이션과 데이터베이스 사이에 동작하여 최적화 관점에서 시도해 볼 수 있는 것들이 많다. 예를 들어 동일한 조건으로 조회 했을 경우엔 SELECT SQL을 한 번만 데이터베이스에 전달하고 두 번째 조회한 회원 객체는 재사용할 수 있다.
Member member = memberDAO.find(memberId); //selcet * from member
Team team = member.getTeam();
String teamName = team.getName(); //select * from team
즉시로딩
Member member = memberDAO.find(memberId); //select M.*, T.* from member join team
Team team = member.getTeam();
String teamName = team.getName();
JPA, Hibernate, Spring Data JPA 차이점
JPA(=껍데기)
JPA공부를 시작함에 있어서 가장헷갈렸던 부분이 JPA와 Hibernate와의 관계였다. 동영상강의에서는 처음에 EntityManager를 활용하여 Data를 삭제 저장 업데이트를 하지만, 실제 실무에서는 EntityManager를 사용하지 않고 Repository 인터페이스 만을 이용해서 JPA를 사용한다.
JPA는 Java Persistence API의 약자로,자바 어플리케이션에서 관계형 데이터베이스를 사용하는 방식을 정의한 인터페이스이다. 여기서 중요하게 여겨야 할 부분은, JPA는 말 그대로 인터페이스라는 점이다. JPA는 특정 기능을 하는 라이브러리가 아니다. 마치 일반적인 백엔드 API가 클라이언트가 어떻게 서버를 사용해야 하는지를 정의한 것처럼, JPA 역시 자바 어플리케이션에서 관계형 데이터베이스를 어떻게 사용해야 하는지를 정의하는 한 방법일 뿐이다.
JPA는 단순히 명세이기 때문에 구현이 없다. JPA를 정의한javax.persistence패키지의 대부분은interface,enum,Exception, 그리고 각종Annotation으로 이루어져 있다. 예를 들어, JPA의 핵심이 되는EntityManager는 아래와 같이javax.persistence.EntityManager라는 파일에interface로 정의되어 있다.
package javax.persistence;
import ...
public interface EntityManager {
public void persist(Object entity);
public <T> T merge(T entity);
public void remove(Object entity);
public <T> T find(Class<T> entityClass, Object primaryKey);
// More interface methods...
}
Hibernate(JPA를 구현한 구현체)
Hibernate는 JPA 명세의 구현체이다. javax.persistence.EntityManager와 같은 JPA의 인터페이스를 직접 구현한 라이브러리이다.
위 사진은 JPA와 Hibernate의 상속 및 구현 관계를 나타낸 것이다. JPA의 핵심인EntityManagerFactory,EntityManager,EntityTransaction을 Hibernate에서는 각각SessionFactory,Session,Transaction으로 상속받고 각각Impl로 구현하고 있음을 확인할 수 있다.
“Hibernate는 JPA의 구현체이다”로부터 도출되는 중요한 결론 중 하나는 JPA를 사용하기 위해서 반드시 Hibernate를 사용할 필요가 없다는 것이다. Hibernate의 작동 방식이 마음에 들지 않는다면 언제든지 DataNucleus, EclipseLink 등 다른 JPA 구현체를 사용해도 되고, 심지어 본인이 직접 JPA를 구현해서 사용할 수도 있다. 다만 그렇게 하지 않는 이유는 단지 Hibernate가 굉장히 성숙한 라이브러리이기 때문일 뿐이다.
Spring Data JPA
필자는 Spring으로 개발하면서 단 한 번도 EntityManager를 직접 다뤄본 적이 없다. DB에 접근할 필요가 있는 대부분의 상황에서는 Repository를 정의하여 사용했다. 아마 다른 분들도 다 비슷할 것이라 생각한다. 이 Repository가 바로 Spring Data JPA의 핵심이다.
Spring Data JPA는 Spring에서 제공하는 모듈 중 하나로, 개발자가 JPA를 더 쉽고 편하게 사용할 수 있도록 도와준다. 이는JPA를 한 단계 추상화시킨 Repository라는 인터페이스를 제공함으로써 이루어진다. 사용자가Repository 인터페이스에 정해진 규칙대로 메소드를 입력하면, Spring이 알아서 해당 메소드 이름에 적합한 쿼리를 날리는 구현체를 만들어서 Bean으로 등록해준다.
Spring Data JPA가 JPA를 추상화했다는 말은,Spring Data JPA의 Repository의 구현에서 JPA를 사용하고 있다는 것이다. 예를 들어,Repository인터페이스의 기본 구현체인SimpleJpaRepository의 코드를 보면 아래와 같이 내부적으로EntityManager을 사용하고 있는 것을 볼 수 있다.