From afedb8b5e8855553dd6c471227235b4fbe90dcd3 Mon Sep 17 00:00:00 2001 From: choiseoji Date: Sat, 18 Oct 2025 23:08:32 +0900 Subject: [PATCH 01/18] =?UTF-8?q?[#39]=20delete:=20RunningService=20?= =?UTF-8?q?=EC=9D=B8=ED=84=B0=ED=8E=98=EC=9D=B4=EC=8A=A4=20=EC=82=AD?= =?UTF-8?q?=EC=A0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/running/service/RunningService.java | 15 --------------- 1 file changed, 15 deletions(-) delete mode 100644 src/main/java/run/backend/domain/running/service/RunningService.java diff --git a/src/main/java/run/backend/domain/running/service/RunningService.java b/src/main/java/run/backend/domain/running/service/RunningService.java deleted file mode 100644 index bf35371..0000000 --- a/src/main/java/run/backend/domain/running/service/RunningService.java +++ /dev/null @@ -1,15 +0,0 @@ -package run.backend.domain.running.service; - -import run.backend.domain.running.dto.response.CrewRunningSummaryResponse; -import run.backend.domain.running.dto.response.RunningRecordResponse; - -public interface RunningService { - - void startRunning(Long eventId); - - RunningRecordResponse stopRunning(Long eventId); - - void joinRunning(Long memberId, Long eventId); - - CrewRunningSummaryResponse getCrewRunningSummary(Long crewId); -} From ec571c5aba15332c01b78a137fc7cd3f4bbd0914 Mon Sep 17 00:00:00 2001 From: choiseoji Date: Sat, 18 Oct 2025 23:11:05 +0900 Subject: [PATCH 02/18] =?UTF-8?q?[#39]=20feat:=20=EC=A2=8C=ED=91=9C?= =?UTF-8?q?=EC=99=80=20=ED=95=B4=EB=8B=B9=20=EC=A2=8C=ED=91=9C=EB=A5=BC=20?= =?UTF-8?q?=EC=A7=80=EB=82=98=EA=B0=84=20timestamp=EB=A5=BC=20=EB=B0=9B?= =?UTF-8?q?=EC=9D=84=20dto=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../backend/domain/running/dto/request/Coordinate.java | 10 ++++++++++ 1 file changed, 10 insertions(+) create mode 100644 src/main/java/run/backend/domain/running/dto/request/Coordinate.java 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 +) { +} From 8dc351097acc9f79945f2e2f25d924e78156c4e7 Mon Sep 17 00:00:00 2001 From: choiseoji Date: Sat, 18 Oct 2025 23:11:26 +0900 Subject: [PATCH 03/18] =?UTF-8?q?[#39]=20feat:=20=ED=95=B4=EB=8B=B9=20?= =?UTF-8?q?=EC=A2=8C=ED=91=9C=EB=A5=BC=20=EC=A7=80=EB=82=98=EA=B0=84=20cre?= =?UTF-8?q?w=EB=A5=BC=20=EC=A0=80=EC=9E=A5=ED=95=A0=20Pixel=20=EC=97=94?= =?UTF-8?q?=ED=8B=B0=ED=8B=B0=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../backend/domain/running/entity/Pixel.java | 33 +++++++++++++++++++ 1 file changed, 33 insertions(+) create mode 100644 src/main/java/run/backend/domain/running/entity/Pixel.java 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; + } +} From 1ab3cf9fddddac7d5593186c6f7d85ce6980e867 Mon Sep 17 00:00:00 2001 From: choiseoji Date: Sat, 18 Oct 2025 23:11:38 +0900 Subject: [PATCH 04/18] =?UTF-8?q?[#39]=20feat:=20=EC=A2=8C=ED=91=9C?= =?UTF-8?q?=EB=A1=9C=20=EB=B3=B5=ED=95=A9=ED=82=A4=20=EC=83=9D=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/running/entity/PixelId.java | 20 +++++++++++++++++++ 1 file changed, 20 insertions(+) create mode 100644 src/main/java/run/backend/domain/running/entity/PixelId.java 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..4f47b25 --- /dev/null +++ b/src/main/java/run/backend/domain/running/entity/PixelId.java @@ -0,0 +1,20 @@ +package run.backend.domain.running.entity; + +import jakarta.persistence.Embeddable; +import lombok.AccessLevel; +import lombok.NoArgsConstructor; + +import java.io.Serializable; + +@Embeddable +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class PixelId implements Serializable { + + private int x; + private int y; + + public PixelId(int x, int y) { + this.x = x; + this.y = y; + } +} From 2d7957b2302675bcb872734af443e773282933b6 Mon Sep 17 00:00:00 2001 From: choiseoji Date: Sat, 18 Oct 2025 23:11:51 +0900 Subject: [PATCH 05/18] =?UTF-8?q?[#39]=20feat:=20PixelRepository=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/running/repository/PixelRepository.java | 8 ++++++++ 1 file changed, 8 insertions(+) create mode 100644 src/main/java/run/backend/domain/running/repository/PixelRepository.java 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..d247b9f --- /dev/null +++ b/src/main/java/run/backend/domain/running/repository/PixelRepository.java @@ -0,0 +1,8 @@ +package run.backend.domain.running.repository; + +import org.springframework.data.jpa.repository.JpaRepository; +import run.backend.domain.running.entity.Pixel; +import run.backend.domain.running.entity.PixelId; + +public interface PixelRepository extends JpaRepository { +} From 4aea177d2b494129b9e74d4ca0e97933c8fa2c7a Mon Sep 17 00:00:00 2001 From: choiseoji Date: Sat, 18 Oct 2025 23:12:06 +0900 Subject: [PATCH 06/18] =?UTF-8?q?[#39]=20feat:=20=EB=9F=AC=EB=8B=9D=20?= =?UTF-8?q?=EC=A2=8C=ED=91=9C=20=EC=A0=80=EC=9E=A5=20=EC=BB=A8=ED=8A=B8?= =?UTF-8?q?=EB=A1=A4=EB=A0=81=20=EC=83=9D=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../running/controller/RunningController.java | 33 +++++++++++++++++++ 1 file changed, 33 insertions(+) create mode 100644 src/main/java/run/backend/domain/running/controller/RunningController.java 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<>("크루 러닝 좌표 저장 완료")); + } +} From 840687dc27bc6217f891dc58a7a4d54108ce2d3a Mon Sep 17 00:00:00 2001 From: choiseoji Date: Sat, 18 Oct 2025 23:12:24 +0900 Subject: [PATCH 07/18] =?UTF-8?q?[#39]=20feat:=20=EC=8B=A4=EC=A0=9C=20?= =?UTF-8?q?=EC=A2=8C=ED=91=9C=EB=A5=BC=20=EA=B8=B0=EC=A4=80=EC=9C=BC?= =?UTF-8?q?=EB=A1=9C=200~1000=20=ED=94=BD=EC=85=80=EB=A1=9C=20=EB=B3=80?= =?UTF-8?q?=ED=99=98=ED=95=98=EB=8A=94=20=EB=A1=9C=EC=A7=81=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../running/service/RunningService.java | 70 +++++++++++++++++++ 1 file changed, 70 insertions(+) create mode 100644 src/main/java/run/backend/domain/running/service/RunningService.java diff --git a/src/main/java/run/backend/domain/running/service/RunningService.java b/src/main/java/run/backend/domain/running/service/RunningService.java new file mode 100644 index 0000000..41f21e8 --- /dev/null +++ b/src/main/java/run/backend/domain/running/service/RunningService.java @@ -0,0 +1,70 @@ +package run.backend.domain.running.service; + +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; + +import java.util.ArrayList; +import java.util.List; + +@Service +@RequiredArgsConstructor +public class RunningService { + + public static final double SEOUL_AREA_NORTH = 37.8; + public static final double SEOUL_AREA_SOUTH = 37.2; + public static final double SEOUL_AREA_WEST = 126.5; + public static final double SEOUL_AREA_EAST = 127.3; + + public static final int GRID_WIDTH = 1000; + public static final int GRID_HEIGHT = 1000; + + private final PixelRepository pixelRepository; + + @Transactional + public void processRunningRoute(Long crewId, List coordinates) { + List pixelsToSave = new ArrayList<>(); + + for (Coordinate coord : coordinates) { + int[] pixelPos = toPixel(coord.latitude(), coord.longitude()); + PixelId id = new PixelId(pixelPos[0], pixelPos[1]); + + Pixel pixel = pixelRepository.findById(id) + .orElseGet(() -> new Pixel(id, crewId, coord.timestamp())); + + // 기존 Pixel의 updatedAt이 더 최신이면 skip + if (pixel.getUpdatedAt() != null && pixel.getUpdatedAt().isAfter(coord.timestamp())) { + continue; + } + + pixel.updateCrew(crewId, coord.timestamp()); + pixelsToSave.add(pixel); + } + + pixelRepository.saveAll(pixelsToSave); + } + + // latitude : 위도(y), longitude : 경도(x) + 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}; + } +} From bb69df345e778081dcd626dcbeb95eb508b83982 Mon Sep 17 00:00:00 2001 From: choiseoji Date: Sat, 18 Oct 2025 23:12:35 +0900 Subject: [PATCH 08/18] =?UTF-8?q?[#39]=20feat:=20Running=20=EA=B4=80?= =?UTF-8?q?=EB=A0=A8=20=EC=98=88=EC=99=B8=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../running/exception/RunningErrorCode.java | 15 +++++++++++++++ .../running/exception/RunningException.java | 16 ++++++++++++++++ 2 files changed, 31 insertions(+) create mode 100644 src/main/java/run/backend/domain/running/exception/RunningErrorCode.java create mode 100644 src/main/java/run/backend/domain/running/exception/RunningException.java 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); + } + } +} From 37b005a011326f632b285f9cb09793739db9a3f1 Mon Sep 17 00:00:00 2001 From: choiseoji Date: Mon, 20 Oct 2025 16:44:03 +0900 Subject: [PATCH 09/18] =?UTF-8?q?[#39]=20fix:=20=EB=8D=94=20=EC=9E=90?= =?UTF-8?q?=EC=84=B8=ED=95=9C=20=EA=B0=92=EC=9C=BC=EB=A1=9C=20=EC=A2=8C?= =?UTF-8?q?=ED=91=9C=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/running/service/RunningService.java | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) 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 41f21e8..88dfe93 100644 --- a/src/main/java/run/backend/domain/running/service/RunningService.java +++ b/src/main/java/run/backend/domain/running/service/RunningService.java @@ -16,13 +16,13 @@ @RequiredArgsConstructor public class RunningService { - public static final double SEOUL_AREA_NORTH = 37.8; - public static final double SEOUL_AREA_SOUTH = 37.2; - public static final double SEOUL_AREA_WEST = 126.5; - public static final double SEOUL_AREA_EAST = 127.3; + 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; - public static final int GRID_WIDTH = 1000; - public static final int GRID_HEIGHT = 1000; + public static final int GRID_WIDTH = 939; + public static final int GRID_HEIGHT = 671; private final PixelRepository pixelRepository; From 66aa427f44a8f472e04eedbaeec30378cfea21c8 Mon Sep 17 00:00:00 2001 From: choiseoji Date: Tue, 21 Oct 2025 00:52:51 +0900 Subject: [PATCH 10/18] =?UTF-8?q?[#39]=20test:=20=EB=8F=99=EC=8B=9C?= =?UTF-8?q?=EC=84=B1=20=EB=AC=B8=EC=A0=9C=20=ED=99=95=EC=9D=B8=20=EA=B0=80?= =?UTF-8?q?=EB=8A=A5=ED=95=9C=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=BD=94?= =?UTF-8?q?=EB=93=9C=20=EC=9E=91=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../running/service/RunningServiceTest.java | 86 +++++++++++++++++++ 1 file changed, 86 insertions(+) create mode 100644 src/test/java/run/backend/domain/running/service/RunningServiceTest.java 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..eeeee0a --- /dev/null +++ b/src/test/java/run/backend/domain/running/service/RunningServiceTest.java @@ -0,0 +1,86 @@ +package run.backend.domain.running.service; + +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.transaction.PlatformTransactionManager; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.transaction.support.TransactionTemplate; +import run.backend.domain.running.entity.Pixel; +import run.backend.domain.running.entity.PixelId; +import run.backend.domain.running.repository.PixelRepository; + +import java.time.LocalDateTime; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.TimeUnit; + +@SpringBootTest +@Transactional(propagation = Propagation.NOT_SUPPORTED) +public class RunningServiceTest { + + @Autowired + private PixelRepository pixelRepository; + + @Autowired + private PlatformTransactionManager transactionManager; + + @Test + void 동시성_테스트_A가_B를_덮어쓰는_상황_확인() throws InterruptedException { + // given + // 초기 데이터 셋팅 : 99L 크루가 14시에 해당 픽셀을 지남 + PixelId id = new PixelId(467, 478); + Pixel initial = new Pixel(id, 99L, LocalDateTime.of(2025, 10, 20, 14, 0)); + pixelRepository.save(initial); + + // when + ExecutorService executor = Executors.newFixedThreadPool(2); + CountDownLatch latchReady = new CountDownLatch(2); // 두 스레드가 조회까지 끝났음을 알리는 역할 + CountDownLatch latchStart = new CountDownLatch(1); // 동시에 시작하라는 신호 알리는 역할 + + Runnable taskA = () -> { + TransactionTemplate tx = new TransactionTemplate(transactionManager); + tx.execute(status -> { + Pixel pixel = pixelRepository.findById(id).get(); + latchReady.countDown(); + try { + latchStart.await(); + Thread.sleep(300); // A가 늦게 commit되도록 + } catch (InterruptedException ignored) {} + pixel.updateCrew(1L, LocalDateTime.of(2025, 10, 20, 15, 0)); + pixelRepository.save(pixel); + return null; + }); + }; + + Runnable taskB = () -> { + TransactionTemplate tx = new TransactionTemplate(transactionManager); + tx.execute(status -> { + Pixel pixel = pixelRepository.findById(id).get(); + latchReady.countDown(); + try { + latchStart.await(); + } catch (InterruptedException ignored) {} + pixel.updateCrew(2L, LocalDateTime.of(2025, 10, 20, 16, 0)); + pixelRepository.save(pixel); + return null; + }); + }; + + executor.submit(taskA); + executor.submit(taskB); + + latchReady.await(); + latchStart.countDown(); + executor.shutdown(); + executor.awaitTermination(5, TimeUnit.SECONDS); + + // then + // B가 출력이 되어야 하는데 A가 더 마지막에 commit 했으므로 A로 덮어씀 + Pixel result = pixelRepository.findById(id).get(); + System.out.println("최종 crewId: " + result.getCrewId()); + System.out.println("최종 updatedAt: " + result.getUpdatedAt()); + } +} From 4932372962bc16ad25652dbc642d2443fb7311c1 Mon Sep 17 00:00:00 2001 From: choiseoji Date: Wed, 22 Oct 2025 16:58:01 +0900 Subject: [PATCH 11/18] =?UTF-8?q?[#39]=20feat:=20=EB=B9=84=EA=B4=80?= =?UTF-8?q?=EC=A0=81=20=EB=9D=BD=20=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/running/repository/PixelRepository.java | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/src/main/java/run/backend/domain/running/repository/PixelRepository.java b/src/main/java/run/backend/domain/running/repository/PixelRepository.java index d247b9f..4b16b88 100644 --- a/src/main/java/run/backend/domain/running/repository/PixelRepository.java +++ b/src/main/java/run/backend/domain/running/repository/PixelRepository.java @@ -1,8 +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); } From af128861a9d6d3302e2e45c6596f8d1ce7d91c4d Mon Sep 17 00:00:00 2001 From: choiseoji Date: Wed, 22 Oct 2025 16:58:05 +0900 Subject: [PATCH 12/18] =?UTF-8?q?[#39]=20feat:=20=EB=B9=84=EA=B4=80?= =?UTF-8?q?=EC=A0=81=20=EB=9D=BD=20=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/run/backend/domain/running/service/RunningService.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 88dfe93..0b9de5c 100644 --- a/src/main/java/run/backend/domain/running/service/RunningService.java +++ b/src/main/java/run/backend/domain/running/service/RunningService.java @@ -34,7 +34,7 @@ public void processRunningRoute(Long crewId, List coordinates) { int[] pixelPos = toPixel(coord.latitude(), coord.longitude()); PixelId id = new PixelId(pixelPos[0], pixelPos[1]); - Pixel pixel = pixelRepository.findById(id) + Pixel pixel = pixelRepository.findByIdWithLock(id) .orElseGet(() -> new Pixel(id, crewId, coord.timestamp())); // 기존 Pixel의 updatedAt이 더 최신이면 skip From 017f0d4828a54f0354b96a02c78b01aa2f910082 Mon Sep 17 00:00:00 2001 From: choiseoji Date: Wed, 22 Oct 2025 16:58:22 +0900 Subject: [PATCH 13/18] =?UTF-8?q?[#39]=20test:=20=EB=B9=84=EA=B4=80?= =?UTF-8?q?=EC=A0=81=20=EB=9E=B5=20=EC=A0=81=EC=9A=A9=ED=95=B4=EC=84=9C=20?= =?UTF-8?q?=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=BD=94=EB=93=9C=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../running/service/RunningServiceTest.java | 53 +++++++------------ 1 file changed, 19 insertions(+), 34 deletions(-) diff --git a/src/test/java/run/backend/domain/running/service/RunningServiceTest.java b/src/test/java/run/backend/domain/running/service/RunningServiceTest.java index eeeee0a..4a469ad 100644 --- a/src/test/java/run/backend/domain/running/service/RunningServiceTest.java +++ b/src/test/java/run/backend/domain/running/service/RunningServiceTest.java @@ -6,17 +6,20 @@ import org.springframework.transaction.PlatformTransactionManager; import org.springframework.transaction.annotation.Propagation; import org.springframework.transaction.annotation.Transactional; -import org.springframework.transaction.support.TransactionTemplate; +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; 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 static org.assertj.core.api.Assertions.assertThat; + @SpringBootTest @Transactional(propagation = Propagation.NOT_SUPPORTED) public class RunningServiceTest { @@ -25,48 +28,32 @@ public class RunningServiceTest { private PixelRepository pixelRepository; @Autowired - private PlatformTransactionManager transactionManager; + private RunningService runningService; @Test - void 동시성_테스트_A가_B를_덮어쓰는_상황_확인() throws InterruptedException { + void 비관적락_적용된_processRunningRoute_동시성_검증() throws Exception { // given - // 초기 데이터 셋팅 : 99L 크루가 14시에 해당 픽셀을 지남 PixelId id = new PixelId(467, 478); Pixel initial = new Pixel(id, 99L, LocalDateTime.of(2025, 10, 20, 14, 0)); pixelRepository.save(initial); - // when + Coordinate coordA = new Coordinate(37.5, 126.9, LocalDateTime.of(2025, 10, 20, 15, 0)); + Coordinate coordB = new Coordinate(37.5, 126.9, LocalDateTime.of(2025, 10, 20, 16, 0)); + + CountDownLatch latchReady = new CountDownLatch(2); + CountDownLatch latchStart = new CountDownLatch(1); ExecutorService executor = Executors.newFixedThreadPool(2); - CountDownLatch latchReady = new CountDownLatch(2); // 두 스레드가 조회까지 끝났음을 알리는 역할 - CountDownLatch latchStart = new CountDownLatch(1); // 동시에 시작하라는 신호 알리는 역할 Runnable taskA = () -> { - TransactionTemplate tx = new TransactionTemplate(transactionManager); - tx.execute(status -> { - Pixel pixel = pixelRepository.findById(id).get(); - latchReady.countDown(); - try { - latchStart.await(); - Thread.sleep(300); // A가 늦게 commit되도록 - } catch (InterruptedException ignored) {} - pixel.updateCrew(1L, LocalDateTime.of(2025, 10, 20, 15, 0)); - pixelRepository.save(pixel); - return null; - }); + latchReady.countDown(); + try { latchStart.await(); } catch (InterruptedException ignored) {} + runningService.processRunningRoute(1L, List.of(coordA)); }; Runnable taskB = () -> { - TransactionTemplate tx = new TransactionTemplate(transactionManager); - tx.execute(status -> { - Pixel pixel = pixelRepository.findById(id).get(); - latchReady.countDown(); - try { - latchStart.await(); - } catch (InterruptedException ignored) {} - pixel.updateCrew(2L, LocalDateTime.of(2025, 10, 20, 16, 0)); - pixelRepository.save(pixel); - return null; - }); + latchReady.countDown(); + try { latchStart.await(); } catch (InterruptedException ignored) {} + runningService.processRunningRoute(2L, List.of(coordB)); }; executor.submit(taskA); @@ -77,10 +64,8 @@ public class RunningServiceTest { executor.shutdown(); executor.awaitTermination(5, TimeUnit.SECONDS); - // then - // B가 출력이 되어야 하는데 A가 더 마지막에 commit 했으므로 A로 덮어씀 Pixel result = pixelRepository.findById(id).get(); - System.out.println("최종 crewId: " + result.getCrewId()); - System.out.println("최종 updatedAt: " + result.getUpdatedAt()); + assertThat(result.getCrewId()).isEqualTo(2L); + assertThat(result.getUpdatedAt()).isEqualTo(LocalDateTime.of(2025, 10, 20, 16, 0)); } } From d06a42570dba7d7781d97a5edf3313af193d5dc2 Mon Sep 17 00:00:00 2001 From: west_east Date: Thu, 20 Nov 2025 15:41:01 +0900 Subject: [PATCH 14/18] =?UTF-8?q?fix:=20=EC=A2=8C=ED=91=9C=20=EC=A0=80?= =?UTF-8?q?=EC=9E=A5=20=EB=8D=B0=EB=93=9C=EB=9D=BD=20=EB=AC=B8=EC=A0=9C=20?= =?UTF-8?q?=ED=95=B4=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/running/entity/PixelId.java | 15 +- .../running/service/RunningService.java | 23 ++- .../backend/config/TestSecurityConfig.java | 22 +++ .../running/service/RunningServiceTest.java | 185 +++++++++++++++--- 4 files changed, 216 insertions(+), 29 deletions(-) create mode 100644 src/test/java/run/backend/config/TestSecurityConfig.java diff --git a/src/main/java/run/backend/domain/running/entity/PixelId.java b/src/main/java/run/backend/domain/running/entity/PixelId.java index 4f47b25..b55c8c2 100644 --- a/src/main/java/run/backend/domain/running/entity/PixelId.java +++ b/src/main/java/run/backend/domain/running/entity/PixelId.java @@ -2,13 +2,17 @@ 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) -public class PixelId implements Serializable { +@EqualsAndHashCode +public class PixelId implements Serializable, Comparable { private int x; private int y; @@ -17,4 +21,13 @@ 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/service/RunningService.java b/src/main/java/run/backend/domain/running/service/RunningService.java index 0b9de5c..da114ad 100644 --- a/src/main/java/run/backend/domain/running/service/RunningService.java +++ b/src/main/java/run/backend/domain/running/service/RunningService.java @@ -10,7 +10,9 @@ import run.backend.domain.running.repository.PixelRepository; import java.util.ArrayList; +import java.util.LinkedHashMap; import java.util.List; +import java.util.Map; @Service @RequiredArgsConstructor @@ -28,12 +30,29 @@ public class RunningService { @Transactional public void processRunningRoute(Long crewId, List coordinates) { - List pixelsToSave = new ArrayList<>(); - + // 1. 좌표를 픽셀로 변환하고 매핑 정보 저장 (순서 유지) + 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); + } + } + + // 2. 픽셀 ID를 정렬 (데드락 방지) + List sortedPixelIds = pixelToCoordMap.keySet().stream() + .sorted() + .toList(); + // 3. 정렬된 순서로 락 획득 및 업데이트 + 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())); 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 index 4a469ad..4d767dd 100644 --- a/src/test/java/run/backend/domain/running/service/RunningServiceTest.java +++ b/src/test/java/run/backend/domain/running/service/RunningServiceTest.java @@ -1,9 +1,23 @@ 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.transaction.PlatformTransactionManager; +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; @@ -11,18 +25,14 @@ import run.backend.domain.running.entity.PixelId; import run.backend.domain.running.repository.PixelRepository; -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 static org.assertj.core.api.Assertions.assertThat; - @SpringBootTest +@ActiveProfiles("test") @Transactional(propagation = Propagation.NOT_SUPPORTED) -public class RunningServiceTest { +@Import(run.backend.config.TestSecurityConfig.class) +class RunningServiceTest { + + @MockBean + private ClientRegistrationRepository clientRegistrationRepository; @Autowired private PixelRepository pixelRepository; @@ -30,42 +40,165 @@ public class RunningServiceTest { @Autowired private RunningService runningService; + @BeforeEach + void setUp() { + pixelRepository.deleteAll(); + } + @Test - void 비관적락_적용된_processRunningRoute_동시성_검증() throws Exception { - // given - PixelId id = new PixelId(467, 478); - Pixel initial = new Pixel(id, 99L, LocalDateTime.of(2025, 10, 20, 14, 0)); - pixelRepository.save(initial); + @DisplayName("비관적 락 - 동시 요청 시 순차 처리로 최신 데이터만 저장") + void pessimisticLock_concurrency_test() throws Exception { + // given: 서울 시청 근처 좌표 (37.5663, 126.9779) + int[] pixel = runningService.toPixel(37.5663, 126.9779); + PixelId pixelId = new PixelId(pixel[0], pixel[1]); - Coordinate coordA = new Coordinate(37.5, 126.9, LocalDateTime.of(2025, 10, 20, 15, 0)); - Coordinate coordB = new Coordinate(37.5, 126.9, LocalDateTime.of(2025, 10, 20, 16, 0)); + // 초기 데이터: crew 99, 14:00 + 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(); } catch (InterruptedException ignored) {} - runningService.processRunningRoute(1L, List.of(coordA)); + try { + latchStart.await(); + runningService.processRunningRoute(1L, List.of(coordA)); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } }; Runnable taskB = () -> { latchReady.countDown(); - try { latchStart.await(); } catch (InterruptedException ignored) {} - runningService.processRunningRoute(2L, List.of(coordB)); + 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(); + latchReady.await(); // 두 스레드 준비 대기 + latchStart.countDown(); // 동시 시작 executor.shutdown(); - executor.awaitTermination(5, TimeUnit.SECONDS); + executor.awaitTermination(10, TimeUnit.SECONDS); - Pixel result = pixelRepository.findById(id).get(); + // then: 가장 최신 시간(16:00)의 crew 2가 저장되어야 함 + 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: 20번 반복 (데드락 발생 확률 높임) + 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)); + } } From ce3a35d40713b27925ca3b5d53aaf90fb936c76a Mon Sep 17 00:00:00 2001 From: west_east Date: Thu, 20 Nov 2025 15:42:11 +0900 Subject: [PATCH 15/18] =?UTF-8?q?chore:=20=EC=A3=BC=EC=84=9D=20=EC=A0=9C?= =?UTF-8?q?=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../running/service/RunningServiceTest.java | 21 ++++++++----------- 1 file changed, 9 insertions(+), 12 deletions(-) diff --git a/src/test/java/run/backend/domain/running/service/RunningServiceTest.java b/src/test/java/run/backend/domain/running/service/RunningServiceTest.java index 4d767dd..10b9aed 100644 --- a/src/test/java/run/backend/domain/running/service/RunningServiceTest.java +++ b/src/test/java/run/backend/domain/running/service/RunningServiceTest.java @@ -48,19 +48,17 @@ void setUp() { @Test @DisplayName("비관적 락 - 동시 요청 시 순차 처리로 최신 데이터만 저장") void pessimisticLock_concurrency_test() throws Exception { - // given: 서울 시청 근처 좌표 (37.5663, 126.9779) + // given int[] pixel = runningService.toPixel(37.5663, 126.9779); PixelId pixelId = new PixelId(pixel[0], pixel[1]); - // 초기 데이터: crew 99, 14:00 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: 두 스레드가 동시에 업데이트 시도 + // when CountDownLatch latchReady = new CountDownLatch(2); CountDownLatch latchStart = new CountDownLatch(1); ExecutorService executor = Executors.newFixedThreadPool(2); @@ -88,12 +86,12 @@ void pessimisticLock_concurrency_test() throws Exception { executor.submit(taskA); executor.submit(taskB); - latchReady.await(); // 두 스레드 준비 대기 - latchStart.countDown(); // 동시 시작 + latchReady.await(); + latchStart.countDown(); executor.shutdown(); executor.awaitTermination(10, TimeUnit.SECONDS); - // then: 가장 최신 시간(16:00)의 crew 2가 저장되어야 함 + // then Pixel result = pixelRepository.findById(pixelId).orElseThrow(); assertThat(result.getCrewId()).isEqualTo(2L); assertThat(result.getUpdatedAt()).isEqualTo(LocalDateTime.of(2025, 10, 20, 16, 0)); @@ -119,13 +117,12 @@ void deadlock_reproduction_test() throws Exception { 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: 20번 반복 (데드락 발생 확률 높임) + // when int attempts = 20; AtomicInteger deadlockCount = new AtomicInteger(0); AtomicInteger successCount = new AtomicInteger(0); @@ -171,7 +168,7 @@ void deadlock_reproduction_test() throws Exception { } } - // then: 결과 출력 + // then System.out.println("성공: " + successCount.get() + "회"); System.out.println("데드락/타임아웃: " + deadlockCount.get() + "회"); System.out.println("데드락 발생률: " + String.format("%.1f%%", (deadlockCount.get() * 100.0 / attempts))); @@ -192,11 +189,11 @@ void skipOlderData_test() { Pixel existing = new Pixel(pixelId, 1L, LocalDateTime.of(2025, 10, 20, 16, 0)); pixelRepository.saveAndFlush(existing); - // when: 더 오래된 시간으로 업데이트 시도 + // when Coordinate olderCoord = new Coordinate(37.5663, 126.9779, LocalDateTime.of(2025, 10, 20, 15, 0)); runningService.processRunningRoute(2L, List.of(olderCoord)); - // then: 업데이트되지 않음 + // then Pixel result = pixelRepository.findById(pixelId).orElseThrow(); assertThat(result.getCrewId()).isEqualTo(1L); // 기존 crew 유지 assertThat(result.getUpdatedAt()).isEqualTo(LocalDateTime.of(2025, 10, 20, 16, 0)); From c2d72833032ce46d2f1a0f82f4334a0552cf9694 Mon Sep 17 00:00:00 2001 From: west_east Date: Thu, 20 Nov 2025 15:55:13 +0900 Subject: [PATCH 16/18] =?UTF-8?q?chore:=20=EC=A3=BC=EC=84=9D=20=EC=A0=9C?= =?UTF-8?q?=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../backend/domain/running/service/RunningService.java | 8 -------- 1 file changed, 8 deletions(-) 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 da114ad..b068097 100644 --- a/src/main/java/run/backend/domain/running/service/RunningService.java +++ b/src/main/java/run/backend/domain/running/service/RunningService.java @@ -30,25 +30,21 @@ public class RunningService { @Transactional public void processRunningRoute(Long crewId, List coordinates) { - // 1. 좌표를 픽셀로 변환하고 매핑 정보 저장 (순서 유지) 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); } } - // 2. 픽셀 ID를 정렬 (데드락 방지) List sortedPixelIds = pixelToCoordMap.keySet().stream() .sorted() .toList(); - // 3. 정렬된 순서로 락 획득 및 업데이트 List pixelsToSave = new ArrayList<>(); for (PixelId id : sortedPixelIds) { Coordinate coord = pixelToCoordMap.get(id); @@ -56,7 +52,6 @@ public void processRunningRoute(Long crewId, List coordinates) { Pixel pixel = pixelRepository.findByIdWithLock(id) .orElseGet(() -> new Pixel(id, crewId, coord.timestamp())); - // 기존 Pixel의 updatedAt이 더 최신이면 skip if (pixel.getUpdatedAt() != null && pixel.getUpdatedAt().isAfter(coord.timestamp())) { continue; } @@ -68,7 +63,6 @@ public void processRunningRoute(Long crewId, List coordinates) { pixelRepository.saveAll(pixelsToSave); } - // latitude : 위도(y), longitude : 경도(x) public int[] toPixel(double latitude, double longitude) { if (latitude < SEOUL_AREA_SOUTH || latitude > SEOUL_AREA_NORTH || @@ -76,11 +70,9 @@ public int[] toPixel(double latitude, double longitude) { 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); From b9d1bdbe932a7e4bb8e336fffcc055585ea2c090 Mon Sep 17 00:00:00 2001 From: west_east Date: Thu, 20 Nov 2025 16:10:19 +0900 Subject: [PATCH 17/18] =?UTF-8?q?test:=20GitHub=20Actions=EC=97=90=20MySQL?= =?UTF-8?q?=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20=ED=99=98=EA=B2=BD=20=EA=B5=AC?= =?UTF-8?q?=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/jacoco.yml | 52 ++++++++++++++++++++++++++++++++++++ 1 file changed, 52 insertions(+) diff --git a/.github/workflows/jacoco.yml b/.github/workflows/jacoco.yml index 1258b86..36e505c 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: test_jwt_secret_key_for_ci_testing_only_1234567890 + 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 From 527de3a89da3e30c0e8f161de5e96d657610c101 Mon Sep 17 00:00:00 2001 From: west_east Date: Thu, 20 Nov 2025 16:15:13 +0900 Subject: [PATCH 18/18] =?UTF-8?q?test:=20GitHub=20Actions=EC=97=90=20MySQL?= =?UTF-8?q?=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20=ED=99=98=EA=B2=BD=20=EA=B5=AC?= =?UTF-8?q?=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/jacoco.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/jacoco.yml b/.github/workflows/jacoco.yml index 36e505c..d607051 100644 --- a/.github/workflows/jacoco.yml +++ b/.github/workflows/jacoco.yml @@ -60,7 +60,7 @@ jobs: show-sql: true jwt: - secret: test_jwt_secret_key_for_ci_testing_only_1234567890 + secret: dGVzdF9qd3Rfc2VjcmV0X2tleV9mb3JfY2lfdGVzdGluZ19vbmx5XzEyMzQ1Njc4OTA= access-token-expire-time: 1800000 refresh-token-expire-time: 1209600000