Dev

원자성 보장 코드에서? DB에서?

친환경사과 2025. 3. 18. 17:32

🍎 마주한 문제에서 데이터의 원자성을 어느 곳에서 관리하면 좋을지 고민하게 되어 정리합니다.


🍏 문제 상황

✓ 공공데이터 API에서 여러 건의 원시 데이터를 조회한 후, 이를 내부 비즈니스에 활용할 수 있도록 정제하여 저장해야 했다. 이 과정에서 데이터를 여러 개의 테이블에 나누어 저장해야 하는 상황이 발생

 

 처음에는 Bulk Insert를 사용하여 성능을 최적화하는 방안을 고려했지만, 하나의 테이블이 아닌 여러 개의 테이블에 데이터를 삽입해야 하기 때문에 Bulk Insert를 적용할 수 없었다.

 

결국 데이터를 한 건씩 개별적으로 저장하는 방식이 필요했는데 중간에 데이터 삽입이 실패할 경우 어떻게 처리할 것인가라는 고민이 생겼다.

 

 데이터 정제 및 저장 로직이 일부만 실행되고 중단될 경우, 데이터 정합성이 깨질 가능성이 있었다. 이 문제를 해결하기 위해 원자성을 보장하는 방법을 고민하게 되었고 DB 레벨에서 트랜잭션을 활용해 보장할 것인지, 코드 레벨에서 직접 관리할 것인지에 대한 선택이 필요했다.


🍏 정제된 입찰 공고 데이터의 저장 프로세스

 

✓ 단 건의 데이터 모델에서 저장 프로세스를 거친 후 입찰 공고, 기관, 기관 매니저, 입찰 공고 기관 관계 테이블에 데이터가 저장됩니다.

// 입찰 공고 테이블
CREATE TABLE IF NOT EXISTS tender_notice (
    idx SERIAL PRIMARY KEY,          -- 자동 증가 PK
    id VARCHAR(256) NOT NULL UNIQUE, -- 공고 ID
)

// 기관 테이블
CREATE TABLE IF NOT EXISTS institution (
    idx SERIAL PRIMARY KEY,          -- 자동 증가 PK
    institution_id VARCHAR(2556) UNIQUE, -- 기관의 ID
)

// 입찰 공고 기관 관계 테이블
CREATE TABLE IF NOT EXISTS tender_notice_institution_relation (
    tender_notice_idx INT NOT NULL, -- 공고 IDX
    institution_idx INT NOT NULL   -- 기관 IDX
)

 

입찰 공고-기관 관계 매핑 테이블은 다음 두 개의 Auto Increment된 IDX 값을 속성으로 갖습니다.

    - 입찰 공고 테이블의 IDX

    - 기관 테이블의 IDX

➡️ 따라서 관계 테이블에 데이터를 삽입하려면 입찰 공고 테이블기관 테이블에 데이터가 먼저 저장되어야 하고 이후 DB에서 자동으로 증가된 IDX 값을 조회하여 관계 테이블에 데이터를 삽입해야 합니다.


🍎 DB 레벨에서 트랜잭션을 사용한 원자성 보장

트랜잭션 이란 모든 데이터베이스 시스템에서 기본적인 한 개념입니다. 트랜잭션의 핵심은 여러개의 작업이 최종적으로는 하나로 취급된다는 것입니다.

PostgreSQL에서는 BEGIN과 COMMIT을 사용하여 원자성을 보장하는 트랜잭션을 관리할 수 있습니다.

BEGIN;

WITH inserted_tender AS (
    INSERT INTO tender_notice (id, type)
    VALUES ('TND002', '공사')
    RETURNING idx
),
inserted_institution AS (
    INSERT INTO institution (institution_id, organizer_name)
    VALUES ('INST002', '기관 A')
    RETURNING idx
)
INSERT INTO tender_notice_institution_relation (tender_notice_idx, institution_idx)
SELECT inserted_tender.idx, inserted_institution.idx
FROM inserted_tender, inserted_institution;

COMMit;

 

✓ WITH Query를 사용하여 INSERT의 순서를 지정할 수 있고 RETURNING Keyword를 사용해 DB에서 생성된 idx를 반환받을 수 있습니다.

 

🤔 DB 레벨에서 트랜잭션을 관리하면 어떤 불편한 점이 있을까?

여러 테이블에 모델을 나누어 저장하는 첫 번째 방법으로, DB 레벨에서 BEGIN과 COMMIT 키워드를 사용하여 데이터를 적재하고자 했습니다.

 

✓ 두 가지 이유 때문에 이 해당 방법을 사용하지 않기로 결정했습니다.

1️⃣ BEGIN과 COMMIT 으로 이뤄진 하나의 트랜잭션에서 예외가 영속 계층에서 발생한다.

2️⃣ ROLLBACK 처리를 영속 계층에서 직접 쿼리를 사용해 핸들링해줘야 한다.

 

✓ 세부적으로 나눠 두 가지의 이유라고 적었지만 한 문장으로 표현하면 "비즈니스 관리 포인트가 외부 시스템에 걸친다"입니다.

✓ 데이터의 CRUD를 위 쿼리를 사용하는 것은 도구 사용목적이기에 상관없지만 트랜잭션 실패에 대한 예외, 예외 후 처리가 영속 계층에 존재한다는 것은 비즈니스 로직에서 이를 유연하게 제어할 수 없음을 의미합니다.

(+ Postgresql 트랜잭션 처리 시 내부 동작은 다른 글에서 다루겠습니다.)


🍎 코드 레벨에서 직접 관리하여 문제 해결

Spring이 제공하는 @Transactional을 활용하면 코드 레벨에서 직접 트랜잭션을 관리할 수 있습니다.

 

🤔 언제 @Transactional을 사용해야 할까요?

✓ 사용할지 여부는 요구사항과 데이터 정합성의 중요도에 따라 결정됩니다.

 

예를 들어, 트랜잭션이 필수적인 경우:

 결제 처리와 영수증 발행 같은 금융 관련 비즈니스 로직에서는 데이터 정합성이 필수적입니다.

 결제가 성공적으로 이루어졌다면, 반드시 영수증이 생성되어야 하며, 만약 영수증 생성에 실패하면 결제도 무효화해야 합니다.

 이러한 경우 @Transactional을 사용하여 하나의 작업 단위로 묶고, 실패 시 전체 롤백(rollback) 하여 데이터 불일치 문제를 방지합니다.

 

반면, 트랜잭션이 필요하지 않을 수도 있는 경우:

 비즈니스적으로 상대적으로 중요도가 낮은 로깅에서는 @Transactional을 사용하지 않는 것이 좋을 수 있습니다. 이유는 일부 실패하더라도 비즈니스 로직이 크게 영향을 받지 않기 때문입니다.

 

💡 즉, @Transactional은 데이터 정합성이 중요한 핵심 비즈니스 로직에 적용하고, 상대적으로 영향이 적은 로직에는 사용하지 않는 것이 효율적입니다.

 

 메서드에 @Transactional을 선언하면 해당 메서드 내의 모든 작업이 하나의 트랜잭션으로 묶여 실행됩니다. 메서드 블록 내부의 로직이 하나의 작업 단위(Atomicity)로 처리되며, 오류 발생 시 전체 롤백(Rollback)이 보장됩니다.

@Transactional
override fun storeTenderNotice(tenderNoticeRawData: TenderNoticeRawData) {
    // 입찰 공고 저장
    val tenderNotice = TenderNotice(tenderNoticeRawData)
    // 저장 후 idx 반환
    val tenderNoticeIdx = tenderNoticeRepository.save(tenderNotice)
    
    // 기관 저장
    val institution = Institution(tenderNoticeRawData)
    // 저장 후 idx 반환
    val institutionIdx = institutionRepository.save(institution)

    // 기관 매니저 저장
    val registrant = Registrant(tenderNoticeRawData, institutionIdx)
    val registrantIdx = registrantRepository.save(registrant)

    // 입찰 공고, 기관 관계 저장
    val result = tenderNoticeRepository.saveTenderNoticeInstitutionRelation(institutionIdx, tenderNoticeIdx)
    return if (result == 1) {
        return
    } else {
        // 문제가 발생했을 경우 예외를 던져 위 레벨에서 처리
        throw IllegalArgumentException("TenderNotice $tenderNotice")
    }
}

 

✓ 하나의 과정이라도 실패하게 된다면 모든 처리가 롤백되며 예외 처리를 핸들링 할 수 있어 비즈니스 레벨에서 해결할 수 있습니다.

영속 계층에서 어떤 데이터베이스를 사용하는지 신경 쓸 필요가 없다는 점도 큰 장점입니다. 진행 중인 프로젝트에서는 PostgreSQL을 사용하고 있지만, MySQL로 변경되더라도 코드 레벨에서는 별다른 수정 없이 그대로 유지될 수 있습니다. 이는 설계 원칙 중 하나인 OCP를 준수합니다.

 

(+ @Transactional에 관한 내부 동작은 다른 글에서 다루겠습니다.)


🍎 의사 결정 및 결론

코드 레벨에서 트랜잭션을 관리하면 데이터 정합성을 유지하면서도 비즈니스 로직과의 결합도를 낮출 수 있다는 점에서 큰 이점이 있습니다. 이를 고려하여, 트랜잭션 관리를 데이터베이스 레벨이 아닌 코드 레벨에서 수행하는 것이 더욱 적합하다고 판단하였고 @Transactional을 활용한 방식으로 결정하게 되었습니다.

 

글을 정리하는 과정에서 데이터베이스 레벨에서 트랜잭션을 관리하는 방법과 코드 레벨에서 관리하는 방법의 장단점을 직접 경험할 수 있었습니다.

    ✓ DB 레벨 트랜잭션 관리는 데이터의 일관성을 강력하게 보장할 수 있지만, 트랜잭션의 흐름과 예외 처리를 애플리케이션에서 유연하게 제어하기 어려운 단점이 있었습니다.

    ✓ 코드 레벨 트랜잭션 관리는 데이터베이스에 종속되지 않으며, 예외 상황을 코드에서 효과적으로 핸들링할 수 있어 유지보수성과 확장성이 뛰어났습니다.

🔥 의사 결정 후 “트랜잭션 내부에서는 어떤 동작이 이루어질까?“라는 궁금증이 생겼습니다.🔥 

 

✓ Spring의 @Transactional은 내부적으로 어떻게 트랜잭션을 생성하고 관리할까?

✓ PostgreSQL에선 내부적으로 어떻게 트랜잭션을 생성하고 관리할까?

트랜잭션 격리 수준은 무엇이며 어떻게 설정해야 할까?

 

🫡 질문들을 해결하기 위해 다음 연재에서는 트랜잭션이 내부적으로 어떻게 동작하는지, 그리고 Spring이 이를 어떻게 관리하는지에 대해 탐구해 볼 예정입니다! 🫡 


📚 Reference

 

Using @Transactional :: Spring Framework

The @Transactional annotation is metadata that specifies that an interface, class, or method must have transactional semantics (for example, "start a brand new read-only transaction when this method is invoked, suspending any existing transaction"). The de

docs.spring.io

 

 

트랜잭션

트랜잭션 transaction 이란 모든 데이터베이스 시스템에서 기본적인 한 개념입니다. 트랜잭션의 핵심은 여러개의 작업이 최종적으로는 하나로 취급된다는 것입니다. 이것을 전부 적용 아니면 전부

postgresql.kr