---
title: "[Lap 03] GCP Keyless ADC 패치와 2560px 이미지 에러 해결"
canonical_url: "https://wealth-podium.com/2026/10/22/track-01-lap-3-keyless-adc-debugging/"
published_at: "2026-10-22T09:00:00+09:00"
modified_at: "2026-11-07T15:44:35+09:00"
author: "Dellwon (Software Developer & System Architect)"
summary: "GCP 조직 정책의 서비스 계정 JSON 키 생성 차단을 Cloud Run 내장 인증(Keyless ADC) 코드 패치로 해결하고, 고해상도 이미지 업로드 시 발생하던 2560px 에러의 두 가지 원인을 찾아내 수정한 디버깅 기록입니다."
---
# [Lap 03] GCP Keyless ADC 패치와 2560px 이미지 에러 해결

> **"서버리스 컨테이너 환경에서는 로컬 디스크가 영구적이지 않기 때문에, 미디어 파일 하나를 올리는 일도 정교한 파이프라인 설계가 필요합니다."**  
> [Lap 02](/2026/10/21/track-01-lap-2-db-tuning-and-cicd/)에서 DB 메모리 다이어트와 무인 CI/CD 배포를 성공적으로 안착시킨 뒤, 첫 글에 넣을 고해상도 아키텍처 다이어그램 이미지를 에디터에 드래그 앤 드롭하는 순간이었습니다. 화면에 붉은색 에러 메시지가 나타났습니다.  
> 
> *"The server cannot process the image. This can happen if the server is busy or does not have enough resources. Suggested maximum size is 2560 pixels."*  
> 
> [Warm-up Lap](/2026/10/19/track-01-lap-0-warm-up-adr-genesis/)에서 예고했던 **'장벽 4: 엔터프라이즈 보안 조직 정책과의 충돌(JSON 키 생성 차단)'**과 워드프레스 5.3+ 썸네일 생성 버그가 정면으로 터져 나온 것입니다. 이번 글에서는 3단계 미디어 업로드 장애의 원인을 하나씩 추적하고, **Keyless ADC 코어 패치(ADR-006)**로 영구 해결한 과정을 공유합니다.

---

## 1. 배경 이해: 무상태(Stateless) 컨테이너와 외부 스토리지 연동

일반적인 가상 서버(EC2나 VPS)에서는 워드프레스에 이미지를 올리면 서버 로컬 디렉터리(`/var/www/html/wp-content/uploads/`)에 파일이 영구 저장됩니다.

하지만 **Google Cloud Run**은 무상태(Stateless) 서버리스 컨테이너입니다.
- 컨테이너 내부의 파일시스템은 메모리에 마운트된 **휘발성(tmpfs/overlay) 공간**입니다.
- 비용을 아끼기 위해 `min-instances: 0`으로 설정했기 때문에, 방문자가 없으면 컨테이너는 자동으로 내려갑니다(Scale-to-Zero).
- 이후 새 방문자가 오거나 새 코드가 배포되면 초기화된 새 컨테이너가 다시 올라옵니다(Cold Start).

즉, 로컬 폴더에 이미지를 그대로 두면 **컨테이너가 재시작되는 순간 이전에 올린 모든 이미지가 404 Not Found(엑스박스)로 깨지게 됩니다.** 그래서 업로드된 미디어를 외부 영구 저장소인 **Google Cloud Storage(GCS)** 버킷으로 실시간 전송해 주는 `WP-Stateless` 플러그인 연동이 꼭 필요했습니다.

그런데 이 플러그인을 Cloud Run에 붙이는 과정에서 세 가지 까다로운 문제와 마주쳤습니다.

---

## 2. 3연속 장애 분석 및 해결 과정 (ADR-006)

제가 겪은 세 가지 문제와 해결 구조를 한눈에 정리한 다이어그램입니다.

![GCP Keyless ADC 패치 및 2560px 이미지 업로드 장애 3단계 해결 아키텍처](/wp-content/diagrams/lap-03-media-fix.png)

```mermaid
flowchart TD
    Upload["사용자 이미지 업로드 (JPG/PNG)"] --> Core["WordPress 5.3+ 이미지 처리 엔진"]
    
    subgraph Bug2 ["이슈 3: 2560px 에러 & 원본 조기 삭제"]
        Core -->|"1. 메모리 급증"| Imagick["Imagick (OpenMP 멀티스레드)<br/>512Mi 메모리 한계 초과 Crash!"]
        Core -->|"2. 썸네일 루프"| Subsizes["_wp_make_subsizes()"]
        Subsizes -->|"첫 번째 서브사이즈 직후"| Premature["WP-Stateless @unlink(원본 로컬 파일)<br/>후속 썸네일 생성 시 원본 소실!"]
    end
    
    subgraph Bug1 ["이슈 1 & 2: 인증 및 권한 충돌"]
        WPStateless["WP-Stateless 플러그인"] -->|"JSON 키 요구"| OrgPolicy["GCP 조직 정책 차단<br/>disableServiceAccountKeyCreation"]
        WPStateless -->|"buckets.get() 호출"| PermDeny["최소 권한 거부<br/>roles/storage.objectAdmin 부족"]
    end

    Imagick -.->|"해결: mu-plugins"| FixGD["WP_Image_Editor_GD 강제 전환<br/>단일 스레드 저메모리 처리"]
    Premature -.->|"해결: mu-plugins"| FixSync["wp_generate_attachment_metadata 완료 시까지<br/>GCS 동기화 지연 & shutdown 일괄 삭제"]
    OrgPolicy -.->|"해결: 빌드 타임 패치"| FixADC["Keyless ADC 주입<br/>useApplicationDefaultCredentials()"]
    PermDeny -.->|"해결: 빌드 타임 패치"| FixList["objects.listObjects() 대체 & ephemeral 모드"]

    FixGD & FixSync & FixADC & FixList ==> Success["gs://wp-media 버킷 안전 저장 완료!"]
```

---

### 💥 이슈 1: GCP 조직 정책의 JSON 키 생성 차단과 Keyless ADC 패치

`WP-Stateless` 플러그인을 활성화하면 설정 화면에서 가장 먼저 **"Google Cloud 서비스 계정 JSON 키를 입력하라"**고 안내합니다.

하지만 GCP 콘솔이나 CLI에서 서비스 계정 키를 발급하려고 하면 다음과 같은 조직 정책 위반 에러가 발생했습니다.

```text
ERROR: (gcloud.iam.service-accounts.keys.create) FAILED_PRECONDITION: 
Precondition check failed.
- One or more organization policies violated:
  constraints/iam.disableServiceAccountKeyCreation: 
  Key creation is disabled for service accounts in this organization.
```

최근 보안이 강화된 GCP 환경에서는 키 파일 유출 사고를 막기 위해 **`disableServiceAccountKeyCreation` 조직 정책으로 JSON 키 생성을 기본 차단**하는 경우가 많습니다. 키를 만들 수 없으니 플러그인 설정란을 비워둘 수밖에 없었고, 플러그인은 즉시 에러를 냈습니다.

```text
Fatal Error: Service Account JSON is invalid.
```

#### 제가 선택한 해결책: Docker 빌드 타임 Keyless ADC 코드 패치
사실 Cloud Run 위에서 돌아가는 컨테이너는 굳이 외부 JSON 키 파일이 필요하지 않습니다. Cloud Run 자체가 이미 실행 서비스 계정(`Compute SA`)의 권한을 내장하고 있어, 내부 메타데이터 서버를 통해 인증 토큰을 자동으로 받아오는 **ADC(Application Default Credentials)**를 지원하기 때문입니다.

문제는 `WP-Stateless` 플러그인 코드가 JSON 키 문자열이 비어 있으면 무조건 에러를 던지도록 짜여 있었다는 점입니다. 그래서 저는 Docker 이미지를 빌드할 때 플러그인의 PHP 코드를 자동으로 수정하는 패치 스크립트(`scripts/patch-wp-stateless-adc.php`)를 작성했습니다.

```php
// scripts/patch-wp-stateless-adc.php 발췌
// WP-Stateless의 class-gs-client.php에서 JSON 키가 없으면 Cloud Run 내장 ADC를 사용하도록 패치
$gsClientPatched = str_replace(
    '$this->client->setAuthConfig($this->key_json);',
    'if (!empty($this->key_json) && is_array($this->key_json) && !empty($this->key_json[\'private_key\'])) {' . "\n" .
    '            $this->client->setAuthConfig($this->key_json);' . "\n" .
    '          } else {' . "\n" .
    '            // JSON 키가 비어있으면 Cloud Run 내장 ADC 인증 사용' . "\n" .
    '            $this->client->useApplicationDefaultCredentials();' . "\n" .
    '          }',
    $gsClientCode
);
```

이 스크립트를 `Dockerfile` 빌드 단계에서 실행하도록 넣어두니, 물리적인 JSON 키 파일을 하나도 만들지 않고도 안전하게 GCS 인증을 통과할 수 있었습니다.

---

### 💥 이슈 2: 최소 권한 원칙과 `Unable to create directory` 충돌

인증 문제를 넘기자마자 이번에는 버킷 연결 확인과 업로드 폴더 생성 단계에서 권한 에러가 발생했습니다.

```text
Could not connect to Google Storage bucket: AccessDenied
Unable to create directory /wp-content/uploads/2026/10. Is its parent directory writable by the server?
```

코드를 열어 원인을 확인해 보니 두 가지 문제가 있었습니다.

1. **과도한 버킷 메타데이터 조회 권한 요구**:  
   `WP-Stateless`는 버킷 연결 상태를 확인하기 위해 `$this->service->buckets->get($this->bucket)` API를 호출합니다. 하지만 제가 서비스 계정에 부여한 권한은 최소 권한 원칙에 따른 **`Storage Object Admin(객체 관리자)`**이었습니다. 파일(Object)을 읽고 쓸 권한은 충분하지만, 버킷 자체의 설정 정보를 조회(`storage.buckets.get`)할 권한은 포함되어 있지 않아 거부된 것입니다.
2. **스트림 래퍼의 디렉터리 확인 충돌**:  
   GCS 스트림 래퍼가 업로드 경로를 생성할 때마다 버킷 존재 여부를 다시 조회하면서 디렉터리 생성 실패로 이어졌습니다.

#### 해결책: `objects->listObjects()` 대체 및 `ephemeral` 모드 적용
서비스 계정에 과도한 전체 관리자 권한을 주는 대신, 패치 스크립트에서 버킷 연결 확인 코드를 `Object Admin` 권한만으로도 실행 가능한 **`objects->listObjects(['maxResults' => 1])`** 호출로 바꾸었습니다.

```php
// 버킷 메타데이터 조회 대신 객체 목록 1건 조회로 연결 테스트 수행
$gsClientPatched = str_replace(
    '$this->service->buckets->get($this->bucket);',
    '$this->service->objects->listObjects($this->bucket, array(\'maxResults\' => 1));',
    $gsClientPatched
);
```

그리고 환경변수로 **`WP_STATELESS_MEDIA_MODE=ephemeral`**을 설정했습니다. `ephemeral` 모드를 사용하면 업로드 시 컨테이너 로컬 폴더에 임시 파일을 만들어 이미지 리사이징을 마친 뒤 GCS로 전송하고 로컬 파일을 지워주므로, 스트림 래퍼의 디렉터리 생성 충돌을 깔끔하게 피할 수 있습니다.

---

### 💥 이슈 3: 고해상도 이미지 업로드 시 발생하는 `2560px` 에러와 원본 조기 삭제 버그

여기까지 패치하자 작은 아이콘 이미지(400x300)는 GCS 버킷에 잘 올라갔습니다. 그런데 실제 본문에 넣을 고해상도 스크린샷(3840x2160)을 올리자 다시 에러가 떴습니다.

```text
The server cannot process the image. This can happen if the server is busy or does not have enough resources. 
Suggested maximum size is 2560 pixels.
```

보통 워드프레스에서 이 메시지를 보면 PHP `memory_limit` 부족을 의심하곤 합니다. 혹시 여러분도 이 에러 때문에 `php.ini`의 메모리 설정만 한참 늘려보신 적이 있나요? 저도 처음엔 `memory_limit = 512M`으로 올려보았지만 소용이 없었습니다.

직접 워드프레스 코어의 이미지 처리 함수와 플러그인 훅 실행 순서를 따라가며 디버깅해 본 결과, 진짜 원인은 다른 두 곳에 있었습니다.

#### 원인 A: Cloud Run `512Mi` 환경에서 `Imagick`의 멀티스레드 메모리 급증
워드프레스가 기본으로 사용하는 이미지 처리 라이브러리는 `Imagick`(ImageMagick)입니다. 그런데 ImageMagick은 OpenMP를 통해 이미지 리사이징 시 여러 스레드로 병렬 처리를 시도합니다.  
Cloud Run 컨테이너(`1 vCPU`, `512MiB RAM`, gVisor 샌드박스) 환경에서 4K 해상도 이미지를 `Imagick`으로 열면 순간적으로 메모리 할당량이 급증하면서 리사이징 도중 프로세스가 비정상 종료되고 있었습니다.

#### 원인 B: WordPress 5.3+ 썸네일 생성 루프와 `WP-Stateless`의 조기 삭제(`@unlink`) 충돌
더 결정적인 원인은 워드프레스 코어와 `WP-Stateless` 사이의 타이밍 문제였습니다.
1. 워드프레스 5.3 이상부터는 가로/세로 2560px을 넘는 큰 이미지가 올라오면 `_wp_make_subsizes()` 함수가 실행되어 원본을 `scaled` 이미지로 줄이고, 6~7개의 서브사이즈 썸네일(thumbnail, medium, large 등)을 루프를 돌며 하나씩 만듭니다.
2. 이때 워드프레스 코어는 **썸네일 1개를 만들 때마다 중간 진행 상태를 DB에 기록하려고 `wp_update_attachment_metadata` 필터를 매번 호출**합니다.
3. 그런데 `WP-Stateless`의 `ephemeral` 모드는 이 필터가 호출되자마자 **"메타데이터 업데이트가 끝났으니 GCS로 파일을 올리고 로컬 원본 파일을 삭제(`@unlink`)해야겠다"**라고 판단해 버립니다.
4. 결국 **첫 번째 작은 썸네일 하나만 만들어진 상태에서 로컬 디스크의 원본 이미지가 삭제**되어 버리고, 두 번째 썸네일(medium)을 만들려던 워드프레스 코어는 원본 파일이 사라진 것을 보고 `2560 pixels` 에러를 뱉으며 업로드를 중단했던 것입니다.

---

## 3. 최종 해결 코드: `mu-plugins/cloudrun.php` 3단계 제어

원인을 정확히 찾았으니 해결은 명확했습니다. 테마나 플러그인이 업데이트되어도 초기화되지 않도록 필수 플러그인(`mu-plugins/cloudrun.php`)에 아래 제어 로직을 작성해 넣었습니다.

```php
// 1. Imagick 메모리 급증 방지: 단일 스레드 기반의 가벼운 WP_Image_Editor_GD 강제 사용
add_filter('wp_image_editors', function () {
    return array('WP_Image_Editor_GD');
}, 99);

$GLOBALS['cloudrun_generating_subsizes'] = array();
$GLOBALS['cloudrun_cleanup_paths'] = array();

// 2. 대용량 이미지 서브사이즈 생성 루프 시작 감지
add_filter('big_image_size_threshold', function ($threshold, $imagesize, $file, $attachment_id) {
    if ($attachment_id) {
        $GLOBALS['cloudrun_generating_subsizes'][$attachment_id] = true;
    }
    if ($file) {
        $GLOBALS['cloudrun_cleanup_paths'][] = $file;
    }
    return $threshold;
}, 1, 4);

// 3. 썸네일 루프가 도는 동안에는 WP-Stateless의 조기 업로드 및 로컬 파일 삭제(@unlink) 보류
add_filter('wp_stateless_skip_remove_media', '__return_true', 99);
add_filter('wp_stateless_skip_add_media', function ($skip, $metadata, $attachment_id) {
    if ($attachment_id && !empty($GLOBALS['cloudrun_generating_subsizes'][$attachment_id])) {
        return true;
    }
    return $skip;
}, 99, 3);

// 4. 모든 썸네일 생성이 완전히 끝난 시점(wp_generate_attachment_metadata)에 일괄 GCS 동기화
add_filter('wp_generate_attachment_metadata', function ($metadata, $attachment_id) {
    if ($attachment_id && isset($GLOBALS['cloudrun_generating_subsizes'][$attachment_id])) {
        unset($GLOBALS['cloudrun_generating_subsizes'][$attachment_id]);
    }
    $full_path = get_attached_file($attachment_id);
    if ($full_path) {
        $GLOBALS['cloudrun_cleanup_paths'][] = $full_path;
        $base_dir = dirname($full_path);
        if (!empty($metadata['sizes']) && is_array($metadata['sizes'])) {
            foreach ($metadata['sizes'] as $size_data) {
                if (!empty($size_data['file'])) {
                    $GLOBALS['cloudrun_cleanup_paths'][] = $base_dir . '/' . $size_data['file'];
                }
            }
        }
    }
    return $metadata;
}, 999, 2);

// 5. HTTP 응답이 끝나는 shutdown 시점에 로컬 임시 파일들을 안전하게 정리
add_action('shutdown', function () {
    if (!empty($GLOBALS['cloudrun_cleanup_paths'])) {
        foreach (array_unique($GLOBALS['cloudrun_cleanup_paths']) as $path) {
            if ($path && file_exists($path)) {
                @unlink($path);
            }
        }
    }
});
```

### 적용 후 달라진 점
- **GD 라이브러리 전환**: 4K 이상의 고해상도 PNG/JPG 이미지도 60MB 미만의 메모리만 사용하며 빠르게 리사이징됩니다.
- **조기 삭제 버그 해결**: 모든 크기의 썸네일이 다 만들어질 때까지 원본 파일이 보존된 후 한 번에 GCS로 동기화되어, 2560px 에러가 완전히 사라졌습니다.
- **메모리 누수 방지**: 요청이 종료(`shutdown`)될 때 컨테이너 내부의 임시 파일들을 깔끔하게 지워 컨테이너의 가용 메모리를 지킵니다.

---

## 4. 마치며: 다음 글(Lap 4) 예고

처음에는 단순한 이미지 업로드 에러처럼 보였지만, 그 안에는 **GCP 조직 정책과 Keyless ADC 인증, 최소 권한 IAM, 그리고 워드프레스 코어 훅의 실행 순서**라는 꽤 깊은 아키텍처 이슈들이 얽혀 있었습니다.

이제 미디어 파이프라인까지 안정적으로 동작하게 되었으니, 다음 단계에서는 외부 도메인을 연결할 때 마주친 `:8080` 포트 노출 문제와 무차별적으로 날아오는 자동화 해킹 봇들을 막아낸 **Cloudflare WAF 및 컨테이너 5중 보안 설정(Lap 4)**을 살펴보겠습니다.
