파트너 포털 기능 개선
목차
파트너 포털 기능을 손봤다. 규모로 보면 크지 않은 작업이었는데, 이런 류의 수정이 종종 그렇듯 실제로 건드려야 하는 범위는 생각보다 넓었다.
파트너 포털 같은 내부 향 시스템은 외부 고객 대면 기능보다 우선순위에서 밀리기 쉽다. 그러다 운영 중에 "이게 왜 이렇게 동작하지?" 싶은 상황이 터지면 그때서야 들여다보게 된다. 이번 작업도 실제 사용 흐름에서 불편하거나 비어있는 부분을 채우는 성격이었다. 기능 자체가 없었거나, 있었더라도 엣지 케이스를 제대로 못 잡고 있었던 것.
Spring MVC + MyBatis 구조에서 내부 클래스가 하는 일
이 프로젝트는 Spring MVC + MyBatis 기반이고 뷰는 JSP다. 요청이 들어오면 Controller(또는 내부 클래스 형태의 액션 핸들러)가 받아서 파라미터를 바인딩하고 서비스 레이어로 넘긴다. MyBatis는 SQL XML에서 쿼리를 관리하고 결과를 VO나 Map으로 매핑해 돌려준다. JSP가 그걸 받아 HTML로 찍어내거나, Ajax 요청이면 JSON으로 응답을 구성한다.
내부 클래스로 구성된 핸들러는 관련 로직을 한 파일 안에 묶어두는 패턴인데, 파일 수를 줄이는 대신 한 파일이 길어지는 트레이드오프가 있다. 기능이 늘어날수록 한 클래스에 메서드가 쌓이고, 나중에 어디를 건드려야 하는지 추적하는 비용이 올라간다. 이번 수정도 기존 내부 클래스 안에 로직을 추가하거나 수정하는 방식이라, 변경이 다른 메서드에 영향을 주는지 꼼꼼히 확인해야 했다.
파라미터 바인딩 쪽에서는 null과 빈 문자열을 구분해서 처리하는 게 늘 번거롭다. 스프링 바인딩은 폼 파라미터가 없으면 null로 들어오기도 하고 빈 문자열로 들어오기도 한다. MyBatis 쿼리에서는 이를 명시적으로 걸러야 한다.
<!-- MyBatis SQL XML - 동적 조건 처리 예시 -->
<select id="selectPartnerList" parameterType="map" resultType="PartnerVO">
SELECT partner_id, partner_nm, status_cd
FROM partner
WHERE 1=1
<if test="partnerNm != null and partnerNm != ''">
AND partner_nm LIKE CONCAT('%', #{partnerNm}, '%')
</if>
<if test="statusCd != null and statusCd != ''">
AND status_cd = #{statusCd}
</if>
ORDER BY reg_dt DESC
</select>
동적 쿼리가 복잡해질수록 조건 누락 버그가 생기기 쉽다. 1=1 패턴은 쿼리 작성 편의를 위한 관용구인데, 팀 내에서 호불호가 갈리기도 하는 부분이다. 어쨌든 경계값 케이스는 개발 DB에서 실제로 쿼리 결과를 찍어가며 확인하는 게 안전하다.
트랜잭션 범위와 예외 처리 - 선택의 문제
이번 작업에서 가장 신경 쓴 부분이 트랜잭션 범위였다. DB를 건드리는 로직에 @Transactional 범위를 어디까지 잡을지는 기능마다 판단이 다르다.
| 상황 | 선택 | 이유 |
|---|---|---|
| 단일 INSERT/UPDATE | 서비스 메서드 단위로 최소화 | 락 점유 시간 줄이기 |
| 여러 테이블 연동 수정 | 서비스 메서드 전체 묶기 | 원자성 보장 |
| 읽기 전용 조회 | readOnly = true 명시 |
불필요한 락 회피 |
범위를 너무 크게 잡으면 락 점유 시간이 늘어 동시 접근 시 병목이 생기고, 너무 좁게 잡으면 중간에 예외 났을 때 일부만 반영된 상태로 남는다. 어느 쪽이든 롤백 케이스를 머릿속으로 한 번 그려보고 결정하는 게 맞다.
예외 처리는 RuntimeException을 전파할지, 잡아서 응답으로 변환할지 선택해야 한다. 파트너 포털 같은 내부 시스템이라도 스택트레이스가 그대로 화면에 뜨는 것보다는 의미 있는 메시지로 변환해서 내려주는 쪽이 낫다. 다만 예외를 너무 일찍 삼키면 로그에 아무것도 안 남아서 나중에 원인 추적이 힘들어진다. 이번에 정착시킨 패턴은 이렇다.
try {
partnerService.updatePartnerInfo(param);
} catch (PartnerNotFoundException e) {
log.warn("[파트너 정보 수정] 대상 없음 - partnerId={}", param.getPartnerId());
model.addAttribute("resultMsg", "수정 대상이 존재하지 않습니다.");
return "partner/partnerEdit";
} catch (Exception e) {
log.error("[파트너 정보 수정] 처리 실패 - param={}", param, e);
throw new RuntimeException("파트너 정보 수정 중 오류가 발생했습니다.", e);
}
비즈니스 예외는 잡아서 메시지 변환하고, 예상 못 한 예외는 래핑해서 전파한다. 이렇게 하면 운영 로그에 스택트레이스가 남으면서도 사용자한테는 깔끔한 메시지가 간다. 예외를 래핑할 때 원인 예외를 생략하면 로그에서 진짜 원인이 사라지니 cause를 꼭 넘겨야 한다.
로그 전략과 코드 리뷰에서 챙긴 것들
운영 환경에서 문제가 생겼을 때 디버깅 가능하려면 로그가 받쳐줘야 한다. DEBUG로 남기면 운영에서 기본적으로 꺼져 있어서 정작 필요할 때 없고, 반대로 너무 많이 INFO를 남기면 로그 양이 폭증해서 중요한 게 묻힌다. 이번에도 중요 입출력값은 INFO, 비정상이지만 처리 가능한 케이스는 WARN으로 분리했다.
- INFO: 요청 진입점, 주요 분기 결과, 외부 연동 성공/실패
- WARN: 빈 결과 반환, 선택적 파라미터 누락 등 비정상이지만 처리 가능한 케이스
- ERROR: 예상 못 한 예외, 트랜잭션 롤백
코드 리뷰 단계에서는 내가 수정한 메서드가 다른 호출 경로에서도 그대로 쓰이는지 확인했다. 내부 클래스 안에서 private 메서드를 여러 곳에서 공유해 쓰는 경우, 한 곳에서 로직을 바꾸면 다른 흐름이 깨질 수 있다. 이번엔 다행히 수정한 로직이 단일 흐름에서만 쓰이고 있었다. 공유 메서드였다면 변경 전 호출 경로를 전부 파악하고 들어갔을 것이다.
작업 후 로컬 기동 확인 → 개발 DB 반영 → 배포 순서는 이런 작업의 기본인데, "로컬에서 됐으니까"를 믿고 개발 DB 확인을 생략하면 환경 차이로 뜻밖의 이슈가 나온다. 쿼리 실행 계획이 다르거나, 개발 DB 데이터 상태가 로컬과 달라서 특정 케이스가 터지는 일이 있다. 귀찮아도 스텝을 밟는 게 맞다.
작업 규모는 작았지만 이런 수정들이 쌓이면 포털 안정성이 달라진다. 파트너 포털은 외부에서 보이진 않아도 내부 운영팀이 매일 쓰는 도구라, 여기서 튀는 동작이 있으면 결국 운영 팀에 대한 신뢰도로 이어진다. 내부 도구라고 대충 만드는 것과 제대로 만드는 것 사이의 차이가 장기적으로 꽤 크다.
댓글 0
첫 댓글 달아줘.