개발 slecs

공급사 포탈에 카테고리·상품·브랜드 관리 기능 추가

목차

Supplier 포탈에 카테고리·상품·브랜드 관리 기능을 붙이는 작업이었다. 변경된 내부 클래스가 6개. 숫자만 보면 가볍게 느껴지는데, 실제로는 SQL 매퍼 설계부터 화면 렌더링 연동, 권한 체크, 예외 처리까지 레이어가 전부 묶여 있는 작업이었다.

공급사 포탈은 내부에서 카테고리 계층을 관리하고, 그 계층에 상품과 브랜드가 연결되는 구조다. 기존에는 조회만 가능하거나 아예 화면이 없던 영역인데, 운영 측에서 직접 데이터를 등록·수정·삭제할 수 있어야 한다는 요구가 생겼다. 단순히 UI에 폼을 하나 추가하는 수준이 아니라, 쿼리 레벨부터 설계해서 데이터 정합성을 보장해야 했다.

쿼리 레벨부터 설계한 이유

UI를 먼저 그리고 뒤에 쿼리를 붙이는 방식은 나중에 반드시 문제가 생긴다. 카테고리처럼 계층이 있는 데이터는 특히 그렇다. 화면에서 "상위 카테고리 아래 하위 카테고리"를 보여줘야 할 때, 앱 레이어에서 반복문으로 트리를 조립하면 데이터가 수백 건만 넘어도 쿼리 횟수가 폭발한다.

이번엔 SQL 매퍼에서 계층 조인을 써서 한 번에 묶어 올리는 방식으로 설계했다. MyBatis 기준으로 보면 대략 이런 구조다.

<!-- CategoryMapper.xml -->
<select id="selectCategoryTree" resultMap="CategoryTreeResultMap">
  SELECT
    c.category_id,
    c.category_name,
    c.parent_id,
    p.category_name AS parent_name,
    c.sort_order,
    c.use_yn
  FROM tb_category c
  LEFT JOIN tb_category p ON c.parent_id = p.category_id
  WHERE c.use_yn = 'Y'
  ORDER BY COALESCE(c.parent_id, c.category_id), c.sort_order ASC
</select>

ResultMap에서 collection으로 자식 목록을 묶거나, 서비스 레이어에서 flat list를 트리로 재조합하거나 - 어느 쪽이 맞느냐는 케이스마다 다르다. 뎁스가 2단계로 고정이면 SQL 조인이 깔끔하고, 뎁스가 가변이거나 재귀가 필요하면 서비스 레이어 재조합이 유연하다. 이번 구조는 2단계 고정이어서 조인으로 처리했다.

브랜드·상품 CRUD는 상대적으로 단순한데, 상품 쪽은 카테고리 FK가 걸려 있어서 카테고리 삭제 시 상품이 고아 데이터가 되는 상황을 막아야 했다. DB 제약으로 막는 방법도 있지만, 제약 조건 에러를 그대로 클라이언트에 노출하면 의미 없는 에러 문자열이 화면에 그냥 뜬다. 이번엔 삭제 요청이 들어오면 하위에 연결된 상품이 있는지 먼저 조회하고, 존재하면 명확한 메시지로 거부하는 방식을 택했다.

// CategoryService.java
public void deleteCategory(Long categoryId) {
    int linkedProductCount = productMapper.countByCategoryId(categoryId);
    if (linkedProductCount > 0) {
        throw new BusinessException("해당 카테고리에 연결된 상품이 있어 삭제할 수 없습니다.");
    }
    categoryMapper.deleteById(categoryId);
}

DB 제약 에러를 서비스 레이어에서 걸러내는 게 번거롭더라도, 운영자 입장에서는 "외래키 제약 조건 위반"보다 "연결된 상품이 있습니다"가 훨씬 낫다. 메시지 하나가 운영 도중 멈칫거리는 시간을 줄여준다.

실시간 갱신이 필요한지 먼저 따진다

새 기능을 붙일 때마다 나오는 질문이 "이거 실시간으로 반영돼야 해?"다. 폴링이냐 이벤트 기반이냐를 고민하기 전에, 그 데이터가 실제로 초 단위로 바뀌는지부터 확인해야 한다.

공급사 포탈의 카테고리·브랜드 데이터는 운영자가 수동으로 업데이트하는 데이터다. 수십 초 내 전파가 필요한 게 아니라, 페이지 로드 시점에 최신 데이터면 충분하다. 굳이 WebSocket이나 SSE를 붙일 이유가 없다.

데이터 유형 변경 빈도 이번에 선택한 방식
카테고리 계층 낮음 (운영자 수동) 요청 시 조회
브랜드 목록 낮음 요청 시 조회
상품 정보 중간 요청 시 조회
재고·가격 높음 별도 도메인, 이번 범위 외

이런 판단을 놓치면 오버엔지니어링이 슬금슬금 들어온다. 복잡도가 올라가고, 나중에 유지보수할 사람이 "왜 여기에 소켓이 있지?"라고 의아해하게 된다. 기술 선택은 "멋있어서"가 아니라 "이 상황에 맞아서"여야 한다.

검증 방식과 개발 습관

구현 후 직접 화면에서 동작을 확인했다. 금융·결제 도메인에서는 "대충 돌아가는 것 같다"가 제일 위험한 상태다. 숫자 하나가 다른 화면 집계와 맞지 않으면 신뢰가 무너지고, 그 신뢰를 다시 쌓는 비용이 처음 꼼꼼하게 만드는 비용보다 훨씬 크다.

이번에 직접 cross-check한 항목들:

  • 카테고리 등록·수정·삭제 후 목록 화면에 즉시 반영되는지
  • 하위 상품이 있을 때 카테고리 삭제 거부 메시지가 제대로 나오는지
  • 빈 데이터(등록 건 없는 브랜드 목록) 렌더링이 깨지지 않는지
  • 권한이 없는 역할로 접근 시 정상 차단되는지
  • 기존 화면의 집계 수치가 새 기능 추가 후에도 동일한지
  • 신규 쿼리의 실행 계획에서 풀 스캔이 없는지

실행 계획 확인은 습관처럼 하고 있다. 지금 데이터가 수백 건이어서 빠르더라도, 상품이 수만 건으로 늘면 인덱스 없는 조회가 갑자기 병목이 된다. 신규 쿼리를 추가할 때마다 EXPLAIN을 한 번씩 돌려보는 게 당연한 루틴이라고 생각한다.

커밋은 논리적으로 독립된 변경 단위로 쪼갰다. 카테고리 매퍼 추가, 서비스 메서드 구현, 화면 연동 이런 식으로. 하나의 커밋이 너무 많은 책임을 지면 나중에 어느 변경에서 버그가 생겼는지 추적하기가 어려워진다. 커밋 메시지는 "무엇을 추가했다" 대신 "왜 이 변경이 필요했는지"를 담으려고 노력한다. 3개월 뒤에 git log를 볼 때 "왜"가 빠져 있으면 코드만 보고 의도를 역추적해야 하는데, 그게 생각보다 꽤 힘들다.

변경 전 현재 동작을 수치나 스크린샷으로 메모해두고, 수정 후 같은 케이스로 비교하는 방식도 유지하고 있다. 특히 관련 화면이 여러 개일 때 숫자 cross-check는 빠뜨리면 안 된다. 사내 서비스는 기능 하나가 버튼 하나로 끝나지 않는다. SQL 집계, 상태 처리, 예외 방어, 렌더링, 권한 체크가 전부 엮여 있어서 하나라도 빠뜨리면 특정 케이스에서 이상한 화면이 나오거나 숫자가 맞지 않는다. "대충 맞는 것 같다"로 넘어가면 나중에 반드시 다시 돌아온다.

댓글 0

첫 댓글 달아줘.