2024년 7월 2일 화요일

Laravel : 정적코드분석 PHPSTAN

Laravel : 정적코드분석 PHPSTAN

정적코드 분석 라이브러리 PHPSTAN ( LaraStan)

Java나 C# 같은 컴파일언어들은 정의안된 함수를 호출하거나 잘못된 수의 인수를 함수에 전달하는 등의 오류를 차컴파일단계에서 찾아준다. 하지만 php 같은 인터프리터 언어에서는 실제로 서비스를 돌려보기 전까지는 어디에서 에러가 나올지 예측할수없다.

이러한 문제점을 해결하고자 정적분석툴이 존재하는데 Laravel에서는 phpstan 을 Laravel에 맞게 수정한 larastan을 따로 제공한다.

정적분석툴을 이용해서 프로그램이 어디서 언제 에러를 뱉어낼지 두려워 하지 않아도 될지도 모르겠다. ( 그렇다고 100% 믿지는 말라. 어디까지나 프로그램입장에서 코드를 분석하는 자동툴이다)

설치는 composer로 간단히 할수 있다. 실제 서비스인 production 환경에서는 사용하지 않도록 --dev 를 이용해 설치하자.

composer require nunomaduro/larastan --dev

설치가 되었다면 neon yaml 이라는 파일을 작성하자.

includes:
    - ./vendor/nunomaduro/larastan/extension.neon
parameters:
    paths:
        - app
    level: 9
    ignoreErrors:
    excludePaths:

첫 번째 옵션을 사용하면 pathsphpstan이 확인할 위치를 정의할 수 있다. 이 경우에는 app애플리케이션 코드가 있는 디렉터리만 검사한다. 원하는 경우 더 많은 영역으로 확장할 수 있지만 상황이 엄격해질 수 있으므로 포함하는 내용에 주의해야한다.
다음으로 levelPHPStan에는 확인할 수준이 다르며 0이 가장 낮고 9가 현재 가장 높다. 조금씩 높여서 9에서도 문제없는 코드를 만들어가자.

./vendor/bin/phpstan analyse
기본 Laravel 애플리케이션에서 얻은 출력은 다음과 같다.

Note: Using configuration file /Users/steve/code/sites/larastan-test/phpstan.neon.
 18/18 [▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓] 100%
 ------ ----------------------------------------------------------------------------------------------------------------------------
  Line   Providers/RouteServiceProvider.php
 ------ ----------------------------------------------------------------------------------------------------------------------------
  49     Parameter #1 $key of method Illuminate\Cache\RateLimiting\Limit::by() expects string, int<min, -1>|int<1, max>|string|null
         given.
 ------ ----------------------------------------------------------------------------------------------------------------------------
 [ERROR] Found 1 error

보다시피, 엄격도가 가장 높게 설정되어 있음에도 불구하고 기본 Laravel 애플리케이션에서는 단 하나의 오류만 발생했다. 훌륭한 결과다.

이를 편리하게 실행하려면 작곡가 파일에 스크립트를 추가하여 이 명령을 실행할 수 있다. 이제 이 명령을 더 쉽게 실행할 수 있도록 이 스크립트를 파일에 추가한다.

composer.json

"scripts": {
  "phpstan": [
    "./vendor/bin/phpstan analyse"
  ]
},
"scripts-descriptions": {
  "phpstan": "Run PHPStan static analysis against your application."
},

이제 composer phpstan 라고만 치면된다.

위의 분석 결과에서 지적당했던 내용을 보자.

protected function configureRateLimiting()
{
    RateLimiter::for('api', function (Request $request) {
        return Limit::perMinute(60)->by($request->user()?->id ?: $request->ip());
    });
}

$request->user()?->id ?: $request->ip()

이 부분에서 정적분석툴이 문제가 있다고 한다. 그런데 애매하게도 이부분은 외부에서 가져오는 거라 이렇게 써야만 헀다.
때문에 HPStan 에게 해당 라인을 무시하라고 해야 하는데 아래와 같다.

protected function configureRateLimiting(): void
{
    RateLimiter::for('api', static function (Request $request): Limit {
        /** @phpstan-ignore-next-line  */
        return Limit::perMinute(60)->by($request->user()?->id ?: $request->ip());
    });
}

다시 실행하면 문제 없다고 나온다. 이제 적어도 웹사이트나 api 도중에 500 에러는 안 만날것이다.

Note: Using configuration file /Users/steve/code/sites/larastan-test/phpstan.neon.
 18/18 [▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓] 100%
 [OK] No errors

artisan을 사용하여 모델을 추가하고 CLI 플래그를 사용하여 마이그레이션과 팩토리를 모두 생성하는 예제도 보자.

php artisan make:model Bookmark -mf

마이그레이션은 다음과 같다.

Schema::create('bookmarks', static function (Blueprint $table): void {
    $table->id();
    $table->string('name');
    $table->string('url');
    $table->boolean('starred')->default(false);
    $table->foreignId('user_id')->index()->constrained()->cascadeOnDelete();
    $table->timestamps();
});

이제 이것을 모델에 추가한다.

class Bookmark extends Model
{
    use HasFactory;
    protected $fillable = [
        'name',
        'url',
        'starred',
        'user_id',
    ];
    protected $casts = [
        'starred' => 'boolean',
    ];
    /**
     * @return BelongsTo
     */
    public function user(): BelongsTo
    {
        return $this->belongsTo(
            related: User::class,
            foreignKey: 'user_id',
        );
    }

}

그렇지만 위처럼 배열을 대충 사용하면 레벨 9의 phpstan 은 화를 낸다. 그래서 아래와 같이 코멘트를 추가한다.

class Bookmark extends Model
{
    use HasFactory;
    /**
     * @var array<int,string>
     */
    protected $fillable = [
        'name',
        'url',
        'starred',
        'user_id',
    ];
    /**
     * @var array<string,string>
     */
    protected $casts = [
        'starred' => 'boolean',
    ];
    /**
     * @return BelongsTo
     */
    public function user(): BelongsTo
    {
        return $this->belongsTo(
            related: User::class,
            foreignKey: 'user_id',
        );
    }
}

이렇게 하면 phpstan 은 화를 내지 않는다.

자 이제, controller 를 만들자

php artisan make:controller Bookmarks/IndexController --invokable

라우터도 만들자.

Route::middleware(['auth'])->prefix('bookmarks')->as('bookmarks:')->group(static function (): void {
    Route::get('/', App\Http\Controllers\Bookmarks\IndexController::class)->name('index');
});

그냥 그대로 phpstan 을 돌려보면,

composer phpstan


Note: Using configuration file /Users/steve/code/sites/larastan-test/phpstan.neon.
 20/20 [▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓] 100%
 ------ -------------------------------------------------------------------------------------------------
  Line   Http/Controllers/Bookmarks/IndexController.php
 ------ -------------------------------------------------------------------------------------------------
  15     Method App\Http\Controllers\Bookmarks\IndexController::__invoke() has no return type specified.
 ------ -------------------------------------------------------------------------------------------------
 ------ -----------------------------------------------------------------------------------------------------------------------------
  Line   Models/Bookmark.php
 ------ -----------------------------------------------------------------------------------------------------------------------------
  33     Method App\Models\Bookmark::user() return type with generic class Illuminate\Database\Eloquent\Relations\BelongsTo does not
         specify its types: TRelatedModel, TChildModel
         💡 You can turn this off by setting checkGenericClassInNonGenericObjectType: false in your
         phpstan.neon.
 ------ -----------------------------------------------------------------------------------------------------------------------------
 ------ ----------------------------------------------------------------------------------------------------------------------------
  Line   Models/User.php
 ------ ----------------------------------------------------------------------------------------------------------------------------
  49     Method App\Models\User::bookmarks() return type with generic class Illuminate\Database\Eloquent\Relations\HasMany does not
         specify its types: TRelatedModel
         💡 You can turn this off by setting checkGenericClassInNonGenericObjectType: false in your
         phpstan.neon.
 ------ ----------------------------------------------------------------------------------------------------------------------------
 [ERROR] Found 3 errors

눈에 띄는 첫 번째 문제는 Method App\Models\User::bookmarks() return type with generic class오류다. 우리는 Generic 에 의존하지 않으니까 지시대로 phpstan.neon파일의 checkGenericClassInNonGenericObjectType 에 false 로 해주자.

includes:
    - ./vendor/nunomaduro/larastan/extension.neon
parameters:
    paths:
        - app
    level: 9
    ignoreErrors:
    excludePaths:
    checkGenericClassInNonGenericObjectType: false

IndexController 의 경고를 보자. __invoke 에 return 이 없는거하고 request에서 id 가 null 올수 있다고 한다.

class IndexController extends Controller
{
    public function __invoke(Request $request)
    {
        return View::make(
            view: 'bookmarks.list',
            data: [
                'bookmarks' => Bookmark::query()
                    ->where('user_id', $request->user()->id)
                    ->paginate(),
            ]
        );
    }
}

------ -------------------------------------------------------------------------------------------------
  Line   Http/Controllers/Bookmarks/IndexController.php
 ------ -------------------------------------------------------------------------------------------------
  15     Method App\Http\Controllers\Bookmarks\IndexController::__invoke() has no return type specified.
  21     Cannot access property $id on App\Models\User|null.
 ------ -------------------------------------------------------------------------------------------------

아래와 같이 바꾸자.

public function __invoke(Request $request): \Illuminate\Contracts\View\View

다음으로 Cannot access property $id on App\Models\User|null.은 요청하는 사용자의 ID가 비어 있을 수 있는 기본 Laravel 애플리케이션의 것과 유사하다. 따라서 이 문제를 해결하는 방법은 인증 도우미 기능을 사용하여 인증 가드로부터 직접 ID를 얻는 것이다.
쿼리를 바꿔보자

Bookmark::query()
    ->where('user_id', auth()->id())
    ->paginate()

인증 ID 방법을 사용하면 요청의 ID가 비어 있을 수 있으므로 요청에서 ID를 가져오는 대신 인증 보호에서 직접 ID를 가져온다.
이 작업을 수행할 때 주의할 점은 경로가 인증 미들웨어로 래핑되지 않은 경우 id 메서드가 속성 ID null을 가져오려고 한다고 불평한다는 거다. 따라서 이 경로에 대한 미들웨어를 설정했는지 확인해야한다. 어쨌든 정적분석툴 입장에서는 외부에서 들어오는 request 의 id 값을 그대로 사용하지 않는 다고 생각해서 에러라고 생각 하지 않게된다.

이제 정적 분석을 다시 실행하면 이러한 오류가 제거되었을 것이다.

composer phpstan
Note: Using configuration file /Users/steve/code/sites/larastan-test/phpstan.neon.
 20/20 [▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓] 100%
 [OK] No errors

이제 IndexController오류가 없다.

java라면 intellij 나 vscode(java인데 vscode를?) 에서 nullable 정도는 쉽게 잡아줄텐데 php 에서는 phpstan 을 이용하여야 하니 조금 불편하고 마치 잔소리 하는 우리의 누구네 처럼 귀찮게 들리지만 결국 코드 품질이 올라가고 안정된 소프트웨어가 되었다는 것은 큰 가치가 있다고 생각한다.

프로젝트 초기단게라면 어서 빨리 도입하자.

참고로 , SonarQube 라는 유료지만 좋은 툴도 있다.

0 comments:

댓글 쓰기