도서 등록 동시성 문제 해결

텐저린티·2024년 7월 30일
0

🎯 사건의 발단

도서 테이블에 ISBN 이라는 컬럼이 있다.
국제적으로 정해주던가 아무튼 도서를 출판할 때 일련번호를 부여받고 세상에 나온다.
시중에 나와있는 거의 대부분의 도서는 ISBN이 존재한다.
내 독립출판 서평서재 프로젝트에서 가장 근간이 되는 데이터라고 할 수 있다.

문제 상황을 한 번 고려해보았다.

같은 ISBN을 공유하는 도서가 여러개라면, 서평을 작성하려고 도서를 검색했을때 여러개의 결과가 나올거다.

그러면 유저들은 이 중에서 어떤 것을 골라야할지 모르겠거나, 아니면 아무거나 마음에 드는 도서를 선택해서 서평을 작성할거다.

그런식으로 꽤 오랜시간이 지나다보면, 전반적으로 난잡한 서비스가 될거다.

나는 이 문제를 해결하고 싶었다.

🔍 톺아보기

동시성 문제를 해결하고자 하는 대상은 두 가지다.

  1. 중복된 ISBN으로 도서 등록을 못하도록 방지
  2. 도서 등록에 실패한 경우 포인트 지급을 받지 못하도록 방지

이 두가지 문제를 해결하기 위해서 떠올린 생각은 다음과 같다.

  • ISBN 컬럼에 대해서 유니크 제약조건 설정하는 방법
  • 트랜잭션 락을 걸어서 도서 등록하지 못하도록 설정

그럼 레츠고

🏗️ 구조

먼저 기존 로직을 먼저 보고 오자.

코드 어떻게 생겼나

// Controller
@PostMapping(  
    consumes = MediaType.MULTIPART_FORM_DATA_VALUE,  
    produces = MediaType.APPLICATION_JSON_VALUE)  
public ResponseEntity<BookResponse> postNewBook(  
    @LoginId Long loginId,  
    @RequestPart(name = "request") @Valid BookPostRequest request,  
    @RequestPart(name = "cover-image", required = false) MultipartFile coverImage) {  
  Book result = bookService.register(loginId, request.to(), coverImage);  
  BookResponse response = BookResponse.from(result);  
  
  return ResponseEntity.status(HttpStatus.CREATED).body(response);  
}
// BookService
@Transactional  
public Book register(long loginId, BookPostParam param, MultipartFile coverImage) {  
  Book book =  
      Book.of(  
          param.title(),  
          param.authorName(),  
          param.isbn(),  
          param.publisher(),  
          param.categories(),  
          param.isIndie(),  
          coverImage);  
  
  pointService.creditPointForBookRegistration(loginId);  
  fileRepository.putFile(book.coverImageFile());  
  
  return bookRepository.create(book);  
}
// PointService
@Override  
@Transactional  
public void creditPointForBookRegistration(long memberId) {  
  pointRepository.creditPoints(memberId, BOOK_REGISTRATION_POINT);  
}
// PointRepository
@Override  
@Transactional  
public void creditPoints(long memberId, long creditPoint) {  
  validatePointValue(creditPoint);  
  
  getMember(memberId).addPoints(creditPoint);  
}

private MemberEntity getMember(long memberId) {  
  return repository  
      .findById(memberId)  
      .orElseThrow(() -> new EntityNotFoundException("존재하지 않는 멤버입니다."));  
}

코드 요약

코드 읽기가 싫은 사람을 위해 요약을 하자면,

  1. 도서 등록이 들어옴. (JWT 토큰으로 도서 등록을 희망하는 멤버 특정)
  2. 해당 멤버에게 도서 등록 감사 포인트 지급
  3. 도서 커버 이미지 저장
  4. 도서 등록

이런 식으로 진행된다.

내가 걱정했던 것을 구체화하면 이렇다.

  1. 도서 등록이 안 되었는데 포인트 지급하고, 파일 저장하면 어쩌지?
  2. 중복 ISBN 저장하면 어쩌지?

🧳 준비물

뭘로 해결할 생각?

위에서 언급한 것처럼 맨 처음에 필요하다고 생각했던 것이 두가지다.

  1. ISBN 컬럼 유니크 제약조건 설정
  2. 도서 등록 / 포인트 지급 로직에 트랜잭션 락 설정

결론부터 말하면, 1번만 필요하다.
2번은 필요없다.
애초에 불가능하다.

📺 진행과정

어떻게 테스트할 생각?

동시성 테스트를 위해서 curl 명령어로 직접 요청을 쏴보냈다.

원래는 nGrinder 로 테스트해보고자 했는데,
죽었다 깨나도 nGrinder 로 multipart/form-data Content-Type 요청 보내는 방법을 못 찾았다.
하루 반나절을 찾아해매면서 nGrinder 코드까지 뜯어봤는데 못 찾음. 마음이 아팠다.

어쨌든 nGrinder 는 포기하고 어떻게 해볼까 고민하다가 Postman 을 떠올렸다.
분명히 여러 요청 묶어서 보내는 기능이 있었던 걸로 기억해서 찾아봤다.

근데 불가능하다고 한다.
Postman 에서 요청 묶음 보내기는 동기 처리라고 한다.
다시 말해서 여러 요청이 순차적으로 보내진다는 것.
그렇다면 동시성 문제를 해결하기에는 부적합하다.

curl 로 테스트?

그래서 좀 더 찾아보니까 curl 명령어로 직접 요청을 보낼 수 있다고 했다.

이유를 찾아본즉,

curl 명령어를 & 키워드로 연결하여 여러 요청을 보낼 경우, 각 요청은 비동기적으로 실행됩니다. 즉, 각 요청이 백그라운드에서 동시에 실행되며, 실행 순서에 상관없이 독립적으로 처리됩니다.

바로 스크립트 만들어서 실행해봤다.

#!/bin/zsh  
  
curl --location POST 'http://localhost:8080/api/books' \  
--header 'Authorization: Bearer' \  
--form 'request="{  
    \"title\": \"깃허브\",  
    \"authorName\": \"빌게이츠\",  
    \"isbn\": \"7676767676769\",    \"publisher\": \"마이크로소프트\",  
    \"categories\": [\"PHOTO\", \"ESSAY\"],    \"isIndie\": true}";type=application/json'  
&  
...
&  
curl --location POST 'http://localhost:8080/api/books' \  
--header 'Authorization: Bearer' \  
--form 'request="{  
    \"title\": \"깃허브\",  
    \"authorName\": \"빌게이츠\",  
    \"isbn\": \"7676767676769\",    \"publisher\": \"마이크로소프트\",  
    \"categories\": [\"PHOTO\", \"ESSAY\"],    \"isIndie\": true}";type=application/json'

너무 길어서 요약했다.

isbn 번호가 같으니까 저 많은 요청 중에 하나만 정상적으로 수행되고, 나머지는 리젝되어야 한다.

결과는 어케됨?

  • Before

  • After

원하는 결과는 모두 얻었다.

락 안 걸었는데 어케했는고

레코드 락

내가 멍청했던거다.

애초에 레코드를 저장할때는 굳이 락이 필요하지 않다.

엥? MySQL InnoDB 는 테이블 락이 아니고 레코드 락이니까 등록 되어야 하는거 아닌고?

아니다.

레코드락은 삽입할 때 잡히는게 아니라, 갱신하거나 제거할 때 잡히는거다.
그래서 괜찮은것.

insert 시에 충돌하면?

나도 이게 궁금했는데, 결론은 이렇다.

중복 컬럼을 체크하는것은 Insert 문이 실행될때다.
엄밀히 말하면 insert 문 실행 직전 확인하고 작업을 수행한다.

따라서 여러 개의 요청이 정~말 우연히 DB까지 동시에 왔다면 문제가 발생할 수 있다.
하지만, 그 여러개의 요청 중에서 가장 빨랐던 요청이 DB에 성공적으로 저장을 하게 되면 다른 요청은 제약조건 때문에 실패하게 된다.
그래서 동시성 문제가 유니크 컬럼 설정만으로 해결이 되는거다.

그리고 애초에 레코드를 삽입할때 사용하는 락은 없는 것 같다.
일단 JPA에서는 제공하지 않는 것 같다.
LockModeType 객체를 확인해봤는데 없었음.

🔑 결론

동시성 처음 잡아보는데 재밌다.

멘토나 동료들이 입버릇처럼 하던 말이있다.

"락은 만능이 아니에용"

이제 나도 그 말 할 수 있다.

profile
개발하고 말테야

0개의 댓글