BBOARD

 

✅  프로젝트 링크

https://github.com/damdam6/BaekJoon-Group-Board

 

GitHub - damdam6/BaekJoon-Group-Board

Contribute to damdam6/BaekJoon-Group-Board development by creating an account on GitHub.

github.com

✅  프로젝트 설명

  • 서비스 이름 : BBoard
  • 서비스 설명 : 사용자가 그룹을 생성하여 그룹원들이 푼 백준 문제들을 통합하고 시각화하며, 사용자 레벨에 맞는 문제를 자동 추천해주는 서비스
  • 기획 배경 : 알고리즘을 공부하는 과정에서 서로가 문제를 추천하거나, 각자 풀이한 문제를 비교하면서 동기부여를 얻는 경우가 다수 있음. 따라서 사용자가 그룹을 생성하여 서로 풀어본 문제들을 한눈에 보고 비교할 수 있고, 유사한 티어의 문제를 자동으로 추천해주는 웹페이지 제작을 기획
  • 개발 기간 : 2023.11.13 ~ 2023.11.24 (2주)
  • 인원 : 2명

✅ 역할

Back 구현 (70%)

외부 API 비동기 요청 및 응답 Logic 설계, 구현 및 최적화

 

Front (30%)

Login Page, Admin Page, Group Page 구현

 

✅ 주요 기술 스택

  • SpringBoot 3.1.5
  • Mybatis
  • MySql
  • Vue.js
  • Tailwind

✅ 사용한 API (비공식 Solved.ac API Ver.3)

https://solvedac.github.io/unofficial-documentation/#/

 

@solvedac/unofficial-documentation

이 프로젝트는 solved.ac API를 문서화하는 커뮤니티 프로젝트입니다. 이 저장소는 원작자의 요청에 따라 언제든 지워질 수 있으며, 현재 API와 일치하지 않을 수도 있는 점 양해 부탁드립니다.

solvedac.github.io

 

위 API를 사용함에 있어서 주의할 점이 있다.

solved.ac api가 비공식 api임으로 이후 version이 변경되거나 서비스를 중단할 수 있다. 아무래도 공식적으로 solved.ac, 백준에서 제공하는 API가 아니니 어쩔 수 없는 부분이 있다. 또한 solved.ac api 호출 횟수 제한으로 인해 오류가 발생할 수 있다. 자세한 내용은 뒤에 설명해두었다.

 

 

1. 사용자 정보 가져오기 (GET)

https://solved.ac/api/v3/user/show?handle=[유저아이디]


2. 상위 100문제 가져오기 (GET)

https://solved.ac/api/v3/user/top_100?handle=[유저아이디]


3. 사용자 티어별 푼 문제 수 가져오기 (GET)

https://solved.ac/api/v3/user/problem_stats?handle=[유저아이디]


4. 사용자가 푼 문제 페이지별로 가져오기 (GET)

https://solved.ac/api/v3/search/problem?query=@[유저아이디]&sort=level&direction=desc&page=[페이지번호]

✅ 당면한 문제와 이를 해결한 방법

1. API 횟수 제한

문제상황

 

 solved.ac 비공식 api는 테스트 결과 약 10분 기준으로 1000회 호출 제한이 있었다.. 이번 프로젝트에서 가장 큰 난관이었다.

외부 API 자체를 개선할 수 없기에, 제한된 조건에서 가장 효율적인 서비스를 만들어 내는 것을 목표로 하게 되었다.

기획한 서비스를 구현하기 위해서는 유저 1명당 아래의 정보가 필요했었다.

 

1. 유저 랭크 및 티어 정보

2. 유저가 푼 상위 100개 문제 정보

3. 유저가 각 티어별로 푼 문제 1개씩 필요

 

 위 정보를 매번 업데이트 하기 위해서는 최소 3회에서 최악의 경우 34회의 호출이 필요했었다. 1번 정보를 위해 사용자 정보 API 호출 1회, 유저가 푼 상위 100개 문제 정보 API 호출 1회, 유저가 각 티어별로 푼 문제 정보수 1회 호출이 필요하고 티어별 문제를 가져오기 위해서는 유저가 푼 문제를 50개 단위로 가져올 수 있는데 티어가 31종류 (브론즈 5 ~ 루비 1 + 미분류)로 단순히 요청하게되면 31번을 호출해야하는 상황이었다. 이를 개선하기 위해 최소한의 요청으로 최대한 다양한 문제를 가져올 필요가 있었다.

 

개선

1. 지정시간에 reset하는 로직으로 수정 

 유저가 새로고침 할 때마다 해당 유저가 속한 그룹에 대한 모든 유저 정보를 매번 가져오는 것은 매우 비효율적이었다. API 횟수 제한도 있었지만, 서비스의 특성상 상 그룹 내 유저가 문제를 풀면서 정보가 업데이트 되는 일이 분단위로 일어나지 않고, 실시간 단위로 유저 정보를 반영하는 것이 중요하지 않기 때문이었다.

 위 고찰을 바탕으로 유저가 새로고침할 때마다 외부 API를 요청하여 정보를 갱신하는 것이 아닌, 10분마다 서버가 자동갱신하는 것으로 로직을 수정하였, 이를 위해 @Scheduled 어노테이션을 사용하였다.

@Scheduled(fixedRate = 3600000)
@Transactional
public void schedulTask() {
// ..
}

 

 

2. Memoization 활용

다행히 제공하는 API는 유저가 푼 문제를 정렬하여 페이징하여 받아올 수 있는 기능을 제공하였다 (API 문서에는 나와있지 않다.. 많은 글들을 보다가 다행히 찾아냈다!). 덕분에 유저가 티어별로 푼 문제수 정보를 통해 몇번 째 페이지 정보를 가져와야 하는지 알 수 있었다.

아래는 사용자 티어별 푼 문제 수에 대한 API 응답 정보 중 일부이다.

...
{
    "level": 4,
    "solved": 22,
    "tried": 0,
    "partial": 0,
    "total": 869
  },
  {
    "level": 5,
    "solved": 14,
    "tried": 0,
    "partial": 0,
    "total": 781
  },
  {
    "level": 6,
    "solved": 22,
    "tried": 1,
    "partial": 0,
    "total": 761
  },
    {
    "level": 7,
    "solved": 26,
    "tried": 0,
    "partial": 0,
    "total": 838
  },
  ...

 

예시로 level 0~3까지 푼 문제가 없고 4 ~ 7까지는 위와 같다면 우리는 아래와 같은 정보를 얻을 수 있다.

 

Lv 4 : 첫 페이지에서 1 ~ 22번 문제

Lv 5 : 첫 페이지에서 23 ~ 36번 문제

Lv 6 : 첫 페이지에서 37 ~ 50 + 둘째 페이지에서 1 ~ 9번 문제

Lv 7 : 둘째 페이지에서 10 ~ 35번 문제

...

 

위에서 Lv 4 ~ 6번 문제를 가져오기 위해서는 실제로는 첫 페이지를 가져오기 위한 1번의 API 요청만 있으면 된다. 

위 예시를 코드로 구현하면 아래와 같다.

public List<UserTier> makeUserTierObject(List<UserTier> userTierList){

        // User 1개 당
        // 0 ~ 30 까지 총 31개의 Tier가 존재 가능한 것으로 보임
        int[] problemPrefixSum = new int[MAX_TIER+2];
        for (int i = MAX_TIER; i >= 0; i--) {
            UserTier now = userTierList.get(i);
            problemPrefixSum[i] = now.getProblemCount() +  problemPrefixSum[i+1];
            if (problemPrefixSum[i+1] == 0) continue;
            now.setPageNo(problemPrefixSum[i+1] / NUMBER_OF_PAGES + 1);
            now.setPageIdx(problemPrefixSum[i+1] % NUMBER_OF_PAGES == 0 ? 0 : problemPrefixSum[i+1] % NUMBER_OF_PAGES);
        }
        return userTierList;
    }

모든 유저에 대해서 모든 티어의 문제가 몇번째 페이지 & 몇번째 문제에 있는지를 구하는 코드이다.

각 티어별로 동일한 페이지를 가지고 있다면 추가 API 요청을 보내지 않고 이미 받아온 정보를 캐싱하여 repository에 저장하였다 (메모이제이션). 구현 코드는 아래와 같다.

public List<ProblemAndAlgoObjectDomain> makeTotalProblemAndAlgoList(Map<User, Map<Integer,
            List<ProblemAndAlgoObjectDomain>>> memoMap, Map<Integer, List<UserTier>> userTierMap){

        List<ProblemAndAlgoObjectDomain> totalProblemAndAlgoList = new ArrayList<>();
        for (User user: memoMap.keySet()) {
            List<UserTier> userTierList = userTierMap.get(user.getUserId()); // 유저당 userTier 데이터 저장된 맵
            int prevPage = 0;
            List<ProblemAndAlgoObjectDomain> problemListByPage = null;
            for (UserTier userTier : userTierList) {
                if(prevPage != userTier.getPageNo()){
                    problemListByPage = memoMap.get(user).get(userTier.getPageNo());
                    prevPage = userTier.getPageNo();
                }
                if (userTier.getProblemCount() != 0) {
                    ProblemAndAlgoObjectDomain problemAndAlgo = problemListByPage.get(userTier.getPageIdx());
                    problemAndAlgo.getProblem().setUserId(user.getUserId());
                    totalProblemAndAlgoList.add(problemAndAlgo);
                }
            }
        }

        return totalProblemAndAlgoList;
    }

 코드에는 생략되었지만 위 makeUserTierObject에서 생성된 userTierList를 userTierMap에 user를 키로 저장해두었다. 

userTierList를 순서대로 탐색하면서 서로 다른 페이지일 경우에만 totalProblemAndAlgoList에 추가함으로써 이후 한번에 비동기적으로 외부 API로 요청을 보낼 것이다. 이렇게 불필요한 API 요청을 줄임으로써 API 횟수 제한의 risk를 방지할 수 있었다

2. API 응답 시간

문제상황

 

 API 요청 횟수를 제한 조건 아래로 줄였다고 해도 외부 API I/O는 delay가 큰 작업이다. 초반에는 사용자 정보 업데이트를 RestTemplate로 구현하여 동기적으로 요청함으로서 서비스 최대 인원인 30명의 사용자 정보를 가져올 때 150초 소요되는 문제가 있었다.  또한 사용자 정보를 업데이트하는 과정에서 메인 쓰레드가 다른 작업을 처리하지 못하고 서비스가 중단되었다.. 10분마다 일어나는 업데이트를 비동기적으로 개선해야할 필요가 있었다.

개선

 개선을 위해 WebClient & Reactor를 이용하여 비동기적으로 api를 호출하게 코드를 수정하였다. Flux를 이용하여 iterable한 데이터에 대해 비동기적으로 처리한 결과, 사용자 정보 호출을 15초로 90% 가량 감소하였다. 또한 사용자 정보 업데이트가 비동기적으로 처리되기 때문에 업데이트가 진행되는 동안 서비스가 멈추는 현상이 해소되었다.

    public Flux<JsonNode> fetchOneQueryData(String pathQuery, String query) {
        return webClient.get()
                .uri(uriBuilder -> uriBuilder
                        .path(pathQuery)
                        .query(query)
                        .build()).retrieve()
                .bodyToFlux(JsonNode.class);
    }

 

 

소감

 우선, 차근차근 기다려주고 잘 이끌어준 담비에게 고맙다! 🙇‍♂️

 

 첫 프로젝트였다. 얼마나 허접한가... ㅠㅠ 단순히 Springboot, MySql를 설치하는 것부터 git 컨벤션을 지키고 브랜치 관리하는 것까지 모두 처음 하는 만큼 익숙치 않은 것들 뿐이었다. 심지어 Reactor를 활용한 코드는 정상적으로 구동은 하지만 return 값이 void이고 메소드 내에서 subscribe할만큼 매우 이상한 코드였다. 그러나 어찌 되었든 돌아가는 코드를 만들어 내는 것이 정말 중요하다는 것을 느꼈고, 내놓기 부끄러울지라도 무언가를 만들어가면서 책상 앞에서 공부하는 것보다 훨씬 많은 것을 배웠다. 자극받은 만큼 1학기 방학기간동안 프로젝트를 하나 더 따로 진행하고 있고, 이 내용도 공유할 수 있도록 하겠다! 

 

 아쉬운 점으로는 개발 기간이 부족하여 사용자가 직접 추가한 문제를 추가하지 못했었다. 프로젝트 설정기간인 2주가 지난 후에 팀원과 논의하여 방학기간에는 코드 리팩토링과 추가 프로젝트에 집중하는 것으로 하여 최종적으로는 추가하지 않기로 했다. 다음 프로젝트에는 더 짧은 시간 내에 집중하여 구현할 수 있을 것 같다! 또한 Reactor 를 너무 엉성하게 짠 느낌이 강했다. 비동기 프로그래밍에 뒤통수를 쎄게 맞고 제발 돌아만 가달라는 식으로 이틀을 붙잡고 있다보니.. 다음 프로젝트에서는 보다 깔끔하게 구현하고 싶은 욕심이 생겼고, 백기선 강사님의 유튜브를 보면서 기본 원리부터 차근차근 배워나가고 있다.

 

 프로젝트 외적으로, 프로그래밍으로 진로를 아예 바꾸면서 느낀 점이 많다. 공부를 하다가 막히는 것이 생겼을 때, "이건 내가 몰라도 돼" 라고 생각하지 않고 어떻게든 찾아보려고 이만큼 노력했던 적이 있었던가? 생각해보면 그럴 필요가 없었었다. 그렇게 더 공부한다고 인정해주는 사람도 없었을 뿐더러, 나도 그렇게까지 내 전공에 흥미를 가지지는 않았던 것 같았다. 자기주도적으로 공부한 것은 맞지만, 주어진 수업과 교재라는 범위 내에서만 성실하게 공부했던 것 같다. 대신 내가 좋아했던 음악, 춤을 누가 시키지 않아도 공부하고 연구했었다. 오히려 전 회사에서 해답이 없는 문제를 많이 풀어봤던 것 같다. 하지만 회사에서 엔지니어로서는 경험과 "짬"으로 쌓여가는 노하우로 성장했다면, 지금은 찾아볼 수 있는 자료들을 공부하고 원리를 찾아가며 성장해 나아감을 느끼고, 이 과정이 너무나도 재밌다! 이 재미를 오랬동안 놓치지 않기 위해 부단히 노력해야지!

 

 앞으로도 빠이팅이다! 🙌

프로젝트 2개째 진행하느라 노션이랑 깃헙에만 열심히 기록하고 블로그 글을 한달 넘게 못썼다..

프로젝트는 정말 재밌고 빡센거구나 

확실히 만들어봐야 많이 배우는 것 같다.

 

https://cloud.google.com/vision/docs/detecting-landmarks?hl=ko

 

특징 감지  |  Cloud Vision API  |  Google Cloud

의견 보내기 특징 감지 컬렉션을 사용해 정리하기 내 환경설정을 기준으로 콘텐츠를 저장하고 분류하세요. 명소 감지는 이미지에서 유명한 자연 경관과 인공 구조물을 감지하는 기능입니다. 참

cloud.google.com

 

필자는 CloudVision API를 사용하여 여러 이미지에 해당하는 키워드를 받아보는 기능을 작성중이었다.

Cloud Vision API는 구글의 Cloud Vision AI 를 사용하여 이미지의 특징(키워드), 주요 색상, 라벨 디텍팅 등의 기능을 사용할 수 있는 이미지 관련 기능을 제공한다. 

API 문서의 Request 예시

{
  "requests": [
    {
      "features": [
        {
          "maxResults": 10,
          "type": "LANDMARK_DETECTION"
        }
      ],
      "image": {
        "source": {
          "imageUri": "gs://cloud-samples-data/vision/landmark/st_basils.jpeg"
        }
      }
    }
  ]
}

Response

{
  "responses": [
    {
      "landmarkAnnotations": [
        {
          "mid": "/m/0hm_7",
          "description": "Red Square",
          "score": 0.8098141,
          "boundingPoly": {
            "vertices": [
              {},
              {
                "x": 2487
              },
              {
                "x": 2487,
                "y": 3213
              },
              {
                "y": 3213
              }
            ]
          },
          "locations": [
            {
              "latLng": {
                "latitude": 55.753930299999993,
                "longitude": 37.620794999999994
              }
            }
          ]
        }
      ]
    }
  ]
}

 

 

다른 블로그나 Cloud Vision API를 참고하여 한 개의 이미지를 제공하여 이에 해당하는 키워드를 받아오는 코드를 작성해보았다.

하지만 아래 코드를 보면 ImageRequest에 대해 하나하나 비동기적으로 작동한다. 때문에 API block이 많이 발생하여 매우 비효율적임을 알 수 있다. 내부 for문은 많은 키워드 중 정확도가 제일 높은 키워드 3개(MAX_KEYWORD_COUNT)까지만 사용하는 로직임으로 참고만 하자.

// 동기 코드 (비동기 전환 전)

@Autowired
private CloudVisionTemplate cloudVisionTemplate;

@Override
public List<PromptDTO> getKeywords(List<ImageRequest> imageRequests) {

    List<PromptDTO> promptList = new ArrayList<>();

    for (ImageRequest imageRequest : imageRequests) {
        Resource imageResource = resourceLoader.getResource(imageRequest.getUrl());
        AnnotateImageResponse res = this.cloudVisionTemplate.analyzeImage(imageResource,
                Feature.Type.LABEL_DETECTION);

        for (EntityAnnotation e : res.getLabelAnnotationsList().stream().
                sorted(((o1, o2) -> (int) ((o2.getScore() - o1.getScore()) * 10000))). // 정확도가 높은 순으로 
                limit(MAX_KEYWORD_COUNT).toList()) { // MAX_KEYWORD_COUNT개 까지만 추출
            promptList.add(new PromptDTO(e.getDescription(), e.getScore(), PromptDTOEnum.KEYWORD.getType()));
        }

    }

    return promptList;
}

 

그러나 필자가 구현하기 위한 서비스에서는 이미지를 4개까지 넣어야 했고, 위 코드와 같이 for문을 돌면서 하나씩 api 요청을 보내는 것은 매우 비효율적이었다. 또한 추후에 이미지를 더 많이 사용하게 될 수도 있기 때문에 이를 비동기적으로 구현할 방법을 고민하다 Java Reactor를 구현한 WebFlux를 적용해보기로 했다. 이를 사용하면 이벤트, 데이터 스트림 비동기적으로 처리, 동시 수행 가능, 블로킹 피할 수 있어 성능 향상을 노릴 수 있다!

 

// 비동기 전환 코드
@Autowired
private CloudVisionTemplate cloudVisionTemplate;

@Override
public Flux<PromptDTO> getKeywords(List<ImageRequest> imageRequests) {
    return Flux.fromIterable(imageRequests)
            .flatMap(imageRequest -> {
                Resource imageResource = resourceLoader.getResource(imageRequest.getUrl());

                AnnotateImageResponse res = cloudVisionTemplate.analyzeImage(imageResource,
                        Feature.Type.LABEL_DETECTION);

                return Flux.fromIterable(res.getLabelAnnotationsList())
                        .sort((o1, o2) -> (int) ((o2.getScore() - o1.getScore()) * 10000))
                        .take(MAX_KEYWORD_COUNT)
                        .map(e -> new PromptDTO(e.getDescription(), e.getScore(), PromptDTOEnum.KEYWORD.getType()));
            });
}

 

우선, 2개 이상의 아이템을 방출하기 위해 Mono가 아닌 Flux를 사용하였고 fromIterable로 각 imageRequest에 대해 비동기적으로 API 요청을 보내도록 코드를 수정하였다.

여기서 필자가 익숙치 않았던 것은 Flux 안에서 새로운 Flux를 리턴하는 것이었는데 이렇게 활용할 수도 있음을 공부하면서 알게 되었다.

참고로 Flux 값 자체를 리턴하는 것은 이는 후에 구독자가 이를 subscibe하여 한번에 쌓인 이벤트를 처리할 수 있게 하기 위함이다.

 

이 코드에서 이벤트는 ImageRequest -> AnnotateImageResponse -> PromptDTO로 전환되면서 구독자에 의해 구독될 때까지 쌓이게 된다. 이후 아래 코드와 같이 컨트롤러 단에서 한번에 모인 PromptDTO를 구독하여 사용할 수 있다.

 

//MainController 활용
Map<String, List<PromptDTO>> promptDTOMap = new HashMap<>();
List<PromptDTO> keywords = new ArrayList<>();

//Flux를 subscribe하여 사용
cloudVisionService.getKeywords(imageRequests).subscribe((promptDTO -> keywords.add(promptDTO))); // 구독하여 리스트에 저장
promptDTOMap.put("keywords", keywords);

 

참고로 map 과 flatMap의 차이는 sync하냐 async하냐의 차이에 있다. 때문에 비동기한 처리를 위해서는 flatMap을 사용해야 하며, map 함수 메서드를 여러번 조합하게 되면 새로운 객체를 매번 생성하게 되어 GC에 부하를 줄 수 있음으로 주의해야 한다. 이에 관한 내용은 따로 NHN 클라우드 발표 영상 내용을 정리하면서 자세히 올리도록 하겠다.

 

그렇게 기분좋게 처리시간을 70% 가량 개선하였지만... 추출된 키워드만으로 조합된 프롬프트가 너무 조악하여 아예 GPT4의 신기술인 이미지를 input으로 주는 API를 사용하는 방향으로 전환한 것은 안비밀이다.. (위 코드는 바로 무쓸모됨 ㅎ)

하지만 Reactor를 사용해본 것은 기록할 만 하다고 생각되어.. 매우 정돈되지 않은 글을 써본다.

이후에는 아래의 저 괴상한 이미지를 어떻게 볼만하게 바꾸게 되었는지 작성해보도록 하겠다.

 

Illustrate realistic image. Combine the following keywords to illustrate : Nose, Water, Font, Cloud, Sky, Lips&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; 이.. 이게뭐야..

 

 

+ Recent posts