🔒 보안

SonarQube

SonarQube

코드 품질 및 보안 분석 플랫폼. 정적 분석, 기술 부채 관리.

📖 상세 설명

SonarQube(소나큐브)는 소스 코드의 품질과 보안을 자동으로 분석하는 오픈소스 정적 분석(Static Analysis) 플랫폼입니다. SonarSource에서 개발하여 Java, JavaScript, TypeScript, Python, C/C++, C#, Go, PHP 등 30개 이상의 프로그래밍 언어를 지원합니다. 코드 냄새(Code Smell), 버그, 보안 취약점(OWASP Top 10, CWE)을 탐지하고, 기술 부채(Technical Debt)를 시간 단위로 추정합니다.

SonarQube의 핵심 개념은 Quality Gate입니다. 코드 커버리지, 중복률, 새 코드의 버그/취약점 수 등 품질 지표에 대한 임계값을 설정하고, 이를 통과해야만 배포가 가능하도록 CI/CD 파이프라인과 통합합니다. "Clean as You Code" 철학에 따라 새로 작성/수정된 코드에 엄격한 기준을 적용하여 점진적으로 코드베이스 품질을 개선합니다.

분석 결과는 웹 대시보드에서 프로젝트별, 이슈별로 확인할 수 있습니다. 각 이슈에 대해 문제 설명, 영향, 수정 방법을 제안하며, IDE 플러그인(SonarLint)을 통해 개발자가 코딩 중 실시간으로 피드백을 받을 수 있습니다. Pull Request 분석 기능으로 코드 리뷰 전 자동으로 문제를 감지하고, 보고서를 PR 댓글로 남깁니다.

SonarQube는 Community(무료), Developer, Enterprise, Data Center 에디션으로 제공됩니다. 상위 에디션에서는 OWASP/CWE 매핑, 브랜치 분석, PR decoration, 더 많은 언어 지원 등 추가 기능을 제공합니다. SonarCloud는 SaaS 버전으로 GitHub, GitLab, Azure DevOps와 쉽게 통합됩니다. DevSecOps 파이프라인에서 SAST(Static Application Security Testing) 도구로 널리 사용됩니다.

💻 코드 예제

Docker로 SonarQube 실행

# SonarQube 서버 실행
docker run -d --name sonarqube \
  -p 9000:9000 \
  -e SONAR_ES_BOOTSTRAP_CHECKS_DISABLE=true \
  -v sonarqube_data:/opt/sonarqube/data \
  -v sonarqube_extensions:/opt/sonarqube/extensions \
  -v sonarqube_logs:/opt/sonarqube/logs \
  sonarqube:lts-community

# 브라우저에서 http://localhost:9000 접속
# 초기 계정: admin / admin

# PostgreSQL과 함께 실행 (프로덕션)
# docker-compose.yml
# version: "3"
# services:
#   sonarqube:
#     image: sonarqube:lts-community
#     depends_on:
#       - db
#     environment:
#       SONAR_JDBC_URL: jdbc:postgresql://db:5432/sonar
#       SONAR_JDBC_USERNAME: sonar
#       SONAR_JDBC_PASSWORD: sonar
#     ports:
#       - "9000:9000"
#   db:
#     image: postgres:15
#     environment:
#       POSTGRES_USER: sonar
#       POSTGRES_PASSWORD: sonar
#       POSTGRES_DB: sonar

sonar-project.properties 설정

# 프로젝트 식별
sonar.projectKey=my-project
sonar.projectName=My Project
sonar.projectVersion=1.0

# 소스 코드 위치
sonar.sources=src
sonar.tests=tests
sonar.exclusions=**/node_modules/**,**/dist/**,**/*.test.ts

# 언어별 설정
sonar.language=java
sonar.java.binaries=target/classes
sonar.java.source=17

# 커버리지 리포트 연동
sonar.coverage.jacoco.xmlReportPaths=target/site/jacoco/jacoco.xml
sonar.javascript.lcov.reportPaths=coverage/lcov.info
sonar.python.coverage.reportPaths=coverage.xml

# 정적 분석 리포트 연동 (ESLint, Pylint 등)
sonar.eslint.reportPaths=eslint-report.json
sonar.python.pylint.reportPaths=pylint-report.txt

# 인코딩
sonar.sourceEncoding=UTF-8

# 브랜치 분석 (Developer Edition 이상)
# sonar.branch.name=feature/my-feature

# Pull Request 분석
# sonar.pullrequest.key=123
# sonar.pullrequest.branch=feature/my-feature
# sonar.pullrequest.base=main

GitHub Actions CI/CD 통합

# .github/workflows/sonarqube.yml
name: SonarQube Analysis

on:
  push:
    branches: [main, develop]
  pull_request:
    types: [opened, synchronize, reopened]

jobs:
  sonarqube:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 0  # 전체 히스토리 (blame 분석용)

      - name: Set up JDK 17
        uses: actions/setup-java@v4
        with:
          java-version: '17'
          distribution: 'temurin'

      - name: Cache SonarQube packages
        uses: actions/cache@v4
        with:
          path: ~/.sonar/cache
          key: ${{ runner.os }}-sonar

      - name: Build and Test with Coverage
        run: |
          ./gradlew build jacocoTestReport

      - name: SonarQube Scan
        uses: SonarSource/sonarqube-scan-action@master
        env:
          SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}
          SONAR_HOST_URL: ${{ secrets.SONAR_HOST_URL }}
        with:
          args: >
            -Dsonar.projectKey=my-project
            -Dsonar.sources=src/main
            -Dsonar.tests=src/test
            -Dsonar.java.binaries=build/classes
            -Dsonar.coverage.jacoco.xmlReportPaths=build/reports/jacoco/test/jacocoTestReport.xml

      # PR 분석 시 Quality Gate 결과 확인
      - name: SonarQube Quality Gate
        uses: SonarSource/sonarqube-quality-gate-action@master
        timeout-minutes: 5
        env:
          SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}

Python 프로젝트 분석 예제

# 의존성 설치
pip install coverage pytest pytest-cov

# 테스트 및 커버리지 수집
pytest --cov=src --cov-report=xml --cov-report=html tests/

# SonarScanner 실행
sonar-scanner \
  -Dsonar.projectKey=my-python-project \
  -Dsonar.sources=src \
  -Dsonar.tests=tests \
  -Dsonar.python.coverage.reportPaths=coverage.xml \
  -Dsonar.python.version=3.11 \
  -Dsonar.host.url=http://localhost:9000 \
  -Dsonar.token=$SONAR_TOKEN

SonarQube API 활용

import requests
from typing import Optional

class SonarQubeClient:
    """SonarQube REST API 클라이언트"""

    def __init__(self, base_url: str, token: str):
        self.base_url = base_url.rstrip('/')
        self.session = requests.Session()
        self.session.auth = (token, '')  # token as username, empty password

    def get_project_status(self, project_key: str) -> dict:
        """프로젝트 Quality Gate 상태 조회"""
        response = self.session.get(
            f"{self.base_url}/api/qualitygates/project_status",
            params={"projectKey": project_key}
        )
        response.raise_for_status()
        return response.json()

    def get_issues(
        self,
        project_key: str,
        severities: Optional[list] = None,
        types: Optional[list] = None,
        page_size: int = 100
    ) -> list:
        """프로젝트 이슈 목록 조회"""
        params = {
            "componentKeys": project_key,
            "ps": page_size,
            "resolved": "false"
        }
        if severities:
            params["severities"] = ",".join(severities)
        if types:
            params["types"] = ",".join(types)

        all_issues = []
        page = 1

        while True:
            params["p"] = page
            response = self.session.get(
                f"{self.base_url}/api/issues/search",
                params=params
            )
            response.raise_for_status()
            data = response.json()

            all_issues.extend(data["issues"])

            if len(all_issues) >= data["total"]:
                break
            page += 1

        return all_issues

    def get_metrics(self, project_key: str, metrics: list) -> dict:
        """프로젝트 메트릭 조회"""
        response = self.session.get(
            f"{self.base_url}/api/measures/component",
            params={
                "component": project_key,
                "metricKeys": ",".join(metrics)
            }
        )
        response.raise_for_status()
        return response.json()

    def create_quality_gate_condition(
        self,
        gate_name: str,
        metric: str,
        operator: str,  # "LT", "GT"
        error: str
    ):
        """Quality Gate 조건 추가"""
        # 먼저 gate ID 조회
        gates = self.session.get(
            f"{self.base_url}/api/qualitygates/list"
        ).json()

        gate_id = None
        for gate in gates["qualitygates"]:
            if gate["name"] == gate_name:
                gate_id = gate["id"]
                break

        if not gate_id:
            raise ValueError(f"Quality Gate '{gate_name}' not found")

        response = self.session.post(
            f"{self.base_url}/api/qualitygates/create_condition",
            data={
                "gateId": gate_id,
                "metric": metric,
                "op": operator,
                "error": error
            }
        )
        response.raise_for_status()
        return response.json()

# 사용 예제
client = SonarQubeClient("http://localhost:9000", "squ_xxxxx")

# Quality Gate 상태 확인
status = client.get_project_status("my-project")
print(f"Quality Gate: {status['projectStatus']['status']}")

# 심각한 이슈 조회
critical_issues = client.get_issues(
    "my-project",
    severities=["BLOCKER", "CRITICAL"],
    types=["BUG", "VULNERABILITY"]
)
print(f"Critical issues: {len(critical_issues)}")

# 주요 메트릭 조회
metrics = client.get_metrics("my-project", [
    "coverage",
    "duplicated_lines_density",
    "sqale_rating",  # Maintainability
    "reliability_rating",
    "security_rating",
    "ncloc"  # Lines of code
])
for measure in metrics["component"]["measures"]:
    print(f"{measure['metric']}: {measure['value']}")

🗣️ 실무에서 이렇게 말해요

  • "이번 PR이 Quality Gate 실패했어요. 새 코드 커버리지가 80% 미만이라서 테스트 추가해야 합니다."
  • "SonarQube에서 Critical 보안 취약점이 3개 탐지됐어요. SQL Injection 가능성이 있는 코드라 수정이 필요합니다."
  • "기술 부채가 20일로 측정됐네요. 리팩토링 스프린트를 계획해서 점진적으로 줄여봅시다."
  • "SonarLint IDE 플러그인 설치하면 커밋 전에 로컬에서 이슈를 미리 확인할 수 있어요."
  • "정적 분석과 동적 분석의 차이점은 무엇인가요? 각각 언제 사용하나요?"
  • "Quality Gate란 무엇이고, CI/CD 파이프라인에서 어떻게 활용하나요?"
  • "코드 냄새(Code Smell)의 예시를 들고, 왜 문제가 될 수 있는지 설명해 주세요."
  • "SonarQube가 탐지하는 보안 취약점 유형에는 어떤 것들이 있나요?"
  • "SonarQube에서 이 함수의 인지 복잡도(Cognitive Complexity)가 높다고 나왔어요. 작은 함수로 분리하면 좋겠습니다."
  • "이 SQL 쿼리에서 문자열 연결로 파라미터를 넣고 있어서 SQL Injection 경고가 떠요. PreparedStatement 사용하세요."
  • "중복 코드 블록이 탐지됐어요. 공통 함수로 추출하면 유지보수가 쉬워집니다."

⚠️ 주의사항

False Positive 관리

SonarQube가 탐지한 이슈 중 일부는 실제 문제가 아닌 경우(False Positive)가 있습니다. Won't Fix나 False Positive로 표시하여 노이즈를 줄이고, 커스텀 규칙 프로파일을 만들어 팀에 맞게 조정하세요.

점진적 개선 전략

레거시 코드베이스에 SonarQube를 도입하면 수천 개의 이슈가 나올 수 있습니다. 모든 이슈를 한 번에 해결하려 하지 말고, "Clean as You Code" 원칙에 따라 새 코드에만 엄격한 기준을 적용하여 점진적으로 개선하세요.

커버리지 맹신 금지

코드 커버리지 80%가 품질을 보장하지 않습니다. 의미 있는 테스트인지, 엣지 케이스를 다루는지가 더 중요합니다. 커버리지를 높이기 위해 단순 실행만 하는 테스트는 가치가 없습니다.

🔗 관련 용어

📚 더 배우기