자바스크립트 공부 일지 20

Sequelize에서의 트랜잭션 적용

Transaction

트랜잭션의 정의

트랜잭션(Transaction)은 작업의 완전성을 보장해주기 위해 사용되는 개념입니다. 특정한 작업을 전부 처리하거나, 전부 실패하게 만들어 데이터의 일관성을 보장해주는 기능입니다.

**트랜잭션(Transaction)**을 사용하는 대표적인 이유는 작업의 단위를 하나의 쿼리에 종속하는 것이 아닌, **여러개의 작업(쿼리)**을 묶어 하나의 작업 단위그룹화하여 처리하는 작업을 뜻합니다. 대부분의 데이터베이스가 트랜잭션의 특징을 이용하고 있으며, MySQL뿐만 아니라 AWS DynamoDB, MongoDB, CockroachDB 등 다양한 데이터베이스에서도 트랜잭션의 개념을 확인할 수 있습니다.

예를들어 은행에서 계좌이체를 하게 될 경우 아래와 같은 상황이 발생하게 됩니다.
1️⃣ A 고객의 계좌에서 1,000원을 차감합니다.. 2️⃣ B 고객의 계좌에 1,000원을 추가합니다.. 여기서 1️⃣ 번 작업 이후 2️⃣ 번 작업을 수행하던 중 에러가 발생하게 될 경우 A 고객의 계좌에서 1,000원만 차감되기만 하는 문제점이 발생하게됩니다. 만약 순서가 반대로 되었다면, B 고객의 계좌에 1,000원이 증가되기만 하는 문제가 발생하겠죠?
이런 **부분 업데이트(Partial Update)**와 같은 상황을 방지하기 위해 **트랜잭션(Transaction)**이라는 개념을 도입하게 되었습니다.
단순히 위와 같은 상황 외에도 작업한 내역을 저장하는 로깅작업, 영화관의 예매 시스템, 은행의 결제 시스템데이터의 일관성을 유지해야하는 다양한 상황에서 사용하게 됩니다.

  • 결국, 트랜잭션(Transaction)을 이용한다면 사용자가 항상 프로그램 실행을 완료하도록 구성하고, 만약 실행을 중단할 만한 치명적인 오류가 발생하더라도, DB에 피해가 가지않아 더욱 안전하게 구성할 수 있게 됩니다.

특징(ACID)

  • 트랜잭션의 특징(ACID)은 데이터베이스 트랜잭션이 안전하게 수행된다는 것을 보장하기 위한 특징들을 나열해 놓은 개념입니다.

ACID는 트랜잭션을 이용하여 데이터베이스를 더욱 안전하게 구성할 수 있게 도와주는 트랜잭션 특징 4가지의 맨 앞단어를 하나씩 가져와 만든 것 입니다.

transaction_ACID

ACID는 트랜잭션의 4가지 특징을 나타냅니다.

  • 원자성(Atomicity)
  • 일관성(Consistency)
  • 격리성(Isolation)
  • 지속성(Durability)

원자성(Atomicity)

  • 원자성(Atomicity)은 트랜잭션 내에서 실행되는 명령들을 하나의 묶음으로 처리하여, 내부에서 실행된 명령들이 전부 성공하거나, 아니면 모두 실패해야한다는 특징입니다.

원자성(Atomicity)은 트랜잭션의 가장 대표적인 특징입니다. 트랜잭션에서 실행되는 쿼리마다 하나의 작업 단위로 보지않고, 여러개의 작업들을 묶어 하나의 작업단위로 보게됩니다.

📌 계좌이체의 비즈니스 로직

  1. A 계좌의 금액을 차감합니다.
  2. B 계좌의 금액을 증가 시킵니다.

트랜잭션을 사용하게 될 경우 2단계로 구분된 비즈니스로직을 고객의 계좌 금액 차감, 금액 증가를 하나의 계좌이체라는 기능으로 묶어서 관리할 수 있게 될 것입니다.

원자성이라는 특징을 이용해 여러분들은 각각의 쿼리를 별도로 실행하는 것이 아니라, 동시에 실행해야하는 여러개의 쿼리를 묶어서 관리할 수 있게 될 것이고, 여러개의 비즈니스 로직을 상세하게 알지 않더라도 하나의 함수처럼 트랜잭션을 사용할 수 있게 될 것입니다.

일관성(Consistency)

  • 일관성(Consistency)은 트랜잭션 내부에서 처리되는 데이터의 일관성을 유지해야하는 특징입니다. 만약 작업이 성공할 경우 아무런 문제가 발생하지 않고, 실패하더라도 작업을 진행하던 도중 실패한 상태로 데이터를 방치하지 않는 특징입니다.

일관성(Consistency)의 특징은 트랜잭션 내의 데이터는 일관되어야하며, 에러가 발생하더라도 데이터의 문제가 발생하면 안된다는 특징입니다. 만약 일관성이 지켜지지 않을 경우 여러분들은 트랜잭션을 이용하더라도 언제 데이터가 파손될 지 모르는 불안감을 가진 체 작업을 해야할 것 입니다.

📌 강의 등록의 비즈니스로직

  1. 강의(Courses) 테이블에서 강의를 생성합니다.
  2. 생성된 강의 데이터를 참조하는 강의 목록(CourseUnit) 테이블에 강의 영상을 업로드합니다.
  3. 강의 목록(CourseUnit) 테이블에 모든 강의 영상을 업로드 하였다면 COMMIT

첫번째 강의 테이블에 강의를 생성하는것은 성공하였지만 두번째 강의 목록 테이블에서 모든 강의 영상을 삽입하는데 실패하였다면, ROLLBACK이 실행되어 강의(Courses) 테이블과 강의 목록(CourseUnit) 테이블에는 트랜잭션에서 조작한 데이터가 아무것도 존재하지 않는 상태가 될 것입니다.

이렇게 강의 자체가 생성되지 않거나, 모든 강의가 업로드 되는 상황이 발생하게 되어야, 데이터베이스를 믿고 작업을 진행할 수 있게 될 것 입니다.

격리성(Isolation)

  • 격리성(Isolation)은 트랜잭션의 경우 실행 전이나 실행 후의 데이터를 외부에서 참조할 수 있지만, 트랜잭션을 수행하는 중간 상태를 보거나 변경할 수 없도록 구성하는 특징입니다.

격리성(Isolation)의 경우, MySQL에서는 사용중인 DB 오브젝트에 락(Lock)을 걸어 격리성을 구현하게 됩니다. 여기서 락(Lock)을 건 상태는 DB에 접속한 또다른 클라이언트가 해당하는 DB 오브젝트를 읽거나, 사용할 수 없도록 만든다는 개념입니다.
격리성이란 특징에서 동시성(Concurrency)과 격리 수준(Isolation Level)라는 개념이 나타나게되었습니다.

동시성(Concurrency)
  • **동시성(Concurrency)**은 여러명의 클라이언트가 하나의 데이터를 동시에 사용및 공유 하는 것을 뜻합니다.

📌 계좌이체를 하기 위해, 2개의 계좌이체가 발생한다고 가정해보겠습니다.

  1. 1️⃣  트랜잭션에서 A 계좌에서 천원을 차감하여 10,0009,000으로 수정하였습니다.
  2. 2️⃣  트랜잭션에서 수정된 데이터를 바탕으로 A 계좌에서 천원을 차감하여 9,0008,000으로 수정하였습니다.
  3. 1️⃣  트랜잭션에서 에러가 발생하여, 트랜잭션을 ROLLBACK하게 되었습니다.
  4. 2️⃣  트랜잭션에서 트랜잭션을 COMMIT 하게 되었습니다.

2️⃣  트랜잭션은 수정 중인 1️⃣ 트랜잭션의 잘못된 데이터를 참조하는 문제가 발생하였습니다. 잘못된 데이터를 바탕으로 2️⃣  트랜잭션이 비즈니스로직을 수행하여 9,000이 되어야할 계좌의 잔고가 8,000이 되었습니다.

하나의 자원을 여러명이 사용하여 발생하는 문제를 동시성 문제(Concurrency Issues)라고 칭하게 됩니다.

자원을 사용하는 하나의 클라이언트만 해당 자원을 점유할 수 있도록 하여, 다른 사용자가 접근할 수 없도록 만들어 자원을 공유하는 원인을 제거하면 됩니다. 이것을 자원 잠금(Resource Locking)라고 부르며, 락(Lock)이라는 개념이 나오게 된 것 입니다.

지속성(Durability)

  • 지속성(Durability)은 트랜잭션을 성공적으로 수행하면 수정된 데이터를 시스템에 영구적으로 적용하는 특징입니다. 트랜잭션의 중간 결과가 아니라 완성된 결과만 저장하여 데이터베이스에 이상이 생기더라도 자동 복구할 수 있는 특성을 가지고 있습니다.

어플리케이션이 트랜잭션을 완료한 이후, DB에 COMMIT을 요청하였지만 변경사항이 반영되기 전에 DB가 비정상 종료될 경우, DB가 재시작 될 때 트랜잭션의 변경 사항을 다시 반영하게 되는데, 이러한 특징을 지속성(Durability)이라고 합니다.

MySQL 트랜잭션

MySQL에서 트랜잭션은 아래와 같은 명령어를 사용합니다.

sql
-- 트랜잭션을 시작합니다.
START TRANSACTION;

-- 성공시 작업 내역을 DB에 반영합니다.
COMMIT;

-- 실패시 START TRANSACTION이 실행되기 전 상태로 작업 내역을 취소합니다.
ROLLBACK;

COMMIT과 ROLLBACK

우선 COMMIT과 ROLLBACK을 봅시다.

sql
-- SPARTA 테이블을 생성합니다.
CREATE TABLE IF NOT EXISTS SPARTA
(
    spartaId      INT(11)      NOT NULL PRIMARY KEY AUTO_INCREMENT,
    spartaName    VARCHAR(255) NOT NULL,
    spartaAddress VARCHAR(255) NOT NULL
);

-- 1번째 트랜잭션을 실행합니다.
START TRANSACTION;

-- SPARTA 테이블에 더미 데이터 3개를 삽입합니다.
INSERT INTO SPARTA (spartaName, spartaAddress)
VALUES ('SPARTA1', 'SEOUL'),
       ('SPARTA2', 'BUSAN'),
       ('SPARTA3', 'DAEGU');

-- 1번째 트랜잭션을 DB에 적용합니다.
COMMIT;


-- 2번째 트랜잭션을 실행합니다.
START TRANSACTION;

-- SPARTA 테이블에 더미 데이터 3개를 삽입합니다.
INSERT INTO SPARTA (spartaName, spartaAddress)
VALUES ('SPARTA4', 'SEOUL'),
       ('SPARTA5', 'BUSAN'),
       ('SPARTA6', 'DAEGU');

-- 2번째 트랜잭션을 롤백합니다.
ROLLBACK;

예시 코드를 실행할 경우, 2번째 트랜잭션에서 수행하는 INSERT INTO가 ROLLBACK되었기 때문에 실제 SPARTA 테이블은 6개가 아닌, 3개의 데이터만 삽입되어 있는 상태로 존재하게 됩니다.

락(LOCK)

  • 락(Lock)은 동시성을 제어하기 위해 사용하는 기능입니다. 해당하는 데이터를 점유하여 다른 트랜잭션의 접근을 막아 동시성과 일관성의 균형을 맞추기 위해 사용합니다.

하나의 데이터를 여러 사용자들이 동시에 변경하려고 할 때, 락이 존재하지 않다면, 한번에 여러번의 수정이 발생하게되고, 최종 수정된 결과값을 인지할 수 없게 되는 상황으로 인해 데이터베이스의 일관성이 깨지게 됩니다. 이런 상황을 방지하기 위해 데이터베이스에서는 **락(Lock)**이라는 기능을 지원하게 되었습니다.

락(Lock)의 종류

  • 공유 락(Shared Locks) | 읽기 락(READ Locks)

    • 다른 트랜잭션이 데이터를 읽는 것은 허용하지만, 수정하는 것을 금지합니다.
    • **READ 전용 락**이라고 불리기도 하며, 해당 락을 사용하는 트랜잭션이 모든 작업을 수행하였다면 공유 락은 해제됩니다.
  • 배타 락(Exclusive Locks) | 쓰기 락(WRITE Locks)

    • 다른 트랜잭션이 데이터를 읽거나, 수정하는 것을 금지합니다.
    • **WRITE 전용 락**이라고 불리며, 트랜잭션이 해당하는 데이터를 점유한 후 다른 트랜잭션이 해당 데이터에 접근 할 수 없도록 만듭니다.

락킹 수준(Locking Level)

  • 글로벌 락(Global Locks) | 데이터베이스 락(Database Locks)

    • 데이터베이스의 모든 테이블에 락을 걸어, 현재 트랜잭션을 제외한 나머지 트랜잭션들이 모든 테이블을 사용할 수 없도록 만듭니다.
    • 가장 높은 수준의 락을 가지고 있으며, 가장 큰 범위를 가지고 있습니다.
  • 테이블 락(Table Locks)

    • 다른 사용자가 작업중인 테이블을 동시에 수정하지 못하도록 합니다.
  • 네임드 락(Named Locks)

    • 테이블이나 테이블의 행과같은 DB 오브젝트가 아닌, 특정한 문자열점유합니다.
  • 메타데이터 락(Metadata Locks)

    • 다른 사용자가 작업중인 테이블의 동일한 행 및 동일한 데이터베이스의 객체를 동시에 수정하지 못하도록 합니다.

락은 다양한 락킹 수준(Locking Level)을 가지고 있는데, 잘못된 락 설정을 하게 될 경우 모든 API가 동작하지 않는 교착 상태(Dead Lock)가 발생하게 되어, 프로그램이 멈춰버리는 문제가 발생하게 될 수 있습니다.

교착 상태(Dead Lock)

❓ 교착 상태(Dead Lock)를 해결하기 위해선 어떻게 구성해야할까요?
교착 상태(Dead Lock)는 여러 테이블에 락(Lock)을 적용하여, 다른 작업이 처리되지 못하게 점유하고 있는 작업이 있을 때, 다른 작업을 끝나는 것을 무한정 기다리는것을 나타냅니다.

교착상태

💡 교착 상태의 경우 아래와 같은 2개의 트랜잭션이 동시에 실행된다고 가정해보겠습니다.
1️⃣  A→ B 테이블을 순차적으로 사용하는 트랜잭션
2️⃣  B→ A 테이블을 순차적으로 사용하는 트랜잭션

1️⃣  은 처음 A 테이블을 점유하기 위해 을 걸어놓을 것 입니다. 그리고 동시에 2️⃣  또한 B 테이블을 점유하기 위해 을 걸어놓을 것 입니다.

그러면 1️⃣ 은 B 테이블의 락이 풀리기를 기다리는 상태가 발생하게 될 것이고, 2️⃣  또한 A 테이블의 락이 풀리기를 기다리는 상태가 발생하게 될 것 입니다. 이처럼 리소스를 접근하려고 할 때, 서로가 서로의 리소스를 점유하고 있을 때 발생하는 것을 **교착 상태(Dead Lock)**이라고 부릅니다.

해당하는 상황을 해결하기 위해 여러분들은 트랜잭션에서 사용하는 **락(Lock)**의 수준을 명확하게 이해하고, 적재적소에 필요한 락의 수준을 설정하여 트랜잭션을 구성해야합니다.

트랜잭션의 격리 수준 (Isolation Level)

  • 트랜잭션의 격리 수준 (Isolation Level)은 여러 트랜잭션이 동시에 처리될 때 다른 트랜잭션에서 변경 및 조회하는 데이터를 읽을 수 있도록 허용하거나 거부하는 것을 결정하기 위해 사용하는 것 입니다.

트랜잭션의 격리 수준은 대표적으로 4가지로 나타냅니다.

READ UNCOMMITTED

  • **커밋 되지 않은 읽기(Uncommitted Read)**를 허용하는 격리 수준입니다.
  • 가장 낮은 수준의 격리수준이며, 락을 걸지 않아 동시성이 높지만 일관성이 쉽게 깨질 수 있습니다.

READ COMMITTED

  • **커밋 된 읽기(Committed Read)**만을 허용하고, SELECT 문을 실행할 때 공유락을 겁니다.
  • 다른 트랜잭션이 데이터를 수정하고 있는 중에는 데이터를 읽을 수 없어 커밋되지 않은 읽기현상이 발생하지 않습니다.

REPEATABLE READ

  • 읽기를 마치더라도 공유락풀지 않으며, 트랜잭션이 완전히 종료될 때 까지 락을 유지합니다.
  • 공유락이 걸린 상태에서 데이터를 수정하는 것은 불가능하지만, 데이터를 삽입하는 것이 가능해집니다. 그로인해 팬텀 읽기가 발생할 수 있는 문제점이 있습니다.

SERIALIZABLE

  • 데이터를 읽는 동안 다른 트랜잭션이 해당 데이터를 읽거나 삽입할 수 없고, 새로운 데이터를 추가하는 것 또한 불가능합니다.
  • 가장 높은 수준의 격리 수준이므로, 동시성이 떨어지는 문제점이 존재합니다.

💡 커밋되지 않은 읽기(Uncommitted Read) **커밋되지 않은 읽기(Uncommitted Read)**는 다른 트랜잭션에 의해 작업중인 데이터를 읽게 되는 것을 나타냅니다. 만약 커밋되지 않은 읽기가 발생할 경우, 의도치 않은 데이터를 참조하게 되어 데이터의 일관성이 깨지게 되는 상황이 발생하게됩니다.

💡 팬텀 읽기(Phantom Read) 트랜잭션을 수행하던 중 다른 트랜잭션에 의해 삭제된 데이터를 팬텀행(Phantom Rows)이라고 합니다. 여기서, 팬텀행에 해당하는 데이터를 읽는 것을 팬텀 읽기(Phantom Read)라고 부릅니다.

Sequelize의 트랜잭션

  • Sequelize의 트랜잭션은 COMMIT, ROLLBACK을 수동으로 사용하여 트랜잭션을 관리하는 UnManaged 트랜잭션과 Sequelize가 자체적으로 트랜잭션의 성공과 실패를 관리하는 Managed 트랜잭션이 존재합니다.

Managed 트랜잭션

js
const { sequelize } = require("../models/index.js");

// 콜백으로 함수를 할당하여 비즈니스로직을 처리합니다.
const result = await sequelize.transaction( async(t) => {
  // 에러가 발생하면 ROLLBACK
  const user = await User.create({
    firstName: '용우',
    lastName: '이',
  }, { transaction: t }); // 해당 쿼리에 트랜잭션을 적용합니다.

  return user;
});

Managed 트랜잭션은 모든 비즈니스 로직이 성공하거나 에러가 발생한 경우 Sequelize 자체적으로 COMMIT과 ROLLBACK을 하는 장점을 가지고 있습니다.

UnManaged 트랜잭션

js
const { sequelize } = require("../models/index.js");

// Sequelize의 트랜잭션을 변수에 할당하여 트랜잭션을 시작합니다.
const t = await sequelize.transaction();

try {
  const user = await User.create({
    firstName: '용우',
    lastName: '이',
  }, { transaction: t }); // 해당 쿼리에 트랜잭션을 적용합니다.

  // 분기마다 COMMIT, ROLLBACK이 가능하다.

  // 트랜잭션을 사용한 모든 로직을 Commit, DB에 반영합니다.
  await t.commit();
} catch(transactionError) {
  // 에러가 발생하였다면, 트랜잭션을 사용한 모든 쿼리를 Rollback, DB에 반영하지 않습니다.
  await t.rollback();
}

UnManaged 트랜잭션의 경우 명시적으로 트랜잭션을 관리하기 때문에, 트랜잭션의 관리현황을 명확하게 알 수 있고, 원하는 분기처리를 간단하게 구분할 수 있어 비즈니스로직을 있는 그대로 코드로 나타내기 쉽습니다.
COMMIT과 ROLLBACK을 실행해야만 트랜잭션을 종료할 수 있습니다.

트랜잭션 구현

회원가입 트랜잭션

💡 [게시판 프로젝트] 회원가입 API 비즈니스 로직

  1. email, password, name, age, gender, profileImagebody로 전달받습니다.
  2. 동일한 email을 가진 사용자가 있는지 확인합니다.
  3. Users 테이블에 email, password를 이용해 사용자를 생성합니다.
  4. UserInfos 테이블에 name, age, gender, profileImage를 이용해 사용자 정보를 생성합니다.
  5. 회원가입을 완료 처리합니다.

여기서 3️⃣  사용자 및 4️⃣  사용자 정보를 생성하는 로직에서, 에러가 발생하게 될 경우 문제가 발생할 수 있었습니다. 해당하는 로직을 별도로 실행하는 것이 아닌, 트랜잭션을 이용해 하나의 작업 단위로 수정하여, 비즈니스로직을 안전하게 구현해보도록 하겠습니다.

js
// 회원가입 API
router.post('/users', async (req, res) => {
  const { email, password, name, age, gender, profileIamge } = req.body;

  // 1. 동일한 이용자가 있는지 확인
  const isExistUser = await Users.findOne({
    where: {
      email,
    },
  });

  // email과 동일 유저가 있는 경우 에러
  if (isExistUser) {
    return res.status(409).json({
      message: '이미 존재하는 이메일입니다.',
    });
  }

  // 1. 트랜잭션 객체를 할당 Models에 있는 index.js에서 가져옴.
  const t = await sequelize.transaction({
    // 격리 수준 설정
    isolationLevel: Transaction.ISOLATION_LEVELS.READ_COMMITTED,
  });

  try {
    // 사용자 테이블에 데이터 삽입
    const user = await Users.create(
      { email, password },
      { transaction: t } // 트랜잭션 할당
    );

    // 사용자 정보 테이블에 데이터 삽입
    // 어떤 사용자의 정보인지 내용이 필요
    await UserInfos.create(
      {
        UserId: user.userId, // 현재 사용자 정보가 24번째 줄에서 생성된 사용자의 userId를 할당
        name,
        age,
        gender,
        profileIamge,
      },
      { transaction: t } // 트랜잭션 할당
    );

    // 모든 로직이 완료된 경우
    await t.commit();
  } catch (transactionError) {
    // 트랜잭션 내 작업 실패한 경우
    await t.rollback();
    return res.status(400).json({ errorMessage: '유저 생성에 실패했습니다.' });
  }

  return res.status(201).json({ message: '회원가입이 완료되었습니다.' });
});

이후 서버 로그를 보시면 아래와 같이 표시됩니다.

log
Executing (b839f436-3f3c-4c08-a633-a852b55f82c1): SET TRANSACTION ISOLATION LEVEL READ COMMITTED;
Executing (b839f436-3f3c-4c08-a633-a852b55f82c1): START TRANSACTION;
Executing (b839f436-3f3c-4c08-a633-a852b55f82c1): INSERT INTO `Users` (`userId`,`email`,`password`,`createdAt`,`updatedAt`) VALUES (DEFAULT,?,?,?,?);
Executing (b839f436-3f3c-4c08-a633-a852b55f82c1): INSERT INTO `UserInfos` (`userInfoId`,`UserId`,`name`,`age`,`gender`,`createdAt`,`updatedAt`) VALUES (DEFAULT,?,?,?,?,?,?);
Executing (b839f436-3f3c-4c08-a633-a852b55f82c1): COMMIT;

도중에 에러가 발생한 경우 아래와 같이 표시됩니다.

log
Executing (649f8232-6820-4cc2-b726-42842f22d79a): SET TRANSACTION ISOLATION LEVEL READ COMMITTED;
Executing (649f8232-6820-4cc2-b726-42842f22d79a): START TRANSACTION;
Executing (649f8232-6820-4cc2-b726-42842f22d79a): INSERT INTO `Users` (`userId`,`email`,`password`,`createdAt`,`updatedAt`) VALUES (DEFAULT,?,?,?,?);
Executing (649f8232-6820-4cc2-b726-42842f22d79a): ROLLBACK;

Sequelize의 격리수준은 트랜잭션을 생성할 때, isolationLevel 프로퍼티를 정의함으로써 설정할 수 있습니다.

js
const { Transaction } = require("sequelize");

const t = await sequelize.transaction({
  isolationLevel: Transaction.ISOLATION_LEVELS.READ_COMMITTED, // 트랜잭션 격리 수준을 설정합니다.
});

격리 수준(Isolation Level)을 설정할 때, 현재 구현하려는 API에는 어떠한 격리 수준이 필요한지 명확하게 이해하여야 효율적인 데이터베이스의 설계를 할 수 있고, 데이터의 일관성이 깨지지 않도록 구현할 수 있게 됩니다.

사용자 정보 수정 API 구현

사용자 이름 변경 API의 경우 결제시스템과 같이 높은 수준의 일관성을 요구하지 않기 때문에, READ_COMMITTED 격리 수준을 사용하였습니다.

사전 준비

사용자 히스토리

새롭게 사용자 히스토리(UserHisotires) 테이블이 생성되었습니다.

사용자의 이름을 변경하는 API를 구현하면서, 변경된 사용자 이름 정보를 **로깅(Logging)**하기 위해 사용자 히스토리 테이블에 데이터를 같이 생성하도록 구현해보겠습니다.

migration파일은 다음과 같이 수정합니다. createdAt, UpdatedAt이 없으며, 외래키인 userHistoyId는 타입이 Sequelize.UUID로 되어 있습니다. 기본값은 Sequelize.UUIDV4입니다. UUID의 최댓값으로 타입을 지정하는 겁니다.

js
await queryInterface.createTable('UserHistories', {
      userHistoryId: {
        allowNull: false, // NOT NULL
        primaryKey: true, // Primary Key (기본키)
        type: Sequelize.UUID, // UUID의 최댓값으로 타입을 지정합니다.
        defaultValue: Sequelize.UUIDV4, // UUID를 기본 값으로 설정합니다.
      },
      UserId: {
        allowNull: false, // NOT NULL
        type: Sequelize.INTEGER,
        references: {
          model: 'Users', // Users 모델을 참조합니다.
          key: 'userId', // Users 모델의 userId를 참조합니다.
        },
        onDelete: 'CASCADE', // 만약 Users 모델의 userId가 삭제되면, UserHistories 모델의 데이터가 삭제됩니다.
      },
      beforeName: {
        allowNull: false, // NOT NULL
        type: Sequelize.STRING,
      },
      afterName: {
        allowNull: false, // NOT NULL
        type: Sequelize.STRING,
      }
    });
  },

model은 다음과 같이 수정합니다.

js
// userhistories.js
'use strict';
const { Model } = require('sequelize');
const Sequelize = require('sequelize');
module.exports = (sequelize, DataTypes) => {
  class UserHistories extends Model {
    /**
     * Helper method for defining associations.
     * This method is not a part of Sequelize lifecycle.
     * The `models/index` file will call this method automatically.
     */
    static associate(models) {
      // define association here

      // 1. UserHistories 모델에서
      this.belongsTo(models.Users, { // 2. Users 모델에게 N:1 관계 설정을 합니다.
        targetKey: 'userId', // 3. Users 모델의 userId 컬럼을
        foreignKey: 'UserId', // 4. UserHistories 모델의 UserId 컬럼과 연결합니다.
      });
    }
  }

  UserHistories.init(
    {
      userHistoryId: {
        allowNull: false, // NOT NULL
        primaryKey: true, // Primary Key (기본키)
        type: Sequelize.UUID, // UUID의 최댓값으로 타입을 지정합니다.
        defaultValue: Sequelize.UUIDV4, // UUID를 기본 값으로 설정합니다.
      },
      UserId: {
        allowNull: false, // NOT NULL
        type: DataTypes.INTEGER,
        references: {
          model: 'Users', // Users 모델을 참조합니다.
          key: 'userId', // Users 모델의 userId를 참조합니다.
        },
        onDelete: 'CASCADE', // 만약 Users 모델의 userId가 삭제되면, Comments 모델의 데이터가 삭제됩니다.
      },
      beforeName: {
        allowNull: false, // NOT NULL
        type: DataTypes.STRING,
      },
      afterName: {
        allowNull: false, // NOT NULL
        type: DataTypes.STRING,
      }
    },
    {
      sequelize,
      modelName: 'UserHistories',
      timestamps: false, // createdAt, updatedAt 컬럼을 생성하지 않습니다.
    }
  );
  return UserHistories;
};

sql은 다음과 같죠.

sql
CREATE TABLE Users
(
    userId    int(11)             NOT NULL AUTO_INCREMENT PRIMARY KEY,
    email     varchar(255) UNIQUE NOT NULL,
    password  varchar(255)        NOT NULL,
    createdAt datetime            NOT NULL DEFAULT NOW(),
    updatedAt datetime            NOT NULL DEFAULT NOW()
);


CREATE TABLE UserInfos
(
    userInfoId   int(11)        NOT NULL AUTO_INCREMENT PRIMARY KEY,
    UserId       int(11) UNIQUE NOT NULL, -- 1:1 관계 이므로 UNIQUE 조건을 삽입합니다.
    name         varchar(255)   NOT NULL,
    age          int(11)        NOT NULL,
    gender       varchar(255)   NOT NULL,
    profileImage varchar(255)   NULL,
    createdAt    datetime       NOT NULL DEFAULT NOW(),
    updatedAt    datetime       NOT NULL DEFAULT NOW()
);

ALTER TABLE UserInfos
    ADD CONSTRAINT FK_UserInfos_Users
        FOREIGN KEY (UserId) REFERENCES Users (userId) ON DELETE CASCADE;


CREATE TABLE Posts
(
    postId    int(11)      NOT NULL AUTO_INCREMENT PRIMARY KEY,
    UserId    int(11)      NOT NULL,
    title     varchar(255) NOT NULL,
    content   varchar(255) NOT NULL,
    createdAt datetime     NOT NULL DEFAULT NOW(),
    updatedAt datetime     NOT NULL DEFAULT NOW()
);

ALTER TABLE Posts
    ADD CONSTRAINT FK_Posts_Users
        FOREIGN KEY (UserId) REFERENCES Users (userId) ON DELETE CASCADE;


CREATE TABLE Comments
(
    commentId int(11)      NOT NULL AUTO_INCREMENT PRIMARY KEY,
    UserId    int(11)      NOT NULL,
    PostId    int(11)      NOT NULL,
    comment   varchar(255) NOT NULL,
    createdAt datetime     NOT NULL DEFAULT NOW(),
    updatedAt datetime     NOT NULL DEFAULT NOW()
);

ALTER TABLE Comments
    ADD CONSTRAINT FK_Comments_Posts
        FOREIGN KEY (PostId) REFERENCES Posts (postId) ON DELETE CASCADE;

ALTER TABLE Comments
    ADD CONSTRAINT FK_Comments_Users
        FOREIGN KEY (UserId) REFERENCES Users (userId) ON DELETE CASCADE;


CREATE TABLE UserHistories
(
    userHistoryId VARCHAR(36)  NOT NULL DEFAULT (UUID()) PRIMARY KEY,
    UserId        int(11)      NOT NULL,
    beforeName    varchar(255) NOT NULL,
    afterName     varchar(255) NOT NULL
);

ALTER TABLE UserHistories
    ADD CONSTRAINT FK_UserHistories_Users
        FOREIGN KEY (UserId) REFERENCES Users (userId) ON DELETE CASCADE;
UUID
  • UUID(범용 고유 식별자)란 무엇일까요?

UUID(범용 고유 식별자)란 무엇일까요?
💡 **UUID(Universally Unique Identifier, 범용 고유 식별자)**는 총 4개의 정보를 하이픈(-) 으로 구분하여 순차적으로 저장한 데이터 타입입니다. 시간 정보를 포함하고 있어 생성된 순서대로 정렬이 되는 특징을 가지고 있습니다. mongoDB의 ObjectId와 유사하다고 생각하면 될 것 같습니다. → UUID 디코드 사용해보기

UUID

사용자 히스토리(UserHistories) 테이블의 경우 현재는 이름(name)컬럼의 변경 내역만 저장하고 있지만, 이후 사용자의 모든 변경사항, API 호출 내역 등 다양한 정보를 저장하게 될 수 있습니다. 그럴 경우 테이블의 컬럼을 최소화하여 데이터를 효율적으로 저장 및 활용할 수 있게 되어야 합니다.

그렇기 때문에, 일반적인 다른 테이블과 같이 Integer 타입의 기본키createdAt, **updatedAt**과 같은 컬럼을 사용하는것 보단, UUID를 사용하여 컬럼을 최소화 하는것이 로그 테이블에서는 더욱 효율적인 설계가 될 것 입니다.

구현

💡 [게시판 프로젝트] 사용자 이름 변경 API 비즈니스 로직

  1. 게시글을 작성하려는 클라이언트가 로그인된 사용자인지 검증합니다.
  2. 변경할 이름(name)을 body로 전달받습니다.
  3. 사용자의 현재 이름(name**)**을 조회합니다.
  4. **사용자 정보 테이블(UserInofes)사용자의 이름(name)**을 수정합니다.
  5. 사용자의 이름이 변경된 이력을 사용자 히스토리(UserHistories) 테이블에 저장합니다.
  6. 사용자 이름 변경 API를 완료합니다.

사용자 이름 변경 API는 4️⃣  사용자 정보의 수정과 5️⃣  사용자 히스토리 데이터 삽입, 2개의 비즈니스 로직이 하나의 작업처럼 이루어져야합니다.
비즈니스 로직을 수행하던 중 오류가 발생하게 된다면 데이터의 일관성이 깨지게 될 것이고, 사용자 히스토리 테이블의 데이터들을 믿을 수 없게되는 상황이 발생하게 됩니다.

이전 사전 준비 과정을 반영하기 위해 db를 다시 생성합니다.

bash
npx sequelize db:drop

npx sequelize db:create

npx sequelize db:migrate

코드 완성

js
// 사용자 이름 변경 API
router.put('/users/name', authMiddleWare, async(req, res) => {
  const {name} = req.body; // 변경할 이름
  const {userId} = res.locals.user;

  const userInfo = await UserInfos.findOne({where: {userId}});
  const beforeUserName = userInfo.name;

  // 트랜잭션
  const t = await sequelize.transaction({
    isolationLevel: Transaction.ISOLATION_LEVELS.READ_COMMITTED,
  });

  try{
    // 사용자 정보 테이블에 있는 이름 변경
    await UserInfos.update(
      { name },
      {
        where: { userId },
        transaction: t, // transaction을 사용합니다.
      }
    );

    // 변경된 이름 내역은 UserHistories 테이블에 삽입
    await UserHistories.create(
      {
        UserId: userId,
        beforeName: beforeUserName,
        afterName: name,
      },
      { transaction: t } // transaction을 사용합니다.
    );
    await t.commit(); // 모든 로직 성공 시 DB에 반영
  }catch(transactionError){
    console.error(transactionError);
    t.rollback();
    return res.status(400).json({errorMessage:"유저 이름 변경에 실패하였습니다."})
  }
  return res.status(200).json({message:"유저 이름 변경에 성공하였습니다."})
})

아래 로그와 같이 출력됩니다.

log
Executing (c03a9600-80df-4713-b8c3-2815a068755f): SET TRANSACTION ISOLATION LEVEL READ COMMITTED;
Executing (c03a9600-80df-4713-b8c3-2815a068755f): START TRANSACTION;
Executing (c03a9600-80df-4713-b8c3-2815a068755f): UPDATE `UserInfos` SET `name`=?,`updatedAt`=? WHERE `userId` = ?
Executing (c03a9600-80df-4713-b8c3-2815a068755f): INSERT INTO `UserHistories` (`userHistoryId`,`UserId`,`beforeName`,`afterName`) VALUES (?,?,?,?);
Executing (c03a9600-80df-4713-b8c3-2815a068755f): COMMIT;