열공스토리

[Spring] 파일 저장 로직의 동시 요청에 의한 동시 저장을 방지하는 방법 본문

Spring

[Spring] 파일 저장 로직의 동시 요청에 의한 동시 저장을 방지하는 방법

열쩡열쩡열쩡_ 2024. 8. 17. 16:05

현재 git과 같은 형상관리 시스템을 활용하기 어려운 초보 개발자들을 위한 형상관리 웹 서비스를 개발하고 있습니다.

 

형상관리 서비스에는 "팀 프로젝트 최초 저장"이라는 API가 존재하는데, 이는 팀을 만들고 팀의 레포지토리를 만든 다음에 최초로 프로젝트를 업로드하는 API 입니다. 처음에는 이 API를 여러 팀원이 동시에 요청했을 경우 파일을 업로드 했을 경우 서버에는 최초 한 팀원의 파일만이 저장되는 것을 의도하였습니다.

 

하지만 의도와 달리 여러 팀원이 동시에 업로드한 파일이 서버에 모두 저장되었고 이러한 문제를 DB 락을 걸어 우회적인 방법으로 해결하였습니다.

 

이번 포스팅에서는 위 상황에서의 문제를 해결하는 과정에 대한 자세한 기록을 남겨보았습니다.

 

처음 "팀 프로젝트 최초 저장" API 요청에 대한 흐름은 다음과 같았습니다.

  1. 요청한 유저가 프로젝트를 최초 저장할 수 있는 권한을 갖고 있는지 검증
  2. 파일 업로드 용량 제한 검증
  3. 서버 로컬 디스크에 프로젝트 파일 저장 (깃으로 관리)
  4. 저장한 정보를 기반으로 브랜치 관련 메타데이터와 커밋 관련 메타데이터를 담고 있는 엔티티 영속화 & DB에 저장
  5. 브랜치 엔티티, 커밋 엔티티 정보를 담아 응답

[코드]

[서비스 로직]

public TeamProjectInitResponse saveInitialProject(TeamProjectInitRequest request, long userId) {
    TeamProject project = teamProjectRepository.findById(request.getProjectId())
            .orElseThrow(() -> new TeamProjectNotFoundException(ErrorCode.TEAM_PROJECT_NOT_FOUND));
    User user = userRepository.findById(userId)
            .orElseThrow(() -> new UserNotFoundException(ErrorCode.USER_NOT_FOUND));
    UserTeam userTeam = userTeamRepository.findByUserAndTeam(user, project.getTeam())
            .orElseThrow(() -> new UserTeamNotFoundException(ErrorCode.USER_TEAM_NOT_FOUND));
    // 1번
    validSubManagerOrHigher(userTeam);
    // 2번
    validUploadFileSize(request.getFiles());

	// 3번
    RepositoryUtil.saveProjectFiles(project, request.getFiles());
    RepositoryUtil.createGitIgnoreFile(project);
    RevCommit newCommit = VersionControlUtil.initializeProject(project, request.getCommitMessage());
    Ref newBranch = VersionControlUtil.getBranch(project, newCommit)
            .orElseThrow(() -> new BranchNotFoundException(ErrorCode.BRANCH_NOT_FOUND));
		
	// 4번
    TeamBranch branch = teamBranchRepository.save(
            TeamBranch.builder()
                    .name(newBranch.getName())
                    .project(project)
                    .createdBy(user)
                    .build()
    );

    TeamCommit commit = teamCommitRepository.save(
            TeamCommit.builder()
                    .commitCode(newCommit.getName())
                    .commitMessage(request.getCommitMessage())
                    .branch(branch)
                    .build()
    );

	// 5번
    return TeamProjectInitResponse.of(branch, commit);
}

[문제]

만약 이러한 흐름대로 여러 팀원이 동시에 프로젝트 최초 저장 API를 요청한다면 서버 로컬 디스크에 요청 시 보내는 파일이 전부 저장됩니다.

 

그렇게 되면 최초 저장한 프로젝트의 파일을 조회했을 때 각 유저의 입장에서 보면 자신이 저장한 파일 이외의 다른 파일도 함께 조회가 되므로 이상하다 생각하겠죠.

 

[테스트]

테스트에서는 JMeter를 사용했습니다.

 

1. 각 요청에 대한 Thread를 1개 생성하고 1초에 1번만 실행하도록 설정합니다.

 

2. 3명의 유저를 회원가입 시킨 후, 팀을 생성한 유저 이외의 나머지 두 유저를 팀에 참여시킵니다.

 

3. 각 요청 마다 (유저 마다) 다른 파일을 세팅합니다.

 

4. 요청 결과가 모두 성공합니다. (동시성 문제 발생)

 

5. 로컬 디스크에 모든 요청한 파일들이 저장됩니다.

[해결과정]

현재 겪고 있는 문제가 근본적인 동시성 문제와는 다르지만 동시성 문제를 해결하는 방법을 활용해서 우회적으로 해결할 수 있을 것 같다는 생각이 들었습니다. 따라서 이전에 게시판 프로젝트에서 게시글 좋아요 기능에서 발생하던 동시성 문제를 해결해봤던 경험을 바탕으로 이 아이디어를 시도해봤습니다.

 

[바뀐 코드]

[서비스 로직]

public TeamProjectInitResponse saveInitialProject(TeamProjectInitRequest request, long userId) {
	// 변경
    TeamProject project = teamProjectRepository.findByIdForSaveProject(request.getProjectId())
            .orElseThrow(() -> new TeamProjectNotFoundException(ErrorCode.TEAM_PROJECT_NOT_FOUND));
    
    User user = userRepository.findById(userId)
            .orElseThrow(() -> new UserNotFoundException(ErrorCode.USER_NOT_FOUND));
    UserTeam userTeam = userTeamRepository.findByUserAndTeam(user, project.getTeam())
            .orElseThrow(() -> new UserTeamNotFoundException(ErrorCode.USER_TEAM_NOT_FOUND));
    validSubManagerOrHigher(userTeam);
    
    // 추가
    validExistsProjectBranch(project);
    
    validUploadFileSize(request.getFiles());

    RepositoryUtil.saveProjectFiles(project, request.getFiles());
    RepositoryUtil.createGitIgnoreFile(project);
    RevCommit newCommit = VersionControlUtil.initializeProject(project, request.getCommitMessage());
    Ref newBranch = VersionControlUtil.getBranch(project, newCommit)
            .orElseThrow(() -> new BranchNotFoundException(ErrorCode.BRANCH_NOT_FOUND));

    TeamBranch branch = teamBranchRepository.save(
            TeamBranch.builder()
                    .name(newBranch.getName())
                    .project(project)
                    .createdBy(user)
                    .build()
    );

    TeamCommit commit = teamCommitRepository.save(
            TeamCommit.builder()
                    .commitCode(newCommit.getName())
                    .commitMessage(request.getCommitMessage())
                    .branch(branch)
                    .build()
    );

    return TeamProjectInitResponse.of(branch, commit);
}

 

[레포지토리]

@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("select tp from TeamProject tp where tp.id = :id")
Optional<TeamProject> findByIdForSaveProject(@Param("id") Long id);

 

바뀐 부분은 다음과 같습니다.

  1. 변경된 부분은 projectId로 TeamProject 엔티티를 레포지토리에서 조회할 때 조회 메서드에 비관적 락을 설정하여 정의한 메서드로 조회하도록 변경
  2. 프로젝트 최초 저장 시, 영속되는 (TeamProject의 자식) 브랜치 엔티티가 존재하는지 확인하는 프로젝트 최초 저장 유무 검증 코드 추가

만약 프로젝트 최초 저장 유무를 검증하는 로직을 거치지 않는다면 문제 상황과 다름 없이 자신이 저장하는 파일 외에 다른 파일도 저장되기 때문에 반드시 필요합니다.

 

[테스트]

위 동시성 문제가 발생하는 테스트에서 1~3번까지의 세팅과 동작을 똑같이 한 후에 바뀐 코드로 동시성 테스트를 해 보았습니다.

 

1. 최초 저장 성공한 요청 이외의 다른 요청들은 요청에 실패합니다. (동시성 문제 발생 x)

 

2. 로컬 디스크에 최초 저장된 파일 이외에는 저장되지 않습니다.

 

[결론]

이번에 서비스의 의도에 맞게 문제에 대해 고민하고 그 해결 과정을 기록하는 것에 좀 더 신경을 써 봤습니다. 비록 시간이 더 소요됐지만 실제로 마주한 문제와 상황에 대해 보다 확실히 이해할 수 있었고 나중에 이 기록을 봤을 때도 당시 겪은 문제를 쉽게 상기할 수 있을 것 같았습니다.

 

또한 이전보다 JMeter의 다양한 기능을 더 활용해 볼 수 있었던 것이 좋은 경험치가 된 것 같습니다.