귀찮은 작업 자동화로 대체해보기
그 수작업이 대체 무슨 수작업이을까 ?
간단히 말하자면 필자의 회사에서는 SaaS를 제공하고 그를 기반으로 사업을 키워나가고 있는 사업 초반 뭔가 어필할게 필요했는지 서브도메인에서도 Oauth를 통한 가입을 할 수 있도록 해놓았었는데, 일단 그렇게되면 Google과 Facebook에 해당 origin URI과 redirectURI을 다 등록을 해줘야했엇다. 다들 한번쯤은 Oauth 로그인을 위해서 등록해본적이 있을것으로 생각이 된다.

나는 당연히 이 작업을 보자마자 - 뭐지 Google에서 API를 제공안해주나 ? Meta API에 이게 없다고 ? 찾아봤었다. 나랑 같이 작업해주던 동기분도 찾아봤었지만 무용지물이라고 했었다. 그래서 일을 쳐내기 바빴어서 자동화 할 생각없이 그냥 일단 하다보니까 이게 고착되어 버린 상태였다
자동화를 해봐야겠다.
옛날에 파이썬으로 웹 크롤링같은거 하면서 자동화 비슷한걸 해본거 같아서 자동화 관련해서 검색을 해보았었다. selenium 그래 이 녀석이 내가 찾던 라이브러리 였었다. 일단 로컬로 먼저해보고 뭔가 해볼려고 했기때문에 일단 만들어보았다.
try:
    # Wait until the cfc-main-column element appears on the page
    wait = WebDriverWait(driver, 10)  # Wait up to 10 seconds
    mat_toolbar = wait.until(EC.presence_of_element_located((By.TAG_NAME, "cfc-main-column")))
    # Find the parent element
    parent_element = driver.find_element(By.CLASS_NAME, 'cfc-panel-body-scroll-content')
    # Find all buttons within the parent element
    buttons = parent_element.find_elements(By.CLASS_NAME, 'cfc-form-stack-add-button')
    # Click button1 and send keys
    click_button_and_send_keys(driver, buttons[0], 'https://' + hostname + '.subdomain.com')
    # Click button2 and send keys
    click_button_and_send_keys(driver, buttons[1], 'https://' + hostname + '.subdomain.com/api/auth/callback/google')
    # Wait for 1 second and click submit button
    time.sleep(1)
    submit_button = driver.find_element(By.XPATH, "//button[@type='submit']")
    # Get the current URL
    previous_url = driver.current_url
    # Perform the action that causes a redirect
    submit_button.click()
    # Wait for the URL to change
    WebDriverWait(driver, 10).until(EC.url_changes(previous_url))
이런식으로 엘리먼트 한땀한땀 잡아가면서 구현을 했었는데 이번에 셀레니움을 작업하면서 느낀거지만 driver라는 인스턴스안에 메소드들이 굉장히 직관적이라서 혹시 있나 (?) 하는 메소드는 이미 구현되어 있었고 어렵지않게 로컬에서 구현을 완료했었다. 복잡한 로직들이라면 좀 더 오래 걸릴 수 있겠지만 나는 입력과 클릭 같은 간단한 작업이라서 괜찮았던 것 같다. 만들고나서 보니까 특정환경에서만 ( 파이썬이 설치되고, 크롬 버전도 호환에 맞아야함 ) 돌아간다는 것을 알게 되었다. 그리고 그런것에 대응하기도 싫었고.. 애초에 자바스크립트도 아니었기에.. 그렇다면 두가지 방법이 있었는데
- 파이썬 코드를 서버리스로 빼자.
- 노드로 돌려 ?!
이렇게 두가지 선택사항이 있었는데, 사실 로컬에서 구현했을때의 내 파이썬 코드는 정말 맘에 안들기도 했고 처음에 로직을 만들때 너무 한번의 액션의 많은 일을 해서 가독성도 안좋고 예외처리가 힘들다는 생각을 계속했었다. 회사 프로젝트가 Nextjs 를 사용해서 노드에서도 한번 돌려보고 싶기도 했다. 그래서 검색을 해봤을때 나온게 selenium-webdriver 였는데 역시 npm은 날 위해 다 준비해줬구나라는 생각을 하게 되었다. 다행히도 파이썬쪽과 사용법은 거의 똑같았다. 그래서 일단은 Service, Controller 코드를 만들었다.
//  WebDriverService.ts
import { Browser, Builder } from "selenium-webdriver";
import chrome from "selenium-webdriver/chrome";
import firefox from "selenium-webdriver/firefox";
class WebDriverService {
  public ChromeDriver() {
    return new Builder()
      .forBrowser(Browser.CHROME)
      .setChromeOptions(
        new chrome.Options()
          .addArguments("--headless")
          .windowSize({ width: 1920, height: 3000 })
      )
      .build();
  }
  public FirefoxDriver() {
    return new Builder()
      .forBrowser(Browser.FIREFOX)
      .setFirefoxOptions(
        new firefox.Options()
          .addArguments("--headless")
          .windowSize({ width: 1920, height: 3000 })
      )
      .build();
  }
}
export default WebDriverService;
selenium-webdriver는 다양한 브라우저를 지원해준다. 파이썬도 그렇고 노드쪽도 똑같은거 같은데 web-driver 인스턴스를 먼저 만들어주고 그 후에 그 인스턴스의 메소드를 통해서 동작을 수행합니다. 그 외에 브라우저가 headless로 GUI 없이 동작하게 하거나 특정 크기로 띄우고 싶다거나 같은 다양한 옵션은 Options에 추가해서 사용하면 됩니다.
export class GoogleOAuthController extends OAuthController {
  constructor(domain: string) {
    super(domain, '/api/callback/google', process.env.GOOGLE_OAUTH_URL as string)
  }
  async login(): Promise<void> {
    // 특정 URL로 이동
    await this.driver.get(this.oauthURL)
    // 아이디 엘리먼트 검색 및 입력
    await this.driver.wait(until.elementIsVisible(await this.driver.findElement(By.css('#identifierId'))), 5000)
    await this.driver.findElement(By.css('#identifierId')).sendKeys(this.id)
    await this.driver.findElement(By.css('#identifierNext')).click()ㄴ
    // 패스워드 엘리먼트 검색 및 입력 후 로그인
    await this.driver.switchTo().activeElement().sendKeys(this.password)
    await this.driver.findElement(By.css('#passwordNext')).click()
  }
  async postOriginAndCallbackURL(): Promise<void> {
    try {
      await this.login()
      // 엘리먼트 CSS 선택자를 통해 검색하고 그 엘리먼트가 나올때까지 기다린다.
      await this.driver.wait(until.elementLocated(By.css('div.cfc-panel-body-scroll-content')))
      // 모든 버튼을 가져온다
      const ADD_BUTTONS = await this.driver.findElements(By.css('button[type="button"]'))
      // 활성화된 엘리먼트에 값을 입력
      await this.driver
        .switchTo()
        .activeElement()
        .sendKeys(cnt === 0 ? this.domain : this.domain + this.callbackSuffix)
    } catch (error) {
      log(error)
      throw error
    } finally {
      await this.driver.quit()
    }
  }
  async deleteOriginAndCallbackURL(): Promise<void> {
    try {
      await this.login()
      // ...삭제로직
    }
  }
}
사실 이 코드도 맘에는 안들지만 일단 초기화와 추상화를 위해서 OauthController라는 클래스를 먼저 만들어서 GoogleOauthController라는 클래스에 상속시켜서 구현을 해보았다. 이번에는 등록, 삭제, 로그인 이렇게 로직을 나눠서 하나의 액션만 할 수 있도록 해놓았고, 조금의 문법차이는 있었지만 비교적 쉽게 해결해서 사용할 수 있었다.

하지만 selenium-webdriver는 무려 18MB에 육박하기 때문에 부담이 적지 않았다. 그래서 다른 대안이 있는지 찾아봤었는데 그래서 찾은게 Node 환경에서 자동화가 특화되어 있는 puppeteer를 적용시켜보기로 했다. 용량은 7MB에 정도로 절반보다 더 작았고, 속도는 좀 더 빠른 감이 있었다. 간략하게 차이를 표로 표현하면 아래와 같았다.
| 특징 | Puppeteer | Selenium | 
|---|---|---|
| 호환성 | Chrome과 Chromium에 국한 | 다양한 브라우저 (Chrome, Firefox, Safari 등) 지원 | 
| 통신 프로토콜 | Chrome DevTools 프로토콜을 사용 | WebDriver API를 사용 | 
| 실행 속도 | 매우 빠름 (브라우저와의 직접 통신) | 상대적으로 느릴 수 있음 (API 통신) | 
| 사용 사례 | 웹 스크래핑, PDF 생성 등 특정 자동화 작업에 최적화 | 광범위한 웹 애플리케이션 테스팅에 적합 | 
| 프로그래밍 언어 지원 | JavaScript (Node.js) | 다양한 프로그래밍 언어 (Java, Python, Ruby 등) 지원 | 
그리고 제일 큰 차이점은 사용법이였는데 코드가 사실 selenium-webdriver 쪽이 좀 더 DX 적으로 직관적으로 좋았다. 위 같은 코드를 puppeteer로 구현하면 아래와 같다.
export class GoogleOAuthController extends OAuthController {
  constructor(domain: string) {
    super(domain, '/api/callback/google', process.env.GOOGLE_OAUTH_URL as string)
  }
  async login(): Promise<void> {
    await this.initialize()
    if (this.page === null) {
      throw new Error('Page is not initialized')
    }
    // 특정 URL로 이동
    await this.page.goto(this.oauthURL)
    // 아이디 엘리먼트 검색 및 입력
    await this.page.waitForSelector('#identifierId', { visible: true })
    await this.page.type('#identifierId', this.id)
    await this.page.click('#identifierNext')
    // 패스워드 엘리먼트 검색 및 입력 후 로그인
    await this.page.waitForSelector('input[type="password"]', { visible: true })
    await this.page.focus('input[type="password"]')
    await this.page.keyboard.sendCharacter(this.password)
    await this.page.click('#passwordNext')
  }
  async postOriginAndCallbackURL(): Promise<void> {
    try {
      await this.login()
      if (this.page === null) {
        throw new Error('Page is not initialized')
      }
      // 엘리먼트 JS 경로에 해당되는 엘리먼트 나올때까지 대기
      await this.page.waitForSelector('div > .cfc-panel-body-scroll-content')
      // 모든 버튼 가져오기
      const ADD_BUTTONS = await this.page.$$('button[type="button"]')
      // 페이지에서 활성화된 Input 엘리먼트를 가져오기
      const isInputFocused = await this.page.evaluate(() => {
            return document.activeElement?.tagName.toLowerCase() === 'input'
          })
      // ... 입력 로직
    } catch (error) {
      log(error)
      throw error
    } finally {
      await this.browser?.close()
    }
  }
  async deleteOriginAndCallbackURL(): Promise<void> {
    try {
      await this.login()
      // ... 삭제로직
    }
}
수동적으로만 이루어졌던 일을 두가지 패키지를 써보면서 해봤는데, 확실히 매력적이고 한번 고생해서 자동화를 해놓으면 재사용하면 되니까 좋았고 무엇보다 자동화라는게 개발자에게서 반복적인 작업에서 발생할 수 있는 오류를 최소화하고, 더 복잡하고 창의적인 문제 해결에 집중할 수 있는 시간을 확보하기에 너무 좋은 것 같다.