각종 후기/우아한테크코스

[우아한 테크코스 3기] LEVEL 2 회고 (123일차)

제이온 (Jayon) 2021. 6. 4.

안녕하세요? 제이온입니다.

 

어제는 저녁에 학교 선배와 술을 마시고, 새벽에는 크루들과 피방에서 게임을 하느라 부득이하게 포스팅을 작성하지 못했습니다. 오늘 방학식에서 간단히 느낀 점과 함께 기록하려고 합니다.

 

 

레벨2 방학식

오늘 부로 레벨2가 종료되었습니다. 이번 레벨은 스프링의 방대한 개념을 빠르게 습득하여 응용하는 것이 최대 과제였고, 나름 잘 수행했다고 생각합니다. 다만, 빠르게 사용법을 적용하느라 개념적인 측면에서 깊이는 얕다고 느꼈습니다. 그래서 이번 방학때 모의 면접을 준비하면서 개념을 복습하고 정리하는 시간을 가질 듯합니다. 스프링의 모든 원리를 정리하는 것은 말이 안 되고, 아마도 그동안 미션을 수행하면서 알게된 개념 위주로 포스팅을 작성하려고 합니다.

 

방학식은 공지 사항 전달, 감동 크루 사연 및 우수 글쓰기 시상, 그리고 마지막으로 협업 미션 크루들과 회고로 진행되었습니다. 저는 그 중에서도 협업 미션 크루들과 온라인으로 회고한 점이 제일 좋았습니다. 안그래도 이번 미션이 찍먹 수준으로 합의를 보아야할 기능이 한 두개였기때문에 크게 프론트 크루들과 연락할 기회가 적었습니다. 팀플을 했음에도 불구하고 거의 친해지지 못한 것이었죠.

 

그래서 이번 협업 미션 회고 시간을 통해 프론트 크루들이 어떻게 레벨 2를 보냈고, 백엔드 크루들과의 협업이 어땠는지 진솔하게 들어볼 수 있었습니다. 다행히 미키와 썬 모두 백엔드 크루들과 협업해서 좋았다고 말씀해 주셨고, 여러 가지 시시콜콜한 이야기를 나누면서 조금이나마 친해질 수 있었다고 생각합니다.

 

끝으로, 조앤의 제안에 따라서 레벨 3가 시작하면 이번 협업 미션 크루들끼리 모여서 밥을 먹기로 했습니다 ㅎㅎ

 

 

건국대학교 학생들의 백준 안 푼 문제 리스트를 보여주는 프로그램

어제와 오늘은 성능 이슈를 해결하는 것이 관건이었습니다. 기능 자체는 모두 구현하였지만, 외부 api를 통해 원하는 모든 데이터를 가져오는 것은 시간이 느렸습니다. 예를 들어, 백준의 모든 문제는 20,000개인데 이것을 모두 조회하려면 약 11초가 걸립니다. 이것은 solved.ac 외부 api에서 1번의 요청으로 가져올 수 있는 문제 수가 100개로 제한되어있기 때문이죠. 즉, 200번의 api 요청을 보내야하므로 느릴 수 밖에 없는 것이죠..

 

물론, 실제로 클라이언트 단에서 페이지네이션 기법을 통해 특정 개수씩 나눠서 받긴 합니다. 그럼에도, 저는 이번 프로젝트에서 DB를 구축하여 좀 더 빠르게 데이터를 가져오고 싶었습니다. 아무리 외부 api에 페이지네이션 기법이 적용되어 있더라도 당연히 외부 api에 요청하는 것보다는 저의 DB에 쿼리를 날리는 것이 성능이 빠를 것입니다. 그래서 저는 DB를 구축하고, Spring 스케줄링을 통해 일정 시간 간격으로 DB를 업데이트해 주자고 생각했습니다.

 

 

    @GetMapping("/problems")
    public ResponseEntity<ProblemInfoResponses> showAllProblems() {
        return ResponseEntity.ok(problemService.findAll());
    }

 

 

이런 식으로 모든 문제를 가져올 때에는 외부 api를 호출하지 않고, 단순히 제 DB에 있는 컬럼을 싹 가져오도록 만들었습니다. 여기서 제가 정의한 스키마를 보고 가겠습니다.

 

 

create table if not exists PROBLEM
(
    id bigint auto_increment not null,
    problem_id bigint not null unique,
    title varchar(255) not null
);

create table if not exists USER
(
    id bigint auto_increment not null,
    group_id bigint,
    nickname varchar(255) not null unique
);

create table if not exists USER_PROBLEM_MAP
(
  user_id bigint not null,
  group_id bigint,
  problem_id bigint not null
);

 

 

PROBLEM, USER, 그리고 두 테이블의 관계형 테이블인 USER_PROBLEM_MAP로 총 3개의 스키마를 정의했습니다. 여기서 중요한 점은 백준 내의 그룹에 참여한 유저가 없을 수도 있으므로 not null 속성을 설정하면 안 됩니다. 이렇게 스키마를 정의하고 나면 적절한 쿼리를 날려주면 됩니다.

 

전체 문제를 저장하거나 특정 그룹의 유저를 저장하는 일은 쉽습니다. 쿼리가 단순하기 때문이죠. 다만, 특정 그룹의 유저들이 푼 문제를 중복 배제하여 조회하는 쿼리가 어려웠습니다. 저는 처음에 join을 통해서 일단 PROBLEM과 USER_PROBLEM_MAP을 연관시키고 특정 그룹의 유저들이 푼 문제만 조회하도록 만들었습니다.

 

 

select PROBLEM.ID, PROBLEM.problem_id, PROBLEM.title
from PROBLEM inner join USER_PROBLEM_MAP on 
PROBLEM.id = USER_PROBLEM_MAP.problem_id 
where USER_PROBLEM_MAP.group_id = ?;

 

 

그리고 중복 배제가 안 된 리스트를 Set을 통한 비즈니스 로직을 통해서 중복을 제거해 주었습니다. 하지만, 이 방식은 비효율적입니다. MySQL 단에서 distinct를 통해 문제 중복을 제거할 수 있기 때문이죠. 다만, 저는 join을 할 때 문제에 대해 distinct를 어떻게 걸어야 할 지 감이 안 와서 서브 쿼리를 사용했습니다.

 

 

select * from PROBLEM where problem_id in 
(select distinct(problem_id) from USER_PROBLEM_MAP where group_id = ?)

 

 

먼저, USER_PROBLEM_MAP에서 특정 그룹의 유저가 푼 문제들을 중복을 제거하여 가져옵니다. 그리고 PROBLEM 테이블에서 해당 problem_id인 컬럼을 가져오는 것이죠. 이 한 번에 쿼리만으로 건대 유저가 푼 문제들을 가져오는 데 불과 1초밖에 걸리지 않았습니다. 비슷한 방식으로 not in을 사용하여 건대 유저가 못 푼 문제들을 가져올 수 있었는데, 이 부분도 2~3초면 모두 수행되었습니다.

 

어차피 해당 문제들은 또 다시 티어별로 나눌 것인데, 티어별로 나누고 페이지네이션 기법까지 적용한다면 0.x초만으로도 원하는 데이터를 클라이언트에게 빠르게 제공할 수 있을 것으로 기대합니다.

 

 

마지막으로, 스케줄링을 간단히 적용해 보았습니다.

 

 

@Component
public class Scheduler {

    private ProblemsProvider problemsProvider;
    private UserInfoProvider userInfoProvider;
    private ProblemService problemService;
    private UserService userService;

    public Scheduler(ProblemsProvider problemsProvider, UserInfoProvider userInfoProvider,
        ProblemService problemService, UserService userService) {
        this.problemsProvider = problemsProvider;
        this.userInfoProvider = userInfoProvider;
        this.problemService = problemService;
        this.userService = userService;
    }

    @Scheduled(cron = "0  0/10  *  *  * *")
    public void dbUpdate() {
        problemService.deleteAllProblems();
        problemService.deleteAllProblemMap();
        userService.deleteAll();

        problemService.saveProblems(problemsProvider.getAllProblems());
        userService.saveUsers(194L, userInfoProvider.getUserInfosInGroup(194L));
        userService.saveSolvedProblemsOfUsers(194L, userService.findByGroupId(194L));
    }
}

 

 

일단은 대충 10분 간격으로 테이블 싹다 날리고 DB를 새롭게 구축하도록 만들었습니다. 해당 DB 업데이트 예상 시간은 약 40초이며, 여기서도 더 줄이고 싶다는 생각이 들었습니다. 왜냐하면 saveSolvedProblemsOfUsers() 코드가 아래와 같기 때문이죠.

 

 

    public void saveSolvedProblemsOfUsers(Long groupId, UserInfoResponses userInfoResponses) {
        final List<String> nicknames = userInfoResponses.getUserInfoResponses().stream()
            .map(UserInfoResponse::getNickname)
            .collect(Collectors.toList());

        for (final String nickname : nicknames) {
            ProblemInfoResponses solvedProblems = problemsProvider.getSolvedProblems(nickname);
            problemService.saveProblems(nickname, groupId, solvedProblems);
        }
    }

 

 

특정 그룹의 유저의 닉네임을 전부 갖고 온다음에, 각 유저가 푼 문제를 외부 api에 호출하여 가져온 후 DB에 저장하고 있습니다. 요컨대, 건대 유저가 220명이고 푼 문제가 각각 200개라면 220 * 2 = 440번의 api를 호출해야하는 것입니다. 해당 작업이 약 18초 정도 소요되었으니 스키마 구조나 쿼리나 비즈니스 로직을 리팩토링하면 성능이 개선되지 않을까 예상하고 있습니다. 이 부분은 내일 고민해보겠습니다.

 

그리고 해당 스케줄러에서는 실 서비스에서 사용하고 있는 DB를 업데이트하고 있는데, 내일은 업데이트할 DB를 실 서비스가 아니라 TEMP로 하고, TEMP DB의 업데이트가 끝난 다면, 실 서비스에서 사용하고 있는 DB와 바꿔치기하려고 합니다. 이 부분은 우기가 알려주었고, "RENAME TABLE old_table TO new_table;"를 통해 해당 요구 사항을 구현할 수 있다고 합니다.

 

 

정리

방학식에서의 회고를 만족스럽게 마치고, 제 토이 프로젝트에서의 성능 이슈도 어느 정도는 개선하여 기분이 좋습니다. 다만, 이번 프로젝트에서 TDD를 하지 않았을 뿐 아니라 테스트 코드조차 거의 만들지 않았습니다. 변명을 하자면, 외부 api에 호출하는 과정이 낯설었고 구조를 어떻게 짤지 어색해서 그랬습니다. 그래서 내일은 몇몇 변수나 메소드 등의 네이밍을 수정한 후, DAO와 SEVICE 테스트, 그리고 시간이 된다면 인수 테스트까지 적용해보려고 합니다.

댓글

추천 글