[사이드 프로젝트] 모아모아 백엔드 리팩토링 #1

윤여준·2024년 1월 12일
0
post-thumbnail

소개

이번에 새로운 프로젝트 팀에 들어가게 되었다. 이 팀에서는 모아모아라고 하는 네컷사진 포즈 추천 서비스를 운영하고 있다. 서비스 링크

팀에 합류한 후에 가장 먼저 작성되어 있는 코드를 읽고 이해한 후에 리팩토링하는 작업을 수행했다.

문제점

모아모아에는 랜덤으로 포즈를 조회할 수 있는 기능이 있다. 이때 10번 동안은 포즈가 겹치지 않아야 한다는 요구사항이 있다.

랜덤 포즈 조회를 담당하는 기존의 코드는 다음과 같다.

import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import java.util.Random;
import java.util.stream.Collectors;

@Transactional
public class PoseService {
    private final User_AlbumJPARepository user_albumJPARepository;
    private final PoseJPARepository poseJPARepository;

    private List<Integer> randomIndexList = new ArrayList<>();

    /**
     * 랜덤 포즈 조회
     * 인원 수에 따른 랜덤 포즈 개별 조회
     */
    public PoseResponse.PoseDTO randomPoseDetailByPeopleCount(int peopleCount) {
        List<Pose> poseList = poseJPARepository.findByPeopleCountAndOpenAndPass(peopleCount, true, "ACCEPT");

        if (randomIndexList.size() == 10) {
            randomIndexList.clear();
        }

        Random random = new Random();
        random.setSeed(System.currentTimeMillis());

        int randomIndex;
        while (true) {
            randomIndex = random.nextInt(poseList.size());
            if (!randomIndexList.contains(randomIndex)) {
                randomIndexList.add(randomIndex);
                break;
            }
        }
        Pose randomPose = poseList.get(randomIndex);

        return new PoseResponse.PoseDTO(randomPose);
    }

    /**

문제점 1 - 포즈 중복 체크 로직 성능

위의 코드 중 아래 부분에서 성능을 개선시킬 수 있다는 생각이 들었다.

		int randomIndex;
        while (true) {
            randomIndex = random.nextInt(poseList.size());
            if (!randomIndexList.contains(randomIndex)) {
                randomIndexList.add(randomIndex);
                break;
            }
        }
        Pose randomPose = poseList.get(randomIndex);

위 로직은
1. 랜덤으로 인덱스를 생성한다.
2. 랜덤 인덱스가 이전에 나온적이 있는지 randomIndexList.contains(randomIndex)를 이용해서 검사한다.
3. 만약 이전에 나온적이 있다면 1번으로 돌아간다.
4. 만약 이전에 나온적이 없다면 이 랜덤 인덱스를 randomIndexList에 추가하고 반복문을 빠져나온다.
5. 해당 랜덤 인덱스로 사진을 조회한다.
의 순서로 이루어진다.

여기서 contains 함수를 사용해서 ArrayList에 특정 원소가 존재하는지 체크하는 것을 개선하고 싶었다.

ArrayList의 contains 함수의 시간 복잡도는 O(n)이다.
while 문 안에서 ArrayList의 contains 함수를 호출하기 때문에 위 로직의 시간 복잡도는 O(n^2)으로 판단했다.

물론 지금의 요구사항은 10번 동안 사진이 겹치지 않아야 된다는 것이기 때문에 로직의 시간 복잡도가 O(n^2)이어도 크게 성능 저하가 발생하지 않는다.

하지만 요구사항은 언제든 바뀔 수 있기 때문에 성능 저하가 발생할 수 있는 부분은 고치고 넘어가야겠다고 생각했다.

문제점 2 - 필요 이상으로 많은 DB 조회 요청

randomPoseDetailByPeopleCount 함수의 첫 부분을 보면 다음과 같다.

    public PoseResponse.PoseDTO randomPoseDetailByPeopleCount(int peopleCount) {
        List<Pose> poseList = poseJPARepository.findByPeopleCountAndOpenAndPass(peopleCount, true, "ACCEPT");

랜덤 포즈 조회 요청이 들어올 때마다 포즈 DB에 조회 요청을 보낸다.

호출하는 함수 명을 보면 알겠지만, 보내는 쿼리 자체도 간단한 쿼리가 아니라서 시간이 꽤 오래 걸린다.

근데 사실 생각해보면 굳이 랜덤 포즈 조회 요청이 들어올 때마다 포즈 DB에 조회 요청을 보낼 필요가 없다.

물론 새로운 포즈가 추가되었다면 그걸 반영하긴 해야하지만, 새로운 포즈가 그렇게 자주 생기지 않기 때문에 매번 DB에 쿼리를 날리는 것은 비효율적이라고 판단했다.

해결

    private final User_AlbumJPARepository user_albumJPARepository;
    private final PoseJPARepository poseJPARepository;

    private final int MAX_PEOPLE_COUNT = 5;
    private final int DUPLICATION_LIMIT = 10;


    private int[] indexes = new int[MAX_PEOPLE_COUNT];
    private List<Integer>[] randomIndexLists = new List[10];
    private List<Pose>[] poseLists = new List[MAX_PEOPLE_COUNT];

    @PostConstruct
    public void postConstructor() {
        for (int i = 0; i < MAX_PEOPLE_COUNT; i++) {
            int peopleCount = i + 1;
            initHelper(peopleCount);
        }
    }

    public void initHelper(int peopleCount) {
        indexes[peopleCount - 1] = 0;
        poseLists[peopleCount - 1] = poseJPARepository.findByPeopleCountAndOpenAndPass(peopleCount, true, "ACCEPT");
        randomIndexLists[peopleCount - 1] = makeRandomIndexList(DUPLICATION_LIMIT, poseLists[peopleCount - 1].size());
    }

    /**
     * 랜덤 포즈 조회
     * 인원 수에 따른 랜덤 포즈 개별 조회
     * 10번의 요청 동안 중복되는 포즈 x
     */
    public PoseResponse.PoseDTO randomPoseDetailByPeopleCount(int peopleCount) {
        if (peopleCount > MAX_PEOPLE_COUNT) throw new Exception404("요청한 peopleCount를 갖는 포즈가 없습니다.");

        if (indexes[peopleCount - 1] == DUPLICATION_LIMIT - 1) {
            initHelper(peopleCount);
        }

        int randomIndex = randomIndexLists[peopleCount - 1].get(indexes[peopleCount - 1]++);
        Pose randomPose = poseLists[peopleCount - 1].get(randomIndex);

        return new PoseResponse.PoseDTO(randomPose);
    }

    private static List<Integer> makeRandomIndexList(int size, int max) {
        Random random = new Random();
        random.setSeed(System.currentTimeMillis());

        List<Integer> randomIndexList = new ArrayList<>(size);
        Set<Integer> randomIndexSet = new HashSet<>(size);

        while (true) {
            int randomNum = random.nextInt(max);
            if (randomIndexSet.contains(randomNum))
                continue;
            randomIndexList.add(randomNum);
            randomIndexSet.add(randomNum);
            if (randomIndexSet.size() == size)
                break;
        }

        return randomIndexList;
    }

기존의 코드보다 많이 길어지긴 했다...

문제점 1의 해결

문제점 1의 해결책과 관련된 코드는 다음과 같다.

public PoseResponse.PoseDTO randomPoseDetailByPeopleCount(int peopleCount) {
        if (peopleCount > MAX_PEOPLE_COUNT) throw new Exception404("요청한 peopleCount를 갖는 포즈가 없습니다.");

        if (indexes[peopleCount - 1] == DUPLICATION_LIMIT - 1) {
            initHelper(peopleCount);
        }

        int randomIndex = randomIndexLists[peopleCount - 1].get(indexes[peopleCount - 1]++);
        Pose randomPose = poseLists[peopleCount - 1].get(randomIndex);

        return new PoseResponse.PoseDTO(randomPose);
    }

    private static List<Integer> makeRandomIndexList(int size, int max) {
        Random random = new Random();
        random.setSeed(System.currentTimeMillis());

        List<Integer> randomIndexList = new ArrayList<>(size);
        Set<Integer> randomIndexSet = new HashSet<>(size);

        while (true) {
            int randomNum = random.nextInt(max);
            if (randomIndexSet.contains(randomNum))
                continue;
            randomIndexList.add(randomNum);
            randomIndexSet.add(randomNum);
            if (randomIndexSet.size() == size)
                break;
        }

        return randomIndexList;
    }

ArrayList의 contains에서 발생한 성능 문제를 HashSet의 contains를 사용하여 해결하였다.

기존의 ArrayList contains의 시간 복잡도는 O(n)이지만 HashSet contains의 시간 복잡도는 O(1)이다. 따라서 randomIndexList를 만드는 과정이 기존의 O(n^2)에서 O(n)으로 개선되었다.

자세한 해결 방법은 다음과 같다.

ArrayList만 사용하던 기존과 달리 ArrayList와 HashSet을 동시에 사용하였다.
HashSet contains 함수의 시간 복잡도가 O(1)인 점을 이용해서 중복 체크를 진행하고 만약 중복되지 않는다면 ArrayList와 HashSet에 동시에 해당 값을 추가해주었다.

ArrayList와 HashSet을 동시에 사용한 이유는 랜덤하게 생성된 값의 순서를 그대로 유지하고 싶었기 때문이다. HashSet의 경우 순서가 어떻게 될 것이라는게 보장이 되지 않기 때문에 ArrayList를 사용하여 순서를 유지하고 싶었다.

문제점 2의 해결

문제점 2의 해결책과 관련된 코드는 다음과 같다.

    private final int MAX_PEOPLE_COUNT = 5;
    private final int DUPLICATION_LIMIT = 10;


    private int[] indexes = new int[MAX_PEOPLE_COUNT];
    private List<Integer>[] randomIndexLists = new List[10];
    private List<Pose>[] poseLists = new List[MAX_PEOPLE_COUNT];

    @PostConstruct
    public void postConstructor() {
        for (int i = 0; i < MAX_PEOPLE_COUNT; i++) {
            int peopleCount = i + 1;
            initHelper(peopleCount);
        }
    }

    public void initHelper(int peopleCount) {
        indexes[peopleCount - 1] = 0;
        poseLists[peopleCount - 1] = poseJPARepository.findByPeopleCountAndOpenAndPass(peopleCount, true, "ACCEPT");
        randomIndexLists[peopleCount - 1] = makeRandomIndexList(DUPLICATION_LIMIT, poseLists[peopleCount - 1].size());
    }

    /**
     * 랜덤 포즈 조회
     * 인원 수에 따른 랜덤 포즈 개별 조회
     * 10번의 요청 동안 중복되는 포즈 x
     */
    public PoseResponse.PoseDTO randomPoseDetailByPeopleCount(int peopleCount) {
        if (peopleCount > MAX_PEOPLE_COUNT) throw new Exception404("요청한 peopleCount를 갖는 포즈가 없습니다.");

        if (indexes[peopleCount - 1] == DUPLICATION_LIMIT - 1) {
            initHelper(peopleCount);
        }

        int randomIndex = randomIndexLists[peopleCount - 1].get(indexes[peopleCount - 1]++);
        Pose randomPose = poseLists[peopleCount - 1].get(randomIndex);

        return new PoseResponse.PoseDTO(randomPose);
    }

기존의 코드에서는 랜덤 포즈 조회 요청이 들어올 때마다 DB에 조회 요청을 보냈지만 리팩토링한 코드에서는 initHelper 함수를 호출할 때만 DB에 조회 요청을 보낸다.

initHelper 함수는 말 그대로 초기화를 도와주는 함수로, 랜덤 포즈를 10번 조회하면 리스트를 초기화하는데 그때 호출하는 함수이다. 이때, 랜덤 포즈 조회는 포즈 인원수마다 별도로 카운팅한다.

기존에는 랜덤 포즈 조회 요청이 들어올 때마다 JPA를 이용하여 DB에 조회 요청을 보냈는데, 리팩토링한 코드에서는 처음 PoseService 빈이 컨테이너에 등록될 때 DB에 조회 요청을 몰아서 보내고 이후엔 각 인원수마다 10번의 조회 요청이 들어와야 DB에 조회 요청을 1번 보낸다.

단순하게 계산해봐도 빈 등록 이후에는 10번의 조회 요청을 보내야 했던 것을 1번의 조회 요청만 보낼 수 있도록 했다.

물론 이 로직에도 문제점이 있다. 포즈가 추가되는 것은 크게 문제가 없지만, 포즈가 삭제될 경우 문제가 발생할 수 있다. 하지만 아직까지는 서비스에서 포즈 삭제 기능을 지원하지 않기 때문에 위와 같은 코드를 작성하였다. 만약 포즈 삭제 기능을 지원하게 된다면 위의 로직도 수정을 할 것이다.

여담으로, initHelper 함수에는 @PostConstruct라는 어노테이션이 붙어 있다. 이 어노테이션은 PoseService 빈이 컨테이너에 등록된 이후에 (PoseService의 생성자가 실행된 이후에) 내가 원하는 로직을 수행하도록 해준다. 이를 통해 PoseService 빈이 컨테이너에 등록된 이후에 내가 원하는 로직을 실행하도록 할 수 있었다. 이번에 리팩토링을 진행하면서 발견하고 써본 건데 다행히 잘 작동하는 것 같다.

관련 PR

https://github.com/whatever-mentoring/PixelPioneers_BE/pull/12

  • PR에 남긴 메세지 중에서 실행이 오래 걸린다는 문제가 있다고 해놨는데, 이건 카페 와이파이 속도 문제였다. 저 PR을 올리던 당시에는 카페에서 작업을 하고 있었는데 카페 와이파이 속도가 느려서 조회 요청과 응답이 느리게 수행된 것이었다. 정상적인 인터넷 속도를 가진 환경에서 실행하니 정상적으로 10초 내외의 시간 안에 실행되었다.
  • PR 관련 대화를 카톡으로 진행하여 PR에는 별도의 메세지가 남지 않은 것은 아쉽다.
profile
Junior Backend Engineer

0개의 댓글