해당 게시글은 내용 정리 목적으로 작성되었습니다. 틀린 내용이 있다면 언제든지 말씀해 주세요

동시성 이슈 관련해서 혼자 이것 저것 테스트해보다가 데드락 에러를 발견했다.

Caused by: java.sql.SQLTransactionRollbackException: (conn=644) Deadlock found when trying to get lock; try restarting transaction
	at org.mariadb.jdbc.export.ExceptionFactory.createException(ExceptionFactory.java:303)
	at org.mariadb.jdbc.export.ExceptionFactory.create(ExceptionFactory.java:378)
	at org.mariadb.jdbc.message.ClientMessage.readPacket(ClientMessage.java:172)
	at org.mariadb.jdbc.client.impl.StandardClient.readPacket(StandardClient.java:915)
	at org.mariadb.jdbc.client.impl.StandardClient.readResults(StandardClient.java:854)
	at org.mariadb.jdbc.client.impl.StandardClient.readResponse(StandardClient.java:773)
	at org.mariadb.jdbc.client.impl.StandardClient.execute(StandardClient.java:697)
	at org.mariadb.jdbc.ClientPreparedStatement.executeInternal(ClientPreparedStatement.java:93)
	at org.mariadb.jdbc.ClientPreparedStatement.executeLargeUpdate(ClientPreparedStatement.java:342)
	at org.mariadb.jdbc.ClientPreparedStatement.executeUpdate(ClientPreparedStatement.java:319)
	at com.zaxxer.hikari.pool.ProxyPreparedStatement.executeUpdate(ProxyPreparedStatement.java:61)
	at com.zaxxer.hikari.pool.HikariProxyPreparedStatement.executeUpdate(HikariProxyPreparedStatement.java)
	at org.hibernate.engine.jdbc.internal.ResultSetReturnImpl.executeUpdate(ResultSetReturnImpl.java:194)
  • "SQLTransactionRollbackException 예외가 발생했고 Lock을 얻는 과정에서 Deadlock이 발생했다" 라는 메시지였다.
  • 테스트는 다음과 같다. 
  • 상품 응모 이벤트가 있고 정해진 수만큼만 응모가 가능하다.
  • 예를 들어, 선착순으로 100명까지 상품 응모가 가능한 이벤트가 있다고 가정하고 해당 이벤트에서 동시에 100번 응모했을 때 원하는 결과가 나오는지 테스트했다.
  • 엔티티는 EventProduct, ProductDrawEvent, ProductDrawEventHistory가 있고, 각각 이벤트 상품, 상품 응모 이벤트, 상품 응모 이벤트 이력 엔티티이다.
  •  특정 유저가 이벤트 응모에 참가하면 ProductDrawEvent의 drawQuantity가 한개 차감된다. drawQuantity가 0이 된 경우 상품 응모가 모두 소진되었다는 예외 메시지가 반환된다.

EventProductProductDrawEventProductDrawEventHistory 엔티티

@NoArgsConstructor(access = AccessLevel.PROTECTED)
@AllArgsConstructor(access = AccessLevel.PRIVATE)
@Getter
@Table(name = "event_product")
@Entity
public class EventProduct {

    @GeneratedValue(strategy = GenerationType.IDENTITY)
    @Id
    private Long id;

    @Column(name = "name", nullable = false)
    private String name;

    public static EventProduct of(String name) {
        return new EventProduct(null, name);
    }
}

@NoArgsConstructor(access = AccessLevel.PROTECTED)
@AllArgsConstructor(access = AccessLevel.PRIVATE)
@Getter
@Table(name = "product_draw_event")
@Entity
public class ProductDrawEvent {

    @GeneratedValue(strategy = GenerationType.IDENTITY)
    @Id
    private Long id;

    @ManyToOne
    @JoinColumn(name = "event_product_id", nullable = false)
    private EventProduct eventProduct;

    @Column(name = "product_quantity", nullable = false)
    private Long productQuantity;

    @Column(name = "draw_quantity", nullable = false)
    private Long drawQuantity;

    public boolean hasEventProductDrawQuantities() {
        return this.drawQuantity > 0;
    }

    public ProductDrawEventHistory draw(Long userId) {
        if (hasEventProductDrawQuantities()) {
            drawQuantity--;
            return ProductDrawEventHistory.of(userId, this);
        }

        throw new IllegalStateException("상품 응모 개수가 모두 소진되었습니다.");
    }

    public static ProductDrawEvent of(EventProduct eventProduct, Long productQuantity, Long drawQuantity) {
        return new ProductDrawEvent(null, eventProduct, productQuantity, drawQuantity);
    }
}

@NoArgsConstructor(access = AccessLevel.PROTECTED)
@AllArgsConstructor(access = AccessLevel.PRIVATE)
@Getter
@Table(name = "product_draw_event_history",
    uniqueConstraints = {
        @UniqueConstraint(
            name = "uk_product_draw_event_history_product_draw_event_id_user_id",
            columnNames = {"product_draw_event_id", "user_id"}
        )
    }, indexes = @Index(name = "idx_product_draw_event_history_user_id", columnList = "user_id"))
@Entity
public class ProductDrawEventHistory {

    @GeneratedValue(strategy = GenerationType.IDENTITY)
    @Id
    private Long id;

    @ManyToOne
    @JoinColumn(name = "product_draw_event_id", nullable = false)
    private ProductDrawEvent productDrawEvent;

    @Column(name = "user_id", nullable = false)
    private Long userId;

    @Column(name = "is_winner")
    private boolean isWinner;

    @Column(name = "create_at")
    private LocalDateTime createAt;

    public static ProductDrawEventHistory of(Long userId, ProductDrawEvent productDrawEvent) {
        return new ProductDrawEventHistory(null, productDrawEvent, userId, false, LocalDateTime.now());
    }
}

ProductDrawEventEnterUseCase

  • 상품 응모 이벤트 참가 유스케이스
@RequiredArgsConstructor
@Service
public class ProductDrawEventEnterUseCase {
    private final ProductDrawEventRepository productDrawEventRepository;
    private final ProductDrawEventHistoryRepository productDrawEventHistoryRepository;

    @Transactional
    public Long draw(Long userId, Long productDrawEventId) {
        ProductDrawEvent productDrawEvent = productDrawEventRepository.findById(productDrawEventId)
            .orElseThrow(() -> new IllegalStateException("존재하지 않는 이벤트 입니다."));

        return productDrawEventHistoryRepository.save(productDrawEvent.draw(userId))
            .getId();
    }
}
  • draw 메소드 실행 시 상품 응모 이벤트를 조회하고 남은 응모 회수가 있으면 상품 응모 이벤트 내역을 생성한다.
  • 동시성 이슈가 발생하는지 확인하기 위해서 비관적 락을 걸지 않고 productDrawEvent를 조회하게 했다.
  •  그리고 아래와 같은 테스트 코드를 작성했다.
@SpringBootTest
class ProductDrawEventEnterUseCaseTest {

    @Autowired
    public EventProductRepository eventProductRepository;

    @Autowired
    public ProductDrawEventRepository productDrawEventRepository;

    @Autowired
    public ProductDrawEventHistoryRepository productDrawEventHistoryRepository;

    @Autowired
    public ProductDrawEventEnterUseCase productDrawEventEnterUseCase;

    @BeforeEach
    public void setup() {
        productDrawEventHistoryRepository.deleteAll();
        productDrawEventRepository.deleteAll();
        eventProductRepository.deleteAll();
    }

    @Test
    public void 동시에_1000번_이벤트상품_응모_요청_성공_테스트() throws InterruptedException {
        final String eventProductName = "Apple 맥북 프로 14 M2";
        final long productQuantity = 10L;
        final long drawQuantity = 1000L;

        final int nThreads = 20;

        final EventProduct eventProduct = EventProduct.of(eventProductName);

        // 미리 이벤트 상품과 상품 응모 이벤트를 생성한다.
        eventProductRepository.save(eventProduct);
        ProductDrawEvent productDrawEvent = productDrawEventRepository.save(ProductDrawEvent.of(eventProduct, productQuantity, drawQuantity));

        List<Callable<Long>> tasks = new ArrayList<>();

        // task 목록을 생성한다
        for (long index = 0; index < drawQuantity; index++) {
            final long userId = index;
            tasks.add(() -> productDrawEventEnterUseCase.draw(userId, productDrawEvent.getId()));
        }

        // 스레드 개수롤 고정적으로 nThreads 만큼 생성
        ExecutorService executorService = Executors.newFixedThreadPool(nThreads);

        // 멀티 스레드로 동시에 상품 응모를 진행한다.
        List<Future<Long>> futures = executorService.invokeAll(tasks);

        // 상품 응모 결과 확인
        for (Future<Long> future : futures) {
            // 예외가 발생하지 않았는지 확인
            assertThatCode(future::get)
                .doesNotThrowAnyException();
        }

        // 작업 종료
        executorService.shutdown();

        ProductDrawEvent resultProductDrawEvent = productDrawEventRepository.findById(eventProduct.getId()).get();
        Assertions.assertThat(resultProductDrawEvent.getDrawQuantity()).isEqualTo(0L);
    }
}
  • 위 테스트를 실행했을 때 레이스 컨디션 증상이 발생해서 동시성 이슈가 발생할 것으로 예상했는데 데드락 예외가 발생하는 것이다.
  • ProductDrawEventEnterUseCase를 살펴보았을 때 별도로 Lock을 거는 부분이 없었는데 데드락 예외가 발생한 것이다..... (이게 무슨일이지)
  • 그래서 찾아보았는데 충격적인 사실을 알았다.
테이블에 데이터를 insert 할 때 외래키 제약조건이 걸린 외부 테이블의 id를 저장하는 경우 해당 외부 테이블 행에 공유락을 건다. 

즉 자식 테이블에서 데이터 insert 시 실행 시 부모 테이블 행에 공유락이 걸리는 것이다. 
  • ProductDrawEventEnterUseCase 코드를 보면 productDrawEventHistoryRepository.save(productDrawEvent.draw(userId)) 부분이 있는데 productDrawEventHistory를 insert하고 productDrawEvent의 drawQuantity를 한개 차감한다. 
  • 단일 스레드로 한개씩 수행된다면 문제 없지만 동시에 여러 스레드가 해당 메소드를 실행하면 deadlock 문제가 발생하는 것이다.
  • productDrawEventHistory insert 시 productDrawEvent의 id (외래키)를 저장하고 이때 해당 행에 공유락이 걸린다.
순서 트랜잭션 1 트랜잭션 2
1
insert into product_draw_event_history (user_id, product_draw_event_id) values (1, 1);

id 1번 product_draw_event에 공유락 선점
 
2   insert into product_draw_event_history (user_id, product_draw_event_id) values (2, 1);

id 1번 product_draw_event에 공유락 선점
3 update product_draw_event 
set draw_quantity = draw_quantity - 1 
where id = 1;
id 1번 product_draw_event에 쓰기락 대기
 
4   update product_draw_event 
set draw_quantity = draw_quantity - 1 
where id = 1;
id 1번 product_draw_event에 쓰기락 대기
  • id 1번  product_draw_event 행에 공유 락이 걸린 상태에서 트랜잭션 1, 2 모두 id 1번  product_draw_event 에 쓰기 락을 걸려고 하기 때문에 데드락이 발생한다.
  • 더 정확하게 확인해보기 위해 db client에서 테스트해보았다.
# tx1
start transaction;

insert into product_draw_event_history (create_at, is_winner, user_id, product_draw_event_id)
values (now(), false, 1, 1);
  • 트랜잭션 생성 후 1번 상품 응모 이벤트에 대해서 응모 이력을 생성한다. 
  • 커밋하지 않으면 1번 상품 응모 이벤트에 공유락이 걸린 상태로 대기한다.
# tx2
start transaction;

update product_draw_event
set draw_quantity = draw_quantity - 1
where id = 1;
  • 새로운 트랜잭션에서 1번 상품 응모 이벤트의 남은 응모 회수를 1 차감하는 update 문을 실행한다.
  • update 문 실행 시 쓰기락을 걸기 때문에 1번 상품 응모 이벤트에 공유락이 걸려 있으면 공유락이 해제 될 때까지 대기한다. 

  •  1번 트랜잭션을 커밋하기 전까지 2번 트랜잭션의 update 작업은 대기하게 된다.

 

결론

  • insert 시 foreign key가 걸린 id가 있다면 해당 부모 테이블에 공유락이 걸린다. (dbms 마다 다름)
  • 혹여나 알 수 없는 데드락 증상이 발생한다면 해당 이슈를 참고해보자.

1. 외래키 (Foreign Key)란?

외래키는 두 테이블을 서로 연결하는 데 사용되는 키이다.

외래키가 포함된 테이블을 자식 테이블이라고 하고 외래키 값을 제공하는 테이블을 부모 테이블이라한다.


2. 외래키 사용시 주의 사항

1) 외래키 값은 NULL이거나 부모 테이블의 기본키 값과 동일해야한다. (참조 무결성 제약조건)

2) 부모 테이블의 기본키, 고유키를 외래키로 지정할 수 있다.

3) 부모 테이블의 기본키, 고유키가 여러개의 컬럼으로 이루어져 있다면 부모가 가진 기본키, 고유키 컬럼을 원하는 개수만큼 묶어서 외래키로 지정할 수 있다. 

CREATE TABLE `parent` (
	`id1` INT(11) NOT NULL,
	`id2` INT(11) NOT NULL,
	`id3` INT(11) NOT NULL,
	`uk1` INT(11) NOT NULL,
	`uk2` INT(11) NOT NULL,
	`uk3` INT(11) NOT NULL,
	PRIMARY KEY (`id1`, `id2`, `id3`),
	UNIQUE KEY (`uk1`, `uk2`, `uk3`)
);

CREATE TABLE `child` (
	`id` INT(11) NOT NULL,
	`id1` INT(11) NOT NULL,
	`id2` INT(11) NOT NULL,
	`uk1` INT(11) NOT NULL,
	`uk2` INT(11) NOT NULL,
	PRIMARY KEY (`id`),
	FOREIGN KEY (`id1`, `id2`) REFERENCES `parent` (`id1`, `id2`),
	FOREIGN KEY (`uk1`, `uk2`) REFERENCES `parent` (`uk1`, `uk2`)
)

4) 외래키로 지정할 두 테이블의 필드는 같은 데이터 타입이어야 한다.


3. 외래키 예제

데이터베이스 마다 선언 하는 방식이 다를 수 있으며, Mysql 기준으로 설명하겠다.

create table department(
  	id int auto_increment primary key,
	name varchar(20) not null,
	code char(13) not null unique key
);

create table employee (
  	id int auto_increment primary key,
	name varchar(20) not null,
	code char(13) not null unique key,
	dept_id int,
	foreign key (dept_id) references department(id)
);

 

department(부서)와 employee(회사원) 테이블이 있다. department이 부모 테이블이고, employee가 자식 테이블이다.

외래키를 가진 테이블이 자식 테이블이고, 참조되는 테이블이 부모 테이블이다. 

  
  CONSTRAINT [CONSTRAINT_NAME] FOREIGN KEY (자식 테이블 컬럼 명) REFERENCES 참조테이블(부모 테이블 기본키명) 
  ON UPDATE 옵션 ON DELETE 옵션;
  
  # CONSTRAINT [CONSTRAINT_NAME]은 생략이 가능하다.
  

 

다대 일 관계


4. 외래키 옵션

1) On Delete

 Cascade : 부모 데이터 삭제 시 자식 데이터도 삭제 

 Set null : 부모 데이터 삭제 시 자식 테이블의 참조 컬럼을 Null로 업데이트

 Set default : 부모 데이터 삭제 시 자식 테이블의 참조 컬럼을 Default 값으로 업데이트

 Restrict : 자식 테이블이 참조하고 있을 경우, 데이터 삭제 불가

 No Action : Restrict와 동일, 옵션을 지정하지 않았을 경우 자동으로 선택된다.

 
2) On Update

 Cascade : 부모 데이터 업데이트 시 자식 데이터도 업데이트 

 Set null : 부모 데이터 업데이트 시 자식 테이블의 참조 컬럼을 Null로 업데이트

 Set default : 부모 데이터 업데이트 시 자식 테이블의 참조 컬럼을 Default 값으로 업데이트

 Restrict : 자식 테이블이 참조하고 있을 경우, 업데이트 불가

 No Action : Restrict와 동일, 옵션을 지정하지 않았을 경우 자동으로 선택된다.


5. 외래키 추가

ALTER TABLE employee
ADD FOREIGN KEY (dept_id) REFERENCES department(id);

6. 외래키 삭제

외래키를 삭제하려면 CONSTRAINT_NAME을 알아야한다.

select * 
from information_schema.table_constraints
where TABLE_SCHEMA = 'DB명' and TABLE_NAME = '테이블명'

삭제하고 싶은 키의 CONSTRAINT_NAME을 확인한다.

ALTER TABLE [Table_Name]
DROP CONSTRAINT [CONSTRAINT_NAME];

또는

ALTER TABLE [Table_Name]
DROP FOREIGN KEY [CONSTRAINT_NAME];

 

7. TEST


# 외래키 이름을 검색한다. ex) employee_ibfk_1
select * 
from information_schema.table_constraints
where TABLE_SCHEMA = 'myDB' and TABLE_NAME = 'employee'

# 외래키를 삭제한다.
ALTER TABLE employee
DROP FOREIGN KEY employee_ibfk_1;

#새로운 조건의 외래키를 추가한다.
#부모 행이 삭제되었을 경우 외래키 ID를 NULL로 업데이트한다.
ALTER TABLE employee
ADD CONSTRAINT employee_ibfk_1 FOREIGN KEY (dept_id) REFERENCES department(id) ON DELETE SET NULL;

도움이 되셨다면 공감버튼을 눌러주세요!

+ Recent posts