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 @@ -9,6 +9,7 @@
import ku_rum.backend.domain.notice.domain.repository.NoticeRepository;
import ku_rum.backend.domain.notice.dto.response.NoticeDetailResponse;
import ku_rum.backend.domain.notice.dto.response.NoticeResponse;
import ku_rum.backend.domain.search.application.RecentSearchService;
import ku_rum.backend.domain.user.application.UserService;
import ku_rum.backend.domain.user.domain.User;
import ku_rum.backend.global.exception.global.GlobalException;
Expand Down Expand Up @@ -36,6 +37,7 @@ public class NoticeService {
private final NoticeDetailRepository noticeDetailRepository;
private final BookmarkRepository bookmarkRepository;
private final UserService userService;
private final RecentSearchService recentSearchService;

public Page<NoticeResponse> findByCategory(Long categoryId, Pageable pageable) {
return noticeRepository.findByCategoryIdAndPublishStatus(categoryId, PublishStatus.SUCCESS_CRAWLING, pageable)
Expand Down Expand Up @@ -84,6 +86,8 @@ public List<NoticeResponse> findPrimaryNotices() {

// 키워드 검색 로직
public Page<NoticeResponse> searchByKeyword(String keyword, Pageable pageable) {
recentSearchService.save(keyword);

return noticeRepository.searchByKeyword(keyword, PublishStatus.SUCCESS_CRAWLING, pageable)
.map(NoticeResponse::from);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -53,4 +53,5 @@ public Page<NoticeResponse> searchNoticesByKeyword(
) {
return noticeService.searchByKeyword(keyword, pageable);
}

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
package ku_rum.backend.domain.search.application;

import ku_rum.backend.domain.search.domain.RecentSearch;
import ku_rum.backend.domain.search.domain.repository.RecentSearchRepository;
import ku_rum.backend.domain.user.application.UserService;
import ku_rum.backend.domain.user.domain.User;
import lombok.RequiredArgsConstructor;
import org.springframework.dao.DataIntegrityViolationException;
import org.springframework.data.domain.PageRequest;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import java.time.LocalDateTime;
import java.util.List;

@Service
@RequiredArgsConstructor
public class RecentSearchService {

private static final int MAX_RECENT_SEARCH = 20;

private final RecentSearchRepository repository;
private final UserService userService;

@Transactional
public void save(String keyword) {
String normalized = normalizeKeyword(keyword);
if (normalized == null) {
return;
}

User user = userService.getUser();
LocalDateTime now = LocalDateTime.now();

try {
repository.findByUserIdAndKeyword(user.getId(), normalized)
.ifPresentOrElse(
rs -> rs.touch(now),
() -> repository.save(
new RecentSearch(user.getId(), normalized, now)
)
);
} catch (DataIntegrityViolationException e) {
repository.findByUserIdAndKeyword(user.getId(), normalized)
.ifPresent(rs -> rs.touch(now));
}
Comment on lines +43 to +46
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

DataIntegrityViolationException 발생 시 트랜잭션 롤백 문제

DataIntegrityViolationException이 발생하면 Spring은 트랜잭션을 롤백 전용으로 마킹합니다. catch 블록에서 touch() 호출이 정상적으로 커밋되지 않을 수 있습니다.

경쟁 조건을 안전하게 처리하려면 @Retryable을 사용하거나, 새 트랜잭션에서 재시도하는 것을 고려해 주세요.

🔎 대안 1: REQUIRES_NEW 전파 사용
@Transactional
public void save(String keyword) {
    String normalized = normalizeKeyword(keyword);
    if (normalized == null) {
        return;
    }

    User user = userService.getUser();
    LocalDateTime now = LocalDateTime.now();

    repository.findByUserIdAndKeyword(user.getId(), normalized)
            .ifPresentOrElse(
                    rs -> rs.touch(now),
                    () -> insertNewKeyword(user.getId(), normalized, now)
            );

    deleteRecentKeywordsOversize(user);
}

@Transactional(propagation = Propagation.REQUIRES_NEW)
public void insertNewKeyword(Long userId, String keyword, LocalDateTime now) {
    try {
        repository.save(new RecentSearch(userId, keyword, now));
    } catch (DataIntegrityViolationException e) {
        repository.findByUserIdAndKeyword(userId, keyword)
                .ifPresent(rs -> rs.touch(now));
    }
}


deleteRecentKeywordsOversize(user);
}

private void deleteRecentKeywordsOversize(User user) {
List<RecentSearch> list =
repository.findByUserIdOrderByUpdatedAtDesc(
user.getId(),
PageRequest.of(0, MAX_RECENT_SEARCH + 1)
);

if (list.size() > MAX_RECENT_SEARCH) {
repository.deleteAll(list.subList(MAX_RECENT_SEARCH, list.size()));
}
}

private String normalizeKeyword(String keyword) {
if (keyword == null) {
return null;
}

String trimmed = keyword.trim();
if (trimmed.isEmpty()) return null;
return trimmed;
}

@Transactional(readOnly = true)
public List<RecentSearch> list(final int limit) {
User user = userService.getUser();
return repository.findByUserIdOrderByUpdatedAtDesc(
user.getId(),
PageRequest.of(0, limit)
);
}

@Transactional
public void delete(Long id) {
User user = userService.getUser();
repository.deleteByIdAndUserId(id, user.getId());
}

@Transactional
public void deleteAll() {
User user = userService.getUser();
repository.deleteByUserId(user.getId());
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
package ku_rum.backend.domain.search.domain;

import jakarta.persistence.*;
import lombok.Getter;
import lombok.NoArgsConstructor;

import java.time.LocalDateTime;

@Getter
@NoArgsConstructor
@Entity
@Table(
name = "recent_search",
uniqueConstraints = @UniqueConstraint(
name = "uk_recent_search_user_keyword",
columnNames = {"user_id", "keyword"}
),
indexes = @Index(
name = "idx_recent_search_user_updated",
columnList = "user_id, updated_at"
)
)
public class RecentSearch {

@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;

@Column(name = "user_id", nullable = false)
private Long userId;

@Column(nullable = false, length = 255)
private String keyword;

@Column(name = "created_at", nullable = false)
private LocalDateTime createdAt;

@Column(name = "updated_at", nullable = false)
private LocalDateTime updatedAt;

public RecentSearch(Long userId, String keyword, LocalDateTime now) {
this.userId = userId;
this.keyword = keyword;
this.createdAt = now;
this.updatedAt = now;
}

public void touch(LocalDateTime now) {
this.updatedAt = now;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
package ku_rum.backend.domain.search.domain.repository;

import ku_rum.backend.domain.search.domain.RecentSearch;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.JpaRepository;

import java.util.List;
import java.util.Optional;

public interface RecentSearchRepository extends JpaRepository<RecentSearch, Long> {

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

List<RecentSearch> findByUserIdOrderByUpdatedAtDesc(Long userId, Pageable pageable);

long deleteByIdAndUserId(Long id, Long userId);

long deleteByUserId(Long userId);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
package ku_rum.backend.domain.search.presentation;

import jakarta.validation.constraints.Max;
import jakarta.validation.constraints.Min;
import ku_rum.backend.domain.search.application.RecentSearchService;
import ku_rum.backend.domain.search.domain.RecentSearch;
import ku_rum.backend.global.support.response.BaseResponse;
import lombok.RequiredArgsConstructor;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.*;

import java.util.List;

@RestController
@RequestMapping("/api/v1/notices/searches/recent")
@RequiredArgsConstructor
@Validated
public class RecentSearchController {

private final RecentSearchService recentSearchService;

@GetMapping
public BaseResponse<List<RecentSearch>> list(
@RequestParam(defaultValue = "20") @Min(1) @Max(100) final int limit) {
return BaseResponse.ok(recentSearchService.list(limit));
}

@DeleteMapping("/{id}")
public BaseResponse<Void> delete(@PathVariable Long id) {
recentSearchService.delete(id);
return BaseResponse.ok(null);
}

@DeleteMapping("/all")
public BaseResponse<Void> deleteAll() {
recentSearchService.deleteAll();
return BaseResponse.ok(null);
}
}
2 changes: 1 addition & 1 deletion src/main/resources/config
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
package ku_rum.backend.domain.search.presentation;

import com.epages.restdocs.apispec.ResourceSnippetParameters;
import ku_rum.backend.config.RestDocsTestSupport;
import ku_rum.backend.domain.search.application.RecentSearchService;
import ku_rum.backend.domain.search.domain.RecentSearch;
import ku_rum.backend.global.domain.repository.ApiLogRepository;
import ku_rum.backend.global.security.JwtTokenAuthenticationFilter;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.http.MediaType;
import org.springframework.restdocs.payload.JsonFieldType;
import org.springframework.security.test.context.support.WithMockUser;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.test.context.ActiveProfiles;
import org.springframework.test.util.ReflectionTestUtils;

import java.time.LocalDateTime;
import java.util.List;

import static com.epages.restdocs.apispec.ResourceDocumentation.resource;
import static org.mockito.BDDMockito.given;
import static org.mockito.Mockito.doNothing;
import static org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders.delete;
import static org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders.get;
import static org.springframework.restdocs.payload.PayloadDocumentation.fieldWithPath;
import static org.springframework.restdocs.request.RequestDocumentation.parameterWithName;
import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;

@WebMvcTest(RecentSearchController.class)
@ActiveProfiles("test")
class RecentSearchControllerTest extends RestDocsTestSupport {

@MockBean
private RecentSearchService recentSearchService;

@MockBean
private ApiLogRepository apiLogRepository;

@MockBean
private SecurityFilterChain securityFilterChain;

@MockBean
private JwtTokenAuthenticationFilter jwtTokenAuthenticationFilter;


@Test
@DisplayName("최근 검색어 목록을 조회한다")
@WithMockUser
void getRecentSearches() throws Exception {
// given
List<RecentSearch> response = List.of(
mockRecentSearch(1L, 1L, "장학금"),
mockRecentSearch(2L, 1L, "등록금")
);

given(recentSearchService.list(20)).willReturn(response);

// when & then
mockMvc.perform(get("/api/v1/notices/searches/recent")
.param("limit", "20")
.contentType(MediaType.APPLICATION_JSON)
)
.andDo(print())
.andExpect(status().isOk())
.andDo(restDocs.document(
resource(
ResourceSnippetParameters.builder()
.tag("최근 검색어 API")
.description("최근 검색어 목록 조회")
.queryParameters(
parameterWithName("limit")
.description("조회할 최근 검색어 개수 (기본값 20)")
)
.responseFields(
fieldWithPath("code").type(JsonFieldType.NUMBER).description("응답 코드"),
fieldWithPath("status").type(JsonFieldType.STRING).description("응답 상태"),
fieldWithPath("message").type(JsonFieldType.STRING).description("응답 메시지"),
fieldWithPath("data[].id").type(JsonFieldType.NUMBER).description("최근 검색어 ID"),
fieldWithPath("data[].userId").type(JsonFieldType.NUMBER).description("유저 ID"),
fieldWithPath("data[].keyword").type(JsonFieldType.STRING).description("검색 키워드"),
fieldWithPath("data[].createdAt").type(JsonFieldType.STRING).description("생성 시각"),
fieldWithPath("data[].updatedAt").type(JsonFieldType.STRING).description("최근 검색 시각")
)
.build()
)
));
}

@Test
@DisplayName("최근 검색어를 단건 삭제한다")
@WithMockUser
void deleteRecentSearch() throws Exception {
doNothing().when(recentSearchService).delete(1L);

mockMvc.perform(delete("/api/v1/notices/searches/recent/{id}", 1L)
.contentType(MediaType.APPLICATION_JSON)
)
.andDo(print())
.andExpect(status().isOk())
.andDo(restDocs.document(
resource(
ResourceSnippetParameters.builder()
.tag("최근 검색어 API")
.description("최근 검색어 단건 삭제")
.responseFields(
fieldWithPath("code").type(JsonFieldType.NUMBER).description("응답 코드"),
fieldWithPath("status").type(JsonFieldType.STRING).description("응답 상태"),
fieldWithPath("message").type(JsonFieldType.STRING).description("응답 메시지")
)
.build()
)
));
}

@Test
@DisplayName("최근 검색어 전체 삭제")
@WithMockUser
void deleteAllRecentSearches() throws Exception {
doNothing().when(recentSearchService).deleteAll();

mockMvc.perform(delete("/api/v1/notices/searches/recent/all")
.contentType(MediaType.APPLICATION_JSON)
)
.andDo(print())
.andExpect(status().isOk())
.andDo(restDocs.document(
resource(
ResourceSnippetParameters.builder()
.tag("최근 검색어 API")
.description("최근 검색어 전체 삭제")
.responseFields(
fieldWithPath("code").type(JsonFieldType.NUMBER).description("응답 코드"),
fieldWithPath("status").type(JsonFieldType.STRING).description("응답 상태"),
fieldWithPath("message").type(JsonFieldType.STRING).description("응답 메시지")
)
.build()
)
));
}

private RecentSearch mockRecentSearch(Long id, Long userId, String keyword) {
RecentSearch rs = new RecentSearch(userId, keyword, LocalDateTime.now());
ReflectionTestUtils.setField(rs, "id", id);
return rs;
}
}
Loading