Skip to content
Merged
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 @@ -72,7 +72,7 @@ public NaverBookParseResult searchBooks(String keyword, int page, Long userId) {
.type(BOOK_SEARCH.getSearchType())
.userId(userId)
.build();
recentSearchCommandPort.save(userId,recentSearch);
recentSearchCommandPort.save(recentSearch);

return result;
}
Expand Down
2 changes: 2 additions & 0 deletions src/main/java/konkuk/thip/common/entity/BaseDomainEntity.java
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package konkuk.thip.common.entity;

import lombok.Getter;
import lombok.Setter;
import lombok.experimental.SuperBuilder;

import java.time.LocalDateTime;
Expand All @@ -9,6 +10,7 @@
@SuperBuilder
public class BaseDomainEntity {

@Setter
private LocalDateTime createdAt;
Comment on lines +13 to 14
Copy link

Choose a reason for hiding this comment

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

🛠️ Refactor suggestion

createdAt 필드 수정 가능성에 대한 신중한 검토가 필요합니다.

Base 엔티티의 createdAt 필드에 @Setter를 추가한 것은 일반적인 도메인 규칙에 어긋날 수 있습니다. 생성 시간은 보통 불변이어야 하는데, 최근 검색 기능에서 "최근성" 업데이트를 위해 수정하는 것이 적절한지 검토가 필요합니다.

대안으로 다음을 고려해보세요:

  1. 최근 검색에서는 기존 레코드를 삭제하고 새로 생성
  2. 별도의 lastSearchedAt 필드 추가
  3. RecentSearch만을 위한 별도 메서드 제공
🤖 Prompt for AI Agents
In src/main/java/konkuk/thip/common/entity/BaseDomainEntity.java around lines 13
to 14, the createdAt field currently has a @Setter which allows modification,
but createdAt should be immutable as it represents the creation timestamp.
Remove the @Setter annotation from createdAt to prevent external modification.
Instead, implement one of the suggested alternatives such as adding a separate
lastSearchedAt field or handling recent search updates by deleting and
recreating records, ensuring createdAt remains unchanged.


private LocalDateTime modifiedAt;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,7 @@ public enum ErrorCode implements ResponseCode {
* 90000 : recentSearch error
*/
INVALID_SEARCH_TYPE(HttpStatus.BAD_REQUEST, 90000,"알맞은 검색어 타입을 찾을 수 없습니다."),
RECENT_SEARCH_NOT_FOUND(HttpStatus.NOT_FOUND, 90001, "존재하지 않는 RECENT SEARCH 입니다."),

/**
* 100000 : room error
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,9 @@ public enum SwaggerResponseDescription {
USER_SIGNUP(new LinkedHashSet<>(Set.of(
ALIAS_NAME_NOT_MATCH
))),
USER_SEARCH(new LinkedHashSet<>(Set.of(
USER_NOT_FOUND
))),

// Follow
CHANGE_FOLLOW_STATE(new LinkedHashSet<>(Set.of(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@

import jakarta.persistence.*;
import konkuk.thip.common.entity.BaseJpaEntity;
import konkuk.thip.recentSearch.domain.RecentSearch;
import konkuk.thip.user.adapter.out.jpa.UserJpaEntity;
import lombok.*;

Expand All @@ -29,4 +30,10 @@ public class RecentSearchJpaEntity extends BaseJpaEntity {
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "user_id")
private UserJpaEntity userJpaEntity;

public void updateFrom(RecentSearch recentSearch) {
this.searchTerm = recentSearch.getSearchTerm();
this.type = SearchType.from(recentSearch.getType());
this.setCreatedAt(recentSearch.getCreatedAt());
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
public enum SearchType {

USER_SEARCH("사용자 검색"),
BOOK_SEARCH("책_검색");
BOOK_SEARCH("책 검색");

private final String searchType;

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Repository;

import static konkuk.thip.common.exception.code.ErrorCode.RECENT_SEARCH_NOT_FOUND;
import static konkuk.thip.common.exception.code.ErrorCode.USER_NOT_FOUND;

@Repository
Expand All @@ -23,14 +24,31 @@ public class RecentSearchCommandPersistenceAdapter implements RecentSearchComman
private final RecentSearchMapper recentSearchMapper;

@Override
public void save(Long userId,RecentSearch recentSearch) {
public void save(RecentSearch recentSearch) {

UserJpaEntity userJpaEntity = userJpaRepository.findById(userId)
UserJpaEntity userJpaEntity = userJpaRepository.findById(recentSearch.getUserId())
.orElseThrow(() -> new EntityNotFoundException(USER_NOT_FOUND));

RecentSearchJpaEntity recentSearchJpaEntity =
recentSearchMapper.toJpaEntity(recentSearch, userJpaEntity);

recentSearchJpaRepository.save(recentSearchJpaEntity);
}

@Override
public void delete(Long id) {
recentSearchJpaRepository.delete(
recentSearchJpaRepository.findById(id)
.orElseThrow(() -> new EntityNotFoundException(RECENT_SEARCH_NOT_FOUND))
);
}
Comment on lines +38 to +44
Copy link

@coderabbitai coderabbitai bot Jul 30, 2025

Choose a reason for hiding this comment

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

🛠️ Refactor suggestion

delete 메서드 구현을 개선해주세요

현재 구현에서 findByIddelete를 연쇄 호출하고 있는데, deleteById를 사용하면 더 효율적입니다.

다음과 같이 개선해주세요:

 @Override
 public void delete(Long id) {
-     recentSearchJpaRepository.delete(
-             recentSearchJpaRepository.findById(id)
-                     .orElseThrow(() -> new EntityNotFoundException(RECENT_SEARCH_NOT_FOUND))
-     );
+     if (!recentSearchJpaRepository.existsById(id)) {
+         throw new EntityNotFoundException(RECENT_SEARCH_NOT_FOUND);
+     }
+     recentSearchJpaRepository.deleteById(id);
 }
🤖 Prompt for AI Agents
In
src/main/java/konkuk/thip/recentSearch/adapter/out/persistence/RecentSearchCommandPersistenceAdapter.java
around lines 38 to 44, the delete method currently calls findById followed by
delete, which is inefficient. Replace this by directly using
recentSearchJpaRepository.deleteById(id) to improve performance and simplify the
code.

Copy link
Collaborator

Choose a reason for hiding this comment

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

오호 이런 구현 방식도 괜찮은거 같네요

Copy link
Collaborator

Choose a reason for hiding this comment

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

@coderabbitai 너가 제안한 delete 방식이 현재 구현된 방식보다 어떤 점에서 더 효율적인지 설명해줘

Copy link

Choose a reason for hiding this comment

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

@seongjunnoh 좋은 질문이네요! 제가 제안한 방식이 더 효율적인 이유를 설명드리겠습니다.

현재 구현 방식 vs 제안된 방식

현재 구현 (비효율적):

// 1단계: SELECT 쿼리로 전체 엔티티 조회
RecentSearchJpaEntity entity = recentSearchJpaRepository.findById(id)
    .orElseThrow(() -> new EntityNotFoundException(RECENT_SEARCH_NOT_FOUND));
// 2단계: DELETE 쿼리 실행
recentSearchJpaRepository.delete(entity);

제안된 구현 (효율적):

// 1단계: SELECT COUNT 쿼리로 존재 여부만 확인
if (!recentSearchJpaRepository.existsById(id)) {
    throw new EntityNotFoundException(RECENT_SEARCH_NOT_FOUND);
}
// 2단계: DELETE 쿼리 실행
recentSearchJpaRepository.deleteById(id);

효율성 차이점

  1. 메모리 사용량:

    • 현재: 전체 엔티티 객체를 메모리에 로드
    • 제안: 존재 여부(boolean)만 확인
  2. 네트워크 트래픽:

    • 현재: 모든 컬럼 데이터를 DB에서 가져옴
    • 제안: 존재 여부만 확인 (훨씬 적은 데이터)
  3. 데이터베이스 연산:

    • 현재: SELECT * FROM recent_search WHERE id = ?
    • 제안: SELECT 1 FROM recent_search WHERE id = ? LIMIT 1
  4. JPA 최적화:

    • deleteById(id)는 JPA가 내부적으로 최적화된 DELETE 쿼리를 직접 실행
    • delete(entity)는 영속성 컨텍스트에서 엔티티 관리 오버헤드 발생

특히 대용량 데이터나 빈번한 삭제 작업이 있는 환경에서 이런 최적화가 누적되면 상당한 성능 개선 효과를 가져올 수 있습니다!


✏️ Learnings added
Learnt from: seongjunnoh
PR: THIP-TextHip/THIP-Server#113
File: src/main/java/konkuk/thip/recentSearch/adapter/out/persistence/RecentSearchCommandPersistenceAdapter.java:38-44
Timestamp: 2025-07-30T14:05:04.918Z
Learning: seongjunnoh는 코드 최적화 제안에 대해 구체적인 기술적 근거와 효율성 차이를 이해하고 싶어하며, 성능 개선 방식에 대한 상세한 설명을 선호한다.

Note: Learnings are effective only in the context of similar code segments. To apply general coding guidelines, please configure review instructions. You can manage existing learnings in the app.


🧠 Learnings used
Learnt from: seongjunnoh
PR: THIP-TextHip/THIP-Server#93
File: src/main/java/konkuk/thip/room/adapter/out/persistence/RoomQueryPersistenceAdapter.java:49-114
Timestamp: 2025-07-28T16:44:31.224Z
Learning: seongjunnoh는 코드 중복 문제에 대한 리팩토링 제안을 적극적으로 수용하고 함수형 인터페이스를 활용한 해결책을 선호한다.

Learnt from: seongjunnoh
PR: THIP-TextHip/THIP-Server#112
File: src/main/java/konkuk/thip/feed/adapter/out/persistence/repository/FeedQueryRepositoryImpl.java:272-272
Timestamp: 2025-07-30T10:44:34.115Z
Learning: seongjunnoh는 피드 커서 페이지네이션에서 LocalDateTime 단일 커서 방식을 선호하며, 복합 키 기반 커서보다 구현 단순성과 성능을 우선시한다.

Copy link

Choose a reason for hiding this comment

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

@seongjunnoh 제가 제안한 delete 방식이 더 효율적인 이유를 데이터베이스 쿼리와 메모리 관점에서 설명드리겠습니다.

현재 구현 방식의 문제점

// 현재 방식
recentSearchJpaRepository.delete(
    recentSearchJpaRepository.findById(id)
        .orElseThrow(() -> new EntityNotFoundException(RECENT_SEARCH_NOT_FOUND))
);

실행되는 쿼리:

  1. SELECT * FROM recent_search WHERE id = ? - 전체 엔티티 조회
  2. DELETE FROM recent_search WHERE id = ? - 삭제 실행

제안한 방식의 장점

// 제안한 방식
if (!recentSearchJpaRepository.existsById(id)) {
    throw new EntityNotFoundException(RECENT_SEARCH_NOT_FOUND);
}
recentSearchJpaRepository.deleteById(id);

실행되는 쿼리:

  1. SELECT 1 FROM recent_search WHERE id = ? LIMIT 1 - 존재 여부만 확인
  2. DELETE FROM recent_search WHERE id = ? - 삭제 실행

구체적인 효율성 차이

1. 네트워크 트래픽 감소

  • 현재: 전체 엔티티 데이터를 DB → 애플리케이션으로 전송
  • 제안: 존재 여부(boolean)만 전송 → 데이터 전송량 대폭 감소

2. 메모리 사용량 감소

  • 현재: RecentSearchJpaEntity 객체를 메모리에 생성
  • 제안: 불필요한 객체 생성 없음 → 힙 메모리 절약

3. DB 성능 최적화

  • existsById(): DB 옵티마이저가 더 효율적인 실행 계획 수립 가능
  • 인덱스만으로 처리 가능, 실제 데이터 페이지 접근 불필요

실제 운영 환경에서는 이런 작은 최적화들이 누적되어 상당한 성능 차이를 만들어냅니다!


🧠 Learnings used
Learnt from: seongjunnoh
PR: THIP-TextHip/THIP-Server#93
File: src/main/java/konkuk/thip/room/adapter/out/persistence/RoomQueryPersistenceAdapter.java:49-114
Timestamp: 2025-07-28T16:44:31.224Z
Learning: seongjunnoh는 코드 중복 문제에 대한 리팩토링 제안을 적극적으로 수용하고 함수형 인터페이스를 활용한 해결책을 선호한다.

Learnt from: seongjunnoh
PR: THIP-TextHip/THIP-Server#112
File: src/main/java/konkuk/thip/feed/adapter/out/persistence/repository/FeedQueryRepositoryImpl.java:272-272
Timestamp: 2025-07-30T10:44:34.115Z
Learning: seongjunnoh는 피드 커서 페이지네이션에서 LocalDateTime 단일 커서 방식을 선호하며, 복합 키 기반 커서보다 구현 단순성과 성능을 우선시한다.


@Override
public void update(RecentSearch recentSearch) {
RecentSearchJpaEntity recentSearchJpaEntity = recentSearchJpaRepository.findById(recentSearch.getId())
.orElseThrow(() -> new EntityNotFoundException(RECENT_SEARCH_NOT_FOUND));

recentSearchJpaEntity.updateFrom(recentSearch);
recentSearchJpaRepository.save(recentSearchJpaEntity);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,14 +3,24 @@
import konkuk.thip.recentSearch.adapter.out.mapper.RecentSearchMapper;
import konkuk.thip.recentSearch.adapter.out.persistence.repository.RecentSearchJpaRepository;
import konkuk.thip.recentSearch.application.port.out.RecentSearchQueryPort;
import konkuk.thip.recentSearch.domain.RecentSearch;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Repository;

import java.util.Optional;

import static konkuk.thip.recentSearch.adapter.out.jpa.SearchType.USER_SEARCH;

@Repository
@RequiredArgsConstructor
public class RecentSearchQueryPersistenceAdapter implements RecentSearchQueryPort {

private final RecentSearchJpaRepository recentSearchJpaRepository;
private final RecentSearchMapper recentSearchMapper;

@Override
public Optional<RecentSearch> findRecentSearchByKeywordAndUserId(String keyword, Long userId) {
return recentSearchJpaRepository.findBySearchTermAndTypeAndUserId(keyword, USER_SEARCH, userId)
.map(recentSearchMapper::toDomainEntity);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@

import konkuk.thip.recentSearch.adapter.out.jpa.RecentSearchJpaEntity;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;

public interface RecentSearchJpaRepository extends JpaRepository<RecentSearchJpaEntity, Long> {
@Repository
public interface RecentSearchJpaRepository extends JpaRepository<RecentSearchJpaEntity, Long>, RecentSearchQueryRepository {
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
package konkuk.thip.recentSearch.adapter.out.persistence.repository;

import konkuk.thip.recentSearch.adapter.out.jpa.RecentSearchJpaEntity;
import konkuk.thip.recentSearch.adapter.out.jpa.SearchType;

import java.util.Optional;

public interface RecentSearchQueryRepository {
Optional<RecentSearchJpaEntity> findBySearchTermAndTypeAndUserId(String searchTerm, SearchType type, Long userId);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
package konkuk.thip.recentSearch.adapter.out.persistence.repository;

import com.querydsl.jpa.impl.JPAQueryFactory;
import konkuk.thip.recentSearch.adapter.out.jpa.RecentSearchJpaEntity;
import konkuk.thip.recentSearch.adapter.out.jpa.SearchType;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Repository;

import java.util.Optional;

import static konkuk.thip.recentSearch.adapter.out.jpa.QRecentSearchJpaEntity.recentSearchJpaEntity;

@Repository
@RequiredArgsConstructor
public class RecentSearchQueryRepositoryImpl implements RecentSearchQueryRepository {

private final JPAQueryFactory queryFactory;

@Override
public Optional<RecentSearchJpaEntity> findBySearchTermAndTypeAndUserId(String searchTerm, SearchType type, Long userId) {
RecentSearchJpaEntity result = queryFactory
.selectFrom(recentSearchJpaEntity)
.where(
recentSearchJpaEntity.searchTerm.eq(searchTerm),
recentSearchJpaEntity.type.eq(type),
recentSearchJpaEntity.userJpaEntity.userId.eq(userId)
)
.fetchOne();

return Optional.ofNullable(result);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -4,5 +4,8 @@
import konkuk.thip.recentSearch.domain.RecentSearch;

public interface RecentSearchCommandPort {
void save(Long userId, RecentSearch recentSearch);
void save(RecentSearch recentSearch);
void delete(Long id);

void update(RecentSearch recentSearch);
}
Original file line number Diff line number Diff line change
@@ -1,5 +1,11 @@
package konkuk.thip.recentSearch.application.port.out;

import konkuk.thip.recentSearch.domain.RecentSearch;

import java.util.Optional;

public interface RecentSearchQueryPort {

Optional<RecentSearch> findRecentSearchByKeywordAndUserId(String keyword, Long userId);

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
package konkuk.thip.recentSearch.application.service.manager;

import konkuk.thip.recentSearch.application.port.out.RecentSearchCommandPort;
import konkuk.thip.recentSearch.application.port.out.RecentSearchQueryPort;
import konkuk.thip.recentSearch.domain.RecentSearch;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;

import java.time.LocalDateTime;

@Service
@RequiredArgsConstructor
public class RecentSearchCreateManager {

private static final String USER_SEARCH_TERM = "사용자 검색";
Copy link

Choose a reason for hiding this comment

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

🛠️ Refactor suggestion

하드코딩된 상수를 enum으로 개선 권장

"사용자 검색"이 하드코딩되어 있습니다. SearchType enum을 활용하거나 별도 상수 클래스에서 관리하는 것이 좋겠습니다.

-    private static final String USER_SEARCH_TERM = "사용자 검색";
+    private static final SearchType USER_SEARCH_TYPE = SearchType.USER_SEARCH;

그리고 line 32에서도 수정:

-            RecentSearch userRecentSearch = RecentSearch.withoutId(keyword, USER_SEARCH_TERM, userId);
+            RecentSearch userRecentSearch = RecentSearch.withoutId(keyword, USER_SEARCH_TYPE.getDescription(), userId);
🤖 Prompt for AI Agents
In
src/main/java/konkuk/thip/recentSearch/application/service/manager/RecentSearchCreateManager.java
at line 15, the string "사용자 검색" is hardcoded as a constant. Refactor this by
replacing the hardcoded string with a value from the SearchType enum or a
dedicated constants class to improve maintainability and consistency. Also,
apply the same change at line 32 where this string is used.


private final RecentSearchCommandPort recentSearchCommandPort;
private final RecentSearchQueryPort recentSearchQueryPort;

public void saveRecentSearchByUser(Long userId, String keyword) {

// 동일 조건 (userId + keyword + type) 검색 기록이 이미 있는지 확인
recentSearchQueryPort.findRecentSearchByKeywordAndUserId(keyword, userId)
.ifPresentOrElse(
Comment on lines +22 to +24
Copy link
Collaborator

Choose a reason for hiding this comment

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

p3 : 왜 메서드의 인자값으로 type은 넘기지 않고, 메서드 내부에서 jpa repository 를 호출할때 USER_SEARCH 를 주입해주는 건가요?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

이 부분은 제가 SearchType을 서비스 로직에서 import하여 쓰지 않은 이유와 일맥상통합니다! 다만, 뒷 pr에서 정의한 RecentSearchType을 여기서 활용할 수 있겠네요! 한번 수정해보겠습니다.

existingRecentSearch -> {
// 이미 존재하면 createdAt만 갱신
existingRecentSearch.updateCreatedAt(LocalDateTime.now());
recentSearchCommandPort.update(existingRecentSearch);
},
Comment on lines +26 to +29
Copy link
Collaborator

Choose a reason for hiding this comment

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

p3 : 엇 createdAt이 udpate 안되지 않나요??

Copy link
Contributor Author

Choose a reason for hiding this comment

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

네넵 그걸 뒷 pr에서 발견해서 뒤쪽 pr에서 수정하였습니다! 혼란을 드렸네요,, 지송

() -> {
// 없으면 새로 저장
RecentSearch userRecentSearch = RecentSearch.withoutId(keyword, USER_SEARCH_TERM, userId);
Copy link
Collaborator

Choose a reason for hiding this comment

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

p3 : 지금처럼 RecentSearchCreateManager에서 상수로 선언한 String 값을 주입해주는 구조보다는 유지보수를 위해

RecentSearch를 생성할 때 SearchType.USER_SEARCH.getSearchType() 을 주입

하는 방식으로 수정하는건 어떤가요?

현재 User 등 여러 도메인 엔티티에서 위와 같은 방식으로 코드가 작성되어 있긴 합니다!

public class User extends BaseDomainEntity {

    private Long id;

    private String nickname;

    private String userRole;

    private String oauth2Id;

    private Integer followerCount; // 팔로워 수

    private Alias alias;

    public static User withoutId(String nickname, String userRole, String oauth2Id, Alias alias) {
        return User.builder()
                .id(null)
                .nickname(nickname)
                .userRole(userRole)
                .oauth2Id(oauth2Id)
                .followerCount(0)
                .alias(alias)
                .build();
    }
}

public Long signup(UserSignupCommand command) {
        Alias alias = Alias.from(command.aliasName());
        User user = User.withoutId(
                command.nickname(), USER.getType(), command.oauth2Id(), alias
        );

        return userCommandPort.save(user);
    }

Copy link
Contributor Author

Choose a reason for hiding this comment

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

사실 처음에 이렇게 고려하려고 했습니다! 다만, SearchType은 저희가 adapter.out 계층에서 사용중인 Enum 클래스이기 때문에 해당 클래스를 서비스가 import하고 있는 것은 적절하지 않다고 판단했습니다!

뒷 pr 리뷰하시면 보실 수 있겠지만, Domain 차원에서 param과 SearchType.searchType들을 매핑지어서 활용하도록 했습니다!!

Copy link
Collaborator

Choose a reason for hiding this comment

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

뒷 pr에 관련해서 코멘트 달았습니다!

recentSearchCommandPort.save(userRecentSearch);
}
);
}
Comment on lines +20 to +36
Copy link

Choose a reason for hiding this comment

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

🛠️ Refactor suggestion

트랜잭션 관리 추가 고려

메서드에서 조회 후 저장/업데이트 작업을 수행하고 있습니다. 데이터 일관성을 위해 @Transactional 어노테이션 추가를 고려해보세요.

+    @Transactional
     public void saveRecentSearchByUser(Long userId, String keyword) {
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
public void saveRecentSearchByUser(Long userId, String keyword) {
// 동일 조건 (userId + keyword + type) 검색 기록이 이미 있는지 확인
recentSearchQueryPort.findRecentSearchByKeywordAndUserId(keyword, userId)
.ifPresentOrElse(
existingRecentSearch -> {
// 이미 존재하면 createdAt만 갱신
existingRecentSearch.updateCreatedAt(LocalDateTime.now());
recentSearchCommandPort.update(existingRecentSearch);
},
() -> {
// 없으면 새로 저장
RecentSearch userRecentSearch = RecentSearch.withoutId(keyword, USER_SEARCH_TERM, userId);
recentSearchCommandPort.save(userRecentSearch);
}
);
}
@Transactional
public void saveRecentSearchByUser(Long userId, String keyword) {
// 동일 조건 (userId + keyword + type) 검색 기록이 이미 있는지 확인
recentSearchQueryPort.findRecentSearchByKeywordAndUserId(keyword, userId)
.ifPresentOrElse(
existingRecentSearch -> {
// 이미 존재하면 createdAt만 갱신
existingRecentSearch.updateCreatedAt(LocalDateTime.now());
recentSearchCommandPort.update(existingRecentSearch);
},
() -> {
// 없으면 새로 저장
RecentSearch userRecentSearch = RecentSearch.withoutId(keyword, USER_SEARCH_TERM, userId);
recentSearchCommandPort.save(userRecentSearch);
}
);
}
🤖 Prompt for AI Agents
In
src/main/java/konkuk/thip/recentSearch/application/service/manager/RecentSearchCreateManager.java
around lines 20 to 36, the method performs read and write operations that should
be atomic to maintain data consistency. Add the @Transactional annotation to the
saveRecentSearchByUser method or the enclosing class to ensure the entire
operation runs within a transaction boundary, preventing partial updates or
inconsistent state.

}
14 changes: 14 additions & 0 deletions src/main/java/konkuk/thip/recentSearch/domain/RecentSearch.java
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@
import lombok.Getter;
import lombok.experimental.SuperBuilder;

import java.time.LocalDateTime;

@Getter
@SuperBuilder
public class RecentSearch extends BaseDomainEntity {
Expand All @@ -15,4 +17,16 @@ public class RecentSearch extends BaseDomainEntity {
private String type;

private Long userId;

public static RecentSearch withoutId(String searchTerm, String type, Long userId) {
return RecentSearch.builder()
.searchTerm(searchTerm)
.type(type)
.userId(userId)
.build();
}

public void updateCreatedAt(LocalDateTime localDateTime) {
this.setCreatedAt(localDateTime);
}
Comment on lines +29 to +31
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

가변성 설계에 대한 검토가 필요합니다.

updateCreatedAt 메서드가 setCreatedAt을 호출하여 도메인 객체의 상태를 변경하고 있습니다. 이는 BaseDomainEntity가 가변 상태를 허용하도록 수정되었음을 의미합니다. 도메인 객체의 불변성 원칙과 현재 설계 선택에 대한 검토가 필요합니다.

다음 스크립트로 BaseDomainEntity의 변경사항을 확인해 주세요:


🏁 Script executed:

#!/bin/bash
# Description: BaseDomainEntity의 setCreatedAt 메서드 존재 여부와 가변성 설계 확인

# BaseDomainEntity 클래스에서 setCreatedAt 메서드 찾기
ast-grep --pattern $'class BaseDomainEntity {
  $$$
  setCreatedAt($_) {
    $$$
  }
  $$$
}'

# 또는 createdAt 필드와 관련된 setter 메서드들 확인
rg -A 5 -B 5 "setCreatedAt|createdAt.*=" src/main/java/konkuk/thip/common/entity/

Length of output: 950


도메인 객체의 가변성 (createdAt) 재검토 필요
RecentSearch.updateCreatedAt 메서드가 BaseJpaEntity의 프로텍티드 setter를 호출하여 createdAt을 변경하고 있습니다.

  • 확인된 setter 위치
    • 파일: src/main/java/konkuk/thip/common/entity/BaseJpaEntity.java
    • 메서드: protected void setCreatedAt(LocalDateTime createdAt) { this.createdAt = createdAt; }

도메인 레벨에서 createdAt 변경을 허용할지, 불변성(immutability) 전략을 다시 검토해 주세요.

  • 불변성을 유지하려면 setter 및 updateCreatedAt 제거 또는 별도 빌더/팩토리 패턴, 도메인 이벤트 등 대안 적용을 제안합니다.
🤖 Prompt for AI Agents
In src/main/java/konkuk/thip/recentSearch/domain/RecentSearch.java around lines
29 to 31, the updateCreatedAt method modifies the createdAt field via a
protected setter in the base entity, which breaks domain immutability. To fix
this, remove the updateCreatedAt method and avoid exposing any setter for
createdAt. Instead, ensure createdAt is set only once during entity creation,
possibly by using a constructor, builder, or factory method, and consider domain
events if updates are needed without breaking immutability.

}
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,10 @@
import jakarta.validation.Valid;
import jakarta.validation.constraints.Max;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotBlank;
import konkuk.thip.common.dto.BaseResponse;
import konkuk.thip.common.security.annotation.UserId;
import konkuk.thip.user.adapter.in.web.response.*;
import konkuk.thip.common.swagger.annotation.ExceptionDescription;
import konkuk.thip.user.adapter.in.web.request.UserVerifyNicknameRequest;
import konkuk.thip.user.adapter.in.web.response.UserFollowersResponse;
Expand All @@ -18,13 +20,22 @@
import konkuk.thip.user.application.port.in.UserGetFollowUsecase;
import konkuk.thip.user.application.port.in.UserVerifyNicknameUseCase;
import konkuk.thip.user.application.port.in.UserIsFollowingUsecase;
import konkuk.thip.user.application.port.in.UserSearchUsecase;
import konkuk.thip.user.application.port.in.UserViewAliasChoiceUseCase;
import konkuk.thip.user.application.port.in.dto.UserSearchQuery;
import lombok.RequiredArgsConstructor;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.bind.annotation.*;

import static konkuk.thip.common.swagger.SwaggerResponseDescription.GET_USER_FOLLOW;
import static konkuk.thip.common.swagger.SwaggerResponseDescription.USER_SEARCH;

@Tag(name = "User Query API", description = "사용자가 주체가 되는 조회")
@Validated
@RestController
@RequiredArgsConstructor
public class UserQueryController {
Expand All @@ -33,6 +44,7 @@ public class UserQueryController {
private final UserGetFollowUsecase userGetFollowUsecase;
private final UserIsFollowingUsecase userIsFollowingUsecase;
private final UserVerifyNicknameUseCase userVerifyNicknameUseCase;
private final UserSearchUsecase userSearchUsecase;

@Operation(
summary = "닉네임 중복 확인",
Expand Down Expand Up @@ -94,4 +106,18 @@ public BaseResponse<UserIsFollowingResponse> checkIsFollowing(
@Parameter(description = "팔로우 여부를 확인할 대상 사용자 ID") @PathVariable final Long targetUserId) {
return BaseResponse.ok(UserIsFollowingResponse.of(userIsFollowingUsecase.isFollowing(userId, targetUserId)));
}


@Operation(
summary = "사용자 검색",
description = "닉네임을 기준으로 사용자를 검색합니다. 정확도순 정렬을 지원합니다."
)
@ExceptionDescription(USER_SEARCH)
@GetMapping("/users")
public BaseResponse<UserSearchResponse> showSearchUsers(
@Parameter(description = "검색어", example = "thip") @RequestParam @NotBlank(message = "검색어는 필수입니다.") final String keyword,
@Parameter(description = "단일 검색 결과 페이지 크기 (1~30) / default : 30", example = "30") @RequestParam(required = false, defaultValue = "30") @Min(1) @Max(30) final Integer size,
@Parameter(hidden = true) @UserId final Long userId) {
return BaseResponse.ok(userSearchUsecase.searchUsers(UserSearchQuery.of(keyword, userId, size)));
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -6,16 +6,17 @@

@Builder
public record UserFollowersResponse(
List<Follower> followers,
List<FollowerDto> followers,
String nextCursor,
boolean isLast
) {
@Builder
public record Follower(
public record FollowerDto(
Long userId,
String nickname,
String profileImageUrl,
String aliasName,
String aliasColor,
Integer followerCount
){

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,16 +6,17 @@

@Builder
public record UserFollowingResponse(
List<Following> followings,
List<FollowingDto> followings,
String nextCursor,
boolean isLast
) {
@Builder
public record Following(
public record FollowingDto(
Long userId,
String nickname,
String profileImageUrl,
String aliasName
String aliasName,
String aliasColor
){
}

Expand Down
Loading