개발 slecs

로그인 사용자 기반 상품 필터링 기능 추가

목차

로그인한 사용자 ID를 기준으로 상품을 필터링하는 기능을 slecs 레포에 추가했다. 변경 파일 수는 3개 — 컨트롤러 2개, MyBatis 매퍼 1개. 숫자만 보면 작은 작업처럼 느껴지는데, 인증 컨텍스트를 SQL 레이어까지 끌어내리면서 기존 코드에 사이드 이펙트를 내지 않아야 한다는 제약이 있어서 설계 방향을 먼저 정리하고 들어갔다.

왜 이 구조가 필요했나

상품 목록을 전체 노출하다가 "내 상품만 보기" 혹은 "내 계정과 연결된 상품만 필터링"하는 요건은 서비스가 커지면 거의 반드시 생긴다. 문제는 이걸 어느 레이어에서 처리하느냐다. 프론트에서 받아온 전체 목록을 클라이언트 사이드에서 걸러내는 방식은 데이터를 다 내려보낸다는 점에서 비효율적이고, 보안상으로도 좋지 않다. SQL 레벨에서 WHERE userId = ? 로 잘라내는 게 맞다.

그러면 userId를 어디서 가져오느냐가 남는다. 클라이언트가 파라미터로 넘겨주는 userId를 그냥 쓰면 안 된다. 그걸 믿으면 URL이나 요청 바디를 조작해서 타 사용자의 상품을 조회하는 게 가능해진다. 반드시 서버 세션에서 꺼낸 userId를 쿼리에 박아야 한다. 이게 이번 구현의 핵심 원칙이었다.

기존 코드베이스가 Spring MVC + MyBatis 조합으로 컨트롤러-서비스-매퍼 흐름이 이미 정착해 있었기 때문에 새 아키텍처 레이어를 도입할 이유가 없었다. 같은 패턴 위에 새 진입점과 새 쿼리 ID를 얹는 방식으로 진행했다.

구현 흐름과 레이어별 선택지

변경은 세 레이어에 걸쳐 있다.

레이어 역할 이번 변경
컨트롤러 HTTP 진입점, 세션 처리 신규 엔드포인트, 세션에서 userId 추출
비즈니스 로직/유틸 파라미터 조립, 권한 흐름 기존 패턴 재사용, userId 필터 조건 추가
MyBatis 매퍼 SQL 실행 신규 쿼리 ID, WHERE user_id 조건 분리

컨트롤러에서 세션 userId를 꺼내는 부분은 아래 패턴을 따랐다. 프로젝트 기존 코드에서 세션 처리하는 방식이 있었고, 그걸 그대로 가져다 썼다.

String userId = (String) session.getAttribute("userId");
if (userId == null) {
    // 비로그인 접근 — 기존 에러 처리 방식 재사용
    return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build();
}

Map<String, Object> params = new HashMap<>();
params.put("userId", userId);
List<ProductVO> result = productService.getProductsByUser(params);

파라미터를 Map으로 넘기는 건 MyBatis 프로젝트에서 흔한 패턴이다. 타입 안전성은 약하지만 기존 코드베이스 전반이 이 방식을 쓰고 있었기 때문에 일관성을 맞추는 편이 유지보수 측면에서 낫다고 판단했다.

매퍼 XML에서는 신규 쿼리 ID를 기존 것과 분리해서 추가했다.

<select id="selectProductListByUserId"
        parameterType="map"
        resultType="com.example.vo.ProductVO">
    SELECT
        product_id,
        product_nm,
        reg_dt
    FROM product
    WHERE user_id = #{userId}
      AND del_yn = 'N'
    ORDER BY reg_dt DESC
</select>

기존 전체 조회 쿼리(selectProductList 계열)는 손대지 않았고, userId 필터 버전은 별도 쿼리 ID로 분리했다. 이렇게 해두면 나중에 필터 조건이 추가되더라도 기존 쿼리에 영향 없이 이쪽 쿼리만 수정하면 된다.

MyBatis 쓸 때 자주 실수하는 게 파라미터 키 이름 불일치다. params.put("userId", ...) 로 넣었는데 XML에서 #{user_id}#{userIdx} 처럼 키 이름이 달라지면 null이 내려가서 WHERE 조건이 없는 것처럼 동작한다. 전체 데이터가 다 나오거나 0건이 나오는데, 쿼리 자체는 에러 없이 실행되기 때문에 로그만 봐서는 잡기 어렵다. 이번엔 서비스 레이어에서 Map에 넣는 키 이름과 XML의 #{} 표현을 명시적으로 맞춰서 검증 단계에서 바로 확인했다.

검증 방식과 엣지 케이스

신규 기능이라 가장 중요한 게 기존 API에 회귀가 없는지였다. 작업 순서를 이렇게 잡았다.

  • 코드 변경 전: 기존 상품 조회 API, 목록 화면 정상 동작 확인
  • 신규 엔드포인트 추가 후: 기존 흐름 재확인. 새 엔드포인트가 기존 라우팅이나 매퍼 네임스페이스와 충돌하지 않는지 포함
  • 신규 기능 동작: 로그인 상태에서 필터링 결과 조회, DB 직접 SELECT로 기대값 대조
  • 엣지 케이스: 해당 사용자에게 상품이 없는 경우 빈 배열 정상 반환 / 미로그인 접근 처리 / 세션 만료 후 접근

특히 빈 결과 케이스는 꼭 확인해야 한다. LIST 응답에서 null을 반환하면 프론트에서 .map() 이나 .forEach() 돌다가 NPE가 나기 쉽다. 매퍼 resultType이 List인 경우 MyBatis는 기본적으로 빈 리스트를 반환하지만, 서비스 레이어에서 추가 처리하다가 null로 바꾸는 실수가 종종 생긴다. 이번엔 빈 결과로 쿼리를 실행해서 응답이 []로 내려오는지 직접 확인했다.

트레이드오프 하나는 남겨뒀다. 지금 구조에서는 세션 userId만 필터로 쓰기 때문에, 나중에 관리자가 특정 사용자의 상품을 조회해야 하는 케이스가 생기면 이 엔드포인트를 그대로 재사용하기 어렵다. 그때 가서는 역할 기반으로 파라미터 전달 방식에 분기를 추가하면 되는데, 지금 시점에서 그 요건이 없으니 일단 단순하게 갔다. 미래 요건을 미리 반영해서 구조를 복잡하게 만드는 건 YAGNI 원칙에 어긋난다고 봤다.

파일 3개 변경인데 검증까지 포함하면 생각보다 시간이 걸렸다. 각 레이어 경계에서 데이터가 제대로 흘러가는지 직접 찍어보고 대조하는 과정이 반복됐기 때문이다. 작업 규모 대비 검증 비용이 크게 드는 유형이다. 그래도 이 번거로움을 생략하면 나중에 더 큰 비용으로 돌아오는 게 보안 관련 버그라서, 인증 컨텍스트가 엮이는 기능은 이 정도 꼼꼼함이 필요하다고 생각한다.

댓글 0

첫 댓글 달아줘.