Dart http 클라이언트는 POST의 301을 따라가지 않는다: 실측과 해결

Flutter 앱의 검색이 서버의 301 때문에 조용히 실패하고 있었다. Dart 3.11과 http 1.6이 리다이렉트 코드마다 어떻게 동작하는지 curl과 비교하고, 검증한 해결법을 정리했다.

한 복권 앱의 "회차로 검색" 기능이 계속 실패하고 있었습니다. 요청도 서버 코드도 맞아 보였고, curl로는 재현되지 않았습니다. 원인은 빠진 슬래시 하나와, 잊기 쉬운 Dart HTTP 클라이언트의 규칙이었습니다. POST일 때 Dart는 301·302·307·308 리다이렉트를 따라가지 않습니다.

이 글에서는 리다이렉트 코드별 실측 결과, 이 버그가 운영 환경에서 숨어 있던 과정, 그리고 직접 검증한 클라이언트 쪽 해결법을 정리합니다.

운영 환경에서 생긴 일

앱은 이렇게 요청을 보내고 있었습니다.

final response = await _post(
  Uri.parse('$_baseUrl/${type.apiKey}'),          // .../Takarakuji/json/lotto6
  headers: {'Content-Type': 'application/json'},
  body: jsonEncode({'query': 'round', 'round': round}),
);
if (response.statusCode == 404) return null;
_checkStatus(response);                              // 2xx가 아니면 예외

서버에서 lotto6은 디렉토리입니다. Apache와 LiteSpeed는 끝 슬래시 없이 디렉토리를 요청하면 슬래시가 붙은 URL로 301 Moved Permanently를 돌려줍니다. 서버 이전용 회귀 테스트 기준값을 만들 때 이 주고받음이 기록돼 있었습니다.

POST /Takarakuji/json/lotto6    → 301  Location: /Takarakuji/json/lotto6/
POST /Takarakuji/json/lotto6/   → 403  (index 파일 없음, 디렉토리 목록 금지)

즉 문제가 두 개 겹쳐 있었습니다. 서버의 슬래시 붙은 URL에는 처리하는 코드가 없었고, 클라이언트는 그 사실을 알아챌 만큼 가지도 못했습니다. 앱에서는 301이 그대로 _checkStatus로 들어가 흔한 오류로 처리되고 있었습니다.

Dart의 실제 동작: 리다이렉트 코드별

추측을 그만두려고 로컬에 작은 서버를 띄웠습니다. /r301·/r302·/r303·/r307·/r308은 해당 상태 코드와 Location: /final을 돌려주고, /final은 받은 메서드와 본문을 그대로 돌려줍니다. 클라이언트는 Dart 3.11.5 + package:http 1.6.0, 비교 대상은 curl 8.7.1(-L --data)입니다.

코드 Dart GET Dart POST curl -L POST
301 따라감 → 200 301을 그대로 반환 (본문 없음) GET으로 바꿔 따라감 (본문 사라짐)
302 따라감 → 200 302 반환 GET으로 바꿔 따라감
303 따라감 → 200 GET으로 바꿔 따라감 GET으로 따라감
307 따라감 → 200 307 반환 POST 그대로 따라감 (본문 유지)
308 따라감 → 200 308 반환 POST 그대로 따라감

Dart 실행 결과 원문:

301 GET -> 200  GET /final body=''
301 POST -> 301  (empty)
302 POST -> 302  (empty)
303 POST -> 200  GET /final body=''
307 POST -> 307  (empty)
308 POST -> 308  (empty)

눈여겨볼 점은 두 가지입니다.

  • Dart의 규칙은 curl보다도, HTTP 명세의 의도보다도 좁습니다. POST를 안전하게 리다이렉트하라고 만든 307과 308조차 따라가지 않습니다.
  • curl은 문제를 가립니다. -L을 붙이면 curl은 301을 GET으로 따라가서 뭔가를 돌려줍니다. 그래서 POST 엔드포인트를 curl로 점검하면, 앱에는 301만 덩그러니 도착하는데도 그럴듯한 응답을 보게 됩니다.

해결 1: 정확한 URL을 보낸다

가장 싼 해결책은 대개 슬래시 하나입니다('$_baseUrl/${type.apiKey}/'). 다만 그 URL에 서버 쪽 처리 코드가 있어야 합니다. 이번에는 없었기 때문에 슬래시를 붙인 요청도 403이었습니다.

해결 2: 서버가 리다이렉트 없이 응답하게 한다

구버전 앱이 설치돼 있다면 앱이 보내는 URL은 바꿀 수 없습니다. 서버에서 내부 리라이트를 쓰면 슬래시 없는 경로를 리다이렉트 없이 실제 처리 코드로 연결할 수 있습니다.

RewriteCond %{DOCUMENT_ROOT}/api/v1/takarakuji/$1/index.php -f
RewriteRule ^Takarakuji/json/(.*?)/?$ api/v1/takarakuji/$1/index.php [L,QSA]

같은 LiteSpeed 서버의 일회용 폴더에서 이 형태를 실측했습니다. 슬래시 없는 디렉토리 URL로 보낸 POST가 200을 받았고, 처리 코드에는 POST로 도착했습니다. -f 조건에 주목하세요. 처리 코드가 없으면 규칙이 걸리지 않아 기존 동작이 그대로 유지됩니다. 이전 작업 중에는 이 성질이 필요합니다.

참고로 서버의 리다이렉트를 307이나 308로 바꿔도 Dart에서는 해결되지 않습니다(위 표 참고).

해결 3: 클라이언트에서 직접 따라간다

새 버전의 앱에서는 같은 메서드와 본문으로 POST를 다시 보내는 작은 헬퍼를 씁니다. 같은 로컬 서버로 검증했습니다.

import 'package:http/http.dart' as http;

/// POST를 보내고, 301/302/307/308이면 같은 메서드·본문으로 Location에 다시 보낸다.
/// (303은 package:http가 이미 GET으로 따라간다)
Future<http.Response> postFollowing(Uri uri,
    {Map<String, String>? headers, Object? body, int maxHops = 3}) async {
  final client = http.Client();
  try {
    var target = uri;
    for (var hop = 0; hop <= maxHops; hop++) {
      final req = http.Request('POST', target)..followRedirects = false;
      if (headers != null) req.headers.addAll(headers);
      if (body is String) req.body = body;
      final res = await http.Response.fromStream(await client.send(req));
      const redirect = {301, 302, 307, 308};
      final loc = res.headers['location'];
      if (!redirect.contains(res.statusCode) || loc == null) return res;
      target = target.resolve(loc);
    }
    throw http.ClientException('too many redirects', uri);
  } finally {
    client.close();
  }
}
301 POST (helper) -> 200  POST /final body='{"round":1}'
302 POST (helper) -> 200  POST /final body='{"round":1}'
307 POST (helper) -> 200  POST /final body='{"round":1}'
308 POST (helper) -> 200  POST /final body='{"round":1}'

301이나 302 뒤에 POST를 다시 보내면 원래 메서드가 유지됩니다. 이 API에는 그게 필요하지만, 브라우저 동작(GET으로 바뀜)과는 다릅니다. 이 헬퍼는 이번 검색처럼 다시 보내도 안전한(멱등한) 엔드포인트, 그리고 직접 관리하는 API에만 쓰세요.

설치된 앱에서 원인 찾기

현장에서만 기능이 실패할 때 가장 먼저 알아야 할 것은 앱이 실제로 받은 상태 코드입니다. 2xx가 아닌 응답을 Location 헤더와 함께 로그로 남기는 작은 래퍼를 두면, "흔한 오류"가 한 줄짜리 진단으로 바뀝니다.

Future<http.Response> _post(Uri uri, {Map<String, String>? headers, Object? body}) async {
  final res = await _client.post(uri, headers: headers, body: body).timeout(_kTimeout);
  if (res.statusCode >= 300) {
    debugPrint('[api] POST $uri -> ${res.statusCode} '
        'location=${res.headers['location']} bytes=${res.bodyBytes.length}');
  }
  return res;
}

POST …/lotto6 -> 301 location=…/lotto6/ bytes=0 한 줄이면 모든 게 보입니다. 서버가 리다이렉트했고, 클라이언트는 따라가지 않았고, 본문은 비어 있었다는 것입니다. 크래시 수집이나 분석 도구에 사용자 정의 이벤트를 보낼 수 있다면 상태 코드와 경로 패턴을 보내세요(사용자 데이터가 담긴 전체 URL은 빼고). 특정 엔드포인트에서 3xx가 몰리면 서버 쪽 리다이렉트가 원인이라는 것을 바로 알 수 있습니다.

서버 쪽에서는 같은 버그가 로그 두 줄의 조합으로 나타납니다. 슬래시 없는 경로로 온 POST가 301로 기록되고, 그 Location으로 이어지는 요청이 없습니다. 리다이렉트를 따라가는 클라이언트라면 몇 밀리초 안에 두 번째 요청이 남았을 것입니다.

더 일찍 알아채려면

  • 상태 코드는 검사하기 전에 로그로 남긴다. 로그가 없으면 _checkStatus로 들어간 301은 다른 오류와 구별되지 않습니다.
  • POST 엔드포인트는 앱과 같은 클라이언트로 시험한다. curl이 아니라 Dart 테스트나 디버그 빌드에서 호출해 보세요.
  • 리다이렉트를 따라가지 않는 회귀 테스트 기준값을 갖는다. 저희 기준값은 첫 실행에서 POST … → 301을 기록했습니다. 검색이 언제부터 망가져 있었는지는 모릅니다. 기준값이 없었다면 망가졌다는 사실조차 몰랐을 겁니다.

정리

상황 할 일
클라이언트와 URL을 직접 고칠 수 있다 정확한 URL을 보낸다 (끝 슬래시 주의)
구버전 앱이 설치돼 있다 서버에서 내부 리라이트한다. 리다이렉트하지 않는다
직접 운영하는 API를 부르는 새 클라이언트 코드 followRedirects = false로 두고 직접 따라간다
POST에 307/308을 쓰려고 한다 curl과 브라우저는 해결되지만 Dart는 해결되지 않는다

자주 묻는 질문

Dart의 http 패키지는 POST 리다이렉트를 따라가나요?

303만 따라가고, 그때는 GET으로 바뀝니다. 301·302·307·308에서는 POST가 리다이렉트 응답 자체(3xx, 빈 본문)를 돌려줍니다. GET은 다섯 가지 모두 따라갑니다.

서버를 307이나 308로 바꾸면 해결되나요?

Dart에서는 안 됩니다. 307과 308은 curl과 브라우저에서는 메서드를 유지한 채 다시 보내지지만, Dart 클라이언트는 POST의 307/308도 따라가지 않고 그대로 돌려줍니다. URL을 고치거나, 서버에서 내부 리라이트하거나, 클라이언트에서 직접 따라가야 합니다.

curl이나 Postman에서는 되는데 앱에서는 왜 실패하나요?

클라이언트마다 리다이렉트 규칙이 다르기 때문입니다. curl -L은 301/302에서 GET으로 바꾸고, 307/308에서는 POST를 다시 보냅니다. Dart는 POST에서 303 말고는 따라가지 않습니다. 반드시 앱이 실제로 쓰는 클라이언트로 시험하세요.

  • #Dart
  • #Flutter
  • #http
  • #리다이렉트
  • #POST
  • #API

t.laboratory

직접 만들고 운영하는 앱·서버·서비스에서 겪은 문제와, 그 해결에 쓴 코드를 기록하는 기술 블로그입니다.

운영자 소개 · 앱

↑