Spring Boot 여행 일정 API 개발기 - QueryDSL, DTO 설계, Haversine 거리 계산

Yong Lee·2025년 12월 10일

인턴

목록 보기
1/1

들어가며

여행 플래닝 앱에서 스케줄 상세 조회 API를 구현하게 되었다. 단순히 "데이터 조회해서 반환하면 되겠지"라고 생각했는데, 생각보다 고민할 게 많았다.
이 글에서는 구현 과정에서 마주친 고민들과 해결 과정을 공유한다.


🤔 고민 1: 응답에 owner를 어떻게 담을까?

상황:

스케줄에는 생성자(owner)초대된 참가자(participants)가 있다. 응답 설계 시 이들을 어떻게 구분할지 고민이 됐다.

선택지:

  1. owner_user 필드와 participants 배열을 분리
  2. participants 배열에 owner를 포함하고, 첫 번째 요소로 구분

결정:

라운지 공개용 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 용도에 따라 같은 데이터도 다르게 표현할 수 있다. 클라이언트 입장에서 어떤 형태가 사용하기 편한지 고민하자.


🤔 고민 2: 중복되는 DTO, 어떻게 할까?

상황:

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) 위반이다. 지금 중복이면 지금 제거하자.


🤔 고민 3: 장소 간 거리를 어떻게 계산할까?

상황:

라운지 스케줄 상세 조회 시 각 장소(Plan) 사이의 거리를 보여주고 싶었다.

선택지:

  1. 직선 거리 (Haversine 공식): 서버에서 바로 계산 가능
  2. 실제 이동 거리: Google Maps API 등 외부 서비스 필요

결정:

일단 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로 충분할 수 있다.


😱 삽질 기록: numeric overflow

상황:

테스트 데이터 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이다.


🔍 QueryDSL로 다국어 데이터 조회

태그, 도시 정보가 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();
  }

📝 마무리

이번 구현에서 배운 것들:

  1. DTO 설계는 용도에 맞게 - 같은 데이터도 API 목적에 따라 다르게 표현

  2. 중복은 바로 제거 - "나중에 달라질 수 있으니까"는 핑계

  3. MVP 먼저 - 완벽한 거리 계산보다 일단 동작하는 것부터

  4. DB 설계 시 데이터 범위 확인 - precision/scale 실수하면 런타임 에러

    다음엔 실제 이동 거리 계산을 위한 외부 API 연동을 고민해봐야겠다. 🚗


    참고 자료

profile
오늘은 어떤 새로운 것이 나를 즐겁게 할까?

0개의 댓글