"당연하다고 생각했던 것들의 실체"
"환경 문제인 것 같은데요?"
11년 동안 QA 엔지니어로 일하면서 수도 없이 했던 말입니다. 개발자가 "로컬에서는 잘 되는데..."라고 하면 으레 환경 설정을 확인하라고 조언했죠. 마치 환경 문제가 뭔지 다 아는 것처럼 말이에요.
그런데 막상 JamesCompany 프로젝트를 직접 개발하면서, 제가 얼마나 무지했는지 깨달았습니다. "환경 분리"라는 게 단순히 localhost와 production URL만 바꾸면 되는 줄 알았던 제 자신이 부끄러웠죠.
이제 와서 생각해보면, 저는 환경 문제의 표면만 긁고 있었습니다. 마치 빙산의 일각만 보고 전체를 안다고 착각했던 것처럼요. 실제로 개발을 시작하고 나서야 그 빙산 아래에 얼마나 거대한 복잡성이 숨어있는지 알게 되었습니다.
QA 시절, 저는 환경 변수가 뭔지 안다고 생각했습니다. 하지만 실제로는 .env 파일을 단 한 번도 직접 본 적이 없었다는 걸 깨달았죠. 왜? 이 파일들은 보안상 Git에 올리지 않기 때문입니다. 개발자가 아닌 이상 접근할 기회조차 없었던 거예요.
막상 직접 만들어보니, 제가 얼마나 무지했는지 알게 되었습니다. "환경 변수 몇 개 설정하면 되겠지"라고 생각했는데, 실제로는 수 십 개가 넘는 설정값들이 빼곡히 들어있었습니다.
프론트엔드만 해도 Vite 때문에 모든 환경 변수 앞에 VITE_를 붙여야 했고, 각종 외부 서비스를 연동할 때마다 새로운 키들이 추가되었습니다. 토스페이먼츠 연동하니 클라이언트 키가 필요했고, Google Analytics 붙이니 트래킹 ID가 필요했고, Sentry 연동하니 DSN이 필요했고... 끝이 없었습니다.
백엔드는 더 복잡했습니다. JWT 토큰 관리를 위한 시크릿 키와 알고리즘 설정, 세션 관리를 위한 별도의 시크릿, 데이터 암호화를 위한 또 다른 키... 보안 관련 설정만 해도 수 십 개가 훌쩍 넘어갔습니다.
더 놀라운 건 이 환경 변수들이 서로 복잡하게 얽혀있다는 점이었습니다. 마치 거미줄처럼 하나를 건드리면 다른 것들도 영향을 받았죠. SSL을 활성화하면 쿠키 설정도 바꿔야 했고, CORS 정책도 수정해야 했습니다.
"이렇게 복잡한 걸 개발자들이 매번 관리하고 있었구나..." 처음으로 개발자들의 고충을 실감했습니다.
결제 시스템을 연동하면서 흥미로운 점을 발견했습니다. 토스페이먼츠는 테스트용 키와 운영용 키를 따로 제공하는데, 테스트 키는 test_로 시작하고, 운영 키는 live_로 시작하죠.
QA 엔지니어로서 항상 궁금했던 게 있었습니다. "테스트 결제는 어떻게 하고, 실제 결제는 어떻게 구분하는 거지? 같은 시스템인데 어떻게 다르게 동작하는 거야?"
직접 구현해보니 그 원리가 명확해졌습니다. 토스페이먼츠는 키의 prefix를 보고 완전히 다른 로직을 실행하더군요. test_로 시작하는 키를 사용하면,
실제 카드사와 통신하지 않음
특정 테스트 카드 번호만 인식 (4242-4242-4242-4242 같은)
항상 성공하거나, 특정 조건에서만 실패 시뮬레이션
반면 live_ 키를 사용하면 실제 PG사와 연동되어 진짜 결제가 일어납니다.
이런 구조의 장점을 실감한 건, 실수로 운영 환경에 테스트 키를 넣었을 때였습니다. 만약 이런 안전장치가 없었다면? 개발 중에 실수로 실제 결제를 발생시킬 수도 있었을 겁니다. 반대로 운영에 테스트 키가 들어가도 실제 결제가 일어나지 않으니 고객 피해는 막을 수 있죠.
하지만 이것도 함정이 있었습니다. 테스트 키로는 실패 케이스를 제대로 테스트하기 어려웠거든요. 특정 테스트 카드 번호를 써야만 잔액 부족, 카드 정지 등의 시나리오를 테스트할 수 있었습니다. QA 관점에서 "모든 케이스를 테스트했다"고 생각했는데, 사실은 매우 제한적인 테스트였던 셈이죠.
데이터베이스 설정은 솔직히 귀찮았습니다. "그냥 하나의 데이터베이스를 개발과 운영이 같이 쓰면 안 되나? 어차피 스키마는 같은데..." 이런 생각이 들었죠.
하지만 이내 왜 분리해야 하는지 깨달았습니다. 개발 중에는 수많은 테스트 데이터를 만들고 지웁니다. "테스트유저1", "test@test.com", "ㅁㄴㅇㄹ", "asdf", ... 같은 의미 없는 데이터들이 쌓이죠. 만약 이런 데이터가 운영 환경에 섞여 있다면?
실제로 한 번은 개발 DB에서 성능 테스트를 한다고 100만 건의 더미 데이터를 생성한 적이 있습니다. 만약 이게 운영 DB였다면? 실제 고객 데이터 사이에 쓰레기 데이터 100만 건이 섞여버리는 거죠. 분석도 어려워지고, 성능도 떨어지고, 무엇보다 어떤 게 진짜 데이터인지 구분할 수 없게 됩니다.
더 무서운 시나리오도 있었습니다. 개발 중에는 자유롭게 스키마를 변경하고, 테이블을 DROP하고, 데이터를 TRUNCATE합니다. 만약 실수로 운영 DB에서 이런 명령을 실행한다면? 상상만 해도 끔찍합니다.
그래서 환경별로 완전히 분리된 데이터베이스를 사용하기로 했습니다.
로컬(Local): Docker로 띄운 PostgreSQL (맘대로 부수고 다시 만들 수 있음)
개발(Development): 클라우드 DB 개발 인스턴스 (팀원과 공유, 테스트 데이터 포함)
운영(Production): 클라우드 DB 운영 인스턴스 (실제 고객 데이터, 절대 건드리면 안 됨)
이렇게 분리하니 마음이 편해졌습니다. 로컬에서는 마음껏 실험하고, 개발 DB에서는 통합 테스트를 하고, 운영 DB는 정말 필요한 경우가 아니면 접근조차 하지 않죠.
CORS(Cross-Origin Resource Sharing) 설정은 제가 완전히 과소평가했던 부분입니다. QA 시절엔 "CORS 에러요? 그냥 Access-Control-Allow-Origin 헤더 추가하면 되잖아요"라고 쉽게 말했었죠. 실제로 해보니 이게 보통 일이 아니더군요.
로컬 환경에서는 간단했습니다. 프론트엔드는 http://localhost:3000, 백엔드는 http://localhost:8000이니까 CORS 설정에 localhost:3000만 추가하면 끝이었죠.
하지만 Vercel에 배포하면서 상황이 복잡해졌습니다. Vercel은 브랜치마다, PR마다 새로운 URL을 생성합니다.
jamescompany-git-develop-james.vercel.app 같은 형태로요. 문제는 이 URL이 동적으로 생성되기 때문에 미리 예측할 수 없다는 점이었습니다.
처음엔 와일드카드를 써서 *.vercel.app을 모두 허용하려고 했습니다. 하지만 이건 보안상 매우 위험한 설정이었죠. 누구나 Vercel에 뭔가를 배포하고 제 API를 호출할 수 있게 되는 거니까요.
그래서 정규식을 사용해서 jamescompany-*.vercel.app 형태만 허용하도록 수정했습니다. 그런데 이것도 완벽하지 않았습니다. Vercel의 preview 배포는 또 다른 형태의 URL을 생성하더군요.
결국 환경별로 완전히 다른 CORS 정책을 적용해야 했습니다. 로컬은 localhost만, 개발은 Vercel 도메인들을, 운영은 오직 jamescompany.kr과 www.jamescompany.kr만 허용하도록요.
이 과정에서 깨달은 건, CORS가 단순한 귀찮은 설정이 아니라 중요한 보안 메커니즘이라는 점이었습니다. 그리고 환경별로 적절한 수준의 보안을 적용하는 것이 얼마나 섬세한 작업인지도 알게 되었죠.
개발자가 되고 나서 가장 혼란스러웠던 순간들 중 하나를 공유하고 싶습니다. API_BASE_URL 설정 때문에 겪은 일련의 사건들이죠.
처음엔 단순했습니다.
VITE_API_BASE_URL=https://api.jamescompany.kr로 설정하고, 코드에서 ${API_BASE_URL}/users 형태로 사용했죠. 그런데 어느 날 API 버전 관리가 필요해졌습니다.
"아, 그럼 BASE_URL에 /api/v1을 붙이면 되겠네!" 생각했습니다. 그래서 VITE_API_BASE_URL=https://api.jamescompany.kr/api/v1로 수정했습니다.
그런데 여기서부터 혼돈이 시작되었습니다. 어떤 개발자(제 과거의 저)는 이미 코드에서 /api/v1/users 형태로 경로를 만들고 있었거든요. 결과?
https://api.jamescompany.kr/api/v1/api/v1/users라는 기괴한 URL이 생성되었습니다.
"아, 그럼 코드를 수정하자!" 해서 /users로 바꿨더니, 이번엔 다른 문제가 생겼습니다. 어떤 곳에서는 이미
api/v1 없이 직접 엔드포인트를 호출하고 있었거든요. 그 부분들은 갑자기 404 에러를 뱉기 시작했습니다.
더 웃긴 건 슬래시 문제였습니다. 어떤 환경 변수는 끝에 슬래시가 있고, 어떤 건 없고...
https://api.jamescompany.kr/api/v1/ + users = https://api.jamescompany.kr/api/v1/users ✅
https://api.jamescompany.kr/api/v1 + /users = https://api.jamescompany.kr/api/v1/users ✅
https://api.jamescompany.kr/api/v1/ + /users = https://api.jamescompany.kr/api/v1//users ❌
이 이중 슬래시 때문에 몇 시간을 디버깅했는지 모릅니다. 브라우저 개발자 도구에서는 정상적으로 보이는데, 실제로는 서버에서 다르게 해석하더군요.
결국 이 문제를 해결하기 위해 URL을 조합하는 유틸 함수를 만들어야 했습니다. 슬래시를 정규화하고, 중복을 제거하고, 일관성 있게 관리하는... QA 엔지니어 실무에서는 "그냥 URL 확인해보세요"라고 했을 텐데, 이게 얼마나 복잡한 문제인지 이제야 알게 되었죠.
클라우드 시대에 환경 변수를 코드에 직접 넣는 건 보안상 매우 위험합니다. 그래서 Vercel Dashboard를 통해 환경 변수를 관리하기로 했죠. 깔끔하고 안전해 보였습니다. 하지만 여기에도 예상치 못한 함정들이 있었습니다.
첫 번째 함정은 환경 변수의 스코프였습니다. Vercel은 Production, Preview, Development 세 가지 환경을 구분합니다. 처음엔 이게 뭔 차이인지 몰라서 모든 환경 변수를 Production에만 설정했었죠. 그랬더니 개발 브랜치에서는 환경 변수를 읽을 수 없었습니다. 각 환경별로 따로 설정해야 한다는 걸 나중에야 알았죠.
두 번째 함정은 Preview 환경이었습니다. PR을 올릴 때마다 Vercel이 자동으로 생성하는 preview 배포는 어떤 환경 변수를 사용할까요? 저는 당연히 Development 환경 변수를 쓸 거라고 생각했는데, 실제로는 Preview 환경 변수를 사용하더군요. 이것 때문에 PR 리뷰 중에 "왜 이게 안 되지?"하며 한참을 헤맸습니다.
세 번째이자 가장 큰 함정은 환경 변수 변경의 반영 시점이었습니다. Vercel Dashboard에서 환경 변수를 수정하면 즉시 반영될 거라고 생각했습니다. 하지만 실제로는 재배포를 해야만 새로운 환경 변수가 적용되더군요.
어느 날 급하게 API 키를 변경해야 하는 상황이 생겼습니다. Dashboard에서 재빨리 수정하고 "휴, 해결됐다"고 안심했는데, 여전히 에러가 발생하는 거예요. 알고 보니 재배포를 하지 않아서 이전 값이 그대로 사용되고 있었던 겁니다. (코드 변경이 없어도, 환경 변수에 대해서도 배포가 되어야 함.)
이런 경험들을 통해 클라우드 서비스의 편리함 뒤에는 반드시 알아야 할 세부사항들이 있다는 걸 배웠습니다. 그리고 "왜 안 되지?"라고 당황하기 전에 문서를 꼼꼼히 읽어보는 습관의 중요성도 깨달았죠.
환경 변수 파일을 관리하면서 수많은 실수를 했고, 그 과정에서 나름의 노하우를 쌓았습니다.
가장 큰 실수는 .env 파일을 Git에 커밋한 것이었습니다. 다행히 개인 프로젝트였고 아직 중요한 정보가 없을 때여서 큰 문제는 없었지만, 만약 실제 운영 키가 들어있었다면? GitHub에 한 번 올라간 정보는 지워도 히스토리에 남는다는 걸 생각하면 아찔합니다. (히스토리도 삭제가 가능하지만, 기분은....)
그 이후로는 .gitignore에 가장 먼저 추가하는 게 .env* 패턴이 되었습니다. 하지만 이것도 완벽하지 않았죠. 팀원이나 미래의 저 자신은 어떤 환경 변수가 필요한지 어떻게 알까요?
그래서 .env.example 파일을 만들기 시작했습니다. 실제 값은 넣지 않고 어떤 변수가 필요한지, 어떤 형식이어야 하는지만 명시하는 거죠. 이 파일은 Git에 커밋해도 안전합니다.
하지만 이것만으로는 부족했습니다. 환경 변수의 값이 올바른지 검증하는 과정이 필요했죠. 특히 운영 환경에 테스트 키가 들어가는 것 같은 치명적인 실수를 방지하기 위해 검증 스크립트를 만들었습니다.
이런 과정을 거치며 깨달은 건, 환경 변수 관리가 단순한 설정 작업이 아니라 하나의 '예술'에 가깝다는 점이었습니다. 보안성, 편의성, 유지보수성을 모두 고려해야 하는 섬세한 작업이죠.
11년간 QA를 하면서 "환경 확인하세요"라고 얼마나 쉽게 말했는지 이제야 반성하게 됐습니다. 개발자가 되어보니, 그 한 마디 뒤에 얼마나 많은 복잡성이 숨어있는지 알게 되었죠.
첫째, 환경 문제는 절대 단순하지 않습니다.
URL 하나 바꾸는 게 아니라 수십 개의 설정이 유기적으로 연결되어 있고, 하나를 바꾸면 도미노처럼 다른 것들도 영향을 받습니다. 이제는 "환경 문제"라고 단순하게 치부하지 않고, 구체적으로 어떤 환경 설정이 문제인지 함께 고민하려고 합니다.
둘째, 환경별 차이는 필연적입니다.
로컬, 개발, 운영이 100% 같을 수는 없습니다. 중요한 건 그 차이를 명확히 인지하고 관리하는 것이죠. 앞으로는 환경별 차이를 문서화하고, 그 차이가 테스트 결과에 미칠 수 있는 영향을 미리 예측하려고 합니다.
셋째, 테스트 환경의 한계를 인정해야 합니다.
토스페이먼츠 테스트 키처럼, 아무리 비슷하게 만들어도 운영 환경을 100% 재현할 수는 없습니다. 이제는 "개발 환경에서 충분히 테스트했으니 운영에서도 문제없을 것"이라고 단언하지 않으려고 합니다.
넷째, 환경 설정도 테스트 대상입니다.
코드만 테스트하는 게 아니라 환경 변수 하나하나가 올바르게 설정되었는지도 검증해야 합니다. 특히 배포 전에는 더욱 철저히 확인해야 하죠.
"로컬에서는 되는데 운영에서는 안 돼요"
이제 이 말을 들으면 예전처럼 "환경 확인해보세요"라고 쉽게 말하지 못할 것 같습니다. 대신 이렇게 물어볼 것 같습니다.
"어떤 환경 변수들을 쓰고 있나요? 특히 환경별로 다른 값을 가지는 것들이 있나요?"
"API_BASE_URL에 버전 경로가 포함되어 있나요? 코드에서도 중복으로 추가하고 있지는 않나요?"
"외부 서비스 연동이 있다면, 각 환경별로 다른 키나 엔드포인트를 사용하나요?"
"CORS 설정은 어떻게 되어 있나요? 개발 환경의 동적 URL도 포함되어 있나요?"
"환경 변수에 trailing slash나 따옴표 같은 특수문자가 포함되어 있지는 않나요?"
"최근에 환경 변수를 수정했다면, 재배포는 하셨나요?"
개발자가 되어보니, QA가 더 잘 보입니다. 환경 문제를 제대로 이해하고 나니, 더 정확한 이슈 리포팅을 할 수 있을 것 같습니다. "환경 문제인 것 같아요"가 아니라 "개발 환경의 API_BASE_URL에 /api/v1이 중복으로 들어가 있는 것 같아요"라고 구체적으로 지적할 수 있을 것 같네요.
무엇보다 개발자들이 환경 관련 이슈로 고생할 때, 진심으로 공감할 수 있게 되었습니다. 그 슬래시 하나, 그 따옴표 하나가 얼마나 찾기 어려운지 이제는 압니다. 그리고 그걸 찾아내는 개발자들의 인내심에 진심으로 경의를 표하게 되었죠.
다음 편에서는 "npm, yarn, brew... 아무거나 쓰면 되는 줄 알았다"는 주제로 패키지 관리자의 혼돈에 대해 이야기해보겠습니다.
package-lock.json과 yarn.lock이 같이 있던 제 레포지토리를 보며 느꼈던 충격, 그리고 "왜 로컬에서는 되는데 CI/CD에서는 안 되지?"하며 5시간을 디버깅했던 경험을 공유하겠습니다. 스포일러를 하나 하자면, npm으로 설치한 패키지와 yarn으로 설치한 패키지가 미묘하게 다를 수 있다는 걸 아시나요?
이 시리즈는 11년차 QA 엔지니어가 풀스택 개발에 도전하며 겪은 실제 경험을 바탕으로 작성되었습니다. JamesCompany는 QA를 위한 플랫폼을 만드는 1인 기업입니다.