250x250
seungh1024
학습 정리
seungh1024
전체 방문자
오늘
어제
  • 분류 전체보기 (14) N
    • 우아한 테크 캠프 (1)
    • Java (13) N
      • Java (1)
      • JPA (0)
      • Spring(Boot) (0)
      • Spring boot 프로젝트 (0)
      • Querydsl (0)
      • Mini-Pay 프로젝트 (3)
      • My-Storage 프로젝트 (9) N
    • Javascript (0)
    • Server (0)
    • Network (0)
    • 기타 (0)
    • Database (0)

블로그 메뉴

  • 홈
  • 태그
  • 방명록

공지사항

인기 글

태그

  • 분산락
  • 연결
  • 우아한테크캠프
  • 연관 관계
  • Spring Boot
  • db
  • entity
  • JWT
  • 부하테스트
  • Docker
  • spring
  • jpa
  • SpringBoot
  • java
  • Redis
  • IoC/DI
  • 비동기
  • QueryDSL
  • 데드락
  • rgw

최근 댓글

최근 글

티스토리

hELLO · Designed By 정상우.
seungh1024

학습 정리

Java/My-Storage 프로젝트

[My-Storage] 폴더 이동 로직에서 사용한 분산락 작업 개선

2025. 7. 13. 16:41
728x90

이전 로직의 문제점

기존 폴더 이동은 각각의 root 폴더의 ID 값을 기준으로 락을 획득하고 이동 작업을 진행했다. 그러다 보니 한 번에 하나의 이동 작업만 처리할 수 있었고, 동시에 여러 이동 작업을 처리할 수 없었다.

이렇게 했던 이유는 폴더의 순환 구조를 막기 위함이었다. 최근 더 나은 방법이 생각나서 변경하고 테스트를 진행해 보았다.

 

아이디어

이동 작업이 발생한 폴더의 ID를 기준으로 락을 요청한다. 락 획득이 가능하면 획득 후, 해당 폴더를 기준으로 상위로 탐색하며 탐색한 폴더의 ID를 기준으로 락이 걸려 있는지 확인한다. 만약 락이 걸려 있다면 나보다 먼저 이동 작업이 진행 중인 폴더가 존재한다는 것이다. 이 경우 획득한 락을 해제하고 이동 작업에 실패한다.

 

즉, 현재 이동 작업이 진행 중인 폴더 트리 하위에서는 다른 이동 작업 및 삭제 작업이 진행되지 않도록 하는 것이다. 이렇게 한다면, 순환 구조가 만들어질 수 있는 이동 작업에서, 상위로 탐색하며 이미 이동 작업이 존재하는 것을 확인하고 작업을 취소하여 문제가 발생하지 않을 것이다.

위의 그림처럼, 4→8, 2→6으로의 이동 작업이 동시에 발생할 수 있을 것이다. 이때, 파란색으로 색칠된 폴더를 기준으로 락을 획득하는 것이다. 그리고 4의 경우, 자신의 상위로 탐색하며 락이 걸려있는지 확인하고, 목적지인 8부터 상위로 탐색하며 락이 걸려 있는지 확인한다. 4에는 락이 걸려 있는 상태이고 이 상태로 탐색을 진행하기 때문에 2에 락이 걸려 있다면 4의 이동 작업은 취소가 될 것이다. 반대로 2도 동일한 작업을 통해 4에 락이 걸려 있다면 자신의 작업을 취소할 것이다.

 

하지만 만약 4가 8→7→2 순서로 탐색하는 과정에서는 락이 걸려 있지 않음을 확인했지만, 확인 이후 로직이 끝나기 전에 2→6으로 이동 작업이 발생한 경우라면 어떻게 될까?

운이 좋게도 6→4의 탐색 과정 중에 4의 이동 작업이 끝나지 않은 경우, 2의 작업은 취소가 될 것이다. 하지만 이미 4가 이동한 경우는 그대로 6→4→8→7→2의 순서로 탐색이 진행된다. 이 경우, 자기 자신을 만나게 되고, 자식 폴더 중 하나로 이동하는 것으로 간주하여 작업이 실패하도록 처리하면 된다. 결국 트랜잭션을 커밋하고 락을 해제하기 때문에 순환 구조가 발생하지 않고 동시에 여러 이동 작업을 처리할 수 있다!

 

분산락 자동 갱신

우선 내 아이디어를 구현하려면 분산락을 자동으로 갱신할 필요가 있었다. 왜냐하면 폴더 깊이가 깊어질수록 탐색 시간이 오래 걸리고, 기존처럼 TTL을 통해 해제한다면 동시성 이슈가 발생할 수 있기 때문이다.

찾아보니 redisson에서는 락 연장을 따로 제공하지 않고, 자동으로 연장하는 방법을 제공했다.

RLock lock = redissonClient.getLock(lockName);
boolean isLocked = false;

try {
	isLocked = lock.tryLock(takingLockTime, keepingLockTime, TimeUnit.SECONDS);

	...

위와 같이 사용 중이었는데, keepingLockTime을 제거하면 LockWatchdogTimeout만큼 자동 갱신이 된다고 한다. LockWatchdogTimeout은 설정 파일을 수정해야 한다.

@Configuration
public class RedissonConfig {

		@Value("${spring.redisson.address}")
		private String redissonAddress;
		@Value("${spring.redisson.lock-watchdog-timeout}")
		private long lockWatchdogTimeout;
		
		@Bean
		public RedissonClient redissonClient() throws IOException {
			InputStream configStream = getClass().getClassLoader().getResourceAsStream("redisson.yml");
			Config config = Config.fromYAML(configStream);
			config.useSingleServer().setAddress(redissonAddress);
			config.setLockWatchdogTimeout(lockWatchdogTimeout);
			return Redisson.create(config);
		}
}

위에 보면 setLockWatchdogTimeout이 있고, 나는 이 값을 10초로 설정했다. 락 해제가 되지 않으면 자동 연장하는데, 연장할 시간을 10초로 한 것이다. 즉, 10초간 더 소유하게 된다.

isLocked = lock.tryLock(takingLockTime, TimeUnit.SECONDS);

이후에는 위처럼 keepingLockTime을 제거하고 사용하면 된다. 이제 일정 시간이 지나면 설정한 시간만큼 자동 연장을 한다.

다행히 계속 연장하는 요청을 보내는 형태라서, 서버가 죽으면 연장한 시간 이후 자동으로 락이 해제된다.

public void moveFolder(Long sourceFolderId, FolderMoveDto dto) {
	redisLockService.runWithWatchdogMultiLock(sourceFolderId + "", dto.targetFolderId() + "",
		() -> moveFolderTask(sourceFolderId, dto));
}

@Transactional
protected void moveFolderTask(Long sourceFolderId, FolderMoveDto dto) {
	FolderMetadata sourceFolder = folderMetadataJpaRepository.findByIdNotDeleted(sourceFolderId)
		.orElseThrow(ErrorCode.FOLDER_NOT_FOUND::baseException);
	FolderMetadata targetFolder = folderMetadataJpaRepository.findByIdNotDeleted(dto.targetFolderId())
		.orElseThrow(ErrorCode.FOLDER_NOT_FOUND::baseException);

	folderSearchUtil.folderLockCheck(sourceFolder.getId(), null);
	int targetFolderDepth = folderSearchUtil.folderLockCheck(targetFolder.getId(),
		sourceFolderId);// target은 source의 자식이면 안된다.

	// 락을 건 후에 삭제되지 않았는지 체크
	folderMetadataJpaRepository.findByIdNotDeleted(targetFolder.getId())
		.orElseThrow(ErrorCode.FOLDER_NOT_FOUND::baseException);

	validateInvalidMove(targetFolder, sourceFolder);

	validateFolderDepth(sourceFolder.getId(), targetFolder.getId(), targetFolderDepth);
	long originParentId = sourceFolder.getParentFolderId();

	// 목적지에 동일 폴더를 생성하지 않도록 락이 필요하다. 누군가 폴더를 생성해서 같은 이름이 생길 수 있기 때문.
	// 또한 트랜잭션 내부에서 락을 사용하면 커밋 전에 락이 해제되기 때문에 일관성이 깨질 수 있다.
	String moveFolderLock = targetFolder.getId() + "/" + sourceFolder.getUploadFolderName();
	redisLockService.runWithWatchdogLock(moveFolderLock,
		() -> duplicatedCheckAndMoveCommit(targetFolder, sourceFolder));

	// 업데이트가 완료된 이후 용량 계산을 실시한다.
	// metadataService.calculateSize(originParentId);
	// metadataService.calculateSize(targetFolder.getId());
}

용량 계산은 처리되지 않도록 했는데, 저 친구 덕분에 데드락이 발생했기 때문이다. 해당 내용은 이후에 따로 정리할 생각이다.

기존 코드와 크게 달라진 것은 없다. runWithWatchdogMultiLock 메서드는 출발지와 목적지 폴더의 락을 동시에 획득하고 작업을 수행한다.

public int folderLockCheck(Long folderId, Long invalidId) {
	int depth = 0;
	FolderMetadata folderMetadata = folderMetadataJpaRepository.findById(folderId)
		.orElseThrow(ErrorCode.FOLDER_NOT_FOUND::baseException);
	Long parentId = folderMetadata.getParentFolderId();
	while(parentId!=null){
		if (parentId != null && parentId == invalidId) {
			throw ErrorCode.FOLDER_MOVE_NOT_AVAILABLE.baseException();
		}

		FolderMetadata parentFolder = folderMetadataJpaRepository.findById(parentId)
			.orElseThrow(ErrorCode.FOLDER_NOT_FOUND::baseException);

		// 부모가 이동이나 삭제 작업 중인지 확인 후 이미 진행 중이라면 예외 발생
		boolean checkLockResult = redisLockService.checkLock(parentFolder.getId().toString());
		if (checkLockResult) {
			throw ErrorCode.PARENT_LOCKED.baseException();
		}

		parentId = parentFolder.getParentFolderId(); // 부모 갱신하여 상위로 탐색
		depth++;
	}

	return depth;
}

상위로 탐색하며 락이 걸렸는지 확인하는 메서드이다. 어차피 탐색하기 때문에 현재 내 폴더의 깊이도 함께 계산해서 반환했다. validateFolderDepth 메서드를 호출할 때, 계산한 깊이를 활용하여 최대 깊이가 넘지 않는지 확인을 한다.

추가로 폴더 생성에는 락을 걸지 않았다. 폴더의 경우 논리적인 데이터이고, 생성도 쉽고 삭제도 쉽고 무엇보다 중요한 데이터라고 생각이 들지 않았다. 파일 업로드는 고민인데, 큰 파일은 업로드에 긴 시간이 걸리기 때문이다. 그래서 이런 부분은 중간에 삭제 취소를 해서 롤백을 할지, 아니면 업로드 중인 파일들은 별도의 임시 폴더에 전부 옮길지 고민이다. 우선 이동 작업 변경에 대해서 집중했고, 이에 대한 부하 테스트를 진행했다.

 

부하 테스트

테스트는 이전과 마찬가지로 편의를 위해서 폴더 리스트 조회, 폴더 이동, 폴더 생성 3가지 작업을 6:2:2의 비율로 요청을 보내도록 했다.

참고로 아무것도 없는 root 폴더에서 폴더를 생성하고, 폴더가 존재하면 조회하고 이동하도록 테스트를 진행했다. 그래서 시간이 지날수록 많은 폴더가 생성되고, 폴더가 별로 없는 초기에는 락 경합이 비교적 자주 발생했다.

참고로 동일한 환경을 최대한 맞추기 위해, 서버는 도커를 활용해서 메모리 1GB, cpu 코어는 2개로 제한했다.

 

서버 리소스 수집

이전에 부하 테스트를 할 때는 서버 상태를 확인하지 않았었다. 그래서 이번에는 서버 상태도 함께 모니터링을 진행했다. 이전에 그라파나와 프로메테우스를 사용한 경험이 있는데, 프로메테우스가 pull 방식으로 HTTP 요청을 서버에 보내서 데이터를 가져오기 때문에 서버에 부하가 심하면 실시간으로 데이터를 받아보기 힘들었다. 그래서 push 방식으로 데이터를 전송하는 방법을 gpt에게 물어보았고, telegarf를 추천해 줘서 사용했다. telegraf로 시스템 리소스를 수집하고, 해당 데이터를 influxDB에 전송하는 방식을 사용했다.

# 1. 빌드 단계
FROM gradle:7.6-jdk17 AS build
WORKDIR /app
COPY --chown=gradle:gradle . .
RUN gradle clean build -x test --no-daemon

# 2. 실행 단계
FROM openjdk:17-jdk-slim

WORKDIR /app

# 필요한 도구 설치 (telegraf 포함)
RUN apt-get update && \\
    apt-get install -y tzdata sysstat curl gnupg procps && \\
    ln -sf /usr/share/zoneinfo/Asia/Seoul /etc/localtime && \\
    echo "Asia/Seoul" > /etc/timezone && \\
    dpkg-reconfigure -f noninteractive tzdata && \\
    curl -s <https://repos.influxdata.com/influxdata-archive_compat.key> | gpg --dearmor | tee /usr/share/keyrings/influxdata-archive-keyring.gpg >/dev/null && \\
    echo "deb [signed-by=/usr/share/keyrings/influxdata-archive-keyring.gpg] <https://repos.influxdata.com/debian> stable main" | tee /etc/apt/sources.list.d/influxdata.list && \\
    apt-get update && \\
    apt-get install -y telegraf && \\
    rm -rf /var/lib/apt/lists/*

# 로그 디렉토리 생성
RUN mkdir -p /app/logs && chmod -R 777 /app/logs

# JAR 복사
COPY --from=build /app/build/libs/*.jar app.jar

# 기본 실행: 자바 애플리케이션만
ENTRYPOINT ["java", "-XX:NativeMemoryTracking=summary", "-jar", "app.jar"]

서버 리소스를 제한하고 비교적 동일한 환경에서 부하 테스트를 진행하기 위해 도커를 사용했다.

[agent]
  interval = "2s"           # 10초마다 메트릭 수집
  round_interval = true
  metric_batch_size = 1000
  metric_buffer_limit = 10000
  collection_jitter = "0s"
  flush_interval = "10s"     # 10초마다 InfluxDB에 전송
  flush_jitter = "0s"
  precision = "s"
	hostname = "test-server"
  omit_hostname = false

[[outputs.influxdb_v2]]
  urls = ["<http://host.docker.internal:8086>"]
  token = "your token"
  organization = "seungh"
  bucket = "test"

[[inputs.cpu]]
  percpu = true
  totalcpu = true
  fielddrop = ["time_*"]

[[inputs.mem]]

[[inputs.net]]
  interfaces = ["eth0"]

telegraf.conf라는 파일을 만들고, 해당 파일을 실행했다. influxDB에 수집한 데이터를 저장한다.

interval = "2s" 데이터 수집 주기

round_interval = true 수집 주기를 시간 단위에 맞춰 정렬
metric_batch_size = 1000 한 번에 전송할 메트릭 포인트의 최대 개수. 1000개씩 모아서 보낸다.
metric_buffer_limit = 10000 네트워크 문제로 전송이 지연될 때 내부에서 버퍼링할 수 있는 최대 메트릭 개수
collection_jitter = "0s" 수집 주기에 랜덤 지연을 얼마나 줄지 설정.
flush_interval = "10s" 수집한 데이터를 influxDB같은 출력 대상으로 실제 전송하는 간격
flush_jitter = "0s" 전송 시점에 주는 랜덤 지연
precision = "1s" 타임스탬프의 정밀도 단위. 초단위로 기록한다는 의미.
hostname = "test-server" 수집 시점에 자동으로 호스트 이름을 붙인다.
omit_hostname = false true로 하면 호스트 이름을 매트릭에 포함하지 않는다.
telegraf --config /app/config/telegraf.conf
telegraf --config /app/config/telegraf.conf &

실행 명령어인데, ‘&’를 붙이면 데몬으로 실행된다.

참고로 token 값과 organazation, bucket은 influxDB를 설치하고, influxDB에서 발급 받은 토큰과 생성한 bucket, organazation 등을 사용해야 한다. 설치하면 매우 상세하게 사용법을 알려주니 따라가면 된다.

Grafana

이제 시각화하기 위해 cpu, memory, network 지표 수집을 위한 쿼리를 작성했다. gpt 도움을 받아서 쉽게 쿼리를 생성할 수 있었다.

from(bucket: "test")
  |> range(start: -1h)
  |> filter(fn: (r) =>
    r._measurement == "cpu" and
    r.cpu == "cpu-total" and
    (r._field == "usage_user" or r._field == "usage_system")
  )
  |> aggregateWindow(every: 10s, fn: mean)
  |> pivot(rowKey:["_time"], columnKey: ["_field"], valueColumn: "_value")
  |> map(fn: (r) => ({
      r with
      cpu_usage_percent: r.usage_user + r.usage_system
    }))
  |> yield(name: "cpu_usage_percent")

from(bucket: "test")
  |> range(start: -1h)
  |> filter(fn: (r) => r._measurement == "mem" and r._field == "used_percent")
  |> aggregateWindow(every: 10s, fn: mean)
  |> yield(name: "mean")

from(bucket: "test")
  |> range(start: -1h)
  |> filter(fn: (r) =>
    r._measurement == "net" and
    r.interface == "eth0" and
    (r._field == "bytes_sent" or r._field == "bytes_recv")
  )
  |> aggregateWindow(every: 10s, fn: sum)
  |> pivot(rowKey:["_time"], columnKey: ["_field"], valueColumn: "_value")
  |> map(fn: (r) => ({
      r with
      bytes_sent: float(v: r.bytes_sent) / 1024.0 ,
      bytes_recv: float(v: r.bytes_recv) / 1024.0 ,
      total_bytes: (float(v: r.bytes_sent) + float(v: r.bytes_recv)) / 1024.0 ,
    
    }))
  |> yield(name: "net_usage_mb_per_sec")

위에서부터 각각 cpu, memory, network 이다.

이후 부하 테스트를 진행했다.

 

테스트는 유저 250명~300명, 초당 5명씩 증가하도록 했고, 10분 정도 진행을 했다.

응답 시간이 20초에 걸리는 것이 보였고, 내가 클라이언트 타임아웃을 20초로 설정했다. 그래서 이 정도 요청 자체를 감당하지 못한다고 생각했고, 서버의 메모리와 힙 상태를 살펴보았다.

힙 메모리가 부족해 보였고, 메모리 사용률도 75% 정도면 25% 남았으니까 더 늘려도 되겠다고 생각했다. 그래서 max heap size를 512MB로 늘리고 실행했다. 또한, 메모리가 늘어도 처리할 스레드 수가 부족하면 처리하지 못하니 스레드 수와 tcp 커넥션 수, 대기 큐 사이즈를 늘려 주었다. 메모리에 큰 이슈가 없어서 스레드 수 300, 커넥션 수 400, 대기 큐 사이즈 200 이런 식으로 설정했다.

 

이후 동일하게 부하를 주었다.

 

다시 진행한 테스트 결과이고, 중간에 응답이 많이 느린 구간이 있었다. 이 부분을 살펴보니

2025-07-08 17:10:40.910 [http-nio-8080-exec-14] ERROR c.w.s.g.error.GlobalExceptionHandler - Internal Server Error
org.springframework.transaction.CannotCreateTransactionException: Could not open JPA EntityManager for transaction
	at org.springframework.orm.jpa.JpaTransactionManager.doBegin(JpaTransactionManager.java:466)
	at org.springframework.transaction.support.AbstractPlatformTransactionManager.startTransaction(AbstractPlatformTransactionManager.java:532)

이런 에러가 있었고, db connection timeout이 발생한 것을 알 수 있었다.

결국 스레드 수를 아무리 늘려 봤자, db에서 이를 전부 처리할 수 없으면 결국 지연이 발생한다. 아마 이동 작업이 커넥션을 오래 사용하기 때문에 다른 작업에도 영향을 미친 게 아닐까 생각이 들었다.

그래서 사용자 수를 250까지 줄여보았다.

 

요청 수를 줄이니 커넥션 관련 이슈는 발생하지 않았고, 락 관련 예외만 발생했다.

 

DB에 더미 데이터 넣은 후 테스트 진행

어차피 공유한 폴더가 아니라면 락 경합이 발생할 일이 굉장히 드물기 때문에 더미 데이터 없이 진행했다. 관련된 폴더가 아니더라도 데이터 양이 많아지면 db 처리 속도에도 영향을 줄 것이기 때문에, 100만 개의 데이터를 넣은 후에 동일한 테스트를 진행했다.

그래도 pk 기반의 페이징 쿼리, 인덱스 설정 등을 잘했기 때문에 큰 차이는 없을 것으로 예상했다.

하지만 시작하자마자 수많은 에러가 발생했고, 에러 로그를 확인해 보았다.

2025-07-10 11:19:03.274 [scheduling-1] WARN  o.h.e.jdbc.spi.SqlExceptionHelper - SQL Error: 0, SQLState: 08S01
2025-07-10 11:19:03.280 [scheduling-1] ERROR o.h.e.jdbc.spi.SqlExceptionHelper - HikariPool-1 - Connection is not available, request timed out after 3003ms (total=0, active=0, idle=0, waiting=0)
2025-07-10 11:19:03.281 [scheduling-1] ERROR o.h.e.jdbc.spi.SqlExceptionHelper - Communications link failure

The last packet sent successfully to the server was 0 milliseconds ago. The driver has not received any packets from the server.
2025-07-10 11:19:03.298 [scheduling-1] ERROR o.s.s.s.TaskUtils$LoggingErrorHandler - Unexpected error occurred in scheduled task
org.springframework.transaction.CannotCreateTransactionException: Could not open JPA EntityManager for transaction

이상하게 스케줄링 작업에서 타임아웃이 계속 발생했다. 우선 의심되는 부분은 스케줄링에 사용되는 쿼리인데, 고아가 되는 파일이 없더라도 데이터가 많아지면 조회에 오랜 시간이 걸리니 문제가 될 수 있으려나..?라는 생각이었다.

explain analyze select * from folder_metadata fm1_0
where fm1_0.is_deleted=1 order by fm1_0.folder_metadata_id limit 10;

-> Limit: 10 row(s)  (cost=0.916 rows=1) (actual time=1191..1191 rows=0 loops=1)
    -> Filter: (fm1_0.is_deleted = 1)  (cost=0.916 rows=1) (actual time=1191..1191 rows=0 loops=1)
        -> Index scan on fm1_0 using PRIMARY  (cost=0.916 rows=10) (actual time=0.0539..1121 rows=1.04e+6 loops=1)

실행 계획을 보니, 104만 개를 스캔하고 using where로 필터링이 되는 것을 확인할 수 있었다. 기존 db에는 문제가 없었는데, 컨테이너로 띄운 db에는 문제가 있었다. 그래서 살펴보니 데이터를 옮기는 과정에서 컨테이너 환경에서는 인덱스가 없이 테이블이 생성되었다. 컨테이너 환경은 귀찮아서 ddl-auto를 update로 해서 생성했는데, entity 클래스에 인덱스 관련된 정보가 빠져 있어서 인덱스가 생성되지 않았던 것이었다. 그래서 해당 인덱스를 생성하고 entity 클래스도 수정했다.

 

그리고 다시 테스트를 진행했는데,

계속 테스트를 진행해 보니 이런 상황이 발생했다. 처음에 굉장히 당황했는데, 문제를 찾아보니 데드락 이슈가 있었고, 그로 인해 요청을 처리하지 못하는 상황이 발생했다. 그래서 테스트를 위해 해당 부분이 처리되지 않도록 코드를 수정하고 테스트를 이어 진행했다. 데드락 이슈 해결은 별도로 정리할 예정이다.

 

 

유저 250명, 5명씩 증가, 10분간 테스트 진행

RPS 자체는 거의 변화가 없었다. 일정량 이상의 요청에서는 어차피 많은 폴더가 생성될 것이고, 랜덤으로 폴더 선택하다 보면 깊이가 깊은 폴더 간의 이동이 발생할 수 있으니 그런 것으로 보인다.

timeout은 11번 발생했고, 이는 기록된 에러의 수와 일치한다.

결국 스레드 수를 늘린다고 해서 RPS가 크게 늘어나지 않았다. DB에서 처리할 수 있는 수에는 한계가 있고, 한계를 넘어서면 위와 같이 타임아웃이 발생했다.

 

유저 250명, 5명씩 증가, 10분 테스트 진행(409 에러도 실패 집계에 포함)

이전에는 순수하게 락획득 요청 경합만 가지고 테스트를 했다. 이번에는 상위 폴더에 락이 있어 작업을 진행하지 못하는 경우(409 에러)도 실패에 포함해 보았다.

예상대로 수많은 실패 응답이 돌아왔고, 실패율은 15%였다. 당연했던 것이, 실제로 이미 대상 폴더가 이동 작업 중이라 안 되는 것을 아는데, 그런 것을 신경 쓰지 않고 랜덤으로 이동 작업을 실행했기에 대부분의 이동 작업이 실패할 수밖에 없었다.

 

이동 작업 11700개 중 8590개는 실패했고, 3110개만 성공했다. 약 26.5%가 성공하고, 73.5%는 실패한 것이다.

 

개인적인 생각으로는 위와 같이 요청이 들어오는 경우는, 일부러 DoS 공격을 하는 것 외에는 없지 않을까 싶다. 이러한 이유와 함께 409 응답은 데이터 무결성을 위해 정상적인 처리라고 생각해서 이전 테스트에서는 409 응답은 실패 카운트에서 제외했었다.

 

분산락 1개로 제어하는 이전 로직 테스트

유저 수, 테스트 시간 등은 동일한 설정으로 진행했다.

RPS는 큰 차이가 없었고, 실패율이 16%로 1% 상승했다. RPS는 내 테스트에서 생각보다 큰 의미가 없는 것 같은 게, 이동 작업을 랜덤으로 하다 보니, 깊이가 매우 큰 폴더들이 많이 걸리면 상대적으로 낮게 측정된다. 그리고 락을 하나만 잡고 처리하니 실패하는 횟수가 더 늘었고, 그만큼 이동 작업이 적어지니 RPS가 높이 나올 수밖에 없다고 생각한다. 그리고 분산락 획득 대기 시간도 1초로 짧게 설정해서 이것 또한 영향을 미치는 것 같다.

 

이동 작업의 경우 12423개 중 9858개가 실패했고, 2565개가 성공했다. 20%의 작업이 성공하고, 80%가 실패했다. 이전과 비교했을 때, 6.5%의 차이가 보인다.

 

그리고 이동 작업 수가 적다 보니 커넥션을 오래 사용하는 요청 수가 적고, db connection timeout도 발생하지 않았다.

성공 횟수를 늘리기 위해 락 대기 시간을 늘려보기로 결정했다. 현재 1초인데, 3초로 늘려서 다시 테스트를 해보았다.

 

대기 시간이 길어진 만큼, 처리를 더 할 수 있었고, RPS는 떨어지는 것을 볼 수 있었다. 여전히 db timeout은 거의 발생하지 않았다.

10450개의 이동 요청 중 8139개가 실패했다. 성공 수는 2311개이고, 약 22%의 성공률이었다. 여전히 낮은 것을 볼 수 있다.

 

전체 처리 속도는 분산락 정책에 따라 차이가 있어도, 이동 작업의 처리량이 어떻게 변했는지 보자.

  • 분산락 대기 1초에서는, 전체 이동 요청 수 대비 20%를 처리할 수 있었다.
  • 분산락 대기 3초에서는 전체 이동 요청 수 대비 22%를 처리할 수 있었다.
  • 각 폴더별로 분산락을 획득하는 로직에서는 전체 이동 요청 수 대비 26.5%를 처리할 수 있었다.
  • 약 5% 정도 처리량이 증가한 것 같다.
  • 기존 성능 대비 상대적으로 32.5%가 향상되긴 했다.

 

조금 의문이 드는 것은 409는 실패로 간주해야 하는지, 성공으로 간주해야 하는 지이다. 사용자가 작업을 반드시 완료해야 한다는 관점에서는 실패지만, 시스템 무결성을 위해 일부러 회피하여 폴더의 순환 구조를 막고자 한 작업이라 요청에 대해 올바르게 차단된 작업이어서 성공으로 봐야 할까..?  결국 루트 폴더로 락 거는 것도 무결성을 위한 행위이고, 개선한 작업의 상위 폴더 탐색 과정도 무결성을 위한 행위이다. 그렇다면 409는 정상적으로 예외를 잘 처리한 것으로 보고, 성공으로 봐도 되지 않을까..?

 

만약 정상적으로 예외를 잘 처리한 것으로 보는 경우 실패율이 0%이기 때문에 처리량이 비교도 안되게 올라간 것으로 볼 수 있다.

만약 그것 또한 실패로 본다면, 두 가지 방법의 실패율이 비슷하지만, 그래도 동시 이동 작업의 처리량 자체는 늘어났기 때문에 이동 작업 자체는 개선된 것으로 볼 수 있을 것 같다.

 

 

마무리

  • 폴더 이동에 사용한 락 작업을 개선하여 동시 작업량을 늘려보았다. RPS 자체는 큰 차이가 없지만, 이동 작업 자체는 개선되어 동시 이동 작업량이 늘어난 것을 확인할 수 있었다.
    • RPS가 크게 변화가 없는 이유는, 기존 로직은 실패 응답이 더 많고, 현재 방법은 락 확인을 위해 네트워크 I/O를 하기 때문이다. 아마 실제 분리된 서버에서 처리한다면 RPS는 더 낮아질 것으로 예상된다.
    • 그렇다면 이왕 폴더 탐색을 하는 김에 레디스 대신 DB 필드를 하나를 사용해서 CAS로 처리해 보는 것도 비교하면 좋겠다는 생각이 들었다.
  • 이것저것 조금씩 건드리며 부하 테스트를 굉장히 많이 진행했다. 그 결과로 인덱스가 빠진 것도 확인할 수 있었고, 데드락이 발생한 것도 발견해서 해결할 수 있었다. 특히 데드락의 경우 부하 테스트를 하지 않았다면 이슈를 발견하지 못했을 것이다. 만드는 것도 중요하지만 문제가 없는지 테스트를 하며 확인하는 과정도 굉장히 중요함을 배웠다.
728x90

'Java > My-Storage 프로젝트' 카테고리의 다른 글

[My-Storage] 재귀 호출에서 발생할 수 있는 데드락  (0) 2025.07.13
[My-Storage 개선하기] Ceph에 파일 저장하기  (0) 2025.01.05
[My-Storage 개선하기] 로컬 환경에서 Docker로 Ceph 설치하기  (0) 2025.01.04
[My-Storage 개선하기] 데드락 해결, DB lock 사용 줄이기(2) - 테스트  (0) 2024.12.19
[My-Storage 개선하기] 데드락 해결, DB lock 사용 줄이기(1)  (0) 2024.12.19
    'Java/My-Storage 프로젝트' 카테고리의 다른 글
    • [My-Storage] 재귀 호출에서 발생할 수 있는 데드락
    • [My-Storage 개선하기] Ceph에 파일 저장하기
    • [My-Storage 개선하기] 로컬 환경에서 Docker로 Ceph 설치하기
    • [My-Storage 개선하기] 데드락 해결, DB lock 사용 줄이기(2) - 테스트
    seungh1024
    seungh1024

    티스토리툴바