🍎 마주한 문제에서 데이터의 원자성을 어느 곳에서 관리하면 좋을지 고민하게 되어 정리합니다.
🍏 문제 상황
✓ 공공데이터 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
'Dev' 카테고리의 다른 글
모듈을 만들 때 염두에 두어야 할 점 (0) | 2025.04.07 |
---|---|
[Tip] @Scheduled 와 Cron 어떤 것을 선택해야할까? (0) | 2025.03.04 |
[Dev] 프로세스 메모리 할당과 JVM (3) | 2024.11.12 |
[Dev] 서버 클라이언트 사이 버전을 관리하는 방법 (2) | 2024.10.16 |
Enum을 사용할 때 생각해봐야할 점 (2) | 2024.09.26 |