diff --git a/.github/workflows/jacoco.yml b/.github/workflows/jacoco.yml index 1258b86..d607051 100644 --- a/.github/workflows/jacoco.yml +++ b/.github/workflows/jacoco.yml @@ -10,6 +10,20 @@ jobs: test: runs-on: ubuntu-22.04 + services: + mysql: + image: mysql:8.0 + env: + MYSQL_ROOT_PASSWORD: test_password_1234 + MYSQL_DATABASE: runners + ports: + - 3306:3306 + options: >- + --health-cmd="mysqladmin ping" + --health-interval=10s + --health-timeout=5s + --health-retries=3 + steps: - uses: actions/checkout@v4 with: @@ -24,6 +38,44 @@ jobs: - name: Create .env file for CI environment run: echo "${{ secrets.ENV_FILE_CONTENT }}" > .env + - name: Create test application.yml + run: | + mkdir -p src/test/resources + cat > src/test/resources/application.yml << 'EOF' + spring: + datasource: + url: jdbc:mysql://localhost:3306/runners?useSSL=false&serverTimezone=Asia/Seoul&characterEncoding=UTF-8&allowPublicKeyRetrieval=true + username: root + password: test_password_1234 + driver-class-name: com.mysql.cj.jdbc.Driver + + jpa: + database: mysql + hibernate: + ddl-auto: update + properties: + hibernate: + dialect: org.hibernate.dialect.MySQLDialect + format_sql: true + show-sql: true + + jwt: + secret: dGVzdF9qd3Rfc2VjcmV0X2tleV9mb3JfY2lfdGVzdGluZ19vbmx5XzEyMzQ1Njc4OTA= + access-token-expire-time: 1800000 + refresh-token-expire-time: 1209600000 + + file: + upload: + dir: ${java.io.tmpdir}/test-uploads + + swagger: + id: runners + pwd: runners123 + server: + url: + prod: http://localhost:8080 + EOF + - name: Grant execute permission for gradlew run: chmod +x gradlew diff --git a/src/main/java/run/backend/domain/running/controller/RunningController.java b/src/main/java/run/backend/domain/running/controller/RunningController.java new file mode 100644 index 0000000..d072606 --- /dev/null +++ b/src/main/java/run/backend/domain/running/controller/RunningController.java @@ -0,0 +1,33 @@ +package run.backend.domain.running.controller; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; +import run.backend.domain.crew.entity.Crew; +import run.backend.domain.running.dto.request.Coordinate; +import run.backend.domain.running.service.RunningService; +import run.backend.global.annotation.member.MemberCrew; +import run.backend.global.common.response.CommonResponse; + +import java.util.List; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/v1/running") +@Tag(name = "러닝 API", description = "러닝 관련 API") +public class RunningController { + + private final RunningService runningService; + + @PostMapping("/route") + @Operation(summary = "러닝 좌표 저장", description = "크루의 러닝 좌표를 저장하는 API 입니다.") + public ResponseEntity> uploadRoute( + @RequestBody List coordinates, + @MemberCrew Crew crew) { + + runningService.processRunningRoute(crew.getId(), coordinates); + return ResponseEntity.ok(new CommonResponse<>("크루 러닝 좌표 저장 완료")); + } +} diff --git a/src/main/java/run/backend/domain/running/dto/request/Coordinate.java b/src/main/java/run/backend/domain/running/dto/request/Coordinate.java new file mode 100644 index 0000000..f293c7a --- /dev/null +++ b/src/main/java/run/backend/domain/running/dto/request/Coordinate.java @@ -0,0 +1,10 @@ +package run.backend.domain.running.dto.request; + +import java.time.LocalDateTime; + +public record Coordinate( + double latitude, + double longitude, + LocalDateTime timestamp +) { +} diff --git a/src/main/java/run/backend/domain/running/entity/Pixel.java b/src/main/java/run/backend/domain/running/entity/Pixel.java new file mode 100644 index 0000000..e226c05 --- /dev/null +++ b/src/main/java/run/backend/domain/running/entity/Pixel.java @@ -0,0 +1,33 @@ +package run.backend.domain.running.entity; + +import jakarta.persistence.EmbeddedId; +import jakarta.persistence.Entity; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; + +@Entity +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class Pixel { + + @EmbeddedId + private PixelId id; + + private Long crewId; + + private LocalDateTime updatedAt; + + public Pixel(PixelId id, Long crewId, LocalDateTime updatedAt) { + this.id = id; + this.crewId = crewId; + this.updatedAt = updatedAt; + } + + public void updateCrew(Long crewId, LocalDateTime updatedAt) { + this.crewId = crewId; + this.updatedAt = updatedAt; + } +} diff --git a/src/main/java/run/backend/domain/running/entity/PixelId.java b/src/main/java/run/backend/domain/running/entity/PixelId.java new file mode 100644 index 0000000..b55c8c2 --- /dev/null +++ b/src/main/java/run/backend/domain/running/entity/PixelId.java @@ -0,0 +1,33 @@ +package run.backend.domain.running.entity; + +import jakarta.persistence.Embeddable; +import lombok.AccessLevel; +import lombok.EqualsAndHashCode; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.io.Serializable; + +@Embeddable +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@EqualsAndHashCode +public class PixelId implements Serializable, Comparable { + + private int x; + private int y; + + public PixelId(int x, int y) { + this.x = x; + this.y = y; + } + + @Override + public int compareTo(PixelId other) { + int xCompare = Integer.compare(this.x, other.x); + if (xCompare != 0) { + return xCompare; + } + return Integer.compare(this.y, other.y); + } +} diff --git a/src/main/java/run/backend/domain/running/exception/RunningErrorCode.java b/src/main/java/run/backend/domain/running/exception/RunningErrorCode.java new file mode 100644 index 0000000..f135cfd --- /dev/null +++ b/src/main/java/run/backend/domain/running/exception/RunningErrorCode.java @@ -0,0 +1,15 @@ +package run.backend.domain.running.exception; + +import lombok.AllArgsConstructor; +import lombok.Getter; +import run.backend.global.exception.ErrorCode; + +@Getter +@AllArgsConstructor +public enum RunningErrorCode implements ErrorCode { + + INVALID_COORDINATE(8001, "유효하지 않은 좌표 범위입니다."); + + private final int errorCode; + private final String errorMessage; +} diff --git a/src/main/java/run/backend/domain/running/exception/RunningException.java b/src/main/java/run/backend/domain/running/exception/RunningException.java new file mode 100644 index 0000000..e75da4f --- /dev/null +++ b/src/main/java/run/backend/domain/running/exception/RunningException.java @@ -0,0 +1,16 @@ +package run.backend.domain.running.exception; + +import run.backend.global.exception.CustomException; + +public class RunningException extends CustomException { + + public RunningException(final RunningErrorCode runningErrorCode) { + super(runningErrorCode); + } + + public static class InvalidCoordinate extends CustomException { + public InvalidCoordinate() { + super(RunningErrorCode.INVALID_COORDINATE); + } + } +} diff --git a/src/main/java/run/backend/domain/running/repository/PixelRepository.java b/src/main/java/run/backend/domain/running/repository/PixelRepository.java new file mode 100644 index 0000000..4b16b88 --- /dev/null +++ b/src/main/java/run/backend/domain/running/repository/PixelRepository.java @@ -0,0 +1,18 @@ +package run.backend.domain.running.repository; + +import jakarta.persistence.LockModeType; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Lock; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import run.backend.domain.running.entity.Pixel; +import run.backend.domain.running.entity.PixelId; + +import java.util.Optional; + +public interface PixelRepository extends JpaRepository { + + @Lock(LockModeType.PESSIMISTIC_WRITE) + @Query("SELECT p FROM Pixel p WHERE p.id = :id") + Optional findByIdWithLock(@Param("id") PixelId id); +} diff --git a/src/main/java/run/backend/domain/running/service/RunningService.java b/src/main/java/run/backend/domain/running/service/RunningService.java index bf35371..b068097 100644 --- a/src/main/java/run/backend/domain/running/service/RunningService.java +++ b/src/main/java/run/backend/domain/running/service/RunningService.java @@ -1,15 +1,81 @@ package run.backend.domain.running.service; -import run.backend.domain.running.dto.response.CrewRunningSummaryResponse; -import run.backend.domain.running.dto.response.RunningRecordResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import run.backend.domain.running.dto.request.Coordinate; +import run.backend.domain.running.entity.Pixel; +import run.backend.domain.running.entity.PixelId; +import run.backend.domain.running.exception.RunningException; +import run.backend.domain.running.repository.PixelRepository; -public interface RunningService { +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; - void startRunning(Long eventId); +@Service +@RequiredArgsConstructor +public class RunningService { - RunningRecordResponse stopRunning(Long eventId); + public static final double SEOUL_AREA_NORTH = 37.715133; + public static final double SEOUL_AREA_SOUTH = 37.413294; + public static final double SEOUL_AREA_WEST = 126.734086; + public static final double SEOUL_AREA_EAST = 127.269311; - void joinRunning(Long memberId, Long eventId); + public static final int GRID_WIDTH = 939; + public static final int GRID_HEIGHT = 671; - CrewRunningSummaryResponse getCrewRunningSummary(Long crewId); + private final PixelRepository pixelRepository; + + @Transactional + public void processRunningRoute(Long crewId, List coordinates) { + Map pixelToCoordMap = new LinkedHashMap<>(); + for (Coordinate coord : coordinates) { + int[] pixelPos = toPixel(coord.latitude(), coord.longitude()); + PixelId id = new PixelId(pixelPos[0], pixelPos[1]); + + Coordinate existing = pixelToCoordMap.get(id); + if (existing == null || coord.timestamp().isAfter(existing.timestamp())) { + pixelToCoordMap.put(id, coord); + } + } + + List sortedPixelIds = pixelToCoordMap.keySet().stream() + .sorted() + .toList(); + + List pixelsToSave = new ArrayList<>(); + for (PixelId id : sortedPixelIds) { + Coordinate coord = pixelToCoordMap.get(id); + + Pixel pixel = pixelRepository.findByIdWithLock(id) + .orElseGet(() -> new Pixel(id, crewId, coord.timestamp())); + + if (pixel.getUpdatedAt() != null && pixel.getUpdatedAt().isAfter(coord.timestamp())) { + continue; + } + + pixel.updateCrew(crewId, coord.timestamp()); + pixelsToSave.add(pixel); + } + + pixelRepository.saveAll(pixelsToSave); + } + + public int[] toPixel(double latitude, double longitude) { + + if (latitude < SEOUL_AREA_SOUTH || latitude > SEOUL_AREA_NORTH || + longitude < SEOUL_AREA_WEST || longitude > SEOUL_AREA_EAST) { + throw new RunningException.InvalidCoordinate(); + } + + double latRatio = (latitude - SEOUL_AREA_SOUTH) / (SEOUL_AREA_NORTH - SEOUL_AREA_SOUTH); + double lonRatio = (longitude - SEOUL_AREA_WEST) / (SEOUL_AREA_EAST - SEOUL_AREA_WEST); + + int x = (int) Math.round(lonRatio * GRID_WIDTH); + int y = (int) Math.round((1 - latRatio) * GRID_HEIGHT); + + return new int[]{x, y}; + } } diff --git a/src/test/java/run/backend/config/TestSecurityConfig.java b/src/test/java/run/backend/config/TestSecurityConfig.java new file mode 100644 index 0000000..9dbc673 --- /dev/null +++ b/src/test/java/run/backend/config/TestSecurityConfig.java @@ -0,0 +1,22 @@ +package run.backend.config; + +import org.springframework.boot.test.context.TestConfiguration; +import org.springframework.context.annotation.Bean; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; +import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; +import org.springframework.security.web.SecurityFilterChain; + +@TestConfiguration +@EnableWebSecurity +public class TestSecurityConfig { + + @Bean + public SecurityFilterChain testSecurityFilterChain(HttpSecurity http) throws Exception { + http + .csrf(AbstractHttpConfigurer::disable) + .authorizeHttpRequests(auth -> auth.anyRequest().permitAll()); + + return http.build(); + } +} diff --git a/src/test/java/run/backend/domain/running/service/RunningServiceTest.java b/src/test/java/run/backend/domain/running/service/RunningServiceTest.java new file mode 100644 index 0000000..10b9aed --- /dev/null +++ b/src/test/java/run/backend/domain/running/service/RunningServiceTest.java @@ -0,0 +1,201 @@ +package run.backend.domain.running.service; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.context.annotation.Import; +import org.springframework.security.oauth2.client.registration.ClientRegistrationRepository; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; +import run.backend.domain.running.dto.request.Coordinate; +import run.backend.domain.running.entity.Pixel; +import run.backend.domain.running.entity.PixelId; +import run.backend.domain.running.repository.PixelRepository; + +@SpringBootTest +@ActiveProfiles("test") +@Transactional(propagation = Propagation.NOT_SUPPORTED) +@Import(run.backend.config.TestSecurityConfig.class) +class RunningServiceTest { + + @MockBean + private ClientRegistrationRepository clientRegistrationRepository; + + @Autowired + private PixelRepository pixelRepository; + + @Autowired + private RunningService runningService; + + @BeforeEach + void setUp() { + pixelRepository.deleteAll(); + } + + @Test + @DisplayName("비관적 락 - 동시 요청 시 순차 처리로 최신 데이터만 저장") + void pessimisticLock_concurrency_test() throws Exception { + // given + int[] pixel = runningService.toPixel(37.5663, 126.9779); + PixelId pixelId = new PixelId(pixel[0], pixel[1]); + + Pixel initial = new Pixel(pixelId, 99L, LocalDateTime.of(2025, 10, 20, 14, 0)); + pixelRepository.saveAndFlush(initial); + + Coordinate coordA = new Coordinate(37.5663, 126.9779, LocalDateTime.of(2025, 10, 20, 15, 0)); + Coordinate coordB = new Coordinate(37.5663, 126.9779, LocalDateTime.of(2025, 10, 20, 16, 0)); + + // when + CountDownLatch latchReady = new CountDownLatch(2); + CountDownLatch latchStart = new CountDownLatch(1); + ExecutorService executor = Executors.newFixedThreadPool(2); + + Runnable taskA = () -> { + latchReady.countDown(); + try { + latchStart.await(); + runningService.processRunningRoute(1L, List.of(coordA)); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + }; + + Runnable taskB = () -> { + latchReady.countDown(); + try { + latchStart.await(); + runningService.processRunningRoute(2L, List.of(coordB)); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + }; + + executor.submit(taskA); + executor.submit(taskB); + + latchReady.await(); + latchStart.countDown(); + executor.shutdown(); + executor.awaitTermination(10, TimeUnit.SECONDS); + + // then + Pixel result = pixelRepository.findById(pixelId).orElseThrow(); + assertThat(result.getCrewId()).isEqualTo(2L); + assertThat(result.getUpdatedAt()).isEqualTo(LocalDateTime.of(2025, 10, 20, 16, 0)); + } + + @Test + @DisplayName("데드락 재현 - 교차 경로") + void deadlock_reproduction_test() throws Exception { + // given: 정반대 경로 (데드락 유발) + List forwardRoute = List.of( + new Coordinate(37.5650, 126.9770, LocalDateTime.of(2025, 10, 20, 14, 0)), + new Coordinate(37.5652, 126.9772, LocalDateTime.of(2025, 10, 20, 14, 1)), + new Coordinate(37.5654, 126.9774, LocalDateTime.of(2025, 10, 20, 14, 2)), + new Coordinate(37.5656, 126.9776, LocalDateTime.of(2025, 10, 20, 14, 3)), + new Coordinate(37.5658, 126.9778, LocalDateTime.of(2025, 10, 20, 14, 4)) + ); + + List reverseRoute = List.of( + new Coordinate(37.5658, 126.9778, LocalDateTime.of(2025, 10, 20, 15, 0)), + new Coordinate(37.5656, 126.9776, LocalDateTime.of(2025, 10, 20, 15, 1)), + new Coordinate(37.5654, 126.9774, LocalDateTime.of(2025, 10, 20, 15, 2)), + new Coordinate(37.5652, 126.9772, LocalDateTime.of(2025, 10, 20, 15, 3)), + new Coordinate(37.5650, 126.9770, LocalDateTime.of(2025, 10, 20, 15, 4)) + ); + + for (Coordinate coord : forwardRoute) { + int[] pixel = runningService.toPixel(coord.latitude(), coord.longitude()); + pixelRepository.saveAndFlush(new Pixel(new PixelId(pixel[0], pixel[1]), 99L, coord.timestamp())); + } + + // when + int attempts = 20; + AtomicInteger deadlockCount = new AtomicInteger(0); + AtomicInteger successCount = new AtomicInteger(0); + + System.out.println("\n=== 데드락 재현 테스트 (" + attempts + "회 시도) ==="); + + for (int i = 0; i < attempts; i++) { + CountDownLatch latch = new CountDownLatch(2); + CountDownLatch start = new CountDownLatch(1); + ExecutorService executor = Executors.newFixedThreadPool(2); + + AtomicInteger errorCount = new AtomicInteger(0); + + executor.submit(() -> { + latch.countDown(); + try { + start.await(); + runningService.processRunningRoute(1L, forwardRoute); + } catch (Exception e) { + errorCount.incrementAndGet(); + } + }); + + executor.submit(() -> { + latch.countDown(); + try { + start.await(); + runningService.processRunningRoute(2L, reverseRoute); + } catch (Exception e) { + errorCount.incrementAndGet(); + } + }); + + latch.await(); + start.countDown(); + executor.shutdown(); + + if (!executor.awaitTermination(10, TimeUnit.SECONDS) || errorCount.get() > 0) { + deadlockCount.incrementAndGet(); + executor.shutdownNow(); + } else { + successCount.incrementAndGet(); + } + } + + // then + System.out.println("성공: " + successCount.get() + "회"); + System.out.println("데드락/타임아웃: " + deadlockCount.get() + "회"); + System.out.println("데드락 발생률: " + String.format("%.1f%%", (deadlockCount.get() * 100.0 / attempts))); + System.out.println("=".repeat(60) + "\n"); + + if (deadlockCount.get() > 0) { + System.out.println("🔴 데드락 발생! 정렬 로직이 필요합니다."); + } + } + + @Test + @DisplayName("오래된 데이터는 업데이트하지 않음") + void skipOlderData_test() { + // given + int[] pixel = runningService.toPixel(37.5663, 126.9779); + PixelId pixelId = new PixelId(pixel[0], pixel[1]); + + Pixel existing = new Pixel(pixelId, 1L, LocalDateTime.of(2025, 10, 20, 16, 0)); + pixelRepository.saveAndFlush(existing); + + // when + Coordinate olderCoord = new Coordinate(37.5663, 126.9779, LocalDateTime.of(2025, 10, 20, 15, 0)); + runningService.processRunningRoute(2L, List.of(olderCoord)); + + // then + Pixel result = pixelRepository.findById(pixelId).orElseThrow(); + assertThat(result.getCrewId()).isEqualTo(1L); // 기존 crew 유지 + assertThat(result.getUpdatedAt()).isEqualTo(LocalDateTime.of(2025, 10, 20, 16, 0)); + } +}