온라인 엔터테인먼트 시장이 첨단 정보통신 기술과 결합하여 비약적인 성장을 거듭함에 따라 유저들이 접할 수 있는 플랫폼의 종류와 선택지는 이전에 비해 훨씬 넓어졌습니다. 그러나 수많은 플랫폼이 우후죽순 생겨나는 양적 성장의 이면에는 자본력이 현저히 부족하거나 유저의 소중한 예치 자산을 부당하게 취득할 목적으로 개설된 부실 사이트들의 난립이라는 심각한 금전적 위험성이 공존하고 있습니다. 수많은 분석과 전략을 통해 아무리 막대한 당첨 수익을 거둔다 할지라도, 이를 실제 현금으로 안전하고 투명하게 정산받지 못한다면 유저가 투입한 시간과 노력, 자본은 한순간에 수포로 돌아가게 됩니다. 과장된 홍보나 눈앞의 화려한 보너스 혜택에 현혹되지 않고 자산을 안정적으로 보호하기 위해서는 제3의 전문 검증 기관이 실물 자금을 직접 동결하여 금전 피해를 담보하는 시스템의 구조적 이점과 작동 메커니즘을 정밀하게 진단하고 활용하는 지혜가 필수적입니다. 안심할 수 있는 이용 환경을 구축하기 위해 필수적으로 체크해야 할 핵심 지표들을 아래에서 상세히 분석해 드립니다. 목차 베팅 생태계의 재정적 불확실성과 실물 담보 체계의 필요성 사전 예치금 제도의 작동 구조와 피해 즉시 보상 메커니즘 플랫폼의 자본 건전성 및 대규모 당첨금 정산 유동성 진단 최첨단 서버 통신 암호화 및 네트워크 보안 인프라 검증 자의적 규정 적용을 차단하는 약관 독소 조항 사전 포착 집단지성 빅데이터 모니터링 기반 실시간 평판 추적 정품 오리지널 게이밍 솔루션 연동 및 결과값 공정성 확인 24시간 실시간 입출금 인프라 및 고객지원 응답성 평가 자주 묻는 질문 (FAQ) 정교하게 동결된 안전 보호막 위에서 완성되는 안심 플레이 1. 베팅 생태계의 재정적 불확실성과 실물 담보 체계의 필요성 초고속 모바일 네트워크와 스마트 단말기의 보급으로 언제 어디서나 게이밍 플랫폼에 접속할 수 있는 편의성이 확보되었지만, 기술적 및 재정적 검증을 제대로 거치지 않은 부실 업체들 또한 대거 난립하는 결과를 낳았습니다. 대다수의 자금 피해 사례는 플랫폼의 재정 구조나 과거 정산 이력을 다각도로 검증하지 않은 채, 시장 평균치를 지나치게 상회하는 보너스 비율이나 과장된 이벤트 문구에 현혹되어 가입하면서 발생합니다. 아무리 우수한 전략으로 높은 당첨금을 달성한다 할지라도 이를 신속하고 투명하게 정산받지 못한다면 모든 노력은 무용지물이 됩니다. 따라서 플랫폼 선택 시 단순한 혜택보다는 정산 안정성을 물리적으로 담보해 줄 수 있는 실물 보증 체계의 존재 여부를 최우선 진단 지표로 설정해야 합니다. 2. 사전 예치금 제도의 작동 구조와 피해 즉시 보상 메커니즘 온라인 게이밍 환경에서 유저의 자산을 가장 확실하게 보호하는 핵심 안전장치는 바로 제3의 검증 기관에 사전 동결하는 예치금 시스템입니다. 이 시스템은 대상 플랫폼이 검증 기관에 일정 규모 이상의 실물 보상용 자금을 사전에 입금하고, 해당 금액을 동결 상태로 유지하도록 조치하는 방식으로 구동됩니다. 이러한 체계가 갖춰지면 만에 하나 플랫폼 측에서 예기치 못한 정산 지연이나 불합리한 사유로 환전을 거부하는 분쟁이 발생하더라도 제3의 기관이 즉각 개입합니다. 정밀 조사를 통해 이용자의 정당한 게임 및 베팅 내역이 확인되는 순간 동결되어 있던 예치 자금에서 피해액을 즉시 100% 보상하므로, 유저는 어떠한 상황에서도 원금과 당첨 손실 위험으로부터 완벽히 보호받게 됩니다. 3. 플랫폼의 자본 건전성 및 대규모 당첨금 정산 유동성 진단 사이트가 환전 지연 없이 안정적인 서비스를 지속적으로 제공할 수 있는가를 결정짓는 가장 근본적인 기준은 막강한 자본력과 끊김 없는 자금 유동성 확보 여부입니다. 스포츠 경기나 라이브 게임 결과는 특정 시점에 대형 당첨자가 일시에 몰릴 수 있어 자체 자본의 깊이가 얕은 곳은 불어난 정산 요청을 감당하지 못하고 서비스 중단이나 횡령을 선택하게 됩니다. 넉넉한 재정적 유동성을 확보한 우수 플랫폼은 대규모 정산 요청이 동시에 들어오더라도 지체 없이 신속하게 환전 절차를 완료하는 정산 인프라를 가동합니다. 반면 자본 구조가 취약한 중소형 업체는 말도 안 되는 시스템 오류나 추가 인증을 핑계로 정산을 미루다가 회원 자격을 일방적으로 박탈하곤 합니다. 이에 따라 사전에 예치금을 동결해 둔 검증된 안전놀이터 파이프라인을 선별하여 활용하는 것이 최우선 과제입니다. 4. 최첨단 서버 통신 암호화 및 네트워크 보안 인프라 검증 기술적인 관점에서 대상 플랫폼의 신뢰성과 안정성을 측정하는 핵심 기준은 서버 인프라의 보안 수준과 도메인 유지 이력입니다. 단기간에 금전적 이익만을 취하고 서비스를 폐쇄할 목적으로 만들어진 미검증 업체들은 대체로 생성된 지 불과 몇 달 되지 않은 신규 도메인을 사용하며 기술적 보안 설정이 매우 취약하다는 공통점이 있습니다. 반면 수년간 아무런 불미스러운 사고 없이 안정적인 서비스를 유지해 온 메이저 사이트들은 최신 SSL(Secure Sockets Layer) 암호화 통신 프로토콜을 가동하여 사용자의 개인정보와 금융 거래 데이터를 철저히 보호합니다. 이에 더해 해외 전문 IDC 센터를 경유하는 디도스(DDoS) 방어 전용 장비와 분산 서버 시스템을 구비하고 있으므로, 도메인의 등록 시점과 서버 통신 보안 상태를 엄격하게 점검하는 작업이 우선되어야 합니다. 5. 자의적 규정 적용을 차단하는 약관 독소 조항 사전 포착 실제 자금 피해 제보 내역을 면밀히 분석해 보면 악의적인 불량 사이트들은 이용자에게 불리하게 작동하도록 애매하게 작성된 독소적 약관 조항을 자주 악용합니다. 가입 초기에는 세부 제한 조항을 명확히 공지하지 않다가, 유저가 게임에서 승리하여 환전을 요청할 때 비로소 불명확한 제재 문구를 적용하여 정산을 거부하는 패턴입니다. 롤링 요구 비율의 타당성: 보너스 지급 시 요구되는 롤링 비율이 실제 달성 가능한 합리적인 범위 내에 있는지 사전 점검합니다. 제재 조항의 구체성: 특정 분석 기법이나 이용 패턴에 대해 운영진의 자의적 해석에 남용되지 않는지 검토합니다. 약관의 소급 적용 금지: 베팅이 완료된 이후 사후에 변경된 규정을 적용하여 이용자에게 불이익을 주지 않는지 확인합니다. 고객 지원 기록 보존: 명확하지 않은 조항이 존재할 경우 가입 전 1:1 상담을 통해 명확한 지침을 확답받고 해당 대화 내역을 저장해 둡니다. 모든 운영 지침과 규정이 객관적이고 투명하게 공지된 플랫폼만을 선택하는 것이 불합리한 당첨금 몰수 피해를 막는 확실한 방어선이 됩니다. 6. 집단지성 빅데이터 모니터링 기반 실시간 평판 추적 개인 이용자 혼자의 힘으로 특정 플랫폼의 전체 서버 이력, 과거 도메인 변경 내역, 비공개 분쟁 사건을 모두 추적하는 데에는 명확한 한계가 존재합니다. 따라서 신뢰성 높은 정보 자료를 신속히 확보하기 위해서는 수많은 실제 이용자들의 경험담과 사고 제보가 실시간으로 수집되는 전문 보증업체 빅데이터 모니터링 시스템을 활용하는 집단지성의 방식이 매우 효율적입니다. 이러한 정보 공유 네트워크 공간에서는 실제 사용자가 경험한 입출금 정산 속도, 시스템 장애 발생 시 운영진의 대처 방식, 예고 없는 약관 변경 사례 등 플랫폼 생태계의 실제 모습이 반영됩니다. 이용을 고려 중인 사이트의 상호나 도메인 주소를 통합 검색하여 과거 불미스러운 피해 분쟁 사례가 한 건이라도 기록되어 있는지 사전에 체크하는 습관이 중요합니다. 7. 정품 오리지널 게이밍 솔루션 연동 및 결과값 공정성 확인 카지노 및 슬롯 계열의 게임 서비스를 제공하는 플랫폼을 평가할 때는 영상 수급 과정의 투명성과 무작위 숫자 생성기(RNG) 시스템의 공정성이 담보되어 있는지 점검해야 합니다. 검증된 우수 플랫폼들은 에볼루션, 프라그마틱 등 세계적으로 인정받는 글로벌 오리지널 게이밍 솔루션사와 정식 계약을 체결하여 정품 게임 소프트웨어를 수급합니다. 반면 신뢰성이 부족한 불량 업체는 승률 조작이 가능한 녹화 영상을 실시간 화면처럼 속여 송출하거나, 결과값을 임의로 조정할 수 있는 카피 프로그램을 무단 탑재하여 유저에게 손실을 유발합니다. 정식 정품 검증 절차를 거치지 않은 엔터테인먼트 소프트웨어는 승률의 공정성을 신뢰할 수 없으므로, 정식 라이선스를 보유한 글로벌 제조사의 정품 게임 영상과 결과값이 정상 출력되고 있는지 정밀 분석해야 합니다. 8. 24시간 실시간 입출금 인프라 및 고객지원 응답성 평가 플랫폼 운영진의 정직함과 서비스의 질적 수준을 가장 직관적으로 증명하는 지표는 입출금 정산 처리의 속도와 24시간 실시간 고객 지원 체계입니다. 정직하고 투명하게 운영되는 곳은 유저의 정당한 환전 신청에 대해 불필요한 증빙 서류를 반복 요구하거나 정기 점검을 구실로 무기한 처리를 지연시키지 않습니다. 본격적인 대규모 플레이를 진행하기 전 소액 자금을 활용해 입출금을 미리 진행해 보면서 정산 시스템의 반응 속도를 직접 체크해 보는 단계적 조치가 안전합니다. 정상 진단을 거친 사이트 이용 시 환전 신청 후 안내된 정해진 시간 이내에 차질 없이 정산이 완료되는지, 실시간 고객 상담 채널을 통해 친절하고 전문적인 가이드가 제공되는지 모니터링해야 합니다. 자주 묻는 질문 (FAQ) Q1. 보증금 예치 시스템이 적용된 플랫폼을 이용하면 환전 사고 시 100% 보상받을 수 있나요? 네, 그렇습니다. 제3의 전문 검증 기관에 실물 예치금이 정상적으로 동결된 업체를 이용할 경우, 플랫폼의 갑작스러운 운영 중단이나 부당한 환전 거부가 발생하더라도 사전에 예치된 보증금 자산에서 피해 금액 전액을 즉시 보상받을 수 있습니다. Q2. 보증 시스템을 갖춘 업체를 이용할 때 이용자가 주의해야 할 점이 있나요? 반드시 지정된 공식 보증 안내 링크 및 코드 파이프라인을 통해 가입을 완료해야 합니다. 공식 경로가 아닌 임의의 마케팅 링크나 서드파티 경로로 가입할 경우, 검증 기관의 예치금 보상 대상 회원 목록에서 누락되어 만일의 사고 발생 시 보상 혜택을 받지 못할 수 있습니다. Q3. 안전한 베팅 공간을 선별할 때 최우선으로 검토해야 할 단 하나의 기준은 무엇인가요? 가장 핵심적인 단 하나의 기준은 바로 '객관적으로 증명된 자본력과 예치 보증금의 실제 존재 유무'입니다. 자본력이 건실하여 신속한 정산을 보장하고, 만일의 사태에 대비해 제3의 기관에 피해 보상용 실물 보증금을 동결해 둔 곳이라면 정산과 관련된 대부분의 위험 요소로부터 유저의 자산을 완벽히 지켜낼 수 있습니다. 정교하게 동결된 안전 보호막 위에서 완성되는 안심 플레이 온라인 베팅 플랫폼을 안심하고 즐기기 위한 본질은 자극적인 혜택이나 화려한 광고 문구에 휘둘리지 않고, 객관적인 검증 지표와 투명한 정산 프로세스를 바탕으로 플랫폼을 냉철하게 판별하는 것입니다. 서버 보안 인프라, 자본 건전성, 도메인 운영 이력, 그리고 실제 이용자들의 객관적인 평판을 종합적으로 검토하여 위험 요소를 선제적으로 차단해야 합니다. 이러한 체계적인 검증 과정을 통과한 안심 공간을 선별하고, 사전 예치 보증금이 확보된 안전한 보호막 위에서 스스로의 이성적인 자금 관리 수칙을 준수할 때 비로소 자산 손실에 대한 불안감 없이 온전히 게임 본연의 즐거움을 만끽할 수 있습니다. 세심한 사전 조사와 이성적인 이용 습관을 바탕으로 언제나 쾌적하고 안심할 수 있는 최고의 엔터테인먼트 환경을 완성해 가시길 바랍니다.
Finding comfortable denim that flatters your unique body shape often requires trying on dozens of rigid pairs before making a final decision. Many shoppers eventually crave a reliable silhouette that provides absolute comfort while maintaining a stylish vintage aesthetic. Beautiful pilcro jeans offer the exact balance of premium stretch and classic visual appeal. The style editors at Editorialist frequently recommend this specific brand for anyone wanting to look effortlessly polished during busy weekend outings. You will quickly understand why these specific pants remain a permanent staple in stylish closets around the world. Pilcro jeans fabric technology and unique design details Creating a beautiful everyday pant requires incredible skill and very soft luxury materials sourced from the best fabric mills. Authentic pieces like pilcro barrel jeans usually feature premium cotton blends that feel amazing against your bare skin from the very first wear. Crafters spend hours making sure the metallic hardware and distinctive pocket stitching sit perfectly without causing any irritation. Every small detail reflects a deep commitment to lasting quality and charming visual appeal. Your legs will definitely appreciate the thoughtful construction and beautifully finished hems after a long day of walking around town. The interior construction plays a massive role in how these garments feel during busy daily routines. Innovative stretch technology offers subtle flexibility that makes sitting for extended periods much more bearable than wearing traditional stiff denim. Investing in high quality pilcro jeans means your favorite clothing staple will survive daily wear and constant washing perfectly. Your daily morning routines will become much more enjoyable when you completely trust your clothing choices. Styling your versatile denim for casual and elevated settings Integrating this comfortable aesthetic into your regular rotation takes very little effort or planning on busy mornings. You can easily pair your denim with a flowing floral blouse for a romantic weekend brunch date. Adding oversized sunglasses and a simple leather tote creates a highly polished aesthetic perfect for a relaxed city environment. If you want to transition your outfit for a casual evening dinner a sleek silk camisole works beautifully. This extreme versatility proves that comfortable luxury clothing deserves a permanent spot in your travel luggage. Many people falsely believe that relaxed vintage inspired denim only works for highly casual daytime events. Modern fashion rules highly encourage wearing these rich textured fabrics throughout the busy evening hours as well. Pairing your pilcro jeans with a sharp tailored blazer and pointy toe heels creates a stunning nighttime outfit. The bright indigo washes pop beautifully against dark fabrics and heavy seasonal knits making your outfits incredibly unique. Finding the proper fit and caring for your premium garments Getting the exact right fit is very important when you buy fitted clothing for heavy daily use. A proper fit ensures the waistband stays comfortably secure preventing any awkward adjusting while walking. Most shoppers find that trying on different silhouettes helps them discover exactly what flatters their personal body type best. The premium stretchy fabrics will slowly mold to your unique proportions over time creating a highly custom fit. Taking time to find the correct size ensures you can wear them comfortably from morning until late at night. Keeping your denim looking completely brand new requires a very simple daily care routine. You should always wash your garments inside out in cold water to prevent accidental shrinking or color fading. Keeping them away from harsh standard fabric softeners ensures the material retains its essential stretch properties over time. Hanging your pieces to dry naturally prevents unwanted pilling and extends the lifespan of the woven material significantly. Adding this reliable staple to your collection brings immediate sophistication to all your future casual adventures. This versatile brand gives you a wonderful opportunity to look highly polished while keeping your body entirely comfortable. Leaving behind uncomfortable stiff pants means you confidently embrace physical freedom without losing any personal style. Embracing beautiful pilcro jeans means choosing a lifestyle where you never have to sacrifice personal aesthetics for basic comfort. Your daily outfits will become much more exciting with these timeless pants waiting in your closet. View more: https://editorialist.com/shop/pilcro/women/jeans/
한국과 일본은 지리적으로도 가깝고, 한류 콘텐츠를 오래 나눠 온 이웃이다. 그런데 최근 일본인들의 한국 방문이 단순한 '가까워서'를 넘어선, 뚜렷한 흐름으로 자리 잡고 있다. 일본의 공공 데이터와 한국 관광 당국의 조사를 바탕으로, "일본인이 한국을 찾는 이유"를 5가지 숫자로 정리해 봤다. 1. 일본인 해외여행 4명 중 1명 이상이 한국을 찾는다 문화체육관광부와 한국관광공사에 따르면, 올해 1~7월 해외로 출국한 일본인 813만 6천 명 가운데 한국을 방문한 사람은 228만 명으로 28.0%를 차지했다. 이는 일본인 해외여행객이 가장 많이 찾는 국가로, 지난해 같은 기간 24.6%보다 3.5%포인트 높아진 역대 최고치다. 미국(13.1%), 대만(9.4%), 태국(6.8%)을 모두 크게 앞선 수치다. 2. 방문 결정의 1순위 이유는 '음식'이다 한국관광공사의 2024 잠재방한여행객 조사에서, 일본인 관광객이 한국 방문을 결정한 가장 큰 요인은 '현지의 맛있는 한국 음식'으로 45%를 차지했다. 같은 응답을 한 외래객 평균(32.8%)보다 월등히 높은 비율로, 일본인의 '미식여행' 성향이 숫자로 확인된다. 3. 양국 간 여행객이 1,100만 명을 넘어섰다 2024년 한국과 일본 사이를 오간 여행객은 1,100만 명을 넘어섰다. 이는 양국이 외교 관계를 수립한 1965년 당시 약 2만 2천 명과 비교하면 500배에 가까운 성장이다. 항공 노선 확대와 문화 교류가 쌓이면서, 두 나라가 서로에게 '가장 자연스러운 여행지'가 된 셈이다. 4. 방한 일본인 증가세가 가파르다 올해 4월까지 한국을 찾은 일본인 관광객은 104만여 명으로, 지난해 같은 기간보다 16% 이상 늘었다. 문체부는 한일 항공 공급 확대, K컬처 인기, 환율 등을 성장 요인으로 분석한다. 방한 외국인 전체도 올해 1~7월 1,280만 명으로 전년 동기 대비 21.3% 증가하며 함께 커지고 있다. 5. '음식 여행'을 지역으로 넓히고 있다 한국관광공사는 일본인의 방한 선호 1순위인 음식을 활용해 지역 여행으로 연결하는 캠페인을 진행 중이다. 수원 왕갈비, 대구 막창, 춘천 닭갈비, 전주 막걸리, 광주 떡갈비 등 지역 대표 음식을 일본인 관광객이 더 쉽게 즐길 수 있도록 접근성을 높였다. 수도권에 집중된 수요를 지역으로 나누고, 지역 경제를 살리려는 시도다. 정리하면, 일본인의 한국 방문은 이제 일시적인 붐이 아니라 구조적인 흐름이다. 해외여행 4명 중 1명 이상이 한국을 선택하고, 그 선택의 중심에 음식이 있다. 숫자가 보여주는 방향은 분명하다. 두 나라가 서로에게 점점 더 가까워지고 있다는 것. (이 글은 문화체육관광부·한국관광공사 보도자료(연합뉴스 2026.08 인용), 한국관광공사 2024 잠재방한여행객 조사·2025 지역특화음식 캠페인(이코리아), Travel And Tour World의 공개 데이터를 요약·번역한 것입니다.)
이번 한 주간 "안아수달" 프로젝트 백엔드(anasudal_BE)와 프론트엔드(anasudal_FE)는 서비스의 핵심 기능 개발과 인프라 안정화에 집중했다. 특히 백엔드는 초기 인프라 비용을 대폭 절감하고, 배포 과정을 간소화하며, Gemini API의 안정성을 높이는 데 주력했다. 안아수달 서비스 소개 안아수달은 아이 발달이 걱정될 때, 검증된 공공 자료로만 확인해볼 영역을 찾아주고 가까운 발달재활 기관을 추천하는 서비스다. 부모가 아이의 발달 상황을 입력하면, 시스템은 1,473개의 지식 조각에서 월령에 맞는 근거를 찾아 Gemini가 답변을 생성하고, 그 근거 안에서 확인해볼 치료 영역을 뽑아 전국 2,866곳 중 해당 영역을 다루는 기관 3곳을 추천한다. 진단 대신 '확인해볼 영역'을 안내하며, 로그인 없이 대화 원문을 저장하지 않아 개인 정보를 보호하는 것을 원칙으로 한다. 인프라 비용 절감과 배포 안정성 확보 이번 주 작업의 가장 큰 줄기는 인프라 비용 절감과 배포 과정의 안정화였다. 초기 AWS 인프라는 월 94달러의 비용이 발생했는데, 이를 해커톤 검증용 최소 비용 수준인 24달러로 낮추면서도 핵심 요구사항(ECR, ECS, EC2, SSM)을 유지하는 것이 목표였다. 문제: 높은 인프라 비용과 복잡한 배포 과정 기존 인프라는 ECS Fargate, RDS PostgreSQL, ElastiCache Redis를 각각 독립적으로 운영하여 월 94달러의 비용이 들었다. 또한, 배포를 위한 IAM 사용자 생성 과정이 복잡했고, 수동으로 키를 입력해야 하는 불편함이 있었다. Windows PowerShell 환경에서는 스크립트 호환성 문제도 발생했다. 기존 계정에 다른 프로젝트( syak )가 있어 리소스 간의 의도치 않은 간섭 가능성도 배제할 수 없었다. HTTPS 미적용으로 프론트엔드와의 통신에 제약이 있는 점도 개선이 필요했다. 판단: 단일 EC2 인스턴스 기반의 통합 환경 구축 비용 절감과 배포 편의성을 동시에 잡기 위해, 단일 EC2 인스턴스 위에 모든 백엔드 구성 요소를 통합하는 방향으로 전환했다. ECS 시작 유형 변경: Fargate를 EC2 시작 유형으로 변경하여 게이트웨이 인스턴스가 컨테이너 인스턴스 역할을 겸하게 했다. 데이터베이스 및 캐시 통합: RDS와 ElastiCache를 제거하고, pgvector/pgvector:pg16 및 redis:7-alpine 컨테이너를 EC2 인스턴스 위에서 실행했다. 데이터는 EBS의 /var/lib/anasudal 에 저장하여 영속성을 확보했다. NAT Gateway 제거: EC2 인스턴스가 퍼블릭 서브넷에서 EIP를 통해 직접 외부와 통신하므로 월 43달러에 달하는 NAT Gateway를 제거했다. 보안 그룹 간소화: 프라이빗 서브넷과 API/DB 보안 그룹을 제거하고, 외부에 열린 포트는 80/443만 유지했다. AMI 교체 및 리소스 증설: ARM 기반 t4g.micro 인스턴스를 t3.small (2GB) x86 ECS 최적화 AMI로 교체하여 Postgres, Redis, API를 안정적으로 운영할 수 있도록 했다. 루트 볼륨도 8GB에서 30GB로 확장했다. HTTPS 적용: CloudFront를 앞에 두어 도메인 없이 *.cloudfront.net HTTPS를 얻도록 구성했다. 캐시는 끄고 모든 헤더와 쿼리를 오리진(EC2)에 넘겼다. 배포 과정의 편의성과 보안을 위해 IAM 정책을 강화하고 부트스트랩 스크립트를 개선했다. 프로젝트 전용 IAM 사용자: anasudal-deploy IAM 사용자를 생성하고, anasudal-* 이름 규칙과 Project=anasudal 태그를 따르는 리소스만 접근하도록 권한을 분리했다. 기존 syak 프로젝트의 리소스는 ARN을 명시하여 하드 차단하고, 태그 없는 타 리소스에 Project 태그를 붙여 보호를 해제하는 행위도 Deny 정책으로 막았다. 간소화된 부트스트랩 스크립트: login.sh (Linux/macOS) 및 login.ps1 (Windows PowerShell) 스크립트를 통해 루트 액세스 키 두 줄만 입력하면 IAM 사용자 생성부터 키 등록, 권한 점검까지 자동으로 처리되도록 개선했다. 특히 Windows 환경에서는 .csv 파일이나 메모장 경로를 입력받아 키를 자동으로 파싱하고, 터미널 붙여넣기가 어려운 상황에 대비했다. 배포 문제 해결: systemd 교착 상태 해결을 위해 systemctl restart ecs 호출을 --no-block 으로 변경했고, ec2:DisassociateAddress 동작이 태그 없는 ENI에 적용되어 배포를 막던 IAM Deny 정책도 수정했다. 변경 전/후: 간소화된 인프라와 자동화된 키 입력 1. (BE) 인프라 분리 및 보호 IAM 정책 ( infra/iam/anasudal-deploy-infra.json ) 기존 프로젝트 리소스에 대한 접근을 명시적으로 거부하고, anasudal 프로젝트 리소스만 관리할 수 있도록 IAM 정책을 강화했다. { "Version": "2012-10-17", "Statement": [ { "Sid": "NetworkAndDataPlane", "Effect": "Allow", "Action": [ "ec2:*", "rds:*", "elasticache:*" ], "Resource": "*" }, { "Sid": "DenyTouchingOtherProjects", "Effect": "Deny", "Action": [ "ec2:TerminateInstances", "ec2:StopInstances", "ec2:RebootInstances", "ec2:DeleteVpc", "ec2:DeleteSubnet", "ec2:DeleteSecurityGroup", // ... (중략) ... "rds:DeleteDBInstance", "rds:StopDBInstance", "rds:RebootDBInstance", "rds:ModifyDBInstance", "rds:DeleteDBSubnetGroup", "rds:DeleteDBParameterGroup", "elasticache:DeleteCacheCluster", "elasticache:ModifyCacheCluster", "elasticache:DeleteCacheSubnetGroup" ], "Resource": "*", "Condition": { "StringNotEqualsIfExists": { "aws:ResourceTag/Project": "anasudal" } } }, { "Sid": "DenyAccountWideDamage", "Effect": "Deny", "Action": [ "ec2:DeleteDefaultVpc", // ... (중략) ... "iam:CreateUser", "iam:DeleteUser", "iam:CreateAccessKey", "iam:DeleteAccessKey", "iam:CreatePolicy", "iam:DeletePolicy", "iam:AttachUserPolicy", "iam:DetachUserPolicy" ], "Resource": "*" } ] } DenyTouchingOtherProjects 정책은 Project 태그가 anasudal 이 아닌 리소스에 대해 특정 파괴적인 작업을 명시적으로 거부한다. 또한, DenyAccountWideDamage 정책은 IAM 사용자/정책 조작과 같은 권한 상승 가능성이 있는 작업을 금지하여 보안을 강화했다. 2. (BE) Windows PowerShell을 위한 자동화된 키 입력 ( infra/scripts/login.ps1 ) AWS CLI 키를 수동으로 붙여넣는 대신, 파일이나 메모장에서 자동으로 키를 찾아 입력하는 기능을 추가하여 사용자 경험을 개선했다. # ... (중략) ... # -- 키 읽기 도우미 ----------------------------------------------------------- # 형식을 가리지 않는다. csv / 메모장 메모 / 아무 텍스트에서나 정규식으로 찾아낸다. # 액세스 키 ID : AKIA/ASIA + 영숫자 16자 # 시크릿 : base64 문자 40자 function Get-AwsKeyFromText ($text) { if (-not $text) { return $null } $id = $null; $sec = $null # (1) key = value 표기 (메모장 템플릿, 환경변수, ~/.aws/credentials 형식) $m = [regex]::Match($text, '(?im)^\s*(?:aws[_ ]?)?access[_ ]?key[_ ]?id\s*[=:]\s*["'']?([A-Z0-9]{16,128})') if ($m.Success) { $id = $m.Groups[1].Value } $m = [regex]::Match($text, '(?im)^\s*(?:aws[_ ]?)?secret[_ ]?access[_ ]?key\s*[=:]\s*["'']?([A-Za-z0-9+/=]{30,128})') if ($m.Success) { $sec = $m.Groups[1].Value } # (2) 콘솔에서 받은 .csv — 헤더 이름으로 열을 찾는다. # IAM 사용자 csv 에는 비밀번호 열이 섞여 있어서, 위치로 찍지 않고 이름으로 찾아야 한다. if (-not ($id -and $sec)) { try { foreach ($r in @($text | ConvertFrom-Csv)) { foreach ($p in $r.PSObject.Properties) { $n = ($p.Name -replace '[^A-Za-z]', '').ToLower() if ($n -match 'accesskeyid') { $id = $p.Value } if ($n -match 'secretaccesskey') { $sec = $p.Value } } } } catch { } # ConvertFrom-Csv 가 실패해도 무시 } # (3) 생김새 기반 폴백 — 헤더 없이 키만 있는 경우 if (-not $id) { $m = [regex]::Match($text, '(?m)\b(AKIA|ASIA)[A-Z0-9]{16}\b'); if ($m.Success) { $id = $m.Value } } if (-not $sec) { $m = [regex]::Match($text, '(?m)\b[A-Za-z0-9+/=]{40}\b'); if ($m.Success) { $sec = $m.Value } } if ($id -and $sec) { [PSCustomObject]@{ AccessKeyId = $id; SecretAccessKey = $sec } } else { $null } } # ... (중략) ... Get-AwsKeyFromText 함수는 CSV 파일, 메모장 내용, 또는 일반 텍스트에서 AWS 액세스 키 ID와 시크릿 액세스 키를 자동으로 추출한다. 이를 통해 사용자는 키를 직접 복사하여 붙여넣는 번거로움 없이 쉽게 초기 설정을 완료할 수 있게 되었다. 3. (BE) Gemini 키 풀 분리 및 폴백 설정 ( backend/app/core/config.py ) Gemini API 키를 용도별로 분리하고, 키 풀을 활용하여 429 에러 발생 시 단계적으로 대응하도록 변경했다. # backend/app/core/config.py # ... (중략) ... class Settings(BaseSettings): model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8", extra="ignore") database_url: str # ── Gemini 키 # 답변·임베딩(사용자가 기다리는 경로)은 여러 개를 돌려 쓴다. 콤마로 구분. gemini_api_keys: str = "" # 질문 요약(백그라운드로 DB 에 쌓는 것) 전용. 답변 쿼터를 갉아먹지 않게 분리. gemini_summary_key: str = "" # 하위호환 — 키가 하나뿐이던 시절의 이름. 위 두 개가 비면 이걸 쓴다. gemini_api_key: str = "" # ... (중략) ... @property def answer_keys(self) -> list[str]: """답변·임베딩용 키 목록""" return _split(self.gemini_api_keys) or _split(self.gemini_api_key) @property def summary_keys(self) -> list[str]: """요약 전용 키. 따로 안 주면 답변 키를 같이 쓴다(개발 환경).""" return _split(self.gemini_summary_key) or self.answer_keys # ... (중략) ... gemini_api_keys 는 사용자에게 즉시 응답해야 하는 답변 및 임베딩에 사용되며 여러 키를 로테이션한다. gemini_summary_key 는 백그라운드에서 실행되는 질문 요약 전용으로, 사용자 대기 경로의 쿼터에 영향을 주지 않도록 분리했다. 결과: 비용 절감과 안정적인 배포 환경 이러한 변경으로 인프라 월 비용은 94달러에서 24달러로 대폭 절감되었다. 배포 과정은 단일 스크립트 실행으로 간소화되었고, Windows PowerShell 환경에서도 원활하게 작동하게 되었다. 프로젝트 전용 IAM 정책으로 기존 프로젝트와의 간섭 가능성을 완전히 차단하고, CloudFront를 통해 HTTPS를 지원하게 되어 서비스의 안정성과 보안이 강화되었다. Gemini API 키 관리 및 폴백 강화 문제: 단일 Gemini 키의 한계와 429 에러 대응 부재 이전에는 하나의 Gemini API 키로 모든 LLM 작업을 처리했다. 이는 사용자가 기다리는 답변 생성, 임베딩, 그리고 백그라운드에서 이루어지는 질문 요약까지 모두 같은 쿼터를 소모하게 만들어, 429(Rate Limit Exceeded) 에러 발생 시 서비스 전체에 영향을 줄 수 있었다. 429 에러에 대한 적절한 폴백(fallback) 전략이 없어 사용자 경험을 해칠 우려도 있었다. 판단: 키 풀 분리와 단계적 폴백 구현 사용자 대기 경로의 안정성을 최우선으로 고려하여, Gemini API 키를 두 묶음으로 분리하고 429 에러 발생 시 단계적으로 대응하는 전략을 수립했다. 키 풀 분리: GEMINI_API_KEYS : 사용자에게 직접 응답하는 답변 생성 및 임베딩에 사용되는 키 묶음. 여러 키를 등록하여 로테이션하며 사용한다. GEMINI_SUMMARY_KEY : 백그라운드에서 질문을 요약하여 DB에 저장하는 용도 전용 키. 답변 쿼터와 분리하여 운영 안정성을 높였다. 429 단계적 폴백: 1단계 (키 로테이션): 429 에러 발생 시 해당 키를 잠시 쉬게 하고 즉시 다음 키로 재시도한다. 쉬는 상태는 Redis에 공유하여 여러 ECS 태스크가 같은 죽은 키를 반복해서 호출하는 것을 방지한다. 2단계 (대체 답변): 모든 키가 소진되었지만 근거 데이터는 확보된 경우, LLM 없이 근거와 출처를 그대로 보여주는 대체 답변을 제공한다. 치료 영역은 지식 청크의 K-DST 영역에서 추출하여 기관 추천까지 이어지도록 했다. 3단계 (오류 응답): 검색(임베딩)조차 불가능한 경우, 503 LLM_RATE_LIMITED 에러와 함께 재시도 가능 시간을 안내한다. 4단계 (요약 키 소진): 질문 요약 키가 소진된 경우 조용히 넘어가 question_summary 만 비워둔다. (사용자 대기 경로에 영향 없음) 키 풀 상태 모니터링: GET /health 엔드포인트에 키 풀 상태를 노출하여 운영자가 쉽게 확인할 수 있도록 했다 (키는 해시 앞 8자만 표시). 결과: 안정적인 LLM 서비스 운영 Gemini API 키 풀 분리와 단계적 폴백 구현을 통해 429 에러 발생 시 사용자 경험에 미치는 영향을 최소화하고, LLM 서비스의 안정성을 크게 높였다. 백그라운드 작업이 사용자 대기 경로의 쿼터를 소모하지 않게 되어 전체적인 서비스 지연 위험도 줄었다. 데이터 보강으로 기관 정보 정확도 향상 문제: 낮은 좌표 매칭률과 운영시간·홈페이지 정보 부재 공공데이터 기반의 전국 기관 2,866곳 중 좌표 매칭률이 65.6%에 불과했고, 카카오맵과 공공데이터 간 기관명 불일치로 인해 이름 매칭이 실패하는 경우가 많았다. 특히 운영시간과 기관 자체 홈페이지 정보는 거의 전무한 상태였다. 판단: 카카오 API를 활용한 데이터 보강 기관 정보의 정확도와 풍부함을 높이기 위해 카카오 API를 적극적으로 활용하여 데이터를 보강했다. 카카오 주소검색을 통한 좌표 보강: 기존의 기관명 키워드 검색 방식에서 벗어나, 주소 검색을 우선적으로 사용했다. 도로명 주소 정리 및 시군구 보정 로직을 개선하여 이름이 다르더라도 주소가 맞으면 매칭되도록 했다. 이로써 좌표 매칭률을 65.6%에서 95.0%로 크게 끌어올렸다. 카카오 플레이스 API를 통한 운영시간·홈페이지 수집: 카카오 공식 REST API에는 없는 운영시간과 홈페이지 정보를 카카오맵 웹이 내부적으로 사용하는 비공개 place-api.map.kakao.com 엔드포인트를 통해 수집했다. 비공개 API 사용에 따른 위험을 인지하고, 기본 1.5건/초의 속도 제한과 8건 연속 실패 시 자동 중단 기능을 구현하여 안정성을 확보했다. 수집된 운영시간은 '월 금 10:00 19:30, 토 09:30~18:30'과 같이 보기 좋게 정리하고, 휴무일도 표시하도록 파싱 로직을 개선했다. 이름 매칭 완화: '파랑새감각통합언어발달'과 '파랑새감각통합언어발달센터'처럼 접미사만 다른 경우를 놓치지 않도록 정규화 후 포함 관계를 인정하되, 오탐 방지를 위해 6자 이상일 때만 적용했다. 결과: 풍부하고 정확해진 기관 정보 데이터 보강 작업을 통해 기관 좌표 매칭률은 95%에 달했고, 전화번호 매칭률도 90.4%로 개선되었다. 운영시간은 0%에서 30.7%로, 카카오맵 링크는 71.5%로 대폭 증가하여 사용자에게 더욱 풍부하고 정확한 정보를 제공할 수 있게 되었다. 마무리 이번 한 주간 "안아수달" 프로젝트 백엔드는 인프라 비용 절감, 배포 과정 자동화, Gemini API 안정성 강화, 그리고 기관 정보 데이터 보강이라는 네 가지 주요 목표를 성공적으로 달성했다. 이를 통해 서비스의 운영 효율성과 안정성을 높이고, 사용자에게 더 나은 경험을 제공할 수 있는 견고한 기반을 마련했다. 앞으로도 지속적인 개선을 통해 더 나은 서비스를 만들어 나갈 것이다.
TL;DR 테스트가 ../../fixtures/... 처럼 프로젝트 폴더 밖의 파일 을 읽고 있었다. 상대경로는 코드 파일의 위치가 아니라 프로세스의 현재 작업 디렉터리(cwd) 를 기준으로 해석된다. 로컬과 Docker 컨테이너의 cwd와 디렉터리 구조가 달라 같은 코드가 서로 다른 경로를 가리켰다. Docker 이미지에는 호스트의 파일이 자동으로 들어가지 않는다. 필요한 파일은 COPY 등으로 명시적으로 포함해야 한다. 테스트가 실제로 찾고 있던 /fixtures 를 빌드 스테이지에 복사해 해결했다. 근본적으로는 빌드와 테스트가 기대하는 입력을 명시적으로 관리하는 것이 중요하다. 목차 증상 로그 읽기: ENOENT 상대경로는 어디를 기준으로 계산될까? Docker 이미지 안에는 어떤 파일이 있을까? 두 조건이 만나면서 문제가 발생했다 해결 멀티스테이지 빌드라서 운영 이미지에는 들어가지 않는다 더 나은 설계: 암묵적인 입력을 명시적으로 만들기 해결 방법 비교 Docker 없이 비슷한 환경 재현하기 체크리스트 마치며 1. 증상 로컬에서는 npm test 가 전부 통과했다. 그런데 CI에서 Docker 이미지를 빌드하자 테스트 단계에서 실패했다. FAIL lib/price.test.ts Error: ENOENT: no such file or directory, open '/fixtures/products.json' ❯ lib/price.test.ts:12:14 Test Files 1 failed | 8 passed (9) Tests 5 failed | 146 passed (151) ERROR: process "/bin/sh -c npm test && npm run build" did not complete successfully: exit code: 1 여기서 가장 먼저 눈에 들어온 것은 두 가지였다. 첫째, ENOENT 둘째, 전체 테스트가 아니라 151개 중 파일을 읽는 5개만 실패했다는 점 즉 테스트 러너 전체나 Node.js 버전 문제보다는, 먼저 파일 접근과 경로 문제 를 의심할 수 있었다. 2. 로그 읽기: ENOENT ENOENT 는 POSIX 계열 운영체제에서 사용하는 오류 코드 중 하나로, 의미는 다음과 같다. ENOENT No such file or directory Node.js가 fs.readFileSync() 같은 파일시스템 API를 호출했는데, 운영체제가 해당 경로에서 파일을 찾지 못했을 때 발생한다. 즉 이것은 보통 "비즈니스 로직이 틀렸다" 라는 오류라기보다 "운영체제가 요청받은 위치에서 파일을 찾지 못했다" 라는 의미다. 따라서 먼저 확인해야 할 것은 코드 로직보다 실제로 어떤 경로를 찾았는가 였다. 경로 테스트가 기대한 파일 shop/fixtures/products.json 에러 메시지의 경로 /fixtures/products.json 저장소 내부가 아니라 파일시스템 최상위 /fixtures 를 찾고 있었다. 같은 코드가 왜 환경에 따라 서로 다른 위치를 가리켰을까? 원인을 이해하려면 두 가지를 알아야 한다. 상대경로는 무엇을 기준으로 계산되는가 Docker 이미지 안에는 어떤 파일이 존재하는가 3. 상대경로는 어디를 기준으로 계산될까? 3-1. 기준은 코드 파일의 위치가 아니라 cwd 다음과 같은 코드를 생각해보자. readFileSync("../../fixtures/products.json", "utf8"); 많은 경우 이 경로가 현재 .ts 파일을 기준으로 계산될 것처럼 느껴진다. 하지만 Node.js의 fs API에 상대경로를 넘기면 기본적으로 프로세스의 현재 작업 디렉터리(current working directory) 를 기준으로 해석한다. Node.js에서는 process.cwd() 로 확인할 수 있다. 예를 들어 아래 두 코드는 개념적으로 같다. path.resolve("../../fixtures"); path.resolve(process.cwd(), "../../fixtures"); 값 의미 언제 바뀌는가 process.cwd() 프로세스를 어디에서 실행했는가 cd , Docker WORKDIR , 실행 스크립트 등에 따라 달라짐 import.meta.url 현재 모듈 파일이 어디에 있는가 파일 위치가 바뀌지 않는 한 동일 __dirname 현재 파일이 있는 디렉터리 CommonJS에서 사용 즉 상대경로가 cwd에 의존한다면 같은 코드라도 어디에서 실행하느냐에 따라 다른 파일을 읽게 된다. 이번 경우 로컬에서는 shop/apps/web 에서 테스트를 실행했고, Docker 안에서는 Dockerfile의 WORKDIR /app 때문에 테스트 프로세스의 cwd가 /app 이었다. 3-2. / 보다 위로는 올라갈 수 없다 POSIX 파일시스템에서 루트 디렉터리 / 의 부모는 사실상 / 자신이다. 따라서 다음 코드는 오류를 내지 않는다. path.resolve("/app", "../../fixtures"); // → "/fixtures" 과정을 보면 이해하기 쉽다. /app ↓ .. / ↓ .. ← 루트보다 위로 못 가고 그대로 / / ↓ fixtures /fixtures 반면 로컬에서는 결과가 다르다. path.resolve("/home/me/shop/apps/web", "../../fixtures"); // → "/home/me/shop/fixtures" 이 차이가 이번 문제의 첫 번째 원인 이었다. 4. Docker 이미지 안에는 어떤 파일이 있을까? 4-1. Docker 이미지는 호스트 폴더의 복사본이 아니다 Docker 이미지를 만들면 프로젝트 폴더 전체가 자동으로 들어가는 것처럼 생각하기 쉽지만, 실제로는 그렇지 않다. 이미지는 Dockerfile 명령을 순서대로 실행하면서 만들어진 파일시스템 변경의 결과 다. FROM node:24-alpine WORKDIR /app COPY package*.json ./ RUN npm ci COPY apps/web/ ./ RUN npm test COPY 는 호스트의 파일을 이미지 안으로 가져온다. RUN npm ci 역시 이미지 안에서 새로운 파일(예: node_modules )을 생성할 수 있다. 즉 이미지는 단순히 " COPY 한 것의 합"이라기보다 Dockerfile 명령들이 각 레이어에 기록한 파일시스템 변경의 결과 라고 보는 것이 정확하다. 다만 호스트에 있는 파일은 자동으로 이미지 안에 나타나지 않으며, 사용하려면 COPY 나 ADD 등으로 명시적으로 가져와야 한다. 4-2. 빌드 컨텍스트와 COPY 다음 명령을 실행했다고 하자. docker build -f apps/web/Dockerfile . 마지막의 . 이 빌드 컨텍스트(build context) , 즉 Docker 빌드가 참조할 수 있는 범위다. shop/ ├─ fixtures/ ├─ apps/ │ └─ web/ └─ ... 저장소 루트에서 위 명령을 실행했다면 fixtures/ 역시 빌드 컨텍스트 안에 있다. 하지만 빌드 컨텍스트 안에 있다는 것과 이미지 안에 들어간다는 것은 다른 이야기다. 실제로 이미지 안에 넣으려면 COPY 가 필요하다. COPY fixtures/ /fixtures/ 또한 .dockerignore 에 제외된 파일은 빌드 컨텍스트에서 사용할 수 없게 될 수 있으므로 함께 확인해야 한다. 핵심: Docker 빌드가 어떤 파일을 볼 수 있는가와, 그 파일이 실제 이미지 안에 들어가는가는 별개의 문제다. 이삿짐에 비유하면 다음과 비슷하다. Docker 비유 빌드 컨텍스트 이사할 집 COPY 실제 이삿짐 목록 이미지 트럭에 실린 물건 집에 물건이 있다고 해서 전부 트럭에 실리는 것은 아니다. 5. 두 조건이 만나면서 문제가 발생했다 저장소 구조와 테스트 코드는 다음과 같았다. shop/ ├─ fixtures/ │ └─ products.json │ └─ apps/ └─ web/ ├─ Dockerfile ├─ package.json └─ lib/ └─ price.test.ts const products = JSON.parse( readFileSync( resolve(process.cwd(), "../../fixtures/products.json"), "utf8", ), ); 로컬과 Docker를 비교하면 다음과 같다. 로컬 Docker process.cwd() shop/apps/web /app ../../ 결과 shop/ / 최종 경로 shop/fixtures/products.json /fixtures/products.json 파일 존재 여부 ✅ ❌ [로컬] [Docker] shop/apps/web /app │ ../../ │ ../../ ▼ ▼ shop / ▼ ▼ fixtures/products.json ✅ /fixtures/products.json ❌ 여기에 두 번째 문제가 겹쳤다. 기존 Dockerfile은 웹 애플리케이션 디렉터리만 이미지 안으로 복사하고 있었다. COPY apps/web/ ./ 따라서 호스트 저장소의 fixtures/products.json 은 Docker 이미지 안에 존재하지 않았다. 정리하면 이번 장애는 두 조건이 동시에 만나 발생했다. 조건 1: 상대경로가 process.cwd() 를 기준으로 계산되면서 ../../fixtures 가 Docker 안에서 /fixtures 로 바뀌었다. 조건 2: fixtures/ 를 별도로 COPY 하지 않아 이미지 안에 /fixtures 자체가 없었다. 그래서 Node.js가 ENOENT: no such file or directory 를 반환했다. 6. 해결 이번에는 테스트 코드의 경로 계산 방식은 그대로 두고, 테스트가 Docker 환경에서 실제로 찾고 있는 위치에 필요한 파일을 넣었다. FROM node:24-alpine AS build WORKDIR /app COPY apps/web/package*.json ./ RUN npm ci COPY apps/web/ ./ # 테스트의 cwd는 /app. # ../../fixtures 는 /fixtures 로 해석되므로 # 테스트가 실제로 찾는 위치에 fixture를 복사한다. COPY fixtures/ /fixtures/ RUN npm test && npm run build FROM node:24-alpine WORKDIR /app COPY --from=build /app/.next/standalone ./ CMD ["node", "server.js"] 에러 메시지에서 테스트가 찾던 경로는 /fixtures/products.json 이었고, Dockerfile에서도 COPY fixtures/ /fixtures/ 로 정확히 같은 위치를 만들었다. 이후 테스트가 정상적으로 통과했다. 이번 실패의 직접적인 원인 은 테스트 로직 자체라기보다, 테스트가 필요로 하는 파일이 빌드 이미지에 포함되지 않았다는 점이었다. 다만 ../../fixtures 라는 디렉터리 구조를 암묵적으로 가정하는 방식 역시 장기적으로는 개선할 여지가 있다. 7. 멀티스테이지 빌드라서 운영 이미지에는 들어가지 않는다 COPY fixtures/ /fixtures/ 를 추가하면 테스트용 JSON 파일이 운영 이미지에도 들어가는 것 아닐까? 이번 Dockerfile에서는 그렇지 않다. 멀티스테이지 빌드 를 사용하고 있기 때문이다. FROM node:24-alpine AS build 에서 첫 번째 스테이지가 시작된다. 이곳에는 /app 과 /fixtures 가 모두 존재하며, npm test && npm run build 를 실행한다. 새로운 FROM node:24-alpine 이 등장하는 순간 새로운 파일시스템 이 시작된다. 첫 번째 스테이지의 파일은 자동으로 넘어오지 않는다. 필요한 결과물만 COPY --from=build /app/.next/standalone ./ 로 선택해서 가져온다. ┌─ Build stage ──────────────┐ │ /app │ │ ├─ source │ │ ├─ node_modules │ │ └─ build output │ │ /fixtures │ │ └─ products.json │ └────────────┬───────────────┘ │ npm test │ npm run build ▼ ┌─ Final stage ──────────────┐ │ /app │ │ └─ standalone build │ │ /fixtures 없음 │ └────────────────────────────┘ 테스트에 필요한 파일은 빌드 단계에서만 사용하고, 운영 이미지에는 필요한 결과물만 포함할 수 있다. 8. 더 나은 설계: 암묵적인 입력을 명시적으로 만들기 COPY 한 줄로 문제는 해결됐지만, 왜 이런 문제가 생겼는지 조금 더 생각해볼 필요가 있다. 기존 테스트에는 다음 가정이 숨어 있었다. 저장소 루트에 fixtures/ 가 존재한다. 테스트 프로세스는 저장소 루트에서 두 단계 아래에서 실행된다. 즉 테스트의 입력 파일 위치가 코드에 명확히 선언된 것이 아니라, 디렉터리 구조와 실행 위치에 암묵적으로 의존하고 있었다. 이런 값을 흔히 암묵적인 입력(implicit input) 이라고 볼 수 있고, 환경이 조금만 바뀌어도 쉽게 깨진다. 8-1. cwd 의존성을 파일 위치 의존성으로 바꾸기 ES Module 환경이라면 현재 파일의 위치를 기준으로 경로를 계산할 수 있다. const fixture = new URL( "../../../fixtures/products.json", import.meta.url, ); 이제 cd /somewhere && npm test 처럼 실행 위치가 달라져도 현재 모듈 파일을 기준으로 같은 상대 위치를 찾는다. 다만 이것도 완전히 독립적인 설계는 아니다. 여전히 다음 구조를 가정하기 때문이다. lib/price.test.ts │ ../../../ ▼ fixtures/ 즉 cwd 의존성 이 저장소 디렉터리 구조 의존성 으로 바뀐 것이다. 그리고 어떤 방식으로 경로를 계산하든 실제 파일이 Docker 이미지 안에 존재해야 한다는 조건은 변하지 않는다. 8-2. 파일 URL을 실제 경로로 변환할 때는 fileURLToPath() URL.pathname 을 바로 파일시스템 경로로 쓰는 코드를 볼 때가 있다. // ⚠️ Windows에서 문제가 될 수 있음 new URL("../../../fixtures/", import.meta.url).pathname 파일 URL과 운영체제의 경로 표현 방식이 다르기 때문이다. Node.js에서는 fileURLToPath() 를 사용하는 것이 안전하다. import { fileURLToPath } from "node:url"; const fixturesDir = fileURLToPath( new URL("../../../fixtures/", import.meta.url), ); Windows에서 개발하고 Linux Docker 컨테이너에서 빌드하는 프로젝트라면 이런 차이를 더욱 신경 쓰는 것이 좋다. 8-3. 입력 위치 자체를 설정으로 명시하기 조금 더 명시적으로 만들려면 fixture 위치를 한 곳에서 관리할 수도 있다. import { fileURLToPath } from "node:url"; export const FIXTURES_DIR = process.env.FIXTURES_DIR ?? fileURLToPath(new URL("../../../fixtures/", import.meta.url)); COPY fixtures/ /fixtures/ ENV FIXTURES_DIR=/fixtures 그러면 테스트는 fixture가 저장소에서 정확히 몇 단계 위에 있는가 보다 현재 환경에서 fixture 디렉터리가 어디인가 라는 명시적인 설정을 사용하게 된다. 로컬에서는 기본값을, Docker나 CI에서는 환경변수를 주입할 수 있고, 문제가 생겼을 때 확인해야 할 곳도 명확해진다. 9. 해결 방법 비교 방법 장점 단점 Dockerfile에 COPY 추가 코드 수정 없이 바로 해결 가능 Dockerfile이 테스트의 경로 규칙을 알아야 함 fixture를 apps/web/ 내부로 이동 프로젝트가 자체 테스트 데이터를 가지므로 구조가 단순 여러 프로젝트가 같은 fixture를 공유하기 어려움 fixture를 import 번들러/테스트 러너가 파일 의존성을 추적 가능 프로젝트 외부 파일 import 설정이 필요할 수 있음 환경변수로 fixture 경로 주입 환경마다 명시적으로 경로 설정 가능 설정 항목이 늘어남 import.meta.url 기반 경로 cwd 변화에 영향받지 않음 저장소 디렉터리 구조에는 계속 의존 이번 프로젝트에서는 여러 프로젝트가 같은 fixture 원본을 공유하고 있었기 때문에, 우선 가장 작은 변경인 COPY fixtures/ /fixtures/ 방식을 적용했다. 필요하다면 이후 FIXTURES_DIR 같은 환경변수 기반 구조로 확장할 수 있다. 10. Docker 없이 비슷한 환경 재현하기 CI가 오래 걸린다면 매번 Docker 이미지를 빌드해 확인하기 번거롭다. 이번 문제는 Docker 없이도 비슷한 디렉터리 구조를 만들어 재현할 수 있다. mkdir -p /tmp/sim/a/app cp -r apps/web/. /tmp/sim/a/app/ cp -r fixtures /tmp/sim/fixtures cd /tmp/sim/a/app npm ci npm test npm run build 일부러 다음 구조를 만든 것이다. /tmp/sim/ ├─ fixtures/ └─ a/ └─ app/ ← cwd cwd가 /tmp/sim/a/app 이므로 ../../fixtures 는 정확히 /tmp/sim/fixtures 를 가리킨다. Docker에서 기대하는 관계를 로컬 디렉터리 구조로 흉내 낸 것이다. 이 상태에서 /tmp/sim/fixtures 를 삭제하고 테스트를 실행하면 동일한 ENOENT 를 재현할 수 있다. 트러블슈팅에서 중요한 기준 중 하나는 문제를 재현할 수 있는가 다. 수정 전에는 실패하고 수정 후에는 성공하는 상황을 반복해서 만들 수 있어야, 수정이 실제 원인을 해결했다고 판단하기 쉽다. 11. 체크리스트 Docker나 CI에서만 파일 관련 테스트가 실패한다면 다음 항목부터 확인해볼 수 있다. 경로 테스트나 스크립트가 ../ 또는 ../../ 로 프로젝트 외부 파일을 읽고 있는가? 상대경로가 process.cwd() 기준인가? 로컬과 Docker의 process.cwd() 값이 같은가? Dockerfile의 WORKDIR 은 어디인가? 에러 메시지에서 실제로 어떤 절대경로를 찾고 있는가? Docker 이미지 필요한 파일이 Docker 빌드 컨텍스트 안에 있는가? 필요한 파일을 Dockerfile에서 실제로 COPY 했는가? .dockerignore 가 해당 파일이나 폴더를 제외하고 있지는 않은가? 테스트용 파일과 운영 이미지에 필요한 파일을 구분하고 있는가? 멀티스테이지 빌드라면 최종 스테이지에 불필요한 테스트 데이터가 넘어가고 있지 않은가? 설계 fixture나 설정 파일의 위치가 코드에 암묵적으로 숨어 있지는 않은가? 필요한 입력 위치를 환경변수나 설정 값으로 명시할 수 있는가? 12. 마치며: "로컬에서는 되는데 CI에서는 안 된다" 개발 중 자주 듣는 말이 있다. "로컬에서는 되는데 CI에서는 안 돼요." 이 문제의 원인은 많은 경우 코드 자체보다 환경 차이 에 있다. 이번에는 차이가 두 가지였다. 현재 작업 디렉터리(cwd) Docker 이미지 내부의 파일 구조 로컬에서는 cwd가 shop/apps/web 이라 ../../fixtures 가 자연스럽게 저장소 루트의 fixtures 를 가리켰다. Docker에서는 cwd가 /app 이라 같은 코드가 /fixtures 를 가리켰고, 이미지에는 해당 폴더를 COPY 하지 않았기 때문에 파일이 존재하지 않았다. 원인 해결 상대경로 테스트가 실제로 찾는 위치 확인 ↓ process.cwd() ↓ ↓ 환경마다 다른 절대경로 필요한 입력을 Dockerfile에 명시 ↓ Docker 이미지에 필요한 파일 없음 ↓ COPY fixtures/ /fixtures/ ENOENT 테스트 통과 조금 더 넓게 보면 이번 문제는 재현 가능한 빌드 에 대한 이야기이기도 하다. 빌드에 어떤 파일, 어떤 환경변수, 어떤 디렉터리 구조가 필요한지 명확하게 선언되어 있을수록 환경 차이에 덜 흔들린다. 이렇게 외부 환경의 숨은 상태에 의존하지 않고 선언된 입력만으로 동일한 결과를 만드는 빌드를 흔히 h
네트워크란? 여러대의 컴퓨터 또는 장비가 서로 연결되어서 정보를 주고 받을 수 있게 도와주는 기술 Client와 Server **Client** : 서버로 요청하는 프로그램 ex)웹 브라우저 **Server**: 클라이언트의 요청을 받아 처리하는 주체 - 흔히 우리가 웹 브라우저에 주소를 입력하는 건 ‘새로운 화면을 그리기 위한 데이터를 달라’는 데이터 요청에 해당 인터넷상의 주소 IP : 컴퓨터를 식별하기 위한 위치 주소 ex) 서울시 00구 포트 번호 : 그 서버에서 운용되고 있는 서비스를 구분하기 위한 번호, 받는 사람 ex)홍길동 웹서버란? 인터넷을 통해 HTTP를 이용하여 웹상의 클라이언트의 요청을 응답해주는 통신을 하는 일종의 컴퓨터 웹 서버의 기본 동작 원리 브라우저가 HTTP Request 요청 웹서버는 요청을 승인 HTTP Response 를 통해 웹사이트 데이터를 브라우저에 전송 브라우저는 서버에서 받아온 데이터를 이용해 화면에 출력 API와 RESTful API API 다른 소프트웨어 시스템과 통신하기 위해 따라야 하는 규칙, 하나의 "약속" RESTful API 자원을 이름으로 구분하여 해당 자원의 상태를 주고받는 모든 것 api가 적절하게 http를 준수하며 잘 설계되어있으면 RESTful 하게 설계된 것 HTTP 메서드 GET : 데이터 조회 POST : 데이터 생성 PUT : 데이터 수정 DELETE : 데이터 삭제 웹 서버와 WAS Web Server 브라우저에서 URL을 입력하여 어떠한 페이지를 요청했을 때 HTTP의 요청을 받아들여 HTML 문서와 같은 정적인 콘텐츠를 사용자에게 전달해주는 역할을 하는 것 정적 콘텐츠: 이미 완성이 되어있는 HTML과 같은 문서를 전달 동적 콘텐츠(마이페이지): 자체 처리 불가능, 해당 요청을 WAS에 전달 WAS 웹 애플리케이션, 즉 실제 기능이 동작하는 서버 동적인 콘텐츠 처리 가능하다는 점에서 web server와 구별 ex) Apache, Nginx SpringBoot와 Spring Spring Framework는 많은 xml 설정을 필요로 함 -> SpringBoot 등장 SpringBoot Java의 @애너테이션 기반의 설정 외부 라이브러리나 하위 프레임워크들의 의존성 관리가 쉬워짐 내장 Apache Tomcat HTTP 데이터를 주고 받는 양식을 정의한 "통신 규약"중 하나 HTTP 상태 코드(Status Code) 2xx (Successful) 3xx (Redirection) 4xx (Client Error) :클라이언트 오류 5xx (Server Error) : 서버 오류 Header : 추가 데이터, 메타 데이터 ex) GET naver.com HTTP/1.1 Payload: 실제 데이터 GET method를 제외하곤 모두 Payload를 보낼 수 있음(http에서의 약속) 테스트 코드 JUnit : 자바 프로그래밍 언어용 단위 테스트 프레임워크 테스트 파일 생성 단축키 Windows : Ctrl + shift + t Mac : ⌘ + shift + t Lombok과 application.properties Lombok 자바 프로젝트를 진행하는데 거의 필수적으로 필요한 메서드/생성자 등을 자동 생성해줌으로써 코드를 절약할 수 있도록 도와주는 라이브러리 @getter, setter @getter @Setter public class Memo { private String username; private String contents; } ... //아래와 같은 역할 public String getUsername() { return this.username; } public String getContents() { return this.contents; } public void setUsername(String username) { this.username = username; } public void setContents(String contents) { this.contents = contents; } @AllArgsConstructor, NoArgsConstructor @NoArgsConstructor @AllArgsConstructor public class Memo { private String username; private String contents; } ... public Memo() { //@NoArgsConstructor 역할 } public Memo(String username, String contents) { //@AllArgsConstructor 역할 this.username = username; this.contents = contents; } @RequiredArgsConstructor @RequiredArgsConstructor public class Memo { private final Calculator calculator; private final String username; private String contents; } ... // 아래와 같은 역할 public Memo(Calculator calculator, String username) { this.calculator = calculator; this.username = username; } application.properties Spring과 관련된 설정을 할 때 사용되는 파일 server.port=8081 변경시 서버의 port가 8081로 변경됨