이전 포스트에서는 Mutext와 Semaphore를 활용하여 고루틴을 제어하는 방법에 대해서 알아보았다.
원래는 바로 goquery와 chromedp를 활용한 스크래퍼 코드를 뜯어보려고 했지만, 그 전에 type assertion 파트가 있어 잠깐 정리하고 넘어가려고 한다. Type Assertion은 원래 간단하게 기법만 설명하려고 했는데, 알고보니 엄청난 내용들이 뒤에 숨겨져 있어서 이 기회에 확실하게 정리하고자 한다.
오늘 다룰 코드 전체 - type assertion 부분
// subway_information에서 각 호선의 네이버코드와 역이름 정보 가져오기 -> 해당 역이름 페이지로 접속하여 HTML 획득 -> crawler로 스크래핑
func runCrawler(val map[string]interface{}, baseURL string, lineNum string) {
// val안의 값들은 interface이기 때문에 type assertion 필요
naverCode := int(val["naverCode"].(float64)) // 왜인지 모르겠지만 처음 파일에서 interface로 값 가져올 때 float64로 가져와짐
stationNm := val["stationNm"].(string)
URL := baseURL + strconv.Itoa(naverCode) + "/home"
// 타깃 페이지에 접속하여 outerHMTL 획득
htmlContent := getHTMLContents(URL)
// 페이지 소스 크롤링 & 필요한 정보 정리하기
crawler(htmlContent, lineNum, stationNm)
}
Type Assertion
처음에는 만만하게 보았던 type assertion. 하지만 만만하게 보였던 이유는 대충 보았기 때문이었다. 그냥 chatgpt씨가 던져준 코드 보고 대애애충 응용하고 넘어갔던 것이지. 알고보니 type assertion을 이해하기 위해서는 interface에 대한 이해가 필요했고, 이는 곧 Golang에서의 객체지향과도 연결되어 있었다,,, (가볍게 시작했지만 자료를 읽고 기초를 이해하는데 거의 3~4일은 쏟아부은듯) interface에 대해서는 우선 간략하게 컨셉만 보고 (자세하게는 이후 다른 포스트에서), 오늘은 type assertion에 조금 더 집중하여 다뤄보도록 하겠다.
Interface에 대한 대략적인 컨셉
Interface는 거칠게 요약하자면 method signature의 모음이다. 이때 method가 아니라 method signature의 모듬이라는 것이 이해하는데 중요한 포인트인데, interface는 그것을 구현(implement)하는 type의 method가 구체적으로 어떻게 짜여있는지는 관심이 없고 오직 method의 이름과 method를 사용하기 위한 인자들에만 관심이 있다. 즉, method의 이름과 받는 인자의 이름만 같으면 구체적인 내부 코드는 상관없이 취급한다는 뜻이다. (Go에서는 Java와 달리 구현하는 타입의 이름을 명시적으로 적어줄 필요도 없다. 컴파일 단계에서 method 이름과 인자만 같다는 것만 확인이 되면 자동으로 이어준다. 이를 암시적 암묵적으로 구현한다고 한다(implicitly implemented)) 어떻게 보면 Interface는 구체적인 값이나 메서드 보다는 그것이 포함하는 관계들을 지칭하는데 사용된다고 볼 수 있을 것 같다.
관계들을 지칭한다는 것을 생각해보았을 때(그리고 실제 사용 용법을 봐도), Interface의 값을 인터페이스가 아닌 타입의 값을 캡슐화 해주는 박스라고도 이해할 수 있다. (빠른 이해를 위해 실제 값과 그것을 지칭하는 메타데이터? 같은 느낌?으로 우선 생각해보자) 하나의 타입 인터페이스를 구현한다면 (혹은 인터페이스 타입에 할당된 값이 있다면) 그것의 원래 타입과 값은 그대로 있지만, 그 위에 인터페이스라는 겹이 하나 더 생기게 된다. 아래의 예시를 보자
package main
import "fmt"
type Shape interface {
Area() float64
Perimeter() float64
}
type Rect struct {
width float64
height float64
}
// Rect가 Shape 인터페이스를 구현함
func (r Rect) Area() float64 {
return r.width * r.height
}
func (r Rect) Perimeter() float64 {
return 2 * (r.width + r.height)
}
func main() {
var s Shape = Rect{4, 5}
fmt.Printf("Type of s is %T and value of s is %v\n", s, s)
// 결과: Type of s is main.Rect and value of s is {4 5}
// Rect가 아니라 main.rect인 이유는 네임스페이스 구조, Go에서 타입을 패키지 이름+자료형명으로 관리하기 때문
var q interface{}
q = 3
fmt.Printf("Type of q is %T and value of q is %v", q, q)
// 결과: Type of q is int and value of q is 3
r := q + 3
fmt.Println(r)
// 결과: invalid operation: q + 3 (mismatched types interface{} and int)
}
변수 s와 q는 둘 다 인터페이스 타입으로 정의된 이후에, 각각 struct와 int 타입의 값을 할당받았다.
하지만 Printf를 해보면 할당받은 값의 원래 타입, 즉 s는 main.Rect, q는 int로 나타나는 것을 볼 수 있다.
그럼 s와 q는 완전히 struct, int가 된 것일까?
아니다. q에 정수인 3을 더하는 연산을 실행해보면 invalid operation: mismatched types interface{} and int 에러가 뜬다.
다시 박스(혹은 캡슐화 )컨셉을 생각해보면, struct와 int 타입과 값 위에 interface라는 박스를 하나 포장했다고 볼 수 있을 것 같다.
이 시점에서 인터페이스를 정적, 동적 두 가지 개념으로 다시 이해해보자. 정적 타입(static type)은 인터페이스 그 자체를 뜻한다. 하지만 정적 값(static value)는 따로 없다고 볼 수 있는데, 박스 그 자체가 값은 아니라는 점을 생각해보면 직관적으로 이해할 수 있다. (참고로 아무것도 할당하지 않은 인터페이스(zero value and type of interface)의 값은 nil이다) 그렇다면 박스 안에 있는 내용물을 우리는 동적인 것, 즉 안에 있는 실질적인 값을 동적 값(dynamic value), 그리고 그것의 타입을 동적 타입(dynamic type)이라고 (개념적으로) 이해할 수 있을 것이다. 동적이라고 부르는 것은 해당 인터페이스에 또다른 값을 할당할 수 있기 때문이다. (+ dynamic value가 다른 interface 문서와 교육 자료에서 나오는 그 concrete value이다)
var q interface{}
q = 3
q = 5
fmt.Printf("Type of q is %T and value of q is %v", q, q)
// Type of q is int and value of q is 5
Dynamic value에 접근하는 방법인 Type Assertion
자 돌고돌아 이제 type assertion이다. type conversion이 타입을 변환해주는 것이라면, type assertion은 interface 안에 숨겨져 있는 값을 확인해주는 것, 주장하는 것, 접근할 수 있게 해주는 것이다.
func main() {
var a interface{}
a = 3
fmt.Println(a+3)
// 결과: invalid operation: a + 3 (mismatched types interface{} and int)
fmt.Println(a.(int) + 3)
// 결과: 6
a = a.(int)
fmt.Println(a + 3)
// 결과: invalid operation: a + 3 (mismatched types interface{} and int)
b := a.(int)
fmt.Printf("Type of b is %T\n", b)
fmt.Println("operation result of b+3 is ", b+3)
// 결과: Type of b is int operation result of b+3 is 6
}
첫번째 보았던 것 과 같이 interface a에 int인 3을 할당했다고 해서 바로 int끼리와 연산을 할 수는 없다. 하지만 type assertion을 통해 a의 concrete value가 int 자료형임을 확인한 이후에는 int 연산을 할 수 있다.
그렇다고 해서 a.(int)가 a의 정적 타입까지 바꿔주는 것은 아니다. a = a.(int)로 재할당을 했음에도 여전히 a + 3 연산은 invalid한 연산이다. a의 (정적, 실제 코드 단에서의) 자료형은 interface로 고정되어 있다.
물론 assert한 타입과 값을 다른 변수에 넣는 것은 가능하다. b := a.(int)를 하게 되면 실제 코드 단에서도 int 타입이면서 값도 3인 변수를 만들 수 있다.
func main() {
var a interface{}
a = 3
b := a.(string)
fmt.Println(b)
// panic: interface conversion: interface {} is int, not string
b, ok := a.(string)
fmt.Printf("Value of 'b' is %v, and 'ok' is %v", b, ok)
// Value of 'b' is , and 'ok' is false
b, err := a.(int)
fmt.Printf("Value of 'b' is %v, and 'ok' is %v", b, err)
// Value of 'b' is 3, and 'ok' is true
}
선언, 확인한다는 것은 틀릴 수도 있다는 얘기이다. 만약 잘못된 타입으로 assert할 경우 런타임 에러가 발생한다.
이를 방지하기 위해 변수 하나(일반적으로 ok라고 이름 짓는다)를 더 추가하여 assert 하는 방법이 있다. 이럴 경우 ok에 type assertion 성공 여부가 기록되고, 만약 fail하더라도 런타임 에러를 내지 않고 zero value와 false를 반환하고 넘어가게 된다.
직접 짠 코드 분석
위 이론을 바탕으로 내 코드를 분석해보자
// subway_information에서 각 호선의 네이버코드와 역이름 정보 가져오기 -> 해당 역이름 페이지로 접속하여 HTML 획득 -> crawler로 스크래핑
func runCrawler(val map[string]interface{}, baseURL string, lineNum string) {
// val안의 값들은 interface이기 때문에 type assertion 필요
naverCode := int(val["naverCode"].(float64)) // 왜인지 모르겠지만 처음 파일에서 interface로 값 가져올 때 float64로 가져와짐
stationNm := val["stationNm"].(string)
URL := baseURL + strconv.Itoa(naverCode) + "/home"
// 타깃 페이지에 접속하여 outerHMTL 획득
htmlContent := getHTMLContents(URL)
// 페이지 소스 크롤링 & 필요한 정보 정리하기
crawler(htmlContent, lineNum, stationNm)
}
참고로 나는 interface가 뭔지도 모르고 그냥 썼다 ㅎㅎ... python의 dictionary처럼 값 부분에 다양한 타입의 데이터가 올 수 있는 자료형을 찾다가 interface를 찾아서 쓴 것이다.
여튼 이전에 데이터를 저장할 때는 interface를 이용하여 {"naverCode": int자료형의값, "stationNm":string자료형의값} 으로 데이터를 만들고 json marshal하여 저장하였다(자세한 과정과 내용은 이전 포스트에서 확인할 수 있다). 위 코드에서는 이전에 json marshal하여 만든 파일을 다운받아 데이터를 다시 꺼내오는 코드이다.
interface 안의 값이므로 type assertion을 써서 새로운 변수 안에 값을 넣어주었다. naverCode는 int로 저장헀으니 .(int)로, stationNm은 string으로 저장했으니 .(string)으로 시도했다.
하지만 naverCode를 assertion하는데 문제가 발생했는데, int가 아니라 float64로 assertion해야 한다는 에러가 뜬 것이었다. 처음에는 의문이었다... 나는 분명 int로 저장했는데 왜 float64로 assertion하라는걸까...?
나아아중에 이 포스트를 적기 위해 공부하면서 알게 된 것이지만, interface 값을 json unmarshal하게 되면 json 안에 있는 숫자 값들은 모두 float64가 되기 때문이었다. (공식 문서 참고)
To unmarshal JSON into an interface value, Unmarshal stores one of these in the interface value:
bool, for JSON booleans
float64, for JSON numbers
string, for JSON strings
[]interface{}, for JSON arrays
map[string]interface{}, for JSON objects
nil for JSON null
여튼 int가 아닌 float64로 type assert를 하여 float64 타입인 val["naverCode"] 값에 접근하였고, 이를 int로 type conversion하여 naverCode라는 새로운 변수 안에 넣어주었다.
naverCode := int(val["naverCode"].(float64))
ok 방법에 대해서 아예 모르고 넘어갔기 때문에 따로 내 코드에 녹여내지는 못했던 것 같다. 그런데 생각해보니 ok를 쓰지 않아서 얻는 이득도 있지 않나 생각이 났다. ok를 썼다면 naverCode에는 빈값이 들어가지만, 런타임 에러는 나지 않았을 것이다. 코드는 잘 실행되는데 어떤 부분에서 데이터가 비어있는 것이다. 이에 대한 원인을 찾고 디버깅하기까지는 꽤 많은 시간이 걸리지 않았을까 생각한다. 개발 단계에서는 차라리 런타임 에러를 내고, 어느 부분에서 에러가 발생했는지를 보는 것이 더 빠르게 코드를 수정하기에 유리했던 것 같다.
코드를 다 짜고, 운영하는 시점에서도 데이터가 비어 있는 상태로 어물쩡 성공하는 것보다는 아예 에러를 내는 것이 더 좋을 수도 있을 것 같다. 이게 서비스가 아니라, 데이터 엔지니어링 프로젝트여서 더 그런 것 같다. 코드는 성공하는데 데이터가 비어있는 상황보다, 런타임 에러를 내서 빠르게 데이터 엔지니어가 코드를 수정하는 것이 좋을 수도 있겠다는 생각이 든다.
참고자료
[Tucker의 Go 언어 프로그래밍] 20장 인터페이스 1/2
[Tucker의 Go 언어 프로그래밍] 20장 인터페이스 2/2
Type Conversion and Type Assertion in Golang - Everything You Neer to Know (With Examples)
2020 TIL no. 8 - Go의 Interface