[Project]/[Momo]

[이슈 #4] 분산락을 활용한 중복 데이터 삽입 이슈 해결3

DevLoki 2022. 8. 2. 22:37

분산락 중첩 적용

특정 사용자 요청을 처리하는 경우 분산락에 대한 중첩 적용이 필요했습니다.

모임을 생성하는 경우로 예시를 들어보겠습니다.

모임 생성 로직

모임을 생성하는 요청은 우선, 회원에 대한 분산락을 획득하여 한 회원이 모임 생성 제한 갯수를 초과하여 생성하는 것을 검사합니다.

그 후, 모임이름에 대한 분산락을 획득하여 동일한 이름의 모임이 생성되는 것을 검사한 후 모임을 생성합니다.

중첩 분산락 적용 프로세스

따라서 다음과 같이 메서드를 분리하여 각 메서드에 별도의 분산락 적용을 위한 AOP를 적용했습니다.

분산락을 사용하는 메서드를 분리

컨트롤러

@Service
@RequiredArgsConstructor
public class ClubService {

    @DistributedLock(prefix = DistributedLockPrefix.MEMBER_ID)
    public void registerNewClubWithMemberIdDistributedLock(@LockName final long memberId,
                                                           ClubRegisterRequest clubRegisterRequest) {
        clubService.checkMaxClubCreationPerMember(memberId);
        clubService.registerNewClubWithClubNameDistributedLock(clubRegisterRequest.getName(), memberId, clubRegisterRequest);
    }

    @DistributedLock(prefix = DistributedLockPrefix.CLUB_NAME)
    public void registerNewClubWithClubNameDistributedLock(@LockName final String clubName, final long memberId,
                                                           ClubRegisterRequest clubRegisterRequest) {
        clubService.checkDuplicateClubName(clubName);
        clubService.registerNewClub(memberId, clubRegisterRequest);
    }

    //생략
}

서비스

@Service
@RequiredArgsConstructor
public class ClubService {

    @DistributedLock(prefix = DistributedLockPrefix.MEMBER_ID)
    public void registerNewClubWithMemberIdDistributedLock(@LockName final long memberId,
                                                           ClubRegisterRequest clubRegisterRequest) {
        clubService.checkMaxClubCreationPerMember(memberId);
        clubService.registerNewClubWithClubNameDistributedLock(clubRegisterRequest.getName(), memberId, clubRegisterRequest);
    }

    @DistributedLock(prefix = DistributedLockPrefix.CLUB_NAME)
    public void registerNewClubWithClubNameDistributedLock(@LockName final String clubName, final long memberId,
                                                           ClubRegisterRequest clubRegisterRequest) {
        clubService.checkDuplicateClubName(clubName);
        clubService.registerNewClub(memberId, clubRegisterRequest);
    }

    //생략
}

하지만 예상과 달리 분산락이 중첩 적용되지 않고 우선 호출되는 메서드의 분산락만이 적용되었습니다.

 

해당 문제의 원인은 프록시의 Self Invocation 이슈였습니다.

Self Invocation 이슈

먼저 분산락을 사용하는 메서드가 다른 분산락이 적용된 메서드를 호출하는 경우, AOP가 적용된 프록시의 메서드가 아닌 실제 타깃인 ClubService의 메서드를 호출했습니다.

 

이를 해결하기 위해 분산락을 사용하는 각 메서드를 외부에서 호출하도록 분리하는 방법을 고려했습니다. 별도의 코드 작성 없이 해당 문제를 해결할 수 있지만, 중첩되는 분산락의 갯수만큼 서비스 클래스가 늘어나는 단점이 있었습니다. 지금은 최대 2개의 중첩된 분산락을 사용하지만 추후 요구사항이 추가되는 것을 고려하면 좋은 방법이 아니라고 판단했습니다.

 

따라서 ObjectProvider로 자기 자신(프록시)을 주입받아 직접 프록시의 메서드를 호출하는 방법으로 개선하였습니다.

ObjectProvider를 사용해 Self Invocation 이슈 해결

@Service
@RequiredArgsConstructor
public class FClubService {
    private final ClubService clubService;
    private final ObjectProvider<FClubService> proxy;

    @DistributedLock(prefix = DistributedLockPrefix.MEMBER_ID)
    public void registerNewClubWithMemberIdDistributedLock(@LockName final long memberId,
                                                           ClubRegisterRequest clubRegisterRequest) {
        clubService.checkMaxClubCreationPerMember(memberId);
        proxy.getObject().registerNewClubWithClubNameDistributedLock(clubRegisterRequest.getName(), memberId, clubRegisterRequest);
    }

    @DistributedLock(prefix = DistributedLockPrefix.CLUB_NAME)
    public void registerNewClubWithClubNameDistributedLock(@LockName final String clubName, final long memberId,
                                                           ClubRegisterRequest clubRegisterRequest) {
        clubService.checkDuplicateClubName(clubName);
        clubService.registerNewClub(memberId, clubRegisterRequest);
    }

    //생략
}

ThreadLocalConnectionHolder의 개선

위 Self Invocation 이슈 해결하고 애플리케이션을 실행하면 NullPointerException이 발생합니다.

그 이유는 ThreadLocal<Connection>을 사용하여 사용중인 커넥션을 저장하는 구조에 있습니다.

 

중첩된 분산락이 사용되면 그만큼 사용되는 커넥션도 늘어납니다. 따라서 분산락이 사용되는 횟수만큼 커넥션을 저장해야하지만 현재 ThreadLocalConnectionHolder는 단일 커넥션만을 저장하기 때문에 예외가 발생합니다.

중첩 분산락 적용 프로세스

현재 사용되는 중첩 분산락이 적용되는 프로세스를 다시 살펴보면 먼저 획득한 커넥션을 마지막으로 반납하고,

가장 최근에 획득한 커넥션을 가장 먼저 반납합니다. 즉, LIFO의 방식으로 동작합니다.

Stack을 사용한 ThreadLocalConnectionHolder의 동작

따라서 ThreadLocalConnectionHolder가 단일 커넥션(<Connection>)을 저장하는 구조에서 커넥션 스택(<Stack<Connection>>)을 저장하도록 변경하였습니다.

import java.sql.Connection;
import java.util.Stack;

public class ThreadLocalConnectionHolder {
    private static final ThreadLocal<Stack<Connection>> connectionHolder = new ThreadLocal<>();

    public static void save(Connection connection) {
        Stack<Connection> stack = connectionHolder.get();
        if (stack == null) {
            stack = createEmptyStack();
        }
        stack.push(connection);
        connectionHolder.set(stack);
    }

    public static Connection get() {
        Stack<Connection> stack = connectionHolder.get();
        Connection con = stack.pop();
        connectionHolder.set(stack);

        return con;
    }

    public static void clear() {
        Stack<Connection> stack = connectionHolder.get();
        if (stack.isEmpty())
            connectionHolder.remove();
    }

    private static Stack<Connection> createEmptyStack() {
        return new Stack<>();
    }
}