귀찮은 작업 자동화로 대체해보기
그 수작업이 대체 무슨 수작업이을까 ?
간단히 말하자면 필자의 회사에서는 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()
// ... 삭제로직
}
}
수동적으로만 이루어졌던 일을 두가지 패키지를 써보면서 해봤는데, 확실히 매력적이고 한번 고생해서 자동화를 해놓으면 재사용하면 되니까 좋았고 무엇보다 자동화라는게 개발자에게서 반복적인 작업에서 발생할 수 있는 오류를 최소화하고, 더 복잡하고 창의적인 문제 해결에 집중할 수 있는 시간을 확보하기에 너무 좋은 것 같다.