느리더라도 꾸준하게

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

 

오늘은 우테코를 온라인으로 진행하였고, 어제 소개했던 업비트 프로젝트가 아닌 다른 토이 프로젝트를 어느 정도 개발하였습니다. 왜 하루 만에 주제가 달라진 것인지는 차차 설명드리겠습니다.

 

 

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

저는 현재 대학교에서 알고리즘 소모임에 들어가 있습니다. 어젯밤에 학교 선배로부터 백준 랭작을 위해 우리 학교 학생들이 아직 풀지 않은 문제 리스트를 보여주는 프로그램을 만드는 것이 어떻겠냐는 연락을 받았습니다. 처음에는 이미 하려던 프로젝트가 있어서 망설였으나, 이내 수락했습니다. 왜냐하면, 업비트 api는 제작 과정이 복잡하고 인증 과정까지 들어가지만, 백준 리스트 조회 기능은 인증도 없을 뿐만 아니라 요구 사항 자체도 적었기 때문이죠. 또한, 어제부터 외부 api를 이용하여 개발을 하고 있는데, 이 방식 자체가 굉장히 낯설었기 때문에 비교적 쉬운 프로젝트를 통해 적응하는 것이 좋겠다는 판단이 들었습니다.

 

외부 api는 solved.ac의 api를 사용하고 있으며, 공식적인 api는 아니고 비공식적인 api를 사용하고 있습니다. 일부 api는 해당 문서에 확인하실 수 있으며, 나머지 부족한 기능들은 알고리즘 소모임 조원분들이 알려주셨습니다. 도대체 감춰진 api는 어떻게 찾아내는 것인지 신기합니다.

 

 

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

제가 정리한 요구 사항은 다음과 같습니다.

 

 

 

결국 핵심은 '전체 문제 리스트에서 건국 대학교 유저가 푼 문제 제외하기'지만, 그 전 과정을 차례대로 수행할 수 있어야 했습니다. 오늘은 로직에 대한 설명보다는 구현 중에 막히거나 새롭게 알게된 부분을 위주로 기록하려고 합니다.

 

 

(1) HttpClient에서 RestTemplate로의 전환

저는 기존의 외부 api와의 통신을 위해서 HttpClient를 사용했습니다. 이유는 참 단순했습니다. 업비트 공식 문서의 예제 코드로 제공되었기 때문이죠. 그래서 별다른 의심 없이 사용하다가 오늘 어려움에 봉착했습니다. 바로, 외부 api 테스트 코드를 어떻게 작성할 것이냐입니다.

 

결론부터 말씀드리자면, 구글링하면서 제 기준 가장 쉬운 외부 api 테스트는 @RestClientTest를 사용하는 것이었습니다. 그리고 이 테스트를 사용하기 위하여 프로덕션의 HttpClient 코드를 RestTemplate로 뜯어 고쳤습니다. HttpClient 코드와 RestTemplate 코드를 비교해 보겠습니다.

 

 

    public ProblemResponses getSolvedProblems(String id) {
        final CloseableHttpClient client = HttpClientBuilder.create().build();
        final String url = SERVER_URL + "/v2/search/problems.json?query=solved_by:" + id;
        final HttpGet request = new HttpGet(url);
        final SolvedProblemsResponse initialSolvedProblemsResponse = requestSolvedProblemsOfMember(client, request);
        final Long totalPages = initialSolvedProblemsResponse.getTotalPage();
        final List<ProblemResponse> problemResponses = new ArrayList<>(initialSolvedProblemsResponse.getProblems());
        for (int i = 2; i <= totalPages; i++) {
            request.setURI(URI.create(url + "&page=" + i));
            final SolvedProblemsResponse solvedProblemsResponse = requestSolvedProblemsOfMember(client, request);
            problemResponses.addAll(solvedProblemsResponse.getProblems());
        }
        return new ProblemResponses(problemResponses);
    }

    private SolvedProblemsResponse requestSolvedProblemsOfMember(CloseableHttpClient client, HttpGet request) {
        try {
            final CloseableHttpResponse response = client.execute(request);
            final HttpEntity entity = response.getEntity();
            final ObjectMapper mapper = new ObjectMapper();
            mapper.setPropertyNamingStrategy(PropertyNamingStrategies.SNAKE_CASE);
            final String result = EntityUtils.toString(entity, "UTF-8");
            final SolvedProblemsResponse solvedProblemsResponse = mapper.readValue(result, SolvedProblemsResponse.class);
            if (!solvedProblemsResponse.isSuccess()) {
                throw new InvalidIOException("예상치 못한 에러로 멤버의 푼 문제 리스트를 가져오지 못하였습니다.");
            }
            return solvedProblemsResponse;
        } catch (IOException e) {
            e.printStackTrace();
            throw new InvalidIOException("라이브러리 예외가 발생하였습니다.");
        }
    }

 

 

이것은 특정 유저가 푼 문제 리스트를 가져오는 코드입니다. 메소드가 2개로 분리되어 있으며, 코드가 한 눈에 들어오지는 않습니다.

 

 

    private final RestTemplate template;

    public SolvedProblemsProvider(RestTemplateBuilder restTemplateBuilder) {
        this.template = restTemplateBuilder.build();
    }

    public ProblemResponses getSolvedProblems(String id) {
        final String url = SERVER_URL + "/v2/search/problems.json?query=solved_by:" + id;
        final SolvedProblemsResponse initialSolvedProblemsResponse = template.getForObject(url, SolvedProblemsResponse.class);
        final SolvedProblemsResultResponse result = Objects.requireNonNull(initialSolvedProblemsResponse).getResult();

        final long totalPages = Objects.requireNonNull(result.getTotalPage());
        final List<ProblemResponse> problemResponses = new ArrayList<>(result.getProblems());

        for (int i = 2; i <= totalPages; i++) {
            problemResponses.addAll(Objects.requireNonNull(template.getForObject(url + "&page=" + i,
                SolvedProblemsResponse.class)).getResult().getProblems());
        }
        return new ProblemResponses(problemResponses);
    }

 

 

위는 RestTemplate를 적용한 코드입니다. 메소드는 하나만 사용하였고, 코드 또한 간결해진 것을 알 수 있습니다. RestTemplate에 관한 자세한 사용법은 해당 링크를 참고하시길 바랍니다.

 

 

이제, @RestClientTest를 통해 외부 api 테스트를 어떻게 하는지 알아봅시다. @RestClientTest 사용법은 조졸두님이 작성하신 포스팅을 참고하시길 바랍니다.

 

 

@RestClientTest(ProblemsProvider.class)
class ProblemsProviderTest {

    @Autowired
    private ProblemsProvider problemsProvider;

    @Autowired
    private MockRestServiceServer mockServer;

    @Autowired
    private ObjectMapper mapper;

    @Test
    void getSolvedProblem() throws JsonProcessingException {
        final String expectedMemberId = "test";
        final List<ProblemInfoResponse> problems = Collections.singletonList(
            new ProblemInfoResponse(1000L, 1L, (short) 1, (short) 1, "A+B", 126343L, 2.2986)
        );
        final ProblemResultResponse result = new ProblemResultResponse(1L, 1L, problems);
        final ProblemsResponse problemsResponse = new ProblemsResponse(true, result);
        final String expectResult = mapper.writeValueAsString(problemsResponse);

        this.mockServer.expect(requestTo(SERVER_URL + SOLVED_PROBLEMS_URL + expectedMemberId))
            .andRespond(withSuccess(expectResult, MediaType.APPLICATION_JSON));

        final ProblemInfoResponses actual = problemsProvider.getSolvedProblems(expectedMemberId);
        assertThat(actual.getProblemInfoResponses()).hasSize(1);
        assertThat(actual.getProblemInfoResponses().get(0).getId()).isEqualTo(1000L);
    }
}

 

 

우리는 실제 외부 api와 통신해서 테스트를 하기는 어렵습니다. 그래서 외부 api를 mock server로 두어야 합니다. 그리고 해당 mock server에 특정 url로 통신하면 특정 응답을 하도록 설정해 줍니다. 여기서 쉽게 직렬화하기 위하여 writeValueAsString()을 사용했습니다.

 

 

(2) 역직렬화할 필드에 예약어가 있는 경우

특정 응답값을 역직렬화하려는 과정에서 난감한 경험을 했습니다.

 

 

 

 

바로, 다음과 같이 특정 json 필드에 자바 예약어가 들어가 있다는 것이죠. 제 Dto 필드에 class라는 변수를 만들 수도 없는 노릇이고 어떻게 해야하나 고민이었습니다. 이 부분은 현재 보충역으로 일하고 계시는 학교 선배의 도움을 구했습니다. 해결 방법은 간단했죠. 문제가 되는 필드 위에 @JsonProperty를 붙이게 되면, 역직렬화시 해당 어노테이션의 붙인 속성 이름으로 동작하는 것입니다.

 

 

public class UserInfoResponse {

    private String userId;
    private String bio;
    private String profileImageUrl;
    private Long solved;
    private Long exp;
    private Integer rating;
    private Integer level;
    @JsonProperty("class")
    private Long classLevel;
    private Long classDecoration;
    private Long voteCount;
    private Integer rank;
    private Integer globalRank;
}

 

 

간단하죠? 자세한 설명은 해당 문서를 참고하시길 바랍니다.

 


(3) 특정 그룹의 유저가 푼 문제 수 불러오는 기능의 문제점

현재 저는 따로 DB를 두고 있지 않으므로 항상 외부 api에 통신을 하여 응답을 받아와야 합니다. 이때, 특정 그룹의 유저가 푼 문제 수를 불러오려면, 다음과 같은 순서를 따라야 합니다.

 

 

1. 모든 유저 중에서 특정 그룹인 유저만 뽑아낸다.

 

2. 뽑아낸 유저의 ID를 통해 그 유저가 푼 문제를 찾는다.

 

3. 2번으로 얻어낸 문제들을 중복되지 않도록 처리한다.

 

 

여기서 시간이 오래 걸리는 부분은 2번째입니다. solved.ac에 등록된 건대생은 약 220명이고, 각각 평균적으로 못해도 200문제는 푼 것 같습니다. 그리고 풀어낸 문제 쿼리는 한 번에 100개까지만 갖고 올 수 있으므로 총 220 * 2 = 440번 api 요청을 보내야 합니다. 또한, 중복 배제를 위한 코드도 효율적으로 짠 것 같지는 않습니다.

 

 

    public ProblemInfoResponses showSolvedProblemsOfUsers(UserInfoResponses userInfosInGroup) {
        final List<String> userIds = userInfosInGroup.getUserInfoResponses().stream()
            .map(UserInfoResponse::getUserId)
            .collect(Collectors.toList());

        final Set<Problem> problems = new LinkedHashSet<>();
        for (final String id : userIds) {
            final List<Problem> result = problemsProvider.getSolvedProblems(id).getProblemInfoResponses().stream()
                .map(ProblemInfoResponse::toEntity)
                .collect(Collectors.toList());
            problems.addAll(result);
        }
        return ProblemInfoResponses.of(problems);
    }

 

 

problemsProvider의 getSolvedProblems() 메소드를 통해 풀어낸 문제 수를 가져오는데, DTO에 equals와 hashCode를 두어 중복 배제하는 것은 부적절하므로 따로 정의된 도메인으로 매핑하고 거기서 중복 처리를 해 주었습니다. 일단 저로서는 이것이 최선이지만, 내일 다시 고민해 보려고 합니다.

 

아무튼, 여러 가지 문제점이 잘 비벼져서 건대생들이 풀어낸 문제 리스트를 받아오는데 23초나 걸렸습니다. DB를 따로 두어 일정 주기마다 업데이트를 하고, 클라이언트에게 응답할 때는 DB에서 조회한 결과를 받아오는 것이 적절해 보이나..... 어떻게 구현할 지는 모르겠습니다.

 

 

정리

우테코에 들어와서 처음으로 성능 이슈를 겪었습니다. 그리고 그 이슈는 다름아닌 다량의 API 통신 요청때문이었습니다. 제가 외부 API 코드를 바꿀 수는 없으므로 DB 연결하는 방법말고는 떠오르지 않는데, 내일 우테코 크루들과 이야기해 보면서 해답을 찾아봐야겠습니다.

donaricano-btn

이 글을 공유합시다

facebook twitter kakaoTalk kakaostory naver band

본문과 관련 있는 내용으로 댓글을 남겨주시면 감사하겠습니다.

비밀글모드

  1. 언포츈
    06.03 ㅇㄷ
    2021.06.04 03:06
  2. 제 solvedac 프로필 정보를 api로 염탐하다니 무섭네요 ㄷㄷ
    2021.06.09 20:05 신고