회고록

퍼피캣 프로젝트 개선 사례 및 경험한 것 들

odong2 2024. 3. 24. 02:28

성능 개선 사례 (1)

문제 상황 (이미지 로딩 지연)

오른쪽 이미지와 같이 퍼피캣 프로젝트의 메인 페이지는 로딩 시 최근 게시물 순으로 한 페이지당 20개의 컨텐츠를 불러오는 상황이었습니다.
한 컨텐츠당 이미지 최대 등록 개수는 12개로 정책이 픽스되어 있었습니다.

 

만약 20개의 컨텐츠가 최대 이미지 개수로 등록인 된 경우 240개의 이미지를 불러오게 되는 상황으로 실제 테스트 시 API 응답 속도가 뿐 아니라 이미지 다운로드로 인해 로딩 시간이 오래 걸리는 문제에 직면하였습니다. 

컨텐츠에 등록된 첫 번째 이미지를 제외하면, 사용자가 해당 컨텐츠의 이미지를 스와이프 하여야 다음 이미지를 볼 수 있는 상황이었습니다.
그러므로 사용자는 이 이미지를 모두 볼 수도 있지만 보지 않고 스크롤을 내릴 수 있기 때문에 모든 이미지를 불러오는 것은 리소스 낭비였습니다.

해결 (최초 업로드 시 일부 이미지만 제공)

해당 컨텐츠의 이미지는 스와이프 기능을 통해 다음 사진을 볼 수 있게 되는데
최초 로딩 시 5개의 이미지만 불러오고 사용자가 스와이프를 통해 4번째 이미지를 볼 때 나머지 이미지를 불러오도록 아래의 API를 추가로 작업하였습니다.

아래의 url로 요청

/v1/contents/{contentsIdx}/images

 

해당 엔드포인트로 요청 시 파라미터로 imgOffSet(이미지 노출 시작 위치) 값을 받아 해당 imgOffSet 부터 마지막 12번째 이미지까지 응답하여 렌더링 하도록 변경하였습니다.

이러한 개선으로 인해 최대 로딩 속도가 1.2s → 0.5s로 줄어드는 효과를 볼 수 있었습니다.

 


성능 개선 사례 (2)

 

문제 상황 (컨텐츠 등록 시 알림 등록으로 인한 속도 저하)

컨텐츠 등록 시 나를 팔로우하고 있는 회원에게 '~님이 새로운 피드를 올렸어요'라는 문구와 함께 알림이 가야 하는 요구사항이 있었습니다.

첫 구현 시 한 트렌젝션 안에서 컨텐츠 등록, 이미지 업로드, 팔로우한 회원에게 알림 전송, 맨션 된 회원에게 알림 전송 등을 수행하도록 작업하였습니다.

 

만약 해당 게시글 작성자의 팔로우가 만 명인 경우 알림 테이블에 만개의 데이터가 insert 되는 상황이었습니다.

이로 인해 실제 테스트 시 팔로우가 많을수록 너무 많은 태스크로 인해 하나의 컨텐츠 등록에 오랜 시간이 걸리고,  하나의 프로세스에서 각 비즈니스가 강하게 결합된 코드 형태로 하나의 로직이 실패하면 모든 프로세스가 실패하는 문제에 직면하게 되었습니다. 

해결 (Message Queue 사용)

컨텐츠 등록 알림과 맨션 알림에 AWS SNS + SQS를 사용하여 메시지 큐 방식을 적용하였습니다.

AWS SNS에 컨텐츠 등록 토픽을 만든 후 Subscriber로 SQS(FIFO)를 지정하였습니다.

컨텐츠가 정상적으로 등록이 되면 등록된 해당 컨텐츠 키 값을 SQS에 쌓고, 스케줄러에서 SQS에 쌓인 메시지를 가져와 처리하도록 하는 이벤트 기반의 아키텍쳐를 적용하였습니다.

만약 실패한 메시지는 DLQ(Dead Letter Queue)에 쌓아 실패한 이유를 확인하고 디버깅할 수 있도록 하였습니다.

 

메시지 큐 방식을 컨텐츠 등록에 적용함으로써 컨텐츠 등록 시간이 상당히 줄어들었을 뿐 아니라 강하게 결합되어 있던 코드를 분리하여 유지보수 및 장애 대응에 드는 비용을 줄일 수 있는 경험을 할 수 있었습니다. 

 

 


성능 개선 사례 (3)

 

문제 상황 (컨텐츠 리스트 조회 시 속도 저하)

테스트를 통해 컨텐츠 리스트 조회 시 응답 속도가 예상했던 것 보다 느리다는 생각이 있었습니다.

해당 테이블에 필요한 인덱스는 미리 생성해 두었기 때문에 별 의심하지 않고 작업을 진행했었습니다.

테스트 이후 해당 쿼리문을 explain 해 보니 Extra가 null인 것을 확인 알 수 있었습니다.

알고 보니 필요한 컬럼에 인덱스는 모두 적용되어 있었지만, 불필요한 컬럼들을 같이 조회하면서 테이블을 스캔하고 있는 것을 알게 되었습니다.

 

해결 (커버링 인덱스 적용)

해당 쿼리문에 커버링 인덱스를 적용하기 위해 인덱스가 적용되지 않은 불필요한 컬럼을 제거하였습니다.

이로 인해 테이블을 스캔하지 않고 인덱스만 조회하여 쿼리 속도를 향상해 응답 속도 저하 문제를 해결할 수 있었습니다.

이 이후로는 쿼리 작성 시 항상 Explain을 통해 실행 계획과 인덱스를 제대로 타는지 또는 테이블 풀 스캔은 하지 않는지 확인하는 습관이 생겼습니다.

 


코드 개선 사례

 

상황

퍼피캣 프로젝트는 MSA(MicroService Architecture) 기반의 프로젝트로 회원 서버, SNS 서버, SHOP 서버, 결제 서버 등 여러 개의 작은 서비스로 구성되어 각 서비스가 독립적인 구조였습니다.

그로 인해 SNS에서 회원의 정보가 필요한 경우 CURL 통신을 통해 회원 서버에서 회원의 정보를 가져와야 하는 구조로 되어있었습니다.

기존에는 CURL 통신을 WebClient를 사용하였는데 불필요하게 코드가 길어지고, curl 통신에 대한 코드가 각 서비스에 종속되어 유지 보수에 어렵다는 단점을 가지고 있었습니다.

이로 인해 OpenFeign에 대해 조사하게 되었고, 테스트 케이스 작성과 조사한 자료를 사내 컨퍼런스를 통해 발표하여 도입하고 적용하게 되었습니다.

 

아래는 같은 기능을 하는 WebClient, openFeign코드 비교입니다.

 

WebClient 사용 시

@SneakyThrows
private List<PetDto> getPetUuidListByCurl(PetDto petDto) {
    List<PetDto> petList = new ArrayList<>(); // 리턴할 결과 값

    if (Boolean.TRUE.equals(usePetWalkCurl)) {
        // curl 통신
        WebClient webClient = WebClient.builder()
                .baseUrl(walkDomain)
                .build();

        String jsonString = webClient.get()
                .uri(uriBuilder -> uriBuilder
                        .path("/v1/pet/walk/entry")
                        .queryParam("walkUuid", petDto.getWalkUuid())
                        .queryParam("memberUuid", petDto.getMemberUuid())
                        .build())
                .retrieve()
                .bodyToMono(String.class)
                .onErrorResume(throwable -> {
                    throw new CustomException(CustomError.WALK_UUID_ERROR); // 존재하지 않는 산책 고유아이디입니다.
                })
                .block();

        // curl 통신 정상
        if (!ObjectUtils.isEmpty(jsonString)) {
            JsonParser parser = new JsonParser();
            ObjectMapper mapper = new ObjectMapper();

            JsonObject jsonObject = (JsonObject) parser.parse(jsonString); // json object 파싱

            String dataJson = jsonObject.get("data").toString();
            JsonObject listJson = (JsonObject) parser.parse(dataJson); // data 안 list 파싱
            List<String> petUuidList = mapper.readValue(listJson.get("list").toString(), List.class);

            if (!ObjectUtils.isEmpty(petUuidList)) {
                for (String uuid : petUuidList) {
                    PetDto tmpPetDto = PetDto.builder()
                            .uuid(uuid)
                            .memberIdx(petDto.getMemberIdx()).build();
                    PetDto walkPetInfo = memberPetService.getWalkPetInfo(tmpPetDto);
                    if (petDto != null) {
                        // 상태값 text setting
                        memberPetService.stateText(walkPetInfo);
                        petList.add(walkPetInfo);
                    }
                }
            }
        }
    }

    return petList;
}

 

 

openFeign 사용 시

private List<PetDto> getPetUuidListByCurl(PetDto petDto) {
    if (Boolean.TRUE.equals(usePetWalkCurl)) {
        List<PetDto> petList = new ArrayList<>(); // 리턴할 결과 값
        // walk curl 통신
        List<String> petUuidList;

        try {
            petUuidList = memberCurl.petUuidList(petDto.getWalkUuid(), petDto.getMemberUuid());
        } catch (FeignException e) {
            petUuidList = null;
        }

        if (!ObjectUtils.isEmpty(petUuidList)) {
            for (String uuid : petUuidList) {
                PetDto tmpPetDto = PetDto.builder()
                        .uuid(uuid)
                        .memberIdx(petDto.getMemberIdx()).build();
                PetDto walkPetInfo = memberPetService.getWalkPetInfo(tmpPetDto);
                if (petDto != null) {
                    // 상태값 text setting
                    memberPetService.stateText(walkPetInfo);
                    petList.add(walkPetInfo);
                }
            }
        }
	}
  return petList;
}

위 코드에서 memberCurl은 인터페이스로 openFeign에서 사용하는 어노테이션으로 정의된 인터페이스입니다.

위의 두 코드는 같은 기능을 하는 코드이지만 openFeign 사용 시 코드가 훨씬 간결해지는 것을 알 수 있습니다.

 

얻은 효과

openFeign을 적용하게 되면서 얻은 효과는 아래와 같습니다.

 

1. 어노테이션 기반의 간편한 사용

     - 아래의 코드와 같이 단순히 인터페이스를 정의하고, 어노테이션 기반으로 개발이 가능해졌습니다.

@FeignClient(name = "users", url = "https://api.example.com")
public interface UserServiceClient {
    
	@GetMapping("/users/{id}")
	User getUserById(@PathVariable("id") Long id);

	@PostMapping("/users")
	User registerUser(User user);

	@DeleteMapping("/users/{idx}")
	int deleteUser(@PathVariable("idx") Long idx);
}

 

2. 유지보수 및 재활용성 증가

    - 하나의 인터페이스에서 컬통신에 대한 부분만 관리하다 보니 유지보수에 편의성이 증가했습니다.

    - 또한 각 서비스 계층에서 해당 인터페이스를 주입받아 사용할 수 있어 재활용성이 증가했습니다.

 

3. 비즈니스 로직에 집중

   - 복잡한 로직에서 벗어나다 보니 비즈니스 로직에 더욱 집중할 수 있게 되어 업무 효율이 증대되었습니다.

 

 


경험 (Jmeter를 통한 단위 테스트 및 통합 테스트)

 

Jmeter 통계 그래프 및 테스트 케이스 리스트

 

프로젝트 초반 단계에 Junit5를 통해 단위 테스트 및 통합 테스트르 진행했었습니다.

하지만 프로젝트의 출시일이 앞당겨지면서 시간적 여유가 없어져 테스트 도구인 Jmeter를 사용하는 방향으로 전환되었습니다.

 

Jmeter의 기능 중 여러 쓰레드를 생성하여 동시 다발적으로 요청을 보낼 수 있어 기능 테스트뿐 아니라 부하 테스트용으로도 사용하였습니다.

 

하나의 작업이 끝나면 Jmeter에 테스트 케이스를 작성하여 GitHub에 올림으로써 추후 코드 리팩터링 후 테스트에 대한 부담이 확연히 줄어들었습니다.

 

또한 테스트 통계 그래프를 지원하여 실행 횟수, 평균점수, 에러 발생, 에러발생비율, 데이터양 등 그래프 형식으로 시각화하여 볼 수 있어 많은 도움이 되는 것을 경험할 수 있었습니다.