공부 내용을 정리하고 앞으로의 학습에 이해를 돕기 위해 작성합니다.
이번 포스팅에서는 고객의 위치와 가까운 약국을 찾아주는 기능을 구현한다. 이 기능을 구현하기 위해 먼저 거리 계산 알고리즘을 알아본다.
이전 단계에서는 고객이 주소를 입력하면, 시스템이 해당 주소를 카카오 API를 통해 위도와 경도로 변환하는 작업을 구현했다. 그리고 공공기관에서 제공하는 약국 데이터를 DB에 저장하여 필요한 데이터를 확보했다. 이제 남은 작업은 이 데이터를 기반으로 고객의 위치와 가장 가까운 약국을 찾아주는 것이다.
이를 위해 Haversine formula라는 거리 계산 알고리즘을 사용한다. 이 알고리즘은 두 위도와 경도 사이의 거리를 계산할 때 사용되며, 지구를 완전한 구로 가정하여 0.5% 정도의 오차를 포함할 수 있다. 하지만 해당 프로젝트에서는 이러한 오차가 큰 영향을 주지 않기 때문에 Haversine formula를 사용한다.
Haversine formula를 이용한 거리 계산 로직을 구현하기 위해 DirectionService 클래스를 생성한다. 이 클래스는 고객의 위도와 경도, 그리고 약국의 위도와 경도를 입력받아 두 지점 간의 거리를 계산하는 메서드를 포함한다.
package com.example.phamnav.direction.service;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
@Slf4j
@Service
@RequiredArgsConstructor
public class DirectionService {
// Haversine formula
private double calculateDistance(double lat1, double lon1, double lat2, double lon2) {
lat1 = Math.toRadians(lat1);
lon1 = Math.toRadians(lon1);
lat2 = Math.toRadians(lat2);
lon2 = Math.toRadians(lon2);
double earthRadius = 6371; //Kilometers
return earthRadius * Math.acos(Math.sin(lat1) * Math.sin(lat2) + Math.cos(lat1) * Math.cos(lat2) * Math.cos(lon1 - lon2));
}
}
- 먼저 DirectionService 클래스에서는 입력받은 위도와 경도를 라디안으로 변환한 후, Haversine formula를 이용해 지구 반지름(6371km)을 기준으로 두 지점 간의 거리를 계산한다.
약국 엔티티 및 DTO 설계
다음으로, 약국과 고객의 위치 정보를 저장하기 위한 Direction 엔티티를 설계한다. 이 엔티티는 고객과 약국의 위도, 경도 및 두 지점 간의 거리를 포함하며, 데이터베이스에 저장될 정보를 나타낸다.
package com.example.phamnav.direction.entity;
import com.example.phamnav.BaseTimeEntity;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
@Entity(name = "direction")
@AllArgsConstructor
@NoArgsConstructor
@Builder
@Getter
public class Direction extends BaseTimeEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
// 고객
private String inputAddress;
private double inputLatitude;
private double inputLongitude;
// 약국
private String targetPharmacyName;
private String targetAddress;
private double targetLatitude;
private double targetLongitude;
// 고객 주소와 약국 주소 사이의 거리
private double distance;
}
- Direction 엔티티는 고객과 약국의 정보를 담고 있으며, 두 지점 간의 거리도 포함하고 있다. 이 엔티티는 데이터베이스와 밀접한 관련이 있기 때문에, 실제 비즈니스 로직에서 사용될 때는 엔티티 대신 DTO를 통해 데이터를 전달한다.
DTO는 데이터베이스와의 직접적인 연관을 피하고, 데이터 전달을 목적으로 한다. 이렇게 하면 엔티티가 변경되더라도 DB 구조를 건드리지 않게 되므로 유지보수성이 높아진다. PharmacyDto는 약국의 이름, 주소, 위도, 경도 등의 정보를 포함하고 있으며, 엔티티와 분리되어 있어 데이터 전달에 유용하다.
package com.example.phamnav.pharmacy.dto;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
@Getter
@Builder
@AllArgsConstructor
@NoArgsConstructor
public class PharmacyDto {
private Long id;
private String pharmacyName;
private String pharmacyAddress;
private double latitude;
private double longitude;
}
약국 데이터 검색 서비스 구현
PharmacySearchService 클래스는 데이터베이스에서 약국 데이터를 검색하여 DTO 리스트로 반환하는 역할을 한다. 이 클래스는 엔티티를 DTO로 변환하여 사용하며, 이를 통해 데이터베이스와의 직접적인 연관을 피하고 유지보수성을 높인다.
package com.example.phamnav.pharmacy.service;
import com.example.phamnav.pharmacy.dto.PharmacyDto;
import com.example.phamnav.pharmacy.entity.Pharmacy;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import java.util.List;
import java.util.stream.Collectors;
@Slf4j
@Service
@RequiredArgsConstructor
public class PharmacySearchService {
private final PharmacyRepositoryService pharmacyRepositoryService;
public List<PharmacyDto> searchPharmacyDtoList() {
//redis
//db
return pharmacyRepositoryService.findAll()
.stream()
.map(this::convertToPharmacyDto)
.collect(Collectors.toList());
}
private PharmacyDto convertToPharmacyDto(Pharmacy pharmacy) {
return PharmacyDto.builder()
.id(pharmacy.getId())
.pharmacyAddress(pharmacy.getPharmacyAddress())
.pharmacyName(pharmacy.getPharmacyName())
.latitude(pharmacy.getLatitude())
.longitude(pharmacy.getLongitude())
.build();
}
}
- 필드
- pharmacyRepositoryService: 약국 데이터를 검색하기 위해 사용하는 리포지토리 서비스다. 이 클래스는 실제 데이터베이스와 상호작용하여 데이터를 가져온다.
- 메서드
- searchPharmacyDtoList(): 이 메서드는 약국 데이터를 검색하고, 그 결과를 PharmacyDto 리스트로 반환한다. PharmacyRepositoryService를 통해 모든 약국 데이터를 가져오며, 각 약국 엔티티를 DTO로 변환하여 리스트로 수집한다.
- convertToPharmacyDto(Pharmacy pharmacy)
- 이 메서드는 Pharmacy 엔티티를 PharmacyDto로 변환하는 역할을 한다. DTO로 변환함으로써, 엔티티가 직접적으로 외부로 노출되는 것을 방지하고, 데이터 전달의 명확성을 높인다.
가장 가까운 약국 찾기 로직 구현
DirectionService 클래스는 고객의 주소 정보를 바탕으로 가장 가까운 약국 3 곳을 찾는 기능을 제공한다. 이 클래스는 PharmacySearchService를 이용해 약국 데이터를 가져오고, Haversine Formula를 이용해 거리를 계산한다.
package com.example.phamnav.direction.service;
import com.example.phamnav.api.dto.DocumentDto;
import com.example.phamnav.direction.entity.Direction;
import com.example.phamnav.pharmacy.service.PharmacySearchService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import java.util.Collections;
import java.util.Comparator;
import java.util.List;
import java.util.Objects;
import java.util.stream.Collectors;
@Slf4j
@Service
@RequiredArgsConstructor
public class DirectionService {
private static final int MAX_SEARCH_COUNT = 3; // 약국 최대 검색 갯수
private static final double RADIUS_KM = 10.0; // 반경 10 km
private final PharmacySearchService pharmacySearchService;
public List<Direction> buildDriectionList(DocumentDto documentDto) {
if(Objects.isNull(documentDto)) return Collections.emptyList();
return pharmacySearchService.searchPharmacyDtoList()
.stream().map(pharmacyDto ->
Direction.builder()
.inputAddress(documentDto.getAddressName())
.inputLatitude(documentDto.getLatitude())
.inputLongitude(documentDto.getLongitude())
.targetPharmacyName(pharmacyDto.getPharmacyName())
.targetAddress(pharmacyDto.getPharmacyAddress())
.targetLatitude(pharmacyDto.getLatitude())
.targetLongitude(pharmacyDto.getLongitude())
.distance(
calculateDistance(documentDto.getLatitude(), documentDto.getLongitude(),
pharmacyDto.getLatitude(), pharmacyDto.getLongitude())
)
.build())
.filter(direction -> direction.getDistance() <= RADIUS_KM)
.sorted(Comparator.comparing(Direction::getDistance))
.limit(MAX_SEARCH_COUNT)
.collect(Collectors.toList());
}
// Haversine formula
private double calculateDistance(double lat1, double lon1, double lat2, double lon2) {
lat1 = Math.toRadians(lat1);
lon1 = Math.toRadians(lon1);
lat2 = Math.toRadians(lat2);
lon2 = Math.toRadians(lon2);
double earthRadius = 6371; //Kilometers
return earthRadius * Math.acos(Math.sin(lat1) * Math.sin(lat2) + Math.cos(lat1) * Math.cos(lat2) * Math.cos(lon1 - lon2));
}
}
- 상수
- MAX_SEARCH_COUNT: 최대 검색 결과 수를 나타내며, 최대 3개의 약국을 반환한다.
- RADIUS_KM: 검색할 반경을 나타내며, 반경 10km 이내의 약국을 대상으로 한다.
- 필드
- pharmacySearchService: 약국 데이터를 검색하기 위해 사용하는 서비스.
- 메서드
- buildDriectionList(DocumentDto documentDto): 이 메서드는 고객의 위치(documentDto)를 입력받아, 반경 10km 내에서 가장 가까운 약국 3곳을 검색하여 Direction 객체로 반환한다. 여기서 Direction은 고객과 약국 간의 거리 정보와 함께 저장된다.
- 프로세스
- documentDto가 null인지 확인하여 null인 경우 빈 리스트를 반환한다.
- PharmacySearchService를 사용해 약국 데이터를 가져온다.
- 각 약국 데이터를 바탕으로 Direction 객체를 생성하며, 이 객체에는 고객의 위치, 약국의 위치, 그리고 두 지점 간의 거리가 포함된다.
- 이 Direction 객체 리스트를 거리 순으로 정렬하고, 반경 10km 이내에 있는 최대 3개의 약국을 필터링하여 반환한다.
- calculateDistance(double lat1, double lon1, double lat2, double lon2): 이 메서드는 Haversine Formula를 이용해 두 지점 간의 거리를 계산한다. 입력받은 위도와 경도를 라디안으로 변환한 후, 지구의 반지름(6371km)을 기준으로 거리를 계산하여 반환한다.
테스트 코드 작성
마지막으로, DirectionServiceTest 클래스를 작성하여 서비스 로직이 의도한 대로 동작하는지 검증한다. 이 테스트는 거리 순으로 약국이 잘 정렬되는지, 반경 10km 내에서 약국이 검색되는지를 확인한다.
package com.example.phamnav.direction.service
import com.example.phamnav.api.dto.DocumentDto
import com.example.phamnav.pharmacy.dto.PharmacyDto
import com.example.phamnav.pharmacy.service.PharmacySearchService
import spock.lang.Specification
class DirectionServiceTest extends Specification {
private PharmacySearchService pharmacySearchService = Mock()
private DirectionService directionService = new DirectionService(pharmacySearchService)
private List<PharmacyDto> pharmacyList
def setup() {
pharmacyList = new ArrayList<>()
pharmacyList.addAll(PharmacyDto.builder()
.id(1L)
.pharmacyName("돌곶이온누리약국")
.pharmacyAddress("주소1")
.latitude(37.61040424)
.longitude(127.0569046)
.build(),
PharmacyDto.builder()
.id(2L)
.pharmacyName("호수온누리약국")
.pharmacyAddress("주소2")
.latitude(37.60894036)
.longitude(127.029052)
.build())
}
def "buildDirectionList - 결과 값이 거리 순으로 정렬이 되는지 확인"() {
given:
def addressName = "서울 성북구 종암로10길"
double inputLatitude = 37.5960650456809
double inputLongitude = 127.037033003036
def documentD = DocumentDto.builder()
.addressName(addressName)
.latitude(inputLatitude)
.longitude(inputLongitude)
.build()
when:
pharmacySearchService.searchPharmacyDtoList() >> pharmacyList
def results = directionService.buildDriectionList(documentD)
then:
results.size() == 2
results.get(0).targetPharmacyName == "호수온누리약국"
results.get(1).targetPharmacyName == "돌곶이온누리약국"
}
def "buildDirectionList - 정해진 반경 10 km 내에 검색이 되는지 확인"() {
given:
pharmacyList.add(PharmacyDto.builder()
.id(3L)
.pharmacyName("경기약국")
.pharmacyAddress("주소3")
.latitude(37.3825107393401)
.longitude(127.236707911313)
.build())
def addressName = "서울 성북구 종암로10길"
double inputLatitude = 37.5960650456809
double inputLongitude = 127.037033003036
def documentDto = DocumentDto.builder()
.addressName(addressName)
.latitude(inputLatitude)
.longitude(inputLongitude)
.build()
when:
pharmacySearchService.searchPharmacyDtoList() >> pharmacyList
def results = directionService.buildDriectionList(documentDto)
then:
results.size() == 2
results.get(0).targetPharmacyName == "호수온누리약국"
results.get(1).targetPharmacyName == "돌곶이온누리약국"
}
}
주요 구성 요소
- 테스트 대상 클래스 (DirectionService):
- DirectionService 클래스는 주어진 위치 정보(위도, 경도)를 바탕으로 가까운 약국 목록을 빌드하는 역할을 한다. 이를 위해 약국 데이터를 검색하고, 거리 계산을 통해 가까운 약국들을 찾아낸다.
- Mock 객체 (PharmacySearchService):
- PharmacySearchService는 실제로 약국 데이터를 검색하는 서비스 클래스다. 이 테스트에서는 실제 PharmacySearchService의 동작을 대체하기 위해 Mock 객체로 대체하여 사용한다. Mock 객체는 테스트 중 특정 메서드 호출에 대해 미리 정의된 응답을 반환하도록 설정할 수 있다.
- 테스트 시나리오:
- setup 메서드: 테스트 전에 실행되는 초기화 메서드. 여기서 테스트에 사용될 약국 데이터를 생성하여 리스트에 추가한다.
- buildDirectionList 메서드: DirectionService 클래스의 주요 메서드로, 주어진 위치 정보를 바탕으로 약국 데이터를 가져와 거리 순으로 정렬된 약국 리스트를 빌드한다.
테스트 시나리오 설명
buildDirectionList - 결과 값이 거리 순으로 정렬이 되는지 확인한다.
- Given: 테스트의 초기 상태를 설정한다.
- 입력으로 사용할 주소와 위도, 경도 값을 설정한다.
- DocumentDto 객체를 빌드하여 입력 데이터를 준비한다.
- When: 테스트의 주요 동작을 실행한다.
- pharmacySearchService.searchPharmacyDtoList() 메서드 호출 시 미리 설정해 둔 pharmacyList를 반환하도록 Mock 설정을 한다.
- directionService.buildDriectionList(documentD) 메서드를 호출하여 결과를 받아온다.
- Then: 기대하는 결과를 확인한다.
- 결과 리스트의 크기가 2여야 한다는 것을 확인한다.
- 첫 번째 약국이 "호수온누리약국"이어야 하고, 두 번째 약국이 "돌곶이온누리약국"이어야 한다는 것을 검증한다.
buildDirectionList - 정해진 반경 10 km 내에 검색이 되는지 확인한다.
- Given: 테스트의 초기 상태를 설정한다.
- 첫 번째 테스트와 비슷하지만, 추가로 pharmacyList에 한 약국("경기약국")을 더 추가한다.
- When: 테스트의 주요 동작을 실행한다.
- Mock 설정은 동일하며, pharmacySearchService.searchPharmacyDtoList()가 pharmacyList를 반환하도록 설정한다.
- directionService.buildDriectionList(documentDto) 메서드를 호출하여 결과를 받아온다.
- Then: 기대하는 결과를 확인한다.
- 결과 리스트의 크기가 여전히 2여야 한다는 것을 확인한다.
- 첫 번째 약국이 "호수온누리약국", 두 번째 약국이 "돌곶이온누리약국"이어야 한다는 것을 확인한다. 세 번째 약국(추가된 "경기약국")은 반경 10km를 초과하여 결과에 포함되지 않음을 확인한다.
'BackEnd > Project' 카테고리의 다른 글
[PharmNav] Ch05. 추천 결과 저장 기능 구현 (0) | 2024.09.03 |
---|---|
[PharmNav] Ch05. Spring retry (0) | 2024.09.03 |
[PharmNav] Ch05. 약국 데이터 셋업 (4) | 2024.09.03 |
[PharmNav] Ch05. Spring Transactional 사용시 주의사항 (2) | 2024.09.03 |
[PharmNav] Ch05. JPA Auditing으로 생선시간-수정시간 자동화 구현 (1) | 2024.09.03 |