백엔드 개발자(node.js)가 되는 과정

백엔드 기술 면접 회고 (객체지향, 데이터베이스, 호이스팅과 스코프)

soopy 2023. 12. 22. 18:35
728x90

객체지향프로그래밍에 대한 질문

파이썬을 처음 공부하던 시절부터 지금까지 몇 차례 객체지향에 대해 정리된 글들을 읽어왔었지만 항상 구체적으로 어떻게 구현해야할까 생각하면 쉽게 감이 오지 않았다. 지금 생각해보면 Nest.js 프레임워크가 객체지향 프로그래밍을 따르는 것으로 보인다. 컨트롤러와 서비스 하나하나를 객체로 구분하고 의존성 주입, 상속 등의 개념이 객체지향과 연관되어 있으니 말이다. 이렇듯 완성된 거대한 구조를 지닌 객체지향 프로그래밍의 예시를 주로 접하다 보니 설계 관점에서 바라봤을 때 너무 큰 벽으로 느껴졌기에 그저 막막했던게 아닌가 생각한다.

객체지향이란?

객체 지향은 소프트웨어의 핵심을 기능이 아닌 객체로 삼으며 “누가 어떠한 일을 할 것인가?”에 초점을 둔다.
책임과 권한을 가진 객체들이 서로 협력해서 필요한 기능을 수행하도록 시스템을 개발하는 방식을 말한다.
절차지향과 상반되는 개념임을 기억하자.

객체지향의 주요 특징을 살펴보자

캡슐화

내부의 세부적인 사항을 감추는 것을 말한다. (정보 은닉)
typescript같은 경우 클래스 변수에 private 설정을 하면 외부에서 접근이 불가한 이러한 설정을 뜻한다.
javascript나 python의 경우 private 설정이 없어 변수명 앞에 '언더바'를 붙임으로 인해 프라이빗함을 표시하긴 하지만 사실상 접근이 가능하다.

상속

상위 클래스의 특징을 하위 클래스가 물려받아 코드의 중복을 제거하고 재사용성을 증대하는 것에 목적을 둔다.

추상화

객체에서 공통된 부분을 모아 상위 개념으로 새롭게 선언하는 것을 추상화라고 한다. 이렇게하면 일관성을 보장할 수 있다.
클래스와 인터페이스를 활용하여 추상화를 한다고 한다. 아래 예시를 보자

// Abstraction  
interface Human {  
name: string;  
setName(name);  
getName();  
}

// 인터페이스에서 상속받은 프로퍼티와 메소드는 구현하지 않을 경우 에러가 발생합니다.  
class Employee implements Human {  
constructor(public name: string) { }

// Human 인터페이스에서 상속받은 메소드  
setName(name) { this.name = name; }

// Human 인터페이스에서 상속받은 메소드  
getName() { return this.name; }  
}

const employee = new Employee("");  
employee.setName("김이박"); // Employee 클래스의 name을 변경하는 setter  
console.log(employee.getName()); // Employee 클래스의 name을 조회하는 getter  

Employee는 Human 인터페이스를 상속받는다. 그래서 Employee는 인터페이스에 구현된 추상 메소드를 강제로 구현해야 한다.
클래스를 설계할 때 공통적으로 묶일 수 있는 기능을 추상화 → 추상 클래스 → 인터페이스로 모델링해서 향후 다형성(Polymorphism)으로 확장할 수 있도록 설계한다.

다형성

해당 부분에 대해서도 질문을 받았었다. 다형성이라는 단어가 언급되기 보다는 동일명의 함수를 사용해야 하는 상황에 대해 언급하셨다.

동일한 메소드의 이름을 사용하지만 메소드에 대해 클래스마다 다르게 구현되는 개념이 다형성이다.
다형성을 통해 역할(인터페이스)과 구현을 분리해서 오버라이딩(Overriding)을 통해 서비스의 구현기능을 유연하게 변경, 확장이 가능하다.
클래스 예시에 자주 등장하는 Animal과 Dog 클래스를 예로 들자면 Dog가 Animal을 상속받을 떄 Animal에서 makeSound라는 함수가 정의되어 있다면 자식 클래스에 해당하는 Dog가 해당 함수도 함께 상속받게 되지만 Dog 클래스 안에서 makeSound함수를 다시 정의한다면 이를 오버라이딩이라 하고 이러한 방식을 통해 Dog 뿐 아니라 Cat이나 기타 새로운 클래스를 생성할 때마다 makeSound 함수를 클래스 특성에 맞게 다양하게 구현할 수 있다는 점이 컨셉이다.

그렇게 되면 가령 Dog와 Cat 클래스로 인스턴스를 여럿 생성했을 때 각 인스턴스들을 리스트에 담아 makeSound 함수를 순차 실행시킨다면 같은 함수명이지만 다른 결과를 얻을 수 있는 것이다. 한통속이지만 조금 다른 결과를 얻어야 하는 상황에서 함수명을 변경하지 않고서 원하는 결과를 얻는 것이다.

이쯤에서 오버로딩과 오버라이딩을 살펴보자.
오버로딩은 메소드의 이름이 같으면서 파라미터의 개수 또는 타입이 다른 메소드를 중복 정의하는 것을 의미한다. JAVA 코드를 예시로 봤었는데 클래스 내에서 같은 이름의 함수를 파라미터만 달리하여 여럿 생성하는 것을 볼 수 있었다. 그러고보니 python 함수에서는 "*args"와 "**kwargs"라는 파라미터를 사용할 수 있는데 이는 파라미터의 개수나 의미가 확정적이지 않을 때 쓸 수 있는 기능이다. 이를 사용하면 하나의 함수명을 가지고 여러 상황에 대체할 수 있게 된다. 일관된 파라미터 입력이 필수적인 상황이 아니라면 편리한 기능이다.

오버라이딩은 기존의 메소드를 재정의하는 것을 의미하며, 부모클래스에게 상속받은 메소드를 같은 이름으로 재정의하면 이를 오버라이딩이라 한다. 아직까지 직접적으로 오버라이딩을 구현해야할 필요성을 접한 경험은 없다. 하지만 분명 앞으로의 코드 설계 과정에서 해당 기능을 응용할 수 있으려면 알고 있어야 한다.

의존성

A 객체의 내부 로직이 변경될 때 그 객체와 연관된 또 다른 객체 B가 영향을 받는다면 B 객체는 A 객체에 의존하고 있다고 말한다.

결합도

높은 결합, 느슨한 결합을 얘기할 때 쓰는 말로, 의존도의 높고 낮은 상태를 평가할 때 쓰는 용어이다. MSA를 EDA로 설계할 때 느슨한 결합이라는 용어를 접할 수 있었다. 간단하게 말해 도메인 서버 간 최대한의 독립을 이루는 것이 목적이다. 그래야 A 도메인에서 코드 수정하는데 B 도메인과 합을 맞추는 비용을 감소할 수 있기 때문이기도 하다.

객체지향프로그래밍이란?

현실 세계의 객체(Object)를 모델링하여 소프트웨어를 개발하는 방법론이다. 이 패러다임은 코드의 재사용성을 증가시키고 유지보수를 향상시키며, 큰 규모의 소프트웨어를 효과적으로 관리할 수 있도록 도와준다.

주요 특징으로는 데이터와 함수를 하나의 객체(클래스)로 묶어서 관리한다. 절차지향의 경우 함수의 연속적인 호출에 의존하는 방식이며, 데이터와 함수를 별도로 관리한다. 생각해보면 클래스는 항상 init을 통해 필요한 데이터를 선언하거나 파라미터를 통해 공급받는다. 그러니 하나의 객체가 업무를 수행하는데 필요한 데이터를 내부에서 받아 두고, 그것을 토대로 로직이 실행되므로 오류가 발생하면 관련 객체를 디버깅하면 된다. 하지만 절차지향의 경우 사용 경험은 없지만 데이터와 함수를 별도로 관리하므로 객체지향처럼 디버깅의 타겟을 어디로 잡아야할지 어려울 수 있을 것 같다. 심할 경우 처음부터 차근차근 다 훑어봐야할지도 모른다는 생각이 들었다.

데이터베이스에 대한 질문

데이터베이스와 정규화를 왜 하는가?

데이터베이스의 중복을 최소화하기 위한 구조화 작업이다. 가령 유저 테이블에서 유저의 출신 학교와 해당 학교 주소가 같이 있다고 하자. 이 경우 동일 학교 출신의 유저가 추가될 때 마다 동일한 학교 주소가 함께 추가될텐데 이런 부분이 낭비라는 것이다. 이 경우는 학교 테이블로 따로 떼어낸다면 동일학 학교 주소가 반복적으로 저장될 필요가 없어지기 때문이다.

정규화는 장점이 될 수도 단점이 될 수도 있다고 알려져 있다. 그 이유는 정규화의 목적에 있다. 위에서 언급한 바와 같이 데이터의 불필요한 중복을 방지하기 위해 테이블 수를 늘려가는 방식을 채택하지만 테이블이 늘어간다는 것은 그만큼 JOIN 연산을 요청할 가능성이 증가한다는 얘기가 된다. 그래서 JOIN 연산이 많아져서 오히려 성능이 떨어질 수도 있다. 하지만 그렇다고 정규화를 하지 않으면 하나의 테이블에서 조회해야할 데이터가 그만큼 많아진다는 이야기가 되므로 이 부분은 또 이대로 성능 저하의 원인이 된다. 하지만 확실한 건 무조건 데이터량이 방대하게 증가하면 정규화의 유무를 떠나 대책이 필요하게 된다. 이 부분에 대해서는 CQRS 개념을 적용할 것 같다. 읽기와 쓰기를 분리하는 것이다. 실제로 배민 우아한테크 세미나에서 주문 조회 트래픽이 너무 높아 조회만을 위한 mongoDB를 쓰기용 SQL와 별개로 둔다는 내용을 본 적이 있다.

데이터베이스의 1:N 관계에서 N+1 문제가 무엇인가?

ORM 사용에서 OneToMany와 ManyToOne과 같은 연관 관계로 묶인 테이블을 조회할 때 성능이슈가 발생하는 문제을 말한다. 
가령 ChannelUser라는 Entity가 1:다 관계로 묶여 있다고 하고, Channel의 레코드는 총 10개가 있다고 가정하자.
이제 ORM으로 Channel Entity를 대상으로 findAll을 명령하면 내부적으로 "SELECT * FROM Channel"이라는 쿼리문 하나를 생성하여 데이터베이스에 요청할 것이다. 이때 생성된 쿼리문이 1개이고 딱 한 번 요청을 하는 것이다.

하지만 문제는 Channel과 연관된 User데이터도 함께 가져오려고 한다는 것이다. 그래서 최초 요청했던 findAll의 결과로 총 10개의 레코드를 획득할텐데, 이 각각의 채널에 속한 User정보를 가져오기 위해 "SELECT * FROM User WHERE User.channel_id=?"와 같은 형태의 쿼리문을 총 10개 생성해서 요청을 하게 된다. 그래서 의도는 findAll을 사용하여 SQL에 딱 한 번의 요청을 할 것으로 기대하지만 사실상 총 11번의 요청을 하게 된 셈이다.
User 테이블에 총 10개의 Channel id를 타겟으로 총 10번을 조회한다고 생각해보면 끔찍하지 않은가? Channel과 User 레코드가 모두 많으면 많을 수록 심각한 성능이슈로 이어질 가능성이 높을 것이다.

정리하자면 N+1에서 1이 findAll 요청에 해당하고 N이 쉽게 말해 findAll 결과 갯수, 구체적으로는 findAll의 결과인 각각의 레코드를 대상으로 연관된 테이블을 조회하기 위한 요청의 갯수를 의미한다.

ORM에서 Fetch의 EAGER과 LAZY 설정

위 N+1문제가 ORM에서 늘상 발생하는 그런 문제는 아니다. 내부적으로 ORM에서는 OneToMany나 ManyToOne 등과 같은 관계 설정에서 옵션으로 Fetch를 설정하는 옵션이 있다. 아래 TypeORM 예시를 보자

import { Entity, PrimaryGeneratedColumn, Column, OneToMany } from 'typeorm';
import { User } from './User';

@Entity()
export class Channel {
  @PrimaryGeneratedColumn()
  id: number;

  @Column()
  name: string;

  // EAGER로 설정된 OneToMany 관계
  @OneToMany(() => User, user => user.channel, { eager: true })
  users: User[];
}

eager: true로 설정할 경우 위에서 설명한 N+1 방식으로 동작하게 된다. 즉 Channel을 조회하는 것 만으로도 연관된 User정보를 함께 들고 오게 된다.

eager 대신에 lazy: true로 설정하게 되면 find만으로는 연관된 정보를 당장에 들고오지는 않는다 하지만 N+1 문제를 일으킬 여지가 남아 있다. 아래 코드를 보자

// 예제: ChannelEntity 조회 (Lazy Loading 활성화)
const channel = await channelRepository.findOne(1); // channelEntity의 id가 1인 개체 조회
console.log(channel.users);

Lazy가 활성화 되었기 때문에 channel.users에 접근이 가능하다. 그리고 이 코드가 실행되는 순간 N+1에서 N이 동작한다. 즉 저런식으로 코드를 작성할 경우 N+1을 나눠서 실행한 것과 다를게 없다.
헷깔릴 수 있어 부가 설명을 하자면 위 코드의 경우 findOne을 썼기 때문에 N = 1이 된다. 채널 하나를 대상으로 연관 데이터 조회 쿼리를 날리기 때문이다. 총 2개의 쿼리를 날린 셈이다. 하지만 그냥 find를 쓰면(typeORM은 find가 곧 findAll이다.) N 개수가 늘어날 것이다.

내가 면접에서 N+1 문제를 대답할 수 없었던 이유

typeORM은 기본적으로 N+1 문제를 경험하도록 두지 않는다. 우선 기본적으로 관계 설정에서의 디폴트 값은 eager도 lazy도 아닌 아무 설정도 되어있지 않다. 그렇기 때문에 기본적으로 위 코드처럼 코드를 작성할 일이 없다. 도큐먼트에서도 JOIN 연산이 필요한 쿼리를 요청할 때는 find의 relations 옵션을 쓰거나 createQueryBuilder 메서드를 쓰도록 알려주기 때문이다. 

relations 옵션

// ...윗 부분 생략
const channels = await this.channelRepository.find({
  relations: ['user']
});
console.log(channels[0].users)

위 처럼 relations 옵션을 적용하면 기본적으로 JOIN 연산이 적용된다. 옵션에 달린 주석도 한 번 살펴보자

기본적으로는 join이 적용된다고 명시되어 있다. 추가적으로 relationLoadStrategy옵션을 통해 "join"과 "query" 방식 중 선택할 수도 있다. 주석에서 명시된 것과 같이 경우에 따라서는 join이 오히려 세부 query 요청보다 느릴 가능성도 있으니 그때는 "query" 방식을 적용하라고 한다. 여기서 query는 N+1 문제를 부를 수 있는 그 쿼리 요청 방식을 말한다. (그런데 @deprecated가 있는 것을 보니 웬만해서는 그냥 join을 쓰는 듯 하다.) 어찌됐건 relations 옵션을 설정하면 join연산이 적용된 쿼리문을 한 개를 요청하게 된다.

createQueryBuilder

const channels = await this.channelRepository
  .createQueryBuilder('channel')
  .leftJoinAndSelect('channel.users', 'user')
  .getMany();

위 메서드처럼 쿼리를 직접 작성하는 듯한 느낌의 방식으로도 연관 데이터 불러오기가 가능하다. 추가적으로 select 메서드가 추가적으로 적용된다면 join 결과 중 일부 컬럼만 뽑아서 가져올 수도 있다. 이 방식도 relations를 사용하는 것과 큰 차이는 없다.

정리하자면 나의 경우 처음부터 연관 데이터를 불러와야 할 경우 createQueryBuilder를 써서 JOIN을 적용하는것을 당연하게 여겼고, 습관이 들어져 있었기 때문에 ORM을 통한 N+1문제를 발생시킬 일이 없었다. 하지만 필요에 따라 세부적인 쿼리 튜닝이 필요할 경우 이러이러한 방식을 활용할 수 있다는 점을 알아둘 수 있는 시간이었다.

정합성

정합성이라는 단어는 항상 뉘앙스는 알면서 설명하기는 어려웠다. 이제 기억해야할 두 단어는 일관성과 무결성이다. 데이터베이스에서의 일관성은 말 그대로 일관된 상태를 유지함을 뜻하는데 구체적으로 말하자면 내가 외래키나 유니크 설정으로 규칙이나 제약을 가함으로 인해 데이터에 어떠한 변화가 발생하더라도 기본 틀(규칙, 제약)이 깨져서는 안되게 한다는 것을 말한다. 이를 지키기 위해 무결성을 지킨다는 개념이 나온다. 이는 각 행이 반드시 Primary Key를 가지도록 하여 행의 완전 중복을 방지한다는 개체 무결성과 Foreign Key와 Primary Key의 참조 관계가 꺠어지지 않도록 하는 참조 무결성, 마지막으로 각 attr은 사전 정의된 타입과 크기를 가져야 한다는 도메인 무결성을 통틀어 말한다. 이 둘을 잘 지켜내는 것을 정합성을 보장한다고 말하는 것이다.

 

var, let, const의 차이와 호이스팅, 스코프

자바스크립트에서 var, let, const 모두 호이스팅을 한다는 사실을 뒤늦게 알게 되었다. 사실 호이스팅 자체가 잘 이해가 가지 않았기 때문인 것 같다. "호이스팅은 선언된 변수나 함수를 코드의 상단으로 끌어올린다." 라는 표현을 쓰는데 이전에는 "var apple;을 선언한 뒤 console.log(apple)을 입력하고, 그 아랫줄에 apple = 1이라고 초기화하면 console.log(apple)은 1이 출력되어야 한다는 말인가?" 라고 받아들여졌다. 하지만 틀린 말이다. 실제로 출력값은 undefined가 나오기 때문에 별 문제가 없어보인다. 그럼 상단으로 끌어올려지는건 대체 뭐란 말인가?

호이스팅

일단 호이스팅은 스크립트의 컴파일 단계에서 벌어지는 일이라고 한다. 컴파일 과정에서 var, let, const 변수가 호이스팅 된다는 말은 "선언된" 변수가 가장 윗줄로 끌어올려 진다는 말이었다. 여기에서 선언과 초기화에 대한 이해가 필요하다. 먼저 let apple; 또는 var apple; 이라고 코드를 작성하면 이를 apple 변수가 "선언"되고, undefined라는 값으로 "초기화" 되었다고 한다. 선언과 초기화 단계가 분리되어 있다는 점을 기억하자. 물론 let apple = 1로 입력하면 이 때는 선언되고, 1로 초기화된 것을 의미한다.

이제 호이스팅이 되면 var와 let에 어떤 차이가 있는지 살펴보자.

console.log(apple); // 레퍼런스 에러: Cannot access 'apple' before initialization
let apple;


먼저 let apple 의 경우 해당 코드가 작성된 line에 당도했을 때 undefined가 초기화 된다. 그러므로 호이스팅된 apple은 "선언"된 시점의 apple만 끌어올려 진다고 볼 수 있다. 그래서 apple 변수는 "일시적인 사각지대(temporal dead zone)" 라는 영역에 존재하게 되어 console.log(apple) 출력 시 'Cannot access 'apple' before initialization' 에러가 발생한다. 이게 현재의 우리에게 익숙한 결과일 것이다.

console.log(apple); // undefined
var apple;

하지만 var apple의 경우는 조금 다르다. apple이 호이스팅 되면 선언한 시점에 초기화도 함께 이루어진다. let의 경우 실제로 let apple;이라는 코드를 작성한 line에 당도했을 때 초기화가 이루어진다는 점에서 이 둘은 차이가 있다. 그래서 console.log(apple)이 undefined를 출력하게 되는 것이다. 

console.log(apple); // undefined
var apple = 10;

혹시나 해서 위 코드에서 console.log(apple)이 10이 출력되리라 생각할 수도 있을 것이다. 하지만 그렇지 않다. 호이스팅되는 것은 apple에 대한 선언인데, var의 경우 선언과 동시에 초기화가 이루어진다는 점을 되새겨보자. 그러므로 var apple = 10은 사실상 10으로의 초기화가 아니라 10으로의 값 변경이라고 볼 수 있다. 초기화 시점이 호이스팅된 시점이기 때문이다.

console.log(apple);
const apple; // SyntaxError: Missing initializer in const declaration

참고로 const는 var와 let보다 좀 더 엄격한 면이 있다. 우선 위 코드와 같이 const apple에 대한 값을 설정하지 않으면 문법 오류를 내버린다. 그래서 초기값을 지정해 주어야 선언이 발생한다고 볼 수 있다. 그리고는 값이 초기화되는 시점도 let과 동일하지만 const는 초기화 된 이후 값을 변경할 수 없다는 차이가 있다.

정리하자면 let, const vs var는 선언된 변수의 초기화 시점에 차이가 있다. 라고 받아들이면 되겠다.

위 호이스팅 문제는 var를 안쓰면 크게 헷깔릴 문제가 아니며 나처럼 파이썬으로 코딩 공부를 시작했다면 let, const만 써야지라고 생각하면 호이스팅 문제로 혼란을 겪을 일이 크게 없을 것이다.

스코프

var vs let, const 문제는 스코프의 차이에 의해서도 발생한다.
먼저 스코프는 변수의 유효 범위를 말한다. 초기화된 변수를 사용할 수 있는 범위가 상황에 따라 다르다는 의미이다. 그래서 스코프는 글로벌, 함수 스코프, 블록 스코프 라는 범위로 구분한다. 글로벌은 스크립트 전체 영역을 의미하고, 함수는 말그대로 함수(function)영역, 블록은 if, while, for문 영역을 말한다. 이렇게 각 영역이 구분되어 있음을 기억하고서 계속 이어나가자.
먼저 함수와 블록은 글로벌 영역의 부분집합에 속한다. 그래서 글로벌 영역에서 초기화된 변수는 함수에서도, if문, for문, while문, 어디서든지 사용이 가능하다.

const apple = '애플'

function fx1() {
    console.log(`함수 안에 ${apple}`)
}

fx1()

if (true) {
    console.log(`if문 안에 ${apple}`)
}
함수 안에 애플
if문 안에 애플

하지만 그 반대는 제한이 있다. 함수스코프와 블록스코프에서 선언된 변수의 영향력은 그 바깥 영역인 글로벌로 뻗어나가지는 못한다. 태생이 부분집합에 속하는 스코프이기 때문에 그곳을 벗어날 수 없다는 개념이 자바스크립트에서는 일반적으로 적용되고 있다.

if (true) {
    let apple = '애플'
}
console.log(apple) // apple is not defined

// 함수도 마찬가지다.

그런데 var는 희안하게도 이 개념을 지키지 않았다. 바로 블록스코프 내에서 선언된 var 변수가 글로벌 변수까지 영향력이 닿는 것이었다.(함수 스코프에 대해서는 선언된 var 변수가 함수 스코프를 벗어나지 못한다.)

console.log(x) // undefined

if (true) {  
    var x = 3;  
}  

console.log(x) // 3

 

위 코드를 보면 if문(블록스코프) 안에 선언된 var x 변수가 글로벌에서 undefined를 출력하는 것을 확인할 수 있다. 이 문제가 바로 var의 호이스팅 특징 때문에 발생한 것이다. 앞서 말한 바와 같이 var는 호이스팅 되면서 선언과 동시에 초기화된다. 그러니 console.log(x)가 undefined를 출력하는 것이다. 그리고 블록스코프 안에서 선언된 변수의 값을 3으로 변경하면서 호이스팅된 x값이 영향을 받고, 이후 console.log(x)에는 3이 출력된다. 그래서 var의 스코프 개념이 다소 혼잡하여 오류를 범할 가능성이 그만큼 높은 것이다. 

console.log(x) // x is not defined

if (true) {  
    let x = 3;  
}  

console.log(x) // x is not defined

let이나 const를 쓰면 사고하기 편리하다. "블록스코프 안에서 선언되었으니 밖을 벗어나지 못할거야" 라는 개념만 이해하면 위 코드가 매우 쉽게 다가오기 때문이다. 이러한 이유로 var 사용을 지양하는 것이다.

let x = 10

console.log(x) // 10

if (true) {  
    x = 3;  
}  

console.log(x) // 3

 

그렇다면 if문을 통해서 글로벌 값을 변경하고 싶을 수도 있을 텐데 그런 경우 위와 같이 글로벌에서 변수를 선언한 뒤 해당 변수를 함수나 블록스코프에서 변경하면 된다. 글로벌 변수는 어디든 갈 수 있기 때문이다.

 

(부록) 파이썬과 자바스크립트의 스코프 차이

# python
if True:  
    x = 3  
print(x) # 3 출력
// javascript
if (true) {  
  let x = 3;  
}  
console.log(x) // x is not defined

 

파이썬을 공부한 뒤 자바스크립트를 공부한 나로서 재밌는 점을 발견했다. 위와 같이 파이썬 변수는 if문에서 선언되어도 글로벌로 뻗어나온다. 사실 파이썬은 함수와 모듈 단위로 변수의 스코프가 구분되다보니 if문은 그냥 글로벌로 취급된다. 

728x90
728x90