개발 slecs

파트너 신청 기능 운영 안정성 개선

목차

파트너 신청 기능은 플랫폼에서 외부 사업자나 제휴 파트너가 온보딩 요청을 넣는 진입점이다. 사용 빈도가 매일 폭발적이진 않더라도, 오류가 나면 파트너 측에서 바로 체감하는 흐름이라 운영 중 발견된 개선 포인트는 빠르게 반영하는 편이다. 이번 작업도 그런 맥락이었음. 기능이 아예 안 된 건 아니었고, 실제 사용 흐름을 따라가다 보니 엣지 케이스 처리가 미흡한 지점이 보였다.

수정 범위는 내부 클래스였는데, Spring MVC 구조에서 내부 클래스는 요청 처리 - 비즈니스 로직 - DB 호출 - 응답 조립까지 한 흐름을 이어주는 역할을 한다. 파일 하나 안에서 컨트롤러, 서비스 역할이 분리 없이 뭉쳐있는 구조인 경우도 있는데, 이럴 때 작은 수정이어도 기존 동작이 어느 지점에서 바뀔 수 있는지 추적이 필요하다. 클래스 안에 클래스가 중첩되면 의존성이 암묵적으로 묶여 있어서 IDE 리팩토링 도구가 잡아주지 못하는 케이스도 나온다.

요청 검증과 바인딩 - 첫 번째 방어선

파트너 신청처럼 외부 입력이 들어오는 엔드포인트는 파라미터 검증이 제일 먼저다. Spring의 @ModelAttribute@RequestParam 으로 바인딩할 때 null / 빈 문자열 / 타입 불일치가 조용히 통과하면 그게 SQL까지 내려가서 이상한 결과를 만들거나, 서비스 레이어에서 NPE로 터진다.

이번에 챙긴 패턴은 아래와 같다.

public String applyPartner(@ModelAttribute PartnerApplyDto dto,
                            BindingResult result,
                            Model model) {
    if (dto.getApplicantName() == null || dto.getApplicantName().isBlank()) {
        result.rejectValue("applicantName", "required", "신청자명은 필수입니다.");
    }
    if (dto.getContactEmail() == null || !dto.getContactEmail().contains("@")) {
        result.rejectValue("contactEmail", "invalid", "유효한 이메일을 입력해주세요.");
    }
    if (result.hasErrors()) {
        return "partner/applyForm";
    }
    partnerService.apply(dto);
    return "redirect:/partner/complete";
}

@Valid + BindingResult 조합으로 깔끔하게 처리할 수도 있지만, 레거시 코드베이스에서 DTO에 검증 어노테이션을 추가하면 같은 DTO를 쓰는 다른 바인딩 지점에 사이드 이펙트가 생기는 경우가 있다. 검증 규칙이 폼마다 달라야 하는 상황에서는 오히려 직접 분기가 더 명확할 때도 있음. 트레이드오프를 따져서 이번엔 직접 체크 방식을 유지했다.

MyBatis XML 쪽에서는 <if test="value != null and value != ''"> 로 동적 쿼리를 방어하는 패턴이 일반적인데, 여기서 주의할 점이 있다. 필수 조건인데 null이 들어오면 WHERE 절 자체가 빠지면서 의도치 않게 전체 레코드를 건드리는 쿼리가 실행될 수 있다. SQL 레벨 방어는 서비스 레이어 검증이 이미 통과한 데이터에 대한 보조 장치지, 1차 방어를 대신할 수 없다.

트랜잭션 범위와 예외 처리 선택

DB를 건드리는 로직에서 @Transactional 범위를 어디에 두느냐는 매번 판단이 필요하다. 서비스 메서드 단위로 묶거나, 더 작게 나누거나. 이번 파트너 신청 케이스는 신청 데이터 INSERT와 상태 플래그 업데이트가 원자성을 가져야 해서 서비스 레이어에 하나로 묶었음. 두 작업 중 하나만 성공하면 데이터가 불일치 상태로 남는다.

예외 처리는 RuntimeException 전파 vs 잡아서 응답 변환 중 선택이 늘 따라온다. 기준을 정리하면 이렇다.

상황 처리 방식
DB 연결 실패 등 시스템 오류 전파 → 공통 에러 핸들러 처리
중복 신청, 비즈니스 규칙 위반 잡아서 사용자 메시지 응답으로 변환
외부 연동 타임아웃 잡아서 fallback 또는 재시도
유효성 검증 실패 BindingResult 로 폼 재렌더링

이번엔 중복 신청 방어 로직이 포함됐는데, 같은 계정으로 이미 신청이 들어온 경우 사용자한테 안내 메시지를 줘야 하니 서비스에서 잡아서 컨트롤러로 결과 코드를 내려주는 방식으로 처리했다. 공통 @ControllerAdvice 가 있긴 했는데, 파트너 신청 특화 처리가 필요한 케이스라 로컬 핸들링을 택했음. 공통 핸들러를 너무 많은 예외로 채우면 나중에 어떤 화면에서 어떤 메시지가 나오는지 추적이 힘들어진다.

로그는 신청자 식별값과 처리 결과를 INFO 레벨로 박아뒀다. 운영에서 "이 파트너 신청이 왜 안 됐냐"는 문의가 들어올 때 로그가 없으면 재현부터 해야 한다. DEBUG로 남기면 운영 로그 레벨에서 아예 안 보이니, 중요 입출력값은 INFO로 찍어두는 게 기본이다. 반대로 너무 세세하게 INFO를 남기면 로그 볼륨이 커져서 진짜 중요한 이벤트가 묻힌다. 신청 접수, 검증 실패, 처리 완료 정도를 기록하는 수준으로 잡았음.

CI/CD 파이프라인에서 반복적으로 걸리는 것들

로컬 확인 → 개발 DB 반영 → 배포 순서로 처리했는데, 배포 파이프라인에서 자꾸 걸리는 지점들이 있어서 정리해둔다.

SSH 타임아웃이 제일 자주 나왔다. 빌드가 길어지면 연결이 끊기는데, 배포 도구 쪽 command_timeout 을 명시하거나 SSH 클라이언트 ServerAliveInterval 을 설정하면 해결된다. 그냥 두면 빌드가 성공해도 배포가 중간에 조용히 끊기는 케이스가 생김. 로그를 보지 않으면 배포가 된 건지 안 된 건지도 파악이 안 된다.

Gradle 캐시 충돌도 CI에서 꽤 자주 겪는다. 로컬에서 멀쩡하던 빌드가 CI에서 클래스파일 충돌이나 타입 오류로 실패하면 대부분 캐시 문제다. --no-daemon --rerun-tasks 로 한 번 돌려보거나, CI 캐시 키를 build.gradle 해시 기반으로 바꿔주면 대부분 해결된다.

환경변수 누락이 제일 찾기 힘든 유형이다. 빌드 단계는 통과하고 앱 기동 시점에서 터지는데, 스택 트레이스가 property is required 같은 직관적인 메시지가 아니라 커넥션 오류나 NPE로 나오면 원인 추적이 한 단계 더 걸린다. Secrets 설정을 배포 PR 체크리스트에 항목으로 박아두는 게 습관이 됐음.

작업 규모 자체는 크지 않았다. 그런데 이런 수정이 제때 안 들어가면 신청 실패 케이스가 쌓이거나, 재시도 로직이 없는 상태에서 데이터 불일치가 남는다. 기능을 새로 만드는 것보다 이런 안정성 수정이 까다로운 이유는, 건드리는 범위를 최소화하면서 효과는 확실히 내야 하기 때문이다. 기존 흐름을 망가뜨리지 않았는지 확인하는 데 구현 자체만큼 시간이 걸리는 게 이쪽 작업의 특성이다.

댓글 0

첫 댓글 달아줘.