얼떨결에 JMeter로 부하테스트를 진행하고 있었는데, 연결 시간 초과나 소켓 닫힘 같은 기본적인 네트워크 오류가 특정 시점부터 많이 발생하길래 (1) 스위치나 보안장비에서 동일 IP의 동시 요청 건수를 제한하거나 또는 그런 설정이 있는지 확인하였으나 본인측 요청에 의해 보안장비는 필터링 제외를 시켰고, 스위치는 기본 셋팅이라 제한이 업다고 한다. (2) 당시 Apache의 mpm 설정은 worker로 되어있었고, MaxRequestWorkers는 4,096으로 설정되어 있었다. 모니터링 했을 때 Busy Worker가 폭등하다가 폭락하는 그래프를 보였고 폭락 지점이 네트워크 오류가 다수 발생하는 시점이었다.
여기서 부하발생기가 2,000 스레드로 요청을 발생하고 있었는데, 모든 요청을 온전하게 받아들이지 못하고 있었다고 판단을 하였다. 부하발생기의 자원사용률이나 TCP 소켓의 TIME_WAIT 상태는 크게 많지 않았음에도 불구하고 왜 이런 그래프가 반복되는가 고민하는 찰나에 작년 일이 생각났다. Apache를 운영하는 서버의 커널 파라미터를 수정했었다. 당시 사용하던 서버는 Destroyed 되었기 때문에 설정값은 기억나지 않지만(기록이란 참 중요하다) TCP 소켓 reuse와 recycle에 관련했던거 같다.
하지만 이게 왠걸 tcp_tw_reuse와 tcp_tw_recycl\은 기본적으로 Outbound 연결에 대해서 효과가 있고 Web 서버 같이 Inbound 연결이 많은 환경에서는 아무 의미가 없다고 한다. 심지어 tcp_tw_recycle은 최신 커널에서는 폐기된 파라미터다. 도대체 작년에는 왜 그런일을 하면서 해골물을 마셨었는지 알 수가 없다.
각설하고 이번에는 net.core.somaxconn 파라미터를 수정했다. 연결에 대한 대기열(Queue)인데 기본값인 512로 설정되어 있었다. 부하발생기의 스레드는 반복해서 요청을 보내고 있는데 일부 응답이 늦어지며 연결을 유지하고 있었던 것들에 의해 대기열에 들어갔어야 했다. 하지만 대기열의 크기가 512에 불과하니 연결에 실패하는 등의 오류가 발생했던 것으로 판단되었다. net.core.somaxconn의 값은 Apache의 MaxRequestWorkers의 값과 동일하거나 그 이상으로 설정 해주면 된다.
sudo vim /etc/sysctl.conf
### 파일 내용에서 값을 찾아 수정하거나, 없으면 추가
net.core.somaxconn = 8192
# 바로 적용
sudo sysctl -p
그리고 Apache 설정도 바꿔줘야한다. 나중에 헷갈리지 않게 mpm.conf 쪽에다 추가했다. 해당 값은 net.core.somaxconn보다 작거나 같고, MaxRequestWorkers보다 큰 값으로 설정하면 된다. 나는 net.core.somaxconn과 같은 값으로 설정했다.
ListenBacklog 8192
Apache도 재기동하고 다시 요청을 보내니 관련 오류가 현저하게 줄어들어 Busy Worker 그래프가 폭락하지는 않았고, 코끼리를 삼킨 보아뱀처럼 그래프가 그려졌다. 아무튼 이를 통해 일부 문제를 해결할 수 있었다. JMeter 다루기도 무엇보다 쉽지가 않네.
# 패키지를 저장할 폴더
$ mkdir /opt/zabbix
# 압축 풀기
$ tar -zxvf zop.tar.gz -C /opt/zabbix
# createrepo_c 먼저 설치
$ sudo rpm -ivh /opt/zabbix/createrepo_c-*.rpm
libdrpm.so.0()이 없다면서 오류가 발생했다. 노트북을 챙겨오지 않아서 pkgs.org에서 drpm 패키지를 다운로드 했다.
# drpm 설치
$ sudo dnf install drpm-*.rpm
# createrepo_c 다시 설치
$ sudo dnf install createrepo_c-*.rpm
# 설치 성공 했으니 RPM 파일들이 있는 폴더를 리포지토리로 만들기
$ sudo createrepo_c /opt/zabbix
# 리포지토리 파일 작성
sudo nano /etc/yum.repos.d/zabbix-local.repo
# 파일 내용
[zabbix-local]
name=Zabbix Local Repository
baseurl=file:///opt/zabbix/
enabled=1
gpgcheck=0
# 파일 내용
# 나머지 파일 설치
$ sudo dnf install zabbix-server-mysql zabbix-web-mysql zabbix-apache-conf zabbix-sql-scripts zabbix-agent mariadb-server
실행하니까 Updating Subscription Management repositories. Unable to read consumer identity. This system is not registered with an entitlement server. You can use subscription-manager to register. AppStream Errors During downloading metadata for repository 'AppStream': .. 오류가 발생했는데 폐쇄망이라 그런가보다.
# disablerepo="*": 모든 리포지토리 비활성화
# enablerepo="zabbix-local": 방금 만든 리포지토리만 활성화
$ sudo dnf --disablerepo="*" --enablerepo="zabbix-local" install zabbix-server-mysql zabbix-web-mysql zabbix-apache-conf zabbix-sql-scripts zabbix-agent mariadb-server
이것도 실행하니까 Error: No available modular metadata for modular package 오류가 발생했다.
# /opt/zabbix 에서 모든 rpm 설치
$ sudo dnf --disablerepo="*" install *.rpm
몰랐는데 서버에 이미 MySQL이 설치되어있어서 오류가 났다. 관련 패키지 파일 삭제 후 설치에 성공했다. 근데 나는 MySQL이 설치되어있는지 몰랐기 때문에 계정과 패스워드도 모른다. 그래서 root 패스워드를 초기화 했다.
# 안전모드로 MySQL 시작
$ sudo systemctl stop mysqld
$ sudo systemctl set-environment MYSQLD_OPTS="--skip-grant-tables"
$ sudo systemctl start mysqld
# 접속 성공
$ sudo mysql -uroot
> FLUSH PRIVILEGES;
> ALTER USER 'root'@'localhost' IDENTIFIED BY 'root';
> quit;
# 안전모드 제거
$ sudo systemctl stop mysqld
$ sudo systemctl unset-environment MYSQLD_OPTS
$ sudo systemctl start mysqld
# 변경한 비밀번호로 접속 성공
$ sudo mysql -uroot -p
# Zabbix 데이터베이스 생성(utf8mb4 형식 필수)
> create database zabbix character set utf8mb4 collate utf8mb4_bin;
# Zabbix 사용자 생성
> create user zabbix@localhost identified by 'zabbix';
# Zabbix 사용자에게 zabbix 데이터베이스의 모든 권한을 부여
> grant all privileges on zabbix.* to zabbix@localhost;
# Zabbix 6.0 이상은 SUPERUSER 권한 필요(스키마 가져오고 0으로 초기화)
> set global log_bin_trust_function_creators = 1;
# 변경사항을 적용 후 종료
> flush privileges;
> quit;
# Zabbix 초기 스키마 가져오기
$ zcat /usr/share/zabbix-sql-scripts/mysql/server.sql.gz | mysql --default-character-set=utf8mb4 -uzabbix -p zabbix
# Zabbix 설정 파일 수정(DBPassword)
$ sudo vim /etc/zabbix/zabbix_server.conf
# 파일 수정
# 주석 풀고 비밀번호 입력
DBPassword=zabbix
# 파일 수정
# 서비스 재시작 및 자동 실행 등록
$ sudo systemctl restart zabbix-server zabbix-agent httpd
$ sudo systemctl enable zabbix-server zabbix-agent httpd
Zabbix 설치하면서 Apache도 하나 더 설치가 되었는데, 기존에 설치가 된게 있어서 포트 충돌이 났다.
# Apache 포트 수정
$ sudo vim /etc/httpd/conf/httpd.conf
# 파일 수정
Listen 80 → 8080
# 파일 수정
# Apache 서비스 시작
$ sudo systemctl start httpd
이제 {서버 IP}:8080/zabbix으로 접근은 되지만 'Minimum required PHP version is 8.0.0' 메시지가 나왔다. 기존에 7 버전대 패키지가 다운로드 되었고, 그걸 설치해서 그런가보다. 또 pkgs에서 PHP 8.0.0 필요한 패키지 다운로드했다.
# 7 버전 삭제
$ sudo dnf remove 'php*'
# 8 버전 설치
$ sudo dnf --disablerepo="*" install php*.rpm
# PHP 재시작 및 자동 실행 등록
$ sudo systemctl restart php-fpm
$ sudo systemctl enable php-fpm
# Apache 서비스 재시작
$ sudo systemctl restart httpd
다시 {서버 IP}:8080/zabbix로 접근하면 Zabbix 설치 화면이 나오고, Check of pre-requisites의 모든 항목이 OK로 표시되는지 확인 후 다음으로 넘어가서 DB 정보 입력하고 완료되었다.
WAS 서버들은 다른 모니터링 툴로 잘 보고 있는데, WEB 서버는 자연스럽게 모니터링을 하지 않고 있음을 문득 깨달아버렸다. 조만간 부하 테스트를 진행해야하는데 putty 여러개 띄워 놓는 것도 그만둘 때가 되었다고 생각했고, Grafana + Prometheus와 Zabbix 중에서 비교하다가 그래도 빠르게 시작할 수 있는 Zabbix로 선택했다.
빠르게 Zabbix Appliance(모든 구성 요소가 미리 설치된 가상 머신 이미지)로 시작하려했으나, Zabbix 서버로 사용하려 했던 서버도 이미 가상화 서버이고 중첩 가상화도 지원하지 않아서 포기했다. Docker Container도 제공하고 있어 이쪽으로 할까 했지만 Docker 설정도 안 되어 있어서 직접 설치하기로 했다. 하지만 외부 리포지토리에 접근이 불가한 폐쇄망 서버여서 외부에서 설치 패키지를 가져와 옮겨야한다.
VirtualBox 화면에서 하면 복사/붙여넣기가 자유롭지 않으니 파워쉘에서 ssh 연결해서 명령어 입력했다.
# RHEL 8.9용 Zabbix 7.0 LTS 버전 repo 설치
$ sudo rpm -Uvh https://repo.zabbix.com/zabbix/7.0/rhel/8/x86_64/zabbix-release-7.0-1.el8.noarch.rpm
$ sudo dnf clean all
# Zabbix 관련 패키지 및 의존성을 모두 다운로드
$ sudo dnf install --downloadonly --downloaddir=/home/vboxuser/zop \
zabbix-server-mysql \
zabbix-web-mysql \
zabbix-apache-conf \
zabbix-sql-scripts \
zabbix-agent \
mariadb-server \
createrepo_c
# mariadb-server랑 createrepo_c가 No match for argument 가 나오면서 실패
$ sudo subscription-manager repos --enable codeready-builder-for-rhel-8-x86_64-rpms
This system has no repositories available through subscriptions.
# repo 구독도 실패해서 구독 처리
$ sudo subscription-manager register
Registering to: subscription.rhshttp://m.redhat.com:443/subscription
Username:
Password:
The system has been registered with ID:
The registered system name is:
$ sudo subscription-manager attach --auto
Ignoring the request to auto-attach. Attaching subscriptions is disabled for organization "" because Simple Content Access (SCA) is enabled.
# 위의 다운로드 다시 실행하면 정상적으로 다운로드 완료
45개 정도의 패키지가 다운로드 되는데 압축해서 서버로 옮기면 된다. 월요일에 출근해서 처리해야한다. 참고로 뒤에 구독 같은거 안 하려면 Rocky Linux 8.9로 하면 된다고 한다.
//회원 객체
@Entity
public class Member {
@Id @GeneratedValue
private Long id;
@Column(name = "USERNAME")
private String name;
@Column(name = "TEAM_ID")
private Long teamId;
...
}
//팀 객체
@Entity
public class Team {
@Id @GeneratedValue
private Long id;
private String name;
...
}
회원과 팀 객체를 만들고 회원과 팀을 저장하는 샘플 코드를 작성한다.
//팀 저장
Team team = new Team();
team.setName("TeamA");
em.persist(team);
//회원 저장
Member member = new Member();
member.setName("member1");
member.setTeamId(team.getId());
em.persist(member);
//조회
Member findMember = em.find(Member.class, member.getId());
//연관관계가 없음
Team findTeam = em.find(Team.class, team.getId());
객체는 참조를 사용해 연관된 객체를 찾고, 테이블은 외래키(Foreign Key)로 조인을 사용해 연관된 테이블을 찾는다. 이런 객체와 테이블의 차이가 있기 때문에, 객체를 테이블에 맞추어 테이블 중심으로 모델링하면 협력 관계를 만들 수 없다.
단방향 연관관계
객체의 참조와 테이블의 외래키를 매핑한다.
//회원 객체
@Entity
public class Member {
...
//@Column(name = "TEAM_ID")
//private Long teamId;
@ManyToOne
@JoinColumn(name = "TEAM_ID")
private Team team;
...
}
회원 객체의 team 속성을 팀 객체로 변경하고, @ManyToOne과 @JoinColumn 어노테이션을 붙여준다. @ManyToOne은 회원과 팀이 N:1 관계이기 때문에 회원 객체 기준 '다'이기 때문에 사용한다.
//팀 저장
Team team = new Team();
team.setName("TeamA");
em.persist(team);
//회원 저장
Member member = new Member();
member.setName("member1");
member.setTeam(team);
em.persist(member);
//조회
Member findMember = em.find(Member.class, member.getId());
//참조를 사용해서 연관관계 조회
Team findTeam = findMember.getTeam();
양방향 연관관계
앞에서 회원 객체 -> 팀 객체로 N:1 단방향 연관관계를 설정하였다.
@Entity
public class Team {
...
@OneToMany(mappedBy = "team")
List<Member> members = new ArrayList<Member>();
...
}
팀 객체 -> 회원 객체 1:N 단방향 연관관계를 추가로 설정한다. 이렇게 양방향 연관관계가 된다.
연관관계의 주인과 mappedBy
객체와 테이블이 관계를 맺는 방법에는 차이가 있다. 앞의 예시에서 객체 기준 연관관계는 2개로 '회원 객체 -> 팀 객체 단방향 연관관계'와 '팀 객체 -> 회원 객체 단방향 연관관계'가 있고, 테이블 기준 연관관계는 '회원 테이블 <-> 팀 테이블 양방향 연관관계'다. 객체에서도 양방향 연관관계로 얘기하고 있지만, 사실은 서로 다른 단방향 연관관계 2개인 것이다. 객체를 양방향으로 참조하려면 이런식으로 만들어야한다. 하지만 테이블은 외래키 하나로 연관관계를 관리할 수 있다.
연관관계에는 주인(Owner)가 필요하다. 객체의 두 관계 중 하나를 연관관계의 주인으로 지정하는데, 주인만 외래키를 관리(등록, 수정)하고 주인이 아닌 쪽은 읽기만 가능하다. mappedBy는 주인이 아닌 쪽이 주인을 지정하기 위해 사용한다. 주인의 선정은 일반적으로 외래키가 있는 곳을 주인으로 한다. 또한 순수 객체 상태를 고려해서 항상 양쪽에 값을 설정하는 것이 좋다(연관관계 편의 메소드 등 사용).
단방향 매핑만으로도 이미 연관관계 매핑은 완료된다. 양방향 매핑은 반대 방향으로 조회(객체 그래프 탐색) 기능이 추가된 것 뿐이다. JPQL 사용시에도 역방향으로 탐색을 할 일이 많다. 기본적으로 단방향 매핑을 잘 하고, 양방향 매핑을 필요할 때 추가해도 된다.
지난 주 토요일 시험으로 AWS 기초 중의 기초인 'Certified Cloud Practitioner(CLF-002)'를 취득하였다. 합격으로 받은 50% 할인 바우처로 'Certified Solutions Architect - Associate'를 취득할 생각이다. Practitioner의 경우 AWS Builders에서 제공하는 에센셜 위주로 듣고, 샘플 시험이나 기타 문서를 참고하였다.
JPA에서 @Entity 어노테이션은 자바 클래스를 데이터베이스의 테이블과 매핑하기 위해 사용하며, 이 어노테이션이 선언된 클래스는 JPA가 관리하는 영속 객체(Entity)가 된다. 파라미터가 없는 기본 생성자가 필요하며, @Id로 기본키(Primary Key)를 지정해야한다.
주요 속성
name:
객체의 이름을 지정하며, 지정하지 않으면 클래스명이 이름이 된다.
JPQL에서 객체를 구분하는 데 사용된다.
@Entity(name = "User")
public class Member {
...
}
@Table
@Table 어노테이션은 객체가 매핑될 실제 데이터베이스 테이블의 이름이나 세부 정보를 지정할 때 사용한다. 보통 @Entity와 함께 사용되며, 추가적으로 테이블명, 스키마, 인덱스, 고유 제약 조건 등 다양한 정보를 설정할 수 있다.
주요 속성
name: 매핑할 테이블명 지정, 지정하지 않으면 클래스명이 테이블명이 된다.
schema: 매핑할 스키마명 지정
catalog: 매핑할 카탈로그명 지정
uniqueConstraints: 테이블에 고유 제약 조건(Unique 제약 조건) 설정
indexes: 테이블에 인덱스 설정
데이터베이스 스키마 자동 생성
JPA는 애플리케이션 실행 시점에 DDL을 자동으로 생성한다. 이때 테이블 중심이 아닌 객체 중심으로 생성하고, persistence.xml에서 설정한 데이터베이스 방언을 활용하여 데이터베이스에 맞는 적절한 DDL을 생성한다. 하지만 이렇게 생성된 DDL은 개발 환경에서만 사용하고, 운영서버에서는 사용하지 않거나 적절히 다듬은 후 사용해야 한다.
데이터베이스 스키마 자동 생성과 관련해서는 persistence.xml의 hibernate.hbm2ddl.auto 속성으로 지정할 수 있다. 주의할 점이 있다면 운영 환경에서 create, create-drop, update를 사용하면 절대 안 된다. 'update 정도는 써도 되지 않나?' 싶지만, 주요 테이블에 ALTER 잘못 날리면 서비스 장애가 발생할 가능성이 높다.
주요 속성
create: 애플리케이션 실행 시 기존 테이블 DROP → 신규 테이블 CREATE
create-drop: 애플리케이션 실행 시 기존 테이블 DROP → 신규 테이블 CREATE → 종료 시 DROP
update: 변경된 내용만 ALTER
validate: 객체와 테이블이 정상 매핑되었는지만 확인
none: 미사용
필드와 컬럼 매핑
약간의 요구사항이 있었다.
회원은 일반회원과 관리자로 구분해야 한다.
회원가입일과 수정일이 있어야한다.
회원을 설명할 수 있는 필드가 있으며, 이 필드는 길이 제한이 없다.
...
@Entity
public class Member {
@Id
private Long id;
@Column(name = "username")
private String name;
private Integer age;
@Enumerated(EnumType.STRING)
private RoleType roleType;
@Temporal(TemporalType.TIMESTAMP)
private Date createdDate;
@Temporal(TemporalType.TIMESTAMP)
private Date lastModifiedDate;
@Lob
private String description;
}
필드와 컬럼 매핑 시 사용하는 어노테이션과 주요 속성이다.
@Column: 필드와 컬럼 매핑. 세부 속성값을 설정하여 구체적으로 지정가능하고 생략 가능(생략 시 기본값 적용)
name: 매핑할 컬럼 이름 지정(기본값: 필드명)
nullable: 컬럼의 NULL 허용 여부 지정(기본값: true)
unique: 고유 제약 조건 생성(기본값: false)
length: 문자열 컬럼의 최대 길이 지정(String 타입에만 적용 / 기본값: 255)
columnDefinition: 데이터베이스 컬럼 정의 직접 입력(SQL 타입 및 디폴트 등)
insertable/updatable: 해당 필드를 INSERT/UPDATE SQL에 포함시킬지 여부(기본값: true)
precision/scale: BigDecimal/BigInteger 타입에서 전체 자릿수, 소수점 이하 자릿수 지정
table: 여러 테이블에 매핑할 때 사용하는 옵션
@Enumerated: enum 타입 필드와 컬럼 매핑
value: 기본값 EnumType.ORDINAL이나 ORDINAL은 사용하지 않는 것을 권장
EnumType.ORDINAL: enum 순서를 데이터베이스에 저장(0, 1, 2 ...)
EnumType.STRING: enum 이름을 데이터베이스에 저장
@Temporal: 날짜/시간 타입 필드와 컬럼 매핑(java.util.Date, java.util.Calendar)
@Lob: Large Object 필드와 컬럼 매핑(String→CLOB, byte[]→BLOB)
@Transient: 필드를 컬럼과 매핑하지 않음(메모리상에서 임시로 사용하는 필드에 사용)
기본키 매핑
기본키 매핑 방법은 @Id 어노테이션만 사용하여 직접 값을 입력하거나, @GeneratedValue 어노테이션을 사용하여 자동으로 생성하여 입력할 수 있다. @GeneratedValue 어노테이션의 주요 전략은 IDENTITY, SEQUENCE, TABLE, AUTO 4가지가 있다.
IDENTITY: 데이터베이스에 위임
주로 MySQL, PostgreSQL, SQL Server, DB2에서 사용
JPA는 트랜잭션 커밋 시점에 INSERT SQL 실행
IDENTITY 전략 사용 시 em.persist() 시점에 즉시 INSERT SQL 실행하여 DB에서 ID값 조회
SEQUENCE: 데이터베이스 스퀀스 오브젝트 사용
주로 ORACLE, PostgreSQL, DB2, H2에서 사용
@SequenceGenerator 필요
name: 식별자 생성기 이름
sequenceName: 데이터베이스에 등록되어 있는 시퀀스 이름
initialValue: DDL 생성 시에만 사용되며, 처음 시작하는 수 지정
allocationSize: 시퀀스 한 번 호출에 증가하는 수(기본값: 50)
데이터베이스 스퀀스 설정이 하나씩 증가이면, 반드시 1로 설정
catalog, schema: 데이터베이스 catalog, schema 이름
TABLE: 키 생성용 테이블 사용
모든 데이터베이스에서 사용 가능하나, 성능이 좋지 못함
@TableGenerator 필요
name: 식별자 생성기 이름
table: 키 생성 테이블 이름(기본값: hibernate_sequences)
pkColumnName: 시퀀스 컬럼 이름(기본값: sequence_name)
valueColumnName: 시퀀스 값 컬럼 이름(기본값: next_val)
pkColumnValue: 키로 사용할 값 이름(기본값: 객체명)
initialValue: 초기값, 마지막으로 생성된 값 기준(기본값: 0)
allocationSize: 시퀀스 한 번 호출에 증가하는 수(기본값: 50)
catalog, schema: 데이터베이스 catalog, schema 이름
uniqueConstraints: 고유 제약 조건 지정
AUTO: 데이터베이스 방언에 따라 자동 지정(기본값)
실전 예제
요구사항 분석과 기본 매핑인데, 강의에서 신규 프로젝트 생성하였으나 테스트 하던 프로젝트에 추가했다.
이전에 얘기했듯 웹 애플리케이션에서는 하나의 EntityManagerFactory를 생성하고, 사용자의 요청에 따라 EntityManager를 생성하여 트랜잭션을 처리한다. 영속성 컨텍스트(Persistence Context)는 애플리케이션 내에서 객체 인스턴스를 일시적으로 보관하고 관리하는 논리적 환경이다. JPA를 이해하는데 가장 중요한 단어이다.
영속성 컨텍스트는 쉽게 말해 가상의 데이터베이스 역할을 하는 논리적인 개념으로, 눈에 보이지 않는다. EntityManager가 생성될 때 같이 생성되며, 보통 하나의 트랜잭션 범위 동안 유지된다. 객체의 생명주기는 비영속(New/Transient), 영속(Managed), 준영속(Detached), 삭제(Removed)로 나뉜다.
비영속(New/Transient): 객체가 새로 생성만 되어 영속성 컨텍스트와 연관이 없는 상태
영속(Managed): persist, find 등으로 영속성 컨텍스트에서 관리되는 상태
준영속(Detached): 영속성 컨텍스트에서 분리된 상태
영속성 컨텍스트가 제공하는 기능을 사용 못하게 된다.
em.detach(객체), em.clear(), em.close()로 준영속 상태로 만들 수 있다.
각각 특정 객체만 전환, 전체 초기화, 종료의 동작을 한다.
삭제(Removed): 영속성 컨텍스트에서 삭제된 상태
영속성 컨텍스트의 장점(또는 주요 특징)으로는 1차 캐시, 동일성(Identity) 보장, 쓰기 지연(Write-behind), 변경 감지(Dirty Checking), 지연 로딩(Lazy Loading)이 있다.
1차 캐시: 컨텍스트 내부에 객체들이 캐시되어 있어, 동일한 객체를 여러번 조회해도 최초 한 번만 DB에서 읽고, 이후에는 영속성 컨텍스트에서 조회한다. 영속성 컨텍스트에 없는 객체를 find하면 DB에서 조회 후 1차 캐시에 저장한다.
동일성 보장: 영속성 컨텍스트 내에서 같은 PK를 가지는 객체는 항상 같인 객체로 식별된다.
쓰기 지연: persist() 나 객체 추가 작업들이 먼저 영속성 컨텍스트에 저장되고, 실제 DB에는 트랜잭션 커밋 시 일괄로 반영된다. 이때 쓰기 지연 SQL 저장소에 쿼리를 저장하고 있다가 한 번에 실행한다.
변경 감지: 트랜잭션 내에서 객체의 값이 변경되면, 커밋 시점에 자동으로 변경 내용을 탐지해 DB에 반영한다.
쓰기 지연에서 '트랜잭션 커밋 시 일괄로 반영된다'고 했는데, 이때 flush가 실제로 영속성 컨텍스트의 변경 내용을 데이터베이스에 반영한다. 커밋 할 때 flush가 자동(jpql 쿼리 실행 시에도)으로 호출되며, em.flush()로 직접 호출할 수도 있다. flush는 이름과 다르게 영속성 컨텍스트를 비우는 것이 아니며, 객체는 계속 영속 상태를 유지한다. flush가 발샐하면 다음 단계로 진행된다.
변경 감지로 변경된 객체를 찾는다.
쓰기 지연 SQL 저장소에 변경 SQL(INSERT, UPDATE, DELETE)를 등록한다.
H2 데이터베이스는 기존에 사용하고 있었기에 신규 DB 파일만 생성하고 Java 프로젝트를 생성한다. 강의에서는 Java 8에 빌드도 Maven으로 설정하고 있으나, 깔린대로 해도 문제가 없을거 같아서 Java 21에 빌드는 Gradle로 설정했다.
plugins {
id("java")
}
group = "jpa-basic"
version = "1.0-SNAPSHOT"
repositories {
mavenCentral()
}
dependencies {
//spring-boot-starter-jpa 를 보통 많이 쓴다고 하지만,
//강의에서는 maven 에 hibernate 와 h2 database 를 따로 추가했다.
implementation("org.hibernate.orm:hibernate-core:7.0.6.Final") //hibernate
implementation("com.h2database:h2:2.3.232") //h2 database
testImplementation(platform("org.junit:junit-bom:5.10.0"))
testImplementation("org.junit.jupiter:junit-jupiter")
}
tasks.test {
useJUnitPlatform()
}
JPA 구현체인 Hibernate와 사용 할 데이터베이스인 H2 데이터베이스의 의존성을 추가해준다. 스프링 부트였다면 spring-boot-starter-jpa만 추가해도 두 개 다 포함하고 있으나, 이번 강의는 스프링을 사용하지 않기 때문에 제외한다.
JPA 설정 파일
JPA를 사용하기 위해서는 persistence.xml이라는 설정 파일이 필요하다. 기본적으로 META-INF 디렉토리 하위에 위치하고, persistence-unit의 name으로 이름을 지정하여 사용한다. 속성 중에 javax.persistence로 시작하는 속성은 JPA 표준 속성으로 구현체의 영향을 받지 않고, hibernate로 시작하는 속성은 Hibernate에서만 사용할 수 있는 속성이다.
JPA의 Dialect(방언)은 JPA 구현체가 어떤 데이터베이스에 맞는 SQL을 생성해야 하는지 알려주는 설정이다. JPA는 특정 데이터베이스에 종속되어있지 않고, SQL은 표준이 있지만 DB마다 문법·함수·데이터타입 등이 다르기 때문에 필요하다. 위의 JPA 설정 파일에서도 hibernate.dialect 속성을 H2 데이터베이스에 맞추었다. 만약 MySQL이나 Oracle을 사용한다면 사용하는 데이터베이스에 맞추면 된다.
기본 코드 생성
package hellojpa;
import jakarta.persistence.EntityManager;
import jakarta.persistence.EntityManagerFactory;
import jakarta.persistence.EntityTransaction;
import jakarta.persistence.Persistence;
import java.util.List;
public class JpaMain {
public static void main(String[] args) {
//애플리케이션에 하나만 생성
EntityManagerFactory emf = Persistence.createEntityManagerFactory("hello");
//트랜잭션 필요 시 생성 및 close, 쓰레드 간 공유 X
EntityManager em = emf.createEntityManager();
//JPA 의 모든 데이터 변경은 트랜잭션 안에서 실행
EntityTransaction tx = em.getTransaction();
tx.begin();
try {
/* Create
Member member = new Member();
member.setId(2L);
member.setName("2Name");
em.persist(member);
*/
/* Update
//JPA 를 통해서 객체를 가져오면 persist 할 필요가 없다.
Member findMember = em.find(Member.class, 1L);
findMember.setName("Name1");
*/
List<Member> result = em.createQuery("select m from Member as m", Member.class)
.getResultList();
for(Member member : result) {
System.out.println("member.name = " + member.getName());
}
tx.commit();
} catch (Exception e) {
tx.rollback();
} finally {
em.close();
}
emf.close();
}
}
JpaMain.java를 생성하고 EntityManagerFactory와 EntityManager(em)를 생성하고, 생성한 em에서 트랜잭션을 얻어와 그 안에서 데이터 변경을 실행한다. 트랜잭션이 종료되면 결과에 따라 commit 또는 rollback하게 되고, em을 반환하게 된다. 주석으로 작성하였 듯이 EntityManagerFactory는 애플리케이션에 하나만 생성하고, em은 트랜잭션 필요 시 생성하고 반환하면 된다.
=> EntityManagerFactory는 데이터베이스 연결 설정, JPA 메타데이터 파싱, EntityManager 생성 등 무거운 리소스를 관리하는 역할이다. 생성 비용이 매우 크고, 내부적으로 커넥션 풀, 캐시, 설정 정보 등을 보유한다. 또한 여러 스레드에서 동시에 접근해도 안전하게 설계(Thread-safe)되어 있지만, 여러 개를 만들면 시스템 자원을 낭비하고, 성능 저하 및 예기치 않은 동시성 문제가 발생할 수 있어 애플리케이션에 하나만 생성하고 공유하는 것이 가장 효율적이다.
=> EntityManager는 실제로 Entity를 관리(CRUD)하며, 영속성 컨텍스트※를 관리한다. 생성 비용이 크지 않고, 가볍다. 하지만 여러 스레드에서 동시에 접근하면 데이터 불일치, 예기치 않은 버그가 발생할 수 있다(Non-thread-safe). EntityManager는 트랜잭션 범위 내에서 Entity의 상태를 관리하는데, 트랜잭션이 끝나면 EntityManager와 함께 영속성 컨텍스트도 종료되어야 일관성이 보장된다. 다음과 같은 이유로 필요 시 생성하고, 공유하지 않는다.
동시성 문제: 여러 트랜잭션이 하나의 `EntityManager`를 공유하면, 서로 다른 작업이 섞여 데이터 정합성이 깨진다.
영속성 컨텍스트 오염: 이전 트랜잭션의 데이터가 남아있어 잘못된 결과를 반환할 수 있다.
JPA 명세: `EntityManager`는 트랜잭션 단위로 생성/사용/종료하는 것이 명확하게 권장된다.
※ 영속성 컨텍스트(Persistence Context)는 JPA에서 Entity 객체를 영구적으로 저장하고 관리하는 메모리 공간을 의미하는 핵심 개념 중 하나이다. Entity 생명주기 관리, 1차 캐시, 동일성 보장, 변경 감지(Dirty Checking), 쓰기 지연(Write-behind), 지연 로딩(Lazy Loading) 등의 주요 특징이 있다.
package hellojpa;
import jakarta.persistence.Entity;
import jakarta.persistence.Id;
@Entity
public class Member {
@Id
private Long id;
private String name;
public Long getId() {
return id;
}
public void setId(Long id) {
this.id = id;
}
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
}
@Entity 어노테이션이 있어야 JPA가 관리하는 객체가 된다. 순수한 Java 프로젝트를 생성했기 때문에 persistence.xml에 <class>hellojpa.Member</class>와 같이 Entity 클래스를 등록해줘야 인식한다.
JPQL
JPQL(Java Persistence Query Language)는 JPA에서 사용하는 객체지향 쿼리 언어다. 가장 큰 특징은 데이터베이스의 테이블이 아니라 Entity 객체를 대상으로 쿼리를 작성한다는 점인데, 검색도 테이블이 아니라 Entity 객체를 대상으로 검색한다. SQL을 추상화하기 때문에 특정 데이터베이스에 종속되지 않으며, JPA가 JPQL을 실제 DB에 맞는 SQL로 변환해서 실행한다.
...
List<Member> result = em.createQuery("select m from Member as m", Member.class)
.getResultList();
...
JPA는 Java Persistence API의 약자로 자바 객체를 관계형 데이터베이스에 저장하는 방식을 표준화한 자바 명세이다. ORM을 위한 인터페이스와 어노테이션을 정의하며, 자바 클래스를 데이터베이스 테이블에 매핑하거나 SQL 대신 객체지향 코드로 데이터베이스 CRUD 작업을 수행한다. JPA 자체는 '명세'이고, 실제로는 Hibernate, EclipseLink 등과 같은 구현체를 통해 동작한다. 이런 JPA는 데이터베이스 접근을 단순화하고, 생산성을 높이며, 객체지향 자바 코드와 관계형 데이터베이스 간의 간극을 줄여준다.
public class Member {
private String memberId;
private String name;
private String tel; // 기존에 없던 필드가 추가되었을 때
...
}
JPA를 사용하지 않을 때는 tel이라는 필드가 추가되면 해당 테이블을 조회하는 모든 SQL을 찾아서 수정해야하지만, JPA를 사용하면 필드 추가 이후 SQL은 JPA가 알아서 생성해준다.
지연 로딩과 즉시 로딩
지연 로딩(Lazy Loading)은 연관된 엔티티를 실제로 사용하는 시점까지 데이터베이스에서 조회를 미루는 방식으로, Member 엔티티에서 Team 엔티티를 지연 로딩으로 설정하면, Member를 조회할 때는 Team 데이터가 실제로 필요할 때까지 쿼리가 실행되지 않는다. member.getTeam() 호출 등 실제로 Team 데이터가 필요해질 때 추가 쿼리가 실행된다.
=> 불필요한 데이터 조회를 방지해 성능을 향상시킬 수 있고, 메모리 사용량을 줄일 수 있다는 장점이 있다. 단점으로는 연관 객체를 사용하는 시점에 추가 쿼리가 발생하므로, 트랜잭션이 종료된 후 접근하면 LazyInitializationException이 발생할 수 있다.
즉시 로딩(Eager Loading)은 연관된 모든 엔티티도 함께 즉시 조회하는 방식으로, Member 엔티티에서 Team 엔티티를 즉시 로딩으로 설정하면, Member를 조회할 때 Team도 함께 조인 쿼리로 조회된다.
=> 연관 객체를 바로 사용할 수 있고, 트랜잭션 종료 후에도 이미 데이터가 로딩되어 있어 예외가 발생하지 않는다는 장점이 있다. 단점으로는 연관된 데이터가 많을 경우 불필요하게 많은 데이터를 한 번에 가져와 성능 저하 및 메모리 낭비가 발생할 수 있고, 복잡한 연관관계에서는 N+1 문제 등 예기치 않은 쿼리 폭증이 발생할 수 있다.
대부분에 실무에서는 지연 로딩 사용이 권장된다.
패러다임 불일치 해결
객체지향 프로그래밍과 관계형 데이터베이스 사이에는 패러다임의 불일치라는 근본적인 차이가 존재한다. 객체지향은 데이터를 객체와 메소드로 구성하며, 상속·다형성 등 계층적 구조를 지원한다. 관계형 데이터베이스는 데이터를 행과 열로 구조화하며, 상속이나 객체 참조 개념이 없다. 이러한 차이로 인해 다음과 같은 문제가 발생한다.
상속 미지원: 객체지향의 상속 구조를 테이블로 직접 표현할 수 없다
참조와 식별의 차이: 객체는 참조로 연결되지만, RDB는 외래키로 연결된다
데이터 구조의 불일치: 객체는 중첩, 컬렉션 등 다양한 구조를 가지지만, RDB는 단순한 테이블 구조이다
데이터 접근 방식의 차이: 객체는 관계를 따라 탐색하지만, RDB는 조인으로 데이터를 결합한다
SQL 대신 객체를 대상으로 하는 JPQL 등 객체지향 쿼리를 지원해 객체 모델에 맞는 데이터 접근이 가능하다
따라서 상속, 다형성, 컬렉션 등 객체지향 개념을 데이터베이스에 자연스럽게 반영하므로 반복적인 SQL 및 매핑 코드가 감소되어 개발 생산성이 향상되고, 객체지향적 설계와 데이터베이스 설계의 간극이 해소된다. 또한 데이터베이스 벤더에 종속되지 않는 이식성을 확보할 수 있는 효과가 있다.