![[백준 문제 추천/개발 일지] (5) 추천 시스템 구성 + 코드 리팩토링](https://img1.daumcdn.net/thumb/R750x0/?scode=mtistory2&fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FnCiel%2FbtsIizMcFLK%2FeS56AIL9kgVC0JaKgiH7Lk%2Fimg.png)
- 추천 시스템 구성
- 간단 로직 설명 - sovled.java
- 1. tierCalculator
- 2. tagRatingCalculator
- 간단 로직 설명 - user이름으로 문제 추천 받기
- 1. GET /{username}/
- 2. User (엔티티)
- 3. ProblemService
- 4. ProblemRepository
- 간단 로직 설명 - user이름, tagId로 문제 추천 받기
- 1. GET /{username}/tag/{tagId}
- 2. ProblemService
- 2-1-1. userTagAverageLevel
- 2-1-2. findSolvedProblemsByUserAndTag
- 2-2. 는 위에서 설명한 메소드라 생략 2-3은 일반 계산이라 생략
- 2-4. findProblemsByTagExcludingUserSolved
- 직면한 문제 (회고)
- 다른 프로젝트에 이 프로젝트를 사용하기로 해서 다음 글작성은 좀 시간이 걸릴것 같다.

예비군을 다녀오면서 잠시 손을 놨었는데 다시 하기가 지~~~~~인~~~짜 귀찮았다. 근데 해야지 하고 막상 앉아서 하니까 재미있어서 계속만들게 됐다. 생각보다 빨리 되서 기분 좋긴한데 내 수학적 능력이 좀 부족해서 추천 알고리즘을 좀더 깔꼼하게 작성하지 못한거 같아서 나중에 보정할 생각이다.
또한 좋은 문제라는 것이 뭘까 고민한 시간이기도 하다 많은 사용자정답을 많이 맞춘 문제? 정답률이 60% ~ 30%사이에 있는 문제? 시도한 사람이 많은 문제? sovled.ac는 사람들이 평가를 해주는 커뮤니티 형식으로 되어 있고 나는 이 사이트를 따라가는 사람이니 많은사람이 맞춘만큼 많은 정보가 있어 랭킹이 정확히 매겨질 확률이 높은 그리고 많은 사람이 관심을 가지는 문제가 좋은 문제라고 판단했다.
개인적 사정으로 코드작성후 시간이 좀걸려서 일지를 작성하게됐다. 그래서 좀 자세히 못적을 것 같아서 슬프다. 거기다가 중간중간 느낀점을 정리해둔 txt가 있는데 어디로 갔는지 날라가 버렸다. 악재는 항상 같이 온다.
요즘 중식이님 노래 즐겨듣습니다. 좋은 노래 감사합니다.
https://youtu.be/5zNwGdcaKfg?si=JLSfLGrsaICD_pwa
들으면서 봐주시면 좋겠습니다. (저 대머리 아닙니다)
추천 시스템 구성
간단 로직 설명 - sovled.java
solved관련 계산을 모아둘 객체가 필요함을 느꼈고 sovled.class를 만들었다.
1. tierCalculator
public static int tierCalculator(int rating){
for (int tier = tiers.length - 1; tier >= 0; tier--){
if (rating >= tiers[tier]){
return tier;
}
}
return 0;
}
rating을 입력받으면 tier를 tiers라는 표에서 받아오도록 했는데 index를 0에서부터 시작하지 않은 이유가 있다. 0에서부터 시작하면 level구간 시작 < 사용자 레이팅 < level구간 종료 이렇게 코드를 작성해야 하는데 rating을 위에서부터 검사함으로써 rating이 구간 시작보다 크면 종료되도록 했다.
2. tagRatingCalculator
public static int tagRatingCalculator(List<Problem> solvedProblems){
return ((solvedProblems.stream().mapToInt(Problem::getLevel).sum() * 2) +
(int) (200 * (1 - Math.pow(0.99, solvedProblems.size()))));
}
sovled.ac의 tag별 레이팅 계산법이다.

이것을 똑같이 만들었다. 문제들이 주어지면 그 문제들의 난이도를 가지고 계산하도록 했다.
간단 로직 설명 - user이름으로 문제 추천 받기
1. GET /{username}/
//@RequestMapping("/recommend")가 RecommendController에 붙어 있음
@GetMapping("/{username}")
public List<Problem> getProblemByUesrname(@PathVariable String username){
User user = userService.findByName(username);
return problemService.findProblemByUser(user);
}
user의 이름을 받아서 문제를 추천한다. 10개 추천해주려고 한다.
username을 나중엔 session에서 받아올까 생각도 했는데 굳이 로그인 기능을 만들어서 관리를 해야할까 싶었다.
필터링 기능을 이용해서 사용자 입력이 들어오지 않으면 recommend로 이동못하게하고 입력을 하면 이동할 수 있도록 하면 되지 않을까?
username이 입력되었다는 것은 user가 server에 등록되었다는 걸 전제로 만들었다.
2. User (엔티티)
@Column(name = "rating")
private int rating;
@Column(name = "rank")
private int rank;
sovled.ac api에서는 rank랑 rating을 제공해주기 때문에 이들을 저장할 column을 생성했다. rating은 문제 추천을 받을 때 필요한데 내가 직접계산해주기보다. 어차피 sovled.ac에서 사용자 정보 불러올때 사용할테니 사용자 정보 불러올 때 받아오도록 했다.
3. ProblemService
public List<Problem> findProblemByUser(User user){
int userTier = Solved.tierCalculator(user.getRating());
return problemRepository.findProblemByUser(user.getId(), userTier, PageRequest.of(0, 10));
}
User를 통해서 Problem을 찾아주는 메서드 이다. 사용자의 rating을 받아와서 tier를 계산해주고 이것을 통해서 리파지토리에 요청을 한다. 공식적으로 jpa에서는 sql에서 사용하는 limit로 사용자가 데이터의 개수를 제한할 수 없기 때문에 PageRequest를 사용해서 문제를 10개 받아오도록 했다.
4. ProblemRepository
@Query("SELECT p FROM Problem p WHERE p.level BETWEEN :tier - 2 AND :tier + 2 " +
"AND p.problemId NOT IN (SELECT upm.problem.problemId FROM UserProblemMapping upm WHERE upm.user.id = :userId) " +
"ORDER BY p.acceptedUserCount DESC")
List<Problem> findProblemByUser(@Param("userId") Long userId, @Param("tier") int tier, Pageable pageable);
다음은 사용자의 tier를 기준으로 절대값 2사이에 그리고 사용자가 풀지 않은 문제중 정답을 맞춘 사람이 많은 문제를 가져오도록 했다.
접은글은 내가 문제 추천을 받았을 때 기뻐서 기록해둔 api return값이다. (참고로 필자의 현재 level은 15)
[
{
"problemId": 2252,
"title": "줄 세우기",
"level": 13,
"acceptedUserCount": 20622
},
{
"problemId": 16236,
"title": "아기 상어",
"level": 13,
"acceptedUserCount": 20285
},
{
"problemId": 1238,
"title": "파티",
"level": 13,
"acceptedUserCount": 17073
},
{
"problemId": 12100,
"title": "2048 (Easy)",
"level": 14,
"acceptedUserCount": 16249
},
{
"problemId": 12015,
"title": "가장 긴 증가하는 부분 수열 2",
"level": 14,
"acceptedUserCount": 15080
},
{
"problemId": 1005,
"title": "ACM Craft",
"level": 13,
"acceptedUserCount": 14735
},
{
"problemId": 1655,
"title": "가운데를 말해요",
"level": 14,
"acceptedUserCount": 14638
},
{
"problemId": 1644,
"title": "소수의 연속합",
"level": 13,
"acceptedUserCount": 14518
},
{
"problemId": 2042,
"title": "구간 합 구하기",
"level": 15,
"acceptedUserCount": 12833
},
{
"problemId": 14890,
"title": "경사로",
"level": 13,
"acceptedUserCount": 12609
}
]
간단 로직 설명 - user이름, tagId로 문제 추천 받기
1. GET /{username}/tag/{tagId}
//@RequestMapping("/recommend")가 RecommendController에 붙어 있음
@GetMapping("/{username}/tag/{tagId}")
public List<Problem> getProblemByUsernameAndTag(@PathVariable String username,@PathVariable Long tagId){
User user = userService.findByName(username);
return problemService.findProblemByUserAndTag(user, tagId);
}
tag의 id값과 username을 path로 보내주면 이를통해서 문제를 추천해준다. 5개를 보내준다.
사용자의 정보를 이름으로 받아와 보내준다.
2. ProblemService
public List<Problem> findProblemByUserAndTag(User user, Long tagId){
int userTagTier = userTagAverageLevel(user.getId(), tagId);
int userTier = Solved.tierCalculator(user.getRating());
int averageTier = (userTagTier + userTier)/2;
return problemRepository.findProblemsByTagExcludingUserSolved(user.getId(), tagId, averageTier, PageRequest.of(0, 5));
}
1. user가 해결한 문제를 토대로 user의 TagTier를 계산해야한다.
2. user의 Rating을 계산해야 한다.
3. averageTier를 1번과 2번에서 나온값으로 더하고 2로 나눠서 사용자가 문제를 너무 쉽게 느끼지도 너무 어렵게 느끼지도 않게 만드려고 한다. 사용자의 총티어와 tag의 티어의 중간 값을 찾는 과정이다. 보통 사용자 총티어가 훨씬 높다.
4. 문제를 찾아서 return해준다. page기능으로 5개만 보내준다.
2-1-1. userTagAverageLevel
public int userTagAverageLevel(Long userId, Long tagId){
List<Problem> solvedProblems = problemRepository.findSolvedProblemsByUserAndTag(userId, tagId);
return Solved.tierCalculator(Solved.tagRatingCalculator(solvedProblems));
}
사용자와 tagid값을 기반으로 사용자가 푼 문제를 찾아와 tier계산기에 넣어준다.
2-1-2. findSolvedProblemsByUserAndTag
@Query("SELECT p FROM Problem p JOIN p.userProblemMappings upm JOIN p.problemTagMappings ptm " +
"WHERE upm.user.id = :userId AND ptm.problemTag.bojTagId = :tagId")
List<Problem> findSolvedProblemsByUserAndTag(@Param("userId") Long userId, @Param("tagId") Long tagId);
사용자와 tagid값을 기반으로 찾는 리파지토리
2-2. 는 위에서 설명한 메소드라 생략 2-3은 일반 계산이라 생략
2-4. findProblemsByTagExcludingUserSolved
@Query("SELECT p FROM Problem p JOIN p.problemTagMappings ptm " +
"WHERE ptm.problemTag.bojTagId = :tagId AND p.problemId NOT IN " +
"(SELECT upm.problem.problemId FROM UserProblemMapping upm WHERE upm.user.id = :userId) " +
"ORDER BY ABS(p.level - :averageTier) ASC, p.acceptedUserCount DESC")
List<Problem> findProblemsByTagExcludingUserSolved(@Param("userId") Long userId, @Param("tagId") Long tagId, @Param("averageTier") int averageTier, Pageable pageable);
이번코드는 계산된 tier로 +-2를 해서 계산하지 않는다. 왜냐하면 tag별로 문제를 찾을시 문제수가 적은 tag가 존재하거나 특정 level에서는 많이 존재하지 않는 문제가 있을 수 있다. 따라서 계산된 티어에 가까운 순서대로 문제를 정렬하고 같은 거리에 있는 문제일 경우 문제를 맞은 사용자 수가 많은 순서대로 정렬하도록 했다.
tagid 33그리디 알고리즘으로 추천받은 문제 필자의 그리디 알고리즘 티어는 gold5 - 11 총 티어는 gold1 - 15 그래서 13에서 많이 추천된 모습 추후에 푼 사용자가 얼마 이하인 문제는 추천하지 않도록 하는 계선 사항이 필요해 보임
[
{
"problemId": 13904,
"title": "과제",
"level": 13,
"acceptedUserCount": 4577
},
{
"problemId": 2109,
"title": "순회강연",
"level": 13,
"acceptedUserCount": 4318
},
{
"problemId": 1082,
"title": "방 번호",
"level": 13,
"acceptedUserCount": 1644
},
{
"problemId": 1132,
"title": "합",
"level": 13,
"acceptedUserCount": 1279
},
{
"problemId": 2879,
"title": "코딩은 예쁘게",
"level": 13,
"acceptedUserCount": 916
}
]
직면한 문제 (회고)
를 해야하나 직면한 문제를 기록한 txt가 사라져 회고로 하려한다.
1. query 실력 부족
내가 아직 많이 미숙하다는 것을 느낀 단계 일단 쿼리 작성하는 것이 너무나도 어려웠다. 쿼리문들을 대학에서 배운지는 거의 2년이 다되가는 것 같고 그동안 내가 복습을 하지 않았다는 걸 느꼈다. 공부하자 내 자신한테 엄격하자
2. return문 전부 수정
User user = userService.findByName(username);
return problemService.findProblemByUser(user);
이런걸
User user = userService.findByName(username);
List<Problem> problems = problemService.findProblemByUser(user);
return problems
다음과 같이 적어서 불필요한 객체들을 생성하는 아주 못된 짓을 계속 하고 있었다. 그래서 내가만든 모든 메소드들을 리펙토링
3. user를 sovled.ac에서 받아오는 방식 변경
@GetMapping("/user")
public void fetchAndSaveUser() throws IOException, InterruptedException{
String username = "parkswon1";
int page = 1;
User user = userService.findByName(username);
if (user == null){
userService.saveUserByName(username);
user = userService.findByName(username);
}
String uri = SolvedAPI.getUserSolvedProblemByName(username, page);
HttpResponse<String> response = SolvedAPI.solvedacAPIRequest(uri);
JsonObject JsonUser = parser.parse(response.body()).getAsJsonObject();
userProblemMappingService.saveUserProblemMapping(JsonUser, user);
int endPage = (JsonUser.get("count").getAsInt() / 50) + 1;
for (page = 2; page <= endPage; page++){
uri = SolvedAPI.getUserSolvedProblemByName(username, page);
response = SolvedAPI.solvedacAPIRequest(uri);
JsonUser = parser.parse(response.body()).getAsJsonObject();
userProblemMappingService.saveUserProblemMapping(JsonUser, user);
}
}
}
위 코드의 문제가 뭐였을까? 바로 username을 입력하면 SolvedAPI에서 user정보를 가져오지 않고 문제만 가져와서 문제를 받으면서 동시에 문제 수를 계산해야한다는 점이다 이를 수정했다. rating이랑 rank를 사용자 정보에서 불러와야하는 김에 사용자가 푼문제도 받아와 좀더 깔끔한 로직으로 변경했다.
@GetMapping("/user/{username}")
public ResponseEntity<String> fetchAndSaveUser(@PathVariable String username) throws IOException, InterruptedException{
String uri = SolvedAPI.getUserByName(username);
HttpResponse<String> response = SolvedAPI.solvedacAPIRequest(uri);
JsonObject originJson = JsonParser.parseString(response.body()).getAsJsonObject();
JsonObject JsonUser = originJson.getAsJsonArray("items").get(0).getAsJsonObject();
if (originJson.get("count").getAsInt() == 1){
User user = userService.findByName(username);
if (user == null){
user = new User();
user.setUsername(username);
}
user.setRank(JsonUser.get("rank").getAsInt());
user.setRating(JsonUser.get("rating").getAsInt());
userService.saveUser(user);
int endPage = (JsonUser.get("solvedCount").getAsInt() / 50) + 1;
for (int page = 1; page <= endPage; page++){
uri = SolvedAPI.getUserSolvedProblemByName(username, page);
response = SolvedAPI.solvedacAPIRequest(uri);
originJson = JsonParser.parseString(response.body()).getAsJsonObject();
userProblemMappingService.saveUserProblemMapping(originJson, user);
}
return ResponseEntity.ok("User fetched successfully.");
}
else{
return ResponseEntity.status(HttpStatus.NOT_FOUND).body("User not found.");
}
}
ResponseEntity라는 걸 알게되어서 사용자가 있다면 내 로직이 돌아가도록 아니면 클라이언트에 알려주도록 상태코드도 포함하도록 만들었다. sovled.ac에 username으로 사용자 정보를 요청했을때 count : 숫자, items = [사용자 정보들] 로 알려주는데이를 이용해서 count가 1이면 사용자가 있는거 아니면 사용자가 없는걸로 판단하도록 했다. (원래도 1이면 켜진거니까 ^^)
4. ResponseEntity 적극적 사용
너무 RESTFULL한 설계에서 멀어지려고 하고 있다. 이제부터 상태코드를 좀 커스터마이징도 해보고 해야겠다.
5. @JsonIgnore 추가
@OneToMany(mappedBy = "problem")
@JsonIgnore
private Set<ProblemTagMapping> problemTagMappings = new HashSet<>();
@OneToMany(mappedBy = "problem")
@JsonIgnore
private Set<UserProblemMapping> userProblemMappings = new HashSet<>();
분명 디버깅 과정에서는 Controller에 문제가 10개 혹은 5개가 정확하게 들어가는걸 확인했는데 응답받은 문제 목록을 보니까 if문 지옥처럼 쭉 늘어난 엄청난 양의 Json이 값들이 중복된 상태로 들어오는걸 확인했다. 이는 Json이 객체를 보내줄 때 발생했는데 mappedBy에 의해 순환 참조가 일어남으로써 불필요한 데이터를 엄청나게 주는 것이였다. 그래서 이건 굳이 보내줄 필요가 없으니까 무시하도록 시켰다.
6. 테스트 주도 개발의 필요성
테스트 주도 개발이라는 것을 배웠는데 매우 흥미로웠다. 블로그에 다른글로 한번 다뤄보겠다. 내가이제 만들 코드에도 적용시켜 보고 싶다.
7. MVC만 사용할 때의 한계점과 DDD의 도입 필요성
코드를 작성하면 작성할 수록 점점 객체간의 의존성이 올라가는 느낌 한번 날잡아서 싹다 이벤트기반의 DDD로 만들어 보고 싶다. 물론 MVC의 장점을 흡수한 DDD로 바꿔보려한다.
8. 좀더 깔끔하고 규칙있는 commit의 필성
txt를 잃어버린 만큼 내 git에 있는 commit을 보면서 작성했는데 내가봐도 보기힘든 commit이 즐비했다. 나중에 내가 누군가랑 협업할 때 이런식으로 하면 빰을 맞아도 고소도 못할 꺼 같아 코딩 외적인 사회적인? 부분을 기르기 위해 노력해야 겠다 결심했다.
오히려 txt를 잃어버림으로써 내자신을 더 돌아볼 수 있는 기회가 생긴 것 같다. 명확한 목표가 생긴거 같아서 기분이 좋다.
다른 프로젝트에 이 프로젝트를 사용하기로 해서 다음 글작성은 좀 시간이 걸릴것 같다.
'Project : 백준 문제 추천 서비스 > 개발일지' 카테고리의 다른 글
[백준 문제 추천/개발 일지] (4) Mapping table 중복 문제 + api호출 요청을 줄이기 위한 노력? (0) | 2024.06.13 |
---|---|
[백준 문제 추천/개발 일지] (3) 문제 DB에 저장하기 + tag랑 Mapping하기 (1) | 2024.06.11 |
[백준 문제 추천/개발 일지] (2) 문제 Tag DB에 저장하기 + 회고 (0) | 2024.06.04 |
[백준 문제 추천/개발 일지] (1) 개발 이유 + sovled.ac API 사용하기 (0) | 2024.06.01 |
Coding, Software, Computer Science 내가 공부한 것들 잘 이해했는지, 설명할 수 있는지 적는 공간