Now Loading ...
-
Legacy API 리팩토링
특정 프로세스에서 다수의 LEFT JOIN으로 부가 데이터를 함께 조회하던 쿼리를 제거하고,
그 쿼리에 의존하던 로직들을 단일 책임 원칙에 맞게 분리·수정한다.
Composable 함수
export function TermsAgreement(publisherId: Ref<number>) {
const { $api } = useNuxtApp();
const { data: termsResponse } = useAsyncData('terms-response', () => {
if (!publisherId.value) {
return null;
}
return $api.termsService.getTerms(publisherId.value);
});
const terms = computed(() => {
if (!termsResponse.value) {
return [];
}
return termsResponse.value;
});
function getTermsAgreements(userIds: number[]) {
if (!publisherId.value || userIds.length === 0) {
throw new Error('Invalid publisherId or userIds.');
}
return $api.termsService.getTermsAgreements({ publisherId: publisherId.value, userIds: userIds });
}
function isAgreementRequired(userIds: number[], termsAgreements: { [userId: number]: TermsAgreementResponse }) {
if (!publisherId.value || userIds.length === 0) {
return true;
}
if (Object.keys(termsAgreements).length === 0) {
return true;
}
const everyoneAgreedMustTerms = userIds.every((userId) => termsAgreements[userId]?.isMust);
if (!everyoneAgreedMustTerms) {
console.debug('필수 약관 동의하지 않은 유저가 있음.');
return true;
}
console.debug('모두 필수 약관 동의함.');
const hasOptionalTerms = terms.value.some(
(term) => !term.mainTermsClause?.isMust || term.subTermsClauses?.some((subTerms) => !subTerms.isMust),
);
if (hasOptionalTerms) {
const everyoneCheckedOptionalTerms = userIds.every((userId) => termsAgreements[userId]?.agreeOptionalTerms);
if (!everyoneCheckedOptionalTerms) {
console.debug('선택 약관을 확인하지 않은 유저가 있음.');
return true;
}
console.debug('모두 선택 약관 확인함.');
}
return false;
}
return {
getTermsAgreements,
isAgreementRequired,
terms,
};
}
Page Script
const publisherId = computed(() => publisher.value.id);
const { getTermsAgreements, isAgreementRequired, terms } = TermsAgreement(publisherId);
const requestUserIds = computed(() => {
return requests.map((request) => request.userId).filter((id) => id);
});
// 약관 동의 흐름
// 1) 요청 유저들의 약관 동의 이력을 조회한다.
getTermsAgreements(requestUserIds.value).then((response) => {
// 2) 동의 절차 필요 여부 판단
if (isAgreementRequired(requestUserIds.value, response)) {
// 3) 필요 시 약관 동의 모달 표시
showTermsModal();
return;
}
// 4) 불필요하면 다음 단계로 진행
goComplete();
});
// ...
컴포저블 함수를 통해 Script 코드 라인을 줄였다.
약관 관련 로직에 대한 책임은 TermsAgreement 컴포저블 함수에게만 위임된다.
불필요한 시점에는 약관 관련 API를 요청 및 조회하지 않는다.
유저 상세 정보 페이지에서 /my/api/user/{id} API로부터 함께 내려오던 부가 데이터들에 대해
클라이언트/프론트 측면에서 분석해보고 개선점을 찾으려한다.
@GetMapping(value = "/user/{id}")
public Map<String, Object> findUser(@PathVariable("id") String id) {
UserDetail user = userFacadeService.getUserDetail(id);
if (user == null) {
throw MyException("not found user.");
}
Map<String, Object> map = new HashMap<>();
map.put("user", user);
map.put("friends", user.getFriends());
return map;
}
@GetMapping(value = "/user/{id}")
public UserDetail findUser(@PathVariable("id") String id) {
return userFacadeService.getUserDetail(id);
}
public UserDetail getUserDetail(String id) {
UserVO user =
Optional.ofNullable(userService.findById(id))
.orElseThrow(() -> MyException("data not found"));
userService.setAgreement(user);
return UserDetail.from(user, userService.findFriend(id));
}
public UserDetail getUserDetail(String id) {
return Optional.ofNullable(userService.findById(id))
.map(UserDetail::from)
.orElseThrow(() -> MyException("data not found"));
}
기존에 DAO단에서부터 많은 부가적인 데이터를 많이 조회 및 전달하고 있던 로직들을 단일 책임 원칙을 반영하여 유저의 기본 정보만을 조회 및 전달하도록 리팩토링하였다.
유저의 친구들 조회 API 생성
@GetMapping(value = "/user/{id}/friends")
public List<UserFriend> findFriends(@PathVariable("id") String id) {
return userFacadeService.getAllFriends(id);
}
public List<UserFriend> getAllFriends(String id) {
List<UserFriendVO> friends = service.findAllFriends(id);
return UserFriend.from(friends);
}
유저 결제 수단 조회 API 생성
@GetMapping(value = "/user/{id}/credit-card")
public UserCreditCard findUserCreditCard(
@PathVariable("id") String id) {
return userFacadeService.getUserCreditCard(id);
}
public UserCreditCard getUserCreditCard(String id) {
return UserCreditCard.from(userCreditCardService.getCompleteCreditCard(id));
}
유저와 연관된 부가 정보는 별도 API로 데이터를 제공하는 방식으로 리팩토링하여
각각의 API를 프론트 단에서 조립하여 설계하는 방향으로 가보려고 한다.
기존 일반 함수에서 직접 API를 동기식으로 요청하던 방식에서
tanstack/useQuery를 통해 특정 데이터에 의존하여 적절한 시점에 API를 순차적으로 요청하도록 설계해보았다.
const userId = computed(() => {
if (props.userId) {
return props.userId;
}
return route.params.userId;
});
// userId 값이 유효하다면 유저 기본 정보 조회 API 호출
const { data: userDetail, refetch: refetchUserDetail } = useQuery({
queryKey: ['user-detail', () => userId.value],
queryFn: () => $api.userService.userDetail(userId.value),
enabled: computed(() => !!userId.value),
initialData: {},
});
const { data: userFriends } = useQuery({
queryKey: ['user-friends', () => userDetail.value.id],
queryFn: () => $api.userService.getUserFriends(userDetail.value.id),
enabled: computed(() => !!userDetail.value.id),
initialData: [],
});
const { data: userCreditCard } = useQuery({
queryKey: ['user-credit-card', () => userDetail.value.id],
queryFn: () => $api.userService.getUserCreditCard(userDetail.value.id),
enabled: computed(() => !!userDetail.value.id),
});
-
병원 검색 API 성능 최적화: 분할 정복 접근법
개요
본 문서는 병원 검색 시스템의 성능 최적화 과정을 다룹니다. 특히 API 응답 시간 개선과 클라이언트 렌더링 최적화에 중점을 두었습니다.
1차 개선: 쿼리 최적화 접근
개선 방법: 부가 정보 조회 쿼리 분리 및 데이터 병합
서버 측 영향
✅ 개선된 부분
DB 체류 시간 감소 (복잡한 쿼리 해소)
코드 가독성 및 유지보수성 향상
메모리 효율성 개선 (Map 구조 활용)
⚠️ 한계점
DB 접근 횟수 증가
전체 처리 시간 유지
클라이언트 측 영향
❌ 미해결 문제
API 응답 속도 개선 미미
렌더링 성능 목표 미달성
사용자 체감 성능 개선 부족
성능 측정 결과
측정 항목
기존
1차 개선 후
개선율
DB 쿼리 실행 시간
2.5s
0.8s
68% ↓
전체 API 응답 시간
3.2s
2.9s
9% ↓
메모리 사용량
450MB
280MB
38% ↓
2차 개선: API 분할 전략
1차 개선의 한계를 극복하기 위해 API 분할 전략을 도입했습니다. 이 접근법은 데이터의 사용 시점과 중요도에 따라 API를 분리하는 방식입니다.
페이지 구조 분석
핵심 데이터 식별
즉시 필요한 데이터
병원명
원격진료 활성화 여부
예약 신청 가능 여부
바로 접수 신청 가능 여부
진료 과목 리스트
최적화 전략
데이터 분류
즉시 필요한 핵심 데이터
지연 로딩 가능한 부가 데이터
성능 개선 효과
초기 응답 시간: 65% 감소
메모리 사용량: 45% 감소
첫 렌더링 시간: 58% 개선
API 분할 구현
1. 진료실 정보 처리
@GetMapping("/api/hospitals")
public Map<Long, HospitalDto> getAllHospitals() {
// 병원 기본 정보 조회
List<Hospital> hospitals = hospitalRepository.findAll();
// 진료실 정보 맵핑
Map<Long, List<Room>> roomMap = hospitals.stream()
.collect(Collectors.groupingBy(
Hospital::getId,
Collectors.mapping(
hospital -> roomRepository.findByHospitalId(hospital.getId()),
Collectors.toList()
)
));
return hospitals.stream()
.collect(Collectors.toMap(
Hospital::getId,
hospital -> new HospitalDto(hospital, roomMap.get(hospital.getId()))
));
}
2. 즐겨찾기 정보 분리
// 즐겨찾기 API 인터페이스
interface FavoriteResponse {
hospitalId: number;
isFavorite: boolean;
}
// 별도 API 엔드포인트
@GetMapping("/api/hospitals/favorites")
public Map<Long, Boolean> getFavorites(@RequestParam Long userSeq) {
return favoriteRepository.findByUserSeq(userSeq)
.stream()
.collect(Collectors.toMap(
Favorite::getHospitalId,
favorite -> true
));
}
3. 운영 시간 정보 최적화
// 운영 시간 조회 인터페이스
interface OperatingHours {
weekday: string[];
weekend: string[];
holiday: string[];
}
// 지연 로딩을 위한 별도 API
@GetMapping("/api/hospitals/{hospitalId}/hours")
public Map<String, List<String>> getOperatingHours(@PathVariable Long hospitalId) {
Hospital hospital = hospitalRepository.findById(hospitalId)
.orElseThrow(() -> new NotFoundException("Hospital not found"));
return hospital.getOperatingHours()
.stream()
.collect(groupingBy(
OperatingHour::getType,
mapping(OperatingHour::getTimeSlot, toList())
));
}
클라이언트 최적화
1. 컴포저블 함수 리팩토링
// 검색 로직을 컴포저블 함수로 추상화
export function useHospitalSearch() {
const hospitals = ref<Hospital[]>([])
const filteredHospitals = computed(() => {
return filterHospitals(hospitals.value, searchOptions.value)
})
// 반응형 검색 결과 처리
watch(filteredHospitals, async (newHospitals) => {
if (newHospitals.length) {
await fetchOperatingHours(newHospitals.map(h => h.id))
}
})
return {
hospitals,
filteredHospitals
}
}
2. 검색 필터링 로직
interface FilterOptions {
keyword: string;
speciality?: string;
sortBy?: 'default' | 'distance';
}
function filterHospitals(hospitals: Hospital[], options: FilterOptions): Hospital[] {
let filtered = hospitals;
// 1. 키워드 기반 필터링
if (options.keyword) {
filtered = filtered.filter(hospital => {
// 진료 유형 검색
if (isTreatmentType(options.keyword)) {
return hospital.acceptsTreatmentType(options.keyword);
}
// 진료 과목 검색
if (isSpeciality(options.keyword)) {
return hospital.hasSpeciality(options.keyword);
}
// 병원명 검색
return hospital.name.includes(options.keyword);
});
}
// 2. 진료 과목 추가 필터링
if (options.speciality) {
filtered = filtered.filter(h => h.hasSpeciality(options.speciality));
}
// 3. 정렬 적용
return sortHospitals(filtered, options.sortBy);
}
3. 정렬 로직
function sortHospitals(hospitals: Hospital[], sortBy?: 'default' | 'distance'): Hospital[] {
switch (sortBy) {
case 'distance':
return sortByDistance(hospitals);
default:
return sortByOperatingStatus(hospitals);
}
}
// 현재 운영 중인 병원 우선 정렬
function sortByOperatingStatus(hospitals: Hospital[]): Hospital[] {
return hospitals.sort((a, b) => {
if (a.isCurrentlyOperating && !b.isCurrentlyOperating) return -1;
if (!a.isCurrentlyOperating && b.isCurrentlyOperating) return 1;
return 0;
});
}
4. 즐겨찾기 관리
export function useFavorites() {
const favorites = ref<Set<number>>(new Set());
const toggleFavorite = async (hospitalId: number) => {
try {
await api.post(`/hospitals/${hospitalId}/favorite`);
await refreshFavorites();
} catch (error) {
console.error('Failed to toggle favorite:', error);
}
};
// 즐겨찾기 목록 갱신
const refreshFavorites = async () => {
const response = await api.get('/hospitals/favorites');
favorites.value = new Set(Object.keys(response.data).map(Number));
};
return {
favorites,
toggleFavorite,
refreshFavorites
};
}
성능 개선 결과
측정 지표
개선 전
개선 후
향상율
초기 로딩 시간
3.2s
0.8s
75%
검색 응답 시간
1.5s
0.3s
80%
메모리 사용량
450MB
180MB
60%
렌더링 시간
2.1s
0.5s
76%
Touch background to close