[{"content":"\n배포 버튼을 누르면 서비스가 몇 초 동안 사라졌다. 타겟 그룹에 묶인 서버 전부에 같은 순간 JAR가 올라가고, 같은 순간 재시작됐기 때문이다. 파이프라인은 SSH_SERVERS 목록을 반복문으로 돌 뿐, 서버 하나가 재시작하는 동안 나머지가 트래픽을 받아준다는 개념이 없었다.\n운영 환경을 살펴보니 이런 단일 타겟 그룹으로 돌아가는 서비스가 생각보다 많았다. 서버가 두 대 이상인데도 배포 순간에는 한 대짜리와 다를 게 없었다. 서버 수는 가용성을 위해 늘려 놓은 것인데, 배포 방식이 그 가용성을 매번 지워 버리고 있었다.\n배포는 빨라야 하는 게 아니라 끊기지 않아야 한다. 이 글에서는 동시 배포 파이프라인을 Rolling Deployment로 바꾸며 내린 네 가지 결정을 정리한다. 서버별 순차 배포와 Load Balancer 제어를 묶는 방식이다. 순서, LB 제어, 승인 모드, 실패 처리 순이다.\n동시 배포는 빠르기 때문에 오래 살아남았다 기존 파이프라인의 배포 스테이지는 이렇게 생겼다. Publish Over SSH 플러그인으로 서버 목록을 순회하며 파일을 올리고 execCommand로 재시작한다.\nstage(\u0026#39;App. SSH Deploy\u0026#39;) { when { expression { env.ONLY_BUILD != \u0026#39;true\u0026#39; } } steps([$class: \u0026#39;BapSshPromotionPublisherPlugin\u0026#39;]) { script { targets = env.SSH_SERVERS.split(\u0026#39;,\u0026#39;) targets.each { server -\u0026gt; echo \u0026#34;TARGET SSH SERVER : ${server}\u0026#34; sshPublisher( continueOnError: false, failOnError: true, publishers: [ sshPublisherDesc( configName: server, verbose: true, transfers: [ sshTransfer(sourceFiles: env.SSH_SOURCE_FILES, removePrefix: env.SSH_REMOVE_REFIX, remoteDirectory: env.SSH_REMOTE_DIRECTORY, execCommand: env.SSH_EXEC_COMMAND) ] ) ] ) } } } } 반복문이 있으니 얼핏 순차 배포처럼 보인다. 하지만 서버 사이에 아무 대기도 없고 LB는 이 서버가 지금 재시작 중이라는 사실을 모른다. 모든 서버가 거의 동시에 내려갔다 올라오고, 그 사이 요청은 죽은 포트로 흘러간다. 문제는 배포가 느린 게 아니라 LB가 배포를 모른다는 것이다.\n이 구조가 오래 남아 있던 이유는 단순하다. 빠르고, 코드가 짧고, 서버가 한 대일 때는 아무 차이가 없다. 서버가 늘어난 뒤에도 파이프라인은 그대로였다.\n무중단을 위해 정한 네 가지 요구사항 Rolling Deployment로 바꾸면서 요구사항을 넷으로 정리했다. 요구사항 하나가 곧 설계 결정이었다.\n서버별 순차 배포. 한 번에 한 서버만 배포한다. 배포 시간은 서버 수에 비례해 늘어나지만, 그 대가로 배포 중에도 항상 N-1대가 살아 있다.\nLoad Balancer 제어. 배포 대상 서버를 LB에서 먼저 빼고, 배포가 끝나면 다시 넣는다. 순차 배포만으로는 부족하다. LB가 모르는 순차 배포는 죽은 서버로도 요청을 보낸다.\n승인 단계 제어. 승인 절차를 파라미터로 켜고 끈다. 개발 환경은 승인이 필요 없고, 운영 환경은 서버마다 확인이 필요하다. 이 파이프라인은 여러 Business Partner가 같이 쓰기 때문에 환경별로 다른 파이프라인을 만드는 대신 하나에 옵션을 열어 두기로 했다.\n실패 시 복구. 배포 도중 예외가 나면 그 서버를 LB에 되돌려 놓고 파이프라인을 멈춘다.\n승인 단계에는 Choice Parameter로 세 가지 모드를 뒀다.\nFirst server only: 첫 번째 서버만 승인한다. 첫 서버를 LB에서 빼고 배포한 뒤 파이프라인을 멈추고 담당자가 검증한다. Proceed면 LB에 재등록하고 나머지 서버는 승인 없이 이어서 배포한다. Abort면 배포를 중단하고 그 서버를 LB에 재등록한다.\nAll servers: 같은 흐름을 모든 서버에 적용한다. 서버마다 검증과 승인이 들어간다.\nVerification Pass: 승인 단계를 전부 생략하고 자동으로 진행한다.\n서버를 하나씩 LB에서 빼면 왜 끊기지 않는가 원리는 한 문단이면 된다. LB에서 deregister된 서버는 새 요청을 받지 않으니, 그 서버가 JAR를 교체하고 재시작하는 동안 트래픽은 나머지 서버로 간다. 재시작이 끝나면 register해서 다시 트래픽을 받게 하고 그다음 서버로 넘어간다. 어느 순간에도 LB에 등록된 서버 중 재시작 중인 서버는 없다. 이것이 무중단의 전부다.\nsequenceDiagram participant J as Jenkins participant LB as Load Balancer participant S1 as Server 1 participant S2 as Server 2 J-\u0026gt;\u0026gt;LB: deregister S1 Note over LB,S2: 트래픽은 S2로만 간다 J-\u0026gt;\u0026gt;S1: JAR 배포, 재시작 J--\u0026gt;\u0026gt;J: (승인 모드에 따라) input 대기 J-\u0026gt;\u0026gt;LB: register S1 J-\u0026gt;\u0026gt;LB: deregister S2 Note over LB,S1: 트래픽은 S1로만 간다 J-\u0026gt;\u0026gt;S2: JAR 배포, 재시작 J-\u0026gt;\u0026gt;LB: register S2단, 이 원리는 \u0026ldquo;deregister 직후 그 서버로 가던 요청이 다 끝났다\u0026quot;와 \u0026ldquo;register 직전 애플리케이션이 실제로 떠 있다\u0026quot;를 전제한다. 파이프라인은 둘 다 보장하지 않는다. 이 이야기는 뒤에서 다시 한다.\n파이프라인을 어떻게 바꿨나 승인 모드 파라미터 기존 파라미터에 INPUT_APPROVAL_MODE를 추가했다. 값은 위의 세 문자열이다.\nparameters { // 기존 파라미터들... choice(name: \u0026#39;INPUT_APPROVAL_MODE\u0026#39;, choices: [\u0026#39;First server only\u0026#39;, \u0026#39;All servers\u0026#39;, \u0026#39;Verification Pass\u0026#39;], description: \u0026#39;\u0026#39; ) } LB 제어 스크립트 경로 LB를 빼고 넣는 실제 동작은 각 서버에 있는 쉘 스크립트에 맡기고 파이프라인은 경로만 환경변수로 안다. 기본값을 두어 환경변수가 없어도 돌아가게 했다.\nenvironment { // 기존 환경변수들... INPUT_APPROVAL_MODE = \u0026#34;${params.INPUT_APPROVAL_MODE}\u0026#34; LB_DEREGISTER_SCRIPT = \u0026#34;${env.LB_DEREGISTER_SCRIPT ?: \u0026#39;lb_deregister.sh\u0026#39;}\u0026#34; LB_REGISTER_SCRIPT = \u0026#34;${env.LB_REGISTER_SCRIPT ?: \u0026#39;lb_register.sh\u0026#39;}\u0026#34; } LB 종류를 파이프라인에서 분리한 것이 이 설계의 두 번째 결정이다. ALB든 NGINX upstream이든 HAProxy든, 파이프라인은 script server action 형태로 호출만 하고 내용은 서버 쪽 스크립트가 책임진다.\nRolling Deploy 스테이지 기존 App. SSH Deploy 스테이지를 통째로 교체했다. 서버마다 deregister, 배포, 조건부 승인, register 네 단계를 돌고 예외가 나면 catch에서 register를 다시 시도한 뒤 파이프라인을 멈춘다.\nstage(\u0026#39;App. Rolling Deploy\u0026#39;) { when { expression { env.ONLY_BUILD != \u0026#39;true\u0026#39; } } steps { script { def servers = env.SSH_SERVERS.split(\u0026#39;,\u0026#39;) def serverCount = servers.size() echo \u0026#34;Starting Rolling Deployment to ${serverCount} servers...\u0026#34; echo \u0026#34;Input Approval Mode: ${env.INPUT_APPROVAL_MODE}\u0026#34; servers.eachWithIndex { server, index -\u0026gt; def currentServerIndex = index + 1 echo \u0026#34;==========================================\u0026#34; echo \u0026#34;배포 시작 ${currentServerIndex}/${serverCount}: ${server}\u0026#34; echo \u0026#34;==========================================\u0026#34; try { // 1. 배포 전에 LB에서 뺀다. 이 서버로는 새 요청이 오지 않는다 echo \u0026#34;[Step 1] 로드 밸런서에서 ${server} 서버 등록 해제\u0026#34; executeLBScript(server, env.LB_DEREGISTER_SCRIPT, \u0026#34;deregister\u0026#34;) // 2. JAR 배포 및 재시작. 기존 SSH Deploy와 같은 동작 echo \u0026#34;[Step 2] ${server} 서버에 애플리케이션 배포 시작\u0026#34; deployToServer(server) // 3. 승인 모드에 따라 여기서 멈춘다. 서버는 아직 LB 밖이다 if (shouldRequireInput(currentServerIndex, env.INPUT_APPROVAL_MODE)) { echo \u0026#34;[Step 3] ${server} 서버 배포 정상여부 확인 및 승인 대기\u0026#34; def approvalMessage = \u0026#34;\u0026#34;\u0026#34; 🔄 배포 완료 진행률 ${currentServerIndex}/${serverCount} (${server}) 애플리케이션이 올바르게 작동하는지 확인하세요: • 애플리케이션 로그 확인 • 서비스 상태 확인 • 중요 기능 테스트 \u0026#39;Proceed\u0026#39;을 클릭하여 서버를 로드 밸런서에 다시 등록하고 다음 서버로 계속 진행합니다. \u0026#34;\u0026#34;\u0026#34; input( message: approvalMessage, ok: \u0026#39;Proceed\u0026#39;, submitterParameter: \u0026#39;APPROVER\u0026#39; ) // echo \u0026#34;Deployment verification approved by: ${env.APPROVER}\u0026#34; echo \u0026#34;승인자: ${currentBuild.getBuildCauses(\u0026#39;hudson.model.Cause$UserIdCause\u0026#39;)[0]?.userId ?: \u0026#39;Unknown\u0026#39;}\u0026#34; } else { echo \u0026#34;[Step 3] ${server} 서버 승인 건너뛰기 (mode: ${env.INPUT_APPROVAL_MODE})\u0026#34; } // 4. 검증이 끝난 뒤에야 LB에 되돌린다 echo \u0026#34;[Step 4] ${server} 서버 로드밸런서에 다시 등록\u0026#34; executeLBScript(server, env.LB_REGISTER_SCRIPT, \u0026#34;register\u0026#34;) echo \u0026#34;Server ${currentServerIndex}/${serverCount} (${server}) deployment completed successfully!\u0026#34; } catch (Exception e) { echo \u0026#34;❌ ${server} 서버에 배포하는 동안 오류가 발생했습니다. (${e.getMessage()})\u0026#34; try { // 실패한 서버를 LB 밖에 남겨 두지 않는다. Abort도 여기로 들어온다 echo \u0026#34;🔄 ${server} 서버 로드밸런서에 다시 등록\u0026#34; executeLBScript(server, env.LB_REGISTER_SCRIPT, \u0026#34;register\u0026#34;) } catch (Exception rollbackError) { echo \u0026#34;❌ Rollback failed: ${rollbackError.getMessage()}\u0026#34; } error \u0026#34;${server} 서버 배포 실패\u0026#34; } } echo \u0026#34;🎉 모든 서버에 대한 롤링 배포가 성공적으로 완료되었습니다.\u0026#34; } } } 승인은 배포 뒤, 재등록 전에 온다. 담당자는 LB 밖에 있는 서버를 직접 붙어 확인하고 Proceed를 눌러야만 그 서버가 트래픽을 받는다. Abort를 누르면 input이 예외를 던지고 catch로 들어가 서버가 LB에 되돌아간다. 이 순서 덕분에 검증 중인 서버가 사용자 트래픽을 받는 일이 없다.\n지원 함수 승인 여부 판단, LB 스크립트 실행, 배포를 함수로 뺐다. executeLBScript와 deployToServer는 둘 다 sshPublisher를 쓴다. 다만 executeLBScript는 파일 전송 없이 execCommand만 보낸다.\ndef shouldRequireInput(serverIndex, inputMode) { switch(inputMode) { case \u0026#39;First server only\u0026#39;: return serverIndex == 1 case \u0026#39;All servers\u0026#39;: return true case \u0026#39;Verification Pass\u0026#39;: return false default: return false } } def executeLBScript(server, script, action) { sshPublisher( continueOnError: false, failOnError: true, publishers: [ sshPublisherDesc( configName: server, verbose: true, transfers: [ sshTransfer(execCommand: \u0026#34;${script} ${server} ${action}\u0026#34;) ] ) ] ) } def deployToServer(server) { sshPublisher( continueOnError: false, failOnError: true, publishers: [ sshPublisherDesc( configName: server, verbose: true, transfers: [ sshTransfer( sourceFiles: env.SSH_SOURCE_FILES, removePrefix: env.SSH_REMOVE_REFIX, remoteDirectory: env.SSH_REMOTE_DIRECTORY, execCommand: env.SSH_EXEC_COMMAND ) ] ) ] ) } 서버 쪽 LB 스크립트 파이프라인이 호출하는 스크립트는 각 서버에 미리 둔다. 원문 그대로 껍데기만 있다. 실제 LB API 호출은 환경마다 채워야 한다.\n#!/bin/bash # lb_deregister.sh SERVER_NAME=$1 ACTION=$2 echo \u0026#34;Deregistering server $SERVER_NAME from Load Balancer...\u0026#34; # AWS ALB, NGINX Upstream, HAProxy 등 실제 LB API 호출 #!/bin/bash # lb_register.sh SERVER_NAME=$1 ACTION=$2 echo \u0026#34;Registering server $SERVER_NAME to Load Balancer...\u0026#34; # AWS ALB, NGINX Upstream, HAProxy 등 실제 LB API 호출 환경마다 승인 모드가 달라진다 승인 모드를 파라미터로 열어 둔 이유가 여기서 드러난다. 파이프라인 하나가 세 환경을 덮는다.\n개발 환경. 승인 모드 Verification Pass\n# 승인 절차 없이 자동 배포 Server 1: LB 해제 → 배포 → LB 등록 Server 2: LB 해제 → 배포 → LB 등록 Server 3: LB 해제 → 배포 → LB 등록 스테이징 환경. 승인 모드 First server only\n# 첫 번째 서버만 승인 절차 적용 Server 1: LB 해제 → 배포 → [승인 대기] → LB 등록 Server 2: LB 해제 → 배포 → LB 등록 Server 3: LB 해제 → 배포 → LB 등록 운영 환경. 승인 모드 All servers\n# 모든 서버에 승인 절차 적용 Server 1: LB 해제 → 배포 → [승인 대기] → LB 등록 Server 2: LB 해제 → 배포 → [승인 대기] → LB 등록 Server 3: LB 해제 → 배포 → [승인 대기] → LB 등록 First server only가 실질적인 Canary 역할을 한다. 첫 서버에서 문제가 보이면 Abort 한 번으로 나머지 서버는 손대지 않은 채 멈춘다.\n바뀐 것과 바뀌지 않은 것 구분 기존 방식 Rolling Deployment 배포 방식 전체 서버 동시 배포 서버별 순차 배포 서비스 가용성 배포 중 서비스 중단 무중단 배포 문제 발생 시 전체 서비스 영향 단일 서버 영향 승인 절차 없음 파라미터로 제어 롤백 수동 처리 자동 롤백 시도 표의 마지막 줄 \u0026ldquo;자동 롤백 시도\u0026quot;는 코드의 catch 블록에서 LB 재등록 하나뿐이다. 이전 JAR로 되돌리지는 않는다.\n정리 무중단의 핵심은 순서다. 서버를 하나씩 LB에서 빼고, 올리고, 검증하고, 다시 넣는다. 어느 순간에도 LB에 등록된 서버 중 재시작 중인 서버는 없다.\nLB가 모르는 순차 배포는 무중단이 아니다. 반복문만으로는 부족하고, deregister와 register가 배포를 감싸야 한다.\n승인은 환경마다 다르게 필요하다. 첫 서버만, 전부, 생략 세 가지를 파라미터로 열어 두면 파이프라인 하나로 개발, 스테이징, 운영을 다 덮는다.\n승인은 배포 뒤, 재등록 전에 둔다. 검증 중인 서버가 트래픽을 받지 않게 하는 위치다.\n롤백이라 부르지만 아직은 LB 재등록뿐이다. 헬스체크와 이전 JAR 복원은 다음 과제다.\nLB 제어 스크립트는 껍데기다. ALB든 NGINX upstream이든 실제 API 호출은 각자 채워야 한다.\n동시 배포는 빠르다. 그래서 오래 남아 있었다. 바꾸고 나니 배포 시간은 서버 수만큼 늘었고, 대신 배포 중에 서비스가 사라지는 일은 없어졌다. 서버를 여러 대 두는 이유가 가용성이라면, 배포도 그 가용성을 존중해야 한다.\n느린 배포가 끊기는 배포보다 낫다.\n참고자료 Pipeline: Input Step — input 스텝의 submitterParameter, ok, 반환값 설명\nPublish Over SSH Plugin — sshPublisher, sshTransfer의 execCommand와 sourceFiles 옵션\nPipeline Syntax — parameters, environment, when 블록의 선언적 문법\nTarget groups for your Application Load Balancers — ALB 타겟 등록·해제와 deregistration delay\n","permalink":"https://jaylab.link/jenkins-rolling-deploy-pipeline/","summary":"타겟 그룹 전체를 동시에 재시작하던 Jenkins 파이프라인을 서버별 순차 배포와 LB 제어로 바꾸며 내린 네 가지 결정과 아직 남은 한계","title":"배포할 때마다 끊기던 서비스를 무중단으로: Jenkins Rolling Deployment에서 내린 네 가지 결정"},{"content":"\n고객사의 수많은 애플리케이션 배포 파이프라인을 하나로 추상화하는 작업을 하고 있다. 파이프라인은 하나로 모이지만 그 위를 지나가는 애플리케이션의 Java 버전은 제각각이라, 추상화한 파이프라인이 어느 버전에서도 빌드를 깨뜨리지 않는지 로컬에서 직접 돌려봐야 했다. 그런데 java -version을 치면 Corretto 21 하나만 나온다. 당장 필요한 건 JDK 11이었다.\n이 상황에서 할 수 있는 가장 단순한 일은 JDK 11을 하나 더 설치하고 JAVA_HOME을 손으로 바꾸는 것이다. 그 방법은 두 번째 버전부터 무너진다. 테스트할 버전이 하나가 아니기 때문이다. 어느 터미널이 어느 JDK를 보고 있는지 매번 확인해야 하고 되돌리는 걸 잊으면 다음 날 21이 필요한 프로젝트가 이유 없이 깨진다.\nJDK 전환은 설치의 문제가 아니라 PATH 우선순위의 문제다. 어떤 java가 실행되느냐는 설치된 JDK의 개수가 아니라 쉘이 PATH를 어느 순서로 훑느냐가 결정한다. SDKMAN은 그 순서에 끼어드는 도구다. SDKMAN이 기존 JDK를 지우지 않고 우선순위를 가져가는 방식, 이미 깔려 있던 JDK를 처리하는 방법, 빌드 테스트에 맞는 전환 방식까지 차례로 정리한다.\n쉘이 java를 찾는 순서가 곧 현재 JDK다 터미널에서 java를 입력하면 쉘은 PATH에 등록된 디렉토리를 앞에서부터 순서대로 뒤져 가장 먼저 발견되는 java 실행 파일을 쓴다. Corretto 21이 나오는 이유는 .zshrc 어딘가에서 그 JDK 경로가 PATH에 잡혀 있기 때문이다. 다른 JDK를 쓰고 싶으면 그보다 앞에 새 경로를 두면 된다.\nSDKMAN은 설치 과정에서 쉘 설정 파일 맨 아래에 초기화 스크립트 한 줄을 추가한다.\nexport SDKMAN_DIR=\u0026#34;$HOME/.sdkman\u0026#34; [[ -s \u0026#34;$HOME/.sdkman/bin/sdkman-init.sh\u0026#34; ]] \u0026amp;\u0026amp; source \u0026#34;$HOME/.sdkman/bin/sdkman-init.sh\u0026#34; 이 스크립트는 터미널이 새로 열릴 때마다 실행되면서 SDKMAN이 관리하는 JDK 경로를 기존 경로보다 앞, 그러니까 PATH의 맨 앞에 놓는다. 그래서 그 뒤로는 SDKMAN이 지정한 java가 항상 먼저 발견된다.\nSDKMAN은 기존 Java를 덮어쓰거나 삭제하지 않는다. 더 높은 우선순위로 가로챌 뿐이다. JDK를 포함한 모든 SDK는 홈 디렉토리 아래 ~/.sdkman/candidates/에 독립적으로 설치되고 기존에 설치된 Corretto-21.0.3.9.1은 원래 자리에 그대로 있다. JAVA_HOME 같은 환경변수도 SDKMAN이 함께 관리하므로 쉘 설정을 직접 고칠 일이 없다.\n이 구조라서 실패해도 되돌리기 쉽다. 초기화 스크립트 한 줄을 지우면 PATH는 원래대로 돌아가고 기존 JDK가 다시 앞으로 나온다.\n이미 깔려 있던 JDK는 어떻게 할 것인가 SDKMAN을 설치하고 나면 기존 JDK를 두고 세 가지 선택지가 생긴다.\n삭제한다. brew로 설치했으면 brew uninstall, 직접 설치했으면 직접 지운다. 가장 깔끔하지만 그 JDK에 의존하던 IDE나 스크립트가 있는지 먼저 확인해야 한다.\n그냥 둔다. SDKMAN이 우선순위를 가져갈 뿐 기존 JDK에는 영향이 없으므로 아무것도 안 해도 된다. 대신 sdk list에는 보이지 않고 SDKMAN으로 그 버전으로 되돌아갈 수도 없다.\nSDKMAN에 로컬 버전으로 등록한다. 기존 JDK를 SDKMAN의 관리 대상으로 넣어 새로 설치한 버전들과 같은 명령어로 전환할 수 있게 한다.\n하나의 도구로 모든 JDK를 다루려면 세 번째가 맞다. 절차는 두 단계다.\n먼저 설치된 JDK 목록과 경로를 확인한다.\n/usr/libexec/java_home -V 여기서 Corretto 21의 경로를 얻는다. 예를 들면 /Users/jin/Library/Java/JavaVirtualMachines/corretto-21.0.3/Contents/Home이다. 이 경로를 식별자와 함께 SDKMAN에 등록한다.\n# sdk install java [원하는 식별자] [JDK 경로] sdk install java 21.0.3-corretto-local /Users/jin/Library/Java/JavaVirtualMachines/corretto-21.0.3/Contents/Home 이제 sdk list java에 21.0.3-corretto-local이 나타나고 SDKMAN으로 설치한 다른 버전과 똑같이 sdk use나 sdk default로 전환할 수 있다. 식별자 뒤에 -local을 붙여 두면 SDKMAN이 내려받은 배포판과 내가 직접 등록한 것을 목록에서 구분할 수 있다.\n설치와 버전 전환 brew로도 설치할 수 있지만 공식 문서는 curl 스크립트를 권장한다. 이 방식이 위에서 본 초기화 스크립트를 쉘 설정에 자동으로 추가해 준다.\ncurl -s \u0026#34;https://get.sdkman.io\u0026#34; | bash 설치 가능한 버전은 sdk list java로 본다. OpenJDK, Oracle, Amazon Corretto, Zulu, Temurin 같은 배포판이 한 목록에 나오고, 식별자는 버전-배포판 형식이다.\n# Temurin (구 AdoptOpenJDK) 21 버전 설치 sdk install java 21.0.4-tem # Amazon Corretto 17 버전 설치 sdk install java 17.0.12-amzn 지금 필요한 것은 JDK 11이다.\nsdk install java 11.0.28-amzn 설치한 뒤 전환하는 명령은 둘인데 그 차이가 이 글의 마지막 결정이다.\n# 모든 새 터미널의 기본 JDK를 바꾼다 sdk default java 11.0.28-amzn # 지금 열려 있는 이 터미널 세션에서만 바꾼다 sdk use java 11.0.28-amzn 빌드 테스트에는 default가 아니라 use다 sdk default는 기본 JDK를 통째로 바꾼다. 앞으로 여는 모든 터미널이 11을 본다. 반면 sdk use는 지금 열린 세션 하나에서만 11을 쓰고 터미널을 닫으면 원래 기본 버전으로 돌아간다.\n특정 버전으로 빌드를 테스트하는 일은 예외 상황이지 새 기준이 아니다. 테스트할 버전이 여럿이면 더 그렇다. 예외를 기본값으로 만들면 다시 되돌리는 일이 생기고 그게 처음에 JAVA_HOME을 손으로 바꾸다 겪은 문제였다. 세션 단위 전환은 그 문제를 구조적으로 없앤다. 터미널을 열고, sdk use로 11을 잡고, 빌드를 돌리고, 닫는다. 다음 터미널은 아무 일도 없었다는 듯 21이다.\n주의할 점이 하나 있다. sdk use는 그 쉘 세션의 PATH만 바꾼다. IDE는 보통 자기 설정에서 JDK를 따로 고르므로 터미널에서 11로 바꿨다고 IDE 빌드까지 11이 되지는 않는다. IDE에서 같은 프로젝트를 열 때는 프로젝트 SDK를 별도로 맞춰야 한다.\n정리 어떤 JDK가 실행되는지는 PATH 순서가 정한다. 설치 개수가 아니다.\nSDKMAN은 기존 JDK를 지우지 않고 PATH 앞에 끼어든다. 초기화 스크립트 한 줄이 전부라서 되돌리기도 한 줄이다.\n기존 JDK는 SDKMAN에 로컬 버전으로 등록하는 편이 낫다. 도구 하나로 모든 버전을 같은 명령어로 다룰 수 있다.\n**빌드 테스트에는 **sdk use를 쓴다. 세션이 끝나면 기본 버전으로 복귀하므로 예외가 기준이 되는 일이 없다.\nsdk use는 터미널만 바꾼다. IDE의 프로젝트 SDK는 따로 맞춘다.\nJDK를 바꾸는 도구는 많다. 어느 것을 고르든 결국 하는 일은 PATH 앞에 뭘 둘 것인가다. 그 원리를 알고 나면 도구가 무엇을 해 주고 무엇을 해 주지 않는지가 보인다.\n","permalink":"https://jaylab.link/jdk-version-manager-sdkman/","summary":"로컬은 Corretto 21인데 빌드 테스트는 JDK 11이 필요할 때, SDKMAN이 기존 JDK를 건드리지 않고 PATH 우선순위만 가로채 세션 단위로 버전을 바꾸는 원리와 기존 JDK를 처리하는 세 가지 선택","title":"기존 JDK를 지우지 않고 JDK 11로 빌드하기: SDKMAN이 PATH를 가로채는 원리"},{"content":"exec(\u0026#39;docker run -d --name tenant-123 -p 3001:80 httpd:2.4\u0026#39;, (error, stdout) =\u0026gt; { if (error) { // 포트 충돌?? 이미지 없음?? 권한 문제?? } }); 컨테이너를 띄우는 가장 단순한 방법은 docker run 한 줄을 쉘에서 부르는 것이다. 문제는 그 한 줄이 실패했을 때다. error 객체 하나로는 포트가 겹친 건지, 이미지가 없는 건지, 권한이 없는 건지 알 수 없다.\n최근 IaaS(Infrastructure as a Service) 서비스를 직접 만들면서 이 문제를 정면으로 만났다. 사용자가 버튼을 누르면 그 사람 전용 컨테이너와 DB 스키마가 만들어지고, Google OAuth로 로그인한 본인만 거기에 접근할 수 있어야 했다. 이런 멀티 테넌트 프로비저닝에서는 컨테이너 하나를 띄우는 일보다 여러 사람이 동시에 요청하고 그중 일부가 중간에 실패하는 상황을 다루는 일이 훨씬 많다.\n테넌트를 만드는 것보다 테넌트 생성이 실패했을 때 무엇을 치워야 하는지 아는 것이 어려웠다. 이 글에서는 쉘 스크립트 대신 Node.js의 Dockerode를 고른 이유와 DB를 스키마 단위로 나눈 결정, 인증과 롤백을 묶은 프로비저닝 흐름을 정리한다.\ndocker run 한 줄로는 실패의 종류를 알 수 없었다 처음에는 Bash 쉘 스크립트로 Docker CLI를 직접 호출하려 했다. 컨테이너 생성, 삭제, 상태 확인 정도라면 충분해 보였지만, 요구사항을 하나씩 붙일수록 스크립트가 불어났다.\n컨테이너 생성이 실패하면 앞 단계에서 만든 것을 되돌려야 한다 (롤백).\n이미 쓰고 있는 포트를 피해 새 포트를 골라야 한다 (포트 충돌 방지).\n테넌트마다 메모리·CPU를 정해 줘야 한다 (동적 리소스 할당).\n여러 사용자가 동시에 테넌트를 만들면 포트 번호나 컨테이너 이름이 겹칠 수 있다 (동시성).\n네 가지 모두 \u0026ldquo;무엇이 실패했는지\u0026quot;를 코드가 알아야 풀리는 문제다. 쉘의 종료 코드와 stderr 문자열로는 그 분기를 안정적으로 짤 수 없었다.\nDockerode는 Docker Engine API를 Node.js에서 부르는 라이브러리다. 고른 이유는 두 가지다. 호출이 Promise를 돌려주니 await로 단계를 이어 붙일 수 있고, 실패가 예외로 올라오니 단계마다 따로 잡아서 처리할 수 있다.\n// ❌ 기존 CLI 방식: 실패 원인을 구분할 수 없다 exec(\u0026#39;docker run -d --name tenant-123 -p 3001:80 httpd:2.4\u0026#39;, (error, stdout) =\u0026gt; { if (error) { // 어떤 종류의 에러인지 파악 어려움.. // 포트 충돌?? 이미지 없음?? 권한 문제?? } }); // ✓ Dockerode: 설정을 객체로 넘기고, 실패는 예외로 받는다 const container = await docker.createContainer({ Image: \u0026#39;httpd:2.4\u0026#39;, name: `tenant-${tenantId}-${Date.now()}`, // 이름 충돌을 피하려고 타임스탬프를 붙인다 HostConfig: { PortBindings: { \u0026#39;80/tcp\u0026#39;: [{ HostPort: port.toString() }] } } }); 실제 설정은 위 예시보다 길다. 재시작 정책, 메모리와 CPU 제한, 테넌트 전용 네트워크, 볼륨, 테넌트 ID를 담은 Label까지 한 객체에 들어간다. 이걸 쉘 인자로 이어 붙였다면 따옴표 하나에 스크립트 전체가 흔들렸을 것이다.\nDockerode의 효용이 가장 컸던 곳은 포트 자동 할당이다. 사용 중인 포트를 실시간으로 확인하고 비어 있는 포트를 골라 붙이려면 Docker Engine API를 세밀하게 제어해야 했다. 목록 조회, 판단, 생성이 모두 같은 언어의 같은 흐름 안에 있어야 동시 요청 사이에서 포트가 겹치지 않게 다룰 수 있다.\n테넌트마다 DB를 줄 수는 없었다: 스키마로 나눈 논리적 격리 멀티 테넌트 시스템에서 데이터 격리는 크게 두 방식으로 나뉜다.\n구분 물리적 분리 논리적 분리 단위 테넌트마다 별도 DB 인스턴스 하나의 DB 안에서 스키마나 테이블로 분리 격리 수준 완벽한 격리 DB 계정 권한에 기대는 격리 리소스 테넌트 수만큼 인스턴스 인스턴스 하나 격리 수준을 사려면 리소스를 내야 하고, 주어진 개발 환경에는 그 리소스가 없었다. 그래서 논리적 분리를 골랐다. 현실적인, 솔직히 말하면 쓰라린 선택이었다.\n대신 스키마만 나누지 않고 테넌트 전용 DB 계정을 함께 만들고, 그 계정에는 자기 스키마에 대한 권한만 준다. 같은 인스턴스 안에 있어도 다른 테넌트의 스키마는 계정 수준에서 막힌다.\n// 테넌트별 스키마 생성 async createTenantDatabaseSchema(tenantId, schemaName) { const dbUser = `tenant_${tenantId}`; const dbPassword = crypto.randomBytes(16).toString(\u0026#39;hex\u0026#39;); // 1. 스키마 생성 await safeQuery(`CREATE SCHEMA IF NOT EXISTS ${schemaName}`); // 2. 전용 사용자 생성 및 권한 부여 (자기 스키마에만 ALL PRIVILEGES) await safeQuery(`CREATE USER \u0026#39;${dbUser}\u0026#39;@\u0026#39;%\u0026#39; IDENTIFIED BY \u0026#39;${dbPassword}\u0026#39;`); await safeQuery(`GRANT ALL PRIVILEGES ON ${schemaName}.* TO \u0026#39;${dbUser}\u0026#39;@\u0026#39;%\u0026#39;`); // 3. 기본 테이블 구조 생성 await this.createTenantTables(schemaName); } ※ 결국 나중엔 제거 🤫\n처음에는 테넌트별 스키마 정보를 tenant_schemas 테이블에 따로 기록했고, connection limit 10, storage quota 1GB 같은 값까지 넣었다. 결국 이 코드는 나중에 통째로 지웠다.\n스키마를 나눈 다음에는 요청 단계에서도 막아야 한다. Google OAuth로 인증받은 사용자가 자기 테넌트에만 접근하도록 소유권을 확인하는 미들웨어를 붙였다.\n// 테넌트 접근 제어 미들웨어 async verifyTenantAccess() { return async (req, res, next) =\u0026gt; { const tenantId = req.params.tenantId; const userId = req.user.id; // 사용자-테넌트 소유권 확인 const ownership = await this.getTenantOwnership(tenantId, userId); if (!ownership) { return res.status(403).json({ error: \u0026#39;Access denied\u0026#39; }); } next(); }; } DB 계정이 데이터를 막고, 미들웨어가 요청을 막는다. 논리적 분리의 약한 격리를 두 겹으로 메운 셈이다.\n어디서 실패했느냐에 따라 치울 것이 달라진다 테넌트 하나를 만드는 과정은 다음 단계를 거친다.\n사용자 인증 및 권한 확인\nDocker 컨테이너 생성 및 포트 할당\n데이터베이스 스키마 생성\n메타데이터 저장 및 소유권 매핑\n상태 확인 및 롤백 처리\n각 단계가 실패할 수 있고, 실패한 시점마다 되돌릴 대상이 다르다. 예를 들어 컨테이너는 만들어졌는데 스키마 생성이 실패했다면, 이미 떠 있는 컨테이너를 정리해야 한다. 쉘 스크립트로 이 분기를 짜기 어려웠던 이유가 여기 있다.\nflowchart LR A[\u0026#34;인증·권한 확인\u0026#34;] --\u0026gt; B[\u0026#34;컨테이너 생성·포트 할당\u0026#34;] B --\u0026gt; C[\u0026#34;DB 스키마 생성\u0026#34;] C --\u0026gt; D[\u0026#34;메타데이터·소유권 저장\u0026#34;] D --\u0026gt; E[\u0026#34;상태 확인\u0026#34;] C -- 실패 --\u0026gt; R[\u0026#34;생성된 컨테이너 정리\u0026#34;]\n실제 createTenant는 입력 검증, 테넌트 레코드 생성, 컨테이너 생성, NPM(nginx-proxy-manager) 프록시 설정, 접근성 확인, 최종 업데이트의 6단계로 짜여 있다. 단계가 메서드 하나씩으로 나뉘어 있어서, 어느 await에서 예외가 났는지가 곧 롤백 범위가 된다.\n30초에서 1분을 기다리게 하는 법 테넌트 생성은 오래 걸린다. 컨테이너 이미지 다운로드, DB 스키마 생성, 네트워크 설정이 순서대로 진행되면서 30초에서 1분 정도 걸린다. 그동안 사용자가 빈 화면만 보면 실패한 줄 안다.\n그래서 프로비저닝 상태 추적 시스템을 따로 만들었다. 서버는 현재 단계와 진행률을 기록하고, 프론트엔드는 WebSocket이나 폴링으로 그 상태를 받아 화면에 보여준다.\nNode.js와 Google OAuth를 고른 이유 프로비저닝은 기다리는 시간이 대부분인 작업이다. Docker Engine API 응답을 기다리고, DB 쿼리를 기다린다. Node.js의 이벤트 루프 기반 비동기 처리는 이런 작업을 여러 개 동시에 걸어 두기에 맞았다. 특히 컨테이너 생성과 스키마 생성처럼 서로 의존하지 않는 작업은 Promise.all()로 동시에 실행해 전체 프로비저닝 시간을 줄였다.\n인증은 자체 구현 대신 Google OAuth를 썼다. 자체 인증 시스템을 만들면 제어권은 더 갖지만, 비밀번호 저장과 세션 관리에서 생길 보안 취약점과 개발 복잡도를 함께 떠안는다. 테넌트 격리가 본업인 시스템에서 인증까지 직접 짤 이유는 없다고 봤다.\n// Google OAuth 기반 사용자 정보 추출 passport.use(new GoogleStrategy({ clientID: process.env.GOOGLE_CLIENT_ID, clientSecret: process.env.GOOGLE_CLIENT_SECRET, callbackURL: \u0026#34;/auth/google/callback\u0026#34; }, async (accessToken, refreshToken, profile, done) =\u0026gt; { const user = { id: profile.id, // 테넌트 소유권 매핑에 쓰는 사용자 ID email: profile.emails[0].value, name: profile.displayName }; return done(null, user); })); 아직 남은 문제 지금 시스템은 단일 서버에서 돈다. 실제 서비스로 키우려면 풀어야 할 것이 남아 있다.\n컨테이너 오케스트레이션. 서버가 한 대를 넘으면 포트 할당을 한 호스트 안에서 판단할 수 없다. Docker Swarm이나 Kubernetes로 넘어가야 한다.\n데이터베이스 샤딩. 논리적 분리는 인스턴스 하나에 테넌트를 모두 싣는다. 테넌트 수가 늘면 그 인스턴스가 병목이 된다.\n로드 밸런싱. nginx-proxy-manager로 트래픽을 나누는 구성이 필요하다.\n모니터링. 테넌트별 리소스 사용량, 성능 지표, 오류 현황을 실시간으로 보는 체계가 없다. 다음 단계의 핵심이다.\n스키마 이름은 쿼리에 문자열로 들어간다. CREATE SCHEMA와 GRANT의 식별자는 파라미터 바인딩을 할 수 없어서 템플릿 문자열로 넣었다. schemaName과 tenantId가 사용자 입력에서 오지 않도록 앞단 검증에 기대고 있다.\n폴링은 404를 완료로 간주한다. 상태가 정리돼서 404가 났는지, 애초에 잘못된 ID라서 404가 났는지 프론트엔드는 구분하지 못한다.\n정리 쉘 스크립트의 한계는 실패를 구분하지 못한다는 데 있었다. 롤백, 포트 충돌, 동시성은 모두 실패 원인을 알아야 풀린다.\nDockerode를 고른 이유는 Promise와 예외 두 가지다. 단계를 await로 잇고, 실패는 예외로 받아 단계별로 처리한다.\n격리는 리소스와 맞바꾼다. 물리적 분리 대신 스키마 분리를 택했고, 전용 DB 계정과 소유권 미들웨어로 약한 격리를 보강했다.\n롤백 범위는 실패한 단계가 정한다. 프로비저닝을 단계별 메서드로 쪼개 두면 어디까지 치울지가 분명해진다.\n오래 걸리는 작업은 진행 상태를 보여줘야 한다. 30초에서 1분이면 사용자는 기다리지 않는다.\n멀티 테넌트 아키텍처는 개념으로는 한 문단이면 설명되지만, 막상 구현하면 포트 하나, 계정 하나, 실패 분기 하나마다 결정이 필요했다. 그중 몇 개는 벌써 지웠다.\n이론과 실무의 거리는 그 지운 코드만큼이다.\n참고자료 apocas/dockerode — Node.js용 Docker Remote API 클라이언트\nDocker Engine API — 컨테이너 생성 옵션(HostConfig, PortBindings 등)의 원본 명세\nMySQL CREATE USER Statement — 테넌트 전용 계정 생성 문법\npassport-google-oauth20 — Passport의 Google OAuth 2.0 전략\n","permalink":"https://jaylab.link/multi-tenant-provisioning-system/","summary":"docker run을 감싼 쉘로는 실패 원인도 롤백도 다룰 수 없어 Dockerode와 스키마 분리로 테넌트 생성을 자동화한 과정","title":"쉘 스크립트 대신 Dockerode를 고른 이유: 멀티 테넌트 프로비저닝의 롤백과 동시성"},{"content":"\n새 테넌트가 생기면 할 일이 두 가지였다. 서버를 만들고, 그 서버에 도메인을 연결한다. 이전 버전까지는 둘 다 손으로 했다. 한두 번이면 괜찮지만, 테넌트가 늘어나면 번거로워지고 번거로운 일에는 실수가 끼어든다.\n지금 여러 테넌트를 한곳에서 관리하는 시스템을 만들고 있다. 컨테이너를 띄우는 쪽은 앞선 글에서 Node.js와 Dockerode로 자동화했다. 이 글에서는 나머지 절반, 띄운 컨테이너에 (서브)도메인을 붙이는 일을 Nginx Proxy Manager API로 자동화한 과정을 정리한다.\n컨테이너만 뜨고 주소가 없으면 반쪽이다 앞선 작업에서 클라이언트가 특정 API를 호출하면 Dockerode가 컨테이너를 만들고 지우도록 해 두었다. 그런데 컨테이너가 떠도 사용자가 들어올 주소가 없으면 프로비저닝은 끝난 게 아니다. 각 테넌트가 자기 도메인으로 접속하려면 누군가 리버스 프록시에 \u0026ldquo;이 도메인은 저 컨테이너로\u0026quot;라는 규칙을 추가해야 한다. 그 누군가가 지금까지는 나였다.\n그래서 컨테이너를 만드는 흐름 끝에 Nginx Proxy Manager API 호출을 붙여, Proxy Host까지 자동으로 만들게 했다.\nProxy Host는 도메인이 아니라 라우팅 규칙이다 Proxy Host는 한마디로 \u0026ldquo;이 주소로 들어오는 요청은 저 서버로 보내라\u0026quot;는 규칙이다. 이름 때문에 도메인 자체로 착각하기 쉬운데, 도메인은 규칙의 입력일 뿐이고 Proxy Host는 리버스 프록시 설정 전체를 가리킨다.\n테넌트 단위로 보면 이렇다.\ntenant1.yourdomain.com → tenant1 컨테이너로 트래픽을 전달하는 규칙\ntenant2.yourdomain.com → tenant2 컨테이너로 트래픽을 전달하는 규칙\n테넌트 하나가 늘면 규칙 하나가 는다. 반복되는 일이니 코드로 넘기기 좋다.\nAPI로 보내면 설정 파일은 Nginx Proxy Manager가 쓴다 아래 정보를 담아 API로 보내면 Nginx Proxy Manager가 설정 파일을 알아서 만든다.\nRequest Payload:\n{ \u0026#34;domain_names\u0026#34;: [\u0026#34;app.yourdomain.com\u0026#34;, \u0026#34;www.app.yourdomain.com\u0026#34;], \u0026#34;forward_host\u0026#34;: \u0026#34;192.168.1.100\u0026#34;, \u0026#34;forward_port\u0026#34;: 3000, \u0026#34;forward_scheme\u0026#34;: \u0026#34;http\u0026#34;, \u0026#34;certificate_id\u0026#34;: 1, \u0026#34;ssl_forced\u0026#34;: true } 그 결과 생성되는 설정 파일:\n# /data/nginx/proxy_host/1.conf server { listen 80; server_name app.yourdomain.com www.app.yourdomain.com; location / { proxy_pass http://192.168.1.100:3000; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; # ... 기타 프록시 설정 } } domain_names가 server_name으로, forward_scheme·forward_host·forward_port가 proxy_pass 한 줄로 들어간다. 내가 다루는 건 JSON 필드 여섯 개이고, nginx 문법은 Nginx Proxy Manager가 책임진다. 설정 파일을 직접 만지지 않으니 테넌트가 늘어도 conf 파일을 잘못 고쳐서 다른 테넌트까지 깨뜨릴 일이 적다.\nAPI 한 번에 서버와 도메인이 같이 붙었다 결과는 성공이다. 이제 해당 API를 호출하면 서버 생성과 도메인 연결이 순식간에 끝난다. 생각만 해보던 기능이 실제로 만들어졌다.\n정리 컨테이너 생성(Dockerode)과 도메인 연결(Nginx Proxy Manager)을 API 호출 하나로 묶어, 테넌트 프로비저닝을 손 없이 끝내게 했다.\nProxy Host는 도메인이 아니라 \u0026ldquo;이 도메인은 저 서버로\u0026quot;라는 라우팅 규칙이고, JSON 여섯 필드면 만들어진다.\n손으로 하던 일 두 개가 API 하나가 됐다. 테넌트가 몇 명이 되든 할 일은 같다.\n","permalink":"https://jaylab.link/npm-proxy-host-dynamic-creating/","summary":"테넌트가 생길 때마다 손으로 하던 도메인 연결을 Nginx Proxy Manager API로 Proxy Host를 만들어 자동화했다.","title":"Nginx Proxy Manager API로 Proxy Host 자동으로 붙이기"},{"content":"프록시 호스트에 webserver를 적었는데 요청이 webserver 컨테이너에 닿지 않았다. 컨테이너는 떠 있었고, docker ps로 보면 Up 15 minutes 상태였다. 개발 중인 시스템에서 Nginx Proxy Manager(이하 NPM)가 외부 요청을 내부 웹서버로 넘기는 구조였는데, 그 연결 하나가 끊겨 있었다.\n컨테이너 간 트래픽 전달은 Docker에서 가장 기본적인 영역이라 오히려 늦게 의심하게 된다. 컨테이너가 살아 있어도 같은 네트워크에 있지 않으면 서로의 이름을 모른다. 이 글은 두 컨테이너가 어느 네트워크에 붙어 있는지 확인한 데서 시작해, 이름으로 찾지 못한 이유와 네트워크를 다시 연결해 해결한 방법까지 정리한다.\n두 컨테이너는 서로 다른 네트워크에 있었다 NPM은 리버스 프록시다. 어느 컨테이너에 트래픽을 넘길 수 있는지는 NPM과 대상 컨테이너가 어떤 네트워크에 할당되어 있는지로 정해진다. 두 컨테이너의 네트워크부터 확인했다.\ndocker ps | grep webserver ╰─❯ ae0f096ac981 httpd \u0026#34;httpd-foreground\u0026#34; 15 minutes ago Up 15 minutes 0.0.0.0:8888-\u0026gt;80/tcp webserver docker network ls ╰─❯ NETWORK ID NAME DRIVER SCOPE ba99d5799e32 3vi-waf-npm_network bridge local a2053532b62f bridge bridge local 1db1d050373f docker_default bridge local 8af1a1b95e98 host host local b383f6e17a69 none null local bbf73a74ebdb npm_network bridge local docker inspect webserver | grep -A 10 \u0026#34;Networks\u0026#34; ╰─❯ \u0026#34;Networks\u0026#34;: { \u0026#34;bridge\u0026#34;: { \u0026#34;IPAMConfig\u0026#34;: null, \u0026#34;Links\u0026#34;: null, \u0026#34;Aliases\u0026#34;: null, \u0026#34;NetworkID\u0026#34;: \u0026#34;a20535.....\u0026#34;, \u0026#34;EndpointID\u0026#34;: \u0026#34;c587d6.....\u0026#34;, \u0026#34;Gateway\u0026#34;: \u0026#34;172.17.0.1\u0026#34;, \u0026#34;IPAddress\u0026#34;: \u0026#34;172.17.0.3\u0026#34;, \u0026#34;IPPrefixLen\u0026#34;: 16, \u0026#34;IPv6Gateway\u0026#34;: \u0026#34;\u0026#34;, docker inspect npm_core | grep -A 10 \u0026#34;Networks\u0026#34; ╰─❯ \u0026#34;Networks\u0026#34;: { \u0026#34;npm_network\u0026#34;: { \u0026#34;IPAMConfig\u0026#34;: null, \u0026#34;Links\u0026#34;: null, \u0026#34;Aliases\u0026#34;: [ \u0026#34;npm_core\u0026#34;, \u0026#34;npm\u0026#34;, \u0026#34;4d521a92b175\u0026#34; ], \u0026#34;NetworkID\u0026#34;: \u0026#34;bbf73a.....\u0026#34;, webserver는 기본 bridge 네트워크(172.17.0.3)에, NPM(npm_core)은 npm_network라는 별도 네트워크에 붙어 있었다. 두 출력의 차이는 Aliases에도 보인다. **npm_network 쪽은 컨테이너 이름이 Aliases로 등록되어 있고, 기본 bridge 쪽 webserver는 **null이다.\n이름으로 찾지 못한 이유는 네트워크 격리다 Docker는 네트워크 단위로 컨테이너를 격리한다. 같은 호스트에 있어도 서로 다른 bridge 네트워크에 속한 컨테이너끼리는 기본적으로 통신이 막혀 있다. 여기에 이름 해석 문제가 겹친다. 사용자 정의 네트워크(npm_network 같은)는 Docker의 내장 DNS가 컨테이너 이름과 Alias를 풀어주지만, 기본 bridge 네트워크는 컨테이너 이름 기반 이름 해석을 제공하지 않는다.\n그래서 NPM 프록시 설정에 webserver라는 이름을 적어도 NPM 입장에서는 풀 수 없는 이름이었다. 설정이 틀린 게 아니라, NPM이 bridge 네트워크의 webserver를 볼 수 없는 구조였다.\n호스트를 돌아가지 않고 같은 네트워크에 붙였다 길은 두 가지였다. webserver가 호스트에 열어 둔 8888 포트를 이용해 NPM이 host.docker.internal:8888로 호스트를 거쳐 들어가거나, webserver를 NPM과 같은 네트워크에 추가로 붙이는 것이다.\n호스트 경유는 컨테이너 밖으로 나갔다가 다시 들어오는 경로라 호스트 포트 매핑에 의존한다. 같은 네트워크에 붙이면 NPM이 컨테이너 이름과 컨테이너 내부 포트(80)로 바로 접근한다. 후자를 골랐다.\n# webserver를 NPM이 있는 npm_network에 추가로 연결한다 (기존 bridge 연결은 유지) docker network connect npm_network webserver docker network connect는 기존 연결을 끊지 않는다. 이 명령 이후 webserver는 bridge와 npm_network 두 네트워크에 동시에 연결된 상태가 됐다. npm_network 안에서 webserver라는 이름이 풀리기 시작했고, 프록시 설정이 트래픽을 webserver 컨테이너로 직접 라우팅할 수 있게 됐다.\n적용 결과: curl 한 번으로 확인했다 재구성한 네트워크에서 프록시를 통해 요청을 보냈다.\ncurl http://test.local # 결과: \u0026#34;It works!\u0026#34; It works!는 httpd 기본 페이지다. NPM이 test.local로 들어온 요청을 webserver:80으로 넘겼다는 뜻이다.\n상태 webserver 네트워크 NPM과의 직접 통신 프록시 호스트 설명 Forward Port 적용 전 bridge 불가능 host.docker.internal 필요 호스트를 거쳐서 통신 8888 적용 후 bridge + npm_network 가능 webserver (직접 접근) 컨테이너 간 직접 통신 (권장) 80 Forward Port가 8888에서 80으로 바뀐 점이 핵심이다. 호스트에 매핑된 포트가 아니라 컨테이너가 실제로 듣는 포트로 바로 간다.\n아직 남은 문제 docker network connect는 실행 중인 컨테이너에 대한 일회성 조치다. 컨테이너를 지우고 다시 만들면 연결이 사라진다. 재생성 후에도 유지하려면 docker run --network나 Compose 파일의 networks에 npm_network를 명시해야 한다.\n**webserver는 여전히 **0.0.0.0:8888-\u0026gt;80/tcp로 호스트에 열려 있다. 프록시를 거치지 않는 경로가 남아 있다는 뜻이다. NPM 앞단에서만 받으려면 포트 매핑을 걷어내는 것을 검토할 만하다.\n기본 bridge 연결도 그대로 남아 있다. 다른 이유로 필요한지 확인하지 않았다면, 불필요한 연결은 docker network disconnect bridge webserver로 정리할 수 있다.\n정리 컨테이너가 떠 있다는 것과 통신할 수 있다는 것은 다른 문제다. 먼저 docker inspect로 서로 어느 네트워크에 있는지 본다.\n기본 bridge 네트워크에서는 컨테이너 이름이 풀리지 않는다. 이름으로 부르려면 사용자 정의 네트워크에 함께 있어야 한다.\ndocker network connect로 기존 연결을 유지한 채 네트워크를 하나 더 붙일 수 있다. 프록시 호스트를 host.docker.internal:8888에서 webserver:80으로 바꿀 수 있었다.\n런타임에 붙인 연결은 컨테이너를 다시 만들면 사라진다. 정의 파일에 남겨야 끝난다.\n설정 한 줄 차이로 생긴 단절이었지만, 덕분에 컨테이너 네트워크가 어떻게 격리되고 어떻게 이름을 푸는지를 확인할 수 있었다. 운영 단계에서도 네트워크 연결 점검과 구조 변경은 반복해서 필요할 것이다. 프록시가 대상을 못 찾을 때는 프록시 설정보다 네트워크부터 본다.\n참고자료 Docker Docs: Networking overview — Docker 네트워크 드라이버와 컨테이너 간 통신의 기본 개념\nDocker Docs: Bridge network driver — 기본 bridge와 사용자 정의 bridge의 차이(이름 해석, 격리)\nDocker Docs: docker network connect — 실행 중인 컨테이너를 네트워크에 추가로 연결하는 명령\n","permalink":"https://jaylab.link/docker-network-proxy-traffic-resolve/","summary":"NPM과 webserver가 서로 다른 Docker 네트워크에 있어 프록시가 끊긴 원인을 찾고, 네트워크 연결로 컨테이너 간 직접 통신을 복구한 기록","title":"프록시가 webserver를 찾지 못한 이유: Docker 네트워크 격리와 컨테이너명 DNS"},{"content":"타 부서에서 지원 요청이 왔다. 제안서 일부 파트를 맡아 달라는 것이었다. 내 몫은 아키텍처와 구축 전략으로, 구상부터 장표 제작까지 전부였다. 며칠을 야근했고 밤도 새웠다.\n제안서는 코드와 다르다. 틀려도 테스트가 잡아주지 않고, 읽는 사람은 그림 한 장으로 판단한다. 붙잡은 건 두 가지뿐이었다. 정보가 정확해야 하고, 설계가 효율적이어야 한다. 이 글은 그 며칠의 기록이다.\n모호한 문장을 다시 쓰는 데 시간이 가장 많이 들었다 받은 자료에는 모호한 내용이 많았다.\n장표를 그리기 전에 그 내용부터 다시 정리했다. 요구사항을 기술적으로 분석한 뒤 구조로 옮기는 순서였다. 아키텍처 그림은 요구사항을 구조화한 결과이지, 그 반대가 아니다. 평소 개발할 때보다 요구사항을 훨씬 깊게 뜯어본 셈이다.\n기존 영역은 건드리지 않고 옆에 붙였다 인프라 아키텍처 장표의 구성:\n사용자 요청은 CloudFront와 S3가 받는다.\n새로 만드는 서비스는 VPC 안 ALB 뒤의 ECS Fargate에 둔다. SSO Service와 Spring API가 여기에 올라간다.\n주기 작업은 EventBridge가 Batch를 깨우는 방식\n이미 운영 중인 영역(Pre-existing IoTOps Area)의 SSO Server, API Servers, Database는 점선으로 따로 묶고, 새 서비스가 그쪽과 통신만 하도록 그렸다.\n고객 IDC의 서버와는 Site-to-Site VPN으로 연결한다.\n새로 만들 것과 이미 있는 것의 경계가 그림에서 먼저 보여야 한다. 구성도에서 기존 영역을 점선으로 따로 묶은 이유.\n","permalink":"https://jaylab.link/architecture-strategy-proposal/","summary":"타 부서 제안서의 아키텍처·구축 전략 파트를 며칠 만에 맡아, 모호한 요구사항을 AWS 구조도로 정리한 기록","title":"타 부서 제안서의 아키텍처 파트를 맡았다"},{"content":"sonar-scanner를 처음 돌렸을 때 돌아온 건 분석 결과가 아니라 이 에러였다.\n08:45:50.100 ERROR Error during SonarScanner CLI execution org.sonar.java.AnalysisException: Your project contains .java files, please provide compiled classes with sonar.java.binaries property, or exclude them from the analysis with sonar.exclusions property. demoapp 프로젝트에 SonarQube로 SAST(정적 애플리케이션 보안 테스트) 환경을 붙이면서 겪은 일을 정리한다. 처음에는 Mac에서 brew로 Scanner를 설치해 끝냈고, 그다음 brew가 없는 환경에서도 같은 구성이 돌도록 서버와 Scanner를 모두 Docker로 옮겼다.\n서버는 Docker로, Scanner는 brew로 시작했다 SonarQube는 두 조각으로 나뉜다. SonarQube는 코드 품질과 보안을 정적으로 분석하고 결과를 웹 UI로 보여주는 서버다. SonarScanner는 소스를 그 서버로 보내 분석을 시키는 커맨드라인 도구다.\n첫 구성에서 서버는 docker run으로 띄우고, Scanner만 brew로 설치했다.\n# SonarQube 서버를 Docker로 실행 docker run -d --name sonarqube \\ -p 9000:9000 \\ -e SONAR_ES_BOOTSTRAP_CHECKS_DISABLE=true \\ sonarqube:lts-community # Mac에서 SonarScanner 설치 brew install sonar-scanner # 설치 확인 sonar-scanner --version 서버가 뜨면 웹 UI에서 프로젝트를 만들고 토큰을 받는다.\nSonarQube 웹 UI(http://localhost:9000)에 접속한다.\n로그인 후 상단 \u0026ldquo;Projects\u0026quot;에서 \u0026ldquo;Create Project\u0026quot;를 누른다.\n\u0026ldquo;Manually\u0026quot;를 선택한다.\n이름(예: demoapp)과 display name을 입력하고 \u0026ldquo;Set Up\u0026quot;을 누른다.\n\u0026ldquo;Locally\u0026quot;를 선택한다.\n\u0026ldquo;Generate\u0026rdquo; 버튼으로 토큰을 만들고 복사해 둔다.\n프로젝트 Key와 토큰을 기억해 둔다. (예: 프로젝트 key demoapp, 토큰 sqp_6f...)\n프로젝트 루트에 sonar-project.properties를 만들고 Scanner를 실행한다.\nsonar.projectKey=demoapp sonar.projectName=DemoApp sonar.host.url=http://localhost:9000 sonar.login=\u0026lt;여기에_아까_복사한_토큰_붙여넣기\u0026gt; # Gradle 프로젝트일 경우 sonar.java.binaries=build/classes # Maven 프로젝트일 경우 # sonar.java.binaries=target/classes # 프로젝트 루트 폴더로 이동 cd /Users/jin/workspace/DevSecOps/demoapp # 정적 분석 실행 sonar-scanner Java 분석은 소스가 아니라 .class를 요구한다 맨 위의 에러가 여기서 나왔다. 메시지를 풀면 \u0026ldquo;자바 파일은 있는데 컴파일된 클래스 파일(.class)을 못 찾겠다\u0026quot;는 뜻이다. SonarQube의 Java 정적 분석은 소스만으로 끝나지 않고 컴파일된 클래스 파일을 반드시 필요로 한다. sonar.java.binaries에 Gradle이면 build/classes, Maven이면 target/classes처럼 .class 경로를 적어 줘야 하고, 그 경로에 실제로 파일이 있어야 한다.\n해결은 빌드를 먼저 하는 것이었다.\n# 프로젝트 루트 경로에서 빌드해 build/classes를 만든다 ./gradlew build # 그다음 다시 분석 sonar-scanner brew가 없는 곳에서도 같은 구성이 돌게 하고 싶었다 여기까지로 Mac에서는 분석이 돌았지만 뭔가 아쉬웠다. 이 구성은 brew가 있다는 전제 위에 서 있다. brew를 쓸 수 없는 서버나 다른 개발자의 머신, 좀 더 보편적인 환경에서도 그대로 쓸 수 있는 방법이 필요했다.\n그래서 서버와 Scanner를 모두 컨테이너로 옮겼다. 서버는 docker compose로 띄우고, Scanner는 sonarsource/sonar-scanner-cli 이미지를 일회성 컨테이너로 돌린다.\ndocker compose로 서버 띄우기 docker run 한 줄과 달리 compose 파일에는 volume을 붙여 분석 데이터와 플러그인이 컨테이너 재생성 후에도 남도록 했다.\nversion: \u0026#34;3.8\u0026#34; services: sonarqube: image: sonarqube:lts-community container_name: sonarqube ports: - \u0026#34;9000:9000\u0026#34; environment: # Elasticsearch 부트스트랩 체크를 끈다 (로컬 실습용) - SONAR_ES_BOOTSTRAP_CHECKS_DISABLE=true volumes: # 분석 데이터 보존 - sonarqube_data:/opt/sonarqube/data # 플러그인 보존 - sonarqube_extensions:/opt/sonarqube/extensions volumes: sonarqube_data: sonarqube_extensions: # docker-compose 파일이 있는 디렉토리에서 실행 docker-compose up -d sonarqube SONAR_ES_BOOTSTRAP_CHECKS_DISABLE=true는 SonarQube 안에 들어 있는 Elasticsearch의 부트스트랩 체크를 건너뛰게 한다. 로컬에서 커널 설정을 따로 만지지 않고 띄우기 위한 옵션이라 운영 환경에서는 다시 볼 필요가 있다.\nScanner를 컨테이너로 돌리기 설정값은 devsecops.env로 빼고, 스크립트가 그 값을 읽어 Scanner 컨테이너에 넘긴다.\nSONAR_PROJECT_KEY=demoapp SONAR_LOGIN={토큰} SONAR_BINARIES=build/classes SONAR_HOST_URL=http://host.docker.internal:9000 SONAR_SOURCES=. PROJ_DIR=/Users/jin/IdeaProjects/workspace/DevSecOps/demoapp Scanner가 컨테이너 안에서 돌기 때문에 서버 주소를 호스트를 가리키는 host.docker.internal로 바꿔야 한다. 컨테이너 안의 localhost는 컨테이너 자신이고, 호스트의 9000번 포트에 떠 있는 SonarQube에 닿으려면 호스트를 가리키는 이름이 필요하다.\n#!/bin/bash # 같은 경로에 있는 devsecops.env 파일을 읽어온다 source ./devsecops.env # 프로젝트 디렉토리를 /usr/src에 마운트하고 Scanner를 일회성으로 실행 docker run --rm \\ -v \u0026#34;$PROJ_DIR\u0026#34;:/usr/src \\ -w /usr/src \\ sonarsource/sonar-scanner-cli \\ -Dsonar.projectKey=\u0026#34;$SONAR_PROJECT_KEY\u0026#34; \\ -Dsonar.sources=\u0026#34;$SONAR_SOURCES\u0026#34; \\ -Dsonar.host.url=\u0026#34;$SONAR_HOST_URL\u0026#34; \\ -Dsonar.login=\u0026#34;$SONAR_LOGIN\u0026#34; \\ -Dsonar.java.binaries=\u0026#34;$SONAR_BINARIES\u0026#34; # 실행 권한 부여 chmod +x sonar-docker.sh # 실행 ./sonar-docker.sh sonar.java.binaries는 여기서도 그대로 필요하다. Scanner를 컨테이너로 옮겨도 .class를 요구하는 건 달라지지 않으니, 스크립트를 돌리기 전에 호스트에서 빌드가 끝나 있어야 한다.\nSonarQube SAST 분석은 이렇게 성공했고, 이제 이 구성에 필요한 건 Docker 하나뿐이다.\n정리 brew 버전은 금방 끝났지만 Mac 밖으로 가져갈 수 없었다. Docker 버전은 파일 세 개(docker-compose.yml, devsecops.env, sonar-docker.sh)로 같은 분석을 어디서든 재현한다. 준비할 파일은 늘었지만, 옮겨 갈 수 있다는 점이 그 값을 한다.\n","permalink":"https://jaylab.link/brew-sonarqube-install-and-sast/","summary":"SonarQube Java 분석의 .class 요구 에러를 빌드로 풀고, brew 없이도 돌도록 서버와 Scanner를 Docker로 옮긴 기록","title":"SonarQube를 brew에서 Docker로 옮겼다"},{"content":"A VM에서 B VM으로 처음 보낸 Ansible 명령은 ping이었다. 돌아온 답은 pong이 아니라 UNREACHABLE!이었다.\n앞 글에서 Multipass로 만든 VM 두 대 위에서 이어가는 실습이다. A(dsoserver)를 컨트롤 노드로, B(dsoserver2)를 관리 대상 노드로 둔다. 이 글에서는 인벤토리 파일 한 줄로 B를 등록하고 ping과 ad-hoc 명령을 거쳐 첫 플레이북으로 B에 파일을 만드는 데까지 정리한다.\n실습 환경: 같은 네트워크의 Ubuntu VM 두 대 Name State IPv4 Image dsoserver Running 192.168.64.10 Ubuntu 24.04 LTS dsoserver2 Running 192.168.64.11 Ubuntu 24.04 LTS 인벤토리 한 줄이 곧 접속 정보다 Ansible은 관리 대상에 에이전트를 깔지 않고, SSH로 들어가 모듈을 실행한 뒤 나온다. 그래서 Ansible이 호스트에 대해 알아야 하는 것은 어디로, 누구로 접속하느냐뿐이다. 그 정보를 적는 파일이 인벤토리다.\n# VM A 접속 multipass shell dsoserver # ansible_hosts 라는 파일을 \u0026#34;인벤토리\u0026#34;로 사용하여 관리할 호스트(서버) 지정하기 echo \u0026#34;bvm ansible_host=192.168.64.11 ansible_user=ubuntu\u0026#34; \u0026gt; ~/ansible_hosts 항목 의미 bvm B VM의 별칭(호스트명). 이후 명령에서 이 이름으로 부른다 ansible_host B VM의 실제 IP 주소 ansible_user B VM에 SSH로 접속할 때 쓰는 사용자명(예: ubuntu, multipass 등) 첫 ping이 publickey에서 막힌 이유 인벤토리만 만들고 ansible -i ~/ansible_hosts bvm -m ping을 치면 B에 닿지 못한다. 에러는 Permission denied (publickey)다. Ansible은 기본 키로 SSH 접속을 시도하는데, B에 등록해 둔 키는 id_ansible이었기 때문이다. 키 위치를 명시하면 바로 통과한다.\n# Ansible에 명시적으로 SSH 비밀키 위치를 지정하는 방법 ansible -i ~/ansible_hosts bvm -m ping --private-key=~/.ssh/{비밀키 이름} # 실제 입력한 명령어: ansible -i ~/ansible_hosts bvm -m ping --private-key=~/.ssh/id_ansible 성공 출력 위에 붙은 [WARNING]은 B의 Python 인터프리터(/usr/bin/python3.12)를 자동으로 찾아 썼다는 알림이다. ping 결과에는 영향이 없다. 여기서 -m ping은 ICMP ping이 아니라 SSH로 들어가 Python 모듈을 실행할 수 있는지 확인하는 Ansible 모듈이라, 인터프리터 이야기가 같이 나온다.\n매번 --private-key를 붙이는 대신 인벤토리에 ansible_ssh_private_key_file로 키를 적어 둘 수 있다.\nvi ~/ansible_hosts # 아래 내용으로 수정(비밀키 지정) bvm ansible_host=192.168.64.11 ansible_user=ubuntu ansible_ssh_private_key_file=~/.ssh/{비밀키 이름} 이제는 ansible -i ~/ansible_hosts bvm -m ping만 쳐도 핑테스트가 통과한다. 접속 방법은 명령어가 아니라 인벤토리에 두는 게 맞다. 호스트가 늘어날수록 차이가 커진다.\nad-hoc 명령: A에서 쳤는데 B의 결과가 나온다 -a로 명령을 넘기면 B에서 실행한 결과가 A 터미널에 찍힌다. 모듈을 지정하지 않으면 기본 command 모듈이 쓰인다.\nansible -i ~/ansible_hosts bvm -a \u0026#34;date\u0026#34; # bvm | CHANGED | rc=0 \u0026gt;\u0026gt; # Sun Jun 1 23:18:40 KST 2025 ansible -i ~/ansible_hosts bvm -a \u0026#34;ls -a\u0026#34; # bvm | CHANGED | rc=0 \u0026gt;\u0026gt; # . # .. # .ansible # .bash_history # .bash_logout # .bashrc # .cache # .profile # .ssh # .viminfo # test 첫 플레이북: B VM에 test.txt 만들기 ad-hoc 명령이 한 줄짜리 원격 실행이라면, 플레이북은 \u0026ldquo;어떤 호스트가 어떤 상태여야 하는가\u0026quot;를 YAML로 적은 파일이다. A VM에서 hello.yml을 만든다.\n# A VM에서 vi hello.yml --- - name: 테스트 파일 생성 실습 hosts: bvm tasks: - name: test.txt 파일 만들기 copy: content: \u0026#34;안녕하세요, Ansible 실습입니다!\u0026#34; dest: /home/ubuntu/test.txt ansible-playbook -i ~/ansible_hosts hello.yml # B VM에 test.txt 파일 생성 완료 copy 모듈은 content를 그대로 dest에 쓴다. 생각보다 금방 끝났다. 같은 플레이북을 한 번 더 돌리면 changed가 아니라 ok로 나오는지, 이건 다음에 다시 봐야겠다.\n정리 B에 붙기 위해 필요한 건 인벤토리 한 줄이었고, 첫 실패도 그 한 줄에 키가 빠져서였다.\n","permalink":"https://jaylab.link/ansible-simple-playbook-practice/","summary":"첫 Ansible ping이 publickey 오류로 막혔고, 인벤토리에 키를 지정해 풀었다. 이어서 ad-hoc 명령과 첫 플레이북까지 돌렸다.","title":"Ansible 첫 ping은 publickey에서 막혔다"},{"content":"Ansible을 연습하려면 서버가 최소 두 대 필요하다. 명령을 내리는 쪽과 명령을 받는 쪽이다. 클라우드에 인스턴스를 두 개 띄울 수도 있지만, 실습 단계에서는 맥북 안에서 끝내는 편이 부담이 적다. 그래서 Multipass로 Ubuntu VM 두 대를 만들고, 한 대에서 다른 한 대를 Ansible로 제어하는 구성을 잡았다.\n이 글에서는 VM 생성, 제어노드의 Ansible 설치, 제어노드에서 관리노드로 가는 SSH 키 설정까지를 정리한다.\n제어노드와 관리노드, 그리고 둘 사이의 SSH 키 Ansible은 서버를 두 역할로 나눈다. 제어노드(control node)는 Ansible이 설치되어 명령을 내리는 서버이고, 관리노드(managed node)는 제어노드가 관리하는 \u0026ldquo;대상 서버\u0026quot;다. 이번 구성은 이렇다.\n개발 PC(맥북)에서 Multipass로 VM 두 대를 만든다.\nA = 제어노드(dsoserver), B = 관리노드(dsoserver2).\nA 안에서 B를 Ansible로 제어한다.\ngraph LR Mac[\u0026#34;맥북 (Multipass)\u0026#34;] --\u0026gt;|multipass shell| A[\u0026#34;A: dsoserver\u0026lt;br\u0026gt;제어노드 + Ansible\u0026#34;] A --\u0026gt;|ssh -i ~/.ssh/id_ansible| B[\u0026#34;B: dsoserver2\u0026lt;br\u0026gt;관리노드\u0026#34;] A가 B에 들어가는 방식은 SSH 키다. ssh-keygen으로 키를 만들면 두 파일이 생긴다. {키이름}.pub은 공개키로 일종의 ID이고, 확장자 없는 {키이름}은 비밀키로 비밀번호에 해당한다. A에서 키 쌍을 만든 뒤, B의 ~/.ssh/authorized_keys에 A의 공개키를 추가하면 B는 \u0026ldquo;이 ID로 들어오는 접속은 허용한다\u0026quot;는 상태가 된다. 그다음부터 A에서 ssh -i {비밀키 경로} ubuntu@{B 서버 IP}로 B에 접속할 수 있다.\nMultipass로 VM 두 대 띄우기 brew install --cask multipass # 버전 확인 multipass version multipass help # A노드(제어노드), B노드(관리노드 = 관리되는 노드) 생성 multipass launch --name dsoserver --cpus 2 --memory 2G --disk 10G multipass launch --name dsoserver2 --cpus 2 --memory 2G --disk 10G # 서버 목록 확인 multipass list 두 VM 모두 CPU 2개, 메모리 2G, 디스크 10G로 잡았다. multipass list에서 각 VM의 IP를 확인할 수 있다.\n맥북에서 A로 SSH 접속하기, 그리고 굳이 필요했나 처음에는 맥북에서 A로도 SSH로 들어가려고 했다.\n# 제어노드 구성 multipass launch --name dsoserver --cpus 2 --memory 2G --disk 10G multipass list # dsoserver 가상서버의 IP 확인 ssh ubuntu@{위 명령어로 확인한 제어노드 서버 IP} 그런데 바로 들어가지지 않는다. 아래 둘 중 하나를 만난다.\nThe authenticity of host \u0026#39;192.168.64.10 (192.168.64.10)\u0026#39; can\u0026#39;t be established. ED25519 key fingerprint is SHA256:Ov7nyVhd50LlAVxCJs5WYmzf4TzNt3Rl+JPr7NBABqc. This key is not known by any other names. Are you sure you want to continue connecting (yes/no/[fingerprint])? Permission denied (publickey). Multipass VM은 처음에는 SSH로 접속할 수 없고, 내 Mac의 공개키도 모른다. Mac에서 공개키를 만들어 VM에 넣어줘야 한다.\n# 1. 내 Mac에서 SSH 공개키 확인 ls ~/.ssh/id_dsorsa.pub # 2. 없으면 생성 ssh-keygen -t rsa -b 4096 -f ~/.ssh/id_dsorsa # 3-1. 생성된 공개키 읽어서 VM에 공개키 저장 cat ~/.ssh/id_dsorsa.pub | multipass exec dsoserver -- bash -c \u0026#34;mkdir -p ~/.ssh \u0026amp;\u0026amp; cat \u0026gt;\u0026gt; ~/.ssh/authorized_keys \u0026amp;\u0026amp; chmod 600 ~/.ssh/authorized_keys \u0026amp;\u0026amp; chmod 700 ~/.ssh\u0026#34; # 3-2. 제어노드의 공개키를 관리노드에 저장방법 # 위 명령어는 multipass가 최초에는 ssh로 접속불가능 하기때문에 사용할 수 밖에 없는 명령어. # 고객사를 대상으로하는 실무에서는 user name과 password를 통해 접속할 수 있을 것이기 때문에 # user명과, pw를 알고있다면 아래 명령어로 더 쉽고 안전하게 \u0026#34;제어 노드\u0026#34;에 있는 ssh 공개키를 \u0026#34;관리 노드\u0026#34;로 # 복사할 수 있다.(+권한도 자동 세팅, 여러 번해도 ~/.ssh/authorized_keys에는 하나만 추가됨) ssh-copy-id -i ~/.ssh/id_dsorsa.pub ubuntu@{제어노드 IP} # -i 옵션으로 키를 통해 ssh 접속 ssh -i ~/.ssh/id_dsorsa ubuntu@{제어노드 IP} 키 이름이 표준 이름(id_rsa, id_ed25519 등 기본값)이 아니면 -i 옵션이 항상 필요하다. id_dsorsa처럼 이름을 따로 붙였다면 접속할 때마다 비밀키 경로를 지정해야 한다.\nmultipass exec로 공개키를 밀어 넣는 방식은 Multipass라서 쓰는 우회로다. 실제 고객사 서버처럼 사용자명과 비밀번호로 접속할 수 있는 환경이라면 ssh-copy-id가 낫다. 권한 설정까지 알아서 해준다. 여러 번 실행해도 authorized_keys에는 한 줄만 들어간다.\n다 해놓고 보니 이 과정은 굳이 필요 없었다. 맥북에서 A로 들어갈 때는 multipass shell {A노드명}이면 된다. SSH 키 설정은 A에서 B로 접속할 때만 하면 되는 작업이다.\nA(제어노드)에 Ansible 설치하기 A에 들어가서 pipx로 Ansible을 설치한다.\n# A노드 접속한 상태 # pipx --version # pipx 유무 확인 sudo apt install pipx # 없으면 설치 pipx install --include-deps ansible # Ansible 설치 pipx ensurepath # /home/ubuntu/.local/bin에 ansible path 업데이트(cli로 ansible을 사용하기 위함) source ~/.bashrc # path 변경사항 적용 pipx ensurepath를 빼먹으면 설치는 됐는데 ansible 명령을 못 찾는다. ~/.local/bin을 PATH에 넣고 source ~/.bashrc로 바로 반영한다.\nA에서 B로 SSH 키 열기 이제 진짜 필요한 작업이다. A에서 Ansible 전용 키를 만들고, 공개키를 B의 authorized_keys에 넣는다.\n# A노드 접속한 상태 # # 키 생성 ssh-keygen -t rsa -b 4096 -f ~/.ssh/id_ansible cat ~/.ssh/id_ansible.pub # 공개키 확인 후 복사하기! exit # A노드 로그아웃 multipass shell {관리노드명} vi ~/.ssh/authorized_keys # 인증된 키 목록 편집 # 복사한 공개키 추가 B도 처음에는 SSH로 들어갈 수 없어서 multipass shell로 B에 직접 들어가 공개키를 붙여 넣었다. 다시 A로 돌아와 ssh -i ~/.ssh/id_ansible ubuntu@192.168.64.11을 실행하면 B의 셸이 뜬다.\n프롬프트가 ubuntu@dsoserver2로 바뀌면 A에서 B로 가는 길이 열린 것이다. Ansible이 관리노드에 붙을 준비는 여기까지다.\n정리 제어노드는 Ansible이 설치된 쪽, 관리노드는 제어받는 쪽이다. 둘을 잇는 건 SSH 키다.\nSSH 키 설정은 제어노드에서 관리노드 방향으로만 있으면 된다. 맥북에서 VM으로는 multipass shell이면 충분하다.\n키 이름을 기본값과 다르게 지었다면 ssh -i로 비밀키 경로를 매번 지정한다.\n실제 서버라면 multipass exec 대신 ssh-copy-id를 쓴다.\nVM 두 대를 띄우고 SSH를 여는 것까지는 생각보다 금방 끝났다. 맥북에서 A로 SSH를 뚫는 데 시간을 쓴 게 조금 아깝다.\n참고자료 Ansible Installation Guide — pipx로 Ansible을 설치하는 공식 방법 ","permalink":"https://jaylab.link/ansible-multipass-environment/","summary":"맥북에 Multipass VM 두 대를 띄워 제어노드에 Ansible을 설치하고, 관리노드로 SSH 키 접속을 여는 과정을 정리했다.","title":"Multipass VM 두 대로 Ansible 실습 환경 만들기"},{"content":"docker inspect를 치면 컨테이너 IP가 172.17.0.2라고 분명히 나온다. 그런데 Mac 터미널에서 그 주소로 접속하면 아무 응답이 없다. IP는 있는데 길이 없는 셈이다.\n결론부터 말하면 Mac에서 보이는 컨테이너 IP는 정보일 뿐, 접속할 수 있는 주소가 아니다. 이 글에서는 Docker Desktop(Mac)의 네트워크가 어떻게 생겼길래 이런 일이 생기는지, Linux와 무엇이 다른지 정리한다.\n컨테이너 IP는 VM 안에서만 유효하다 Docker 컨테이너는 Linux 커널 기능(namespace, cgroup) 위에서 돈다. macOS 커널에는 그 기능이 없어서, Docker Desktop은 Mac 위에 가벼운 Linux VM을 하나 띄우고 Docker Engine과 컨테이너를 전부 그 안에서 실행한다.\n컨테이너가 받는 172.17.x.x 주소는 이 VM 안의 docker0 브릿지 네트워크에서 나온다. 기본 bridge 네트워크의 대역이 172.17.0.0/16이고, 게이트웨이 172.17.0.1이 VM 안의 docker0이다. 사용자 정의 네트워크를 만들면 172.18.x.x처럼 다른 대역을 받는다.\ngraph LR subgraph Mac[\u0026#34;macOS 호스트\u0026#34;] T[\u0026#34;터미널: curl 172.17.0.2 (경로 없음)\u0026#34;] end subgraph VM[\u0026#34;Docker Desktop Linux VM\u0026#34;] B[\u0026#34;docker0 브릿지 172.17.0.1\u0026#34;] C[\u0026#34;컨테이너 172.17.0.2\u0026#34;] B --- C end T -. \u0026#34;라우팅 없음\u0026#34; .-\u0026gt; BMac의 라우팅 테이블에는 172.17.0.0/16으로 가는 경로가 없다. 브릿지는 VM 안에만 있고 Mac 쪽 네트워크 인터페이스와 연결돼 있지 않다. 그래서 **Mac에서 **172.17.x.x로 보낸 패킷은 갈 곳이 없다. docker inspect가 IP를 보여주는 건 VM 안에서 Docker Engine이 알려준 값을 그대로 옮긴 것일 뿐이다.\nLinux에서는 같은 명령이 된다 Linux에서는 Docker Engine이 호스트 커널에서 직접 돈다. docker0 브릿지가 호스트의 네트워크 인터페이스로 생기고, 호스트 라우팅 테이블에 172.17.0.0/16 경로가 붙는다. 덕분에 호스트에서도 컨테이너 IP로 바로 접속할 수 있다.\n구분 Linux Mac (Docker Desktop) Docker Engine 위치 호스트 커널 Linux VM 안 docker0 브릿지 호스트에 존재 VM 안에만 존재 호스트에서 컨테이너 IP 접속 가능 불가능 Mac에서 컨테이너에 닿으려면 IP 대신 포트를 게시해야 한다. docker run -p 8080:80 ...처럼 포트를 열면 Docker Desktop이 Mac의 localhost:8080으로 들어온 요청을 VM 안 컨테이너로 넘겨준다. 공식 문서도 Mac에서는 컨테이너로 직접 트래픽을 라우팅할 수 없으니 포트 게시를 쓰라고 안내한다.\n컨테이너 IP를 확인하는 두 가지 방법 접속은 못 해도 IP를 확인할 일은 있다. 컨테이너끼리 통신을 디버깅할 때가 그렇다.\n호스트에서는 docker inspect로 본다.\ndocker inspect {Container ID} 출력이 길어서 IP만 필요하면 포맷을 지정하는 편이 낫다.\n# 연결된 모든 네트워크의 IP만 출력 docker inspect -f \u0026#39;{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}\u0026#39; {Container ID} 컨테이너 안에서는 아래 명령으로 본다.\nip addr # 또는 hostname -I 두 결과가 같은 172.17.0.2를 가리킨다. 다만 slim·alpine 계열 이미지에는 ip 명령이 없거나 hostname -I 옵션을 지원하지 않을 수 있다.\n정리 Mac에서 컨테이너 IP로 접속이 안 되는 건 설정 실수가 아니라 구조 때문이다. 컨테이너 네트워크는 Docker Desktop의 Linux VM 안에만 있고, Mac은 그 안으로 가는 길을 모른다. Mac에서는 IP 대신 포트 게시를 쓰면 된다.\n참고자료 Docker Desktop networking — Docker Desktop의 네트워크 동작과 Mac에서의 제약\nBridge network driver — docker0 기본 bridge 네트워크 설명\n","permalink":"https://jaylab.link/docker-container-network-arch/","summary":"Mac에서 컨테이너 IP가 보이는데도 접속되지 않는 건 Docker 네트워크가 Linux VM 안에만 있어서다.","title":"Mac에서 컨테이너 IP로 접속이 안 되는 이유"},{"content":"⚠️아래 내용은 컨테이너에 root 계정의 비밀번호를 간단히 설정하여 ssh 방식으로 접속하는 방법이다. **(보안을 위해 별도의 계정 추가와 공개키 인증 방식 등을 권장)\n또한 컨테이너의 IP 를 통해서 접속하는게 아닌** “localhost“를 통해서 접속하는 방법이다. (컨테이너의 IP로 ssh 접속을 할 수 없는 이유는 다른 글에서 다룰 예정)\n1. Ubuntu 컨테이너 실행 (+ SSH 설치) 먼저 Ubuntu 컨테이너를 실행하면서 bash로 진입\ndocker run -it --name {컨테이너 이름} -p 2222:22 ubuntu:latest bash p 2222:22: 로컬 포트 2222를 컨테이너의 22번(SHH)포트로 포트포워딩 2. 컨테이너 내부에서 SSH 서버 설치 및 설정 컨테이너 내부에서 아래 명령어로 SSH 서버(ssh-server) 및 기타 유틸 설치\napt update apt install -y openssh-server SSH 설정을 약간 변경하고 root 비밀번호 설정\necho \u0026#34;root:DockerPass123\u0026#34; | chpasswd # root 비밀번호 설정 # SSH 설정파일에서 root 로그인 금지 → 허용으로 바꿔주는 설정 sed -i \u0026#39;s/#PermitRootLogin prohibit-password/PermitRootLogin yes/\u0026#39; /etc/ssh/sshd_config service ssh start # SSH 서버 구동 3. 로컬에서 컨테이너로 SSH 접속 컨테이너에서 나와(새 터미널에서), 로컬에서 아래와 같이 SSH 접속 테스트\nssh root@localhost -p 2222 비밀번호: DockerPass123 4. 기타 참고사항 만약 \u0026ldquo;컨테이너를 띄울 때마다 sshd를 실행\u0026quot;하고 싶다면 CMD [\u0026quot;usr/sbin/sshd\u0026quot;, \u0026quot;-D\u0026quot;] 등을 Dockerfile에 명시 # Dockerfile 예시 FROM ubuntu:latest RUN apt update \u0026amp;\u0026amp; apt install -y openssh-server RUN echo \u0026#34;root:DockerPass123\u0026#34; | chpasswd RUN sed -i \u0026#39;s/#PermitRootLogin prohibit-password/PermitRootLogin yes/\u0026#39; /etc/ssh/sshd_config CMD [\u0026#34;/usr/sbin/sshd\u0026#34;, \u0026#34;-D\u0026#34;] 공식 Ubuntu 이미지 대신 ssh가 이미 내장된 이미지를 쓸 수도 있다. ","permalink":"https://jaylab.link/docker-ssh-setting/","summary":"현재 진행 중인 PoC를 위해 SSH 접속 방식 테스트가 필요했다. 그러나 Ubuntu Docker image에는 기본적으로 SSH(즉, openssh-server)가 설치되어있지 않기 때문에 이번에 Docker + SSH 접속 방식을 테스트하고 기록했다.","title":"Docker Container SSH 접속 설정"},{"content":"앞서 ModSecurity에 대해 간단히 다뤘다. 이번에는 우분투 컨테이너 생성부터 ModSecurity 구성과 테스트까지 진행했고 그 과정을 기록했다.\nModSecurity 구성 Ubuntu Container 설치(A)\nA안에 앞서 다뤘던 Nginx Proxy Manager 구성\nModSecurity 설치\n# 우분투 컨테이너 실행 docker run -it --name my-ubuntu-container ubuntu:latest /bin/bash # NGINX Proxy Manager 설치 curl -fsSL https://deb.nodesource.com/setup_20.x | bash - # apt install -y nodejs # ModSecurity 설치 apt-cache search modsecurity apt install libnginx-mod-http-modsecurity apt install modsecurity-crs apt install vim ls /usr/share/modsecurity-crs/ `=========================================================== [실행결과] owasp-crs.load rules uti ============================================================` cat /usr/share/modsecurity-crs/owasp-crs.load `=========================================================== [실행결과] ## ## This file loads OWASP CRS\u0026#39;s rules when the package is installed ## It is Included by libapache2-mod-security2 ## Include /etc/modsecurity/crs/crs-setup.conf IncludeOptional /etc/modsecurity/crs/REQUEST-900-EXCLUSION-RULES-BEFORE-CRS.conf Include /usr/share/modsecurity-crs/rules/*.conf IncludeOptional /etc/modsecurity/crs/RESPONSE-999-EXCLUSION-RULES-AFTER-CRS.conf ============================================================` 💬 Include 형태로 규칙이 들어있는 파일들이 지정되어 있는것을 확인할 수 있다. # nginx 설치 apt install nginx # nginx 실행 nginx `============================================================ [실행결과] nginx is running =============================================================` # nginx 프로세스 확인1 ps aux | grep nginx # nginx 프로세스 확인2 service nginx status # 80포트 리스닝 중인지 확인 ss -tuln | grep 80 # log 확인하기 tail -f /var/log/nginx/error.log tail -f /var/log/nginx/access.log # 다른 터미널에서 우분투 컨테이너 B 진입 docker exec -it my-ubuntu-container /bin/bash # Nginx 상태 확인 curl -I http://localhost # 우분투 컨테이너에서 accesslog 확인 -\u0026gt; 정상 # 컨테이너 시작시 Nginx가 포그라운드에서 사용될 수 있도록 하는 명령어들(아래 중 한가지 선택) CMD [\u0026#34;nginx\u0026#34;, \u0026#34;-g\u0026#34;, \u0026#34;daemon off;\u0026#34;] nginx -g \u0026#39;daemon off;\u0026#39; ➡️ tail -f 명령어로 모니터링한 로그\n127.0.0.1 - - [04/Jul/2024:23:45:31 +0900] \u0026#34;HEAD /?data=test HTTP/1.1\u0026#34; 403 0 \u0026#34;-\u0026#34; \u0026#34;curl/8.5.0\u0026#34; 127.0.0.1 - - [04/Jul/2024:23:45:55 +0900] \u0026#34;HEAD /?data=hello HTTP/1.1\u0026#34; 200 0 \u0026#34;-\u0026#34; \u0026#34;curl/8.5.0\u0026#34; 127.0.0.1 - - [04/Jul/2024:23:46:02 +0900] \u0026#34;GET /?data=test HTTP/1.1\u0026#34; 403 162 \u0026#34;-\u0026#34; \u0026#34;curl/8.5.0\u0026#34; 127.0.0.1 - - [04/Jul/2024:23:46:08 +0900] \u0026#34;GET /?data=hello HTTP/1.1\u0026#34; 200 615 \u0026#34;-\u0026#34; \u0026#34;curl/8.5.0\u0026#34; tail -f 명령어로 모니터링한 로그\n위와는 다르게 ModSecurity 로그 파일의 경로는 /etc/nginx/modsecurity.conf 파일 SecAuditLog 항목에서 확인할 수 있다.\n→ /var/log/nginx/modsec_audit.log 경로 확인완료\ntail -f /var/log/nginx/modsec_audit.log 실행\n보안 규칙에 위배되지 않는 request인 경우 아무런 로그가 확인되지 않다가, 위배되는 request 발생시 다음과 같은 로그가 확인된다.\n---AU9HhbBg---A-- [04/Jul/2024:23:51:11 +0900] 172010467164.403561 127.0.0.1 57376 127.0.0.1 80 ---AU9HhbBg---B-- HEAD /?data=test HTTP/1.1 Host: localhost User-Agent: curl/8.5.0 Accept: */* ---AU9HhbBg---D-- ---AU9HhbBg---F-- HTTP/1.1 403 ---AU9HhbBg---H-- ModSecurity: Access denied with code 403 (phase 1). Matched \u0026#34;Operator `Contains\u0026#39; with parameter `test\u0026#39; against variable `ARGS:data\u0026#39; (Value: `test\u0026#39; ) [file \u0026#34;/etc/nginx/modsec/modsec_test.conf\u0026#34;] [line \u0026#34;1\u0026#34;] [id \u0026#34;1234\u0026#34;] [rev \u0026#34;\u0026#34;] [msg \u0026#34;param contains test\u0026#34;] [data \u0026#34;\u0026#34;] [severity \u0026#34;0\u0026#34;] [ver \u0026#34;\u0026#34;] [maturity \u0026#34;0\u0026#34;] [accuracy \u0026#34;0\u0026#34;] [hostname \u0026#34;127.0.0.1\u0026#34;] [uri \u0026#34;/\u0026#34;] [unique_id \u0026#34;172010467164.403561\u0026#34;] [ref \u0026#34;o0,4v12,4\u0026#34;] ---AU9HhbBg---I-- ---AU9HhbBg---J-- ---AU9HhbBg---Z-- Success! ","permalink":"https://jaylab.link/modsecurity-waf-with-ubuntu-setting/","summary":"앞서 ModSecurity에 대해 간단히 다뤘다. 이번에는 우분투 컨테이너 생성부터 ModSecurity 구성과 테스트까지 진행했고 그 과정을 기록했다.","title":"Ubuntu 컨테이너 생성부터 ModSecurity WAF 구성 및 테스트까지"},{"content":"✅ Docker를 사용한 ModSecurity WAF 사용기 Web Application Firewall (WAF) 웹 서비스를 통해서 공격을 시도하는 XSS, SQL Injection, Command Execute과 같은 공격을 효과적으로 차단하는 역할을 수행하는 보안 모듈\n어플리케이션 계층(L7) 방화벽\nModSecurity ModSecurity는 웹 공격에 대한 침입탐지 및 침입방지 기능을 추가해 주는 아파치 웹서버의 하나의 모듈로 동작하는 WAF\n웹 클라이언트와 아파치 웹 서버 사이에 존재하여 클라이언트로부터 악의적인 트래픽이 발생되면 공격을 차단, 로깅 등 사전에 정의된 행위를 수행\n다른 아파치 모듈과 마찬가지로 ModSeucrity를 아파치의 한 부분으로 설치할 수 있으며, 정상적으로 설치되었을 경우 ModSecurity의 추가적인 처리로부터 발생되는 부하는 거의 없다고 할 수 있다.\n사용해보기 🤔 간단한 실습은 Docker로 하는게 좋을것 같아 확인해본 결과 웹에서 레퍼런스를 도저히 못찾음!!!!!\n혹시몰라서 docker search modsecurity 확인 결과, 뭔가 관련 있어보이는 Image 두 가지를 확인\n이미지 Pull → 실패!\n→ 발견. 컨테이너가 8080포트 리스닝 중.\n다시 컨테이너 삭제 후 run 명령 수정\ndocker stop modsecurity-crs docker rm modsecurity-crs docker run -d -p 8080:8080 --name modsecurity-crs owasp/modsecurity-crs:nginx localhost:8080 접속 결과 → 정상\nSQL Injection Test 다른 터미널 띄워놓고 docker logs -f modsecurity-crs 명령으로 실시간으로 로그 확인\nRequest 1 curl \u0026#34;http://localhost:8080/?test=\u0026#39;OR\u0026#39;1\u0026#39;=\u0026#39;1\u0026#34; Response 1 2024/07/03 16:36:38 [error] 98#98: *43 [client 172.17.0.1] ModSecurity: Access denied with code 403 (phase 2). Matched \u0026#34;Operator `Ge\u0026#39; with parameter `5\u0026#39; against variable `TX:BLOCKING_INBOUND_ANOMALY_SCORE\u0026#39; (Value: `5\u0026#39; ) [file \u0026#34;/etc/modsecurity.d/owasp-crs/rules/REQUEST-949-BLOCKING-EVALUATION.conf\u0026#34;] [line \u0026#34;222\u0026#34;] [id \u0026#34;949110\u0026#34;] [rev \u0026#34;\u0026#34;] [msg \u0026#34;Inbound Anomaly Score Exceeded (Total Score: 5)\u0026#34;] [data \u0026#34;\u0026#34;] [severity \u0026#34;0\u0026#34;] [ver \u0026#34;OWASP_CRS/4.3.0\u0026#34;] [maturity \u0026#34;0\u0026#34;] [accuracy \u0026#34;0\u0026#34;] [tag \u0026#34;modsecurity\u0026#34;] [tag \u0026#34;anomaly-evaluation\u0026#34;] [tag \u0026#34;OWASP_CRS\u0026#34;] [hostname \u0026#34;172.17.0.2\u0026#34;] [uri \u0026#34;/\u0026#34;] [unique_id \u0026#34;172002459857.523994\u0026#34;] [ref \u0026#34;\u0026#34;], client: 172.17.0.1, server: localhost, request: \u0026#34;GET /?test=\u0026#39;OR\u0026#39;1\u0026#39;=\u0026#39;1 HTTP/1.1\u0026#34;, host: \u0026#34;localhost:8080\u0026#34; 172.17.0.1 - - [03/Jul/2024:16:36:38 +0000] \u0026#34;GET /?test=\u0026#39;OR\u0026#39;1\u0026#39;=\u0026#39;1 HTTP/1.1\u0026#34; 403 146 \u0026#34;-\u0026#34; \u0026#34;curl/8.4.0\u0026#34; \u0026#34;-\u0026#34; {\u0026#34;transaction\u0026#34;:{\u0026#34;client_ip\u0026#34;:\u0026#34;172.17.0.1\u0026#34;,\u0026#34;time_stamp\u0026#34;:\u0026#34;Wed Jul 3 16:36:38 2024\u0026#34;,\u0026#34;server_id\u0026#34;:\u0026#34;c670a1574c42004e6043ee8f3dd5301d3cdc8e0c\u0026#34;,\u0026#34;client_port\u0026#34;:59482,\u0026#34;host_ip\u0026#34;:\u0026#34;172.17.0.2\u0026#34;,\u0026#34;host_port\u0026#34;:8080,\u0026#34;unique_id\u0026#34;:\u0026#34;172002459857.523994\u0026#34;,\u0026#34;request\u0026#34;:{\u0026#34;method\u0026#34;:\u0026#34;GET\u0026#34;,\u0026#34;http_version\u0026#34;:1.1,\u0026#34;uri\u0026#34;:\u0026#34;/?test=\u0026#39;OR\u0026#39;1\u0026#39;=\u0026#39;1\u0026#34;,\u0026#34;headers\u0026#34;:{\u0026#34;Host\u0026#34;:\u0026#34;localhost:8080\u0026#34;,\u0026#34;User-Agent\u0026#34;:\u0026#34;curl/8.4.0\u0026#34;,\u0026#34;Accept\u0026#34;:\u0026#34;*/*\u0026#34;}},\u0026#34;response\u0026#34;:{\u0026#34;body\u0026#34;:\u0026#34;\u0026lt;html\u0026gt;\\r\\n\u0026lt;head\u0026gt;\u0026lt;title\u0026gt;403 Forbidden\u0026lt;/title\u0026gt;\u0026lt;/head\u0026gt;\\r\\n\u0026lt;body\u0026gt;\\r\\n\u0026lt;center\u0026gt;\u0026lt;h1\u0026gt;403 Forbidden\u0026lt;/h1\u0026gt;\u0026lt;/center\u0026gt;\\r\\n\u0026lt;hr\u0026gt;\u0026lt;center\u0026gt;nginx\u0026lt;/center\u0026gt;\\r\\n\u0026lt;/body\u0026gt;\\r\\n\u0026lt;/html\u0026gt;\\r\\n\u0026#34;,\u0026#34;http_code\u0026#34;:403,\u0026#34;headers\u0026#34;:{\u0026#34;Server\u0026#34;:\u0026#34;nginx\u0026#34;,\u0026#34;Date\u0026#34;:\u0026#34;Wed, 03 Jul 2024 16:36:38 GMT\u0026#34;,\u0026#34;Content-Length\u0026#34;:\u0026#34;146\u0026#34;,\u0026#34;Content-Type\u0026#34;:\u0026#34;text/html\u0026#34;,\u0026#34;Connection\u0026#34;:\u0026#34;keep-alive\u0026#34;}},\u0026#34;producer\u0026#34;:{\u0026#34;modsecurity\u0026#34;:\u0026#34;ModSecurity v3.0.12 (Linux)\u0026#34;,\u0026#34;connector\u0026#34;:\u0026#34;ModSecurity-nginx v1.0.3\u0026#34;,\u0026#34;secrules_engine\u0026#34;:\u0026#34;Enabled\u0026#34;,\u0026#34;components\u0026#34;:[\u0026#34;OWASP_CRS/4.3.0\\\u0026#34;\u0026#34;]},\u0026#34;messages\u0026#34;:[{\u0026#34;message\u0026#34;:\u0026#34;SQL Injection Attack Detected via libinjection\u0026#34;,\u0026#34;details\u0026#34;:{\u0026#34;match\u0026#34;:\u0026#34;detected SQLi using libinjection.\u0026#34;,\u0026#34;reference\u0026#34;:\u0026#34;v11,9\u0026#34;,\u0026#34;ruleId\u0026#34;:\u0026#34;942100\u0026#34;,\u0026#34;file\u0026#34;:\u0026#34;/etc/modsecurity.d/owasp-crs/rules/REQUEST-942-APPLICATION-ATTACK-SQLI.conf\u0026#34;,\u0026#34;lineNumber\u0026#34;:\u0026#34;46\u0026#34;,\u0026#34;data\u0026#34;:\u0026#34;Matched Data: s\u0026amp;sos found within ARGS:test: \u0026#39;OR\u0026#39;1\u0026#39;=\u0026#39;1\u0026#34;,\u0026#34;severity\u0026#34;:\u0026#34;2\u0026#34;,\u0026#34;ver\u0026#34;:\u0026#34;OWASP_CRS/4.3.0\u0026#34;,\u0026#34;rev\u0026#34;:\u0026#34;\u0026#34;,\u0026#34;tags\u0026#34;:[\u0026#34;application-multi\u0026#34;,\u0026#34;language-multi\u0026#34;,\u0026#34;platform-multi\u0026#34;,\u0026#34;attack-sqli\u0026#34;,\u0026#34;paranoia-level/1\u0026#34;,\u0026#34;OWASP_CRS\u0026#34;,\u0026#34;capec/1000/152/248/66\u0026#34;,\u0026#34;PCI/6.5.2\u0026#34;],\u0026#34;maturity\u0026#34;:\u0026#34;0\u0026#34;,\u0026#34;accuracy\u0026#34;:\u0026#34;0\u0026#34;}},{\u0026#34;message\u0026#34;:\u0026#34;Inbound Anomaly Score Exceeded (Total Score: 5)\u0026#34;,\u0026#34;details\u0026#34;:{\u0026#34;match\u0026#34;:\u0026#34;Matched \\\u0026#34;Operator `Ge\u0026#39; with parameter `5\u0026#39; against variable `TX:BLOCKING_INBOUND_ANOMALY_SCORE\u0026#39; (Value: `5\u0026#39; )\u0026#34;,\u0026#34;reference\u0026#34;:\u0026#34;\u0026#34;,\u0026#34;ruleId\u0026#34;:\u0026#34;949110\u0026#34;,\u0026#34;file\u0026#34;:\u0026#34;/etc/modsecurity.d/owasp-crs/rules/REQUEST-949-BLOCKING-EVALUATION.conf\u0026#34;,\u0026#34;lineNumber\u0026#34;:\u0026#34;222\u0026#34;,\u0026#34;data\u0026#34;:\u0026#34;\u0026#34;,\u0026#34;severity\u0026#34;:\u0026#34;0\u0026#34;,\u0026#34;ver\u0026#34;:\u0026#34;OWASP_CRS/4.3.0\u0026#34;,\u0026#34;rev\u0026#34;:\u0026#34;\u0026#34;,\u0026#34;tags\u0026#34;:[\u0026#34;modsecurity\u0026#34;,\u0026#34;anomaly-evaluation\u0026#34;,\u0026#34;OWASP_CRS\u0026#34;],\u0026#34;maturity\u0026#34;:\u0026#34;0\u0026#34;,\u0026#34;accuracy\u0026#34;:\u0026#34;0\u0026#34;}}]}} **Matched \u0026ldquo;Operator Ge' with parameter 5\u0026rsquo; against variable TX:BLOCKING_INBOUND_ANOMALY_SCORE' (Value: 5\u0026rsquo; ) **→ ModSecurity가 특정 규칙에 따라 요청을 차단했음을 나타냄\n**SQL Injection Attack Detected via libinjection match\u0026rdquo;:\u0026ldquo;detected SQLi using libinjection.\u0026rdquo;,\u0026ldquo;reference\u0026rdquo;:\u0026ldquo;v11,9\u0026rdquo;,\u0026ldquo;ruleId\u0026rdquo;:\u0026ldquo;942100 → SQL 인젝션 공격이 감지되었음을 나타냄 **\nSuccess! ","permalink":"https://jaylab.link/modsecurity-waf-docker-setting/","summary":"부제: Docker를 사용한 ModSecurity WAF 사용기 Web Application Firewall (WAF): 웹 서비스를 통해서 공격을 시도하는 XSS, SQL Injection, Command Execute과 같은 공격을 효과적으로 차단하는 역할을 수행하는 보안 모듈","title":"ModSecurity. Opensource Web Application Firewall"},{"content":"📌 Contents.\n환경 Spring framework 기반 MSA 구조\nBatch 전용 모듈 2 종류\n모듈 A: 작업 목록을 가지고있는 Queue 역할\n모듈 B: A의 Queue에서 작업목록(데이터)을 읽어와 분석 프로세스 수행하는 역할\nA는 Redshift의 테이블을 조회하여 작업 목록 결과값을 Queue에 적재\nB는 A에서 작업목록 조회. 데이터 존재시 Queue에서 작업목록 pull. 작업목록 분석 프로세스 수행 후, 프로세스가 완료되면 Redshift 특정 테이블 특정 row 데이터 delete\n모든 모듈은 AWS ECS의 Fargate로 운영\n시간이 흐를수록 점점 필요한 분석 작업이 많아지면서 보통 하루 안에 완료되던 분석 Batch 프로세스가 점점 하루를 넘어가기 시작\n해결 방안 A가 저장하고있는 Queue의 작업목록이 0이 아닌 경우(분석해야할 작업 목록이 존재하는 경우) B 모듈 태스크 증량 → 기존에 1개로만 운영되던 B 모듈을 10개로 Scale Out\nA의 Queue 데이터 count가 0 즉, 모두 분석 작업이 모두 완료된 경우 B모듈 관련 태스크 Scale In\n목표 A 모듈 자체에서 Schedule Batch 프로그램 개발:\n주기적으로 Queue의 데이터 Count 및 Redishift 잔여 작업 목록 Count 확인\n확인된 Count 값 AWS SDK를 통해서 CloudWatch 메트릭으로 전송\nA의 Queue에 쌓인 데이터 수가 0이 아닌 경우 + Redshift의 작업대상목록 테이블 count가 0이 아닌경우, CloudWatch Alarm이 감지하여 ECS Fargate B 모듈 관련 Task Scale Out\nCloudWatch에서 수신한 Count가 0인 경우 Scale In\n기본적으로 운영할 최소 태스크는 1개, Capacity provider는 Fargate 타입\nScale Out시 추가될 태스크의 Capacity provider는 비용절감을 위해 Fargate_spot 타입\n과정 ✅ A 모듈 자체에서 Schedule Batch 프로그램 개발\nbuild.gradle에 CloudWatch 관련 SDK 추가 dependencies { implementation \u0026#39;software.amazon.awssdk:cloudwatch:2.22.9\u0026#39; } AWS 설정 관련 스프링 컨테이너 생성 import org.springframework.beans.factory.annotation.Value; import org.springframework.boot.autoconfigure.EnableAutoConfiguration; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import software.amazon.awssdk.auth.credentials.DefaultCredentialsProvider; import software.amazon.awssdk.regions.Region; import software.amazon.awssdk.services.cloudwatch.CloudWatchClient; import java.util.Objects; @Configuration @EnableAutoConfiguration public class AwsConfiguration { private final String defaultRegion; public AwsConfiguration(@Value(\u0026#34;${cloud.aws.region.static}\u0026#34;) String defaultRegion) { this.defaultRegion = defaultRegion; } @Bean public CloudWatchClient cloudWatchClient() { return CloudWatchClient.builder() .region(Region.of(defaultRegion)) .credentialsProvider(DefaultCredentialsProvider.create()) .build(); } } Batch 기능 추가: 5분 주기로 Queue 사이즈 모니터링 → CloudWatch Metric으로 전송 import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.context.annotation.Configuration; import org.springframework.context.annotation.Profile; import org.springframework.scheduling.annotation.Scheduled; import software.amazon.awssdk.services.cloudwatch.CloudWatchClient; import software.amazon.awssdk.services.cloudwatch.model.Dimension; import software.amazon.awssdk.services.cloudwatch.model.MetricDatum; import software.amazon.awssdk.services.cloudwatch.model.PutMetricDataRequest; import software.amazon.awssdk.services.cloudwatch.model.StandardUnit; @Profile({\u0026#34;prd\u0026#34;, \u0026#34;dev\u0026#34;}) @Configuration @Slf4j @RequiredArgsConstructor public class CmmtOptiMetricJob { private final CmmtRecmQueueRepository cmmtRecmQueueRepository; private final CmmtRecmDao cmmtRecmDao; private final CloudWatchClient cloudWatchClient; private final String NAMESPACE = \u0026#34;ECS/CustomMetrics\u0026#34;; private final String METRIC_NAME = \u0026#34;RecommendationTargetCount\u0026#34;; private final String DIMENSION_NAME = \u0026#34;ServiceName\u0026#34;; private final String DIMENSION_VALUE = \u0026#34;cbp-inf-opti-aws\u0026#34;; @Scheduled(fixedDelay = 300000) // 매 5분 public void putRecommendationTargetCountMetricToCloudWatch() { int queueTargetCount = cmmtRecmQueueRepository.getRecommendationTargetQueue().size(); int tableTargetCount = cmmtRecmDao.selectRecommendationTargetCount(); // 대기열 테이블 레코드 개수 조회 int recommendationTargetCount = queueTargetCount \u0026gt; 0 ? queueTargetCount : tableTargetCount; Dimension dimension = Dimension.builder() .name(DIMENSION_NAME) .value(DIMENSION_VALUE) .build(); MetricDatum datum = MetricDatum.builder() .metricName(METRIC_NAME) .unit(StandardUnit.COUNT) .value((double) recommendationTargetCount) .dimensions(dimension) .build(); PutMetricDataRequest request = PutMetricDataRequest.builder() .namespace(NAMESPACE) .metricData(datum) .build(); cloudWatchClient.putMetricData(request); } } ✅ CloudWatch Metric 수신 확인\n✅ CloudWatch Alarm 생성\n작업목록 발생시 Scale Out 수행할 Alarm 작업목록 발생시 Scale In 수행할 Alarm ✅ B 모듈 ECS 서비스 Auto Scaling 설정\n🎉 완료 B Module ↔ CloudWatch Metric 송수신 정상\n대기열에 작업 목록 발생시 B 모듈 태스크 증가(Fargate spot 타입) 작업 목록만큼 프로세스 모두 수행 후 대기열이 비면 B 모듈 태스크 1개(Fargate 타입)만 남겨놓고 모두 삭제 → CloudWatch Alarm 기능, 스케일링 설정 정상\n태스크 start\n태스크 stop\nAlarm 모니터링\n+ 진행 간 겪었던 문제 원래는 CloudWatch Alarm 없이 A 모듈 내에서 AWS SDK를 통해 직접 B 모듈 서비스 컴퓨팅 구성을 조절하도록 구현할 계획이었다. 그런데 사전 테스트 결과 다음과 같은 문제점을 확인했다.\nService Auto Scaling - 조정 정책을 ‘직접’ 컨트롤하는 경우 새로운 태스크를 필요한 만큼 모두 생성 후 기존의 태스크를 모두 삭제하는 현상\n예를 들어 기본적으로 1개의 태스크로 운용하다가 작업목록이 증가하여 4개의 B 태스크를 추가하면 4개만 추가되지않고 5개 생성 후 기존 태스크 1개를 삭제\n이렇게되면 기존 태스크에서 멀쩡히 프로세스를 수행하고있다가 뜬금없이 중단되어버리는 불상사 발생 가능\n이에 따라 CloudWatch Alarm 활용하여 ECS Service Auto Scaling 하는 방식으로 계획 변경\n","permalink":"https://jaylab.link/cloudwatch-alarm-ecs-task-auto-scaling/","summary":"시간이 흐를수록 점점 분석 Batch 프로세스가 오래걸리기 시작하면서 점점 하루를 넘어가기 시작. 이를 해결하기 위해 ECS Service에 CloudWatch를 활용한 오토스케일링을 적용하였고 그 과정을 기록하였다.","title":"CloudWatch Alarm 활용한 ECS Fargate 스케일아웃"},{"content":"고객사 업무를 하다보니 MyBatis를 더 많이 접하고있다.\n일반적으로 SQL 매핑 프레임워크 관련 트렌드는 JPA, Hibernate가 압도적이긴 하나 아직까지 한국에서 MyBatis 비율이 높은데에는 정부의 표준 프레임워크가 MyBatis를 채택하고 있는게 한 몫하지 않나싶다.\n무튼 이러한 MyBatis 사용시 SQL이 동적으로 작성되도록 할 때 유용한 \u0026lt;bind\u0026gt;에 대해 간단히 정리해보았다.\n바로 예제 코드부터. (✭ 실무에서 유용할 활용법은 예시 3번)\n예시 1 \u0026lt;select id=\u0026#34;getUsersByAge\u0026#34; resultType=\u0026#34;User\u0026#34;\u0026gt; \u0026lt;bind name=\u0026#34;maxAge\u0026#34; value=\u0026#34;age + 5\u0026#34; /\u0026gt; SELECT * FROM users WHERE age BETWEEN #{age} AND #{maxAge} \u0026lt;/select\u0026gt; 예시 1\n여기서 \u0026lt;bind\u0026gt; 태그는 \u0026lsquo;maxAge\u0026rsquo;라는 변수를 만들고 있다. 파라미터로 받은 \u0026lsquo;age\u0026rsquo;에 5를 더해서 5살 더 많은 사람들까지 조회하는 쿼리를 만들었다. 사실 위 예제는 괜히 코드량만 더 길게 만든 감이 있는 예제이지만, bind 태그를 잘 활용하면 다음과 같은 이점을 얻을 수 있다.\n코드 재사용성 ↑\n복잡한 로직 분리, SQL 가독성 ↑\n두 개의 예시를 더 살펴보자\n예시 2 \u0026lt;select id=\u0026#34;searchProducts\u0026#34; resultType=\u0026#34;Product\u0026#34;\u0026gt; \u0026lt;bind name=\u0026#34;searchPattern\u0026#34; value=\u0026#34;\u0026#39;%\u0026#39; + keyword + \u0026#39;%\u0026#39;\u0026#34; /\u0026gt; SELECT * FROM products WHERE name LIKE #{searchPattern} OR description LIKE #{searchPattern} \u0026lt;/select\u0026gt; 예시 2\n이 예제에서는 검색 키워드를 포함하는 패턴을 만들고 있다. \u0026lsquo;searchPattern\u0026rsquo;이라는 변수에 앞뒤로 \u0026lsquo;%\u0026lsquo;를 붙여서 부분 일치 검색이 가능하도록 만들어졌다.\n이렇듯 \u0026lt;bind\u0026gt; 태그는 SQL 실행 전 java 코드로 선 처리된다는 점에서 의미가 있다. 따라서 다음에 소개할 예시와 같이 응용해볼 수 있다.\n**✭ **예시 3 ✭ 예시 3-1)\n\u0026lt;sql id=\u0026#34;autospFilterSp\u0026#34;\u0026gt; \u0026lt;bind name=\u0026#34;purchaseCdListIncludedInSp\u0026#34; value=\u0026#34;@com.a.b.c.common.constant.d.EnumPurchaseType@getPurchaseCdListIncludedInSp()\u0026#34;/\u0026gt; AND IF(ATSP.SP_YN = \u0026#39;Y\u0026#39;, RCM.OFFR_TYP_NM NOT IN (\u0026lt;foreach collection=\u0026#34;purchaseCdListIncludedInSp\u0026#34; item=\u0026#34;item\u0026#34; separator=\u0026#34;,\u0026#34;\u0026gt;#{item}\u0026lt;/foreach\u0026gt;), TRUE) \u0026lt;/sql\u0026gt; 예시 3-1\n예시 3-2)\n@Getter public enum CmmtOptiSpPurchaseType { /** ComputeSavingsPlans */ COMPUTE(\u0026#34;ComputeSavingsPlans\u0026#34;, \u0026#34;Compute\u0026#34;, \u0026#34;AmazonEC2\u0026#34;, \u0026#34;AmazonECS\u0026#34;, \u0026#34;AmazonEKS\u0026#34;, \u0026#34;AWSLambda\u0026#34; ), /** EC2InstanceSavingsPlans */ EC2_INSTANCE(\u0026#34;EC2InstanceSavingsPlans\u0026#34;, \u0026#34;EC2 Instance\u0026#34;, \u0026#34;AmazonEC2\u0026#34; ), /** SageMakerSavingsPlans */ SAGEMAKER(\u0026#34;SageMakerSavingsPlans\u0026#34;, \u0026#34;SageMaker\u0026#34;, \u0026#34;AmazonSageMaker\u0026#34; ) /** 코드 */ private final String cd; /** 이름 */ private final String nm; /** 서비스 코드 집합 */ private final Set\u0026lt;String\u0026gt; svcCdSet; CmmtOptiSpPurchaseType(String cd, String nm, String... svcCdArray) { this.cd = cd; this.nm = nm; this.svcCdSet = Collections.unmodifiableSet(new HashSet\u0026lt;\u0026gt;(Arrays.asList(svcCdArray))); } ...생략... public static List\u0026lt;String\u0026gt; getPurchaseCdListIncludedInSp() { return Arrays.asList(EnumPurchaseType.COMPUTE.getCd(), EnumPurchaseType.EC2_INSTANCE.getCd()); } } 예시 3-2\n설명:\ncom.a.b.c.common.constant.d 패키지 경로의 EnumPurchaseType라는 이름의 enum에서 getPurchaseCdListIncludedInSp 메서드를 실행 후 그 return값을 purchaseCdListIncludedInSp 변수로 설정 ※ return값: [\u0026ldquo;ComputeSavingsPlans\u0026rdquo;, \u0026ldquo;EC2InstanceSavingsPlans\u0026rdquo;]\nMyBatis \u0026lt;foreach\u0026gt;를 통해 동적 SQL문 구현\n💡작성될 SQL AND IF(ATSP.AUTOSP_YN = 'Y', RCM.OFFR_TYP_NM NOT IN ('``ComputeSavingsPlans’, ‘EC2InstanceSavingsPlans’) 앞선 두 예시는 뭔가 쿼리로도 해결할 수 있는 내용을 더 번잡하게 만든 것 같은 느낌이 든다.\n하지만 세 번째 예시 처럼, \u0026lt;bind\u0026gt; 태그는 이미 구현되어있는 Enum 코드를 재사용하거나 method return 값을 통해 SQL을 동적으로 생성할 수 있고, 하드코딩과 중복코드를 피할 수 있다는 점에서 가치가 있다.\n","permalink":"https://jaylab.link/mybatis-bind-tag-dynamic-sql-query/","summary":"MyBatis에서 SQL을 동적으로 작성할 때 유용한 \u003cbind\u003e에 대해 간단하게 정리해보았다.","title":"MyBatis bind tag. 동적 SQL 구현"},{"content":"앞서 Docker를 활용한 ELK 구성에 이어서, 이번에는 Metricbeat를 붙여보았다\n📌 Contents.\nMetricbeat ℹ️**Metricbeat란? **컴퓨터나 서버의 상태를 모니터링하는 도구. 쉽게 말하면, 컴퓨터의 \u0026lsquo;건강 검진\u0026rsquo;을 자동으로 해주는 프로그램\n예를 들어:\nCPU 사용량이 얼마나 되는지\n메모리가 얼마나 남았는지\n디스크 공간은 충분한지\n이런 정보들을 주기적으로 수집해서 보고해주는 프로그램\nMetricbeat 추가구성 방법 지난 번에 다뤘던 docker-compose.yml 파일 내용에서 metricbeat 관련 추가된 설정\nmetricbeat: build: context: ../elk/metricbeat/ args: ELASTIC_VERSION: ${ELASTIC_VERSION} container_name: metricbeat user: root # needed to access host metrics volumes: - ../elk/metricbeat/config/metricbeat.yml:/usr/share/metricbeat/metricbeat.yml:ro,Z - /var/run/docker.sock:/var/run/docker.sock:ro environment: - ELASTICSEARCH_HOSTS=http://elasticsearch:9200 - ELASTICSEARCH_USERNAME=metricbeat_internal - METRICBEAT_INTERNAL_PASSWORD=${METRICBEAT_INTERNAL_PASSWORD:-} networks: - nginx_proxy_manager depends_on: - elasticsearch - kibana docker-compose.yml\nelasticsearch/config/elasticsearch.yml에 아래 내용 추가\n# Metricbeat 관련 추가 항목 xpack.monitoring.collection.enabled: true metricbeat/Dockerfile\nARG ELASTIC_VERSION FROM docker.elastic.co/beats/metricbeat:${ELASTIC_VERSION} USER root RUN mkdir /var/log/metricbeat \u0026amp;\u0026amp; chown metricbeat /var/log/metricbeat metricbeat/config/metricbeat.yml\nmetricbeat.config: modules: path: ${path.config}/modules.d/*.yml reload.enabled: false metricbeat.modules: - module: system metricsets: - cpu - load - memory - network - process - process_summary enabled: true period: 10s processes: [\u0026#39;.*\u0026#39;] - module: docker metricsets: - container - cpu - diskio - healthcheck - info - memory - network hosts: [\u0026#34;unix:///var/run/docker.sock\u0026#34;] period: 10s output.elasticsearch: hosts: [\u0026#39;elasticsearch:9200\u0026#39;] username: \u0026#39;metricbeat_internal\u0026#39; password: \u0026#39;${METRICBEAT_INTERNAL_PASSWORD}\u0026#39; setup.kibana: host: \u0026#34;kibana:5601\u0026#34; setup/roles/metricbeat_writer.json\n{ \u0026#34;cluster\u0026#34;: [\u0026#34;monitor\u0026#34;, \u0026#34;manage_index_templates\u0026#34;, \u0026#34;manage_ilm\u0026#34;], \u0026#34;indices\u0026#34;: [ { \u0026#34;names\u0026#34;: [\u0026#34;metricbeat-*\u0026#34;], \u0026#34;privileges\u0026#34;: [\u0026#34;manage\u0026#34;, \u0026#34;index\u0026#34;, \u0026#34;create_index\u0026#34;, \u0026#34;view_index_metadata\u0026#34;, \u0026#34;read\u0026#34;] } ] } ⚠ Metricbeat 테스트 참고로, Mac OS 환경에서는 Metricbeat 도커 컨테이너가 Mac의 시스템 메트릭을 수집하는 것에 보안상의 제한사항이 존재했다. 뭔가 방법이 없나 찾아보았지만 결국 Mac OS에서 메트릭 수집 테스트를 하기위해서는 Mac 호스트에 직접 Metricbeat 별도 설치가 필요했다.\n따라서 이에 대한 가이드를 아래에 별도로 정리하였다.\nMac OS에서의 Metricbeat 로컬 설치 curl -L -O https://artifacts.elastic.co/downloads/beats/metricbeat/metricbeat-8.14.3-darwin-x86_64.tar.gz tar xzvf metricbeat-8.14.3-darwin-x86_64.tar.gz cd metricbeat-8.14.3-darwin-x86_64 # metricbeat.yml 생성 vi metricbeat.yml 아래 내용 metricbeat.yml에 추가\nmetricbeat.modules: - module: system metricsets: - cpu # CPU usage - load # CPU load averages - memory # Memory usage - network # Network IO - process # Per process metrics - process_summary # Process summary - uptime # System Uptime - socket_summary # Socket summary - core # Per CPU core usage - diskio # Disk IO processes: [\u0026#39;.*\u0026#39;] process.include_top_n: by_cpu: 5 # include top 5 processes by CPU by_memory: 5 # include top 5 processes by memory output.elasticsearch: hosts: [\u0026#34;localhost:9200\u0026#34;] username: \u0026#34;metricbeat_internal\u0026#34; password: \u0026#34;{metricbeat 비밀번호(※초기 changeme로 설정한상태)}\u0026#34; setup.kibana: host: \u0026#34;localhost:5601\u0026#34; Metricbeat 실행 및 Kibana에서 메트릭 확인하기 # Metricbeat 실행 ./metricbeat -e Kiabana에서 시스템 메트릭 확인을 위한 인덱스 구성 및 차트 확인\nhttp://localhsot:5601 접속\n좌측 Stack Management \u0026gt; Data View \u0026gt; Create Data View\n인덱스 명: metricbeat (중요X)\n인덱스 패턴: metricbeat-* ← (MetricBeat가 기본적으로 사용하는 인덱스 패턴)\nTime field: @timestamp\nKibana 초기 화면 Dashboard \u0026gt; Create dashboard\u0026gt; Add visuallization \u0026gt; host.cpu.usage 필드를 차트로 끌어와보면 시스템 메트릭이 정상적으로 수집되고있는 것을 확인할 수 있다.\n","permalink":"https://jaylab.link/docker-elk-metricbeat/","summary":"앞서 Docker를 활용한 ELK 구성에 이어서, 이번에는 Metricbeat를 붙여보았다","title":"Docker ELK 구성에 Metricbeat 추가"},{"content":"✍🏻**ELK **구성 방법 중에서도 Docker를 활용한 방법으로 진행. 📎 Reference.\nhttps://github.com/deviantony/docker-elk\nhttps://blog.naver.com/gi_balja/223178660486\n📌 Contents.\nDocker 활용 ELK 구성 테스트 git clone git clone https://github.com/deviantony/docker-elk.git cd docker-elk .env 파일에 패스워드 설정부분 참고 나의 경우, Nginx에 대한 컨테이너를 추가로 구성. 기본 페이지 접속시 Access 로그를 Logstash를 통해 Elasticsearch로 전달하는 구성으로 커스텀해보았다.\ndocker-compose.yml 변경점 version: \u0026#39;3.7\u0026#39; services: # The \u0026#39;setup\u0026#39; service runs a one-off script which initializes users inside # Elasticsearch — such as \u0026#39;logstash_internal\u0026#39; and \u0026#39;kibana_system\u0026#39; — with the # values of the passwords defined in the \u0026#39;.env\u0026#39; file. It also creates the # roles required by some of these users. # # This task only needs to be performed once, during the *initial* startup of # the stack. Any subsequent run will reset the passwords of existing users to # the values defined inside the \u0026#39;.env\u0026#39; file, and the built-in roles to their # default permissions. # # By default, it is excluded from the services started by \u0026#39;docker compose up\u0026#39; # due to the non-default profile it belongs to. To run it, either provide the # \u0026#39;--profile=setup\u0026#39; CLI flag to Compose commands, or \u0026#34;up\u0026#34; the service by name # such as \u0026#39;docker compose up setup\u0026#39;. setup: profiles: - setup build: context: setup/ args: ELASTIC_VERSION: ${ELASTIC_VERSION} init: true volumes: - ./setup/entrypoint.sh:/entrypoint.sh:ro,Z - ./setup/lib.sh:/lib.sh:ro,Z - ./setup/roles:/roles:ro,Z environment: ELASTIC_PASSWORD: ${ELASTIC_PASSWORD:-} LOGSTASH_INTERNAL_PASSWORD: ${LOGSTASH_INTERNAL_PASSWORD:-} KIBANA_SYSTEM_PASSWORD: ${KIBANA_SYSTEM_PASSWORD:-} METRICBEAT_INTERNAL_PASSWORD: ${METRICBEAT_INTERNAL_PASSWORD:-} FILEBEAT_INTERNAL_PASSWORD: ${FILEBEAT_INTERNAL_PASSWORD:-} HEARTBEAT_INTERNAL_PASSWORD: ${HEARTBEAT_INTERNAL_PASSWORD:-} MONITORING_INTERNAL_PASSWORD: ${MONITORING_INTERNAL_PASSWORD:-} BEATS_SYSTEM_PASSWORD: ${BEATS_SYSTEM_PASSWORD:-} networks: - elk depends_on: - elasticsearch - kibana - logstash elasticsearch: build: context: elasticsearch/ args: ELASTIC_VERSION: ${ELASTIC_VERSION} volumes: - ./elasticsearch/config/elasticsearch.yml:/usr/share/elasticsearch/config/elasticsearch.yml:ro,Z - elasticsearch:/usr/share/elasticsearch/data:Z ports: - 9200:9200 - 9300:9300 environment: node.name: elasticsearch ES_JAVA_OPTS: -Xms512m -Xmx512m # Bootstrap password. # Used to initialize the keystore during the initial startup of # Elasticsearch. Ignored on subsequent runs. ELASTIC_PASSWORD: ${ELASTIC_PASSWORD:-} # Use single node discovery in order to disable production mode and avoid bootstrap checks. # see: https://www.elastic.co/guide/en/elasticsearch/reference/current/bootstrap-checks.html discovery.type: single-node networks: - elk restart: unless-stopped web: image: nginx:latest volumes: - web-logs:/var/log/nginx:Z # Nginx 로그 디렉토리 공유 networks: - elk depends_on: - logstash restart: unless-stopped ports: - 8081:80 logstash: build: context: logstash/ args: ELASTIC_VERSION: ${ELASTIC_VERSION} volumes: - ./logstash/config/logstash.yml:/usr/share/logstash/config/logstash.yml:ro,Z - ./logstash/pipeline:/usr/share/logstash/pipeline:ro,Z - ./logstash/input:/usr/share/logstash/input:rw,Z - web-logs:/var/log/nginx:Z # 새로운 볼륨 추가 ports: - 5044:5044 - 50000:50000/tcp - 50000:50000/udp - 9600:9600 environment: LS_JAVA_OPTS: -Xms256m -Xmx256m LOGSTASH_INTERNAL_PASSWORD: ${LOGSTASH_INTERNAL_PASSWORD:-} networks: - elk depends_on: - elasticsearch restart: unless-stopped kibana: build: context: kibana/ args: ELASTIC_VERSION: ${ELASTIC_VERSION} volumes: - ./kibana/config/kibana.yml:/usr/share/kibana/config/kibana.yml:ro,Z ports: - 5601:5601 environment: KIBANA_SYSTEM_PASSWORD: ${KIBANA_SYSTEM_PASSWORD:-} networks: - elk depends_on: - elasticsearch restart: unless-stopped networks: elk: driver: bridge volumes: elasticsearch: web-logs: basic 라이센스 적용 xpack.license.self_generated.type: trial -\u0026gt; basic elasticsearch.yml\n수정된 logstash.conf input { beats { port =\u0026gt; 5044 } tcp { port =\u0026gt; 50000 } file { path =\u0026gt; \u0026#34;/usr/share/logstash/input/test-input.csv\u0026#34; start_position =\u0026gt; \u0026#34;beginning\u0026#34; sincedb_path =\u0026gt; \u0026#34;/usr/share/logstash/input/sincedb_file\u0026#34; mode =\u0026gt; \u0026#34;tail\u0026#34; } file { path =\u0026gt; \u0026#34;/var/log/nginx/host.access.log\u0026#34; # 새로운 로그 파일 경로 start_position =\u0026gt; \u0026#34;beginning\u0026#34; sincedb_path =\u0026gt; \u0026#34;NUL\u0026#34; } } output { elasticsearch { hosts =\u0026gt; \u0026#34;elasticsearch:9200\u0026#34; user =\u0026gt; \u0026#34;elastic\u0026#34; password =\u0026gt; \u0026#34;${LOGSTASH_INTERNAL_PASSWORD}\u0026#34; index =\u0026gt; \u0026#34;test-input\u0026#34; } stdout { codec =\u0026gt; json_lines } } docker-compose up -d 원래는 nginx access 로그를 활성화해야하는 내용까지 적용해야하겠지만, 일단 다이렉트로 nginx 컨테이너 접속 후 access 로그 활성화\ndocker exec -it docker-elk-web-1 /bin/bash vi /etc/nginx/conf.d/default.conf `=============================================== #access_log /var/log/nginx/host.access.log main; ===============================================` # 위 내용 주석처리(\u0026#39;#\u0026#39;) 제거 후 저장 service nginx restart 이 후 localhost:8081 접속하여 host.access.log 파일에 액세스 로그 정상 확인.\nlocalhost:5601 (키바나) 접속 → Stack Management → Index management 접속\ntest-input 인덱스 → console → GET test-input/_search?pretty\n→ 액세스 로그 확인\n✅ 테스트 성공 이제 위 테스트 경험을 바탕으로 다른 애플리케이션 Docker Compose에 결합 테스트할 차례. (Nginx Proxy Manager 관련 애플리케이션)\nELK 통합 완료 통합 시점 수정된 파일목록 일부 결합 후 수정된 파일 목록\n주요 코드 ▼ kibana.yml\nserver.name: kibana server.host: 0.0.0.0 elasticsearch.hosts: [ http://elasticsearch:9200 ] monitoring.ui.container.elasticsearch.enabled: true monitoring.ui.container.logstash.enabled: true elasticsearch.username: kibana_system elasticsearch.password: ${KIBANA_SYSTEM_PASSWORD} ▼ logstash.conf\ninput { file { path =\u0026gt; \u0026#34;/data/logs/modsec_audit.log\u0026#34; start_position =\u0026gt; \u0026#34;end\u0026#34; sincedb_path =\u0026gt; \u0026#34;/data/logs/.sincedb_modsec_audit\u0026#34; type =\u0026gt; \u0026#34;modsecurity-audit\u0026#34; } } filter { if [type] == \u0026#34;modsecurity-audit\u0026#34; { grok { match =\u0026gt; { \u0026#34;message\u0026#34; =\u0026gt; \u0026#34;(?m)%{DATA:modsec_timestamp}.*%{DATA:transaction_id}.*%{GREEDYDATA:raw_data}\u0026#34; } } date { match =\u0026gt; [ \u0026#34;timestamp\u0026#34;, \u0026#34;dd/MMM/yyyy:HH:mm:ss Z\u0026#34; ] } } } output { elasticsearch { hosts =\u0026gt; [\u0026#34;elasticsearch:9200\u0026#34;] index =\u0026gt; \u0026#34;modsecurity-audit-%{+YYYY.MM.dd}\u0026#34; user =\u0026gt; \u0026#34;elastic\u0026#34; password =\u0026gt; \u0026#34;${LOGSTASH_INTERNAL_PASSWORD:-}\u0026#34; ssl =\u0026gt; false ssl_certificate_verification =\u0026gt; false } } ▼ docker-compose.yml\n# WARNING: This is a DEVELOPMENT docker-compose file, it should not be used for production. services: setup: profiles: - setup build: context: ../elk/setup/ args: ELASTIC_VERSION: ${ELASTIC_VERSION} init: true volumes: - ../elk/setup/entrypoint.sh:/entrypoint.sh:ro,Z - ../elk/setup/lib.sh:/lib.sh:ro,Z - ../elk/setup/roles:/roles:ro,Z environment: ELASTIC_PASSWORD: ${ELASTIC_PASSWORD:-} LOGSTASH_INTERNAL_PASSWORD: ${LOGSTASH_INTERNAL_PASSWORD:-} KIBANA_SYSTEM_PASSWORD: ${KIBANA_SYSTEM_PASSWORD:-} METRICBEAT_INTERNAL_PASSWORD: ${METRICBEAT_INTERNAL_PASSWORD:-} FILEBEAT_INTERNAL_PASSWORD: ${FILEBEAT_INTERNAL_PASSWORD:-} HEARTBEAT_INTERNAL_PASSWORD: ${HEARTBEAT_INTERNAL_PASSWORD:-} MONITORING_INTERNAL_PASSWORD: ${MONITORING_INTERNAL_PASSWORD:-} BEATS_SYSTEM_PASSWORD: ${BEATS_SYSTEM_PASSWORD:-} networks: - nginx_proxy_manager depends_on: - elasticsearch - kibana - logstash npm: image: nginxproxymanager:dev ..(생략).. volumes: - npm_data:/data - le_data:/etc/letsencrypt - ../backend:/app - ../frontend:/app/frontend - ../global:/app/global depends_on: - db working_dir: /app db: ..(생략).. elasticsearch: build: context: ../elk/elasticsearch/ args: ELASTIC_VERSION: ${ELASTIC_VERSION} init: true volumes: - ../elk/elasticsearch/config/elasticsearch.yml:/usr/share/elasticsearch/config/elasticsearch.yml:ro,Z - elasticsearch_data:/usr/share/elasticsearch/data:Z # ! container_name: elasticsearch environment: node.name: elasticsearch ES_JAVA_OPTS: -Xms512m -Xmx512m ELASTIC_PASSWORD: ${ELASTIC_PASSWORD:-} discovery.type: single-node ports: - 9200:9200 networks: - nginx_proxy_manager logstash: build: context: ../elk/logstash/ args: ELASTIC_VERSION: ${ELASTIC_VERSION} container_name: logstash volumes: - ../elk/logstash/config/logstash.yml:/usr/share/logstash/config/logstash.yml:ro,Z - ../elk/logstash/pipeline:/usr/share/logstash/pipeline:ro,Z - npm_data:/data:rw ports: - 5044:5044 networks: - nginx_proxy_manager depends_on: - elasticsearch environment: LS_JAVA_OPTS: -Xms256m -Xmx256m LOGSTASH_INTERNAL_PASSWORD: ${LOGSTASH_INTERNAL_PASSWORD:-} kibana: build: context: ../elk/kibana/ args: ELASTIC_VERSION: ${ELASTIC_VERSION} container_name: kibana volumes: - ../elk/kibana/config/kibana.yml:/usr/share/kibana/config/kibana.yml:ro,Z ports: - 5601:5601 environment: KIBANA_SYSTEM_PASSWORD: ${KIBANA_SYSTEM_PASSWORD:-} networks: - nginx_proxy_manager depends_on: - elasticsearch volumes: npm_data: name: npm_core_data le_data: name: npm_le_data db_data: name: npm_db_data elasticsearch_data: driver: local name: elk_es_data networks: nginx_proxy_manager: name: npm_network driver: bridge 최종 결합 완료 및 실행 docker compose -f docker-compose.yml --profile setup up 명령 실행 결과:\n(몇가지 시행착오가 있었지만 최종적으로 정상 실행 완료된 로그)\nNginx Proxy Manager 관리자 페이지:\nhttp://localhost:3081 → ✅ 정상\nKibana 페이지:\nhttp://localhost:5601 → ✅ 정상\n→ ✅ logstash를 통해 modsec log 수집 성공\n키바나에서 로그 확인을 위한 추가설정 가이드 모든 서비스가 정상적으로 실행 완료되면, localhost:3080 (추후 npm proxy 서버를 거쳐갈 트래픽 대상 서비스) 접속에 대한 modesecurity audit 로그는 elasticsearch에 저장되고있는 상황 키바나 접속(http://localhost:5601)초기 계정 정보 ID: elastic\nPASSWORD: changeme\n좌측 네비게이션 \u0026gt; Stack Management \u0026gt; Data view \u0026gt; Create Data View 인덱스 명: (아무 이름)\n인덱스 패턴: modsecurity-audit-*\n시간 필드: @timestamp\n→ ✅ 인덱스 생성 완료\nKibana 초기 화면 Discover 메뉴에서 http://localhost:3080 접속에 대한 로그 확인 및 Visualize 메뉴에서 차트 구성 가능\nComments 진행 간에 로그인 페이지 없는 ELK 구성은 빨리 구현할 수 있었다. 하지만 위와 같이 로그인 페이지를 붙이는 과정은 조금 달랐다.\n“1️⃣Docker 컨테이너 활용 + 2️⃣ 다른 애플리케이션에 결합”하는 조건과 동일하게 진행된 레퍼런스를 찾지 못했고, 이 때문에 직접 이런저런 소스 분석하며 결합해보고 테스트 하느라 시간이 제법 소요된 작업이었다. ✅ 아무튼 성공.\n","permalink":"https://jaylab.link/elk-for-docker-setting/","summary":"ELK 구성 방법 중에서도 Docker를 활용한 방법으로 진행해보았다.","title":"Docker를 활용한 ElasticSearch, Logstash, Kibana 구성"},{"content":"Nginx Proxy Manager Nginx 기반 웹 기반의, 인터페이스를 통해 사용자가 간단하게 리버스 프록시를 설정하고 관리할 수 있도록 도와주는 도구\n리버스 프록시? 트래픽이 요청되는 출발점으로 부터 외부망 사이에 위치한 프록시 서버를 포워드 프록시 클라이언트의 요청을 받아 적절한 서버로 전달하고, 서버의 응답을 클라이언트에 반환하는 중개 서버가 바로 리버스 프록시(쉽게 이해하자면, 백엔드 서버 쪽 망에 위치한 중개서버)\nReference Github: https://github.com/NginxProxyManager/nginx-proxy-manager\nOfficial Docs: https://nginxproxymanager.com/\nQuick Setup (사전에 docker가 설치되어있다고 가정)\ndocker-compose.yml 작성\nversion: \u0026#39;3.8\u0026#39; services: app: image: \u0026#39;jc21/nginx-proxy-manager:latest\u0026#39; restart: unless-stopped ports: # These ports are in format \u0026lt;host-port\u0026gt;:\u0026lt;container-port\u0026gt; - \u0026#39;80:80\u0026#39; # Public HTTP Port - \u0026#39;443:443\u0026#39; # Public HTTPS Port - \u0026#39;81:81\u0026#39; # Admin Web Port # Add any other Stream port you want to expose # - \u0026#39;21:21\u0026#39; # FTP # Uncomment the next line if you uncomment anything in the section # environment: # Uncomment this if you want to change the location of # the SQLite DB file within the container # DB_SQLITE_FILE: \u0026#34;/data/database.sqlite\u0026#34; # Uncomment this if IPv6 is not enabled on your host # DISABLE_IPV6: \u0026#39;true\u0026#39; volumes: - ./data:/data - ./letsencrypt:/etc/letsencrypt docker-compose.yml\ndocker compose up -d 입력하여 간단하게 Nginx Proxy Manager + MariaDB 구성 완료.\n그런데 Nginx Proxy Manger 관리자 페이지에서 이뤄지는 데이터를 저장할 DB를 따로 구성해야하는게 일반적이다. 이 또한 간단히하기 위해 공식문서에서는 MySQL관련 컨테이너 구성도 아래와 같이 추가 설명하고있다.\ndocker-compose.yml (MySQL 추가 구성 버전)\nversion: \u0026#39;3.8\u0026#39; services: app: image: \u0026#39;jc21/nginx-proxy-manager:latest\u0026#39; restart: unless-stopped ports: # These ports are in format \u0026lt;host-port\u0026gt;:\u0026lt;container-port\u0026gt; - \u0026#39;80:80\u0026#39; # Public HTTP Port - \u0026#39;443:443\u0026#39; # Public HTTPS Port - \u0026#39;81:81\u0026#39; # Admin Web Port # Add any other Stream port you want to expose # - \u0026#39;21:21\u0026#39; # FTP environment: # Mysql/Maria connection parameters: DB_MYSQL_HOST: \u0026#34;db\u0026#34; DB_MYSQL_PORT: 3306 DB_MYSQL_USER: \u0026#34;npm\u0026#34; DB_MYSQL_PASSWORD: \u0026#34;npm\u0026#34; DB_MYSQL_NAME: \u0026#34;npm\u0026#34; # Uncomment this if IPv6 is not enabled on your host # DISABLE_IPV6: \u0026#39;true\u0026#39; volumes: - ./data:/data - ./letsencrypt:/etc/letsencrypt depends_on: - db db: image: \u0026#39;jc21/mariadb-aria:latest\u0026#39; restart: unless-stopped environment: MYSQL_ROOT_PASSWORD: \u0026#39;npm\u0026#39; MYSQL_DATABASE: \u0026#39;npm\u0026#39; MYSQL_USER: \u0026#39;npm\u0026#39; MYSQL_PASSWORD: \u0026#39;npm\u0026#39; MARIADB_AUTO_UPGRADE: \u0026#39;1\u0026#39; volumes: - ./mysql:/var/lib/mysql docker-compose.yml (MySQL 컨테이너 추가 구성버전)\n⚠️공식문서 일부 내용: Please note that the jc21/mariadb-aria:latest image might have some problems on some ARM devices, if you want a separate database container, use the yobasystems/alpine-mariadb:latest image. 컨테이너가 구성 완료되고나면 localhost:81에 접속하여 관리자 화면이 정상적으로 뜨는걸 볼수있음\n초기 계정 정보 Email: admin@example.com / Password: changeme\nCustomization 나는 이 오픈소스를 좀 더 커스텀해보기로.\n그러기 위해 위 절차와는 다르게 소스코드 clone 및 수정 후 Nginx Proxy Manager를 실행할 수 있어야했는데, 그 어디에서도 이를 위한 가이드를 찾을 수 없었다.\n결국 직접 소스코드 분석. 아래의 내용들을 확인할 수 있었다.\nfrontend, backend에 대한 디렉토리 구성. 그 내부에 각각 다른 package.json 파일 존재\nyarn.lock 파일 존재 ⇒ 패키지 설치 매니저 npm 보단 yarn이 사용되는 것으로 추측 npm install시 에러. yarn install 시 패키지 설치 정상 확인.\ndocker 내부에 각종 docker-compose 파일 확인.\ndocker-compose.dev.yml 파일 기준으로 도커 컨테이너 실행하는것으로 추측.\ndocker compose -f docker-compose.dev.yml up -d 실행해보니 해당 파일 기반으로 컨테이너 구성 후 dev 디렉토리 Dockerfile을 읽고 컨테이너 내부 후속처리가 이루어지는 것을 확인.\n컨테이너 내부에서 yarn install 실행 확인 → frontend, backend 각각에서 직접 패키지 설치 필요 X\nrootfs 디렉토리의 폴더가 npm_core 컨테이너에 마운트 ←서버 설정을 여기서 컨트롤 해볼 수 있겠다.\n","permalink":"https://jaylab.link/npm-official-setup-and-customization/","summary":"커스텀을 위해 코드 수정 후 Nginx Proxy Manager를 정상적으로 실행할 수 있어야했는데, 그 어디에서도 이를 위한 가이드를 찾을 수 없었다. 결국 직접 소스코드 분석 후 다음과 같은 내용들을 확인했다.","title":"Nginx Proxy Manager - Official Guide \u0026 Local Setup for Customization"},{"content":"※ Amazon Aurora MySQL 버전 2 (MySQL 5.7 호환) 표준 지원 종료 날짜: 2024년 10월 31일\nAWS Aurora MySQL 버전 업그레이드 작업 전 알아두면 좋을 내용들을 정리하였다.\n배포방식 1️⃣ In-Place 배포 방식 현재 데이터베이스를 다이렉트로 업그레이드 하는 작업이기에 다운타임 발생\n2️⃣ Blue/Green 배포 방식 프로덕션 환경(블루)에 영향을 주지 않고 스테이징 환경(그린)에서 DB 변경(또는 업그레이드)\n이 후 그린환경이라는 복제본과 블루 환경을 교체하는 방식이기에 무중단으로 배포해야하는 환경에서 우선적으로 고려해볼 수 있는 배포방식. (그렇다고해서 다운타임이 전혀 없지는 않고 약 2분가량의 다운타임 발생)\n일단 그린 환경의 복제본이 생성되고나면 두 환경 간 스위칭을 하지 않으면 블루환경에서 발생되는 변경사항은 그린 환경에 그대로 동기화 된다. 최종적으로 블루 그린 환경 간 스위칭을 시작하면 동기화가 모두 완료될때까지 쓰기작업이 중단된다. 따라서 이 때 발생되는 다운타임을 최소화 하기위해서 Switch Over 작업은 트래픽이 가장 적은 시간대에 수행하는 것을 권장하고있다.\n위에서 말한 ‘동기화 작업’을 위해 Aurora MySQL은 기본적으로 binlog_format 파라미터가 ROW로 활성화 되어있어야한다. 따라서 “블루그린 배포” 생성 전 해당 파라미터를 수정하고, 이 파라미터가 잘 적용되도록 하기 위해 인스턴스 재부팅을 해야한다. 즉, 이 때 첫번째 다운타임 발생.\n파라미터 설정 주요포인트를 정리하였다.\n파라미터 그룹은 기본적으로 클러스터 파라미터 그룹과 인스턴스 파라미터 그룹 2가지로 분류.\n즉, 기존의 5.7버전 DB의 클러스터에는 5.7버전의 클러스터 파라미터 그룹, 인스턴스에는 5.7버전의 인스턴스 파라미터 그룹이 설정되어있는 상태.\nDB 메이저 업그레이드 작업은 이 각각의 파라미터 그룹도 8.0 버전 파라미터 그룹으로의 교체까지도 포함된다.\n처음에는 클러스터와 인스턴스 파라미터 그룹이 Default 버전으로 설정 (클러스터 생성시 별도의 사용자 정의 파라미터그룹을 지정하지 않으면 Default로 설정되는 파라미터 그룹이며, 삭제 불가능한 파라미터 그룹)\n클러스터 파라미터 그룹에 있는 Param이 인스턴스 파라미터 그룹에도 있는 것을 볼 수 있는데, 이 때 인스턴스 파라미터 그룹이 더 우선순위를 가지고 있기 때문에 클러스터 파라미터 그룹의 Param은 무시된다.\n만약 Default가 아닌 **‘사용자 정의 클러스터 파라미터 그룹’**을 클러스터에 적용하면 이 클러스터 파라미터 그룹 내 Param이 더 우선권을 가진다. (인스턴스 파라미터 그룹의 중복되는 Param은 무시)\n그리고 다시 인스턴스에도 Default가 아닌 사용자가 생성한 ‘사용자 정의 인스턴스 파라미터 그룹’을 적용하면 당연히 이 파라미터 그룹이 또 다시 우선권을 가진다.\n즉, 파라미터 그룹 우선순위를 요약하면 Default Cluster PG \u0026lt; Default Instance PG \u0026lt; Custom Cluster PG \u0026lt; Custom Instance PG\n위 내용은 DB 인스턴스 마다 서로 다른 파라미터 그룹을 설정할 수 있도록 설계되었다고 생각하면 이해하기 쉽다. 이것을 이해하고나면 5.7 파라미터 그룹의 내용을 기반으로 8.0 파라미터 그룹을 어떻게 생성 및 반영할지 계획해 볼 수 있다.\n클러스터 및 인스턴스 파라미터 그룹 각각에서,\n적용된 5.7 파라미터 그룹이 Default인지 아닌지 확인\nDefault 파라미터 그룹 대비 어떤 Parameter가 변경되어있는지 확인 → 값이 다른 파라미터는 사용자 정의된 파라미터\n이 파라미터가 Default 8.0 버전 파라미터 그룹에도 존재하는 경우 사용자 정의된 값으로 적용\n위에서도 언급했지만, Blue/Green 배포방식으로 업그레이드 하는경우, binlog_format 파라미터를 ROW로 수정 후 재부팅해야한다.\n5.7버전 파라미터 그룹을 보면 utf8 관련된 파라미터가 다음과 같이 있다. uft8은 “utf8mb3”의 별칭이며, 이 utf8(utf8mb3) 타입은 8.0.x과 8.4.x LTS release 버전까지만 지원될 예정이며, 향후 주요 릴리스에서 제거 예정이라고 안내되고있다.(관련내용 공식문서) 따라서 이에 대비하기 위해 8.0 업그레이드 작업은 utf8mb4로 설정하여 테스트 진행했고, 이상 없는 것을 확인 후 실제 운영싸지 안전하게 utf8mb4 타입으로 적용되었다. 두 타입은 내부적으로 한 문자를 표현하는데 사용되는 byte 크기가 다음과 같은 차이가 있다. **• utf8: 3 byte • utf8mb: 4 byte **emoji가 사용되는 서비스, 그리고 외국어가 사용되는 서비스에서 특히 utf8mb4 사용이 권장되고있는데, utf8 타입 DB에 이러한 데이터가 저장되어있는 DB를 갑자기 utf8mb4로 변경 및 업그레이드하는 경우 문제가 될 수 있으니 이 부분 주의해서 충분한 테스트가 필요하다고했다.\nevent_scheduler 파라미터는 5.7과 8.0 모두 기본적으로 ‘-’(Dynamic)로 설정되어있는데, 이게 5.7에서는 false(비활성) 상태이며 8.0에서는 true(활성) 상태로 적용된다는 내용이 있었다. 실제로 각 버전 DB에서 SHOW`` VARIABLES ``LIKE`` ``'event%'``; 명령을 실행해보면 다른 결과값을 출력하고있다. 이에 따라 8.0에서는 SHOW PROCESSLIST; 실행시 아래와 같은 Daemon이 계속해서 대기중인 것을 볼 수 있었다. ROLLBACK 최근 참석했던 ‘**Aurora MySQL 2 End of Life (EOL) 웨비나’**에서도 현재로써는 롤백을 위한 서비스가 아직까진 지원되고있지 않다는 얘기를 들었다. 다른 여러 문서도 찾아보았지만 결국 블루/그린 배포 방식은 운영 중인 데이터베이스 업그레이드시 발생되는 다운타임을 최소화하기 위한 방법일 뿐 롤백을 더 수월하게 할 수 있도록 지원해주지는 않는다.\n어쩔수 없지만 번거롭더라도 업그레이드 전 다음과 같은 롤백 프로세스 세워두었다.\n업그레이드 전 복제본 생성(백업) → 일종의 백업이자 롤백시 사용될 DB 클러스터\nIn Place 방식이라면, 업그레이드 전 “복제본 생성”\nBlue/Green 배포 방식이라면, Switch Over 후의 기존의 Blue 환경이 곧 롤백용 클러스터 (기존의 Blue 환경은 스위칭 되고나면 Blue 클러스터명 뒤에 -old가 추가된다)\n업그레이드 후 문제가 있다고 판단되어 롤백 해야하는 경우: 업그레이드된 DB 클러스터 명을 ‘A’, 위에서 언급한 백업용 클러스터 명을 ‘B’라고 할 때, A 클러스터 명을 A-bak 으로 수정, B 클러스터 명을 A로 수정 (애플리케이션이 바라보는 클러스터의 엔드포인트를 기존의(B) DB클러스터가 다시 갖도록 교체하는 작업)\n이렇게 하면 기존의 ‘A’ DB 클러스터 엔드포인트를 바라보고있던 애플리케이션에서 코드 수정 없이 DB 롤백이 되도록 의도할 수 있다.\n물론 이 방법은 다음과 같은 문제가 있다.\n클러스터 명을 전환하는 동안의 다운타임.\n업그레이드 후 롤백 시점 동안에 발생된 변경사항 반영 X → 서비스의 종류에 따라 다르지만 사실상 크리티컬한 문제.. 변경 사항을 롤백용 DB에 동기화 될 수 있도록 별도 방법만 구축해놓는다면 더 완벽할텐데 이 내용은 추후 더 찾아볼 예정\n업그레이드 후기 1. 아쉬움 아쉬움이 남는 부분도 있었다. 작업 대상 DB와 동일한 인프라 내에 있는 애플리케이션에서는 DB에 붙기 위해 RDS 엔드포인트를 하드코딩으로, 다이렉트로 바라보고있었다. 그것도 다양한 모듈과 매우 많은 코드에서. 이 프로젝트 지원에 투입 된지 얼마 안되었기에 애플리케이션 구성을 전부 알지 못하는 상태였고, 수정시 영향도를 가늠할 수 없었다. Prd DB 업그레이드 작업 기간도 넉넉하지 않아 더욱 더 인프라를 함부로 손 볼 수 없었다.\n만약 작업기간도 충분하고 인프라 변경도 할 수 있었다면, ELB와 PrivateLink을 구성하여 트래픽을 제어하고 보다 좋은 사전테스트를 해볼 수 있었을텐데 하는 아쉬움이 남았다. (속도 \u0026amp; 보안 개선도 할 수 있었을텐데)\n2. 고객사 측에서 RDS의 Private IP 사용여부 이번에 업그레이드 대상 고객사는 고객사 측에서 사용하는 가상환경 안에서 Aurora MySQL DB에 연결시 엔드포인트가 아니라 IP를 통해서 연결하고있었다. 참고로 RDS의 IP는 동적으로 변한다. 그래서 AWS에서도 IP가 아니라 엔드포인트로 DB 연결하는 것을 모범사례라고 설명하고있다.\n그럼에도 고객사에서 IP를 사용한 이유는 통신장비에서 RDS 엔드포인트 형식의 값을 사용할 수 없어서라는 이유를 들었다. 그래서 콘솔에서는 확인할 수도 없는 Private IP를 아마도 지금까지 nslookup 같은 명령으로 찾아서 사용해왔던 것 같다. 지금까지 운좋게 Private IP가 바뀐적 없이 운영되어 왔지만, 결국 이번 업그레이드 작업 때문에 IP가 변경되면서 접속문제가 발생하긴 했었다(다행히 크리티컬하진 않은).\n따라서, 다음 업그레이드 작업시에는 고객사 측 환경에서 RDS 연결시 IP를 사용하고 있지는 않은지 확인해보는 것도 체크포인트가 될 수 있겠다. 이 경우 고객사에게 IP가 변경될 수 있다는 점을 안내하면서 별도의 프록시 서버 구성을 제안하는 것도 방법이 될 수 있다.\n※ 변경까지 걸린 시간 약 200GB 크기의 스토리지, db.r6.xlarge 사이즈 Aurora MySQL의 복제본 생성: 약 10분\nDB의 버전 업그레이드: 약 20분\n재부팅: 약 1분\n","permalink":"https://jaylab.link/aurora-mysql-upgrade-8-key-points/","summary":"AWS Aurora MySQL 버전 업그레이드 작업 전 알아두면 좋을 내용들을 정리하였다.","title":"Aurora MySQL 업그레이드 5.7 to 8.0, 작업 전 알아두면 좋았을 주요 포인트"},{"content":"Contents.\nDocker build 에러 발생 docker를 사용하다보니 다음과 같은 에러를 겪었다. exec user process caused 'exec format error'\n이 오류는 주로 운영체제 아키텍처 차이로 인해 발생한다. 예를 들어, M1 Mac에서 빌드한 이미지를 ARM 기반 서버에서 실행하려 할 때 이러한 문제가 발생할 수 있다.\n**해결 방법: **Docker Buildx Docker Buildx는 다양한 아키텍처에서 실행 가능한 이미지를 빌드할 수 있는 Docker CLI이다. 이를 활용하면 특정 아키텍쳐에서 지원하는 이미지로 빌드할 수 있다.\nBuildx 사용 준비 Docker 버전 확인: Buildx는 Docker 19.03 이상에서 사용 가능\nDocker Desktop 사용자: Buildx 기본 포함\n일부 하위 버전: ~/.docker/config.json 파일에 \u0026quot;experimental\u0026quot;: \u0026quot;enabled\u0026quot; 옵션 추가 필요\nBuildx 사용법 빌더 생성 Docker Buildx를 사용하여 멀티 아키텍처 이미지를 빌드하기 위해서는 먼저 새로운 빌더 인스턴스 생성 필요\ndocker buildx create --name multiarch-builder --driver docker-container --use docker buildx create 새로운 빌더 인스턴스를 생성하는 기본 명령어\n--name multiarch-builder 생성할 빌더의 이름을 multiarch-builder로 지정하는 내용(다른 이름으로 변경 가능)\n--driver docker-container 빌더가 사용할 드라이버 지정. \u0026ldquo;docker-container\u0026rdquo; 드라이버는 새로운 Docker 컨테이너 내에서 빌드 프로세스를 실행. 이는 호스트 시스템과 격리된 환경에서 빌드를 수행할 수 있게 해준다\n--use 이 옵션은 새로 생성된 빌더를 즉시 현재 활성 빌더로 설정합니다. 이렇게 하면 추가 명령 없이 바로 새 빌더를 사용할 수 있다\n멀티 아키텍처 이미지 빌드 docker buildx build --platform=linux/amd64,linux/arm64 -t your-image:tag --push . 주요 옵션 설명 --platform: 빌드하려는 이미지가 지원해야 할 아키텍처 명시 (예: linux/amd64, linux/arm64)\n--push: 이미지 빌드 후 설정되어있는 Docker 레지스트리로 push\n빌드 결과 확인 빌드된 이미지의 아키텍처는 다음 명령어로 확인할 수 있다\ndocker exec -it container-name dpkg -s libc6 | grep Arch\n이 방법을 통해 다양한 아키텍처에서 실행 가능한 Docker 이미지를 생성할 수 있으며, \u0026ldquo;exec format error\u0026rdquo; 문제를 해결할 수 있습니다.\n컨테이너의 아키텍쳐 확인 docker exec [container-name] uname -m 일반적인 결과 값 예시\nx86_64: 64비트 x86 아키텍처 (Intel 또는 AMD 프로세서)\naarch64: 64비트 ARM 아키텍처 (예: Apple M1, Amazon Graviton)\narmv7l: 32비트 ARM 아키텍처\ni686: 32비트 x86 아키텍처\nppc64le: 64비트 PowerPC 아키텍처 (리틀 엔디안)\ns390x: IBM System z 아키텍처\n📌 나의 실무 적용 사례 나는 로컬에서 buildx를 통해 특정 아키텍처로 image 빌드 및 push를 진행 하기도 했지만, 이보다 더 중요한 것은 이를 활용하여 Github Actions에 적용 후 가상머신이 스스로 buildx를 사용하여 Build \u0026amp; Push 되도록 하는 것이었다.\n이를 활용하여 실제로 내가 실무에 적용했던 워크플로우 구성파일 일부는 다음과 같다.\n..(생략).. docker: needs: build runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - name: Download artifact uses: actions/download-artifact@v4 with: name: ${{env.APP_NAME}}-application-jar path: target/ # OIDC 인증 - name: configure aws credentials uses: aws-actions/configure-aws-credentials@v3 with: role-to-assume: ${{secrets.AWS_OIDC_ROLE}} role-session-name: ecrRoleSession aws-region: ${{secrets.AWS_REGION}} # ECR 로그인 - name: Login to Amazon ECR id: login-ecr uses: aws-actions/amazon-ecr-login@v2 # Docker 이미지 빌드 \u0026amp; ECR에 Push - name: Set up Docker Buildx uses: docker/setup-buildx-action@v2 - name: Build and push Docker image uses: docker/build-push-action@v3 with: context: . file: ./Dockerfile push: true tags: ${{ secrets.AWS_ECR_URL }}/${{ env.APP_NAME }}:${{ env.BRANCH_NAME }}-${{ github.sha }} platforms: linux/amd64 update-deployment: needs: docker runs-on: ubuntu-latest ..(생략).. .github/workflows/application-aws.yml\n","permalink":"https://jaylab.link/docker-buildx-multiarch-and-git-actions/","summary":"Docker Buildx는 다양한 아키텍처에서 실행 가능한 이미지를 빌드할 수 있는 Docker CLI이다. 이를 활용하면 특정 아키텍쳐에서 지원하는 이미지로 빌드할 수 있다.","title":"Docker Buildx를 사용한 멀티 아키텍처 이미지 빌드, Github Actions 적용 사례"},{"content":"❓Poetry: 패키지 의존성 관리, 가상 환경 제공, 프로젝트 시작을 쉽게 만들어주는 도구 📌 Content.\n기존 Python 패키지 관리의 문제점 Python 패키지 관리를 위해서는 보편적으로 requirements.txt라는 파일을 사용된다. 다만 이 파일은 필요한 패키지의 목록만 단순하게 나열한 느낌. 개발에 필요한 패키지 vs 실제 배포에 필요한 패키지를 명확히 구분하기 힘들어 나중에는 관리가 복잡해질 수 있다. 또한 서로 다른 패키지가 동일한 라이브러리의 다른 버전을 요구할 때 의존성 충돌이 발생할 수도 있다.\n여기서 등장한 Poetry\n주요 기능 pyproject.toml 파일: 프로젝트의 메타데이터와 의존성을 관리하는 파일 (모든 정보를 이 파일 한 곳에 저장)\n패키지 구분: 개발용 패키지와 배포용 패키지를 구분할 수 있어 프로젝트 관리가 간편해진다.\npoetry.lock 파일: 정확한 의존성 버전을 기록하여, 팀원 간의 환경 차이를 줄이고 안정성을 높일 수 있다.\n장점 명확한 구분: 개발 환경과 배포 환경을 명확히 구분하여, 혼란을 줄일 수 있다.\n의존성 문제 해결: 의존성 충돌 문제를 효과적으로 관리할 수 있게된다.\nPoetry 사용 방법 Poetry 설치 터미널에서 다음 명령어 실행(Mac OS 기준)\ncurl -sSL https://install.python-poetry.org | python3 - **윈도우에서의 설치방법** (Invoke-WebRequest -Uri https://install.python-poetry.org -UseBasicParsing).Content | py - 설치가 완료된 후에는, 쉘을 재시작하거나 설치 스크립트에서 제공하는 명령어를 실행하여 설치 완료 필요\nPoetry 최신 버전으로 업데이트 poetry self update 프로젝트 시작 Poetry로 새 프로젝트를 시작하는 방법 두 가지\n기존 디렉토리 초기화: poetry init 새 디렉토리와 기본 구조 자동 생성: poetry new 프로젝트이름 패키지 관리 패키지 추가: poetry add 패키지명 개발 의존성으로 추가: poetry add --dev 패키지명 특정 버전 패키지 추가: poetry add requests@2.25.0 패키지 삭제: poetry remove 패키지명 가상환경 관리 가상 환경 활성화: poetry shell 비활성화: deactivate 의존성 내보내기 프로젝트의 의존성을 requirements.txt 파일로 내보내고 싶을 때\npoetry export -f requirements.txt --output requirements.txt 프로젝트 빌드 및 배포 빌드 poetry build PyPI에 배포 poetry publish 🙏🏻**참고: **https://teddylee777.github.io/poetry/poetry-tutorial/ ","permalink":"https://jaylab.link/python-poetry-basic/","summary":"패키지 의존성 관리와 가상 환경 생성 및 관리를 위한 방법.","title":"Poetry를 활용한 Python 프로젝트 관리"},{"content":"ArgoCD Application 생성시 에러 발생 ▼ Error message Unable to create application: application spec for saassss is invalid: InvalidSpecError: Unable to get app details: rpc error: code = Internal desc = Failed to checkout FETCH_HEAD: git checkout --force FETCH_HEAD failed exit status 128: fatal: Unable to create ‘..git/index.lock’: File exists. Another git process seems to be running in this repository, e.g. an editor opened by ‘git commit’. Please make sure all processes are terminated then try again. If it still fails, a git process may have crashed in this repository earlier: remove the file manually to continue.\n참고 링크 https://github.com/argoproj/argo-cd/issues/7898\nhttps://github.com/argoproj/argo-cd/issues/17623\n해결 kubectl apply -n argocd -f https://raw.githubusercontent.com/argoproj/argo-cd/stable/manifests/install.yaml ","permalink":"https://jaylab.link/argocd-index-lock-error/","summary":"\u003ch4 id=\"argocd-application-생성시-에러-발생\"\u003eArgoCD Application 생성시 에러 발생\u003c/h4\u003e\n\u003cblockquote\u003e\n\u003cp\u003e\u003cstrong\u003e▼ Error message\u003c/strong\u003e\nUnable to create application: application spec for saassss is invalid: InvalidSpecError: Unable to get app details: rpc error: code = Internal desc = Failed to checkout FETCH_HEAD: \u003ccode\u003egit checkout --force FETCH_HEAD\u003c/code\u003e failed exit status 128: fatal: Unable to create ‘..git/index.lock’: File exists. Another git process seems to be running in this repository, e.g. an editor opened by ‘git commit’. Please make sure all processes are terminated then try again. If it still fails, a git process may have crashed in this repository earlier: remove the file manually to continue.\u003c/p\u003e","title":"[ArgoCD] Error: Unable to create ‘..git/index.lock’: File exists"},{"content":"Github Actions을 사용하여 멀티플랫폼이 지원되는 이미지 빌드 방법과 Docker Hub 혹은 ECR에 Push하는 방법. 그리고 제 3의 Git Repo 파일 내용 변경 방법을 정리해보았다.\n- Contents -\nGithub Actions 워크플로 yaml 파일 작성 ⏤ Actions를 통해서 Java Application 빌드시, 다음과 같은 로직이 필요했다.\nApplication을 Docker image로 build 후 ECR Private repository에 image push\n위 Repo와는 별개의 Kubernetes manifest 관련 Github repository 특정 파일 image tag 갱신\n결국 Manifest 파일 변경 발생 → ArgoCD에 감지되어 Kubernetes Cluster에 Sync 동작\n위 내용대로 Actions를 구현하였고 그 내용을 다음과 같이 기록하였다.\n**Spring boot application repository \u0026gt; **.github/workflows/action.yml\n※ Github Actions에서 ECR에 접근할 수 있도록 OIDC 방식 사용.\nname: saas-oil application deploy on: push: branches: [ \u0026#34;develop\u0026#34; ] pull_request: branches: [ \u0026#34;develop\u0026#34; ] permissions: id-token: write # This is required for requesting the JWT env: APP_NAME: saas-oil BRANCH_NAME: develop jobs: build: runs-on: ubuntu-latest steps: - uses: actions/checkout@v3 - name: Set up JDK 11 uses: actions/setup-java@v4 with: java-version: \u0026#39;11\u0026#39; distribution: \u0026#39;temurin\u0026#39; cache: maven - name: Build with Maven run: mvn -B package --file pom.xml - name: Upload artifact uses: actions/upload-artifact@v4 with: name: ${{env.APP_NAME}}-application-jar path: target/*.jar - name: List files in the working directory - 1 run: ls target/ docker: needs: build runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - name: Download artifact uses: actions/download-artifact@v4 with: name: ${{env.APP_NAME}}-application-jar path: target/ # OIDC 인증 - name: configure aws credentials uses: aws-actions/configure-aws-credentials@v3 with: role-to-assume: ${{secrets.AWS_OIDC_ROLE}} role-session-name: ecrRoleSession aws-region: ${{secrets.AWS_REGION}} # ECR 로그인 - name: Login to Amazon ECR id: login-ecr uses: aws-actions/amazon-ecr-login@v2 # Docker 이미지 빌드 \u0026amp; ECR에 푸시 - name: Build Docker image And Push to AWS ECR run: | docker build . -t ${{ secrets.AWS_ECR_URL }}/${{ env.APP_NAME }}:${{ env.BRANCH_NAME }}-${{ github.sha }} docker push ${{ secrets.AWS_ECR_URL }}/${{ env.APP_NAME }}:${{ env.BRANCH_NAME }}-${{ github.sha }} update-deployment: needs: docker runs-on: ubuntu-latest steps: - name: Checkout Kubernetes manifest repository uses: actions/checkout@v2 with: repository: ${{ vars.SAAS_KUBE_REPOSITORY }} token: ${{ secrets.SAAS_GITHUB_TOKEN }} ref: ${{ env.BRANCH_NAME }} - name: Update k8s oil file run: | sed -i \u0026#39;s|${{ secrets.AWS_ECR_URL }}/.*|${{ secrets.AWS_ECR_URL }}/${{ env.APP_NAME }}:${{ env.BRANCH_NAME }}-${{ github.sha }}|\u0026#39; kube/${{env.BRANCH_NAME}}/${{env.APP_NAME}}.yaml git config user.name ${{ vars.SAAS_GITHUB_EMAIL }} git config user.email ${{ vars.SAAS_GITHUB_NAME }} git add kube/${{env.BRANCH_NAME}}/${{env.APP_NAME}}.yaml git commit -m \u0026#34;Update image tag to ${{ github.sha }}\u0026#34; git push 📌 image build와 동시에 push하는 방법은 다음과 같이 더 간단하게 할 수도 있다.\ndocker buildx build --platform=linux/and64, 11nux/arm64 -t YOUR_DOCKERHUB_ID/YOUR_TAG_NAME . --push\n빌드 명령어 뒤에 --push 명령어를 붙여주면, 별도로 push 명령을 하지 않아도 빌드와 동시에 이미지를 푸시한다.\n(참고로 위 명령어는 buildx를 사용해서 amd64, larm64 플랫폼에서 사용할 수 있는 형태의 이미지로 빌드한다는 내용. Buildx는 밑에서 한번 더 다룰 예정)\nDocker Hub Private repo에 push 하는 방법 기존에는 Docker image를 ECR이 아닌 Docker Hub Private repo에 push 하는 방식. 이를 위한 actions 일부 내용 ⬇\ndocker: needs: build runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - name: Download artifact uses: actions/download-artifact@v2 with: name: spring-application-jar path: build/libs - name: Build Docker image run: docker build . -t ${{ secrets.DOCKER_REPO }}:tag-${{ github.sha }} - name: Log in to DockerHub uses: docker/login-action@v1 with: username: ${{ secrets.DOCKER_USERNAME }} password: ${{ secrets.DOCKER_PASSWORD }} - name: Push Docker image to DockerHub run: docker push ${{ secrets.DOCKER_REPO }}:tag-${{ github.sha }} DOCKER_USERNAME ⇒ 도커 이미지가 올라갈 레파지토리에 접근할 사용자 이름\nDOCKER_PASSWORD ⇒ 사용자의 Access token (토근 발급 방법: Docker Hub 사이트 로그인 → My Account → Security → New Access Token 발급)\nMult Platform 이미지 - Buildx를 사용한 Docker image build \u0026amp; push 위에서는 docker image를 build하고 push하는 방법으로 아래와 같은 커맨드 라인으로 수행하고있다.\nrun: docker build . -t ${{ secrets.DOCKER_REPO }}:tag-${{ github.sha }} run: docker push ${{ secrets.DOCKER_REPO }}:tag-${{ github.sha }}\n이 방법은 위에도 명시되어 있듯이 “runs-on: ubuntu-latest” 이 부분에 의해 기본적으로 amd64 아키텍처로 설정된 이미지로 빌드되는 방식이다.\n즉, 이미지를 pull하게 되는 컴퓨터의 하드웨어가 x86-64이라는 전제가 깔려있는 내용이다.\n그래서 이 image를 가지고 로컬테스트 해보겠다고 맥북에서 pull하여 사용하면 당연히 에러가 발생한다. (exec format error 라는 에러메세지를 볼 수 있다)\n따라서, 만약 이미지를 내 Mac에서도 사용할 수 있도록 Multi Platform을 지원하기 위한 방법은 buildx를 사용하여 arm64 아키텍쳐도 지원하도록 하면된다.\ndocker: needs: build runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - name: Download artifact uses: actions/download-artifact@v4 with: name: ${{env.APP_NAME}}-application-jar path: target/ # OIDC 인증 - name: configure aws credentials uses: aws-actions/configure-aws-credentials@v3 with: role-to-assume: ${{secrets.AWS_OIDC_ROLE}} role-session-name: ecrRoleSession aws-region: ${{secrets.AWS_REGION}} # ECR 로그인 - name: Login to Amazon ECR id: login-ecr uses: aws-actions/amazon-ecr-login@v2 # Docker 이미지 빌드 \u0026amp; ECR에 푸시 - name: Set up Docker Buildx uses: docker/setup-buildx-action@v2 - name: Build and push Docker image uses: docker/build-push-action@v3 with: context: . file: ./Dockerfile push: true tags: ${{ secrets.AWS_ECR_URL }}/${{ env.APP_NAME }}:${{ env.BRANCH_NAME }}-${{ github.sha }} platforms: linux/amd64,linux/arm64 참고 링크: https://docs.docker.com/build/ci/github-actions/multi-platform/ (Docker Hub 공식문서)\n","permalink":"https://jaylab.link/git-actions-build-push-update/","summary":"Github Actions을 사용하여 멀티플랫폼이 지원되는 이미지 빌드 방법과 Docker Hub 혹은 ECR에 Push하는 방법. 그리고 제 3의 Git Repo 파일 내용 변경 방법을 정리해보았다.","title":"[CI/CD] Github Actions: build, push, update another repo file"},{"content":"Terraform으로 Mysql DB Instance 생성 시도. 그러나 계속해서 에러 발생.\n⚠️Error Message\naws_db_instance.example: Creating\u0026hellip; │ Error: creating RDS DB Instance (terraform-db20240505074947525100000001): InvalidVPCNetworkStateFault: The VPC vpc-05819890763cddf88 associated with the default subnet group does not exist. Please delete the default subnet group or specify another subnet group. │ status code: 400, request id: 84afb05a-bd40-422c-9e62-958154a568c0 │ │ with aws_db_instance.example, │ on main.tf line 25, in resource \u0026ldquo;aws_db_instance\u0026rdquo; \u0026ldquo;example\u0026rdquo;: │ 25: resource \u0026ldquo;aws_db_instance\u0026rdquo; \u0026ldquo;example\u0026rdquo; {\n몇번이고 잘 돌아가던 코드였으나 오늘은 저렇게 에러를 뱉고 있다. 일단 로그에서 확인된 VPC는 존재하지 않는 VPC라는 점. 아마도 로그에 잡힌 VPC ID는 팀원 중 한분이 삭제했을 기존의 Default VPC로 추측되고, 오늘보니 Default VPC가 없길래 내가 직접 생성했었는데 아마 이 때 부터 존재하지 않는 VPC를 참조하고 있는 것으로 보여진다.\n아무튼 테라폼이 이를 인지하지 못하고 state와 꼬여버린 문제인가 싶어 모든 리소스 destroy 진행, state 파일 삭제. 그래도 해결이 안돼서 “.terraform” 디렉터리 삭제, 백엔드 상태파일 저장소였던 S3 버킷까지 삭제 등 더 이상 할 수 있는 조치가 없었음에도 apply 결과는 동일했다.\n찾아보니 나와 동일한 문제를 겪은 사람들이 여럿 보이긴 했다. 그러나 그 글들에도 명쾌한 해답이 없었다. 기나긴 삽질 결과 결국 혼자 해결한 방법은 매개변수 db_subnet_group_name를 명시적으로 설정하는 것이었다. 자꾸만 존재하지 않는 Default VPC를 참조하던 현상은 여전히 이해가 되지 않았지만, 아마도 이것이 사람들이 말하는 테라폼의 미흡한 부분이 아닐까하는 생각이 들었다.\n전체적인 방법을 간략하게 기록해두자면 다음과 같다.\ndata \u0026#34;aws_vpc\u0026#34; \u0026#34;default\u0026#34; { default = true } data \u0026#34;aws_subnets\u0026#34; \u0026#34;default\u0026#34; { filter { name = \u0026#34;vpc-id\u0026#34; values = [data.aws_vpc.default.id] } } resource \u0026#34;aws_db_subnet_group\u0026#34; \u0026#34;example\u0026#34; { name = \u0026#34;example-subnet-group\u0026#34; subnet_ids = data.aws_subnets.default.ids # 예제 서브넷 ID들 tags = { Name = \u0026#34;My DB subnet group\u0026#34; } } resource \u0026#34;aws_db_instance\u0026#34; \u0026#34;example\u0026#34; { db_subnet_group_name = aws_db_subnet_group.example.name identifier_prefix = \u0026#34;terraform-db\u0026#34; engine = \u0026#34;mysql\u0026#34; allocated_storage = 10 # 스토리지 10 GB instance_class = \u0026#34;db.t3.micro\u0026#34; # 1 CPU, 1 GB Memory db_name = \u0026#34;example_database\u0026#34; username = \u0026#34;admin\u0026#34; ... } 📌 문제 해결 후 기록을 위해 정리하다보니 나와 같은 증상, 나와 동일한 문제를 겪는 포스팅이 추가로 확인 되었는데, 여기서 말하고있는 해결방법은 내가 한 방법과 동일했다. 지금으로써는 이 방법이 최선이긴 한가보다. https://github.com/hashicorp/terraform-provider-aws/issues/4318\nhttps://github.com/hashicorp/terraform-provider-aws/issues/4318\n","permalink":"https://jaylab.link/terraform-error-create-intance-by-old-vpc-id/","summary":"Terraform으로 DB Instance 생성시 존재하지 않는 VPC를 참조하는 현상에 대하여","title":"[Terraform] DB Instance 삭제된 VPC 참조 현상"},{"content":"작업 내용 요약 Github Actions를 활용해서 애플리케이션 Build \u0026amp; Image push. 애플리케이션과 Kubernetes manifest 파일 변경점 발생시 ArgoCD를 통하여 로컬 Minikube 환경에 자동배포 환경 구축하기\n📌사용 환경\nMac Apple Sillicon\nLocal Minikube 환경\n배포할 Java application - Kafka test app (4 Deployment, 4 Service) (zookeeper, kafka, kafka-ui, spring-boot-app)\n전체 구성도 Developer \u0026amp; Engineer 관점 ArgoCD 활용 플로우차트\nArgoCD Install \u0026amp; NordPort Service 구성 brew install argocd kubectl create namespace argocd # 네임스페이스 생성 # argoCD manifest파일을 사용한 배포방식 kubectl apply -n argocd -f https://raw.githubusercontent.com/argoproj/argo-cd/stable/manifests/install.yaml kubectl apply -n argocd -f https://raw.githubusercontent.com/argoproj/argo-cd/stable/manifests/ha/install.yaml kubectl get pods -n argocd # 배포 확인 kubectl patch svc argocd-server -n argocd -p \u0026#39;{\u0026#34;spec\u0026#34;: {\u0026#34;type\u0026#34;: \u0026#34;NodePort\u0026#34;}}\u0026#39; # NodePort 설정 kubectl get service -n argocd # NodePort 확인 ArgoCD 대시보드 접속 minikube에서는 ClusterIP, NodePort로 서버에 접근 하려해도 불가능하다\n→ 터널링 필요. 다음 명령어를 실행하여 argocd-service 의 터널링을 실행해야한다. (※ 보안상 터널링이 필요하다고하며, 풀수 있는 방법도 따로 존재한다고 한다)\nusage: minikube service \u0026lt;service name\u0026gt; -n \u0026lt;nsmespace\u0026gt;\n~ » minikube service argocd-server -n argocd seongjin.an@seongjinan-DJ6306H4DN |-----------|---------------|-------------|---------------------------| | NAMESPACE | NAME | TARGET PORT | URL | |-----------|---------------|-------------|---------------------------| | argocd | argocd-server | http/80 | http://192.168.49.2:31866 | | | | https/443 | http://192.168.49.2:30635 | |-----------|---------------|-------------|---------------------------| 🏃 argocd-server 서비스의 터널을 시작하는 중 |-----------|---------------|-------------|------------------------| | NAMESPACE | NAME | TARGET PORT | URL | |-----------|---------------|-------------|------------------------| | argocd | argocd-server | | http://127.0.0.1:55140 | | | | | http://127.0.0.1:55141 | |-----------|---------------|-------------|------------------------| [argocd argocd-server http://127.0.0.1:55140 http://127.0.0.1:55141] ❗ Because you are using a Docker driver on darwin, the terminal needs to be open to run it. 대시보드 PW 추출 위 명령어를 실행하여 출력되는 URL 접속화면 ▼\nID: admin(default)\nPW: (아래 명령어 실행하여 확인)\nkubectl -n argocd get secret argocd-initial-admin-secret -o jsonpath=\u0026#34;{.data.password}\u0026#34; | base64 -d; echo ArgoCD 대시보드 설정 Github Repository 여기서 연결되는 github repository는 ArgoCD의 Oserve 대상이 될 Repo로써, manifest 파일에 변경사항이 발생하면 ArgoCD가 해당 내용을 K8S 클러스터에 반영하게된다.\nApplications ArgoCD 대시보드를 구성하고자하는 K8S 클러스터 정보와 위에서 생성한 Github URL 정보를 입력하는 과정\nApplication Name: 임의지정\nProject Name : 임의지정\nSync Policy: 동기화 정책 Automatic으로 설정\nSOURCE\nRepository URL: 위에서 생성한 Github URL 정보 선택\nRevision: ArgoCD가 모니터링할 브랜치 선택\nPath: ArgoCD가 모니터링할 Manifest 파일이 있는 폴더 경로 선택\nDESTINATION\nCluster URL: ArgoCD가 변경사항을 어느 클러스터에 배포하도록할 것인지 설정 (나는 minikube 환경에서 테스트하고있기 때문에 https://kubernetes.default.svc로 설정)\nNamespace: 배포 목적지가 될 네임스페이스\nArgoCD 대시보드 Application 생성 결과 연결된 Github의 manifest 파일을 ArgoCD가 참조하여 구성한 Kubernetes 인프라 환경을 볼 수 있다.\n이 상태에서 Github 저장소 파일에 변경사항이 발생하면 Kubernetes 인프라 환경에도 동기화가 진행된다.\n","permalink":"https://jaylab.link/git-actions-argocd-cicd-minikube/","summary":"Github Actions를 활용해서 애플리케이션 Build \u0026amp; Image push. 애플리케이션과 Kubernetes manifest 파일 변경점 발생시 ArgoCD를 통하여 로컬 Minikube 환경에 자동배포 환경 구축하기","title":"Github Actions \u0026 ArgoCD CI/CD (for K8S, Java App)"},{"content":"나는 ArgoCD를 통해 내 로컬에 Minikube 클러스터 환경이 구성되도록 만들었다. 그리고 Dockerhub Public image를 가져오던 기존방식에서 AWS ECR의 image를 pull 하는 방식으로 바꿔보았고, 이 과정에서 발생한 문제에 대해 알게된 원인과 해결방법을 기록해보았다.\n⚠️Error message Failed to pull image \u0026ldquo;{AWS Account ID}.dkr.ecr.ap-northeast-2.amazonaws.com/saas:tag-09ce85b761470bd32785e3285b1bee91eaed1fc5\u0026rdquo;: Error response from daemon: Head \u0026ldquo;https://{AWS Account ID}.dkr.ecr.ap-northeast-2.amazonaws.com/v2/saas/manifests/tag-09ce85b761470bd32785e3285b1bee91eaed1fc5\u0026rdquo;: no basic auth credentials 기존 공식문서에서 제공하고있는 ECR 로그인 자격증명 생성 명령어는 다음과 같다.\naws ecr get-login-password --region ap-northeast-2 | docker login --username AWS --password-stdin {AWS Account ID}.dkr.ecr.ap-northeast-2.amazonaws.com 그러나 이를 활용해도 에러 내용에는 변함이 없었다. 처음 확인했던 방법으로는 위 내용을 입력하면 ~/.aws/config 파일 내용이 갱신된다고 한다. 그래서 이 내용을 통해 Kubernetes Secret을 생성하라고 하는데, 갱신되는 내용 자체가 없었다. 두번째로 알게된 방법이 그나마 해결될 수 있었고 그 방법은 다음과 같다. (Secret을 생성한다는 점은 동일하다)\n해결방법 kubectl create secret docker-registry ecr-registry \\ --docker-server=https://{AWS Account ID}.dkr.ecr.ap-northeast-2.amazonaws.com \\ --docker-username=AWS \\ --docker-password=$(aws ecr get-login-password) \\ --docker-email={Email정보} # 시크릿 생성 확인 kubectl get secret # Deployment 재시작 설명 AWS ECR에서 이미지를 Pull 하기 위해서 AWS의 자격증명을 Kubernetes 시크릿으로 저장해야한다.\nkubectl create secret docker-registry: 위 패스워드를 Kubernetes 시크릿으로 생성.\n--docker-server: Amazon ECR의 레포지토리 주소를 전체 포함하지 않고, ECR 서비스의 베이스 URL만 포함해야 한다. 즉, 개별 이미지의 주소나 프로젝트 디렉토리 경로를 포함하지 않는 것이 일반적. AWS ECR 레포지토리를 가리키는 베이스 URL이며, 여기에 개별 이미지나 프로젝트 폴더를 추가하지 않는다.\n--docker-email: 용자 이메일 주소는 시크릿 생성에 필요하지 않으나 오류를 방지하기 위해 제공될 수 있다.\naws ecr get-login-password: ECR 로그인 패스워드 획득. 서브쉘($(...))을 사용하여 aws ecr get-login-password 명령의 출력을 --docker-password 옵션의 인자로 바로 전달하는 방식이다.\n","permalink":"https://jaylab.link/argocd-ecr-image-pull-error/","summary":"ArgoCD를 통해 내 로컬에 Minikube 클러스터 환경이 구성되도록 만들었다. 그리고 Dockerhub Public image를 가져오던 기존방식에서 AWS ECR의 image를 pull 하는 방식으로 바꿔보았고, 이 과정에서 발생한 문제에 대해 알게된 원인과 해결방법을 기록해보았다.","title":"ArgoCD 활용 Minikube 환경에 배포(feat. AWS ECR)"},{"content":"⚠️Error message\nio.opentelemetry:opentelemetry-exporter-zipkin:jar:unknown was not found in https://repo.maven.apache.org/maven2 during a previous attempt. This failure was cached in the local repository and resolution is not reattempted until the update interval of central has elapsed or updates are forced\nTry to run Maven import with -U flag (force update snapshots)\n방법 1. -U 플래그 사용: Maven 명령어에 -U 플래그를 추가하여 스냅샷 의존성을 강제로 업데이트할 수 있다. 이를 통해 로컬 Maven 저장소의 오래된 캐시를 무시하고 최신 의존성을 다시 다운로드하려고 시도할 수 있다.\nmvn clean install -U 방법 2. 의존성 버전 확인: \u0026lt;version\u0026gt; 태그가 pom.xml의 해당 의존성에 포함되어 있는지 확인한다. Maven 중앙 저장소에는 해당 의존성의 구체적인 버전이 필요하다.\n# 수정 전 \u0026lt;dependency\u0026gt; \u0026lt;groupId\u0026gt;io.micrometer\u0026lt;/groupId\u0026gt; \u0026lt;artifactId\u0026gt;micrometer-tracing-bridge-otel\u0026lt;/artifactId\u0026gt; \u0026lt;/dependency\u0026gt; \u0026lt;dependency\u0026gt; \u0026lt;groupId\u0026gt;io.opentelemetry\u0026lt;/groupId\u0026gt; \u0026lt;artifactId\u0026gt;opentelemetry-exporter-zipkin\u0026lt;/artifactId\u0026gt; \u0026lt;/dependency\u0026gt; # 수정 후 \u0026lt;dependency\u0026gt; \u0026lt;groupId\u0026gt;io.micrometer\u0026lt;/groupId\u0026gt; \u0026lt;artifactId\u0026gt;micrometer-tracing-bridge-otel\u0026lt;/artifactId\u0026gt; \u0026lt;version\u0026gt;1.2.5\u0026lt;/version\u0026gt; \u0026lt;/dependency\u0026gt; \u0026lt;dependency\u0026gt; \u0026lt;groupId\u0026gt;io.opentelemetry\u0026lt;/groupId\u0026gt; \u0026lt;artifactId\u0026gt;opentelemetry-exporter-zipkin\u0026lt;/artifactId\u0026gt; \u0026lt;version\u0026gt;1.37.0\u0026lt;/version\u0026gt; \u0026lt;/dependency\u0026gt; ※ https://central.sonatype.com/ 에서 맞는 버전 찾기\n","permalink":"https://jaylab.link/maven-build-error-not-found/","summary":"io.opentelemetry:opentelemetry-exporter-zipkin:jar:unknown was not found in \u003ca href=\"https://repo.maven.apache.org/maven2\"\u003ehttps://repo.maven.apache.org/maven2\u003c/a\u003e during a previous attempt. This failure was cached in the local repository and resolution is not reattempted until the update interval of central has elapsed or updates are forced","title":"What to do about maven import errors"},{"content":"작업 환경 :\nSpring Framework 4.3.7\nJVM 옵션에 프록시 서버정보(IP, PORT) 설정\nHttpURLConnection과 HttpClient는 Java에서 기본적으로 제공하는 클래스로, 기본적인 HTTP 연결시 사용.\n이들은 아래와 같은 JVM 옵션 프록시 설정시 각각 다음과 같은 차이가 존재한다.\nexport JAVA_OPTS=\u0026#34; ${JAVA_OPTS} -Dhttp.proxyHost=(프록시 서버 아이피) -Dhttp.proxyPort=(프록시 서버 포트)\u0026#34; export JAVA_OPTS=\u0026#34; ${JAVA_OPTS} -Dhttps.proxyHost=(프록시 서버 아이피) -Dhttps.proxyPort=(프록시 서버 포트)\u0026#34; HttpURLConnection과 HttpClient 차이 ✔︎ HttpURLConnection 사용하여 HTTP 연결시\nJVM 시스템 프로퍼티를 사용하여 프록시 설정을 가져온다.\n따라서 -Dhttp.proxyHost 및 -Dhttp.proxyPort 옵션으로 지정한 프록시 설정이 자동 적용된다.\nURL url = new URL(\u0026#34;http://example.com\u0026#34;); HttpURLConnection conn = (HttpURLConnection) url.openConnection(); ✔︎ HttpClient 사용하여 HTTP 연결시\n기본적으로 JVM 시스템 프로퍼티를 무시. 따라서 별도의 프록시 설정 추가 필요.\nHttpClient httpClient = HttpClients.custom() .setDefaultRequestConfig(requestConfig) .setProxy(new HttpHost(\u0026#34;{프록시 서버 아이피}\u0026#34;, {프록시 서버 포트})) .build(); 추가로, 스프링 프레임워크에서 HTTP 통신을 더 쉽게 할 수 있도록 지원하는 RestTemplate이나 WebClient의 경우에도\n프록시 설정 관련하여 다음과 같은 차이가 있다.\nRestTemplate이나 WebClient 차이 ✔︎ RestTemplate\nRestTemplate은 내부적으로 HttpURLConnection을 사용하므로, 기본적으로 JVM 시스템 프로퍼티에 설정된 프록시 설정을 따른다.\nRestTemplate restTemplate = new RestTemplate(); String result = restTemplate.getForObject(\u0026#34;http://example.com\u0026#34;, String.class); ✔︎ WebClient\nWebClient는 Reactor Netty 라이브러리를 사용하여 HTTP 요청을 처리하므로,\nJVM 시스템 프로퍼티의 프록시 설정을 무시하고 별도의 프록시 설정이 필요하다.\nHttpClient httpClient = HttpClient.create().proxy(proxy -\u0026gt; proxy.type(Proxy.HTTP) .host(\u0026#34;{프록시 서버 아이피}\u0026#34;) .port({프록시 서버 포트})); WebClient webClient = WebClient.builder() .clientConnector(new ReactorClientHttpConnector(httpClient)) .build(); ","permalink":"https://jaylab.link/httpurlconnection-httpclient-compare/","summary":"프록시 서버 구성을 하는 과정에서 알게된 Java 클래스 몇가지를 비교하여 정리하였다.","title":"VM Proxy 설정에 대한 HttpURLConnection \u0026 HttpClient 차이"},{"content":"netcat netcat: 네트워크 연결을 테스트하고 다른 서버와 포트의 통신 상태를 확인할 수 있다.\nnetcat은 많은 Linux 배포판에서 사용할 수 있는 네트워킹 유틸리티로, 다양한 패키지 매니저를 통해 설치할 수 있다.\n주요 Linux 배포판별로 netcat을 설치하고 사용하는 방법을 다음과 같이 정리했다\n1. Debian, Ubuntu (apt 사용) sudo apt update \u0026amp;\u0026amp; sudo apt install netcat-openbsd nc -zv 10.244.0.74 9092 2. Fedora, CentOS (dnf 또는 yum 사용) sudo dnf install nc # 또는 CentOS에서는 yum이 보편적인 방식 sudo yum install nc nc -zv 10.244.0.74 9092 3. Arch Linux (pacman 사용) sudo pacman -Syu gnu-netcat nc -zv 10.244.0.74 9092 4. openSUSE (zypper 사용) sudo zypper install netcat-openbsd nc -zv 10.244.0.74 9092 5. Alpine Linux (apk 사용) apk update \u0026amp;\u0026amp; apk add netcat-openbsd nc -zv 10.244.0.74 9092 6. Amazon Linux (yum 또는 dnf 사용) Amazon Linux 2의 경우: sudo yum install nc nc -zv 10.244.0.74 9092 Amazon Linux 2023의 경우: sudo dnf install nc nc -zv 10.244.0.74 9092 ss netstat -tuln: 시스템의 네트워크 연결, 라우팅 테이블, 인터페이스 통계, masquerade 연결, 멀티캐스트 멤버십 등을 보여준다.\n-tuln 옵션은 TCP와 UDP 포트를 리스닝 상태에서 보여주며, 포트 번호와 함께 표시한다.\n최근 대부분의 Linux 배포판에서 ss 명령어로 대체되고 있고 netstat 보다 더 빠르고 효율적인 정보를 제공한다고 한다.\n주요 Linux 배포판별로 ss 명령어를 사용하여 netstat -tuln과 유사한 기능을 수행하는 방법은 다음과 같다.\n1. Debian, Ubuntu sudo apt update \u0026amp;\u0026amp; sudo apt install iproute2 ss -tuln 2. Fedora, CentOS sudo dnf install iproute # 또는 CentOS에서는 sudo yum install iproute ss -tuln 3. Arch Linux sudo pacman -Syu iproute2 ss -tuln 4. openSUSE sudo zypper install iproute2 ss -tuln 5. Alpine Linux apk update \u0026amp;\u0026amp; apk add iproute2 ss -tuln 6. Amazon Linux Amazon Linux 2 sudo yum install iproute ss -tuln Amazon Linux 2023 sudo dnf install iproute ss -tuln 이러한 방법을 통해 각 배포판에서 netstat -tuln과 유사한 기능을 수행할 수 있다. ss 명령어는 iproute2 패키지의 일부로 제공되며, 대부분의 최신 Linux 배포판에서는 기본적으로 설치되어 있을 수 있다.\n","permalink":"https://jaylab.link/netcat-and-netstat/","summary":"\u003ch2 id=\"netcat\"\u003enetcat\u003c/h2\u003e\n\u003cp\u003enetcat: 네트워크 연결을 테스트하고 다른 서버와 포트의 통신 상태를 확인할 수 있다.\u003c/p\u003e\n\u003cp\u003e\u003ccode\u003enetcat\u003c/code\u003e은 많은 Linux 배포판에서 사용할 수 있는 네트워킹 유틸리티로, 다양한 패키지 매니저를 통해 설치할 수 있다.\u003c/p\u003e\n\u003cp\u003e주요 Linux 배포판별로 \u003ccode\u003enetcat\u003c/code\u003e을 설치하고 사용하는 방법을 다음과 같이 정리했다\u003c/p\u003e\n\u003ch4 id=\"1-debian-ubuntu-apt-사용\"\u003e\u003cstrong\u003e1. Debian, Ubuntu (apt 사용)\u003c/strong\u003e\u003c/h4\u003e\n\u003cdiv class=\"highlight\"\u003e\u003cpre tabindex=\"0\" class=\"chroma\"\u003e\u003ccode class=\"language-shell\" data-lang=\"shell\"\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003esudo apt update \u003cspan class=\"o\"\u003e\u0026amp;\u0026amp;\u003c/span\u003e sudo apt install netcat-openbsd\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003enc -zv 10.244.0.74 \u003cspan class=\"m\"\u003e9092\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003ch4 id=\"2-fedora-centos-dnf-또는-yum-사용\"\u003e\u003cstrong\u003e2. Fedora, CentOS (dnf 또는 yum 사용)\u003c/strong\u003e\u003c/h4\u003e\n\u003cdiv class=\"highlight\"\u003e\u003cpre tabindex=\"0\" class=\"chroma\"\u003e\u003ccode class=\"language-shell\" data-lang=\"shell\"\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003esudo dnf install nc\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"c1\"\u003e# 또는 CentOS에서는 yum이 보편적인 방식\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003esudo yum install nc\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003enc -zv 10.244.0.74 \u003cspan class=\"m\"\u003e9092\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003ch4 id=\"3-arch-linux-pacman-사용\"\u003e\u003cstrong\u003e3. Arch Linux (pacman 사용)\u003c/strong\u003e\u003c/h4\u003e\n\u003cdiv class=\"highlight\"\u003e\u003cpre tabindex=\"0\" class=\"chroma\"\u003e\u003ccode class=\"language-shell\" data-lang=\"shell\"\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003esudo pacman -Syu gnu-netcat\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003enc -zv 10.244.0.74 \u003cspan class=\"m\"\u003e9092\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003ch4 id=\"4-opensuse-zypper-사용\"\u003e\u003cstrong\u003e4. openSUSE (zypper 사용)\u003c/strong\u003e\u003c/h4\u003e\n\u003cdiv class=\"highlight\"\u003e\u003cpre tabindex=\"0\" class=\"chroma\"\u003e\u003ccode class=\"language-shell\" data-lang=\"shell\"\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003esudo zypper install netcat-openbsd\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003enc -zv 10.244.0.74 \u003cspan class=\"m\"\u003e9092\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003ch4 id=\"5-alpine-linux-apk-사용\"\u003e\u003cstrong\u003e5. Alpine Linux (apk 사용)\u003c/strong\u003e\u003c/h4\u003e\n\u003cdiv class=\"highlight\"\u003e\u003cpre tabindex=\"0\" class=\"chroma\"\u003e\u003ccode class=\"language-shell\" data-lang=\"shell\"\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003eapk update \u003cspan class=\"o\"\u003e\u0026amp;\u0026amp;\u003c/span\u003e apk add netcat-openbsd\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003enc -zv 10.244.0.74 \u003cspan class=\"m\"\u003e9092\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003ch4 id=\"6-amazon-linux-yum-또는-dnf-사용\"\u003e\u003cstrong\u003e6. Amazon Linux (yum 또는 dnf 사용)\u003c/strong\u003e\u003c/h4\u003e\n\u003cul\u003e\n\u003cli\u003eAmazon Linux 2의 경우:\u003c/li\u003e\n\u003c/ul\u003e\n\u003cdiv class=\"highlight\"\u003e\u003cpre tabindex=\"0\" class=\"chroma\"\u003e\u003ccode class=\"language-shell\" data-lang=\"shell\"\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003esudo yum install nc\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003enc -zv 10.244.0.74 \u003cspan class=\"m\"\u003e9092\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003cul\u003e\n\u003cli\u003eAmazon Linux 2023의 경우:\u003c/li\u003e\n\u003c/ul\u003e\n\u003cdiv class=\"highlight\"\u003e\u003cpre tabindex=\"0\" class=\"chroma\"\u003e\u003ccode class=\"language-shell\" data-lang=\"shell\"\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003esudo dnf install nc\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003enc -zv 10.244.0.74 \u003cspan class=\"m\"\u003e9092\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003ch2 id=\"ss\"\u003ess\u003c/h2\u003e\n\u003cp\u003e\u003ccode\u003enetstat -tuln\u003c/code\u003e:\n시스템의 네트워크 연결, 라우팅 테이블, 인터페이스 통계, masquerade 연결, 멀티캐스트 멤버십 등을 보여준다.\u003c/p\u003e","title":"About Netcat and Netstat"},{"content":"구축 환경 Mac Apple Sillicon (arm64)\n기존 docker-compose.yaml 파일을 k8s 클러스터 구성하기 위한 yaml 파일로 convert하기 위해 kompose 툴 사용\nbrew install kompose kompose convert -f docker-compose.yml --out converted.yaml converted.yaml\nminikube, 위의 converted.yaml 활용하여 로컬에 k8s 클러스터 구축 minikube 사용하여 기존 Kafka Application 구축 minikube 설치 brew install minikube minikube 클러스터 시작 minikube start (설치하고나면 ~/.kube/config 파일에 클러스터와 컨텍스트 관련 정보 자동으로 설정 완료)\n(option) 사용할 Namespace 설정 kubectl create ns kafka-app kubectl config set-context --current --namespace=kafka-app 로컬 image 사용하도록 설정 Registry에 올리지 않은, 로컬에만 있는 image를 사용하기 위해서는 다음과 같은 커맨드 실행이 필요하다.\n기본적으로 yaml에 명시되어있는 이미지는 로컬에 있는것부터 참조 후 없으면 Registry에서 찾는다. 그런데 로컬에 뻔히 있는데도 minikube는 그것을 알지 못하여 Registry에서 찾으려고시도 후 결국 찾지 못해 Pod를 생성해내지 못하는 에러가 발생한다.\nminikube가 로컬에있는 image를 참조할 수 있도록 아래 명령어 실행.\neval $(minikube docker-env) yaml 파일로 k8s 리소스 생성 kubectl apply -f converted.yaml 생성된 리소스\nzookeeper\nkafka\nkafka-ui 관련 Deployment\nService\n빌드 되어있는 jar파일 → Docker image Gradle로 빌드하여 생성된 Spring Boot 애플리케이션 jar파일 → docker image로 변환\nDockerfile 코드 # 베이스 이미지를 지정 # 이미지가 로컬에 존재하지 않을 경우에만 다운로드 # 이미지가 로컬에 있을 경우에는 다시 다운로드 X FROM arm64v8/openjdk:11-jdk-slim@sha256:482a519d80f99a32117003a369c14fdc180c3175ca3ee8b2bf659b9de05cf53b # 작업 디렉토리를 설정 WORKDIR /app # 호스트의 JAR 파일을 Docker 이미지로 복사 COPY build/libs/kafka-chat-0.0.1-SNAPSHOT.jar /app/kafka-chat-0.0.1-SNAPSHOT.jar # 어플리케이션을 실행 CMD [\u0026#34;java\u0026#34;, \u0026#34;-jar\u0026#34;, \u0026#34;kafka-chat-0.0.1-SNAPSHOT.jar\u0026#34;] # Dockerfile이 있는 경로에서 # [usage] docker build -t {생성할 image 명}:{지정할 tag 혹은 버전정보} . docker build -t my-spring-application:0.0.14 . 리소스 생성 확인 minikube dashboard → Output URL 접속하여 생성된 k8s 리소스 확인\n(화면상단 Namespace 확인)\n로그 확인 kubectl logs pod/kafka-ui-57f44b6bb8-cgwzq 네트워크 연결 체크 - 특정 pod에서 다른 pod로 통신 되는지 확인 # Pod(ex: kafka-ui) 접속 kubectl exec -it pod/kafka-ui-57f44b6bb8-cgwzq -- sh # netcat 설치 apk update \u0026amp;\u0026amp; apk add netcat-openbsd # 다른 Pod(kafka server)로의 통신 체크 # [usage] nc -zv {수신 target container IP} {open 되어있는지 확인하고자 하는 port} nc -zv 10.244.0.74 9092 Success case → Connection to 10.244.0.74 9092 port [tcp/*] succeeded! Fail case → connect to 10.244.0.74 port 9094 (tcp) failed: Connection refused -z 옵션은 데이터를 보내지 않고 연결만 시도\n-v 옵션은 자세한 출력(Verbose)을 활성화하여 연결 과정을 출력\nminikube 특정 서비스 외부접속허용(로컬에서 접속할 수 있도록) # minikube 클러스터 내 특정 서비스 \u0026lt;--\u0026gt; local 터널링 (새로운 port 생성되며 해당 포트에서 터널링) minikube service kafka-ui --url --namespace {애플리케이션 구성되어있는 네임스페이스이름} # OR # minikube 클러스터 내 전체 서비스 \u0026lt;--\u0026gt; local 터널링 (새로운 port 생성되며 해당 포트에서 터널링) minikube service --all --namespace {애플리케이션 구성되어있는 네임스페이스이름} # (option) 위 과정으로 생성된 포트 확인(위 명령으로 확인된 포트와 일치하는지 확인), 다른 터미널에서 실행 ps -ef | grep docker@127.0.0.1 # 브라우저에서 접속 Pod \u0026gt; Application log check # 스프링 부트 애플리케이션 pod로 접속 kubectl exec -it pod/spring-boot-application-647647954d-g9rxp -- sh # curl 설치 apt-get update \u0026amp;\u0026amp; apt-get install -y curl # 리스닝하고있는 포트 확인하기 위해 net-tools 설치 apt-get install -y net-tools # 리스닝하고있는 포트 확인 netstat -tuln Active Internet connections (only servers) Proto Recv-Q Send-Q Local Address Foreign Address State tcp 0 0 0.0.0.0:8080 0.0.0.0:* LISTEN ## 8080포트를 리스닝 확인 # 다른터미널에서 스프링 부트 애플리케이션 pod 로그확인 kubectl logs -f spring-boot-application-647647954d-g9rxp curl --location \u0026#39;http://localhost:8080/kafka/send/message\u0026#39; --header \u0026#39;Content-Type: application/x-www-form-urlencoded\u0026#39; --data-urlencode \u0026#39;message=kafka chat sample\u0026#39; ","permalink":"https://jaylab.link/minikube-build-kafka-app/","summary":"\u003ch4 id=\"구축-환경\"\u003e구축 환경\u003c/h4\u003e\n\u003cul\u003e\n\u003cli\u003e\n\u003cp\u003eMac Apple Sillicon (arm64)\u003c/p\u003e\n\u003c/li\u003e\n\u003cli\u003e\n\u003cp\u003e기존 docker-compose.yaml 파일을 k8s 클러스터 구성하기 위한 yaml 파일로 convert하기 위해 \u003cstrong\u003ekompose\u003c/strong\u003e 툴 사용\u003c/p\u003e\n\u003c/li\u003e\n\u003c/ul\u003e\n\u003cdiv class=\"highlight\"\u003e\u003cpre tabindex=\"0\" class=\"chroma\"\u003e\u003ccode class=\"language-shell\" data-lang=\"shell\"\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003ebrew install kompose\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003ekompose convert -f docker-compose.yml --out converted.yaml\n\u003c/span\u003e\u003c/span\u003e\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003cp\u003e\u003cem\u003econverted.yaml\u003c/em\u003e\u003c/p\u003e\n\u003cul\u003e\n\u003cli\u003eminikube, 위의 converted.yaml 활용하여 로컬에 k8s 클러스터 구축\u003c/li\u003e\n\u003c/ul\u003e\n\u003ch3 id=\"minikube-사용하여-기존-kafka-application-구축\"\u003eminikube 사용하여 기존 Kafka Application 구축\u003c/h3\u003e\n\u003ch4 id=\"minikube-설치\"\u003eminikube 설치\u003c/h4\u003e\n\u003cdiv class=\"highlight\"\u003e\u003cpre tabindex=\"0\" class=\"chroma\"\u003e\u003ccode class=\"language-shell\" data-lang=\"shell\"\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003ebrew install minikube\n\u003c/span\u003e\u003c/span\u003e\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003ch4 id=\"minikube-클러스터-시작\"\u003eminikube 클러스터 시작\u003c/h4\u003e\n\u003cdiv class=\"highlight\"\u003e\u003cpre tabindex=\"0\" class=\"chroma\"\u003e\u003ccode class=\"language-shell\" data-lang=\"shell\"\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003eminikube start\n\u003c/span\u003e\u003c/span\u003e\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003cp\u003e\u003cimg loading=\"lazy\" src=\"/minikube-build-kafka-app/image-1.png\"\u003e\u003c/p\u003e\n\u003cp\u003e(설치하고나면 ~/.kube/config 파일에 클러스터와 컨텍스트 관련 정보 자동으로 설정 완료)\u003c/p\u003e\n\u003ch4 id=\"option-사용할-namespace-설정\"\u003e(option) 사용할 Namespace 설정\u003c/h4\u003e\n\u003cdiv class=\"highlight\"\u003e\u003cpre tabindex=\"0\" class=\"chroma\"\u003e\u003ccode class=\"language-shell\" data-lang=\"shell\"\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003ekubectl create ns kafka-app\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003ekubectl config set-context --current --namespace\u003cspan class=\"o\"\u003e=\u003c/span\u003ekafka-app\n\u003c/span\u003e\u003c/span\u003e\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003ch4 id=\"로컬-image-사용하도록-설정\"\u003e로컬 image 사용하도록 설정\u003c/h4\u003e\n\u003cp\u003eRegistry에 올리지 않은, 로컬에만 있는 image를 사용하기 위해서는 다음과 같은 커맨드 실행이 필요하다.\u003c/p\u003e","title":"Local K8S 환경 Kafka Application 구성(feat. Minikube)"},{"content":"Linux를 사용하다보면 그 안에서 사용 가능한 패키지 매니저 확인이 필요할 때가 있다.\nKubernetes나 docker를 사용하면 image를 통해 pod container를 편리하게 구성할 수는 있지만, 그 image가 구성된 OS가 어떤 배보판인지 확인하고 적합한 패키지 매니저를 찾아서 써야하는 번거로움이 있었다.\n배포판마다 사용할 수 있는 패키지매니저가 다른데, 사람들은 이걸 어떻게 찾는건지 궁금해서 찾아봤지만 ‘내가 사용하는 리눅스의 패키지 매니저를 쉽게 확인하는 방법’같은건 찾지 못했다.\n결국 GPT에 물어봤다. 계속 긴 스크립트만 주길래 간단한 방법을 계속해서 되묻고나니 결국 다음 명령어를 얻어낼 수 있었다. 번거롭게 찾지말고 아래 명령어로 패키지 관리자를 쉽게 확인하자.\ntype apt-get dnf yum pacman zypper apk 2\u0026gt;/dev/null 정석은 아닌 것 같지만.. 더 간단한 방법이나 실무상 정석을 아시는 분께서 이 글을 보시게 된다면 조언 부탁드림다!\n","permalink":"https://jaylab.link/quick-guide-identify-package-manager/","summary":"Linux를 사용하다보면 그 안에서 사용 가능한 패키지 관리자 확인이 필요할 때가 있다. 쿠버네티스나 도커를 사용하면 image를 통해 pod container를 편리하게 구성할 수 있지만, 그 image가 구성된 OS가 어떤 배보판인지 확인하고 적합한 패키지관리자를 찾아야했다.","title":"Quick Guide: How to Identify Your Package Manager"},{"content":"기존 코드 public \u0026lt;T\u0026gt; Map\u0026lt;String, String\u0026gt; convertToMap(T object) { Map\u0026lt;String, String\u0026gt; map = new HashMap\u0026lt;\u0026gt;(); try { Class\u0026lt;?\u0026gt; clazz = object.getClass(); Field[] fields = clazz.getDeclaredFields(); for (Field field : fields) { field.setAccessible(true); String fieldName = field.getName(); Object value = field.get(object); if (value != null) { map.put(fieldName, value.toString()); } } } catch (IllegalAccessException e) { e.printStackTrace(); } return map; } 위 코드는 Reflection을 사용하여 객체의 모든 필드에 접근하는 방식.\n**getDeclaredXXX() → 상속받은 클래스와 인터페이스를 제외하고 해당 클래스에 직접 정의된 내용만 가져옴. **접근 제어자와 상관없이 요소에 접근할 수 있다. 예를 들어 getDeclaredMethods() 는 해당 클래스에만 직접 정의된 private, protected, public 메소드를 전부 가져온다.\n모든 필드에 대해 접근 권한을 설정하고(setAccessible(true)), 그 값을 읽어와서 Map에 저장.\n**IllegalAccessException: **필드 접근 중 IllegalAccessException이 발생할 수 있는데, 이는 일반적으로 필드가 접근 불가 상태일 때 발생한다. 일반적으로 필드가 접근 불가 상태일 때 필드 접근시 IllegalAccessException이 발생할 수 있다.\n리팩토링 Jackson 라이브러리의 ObjectMapper를 사용하여 객체를 Map으로 변환\nObjectMapper** 라이브러리 활용**: 는 JSON 처리를 위해 널리 사용되는 강력한 도구. 이를 사용하여 복잡한 설정 없이도 객체를 다양한 형태로 쉽게 변환 가능하다. 또한 내부적으로 최적화된 처리와 캐싱 메커니즘을 사용하기 때문에. Reflection에 비해 ObjectMapper를 사용하는 것이 일반적으로 성능이 더 우수하다.\n타입 안정성: TypeReference를 사용하여 명시적으로 결과의 타입을 지정할 수 있다. 타입 안전성을 보장하고, Runtime 타입 오류 발생 가능성을 줄일 수 있다.\npublic \u0026lt;T\u0026gt; Map\u0026lt;String, String\u0026gt; convertToMap(T object) { ObjectMapper mapper = new ObjectMapper(); return mapper.convertValue(object, new TypeReference\u0026lt;Map\u0026lt;String, String\u0026gt;\u0026gt;() {}); } ","permalink":"https://jaylab.link/spring-refactoring-object-to-map/","summary":"기존 Reflection 사용 방식 Object to Map 컨버팅 메서드의 성능 및 유지보수성 개선하기 위한 방법","title":"Spring Optimization: Refactoring Object-to-Map Conversion Methods"},{"content":"로컬 쿠버네티스 환경에 애플리케이션 배포를 하면서 겪은 Docker Image Pull 에러.\n이것을 해결할 수 있었던 몇 가지 방법에 대한 기록\n환경\nMac Apple Sillicon\nMinikube K8S 구성 환경\nLocal application build, Docker Image build 파일을 k8s 환경으로 배포 테스트\n상황:\nDeployment와 Service가 명시되어있는 yaml파일을 kubectl apply 했을 때 Pod의 상태가 “ErrImagePull”이라는 점이었다. docker ps 명령으로 확인해보면 분명 로컬에 image가 있음에도 에러가 발생하고있었다.\n에러메세지는 다음과 같다.\npull access denied for cms, repository does not exist or may require \u0026lsquo;docker login\u0026rsquo;: denied: requested access to the resource is denied\n원인은 간단했다. minikube로 구성된 쿠버네티스 환경 내에서 나의 로컬에 설치되어있는 Docker image를 찾지 못해서였다. 그래서 minikube와 나의 docker host와 연결하면 된다. 아래 명령어를 통해 연결할 수 있다.\neval $(minikube -p minikube docker-env) 나는 이것 말고도 다른 애플리케이션 배포도 진행해야할 건이 있었다. 이번에는 Vue front 관련 application 배포였고 또 Image pull error가 발생했다.(앞서 겪은 경험으로 이미 주의 상태였음에도..)\n※ 에러메세지\ndocker: error response from daemon: pull access denied for... 이 과정에서 두번째 문제를 알게됨. 분명 docker login이 되어있음에도 로그인을 요구하는 내용은 아마도 다음과 같은 문제일 수도 있다.\n**📌 이미지의 이름이 {사용자명}/{이미지명} 형식이 아니라 단순히 **{이미지명}**으로 되어있는 문제 **\n나의 경우 Dockerhub에서 참조할 이미지도 아니었고, 로컬 이미지만 사용하면 됐기 때문에 이것이 문제가 될 줄은 몰랐다. 어쨌든 이미지명이 {사용자명}/{이미지명}형식이 되도록 관련 부분을 모두 수정 적용하여 해결되었다.\n","permalink":"https://jaylab.link/docker-pull-error-two-effective-solutions/","summary":"로컬 쿠버네티스 환경에 애플리케이션 배포를 하면서 겪은 Docker Image Pull 에러. 이것을 해결할 수 있었던 몇 가지 방법에 대한 기록.","title":"Docker Pull Error: 두 가지 해결 방법"},{"content":"로드밸런서: 로드밸런서 타입(Network or Application) 설정\n서브넷 설정\n보안그룹 설정\nresource \u0026#34;aws_lb\u0026#34; \u0026#34;alb\u0026#34; { name_prefix = var.alb_name load_balancer_type = \u0026#34;application\u0026#34; subnets = data.aws_subnets.default.ids security_groups = [aws_security_group.alb.id] } 로드밸런서 리스너: 특정 로드밸런서 참조 설정\n요청에서 포트와 프로토콜 확인.\ndefault action: 리스너 규칙에 정의되지 않은 요청 들어올시 반환할 응답값 설정\nresource \u0026#34;aws_lb_listener\u0026#34; \u0026#34;http\u0026#34; { load_balancer_arn = aws_lb.alb.arn port = 80 protocol = \u0026#34;HTTP\u0026#34; # By default, return a simple 404 page default_action { type = \u0026#34;fixed-response\u0026#34; fixed_response { content_type = \u0026#34;text/plain\u0026#34; message_body = \u0026#34;404: page not found\\n\u0026#34; status_code = 404 } } } 로드밸런서 리스너 규칙: 특정 리스너를 참조 설정\n클라이언트의 요청이 어떤 경로에 대한 요청인지 패턴 분석.\n어떤 경로일때 어디로 보내야하는지를 \u0026ldquo;로드밸런서 타겟그룹\u0026ldquo;으로 지정 즉, 리스너 규칙에는 리스너와 타겟그룹 연결작업이 진행된다.\nresource \u0026#34;aws_lb_listener_rule\u0026#34; \u0026#34;asg\u0026#34; { listener_arn = aws_lb_listener.http.arn priority = 100 condition { path_pattern { values = [\u0026#34;*\u0026#34;] } } # action: 요청이 조건의 규칙을 만족할때 수행될 동작 정의 action { type = \u0026#34;forward\u0026#34; target_group_arn = aws_lb_target_group.asg.arn } } ⚠️만약 요청이 \u0026ldquo;/static1/\u0026ldquo;경로로 시작할때는 대상그룹A로 전달하고, \u0026ldquo;/static2/\u0026ldquo;경로로 시작할때는 대상그룹B로 전달하도록 설정하기 위해서는 하나의 aws_lb_listener_rule이 아니라 2개의 aws_lb_listener_rule과 2개의 대상그룹으로 가능.\n**예시코드** # 대상 그룹 A 설정 resource \u0026#34;aws_lb_target_group\u0026#34; \u0026#34;group_a\u0026#34; { // 대상 그룹 A에 대한 설정... } # 대상 그룹 B 설정 resource \u0026#34;aws_lb_target_group\u0026#34; \u0026#34;group_b\u0026#34; { // 대상 그룹 B에 대한 설정... } # \u0026#34;/static1/\u0026#34; 경로로 시작하는 요청을 대상 그룹 A로 라우팅 resource \u0026#34;aws_lb_listener_rule\u0026#34; \u0026#34;rule_static1\u0026#34; { listener_arn = aws_lb_listener.front_end.arn priority = 100 action { type = \u0026#34;forward\u0026#34; target_group_arn = aws_lb_target_group.group_a.arn } condition { path_pattern { values = [\u0026#34;/static1/*\u0026#34;] } } } # \u0026#34;/static2/\u0026#34; 경로로 시작하는 요청을 대상 그룹 B로 라우팅 resource \u0026#34;aws_lb_listener_rule\u0026#34; \u0026#34;rule_static2\u0026#34; { listener_arn = aws_lb_listener.front_end.arn priority = 110 action { type = \u0026#34;forward\u0026#34; target_group_arn = aws_lb_target_group.group_b.arn } condition { path_pattern { values = [\u0026#34;/static2/*\u0026#34;] } } } 로드밸런서 타겟 그룹 헬스체크를 하는 주체는 로드밸런서이지만, 인스턴스 집합에 대해 구체적으로 어떤방식으로 헬스체크할지 설정되는 명세서\n오토스케일링 그룹에서 EC2 생성시, 그 EC2는 이 타겟그룹에 포함되어 생성된다.\n로드밸런서 타겟그룹은 **\u0026ldquo;로드밸런서 리스너 규칙\u0026rdquo;**에서 참조하기도 하고 **\u0026ldquo;오토스케일링 그룹\u0026rdquo;**에서 참조하기도 함\n오토스케일링 그룹에서 로드밸런서 타겟그룹을 참조하는 이유:\n오토스케일링 그룹의 헬스체크 특징: 로드밸런서 뿐만 아니라 오토스케일링도 헬스체크 기능을 제공한다.\n다만, Default 헬스체크인 “EC2 타입”으로는 대상그룹의 상태를 확인하지 않고 인스턴스 자체의 상태를 기반으로 헬스체크한다. AWS는 EC2 인스턴스의 상태를 주기적으로 확인하고, 인스턴스가 \u0026lsquo;running\u0026rsquo; 상태에서 벗어나면 문제가 있는 것으로 간주한다. 이 방식은 인스턴스가 OS 수준에서 정상적으로 작동하고 있는지를 확인하지만, 애플리케이션 수준의 문제는 감지하지 못할 수 있다.\n반면 ELB 타입으로 설정되면 로드밸런서 타겟그룹도 함께 설정이 요구된다, 결과적으로 로드밸런서가 타겟그룹에 속한 EC2들에게 타겟그룹에 설정된 내용을 견적대로 헬스체크를 할 수 있게된다.\n따라서, 로드 밸런서와 오토스케일링은 모두 헬스 체크를 진행하긴 하지만, 각각의 헬스 체크는 서로 다른 목적으로 사용된다.\n로드 밸런서는 요청을 정상적으로 처리할 수 있는 서버로만 요청을 전달하는 데 집중하는 반면,\n오토스케일링의 헬스 체크는 그룹 내 인스턴스의 상태를 모니터링하고, 필요한 경우 인스턴스를 교체하는 데 중점을 둔다.\n두 시스템은 함께 작동하여 인프라의 가용성과 탄력성을 높이는 데 기여한다.\nresource \u0026#34;aws_lb_target_group\u0026#34; \u0026#34;asg\u0026#34; { name = var.alb_name port = 80 protocol = \u0026#34;HTTP\u0026#34; vpc_id = data.aws_vpc.default.id health_check { path = \u0026#34;/\u0026#34; protocol = \u0026#34;HTTP\u0026#34; matcher = \u0026#34;200\u0026#34; interval = 15 timeout = 3 healthy_threshold = 2 unhealthy_threshold = 2 } } ","permalink":"https://jaylab.link/aws-tf-as-lb/","summary":"LoadBalancer, Listener, Listener Rule 그리고 Health Check에 대하여","title":"[AWS] Terraform으로 이해하는 LoadBalancer(feat. ASG)"},{"content":"Default Port 변경 참고링크\n# my.cnf 파일 위치 확인 mysqld --verbose --help | grep -A 1 \u0026#39;Default options\u0026#39; → my.cnf파일이 /opt/homebrew/etc/에 있는 것을 확인\nvi /opt/homebrew/etc/my.cnf # 3301 포트로 변경 ` port = 3301 ` mysql.server restart # 재시작하여 적용 완료 Public Key Retrieval is not allowed 에러 관련링크\n[Driver properties] 탭에서\nallowPublicKeyRetrieval → true로 수정\nuseSSL → false로 수정\n🙏 참고링크:\nhttps://velog.io/@inyong_pang/MySQL-설치와-초기-설정\nhttps://deeplify.dev/database/troubleshoot/public-key-retrieval-is-not-allowed\nhttps://losskatsu.github.io/database/mysql-install-mac/#1-mysql-다운로드-받기\n","permalink":"https://jaylab.link/mysql-set-default-port-and-public-key-error/","summary":"기본 포트 설정과 Public Key Retrieval is not allowed 에러에 대하여","title":"MySQL SET - Change Default Port, Public Key Error"},{"content":"👾 현상 IntelliJ에서 보여야할 파일들이 안보이는 현상\nfinder에서는 정상적으로 확인됨\n🔧 해결 IntelliJ 상단 File → Project Structure Project Settings → Modules → +(Add) → Import Module → 작업 디렉토리 선택 및 Open → Create ","permalink":"https://jaylab.link/intellij-invisible-file/","summary":"IntelliJ에서만 보여야할 작업 디렉토리 및 파일들이 안보이는 현상과 조치방법","title":"[IntelliJ] 프로젝트 파일 안보이는 현상 및 해결"},{"content":"Amazon Linux 2023 2023년 3월 15일 Amazon Linux 2 후속버전으로 출시\n클라우드 애플리케이션 개발 및 실행에 더욱 최적화된 환경을 제공\n기존 Amazon Linux 2 기반 메타데이터 조회 방법 curl http://169.254.169.254/latest/meta-data/ \u0026gt; (실행결과) ami-id ami-launch-index ami-manifest-path block-device-mapping/ events/ hostname identity-credentials/ instance-action instance-id instance-life-cycle instance-type local-hostname local-ipv4 mac metrics/ network/ placement/ profile public-hostname public-ipv4 public-keys/ reservation-id security-groups services/ # AMI Key 조회 curl http://169.254.169.254/latest/meta-data/ami-id # 인스턴스 ID 조회 curl http://169.254.169.254/latest/meta-data/instance-id \u0026gt; (출력결과) i-0df8321768b104bcb # Region 조회 curl http://169.254.169.254/latest/meta-data/placement/region \u0026gt; (출력결과) ap-northeast-2 Amazon Linux 2023 기반 메타데이터 조회 방법 TOKEN=$(curl --request PUT \u0026#34;http://169.254.169.254/latest/api/token\u0026#34; --header \u0026#34;X-aws-ec2-metadata-token-ttl-seconds: 3600\u0026#34;) echo $TOKEN INSTANCE_METADATA=$(curl -s http://169.254.169.254/latest/meta-data/ --header \u0026#34;X-aws-ec2-metadata-token: $TOKEN\u0026#34;) echo $INSTANCE_METADATA INSTANCE_ID=$(curl -s http://169.254.169.254/latest/meta-data/instance-id --header \u0026#34;X-aws-ec2-metadata-token: $TOKEN\u0026#34;) REGION=$(curl -s http://169.254.169.254/latest/meta-data/placement/region --header \u0026#34;X-aws-ec2-metadata-token: $TOKEN\u0026#34;) Amazon Linux 2023에서 MySQL 설치 및 DB 연결\n(참고 공식문서 링크: Link)\n# install sudo dnf install mariadb105 # check mysql --version # connect # (ex. user: master, password: tf-password, endpoint: tf-db.cso8wtmrwb6r.ap-northeast-2.rds.amazonaws.com) mysql -umaster -ptf-password -htf-db.cso8wtmrwb6r.ap-northeast-2.rds.amazonaws.com # Using MySQL MySQL [(none)]\u0026gt; show databases; \u0026gt; +--------------------+ | Database | +--------------------+ | information_schema | | innodb | | mysql | | performance_schema | | sys | | tf | +--------------------+ MySQL [(none)]\u0026gt; use tf \u0026gt; Database changed MySQL [tf]\u0026gt; show tables; \u0026gt; Empty set (0.00 sec) MySQL [tf]\u0026gt; quit \u0026gt; Bye ","permalink":"https://jaylab.link/aws-get-metadata-al2023/","summary":"Amazon Linux 2023 Machine Image 기반 서버에서 메타데이터 조회, MySQL 연결 방법","title":"[AWS] Getting Metadata on Amazon Linux 2023"},{"content":"Nuxt Framework 애플리케이션 배포를 위한 기본 지식 정리.\nnpx 패키지 러너의 확장된 형태. npm 5.2.0 버전부터 기존적으로 제공\nnpx를 사용하면 로컬 또는 글로벌로 설치하지 않고 npm 레지스트리에 있는 패키지 실행 가능. 이는 일회성으로 패키지를 사용해야 할때, 혹은 다양한 버전의 도구를 시험해보거나 프로젝트별로 다른 버전을 사용해야할 때 유용하다.\nnpm❓ Node Package Manager의 약자\n주요기능 두 가지: 패키지 레지스트리, 패키지 러너\nnpm 레지스트리\n전 세계의 개발자들이 만든 수많은 JavaScript 라이브러리와 애플리케이션을 호스팅하고 공유하는 중앙 저장소\n이 레지스트리를 통해 개발자들은 필요한 패키지를 찾고, 다운로드하며, 자신이 만든 패키지를 업로드하여 다른 사람들과 공유할 수 있음\nnpm 레지스트리는 웹 사이트를 통해서도 접근할 수 있으며, npm CLI를 통해 직접적으로 패키지를 설치하고 관리할 수 있음\nnpm 패키지 러너\nnpm의 CLI\n패키지 설치, 업데이트, 삭제 등의 작업을 수행하며 프로젝트의 의존성 관리를 도와준다\npackage.json 파일을 통해 프로젝트에서 사용하는 스크립트를 실행할 수 있도록 해준다.\nnpm run generate npm run 명령어 자체는 package.json 파일 내의 scripts 섹션에 정의된 스크립트를 실행시키는 명령어 따라서 package.json 파일에서 generate 섹션을 보면 다음과 같이 되어있는것을 볼 수 있다.\nnuxt generate: Nuxt.js v2에서 사용되는 명령어\nNuxt.js 애플리케이션을 정적 파일들로 빌드한다. 애플리케이션의 모든 페이지를 사전 렌더링하여 HTML 파일로 변환하고, 필요한 JavaScript, CSS, 이미지 파일들을 생성한다.\nnpx nuxi generate Nuxt.js 3.0 버전 이상에서 사용\nNuxt.js 3.0에서는 nuxi라는 새로운 CLI 도구를 도입했다. 이 nuxi generate 명령어 또한 애플리케이션을 정적 파일들로 빌드하는 역할을 하지만, Nuxt.js 3.0의 새로운 아키텍처와 기능을 활용하여 더 효율적으로 수행된다.\nnpx는 Node 패키지를 실행할 때 사용하는 명령어. 로컬에 설치하지 않고도 패키지를 실행할 수 있게 해준다. 따라서 프로젝트에 nuxi가 설치되어 있지 않아도 nuxi를 실행하여 Nuxt 3.0 프로젝트를 정적 파일로 빌드할 수 있다.\n","permalink":"https://jaylab.link/how-to-build-static-web-nuxt/","summary":"Nuxt Framework 애플리케이션을 배포하기 위해 알아야할 기본적인 커맨드","title":"[Nuxt] How to build a static website"},{"content":"nvm -v 실행시 발생했던 에러에 대한 조치방법에 대한 기록.\nnvm의 작업 디렉터리 생성 nvm이 설치한 노드 버전을 저장할 디렉터리가 만약 아직 존재하지 않는다면 생성 필요\nmkdir ~/.nvm shell 프로필 업데이트 새로운 shell의 세션마다 nvm이 자동으로 초기화 되도록 하려면 shell profile file에 내용 추가 필요 (ex: ~/.bash_profile, ~/.zshrc, ~/.profile 등. 나의 경우 ~/.zshrc에 추가)\nexport NVM_DIR=\u0026#34;$HOME/.nvm\u0026#34; [ -s \u0026#34;/opt/homebrew/opt/nvm/nvm.sh\u0026#34; ] \u0026amp;\u0026amp; \\. \u0026#34;/opt/homebrew/opt/nvm/nvm.sh\u0026#34; # This loads nvm [ -s \u0026#34;/opt/homebrew/opt/nvm/etc/bash_completion.d/nvm\u0026#34; ] \u0026amp;\u0026amp; \\. \u0026#34;/opt/homebrew/opt/nvm/etc/bash_completion.d/nvm\u0026#34; 위의 변경사항 활성화 source ~/.zshrc 설치 확인 nvm --version 추가 내용 사용 가능한 Node.js 버전 확인: nvm list-remote\n특정 Node.js 버전 설치: nvm install {node_version}\n","permalink":"https://jaylab.link/nvm-error-action/","summary":"nvm -v 실행시 발생했던 에러에 대한 조치방법","title":"Errors when changing the \"nvm\" version"},{"content":"하나의 repository가 있다. 이 저장소의 코드는 A라는 기존 템플릿 코드에 B 템플릿 코드를 합친 형태다. 하나의 도메인 안에서 A와 B 화면이 유기적으로 사용되어야했고, A와 B에 각각에 있는 스타일이나 기능 코드를 쉽게 가져다 쓰기 위해 이렇게 병합된 형태가 되었다. 그런데 애플리케이션의 사이즈가 커지면서 점점 패키지 충돌이 발생하게 되었다. 그래서 처음부터 다시 설계하여 분리하는것이 적절하다고 판단했고, 이 때 Monolithic Repository 구조를 적용하게되었다.\nMonorepo 다수의 프로젝트 코드를 하나의 리포지토리에서 관리하는 접근 방식. 예를 들어, 하나의 대형 소프트웨어 프로젝트가 여러 서브 프로젝트로 나뉘어 있을 때, 각각을 별도의 리포지토리에서 관리하는 대신 모든 코드를 한 곳에 모아두는 방식.\n출처: https://levelup.gitconnected.com/moving-from-multiple-repositories-to-a-lerna-js-mono-repo-faa97aeee35b\n장점 통합된 버전 관리: 모든 프로젝트의 코드가 한 곳에 있어, 의존성 관리가 용이. 버전 충돌을 쉽게 해결하고, 코드 베이스 전체에서 한 번에 업데이트를 진행할 수 있습니다. 또 한편으로는 서브 프로젝트로 분리되는 구조 덕분에 서로 다른 버전의 의존 패키지를 사용하기가 쉬워진다.(즉 소프트웨어 확장이 쉬워진다)\n재사용 용이: 공통으로 사용되는 코드나 라이브러리를 쉽게 공유하고 재사용 가능. 코드 중복 ⬇️ 효율성 ⬆️\n팀 간 협업 강화: 하나의 저장소에서 작업함으로써, 팀 간의 협업이 원활해진다. 서로 다른 서브 프로젝트를 관리하는 개발자들 입장에서도 필요한 정보를 쉽게 찾을 수 있게되고, 변경 사항을 쉽게 파악할 수 있다.\n단점 규모의 복잡성: 저장소의 크기가 방대해지면서 관리가 복잡해질 수 있다. → 빌드 시간 ⬆️\n보안 문제: 하나의 저장소에 여러 프로젝트가 함께 있을 경우 보안상의 리스크가 커질 수 있다. 특정 부분의 접근 권한을 제한하는 것이 더 어려워질 수 있다.\nMonorepo 방식으로 구조 변경 후 Monorepo 구조로 전환 후, 전 보다 많은 부분에서 편리해졌다. 처음엔 단순히 의존성 충돌을 줄이려는 목적이었지만, 실제로 경험한 장점은 그보다 많았다. 서로 다른 서브 앱 간 상호작용이 필요한 부분을 빠르게 파악할 수 있게 되었고, 이에 따라 더 효과적으로 작업을 진행할 수 있었다. 또한, 불필요한 코드 중복을 크게 줄일 수 있었고, 전체 프로젝트의 코드 히스토리를 한눈에 파악하기도 쉬워졌다. 빌드와 배포 과정 역시 이전보다 훨씬 간단해졌고, 팀 전체의 작업 흐름이 더욱 빠르고 효율적으로 변했다.\n하지만 몇가지 의문은 여전히 남아있다. 하나의 저장소에 모든 것이 통합되다보니 특정 서브 프로젝트에 대한 커밋 히스토리만 추적하는 것이 예전보다 조금 더 복잡할것 같다는 생각이 들었다. 우리 프로젝트는 2~3개의 서브 프로젝트만 포함하고 있었기에 이러한 Monorepo 구조에서 더 큰 이점을 얻을 수 있었지만, 더 많은 서브 프로젝트가 포함되는 경우에는 이 방식이 효율적일지는 여전히 의문으로 남아있다. 이 점에서는 역시 Multi-repo 방식이 더 나을 것 같다.\n기존의 애플리케이션을 monorepo 구조로 재설계하기 위해 활용할 수 있는 도구로는 Yarn workspaces, Lerna, Nx, Turborepo 등이 있는데, 나는 이 중에서 nx를 사용하였다. Sample code - package.json \u0026#34;scripts\u0026#34;: { \u0026#34;start:A\u0026#34;: \u0026#34;npx nx serve A --port 3005\u0026#34;, \u0026#34;start:B\u0026#34;: \u0026#34;npx nx serve B --port 3006\u0026#34;, \u0026#34;start:all\u0026#34;: \u0026#34;npm-run-all --parallel start:A start:B\u0026#34;, \u0026#34;build:A\u0026#34;: \u0026#34;npx nx run A:build\u0026#34;, \u0026#34;build:B\u0026#34;: \u0026#34;npx nx run B:build\u0026#34;, \u0026#34;build:generate\u0026#34;: \u0026#34;(cd apps/A \u0026amp;\u0026amp; npx nuxi generate --dotenv .env.production) \u0026amp;\u0026amp; (cd apps/B \u0026amp;\u0026amp; npx nuxi generate)\u0026#34; }, 애플리케이션 최상위 레벨에 있는 package.json 예시\n","permalink":"https://jaylab.link/front-monorepo/","summary":"하나의 repository가 있다. 이 저장소의 코드는 A라는 기존 템플릿 코드에 B 템플릿 코드를 합친 형태다. 하나의 도메인 안에서 A와 B 화면이 유기적으로 사용되어야했고, A와 B에 각각에 있는 스타일이나 기능 코드를 쉽게 가져다 쓰기 위해 이렇게 병합된 형태가 되었다. 그런데 애플리케이션의 사이즈가 커지면서 점점 패키지 충돌이 발생하게 되었다. 그래서 처음부터 다시 설계하여 분리하는것이 적절하다고 판단했고, 이 때 Monolithic Repository 구조를 적용하게되었다.","title":"[Nx] Monorepo structure design"},{"content":"어느 Github 저장소를 Fork하여 나의 repository로 옮긴 후 이 코드를 가지고 몇번의 commit \u0026amp; push 작업을 해왔다. 어느날 메인(Fork했던 대상 Repository) 저장소에서 그 동안 이루어진 수정내역을 나의 repository에 반영해줘야할 시기가 왔다. 이 때 vs code로 진행했던 작업과정을 순서대로 기록해보았다.\n용어 정리 fork : 다른 사람의 repository를 내가 어떤 부분을 수정하거나 추가 기능을 넣고 싶을 때 해당 repository를 내 github repository로 복제하는 기능\norigin : 다른 사람의 GitHub의 저장소를 Fork한 경우 내 GitHub의 Repository를 origin이라고 표현한다.\nupstream : 내가 fork를 시도한 대상이되는 원본 저장소를 upstream이라고 부른다.(따라서 해당 코드에 직접적인 쓰기권한은 없다)\n즉, origin와 upstream 모두 Github에 올라가있는 remote(원격) 저장소\nlocal : 내 컴퓨터에 생성된 git repository file\nUpstream remote 연결 우선, clone 받아 작업하던 경로에서 git remote -v 명령을 실행하면 다음과 같이 표시된다\n$ git remote -v origin https://github.com/YOUR_USERNAME/YOUR_FORK.git (fetch) origin https://github.com/YOUR_USERNAME/YOUR_FORK.git (push) 나는 원본저장소(upstream)의 수정내역을 내 로컬 브랜치에 반영해줘야하기 때문에 upstream을 바라볼수 있도록 다음과 같이 설정해준다.\n$ git remote add upstream https://github.com/{ORIGINAL_OWNER}/{ORIGINAL_REPOSITORY}.git 다시 git remote -v 명령을 실행하면 출력내용이 바뀐 것을 볼 수 있다.\n$ git remote -v origin https://github.com/YOUR_USERNAME/YOUR_FORK.git (fetch) origin https://github.com/YOUR_USERNAME/YOUR_FORK.git (push) upstream https://github.com/ORIGINAL_OWNER/ORIGINAL_REPOSITORY.git (fetch) upstream https://github.com/ORIGINAL_OWNER/ORIGINAL_REPOSITORY.git (push) upstream repository 최신 정보 가져오기 $ git fetch upstream 이 후부터는 upstream으로부터 commit 내역을 받아올때 발생할 수도 있는 소스 충돌에 대비하기위해 Visual Studio Code로 진행하였고,\nextension plugin으로써 “Gitlens”를 설치하였다.\n(선택사항) 기존 로컬저장소 작업 브랜치 백업 작업중인 브랜치 우 클릭 \u0026gt; Create Branch \u0026gt; ‘main_backup’ 입력 \u0026gt; enter\n이렇게 생성된 브랜치는 만일을 위한 단순 백업 목적 (사용하지 않음)\nupstream 저장소 변경내역 반영하기 upstream에서 commit 내역을 가져올 대상 branch 우클릭 \u0026gt; “Merge Branch into Current Branch…” \u0026gt; Merge 진행\n소스 충돌 내역 조치하기 Merge를 진행하면 내가 작업했던 내용과 충돌이 발생할 수 있는데 다음과 같이 순서대로 처리하면된다.\n좌측 상단 ‘변경 사항 병합’에서 충돌이 발생된 파일 클릭\n[병합 편집기에서 확인] 클릭\n적절하게 수정하기 충돌이 발생한 코드 위에, 작게 표시되는 이 버튼도 적절하게 사용하면 수월하게 병합을 진행할 수 있다.\n그리고 코드 하나가 수정되었다고 바로 **[병합 완료]**를 누르기 전에 우측 상단에 표시되는 화살표를 통해 다른 충돌 내용까지 모두 처리한 후 **[병합 완료]**를 누르자.\n[병합 완료]를 눌렀다면 왼쪽에 보이는것 처럼 적절한 commit 메세지를 입력한 후 [커밋]을 누르면, upstream의 최신 변경사항이 내 로컬에 반영된다.\n이 후 [동기화] 버튼 클릭하여 push를 진행하면 upstream 수정내역 반영이 완료된다.\n","permalink":"https://jaylab.link/git-upstream-merge/","summary":"어느 Github 저장소를 Fork하여 나의 repository로 옮긴 후 이 코드를 가지고 몇번의 commit \u0026amp; push 작업을 해왔다. 어느날 메인(Fork했던 대상 Repository) 저장소에서 그 동안 이루어진 수정내역을 나의 repository에 반영해줘야할 시기가 왔다. 이 때 vs code로 진행했던 작업과정을 순서대로 기록해보았다.","title":"[Git] Upstream commit history merge"},{"content":"뜬금없이 발생된 이번 문제는 이미 예전에도 몇번 경험한 현상이지만, 이번에 또 다시 재발한 김에 그 해결법을 간단히 정리해보았다.\n문제점 코드 수정 내역이 많지 않았음에도, commit시 줄바꿈 기호 변환에 따라 전체 코드가 수정되는 현상\n📌참고사항\nLF 멀틱스, 유닉스, 리눅스, 제닉스, AIX, OS X, FreeBSD CR+LF DEC TOPS-10, RT-11, CP/M, MP/M, 도스, OS/2, 윈도, 심비안 OS, 팜 OS CR 코모도어 8비트 머신, TRS-80, 애플 II, 맥 OS (버전 9 이하), OS-9 RS POSIX 이전의 QNX 윈도우 ↔ 맥 OS 협업 간 발생할 수 있는 현상\n윈도우에서 CRLF 방식으로 줄바꿈처리되어 commit된 코드를, 맥에서 코드변경이 발생하면 LF 방식으로 자동변경됨에 따른 문제\n해결 방법 git config --list core.autocrlf 값의 종류\ntrue(windows용) : commit할때 CRLF를 LF를 변환하고 checkout할때 LF를 CRLF로 변환한다.\ninput(*nix용) : commit할때만 CRLF를 LF로 변환한다.\nfalse : autocrlf 미사용\n나의 경우 core.autocrlf = false로 되어있었다. (여기까진 정상. 만약 false가 아니라면 false로 변경하자)\n다시 커밋 시 마찬가지로 다음과 같은 경고창이 출력된다.\nIntelliJ와 같이 인텔리한 IDE에서는 core.autocrlf 설정이 되어있지 않은 경우\n**\u0026ldquo;You are about to commit CRLF line separators to the Git repository\u0026rdquo;**라는 경고 메시지가 출력된다.\n[Fix and Commit] 버튼 클릭 시 core.autocrlf = input으로 설정 해버리기 때문에, [Commit As Is] 버튼을 클릭하여 core.autocrlf = false 설정을 유지하자.\n출처 : https://mingggu.tistory.com/154\n","permalink":"https://jaylab.link/git-crlf-lf-conflict/","summary":"인텔리제이를 통한 Git 작업시 종종 겪을 수 있는 CRLF/LF 개행 변경 관련 문제와 그 해결법","title":"[IntelliJ] CRLF/LF 개행 변경 문제 해결"},{"content":"Nuxt3에서 HTML 내용 일부를 PDF 파일로 추출하는 방법 📌 기능구현에 사용된 패키지 : \u0026ldquo;html2pdf\u0026rdquo;\n패키지 설치 npm install --save html2pdf.js 위 명령을 입력하면 package.json 파일의 dependencies 안에 \u0026quot;html2pdf.js\u0026quot;: \u0026quot;^0.10.1\u0026quot;, 한줄 추가된 것을 확인할 수 있다.\nHTML to PDF Code PDF로 다운로드하고자 하는 특정 HTML 요소의 id를 html-to-pdf 라고 가정 // PDF 다운로드 버튼 추가 예시 \u0026lt;button type=\u0026#34;button\u0026#34; @click=\u0026#34;generatePdf\u0026#34;\u0026gt; PDF Download\u0026lt;/button\u0026gt; PDF 다운로드 기능 js \u0026lt;script setup lang=\u0026#34;ts\u0026#34;\u0026gt; import html2pdf from \u0026#34;html2pdf.js\u0026#34; const generatePdf = () =\u0026gt; { let element = document.getElementById(\u0026#39;html-to-pdf\u0026#39;); let opt = { margin: 0, filename: \u0026#39;myfile.pdf\u0026#39;, image: { type: \u0026#39;jpeg\u0026#39;, quality: 0.98 }, html2canvas: { scale: 2 }, jsPDF: { unit: \u0026#39;in\u0026#39;, format: \u0026#39;letter\u0026#39;, orientation: \u0026#39;portrait\u0026#39; } }; // New Promise-based usage: html2pdf().set(opt).from(element).save(); } \u0026lt;/script\u0026gt; ","permalink":"https://jaylab.link/nuxt-html-to-pdf/","summary":"\u003ch2 id=\"nuxt3에서-html-내용-일부를-pdf-파일로-추출하는-방법\"\u003eNuxt3에서 HTML 내용 일부를 PDF 파일로 추출하는 방법\u003c/h2\u003e\n\u003cp\u003e📌 기능구현에 사용된 패키지 : \u0026ldquo;html2pdf\u0026rdquo;\u003c/p\u003e\n\u003ch4 id=\"패키지-설치\"\u003e패키지 설치\u003c/h4\u003e\n\u003cdiv class=\"highlight\"\u003e\u003cpre tabindex=\"0\" class=\"chroma\"\u003e\u003ccode class=\"language-shell\" data-lang=\"shell\"\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003enpm install --save html2pdf.js\n\u003c/span\u003e\u003c/span\u003e\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003cp\u003e위 명령을 입력하면 package.json 파일의 dependencies 안에 \u003ccode\u003e\u0026quot;html2pdf.js\u0026quot;: \u0026quot;^0.10.1\u0026quot;,\u003c/code\u003e 한줄 추가된 것을 확인할 수 있다.\u003c/p\u003e\n\u003ch4 id=\"html-to-pdf-code\"\u003eHTML to PDF Code\u003c/h4\u003e\n\u003cul\u003e\n\u003cli\u003ePDF로 다운로드하고자 하는 특정 HTML 요소의 id를 html-to-pdf 라고 가정\u003c/li\u003e\n\u003c/ul\u003e\n\u003cdiv class=\"highlight\"\u003e\u003cpre tabindex=\"0\" class=\"chroma\"\u003e\u003ccode class=\"language-html\" data-lang=\"html\"\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e// PDF 다운로드 버튼 추가 예시\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"p\"\u003e\u0026lt;\u003c/span\u003e\u003cspan class=\"nt\"\u003ebutton\u003c/span\u003e \u003cspan class=\"na\"\u003etype\u003c/span\u003e\u003cspan class=\"o\"\u003e=\u003c/span\u003e\u003cspan class=\"s\"\u003e\u0026#34;button\u0026#34;\u003c/span\u003e \u003cspan class=\"err\"\u003e@\u003c/span\u003e\u003cspan class=\"na\"\u003eclick\u003c/span\u003e\u003cspan class=\"o\"\u003e=\u003c/span\u003e\u003cspan class=\"s\"\u003e\u0026#34;generatePdf\u0026#34;\u003c/span\u003e\u003cspan class=\"p\"\u003e\u0026gt;\u003c/span\u003e PDF Download\u003cspan class=\"p\"\u003e\u0026lt;/\u003c/span\u003e\u003cspan class=\"nt\"\u003ebutton\u003c/span\u003e\u003cspan class=\"p\"\u003e\u0026gt;\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003cul\u003e\n\u003cli\u003ePDF 다운로드 기능 js\u003c/li\u003e\n\u003c/ul\u003e\n\u003cdiv class=\"highlight\"\u003e\u003cpre tabindex=\"0\" class=\"chroma\"\u003e\u003ccode class=\"language-javascript\" data-lang=\"javascript\"\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"o\"\u003e\u0026lt;\u003c/span\u003e\u003cspan class=\"nx\"\u003escript\u003c/span\u003e \u003cspan class=\"nx\"\u003esetup\u003c/span\u003e \u003cspan class=\"nx\"\u003elang\u003c/span\u003e\u003cspan class=\"o\"\u003e=\u003c/span\u003e\u003cspan class=\"s2\"\u003e\u0026#34;ts\u0026#34;\u003c/span\u003e\u003cspan class=\"o\"\u003e\u0026gt;\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"kr\"\u003eimport\u003c/span\u003e \u003cspan class=\"nx\"\u003ehtml2pdf\u003c/span\u003e \u003cspan class=\"nx\"\u003efrom\u003c/span\u003e \u003cspan class=\"s2\"\u003e\u0026#34;html2pdf.js\u0026#34;\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"kr\"\u003econst\u003c/span\u003e \u003cspan class=\"nx\"\u003egeneratePdf\u003c/span\u003e \u003cspan class=\"o\"\u003e=\u003c/span\u003e \u003cspan class=\"p\"\u003e()\u003c/span\u003e \u003cspan class=\"p\"\u003e=\u0026gt;\u003c/span\u003e \u003cspan class=\"p\"\u003e{\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e  \u003cspan class=\"kd\"\u003elet\u003c/span\u003e \u003cspan class=\"nx\"\u003eelement\u003c/span\u003e \u003cspan class=\"o\"\u003e=\u003c/span\u003e \u003cspan class=\"nb\"\u003edocument\u003c/span\u003e\u003cspan class=\"p\"\u003e.\u003c/span\u003e\u003cspan class=\"nx\"\u003egetElementById\u003c/span\u003e\u003cspan class=\"p\"\u003e(\u003c/span\u003e\u003cspan class=\"s1\"\u003e\u0026#39;html-to-pdf\u0026#39;\u003c/span\u003e\u003cspan class=\"p\"\u003e);\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e  \u003cspan class=\"kd\"\u003elet\u003c/span\u003e \u003cspan class=\"nx\"\u003eopt\u003c/span\u003e \u003cspan class=\"o\"\u003e=\u003c/span\u003e \u003cspan class=\"p\"\u003e{\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e    \u003cspan class=\"nx\"\u003emargin\u003c/span\u003e\u003cspan class=\"o\"\u003e:\u003c/span\u003e       \u003cspan class=\"mi\"\u003e0\u003c/span\u003e\u003cspan class=\"p\"\u003e,\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e    \u003cspan class=\"nx\"\u003efilename\u003c/span\u003e\u003cspan class=\"o\"\u003e:\u003c/span\u003e     \u003cspan class=\"s1\"\u003e\u0026#39;myfile.pdf\u0026#39;\u003c/span\u003e\u003cspan class=\"p\"\u003e,\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e    \u003cspan class=\"nx\"\u003eimage\u003c/span\u003e\u003cspan class=\"o\"\u003e:\u003c/span\u003e        \u003cspan class=\"p\"\u003e{\u003c/span\u003e \u003cspan class=\"nx\"\u003etype\u003c/span\u003e\u003cspan class=\"o\"\u003e:\u003c/span\u003e \u003cspan class=\"s1\"\u003e\u0026#39;jpeg\u0026#39;\u003c/span\u003e\u003cspan class=\"p\"\u003e,\u003c/span\u003e \u003cspan class=\"nx\"\u003equality\u003c/span\u003e\u003cspan class=\"o\"\u003e:\u003c/span\u003e \u003cspan class=\"mf\"\u003e0.98\u003c/span\u003e \u003cspan class=\"p\"\u003e},\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e    \u003cspan class=\"nx\"\u003ehtml2canvas\u003c/span\u003e\u003cspan class=\"o\"\u003e:\u003c/span\u003e  \u003cspan class=\"p\"\u003e{\u003c/span\u003e \u003cspan class=\"nx\"\u003escale\u003c/span\u003e\u003cspan class=\"o\"\u003e:\u003c/span\u003e \u003cspan class=\"mi\"\u003e2\u003c/span\u003e \u003cspan class=\"p\"\u003e},\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e    \u003cspan class=\"nx\"\u003ejsPDF\u003c/span\u003e\u003cspan class=\"o\"\u003e:\u003c/span\u003e        \u003cspan class=\"p\"\u003e{\u003c/span\u003e \u003cspan class=\"nx\"\u003eunit\u003c/span\u003e\u003cspan class=\"o\"\u003e:\u003c/span\u003e \u003cspan class=\"s1\"\u003e\u0026#39;in\u0026#39;\u003c/span\u003e\u003cspan class=\"p\"\u003e,\u003c/span\u003e \u003cspan class=\"nx\"\u003eformat\u003c/span\u003e\u003cspan class=\"o\"\u003e:\u003c/span\u003e \u003cspan class=\"s1\"\u003e\u0026#39;letter\u0026#39;\u003c/span\u003e\u003cspan class=\"p\"\u003e,\u003c/span\u003e \u003cspan class=\"nx\"\u003eorientation\u003c/span\u003e\u003cspan class=\"o\"\u003e:\u003c/span\u003e \u003cspan class=\"s1\"\u003e\u0026#39;portrait\u0026#39;\u003c/span\u003e \u003cspan class=\"p\"\u003e}\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e  \u003cspan class=\"p\"\u003e};\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e  \u003cspan class=\"c1\"\u003e// New Promise-based usage:\n\u003c/span\u003e\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e  \u003cspan class=\"nx\"\u003ehtml2pdf\u003c/span\u003e\u003cspan class=\"p\"\u003e().\u003c/span\u003e\u003cspan class=\"nx\"\u003eset\u003c/span\u003e\u003cspan class=\"p\"\u003e(\u003c/span\u003e\u003cspan class=\"nx\"\u003eopt\u003c/span\u003e\u003cspan class=\"p\"\u003e).\u003c/span\u003e\u003cspan class=\"nx\"\u003efrom\u003c/span\u003e\u003cspan class=\"p\"\u003e(\u003c/span\u003e\u003cspan class=\"nx\"\u003eelement\u003c/span\u003e\u003cspan class=\"p\"\u003e).\u003c/span\u003e\u003cspan class=\"nx\"\u003esave\u003c/span\u003e\u003cspan class=\"p\"\u003e();\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"p\"\u003e}\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"o\"\u003e\u0026lt;\u003c/span\u003e\u003cspan class=\"err\"\u003e/script\u0026gt;\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e","title":"[Nuxt3] HTML to PDF"},{"content":"현상 git push 진행시 무한로딩\nprogress bar에 표시되는 내용으로는 모든 파일의 push가 100% 이루어졌다는 듯한 수치가 표시되지만, 이 상태에서 계속 pushing 상태\npush 자체가 안되는 건지 확인하기 위해 수정사항 원복 후 다시 살짝만 수정 후 push 하니 push 정상. (이 때문에 credentials 정보 혹은 remote 설정 상의 문제는 아닌 것으로 추정)\npush 될 파일 중 올라갈 수 없는 내용의 파일이 있어서? 라고 하기엔 그 정도로 의심가는 파일이 없음\n에러 로그도 없이 무한 로딩되는 악질적인 문제 원인 stackoverflow에서 다음과 같은 원인을 알 수 있었다.\n결론적으로, HTTP 전송 간 Post 사용시 버퍼크기 제한에 의한 현상\n해결 git config --global http.postBuffer 157286400 위의 터미널 명령어를 통해서 버퍼 크기 제한을 늘려주니 정상적으로 push 완료\n","permalink":"https://jaylab.link/git-push-infinite-loading/","summary":"Source Push infinite loading","title":"[Git] 소스코드 Push 무한로딩 현상"},{"content":"기존 코드 import \u0026#39;package:flutter/material.dart\u0026#39;; import \u0026#39;package:meals_clone/screens/categories.dart\u0026#39;; // import \u0026#39;package:riverpod/riverpod.dart\u0026#39;; class TabScreen extends StatefulWidget { const TabScreen({super.key}); @override State\u0026lt;TabScreen\u0026gt; createState() =\u0026gt; _TabScreenState(); } class _TabScreenState extends State\u0026lt;TabScreen\u0026gt; { int _selectedPageIndex = 0; Widget categoriesScreen = const CategoriesScreen(); Widget placeholder = const Placeholder(); Widget activePage = const CategoriesScreen(); void _selectPage(int index){ setState(() { _selectedPageIndex = index; }); if(_selectedPageIndex == 0){ activePage = categoriesScreen; } else if(_selectedPageIndex == 1){ activePage = placeholder; } } @override Widget build(BuildContext context) { return Scaffold( appBar: AppBar( title: const Text(\u0026#39;Categories\u0026#39;), centerTitle: true, ), // body: const CategoriesScreen(), body: activePage, bottomNavigationBar: BottomNavigationBar( items: const [ BottomNavigationBarItem( icon: Icon( Icons.set_meal, size: 30, ), label: \u0026#39;카테고리\u0026#39;), BottomNavigationBarItem( icon: Icon( Icons.star, size: 30, ), label: \u0026#39;즐겨찾기\u0026#39;) ], showSelectedLabels: false, // 선택된 레이블 숨기기 showUnselectedLabels: false, // 선택되지 않은 레이블 숨기기 currentIndex: _selectedPageIndex, onTap: _selectPage, ), ); } } 수정된 코드 import \u0026#39;package:flutter/material.dart\u0026#39;; import \u0026#39;package:meals_clone/screens/categories.dart\u0026#39;; // import \u0026#39;package:riverpod/riverpod.dart\u0026#39;; class TabScreen extends StatefulWidget { const TabScreen({super.key}); @override State\u0026lt;TabScreen\u0026gt; createState() =\u0026gt; _TabScreenState(); } class _TabScreenState extends State\u0026lt;TabScreen\u0026gt; { int _selectedPageIndex = 0; void _selectPage(int index) { setState(() { _selectedPageIndex = index; }); } @override Widget build(BuildContext context) { // 현재 선택된 페이지에 따라 적절한 위젯을 동적으로 선택합니다. Widget activePage = _selectedPageIndex == 0 ? const CategoriesScreen() : const Placeholder(); return Scaffold( appBar: AppBar( title: const Text(\u0026#39;Categories\u0026#39;), centerTitle: true, ), body: activePage, bottomNavigationBar: BottomNavigationBar( items: const [ BottomNavigationBarItem( icon: Icon( Icons.set_meal, size: 30, ), label: \u0026#39;카테고리\u0026#39;), BottomNavigationBarItem( icon: Icon( Icons.star, size: 30, ), label: \u0026#39;즐겨찾기\u0026#39;) ], showSelectedLabels: false, // 선택된 레이블 숨기기 showUnselectedLabels: false, // 선택되지 않은 레이블 숨기기 currentIndex: _selectedPageIndex, onTap: _selectPage, ), ); } } 수정된 내용 불필요한 인스턴스화 제거: categoriesScreen과 placeholder 위젯을 미리 인스턴스화할 필요가 없다. 이들은 필요할 때 생성될 수 있으며, 위와 같이 수정하면 메모리 사용량을 줄이고 성능을 향상시킬 수 있다.\n상태 관리 개선: 상태를 더 효율적으로 관리하기 위해, _selectPage 메서드 내에서 activePage를 직접 변경하는 대신, build 메서드 내에서 _selectedPageIndex에 기반하여 동적으로 페이지를 결정하도록 수정되었다.\n+추가 의문사항 이렇게 수정하고나니 만약 탭이 여러 개일 때는 어떻게 구현해야 적절할지, 그리고 방식에 따라서 메모리는 어떻게 사용될지 궁금해서 찾아보았다.\n1. List 방식 설명:\nList\u0026lt;Widget\u0026gt; 방식에서는 탭에 대응하는 모든 위젯을 List에 미리 저장한다. 탭 전환 시, 이 List에서 해당 인덱스의 위젯을 가져와 렌더링한다.\n이 방식에서는 각 탭의 위젯이 List에 미리 인스턴스화되어 있으며, 탭 전환 시 해당 인스턴스를 재사용한다.\n장점:\n초기 세팅의 단순함: 모든 탭의 위젯을 미리 정의하고 관리하기 때문에, 초기 코드 구성이 단순.\n렌더링 성능: 이미 생성된 위젯 인스턴스를 재사용하기 때문에, 탭 전환 자체는 빠르게 이루어진다.\n단점:\n메모리 사용량: 모든 탭의 위젯이 메모리에 미리 로드되므로, 앱 시작 시 메모리 사용량이 높아질 수 있다.\n상태 유지의 부재: 탭 전환 시 위젯의 상태가 유지되지 않는다. 새로운 탭으로 전환할 때마다 위젯이 재생성될 수 있으며, 이는 이전 탭의 상태가 초기화될 수 있음을 의미한다.\n예시 코드:\nclass MyTabScreen extends StatefulWidget { @override _MyTabScreenState createState() =\u0026gt; _MyTabScreenState(); } class _MyTabScreenState extends State\u0026lt;MyTabScreen\u0026gt; { int _selectedIndex = 0; final List\u0026lt;Widget\u0026gt; _tabs = [ WidgetA(), // 탭 A에 대응하는 위젯 WidgetB(), // 탭 B에 대응하는 위젯 WidgetC(), // 탭 C에 대응하는 위젯 ]; @override Widget build(BuildContext context) { return Scaffold( body: _tabs[_selectedIndex], // 현재 선택된 탭의 위젯을 렌더링 bottomNavigationBar: BottomNavigationBar( currentIndex: _selectedIndex, onTap: (index) =\u0026gt; setState(() =\u0026gt; _selectedIndex = index), // BottomNavigationBarItems 정의 ), ); } } 2. IndexedStack 방식 설명:\nIndexedStack은 모든 자식 위젯을 미리 인스턴스화하지만, 한 번에 하나의 자식만 화면에 표시한다.\n이 방식은 탭 간 상태를 유지하며 탭 전환 시 빠른 반응성을 제공한다.\n장점:\n탭의 상태가 유지된다. 예를 들어, 사용자가 스크롤한 위치는 그대로 유지된다.\n이미 로드된 위젯을 화면에 표시하기만 하면 되기 때문에 탭 전환 시 빠르게 반응한다.\n단점:\n초기 앱 로드 시 모든 탭의 위젯이 메모리에 로드된다. 이는 초기 메모리 사용량이 크다는 것을 의미한다. 예시 코드:\nclass MyTabScreen extends StatefulWidget { @override _MyTabScreenState createState() =\u0026gt; _MyTabScreenState(); } class _MyTabScreenState extends State\u0026lt;MyTabScreen\u0026gt; { int _selectedIndex = 0; @override Widget build(BuildContext context) { return Scaffold( body: IndexedStack( index: _selectedIndex, children: [ WidgetA(), WidgetB(), WidgetC(), ], ), bottomNavigationBar: BottomNavigationBar( currentIndex: _selectedIndex, onTap: (index) =\u0026gt; setState(() =\u0026gt; _selectedIndex = index), // ... BottomNavigationBarItems ... ), ); } } 3. 지연 인스턴스화 (Lazy Instantiation) 방식 설명:\n지연 인스턴스화 방식은 탭의 위젯을 실제로 필요할 때 생성한다. 즉, 탭이 활성화될 때 해당 탭의 위젯이 인스턴스화된다.\n이 방식은 메모리 사용을 최적화할 수 있으나, 탭의 상태가 유지되지 않는다.\n장점:\n초기 앱 로드 시에는 필요한 탭의 위젯만 메모리에 로드되어 메모리 사용량이 적다.\n사용하지 않는 탭의 위젯은 메모리에 로드되지 않는다.\n단점:\n탭 전환 시마다 새로운 위젯 인스턴스를 생성해야 한다.\n탭의 상태가 유지되지 않는다.\n예시 코드:\nclass MyTabScreen extends StatefulWidget { @override _MyTabScreenState createState() =\u0026gt; _MyTabScreenState(); } class _MyTabScreenState extends State\u0026lt;MyTabScreen\u0026gt; { int _selectedIndex = 0; Widget _buildTab(int index) { switch (index) { case 0: return WidgetA(); case 1: return WidgetB(); case 2: return WidgetC(); default: return Placeholder(); } } @override Widget build(BuildContext context) { return Scaffold( body: _buildTab(_selectedIndex), bottomNavigationBar: BottomNavigationBar( currentIndex: _selectedIndex, onTap: (index) =\u0026gt; setState(() =\u0026gt; _selectedIndex = index), // ... BottomNavigationBarItems ... ), ); } } ","permalink":"https://jaylab.link/flutter-bottom-nav-memory/","summary":"\u003ch3 id=\"기존-코드\"\u003e기존 코드\u003c/h3\u003e\n\u003cdiv class=\"highlight\"\u003e\u003cpre tabindex=\"0\" class=\"chroma\"\u003e\u003ccode class=\"language-dart\" data-lang=\"dart\"\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"k\"\u003eimport\u003c/span\u003e \u003cspan class=\"s1\"\u003e\u0026#39;package:flutter/material.dart\u0026#39;\u003c/span\u003e\u003cspan class=\"p\"\u003e;\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"k\"\u003eimport\u003c/span\u003e \u003cspan class=\"s1\"\u003e\u0026#39;package:meals_clone/screens/categories.dart\u0026#39;\u003c/span\u003e\u003cspan class=\"p\"\u003e;\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"c1\"\u003e// import \u0026#39;package:riverpod/riverpod.dart\u0026#39;;\n\u003c/span\u003e\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"kd\"\u003eclass\u003c/span\u003e \u003cspan class=\"nc\"\u003eTabScreen\u003c/span\u003e \u003cspan class=\"kd\"\u003eextends\u003c/span\u003e \u003cspan class=\"n\"\u003eStatefulWidget\u003c/span\u003e \u003cspan class=\"p\"\u003e{\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e  \u003cspan class=\"kd\"\u003econst\u003c/span\u003e \u003cspan class=\"n\"\u003eTabScreen\u003c/span\u003e\u003cspan class=\"p\"\u003e({\u003c/span\u003e\u003cspan class=\"k\"\u003esuper\u003c/span\u003e\u003cspan class=\"p\"\u003e.\u003c/span\u003e\u003cspan class=\"n\"\u003ekey\u003c/span\u003e\u003cspan class=\"p\"\u003e});\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e  \u003cspan class=\"err\"\u003e@\u003c/span\u003e\u003cspan class=\"n\"\u003eoverride\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e  \u003cspan class=\"n\"\u003eState\u003c/span\u003e\u003cspan class=\"o\"\u003e\u0026lt;\u003c/span\u003e\u003cspan class=\"n\"\u003eTabScreen\u003c/span\u003e\u003cspan class=\"o\"\u003e\u0026gt;\u003c/span\u003e \u003cspan class=\"n\"\u003ecreateState\u003c/span\u003e\u003cspan class=\"p\"\u003e()\u003c/span\u003e \u003cspan class=\"o\"\u003e=\u0026gt;\u003c/span\u003e \u003cspan class=\"n\"\u003e_TabScreenState\u003c/span\u003e\u003cspan class=\"p\"\u003e();\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"p\"\u003e}\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"kd\"\u003eclass\u003c/span\u003e \u003cspan class=\"nc\"\u003e_TabScreenState\u003c/span\u003e \u003cspan class=\"kd\"\u003eextends\u003c/span\u003e \u003cspan class=\"n\"\u003eState\u003c/span\u003e\u003cspan class=\"o\"\u003e\u0026lt;\u003c/span\u003e\u003cspan class=\"n\"\u003eTabScreen\u003c/span\u003e\u003cspan class=\"o\"\u003e\u0026gt;\u003c/span\u003e \u003cspan class=\"p\"\u003e{\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e  \u003cspan class=\"kt\"\u003eint\u003c/span\u003e \u003cspan class=\"n\"\u003e_selectedPageIndex\u003c/span\u003e \u003cspan class=\"o\"\u003e=\u003c/span\u003e \u003cspan class=\"m\"\u003e0\u003c/span\u003e\u003cspan class=\"p\"\u003e;\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e  \u003cspan class=\"n\"\u003eWidget\u003c/span\u003e \u003cspan class=\"n\"\u003ecategoriesScreen\u003c/span\u003e \u003cspan class=\"o\"\u003e=\u003c/span\u003e \u003cspan class=\"kd\"\u003econst\u003c/span\u003e \u003cspan class=\"n\"\u003eCategoriesScreen\u003c/span\u003e\u003cspan class=\"p\"\u003e();\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e  \u003cspan class=\"n\"\u003eWidget\u003c/span\u003e \u003cspan class=\"n\"\u003eplaceholder\u003c/span\u003e \u003cspan class=\"o\"\u003e=\u003c/span\u003e \u003cspan class=\"kd\"\u003econst\u003c/span\u003e \u003cspan class=\"n\"\u003ePlaceholder\u003c/span\u003e\u003cspan class=\"p\"\u003e();\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e  \u003cspan class=\"n\"\u003eWidget\u003c/span\u003e \u003cspan class=\"n\"\u003eactivePage\u003c/span\u003e \u003cspan class=\"o\"\u003e=\u003c/span\u003e \u003cspan class=\"kd\"\u003econst\u003c/span\u003e \u003cspan class=\"n\"\u003eCategoriesScreen\u003c/span\u003e\u003cspan class=\"p\"\u003e();\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e  \u003cspan class=\"kt\"\u003evoid\u003c/span\u003e \u003cspan class=\"n\"\u003e_selectPage\u003c/span\u003e\u003cspan class=\"p\"\u003e(\u003c/span\u003e\u003cspan class=\"kt\"\u003eint\u003c/span\u003e \u003cspan class=\"n\"\u003eindex\u003c/span\u003e\u003cspan class=\"p\"\u003e){\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e    \u003cspan class=\"n\"\u003esetState\u003c/span\u003e\u003cspan class=\"p\"\u003e(()\u003c/span\u003e \u003cspan class=\"p\"\u003e{\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e      \u003cspan class=\"n\"\u003e_selectedPageIndex\u003c/span\u003e \u003cspan class=\"o\"\u003e=\u003c/span\u003e \u003cspan class=\"n\"\u003eindex\u003c/span\u003e\u003cspan class=\"p\"\u003e;\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e    \u003cspan class=\"p\"\u003e});\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e    \u003cspan class=\"k\"\u003eif\u003c/span\u003e\u003cspan class=\"p\"\u003e(\u003c/span\u003e\u003cspan class=\"n\"\u003e_selectedPageIndex\u003c/span\u003e \u003cspan class=\"o\"\u003e==\u003c/span\u003e \u003cspan class=\"m\"\u003e0\u003c/span\u003e\u003cspan class=\"p\"\u003e){\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e      \u003cspan class=\"n\"\u003eactivePage\u003c/span\u003e \u003cspan class=\"o\"\u003e=\u003c/span\u003e \u003cspan class=\"n\"\u003ecategoriesScreen\u003c/span\u003e\u003cspan class=\"p\"\u003e;\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e    \u003cspan class=\"p\"\u003e}\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e    \u003cspan class=\"k\"\u003eelse\u003c/span\u003e \u003cspan class=\"k\"\u003eif\u003c/span\u003e\u003cspan class=\"p\"\u003e(\u003c/span\u003e\u003cspan class=\"n\"\u003e_selectedPageIndex\u003c/span\u003e \u003cspan class=\"o\"\u003e==\u003c/span\u003e \u003cspan class=\"m\"\u003e1\u003c/span\u003e\u003cspan class=\"p\"\u003e){\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e      \u003cspan class=\"n\"\u003eactivePage\u003c/span\u003e \u003cspan class=\"o\"\u003e=\u003c/span\u003e \u003cspan class=\"n\"\u003eplaceholder\u003c/span\u003e\u003cspan class=\"p\"\u003e;\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e    \u003cspan class=\"p\"\u003e}\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e  \u003cspan class=\"p\"\u003e}\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e  \u003cspan class=\"err\"\u003e@\u003c/span\u003e\u003cspan class=\"n\"\u003eoverride\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e  \u003cspan class=\"n\"\u003eWidget\u003c/span\u003e \u003cspan class=\"n\"\u003ebuild\u003c/span\u003e\u003cspan class=\"p\"\u003e(\u003c/span\u003e\u003cspan class=\"n\"\u003eBuildContext\u003c/span\u003e \u003cspan class=\"n\"\u003econtext\u003c/span\u003e\u003cspan class=\"p\"\u003e)\u003c/span\u003e \u003cspan class=\"p\"\u003e{\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e    \u003cspan class=\"k\"\u003ereturn\u003c/span\u003e \u003cspan class=\"n\"\u003eScaffold\u003c/span\u003e\u003cspan class=\"p\"\u003e(\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e      \u003cspan class=\"nl\"\u003eappBar:\u003c/span\u003e \u003cspan class=\"n\"\u003eAppBar\u003c/span\u003e\u003cspan class=\"p\"\u003e(\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e        \u003cspan class=\"nl\"\u003etitle:\u003c/span\u003e \u003cspan class=\"kd\"\u003econst\u003c/span\u003e \u003cspan class=\"n\"\u003eText\u003c/span\u003e\u003cspan class=\"p\"\u003e(\u003c/span\u003e\u003cspan class=\"s1\"\u003e\u0026#39;Categories\u0026#39;\u003c/span\u003e\u003cspan class=\"p\"\u003e),\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e        \u003cspan class=\"nl\"\u003ecenterTitle:\u003c/span\u003e \u003cspan class=\"kc\"\u003etrue\u003c/span\u003e\u003cspan class=\"p\"\u003e,\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e      \u003cspan class=\"p\"\u003e),\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e      \u003cspan class=\"c1\"\u003e// body: const CategoriesScreen(),\n\u003c/span\u003e\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e      \u003cspan class=\"nl\"\u003ebody:\u003c/span\u003e \u003cspan class=\"n\"\u003eactivePage\u003c/span\u003e\u003cspan class=\"p\"\u003e,\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e      \u003cspan class=\"nl\"\u003ebottomNavigationBar:\u003c/span\u003e \u003cspan class=\"n\"\u003eBottomNavigationBar\u003c/span\u003e\u003cspan class=\"p\"\u003e(\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e        \u003cspan class=\"nl\"\u003eitems:\u003c/span\u003e \u003cspan class=\"kd\"\u003econst\u003c/span\u003e \u003cspan class=\"p\"\u003e[\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e          \u003cspan class=\"n\"\u003eBottomNavigationBarItem\u003c/span\u003e\u003cspan class=\"p\"\u003e(\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e              \u003cspan class=\"nl\"\u003eicon:\u003c/span\u003e \u003cspan class=\"n\"\u003eIcon\u003c/span\u003e\u003cspan class=\"p\"\u003e(\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e                \u003cspan class=\"n\"\u003eIcons\u003c/span\u003e\u003cspan class=\"p\"\u003e.\u003c/span\u003e\u003cspan class=\"n\"\u003eset_meal\u003c/span\u003e\u003cspan class=\"p\"\u003e,\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e                \u003cspan class=\"nl\"\u003esize:\u003c/span\u003e \u003cspan class=\"m\"\u003e30\u003c/span\u003e\u003cspan class=\"p\"\u003e,\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e              \u003cspan class=\"p\"\u003e),\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e              \u003cspan class=\"nl\"\u003elabel:\u003c/span\u003e \u003cspan class=\"s1\"\u003e\u0026#39;카테고리\u0026#39;\u003c/span\u003e\u003cspan class=\"p\"\u003e),\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e          \u003cspan class=\"n\"\u003eBottomNavigationBarItem\u003c/span\u003e\u003cspan class=\"p\"\u003e(\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e              \u003cspan class=\"nl\"\u003eicon:\u003c/span\u003e \u003cspan class=\"n\"\u003eIcon\u003c/span\u003e\u003cspan class=\"p\"\u003e(\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e                \u003cspan class=\"n\"\u003eIcons\u003c/span\u003e\u003cspan class=\"p\"\u003e.\u003c/span\u003e\u003cspan class=\"n\"\u003estar\u003c/span\u003e\u003cspan class=\"p\"\u003e,\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e                \u003cspan class=\"nl\"\u003esize:\u003c/span\u003e \u003cspan class=\"m\"\u003e30\u003c/span\u003e\u003cspan class=\"p\"\u003e,\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e              \u003cspan class=\"p\"\u003e),\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e              \u003cspan class=\"nl\"\u003elabel:\u003c/span\u003e \u003cspan class=\"s1\"\u003e\u0026#39;즐겨찾기\u0026#39;\u003c/span\u003e\u003cspan class=\"p\"\u003e)\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e        \u003cspan class=\"p\"\u003e],\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e        \u003cspan class=\"nl\"\u003eshowSelectedLabels:\u003c/span\u003e \u003cspan class=\"kc\"\u003efalse\u003c/span\u003e\u003cspan class=\"p\"\u003e,\u003c/span\u003e \u003cspan class=\"c1\"\u003e// 선택된 레이블 숨기기\n\u003c/span\u003e\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e        \u003cspan class=\"nl\"\u003eshowUnselectedLabels:\u003c/span\u003e \u003cspan class=\"kc\"\u003efalse\u003c/span\u003e\u003cspan class=\"p\"\u003e,\u003c/span\u003e \u003cspan class=\"c1\"\u003e// 선택되지 않은 레이블 숨기기\n\u003c/span\u003e\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e        \u003cspan class=\"nl\"\u003ecurrentIndex:\u003c/span\u003e \u003cspan class=\"n\"\u003e_selectedPageIndex\u003c/span\u003e\u003cspan class=\"p\"\u003e,\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e        \u003cspan class=\"nl\"\u003eonTap:\u003c/span\u003e \u003cspan class=\"n\"\u003e_selectPage\u003c/span\u003e\u003cspan class=\"p\"\u003e,\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e      \u003cspan class=\"p\"\u003e),\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e    \u003cspan class=\"p\"\u003e);\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e  \u003cspan class=\"p\"\u003e}\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"p\"\u003e}\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003ch3 id=\"수정된-코드\"\u003e수정된 코드\u003c/h3\u003e\n\u003cdiv class=\"highlight\"\u003e\u003cpre tabindex=\"0\" class=\"chroma\"\u003e\u003ccode class=\"language-dart\" data-lang=\"dart\"\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"k\"\u003eimport\u003c/span\u003e \u003cspan class=\"s1\"\u003e\u0026#39;package:flutter/material.dart\u0026#39;\u003c/span\u003e\u003cspan class=\"p\"\u003e;\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"k\"\u003eimport\u003c/span\u003e \u003cspan class=\"s1\"\u003e\u0026#39;package:meals_clone/screens/categories.dart\u0026#39;\u003c/span\u003e\u003cspan class=\"p\"\u003e;\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"c1\"\u003e// import \u0026#39;package:riverpod/riverpod.dart\u0026#39;;\n\u003c/span\u003e\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"kd\"\u003eclass\u003c/span\u003e \u003cspan class=\"nc\"\u003eTabScreen\u003c/span\u003e \u003cspan class=\"kd\"\u003eextends\u003c/span\u003e \u003cspan class=\"n\"\u003eStatefulWidget\u003c/span\u003e \u003cspan class=\"p\"\u003e{\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e  \u003cspan class=\"kd\"\u003econst\u003c/span\u003e \u003cspan class=\"n\"\u003eTabScreen\u003c/span\u003e\u003cspan class=\"p\"\u003e({\u003c/span\u003e\u003cspan class=\"k\"\u003esuper\u003c/span\u003e\u003cspan class=\"p\"\u003e.\u003c/span\u003e\u003cspan class=\"n\"\u003ekey\u003c/span\u003e\u003cspan class=\"p\"\u003e});\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e  \u003cspan class=\"err\"\u003e@\u003c/span\u003e\u003cspan class=\"n\"\u003eoverride\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e  \u003cspan class=\"n\"\u003eState\u003c/span\u003e\u003cspan class=\"o\"\u003e\u0026lt;\u003c/span\u003e\u003cspan class=\"n\"\u003eTabScreen\u003c/span\u003e\u003cspan class=\"o\"\u003e\u0026gt;\u003c/span\u003e \u003cspan class=\"n\"\u003ecreateState\u003c/span\u003e\u003cspan class=\"p\"\u003e()\u003c/span\u003e \u003cspan class=\"o\"\u003e=\u0026gt;\u003c/span\u003e \u003cspan class=\"n\"\u003e_TabScreenState\u003c/span\u003e\u003cspan class=\"p\"\u003e();\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"p\"\u003e}\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"kd\"\u003eclass\u003c/span\u003e \u003cspan class=\"nc\"\u003e_TabScreenState\u003c/span\u003e \u003cspan class=\"kd\"\u003eextends\u003c/span\u003e \u003cspan class=\"n\"\u003eState\u003c/span\u003e\u003cspan class=\"o\"\u003e\u0026lt;\u003c/span\u003e\u003cspan class=\"n\"\u003eTabScreen\u003c/span\u003e\u003cspan class=\"o\"\u003e\u0026gt;\u003c/span\u003e \u003cspan class=\"p\"\u003e{\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e  \u003cspan class=\"kt\"\u003eint\u003c/span\u003e \u003cspan class=\"n\"\u003e_selectedPageIndex\u003c/span\u003e \u003cspan class=\"o\"\u003e=\u003c/span\u003e \u003cspan class=\"m\"\u003e0\u003c/span\u003e\u003cspan class=\"p\"\u003e;\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e  \u003cspan class=\"kt\"\u003evoid\u003c/span\u003e \u003cspan class=\"n\"\u003e_selectPage\u003c/span\u003e\u003cspan class=\"p\"\u003e(\u003c/span\u003e\u003cspan class=\"kt\"\u003eint\u003c/span\u003e \u003cspan class=\"n\"\u003eindex\u003c/span\u003e\u003cspan class=\"p\"\u003e)\u003c/span\u003e \u003cspan class=\"p\"\u003e{\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e    \u003cspan class=\"n\"\u003esetState\u003c/span\u003e\u003cspan class=\"p\"\u003e(()\u003c/span\u003e \u003cspan class=\"p\"\u003e{\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e      \u003cspan class=\"n\"\u003e_selectedPageIndex\u003c/span\u003e \u003cspan class=\"o\"\u003e=\u003c/span\u003e \u003cspan class=\"n\"\u003eindex\u003c/span\u003e\u003cspan class=\"p\"\u003e;\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e    \u003cspan class=\"p\"\u003e});\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e  \u003cspan class=\"p\"\u003e}\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e  \u003cspan class=\"err\"\u003e@\u003c/span\u003e\u003cspan class=\"n\"\u003eoverride\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e  \u003cspan class=\"n\"\u003eWidget\u003c/span\u003e \u003cspan class=\"n\"\u003ebuild\u003c/span\u003e\u003cspan class=\"p\"\u003e(\u003c/span\u003e\u003cspan class=\"n\"\u003eBuildContext\u003c/span\u003e \u003cspan class=\"n\"\u003econtext\u003c/span\u003e\u003cspan class=\"p\"\u003e)\u003c/span\u003e \u003cspan class=\"p\"\u003e{\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e    \u003cspan class=\"c1\"\u003e// 현재 선택된 페이지에 따라 적절한 위젯을 동적으로 선택합니다.\n\u003c/span\u003e\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e    \u003cspan class=\"n\"\u003eWidget\u003c/span\u003e \u003cspan class=\"n\"\u003eactivePage\u003c/span\u003e \u003cspan class=\"o\"\u003e=\u003c/span\u003e \u003cspan class=\"n\"\u003e_selectedPageIndex\u003c/span\u003e \u003cspan class=\"o\"\u003e==\u003c/span\u003e \u003cspan class=\"m\"\u003e0\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e        \u003cspan class=\"o\"\u003e?\u003c/span\u003e \u003cspan class=\"kd\"\u003econst\u003c/span\u003e \u003cspan class=\"n\"\u003eCategoriesScreen\u003c/span\u003e\u003cspan class=\"p\"\u003e()\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e        \u003cspan class=\"o\"\u003e:\u003c/span\u003e \u003cspan class=\"kd\"\u003econst\u003c/span\u003e \u003cspan class=\"n\"\u003ePlaceholder\u003c/span\u003e\u003cspan class=\"p\"\u003e();\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e    \u003cspan class=\"k\"\u003ereturn\u003c/span\u003e \u003cspan class=\"n\"\u003eScaffold\u003c/span\u003e\u003cspan class=\"p\"\u003e(\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e      \u003cspan class=\"nl\"\u003eappBar:\u003c/span\u003e \u003cspan class=\"n\"\u003eAppBar\u003c/span\u003e\u003cspan class=\"p\"\u003e(\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e        \u003cspan class=\"nl\"\u003etitle:\u003c/span\u003e \u003cspan class=\"kd\"\u003econst\u003c/span\u003e \u003cspan class=\"n\"\u003eText\u003c/span\u003e\u003cspan class=\"p\"\u003e(\u003c/span\u003e\u003cspan class=\"s1\"\u003e\u0026#39;Categories\u0026#39;\u003c/span\u003e\u003cspan class=\"p\"\u003e),\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e        \u003cspan class=\"nl\"\u003ecenterTitle:\u003c/span\u003e \u003cspan class=\"kc\"\u003etrue\u003c/span\u003e\u003cspan class=\"p\"\u003e,\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e      \u003cspan class=\"p\"\u003e),\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e      \u003cspan class=\"nl\"\u003ebody:\u003c/span\u003e \u003cspan class=\"n\"\u003eactivePage\u003c/span\u003e\u003cspan class=\"p\"\u003e,\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e      \u003cspan class=\"nl\"\u003ebottomNavigationBar:\u003c/span\u003e \u003cspan class=\"n\"\u003eBottomNavigationBar\u003c/span\u003e\u003cspan class=\"p\"\u003e(\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e        \u003cspan class=\"nl\"\u003eitems:\u003c/span\u003e \u003cspan class=\"kd\"\u003econst\u003c/span\u003e \u003cspan class=\"p\"\u003e[\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e          \u003cspan class=\"n\"\u003eBottomNavigationBarItem\u003c/span\u003e\u003cspan class=\"p\"\u003e(\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e              \u003cspan class=\"nl\"\u003eicon:\u003c/span\u003e \u003cspan class=\"n\"\u003eIcon\u003c/span\u003e\u003cspan class=\"p\"\u003e(\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e                \u003cspan class=\"n\"\u003eIcons\u003c/span\u003e\u003cspan class=\"p\"\u003e.\u003c/span\u003e\u003cspan class=\"n\"\u003eset_meal\u003c/span\u003e\u003cspan class=\"p\"\u003e,\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e                \u003cspan class=\"nl\"\u003esize:\u003c/span\u003e \u003cspan class=\"m\"\u003e30\u003c/span\u003e\u003cspan class=\"p\"\u003e,\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e              \u003cspan class=\"p\"\u003e),\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e              \u003cspan class=\"nl\"\u003elabel:\u003c/span\u003e \u003cspan class=\"s1\"\u003e\u0026#39;카테고리\u0026#39;\u003c/span\u003e\u003cspan class=\"p\"\u003e),\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e          \u003cspan class=\"n\"\u003eBottomNavigationBarItem\u003c/span\u003e\u003cspan class=\"p\"\u003e(\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e              \u003cspan class=\"nl\"\u003eicon:\u003c/span\u003e \u003cspan class=\"n\"\u003eIcon\u003c/span\u003e\u003cspan class=\"p\"\u003e(\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e                \u003cspan class=\"n\"\u003eIcons\u003c/span\u003e\u003cspan class=\"p\"\u003e.\u003c/span\u003e\u003cspan class=\"n\"\u003estar\u003c/span\u003e\u003cspan class=\"p\"\u003e,\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e                \u003cspan class=\"nl\"\u003esize:\u003c/span\u003e \u003cspan class=\"m\"\u003e30\u003c/span\u003e\u003cspan class=\"p\"\u003e,\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e              \u003cspan class=\"p\"\u003e),\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e              \u003cspan class=\"nl\"\u003elabel:\u003c/span\u003e \u003cspan class=\"s1\"\u003e\u0026#39;즐겨찾기\u0026#39;\u003c/span\u003e\u003cspan class=\"p\"\u003e)\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e        \u003cspan class=\"p\"\u003e],\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e        \u003cspan class=\"nl\"\u003eshowSelectedLabels:\u003c/span\u003e \u003cspan class=\"kc\"\u003efalse\u003c/span\u003e\u003cspan class=\"p\"\u003e,\u003c/span\u003e \u003cspan class=\"c1\"\u003e// 선택된 레이블 숨기기\n\u003c/span\u003e\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e        \u003cspan class=\"nl\"\u003eshowUnselectedLabels:\u003c/span\u003e \u003cspan class=\"kc\"\u003efalse\u003c/span\u003e\u003cspan class=\"p\"\u003e,\u003c/span\u003e \u003cspan class=\"c1\"\u003e// 선택되지 않은 레이블 숨기기\n\u003c/span\u003e\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e        \u003cspan class=\"nl\"\u003ecurrentIndex:\u003c/span\u003e \u003cspan class=\"n\"\u003e_selectedPageIndex\u003c/span\u003e\u003cspan class=\"p\"\u003e,\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e        \u003cspan class=\"nl\"\u003eonTap:\u003c/span\u003e \u003cspan class=\"n\"\u003e_selectPage\u003c/span\u003e\u003cspan class=\"p\"\u003e,\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e      \u003cspan class=\"p\"\u003e),\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e    \u003cspan class=\"p\"\u003e);\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e  \u003cspan class=\"p\"\u003e}\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"p\"\u003e}\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003ch3 id=\"수정된-내용\"\u003e수정된 내용\u003c/h3\u003e\n\u003col\u003e\n\u003cli\u003e\n\u003cp\u003e불필요한 인스턴스화 제거:\n\u003ccode\u003ecategoriesScreen\u003c/code\u003e과 \u003ccode\u003eplaceholder\u003c/code\u003e 위젯을 미리 인스턴스화할 필요가 없다.\n이들은 필요할 때 생성될 수 있으며, 위와 같이 수정하면 메모리 사용량을 줄이고 성능을 향상시킬 수 있다.\u003c/p\u003e","title":"[Flutter] BottomNavigationBar 구현시 메모리 고려사항"},{"content":"에러 발생 개발 중 다음과 같은 로그가 발생했다.\nMissingPluginException(No implementation found for method getApplicationDocumentsDirectory on channel plugins.flutter.io/path_provider) Hot Restart를 해도 동일하게 에러가 출력.\n그러나 동작에는 문제가 없는 상황\n.\n.\n.\n해결 💢 단순히 IDE와 시뮬레이터 재부팅 혹은 flutter clean\nFlutter 공식 이슈이기도 한 문제 ⇒ https://github.com/flutter/flutter/issues/10912\n","permalink":"https://jaylab.link/flutter-missing-plugin-exception/","summary":"\u003ch3 id=\"에러-발생\"\u003e에러 발생\u003c/h3\u003e\n\u003cp\u003e개발 중 다음과 같은 로그가 발생했다.\u003c/p\u003e\n\u003cdiv class=\"highlight\"\u003e\u003cpre tabindex=\"0\" class=\"chroma\"\u003e\u003ccode class=\"language-text\" data-lang=\"text\"\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003eMissingPluginException(No implementation found for method getApplicationDocumentsDirectory on channel plugins.flutter.io/path_provider)\n\u003c/span\u003e\u003c/span\u003e\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003cp\u003e\u003ccode\u003eHot Restart\u003c/code\u003e를 해도 동일하게 에러가 출력.\u003c/p\u003e\n\u003cp\u003e그러나 동작에는 문제가 없는 상황\u003c/p\u003e\n\u003cp\u003e.\u003c/p\u003e\n\u003cp\u003e.\u003c/p\u003e\n\u003cp\u003e.\u003c/p\u003e\n\u003ch3 id=\"해결-\"\u003e해결 💢\u003c/h3\u003e\n\u003cp\u003e단순히 IDE와 시뮬레이터 재부팅 혹은 \u003ccode\u003eflutter clean\u003c/code\u003e\u003c/p\u003e\n\u003cp\u003eFlutter 공식 이슈이기도 한 문제 ⇒ \u003ca href=\"https://github.com/flutter/flutter/issues/10912\"\u003ehttps://github.com/flutter/flutter/issues/10912\u003c/a\u003e\u003c/p\u003e","title":"[Flutter] 에러 내용 “MissingPluginException(No implementation found for method getApplicationDocumentsDirectory on channel plugins.flutter.io/path_provider)”"},{"content":"본 내용과 관련해서는 더 간단하게 설명된 다른 블로그 글들이 많이 있다. 그러나 그 각각의 내용이 무엇인지, 그것들이 어떻게 동작되는지 궁금해서 이 기회에 이것저것 찾고 알게된 내용들을 정리해보았다. 목차 - 사전 지식 RSA 키 쌍의 의미와 사용 목적:\nRSA 키 쌍은 공개 키와 개인 키로 구성되며, 이들은 서로 수학적으로 연관되어 있다.\n공개 키(public key): 이 키는 안전하게 공유될 수 있으며, Git 서버에 등록된다. 서버는 이 공개 키를 사용하여 사용자의 인증 정보를 확인한다.\n개인 키(private key): 이 키는 사용자의 로컬 컴퓨터에 안전하게 보관되어야 하며, 절대 공유되어서는 안 된다. 개인 키를 통해 사용자는 Git 서버에 자신의 정체성을 증명할 수 있다.\nGit 서비스(예: GitHub, GitLab 등)에서는 사용자가 저장소에 접근하거나 변경 사항을 push할 때 이 키를 통한 인증이 요구된다.\nSSH 키를 사용하는 인증 방식은 사용자 이름과 비밀번호를 사용하는 방식보다 더 안전하며, 자주 사용되는 방법이다.\nssh key 생성 cd ~/.ssh ssh-keygen -t rsa -C \u0026#34;{git 계정 이메일}\u0026#34; -f \u0026#34;{키 이름}\u0026#34; 생성하고나면 {키 이름}, {키 이름}.pub 두가지가 생성된다. {키 이름}는 프라이빗 키, {키 이름}.pub 는 퍼블릭 키\n-t rsa는 생성할 키의 type을 RSA로 지정한다는 설정. (RSA는 널리 사용되는 키 유형 중 하나)\n-C \u0026quot;{git 계정 이메일}\u0026quot;는 키에 주석을 추가하는 옵션. 주석은 보통 키를 식별하는데 사용되는 이메일 주소로 설정된다. 이는 키 관리를 용이하게 하며, 어떤 목적으로 키가 사용되는지 식별할 수 있게 도와준다.\n-f \u0026quot;{키 이름}\u0026quot;는 생성될 키 파일의 이름을 지정한다. 이를 통해 기본 이름(id_rsa) 대신 다른 이름을 사용할 수 있어, 여러 키를 관리할 때 유용하다.\n-b {3072~4096} 이 옵션은 생성할 key의 크기로써, 명시하지않으면 기본적으로 3072가 적용된다(최대 4096)\n※ 이 명령어를 입력하면 시스템은 사용자에게 개인키에 대한 비밀번호 설정 여부를 묻는다. 이 때, 비밀번호를 입력하면, 해당 비밀번호는 생성된 개인키에 연결된다.(비밀번호를 입력하지 않고 엔터를 누르면, 비밀번호 없이 개인키가 생성)\n예를들어 아래와같은 프롬프트가 나타날 수 있다.\nEnter passphrase (empty for no passphrase): [여기에 비밀번호 입력] Enter same passphrase again: [비밀번호 재입력] ssh-keygen을 사용시 비밀번호 설정은 선택. 비밀번호를 설정하면 보안이 강화되지만, 매번 SSH 접속 시 비밀번호를 입력해야 하는 불편함이 있을 수 있다. 비밀번호 없이 키를 생성하면 접속이 더 간편해지지만, 그 만큼 키의 보안 수준은 낮아진다.\n☝🏻ssh key 생성방법 (더 기본적인 방법) 만약 ssh-keygen 만 입력하게 되면,\n기본적으로 사용자의 홈 디렉토리 아래 .ssh 폴더에 RSA 키 쌍(공개 키와 개인 키)을 생성한다. 사용자에게 키의 저장 위치와 비밀번호 설정 여부를 물어보며, 추가 옵션을 지정하지 않으면 기본 설정으로 진행한다. 예시: 사용자가 특별히 지정하지 않으면, 파일 이름은 id_rsa와 id_rsa.pub (공개 키)로 저장된다. 앞서 정리한 방법은 구체적인 설정(키 타입, 주석, 파일 이름)으로 사용자 정의 SSH 키를 생성하는 방법. 두 번째 기본 방법은 표준 설정으로 SSH 키를 생성하는 방법.\nssh agent 실행 eval \u0026#34;$(ssh-agent -s)\u0026#34; ssh agent에 새로 생성한 프라이빗 key 추가 ssh-add ~/.ssh/{프라이빗키 이름} 이 명령어는 생성된 개인키를 SSH 에이전트에 추가하여 이 후 인증 과정을 자동화한다.\n만약 개인키에 비밀번호가 설정되어 있다면, ssh-add 실행 시 비밀번호를 한 번 입력해야 한다.\n여러 개의 SSH 키를 사용한다면, ssh-add를 사용하여 필요한 키를 에이전트에 추가할 수 있다. 이는 SSH 에이전트가 자동으로 적절한 키를 선택하여 사용할 수 있도록 해준다.\n※ssh-add 과정 없이도 ssh-keygen만으로도 생성한 키를 사용할 수 있다. 이 때도 개인키에 설정된 비밀번호를 매번 입력할 필요도 없다. 시스템의 SSH 에이전트가 한 번 입력된 비밀번호를 기억하고, 후속 접속 시 자동으로 처리해준다.\nssh agent에 개인키 등록 되었는지 조회 ssh-add -l Github에 퍼블릭 키 등록 cd ~/.ssh cat {생성한 공개키 이름}.pub 위 명령어를 통해 퍼블릭키 내용 확인 및 복사 후\n[Github] → [Settings] → [SSH and GPG keys] 에 붙여넣기(등록)\nPrivate Repository Clone 여기까지하면 아래의 터미널 명령을 통해 private repository를 clone할 수 있다.\ngit clone git@github.com:{private repository 소유자 이름}/{private repository 이름}.git Clone 동작 과정 간 인증 메커니즘 git clone 명령어를 사용하여 GitHub의 비공개 저장소를 복제하는 과정에서의 SSH 키 인증 메커니즘은 다음과 같이 이해할 수 있다\n개인키(Private Key) 사용: git clone git@github.com:``{private repository 소유자 이름}``/``{private repository 이름}``.git 명령어를 실행할 때, SSH는 연결된 컴퓨터의 .ssh 디렉토리 내 개인키를 사용하여 GitHub 서버에 인증을 요청한다.\n만약 개인키에 비밀번호가 설정되어 있다면, 사용자는 이 비밀번호를 입력해야 한다.\nGitHub에 등록된 공개키(Public Key) 확인: GitHub 서버는 요청 받은 인증 정보를 검증하기 위해 해당 사용자 계정에 등록된 공개키와 비교한다.\n이 과정에서, 공개키와 개인키의 쌍이 정확히 일치해야 한다.\n저장소 접근 권한 확인: 인증 과정이 성공적으로 완료되면, GitHub는 해당 사용자 계정이 요청된 비공개 저장소에 접근할 권한이 있는지 확인한다.\n접근 권한이 있다면, git clone 명령어는 저장소의 내용을 로컬 시스템으로 복제한다.\n✨ 요약 (Private Repo Clone을 위한 터미널 명령어) cd ~/.ssh ssh-keygen -t rsa -C \u0026#34;{git 계정 이메일}\u0026#34; -f \u0026#34;{키 이름}\u0026#34; eval \u0026#34;$(ssh-agent -s)\u0026#34; ssh-add ~/.ssh/{프라이빗키 이름} cat {생성된 (공개)키 이름}.pub # 조회된 공개키 복사 (github에 공개키 등록) git clone git@github.com:{private repository 소유자 이름}/{private repository 이름}.git + 또 다른 간단한 방법 [Github] → [Settings] → [Developer Settings] 에서 퍼스널 액세스토큰 → 클래식에서 토큰 발급\ngit clone https://``{닉네임}``:``{토큰}``@github.com/``{저장소의 주인 닉네임}``/``{저장소 이름}``.git\n( 💭 엄청 간단하긴한데 보안측면에서 괜찮을지 의문 )\n","permalink":"https://jaylab.link/git-clone-private-repo/","summary":"\u003cdiv class=\"callout callout-gray\"\u003e\u003cdiv class=\"callout-body\"\u003e본 내용과 관련해서는 더 간단하게 설명된 다른 블로그 글들이 많이 있다.\n그러나 그 각각의 내용이 무엇인지, 그것들이 어떻게 동작되는지 궁금해서\n이 기회에 이것저것 찾고 알게된 내용들을 정리해보았다.\u003c/div\u003e\n\u003c/div\u003e\n\n\u003cul\u003e\n\u003cli\u003e\u003cstrong\u003e목차 -\u003c/strong\u003e\u003c/li\u003e\n\u003c/ul\u003e\n\u003ch2 id=\"사전-지식\"\u003e사전 지식\u003c/h2\u003e\n\u003chr\u003e\n\u003cp\u003e\u003cstrong\u003eRSA 키 쌍의 의미와 사용 목적\u003c/strong\u003e:\u003c/p\u003e\n\u003cul\u003e\n\u003cli\u003e\n\u003cp\u003eRSA 키 쌍은 공개 키와 개인 키로 구성되며, 이들은 서로 수학적으로 연관되어 있다.\u003c/p\u003e\n\u003c/li\u003e\n\u003cli\u003e\n\u003cp\u003e\u003cstrong\u003e공개 키(public key)\u003c/strong\u003e: 이 키는 안전하게 공유될 수 있으며, Git 서버에 등록된다. 서버는 이 공개 키를 사용하여 사용자의 인증 정보를 확인한다.\u003c/p\u003e","title":"[Git] Private Repo Clone 방법 및 RSA 키에 대한 이해"},{"content":"Nuxt3에서는 vue 파일에 특별한 설정이 없다면 해당 페이지의 레이아웃은 “default” 레이아웃으로 설정된다.\ndefault 레이아웃 예시 \u0026lt;template\u0026gt; \u0026lt;div\u0026gt; \u0026lt;slot /\u0026gt; \u0026lt;/div\u0026gt; \u0026lt;/template\u0026gt; 실무에서 사용중인 레이아웃 예시 ~/layouts/ 경로에 default.vue 파일을 추가하여 설정할 수 있다.\n\u0026lt;template\u0026gt; \u0026lt;div id=\u0026#34;page\u0026#34; class=\u0026#34;page font--jakarta\u0026#34;\u0026gt; \u0026lt;HeaderLayout1 /\u0026gt; \u0026lt;slot /\u0026gt; \u0026lt;FooterLayout1 /\u0026gt; \u0026lt;/div\u0026gt; \u0026lt;/template\u0026gt; *default.vue *\n※ 참고 slot 태그는 페이지를 렌더링하는 특수한 태그\n해당 태그가 존재하지 않을 경우, 이상 동작할 수 있으므로 반드시 포함 필요\n단, 페이지 영역을 숨겨할 경우는 v-show 등을 사용하여 제어해야 하며, v-show는 반드시 slot이 아닌 상위 Element에 위치해야 한다\n\u0026lt;template\u0026gt; \u0026lt;div\u0026gt; \u0026lt;Header /\u0026gt; \u0026lt;div v-show=\u0026#34;false\u0026#34;\u0026gt; \u0026lt;slot /\u0026gt; \u0026lt;/div\u0026gt; \u0026lt;Footer /\u0026gt; \u0026lt;/div\u0026gt; \u0026lt;/template\u0026gt; 위 설명과 같이 기본 레이아웃은 default로 설정되어 있으며, 각 페이지 컴포넌트에서는 definePageMeta 메소드를 사용하여 사용 레이아웃을 변경할 수 있다.\n\u0026lt;script lang=\u0026#34;ts\u0026#34; setup\u0026gt; definePageMeta({ layout: \u0026#34;custom\u0026#34;, }); \u0026lt;/script\u0026gt; 만약 동적으로 레이아웃을 변경하고자 할때는 setPageLayout 메서드를 사용하여 변경할 수 있다.\n\u0026lt;script lang=\u0026#34;ts\u0026#34; setup\u0026gt; // ... setPageLayout(\u0026#39;custom2\u0026#39;) \u0026lt;/script\u0026gt; ","permalink":"https://jaylab.link/nuxt-layout-setting/","summary":"\u003cp\u003eNuxt3에서는 vue 파일에 특별한 설정이 없다면 해당 페이지의 레이아웃은 “\u003ccode\u003edefault\u003c/code\u003e” 레이아웃으로 설정된다.\u003c/p\u003e\n\u003ch4 id=\"default-레이아웃-예시\"\u003edefault 레이아웃 예시\u003c/h4\u003e\n\u003cdiv class=\"highlight\"\u003e\u003cpre tabindex=\"0\" class=\"chroma\"\u003e\u003ccode class=\"language-html\" data-lang=\"html\"\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"p\"\u003e\u0026lt;\u003c/span\u003e\u003cspan class=\"nt\"\u003etemplate\u003c/span\u003e\u003cspan class=\"p\"\u003e\u0026gt;\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e  \u003cspan class=\"p\"\u003e\u0026lt;\u003c/span\u003e\u003cspan class=\"nt\"\u003ediv\u003c/span\u003e\u003cspan class=\"p\"\u003e\u0026gt;\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e    \u003cspan class=\"p\"\u003e\u0026lt;\u003c/span\u003e\u003cspan class=\"nt\"\u003eslot\u003c/span\u003e \u003cspan class=\"p\"\u003e/\u0026gt;\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e  \u003cspan class=\"p\"\u003e\u0026lt;/\u003c/span\u003e\u003cspan class=\"nt\"\u003ediv\u003c/span\u003e\u003cspan class=\"p\"\u003e\u0026gt;\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"p\"\u003e\u0026lt;/\u003c/span\u003e\u003cspan class=\"nt\"\u003etemplate\u003c/span\u003e\u003cspan class=\"p\"\u003e\u0026gt;\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003ch4 id=\"실무에서-사용중인-레이아웃-예시\"\u003e실무에서 사용중인 레이아웃 예시\u003c/h4\u003e\n\u003cp\u003e\u003ccode\u003e~/layouts/\u003c/code\u003e 경로에 \u003ccode\u003edefault.vue\u003c/code\u003e 파일을 추가하여 설정할 수 있다.\u003c/p\u003e\n\u003cdiv class=\"highlight\"\u003e\u003cpre tabindex=\"0\" class=\"chroma\"\u003e\u003ccode class=\"language-html\" data-lang=\"html\"\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"p\"\u003e\u0026lt;\u003c/span\u003e\u003cspan class=\"nt\"\u003etemplate\u003c/span\u003e\u003cspan class=\"p\"\u003e\u0026gt;\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e    \u003cspan class=\"p\"\u003e\u0026lt;\u003c/span\u003e\u003cspan class=\"nt\"\u003ediv\u003c/span\u003e \u003cspan class=\"na\"\u003eid\u003c/span\u003e\u003cspan class=\"o\"\u003e=\u003c/span\u003e\u003cspan class=\"s\"\u003e\u0026#34;page\u0026#34;\u003c/span\u003e \u003cspan class=\"na\"\u003eclass\u003c/span\u003e\u003cspan class=\"o\"\u003e=\u003c/span\u003e\u003cspan class=\"s\"\u003e\u0026#34;page font--jakarta\u0026#34;\u003c/span\u003e\u003cspan class=\"p\"\u003e\u0026gt;\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e        \u003cspan class=\"p\"\u003e\u0026lt;\u003c/span\u003e\u003cspan class=\"nt\"\u003eHeaderLayout1\u003c/span\u003e \u003cspan class=\"p\"\u003e/\u0026gt;\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e        \u003cspan class=\"p\"\u003e\u0026lt;\u003c/span\u003e\u003cspan class=\"nt\"\u003eslot\u003c/span\u003e \u003cspan class=\"p\"\u003e/\u0026gt;\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e        \u003cspan class=\"p\"\u003e\u0026lt;\u003c/span\u003e\u003cspan class=\"nt\"\u003eFooterLayout1\u003c/span\u003e \u003cspan class=\"p\"\u003e/\u0026gt;\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e    \u003cspan class=\"p\"\u003e\u0026lt;/\u003c/span\u003e\u003cspan class=\"nt\"\u003ediv\u003c/span\u003e\u003cspan class=\"p\"\u003e\u0026gt;\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"p\"\u003e\u0026lt;/\u003c/span\u003e\u003cspan class=\"nt\"\u003etemplate\u003c/span\u003e\u003cspan class=\"p\"\u003e\u0026gt;\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003cp\u003e*default.vue *\u003c/p\u003e\n\u003ch4 id=\"-참고\"\u003e※ 참고\u003c/h4\u003e\n\u003cul\u003e\n\u003cli\u003e\n\u003cp\u003e\u003ccode\u003eslot\u003c/code\u003e 태그는 페이지를 렌더링하는 특수한 태그\u003c/p\u003e\n\u003c/li\u003e\n\u003cli\u003e\n\u003cp\u003e해당 태그가 존재하지 않을 경우, 이상 동작할 수 있으므로 반드시 포함 필요\u003c/p\u003e\n\u003c/li\u003e\n\u003cli\u003e\n\u003cp\u003e단, 페이지 영역을 숨겨할 경우는 \u003ccode\u003ev-show\u003c/code\u003e 등을 사용하여 제어해야 하며, \u003ccode\u003ev-show\u003c/code\u003e는 반드시 slot이 아닌 상위 Element에 위치해야 한다\u003c/p\u003e","title":"[Nuxt3] Layout 설정 및 동적 변경"},{"content":"우선 fetch와 $fetch를 사용한 각각의 예시코드부터 확인해보자.\nfetch 사용 예시\nimport { useAsyncData } from \u0026#39;nuxt3\u0026#39; export default { setup() { const { data, pending, error, refresh } = useAsyncData(\u0026#39;user\u0026#39;, () =\u0026gt; fetch(\u0026#39;http://localhost:8080/api/user/1\u0026#39;) .then(response =\u0026gt; response.json()) ) return { data, pending, error, refresh } } } $fetch 사용 예시\nconst { data, pending, error, refresh } = await useAsyncData( \u0026#39;mountains\u0026#39;, // key () =\u0026gt; $fetch(\u0026#39;https://api.nuxtjs.dev/mountains\u0026#39;) ) fetch, $fetch 차이점 위의 코드를 살펴보면 둘 다 useAsyncData의 두 번째 매개변수로 함수를 전달하고 있다. 이 함수는 데이터를 가져오는 데 필요한 비동기 작업을 수행하며, 이 함수가 반환하는 Promise의 결과값이 useAsyncData의 data로 설정된다.\n이 중 첫 번째 코드는 fetch 함수를 직접 사용하여 HTTP 요청을 보낸 후 .then 메서드를 사용하여 응답을 처리하는 내용이다. 그리고 응답값을 JSON 형식으로 파싱하기 위해 response.json()을 직접 호출하고있다. response.json()은 Fetch API에서 제공하는 메서드로, 응답값을 JSON 형식으로 파싱한다. 두 번째 코드에서 $fetch를 사용하면 이 메서드가 자동으로 적용된다. $fetch는 내부적으로 fetch를 사용하되, 추가적인 처리를 수행한다. 그 중 하나가 바로 응답을 자동으로 JSON 형식으로 파싱하는것이기 때문에 $fetch를 사용하면 response.json()을 호출하지 않아도 된다.\nfetch에 대하여 fetch는 웹 브라우저와 Node.js에서 모두 사용할 수 있는, HTTP 요청을 보내는 데 사용되는 함수\n이 함수를 통해 서버에 데이터를 요청하거나 서버에 데이터 전송 가능\nfetch 함수는 Promise를 반환. 따라서 async/await 구문을 사용하여 비동기적으로 데이터를 처리할 수 있다.\n$fetch에 대하여 “Nuxt3”에서 제공하는 전역 함수로, 내부적으로 fetch를 사용하여 HTTP 요청을 보내는 함수\n내부적으로 에러 처리 등이 추가적으로 수행되므로, 좀 더 편리하게 HTTP 요청 가능\n$fetch를 사용하게 되면 해당 api 호출을 클라이언트가 아니라, nuxt의 웹서버에서 직접 api 호출을 하고, 서버사이드 렌더링 후 그 결과를 클라이언트에게 보여준다. 따라서, 클라이언트에서 직접 API 호출했을 때와 달리 더 빠른 페이지 로딩 사용자 경험을 제공할 수 있다.\n기존 fetch 방식 혹은 api 호출 방식 :\nNuxt 웹서버가 클라이언트에게 페이지 제공 → 브라우저에서 API 호출 → API 결과로 CSR\n$fetch 사용 방식 :\nNuxt 웹서버가 직접 API 호출 → API 결과값으로 SSR된 페이지를 클라이언트에 제공\n컴포넌트에서 $fetch를 useAsyncData로 래핑하지 않고 사용하면 $fetch가 서버에서 클라이언트로 ‘상태’를 전송하지 않기 때문에 처음에는 “서버”에서, 그리고 하이드레이션 중에 “클라이언트 측”에서 데이터를 두 번 가져오게 된다. 즉, 서버와 클라이언트 양쪽 모두에서 fetch가 실행된다.\n이러한 이유로, 컴포넌트에서 \u0026lsquo;$fetch\u0026lsquo;를 사용할 때는 반드시 \u0026lsquo;useAsyncData\u0026lsquo;로 감싸주어야 한다. \u0026lsquo;useAsyncData\u0026lsquo;를 사용하면 중복 호출을 방지할 수 있다.\n\u0026lt;script setup lang=\u0026#34;ts\u0026#34;\u0026gt; // SSR 동안 데이터는 서버에서 한 번, 클라이언트에서 한 번, 총 두 번 가져옵니다. const dataTwice = await $fetch(\u0026#39;/api/item\u0026#39;) // SSR 중에는 데이터가 서버 측에서만 가져와 클라이언트로 전송됩니다. const { data } = await useAsyncData(\u0026#39;item\u0026#39;, () =\u0026gt; $fetch(\u0026#39;/api/item\u0026#39;)) // useAsyncData + $fetch의 바로가기로 Fetch를 사용할 수도 있습니다. const { data } = await useFetch(\u0026#39;/api/item\u0026#39;) \u0026lt;/script\u0026gt; ","permalink":"https://jaylab.link/nuxt-about-fetch/","summary":"\u003cp\u003e우선 fetch와 $fetch를 사용한 각각의 예시코드부터 확인해보자.\u003c/p\u003e\n\u003cp\u003e\u003cstrong\u003efetch 사용 예시\u003c/strong\u003e\u003c/p\u003e\n\u003cdiv class=\"highlight\"\u003e\u003cpre tabindex=\"0\" class=\"chroma\"\u003e\u003ccode class=\"language-javascript\" data-lang=\"javascript\"\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"kr\"\u003eimport\u003c/span\u003e \u003cspan class=\"p\"\u003e{\u003c/span\u003e \u003cspan class=\"nx\"\u003euseAsyncData\u003c/span\u003e \u003cspan class=\"p\"\u003e}\u003c/span\u003e \u003cspan class=\"nx\"\u003efrom\u003c/span\u003e \u003cspan class=\"s1\"\u003e\u0026#39;nuxt3\u0026#39;\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"kr\"\u003eexport\u003c/span\u003e \u003cspan class=\"k\"\u003edefault\u003c/span\u003e \u003cspan class=\"p\"\u003e{\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e  \u003cspan class=\"nx\"\u003esetup\u003c/span\u003e\u003cspan class=\"p\"\u003e()\u003c/span\u003e \u003cspan class=\"p\"\u003e{\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e    \u003cspan class=\"kr\"\u003econst\u003c/span\u003e \u003cspan class=\"p\"\u003e{\u003c/span\u003e \u003cspan class=\"nx\"\u003edata\u003c/span\u003e\u003cspan class=\"p\"\u003e,\u003c/span\u003e \u003cspan class=\"nx\"\u003epending\u003c/span\u003e\u003cspan class=\"p\"\u003e,\u003c/span\u003e \u003cspan class=\"nx\"\u003eerror\u003c/span\u003e\u003cspan class=\"p\"\u003e,\u003c/span\u003e \u003cspan class=\"nx\"\u003erefresh\u003c/span\u003e \u003cspan class=\"p\"\u003e}\u003c/span\u003e \u003cspan class=\"o\"\u003e=\u003c/span\u003e \u003cspan class=\"nx\"\u003euseAsyncData\u003c/span\u003e\u003cspan class=\"p\"\u003e(\u003c/span\u003e\u003cspan class=\"s1\"\u003e\u0026#39;user\u0026#39;\u003c/span\u003e\u003cspan class=\"p\"\u003e,\u003c/span\u003e \u003cspan class=\"p\"\u003e()\u003c/span\u003e \u003cspan class=\"p\"\u003e=\u0026gt;\u003c/span\u003e \n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e      \u003cspan class=\"nx\"\u003efetch\u003c/span\u003e\u003cspan class=\"p\"\u003e(\u003c/span\u003e\u003cspan class=\"s1\"\u003e\u0026#39;http://localhost:8080/api/user/1\u0026#39;\u003c/span\u003e\u003cspan class=\"p\"\u003e)\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e        \u003cspan class=\"p\"\u003e.\u003c/span\u003e\u003cspan class=\"nx\"\u003ethen\u003c/span\u003e\u003cspan class=\"p\"\u003e(\u003c/span\u003e\u003cspan class=\"nx\"\u003eresponse\u003c/span\u003e \u003cspan class=\"p\"\u003e=\u0026gt;\u003c/span\u003e \u003cspan class=\"nx\"\u003eresponse\u003c/span\u003e\u003cspan class=\"p\"\u003e.\u003c/span\u003e\u003cspan class=\"nx\"\u003ejson\u003c/span\u003e\u003cspan class=\"p\"\u003e())\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e    \u003cspan class=\"p\"\u003e)\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e    \u003cspan class=\"k\"\u003ereturn\u003c/span\u003e \u003cspan class=\"p\"\u003e{\u003c/span\u003e \u003cspan class=\"nx\"\u003edata\u003c/span\u003e\u003cspan class=\"p\"\u003e,\u003c/span\u003e \u003cspan class=\"nx\"\u003epending\u003c/span\u003e\u003cspan class=\"p\"\u003e,\u003c/span\u003e \u003cspan class=\"nx\"\u003eerror\u003c/span\u003e\u003cspan class=\"p\"\u003e,\u003c/span\u003e \u003cspan class=\"nx\"\u003erefresh\u003c/span\u003e \u003cspan class=\"p\"\u003e}\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e  \u003cspan class=\"p\"\u003e}\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"p\"\u003e}\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003cp\u003e\u003cstrong\u003e$fetch 사용 예시\u003c/strong\u003e\u003c/p\u003e\n\u003cdiv class=\"highlight\"\u003e\u003cpre tabindex=\"0\" class=\"chroma\"\u003e\u003ccode class=\"language-javascript\" data-lang=\"javascript\"\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"kr\"\u003econst\u003c/span\u003e \u003cspan class=\"p\"\u003e{\u003c/span\u003e \u003cspan class=\"nx\"\u003edata\u003c/span\u003e\u003cspan class=\"p\"\u003e,\u003c/span\u003e \u003cspan class=\"nx\"\u003epending\u003c/span\u003e\u003cspan class=\"p\"\u003e,\u003c/span\u003e \u003cspan class=\"nx\"\u003eerror\u003c/span\u003e\u003cspan class=\"p\"\u003e,\u003c/span\u003e \u003cspan class=\"nx\"\u003erefresh\u003c/span\u003e \u003cspan class=\"p\"\u003e}\u003c/span\u003e \u003cspan class=\"o\"\u003e=\u003c/span\u003e \u003cspan class=\"kr\"\u003eawait\u003c/span\u003e \u003cspan class=\"nx\"\u003euseAsyncData\u003c/span\u003e\u003cspan class=\"p\"\u003e(\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e  \u003cspan class=\"s1\"\u003e\u0026#39;mountains\u0026#39;\u003c/span\u003e\u003cspan class=\"p\"\u003e,\u003c/span\u003e \u003cspan class=\"c1\"\u003e// key\n\u003c/span\u003e\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e  \u003cspan class=\"p\"\u003e()\u003c/span\u003e \u003cspan class=\"p\"\u003e=\u0026gt;\u003c/span\u003e \u003cspan class=\"nx\"\u003e$fetch\u003c/span\u003e\u003cspan class=\"p\"\u003e(\u003c/span\u003e\u003cspan class=\"s1\"\u003e\u0026#39;https://api.nuxtjs.dev/mountains\u0026#39;\u003c/span\u003e\u003cspan class=\"p\"\u003e)\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"p\"\u003e)\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003ch3 id=\"fetch-fetch-차이점\"\u003efetch, $fetch 차이점\u003c/h3\u003e\n\u003cp\u003e위의 코드를 살펴보면 둘 다 \u003ccode\u003euseAsyncData\u003c/code\u003e의 두 번째 매개변수로 함수를 전달하고 있다. 이 함수는 데이터를 가져오는 데 필요한 비동기 작업을 수행하며, 이 함수가 반환하는 Promise의 결과값이 \u003ccode\u003euseAsyncData\u003c/code\u003e의 \u003ccode\u003edata\u003c/code\u003e로 설정된다.\u003c/p\u003e\n\u003cp\u003e이 중 첫 번째 코드는 \u003ccode\u003efetch\u003c/code\u003e 함수를 직접 사용하여 HTTP 요청을 보낸 후 \u003ccode\u003e.then\u003c/code\u003e 메서드를 사용하여 응답을 처리하는 내용이다. 그리고 응답값을 JSON 형식으로 파싱하기 위해 \u003ccode\u003eresponse.json()\u003c/code\u003e을 직접 호출하고있다. \u003ccode\u003eresponse.json()\u003c/code\u003e은 Fetch API에서 제공하는 메서드로, 응답값을 JSON 형식으로 파싱한다.\n두 번째 코드에서 \u003ccode\u003e$fetch\u003c/code\u003e를 사용하면 이 메서드가 자동으로 적용된다. \u003ccode\u003e$fetch\u003c/code\u003e는 내부적으로 \u003ccode\u003efetch\u003c/code\u003e를 사용하되, 추가적인 처리를 수행한다. 그 중 하나가 바로 응답을 자동으로 JSON 형식으로 파싱하는것이기 때문에 \u003ccode\u003e$fetch\u003c/code\u003e를 사용하면 \u003ccode\u003eresponse.json()\u003c/code\u003e을 호출하지 않아도 된다.\u003c/p\u003e","title":"[Nuxt3] $fetch 함수에 대하여(feat. 일반적인 fetch와의 차이)"},{"content":"Nuxt3에서는 useAsyncData와 useFetch라는 훅을 지원한다. 이 둘은 코드 구성 형태가 비슷하기 때문에 차이점은 무엇인지 정리해보았다.\nuseFetch useFetch는 created 단계 이후에 실행 (즉 ,새로운 Vue 인스턴스가 만들어진 후 데이터가 준비된 후에 실행)\n이렇게 하면 useFetch가 실행되는 시점에는 컴포넌트의 데이터가 이미 준비되어 있으므로, 이 데이터를 기반으로 서버로부터 필요한 데이터를 요청하거나 필요한 작업을 수행할 수 있다. 이는 비동기적으로 데이터를 처리하는 웹 애플리케이션에서 매우 중요한 부분\nuseAsyncData useAsyncData는 created 단계 이전에 즉, 첫 렌더링이 시작되기 전에 실행\n이렇게 하면 첫 페이지 로드 전에 필요한 데이터를 서버에서 미리 가져올 수 있다.\n💡공식 문서 설명 useAsyncData는 setup 함수, 플러그인 또는 라우트 미들웨어에서 직접 호출되는 컴포저블(composable)입니다. 이는 반응성을 갖는 컴포저블을 반환하며, 서버에서 클라이언트로 데이터를 다시 가져오지 않고도 Nuxt 페이로드에 응답을 추가하는 작업을 처리합니다. 즉, 페이지가 하이드레이트될 때 클라이언트 측에서 데이터를 다시 가져오지 않고도 이전에 서버에서 받은 데이터를 사용할 수 있게 해줍니다. 이를 통해 성능을 향상시킬 수 있습니다. 사용 방법 및 구성요소 const { data, pending, error, refresh } = await useAsyncData(key, fetcher) useAsyncData 매개변수\nkey는 고유한 키로, 캐시된 데이터를 가져오거나 저장할 때 사용된다.\nfetcher는 비동기 작업을 수행하는 함수\nuseAsyncData 리턴값\nuseAsyncData는 data, pending, error, refresh라는 네 가지 값을 반환한다.\ndata: fetcher 함수가 반환하는 값. 아직 데이터가 로드되지 않았거나 오류가 발생하면 null\npending: 데이터가 로드 중인지 여부를 알려주는 불리언 값 데이터를 가져오는 동안은 true, 그 외의 경우는 false\nerror: 데이터를 가져오는 도중 발생한 오류. 오류가 없으면 null\nrefresh: 이 함수를 호출하면 fetcher 함수를 다시 실행하여 데이터를 새로 가져온다 ※ refresh 관련해서는 밑에서 더 자세히 정리해 놓았다.\nuseAsyncData 사용 예시 import { useAsyncData } from \u0026#39;nuxt3\u0026#39; export default { setup() { const { data, pending, error, refresh } = useAsyncData(\u0026#39;user\u0026#39;, () =\u0026gt; fetch(\u0026#39;http://localhost:8080/api/user/1\u0026#39;) .then(response =\u0026gt; response.json()) ) return { data, pending, error, refresh } } } 위 코드는 \u0026lsquo;user\u0026rsquo;라는 키를 가지고, \u0026lsquo;http://localhost:8080/api/user/1\u0026rsquo; 주소에서 사용자 정보를 가져오는 내용.\n\u0026lsquo;user\u0026lsquo;는 키(key)로써 데이터를 캐싱하는데 사용되는 고유식별자이다. 같은 키를 가진 비동기 데이터 요청이 여러 번 발생하면, Nuxt3는 캐시된 데이터를 반환함으로써 중복된 요청을 방지하여 성능 향상을 기대할 수 있다. (이 키는 클라이언트 측(Nuxt3)에서만 사용되고 서버 측에는 전달되지 않는다)\n하지만 이렇게 캐시를 사용하는 경우, 데이터의 신선도를 보장하기 어려울 수 있다. 예를 들어, 서버의 데이터가 변경되었지만, 클라이언트의 캐시는 그대로인 경우, 클라이언트는 서버의 최신 데이터를 보지 못하게 된다. 이런 문제를 해결하기 위해 refresh 함수를 사용하여 캐시를 갱신해야한다.\nrefresh 함수는 useAsyncData 훅이 반환하는 값 중 하나로, 이 함수를 호출하면 useAsyncData의 fetcher 함수를 다시 호출하여 캐시를 갱신하고 최신 데이터를 가져온다.\n**useAsyncData의 **refresh 함수 사용 예시 import { useAsyncData } from \u0026#39;nuxt3\u0026#39; export default { setup() { const { data, refresh } = useAsyncData(\u0026#39;user\u0026#39;, () =\u0026gt; fetch(\u0026#39;http://localhost:8080/api/user/1\u0026#39;) .then(response =\u0026gt; response.json()) ) const refreshData = () =\u0026gt; { refresh() } return { data, refreshData } }, template: ` \u0026lt;div\u0026gt; \u0026lt;div v-if=\u0026#34;data\u0026#34;\u0026gt; \u0026lt;p\u0026gt;{{ data.name }}\u0026lt;/p\u0026gt; \u0026lt;p\u0026gt;{{ data.email }}\u0026lt;/p\u0026gt; \u0026lt;/div\u0026gt; \u0026lt;button @click=\u0026#34;refreshData\u0026#34;\u0026gt;Refresh Data\u0026lt;/button\u0026gt; \u0026lt;/div\u0026gt; ` } \u0026lsquo;http://localhost:8080/api/user/1\u0026rsquo; 주소에서 사용자 정보를 조회한 후 응답값을 화면에 보여주는 내용의 코드.\n사용자가 \u0026lsquo;Refresh Data\u0026rsquo; 버튼을 클릭하면 refreshData 함수가 호출되어 서버에서 최신 데이터를 가져와 화면에 반영된다(데이터 갱신)\n","permalink":"https://jaylab.link/nuxt-usefetch-useasyncdata/","summary":"\u003cp\u003eNuxt3에서는 \u003ccode\u003euseAsyncData\u003c/code\u003e와 \u003ccode\u003euseFetch\u003c/code\u003e라는 훅을 지원한다. 이 둘은 코드 구성 형태가 비슷하기 때문에 차이점은 무엇인지 정리해보았다.\u003c/p\u003e\n\u003ch4 id=\"usefetch\"\u003euseFetch\u003c/h4\u003e\n\u003cul\u003e\n\u003cli\u003e\n\u003cp\u003e\u003ccode\u003euseFetch\u003c/code\u003e는 created 단계 이후에 실행 (즉 ,새로운 Vue 인스턴스가 만들어진 후 데이터가 준비된 후에 실행)\u003c/p\u003e\n\u003c/li\u003e\n\u003cli\u003e\n\u003cp\u003e이렇게 하면 \u003ccode\u003euseFetch\u003c/code\u003e가 실행되는 시점에는 컴포넌트의 데이터가 이미 준비되어 있으므로, 이 데이터를 기반으로 서버로부터 필요한 데이터를 요청하거나 필요한 작업을 수행할 수 있다. 이는 비동기적으로 데이터를 처리하는 웹 애플리케이션에서 매우 중요한 부분\u003c/p\u003e\n\u003c/li\u003e\n\u003c/ul\u003e\n\u003ch4 id=\"useasyncdata\"\u003euseAsyncData\u003c/h4\u003e\n\u003cul\u003e\n\u003cli\u003e\n\u003cp\u003e\u003ccode\u003euseAsyncData\u003c/code\u003e는 \u003ccode\u003ecreated\u003c/code\u003e 단계 이전에  즉, 첫 렌더링이 시작되기 전에 실행\u003c/p\u003e","title":"[Nuxt3] useFetch, useAsyncData의 차이"},{"content":"📎얼마전 고객사 인프라 구성이 변경됨에 따라 다중 was에 대한 세션 클러스터링을 적용할 필요가 있었습니다.\n원래 세션 클러스터링 적용은 크게 어려운 작업이 아니지만, 낮은 버전의 스프링 프레임워크 기반 그리고 AWS ElastiCache for Redis에 \u0026ldquo;전송 및 저장 중 암호화\u0026ldquo;가 설정된 상황에서는 기존에 알려진 방법만으로는 동작하지않는 문제가 존재했습니다.\n이러한 특수 조건에서 세션 클러스터링이 정상적으로 동작할 수 있기까지 참 많은 시행착오가 있었지만, 그 방법만 간략히 정리해보았습니다.\n목표 Spring framework 애플리케이션에서 Redis를 통한 세션 클러스터링\n작업 환경 AWS ElastiCache for Redis 포트 : 6480\nAWS ElastiCache for Redis 저장 중 암호화, 전송 중 암호화 설정 : 모두활성화\nSpring Framework Version : 4.3.7\n(※ 스프링 프레임워크 최신버전에서는 보다 쉬운 세션클러스터링 방법 존재)\n설정 방법 세션클러스터링을 위해 jar 파일 설치 https://github.com/redisson/redisson/blob/master/redisson-tomcat/README.md 접속 →\nredisson-all-3.22.1.jar, redisson-tomcat-8-3.22.1.jar 다운로드 →\n설치되어있는 tomcat 폴더 내 lib 폴더 안으로 이동\n{tomcat_base}/context.xml \u0026gt; Context 태그 안에 아래 내용 적용 \u0026lt;Manager className=\u0026#34;org.redisson.tomcat.RedissonSessionManager\u0026#34; configPath=\u0026#34;${catalina.base}/conf/redisson.yaml\u0026#34; readMode=\u0026#34;REDIS\u0026#34;/\u0026gt; \u0026lt;ResourceLink name=\u0026#34;bean/redisson\u0026#34; global=\u0026#34;bean/redisson\u0026#34; type=\u0026#34;org.redisson.api.RedissonClient\u0026#34; /\u0026gt; \u0026lt;Manager className=\u0026#34;org.redisson.tomcat.JndiRedissonSessionManager\u0026#34; readMode=\u0026#34;REDIS\u0026#34; jndiName=\u0026#34;bean/redisson\u0026#34; /\u0026gt; {tomcat_base}/server.xml \u0026gt; GlobalNamingResources 태그 안에 아래 내용 적용 \u0026lt;Resource name=\u0026#34;bean/redisson\u0026#34; auth=\u0026#34;Container\u0026#34; factory=\u0026#34;org.redisson.JndiRedissonFactory\u0026#34; configPath=\u0026#34;${catalina.base}/conf/redisson.yaml\u0026#34; closeMethod=\u0026#34;shutdown\u0026#34;/\u0026gt; {tomcat_base}/conf/redisson.yaml (파일 신규 생성) singleServerConfig: idleConnectionTimeout: 10000 connectTimeout: 10000 timeout: 10000 retryAttempts: 20 retryInterval: 2000 password: null subscriptionsPerConnection: 5 clientName: null # \u0026#34;rediss://\u0026#34; 스키마 사용 address: \u0026#34;{생성된 AWS ElastiCache for Redis의 Endpoint}:6480\u0026#34; # SSL 설정 추가 sslEnableEndpointIdentification: false sslProvider: \u0026#34;JDK\u0026#34; subscriptionConnectionMinimumIdleSize: 1 subscriptionConnectionPoolSize: 50 connectionMinimumIdleSize: 24 connectionPoolSize: 64 database: 2 dnsMonitoringInterval: 5000 threads: 0 nettyThreads: 0 codec: !\u0026lt;org.redisson.codec.Kryo5Codec\u0026gt; {} transportMode: \u0026#34;NIO\u0026#34; 각 WAS에 위와 같은 설정을 완료 후 재시작하면 세션클러스터링이 정상적으로 동작하는것을 확인할 수 있습니다.\n","permalink":"https://jaylab.link/redis-session-clustering/","summary":"How to Enable Session Clustering in \u0026ldquo;Low-level Spring Framework\u0026rdquo; and \u0026ldquo;Redis with Encryption”","title":"Redis 세션 클러스터링 설정 (feat. Redisson)"},{"content":"들어가며 현재 투입되어있는 프로젝트의 서비스 중에는 특정 데이터를 조회할 수 있는 화면이 있습니다.\n그런데 이 화면을 사용하게되는 업무 특성상 데이터를 일일이 하나씩 조회하기가 번거롭다는 불편사항이있어\n이를 한번에 다중 검색 가능하도록 수정 조치하였고, 당시 작업내용을 핵심부분만 간략하게 정리하였습니다.\n기존 방식 mapper.xml \u0026lt;sql id=\u0026#34;ticketSearch\u0026#34;\u0026gt; \u0026lt;if test=\u0026#34;(method_info != null and method_info != \u0026#39;\u0026#39;)\u0026#34;\u0026gt; \u0026lt;if test=\u0026#34;method_id == \u0026#39;gkId\u0026#39;\u0026#34;\u0026gt; AND GK_ID = #{method_info} \u0026lt;/if\u0026gt; \u0026lt;if test=\u0026#34;method_id == \u0026#39;parentTicketId\u0026#39;\u0026#34;\u0026gt; AND UPPER(PRNT_TKT_ID) LIKE CONCAT(\u0026#39;%\u0026#39;,UPPER(REPLACE(#{method_info}, \u0026#39;_\u0026#39;, \u0026#39;#_\u0026#39;)),\u0026#39;%\u0026#39;) ESCAPE \u0026#39;#\u0026#39; \u0026lt;/if\u0026gt; \u0026lt;if test=\u0026#34;method_id == \u0026#39;ticketId\u0026#39;\u0026#34;\u0026gt; AND UPPER(TKT_ID) LIKE CONCAT(\u0026#39;%\u0026#39;,UPPER(REPLACE(#{method_info}, \u0026#39;_\u0026#39;, \u0026#39;#_\u0026#39;)),\u0026#39;%\u0026#39;) ESCAPE \u0026#39;#\u0026#39; \u0026lt;/if\u0026gt; \u0026lt;if test=\u0026#34;method_id == \u0026#39;guid\u0026#39;\u0026#34;\u0026gt; AND GUID LIKE CONCAT(\u0026#39;%\u0026#39;,#{method_info},\u0026#39;%\u0026#39;) \u0026lt;/if\u0026gt; \u0026lt;/if\u0026gt; \u0026lt;/sql\u0026gt; \u0026lt;select id=\u0026#34;list\u0026#34; parameterType=\u0026#34;map\u0026#34; resultMap=\u0026#34;ticketVO\u0026#34;\u0026gt; SELECT 컬럼 1, 컬럼 2, 컬럼 3 FROM 테이블 A WHERE DEL_YN_FG = \u0026#39;N\u0026#39; \u0026lt;include refid=\u0026#34;ticketSearch\u0026#34;/\u0026gt; \u0026lt;/select\u0026gt; ▲ 화면에서 입력한 문자열 1개를 포함하고있는 데이터를 “like” 검색을 통한 단일 조회 방식\n작업 내용 검색하고자하는 데이터의 종류가 \u0026quot;gkId\u0026quot;, \u0026quot;parentTicketId\u0026quot;, \u0026quot;ticketId\u0026quot; 중 하나일 경우, 화면에서 검색어 입력 시 구분자(콤마(”,”) 혹은 공백(” ”))를 기준으로 각각의 검색어를 “포함”하는 데이터를 전체 조회하도록 수정\nMyBatis foreach \u0026gt; \u0026ldquo;separator\u0026quot;를 활용한 OR 조건 동적 쿼리 적용\nservice.java (로직 추가) protected void setMultipleSearchInfo(Map\u0026lt;String, Object\u0026gt; reqMap){ String method_info = GkUtils.nvl(reqMap, METHOD_INFO); String method_id = GkUtils.nvl(reqMap, METHOD_ID); if( !(Arrays.asList(\u0026#34;gkId\u0026#34;, \u0026#34;parentTicketId\u0026#34;, \u0026#34;ticketId\u0026#34;).contains(method_id)) \u0026amp;\u0026amp; StringUtil.isEmpty(method_info)) return; method_info = method_info.replaceAll(\u0026#34;\\\\s{2,}\u0026#34;, \u0026#34; \u0026#34;).trim(); String[] splitStr = method_info.split(\u0026#34;[\\\\s,]+\u0026#34;); if(\u0026#34;gkId\u0026#34;.equals(method_id)){ splitStr = Arrays.stream(splitStr) .map(str -\u0026gt; GkUtils.getPlainGkId(str)) .toArray(String[]::new); } reqMap.put(\u0026#34;method_info_list\u0026#34;, splitStr); } 특정 카테고리 여부 체크\n구분자 기준으로 split → String[] 타입 파라미터에 Set\nmapper.xml (MyBatis 동적 쿼리 적용) \u0026lt;sql id=\u0026#34;ticketSearch\u0026#34;\u0026gt; \u0026lt;if test=\u0026#34;(method_info != null and method_info != \u0026#39;\u0026#39;)\u0026#34;\u0026gt; \u0026lt;if test=\u0026#34;method_id == \u0026#39;gkId\u0026#39;\u0026#34;\u0026gt; \u0026lt;choose\u0026gt; \u0026lt;when test=\u0026#34;method_info_list != null and method_info_list.length \u0026gt; 1\u0026#34;\u0026gt; AND \u0026lt;foreach collection=\u0026#34;method_info_list\u0026#34; item=\u0026#34;item\u0026#34; open=\u0026#34;(\u0026#34; close=\u0026#34;)\u0026#34; separator=\u0026#34;OR\u0026#34;\u0026gt; GK_ID LIKE CONCAT(\u0026#39;%\u0026#39;, REPLACE(#{item}, \u0026#39;_\u0026#39;, \u0026#39;#_\u0026#39;), \u0026#39;%\u0026#39;) ESCAPE \u0026#39;#\u0026#39; \u0026lt;/foreach\u0026gt; \u0026lt;/when\u0026gt; \u0026lt;otherwise\u0026gt; AND GK_ID = #{method_info} \u0026lt;/otherwise\u0026gt; \u0026lt;/choose\u0026gt; \u0026lt;/if\u0026gt; \u0026lt;if test=\u0026#34;method_id == \u0026#39;parentTicketId\u0026#39;\u0026#34;\u0026gt; \u0026lt;choose\u0026gt; \u0026lt;when test=\u0026#34;method_info_list != null and method_info_list.length \u0026gt; 1\u0026#34;\u0026gt; AND \u0026lt;foreach collection=\u0026#34;method_info_list\u0026#34; item=\u0026#34;item\u0026#34; open=\u0026#34;(\u0026#34; close=\u0026#34;)\u0026#34; separator=\u0026#34;OR\u0026#34;\u0026gt; PRNT_TKT_ID LIKE CONCAT(\u0026#39;%\u0026#39;, REPLACE(#{item}, \u0026#39;_\u0026#39;, \u0026#39;#_\u0026#39;), \u0026#39;%\u0026#39;) ESCAPE \u0026#39;#\u0026#39; \u0026lt;/foreach\u0026gt; \u0026lt;/when\u0026gt; \u0026lt;otherwise\u0026gt; AND PRNT_TKT_ID LIKE CONCAT(\u0026#39;%\u0026#39;,REPLACE(#{method_info}, \u0026#39;_\u0026#39;, \u0026#39;#_\u0026#39;),\u0026#39;%\u0026#39;) ESCAPE \u0026#39;#\u0026#39; \u0026lt;/otherwise\u0026gt; \u0026lt;/choose\u0026gt; \u0026lt;/if\u0026gt; \u0026lt;if test=\u0026#34;method_id == \u0026#39;ticketId\u0026#39;\u0026#34;\u0026gt; \u0026lt;choose\u0026gt; \u0026lt;when test=\u0026#34;method_info_list != null and method_info_list.length \u0026gt; 1\u0026#34;\u0026gt; AND \u0026lt;foreach collection=\u0026#34;method_info_list\u0026#34; item=\u0026#34;item\u0026#34; open=\u0026#34;(\u0026#34; close=\u0026#34;)\u0026#34; separator=\u0026#34;OR\u0026#34;\u0026gt; TKT_ID LIKE CONCAT(\u0026#39;%\u0026#39;, REPLACE(#{item}, \u0026#39;_\u0026#39;, \u0026#39;#_\u0026#39;), \u0026#39;%\u0026#39;) ESCAPE \u0026#39;#\u0026#39; \u0026lt;/foreach\u0026gt; \u0026lt;/when\u0026gt; \u0026lt;otherwise\u0026gt; AND TKT_ID LIKE CONCAT(\u0026#39;%\u0026#39;,REPLACE(#{method_info}, \u0026#39;_\u0026#39;, \u0026#39;#_\u0026#39;),\u0026#39;%\u0026#39;) ESCAPE \u0026#39;#\u0026#39; \u0026lt;/otherwise\u0026gt; \u0026lt;/choose\u0026gt; \u0026lt;/if\u0026gt; \u0026lt;if test=\u0026#34;method_id == \u0026#39;guid\u0026#39;\u0026#34;\u0026gt; AND GUID LIKE CONCAT(\u0026#39;%\u0026#39;,#{method_info},\u0026#39;%\u0026#39;) \u0026lt;/if\u0026gt; \u0026lt;/if\u0026gt; \u0026lt;/sql\u0026gt; \u0026lt;select id=\u0026#34;list\u0026#34; parameterType=\u0026#34;map\u0026#34; resultMap=\u0026#34;ticketVO\u0026#34;\u0026gt; SELECT 컬럼 1, 컬럼 2, 컬럼 3 FROM 테이블 A WHERE DEL_YN_FG = \u0026#39;N\u0026#39; \u0026lt;include refid=\u0026#34;ticketSearch\u0026#34;/\u0026gt; \u0026lt;/select\u0026gt; ","permalink":"https://jaylab.link/delimiter-multi-searching/","summary":"\u003ch3 id=\"들어가며\"\u003e들어가며\u003c/h3\u003e\n\u003cp\u003e현재 투입되어있는 프로젝트의 서비스 중에는 특정 데이터를 조회할 수 있는 화면이 있습니다.\u003c/p\u003e\n\u003cp\u003e그런데 이 화면을 사용하게되는 업무 특성상 데이터를 일일이 하나씩 조회하기가 번거롭다는 불편사항이있어\u003c/p\u003e\n\u003cp\u003e이를 한번에 다중 검색 가능하도록 수정 조치하였고, 당시 작업내용을 핵심부분만 간략하게 정리하였습니다.\u003c/p\u003e\n\u003ch3 id=\"기존-방식\"\u003e기존 방식\u003c/h3\u003e\n\u003cul\u003e\n\u003cli\u003emapper.xml\u003c/li\u003e\n\u003c/ul\u003e\n\u003cdiv class=\"highlight\"\u003e\u003cpre tabindex=\"0\" class=\"chroma\"\u003e\u003ccode class=\"language-sql\" data-lang=\"sql\"\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"o\"\u003e\u0026lt;\u003c/span\u003e\u003cspan class=\"k\"\u003esql\u003c/span\u003e\u003cspan class=\"w\"\u003e \u003c/span\u003e\u003cspan class=\"n\"\u003eid\u003c/span\u003e\u003cspan class=\"o\"\u003e=\u003c/span\u003e\u003cspan class=\"s2\"\u003e\u0026#34;ticketSearch\u0026#34;\u003c/span\u003e\u003cspan class=\"o\"\u003e\u0026gt;\u003c/span\u003e\u003cspan class=\"w\"\u003e\n\u003c/span\u003e\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"w\"\u003e    \u003c/span\u003e\u003cspan class=\"o\"\u003e\u0026lt;\u003c/span\u003e\u003cspan class=\"k\"\u003eif\u003c/span\u003e\u003cspan class=\"w\"\u003e \u003c/span\u003e\u003cspan class=\"n\"\u003etest\u003c/span\u003e\u003cspan class=\"o\"\u003e=\u003c/span\u003e\u003cspan class=\"s2\"\u003e\u0026#34;(method_info != null and method_info != \u0026#39;\u0026#39;)\u0026#34;\u003c/span\u003e\u003cspan class=\"o\"\u003e\u0026gt;\u003c/span\u003e\u003cspan class=\"w\"\u003e\n\u003c/span\u003e\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"w\"\u003e        \u003c/span\u003e\u003cspan class=\"o\"\u003e\u0026lt;\u003c/span\u003e\u003cspan class=\"k\"\u003eif\u003c/span\u003e\u003cspan class=\"w\"\u003e \u003c/span\u003e\u003cspan class=\"n\"\u003etest\u003c/span\u003e\u003cspan class=\"o\"\u003e=\u003c/span\u003e\u003cspan class=\"s2\"\u003e\u0026#34;method_id == \u0026#39;gkId\u0026#39;\u0026#34;\u003c/span\u003e\u003cspan class=\"o\"\u003e\u0026gt;\u003c/span\u003e\u003cspan class=\"w\"\u003e\n\u003c/span\u003e\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"w\"\u003e            \u003c/span\u003e\u003cspan class=\"k\"\u003eAND\u003c/span\u003e\u003cspan class=\"w\"\u003e \u003c/span\u003e\u003cspan class=\"n\"\u003eGK_ID\u003c/span\u003e\u003cspan class=\"w\"\u003e \u003c/span\u003e\u003cspan class=\"o\"\u003e=\u003c/span\u003e\u003cspan class=\"w\"\u003e \u003c/span\u003e\u003cspan class=\"o\"\u003e#\u003c/span\u003e\u003cspan class=\"err\"\u003e{\u003c/span\u003e\u003cspan class=\"n\"\u003emethod_info\u003c/span\u003e\u003cspan class=\"err\"\u003e}\u003c/span\u003e\u003cspan class=\"w\"\u003e\n\u003c/span\u003e\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"w\"\u003e        \u003c/span\u003e\u003cspan class=\"o\"\u003e\u0026lt;/\u003c/span\u003e\u003cspan class=\"k\"\u003eif\u003c/span\u003e\u003cspan class=\"o\"\u003e\u0026gt;\u003c/span\u003e\u003cspan class=\"w\"\u003e\n\u003c/span\u003e\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"w\"\u003e        \u003c/span\u003e\u003cspan class=\"o\"\u003e\u0026lt;\u003c/span\u003e\u003cspan class=\"k\"\u003eif\u003c/span\u003e\u003cspan class=\"w\"\u003e \u003c/span\u003e\u003cspan class=\"n\"\u003etest\u003c/span\u003e\u003cspan class=\"o\"\u003e=\u003c/span\u003e\u003cspan class=\"s2\"\u003e\u0026#34;method_id == \u0026#39;parentTicketId\u0026#39;\u0026#34;\u003c/span\u003e\u003cspan class=\"o\"\u003e\u0026gt;\u003c/span\u003e\u003cspan class=\"w\"\u003e\n\u003c/span\u003e\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"w\"\u003e            \u003c/span\u003e\u003cspan class=\"k\"\u003eAND\u003c/span\u003e\u003cspan class=\"w\"\u003e \u003c/span\u003e\u003cspan class=\"k\"\u003eUPPER\u003c/span\u003e\u003cspan class=\"p\"\u003e(\u003c/span\u003e\u003cspan class=\"n\"\u003ePRNT_TKT_ID\u003c/span\u003e\u003cspan class=\"p\"\u003e)\u003c/span\u003e\u003cspan class=\"w\"\u003e \u003c/span\u003e\u003cspan class=\"k\"\u003eLIKE\u003c/span\u003e\u003cspan class=\"w\"\u003e \u003c/span\u003e\u003cspan class=\"n\"\u003eCONCAT\u003c/span\u003e\u003cspan class=\"p\"\u003e(\u003c/span\u003e\u003cspan class=\"s1\"\u003e\u0026#39;%\u0026#39;\u003c/span\u003e\u003cspan class=\"p\"\u003e,\u003c/span\u003e\u003cspan class=\"k\"\u003eUPPER\u003c/span\u003e\u003cspan class=\"p\"\u003e(\u003c/span\u003e\u003cspan class=\"k\"\u003eREPLACE\u003c/span\u003e\u003cspan class=\"p\"\u003e(\u003c/span\u003e\u003cspan class=\"o\"\u003e#\u003c/span\u003e\u003cspan class=\"err\"\u003e{\u003c/span\u003e\u003cspan class=\"n\"\u003emethod_info\u003c/span\u003e\u003cspan class=\"err\"\u003e}\u003c/span\u003e\u003cspan class=\"p\"\u003e,\u003c/span\u003e\u003cspan class=\"w\"\u003e \u003c/span\u003e\u003cspan class=\"s1\"\u003e\u0026#39;_\u0026#39;\u003c/span\u003e\u003cspan class=\"p\"\u003e,\u003c/span\u003e\u003cspan class=\"w\"\u003e \u003c/span\u003e\u003cspan class=\"s1\"\u003e\u0026#39;#_\u0026#39;\u003c/span\u003e\u003cspan class=\"p\"\u003e)),\u003c/span\u003e\u003cspan class=\"s1\"\u003e\u0026#39;%\u0026#39;\u003c/span\u003e\u003cspan class=\"p\"\u003e)\u003c/span\u003e\u003cspan class=\"w\"\u003e \u003c/span\u003e\u003cspan class=\"k\"\u003eESCAPE\u003c/span\u003e\u003cspan class=\"w\"\u003e \u003c/span\u003e\u003cspan class=\"s1\"\u003e\u0026#39;#\u0026#39;\u003c/span\u003e\u003cspan class=\"w\"\u003e\n\u003c/span\u003e\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"w\"\u003e        \u003c/span\u003e\u003cspan class=\"o\"\u003e\u0026lt;/\u003c/span\u003e\u003cspan class=\"k\"\u003eif\u003c/span\u003e\u003cspan class=\"o\"\u003e\u0026gt;\u003c/span\u003e\u003cspan class=\"w\"\u003e\n\u003c/span\u003e\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"w\"\u003e        \u003c/span\u003e\u003cspan class=\"o\"\u003e\u0026lt;\u003c/span\u003e\u003cspan class=\"k\"\u003eif\u003c/span\u003e\u003cspan class=\"w\"\u003e \u003c/span\u003e\u003cspan class=\"n\"\u003etest\u003c/span\u003e\u003cspan class=\"o\"\u003e=\u003c/span\u003e\u003cspan class=\"s2\"\u003e\u0026#34;method_id == \u0026#39;ticketId\u0026#39;\u0026#34;\u003c/span\u003e\u003cspan class=\"o\"\u003e\u0026gt;\u003c/span\u003e\u003cspan class=\"w\"\u003e\n\u003c/span\u003e\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"w\"\u003e            \u003c/span\u003e\u003cspan class=\"k\"\u003eAND\u003c/span\u003e\u003cspan class=\"w\"\u003e \u003c/span\u003e\u003cspan class=\"k\"\u003eUPPER\u003c/span\u003e\u003cspan class=\"p\"\u003e(\u003c/span\u003e\u003cspan class=\"n\"\u003eTKT_ID\u003c/span\u003e\u003cspan class=\"p\"\u003e)\u003c/span\u003e\u003cspan class=\"w\"\u003e \u003c/span\u003e\u003cspan class=\"k\"\u003eLIKE\u003c/span\u003e\u003cspan class=\"w\"\u003e \u003c/span\u003e\u003cspan class=\"n\"\u003eCONCAT\u003c/span\u003e\u003cspan class=\"p\"\u003e(\u003c/span\u003e\u003cspan class=\"s1\"\u003e\u0026#39;%\u0026#39;\u003c/span\u003e\u003cspan class=\"p\"\u003e,\u003c/span\u003e\u003cspan class=\"k\"\u003eUPPER\u003c/span\u003e\u003cspan class=\"p\"\u003e(\u003c/span\u003e\u003cspan class=\"k\"\u003eREPLACE\u003c/span\u003e\u003cspan class=\"p\"\u003e(\u003c/span\u003e\u003cspan class=\"o\"\u003e#\u003c/span\u003e\u003cspan class=\"err\"\u003e{\u003c/span\u003e\u003cspan class=\"n\"\u003emethod_info\u003c/span\u003e\u003cspan class=\"err\"\u003e}\u003c/span\u003e\u003cspan class=\"p\"\u003e,\u003c/span\u003e\u003cspan class=\"w\"\u003e \u003c/span\u003e\u003cspan class=\"s1\"\u003e\u0026#39;_\u0026#39;\u003c/span\u003e\u003cspan class=\"p\"\u003e,\u003c/span\u003e\u003cspan class=\"w\"\u003e \u003c/span\u003e\u003cspan class=\"s1\"\u003e\u0026#39;#_\u0026#39;\u003c/span\u003e\u003cspan class=\"p\"\u003e)),\u003c/span\u003e\u003cspan class=\"s1\"\u003e\u0026#39;%\u0026#39;\u003c/span\u003e\u003cspan class=\"p\"\u003e)\u003c/span\u003e\u003cspan class=\"w\"\u003e \u003c/span\u003e\u003cspan class=\"k\"\u003eESCAPE\u003c/span\u003e\u003cspan class=\"w\"\u003e \u003c/span\u003e\u003cspan class=\"s1\"\u003e\u0026#39;#\u0026#39;\u003c/span\u003e\u003cspan class=\"w\"\u003e\n\u003c/span\u003e\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"w\"\u003e        \u003c/span\u003e\u003cspan class=\"o\"\u003e\u0026lt;/\u003c/span\u003e\u003cspan class=\"k\"\u003eif\u003c/span\u003e\u003cspan class=\"o\"\u003e\u0026gt;\u003c/span\u003e\u003cspan class=\"w\"\u003e\n\u003c/span\u003e\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"w\"\u003e        \u003c/span\u003e\u003cspan class=\"o\"\u003e\u0026lt;\u003c/span\u003e\u003cspan class=\"k\"\u003eif\u003c/span\u003e\u003cspan class=\"w\"\u003e \u003c/span\u003e\u003cspan class=\"n\"\u003etest\u003c/span\u003e\u003cspan class=\"o\"\u003e=\u003c/span\u003e\u003cspan class=\"s2\"\u003e\u0026#34;method_id == \u0026#39;guid\u0026#39;\u0026#34;\u003c/span\u003e\u003cspan class=\"o\"\u003e\u0026gt;\u003c/span\u003e\u003cspan class=\"w\"\u003e\n\u003c/span\u003e\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"w\"\u003e            \u003c/span\u003e\u003cspan class=\"k\"\u003eAND\u003c/span\u003e\u003cspan class=\"w\"\u003e \u003c/span\u003e\u003cspan class=\"n\"\u003eGUID\u003c/span\u003e\u003cspan class=\"w\"\u003e \u003c/span\u003e\u003cspan class=\"k\"\u003eLIKE\u003c/span\u003e\u003cspan class=\"w\"\u003e \u003c/span\u003e\u003cspan class=\"n\"\u003eCONCAT\u003c/span\u003e\u003cspan class=\"p\"\u003e(\u003c/span\u003e\u003cspan class=\"s1\"\u003e\u0026#39;%\u0026#39;\u003c/span\u003e\u003cspan class=\"p\"\u003e,\u003c/span\u003e\u003cspan class=\"o\"\u003e#\u003c/span\u003e\u003cspan class=\"err\"\u003e{\u003c/span\u003e\u003cspan class=\"n\"\u003emethod_info\u003c/span\u003e\u003cspan class=\"err\"\u003e}\u003c/span\u003e\u003cspan class=\"p\"\u003e,\u003c/span\u003e\u003cspan class=\"s1\"\u003e\u0026#39;%\u0026#39;\u003c/span\u003e\u003cspan class=\"p\"\u003e)\u003c/span\u003e\u003cspan class=\"w\"\u003e\n\u003c/span\u003e\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"w\"\u003e        \u003c/span\u003e\u003cspan class=\"o\"\u003e\u0026lt;/\u003c/span\u003e\u003cspan class=\"k\"\u003eif\u003c/span\u003e\u003cspan class=\"o\"\u003e\u0026gt;\u003c/span\u003e\u003cspan class=\"w\"\u003e\n\u003c/span\u003e\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"w\"\u003e    \u003c/span\u003e\u003cspan class=\"o\"\u003e\u0026lt;/\u003c/span\u003e\u003cspan class=\"k\"\u003eif\u003c/span\u003e\u003cspan class=\"o\"\u003e\u0026gt;\u003c/span\u003e\u003cspan class=\"w\"\u003e\n\u003c/span\u003e\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"o\"\u003e\u0026lt;/\u003c/span\u003e\u003cspan class=\"k\"\u003esql\u003c/span\u003e\u003cspan class=\"o\"\u003e\u0026gt;\u003c/span\u003e\u003cspan class=\"w\"\u003e\n\u003c/span\u003e\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"w\"\u003e\n\u003c/span\u003e\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"o\"\u003e\u0026lt;\u003c/span\u003e\u003cspan class=\"k\"\u003eselect\u003c/span\u003e\u003cspan class=\"w\"\u003e \u003c/span\u003e\u003cspan class=\"n\"\u003eid\u003c/span\u003e\u003cspan class=\"o\"\u003e=\u003c/span\u003e\u003cspan class=\"s2\"\u003e\u0026#34;list\u0026#34;\u003c/span\u003e\u003cspan class=\"w\"\u003e \u003c/span\u003e\u003cspan class=\"n\"\u003eparameterType\u003c/span\u003e\u003cspan class=\"o\"\u003e=\u003c/span\u003e\u003cspan class=\"s2\"\u003e\u0026#34;map\u0026#34;\u003c/span\u003e\u003cspan class=\"w\"\u003e \u003c/span\u003e\u003cspan class=\"n\"\u003eresultMap\u003c/span\u003e\u003cspan class=\"o\"\u003e=\u003c/span\u003e\u003cspan class=\"s2\"\u003e\u0026#34;ticketVO\u0026#34;\u003c/span\u003e\u003cspan class=\"o\"\u003e\u0026gt;\u003c/span\u003e\u003cspan class=\"w\"\u003e\n\u003c/span\u003e\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"w\"\u003e    \u003c/span\u003e\u003cspan class=\"k\"\u003eSELECT\u003c/span\u003e\u003cspan class=\"w\"\u003e\n\u003c/span\u003e\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"w\"\u003e        \u003c/span\u003e\u003cspan class=\"err\"\u003e컬럼\u003c/span\u003e\u003cspan class=\"w\"\u003e \u003c/span\u003e\u003cspan class=\"mi\"\u003e1\u003c/span\u003e\u003cspan class=\"p\"\u003e,\u003c/span\u003e\u003cspan class=\"w\"\u003e\n\u003c/span\u003e\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"w\"\u003e        \u003c/span\u003e\u003cspan class=\"err\"\u003e컬럼\u003c/span\u003e\u003cspan class=\"w\"\u003e \u003c/span\u003e\u003cspan class=\"mi\"\u003e2\u003c/span\u003e\u003cspan class=\"p\"\u003e,\u003c/span\u003e\u003cspan class=\"w\"\u003e\n\u003c/span\u003e\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"w\"\u003e        \u003c/span\u003e\u003cspan class=\"err\"\u003e컬럼\u003c/span\u003e\u003cspan class=\"w\"\u003e \u003c/span\u003e\u003cspan class=\"mi\"\u003e3\u003c/span\u003e\u003cspan class=\"w\"\u003e\n\u003c/span\u003e\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"w\"\u003e    \u003c/span\u003e\u003cspan class=\"k\"\u003eFROM\u003c/span\u003e\u003cspan class=\"w\"\u003e\n\u003c/span\u003e\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"w\"\u003e        \u003c/span\u003e\u003cspan class=\"err\"\u003e테이블\u003c/span\u003e\u003cspan class=\"w\"\u003e \u003c/span\u003e\u003cspan class=\"n\"\u003eA\u003c/span\u003e\u003cspan class=\"w\"\u003e\n\u003c/span\u003e\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"w\"\u003e    \u003c/span\u003e\u003cspan class=\"k\"\u003eWHERE\u003c/span\u003e\u003cspan class=\"w\"\u003e\n\u003c/span\u003e\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"w\"\u003e        \u003c/span\u003e\u003cspan class=\"n\"\u003eDEL_YN_FG\u003c/span\u003e\u003cspan class=\"w\"\u003e \u003c/span\u003e\u003cspan class=\"o\"\u003e=\u003c/span\u003e\u003cspan class=\"w\"\u003e \u003c/span\u003e\u003cspan class=\"s1\"\u003e\u0026#39;N\u0026#39;\u003c/span\u003e\u003cspan class=\"w\"\u003e \n\u003c/span\u003e\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"w\"\u003e        \u003c/span\u003e\u003cspan class=\"o\"\u003e\u0026lt;\u003c/span\u003e\u003cspan class=\"n\"\u003einclude\u003c/span\u003e\u003cspan class=\"w\"\u003e \u003c/span\u003e\u003cspan class=\"n\"\u003erefid\u003c/span\u003e\u003cspan class=\"o\"\u003e=\u003c/span\u003e\u003cspan class=\"s2\"\u003e\u0026#34;ticketSearch\u0026#34;\u003c/span\u003e\u003cspan class=\"o\"\u003e/\u0026gt;\u003c/span\u003e\u003cspan class=\"w\"\u003e\n\u003c/span\u003e\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"o\"\u003e\u0026lt;/\u003c/span\u003e\u003cspan class=\"k\"\u003eselect\u003c/span\u003e\u003cspan class=\"o\"\u003e\u0026gt;\u003c/span\u003e\u003cspan class=\"w\"\u003e\n\u003c/span\u003e\u003c/span\u003e\u003c/span\u003e\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003cp\u003e▲ 화면에서 입력한 문자열 1개를 포함하고있는 데이터를 “like” 검색을 통한 단일 조회 방식\u003c/p\u003e","title":"Delimiter를 통한 다중 검색 처리"},{"content":"들어가며 현재 투입되어있는 프로젝트에서는 최근 기존의 인프라 구성에서 많은 부분 전환된 일정이 있었습니다. 그 당시 Autoscaling을 적용하기 위해서 예상되는 문제점은 무엇이 있었는지, 그리고 그것을 어떻게 극복했는지 다음과 같이 정리하였습니다.\n기존 환경 Autoscaling이 적용되어있지 않은 was EC2 2개 운영\n해당 서버의 Spring Framework 애플리케이션 내에는 @Scheduled 어노테이션을 통한 스케줄잡 사용\n일부 스케줄 관련 코드 :\n@Scheduled(cron = \u0026#34;0 30 9 ? * *\u0026#34;, zone = \u0026#34;GMT+9:00\u0026#34;) public void notifyLongTermNoUseAdmin() { if(\u0026#34;1\u0026#34;.equals(GkConstant.WAS_NUMBER)) { // STG, PRD 의 1번 서버인 경우에만 Batch 실행 try { List\u0026lt;LongTermNoUseAdminVO\u0026gt; notifyAdminDataList = longTermNoUseAdminSvc.getNotifyAdminDataList(); for (LongTermNoUseAdminVO voNotifyAdmin : notifyAdminDataList) { try { List\u0026lt;String\u0026gt; mailReceiverIDList = new ArrayList\u0026lt;\u0026gt;(Arrays.asList(voNotifyAdmin.getAccountId())); // 메일 수신인 리스트에 장기 미사용자의 이메일 주소만 추출해 추가 // 대상자별 mail template parameter 가 다르기 때문에 for 문을 통해 개별 메일 발송 awssesClient.sendEmail(mailReceiverIDList, \u0026#34;[\u0026#34; + ospAppConfig.getGkServerType() + \u0026#34;]\u0026#34; + MailEnum.NOTIFICATION.getMailSubject(), setMailHtmlBody(MailEnum.NOTIFICATION, voNotifyAdmin), null); LOG.info(\u0026#34;##### SUCCESS Sending Notification Email : {}, {}\u0026#34;, voNotifyAdmin.getSaGuid(), voNotifyAdmin.getAccountId()); } catch (Exception e) { e.printStackTrace(); LOG.info(\u0026#34;##### FAIL Sending Notification Email : {}, {}\u0026#34;, voNotifyAdmin.getSaGuid(), voNotifyAdmin.getAccountId()); } } LOG.info(\u0026#34;##### SUCCESS notifyLongTermNoUseAdmin Batch\u0026#34;); } catch (Exception e) { e.printStackTrace(); LOG.info(\u0026#34;##### FAIL notifyLongTermNoUseAdmin Batch\u0026#34;); } } } 위와 같이 “코드가 실행되는 두대의 서버 중 vm option의 특정 시스템 프로퍼티가 ‘1’로 되어있는 서버에서만 동작”하도록 조건이 설정 (if(\u0026quot;1\u0026quot;.equals(GkConstant.WAS_NUMBER))) 문제점 Autoscaling을 적용하게되면 서버가 동적으로 생성 및 삭제되기 때문에 어느서버가 1번 서버인지 기준이 사라지는 상황. 따라서 기존의 Scheduled 로직이 여러 서버에서 중복으로 실행되거나 혹은 전혀 실행될 수 없는 로직\n수정 방향 ※ 투입되어있는 프로젝트에서는 Spring framework의 버전이 낮은관계로 최신기술 적용하기에 어려움이 있었습니다. 예를들어 멀티서버 환경에서 Spring 스케줄 중복실행을 방지하기위해 사용되는 \u0026ldquo;@SchedulerLock\u0026quot;과 같은 어노케이션이 지원되지 않는 문제가 있었습니다. 따라서, 이를 해결하기 위해서 AWS 리소스를 활용한 API호출 방식으로 설계하였습니다.\nAWS EventBridge \u0026gt; Scheduler를 통해 매일 09:30 AM에 AWS Lambda 호출\nAWS Lambda에서 was에 연결된 Internal ALB DNS 호출\nLambda의 Request를 받은 애플리케이션 서버에서 (수정된) 스케줄링 실행\n작업 상세 내용 Python Code 생성 (Application에 API 호출하는 (Python) Lambda Code)\n로컬파일에 lambda_function.py 파일 생성 후 아래의 파이썬 코드 내용 입력 및 저장\nimport json import requests import os def lambda_handler(event, context): url = os.environ[\u0026#39;ALB_URL\u0026#39;] print(\u0026#34;url : \u0026#34;, url) print(event) if \u0026#34;key\u0026#34; in event : print(event[\u0026#34;key\u0026#34;]) url = url + event[\u0026#34;key\u0026#34;] headers = { \u0026#34;Content-Type\u0026#34;: \u0026#34;application/json\u0026#34;, \u0026#34;Connection\u0026#34; : \u0026#34;keep-alive\u0026#34; } response = requests.post(url, headers=headers, verify=False) # health check 테스트시 주석처리 # ------CONNECTION TEST CODE------ # health check 테스트시 아래 블록 주석해제 # response = requests.get(os.environ[\u0026#39;ALB_URL\u0026#39;] + \u0026#39;common/health\u0026#39;, headers=headers, verify=False) # content = response.content.decode() # print(\u0026#34;content:\u0026#34;, content) # ------CONNECTION TEST CODE------ if response.status_code == 200: return { \u0026#39;statusCode\u0026#39;: 200, \u0026#39;body\u0026#39;: json.dumps(\u0026#39;API Success\u0026#39;) } else: return { \u0026#39;statusCode\u0026#39;: response.status_code, \u0026#39;body\u0026#39;: json.dumps(\u0026#39;API Fail\u0026#39;) } 라이브러리 및 패키지 생성 (HTTP 통신을 위한 request 라이브러리 구성 및 패키지 생성)\n위의 Python Code가 있는 경로에서 requirements.txt 파일 생성\nrequirements.txt 파일 내용 : requests==2.22.0\nrequirements.txt 파일이 있는 경로에서 “pip3 install -r requirements.txt -t .” 실행 \u0026gt; 라이브러리 설치 완료\n“zip -r scheduler.zip .” 명령을 통해 Lambda에 업로드할 압축파일 형식의 패키지 생성\nLambda 구성 (Lambda 생성, 패키지 업로드, 환경변수 설정)\nLambda 생성 (DEV 기준 “gk-dev-scheduling-lambda” Lambda 생성)\n설정 1. 런타임 : Python 3.9\n설정 2. 아키텍쳐 : x86_64\n설정 3. VPC 설정 후 서브넷 설정 \u0026gt; was가 속한 private 서브넷 2EA\n설정 4. 보안그룹 Gk_dev_lambda(dev alb에 대한 outbound(443포트)가 open된 SG // ※ alb쪽 SG에는 “Gk_dev_lambda” inbound 허용 필요)\nLambda 생성 완료\n“.zip 파일” \u0026gt; “scheduler.zip” 업로드 \u0026gt; 소스 및 라이브러리 구성 완료\nALB DNS 환경변수 설정 : [구성] 탭 \u0026gt; [환경 변수] \u0026gt; API 호출을 위한 도메인(로드밸런서 DNS) 환경변수 생성 → ex) “key” : “ALB_URL”, “value” : “{Internal-ALB-DNS}” 통신 테스트 AWS Lambda \u0026lt;-\u0026gt; Admin WAS 통신 테스트 1번 코드 주석 처리\n2번 코드 주석 해제\n변경사항 저장(Ctrl + S) 후\n3번 [Deploy] 클릭하여 배포\n배포 완료 후 4번 [Test] 버튼 클릭\nhealth check “up” 확인되는 경우 통신테스트 정상\n✅통신 테스트 완료되면 반드시 코드 원복\n1번 코드 주석 해제\n2번 코드 주석 처리\n변경사항 저장(Ctrl + S) 후\n[Deploy] 클릭하여 배포\n운영 환경 스케줄링 정상실행 확인 1 (Lambda Log) AWS CloudWatch \u0026gt; 로그 그룹 \u0026gt; {설정된로그적재경로} \u0026gt; 9:30 AM에 실행된 스케줄링 이벤트 로그 확인 가능\n운영 환경 스케줄링 정상실행 확인 2 (Application WAS Server Log) AWS CloudWatch \u0026gt; 로그 그룹 \u0026gt; {설정된로그적재경로} \u0026gt; 9:30 AM에 실행된 스케줄링 이벤트 로그 확인 가능\n","permalink":"https://jaylab.link/scheduler-transition/","summary":"\u003ch4 id=\"들어가며\"\u003e들어가며\u003c/h4\u003e\n\u003cp\u003e현재 투입되어있는 프로젝트에서는 최근 기존의 인프라 구성에서  많은 부분 전환된 일정이 있었습니다.\n그 당시 Autoscaling을 적용하기 위해서 예상되는 문제점은 무엇이 있었는지, 그리고 그것을 어떻게 극복했는지 다음과 같이 정리하였습니다.\u003c/p\u003e\n\u003ch4 id=\"기존-환경\"\u003e기존 환경\u003c/h4\u003e\n\u003cul\u003e\n\u003cli\u003e\n\u003cp\u003eAutoscaling이 적용되어있지 않은 was EC2 2개 운영\u003c/p\u003e\n\u003c/li\u003e\n\u003cli\u003e\n\u003cp\u003e해당 서버의 Spring Framework 애플리케이션 내에는 \u003ccode\u003e@Scheduled\u003c/code\u003e 어노테이션을 통한 스케줄잡 사용\u003c/p\u003e\n\u003c/li\u003e\n\u003cli\u003e\n\u003cp\u003e일부 스케줄 관련 코드 :\u003c/p\u003e\n\u003c/li\u003e\n\u003c/ul\u003e\n\u003cdiv class=\"highlight\"\u003e\u003cpre tabindex=\"0\" class=\"chroma\"\u003e\u003ccode class=\"language-java\" data-lang=\"java\"\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"nd\"\u003e@Scheduled\u003c/span\u003e\u003cspan class=\"p\"\u003e(\u003c/span\u003e\u003cspan class=\"n\"\u003ecron\u003c/span\u003e\u003cspan class=\"w\"\u003e \u003c/span\u003e\u003cspan class=\"o\"\u003e=\u003c/span\u003e\u003cspan class=\"w\"\u003e \u003c/span\u003e\u003cspan class=\"s\"\u003e\u0026#34;0 30 9 ? * *\u0026#34;\u003c/span\u003e\u003cspan class=\"p\"\u003e,\u003c/span\u003e\u003cspan class=\"w\"\u003e \u003c/span\u003e\u003cspan class=\"n\"\u003ezone\u003c/span\u003e\u003cspan class=\"w\"\u003e \u003c/span\u003e\u003cspan class=\"o\"\u003e=\u003c/span\u003e\u003cspan class=\"w\"\u003e \u003c/span\u003e\u003cspan class=\"s\"\u003e\u0026#34;GMT+9:00\u0026#34;\u003c/span\u003e\u003cspan class=\"p\"\u003e)\u003c/span\u003e\u003cspan class=\"w\"\u003e\n\u003c/span\u003e\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"w\"\u003e    \u003c/span\u003e\u003cspan class=\"kd\"\u003epublic\u003c/span\u003e\u003cspan class=\"w\"\u003e \u003c/span\u003e\u003cspan class=\"kt\"\u003evoid\u003c/span\u003e\u003cspan class=\"w\"\u003e \u003c/span\u003e\u003cspan class=\"nf\"\u003enotifyLongTermNoUseAdmin\u003c/span\u003e\u003cspan class=\"p\"\u003e()\u003c/span\u003e\u003cspan class=\"w\"\u003e \u003c/span\u003e\u003cspan class=\"p\"\u003e{\u003c/span\u003e\u003cspan class=\"w\"\u003e\n\u003c/span\u003e\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"w\"\u003e        \u003c/span\u003e\u003cspan class=\"k\"\u003eif\u003c/span\u003e\u003cspan class=\"p\"\u003e(\u003c/span\u003e\u003cspan class=\"s\"\u003e\u0026#34;1\u0026#34;\u003c/span\u003e\u003cspan class=\"p\"\u003e.\u003c/span\u003e\u003cspan class=\"na\"\u003eequals\u003c/span\u003e\u003cspan class=\"p\"\u003e(\u003c/span\u003e\u003cspan class=\"n\"\u003eGkConstant\u003c/span\u003e\u003cspan class=\"p\"\u003e.\u003c/span\u003e\u003cspan class=\"na\"\u003eWAS_NUMBER\u003c/span\u003e\u003cspan class=\"p\"\u003e))\u003c/span\u003e\u003cspan class=\"w\"\u003e \u003c/span\u003e\u003cspan class=\"p\"\u003e{\u003c/span\u003e\u003cspan class=\"w\"\u003e \u003c/span\u003e\u003cspan class=\"c1\"\u003e// STG, PRD 의 1번 서버인 경우에만 Batch 실행\u003c/span\u003e\u003cspan class=\"w\"\u003e\n\u003c/span\u003e\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"w\"\u003e            \u003c/span\u003e\u003cspan class=\"k\"\u003etry\u003c/span\u003e\u003cspan class=\"w\"\u003e \u003c/span\u003e\u003cspan class=\"p\"\u003e{\u003c/span\u003e\u003cspan class=\"w\"\u003e\n\u003c/span\u003e\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"w\"\u003e                \u003c/span\u003e\u003cspan class=\"n\"\u003eList\u003c/span\u003e\u003cspan class=\"o\"\u003e\u0026lt;\u003c/span\u003e\u003cspan class=\"n\"\u003eLongTermNoUseAdminVO\u003c/span\u003e\u003cspan class=\"o\"\u003e\u0026gt;\u003c/span\u003e\u003cspan class=\"w\"\u003e \u003c/span\u003e\u003cspan class=\"n\"\u003enotifyAdminDataList\u003c/span\u003e\u003cspan class=\"w\"\u003e \u003c/span\u003e\u003cspan class=\"o\"\u003e=\u003c/span\u003e\u003cspan class=\"w\"\u003e \u003c/span\u003e\u003cspan class=\"n\"\u003elongTermNoUseAdminSvc\u003c/span\u003e\u003cspan class=\"p\"\u003e.\u003c/span\u003e\u003cspan class=\"na\"\u003egetNotifyAdminDataList\u003c/span\u003e\u003cspan class=\"p\"\u003e();\u003c/span\u003e\u003cspan class=\"w\"\u003e\n\u003c/span\u003e\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"w\"\u003e\n\u003c/span\u003e\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"w\"\u003e                \u003c/span\u003e\u003cspan class=\"k\"\u003efor\u003c/span\u003e\u003cspan class=\"w\"\u003e \u003c/span\u003e\u003cspan class=\"p\"\u003e(\u003c/span\u003e\u003cspan class=\"n\"\u003eLongTermNoUseAdminVO\u003c/span\u003e\u003cspan class=\"w\"\u003e \u003c/span\u003e\u003cspan class=\"n\"\u003evoNotifyAdmin\u003c/span\u003e\u003cspan class=\"w\"\u003e \u003c/span\u003e\u003cspan class=\"p\"\u003e:\u003c/span\u003e\u003cspan class=\"w\"\u003e \u003c/span\u003e\u003cspan class=\"n\"\u003enotifyAdminDataList\u003c/span\u003e\u003cspan class=\"p\"\u003e)\u003c/span\u003e\u003cspan class=\"w\"\u003e \u003c/span\u003e\u003cspan class=\"p\"\u003e{\u003c/span\u003e\u003cspan class=\"w\"\u003e\n\u003c/span\u003e\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"w\"\u003e                    \u003c/span\u003e\u003cspan class=\"k\"\u003etry\u003c/span\u003e\u003cspan class=\"w\"\u003e \u003c/span\u003e\u003cspan class=\"p\"\u003e{\u003c/span\u003e\u003cspan class=\"w\"\u003e\n\u003c/span\u003e\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"w\"\u003e                        \u003c/span\u003e\u003cspan class=\"n\"\u003eList\u003c/span\u003e\u003cspan class=\"o\"\u003e\u0026lt;\u003c/span\u003e\u003cspan class=\"n\"\u003eString\u003c/span\u003e\u003cspan class=\"o\"\u003e\u0026gt;\u003c/span\u003e\u003cspan class=\"w\"\u003e \u003c/span\u003e\u003cspan class=\"n\"\u003emailReceiverIDList\u003c/span\u003e\u003cspan class=\"w\"\u003e \u003c/span\u003e\u003cspan class=\"o\"\u003e=\u003c/span\u003e\u003cspan class=\"w\"\u003e \u003c/span\u003e\u003cspan class=\"k\"\u003enew\u003c/span\u003e\u003cspan class=\"w\"\u003e \u003c/span\u003e\u003cspan class=\"n\"\u003eArrayList\u003c/span\u003e\u003cspan class=\"o\"\u003e\u0026lt;\u0026gt;\u003c/span\u003e\u003cspan class=\"p\"\u003e(\u003c/span\u003e\u003cspan class=\"n\"\u003eArrays\u003c/span\u003e\u003cspan class=\"p\"\u003e.\u003c/span\u003e\u003cspan class=\"na\"\u003easList\u003c/span\u003e\u003cspan class=\"p\"\u003e(\u003c/span\u003e\u003cspan class=\"n\"\u003evoNotifyAdmin\u003c/span\u003e\u003cspan class=\"p\"\u003e.\u003c/span\u003e\u003cspan class=\"na\"\u003egetAccountId\u003c/span\u003e\u003cspan class=\"p\"\u003e()));\u003c/span\u003e\u003cspan class=\"w\"\u003e \u003c/span\u003e\u003cspan class=\"c1\"\u003e// 메일 수신인 리스트에 장기 미사용자의 이메일 주소만 추출해 추가\u003c/span\u003e\u003cspan class=\"w\"\u003e\n\u003c/span\u003e\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"w\"\u003e\n\u003c/span\u003e\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"w\"\u003e                        \u003c/span\u003e\u003cspan class=\"c1\"\u003e// 대상자별 mail template parameter 가 다르기 때문에 for 문을 통해 개별 메일 발송\u003c/span\u003e\u003cspan class=\"w\"\u003e\n\u003c/span\u003e\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"w\"\u003e                        \u003c/span\u003e\u003cspan class=\"n\"\u003eawssesClient\u003c/span\u003e\u003cspan class=\"p\"\u003e.\u003c/span\u003e\u003cspan class=\"na\"\u003esendEmail\u003c/span\u003e\u003cspan class=\"p\"\u003e(\u003c/span\u003e\u003cspan class=\"n\"\u003emailReceiverIDList\u003c/span\u003e\u003cspan class=\"p\"\u003e,\u003c/span\u003e\u003cspan class=\"w\"\u003e \u003c/span\u003e\u003cspan class=\"s\"\u003e\u0026#34;[\u0026#34;\u003c/span\u003e\u003cspan class=\"w\"\u003e \u003c/span\u003e\u003cspan class=\"o\"\u003e+\u003c/span\u003e\u003cspan class=\"w\"\u003e \u003c/span\u003e\u003cspan class=\"n\"\u003eospAppConfig\u003c/span\u003e\u003cspan class=\"p\"\u003e.\u003c/span\u003e\u003cspan class=\"na\"\u003egetGkServerType\u003c/span\u003e\u003cspan class=\"p\"\u003e()\u003c/span\u003e\u003cspan class=\"w\"\u003e \u003c/span\u003e\u003cspan class=\"o\"\u003e+\u003c/span\u003e\u003cspan class=\"w\"\u003e \u003c/span\u003e\u003cspan class=\"s\"\u003e\u0026#34;]\u0026#34;\u003c/span\u003e\u003cspan class=\"w\"\u003e \u003c/span\u003e\u003cspan class=\"o\"\u003e+\u003c/span\u003e\u003cspan class=\"w\"\u003e \u003c/span\u003e\u003cspan class=\"n\"\u003eMailEnum\u003c/span\u003e\u003cspan class=\"p\"\u003e.\u003c/span\u003e\u003cspan class=\"na\"\u003eNOTIFICATION\u003c/span\u003e\u003cspan class=\"p\"\u003e.\u003c/span\u003e\u003cspan class=\"na\"\u003egetMailSubject\u003c/span\u003e\u003cspan class=\"p\"\u003e(),\u003c/span\u003e\u003cspan class=\"w\"\u003e\n\u003c/span\u003e\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"w\"\u003e                                \u003c/span\u003e\u003cspan class=\"n\"\u003esetMailHtmlBody\u003c/span\u003e\u003cspan class=\"p\"\u003e(\u003c/span\u003e\u003cspan class=\"n\"\u003eMailEnum\u003c/span\u003e\u003cspan class=\"p\"\u003e.\u003c/span\u003e\u003cspan class=\"na\"\u003eNOTIFICATION\u003c/span\u003e\u003cspan class=\"p\"\u003e,\u003c/span\u003e\u003cspan class=\"w\"\u003e \u003c/span\u003e\u003cspan class=\"n\"\u003evoNotifyAdmin\u003c/span\u003e\u003cspan class=\"p\"\u003e),\u003c/span\u003e\u003cspan class=\"w\"\u003e \u003c/span\u003e\u003cspan class=\"kc\"\u003enull\u003c/span\u003e\u003cspan class=\"p\"\u003e);\u003c/span\u003e\u003cspan class=\"w\"\u003e\n\u003c/span\u003e\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"w\"\u003e\n\u003c/span\u003e\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"w\"\u003e                        \u003c/span\u003e\u003cspan class=\"n\"\u003eLOG\u003c/span\u003e\u003cspan class=\"p\"\u003e.\u003c/span\u003e\u003cspan class=\"na\"\u003einfo\u003c/span\u003e\u003cspan class=\"p\"\u003e(\u003c/span\u003e\u003cspan class=\"s\"\u003e\u0026#34;##### SUCCESS Sending Notification Email : {}, {}\u0026#34;\u003c/span\u003e\u003cspan class=\"p\"\u003e,\u003c/span\u003e\u003cspan class=\"w\"\u003e \u003c/span\u003e\u003cspan class=\"n\"\u003evoNotifyAdmin\u003c/span\u003e\u003cspan class=\"p\"\u003e.\u003c/span\u003e\u003cspan class=\"na\"\u003egetSaGuid\u003c/span\u003e\u003cspan class=\"p\"\u003e(),\u003c/span\u003e\u003cspan class=\"w\"\u003e \u003c/span\u003e\u003cspan class=\"n\"\u003evoNotifyAdmin\u003c/span\u003e\u003cspan class=\"p\"\u003e.\u003c/span\u003e\u003cspan class=\"na\"\u003egetAccountId\u003c/span\u003e\u003cspan class=\"p\"\u003e());\u003c/span\u003e\u003cspan class=\"w\"\u003e\n\u003c/span\u003e\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"w\"\u003e                    \u003c/span\u003e\u003cspan class=\"p\"\u003e}\u003c/span\u003e\u003cspan class=\"w\"\u003e \u003c/span\u003e\u003cspan class=\"k\"\u003ecatch\u003c/span\u003e\u003cspan class=\"w\"\u003e \u003c/span\u003e\u003cspan class=\"p\"\u003e(\u003c/span\u003e\u003cspan class=\"n\"\u003eException\u003c/span\u003e\u003cspan class=\"w\"\u003e \u003c/span\u003e\u003cspan class=\"n\"\u003ee\u003c/span\u003e\u003cspan class=\"p\"\u003e)\u003c/span\u003e\u003cspan class=\"w\"\u003e \u003c/span\u003e\u003cspan class=\"p\"\u003e{\u003c/span\u003e\u003cspan class=\"w\"\u003e\n\u003c/span\u003e\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"w\"\u003e                        \u003c/span\u003e\u003cspan class=\"n\"\u003ee\u003c/span\u003e\u003cspan class=\"p\"\u003e.\u003c/span\u003e\u003cspan class=\"na\"\u003eprintStackTrace\u003c/span\u003e\u003cspan class=\"p\"\u003e();\u003c/span\u003e\u003cspan class=\"w\"\u003e\n\u003c/span\u003e\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"w\"\u003e                        \u003c/span\u003e\u003cspan class=\"n\"\u003eLOG\u003c/span\u003e\u003cspan class=\"p\"\u003e.\u003c/span\u003e\u003cspan class=\"na\"\u003einfo\u003c/span\u003e\u003cspan class=\"p\"\u003e(\u003c/span\u003e\u003cspan class=\"s\"\u003e\u0026#34;##### FAIL Sending Notification Email : {}, {}\u0026#34;\u003c/span\u003e\u003cspan class=\"p\"\u003e,\u003c/span\u003e\u003cspan class=\"w\"\u003e \u003c/span\u003e\u003cspan class=\"n\"\u003evoNotifyAdmin\u003c/span\u003e\u003cspan class=\"p\"\u003e.\u003c/span\u003e\u003cspan class=\"na\"\u003egetSaGuid\u003c/span\u003e\u003cspan class=\"p\"\u003e(),\u003c/span\u003e\u003cspan class=\"w\"\u003e \u003c/span\u003e\u003cspan class=\"n\"\u003evoNotifyAdmin\u003c/span\u003e\u003cspan class=\"p\"\u003e.\u003c/span\u003e\u003cspan class=\"na\"\u003egetAccountId\u003c/span\u003e\u003cspan class=\"p\"\u003e());\u003c/span\u003e\u003cspan class=\"w\"\u003e\n\u003c/span\u003e\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"w\"\u003e                    \u003c/span\u003e\u003cspan class=\"p\"\u003e}\u003c/span\u003e\u003cspan class=\"w\"\u003e\n\u003c/span\u003e\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"w\"\u003e                \u003c/span\u003e\u003cspan class=\"p\"\u003e}\u003c/span\u003e\u003cspan class=\"w\"\u003e\n\u003c/span\u003e\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"w\"\u003e                \u003c/span\u003e\u003cspan class=\"n\"\u003eLOG\u003c/span\u003e\u003cspan class=\"p\"\u003e.\u003c/span\u003e\u003cspan class=\"na\"\u003einfo\u003c/span\u003e\u003cspan class=\"p\"\u003e(\u003c/span\u003e\u003cspan class=\"s\"\u003e\u0026#34;##### SUCCESS notifyLongTermNoUseAdmin Batch\u0026#34;\u003c/span\u003e\u003cspan class=\"p\"\u003e);\u003c/span\u003e\u003cspan class=\"w\"\u003e\n\u003c/span\u003e\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"w\"\u003e            \u003c/span\u003e\u003cspan class=\"p\"\u003e}\u003c/span\u003e\u003cspan class=\"w\"\u003e \u003c/span\u003e\u003cspan class=\"k\"\u003ecatch\u003c/span\u003e\u003cspan class=\"w\"\u003e \u003c/span\u003e\u003cspan class=\"p\"\u003e(\u003c/span\u003e\u003cspan class=\"n\"\u003eException\u003c/span\u003e\u003cspan class=\"w\"\u003e \u003c/span\u003e\u003cspan class=\"n\"\u003ee\u003c/span\u003e\u003cspan class=\"p\"\u003e)\u003c/span\u003e\u003cspan class=\"w\"\u003e \u003c/span\u003e\u003cspan class=\"p\"\u003e{\u003c/span\u003e\u003cspan class=\"w\"\u003e\n\u003c/span\u003e\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"w\"\u003e                \u003c/span\u003e\u003cspan class=\"n\"\u003ee\u003c/span\u003e\u003cspan class=\"p\"\u003e.\u003c/span\u003e\u003cspan class=\"na\"\u003eprintStackTrace\u003c/span\u003e\u003cspan class=\"p\"\u003e();\u003c/span\u003e\u003cspan class=\"w\"\u003e\n\u003c/span\u003e\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"w\"\u003e                \u003c/span\u003e\u003cspan class=\"n\"\u003eLOG\u003c/span\u003e\u003cspan class=\"p\"\u003e.\u003c/span\u003e\u003cspan class=\"na\"\u003einfo\u003c/span\u003e\u003cspan class=\"p\"\u003e(\u003c/span\u003e\u003cspan class=\"s\"\u003e\u0026#34;##### FAIL notifyLongTermNoUseAdmin Batch\u0026#34;\u003c/span\u003e\u003cspan class=\"p\"\u003e);\u003c/span\u003e\u003cspan class=\"w\"\u003e\n\u003c/span\u003e\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"w\"\u003e            \u003c/span\u003e\u003cspan class=\"p\"\u003e}\u003c/span\u003e\u003cspan class=\"w\"\u003e\n\u003c/span\u003e\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"w\"\u003e        \u003c/span\u003e\u003cspan class=\"p\"\u003e}\u003c/span\u003e\u003cspan class=\"w\"\u003e\n\u003c/span\u003e\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"w\"\u003e    \u003c/span\u003e\u003cspan class=\"p\"\u003e}\u003c/span\u003e\u003cspan class=\"w\"\u003e\n\u003c/span\u003e\u003c/span\u003e\u003c/span\u003e\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003cul\u003e\n\u003cli\u003e위와 같이 “코드가 실행되는 두대의 서버 중 vm option의 특정 시스템 프로퍼티가 ‘1’로 되어있는 서버에서만 동작”하도록 조건이 설정 (\u003ccode\u003eif(\u0026quot;1\u0026quot;.equals(GkConstant.WAS_NUMBER))\u003c/code\u003e)\u003c/li\u003e\n\u003c/ul\u003e\n\u003ch4 id=\"문제점\"\u003e문제점\u003c/h4\u003e\n\u003cp\u003eAutoscaling을 적용하게되면 서버가 동적으로 생성 및 삭제되기 때문에 어느서버가 1번 서버인지 기준이 사라지는 상황. 따라서 기존의 Scheduled 로직이 여러 서버에서 중복으로 실행되거나 혹은 전혀 실행될 수 없는 로직\u003c/p\u003e","title":"Autoscaling 적용을 위한 스케줄(@Scheduled) 로직 변경"},{"content":"명령어를 통해 Visual Studio Code로 특정 경로 열기 터미널에서 작업을 하다보면 종종 특정 경로를 “Visual Studio Code”로 바로 열고싶을 때가 있다. 이럴때 사용할 수 있는 방법으로, 터미널에서 code . 명령을 통해서 현재 위치를 vscode로 열거나 code ~/열고싶은 디렉토리 경로 명령으로 열 수 있는데, 이 커맨드를 사용하기 위해서는 다음과 같은 과정이 필요하다.\n‘code’ 명령 설치 Visual Studio Code 열고 cmd + shift + p\ncode 입력 후 Shell Command: Install ‘code’ command in PATH 클릭\nEnd.\n⚠️ 다음과 같은 에러가 발생할 때도 있다.\n이 경우 sudo rm -rf /usr/local/bin/code 명령 후 다시 위 과정 진행하면 된다.\n","permalink":"https://jaylab.link/vscode-open-command/","summary":"\u003ch3 id=\"명령어를-통해-visual-studio-code로-특정-경로-열기\"\u003e명령어를 통해 \u003cstrong\u003eVisual Studio Code\u003c/strong\u003e로 특정 경로 열기\u003c/h3\u003e\n\u003cp\u003e터미널에서 작업을 하다보면 종종 특정 경로를 “\u003cstrong\u003eVisual Studio Code\u003c/strong\u003e”로 바로 열고싶을 때가 있다.\n이럴때 사용할 수 있는 방법으로, 터미널에서 \u003ccode\u003ecode .\u003c/code\u003e 명령을 통해서 현재 위치를 vscode로 열거나\n\u003ccode\u003ecode ~/열고싶은 디렉토리 경로\u003c/code\u003e 명령으로 열 수 있는데, 이 커맨드를 사용하기 위해서는 다음과 같은 과정이 필요하다.\u003c/p\u003e\n\u003ch3 id=\"code-명령-설치\"\u003e‘code’ 명령 설치\u003c/h3\u003e\n\u003col\u003e\n\u003cli\u003e\n\u003cp\u003eVisual Studio Code 열고 \u003ccode\u003ecmd + shift + p\u003c/code\u003e\u003c/p\u003e\n\u003c/li\u003e\n\u003cli\u003e\n\u003cp\u003e\u003ccode\u003ecode\u003c/code\u003e 입력 후 \u003ccode\u003eShell Command: Install ‘code’ command in PATH\u003c/code\u003e 클릭\u003c/p\u003e","title":"Open vscode with the Terminal 'code .' command"},{"content":"들어가며 Input Box에 입력된 값을 코드를 통해 대소문자 변경하는 것은 매우매우 쉽다. 다만, 무엇이든 ‘편의성을 올리는 것’은 조금 더 고민이 필요하다. ‘이러면 좀 더 편할 것 같은데..’ 라는 생각이 들만한 부분을 사용자 관점에서 주관적으로 생각해보아야하고, 다양한 측면에서 따져보아야하기 때문이다.\n최근에 만들었던 기능 중에도 위와 같이 별거 아니지만 사용자 관점에서 문제를 생각해봐야하는 순간이 있었다. Javascript를 다루는 한 결국 언젠가 이러한 고민의 순간은 또 다시 찾아올 것이기에, 그 때 내가 고민한 것은 어떤 것들이었는지 간단하게 정리해보고 그렇게 구현한 코드를 기록하고자 한다.\n상황 및 목표 상황 : “select box”와 “text input box”가 1개 씩 존재\n목표 :\nselect box 특정 요소 A 선택시 : input box에 기존에 입력된 값과 앞으로 입력 될 값은 모두 소문자\nselect box 특정 요소 B 선택시 : input box에 기존에 입력된 값과 앞으로 입력 될 값은 모두 대문자\ninput box에 글이 길게 입력되어있는 상태에서, 글의 중간으로 커서를 옮긴 후 값을 입력하는 경우 기존의 값 영향 없이, 입력되는 값의 타입 대소문자 설정\n이슈 개발을 하다보니 위의 세번째 목표에서 의외로 문제가 있었다.\n화면에 보여지는 값은 대소문자 변환이 정상적으로 이루어진듯 보이지만 실제로 내부적으로는 값이 변경되지 않는 현상\n단순하게 대소문자 변경하는 방식은 SELECT 변환시 혹은 타이핑시 커서가 맨끝으로 강제로 이동되는 현상\n첫번째 문제는 확인해보니 vue.js를 사용하는 경우 겪을 수 있을 문제였다. 단순히 Input Box의 값 뿐만 아니라 지정된 v-model 프로퍼티 값도 명시적 변경이 필요했다.\n두번째 문제는 대소문자 변경 후 입력된 값이 전체 단위로 replace되어 기존 커서의 위치를 잃어버리기 때문이었다. 이것은 “setSelectionRange\u0026ldquo;라는 함수를 통해 해결할 수 있었다. 원래 setSelectionRange는 특정 영역의 글자를 마치 마우스로 드래그한 듯 선택하기 위해 사용되는 함수다. 그래서 나는 이것을 활용하여 대소문자 변경 직전 keyup 이벤트가 발생된 위치를 저장해두고 문자열 치환 직후 setSelectionRange를 통해 커서의 위치를 되돌려주는 방법으로 문제를 해결하였다.\n최종 코드 위 모든 목표와 문제를 해결할 수 있는, 최종적으로 구현된 코드는 다음과 같다.\ncommon.js function handleInput(selectBox, inputBox, data, fieldPath) { selectBox = document.querySelector(selectBox); inputBox = document.querySelector(inputBox); if (!isEmpty(fieldPath)) { data = fieldPath.reduce((target, key) =\u0026gt; target[key], data); } function convertByChange() { if (selectBox.value == \u0026#39;gkId\u0026#39;) { data.method_info = data.method_info.trim().toLowerCase(); } else if (selectBox.value == \u0026#39;parentTicketId\u0026#39; || selectBox.value == \u0026#39;ticketId\u0026#39;) { data.method_info = data.method_info.trim().toUpperCase(); } } function convertByKeyup(event) { if( ![\u0026#39;gkId\u0026#39;, \u0026#39;parentTicketId\u0026#39;, \u0026#39;ticketId\u0026#39;].includes(selectBox.value) ) return; let input = event.target; let start = input.selectionStart; let end = input.selectionEnd; let inputValue = input.value; if (selectBox.value == \u0026#39;gkId\u0026#39;) { inputValue = inputValue.toLowerCase(); } else if (selectBox.value == \u0026#39;parentTicketId\u0026#39; || selectBox.value == \u0026#39;ticketId\u0026#39;) { inputValue = inputValue.toUpperCase(); } input.value = inputValue; data.method_info = inputValue; input.setSelectionRange(start, end); } function convertInputString(event) { let input = event.target; let inputValue = input.value; inputValue = inputValue.replace(/(\\s+)/g, \u0026#39;,\u0026#39;).replace(/,+/g, \u0026#39;,\u0026#39;).replace(/(^,)|(,$)/g, \u0026#39;\u0026#39;).split(\u0026#39;,\u0026#39;); if(inputValue.length \u0026gt; 30) { alert(\u0026#34;한 번에 여러 개 조회 시 최대 30개까지 검색할 수 있습니다.\u0026#34;); inputValue = inputValue.slice(0,30); } inputValue = inputValue.join(\u0026#39;, \u0026#39;); input.value = inputValue; data.method_info = inputValue; } function convertByEnter(event){ if(event.keyCode == 13){ convertInputString(event); } } selectBox.addEventListener(\u0026#39;change\u0026#39;, convertByChange); inputBox.addEventListener(\u0026#39;keyup\u0026#39;, convertByKeyup); inputBox.addEventListener(\u0026#39;blur\u0026#39;, convertInputString); inputBox.addEventListener(\u0026#39;keydown\u0026#39;, convertByEnter); } Input핸들링대상화면.js 화면초기화함수 : function() { handleInput(\u0026#39;#method_id\u0026#39;,\u0026#39;#method_info\u0026#39;, this); } input핸들링대상화면.jsp \u0026lt;div class=\u0026#34;input_select col6\u0026#34;\u0026gt; \u0026lt;select name=\u0026#34;\u0026#34; v-model=\u0026#34;method_id\u0026#34; id=\u0026#34;method_id\u0026#34;\u0026gt; \u0026lt;option v-for=\u0026#34;method in methodIdList\u0026#34; :value=\u0026#34;method.comnCd\u0026#34;\u0026gt; {{ method.comnCdNm }} \u0026lt;/option\u0026gt; \u0026lt;/select\u0026gt; \u0026lt;/div\u0026gt; \u0026lt;input type=\u0026#34;text\u0026#34; name=\u0026#34;\u0026#34; @keyup.enter=\u0026#34;searchVocRequestedList(1)\u0026#34; v-model=\u0026#34;method_info\u0026#34; id=\u0026#34;method_info\u0026#34; class=\u0026#34;col4\u0026#34; value=\u0026#34;\u0026#34;\u0026gt; 결과 SELECT → 선택된 option에 따른 입력값 대소문자 변경 \u0026amp; option 변경시 대소문자 변경 중간부터 입력시 대소문자 제어 \u0026amp; 커서 위치 보존 ","permalink":"https://jaylab.link/js-input-upper-lower-case/","summary":"Thinking about user usability for \u0026lsquo;input box’","title":"[JS] input box 사용자 편의성에 대한 고민"},{"content":"특정 IP에서만 접근할 수 있도록 제한하는 Policy 생성 및 적용하기 Policy \u0026gt; Create Policy \u0026gt; JSON \u0026gt; 아래 내용 적용 \u0026gt; 생성된 Policy를 대상 사용자 권한 목록에 추가\n{ \u0026#34;Version\u0026#34;: \u0026#34;2012-10-17\u0026#34;, \u0026#34;Statement\u0026#34;: { \u0026#34;Effect\u0026#34;: \u0026#34;Deny\u0026#34;, \u0026#34;Action\u0026#34;: \u0026#34;*\u0026#34;, \u0026#34;Resource\u0026#34;: \u0026#34;*\u0026#34;, \u0026#34;Condition\u0026#34;: { \u0026#34;NotIpAddress\u0026#34;: { \u0026#34;aws:SourceIp\u0026#34;: [ \u0026#34;{허용 대상 IP}/32\u0026#34; ] }, \u0026#34;Bool\u0026#34;: { \u0026#34;aws:ViaAWSService\u0026#34;: \u0026#34;false\u0026#34; } } } } 이미 콘솔에 접속되어있는 경우 로그아웃 후 재접속 후 부터 정책이 정상적으로 적용되었다.\n콘솔에 여전히 접속은 되지만 서비스 리소스는 확인도 접근도 할 수 없는 상태가 되었다.\n","permalink":"https://jaylab.link/aws-access-deny-policy/","summary":"Policy to block certain IP access to the console or CLI","title":"[AWS] 콘솔과 CLI 접속시 특정 IP 외 접근 차단 정책"},{"content":"document.cookie 들어가며 Frontend에서 Cookie를 다룰 때, 일반적으로 javascript의 document.cookie 를 통해 get, set, delete 할 수 있다. 이러한 cookie는 같은 브라우저 내에서 값을 저장, 전달, 공유할 수 있다는 점에서 유용하게 사용된다. 현재 투입되어있는 프로젝트에서도 document.cookie로 쿠키가 관리되고있으며, 다음과 같은 용도로 사용되고있다.\n다른 화면으로 파라미터를 넘기기 위해\n뒤로가기 시 이전의 검색조건 값을 다시 불러오기 위해\nwindow.open 을 통한 팝업 창에서 부모 창으로 값을 전달하기 위해\n문제점 이러한 document.cookie를 단순하게 사용하기에는 한 가지 문제가 있었다. 사용자가 목록페이지에서 “각기 다른 여러 글”을 Ctrl을 누른채 클릭하여 새로운 탭으로 띄울때, 그리고 이 과정이 짧은 시간에 이루어질때 몇가지 문제가 발생했다. A, B, C 글을 짧은시간에 ‘거의’ 동시에 띄울경우 A, B, C 글에 대한 각각의 탭이 열리는게 아닌, C의 글만 3개가 띄워진 것. 이는 A탭이 열린 직 후 A에 대한 파라미터를 담은 쿠키를 조회하기 전, C에 대한 쿠키로 덮어쓰기되어 발생된 현상이었다.\n그리고 자주 발생하지는 않지만 간혹 A의 탭만 정상적으로 출력되고 나머지는 데이터가 보이지 않는 현상도 있었다. 결과적으로 document.cookie가 비동기적으로 작동한다는 특징 때문에 발생되는 현상이었다.\n그래서 document.cookie를 사용할 때는 다음과 같은 상황을 고려해야한다.\ndocument.cookie 객체는 값 변경시 즉시 업데이트 되지 않기 때문에, 짧은시간 내 조회시 이전 값이 반환될 수도 있다.\n쿠키의 “생성 \u0026gt; 조회 \u0026gt; 삭제”가 짧은시간에 여러번 실행되는 경우 정확한 데이터를 보장할 수 없다.\n이를 해결하기 위해 localStorage, sessionStorage를 사용한 방식을 생각해볼 수도 있었다. 두개 모두 기본적으로 간단한 데이터를 브라우저에 저장할 수 있도록 지원하지만 이는 또 다른 문제를 야기한다.\nlocalStorage는 데이터를 브라우저에 “영구적”으로 저장시킨다. 그래서 특정 패턴에 따라 데이터가 삭제되지 않을 수도 있다. (방어코드와 만료시간을 적용하여 해결하는 것도 가능은 하겠지만 모든 케이스에 대한 완벽 방어를 하려면 공수가 커지는 것도 고민해봐야할 문제)\nsessionStorage는 localStorage와 달리 데이터를 “영구적”으로 저장하지는 않는다. localStorage 처럼 새로고침을 해도 데이터가 유지되지만, 해당 탭이 닫히면 데이터가 삭제된다. 데이터의 사용 범위도 sessionStorage가 사용된 그 ‘탭’에서만 국한된다.\n이러한 특징들 때문에 localStorage, sessionStorage 모두 이번 현상을 해결하는데 적합하지 않았다.\n해결 방법 목표\n이 밖에도 새로운 라이브러리를 도입하는 것도 고민해 보았으나, 결국 많은 화면에서 document.cookie가 사용 적용되고 있었기에, 다음과 같은 목표를 세워두고 최대한 기존 코드를 활용하는 방법으로 수정하였다.\n만료시간 지정이 가능할 것\n같은 글을 여러개 탭으로 동시에 띄워도 파라미터는 독립적으로 전달될 것\n“파라미터 전달용 데이터”와 “검색조건 값 복원을 위한 데이터”간 충돌 혹은 덮어쓰기 현상이 발생되지 않을 것\n공통함수를 구현하여 기존의 로직을 최대한 활용할 것\n**구현 **\nAS-IS :\n화면의 카테고리별로 동일한 Cookie Key 값 사용 TO-BE :\n생성되는 쿠키의 key값이 중복되지 않도록 조치(랜덤값)\n쿠키 생성 후 고유한 쿠키 key값을 url에 QueryString 형태로 저장 (화면 진입 및 뒤로가기시 key 값 전달)\n모든 쿠키는 독립적으로 생성되는 만큼 사용자가 여러 탭을 지속하는 만큼 쿠키가 누적되는 문제 방지 → 쿠키 생성시 현재 메모리 체크, 기준치를 초과하는 경우 오래된 쿠키 삭제\n그렇게 수정된 코드는 다음과 같다.\n수정된 코드 common.js // 고유 key name 쿠키 생성 함수 // 여러 Tab 동시 오픈시 데이터 조회 에러 대응 \u0026gt; document.cookie의 비동기적 방식에 의한 현상 function setRareCookie(cookieValue, expireDate, uri) { checkAndDeleteCookie(); // 총 크기 체크. 기준치 이상인 경우 가장 오래된 쿠키 삭제하는 함수 let cookieName = randomNum(); // 쿠키의 고유 key name 생성 changeBrowserLocation(cookieName); // key name을 URL에 설정 document.cookie = cookieName + \u0026#34;=\u0026#34; + escape(cookieValue) + \u0026#34;;expires=\u0026#34; + expireDate + \u0026#34;;path=\u0026#34; + uri; return cookieName; } // URL에 있는 dataKey를 Key Name으로 갖는 쿠키 조회 function getRareCookie() { let cookieName = getDataKey(); let cookie = document.cookie; if (cookie.length \u0026gt; 0 \u0026amp;\u0026amp; !isEmpty(cookieName)) { let startIndex = cookie.indexOf(cookieName); if (startIndex != -1) { startIndex += cookieName.length; let endIndex = cookie.indexOf(\u0026#34;;\u0026#34;, startIndex); if (endIndex == -1) endIndex = cookie.length; return unescape(cookie.substring(startIndex + 1, endIndex)); } else { return \u0026#39;\u0026#39;; } } else { return \u0026#39;\u0026#39;; } } // 쿠키 삭제 function deleteRareCookie(uri) { let expireDate = new Date(); expireDate.setDate(expireDate.getDate() - 1); document.cookie = getDataKey() + \u0026#34;= \u0026#34; + \u0026#34;;expires=\u0026#34; + expireDate.toGMTString() + \u0026#34;;path=\u0026#34; + uri; } // URL의 QueryString 중 dataKey 유무 확인 function getDataKey(){ let dataKey = new URL(location.href).searchParams.get(\u0026#39;dataKey\u0026#39;) || \u0026#39;\u0026#39;; return dataKey; } // 화면 이동시 URL에 cookie key name 전달 function getDataKeyUrl(delimiter){ let dataKey = getDataKey(); return dataKey \u0026amp;\u0026amp; `${delimiter}dataKey=${dataKey}`; } function randomNum(){ let randomNum = String(Math.floor(Math.random() * 10000)); let currDateTime = String(new Date().getTime()).substring(3); return currDateTime + randomNum; } // 쿠키의 총 크기 반환 함수 function getCookieSize() { let totalSize = 0; let cookies = document.cookie.split(\u0026#39;;\u0026#39;); for (let i = 0; i \u0026lt; cookies.length; i++) { let cookie = cookies[i].trim(); totalSize += cookie.length * 2; // 쿠키의 길이 2배(유니코드 문자 고려) } return totalSize; } // 총 크기를 체크하여 기준치 이상인 경우 가장 오래된 쿠키 삭제 function checkAndDeleteCookie() { let maxSize = 8500; while (getCookieSize() \u0026gt; maxSize) { let cookies = document.cookie.split(\u0026#39;; \u0026#39;); let oldestCookie = 0; let firstOfAll = true; let deleteTargetCookieName = 0; for (let i = 0; i \u0026lt; cookies.length; i++) { let keyName = cookies[i].substring(0, cookies[i].indexOf(\u0026#39;=\u0026#39;)); if(!isNumericString(keyName)) continue; let keyName_dateTime = parseInt(keyName.substring(0,10)); if(firstOfAll) { oldestCookie = keyName_dateTime; deleteTargetCookieName = keyName; firstOfAll = false; continue; } if(oldestCookie \u0026gt; keyName_dateTime){ oldestCookie = keyName_dateTime; deleteTargetCookieName = keyName; } } if(firstOfAll) break; // 삭제할 쿠키 없는 경우 break document.cookie = deleteTargetCookieName + \u0026#39;=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/;\u0026#39;; } } function isNumericString(str) { let pattern = /^\\d+$/; return pattern.test(str); } // 고유 key name을 URL에 설정 function changeBrowserLocation(dataKey){ let params = new URLSearchParams(location.search); params.set(\u0026#39;dataKey\u0026#39;, dataKey); let queryString = params.toString(); let url = location.pathname + \u0026#39;?\u0026#39; + queryString; window.history.replaceState({}, document.title, url); } 리스트페이지.js (저장된 쿠키 조회. 쿠키 존재하는 경우 화면에 데이터 세팅) // 쿠키 조회 후 화면에 데이터 세팅 화면시작시실행되는초기화함수 : function(){ this.searchCondition = getRareCookie(); if(this.searchCondition != \u0026#39;\u0026#39;) { ... (화면 inputbox에 setting 변수 초기화) ... deleteRareCookie(\u0026#34;/\u0026#34;); } } ... // 상세페이지로 이동 화면이동함수 : function(){ ... let dataKey = setRareCookie({다음화면에 전달할 JSON 데이터}, expireDate, \u0026#34;/\u0026#34;); window.location.href = \u0026#34;{상세페이지URL}?dataKey=\u0026#34; + dataKey; } 상세페이지.js (뒤로가기시 파라미터 설정) 뒤로가기함수 : function() { window.location.href = \u0026#34;{목록페이지URL}\u0026#34; + getDataKeyUrl(\u0026#34;?\u0026#34;); } 📌Vanila JS 외에도 쿠키를 편하게 다룰수 있도록 지원하는 라이브러리\njs-cookie react-cookie ","permalink":"https://jaylab.link/js-cookie/","summary":"\u003ch2 id=\"documentcookie\"\u003edocument.cookie\u003c/h2\u003e\n\u003chr\u003e\n\u003ch4 id=\"들어가며\"\u003e들어가며\u003c/h4\u003e\n\u003cp\u003eFrontend에서 Cookie를 다룰 때, 일반적으로 javascript의  \u003ccode\u003edocument.cookie\u003c/code\u003e 를 통해 get, set, delete 할 수 있다. 이러한 cookie는 같은 브라우저 내에서 값을 저장, 전달, 공유할 수 있다는 점에서 유용하게 사용된다. 현재 투입되어있는 프로젝트에서도 \u003ccode\u003edocument.cookie\u003c/code\u003e로 쿠키가 관리되고있으며, 다음과 같은 용도로 사용되고있다.\u003c/p\u003e\n\u003cul\u003e\n\u003cli\u003e\n\u003cp\u003e다른 화면으로 파라미터를 넘기기 위해\u003c/p\u003e\n\u003c/li\u003e\n\u003cli\u003e\n\u003cp\u003e뒤로가기 시 이전의 검색조건 값을 다시 불러오기 위해\u003c/p\u003e\n\u003c/li\u003e\n\u003cli\u003e\n\u003cp\u003e\u003ccode\u003ewindow.open\u003c/code\u003e 을 통한 팝업 창에서 부모 창으로 값을 전달하기 위해\u003c/p\u003e\n\u003c/li\u003e\n\u003c/ul\u003e\n\u003ch4 id=\"문제점\"\u003e문제점\u003c/h4\u003e\n\u003cp\u003e이러한 \u003ccode\u003edocument.cookie\u003c/code\u003e를 단순하게 사용하기에는 한 가지 문제가 있었다.\n사용자가 목록페이지에서 “각기 다른 여러 글”을 \u003ccode\u003eCtrl\u003c/code\u003e을 누른채 클릭하여 새로운 탭으로 띄울때, 그리고 이 과정이 짧은 시간에 이루어질때 몇가지 문제가 발생했다. A, B, C 글을 짧은시간에 ‘거의’ 동시에 띄울경우 A, B, C 글에 대한 각각의 탭이 열리는게 아닌, C의 글만 3개가 띄워진 것. 이는 A탭이 열린 직 후 A에 대한 파라미터를 담은 쿠키를 조회하기 전, C에 대한 쿠키로 덮어쓰기되어 발생된 현상이었다.\u003c/p\u003e","title":"[JS] document.cookie 사용 이슈 및 해결 방법"},{"content":"📜 contents\nREDIS CLI 명령어 정리 접속 관련 명령어 포트오픈 확인하기\n$ netstat -nlpt | grep 6379 (Not all processes could be identified, non-owned process info will not be shown, you would have to be root to see it all.) tcp 0 0 127.0.0.1:6379 0.0.0.0:* LISTEN - 6480번 포트의 redis 서버 접속\n$ ./redis-cli -p 6480 127.0.0.1:6480\u0026gt; 다른 서버에 있는 6480포트의 redis 서버 접속\n$ ./redis-cli -h 192.168.124.xxx -p 6480 127.0.0.1:6480\u0026gt; DB번호가 0인 redis 서버 접속\n$ ./redis-cli -n 0 -p 6480 127.0.0.1:6480\u0026gt; **비밀번호가 **test인 redis 서버 접속\n$ ./redis-cli -a test -p 6480 127.0.0.1:6480\u0026gt; ※ -a auth의 약어\nredis 서버가 cluster인 경우.\nget을 해보면 해당 key가 있는 서버로 Redirected\n$ ./redis-cli -p 6480 -c 127.0.0.1:6480\u0026gt; get a -\u0026gt; Redirected to slot [15495] located at node3:6480 \u0026#34;b\u0026#34; 정보 확인하기 redis 서버 정보 확인\n$ ./redis-cli -p 6480 info info 정보에 대한 설명\n# Server # 레디스 서버에 대한 일번적인 정보 # Clients # 클라이언트 정보 # 현재 접속해 있는 클라이언트 수 connected_clients:2 # 대기중인 클라이언트 수 blocked_clients:0 # Memory used_memory:3287928 # 현재 사용중인 메모리 used_memory_human:3.14M # OS에서 Redis가 실제 차지하는 메로리양 used_memory_rss_human:4.09M # peak 시 사용한 메모리 used_memory_peak_human:3.14M # MaxMemory 설정 maxmemory_human:0B # MaxMemory 초과 시 데이터 처리 정책 maxmemory_policy:noeviction # Persistence # Stats # 현재 redis 서버 상태 total_connections_received:8 # 현재 처리하고 있는 초 당 operation의 수 instantaneous_ops_per_sec:0 # 현재 input kbps instantaneous_input_kbps:0.00 # 현재 output kbps instantaneous_output_kbps:0.00 # Replication # CPU # Modules # Cluster cluster_enabled:0 # Keyspace 해당 redis 서버에 실시간으로 어떠한 작업이 있는지 모니터링 가능\n$ redis-cli -p 6480 monitor OK 데이터 CRUD 명령어 redis 서버 내 모든 key 조회\nkeys * 키, 값 저장\nset {key} {value} 특정 키 값 조회\nget {key} 특정 문자열을 포함하는 키 조회 keys *te* 결과 : 1) \u0026#34;test1\u0026#34; 2) \u0026#34;test2\u0026#34; 여러 개의 key, value 를 한번에 저장\nmset {key} {value} [{key} {value} ...] 여러 개의 key 에 해당하는 value 를 한번에 가져옴\nmget {key} [{key} ...] # 사용 예시 127.0.0.1:6379\u0026gt; MSET username assu userage 20 usercity seoul OK 127.0.0.1:6379\u0026gt; MGET username usercity 1) \u0026#34;assu\u0026#34; 2) \u0026#34;seoul\u0026#34; 127.0.0.1:6379\u0026gt; GET usercity \u0026#34;seoul\u0026#34; 특정 키 삭제\ndel {key} # 동시에 여러 key 삭제하기 del {key} [{key} ...] 만료시간 확인하기(단위: 초)\nttl {key} 출력결과 -1 : 만료시간이 설정되어있지 않음\n출력결과 -2 : 만료\n30초 만료시간 지정하기\nset {key} {value} EX 30 30초 안에 해당 키에 업데이트가 발생하면 만료시간 30초로 초기화. 업데이트와 상관없이 설정한 만료시간이 지나면 폐기하는 방법 set {key} {value} KEEPTTL 30 생성되어있는 키에 만료시간 설정하기\nEXPIRE tutorialspoint 60 출력결과가 (integer) 1 이면 만료시간 설정 완료 (integer) 0 이면 존재하지 않거나 제한 시간을 설정할 수 없는 경우 key 존재 여부 확인\nexists {key} 랜덤으로 키 조회\nrandomkey 특정 key의 타입 조회\ntype {key} redis 서버 내 key의 전체 갯수\ndbsize 해당 레디스 서버 내 모든 데이터 삭제\nflushall 현재 접속되어있는(선택된) db 내 모든 데이터 삭제\nflushdb 기존 key값 재사용\nset a b nx =\u0026gt; key a에 값이 있는 경우 수정 X (기존 값이 없다면 값을 세팅) set a b xx =\u0026gt; key a에 값이 있는 경우 수정 O (기존 값이 없다면 변경 X) 특정 key값의 value 증가 및 감소\nset a 10 incr a # 1증가 get a # \u0026#34;11\u0026#34; decr a # 1갑소 set a 10 incrby a 10 # 10증가 get a # \u0026#34;20\u0026#34; decrbt a 10 # 10감소 #소수형태로 증감 처리 \u0026gt; INCRBYFLOAT (증가처리 감소처리 모두 동일한 명령어로 처리) INCRBYFLOAT a 2.5 # 2.5 증가 INCRBYFLOAT a -2.5 # 2.5 감소 append\n현재 value 값 뒤에 value 를 추가\n만일 key 가 존재하지 않으면 신규로 생성\n127.0.0.1:6480\u0026gt; APPEND username Hello # key 가 없는 경우 생성 (integer) 4 127.0.0.1:6480\u0026gt; GET username \u0026#34;assu\u0026#34; 127.0.0.1:6480\u0026gt; APPEND username World # 기존에 존재하는 key 에 APPEND (integer) 6 127.0.0.1:6480\u0026gt; GET username \u0026#34;HelloWorld\u0026#34; getrange, setrange\nstring 연산에서 substring, replace 와 동일한 연산 127.0.0.1:6379\u0026gt; SET hello world OK 127.0.0.1:6379\u0026gt; GET hello \u0026#34;world\u0026#34; 127.0.0.1:6379\u0026gt; GETRANGE hello 2 4 \u0026#34;rld\u0026#34; 127.0.0.1:6379\u0026gt; SETRANGE hello 2 ppp (integer) 5 127.0.0.1:6379\u0026gt; GET hello \u0026#34;woppp\u0026#34; ※ Redis 는 Single Thread 기반이기 때문에 keys, flushall, flushdb, getall 등의 명령어는 운영 환경에서 사용시 위험\n기타 명령어 redis-cli로 COMMAND 실행\n$ redis-cli -p 6379 keys \u0026#34;*\u0026#34; 레디스 서버의 중요 통계정보를 주기적보기\n$ redis-cli -p 6379 --stat 여러 서버에 일괄 명령 실행하기\n$ for i in {6001..6010}; do src/redis-cli -p $i dbsize; done 자료구조 다루기 ➤ Hash hset user:1 name An city seoul # 초기화 # \u0026#34;특정 hash \u0026amp; 특정 key\u0026#34;의 값 조회 hget user:1 name # \u0026#34;An\u0026#34; hget user:1 city # \u0026#34;seoul\u0026#34; # \u0026#34;특정 hash \u0026amp; 특정 key\u0026#34;의 값 삭제 hdel user:1 name # name 삭제 # \u0026#34;특정 hash\u0026#34;의 모든 key-value 조회 hgetall user:1 # \u0026#34;특정 hash\u0026#34;의 모든 key만 조회 hkeys user:1 # key만 조회 # \u0026#34;특정 hash\u0026#34;의 모든 value만 조회 hvals user:1 # value만 조회 ➤ List Linked List 형태\n특정 값이나 인덱스로 데이터를 찾거나 삭제할 수 있음\n최대 길이 : 2^32 - 1(4,294,967,295)\n주로 Stack, Queue 자료구조를 구현하기 위한 용도로 사용\nex) 백그라운드 작업자 시스템을 위한 대기열 관리 구축 Stack\nQueue\n# 생성 lpush {queue name} {value 1} 혹은 lpush {queue name} {value 1} {value 2} {value 3} ... # FIFO 추출 rpop {queue name} # LIFO 추출 lpop {queue name} 🎯 rpush와 lpush 명령어가 있고, rpop과 lpop이 있다. rpush를 통해 값을 생성하면 \u0026#34;큐\u0026#34; 배열 기준 오른쪽에서 값을 insert하는 식이기 때문에 오른쪽으로 갈 수록 가장 최근에 값이 들어온 형태. 예를들어, rpush test-queue A B C 라고한다면 A부터 순서대로 insert. 오른쪽에서 C가 가장 마지막에 insert 예상 큐 형태 : [A, B, C] 이 상태에서 lpop test-queue \u0026#34;A\u0026#34; lpop test-queue \u0026#34;B\u0026#34; lpop test-queue \u0026#34;C\u0026#34; 🎯 pop 명령어로 큐에서 out되는게 아니라 특정 인덱스에 저장되어있는 값 조회하기 # 선언 127.0.0.1:6480\u0026gt; rpush queue-name 1 2 3 4 5 (integer) 5 127.0.0.1:6480\u0026gt; llen queue-name # 큐 크기 확인 (integer) 5 # 큐 요소 전체 확인 127.0.0.1:6480\u0026gt; lrange queue 0 -1 1) \u0026#34;1\u0026#34; 2) \u0026#34;2\u0026#34; 3) \u0026#34;3\u0026#34; 4) \u0026#34;4\u0026#34; 5) \u0026#34;5\u0026#34; # 특정 인덱스에 저장되어있는 값 조회하기 127.0.0.1:6480\u0026gt; lrange queue 0 0 1) \u0026#34;1\u0026#34; ➤ Set 순서를 유지하지 않고 중복을 허용하지 않는 자료구조\n# 생성 127.0.0.1:6480\u0026gt; sadd set-name1 1 1 2 2 3 3 1 1 (integer) 3 # set의 크기 확인 127.0.0.1:6480\u0026gt; scard set-name1 (integer) 3 # set의 모든 원소 조회 127.0.0.1:6480\u0026gt; smembers set-name1 1) \u0026#34;1\u0026#34; 2) \u0026#34;2\u0026#34; 3) \u0026#34;3\u0026#34; #생성 127.0.0.1:6480\u0026gt; sadd set-name2 1 2 3 # 1이 set에 존재하는지 확인 127.0.0.1:6480\u0026gt; sismember set-name2 1 # 1을 set-name2라는 set에서 삭제 (remove) 127.0.0.1:6480\u0026gt; srem set-name2 1 # set에서 랜덤하게 한 개 원소를 pop 127.0.0.1:6480\u0026gt; spop set-name2 ➤ SortedSet value마다 가중치를 부여하여 정렬되는 Set 타입\n랭킹 기능 구현시 고려해볼 수 있는 자료구조\n# 기본 문법 zadd {SortedSet name} [가중치1] [key1] [가중치2] [key2] ... # 생성 zadd test-name 30 a 10 b 15 c (integer) 3 # 전체 출력 (default 오름차순) zrange test-name 0 -1 1) \u0026#34;b\u0026#34; 2) \u0026#34;c\u0026#34; 3) \u0026#34;a\u0026#34; # 전체 출력 (내림차순) zrange test-name 0 -1 rev 1) \u0026#34;a\u0026#34; 2) \u0026#34;c\u0026#34; 3) \u0026#34;b\u0026#34; # 가중치와 함께 내림차순 조회 (withscores) zrange test-name 0 -1 rev withscores 1) \u0026#34;a\u0026#34; 2) \u0026#34;30\u0026#34; 3) \u0026#34;c\u0026#34; 4) \u0026#34;15\u0026#34; 5) \u0026#34;b\u0026#34; 6) \u0026#34;10\u0026#34; # zrank \u0026gt; 가장 가중치가 낮은 요소의 순위가 0 (오름차순) zrank test-name a (integer) 2 zrank test-name b (integer) 0 zrank test-name c (integer) 1 # zrevrank\u0026gt; 가장 가중치가 높은 요소의 순위가 0 (내림차순) zrank test-name a (integer) 0 zrank test-name b (integer) 2 zrank test-name c (integer) 1 # 가중치가 가장 높은 요소 삭제 zpopmax {SortedSet name} # 가중치가 가장 낮은 요소 삭제 zpopmin {SortedSet name} ✅참고링크 :\n**명령어로 패턴과 일치하는 키들을 찾는 방법 **https://codingdog.tistory.com/entry/redis-keys-명령어로-패턴과-일치하는-키들을-찾아봅시다\n**Redis 공식문서 명령어 **https://redis.io/commands/\n","permalink":"https://jaylab.link/redis-cli-and-data-structure/","summary":"\u0026lsquo;Commands used in Redis and summary of \u0026lsquo;data structure\u0026rsquo;","title":"[DB] redis-cli 명령어 종류 및 자료구조 정리"},{"content":"📌테스트 환경 : Mac OS M1 Redis 클러스터 생성 설정값 ‘클러스터 모드’ 비활성화\nAWS ElastiCache for Redis 생성시 default로 잡히는 포트는 6379\n그러나 보안을 위해 default 포트는 피해서 6480으로 설정\n암호화는 ‘저장 중 암호화, 전송 중 암호화’ 모두 활성화하여 생성\nRedis 접근 Redis 접근 조건 로컬에서 AWS Redis에 cli로 접근하기 위해서는 “로컬 → Redis”로 다이렉트로 접근할 수 없음 외부 접근 비허용. 같은 VPC 내부의 인스턴스를 통해서만 접근할 수 있음\n이러한 특징 때문에 로컬에서 Redis에 접속하기 위해서는 Bastion Host가 필요\nBastion Host Port Forwarding 로컬에서 Redis에 접근하기 위해 “Local - Bastion Host - AWS ElastiCache for Redis”로 연결된 포트포워딩 ssh -L 6480:{Redis Endpoint}:{Redis Port} {Bastion Host 접속 유저명}@{Bastion Host IP} -p {Bastion Host 접근할 수 있도록 보안그룹에 인바운드 오픈한 TCP port} # 만약 pem키를 통해서 Bastion Host에 접근해야한다면 # ssh -i \u0026#34;{pem 키 경로/키 이름.pem}\u0026#34; -L 6480:{Redis Endpoint}:{Redis Port} {Bastion Host 접속 유저명}@{Bastion Host IP} -p {Bastion Host 접근할 수 있도록 보안그룹에 인바운드 오픈한 TCP port} # ex) ssh -L 6480:master.dev-redis.xxxxxx.euc1.cache.amazonaws.com:6480 {IPA ID}@10.14.21.85 -p 70 포트포워딩이 정상적으로 연결 되었는지 확인 # 방법 1 lsof -i4 -P | grep -i \u0026#34;listen\u0026#34; | grep 6480 # 연결 정상일 경우 결과 ssh 26646 user 29u IPv4 0xb9b737e263825d0b 0t0 TCP localhost:6480 (LISTEN) # 방법 2 nc -zv 127.0.0.1 6480 # 연결 정상일 경우 결과 Connection to 127.0.0.1 port 6480 [tcp/sun-sr-http] succeeded! redis-cli 사용하여 Redis DB 접속 redis-cli 설치 : brew install redis\nredis-cli 명령어를 통해 AWS ElastiCache for Redis 접속\nredis-cli -c -h {redis-endpoint or host} --tls -p {port} # 옵션 -c: AWS ElastiCache for Redis의 클러스터가 \u0026#39;클러스터 모드 비활성화\u0026#39;인 경우 생략 가능 # 옵션 -h: {redis-endpoint or host}부분이 localhost면 생략 가능 # 옵션 -p: 기본값 포트 6379면 생략 가능 # 옵션 --tls: AWS ElastiCache for Redis의 클러스터의 \u0026#39;전송 중 암호화\u0026#39;설정이 활성화일때 사용 필요(TLS 프로토콜을 사용하여 통신하겠다는 옵션) # ex) redis-cli -h localhost --tls -n 1 -p 6480 # 로컬에서 redis까지 포트포워딩된 6480포트를 통해 # TLS 프로토콜을 사용하여 터널링하겠다는 명령. # 옵션 \u0026#34;-n 1\u0026#34; : 그 중에서도 1번 데이터베이스에 접속한다는 옵션(명시하지 않을경우 default로 0번 데이터베이스에 접속) AWS ElastiCache for Redis 1번 DB 접속 성공\n※ --tls 옵션에 대하여 위의 과정은 Mac M1 환경에서 진행되었다. 이 환경에서 brew를 통해 설치한 redis-cli 패키지의 경우 기본적으로 --tls옵션이 지원되었으나, 기본적으로 SSL/TLS는 Redis에서 버전 6부터 컴파일 시 활성화해야 하는 선택적 기능으로 지원된다고한다. 따라서, redis-cli 사용시 tls 지원을 받기 위해서는 다음 링크에서 방법을 찾을 수 있다.\n참고 링크 1. (AWS 공식문서) https://docs.aws.amazon.com/ko_kr/AmazonElastiCache/latest/red-ug/GettingStarted.ConnectToCacheNode.html\n참고링크 2. (Redis 공식문서) https://redis.io/docs/management/security/acl/\n번외. Redis GUI Tools 위의 과정 중에는 포트포워딩 이후 redis-cli로 Redis DB에 접속하는 방법을 살펴보았다. 이러한 CLI 접속 방법 외에도 GUI 툴을 통해 Redis DB를 확인 및 관리할 수도 있다.\n아래 두 툴들은 모두 무료. Medis2의 경우 엄청 심플한 구성으로 다루기 쉽다는 장점이 있지만, 무료버전으로는 클러스터를 하나만 연결 가능하다. 업무 간 두개 이상의 클러스터 연결이 필요한 상황도 있어서 Medis2에서 Redis-insight로 바꿈.\nMedis2 Redis-insight ","permalink":"https://jaylab.link/redis-create-access/","summary":"\u003cdiv class=\"callout callout-yellow\"\u003e\u003cspan class=\"callout-icon\"\u003e📌\u003c/span\u003e\u003cdiv class=\"callout-body\"\u003e테스트 환경 : Mac OS M1\u003c/div\u003e\n\u003c/div\u003e\n\n\u003ch3 id=\"redis-클러스터-생성-설정값\"\u003eRedis 클러스터 생성 설정값\u003c/h3\u003e\n\u003chr\u003e\n\u003cul\u003e\n\u003cli\u003e\n\u003cp\u003e‘클러스터 모드’ \u003cstrong\u003e비활성화\u003c/strong\u003e\u003c/p\u003e\n\u003c/li\u003e\n\u003cli\u003e\n\u003cp\u003eAWS ElastiCache for Redis 생성시 default로 잡히는 포트는 \u003ccode\u003e6379\u003c/code\u003e\u003c/p\u003e\n\u003c/li\u003e\n\u003cli\u003e\n\u003cp\u003e그러나 보안을 위해 default 포트는 피해서 \u003ccode\u003e6480\u003c/code\u003e으로 설정\u003c/p\u003e\n\u003c/li\u003e\n\u003cli\u003e\n\u003cp\u003e암호화는 ‘저장 중 암호화, 전송 중 암호화’ 모두 활성화하여 생성\u003c/p\u003e\n\u003c/li\u003e\n\u003c/ul\u003e\n\u003ch3 id=\"redis-접근\"\u003eRedis 접근\u003c/h3\u003e\n\u003chr\u003e\n\u003ch4 id=\"redis-접근-조건\"\u003e\u003cstrong\u003eRedis 접근 조건\u003c/strong\u003e\u003c/h4\u003e\n\u003cul\u003e\n\u003cli\u003e\n\u003cp\u003e로컬에서 AWS Redis에 cli로 접근하기 위해서는 “로컬 → Redis”로 다이렉트로 접근할 수 없음\n외부 접근 비허용. 같은 VPC 내부의 인스턴스를 통해서만 접근할 수 있음\u003c/p\u003e\n\u003c/li\u003e\n\u003cli\u003e\n\u003cp\u003e이러한 특징 때문에 로컬에서 Redis에 접속하기 위해서는 Bastion Host가 필요\u003c/p\u003e","title":"[AWS] ElastiCache for Redis 생성 및 접속"},{"content":"앞서 Redis의 기본적인 개념에 대해 알아보았고, 이번에는 AWS에서 지원하고있는 ElastiCache for Redis의 구성요소에 대해 정리해보았다.\nElastiCache for Redis 구성 요소 1. 노드 작은 구성 요소\n다른 노드와 독립전으로 존재할 수도, 일부와 관련되어 존재할 수도 있음\nRAM의 크기가 고정된 청크\n클러스터를 생성할 때 선택한 엔진 및 버전의 인스턴스 실행 \u0026gt; Redis 클러스터 생성시 선택하는 인스턴스 유형(노드 유형)가 각 노드마다 할당되는 spec\n필요한 경우 스케일 업, 스케일 다운 가능\n클러스터 내 모든 노드는 인스턴스 유형 동일\n각 노드 별 고유한 DNS 이름 및 포트 존재\n연결된 메모리 양이 각각 다름\n2. 샤드(노드 그룹) : 1~6개의 노드 그룹\n한 샤드에 노드가 두 개 이상인 경우 \u0026lsquo;복제\u0026rsquo;를 지원. 즉, 하나의 노드만 읽기/쓰기, 나머지는 읽기 전용 복제 노드\n3. 클러스터(샤드 그룹) : 샤드 하나 이상이 포함된 \u0026ldquo;Logical Group\u0026rdquo;\n클러스터모드 비활성시의 단일 샤드 구성도 어쨌든 하나의 logical group으로써 \u0026lsquo;Redis 클러스터\u0026rsquo;\nElastiCache for Redis 클러스터 모드 ➤ 클러스터 모드 비활성화 클러스터 모드 비활성시 항상 \u0026lsquo;단일 샤드\u0026rsquo;\n수요가 변경되면 확장 또는 축소 가능 \u0026gt; 클러스터를 다른 인스턴스 유형의 노드로 이동\n읽기 작업이 일부 데이터에 집약적인 경우에 활용하여 적절한 수의 노드로 읽기 분산\n➤ 클러스터 모드 활성화 샤드가 두 개 이상(클러스터 모드 활성화)일 때는 데이터가 샤드 간에 파티셔닝된다. 샤딩 기법이라고도 표현하며, 거대한 데이터를 재구조화하여 여러 샤드에 분산하는 것을 의미.\n클러스터 모드 활성화시 최대 500개의 샤드로 데이터 분할 가능\n조건 : 엔진 버전 5.0.6 이상(이하인 경우 클러스터당 250 개)\n예를들어 \u0026ldquo;샤드 1 \u0026amp; 복제본 5(총 6개)\u0026rdquo; * 83개 샤드 + 샤드 1개 \u0026amp; 복제본 0 = 499\n증가를 수용할 수 있는 IP 주소가 충분한지 확인 필요\n한도 증가 요청 가능\n📌내결함성을 위한 구성 : 최소 두개의 노드 \u0026amp; 다중 AZ 활성화 ex) ElastiCache for Redis 클러스터 생성 시 구성 모습 Redis 클러스터 생성시 구성 값 클러스터모드 비활성화 (단일 샤드 구성)\n다중 AZ 활성화 (서브넷 그룹에 eu-central-1a, eu-central-1c 두 개의 서브넷을 할당했던 상태. 따라서, 노드 생성시 두 AZ에 랜덤하게 배정)\n복제본 수 : 2** **(복제본(Replica, 읽기 전용 노드) 2개 + 원본(Master, 읽기/쓰기) 1개 = 총 3개의 노드가 구성)\n","permalink":"https://jaylab.link/redis-component/","summary":"Components of \u0026lsquo;ElastiCache for Redis’","title":"[AWS] ElastiCache for Redis 구성요소"},{"content":"Redis란? Redis : REmote DIctionary Server\nKey-Value 구조의 비정형 데이터를 저장하고 관리하기 위한 오픈소스 기반의 NoSQL DBMS\n사용에 따라 데이터베이스로 사용될 수도 있고, Message Queue, Shared Memory 용도로 사용될 수 있음. 주로 Cache Server로 사용.\nRedis의 특징과 장점 Key, Value 구조이기 때문에 쿼리를 사용할 필요가 없음\n고정되지 않은 스키마를 가지므로 키마다 원하는 내용만 저장 가능 In-Memory 데이터베이스\n매우 빠름\n서버 재시작 시 모든 데이터 유실\n복제 기능이 있지만 사람의 실수에는 복원 불가\n백업이 필요할 수 있음 (persist option 지원)\n다양한 자료구조를 제공\nString, List, Hash, Set, Sorted Set, Stream 등\n사용 용이, 개발 편의성 증대\napplication 단에서 필요한 로직을 DB 단에서 줄일 수 있음\nlist형 데이터 입력/삭제가 MySQL에 비해 10배 빠름\nSingle Thread로 동작\n한 번에 여러 클라이언트의 요청을 수행할 수 없음 때문에 여러 요청이 동시에 돌아가지는 않지만 빠르게 처리해 동시성을 보장하려고 노력하고 경쟁상태를 방지(원자적 연산 수행)\n한 클라이언트가 오래 걸리는 작업을 요청하면 장애가 발생할 수 있음\n여러 대의 서버 구성 가능 (Master-Slave)\nDatabase, Cache, Message broker로 사용됨\nCache로 사용 시 무조건 TTL로 사용. 시간 중요 읽기 성능 증대를 위한 서버 측 리플리케이션을 지원\n쓰기 성능 증대를 위한 클라이언트 측 샤딩 지원\n데이터베이스를 두고 Redis를 사용하는 이유는 데이터베이스는 물리디스크에 직접 write 작업이 이루어지기 때문에 데이터가 손실되지 않는 장점이 있지만, 사용자가 많아질 수록 디스크의 부하가 발생하게 된다. 이에 따라 한번 읽어온 데이터를 인 메모리 DB인 Redis에 캐싱하여 데이터 조회 작업을 빠르게 하여 서비스의 속도를 향상시켜줄 수 있다.\n❓인 메모리 : 컴퓨터의 주 기억장치(메인메모리)인 RAM에 데이터를 올려서 사용하는 방법. 하드디스크(SSD, HDD)보다 수백배 이상 빠름. but, 휘발성 메모리 Redis의 주 활용 방식 여러 서버에서 같은 데이터를 공유가 필요할 때\n한대에서만 필요하면 전역변수 쓰면 되지 않나? → ❌ Redis 자체가 Atomic하기때문에 Thread safe 하고 Single Thread 이기 때문에 Race Condition 발생 가능성이 낮음 인증 토큰 등을 저장 (Strings or hash)\nRanking 보드로 사용 ( Sorted Set )\n유저 API Limit\nJob QUeue\n","permalink":"https://jaylab.link/redis-basic-concepts/","summary":"Basic concepts about \u0026lsquo;Redis’","title":"[DB] Redis란 무엇인가? 기본적인 개념"},{"content":"인텔리제이로 작업하는 경우 정적 리소스(HTML, CSS, JS)의 수정이 자동으로 반영되지 않는 현상이 존재. 브라우저 새로고침, 쿠키 삭제로도 반영되지 않는 경우 다음과 같이 해결할 수 있다.\n[Edit Configurations] [Server] 탭\nUpdate 액션시(On ‘Update’ action)\n프레임 비활성화 시(On frame deactivation)\n두 항목 모두 (Update classes and resources)로 변경\nUpdate classes and resources 항목이 보이지 않는 경우 Deployment 탭 \u0026gt; “Deploy at the server startup” 항목을 보면, {프로젝트명}:war로 되어있는 것을 볼 수 있다.\n이 war는 Remove 후 새로운 Artifact 추가 \u0026gt; {프로젝트명}:war exploded 선택 Application context: / 로 되어있는지 확인. 안되어있으면 / 로 수정\n추가된 Artifact에 대해 Edit Artifact\nOutput directory: {프로젝트경로}/src/main/webapp로 수정\n","permalink":"https://jaylab.link/intellij-resource-build/","summary":"Automatically reflect static resource changes","title":"[IntelliJ] 정적 리소스 변경사항 자동 반영"},{"content":"HotSwap 활성화 상단 메뉴바 IntelliJ IDEA \u0026gt; Preferences(or Settings) Build, Execution, Deployment \u0026gt; Complier \u0026gt; Build project automatically 활성화 Build, Execution, Deployment \u0026gt; Debugger \u0026gt; HostSwap \u0026gt; Reload classes after compilation \u0026gt; Always 로 체크되어있는지 확인 shift 두 번 연속(혹은 Win: Ctrl + Shift + A / Mac : Shift + Cmd + A)\n“Registry” 검색 → compiler.automake.allow.when.app.running 활성화 📌 2021.2 버전부터는 Registry가 아닌 Advanced Settings에서 compiler.automake.allow.when.app.running 활성화 가능\nRun / Debug Configurations 설정 다시 IntelliJ 우측 상단 [Edit Configurations]\n[On ‘Update’ action], [On frame deactivation] 두 항목을 Update classes and resources로 변경\n","permalink":"https://jaylab.link/injellij-hotswap/","summary":"Apply changes to java files without restarting the server","title":"[IntelliJ] java 파일 서버 재실행 없이 변경사항 적용 (HotSwap)"},{"content":"SSL 서버인증서\nHTTP : 암호화되지 않은상태\nHTTPS : 암호화된 상태\n브라우저로부터 어떤 웹서버로 보내진 암호화된 데이터는 사이트 운영자가 가진 비밀키를 이용해 원래 데이터로 되돌릴 수 있다. 그러나 운영자 외 악의를 가진 누군가가 운영자로 위장한 (피싱) 사이트를 구축해 데이터를 훔칠 가능성도 있다.\n이 경우, 위장하지 않았음을 보장해주는 회사들을 **‘인증기관’**이라 부르며, 이 보장을 증명하는 것이 SSL 서버 인증서(인증서)\n브라우저 주소창에서 열쇠아이콘을 클릭하면 볼수 있는 것이 SSL 서버 인증서\nRoute53의 두 가지 기능\n도메인 이름 등록 → 상위 도메인을 관리하느 조직에 자신의 도메인을 요청해서 등록하는 것\njpub.co.kr 이라는 도메인 이름을 관리하려면 .co.kr을 관리하는 조직에 요청을 보내야하나다. 요청이 통과되면 이 도메인을 얻을 수 있다. 이러한 도메인 획득과 관리를 대행하는 회사는 전 세계에 몇 개 정도 존재하며, Route 53을 이용하면 AWS가 도메인 취득을 대행해준다.\nRoute53을 이용하지 않고 EC2 인스턴스를 이용해 오픈 소스 DNS 서버를 구축할 수도 있지만, Route53이 안정성이나 비용 성능, 그리고 로드밸런서와의 연계 등 다양한 장점이 있다.\nDNS 서버\n항목 퍼블릭 DNS 프라이빗 DNS 도메인 이름 취득 필요 불필요 관리하는 IP 주소 퍼블릭 IP 주소 프라이빗 IP 주소 SSL 서버 인증서는 인증 대상 레벨에 따라 크게 3단계 유형으로 나뉜다.\n도메인 검증완료(DV) 인증서 : 도메인 이름이 올바름을 보증\n조직 검증 완료(OV) 인증서 : 도메인 이름의 올바름과 함께, 도메인을 관리하는 회사명을 보증\n확장 인증(EV) 인증서 : 도메인을 관리하는 회사의 실제 존재 여부와 신뢰성까지 보증\n아래쪽 인증서일수록 더 엄격하게 조사, 비용 상승.\nAWS는 SSL 서버 인증서를 발행할 수 있는 인증기관의 기능을 가지며 AWS에서 구축하는 사이트에서 이용하는 SSL서버 서버 인증서를 무료로 발행할 수 있으며, 도메인 검증완료(DV) 인증서에만 대응한다\n즉, Route53이 ‘DNS서버’의 기능을 한다는 것은, 퍼블릭 DNS든 프라이빗DNS든 어쨌든 DNS의 기능을 한다는 것. → 특정 도메인 이름에 대해 IP를 관리, 반환해주는 시스템이라는 것.\n퍼블릭 DNS\n호스팅 영역: 여러 “도메인 이름” 목록 페이지(ex: aws-intro-sample.com)\n특정 “도메인 이름”의 상세페이지: 레코드 이름 목록 페이지 ex: bastion(레코드유형A/퍼블릭IP), www(레코드유형A/생성한로드밸런서선택)\n프라이빗 DNS\n호스팅 영역: 여러 “도메인 이름” 목록 페이지(ex: home)\n특정 “도메인 이름”의 상세페이지: 레코드 이름 목록 페이지 ex: bastion(레코드유형A/프라이빗IP), web01(레코드유형A/프라이빗IP), web02(레코드유형A/프라이빗IP), db(레코드유형CNAME/엔드포인트)\n","permalink":"https://jaylab.link/aws-dns/","summary":"\u003cp\u003eSSL 서버인증서\u003c/p\u003e\n\u003cp\u003eHTTP : 암호화되지 않은상태\u003c/p\u003e\n\u003cp\u003eHTTPS : 암호화된 상태\u003c/p\u003e\n\u003cp\u003e브라우저로부터 어떤 웹서버로 보내진 암호화된 데이터는 사이트 운영자가 가진 비밀키를 이용해 원래 데이터로 되돌릴 수 있다. 그러나 운영자 외 악의를 가진 누군가가 운영자로 위장한 (피싱) 사이트를 구축해 데이터를 훔칠 가능성도 있다.\u003c/p\u003e\n\u003cp\u003e이 경우, 위장하지 않았음을 보장해주는 회사들을 **‘인증기관’**이라 부르며, 이 보장을 증명하는 것이 \u003cstrong\u003eSSL 서버 인증서(인증서)\u003c/strong\u003e\u003c/p\u003e\n\u003cp\u003e브라우저 주소창에서 열쇠아이콘을 클릭하면 볼수 있는 것이 \u003cstrong\u003eSSL 서버 인증서\u003c/strong\u003e\u003c/p\u003e\n\u003cp\u003eRoute53의 두 가지 기능\u003c/p\u003e","title":"Custom Domain and Create DNS"},{"content":"VPC\nvpc cidr : 10.0.0.0/16\n서브넷\nSubnet 간단 개념 개념 : VPC의 IP 주소 범위를 나누는 단위.\n역할분리 - 외부에 공개하느 리소스 여부를 구별\n기기 분리 - AWS 안에서의 물리적인 이중화(다중화)를 수행\nsubnet cidr :\n10.0.0.0/20(public1)\n10.0.16.0/20(public2)\n10.0.64.0/20(private1)\n10.0.80.0/20(private2)\nInternet Gateway\ninternet gateway 생성 후 vpc에 연결\nNAT Gateway\nNAT Gateway : private subnet 2개에 각각 대응해줄 2개의 NAT Gateway 생성 후 “퍼블릭 서브넷”에 1개씩 연결 ( + eip 할당 )\n라우팅 테이블\n라우팅 테이블 생성\n퍼블릭 1개 생성\n라우팅 : 0.0.0.0/0 에 대해 인터넷 게이트웨이 연결\n서브넷 연결 : 퍼블릭서브넷 2개 모두 연결\n프라이빗 2개 생성\n라우팅 : 각각 모두 0.0.0.0/0 에 대해 NAT 게이트웨이 연결\n서브넷연결 : 각각 프라이빗서브넷 1개씩 분담\n**라우팅테이블 부연 설명** 서브넷과 서브넷, 또는 서브넷과 각 게이트웨이가 통신할 수 있는 경로가 필요할 때, 서브넷 사이의 통신 경로를 설정하여 어떤 서브넷 안의 리소스가 해당 서브넷 밖의 리소스에 접근할 수 있도록하는 기능\n여러 서브넷이 같은 라우팅 테이블을 공유할 수 있다. 즉, 라우팅테이블:서브넷 = 1:n\n책에서는 퍼블릭 라우팅 테이블을 1개만 생성, 퍼블릭 서브넷 1, 2에 연결 프라이빗 라우팅 테이블 2개를 생성, 프라이빗 서브넷 1, 2에 각각 연결\n라우팅테이블 \u0026gt; 라우팅 정보에 입력된 대상의 순서는 상관없다. “설정 정보의 순서는 중요하지 않습니다. AWS 라우팅 테이블에서는 목적지 CIDR 블록과 일치하는 가장 구체적인 경로를 찾는 기능을 제공합니다”\n💡라우터 일반적인 네트워크 설계에서는 라우팅 테이블에 수행하는 설정을 라우터(router)라는 기기에 수행한다. 그러나 AWS 관리 콘솔에서는 라우터를 명시적으로 생성할 필요가 없다. 라우팅 테이블을 생성하면 라우터에 해당하는 것도 자동 생성된다. 보안그룹\n2개의 보안그룹 생성\n점프서버(bastion host)용\n인바운드만 설정 \u0026gt; SSH(22포트)에 대해서 Anywhere-IPv4(0.0.0.0/0) 로드밸런서 용\n인바운드만 설정 \u0026gt; HTTP(80포트), HTTPS(443)에 대해서 Anywhere-IPv4(0.0.0.0/0) NACL ↔ 보안그룹 차이 💡 보안그룹 : 리소스(EC2, 로드밸런서, RDS 등)에 대해 설정 가능 네트워크 ACL : 네트워크에 대한 설정. 즉, 해당 서브넷에 포함되는 리소스 모두에 적용 (책 필자의 경우 설정 누락은 인프라스트럭처 설계서 또는 CloudFormation을 이용한 IaC 시스템을 활용해서 방지하고, 네트워크 ACL은 잘 이용하지 않는다고한다.) 💡점프서버(Bastion Host)\n외부에서 리소스로의 접속은 제한된 관리자만 수행할 수 있도록 해야하는데, 이러한 설정을 모든 리소스에 수행하기란 매우 어려우며 설정이 누락될 가능성도 높다. 따라서, 모든 리소스에 접속할 수 있는 입구인 점프서버를 준비하고, 해당 서버를 경유해야만 각 리소스에 접속할 수 있는 방식을 많이 사용한다. 목적한 리소스로의 통호 이외의 용도는 없다. 따라서 성능이 낮아도 되며 OS도 특별히 가리지 않는다.\n키페어 생성 (pem키)\n점프 서버 생성(Bastion Host)\n사용 AMI : Amazon Linux 2 AMI(HVM) - Kernel 5.10, SSD VolumeType\n키페어 설정 : 위에서 생성한 키페어 선택\n네트워크 설정 :\n퍼블릭 서브넷 01 연결 (이 설정을 통해 퍼블릭 서브넷 01 구역 안에 이 EC2가 생성된다)\n퍼블릭 IP 자동할당 활성화\n기존 보안그룹 2개 연결\ndefault\n위에서 생성한 sg-bastion\n점프 서버(Bastion Host) 접속 확인\n# 생성 및 다운로드된 pem키 위치에서 ssh -i {pem키 이름} ec2-user@{Bastion Bublic IP} # 근데 pem키의 권한이 너무 열려있다고 에러를 뱉는다.(보호된 키 X) -\u0026gt; # 다른사람이 액세스할 수 없도록 수정이 필요하다고 한다 chmod 400 {pem키 이름} # 다시 ssh -i {pem키 이름} ec2-user@{Bastion Bublic IP} # 접속 성공 # 로그아웃 방법은 logout 혹은 exit 혹은 Ctrl+ D 📌 키페어를 깃허브와 같은 저장소에 업로드되는 것을 방지하는 git-secrets라는 도구가 제공된다는 점 참고() 나중에 마이크로 서비스 구축에 흥미가 있다면 AWS가 제공하는 ‘서버리스 컴퓨팅’이라는 유스케이스(https://aws.amazon.com/ko/serverless/) 읽어보기 웹 서버 생성\n사용 AMI : Amazon Linux 2 AMI(HVM) - Kernel 5.10, SSD VolumeType\n키페어 설정 : 위에서 생성한 키페어 선택\n네트워크 설정 :\n프라이빗 서브넷 01 설정\n퍼블릭 IP 자동할당 비활성화\n기존 보안그룹 1개 연결 : default\n※ 위와 동일하게 1개 더 생성 → 서브넷만 프라이빗 서브넷 02로 설정\n📌점프서버 보안그룹 : 기본 + SSH 웹서버 보안그룹 : 기본 로드밸런서 생성\n인터넷 게이트웨이로의 경로가 있는 서브넷을 지정해야한다는 점 주의(여기서는 퍼블릭 서브넷) 서브넷을 잘못지정하면 외부로부터 웹 서버에 도달할 수 없다.\n로그밸런서 개념정리\n주요역할 3가지\n요청 분산\nSSL(Secure Socket Layer) 처리 HTTPS \u0026gt; 외부로부터의 요청패킷을 복호화하여 웹서버에 전달하고 외부로의 응답 패킷은 암호화하여 정보를 보호하는 기능 웹서버에서 암복호화를 모두 수행하면 부하에 따른 비효율\n부정요청 대응\n📌요청라우팅(Request Routing) : 외부에 공개된 프로토콜과 포트번호의 조합으로 이루어진 요청에서 → 내부의 웹서버가 받는 프로토콜과 포트 번호로 변환하는 기능 하나의 로드밸런서에는 여러 대상 그룹을 지정 가능\nALB 설정 항목 2가지\n로드밸런서: 어떤 프로토콜(HTTP, HTTPS)을 이용할 것인지와 같은 설정. 주로 인터넷에서 로드 밸런서로 접근할 때와 관련한 설정을 수행. 클라이언트로부터 처리를 받는 “리스너” 기능\nNetwork mapping : 퍼블릭 서브넷 2개 연결\n보안그룹 2개 연결 : default, (위에서 생성한) sj-sg-elb\nLinstener and routing : HTTP, 80 port 설정 후 “Create target group” 클릭\n대상 그룹\nChoose a target type : Instances\nProtocol : HTTP, Port : 3000(”외부에서 인터넷으로 접근할때는 위에서 설정한 HTTP프로토콜과 포트(80)으로 수신을 수행하지만 로드밸런서에서 복호화해 웹서버에 접근할 때는 HTTP프로토콜, 3000번 포트를 이용한다”)\nRegister targets : 웹서버 2개 선택 후 “include as pending below”\n로드밸런서(대상그룹 생성 이 후 이어서 진행)\nLinstener and routing \u0026gt; Default action에 위에서 생성한 대상그룹 추가 HTTP 작동 확인하기\nssh web01, ssh web02 접속 후 각 서버에서 다음 과정 실행\nindex.html파일 생성 \u0026lt;html\u0026gt;\u0026lt;body\u0026gt;hello world\u0026lt;/body\u0026gt;\u0026lt;html\u0026gt; Amazon Linux에 기본설치되어있는 파이썬으로 HTTP 서버 기동 python -m SimpleHTTPServer 3000 index.html파일이 존재하는디렉터리에서 파이썬을 이용해 HTTPS 서버를 기동. 올바르게 기동되면 로드밸런서로부터 헬스체크를 수행할 때마다 정기적인 연결에 대한 액세스 로그가 계속해서 표시된다.\n여기까지 진행했더라도 로드밸런서는 곧바로 요청을 라우팅하지 않음. 수 차례 서버에 헬스체크 수행 후, 요청들이 모두 성공한 뒤에야 비로소 요청을 라우팅하기 시작한다.\nEC2 대시보드 \u0026gt; Targets groups \u0026gt; Detail page \u0026gt; [Targets] Tab \u0026gt; Health status에서 healthy 상태 확인\n이것으로 로드밸런서에 접근할 수 있는 준비 완료.\n브라우저를 이용해 작동 확인하기 : 생성한 로드밸런서를 클릭하여 DNS 이름 확인 → 해당 도메인 이름으로 브라우저에서 접근하여 확인 → 정상 확인 완료\n로그밸런서의 작동을 확인했으니 이제 서버에서 실행중인 파이썬 프로그램을 Ctrl+D를 눌러 종료 더는 필요없는 index.html도 삭제\n데이터베이스 생성\n파라미터 그룹 생성 데이터베이스의 성능 개선, 사용 현황 파악, 기능 추가 등을 수행. 주로 데이터베이스 엔진 고유의 설정을 수행. 사용하는 언어나 데이터베이스 튜닝을 설정할 수 있다.\n옵션 그룹 생성 AWS를 이용한 데이터베이스 모니터링에 관한 설정 등 주로 RDS고유의 설정을 수행\n서브넷 그룹 생성 데이터베이스 서버를 여러 개의 가용 영역에 분산 배치할 때 이용되는 설정. 데이터베이스 서버도 여러 대의 서버를 제공함으로써 신뢰성이나 성능을 높일 수 있다.\n가용영역 : VPC에서 사용되는 가용영역 선택\n서브넷 : 각 가용영역에 해당되는 private 서브넷 선택\n※ 데이터베이스는 기본적으로는 보안을 위해 퍼블릭 서브넷을 서브넷 그룹에 추가하지 않는다.\n데이터베이스 생성 데이터베이스 생성방식 표준생성 엔진옵션 MySQL 템플릿 프리티어 마스터 사용자 이름 admin 마스터 암호 Masterpw (특수문자 들어가게끔 설정했더니 최종적으로 Access denied for user. 연결 실패. 문자열로만 설정 필요) 최초 데이터베이스 이름 (공란) 데이터베이스 작동 확인하기\n웹 서버에서 접속을 진행한다. 웹 서버에 연결했다면 MtSQL 명령어를 이용해 데이터베이스에 연결한다. MySQL 명령어는 Amazon Linux 2에 기본으로 포함되지 않으므로 별도 설치해야한다.\n먼저 웹 서버에 SSH를 이용해 연결. (ssh web01)\nsudo yum -y install mysql\nRDS 대시보드에서 데이터베이스 클릭 → 생성한 인스턴스 → 아래쪽의 ‘연결 \u0026amp; 보안’ 탭 → 엔드포인트 및 포트 확인\n웹 서버에서 아래 명령어 입력 후 패스워드까지 입력했을때 “mysqld is alive”가 출력되면 MySQL 데이터베이스에 연결확인 완료 mysqladmin ping -u admin -p -h {데이터베이스의 엔드포인트}\n스토리지 생성\nEC2 인스턴스는 SSD나 하드 디스크에 해당하는 스토리지를 제공한다.\nEBS(Amazon Elastic Block Store)라는 이름으로 제공되는 서비스다. 다만 EBS를 스토리지로 이용하면 다음과 같은 문제가 발생한다.\nEC2 인스턴스의 OS 자체도 관리해야한다(보안 대응 등)\n예측하지 못한 장애 발생 시 대응이 필요한 경우에 대한 준비를 해야한다.\nEC2 인스턴스를 사용하지 못할 가능성이 있다(SLA(서비스품질보증)에 의하면 1년에 약 5분 정도 서비스가 정지될 가능성이 있다)\n이런 문제를 해결하고자 AWS에서 제공되는 서비스가 S3\nS3는 내결함성과 비용 측면에서 EBS보다 압도적으로 우수\n단점으로는 S3는 어디까지나 외부 스토리지 서비스라는 한계가 있기때문에 EBS와 달리 C드라이브나 D드라이브 등으로 활용할 수 없으며 리눅스나 맥에서의 파일시스템처럼 마운트할 수도 없다.\nS3와 VPC의 관계\n지금까지 다뤄본 서비스는 모두 VPC 안에 생성. 그러나 S3는 VPC 밖에 생성. 따라서 S3에 접근하는 방법으로는 다음과 같은 두가지를 고려할 수 있다.\n인터넷으로부터 직접 접근\nVPC로부터 접근 이 경우 S3 버킷에 대한 접근 권한이 필요하다. 이 권한은 일반적으로 IAM의 역할(role)이라는 개념을 이용해서 적용한다. 즉, S3 버킷에 접근할 수 있는 “정책을 가진 역할”을 생성하고 그 역할을 EC2에 적용한다.\n💡기술적으로는 S3에 접근할 수 있는 “정책을 가진 사용자”를 만든 뒤 해당 사용자의 권한과 액세스 키를 조합해 S3 버킷에 접근하는 것도 가능하다. 단, “역할”을 이용하는 방법을 보통 더 많이 이용한다. 버킷 생성시 이 버킷의 퍼블릭 액세스 차단 설정에서** **‘모든 퍼블릭 액세스 차단’(기본값)을 설정한다. 따라서 의도하지 않은 열람이나 변경을 방지. 이렇게 버킷을 우선 생성하고, 이후 용도에 맞춰 필요한 접근 권한만 부여하는 게 좋다.\n”EC2가 S3에 접근할 수 있는 권한”을 가진 역할 생성하기\nAmazonS3FullAccess 정책을 사용해서 만드는 방법이 있지만 ‘특정 S3 버킷에만 연결할 수 있는 정책 설정’을 사용해본다\n기본적으로 AamazonS3FullAccess 정책은 다음과 같다.\n{ \u0026#34;Version\u0026#34;: \u0026#34;2012-10-17\u0026#34;, \u0026#34;Statement\u0026#34;: [ { \u0026#34;Effect\u0026#34;: \u0026#34;Allow\u0026#34;, \u0026#34;Action\u0026#34;: [ \u0026#34;s3:*\u0026#34;, \u0026#34;s3-object-lambda:*\u0026#34; ], \u0026#34;Resource\u0026#34;: \u0026#34;*\u0026#34; } ] } 이것을 활용하여 원하는 버킷을 특정할 수 있다 ▼\n{ \u0026#34;Version\u0026#34;: \u0026#34;2012-10-17\u0026#34;, \u0026#34;Statement\u0026#34;: [ { \u0026#34;Effect\u0026#34;: \u0026#34;Allow\u0026#34;, \u0026#34;Action\u0026#34;: [ \u0026#34;s3:*\u0026#34;, \u0026#34;s3-object-lambda:*\u0026#34; ], \u0026#34;Resource\u0026#34;: [ \u0026#34;arn:aws:s3:::(bucketname)\u0026#34;, \u0026#34;arn:aws:s3:::(bucketname)/*\u0026#34; ] } ] } 위의 코드를 통해 ‘특정 S3 버킷에만 연결할 수 있는 정책’을 생성한 다음, 이 정책을 통해 역할을 생성한다\n신뢰할 수 있는 엔터티 유형 : AWS 서비스 / EC2\n권한 추가 \u0026gt; 권한 정책 : 위에서 생성한 정책 선택\nEC2에 역할 적용하기\nEC2 콘솔 \u0026gt; 웹서버 인스턴스 클릭 \u0026gt; 작업 \u0026gt; 보안 \u0026gt; IAM 역할 수정 \u0026gt; 위에서 생성한 역할 부여\n작동 확인하기\nAmazon Linux 2는 S3와 같은 AWS 리소스를 조작하는 명령어를 제공하므로, 이를 이용해 S3 버킷에 파일을 업로드 해본다.\n웹서버 접속 → 홈디렉토리에 test.txt 파일 생성\naws s3 cp test.txt s3://{버킷이름}\nS3에 업로드 완료\n","permalink":"https://jaylab.link/aws-basic-infrastructure/","summary":"\u003cp\u003e\u003cstrong\u003eVPC\u003c/strong\u003e\u003c/p\u003e\n\u003cp\u003evpc cidr : 10.0.0.0/16\u003c/p\u003e\n\u003cp\u003e\u003cstrong\u003e서브넷\u003c/strong\u003e\u003c/p\u003e\n\u003cdetails\u003e\n\u003csummary\u003eSubnet 간단 개념\u003c/summary\u003e\n\u003chr\u003e\n\u003cul\u003e\n\u003cli\u003e\n\u003cp\u003e개념 : VPC의 IP 주소 범위를 나누는 단위.\u003c/p\u003e\n\u003c/li\u003e\n\u003cli\u003e\n\u003cp\u003e역할분리 - 외부에 공개하느 리소스 여부를 구별\u003c/p\u003e\n\u003c/li\u003e\n\u003cli\u003e\n\u003cp\u003e기기 분리 - AWS 안에서의 물리적인 이중화(다중화)를 수행\u003c/p\u003e\n\u003c/li\u003e\n\u003c/ul\u003e\n\u003c/details\u003e\n\u003cp\u003esubnet cidr :\u003c/p\u003e\n\u003col\u003e\n\u003cli\u003e\n\u003cp\u003e10.0.0.0/20(public1)\u003c/p\u003e\n\u003c/li\u003e\n\u003cli\u003e\n\u003cp\u003e10.0.16.0/20(public2)\u003c/p\u003e\n\u003c/li\u003e\n\u003cli\u003e\n\u003cp\u003e10.0.64.0/20(private1)\u003c/p\u003e\n\u003c/li\u003e\n\u003cli\u003e\n\u003cp\u003e10.0.80.0/20(private2)\u003c/p\u003e\n\u003c/li\u003e\n\u003c/ol\u003e\n\u003cp\u003e\u003cstrong\u003eInternet Gateway\u003c/strong\u003e\u003c/p\u003e\n\u003cp\u003einternet gateway 생성 후 vpc에 연결\u003c/p\u003e\n\u003cp\u003e\u003cstrong\u003eNAT Gateway\u003c/strong\u003e\u003c/p\u003e\n\u003cp\u003eNAT Gateway : private subnet 2개에 각각 대응해줄 2개의 NAT Gateway 생성 후\n“퍼블릭 서브넷”에 1개씩 연결\n( + eip 할당 )\u003c/p\u003e\n\u003cp\u003e\u003cstrong\u003e라우팅 테이블\u003c/strong\u003e\u003c/p\u003e","title":"Using AWS to Build Infrastructure"},{"content":"Tomcat 서버의 애플리케이션 배포는 웹 애플리케이션 운영에서 가장 기본적인 영역이면서도, 실제로는 많은 개발자들이 정확한 동작 원리를 놓치고 있는 부분이다. 최근 고객사 운영 서버의 server.xml 설정을 분석하던 중, \u0026lt;Context\u0026gt; 태그와 \u0026lt;Host\u0026gt;의 appBase 속성이 혼재된 구성에서 WAR 파일 배포 과정에 대한 오해를 발견했다.\n기존의 배포 설정 운영 중인 서버의 server.xml 설정:\n\u0026lt;Host name=\u0026#34;localhost\u0026#34; appBase=\u0026#34;/home/manager/eps/webapps\u0026#34; unpackWARs=\u0026#34;true\u0026#34; autoDeploy=\u0026#34;true\u0026#34;\u0026gt; \u0026lt;Context path=\u0026#34;\u0026#34; docBase=\u0026#34;/home/manager/eps/webapps/legacy\u0026#34; reloadable=\u0026#34;false\u0026#34; /\u0026gt; \u0026lt;Context path=\u0026#34;/v5\u0026#34; docBase=\u0026#34;/home/manager/eps/webapps/ui5\u0026#34; reloadable=\u0026#34;false\u0026#34; /\u0026gt; \u0026lt;!-- ... AccessLogValve 설정 ... --\u0026gt; \u0026lt;/Host\u0026gt; 이 설정에서 명시적인 \u0026lt;Context\u0026gt; 배포와 appBase를 통한 자동 배포가 공존하고 있었음.. 그런데 여기서 문제가 발생. 새로운 버전의 ui5.war 파일을 배포하려 할 때, 기존 관리자는 다음과 같은 절차를 따르고 있었다.\n로컬에서 ui5.war 파일의 압축을 수동으로 해제..\n압축 해제된 파일들을 서버의 /home/manager/eps/webapps/ui5 디렉터리에 수동 복사..\nTomcat 서버 재시작\n잘못됐다. \u0026lt;Context\u0026gt; 태그를 사용할 때 개발자가 직접 WAR 파일의 압축 푼 결과물을 목적지 경로로 복사하고있었다. (sh script를 통해서..)\nTomcat의 실제 배포 메커니즘 분석 사실을 확인하기 파악하기 위해 Tomcat 배포 메커니즘을 단계별로 추적해보았다.\n1. 서버 시작 시 동작 순서 Tomcat 서버가 시작될 때의 동작 순서를 확인한 결과:\n# Tomcat 로그에서 확인되는 초기화 순서 INFO [main] org.apache.catalina.startup.HostConfig.deployApps Deploying web application directory [/home/manager/eps/webapps/ui5] INFO [main] org.apache.catalina.startup.HostConfig.deployApps Deployment of web application directory [/home/manager/eps/webapps/ui5] has finished INFO [main] org.apache.catalina.startup.HostConfig.deployWARs Deploying web application archive [/home/manager/eps/webapps/shop.war] INFO [main] org.apache.catalina.startup.HostConfig.deployWARs Deployment of web application archive [/home/manager/eps/webapps/shop.war] has finished 이 로그를 통해 확인할 수 있는 것은 Tomcat이 먼저 appBase 디렉터리를 스캔하여 WAR 파일을 자동으로 압축 해제한 후, \u0026lt;Context\u0026gt; 설정을 처리한다는 점이다.\n2. WAR 파일 자동 처리 과정 appBase 디렉터리에 WAR 파일이 배치됐을 때의 실제 동작을 확인해보았다:\n# WAR 파일 배치 전 $ ls -la /home/manager/eps/webapps/ total 12 drwxr-xr-x 3 manager manager 4096 Jan 15 10:00 . drwxr-xr-x 3 manager manager 4096 Jan 15 09:30 .. drwxr-xr-x 8 manager manager 4096 Jan 15 10:00 legacy # ui5.war 파일 업로드 $ cp ui5.war /home/manager/eps/webapps/ # Tomcat이 자동으로 처리한 결과 $ ls -la /home/manager/eps/webapps/ total 15240 drwxr-xr-x 4 manager manager 4096 Jan 15 10:01 . drwxr-xr-x 3 manager manager 4096 Jan 15 09:30 .. drwxr-xr-x 8 manager manager 4096 Jan 15 10:00 legacy drwxr-xr-x 6 manager manager 4096 Jan 15 10:01 ui5 -rw-r--r-- 1 manager manager 15595520 Jan 15 10:01 ui5.war 확인 완료. Tomcat이 ui5.war 파일을 감지하자마자 자동으로 ui5 디렉터리를 생성하고 압축을 해제함. 수동 작업이 전혀 필요하지 않았다.\n배포 방식 간 우선순위와 경로 매핑 실제 요청 처리 과정을 통해 두 배포 방식의 상호작용을 확인했다.\n우선순위 테스트 다음과 같은 상황에서 요청 처리 방식을 테스트했다:\n\u0026lt;Context path=\u0026#34;/v5\u0026#34; docBase=\u0026#34;/home/manager/eps/webapps/ui5\u0026#34; /\u0026gt; 동시에 appBase에 v5.war 파일도 존재하는 상황에서\n# 요청 테스트 $ curl -I http://localhost:8080/v5/ # 결과: Context로 지정된 ui5 디렉터리에서 응답 # appBase의 v5.war는 완전히 무시됨 Tomcat 로그를 통해 확인한 결과, \u0026lt;Context\u0026gt; 태그로 명시된 매핑이 절대적 우선순위를 가지며, appBase의 동일한 이름의 WAR 파일은 아예 처리되지 않는다는 것을 확인했다.\n동적 배포 동작 확인 \u0026lt;Context\u0026gt;에 정의되지 않은 새로운 애플리케이션의 동적 배포 과정:\n# shop.war 파일 배치 $ cp shop.war /home/manager/eps/webapps/ # 실시간 로그 확인 $ tail -f /opt/tomcat/logs/catalina.out INFO [ContainerBackgroundProcessor[StandardEngine[Catalina]]] org.apache.catalina.startup.HostConfig.deployWAR Deploying web application archive [/home/manager/eps/webapps/shop.war] INFO [ContainerBackgroundProcessor[StandardEngine[Catalina]]] org.apache.catalina.startup.HostConfig.deployWAR Deployment of web application archive [/home/manager/eps/webapps/shop.war] has finished in [1,234] ms # 자동 생성된 디렉터리 확인 $ ls -la /home/manager/eps/webapps/shop/ total 28 drwxr-xr-x 5 manager manager 4096 Jan 15 10:05 . drwxr-xr-x 5 manager manager 4096 Jan 15 10:05 .. drwxr-xr-x 3 manager manager 4096 Jan 15 10:05 META-INF drwxr-xr-x 2 manager manager 4096 Jan 15 10:05 WEB-INF -rw-r--r-- 1 manager manager 150 Jan 15 10:05 index.html Tomcat이 WAR 파일을 감지하고 자동으로 압축 해제하여 서비스를 시작하는 전 과정이 거의 1~2초 내에 완료됐다.\n올바른 배포 프로세스 분석 결과를 바탕으로 두 배포 방식의 올바른 사용법을 정리했다.\nContext 기반 배포의 실제 절차 기존 잘못된 절차\n# 잘못된 방법 (수동 압축 해제) unzip ui5.war -d /tmp/ui5_temp cp -r /tmp/ui5_temp/* /home/manager/eps/webapps/ui5/ systemctl restart tomcat 올바른 절차\n# 올바른 방법 (Tomcat 자동 처리 활용) cp ui5.war /home/manager/eps/webapps/ # Tomcat이 자동으로 ui5 디렉터리 생성 및 압축 해제 # Context 설정이 해당 디렉터리를 참조 배포 방식별 특성 비교 실제 운영 환경에서 확인한 각 방식의 특성:\n구분 appBase 자동 배포 Context 명시적 배포 배포 대상 Context에 정의되지 않은 모든 경로 server.xml에 명시된 고정 경로 경로 결정 WAR 파일명 기반 (shop.war → /shop) path 속성으로 임의 지정 가능 재시작 필요성 불필요 (Hot Deploy) 경우에 따라 필요 운영 편의성 매우 높음 중간 (설정 관리 필요) 서비스 안정성 중간 높음 (명시적 제어) 적용 결과 \u0026amp; 운영 개선 고객사의 다양한 서비스 모듈에 대한 신규 젠킨스 파이프라인 구축 작업 간 위와 같은 개선사항을 확인하게 되었다. 사실 현실은 더 처참한 방식으로 배포되고있는 모듈도 있었다.. 결과적으로 배포 프로세스 수정 후 다음과 같은 개선 효과를 확인했다.\n배포 시간 단축\n기존 수동 방식: 약 5-10분 ⇒ 개선된 방식: 약 30초 운영 오류 제거\n수동 압축 해제 과정에서 발생하던 파일 권한 문제 해결\n배포 과정의 휴먼 에러 요소 최소화\n","permalink":"https://jaylab.link/tomcat-improve-cicd-pipeline/","summary":"\u003cp\u003eTomcat 서버의 애플리케이션 배포는 웹 애플리케이션 운영에서 가장 기본적인 영역이면서도, 실제로는 많은 개발자들이 정확한 동작 원리를 놓치고 있는 부분이다. 최근 고객사 운영 서버의 \u003ccode\u003eserver.xml\u003c/code\u003e 설정을 분석하던 중, \u003ccode\u003e\u0026lt;Context\u0026gt;\u003c/code\u003e 태그와 \u003ccode\u003e\u0026lt;Host\u0026gt;\u003c/code\u003e의 \u003ccode\u003eappBase\u003c/code\u003e 속성이 혼재된 구성에서 WAR 파일 배포 과정에 대한 오해를 발견했다.\u003c/p\u003e\n\u003ch3 id=\"기존의-배포-설정\"\u003e기존의 배포 설정\u003c/h3\u003e\n\u003cp\u003e운영 중인 서버의 \u003ccode\u003eserver.xml\u003c/code\u003e 설정:\u003c/p\u003e\n\u003cdiv class=\"highlight\"\u003e\u003cpre tabindex=\"0\" class=\"chroma\"\u003e\u003ccode class=\"language-xml\" data-lang=\"xml\"\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"nt\"\u003e\u0026lt;Host\u003c/span\u003e \u003cspan class=\"na\"\u003ename=\u003c/span\u003e\u003cspan class=\"s\"\u003e\u0026#34;localhost\u0026#34;\u003c/span\u003e \u003cspan class=\"na\"\u003eappBase=\u003c/span\u003e\u003cspan class=\"s\"\u003e\u0026#34;/home/manager/eps/webapps\u0026#34;\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e      \u003cspan class=\"na\"\u003eunpackWARs=\u003c/span\u003e\u003cspan class=\"s\"\u003e\u0026#34;true\u0026#34;\u003c/span\u003e \u003cspan class=\"na\"\u003eautoDeploy=\u003c/span\u003e\u003cspan class=\"s\"\u003e\u0026#34;true\u0026#34;\u003c/span\u003e\u003cspan class=\"nt\"\u003e\u0026gt;\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e  \u003cspan class=\"nt\"\u003e\u0026lt;Context\u003c/span\u003e \u003cspan class=\"na\"\u003epath=\u003c/span\u003e\u003cspan class=\"s\"\u003e\u0026#34;\u0026#34;\u003c/span\u003e \u003cspan class=\"na\"\u003edocBase=\u003c/span\u003e\u003cspan class=\"s\"\u003e\u0026#34;/home/manager/eps/webapps/legacy\u0026#34;\u003c/span\u003e \u003cspan class=\"na\"\u003ereloadable=\u003c/span\u003e\u003cspan class=\"s\"\u003e\u0026#34;false\u0026#34;\u003c/span\u003e \u003cspan class=\"nt\"\u003e/\u0026gt;\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e  \u003cspan class=\"nt\"\u003e\u0026lt;Context\u003c/span\u003e \u003cspan class=\"na\"\u003epath=\u003c/span\u003e\u003cspan class=\"s\"\u003e\u0026#34;/v5\u0026#34;\u003c/span\u003e \u003cspan class=\"na\"\u003edocBase=\u003c/span\u003e\u003cspan class=\"s\"\u003e\u0026#34;/home/manager/eps/webapps/ui5\u0026#34;\u003c/span\u003e \u003cspan class=\"na\"\u003ereloadable=\u003c/span\u003e\u003cspan class=\"s\"\u003e\u0026#34;false\u0026#34;\u003c/span\u003e \u003cspan class=\"nt\"\u003e/\u0026gt;\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e  \u003cspan class=\"c\"\u003e\u0026lt;!-- ... AccessLogValve 설정 ... --\u0026gt;\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"nt\"\u003e\u0026lt;/Host\u0026gt;\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003cp\u003e이 설정에서 명시적인 \u003ccode\u003e\u0026lt;Context\u0026gt;\u003c/code\u003e 배포와 \u003ccode\u003eappBase\u003c/code\u003e를 통한 자동 배포가 공존하고 있었음.. 그런데 여기서 문제가 발생. 새로운 버전의 \u003ccode\u003eui5.war\u003c/code\u003e 파일을 배포하려 할 때, 기존 관리자는 다음과 같은 절차를 따르고 있었다.\u003c/p\u003e","title":"Tomcat 배포 방식의 올바른 이해와 구조적 분석"},{"content":"Argparse argparse를 이용하면 파이썬 스크립트 실행시 사용자 임의의 Argument값을 넘겨서 활용할 수 있다.\n유용한 argparse에 대하여 간단한 예제 import argparse # 인자값을 받을 수 있는 인스턴스 생성 parser = argparse.ArgumentParser(description=\u0026#39;사용법 테스트입니다.\u0026#39;) # 입력받을 인자값 등록 parser.add_argument(\u0026#34;-testArg\u0026#34;, dest=\u0026#34;testDest\u0026#34;, help=\u0026#34;python argparse.py -testArg={입력값} 형태로 입력하세요~\u0026#34;) # 입력받은 인자값을 args에 저장 (type: namespace) args = parser.parse_args() if args.testDest: print(\u0026#34;결과값: \u0026#34;, args.testDest) **사용예시 : ** » python argparseTest.py --help usage: argparseTest.py [-h] [-testArg TESTDEST] 사용법 테스트입니다. optional arguments: -h, --help show this help message and exit -testArg TESTDEST python argparse.py -testArg={입력값} 형태로 입력하세요~ 결과값 : » python argparseTest.py -testArg=123 결과값: 123` ","permalink":"https://jaylab.link/python-argparse/","summary":"\u003ch2 id=\"argparse\"\u003eArgparse\u003c/h2\u003e\n\u003cp\u003eargparse를 이용하면 파이썬 스크립트 실행시 사용자 임의의 Argument값을 넘겨서 활용할 수 있다.\u003c/p\u003e\n\u003ch3 id=\"유용한-argparse에-대하여-간단한-예제\"\u003e\u003cstrong\u003e유용한 argparse에 대하여 간단한 예제\u003c/strong\u003e\u003c/h3\u003e\n\u003cdiv class=\"highlight\"\u003e\u003cpre tabindex=\"0\" class=\"chroma\"\u003e\u003ccode class=\"language-python\" data-lang=\"python\"\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"kn\"\u003eimport\u003c/span\u003e \u003cspan class=\"nn\"\u003eargparse\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"c1\"\u003e# 인자값을 받을 수 있는 인스턴스 생성\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"n\"\u003eparser\u003c/span\u003e \u003cspan class=\"o\"\u003e=\u003c/span\u003e \u003cspan class=\"n\"\u003eargparse\u003c/span\u003e\u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"n\"\u003eArgumentParser\u003c/span\u003e\u003cspan class=\"p\"\u003e(\u003c/span\u003e\u003cspan class=\"n\"\u003edescription\u003c/span\u003e\u003cspan class=\"o\"\u003e=\u003c/span\u003e\u003cspan class=\"s1\"\u003e\u0026#39;사용법 테스트입니다.\u0026#39;\u003c/span\u003e\u003cspan class=\"p\"\u003e)\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"c1\"\u003e# 입력받을 인자값 등록\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"n\"\u003eparser\u003c/span\u003e\u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"n\"\u003eadd_argument\u003c/span\u003e\u003cspan class=\"p\"\u003e(\u003c/span\u003e\u003cspan class=\"s2\"\u003e\u0026#34;-testArg\u0026#34;\u003c/span\u003e\u003cspan class=\"p\"\u003e,\u003c/span\u003e \u003cspan class=\"n\"\u003edest\u003c/span\u003e\u003cspan class=\"o\"\u003e=\u003c/span\u003e\u003cspan class=\"s2\"\u003e\u0026#34;testDest\u0026#34;\u003c/span\u003e\u003cspan class=\"p\"\u003e,\u003c/span\u003e \u003cspan class=\"n\"\u003ehelp\u003c/span\u003e\u003cspan class=\"o\"\u003e=\u003c/span\u003e\u003cspan class=\"s2\"\u003e\u0026#34;python argparse.py -testArg=\u003c/span\u003e\u003cspan class=\"si\"\u003e{입력값}\u003c/span\u003e\u003cspan class=\"s2\"\u003e 형태로 입력하세요~\u0026#34;\u003c/span\u003e\u003cspan class=\"p\"\u003e)\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"c1\"\u003e# 입력받은 인자값을 args에 저장 (type: namespace)\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"n\"\u003eargs\u003c/span\u003e \u003cspan class=\"o\"\u003e=\u003c/span\u003e \u003cspan class=\"n\"\u003eparser\u003c/span\u003e\u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"n\"\u003eparse_args\u003c/span\u003e\u003cspan class=\"p\"\u003e()\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"k\"\u003eif\u003c/span\u003e \u003cspan class=\"n\"\u003eargs\u003c/span\u003e\u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"n\"\u003etestDest\u003c/span\u003e\u003cspan class=\"p\"\u003e:\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e    \u003cspan class=\"nb\"\u003eprint\u003c/span\u003e\u003cspan class=\"p\"\u003e(\u003c/span\u003e\u003cspan class=\"s2\"\u003e\u0026#34;결과값: \u0026#34;\u003c/span\u003e\u003cspan class=\"p\"\u003e,\u003c/span\u003e \u003cspan class=\"n\"\u003eargs\u003c/span\u003e\u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"n\"\u003etestDest\u003c/span\u003e\u003cspan class=\"p\"\u003e)\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003ch3 id=\"사용예시--\"\u003e**사용예시 : **\u003c/h3\u003e\n\u003cdiv class=\"highlight\"\u003e\u003cpre tabindex=\"0\" class=\"chroma\"\u003e\u003ccode class=\"language-shell\" data-lang=\"shell\"\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e» python argparseTest.py --help\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003eusage: argparseTest.py \u003cspan class=\"o\"\u003e[\u003c/span\u003e-h\u003cspan class=\"o\"\u003e]\u003c/span\u003e \u003cspan class=\"o\"\u003e[\u003c/span\u003e-testArg TESTDEST\u003cspan class=\"o\"\u003e]\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e사용법 테스트입니다.\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003eoptional arguments:\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e  -h, --help         show this \u003cspan class=\"nb\"\u003ehelp\u003c/span\u003e message and \u003cspan class=\"nb\"\u003eexit\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e  -testArg TESTDEST  python argparse.py -testArg\u003cspan class=\"o\"\u003e={\u003c/span\u003e입력값\u003cspan class=\"o\"\u003e}\u003c/span\u003e 형태로 입력하세요~\n\u003c/span\u003e\u003c/span\u003e\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003ch3 id=\"결과값-\"\u003e결과값 :\u003c/h3\u003e\n\u003cdiv class=\"highlight\"\u003e\u003cpre tabindex=\"0\" class=\"chroma\"\u003e\u003ccode class=\"language-shell\" data-lang=\"shell\"\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e» python argparseTest.py -testArg\u003cspan class=\"o\"\u003e=\u003c/span\u003e\u003cspan class=\"m\"\u003e123\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e결과값:  123\u003cspan class=\"sb\"\u003e`\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e","title":"Try Python's argparse"},{"content":"venv(가상환경) 사용 이유 파이썬을 다루다보면 다양한 패키지들을 사용하게 된다.\n그렇게, pip3 install {패키지명}을 무분별하게 사용하다보면 로컬에 있는 다른 프로젝트 간 패키지 버전 충돌 혹은 패키지(라이브러리)간 의존성 문제가 발생하기도 한다.\n이를 해결하기 위해 ‘venv’라는 파이썬 모듈로 패키지 환경 분리가 가능하며, 작업환경이 바뀌더라도 필요한 패키지들을 동일한 버전으로 설치해 작업할 수도 있다.\n현재 설치되어있는 파이썬 패키지 목록 확인 pip list 가상환경 생성 python -m venv myenv venv 라는 모듈을 사용하겠다는 의미\nmyenv는 사용자 임의의 가상환경 별칭\n가상환경 활성화 source myenv/bin/activate 이렇게 가상환경을 활성화한 후, 다시 패키지 목록을 확인(pip list)해보면 해당 환경에는 아무런 패키지가 없는 깨끗한 환경임을 확인할 수 있다.\nVS Code에서 가상환경 사용하기 위의 명령으로인해 가상환경이 “활성화”되긴 했지만, 이것은 터미널에서만 준비가 된 상태. (VS Code 터미널 맨 앞에 가상환경 명이 표시되더라도 터미널 명령이 아닌 VS Code 자체의 인터프리터는 아직 가상환경의 파이썬버전 및 인터프리터가 동일하지 않은 상태)\n즉, VS Code도 가상환경과 동일한 환경(버전 및 패키지)으로 설정 되어야한다.\n방법 VS Code 하단의 파이썬 버전정보 클릭 → 생성한 가상환경 이름 클릭\n혹은 ctrl + shift + p(win) / cmd + shift + p(mac) 눌렀을때 표시되는 input box에서 “python select Interpreter” 라고 입력 후 위에서 생성한 가상환경이름이 포함된 인터프리터 선택\nVS Code 환경, 가상환경 동기화 완료\n이것은 앞서 설명한 가상환경 활성화 작업과의 순서는 상관없지만 별개로 꼭 필요한 작업.\n하단의 버전정보를 클릭하여 설정하는 방법과는 별개로 가상환경 생성 직후 뜨는 알림창을 통해 바로 가상환경과 동일하게 설정할 수 있다.\n파이썬 패키지 설치 pip3 install {패키지명} 가상환경에서 설치된 패키지 목록 ‘백업’ 혹은 ‘다른 가상환경에서 재사용’ 방법 pip3 freeze : 설치된 패키지 명과 버전정보가 아래와 같이 출력 위에서 출력된 결과를 파일에 담기 pip3 freeze \u0026gt; requirements.txt\n이렇게 생성된 파일(requirements.txt)을 통해 다른 가상환경에서 동일한 패키지 목록 다운로드 pip3 install -r requirements.txt\n가상환경 비활성화 deactivate 가상환경 삭제 생성된 가상환경 폴더(여기서는 myenv)를 지워주기만 하면 끝. 우클릭하여 delete 혹은 rm -rf myenv\n처음에 가상환경을 설치할 때는 아무 패키지가 없는 ‘백지’와 같다. 가상환경을 생성할 때 로컬에 있는 패키지 정보를 그대로 가상환경까지 가져가고 싶을때의 방법은 다음과 같다\n시스템 내 패키지 목록과 동일한 가상환경 생성 python3 -m venv myenv --system-site-packages 이후 pip3 list 를 하게되면 가상환경에 설치된 패키지 목록이 로컬시스템 패키지와 동일한 것을 확인할 수 있다.\n가상환경에만 따로 설치되어있는 패키지목록을 확인하는 방법 pip3 list --local ","permalink":"https://jaylab.link/python-venv/","summary":"\u003ch3 id=\"venv가상환경-사용-이유\"\u003evenv(가상환경) 사용 이유\u003c/h3\u003e\n\u003cp\u003e파이썬을 다루다보면 다양한 패키지들을 사용하게 된다.\u003c/p\u003e\n\u003cp\u003e그렇게, \u003ccode\u003epip3 install {패키지명}\u003c/code\u003e을 무분별하게 사용하다보면 로컬에 있는 다른 프로젝트 간 패키지 버전 충돌 혹은 패키지(라이브러리)간 의존성 문제가 발생하기도 한다.\u003c/p\u003e\n\u003cp\u003e이를 해결하기 위해 ‘\u003ccode\u003evenv\u003c/code\u003e’라는 파이썬 모듈로 패키지 환경 분리가 가능하며, 작업환경이 바뀌더라도 필요한 패키지들을 동일한 버전으로 설치해 작업할 수도 있다.\u003c/p\u003e\n\u003ch3 id=\"현재-설치되어있는-파이썬-패키지-목록-확인\"\u003e현재 설치되어있는 파이썬 패키지 목록 확인\u003c/h3\u003e\n\u003cdiv class=\"highlight\"\u003e\u003cpre tabindex=\"0\" class=\"chroma\"\u003e\u003ccode class=\"language-shell\" data-lang=\"shell\"\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003epip list\n\u003c/span\u003e\u003c/span\u003e\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003ch3 id=\"가상환경-생성\"\u003e\u003cstrong\u003e가상환경 생성\u003c/strong\u003e\u003c/h3\u003e\n\u003cdiv class=\"highlight\"\u003e\u003cpre tabindex=\"0\" class=\"chroma\"\u003e\u003ccode class=\"language-shell\" data-lang=\"shell\"\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003epython -m venv myenv\n\u003c/span\u003e\u003c/span\u003e\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003cp\u003e\u003ccode\u003evenv\u003c/code\u003e 라는 \u003ccode\u003e모듈\u003c/code\u003e을 사용하겠다는 의미\u003c/p\u003e\n\u003cp\u003e\u003ccode\u003emyenv\u003c/code\u003e는 사용자 임의의 가상환경 별칭\u003c/p\u003e","title":"How to separate Python's development environment (venv)"},{"content":"❓Injetion : 보안 취약점을 이용하여, 임의의 SQL 문을 주입하여 데이터베이스가 비정상적인 동작을 하도록 조작하는 행위 ** 📜 Content**\n🗡️ Injection 공격 예시 /* 어떠한 POST방식 API에 아래와 같은 인자값을 넘기는 것이 정상적인 방법일 때*/ { testKey : \u0026#34;testValue\u0026#34; } /* 악의적인 사용자는 다음과 같이 인자값을 설정하여 쿼리를 조작할 수 있다. */ { testKey : \u0026#34;testValue\u0026#39; and \u0026#39;해킹\u0026#39;=\u0026#39;해킹\u0026#34; } 위 인자값이 SQL Query에 적용될 때 정상적인 인자값을 넘겨받은 쿼리\nselect * from testTable where key = \u0026#39;testValue\u0026#39; Injection 공격을 시도된 쿼리\nselect * from testTable where key = \u0026#39;testValue\u0026#39; and \u0026#39;해킹\u0026#39;=\u0026#39;해킹\u0026#39; 🛡️ python에 대한 injection 방어 코드 실무에서 사용된 DB 호출 코드 방식 (Injection 대응 전) import psycopg2 from psycopg2.extras import RealDictCursor # DB 연결 준비 conn = psycopg2.connect(\u0026#34;dbname={} user={} host={} password={}\u0026#34;.format(db_name, db_user, db_host, db_pass)) # DB 호출함수 선언 def fetch(conn, query): result = [] cursor = conn.cursor(cursor_factory=RealDictCursor) cursor.execute(query) # SQL 실행 # DB조회 결과값 담기 raw = cursor.fetchall() for line in raw: result.append(line) return result # SQL 쿼리 선언 query = \u0026#34;select pjt_no, item_type from testTable \u0026#34; query += \u0026#34;where pjt_no = \u0026#39;\u0026#34; + pjt_no + \u0026#34;\u0026#39; \u0026#34; query += \u0026#34;and item_type = \u0026#39;\u0026#34; + type_cd2 + \u0026#34;\u0026#39;\u0026#34; # DB 호출함수 실행 result = fetch(conn=conn, query=query) Injection 대응 (Type 1) import psycopg2 from psycopg2.extras import RealDictCursor # DB 연결 준비 conn = psycopg2.connect(\u0026#34;dbname={} user={} host={} password={}\u0026#34;.format(db_name, db_user, db_host, db_pass)) # DB 호출함수 선언 def fetch(conn, query, sql_arg): result = [] cursor = conn.cursor(cursor_factory=RealDictCursor) cursor.execute(query, sql_arg) # SQL 실행 # DB조회 결과값 담기 raw = cursor.fetchall() for line in raw: result.append(line) return result # SQL 쿼리 선언 query = \u0026#34;select pjt_no, item_type from testTable \u0026#34; query += \u0026#34;where pjt_no = %(pjt_no)s\u0026#34; query += \u0026#34;and item_type = %(item_type)s\u0026#34; sql_arg = { \u0026#39;pjt_no\u0026#39; : pjt_no, \u0026#39;item_type\u0026#39; : item_type, } # DB 호출함수 실행 result = fetch(conn=conn, query=query, sql_arg=sql_arg) Injection 대응 (Type 2) (딕셔너리 타입(X) → tuple 타입으로 넘기는 것도 가능)\nimport psycopg2 from psycopg2.extras import RealDictCursor # DB 연결 준비 conn = psycopg2.connect(\u0026#34;dbname={} user={} host={} password={}\u0026#34;.format(db_name, db_user, db_host, db_pass)) # DB 호출함수 선언 def fetch(conn, query, sql_arg): result = [] cursor = conn.cursor(cursor_factory=RealDictCursor) cursor.execute(query, sql_arg) # SQL 실행 # DB조회 결과값 담기 raw = cursor.fetchall() for line in raw: result.append(line) return result # SQL 쿼리 선언 query = \u0026#34;select pjt_no, item_type from testTable \u0026#34; query += \u0026#34;where pjt_no = %s\u0026#34; query += \u0026#34;and item_type = %s\u0026#34; sql_arg = (pjt_no, item_type) # DB 호출함수 실행 result = fetch(conn=conn, query=query, sql_arg=sql_arg)슈슈 🎯 이슈 1. 정수형에 대한 Injection 대응 파이썬에서 %s는 문자형을 받을때 사용하고 정수는 %d로 받는다.\n그럼 DB에서 컬럼이 int 타입일 때의 Injection 대응코드는?? 혹시 %(변수명)d 혹은 %d\n확인 결과 : 똑같이 문자형으로 넘겨주면 된다.\nDB에서 컬럼이 int 타입이라도 인자값은 문자형으로 만들어서 넘긴다. 즉, Argument 부터 정수여도 문자열로 형변환해서 넘긴다.\n그것을 쿼리에서 %()s로 받아주면 된다.\ntest = 1 # 인자값 query = \u0026#39;select * from testTable where key = %(str_test)s\u0026#39; query_paramDict = { \u0026#39;str_test\u0026#39; : str(test) } cursor.execute(query, query_paramDict) 2. 테이블에 null값이 되도록 해야할 경우 update나 insert시 전달받은 인자값이 공백(‘‘)일 때, 테이블에는 부득이하게 null을 넣고자할 경우\n# \u0026#39;test\u0026#39;라는 변수에 전달받은 인자값이 공백일 경우 query = \u0026#39;select * from testTable where key = %(str_test)s\u0026#39; query_paramDict = { \u0026#39;str_test\u0026#39; : str(test) } if test is \u0026#39;\u0026#39;: query = query.replace(\u0026#39;%(str_test)s\u0026#39;, \u0026#39;null\u0026#39;) cursor.execute(query, query_paramDict) ※ 쿼리문 기준으로 보면 select * from testTable where key = null ( ~ ~ where key = 'null' ❌** )**\n","permalink":"https://jaylab.link/python-injection/","summary":"\u003cdiv class=\"callout callout-yellow\"\u003e\u003cspan class=\"callout-icon\"\u003e❓\u003c/span\u003e\u003cdiv class=\"callout-body\"\u003e\u003cem\u003e\u003cstrong\u003eInjetion : 보안 취약점을 이용하여, 임의의 SQL 문을 주입하여 데이터베이스가 비정상적인 동작을 하도록 조작하는 행위\u003c/strong\u003e\u003c/em\u003e\u003c/div\u003e\n\u003c/div\u003e\n\n\u003cp\u003e** 📜 Content**\u003c/p\u003e\n\u003ch3 id=\"injection-공격-예시\"\u003e🗡️ Injection 공격 예시\u003c/h3\u003e\n\u003chr\u003e\n\u003cdiv class=\"highlight\"\u003e\u003cpre tabindex=\"0\" class=\"chroma\"\u003e\u003ccode class=\"language-json\" data-lang=\"json\"\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"err\"\u003e/*\u003c/span\u003e \u003cspan class=\"err\"\u003e어떠한\u003c/span\u003e \u003cspan class=\"err\"\u003ePOST방식\u003c/span\u003e \u003cspan class=\"err\"\u003eAPI에\u003c/span\u003e \n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"err\"\u003e아래와\u003c/span\u003e \u003cspan class=\"err\"\u003e같은\u003c/span\u003e \u003cspan class=\"err\"\u003e인자값을\u003c/span\u003e \u003cspan class=\"err\"\u003e넘기는\u003c/span\u003e \u003cspan class=\"err\"\u003e것이\u003c/span\u003e \u003cspan class=\"err\"\u003e정상적인\u003c/span\u003e \u003cspan class=\"err\"\u003e방법일\u003c/span\u003e \u003cspan class=\"err\"\u003e때*/\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"p\"\u003e{\u003c/span\u003e \u003cspan class=\"err\"\u003etestKey\u003c/span\u003e \u003cspan class=\"err\"\u003e:\u003c/span\u003e \u003cspan class=\"nt\"\u003e\u0026#34;testValue\u0026#34;\u003c/span\u003e \u003cspan class=\"p\"\u003e}\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"err\"\u003e/*\u003c/span\u003e \u003cspan class=\"err\"\u003e악의적인\u003c/span\u003e \u003cspan class=\"err\"\u003e사용자는\u003c/span\u003e \u003cspan class=\"err\"\u003e다음과\u003c/span\u003e \u003cspan class=\"err\"\u003e같이\u003c/span\u003e \u003cspan class=\"err\"\u003e인자값을\u003c/span\u003e \u003cspan class=\"err\"\u003e설정하여\u003c/span\u003e \u003cspan class=\"err\"\u003e쿼리를\u003c/span\u003e \u003cspan class=\"err\"\u003e조작할\u003c/span\u003e \u003cspan class=\"err\"\u003e수\u003c/span\u003e \u003cspan class=\"err\"\u003e있다.\u003c/span\u003e \u003cspan class=\"err\"\u003e*/\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"p\"\u003e{\u003c/span\u003e \u003cspan class=\"err\"\u003etestKey\u003c/span\u003e \u003cspan class=\"err\"\u003e:\u003c/span\u003e \u003cspan class=\"nt\"\u003e\u0026#34;testValue\u0026#39; and \u0026#39;해킹\u0026#39;=\u0026#39;해킹\u0026#34;\u003c/span\u003e \u003cspan class=\"p\"\u003e}\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003cp\u003e위 인자값이 SQL Query에 적용될 때 정상적인 인자값을 넘겨받은 쿼리\u003c/p\u003e\n\u003cdiv class=\"highlight\"\u003e\u003cpre tabindex=\"0\" class=\"chroma\"\u003e\u003ccode class=\"language-sql\" data-lang=\"sql\"\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"k\"\u003eselect\u003c/span\u003e\u003cspan class=\"w\"\u003e \u003c/span\u003e\u003cspan class=\"o\"\u003e*\u003c/span\u003e\u003cspan class=\"w\"\u003e \u003c/span\u003e\u003cspan class=\"k\"\u003efrom\u003c/span\u003e\u003cspan class=\"w\"\u003e \u003c/span\u003e\u003cspan class=\"n\"\u003etestTable\u003c/span\u003e\u003cspan class=\"w\"\u003e \u003c/span\u003e\u003cspan class=\"k\"\u003ewhere\u003c/span\u003e\u003cspan class=\"w\"\u003e \u003c/span\u003e\u003cspan class=\"k\"\u003ekey\u003c/span\u003e\u003cspan class=\"w\"\u003e \u003c/span\u003e\u003cspan class=\"o\"\u003e=\u003c/span\u003e\u003cspan class=\"w\"\u003e \u003c/span\u003e\u003cspan class=\"s1\"\u003e\u0026#39;testValue\u0026#39;\u003c/span\u003e\u003cspan class=\"w\"\u003e\n\u003c/span\u003e\u003c/span\u003e\u003c/span\u003e\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003cp\u003eInjection 공격을 시도된 쿼리\u003c/p\u003e\n\u003cdiv class=\"highlight\"\u003e\u003cpre tabindex=\"0\" class=\"chroma\"\u003e\u003ccode class=\"language-sql\" data-lang=\"sql\"\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"k\"\u003eselect\u003c/span\u003e\u003cspan class=\"w\"\u003e \u003c/span\u003e\u003cspan class=\"o\"\u003e*\u003c/span\u003e\u003cspan class=\"w\"\u003e \u003c/span\u003e\u003cspan class=\"k\"\u003efrom\u003c/span\u003e\u003cspan class=\"w\"\u003e \u003c/span\u003e\u003cspan class=\"n\"\u003etestTable\u003c/span\u003e\u003cspan class=\"w\"\u003e \u003c/span\u003e\u003cspan class=\"k\"\u003ewhere\u003c/span\u003e\u003cspan class=\"w\"\u003e \u003c/span\u003e\u003cspan class=\"k\"\u003ekey\u003c/span\u003e\u003cspan class=\"w\"\u003e \u003c/span\u003e\u003cspan class=\"o\"\u003e=\u003c/span\u003e\u003cspan class=\"w\"\u003e \u003c/span\u003e\u003cspan class=\"s1\"\u003e\u0026#39;testValue\u0026#39;\u003c/span\u003e\u003cspan class=\"w\"\u003e \u003c/span\u003e\u003cspan class=\"k\"\u003eand\u003c/span\u003e\u003cspan class=\"w\"\u003e \u003c/span\u003e\u003cspan class=\"s1\"\u003e\u0026#39;해킹\u0026#39;\u003c/span\u003e\u003cspan class=\"o\"\u003e=\u003c/span\u003e\u003cspan class=\"s1\"\u003e\u0026#39;해킹\u0026#39;\u003c/span\u003e\u003cspan class=\"w\"\u003e\n\u003c/span\u003e\u003c/span\u003e\u003c/span\u003e\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003ch3 id=\"python에-대한-injection-방어-코드\"\u003e🛡️ \u003cstrong\u003epython에 대한 injection 방어 코드\u003c/strong\u003e\u003c/h3\u003e\n\u003chr\u003e\n\u003ch4 id=\"실무에서-사용된-db-호출-코드-방식-injection-대응-전\"\u003e\u003cstrong\u003e실무에서 사용된 DB 호출 코드 방식 (Injection 대응 전)\u003c/strong\u003e\u003c/h4\u003e\n\u003cdiv class=\"highlight\"\u003e\u003cpre tabindex=\"0\" class=\"chroma\"\u003e\u003ccode class=\"language-python\" data-lang=\"python\"\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"kn\"\u003eimport\u003c/span\u003e \u003cspan class=\"nn\"\u003epsycopg2\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"kn\"\u003efrom\u003c/span\u003e \u003cspan class=\"nn\"\u003epsycopg2.extras\u003c/span\u003e \u003cspan class=\"kn\"\u003eimport\u003c/span\u003e \u003cspan class=\"n\"\u003eRealDictCursor\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"c1\"\u003e# DB 연결 준비\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"n\"\u003econn\u003c/span\u003e \u003cspan class=\"o\"\u003e=\u003c/span\u003e \u003cspan class=\"n\"\u003epsycopg2\u003c/span\u003e\u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"n\"\u003econnect\u003c/span\u003e\u003cspan class=\"p\"\u003e(\u003c/span\u003e\u003cspan class=\"s2\"\u003e\u0026#34;dbname=\u003c/span\u003e\u003cspan class=\"si\"\u003e{}\u003c/span\u003e\u003cspan class=\"s2\"\u003e user=\u003c/span\u003e\u003cspan class=\"si\"\u003e{}\u003c/span\u003e\u003cspan class=\"s2\"\u003e host=\u003c/span\u003e\u003cspan class=\"si\"\u003e{}\u003c/span\u003e\u003cspan class=\"s2\"\u003e password=\u003c/span\u003e\u003cspan class=\"si\"\u003e{}\u003c/span\u003e\u003cspan class=\"s2\"\u003e\u0026#34;\u003c/span\u003e\u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"n\"\u003eformat\u003c/span\u003e\u003cspan class=\"p\"\u003e(\u003c/span\u003e\u003cspan class=\"n\"\u003edb_name\u003c/span\u003e\u003cspan class=\"p\"\u003e,\u003c/span\u003e \u003cspan class=\"n\"\u003edb_user\u003c/span\u003e\u003cspan class=\"p\"\u003e,\u003c/span\u003e \u003cspan class=\"n\"\u003edb_host\u003c/span\u003e\u003cspan class=\"p\"\u003e,\u003c/span\u003e \u003cspan class=\"n\"\u003edb_pass\u003c/span\u003e\u003cspan class=\"p\"\u003e))\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"c1\"\u003e# DB 호출함수 선언\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"k\"\u003edef\u003c/span\u003e \u003cspan class=\"nf\"\u003efetch\u003c/span\u003e\u003cspan class=\"p\"\u003e(\u003c/span\u003e\u003cspan class=\"n\"\u003econn\u003c/span\u003e\u003cspan class=\"p\"\u003e,\u003c/span\u003e \u003cspan class=\"n\"\u003equery\u003c/span\u003e\u003cspan class=\"p\"\u003e):\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e    \u003cspan class=\"n\"\u003eresult\u003c/span\u003e \u003cspan class=\"o\"\u003e=\u003c/span\u003e \u003cspan class=\"p\"\u003e[]\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e    \u003cspan class=\"n\"\u003ecursor\u003c/span\u003e \u003cspan class=\"o\"\u003e=\u003c/span\u003e \u003cspan class=\"n\"\u003econn\u003c/span\u003e\u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"n\"\u003ecursor\u003c/span\u003e\u003cspan class=\"p\"\u003e(\u003c/span\u003e\u003cspan class=\"n\"\u003ecursor_factory\u003c/span\u003e\u003cspan class=\"o\"\u003e=\u003c/span\u003e\u003cspan class=\"n\"\u003eRealDictCursor\u003c/span\u003e\u003cspan class=\"p\"\u003e)\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e    \u003cspan class=\"n\"\u003ecursor\u003c/span\u003e\u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"n\"\u003eexecute\u003c/span\u003e\u003cspan class=\"p\"\u003e(\u003c/span\u003e\u003cspan class=\"n\"\u003equery\u003c/span\u003e\u003cspan class=\"p\"\u003e)\u003c/span\u003e \u003cspan class=\"c1\"\u003e# SQL 실행\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e    \u003cspan class=\"c1\"\u003e# DB조회 결과값 담기\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e    \u003cspan class=\"n\"\u003eraw\u003c/span\u003e \u003cspan class=\"o\"\u003e=\u003c/span\u003e \u003cspan class=\"n\"\u003ecursor\u003c/span\u003e\u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"n\"\u003efetchall\u003c/span\u003e\u003cspan class=\"p\"\u003e()\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e    \u003cspan class=\"k\"\u003efor\u003c/span\u003e \u003cspan class=\"n\"\u003eline\u003c/span\u003e \u003cspan class=\"ow\"\u003ein\u003c/span\u003e \u003cspan class=\"n\"\u003eraw\u003c/span\u003e\u003cspan class=\"p\"\u003e:\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e        \u003cspan class=\"n\"\u003eresult\u003c/span\u003e\u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"n\"\u003eappend\u003c/span\u003e\u003cspan class=\"p\"\u003e(\u003c/span\u003e\u003cspan class=\"n\"\u003eline\u003c/span\u003e\u003cspan class=\"p\"\u003e)\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e    \u003cspan class=\"k\"\u003ereturn\u003c/span\u003e \u003cspan class=\"n\"\u003eresult\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"c1\"\u003e# SQL 쿼리 선언\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"n\"\u003equery\u003c/span\u003e \u003cspan class=\"o\"\u003e=\u003c/span\u003e \u003cspan class=\"s2\"\u003e\u0026#34;select pjt_no, item_type from testTable \u0026#34;\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"n\"\u003equery\u003c/span\u003e \u003cspan class=\"o\"\u003e+=\u003c/span\u003e \u003cspan class=\"s2\"\u003e\u0026#34;where pjt_no = \u0026#39;\u0026#34;\u003c/span\u003e \u003cspan class=\"o\"\u003e+\u003c/span\u003e \u003cspan class=\"n\"\u003epjt_no\u003c/span\u003e \u003cspan class=\"o\"\u003e+\u003c/span\u003e \u003cspan class=\"s2\"\u003e\u0026#34;\u0026#39; \u0026#34;\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"n\"\u003equery\u003c/span\u003e \u003cspan class=\"o\"\u003e+=\u003c/span\u003e \u003cspan class=\"s2\"\u003e\u0026#34;and item_type = \u0026#39;\u0026#34;\u003c/span\u003e \u003cspan class=\"o\"\u003e+\u003c/span\u003e \u003cspan class=\"n\"\u003etype_cd2\u003c/span\u003e \u003cspan class=\"o\"\u003e+\u003c/span\u003e \u003cspan class=\"s2\"\u003e\u0026#34;\u0026#39;\u0026#34;\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"c1\"\u003e# DB 호출함수 실행\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"n\"\u003eresult\u003c/span\u003e \u003cspan class=\"o\"\u003e=\u003c/span\u003e \u003cspan class=\"n\"\u003efetch\u003c/span\u003e\u003cspan class=\"p\"\u003e(\u003c/span\u003e\u003cspan class=\"n\"\u003econn\u003c/span\u003e\u003cspan class=\"o\"\u003e=\u003c/span\u003e\u003cspan class=\"n\"\u003econn\u003c/span\u003e\u003cspan class=\"p\"\u003e,\u003c/span\u003e \u003cspan class=\"n\"\u003equery\u003c/span\u003e\u003cspan class=\"o\"\u003e=\u003c/span\u003e\u003cspan class=\"n\"\u003equery\u003c/span\u003e\u003cspan class=\"p\"\u003e)\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003ch4 id=\"injection-대응-type-1\"\u003e\u003cstrong\u003eInjection 대응 (Type 1)\u003c/strong\u003e\u003c/h4\u003e\n\u003cdiv class=\"highlight\"\u003e\u003cpre tabindex=\"0\" class=\"chroma\"\u003e\u003ccode class=\"language-python\" data-lang=\"python\"\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"kn\"\u003eimport\u003c/span\u003e \u003cspan class=\"nn\"\u003epsycopg2\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"kn\"\u003efrom\u003c/span\u003e \u003cspan class=\"nn\"\u003epsycopg2.extras\u003c/span\u003e \u003cspan class=\"kn\"\u003eimport\u003c/span\u003e \u003cspan class=\"n\"\u003eRealDictCursor\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"c1\"\u003e# DB 연결 준비\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"n\"\u003econn\u003c/span\u003e \u003cspan class=\"o\"\u003e=\u003c/span\u003e \u003cspan class=\"n\"\u003epsycopg2\u003c/span\u003e\u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"n\"\u003econnect\u003c/span\u003e\u003cspan class=\"p\"\u003e(\u003c/span\u003e\u003cspan class=\"s2\"\u003e\u0026#34;dbname=\u003c/span\u003e\u003cspan class=\"si\"\u003e{}\u003c/span\u003e\u003cspan class=\"s2\"\u003e user=\u003c/span\u003e\u003cspan class=\"si\"\u003e{}\u003c/span\u003e\u003cspan class=\"s2\"\u003e host=\u003c/span\u003e\u003cspan class=\"si\"\u003e{}\u003c/span\u003e\u003cspan class=\"s2\"\u003e password=\u003c/span\u003e\u003cspan class=\"si\"\u003e{}\u003c/span\u003e\u003cspan class=\"s2\"\u003e\u0026#34;\u003c/span\u003e\u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"n\"\u003eformat\u003c/span\u003e\u003cspan class=\"p\"\u003e(\u003c/span\u003e\u003cspan class=\"n\"\u003edb_name\u003c/span\u003e\u003cspan class=\"p\"\u003e,\u003c/span\u003e \u003cspan class=\"n\"\u003edb_user\u003c/span\u003e\u003cspan class=\"p\"\u003e,\u003c/span\u003e \u003cspan class=\"n\"\u003edb_host\u003c/span\u003e\u003cspan class=\"p\"\u003e,\u003c/span\u003e \u003cspan class=\"n\"\u003edb_pass\u003c/span\u003e\u003cspan class=\"p\"\u003e))\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"c1\"\u003e# DB 호출함수 선언\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"k\"\u003edef\u003c/span\u003e \u003cspan class=\"nf\"\u003efetch\u003c/span\u003e\u003cspan class=\"p\"\u003e(\u003c/span\u003e\u003cspan class=\"n\"\u003econn\u003c/span\u003e\u003cspan class=\"p\"\u003e,\u003c/span\u003e \u003cspan class=\"n\"\u003equery\u003c/span\u003e\u003cspan class=\"p\"\u003e,\u003c/span\u003e \u003cspan class=\"n\"\u003esql_arg\u003c/span\u003e\u003cspan class=\"p\"\u003e):\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e    \u003cspan class=\"n\"\u003eresult\u003c/span\u003e \u003cspan class=\"o\"\u003e=\u003c/span\u003e \u003cspan class=\"p\"\u003e[]\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e    \u003cspan class=\"n\"\u003ecursor\u003c/span\u003e \u003cspan class=\"o\"\u003e=\u003c/span\u003e \u003cspan class=\"n\"\u003econn\u003c/span\u003e\u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"n\"\u003ecursor\u003c/span\u003e\u003cspan class=\"p\"\u003e(\u003c/span\u003e\u003cspan class=\"n\"\u003ecursor_factory\u003c/span\u003e\u003cspan class=\"o\"\u003e=\u003c/span\u003e\u003cspan class=\"n\"\u003eRealDictCursor\u003c/span\u003e\u003cspan class=\"p\"\u003e)\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e    \u003cspan class=\"n\"\u003ecursor\u003c/span\u003e\u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"n\"\u003eexecute\u003c/span\u003e\u003cspan class=\"p\"\u003e(\u003c/span\u003e\u003cspan class=\"n\"\u003equery\u003c/span\u003e\u003cspan class=\"p\"\u003e,\u003c/span\u003e \u003cspan class=\"n\"\u003esql_arg\u003c/span\u003e\u003cspan class=\"p\"\u003e)\u003c/span\u003e \u003cspan class=\"c1\"\u003e# SQL 실행\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e    \u003cspan class=\"c1\"\u003e# DB조회 결과값 담기\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e    \u003cspan class=\"n\"\u003eraw\u003c/span\u003e \u003cspan class=\"o\"\u003e=\u003c/span\u003e \u003cspan class=\"n\"\u003ecursor\u003c/span\u003e\u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"n\"\u003efetchall\u003c/span\u003e\u003cspan class=\"p\"\u003e()\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e    \u003cspan class=\"k\"\u003efor\u003c/span\u003e \u003cspan class=\"n\"\u003eline\u003c/span\u003e \u003cspan class=\"ow\"\u003ein\u003c/span\u003e \u003cspan class=\"n\"\u003eraw\u003c/span\u003e\u003cspan class=\"p\"\u003e:\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e        \u003cspan class=\"n\"\u003eresult\u003c/span\u003e\u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"n\"\u003eappend\u003c/span\u003e\u003cspan class=\"p\"\u003e(\u003c/span\u003e\u003cspan class=\"n\"\u003eline\u003c/span\u003e\u003cspan class=\"p\"\u003e)\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e    \u003cspan class=\"k\"\u003ereturn\u003c/span\u003e \u003cspan class=\"n\"\u003eresult\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"c1\"\u003e# SQL 쿼리 선언\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"n\"\u003equery\u003c/span\u003e \u003cspan class=\"o\"\u003e=\u003c/span\u003e \u003cspan class=\"s2\"\u003e\u0026#34;select pjt_no, item_type from testTable \u0026#34;\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"n\"\u003equery\u003c/span\u003e \u003cspan class=\"o\"\u003e+=\u003c/span\u003e \u003cspan class=\"s2\"\u003e\u0026#34;where pjt_no = \u003c/span\u003e\u003cspan class=\"si\"\u003e%(pjt_no)s\u003c/span\u003e\u003cspan class=\"s2\"\u003e\u0026#34;\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"n\"\u003equery\u003c/span\u003e \u003cspan class=\"o\"\u003e+=\u003c/span\u003e \u003cspan class=\"s2\"\u003e\u0026#34;and item_type = \u003c/span\u003e\u003cspan class=\"si\"\u003e%(item_type)s\u003c/span\u003e\u003cspan class=\"s2\"\u003e\u0026#34;\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"n\"\u003esql_arg\u003c/span\u003e \u003cspan class=\"o\"\u003e=\u003c/span\u003e \u003cspan class=\"p\"\u003e{\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e  \u003cspan class=\"s1\"\u003e\u0026#39;pjt_no\u0026#39;\u003c/span\u003e \u003cspan class=\"p\"\u003e:\u003c/span\u003e \u003cspan class=\"n\"\u003epjt_no\u003c/span\u003e\u003cspan class=\"p\"\u003e,\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e  \u003cspan class=\"s1\"\u003e\u0026#39;item_type\u0026#39;\u003c/span\u003e \u003cspan class=\"p\"\u003e:\u003c/span\u003e \u003cspan class=\"n\"\u003eitem_type\u003c/span\u003e\u003cspan class=\"p\"\u003e,\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"p\"\u003e}\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"c1\"\u003e# DB 호출함수 실행\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"n\"\u003eresult\u003c/span\u003e \u003cspan class=\"o\"\u003e=\u003c/span\u003e \u003cspan class=\"n\"\u003efetch\u003c/span\u003e\u003cspan class=\"p\"\u003e(\u003c/span\u003e\u003cspan class=\"n\"\u003econn\u003c/span\u003e\u003cspan class=\"o\"\u003e=\u003c/span\u003e\u003cspan class=\"n\"\u003econn\u003c/span\u003e\u003cspan class=\"p\"\u003e,\u003c/span\u003e \u003cspan class=\"n\"\u003equery\u003c/span\u003e\u003cspan class=\"o\"\u003e=\u003c/span\u003e\u003cspan class=\"n\"\u003equery\u003c/span\u003e\u003cspan class=\"p\"\u003e,\u003c/span\u003e \u003cspan class=\"n\"\u003esql_arg\u003c/span\u003e\u003cspan class=\"o\"\u003e=\u003c/span\u003e\u003cspan class=\"n\"\u003esql_arg\u003c/span\u003e\u003cspan class=\"p\"\u003e)\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003ch4 id=\"injection-대응-type-2\"\u003e\u003cstrong\u003eInjection 대응 (Type 2)\u003c/strong\u003e\u003c/h4\u003e\n\u003cp\u003e(\u003cstrong\u003e딕셔너리 타입(X) → tuple 타입으로 넘기는 것도 가능)\u003c/strong\u003e\u003c/p\u003e","title":"Defense against injection attacks"},{"content":"✅The blog works by importing posts written in Notion! Hello, I am jay, the owner of the blog. 🙂\nIf there is anything wrong or if you have any questions, please feel free to contact us by email. thank you! 😎\nBlhamz wallpaper\nGITHUB.\nhttps://github.com/jay-lab\n","permalink":"https://jaylab.link/about/","summary":"\u003cdiv class=\"callout callout-gray\"\u003e\u003cspan class=\"callout-icon\"\u003e✅\u003c/span\u003e\u003cdiv class=\"callout-body\"\u003eThe blog works by importing posts written in Notion!\u003c/div\u003e\n\u003c/div\u003e\n\n\u003cp\u003eHello, I am \u003ccode\u003ejay\u003c/code\u003e, the owner of the blog. 🙂\u003c/p\u003e\n\u003cp\u003eIf there is anything wrong or if you have any questions, please feel free to contact us by email.  thank you! 😎\u003c/p\u003e\n\u003cfigure\u003e\n    \u003cimg loading=\"lazy\" src=\"image-1.png\"\n         alt=\"Blhamz wallpaper\"/\u003e \u003cfigcaption\u003e\n            \u003cp\u003eBlhamz wallpaper\u003c/p\u003e\n        \u003c/figcaption\u003e\n\u003c/figure\u003e\n\n\u003cp\u003e\u003cem\u003e\u003cstrong\u003eGITHUB.\u003c/strong\u003e\u003c/em\u003e\u003c/p\u003e\n\u003cp\u003e\u003ca href=\"https://github.com/jay-lab\"\u003ehttps://github.com/jay-lab\u003c/a\u003e\u003c/p\u003e","title":"Hello world!"},{"content":"morethanmin, Software Engineer Contact. Email. morethanmin.dev@gamil.com\nPhone. 010-1234-1234\nChannel. GitHub. https://github.com/morethanmin\nBlog. https://morethanmin.web.app\nIntroduce. hello? I am a developer who loves to code. hello? I am a developer who loves to code. This is the requirements test page. This is the requirements test page. This is the requirements test page. This is the requirements test page. This is the requirements test page.\nThe cat is doing the coding for me. The cat is doing the coding for me.\nWork Experience. Untilled company Frontend Developer 2021.12 - present\nUntilled service development 2022.06 - present\nDescription Responsible for development and maintenance of overall front-end functions of Untilled, a web service with more than MAU1 million. I am filling out a sample form.\nThe existing development environment, which requires the backend to run locally, has been separated to improve the development environment into a form that can be maintained and expanded.\nWhat did I do member function development\nMaintenance and refactoring of existing service frontends\nIntroducing code reviews and improving the overall development culture\nTech Stack Next.js, TypeScript, Apollo, GraphQL\nUntil full stack development 2021.12 - 2022.06\nDescription We developed a full-stack blog service called Until, which is expected to be used by the entire nation.\nBy proactively migrating the JQuery-based service to the Next.js-based service, we were able to solve many existing problems and improve maintenance.\nWhat did I do Implementing the Until frontend\nUntil design system improvements\nMigrating a project based on Next.js\nTech Stack Next.js, JavaScript, Redux, Spring, jquery\nICT Internship Frontend Intern 2021.02 - 2021.09\nEveryone\u0026rsquo;s community development development is good company 2021.06 - 2021.09\nDescription About 500 million people have participated offline as an educational service, and it is a service that helps young people become self-reliant locally. Developed frontend and backend. Basic maintenance and various functions have been developed.\nWhat did I do Development of venue and equipment rental/rental function\nDevelopment of lodging reservation function\nMain page UI reorganization and refactoring\nAdmin page UI development\nBuilding a front-end development environment\nTech Stack Nuxt.js, JavaScript, Express.js\nDevelop used trading service right away reo company 2021.02 - 2021.04\nDescription Developed the front end of a used MacBook sales service. Developed maintenance and simple functions. The admin page was developed using React-admin.\nWhat did I do Main page UI reorganization\nAdmin (Seller) page development\nTech Stack React, Redux, TypeScript, Mui\nfreelancer Freelance Developer 2020.12 - 2021.01\nE-Learning content development Entropy Software company 2020.12 - 2021.01\nDescription As Adobe Flash Player is no longer supported, we have migrated the Flash Player-based lecture service to a web-based one.\nWhat did I do Implementing lecture page UI\nSwitch to Javascript\nTech Stack Html, CSS, JavaScript\nOther Experience. Untilled Developer Student Clubs Web Core 2022.09 - present\nIt is a program that supports development and leadership development in Untilled, and is in charge of studying the web part by participating as a Web Core member.\n12 when frontend and backend meet Web Leader 2022.09 - present\nFmanman is a development alliance club that aims to launch a service where frontend and backend meet. Currently, I am participating in the development as a front-end leader for 4 teams by participating in 12 web parts.\nZext Frontend Lead 2021.08 - 2022.04\nZext, a global startup, is a global team formed by international students and developers from four countries: Korea, the United States, China, and Canada, with the goal of creating a social space that creates synergy among international students around the world. Participated as a side project and developed the MVP of Int’ly, an international student community platform.\nJ2KB Frontend Lead 2021.06 - 2021.12\nI participated in the 3rd class of J2KB, a national coding association club, conducted a study in the front-end (React, Redux) session and had time to make a presentation every week.\nHe also participated in Unicorn, a hackathon hosted by the development club, as a front-end developer of a team called Dev-in, developed a Q\u0026amp;A-based development community service, and won the gold prize.\nTech Blog Author 2021.08 - present\nI have developed and operated a blog called morethan-log. If you are using next.js and write a text in Notion, the text is called and displayed. Previously, I ran a blog called CatHub.\nMyongji University Computer Engineering 2019.03 - 2021.03 (leave of absence)\nI am very interested in networks, so I entered the Department of Information and Communication Engineering and completed my sophomore year.\nSkill. React Use React hooks appropriately, use them in a variety of ways, and use them well.\nYou can use state management libraries such as redux, recoil, react-query, and apollo and perform asynchronous state management in various ways.\nYou can implement SSR through Next.js and Next Level\nYou can do component driven development using Facebook.\nJavascript Proficient in Javascript and Typescript.\nIt is not possible to build and handle Node.js-based eco-friendly using bundle bundler, transformer, etc.\nI do not understand the module system of CommonJS and ES Modules and use it.\nEtc I have experience contributing to open source.\nI like to find efficient ways through communication.\nExperienced in Search Engine Optimization.\nIt can be styled appropriately using OOTD.\nThis resume is a template. See here for details.\n","permalink":"https://jaylab.link/resume/","summary":"\u003ch2 id=\"morethanmin-software-engineer\"\u003e\u003cstrong\u003emorethanmin, Software Engineer\u003c/strong\u003e\u003c/h2\u003e\n\u003cp\u003e\u003cimg loading=\"lazy\" src=\"/resume/image-1.png\"\u003e\u003c/p\u003e\n\u003ch4 id=\"contact\"\u003eContact.\u003c/h4\u003e\n\u003cp\u003eEmail. \u003ca href=\"mailto:morethanmin.dev@gamil.com\"\u003emorethanmin.dev@gamil.com\u003c/a\u003e\u003c/p\u003e\n\u003cp\u003ePhone. 010-1234-1234\u003c/p\u003e\n\u003ch4 id=\"channel\"\u003eChannel.\u003c/h4\u003e\n\u003cp\u003eGitHub. \u003ca href=\"https://github.com/morethanmin\"\u003ehttps://github.com/morethanmin\u003c/a\u003e\u003c/p\u003e\n\u003cp\u003eBlog. \u003ca href=\"https://morethanmin.web.app/\"\u003ehttps://morethanmin.web.app\u003c/a\u003e\u003c/p\u003e\n\u003ch2 id=\"introduce\"\u003eIntroduce.\u003c/h2\u003e\n\u003cp\u003ehello? I am a developer who loves to code. hello? I am a developer who loves to code. This is the requirements test page. This is the requirements test page. This is the requirements test page. This is the requirements test page. This is the requirements test page.\u003c/p\u003e\n\u003cp\u003eThe cat is doing the coding for me. The cat is doing the coding for me.\u003c/p\u003e","title":"Resume."}]