개발/사이드 프로젝트

사이드 프로젝트에 테스트 코드 작성하기

dev_jiwonpark 2025. 11. 26. 22:37

SNS 앱 프로젝트를 개발하면서 매번 Postman 이나 브라우저를 열어서 API를 수동으로 테스트하는 것이 번거로웠다. 

회원가입 API를 수정할 때마다 직접 폼을 채우고, 로그인을 테스트할 때마다 이메일과 비밀번호를 입력하고, 게시글 작성 API를 확인할 때마다 일일히 폼 데이터를 채우고.. 이런 반복 작업이 쌓이다 보니 아래의 몇 가지 문제들이 생겼다.

  1. 시간 낭비: 작은 수정 하나에도 여러 API를 다시 테스트해야 함
  2. 휴먼 에러: 수동 테스트라 놓치는 케이스가 생김
  3. 회귀 버그: 새로운 기능 추가 시 기존 기능이 망가졌는지 확인하기 어려움
  4. 문서화 부재: 어떤 API가 어떤 응답을 반환하는지 기억에 의존

 

때문에 Test 자동화가 필요하다는 생각이 들었다.

처음에는 "테스트 = Jest"라는 생각으로 Jest를 설치하려고 했는데 
프로젝트가 초기 단계이고, 빠르게 테스트를 만들어 실용성을 확인하고 싶었기 때문에 간단한 Node.js 스크립트를 선택했다.

 

테스트 종류에는 여러 가지가 있다.
  - 유닛 테스트: 개별 함수만 테스트
  - 통합 테스트: 여러 모듈이 함께 동작하는지 테스트
  - E2E 테스트: 브라우저에서 실제 사용자 시나리오 테스트

 

블로그 작성에는 프로젝트의 핵심 기능인 Auth  & 게시글 관련 기능들이 실제로 잘 동작하고 있는지 확인하기 위해 통합 테스트를 

진행 해본 내용을 작성해보려고 한다. 

깃헙에 들어가면 자세한 코드를 확인할수 있다.

 

https://github.com/parkjiwonn/Nextjs-SNS

 

GitHub - parkjiwonn/Nextjs-SNS: Next.js로 구축한 풀스택 SNS 앱

Next.js로 구축한 풀스택 SNS 앱 . Contribute to parkjiwonn/Nextjs-SNS development by creating an account on GitHub.

github.com

( 현재 개발중인 프로젝트는 아래 git hub에서 확인 가능합니다. )

 

우선 내가 진행한 테스트 카테고리는 아래와 같다!

카테고리 검증목적
회원가입 필수 필드 검증, 중복 체크, 이메일 형식, 길이 제한
로그인 인증 실패 케이스들 (잘못된 비밀번호, 빈 값, 공백 등)
세션관리 쿠키 발급, 보호된 API 접근, 다중 세션
비밀번호 보안 응답에 비밀번호 노출 방지, 해시 검증, SQL Injection 방어
API 기본 DB 연결, 인증 없이 접근이 차단되는지

 

예를들어 " SQL Injection 방어 테스트 " 가 되는지 확인해본 테스트는 다음과 같다.

  async function testSqlInjectionInPassword(): Promise<void> {
    const sqlInjectionPasswords = [
      "' OR '1'='1",
      "admin'--",
      "' OR 1=1--",
      "'; DROP TABLE users--"
    ];

    for (const maliciousPassword of sqlInjectionPasswords) {
      const userData = {
        email: `sql${Date.now()}@test.com`,
        username: `sqluser${Date.now()}`,
        password: maliciousPassword,
        name: 'SQL테스트'
      };

      // 회원가입은 성공 (일반 문자열로 처리)
      const response = await fetch(`${BASE_URL}/api/auth/signup`, {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify(userData)
      });

      if (response.status === 201) {
        // 정확히 같은 문자열로만 로그인되어야 함
        const { success } = await login(userData.email, maliciousPassword);
        expect(success, true, 'SQL injection string should be treated as normal password');

        // 다른 변형으로는 로그인 안 됨
        const { success: failed } = await login(userData.email, "' OR '1'='2");
        expect(failed, false, 'Should not login with different SQL injection');
      }
    }

    log(`   SQL Injection 패턴이 일반 문자열로 안전하게 처리됨`, 'yellow');
  }

 

또는 "세션 없이 보호된 API 호출 차단" 되는지 확인하는 테스트는 다음과 같다.

  async function testProtectedApiWithoutSession(): Promise<void> {
    const { response, data } = await createPost('', '테스트 게시글');

    expect(response.status, 401, 'Should return 401 Unauthorized');
    expect(!!data.error, true, 'Should return error message');
    expect(data.error, '로그인이 필요합니다', 'Should return login required message');

    log(`   에러 메시지: ${data.error}`, 'yellow');
  }

 

추가로 테스트를 진행하면서 까다로웠던 점은 NextAuth 였다.

현재 사이드 프로젝트를 진행하면서 세션 & 쿠키 관리를 NextAuth로 개발하고 있는데 

" 로그인 성공 여부 판단 " 하는 테스트를 진행하면서 NextAuth는 성공/실패를 HTTP 상태 코드로 명확히 구분하지 않아서
세션 토큰 존재 여부로 판단해야 했다. 
=> const success = cookies.includes('next-auth.session-token');

 

 - 성공: 302 리다이렉트 또는 200
 - 실패: 302 리다이렉트 (그런데 세션 쿠키가 없음) 

 

테스트를 작성하면서 깨달은 건..

두둥.. 테스트 커버리지 100%는 힘들다 였다.

처음에는 "모든 코드에 테스트를 작성해야겠다!"라는 생각으로 시작했지만, 현실은 달랐다.

 

테스트 커버리지 100%를 목표로 하면 테스트 코드가 프로덕션 코드보다 방대해지기 때문에 

코드량과 테스트량이 비례해서 증가하는 방향으로 개발을 해야 지속 가능한 테스트를 진행할수 있다는 것이다.

 

프로덕션 코드 1000줄 증가 할때 테스트 코드 1000줄 증가하면 감당 가능하지만
프로덕션 코드 1000줄 증가 할때 테스트 코드 3000줄 증가하면 유지보수 지옥이 펼쳐지는 것이다.

 

때문에 모든 것을 테스트하려는 생각을 하는것이 아니라 비즈니스적으로 중요한 기준을 먼저 테스트하는것이 중요하다는걸 깨달았다.

 

예를들어 회원가입 시 "이메일 중복 체크"는 핵심 비즈니스 로직이다.
그래서 반드시 테스트 필요하다.

하지만 에러 메시지 문구가 정확히 "이미 사용 중인 이메일입니다"인지 테스트하는건 덜 중요하다.  

왜냐하면 메시지는 바뀔 수 있기 때문이다.

이렇기 때문에 테스트에도 아래와 같은 우선순위를 두는것이 중요하다.

 

< 우선 순위 >

1. 인증/인가 (보안 핵심)
2. 결제 로직 (금전 관련)
3. 데이터 무결성 (중복, 제약조건)
4. 외부 API 연동

 

그리고  비즈니스 로직보다 순수 함수를 테스트하는것이 더 효율적이라는것을 깨닫게 되었다.

비즈니스 로직 테스트의 문제점은 기획이 바뀌면 테스트 코드도 전부 수정해야한다.

그래서 유지보수 비용이 기하급수적으로 증가하게 되는데

순수 함수 테스트의 장점은 입력 & 출력만 검증하면 되기 때문에 비즈니스 로직이 바뀌어도 테스트 코드는 유지할 수 있게 된다.

 

근데 만약 테스트를 하려고하는데... 내가 작성한 코드들이 순수한 로직들이 없다 라는 건

즉 모듈화가 제대로 되어있지 않다 이기 때문에 설계가 잘못되었다는 신호일 수 있다.

 // 비즈니스 로직 테스트 (기획이 바뀌면 수정해야함.)
  test('회원가입 시 포인트 1000점 지급', () => {
    // 기획: 포인트 1000 → 500으로 변경
    // → 테스트 수정 필요
  });

  // 순수 함수 테스트 (기획과 무관)
  test('이메일 유효성 검사', () => {
    expect(isValidEmail('test@test.com')).toBe(true);
    expect(isValidEmail('invalid')).toBe(false);
    // 기획이 바뀌어도 이메일 형식은 안 바뀜
  });

 


지금까지 계속 테스트를 어떻게 했는지에 대한 이야기를 했다면 좀 더 근본적으로 이야기 해보려고 한다.

나는 이번 작업을 하면서 테스트 코드를 왜 작성해야하는지 를 고민하게 되었다.

첫번째로 테스트는 기획자의 의도대로 코드를 작성했는지 검증하는 요소 라고 생각했다.

테스트를 작성하면서 기획에 대한 명확한 이해를 하게 된다.

예를 들어 

Q. " 로그인 실패 " 의 기준이 무엇인지?

Q. 에러 메세지는 어떤 형식인지?

Q. 로그인을 몇번 실패하면 계정이 잠기게 되는지? 와 같은 질문들을 하면서 촘촘하게 기획에 대해 이해할 수 있게 된다.

 

두번째로 외부 의존성을 발견하고 문제의 근원지를 빠르게 파악할수 있는 기회 라고 생각한다.

테스트를 작성하면서 내가 작성한 코드만 테스트하는 것이 아니구나! 라는걸 깨달았다.

요근래 AWS 에서 발생하는 에러로 인해 서비스들의 일시적인 장애 발생하게 되는데 이런 경우 외부 서비스의 문제로 우리 서비스에 문제가 생기는 건데

내가 작성한 코드의 테스트 결과가 무엇인지~ 알지 못하면 외부적인 문제는 보지 못한채 애꿎은 내부사정만 들여다보게 되는것이다.

그래서 테스트 코드는 시스템 문제의 근원지 파악이 빨라진다는 장점이 있다. 

 

세번째로는 회귀 테스트 방지이다. 

새로운 기능을 추가하거나 코드를 리팩토링했을 때, 기존에 잘 동작하던 기능이 갑자기 망가지는 경우가 있다. 이걸 "회귀 버그"라고 한다.

예를 들어

  • 회원가입 API 응답 형식을 바꿨는데, 로그인 로직에서 그 응답을 파싱하는 부분이 깨짐
  • 비밀번호 해싱 로직을 개선했는데, 기존 사용자 로그인이 안 됨
  • 세션 처리 코드를 정리했는데, 게시글 작성 API 인증이 풀림

수동 테스트로는 이런 사이드 이펙트를 다 잡기 어렵다. 수정한 부분만 확인하고 넘어가기 쉽기 때문이다.

자동화된 테스트가 있으면 코드 수정 후 테스트를 돌려서 "다른 곳 안 터졌나?"를 바로 확인할 수 있다. 테스트가 다 통과되면 안심하고 커밋할 수 있고, 하나라도 실패하면 어디서 문제가 생겼는지 바로 알 수 있다.

 

그러면,,!
테스트를 왜 해야할까?

첫번째로 신뢰성이 향상된다 라고 생각한다.

"이 코드는 동작한다"는 객관적인 증거가 된다. 내가 짠 코드가 맞게 동작하는지 매번 수동으로 확인할 필요 없이, 테스트 결과로 증명할 수 있디 때문이다.

두번째로 테스트는 명세 역할을 대신할수 있다.

테스트 코드는 기획서만큼이나 "이 기능이 어떻게 동작해야 하는지" 알려주는 문서가 된다.

// 기획서: "회원가입 시 이메일은 필수입니다"

// 테스트 코드는 더 구체적인 명세가 됨:
test('[실패] 이메일 누락', async () => {
  const response = await signup({ username: 'test', password: '1234' });
  expect(response.status, 400);
  expect(response.error, '이메일은 필수입니다');
});

// → 이메일 없이 요청하면 400 에러와 함께 에러 메시지가 온다는 것까지 알 수 있음

 

이렇게 테스트에 대해서 공부하고 개발하면서 과연 그럼 좋은 테스트란 무엇일까? 에 대해서 고민을 해봤는데

정답은 없지만 .. 프로젝트에서 어떤 규모의 테스트를 진행해야하는지를 먼저 고민하고 진행하는 테스트가 좋은 테스트라고 생각한다.

예를 들어서 

단위 테스트는 격리성과 속도가 중요하다.

순수 함수나 유틸리티처럼 외부 의존성 없이 독립적으로 실행되어야 하고, 빠르게 돌아가야 한다.

 

통합 테스트는 실제 환경과 얼마나 유사한지가 중요하다.

API 호출, DB 연동처럼 여러 모듈이 함께 동작하는 상황을 테스트하기 때문에, 실제 서버 환경과 비슷하게 구성해야 의미가 있다.

 

E2E 테스트는 사용자 시나리오를 얼마나 잘 재현하는지가 중요하다.

"로그인 → 게시글 작성 → 로그아웃" 같은 실제 사용 흐름을 브라우저에서 그대로 테스트한다.

 

내가 이번에 했던 테스트의 경우 통합 테스트를 진행했는데 Auth와 게시글 API가 실제로 잘 동작하는지 확인하는 게 목적이었기 때문이다. 

 


테스트 코드를 처음 작성해봤는데, 생각보다 재밌었다.

처음엔 "이걸 왜 굳이?" 싶었는데 막상 만들어두니까 코드 수정할 때 마음이 편해졌다.

예전엔 API 하나 고치고 Postman으로 이것저것 눌러보면서 "다른 데 안 터졌겠지...?" 했는데, 이제는 테스트 한 번 돌리면 끝이다.

 

Jest 안 쓰고 직접 테스트 러너 만든 것도 좋은 경험이었다.

프레임워크가 내부에서 뭘 하는지 조금이나마 이해하게 됐다.

물론 프로젝트 커지면 Jest로 갈아타야겠지만, 처음 시작하기엔 이 정도로 충분했다.

앞으로는 프론트엔드 테스트도 추가해보고 싶고, GitHub Actions로 푸시할 때마다 자동으로 테스트 돌아가게 만들어 보고 싶다!

'개발 > 사이드 프로젝트' 카테고리의 다른 글

개발 명세서 작성하기  (1) 2025.12.10