diff --git a/backend/src/main/java/com/shyashyashya/refit/domain/company/api/CompanyController.java b/backend/src/main/java/com/shyashyashya/refit/domain/company/api/CompanyController.java new file mode 100644 index 000000000..6ac5fdb4e --- /dev/null +++ b/backend/src/main/java/com/shyashyashya/refit/domain/company/api/CompanyController.java @@ -0,0 +1,36 @@ +package com.shyashyashya.refit.domain.company.api; + +import static com.shyashyashya.refit.global.model.ResponseCode.COMMON200; + +import com.shyashyashya.refit.domain.company.api.response.CompanyResponse; +import com.shyashyashya.refit.domain.company.service.CompanyService; +import com.shyashyashya.refit.global.dto.ApiResponse; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; +import org.springdoc.core.annotations.ParameterObject; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +@Tag(name = "Company API", description = "회사 관련 API 입니다.") +@RestController +@RequiredArgsConstructor +@RequestMapping("/company") +public class CompanyController { + + private final CompanyService companyService; + + @GetMapping + @Operation(summary = "회사 목록을 검색합니다.", description = "회사 목록을 검색합니다. 한글의 일부가 완성되어도 검새이 가능합니다.") + public ResponseEntity>> findCompanies( + @RequestParam(required = false) String query, @ParameterObject Pageable pageable) { + var response = companyService.findCompanies(query, pageable); + var body = ApiResponse.success(COMMON200, response); + return ResponseEntity.ok(body); + } +} diff --git a/backend/src/main/java/com/shyashyashya/refit/domain/company/api/response/CompanyResponse.java b/backend/src/main/java/com/shyashyashya/refit/domain/company/api/response/CompanyResponse.java new file mode 100644 index 000000000..d34ef9e09 --- /dev/null +++ b/backend/src/main/java/com/shyashyashya/refit/domain/company/api/response/CompanyResponse.java @@ -0,0 +1,3 @@ +package com.shyashyashya.refit.domain.company.api.response; + +public record CompanyResponse(Long companyId, String companyName) {} diff --git a/backend/src/main/java/com/shyashyashya/refit/domain/company/model/Company.java b/backend/src/main/java/com/shyashyashya/refit/domain/company/model/Company.java index 8b7530f18..0b4c019ed 100644 --- a/backend/src/main/java/com/shyashyashya/refit/domain/company/model/Company.java +++ b/backend/src/main/java/com/shyashyashya/refit/domain/company/model/Company.java @@ -5,6 +5,7 @@ import jakarta.persistence.GeneratedValue; import jakarta.persistence.GenerationType; import jakarta.persistence.Id; +import jakarta.persistence.Index; import jakarta.persistence.Table; import lombok.AccessLevel; import lombok.Builder; @@ -13,7 +14,9 @@ @Getter @Entity -@Table(name = "companies") +@Table( + name = "companies", + indexes = {@Index(name = "idx_company_search_composite", columnList = "is_search_allowed, search_name")}) @NoArgsConstructor(access = AccessLevel.PROTECTED) public class Company { @@ -25,26 +28,35 @@ public class Company { @Column(name = "company_name", columnDefinition = "varchar(20)", unique = true) private String name; + @Column(name = "search_name", columnDefinition = "varchar(80)") + private String decomposedName; + @Column(name = "company_logo_url", columnDefinition = "varchar(2048)") private String logoUrl; - @Column(nullable = false) + @Column(name = "is_search_allowed", nullable = false) private boolean isSearchAllowed; + public void allowSearch() { + this.isSearchAllowed = true; + } + /* Factory Method */ - public static Company create(String name, String logoUrl, boolean isSearchAllowed) { + public static Company create(String name, String decomposedName, String logoUrl) { return Company.builder() .name(name) + .decomposedName(decomposedName) .logoUrl(logoUrl) - .isSearchAllowed(isSearchAllowed) + .isSearchAllowed(false) .build(); } @Builder(access = AccessLevel.PRIVATE) - private Company(String name, String logoUrl, boolean isSearchAllowed) { + private Company(String name, String decomposedName, String logoUrl, boolean isSearchAllowed) { this.name = name; + this.decomposedName = decomposedName; this.logoUrl = logoUrl; this.isSearchAllowed = isSearchAllowed; } diff --git a/backend/src/main/java/com/shyashyashya/refit/domain/company/repository/CompanyRepository.java b/backend/src/main/java/com/shyashyashya/refit/domain/company/repository/CompanyRepository.java index d7fb19f36..ee0bf973e 100644 --- a/backend/src/main/java/com/shyashyashya/refit/domain/company/repository/CompanyRepository.java +++ b/backend/src/main/java/com/shyashyashya/refit/domain/company/repository/CompanyRepository.java @@ -1,9 +1,27 @@ package com.shyashyashya.refit.domain.company.repository; +import com.shyashyashya.refit.domain.company.api.response.CompanyResponse; import com.shyashyashya.refit.domain.company.model.Company; import java.util.Optional; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; public interface CompanyRepository extends JpaRepository { Optional findByName(String name); + + // TODO: QueryDSL 적용 + @Query(value = """ + SELECT new com.shyashyashya.refit.domain.company.api.response.CompanyResponse(c.id, c.name) + FROM Company c + WHERE LOWER(c.decomposedName) LIKE LOWER(CONCAT(:query, '%')) + AND c.isSearchAllowed = TRUE + """, countQuery = """ + SELECT COUNT(c) + FROM Company c + WHERE LOWER(c.decomposedName) LIKE LOWER(CONCAT(:query, '%')) + AND c.isSearchAllowed = TRUE + """) + Page findAllBySearchQuery(String query, Pageable pageable); } diff --git a/backend/src/main/java/com/shyashyashya/refit/domain/company/service/CompanyService.java b/backend/src/main/java/com/shyashyashya/refit/domain/company/service/CompanyService.java new file mode 100644 index 000000000..34ae3806f --- /dev/null +++ b/backend/src/main/java/com/shyashyashya/refit/domain/company/service/CompanyService.java @@ -0,0 +1,21 @@ +package com.shyashyashya.refit.domain.company.service; + +import com.shyashyashya.refit.domain.company.api.response.CompanyResponse; +import com.shyashyashya.refit.domain.company.repository.CompanyRepository; +import com.shyashyashya.refit.global.util.HangulUtil; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +public class CompanyService { + + private final CompanyRepository companyRepository; + private final HangulUtil hangulUtil; + + public Page findCompanies(String query, Pageable pageable) { + return companyRepository.findAllBySearchQuery(hangulUtil.decompose(query), pageable); + } +} diff --git a/backend/src/main/java/com/shyashyashya/refit/domain/interview/service/InterviewService.java b/backend/src/main/java/com/shyashyashya/refit/domain/interview/service/InterviewService.java index ec3b62319..2fe5e543a 100644 --- a/backend/src/main/java/com/shyashyashya/refit/domain/interview/service/InterviewService.java +++ b/backend/src/main/java/com/shyashyashya/refit/domain/interview/service/InterviewService.java @@ -40,6 +40,7 @@ import com.shyashyashya.refit.domain.user.model.User; import com.shyashyashya.refit.global.exception.CustomException; import com.shyashyashya.refit.global.property.S3Property; +import com.shyashyashya.refit.global.util.HangulUtil; import com.shyashyashya.refit.global.util.RequestUserContext; import java.time.Duration; import java.time.LocalDateTime; @@ -79,6 +80,7 @@ public class InterviewService { private final RequestUserContext requestUserContext; private final S3Presigner s3Presigner; private final S3Property s3Property; + private final HangulUtil hangulUtil; @Transactional(readOnly = true) public InterviewDto getInterview(Long interviewId) { @@ -362,7 +364,8 @@ private Company findOrSaveCompany(InterviewCreateRequest request) { return companyRepository.findByName(request.companyName()).orElseGet(() -> { try { // TODO 회사 디폴트 이미지 url로 변경 - Company newCompany = Company.create(request.companyName(), null, false); + Company newCompany = + Company.create(request.companyName(), hangulUtil.decompose(request.companyName()), null); return companyRepository.save(newCompany); } catch (DataIntegrityViolationException e) { // Race condition diff --git a/backend/src/main/java/com/shyashyashya/refit/global/util/HangulUtil.java b/backend/src/main/java/com/shyashyashya/refit/global/util/HangulUtil.java new file mode 100644 index 000000000..3dcc1976e --- /dev/null +++ b/backend/src/main/java/com/shyashyashya/refit/global/util/HangulUtil.java @@ -0,0 +1,61 @@ +package com.shyashyashya.refit.global.util; + +import org.springframework.stereotype.Component; + +@Component +public class HangulUtil { + + // 초성 (19자) + private static final char[] CHOSUNG = { + 'ㄱ', 'ㄲ', 'ㄴ', 'ㄷ', 'ㄸ', 'ㄹ', 'ㅁ', 'ㅂ', 'ㅃ', 'ㅅ', 'ㅆ', 'ㅇ', 'ㅈ', 'ㅉ', 'ㅊ', 'ㅋ', 'ㅌ', 'ㅍ', 'ㅎ' + }; + + // 중성 (21자) + private static final char[] JUNGSUNG = { + 'ㅏ', 'ㅐ', 'ㅑ', 'ㅒ', 'ㅓ', 'ㅔ', 'ㅕ', 'ㅖ', 'ㅗ', 'ㅘ', 'ㅙ', 'ㅚ', 'ㅛ', 'ㅜ', 'ㅝ', 'ㅞ', 'ㅟ', 'ㅠ', 'ㅡ', 'ㅢ', 'ㅣ' + }; + + // 종성 (28자, 0번째는 종성 없음) + private static final char[] JONGSUNG = { + '\0', 'ㄱ', 'ㄲ', 'ㄳ', 'ㄴ', 'ㄵ', 'ㄶ', 'ㄷ', 'ㄹ', 'ㄺ', 'ㄻ', 'ㄼ', 'ㄽ', 'ㄾ', 'ㄿ', 'ㅀ', 'ㅁ', 'ㅂ', 'ㅄ', 'ㅅ', 'ㅆ', 'ㅇ', + 'ㅈ', 'ㅊ', 'ㅋ', 'ㅌ', 'ㅍ', 'ㅎ' + }; + + public String decompose(String input) { + if (input == null || input.isBlank()) { + return ""; + } + + StringBuilder result = new StringBuilder(); + + for (char c : input.toCharArray()) { + + // 한글 완성형 음절이 아닌 경우는 그대로 추가 + if (!isHangleSyllable(c)) { + result.append(c); + continue; + } + + int code = c - 0xAC00; + + int chosungIndex = code / (21 * 28); + int jungsungIndex = (code % (21 * 28)) / 28; + int jongsungIndex = code % 28; + + result.append(CHOSUNG[chosungIndex]); + result.append(JUNGSUNG[jungsungIndex]); + + // 종성이 있는 경우에만 추가 + if (jongsungIndex > 0) { + result.append(JONGSUNG[jongsungIndex]); + } + } + + return result.toString(); + } + + // '가' ~ '힣' 사이의 한글 완성형 음절인지 확인하는 메서드 + public boolean isHangleSyllable(char c) { + return 0xAC00 <= c && c <= 0xD7A3; + } +} diff --git a/backend/src/test/java/com/shyashyashya/refit/integration/core/IntegrationTest.java b/backend/src/test/java/com/shyashyashya/refit/integration/core/IntegrationTest.java index 08c1a6b77..304108d49 100644 --- a/backend/src/test/java/com/shyashyashya/refit/integration/core/IntegrationTest.java +++ b/backend/src/test/java/com/shyashyashya/refit/integration/core/IntegrationTest.java @@ -33,6 +33,7 @@ import com.shyashyashya.refit.domain.user.repository.UserRepository; import com.shyashyashya.refit.global.auth.service.JwtEncoder; import com.shyashyashya.refit.global.constant.AuthConstant; +import com.shyashyashya.refit.global.util.HangulUtil; import io.restassured.RestAssured; import io.restassured.builder.RequestSpecBuilder; import io.restassured.http.ContentType; @@ -87,6 +88,9 @@ public abstract class IntegrationTest { @Autowired private JwtEncoder jwtEncoder; + @Autowired + private HangulUtil hangulUtil; + @Autowired private UserRepository userRepository; @@ -125,9 +129,9 @@ void restAssuredSetUp() { jobCategory1 = jobCategoryRepository.save(JobCategory.create("BE Developer")); jobCategory2 = jobCategoryRepository.save(JobCategory.create("FE Developer")); jobCategory3 = jobCategoryRepository.save(JobCategory.create("Designer")); - company1 = companyRepository.save(Company.create("현대자동차", "logo1", true)); - company2 = companyRepository.save(Company.create("카카오", "logo2", true)); - company3 = companyRepository.save(Company.create("네이버", "logo3", true)); + company1 = createAndSaveCompany("현대자동차", "logo1.jpg"); + company2 = createAndSaveCompany("카카오", "logo2.png"); + company3 = createAndSaveCompany("네이버", "logo3.svg"); qnaSetCategory1 = qnaSetCategoryRepository.save(QnaSetCategory.create("리더십 질문", "당신은 리더십있는 사람입니까?", 3.141592)); qnaSetCategory2 = qnaSetCategoryRepository.save(QnaSetCategory.create("인성 질문", "당신은 인성이 좋은 사람입니까?", 2.145)); qnaSetCategory3 = qnaSetCategoryRepository.save(QnaSetCategory.create("기술 질문", "당신은 기술 있는 사람입니까?", 0.001)); @@ -222,7 +226,14 @@ protected Interview createAndSaveInterview(InterviewCreateRequest request, Inter } protected Company createAndSaveCompany(String companyName) { - Company company = Company.create(companyName, "logo.url", true); + Company company = Company.create(companyName, hangulUtil.decompose(companyName), "logo.url"); + company.allowSearch(); + return companyRepository.save(company); + } + + protected Company createAndSaveCompany(String companyName, String logoUrl) { + Company company = Company.create(companyName, hangulUtil.decompose(companyName), logoUrl); + company.allowSearch(); return companyRepository.save(company); } diff --git a/backend/src/test/java/com/shyashyashya/refit/unit/fixture/CompanyFixture.java b/backend/src/test/java/com/shyashyashya/refit/unit/fixture/CompanyFixture.java index 3dcb881bd..0bdf559b6 100644 --- a/backend/src/test/java/com/shyashyashya/refit/unit/fixture/CompanyFixture.java +++ b/backend/src/test/java/com/shyashyashya/refit/unit/fixture/CompanyFixture.java @@ -1,8 +1,9 @@ package com.shyashyashya.refit.unit.fixture; import com.shyashyashya.refit.domain.company.model.Company; +import com.shyashyashya.refit.global.util.HangulUtil; public class CompanyFixture { - public static final Company TEST_COMPANY = Company.create("test company", "test.com/logo.png", true); + public static final Company TEST_COMPANY = Company.create("test company", new HangulUtil().decompose("test company"), "test.com/logo.png"); } diff --git a/backend/src/test/java/com/shyashyashya/refit/unit/global/util/HangulUtilTest.java b/backend/src/test/java/com/shyashyashya/refit/unit/global/util/HangulUtilTest.java new file mode 100644 index 000000000..cac550bde --- /dev/null +++ b/backend/src/test/java/com/shyashyashya/refit/unit/global/util/HangulUtilTest.java @@ -0,0 +1,172 @@ +package com.shyashyashya.refit.unit.global.util; + +import com.shyashyashya.refit.global.util.HangulUtil; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +class HangulUtilTest { + + private final HangulUtil hangulUtil = new HangulUtil(); + + @Test + void 받침이_없는_평범한_한글을_자소_분리한다() { + // given + String input = "가나다"; + + // when + String result = hangulUtil.decompose(input); + + // then + assertThat(result).isEqualTo("ㄱㅏㄴㅏㄷㅏ"); + } + + @Test + void 받침이_있는_한글을_자소_분리한다() { + // given + String input = "강물"; + + // when + String result = hangulUtil.decompose(input); + + // then + assertThat(result).isEqualTo("ㄱㅏㅇㅁㅜㄹ"); + } + + @Test + void 겹받침이_있는_경우_종성_배열의_문자_그대로_분리된다() { + // given + String input = "닭"; + + // when + String result = hangulUtil.decompose(input); + + // then + // ㄷ + ㅏ + ㄺ (ㄹ,ㄱ으로 나뉘지 않고 ㄺ 한 글자로 나옴) + assertThat(result).isEqualTo("ㄷㅏㄺ"); + } + + @Test + void 복합_모음이_포함된_단어를_분리한다() { + // given + String input = "왜래종"; + + // when + String result = hangulUtil.decompose(input); + + // then + assertThat(result).isEqualTo("ㅇㅙㄹㅐㅈㅗㅇ"); + } + + @Test + void 한글_유니코드_시작과_끝_문자를_테스트한다() { + // given + String start = "가"; // 0xAC00 + String end = "힣"; // 0xD7A3 + + // when + String resultStart = hangulUtil.decompose(start); + String resultEnd = hangulUtil.decompose(end); + + // then + assertThat(resultStart).isEqualTo("ㄱㅏ"); + assertThat(resultEnd).isEqualTo("ㅎㅣㅎ"); + } + + @Test + void 한글이_아닌_영어와_숫자는_변경되지_않는다() { + // given + String input = "Java2026"; + + // when + String result = hangulUtil.decompose(input); + + // then + assertThat(result).isEqualTo("Java2026"); + } + + @Test + void 특수문자와_공백은_변경되지_않는다() { + // given + String input = "!@# $"; + + // when + String result = hangulUtil.decompose(input); + + // then + assertThat(result).isEqualTo("!@# $"); + } + + @Test + void 한글과_영어가_혼합된_문자열을_정상적으로_처리한다() { + // given + String input = "A급코드"; + + // when + String result = hangulUtil.decompose(input); + + // then + assertThat(result).isEqualTo("Aㄱㅡㅂㅋㅗㄷㅡ"); + } + + @Test + void 완성형_한글이_아닌_자음이나_모음_낱자는_분해하지_않고_그대로_반환한다() { + // given + String input = "ㄱㄴㄷㅏ"; + + // when + String result = hangulUtil.decompose(input); + + // then + assertThat(result).isEqualTo("ㄱㄴㄷㅏ"); + } + + @Test + void 빈_문자열이_입력되면_빈_문자열을_반환한다() { + // given + String input = ""; + + // when + String result = hangulUtil.decompose(input); + + // then + assertThat(result).isEqualTo(""); + } + + @Test + void 입력값이_Null이면_빈_문자열이_반환된다() { + // given + String input = null; + + // when + String result = hangulUtil.decompose(input); + + // then + assertThat(result).isEqualTo(""); + } + + @Test + void 한글_음절_판별_메서드를_테스트한다() { + // given + char valid = '강'; + char invalid1 = 'A'; + char invalid2 = 'ㄱ'; + + // when & then + assertThat(hangulUtil.isHangleSyllable(valid)).isTrue(); + assertThat(hangulUtil.isHangleSyllable(invalid1)).isFalse(); + assertThat(hangulUtil.isHangleSyllable(invalid2)).isFalse(); + } + + @Test + void 한글_영어_특수문자_공백이_모두_포함된_문자열을_분리한다() { + // given + String input = "운명의 Destiny, 2024!@#, 시작!"; + + // when + String result = hangulUtil.decompose(input); + + // then + assertThat(result).isEqualTo("ㅇㅜㄴㅁㅕㅇㅇㅢ Destiny, 2024!@#, ㅅㅣㅈㅏㄱ!"); + } +} \ No newline at end of file