S
STONI
GraphDB
Neo4j
PHP
Rust
Code Analysis
Dependency
AWS

GraphDB로 코드 구조 시각화 하기 (2/4): PHP 의존성 분석 도구 구축 실습

시리즈 연결

  • Part 1: Neo4j와 프로그램 분석의 만남
  • Part 2: PHP 의존성 분석 도구 구축 실습 (현재 글)
  • Part 3: AWS 환경에서 Neo4j 배포와 파이프라인 구축
  • Part 4: 고급 시각화와 프로덕션 운영
  • Part 5: Cypher 쿼리 언어 완벽 가이드

개요

Part 1에서 GraphDB의 기본 개념과 Neo4j를 학습했습니다. 이번 Part 2에서는 실제 레거시 PHP 프로젝트를 대상으로 파일 간 의존성을 분석하고 Neo4j에 저장하는 도구를 직접 구축합니다.

대규모 PHP 프로젝트에서 수천 개의 파일이 복잡하게 얽힌 include, require 관계를 파악하는 것은 리팩토링과 마이그레이션의 첫 단계입니다. GraphDB를 활용하면 이러한 의존성을 효과적으로 시각화하고 분석할 수 있습니다.

학습 목표

  1. Rust로 고성능 PHP 파일 스캐너 구현
  2. 정규표현식 기반 의존성 추출 로직
  3. Neo4j 그래프 모델링과 Cypher 활용
  4. 실전 의존성 분석 쿼리 패턴
  5. AWS 환경 배포를 위한 설계 고려사항

1. 아키텍처 설계

1.1 시스템 구성도

graph TD
    A[PHP Project<br/>Source Files] --> B[Rust Scanner<br/>Analyzer]
    B --> C[Neo4j DB<br/>Graph DB]
    C --> D[Cypher<br/>Queries]
    
    style A fill:#e1f5ff,stroke:#01579b,stroke-width:2px
    style B fill:#fff3e0,stroke:#e65100,stroke-width:2px
    style C fill:#f3e5f5,stroke:#4a148c,stroke-width:2px
    style D fill:#e8f5e9,stroke:#1b5e20,stroke-width:2px

1.2 데이터 플로우

  1. 파일 스캔: 프로젝트 디렉토리를 재귀적으로 탐색
  2. 패턴 추출: 정규표현식으로 include_once 구문 파싱
  3. 그래프 생성: Neo4j에 노드(파일)와 관계(의존성) 저장
  4. 분석 및 시각화: Cypher 쿼리로 의존성 패턴 분석

1.3 기술 스택 선정 근거

Rust 선택 이유

  • 성능: 수만 개 파일도 빠르게 처리 (C++ 수준 성능)
  • 안전성: 컴파일 타임에 메모리 안전성 보장
  • 비동기 처리: Tokio를 통한 효율적인 I/O 처리
  • 크로스 플랫폼: AWS Lambda, ECS, EC2 어디서든 배포 가능

Neo4j 선택 이유

  • 관계 최적화: 의존성 그래프 순회에 최적화된 성능
  • Cypher 언어: 직관적인 그래프 쿼리
  • ACID 보장: 데이터 일관성 보장
  • 확장성: AWS Neptune으로 마이그레이션 가능

2. 스캐너 구현

2.1 프로젝트 셋업

# Rust 프로젝트 생성
cargo new php-dependency-scanner
cd php-dependency-scanner

# Cargo.toml 의존성 추가
[package]
name = "php-dependency-scanner"
version = "0.1.0"
edition = "2021"

[dependencies]
neo4rs = "0.7"           # Neo4j 클라이언트
tokio = { version = "1", features = ["full"] }
regex = "1.10"           # 정규표현식
walkdir = "2"            # 디렉토리 순회
serde = { version = "1.0", features = ["derive"] }
config = "0.14"          # 설정 파일 관리

2.2 핵심 코드 구현

use neo4rs::*;
use regex::Regex;
use std::fs;
use std::path::{Path, PathBuf};
use walkdir::WalkDir;

#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
    // ⚠️ 보안: 환경변수에서 민감정보 로드
    let neo4j_uri = std::env::var("NEO4J_URI")
        .unwrap_or_else(|_| "neo4j://127.0.0.1:7687".to_string());
    let neo4j_user = std::env::var("NEO4J_USER")
        .unwrap_or_else(|_| "neo4j".to_string());
    let neo4j_password = std::env::var("NEO4J_PASSWORD")?;
    
    // Neo4j 연결 (TLS 권장)
    let graph = Graph::new(&neo4j_uri, &neo4j_user, &neo4j_password).await?;
    
    // 기존 데이터 초기화 (선택적)
    graph.run(query("MATCH (n) DETACH DELETE n")).await?;
    
    // include_once 패턴 정의
    let re = Regex::new(r#"include_once\s*\(\s*([^)]+)\s*\)"#)?;
    
    // ⚠️ 보안: 스캔 디렉토리 검증
    let project_dir = std::env::var("PROJECT_DIR")
        .unwrap_or_else(|_| "/path/to/php/project".to_string());
    let project_path = Path::new(&project_dir).canonicalize()?;
    
    let mut dependency_count = 0;
    
    // 모든 PHP 파일 순회
    for entry in WalkDir::new(&project_path)
        .into_iter()
        .filter_map(|e| e.ok()) 
    {
        // .php 파일만 처리
        if entry.path().extension().and_then(|s| s.to_str()) == Some("php") {
            // ⚠️ 보안: 경로 검증 (경로 순회 공격 방지)
            if !is_safe_path(entry.path(), &project_path) {
                continue;
            }
            
            if let Ok(content) = fs::read_to_string(entry.path()) {
                let source = entry.path()
                    .strip_prefix(&project_path)?
                    .to_str()
                    .unwrap();
                
                // 모든 include_once 매칭
                for cap in re.captures_iter(&content) {
                    let target = cap[1].trim_matches(|c| c == '"' || c == '\'').trim();
                    
                    // ⚠️ 보안: 입력 검증
                    if !is_valid_php_path(target) {
                        continue;
                    }
                    
                    // 공통 파일 제외
                    if target != "./_common.php" {
                        // ⚠️ 보안: 파라미터 바인딩 사용 (Cypher injection 방지)
                        graph.run(query(
                            "MERGE (s:PHPFile {path: $source}) 
                             MERGE (t:PHPFile {path: $target}) 
                             MERGE (s)-[:INCLUDES]->(t)"
                        )
                        .param("source", source)
                        .param("target", target))
                        .await?;
                        
                        dependency_count += 1;
                    }
                }
            }
        }
    }
    
    println!("✓ Saved {} dependencies to Neo4j", dependency_count);
    Ok(())
}

// ⚠️ 보안 함수: 경로 순회 공격 방지
fn is_safe_path(path: &Path, base: &Path) -> bool {
    path.canonicalize()
        .map(|p| p.starts_with(base))
        .unwrap_or(false)
}

// ⚠️ 보안 함수: PHP 파일 경로 검증
fn is_valid_php_path(path: &str) -> bool {
    // 위험한 패턴 차단
    !path.contains("..") &&           // 디렉토리 순회
    !path.starts_with("/") &&         // 절대 경로
    !path.contains("://") &&          // URL 스키마
    path.len() < 500 &&               // 경로 길이 제한
    path.chars().all(|c| {            // 허용된 문자만
        c.is_alphanumeric() || 
        c == '/' || c == '.' || c == '_' || c == '-'
    })
}

2.3 코드 상세 분석

정규표현식 패턴

let re = Regex::new(r#"include_once\s*\(\s*([^)]+)\s*\)"#)?;

패턴 구조:

  • include_once: 리터럴 매칭
  • \s*: 공백 0개 이상 (유연한 코딩 스타일 지원)
  • \( / \): 괄호 매칭 (이스케이프 필요)
  • ([^)]+): 캡처 그룹 - 괄호 내부 내용 (파일 경로)

매칭 예시:

include_once("config.php");                    // ✓
include_once( "./utils/helper.php" );          // ✓
include_once(    DIR_SYSTEM . 'startup.php');  // ✓

디렉토리 재귀 탐색

for entry in WalkDir::new(project_dir)
    .into_iter()
    .filter_map(|e| e.ok())

최적화 포인트:

  • filter_map(|e| e.ok()): 에러 무시, 성공한 엔트리만 처리
  • 심볼릭 링크 자동 처리
  • 대용량 디렉토리에도 메모리 효율적

Cypher MERGE 전략

MERGE (s:PHPFile {path: $source}) 
MERGE (t:PHPFile {path: $target}) 
MERGE (s)-[:INCLUDES]->(t)

MERGE vs CREATE:

  • CREATE: 중복 노드/관계 생성 → 데이터 오염
  • MERGE: 없으면 생성, 있으면 재사용 → 멱등성 보장

성능 고려사항:

  • 인덱스 필수: CREATE INDEX FOR (f:PHPFile) ON (f.path)
  • 배치 처리: 수천 개 쿼리는 트랜잭션으로 묶기

3. 그래프 모델링

3.1 노드 설계

// PHPFile 노드 구조
(:PHPFile {
  path: "src/controllers/UserController.php",
  name: "UserController.php",
  directory: "src/controllers",
  lineCount: 450,
  size: 15234,
  lastModified: datetime("2024-11-01T10:30:00")
})

3.2 관계 설계

// INCLUDES 관계
(file1)-[:INCLUDES {
  lineNumber: 12,
  conditional: false,  // if 블록 내부 include 여부
  count: 1            // 동일 파일 중복 include 횟수
}]->(file2)

3.3 확장된 스키마

실무 프로젝트에서는 더 많은 메타데이터가 필요합니다:

// 1. 패키지/모듈 노드 추가
CREATE (pkg:Package {name: "user-management", version: "2.1"})
CREATE (file:PHPFile {path: "src/User.php"})
CREATE (pkg)-[:CONTAINS]->(file)

// 2. 클래스 노드 추가
CREATE (class:PHPClass {
  name: "UserController",
  namespace: "App\\Controller",
  type: "controller"
})
CREATE (file)-[:DEFINES]->(class)

// 3. 함수/메소드 노드 추가
CREATE (method:PHPMethod {
  name: "getUserById",
  visibility: "public",
  parameters: ["id"],
  returnType: "User"
})
CREATE (class)-[:HAS_METHOD]->(method)

// 4. 복잡한 의존성
CREATE (method)-[:CALLS]->(otherMethod)
CREATE (class)-[:EXTENDS]->(parentClass)
CREATE (class)-[:IMPLEMENTS]->(interface)

4. 실전 분석 쿼리

4.1 기본 탐색 쿼리

특정 파일의 직접 의존성

// index.php가 직접 include하는 파일들
MATCH (source:PHPFile {path: "index.php"})
      -[:INCLUDES]->(target:PHPFile)
RETURN target.path AS included_file
ORDER BY target.path

역방향 의존성 (누가 나를 include하나?)

// config.php를 include하는 모든 파일
MATCH (source:PHPFile)-[:INCLUDES]->(target:PHPFile {path: "config.php"})
RETURN source.path AS dependent_file, count(*) AS include_count
ORDER BY include_count DESC

4.2 고급 분석 쿼리

의존성 체인 분석

// index.php에서 3단계 이내 의존성
MATCH path = (start:PHPFile {path: "index.php"})
             -[:INCLUDES*1..3]->(end:PHPFile)
RETURN DISTINCT end.path, length(path) AS depth
ORDER BY depth, end.path

순환 의존성 탐지

// 순환 참조 찾기
MATCH cycle = (a:PHPFile)-[:INCLUDES*]->(b:PHPFile)
              -[:INCLUDES*]->(a)
WHERE a <> b
RETURN [node IN nodes(cycle) | node.path] AS circular_dependency
LIMIT 10

순환 의존성이 위험한 이유:

  • 리팩토링 어려움
  • 메모리 누수 가능성
  • 모듈 분리 불가

허브 파일 식별

// 가장 많이 include되는 파일 (높은 중요도)
MATCH (f:PHPFile)<-[:INCLUDES]-(dependent)
RETURN f.path AS hub_file, 
       count(dependent) AS usage_count
ORDER BY usage_count DESC
LIMIT 20

허브 파일 관리 전략:

  • 변경 시 광범위한 영향도
  • 철저한 테스트 필요
  • API 안정성 중요

고립된 파일 찾기

// 의존성이 전혀 없는 파일 (데드 코드 후보)
MATCH (f:PHPFile)
WHERE NOT (f)-[:INCLUDES]->() 
  AND NOT ()<-[:INCLUDES]-(f)
RETURN f.path AS isolated_file

4.3 영향도 분석

파일 변경 시 영향 범위

// database.php 변경 시 영향받는 모든 파일
MATCH path = (affected:PHPFile)
             -[:INCLUDES*1..]->(target:PHPFile {path: "config/database.php"})
RETURN DISTINCT affected.path AS affected_file,
       length(path) AS dependency_distance
ORDER BY dependency_distance, affected_file

레이어 분석

// 계층별 파일 분포
MATCH (f:PHPFile)
WITH f, 
     CASE 
       WHEN f.path STARTS WITH 'controllers/' THEN 'Presentation'
       WHEN f.path STARTS WITH 'models/' THEN 'Business Logic'
       WHEN f.path STARTS WITH 'config/' THEN 'Configuration'
       ELSE 'Other'
     END AS layer
RETURN layer, count(f) AS file_count
ORDER BY file_count DESC

5. 성능 최적화

5.1 인덱스 전략

// 필수 인덱스
CREATE INDEX FOR (f:PHPFile) ON (f.path);
CREATE INDEX FOR (f:PHPFile) ON (f.name);

// 복합 인덱스 (Neo4j 4.x+)
CREATE INDEX FOR (f:PHPFile) ON (f.directory, f.name);

// 전체 텍스트 검색 인덱스
CREATE FULLTEXT INDEX file_search FOR (f:PHPFile) 
ON EACH [f.path, f.name];

5.2 배치 처리

대량 데이터 import 시 성능 개선:

// 트랜잭션 배치 처리
let mut txn = graph.start_txn().await?;

for (source, target) in dependencies {
    txn.run_queries([
        query("MERGE (s:PHPFile {path: $source})")
            .param("source", source),
        query("MERGE (t:PHPFile {path: $target})")
            .param("target", target),
        query("MERGE (s)-[:INCLUDES]->(t)")
    ]).await?;
}

txn.commit().await?;

성능 비교:

  • 개별 쿼리: ~100 deps/sec
  • 배치 처리: ~5,000 deps/sec
  • LOAD CSV: ~50,000 deps/sec

5.3 쿼리 최적화

// ❌ 비효율적: 모든 노드 스캔
MATCH (f:PHPFile)
WHERE f.path CONTAINS "controller"
RETURN f

// ✅ 효율적: 인덱스 활용
MATCH (f:PHPFile)
WHERE f.directory = "controllers"
RETURN f

// ✅ 더 효율적: 직접 조회
MATCH (f:PHPFile {directory: "controllers"})
RETURN f

6. 실전 활용 시나리오

6.1 레거시 마이그레이션

상황: PHP 5.6 → PHP 8.2 마이그레이션

// 1. 순환 의존성 제거 우선순위
MATCH cycle = (a)-[:INCLUDES*2..]->(a)
RETURN cycle, length(cycle) AS cycle_length
ORDER BY cycle_length DESC

// 2. 허브 파일 안정화
MATCH (hub:PHPFile)<-[:INCLUDES]-(dep)
WITH hub, count(dep) AS deps
WHERE deps > 10
RETURN hub.path, deps
ORDER BY deps DESC

// 3. 고립 파일 제거
MATCH (isolated:PHPFile)
WHERE NOT (isolated)--()
RETURN isolated.path

6.2 모듈화 리팩토링

목표: 모놀리식 구조를 마이크로서비스로 분리

// 강하게 연결된 파일 그룹 찾기 (응집도 높은 모듈 후보)
CALL gds.louvain.stream({
  nodeProjection: 'PHPFile',
  relationshipProjection: 'INCLUDES'
})
YIELD nodeId, communityId
RETURN gds.util.asNode(nodeId).path AS file,
       communityId AS potential_module
ORDER BY communityId, file

6.3 보안 감사

// 외부 입력을 받는 파일에서 데이터베이스 접근까지 경로
MATCH path = (entry:PHPFile {name: "api.php"})
             -[:INCLUDES*1..5]->(db:PHPFile)
WHERE db.path CONTAINS "database"
RETURN [node IN nodes(path) | node.path] AS data_flow

7. AWS 배포와 보안

7.1 보안 아키텍처

graph TB
    subgraph VPC["VPC (Private Subnet)"]
        Scanner["Scanner<br/>(ECS Task)"]
        Neo4j["Neo4j DB<br/>(EC2)"]
        Secrets["Secrets Manager<br/>- Neo4j Credentials<br/>- TLS Certificates"]
        
        Scanner -->|TLS 7687| Neo4j
        Scanner -->|Get Secrets| Secrets
    end
    
    Scanner -->|Structured Logs| CloudWatch["CloudWatch Logs"]
    
    style VPC fill:#e8eaf6,stroke:#3f51b5,stroke-width:3px
    style Scanner fill:#fff3e0,stroke:#e65100,stroke-width:2px
    style Neo4j fill:#f3e5f5,stroke:#4a148c,stroke-width:2px
    style Secrets fill:#e0f2f1,stroke:#004d40,stroke-width:2px
    style CloudWatch fill:#fff9c4,stroke:#f57f17,stroke-width:2px

7.2 환경변수 관리

# .env.production (절대 Git에 커밋하지 말 것!)
NEO4J_URI=neo4j+s://production.neo4j.internal:7687
NEO4J_USER=neo4j
NEO4J_PASSWORD=<Secrets Manager에서 가져오기>
PROJECT_DIR=/mnt/source

# AWS Secrets Manager 사용 (권장)

AWS Secrets Manager 통합:

use aws_sdk_secretsmanager as secretsmanager;

async fn get_neo4j_password() -> Result<String, Box<dyn std::error::Error>> {
    let config = aws_config::load_from_env().await;
    let client = secretsmanager::Client::new(&config);
    
    let resp = client
        .get_secret_value()
        .secret_id("prod/neo4j/password")
        .send()
        .await?;
    
    Ok(resp.secret_string().unwrap().to_string())
}

7.3 IAM 권한 최소화

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Action": [
        "secretsmanager:GetSecretValue"
      ],
      "Resource": "arn:aws:secretsmanager:*:*:secret:prod/neo4j/*"
    },
    {
      "Effect": "Allow",
      "Action": [
        "logs:CreateLogStream",
        "logs:PutLogEvents"
      ],
      "Resource": "arn:aws:logs:*:*:log-group:/aws/ecs/php-scanner:*"
    },
    {
      "Effect": "Allow",
      "Action": [
        "efs:ClientMount",
        "efs:ClientRead"
      ],
      "Resource": "arn:aws:elasticfilesystem:*:*:file-system/*",
      "Condition": {
        "StringEquals": {
          "elasticfilesystem:AccessPointArn": "arn:aws:elasticfilesystem:*:*:access-point/*"
        }
      }
    }
  ]
}

7.4 네트워크 보안

Security Group 설정:

# Neo4j Security Group (데이터베이스)
aws ec2 authorize-security-group-ingress \
  --group-id sg-neo4j \
  --protocol tcp \
  --port 7687 \
  --source-group sg-scanner \
  --description "Allow scanner access to Neo4j"

# Scanner Security Group (스캐너)
# 아웃바운드만 허용 (인바운드 불필요)
aws ec2 authorize-security-group-egress \
  --group-id sg-scanner \
  --protocol tcp \
  --port 7687 \
  --destination-group sg-neo4j

7.5 TLS/SSL 암호화

// Neo4j TLS 연결 (프로덕션 필수)
let config = ConfigBuilder::default()
    .uri(&neo4j_uri)
    .user(&neo4j_user)
    .password(&neo4j_password)
    .encryption(Encryption::Required)  // ⚠️ TLS 필수
    .build()?;

let graph = Graph::connect(config).await?;

Neo4j 서버 TLS 설정:

# neo4j.conf
dbms.connector.bolt.tls_level=REQUIRED
dbms.ssl.policy.bolt.enabled=true
dbms.ssl.policy.bolt.base_directory=/var/lib/neo4j/certificates/bolt
dbms.ssl.policy.bolt.private_key=private.key
dbms.ssl.policy.bolt.public_certificate=public.crt

7.6 감사 로깅

use tracing::{info, warn, error, instrument};

#[instrument(skip(graph))]
async fn scan_file(
    path: &Path, 
    graph: &Graph
) -> Result<usize, Box<dyn std::error::Error>> {
    info!(
        file_path = %path.display(),
        "Starting file scan"
    );
    
    // 파일 스캔 로직
    
    info!(
        dependencies_found = count,
        "Scan completed"
    );
    
    Ok(count)
}

// CloudWatch Logs로 전송
let subscriber = tracing_subscriber::fmt()
    .json()
    .with_target(false)
    .with_current_span(false)
    .finish();

7.7 보안 체크리스트

배포 전 필수 확인사항:

  • 환경변수에 비밀번호 하드코딩 제거
  • AWS Secrets Manager 사용
  • IAM 역할 최소 권한 원칙 적용
  • VPC Private Subnet 사용
  • Security Group 인바운드 제한
  • Neo4j TLS/SSL 활성화
  • 입력 검증 함수 구현
  • 경로 순회 공격 방어
  • Cypher injection 방지 (파라미터 바인딩)
  • CloudWatch Logs 활성화
  • 에러 메시지에 민감정보 노출 방지

8. 확장 가능성

8.1 다른 언어 지원

// 패턴 추가
let patterns = HashMap::from([
    ("php", vec![
        r#"include_once\s*\(\s*([^)]+)\s*\)"#,
        r#"require_once\s*\(\s*([^)]+)\s*\)"#,
    ]),
    ("python", vec![
        r#"import\s+([a-zA-Z_][a-zA-Z0-9_]*)"#,
        r#"from\s+([a-zA-Z_][a-zA-Z0-9_.]*)\s+import"#,
    ]),
]);

8.2 AST 기반 분석

정규표현식의 한계를 극복하기 위해 AST 파서 사용:

use tree_sitter::{Parser, Language};

extern "C" { fn tree_sitter_php() -> Language; }

let mut parser = Parser::new();
parser.set_language(unsafe { tree_sitter_php() })?;

let tree = parser.parse(&source_code, None).unwrap();
// AST 순회하여 정확한 의존성 추출

8.3 실시간 모니터링

// 파일 시스템 워처
use notify::{Watcher, RecursiveMode};

let (tx, rx) = channel();
let mut watcher = RecommendedWatcher::new(tx)?;
watcher.watch(path, RecursiveMode::Recursive)?;

for event in rx {
    match event {
        Ok(Event { kind: EventKind::Modify(_), paths, .. }) => {
            // 변경된 파일만 재분석
            analyze_file(&paths[0], &graph).await?;
        }
        _ => {}
    }
}

결론

이번 글에서는 Rust와 Neo4j를 활용하여 실전 PHP 의존성 분석 도구를 구축했습니다.

주요 성과

  1. 고성능 스캐너: Rust 기반으로 대규모 프로젝트도 빠르게 처리
  2. 그래프 모델링: Neo4j를 활용한 직관적인 의존성 표현
  3. 실전 쿼리: 순환 의존성, 허브 파일, 영향도 분석 등 실무 패턴
  4. 확장성: 다양한 언어와 분석 기법으로 확장 가능

실무 적용 가치

  • 레거시 마이그레이션: PHP 5.6 → 8.x 업그레이드 전략 수립
  • 리팩토링 우선순위: 데이터 기반 의사결정
  • 보안 감사: 의존성 체인을 통한 취약점 추적
  • 기술 부채 가시화: 순환 의존성, 데드 코드 식별

다음 글 예고

Part 3에서는 이 도구를 AWS 프로덕션 환경에 배포합니다:

  • EC2/ECS/EKS 배포 전략 비교
  • CI/CD 파이프라인 구축 (CodePipeline, GitHub Actions)
  • 자동화된 분석 워크플로우 (EventBridge, Step Functions)
  • 보안 및 네트워크 설정 (VPC, Security Groups, IAM)
  • 비용 최적화 실전 팁

참고 자료


질문이나 피드백은 언제든 환영합니다!

Clickable cat