Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
ae277cc
feat: 주어진 두 좌표를 통해 snap 하는 기능 구현
kkiseug Jan 4, 2026
d420d8e
feat: snap api 구현
kkiseug Jan 4, 2026
fc9786e
feat: 유저 간 소유 관계를 표현하는 도메인 객체 구현
kkiseug Jan 7, 2026
bafc3cc
feat: 유저가 생성하는 코스 비즈니스 로직 구현
kkiseug Jan 7, 2026
5c198bd
feat: 유저 코스 생성 API 구현
kkiseug Jan 7, 2026
c3d4257
fix: 604 PR 오류 수정
kkiseug Jan 7, 2026
4a2f571
refactor: 서비스에서 원시값 받도록 리팩터링
kkiseug Jan 7, 2026
9a41d6b
refactor: 모든 파라미터 생성자를 Persistence에서 활용하도록 수정
kkiseug Jan 7, 2026
e98e861
feat: 코스 추가에 대한 uri 경로 화이트리스트 등록
kkiseug Jan 7, 2026
3b3c198
refactor: snap uri 단수로 변경
kkiseug Jan 7, 2026
4d894f4
refactor: 화이트리스트 uri 경로 불일치 수정
kkiseug Jan 7, 2026
c1407b2
refactor: 불필요한 import 제거
kkiseug Jan 7, 2026
c0e04ae
refactor: 중복되는 dto를 record 클래스로 분리
kkiseug Jan 7, 2026
3e24484
refactor: 코스 추가 시 기존 생성자로 생성 후 필드 변경 대신 private 생성자로 생성하도록 변경
kkiseug Jan 7, 2026
2ece92b
refactor: 메서드 복수형으로 변경
kkiseug Jan 7, 2026
edadfd9
refactor: WebReqeust NotNull 어노테이션 추가
kkiseug Jan 7, 2026
b7d0cd1
refactor: 코스 공개 및 비공개 필드 명확하게 변경
kkiseug Jan 7, 2026
6e59f0c
refactor: 로깅에서 match로 표기된 부분들 snap으로 수정
kkiseug Jan 7, 2026
62b9e5d
feat: Enum 타입 변환에 대한 예외처리 추가
kkiseug Jan 7, 2026
30a5403
refactor: AbstractSecurityTest 추상 클래스로 변경
kkiseug Jan 7, 2026
30a1d60
refactor: OSRM 스냅 테스트 수정 및 match -> snap 네이밍 통일
kkiseug Jan 7, 2026
4df7ff6
test: 코스 추가 및 생성 과정 테스트 케이스 구현
kkiseug Jan 7, 2026
bd52bcd
test: Application 테스트에서 dbUtil 이용하도록 변경
kkiseug Jan 7, 2026
cd56ef9
chore: 문서화 API 작성
kkiseug Jan 7, 2026
86771e3
refactor: 좌표 두 개 스냅 기능을 Coordinate API로 분리
kkiseug Jan 7, 2026
1f7f1be
refactor: snap 기능 uri 수정
kkiseug Jan 7, 2026
51f9582
refactor: uri 단수형에서 복수형으로 변경
kkiseug Jan 9, 2026
6f85977
refactor: 스냅 시 2개 미만의 좌표인 경우 예외 던지도록 수정
kkiseug Jan 11, 2026
ab1c5cc
Merge branch 'feat/665-model-refactor' into feat/625-osrm-route-make
dompoo Jan 13, 2026
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
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,10 @@

import coursepick.coursepick.application.dto.CourseResponse;
import coursepick.coursepick.application.dto.CoursesResponse;
import coursepick.coursepick.application.dto.SnapResponse;
import coursepick.coursepick.domain.course.*;
import coursepick.coursepick.domain.user.User;
import coursepick.coursepick.domain.user.UserRepository;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.jspecify.annotations.Nullable;
Expand All @@ -13,6 +16,7 @@
import java.util.List;

import static coursepick.coursepick.application.exception.ErrorType.NOT_EXIST_COURSE;
import static coursepick.coursepick.application.exception.ErrorType.NOT_EXIST_USER;

@Slf4j
@Service
Expand All @@ -21,6 +25,9 @@ public class CourseApplicationService {

private final CourseRepository courseRepository;
private final RouteFinder routeFinder;
private final CoordinateSnapper coordinateSnapper;
private final UserRepository userRepository;
private final UserCreatedCourseRepository userCreatedCourseRepository;

@Transactional(readOnly = true)
public CoursesResponse findNearbyCourses(CourseFindCondition condition, @Nullable Double userLatitude, @Nullable Double userLongitude) {
Expand Down Expand Up @@ -67,4 +74,24 @@ private void loggingForNotExistsCourse(List<String> ids, List<Course> courses) {
}
}
}

@Transactional(readOnly = true)
public SnapResponse snapCoordinates(List<Coordinate> coordinates) {
SnapResult snapResult = coordinateSnapper.snap(coordinates);
return new SnapResponse(snapResult.coordinates(), snapResult.length());
Comment on lines +80 to +81
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

이거 SnapResultSnapReponse를 분리하신 이유가 궁금합니다.
SnapResult를 그대로 응답해도 될 것 같거든요.

}
Comment on lines +78 to +82
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

이 로직이 CourseApplicationService에 있는게 좀 부자연스러운 것 같아요.
좌표들에 대한 연산이고, Course라는 단어는 보이지 않는데, 여기 있는 이유가 뭔가요?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

저희가 우테코에서 프로젝트를 하면서 초기에 했던 얘기가 생각나는 대목이네요.
MongoDB로 전환을 하면서 가장 큰 이유가 Course라는 큰 도메인 객체에서 Segment, GeoLine, Coordinate이 각자 MySQL에서 말하는 관계를 맺는다기보다는 하나의 큰 틀에서 포함되는 것이다. 그래서 이를 하나의 어떻게보면 애그리거트로 보는 것이 자연스럽다. 라는 느낌으로 결론이 났었던 거 같아요. 맞나요?

그래서 해당 철학을 기반으로 했을 때 Coordinate의 접근은 Course에서 이뤄지는게 자연스럽다고 생각했습니다.

Copy link
Copy Markdown
Contributor

@dompoo dompoo Jan 10, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

이것도 수정하려면 너무 커질 것 같아서, 일단 넘어가도 좋을 것 같습니다.

다만 고민해보면 좋을 점이, 모든 연산이 꼭 애그리거트 루트를 통할 필요가 없다는 지점입니다. DDD에서도 그것을 강제하지 않고, 다만 CUD 작업은 그것을 통하라고 말합니다. 애그리거트는 복잡한 시스템에서 데이터 일관성을 유지하는 경계이기 때문입니다.

이런 사실을 통해 제가 생각하기로는,
좌표에 대한 수정은 Course에서 수행하는 것이 맞습니다. 하지만 좌표를 조회하는 것은 별도의 객체에서 수행될 수 있는 거죠!
더구나, 해당 로직의 Coordinate은 Course의 Coordinate과 사뭇 다릅니다. 해당 좌표들이 Course를 구성하지도 않고, 그냥 내부 값만 비슷한 전혀 다른 개념인 거죠.
지금은 전혀 그럴 필요가 없지만, 율무가 DDD를 좋아하시니 이런 지점을 고민해보시면 좀 더 재밌지 않을까 싶습니다 ㅋㅋ


@Transactional
public CourseResponse create(String userId, String name, List<Coordinate> coordinates) {
User user = userRepository.findById(userId)
.orElseThrow(() -> NOT_EXIST_USER.create(userId));

Course course = new Course(null, name, coordinates);
Course savedCourse = courseRepository.save(course);

UserCreatedCourse userCreatedCourse = new UserCreatedCourse(user.id(), savedCourse.id(), false);
userCreatedCourseRepository.save(userCreatedCourse);

return CourseResponse.from(savedCourse);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
package coursepick.coursepick.application.dto;

import coursepick.coursepick.domain.course.Coordinate;

import java.util.List;

public record SnapResponse(
List<Coordinate> coordinates,
double length
) {
}
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,14 @@ public enum ErrorType {
"시작과 끝 좌표만 존재할 때 둘은 중복될 수 없습니다.",
IllegalArgumentException::new
),
INVALID_ROAD_TYPE(
"허용된 길 타입이 아닙니다. [트랙, 트레일, 보도, 알수없음] 입력값=%s",
IllegalArgumentException::new
),
INVALID_DIFFICULTY(
"허용된 난이도가 아닙니다. [쉬움, 보통, 어려움] 입력값=%s",
IllegalArgumentException::new
),
NOT_EXIST_COURSE(
"코스가 존재하지 않습니다. 코스id=%s",
NoSuchElementException::new
Expand All @@ -49,10 +57,18 @@ public enum ErrorType {
"잘못된 관리자 비밀번호입니다.",
SecurityException::new
),
INVALID_SNAP_COORDINATE_SIZE(
"스냅을 위해선 최소 2개 이상의 좌표가 필요합니다.",
IllegalArgumentException::new
),
NOT_FOUND_NOTICE(
"존재하지 않는 공지 사항입니다. 공지 사항id=%s",
NoSuchElementException::new
),
NOT_EXIST_USER(
"존재하지 않는 사용자입니다. 유저id=%s",
NoSuchElementException::new
),
AUTHENTICATION_FAIL(
"인증에 실패하였습니다.",
UnauthorizedException::new
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,5 +4,5 @@

public interface CoordinateSnapper {

List<Coordinate> snap(List<Coordinate> coordinates);
SnapResult snap(List<Coordinate> coordinates);
}
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@

public interface CourseRepository {

void save(Course course);
Course save(Course course);

void saveAll(Iterable<? extends Course> courses);

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
package coursepick.coursepick.domain.course;

import java.util.List;

public record SnapResult(
List<Coordinate> coordinates,
double length
) {
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
package coursepick.coursepick.domain.course;

import lombok.AccessLevel;
import lombok.AllArgsConstructor;
import org.springframework.data.annotation.Id;
import org.springframework.data.annotation.PersistenceCreator;
import org.springframework.data.mongodb.core.mapping.Document;

@Document
@AllArgsConstructor(access = AccessLevel.PROTECTED, onConstructor_ = @PersistenceCreator)
public class UserCreatedCourse {

@Id
private final String id;
private final String userId;
private final String courseId;
private boolean isPublic;

Comment on lines +9 to +18
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

UserCreatedCourse가 id로 구분될 필요가 있을까요?

public UserCreatedCourse(String userId, String courseId, boolean isPublic) {
this(null, userId, courseId, isPublic);
}
}
Comment on lines +9 to +22
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

이 데이터들을 Course에 추가하지 않으신 이유가 '소유'의 관계가 Course의 것이 아니라고 하셨는데요.
그러면 그냥 유저가 생성한 코스에만 user_id가 존재하고, 관리자에 의해 생성된 코스에는 user_id가 없어도 되지 않을까요?
소유의 관계가 Course의 것이 아니라고 해도, isPublic은 너무나 Course 것 같기도 하고요.

또 반대로 User 쪽에 추가하는 것도 괜찮을 것 같은데, 이것은 어떨까요?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

어떤 Course를 응답할 때, 그냥 그 Course만 조회하는게 아니라

  1. Course와 연관된 UserCreatedCourse가 있는지 전체 다 뒤진다.
  2. 없으면 public이다. (맞나요? 헷갈립니다)
  3. 있으면 UserCreatedCourse의 isPublic에 따른다.

라는 복잡한 규칙이 생기네요.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

그러면 그냥 유저가 생성한 코스에만 user_id가 존재하고, 관리자에 의해 생성된 코스에는 user_id가 없어도 되지 않을까요?
저도 처음에 그렇게 생각했는데요. 즐겨찾기 기능이 생기면 이것도 어떻게 설계를 풀어내느냐에 따라 좀 다르겠지만 현재 구조라면 UserCreatedCourse처럼 UserFavoriteCourse와 같은 중간 객체를 하나 더 만들면 됩니다. 오히려 확장성면에서 좋다고 생각했어요.
하지만, 확실히 돔푸가 말한 응답면에서는 단점이 명확하긴 하네요.

짧게 해결 방안을 생각했을 때는 즐겨찾기까지 고려하여 isPublic 정도만 Course에 넘겨주는 방식은 어떤가요? 단점도 해결되고, 확장성도 그대로 유지될 거 같긴합니다.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

네! 일단 그렇게 구현하면 두 장점 모두 적절히 챙기는 것 같아서 좋은 것 같아요.

추후에 시간이 되시면 DocumentDB에서 관계를 어떻게 표현해야 하는지 좀 더 고민해보면 좋을 것 같습니다. 모델을 나누는 방식은 좀 더 RDB스럽다고 생각했어요. DocumentDB를 이왕 쓰고 있으니 데이터를 중복해서 임베딩 해두는게 기초가 되는 설계라고 생각합니다.

Copy link
Copy Markdown
Contributor Author

@kkiseug kkiseug Jan 11, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

아, 제가 너무 도메인에만 집중했던 거 같습니다.
DocumentDB 특성도 고려해야 했던 거 같아요.
이 지점이 저에겐 도메인 vs 데이터베이스 성능 트레이드 오프 지점 같습니다.

해당 코멘트를 계기로 다시 한번 MongoDB에 대해서 학습을 해봤어요.

조회의 경우에 돔푸가 말한 것처럼 다음과 같은 상황이 생길 수 있고,

  1. Course와 연관된 UserCreatedCourse가 있는지 전체 다 뒤진다.
  2. 없으면 public이다. (맞나요? 헷갈립니다)
  3. 있으면 UserCreatedCourse의 isPublic에 따른다.

또, 추가로 유저가 자신만의 코스를 조회하는 상황에서도 이렇게 조회가 될 거 같아요.

  1. User를 찾고,
  2. UserCreatedCourse에서 userId를 통해 찾는다.
  3. 그리고 받아온 List-UserCreatedCourse에 있는 courseId를 통해 코스를 조회한 후 응답

혹은 간단한 $lookup 같은 연산을 통해서 찾을 수도 있겠죠?

하지만, 코스 추가 기능의 특성이

  1. 조회가 많고
  2. 쓰기가 적으며
  3. userId에 대한 업데이트가 없다는 점
  4. MongoDB의 기본적인 철학이 '역정규화'인 것
  5. 데이터 중첩, 중복 저장을 통해 조인 필요성을 줄이는 설계를 권장한다는 점

등을 고려하면 $lookup이나 저런 조회로 인한 다수 쿼리 발생이 코스 하나하나의 데이터가 큰 만큼 애플리케이션 성능에 꽤나 문제를 일으킬 것 같습니다. 또한, MongoDB를 '잘' 사용하는 것이 아니라는 점도 들 수 있겠네요.

이와 관련해서 MongoDB Docs에도 기준을 만들어 놨더라구요.
스크린샷 2026-01-11 22 20 18

네.. 그래서 결론은 MongoDB에 맞게 설계하고, 성능이나 복잡성을 생각하면 Embed 하는게 좋다고 생각합니다. 다만 User 자체를 Embed 하기보다는 CreatorInfo 같은 객체를 만들어 넣어주는 것이 좋을 거 같네요. ㅎㅎ

쓰다보니 코멘트가 길어졌는데, 결론에 대한 돔푸의 의견도 궁금합니다.

Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
package coursepick.coursepick.domain.course;

import org.springframework.data.repository.Repository;

public interface UserCreatedCourseRepository extends Repository<UserCreatedCourse, String> {

void save(UserCreatedCourse userCreatedCourse);
}
Original file line number Diff line number Diff line change
Expand Up @@ -8,4 +8,6 @@ public interface UserRepository extends Repository<User, String> {
User save(User user);

Optional<User> findByProviderAndProviderId(UserProvider provider, String providerId);

Optional<User> findById(String id);
}
Original file line number Diff line number Diff line change
Expand Up @@ -27,8 +27,8 @@ public class CourseRepositoryMongoTemplateImpl implements CourseRepository {
private final MongoTemplate mongoTemplate;

@Override
public void save(Course course) {
mongoTemplate.save(course);
public Course save(Course course) {
return mongoTemplate.save(course);
}

@Override
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import coursepick.coursepick.domain.course.Coordinate;
import coursepick.coursepick.domain.course.CoordinateSnapper;
import coursepick.coursepick.domain.course.SnapResult;
import org.springframework.context.annotation.Fallback;
import org.springframework.stereotype.Component;

Expand All @@ -12,7 +13,7 @@
public class DummyCoordinateSnapper implements CoordinateSnapper {

@Override
public List<Coordinate> snap(List<Coordinate> coordinates) {
return coordinates;
public SnapResult snap(List<Coordinate> coordinates) {
return new SnapResult(coordinates, 100);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import coursepick.coursepick.domain.course.Coordinate;
import coursepick.coursepick.domain.course.CoordinateSnapper;
import coursepick.coursepick.domain.course.SnapResult;
import coursepick.coursepick.logging.LogContent;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
Expand All @@ -17,6 +18,8 @@
import java.util.stream.Collectors;
import java.util.stream.IntStream;

import static coursepick.coursepick.application.exception.ErrorType.INVALID_SNAP_COORDINATE_SIZE;

@Slf4j
@Component
@Profile({"dev", "prod"})
Expand All @@ -26,9 +29,9 @@ public class OsrmCoordinateSnapper implements CoordinateSnapper {
private final RestClient osrmRestClient;

@Override
public List<Coordinate> snap(List<Coordinate> coordinates) {
public SnapResult snap(List<Coordinate> coordinates) {
if (coordinates.size() < 2) {
return coordinates;
throw INVALID_SNAP_COORDINATE_SIZE.create();
}

String coordinatesParam = coordinates.stream()
Expand All @@ -52,26 +55,29 @@ public List<Coordinate> snap(List<Coordinate> coordinates) {
.body(new ParameterizedTypeReference<>() {
});

log.info("OSRM Match 결과: {}", response);
return parseMatchResponse(response, coordinates);
log.info("[OSRM Snap 결과] {}", response);
return parseSnapResponse(response, coordinates);
} catch (Exception e) {
log.warn("[EXCEPTION] OSRM 좌표 매칭 실패", LogContent.exception(e));
return coordinates;
log.warn("[EXCEPTION] OSRM 좌표 Snap 실패", LogContent.exception(e));
return new SnapResult(coordinates, 0);
}
Comment on lines +58 to 63
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

(prod 포함) OSRM 응답 전체 info 로깅은 위치정보/로그 용량 측면에서 위험합니다.

response에는 좌표/geometry가 포함될 수 있어 개인정보(위치) 성격의 데이터가 로그로 남을 가능성이 큽니다. 최소한 debug로 내리거나, 건수/거리 등 요약 필드만 로깅하는 형태를 권장합니다.

제안 diff (요약 로깅 + debug 레벨)
-            log.info("[OSRM Snap 결과] {}", response);
+            // Avoid logging full geometry/coordinates in prod logs.
+            log.debug("[OSRM Snap 결과] matchings_size={}", ((List<?>) response.getOrDefault("matchings", List.of())).size());
             return parseSnapResponse(response, coordinates);
🤖 Prompt for AI Agents
In
@backend/src/main/java/coursepick/coursepick/infrastructure/snapper/OsrmCoordinateSnapper.java
around lines 57 - 62, The code currently logs the full OSRM response at info
level in OsrmCoordinateSnapper (log.info("[OSRM Snap 결과] {}", response)) which
may expose location PII and bloat logs; change that to log.debug for the full
response and replace the info-level log with a concise summary (e.g., number of
matched points, total distance or snappedCount) before calling
parseSnapResponse(response, coordinates); ensure exception handling still
returns new SnapResult(coordinates, 0) and keep parseSnapResponse usage
unchanged.

}

private List<Coordinate> parseMatchResponse(Map<String, Object> response, List<Coordinate> originals) {
private SnapResult parseSnapResponse(Map<String, Object> response, List<Coordinate> originals) {
try {
List<Map<String, Object>> matchings = (List<Map<String, Object>>) response.get("matchings");
Map<String, Object> geometry = (Map<String, Object>) matchings.get(0).get("geometry");
List<List<Double>> coordinates = (List<List<Double>>) geometry.get("coordinates");
Double length = (Double) matchings.get(0).get("distance");

return coordinates.stream()
List<Coordinate> snappedCoordinates = coordinates.stream()
.map(coord -> new Coordinate(coord.get(1), coord.get(0)))
.toList();

return new SnapResult(snappedCoordinates, length);
} catch (Exception e) {
log.warn("[EXCEPTION] OSRM Match 응답 파싱 실패", LogContent.exception(e));
return originals;
log.warn("[EXCEPTION] OSRM Snap 응답 파싱 실패", LogContent.exception(e));
return new SnapResult(originals, 0);
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -144,7 +144,7 @@ public void deleteCourse(@PathVariable("id") String id) {

@PostMapping("/admin/api/coordinates/snap")
public CoordinatesSnapWebResponse snapCoordinates(@RequestBody @Valid CoordinatesSnapWebRequest request) {
List<Coordinate> snapped = coordinateSnapper.snap(request.coordinates());
return CoordinatesSnapWebResponse.from(snapped);
SnapResult snapResult = coordinateSnapper.snap(request.coordinates());
return CoordinatesSnapWebResponse.from(snapResult.coordinates());
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
package coursepick.coursepick.presentation;

import coursepick.coursepick.application.CourseApplicationService;
import coursepick.coursepick.application.dto.SnapResponse;
import coursepick.coursepick.domain.course.Coordinate;
import coursepick.coursepick.presentation.api.CoordinateWebApi;
import coursepick.coursepick.presentation.dto.SnapWebRequest;
import coursepick.coursepick.presentation.dto.SnapWebResponse;
import coursepick.coursepick.security.Login;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RestController;

import java.util.List;

@RestController
@RequiredArgsConstructor
public class CoordinateWebController implements CoordinateWebApi {

private final CourseApplicationService courseApplicationService;

@Override
@Login
@PostMapping("/coordinates/snap")
public SnapWebResponse snapCoordinates(@RequestBody SnapWebRequest snapWebRequest) {
List<Coordinate> coordinates = snapWebRequest.coordinates().stream()
.map(dto -> new Coordinate(dto.latitude(), dto.longitude()))
.toList();

SnapResponse snapResponse = courseApplicationService.snapCoordinates(coordinates);
return SnapWebResponse.from(snapResponse);
}
}
Original file line number Diff line number Diff line change
@@ -1,13 +1,17 @@
package coursepick.coursepick.presentation;

import coursepick.coursepick.application.CourseApplicationService;
import coursepick.coursepick.application.dto.CourseResponse;
import coursepick.coursepick.application.dto.CoursesResponse;
import coursepick.coursepick.domain.course.Coordinate;
import coursepick.coursepick.domain.course.CourseFindCondition;
import coursepick.coursepick.presentation.api.CourseWebApi;
import coursepick.coursepick.presentation.dto.CoordinateWebResponse;
import coursepick.coursepick.presentation.dto.CourseCreateWebRequest;
import coursepick.coursepick.presentation.dto.CourseWebResponse;
import coursepick.coursepick.presentation.dto.CoursesWebResponse;
import coursepick.coursepick.security.Login;
import coursepick.coursepick.security.UserId;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*;

Expand Down Expand Up @@ -66,4 +70,17 @@ public List<CourseWebResponse> findFavoriteCourses(@RequestParam("courseIds") Li
.map(CourseWebResponse::from)
.toList();
}

@Override
@Login
@PostMapping("/courses/create")
public CourseWebResponse create(@UserId String userId, @RequestBody CourseCreateWebRequest courseCreateWebRequest) {
Comment on lines +76 to +77
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

fd "CourseCreateWebRequest.java" --exec cat {}

Repository: woowacourse-teams/2025-course-pick

Length of output: 1144


🏁 Script executed:

fd "CourseWebController.java" -x head -n 90 {} | tail -n 30

Repository: woowacourse-teams/2025-course-pick

Length of output: 1169


🏁 Script executed:

fd "AdminWebController.java" -x grep -A 5 "login"

Repository: woowacourse-teams/2025-course-pick

Length of output: 713


🏁 Script executed:

rg "@PostMapping" -A 3 --type java | grep -A 3 "@RequestBody" | head -50

Repository: woowacourse-teams/2025-course-pick

Length of output: 2586


🏁 Script executed:

fd "SignWebRequest.java|SnapWebRequest.java" --exec cat {}

Repository: woowacourse-teams/2025-course-pick

Length of output: 800


@Valid 어노테이션 추가 필요

CourseCreateWebRequest@NotNull 검증 어노테이션이 정의되어 있으나, 엔드포인트에 @Valid가 누락되어 유효성 검증이 실행되지 않습니다. AdminWebController의 다른 엔드포인트들(login, snapCoordinates)에서는 @RequestBody @Valid를 함께 사용하고 있으므로 동일한 패턴을 적용해야 합니다.

수정 방법
 @Login
 @PostMapping("/courses/create")
-public CourseWebResponse create(@UserId String userId, @RequestBody CourseCreateWebRequest courseCreateWebRequest) {
+public CourseWebResponse create(@UserId String userId, @RequestBody @Valid CourseCreateWebRequest courseCreateWebRequest) {
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
@PostMapping("/courses/create")
public CourseWebResponse create(@UserId String userId, @RequestBody CourseCreateWebRequest courseCreateWebRequest) {
@PostMapping("/courses/create")
public CourseWebResponse create(@UserId String userId, @RequestBody @Valid CourseCreateWebRequest courseCreateWebRequest) {
🤖 Prompt for AI Agents
In
@backend/src/main/java/coursepick/coursepick/presentation/CourseWebController.java
around lines 71 - 72, CourseCreateWebRequest의 필드 제약 어노테이션이 동작하지 않으므로
CourseWebController의 create 메서드 시그니처에 요청 바디 검증을 활성화하도록 @Valid를 추가하세요; 구체적으로
public CourseWebResponse create(@UserId String userId, @RequestBody
CourseCreateWebRequest courseCreateWebRequest) 선언에서 CourseCreateWebRequest 파라미터에
@Valid를 함께 적용해 CourseCreateWebRequest의 @NotNull 등 검증 어노테이션이 트리거되도록 수정하세요 (참조:
CourseWebController.create, CourseCreateWebRequest).

List<Coordinate> coordinates = courseCreateWebRequest.coordinates().stream()
.map(dto -> new Coordinate(dto.latitude(), dto.longitude()))
.toList();

Comment on lines +78 to +81
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

DTO → 도메인 변환 책임의 위치
List → List 변환을 Controller에서 수행하도록 했습니다.
배경:
기존에는 서비스에서 원시값을 받아 내부에서 변환
List의 경우 원시값으로 전달하려면 CoordinateDto 또는 변환된 Coordinate를 넘겨야 함
CoordinateDto는 Presentation 계층 소속이므로, 서비스에서 받으면 의존 관계가 역전됨
질문: 이 변환 책임을 Controller에 두는 것이 적절할까요?
-> 이 부분이 돔푸 PR인 코스 조회 API에 필터링 기능 추가에서 만들어주신 CourseFindCondition과 비슷한 맥락입니다.

계층을 정말 지키고 싶다면 따로 DTO를 만들었을 것 같아요.

  • Controller에서는 DTO -> DTO 변환
  • Service에서는 DTO -> Coordinate 변환

하지만 이러면 공이 너무 들어가는 것 같기도 하고, 큰 의미가 없다고 판단되어서 지금같은 형태가 더 좋아보입니다.

CourseResponse courseResponse = courseApplicationService.create(userId, courseCreateWebRequest.name(), coordinates);

return CourseWebResponse.from(courseResponse);
}
}
Loading