지도 버그, SPA 404, 시크릿·GraphQL 보안, 결제 도메인 테스트

궁금하면 500원·2026년 7월 12일

미생의 개발 이야기

목록 보기
92/92

포트폴리오나 사이드 프로젝트를 오래 관리하다 보면, 서비스는 돌아가지만 구조적으로 아쉬운 상태가 자주 남게 됩니다. IdolGlow 역시 마찬가지의 고민을 안고 있었습니다.

관리자 상품 편집 화면에서 서울 관광지를 선택했음에도 지도 마커가 엉뚱한 곳에 표시되는 버그가 있었고, 프론트엔드 라우트 주소에서 새로고침을 하면 404 에러가 발생하곤 했습니다.

백엔드 설정 파일에는 OAuth 정보와 API 키가 평문으로 노출되어 있었으며, 테스트용 결제 웹훅 mutation은 아무런 인증 없이 호출이 가능한 상태였습니다.

서비스의 핵심인 결제와 예약 로직에 단위 테스트가 거의 없다는 점도 큰 공백이었습니다.

이번 글에서는 이러한 문제들의 원인을 분석하고 코드를 어떻게 개선했는지 그 과정과 이유를 공유하고자 합니다.


지도 마커의 위치 왜곡 문제 해결

증상 및 원인 분석

관리자 상품 페이지에서 구로구 호텔을 비롯한 서울 지역 데이터를 선택했을 때, 카카오맵 마커가 뜬금없이 전혀 다른 지역을 가리키는 현상이 있었습니다.

관광 API에서 받아오는 좌표 값을 카카오맵 규칙에 맞춰 올바르게 변환하고 있었기 때문에 데이터 자체에는 문제가 없었습니다.

진짜 원인은 지도의 초기 중심점 좌표가 예전에 테스트용으로 입력해 둔 서울 지역으로 하드코딩되어 있었다는 점이었습니다.

뷰포트 중심은 남해안에 고정되어 있는데 마커는 서울에 찍히다 보니, 화면 밖으로 벗어난 마커를 인지하지 못해 발생한 레이아웃 인지 오류였습니다.

// Before: 테스트용 초기 위치가 그대로 남아 있던 코드
const GLOW_MAP_CENTER = { lat: 35.74028, lng: 127.73596 }; // 서울

개선 코드 및 교훈

이 문제를 해결하기 위해 먼저 지도의 기본 중심점을 서울 시청 근처로 변경하여 접근성을 높였습니다.
이에 그치지 않고, 마커 데이터가 처음 로드될 때 모든 마커를 포괄할 수 있도록 카카오맵의 LatLngBounds 객체를 활용해 지도의 영역을 자동으로 조정하는 setBounds 로직을 추가했습니다.

// After: 데이터 기반으로 경계면을 자동 계산하도록 개선
const GLOW_MAP_CENTER = { lat: 37.5665, lng: 126.978 }; // 서울 시청

const bounds = new kakao.maps.LatLngBounds();
markers.forEach((m) => bounds.extend(new kakao.maps.LatLng(m.lat, m.lng)));
map.setBounds(bounds);

이 과정을 통해 지도 관련 기능을 구현할 때는 단순한 좌표 변환 로직만 검증할 것이 아니라, 초기 중심점이나 줌 레벨, 그리고 화면 경계선이 동적으로 잘 갱신되고 있는지 함께 살펴야 한다는 교훈을 얻었습니다.
임시로 고정해 둔 더미 데이터가 프로덕션 환경까지 이어지지 않도록 주의해야 합니다.


SPA 라우트 새로고침 시 404 에러 해결

증상 및 원인 분석

Vite와 React Router를 활용한 싱글 페이지 애플리케이션 환경에서 관리자 상품 경로인 /admin/products로 진입한 후 브라우저 새로고침을 누르면 404 페이지가 뜨거나 백엔드의 에러 JSON 응답이 화면을 덮는 문제가 있었습니다.

이는 로컬 개발 서버의 Vite 프록시 설정이 브라우저의 HTML 문서 내비게이션 요청까지 백엔드로 그대로 넘겨버렸기 때문입니다.

클라이언트 사이드 라우팅을 통할 때는 브라우저 주소만 바뀔 뿐이지만, 주소창에 직접 입력하거나 새로고침을 하면 브라우저가 서버에 직접 GET 요청을 보냅니다.

이때 프론트엔드 라우트와 백엔드 API 경로가 유사한 프리픽스를 공유하고 있어서 백엔드 애플리케이션이 이를 처리하려다 실패한 것입니다.

개선 코드 및 동작 구분

이를 해결하기 위해 vite.config.ts 파일에 bypassSpaNavigation 함수를 도입하여 요청을 가려내도록 설정했습니다.

브라우저의 문서 요청일 때는 백엔드로 프록시하지 않고 프론트엔드의 index.html로 fallback 처리를 해주는 방식입니다.

/**
 * 브라우저 문서 내비게이션은 백엔드로 프록시하지 않고
 * SPA로 폴백시킨다. 
 `/admin/*`, `/products/*` 처럼 프론트 라우트와
 * 백엔드 API 경로가 겹치는 경우, 새로고침 시 백엔드 JSON이 떠서 앱이 안 뜨는 문제 방지.
 * XHR/fetch API 호출(Accept가 text/html이 아님)은 그대로 백엔드로 프록시된다.
 */
function bypassSpaNavigation(req: {
  method?: string;
  headers: Record<string, string | string[] | undefined>;
}): string | undefined {
  const accept = String(req.headers?.accept ?? "");
  const method = (req.method ?? "GET").toUpperCase();
  if (method === "GET" && accept.includes("text/html")) {
    return "/index.html";
  }
  return undefined;
}

생성한 함수는 아래와 같이 Vite 프록시 구성의 bypass 옵션에 연결해 주었습니다.

const proxy = Object.fromEntries(
  BACKEND_PROXY_PREFIXES.map((prefix) => [
    prefix,
    { target: backendTarget, changeOrigin: true, bypass: bypassSpaNavigation },
  ]),
);

이 설정을 거치면 주소창 입력이나 새로고침처럼 Accept 헤더에 text/html이 포함된 요청은 프론트엔드의 index.html 파일이 담당하게 되며, 일반적인 fetch나 XHR 같은 비동기 API 호출은 정상적으로 백엔드 서버에 프록시되어 안정적인 개발 환경을 유지할 수 있게 됩니다.
프로덕션 환경의 historyApiFallback과 유사한 처리가 로컬 개발 서버의 프록시 레벨에서도 반드시 필요함을 체감했습니다.


설정 파일 내 평문 시크릿을 환경 변수로 격리

문제점 인식

기존의 application-select.yml 파일에는 구글 OAuth 시크릿 키나 Jasypt 마스터 비밀번호, 공공데이터 API 키 등의 민감 정보가 평문으로 기록되어 있었습니다.
이대로 소스코드 저장소에 커밋되면 영구적인 보안 사고로 이어지며, 향후 키를 교체하기도 매우 까다로워집니다.

개선 방향 및 구현

보안 강화를 위해 우선 yml 파일에 직접 적혀 있던 실측값들을 모두 제거하고 ${ENV_VAR:} 형태로 변경하여 환경 변수를 주입받도록 구조를 바꿨습니다.
또한 팀원들이나 배포 환경에서 참고할 수 있도록 backend/.env.example 파일을 만들어 필요한 변수 목록을 문서화했습니다.

# application-select.yml 내부의 구글 OAuth 및 웹훅 설정 예시
      registration:
        google:
          client-id: ${GOOGLE_CLIENT_ID:}
          client-secret: ${GOOGLE_CLIENT_SECRET:}

payment:
  mock:
    webhook-secret: ${MOCK_PAYMENT_WEBHOOK_SECRET:}

특히 암호화를 담당하는 Jasypt 마스터 키의 경우 yml 파일에 주석으로 정책을 명시하고, 구동 시 필수 환경 변수가 누락되었다면 애플리케이션 빌드 및 실행이 즉시 차단되도록 JasyptConfig 클래스에 엄격한 예외 처리 로직을 구현했습니다.

// JasyptConfig.kt 내부의 환경 변수 검증 로직
val encryptKey =
    readDirectProperty("JASYPT_ENCRYPTOR_PASSWORD")
        ?: readDirectProperty("jasypt.encryptor.password")
        ?: readSpringProperty("jasypt.encryptor.password")
        ?: error(
            "Jasypt 비밀번호가 없습니다. " +
                "환경변수 JASYPT_ENCRYPTOR_PASSWORD를 설정하거나, " +
                "JVM 옵션 -Djasypt.encryptor.password=... 를 전달하거나, " +
                "로컬 시크릿 설정 파일에 jasypt.encryptor.password 값을 넣어주세요.",
        )

더불어 프로젝트 루트에 배포한 .env.example 파일은 다음과 같이 구성하여 실제 자격 증명이 노출되지 않는 가이드라인 역할을 하도록 만들었습니다.
만약 기존 커밋 히스토리에 민감한 키가 한 번이라도 포함되었다면 코드에서 지우는 것만으로는 안전하지 않으므로, 반드시 클라우드 콘솔에 접속하여 해당 키들을 리보크하고 로테이션해야 함을 명심해야 합니다.

# .env.example 파일 예시
JASYPT_ENCRYPTOR_PASSWORD=
GOOGLE_CLIENT_ID=
GOOGLE_CLIENT_SECRET=
MOCK_PAYMENT_WEBHOOK_SECRET=

GraphQL API 인가 제어 및 보안 강화

문제점 인식

가상으로 결제 성공과 실패를 테스트할 수 있는 mockPaymentWebhook GraphQL mutation이 존재했습니다.
내부적으로 비록 웹훅 시크릿을 비교하는 로직이 들어있긴 했으나, API 엔드포인트 자체에 아무런 권한 제어가 없다는 점은 위험했습니다.
누구나 무차별 대입 공격을 시도하거나 인트로스펙션기능을 통해 스키마 구조를 쉽게 파헤칠 수 있는 취약점이 존재했습니다.

개선 대책 및 한계점

이 문제를 해결하기 위해 첫 번째 방어선으로 GraphQL 컨트롤러 클래스 상단에 Spring Security의 @PreAuthorize("hasRole('ADMIN')") 어노테이션을 부여했습니다.
이로써 외부의 부적절한 요청은 사전에 차단하고, 권한을 가진 관리자 요청에 한해서만 내부 웹훅 시크릿 검증 로직이 작동하는 이중 보안 구조를 형성했습니다.

@Controller
@PreAuthorize("hasRole('ADMIN')")
class PaymentGraphQlController(
    private val reservationPaymentService: ReservationPaymentService,
    @Value("\${payment.mock.webhook-secret}")
    private val webhookSecret: String,
) {
    @MutationMapping
    fun mockPaymentWebhook(
        @Argument secret: String,
        @Argument @Valid input: MockPaymentWebhookRequest,
    ): PaymentGraphQlResponse {
        require(secret == webhookSecret) { "웹훅 시크릿이 올바르지 않습니다." }
        // ...

두 번째 방어선으로 운영 환경 설정 파일인 application-prod.yml에서는 웹 브라우저 UI 툴인 GraphiQL과 GraphQL 스키마 구조를 외부로 노출하는 인트로스펙션 기능을 모두 비활성화 처리했습니다.

  graphql:
    graphiql:
      enabled: false
    schema:
      introspection:
        enabled: false

다만 현재 SecurityConfig 환경에서 /graphql 엔드포인트 자체는 HTTP 웹 계층에서 permitAll()로 열려 있는 상태입니다.
메서드 보안을 통해 핵심 mutation을 격리하긴 했으나, 진정한 의미의 보안을 달성하기 위해서는 운영 환경에서 특정 IP 대역만 접근을 허용하거나 WAF 설정을 추가하는 등 거시적인 네트워크 인프라 보안을 결합해 나가야 합니다.


결제 및 예약 핵심 도메인 단위 테스트 구축

기존에는 코틀린으로 작성된 비즈니스 코드의 양에 비해 테스트 코드가 턱없이 부족한 상태였습니다.
결제와 예약은 금전적 손실 및 데이터 정합성과 직결되는 가장 민감한 영역이기에, 관점을 접목하여 순수 도메인 레이어부터 애플리케이션 서비스 레이어까지 촘촘하게 검증하기로 했습니다.

도메인 엔티티 검증

가장 먼저 예약 슬롯의 선점 비즈니스 규칙을 검증했습니다.
이미 예약이 완료된 슬롯이거나 다른 사용자가 먼저 선점하여 아직 만료 시간이 지나지 않은 슬롯에 대해 선점 요청이 들어왔을 때, 정확하게 예외를 던지는지 확인하는 시나리오를 구성했습니다.

// ReservationSlot 내부의 선점 로직
fun hold(
    reservationId: Long,
    expiresAt: LocalDateTime,
    now: LocalDateTime = LocalDateTime.now(),
) {
    clearExpiredHold(now)
    if (isStatusBooked) {
        throw IllegalStateException("이미 예약된 슬롯입니다.")
    }
    if (isHeld(now) && holdReservationId != reservationId) {
        throw IllegalStateException("이미 선점 중인 슬롯입니다.")
    }
    holdReservationId = reservationId
    holdExpiresAt = expiresAt
}

이어서 결제 도메인에서는 멱등성 보장과 상태 전이 규칙을 테스트했습니다.
대기 상태에서만 승인이 가능하도록 제어하되, 외부 PG사의 웹훅 중복 전송에 대비하여 이미 성공상태로 바뀐 결제 건에 대해서는 예외를 터뜨리지 않고 부드럽게 리턴되도록 설계한 부분이 정상 작동하는지 확인했습니다.

// Payment 내부의 상태 변경 로직
fun markSucceeded(approvedAt: LocalDateTime = LocalDateTime.now()) {
    if (status == PaymentStatus.SUCCEEDED) {
        return
    }
    require(status == PaymentStatus.PENDING) { "대기 중인 결제만 승인할 수 있습니다." }
    status = PaymentStatus.SUCCEEDED
}

애플리케이션 오케스트레이션 검증

비즈니스 규칙 위에 위치한 애플리케이션 서비스 계층에서는 MockK 라이브러리를 활용해 영속성 포트를 가짜 객체로 대체하고 흐름 제어에 집중했습니다.
결제가 최종 성공했을 때 데이터베이스 락을 활용해 예약을 안전하게 확정하고 안내 메일 발송 서비스가 정확히 1회 호출되는지, 반대로 결제 실패나 만료된 예약 건에 대해서는 그에 맞는 취소 플로우와 알림이 전개되는지 검증했습니다.

@Test
fun `결제 성공 시 예약을 확정한다`() {
    val fixture = pendingFixture()
    every { paymentRepository.findByPaymentReferenceForUpdate("pay_ok") } returns fixture.payment
    every { reservationRepository.findByIdForUpdate(1L) } returns fixture.reservation
    every { reservationCommandService.confirmReservation(1L) } answers {
        fixture.reservation.confirm()
        fixture.reservation
    }

    val result = service.handlePaymentSucceeded("pay_ok")

    assertEquals(PaymentStatus.SUCCEEDED, result.status)
    verify(exactly = 1) { reservationCommandService.confirmReservation(1L) }
    verify(exactly = 1) { paymentNotificationMailService.sendSucceeded(fixture.payment) }
}

이러한 전개 방식을 통해 가벼운 순수 코틀린 도메인 단위 테스트로 코어 로직의 무결성을 먼저 다지고, MockK를 이용해 서비스 계층을 명세화하는 효율적인 테스트 전략을 구축할 수 있었습니다.
추후 인프라가 결합된 MockMvc나 GraphQL 통합 테스트 레이어까지 점진적으로 확장해 나갈 계획입니다.


마무리

이번 개선 작업은 새로운 기능을 추가하는 생산성 중심의 작업이라기보다는, 이미 존재하던 레거시 성격의 코드를 보완하여 견고하고 신뢰할 수 있는 시스템으로 다듬는 과정이었습니다.
프론트엔드 영역에서는 사용자가 마주하는 즉각적인 UX 결함인 지도 마커와 화면 새로고침 버그를 해소했으며, 백엔드 영역에서는 평문 자격 증명 격리, GraphQL 접근 제어, 테스트 자동화를 통한 회귀 방지 등 전반적인 방어력을 높이는 데 초점을 맞추었습니다.
사이드 프로젝트라 할지라도 실무 프로덕션 관점에서 깊게 고민하다 보니 고쳐야 할 엔지니어링 과제들이 계속해서 보이기 시작합니다.
다음에는 가이드 문서와 실제 환경 변수 간의 정합성을 맞추는 일이나 여러 패키지 매니저의 혼용으로 인해 파편화된 잠금 파일들을 정리하는 일에 도전해 보려 합니다.

profile
레거시를 이해하면서도 새로운 기술을 현실적으로 적용할 수 있는 백엔드 개발자가 되는 것이 목표입니다.

0개의 댓글