Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
import org.springframework.boot.autoconfigure.jdbc.DataSourceAutoConfiguration;
import org.springframework.boot.autoconfigure.orm.jpa.HibernateJpaAutoConfiguration;
import org.springframework.boot.autoconfigure.security.servlet.UserDetailsServiceAutoConfiguration;
import org.springframework.cloud.openfeign.EnableFeignClients;
import org.springframework.context.annotation.ComponentScan;

@SpringBootApplication(
Expand All @@ -15,6 +16,7 @@
}
)
@ComponentScan(basePackages = {"com.comatching.chat", "com.comatching.common"})
@EnableFeignClients(basePackages = "com.comatching.chat")
public class ChatServiceApplication {

public static void main(String[] args) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,17 +3,24 @@
import java.time.LocalDateTime;

import com.comatching.chat.domain.entity.ChatRoom;
import com.comatching.common.domain.vo.KoreanAge;
import com.comatching.common.dto.member.ProfileResponse;

public record ChatRoomResponse(
String id,
Long matchingId,
Long initiatorUserId,
Long targetUserId,
UserSummary otherUser,
String lastMessage,
LocalDateTime lastMessageTime,
long unreadCount
) {
public static ChatRoomResponse from(ChatRoom room, long unreadCount) {
return from(room, unreadCount, null);
}

public static ChatRoomResponse from(ChatRoom room, long unreadCount, UserSummary otherUser) {
String content = (room.getLastMessageInfo() != null) ? room.getLastMessageInfo().getContent() : null;
LocalDateTime time = (room.getLastMessageInfo() != null) ? room.getLastMessageInfo().getSentAt() : null;

Expand All @@ -22,9 +29,33 @@ public static ChatRoomResponse from(ChatRoom room, long unreadCount) {
room.getMatchingId(),
room.getInitiatorUserId(),
room.getTargetUserId(),
otherUser,
content,
time,
unreadCount
);
}

public record UserSummary(
Long memberId,
String nickname,
String profileImageUrl,
String university,
Integer age
) {
public static UserSummary from(ProfileResponse profile) {
KoreanAge age = KoreanAge.fromBirthDate(profile.birthDate());
return new UserSummary(
profile.memberId(),
profile.nickname(),
profile.profileImageUrl(),
profile.university(),
age != null ? age.getValue() : null
);
}

public static UserSummary memberOnly(Long memberId) {
return new UserSummary(memberId, null, null, null, null);
}
}
Comment on lines +39 to +60

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

UserSummary 레코드가 ChatRoomResponse 내부에 중첩되어 있습니다. 현재는 ChatRoomResponse와 밀접하게 관련되어 있어 문제가 없지만, 향후 다른 DTO에서도 사용자 요약 정보가 필요해질 경우 중복 코드가 발생할 수 있습니다. UserSummary를 별도의 최상위 레코드로 분리하여 재사용성을 높이고 ChatRoomResponse의 응집도를 유지하는 것을 고려해볼 수 있습니다.

References
  1. 클래스와 메서드가 하나의 이유로만 변경되는지 확인합니다. (link)
  2. 중복 로직이 유틸, 공통 컴포넌트, 도메인 메서드로 추출 가능한지 검토합니다. (link)

}
Original file line number Diff line number Diff line change
Expand Up @@ -4,18 +4,23 @@
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.function.Function;
import java.util.stream.Collectors;

import org.springframework.data.domain.Sort;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import com.comatching.chat.domain.dto.ChatRoomResponse;
import com.comatching.chat.domain.dto.ChatRoomResponse.UserSummary;
import com.comatching.chat.domain.entity.ChatRoom;
import com.comatching.chat.domain.repository.ChatMessageRepository;
import com.comatching.chat.domain.repository.ChatRoomRepository;
import com.comatching.chat.domain.repository.UnreadCountCondition;
import com.comatching.chat.domain.service.block.BlockService;
import com.comatching.chat.infra.client.MemberClient;
import com.comatching.common.dto.event.matching.MatchingSuccessEvent;
import com.comatching.common.dto.member.ProfileResponse;

import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
Expand All @@ -29,6 +34,7 @@ public class ChatRoomServiceImpl implements ChatRoomService {
private final ChatRoomRepository chatRoomRepository;
private final ChatMessageRepository chatMessageRepository;
private final BlockService blockService;
private final MemberClient memberClient;

@Override
public void createChatRoom(MatchingSuccessEvent event) {
Expand Down Expand Up @@ -62,9 +68,14 @@ public List<ChatRoomResponse> getMyChatRooms(Long memberId) {
.toList();

Map<String, Long> unreadCountsByRoom = getUnreadCountsByRoom(visibleRooms, memberId);
Map<Long, ProfileResponse> profilesByMemberId = getProfilesByMemberId(visibleRooms, memberId);

return visibleRooms.stream()
.map(room -> ChatRoomResponse.from(room, unreadCountsByRoom.getOrDefault(room.getId(), 0L)))
.map(room -> {
Long otherUserId = getOtherUserId(room, memberId);
UserSummary otherUser = toUserSummary(otherUserId, profilesByMemberId.get(otherUserId));
return ChatRoomResponse.from(room, unreadCountsByRoom.getOrDefault(room.getId(), 0L), otherUser);
})
Comment on lines 73 to +78

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

getMyChatRooms 메서드 내에서 채팅방 목록 조회, 차단된 방 필터링, 안 읽은 메시지 수 조회, 상대방 프로필 조회 등 여러 단계의 비즈니스 로직이 순차적으로 실행되고 있습니다. 각 단계가 private 메서드로 잘 분리되어 있지만, getMyChatRooms 메서드 자체의 책임이 다소 커 보입니다. 이 메서드의 복잡도를 줄이고 가독성을 높이기 위해, 각 단계를 더 명확하게 구분하는 방식으로 리팩토링하거나, 파이프라인 형태의 처리를 고려해볼 수 있습니다. 예를 들어, ChatRoom 객체에 otherUser와 unreadCount를 설정하는 책임을 별도의 도메인 서비스나 팩토리 메서드로 분리하는 것도 방법입니다.

References
  1. 클래스와 메서드가 하나의 이유로만 변경되는지 확인합니다. (link)
  2. 메서드가 과도하게 길지 않은지 (한 가지 책임만 가지는지) 확인합니다. (link)

.toList();
}

Expand Down Expand Up @@ -106,6 +117,32 @@ private Map<String, Long> getUnreadCountsByRoom(List<ChatRoom> rooms, Long membe
return chatMessageRepository.countUnreadMessagesByRoom(unreadCountConditions, memberId);
}

private Map<Long, ProfileResponse> getProfilesByMemberId(List<ChatRoom> rooms, Long memberId) {
if (rooms.isEmpty()) {
return Map.of();
}

List<Long> otherUserIds = rooms.stream()
.map(room -> getOtherUserId(room, memberId))
.distinct()
.toList();

List<ProfileResponse> profiles = memberClient.getProfiles(otherUserIds);
if (profiles == null || profiles.isEmpty()) {
return Map.of();
}

return profiles.stream()
.collect(Collectors.toMap(ProfileResponse::memberId, Function.identity(), (left, right) -> left));
}

private UserSummary toUserSummary(Long memberId, ProfileResponse profile) {
if (profile == null) {
return UserSummary.memberOnly(memberId);
}
return UserSummary.from(profile);
}

private LocalDateTime getMyLastReadAt(ChatRoom room, Long memberId) {
return memberId.equals(room.getInitiatorUserId())
? room.getInitiatorLastReadAt()
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
package com.comatching.chat.infra.client;

import java.util.List;

import org.springframework.cloud.openfeign.FeignClient;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;

import com.comatching.common.dto.member.ProfileResponse;

@FeignClient(name = "user-service", path = "/api/internal/users", url = "${user-service.url}")
public interface MemberClient {

@PostMapping("/profiles/bulk")
List<ProfileResponse> getProfiles(@RequestBody List<Long> memberIds);
}
3 changes: 3 additions & 0 deletions chat-service/src/main/resources/application-aws.yml
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,9 @@
server:
port: 9003

user-service:
url: http://user-service:9000

spring:
application:
name: chat-service
Expand Down
3 changes: 3 additions & 0 deletions chat-service/src/main/resources/application.yml
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
server:
port: 9003

user-service:
url: http://localhost:9000

spring:
application:
name: chat-service
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
import static org.mockito.ArgumentMatchers.*;
import static org.mockito.BDDMockito.*;

import java.time.LocalDate;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Map;
Expand All @@ -24,6 +25,8 @@
import com.comatching.chat.domain.repository.ChatRoomRepository;
import com.comatching.chat.domain.repository.UnreadCountCondition;
import com.comatching.chat.domain.service.block.BlockService;
import com.comatching.chat.infra.client.MemberClient;
import com.comatching.common.dto.member.ProfileResponse;

@ExtendWith(MockitoExtension.class)
class ChatRoomServiceImplTest {
Expand All @@ -41,6 +44,9 @@ class ChatRoomServiceImplTest {
@Mock
private BlockService blockService;

@Mock
private MemberClient memberClient;

@InjectMocks
private ChatRoomServiceImpl chatRoomService;

Expand All @@ -58,21 +64,61 @@ void getMyChatRooms_batchesUnreadCounts() {
given(blockService.getBlockedUserIds(MEMBER_ID)).willReturn(Set.of());
given(chatMessageRepository.countUnreadMessagesByRoom(anyList(), eq(MEMBER_ID)))
.willReturn(Map.of("room-1", 2L, "room-2", 3L));
given(memberClient.getProfiles(anyList()))
.willReturn(List.of(
profile(OTHER_MEMBER_ID, "첫번째상대", "https://img.example/first.png", "코매칭대", LocalDate.now().minusYears(23)),
profile(SECOND_OTHER_MEMBER_ID, "두번째상대", "https://img.example/second.png", "매칭대", LocalDate.now().minusYears(24))
));

// when
List<ChatRoomResponse> result = chatRoomService.getMyChatRooms(MEMBER_ID);

// then
assertThat(result).extracting(ChatRoomResponse::unreadCount)
.containsExactly(2L, 3L);
ChatRoomResponse.UserSummary firstOtherUser = result.get(0).otherUser();
assertThat(firstOtherUser.memberId()).isEqualTo(OTHER_MEMBER_ID);
assertThat(firstOtherUser.nickname()).isEqualTo("첫번째상대");
assertThat(firstOtherUser.profileImageUrl()).isEqualTo("https://img.example/first.png");
assertThat(firstOtherUser.university()).isEqualTo("코매칭대");
assertThat(firstOtherUser.age()).isEqualTo(24);
then(chatMessageRepository).should().countUnreadMessagesByRoom(
argThat(conditions -> containsCondition(conditions, "room-1", firstReadAt)
&& containsCondition(conditions, "room-2", secondReadAt)),
eq(MEMBER_ID)
);
then(memberClient).should().getProfiles(List.of(OTHER_MEMBER_ID, SECOND_OTHER_MEMBER_ID));
then(chatMessageRepository).should(never()).countUnreadMessages(anyString(), any(), anyLong());
}

@Test
@DisplayName("채팅방 목록에서 차단된 상대는 프로필 조회 대상에서 제외한다")
void getMyChatRooms_excludesBlockedRoomsFromProfileLookup() {
// given
LocalDateTime firstReadAt = LocalDateTime.of(2026, 1, 1, 12, 0);
LocalDateTime blockedReadAt = LocalDateTime.of(2026, 1, 1, 13, 0);
ChatRoom visibleRoom = chatRoom("room-1", 100L, MEMBER_ID, OTHER_MEMBER_ID, firstReadAt);
ChatRoom blockedRoom = chatRoom("room-2", 101L, MEMBER_ID, SECOND_OTHER_MEMBER_ID, blockedReadAt);

given(chatRoomRepository.findMyChatRooms(eq(MEMBER_ID), any(Sort.class)))
.willReturn(List.of(visibleRoom, blockedRoom));
given(blockService.getBlockedUserIds(MEMBER_ID)).willReturn(Set.of(SECOND_OTHER_MEMBER_ID));
given(chatMessageRepository.countUnreadMessagesByRoom(anyList(), eq(MEMBER_ID)))
.willReturn(Map.of("room-1", 7L));
given(memberClient.getProfiles(anyList()))
.willReturn(List.of(
profile(OTHER_MEMBER_ID, "보이는상대", "https://img.example/visible.png", "코매칭대", LocalDate.now().minusYears(22))
));

// when
List<ChatRoomResponse> result = chatRoomService.getMyChatRooms(MEMBER_ID);

// then
assertThat(result).hasSize(1);
assertThat(result.get(0).otherUser().memberId()).isEqualTo(OTHER_MEMBER_ID);
then(memberClient).should().getProfiles(List.of(OTHER_MEMBER_ID));
}

@Test
@DisplayName("전체 안 읽은 메시지 수 조회에서 차단된 방을 제외하고 배치 합산한다")
void getTotalUnreadCount_batchesVisibleRooms() {
Expand Down Expand Up @@ -114,6 +160,22 @@ private ChatRoom chatRoom(String id, Long matchingId, Long initiatorId, Long tar
return room;
}

private ProfileResponse profile(
Long memberId,
String nickname,
String profileImageUrl,
String university,
LocalDate birthDate
) {
return ProfileResponse.builder()
.memberId(memberId)
.nickname(nickname)
.profileImageUrl(profileImageUrl)
.university(university)
.birthDate(birthDate)
.build();
}

private boolean containsCondition(List<UnreadCountCondition> conditions, String roomId, LocalDateTime lastReadAt) {
return conditions.stream()
.anyMatch(condition -> roomId.equals(condition.roomId()) && lastReadAt.equals(condition.lastReadAt()));
Expand Down