시니어 개발자가 ChatGPT와 페어프로그래밍하는 법 (feat. DEVIEW 2023 코드 구현하기)

바쁜 사람들을 위한 3줄 요약

  • ChatGPT는 다양한 분야에서 활용 가능하다.
  • 다양한 분야 중 소프트웨어 개발 프로세스의 ‘구현 단계’에서 활용하면 생산성 향상에 많은 도움을 줄 수 있다.
  • 그러므로 지금부터 ChatGPT를 사용하도록 하자.

프롤로그

<나이를 먹을수록 외로움은 점점 늘어만 간다>

어릴 적에 그 많았던 친구들은 이제 손에 꼽힌다. 더욱이 친구들과의 연락은 뜸해지고, 간간이 생일 축하 메시지만 올 뿐이다. 어릴 적에는 하루 종일 같은 교실에서 같은 수업을 듣던 터라 관심사도 비슷했는데, 나이를 먹으며 서로 학교도, 전공도 달라 관심사가 달라지니, 연락은 점점 줄고, 사이는 서먹서먹해진다. 그래도 첫 입사 동기를 만나 그 우정 변치 말자 했으나, 서로 바쁜 업무에 치이다 보니 그 우정도 점점 변해간다. 내 MBTI 검사를 하면 다른 건 몰라도 항상 I 로 시작하는 만큼 사람을 만나는 것 보다 혼자 있는 게 편하니 상황은 나아질 기미가 전혀 없다.

제 이야기이지만, 대부분의 개발자는 I 성향이니 비슷한 분들이 많을 거라 생각합니다. 게다가 한 분야에 10년 이상 한 전문가가 되면 주변에 비슷한 수준을 찾기 어렵고, 우연히 비슷한 전문가를 찾더라도 무언가를 물어보려면 문제를 단순화하고, 질문의 수를 줄이는 등 선행 과정이 필요하고 무엇보다도 스몰토크가 필수이므로 대화를 시작하기도 대단히 어렵습니다.

만약 어떤 질문이든 아무 때나 대답해 주고, 스몰토크를 하지 않아도 되며 때론 거짓말도 능숙해서 즐거움을 줄 수 있는 친구가 있다면 어떨까요? 바로 여기 ChatGPT가 있습니다. ChatGPT는 어떤 분야의 질문이든, 심지어 새벽에 물어봐도 1분 이내로 즉각 답변해 주며, 간간이 거짓말을 해서 즐거움을 주고 있습니다. 어떠신가요? 만나고 싶지 않으신가요? 그럼 이 친구를 만나보도록 하겠습니다.

들어가기

최근 인공지능 기반 대화형 서비스인 ChatGPT는 뜨거운 이슈입니다. 이를 증명하듯 인터넷에 ChatGPT를검색해보면 무수히 많은 블로그, 책, 심지어 동영상까지 볼 수 있습니다. ChatGPT와 관련해서 여러 가지 이야기를 할 수 있겠지만 이번 글에서는 ‘ChatGPT와 페어프로그래밍’ 하는 법을 소개하겠습니다. 실제로 저는 이 글을 쓰기 위해 몇 주동안 ChatGPT를 사용하였으며, 여전히 지금도 사용하고 있습니다.

이 글의 앞 부분은 전체 소프트웨어 개발 프로세스 중 ChatGPT를 어디서 활용할 수 있는지에 대해서 논의하고, 뒷 부분은 ChatGPT와 페어 프로그래밍 예제로 되어 있습니다. 시간이 부족하고 실제 사용 예시가 궁금하시다면 페어 프로그래밍 예제만 읽어도 무방하나, 시간이 충분하시다면 순서대로 읽는 것을 권합니다.

ChatGPT와 페어프로그래밍 시작하기 (어디서 시작할 것인가?)

ChatGPT는 프로그래밍 분야에도 큰 영향을 미치고 있으며, ChatGPT와 함께 프로그래밍하는 것은 개발자에게 새로운 차원의 도전이자 기회가 되었습니다. 개발자는 ChatGPT의 높은 수준의 자연어 처리 능력을 활용해 사용자의 질문에 적절한 해결책을 제시하거나, 프로그래밍 문제 해결에 도움을 받을 수 있습니다. 따라서 인공지능과 인간의 강점을 결합한다면 더 효율적이고 창의적인 소프트웨어 개발이 가능할 것입니다.

ChatGPT가 개발 과정 중 구체적으로 어느 부분에 도움을 줄 수 있을지 알아보기 위해 소프트웨어 개발 프로세스부터 간략하게 살펴보겠습니다.

<소프트웨어 개발 프로세스>
1. 요구사항 분석: 프로젝트의 목표, 기능을 명확히 이해
2. 설계: 전체적인 구조를 결정
3. 구현: 설계를 통해 정의된 구조와 명세를 기반으로 소스코드 작성
4. 테스트: 소프트웨어가 요구사항에 부합하는지 확인
5. 배포: 소프트웨어를 고객에게 전달
6. 유지보수: 소프트웨어 사용 중 발생하는 문제 해결, 변경된 요구사항이나 기술 발전에 따라 개선

물론 모든 단계에서 ChatGPT를 활용할 수 있으나, 이 글에서는 구현 단계에서 ChatGPT를 활용하였습니다.

이 단계를 선택한 첫 번째 이유는 가장 많은 인력과 시간은 구현 단계에서 소비되기 때문입니다. 그래서 이 과정에서 ChatGPT의 도움을 받는다면 효율적으로 생산성을 높일 수 있을 것입니다.

두 번째 이유는 사전지식이 비교적 덜 요구되고, 보안상 문제가 적은 단계이기 때문입니다. 이외의 요구사항 분석이나 설계는 많은 사전지식이 필요합니다. 이러한 이유로 보통 신입 / 신규 개발자가 입사하면 구현 업무를 진행합니다. ChatGPT도 이와 비슷합니다. 단시간 내에 업무에 관한 모든 사전 지식을 제공하기 어려울 뿐만 아니라 업무에 관한 정보를 알려주게 되면 보안상 문제가 발생할 수 있습니다. 따라서 구현에 활용하는 게 적합하다고 판단했습니다.

마지막 이유는 신뢰 문제입니다. 다른 단계는 검증을 거치기 어렵지만, ChatGPT를 통해 구현된 코드는 실행을 통해 쉽고 빠르게 검증할 수 있습니다.

그럼 어떻게 활용하는지 알아보기 위해 구현 단계를 더 자세하게 살펴보겠습니다.

<기존 구현 단계>
1. 코딩: 설계 문서를 참조해 적절한 프로그래밍 언어와 도구로 소스코드 작성
2. 통합: 여러 모듈과 컴포넌트를 통합해 전체 시스템을 완성
3. 최적화: 소프트웨어 성능 향상을 위한 코드 최적화
4. 코드리뷰: 구현된 코드를 팀원들이 검토하여 오류, 버그, 개선사항을 찾고 수정

각 단계에서 ChatGPT를 활용 방법은 다음과 같습니다.

<ChatGPT를 구현 단계에 활용하기>
1. 코딩 지원: 구현과 디버깅에 ChatGPT를 활용
2. 최적화 지원: 코드 최적화를 위한 제안을 받아 더 나은 코드를 작성하는 습관을 익히게 함
3. 코드리뷰: 코드를 검토해 버그, 보안 취약점을 찾아냄

활용 방법을 통해 코드 품질을 향상 시키고 개발 시간을 단축할 수 있습니다.

ChatGPT와 페어 프로그래밍하기 (예제)

DEVIEW 이후

지금부터는 실제로 ChatGPT와 페어 프로그래밍을 진행하도록 하겠습니다. 예시는 DEVIEW 2023 발표에서 시간상 생략했던 Abort 기능의 구현입니다. 이해를 돕고자 지난 DEVIEW 때 발표했던 내용을 간략하게 요약하겠습니다.

코드는 복잡도, 중복도 때문에 어려워집니다.

  • 복잡도의 예는 동기 / 비동기 중단, 예외 처리입니다.
  • 중복도의 예는 타입별 동작과, 횡단 관심입니다.

이를 해결하기 위해 코드 블록(Action)을 두어 처리 하였습니다.

이후 코드는 Action을 생성하는 코드 입니다.

// Either: [success, value] | [abort | next | fail | success, reason]
// Result: Either | Promise<Either>
//         동기   | 비동기
// Fn: any -> Promise<Either> | Either | Promise<any> | else
// Action: any -> Result
// toAction: Fn -> Action
const toAction = (fn) => {
    return value => {
        return toResult(fn(value));
    }
};

// any -> Result
const toResult = (res, isFail = false) => {
    if (res instanceof Either) {
        return res;
    }
    if (res instanceof Promise) {
        return res.then(v => toResult(v), err => toResult(err, true));
    }

    return !isFail? new Either(success, res) : new Either(fail, res);
};

const action1 = toAction(() => {
    return Promise.resolve(Promise.resolve(1));
});
const action2 = toAction(() => {
    return Promise.reject("fail");
});

action1(); // Promise([success, 1])
action2(); // Promise([fail, "fail"])

toAction은 일반 함수를 Action으로 변경합니다.
toResult에서 동기, 비동기 처리는 Either, Promise<Either>로 처리하는 것을 볼 수 있습니다.

그럼 여기서 Either를 사용한 이유는 무엇일까요?

동기 비동기에 대한 성공 결과와 오류 결과(중단, 예외)를 모두 Either 타입으로 처리할 수 있습니다. 이 방식은 오류 처리를 명확하게 구분하고, 함수형 프로그래밍 패턴을 따르는 데 도움이 됩니다.

간단한 예제를 보도록 하겠습니다.

function fetchData(): Promise<Either<Error, Data>> {
  return new Promise((resolve, reject) => {
    // ... some asynchronous operation
  })
    .then((data) => {
      // ... process the data
      return new Right<Error, Data>(processedData);
    })
    .catch((error) => {
      // ... handle the error
      return new Left<Error, Data>(error);
    });
}

fetchData()
  .then((result) => {
    result
      .map((data) => {
        // ... handle the successful result
      })
      .recover((error) => {
        // ... handle the error
      });
  });

Either로 결과 값을 반환받으면, 오류를 더 명확하게 구분할 수 있습니다.

단, 이 방법에서 주의해야 할 점은, Promise 자체에서 발생하는 오류는 여전히 Promise.catch로 처리해야 한다는 것입니다. Either로 처리되는 건 Promise 내에서 발생한 예상된 오류입니다.

더 자세한 내용이 궁금하시다면 아래의 영상을 참고하시길 바랍니다.

그럼 본격적으로 ChatGPT과 함께 개발을 진행하겠습니다.

1. 코딩 지원: 00 만들어줘

위의 영상을 보면 동기는 Either, 비동기는 Promise<Either>를 사용해서 구현하였는데요. DEVIEW 발표자료에는 긴 코드를 담기 어려워 배열을 사용해 간단히 구현한 Either를 보여드렸습니다.

class Either extends Array {
  constructor(state, result) {
    super(2);
    this[0] = state;
    this[1] = result;
  }
}

이 코드를 ChatGPT를 활용해 제대로 된 코드로 다시 구현 해보겠습니다.

Q: Either 구현해줘

A: JavaScript에는 기본적으로 Either 타입이 없지만, 클래스를 사용하여 직접 구현할 수 있습니다. Either는 보통 Left와 Right 두 가지 서브타입으로 구성되며, Left는 실패나 오류를, Right는 성공적인 값을 나타냅니다. 아래에 간단한 Either 구현을 보여 드리겠습니다.

class Either {
  static left(value) {
    return new Left(value);
  }

  static right(value) {
    return new Right(value);
  }

  // 이 메서드는 서브클래스에서 구현해야 합니다.
  map() {
    throw new Error('Not implemented');
  }

  // 이 메서드는 서브클래스에서 구현해야 합니다.
  flatMap() {
    throw new Error('Not implemented');
  }
}

class Left extends Either {
  constructor(value) {
    super();
    this.value = value;
  }

  map() {
    return this;
  }

  flatMap() {
    return this;
  }
}

class Right extends Either {
  constructor(value) {
    super();
    this.value = value;
  }

  map(fn) {
    return new Right(fn(this.value));
  }

  flatMap(fn) {
    return fn(this.value);
  }
}

이제 Either.left와 Either.right를 사용하여 값을 감쌀 수 있으며, map과 flatMap 메서드를 사용하여 함수를 적용할 수 있습니다.

ChatGPT를 활용하니 1분도 안 돼서 Either 구현을 마칠 수 있습니다. 하지만 제가 필요한 건 타입스크립트로 작성된 코드이기 때문에 다시 타입스크립트로 작성해 달라고 요청해보겠습니다.

Q: Either Typescript로 구현해줘

A: TypeScript를 사용하여 Either 타입을 구현하겠습니다. Either는 Left와 Right 두 가지 서브타입으로 구성되며, Left는 실패나 오류를, Right는 성공적인 값을 나타냅니다.

abstract class Either<L, R> {
  static left<L, R>(value: L): Either<L, R> {
    return new Left(value);
  }

  static right<L, R>(value: R): Either<L, R> {
    return new Right(value);
  }

  abstract map<R2>(fn: (value: R) => R2): Either<L, R2>;
  abstract flatMap<R2>(fn: (value: R) => Either<L, R2>): Either<L, R2>;
}

class Left<L, R> extends Either<L, R> {
  constructor(private readonly value: L) {
    super();
  }

  map<R2>(): Either<L, R2> {
    return this;
  }

  flatMap<R2>(): Either<L, R2> {
    return this;
  }

  getValue(): L {
    return this.value;
  }
}

class Right<L, R> extends Either<L, R> {
  constructor(private readonly value: R) {
    super();
  }

  map<R2>(fn: (value: R) => R2): Either<L, R2> {
    return new Right(fn(this.value));
  }

  flatMap<R2>(fn: (value: R) => Either<L, R2>): Either<L, R2> {
    return fn(this.value);
  }

  getValue(): R {
    return this.value;
  }
}

export { Either, Left, Right };

이렇게 ChatGPT로 빠르게기본 코드를 구현하였습니다. 이제 mocha를 이용해 테스트 코드를 작성해보겠습니다.

Q: Either 코드 mocha로 테스트 코드 구현해줘

A: mocha를 사용하여 Either 클래스를 테스트하는 코드를 작성하겠습니다. 먼저, either.js 파일에 Either, Left, Right 클래스를 정의해야 합니다. 그런 다음 either.test.js 파일에서 mocha 및 chai를 사용하여 테스트 케이스를 작성합니다.

const { expect } = require('chai');
const { Either, Left, Right } = require('./either');

describe('Either', () => {
  describe('Left', () => {
    it('should not apply the function to the value when mapping', () => {
      const left = Either.left('error');
      const mappedLeft = left.map(value => value.toUpperCase());
      expect(mappedLeft).to.be.instanceOf(Left);
      expect(mappedLeft.value).to.equal('error');
    });
  });

  describe('Right', () => {
    it('should apply the function to the value when mapping', () => {
      const right = Either.right('success');
      const mappedRight = right.map(value => value.toUpperCase());
      expect(mappedRight).to.be.instanceOf(Right);
      expect(mappedRight.value).to.equal('SUCCESS');
    });
  });

  describe('Usage Example', () => {
    function divide(a, b) {
      if (b === 0) {
        return Either.left(new Error('Division by zero'));
      } else {
        return Either.right(a / b);
      }
    }

    it('should handle successful division', () => {
      const result = divide(4, 2);
      expect(result).to.be.instanceOf(Right);
      expect(result.value).to.equal(2);
    });

    it('should handle division by zero error', () => {
      const result = divide(4, 0);
      expect(result).to.be.instanceOf(Left);
      expect(result.value).to.be.instanceOf(Error);
      expect(result.value.message).to.equal('Division by zero');
    });
  });
});

위의 either.test.js 파일에서는 Either 클래스의 Left 및 Right 동작을 테스트합니다. 또한 사용 예제를 작성하여 성공적인 나눗셈과 나눗셈 오류를 처리하는 방법을 테스트합니다.

몇 분도 걸리지 않고 Either 코드를 구현하였습니다. 이제 Abort에서 사용하기 위해 Either를 상속할 필요가 생겼습니다. 상속을 구현하는 방법은 두 가지가 있습니다.

  1. CustomEither, CustomLeft, CustomRight는 각각 Either, Right, Left를 상속한다.
  2. CustomEitherEither를 상속하고 CustomRight, CustomLeftCustomEither를 상속한다.

그럼 또다시 ChatGPT에 물어보도록 하겠습니다.

Q: Either를 상속하려면 Right, Left를 다 상속해서 구현해야해?

A: Either를 상속하는 새로운 타입을 만들려면 기본적으로 Right와 Left도 상속해야 합니다. 이렇게 하면 새로운 타입의 Right와 Left에 필요한 추가 기능이나 변경 사항을 적용할 수 있습니다.

예를 들어, Either를 상속하는 CustomEither 타입을 만들어 보겠습니다.

abstract class CustomEither<L, R> extends Either<L, R> {
  // ... 기존 코드와 추가 기능 ...
}

class CustomRight<L, R> extends Right<L, R> {
  constructor(value: R) {
    super(value);
  }

  // ... 기존 코드와 추가 기능 ...
}

class CustomLeft<L, R> extends Left<L, R> {
  constructor(value: L) {
    super(value);
  }

  // ... 기존 코드와 추가 기능 ...
}

이제 CustomEither를 사용하려면 CustomRight와 CustomLeft를 사용해야 합니다. 새로운 타입에 필요한 추가 기능이나 변경 사항을 각각의 CustomRight와 CustomLeft에 구현할 수 있습니다.

CustomEither, CustomLeft, CustomRight는 각각의 Either, Left, Right를 상속받게 구현된 것을 볼 수 있습니다. 하지만 상속으로 구현한다는 것은 CustomRight, CustomLeft에 공통 로직을 추가하고 싶다는 의미라고 여겨 관련한 다른 질문을 해 보았습니다.

Q: CustomEither에 공통로직을 넣고 싶으면 어떻게해?

A: CustomEither에 공통 로직을 추가하려면, 먼저 공통 메서드를 CustomEither 클래스에 정의한 다음, 필요에 따라 CustomRight와 CustomLeft에서 해당 메서드를 오버라이드하거나 확장할 수 있습니다. 예를 들어, CustomEither에 print라는 공통 메서드를 추가해 보겠습니다. 이 메서드는 CustomRight와 CustomLeft의 값을 출력하는 간단한 기능을 수행합니다.

abstract class CustomEither<L, R> extends Either<L, R> {
  // 공통 로직을 정의하는 메서드
  abstract print(): void;
}

class CustomRight<L, R> extends Right<L, R> {
  constructor(value: R) {
    super(value);
  }

  // 공통 로직을 오버라이드하거나 확장하는 메서드
  print(): void {
    console.log(`CustomRight: ${this.getValue()}`);
  }
}

class CustomLeft<L, R> extends Left<L, R> {
  constructor(value: L) {
    super(value);
  }

  // 공통 로직을 오버라이드하거나 확장하는 메서드
  print(): void {
    console.log(`CustomLeft: ${this.getValue()}`);
  }
}

이제 CustomEither를 사용할 때 공통 로직인 print 메서드를 사용할 수 있습니다.

코드를 보면 중간에 잘못된 점이 있는 것을 확인할 수 있습니다. CustomRightRight를 상속하고 있는데 print 메소드를 오버라이드라고 하고 있는 점입니다. 이 부분에 대해서 다시 질문을 해보았습니다.

Q: CustomRight가 CustomEither를 상속 받지 않았는데 어떻게 오버라이드를 하는거지?

A: 죄송합니다. 제가 설명을 잘못 드렸네요. 지금 상태에서 CustomRight와 CustomLeft는 직접 CustomEither를 상속받지 않고 있습니다. CustomEither에서 공통 로직을 추가하고 싶다면, CustomRight와 CustomLeft가 CustomEither를 상속하도록 수정해야 합니다. 다음은 수정된 코드 예시입니다:

abstract class CustomEither<L, R> extends Either<L, R> {
  // 공통 로직을 정의하는 메서드
  abstract print(): void;
}

class CustomRight<L, R> extends CustomEither<L, R> {
  private value: R;

  constructor(value: R) {
    super();
    this.value = value;
  }

  isRight(): this is Right<L, R> {
    return true;
  }

  isLeft(): this is Left<L, R> {
    return false;
  }

  getValue(): R {
    return this.value;
  }

  // 공통 로직을 오버라이드하거나 확장하는 메서드
  print(): void {
    console.log(`CustomRight: ${this.getValue()}`);
  }
}

class CustomLeft<L, R> extends CustomEither<L, R> {
  private value: L;

  constructor(value: L) {
    super();
    this.value = value;
  }

  isRight(): this is Right<L, R> {
    return false;
  }

  isLeft(): this is Left<L, R> {
    return true;
  }

  getValue(): L {
    return this.value;
  }

  // 공통 로직을 오버라이드하거나 확장하는 메서드
  print(): void {
    console.log(`CustomLeft: ${this.getValue()}`);
  }
}

이제 CustomRight와 CustomLeft는 CustomEither를 상속받고 있으므로, CustomEither에 정의된 공통 로직을 오버라이드하거나 확장할 수 있습니다. 다시 한번, 죄송합니다.

이로써, ChatGPT가 작성한 코드가 완벽하지 않음을 알 수 있습니다. 그러므로 ChatGPT가 작성한 코드는 반드시 리뷰 해야합니다.

그럼 다음으로는 Either 코드에 대한 의문점을 하나씩 물어보며 코드를 개선해 보도록 하겠습니다.

2. 구현: 왜 그런지 알려줘, 다른 방법은 없어?

Either 구현을 보면 flatMap 이라는 method가 있는 것을 볼 수 있습니다. 자바스크립트에서도 array에 flatMap method가 있긴 하지만, 대부분 잘 사용하지 않습니다. 물론, 함수형 프로그래밍을 하셨더라면 익숙하겠지만 함수형을 처음 접하시면 이해가 어려울 수 있기 때문에 다른 함수명은 없는지 물어보았습니다.

Q: flatMap 다른 이름은 없어?

A: flatMap은 여러 프로그래밍 언어 및 라이브러리에서 사용되는 일반적인 이름입니다. 그러나 몇 가지 다른 이름도 사용됩니다. 다음은 flatMap에 대한 일반적인 다른 이름입니다:

  1. bind: 주로 Haskell과 같은 함수형 언어에서 사용되는 이름입니다. 이 이름은 Monad 타입 클래스의 일부로 정의되어 있습니다.
  2. chain: 이 이름은 JavaScript의 Fantasy Land 및 Ramda 라이브러리와 같은 몇 가지 함수형 프로그래밍 라이브러리에서 사용됩니다.
  3. andThen: 이 이름은 Rust의 Result 및 Option 타입에서 사용됩니다. 또한, 일부 함수형 프로그래밍 라이브러리에서도 사용됩니다.

각 언어와 라이브러리마다 다른 규칙과 관례가 있으므로, 사용하는 도구에 따라 적절한 이름을 사용해야 합니다. 그러나 이러한 이름 모두 동일한 개념을 나타냅니다: 컨테이너에 저장된 값을 변환 함수를 사용하여 다른 컨테이너로 매핑하는 것입니다.

자바스크립트에서는 ‘chain’이라는 단어가 익숙하기 때문에 이 method의 이름을 flatMap에서 chain으로 변경하였습니다.

추가로, Leftmap, chain에 대해 아래와 같은 궁금증들이 생겨 질문을 해보았습니다.

  1. Right에서는 chain을 통해 Left로 갈 수 있는데 Left에서 Right로 갈 수 있는가?
  2. Left 상태에서 map 기능을 사용할 수 있는가?
Q: left에서 right로 갈 수는 없어?

A: Left에서 Right로 직접 전환할 수 있는 기본 메서드는 없습니다. Either 타입은 두 가지 서로 다른 값을 처리하기 위해 설계되었으며, 이를 통해 오류 처리와 값 처리를 분리할 수 있습니다. 그러나 특정 조건에서 Left 값을 Right 값으로 전환하려는 경우가 있을 수 있습니다.

이 경우, Either 클래스에 사용자 정의 메서드를 추가하여 Left 값을 처리하고 새로운 Right 값을 생성할 수 있습니다. 예를 들어, 다음과 같이 recover 메서드를 구현할 수 있습니다:

abstract class Either<L, R> {
  // ...

  abstract recover(fn: (value: L) => R): Either<L, R>;
}

class Left<L, R> extends Either<L, R> {
  // ...

  recover(fn: (value: L) => R): Either<L, R> {
    return new Right(fn(this.value));
  }
}

class Right<L, R> extends Either<L, R> {
  // ...

  recover(fn: (value: L) => R): Either<L, R> {
    return this;
  }
}

recover 메서드는 Left에서 Right로 전환하는 데 사용할 수 있습니다. Left 인스턴스에 대해 호출되면 전달된 함수를 사용하여 새로운 Right 값을 생성하고, Right 인스턴스에 대해 호출되면 현재 값을 유지합니다.

그러나 이렇게 하면 Either의 오류 처리 기능을 손실할 수 있으므로, 이 방법은 신중하게 사용해야 합니다.

flatMaprecover 기능을 포함하고 있기 때문에 “flatMap하나만 구현 할 수 있을까?” 라고 물어보았습니다.

Q: recover 기능을 flatMap에 넣으면 안되나?

A: flatMap에 직접 recover 기능을 넣는 것은 좋지 않습니다. flatMap의 주요 목적은 Right 값을 처리하여 새로운 Either 인스턴스를 반환하는 것입니다. recover 기능은 오류 처리를 위한 것이므로, 두 기능을 분리하여 오류 처리와 값 처리를 명확하게 구분하는 것이 좋습니다.

오류 처리와 Right 값 처리 기능을 구분하는 이유가 타당해보입니다.
그럼 다음 질문을 이어 가보겠습니다. mapRight일 때만 처리 되는데 Left일 때도 map 필요하면 어떻게 할지 물어보았습니다.

Q: Either에서 mapLeft 메소드 있어도 되나?

A: 네, Either 타입에서 mapLeft 메서드를 정의할 수 있습니다. mapLeft 메서드는 Either 인스턴스가 Left일 때만 함수를 적용하고, Right일 때는 그대로 반환합니다.

실제 코드에 mapLeft를 추가 하였습니다.

class Right extends Either {
  map() {}
  mapLeft() {}
}
class left extends Either {
  map() {}
  mapLeft() {}
}

map, mapLeft가 따로 있어야 하는지 물어보았습니다.

Q: Either에 map, mapLeft 두개를 따로 만들어야해?

A: Either에서 map과 mapLeft를 따로 구현하는 것은 선택 사항입니다. 이 두 메서드를 구현함으로써 Either에서 Right 값과 Left 값을 각각 처리하려는 함수를 적용할 수 있습니다. 이는 명확한 로직 분리와 유연한 처리를 가능하게 해 줍니다.

map 메서드는 Right 값에 함수를 적용하고, mapLeft 메서드는 Left 값에 함수를 적용합니다. 이들 메서드를 사용하면 Either 값을 처리할 때 오류 처리나 다른 상황에 대한 로직을 명확하게 분리할 수 있습니다.

좀 더 확실하게, 두 방법의 장단점을 물어보았습니다.

Q: map 좋아 아니면 map, mapleft를 나누는게 좋아?

A: map 하나로 통일하는 것과 map과 mapLeft를 나누는 것 각각의 장단점이 있습니다.

  1. map 하나로 통일할 경우:
    • 장점: 코드가 간결해지고, 사용자가 사용할 메서드가 하나이기 때문에 API가 단순해집니다.
    • 단점: Right와 Left 값을 동시에 처리해야 할 경우, 분기 처리를 수동으로 해야 합니다.
  2. map과 mapLeft를 나눌 경우:
    • 장점: Right와 Left 값에 대한 처리 로직을 명확하게 분리할 수 있습니다. 이를 통해 코드의 가독성과 유지 보수성이 향상됩니다.
    • 단점: 두 개의 메서드를 제공하므로 사용자가 두 가지 경우를 모두 고려해야 하며, API가 다소 복잡해집니다.

결정적인 기준은 사용 사례와 개발팀의 취향에 따라 다릅니다. 일반적으로 Either를 사용하여 오류 처리나 실패 상황을 구현하는 경우, map과 mapLeft를 나누는 것이 유용합니다. 이를 통해 Right와 Left 값을 명확하게 분리하여 처리할 수 있습니다. 그러나 이렇게 구현할 때도 map을 사용하여 Right 값만 처리하고, Left 값 처리를 나중에 별도로 수행하는 경우가 많습니다.

따라서, 어떤 방식이 더 나은지는 개발팀의 취향과 구현하려는 프로젝트의 특성에 따라 결정하시면 됩니다.

선택이 필요한 시점이 다가왔습니다. 저는 간결함 보다 명확함이 더 중요하다고 판단을 해서 mapmapLeft로 분리하였습니다.

추가로, Either 코드를 리뷰하는 중 다른 동료가 Result로는 구현할 수 없는지 질문도 있어서, 추가적으로 EitherResult의 차이점에 대해 물어보았습니다.

Q: Result와 Either 차이가 뭐야?

A: Result와 Either는 두 가지 서로 다른 값을 나타내는 데 사용되는 유사한 타입입니다. 두 타입 모두 오류 처리와 값 처리를 통합하려는 의도로 설계되었습니다. 그러나 주된 차이점은 사용 목적과 관례입니다.

  1. 사용 목적:
    • Result: 이 타입은 성공 값(Ok) 또는 오류 값(Err)을 포함하는 함수의 반환 값을 나타내기 위해 설계되었습니다. 이를 통해 함수가 성공할 경우 결과를 반환하거나 실패할 경우 오류를 반환할 수 있습니다. 이는 함수형 프로그래밍에서 오류 처리를 단순화하는 데 도움이 됩니다.
    • Either: 이 타입은 두 가지 서로 다른 값, 일반적으로 오류 또는 실패를 나타내는 Left와 성공 값을 나타내는 Right를 포함할 수 있도록 설계되었습니다. 이를 통해 두 가지 서로 다른 값을 구분하고 처리할 수 있습니다. Either는 오류 처리뿐만 아니라 다양한 목적으로 사용할 수 있습니다.
  2. 관례:
    • Result: Result 타입에서 Ok는 성공적인 결과를 나타내고 Err는 오류를 나타냅니다. 이 관례는 일관성을 유지하고 코드의 가독성을 향상시키는 데 도움이 됩니다.
    • Either: Either 타입에서 Left는 일반적으로 오류나 실패를 나타내며, Right는 성공적인 값을 나타냅니다. 그러나 이러한 관례는 엄격하지 않으며, Either는 다양한 목적으로 사용할 수 있습니다.

결론적으로, Result와 Either는 유사한 기능을 제공하지만 사용 목적과 관례가 다릅니다. Result는 주로 오류 처리를 위한 것이고, Either는 더 일반적인 사용 사례를 수용합니다. 이러한 차이로 인해 선택하는 타입에 따라 코드의 목적과 가독성이 달라질 수 있습니다.

최종적으로 Left에 오류 이외에도 Next, Abort 상태가 필요하므로 Either로 선택하였습니다.

이제 중단 가능한 Promise를 구현해 보겠습니다.

Q: 자바스크립트 promise 취소하는 코드 만들어줘

A: 자바스크립트에서 Promise는 기본적으로 취소할 수 없습니다. 하지만, AbortController를 이용해 외부에서 작업을 중단시키는 것은 가능합니다. 아래 코드는 fetch API를 사용하여 작업을 중단시키는 예제입니다.

// 취소 가능한 비동기 작업 함수를 작성합니다.
function fetchWithAbort(url, signal) {
  return new Promise(async (resolve, reject) => {
    if (signal.aborted) {
      reject(new Error('Operation was aborted'));
      return;
    }

    try {
      const response = await fetch(url, { signal });
      resolve(response);
    } catch (error) {
      if (error.name === 'AbortError') {
        reject(new Error('Operation was aborted'));
      } else {
        reject(error);
      }
    }
  });
}

(async () => {
  const controller = new AbortController();
  const signal = controller.signal;

  // 작업을 실행합니다.
  const fetchData = fetchWithAbort('https://api.example.com/data', signal);

  // 일정 시간 후 작업을 취소합니다.
  setTimeout(() => {
    controller.abort();
  }, 3000); // 3초 후 작업 취소

  try {
    const response = await fetchData;
    console.log('Success:', response);
  } catch (error) {
    console.log('Error:', error.message);
  }
})();

위의 코드는 fetchWithAbort 함수를 사용하여 AbortController의 신호를 전달하고, 3초 후에 작업을 취소하도록 설정합니다. 이러한 패턴은 취소 가능한 Promise를 만드는 데 사용할 수 있습니다. 작업이 취소되면 에러가 발생하고, catch 블록에서 처리됩니다.

AbortController를 통해 fetch 시에 취소 처리하는 코드인데요. 제가 원하는 코드는 fetch뿐만 아니라 일반적인 Promise 상황에서 abort 되는 코드이기 때문에 다른 구현 방법을 물어봤습니다.

Q: 다른 취소 구현 방법은 없어?

A: 네, 물론 다른 방법도 있습니다. 여기 취소 가능한 프로미스를 구현하는 또 다른 방법을 제시하겠습니다. 이번에는 취소 토큰 객체를 사용하여 작업을 취소할 수 있는 기능을 추가합니다.

class CancellationToken {
  constructor() {
    this.isCancelled = false;
  }

  cancel() {
    this.isCancelled = true;
  }
}

function createCancellablePromise(task, cancellationToken) {
  return new Promise(async (resolve, reject) => {
    try {
      const result = await task(cancellationToken);
      resolve(result);
    } catch (error) {
      reject(error);
    }
  });
}

// 예제 작업 함수
function exampleTask(cancellationToken) {
  return new Promise((resolve) => {
    const timer = setInterval(() => {
      if (cancellationToken.isCancelled) {
        clearInterval(timer);
        reject(new Error('Task cancelled'));
      }
    }, 100);

    setTimeout(() => {
      clearInterval(timer);
      if (!cancellationToken.isCancelled) {
        resolve('Task completed');
      }
    }, 5000);
  });
}

(async () => {
  const cancellationToken = new CancellationToken();
  const cancellablePromise = createCancellablePromise(exampleTask, cancellationToken);

  // 일정 시간 후 작업을 취소합니다.
  setTimeout(() => {
    cancellationToken.cancel();
  }, 3000); // 3초 후 작업 취소

  try {
    const result = await cancellablePromise;
    console.log('Success:', result);
  } catch (error) {
    console.log('Error:', error.message);
  }
})();

위 코드에서는 CancellationToken 클래스를 정의하여 취소 가능한 프로미스를 만들었습니다. createCancellablePromise 함수는 작업 함수와 취소 토큰을 인수로 받아서 취소 가능한 프로미스를 생성합니다. 작업 함수 내에서 취소 토큰의 상태를 확인하여 작업을 중지할 수 있습니다.

이 방법은 개발자가 작업 함수에서 취소 토큰의 상태를 주기적으로 확인하여 작업을 취소하는 로직을 구현해야 한다는 단점이 있습니다. 그러나 이 방법은 작업 함수 내에서 취소 시점을 더 세밀하게 제어할 수 있다는 장점이 있습니다.

timer를 통해 주기적으로 취소 여부를 확인하는 코드를 보여줬지만, timer를 사용한다는 것이 조금 걸리기 때문에 다른 방법은 없는지 다시 물어보았습니다.

Q: 다른 방법은?

A: 아래에서는 generator 함수를 사용하여 취소 가능한 프로미스를 구현하는 방법을 보여 드리겠습니다. 이 방법은 generator 함수를 사용해 작업을 실행하면서 취소를 처리하는 것입니다.

function createCancellablePromise(generatorFunc) {
  let generator;
  let isCancelled = false;

  const cancel = () => {
    isCancelled = true;
    if (generator) {
      generator.return();
    }
  };

  const runGenerator = async (gen) => {
    let result;
    try {
      while (!isCancelled) {
        result = gen.next();
        if (result.done) break;
        await result.value;
      }
    } catch (error) {
      throw error;
    }

    if (isCancelled) {
      throw new Error('Task cancelled');
    } else {
      return result.value;
    }
  };

  const promise = (async () => {
    generator = generatorFunc();
    return await runGenerator(generator);
  })();

  return { promise, cancel };
}

// 예제 작업 generator 함수
function* exampleTask() {
  yield new Promise((resolve) => setTimeout(resolve, 1000));
  console.log('Step 1');
  yield new Promise((resolve) => setTimeout(resolve, 1000));
  console.log('Step 2');
  yield new Promise((resolve) => setTimeout(resolve, 1000));
  console.log('Step 3');

  return 'Task completed';
}

(async () => {
  const { promise, cancel } = createCancellablePromise(exampleTask);

  // 일정 시간 후 작업을 취소합니다.
  setTimeout(() => {
    cancel();
  }, 2500); // 2.5초 후 작업 취소

  try {
    const result = await promise;
    console.log('Success:', result);
  } catch (error) {
    console.log('Error:', error.message);
  }
})();

위 코드는 createCancellablePromise 함수를 사용하여 generator 함수를 취소 가능한 프로미스로 변환합니다. 이 함수는 취소 가능한 프로미스와 취소 함수를 반환하므로, 취소 함수를 호출하면 작업이 취소되고 에러가 발생합니다.

이 방법은 generator 함수를 사용하여 작업의 각 단계를 더 세밀하게 제어할 수 있다는 장점이 있습니다. 그러나 이 방법은 작업 함수를 generator 함수로 작성해야 한다는 단점이 있습니다.

이 방법 또한 중간 단계에서 멈출 수 없기 때문에, 사용하기 어렵다는 생각이 들어 Promise.race라는 힌트를 주었습니다.

Q: Promise.race로 구현해줘

A: Promise.race를 사용하여 취소 가능한 Promise를 구현하는 방법을 제공하겠습니다. 이 경우 Promise.race는 여러 프로미스 중 하나가 이행되거나 거부될 때까지 기다립니다. 우리는 이러한 동작을 활용하여 cancelToken 프로미스와 원하는 작업 프로미스를 만들어 취소 가능한 프로미스를 구현할 수 있습니다.

function createCancellablePromise(promise) {
  let cancel;

  const cancelToken = new Promise((_, reject) => {
    cancel = () => {
      reject(new Error('Promise cancelled'));
    };
  });

  const cancellablePromise = Promise.race([promise, cancelToken]);

  return {
    promise: cancellablePromise,
    cancel,
  };
}

// 예제 작업 프로미스
function exampleTask() {
  return new Promise((resolve) => {
    setTimeout(() => {
      resolve('Task completed');
    }, 5000);
  });
}

(async () => {
  const { promise, cancel } = createCancellablePromise(exampleTask());

  // 일정 시간 후 작업을 취소합니다.
  setTimeout(() => {
    cancel();
  }, 3000); // 3초 후 작업 취소

  try {
    const result = await promise;
    console.log('Success:', result);
  } catch (error) {
    console.log('Error:', error.message);
  }
})();

위의 코드는 createCancellablePromise 함수를 사용하여 주어진 프로미스를 취소 가능한 프로미스로 감싸고 있습니다. 이 함수는 취소 가능한 프로미스와 취소 함수를 반환하므로, 취소 함수를 호출하면 작업이 취소되고 에러가 발생합니다. 이렇게 하면 Promise.race를 사용하여 프로미스를 취소할 수 있습니다.

Promise.race 방법을 선택하기 전에 이 방법의 단점을 물어보았습니다.

Q: Promise.race를 사용했을 때 단점은 없어?

A: Promise.race를 사용하여 취소 가능한 프로미스를 구현할 때 몇 가지 단점이 있습니다.

  1. 리소스 낭비: Promise.race를 사용하여 취소 가능한 프로미스를 만들면 작업이 취소되더라도 실제 작업 프로미스는 여전히 실행 중입니다. 취소된 작업이 리소스를 계속 소비할 수 있으며, 완료되거나 실패할 때까지 프로미스가 계속 실행됩니다.
  2. 깔끔하지 않은 오류 처리: 취소 작업을 위한 별도의 프로미스를 사용하면, 작업이 취소되었을 때와 실제 작업 오류와 구분하기 어려울 수 있습니다. 이로 인해 추가적인 오류 처리 로직이 필요할 수 있습니다.
  3. 작업 내부에서 취소 구현 필요: 작업 내부에서 명시적으로 취소를 처리해야 하는 경우, Promise.race로만 이를 구현하기 어렵습니다. 작업 내부에서 취소를 처리하려면 작업 함수에 취소 토큰을 전달하거나 다른 방법을 사용해야 합니다.
  4. 코드 복잡성: Promise.race를 사용하여 취소 가능한 프로미스를 구현하려면 추가적인 코드가 필요합니다. 이로 인해 코드가 복잡해질 수 있으며, 다른 개발자가 이해하기 어려울 수 있습니다.

이러한 단점에도 불구하고, Promise.race는 여러 프로미스 중 하나가 이행되거나 거부될 때까지 기다리는 간단하고 빠른 방법을 제공합니다. 이 기능은 작업을 취소하는 데 사용할 수 있지만, 위에서 언급한 단점을 고려해야 합니다.

ChatGPT의 답변을 복합적으로 참고하여 코드를 구현해보겠습니다. 취소구현에서 대부분 토큰을 이용하므로, 토큰을 먼저 구현합니다.

class AbortToken {
    private _prevTokens: IAbortToken[] = [];

    abort(data: unknown): void {
        this._prevTokens.forEach(token => token.abort(data));
    }

    addPrevToken(token: IAbortToken): void {
        this._prevTokens.push(token);
    }
}

class AsyncAbortToken<T = unknown> extends AbortToken {
    private _defer = new Deferred<T>();
    private readonly _parentToken?: IAbortToken;

    constructor(parentToken?: IAbortToken) {
        super();

        this._parentToken = parentToken;
    }

    get promise(): Promise<T> {
        return this._defer.promise;
    }

    abort(data: unknown): void {
        super.abort(data);

        if (this._parentToken) {
            this._parentToken.abort(data);
        }

        this._defer.reject({[aborted]: true, reason: data});
    }
}

개발에 필요한 모든 답변을 얻었기 때문에 이제 본격적으로 코드 작성을 진행합니다. Promise.race, Either 등 ChatGPT의 답변 내용을 고려하여 구현합니다.

class AbortablePromise<T = unknown> implements PromiseLike<T> {
    protected _abortToken: IAbortToken;
    private readonly _promise: Promise<T>;
    private _fulfilled = false;

    constructor(fn: TPromiseConstructorParameter<T>, parentAbortToken?: IAbortToken) {
        const abortToken = new AsyncAbortToken<T>(parentAbortToken);

        this._abortToken = abortToken;
        this._promise = Promise.race([abortToken.promise, new Promise<T>(fn)]).finally(() => {
            this._fulfilled = true;
        });
    }

    abort(data = {}): void {
        if (this._fulfilled) {
            return;
        }

        this._fulfilled = true;

        this._abortToken?.abort(data);
    }

    get abortToken() {
        return this._abortToken;
    }

    toPromise(): Promise<T> {
        return this._promise;
    }

    private _then<TResult1 = T, TResult2 = never>(
        onFulfilled: TOnFulfilled<T, TResult1>,
        onRejected: TOnRejected<TResult2>,
    ): AbortablePromise<TResult1 | TResult2> {
        const instance = new AbortablePromise<TResult1 | TResult2>((resolve, reject) => {
            this.toPromise()
                .then(
                    arg => {
                        const result = onFulfilled(arg);

                        if (result instanceof AbortablePromise) {
                            instance.abortToken.addPrevToken(result.abortToken);
                            result.abortToken.addPrevToken(this.abortToken);
                            return result.toPromise();
                        }

                        return result;
                    },
                    error => {
                        const rej = onRejected(error);
                        const result = rej instanceof AbortablePromise ? rej.toPromise() : rej;

                        if (error && error[aborted]) {
                            const fn = () => Promise.reject(error);
                            return Promise.resolve(result).then(fn, fn);
                        }

                        return result;
                    },
                )
                .then(resolve, reject);
        }, this._abortToken);

        return instance;
    }

    then<TResult1 = T, TResult2 = never>(
        onFulfilled: TOnFulfilled<T, TResult1> = identity,
        onRejected: TOnRejected<TResult2> = err => Promise.reject(err),
    ): AbortablePromise<TResult1 | TResult2> {
        return this._then(onFulfilled, onRejected);
    }

    catch<TResult = never>(onRejected?: TOnRejected<TResult>): AbortablePromise<T | TResult> {
        return this._then<T, TResult>(identity, onRejected);
    }
}
// Either: [success, value] | [abort | next | fail | success, reason]
// Result: Either | AbortablePromise<Either>
//         동기   | 비동기
// Fn: any -> AbortablePromise<Either> | Either | Promise<any> | else
// Action: any -> Result
// toAction: Fn -> Action
const toAction = (fn) => {
    const action = value => {
        return toResult(fn(value));
    }

    return action;
};

// any -> Result
const toResult = (res, isFail = false) => {
    if (res instanceof Either) {
        return res;
    }
    if (res instanceof AbortablePromise) {
        return res.then(v => toResult(v), err => toResult(err, true));
    }

    return !isFail? new Right(res) : new Left(res);
};
const action3 = toAction(() => {
    return new AborablePromise(res => setTimeout(res, 5000));
});

setTimeout(() => action3.abort("abort"), 1000); // Promise<Left<Abort>>

이렇게 AbortPromise를 완성하였습니다.

3. 최적화, 코드리뷰: 코드에 문제가 없는지 알려줘

코드 작성을 완료하였으니, 잘 작성했는지 리뷰를 진행해 보겠습니다. 우선 간단하게 작성한 코드를 잘 이해하고 있는지 ChatGPT에게 물어보겠습니다.

Q: AbortToken, AsyncAbortToken 코드 설명해줘

A: 이 코드는 AbortTokenAsyncAbortToken 클래스를 정의합니다. AbortToken은 작업을 중단하는 데 사용되는 기본 클래스입니다. AsyncAbortTokenAbortToken을 확장하여 비동기 작업을 처리합니다.

AbortToken 클래스는 이전 토큰을 중단할 수 있는 기능을 제공합니다. AsyncAbortTokenAbortToken을 확장하고, 비동기 작업을 처리하기 위해 Deferred 객체를 사용합니다. 이 클래스는 abort 메서드를 사용하여 부모 토큰을 중단할 수도 있습니다.

구현 의도대로 설명하는 것을 확인할 수 있습니다.

Q: 최적화 시켜줘

A: 이 코드는 이미 간결하고 명확하게 작성되어 있습니다. 그러나 아래와 같이 클래스 선언과 생성자를 간결하게 하여 약간 최적화할 수 있습니다:

class AbortToken {
    private _prevTokens: IAbortToken[] = [];

    abort(data: unknown): void {
        this._prevTokens.forEach(token => token.abort(data));
    }

    addPrevToken(token: IAbortToken): void {
        this._prevTokens.push(token);
    }
}

class AsyncAbortToken<T = unknown> extends AbortToken {
    private _defer = new Deferred<T>();

    constructor(private readonly _parentToken?: IAbortToken) {
        super();
    }

    get promise(): Promise<T> {
        return this._defer.promise;
    }

    abort(data: unknown): void {
        super.abort(data);

        if (this._parentToken) {
            this._parentToken.abort(data);
        }

        this._defer.reject({[aborted]: true, reason: data});
    }
}

이 최적화는 생성자에서 _parentToken 변수를 직접 선언하고 초기화하도록 변경하였습니다. 이렇게 하면 별도의 _parentToken 필드 선언을 제거할 수 있습니다. 그러나 이 최적화는 이미 깔끔한 코드를 조금 더 간결하게 만드는 것뿐입니다. 코드의 전체 구조와 작동 방식에는 큰 변화가 없습니다.

변수 선언에 대한 더 간략한 방식을 알려주고 있는 것을 볼 수 있습니다. 저희 팀에서는 변수 선언과 관련된 lint 룰을 사용하고 있기 때문에 수정하지 않았습니다.

이제 마지막으로 코드 리뷰를 받아 보겠습니다.

Q: 코드 리뷰 해줘

A:

  1. 클래스 이름이 명확합니다.
    • AbortToken은 중단 토큰의 기본 기능을 나타내는 이름입니다.
    • AsyncAbortToken은 비동기 작업을 처리하는 클래스임을 나타내는 이름입니다.
  2. 클래스 메서드는 의도에 따라 잘 정의되어 있습니다.
    • abort 메서드는 토큰을 중단하는 역할을 합니다.
    • addPrevToken 메서드는 이전 토큰을 추적하는 역할을 합니다.
  3. 클래스 상속이 적절하게 사용되었습니다.
    • AsyncAbortTokenAbortToken을 확장하여 비동기 작업을 처리할 수 있습니다.
      1. AsyncAbortToken 클래스의 생성자에서 선택적 인수 parentToken을 사용하여 부모 토큰을 설정할 수 있습니다.
      2. AsyncAbortToken 클래스의 abort 메서드는 부모 토큰이 있는 경우 해당 부모 토큰도 중단하도록 구현되어 있습니다.

전반적으로 코드는 잘 구성되어 있으며, 클래스의 목적과 역할이 명확하게 정의되어 있습니다. 깔끔하게 작성된 코드로 보입니다.

다행히 수정사항이 없이 완료되었습니다.

정리: 실무에서 ChatGPT 활용 방법

본문에서 언급한 것처럼 구현 단계에서 ChatGPT를 활용하는 방법은 세 가지 입니다.

  1. 코딩 지원
    • 개발 효율성 향상: ChatGPT를 사용하면 코딩, 디버깅, 테스트 케이스 작성과 같은 일상적인 작업을 빠르게 수행할 수 있어 개발 시간을 단축하고, 효율성을 높일 수 있습니다.
  2. 교육 및 리서치
    • 지식 공유와 협업: 개발자는 ChatGPT를 통해 빠르게 새로운 기술을 습득하고, 팀원과 지식을 공유하며 협업하는 데 도움을 받을 수 있습니다. 이는 프로젝트의 성공 가능성을 높이고, 팀의 역량을 향상합니다.
    • 창의적인 솔루션 리서치: ChatGPT는 개발자가 창의적인 접근 방식으로 문제를 해결할 수 있도록 도와 새로운 아이디어를 구현하고, 독창적인 소프트웨어 솔루션을 개발하도록 합니다.
  3. 최적화 코드 리뷰
    • 코드 품질 개선: ChatGPT는 코드 리뷰 및 최적화를 지원해 개발자가 더 나은 코드 작성 습관을 익히고, 전반적인 코드 품질을 개선하도록 돕습니다.

결론

앞으로도 인공지능 기술이 발전함에 따라 개발자가 ChatGPT 같은 기술을 더욱 광범위하게 활용할 것으로 기대되며, 소프트웨어 개발 분야의 혁신과 발전에도 큰 영향을 미칠 것입니다. 따라서 개발자가 ChatGPT를 활용하는 분야는 더 많아지고, 의존도도 더 높아질 것입니다. AI 기술의 빠른 발전과 적용 가능성은 개발자의 업무 효율을 높이고, 혁신적인 소프트웨어 개발에도 도움을 줄 것입니다.

주의사항 – ChatGPT는 거짓말쟁이

ChatGPT에 대해 주의해야 할 점은 바로 ‘진실성’입니다. ChatGPT의 응답은 때때로 거짓인 경우가 있습니다. 물론 ChatGPT의 지속적인 업데이트를 통해 성능이 개선되고 있지만 여전히 완벽하지 않습니다. 때문에 개발자는 최종 검토와 수정 과정을 거쳐야 합니다.

에필로그

ChatGPT와 페어프로그래밍을 하면서, 느낀 점이 몇 개 있습니다.

첫째, ChatGPT 3.5와 4.0은 큰 차이가 있습니다.
ChatGPT 3.5는 코드 작성은 가능하지만, 코드에 버그가 많아서 대체로 잘 작동하지 않았습니다. 제가 느끼기에는 1년 차 신입 개발자하고 같이 일하는 기분이었습니다. 반면 ChatGPT 4.0은 버그가 존재하긴 하지만 대체로 잘 동작하였습니다. 이는 2~3년 차 개발자 수준이었습니다. 그래서 업무 시에는 항상 ChatGPT 4.0만 사용했습니다.

둘째, 마틴 파울러의 글 등을 보면 프롬프트를 입력할 때, 사전지식(context)을 충분하게 제공하라고 했지만, 저는 아래와 같은 이유로 채팅처럼 간단하게 질문을 던지는 것이 더 어울린다고 생각합니다.

  1. 이름에 ‘Chat’이 들어갔듯이 간단한 대화를 나누는 형태가 목적에 더 부합해 보입니다.
  2. 질문할 때 주로 사용하는 비 프로그래밍 언어(한국어, 영어)는 모순과 결함이 많아 사전지식을 정확히 작성하기가 어렵습니다. 그럼에도 정확히 작성한다고 하면, 결국 프로그래밍 언어로 구현된 코드와 차이가 없을 것입니다.
  3. 상세하게 명세를 작성해도 ChatGPT 4.0의 코드를 그대로 사용하기는 어렵기 때문에, 간단히 질문을 하고 수정하여 사용하는 것이 더 효율적입니다.
  4. ChatGPT는 프롬프트를 기억하기 때문에 처음부터 구체적으로 명시하면, 알고 있는 해답만 나오기 때문에 새로운 해답을 찾기 어렵습니다.

실제로 저는 ChatGPT에 ‘우선 XX를 구현해 줘’, ‘다른 구현 방법은 없어?’, ‘다른 라이브러리를 사용해서 구현해 줘’ 와 같은 간단한 질문을 하고, 답변 코드를 가지고 코딩하였습니다.

셋째,ChatGPT를 사용하였을 때 생산성이 향상되었습니다. 자세히 알아보기 위해 문제 해결 프로세스를 살펴보겠습니다.

  1. 문제 정의: 취소 가능한 Promise 구현
  2. 해결안 모색:
    • AbortController, fetch 를 사용하여 구현
    • CancellationToken을 사용하여 구현
    • generator 함수를 사용하여 구현
    • Promise.race를 사용하여 구현
  3. 해결안 도출: Promise.race를 사용하여 실제 코드를 작성한다.
  4. 평가: 도출된 결과가 실제 문제를 해결하고 있는지 확인한다.

문제 해결 프로세스는 상황이나 문맥을 통한 복합적인 사고 필요 여부에 따라 단순한 업무와 복잡한 업무로 구분 할 수 있습니다.

프로세스업무 구분
문제 정의복잡한 업무
해결안 모색단순한 업무
해결안 도출단순한 + 복잡한 업무
평가복잡한 업무
아래 내용은 하나의 예시입니다. 따라서 업무에 따라 달라질 수 있습니다.

이제 단순한 업무를 ChatGPT로 전달하여 ‘시니어(복잡한 업무) + ChatGPT’의 분업으로 처리할 수 있습니다. 이를 통해 생산성을 향상시킬 수 있으며, 개인적으로는 1.5배 정도 생산성 향상 효과가 있었습니다.

넷째, ChatGPT로 선행 코드를 미리 작성할 수 있기 때문에 백지 공포증이 줄어들어 코딩을 시작할 때 용기가 생겼고, 누군가와 함께 일하는 느낌이 들어 외로움도 줄어드는 등 심리적인 장점이 있었습니다.

마지막으로는 한 명의 친구가 생기는 느낌을 받기도 했습니다. 이 글에서는 프로그래밍에 대한 질문만 나열했지만, 친구와 화해하는 방법, 와이프를 감동시키는 방법 등 여러 질문을 했고, 답변에서 큰 도움을 받았습니다. 그때부터 ChatGPT는 진정한 친구가 되었습니다. 단, 실제 친구는 공짜이지만, 이 친구는 공짜가(매달 20달러) 아니지만요.

김성원 | SmartEditor

자바스크립트 시니어 개발자