Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 17 additions & 20 deletions .github/workflows/security-spring.yml
Original file line number Diff line number Diff line change
Expand Up @@ -170,15 +170,14 @@ jobs:
trivy --version

- name: Trivy filesystem scan
uses: aquasecurity/trivy-action@0.34.1
with:
scan-type: fs
skip-setup-trivy: true
ignore-unfixed: true
severity: HIGH,CRITICAL
format: sarif
output: trivy-fs.sarif
exit-code: '0'
run: |
trivy fs \
--ignore-unfixed \
--severity HIGH,CRITICAL \
--format sarif \
--output trivy-fs.sarif \
--exit-code 0 \
.

- name: Check Trivy FS SARIF has results
id: trivyfs_sarif_check
Expand Down Expand Up @@ -246,16 +245,14 @@ jobs:

- name: Trivy image scan
if: ${{ steps.df.outputs.found == 'true' }}
uses: aquasecurity/trivy-action@0.34.1
with:
scan-type: image
skip-setup-trivy: true
image-ref: local/lms-back:secscan
ignore-unfixed: true
severity: HIGH,CRITICAL
format: sarif
output: trivy-image.sarif
exit-code: '0'
run: |
trivy image \
--ignore-unfixed \
--severity HIGH,CRITICAL \
--format sarif \
--output trivy-image.sarif \
--exit-code 0 \
local/lms-back:secscan

- name: Check Trivy IMG SARIF has results
if: ${{ steps.df.outputs.found == 'true' }}
Expand Down Expand Up @@ -304,4 +301,4 @@ jobs:
if: ${{ steps.gitleaks_sarif_check.outputs.ok == 'true' }}
uses: github/codeql-action/upload-sarif@v3
with:
sarif_file: gitleaks.sarif
sarif_file: gitleaks.sarif
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,17 @@

import com.mjsec.lms.assignment.domain.Plan;
import com.mjsec.lms.user.domain.User;
import jakarta.persistence.LockModeType;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Lock;
import org.springframework.data.jpa.repository.Modifying;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import org.springframework.stereotype.Repository;

import java.time.LocalDateTime;
import java.util.List;
import java.util.Optional;

@Repository
public interface PlanRepository extends JpaRepository<Plan, Long> {
Expand Down Expand Up @@ -43,6 +46,10 @@ List<Plan> findAssignmentsExpiredBetween(
@Param("endTime") LocalDateTime endTime
);

@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("SELECT p FROM Plan p WHERE p.planId = :planId")
Optional<Plan> findByIdWithPessimisticLock(@Param("planId") Long planId);

@Modifying
@Query("DELETE FROM Plan p WHERE p.creator = :user")
void deleteByCreator(@Param("user") User user);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -44,10 +44,17 @@ public interface SubmissionRepository extends JpaRepository<AssignmentSubmission
// 특정 사용자의 특정 과제의 특정 상태 제출물들 조회
List<AssignmentSubmission> findByPlanPlanIdAndSubmitterUserIdAndStatus(Long planId, Long submitterUserId, SubmissionStatus status);

int countByPlanPlanIdAndStatus(Long planId, SubmissionStatus status);

// 특정 과제에 제출한 고유 제출자 수 조회 (미제출자 계산용)
@Query("SELECT COUNT(DISTINCT a.submitter.userId) FROM AssignmentSubmission a WHERE a.plan.planId = :planId")
int countDistinctSubmittersByPlanId(@Param("planId") Long planId);

@Query("SELECT COUNT(DISTINCT a.submitter.userId) FROM AssignmentSubmission a " +
"WHERE a.plan.planId = :planId " +
"AND a.submitter.userId IN (" +
"SELECT gm.user.userId FROM GroupMember gm " +
"WHERE gm.studyGroup.studyId = :groupId AND gm.role = com.mjsec.lms.studygroup.domain.type.GroupMemberRole.MENTEE" +
")")
int countDistinctMenteeSubmittersByPlanIdAndGroupId(@Param("planId") Long planId, @Param("groupId") Long groupId);


@Query("SELECT COUNT(DISTINCT a.submitter.userId) FROM AssignmentSubmission a " +
"WHERE a.plan.planId = :planId AND a.status = :status")
int countDistinctSubmittersByPlanIdAndStatus(@Param("planId") Long planId, @Param("status") SubmissionStatus status);
}
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
import com.mjsec.lms.assignment.dto.SubmissionStatisticsResponse;
import com.mjsec.lms.common.exception.RestApiException;
import com.mjsec.lms.studygroup.repository.GroupMemberRepository;
import com.mjsec.lms.assignment.repository.PlanRepository;
import com.mjsec.lms.assignment.repository.SubmissionRepository;
import com.mjsec.lms.common.type.ErrorCode;
import com.mjsec.lms.studygroup.domain.type.GroupMemberRole;
Expand All @@ -31,12 +32,14 @@ public class AssignmentSubmissionService {
private final ValidationUtils validationUtils;
private final SubmissionRepository submissionRepository;
private final GroupMemberRepository groupMemberRepository;
private final PlanRepository planRepository;

AssignmentSubmissionService(ValidationUtils validationUtils, SubmissionRepository submissionRepository, GroupMemberRepository groupMemberRepository){
AssignmentSubmissionService(ValidationUtils validationUtils, SubmissionRepository submissionRepository, GroupMemberRepository groupMemberRepository, PlanRepository planRepository){

this.validationUtils = validationUtils;
this.submissionRepository = submissionRepository;
this.groupMemberRepository = groupMemberRepository;
this.planRepository = planRepository;
}

// 과제 제출하기 (멘티만)
Expand All @@ -57,6 +60,10 @@ public SubmissionResponse submitAssignment(Long groupId, Long planId, Long curre
//과제 기한 검사
// validationUtils.validateAssignmentDeadline(plan);

// Plan 행에 비관적 락 획득 — 동시 제출 요청을 직렬화하여 중복 제출 race condition 방지
planRepository.findByIdWithPessimisticLock(planId)
.orElseThrow(() -> new RestApiException(ErrorCode.PLAN_NOT_FOUND));

// 과제 제출 중복 여부 체크
validationUtils.validateDuplicateSubmission(user.getUserId(), planId);

Expand Down Expand Up @@ -287,13 +294,13 @@ public SubmissionStatisticsResponse getSubmissionStatistics(Long groupId, Long p
// 해당 스터디 그룹의 전체 멘티 수 조회
int totalMentees = groupMemberRepository.findByStudyGroup_StudyIdAndRole(groupId, GroupMemberRole.MENTEE).size();

// 상태별 제출 수 조회
int submittedCount = submissionRepository.countByPlanPlanIdAndStatus(planId, SubmissionStatus.SUBMITTED);
int completedCount = submissionRepository.countByPlanPlanIdAndStatus(planId, SubmissionStatus.COMPLETED);
int revisionRequiredCount = submissionRepository.countByPlanPlanIdAndStatus(planId, SubmissionStatus.REVISION_REQUIRED);
// 상태별 고유 제출자 수 조회 (중복 제출 시에도 정확한 인원 수 반영)
int submittedCount = submissionRepository.countDistinctSubmittersByPlanIdAndStatus(planId, SubmissionStatus.SUBMITTED);
int completedCount = submissionRepository.countDistinctSubmittersByPlanIdAndStatus(planId, SubmissionStatus.COMPLETED);
int revisionRequiredCount = submissionRepository.countDistinctSubmittersByPlanIdAndStatus(planId, SubmissionStatus.REVISION_REQUIRED);

// 미제출자 수 계산
int submittedMentees = submissionRepository.countDistinctSubmittersByPlanId(planId);
// 미제출자 수 계산 (그룹 멘티 기준으로만 집계 - 음수 방지)
int submittedMentees = submissionRepository.countDistinctMenteeSubmittersByPlanIdAndGroupId(planId, groupId);
int notSubmittedCount = totalMentees - submittedMentees;

return SubmissionStatisticsResponse.builder()
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,186 @@
package com.mjsec.lms.assignment.service;

import com.mjsec.lms.assignment.domain.AssignmentSubmission;
import com.mjsec.lms.assignment.domain.Plan;
import com.mjsec.lms.assignment.dto.SubmissionDto;
import com.mjsec.lms.assignment.repository.PlanRepository;
import com.mjsec.lms.assignment.repository.SubmissionRepository;
import com.mjsec.lms.common.exception.RestApiException;
import com.mjsec.lms.common.type.ErrorCode;
import com.mjsec.lms.common.util.ValidationUtils;
import com.mjsec.lms.studygroup.repository.GroupMemberRepository;
import com.mjsec.lms.user.domain.User;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.mockito.junit.jupiter.MockitoSettings;
import org.mockito.quality.Strictness;

import java.util.ArrayList;
import java.util.List;
import java.util.Optional;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicInteger;

import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyLong;
import static org.mockito.Mockito.*;

/**
* 과제 동시 제출 Race Condition 방지 테스트
*
* 비관적 락의 실제 블로킹 효과는 DB 트랜잭션 수준에서 동작하므로
* 순수 단위 테스트로는 검증할 수 없다.
*
* 이 테스트는 다음 두 가지를 검증한다:
* 1. 서비스가 모든 동시 요청에서 락 획득 → 중복 체크 → 저장 순서를 유지하는가
* 2. 중복 체크 로직(AtomicBoolean으로 DB의 원자적 동작을 시뮬레이션)이
* 동시 요청 환경에서 정확히 1건만 통과시키는가
*
* Mockito mock은 thread-safe하지 않으므로, 스레드 실행 전에 모든 stubbing을
* 완료하고 실행 중에는 새로운 stubbing을 추가하지 않는다.
*/
@ExtendWith(MockitoExtension.class)
@MockitoSettings(strictness = Strictness.LENIENT)
@DisplayName("AssignmentSubmissionService - 동시 제출 Race Condition 테스트")
class AssignmentSubmissionConcurrencyTest {

@Mock private ValidationUtils validationUtils;
@Mock private SubmissionRepository submissionRepository;
@Mock private GroupMemberRepository groupMemberRepository;
@Mock private PlanRepository planRepository;

private AssignmentSubmissionService service;

private static final Long GROUP_ID = 1L;
private static final Long PLAN_ID = 10L;
private static final Long USER_ID = 100L;
private static final Long STUDENT_NUMBER = 20210001L;
private static final String IP = "127.0.0.1";

private User user;
private Plan plan;

@BeforeEach
void setUp() {
service = new AssignmentSubmissionService(
validationUtils, submissionRepository, groupMemberRepository, planRepository
);

// 스레드 실행 전에 모든 stubbing을 완료한다.
// Mockito mock은 thread-safe하지 않으므로 실행 도중 stubbing 호출을 피한다.
user = mock(User.class);
plan = mock(Plan.class);
when(user.getUserId()).thenReturn(USER_ID);
when(user.getName()).thenReturn("홍길동");
when(plan.getPlanId()).thenReturn(PLAN_ID);

when(validationUtils.validateMenteeAccess(GROUP_ID, STUDENT_NUMBER)).thenReturn(user);
when(validationUtils.validatePlan(PLAN_ID)).thenReturn(plan);
when(planRepository.findByIdWithPessimisticLock(PLAN_ID)).thenReturn(Optional.of(plan));
when(submissionRepository.save(any(AssignmentSubmission.class))).thenAnswer(i -> i.getArgument(0));
}

@Test
@DisplayName("동일 사용자가 동시에 N번 제출해도 정확히 1건만 성공한다")
void only_one_submission_succeeds_under_concurrent_requests() throws Exception {
final int threadCount = 5;

// 비관적 락이 보장하는 효과를 시뮬레이션:
// 실제 DB에서는 락 덕분에 트랜잭션이 직렬화되어 두 번째 스레드가
// 중복 체크를 수행할 때 이미 첫 번째 커밋 결과가 반영된다.
// AtomicBoolean의 compareAndSet이 동일한 원자성을 흉내낸다.
AtomicBoolean submitted = new AtomicBoolean(false);
doAnswer(invocation -> {
if (!submitted.compareAndSet(false, true)) {
throw new RestApiException(ErrorCode.DUPLICATE_SUBMISSION);
}
return null;
}).when(validationUtils).validateDuplicateSubmission(USER_ID, PLAN_ID);

AtomicInteger successCount = new AtomicInteger(0);
AtomicInteger failCount = new AtomicInteger(0);
CountDownLatch startLatch = new CountDownLatch(1);
SubmissionDto dto = SubmissionDto.builder().content("https://blog.example.com").build();

ExecutorService executor = Executors.newFixedThreadPool(threadCount);
List<Future<?>> futures = new ArrayList<>();

for (int i = 0; i < threadCount; i++) {
futures.add(executor.submit(() -> {
try {
startLatch.await(); // 모든 스레드가 동시에 출발하도록 동기화
service.submitAssignment(GROUP_ID, PLAN_ID, STUDENT_NUMBER, dto, IP);
successCount.incrementAndGet();
} catch (RestApiException e) {
if (e.getErrorCode() == ErrorCode.DUPLICATE_SUBMISSION) {
failCount.incrementAndGet();
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}));
}

startLatch.countDown(); // 전체 스레드 동시 출발
for (Future<?> f : futures) {
f.get();
}
executor.shutdown();

assertThat(successCount.get())
.as("정확히 1건만 제출에 성공해야 한다")
.isEqualTo(1);
assertThat(failCount.get())
.as("나머지 %d건은 DUPLICATE_SUBMISSION으로 차단되어야 한다", threadCount - 1)
.isEqualTo(threadCount - 1);
}

@Test
@DisplayName("동시 제출 시 비관적 락은 모든 요청에서 각각 호출된다")
void pessimistic_lock_is_called_for_every_concurrent_request() throws Exception {
final int threadCount = 3;

AtomicBoolean submitted = new AtomicBoolean(false);
doAnswer(invocation -> {
if (!submitted.compareAndSet(false, true)) {
throw new RestApiException(ErrorCode.DUPLICATE_SUBMISSION);
}
return null;
}).when(validationUtils).validateDuplicateSubmission(anyLong(), anyLong());

CountDownLatch startLatch = new CountDownLatch(1);
CountDownLatch doneLatch = new CountDownLatch(threadCount);
SubmissionDto dto = SubmissionDto.builder().content("https://blog.example.com").build();

ExecutorService executor = Executors.newFixedThreadPool(threadCount);

for (int i = 0; i < threadCount; i++) {
executor.submit(() -> {
try {
startLatch.await();
service.submitAssignment(GROUP_ID, PLAN_ID, STUDENT_NUMBER, dto, IP);
} catch (RestApiException | InterruptedException ignored) {
if (Thread.interrupted()) Thread.currentThread().interrupt();
} finally {
doneLatch.countDown();
}
});
}

startLatch.countDown();
doneLatch.await(); // 모든 스레드가 끝날 때까지 대기
executor.shutdown();

// 락 획득 시도는 요청 수만큼 반드시 발생해야 한다
verify(planRepository, times(threadCount)).findByIdWithPessimisticLock(PLAN_ID);
}
}
Loading
Loading