ClOr

ClOr

백엔드 실무 트러블슈팅과 AI 에이전트 구조 분석을 기록합니다.

Claude Code 해부학 (완결)

51만 줄 소스코드를 19편에 걸쳐 분석한 완결 시리즈

전체 시리즈 보기 →

백엔드 트러블슈팅

실무에서 겪은 장애와 해결 과정 기록

전체 시리즈 보기 →

최신 글

article thumbnail

이전 편 요약

1편에서 Shared/Tenant 트랜잭션 중첩으로 인한 유령 롤백을 해결했다. 트랜잭션 경계를 분리하고 self-injection으로 독립 트랜잭션을 보장하는 것이 핵심이었다.

하지만 동기화 안정화는 끝이 아니었다.

증상

전사 조직 데이터(수천 명의 멤버십)를 동기화할 때, 간헐적으로 이 에러가 터졌다:

Lock wait timeout exceeded; try restarting transaction

특히 OrganizationMembership(조직-사용자 매핑) 테이블의 batchInsert에서 집중적으로 발생했다.

원인: InnoDB Gap Lock

InnoDB는 INSERT 시 인덱스 범위의 "갭"을 잠근다(gap lock). 다른 트랜잭션이 같은 범위에 INSERT하지 못하게 하려는 것이다.

문제가 된 건 두 가지:

1) 하나의 트랜잭션에서 수천 건을 INSERT

// Before: 전부 한 방에
OrganizationMembershipTable.batchInsert(memberships) { membership ->
    this[targetGroupId] = membership.targetGroupId
    this[memberUserId] = membership.memberUserId
    // ...
}
// → 수천 건의 gap lock이 하나의 트랜잭션에서 유지
// → 다른 동기화 배치가 같은 테이블에 접근하면 대기 → 타임아웃

2) 인덱스가 단일 컬럼

// Before: 각각 단일 인덱스
init {
    index(false, targetGroupId)
    index(false, memberUserId)
}
// → gap lock 범위가 불필요하게 넓음

해결 1: Unique 복합 인덱스

// After: Unique 복합 인덱스
init {
    uniqueIndex("uk_org_membership_group_user", targetGroupId, memberUserId)
    index("idx_org_membership_member_user_id", false, memberUserId)
}

복합 인덱스의 두 가지 효과:

  • Gap lock 범위 축소: 두 컬럼 조합 인덱스를 쓰므로 잠금 범위가 좁아짐
  • 중복 방지: 같은 그룹-사용자 조합의 중복 INSERT를 DB 레벨에서 차단

Flyway 마이그레이션으로 적용:

-- V090__Add_Unique_Index_Organization_Membership.sql
ALTER TABLE tbl_organization_membership
ADD UNIQUE INDEX uk_org_membership_group_user (target_group_id, member_user_id);

해결 2: 청크 분할 (BATCH_CHUNK_SIZE = 200)

수천 건을 한 번에 넣는 대신, 200건씩 나눈다.

companion object {
    private const val BATCH_CHUNK_SIZE = 200
}

override fun batchInsert(memberships: List<OrganizationMembership>): Int {
    if (memberships.isEmpty()) return 0

    // 200건씩 나눠서 INSERT — 각 청크의 gap lock이 짧게 유지됨
    return memberships.chunked(BATCH_CHUNK_SIZE).sumOf { chunk ->
        OrganizationMembershipTable.batchInsert(chunk, ignore = true) { membership ->
            this[OrganizationMembershipTable.targetGroupId] = membership.targetGroupId
            this[OrganizationMembershipTable.memberUserId] = membership.memberUserId
            this[OrganizationMembershipTable.membershipTypeId] = membership.membershipTypeId
            this[OrganizationMembershipTable.syncBatchName] = membership.syncBatchName
            // ...
        }.count()
    }
}

ignore = true가 중요하다. Unique 인덱스를 추가했으므로 중복 데이터가 들어오면 에러가 나는데, INSERT IGNORE로 해당 row만 무시하고 나머지를 계속 진행한다. 동기화는 재실행 가능해야 하므로 멱등성이 중요하다.

SELECT, UPDATE, DELETE도 동일 적용

청크 분할은 INSERT만의 문제가 아니다. OR 조건이 수천 개가 되면 인덱스를 못 타고 테이블 스캔을 하기 때문이다.

// 조회: OR 조건도 청크 분할
override fun findAllByGroupIdAndMemberUserIdPairs(
    pairs: Collection<Pair<Long, Long>>
): List<OrganizationMembership> {
    return pairs.chunked(BATCH_CHUNK_SIZE).flatMap { chunk ->
        OrganizationMembershipTable.selectAll()
            .where {
                chunk.map { (groupId, userId) ->
                    (targetGroupId eq groupId) and (memberUserId eq userId)
                }.reduce { acc, op -> acc or op }
            }
            .map { it.toOrganizationMembership() }
    }
}

// DELETE도 청크 분할
override fun batchDeleteByGroupIdAndMemberUserIdPairs(
    pairs: Collection<Pair<Long, Long>>
): Int {
    return pairs.chunked(BATCH_CHUNK_SIZE).sumOf { chunk ->
        OrganizationMembershipTable.deleteWhere {
            chunk.map { (groupId, userId) ->
                (targetGroupId eq groupId) and (memberUserId eq userId)
            }.reduce { acc, op -> acc or op }
        }
    }
}

해결 3: Repository에서 @Transactional 제거

1편에서 배운 교훈을 바로 적용했다.

// Before: Repository에서 자체 트랜잭션
@Repository
@Transactional(readOnly = true)
class OrganizationMembershipDslRepository {
    @Transactional
    override fun batchInsert(...) { ... }  // 트랜잭션 중첩!
}

// After: Service 레이어의 트랜잭션을 그대로 사용
@Repository
class OrganizationMembershipDslRepository {
    // @Transactional 없음 → lock 보유 시간 최소화
    override fun batchInsert(...) { ... }
}

Repository가 자체 트랜잭션을 열면 Service 트랜잭션과 중첩되어 lock 보유 시간이 늘어난다.

결과

  • Lock Wait Timeout: 간헐 발생 → 0건
  • 대량 동기화(수천 건) 안정적 처리
  • Unique 인덱스로 데이터 무결성까지 확보

배운 점

  1. 대량 INSERT는 반드시 청크 단위로 — gap lock의 존재를 항상 염두에 두자
  2. Unique 인덱스는 lock 범위도 줄여준다 — 데이터 무결성뿐 아니라 동시성 성능에도 영향
  3. INSERT IGNORE는 멱등성의 친구 — 동기화처럼 재실행 가능한 작업에서 중복을 우아하게 처리
  4. 트랜잭션은 가능한 한 곳에서만 관리 — Repository와 Service가 각각 열면 lock이 중첩된다

다음 편: upsert만 하면 외부에서 삭제된 레코드는 어떻게 되나? Reconciliation — "없는 것"을 처리하는 기술.

profile

ClOr

@ClOr

포스팅이 좋았다면 "좋아요❤️" 또는 "구독👍🏻" 해주세요!

ClOr · 백엔드 트러블슈팅과 AI 에이전트 구조 분석을 기록합니다.