Skip to content
Open
Show file tree
Hide file tree
Changes from 8 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
@@ -0,0 +1,32 @@
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 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;

@RestController
@RequiredArgsConstructor
@RequestMapping("/company")
public class CompanyController {

private final CompanyService companyService;

@GetMapping
Comment on lines +21 to +28
Copy link

Copilot AI Feb 15, 2026

Choose a reason for hiding this comment

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

The CompanyController is missing API documentation annotations. According to the codebase conventions, all controllers should have @tag annotation at the class level and @operation annotation at the method level to provide proper Swagger/OpenAPI documentation. Please add:

  • @tag annotation with name and description at the class level
  • @operation annotation with a summary (and optionally description) at the method level

Example based on similar controllers in the codebase:

@Tag(name = "Company API", description = "회사 관련 API 입니다.")

and

@Operation(summary = "회사 목록을 검색합니다.")

Copilot uses AI. Check for mistakes.
public ResponseEntity<ApiResponse<Page<CompanyResponse>>> findCompanies(
@RequestParam(required = false) String q, @ParameterObject Pageable pageable) {
Comment on lines +30 to +31
Copy link
Collaborator

Choose a reason for hiding this comment

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

변수명 q 보다 구체적으로 적어주시면 가독성이 더 좋을 것 같습니다!

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 사용시 /company?q=캌 처럼 검색되는 걸 원하긴 했는데, 그러면 그냥 어노테이션에만 q로 명시하고 변수명은 구체적으로 써도 괜찮나요?

Copy link
Collaborator

Choose a reason for hiding this comment

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

query의 q인가요?

Copy link
Collaborator

@kckc0608 kckc0608 Feb 16, 2026

Choose a reason for hiding this comment

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

저는 실제 path 뒤에 붙는 쿼리 스트링에서도 keyword, query 와 같이 구체화되면 좋을 것 같습니다! query 라는 글자가 너무 길지도 않은 것 같고, 축약해서 사용했을 때 더 좋은 이유가 잘 생각이 안나는 것 같아요!
혹시 축약해서 사용하고 싶으신 이유가 있으신가요??

var response = companyService.findCompanies(q, pageable);
Comment on lines +31 to +32
Copy link

Copilot AI Feb 15, 2026

Choose a reason for hiding this comment

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

The query parameter name 'q' is not descriptive and could be confusing for API consumers. Consider using a more meaningful parameter name like 'query' or 'searchQuery' to improve API clarity. While this is a common shorthand in some APIs, the codebase doesn't show a consistent pattern for abbreviated parameter names in similar search endpoints.

Suggested change
@RequestParam(required = false) String q, @ParameterObject Pageable pageable) {
var response = companyService.findCompanies(q, pageable);
@RequestParam(name = "query", required = false) String query, @ParameterObject Pageable pageable) {
var response = companyService.findCompanies(query, pageable);

Copilot uses AI. Check for mistakes.
var body = ApiResponse.success(COMMON200, response);
return ResponseEntity.ok(body);
}
}
Comment on lines 21 to 36
Copy link

Copilot AI Feb 15, 2026

Choose a reason for hiding this comment

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

There is a significant discrepancy between the PR title/description and the mentioned issue #265. The PR is about implementing a company search API (DEV-265/BE), but the mentioned issue #265 is about "스크랩 폴더 목록 조회하는 API 구현" (DEV-191/BE - retrieving scrap folder lists with QnA set inclusion status). This appears to be a mismatch in the issue reference. Please verify that the correct issue is linked to this PR.

Copilot uses AI. Check for mistakes.
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
package com.shyashyashya.refit.domain.company.api.response;

public record CompanyResponse(Long companyId, String companyName) {}
Copy link

Copilot AI Feb 15, 2026

Choose a reason for hiding this comment

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

The CompanyResponse record doesn't include the logoUrl field, while the existing CompanyDto does include it (companyLogoUrl). Consider whether the company logo URL should be included in the search results, as it would likely be useful for displaying company logos in the UI when showing search results. If this omission is intentional, it's acceptable, but it's worth confirming the expected use case.

Suggested change
public record CompanyResponse(Long companyId, String companyName) {}
public record CompanyResponse(Long companyId, String companyName, String companyLogoUrl) {}

Copilot uses AI. Check for mistakes.
Original file line number Diff line number Diff line change
Expand Up @@ -25,26 +25,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 searchName;
Comment on lines +31 to +32
Copy link

Copilot AI Feb 15, 2026

Choose a reason for hiding this comment

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

The searchName field has a maximum length of 80 characters (varchar(80)), but the name field is only 20 characters (varchar(20)). Since Korean characters decompose into 2-3 jamo characters each, a 20-character Korean company name could expand to approximately 40-60 characters when decomposed. However, the 80-character limit should be sufficient. Consider adding validation to ensure that the decomposed searchName doesn't exceed this limit, or documenting why 80 characters is sufficient for the expected use case.

Copilot uses AI. Check for mistakes.
Comment on lines +31 to +32
Copy link

Copilot AI Feb 15, 2026

Choose a reason for hiding this comment

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

The searchName field is used in a LIKE query with a prefix pattern (CONCAT(:query, '%')) but lacks a database index. For optimal search performance, especially as the number of companies grows, consider adding an index on the searchName column. This would significantly improve query performance for prefix searches. You can add this using JPA's @table annotation with @Index, or through a database migration script.

Copilot uses AI. Check for mistakes.

@Column(name = "company_logo_url", columnDefinition = "varchar(2048)")
private String logoUrl;

@Column(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 searchName, String logoUrl) {
Comment on lines -37 to +47
Copy link
Collaborator

Choose a reason for hiding this comment

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

검색용 이름이라고 하니까 비즈니스 로직을 도메인이 알고 있는 느낌이 드는데, 혹시 decomposedName 과 같은 변수명은 어떻게 생각하시나요?

return Company.builder()
.name(name)
.searchName(searchName)
.logoUrl(logoUrl)
.isSearchAllowed(isSearchAllowed)
.isSearchAllowed(false)
.build();
}

@Builder(access = AccessLevel.PRIVATE)
private Company(String name, String logoUrl, boolean isSearchAllowed) {
private Company(String name, String searchName, String logoUrl, boolean isSearchAllowed) {
this.name = name;
this.searchName = searchName;
this.logoUrl = logoUrl;
this.isSearchAllowed = isSearchAllowed;
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,9 +1,26 @@
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<Company, Long> {
Optional<Company> findByName(String name);

@Query(value = """
SELECT new com.shyashyashya.refit.domain.company.api.response.CompanyResponse(c.id, c.name)
FROM Company c
WHERE LOWER(c.searchName) LIKE LOWER(CONCAT(:query, '%'))
AND c.isSearchAllowed = TRUE
""", countQuery = """
SELECT COUNT(c)
FROM Company c
WHERE LOWER(c.searchName) LIKE LOWER(CONCAT(:query, '%'))
AND c.isSearchAllowed = TRUE
""")
Page<CompanyResponse> findAllBySearchQuery(String query, Pageable pageable);
Comment on lines +15 to +26
Copy link
Collaborator

Choose a reason for hiding this comment

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

쿼리 dsl 로 프로젝션하면 좋을 것 같습니다!

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 Author

Choose a reason for hiding this comment

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

후속에서 하겠습니다!

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
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<CompanyResponse> findCompanies(String query, Pageable pageable) {
query = (query == null || query.isBlank()) ? "" : hangulUtil.decompose(query);
return companyRepository.findAllBySearchQuery(query, pageable);
Comment on lines +19 to +20
Copy link
Collaborator

Choose a reason for hiding this comment

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

null, blank 처리를 decompose 내부에서 빈문자 반환 처리하는 건 어떤 것 같으세요?

}
Comment on lines +18 to +21
Copy link

Copilot AI Feb 15, 2026

Choose a reason for hiding this comment

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

The new company search API endpoint lacks integration test coverage. The codebase has extensive integration tests for similar GET endpoints (e.g., InterviewIntegrationTest, QnaSetIntegrationTest). Consider adding integration tests to verify:

  1. Successful company search with a valid query
  2. Search with empty/null query returns all allowed companies
  3. Search correctly filters by searchName prefix
  4. Search respects isSearchAllowed flag (only returns companies where isSearchAllowed=true)
  5. Pagination works correctly
  6. Hangul decomposition is properly applied to the search query

Copilot uses AI. Check for mistakes.
}
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@
import com.shyashyashya.refit.domain.qnaset.repository.StarAnalysisRepository;
import com.shyashyashya.refit.domain.user.model.User;
import com.shyashyashya.refit.global.exception.CustomException;
import com.shyashyashya.refit.global.util.HangulUtil;
import com.shyashyashya.refit.global.util.RequestUserContext;
import java.util.List;
import java.util.Map;
Expand Down Expand Up @@ -62,6 +63,7 @@ public class InterviewService {

private final InterviewValidator interviewValidator;
private final RequestUserContext requestUserContext;
private final HangulUtil hangulUtil;

@Transactional(readOnly = true)
public InterviewDto getInterview(Long interviewId) {
Expand Down Expand Up @@ -238,7 +240,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
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
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) {
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;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,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;
Expand Down Expand Up @@ -65,6 +66,9 @@ public abstract class IntegrationTest {
@Autowired
private JwtEncoder jwtEncoder;

@Autowired
private HangulUtil hangulUtil;

@Autowired
private UserRepository userRepository;

Expand All @@ -91,9 +95,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");

requestUser = createAndSaveUser("test@example.com", "default", industry1, jobCategory1);
Instant issuedAt = Instant.now();
Expand Down Expand Up @@ -185,7 +189,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);
}
}
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
package com.shyashyashya.refit.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");
Copy link
Contributor

Choose a reason for hiding this comment

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

medium

The change in the Company.create factory method's signature has implicitly set isSearchAllowed to false for TEST_COMPANY. This introduces a hardcoded business logic value within the factory method, which can lead to unexpected test failures if other tests rely on TEST_COMPANY being searchable (isSearchAllowed=true).

According to the rule 'Avoid hardcoding business logic values in entity factory methods. Pass them as parameters to improve clarity and flexibility, leaving business rule enforcement to service layers.', it is recommended to modify the Company.create factory method to explicitly accept isSearchAllowed as a parameter. This approach improves clarity and flexibility, allowing the desired state to be set directly during object creation, rather than relying on post-creation calls like allowSearch() or implicit defaults.

References
  1. Avoid hardcoding business logic values in entity factory methods. Pass them as parameters to improve clarity and flexibility, leaving business rule enforcement to service layers.

Copy link

Copilot AI Feb 15, 2026

Choose a reason for hiding this comment

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

The CompanyFixture creates a new HangulUtil instance directly instead of using dependency injection. This is inconsistent with how HangulUtil is used elsewhere in the codebase (as a Spring-managed @component). While this works for test fixtures, it's better practice to either:

  1. Make HangulUtil methods static if they don't require any dependencies (since it's a pure utility), or
  2. Pass HangulUtil as a parameter to a factory method in the fixture

Consider refactoring HangulUtil to have static methods since it has no dependencies and is purely computational, which would make it easier to use in static fixture contexts.

Suggested change
public static final Company TEST_COMPANY = Company.create("test company", new HangulUtil().decompose("test company"), "test.com/logo.png");
public static Company createTestCompany(HangulUtil hangulUtil) {
return Company.create("test company", hangulUtil.decompose("test company"), "test.com/logo.png");
}

Copilot uses AI. Check for mistakes.
}
Loading
Loading