<?xml version="1.0" encoding="utf-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
    <channel>
        <title>heina-effect.log</title>
        <link>https://velog.io/</link>
        <description></description>
        <lastBuildDate>Tue, 30 Sep 2025 05:00:28 GMT</lastBuildDate>
        <docs>https://validator.w3.org/feed/docs/rss2.html</docs>
        <generator>https://github.com/jpmonette/feed</generator>
        <image>
            <title>heina-effect.log</title>
            <url>https://velog.velcdn.com/images/heina-effect/profile/564b59ad-96ab-46d9-9e91-dfcf3d18e992/social_profile.png</url>
            <link>https://velog.io/</link>
        </image>
        <copyright>Copyright (C) 2019. heina-effect.log. All rights reserved.</copyright>
        <atom:link href="https://v2.velog.io/rss/heina-effect" rel="self" type="application/rss+xml"/>
        <item>
            <title><![CDATA[Flutter 앱 보안 강화(3):  iOS에서 앱 스위처 화면 가리기와 캡처 방지 트릭]]></title>
            <link>https://velog.io/@heina-effect/Flutter-%EC%95%B1-%EB%B3%B4%EC%95%88-%EA%B0%95%ED%99%94-3-iOS%EC%97%90%EC%84%9C-%EC%95%B1-%EC%8A%A4%EC%9C%84%EC%B2%98-%ED%99%94%EB%A9%B4-%EA%B0%80%EB%A6%AC%EA%B8%B0%EC%99%80-%EC%BA%A1%EC%B2%98-%EB%B0%A9%EC%A7%80-%ED%8A%B8%EB%A6%AD</link>
            <guid>https://velog.io/@heina-effect/Flutter-%EC%95%B1-%EB%B3%B4%EC%95%88-%EA%B0%95%ED%99%94-3-iOS%EC%97%90%EC%84%9C-%EC%95%B1-%EC%8A%A4%EC%9C%84%EC%B2%98-%ED%99%94%EB%A9%B4-%EA%B0%80%EB%A6%AC%EA%B8%B0%EC%99%80-%EC%BA%A1%EC%B2%98-%EB%B0%A9%EC%A7%80-%ED%8A%B8%EB%A6%AD</guid>
            <pubDate>Tue, 30 Sep 2025 05:00:28 GMT</pubDate>
            <description><![CDATA[<p>지난 글에서는 스크린샷 및 화면 녹화 방지 트릭을 다뤘는데, 이번 편에서는 iOS에서 앱이 백그라운드로 전환될 때(앱 스위처 화면) 민감 정보가 그대로 노출되지 않도록 가리는 방법을 다뤄보겠다.</p>
<h1 id="intro">intro..</h1>
<p>다른 금융앱이나, 주식앱 및 공공앱들을 보면 화면이 가려져 있기 때문이다. (쿠팡은 와이?)
<img src="https://velog.velcdn.com/images/heina-effect/post/a517db71-fead-4519-a64e-d14d1392047f/image.jpeg" alt=""></p>
<p>여튼 앱 스취어에서 고객정보나 비밀번호 또는 중요 정보들이 노출되는 것 만으로도 큰 사고가 된다.</p>
<p> 따라서 목표는 스크린샷/녹화 방지는 물론, 앱을 백그라운드로 전환했을 때 iOS의 <strong>앱 스위처 화면(멀티태스킹 뷰)</strong>에 Flutter 화면 대신 흰색 배경에 로고가 박힌 썸네일을 표시하는 것!</p>
<h1 id="ios-보안과-flutter의-충돌">iOS 보안과 Flutter의 충돌</h1>
<p>앞서 말했듯이 Android는 단 하나의 코드로 모든 문제가 해결된다.</p>
<pre><code class="language-kotlin">window.addFlags(WindowManager.LayoutParams.FLAG_SECURE)</code></pre>
<p>하지만 iOS는 단순하지 않다.
<em>이쯤 생각나는 첫<del>맛남은 너무 어려워</del>!ㅎ</em></p>
<p>iOS에서는 다음과 같은 기능이 분리되어 있다
<img src="https://velog.velcdn.com/images/heina-effect/post/b4ebbc2c-651e-4ea1-b610-209a084815e2/image.png" alt=""></p>
<p>일반 iOS 앱에서는 <code>SceneDelegate</code>를 사용해서 이렇게 해결하라고 한다.</p>
<p>이는 iOS의 라이프 사이클과 관련이 있는데</p>
<h2 id="ios의-라이프-사이클-이용하기">iOS의 라이프 사이클 이용하기</h2>
<h3 id="ios-13이상--uiscenedelegate">iOS 13이상: ** UISceneDelegate**</h3>
<p><img src="https://velog.velcdn.com/images/heina-effect/post/89dbd9b4-5d59-4213-aa18-d4704f240186/image.png" alt="">
핵심 역할: 개별 UI “장면(Scene)” 관리, 멀티 윈도우 대응
iOS 13 이상에서 등장, <strong>멀티 윈도우(multi-window)</strong>를 지원하기 위해 만들어졋으며, <strong>앱 내 각각의 Scene(화면, 창) 단위</strong>로 생명주기를 관리한다.</p>
<blockquote>
<p><strong>주요 이벤트</strong>:
    Scene이 화면에 나타날 때: <code>sceneDidBecomeActive</code>
    Scene이 백그라운드로 갈 때: <code>sceneDidEnterBackground</code>
    Scene 연결/해제할 때: <code>scene(_:willConnectTo:options:), sceneDidDisconnect</code></p>
</blockquote>
<p><img src="https://velog.velcdn.com/images/heina-effect/post/e19dc99e-24a6-4dc9-9589-1e7da4ab5004/image.png" alt=""></p>
<p><a href="https://developer.apple.com/documentation/uikit/uiscenedelegate">https://developer.apple.com/documentation/uikit/uiscenedelegate</a></p>
<pre><code class="language-swift">func sceneWillResignActive(_ scene: UIScene) {
    window?.addSubview(UIView(frame: window!.bounds))
}</code></pre>
<h3 id="ios-12이하-uiapplicationdelegate">iOS 12이하: <strong>UIApplicationDelegate</strong></h3>
<p><img src="https://velog.velcdn.com/images/heina-effect/post/5cafba2f-3972-43c9-a7a1-9748c3e99180/image.png" alt="">
핵심 역할: 앱 전체 생명주기 관리, 시스템 이벤트 대응
iOS 앱에서 최초 진입점 역할을 수행하며 앱이 <strong>실행, 종료, 백그라운드 진입, 포그라운드 진입</strong> 등과 같은 전체 생명주기 이벤트를 받을 때 호출된다. 단일 앱에서 <strong>하나만 존재</strong>하고 앱 전체를 아우른다.</p>
<p><img src="https://velog.velcdn.com/images/heina-effect/post/9273448e-a09d-4dba-9a26-e3f6699eb11a/image.png" alt=""></p>
<p><a href="https://developer.apple.com/documentation/uikit/uiapplicationdelegate">https://developer.apple.com/documentation/uikit/uiapplicationdelegate</a></p>
<h3 id="❌-screendelegate는-왜-사용할-수-없었나">❌ ScreenDelegate는 왜 사용할 수 없었나?</h3>
<p>단순하다. 우리가 사용하는 플러터 버전(3.29.3)에서는 지원하지 않는다.
하지만 OS 26이 나오면서, 호환성 문제가 대두되었고, 추가적으로 개발중인것 같다.</p>
<p><img src="https://velog.velcdn.com/images/heina-effect/post/7ad42ef3-6e4e-4b61-8761-198409a6569d/image.png" alt=""></p>
<p><a href="https://docs.flutter.dev/release/breaking-changes/uiscenedelegate">https://docs.flutter.dev/release/breaking-changes/uiscenedelegate</a></p>
<p><strong>즉, 우리는 AppDelegate만으로 앱 스위처를 제어해야 했다...</strong></p>
<h1 id="로고가-박힌-secure-layer-생성하기">로고가 박힌 Secure Layer 생성하기</h1>
<h2 id="calayer를-사용한-이유">CALayer를 사용한 이유</h2>
<p>앞서 설명한 앱 스위처 화면 가림 구현에서 핵심 포인트는 <strong>Flutter</strong>나 <strong>WebView</strong>가 그린 화면까지 완전히 가려야하고, 캡쳐 및 녹화시에도 화면을 가려야 한다는 점 이다.</p>
<p>단순히 UIView를 올리거나 isHidden을 바꾸는 수준으로는 모든 기능을 충족하기가 어려웠다.</p>
<p><code>Flutter</code>는 GPU 기반 렌더링을 사용하고, <code>WebView</code>는 별도의 프로세스로 화면을 그리기 때문에, 
<strong>iOS 기본 뷰 계층만으로는 화면 캡처나 멀티태스킹 썸네일에서 민감 정보가 노출</strong>될 수 있다.</p>
<p>그래서 우리는 <strong>CALayer를 활용한 ‘보안 레이어’</strong>를 만들었다.</p>
<p>CALayer는 모든 UIView의 렌더링 최하위 계층이자 GPU 단에서 처리되는 레이어이므로, Flutter·WebView 등 하위 렌더링 엔진에서 그려진 화면까지 덮어 씌울 수 있는 유일한 방법이다.</p>
<h3 id="그러나-추천하지-않는-이유">그러나 추천하지 않는 이유</h3>
<blockquote>
<ol>
<li>UIWindow 또는 UITextField 레이어를 직접 건드리면서 <strong>뷰 계층 구조(View Hierarchy)를 강제로 바꿈</strong></li>
<li>overlay가 제대로 추가되지 않으면 일부 화면이 가려지지 않거나, 터치 이벤트가 overlay에 막혀 Flutter 화면이 반응하지 않음</li>
<li>공식 API가 아닌 ‘hack’ 수준의 처리이므로, 미래 iOS 버전에서 앱 스위처/스크린샷 방지가 제대로 작동한다는 보장이 없음</li>
<li>특히 멀티 Scene, 외부 라이브러리 UI가 많은 앱에서는 예상치 못한 버그가 발생할 가능성 높음</li>
</ol>
</blockquote>
<h2 id="calayer-사용-결과">CALayer 사용 결과</h2>
<h3 id="우리가-생각한-처리-방식">우리가 생각한 처리 방식</h3>
<p>1️⃣ 앱이 백그라운드로 전환될 때<strong>(applicationWillResignActive)</strong>
→ CALayer 기반 overlay를 생성해 화면 전체를 덮고 로고를 표시
2️⃣ 앱이 포그라운드로 돌아올 때<strong>(applicationDidBecomeActive)</strong> 
→ overlay 제거 후, 원래 화면 표시.
3️⃣ 동시에 스크린샷/녹화 감지 알림을 AppDelegate에서 처리
→ 민감 정보가 노출되면 즉시 사용자에게 알림.</p>
<h3 id="적용한-처리-방식-calayer를-이용">적용한 처리 방식 (CALayer를 이용)</h3>
<p><strong>1. 보안 Layer 생성</strong></p>
<pre><code class="language-swift">let field = UITextField(frame: window.bounds)
field.isSecureTextEntry = true
window.addSubview(field)</code></pre>
<p>UITextField를 전체 화면 크기로 생성하고 <code>isSecureTextEntry = true</code> 설정
iOS 시스템에 “<strong>이 Layer는 민감 정보이므로 캡처 금지</strong>”를 알림</p>
<p>*<em>2. 로고 Layer 삽입 *</em></p>
<pre><code class="language-swift">let logoLayer = CALayer()
logoLayer.backgroundColor = UIColor.white.cgColor

let imageLayer = CALayer()
imageLayer.contents = UIImage(named: &quot;LaunchImage&quot;)?.cgImage
imageLayer.contentsGravity = .resizeAspect
logoLayer.addSublayer(imageLayer)

field.layer.insertSublayer(logoLayer, at: 0)</code></pre>
<p>CALayer로 흰색 배경과 앱 로고를 생성
Flutter 화면 위에 강제로 덮어, 앱 사용 중 스크린샷/녹화 시 민감 정보 보호</p>
<p>*<em>3. Layer 강제 조작 *</em></p>
<pre><code class="language-swift">self.layer.superlayer?.addSublayer(field.layer)
field.layer.sublayers?.last!.addSublayer(self.layer)</code></pre>
<p>UIWindow Root Layer를 UITextField Layer 안으로 강제로 이동</p>
<h3 id="적용한-처리-방식-앱-스위처-전환-시">적용한 처리 방식 (앱 스위처 전환 시)</h3>
<p><strong>1.    백그라운드 진입 직전 (applicationWillResignActive)</strong></p>
<pre><code class="language-swift">window.makeUnsecure()   // Secure Layer 제거
window.addSubview(createOverlay(frame: window.bounds)) // 로고 UIView 추가</code></pre>
<p>Secure Layer 제거 후 로고 UIView를 최상위에 추가
앱 스위처 썸네일에는 <strong>깔끔하게 로고만 표시</strong></p>
<p><strong>2.    포그라운드 복귀 (applicationDidBecomeActive)</strong></p>
<pre><code class="language-swift">overlayView?.removeFromSuperview()
window.makeSecure(overlayGenerator: createOverlay)</code></pre>
<p>overlay 제거 후 <strong>Flutter 화면 복구</strong>
Secure Layer 재활성화 → 앱 사용 중 보호 유지</p>
<h4 id="최종-적용-코드">최종 적용 코드</h4>
<pre><code class="language-swift">@main
@objc class AppDelegate: FlutterAppDelegate {
    private var overlayView: UIView?

    override func application(
        _ application: UIApplication,
        didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
    ) -&gt; Bool {
        self.window.makeSecure(overlayGenerator: { _ in UIView() }) //스크린샷 방지        

        // 스크린샷 감지 Alert 셋팅
        NotificationCenter.default.addObserver(
          self,
          selector: #selector(alertCapture),
          name: UIApplication.userDidTakeScreenshotNotification,
          object: nil
        )

        NotificationCenter.default.addObserver(
          self,
          selector: #selector(alertRecoding),
          name: UIScreen.capturedDidChangeNotification,
          object: nil
        )

        return super.application(application, didFinishLaunchingWithOptions: launchOptions)
    }

    // 스크린샷/녹화 알림
     @objc private func alertCapture() {
        showCaptureAlert(&quot;캡쳐가 감지되어 화면을 차단합니다.&quot;)
     }
     @objc private func alertRecoding() {
        guard UIScreen.main.isCaptured else { return }
        showCaptureAlert(&quot;녹화가 감지되어 화면을 차단합니다.&quot;)
     }

     private func showCaptureAlert(_ title: String) {
         let alert = UIAlertController(
             title: title,
             message: &quot;&quot;,
             preferredStyle: .alert
         )
         alert.addAction(UIAlertAction(title: &quot;확인&quot;, style: .default, handler: nil))

         if var topController = self.window?.rootViewController {
            while let presentedViewController = topController.presentedViewController {
              topController = presentedViewController
            }
            DispatchQueue.main.async {
              topController.present(alert, animated: false, completion: nil)
            }
         }
     }

     // 앱이 비활성 상태가 될 때 (백그라운드 진입 직전) 호출
     override func applicationWillResignActive(_ application: UIApplication) {
        self.window?.makeUnsecure()

        guard let window = self.window else { return }

        if overlayView == nil {
            let overlay = createOverlay(frame: window.bounds)
            window.addSubview(overlay)
            overlayView = overlay
        }else {
             window.bringSubviewToFront(overlayView!)
        }
     }

     override func applicationDidBecomeActive(_ application: UIApplication) {
        overlayView?.removeFromSuperview()
        overlayView = nil

        self.window?.makeSecure(overlayGenerator: createOverlay)
     }

     // 로고 썸네일 생성
     private func createOverlay(frame: CGRect) -&gt; UIView {
         let overlay = UIView(frame: frame)
         overlay.backgroundColor = .white

         let imageView = UIImageView(image: UIImage(named: &quot;LaunchImage&quot;))
         imageView.contentMode = .scaleAspectFit
         imageView.translatesAutoresizingMaskIntoConstraints = false
         overlay.addSubview(imageView)

         NSLayoutConstraint.activate([
             imageView.centerXAnchor.constraint(equalTo: overlay.centerXAnchor),
             imageView.centerYAnchor.constraint(equalTo: overlay.centerYAnchor),
             imageView.widthAnchor.constraint(lessThanOrEqualTo: overlay.widthAnchor, multiplier: 0.5),
             imageView.heightAnchor.constraint(lessThanOrEqualTo: overlay.heightAnchor, multiplier: 0.5)
         ])
         return overlay
     }
}


extension UIWindow {
    private static let secureFieldTag = 1000

    // 로고 CALayer를 UITextField의 Layer 내부에 삽입
    func makeSecure(overlayGenerator: @escaping (CGRect) -&gt; UIView) {
        DispatchQueue.main.async {
            // 이미 추가되어 있다면 중복 방지
            guard self.viewWithTag(UIWindow.secureFieldTag) == nil else { return }

            // UITextField를 윈도우 크기로 초기화합니다.
            let field = UITextField(frame: self.bounds)
            field.isSecureTextEntry = true
            field.tag = UIWindow.secureFieldTag

            self.addSubview(field)

            let logoLayer = CALayer()
            logoLayer.frame = field.bounds // UITextField 전체 크기에 맞춤
            logoLayer.backgroundColor = UIColor.white.cgColor

            // 로고 이미지 CALayer를 생성
            if let launchImage = UIImage(named: &quot;LaunchImage&quot;)?.cgImage {
                let imageLayer = CALayer()
                imageLayer.contents = launchImage
                imageLayer.contentsGravity = .resizeAspect

                let logoScale: CGFloat = 0.25
                let imageSize = CGSize(
                    width: field.bounds.width * logoScale,
                    height: field.bounds.height * logoScale
                )

                imageLayer.frame = CGRect(
                    x: (field.bounds.width - imageSize.width) / 2,
                    y: (field.bounds.height - imageSize.height) / 2,
                    width: imageSize.width,
                    height: imageSize.height
                )

                logoLayer.addSublayer(imageLayer)
            }

            // 로고 레이어를 UITextField 레이어의 가장 아래에 삽입
            field.layer.insertSublayer(logoLayer, at: 0)

            // 캡처 방지 핵심: 레이어 강제 조작 코드 유지
            self.layer.superlayer?.addSublayer(field.layer)
            field.layer.sublayers?.last!.addSublayer(self.layer)
        }
    }

    func makeUnsecure() {
        DispatchQueue.main.async {
            if let secureField = self.viewWithTag(UIWindow.secureFieldTag) as? UITextField {
                // 강제로 변경되었던 레이어 구조를 복원시도
                if let secureFieldSuperlayer = secureField.layer.superlayer {
                    secureFieldSuperlayer.addSublayer(self.layer)
                }
                // UITextField 제거 (내부의 logoLayer 포함하여 모두 제거됨)
                secureField.removeFromSuperview()
                secureField.layer.removeFromSuperlayer()
            }
        }
    }
}</code></pre>
<p><img src="https://velog.velcdn.com/images/heina-effect/post/c0ca2a94-abf1-4fcb-9c39-967a27b22b96/image.PNG" alt=""></p>
<h1 id="마무리하며">마무리하며</h1>
<p>처음에는 스크린샷과 화면 녹화 방지는 단순한 문제일 거라고 생각했다.
하지만 iOS와 Flutter, WebView가 각기 다른 렌더링 구조를 가지고 있어 상황은 훨씬 복잡했다.
게다가 앱 스위처 화면에서도 민감 정보가 노출될 수 있다는 점이 문제는 처음 알게 되었다.
<em>(iOS 와 Android는 다르게 작동하는것도 알게됨)</em></p>
<p>iOS 개발자가 아니어서 접근 자체가 쉽지 않았다.
수많은 빌드와 테스트를 반복하며 구현해야 했고, AppDelegate에서 화면 전환이 유연하게 처리되지 않아 캡처 시에도 썸네일이 나타나도록 꼼수를 부릴 수밖에 없었다.
과정 내내 쉽지 않았지만, 그 덕분에 안정적인 보안 처리가 가능했다.</p>
<p>재미있게도 기획자님들은 오히려 결과물을 더 마음에 들어하며 좋아했다.
덕분에 수많은 시행착오와 수많은 빌드가 충분히 보람 있었던것 같다.</p>
<p>이제 다음 글에서는 UI 보안 적용보다는, iOS 앱 빌드 시 freeRASP와 충돌하는 문제를 중심으로 해결 방법을 정리할 예정이다.</p>
<p><strong>결론 보안 처리는 너무 어렵다.....</strong>
<img src="https://velog.velcdn.com/images/heina-effect/post/40765e4c-8c6a-4ebf-86c2-3c1423ee4d59/image.png" alt=""></p>
]]></description>
        </item>
        <item>
            <title><![CDATA[Flutter 앱 보안 강화(2): 스크린 캡쳐 및 녹화 방지]]></title>
            <link>https://velog.io/@heina-effect/Flutter-%EC%95%B1-%EB%B3%B4%EC%95%88-%EA%B0%95%ED%99%942-%EC%8A%A4%ED%81%AC%EB%A6%B0-%EC%BA%A1%EC%B3%90-%EB%B0%8F-%EB%85%B9%ED%99%94-%EB%B0%A9%EC%A7%80</link>
            <guid>https://velog.io/@heina-effect/Flutter-%EC%95%B1-%EB%B3%B4%EC%95%88-%EA%B0%95%ED%99%942-%EC%8A%A4%ED%81%AC%EB%A6%B0-%EC%BA%A1%EC%B3%90-%EB%B0%8F-%EB%85%B9%ED%99%94-%EB%B0%A9%EC%A7%80</guid>
            <pubDate>Fri, 26 Sep 2025 08:22:43 GMT</pubDate>
            <description><![CDATA[<p>지난 글에서는 <strong>루팅 · 탈옥 탐지, 앱 무결성 확인</strong> 등에 대해 다뤘다면, 이번 글에서는 스크린샷(캡쳐)및 화면 녹화 방지 기능을 Flutter 앱에서 어떻게 구현할 수 있는지 정리해보겠다.</p>
<h1 id="플랫폼별-접근">플랫폼별 접근</h1>
<p>우선 Flutter 앱에서 화면 캡처 및 녹화 방지(Screen Security)는 안드로이드(Android)와 iOS가 작동하는 방식이 근본적으로 다르기 때문에 <strong>플랫폼별로 접근</strong>해야 한다. 
특히, iOS는 단순 감지만 가능하므로, 이를 &#39;방지&#39;로 바꾸기 위한 특별한 트릭이 필요하다.
약간 랩같기도함 &#39;방지&#39;는 안되고 &#39;감지&#39;만 돼.. ㅈㅅ</p>
<p><img src="https://hookagency.com/wp-content/uploads/2024/08/defending-work-not-at-the-client-meeting.gif" alt=""></p>
<h1 id="android에서의-스크린샷녹화-방지">Android에서의 스크린샷/녹화 방지</h1>
<p>안드로이드는 상대적으로 간단하다!</p>
<p>안드로이드는 <strong>FLAG_SECURE</strong>라는 강력한 보안 플래그를 제공하고, 이는 앱의 내용을 스크린샷, 화면 녹화, 그리고 앱 스위처(최근 앱 목록) 미리보기에서 <strong>완전히 차단(방지)</strong>할 수 있다.</p>
<h2 id="flag_secure-플래그-윈도우-추가하기">FLAG_SECURE 플래그 윈도우 추가하기</h2>
<p>안드로이드의 <code>MainActivity.kt</code> 파일에서 <strong>FLAG_SECURE</strong> 플래그를 윈도우에 추가하면, 스크린샷·화면 녹화·앱 스위처 미리보기 모두 차단된다.</p>
<p>setFlags와 addFlags를 사용할 수 있는데 이건 기호에 맞게 사용하면 되지 않을까..</p>
<pre><code class="language-kotlin">// MainActivity.kt
import android.os.Bundle
import io.flutter.embedding.android.FlutterActivity
import android.view.WindowManager

class MainActivity : FlutterActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        // addFlags로 설정하기
        window.addFlags(WindowManager.LayoutParams.FLAG_SECURE)

        // setFlags로 설정하기
        window.setFlags(
            WindowManager.LayoutParams.FLAG_SECURE,
            WindowManager.LayoutParams.FLAG_SECURE
        )
    }
}
</code></pre>
<p>적용 후 스크린샷 촬영 시 “스크린샷을 캡처할 수 없습니다” 라는 메시지가 뜨며 방지된다</p>
<h1 id="ios에서의-스크린샷녹화-방지">iOS에서의 스크린샷/녹화 방지</h1>
<h2 id="ios의-한계">iOS의 한계</h2>
<p>먼저 알아야 할 점은, 안드로이드처럼 FLAG_SECURE와 같은 단일 옵션이 존재하지 않기 때문에
<strong>iOS는 시스템 차원에서 앱이 스크린샷을 찍히는 것을 막을 수 없다</strong>는것이다. 진짜 대곰탕
<img src="https://velog.velcdn.com/images/heina-effect/post/f757b20c-7617-4793-9d68-1adbce067822/image.png" alt=""></p>
<p>스크린샷/녹화 이벤트 감지: iOS에서는 <code>UIApplication.userDidTakeScreenshotNotification</code> 또는 <code>UIScreen.capturedDidChangeNotification</code>을 통해 <strong>“사용자가 지금 스크린샷을 찍었다/녹화가 시작되었다”</strong>는 사실만 알 수 있다.</p>
<pre><code class="language-swift">NotificationCenter.default.addObserver(
    forName: UIApplication.userDidTakeScreenshotNotification,
    object: nil,
    queue: .main
) { _ in
    print(&quot;스크린샷이 감지되었습니다.&quot;)
}

NotificationCenter.default.addObserver(
    forName: UIScreen.capturedDidChangeNotification,
    object: nil,
    queue: .main
) { _ in
    if UIScreen.main.isCaptured {
        print(&quot;화면녹화가 감지되었습니다.&quot;)
    }
}</code></pre>
<p><em>근데 이것은 앞서 알려준 패키지에서도 충분히 가능하는 것!</em></p>
<p>다만 이 알림을 받은 후 앱 개발자가 화면을 지우는 등의 로직을 처리해야 하는데 알림을 감지하는 순간과 화면을 가리는 로직이 실행되는 순간 사이에 캡처가 발생한다.</p>
<p>*<em>따라서 스크린샷에는 앱 내용이 모두 노출된다!!!! *</em></p>
<h2 id="감지를-방지처럼-보이게-만드는-트릭">감지를 방지처럼 보이게 만드는 트릭</h2>
<p>차단이 불가능하다면, <strong>“사용자 눈에는 방지된 것처럼 보이게”</strong> 우회하는 방법을 쓰기로 했다.</p>
<p><code>UITextField</code>의 <code>isSecureTextEntry</code> 속성은 비밀번호 입력 시 OS 레벨에서 캡처를 차단하는 특징이 있다.
이를 앱 전체에 적용하면, 캡처 시 검은 화면 또는 지정 로고만 찍히도록 할 수 있다.</p>
<p><img src="https://velog.velcdn.com/images/heina-effect/post/2ae07e10-bc89-4922-b2e3-90ae87185746/image.png" alt=""></p>
<pre><code class="language-swift">// AppDelegate.swift

@main
@objc class AppDelegate: FlutterAppDelegate {
    override func application(
        _ application: UIApplication,
        didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
    ) -&gt; Bool {
        self.window.makeSecure() //스크린샷 방지
        FirebaseApp.configure()

        ...생략</code></pre>
<pre><code class="language-swift">extension UIWindow {
    func makeSecure() {
        DispatchQueue.main.async {
            let field = UITextField()
            let view = UIView(frame: CGRect(x: 0, y: 0, width: field.frame.self.width, height: field.frame.self.height))
            field.isSecureTextEntry = true
            self.addSubview(field)
            self.layer.superlayer?.addSublayer(field.layer)
            field.layer.sublayers?.last!.addSublayer(self.layer)
            field.leftView = view
            field.leftViewMode = .always
        }
    }
}</code></pre>
<p>이 방식은 실제 차단은 불가능하지만, <strong>사용자 눈에는 방지된 것처럼 보이도록 만드는 효과</strong>가 있다.</p>
<p>그럼 여기까지 스크린샷 녹화 및 방지 처리 끝!</p>
<p>다음 글에서는 <strong>앱 스위처</strong>에서 화면을 가리는 방법을 <strong>라이프사이클</strong>을 통해 구현하는 방법을 자세히 다루겠다..... 
오늘도 마무리는 용용이들로...
<img src="https://velog.velcdn.com/images/heina-effect/post/f7dc1d1f-9e2d-4576-ae81-789af480e3d4/image.png" alt=""></p>
]]></description>
        </item>
        <item>
            <title><![CDATA[Flutter 앱 보안 강화(1): freeRASP 적용기와 보완 작업]]></title>
            <link>https://velog.io/@heina-effect/Flutter-%EC%95%B1-%EB%B3%B4%EC%95%88-%EA%B0%95%ED%99%941-freeRASP-%EC%A0%81%EC%9A%A9%EA%B8%B0%EC%99%80-%EB%B3%B4%EC%99%84-%EC%9E%91%EC%97%85</link>
            <guid>https://velog.io/@heina-effect/Flutter-%EC%95%B1-%EB%B3%B4%EC%95%88-%EA%B0%95%ED%99%941-freeRASP-%EC%A0%81%EC%9A%A9%EA%B8%B0%EC%99%80-%EB%B3%B4%EC%99%84-%EC%9E%91%EC%97%85</guid>
            <pubDate>Tue, 23 Sep 2025 08:23:34 GMT</pubDate>
            <description><![CDATA[<h1 id="intro">intro...</h1>
<p>열심히는 아니고 적당히 모바일 웹앱 개발중이었다
그런데 우리가 진행하는 앱이 공공기관과 협의중이였고, 웹은 CSAP인증이 완료되었고, GS인증도 완료 되었다 (축하축하)
그런데 갑자기 모바일 앱에도 보안이 적용이 되었냐고 묻는게 아니겠는가? 근데 우리는 모바일웹앱이기 때문에 상관없지 않을까 라고 방심하고 있었다....</p>
<p>그러나 모바일 앱 개발시 에 필연적으로 따라오는 보안문제들을 간과하고 있었다
예를들어 금융, 기업용 앱 에서는 루팅·탈옥 탐지, 앱 위변조 방지, 스크린샷/화면 녹화 차단 같은 기능이 필수적인데 우리는 전혀 생각지도 못했다 ^^ 되는줄?
<img src="https://velog.velcdn.com/images/heina-effect/post/f740c95e-da48-4bdf-953b-d0da1f8e7dfd/image.png" alt=""></p>
<p>따라서 이번 글에서 실제로 적용해본<code>freeRASP</code> 패키지 소개와 적용 과정, 그리고 freeRASP만으로는 해결할 수 없었던 부분을 네이티브로 직접 보완한 경험을 정리하려고 한다.</p>
<h1 id="freerasp-패키지란">freeRASP 패키지란?</h1>
<p>freeRASP는 Flutter 앱 보안을 위해 제공되는 라이브러리이다.
앱 실행 중 다양한 위협 요소를 탐지하고, 개발자가 지정한 방식으로 대응할 수 있다고 한다.</p>
<p><a href="https://pub.dev/packages/freerasp">https://pub.dev/packages/freerasp</a>
<img src="https://velog.velcdn.com/images/heina-effect/post/42ab8b52-cf48-4bab-98f6-e73fdab00709/image.png" alt=""></p>
<h3 id="주요-기능">주요 기능</h3>
<ul>
<li>루팅/탈옥 탐지 (Android/iOS)</li>
<li>앱 무결성 검증 (변조 여부 확인)</li>
<li>디버깅 탐지</li>
<li>앱 클로닝 감지</li>
<li>에뮬레이터 실행 탐지</li>
<li>화면 캡쳐/녹화 탐지 이벤트 제공 (단, 방지 기능은 직접 구현 필요)</li>
<li>*- Android/iOS 공통 API 이벤트 제공 → Flutter + WebView 연계 가능</li>
<li>*</li>
</ul>
<p>그리고 서치중 좋은 글을 발견했는데, 이를 확인하고 freeRASP의 기능을 비교했다
<a href="https://doverunner.com/kr/blogs/flutter-security/">Flutter(플러터) 보안 – 보안 리스크를 방지하기 위한 10가지 팁</a></p>
<h2 id="📌--freerasp-기능-제공-여부-및-대체-방안">📌  freeRASP 기능 제공 여부 및 대체 방안</h2>
<p><img src="https://velog.velcdn.com/images/heina-effect/post/c0dfee18-ec0b-4ead-9c5b-9efd1404b883/image.png" alt=""></p>
<p>이정도는 참고만 하시길바라며 freeRASP는 어떻게 사용되는가? </p>
<h1 id="freerasp-적용하기">freeRASP 적용하기</h1>
<h2 id="패키지-설치">패키지 설치</h2>
<pre><code> $ flutter pub add freerasp</code></pre><pre><code>dependencies:
  freerasp: ^7.2.1</code></pre><h2 id="초기화">초기화</h2>
<p>main.dart에서 앱이 시작될 때 초기화한다.</p>
<p>다만 우리는 빌드환경을 flavor로 분리하였기 때문에, 
<code>로컬</code>이나 <code>개발</code>환경으로 빌드시: 앱 종료 or 차단을 무시하고 테스트
<code>운영</code>환경으로 빌드시: 앱 종료</p>
<p><em>iOS 심사시 안내 없이 앱 종료시킬 경우 승인이 안된다는 블로그를 참고하여 다이얼로그를 띄운 후 액션을 분기처리 하였다.</em></p>
<pre><code class="language-dart">Future&lt;void&gt; configApp() async {
  WidgetsBinding widgetsBinding = WidgetsFlutterBinding.ensureInitialized();

  // 스플래쉬 화면
  FlutterNativeSplash.preserve(widgetsBinding: widgetsBinding);

  // 빌드 환경 분리
  // appFlavor : local | dev | prod
  await dotenv.load(fileName: &#39;.env&#39;);
  FlavorConfig(appFlavor);

  Get.put(WebviewMainController());

  // SecurityService 초기화
  // 다이얼로그가 뜨면 _securityDialogCompleter가 완료될 때까지 진행 멈춤
  final Completer&lt;void&gt; securityDialogCompleter = Completer();
  SecurityService.dialogCompleteCallback = () {
    if (!securityDialogCompleter.isCompleted) {
      securityDialogCompleter.complete();
    }
  };

  // SecurityService 별도로 분리하여 관리
  await SecurityService.initializeTalsec(
      enableDialog: true,
      isProd: appFlavor == FlavorEnv.prod.name
  );

  // 탐지 다이얼로그가 안 뜨더라도 진행되도록 보장
  if (!securityDialogCompleter.isCompleted) {
    securityDialogCompleter.complete();
  }

  // 탐지 다이얼로그가 끝날 때까지 대기
  await securityDialogCompleter.future;

  ...이후 생략
</code></pre>
<blockquote>
<p>여기서 핵심은 SecurityService.initializeTalsec() 내부에서 freeRASP 초기화를 하고, 위협 탐지 시 빌드 타입에 따라 무시 or 앱 종료로 분기한다는 점</p>
</blockquote>
<h2 id="threatcallback-활용-freerasp의-핵심">ThreatCallback 활용: freeRASP의 핵심</h2>
<p>freeRASP는 위협이 탐지되면 <code>ThreatCallback</code>을 통해 이벤트를 전달하며, 여기서 앱의 흐름을 제어할 수 있다.</p>
<p>아래는 ThreatCallback의 각 기능을 표로 정리했다.
<img src="https://velog.velcdn.com/images/heina-effect/post/164f9da1-5895-4e8d-a3be-ef6baaee3b0b/image.png" alt=""></p>
<p>해당 표를 참고하여 원하는 기능을 추가하면 될 것 같다</p>
<pre><code class="language-dart">import &#39;dart:async&#39;;
import &#39;dart:convert&#39;;
import &#39;dart:typed_data&#39;;
import &#39;dart:io&#39;;
import &#39;package:flutter/foundation.dart&#39;;
import &#39;package:flutter/material.dart&#39;;
import &#39;package:flutter/services.dart&#39;;
import &#39;package:flutter_dotenv/flutter_dotenv.dart&#39;;
import &#39;package:get/get.dart&#39;;
import &#39;package:freerasp/freerasp.dart&#39;;

class SecurityService {
  static void Function()? dialogCompleteCallback;

  SecurityService._();
  static bool _dialogShown = false;

  static String _hexToBase64(String hex) {
    final bytes = Uint8List.fromList(
      [for (int i = 0; i &lt; hex.length; i += 2) int.parse(hex.substring(i, i + 2), radix: 16)],
    );
    return base64.encode(bytes);
  }

  static Future&lt;void&gt; initializeTalsec({required bool enableDialog, required bool isProd}) async {
    final packageName = dotenv.get(&#39;ANDROID_PACKAGE_NAME&#39;);
    final sha256Hex = dotenv.get(&#39;ANDROID_SHA256&#39;);
    final iOSTeamId = dotenv.get(&#39;IOS_TEAM_ID&#39;);
    final watcherMail = dotenv.get(&#39;WATCHER_MAIL&#39;);

    final androidConfig = AndroidConfig(
      packageName: packageName,
      signingCertHashes: [_hexToBase64(sha256Hex)],
      supportedStores: [packageName],
      // malwareConfig: MalwareConfig(
      //   blacklistedPackageNames: [&#39;com.aheaditec.freeraspExample&#39;],
      //   suspiciousPermissions: [
      //     [&#39;android.permission.CAMERA&#39;],
      //     [&#39;android.permission.READ_SMS&#39;, &#39;android.permission.READ_CONTACTS&#39;],
      //   ],
      // ),
    );

    final iosConfig = IOSConfig(
      bundleIds: [packageName],
      teamId: iOSTeamId,
    );

    final config= TalsecConfig(
      androidConfig: androidConfig,
      iosConfig: iosConfig,
      watcherMail: watcherMail,
      isProd: true,
    );

    await Talsec.instance.start(config);

    final threatCallback = ThreatCallback(
      onHooks: () =&gt; _onEvent(&#39;후킹 탐지&#39;, &#39;앱 무결성이 손상되었습니다.&#39;, enableDialog, isProd),
      onDebug: () =&gt; _onEvent(&#39;디버깅 탐지&#39;, &#39;디버깅 도구가 감지되었습니다.&#39;, enableDialog, isProd),
      onSimulator: () =&gt; _onEvent(&#39;에뮬레이터 탐지&#39;, &#39;에뮬레이터 환경이 감지되었습니다.&#39;, enableDialog, isProd),
      onAppIntegrity: () =&gt; _onEvent(&#39;앱 변조 탐지&#39;, &#39;앱이 변조되었습니다.&#39;, enableDialog, isProd),
      onDeviceBinding: () =&gt; _onEvent(&#39;디바이스 바인딩 위반&#39;, &#39;디바이스 바인딩 위반이 감지되었습니다.&#39;, enableDialog, isProd),
      onUnofficialStore: () =&gt; _onEvent(&#39;신뢰되지 않은 설치처&#39;, &#39;신뢰되지 않은 설치 소스가 감지되었습니다.&#39;, enableDialog, isProd),
      onScreenshot: () =&gt; _onScreenshotDetected(enableDialog, isProd),
      onScreenRecording: () =&gt; _onScreenRecordingDetected(enableDialog, isProd),
    );

    Talsec.instance.attachListener(threatCallback);
  }

  static Future&lt;void&gt; _onEvent(String title, String message, bool enableDialog, bool isProd) async {
    if (kDebugMode) debugPrint(&#39;[Talsec] $title : $message&#39;);

    if (!enableDialog) return;
    // Prod 모드에서는 무조건 우회 버튼 없음, Dev/Local 모드에서는 항상 우회 버튼 노출 (릴리즈 빌드 포함)
    final allowBypass = !isProd;


    WidgetsBinding.instance.addPostFrameCallback((_)  {
      if (_dialogShown) {
        return;
      }
      _dialogShown = true;

      Get.dialog(
        PopScope(
          canPop: false,
          child: AlertDialog(
            title: Text(title),
            content: Text(message),
            actions: [
              TextButton(
                onPressed: () {
                  Platform.isAndroid ? SystemNavigator.pop() : exit(0);
                  dialogCompleteCallback?.call(); // 외부 Completer 완료
                },
                child: const Text(&#39;앱 종료&#39;),
              ),
              if (allowBypass)
                TextButton(
                  onPressed: () {
                    if (Get.isDialogOpen ?? false) Get.back();
                    dialogCompleteCallback?.call(); // 외부 Completer 완료
                  },
                  child: const Text(&#39;무시(테스트용)&#39;),
                ),
            ],
          ),
        ),
        barrierDismissible: false,
      );
    });
  }

  static void _onScreenshotDetected(bool enableDialog, bool hideBypassButton) async{
    _onEvent(&#39;스크린샷 탐지&#39;, &#39;화면이 캡처되었습니다.&#39;, enableDialog, hideBypassButton);
    if (Platform.isIOS) {

    }
    // await FlutterWindowManager.addFlags(FlutterWindowManager.FLAG_SECURE);
  }

  static void _onScreenRecordingDetected(bool enableDialog, bool hideBypassButton) {
    _onEvent(&#39;화면 녹화 탐지&#39;, &#39;화면 녹화가 감지되었습니다.&#39;, enableDialog, hideBypassButton);
  }
}</code></pre>
<blockquote>
<p>iOS, Android Config에는 각각의 내용들이 필요하다
[iOS] 번들Ids, teamID 는 iOS 개발자 사이트에서 확인이 가능하며,
[Android] packageName, signingCertHashes 또한 구글 플레이콘솔에서 확인이 가능하다. </p>
</blockquote>
<p><img src="https://velog.velcdn.com/images/heina-effect/post/23c94b13-3cdd-42a3-8a71-88bf3bed6b6f/image.png" alt=""></p>
<h2 id="freerasp의-문제점-및-freerasp-대신-네이티브로-구현한-부분">freeRASP의 문제점 및 freeRASP 대신 네이티브로 구현한 부분</h2>
<p>코드를 보면 스크린샷 탐지 및 화면 녹화 탐지에 어떤 코드도 구현하지 않았다.. 
이것은 해당 패키지는 <strong>감지</strong>만 할 뿐 <strong>방지</strong>를 하지 않기 때문이었다
나는 이것을 고민하던 중 네이티브에 직접 구현하기로 했다. </p>
<h3 id="ios">iOS</h3>
<ul>
<li><code>UIScreen.main.isCaptured</code> 감지</li>
<li>캡쳐·녹화 시 화면 블러 처리 또는 특정 뷰 차단</li>
</ul>
<h3 id="android">Android</h3>
<ul>
<li><code>WindowManager.LayoutParams.FLAG_SECURE</code> 플래그 설정으로 화면 캡쳐·녹화 방지</li>
</ul>
<p>이렇게 네이티브 기능을 직접 적용하면서, freeRASP로 해결할 수 없는 부분을 보완했다.</p>
<h3 id="왜-스크린샷-방지-코드를-여기-안-넣었나">왜 스크린샷 방지 코드를 여기 안 넣었나?</h3>
<p>이번 글의 주제는 freeRASP 적용기에 집중하기 위해
iOS/Android 네이티브 단의 스크린샷 방지 코드 자체는 생략했다.</p>
<p>이유:</p>
<blockquote>
<ul>
<li>freeRASP와 관련된 핵심 흐름(ThreatCallback + 보안 초기화)을 명확히 보여주고 싶었기 때문</li>
</ul>
</blockquote>
<ul>
<li>캡쳐/녹화 방지 코드는 플랫폼마다 방식이 다르고, freeRASP와 직접적으로 연결되지 않음</li>
<li>따라서 글에서는 “왜 freeRASP 대신 네이티브로 처리했는가”까지만 정리</li>
</ul>
<p>(추후 블로그 시리즈로 별도 포스팅: “iOS/Android 네이티브에서 화면 캡쳐/녹화 방지 구현하기”로 다루는 것이 더 적절하다고 판단,, 내용이 핵 길어질것 같기 때문)</p>
<h1 id="마무리">마무리</h1>
<p>이번 글에서는 실제 모바일 앱에서 적용한 freeRASP 패키지를 중심으로 보안 적용 과정을 정리했다.</p>
<ul>
<li><p>freeRASP는 Flutter 앱 보안을 위한 탐지 라이브러리로, 루팅·탈옥, 앱 변조, 디버깅, 클로닝, 에뮬레이터, 화면 캡처/녹화 이벤트를 감지할 수 있다.</p>
</li>
<li><p>ThreatCallback을 활용하면, 탐지 이벤트가 발생했을 때 앱의 흐름을 개발자가 제어할 수 있습니다.</p>
<ul>
<li>운영 환경에서는 다이얼로그를 띄운 뒤 앱을 종료하도록 처리할 수 있다.</li>
<li>개발이나 테스트 환경에서는 탐지를 무시하고 앱을 계속 실행하도록 설정할 수 있고,</li>
</ul>
</li>
<li><p>다만 화면 캡처·녹화 방지는 freeRASP에서 직접 제공하지 않기 때문에, iOS와 Android에서는 네이티브로 직접 구현해야 한다.</p>
</li>
</ul>
<p>특히, <strong>SecurityService</strong>를 별도로 분리한 이유는, 앱 빌드 환경(flavor)에 따라 로컬/개발/운영 환경을 구분하고, 탐지 이벤트를 한 곳에서 관리하기 편리하게 하기 위해서 였음</p>
<p>다음 글에서는 이번에 생략한 iOS/Android 네이티브 화면 캡처·녹화 방지 적용 방법과 실제 앱 적용 사례를 자세히 다룰 예정이다.....</p>
<p>힘내보자...
<img src="https://velog.velcdn.com/images/heina-effect/post/5b4d016f-a1e4-4592-9098-fc009ff801ce/image.png" alt=""></p>
]]></description>
        </item>
        <item>
            <title><![CDATA[Xcode로 다양한 iOS 버전 구동하기]]></title>
            <link>https://velog.io/@heina-effect/Xcode%EB%A1%9C-%EB%8B%A4%EC%96%91%ED%95%9C-iOS-%EB%B2%84%EC%A0%84-%EA%B5%AC%EB%8F%99%ED%95%98%EA%B8%B0</link>
            <guid>https://velog.io/@heina-effect/Xcode%EB%A1%9C-%EB%8B%A4%EC%96%91%ED%95%9C-iOS-%EB%B2%84%EC%A0%84-%EA%B5%AC%EB%8F%99%ED%95%98%EA%B8%B0</guid>
            <pubDate>Mon, 04 Aug 2025 02:27:04 GMT</pubDate>
            <description><![CDATA[<p>GS인증을 진행하면서 모바일도 함께 인증을 하게 됐는데, 
최소 사양을 좀 더 낮추면서 인증하려고 하다보니 iOS에 아무런 정보 없이, 우리 앱의 최소 버전이 iOS 13이니까 iOS14면 되겠다라는 아주 무지한 생각으로 해당 버전으로 인증을 진행하려고 테스트 폰도 iPhone XS (iOS 14.4.1) 로 가져왔다.</p>
<p>그리고 여기서부터 문제가 발생했다
휴대폰에서 개발자 모드가 안보인다는점
<strong>iOS 16부터 개발자 모드가 생겼다는 것이다!</strong></p>
<p>그래도 Xcode로 빌드하면 된다고 하길래 재연결하여 진행하려고 했다.</p>
<p>Xcode에 접속하여 <code>Command + shft + 2</code> 입력 후, 타켓 아이폰을 Unpair Device 진행한 다음, 다시 연결하여 아이폰에서 신뢰하는 컴퓨터로 추가 후, 해당 경로로 다시 이동하면 보일 것!</p>
<p><img src="https://velog.velcdn.com/images/heina-effect/post/12ad9321-ff81-4f3f-96ca-8e4c2a7e30b3/image.png" alt=""></p>
<p>그러나 여기서 더 큰 문제 발생!
<strong>iOS14.4.1 버전은 Xcode에 12.4버전과 호환됨</strong>
그리고  Xcode에 12.4버전은 MacOS 14.x 이상 부터는 호환되지 않음!</p>
<p>대큰절망했지만 3시간 정도 뚝딱거렸더니 개발자 모드로 돌아가긴 하더라..</p>
<h2 id="xcode-다운그레이드-하는-방법">Xcode 다운그레이드 하는 방법</h2>
<h3 id="1️⃣-애플-개발자-사이트에서-해당-버전-다운로드">1️⃣ 애플 개발자 사이트에서 해당 버전 다운로드</h3>
<p><a href="https://developer.apple.com/download/all/">developer.apple/download</a>
<img src="https://velog.velcdn.com/images/heina-effect/post/ad4d073a-d44a-4e2e-8345-aed4365ebb97/image.png" alt=""></p>
<p>다운로드가 완료되면 .xip 파일을 더블 클릭해 압축을 풀고, <strong>/Applications</strong> 폴더로 이동시킨다.
만약 다양한 버전을 사용할거라면 다운로드 후 파일 이름을 변경해주는것이 좋다. 
<img src="https://velog.velcdn.com/images/heina-effect/post/7d8cede2-1ccb-41db-a0c3-0a6d73986171/image.png" alt=""></p>
<p>그리고 터미널에서 사용할 버전을 변경하여 선택해준다 </p>
<pre><code class="language-bash">sudo xcode-select -s /Applications/Xcode12.4.app
xcode-select -p # 위치 확인
xcodebuild -version # 버전 확인

# 아래와 같이 나타남
/Applications/Xcode12.4.app/Contents/Developer </code></pre>
<h3 id="2️⃣-xcodes를-설치해서-원하는-버전-간편-설치">2️⃣ xcodes를 설치해서 원하는 버전 간편 설치</h3>
<p><a href="https://www.xcodes.app/">xcodes</a> 라는 프로그램을 사용하여 자동 설치 및 버전 변경이 가능하다고 한다
<a href="https://tngusmiso.tistory.com/83">참고 사이트: https://tngusmiso.tistory.com/83</a></p>
<p><img src="https://velog.velcdn.com/images/heina-effect/post/cc81a244-207e-4c16-b77f-6780e6eb01c9/image.png" alt="">
<img src="https://velog.velcdn.com/images/heina-effect/post/0816f738-2842-4185-8ae5-57af97c119da/image.png" alt=""></p>
<p>근데 결국 설치가 안되서 수동설치로 재진행함... 또륵
<img src="https://velog.velcdn.com/images/heina-effect/post/7624e27f-fdf6-45ea-8f00-7940d9074d07/image.png" alt=""></p>
<h3 id="3️⃣-ios-144x-버전은-xcode-1251-설치">3️⃣ iOS 14.4.x 버전은 xcode 12.5.1 설치</h3>
<p>여기서 추가로 12.4 설치 실행시 오류가 났는데 운영체제(<strong>macOS 14 Sonoma</strong>)와 Xcode 버전 간 호환성 문제로 인해 내부 프라이빗 프레임워크가 누락되어 실행이 아예 불가능한 상태라고 하여 12.5.1 설치를 권장한다고 하여 버전을 살짝 올렸다.</p>
<pre><code class="language-bash">sudo xcode-select -s /Applications/Xcode12.5.1.app
xcode-select -p # 위치 확인
xcodebuild -version # 버전 확인

# 아래와 같이 나타남
/Applications/Xcode12.5.1.app/Contents/Developer</code></pre>
<p>이후 강제로 실행하기로 했는데, xcode 에서 폴더에서 새로운 터미널 열기 설정하여 실행 하면 아래와 같이 실행된다.</p>
<pre><code class="language-bash"> cd Contents/MacOS
 ./Xcode</code></pre>
<p><img src="https://velog.velcdn.com/images/heina-effect/post/f69b1c24-54b4-4515-b784-1d831d8932d7/image.png" alt=""></p>
<p><img src="https://velog.velcdn.com/images/heina-effect/post/99ba187d-13ce-4577-a292-97c342c076cd/image.png" alt=""></p>
<p>개발자 활성화 되며 인스펙터 실행 하고 싶을 경우 <code>설정 &gt; Safari &gt; 고급 &gt; 웹 속성 ON</code></p>
<h2 id="xcode로-빌드시키고-싶을-때">Xcode로 빌드시키고 싶을 때</h2>
<p>이것은 apple 개발자 사이트에 해당 디바이스의 UUID가 등록되어있어야 하는 상태임! (무조건)</p>
<p>근데 이렇게 하나 저렇게 하나 사실 빌드가 안되었다. 그러다 꼼수를 부려봤는데
Xcode 16에서 iOS 14.4를 강제로 빌드시키도록 하는 방법이였다.
테스트가 인스펙터로는 한계가 있기 때문에 꼭 실제 기기에서 이루어져야 했다.</p>
<p>해당명령어를 실행하여 Xcode16을 강제로 열어서 Platforms에 해당 iOS버전 폴더를 넣고 나면 
iOS버전을 읽게 되어 빌드가 된다..</p>
<pre><code class="language-bash"> open /Applications/Xcode16.0.app/Contents/Developer</code></pre>
<p><img src="https://velog.velcdn.com/images/heina-effect/post/54f906bc-fc52-4263-b6be-8e2decc18c17/image.png" alt=""></p>
<p>이렇게 삽질을 많이 하였으나 결론....
<em><strong>지원되지 않는 CSS 들이 많아서 결국 최소 사양 버전을 올리기로 했다..</strong></em></p>
]]></description>
        </item>
        <item>
            <title><![CDATA[Vue+Vite 환경에서 배포 후 흰화면 발생할 때]]></title>
            <link>https://velog.io/@heina-effect/Vue-Vite-%ED%99%98%EA%B2%BD%EC%97%90%EC%84%9C-%EB%B0%B0%ED%8F%AC%ED%9B%84-%ED%9D%B0%ED%99%94%EB%A9%B4-%EB%B0%9C%EC%83%9D%ED%95%A0-%EB%95%8C</link>
            <guid>https://velog.io/@heina-effect/Vue-Vite-%ED%99%98%EA%B2%BD%EC%97%90%EC%84%9C-%EB%B0%B0%ED%8F%AC%ED%9B%84-%ED%9D%B0%ED%99%94%EB%A9%B4-%EB%B0%9C%EC%83%9D%ED%95%A0-%EB%95%8C</guid>
            <pubDate>Fri, 09 May 2025 05:40:57 GMT</pubDate>
            <description><![CDATA[<h1 id="🔴-발생한-문제">🔴 발생한 문제</h1>
<p>개발서버에 개발한것을 반영하였을 때, 모바일웹앱을 실행하면 이전 화면이 나타나지 않고 흰 화면이 나타나는 매혹적이고 치명적인 오류가 발생했다.</p>
<p>그래서 테스트를 하거나 앱을 시연할 때 앱 캐시를 한번 날리고 진행했다. </p>
<p>그렇게 연명하며 살던 도중 우리는 오류를 발견하였는데 </p>
<h2 id="🔍발견한-오류">🔍발견한 오류</h2>
<pre><code>Uncaught (in promise) TypeError: Failed to fetch dynamically imported module:
https://.../assets/atdrnModify-37776692.js</code></pre><p>앱이 기능을 실행할 때 필요한 파일 <strong>atdrnModify-37776692.js</strong> 을 불러오려고 했는데, 그 파일이 <strong>서버에 없어서</strong> 실패한 상황이다. </p>
<h2 id="🧨-문제-발생의-이유">🧨 문제 발생의 이유</h2>
<h3 id="앱은-기능별로-파일을-쪼개서-불러온다-동적-import">앱은 기능별로 파일을 쪼개서 불러온다. (동적 import)</h3>
<p>Vue + Vite는 성능을 위해 화면 전환 시 필요한 기능만 <strong>동적으로 불러오는 방식(lazy loading)</strong> 을 사용한다.</p>
<h3 id="새로-배포하면-파일명이-바뀐다">새로 배포하면 파일명이 바뀐다.</h3>
<p>새로운 버전이 배포되면, 기존에 있던 atdrnModify-37776692.js 같은 파일은 삭제되고,
대신 atdrnModify-11223344.js 처럼 <strong>새 이름의 파일이 생성</strong>된다.</p>
<h3 id="모바일-사용자는-오래된-페이지를-볼-수-없다">모바일 사용자는 오래된 페이지를 볼 수 없다.</h3>
<p>예를 들어, A 사용자가 오전에 웹앱을 켜둔 채 3시간 뒤에 다시 화면을 눌렀을 때:
→ 이미 서버에는 옛날 파일이 사라졌고, 앱은 여전히 옛날 파일을 불러오려 해서 실패한다.</p>
<h1 id="🧠-해결을-위한-고민">🧠 해결을 위한 고민</h1>
<h2 id="💡문제-해결을-위한-방법들">💡문제 해결을 위한 방법들</h2>
<h3 id="1-모든-파일-이름을-고정시키기">1. 모든 파일 이름을 고정시키기</h3>
<p><strong>chunk 파일명을 고정시켜 삭제 문제 방지</strong>
단, 캐시 문제가 생기기 쉬워 권장되지 않음</p>
<h3 id="2-pwa-캐시-강제-삭제">2. PWA 캐시 강제 삭제</h3>
<p><strong>서비스워커에서 캐시를 지우고 새 버전 강제 로드</strong>
이건 설정이 복잡하고 테스트가 어려워 (내가) 함부로 건들 수 있는 영역이 아니라 패스</p>
<h3 id="3-안내-후-새로고침">3. 안내 후 새로고침</h3>
<p><strong>에러가 나면 “새로고침 하시겠습니까?” 라는 안내창 표시</strong>
해당 방법은 기획과 이야기가 되지 않았고, 만약 사용자가 거절하면 문제가 지속되는 문제가 있음...</p>
<h3 id="4-앱-새로고침">4. 앱 새로고침</h3>
<p><strong>문제가 생기면 자동 새로고침해서 최신 버전을 로드</strong>
단, 사용자 경험이 조금 끊긴다</p>
<p><em>그래서 난 가장 해결이 간단하고 확실한 해결책을 사용하기로 했다.
사용자도 웹앱을 다시 껏다 켠 것 같은 느낌만 받을 뿐, 앱이 멈추거나 흰 화면이 노출되지는 않을테니까 (아마도)</em></p>
<h2 id="✅-해결">✅ 해결</h2>
<p>main.js에 설정해 두었다</p>
<pre><code class="language-js">// Vite가 동적 파일을 못 불러오면
window.addEventListener(&#39;vite:preloadError&#39;, (event) =&gt; {
  event.preventDefault(); // 기본 에러 막고
  window.location.reload(); // 자동 새로고침
});

// Vue Router가 lazy import 실패 시
router.onError((error) =&gt; {
  if (/Failed to fetch dynamically imported module/.test(error.message)) {
    window.location.reload();
  }
});</code></pre>
<p>배포 후 확인해 보았을 때 아직까지 흰화면 발생하는 현상은 발견하지 못했다. 
만약 추후에 발견될 경우 추가적으로 조치 예정</p>
<h1 id="번외">번외</h1>
<h2 id="hash-가-도대체-뭐야">Hash 가 도대체 뭐야</h2>
<p>관련 문제들을 검색했을 때 <strong>Vue-Router에서</strong> 사용하는 <strong>URL 모드를 수정</strong>하라는 방법과, <strong>vite.config.js에 캐시제어</strong>를 하라는 방법들이 많이 나타났다.</p>
<h3 id="🔹-1-createwebhistory-or-createwebhashhistory-란">🔹 1. createWebHistory() or createWebHashHistory() 란?</h3>
<pre><code class="language-js">import { createWebHistory } from &#39;vue-router&#39;

const router = createRouter({
  history: createWebHistory(), // 히스토리 모드
  routes,
})</code></pre>
<p>📌 <strong>createWebHistory()</strong></p>
<ul>
<li>브라우저의 History API를 사용해서 URL에 # 없이 깔끔한 경로(/home) 를 사용한다. 예: <a href="https://example.com/home">https://example.com/home</a></li>
</ul>
<p>📌 <strong>createWebHashHistory()</strong></p>
<ul>
<li>URL에 #이 들어감 
예: <a href="https://example.com/#/home">https://example.com/#/home</a></li>
</ul>
<p>즉, <strong>라우팅 방식의 차이</strong>를 말한다.</p>
<h3 id="2-hash-in-viteconfigjs란">2. [hash] in vite.config.js란?</h3>
<pre><code class="language-js">rollupOptions: {
  output: {
    entryFileNames: &#39;[name].[hash].js&#39;,
    chunkFileNames: &#39;[name].[hash].js&#39;,
    assetFileNames: &#39;assets/[name].[hash].[ext]&#39;,
  },
},</code></pre>
<p>빌드된 파일명에 [hash]를 붙이면, 파일 내용이 바뀌면 이름도 바뀜
예: main.js → main.abc123.js</p>
<p>이는 <strong>브라우저 캐시를 무력화</strong> 시키기 위한 파일 캐싱 제어 방법이다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[Flutter에서 WebView의 폰트 크기 제어하기]]></title>
            <link>https://velog.io/@heina-effect/Flutter%EC%97%90%EC%84%9C-WebView%EC%9D%98-%ED%8F%B0%ED%8A%B8-%ED%81%AC%EA%B8%B0-%EC%A0%9C%EC%96%B4%ED%95%98%EA%B8%B0</link>
            <guid>https://velog.io/@heina-effect/Flutter%EC%97%90%EC%84%9C-WebView%EC%9D%98-%ED%8F%B0%ED%8A%B8-%ED%81%AC%EA%B8%B0-%EC%A0%9C%EC%96%B4%ED%95%98%EA%B8%B0</guid>
            <pubDate>Mon, 28 Apr 2025 05:51:07 GMT</pubDate>
            <description><![CDATA[<h1 id="🚨flutter-폰트-사이즈-조절-문제">🚨Flutter 폰트 사이즈 조절 문제</h1>
<p>Flutter에서 WebView를 사용하다 보면 디바이스의 시스템 폰트 크기가 커질 때, 웹 페이지의 폰트 크기도 함께 커지는 문제가 발생할 수 있다.</p>
<p>예전에 Flutter에서만 개발할 때 클라이언트의 노안이슈로 화면 내 방지턱 현상이 발생해서 한번 크게 고생했던 적이있다.
<img src="https://velog.velcdn.com/images/heina-effect/post/ae21b6de-e45c-4b78-bfe5-a10acaece37b/image.jpg" alt=""></p>
<p>그래서 이번에는 미리 해당 문제를 방지하고자 했다. 
 이 문제는 <strong>Android</strong>와 <strong>iOS</strong>에서 각각 다르게 처리해야 할 부분이 있다!</p>
<h2 id="🤖-android에서의-해결-방법">🤖 Android에서의 해결 방법</h2>
<h3 id="첫-번째-시도-mediaquery에서-textscaler-사용">첫 번째 시도: MediaQuery에서 textScaler 사용</h3>
<pre><code class="language-dart">final MediaQueryData data = MediaQuery.of(context);
return MediaQuery(
  data: data.copyWith(textScaler: const TextScaler.noScaling),
  child: child!,
);</code></pre>
<pre><code class="language-dart">final MediaQueryData data = MediaQuery.of(context);
return MediaQuery(
  data: data.copyWith(textScaler: const TextScaler.linear(1)),
  child: child!,
);</code></pre>
<blockquote>
<p><strong>textScaleFactor</strong> 는 텍스트의 크기를 비율로 설정하여 조정하고, 기본값 1.0은 기본 크기를 유지하게 한다.
<strong>textScaler (예: TextScaler.noScaling)</strong> 는 텍스트 크기의 스케일링을 완전히 비활성화하여, 시스템의 텍스트 크기 설정을 무시하게 만든다.</p>
</blockquote>
<p><a href="https://jutole.tistory.com/112">[Flutter] TextScale fix (텍스트 크기 고정) 참고링크</a></p>
<h4 id="시도결과">시도결과</h4>
<p>처음에는 <strong>MediaQuery를</strong> 사용하여 앱 전체의 폰트 크기를 고정하려 했음. 그러나 MediaQuery는 WebView의 텍스트 크기까지 제어할 수 없었음. WebView는 HTML 콘텐츠를 로드하는 별도의 컴포넌트이므로 <strong>MediaQuery</strong> 설정만으로는 해결되지 않았음. ㅠ^ㅠ</p>
<h3 id="두-번째-시도-textzoom-속성-설정">두 번째 시도: textZoom 속성 설정</h3>
<p>Flutter에서는 <strong>WebViewController</strong>를 통해 직접 setTextZoom 메서드를 호출하여 텍스트 크기를 제어할 수 있음. 이 방법은 주로 Android에서 사용된다.
<a href="https://developer.android.com/reference/android/webkit/WebSettings#setTextZoom(int)">android WebSettings 관련 공식 문서</a></p>
<p>WebViewController의 <strong>platform</strong> 속성을 사용해서 <strong>AndroidWebViewController</strong>에 접근한 후, setTextZoom을 통해 텍스트 크기를 100%로 고정했다.</p>
<pre><code class="language-dart">class WebviewMainController extends GetxController {
  late final WebViewController controller;

  WebviewMainController() {
    // WebViewController 초기화
    controller = WebViewController();

    // WebViewController 설정
    controller
      ..setJavaScriptMode(JavaScriptMode.unrestricted)
      ..setBackgroundColor(const Color(0x00000000))
      ..setNavigationDelegate(...);

    // Android 플랫폼에서 텍스트 줌 고정
    if (controller.platform is AndroidWebViewController) {
      final androidController = controller.platform as AndroidWebViewController;
      androidController.setTextZoom(100);  // 텍스트 줌을 100%로 고정
    }
  }
}</code></pre>
<p>드디어 됐다!!!!!! 폰트가 고정되어서 작아졌지만.. 알게뭐람 우선해!! 나중에 비율 수정만 하면 되니까!!!
<img src="https://velog.velcdn.com/images/heina-effect/post/471584c2-88f7-4fbb-8afa-96d426ca3fd9/image.jpg" alt=""></p>
<h2 id="🍏-ios에서의-해결-방법">🍏 iOS에서의 해결 방법</h2>
<p>iOS는 기본적으로 <strong>-webkit-text-size-adjust: none;</strong>을 무시할 때가 있지만, HTML 내에서 폰트 크기를 명시적으로 설정하면 시스템 폰트 크기 변경이 WebView에 영향을 미치지 않게 된다. 🧐</p>
<pre><code class="language-scss">html{
  font-size: 62.5%; // 기본 10px 기준
  background-color: $bg-color;
  -webkit-text-size-adjust: none; /*Chrome, Safari, newer versions of Opera*/
  -moz-text-size-adjust: none; /*Firefox*/
  -ms-text-size-adjust: none; /*Ie*/
  -o-text-size-adjust: none; /*old versions of Opera*/
}</code></pre>
<p>해결 완료...장렬하게 전사해.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[Flutter vs JavaScript: 네트워크 감지, 누가 더 잘할까?]]></title>
            <link>https://velog.io/@heina-effect/Flutter-vs-JavaScript-%EB%84%A4%ED%8A%B8%EC%9B%8C%ED%81%AC-%EA%B0%90%EC%A7%80-%EB%88%84%EA%B0%80-%EB%8D%94-%EC%9E%98%ED%95%A0%EA%B9%8C</link>
            <guid>https://velog.io/@heina-effect/Flutter-vs-JavaScript-%EB%84%A4%ED%8A%B8%EC%9B%8C%ED%81%AC-%EA%B0%90%EC%A7%80-%EB%88%84%EA%B0%80-%EB%8D%94-%EC%9E%98%ED%95%A0%EA%B9%8C</guid>
            <pubDate>Fri, 25 Apr 2025 08:10:53 GMT</pubDate>
            <description><![CDATA[<p><img src="https://velog.velcdn.com/images/heina-effect/post/21b8a4c3-a0b1-4383-91dc-c3187f07a397/image.jpeg" alt="noWifi"></p>
<h1 id="어쩌다가">어쩌다가?</h1>
<p>비행기 안에서 앱을 켜보신 QA 천재 개발자님의 피드백으로부터 시작된 이 이야기. 기내 Wi-Fi가 없는 환경에서 앱이 정상 작동하지 않는다는 아주 소중한 리포트를 받고, 고민을 시작했다.</p>
<p>&quot;우리 앱은 Vue3 + Flutter인데, 네트워크 감지를 웹에서 할까, 아니면 네이티브(Futter)에서 할까?&quot;</p>
<h1 id="javascript에서는-어떻게-처리하는데">Javascript에서는 어떻게 처리하는데?</h1>
<p>웹에서는 아주 간단하게 네트워크 감지를 할 수 있는 <strong>내장 API</strong>가 존재한다.</p>
<h2 id="javascript-처리-하는-방법">Javascript 처리 하는 방법</h2>
<ol>
<li><p>브라우저가 네트워크에 연결되어 있는지 확인하는 <strong>내장 API</strong> <code>navigator.onLine</code> 사용하기</p>
</li>
<li><p>브라우저에 네트워크 상태가 바뀔 때 연결 상태 변화 감지할수 있는 <code>addEventListener</code> 사용하기</p>
</li>
<li><p>초기값을 설정하고 네트워크 변경을 확인하여 UI를 변경하거나 네트워크 재시도 요청 시도 하기</p>
</li>
</ol>
<p>이것저것 사부작거린 코드 내용들</p>
<pre><code class="language-js">&lt;script setup&gt;
onMounted(() =&gt; {
  console.log(&#39;현재 상태:&#39;, navigator.onLine ? &#39;온라인&#39; : &#39;오프라인&#39;);

  window.addEventListener(&#39;online&#39;, () =&gt; {
    console.log(&#39;📡 온라인으로 복구됨&#39;);
  });

  window.addEventListener(&#39;offline&#39;, () =&gt; {
    console.log(&#39;📴 오프라인으로 전환됨&#39;);
  });
}


const isOnline = ref(navigator.onLine);

const updateNetworkStatus = () =&gt; {
  isOnline.value = navigator.onLine;
  console.log(&#39;####&#39;, navigator.onLine);
  /*  if (isOnline.value) {
    retryCallbacks.forEach((cb) =&gt; cb());
  }*/
};
&lt;/script&gt;</code></pre>
<h2 id="javascript에서의-단점">Javascript에서의 단점</h2>
<p>이렇게 순조롭게 일이 풀리면 내가 아니지?
생각해보니 앱 진입 시점에서 네트워크가 없다면 웹을 가져오지도 못하기 때문에 원하는 화면을 보여줄수도 없잖아!!!!</p>
<p>또한 인터넷 연결 여부를 완전히 보장하지도 않는다. 예로 wifi에 연결되어 있더라도 인터넷이 안될수있다.</p>
<p><img src="https://velog.velcdn.com/images/heina-effect/post/5f01fc51-aee1-4be5-91dc-61af7a14a14a/image.jpg" alt="OVANDE"></p>
<h1 id="그렇다면-flutter에서는">그렇다면 Flutter에서는?</h1>
<p>아주 만족스럽게도 flutter에서 제공하는 패키지가 있다.</p>
<h2 id="connectivity_plus"><code>connectivity_plus</code></h2>
<p><a href="https://pub.dev/packages/connectivity_plus">https://pub.dev/packages/connectivity_plus</a></p>
<p>패키지에서 제공하는 플랫폼인데 얼마나 아름다운가!
<img src="https://velog.velcdn.com/images/heina-effect/post/b51c5fa4-f879-46f1-8396-309609edc809/image.png" alt=""></p>
<p>그리고 요구되는 버전도 잘 파악하길 바란다.
<img src="https://velog.velcdn.com/images/heina-effect/post/952738ab-183b-4d64-b049-8b44c4684488/image.png" alt=""></p>
<p>해당 패키지를 통해 Wi-Fi, Mobile, No Connection 등의 구분이 명확하게 가능하고, javascript보다 세밀하게 제어가 가능하다.</p>
<blockquote>
<p>그래서 내가 생각한 구성</p>
</blockquote>
<ol>
<li>실시간 감지하는 감시자 생성</li>
<li>네트워크 상태의 따라 화면 분기처리</li>
</ol>
<p>-&gt; 이론은 항상 완벽하다.</p>
<p>자 그럼 시작해볼까</p>
<h3 id="🧠-step-1-네트워크-상태를-감지하는-감시자-만들기">🧠 Step 1: 네트워크 상태를 감지하는 감시자 만들기</h3>
<p>network_connectivity_observer.dart</p>
<pre><code class="language-dart">import &#39;package:connectivity_plus/connectivity_plus.dart&#39;;

enum Status {
  available,
  unavailable,
}

class NetworkConnectivityObserver {
//connectivity_plus 패키지를 이용해서 Wi-Fi, LTE 등 모든 연결 상태를 감지
  final Connectivity _connectivity = Connectivity();

  Stream&lt;Status&gt; observe() async* {
    // 처음 연결 상태 체크
    final initial = await _connectivity.checkConnectivity();
    yield _convertToStatus(initial); // 리스트로 감싸서 전달

    // 이후 상태 변경 감지
    yield* _connectivity.onConnectivityChanged.map((List&lt;ConnectivityResult&gt; results) {
      return _convertToStatus(results); // 리스트로 처리
    });
  }

  // 리스트 형태로 연결 상태 확인
  Status _convertToStatus(List&lt;ConnectivityResult&gt; results) {
    // 리스트에서 하나라도 연결되어 있으면 available로 처리
    for (var result in results) {
      if (result != ConnectivityResult.none) {
        return Status.available;
      }
    }
    return Status.unavailable;
  }
}</code></pre>
<ul>
<li>connectivity_plus 패키지를 사용해서 네트워크 상태를 감지</li>
<li>enum Status를 통해 연결 여부를 명확하게 표현</li>
<li>스트림으로 상태를 보내주기 때문에 실시간으로 감지</li>
</ul>
<h3 id="🧱-step-2-연결-상태에-따라-ui를-분기하는-webviewcontainer">🧱 Step 2: 연결 상태에 따라 UI를 분기하는 WebviewContainer</h3>
<p>webview_container.dart</p>
<pre><code class="language-dart">import &#39;dart:async&#39;;
import &#39;package:flutter/material.dart&#39;;
import &#39;package:safeit_mobile_native/app/safeit_app/webview/views/screen/offline_screen.dart&#39;;
import &#39;package:safeit_mobile_native/app/safeit_app/webview/views/screen/webview.dart&#39;;
import &#39;package:safeit_mobile_native/app/utils/service/network_connectivity_observer.dart&#39;;


class WebviewContainer extends StatefulWidget{
  const WebviewContainer({super.key});

  @override
  State&lt;WebviewContainer&gt; createState() =&gt; _WebviewContainerState();
}

class _WebviewContainerState extends State&lt;WebviewContainer&gt; {
  final observer = NetworkConnectivityObserver();
  late StreamSubscription&lt;Status&gt; _subscription;
  Status? _status;

  @override
  void initState() {
    super.initState();

    _subscription = observer.observe().listen((status) {
      setState(() {
        _status = status;
      });
    });
  }

  @override
  void dispose() {
    _subscription.cancel();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    if(_status == Status.unavailable){
      return const OfflineScreen();
    }
    return Webview();
  }
}
</code></pre>
<ul>
<li>앱이 실행되면 네트워크 상태를 구독하고 _status를 업데이트함</li>
<li>오프라인이면 OfflineScreen, 연결되면 WebView를 보여주기</li>
</ul>
<h3 id="📵-step-3-오프라인-안내-전용-화면-만들기">📵 Step 3: 오프라인 안내 전용 화면 만들기</h3>
<p>offline_screen.dart</p>
<pre><code class="language-dart">class OfflineScreen extends StatelessWidget {
  final VoidCallback? onRetry;

  const OfflineScreen({super.key, this.onRetry});

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      backgroundColor: Colors.white,
      appBar: PreferredSize(
        preferredSize: const Size.fromHeight(0),
        child: AppBar(elevation: 0),
      ),
      body: SafeArea(
        bottom: Platform.isAndroid ? true : false,
        child: Center(
          child: Column(
            mainAxisAlignment: MainAxisAlignment.center,
            children: [
              SvgPicture.asset(&quot;assets/images/icons/no_signal.svg&quot;,
                width: MediaQuery.sizeOf(context).width / 5,
              ),
              const SizedBox(height: 20),
              Text(&quot;인터넷 연결이 끊어졌습니다.&quot;, style: TextStyle(fontSize: 22, height: 2)),
              Text(&quot;원활한 이용을 위해 네트워크 상태를&quot;),
              Text(&quot;확인한 후 다시 시도해 주세요.&quot;),
              const SizedBox(height: 50),
              SizedBox(
                width: 120,
                child: Button(
                  text: &#39;재시도&#39;,
                  func: () {
                    // 추후 재시도 기능 구현 가능
                  },
                  borderColor: const Color(0xFF2e81ff),
                  backgroundColor: Colors.white,
                  fontColor: const Color(0xFF2e81ff),
                ),
              ),
            ],
          ),
        ),
      ),
    );
  }
}</code></pre>
<p><img src="https://velog.velcdn.com/images/heina-effect/post/76dcacad-30b9-41ad-80a1-0accb46c078e/image.png" alt=""></p>
<ul>
<li>사용자에게 현재 네트워크가 끊겼다는 점 알려주기</li>
<li>&quot;재시도&quot; 버튼은 향후 기능 확장을 위해 추가 </li>
<li>지금은 재시도 하는 &#39;척&#39;.. 엣큥ㅎ</li>
</ul>
<h2 id="느낀점">느낀점..</h2>
<p>Flutter로 구현하면서 웹보다 훨씬 정교하고 강력한 제어가 가능하다는 걸 느꼈고 마음이 매우 편안했다. 
인터넷이 안 되더라도 우아하게 안내 가넝!
이걸로 며칠을 고민하다니!!!.. 따흐흑 그래도 인터넷 제발 끊기지는 말아죠..</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[Flutter 에서 iOS 빌드 환경 설정하기]]></title>
            <link>https://velog.io/@heina-effect/Flutter-%EC%97%90%EC%84%9C-ios-%EB%B9%8C%EB%93%9C-%ED%99%98%EA%B2%BD-%EC%84%A4%EC%A0%95%ED%95%98%EA%B8%B0</link>
            <guid>https://velog.io/@heina-effect/Flutter-%EC%97%90%EC%84%9C-ios-%EB%B9%8C%EB%93%9C-%ED%99%98%EA%B2%BD-%EC%84%A4%EC%A0%95%ED%95%98%EA%B8%B0</guid>
            <pubDate>Tue, 03 Dec 2024 08:25:17 GMT</pubDate>
            <description><![CDATA[<p> ⚠️ 저는 앱개발자가 아님미다. 저는 말하는 돌맹이일 뿐이며, ios는 처음 만져보는것을 알려드립니다. ⚠️</p>
<h2 id="현재-상황-이렇게-저렇게-이케저케-빌드중">현재 상황: 이렇게 저렇게 이케저케 빌드중</h2>
<p>기존에 다른 프로젝트에서 숟가락떠서 가져온 프로젝트라 기본적인 셋팅은 되어있었다. 물론 안드로이드만, ios는 다 무너져가는 국밥집 같았다.</p>
<p>main.dart 에서 실행을 시키면 </p>
<pre><code class="language-dart">Future&lt;void&gt; configApp() async {
  // 빌드 환경 분리
  // appFlavor : local | dev | prod
  await dotenv.load(fileName: &#39;.env&#39;);
  FlavorConfig(appFlavor);
  ...</code></pre>
<p><code>Flavor</code> 오류가 지속적으로 발생하는 상황</p>
<p>FlavorConfig 구경하러 가보자.</p>
<pre><code class="language-dart">import &#39;package:flutter_dotenv/flutter_dotenv.dart&#39;;

enum FlavorEnv {
  local(&#39;local&#39;),
  dev(&#39;dev&#39;),
  prod(&#39;prod&#39;);

  const FlavorEnv(this.env);
  final String env;

  factory FlavorEnv.getEnv(String env) {
    return FlavorEnv.values.firstWhere(
          (value) =&gt; value.env == env
    );
  }
}

class FlavorConfig {
  static FlavorConfig? _instance;
  static FlavorConfig get instance =&gt;
      _instance ?? FlavorConfig(FlavorEnv.dev.name);
  final String url;

  // IP instead of localhost.
  FlavorConfig._local() : url = dotenv.get(&#39;LOCAL_URL&#39;);

  FlavorConfig._dev() : url = dotenv.get(&#39;DEV_URL&#39;);

  // TODO: 운영 서버 URL 변경
  FlavorConfig._prod() : url = dotenv.get(&#39;PROD_URL&#39;);

  factory FlavorConfig(String? flavor) {
    switch (FlavorEnv.getEnv(flavor!)) {
      case FlavorEnv.local:
        _instance = FlavorConfig._local();
        break;
      case FlavorEnv.dev:
        _instance = FlavorConfig._dev();
        break;
      case FlavorEnv.prod:
        _instance = FlavorConfig._prod();
        break;
      default:
        _instance = FlavorConfig._dev();
        break;
    }
    return instance;
  }
}
</code></pre>
<p>빌드 환경이 분리되어있는것을 확인할 수 있다. 
<em><strong>.env를 통해 각각의 url로 찔러야하니 이것은 본인의 개발 환경에 맞게 설정하길 바랍니다.</strong></em></p>
<p>그런데 이후에 설정되어있는것은 없었다. 그래서 급하게 구동되는지 확인하기 위해 
main.dart에서 <code>FlavorConfig(&#39;local&#39;)</code> 하드코딩하여 사용하였는데, 더 이상 참을 수 없었다. (그냥)</p>
<p> <img src="https://velog.velcdn.com/images/heina-effect/post/fab9eae9-43d3-4fb2-a177-5f93938a7010/image.png" alt=""></p>
<h2 id="ios-native-설정-시작-width-flavor">iOS Native 설정 시작 width &#39;Flavor&#39;</h2>
<p>확인해보니 ios는 xcode를 통하여 빌드 및 flavor 설정을 할 수 있다고 한다.
<a href="https://velog.io/@udong85/Flutter-flavor-%EB%A5%BC-%EC%9D%B4%EC%9A%A9%ED%95%9C-%EA%B0%9C%EB%B0%9C-%EC%9A%B4%EC%98%81-%ED%99%98%EA%B2%BD-%EC%84%A4%EC%A0%95#android-manifest-%EC%84%A4%EC%A0%95">flavor 를 이용한 개발, 운영 환경 설정 (출처: dong.log)</a></p>
<h3 id="1-configrations-설정">1. Configrations 설정</h3>
<p>1) 왼쪽 상단에 <code>Runnuer</code> 클릭 &gt; PROJECT 내 <code>Runnuer</code> 선택 &gt; <code>info</code> Tab 선택 &gt; 하단의 <code>+</code> 버튼 클릭
2) Duplicate 하여 각각의 <strong>-prod</strong>, <strong>-local</strong>, <strong>-dev</strong> 추가
<em>(기존에는 Debug, Relase, Profile 세개의 row만 있었음)</em>
3) 
<img src="https://velog.velcdn.com/images/heina-effect/post/149914dc-effa-47d8-89d2-89513dade42c/image.png" alt="ios-configrations설정"></p>
<h3 id="2-schemes-설정">2. Schemes 설정</h3>
<p>1) main toolbar 에서 Product &gt; Scheme &gt; Manage Schemes 
<img src="https://velog.velcdn.com/images/heina-effect/post/25a6f9b8-cf10-4ffa-9c4d-f8907c926ff8/image.png" alt="">
또는 상단의 아이콘을 통해서 Manage Schemes 들어가기
<img src="https://velog.velcdn.com/images/heina-effect/post/b270cca1-5056-4466-a610-cdab5757eae8/image.png" alt="">
2) Runner 를 복사하여 local, dev, prod 를 만든다
<img src="https://velog.velcdn.com/images/heina-effect/post/82ec20a0-b297-4df1-a384-80c1c3617137/image.png" alt="">
3. Edit Shecme을 선택하여 각각의 탭들의 설정들을 변경한다
(Run, Text, Profile, Analyze, Archive) 에 선택된 build configuration 이 <strong>-local</strong> ,<strong>-dev</strong> 임 
<img src="https://velog.velcdn.com/images/heina-effect/post/b456a862-9962-4274-b8f4-409ce3691361/image.png" alt="">
막간을 이용한 틀린 그림 찾기 시간 ^--------^*
<img src="https://velog.velcdn.com/images/heina-effect/post/440105fe-0c8b-411d-8abf-4009a94b9470/image.png" alt="">
각각의 세션들의 내용은 이렇다.</p>
<blockquote>
<p><code>Run</code>
<code>Build</code>
<code>Test</code> : 빌드 후 테스트 실행
<code>Profile</code> : 특정 기기에서 퍼포먼스 체크용도로 사용
<code>Analyze</code> : static analyzer를 사용해서 빌드를 진행하고 코드의 버그를 검사
<code>Archive</code> : 앱 스토어나 테스트 플라이트, 혹은 adhoc에 보내기 위해 코드 사이닝을 포함해서 빌드를 진행</p>
</blockquote>
<h3 id="3-build-setting-에-설정">3. Build Setting 에 설정</h3>
<p>1) 좌측에 TARGETS &gt; Build Settings 에서 직접 찾으면 좋겠지만 내용이 많으므로, <code>Packaging</code> 검색 &gt; <strong>Product Bundle identifier</strong> &gt; 
빌드 환경에 맞게 각각 .dev, .local 변경해준다
<img src="https://velog.velcdn.com/images/heina-effect/post/e7d877d9-785c-46c0-afb9-33d14d18c5da/image.png" alt=""></p>
<p>2) <code>APP_NAME</code>을 추가 및 <code>APP_FALVOR</code> 추가
각 빌드마다 앱 이름을 다르게 설정하기 위해 APP_NAME 값을 추가 해 준다.
<img src="https://velog.velcdn.com/images/heina-effect/post/99df6063-26f1-470a-9a96-06385b0c4464/image.png" alt="">
<img src="https://velog.velcdn.com/images/heina-effect/post/89b32d21-253b-4ccb-91fb-c54845be3a31/image.png" alt=""></p>
<blockquote>
<p>app_flavor추가 후, flutter 소스에서 AppDelegate에 Method Channel 설정해야 native 의 build 값을 flavor로 가지고 올 수 있기 때문에, 각 빌드 scheme 에 맞는 flavor 값을 추가 설정 해주어야 한다.</p>
</blockquote>
<p><em>여기서 고백하자면 사실 나는 저 이름대로 빌드되지 않는다. 그렇다고 pubspec.yaml에 설정된대로 되지도 않는다..(안드로이드는 됨)
이것은 차차 고민해봐야 할 문제다...ㅠㅠ</em></p>
<p>3) info.plist 에 Bundle display name 을 $(APP_NAME) 로 변경 시켜주면 된다.
그렇다면 일괄적으로 변경 된다.
<img src="https://velog.velcdn.com/images/heina-effect/post/373c4538-c2f8-4c8c-9b59-3ea391bb728f/image.png" alt=""></p>
<h3 id="4-appdelegateswift-수정">4. AppDelegate.swift 수정</h3>
<p>앞서 말했듯 build설정을 flutter 에 전달하기 위해 ios Native의 최상단인 AppDelegate를 설정해야 한다. </p>
<pre><code class="language-swift">import UIKit
import Flutter
import flutter_downloader

@UIApplicationMain
@objc class AppDelegate: FlutterAppDelegate {
    override func application(
        _ application: UIApplication,
        didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
    ) -&gt; Bool {
        GeneratedPluginRegistrant.register(with: self)

        FlutterDownloaderPlugin.setPluginRegistrantCallback({ registry in
            FlutterDownloaderPlugin.register(with: registry.registrar(forPlugin: &quot;vn.hunghd.flutter_downloader&quot;)!)
            GeneratedPluginRegistrant.register(with: registry)
        })

          let controller = window.rootViewController as! FlutterViewController

          let flavorChannel = FlutterMethodChannel(
              name: &quot;flavor&quot;,
              binaryMessenger: controller.binaryMessenger)

          flavorChannel.setMethodCallHandler({(call: FlutterMethodCall, result: @escaping FlutterResult) -&gt; Void in
              // Note: this method is invoked on the UI thread
              let flavor = Bundle.main.infoDictionary?[&quot;App-Flavor&quot;]
              result(flavor)
          })

        return super.application(application, didFinishLaunchingWithOptions: launchOptions)
    }
}
</code></pre>
<p>기존에 main.dart에 설정이 flutter flavor 설정이 되어있기 때문에</p>
<pre><code class="language-cmd">flutter run --flavor local
flutter run --flavor dev</code></pre>
<p><img src="https://velog.velcdn.com/images/heina-effect/post/34529c5d-79d1-4fbe-a12c-8fa2b4c7a375/image.png" alt=""></p>
<p>를 통해 실행하면 동작 완<del>~</del>!</p>
<p>ios 환경 설정을 해본 후기: 
<em>와....apple 이 녀석들 뭐야 이게 뭐야 대체 뭐야 Xcode또 뭔데
피크민에 빠져 멋진 하찮은 앱을 만들려고 했던 나의 꿈 오늘로써 접는다.</em>
<img src="https://velog.velcdn.com/images/heina-effect/post/7d0c726d-e334-4c6d-86b8-9a2320beb6c8/image.png" alt=""></p>
<p>참고
<a href="https://velog.io/@udong85/Flutter-flavor-%EB%A5%BC-%EC%9D%B4%EC%9A%A9%ED%95%9C-%EA%B0%9C%EB%B0%9C-%EC%9A%B4%EC%98%81-%ED%99%98%EA%B2%BD-%EC%84%A4%EC%A0%95#flavor-%EB%9E%80">udong85-velog</a>
<a href="https://dokit.tistory.com/61">https://dokit.tistory.com/61</a>
<a href="https://deku.posstree.com/ko/flutter/app-id/">https://deku.posstree.com/ko/flutter/app-id/</a></p>
]]></description>
        </item>
        <item>
            <title><![CDATA[Vue3 웹앱 카메라 적용기-MediaDevices API편]]></title>
            <link>https://velog.io/@heina-effect/Vue3-%EC%9B%B9%EC%95%B1-%EC%B9%B4%EB%A9%94%EB%9D%BC-%EC%A0%81%EC%9A%A9%EA%B8%B0</link>
            <guid>https://velog.io/@heina-effect/Vue3-%EC%9B%B9%EC%95%B1-%EC%B9%B4%EB%A9%94%EB%9D%BC-%EC%A0%81%EC%9A%A9%EA%B8%B0</guid>
            <pubDate>Mon, 15 Jul 2024 06:32:48 GMT</pubDate>
            <description><![CDATA[<p>웹앱을 만들고 있는 중인데, 네이티브 기능을 어떻게 활용할 수 있을지 열심히 창의적으로 삽질중이다.</p>
<h2 id="vue3에서-카메라-기능을-활용할-수-있는-방법">Vue3에서 카메라 기능을 활용할 수 있는 방법</h2>
<ol>
<li><p><strong>vue-web-cam</strong> : Vue.js용으로 개발된 웹캠 컴포넌트</p>
</li>
<li><p><strong>vue-camera-lib</strong> : Vue.js를 위한 또 다른 카메라 컴포넌트</p>
</li>
<li><p><strong>MediaDevices API</strong> : Vue 3에서 직접 브라우저의 MediaDevices API를 사용(별도의 라이브러리 없이 네이티브 웹 API를 활용)</p>
</li>
<li><p><strong>Capacitor</strong> : Capacitor는 네이티브 기기 기능에 대한 접근을 제공하는 크로스 플랫폼 앱 개발 프레임워크 <em>(quasar에서 추천하긴 하나, 상의 후 해당 방법은 보류)</em></p>
</li>
</ol>
<p><img src="https://velog.velcdn.com/images/heina-effect/post/bcbd6a75-1c4b-4075-b977-8520ef91da8f/image.jpg" alt="">
헤헷 헤헷 어떻게든 되기만 하면 된다.</p>
<h3 id="1-vue-web-cam">1. vue-web-cam</h3>
<p><a href="https://www.npmjs.com/package/vue-web-cam">vue-web-cam 라이브러리</a> 에서 해당 내용을 확인할 수 있으나 이것은 마지막 업데이트가 현재 시간 기준 <strong>5년전</strong> 이라고 한다</p>
<h3 id="2-vue-camera-lib">2. vue-camera-lib</h3>
<p>해당 라이브러리는 Vue 3에 특화되어 개발되어 있다.
가능한 기능으로는 아래와 같다
<strong>웹페이지 내 사진 촬영</strong>: 사용자가 웹 페이지에서 직접 사진을 찍을 수 있는 기능을 제공
<strong>사용자 인터페이스</strong>: 모바일 카메라 앱과 유사한 기본 UI를 제공하며, 필요에 따라 커스텀 UI 구현
<strong>디바이스 방향 감지</strong>: 자이로스코프가 있는 기기에서는 가로 모드로 사용 시 사진을 자동으로 회전
<strong>다양한 출력 옵션</strong>: 이미지를 데이터 URL 또는 Blob 형태로 출력</p>
<p><a href="https://github.com/AlexKratky/vue-camera-lib">vue-camera-lib Git</a>에서 참고할 수 있다.</p>
<h3 id="3-mediadevices-api">3. MediaDevices API</h3>
<p><code>&lt;video&gt;</code> 코드를 통해서 작성이 가능하다.</p>
<pre><code class="language-html">&lt;q-btn @click=&quot;startCamera&quot;&gt;카메라 시작~&lt;/q-btn&gt;
&lt;video ref=&quot;videoElement&quot;&gt;&lt;/video&gt;</code></pre>
<pre><code class="language-js">const startCamera = async () =&gt; {
  try {
    console.log(navigator.mediaDevices.getUserMedia);
    const stream = await navigator.mediaDevices.getUserMedia({video: true});

    if (videoElement.value) {
      videoElement.value.srcObject = stream;
      videoElement.value.play();
    }
  } catch(err) {
    if (err.name === &quot;NotFoundError&quot; || err.name === &quot;DevicesNotFoundError&quot;) {
      console.error(&quot;요청된 장치를 찾을 수 없습니다.&quot;);
    } else if (err.name === &quot;NotAllowedError&quot; || err.name === &quot;PermissionDeniedError&quot;) {
      console.error(&quot;카메라/마이크 접근 권한이 거부되었습니다.&quot;);
    } else {
      console.error(&quot;getUserMedia 오류:&quot;, err);
    }
  }
}</code></pre>
<p>다만 여기서 주의할 점은 
<code>navigator.mediaDevices.getUserMedia() not working</code> 또는 <code>TypeError: Cannot read properties of undefined (reading &#39;getUserMedia&#39;)</code>
이라는 오류가 계속 발생하는데 https, http 관련 보안 문제다.
chrome://flags/#unsafely-treat-insecure-origin-as-secure <a href="chrome://flags/#unsafely-treat-insecure-origin-as-secure">chrome://flags 연결링크</a> 에 접속할 서버 ip를 입력후 브라우저를 재시작하면 임시적으로 허용하여 사용할 수 있다.
<img src="https://velog.velcdn.com/images/heina-effect/post/c929cd16-59d2-4fdc-995a-021cdf861879/image.png" alt=""></p>
<p>라고 생각했으나 또 다른 오류가 error가 발생
<code>DOMException: Requested device not found</code></p>
<h3 id="번외-삼성-노트북-카메라-장치-작동-확인">(번외) 삼성 노트북 카메라 장치 작동 확인</h3>
<ol>
<li><a href="https://webcamtests.com/">Webcam Test</a> 사이트 들어가서 작동 확인</li>
<li>설정&gt;개인 정보 및 보안&gt; 카메라 에서 <code>앱에서 카메라에 액세스하도록 허용</code> 여부 확인하고 <strong>켬</strong>으로 설정 바꾸기
<img src="https://velog.velcdn.com/images/heina-effect/post/850bb773-8907-4139-bf6f-f2316382a0de/image.png" alt=""></li>
<li>Samsung Security 앱 들어가서 카메라 및 마이크 차단 여부 확인하고 <strong>꺼짐</strong> 으로 변경
<img src="https://velog.velcdn.com/images/heina-effect/post/a7575b52-d936-4c27-9c66-52e5db611e2e/image.png" alt=""></li>
<li>다시 웹캠 테스트 사이트 들어와서 <code>Test My Cam</code> 버튼 클릭!
권한 요청 체크가 되면서, 강력하게 허용 해버림
<img src="https://velog.velcdn.com/images/heina-effect/post/6691b33f-3fc7-4146-87ca-0407dd446372/image.png" alt=""></li>
</ol>
<p>다시 원래 하던일(삽질) 로 돌아와서, 설정 변경후 코드 테스트를 해보았는데
페이지 상단에 빨간색 10점만점 과녁표가 생기면서 카메라가 작동해버림
<img src="https://velog.velcdn.com/images/heina-effect/post/d09868d5-dac4-4125-904a-a7c1a809d691/image.png" alt="">
<img src="https://velog.velcdn.com/images/heina-effect/post/530e9015-18e5-4589-8ad8-712398f9d4a4/image.png" alt=""></p>
<p>최종 작성된 Script 코드</p>
<pre><code class="language-js">const videoElement = ref(null);

const startCamera = async () =&gt; {
  try {
    const devices = await navigator.mediaDevices.enumerateDevices();
    const videoDevices = devices.filter(device =&gt; device.kind === &#39;videoinput&#39;);

    if (videoDevices.length === 0) {
      throw new Error(&#39;No video input devices found&#39;);
    }

    const constraints = {
      video: {
        // facingMode: &#39;environment&#39;, // &#39;exact&#39; 제거, 후면 카메라 선호
        width: { ideal: 1280 },    // &#39;ideal&#39;로 변경하여 유연성 확보
        height: { ideal: 720 }
      }
    };
    const stream = await navigator.mediaDevices.getUserMedia(constraints);

    if (videoElement.value) {
      videoElement.value.srcObject = stream;
      videoElement.value.play();
    } else {
      console.error(&#39;Video element not found&#39;);
    }

  } catch(err) {
    if (err.name === &quot;NotFoundError&quot; || err.name === &quot;DevicesNotFoundError&quot;) {
      console.error(&quot;요청된 장치를 찾을 수 없습니다.&quot;);
    } else if (err.name === &quot;NotAllowedError&quot; || err.name === &quot;PermissionDeniedError&quot;) {
      console.error(&quot;카메라/마이크 접근 권한이 거부되었습니다.&quot;);
    } else {
      console.error(&quot;getUserMedia 오류:&quot;, err);
    }
  }
}</code></pre>
<p>나도 너도 퇴근하자.. 
<img src="https://velog.velcdn.com/images/heina-effect/post/bf057280-0644-4e4c-942c-7701226a77e7/image.jpg" alt=""></p>
<p><a href="https://developer.mozilla.org/en-US/docs/Web/API/MediaDevices/getUserMedia">MDN문서-getUserMedia() 메서드</a>, <a href="https://developer.mozilla.org/en-US/docs/Web/API/Media_Capture_and_Streams_API/Taking_still_photos">MDN문서-웹캠 사진촬영</a>에서 자세하게 확인 할 수 있다.</p>
<p>추가 업로드 예정</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[컴포지션 API 사용하여 event-bus 간지나게 태우기]]></title>
            <link>https://velog.io/@heina-effect/%EC%BB%B4%ED%8F%AC%EC%A7%80%EC%85%98-API-%EC%82%AC%EC%9A%A9%ED%95%98%EC%97%AC-event-bus-%EA%B0%84%EC%A7%80%EB%82%98%EA%B2%8C-%ED%83%9C%EC%9A%B0%EA%B8%B0</link>
            <guid>https://velog.io/@heina-effect/%EC%BB%B4%ED%8F%AC%EC%A7%80%EC%85%98-API-%EC%82%AC%EC%9A%A9%ED%95%98%EC%97%AC-event-bus-%EA%B0%84%EC%A7%80%EB%82%98%EA%B2%8C-%ED%83%9C%EC%9A%B0%EA%B8%B0</guid>
            <pubDate>Wed, 13 Mar 2024 10:36:55 GMT</pubDate>
            <description><![CDATA[<h2 id="고뇌의-시작">고뇌의 시작</h2>
<p>대시보드에 다양한 컴포넌트들을 배치하고 놓고 기간의 업데이트에 따라 컴포넌트들의 데이터를 변경해야했다.</p>
<p>가장 단순하게는 기간을 관리하는 컨트롤러에서 <code>defineEmits()</code>를 사용하여 부모로 데이터를 올린후 이벤트를 받아 부모컴포넌트에서 자식컴포넌트로 <code>props</code>를 이용한 데이터 바인딩을 하려고 하였다. </p>
<p>그렇게 단순하게 끗? 하려고 하는 순간 컨트롤러.vue -&gt; 부모.vue -&gt; 자식.vue 의 과정이 너무 불필요하다고 하셨다... (생각해보니 맞는것 같음)</p>
<p>또한 사용하는 자식컴포넌트마다 <code>defindProps()</code>를 사용하는게 마음에 들지 않았다.</p>
<h3 id="시도방법1-defineemits-defindprops-사용하기">시도방법1. defineEmits, defindProps 사용하기</h3>
<h4 id="1-컨트롤러에서-watch를-통해-기간데이터가-변경-될-때-마다-emit를-보내기">1. 컨트롤러에서 watch를 통해 기간데이터가 변경 될 때 마다 <code>emit</code>를 보내기</h4>
<pre><code class="language-javascript">//controller.vue &lt;script setup&gt;

const emit = defineEmits[&#39;period]

watch(period, ()=&gt;{
emit(&#39;period&#39;, period.value)
})
</code></pre>
<h4 id="2-부모-컴포넌트에서-받기">2. 부모 컴포넌트에서 받기</h4>
<pre><code class="language-javascript">//home.vue, &lt;script setup&gt;

&lt;template&gt;
  &lt;controller @period=&quot;period&quot;/&gt;
  &lt;component :period=&#39;period&#39;/&gt;
&lt;/template&gt;

&lt;script setup&gt;
const period = ref([]);

onBeforeMout(()=&gt;{
  emitter.on(&#39;period&#39;, (period)=&gt;{
    period.value = period;
  })
})
&lt;/script&gt;</code></pre>
<h3 id="시도방법2-mitt를-이용한-emitter이용하기">시도방법2. mitt를 이용한 emitter이용하기</h3>
<p><em>이 방법은 mitt 라이브러리를 설치 후 createApp(App)을 통해 전역으로 provide(&#39;emitter&#39;)해줘야함!</em></p>
<h4 id="1-컨트롤러에서-watch를-통해-기간데이터가-변경-될-때-마다-emitter를-보내기">1. 컨트롤러에서 watch를 통해 기간데이터가 변경 될 때 마다 <code>emitter</code>를 보내기</h4>
<pre><code class="language-javascript">//controller.vue &lt;script setup&gt;

watch(period, ()=&gt;{
emitter.emit(&#39;period&#39;, period.value)
})</code></pre>
<h4 id="2-부모-컴포넌트에서-데이터-받기">2. 부모 컴포넌트에서 데이터 받기</h4>
<pre><code class="language-javascript">//home.vue, &lt;script setup&gt;

const period = ref([]);

onBeforeMout(()=&gt;{
  emitter.on(&#39;period&#39;, (period)=&gt;{
    period.value = period;
  })
})
</code></pre>
<p>이렇게 받아서 각 <code>period</code>를 컴포넌트들로 보내는 방법.
사실 이것 또한 처음에 시도했던 방법과 별 다를것이 없었다. 떼잉</p>
<h3 id="시도방법3-store-사용하기">시도방법3. store 사용하기</h3>
<p>간단한 데이터에 비해 Pinia를 통해 store 를 하기엔 덩치가 너무 커져서 포기
<img src="https://velog.velcdn.com/images/heina-effect/post/7c948a45-f4b2-4674-8861-b30464f9b406/image.jpg" alt=""></p>
<h3 id="시도방법4-컴포지션-api-사용하여-이벤트-버스-만들기">시도방법4. 컴포지션 API 사용하여 이벤트 버스 만들기</h3>
<h4 id="1-eventbus용-js-생성하기">1. eventBus용 js 생성하기</h4>
<pre><code class="language-javascript">import { ref } from &#39;vue&#39;;

const bus = ref(new Map());

export const useEventBus = () =&gt; {
  const emit = (event, args) =&gt; {
    bus.value.set(event, args);
  };

  return { emit, bus };
};
</code></pre>
<h4 id="2-컨트롤러에서-이벤트버스에-함수-태워보내기">2. 컨트롤러에서 이벤트버스에 함수 태워보내기</h4>
<pre><code class="language-javascript">import { useEventBus } from &#39;@/modules/dashboard/eventBus&#39;;

const { emit } = useEventBus();

watch(period, ()=&gt;{
emit(&#39;period&#39;, period.value)
})
</code></pre>
<h4 id="3-받을-곳에서-computed를-통해-실시간으로-감지하여-화면에-보여주기">3. 받을 곳에서 computed를 통해 실시간으로 감지하여 화면에 보여주기</h4>
<pre><code class="language-javascript">import { useEventBus } from &#39;@/modules/dashboard/eventBus&#39;;

const { bus } = useEventBus();

const setPeriod = computed(() =&gt; {
  return bus.value.get(&#39;period&#39;);
});</code></pre>
<p><code>Map()</code> 을 이용하여 여러 종류의 함수를 넣고 가져다 쓸 수 있다.
watch를 걸었기 때문에 이벤트가 발생할 때 마다 computed를 통해  화면에 보여주는 용도로만 사용하기에 간단하게 쓸 수 있었는데 추가적으로 api를 호출할때는 싸이클을 한번 자세하게 확인하고 쓸 수 있을 것 같다.</p>
<p><a href="https://stackoverflow.com/questions/63471824/vue-js-3-event-bus">event-bus 참고 stack overflow</a></p>
]]></description>
        </item>
        <item>
            <title><![CDATA[Vue3-Charts를 이용하여 매우 간단한 차트 만들기]]></title>
            <link>https://velog.io/@heina-effect/Vue3-Charts%EB%A5%BC-%EC%9D%B4%EC%9A%A9%ED%95%98%EC%97%AC-%EB%A7%A4%EC%9A%B0-%EA%B0%84%EB%8B%A8%ED%95%9C-%EC%B0%A8%ED%8A%B8-%EB%A7%8C%EB%93%A4%EA%B8%B0</link>
            <guid>https://velog.io/@heina-effect/Vue3-Charts%EB%A5%BC-%EC%9D%B4%EC%9A%A9%ED%95%98%EC%97%AC-%EB%A7%A4%EC%9A%B0-%EA%B0%84%EB%8B%A8%ED%95%9C-%EC%B0%A8%ED%8A%B8-%EB%A7%8C%EB%93%A4%EA%B8%B0</guid>
            <pubDate>Tue, 18 Jul 2023 02:46:23 GMT</pubDate>
            <description><![CDATA[<h1 id="vue3-charts란">Vue3-Charts란?</h1>
<p>우리에겐 멋진 그래프를 만들수 있는 <code>chart.js</code>가 있다.
하지만 내 데이터가 매우 간단하고, 특별한 커스텀이 필요없다하는 경우에 사용할 수 있는 패키지를 공유하겠삼
이름하야 <code>Vue3-charts</code> chartJs와는 다르니까 혼동말기
사용법도 간단하지만, 내용도 매우 간단하여 커스텀이 거의 불가능하니 참고!!! 
(사실 사용법을 매우 자세하게 읽어보지 않아서 그럴수도 있다..내가 필요한 기능만 사용했기 때문에)
<img src="https://velog.velcdn.com/images/heina-effect/post/3c7e723d-1d30-41d3-9b29-526e22d6deaf/image.png" alt=""></p>
<p><a href="https://vue3charts.org/">Vue3-charts 공식 문서 바로가기</a></p>
<h2 id="vu3-charts-시작하기">Vu3-Charts 시작하기</h2>
<h3 id="vu3-charts-패키지-설치">Vu3-Charts 패키지 설치</h3>
<pre><code class="language-cmd"># usign npm
npm install vue3-charts --save

# usign yarn
yarn add vue3-charts</code></pre>
<p>명령어에서도, 패키지 이름에서도 알 수 있듯 <strong>Vue version &gt; 3.x</strong> 이어야 한다!
<a href="https://vue3charts.org/docs/getting-started">Vue3-charts 패키지 설치 안내 바로가기</a></p>
<p>사용할 수 있는 차트의 종류는 <strong>Line, Bar, Area Pie</strong> 정도가 있다.</p>
<h3 id="사용할-차트-종류-import-하여-붙여넣기">사용할 차트 종류 import 하여 붙여넣기</h3>
<p>나는 Bar차트와 부가적인 기능(tooltip, grid) 등을 사용할 예정이다.
해당 내용들을 모두  import 해줘야 사용할 수 있다.</p>
<pre><code class="language-js">import { Chart, Grid, Bar, Tooltip } from &#39;vue3-charts&#39;;</code></pre>
<p><img src="https://velog.velcdn.com/images/heina-effect/post/0ce7a2d4-f7cb-4cfa-ac7d-f093041aef01/image.png" alt="">
왜 이렇게 흐림처리되는지는 모르겠다..마치 사용하지 않는 패키지처럼..;;</p>
<p>그리고 이제 본격적으로 사용을 해보기전에, 공식 사이트에 있는 coade를 긁어 왔다. 
이렇게 집어 넣고 내가 사용할 데이터 들만 변경하여 넣는것이 제일 간단하긴 하다.</p>
<pre><code class="language-js">&lt;template&gt;
  &lt;Chart
    :size=&quot;{ width: 500, height: 420 }&quot;
    :data=&quot;data&quot;
    :margin=&quot;margin&quot;
    :direction=&quot;direction&quot;
    :axis=&quot;axis&quot;&gt;

    &lt;template #layers&gt;
      &lt;Grid strokeDasharray=&quot;2,2&quot; /&gt;
      &lt;Bar :dataKeys=&quot;[&#39;name&#39;, &#39;pl&#39;]&quot; :barStyle=&quot;{ fill: &#39;#90e0ef&#39; }&quot; /&gt;
      &lt;Bar :dataKeys=&quot;[&#39;name&#39;, &#39;avg&#39;]&quot; :barStyle=&quot;{ fill: &#39;#0096c7&#39; }&quot; /&gt;
      &lt;Bar :dataKeys=&quot;[&#39;name&#39;, &#39;inc&#39;]&quot; :barStyle=&quot;{ fill: &#39;#48cae4&#39; }&quot; /&gt;
      &lt;Marker :value=&quot;1000&quot; label=&quot;Avg.&quot; color=&quot;#e76f51&quot; strokeWidth=&quot;2&quot; strokeDasharray=&quot;6 6&quot; /&gt;
    &lt;/template&gt;

    &lt;template #widgets&gt;
      &lt;Tooltip
        borderColor=&quot;#48CAE4&quot;
        :config=&quot;{
          pl: { color: &#39;#90e0ef&#39; },
          avg: { color: &#39;#0096c7&#39; },
          inc: { color: &#39;#48cae4&#39; }
        }&quot;
      /&gt;
    &lt;/template&gt;

  &lt;/Chart&gt;
&lt;/template&gt;

&lt;script lang=&quot;ts&quot;&gt;
import { defineComponent, ref } from &#39;vue&#39;
import { Chart, Grid, Line } from &#39;vue3-charts&#39;
import { plByMonth } from &#39;@/data&#39;

export default defineComponent({
  name: &#39;LineChart&#39;,
  components: { Chart, Grid, Line },
  setup() {
    const data = ref(plByMonth)
    const direction = ref(&#39;horizontal&#39;)
    const margin = ref({
      left: 0,
      top: 20,
      right: 20,
      bottom: 0
    })

    const axis = ref({
      primary: {
        type: &#39;band&#39;
      },
      secondary: {
        domain: [&#39;dataMin&#39;, &#39;dataMax + 100&#39;],
        type: &#39;linear&#39;,
        ticks: 8
      }
    })

    return { data, direction, margin, axis }
  }
})
&lt;/script&gt;</code></pre>
<h3 id="그러나-나는-script-setup을-사용하는걸">그러나 나는 Script setup을 사용하는걸..?</h3>
<p><code>template</code> 사용법은 비슷하지만, 나는 차트들의 옵션을 한번에 관리하고 싶어서 옵션들을 한곳에 담았다.</p>
<pre><code class="language-js">const chartDatas = ref({
  size: { width: 740, height: 340 }, // 차트 사이즈 
  graphData: [],
  margin: {
    left: 0,
    top: 0,
    right: 0,
    bottom: 0,
  }, // 차트들에게 마진값이 필요할 때
  direction: &#39;horizontal&#39;,
  axis: {
    primary: {
      type: &#39;band&#39;,
    },
    secondary: {
      domain: [&#39;dataMin&#39;, &#39;dataMax+10&#39;], //데이터 최소값, 최대값의 범위
      type: &#39;linear&#39;,
      ticks: 5, // 표시되고 싶은 가로선의 갯수
    },
  },
  responsive: &#39;true&#39;,
});</code></pre>
<p>해당 옵션들을 살펴본다면 크게 어려운건 없을것 같다.
<code>chartDatas.graphData</code>에는 API로 부터 받아오는 데이터들을 넣을 예정이다.</p>
<pre><code class="language-html">&lt;template&gt;
  &lt;Chart
    class=&quot;chart&quot;
    :size=&quot;chartDatas.size&quot;
    :margin=&quot;chartDatas.margin&quot;
    :data=&quot;chartDatas.graphData&quot;
    :axis=&quot;chartDatas.axis&quot;
    :direction=&quot;chartDatas.direction&quot;
    :responsive=&quot;chartDatas.responsive&quot;
   &gt;
    &lt;template #layers&gt;
      &lt;Grid
        :hideY=&quot;true&quot;
        :center=&quot;false&quot;
      /&gt;
      &lt;Bar
        :dataKeys=&quot;[&#39;date&#39;, &#39;uCount&#39;]&quot;
        :barStyle=&quot;{ fill: &#39;#eb6483&#39; }&quot;
      /&gt;
      &lt;Bar
        :dataKeys=&quot;[&#39;date&#39;, &#39;pCount&#39;]&quot;
        :barStyle=&quot;{ fill: &#39;#42afc3&#39; }&quot;
      /&gt;
    &lt;/template&gt;
    &lt;template #widgets&gt;
      &lt;Tooltip
        class=&quot;chart--tooltip&quot;
        color=&quot;#606060&quot;
        :config=&quot;{
          date: { color: &#39;#606060&#39; },
          uCount: { label: `u회원가입`, color: &#39;#606060&#39; },
          pCount: { label: `p회원가입`, color: &#39;#606060&#39; },
          }&quot;
          :style=&quot;{ fontSize: &#39;14px&#39; }&quot;
       /&gt;
    &lt;/template&gt;
  &lt;/Chart&gt;
&lt;/template&gt;</code></pre>
<p>표시된 <code>&lt;Bar/&gt;</code>의 <code>dataKeys</code>에는 key, value 값이라고 생각하면 될것같다.</p>
<p>그리하야 만들어진 차트
<img src="https://velog.velcdn.com/images/heina-effect/post/af0de02e-2fd7-43e9-9c1d-9b42c65fc390/image.png" alt=""></p>
<p>추가로 데이터의 범례를 만들고 싶었는데, 사용법을 찾지 못해 직접 만들어 넣었다..</p>
<pre><code class="language-html">&lt;ul class=&quot;item--legend&quot;&gt;
  &lt;li&gt;
    &lt;div class=&quot;square left&quot;&gt;&lt;/div&gt;
    &lt;p&gt;u&lt;/p&gt;
  &lt;/li&gt;
  &lt;li&gt;
    &lt;div class=&quot;square right&quot;&gt;&lt;/div&gt;
    &lt;p&gt;p&lt;/p&gt;
  &lt;/li&gt;
&lt;/ul&gt;</code></pre>
<p><img src="https://velog.velcdn.com/images/heina-effect/post/7003742c-5496-4c63-ac4d-9b693c6a07d7/image.png" alt=""></p>
<h4 id="tooltip을-사용하고-싶을-땐-위에-표시된-코드처럼-작성하면-되고-내가-넣고-싶은-내용은-직접-커스텀하여-넣을-수-있다">Tooltip을 사용하고 싶을 땐 위에 표시된 코드처럼 작성하면 되고, 내가 넣고 싶은 내용은 직접 커스텀하여 넣을 수 있다.</h4>
<p>마우스 오버 하면 툴팁이 생성되여 해당 내용을 볼 수 있고, 
그리드가 표시되어 어떤 지점에 데이터를 확인할 수 있는지 알 수 있다.
<img src="https://velog.velcdn.com/images/heina-effect/post/1de62470-00d7-4d6f-a224-8af40d889a7e/image.png" alt="">
만약 툴팁에 그리드가 표시되는게 싫다면 툴팁 태그 내에 <code>:hide-line=&quot;true&quot;</code> 옵션을 추가하면 된다.</p>
<h3 id="결론">결론..</h3>
<p>사용법이 매우 간단하여 그래프를 간단하게 그려서 표현하고 싶을 땐 매우 추천한다. 다만 커스텀이 어렵고, 사용자들이 많이 없어 관련 내용들을 검색 할 때 찾기가 어렵다.</p>
<p>나도 처음에 간단한 내용을 표현할 때 사용하려고 이 패키지를 사용했는데, 기획쪽에서 요구되는 기능들이 점점 추가되어 chart.js로 다시 갈아타야 하나 했는데, 잘 조율이 되어 현재선에서 마무리 되었다. </p>
<p>이런 대참사를 방지하기 위하여 사전에 협의가 많이 필요할 것 같다.
<img src="https://velog.velcdn.com/images/heina-effect/post/c7f627ba-f608-4bc4-b9c8-aa2b749768f8/image.png" alt=""></p>
]]></description>
        </item>
        <item>
            <title><![CDATA[변수, 데이터를 감시하고 싶을 때 Watch 함수 2편 (Vue3 + script setup)]]></title>
            <link>https://velog.io/@heina-effect/%EB%B3%80%EC%88%98%EB%A5%BC-%EA%B0%90%EC%8B%9C%ED%95%98%EA%B3%A0-%EC%8B%B6%EC%9D%84-%EB%95%8C-Watch-%ED%95%A8%EC%88%98-2%ED%8E%B8</link>
            <guid>https://velog.io/@heina-effect/%EB%B3%80%EC%88%98%EB%A5%BC-%EA%B0%90%EC%8B%9C%ED%95%98%EA%B3%A0-%EC%8B%B6%EC%9D%84-%EB%95%8C-Watch-%ED%95%A8%EC%88%98-2%ED%8E%B8</guid>
            <pubDate>Wed, 12 Jul 2023 07:09:42 GMT</pubDate>
            <description><![CDATA[<h1 id="vu2에서-watch함수를-사용할-때">Vu2에서 watch함수를 사용할 때</h1>
<p><a href="https://velog.io/@heina-effect/%EC%A1%B0%EA%B1%B4%EB%B6%80-%EA%B0%90%EC%8B%9C%EC%9E%90-this.watch">시계는 watch, Vue2에서 조건감시자 사용법 바로 가기</a></p>
<h1 id="vue3--script-setup에서-사용하기">Vue3 + script setup에서 사용하기</h1>
<p>이전에는 Vue2를 사용하여 개발을 하였는데, 이번 프로젝트는 Vue3를 사용하고 있다. 사용법은 크게 차이가 없으나, <code>watch</code> 함수를 반응형 데이터<code>ref</code>와 <code>reactive</code>와 사용할 때 사용법이 달라 정리하려고 한다. </p>
<h2 id="vue3에서-watch-함수-기본-사용법">Vue3에서 watch 함수 기본 사용법</h2>
<p>나는 로그인시 에러가 발생하면 q-input의 <code>rules</code>를 사용하기 때문에 error 처리하여 인풋박스 하단에 에러메세지를 보여주도록 유효성검사를 하였다. 
<em>(quasar는 유효성 검사를 조금 더 쉽게 사용할 수 있는데 이건 나중에 추가로 작성하겠음)</em></p>
<p>그러나 input에 접근하여 재입력 하는 경우 이전에 발생한 유효성검사 error문구가 사라져야 하는데 지워지지 않아 버그로 잡히게 되었다. </p>
<p>그래서 input을 실시간으로 감시하여 작동하도록 해야했다.....
따라서 데이터가 실시간으로 바뀔수 있게 반응형 데이터를 <code>reactive</code>를 활용하여 생성하였으며, 기존 사용법을 참고하여 <code>watch</code>를 사용하였다.</p>
<pre><code class="language-js">// 로그인 정보 관련 관련 
&lt;script setup&gt;

const managerFormValue = reactive({
  managerId: &#39;&#39;,
  managerPwd: &#39;&#39;,
  otpNum: &#39;&#39;,
  record: false,
  tel: &#39;&#39;,
});

//유효성 검사
let errorMessage = ref(&#39;&#39;);
let isValid = computed(() =&gt; errorMessage.value == &#39;&#39;);
const isValidFormat = (val) =&gt; {
  errorMessage.value = Global.loginPwFormat(val);
};

const clearError = () =&gt; {
  errorMessage.value = &#39;&#39;;
};

watch(managerFormValue.managerId, () =&gt; {
  clearError();
});

&lt;/script&gt;</code></pre>
<p>그러나 watch함수가 왜 작동하지 않는지 알수가 없었다. 기존에 watch를 사용할 때는 이런 식으로 잘만사용했다. 
<em>시계는 와치... 기존에 사용하던 방법</em></p>
<pre><code class="language-js">//날짜 변경시 api 재호출
watch(getSearchDate, () =&gt; {
  getDashboardAccountApi();
 });</code></pre>
<p><img src="https://velog.velcdn.com/images/heina-effect/post/562f7c81-3f60-4384-b366-6606ff44f524/image.JPG" alt=""></p>
<h2 id="반응형데이터를-watch로-감시할-때">반응형데이터를 watch로 감시할 때</h2>
<p>오랜 고민 결과 <code>ref</code>와 <code>reactive</code> 차이 때문에 watch가 안먹히는것이였다!!! 
<code>reactive</code> 로 만들어진 객체는 직접적으로 감시할 수 없다고 한다.
그래서 <code>reactive</code> 로 만들어진 객체를 감시하려면, 접근하는 시점에서 함수를 사용하여 감시 대상을 반환시켜야 한다.</p>
<h3 id="reactive를-사용할-경우"><code>reactive</code>를 사용할 경우</h3>
<p><code>() =&gt; managerFormValue.managerId</code>와 같이 함수를 사용하여 managerFormValue.managerId를 감싸야 한다.</p>
<pre><code class="language-js">watch(() =&gt; managerFormValue.managerId, () =&gt; {
  clearError();
});</code></pre>
<p>차이점을 알겠는가? 모르면 그냥 이렇게 써.</p>
<h3 id="ref를-사용할-경우"><code>ref</code>를 사용할 경우</h3>
<p><code>ref</code>로 만들어진 경우에는 <code>.value</code>속성에 실제 값을 가지고 있으므로 직접적으로 사용할 수 있다.</p>
<pre><code class="language-js">watch(managerFormValue.managerId, () =&gt; {
  clearError();
});</code></pre>
<p>그런데 나는 managerId값과 managerPwd의 값 즉 유효성검사 오류 출력 후 아이디, 비밀번호 재입력할 경우 해당 input v-model을 watch를 사용하고 감시하고 싶을 땐, 이렇게 입력하면 된다. </p>
<pre><code class="language-js">watch([() =&gt; managerFormValue.managerId, () =&gt; managerFormValue.managerPwd], () =&gt; {
  clearError();
});
</code></pre>
<p>왜 이렇게 사용하나면, watch 함수는 두가지 형태로 사용될 수 있다고 한다.</p>
<h3 id="watch-함수의-두가지-형태">watch 함수의 두가지 형태</h3>
<h4 id="1-단일-속성을-감시할-경우">1. 단일 속성을 감시할 경우</h4>
<pre><code class="language-js">watch(
  () =&gt; managerFormValue.managerId,
  (newValue, oldValue) =&gt; {
    // 속성 값이 변경될 때 실행되는 로직
  }
);</code></pre>
<p><code>managerFormValue.managerId</code>의 값이 변경될 때마다 두 번째 인자로 전달된 콜백 함수가 실행, 이 콜백 함수는 <code>새 값(newValue)</code>과 <code>이전 값(oldValue)</code>을 인자로 받아 실행된다.</p>
<h4 id="2-여러-속성-감시할-경우">2. 여러 속성 감시할 경우</h4>
<pre><code class="language-js">watch(
  [() =&gt; managerFormValue.managerId, () =&gt; managerFormValue.managerPwd],
  (newValues, oldValues) =&gt; {
    // 속성 값이 변경될 때 실행되는 로직
  }
);</code></pre>
<p><code>managerFormValue.managerId</code>와 <code>managerFormValue.managerPwd</code> 두 속성의 변경을 함께 감시하고 있으며, 이 경우 콜백 함수는 감시 중인 모든 속성의 <code>새 값들(newValues)</code>과 <code>이전 값들(oldValues)</code>을 <code>Array</code> 형태로 받아 실행된다.</p>
<p>그리하여 반응형 데이터를 watch와 함께 사용할 때 매우 주의하도록 하자!</p>
<h2 id="watch를-사용해도-바로-반영되지-않을-때-비동기처리할-때">watch를 사용해도 바로 반영되지 않을 때, 비동기처리할 때</h2>
<p>데이터를 실시간으로 확인하면서 비동기처리하여 작업하려 하였으나, 바로 감지하지 못하였을 때 해결하는 간단한 방법은 <code>nextTick()</code> 사용하기!</p>
<p>변경되는 데이터를 통해, 호출되는 모든 API를 확인하고, API가 모두 처리 되면 작업을 수행하도록 하고 싶었다.</p>
<pre><code class="language-js">watch(searchDate, async () =&gt; {
  await nextTick();
  try {
    emitter.emit(&#39;showLoading&#39;);
    await callApis();
  } catch (err) {
    handleApiError(err);
  } finally {
    emitter.emit(&#39;hideLoading&#39;);
  }
});</code></pre>
<p>이렇게 사용하면, 데이터를 실시간으로 확인하여 원하는 작업도 진행하고, 비동기 처리하여 에러도 확인하고 ! 아주 간단하다!</p>
<p>그럼 잘 사용해보라고 ADIOS!</p>
<p><img src="https://velog.velcdn.com/images/heina-effect/post/dfd4e152-3b75-4931-b7f0-1e251d40fa01/image.jpeg" alt=""></p>
]]></description>
        </item>
        <item>
            <title><![CDATA[Nullish Coalescing Operator - ?? 문법]]></title>
            <link>https://velog.io/@heina-effect/Nullish-Coalescing-Operator-%EB%AC%B8%EB%B2%95</link>
            <guid>https://velog.io/@heina-effect/Nullish-Coalescing-Operator-%EB%AC%B8%EB%B2%95</guid>
            <pubDate>Fri, 07 Jul 2023 01:17:09 GMT</pubDate>
            <description><![CDATA[<h1 id="nullish---널-병합-연산자란">Nullish - 널 병합 연산자란?</h1>
<blockquote>
<p>널 병합 연산자 (??) 는 왼쪽 피연산자가 null 또는 undefined일 때 오른쪽 피연산자를 반환하고, 그렇지 않으면 왼쪽 피연산자를 반환하는 논리 연산자이다.</p>
</blockquote>
<p>라는게 mdn web docs의 이야기!</p>
<p>쉽게 말하면 거짓의 판단을 유연하게 판단하며, 삼항 연산자와 비슷하게 사용할 수 있다.
<code>??</code> 앞에 자료가 없다면 <code>??</code> 뒤에 내용이 출력된다</p>
<pre><code class="language-js">console.log(age.미성년자 ?? &#39;돌아가&#39; ); //돌아가</code></pre>
<h2 id="언제-어떻게-사용할까">언제 어떻게 사용할까</h2>
<p><strong>null값을 체크하여 특정 문구를 내보내거나, null 때문에 발생하는 오류들을 없앨때 사용하면 좋다.</strong></p>
<p>value 을 받아와서 화면에 출력하고 싶다, 단순히 null 이여서 표시가 안되어도 된다면 nullish를 사용하지 않아도 되지만, value를 가공하여 화면에 출력하려 할 때 (replace/substring 같은 메서드 사용 시)이 value가 null 이라면 오류가 발생한다.
또는 내가 해야될 부분은 집계된 데이터를 출력하여야 하는데, 사이트 오픈시 집계전이라 이건 0과는 다르게 표시 되어야 하기 때문에 가공이 필요했다. 
백엔드쪽에서 가공하여 N/A 값으로 표현하여 주면 좋지만.. 힘없는 우리는 어쩔수없지..</p>
<h3 id="사용-방법">사용 방법</h3>
<p>api를 받아 올 때 코드</p>
<pre><code class="language-js">.then((res) =&gt; {
  const resData = res.data;
  // console.log(&#39;resData&#39;, resData);
      if (res.retCode === &#39;000&#39;) {
      dailyBoxList.value.forEach((topList) =&gt; {
        const { contentKey, percentKey, signKey } = topList;
          if (contentKey in resData) {
            topList.content = resData[contentKey];
          }
          if (percentKey in resData) {
            topList.percent = resData[percentKey];
          }
          if (signKey in resData) {
            topList.sign = resData[signKey];
            styleToSign(topList.sign);
          }
        });
      }
    })</code></pre>
<p>해당화면에선 null값을 체크하지 않고, key가 맞으면 value값을 삽입하도록 되어있다.
값이 있을 때 ,
<img src="https://velog.velcdn.com/images/heina-effect/post/f4bf7a9f-1ebd-486b-954e-906d3e45f479/image.png" alt="콘솔"></p>
<p><img src="https://velog.velcdn.com/images/heina-effect/post/4e6f8ea6-7ecb-48ef-8ace-5ab22546d760/image.png" alt="출력된 화면"></p>
<p>값이 없을 때 화면
<img src="https://velog.velcdn.com/images/heina-effect/post/17165824-5b53-4819-81b8-3475bca2dda5/image.png" alt="콘솔"></p>
<p><img src="https://velog.velcdn.com/images/heina-effect/post/77e06184-d507-444a-9258-f79698699bb1/image.png" alt="출력된 화면"></p>
<p>이건 쪼옴,,, 곤란하잖아? 테스터님들께서 분명 버그로 잡을것이 분명하다고!ㅠㅠ
<img src="https://velog.velcdn.com/images/heina-effect/post/97c6b255-8391-43f1-8502-e302392b31f3/image.png" alt="테스터"></p>
<p>그래서 null값을 체크하여 N/A로 내보내기로 했다.
if문을 새로 돌리는 방법도 있지만,, 게으른 나는 그렇게 복잡하게 쓰기 싫어 조금만 추가했다.</p>
<pre><code class="language-js">.then((res) =&gt; {
  const resData = res.data;
  // console.log(&#39;resData&#39;, resData);
      if (res.retCode === &#39;000&#39;) {
      dailyBoxList.value.forEach((topList) =&gt; {
        const { contentKey, percentKey, signKey } = topList;
        if (contentKey in resData) {
          topList.content = resData[contentKey] ?? &#39;N/A&#39;; //?? 추가
        }
        if (percentKey in resData) {
          topList.percent = resData[percentKey] ?? &#39;N/A&#39;; //?? 추가
        }
        if (signKey in resData) {
          topList.sign = resData[signKey];
          styleToSign(topList.sign);
        }
       });
      }
    })</code></pre>
<p>수정후 출력된 화면! 매우 간단하죠? 
<img src="https://velog.velcdn.com/images/heina-effect/post/66d02b73-853a-4cbf-b4a3-bd201fcd3715/image.png" alt="수정후 출력화면"></p>
<p>그럼 나는 이제,, 내 화면에서 나오는 null 값을 체크하러 가야겠다..</p>
<h2 id="번외--하고--차이">번외) || 하고 ?? 차이</h2>
<p><code>||</code>는 0, &quot;&quot;, false, undefined 같은 <code>false</code> 값을 전부 검사 하는 연산자,
<code>??</code>는 undefined 하고 null 같은 <code>nullish</code> 만 검사하는 연산자</p>
<p>다시한번 보여주지만 0과 null의 차이는 당연히 알겠지?
각인시켜주지.
<img src="https://velog.velcdn.com/images/heina-effect/post/30c0ef4d-42c1-49a7-99bf-acaa66570a11/image.png" alt=""></p>
<pre><code class="language-js">const a = false || &quot;어떻게&quot;;
// a =&gt; &quot;어떻게&quot;
const b = false ?? &quot;사람 이름이&quot;;
// b =&gt; false
const c = undefined ?? null ?? &quot;엄&quot;,
// c =&gt; &quot;엄&quot;</code></pre>
]]></description>
        </item>
        <item>
            <title><![CDATA[storybook 설치 및 사용 (Vue3)]]></title>
            <link>https://velog.io/@heina-effect/storybook-%EC%84%A4%EC%B9%98-%EB%B0%8F-%EC%82%AC%EC%9A%A9-Vue</link>
            <guid>https://velog.io/@heina-effect/storybook-%EC%84%A4%EC%B9%98-%EB%B0%8F-%EC%82%AC%EC%9A%A9-Vue</guid>
            <pubDate>Tue, 06 Dec 2022 06:11:15 GMT</pubDate>
            <description><![CDATA[<p>기존 프로젝트의 소스를 따서 신규 프로젝트를 진행 예정이라고 하길래 관련된 라이브러리를 확인하던 도중 storybook 이라는 라이브러리를 발견하였다.</p>
<p><img src="https://velog.velcdn.com/images/heina-effect/post/1887ccc6-23ac-425e-b167-96063daef288/image.png" alt=""></p>
<p>설치부터 해보면서 배우는것이 최고의 방법이기 때문에 설치 시작! 
<a href="https://storybook.js.org/docs/vue/get-started/introduction"> storybook-tutorials : https://storybook.js.org/docs/vue/get-started/introduction</a></p>
<h2 id="설치방법">설치방법</h2>
<ol>
<li><code>npx storybook init</code>
이렇게 사용하면 패키지를 자동으로 분석하여 설치해준다. 
<img src="https://velog.velcdn.com/images/heina-effect/post/810bd848-397f-4eb4-a05b-74533af28b3a/image.png" alt="npx"> 그럼 나는 vue3+vite를 사용중이기 때문에 확인 후 설치를 해준다.</li>
</ol>
<ol start="2">
<li><code>eslintPlugin</code> 을 사용할것이냐고 묻는데 나는 Yes
<img src="https://velog.velcdn.com/images/heina-effect/post/a57c86b9-9f0f-42bc-bbdf-9a907ea26869/image.png" alt="eslint"></li>
</ol>
<p>설치된 package들 이다.
<img src="https://velog.velcdn.com/images/heina-effect/post/11387118-6bf1-4327-90c4-4d418163f058/image.png" alt="설치된 package"></p>
<p>그러면 .storybook 이라는 폴더가 생기고, src아래 stories라는 폴더가 또 생긴다. 
<img src="https://velog.velcdn.com/images/heina-effect/post/bef51a9b-ca1c-440a-beaa-da34e3027e43/image.png" alt="filelist"></p>
<ol start="3">
<li>설치 완료 되었고 , <code>yarn storybook</code> 명령어를 사용하여 실행! 
<img src="https://velog.velcdn.com/images/heina-effect/post/1c3f77eb-50e9-4b8a-9c6c-0162339cb215/image.png" alt=""></li>
</ol>
<p>따로 localhost의 주소를 주지 않아도 자동으로 설정되어 실행된다. </p>
<p>추가적인 셋팅은 조금씩 공부를 더 하면서 추가하도록 하겠다!</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[특정 조건에서 함수 실행하기 - 조건부 감시자 this.$watch()]]></title>
            <link>https://velog.io/@heina-effect/%EC%A1%B0%EA%B1%B4%EB%B6%80-%EA%B0%90%EC%8B%9C%EC%9E%90-this.watch</link>
            <guid>https://velog.io/@heina-effect/%EC%A1%B0%EA%B1%B4%EB%B6%80-%EA%B0%90%EC%8B%9C%EC%9E%90-this.watch</guid>
            <pubDate>Fri, 21 Oct 2022 06:47:06 GMT</pubDate>
            <description><![CDATA[<h2 id="시계는-와치watch">시계는 와치...Watch</h2>
<h3 id="watch-object-사용-방법">watch object 사용 방법</h3>
<p>watch는 감시자 역할을 하고 있으며, 설정한 데이터가 변화할 때 마다 watcher도 실행이 된다
<code>watch : {감시할데이터(){}}</code></p>
<p>코드 예시 : </p>
<pre><code class="language-js">watch : {
  month(e){
    if(isNaN(e) == true || e &gt;=13 ){
      alert(&#39;One year is 12 months&#39;);
      this.month = 1;
    }
  }
},
</code></pre>
<p><code>month</code> 데이터가 변할 때 마다 watcher도 실행 된다.</p>
<blockquote>
<p>💡Tip) input type=range 를 주어서 값을 제한줘도 된다.</p>
</blockquote>
<p>이에 대한 자세한 설명은 타블로그를 참고 부탁드립니다...</p>
<h3 id="thiswatch를-사용하게-된-이유">this.$watch()를 사용하게 된 이유</h3>
<p>내가 처음 데이터를 셋팅해 놓은것은 [현재 날짜로부터 한달전] ~ [현재 날짜] 이기 때문에 이 날짜 안에 선택된 데이터들만 보였다. 
그러나 기획에서 처음에는 [현재 날짜] ~ [현재 날짜] 로만 표시되고 데이터의 기간 필터는 걸지 않았으면 좋겠다고 하셨다. </p>
<blockquote>
<p>요약 : 캘린더의 특정 기간을 선택하게 되었을 경우에&#39;만&#39; 필터가 걸려서 데이터가 변경되도록 하고 싶었다. </p>
</blockquote>
<p>처음에 셋팅한 [현재 날짜로부터 한달전] ~ [현재 날짜] 코드</p>
<pre><code class="language-js">/* data */
      range: {
        start: dayjs().add(-1, &#39;M&#39;).format(&#39;YYYY-MM-DD 00:00:00&#39;),
        end: dayjs().format(&#39;YYYY-MM-DD 23:59:59&#39;),
        field: &#39;regDt&#39;,
        fieldLabel: &#39;등록 일시&#39;,
        type: &#39;date&#39;,
        label: &#39;dateSelectedCal&#39;,
      },</code></pre>
<pre><code class="language-js">  watch: {
    range: {
      deep: true,
      handler(value) {
        this.dateSelected(value) //데이터 선택시 실행 함수
      },
    },
  },
  methods: {
    async initialize() {
      this.range = {
        start: dayjs().add(-1, &#39;M&#39;).format(&#39;YYYY-MM-DD 00:00:00&#39;),
        end: dayjs().format(&#39;YYYY-MM-DD 23:59:59&#39;),
        field: &#39;regDt&#39;,
        fieldLabel: &#39;등록 일시&#39;,
        type: &#39;date&#39;,
        label: &#39;dateSelectedCal&#39;,
      }
    },</code></pre>
<p><img src="https://velog.velcdn.com/images/heina-effect/post/e09e0287-9af2-4108-90be-8e59874f9aaf/image.png" alt=""></p>
<p> 나같은 말하는 돌맹이에겐 또 한번의 삽질의 시간...
 비동기화하여 처리해야하는지 아니면 처음에 데이터를 다 비워놓고 나중에 데이터를 넣어준다던지.. 이 방법들이 맞는지도 틀린지도 모르고 우선 시작해 보았다. </p>
<p> 그러던 도중 <code>조건부 watch</code>를 우연하게 찾게 되었고 바로 삽질을 끝내고 불필요한 코드를 모두 지울 수 있었다. 얏호
 <img src="https://velog.velcdn.com/images/heina-effect/post/0c6b7e9d-2c30-4fd6-a8f3-f8ada7028296/image.png" alt="vuejs.org"></p>
<h3 id="사용-방법">사용 방법</h3>
<p> <code>this.$watch</code> 사용 예시 </p>
<pre><code class="language-js">/* Watch a property name */
this.$watch(&#39;a&#39;, (newVal, oldVal) =&gt; {})

/* Watch a dot-delimited path */
this.$watch(&#39;a.b&#39;, (newVal, oldVal) =&gt; {})

/* Using getter for more complex expressions */
this.$watch(
  // every time the expression `this.a + this.b` yields
  // a different result, the handler will be called.
  // It&#39;s as if we were watching a computed property
  // without defining the computed property itself.
  () =&gt; this.a + this.b,
  (newVal, oldVal) =&gt; {}
)

/* Stopping the watcher */
const unwatch = this.$watch(&#39;a&#39;, cb)

// later...
unwatch()
</code></pre>
<p><a href="https://vuejs.org/guide/essentials/watchers.html#this-watch">vue 공식 문서 -  https://vuejs.org/guide/essentials/watchers.html#this-watch</a></p>
<h3 id="시계는-와치-적용-하기">시계는 와치 적용 하기</h3>
<p> <code>this.$watch</code> 를 적용하기 위해선 초기값(range)데이터가 필요하고, 해당 range가 변경 되었을 경우에만 특정함수를 실행하여 값을 변경해주어야 하기 때문에
 watch(){} 에 있는 것 range를 지우고 이것을 methods에서 작동할 수 있도록 옮겨주었다.</p>
<pre><code class="language-js">  methods: {
   /* this.range = {
        start: dayjs().add(-1, &#39;M&#39;).format(&#39;YYYY-MM-DD 00:00:00&#39;),
        end: dayjs().format(&#39;YYYY-MM-DD 23:59:59&#39;),
        field: &#39;regDt&#39;,
        fieldLabel: &#39;등록 일시&#39;,
        type: &#39;date&#39;,
        label: &#39;dateSelectedCal&#39;,
      } 
  */

      // range 데이터가 변경되는 경우에 watch 실행
      this.$watch(&#39;range&#39;, value =&gt; {
        this.dateSelected(value)
      })
    },
</code></pre>
<p>기존에는 초기 설정된 한달동안의 range 데이터가 먼저 보이고 그 이후에 값이 보였다면,</p>
<p>this.$watch를 사용한 지금은 처음에 전체의 값이 다 보이고 그 이후에 데이터를 변경하였을 경우에만 필터가 적용되어 선택한 기간의 데이터만 보였다. </p>
<p>아주 유용한 기능인것 같다!</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[Vue date-picker를 통한 캘린더 만들기]]></title>
            <link>https://velog.io/@heina-effect/Vue-date-picker%EB%A5%BC-%ED%86%B5%ED%95%9C-%EC%BA%98%EB%A6%B0%EB%8D%94-%EB%A7%8C%EB%93%A4%EA%B8%B0</link>
            <guid>https://velog.io/@heina-effect/Vue-date-picker%EB%A5%BC-%ED%86%B5%ED%95%9C-%EC%BA%98%EB%A6%B0%EB%8D%94-%EB%A7%8C%EB%93%A4%EA%B8%B0</guid>
            <pubDate>Fri, 23 Sep 2022 08:15:36 GMT</pubDate>
            <description><![CDATA[<h1 id="캘린더를-component하여-사용하기">캘린더를 component하여 사용하기</h1>
<p>기존 프로젝트에서는 한 페이지 안에 모든 데이터를 넣다보니 무겁고, 유지보수할 때 찾는게 쉽지 않았다.
그래서 신규 프로젝트때는 component화해서 잘 사용했으면 좋겠다는 생각이 들어서 시작되었다. 나의 지옥행이(?)</p>
<p>프로젝트에서 페이지마다 형태가 다르기때문에 본인 페이지에 맞게 커스텀이 필요했다. 공통component에 다 집어넣으면 사용이 쉽지 않기때문에!</p>
<p>그래서 제일먼저 하고자한것이 캘린더를 component화 시키는 것</p>
<h2 id="1-캘린더-형태-잡기-bootstrap-사용">1. 캘린더 형태 잡기 (Bootstrap 사용)</h2>
<p><img src="https://velog.velcdn.com/images/heina-effect/post/92829941-6fd5-4993-bcbb-008228d3268f/image.png" alt="">
나는 이런형태에서 날짜 클릭시 캘린더가 보여졌으면 했다.</p>
<pre><code class="language-html">&lt;b-button-group class=&quot;mx-1&quot;&gt;
    &lt;b-button @click=&quot;showCalendar&quot; class=&quot;btn-cal&quot; variant=&quot;transparent&quot;&gt;
        {{ startDate }}
        &lt;span&gt;📅&lt;/span&gt;
    &lt;/b-button&gt;
    &lt;b-button :disabled=&quot;true&quot; variant=&quot;transparent&quot; class=&quot;mx-2&quot;&gt;~&lt;/b-button&gt;
    &lt;b-button @click=&quot;showCalendar&quot; class=&quot;btn-cal&quot; variant=&quot;transparent&quot;&gt;
        {{ endDate }}
        &lt;span&gt;📅&lt;/span&gt;
    &lt;/b-button&gt;
&lt;/b-button-group&gt;</code></pre>
<p>해당 형태를 만들었고, <code>click event</code>를 넣어서 클릭시 캘린더가 보여지도록 했다.
<code>startDate</code>와 <code>endDate</code>는 day.js를 통해 생성하여 가져왔다.</p>
<pre><code class="language-javascript">&lt;script&gt;
import dayjs from &#39;dayjs&#39;
export default {
  layout: &#39;default&#39;,
  name: &#39;MemberInfo&#39;,
  components: {
},
data() {
  return {
      calendarFlag: false,
      range: {
        start: dayjs().format(&#39;YYYY-MM-DD&#39;),
        end: dayjs().format(&#39;YYYY-MM-DD&#39;),
      },
},
computed: {
  startDate() {
    return this.range.start
  },
  endDate() {
    return this.range.end
  },
},
methods: {
  showCalendar() {
    this.calendarFlag = true
  },
  hideCalendar() {
    if (this.calendarFlag) this.calendarFlag = false
  },
},
&lt;/script&gt;</code></pre>
<p>그리고 show, hide 하는 methods를 작성해 놓는다! </p>
<h2 id="2-캘린더-component-생성-v-date-piker-사용">2. 캘린더 component 생성 (v-date-piker 사용)</h2>
<p><img src="https://velog.velcdn.com/images/heina-effect/post/73fa37a1-1f1c-41b3-90e2-a7f86f21b8db/image.png" alt=""></p>
<p>calendar.vue 생성 후 코드 작성하기</p>
<pre><code class="language-html">&lt;template&gt;
  &lt;div&gt;
    &lt;v-date-picker
      :v-model=&quot;range&quot;
      locale=&quot;ko&quot;
      is-range
      mode=&quot;date&quot;
      class=&quot;datetime-picker&quot;
      is24hr
      :max-date=&quot;new Date()&quot;
      :columns=&quot;
        $screens({
          default: 1,
        })
      &quot;
    &gt;
    &lt;/v-date-picker&gt;
  &lt;/div&gt;
&lt;/template&gt;
&lt;script&gt;
export default {
  name: &#39;Calendar&#39;,
  data() {
    return {}
  },
  props: {
    range: {
      type: Object,
      default() {
        return {}
      },
    },
  },
}
&lt;/script&gt;

&lt;style&gt;
.datetime-picker {
  position: absolute;
  left: 430px;
  top: 120px;
  z-index: 5;
}
&lt;/style&gt;</code></pre>
<h2 id="3-캘린더-작동하게-하기-emit-사용하기">3. 캘린더 작동하게 하기 ($emit 사용하기)</h2>
<p>이제 진행해야 할 것은 </p>
<p>1) 캘린더(자식) component를 부모 component에 장착시키기
2) click 했을 때 오픈시키기
3) 캘린더 외 다른 부분을 클릭했을 때 닫게하기 (자식컴포넌트에서 작동으로 부모컴포넌트의 데이터 변경시키기)</p>
<h3 id="캘린더자식-component를-부모-component에-장착시키기">캘린더(자식) component를 부모 component에 장착시키기</h3>
<pre><code class="language-html">&lt;Calendar v-if=&quot;calendarFlag&quot; @hideCalendar=&quot;hideCalendar&quot; :range=&quot;range&quot; /&gt;</code></pre>
<h3 id="click-했을-때-오픈시키기">click 했을 때 오픈시키기</h3>
<p>click했을 때 calendarFlag값이 true가 되도록 설정해 놓았다. </p>
<p><code>v-if=&quot;calendarFlag&quot;</code> calendarFlag가 true일경우 컴포넌트를 보여준다. 그리고 데이터(<code>range</code>)를 props로 전송한다. </p>
<h3 id="자식컴포넌트에서-작동으로-부모컴포넌트의-데이터-변경시키기">자식컴포넌트에서 작동으로 부모컴포넌트의 데이터 변경시키기</h3>
<p>자식컴포넌트에서 무언가를 작업하면 <code>calendarFlag</code> 값이 false가 되면서 캘린더가 닫혀야 한다</p>
<p><strong>그러나! 부모에 있는 값을 자식이 함부로 가공해서는 안된다.</strong>
그렇기 때문에 자식이 부모한테 &#39;이 값을 바꿔주세요~&#39;라고 요청해야 한다</p>
<p>그래서 자식 component에서 <code>$emit</code> 쏴주자</p>
<h4 id="자식-component-에서-보낼-정보를-emit-통해서-신호-발송-thisemit이벤트명">자식 component 에서 보낼 정보를 <code>$emit</code> 통해서 신호 발송! <code>this.$emit(’이벤트명’)</code></h4>
<pre><code class="language-html">&lt;template&gt;
  &lt;div&gt;
    &lt;v-date-picker
      v-click-outside=&quot;onClickOutside&quot; 
      :v-model=&quot;range&quot;
      locale=&quot;ko&quot;
      is-range
      mode=&quot;date&quot;
      class=&quot;datetime-picker&quot;
      is24hr
      :max-date=&quot;new Date()&quot;
      :columns=&quot;
        $screens({
          default: 1,
        })
      &quot;
    &gt;
    &lt;/v-date-picker&gt;
  &lt;/div&gt;
&lt;/template&gt;</code></pre>
<pre><code class="language-javascript">methods: {
    onClickOutside() {
      this.$emit(&#39;hideCalendar&#39;)
    },
  },</code></pre>
<p><code>v-click-outsid</code>은 vuetify에서 확인할 수 있는데, 이외의 것을 찍으면 특정 함수를 실행시켜주는것이다. 그래서 우리는 캘린더를 닫아달라는 이벤트명을 쏴주도록 한다.</p>
<h4 id="부모-component-에서-받을-준비를-한다-이벤트명실행할-함수">부모 component 에서 받을 준비를 한다 @이벤트명=”실행할 함수”</h4>
<pre><code>&lt;Calendar v-if=&quot;calendarFlag&quot; @hideCalendar=&quot;hideCalendar&quot; :range=&quot;range&quot; /&gt;</code></pre><p>그래서 이제 부모가 해당 값을 받고나면 <code>hideCalendar()</code>를 실행하여 닫게한다.</p>
<p><strong>그러려고 했으나? 이렇게 쉽게 끝날리 없지</strong></p>
<p>캘린더는 보이는데 닫히지가 않는다! 그래서 서치한 결과</p>
<pre><code class="language-javascript">directives: {
    clickOutside: {
      bind(el, binding, vnode) {
        el.event = function (event) {
          if (event.target.classList.contains(&#39;not-outside&#39;)) return false
          if (!(el === event.target || el.contains(event.target))) {
            vnode.context[binding.expression](event)
          }
        }
        document.body.addEventListener(&#39;click&#39;, el.event)
      },
      unbind(el) {
        document.body.removeEventListener(&#39;click&#39;, el.event)
      },
    },
  },</code></pre>
<p>이것을 사용해야 한다고 한다. 
그래서 당연히 넣어보았으나, 실행되지 않았고 
console()로 찍어보았을때 캘린더는 쥐도새도 모르게 사라져있고 해당 함수마다 다 각각 실행되어 있었다. </p>
<p>뭔가 한 tick만 잡으면 보일것 같은데 계속 고민하고 ref를 사용해보려고 열심히 시간을 보내봤지만 되지 않았다. 그러다 캘린더 버튼을 <code>@click.stop=&quot;showCalendar&quot;</code> click.stop 형태로 바꾸었더니 매우 아주 잘 실행되었다.</p>
<pre><code class="language-html">&lt;b-button @click=&quot;showCalendar&quot; class=&quot;btn-cal&quot; variant=&quot;transparent&quot;&gt;
        {{ startDate }}
  &lt;span&gt;📅&lt;/span&gt;
&lt;/b-button&gt;</code></pre>
<h2 id="그리고-결론">그리고 결론!</h2>
<p>2일에 걸려서 이것저것 알아보면서 하느라 성공시켰지만 V-Calendar를 쓰기로 했다. 기성품이좋아
<a href="https://vcalendar.io/examples/datepickers.html">V-Calendar</a></p>
]]></description>
        </item>
        <item>
            <title><![CDATA[NVM으로 Node.js 버전 쉽게 변경하기]]></title>
            <link>https://velog.io/@heina-effect/NVM%EC%9C%BC%EB%A1%9C-Node.js-%EB%B2%84%EC%A0%84-%EC%89%BD%EA%B2%8C-%EB%B3%80%EA%B2%BD%ED%95%98%EA%B8%B0</link>
            <guid>https://velog.io/@heina-effect/NVM%EC%9C%BC%EB%A1%9C-Node.js-%EB%B2%84%EC%A0%84-%EC%89%BD%EA%B2%8C-%EB%B3%80%EA%B2%BD%ED%95%98%EA%B8%B0</guid>
            <pubDate>Fri, 02 Sep 2022 01:38:40 GMT</pubDate>
            <description><![CDATA[<p>프로젝트를 PULL 받아서 사용하려는데 자꾸 에러가 난다. 외않되?
<img src="https://velog.velcdn.com/images/heina-effect/post/2847dc15-c67e-4737-bd14-174e76e23df9/image.jpg" alt=""></p>
<p>살펴보던 도중 나는 node 16.15.1 버전을 사용하는데, 프로젝트는 node 14버전을 쓰는것을 알게되었다.</p>
<p>이걸 어째..? 신규 프로젝트는 node 16인데 매번 삭제후 재설치 할 수 없잖아</p>
<p>그렇게 찾게된 NVM! 너무나 유용해서 공유하려한다
(사실 매번 사용법 까먹음)</p>
<h2 id="nvm-사용방법">NVM 사용방법</h2>
<h3 id="nvm-설치하기">NVM 설치하기</h3>
<p>nvm의 github에 방문해서 nvm 설치 파일 다운로드하여 설치
<img src="https://velog.velcdn.com/images/heina-effect/post/e3a359a0-1968-4787-bb80-736ad240de25/image.png" alt="">
<img src="https://velog.velcdn.com/images/heina-effect/post/5c8b13ed-4b84-4d7c-b088-3271234b17bc/image.png" alt="">
<a href="https://github.com/coreybutler/nvm-windows/">NVM GItHub(https://github.com/coreybutler/nvm-windows/)</a></p>
<h3 id="cmd에서-node버전-변경">CMD에서 Node버전 변경</h3>
<ol>
<li><p>cmd 창에 해당 명령어 입력 - <code>nvm list available</code>
<img src="https://velog.velcdn.com/images/heina-effect/post/d416e83f-39fc-41b8-92e6-7c4c05df41ad/image.png" alt="">
사용가능한 node.js 버전을 알려준다.</p>
</li>
<li><p>원하는 버전 설치 - <code>nvm install &#39;버전&#39;</code></p>
</li>
<li><p>설치된 node 버전 조회 - <code>nvm list</code>
<img src="https://velog.velcdn.com/images/heina-effect/post/1e768d74-4e99-4df1-a61a-0f6ab728aebd/image.png" alt=""></p>
</li>
<li><p>사용할 버전 선택 - <code>nvm use &#39;버전&#39;</code>
변경완료! 가 될 줄 알았으나 오류가 뜸
<img src="https://velog.velcdn.com/images/heina-effect/post/d263d644-afc3-49cb-bdbe-d3221a4ace6b/image.png" alt="">
그래서 찾아보니! <strong>관리자 권한</strong>으로 실행해야 한다!
<img src="https://velog.velcdn.com/images/heina-effect/post/c8e887a5-0d98-47d9-b210-5be6aad5337e/image.png" alt="">
관리자 권한으로 재실행하여 버전 변경 - <code>node -v</code></p>
</li>
<li><p>버전 변경 확인 하기!
<img src="https://velog.velcdn.com/images/heina-effect/post/16b63622-9a03-474a-bd87-b73029041526/image.png" alt="">
끗!</p>
</li>
</ol>
]]></description>
        </item>
        <item>
            <title><![CDATA[Vue Router 사용하기]]></title>
            <link>https://velog.io/@heina-effect/Vue-Router-%EC%82%AC%EC%9A%A9%ED%95%98%EA%B8%B0</link>
            <guid>https://velog.io/@heina-effect/Vue-Router-%EC%82%AC%EC%9A%A9%ED%95%98%EA%B8%B0</guid>
            <pubDate>Tue, 23 Aug 2022 06:03:49 GMT</pubDate>
            <description><![CDATA[<h1 id="vue-router-사용하기">Vue Router 사용하기</h1>
<h2 id="vue-router를-사용하는-이유">Vue Router를 사용하는 이유</h2>
<p>URL 변경시 Dom을 새로 갱신하는 것이 아니라 변경된 영역만 갱신하기 때문에 유연한 페이지 전환이 가능하다!</p>
<h2 id="라우터-생성-및-사용-방법">라우터 생성 및 사용 방법</h2>
<h3 id="1-vue-router를-설치한다">1. Vue-router를 설치한다</h3>
<p><code>node
npm install vue - router@4</code></p>
<h3 id="2-routerjs-생성하여-route-관리하기">2. router.js 생성하여 route 관리하기</h3>
<p>1) vue-router를 import 하고, router를 변수 선언하여 작성한다.
2) 사용할 component 들을 import 시키고, routes에서 설정을 해준다.
    - path는 주소로 사용, component 에는 해당 페이지 이름</p>
<pre><code class="language-js">import { createWebHistory, createRouter } from &quot;vue-router&quot;;
import Home from &#39;./component/Home.vue&#39;;
import About from &#39;./component/About.vue&#39;

const routes = [
  { path: &#39;/&#39;, component: Home },
  { path: &#39;/about&#39;, component: About },
]

const router = createRouter({
  history: createWebHistory(),
  routes,
});

export default router;</code></pre>
<h3 id="3-mainjs-설정하기">3. main.js 설정하기</h3>
<p>우리는 router를 사용할 것 이기 때문에 <code>.use(router)</code> 꼭 작성!</p>
<pre><code class="language-js">// ** vue2 버전 **//

// 5. Create and mount the root instance.
const app = Vue.createApp({})
// Make sure to _use_ the router instance to make the
// whole app router-aware.
app.use(router)

app.mount(&#39;#app&#39;)</code></pre>
<pre><code class="language-js">// ** vue3 버전 **//

import { createApp } from &#39;vue&#39;
import App from &#39;./App.vue&#39;
import router from &#39;./router&#39;

createApp(App).use(router).mount(&#39;#app&#39;)</code></pre>
<p>버전에 따라 설정하는 방법이 다르기 때문에 확인하고, 더 정확한건 vue-router 공식 홈페이지에서 확인할 수 있다.</p>
<h3 id="4-appvue에서-view-설정하기">4. app.vue에서 view 설정하기</h3>
<p><code>&lt;router-view&gt;&lt;/router-view&gt;</code> 를 사용하여 어디에나 배치하여 레이아웃에 맞게 조정할 수 있다.</p>
<pre><code class="language-html">&lt;template&gt;
  &lt;div&gt;
    &lt;nav class=&quot;navbar bg-light&quot;&gt;
      &lt;div class=&quot;container-fluid&quot;&gt;
        &lt;a class=&quot;navbar-brand&quot; href=&quot;#&quot;&gt;
          &lt;img
            src=&quot;@/assets/1F92A.svg&quot;
            alt=&quot;&quot;
            width=&quot;30&quot;
            height=&quot;24&quot;
            class=&quot;d-inline-block align-text-top&quot;
          /&gt;
          Vuelog
        &lt;/a&gt;
        &lt;router-link to=&quot;/&quot;&gt;Home&lt;/router-link&gt;
        &lt;router-link to=&quot;/list&quot;&gt;list&lt;/router-link&gt;
      &lt;/div&gt;
    &lt;/nav&gt;
    &lt;div class=&quot;mt-4&quot;&gt;
     &lt;router-view :listDatas=&quot;listDatas&quot;&gt;&lt;/router-view&gt;
    &lt;/div&gt;
  &lt;/div&gt;
&lt;/template&gt;</code></pre>
<p><code>router-view</code>에 전송할 정보가 있다면 데이터 바인딩도 진행한다!
여기서 편리한 점은, view로 데이터바인딩을 해주기 때문에 router로 관리하는 모든 component에서 해당 데이터를 사용 할 수 있다! </p>
<h3 id="5-해당-view-로-이동하고-싶다면">5. 해당 view 로 이동하고 싶다면</h3>
<p><code>&lt;router-link to=”/링크”&gt;&lt;/router-link&gt;</code>
해당 페이지로 이동 to 사용하여 페이지 걸면 된다! (4. 의 코드 참조!)</p>
<p><a href="https://router.vuejs.org/">vue-router 공식페이지 참고 https://router.vuejs.org/</a></p>
]]></description>
        </item>
        <item>
            <title><![CDATA[VScode 간지나게 사용하기 - 공백 설정]]></title>
            <link>https://velog.io/@heina-effect/VScode-%EC%9E%98-%EC%82%AC%EC%9A%A9%ED%95%98%EA%B8%B0-%EA%B3%B5%EB%B0%B1-%EC%84%A4%EC%A0%95</link>
            <guid>https://velog.io/@heina-effect/VScode-%EC%9E%98-%EC%82%AC%EC%9A%A9%ED%95%98%EA%B8%B0-%EA%B3%B5%EB%B0%B1-%EC%84%A4%EC%A0%95</guid>
            <pubDate>Thu, 23 Jun 2022 01:51:55 GMT</pubDate>
            <description><![CDATA[<h2 id="공백-설정하기">공백 설정하기</h2>
<p>코드를 작성하다 보면 뭔가 묘하게 불편한게 느껴진다 (나만?)
다른 사람들의 코드를 보면 굉장히 깔끔하고 잘 붙어있는 느낌인데
그래서 다른점을 찾아보니 다른사람들은 공백이 2, 나는 4로 설정되어 있었다.</p>
<h3 id="간단하게-변경하는-방법">간단하게 변경하는 방법</h3>
<p><img src="https://velog.velcdn.com/images/heina-effect/post/894d48a2-b309-4bf0-bccf-3e7a3fac703a/image.png" alt="변경전">
그래서 간단하게 바꾸는 방법을 찾아보니 <code>공백:4</code>을 눌러서 바로 변경할 수 있었다
<img src="https://velog.velcdn.com/images/heina-effect/post/ba19c20f-01a3-4c90-be24-e2035f35906a/image.png" alt="설정1"><img src="https://velog.velcdn.com/images/heina-effect/post/fcc6ab46-e047-4384-ad2a-2d1d610daa1d/image.png" alt="설정2"></p>
<p><img src="https://velog.velcdn.com/images/heina-effect/post/000810ba-9305-4084-93e7-b3b9e66156fe/image.png" alt="변경후">
<code>tab</code>키로 한번에 변경해도 되지만! 귀찮으니까 <code>alt+shift+f</code>
그런데 매번 이렇게 변경하다 보니 귀찮아졌다. 
나는 게으른 개발자라네 껄껄껄</p>
<h3 id="환경설정으로-바꾸는-방법">환경설정으로 바꾸는 방법</h3>
<p><strong>code &gt; 환경설정 &gt; 설정</strong>에 들어가서 <code>Editor:Tab-size</code> 를 2로 변경!
<img src="https://velog.velcdn.com/images/heina-effect/post/6d8663c4-425e-4c42-a9cd-0da294a9a476/image.png" alt="환경설정 변경">
새로운 파일을 만들 때 마다 공백이 2로 변경되어있는것을 확인할 수 있을것이다.</p>
<p>이것도 귀찮다면?
<strong>cmd+shift+p키-&#39;설정&#39; 입력-&#39;tab size&#39; 입력</strong>
<img src="https://velog.velcdn.com/images/heina-effect/post/ad815f40-0bdd-4143-b18c-4ae1374520fe/image.png" alt="환경설정변경후"></p>
]]></description>
        </item>
        <item>
            <title><![CDATA[배열의 합 구하기 Array.reduce()]]></title>
            <link>https://velog.io/@heina-effect/%EB%B0%B0%EC%97%B4%EC%9D%98-%ED%95%A9-%EA%B5%AC%ED%95%98%EA%B8%B0-.reduce</link>
            <guid>https://velog.io/@heina-effect/%EB%B0%B0%EC%97%B4%EC%9D%98-%ED%95%A9-%EA%B5%AC%ED%95%98%EA%B8%B0-.reduce</guid>
            <pubDate>Thu, 16 Jun 2022 01:56:17 GMT</pubDate>
            <description><![CDATA[<p>알고리즘 문제를 풀기 위해 배열의 합을 구해야 했다.
기존에는 배열의 합을 구하기 위하여 for문이나 forEach()를 사용했었다.</p>
<p>그런데 검색도중 .reduce()를 유용하게 많이 사용한다고 하여 나도 한번 알아보았다.</p>
<h2 id="arrayreduce-기본-사용법">Array.reduce() 기본 사용법</h2>
<pre><code class="language-js">// 배열.reduce((누적값, 현재값, 인덱스, 요소) =&gt; { return 결과 }, 초깃값);
// 배열.reduce((accumulator, currentValue, index) =&gt; { return 결과 }, 초깃값);

const array = [ 6, 2, 1, 8, 10 ]

const result = array.reduce((accumulator, currentValue, index, arr) =&gt; {
  console.log(accumulator, currentValue, index);
  return acc + cur;
}, 0);
// 0 6 0
// 6 2 1
// 8 1 2
// 9 8 3
// 17 10 4
result; // 27

//초기값을 적어주지 않았을 떄! 
const result = array .reduce((acc, cur, index) =&gt; {
  console.log(acc, cur, index);
  return acc + cur;
});
// 6 2 1
// 8 1 2
// 9 8 3
// 17 10 4
result; // 27</code></pre>
<p>이렇게 풀어놓기만 하면 무슨말인지 잘 이해가 안될 수 있다. </p>
<p>accumulator 누적값, currentValue 현재값, index는 인덱스, arr은 요소 이다.
반복문처럼 한번씩 돌아가면서 값을 더해준다고 생각하면 된다.</p>
<p>초기값을 0으로 셋팅해 주었으니 누적값은 0, 배열에 처음에 있는 값 현재값 6, 그리고 인덱스는 0, 
그 다음 단계에서는 누적값은 6, 현재값은 두번째 요소인 2, 인덱스는 1, 이런식으로 차근차근 돌아가는 것이다.</p>
<p><strong>내가 알기로 reduce는 중간에 도망쳐 나올수가 없다고 한다. 주의 요망!</strong></p>
<h2 id="화살표함수를-이용한-reduce사용법">화살표함수를 이용한 reduce()사용법</h2>
<pre><code class="language-javascript">//arrow function 이용하기

const array = [ 6, 2, 1, 8, 10 ];

const result = array.reduce((acc,cur)=&gt; acc + cur );
console.log(result); // 27
</code></pre>
<h2 id="object에서-사용하기">Object에서 사용하기</h2>
<pre><code class="language-javascript">var array = [
  { name: &quot;Heina&quot;, age: 25 },
  { name: &quot;Claire&quot;, age: 60 },
  { name: &quot;Patrick&quot;, age: 30 },
  { name: &quot;Teo&quot;, age: 20 },
];

const result = array.reduce((acc, cur) =&gt; acc + cur.age); //err</code></pre>
<p>여기선 결과가 나오지 않는다 이유는 초기값을 설정하지 않았기 때문! </p>
<pre><code class="language-javascript">const result = array.reduce((acc, cur) =&gt; acc + cur.age, 0); //135
const result = array.reduce((acc, cur) =&gt; acc + cur.age, 10); //145

//평균값을 구하려면
console.log(result / array.length); //33.75</code></pre>
<h2 id="콜백으로-reduce사용하기">콜백으로 reduce()사용하기</h2>
<pre><code class="language-js">
function reducer(acc, cur, index){
    const result = acc + cur;
    console.log(&#39;acc = &#39;, acc, &#39;cur = &#39;, cur , &#39;index = &#39;, index , &#39;result = &#39;,result)
    return result
  }

  return array.reduce(reducer, 0);</code></pre>
<p>rudece() 를 이용하여 filter 기능도 사용할 수 있다고 하였는데, 그건 추가로 공부한 후 내용을 추가시킬 예정이다.</p>
<p>array 메소드 함수에 대한 재밌는 짤방 ㅎ
<img src="https://velog.velcdn.com/images/heina-effect/post/4e23eda1-96de-466d-ac53-b55346b85ced/image.jpg" alt=""></p>
]]></description>
        </item>
    </channel>
</rss>