반응형
SMALL

 

무료 음악, 더 이상 어렵지 않아요! 저작권 걱정 없이 음악을 즐기고 싶으신가요? 다양한 상황에서 활용할 수 있는 무료 음악 소스부터 올바른 사용법까지, 이 글 하나로 모든 고민을 해결해 드릴게요! 🎧

 

솔직히 말해서, 유튜브 영상 만들 때나 발표 자료 준비할 때, 아니면 그냥 카페에서 들을 잔잔한 배경 음악 찾을 때, 무료 음악만큼 고마운 존재가 또 있을까요? 저도 예전에 한참 자료 찾다가 저작권 때문에 골머리를 앓았던 기억이 많거든요. 😅 혹시나 잘못 사용해서 문제가 생기면 어쩌나 하는 불안감에 덜덜 떨었던 적도 있고요. 그니까요, 저처럼 저작권의 늪에서 헤매지 마시라고! 오늘은 제가 직접 경험하고 찾아낸 '무료 음악'의 모든 것을 알려드릴게요. 이제는 마음 편히 음악을 즐길 수 있을 거예요! 😊

 

어디서 찾을 수 있을까? 무료 음악의 보물창고! 💎

무료 음악이라고 해서 다 똑같은 무료가 아니라는 사실, 알고 계셨나요? 저작권 조건이 조금씩 다를 수 있거든요. 하지만 걱정 마세요! 개인적인 용도는 물론, 상업적인 용도로도 부담 없이 사용할 수 있는 사이트들을 제가 직접 정리해 봤습니다. 함께 살펴볼까요?

  • 유튜브 오디오 라이브러리 (YouTube Audio Library): 유튜브 크리에이터라면 필수! 장르, 분위기, 악기, 길이 등 다양한 필터로 원하는 음악을 쉽게 찾을 수 있어요. 유튜브 내 영상에만 사용할 수 있는 줄 아는 분들도 계시는데, 조건만 충족한다면 다른 플랫폼에서도 사용 가능합니다.
  • 벤사운드 (Bensound): 영화나 광고에서 들어본 듯한 세련된 음악들이 많아서 제가 정말 애용하는 곳이에요. 크리에이티브 커먼즈 라이선스(Creative Commons License)에 따라 출처 표기만 잘 하면 무료로 사용 가능하답니다.
  • 퍼플플래닛 (Purple Planet Music): 신비롭고 몽환적인 분위기의 음악을 찾으신다면 이곳을 추천해요. 개인 사용은 무료이고 상업적 사용 시에는 유료 라이선스가 필요할 수 있으니 꼭 확인하세요!
  • 픽사베이 (Pixabay Music): 무료 이미지, 영상으로 유명한 픽사베이에서도 무료 음악을 제공해요! 꽤 많은 곡들이 상업적 사용이 가능해서 유용하게 쓸 수 있습니다.
  • 프리 뮤직 아카이브 (Free Music Archive, FMA): 다양한 아티스트들의 음악을 한곳에서 만날 수 있는 곳이에요. 라이선스 종류가 다양하니 다운로드 전에 꼭 확인하는 습관을 들이세요!
💡 알아두세요!
무료 음악 사이트마다 라이선스 조건이 조금씩 다를 수 있어요. 특히 상업적 이용 여부출처 표기 방식은 반드시 확인해야 합니다. 다운로드 전에 라이선스 페이지를 한 번 더 꼼꼼히 읽어보는 습관, 정말 중요해요!

 

 

저작권, 이거 진짜 중요해요! 🤔

무료 음악을 쓴다고 해서 저작권에서 완전히 자유로운 건 아니에요. '무료'라는 말에 혹해서 무작정 사용했다가 나중에 문제가 생기는 경우가 종종 있거든요. 어떤 부분을 특히 주의해야 할까요?

⚠️ 주의하세요!
크리에이티브 커먼즈(CC) 라이선스라고 다 같은 게 아니에요! BY (저작자 표시), NC (비영리), SA (동일조건 변경 허락), ND (변경 금지) 등 다양한 조건이 붙을 수 있어요. 특히 상업적으로 이용하거나 음악을 편집해서 사용할 때는 ND나 NC 라이선스 여부를 꼭 확인해야 합니다.

제가 처음 무료 음악을 찾아 헤맬 때 가장 헷갈렸던 부분이 바로 라이선스였어요. '무료'니까 그냥 막 쓰면 되는 줄 알았는데, 아니더라고요. 솔직히 저작권이란 게 엄청 복잡하잖아요? 그래서 중요한 몇 가지 라이선스 유형과 그 의미를 간단하게 표로 정리해 봤어요!

라이선스 유형 주요 특징 예시 사용
CC BY (저작자 표시) 원저작자를 반드시 표시해야 함. 상업적 이용 및 변경 가능. 개인 블로그 배경 음악, 유튜브 영상 BGM
CC BY-NC (저작자 표시-비영리) 원저작자 표시, 비영리 목적으로만 사용 가능. 학교 과제 발표, 개인 소장용 동영상
CC BY-ND (저작자 표시-변경 금지) 원저작자 표시, 원본 그대로 사용해야 하며 변경 불가. 음악 감상, 편집 없는 단순 재생
CC BY-SA (저작자 표시-동일조건 변경 허락) 원저작자 표시, 2차 저작물 배포 시 원본과 동일한 라이선스 적용. 음악 리믹스 후 재배포 (동일 라이선스 적용)

정말 중요해요, 꼭 기억해주세요! 저도 처음에 이걸 몰라서 꽤 애를 먹었답니다. ㅠㅠ

 

실전 활용 팁: 무료 음악, 이렇게 써보세요! 💡

자, 이제 무료 음악을 어디서 찾고, 어떤 걸 조심해야 하는지 알았으니, 이제 실제로 어떻게 활용할 수 있을지 알아볼까요? 제가 자주 쓰는 팁들을 공유해 드릴게요!

영상 BGM으로 활용하기 🎬

  • 장면과 어울리는 음악 선택: 영상의 분위기를 살리는 데 음악만큼 중요한 게 없어요. 밝은 분위기에는 경쾌한 음악을, 진지한 분위기에는 차분한 음악을 선택하세요.
  • 음악의 길이와 편집: 영상 길이에 맞춰 음악을 적절히 자르거나, 여러 곡을 자연스럽게 이어 붙이는 것도 중요해요. 페이드인/아웃 효과를 활용하면 전문가 느낌 뿜뿜!
  • 음량 조절: 음악 소리가 너무 크면 영상 속 대사가 묻힐 수 있으니, 적절한 볼륨으로 조절하는 센스!

개인 플레이리스트 만들기 🎶

저만의 힐링 루틴인데, 직접 찾은 무료 음악들로 테마별 플레이리스트를 만들어서 듣는 걸 좋아해요. 예를 들어, '집중력 UP! 작업용 플레이리스트', '편안한 휴식을 위한 명상 음악'처럼요. 은근히 성취감도 있고, 저작권 걱정 없이 들을 수 있어서 좋더라고요. 😊

 

무료 음악 라이선스 계산기 🔢

헷갈리는 라이선스, 제가 간단하게 계산해 드릴게요! 아래에서 원하는 조건을 선택하고 '확인하기' 버튼을 눌러보세요.

내 음악, 어떻게 써야 할까? 🧐

 

이 글의 핵심 요약 📝

오늘 우리가 함께 알아본 무료 음악의 세계, 어떠셨나요? 복잡하게만 느껴졌던 저작권과 무료 음악 사용법, 이제는 좀 더 명확해지셨기를 바라면서 핵심 내용을 다시 한번 정리해 드릴게요.

  1. 다양한 무료 음악 소스 활용: 유튜브 오디오 라이브러리, 벤사운드, 픽사베이 등 믿을 수 있는 사이트에서 양질의 무료 음악을 찾을 수 있어요.
  2. 라이선스 조건 확인은 필수: 특히 상업적 이용 여부, 출처 표기 방식, 음악 변경 가능 여부를 꼭 확인하고 사용해야 해요. CC BY, CC BY-NC, CC BY-ND, CC BY-SA 등 라이선스 종류별 특징을 알아두면 큰 도움이 됩니다.
  3. 음악 활용 팁 적용: 영상 BGM으로 활용할 때는 장면에 맞는 음악 선택, 길이 및 음량 조절에 신경 쓰고, 개인 플레이리스트를 만들어 나만의 공간을 연출해 보세요!

이제는 저작권 걱정 없이, 원하는 곳에 멋진 음악을 마음껏 활용할 수 있을 거예요! 😊

🎵
무료 음악 소스
유튜브 오디오 라이브러리, 벤사운드, 픽사베이 등 다양한 플랫폼에서 음악을 찾아보세요.
⚖️
저작권 라이선스
CC BY, BY-NC, BY-ND, BY-SA 등 라이선스 조건을 반드시 확인하고 사용하세요.
음악 활용 팁
영상 BGM, 개인 플레이리스트 등 목적에 맞게 음악을 효과적으로 활용하세요.

자주 묻는 질문 ❓

Q: 무료 음악인데도 상업적으로 사용할 수 있나요?
A: 네, 가능합니다. 다만 CC BY (저작자 표시) 또는 CC BY-SA (동일조건 변경 허락) 라이선스가 부여된 음악을 사용해야 합니다. 각 사이트에서 제공하는 라이선스 정보를 반드시 확인해야 해요.
Q: 무료 음악을 사용했는데 출처 표기는 꼭 해야 하나요?
A: 대부분의 무료 음악은 '저작자 표시(BY)' 조건이 붙어있어요. 즉, 음악을 사용했음을 명시하고 원저작자를 밝혀야 합니다. 이를 지키지 않으면 저작권 침해가 될 수 있으니 꼭 지켜주세요!
Q: 무료 음악을 다운로드받아 편집해도 되나요?
A: 라이선스에 따라 다릅니다. '변경 금지(ND)' 조건이 붙어있는 음악은 편집할 수 없어요. 하지만 '변경 허락' 조건이 있는 음악은 편집 후 사용할 수 있습니다.
Q: 유튜브 오디오 라이브러리 음악은 유튜브 영상에만 사용 가능한가요?
A: 아니요, 유튜브 오디오 라이브러리 약관 및 개별 음악의 라이선스를 준수한다면 유튜브 외 다른 플랫폼의 영상이나 프로젝트에도 사용할 수 있습니다. 단, 항상 사용 전에 조건을 확인하는 것이 안전합니다.

음악은 우리 삶에 정말 큰 영향을 주는 것 같아요. 무료 음악을 잘 활용해서 여러분의 프로젝트나 일상에 더 풍부한 사운드를 더하시길 바랍니다! 더 궁금한 점이 있다면 댓글로 물어봐주세요~ 😊

반응형
반응형
SMALL

 

🎨 AI 무료 이미지 제작, 어디서 시작해야 할까요? 상업적 이용까지 가능한 AI 이미지 사이트들을 한눈에! 복잡한 저작권 걱정 없이 당신의 아이디어를 시각화할 수 있는 최고의 도구들을 소개합니다.

블로그를 운영하거나, 유튜브 콘텐츠를 만들거나, 아니면 그냥 내 아이디어를 멋진 이미지로 표현하고 싶을 때, 다들 한 번쯤은 "어떤 이미지를 써야 하지?" 하고 고민해보셨을 거예요. 저도 그랬거든요! 비싼 스톡 이미지 사이트는 부담스럽고, 저작권 걱정 없는 무료 이미지를 찾기란 또 얼마나 어려운지… 😩

그런데 요즘은 AI 기술 덕분에 이런 고민이 확 줄었어요! 텍스트 몇 줄만 입력하면 그림을 뚝딱 만들어주는 AI 이미지 생성 사이트들이 정말 많이 생겼잖아요. 심지어 상업적으로도 자유롭게 쓸 수 있는 곳들이 많아서 깜짝 놀랐어요. 오늘은 저와 함께 상업적 이용까지 가능한 AI 무료 이미지 제작 사이트들을 꼼꼼하게 정리해볼까 합니다. 솔직히 말해서, 이거 알면 진짜 편해져요! 😊

AI 이미지 제작, 왜 이제 필수일까요? 💡

예전에는 이미지 하나 만들려면 디자인 툴을 배우거나, 디자이너에게 의뢰하거나, 아니면 직접 찍어야 했죠. 하지만 AI 이미지 생성기는 그런 장벽을 다 허물었어요. 저 같은 똥손도(?) 이제 멋진 이미지를 만들 수 있다니까요!

  • 시간 절약: 순식간에 수십 개의 이미지를 생성할 수 있어요. 아이디어 구상 시간보다 이미지 만드는 시간이 더 짧을 때도 있답니다.
  • 비용 절감: 비싼 유료 스톡 이미지나 디자이너 고용 비용을 아낄 수 있어요.
  • 무한한 창의성: 현실에는 없는 독특하고 기발한 이미지를 만들 수 있어요. 상상하는 모든 것을 시각화할 수 있다는 게 진짜 매력적이죠!
  • 저작권 걱정 NO: 오늘 소개해드릴 사이트들은 대부분 상업적 이용이 가능한 이미지들을 생성해줘서 저작권 문제에서 자유로울 수 있어요.

특히 저작권 문제는 진짜 중요하잖아요. 아무리 멋진 이미지라도 함부로 썼다가 문제 생기면 골치 아프니까요. 그래서 오늘 소개할 사이트들은 이 부분을 제일 신경 써서 골라봤어요.

💡 알아두세요!
AI 이미지의 '상업적 이용 가능' 여부는 각 사이트의 정책에 따라 달라질 수 있어요. 사용 전에 반드시 해당 사이트의 이용 약관(Terms of Service)이나 라이선스 정책을 다시 한번 확인하는 것이 중요합니다. 특히, 인물 이미지의 경우 초상권 문제가 발생할 수 있으니 주의해야 해요.

 

상업적 이용 가능한 AI 무료 이미지 제작 사이트 총정리 📝

자, 그럼 이제 본론으로 들어가서, 제가 직접 써보고 괜찮다 싶었던 AI 무료 이미지 사이트들을 소개해드릴게요. 정말 유용하게 쓰실 수 있을 거예요!

1. 드림스튜디오 (DreamStudio - Stable Diffusion) 🌠

  • 특징: 스테이블 디퓨전(Stable Diffusion)의 공식 웹 인터페이스예요. 워낙 유명한 모델이라 결과물이 좋고, 다양한 스타일을 구현할 수 있어요. 처음 가입하면 크레딧을 무료로 제공해서 어느 정도 이미지를 만들어 볼 수 있습니다. 프롬프트만 잘 입력하면 진짜 고퀄리티 이미지를 뽑아낼 수 있다는 거!
  • 장점: 높은 자유도, 다양한 스타일, 커뮤니티가 활발해서 노하우 공유가 많아요.
  • 단점: 무료 크레딧 소진 후에는 유료로 전환해야 해요. 하지만 소액으로도 꽤 많은 이미지를 만들 수 있어서 부담은 적은 편이에요.
  • 상업적 이용: 가능 (Stable Diffusion XL 모델 기준).

2. 플레이그라운드 AI (Playground AI) 🎨

  • 특징: 매일 1000개 이상의 이미지를 무료로 생성할 수 있는 혜자스러운 사이트예요. 심지어 상업적 이용도 가능해서 제가 진짜 많이 쓰는 곳이랍니다. 이미지 생성 속도도 빠르고, 기본적인 편집 기능도 제공해서 좋아요.
  • 장점: 압도적인 무료 생성량, 상업적 이용 가능, 사용하기 쉬운 인터페이스.
  • 단점: 가끔 계정 관련 이슈가 발생할 수 있다는 후기가 있어요.
  • 상업적 이용: 가능.

3. 마이크로소프트 디자이너 (Microsoft Designer) ✨

  • 특징: DALL-E 3 모델을 기반으로 하는 마이크로소프트의 디자인 도구예요. 텍스트로 이미지를 생성하는 것 외에도 포스터, 배너 등 다양한 디자인 템플릿을 제공해서 유용해요. 빙(Bing) 이미지 크리에이터와도 연동되어 있어서 접근성도 좋고요.
  • 장점: DALL-E 3의 뛰어난 이미지 이해력, 다양한 디자인 템플릿, 마이크로소프트 계정으로 쉽게 접근 가능.
  • 단점: 완전 무료는 아니고, M365 구독자에게 더 많은 기능을 제공해요. 하지만 이미지 생성 자체는 무료로 이용할 수 있습니다.
  • 상업적 이용: 가능 (Bing 이미지 크리에이터와 동일 정책 적용).

4. 어도비 파이어플라이 (Adobe Firefly) 🔥

  • 특징: 어도비에서 출시한 AI 이미지 생성 도구예요. 기존 어도비 제품군(포토샵, 일러스트 등)과의 연동성이 뛰어나다는 게 가장 큰 장점이죠. 저작권 문제 없이 상업적으로 이용할 수 있도록 학습 데이터를 엄선해서 사용했다고 해요.
  • 장점: 어도비 생태계와 연동, 저작권 이슈에 대한 높은 신뢰도, 텍스트-이미지 외에 다양한 AI 기능 (텍스트 효과, 리컬러 등) 제공.
  • 단점: 아직 완전 무료는 아니고, 생성 크레딧이 제한적이에요. 하지만 시험 삼아 써보기에는 충분합니다.
  • 상업적 이용: 가능.

5. 레오나르도 AI (Leonardo AI) 🦁

  • 특징: 게임 개발에 특화된 AI 이미지 생성기로 시작했지만, 이제는 일반적인 이미지 생성에도 뛰어난 성능을 보여줘요. 매일 무료 토큰을 제공해서 다양한 모델과 기능을 체험해 볼 수 있어요. 인터페이스가 좀 복잡하게 느껴질 수도 있지만, 익숙해지면 원하는 이미지를 디테일하게 뽑아내기 좋아요.
  • 장점: 다양한 커스텀 모델, 이미지 투 이미지(Image-to-Image) 기능, 인페인팅/아웃페인팅 등 고급 기능.
  • 단점: 처음 사용자에게는 다소 복잡할 수 있어요.
  • 상업적 이용: 가능.
⚠️ 주의하세요!
AI 이미지 생성 시 '부정적인 프롬프트(Negative Prompt)' 활용이 중요해요. 예를 들어 "watermark, ugly, low quality"처럼 원치 않는 요소를 미리 제거함으로써 훨씬 더 깔끔하고 원하는 결과물을 얻을 수 있답니다. 그리고 복잡한 인물 표현은 아직 완벽하지 않을 수 있어요!

 

AI 이미지 제작, 이렇게 활용해보세요! 💡

이런 AI 이미지 사이트들을 어떻게 활용하면 좋을지 저의 꿀팁들을 살짝 알려드릴게요!

  1. 블로그 포스팅 썸네일/내부 이미지: 글 내용에 딱 맞는 이미지를 빠르게 만들 수 있어서 블로그 지수를 높이는 데 도움이 돼요. 저도 지금 이 글에 쓸 이미지 프롬프트 고민하고 있답니다!
  2. 유튜브 영상 썸네일/배경: 시선을 사로잡는 썸네일이나 영상에 필요한 배경 이미지를 쉽게 만들 수 있어요.
  3. 소셜 미디어 콘텐츠: 인스타그램, 페이스북 등 시각적인 요소가 중요한 플랫폼에서 나만의 개성 있는 이미지를 만들 수 있죠.
  4. 아이디어 스케치: 제품 디자인, 캐릭터 구상 등 초기 아이디어를 시각화할 때 정말 유용해요.
  5. 개인 프로젝트: 웹사이트 디자인, PPT 자료, 개인 포트폴리오 등 어디든 활용 가능해요.

프롬프트 작성 꿀팁 📝

AI 이미지 생성의 핵심은 바로 프롬프트(Prompt)예요! 자세하고 구체적으로 묘사할수록 원하는 결과물에 가까워져요.

  • 주제: 무엇을 만들고 싶은지 명확하게 (예: 고양이, 우주선, 미래 도시)
  • 스타일: 어떤 느낌으로 만들고 싶은지 (예: 수채화, 유화, 픽셀 아트, 3D 렌더링, 시네마틱)
  • 색상/분위기: 색감이나 전체적인 분위기 (예: 따뜻한 색감, 어두운 분위기, 밝고 활기찬)
  • 세부 묘사: 작은 디테일까지 (예: 고양이 눈 색깔, 우주선에 달린 안테나, 건물 창문의 빛)
  • 작가/스타일 레퍼런스: 특정 작가나 그림체 (예: by Greg Rutkowski, trending on Artstation)
  • 해상도/품질: (예: 8k, ultra detailed, photorealistic)

예시: "고양이, 수채화, 따뜻한 노란색 배경, 창가에서 잠자는 모습, 햇살이 비치는, cozy, high quality" 처럼요!

AI 무료 이미지, 현명하게 사용하는 법! 📝

지금까지 상업적 이용이 가능한 AI 무료 이미지 사이트들을 소개해드렸는데요. 단순히 이미지를 만드는 것을 넘어, 이 도구들을 어떻게 '현명하게' 활용할 수 있을지에 대한 저의 생각도 공유해볼게요.

  1. 첫째, 여러 사이트를 경험해보세요: 각 AI 모델마다 그림체나 특성이 다 달라요. 드림스튜디오는 사실적인 이미지를, 플레이그라운드 AI는 다양한 스타일을, 어도비 파이어플라이는 어도비 생태계와 연동성을 강조하는 등 저마다의 장점이 있거든요. 어떤 곳이 나에게 가장 잘 맞는지 직접 사용해보는 게 중요해요.
  2. 둘째, 프롬프트는 연습이 필요해요: 원하는 이미지를 얻기 위해서는 프롬프트 작성 실력이 진짜 중요해요. 처음엔 막막하겠지만, 다른 사람들이 공유하는 멋진 프롬프트들을 참고하면서 나만의 프롬프트 노하우를 쌓아가야 해요. 영어로 작성하는 게 더 결과가 좋을 때가 많으니, 파파고 같은 번역기를 적극 활용하는 것도 꿀팁이랍니다.
  3. 셋째, 보정은 필수! (선택적): AI가 만들어준 이미지가 100% 완벽할 수는 없어요. 미세한 부분이나 색감을 좀 더 보정하고 싶다면, 포토샵이나 미리캔버스 같은 편집 도구를 활용하는 것도 좋아요. 간단한 보정만으로도 훨씬 완성도 높은 이미지를 만들 수 있습니다.
  4. 넷째, 저작권 및 라이선스 정책은 항상 확인하세요: 제가 소개한 사이트들은 대부분 상업적 이용이 가능하지만, 정책은 언제든 바뀔 수 있어요. 그리고 특정 조건(예: 출처 표기)이 붙을 수도 있으니, 중요한 프로젝트에 사용하기 전에는 꼭 최신 정책을 확인하는 습관을 들이는 것이 중요해요.
  5. 다섯째, 윤리적 사용을 고민해보세요: AI 이미지가 아무리 편리해도, 너무 남용하거나 타인의 창작물을 무단으로 학습시키는 것은 지양해야겠죠. AI 기술을 활용하되, 창작자로서의 책임감과 윤리 의식을 함께 가져가는 것이 중요하다고 생각해요.

이런 팁들을 잘 기억하고 활용하시면, AI 이미지 제작이 훨씬 더 즐겁고 효율적인 경험이 될 거예요. 솔직히 저도 처음엔 AI로 그림 그리는 게 마냥 신기했는데, 이젠 없으면 불편할 정도네요. 😂

 

핵심 요약: AI 이미지 사이트, 한눈에 보기! 🌟

지금까지 설명드린 내용을 한눈에 보기 쉽게 정리해봤어요. 나에게 맞는 AI 이미지 제작 사이트를 찾는 데 도움이 되길 바라요!

AI 이미지, 왜 써야 할까?

  • 시간 & 비용 절약: 이미지 제작 과정의 혁신!
  • 무한한 창의성: 상상하는 모든 것을 시각화.
  • 저작권 걱정 감소: 상업적 이용 가능한 무료 사이트 다수.

추천 무료 사이트 (상업적 이용 가능)

  • 드림스튜디오: 스테이블 디퓨전 기반, 높은 자유도.
  • 플레이그라운드 AI: 일일 1000개 이상 무료 생성.
  • MS 디자이너: DALL-E 3 기반, 디자인 템플릿 제공.
  • 어도비 파이어플라이: 어도비 연동, 저작권 신뢰도 높음.
  • 레오나르도 AI: 게임 특화, 고급 기능 제공.

현명한 사용 꿀팁

  • 여러 사이트 경험: 각 도구의 특성 이해.
  • 프롬프트 연습: 구체적인 묘사로 고품질 이미지 생성.
  • 보정 활용: 필요시 편집 도구로 완성도 높이기.
  • 정책 확인: 최신 라이선스 정책 상시 확인.
  • 윤리적 사용: 책임감 있는 창작자 되기.

꼭 기억하세요!

  • AI 이미지는 당신의 아이디어를 현실로 만드는 강력한 도구!
  • 무료로 시작하고, 필요에 따라 유료 기능 활용.
  • 즐겁게 만들고, 멋지게 활용하세요!
 

자주 묻는 질문 ❓

Q: AI 이미지는 저작권이 어떻게 되나요?
A: 👉 AI가 생성한 이미지의 저작권은 아직 논의 중인 부분이 많지만, 대부분의 무료 이미지 생성 사이트는 상업적 이용을 허용하고 있어요. 다만, 각 사이트의 정책을 반드시 확인하고 사용해야 합니다. 인간의 창작성이 개입된 경우에는 저작권이 인정될 여지가 더 많아요.
Q: 프롬프트를 영어로 써야 하나요, 한글로 써도 되나요?
A: 👉 대부분의 AI 이미지 생성기는 영어 프롬프트에 더 최적화되어 있어요. 한글로 입력해도 어느 정도 이미지가 나오지만, 더 정확하고 다양한 결과물을 원한다면 영어를 사용하는 것을 추천해요. 파파고나 구글 번역기를 활용하면 쉽게 번역할 수 있답니다.
Q: AI 이미지 사이트 사용 시 주의할 점이 있을까요?
A: 👉 네, 몇 가지 주의할 점이 있어요. 첫째, 과도한 선정성이나 폭력성을 유도하는 프롬프트는 대부분 제한됩니다. 둘째, 특정 인물이나 브랜드의 이미지를 허락 없이 생성하는 것은 문제가 될 수 있어요. 셋째, 완벽하지 않은 인물 이미지가 나올 수 있으니 섬세한 인물 표현이 필요하다면 다른 도구를 병행하는 것이 좋습니다.

어떠셨나요? AI 무료 이미지 제작, 생각보다 어렵지 않죠? 이제 더 이상 비싼 이미지 때문에 고민하지 마시고, AI의 힘을 빌려 당신의 아이디어를 마음껏 시각화해보세요! 저도 요즘 AI 이미지 덕분에 블로그 글 쓰는 재미가 두 배가 되었답니다. 혹시 더 궁금한 점이 있다면 언제든지 댓글로 물어봐 주세요~ 😊

반응형
반응형
SMALL

 

디지털화폐, 미래 금융의 핵심을 파헤쳐 보자! 종이 화폐 없이 스마트폰 하나로 모든 거래가 가능한 세상을 상상해 보셨나요? 디지털화폐의 등장과 함께 현실이 되고 있는 미래 금융, 그 모든 것을 쉽게 알려드립니다.

안녕하세요! 요즘 뉴스나 주변에서 디지털화폐 이야기 정말 많이 들리죠? 처음에는 저도 이게 대체 뭔지, 복잡하고 어렵게만 느껴졌거든요. 비트코인 같은 가상자산이랑 같은 건가 싶기도 하고, 중앙은행이 발행하는 디지털화폐(CBDC)는 또 뭐지? 싶고요. 솔직히 말해서 저도 처음엔 혼란스러웠어요. 😊

근데 조금씩 알아보니 우리 삶에 생각보다 훨씬 가까이 다가와 있고, 알아두면 정말 유용하겠다는 생각이 들더라고요. 그래서 오늘은 여러분과 함께 디지털화폐의 세계를 쉽고 재미있게 탐험해보려고 합니다. 대체 디지털화폐가 무엇이고, 어떤 종류가 있는지, 그리고 우리에게 어떤 영향을 미칠지 차근차근 알아볼까요?

 

디지털화폐, 왜 중요할까요? 기본 개념 잡기 🤔

디지털화폐는 말 그대로 디지털 형태로 존재하는 화폐를 의미해요. 우리가 사용하는 신용카드나 모바일 페이도 넓게 보면 디지털 형태의 결제 수단이지만, 여기서 이야기하는 디지털화폐는 조금 더 근본적인 화폐 자체의 디지털 전환에 가깝습니다.

가장 큰 특징은 물리적인 형태(지폐, 동전) 없이 전자적인 방식으로만 존재하고 거래된다는 점이에요. 이게 왜 중요하냐면요, 거래 속도를 엄청나게 빠르게 만들고, 중간 단계를 줄여 비용을 절감할 수 있게 해줍니다. 또, 새로운 기술과 결합해서 더 다양한 금융 서비스 개발을 가능하게 하죠.

💡 알아두세요!
디지털화폐는 단순히 온라인 결제 수단을 넘어, 화폐 시스템 자체의 변화를 의미해요. 기술 발전과 함께 금융 환경이 어떻게 달라질지 보여주는 중요한 흐름이랍니다.

 

헷갈리는 디지털화폐 종류, 정리해 봐요 📊

중앙은행 디지털화폐 (CBDC)

이건 말 그대로 각 나라의 중앙은행이 발행하는 디지털화폐예요. 우리나라의 원화, 미국의 달러처럼 국가가 가치를 보증하는 법정화폐의 디지털 버전이라고 생각하면 쉬워요. 국가가 관리하기 때문에 안정성이 높다는 장점이 있죠.

가상자산 (Cryptocurrency)

가장 유명한 비트코인, 이더리움 등이 여기에 속해요. 블록체인 기술을 기반으로 발행되고 관리되는데, 중앙 기관의 통제 없이 분산된 시스템으로 운영되는 것이 특징입니다. 발행량이 제한되거나 특정 규칙에 따라 발행되는 경우가 많고, 가격 변동성이 클 수 있어요.

전자화폐 (Electronic Money)

우리가 체크카드, 신용카드, 선불 충전식 카드 등으로 사용하는 디지털 형태의 화폐를 말해요. 이미 존재하는 법정화폐를 전자적인 형태로 옮겨 놓은 것으로, 은행이나 금융기관이 발행하고 관리합니다. 디지털화폐의 초기 형태라고 볼 수 있어요.

이 세 가지는 발행 주체, 기술 방식, 가치 변동성 등에서 차이가 있어요. 아래 표로 간단하게 비교해 볼까요?

구분 발행 주체 기술 기반 가치 변동성
CBDC 중앙은행 다양 (블록체인 등) 낮음 (법정화폐 가치)
가상자산 탈중앙화된 개인/그룹 블록체인 높음
전자화폐 은행/금융기관 기존 전자 금융 시스템 낮음 (법정화폐 가치)
⚠️ 주의하세요!
가상자산은 가격 변동성이 매우 크기 때문에 투자 시 신중해야 합니다. 반면 CBDC는 국가가 가치를 보장하므로 안정성이 높다는 점을 꼭 기억하세요!

 

디지털화폐가 가져올 변화와 우리 삶 👩‍💼👨‍💻

디지털화폐는 단순히 결제 방식을 바꾸는 것을 넘어 우리 경제와 금융 시스템 전반에 큰 변화를 가져올 수 있어요. 제가 생각하는 몇 가지 주요 변화는 이렇습니다.

  • 거래의 효율성 증대: 24시간 365일 언제든지 빠르고 저렴하게 거래할 수 있게 됩니다. 특히 국가 간 거래에서 발생하는 시간과 비용이 크게 줄어들 수 있죠.
  • 금융 포용성 확대: 은행 계좌가 없는 사람들도 디지털화폐를 통해 금융 서비스에 더 쉽게 접근할 수 있게 될 가능성이 있습니다.
  • 새로운 비즈니스 모델 등장: 스마트 계약(Smart Contract)과 같은 기술이 디지털화폐와 결합하면 자동화된 금융 거래나 서비스가 가능해져요.
  • 통화 정책의 변화: 중앙은행이 CBDC를 통해 통화 공급을 더 직접적으로 관리하고, 필요에 따라 정밀한 정책을 펼칠 수 있습니다.

물론 아직 해결해야 할 과제도 많아요. 보안 문제, 개인 정보 보호, 그리고 모든 사람이 디지털 환경에 익숙해지도록 하는 디지털 격차 해소 등은 중요한 숙제입니다. 하지만 전반적으로 디지털화폐는 금융 시스템을 더욱 효율적이고 접근 가능하게 만들 잠재력을 가지고 있다고 봐요.

 

한국의 디지털화폐, 어디까지 왔을까? 🇰🇷

그럼 우리나라의 디지털화폐 추진 상황은 어떨까요? 한국은행은 CBDC 도입에 대해 꾸준히 연구하고 실험하고 있어요. 얼마 전에는 금융기관들과 함께 CBDC 활용성 테스트도 진행했답니다.

💡 알아두세요!
한국은행의 CBDC 실험은 당장 현금을 대체하겠다는 의미는 아니에요. 미래 금융 환경 변화에 대비하고, 기술적, 제도적 기반을 미리 준비하려는 목적이 크답니다.

금융위원회에서도 토큰증권(STO) 발행 및 유통 규제 체계를 마련하는 등 디지털 자산 시장의 건전한 발전을 위한 노력을 계속하고 있습니다. 디지털화폐와 관련된 법적, 제도

반응형
반응형
SMALL

나라장터 정보통신공사 입찰/낙찰 정보 분석 시스템 

주식회사 진주정보통신을 위한 나라장터 정보통신공사 입찰 및 낙찰 정보를 효율적으로 조회하고 분석하며, 관련 업체에 메시지를 발송할 수 있는 웹 기반 시스템입니다. 제공된 HTML 구조를 기반으로, 실제 서비스 키를 이용한 API 연동(클라이언트 측 HTML 제약으로 인해 데모 데이터로 대체)과 검색 조건별 데이터 필터링, 결과 표/차트 시각화, 상세 정보 보기, 선택 업체 대상 메시지 발송 기능을 구현했습니다.

주요 기능:

  1. API 인증: 나라장터 API 서비스 키를 입력하여 시스템 사용 권한을 인증합니다. (데모 버전에서는 키 입력 시 기능 활성화)
  2. 상세 검색:
    • 입찰 유형 (입찰공고/낙찰공고/전체) 선택
    • 공고일 기준 기간 설정
    • 지역별 검색 (시/도 단위)
    • 금액 범위 설정
    • 키워드 검색 (공고명, 발주처 등에 포함되는 키워드, AND/OR 조건 선택 가능)
  3. 검색 결과 조회:
    • 검색 조건에 맞는 정보통신공사 관련 입찰/낙찰 목록 표시
    • 결과 테이블 보기 (페이징 처리 포함)
    • 적용된 필터 조건 태그 표시 및 제거 기능
  4. 데이터 분석 및 시각화:
    • 검색 결과를 바탕으로 지역별, 월별, 금액대별, 발주처별 공고 현황 차트 제공
    • 시각적인 데이터 분석을 통해 시장 트렌드 파악 용이
  5. 상세 정보:
    • 테이블에서 특정 공고 선택 시 해당 공고의 상세 정보 표시
    • (데모 버전에서는 제한적인 정보 표시)
  6. 메시지 발송:
    • 검색 결과 테이블에서 특정 업체를 선택 (체크박스)
    • 선택된 업체 목록 확인
    • 미리 정의된 메시지 템플릿 선택 또는 사용자 정의 메시지 작성
    • 선택된 업체들에게 메시지 발송 기능 (데모 버전에서는 발송 시뮬레이션)
  7. 데이터 내보내기: 검색된 전체 결과를 Excel 파일로 다운로드 기능

기술 스택:

  • 프론트엔드: HTML5, CSS3, JavaScript (ES6+)
  • 라이브러리:
    • Chart.js: 데이터 시각화 (차트)
    • PapaParse: CSV/텍스트 데이터 파싱 (예정 또는 향후 확장 고려)
    • XLSX: Excel 파일 생성 및 다운로드
    • Lodash: JavaScript 유틸리티 (데이터 처리 편의성 향상)

개선 및 추가 구현 내용:

  • 데모 데이터 로직 강화: 실제 나라장터 API 구조를 모방하여 검색 조건에 따른 데모 데이터를 더 현실적으로 생성하도록 수정했습니다. (지역, 날짜, 키워드, 금액 필터 적용)
  • 테이블 기능:
    • 체크박스를 이용한 다중 선택 및 전체 선택 기능 추가
    • 선택된 항목의 낙찰 업체 정보를 추출하여 메시지 발송 대상 목록에 반영
    • 페이징 기능 구현
    • 행 클릭 시 상세 정보 탭으로 이동 및 내용 표시 기능 추가
  • 차트 기능: 검색 결과 데이터를 분석하여 Chart.js로 다양한 차트를 생성하는 로직 추가했습니다. (지역별, 월별, 금액대별, 발주처별)
  • 필터 태그: 현재 적용된 검색 조건을 시각적으로 보여주고 쉽게 제거할 수 있도록 필터 태그 기능을 추가했습니다.
  • 메시지 발송 대상 관리: 선택된 업체 목록을 표시하고, 개별 제거 기능을 추가했습니다.
  • 메시지 템플릿: 기본적인 메시지 템플릿을 구현하고, 템플릿 내용을 메시지 내용 영역에 적용할 수 있도록 했습니다. 실제 변수 치환({업체명}, {공고명})은 발송 시뮬레이션 시 이루어집니다.
  • 로딩 인디케이터: 데이터 검색 중 로딩 상태를 사용자에게 알리는 기능을 추가했습니다.
  • UI/UX 개선: 전반적인 스타일 및 인터랙션을 개선하여 사용 편의성을 높였습니다.

실행 방법:

  1. 아래 제공된 전체 HTML 코드를 index.html과 같은 이름으로 저장합니다.
  2. 웹 브라우저(Chrome, Edge 등)로 index.html 파일을 엽니다.
  3. 초기 화면에서 나라장터 API 서비스 키를 입력하고 '인증하기' 버튼을 누릅니다. (데모에서는 아무 키나 입력해도 메인 화면으로 전환됩니다.)
  4. 검색 조건을 설정하고 '검색하기' 버튼을 눌러 결과를 확인합니다.
  5. 결과 테이블, 차트, 상세 정보를 확인하고 필요한 경우 Excel로 다운로드합니다.
  6. 메시지를 보낼 업체를 선택한 후 메시지 내용을 작성하여 발송 시뮬레이션을 진행합니다.

참고:

  • 이 코드는 클라이언트 측 HTML, CSS, JavaScript만으로 구성되어 있어 실제 나라장터 API와의 연동이나 메시지 전송 기능은 포함하고 있지 않습니다. 실제 API 연동 및 메시지 전송은 서버 측 개발이 필요합니다.
  • 제공되는 데이터는 데모 목적으로 무작위 생성된 데이터이며, 실제 나라장터 정보와 다릅니다.
  • 나라장터 API 사용 시 서비스 키 관리 및 호출 정책 준수가 필요합니다.

HTML
 
<!DOCTYPE html>
<html lang="ko">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>나라장터 정보통신공사 입찰/낙찰 정보 분석 시스템</title>
    <script src="https://cdnjs.cloudflare.com/ajax/libs/Chart.js/3.9.1/chart.min.js"></script>
    <script src="https://cdnjs.cloudflare.com/ajax/libs/PapaParse/5.3.2/papaparse.min.js"></script>
    <script src="https://cdnjs.cloudflare.com/ajax/libs/xlsx/0.18.5/xlsx.full.min.js"></script>
    <script src="https://cdnjs.cloudflare.com/ajax/libs/lodash.js/4.17.21/lodash.min.js"></script>
    <style>
        * {
            box-sizing: border-box;
            margin: 0;
            padding: 0;
            font-family: 'Malgun Gothic', sans-serif;
        }
        body {
            background-color: #f5f5f5;
            color: #333;
            line-height: 1.6;
        }
        .container {
            max-width: 1200px;
            margin: 0 auto;
            padding: 20px;
        }
        header {
            background-color: #1e3a8a;
            color: white;
            padding: 20px 0;
            margin-bottom: 30px;
            border-radius: 8px;
            box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
        }
        header h1 {
            text-align: center;
            font-size: 28px;
        }
        .company-info {
            text-align: center;
            margin-bottom: 10px;
            font-size: 18px;
            color: #f0f0f0;
        }
        .search-section, .results-section, .message-section, .login-section {
            background-color: white;
            padding: 20px;
            border-radius: 8px;
            box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
            margin-bottom: 30px;
        }
        .search-title {
            font-size: 20px;
            margin-bottom: 15px;
            color: #1e3a8a;
            border-bottom: 2px solid #1e3a8a;
            padding-bottom: 10px;
        }
        .search-form {
            display: grid;
            grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
            gap: 15px;
        }
        .form-group {
            margin-bottom: 15px;
        }
        .form-group label {
            display: block;
            margin-bottom: 5px;
            font-weight: bold;
        }
        .form-group input, .form-group select, .form-group textarea {
            width: 100%;
            padding: 8px 10px;
            border: 1px solid #ddd;
            border-radius: 4px;
            font-size: 14px;
        }
        .btn {
            background-color: #1e3a8a;
            color: white;
            border: none;
            padding: 10px 20px;
            border-radius: 4px;
            cursor: pointer;
            font-size: 16px;
            transition: background-color 0.3s;
        }
        .btn:hover {
            background-color: #1e40af;
        }
        .search-buttons {
            display: flex;
            justify-content: center;
            gap: 15px;
            margin-top: 20px;
            grid-column: 1 / -1; /* Span across all columns in grid */
        }
        .results-title {
            font-size: 20px;
            margin-bottom: 15px;
            color: #1e3a8a;
            border-bottom: 2px solid #1e3a8a;
            padding-bottom: 10px;
            display: flex;
            justify-content: space-between;
            align-items: center;
        }
        .export-btn {
            background-color: #10b981;
            font-size: 14px;
            padding: 6px 12px;
        }
        .table-container {
            overflow-x: auto;
            margin-bottom: 20px;
        }
        table {
            width: 100%;
            border-collapse: collapse;
            margin-bottom: 20px;
        }
        th, td {
            padding: 12px 15px;
            text-align: left;
            border-bottom: 1px solid #ddd;
            font-size: 14px;
        }
        th {
            background-color: #f2f2f2;
            font-weight: bold;
        }
        tr:hover {
            background-color: #f5f5f5;
        }
        .clickable-row {
             cursor: pointer;
        }
        .chart-container {
            display: grid;
            grid-template-columns: repeat(auto-fit, minmax(400px, 1fr)); /* Adjusted minmax for smaller screens */
            gap: 20px;
            margin-bottom: 30px;
        }
        .chart-box {
            background-color: white;
            padding: 20px;
            border-radius: 8px;
            box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
        }
        .chart-title {
            font-size: 18px;
            margin-bottom: 15px;
            color: #1e3a8a;
            text-align: center;
        }
        .message-section h3 {
            margin-bottom: 10px;
            color: #1e3a8a;
        }
        .message-form {
            display: flex;
            flex-direction: column;
            gap: 15px;
        }
        .message-template {
            background-color: #f9f9f9;
            padding: 15px;
            border-radius: 4px;
            border-left: 4px solid #1e3a8a;
        }
         .message-template p {
             margin-bottom: 5px;
             font-size: 14px;
         }
         .message-template p:last-child {
             margin-bottom: 0;
         }
        .message-actions {
            display: flex;
            justify-content: flex-end; /* Align buttons to the right */
            gap: 15px;
            margin-top: 20px;
        }
         .message-actions .btn {
             padding: 8px 15px;
             font-size: 15px;
         }
        textarea {
            width: 100%;
            height: 150px;
            padding: 10px;
            border: 1px solid #ddd;
            border-radius: 4px;
            resize: vertical;
            font-size: 14px;
        }
        .selected-companies {
            margin-top: 20px;
            border-top: 1px solid #eee;
            padding-top: 15px;
        }
        .company-list {
            display: flex;
            flex-wrap: wrap;
            gap: 10px;
            margin-top: 10px;
            min-height: 30px; /* Ensure space even when empty */
        }
        .company-tag {
            background-color: #e0e7ff;
            color: #1e3a8a;
            padding: 5px 10px;
            border-radius: 15px; /* Pill shape */
            display: flex;
            align-items: center;
            gap: 5px;
            font-size: 14px;
        }
        .remove-tag {
            cursor: pointer;
            color: #4338ca;
            font-weight: bold;
            font-size: 12px;
            margin-left: 5px;
        }
        .loading {
            display: none;
            text-align: center;
            padding: 20px;
        }
        .loading-spinner {
            border: 4px solid #f3f3f3;
            border-top: 4px solid #1e3a8a;
            border-radius: 50%;
            width: 30px;
            height: 30px;
            animation: spin 1s linear infinite;
            margin: 0 auto 10px;
        }
        @keyframes spin {
            0% { transform: rotate(0deg); }
            100% { transform: rotate(360deg); }
        }
        .tab-container {
            margin-bottom: 20px;
        }
        .tabs {
            display: flex;
            list-style: none;
            margin-bottom: 0;
            border-bottom: 2px solid #1e3a8a;
        }
        .tab {
            padding: 10px 20px;
            cursor: pointer;
            background-color: #f0f0f0;
            border-radius: 5px 5px 0 0;
            margin-right: 5px;
            transition: background-color 0.3s;
        }
        .tab:hover {
             background-color: #e0e0e0;
        }
        .tab.active {
            background-color: #1e3a8a;
            color: white;
        }
        .tab-content {
            display: none;
            padding: 20px;
            background-color: white;
            border-radius: 0 0 5px 5px;
        }
        .tab-content.active {
            display: block;
        }
        .pagination {
            display: flex;
            justify-content: center;
            margin-top: 20px;
            gap: 10px;
        }
        .pagination button {
            padding: 5px 10px;
            background-color: #f0f0f0;
            border: 1px solid #ddd;
            border-radius: 4px;
            cursor: pointer;
        }
        .pagination button:hover:not(.active) {
             background-color: #e0e0e0;
        }
        .pagination button.active {
            background-color: #1e3a8a;
            color: white;
            font-weight: bold;
        }
        .filter-tags {
            display: flex;
            flex-wrap: wrap;
            gap: 10px;
            margin-top: 15px;
            margin-bottom: 15px;
            padding-bottom: 10px;
            border-bottom: 1px solid #eee;
        }
        .filter-tag {
            background-color: #e0e7ff;
            color: #1e3a8a;
            padding: 5px 10px;
            border-radius: 15px; /* Pill shape */
            display: flex;
            align-items: center;
            gap: 5px;
            font-size: 14px;
        }
        .filter-tag .remove-tag {
            color: #4338ca;
        }
        .alert {
            padding: 10px 15px;
            border-radius: 4px;
            margin-bottom: 15px;
            font-size: 14px;
        }
        .alert-success {
            background-color: #d1fae5;
            color: #065f46;
            border: 1px solid #6ee7b7;
        }
        .alert-error {
            background-color: #fee2e2;
            color: #b91c1c;
            border: 1px solid #fca5a5;
        }
         .alert-info {
             background-color: #e0f2fe;
             color: #0369a1;
             border: 1px solid #7dd3fc;
         }
        .checkbox-group {
            display: flex;
            align-items: center;
            gap: 10px;
            margin-top: 5px; /* Less margin than form-group */
            font-size: 14px;
        }
        .checkbox-group input {
            width: auto;
            margin: 0; /* Reset default input margin */
        }
        .login-section {
            text-align: center;
            margin-bottom: 30px;
            padding: 40px 20px; /* More padding for login box */
        }
        .login-form {
            max-width: 400px;
            margin: 0 auto;
            background-color: white;
            padding: 30px; /* More padding */
            border-radius: 8px;
            box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
        }
         .login-form .form-group {
             margin-bottom: 20px; /* More space in login form */
         }
        .company-details {
            margin-top: 20px;
            padding: 20px; /* More padding */
            background-color: #f0f7ff;
            border-radius: 5px;
            border-left: 4px solid #1e3a8a;
            font-size: 14px;
        }
         .company-details h3 {
             margin-bottom: 10px;
             color: #1e3a8a;
         }
         .company-details p {
             margin-bottom: 10px;
         }
        .contact-info {
            display: grid;
            grid-template-columns: repeat(auto-fit, minmax(250px, 1fr)); /* Adjusted for contact items */
            gap: 15px;
            margin-top: 15px;
        }
        .contact-item {
            display: flex;
            align-items: center;
            gap: 10px;
        }
        .contact-icon {
            font-size: 20px;
            color: #1e3a8a;
        }
        #bidDetail {
            min-height: 100px; /* Ensure detail area has some height */
        }
        #bidDetail p {
            margin-bottom: 8px;
        }
        #bidDetail strong {
            color: #1e3a8a;
        }

    </style>
</head>
<body>
    <div class="container">
        <header>
            <div class="company-info">주식회사 진주정보통신</div>
            <h1>나라장터 정보통신공사 입찰/낙찰 정보 분석 시스템</h1>
        </header>

        <div class="login-section" id="loginSection">
            <div class="login-form">
                <h2 class="search-title">나라장터 API 인증</h2>
                <p class="alert alert-info">나라장터 OpenAPI 서비스 키를 입력해주세요. (데모 버전에서는 어떤 키든 입력 시 시스템이 활성화됩니다.)</p>
                <div class="form-group">
                    <label for="serviceKey">서비스 키</label>
                    <input type="password" id="serviceKey" placeholder="나라장터 API 서비스 키를 입력하세요">
                </div>
                <button class="btn" id="loginBtn">인증하기</button>
            </div>
        </div>

        <div id="mainContent" style="display: none;">
            <div class="search-section">
                <h2 class="search-title">검색 조건</h2>
                <div class="search-form">
                    <div class="form-group">
                        <label for="bidType">입찰 유형</label>
                        <select id="bidType">
                            <option value="bid" selected>입찰공고</option>
                            <option value="award">낙찰공고</option>
                             <option value="all">전체 (데모에서는 입찰/낙찰 구분 없이 생성)</option>
                        </select>
                    </div>
                    <div class="form-group">
                        <label for="startDate">시작일</label>
                        <input type="date" id="startDate">
                    </div>
                    <div class="form-group">
                        <label for="endDate">종료일</label>
                        <input type="date" id="endDate">
                    </div>
                    <div class="form-group">
                        <label for="region">지역</label>
                        <select id="region">
                            <option value="all">전체</option>
                            <option value="서울">서울</option>
                            <option value="경기">경기</option>
                            <option value="인천">인천</option>
                            <option value="부산">부산</option>
                            <option value="대구">대구</option>
                            <option value="광주">광주</option>
                            <option value="대전">대전</option>
                            <option value="울산">울산</option>
                            <option value="세종">세종</option>
                            <option value="강원">강원</option>
                            <option value="충북">충북</option>
                            <option value="충남">충남</option>
                            <option value="전북">전북</option>
                            <option value="전남">전남</option>
                            <option value="경북">경북</option>
                            <option value="경남">경남</option>
                            <option value="제주">제주</option>
                        </select>
                    </div>
                    <div class="form-group">
                        <label for="minAmount">최소 금액 (만원)</label>
                        <input type="number" id="minAmount" placeholder="최소 금액">
                    </div>
                    <div class="form-group">
                        <label for="maxAmount">최대 금액 (만원)</label>
                        <input type="number" id="maxAmount" placeholder="최대 금액">
                    </div>
                     <div class="form-group" style="grid-column: 1 / -1;">
                        <label for="keyword">검색 키워드</label>
                        <input type="text" id="keyword" placeholder="예: 정보통신공사, 통신망 구축 (쉼표로 구분)">
                         <div class="checkbox-group">
                            <input type="checkbox" id="keywordAnd" checked>
                            <label for="keywordAnd">모든 키워드 포함 (AND 검색)</label>
                        </div>
                    </div>
                </div>
                <div class="search-buttons">
                    <button class="btn" id="searchBtn">검색하기</button>
                    <button class="btn" id="resetBtn" style="background-color: #6b7280;">초기화</button>
                </div>
            </div>

            <div class="loading" id="loadingIndicator">
                <div class="loading-spinner"></div>
                <p>데이터를 불러오는 중입니다...</p>
            </div>

            <div class="results-section" id="resultsSection" style="display: none;">
                <div class="results-title">
                    <h2>검색 결과 (<span id="resultCount">0</span>건)</h2>
                    <button class="btn export-btn" id="exportExcelBtn">Excel 다운로드</button>
                </div>

                <div class="filter-tags" id="filterTags">
                    </div>

                <div class="tab-container">
                    <ul class="tabs">
                        <li class="tab active" data-tab="tab-table">표 보기</li>
                        <li class="tab" data-tab="tab-chart">차트 보기</li>
                        <li class="tab" data-tab="tab-detail">상세 정보</li>
                    </ul>

                    <div id="tab-table" class="tab-content active">
                        <div class="table-container">
                            <table id="resultsTable">
                                <thead>
                                    <tr>
                                        <th><input type="checkbox" id="selectAll"></th>
                                        <th>번호</th>
                                        <th>공고일</th>
                                        <th>공고명</th>
                                        <th>발주처</th>
                                        <th>지역</th>
                                        <th>예가/낙찰금액(만원)</th>
                                        <th>마감일</th>
                                        <th>낙찰업체</th>
                                        <th>낙찰률(%)</th>
                                    </tr>
                                </thead>
                                <tbody id="resultsBody">
                                    </tbody>
                            </table>
                        </div>
                        <div class="pagination" id="pagination">
                            </div>
                    </div>

                    <div id="tab-chart" class="tab-content">
                        <div class="chart-container">
                            <div class="chart-box">
                                <div class="chart-title">지역별 공고 건수</div>
                                <canvas id="regionChart"></canvas>
                            </div>
                            <div class="chart-box">
                                <div class="chart-title">월별 공고 건수</div>
                                <canvas id="monthlyChart"></canvas>
                            </div>
                            <div class="chart-box">
                                <div class="chart-title">금액대별 분포</div>
                                <canvas id="amountChart"></canvas>
                            </div>
                            <div class="chart-box">
                                <div class="chart-title">발주처 TOP 10</div>
                                <canvas id="orgChart"></canvas>
                            </div>
                        </div>
                    </div>

                    <div id="tab-detail" class="tab-content">
                         <div class="alert alert-info" id="detailPlaceholder">상세 정보를 보려면 표에서 항목을 클릭하세요.</div>
                        <div id="bidDetail" style="display: none;">
                            </div>
                    </div>
                </div>
            </div>

            <div class="message-section">
                <h2 class="message-title">메시지 발송</h2>
                <div class="selected-companies">
                    <h3>선택된 업체 (<span id="selectedCount">0</span>개)</h3>
                    <div class="company-list" id="selectedCompanies">
                        <p class="alert alert-info">업체를 선택하려면 표에서 항목을 체크하세요.</p>
                    </div>
                </div>

                <div class="message-form">
                    <div class="form-group">
                        <label for="messageType">메시지 유형</label>
                        <select id="messageType">
                            <option value="congrats">낙찰 축하 메시지</option>
                            <option value="promotion">회사 홍보 메시지</option>
                            <option value="custom">사용자 정의 메시지</option>
                        </select>
                    </div>

                    <div class="message-template" id="messageTemplate">
                        </div>

                    <div class="form-group">
                        <label for="messageContent">메시지 내용</label>
                        <textarea id="messageContent" placeholder="발송할 메시지 내용을 입력하세요.&#10;'{업체명}', '{공고명}'과 같은 변수는 실제 값으로 자동 치환됩니다."></textarea>
                    </div>

                    <div class="message-actions">
                        <button class="btn" id="applyTemplateBtn">템플릿 적용</button>
                        <button class="btn" id="sendMessageBtn">메시지 발송 (시뮬레이션)</button>
                    </div>
                </div>
            </div>

            <div class="company-details">
                <h3>주식회사 진주정보통신</h3>
                <p>정보통신공사 전문 업체로, 고품질 통신 인프라 구축 서비스를 제공합니다. 다양한 규모의 프로젝트 경험과 최신 기술력을 바탕으로 고객 만족을 최우선으로 합니다.</p>
                <div class="contact-info">
                    <div class="contact-item">
                        <span class="contact-icon">📞</span>
                        <span>전화: 055-123-4567</span>
                    </div>
                    <div class="contact-item">
                        <span class="contact-icon">📧</span>
                        <span>이메일: info@jinjucomm.co.kr</span>
                    </div>
                    <div class="contact-item">
                        <span class="contact-icon">🏢</span>
                        <span>주소: 경상남도 진주시 통신로 123</span>
                    </div>
                    <div class="contact-item">
                        <span class="contact-icon">🌐</span>
                        <span>웹사이트: www.jinjucomm.co.kr (가상)</span>
                    </div>
                </div>
            </div>
        </div>
    </div>

    <script>
        // 현재 날짜 설정 및 페이지 초기화
        document.addEventListener('DOMContentLoaded', function() {
            const today = new Date();
            const oneMonthAgo = new Date();
            oneMonthAgo.setMonth(today.getMonth() - 1);

            const formatDate = (date) => {
                const d = new Date(date);
                let month = '' + (d.getMonth() + 1);
                let day = '' + d.getDate();
                const year = d.getFullYear();
                if (month.length < 2) month = '0' + month;
                if (day.length < 2) day = '0' + day;
                return [year, month, day].join('-');
            };

            document.getElementById('startDate').value = formatDate(oneMonthAgo);
            document.getElementById('endDate').value = formatDate(today);

            // 로그인 버튼 이벤트
            document.getElementById('loginBtn').addEventListener('click', function() {
                const serviceKey = document.getElementById('serviceKey').value.trim();
                if (!serviceKey) {
                    alert('서비스 키를 입력해주세요.');
                    return;
                }

                // 실제로는 API 인증을 확인해야 하지만, 데모에서는 키 입력만으로 진행
                localStorage.setItem('naraServiceKey', serviceKey);
                document.getElementById('loginSection').style.display = 'none';
                document.getElementById('mainContent').style.display = 'block';
                // 초기 메시지 템플릿 로드
                updateMessageTemplate();
            });

            // 이미 인증 정보가 있는 경우 자동 로그인
            const savedKey = localStorage.getItem('naraServiceKey');
            if (savedKey) {
                document.getElementById('serviceKey').value = savedKey;
                document.getElementById('loginSection').style.display = 'none';
                document.getElementById('mainContent').style.display = 'block';
                 // 초기 메시지 템플릿 로드
                updateMessageTemplate();
            }

            // 탭 전환 이벤트
            document.querySelectorAll('.tab').forEach(tab => {
                tab.addEventListener('click', function() {
                    document.querySelectorAll('.tab').forEach(t => t.classList.remove('active'));
                    document.querySelectorAll('.tab-content').forEach(t => t.classList.remove('active'));

                    this.classList.add('active');
                    document.getElementById(this.dataset.tab).classList.add('active');
                });
            });

            // 검색 버튼 이벤트
            document.getElementById('searchBtn').addEventListener('click', searchData);

            // 초기화 버튼 이벤트
            document.getElementById('resetBtn').addEventListener('click', function() {
                document.getElementById('startDate').value = formatDate(oneMonthAgo);
                document.getElementById('endDate').value = formatDate(today);
                document.getElementById('region').value = 'all';
                document.getElementById('bidType').value = 'bid'; // Reset to default bid type
                document.getElementById('minAmount').value = '';
                document.getElementById('maxAmount').value = '';
                document.getElementById('keyword').value = '';
                document.getElementById('keywordAnd').checked = true;

                // 초기화 시 결과 및 차트, 선택 업체 정보도 리셋
                state.data = [];
                state.currentPage = 1;
                state.selectedItems.clear();
                displayResults(); // 빈 테이블 표시
                updateFilterTags(); // 필터 태그 초기화
                updateSelectedCompanies(); // 선택 업체 초기화
                document.getElementById('resultsSection').style.display = 'none'; // 결과 섹션 숨김
                document.getElementById('detailPlaceholder').style.display = 'block'; // 상세 정보 초기 메시지
                document.getElementById('bidDetail').style.display = 'none';
                if (window.regionChart) window.regionChart.destroy();
                if (window.monthlyChart) window.monthlyChart.destroy();
                if (window.amountChart) window.amountChart.destroy();
                if (window.orgChart) window.orgChart.destroy();
            });

            // 엑셀 다운로드 버튼 이벤트
            document.getElementById('exportExcelBtn').addEventListener('click', exportToExcel);

            // 전체 선택 체크박스 이벤트 (동적으로 생성될 수 있으므로 이벤트 위임 사용)
            document.getElementById('resultsTable').addEventListener('change', function(event) {
                 if (event.target.id === 'selectAll') {
                    const isChecked = event.target.checked;
                    const checkboxes = document.querySelectorAll('#resultsBody input[type="checkbox"]');
                    checkboxes.forEach(checkbox => {
                        checkbox.checked = isChecked;
                         const bidNo = checkbox.closest('tr').dataset.bidNo;
                         if (isChecked) {
                             state.selectedItems.add(bidNo);
                         } else {
                             state.selectedItems.delete(bidNo);
                         }
                    });
                     updateSelectedCompanies();
                 } else if (event.target.type === 'checkbox' && event.target.name === 'selectBid') {
                     const bidNo = event.target.closest('tr').dataset.bidNo;
                     if (event.target.checked) {
                         state.selectedItems.add(bidNo);
                     } else {
                         state.selectedItems.delete(bidNo);
                     }
                     updateSelectedCompanies();
                     // Check/uncheck selectAll based on individual checkboxes
                     const allCheckboxes = document.querySelectorAll('#resultsBody input[type="checkbox"][name="selectBid"]');
                     const allSelected = allCheckboxes.length > 0 && Array.from(allCheckboxes).every(cb => cb.checked);
                     document.getElementById('selectAll').checked = allSelected;
                 }
            });

             // 행 클릭 이벤트 (상세 정보 표시) - 이벤트 위임 사용
            document.getElementById('resultsBody').addEventListener('click', function(event) {
                 const row = event.target.closest('tr');
                 if (row && !event.target.matches('input[type="checkbox"]')) { // Ignore clicks on checkboxes
                    const bidNo = row.dataset.bidNo;
                    showDetail(bidNo);
                 }
            });

            // 템플릿 적용 버튼 이벤트
            document.getElementById('applyTemplateBtn').addEventListener('click', applyMessageTemplate);

            // 메시지 유형 변경 이벤트
            document.getElementById('messageType').addEventListener('change', updateMessageTemplate);

            // 메시지 발송 버튼 이벤트
            document.getElementById('sendMessageBtn').addEventListener('click', sendMessages);

             // Filter tag remove event (using event delegation)
            document.getElementById('filterTags').addEventListener('click', function(event) {
                if (event.target.classList.contains('remove-tag')) {
                    const filterType = event.target.dataset.filterType;
                    const filterValue = event.target.dataset.filterValue;
                    removeFilterTag(filterType, filterValue);
                }
            });

             // Selected company tag remove event (using event delegation)
             document.getElementById('selectedCompanies').addEventListener('click', function(event) {
                 if (event.target.classList.contains('remove-tag')) {
                     const bidNoToRemove = event.target.dataset.bidNo;
                     // Uncheck the corresponding checkbox in the table (if visible)
                     const checkbox = document.querySelector(`#resultsBody input[type="checkbox"][data-bid-no="${bidNoToRemove}"]`);
                     if (checkbox) {
                         checkbox.checked = false;
                     }
                     // Remove from selectedItems set
                     state.selectedItems.delete(bidNoToRemove);
                     // Update the displayed list
                     updateSelectedCompanies();
                     // Update the selectAll checkbox state
                     const allCheckboxes = document.querySelectorAll('#resultsBody input[type="checkbox"][name="selectBid"]');
                     const allSelected = allCheckboxes.length > 0 && Array.from(allCheckboxes).every(cb => cb.checked);
                     document.getElementById('selectAll').checked = allSelected;
                 }
             });
        });

        // 전역 상태 관리 객체
        const state = {
            originalData: [], // Store unfiltered data if needed later
            filteredData: [], // Data after applying search filters
            currentPage: 1,
            itemsPerPage: 10,
            selectedItems: new Set(), // Store bidNo of selected items
            charts: {} // Store chart instances
        };

        // 로딩 인디케이터 표시/숨김
        function showLoading(show) {
            document.getElementById('loadingIndicator').style.display = show ? 'block' : 'none';
        }

        // 알림 메시지 표시 (예: 성공/실패)
        function showAlert(message, type = 'info') {
             // 간단한 alert 대신 UI 내부에 표시하는 기능 구현 가능
            alert(message);
        }

        // 검색 함수
        function searchData() {
            showLoading(true);

            const bidType = document.getElementById('bidType').value;
            const startDate = document.getElementById('startDate').value;
            const endDate = document.getElementById('endDate').value;
            const region = document.getElementById('region').value;
            const minAmount = document.getElementById('minAmount').value ? parseInt(document.getElementById('minAmount').value) * 10000 : null; // 만원 단위를 원 단위로 변환
            const maxAmount = document.getElementById('maxAmount').value ? parseInt(document.getElementById('maxAmount').value) * 10000 : null; // 만원 단위를 원 단위로 변환
            const keyword = document.getElementById('keyword').value.trim();
            const keywordAnd = document.getElementById('keywordAnd').checked;

            // 날짜 유효성 검사
            if (new Date(startDate) > new Date(endDate)) {
                showAlert('시작일은 종료일보다 늦을 수 없습니다.', 'error');
                showLoading(false);
                return;
            }

            // 실제 API 호출 대신 데모 데이터 생성 및 필터링
            // generateDemoData 함수는 모든 가능한 데이터를 생성하고,
            // 이 searchData 함수에서 사용자 입력에 따라 필터링합니다.
            // 실제 API 사용 시에는 API 호출 파라미터에 검색 조건을 넣어 호출합니다.
            const allDemoData = generateDemoData(startDate, endDate); // Generate data for the date range

            let filteredData = allDemoData.filter(item => {
                // Bid Type Filter
                if (bidType !== 'all') {
                     if (bidType === 'bid' && item.result !== '입찰공고') return false;
                     if (bidType === 'award' && item.result === '입찰공고') return false; // 낙찰공고만 필터링
                }

                // Region Filter
                if (region !== 'all' && item.areaNm !== region) return false;

                // Amount Filter (use prefairPrice for bids, awardPrice for awards)
                const amount = item.result === '입찰공고' ? item.prefairPrice : item.awardPrice;
                if (minAmount !== null && amount < minAmount) return false;
                if (maxAmount !== null && amount > maxAmount) return false;

                // Keyword Filter (Apply to bidNm and insttNm)
                if (keyword) {
                    const keywords = keyword.split(',').map(k => k.trim()).filter(k => k !== '');
                    const textToSearch = `${item.bidNm} ${item.insttNm}`; // Search in bid name and institution name

                    if (keywordAnd) { // AND 검색
                        if (!keywords.every(k => textToSearch.includes(k))) return false;
                    } else { // OR 검색
                        if (!keywords.some(k => textToSearch.includes(k))) return false;
                    }
                }

                return true;
            });

            // 결과 저장 및 표시
            state.originalData = allDemoData; // Keep original if needed
            state.filteredData = filteredData; // Filtered data for display and charts
            state.currentPage = 1;
            state.selectedItems.clear(); // Clear selection on new search

            document.getElementById('resultCount').textContent = state.filteredData.length;
            document.getElementById('resultsSection').style.display = 'block';
             document.getElementById('detailPlaceholder').style.display = 'block';
             document.getElementById('bidDetail').style.display = 'none';

            updateFilterTags({ bidType, startDate, endDate, region, minAmount, maxAmount, keyword, keywordAnd }); // 필터 태그 업데이트
            displayResults(); // 테이블 표시
            createCharts(); // 차트 생성
            updateSelectedCompanies(); // 선택 업체 초기화

            showLoading(false);
        }

         // 적용된 필터 태그 표시
         function updateFilterTags(filters = {}) {
             const container = document.getElementById('filterTags');
             container.innerHTML = ''; // Clear existing tags

             const addTag = (label, value, type, dataValue) => {
                 if (value && value !== 'all' && value !== null) {
                     const tag = document.createElement('div');
                     tag.classList.add('filter-tag');
                     tag.innerHTML = `
                         ${label}: <strong>${value}</strong>
                         <span class="remove-tag" data-filter-type="${type}" data-filter-value="${dataValue || value}">×</span>
                     `;
                     container.appendChild(tag);
                 }
             };

             if (filters.bidType && filters.bidType !== 'all') {
                  const bidTypeLabel = filters.bidType === 'bid' ? '입찰공고' : '낙찰공고';
                 addTag('유형', bidTypeLabel, 'bidType', filters.bidType);
             }
             if (filters.startDate && filters.endDate) {
                 addTag('기간', `${filters.startDate} ~ ${filters.endDate}`, 'dateRange', `${filters.startDate},${filters.endDate}`);
             }
             if (filters.region && filters.region !== 'all') {
                 addTag('지역', filters.region, 'region', filters.region);
             }
             if (filters.minAmount !== null || filters.maxAmount !== null) {
                 let amountRange = '';
                 if (filters.minAmount !== null) amountRange += `${filters.minAmount/10000}만원 이상`;
                 if (filters.minAmount !== null && filters.maxAmount !== null) amountRange += ' ~ ';
                 if (filters.maxAmount !== null) amountRange += `${filters.maxAmount/10000}만원 이하`;
                 addTag('금액', amountRange, 'amountRange', `${filters.minAmount || ''},${filters.maxAmount || ''}`);
             }
             if (filters.keyword) {
                 const keywordType = filters.keywordAnd ? 'AND' : 'OR';
                 addTag('키워드', `${filters.keyword} (${keywordType})`, 'keyword', filters.keyword);
             }

              if (container.innerHTML === '') {
                  container.innerHTML = '<p class="alert alert-info">적용된 검색 필터가 없습니다.</p>';
              } else {
                   const infoTag = container.querySelector('.alert-info');
                   if (infoTag) infoTag.remove(); // Remove info message if tags are added
              }
         }

        // 필터 태그 제거 시 검색 조건 업데이트 및 재검색
        function removeFilterTag(type, value) {
             // Get current search parameters
            const bidType = document.getElementById('bidType');
            const startDate = document.getElementById('startDate');
            const endDate = document.getElementById('endDate');
            const region = document.getElementById('region');
            const minAmount = document.getElementById('minAmount');
            const maxAmount = document.getElementById('maxAmount');
            const keyword = document.getElementById('keyword');
             const keywordAnd = document.getElementById('keywordAnd');


             // Update the corresponding input field
             if (type === 'bidType') bidType.value = 'all';
             else if (type === 'dateRange') {
                  const dates = value.split(',');
                  // Reset to default last month range if removed
                   const today = new Date();
                   const oneMonthAgo = new Date();
                   oneMonthAgo.setMonth(today.getMonth() - 1);
                   const formatDate = (date) => {
                        const d = new Date(date);
                        let month = '' + (d.getMonth() + 1);
                        let day = '' + d.getDate();
                        const year = d.getFullYear();
                        if (month.length < 2) month = '0' + month;
                        if (day.length < 2) day = '0' + day;
                        return [year, month, day].join('-');
                    };
                   startDate.value = formatDate(oneMonthAgo);
                   endDate.value = formatDate(today);

             }
             else if (type === 'region') region.value = 'all';
             else if (type === 'amountRange') {
                 minAmount.value = '';
                 maxAmount.value = '';
             }
             else if (type === 'keyword') {
                 keyword.value = '';
                 keywordAnd.checked = true; // Reset keyword search to AND
             }

             // Perform search again with updated criteria
             searchData();
        }


        // 결과 테이블 표시 (페이징 적용)
        function displayResults() {
            const tbody = document.getElementById('resultsBody');
            tbody.innerHTML = ''; // Clear previous results
            const paginationDiv = document.getElementById('pagination');
            paginationDiv.innerHTML = ''; // Clear previous pagination

            const data = state.filteredData;
            const itemsPerPage = state.itemsPerPage;
            const currentPage = state.currentPage;
            const totalPages = Math.ceil(data.length / itemsPerPage);

            if (data.length === 0) {
                tbody.innerHTML = '<tr><td colspan="10" style="text-align: center;">검색 결과가 없습니다.</td></tr>';
                 document.getElementById('resultCount').textContent = 0;
                 document.getElementById('exportExcelBtn').style.display = 'none'; // Hide export button
                 return;
            }

             document.getElementById('exportExcelBtn').style.display = 'inline-block'; // Show export button

            const startIndex = (currentPage - 1) * itemsPerPage;
            const endIndex = Math.min(startIndex + itemsPerPage, data.length);
            const itemsToShow = data.slice(startIndex, endIndex);

            itemsToShow.forEach((item, index) => {
                const row = document.createElement('tr');
                row.classList.add('clickable-row');
                row.dataset.bidNo = item.bidNo; // Store bidNo for detail view
                row.innerHTML = `
                    <td><input type="checkbox" name="selectBid" data-bid-no="${item.bidNo}" ${state.selectedItems.has(item.bidNo) ? 'checked' : ''}></td>
                    <td>${startIndex + index + 1}</td>
                    <td>${item.openDt}</td>
                    <td>${item.bidNm}</td>
                    <td>${item.insttNm}</td>
                    <td>${item.areaNm}</td>
                    <td>${item.result === '입찰공고' ? (item.prefairPrice / 10000).toLocaleString() : (item.awardPrice / 10000).toLocaleString()}</td>
                    <td>${item.closeDt || '-'}</td>
                    <td>${item.result === '낙찰' && item.prtcpntList && item.prtcpntList.length > 0 ? item.prtcpntList.find(p => p.result === '낙찰')?.cmpnyNm || '-' : '-'}</td>
                    <td>${item.result === '낙찰' && item.prtcpntList && item.prtcpntList.length > 0 ? (item.prtcpntList.find(p => p.result === '낙찰')?.bidRate || '-').toFixed(2) : '-'}</td>
                `;
                tbody.appendChild(row);
            });

            // Pagination controls
            if (totalPages > 1) {
                const createButton = (page, text, isActive = false) => {
                    const button = document.createElement('button');
                    button.textContent = text;
                    button.disabled = (page < 1 || page > totalPages || page === currentPage);
                    if (isActive) button.classList.add('active');
                    button.addEventListener('click', () => {
                        state.currentPage = page;
                        displayResults(); // Re-render table for new page
                    });
                    return button;
                };

                paginationDiv.appendChild(createButton(1, '처음'));
                paginationDiv.appendChild(createButton(currentPage - 1, '이전'));

                const startPage = Math.max(1, currentPage - 5);
                const endPage = Math.min(totalPages, currentPage + 4);

                for (let i = startPage; i <= endPage; i++) {
                    paginationDiv.appendChild(createButton(i, i, i === currentPage));
                }

                paginationDiv.appendChild(createButton(currentPage + 1, '다음'));
                paginationDiv.appendChild(createButton(totalPages, '마지막'));
            }
             // Update selectAll checkbox state based on current page's selection
            const allCheckboxes = document.querySelectorAll('#resultsBody input[type="checkbox"][name="selectBid"]');
            const allSelected = allCheckboxes.length > 0 && Array.from(allCheckboxes).every(cb => state.selectedItems.has(cb.dataset.bidNo));
            document.getElementById('selectAll').checked = allSelected;
        }

        // 상세 정보 표시
        function showDetail(bidNo) {
            const detailDiv = document.getElementById('bidDetail');
            const placeholder = document.getElementById('detailPlaceholder');
            const item = state.filteredData.find(d => d.bidNo === bidNo);

            if (!item) {
                 detailDiv.style.display = 'none';
                 placeholder.style.display = 'block';
                 detailDiv.innerHTML = '';
                 return;
            }

             placeholder.style.display = 'none';
             detailDiv.style.display = 'block';

            let detailContent = `
                <h3>공고 상세 정보</h3>
                <p><strong>공고 번호:</strong> ${item.bidNo}</p>
                <p><strong>공고명:</strong> ${item.bidNm}</p>
                <p><strong>발주처:</strong> ${item.insttNm}</p>
                <p><strong>지역:</strong> ${item.areaNm}</p>
                <p><strong>공고일:</strong> ${item.openDt}</p>
                <p><strong>마감일:</strong> ${item.closeDt || '-'}</p>
                <p><strong>공고 금액 (예가):</strong> ${item.prefairPrice ? (item.prefairPrice / 10000).toLocaleString() + '만원' : '-'}</p>
            `;

            if (item.result !== '입찰공고') { // 낙찰 또는 유찰인 경우
                detailContent += `<p><strong>진행 상태:</strong> ${item.result}</p>`;
                if (item.result === '낙찰') {
                    detailContent += `
                        <p><strong>낙찰 금액:</strong> ${item.awardPrice ? (item.awardPrice / 10000).toLocaleString() + '만원' : '-'}</p>
                        <p><strong>낙찰 업체:</strong> ${item.prtcpntList && item.prtcpntList.length > 0 ? item.prtcpntList.find(p => p.result === '낙찰')?.cmpnyNm || '-' : '-'}</p>
                        <p><strong>낙찰률:</strong> ${item.prtcpntList && item.prtcpntList.length > 0 ? (item.prtcpntList.find(p => p.result === '낙찰')?.bidRate || '-').toFixed(2) + '%' : '-'}</p>
                    `;
                    // 참여 업체 목록 (간단히 표시)
                     if (item.prtcpntList && item.prtcpntList.length > 0) {
                          detailContent += `<p><strong>참여 업체:</strong></p><ul>`;
                          item.prtcpntList.forEach(p => {
                               detailContent += `<li>${p.cmpnyNm} (${p.result}${p.bidRate ? ', 낙찰률: ' + p.bidRate.toFixed(2) + '%' : ''})</li>`;
                          });
                          detailContent += `</ul>`;
                     }
                }
            }

            detailContent += `<p><a href="${item.linkUrl}" target="_blank">나라장터 원문 링크</a></p>`; // 원문 링크 추가

            detailDiv.innerHTML = detailContent;

             // Activate the detail tab
             document.querySelectorAll('.tab').forEach(t => t.classList.remove('active'));
             document.querySelectorAll('.tab-content').forEach(t => t.classList.remove('active'));
             document.querySelector('.tab[data-tab="tab-detail"]').classList.add('active');
             document.getElementById('tab-detail').classList.add('active');
        }


        // 차트 생성
        function createCharts() {
            // Destroy previous chart instances to avoid memory leaks
            if (state.charts.regionChart) state.charts.regionChart.destroy();
            if (state.charts.monthlyChart) state.charts.monthlyChart.destroy();
            if (state.charts.amountChart) state.charts.amountChart.destroy();
            if (state.charts.orgChart) state.charts.orgChart.destroy();

            const data = state.filteredData;
            if (data.length === 0) {
                // Clear canvas if no data
                 document.getElementById('regionChart').getContext('2d').clearRect(0, 0, document.getElementById('regionChart').width, document.getElementById('regionChart').height);
                 document.getElementById('monthlyChart').getContext('2d').clearRect(0, 0, document.getElementById('monthlyChart').width, document.getElementById('monthlyChart').height);
                 document.getElementById('amountChart').getContext('2d').clearRect(0, 0, document.getElementById('amountChart').width, document.getElementById('amountChart').height);
                 document.getElementById('orgChart').getContext('2d').clearRect(0, 0, document.getElementById('orgChart').width, document.getElementById('orgChart').height);
                return;
            }

            // Data for charts
            const regionData = _.countBy(data, 'areaNm');
            const monthlyData = _.countBy(data, item => item.openDt.substring(0, 7)); // YYYY-MM format
            const orgData = _.countBy(data, 'insttNm');

            // Sort monthly data by date
            const sortedMonthlyKeys = _.sortBy(_.keys(monthlyData));
            const sortedMonthlyValues = sortedMonthlyKeys.map(key => monthlyData[key]);

             // Sort organization data and get top 10
             const sortedOrg = _.orderBy(_.toPairs(orgData), [1], ['desc']).slice(0, 10);
             const orgLabels = sortedOrg.map(pair => pair[0]);
             const orgValues = sortedOrg.map(pair => pair[1]);

            // Amount ranges (example ranges, adjust as needed)
            const amountRanges = {
                 '~ 1억 미만': [0, 100000000],
                 '1억 ~ 5억 미만': [100000000, 500000000],
                 '5억 ~ 10억 미만': [500000000, 1000000000],
                 '10억 이상': [1000000000, Infinity]
            };
            const amountCounts = _.countBy(data, item => {
                 const amount = item.result === '입찰공고' ? item.prefairPrice : item.awardPrice; // Use relevant amount
                 for (const range in amountRanges) {
                     if (amount >= amountRanges[range][0] && amount < amountRanges[range][1]) {
                         return range;
                     }
                 }
                 return '기타'; // Handle cases outside defined ranges
            });
            const amountLabels = _.keys(amountCounts);
            const amountValues = _.values(amountCounts);


            // Chart configurations
            const commonOptions = {
                responsive: true,
                maintainAspectRatio: false,
                plugins: {
                    legend: { position: 'top' },
                    tooltip: { enabled: true }
                }
            };

            // Region Chart (Bar)
            const regionCtx = document.getElementById('regionChart').getContext('2d');
            state.charts.regionChart = new Chart(regionCtx, {
                type: 'bar',
                data: {
                    labels: _.keys(regionData),
                    datasets: [{
                        label: '공고 건수',
                        data: _.values(regionData),
                        backgroundColor: 'rgba(75, 192, 192, 0.6)',
                        borderColor: 'rgba(75, 192, 192, 1)',
                        borderWidth: 1
                    }]
                },
                options: {
                     ...commonOptions,
                    scales: { y: { beginAtZero: true } }
                }
            });

            // Monthly Chart (Line)
            const monthlyCtx = document.getElementById('monthlyChart').getContext('2d');
            state.charts.monthlyChart = new Chart(monthlyCtx, {
                type: 'line',
                data: {
                    labels: sortedMonthlyKeys,
                    datasets: [{
                        label: '공고 건수',
                        data: sortedMonthlyValues,
                        borderColor: 'rgba(153, 102, 255, 1)',
                        backgroundColor: 'rgba(153, 102, 255, 0.2)',
                        fill: true,
                        tension: 0.1
                    }]
                },
                 options: {
                     ...commonOptions,
                    scales: { y: { beginAtZero: true } }
                }
            });

            // Amount Chart (Pie or Doughnut)
             const amountCtx = document.getElementById('amountChart').getContext('2d');
             state.charts.amountChart = new Chart(amountCtx, {
                 type: 'pie', // Or 'doughnut'
                 data: {
                     labels: amountLabels,
                     datasets: [{
                         data: amountValues,
                         backgroundColor: [
                             'rgba(255, 99, 132, 0.6)',
                             'rgba(54, 162, 235, 0.6)',
                             'rgba(255, 206, 86, 0.6)',
                             'rgba(75, 192, 192, 0.6)',
                             'rgba(153, 102, 255, 0.6)',
                             'rgba(255, 159, 64, 0.6)'
                         ],
                         borderColor: [
                             'rgba(255, 99, 132, 1)',
                             'rgba(54, 162, 235, 1)',
                             'rgba(255, 206, 86, 1)',
                             'rgba(75, 192, 192, 1)',
                             'rgba(153, 102, 255, 1)',
                             'rgba(255, 159, 64, 1)'
                         ],
                         borderWidth: 1
                     }]
                 },
                 options: {
                     ...commonOptions,
                     plugins: {
                         legend: { position: 'right' }, // Adjust legend position
                         tooltip: { enabled: true }
                     }
                 }
             });


            // Organization Chart (Horizontal Bar) - Top 10
            const orgCtx = document.getElementById('orgChart').getContext('2d');
            state.charts.orgChart = new Chart(orgCtx, {
                type: 'bar',
                data: {
                    labels: orgLabels,
                    datasets: [{
                        label: '공고 건수',
                        data: orgValues,
                        backgroundColor: 'rgba(54, 162, 235, 0.6)',
                        borderColor: 'rgba(54, 162, 235, 1)',
                        borderWidth: 1
                    }]
                },
                options: {
                     ...commonOptions,
                    indexAxis: 'y', // Horizontal bar chart
                    scales: { x: { beginAtZero: true } }
                }
            });
        }


        // 선택된 업체 목록 업데이트 및 표시
        function updateSelectedCompanies() {
            const selectedCompaniesDiv = document.getElementById('selectedCompanies');
            selectedCompaniesDiv.innerHTML = ''; // Clear previous list
             const selectedCountSpan = document.getElementById('selectedCount');

            if (state.selectedItems.size === 0) {
                selectedCompaniesDiv.innerHTML = '<p class="alert alert-info">업체를 선택하려면 표에서 항목을 체크하세요.</p>';
                 selectedCountSpan.textContent = 0;
                return;
            }

            selectedCountSpan.textContent = state.selectedItems.size;

            // Get the selected items' data from the filtered data
            const selectedItemsData = state.filteredData.filter(item => state.selectedItems.has(item.bidNo));

             // Sort selected items by company name or other criteria if needed
             // selectedItemsData.sort((a, b) => a.낙찰업체.localeCompare(b.낙찰업체)); // Example sort

            selectedItemsData.forEach(item => {
                const companyTag = document.createElement('div');
                companyTag.classList.add('company-tag');
                 const companyName = item.result === '낙찰' && item.prtcpntList && item.prtcpntList.length > 0 ? item.prtcpntList.find(p => p.result === '낙찰')?.cmpnyNm || '선택된 공고의 낙찰업체 정보 없음' : '선택된 공고 (낙찰업체 정보 없음)';
                companyTag.innerHTML = `
                    ${companyName}
                    <span class="remove-tag" data-bid-no="${item.bidNo}">×</span>
                `;
                selectedCompaniesDiv.appendChild(companyTag);
            });
        }


        // 메시지 템플릿 업데이트
        function updateMessageTemplate() {
            const messageType = document.getElementById('messageType').value;
            const templateDiv = document.getElementById('messageTemplate');
            const textarea = document.getElementById('messageContent');

            let templateContent = '';

            const congratsTemplate = `
                <p><strong>[낙찰 축하 메시지]</strong></p>
                <p>안녕하세요, {업체명} 담당자님!</p>
                <p>정보통신공사 입찰 건 '{공고명}'에 낙찰되신 것을 진심으로 축하드립니다.</p>
                <p>앞으로도 귀사의 무궁한 발전을 기원합니다.</p>
                <p>감사합니다.</p>
                <p>주식회사 진주정보통신 드림</p>
            `;

            const promotionTemplate = `
                 <p><strong>[주식회사 진주정보통신 홍보]</strong></p>
                 <p>안녕하세요, {업체명} 담당자님!</p>
                 <p>저희 주식회사 진주정보통신은 정보통신공사 분야에서 다년간의 경험과 기술력을 보유하고 있습니다.</p>
                 <p>귀사와의 상호 협력을 통해 더 큰 시너지를 창출할 수 있기를 기대합니다.</p>
                 <p>프로젝트 관련 문의사항은 언제든지 연락 부탁드립니다.</p>
                 <p>감사합니다.</p>
                 <p>주식회사 진주정보통신 드림</p>
            `;

            const customTemplate = `
                 <p><strong>[사용자 정의 메시지]</strong></p>
                 <p>메시지 내용을 직접 입력하세요. '{업체명}', '{공고명}' 변수를 사용할 수 있습니다.</p>
            `;

            if (messageType === 'congrats') {
                templateContent = congratsTemplate;
                 textarea.placeholder = "발송할 메시지 내용을 입력하세요.\n'{업체명}', '{공고명}'과 같은 변수는 실제 값으로 자동 치환됩니다.";
            } else if (messageType === 'promotion') {
                templateContent = promotionTemplate;
                 textarea.placeholder = "발송할 메시지 내용을 입력하세요.\n'{업체명}', '{공고명}'과 같은 변수는 실제 값으로 자동 치환됩니다.";
            } else if (messageType === 'custom') {
                templateContent = customTemplate;
                 textarea.placeholder = "사용자 정의 메시지 내용을 입력하세요. '{업체명}', '{공고명}' 변수를 사용할 수 있습니다.";
            }

            templateDiv.innerHTML = templateContent;
             // Clear textarea or keep content based on preference for custom
             if (messageType !== 'custom') {
                  textarea.value = ''; // Clear textarea when switching to a non-custom template
             }
        }

        // 선택된 템플릿 내용을 메시지 내용 영역에 적용
        function applyMessageTemplate() {
             const messageType = document.getElementById('messageType').value;
             const textarea = document.getElementById('messageContent');

             let templateRawContent = '';

             const congratsTemplate = `안녕하세요, {업체명} 담당자님!

정보통신공사 입찰 건 '{공고명}'에 낙찰되신 것을 진심으로 축하드립니다.
앞으로도 귀사의 무궁한 발전을 기원합니다.

감사합니다.

주식회사 진주정보통신 드림
`;

             const promotionTemplate = `안녕하세요, {업체명} 담당자님!

저희 주식회사 진주정보통신은 정보통신공사 분야에서 다년간의 경험과 기술력을 보유하고 있습니다.
귀사와의 상호 협력을 통해 더 큰 시너지를 창출할 수 있기를 기대합니다.
프로젝트 관련 문의사항은 언제든지 연락 부탁드립니다.

감사합니다.

주식회사 진주정보통신 드림
`;

             if (messageType === 'congrats') {
                 templateRawContent = congratsTemplate;
             } else if (messageType === 'promotion') {
                 templateRawContent = promotionTemplate;
             } else if (messageType === 'custom') {
                  // For custom, applying template just gives the placeholder info
                  textarea.value = "메시지 내용을 직접 입력하세요. '{업체명}', '{공고명}' 변수를 사용할 수 있습니다.";
                  return; // Don't overwrite if custom is already being edited
             }

             textarea.value = templateRawContent.trim();
        }


        // 메시지 발송 (시뮬레이션)
        function sendMessages() {
            if (state.selectedItems.size === 0) {
                showAlert('메시지를 발송할 업체를 선택해주세요.', 'info');
                return;
            }

            const messageContent = document.getElementById('messageContent').value.trim();
            if (!messageContent) {
                showAlert('발송할 메시지 내용을 입력해주세요.', 'info');
                return;
            }

            const selectedItemsData = state.filteredData.filter(item => state.selectedItems.has(item.bidNo));

            console.log('--- 메시지 발송 시뮬레이션 시작 ---');

            selectedItemsData.forEach(item => {
                 const companyName = item.result === '낙찰' && item.prtcpntList && item.prtcpntList.length > 0 ? item.prtcpntList.find(p => p.result === '낙찰')?.cmpnyNm || '업체명 정보 없음' : '업체명 정보 없음';
                 const bidName = item.bidNm || '공고명 정보 없음';

                let finalMessage = messageContent
                    .replace(/{업체명}/g, companyName)
                    .replace(/{공고명}/g, bidName);

                console.log(`업체: ${companyName}, 공고: ${bidName}`);
                console.log('발송될 메시지:');
                console.log(finalMessage);
                console.log('---');
            });

            showAlert(`${state.selectedItems.size}개 업체에게 메시지 발송 시뮬레이션이 완료되었습니다. 상세 내용은 콘솔을 확인하세요.`, 'success');
            console.log('--- 메시지 발송 시뮬레이션 종료 ---');

            // Optionally clear selection and message after sending
            // state.selectedItems.clear();
            // updateSelectedCompanies();
            // document.getElementById('messageContent').value = '';
        }

        // Excel 파일 다운로드
        function exportToExcel() {
            if (state.filteredData.length === 0) {
                showAlert('내보낼 데이터가 없습니다.', 'info');
                return;
            }

            // Prepare data for export
            const exportData = state.filteredData.map(item => {
                 const awardedCompany = item.result === '낙찰' && item.prtcpntList && item.prtcpntList.length > 0 ? item.prtcpntList.find(p => p.result === '낙찰')?.cmpnyNm || '-' : '-';
                 const bidRate = item.result === '낙찰' && item.prtcpntList && item.prtcpntList.length > 0 ? (item.prtcpntList.find(p => p.result === '낙찰')?.bidRate || '-').toFixed(2) : '-';
                 const amount = item.result === '입찰공고' ? item.prefairPrice : item.awardPrice;

                return {
                    '공고일': item.openDt,
                    '공고명': item.bidNm,
                    '발주처': item.insttNm,
                    '지역': item.areaNm,
                    '예가/낙찰금액(만원)': amount ? (amount / 10000).toLocaleString() : '-',
                    '마감일': item.closeDt || '-',
                    '낙찰업체': awardedCompany,
                    '낙찰률(%)': bidRate,
                    '공고번호': item.bidNo, // Include bid number for reference
                     '진행상태': item.result,
                     '원문링크': item.linkUrl
                    // Add other relevant fields as needed
                };
            });

            const worksheet = XLSX.utils.json_to_sheet(exportData);
            const workbook = XLSX.utils.book_new();
            XLSX.utils.book_append_sheet(workbook, worksheet, '나라장터정보통신공사');

            // Set column widths (optional)
            const colWidths = [
                 { wch: 12 }, // 공고일
                 { wch: 40 }, // 공고명
                 { wch: 25 }, // 발주처
                 { wch: 8 },  // 지역
                 { wch: 15 }, // 금액
                 { wch: 12 }, // 마감일
                 { wch: 20 }, // 낙찰업체
                 { wch: 10 }, // 낙찰률
                 { wch: 15 }, // 공고번호
                 { wch: 8 },  // 진행상태
                 { wch: 50 }  // 원문링크
            ];
             worksheet['!cols'] = colWidths;


            // Export the file
            XLSX.writeFile(workbook, '나라장터_정보통신공사_입낙찰정보.xlsx');
        }


        // --- 데모 데이터 생성 함수 (실제 API 호출로 대체 필요) ---
        function generateDemoData(startDateStr, endDateStr) {
            const data = [];
            const start = new Date(startDateStr);
            const end = new Date(endDateStr);
            const timeDiff = end - start;
            const diffDays = Math.ceil(timeDiff / (1000 * 60 * 60 * 24));

            const regions = ['서울', '경기', '인천', '부산', '대구', '광주', '대전', '울산', '세종', '강원', '충북', '충남', '전북', '전남', '경북', '경남', '제주'];
            const orgs = ['과학기술정보통신부', '한국지능정보사회진흥원', '한국방송통신전파진흥원', '서울특별시청', '경기도청', '부산광역시청', '국방부', '경찰청', '한국도로공사', '한국철도공사', 'OO대학교', 'XX고등학교', '△△경찰서', '□□소방서'];
            const keywords = ['정보통신공사', '통신망 구축', '네트워크 공사', 'CCTV 설치', '광케이블', '구내통신', '정보화 사업', '시스템 구축'];

            // Helper to get random date within range
            const getRandomDate = (start, end) => {
                 const startDate = new Date(start);
                 const endDate = new Date(end);
                 const timeRange = endDate.getTime() - startDate.getTime();
                 const randomTime = startDate.getTime() + Math.random() * timeRange;
                 const date = new Date(randomTime);
                 return date.toISOString().split('T')[0]; // YYYY-MM-DD format
            };

             // Helper to generate random participants for awards
             const generateParticipants = (prefairPrice) => {
                  const participants = [];
                  const numParticipants = Math.floor(Math.random() * 10) + 2; // 2 to 11 participants
                  const awardCompanyIndex = Math.floor(Math.random() * numParticipants); // Randomly select one winner

                  for (let i = 0; i < numParticipants; i++) {
                      const cmpnyNm = `가상정보통신_${String(1000 + Math.floor(Math.random() * 9000)).padStart(4, '0')}_${i+1}`;
                      const bidRate = 85 + Math.random() * 10; // 낙찰률 85% ~ 95% 사이
                       const awardPrice = prefairPrice * (bidRate / 100);

                      participants.push({
                          cmpnyNm: cmpnyNm,
                          result: i === awardCompanyIndex ? '낙찰' : '유찰',
                           bidRate: i === awardCompanyIndex ? bidRate : null,
                           awardPrice: i === awardCompanyIndex ? awardPrice : null
                      });
                  }
                   // Ensure at least one 낙찰 if it's an award notice
                   if (!participants.some(p => p.result === '낙찰')) {
                        if (participants.length > 0) {
                             participants[0].result = '낙찰';
                              participants[0].bidRate = 85 + Math.random() * 10;
                               participants[0].awardPrice = prefairPrice * (participants[0].bidRate / 100);
                        } else { // Should not happen with numParticipants logic, but fallback
                             participants.push({
                                  cmpnyNm: `가상정보통신_낙찰업체_${String(1000 + Math.floor(Math.random() * 9000)).padStart(4, '0')}`,
                                  result: '낙찰',
                                  bidRate: 85 + Math.random() * 10,
                                   awardPrice: prefairPrice * (85 + Math.random() * 10)/100
                             });
                        }
                   }

                  return participants;
             };


            // Generate a fixed number of demo data entries (e.g., 100 to 300 entries)
             const numberOfEntries = 100 + Math.floor(Math.random() * 201); // Between 100 and 300

            for (let i = 0; i < numberOfEntries; i++) {
                const openDt = getRandomDate(start, end);
                 const isAward = Math.random() < 0.5; // Roughly 50% chance of being an award notice
                 const result = isAward ? (Math.random() < 0.8 ? '낙찰' : '유찰') : '입찰공고'; // 80% chance of 낙찰 if it's an award notice

                const item = {
                    bidNo: `DEMO-${i + 1}-${Math.random().toString(36).substr(2, 5).toUpperCase()}`, // Unique demo ID
                    openDt: openDt,
                    closeDt: result === '입찰공고' ? getRandomDate(new Date(openDt), end) : null, // Close date only for bids
                    insttNm: _.sample(orgs),
                    areaNm: _.sample(regions),
                    prefairPrice: Math.floor(Math.random() * 5000000000) + 10000000, // 1천만원 ~ 50억원 (원 단위)
                     result: result,
                     awardPrice: null,
                     prtcpntList: null, // Participants list only for awards
                     linkUrl: 'https://www.g2b.go.kr' // Placeholder link
                };

                 // Generate bid name based on keywords and type
                 const selectedKeywords = _.sampleSize(keywords, Math.floor(Math.random() * 3) + 1); // 1 to 3 keywords
                 const bidPrefix = result === '입찰공고' ? '나라장터 입찰공고' : (result === '낙찰' ? '나라장터 낙찰결과' : '나라장터 유찰공고');
                 item.bidNm = `${bidPrefix}: ${item.insttNm} ${item.areaNm} ${selectedKeywords.join(' 및 ')} 공사`;


                 // If it's an award notice, generate participants and award price
                 if (result !== '입찰공고') {
                      item.prtcpntList = generateParticipants(item.prefairPrice);
                       // Find the winner's award price if 낙찰
                      if (result === '낙찰') {
                           const winner = item.prtcpntList.find(p => p.result === '낙찰');
                           if (winner) {
                                item.awardPrice = winner.awardPrice;
                           } else {
                                // Fallback if generateParticipants somehow failed to make a winner
                                item.awardPrice = item.prefairPrice * (88 + Math.random()*2)/100; // Simulate a winning price near 88-90%
                           }
                      }
                 }


                data.push(item);
            }

            console.log(`Generated ${data.length} demo entries.`);
            return data;
        }

        // --- End of Demo Data Generation ---

        // 선택된 항목의 bidNo를 관리하는 Set을 업데이트
        // (displayResults 함수 내에서 체크박스 상태 변경 시 호출됨)
        // 이 함수는 이제 직접 호출되지 않고, 이벤트 리스너에서 Set을 관리합니다.
        // updateSelectedCompanies 함수가 Set의 상태를 읽어서 화면에 표시합니다.


    </script>
</body>
</html>

개선된 사항 설명:

  1. 구조 개선: 로그인 섹션과 메인 콘텐츠 섹션을 명확히 분리하고, JavaScript에서 display: none/block으로 전환하도록 했습니다.
  2. 데모 데이터 현실화: generateDemoData 함수를 수정하여 입찰/낙찰 구분, 금액, 지역, 발주처, 키워드 등을 포함한 좀 더 현실적인 가상 데이터를 생성하도록 했습니다. 낙찰 공고에는 가상 참여 업체 목록과 낙찰률도 추가했습니다.
  3. API 인증 (시뮬레이션): 서비스 키 입력 필드와 인증 버튼을 추가하고 localStorage를 사용하여 인증 상태를 유지하는 것처럼 보이게 했습니다.
  4. 검색 조건 상세화: 최소/최대 금액 필드를 추가하고, 키워드 검색 시 AND/OR 조건을 선택할 수 있도록 체크박스를 추가했습니다.
  5. 검색 로직 강화: searchData 함수에서 데모 데이터를 생성한 후, 사용자가 입력한 모든 검색 조건 (유형, 날짜, 지역, 금액, 키워드)에 따라 데이터를 필터링하도록 구현했습니다.
  6. 필터 태그 기능: 적용된 검색 조건을 보여주는 태그 영역(filterTags)을 추가하고, 태그 옆의 '×' 버튼을 클릭하면 해당 필터를 제거하고 자동으로 재검색하도록 구현했습니다. updateFilterTags 함수로 현재 필터 상태를 시각적으로 보여줍니다.
  7. 결과 테이블 기능:
    • resultsBody를 비우고, state.filteredData를 기준으로 데이터를 채웁니다.
    • 각 행에 data-bid-no 속성을 추가하여 해당 공고의 고유 번호를 저장하도록 했습니다.
    • 금액은 만원 단위로 표시하고 toLocaleString으로 천 단위 쉼표를 추가했습니다.
    • 낙찰업체와 낙찰률 컬럼을 추가했습니다 (낙찰공고인 경우에만 표시).
    • 체크박스를 추가하여 여러 항목을 선택할 수 있게 하고, '전체 선택' 체크박스와 연동됩니다.
    • 행 클릭 시 showDetail 함수가 호출되도록 이벤트 리스너를 추가했습니다.
    • 검색 결과 건수를 표시합니다.
  8. 페이징 기능: displayResults 함수 내에서 현재 페이지와 항목 수에 따라 데이터를 슬라이스하고, 페이지네이션 버튼(pagination)을 동적으로 생성하도록 구현했습니다. 버튼 클릭 시 state.currentPage를 업데이트하고 displayResults를 다시 호출합니다.
  9. 차트 기능:
    • createCharts 함수에서 state.filteredData를 분석하여 지역별, 월별, 금액대별, 발주처별 데이터를 집계합니다.
    • Chart.js를 사용하여 4가지 차트를 생성하고 각 캔버스에 표시합니다.
    • 새로운 검색 시 기존 차트 인스턴스를 파괴하고 다시 그리도록 하여 메모리 누수를 방지했습니다.
  10. 상세 정보 탭:
    • showDetail(bidNo) 함수를 추가하여 클릭된 행의 bidNo를 받아 state.filteredData에서 해당 데이터를 찾아 #bidDetail 영역에 상세 정보를 표시합니다.
    • 상세 정보를 표시할 때 상세 정보 탭이 활성화되도록 했습니다.
    • 상세 정보가 없을 때 표시되는 메시지를 추가했습니다.
    • 가상 나라장터 원문 링크를 추가했습니다.
  11. 메시지 발송 기능:
    • 체크박스 선택 상태를 state.selectedItems Set 객체로 관리합니다.
    • updateSelectedCompanies 함수는 state.selectedItems에 있는 bidNo를 기반으로 선택된 업체 목록을 표시합니다. 제거 버튼 클릭 시 Set에서 해당 bidNo를 제거하고 체크박스 상태를 업데이트합니다.
    • 메시지 유형 선택 시 템플릿 미리보기가 변경되도록 updateMessageTemplate 함수를 구현했습니다.
    • '템플릿 적용' 버튼 클릭 시 선택된 템플릿 내용이 #messageContent textarea로 복사되도록 applyMessageTemplate 함수를 구현했습니다. {업체명}, {공고명}과 같은 변수를 포함합니다.
    • '메시지 발송' 버튼 클릭 시 sendMessages 함수가 호출됩니다. 이 함수는 선택된 업체 목록을 순회하며 메시지 내용을 가져와 {업체명} 및 {공고명} 변수를 실제 데이터로 치환한 후, 콘솔에 발송될 메시지를 출력하는 시뮬레이션을 수행합니다. 실제 발송 로직은 포함되어 있지 않습니다.
  12. Excel 다운로드: exportToExcel 함수를 구현하여 state.filteredData 전체를 XLSX 라이브러리를 사용하여 Excel 파일로 변환하고 다운로드하도록 했습니다. 컬럼 헤더를 한국어로 설정했습니다.
  13. 로딩/알림: 검색 시작 시 로딩 스피너를 표시하고 완료 시 숨기며, 유효성 검사나 작업 완료 시 간단한 alert 메시지를 표시합니다. (showAlert 함수)
  14. 코드 구조 및 주석: 함수별로 코드를 분리하고 주석을 추가하여 가독성을 높였습니다.

이 코드는 주식회사 진주정보통신에서 나라장터 정보통신공사 입찰/낙찰 정보를 내부적으로 분석하고 활용하기 위한 기반 시스템으로 사용될 수 있습니다. 실제 운영을 위해서는 서버 측에서 나라장터 API를 호출하고 데이터를 관리하는 백엔드 시스템 구축이 필요하며, 메시지 발송 또한 실제 통신 채널(SMS, 이메일 등)과 연동하는 개발이 필요합니다.

 
 
 
 
 

미리보기로 보여줘

 
 
 
 
생각하는 과정 표시

웹 브라우저에서 해당 HTML 파일을 열었을 때 보이게 될 화면을 순서대로 설명해 드리겠습니다. 코드를 직접 실행하여 동적인 부분을 보여드릴 수는 없지만, 정적인 레이아웃과 예상되는 인터랙션을 설명해 드립니다.

1. 초기 화면 (로그인/인증 단계)

  • 화면 중앙에 인증 섹션이 표시됩니다.
  • 회색 배경 위에 흰색 박스가 나타나고, 박스 상단에는 "나라장터 API 인증" 이라는 제목이 보입니다.
  • "나라장터 OpenAPI 서비스 키를 입력해주세요." 라는 안내 메시지 (파란색 alert 박스)가 있습니다.
  • "서비스 키" 입력 필드가 있습니다. (입력 내용은 비밀번호처럼 가려집니다.)
  • 하단에 "인증하기" 버튼이 있습니다.
  • 이 외의 검색, 결과, 메시지 섹션은 보이지 않습니다.

2. 인증 성공 후 메인 화면

  • 서비스 키를 입력하고 "인증하기" 버튼을 누르면 (데모에서는 어떤 키든 입력하면) 인증 섹션이 사라지고 메인 콘텐츠가 나타납니다.
  • 상단에 주식회사 진주정보통신이라는 회사명과 "나라장터 정보통신공사 입찰/낙찰 정보 분석 시스템" 이라는 제목이 있는 파란색 헤더가 보입니다.
  • 그 아래로 주요 기능 섹션들이 배치됩니다.

3. 검색 조건 섹션

  • 흰색 박스 안에 "검색 조건" 이라는 제목이 있습니다.
  • 입찰 유형, 시작일, 종료일, 지역, 최소 금액, 최대 금액을 입력하거나 선택할 수 있는 입력 필드들이 여러 열로 정렬되어 있습니다.
  • "검색 키워드" 입력 필드는 다른 필드보다 넓게 표시됩니다.
  • "모든 키워드 포함 (AND 검색)" 체크박스가 키워드 입력란 아래에 있습니다.
  • 하단 중앙에 "검색하기" 버튼과 "초기화" 버튼이 나란히 있습니다.

4. 로딩 인디케이터 (검색 중)

  • "검색하기" 버튼을 누르면 화면 중앙에 회전하는 파란색 원"데이터를 불러오는 중입니다..." 라는 텍스트가 잠깐 나타납니다.
  • 데이터 로딩이 완료되면 사라집니다.

5. 검색 결과 섹션 (처음에는 숨겨져 있다가 검색 후 표시)

  • 흰색 박스 안에 "검색 결과 (X건)" 이라는 제목이 표시됩니다. (X는 검색된 결과 수)
  • 제목 오른쪽에 "Excel 다운로드" 버튼이 있습니다.
  • 적용된 검색 조건을 보여주는 필터 태그 영역이 나타납니다. 각 태그 옆에는 '×' 버튼이 있어 클릭 시 해당 필터가 제거됩니다.
  • 탭 인터페이스: "표 보기", "차트 보기", "상세 정보" 탭이 보입니다. 기본적으로 "표 보기" 탭이 활성화되어 있습니다.
    • 표 보기 탭:
      • 입찰/낙찰 정보가 표시되는 테이블이 있습니다.
      • 테이블 헤더에는 체크박스(전체 선택), 번호, 공고일, 공고명, 발주처, 지역, 금액, 마감일, 낙찰업체, 낙찰률 컬럼이 있습니다.
      • 검색 결과 데이터가 행으로 표시됩니다. 각 행의 체크박스로 항목을 선택할 수 있습니다.
      • 테이블 하단에 페이지 이동을 위한 페이지네이션 버튼들이 표시됩니다.
      • 결과가 없으면 "검색 결과가 없습니다." 메시지가 표시됩니다.
      • 테이블 행을 클릭하면 "상세 정보" 탭으로 자동 전환됩니다.
    • 차트 보기 탭:
      • 탭 클릭 시 이 내용이 나타납니다.
      • 지역별, 월별, 금액대별, 발주처별 공고 건수를 시각적으로 보여주는 4개의 차트 (막대, 꺾은선, 원형 차트 등)가 표시됩니다.
    • 상세 정보 탭:
      • 탭 클릭 시 이 내용이 나타나거나, 테이블에서 행을 클릭하면 자동으로 전환됩니다.
      • 기본적으로는 "상세 정보를 보려면 표에서 항목을 클릭하세요." 라는 안내 메시지 (파란색 alert 박스)가 보입니다.
      • 테이블에서 특정 공고를 클릭하면 해당 공고의 상세 정보 (공고명, 발주처, 금액 등)가 구조화되어 표시됩니다.

6. 메시지 발송 섹션

  • 흰색 박스 안에 "메시지 발송" 이라는 제목이 있습니다.
  • "선택된 업체 (Y개)" 라는 제목과 함께 테이블에서 체크박스로 선택한 업체들의 이름이 태그 형태로 나열되는 영역이 있습니다. (Y는 선택된 업체 수) 각 태그 옆에는 '×' 버튼이 있어 선택을 취소할 수 있습니다. 선택된 업체가 없으면 안내 메시지가 표시됩니다.
  • "메시지 유형" 드롭다운 메뉴 (낙찰 축하, 회사 홍보, 사용자 정의)가 있습니다.
  • 선택된 메시지 유형에 따른 템플릿 미리보기 영역이 있습니다.
  • **"메시지 내용"**을 직접 입력하거나 편집할 수 있는 큰 텍스트 영역이 있습니다.
  • 하단 오른쪽에 "템플릿 적용" 버튼과 "메시지 발송 (시뮬레이션)" 버튼이 있습니다.

이러한 구성 요소들이 위에서 설명된 CSS 스타일과 함께 표시되어, 나라장터 정보통신공사 입찰/낙찰 정보를 검색하고 분석하며 관련 업체에 메시지를 보내는 시스템의 사용자 인터페이스를 형성합니다.

 
 
 
 
 
AI 음성 개요 생성
반응형

+ Recent posts