Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
89 commits
Select commit Hold shift + click to select a range
81d0e1a
feat: redis 설정 #34
mungsil Jan 26, 2025
773e783
feat: 테스트용 redis 설정 #34
mungsil Jan 26, 2025
57ab209
fix: 외부 API 종류 및 캐싱 방법 변경 #34
mungsil Jan 28, 2025
ab9d8be
fix: 이동 시간 및 거리 정보를 가져오도록 Google API 호출 기능 구현 #34
mungsil Jan 28, 2025
8553c7c
fix: ODsay API 호출 간격 준수 방식 변경 및 429 에러 재시도 기능 구현 #34
mungsil Jan 28, 2025
678f193
fix: 리팩토링 및 API 호출 최적화 #34
mungsil Jan 28, 2025
1aed56b
feat: 테스트 프로파일 및 의존성 작성 #34
mungsil Jan 28, 2025
3fb1711
refactor: 컨버터 경로 변경 #34
mungsil Jan 28, 2025
5a4c259
fix: Location -> Coordinate class 로 변경 #35
mungsil Feb 19, 2025
4417c24
feat: Location class 구현 #35
mungsil Feb 19, 2025
1af9a98
feat: 경로 최적화 알고리즘에 전략 패턴 도입 #35
mungsil Feb 19, 2025
ae5f303
feat: 가상 스레드 enabled true 설정 #36
mungsil Feb 24, 2025
5b897f1
feat: RestClient RequestFactory 구현체 변경 #36
mungsil Feb 24, 2025
22fb88f
refactor: Coordinate 클래스를 이용하여 좌표 표현 #36
mungsil Feb 24, 2025
9a223d6
refactor: 거리, 시간 필드에 단위 표기 #36
mungsil Feb 24, 2025
0eb1d00
feat: virtualThreadExecutor 빈 등록, rate limiter 의존성 추가 #36
mungsil Feb 24, 2025
50f5e66
refactor: 클래스 이름 및 경로 변경 #36
mungsil Feb 24, 2025
c7b5f01
refactor: 컨트롤러 응답 DTO의 Wrapper DTO class 생성 및 적용 #36
mungsil Feb 24, 2025
97d1a94
refactor: Location class 설명 작성 #36
mungsil Feb 24, 2025
26327f4
fix: 생략되어 있던 import 추가 #36
mungsil Feb 24, 2025
e204d99
refactor: Adaptor 패턴을 통해 google API를 호출하도록 변경 #36
mungsil Feb 24, 2025
eb42224
feat: google API Client, Adaptor Test 작성 #36
mungsil Feb 24, 2025
3282a87
fix: ODsay API 호출 속도 조절 및 역직렬화 방식 변경 #36
mungsil Feb 24, 2025
13346e0
fix: TransitRoute class에 출발지, 도착지 필드 추가 작성 #36
mungsil Feb 24, 2025
496263e
refactor: Adaptor 패턴을 통해 ODsay API 호출하도록 변경 #36
mungsil Feb 24, 2025
24e9bbc
refactor: 클래스 이름 수정 #36
mungsil Feb 24, 2025
d2ae439
feat: ODsay API client, adaptor test 작성 #36
mungsil Feb 24, 2025
6718807
refactor: 성능 및 유지보수성 개선을 위한 TravelService 수정 #36
mungsil Feb 24, 2025
16c7a97
refactor: 불필요한 import 삭제 #36
mungsil Feb 24, 2025
866ee43
fix: 429 error 발생하지 않도록 odsay API 호출 간격 조정 #36
mungsil Feb 25, 2025
d14b808
feat: 플랫폼 스레드 사용 시 기본 ExecutorService 설정 추가 #36
mungsil Feb 25, 2025
f2b5eb5
refactor: 변수명 수정 #36
mungsil Feb 26, 2025
6f64df6
feat: 불필요한 코드 주석 처리 #36
mungsil Feb 28, 2025
6d0e1f3
feat: 백트래킹 알고리즘을 forkJoinPool에서 실행하도록 변경 #36
mungsil Feb 28, 2025
332f385
refactor: CompletableFuture 제거 - 외부 API 에러 핸들링 로직을 어댑터로 이동 #36
mungsil Feb 28, 2025
699a488
refactor: 글로벌 가상 스레드 설정에서 명시적 VirtualThreadExecutor 사용으로 전환 #36
mungsil Mar 2, 2025
ad6d832
Merge pull request #1 from mungsil/fix/#36
mungsil Mar 5, 2025
df70820
docs: README에 시스템 아키텍처 다이어그램 추가
mungsil Mar 26, 2025
1a35af7
docs: 시스템 아키텍처 다이어그램 수정
mungsil Mar 27, 2025
672131e
docs: 시스템 아키텍처 다이어그램 수정
mungsil Mar 30, 2025
d9c056d
feat : 가상스레드 설정
mungsil Oct 22, 2025
bc8ffbe
feat : 세마포어 적용
mungsil Oct 22, 2025
6f990ca
refactor: 패키지 위치 변경
mungsil Oct 22, 2025
001d44b
refactor: 패키지 위치 변경
mungsil Oct 22, 2025
1ae6f2c
refactor: 배포 스크립트 수정
mungsil Oct 27, 2025
85eb452
시스템 아키텍처 수정
mungsil Oct 27, 2025
caf5466
fix: 세마포어 방식의 단점으로 인해 CompletableFuture 방식으로 전환
mungsil Oct 27, 2025
f620dc9
refactor: 인터페이스 이름 수정
mungsil Oct 27, 2025
7e592a2
refactor: 클래스 이름 수정
mungsil Oct 27, 2025
e3bb886
Merge pull request #3 from mungsil/fix/odsay-rate-limit-async
mungsil Oct 27, 2025
941fc43
refactor: 미사용 import문 정리
mungsil Oct 27, 2025
333401a
Merge pull request #4 from mungsil/test/deploy
mungsil Oct 27, 2025
8007f9e
Remove 'mingmingmon' from code owners list
mungsil Oct 27, 2025
cd5f6fb
Merge pull request #5 from mungsil/test/deploy
mungsil Oct 27, 2025
2a2a1db
refactor: V2 API와의 구분을 위해 기존 컨트롤러명에 버전 명시
mungsil Oct 31, 2025
550a343
refactor: V2 API와의 구분을 위해 기존 서비스명에 V1 버전 추가
mungsil Oct 31, 2025
681629b
feat: version 2 API controller 뼈대 구현
mungsil Oct 31, 2025
df26cfa
refactor: 명료한 구분을 위해 version 1 패키지 분리 및 이동
mungsil Oct 31, 2025
2f68ae2
feat: redis hash 자료구조 사용을 위해 config 클래스 작성
mungsil Oct 31, 2025
cc81c59
refactor: 패키지 위치 이동
mungsil Oct 31, 2025
5ea1676
refactor: 패키지 위치 이동
mungsil Oct 31, 2025
c1b2b5b
refactor: 패키지 위치 이동
mungsil Oct 31, 2025
d1836ed
refactor: 임시 커밋
mungsil Nov 4, 2025
8b65523
feat: 프로퍼티 스캔 설정 추가
mungsil Nov 12, 2025
2f70e8f
feat: fcm 및 테스트 관련 의존성 추가
mungsil Nov 12, 2025
0b7ef35
feat: FCM 알림 전송을 위한 google credentials 설정
mungsil Nov 12, 2025
9b0c75e
feat: 익명 유저 생성 및 조회 기능 구현
mungsil Nov 12, 2025
79f0f22
log: '방문 장소 선정 완료' 이벤트에 대한 소비자 구현 시도 및 중단
mungsil Nov 22, 2025
0fcd6ea
feat: course 도메인 모델 수정
mungsil Nov 23, 2025
042a03e
feat: course 도메인 모델 수정
mungsil Nov 23, 2025
49ca9a8
archive: FCM 알림 전용 스트림 사용을 위한 엔트리 정의
mungsil Nov 29, 2025
298b6a1
config: lombock을 이용한 DI에서 Qualifier 어노테이션을 사용할 수 있도록 lombock 설정 추가
mungsil Dec 1, 2025
2ac44ce
feat: API 호출 제한 정책이 유발하는 병목 현상의 개선을 위해 API key pooling 전략 구현, API 응답 …
mungsil Dec 3, 2025
01e8486
feat: 설계 의도와 다르게 병목 현상 해결 불가 -> Deprecated 처리
mungsil Dec 3, 2025
d549348
feat: logback을 이용하여 로그 파일 저장
mungsil Dec 3, 2025
24a453f
feat: travelSpot 도메인 객체 구현
mungsil Dec 3, 2025
a09ad2d
archive: 미사용 코드 보관
mungsil Dec 4, 2025
abcd4df
chore: 미사용 코드 삭제
mungsil Dec 4, 2025
f4ef8cd
archive: 배치 기반 이벤트 처리 코드 삭제에 따른 코드 삭제 전 보관
mungsil Dec 4, 2025
77e520b
chore: 미사용 코드 삭제
mungsil Dec 4, 2025
54cadb3
archive: dead letter 조회 및 처리 로직 구현
mungsil Dec 8, 2025
e8eb947
archive: dead letter 전용 예외 클래스 구현
mungsil Dec 8, 2025
d8500ef
feat: push 모델로의 전환 및 외부 API 호출 속도 제한을 통한 안정성 향상
mungsil Dec 22, 2025
36e966f
Merge remote-tracking branch 'origin/develop' into fix/odsay/stateful
mungsil Dec 22, 2025
d1ff189
feat: 멱등성 보장을 위해 엔티티 아이디를 entry ID로 변경
mungsil Dec 27, 2025
334bfc9
feat: 유니크 제약 조건 추가
mungsil Dec 27, 2025
8fae4fa
feat: 이벤트 발행 및 알림 전송 누락 방지를 위한 Outbox 패턴 적용
mungsil Dec 29, 2025
afd68b3
chore: 미사용 메서드 제거
mungsil Dec 29, 2025
fbdf6ab
feat: 스케쥴러 어노테이션 동작을 위한 설정 클래스 추가
mungsil Dec 29, 2025
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
2 changes: 1 addition & 1 deletion .github/workflows/require-codeowners-approval.yml
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ jobs:
}
});

const codeOwners = ['mingmingmon', 'mungsil'];
const codeOwners = ['mungsil'];
const isAuthorCodeOwner = codeOwners.includes(prAuthor);

let requiredApprovals = [];
Expand Down
6 changes: 6 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,12 @@

No Car 서버는 **자가용 없이**도 **편리하고 즐거운 여행**을 할 수 있도록 지원하여, 더 많은 사람들이 환경을 생각하며 여행을 즐길 수 있도록 하는 것을 목표로 합니다.

---
## 🛠️ 시스템 아키텍처
![모하고노카_다이어그램 drawio](https://github.com/user-attachments/assets/34d2823f-b50d-494f-8e00-6c83302895b6)



---

## 👨🏻‍💻 Developer
Expand Down
31 changes: 30 additions & 1 deletion build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ plugins {
id 'java'
id 'org.springframework.boot' version '3.3.4'
id 'io.spring.dependency-management' version '1.1.6'
id "io.sentry.jvm.gradle" version "5.12.2"
}

group = 'com.example'
Expand All @@ -14,6 +15,10 @@ java {
}

configurations {
all {
exclude group: "commons-logging", module: "commons-logging"
exclude group: "org.slf4j", module: "slf4j-simple"
}
compileOnly {
extendsFrom annotationProcessor
}
Expand All @@ -27,32 +32,56 @@ dependencies {
// Web
implementation 'org.springframework.boot:spring-boot-starter-web'
implementation 'org.springframework.boot:spring-boot-starter-web-services'
implementation 'org.springframework.boot:spring-boot-starter-webflux'
compileOnly 'org.projectlombok:lombok'
annotationProcessor 'org.projectlombok:lombok'

// HTTP Client
implementation group: 'org.apache.httpcomponents.client5', name: 'httpclient5', version: '5.3.1'

// logging
implementation 'io.sentry:sentry-logback:8.22.0'

// Swagger
implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.3.0'

// DB
implementation 'org.springframework.boot:spring-boot-starter-data-jdbc'
implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
implementation 'org.springframework.boot:spring-boot-starter-data-redis'
runtimeOnly 'org.postgresql:postgresql'
implementation 'io.hypersistence:hypersistence-utils-hibernate-63:3.12.0'

// Actuator
implementation 'org.springframework.boot:spring-boot-starter-actuator'

// valid
implementation 'org.springframework.boot:spring-boot-starter-validation'

// retry
implementation 'org.springframework.retry:spring-retry'
implementation 'org.springframework:spring-aspects'

implementation 'net.logstash.logback:logstash-logback-encoder:9.0'

// notification
implementation 'com.google.firebase:firebase-admin:9.7.0'

// Test
testImplementation 'org.springframework.boot:spring-boot-starter-test'
testImplementation 'org.springframework.security:spring-security-test'
testRuntimeOnly 'org.junit.platform:junit-platform-launcher'
implementation 'it.ozimov:embedded-redis:0.7.3'
runtimeOnly 'com.h2database:h2'
testImplementation 'org.assertj:assertj-core:3.27.6'
implementation 'net.datafaker:datafaker:2.5.3'
}

tasks.named('test') {
useJUnitPlatform()
}

test {
testLogging {
showStandardStreams = true
}
}
4 changes: 2 additions & 2 deletions gradlew.bat

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions lombock.config
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
lombok.copyableAnnotations += org.springframework.beans.factory.annotation.Qualifier
75 changes: 61 additions & 14 deletions scripts/deploy.sh
Original file line number Diff line number Diff line change
Expand Up @@ -2,28 +2,75 @@

cd /home/ubuntu/app

# Redis 컨테이너 실행 여부 확인
REDIS_CONTAINER_STATUS=$(docker ps | grep redis)
if [ -z "$REDIS_CONTAINER_STATUS" ]; then
echo "Redis 컨테이너가 실행 중이지 않습니다"
echo ">>> Pulling Redis image"
docker compose -f docker-compose.redis.yml pull redis
echo ">>> Starting Redis container"
docker compose -f docker-compose.redis.yml up -d redis
else
echo "Redis 컨테이너가 이미 실행 중입니다."
fi


DOCKER_APP_NAME=spring

# 실행중인 blue가 있는지
EXIST_BLUE=$(docker-compose -p ${DOCKER_APP_NAME}-blue -f docker-compose.blue.yml ps | grep running)
# blue 컨테이너 실행 여부 확인
BLUE_CONTAINER_RUNNING=$(docker-compose -p ${DOCKER_APP_NAME}-blue -f docker-compose.blue.yml ps -q | xargs docker inspect -f '{{.State.Running}}')

# green이 실행중이면 blue up
if [ -z "$EXIST_BLUE" ]; then
echo "blue up"
# 컨테이너 스위칭
if [ "$BLUE_CONTAINER_RUNNING" != "true" ]; then
echo "[blue up]"
docker-compose -p ${DOCKER_APP_NAME}-blue -f docker-compose.blue.yml up -d --build

sleep 30

docker-compose -p ${DOCKER_APP_NAME}-green -f docker-compose.green.yml down
docker image prune -af # 사용하지 않는 이미지 삭제
ACTIVE_ENV="blue"
STANDBY_ENV="green"
ACTIVE_PORT="8081"

# blue가 실행중이면 green up
else
echo "green up"
echo "[green up]"
docker-compose -p ${DOCKER_APP_NAME}-green -f docker-compose.green.yml up -d --build

sleep 30
ACTIVE_ENV="green"
STANDBY_ENV="blue"
ACTIVE_PORT="8082"

fi


# 헬스체크
MAX_RETRIES=30
RETRY_INTERVAL=2
HEALTH_CHECK_URL="http://localhost:$ACTIVE_PORT/actuator/health"
ACTIVE_HEALTH_CHECK_PASSED=false

echo "Waiting for application to be ready..."
for i in $(seq 1 $MAX_RETRIES); do
if curl -s "$HEALTH_CHECK_URL" | grep -q "UP"; then
echo "[Application is ready]"
ACTIVE_HEALTH_CHECK_PASSED=true
break

fi
echo "Waiting... ($i/$MAX_RETRIES)"
sleep $RETRY_INTERVAL

done

# 새로운 컨테이너가 제대로 떴는지 확인
if [ "$ACTIVE_HEALTH_CHECK_PASSED" = "true" ]; then
# 이전 컨테이너 종료
docker-compose -p ${DOCKER_APP_NAME}-${STANDBY_ENV} -f docker-compose.${STANDBY_ENV}.yml down
docker image prune -af
echo "[Down] $STANDBY_ENV Down"

else
echo "Health check failed after $MAX_RETRIES attempts"
# 새 컨테이너 종료
docker-compose -p ${DOCKER_APP_NAME}-${ACTIVE_ENV} -f docker-compose.${ACTIVE_ENV}.yml down
# 디스코드 알림
sudo ./discord_${ACTIVE_ENV}.sh

docker-compose -p ${DOCKER_APP_NAME}-blue -f docker-compose.blue.yml down
docker image prune -af
fi
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,9 @@

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.data.jpa.repository.config.EnableJpaAuditing;

@EnableJpaAuditing
@SpringBootApplication
public class MohagoNocarApplication {

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
package com.example.mohago_nocar.course.application;

import com.example.mohago_nocar.global.common.exception.Status;
import lombok.AccessLevel;
import lombok.AllArgsConstructor;
import lombok.Getter;
import org.springframework.http.HttpStatus;

@Getter
@AllArgsConstructor(access = AccessLevel.PRIVATE)
public enum CourseErrorCode implements Status {
TRAVEL_COURSE_OPTIMIZATION_INCOMPLETE(HttpStatus.BAD_REQUEST, "TRAVEL_COURSE_OPTIMIZATION_INCOMPLETE", "여행 코스 최적화가 완료되지 않았습니다.")
;


private final HttpStatus httpStatus;
private final String code;
private final String message;

}

This file was deleted.

Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
package com.example.mohago_nocar.course.application.course;

import com.example.mohago_nocar.course.application.dto.*;
import com.example.mohago_nocar.course.application.dto.RouteStepDto.LocationDto;
import com.example.mohago_nocar.course.domain.model.routeStep.RouteStep;
import com.example.mohago_nocar.course.domain.model.travelSpot.TravelSpot;
import com.example.mohago_nocar.plan.domain.model.Location;
import com.example.mohago_nocar.transit.domain.model.BusPath;
import com.example.mohago_nocar.transit.domain.model.SubwayPath;
import com.example.mohago_nocar.transit.domain.model.WalkPath;

import java.util.List;
import java.util.stream.Collectors;

public class TravelCourseConverter {

public static RouteStepDto convertToRouteStepDto(
final TravelSpot origin,
final TravelSpot destination,
final RouteStep step
){
return new RouteStepDto(
step.getTimeTakenMin(),
step.getDistanceKm(),
convertToLocationDto(origin),
convertToLocationDto(destination),
convertToSubPathDtos(step));
}

private static List<SubPathDto> convertToSubPathDtos(RouteStep step) {
return step.getDetailPaths().stream()
.map(path ->{
if (path instanceof WalkPath walk) {
return new WalkSubPathDto(walk.getDistanceKm(), walk.getTimeTakenMin(),
walk.getPathType().name());
}

if (path instanceof BusPath bus) {
return new BusSubPathDto(bus.getDistanceKm(), bus.getTimeTakenMin(),
bus.getPathType().name(), bus.getBusNo(), bus.getBusType(),
bus.getStartName(), bus.getStartCoordinate().getLongitude(), bus.getStartCoordinate().getLatitude(),
bus.getEndName(), bus.getEndCoordinate().getLongitude(), bus.getEndCoordinate().getLatitude());
}

if (path instanceof SubwayPath subway) {
return new SubwaySubPathDto(
subway.getDistanceKm(), subway.getTimeTakenMin(),
subway.getPathType().name(), subway.getSubwayLineName(),
subway.getStartName(), subway.getStartCoordinate().getLongitude(), subway.getStartCoordinate().getLatitude(),
subway.getEndName(), subway.getEndCoordinate().getLongitude(), subway.getEndCoordinate().getLatitude()
);
}

throw new IllegalArgumentException("지원하지 않는 경로 유형입니다.");
})
.collect(Collectors.toList());
}

private static LocationDto convertToLocationDto(TravelSpot origin) {
Location location = origin.getLocation();
return LocationDto.builder()
.name(location.getName())
.latitude(location.getCoordinate().getLatitude())
.longitude(location.getCoordinate().getLongitude())
.build();
}

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
package com.example.mohago_nocar.course.application.course;

import com.example.mohago_nocar.course.domain.event.TravelCourseOptimizedEvent;
import com.example.mohago_nocar.course.domain.model.course.TravelCourseCompletionMessage;
import com.example.mohago_nocar.course.domain.model.course.TravelCourseOptimizedEventHandleHistory;
import com.example.mohago_nocar.course.domain.model.course.TravelCourseStatus;
import com.example.mohago_nocar.course.domain.repository.TravelCourseOptimizedEventHandleHistoryRepository;
import com.example.mohago_nocar.course.domain.service.TravelCourseUseCase;
import com.example.mohago_nocar.global.notification.application.user.UserNotificationDto;
import com.example.mohago_nocar.global.notification.application.user.UserNotificationOutboxService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.dao.DataIntegrityViolationException;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Transactional;

import java.util.Map;

@Component
@Slf4j
@RequiredArgsConstructor
public class TravelCourseEventHandler {

private final TravelCourseUseCase travelCourseUseCase;
private final TravelCourseOptimizedEventHandleHistoryRepository handleHistoryRepository;
private final UserNotificationOutboxService notificationOutboxService;

@Transactional
public void handleEvent(TravelCourseOptimizedEvent event) {
travelCourseUseCase.generateTransitRoute(event.getTravelCourseId());
processSuccessEvent(event);
}

private void processSuccessEvent(TravelCourseOptimizedEvent event) {
travelCourseUseCase.updateUncompletedCourseStatus(event.getTravelCourseId(), TravelCourseStatus.SUCCEEDED);
UserNotificationDto notificationDto = createNotificationMessage(event, true);
notificationOutboxService.save(notificationDto);
}

@Transactional
public void processFailEvent(TravelCourseOptimizedEvent event) {
travelCourseUseCase.updateUncompletedCourseStatus(event.getTravelCourseId(), TravelCourseStatus.FAILED);
UserNotificationDto notificationDto = createNotificationMessage(event, false);
notificationOutboxService.save(notificationDto);
}

private UserNotificationDto createNotificationMessage(TravelCourseOptimizedEvent event, boolean isSuccess) {
TravelCourseCompletionMessage message = isSuccess ?
TravelCourseCompletionMessage.SUCCESS : TravelCourseCompletionMessage.FAILURE;

return new UserNotificationDto(
message.getTitle(),
message.getBody(),
event.getAnonymousUserId(),
Map.of("travelCourseId", String.valueOf(event.getTravelCourseId()))
);
}

public boolean hasHandleHistory(Long travelCourseId) {
TravelCourseOptimizedEventHandleHistory history = TravelCourseOptimizedEventHandleHistory.of(travelCourseId);

try {
handleHistoryRepository.save(history);
} catch (DataIntegrityViolationException e) {
return true;
}

return false;
}

}
Loading