단 1%의 충돌도 허용할 수 없을 때

김형준·2025년 3월 11일
0

문제 설명

대기열 상에 성능 테스트 시 특정 구간에서 처리가 지연되는 것을 확인했다.

혹시나 싶어 Redis 모니터링을 해 보니 엄청난 속도로 XREADGROUP들이 실행되고 있었다.

이는 Redis Streams를 아래와 같은 방식으로 구독할 때 생기는 현상일 뿐이었다.

StreamMessageListenerContainerOptions<String, MapRecord<String, String, String>> options =
				StreamMessageListenerContainer.StreamMessageListenerContainerOptions.builder()
                .pollTimeout(Duration.ZERO)
                .build();

따라서 해당 로그가 응답이 돌아오지 않는 원인은 아닌 것 같았다.

JMeter로 요청에 대한 응답을 기다릴 때 97 ~ 98%의 요청들은 제대로 응답을 받았지만, 일부 요청들에 대해서는 Timeout이 발생하고 있었다.

하지만 DB를 확인해보니 요청 수만큼의 예약이 제대로 생성되고 있었다.

따라서 서비스 로직이 아닌 Consumer → Producer 방향으로 Sinks를 통해 전달되는 처리 결과에 문제가 생긴 것이라고 생각하고 아래와 같이 접근해 해결해보았다.

해결 과정

현재 Consumer가 적절한 Producer에게 Sinks로 데이터를 전달하도록 ConcurrentHashMap에서 currentTimeMillis로 Sinks를 구별하고 있다.

하지만 우연의 일치로 인해 밀리초가 겹치는 문제가 발생한 것일 수도 있으니, 이를 확인해보았다.

public void completeSink(long requestId, ReservationCreateResDto response) {
        One<ReservationCreateResDto> sink = sinkMap.get(requestId);
        if (sink == null) { 
            log.error("Request Id {}에 해당하는 Sinks가 존재하지 않습니다.", requestId);
            throw new IllegalArgumentException(requestId + "에 해당하는 Sinks가 존재하지 않습니다."); 
        }

        sink.tryEmitValue(response);
        sinkMap.remove(requestId);
    }

만약 밀리초 충돌이 나서 찾으려는 Sinks를 누가 이미 빼 갔다면, completeSink() 부분에서 문제가 생길 것이라고 판단하였다.

테스트 결과 예상대로 밀리초 중복으로 인해 같은 Sinks에 중복으로 접근하려는 시도가 로깅되었다.

이후 문제가 되는 밀리초 ID를 UUID로 변경하였다.

@AllArgsConstructor
@Builder
@NoArgsConstructor
@Getter
@EqualsAndHashCode
@ToString
public class ReservationCreateReqDto {
    private final String requestId = UUID.randomUUID().toString();
    private Long restaurantId;
    private Long memberId;
    private ReservationPostReqDto reservationPostReqDto;
}

@Component
@Slf4j
public class SinksRegistry {
    private final ConcurrentHashMap<String, One<ReservationCreateResDto>> sinkMap = new ConcurrentHashMap<>();

    public void registerSink(String requestId, One<ReservationCreateResDto> sink) {
        sinkMap.put(requestId, sink);
    }

    public One<ReservationCreateResDto> getSink(String requestId) { return sinkMap.get(requestId); }

    public void completeSink(String requestId, ReservationCreateResDto response) {
        One<ReservationCreateResDto> sink = sinkMap.get(requestId);
        if (sink == null) {
            log.error("Request Id {}에 해당하는 Sinks가 존재하지 않습니다.", requestId);
            throw new IllegalArgumentException(requestId + "에 해당하는 Sinks가 존재하지 않습니다.");
        }

        sink.tryEmitValue(response);
        sinkMap.remove(requestId);
    }

    public void completeSinkExceptionally(String requestId, Throwable e) {
        Sinks.One<ReservationCreateResDto> sink = sinkMap.get(requestId);
        if (sink == null) { return; }

        sink.tryEmitError(e);
        sinkMap.remove(requestId);
    }
}

다시 테스트해보니 에러 없이 정상적으로 수행된 것을 확인할 수 있었다.

결론적으로 요청 객체가 동시에 몰릴 일이 적다면 System.currentTimeMillis만으로도 충분하겠지만, 그렇지 못한 경우 ID 충돌을 방지해야 한다면 UUID를 사용하는 게 좋아보인다.

0개의 댓글

관련 채용 정보