⏺
들어가며
여행 플래닝 앱에서 스케줄 상세 조회 API를 구현하게 되었다. 단순히 "데이터 조회해서 반환하면 되겠지"라고 생각했는데, 생각보다 고민할 게 많았다.
이 글에서는 구현 과정에서 마주친 고민들과 해결 과정을 공유한다.
스케줄에는 생성자(owner)와 초대된 참가자(participants)가 있다. 응답 설계 시 이들을 어떻게 구분할지 고민이 됐다.
라운지 공개용 API에서는 owner_user를 별도로 노출하고, 내 스케줄 조회 API에서는 participants 배열의 첫 번째로 owner를 넣기로 했다.
private List<ParticipantDto> getParticipants(User owner, List<ScheduleMember> members) {
return Stream.concat(
Stream.of(toParticipantDto(owner)), // owner가 항상 첫 번째
members.stream().map(member -> toParticipantDto(member.getInvitedUser()))
).toList();
}
💡 배운 점: API 용도에 따라 같은 데이터도 다르게 표현할 수 있다. 클라이언트 입장에서 어떤 형태가 사용하기 편한지 고민하자.
ScheduleDetailResponse와 LoungeScheduleDetailResponse 안에 똑같은 구조의 TagDto, CityDto가 nested record로 있었다.
// ScheduleDetailResponse 안에
public record TagDto(Long id, String name) {}
// LoungeScheduleDetailResponse 안에도
public record TagDto(Long id, String name) {} // 완전 똑같음!
처음엔 "각 Response에 종속된 DTO니까 분리된 게 맞지 않나?"라고 생각했다.
공통 DTO로 추출하고, from() 팩토리 메서드 패턴 적용:
public record TagDto(Long id, String name) {
public static TagDto from(Long id, String name) {
return new TagDto(id, name);
}
}
public record CityDto(Long cityId, String cityName,
String countryEmoji, String countryName) {
public static CityDto from(Long cityId, String cityName,
String countryEmoji, String countryName) {
return new CityDto(cityId, cityName, countryEmoji, countryName);
}
}
최종 구조:
schedule/dto/
├── TagDto.java ← 공통
├── CityDto.java ← 공통
├── PlanDto.java ← 공통
├── ScheduleDayDto.java ← 공통
├── ScheduleDetailResponse.java
│ └── ParticipantDto (nested)
└── LoungeScheduleDetailResponse.java
└── OwnerUserDto (nested)
💡 배운 점: "나중에 달라질 수 있으니까"는 YAGNI(You Ain't Gonna Need It) 위반이다. 지금 중복이면 지금 제거하자.
라운지 스케줄 상세 조회 시 각 장소(Plan) 사이의 거리를 보여주고 싶었다.
일단 Haversine 공식으로 직선 거리 계산하기로 했다.
public final class DistanceCalculator {
private static final double EARTH_RADIUS_MILES = 3958.8;
public static BigDecimal calculateDistanceMiles(
BigDecimal lat1, BigDecimal lon1,
BigDecimal lat2, BigDecimal lon2) {
if (lat1 == null || lon1 == null || lat2 == null || lon2 == null) {
return null;
}
double lat1Rad = Math.toRadians(lat1.doubleValue());
double lat2Rad = Math.toRadians(lat2.doubleValue());
double deltaLat = Math.toRadians(lat2.subtract(lat1).doubleValue());
double deltaLon = Math.toRadians(lon2.subtract(lon1).doubleValue());
double a = Math.sin(deltaLat / 2) * Math.sin(deltaLat / 2)
+ Math.cos(lat1Rad) * Math.cos(lat2Rad)
* Math.sin(deltaLon / 2) * Math.sin(deltaLon / 2);
double c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
double distance = EARTH_RADIUS_MILES * c;
return BigDecimal.valueOf(distance).setScale(2, RoundingMode.HALF_UP);
}
}
// km일 때
private static final double EARTH_RADIUS_KM = 6371.0;
// mile일 때
private static final double EARTH_RADIUS_MILES = 3958.8;
💡 배운 점: 외부 API 연동 없이도 기본적인 거리 정보는 제공할 수 있다. 완벽하지 않아도 MVP로 충분할 수 있다.
테스트 데이터 INSERT 중 에러 발생:
numeric field overflow - A field with precision 10, scale 8
must round to an absolute value less than 10^2
경도(longitude) 127.0276...를 저장하려는데, precision=10, scale=8 설정으로는 정수부가 2자리밖에 안 됐다.
precision 10, scale 8 → 전체 10자리 중 소수점 8자리 → 정수부 2자리
127.xxx → 정수부 3자리 필요 → 💥 overflow!
// Before
@Column(name = "latitude", precision = 10, scale = 8)
@Column(name = "longitude", precision = 10, scale = 8)
// After
@Column(name = "latitude", precision = 11, scale = 8) // 정수부 3자리
@Column(name = "longitude", precision = 12, scale = 8) // 정수부 4자리 (경도는 -180~180)
💡 배운 점: DB 컬럼 설계할 때 실제 데이터 범위를 꼭 확인하자. 위도는 -9090, 경도는 -180180이다.
태그, 도시 정보가 i18n 테이블로 분리되어 있어서 JOIN이 필요했다.
@Override
public List<TagDto> findTagsByScheduleId(Long scheduleId, Long languageId) {
return queryFactory
.select(tag.id, tagI18n.name)
.from(scheduleTag)
.join(scheduleTag.tag, tag)
.join(tagI18n).on(
tagI18n.tag.eq(tag)
.and(tagI18n.language.id.eq(languageId)) // 언어 필터
)
.where(scheduleTag.schedule.id.eq(scheduleId))
.fetch()
.stream()
.map(tuple -> TagDto.from(
tuple.get(tag.id),
tuple.get(tagI18n.name)
))
.toList();
}
📝 마무리
이번 구현에서 배운 것들:
DTO 설계는 용도에 맞게 - 같은 데이터도 API 목적에 따라 다르게 표현
중복은 바로 제거 - "나중에 달라질 수 있으니까"는 핑계
MVP 먼저 - 완벽한 거리 계산보다 일단 동작하는 것부터
DB 설계 시 데이터 범위 확인 - precision/scale 실수하면 런타임 에러
다음엔 실제 이동 거리 계산을 위한 외부 API 연동을 고민해봐야겠다. 🚗
참고 자료