pre_get_posts is the hook query_posts pretends to be

query_posts() replaces the main query after WordPress has already run it, which throws away the first result set, breaks pagination and leaves conditional tags lying about what page you are on. It is still the first answer most search results give.

add_action( 'pre_get_posts', function ( $query ) {
    if ( is_admin() || ! $query->is_main_query() ) {
        return;
    }

    if ( $query->is_post_type_archive( 'product' ) ) {
        $query->set( 'posts_per_page', 24 );
    }
} );

pre_get_posts modifies the query before it runs, so there is only ever one. The two guards are not optional: without the is_admin() check the change also applies to the posts list in wp-admin, and without is_main_query() it applies to every widget and sidebar loop on the page as well.