리액트의 Advanced guides 페이지를 따라하면서 노트한 내용이다. 가이드 쪽은 옴니버스 같은 기분이라서 반반으로 나눠 읽기로 했다. 기록하고 싶은 부분만 남겼기 때문에 자세한 내용은 각 페이지를 참고한다.

JSX in Depth

리액트 엘리먼트 타입 정의

JSX는 React.createElement(component, props, ...children)의 편의 문법이다. 그래서 JSX를 사용할 때는 스코프 내에 React가 꼭 필요하다.

다음처럼 점 표기법을 사용할 수 있다. 한번에 여러 컴포넌트 내보낼 때 편리하다.

const MyComponents = {
  DatePicker: function DatePicker(props) {
    return <div>Imagine a {props.color} datepicker here.</div>;
  }
}

function BlueDatePicker() {
  return <MyComponents.DatePicker color="blue" />;
}

사용자 정의 컴포넌트는 꼭 Capitalized 되어야 한다. 소문자로 된 컴포넌트라면 사용하기 전에 Capitalized 하는 방식으로 사용할 수 있다. 동적으로 사용할 때도 이런 방식으로 사용한다.

const components = {
  photo: PhotoStory,
  video: VideoStory
};

function Story(Props) {
  // Wrong
  return <components[props.storyType] story={props.story} />;

  // Correct
  const SpecificStory = components[props.storyType];
  return <SpecificStory story={props.story} />;
}

Props

아래는 각각 동일한 표현이다.

// 문자열 리터럴
<MyComponent message="hello world" />
<MyComponent message={'hello world'} />

// 문자열 리터럴은 HTML-unescaped로 처리됨
<MyComponent message="<3" />
<MyComponent message={'<3'} />

// Prop의 기본 값은 `True`
<MyComponent autocomplete />
<MyComponent autocomplete={true} />

Spread Attribute로 간편하게 표현할 수 있다.

<Greeting firstName="John" lastName="Dorian" nickName="Bambi" />

const props = {firstName: 'John', lastName: 'Dorian', nickName: 'Bambi'};
<Greeting {...props} />

const { nickName, ...other } = props;
const nick = nickName === 'Bambi' ? 'Newbie' : 'Scooter';
<button nickName={nick} {...other} />

자식 노드

문자열은 문자열로 처리되고 개행은 공백으로 처리된다.

render()에서 배열로 반환하면 합쳐서 렌더링한다.

JS 표현식도 자식 노드에 사용할 수 있다. 배열도 렌더링 하기 때문에 다음처럼 쓸 수 있다.

<ul>
  {todos.map((message) => <Item key={message} message={message} />)}
</ul>

children에 함수도 전달할 수 있다. Lifting state up이랑 비슷한 느낌이다. 세부 구현을 사용자에게 위임할 수 있을 것 같다.

function Repeat(props) {
  let items = [];
  for (let i = 0; i < props.numTimes; i++) {
    items.push(props.children(i));
  }
  return <div>{items}</div>;
}

function TodoList() {
  const todos = ['finish doc', 'submit pr', 'review'];
  return (
  <Repeat numTimes={10}>
    {(index) => <div key={index}>This is item {index} in the list</div>}
  </Repeat>
  );
}

Boolean, null, undefined는 화면에 렌더링하지 않는다.

// 조건부 표현
{showHeader && <Header />}

// false가 아닌 falsy한 값을 반환하는 경우에는 렌더링되는 점을 주의, 명확하게 boolean으로 반환할 것
{props.messages.length > 0 && <MessageList messages={props.messages} />}

Boolean, null, undefined를 표시하려면 {String(value)} 식으로 작성한다.

PropTypes로 타입 확인하기

정적 타입을 사용하지 않는다면 사용할 만한 검증 라이브러리다. 원래는 React에 포함되어 있다가 분리된 모양이다. 개발 모드에서만 값을 검사한다. 자세한 사용법은 prop-types 참고한다.

class Greeting extends React.Component {
  // ...
}

Greeting.propTypes = {
  name: PropTypes.string,
  nicknames: PropTypes.arrayOf(PropTypes.string),
  children: PropTypes.element.isRequired
};

Greeting.defaultProps = {
  name: 'Stranger'
};

클래스 프로퍼티 문법으로도 사용할 수 있다.

정적 타입 검사

FlowTypeScript를 설정하고 사용하는 방법을 설명한다.

코틀린도 js를 타겟 플랫폼으로 사용 가능하다고 한다. Kotlin Wrappers, create-react-kotlin-app을 참고한다.

Refs와 DOM

일반적으로 데이터 흐름은 props를 사용하게 되어 있지만 몇몇 경우에는 이런 방식에 적합하지 않다.

  • 커서 위치, 텍스트 선택, 미디어 재생
  • 애니메이션 처리
  • 서드파티 DOM 라이브러리와 연동

선언적으로 해결할 수 있는 부분에서는 ref를 쓰지 않는 것을 권한다. 예를 들면 Dialog 컴포넌트에 open(), close() 메소드를 만드는 것보다 isOpen prop을 넘겨주는 식으로 처리한다. 안되는걸 되게 하려고 ref를 쓸 수는 있지만 쓰기 전에 컴포넌트 위계를 보고 상태를 어디에 위치해야 하는지 잘 고려해야 한다. ref를 쓰는 방식보다 상위 계층에 상태가 위치하는게 더 적절하다면 Lifting State Up 방식을 적용해서 해결한다.

Ref는 DOM 컴포넌트와 클래스 컴포넌트에서만 사용할 수 있다. 컴포넌트 자체를 레퍼런스로 넘기 때문인데 함수형 컴포넌트 내에서 DOM 컴포넌트나 클래스 컴포넌트에는 사용할 수 있다.

마운트 될 때는 인자에 해당 엘리먼트를 전달하고 언마운트에는 null을 전달한다. 이 refcomponentDidMount, componentDidUpdate 전에 호출된다.

일반적으로 DOM 엘리먼트에 접근해야 할 일이 있을 때 많이 쓴다. ref={input => this.textInput = input}

class AutoFocusTextInput extends React.Component {
  componentDidMount() {
    this.textInput.focusTextInput();
  }

  render() {
    return <CustomTextInput
      ref={(input) => { this.textInput = input; }}/>;
  }
}

하위 엘리먼트의 DOM ref를 상위에서 사용하려면 props 체인을 따라서 함수를 내려주면 된다. 여기서 inputRef는 일반 prop을 정의해서 쓴 것이지 ref처럼 특별한 기능이 있는 prop이 아니다.

function CustomTextInput(props) {
  return <div><input ref={props.inputRef} /></div>;
}

function FormLayout(props) {
  return (
    <div>
      Name: <CustomTextInput inputRef={props.inputRef} />
    </div>
  );
}

class AwesomePage extends React.Component {
  render() {
    return <FormLayout inputRef={el => this.inputElement = el} />;
  }
}

가능하면 DOM을 노출해서 사용하지 않는 것이 좋다고 한다. 어쩔 수 없이 필요할 때만 사용하고 극단적으로는 findDOMNode()라는 흑마법도 존재한다고.

ref가 두 차례씩 호출되는 것(마운트 && 언마운트)은 null을 전달해서 기존에 연결된 레퍼런스를 지우는 역할도 겸하고 있기 때문이다. (DOM 레퍼런스를 냅두면 DOM은 해제되어도 GC가 지우지 않고 남겨둔다. 그래서 복잡한거 하지 않도록 간단한 함수 형태로만 소개하는 것 같다.)

Uncontrolled 컴포넌트

폼 데이터를 React 컴포넌트에서 다루는 controlled 컴포넌트와 반대로 DOM 자체에서 다루도록 하는 방식의 컴포넌트를 뜻한다.

DOM 엘리먼트를 사용하면 내장된 동작을 그대로 사용할 수 있는 특징이 있다. Controlled and uncontrolled form inputs in React don’t have to be complicated 글에서 비교 도표를 볼 수 있다.

<input defaultValue="Bob" type="text" ref={(input) => this.input = input} />
// checkbox, radio는 defaultChecked, 그 외는 defaultValue

input[type="file"]은 읽기 전용으로 항상 uncontrolled 컴포넌트다. ref를 사용해서 DOM을 직접 다룬다.

성능 최적화

프로덕션 빌드를 사용한다.

  • Create React App (이래서 다 이거 얘기하는듯)
  • 단일 파일로 빌드
  • Brunch -p 옵션 빌드
  • Browserify의 envify, uglifyify, uglify-js
  • Rollup의 replace, commonjs, uglify
  • webpack

크롬 개발자 도구의 성능 탭에서 컴포넌트를 프로파일링한다. 프로파일링 전에 크롬 확장과 React DevTool 끄는 것 잊지 않는다. 성능 테스트 과정 참조.

긴 목록은 한번에 로드하지 말고 동적으로 처리해야 성능이 좋다. react virtualized 같은 패키지가 있고 Virtualize, windowing가 검색 키워드.

React devtool에서 Highlight updates 기능으로 불필요하게 렌더링이 되는 지점을 찾아서 수정한다.

shouldComponentUpdate()의 반환값으로 렌더링 여부를 수동으로 제어할 수 있다. 이 부분을 직접 작성하는 것보다 React.PureComponent를 상속받는게 낫다. PureComponent는 기존 Component 구현에 prop과 state의 shallow 비교를 포함하고 있다. shouldComponentUpdate() 메소드의 동작 방식과 구현은 본문을 읽는다. 갱신이 필요하면 노드를 타고 올라가서 모두 갱신하게 만든다.

불필요한 갱신은 가변 데이터에서 주로 나타나기 때문에 불변 데이터를 사용하면 이 문제를 쉽게 피할 수 있다. Array.push()로 기존 배열을 조작하는 것보다 Array.concat(), [...words, 'new data']을 사용해서 원 데이터가 변형되지 않도록 한다. Immutable.js을 써도 된다.

ES6 없이 React

ES6 없이 쓸 일이 있을 때 읽는다.

JSX 없이 React

JSX 없이 쓸 일이 있을 때 읽는다.


가이드 나머지 다 보고 나면 몇 가지 먼저 만들려고 한다. 그리고나서 enzyme이랑 상태 관리하는 패키지 redux랑 mobx? 찾아서 볼 생각이다. 많은 분들이 열심히 쓰고 있어서 주워들은 것만 공부해도 좀 걸릴 것 같다.

리액트의 Quick start 페이지를 따라하면서 노트한 내용이다. js의 컨텍스트에서 이해할 수 있는 부분은 적지 않았다. 코드 스니핏도 간단히 알아볼 수 있게만 적어놔서 전체 내용이나 설명이 궁금하다면 본문을 확인하는게 좋겠다.

연습 환경 설치

node를 쓴지 오래되어서 업데이트부터 했다. nvm이 있어야 한다.

$ nvm ls-remote
$ nvm install v9.3.0 && nvm alias default v9.3.0
$ nvm use default
$ npm install -g yarn

npx를 이용해서 playground라는 이름으로 프로젝트를 생성한다. npx는 npm 5.2.0+부터 사용할 수 있다.

$ npx create-react-app playground
$ cd playground
$ yarn start

JSX

리액트는 마크업과 로직을 인위적으로 분리하지 않고 대신에 컴포넌트라는 단위를 만들어 약하게 결합하도록 만들었다. JSX를 꼭 사용할 필요는 없지만 시각적으로 더 편리하다.

리액트 엘리먼트는 ReactDOM.render()로 렌더링한다. root DOM 노드를 지정하면 그 내부의 모든 노드를 리액트가 관리한다.

const element = <h1>Hello, world</h1>;
ReactDOM.render(element, document.getElementById('root'));
function formatName(user) { /* ... * /}

const element = (
  <h1>
    Hello, {formatName(user)}!
  </h1>
);

// 함수형 컴포넌트
function HelloBlock() {
  return <div>Hello</div>;
}

// 클래스 컴포넌트
class GoodbyeBlock extends React.Component {
  render() {
    return <div>Good bye</div>;
  }
}

노드 트리를 직접 조회하지 않고 ReactDOM을 통해 비교한 후, 변경된 사항만 반영하기 때문에 DOM을 직접 읽고 조작하는 방식보다 간결하다.

props

컴포넌트는 자바스크립트 함수와 같아서 인자 입력(props)을 받고 리액트 엘리먼트를 반환한다.

const Greeting = (props) => <h1>Hello, {props.name}!</h1>;

function Greeting(props) {
  return (
    <h1>Hello, {props.name}!</h1>
  );
}

class Greeting extends React.Component {
  render () {
    return <h1>Hello, {this.props.name}!</h1>
  }
}

props는 엘리먼트의 어트리뷰트로 전달한다. expression은 {}로, 문자열은 ""로 보낸다.

const element = <Comment user={user} text="string value blarblar" />;

모든 리액트 컴포넌트는 props를 변경하지 않는 순수 함수처럼 동작해야 한다. 이 규칙을 깨지 않고 출력값을 변경하기 위해 state라는 개념이 있다.

State

State는 props과 비슷하지만 private이고 컴포넌트가 전적으로 제어한다. state는 클래스로 작성한 컴포넌트에서 사용할 수 있다.

이 State를 제어하기 위해 componentDidMount()componentWillUnmount()와 같은 생애주기 훅(hook)을 사용한다. State에 대한 지정은 setState() 메소드를 사용한다.

this.state = {} 형태는 오직 constructor() 내에서만 사용 가능하며 그 외에는 setState()를 사용해야 한다. 그러지 않으면 렌더링에 반영되지 않는다.

한번 갱신하는데 setState() 호출을 여러 차례 한다면 경쟁 상태가 될 수 있다. 대신 함수 형태로 전달하는 것이 가능하다.

this.setState((prevState, props) => ({
  conter: prevState.counter + props.increment
}));

setState로 전달한 개체는 this.state에 병합되는 방식으로 동작한다. 전달하지 않은 프로퍼티는 영향을 받지 않는다.

state는 컴포넌트에 속했기 때문에 외부에서는 어떻게 정의되어 있는지 알 수 없고 알 필요도 없다. 지역적, 캡슐화되어 있다고 이야기하는 이유.

컴포넌트가 stateful, stateless인지는 때마다 다르게 정의해서 사용할 수 있음.

이벤트 제어

return <a href="#" onClick={handleClick}>Click Me</a>;

(이제는 시멘틱웹 얘기 부질 없는 것입니까. 나 너무 오래된 사람인듯.)

handleClicke.preventDefault()를 명시적으로 사용해야 함. (return false 넣는거 싫어하는 사람이라서 이런 방식 좋음.) 이 e는 W3C 스펙에서의 그 SyntheticEvent인데 리액트에 맞게 랩핑되어 있다.

이벤트에 넘겨줄 때는 js 특성 상 컨텍스트를 명시적으로 지정해야 한다. 즉, this 바인딩을 잊지 말아야 한다.

constructor(props) {
  super(props);
  // ...
  this.handleClick = this.handleClick.bind(this);
}

인자를 전달할 때는,

<button onClick={this.deleteRow.bind(this, id)}>Delete This</button>
// 이러면 클릭했을 때 `deleteRow(id, e)`로 호출해준다.

이 귀찮음을 피하기 위한 대안 두 가지로 public class fields 문법을 사용하는 방식과 익명 함수를 사용해 스코프를 전달하는 방식을 제안하는데 전자는 아직 확정된 문법이 아니다. 후자는 그나마 깔끔하지만 렌더링 될 때마다 새 콜백이 생성된다. 대부분 괜찮지만 하위 컴포넌트로 전달되었을 때 불필요한 추가 랜더링이 계속 나타나서 성능에 영향을 줄 수 있다.

// 1. public class fields syntax (experimental)
class Button extends React.Component {
  handleClick = () => {
    console.log('this is', this);
  }
  // ...
}

// 2. arrow funtion as callback
class Button extends React.Component {
  render () {
    return <button onClick={(e) => this.handleClick(e)}>Click me</button>;
  }
}

조건부 렌더링

if 사용하면 된다! 인라인으로 사용하고 싶다면 { expression && <p>Elements</p> } 식으로 사용한다. if-else는 삼항연산자를 사용한다.

null을 반환하면 화면에 렌더링하지 않는다. 예시로 에러 메시지 표시 나왔다. 렌더링 안해도 생애주기 훅은 여전히 호출된다.

리스트와 키

const faces = ['?', '?', '?', '?', '?', '?', '?', '?'];
const listItems = faces.map((face, index) => (
  <li key={index}>
    {face}
  </li>
));

// 안정적인 id를 key로 사용하고 없으면 최후의 수단으로
// 배열 자체의 인덱스를 사용한다. 여기는 예시니까 그냥
// 인덱스를 사용했다.

const FaceList = () => <ul>{listItems}</ul>;

// or inline format
const FaceList = () => (
  <ul>
  {faces.map((face, index) => (
    <li key={index}>
      {face}
    </li>
  ))}
  </ul>
)

변화를 감지하고 반영하기 위해서는 id가 필요한데 key로 지정된 값을 활용한다. 또한 같은 계층에는 키가 유일해야 한다.

키는 리스트에서만 사용하고 각 항목 컴포넌트 정의에서 지정하지 않는다. 위에서 보면 li 역할하는 컴포넌트를 정의할 때 key를 지정하는게 아니라 listItems처럼 배열을 책임지는 컴포넌트에서 key를 지정해야 한다.

key는 prop처럼 작성하지만 실제로 해당 컴포넌트에 전달되진 않는다. 전달하려면 다른 이름의 prop을 명시적으로 지정해야 한다.

폼 Form

그냥 html 쓰듯 작성해도 문제 없다! 하지만 js가 있어야 미려한 기능을 만들 수 있는건 당연하고. 이럴 때는 controlled 컴포넌트를 만들어서 해결한다.

<input> 같은 엘리먼트는 상태를 스스로 관리한다. 리액트는 가변값을 state에 저장하고 setState()를 사용한다. 이 두가지를 하나로 합쳐 State를 “single source of truth”로 사용하고 사용자의 입력을 여기에 반영하는 식으로 만든다. 폼 엘리먼트를 리액트에서 관리하니까 controlled 컴포넌트라고 한다.

// bind 생략
handleChange(event) {
  this.setState({
    address: event.target.value
  });

  // 여러 input을 처리할 때는 computed property name 문법으로
  this.setState({
    [event.target.name]: event.target.value
  });
}
// ...
render() {
  return <input name="address" type="text" value={this.state.value} onChange={this.handleChange} />
}

input[type="file"]은 읽기 전용인데 이런 경우는 uncontrolled 컴포넌트라고 한다.

value에 직접 값을 전달하면 사용자가 값을 변경할 수 없다. 값을 전달한 이후에 다시 값을 변경할 수 있게 하려면 undefinednull을 다시 전달해야 한다.

state 위로 보내기 lifting state up

prop에 state를 조작할 수 있는 함수를 만들어서 전달하면 자식 노드에서도 부모 노드의 state를 간접적으로 조작할 수 있다. 이 방법을 “lifting state up” 라고 말한다.

아래는 NameInput에서 변경된 내용을 WelcomeBoard에서 전달받은 onNameChange를 사용해 갱신하는 방식이다.

class NameInput extends React.Component {
  handleChange(e) {
    this.props.onNameChange(e.target.value);
  }
  render() {
    return (
      <input type="text"
        value={this.props.name}
        onChange={this.handleChange.bind(this)} />
    )
  }
}

class WelcomeBoard extends React.Component {
  constructor(props) {
    super(props);
    this.state = {name: 'stranger'};
  }

  handleNameChange(name) {
    this.setState({
      name: name === '' ? 'stranger' : name,
    });
  }

  render() {
    return (
      <div>
        <p>Hello, {this.state.name}!</p>
        <NameInput
          name={this.state.name}
          onNameChange={this.handleNameChange.bind(this)} />
      </div>
    )
  }
}

구성 vs 상속

자식 노드로 어떤 것이 오게 될지 미리 알 수 없다. 그래서 특별한 prop으로 children이 존재한다.

function Box() {
  return <div>{props.children}</div>;
}

이제 다음과 같이 사용하면 알아서 내부 노드로 처리한다.

<Box>
  <NameInput />
  <NameInput />
  <NameInput />
</Box>

Containment의 예시로 SplitPane를 작성했다.

Specialization의 예시로 Dialog를 작성했다.

상속을 사용하는 경우는 적절한 유즈케이스가 없다고 한다. UI와 관련되지 않은 로직을 공유해야 할 경우라도 상속보다는 별도의 JS 모듈로 작성해서 import 하는 방식을 권장한다.

리액트 식으로 생각하기

리액트를 사용할 때 어떤 방식으로 접근해야 하는지 설명한다.

  1. Mock에서 시작. 프로토타이핑과 mock 데이터를 갖고 시작한다.
  2. UI를 나눠 컴포넌트 계층을 만든다.
    • 단일 책임 원칙
    • 데이터 구조에 맞게
  3. React에서 정적인 페이지로 작성한다.
    • state를 사용하지 않음
    • 데이터는 단방향으로
  4. UI state의 Representation을 모든 경우를 소화할 수 있으면서도 최소한으로 파악한다.
  5. state가 어디에 위치해야 하는지 파악한다.
    • owner 컴포넌트, 또는 상위 컴포넌트
    • 적당한 컴포넌트가 없다면 빈 컴포넌트라도 만들어서
  6. 데이터 흐름이 반대가 되야 하는 경우를 파악한다.
    • lifting state up

한참 미루던 블로그를 정돈했다. 새로운 도메인을 구입했는데 그쪽으로 옮길까 하다가 신경써야 할 부분이 너무 많아서 정리만 했다.

  • 새로운 테마를 만들까 싶었지만 엄두가 안나서 기본 테마를 손질하는 쪽으로 방향을 바꿨다. Twenty Fifteen를 사용했다. 깔끔. 서체 크기를 좀 변경하고 헤드라인을 추가했다.
  • 본명조Freight Text Pro를 적용했다. 지금은 Typekit을 사용하고 있는데 동적으로 로딩하는 식이라 뒤죽박죽 보일 때가 종종 있다. 호주 인터넷이 느려서 그렇겠지만. 본명조도 스포카 한 산스처럼 얼른 작고 빠른 웹폰트 패키지로 나왔으면 좋겠다.
  • dns 서비스를 cloudflare로 변경했다. 덕분에 https도 손쉽게 적용했다. dns propagation이 느려서 답답했다.
  • 위젯을 바꿨다. 기존에 쓰던건 그냥 많이 조회되는 순서로 나오는 위젯이었는데 이제 직접 선정한 글만 나온다. 한땀한땀 html로 되어 있는 목록이다.
  • 헤더 이미지로 svg를 넣을 수 있게 수정했다. (플러그인 있어서 설치) OpenGraph는 svg를 지원하지 않아서 소셜 카드에서는 좀 밍밍하게 보일 것 같다.
  • 오래된 글은 메시지를 넣었다.
  • 내용이 오래된 페이지는 메뉴에서 뺐다.
  • 코드 하일라이트 색상을 바꿨다. hightlight.js에서 조금 눅눅하고 밝은 색인 Atelier Estuary Light으로 골랐다.
  • 덧글 기능을 다시 활성화했다.

보기엔 크게 달라지진 않았지만 기분이 좋아졌다. 만드는 일도 즐겁지만 다듬는 일도 재미있다. 언제가 될지 모르겠지만 카테고리 분류가 엉망이라 색인을 다시 만들어서 정리할 생각이다.

정리하면서 이전에 쓴 글도 읽게 되었다. 지금 내 생각과는 다른 부분도 많고 누군가 읽고 상처받을 만한 글도 보였다. 일상의 스트레스와 부정적인 감정을 블로그에 너무 많이 쏟아서 내 스스로도 이 블로그 주인은 일상에 문제가 좀 있나보군, 생각이 들 정도다. 잘 모를 때 썼거나 그냥 어리고 부족한 글도 많다. 이런 부분을 발견할 때마다 삭제 버튼을 누르고 싶어진다. 눈앞에서만 치운다고 내가 달라진다면 참 좋겠지만 과거의 경험으로 봐서는 그렇지 않은 것 같다. 부끄러운 글을 다시 볼 때마다 돌아보고, 반성하고, 개선하는 순환을 만들고 싶다.

앞으로는 좀 더 긍정적으로 생각하고 행동하고, 그런 삶이 글로도 나타났으면 좋겠다.

리디북스 결제하러 들어갔다가 밑줄쳤던 내용을 볼 수 있게 정리된 페이지가 있어서 다시 읽어봤다.

같은 일을 반복하면서 다른 결과를 기대하는 것은 미친 짓이다.

오래 전에 작성했던 코드를 지금에 와서도 고칠 부분이 없어 보인다면, 그것은 그동안 배운 것이 없다는 뜻이다.

— 소프트웨어 장인, 산드로 만쿠소

달인의 길은, 길 위에 머물러 있는 것이다.

— 달인, 조지 레너드 (밑줄 긋는 여자, 성수선에서 인용)

더 많이 아는 것은 곧 더 많이 이해하고 용서하는 것이다.

— 불안, 알랭 드 보통 (밑줄 긋는 여자, 성수선에서 인용)

사람들은 시간을 아끼면 아낄수록 가진 것이 점점 줄었다.

— 모모, 미하엘 엔데 (밑줄 긋는 여자, 성수선에서 인용)

바둑뿐 아니라 모든 분야가 그러할 것이다. 혼자서는 절대로 성장할 수 없다. 서로 나누면서 함께 성장해야 한다.

— 조훈현, 고수의 생각법, 조훈현

창조적 독점이란, 새로운 제품을 만들어서 모든 사람에게 혜택을 주는 동시에 그 제품을 만든 사람은 지속 가능한 이윤을 얻는 것이다. 경쟁이란, 아무도 이윤을 얻지 못하고 의미 있게 차별화 되는 부분도 없이 생존을 위해 싸우는 것이다.

— 제로 투 원, 블레이크 매스터스, 피터 틸

그녀는 상자 안에 물건을 수납함으로써 너무 많이 가진 사실을 깨닫지 못하는 현상을 ‘수납의 블랙박스화’라고 부른다.

전 자신이 좋아하는 일에 온전히 몰두할 수 있는 생활이야말로 정말 가치 있다고 생각해요. 물건이 많으면 아무래도 그것들을 관리하는 데 시간을 빼앗기게 되어서 오롯이 나만의 시간을 보낼 수 없거든요. 그런 생활은 자신이 원해서 하는 것처럼 보이지만, 실상은 물건에게 지배당하는 생활일 뿐이라고 생각해요.

— 아무것도 없는 방에 살고 싶다, 미니멀 라이프 연구회

우리는 기분이 나빠서 기분이 나빠진다. 죄책감을 느껴서 죄책감을 느낀다. 화가 나서 화를 낸다. 불안해서 불안해진다.

난 나이가 들고 경험을 쌓는 과정에서 틀린 점을 조금씩 덜어내 매일매일 덜 틀린 사람으로 거듭날 것이다.

‘무엇을 위해 투쟁할 것인가’ 라는 문제가 당신이라는 존재를 규정한다.

— 신경 끄기의 기술, 마크 맨슨

Published on February 5, 2018

매일매일 맥북프로 키보드에 감사할 줄 모르는 삶을 지내고 있었다. 어느 날 디지털 노마드로 활동하는 hivickylai님의 포스트를 읽게 되었다. 그 포스트는 작년 어떻게 짐을 꾸렸나 하는 내용이었는데 그 중 싱크패드 블루투스 키보드가 눈에 꽂혔다. 키보드에 조예가 깊은 _nodelay님이 싱크패드 사고 키보드 좋다는 트윗을 보고 나서 안그래도 싱크패드를 마련하면 이 슬럼프를 탈출할 수 있을 것 같은 굳은 믿음 같은 것이 생기고 있었다. 하지만 조만간 이사를 준비해야 할지도 모르기 때문에 짐을 늘려서는 안되는데 블루투스 키보드의 존재를 알게 되었으니 지갑이 열렸다. 결제하고 한 주 지나서 홍콩에서 배달이 왔다.

엉성한 박스에 배달이 왔다. 레노보 공식 사이트에서 구입했는데도 표지도 없는 카드보드 박스에 담겨져 와서 좀 놀랬다. 개봉하며 처음 몇 번 눌렀을 때는 애플 블루투스 키보드랑 별다르지 않네 생각했었다. 그리고 컴퓨터에 연결했고 5분 정도 사용하고 나니 완전히 생각이 달라졌다. 이건… 혁명이야! 그렇게 아이맥에서 사용하던 애플 블루투스 키보드를 새 키보드와 교체하게 되었고 맥북프로는 여전히 옆에서 충전기만 꽂혀 있게 되었다.

로지텍 블루투스 키보드도 갖고 있었는데 지금은 아내가 사용하고 있어서 비교할 수 있는 대상은 애플 블루투스 키보드가 전부다. 비교해서 써보면 이렇다.

  • 애플 키보드에 비해서 키 넓이가 넓다. 손이 크다면 싱크패드 쪽이 더 좋을 것 같다. 내 손에는 약간 큰 느낌이다.
  • 키감이 좋다. 뭐라 설명하기 어려운 부분인데 키를 누를 때마다 키가 손가락 끝에 달라붙는 그런 기분이 든다. 그렇다고 반발력이 엄청 느껴진다거나 그런건 아닌데… 뭔가 매력적이야.
  • 포인팅 스틱(빨간 점)이 의외로 편리하다. 물론 마우스가 있으면 습관적으로 마우스에 손이 가긴 하지만 스크롤이라든지 커서를 조금 이동하는 등의 작업은 마우스를 사용하지 않고도 쓸 수 있다. 지금 소파에 앉아서 키보드만 놓고 글을 쓰는데 전혀 불편함이 없다.
  • 배터리가 오래 간다. usb 케이블로 충전할 수 있고 충전하면서 사용할 수 있다. 애플 키보드도 배터리 교체를 언제 했는지 기억나질 않지만 AA 전지 충전하고 하는 번거로움에 비해서 편리하다.
  • 블루투스 끊김이 있다고 들었는데 아직까지 그런 경험이 없다. 시간이 지나면 절전 모드로 들어가는 것 같은데 그 때 슬립 모드였던 아이맥이 잠시 켜지는 증상이 있는 것 같다. 본체가 자는지 핑(?)을 쏘는데 그거에 반응해서 켜지나 싶기도 하고.
  • 마감이 좋다. 애플 키보드에 긁혀본 적이 있어서 플라스틱이 더 마음 편하다.
  • 키보드에 조그마한 다리가 있어서 각도를 높힐 수 있다. 일반적인 블루투스 키보드에서 못봤던 것 같다.

단점은 별로 없지만 억지로 적어보면

  • 색이 검정이다. 그런데 계속 보면 멋있다(?).
  • 포인팅 스틱의 속도를 세밀하게 조절하려면 내 손가락도 그만큼 섬세하게 움직여야 하는데 마우스에 비해서 좀 더 신경을 많이 써야 한다. 물론 적응하면 엄청 편하게 느낄 것 같다.
  • 방향키 옆에 pgUp, pgDn키를 붙여놨다. This is not cool… 삼성 노트북 키보드만큼 이상하다. 어짜피 cmd + 방향키로 쓰고 있기 때문에 키맵으로 두 키를 사용하지 않게 설정했다.
  • 맥 기본 기능키와 순서가 다르다. 게다가 달려있는 FnLk 키는 싱크패드 외에 동작하지 않는다. 물론 맵핑 프로그램을 쓰면 다 마음대로 바꿀 수 있으니까 엄청 큰 단점은 아니고 극복할 수 있는 수준. 오히려 사용할 수 있는 키가 넉넉한건 장점이라고 해야할까.

기계식 키보드를 좋아하긴 하지만 이 키보드는 그 자체로도 크게 완성된 느낌이고 사용하면 즐거운 기분이 든다. 회사용으로 사달라고 해서 하나 비치해둘까 생각하고 있다.

내가 구입한 모델은 장비 하나에만 페어링이 가능한 모델(0B47189)인데 반해 중국에서만 구입할 수 있는 멀티 페어링 버전(4X30K12182)도 존재한다고 한다. 모델명이 완전 다른 걸로 보면 내수용이거나 그런 것 같다. 멀티 페어링 버전이 필요하다면 알리익스프레스에서 구입할 수 있다.

맥북 프로 키보드에 불만족스러워서 그런지 회사에서도 미팅 때마다 사람들이 들고 들어온 노트북을 계속 눈여겨 보게 되었다. 3년 전까지는 델 납품을 받았는데 그 이후로는 레노보를 사용하고 있어서 Dell Latitude랑 XPS, Lenovo Thinkpad 13, Thinkpad T740, 요가 시리즈 정도를 대부분 들고 다녔다. Acer 쓰는 사람도 좀 있었고. 그 중에 sysadmin 하는 분들이 노트북에 리눅스 설치하고 다니는거 보고는 나도 하나 그런 노트북 있으면 좋겠군 싶어서 검트리에서 적당한 가격에 이것저것 설치하고 놀 수 있는 노트북을 찾고 있었다.

그러다가 Dell Latitude E7240를 $100에 구입했다. 원래 $150에 구입하기로 했고 판매자가 보기로 한 장소에 나갔는데 판매자가 약속을 잊었다고 미안하다고 할인해주고 다음 날 집 앞으로 가져다줬다. 40도 넘는 더위를 뚫고 갔다가 돌아오는게 좀 짜증나긴 했지만 역시나 막상 받고나니 다 기분이 풀렸다.

구입한 모델은 i3긴 했지만 8GB 램에 128GB SSD였다. 좀 지난 모델이긴 하지만 울트라북이고 가벼워서 만족스러웠다. $300에 구입했던 인스피론보다 훨씬 안정적이고 빨라서 너무 만족스럽다. TN 패널인게 아쉽지만 찾아보니 IPS로도 모델이 있길래 교체에 문제가 없을 것 같아 IPS 패널도 하나 주문했다.

그런 후에 무슨 배포판을 설치할까 고민했다. 뭘 설치할 일이 있으면 늘 고민 없이 우분투를 설치했었다. Archlinux 좋다는 얘기를 계속 듣기도 했고 arch를 사용하지 않아도 그 위키에서 도움을 받은 적이 많아서 이번엔 arch를 설치하기로 했다. lxde나 그놈을 사용했는데 KDE로 설치해보기로 했다.

Archlinux는 위키에 모든 단계가 잘 정리되어 있어서 따라하기만 하면 된다! 는 생각보다 좀 환상이었다. 설치하기 위해서는 이거 이거 보면 된다 그러고 링크를 누르면 또 엄청난 분량의 설명이 나온다. 그래서 설치하면서 했던 단계를 간단하게 정리했다. 기본적으로는 설치 가이드와 동일하다.

설치 전

archlinux iso를 받아서 usb에 설치한다. Etcher를 사용하면 손쉽게 이미지를 usb에 넣을 수 있다. 이 usb를 노트북에 꽂고 부팅한다. bios에서 부팅 순서를 확인하고 usb를 가장 먼저 읽도록 설정한다.

부팅이 다 되면 root 계정에 로그인 된다. 먼저 인터넷을 연결한다. wiki 설치 안내에서는 안나오는데 wifi 접속할 수 있는 dialog 스크립트를 제공한다.

# wifi-menu

만약 wifi 드라이버를 인식하지 못하면 스크립트가 동작하지 않는데 그럼 위키에서 설명하는 방식 따라서 직접 연결해야 한다.

파티션 설정은 cfdisk 또는 fdisk를 사용한다.

# cfdisk

내 경우에는 sda1 (bootable), sda2 (50GB, home), sda3 (4GB, swap)으로 설정했다.

# mkfs.ext4 /dev/sda1
# mkfs.ext4 /dev/sda2
# mkswap /dev/sda3
# swapon /dev/sda3

드라이버를 마운트한다.

# mount /dev/sda1 /mnt
# mkdir /mnt/home
# mount /dev/sda2 /mnt/home

설치

본격적인 설치 전에 가까운 미러 서버를 선택한다. /etc/pacman.d/mirrorlist 열어서 가까운 서버를 최상단으로 옮긴다. 내 경우는 swin.edu.au를 가장 위로 올렸다.

이제 호스트 드라이브에 리눅스를 설치한다.

# pacstrap /mnt base base-devel

설정한 디스크 설정을 호스트 드라이브에 저장한다.

# genfstab -U /mnt >> /mnt/etc/fstab

chroot로 호스트에 접속한다.

# arch-chroot /mnt

일단 사용에 필수적인 패키지를 설치한다. vim은 필수고, wifi_menu는 dialog에 의존성이 있고 WPA를 사용하면 wpa_supplicant도 설치해야 한다.

# pacman -S vim wpa_supplicant dialog

이제 타임존 설정이 필요하다. /usr/share/zoneinfo/ 안에 적합한 타임존을 찾아서 링크를 추가한다.

# ln -sf /usr/share/zoneinfo/Australia/Melbourne /etc/localtime

하드웨어 시간을 맞춘다.

# hwclock --systohc --utc

로케일을 설정한다.

# locale-gen
# echo LANG=en_AU.UTF-8 > /etc/locale.conf
# export LANG=en_AU.UTF-8

hostname 설정과 host를 설정한다.

# echo "ed-dell" > /etc/hostname
# echo "127.0.0.1 ed-dell" >> /etc/hosts

Grub 설치한다. bootable로 지정한 첫 파티션에 grub을 설치하면 된다.

# pacman -S grub
# grub-install /dev/sda
# grub-mkconfig -o /boot/grub/grub.cfg

root 비밀번호를 설정한다.

# passwd

설치된 패키지를 최신 버전으로 갱신한다. 미러에서 바로 설치했기 때문에 실제로는 별다른 내용 없이 지나가지만 그래도 한다.

# pacman -Syu

이제 종료하고 USB를 제거하고 켜면 설치된 arch를 사용할 수 있다.

# exit
# reboot

KDE 설치

KDE 페이지를 보고 설치하면 된다. 임시로 wifi-menu를 사용해서 인터넷을 연결한다. 그리고 아래 패키지를 설치한다.

xf86-video-intel은 그래픽카드 드라이버인데 본인에게 맞는걸 설치해야 한다. 목록은 Xorg를 참조한다.

# pacman -S xorg
# pacman -S xf86-video-intel
# pacman -S sddm
# pacman -S plasma-desktop
# pacman -S kde-applications
# pacman -S kdeplasma-addons # 네트워크 매니저가 포함됨

root 대신 사용할 계정을 생성한다.

# useradd -d /home/edward edward
# passwd edward
# echo "edward   ALL=(ALL) ALL" >> /etc/sudoers

sddm을 설정한다.

# sddm --example-config > /etc/sddm.conf

/etc/sddm.conf을 열어서 autologin을 설정한다.

[Autologin]
User=edward
Session=plasma.desktop

KDE를 사용하면 wifi-menu가 더이상 필요 없다. 그 전에 네트워크 매니저 서비스를 켠다.

# systemctl enable NetworkManager.service

bluetooth를 사용한다면 몇가지 패키지를 더 설치한다.

# pacman -S bluez bluez-utils
# systemctl enable bluetooth.service
# systemctl start bluetooth.service

한국어 설정

fcitx로 설치하고 싶은데 제대로 동작을 안해서 일단 ibus를 설치했다.

# pacman -S ibus ibus-hangul

/etc/locale.gen에 ko_KR을 추가한다.

ko_KR.UTF-8 UTF-8

/etc/locale.conf도 변경한다.

LANG=ko_KR.UTF-8
LC_COLLATE=ko_KR.UTF-8

/etc/xprofile를 열어서 다음 내용을 추가한다.

export GTK_IM_MODULE=ibus
export XMODIFIERS=@im=ibus
export QT_IM_MODULE=ibus
ibus-daemon -d -x

물론 우분투보다 복잡하게 느껴지긴 하지만 (그게 우분투의 셀링포인트이기도 하고) 직접 설치하지 않으면 전혀 설치가 안되고 설정도 안되는 그런 환경이 OS에 대한 애착을 만드는 기분이다. 만자로를 사용하면 좀 더 쉽게 설치할 수 있다고 한다.

JavaScript에서 커링 currying 함수 작성하기를 다시 보다가 PHP로도 작성해봤다.

function curry($fn) {
    $arity = (new ReflectionFunction($fn))->getNumberOfParameters();

    return ($resolver = function (...$memory) use ($fn, $arity, &$resolver) {
        return function (...$args) use ($fn, $arity, $resolver, $memory) {
            $local = array_merge($memory, $args);
            $next = count($local) >= $arity ? $fn : $resolver;
            return $next(...$local);
        };
    })();
}

js 버전도 요즘 스타일로 작성하면 좀 더 간결할 것 같다. php와 js와의 차이점을 적어보면,

  • 함수에서 몇 개의 파라미터를 사용하는지 알아내기 위해 리플렉션을 사용했다.
  • js는 lexical scoping이지만 php에서는 스코프 내에서 사용할 컨텍스트를 명시적으로 적어줘야 한다.
  • 함수 내에서 named function을 선언할 수 있지만 이 함수에는 인자로 전달하지 않는 이상 스코프를 공유할 방법이 없다. 대신에 아직 선언되지 않은 변수명을 레퍼런스로 선언 내에 전달하고(&$resolver) $resolver에 익명 함수를 저장하는 것으로 스코프 내로 넣을 수 있다.
  • js에서는 배열도 passed by reference지만 php에서 배열은 passed by value다. 그래서 js 코드처럼 매번 slice 할 필요가 없다.

아래는 함수 작성하면서 사용한 테스트다.

$nullary = curry(function() { return 1; });
assert($nullary() === 1, 'nullary failed');

$unary = curry(function($a) { return $a; });
assert($unary(2) === 2, 'unary with one param failed');

$binary = curry(function($a, $b) { return $a + $b; });
assert($binary(2)(10) === 12, 'binary, one and one param failed case 1');
assert($binary(2)(20) === 22, 'binary, one and one param failed case 2');
assert($binary(2, 20) === 22, 'binary, two param failed');

$ternary = curry(function($a, $b, $c) { return $a + $b + $c; });
assert($ternary(2)(10)(4) === 16, 'ternary, one and one and one param failed');
assert($ternary(2, 20)(2) === 24, 'ternary, two and one param failed');
assert($ternary(4)(2, 20) === 26, 'ternary, one and two param failed case 1');
assert($ternary(4)(4, 20) === 28, 'ternary, one and two param failed case 2');
assert($ternary(4, 4, 20) === 28, 'ternary, three param failed');

function ternary($a, $b, $c) {
    return $a + $b + $c;
}

$namedTernary = curry('ternary');
assert($namedTernary(2)(10)(4) === 16, 'named ternary, one and one and one param failed');
assert($namedTernary(2, 20)(2) === 24, 'named ternary, two and one param failed');
assert($namedTernary(4)(2, 20) === 26, 'named ternary, one and two param failed case 1');
assert($namedTernary(4)(4, 20) === 28, 'named ternary, one and two param failed case 2');
assert($namedTernary(4, 4, 20) === 28, 'named ternary, three param failed');

ReactPHP의 child-process 패키지를 사용하면 손쉽게 pipe를 사용할 수 있다. 아래 명령을 코드로 전환한다고 생각해보자.

$ cat app.php | wc -l

수작업으로 proc_open 열어서 pipe를 받아 fread, fwrite 해도 되지만 코드가 복잡해진다. ReactPHP를 사용하면 Child Process에 구현된 pipe로 데이터를 전달할 수 있다.

먼저 필요한 의존성을 추가한다.

$ composer require react/event-loop react/child-process react/stream

예시 코드는 다음과 같다.

<?php

use React\EventLoop\Factory;
use React\ChildProcess\Process;
use React\Stream\ReadableResourceStream;
use React\Stream\WritableResourceStream;

// 이벤트 루프를 먼저 생성
$loop = Factory::create();

$catProc = new Process('cat hello.txt');
$wcProc = new Process('wc -l');

// 최종 결과를 표준출력으로 표시하기 위해 스트림을 추가
$stdout = new WritableResourceStream(STDOUT, $loop);

$catProc->start($loop);
$wcProc->start($loop);

// cat의 출력을 wc의 입력으로, wc의 출력을 표준 출력으로 pipe함
$catProc->stdout->pipe($wcProc->stdin);
$wcProc->stdout->pipe($stdout);

$loop->run();
?>

여기서의 pipe 예시는 간단해서 오히려 명령어를 직접 적는게 낫겠다.

<?php
// ...
$countProc = new Process('cat hello.txt | wc -l');
// ...

보다시피 위 예시는 매우 단순하다. 하지만 xmodem 프로토콜 등으로 전송하는 경우에는 파일 전송 명령과 터미널의 입출력을 서로 연결하는 식으로 작성해야 한다. 예전에는 이런 프로토콜이 터미널 도구 자체에 내장되어 있어서 쉽게 전송할 수 있었다고 한다. 다음처럼 입출력 스트림을 양쪽으로 pipe하면 전송이 가능하다.

<?php
// ...

$hose = new Process('hose 192.168.0.10 2000');
$sx = new Process('sx -k -v /foo/bar.bin');

$hose->start($loop);
$sx->start($loop);

$hose->stdout->on('data', function($chunk) use ($hose, $sx) {
    // 복사 초기화 명령을 전송
    $hose->stdin->write("\n");
    $hose->stdin->write("copy xmodem: flash:bar.bin\n");

    // 두 프로세스의 입출력을 연결
    $hose->stdout->pipe($sx->stdin);
    $sx->stdout->pipe($hose->stdin);
});

// xmodem에서는 stdin/stdout으로 파일을 전송하기 때문에 stderr에 현재 전송 상태를 출력함
$sx->stderr->on('data', function($chunk) {
    echo $chunk;
});

// 전송이 완료되면 해당 프로세스가 종료되고 메시지를 출력
$sx->on('exit', function($exitCode) {
    echo "Process exited with code {$exitCode}." . PHP_EOL;
});

$loop->run();

xmodem은 1977년에 개발되었다고 한다. 프로젝트에서 41년 된 파일 전송 프로토콜을 사용해보게 될 줄은 생각도 못했다.

설정 다시 하기 귀찮아서 Sublime Text 3 업데이트를 미루다가 실수로 눌러버렸다! 사용하는 에디터 중에는 여전히 sublime이 가장 반응속도가 빠르다. 대부분의 코드는 vscode나 vim에서 작성하지만 여전히 짧은 메모라든지 코드 스니핏/마사징은 sublime에서 하고 있다.

2에서 3으로 업데이트 한 후에 달라진 동작이 몇 가지 있다. 탭이 깔끔하게 untitled로 표시된다는 점이 가장 불편했다. 이 부분은 검색해보니 set_unsaved_view_name.py 라는 내장된 플러그인에서 동작을 수정해야 한다.

  1. Package Resource Viewer를 설치한다.
    1. cmd + shift + p으로 Command Palette를 연다.
    2. package control: install package 클릭한 후 package resource viewer를 입력해서 설치한다. (목록에 나오지 않는다면 Package control를 설치해야 한다.)
  2. 앞서 열었던 Command Palette를 다시 연다.
  3. open resource를 입력해서 PackageResourceViewer: Open Resource를 연다.
  4. Default -> set_unsaved_view_name.py 를 선택한다.

이제 해당 파일을 변경하면 된다. SetUnsavedViewName 클래스의 update_title 메소드에서 다음 내용을 찾는다.

        if syntax != 'Packages/Text/Plain text.tmLanguage':
            if cur_name:
                # Undo any previous name that was set
                view.settings().erase('auto_name')
                if cur_name == view_name:
                    view.set_name("")
            return

빈 행으로 설정하고 반환하는 것을 확인할 수 있다. 다음처럼 변경하고 저장한다.

        if syntax != 'Packages/Text/Plain text.tmLanguage':
            if cur_name:
                # Undo any previous name that was set
                view.settings().erase('auto_name')

변경 후에 반영이 되지 않는다면 Preferencesset_unsaved_view_name 항목이 false로 지정되지 않았는지 확인해야 한다. Preferencescmd + ,로 열어서 설정이 있는지 찾는다.

코드를 보니 의외로 수정하기 쉽게 되어 있길래 몇 가지 더 넣었다. #으로 시작하면 Markdown으로 변경하고 탭에는 #을 제거한 후, ✏️ 이모지를 붙였다.

        first_line = view.substr(line)

        first_line = first_line.strip(self.dropped_chars)

        # 추가한 부분
        if first_line[0:2] == "# ":
            first_line = "✏️ " + first_line[2:]
            # Markdown Extended를 사용하는 경우
            view.settings().set('syntax', 'Packages/Markdown Extended/Syntaxes/Markdown Extended.sublime-syntax')
            # 내장 Markdown을 사용하는 경우
            # view.settings().set('syntax', 'Packages/Markdown/Markdown.sublime-syntax')

        self.setting_name = True
        view.set_name(first_line)
        self.setting_name = False

참고로 Ctrl + `를 누르면 콘솔이 열리는데 코드 내에서 print() 한 내용도 확인할 수 있다.

Randall Degges의 포스트 To 30 Billion and Beyond를 번역했다. ipify를 만들고 확장하는 일련의 과정을 풀어 쓴 이야기다.

Thank you, Randall Degges for giving me the opportunity to translate this article. If you want to read the original, please check To 30 Billion and Beyond.


ipify: 300억 요청 처리, 그 너머로

Buzz Lightyear Charging Sketch

몇 년 전에 무료 웹서비스인 ipify를 만들었습니다. ipify는 무료로 사용할 수 있는, 고도로 확장 가능한 IP 주소 검색 서비스입니다. REST API를 호출하면 어느 퍼블릭 IP 주소를 사용하고 있는지 확인 할 수 있습니다.

ipify를 만든 당시에는 복잡한 인프라스트럭처 관리 소프트웨어를 만들고 있었고 클라우드 인스턴스에서 관리용 API를 사용하지 않고 동적으로 퍼블릭 IP 주소를 알아내야 했었습니다.

무료로 사용 가능한 역 IP 검색 서비스를 검색해봤지만 적합한 해결책을 찾지 못했습니다.

  • IP를 스크랩 할 수 있는 웹사이트가 있긴 했습니다. (하지만 별로 좋은 형태가 아니고 호스트에서 사용하기엔 불편합니다)
  • 비용을 청구하는 API도 있었습니다. (잌!)
  • 일간 조회 횟수를 제한하는 API도 있었습니다. (한꺼번에 많은 인스턴스를 관리해야 했기에 사용하기 두려웠습니다)
  • 제가 원하는 방식의 API도 있었지만, 오류가 발생하기도 하고 가끔 서비스가 내려가기도 하거나 질적으로 떨어지기도 했습니다. 어느 제공자를 dig로 살펴봤더니 전체 서비스가 단일 서버 (A 레코드)에서 동작하고 그 서버에서 요청이 직접 종료되었습니다. 세계 규모로 확장 가능하거나 고가용성의 서비스라고 말하기 어려웠습니다.
  • 보기엔 괜찮은 API도 있었지만, 지속해서 유지하기 위한 기부를 받으려고 했습니다. 사용하려는 API 서비스가 언제 죽을지 모를 상황이라니 마음 놓고 사용할 수 없었습니다.

이런 이유로 인해 작은 서비스를 직접 만들어서 내 문제와 가능한 한 많은 사람의 문제를 해결하고 싶었습니다. 더군다나 하나의 문자열을 반환하는 소프트웨어를 작성하는 일은 그렇게 엄청 어려운 일은 아니었습니다. 하지 말아야 할 이유가 있을까요?

가장 최악의 경우라고 상상해볼 수 있는 일은 기꺼해야 월 30달러가량 사용하고 공개 서비스라는 점을 염두에 두고 다루는 정도였습니다.

ipify 버전 0

ipify의 첫 코드는 상당히 단순했습니다. 50줄도 되지 않는 작은 코드를 Node로 작성했습니다. (당시에 많은 시간을 Node를 갖고 놀며 지냈습니다.) ipify 서비스가 문자열을 반환하는 일이 전부라는 전제에서는 Node를 사용할 가장 적합한 경우인 것을 알 수 있었습니다. 적은 CPU 사용량으로 수많은 요청을 처리하는 작업이죠.

API 서비스를 Node로 만들고 나서 간단한 정적 사이트를 프론트엔드로 붙여 아마존의 S3에 올렸습니다. 그리고 S3 버킷 앞에 아마존의 CDN 서비스인 CloudFront를 설정해서 페이지를 캐싱하는 것으로 엄청 빠르게 불러올 수 있도록 했습니다.

어떤 상상의 날개를 펼쳐봐도 저는 디자이너가 아니었습니다. 그래도 다행히 bootstrap을 사용해서 조금 나은 모습을 갖출 수 있었습니다. =)

모든 준비가 끝난 후에 간단하게 테스트를 수행했습니다. 모든 것이 준비되었다는 생각에 다음 단계인 배포로 넘어갔습니다.

Heroku 입성

Heroku Logo

저는 Heroku의 왕 팬입니다. (심지어 Heroku에 대한 도 썼습니다.) Heroku를 수년 간 사용하고 있고 개발 세계에 있어서 가장 저평가되고 있는 서비스 중 하나라고 생각합니다. 한 번도 사용해본 적이 없다면 지금 확인해보시길 바랍니다!

ipify를 확장 가능하고 고가용성으로, 그리고 저렴하게 운영하려면 Heroku 만큼 단순하고 좋은 선택지가 없다고 생각하고는 Heroku를 사용하기로 결정했습니다.

ipify를 Heroku에 1~2분 정도 걸려 배포하고 단일 dyno(웹서버)로 실행한 후에 제한적으로 테스트를 수행했습니다. 여전히 잘 동작하니 스스로 뿌듯한 기분이 들었습니다.

Heroku에 익숙하지 않다면 ipify 인프라스트럭처가 어떻게 동작하는지 살펴봅시다.

  • Heroku는 ipify 웹서비스를 512M 램과 제한된 CPU인 작은 dyno (웹서버)에서 구동합니다.
  • 만약 프로세스가 충돌하거나 어떤 심각한 문제가 발생한다면 Heroku는 자동으로 서비스를 재시작합니다.
  • Heroku는 앱으로 들어오는 모든 요청을 로드 벨런스를 거친 후 요청을 처리하기 위해 dyno (웹서버)로 전달합니다.

이런 설정이 좋은 이유는:

  • 고가용성: Heroku의 로드벨런서, 제 dyno, 모든 것이 고가용성입니다.
  • 유지보수도, 관리 설정도, 어떤 배포 코드도 필요하질 않습니다. 100% 자동입니다.
  • 저렴합니다. 단일 서버를 위해 월 ~7달러가량 냅니다.
  • 빠릅니다. 헤로쿠는 아마존 웹서비스(AWS) 상에서 돌아가며 모든 인프라스트럭처는 세계에서 가장 인기있는 클라우드 호스트 위치인 AWS 미동부 (버지니아)에서 운영됩니다. 이 의미는 지리적으로 US 동부 해안에서 운영된다는 점인데 물 건너 유럽이나 나머지 US 지역에서 그다지 멀지 않다는 의미입니다. 세계 대부분 지역의 사용자가 서비스를 사용한다고 해도 그렇게 오래 걸리지는 않을 겁니다.

지금까지는 상당히 괜찮았습니다. 만들고, 설정하고, 테스트하고, 프로덕션으로 옮기기까지 1일 이하의 노력으로 끝낼 수 있었습니다.

그리고나서 ipify를 제 인프라스트럭처 관리 코드와 통합하는 것으로 원래 해결하려던 문제를 처리했습니다.

한 달 정도 모든 것이 잘 흘러갔습니다. 몇 가지 문제를 알아차리기 전까지는 말이죠.

인기… 헐?

Spartan Warrior Sketch

ipify를 홍보한 적이 없지만 “ip address api”를 구글에서 검색하면 꽤 상단에 걸리게 되었습니다. SEO와 문구 수정에 사용한 수년 간의 노력이 빛을 발한 것 같습니다.

그동안 ipify가 구글 검색 결과에서 상당히 높은 순위로 노출된 덕분에 수천 명의 사용자가 생겼습니다. 서비스가 많이 노출되면서 몇 이슈가 나타나기 시작했습니다.

제 Heroku 로드 벨런서가 경고를 보내기 시작했습니다. Node 서버가 들어오는 요청을 충분히 빠르게 처리하지 못하고 있다는 내용이었습니다. 결과적으로 다음과 같은 일이 나타났습니다.

  • 너무 많은 사용자가 ipify에 API 요청을 보내고 있습니다
  • 제 Node 서버가 요청에 대해 느리게 응답해서 지연이 늘고 있었습니다
  • Heroku의 로드벨런서가 이 문제를 알아차리고 Node 서버에 보내기 전에 요청을 버퍼에 저장했다가 보내기 시작했습니다.
  • Node 서버가 빠르게 요청을 처리하지 못한 탓에 로드벨런서에서 사용자에게 503을 전달하고 요청을 끝내버렸습니다.

그다지 좋은 모습이 아닙니다.

이 문제를 해결하기 위해 한 일은 간단했습니다. Heroku에 dyno를 더 추가했습니다. 이 방식으로 수용량을 두 배로 늘렸고 모든 서비스가 부드럽게 동작하기 시작했습니다. 그 이면엔 두 “프로덕션” dyno를 구동한 탓에 거의 50달러 가량을 지불해야 했습니다. 첫 dyno 이후엔 일반 요금인 dyno당 월 25달러를 지불해야 했습니다.

서비스가 이 정도로 인기 있다면 월 50달러를 내는 것도 나쁘지 않다고 생각해서 지불하기로 결정하고 다시 안정적인 상황으로 돌아올 수 있었습니다.

하지만… 그렇게 말처럼 쉬운 문제가 아니었습니다.

한주도 채 지나기 전에 Heroku에서 이전과 동일한 경고를 받았습니다. 사용량을 보니 트래픽은 두 배로 늘었고 ipify는 더 많은 사용량을 보였습니다.

저는 또 다른 dyno를 추가하고 월 75달러를 지불하게 되었습니다. 그래도 좀 더 살펴보기로 했습니다. 저는 짠돌이라서 월 50달러 이상 쓰는 건 기쁜 일이 아니었습니다.

수사

L Sketch

수사를 시작하면서 가장 먼저 살펴본 것은 실제로 ipify가 초 당 얼마나 많은 요청을 처리하는가(requests per second, rps)를 살펴보는 일이었습니다. 숫자를 보고 상당히 놀랐습니다. 낮았거든요.

제 기억에는 ipify는 10rps 정도였습니다. 이렇게 작은 숫자를 처리할 수 없다면 뭔가 코드가 문제라고 생각했습니다. 만약 10rps를 작은 웹 서버 두 개를 사용하는데도 처리하지 못하는 상태라면 끔찍하게 잘못된 것이 분명했습니다.

처음 알아차린 것은 단일 Node 프로세스로 처리하고 있다는 점이었습니다. Node의 클러스터 모듈을 사용해서 쉽게 고칠 수 있었습니다. 이제 각각의 CPU 코어에서 하나의 프로세스를 구동할 수 있게 되었습니다. 효과적으로 dyno에서 두 배의 처리량을 다룰 수 있게 되었습니다.

하지만 20 rps는 여전히 작은 숫자로 느껴져서 좀 더 깊이 파헤쳐보기로 했습니다. Heroku에서 로드 테스트를 수행하기보다 제 랩탑에서 로컬로 테스트를 수행했습니다.

제 랩탑은 512M 램의 작은 Heroku dyno보다 훨씬 강력하기 때문에 더 처리량이 많을 것으로 생각했습니다.

ab 도구를 사용해서 테스트를 수행했고 놀랄 수 밖에 없었습니다. 훨씬 좋은 사양의 제 랩탑에서도 30 rps 정도 처리하지 못하는 것입니다. (제 랩탑은 리눅스로 구동되고 있었고 ab는 효율적으로 동작하는 환경이었습니다.) 그래서 기초적인 분석을 시작했고 Node에서 단순한 문자열 조작 동작을 하는데 많은 시간을 사용한다는 것을 확인할 수 있었습니다. (X-Forwarded-For 헤더에서 IP 주소를 추출하고 다듬는 일이었습니다) 여러 가지 실험을 해봤지만, 이 한계를 뛰어넘을 만큼 좋은 성능을 내는 것은 불가능했습니다.

이 시점에서 ipify 서비스는 두 dyno를 통해서도 겨우 20 RPS 정도 처리할 수 있었습니다. ipify는 월 ~5200만 요청을 처리하고 있었습니다. 그다지 인상적이지 않았습니다.

이 서비스를 Go로 다시 작성하기로 했습니다. (Go는 몇 달 전부터 사용하기 시작했습니다) 동일한 Go 서버를 작성했을 때 성능 측면에서 더 나은지 확인해보고 싶었습니다.

ipify 버전 1

Warrior Sketch

ipify를 Go로 다시 작성하는 일은 짧은 (그리고 즐거운) 실험이었습니다.

이 과정에서 Gorilla/mux, Martini와 httprouter 같은 다양한 Go 라우팅 스택을 사용해볼 수 있었습니다. 이 세 가지 라우팅 도구를 사용해보고 비교해본 결과 httprouter가 다른 둘에 비교해 비약적으로 성능이 좋은 것을 확인했습니다.

제 랩탑에서 Go 서버는 초당 ~2,500 요청을 처리할 수 있었습니다. 엄청난 향상이었습니다. 또한, 메모리 사용량도 엄청 적어서 5M 남짓을 사용했습니다.

Go와 사랑에 빠진 저는 즉시 행동으로 옮겼습니다. Go 기반 ipify 서비스를 Heroku에 배포했습니다.

결과는 환상적이었습니다. 단일 dyno에서 2천 RPS 정도를 처리할 수 있었습니다. 이런 변화는 월 25달러 수준으로 월 ~52억 요청을 처리할 수 있게 되었습니다.

며칠 후에 경험 있는 Go 개발자와 대화를 한 후, 문자열을 처리하는 기능 몇 가지를 다시 작성했고 그 덕분에 추가로 ~1천 RPS 정도를 확보할 수 있었습니다. 이 시점에 dyno 당 월 ~77억 요청을 처리하고 있었습니다. (조금 더 많거나 적었습니다.)

저는 말할 것도 없이 흥분했습니다.

더 큰 유명세

Tyrael Sketch

짧은 시간이긴 하지만 호스팅 비용을 줄일 수 있었습니다. 대략 2달 정도 지난 후에 ipify는 또 다른 문제를 경험하게 되었습니다.

엄청난 비율로 성장을 지속하고 있었습니다. 이즈음에 ipify에 대해 Google Alerts를 설정해뒀는데 그래서 사람들이 이 서비스에 대해 언급하면 바로 알 수 있었습니다.

점점 많은 사람이 ipify를 개인 프로젝트와 업무에서 사용한다는 점을 확인할 수 있었습니다. 그리고 몇 회사에서는 자신들의 프로덕트에 포함해도 되느냐는 질문을 받기 시작했습니다. (대형 스마트 TV 공급자, 다양한 미디어 대행사와 IoT 업체 등.)

ipify가 월간 150억 요청을 처리할 때쯤 호스팅 비용으로 월 50달러를 사용하고 있었습니다.

하지만 이런 상황은 오래 지속되지 않았습니다.

ipify의 트래픽은 급격히 성장하는걸 확인할 수 있었습니다. 아마 몇 달 후에는 수 십 억의 월간 요청을 더 받게 될 겁니다.

또한, 급작스러운 트래픽에 의한 문제도 겪기 시작했습니다. ipify가 짧은 시간에 엄청난 양의 트래픽을 받게 되면 순식간에 죽는 경우가 있었습니다. 아마 이 트래픽은 부트스트랩 스크립트, 스케쥴 작업(cron job) 그리고 다른 비슷한 방식의 작업으로 발생한 것이라 짐작하고 있습니다.

뒤늦게는 백신 업체에서 ipify를 차단하기 시작했다는 얘기도 들을 수 있었습니다. 루트킷이나 바이러스, 지저분한 소프트웨어가 ipify를 사용하기 시작한 탓입니다. 공격자는 공격 대상을 공격하기 전에 ipify를 사용해서 피해자의 퍼블릭 IP 주소를 얻어내는 등의 악의적인 용도로 사용했습니다. 이런 사용자도 분명 대규모의 급작스러운 트래픽에 책임이 있다고 가정했습니다.

이런 악의적인 사용자를 좋아하지도 않고 그 일을 돕는데 돈을 쓰고 싶지도 않았지만 ipify는 중립적으로 사용하고 싶은 사람은 아무나 사용할 수 있도록 그대로 운영하기로 결정했습니다. 개발자 서비스인데 어떤 사람만 사용하도록 고르고 선택하는 것은 탐탁치 않았습니다. 모든걸 단순하게 유지하기로 했습니다.

이런 결정에도 여전히 급작스러운 트래픽 문제는 해결해야 했습니다. 이런 트래픽은 쉽게 처리하기 어려운데 우선적으로 두 선택지가 있었습니다.

  • 추가적인 dyno를 운영해 비용을 지불하고 항상 급작스러운 트래픽에 대비하는 방법, 또는
  • Adept와 같은 자동 확장 도구를 사용해서 트래픽 패턴에 따라 dyno를 생성하고 제거하는 방법

결국엔 첫 번째를 선택했습니다. 단순히 Adept의 서비스를 사용해서 추가적인 비용을 지출하고 싶지 않았기 때문입니다. (물론 이 서비스를 사용해본 적이 있고 엄청나게 멋진 서비스입니다.) 이 즈음에 월간 150달러를 지불했고 ipify는 250억 월간 요청을 처리했습니다.

이렇게 최근까지 왔습니다.

ipify 300억 요청 도달

Buzz Lightyear Proud Sketch

지난 몇 달 동안 ipify는 새로운 기록을 냈고 월간 300억 요청을 몇 차례 넘었습니다. 이렇게 새로운 기록을 경신하는 일은 지켜보는 즐거움이 있습니다.

오늘날 ipify는 2천에서 2만 RPS를 유지하고 있습니다. (이 수치는 항상 절대 일관적이질 않았습니다.) 트래픽은 항상 달라졌고 사용량은 정기적으로 높아졌는데 트래픽 패턴에서 의미를 찾는 걸 완전히 포기했습니다. 평균 응답 시간은 트래픽 패턴에 따라 1~20ms 사이를 유지했습니다.

현재 서비스는 트래픽과 여러 요인에 따라 월간 150~200달러로 운영되고 있습니다. 월간 200달러를 소비한다고 가정하고 수치를 계산해보면 ipify는 각 요청을 0.000000007달러로 운영하고 있습니다. 충격적으로 낮은 비용이죠.

만약 이 서비스를 Lambda에서 돌린다고 가정하고 요금을 계산해본다면 월간 1,243.582달러 (연산) + 6,000달러 (요청) = ~ 7,243.58달러 정도를 사용하게 될 겁니다. 참고로 이건 간단한 입계산입니다. ipify의 수치를 Lambda 요금 계산기에 입력해서 얻은 결과입니다.

결론적으로 ipify에 들어가는 비용에 _엄청나게 만족_하고 있습니다. 간단한 용도를 제공하는 짠돌이 서비스죠.

ipify의 미래

이제 미래를 생각해보게 되었습니다. ipify는 지속해서 성장하고 있습니다. 서비스는 ipv6 지원 (Heroku가 현재 지원하지 않아요 🙁), 더 나은 웹디자인, IP 주소에 대한 다른 메타데이터 등 여러 가지 요청을 받고 있습니다.

최근 다른 프로젝트 덕분에 엄청나게 바쁜 탓에 ipify의 소유권을 제 좋은 친구인 https://www.whoisxmlapi.com에 넘겼습니다.

조나단과 그의 팀은 가치 있고 흥미로운 개발자 API 서비스를 만드는데 제격인 포트폴리오를 갖고 있습니다.

제가 지속해서 돕고 있긴 하지만 조나단과 그의 팀은 현재 ipify의 새로운 기능을 구현하고 있습니다. 제가 기대하는 좋은 변화를 반영하기 위해 열심히 작업하고 있습니다. (더 나은 UI와 데이터 엔드포인트를 포함해서 말입니다.)

저는 앞으로도 꾸준히 성장할 ipify가 기대됩니다.

저에게 질문이나 궁금한 점이 있다면 이메일로 남겨주세요.

색상을 바꿔요

눈에 편한 색상을 골라보세요 :)

Darkreader 플러그인으로 선택한 색상이 제대로 표시되지 않을 수 있습니다.