Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
f3f704d
feat: 멘토 단일 조회 응답 dto 생성
nayonsoso Jul 2, 2025
21e9a4e
feat: 멘토, 멘토링, 채널 레포지토리 생성
nayonsoso Jul 2, 2025
b6107e8
feat: 멘토 단일 조회 서비스 함수 생성
nayonsoso Jul 2, 2025
73ada9c
feat: 멘토 단일 조회 컨트롤러 생성
nayonsoso Jul 2, 2025
3f98708
test: 멘토 관련 픽스처 생성
whqtker Jul 2, 2025
60553b3
refactor: channel 연관관계 편의 메서드 추가
nayonsoso Jul 3, 2025
b2f6e35
test: channel 픽스쳐 생성
nayonsoso Jul 3, 2025
1864b0a
test: 멘토 단일 조회 테스트 코드 작성
nayonsoso Jul 2, 2025
236c666
feat: 멘토 미리보기 목록 조회 dto 생성
nayonsoso Jul 2, 2025
4717e7a
feat: 멘토 미리보기 Batch 조회를 위한 레포지토리 생성
nayonsoso Jul 2, 2025
7c8419b
feat: 멘토 미리보기 목록 서비스 함수 생성
nayonsoso Jul 2, 2025
3d83350
feat: 멘토 미리보기 목록 컨트롤러 생성
nayonsoso Jul 2, 2025
c5c3761
test: 멘토 미리보기 목록 조회 테스트 코드 작성
nayonsoso Jul 3, 2025
dec6e03
test: 멘토 배치 조회 레포지토리 테스트 코드 작성
nayonsoso Jul 3, 2025
4487fbf
style: 개행 삭제
nayonsoso Jul 7, 2025
a3f5950
refactor: Page가 아니라 Slice를 반환받도록
nayonsoso Jul 7, 2025
fcc9244
refactor: Channel N+1 문제 해결
nayonsoso Jul 7, 2025
e40c943
refactor: SliceResponse로 대체
nayonsoso Jul 7, 2025
5541188
refactor: 멘토 목록 조회 정렬 정책 구체화
nayonsoso Jul 7, 2025
91cef41
feat: 채널 응답을 sequence 오름차순으로 정렬하는 기능 구현
nayonsoso Jul 7, 2025
8095d36
refactor: SliceResponse 정적 팩터리 메서드 사용하도록
nayonsoso Jul 8, 2025
2836715
refactor: 자연스럽게 읽히도록 파라미터 순서 변경
nayonsoso Jul 8, 2025
f917a0b
refactor: 함수 이름이 의미를 드러내도록 이름 변경
nayonsoso Jul 8, 2025
d906c7d
refactor: JPA 표준 따르도록 함수이름 변경
nayonsoso Jul 8, 2025
58fd475
refactor: 멘토 객체에서 채널의 순서를 정렬해 가지고 있도록
nayonsoso Jul 8, 2025
3c6acb4
chore: merge to develop branch
nayonsoso Jul 9, 2025
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
@@ -0,0 +1,21 @@
package com.example.solidconnection.common.dto;

import org.springframework.data.domain.Slice;

import java.util.List;

public record SliceResponse<T>(
List<T> content,
int nextPageNumber
) {

private static final int NO_NEXT_PAGE = -1;
private static final int BASE_NUMBER = 1; // 1-based

public static <T, R> SliceResponse<R> of(List<R> content, Slice<T> slice) {
int nextPageNumber = slice.hasNext()
? slice.getNumber() + BASE_NUMBER + 1
Copy link
Contributor

Choose a reason for hiding this comment

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

이렇게 표기하니 더 가독성이 좋은 거 같네요!

: NO_NEXT_PAGE;
return new SliceResponse<>(content, nextPageNumber);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ public enum ErrorCode {
GPA_SCORE_NOT_FOUND(HttpStatus.NOT_FOUND.value(), "존재하지 않는 학점입니다."),
LANGUAGE_TEST_SCORE_NOT_FOUND(HttpStatus.NOT_FOUND.value(), "존재하지 않는 어학성적입니다."),
NEWS_NOT_FOUND(HttpStatus.NOT_FOUND.value(), "존재하지 않는 소식지입니다."),
MENTOR_NOT_FOUND(HttpStatus.NOT_FOUND.value(), "존재하지 않는 멘토입니다."),

// auth
USER_ALREADY_SIGN_OUT(HttpStatus.UNAUTHORIZED.value(), "로그아웃 되었습니다."),
Expand Down Expand Up @@ -106,7 +107,6 @@ public enum ErrorCode {

// mentor
ALREADY_MENTOR(HttpStatus.BAD_REQUEST.value(), "이미 멘토로 등록된 사용자입니다."),
MENTOR_NOT_FOUND(HttpStatus.NOT_FOUND.value(), "해당 사용자는 멘토로 등록되어 있지 않습니다."),
MENTORING_NOT_FOUND(HttpStatus.NOT_FOUND.value(), "해당 멘토링 신청을 찾을 수 없습니다."),
UNAUTHORIZED_MENTORING(HttpStatus.FORBIDDEN.value(), "멘토링 권한이 없습니다."),
MENTORING_ALREADY_CONFIRMED(HttpStatus.BAD_REQUEST.value(), "이미 승인 또는 거절된 멘토링입니다."),
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
package com.example.solidconnection.mentor.controller;

import com.example.solidconnection.common.dto.SliceResponse;
import com.example.solidconnection.common.resolver.AuthorizedUser;
import com.example.solidconnection.mentor.dto.MentorDetailResponse;
import com.example.solidconnection.mentor.dto.MentorPreviewResponse;
import com.example.solidconnection.mentor.service.MentorQueryService;
import com.example.solidconnection.siteuser.domain.SiteUser;
import lombok.RequiredArgsConstructor;
import org.springframework.data.domain.Pageable;
import org.springframework.data.domain.Sort;
import org.springframework.data.web.PageableDefault;
import org.springframework.data.web.SortDefault;
import org.springframework.data.web.SortDefault.SortDefaults;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;

import static org.springframework.data.domain.Sort.Direction.DESC;

@RequiredArgsConstructor
@RequestMapping("/mentors")
@RestController
public class MentorController {

private final MentorQueryService mentorQueryService;

@GetMapping("/{mentor-id}")
public ResponseEntity<MentorDetailResponse> getMentorDetails(
@AuthorizedUser SiteUser siteUser,
@PathVariable("mentor-id") Long mentorId
) {
MentorDetailResponse response = mentorQueryService.getMentorDetails(mentorId, siteUser);
return ResponseEntity.ok(response);
}

@GetMapping
public ResponseEntity<SliceResponse<MentorPreviewResponse>> getMentorPreviews(
@AuthorizedUser SiteUser siteUser,
@RequestParam("region") String region,

@PageableDefault(size = 3, sort = "menteeCount", direction = DESC)
@SortDefaults({
@SortDefault(sort = "menteeCount", direction = Sort.Direction.DESC),
@SortDefault(sort = "id", direction = Sort.Direction.ASC)
Comment on lines +45 to +48
Copy link
Contributor

Choose a reason for hiding this comment

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

Repository 쪽에서 말고 여기서 정렬조건을 구현하신 이유가 혹시 있나요? 성능상 차이는 없지만 궁금해서 여쭤봅니다 ㅎㅎ

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

이렇게 하면 고정된 정렬 기준이 아니라, 프론트가 요청하는 정렬도 적용할 수 있어서요!
더 유연한 api를 제공한다고 생각했습니다~

})
Pageable pageable
) {
SliceResponse<MentorPreviewResponse> response = mentorQueryService.getMentorPreviews(region, siteUser, pageable);
return ResponseEntity.ok(response);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -44,4 +44,8 @@ public class Channel {

@ManyToOne(fetch = FetchType.LAZY)
private Mentor mentor;

public void updateMentor(Mentor mentor) {
this.mentor = mentor;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,12 @@
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.OneToMany;
import jakarta.persistence.OrderBy;
import lombok.AccessLevel;
import lombok.AllArgsConstructor;
import lombok.Getter;
import lombok.NoArgsConstructor;
import org.hibernate.annotations.BatchSize;

import java.util.ArrayList;
import java.util.List;
Expand Down Expand Up @@ -43,6 +45,8 @@ public class Mentor {
@Column
private long universityId;

@BatchSize(size = 10)
@OrderBy("sequence ASC")
@OneToMany(mappedBy = "mentor", cascade = CascadeType.ALL, orphanRemoval = true)
private List<Channel> channels = new ArrayList<>();

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
package com.example.solidconnection.mentor.dto;

import com.example.solidconnection.mentor.domain.Channel;
import com.example.solidconnection.mentor.domain.ChannelType;

public record ChannelResponse(
ChannelType type,
String url
) {

public static ChannelResponse from(Channel channel) {
return new ChannelResponse(
channel.getType(),
channel.getUrl()
);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
package com.example.solidconnection.mentor.dto;

import com.example.solidconnection.mentor.domain.Mentor;
import com.example.solidconnection.siteuser.domain.ExchangeStatus;
import com.example.solidconnection.siteuser.domain.SiteUser;

import java.util.List;

public record MentorDetailResponse(
long id,
String nickname,
String profileImageUrl,
ExchangeStatus exchangeStatus,
String country,
String universityName,
int menteeCount,
boolean hasBadge,
String introduction,
List<ChannelResponse> channels,
String passTip,
boolean isApplied
) {

public static MentorDetailResponse of(Mentor mentor, SiteUser mentorUser, boolean isApplied) {
return new MentorDetailResponse(
mentor.getId(),
mentorUser.getNickname(),
mentorUser.getProfileImageUrl(),
mentorUser.getExchangeStatus(),
"국가", // todo: 교환학생 기록이 인증되면 추가
"대학 이름", // todo: 교환학생 기록이 인증되면 추가
mentor.getMenteeCount(),
mentor.isHasBadge(),
mentor.getIntroduction(),
mentor.getChannels().stream().map(ChannelResponse::from).toList(),
mentor.getPassTip(),
isApplied
);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
package com.example.solidconnection.mentor.dto;

import com.example.solidconnection.mentor.domain.Mentor;
import com.example.solidconnection.siteuser.domain.ExchangeStatus;
import com.example.solidconnection.siteuser.domain.SiteUser;

import java.util.List;

public record MentorPreviewResponse(
long id,
String nickname,
String profileImageUrl,
ExchangeStatus exchangeStatus,
String country,
String universityName,
int menteeCount,
boolean hasBadge,
String introduction,
List<ChannelResponse> channels,
boolean isApplied
) {

public static MentorPreviewResponse of(Mentor mentor, SiteUser mentorUser, boolean isApplied) {
return new MentorPreviewResponse(
mentor.getId(),
mentorUser.getNickname(),
mentorUser.getProfileImageUrl(),
mentorUser.getExchangeStatus(),
"국가", // todo: 교환학생 기록이 인증되면 추가
"대학 이름", // todo: 교환학생 기록이 인증되면 추가
Comment on lines +29 to +30
Copy link

Choose a reason for hiding this comment

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

💡 Verification agent

🧩 Analysis chain

1. TODO 주석 처리 계획을 명확히 해주세요

현재 countryuniversityName 필드가 하드코딩되어 있습니다. 교환학생 인증 기능 구현 전까지 임시 값으로 사용하는 것으로 보이는데, 이런 임시 값들이 프로덕션에 노출되지 않도록 주의가 필요합니다.

다음 사항들을 확인해 보시겠어요?
1. 현재 API 응답에서 이 필드들이 어떻게 표시되는지
2. 프론트엔드에서 이 값들을 어떻게 처리하는지
3. 교환학생 인증 기능의 예상 완료 시점


🏁 Script executed:

#!/bin/bash
# 하드코딩된 값들이 다른 곳에도 있는지 확인
rg -A 2 -B 2 '"국가"'
rg -A 2 -B 2 '"대학 이름"'

Length of output: 2570


임시값 SOS! TODO 하드코딩 처리 계획을 명확히 해주세요

작은 경고등이 반짝하며 임시값들이 프로덕션에 숨어들 준비를 하고 있습니다.

  1. 하드코딩된 임시값 위치를 확인합니다.
    • MentorPreviewResponse와 MentorDetailResponse에서 "국가"와 "대학 이름"에 동일한 TODO 주석이 사용 중입니다.
  2. API 및 프론트엔드 처리 방식을 점검합니다.
    • API 응답에 임시값이 어떻게 노출되고, 프론트엔드가 이를 어떻게 렌더링하는지 확인이 필요합니다.
  3. 교환학생 인증 기능 완료 시점을 공유합니다.
    • TODO 주석에 관련 이슈 번호를 링크해 두어 추적이 용이하도록 관리하면 좋겠습니다.

추가 제안 사항입니다.
- 프로덕션 노출 방지를 위해 Optional 처리나 Null 필드 숨김 방식을 고려해 주세요.
- 기능 완료 전까지 TODO 주석을 이슈와 연동해 관리해 주시기 바랍니다.

🤖 Prompt for AI Agents
In
src/main/java/com/example/solidconnection/mentor/dto/MentorPreviewResponse.java
around lines 29 to 30, the hardcoded temporary values "국가" and "대학 이름" with TODO
comments need clearer handling. Replace these hardcoded strings with Optional
fields or nullable values to prevent exposing placeholders in production API
responses. Link the TODO comments to the relevant issue number for tracking, and
verify how the frontend renders these fields to ensure no placeholder data is
shown before the exchange student certification feature is complete.

mentor.getMenteeCount(),
mentor.isHasBadge(),
mentor.getIntroduction(),
mentor.getChannels().stream().map(ChannelResponse::from).toList(),
Copy link
Contributor

Choose a reason for hiding this comment

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

이거 채널이 sequence 순서로 정렬하는 로직이 어디있나요?

Copy link
Collaborator Author

@nayonsoso nayonsoso Jul 7, 2025

Choose a reason for hiding this comment

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

그런 로직도 필요하겠네요..!

반영했습니다 🫡 91cef41

isApplied
);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
package com.example.solidconnection.mentor.repository;

import com.example.solidconnection.mentor.domain.Channel;
import org.springframework.data.jpa.repository.JpaRepository;

public interface ChannelRepository extends JpaRepository<Channel, Long> {
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
package com.example.solidconnection.mentor.repository;

import com.example.solidconnection.common.exception.CustomException;
import com.example.solidconnection.mentor.domain.Mentor;
import com.example.solidconnection.mentor.domain.Mentoring;
import com.example.solidconnection.siteuser.domain.SiteUser;
import com.example.solidconnection.siteuser.repository.SiteUserRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Repository;

import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.function.Function;
import java.util.stream.Collectors;

import static com.example.solidconnection.common.exception.ErrorCode.DATA_INTEGRITY_VIOLATION;

@Repository
@RequiredArgsConstructor
public class MentorBatchQueryRepository { // 연관관계가 설정되지 않은 엔티티들을 N+1 없이 하나의 쿼리로 조회
Copy link
Contributor

Choose a reason for hiding this comment

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

오 이렇게 N+1 해결하는 것도 정말 좋은 거 같습니다! 함수명을 잘지어주시니 아주 잘 이해가 되네요! stream은 제가 공부를 조금 더 해보겠습니다..

추가로 채널같은 경우는 N+1이 그대로 발생하는데 최대 2개인가 그러니 별 상관 없겠죠?

Copy link
Collaborator Author

@nayonsoso nayonsoso Jul 7, 2025

Choose a reason for hiding this comment

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

추가로 채널같은 경우는 N+1이 그대로 발생하는데 최대 2개인가 그러니 별 상관 없겠죠?

그래도 N+1 방지하는게 좋을 것 같습니다. 제가 놓친 부분이네요😅

짚어주셔서 감사합니다~! 반영했습니다. fcc9244


private final SiteUserRepository siteUserRepository;
private final MentoringRepository mentoringRepository;

public Map<Long, SiteUser> getMentorIdToSiteUserMap(List<Mentor> mentors) {
List<Long> mentorUserIds = mentors.stream().map(Mentor::getSiteUserId).toList();
List<SiteUser> mentorUsers = siteUserRepository.findAllById(mentorUserIds);
Map<Long, SiteUser> mentorUserIdToSiteUserMap = mentorUsers.stream()
.collect(Collectors.toMap(SiteUser::getId, Function.identity()));

return mentors.stream().collect(Collectors.toMap(
Mentor::getId,
mentor -> {
SiteUser mentorUser = mentorUserIdToSiteUserMap.get(mentor.getSiteUserId());
if (mentorUser == null) { // site_user.id == mentor.site_user_id 에 해당하는게 없으면 정합성 문제가 발생한 것
throw new CustomException(DATA_INTEGRITY_VIOLATION, "mentor에 해당하는 siteUser 존재하지 않음");
}
return mentorUser;
}
));
}

public Map<Long, Boolean> getMentorIdToIsApplied(List<Mentor> mentors, long currentUserId) {
List<Long> mentorIds = mentors.stream().map(Mentor::getId).toList();
List<Mentoring> appliedMentorings = mentoringRepository.findAllByMentorIdInAndMenteeId(mentorIds, currentUserId);
Set<Long> appliedMentorIds = appliedMentorings.stream()
.map(Mentoring::getMentorId)
.collect(Collectors.toSet());

return mentors.stream().collect(Collectors.toMap(
Mentor::getId,
mentor -> appliedMentorIds.contains(mentor.getId())
));
}
}
Original file line number Diff line number Diff line change
@@ -1,13 +1,17 @@
package com.example.solidconnection.mentor.repository;

import com.example.solidconnection.mentor.domain.Mentor;
import org.springframework.data.domain.Pageable;
import org.springframework.data.domain.Slice;
import org.springframework.data.jpa.repository.JpaRepository;

import java.util.Optional;

public interface MentorRepository extends JpaRepository<Mentor, Long> {

boolean existsBySiteUserId(long siteUserId);

Optional<Mentor> findBySiteUserId(long siteUserId);

boolean existsBySiteUserId(long siteUserId);
Slice<Mentor> findAllBy(Pageable pageable);
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,22 @@

import com.example.solidconnection.mentor.domain.Mentoring;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;

import java.util.List;

public interface MentoringRepository extends JpaRepository<Mentoring, Long> {

int countByMentorIdAndCheckedAtIsNull(long mentorId);

boolean existsByMentorIdAndMenteeId(long mentorId, long menteeId);

List<Mentoring> findAllByMentorId(long mentorId);

int countByMentorIdAndCheckedAtIsNull(long mentorId);
@Query("""
SELECT m FROM Mentoring m
WHERE m.mentorId IN :mentorIds AND m.menteeId = :menteeId
""")
List<Mentoring> findAllByMentorIdInAndMenteeId(@Param("mentorIds") List<Long> mentorIds, @Param("menteeId") long menteeId);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
package com.example.solidconnection.mentor.service;

import com.example.solidconnection.common.dto.SliceResponse;
import com.example.solidconnection.common.exception.CustomException;
import com.example.solidconnection.mentor.domain.Mentor;
import com.example.solidconnection.mentor.dto.MentorDetailResponse;
import com.example.solidconnection.mentor.dto.MentorPreviewResponse;
import com.example.solidconnection.mentor.repository.MentorBatchQueryRepository;
import com.example.solidconnection.mentor.repository.MentorRepository;
import com.example.solidconnection.mentor.repository.MentoringRepository;
import com.example.solidconnection.siteuser.domain.SiteUser;
import com.example.solidconnection.siteuser.repository.SiteUserRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.data.domain.Pageable;
import org.springframework.data.domain.Slice;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import java.util.ArrayList;
import java.util.List;
import java.util.Map;

import static com.example.solidconnection.common.exception.ErrorCode.MENTOR_NOT_FOUND;

@RequiredArgsConstructor
@Service
public class MentorQueryService {

private final MentorRepository mentorRepository;
private final MentoringRepository mentoringRepository;
private final SiteUserRepository siteUserRepository;
private final MentorBatchQueryRepository mentorBatchQueryRepository;

@Transactional(readOnly = true)
public MentorDetailResponse getMentorDetails(long mentorId, SiteUser currentUser) {
Mentor mentor = mentorRepository.findById(mentorId)
.orElseThrow(() -> new CustomException(MENTOR_NOT_FOUND));
SiteUser mentorUser = siteUserRepository.findById(mentor.getSiteUserId())
.orElseThrow(() -> new CustomException(MENTOR_NOT_FOUND));
boolean isApplied = mentoringRepository.existsByMentorIdAndMenteeId(mentorId, currentUser.getId());

return MentorDetailResponse.of(mentor, mentorUser, isApplied);
}

@Transactional(readOnly = true)
public SliceResponse<MentorPreviewResponse> getMentorPreviews(String region, SiteUser siteUser, Pageable pageable) { // todo: 멘토의 '인증' 작업 후 region 필터링 추가
Slice<Mentor> mentorSlice = mentorRepository.findAllBy(pageable);
List<Mentor> mentors = mentorSlice.toList();
List<MentorPreviewResponse> content = getMentorPreviewResponses(mentors, siteUser);

return SliceResponse.of(content, mentorSlice);
}

private List<MentorPreviewResponse> getMentorPreviewResponses(List<Mentor> mentors, SiteUser siteUser) {
Map<Long, SiteUser> mentorIdToSiteUser = mentorBatchQueryRepository.getMentorIdToSiteUserMap(mentors);
Map<Long, Boolean> mentorIdToIsApplied = mentorBatchQueryRepository.getMentorIdToIsApplied(mentors, siteUser.getId());

List<MentorPreviewResponse> mentorPreviews = new ArrayList<>();
for (Mentor mentor : mentors) {
SiteUser mentorUser = mentorIdToSiteUser.get(mentor.getId());
boolean isApplied = mentorIdToIsApplied.get(mentor.getId());
MentorPreviewResponse response = MentorPreviewResponse.of(mentor, mentorUser, isApplied);
mentorPreviews.add(response);
}
return mentorPreviews;
}
}
Loading
Loading