240908 TIL - Node 숙련 2주차 2, 대망의 트랜잭션

LIHA·2024년 9월 8일
0

내일배움캠프

목록 보기
40/117
post-thumbnail

Node

트랜잭션? All or none.

아예 되거나 아예 안되거나, 하는 작업단위. 계좌이체처럼 'A의 계좌에서 금액감소 - B의 계좌에서 금액증가' 가 하나의 작업단위로 이루어져야 하여 도입된 개념. 되면 다 되고 안되면 다 안되는거지, 되다 말거나 몇 개만 됐다는 상황은 허용되지 않는다.
-> A의 계좌에서는 돈이 감속했는데 B의 계좌에 돈이 증가하지 않는 '부분 업데이트(Partial update)'가 되어버리면 아주 곤란해지기 때문!

트랜잭션의 특징 ACID 속성

원자성, 일관성, 격리성, 지속성
Atomicity, Consistency, Isolation, Durability

원자성

트랜잭션 내에서 실행되는 명령들을 하나의 묶음으로 처리하여, 내부에서 실행된 명령들이 전부 성공하거나 전부 실패해야 한다는 특징
-> 동시에 실행되어야 하는 여러개의 쿼리를 묶어 관리할 수 있게 될것!
ex) 데이터 삭제 후 삭제 로그 삽입 작업
데이터 삭제(DELETE) - 테이블 선택 (SELECT) - 데이터 정상 삭제 시 해당 삭제 작업에 대한 로그 삽입 (INSERT) 의 작업이 하나의 단위가 될 것.

일관성

트랜잭션 내부에서 처리되는 데이터의 일관성을 유지해야 하는 특징
-> 작업 성공 시 아무 문제가 발생하지 않으며, 실패하더라도 실패한 상태로 데이터를 방치하지 않는다! 작업을 실행하기 이전 상태로 되돌려준다.
ex) 강의 테이블을 만들어 강의 목록을 INSERT 하려고 했는데 10개 중 8개만 성공하고 2개를 실패한 경우 -> ROLLBACK이라는 명령어를 통해서 아예 강의 테이블이 만들어지기 전으로 돌려준다.
실제로는 강의가 업로드 되지 않거나 모두가 업로드되는 현상만 반영될 것.

격리성

트랜잭션의 중간 과정이나 중간 결과를 외부에서 볼 수 없도록 하는 특징
A B 트랜잭션이 있고 A 트랜잭션이 DELETE - INSERT - INSERT 로 이루어져 있다고 할때, B 트랜잭션이 A 트랜잭션의 DELETE가 일어나기 전의 데이터를 바라볼 수는 있다. 즉, A에서 어떤 트랜잭션이 일어나든 상관없이 B는 A를 참조하여 자기 트랜잭션을 수행할 수 있는 것.
-> 트랜잭션이라는 것들은 각각 독립적으로 일어나야 한다!
MySQL의 경우는 사용중인 DB 오브젝트에 Lock을 걸어서 해당 DB 오브젝트를 읽거나 사용할 수 없도록 방지하는 식으로 무결성을 보장하게 된다.

격리성에서 파생되는 '동시성'의 개념

여러 클라이언트가 동시에 하나의 데이터를 사용 및 공유하는 '동시성'에 대해 앞으로 많은 고민을 하게 될 것.
-> 동시성은 다수의 사용자가 동일한 시스템을 공유하면서 발생하는 동시 접근 문제를 해결해야 한다!

A 트랜잭션에서 계좌에서 천원을 차감하면 10000원 -> 9000원을 만든다고 하자.
B 트랜잭션에서 아직 커밋되지 않은 A의 결과를 바탕으로 9000 -> 8000원으로 또 차감한다고 하자.
그런데 A 트랜잭션에서 오류가 일어나 롤백이 되어 잔고가 10000원으로 돌아간다면, 9000원을 기반으로 천원을 차감하여 8000원을 만들려고 했던 B 트랜잭션은?🤔

▶ 잘못된 데이터를 기반으로 작업을 수행하여 트랜잭션을 커밋해버리면 잔고가 8000원으로 표시되는 문제가 발생한다! 그래서 이런 상황이 발생하지 않도록, 트랜잭션 하나가 일어나면 해당 트랜잭션에서 사용중인 오브젝트에 다른 트랜잭션이 접근하지 못하도록 오브젝트를 잠궈버리는 등의 방법으로 보호하는 것.

지속성

일단 트랜잭션이 성공적으로 커밋되고 나면, 해당 트랜잭션에 의해 생성/수정된 데이터는 어떤 상황에서도 보존되는 특징
-> 트랜잭션이 완료되면 결과는 DB에 영구적으로 저장되고, 이후 시스템에 어떤 문제가 생겨도 데이터는 손상되지 않는다!

DELETE - SELECT - INSERT를 하기로 했는데, 트랜잭션 도중 서버가 다운됐다고 하자. DELETE만 수행된 상태라면, 서버에 이 DELETE를 반영해야하나 말아야하나?

▶ 이때 서버가 재시작되면 DB는 트랜잭션 로그를 통해서 커밋되지 않은 트랜잭션을 아예 수행 전으로 돌려버릴 수 있다. (커밋되지 않은 트랜잭션 복구 가능) 이게 바로 지속성의 특성.

트랜잭션은 이렇게 실행해요 - 쿼리문과 종류

START TRANSACTION;
--트랜잭션 시작!

COMMIT;
-- 작업 성공 시 작업 내역을 DB에 반영

ROLLBACK;
-- 작업 실패 시 작업 내역을 반영하지 않고 트랜잭션 실행 전으로 롤백

위에서 말했던 '락을 거는' 그 락에도 종류가 있는데, 다음과 같다.

  • 공유 락 - 트랜잭션 중인 데이터를 읽기는 가능, 쓰기는 금지. 읽기 락 이라고도 한다.
    -> SELECT 문 뒤에 LOCK IN SHARE MODE 라고 쓰면 공유 락을 걸 수 있다
  • 배타(Exclusive) 락 - 트랜잭션 중인 데이터를 읽기도 쓰기도 금지. 쓰기 락이라고도 한다.
    -> SELECT 문 뒤에 FOR UPDATE 라고 쓰면 배타 락을 걸 수 있다.

락킹 수준이라는 게 있다 - 락을 어느 범위까지 걸 건지 설정 가능!

  • 글로벌 락
    가장 높은 수준의 락이고, 가장 큰 범위. WITH READ LOCK 이라고 붙이면 모든 테이블에 다 READ LOCK을 걸겠다는 얘기!
    FLUSH TABLES WITH READ LOCK 이라고

  • 테이블 락
    내가 작업 중인 테이블을 다른 사용자가 동시에 수정하지 못하도록 테이블에만 락을 거는 범위. 테이블 명에 아래와 같이 걸면 된다. 자주 사용하진 않는다.
    LOCK TABLES (테이블명) READ; -> 이렇게 걸면 이 테이블에 리드락이 걸리게 된다.

  • 네임드 락
    테이블이나 테이블 행처럼 DB 오브젝트가 아니라, 그냥 특정 문자열을 잠금시키는 기능.
    SELECT GET LOCK ('잠금할 문자열', n초동안 얻어오지 못했을 경우 null을 반환할 )

  • 메타데이터 락
    내가 작업중인 테이블의 동일한 행 및 동일한 DB객체를 다른 사용자가 동시에 수정하지 못하도록 잠그는 범위. 가장 많이 사용하게 될 락
    ALTER TABLE SPARTA ADD COLUMN Age Int
    ▶ 스파르타 테이블에 Age라는 컬럼 추가하고 Int로 타입 고정해줘

작업 대기열이 있는 곳은 언제나 교착상태가 존재 - 식사하는 철학자

나무위키
참고 블로그

DB는 항상 정합성이나 동시성에 대한 문제가 있다. 이전에 리소스를 어디에 할당해 줄것이냐는 문제에서도 생각난 테마인데, 모두가 왼쪽 포크만 들도록 코드를 단순하게 짜버리면 이런 교착상태가 발생한다는 것이다.

트랜잭션의 격리수준

여러 트랜잭션이 동시에 처리될 때 다른 트랜잭션에서 변경 및 조회하는 데이터를 읽을 수 있도록 허용/거부를 결정하기 위해 사용하는 것. 이 격리 수준을 결정할때는 '데이터의 일관성'과 '동시성 처리 성능' 사이에서 밸런스를 맞춰 트레이드 오프를 하는 것이다.

  • READ UNCOMMITTED
    커밋되지 않은 읽기를 허용하는 격리 수준
    ▶ 락을 걸지 않아 동시성이 높지만 일관성이 쉽게 깨질 수 있다. 추천하는 격리 수준은 아님!

  • READ COMMITTED
    커밋되지 않은 읽기는 허용하지 않는 수준
    ▶ 커밋 된 읽기만을 허용하고 SELECT 문 실행 시 공유 락을 건다.

  • REPEATABLE READ
    읽기를 마쳐도 공유락을 풀지 않는 악독한 격리 수준. 트랜잭션이 완전히 끝날때 까지는 락을 풀어주지 않는다.
    ▶ 공유락이 걸린 상태에서 데이터 수정은 불가능 하지만 삽입은 가능하다. 이로 인해서 '팬텀읽기' 라는 현상이 발생할 수 있다.

  • SERIALIZABLE
    데이터를 읽는 동안 다른 트랜잭션이 해당 데이터를 읽거나 삽입할 수 없고 새 데이터 추가도 불가능한 수준
    ▶ 가장 높은 레벨의 격리 수준이라 동시성이 떨어지는 문제점이 발생! 교착 상태가 자주 발생할 가능성이 높다

커밋되지 않은 읽기?
다른 트랜잭션에 의해 작업중인 데이터를 읽게 되는 것을 나타낸다. 트랜잭션이 완료되어야 커밋이 되는데 커밋 안된 걸 읽고 있다는 얘기.

팬텀 읽기?
다른 트랜잭션에 의해 삭제된 데이터를 '팬텀 행(ROW)' 이라고 하는데, 이 삭제된 팬텀 행을 읽는 것을 팬텀 읽기라 한다.

이미 삭제된 걸 참조한다니! 마치 클로저 같은 느낌이구만.

트랜잭션

Sequential 트랜잭션과 Interactive 트랜잭션이 있다. 독특한 것은 prisma.$transaction(async (tx) => {}) 라는 식으로 백틱 내에 쓰는 쿼리마냥 쓸 수 있다! 함수 내에 또 함수라니. 함수끼리 계산하는 건 고차함수랬는데.

  • Sequential 트랜잭션
    Prisma의 여러 쿼리를 배열 속에 집어넣어 전달받아서, 이름처럼 각 쿼리들을 순서대로 실행한다.

  • Interactive 트랜잭션
    비즈니스 로직의 성공/실패에 따라 Prisma가 자체적으로 COMMIT / ROLLBACK을 실행하여 트랜잭션을 관리해주는 타입.
    -> 트랜잭션 진행 중에도 비즈니스 로직을 처리할 수 있어 복잡한 쿼리를 구현하기 용이하다.

$transaction() 의 첫번째 인자 async(tx) 는 우리가 일반적으로 쓰던 prisma 인스턴스와 같은 기능을 수행한다고? $transaction() 앞에도 prisma. 가 붙어있는데 이건 무슨 말인지 잘 모르겠다.

-> 이 얘기다. prisma.users.create() 이런 식으로 쓰던 것을, $transaction을 걸어주게 되면 tx를 인자로 받으니 tx.users.create() 이렇게 써주라는 것.

  • 아무튼 여기서 throw error를 던질 경우 프리즈마가 알아서 에러 처리를 하고 롤백해준다고 하니 Interactive는 알아서 진행되는 것 같다.

트랜잭션 진행 중 비즈니스 로직을 추가하는 방법에 대해 알아볼 것이다. 그래서 Interactive 방식을 쓸 것!

이젠 User History도 필요해 - String 타입 uuid인 id를 만들어주자

uuid는 일반적인 기본키가 아니라 생성될때마다 암호문같이 생긴 긴 문자열이다. ('범용 고유 식별자'라고 한다.)
실제 해당 데이터의 생성날짜나 시간 같은 것을 담고있는 식별자라서 좀더 여러 정보를 담고 여러 곳에 쓸 수 있는 id. @id @default(uuid()) 라고 써주면 된다.

  • UUID는 왜 써요? -> 기본키이면서 createdAt, updatedAt이 다 들어있는 것이기 때문에, 로깅기능만을 하는 User History 테이블에서 많은 컬럼을 쓰지 않기 위해 선택한 것. UUID는 ID값 하나에 그 정보들이 다 담겨있기 때문.

express-session? 세션을 조금 더 쉽게

Express.js에서 세션 기능을 쉽게 구현하기 위한 미들웨어!
클라이언트에게 '세션 ID'를 발급해줘서, 이 ID를 통해 서버가 클라이언트의 상태를 추적할 수 있다.
-> 클라이언트가 세션 ID를 발급 받은 후에는 모든 서버 요청마다 세션 ID가 포함된 쿠키를 전달하게 되며, 서버는 이 세션 ID로 클라이언트를 식별하는 것!
-> 이전에 구현했던 Session 객체에 세션 ID를 저장한 것과 동일한 방법이라는데, 기억이 안 난다. 헿😋 새로 해보자.

  • req.body에서 받아온 userId 값을 req.session에 넣어서 보내줄 수 있다.
    세션 스토리지는 익스프레스가 시작한 메모리 그 자체에 저장돼서, 인메모리 방식이라 서버를 껐다 켜면 날아간다. 그래서 외부에 세션 스토리지를 둬서 세션을 관리하는 방법도 존재.

기존 사용자 인증 미들웨어는 쿠키를 조회, Bearer 토큰과 JWT를 검증하는 복잡한 과정이었다.
하여 앞으로는 connect.sid에 저장된, express가 자동으로 만든 세션 ID를 이용해서 인증할 것.
이 세션 ID에 저장된 userId를 이용해서 사용자를 조회하고, req.user에 조회된 사용자 정보를 할당한다. 앞선 모든 과정이 성공하면 다음 미들웨어를 실행한다.
-> 이렇게 되면 userId를 이용해 사용자를 조회하고, req.user에 할당하는 간단한 역할만 하게 된다. 코드 복잡도가 내려가고 인증 과정이 단순해짐.

서버를 재실행 할때마다 세션이 초기화 되는건 어쩌지? -> express-mysql-session이라는 모듈이 있다

지금이야 터미널로 잠깐 실행시키고 마는 상태지만, 나중에 여러 서버를 띄워놓고 껐다가 켜게 되는 경우에는 외부 세션 스토리지 사용을 권장. 서버가 껐다 켜지더라도 사용자 인증 정보 같은 것이 날아가지 않아야 하기 때문이다.

단점 : 세션 ID로 조회할때마다 MySQL 조회쿼리를 날려야 한다. 계속 SELECT문을 날리면 리소스 부하가...
이 대신 JWT를 사용하고 외부 스토리지를 Redis로 변경하는 것도 방법이다. Redis는 캐시 메모리 DB이므로 매번 SELECT 하지 않아도 되기 때문.

profile
갑자기 왜 춤춰?

0개의 댓글