프록시를 활용한 LinkedIn 공개 데이터 스크래핑 완벽 가이드: 법적 경계와 기술적 구현

LinkedIn의 공개 프로필, 기업 페이지, 채용 공고를 합법적으로 수집하는 방법을 알아봅니다. hiQ Labs v. LinkedIn 판례 분석, 주거용 프록시 활용법, 그리고 절대 스크래핑하지 말아야 할 데이터 경계를 명확히 합니다.

프록시를 활용한 LinkedIn 공개 데이터 스크래핑 완벽 가이드: 법적 경계와 기술적 구현

중요 법적 고지: 이 글은 교육 목적으로만 작성되었습니다. LinkedIn의 이용약관(Terms of Service)을 위반하는 행위는 계정 정지, 민사 소송, 또는 형사 고발로 이어질 수 있습니다. 미국에서는 CFAA(Computer Fraud and Abuse Act), 유럽에서는 GDPR, 한국에서는 개인정보보호법이 적용됩니다. 스크래핑 전 반드시 법률 전문가와 상담하세요. ProxyHat은 불법적 용도를 장려하지 않습니다.

LinkedIn 공개 데이터 스크래핑: 현실과 한계

LinkedIn은 전 세계적으로 10억 명 이상의 사용자를 보유한 최대 전문 네트워크 플랫폼입니다. 채용 담당자, 영업 팀, 시장 조사 전문가들은 이 방대한 데이터에서 가치를 찾고자 합니다. 하지만 LinkedIn은 스크래핑에 대해 가장 적극적으로 대응하는 플랫폼 중 하나입니다.

2017년 hiQ Labs v. LinkedIn 소송은 이 분야의 가장 중요한 판례입니다. hiQ Labs는 LinkedIn의 공개 프로필 데이터를 스크래핑하여 기술 인재 이직 위험 분석 서비스를 제공했습니다. LinkedIn은 cease-and-desist 서한을 보내고 기술적 차단 조치를 취했습니다. hiQ는 금지 명령(preliminary injunction)을 요청했고, 연방 지방법원은 hiQ의 손을 들어주었습니다.

hiQ Labs v. LinkedIn (9th Circuit, 2019, 2022): 공개적으로 접근 가능한 데이터는 CFAA의 "unauthorized access"에 해당하지 않는다고 판결했습니다. 하지만 이 판결은 특정 사건에 국한되며, LinkedIn의 이용약관 위반이 합법이라는 의미는 아닙니다. 2022년 9th Circuit의 재판에서도 같은 논리가 유지되었지만, 대법원 판결은 아직 나오지 않았습니다.

이 판결에도 불구하고, LinkedIn은 여전히 스크래핑을 이용약관으로 명시적으로 금지하고 있습니다. 실제로 스크래핑을 시도하면 계정이 영구 정지될 수 있으며, IP가 차단될 수 있습니다. 이 글은 공개 데이터에 한정된 기술적 접근 방식을 설명하며, 로그인이 필요한 데이터는 다루지 않습니다.

로그인 없이 접근 가능한 LinkedIn 데이터

LinkedIn에서 로그인하지 않고 접근할 수 있는 데이터는 제한적이지만, 여전히 가치가 있습니다:

1. 공개 프로필 URL

사용자가 "Public profile" 설정을 "Public"으로 지정한 경우, 검색 엔진과 로그인하지 않은 방문자가 일부 정보를 볼 수 있습니다:

  • 이름과 현재 직함
  • 현재 회사
  • 지역(시/국가)
  • 최근 1-2개의 경력 항목

이메일, 전화번호, 연락처 정보는 로그인해야만 볼 수 있습니다.

2. 공개 기업 페이지

회사 페이지(예: linkedin.com/company/google)는 대부분 공개되어 있습니다:

  • 회사명, 산업 분야, 규모
  • 본사 위치
  • 회사 소개 텍스트
  • 팔로워 수
  • 최근 게시물 일부

3. 공개 채용 공고

LinkedIn Jobs의 많은 공고가 로그인 없이 접근 가능합니다:

  • 직무명, 회사명, 위치
  • 직무 설명
  • 요구 자격 요건
  • 게시일 (대략적)

왜 주거용 프록시가 필수적인가

LinkedIn은 스크래핑 탐지와 방어에 세계적 수준의 기술을 보유하고 있습니다. 데이터센터 프록시를 사용하면 거의 즉시 차단됩니다.

LinkedIn의 탐지 메커니즘

탐지 방식데이터센터 IP주거용 IP
IP 평판 데이터베이스즉시 식별됨일반 사용자와 동일
ASN 분석AWS, Azure 등으로 식별실제 ISP (SKT, KT 등)
행동 패턴 분석의심스러운 요청 패턴자연스러운 분산
브라우저 지문자동화 도구 탐지실제 브라우저와 동일
속도 제한IP당 매우 엄격더 관대한 임계값

주거용 프록시의 작동 원리

주거용 프록시(residential proxy)는 실제 가정용 인터넷 연결을 통해 트래픽을 라우팅합니다. 각 IP는 실제 ISP에서 할당된 것으로, LinkedIn 입장에서는 일반 사용자와 구별할 수 없습니다. ProxyHat의 주거용 프록시 풀은 전 세계 190개국 이상에서 수백만 개의 IP를 제공합니다.

IP 순환 전략

LinkedIn은 IP당 요청 수를 엄격하게 제한합니다. 주거용 프록시를 사용하면 각 요청마다 다른 IP로 전환할 수 있습니다:

# ProxyHat 주거용 프록시 설정 예시
# 각 요청마다 새로운 IP 할당
PROXY_URL = "http://user-country-US:PASSWORD@gate.proxyhat.com:8080"

# 스티키 세션 (동일 IP 유지, 최대 30분)
STICKY_PROXY = "http://user-session-profile123:PASSWORD@gate.proxyhat.com:8080"

Python + Playwright로 공개 프로필 스크래핑하기

다음 예제는 로그인 없이 공개 프로필 페이지를 수집하는 방법입니다. 실제 브라우저 컨텍스트를 사용하여 지문 탐지를 회피하고, 적절한 속도 제한을 적용합니다.

import asyncio
import random
import time
from playwright.async_api import async_playwright
from typing import Optional, Dict, List
import json

# ProxyHat 주거용 프록시 설정
PROXY_HOST = "gate.proxyhat.com"
PROXY_PORT = 8080
PROXY_USER = "your_username"  # ProxyHat 대시보드에서 확인
PROXY_PASS = "your_password"

class LinkedInPublicScraper:
    def __init__(self, country: str = "US"):
        self.country = country
        self.browser = None
        self.context = None
        
    async def setup_browser(self):
        """주거용 프록시가 적용된 브라우저 컨텍스트 생성"""
        playwright = await async_playwright().start()
        
        # 프록시 설정 (국가 지정 가능)
        proxy_config = {
            "server": f"http://{PROXY_HOST}:{PROXY_PORT}",
            "username": f"{PROXY_USER}-country-{self.country}",
            "password": PROXY_PASS
        }
        
        self.browser = await playwright.chromium.launch(
            headless=True,
            proxy=proxy_config,
            args=[
                '--disable-blink-features=AutomationControlled',
                '--disable-infobars',
                '--window-size=1920,1080'
            ]
        )
        
        # 실제 브라우저와 유사한 컨텍스트 설정
        self.context = await self.browser.new_context(
            viewport={'width': 1920, 'height': 1080},
            user_agent='Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
            locale='en-US',
            timezone_id='America/New_York',
            geolocation={'latitude': 40.7128, 'longitude': -74.0060},
            permissions=['geolocation']
        )
        
        # 자동화 탐지 방지 스크립트 주입
        await self.context.add_init_script("""
            Object.defineProperty(navigator, 'webdriver', {get: () => undefined});
            Object.defineProperty(navigator, 'plugins', {get: () => [1, 2, 3, 4, 5]});
            Object.defineProperty(navigator, 'languages', {get: () => ['en-US', 'en']});
            window.chrome = {runtime: {}};
        """)
        
        return playwright
    
    async def scrape_public_profile(self, profile_url: str) -> Optional[Dict]:
        """공개 프로필 페이지 스크래핑"""
        page = await self.context.new_page()
        
        try:
            # 랜덤 지연 (3-8초) - 인간처럼 행동
            await asyncio.sleep(random.uniform(3, 8))
            
            await page.goto(profile_url, wait_until='networkidle', timeout=60000)
            
            # 추가 지연 (페이지 로드 후)
            await asyncio.sleep(random.uniform(2, 4))
            
            # LinkedIn 로그인 모달이 나타나면 닫기
            try:
                close_button = await page.query_selector('[aria-label="Dismiss"]')
                if close_button:
                    await close_button.click()
                    await asyncio.sleep(1)
            except:
                pass
            
            # 공개 프로필 데이터 추출
            profile_data = await page.evaluate("""() => {
                const data = {};
                
                // 이름
                const nameEl = document.querySelector('h1');
                if (nameEl) data.name = nameEl.textContent.trim();
                
                // 직함
                const headlineEl = document.querySelector('.text-body-medium');
                if (headlineEl) data.headline = headlineEl.textContent.trim();
                
                // 위치
                const locationEl = document.querySelector('.text-body-small.inline.t-black--light');
                if (locationEl) data.location = locationEl.textContent.trim();
                
                // 현재 회사 (선택자는 변경될 수 있음)
                const companyEl = document.querySelector('[aria-label="Current company"]');
                if (companyEl) data.current_company = companyEl.textContent.trim();
                
                return data;
            }""")
            
            return profile_data
            
        except Exception as e:
            print(f"Error scraping {profile_url}: {e}")
            return None
            
        finally:
            await page.close()
    
    async def scrape_profiles_batch(
        self, 
        profile_urls: List[str], 
        delay_range: tuple = (5, 12),
        max_per_session: int = 50
    ) -> List[Dict]:
        """여러 프로필을 배치로 스크래핑 (속도 제한 적용)"""
        results = []
        
        # 세션당 최대 요청 수 제한
        urls_to_process = profile_urls[:max_per_session]
        
        for i, url in enumerate(urls_to_process):
            print(f"Processing {i+1}/{len(urls_to_process)}: {url}")
            
            data = await self.scrape_public_profile(url)
            if data:
                data['url'] = url
                results.append(data)
            
            # 요청 간 지연
            if i < len(urls_to_process) - 1:
                delay = random.uniform(*delay_range)
                print(f"Waiting {delay:.1f} seconds...")
                await asyncio.sleep(delay)
            
            # 10개마다 더 긴 휴식
            if (i + 1) % 10 == 0:
                print("Taking a longer break...")
                await asyncio.sleep(random.uniform(60, 120))
        
        return results
    
    async def close(self):
        if self.context:
            await self.context.close()
        if self.browser:
            await self.browser.close()

# 사용 예시
async def main():
    scraper = LinkedInPublicScraper(country="US")
    playwright = await scraper.setup_browser()
    
    try:
        profiles = [
            "https://www.linkedin.com/in/some-public-profile-1",
            "https://www.linkedin.com/in/some-public-profile-2",
            # ... 최대 50개까지 권장
        ]
        
        results = await scraper.scrape_profiles_batch(
            profiles,
            delay_range=(8, 15),  # LinkedIn은 엄격하므로 충분한 지연
            max_per_session=30
        )
        
        print(json.dumps(results, indent=2, ensure_ascii=False))
        
    finally:
        await scraper.close()
        await playwright.stop()

if __name__ == "__main__":
    asyncio.run(main())

핵심 구현 포인트

  • 실제 브라우저 지문: Playwright의 자동화 탐지 방지 스크립트를 주입합니다.
  • 주거용 프록시: ProxyHat의 주거용 프록시로 데이터센터 IP 차단을 회피합니다.
  • 속도 제한: 요청 간 8-15초 지연, 10개마다 1-2분 휴식을 적용합니다.
  • 세션 제한: 세션당 30-50개 프로필로 제한하고 새 세션을 시작합니다.
  • 국가 지정: ProxyHat의 country-{CODE} 플래그로 특정 국가 IP를 사용합니다.

LinkedIn Jobs 스크래핑 상세 가이드

LinkedIn Jobs는 채용 시장 분석에 귀중한 데이터 소스입니다. 공개 채용 공고는 로그인 없이도 접근할 수 있습니다.

Jobs 검색 URL 구조

LinkedIn Jobs의 검색 URL은 다음과 같은 구조를 가집니다:

https://www.linkedin.com/jobs/search/?
    keywords={검색어}&
    location={위치}&
    f_JT={직무유형}&
    f_E={경력수준}&
    start={페이지오프셋}

주요 필터 파라미터:

파라미터설명값 예시
keywords검색 키워드software+engineer
location위치San+Francisco%2C+CA
f_JT직무 유형F (풀타임), P (파트타임), C (계약직)
f_E경력 수준1 (인턴), 2 (주니어), 3 (미드), 4 (시니어)
f_WT근무 형태1 (현장), 2 (원격), 3 (하이브리드)
start페이지네이션0, 25, 50, 75... (25개씩)

Jobs 스크래핑 코드 예시

import asyncio
import random
from playwright.async_api import async_playwright
from typing import List, Dict
import json

PROXY_HOST = "gate.proxyhat.com"
PROXY_PORT = 8080

class LinkedInJobsScraper:
    def __init__(self, country: str = "US"):
        self.country = country
        self.browser = None
        self.context = None
    
    async def setup(self):
        playwright = await async_playwright().start()
        
        proxy_config = {
            "server": f"http://{PROXY_HOST}:{PROXY_PORT}",
            "username": f"your_username-country-{self.country}",
            "password": "your_password"
        }
        
        self.browser = await playwright.chromium.launch(
            headless=True,
            proxy=proxy_config,
            args=['--disable-blink-features=AutomationControlled']
        )
        
        self.context = await self.browser.new_context(
            viewport={'width': 1920, 'height': 1080},
            user_agent='Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36'
        )
        
        await self.context.add_init_script("""
            Object.defineProperty(navigator, 'webdriver', {get: () => undefined});
        """)
        
        return playwright
    
    async def search_jobs(
        self,
        keywords: str,
        location: str,
        max_pages: int = 5
    ) -> List[Dict]:
        """채용 공고 검색 및 스크래핑"""
        jobs = []
        page = await self.context.new_page()
        
        try:
            for page_num in range(max_pages):
                start = page_num * 25
                url = f"https://www.linkedin.com/jobs/search/?keywords={keywords}&location={location}&start={start}"
                
                print(f"Scraping page {page_num + 1}: {url}")
                
                await page.goto(url, wait_until='networkidle', timeout=60000)
                await asyncio.sleep(random.uniform(4, 8))
                
                # 로그인 모달 닫기
                try:
                    close_btn = await page.query_selector('[aria-label="Dismiss"]')
                    if close_btn:
                        await close_btn.click()
                        await asyncio.sleep(1)
                except:
                    pass
                
                # 스크롤하여 더 많은 결과 로드
                for _ in range(3):
                    await page.evaluate('window.scrollTo(0, document.body.scrollHeight)')
                    await asyncio.sleep(random.uniform(1, 2))
                
                # 채용 공고 추출
                page_jobs = await page.evaluate("""() => {
                    const jobs = [];
                    const jobCards = document.querySelectorAll('.job-card-container');
                    
                    jobCards.forEach(card => {
                        const job = {};
                        
                        const titleEl = card.querySelector('.job-card-list__title');
                        if (titleEl) {
                            job.title = titleEl.textContent.trim();
                            job.url = titleEl.href;
                        }
                        
                        const companyEl = card.querySelector('.job-card-container__company-name');
                        if (companyEl) job.company = companyEl.textContent.trim();
                        
                        const locationEl = card.querySelector('.job-card-container__metadata-item');
                        if (locationEl) job.location = locationEl.textContent.trim();
                        
                        const postedEl = card.querySelector('.job-card-container__listed-time');
                        if (postedEl) job.posted = postedEl.textContent.trim();
                        
                        if (job.title) jobs.push(job);
                    });
                    
                    return jobs;
                }""")
                
                jobs.extend(page_jobs)
                print(f"Found {len(page_jobs)} jobs on page {page_num + 1}")
                
                # 페이지 간 지연
                if page_num < max_pages - 1:
                    await asyncio.sleep(random.uniform(10, 20))
            
            return jobs
            
        except Exception as e:
            print(f"Error: {e}")
            return jobs
            
        finally:
            await page.close()
    
    async def get_job_details(self, job_url: str) -> Dict:
        """개별 채용 공고 상세 정보"""
        page = await self.context.new_page()
        
        try:
            await asyncio.sleep(random.uniform(3, 6))
            await page.goto(job_url, wait_until='networkidle', timeout=60000)
            await asyncio.sleep(random.uniform(2, 4))
            
            details = await page.evaluate("""() => {
                const data = {};
                
                const descEl = document.querySelector('.jobs-description-content');
                if (descEl) data.description = descEl.textContent.trim();
                
                const criteriaEls = document.querySelectorAll('.jobs-description__content .jobs-box__list-item');
                if (criteriaEls.length) {
                    data.requirements = Array.from(criteriaEls).map(el => el.textContent.trim());
                }
                
                return data;
            }""")
            
            return details
            
        except Exception as e:
            print(f"Error getting details: {e}")
            return {}
            
        finally:
            await page.close()
    
    async def close(self):
        if self.context:
            await self.context.close()
        if self.browser:
            await self.browser.close()

# 사용 예시
async def main():
    scraper = LinkedInJobsScraper(country="US")
    playwright = await scraper.setup()
    
    try:
        # 소프트웨어 엔지니어 채용 공고 검색
        jobs = await scraper.search_jobs(
            keywords="software+engineer",
            location="San+Francisco%2C+California%2C+United+States",
            max_pages=3
        )
        
        print(f"Total jobs found: {len(jobs)}")
        print(json.dumps(jobs[:5], indent=2, ensure_ascii=False))
        
    finally:
        await scraper.close()
        await playwright.stop()

if __name__ == "__main__":
    asyncio.run(main())

절대 스크래핑하지 말아야 할 데이터

다음 데이터는 절대 스크래핑해서는 안 됩니다. 이는 명확한 법적 위반이자 윤리적 문제입니다:

1. 로그인이 필요한 데이터

  • 이메일 주소, 전화번호
  • 전체 경력 히스토리
  • 교육 배경 상세
  • 스킬 인증 정보
  • 연결된 연락처 네트워크

이 데이터는 사용자가 "connections only" 또는 "private"로 설정한 것입니다. CFAA 하에서 "unauthorized access"로 간주될 위험이 높습니다.

2. Sales Navigator 데이터

LinkedIn Sales Navigator는 유료 구독 서비스로, 추가 데이터와 고급 필터링을 제공합니다. 이 데이터는 명시적으로 유료 사용자에게만 제공되며, 스크래핑은 계약 위반이자 잠재적으로 사기 행위입니다.

3. Recruiter Lite / Recruiter Corporate 데이터

채용 담당자를 위한 유료 도구의 데이터도 마찬가지입니다. InMail, 지원자 추적, 후보자 관리 데이터는 스크래핑 금지입니다.

4. 비공개 설정된 데이터

사용자가 "Private" 또는 "Connections only"로 설정한 모든 데이터는 해당 사용자의 명시적 허락 없이 수집해서는 안 됩니다.

5. 대량 연락처 추출

마케팅 목적으로 LinkedIn에서 이메일이나 전화번호를 대량 추출하는 것은 LinkedIn 이용약관 위반이며, GDPR, CCPA 등 개인정보 보호법 위반 가능성이 높습니다.

공식 LinkedIn API 대안

대량 데이터가 필요하다면, 스크래핑 대신 공식 API를 사용하는 것이 안전하고 지속 가능한 방법입니다.

1. LinkedIn Marketing API

광고 관리, 타겟팅, 캠페인 분석을 위한 API입니다. 광고주와 마케팅 에이전시에게 적합합니다.

  • 광고 계정 관리
  • 타겟 오디언스 데이터
  • 캠페인 성과 측정

2. LinkedIn Talent Solutions API

채용 담당자와 HR 팀을 위한 API입니다. 유료 LinkedIn Recruiter 구독이 필요합니다.

  • 후보자 검색 및 추천
  • 직무 게시 관리
  • 지원자 추적 시스템(ATS) 연동

3. LinkedIn Learning API

기업 교육 프로그램을 위한 API입니다. 직원 교육 콘텐츠와 진행 상황 추적에 사용됩니다.

4. LinkedIn Share API

콘텐츠 공유와 소셜 미디어 관리를 위한 API입니다.

공식 API는 승인 프로세스가 필요하지만, 스크래핑으로 인한 법적 리스크와 기술적 차단 문제를 해결할 수 있습니다.

주요 주의사항 및 모범 사례

robots.txt 준수

LinkedIn의 robots.txt를 확인하세요:

User-agent: *
Disallow: /authwall
Disallow: /checkpoint
Disallow: /sales
Disallow: /recruiter
Disallow: /talent
... (많은 경로가 차단됨)

공개 프로필과 Jobs 페이지는 robots.txt에서 명시적으로 차단되지 않지만, 이용약관 위반 소지가 있습니다.

GDPR 및 개인정보 보호법

유럽 연합 거주자의 데이터를 수집하는 경우 GDPR이 적용됩니다. 한국에서는 개인정보보호법이 적용됩니다. 공개 데이터라도 개인정보 처리에는 법적 근거가 필요합니다.

합리적인 사용 원칙

  • 개인용이 아닌 연구, 분석 목적으로만 사용
  • 수집된 데이터를 제3자에게 판매하지 않음
  • 데이터 최소화 원칙 준수
  • 삭제 요청 시 즉시 처리

Key Takeaways

  • 공개 데이터만 접근: 로그인 없이 볼 수 있는 프로필, 기업 페이지, 채용 공고만 수집하세요. 로그인이 필요한 데이터는 CFAA 위반 가능성이 높습니다.
  • 주거용 프록시 필수: LinkedIn은 데이터센터 IP를 즉시 차단합니다. ProxyHat 같은 주거용 프록시를 사용하여 실제 사용자처럼 보이게 하세요.
  • 속도 제한 준수: 요청 간 8-15초 지연, 세션당 30-50개 제한, 정기적인 휴식을 적용하세요. 급하게 수집하면 차단됩니다.
  • hiQ Labs 판례 이해: 공개 데이터 스크래핑이 CFAA 위반이 아니라는 판례이지만, 이용약관 위반과 민사 책임은 여전히 존재합니다.
  • 금지 구역 인식: Sales Navigator, Recruiter, 로그인 필요 데이터, 비공개 설정 데이터는 절대 수집하지 마세요.
  • 공식 API 고려: 대량 데이터가 필요하면 스크래핑 대신 LinkedIn Talent Solutions API 등 공식 채널을 사용하세요.

결론

LinkedIn 공개 데이터 스크래핑은 기술적으로 가능하고, 특정 상황에서는 법적으로도 방어 가능할 수 있습니다. 하지만 이는 명확한 위험 영역입니다. LinkedIn은 이용약관으로 스크래핑을 금지하고 있으며, 적발 시 계정 정지와 법적 조치가 따를 수 있습니다.

정말 데이터가 필요하다면, 먼저 공식 API를 검토하세요. 스크래핑이 불가피하다면, 주거용 프록시를 사용하고, 속도를 줄이고, 공개 데이터에만 한정하세요. 그리고 항상 법률 자문을 구하세요.

ProxyHat의 주거용 프록시는 LinkedIn의 엄격한 탐지 시스템을 우회하는 데 도움이 될 수 있습니다. 프록시 요금제를 확인하거나, 지원 국가 목록을 살펴보세요.

시작할 준비가 되셨나요?

AI 필터링으로 148개국 이상에서 5천만 개 이상의 레지덴셜 IP에 액세스하세요.

가격 보기레지덴셜 프록시
← 블로그로 돌아가기