개요
현재 외부 부트캠프에서 프론트엔드와 백엔드가 모여서 협업 프로젝트를 진행하고 있다.
해당 프로젝트의 주제는 자취생들을 위한 레시피를 추천해 주는 컨셉의 서비스인데, 추천 알고리즘을 잘 만드는 게 핵심이라고 생각한다.
추천 알고리즘을 설계하기 위해서는 단순히 아무 레시피나 추천해주는 것이 아니라, 어떠한 로직에 의해서 결정된 추천 레시피를 유저에게 전달해주어야 한다.
따라서 우리는 프로젝트 기획 단계에서 Slope-One 알고리즘을 채택하여 다른 유저들이 A부터 D 레시피까지 평가를 했다고 가정할 때, 신규 유입 유저가 B를 평가한다면, B 레시피를 좋게 평가한 유저들이 대체로 어떤 레시피를 선호했는지는 평차와 평균을 통해서 계산하고 신규 유저에게 전달한 레시피 Id를 저장하고, 유저가 추천 레시피를 호출할 때, 데이터베이스를 조회하여 유저에게 전달하게 된다.
이러한 로직을 구현할 때, 차이와 평균, 편차를 모두 데이터베이스에 저장하는 것은 비효율적이다. 추천 로직은 프로젝트 정책에 의해서 하루에 오전 7시, 오진 11시, 오후 5시에 실행되며 새로운 추천 레시피를 갱신하게 된다. 데이터베이스에 저장하거나 업데이트하는 경우 Disk I/O가 많이 발생하게 될 것이고, 이는 서버의 성능 저하로 이어질 우려가 존재한다.
이러한 문제점은 사전에 인식하고 있었기 때문에, 추천 로직을 구현할 때, 차이와 해당 레시피에 몇 명의 유저가 평가하였는지를 저장하는 것은 Singloeton Bean으로 정의하여 해당 Bean을 통해서 중간에 산출되는 결과 값들을 저장할 것이다.
해당 글에서는 이러한 과정에서 발생한 트러블 슈팅을 다룰 예정이다.
구현과정
@Component
public class RecommendComponent {
@Bean
public Map<Long, Map<Long, Double>> recipeDifferences() {
return new ConcurrentHashMap<>();
}
@Bean
public Map<Long, Map<Long, Integer>> recipeCounts() {
return new ConcurrentHashMap<>();
}
}
우선 차이와 비교한 횟수를 저장하기 위해서 하나의 컴포넌트 아래에 2개의 Bean을 정의합니다.
첫 번째 Bean은 Map<Long, Map< Long, Double> 타입으로, 레시피 사이의 평가 차이를 임시로 저장합니다.
두 번째 Bean은 Map<Long, Map<Long, Integer>> 타입으로, 해당 레시피에 대해서 몇 명의 유저가 평가했는지를 임시로 저장합니다.
Bean은 반환값을 통해서 주입이 되기 때문에, 명시적으로 다른 타입으로 정의했습니다.
@Service
@RequiredArgsConstructor
public class RecommendCalculate {
private final RecipeRepository recipeRepository;
private final RecipeRatingRepository recipeRatingRepository;
private final UserAccessHandler userAccessHandler;
private final RecommendRecipeRepository recommendRecipeRepository;
private final Map<Long, Map<Long, Double>> recipeDifferences;
private final Map<Long, Map<Long, Integer>> recipeCounts;
....
}
}
이후 비즈니스 로직에서 RequiredArgsConstructor Annotation을 사용해서 final로 선언된 멤버 변수에 대한 Bean을 생성자 주입을 통해서 구성했습니다.
이후 내부에 추천에 대한 로직을 작성하고 테스트 코드를 작성했습니다.
@ExtendWith(MockitoExtension.class)
class RecommendCalculateTest {
@Mock
private RecipeRepository recipeRepository;
@Mock
private RecipeRatingRepository recipeRatingRepository;
@Mock
private UserAccessHandler userAccessHandler;
@Mock
private RecommendRecipeRepository recommendRecipeRepository;
@Mock
private Map<Long, Map<Long, Double>> recipeDifferences;
@Mock
private Map<Long, Map<Long, Integer>> recipeCounts;
@InjectMocks
private RecommendCalculate recommendCalculate;
Mockiito를 통해서 테스트 코드를 작성했습니다.
해당 Service에서는 이러한 Bean들을 주입받아서 로직을 수행하는데, 이전에 정의했던 Custom Bean을 Mock으로 지정해서 Mock 객체를 주입했다.
이전에는 비즈니스 로직을 테스트할 때 외부로 노출할 메서드만 public으로 선언하고, 해당 계산 로직은 전부 private로 선언하였는데, 테스트 코드과정에서 너무 복잡해지는 바람에 하나의 public으로 변환해서 테스트 코드를 작성했다.
테스트 코드를 전부 작성하고 난 후에 테스트 코드를 동작시켜 보니, 뭔가 정상적으로 동작하지 않는 것을 확인할 수 있었다.
문제 인식
일단 제가 짠 코드를 믿지 않고 시작해야 정확하게 문제점을 인식할 수 있기 때문에, 내가 만든 Bean이 정상적으로 주입되었는지부터 파악을 진행하였다.
@Transactional
public void calculateAllRecommendations() {
log.info("Bean 주입 확인");;
log.info("recipeDifferences: " + recipeDifferences);
log.info("recipeCounts: " + recipeCounts);
log.info("recipeDifferences 빈과 recipeCounts 빈이 같은가? : {}", recipeDifferences.equals(recipeCounts));
....
}
만약 Bean이 정상적으로 주입이 됐더라면, 타입이 다르기 때문에 당연히 False를 기대하고 테스트 코드를 실행하였다.
Bean 주입 확인
recipeDifferences: recipeCounts
recipeCounts: recipeCounts
recipeDifferences 빈과 recipeCounts 빈이 같은가? : true
하지만 내 기대와는 다르게 두 Bean 모두 recipeCounts로 빈이 주입된 것을 확인할 수 있었다.
내가 생각한 해결 방법
우선 문제를 해결하기 이전에, 어떠한 방법을 통해서 문제를 해결할 수 있을지부터 정의해야 한다.
- Bean을 정의할 때, name 속성을 부여하여 명시적으로 Bean을 정의한다.
- 기존의 Component를 각각의 Component로 분리하여 따로따로 정의한다. 이때 Component에도 이름을 부여한다.
- Map Interface를 구현한 구현체 클래스를 정의하여, 하나의 컴포넌트에서 Bean으로 등록하여 정의한다.
1, 2번은 Bean을 명시적으로 지정하여 주입하는 것이었고, 3번은 진짜 별도의 클래스를 통해서 Bean을 정의하고 주입하는 것이다.
문제 해결 과정 - 1
@Component
public class RecommendComponent {
@Bean(name = "recipeDifferences")
public Map<Long, Map<Long, Double>> recipeDifferences() {
return new ConcurrentHashMap<>();
}
@Bean(name = "recipeCounts")
public Map<Long, Map<Long, Integer>> recipeCounts() {
return new ConcurrentHashMap<>();
}
}
기존의 Component에서 Bean name 속성을 부여하여, Bean의 이름을 정의한다.
서비스로직에서는 이제 RequiredArgsConstructor를 사용하지 않고, 직점 하나하나 Bean을 주입한다.
@Slf4j
@Service
public class RecommendCalculate {
private final RecipeRepository recipeRepository;
private final RecipeRatingRepository recipeRatingRepository;
private final UserAccessHandler userAccessHandler;
private final RecommendRecipeRepository recommendRecipeRepository;
private final Map<Long, Map<Long, Double>> recipeDifferences;
private final Map<Long, Map<Long, Integer>> recipeCounts;
public RecommendCalculate(
RecipeRepository recipeRepository,
RecipeRatingRepository recipeRatingRepository,
UserAccessHandler userAccessHandler,
RecommendRecipeRepository recommendRecipeRepository,
@Qualifier("recipeDifferences") Map<Long, Map<Long, Double>> recipeDifferences,
@Qualifier("recipeCounts") Map<Long, Map<Long, Integer>> recipeCounts) {
this.recipeRepository = recipeRepository;
this.recipeRatingRepository = recipeRatingRepository;
this.userAccessHandler = userAccessHandler;
this.recommendRecipeRepository = recommendRecipeRepository;
this.recipeDifferences = recipeDifferences;
this.recipeCounts = recipeCounts;
}
이렇게 빈의 이름을 정확하게 정의해서 주입했으니까, Test 코드가 정상적으로 돌 것이라고 예상했다.
Bean 주입 확인
recipeDifferences: recipeCounts
recipeCounts: recipeCounts
recipeDifferences 빈과 recipeCounts 빈이 같은가? : true
하지만 역시 아직도 동일한 로그가 출력되었다.
문제 해결 과정 - 2
이번에는 하나의 Component에서 주입하기 때문에 발생하는 오류인가 싶어서 컴포넌트를 분리하였다.
@Component("recipeDifferences")
public class RecommendDifferencesComponent {
@Bean(name = "recipeDifferences")
public Map<Long, Map<Long, Double>> recipeDifferences() {
return new ConcurrentHashMap<>();
}
}
@Component("recipeCounts")
public class RecommendCountsComponent {
@Bean(name = "recipeCounts")
public Map<Long, Map<Long, Integer>> recipeCounts() {
return new ConcurrentHashMap<>();
}
}
이렇게 2개의 컴포넌트를 만들고 각각 Bean을 정의하여 이전과 동일하게 Qualifier를 통해서 Bean을 주입하였다.
Bean 주입 확인
recipeDifferences: recipeCounts
recipeCounts: recipeCounts
recipeDifferences 빈과 recipeCounts 빈이 같은가? : true
하지만 이번에도 역시 동일한 로그가 출력되는 것을 확인할 수 있었다.
문제 해결 과정 - 3
이제 여기까지 했으면, 타입이 다르더라고 반환값에 의해서 빈이 주입되고, 이름을 명시해도 동일한 객체가 다른 객체로 들어가기 때문에, 이러한 오류가 발생하는 것이라고 판단했다.
그냥 Map<Long, Map<Long, Double>>과 Map<Long, Map<Long,Integer>>를 구현한 구현체를 만들고 해당 객체를 Bean으로 등록해서 사용하는 것이 정신건강(?)에 좋을 것 같았다.
물론 이렇게 하면 코드의 양도 증가하고, 중간중간 해당 객체 안에 있는 다른 객체를 수정할 때, 별도의 메서드를 만들어서 관리해줘야 하기 때문에, 조금 복잡하기는 하다.
public class RecommendRecipeDifferencesMap implements Map<Long, Map<Long,Double>> {
private final Map<Long, Map<Long,Double>> recipeDifferences = new ConcurrentHashMap<>();
@Override
public int size() {
return this.recipeDifferences.size();
}
@Override
public boolean isEmpty() {
return this.recipeDifferences.isEmpty();
}
.......
}
public class RecommendRecipeCountsMap implements Map<Long, Map<Long, Integer>> {
private final Map<Long, Map<Long, Integer>> recipeCounts = new ConcurrentHashMap<>();
@Override
public int size() {
return this.recipeCounts.size();
}
@Override
public boolean isEmpty() {
return this.recipeCounts.isEmpty();
}
두 구현체 모두 Interface에 선언되어 있는 메서드들을 일부 Override 해야 한다.
@Override
public Map<Long, Double> getOrDefault(Object key, Map<Long, Double> defaultValue) {
return Map.super.getOrDefault(key, defaultValue);
}
또한, 이런 식으로 필수가 아닌 메서드임에도 로직에서 사용하는 메서드들은 모두 구현을 해야 한다.
이후 다시 테스트 코드를 실행하면서 로그를 확인해 본 결과
Bean 주입 확인
recipeDifferences: recipeDifferences
recipeCounts: recipeCounts
recipeDifferences 빈과 recipeCounts 빈이 같은가? : false
결국 다른 빈을 주입하는 데 성공했다.
문제 해결 및 결론
커스텀 클래스를 만들어서 인터페이스를 구현한 구현체를 만들어서 빈으로 주입하는 방법을 통해서 문제를 해결하였다.
사실 이제는 특정 Interface의 타입에 대해서 의존하는 Bean이 아니라 특정 구현체를 Bean으로 등록했기 때문에, 어느 정도 예상한 결과이긴 했다.
그리고 이런 문제가 발생한 정확한 원인에 대해서는 아직 찾아보고 있는 중이다.
하지만 추측 상으로는 아마도 반환 타입을 다르게 정의를 했지만 결국 2개의 Bean 모두 ConcurrentHashMap이라는 구현체를 반환했기 때문에 동일한 타입으로 추론되어서 빈 주입이 실패했던 것 같다. 하지만 이런 경우는 Qualifier와 Bean 이름 지정을 통해서 해결될 것이라고 생각했지만, 이 역시도 문제를 해결할 수는 없었다.
이전 프로젝트나 개인적으로 공부를 할 때도 Map 또는 List, Set이라는 Interface를 통해서 빈을 정의하고 주입하는 방식을 통해서 진행했었다.
하지만 이번 문제를 통해서 단순히 Interface를 통해서 Bean을 정의하는 것이 아니라, 구현체를 통해서 더 자세하게 Bean을 정의하고 이를 통해서 구현을 진행하는 것이 더 나을 것 같다는 결론이 나왔다.
그리고 역시 테스트 코드는 "반드시" 짜야한다.
이번 트러블 슈팅을 계기로 Bean과 Spring이 Bean을 주입하는 과정에 대해서 보다 자세하게 공부할 예정이다.
'Backend > Framework' 카테고리의 다른 글
[Spring Boot] Spring Boot의 API 요청 처리 흐름 완벽 해부 (0) | 2024.07.10 |
---|---|
[Spring Boot] Spring Framework의 구성 요소와 배경 (0) | 2024.07.09 |
[Springboot] AWS S3 with Spring Boot3 (1) | 2024.04.29 |
[Springboot] 로그인 구현 & JWT (2) - 로그인 구현 (1) | 2023.11.15 |
[Springboot] 로그인 구현 & JWT (1) - JWT 개념 (0) | 2023.11.09 |