보충
1차 캐시는 identity 즉, 동일성을 보장한다. 같은 트랜잭션(persistence context)내에서는 동일한 엔티티를 조회할 경우 항상 같은 객체 참조를 반환하여 == 비교가 성립한다.
이 보장은 트랜잭션 내부에서만 유효하기 때문에 다른 트랜잭션에서는 별도의 persistence context를 사용해서 == 비교는 성립하지 않는다.
그리고 트랜잭션의 격리수준 Read Committed은 1차 캐시가 아닌 DB가 보장하는 것이고 이게 잘 되어있어야지 2차 캐시의 역할을 수행하는 Redis까지 사용하는 것이 가능하다.
여기서 Redis가 수행하는 것은 DB의 접근을 줄이고 분산환경에서 캐시를 공유하는 역할을 수행한다.
정규화
데이터베이스을 사용하고 추구하는 것 중 하나로는 데이터베이스를 중복없이 일관성 있게 유지하는 것이다. 우리가 테이블을 만들 때 여러가지 정보를 넣는 경우
필요한 정보지만 다른 attribute의 요소의 삭제 때문에 없앨 수가 있다. 이러한 사고를 방지하고 데이터의 무결성 유지를 위해 테이블을 분해하는 과정을 정규화라고 한다.
디민 정규화를 많이 하는 것이 성능과 직결되는 것이 아니다. 테이블을 join연산을 시킬 수록 더 많은 연산이 필요하기 때문에 적절하게 쓰는 것이 좋다. 그래서 실무에서는 대부분 3NF까지 혹은 경우에 따라 BCNF까지 사용한다. 그리고 쿼리의 속도를 높이기 위해 테이블을 합치는 경우가 있는데 이는 반정규화라고 한다.
함수종속
어떤 컬럼의 값이 다른 컬럼의 값을 결정하는 단계이다. 즉 어떤 값이 주어졌을 때 그 컬럼과 함수 종속관게에 있는 다른 컬럼의 값이 정해지는 것을 의미한다.
A->B로 표기 정규화 진행시 기준이 되는 개념이다. 즉 데이터 중복을 줄이고 이상현상을 방지하기 위해 분해할 때의 기준이다.
이상현상
- 삽입 이상(Insertion Anomaly)
- 원하지 않는 데이터까지 같이 넣어야만 삽입이 가능한 문제
- 갱신 이상(Insertion Anomaly)
- 하나의 데이터를 수정할 때 여러 곳을 다 바꾸어야 하는 문제
- 삭제 이상(Deletion Anomaly)
- 특정 데이터를 삭제 했는데 원래 보존해야 할 정보까지 같이 삭제되는 문제
1NF(원자성 확보)
태이블의 모든 속성이 원자값을 가지게 하는 경우
하나의 컬럼에는 하나의 값만 가져야 한다.
-> 컬럼 개수에 의존하는 유연성이 적어진 형태가 된다.
2NF(부분종속 제거)
기본키가 학생ID가 학생명을 과목 명 없이 결정할 줄 알아야 한다.
학생ID랑 과목코드가 함수종속이 된다. 성적이 추가된다면 함수종속이 된다.
종속이 기본키 전체가 아니라 일부 키에 의존할 경우 발생하는 부분함수 종속을 제거해야 한다.
부분함수 종속이 된다. 부분함수 종속을 제거하면 어떤 속성이 무엇에 의해 결정되는지 분류하는 것이 가능하다.
3NF(이행함수 제거)
2정규화를 만족하는 테이블 중 이행함수 종속을 없애는 것이다.
A→B, B→C 이면 A→C에 대해 함수종속이 이루어지는 경우 이러한 간접적인 종속을 제거하는 것이다.
학생 ID가 있고 학과 코드가 있다고 하면 학과 코드가 학과장한테 되어있으면 학생ID가 종속관계를 보고있기 때문에 개선할 필요가 있다.
BCNF(유일성 만족하지 않는 컬럼에도 종속 제거)
모든 결정자가 반드시 후보키여야 한다. X→ Y, X는 반드시 후보키여야 한다.
일반적으로는 3NF까지 정규화를 진행하면 대부분의 이상현상을 제거할 수 있기 때문에 실무에서도 충분한 경우가 많다. 다만 특정 테이블에서 후보키가 아닌 속성이 다른 속성을 결정하는 경우에는 BCNF를 적용하여 추가적인 이상현상을 제거할 수 있다. 그러나 BCNF까지 정규화를 진행하면 테이블이 더 분해되어 조인이 증가하게 되므로, 조회 성능이 저하될 수 있어 상황에 따라 선택적으로 적용한다.
동시성 제어 - 고립과 잠금 (매우 중요, 면접 단골)
여러가지 트랜잭션이 가능한 무결성과 정확도를 보장하기 위해 사용하는 기술이다. 동시성에도 이상현상이 발생가능한데 이를 해결하기 위해서
동시성 제어를 위한 고립이 뭔지 잠금이 뭔지에 대해 알아볼 예정이다.
트랜잭선의 속성중 ACID의 I인 isolation은 완전히 보장하려면 트랜잭션 간 간섭을 철저히 차단해야 한다. 하지만 이 경우 동시에 처리할 수 있는
작업량(Throughput)이 크게 감소한다는 단점이 있다. 또한 각각의 트랜잭션은 마치 혼자 실행되는 것처럼 보장할 수 있어야 한다.
그래서 적절한 고립레벨 설정을 하는 것이 중요하다고 볼 수가 있다.
팁
내가 만약 동시성 제어를 한 것을 보여주고 싶으면
각 과정에 따라 속도와 시간을 스냅샷을 찍어두고
성능 개선을 한 상태에서 찍은 스냅샷을 해서 비교한 모습을 보여주도록 하자.
고려해야 하는 Anomaly
DirtyRead
다른 트랜잭션이 아직 커밋하지 않은 데이터를 읽는 현상
트랜잭션 T1과 T2가 있다고 하면 T1이 진행하고 있는 도중에 T2가 읽었기 때문에 데이터 정합성이 보장되지 않는다.
Non-Repeatable Read
한 트랜잭션에서 같은 데이터를 두 번 읽었는데 값이 달라지는 현상
이는 트랜잭션 A는 이체처리 트랜잭션 B는 감사 작업을 수행하는 경우 transactionA, transactionB가 다시 1번을 조회하는 경우 작업이 끝나지 않았는데 동일한 작업에서 값이 다르게 나온 경우 읽기 시점마다 최신 commit된 상태를 읽어서 발생한 문제라고 볼 수가 있다.
Phantom Read
같은 조건으로 조회했는데 행(row) 자체가 달라지는 현상, 같은 조건으로 조회했는데 행(row) 자체가 달라지는 현상
범위를 조회했을 때 달라질 수가 있다.
SQL 표준 고립 4단계
고립 수준이 높아질 수록 더 많은 이상현상을 대쳐할 수가 있지만 성능이 떨어질 수가 있다. 구현하고자 하는 기능에
맞게 고립 수준을 정하는 것이 좋다.
그리고 참고로 MySQL이랑 MariaDB는 데이터베이스 안에 돌아가는 엔진이 있다. INNODB라는 엔진을 가진다.
이 INNODB는 MVCC패턴이란 것을 적용하게 된다.
Read-uncommitted level
커밋되지 않은 레벨까지 읽는다고 보면 된다.
가장 성능이 좋다. 고립레벨이 가장 많이 풀려있음, 3가지 이상현상 모두 가능한 레벨이다.
Read Committed
커밋된 데이터만 읽도록 제한힌다. 이 경우 Dirty Read 방지하고 커밋된 데이터만 읽을 수 있도록 방지한다.
다만, 동일한 쿼리를 반복 실행했을 때 non repeatable read와 Phantom Read이 발현될 수가 있다.
Dirty Read를 방지한다. 최신 커밋데이터를 읽고 lock이 과도하게 걸리는 상황은 피하려고 한다.
repeatable read
트랜잭션의 시작시점을 스냅샷으로 만든다. 동일한 환경 내에서 동일한 결과를 보장하는 것이다.
참고로 MySQL의 transaction level의 default 설정이다. 이 경우 Phantom Read가 나올 수가 있지만
MySQL에서는 MVCC기능과 Next-key-lock으로 phandotm read를 막는다.
Next-key-lock: 기존 row와 사이 공간까지 잠금을 걸어 다른 트랜잭션이 그 범위에 insert를 못하게 한다.
Serialize
가장 강한 고립레벨, 트랜잭션을 순차적으로 실행한 것과 동일하게 진행, 그래서 가장 성능이 낮다.
트랜잭션간 간섭을 원천적으로 차단, race condition 증가한다. 병목현상이 발생할 가능성이 높아진다.
성능은 안좋지만 은행같이 중요한 데이터를 다루는 곳에서 일부구간에 한정해서 serializable을 사용한다고 한다
MVCC
multi version Concurrency Control
—
데이터의 여러버전을 유지하여 트랜잭션간 충돌 최소화하는 동시성 제어방식이다. 잠금 없이 일관된 읽기를 가능하게 한다.
데이터를 덮어쓰지 않고 버전을 만들어서 변경 이력 유지하고 트랜잭션마다 다른 시점의 데이터를 보게하는 방식이다.
이때 버전을 유지하기 위해 버전을 따로 저장하는 컬럼이나 updatedAt을 만들어서 시간을 통해 결정하도록 한다.
Lock 잠금
데이터에 접근하는 권한을 제한하고 트랜잭션에 접근하는 권한을 부여하여 읽고 쓰는 것을 제한하는 기법이다. 이 기법을 통해 동시성 관리, 데이터 정합성 보장, 자원 관리를 할 수가 있다.
잠금의 작동방식
어디에서, 어떤 전략으로 이루어지느냐에 따라 여러 종류로 나뉘게 된다. JVM, OS, DB등 의미가 조금씩 다르지만 핵심원리는 동일하다고 볼 수가 있다.
공유잠금 shared Lock
읽기 잠금(Read Lock)이라고도 하고 여러 개의 트랜잭션이 동시에 같은 데이터에 공유 잠금 획득가능하다. 만약 공유잠금이 존재하면 배타 잠금 불허한다. 공유 잠금 해제까지 다른 트랜잭션은 기다려야 한다. 잠금이라는 것은 말 그대로 읽기 작업을 수행할 때까지 잠금한다는 것을 의미한다.
배타잠금 Exclusive Lock
쓰기잠금(write lock)이라고도 한다. 트랜잭션이 잠금을 획득하면 다른 모든 접근을 차단한다.
수정중인 데이터가 외부로 노출되면 안되기 때문에 원자성으로 이루어지지 않는다. 왜냐하면 트랜잭션 중간에 다른 트랜잭션이 읽으면 불안정한 상태가 노출되기 때문이다.
잠금의 전략, 잠금의 철학적 방식?
동시성을 바라보는 관점에서 잠금을 적용하는 전략이다. 사용하는 목적과 현실적인 상충방식을 고려하고 적용하게 된다.
낙관적 락
트랜잭션에서 데이터 충돌이 자주 발생하지 않을 것이라고 생각하고 커밋직전에 어떤 충돌이 있었는지 살펴본다. 만약 다른 트랜잭션이 자기를 바라봤으면 취소하고 다시시도한다. 선조치 후보고 같은 느낌으로 수행을 하게 된다.
- 우선 시도 commit하고 만약 충돌이 일어났다면 재시도 하던지 보고나서 다시해 이런 방식
- 읽기 작업이 많고 충돌이 드문 환경에서 높은 동시성을 제공한다.
- 재시도 비용이 크기 때문에 많은 충돌이 있는 환경은 피하는 게 좋다
- updatedAt 타임스태프나 버전 어노테이션을 사용하여 동시성 제어를 한다.
- 낙관적 락을 이것을 사용하여 적용
- 낙관적 락을 걸면 커밋하는 시점에서 검사하기 때문에 이것을 표현하려는 수단을 만들어두면 어플리케이션에서 조절이 가능하다. 다만 정합성에 관한 문제가 발생할 수가 있다.
비관적 락
데이터 충돌이 언제든지 발생가능하다고 가정하고 미리 잠금을 거는 방식
데이터 정합성이 절대적으로 보장되어야 하는 상황에서 선택한다. 비관적 락은 충돌이 될 확률이 매우 큰 경우에 시행한다. 이때 잠금을 보유한 트랜잭션 작업이 길어지면 Deadlock 발생 가능성이 높아진다.
티켓 같은 데이터를 다룰 때 이 비관적 락을 사용하는 것이 조금 더 좋다고 볼 수가 있다.
잠금을 보유한 트랜잭션이 오래걸리면 교착상태에 걸릴 수가 있다.
예를 들어 티켓팅 시스템과 같은 경우 비관적 락과 메세징 큐를 활용하여 순차적 큐를 보장해준다.
이러한 메세징 큐를 제공해주는 프로그램 중에는 레비뎀 큐(오픈소스), kafka가 있는데 이를 메세징 브로커라고 한다.
이러한 프로그램을 다른 말로 MQ라고 한다.
참고
멱등성 : 몇번을 해도 같은 결과가 나오게 하는 것
채팅기능 프로그램을 구현할 때 레비뎀 큐, 카프카를 같이 써보는 것이 좋다
애플리케이션이 메세지를 교환할때 카프카를 이때 활용하는 편이 좋다
교착 상태, Race condition
서로 다른 두개이상의 트랜잭션이 각각 lock을 보유한 상태로 잠금해제를 대기하는 경우를 의미한다.이경우 외부개입이 없으면 영원히 대기상태에 빠지게 된다.
교착상태 조건
무조건 4가지를 충족해야지 교착상태가 일어난다. 이 4가지 조건을 → 코프만 조건이라고 부른다.
- 상호배제(Mutual Exclusion): 잠금은 한 번에 하나의 트랜잭션만을 보유한다.
- 점유와 대기(Hold and Wait): 트랜잭션이 추가적인 잠금을 요청하는 단계
- 비선점(No preemption):다른 트랜잭션의 잠금을 강제로 획득/회수 불가능
- DBMS에서 race condition을 풀기 위해 다른 하나의 트랜잭션을 롤백시켜서 이런 데드락을 푸는 경우가 있다.
- 순환대기(Circular Wait) : 트랜잭션들이 이 원형의 구조로 서로의 잠금을 대기
교착상태 처리
예방
순서를 만들어 놓는다.
간단한 예방방법으로 잠금 타임아웃이 있다. 먼저가져간 애가 타임아웃을 정하고 일단 그 시간이 지나면 잠금을 풀어버린다. 이경우를 잠금 타임아웃이라고 한다.
회피
이 데드락이 발생할 것 같은 요청을 자체에 사전차단하는 것이다.
은행원 알고리즘이 이 회피에 포함된다. 예를 들어서 시스템이 각각의 트랜잭션이 어떤 작업을 하는지, 자원을 가지고 있는지 본다. 시스템이 허용되는 역량 이상의 자원을 쓰려면 시스템이 막는다.
→ 실무에서는 안된다. 두 트랜잭션의 자원과 작업을 아는데 너무 많은 비용을 소모하기때문이다.
탐지 후 복구
탐지 후 보고, 한명을 희생하는 방식으로 동작
희생을 할 트랜잭션을 선택하고 강제로 롤백을 시킨다.
테스트 위주의 코드 생성 및 실습
public class JpqlTests {
private static final EntityManagerFactory emf =
Persistence.createEntityManagerFactory("hibernate-exp-6");
void main(){
setup();
tearDown();
}
void setup(){
EntityManager entityManager = emf.createEntityManager();
EntityTransaction tx = entityManager.getTransaction();
try{
tx.begin();
Member m1 = new Member("오지원","pass1","ojiwon@chicken.com");
Member m2 = new Member("배라자","pass2","beraja@chicken.com");
Member m3 = new Member("문현민","pass3","moonhm@chicken.com");
entityManager.persist(m1);
entityManager.persist(m2);
entityManager.persist(m3);
tx.commit();
} catch (Exception e) {
tx.rollback();
throw new RuntimeException(e);
}finally {
entityManager.close();
}
}
void tearDown(){
emf.close();
}
}
setup()과 tearDown()으로 테스트 코드를 분리하여 테스트를 진행했다. 위 두개의 테스트는 서로에게 영향을 주지 않도록 설계를 하고 진행했다. 이렇게 하면 테스트는 항상 같은 결과를 내야 함에 만족하기 때문에 좋은 테스트라고 볼 수가 있다.
JPQL
특정 데이터베이스에 종속되지 않는 객체지향 쿼리 언어이다.SQL과 유사하지만 테이블 대신 entity이름으로 쿼리 작성한다. 내부적으로는 SQL로 변환된다.
String jpql = """
select m from Member m where m.orders.is not empty
""";
테이블이 아닌 엔티티 객체를 대상으로 실행을 한다. 다만 where문을 사용할 때 n+1과 같은 문제를 조심해야 한다.
이 경우는 fetch join을 사용하여 어느정도 해결 할 수가 있다.
SELECT m FROM Member m JOIN FETCH m.orders
![]()