저번 피드백에서 여러가지 피드백을 받았는데 객체지향적인쪽에 조금 더 집중을 해보라는 피드백을 받았다.
현재 문제는 굳이 패키지를 나눈 의미가 없다는 느낌이었다.
객체관의 행위가 중요하다.
예를 들어 user 혼자서는 못할때 위임이 되어서 연관을 맺는거지
그냥 단순 조회는 정보를 가져오는거니까 user쪽에서 조회해야 하는것이다.
연관관계를 끊기 위해서 dto가 있는것이니
pet | user 를 나누어 놓았으니 dto로 반환을 해야한다!
서로서로 조회해서 dto에 취합을 해서 내보내야 함.
패키지를 나눈것부터가 dto로 왔다갔다 해야댐 -> 영향을 안 받을라고 나눈거니까
다 접근할거면 굳이 나눌 필요가 없음.
또한 서비스 클래스는 컨트롤 타워 느낌이다(db또는 외부에서 데이터를 가져오거나, 트랜잭션 관리만을 진행)
나머지는 전부 객체한테 위임한다!
즉 서비스로직에 단순 조회용 get이 아닌 get이나 if-else문은 지양해라!
라고 피드백을 받아서 차근차근 하나씩 고쳐보려고 한다.
현재 나는 Service로직을 잘못 알고 있었던 것 같다.
비지니스 모델이라고 하면 다 서비스로직으로 생각하고 있었다.
하지만 서비스단에서는 controller와 연결을 해주는것이 올바르다고 생각한다.
하지만 나는 지금 서비스로직이 서비스로직을 참조하고 있는 형태가 되고 있다.
그래서 컴포넌트단을 새롭게 만들어 여러 비지니스 로직을 해결하고
서비스 단에서 그것을 참조하고 컨트롤러에게 넘겨주는 형식으로 진행하려고 한다.


여러가지 컴포넌트로 분리하였음.
@Service, @Repo 에노테이션은 결국 Componet + 가독성을 위한 에노테이션이라고 생각했기에
Componet라는 패키지로 만들어 비지니스 로직들을 집중하기로 하였음.
붕어빵이론
붕어빵 속이 궁금해요
1. 붕어빵을 부셔서 속을 본다. | 2. 붕어빵에게 직접 물어본다
2번을 지향하자!!!

기존의 위도 경도를 체크하는 로직 (원래는 matchPlace)서비스단에 들어있었다.
하지만 붕어빵원칙을 적용하여 user에게 위임.

user객체에게 스스로 검증을 하도록 위임하였다.

하지만 이렇게 타입이 user가 아닌 userResponse인경우에는 어떻게할까?
userResponse : redis에서 꺼내오는 데이터
2개를 고민하다가 어처피 일회성이고, 단순히 null 체크이기에 DTO에 검증로직을 추가하기로 하였다.
(단 record 객체는 보통 불변성이 중요하고 나는 null 체크 후 예외를 던지는 간단한 로직이니 추가하였음. 객체의 값이 변하는 예외처리면 따로 로직을 구성하는것이 바람직하다!!)
같은 원리로 서비스단에서 펫, 유저 직접 조회를 없애고 전부 펫, 유저 패키지에게 위임하였음.

리펙토링 과정에서 이렇게 하니 충돌이 났다.
Stream.toList()는 불변 리스트(Immutable List)를 반환한다.
-> .collect(Collectors.toCollection(ArrayList::new));
//처럼 가변 리스트로 생성해줘야 정렬 가능!
@Service
@RequiredArgsConstructor
public class MatchScoreService {
private final MatchUserRepository matchUserRepository;
private final MatchCareService matchCareService;
private final MatchMbtiService matchMbtiService;
private final MatchPlaceService matchPlaceService;
private final MatchSizeService matchSizeService;
private final PetService petService;
private final RedisTemplate<String, Object> redisTemplate;
private final UserRepository userRepository;
private final PetRepository petRepository;
// sql에서 필터링 후 1000명 가져오기
public List<UserResponse> getUsersWithinBoundingBox(Long userId) {
// User user = userService.findUserById(userId);
// 임시로 사용
User user = userRepository.findById(userId)
.orElseThrow(() -> new IllegalArgumentException("유저 없음: " + userId));
double userLat = user.getLatitude();
double userLng = user.getLongitude();
double rangeKm = 100.0; // 대략 60km 정도 필터링
// 러프한 범위 작성
double latChange = rangeKm / 111.0; // 1도 = 111km
double lngChange = rangeKm / (111.0 * Math.cos(Math.toRadians(userLat)));
double minLat = userLat - latChange;
double maxLat = userLat + latChange;
double minLng = userLng - lngChange;
double maxLng = userLng + lngChange;
// Bounding Box로 후보자들을 먼저 필터링
List<Object[]> rawUsers = matchUserRepository.findUsersWithinBoundingBox(userLng, userLat, minLat, maxLat, minLng, maxLng);
List<UserResponse> userResponses = rawUsers.stream()
.map(row -> {
long id = ((Number) row[0]).longValue();
double latitude = ((Number) row[1]).doubleValue();
double longitude = ((Number) row[2]).doubleValue();
boolean isCareAvailable = (Boolean) row[3];
String preferredSize = (String) row[4];
List<String> ListPreferredSize = changeList(preferredSize);
String mbti = (String) row[5];
// 거리 계산
double distance = (double) row[6];
System.out.println("User ID: " + id + ", Distance: " + distance + " meters");
return new UserResponse(id, latitude, longitude, isCareAvailable, ListPreferredSize, mbti, distance);
})
.sorted(Comparator.comparingDouble(UserResponse::distance)) // 거리로 정렬
.limit(1000)
.collect(Collectors.toList());
return userResponses;
}
// TODO 현재는 임시로 유저, pet(mbti) 직접 조회 | 추후에 user, pet 패키지 구현 의뢰 예정
public List<MatchScoreResponse> calculateTotalScore(Long userId) {
User user = userRepository.findById(userId)
.orElseThrow(() -> new IllegalArgumentException("User not found for ID: " + userId));
List<UserResponse> targetUsers = getUsersWithinBoundingBox(userId);
List<MatchScoreResponse> matchResults = targetUsers.stream().map(targetUser -> {
double distance = matchPlaceService.calculateDistanceOnly(user, targetUser);
double distanceScore = matchPlaceService.findMatchesWithinDistance(user, targetUser);
double careScore = matchCareService.calculateCareScore(targetUser.isCareAvailable());
List<String> preferredSizes = targetUser.preferredSize();
double sizeScore = matchSizeService.calculateDogSizeScore(targetUser.id(), preferredSizes);
String dogMbti = getTemperamentByUserId(userId);
double mbtiScore = matchMbtiService.calculateMbtiScore(dogMbti, targetUser.mbti());
double totalScore = distanceScore + careScore + sizeScore + mbtiScore;
return new MatchScoreResponse(
targetUser.id(),
Math.round(distance * 100.0) / 100.0,
Math.round(distanceScore * 100.0) / 100.0,
Math.round(careScore * 100.0) / 100.0,
Math.round(sizeScore * 100.0) / 100.0,
Math.round(mbtiScore * 100.0) / 100.0,
Math.round(totalScore * 100.0) / 100.0
);
}).collect(Collectors.toList());
// 점수로 내림차순 (같으면 거리로 오름차순)
matchResults.sort(Comparator
.comparingDouble(MatchScoreResponse::totalScore)
.thenComparingDouble(MatchScoreResponse::distance).reversed()
);
String redisKey = "matchResult:" + user.getId();
redisTemplate.opsForValue().set(redisKey, matchResults);
return matchResults;
}
public void decreaseScore(Long userId, Long targetId) {
double penaltyScore = 30.0; // 감소시킬 점수
String redisKey = "matchResult:" + userId;
// Redis에서 캐시된 데이터를 가져옴
Object rawData = redisTemplate.opsForValue().get(redisKey);
if (rawData == null) {
throw new MatchException(NULL_MATCH_DATA);
}
List<MatchScoreResponse> matchScores;
// Redis에서 가져온 데이터를 JSON 문자열로 처리
try {
if (rawData instanceof String) {
ObjectMapper objectMapper = new ObjectMapper();
matchScores = objectMapper.readValue((String) rawData, new TypeReference<List<MatchScoreResponse>>() {});
} else if (rawData instanceof List) {
matchScores = (List<MatchScoreResponse>) rawData;
} else {
throw new MatchException(INVALID_REDIS_DATA);
}
} catch (JsonProcessingException e) {
throw new IllegalArgumentException("JSON 데이터 처리 중 오류 발생", e);
}
// 점수 감소
List<MatchScoreResponse> updatedResults = matchScores.stream()
.map(match -> {
if (match.id().equals(targetId)) {
// targetId에 해당하는 사용자 점수만 감소
double newScore = Math.max(0, match.totalScore() - penaltyScore);
return new MatchScoreResponse(
match.id(),
match.distance(),
match.distanceScore(),
match.careAvailabilityScore(),
match.sizePreferenceScore(),
match.mbtiScore(),
newScore // 수정된 totalScore
);
}
return match; // 다른 값은 그대로 유지
})
.toList();
// JSON 형식으로 직렬화 후 Redis에 저장
try {
ObjectMapper objectMapper = new ObjectMapper();
String updatedData = objectMapper.writeValueAsString(updatedResults);
redisTemplate.opsForValue().set(redisKey, updatedData); // String으로 저장
} catch (JsonProcessingException e) {
throw new IllegalStateException("JSON 직렬화 오류 발생", e);
}
}
// TODO 펫 서비스쪽으로 옮겨야 함
public String getTemperamentByUserId(Long userId) {
// userId로 Pet 목록 조회
List<Pet> pets = petRepository.findByUserId(userId);
// 첫 번째 Pet의 temperament 반환, 없으면 "Unknown"
if (!pets.isEmpty()) {
return pets.get(0).getTemperament() != null ? pets.get(0).getTemperament() : "Unknown";
}
return "Unknown";
}
//
public List<String> changeList(String preferredSizeString) {
List<String> preferredSize = preferredSizeString == null || preferredSizeString.isEmpty()
? new ArrayList<>()
: Arrays.asList(preferredSizeString.split(","));
return preferredSize;
}
변경전 MatchScoreService
서비스가 서비스를 참조하고 있고, 컨트롤타워의 역할보다는 모든 비지니스 로직이 들어가있다.
@Service
@RequiredArgsConstructor
public class MatchScoreService {
private final MatchUserRepository matchUserRepository;
private final UserRepository userRepository;
private final MatchScoreCalculator matchScoreCalculator;
private final RedisMatchComponent matchComponent;
private final UserMapper userMapper;
private final BoundingBoxCalculator boundingBoxCalculator;
// sql에서 필터링 후 1000명 가져오기
public List<UserResponse> getUsersWithinBoundingBox(Long userId) {
User user = userRepository.findById(userId)
.orElseThrow(() -> new IllegalArgumentException("유저 없음: " + userId));
double userLat = user.getLatitude();
double userLng = user.getLongitude();
// Bounding Box 계산
BoundingBoxResponse boundingBox = boundingBoxCalculator.calculateBoundingBox(userLat, userLng, 100.0);
List<Object[]> rawUsers = matchUserRepository.findUsersWithinBoundingBox(
userLng, userLat, boundingBox.minLat(), boundingBox.maxLat(),
boundingBox.minLng(), boundingBox.maxLng()
);
// 사용자 데이터를 UserResponse로 매핑
return rawUsers.stream()
.map(userMapper::mapToUserResponse) // UserMapper로 매핑
.sorted(Comparator.comparingDouble(UserResponse::distance)) // 거리로 정렬
.limit(1000) // 상위 1000명 제한
.collect(Collectors.toList());
}
// TODO 현재는 임시로 유저, pet(mbti) 직접 조회 | 추후에 user, pet 패키지 구현 의뢰 예정
public List<MatchScoreResponse> calculateTotalScore(Long userId) {
User user = userRepository.findById(userId)
.orElseThrow(() -> new IllegalArgumentException("User not found for ID: " + userId));
List<UserResponse> targetUsers = getUsersWithinBoundingBox(userId);
List<UserResponse> filteredTargetUsers = targetUsers.stream()
.filter(targetUser -> !targetUser.id().equals(userId))
.toList();
List<MatchScoreResponse> matchResults = filteredTargetUsers.stream()
.map(targetUser -> matchScoreCalculator.calculateScore(user, targetUser))
.collect(Collectors.toCollection(ArrayList::new)); // 가변 리스트 생성
matchResults.sort(Comparator
.comparingDouble(MatchScoreResponse::totalScore)
.thenComparingDouble(MatchScoreResponse::distance)
.reversed()
);
matchComponent.saveMatchScores(userId, matchResults);
return matchResults;
}
public void decreaseScore(Long userId, Long targetId) {
List<MatchScoreResponse> updatedResults = matchScoreCalculator.decreaseScore(userId, targetId);
matchComponent.saveMatchScores(userId, updatedResults);
}
}
서비스단에서의 비지니스 로직을 최소화 하고, 컨트롤타워 역할에 최대한 집중하는 쪽으로 구현하였다
Componet -> Service -> Controller 라는 식으로 흘러가도록 최대한 구현하였다.
위에서 아래는 되도 아래에서 위는 참조하지 못하도록 말이다!