[{"content":" 배포 버튼을 누르면 서비스가 몇 초 동안 사라졌다. 타겟 그룹에 묶인 서버 전부에 같은 순간 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.blog/jenkins-rolling-deploy-pipeline/","summary":"타겟 그룹 전체를 동시에 재시작하던 Jenkins 파이프라인을 서버별 순차 배포와 LB 제어로 바꾸며 내린 네 가지 결정과 아직 남은 한계","title":"배포 때마다 끊기던 서비스의 무중단 적용 - Jenkins Rolling Deployment에서 내린 네 가지 결정"},{"content":" 고객사의 수많은 애플리케이션 배포 파이프라인을 하나로 추상화하는 작업을 하고 있다. 파이프라인은 하나로 모이지만 그 위를 지나가는 애플리케이션의 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.blog/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); } ※ 결국 나중엔 제거 🤫 처음에는 테넌트별 스키마 정보를 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;] 실제 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.blog/multi-tenant-provisioning-system/","summary":"docker run을 감싼 쉘로는 실패 원인도 롤백도 다룰 수 없어 Dockerode와 스키마 분리로 테넌트 생성을 자동화한 과정","title":"쉘 스크립트 대신 Dockerode를 고른 이유: 멀티 테넌트 프로비저닝의 롤백과 동시성"},{"content":" 새 테넌트가 생기면 할 일이 두 가지였다. 서버를 만들고, 그 서버에 도메인을 연결한다. 이전 버전까지는 둘 다 손으로 했다. 한두 번이면 괜찮지만, 테넌트가 늘어나면 번거로워지고 번거로운 일에는 실수가 끼어든다.\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.blog/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를 명시해야 한다.\nwebserver는 여전히 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.blog/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.blog/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.blog/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.blog/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.blog/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.blog/docker-container-network-arch/","summary":"Mac에서 컨테이너 IP가 보이는데도 접속되지 않는 건 Docker 네트워크가 Linux VM 안에만 있어서다.","title":"Mac에서 컨테이너 IP로 접속이 안 되는 이유"},{"content":"진행 중인 PoC에서 SSH 접속 방식을 테스트해야 했다. 그런데 공식 ubuntu Docker 이미지에는 SSH 서버(openssh-server)가 들어 있지 않다. 컨테이너는 bash 하나만 띄운 채로 시작하니 22번 포트에서 기다리는 프로세스가 없다.\n이 글에서는 컨테이너에 SSH 서버를 직접 깔고 로컬에서 ssh로 들어가기까지를 정리한다.\n테스트용으로 보안은 일부러 낮췄다 먼저 범위를 분명히 해 둔다. 여기서는 root 계정에 비밀번호를 걸고 그 비밀번호로 접속한다. 접속 방식이 되는지만 확인하려는 테스트라서 그렇다. 실제로 쓸 환경이라면 별도 계정을 만들고 공개키 인증을 쓰는 게 맞다.\n접속 주소도 컨테이너 IP가 아니라 localhost다. 컨테이너 IP로는 SSH 접속이 되지 않는다. 왜 안 되는지는 「Mac에서 컨테이너 IP로 접속이 안 되는 이유」에서 따로 다뤘다.\n링크: https://jaylab.blog/docker-container-network-arch/\n컨테이너를 띄울 때 22번 포트를 미리 연다 Ubuntu 컨테이너를 실행하면서 바로 bash로 들어간다.\ndocker run -it --name {컨테이너 이름} -p 2222:22 ubuntu:latest bash -p 2222:22: 로컬 포트 2222를 컨테이너의 22번(SSH) 포트로 포트포워딩 포트 매핑은 컨테이너를 만들 때만 정할 수 있다. 나중에 SSH를 쓸 생각이라면 이 단계에서 열어 둬야 한다.\n컨테이너 안에서 sshd를 깔고 root 로그인을 연다 컨테이너 안에서 SSH 서버를 설치한다.\napt update apt install -y openssh-server 설치만 해서는 비밀번호로 root에 들어갈 수 없다. sshd_config의 기본값인 PermitRootLogin prohibit-password가 root 로그인 자체는 허용하되 비밀번호 방식은 막아 두기 때문이다. 이 줄을 yes로 바꾸고 root 비밀번호를 정한 뒤 SSH 서버를 띄운다.\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 서버 구동 로컬에서 2222번으로 붙는다 컨테이너 쉘은 그대로 두고 새 터미널을 열어 로컬에서 접속한다.\nssh root@localhost -p 2222 비밀번호는 앞에서 정한 DockerPass123이다. 로컬 2222번으로 들어온 연결이 컨테이너 22번으로 넘어가고, 거기서 기다리던 sshd가 받는다.\n매번 sshd를 켜기 싫으면 Dockerfile로 굳힌다 위 방식대로면 컨테이너를 다시 띄울 때마다 service ssh start를 또 쳐야 한다. 컨테이너가 뜰 때마다 sshd가 돌게 하려면 Dockerfile에 CMD [\u0026quot;/usr/sbin/sshd\u0026quot;, \u0026quot;-D\u0026quot;]를 적는다. -D 옵션을 주면 sshd가 데몬으로 떨어져 나가지 않고 포그라운드에 남아 컨테이너의 메인 프로세스 역할을 한다.\n# Dockerfile 예시 FROM ubuntu:latest RUN mkdir /run/sshd 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가 이미 들어 있는 이미지를 쓰는 방법도 있다.\n정리 컨테이너는 서버가 아니라 프로세스 하나라서, SSH가 필요하면 sshd를 직접 깔고 띄워야 한다. 포트는 컨테이너를 만들 때 열고, root 비밀번호 로그인은 테스트에서만 쓴다.\n참고자료 docker container run: Publish or expose port (-p) — -p 호스트포트:컨테이너포트 포트 게시 옵션\nsshd_config(5) — PermitRootLogin의 prohibit-password, yes 값 설명\nsshd(8) — -D 옵션(데몬으로 분리하지 않음)\n","permalink":"https://jaylab.blog/docker-ssh-setting/","summary":"PoC에서 SSH 접속을 시험하려는데 Ubuntu 이미지에 sshd가 없어서, openssh-server를 깔고 로컬 2222번 포트로 붙였다.","title":"Ubuntu 컨테이너에는 sshd가 없다"},{"content":" 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; 쿼리 파라미터 하나만 다른 두 요청이다. test를 보낸 쪽은 403, hello를 보낸 쪽은 200을 받았다. 앞 글에서는 ModSecurity가 무엇인지 개념만 짚었다. 이번에는 실제로 요청을 막는 장면까지 가 봤다.\n전편 “ModSecurity. Opensource Web Application Firewall” 링크: https://jaylab.blog/modsecurity-waf-docker-setting/ 이 글에서는 Ubuntu 컨테이너 하나에 Nginx와 ModSecurity를 깔고, 위 로그가 나오기까지와 막힌 요청이 audit log에 어떻게 남는지를 정리한다.\nUbuntu 컨테이너 하나에 전부 올린다 구성은 단순하다. Ubuntu 컨테이너(A)를 하나 띄우고, 그 안에 앞서 다뤘던 Nginx Proxy Manager를 구성한 뒤 ModSecurity를 설치한다.\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는 Nginx 모듈과 룰셋, 두 패키지다 ModSecurity 관련 패키지를 찾아보고, Nginx용 모듈과 OWASP CRS(Core Rule Set)를 설치한다. 엔진(libnginx-mod-http-modsecurity)과 규칙(modsecurity-crs)은 따로 온다. 엔진만 있으면 아무것도 막지 않고, 규칙만 있으면 읽어 줄 쪽이 없다.\n# 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 owasp-crs.load에 규칙이 직접 들어 있지는 않다. Include 지시어로 규칙 파일들을 가리킬 뿐이다. 설정(crs-setup.conf) → 사용자 예외 → 본 규칙(rules/*.conf) → 응답 쪽 예외 순서다.\nNginx를 띄우고 로그를 열어 둔다 # 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 tail -f가 터미널을 붙잡고 있으니, 요청은 다른 터미널에서 같은 컨테이너로 들어가서 보낸다.\n# 다른 터미널에서 우분투 컨테이너 진입 docker exec -it my-ubuntu-container /bin/bash # Nginx 상태 확인 curl -I http://localhost # 우분투 컨테이너에서 access log 확인 -\u0026gt; 정상 test가 들어간 요청만 403을 받는다 tail -f로 지켜본 access log다. HEAD는 curl -I, GET은 그냥 curl로 보낸 요청이다.\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; 메서드와 상관없이 data=test면 403, data=hello면 200이다. 여기까지는 Nginx access log라서 \u0026ldquo;막혔다\u0026quot;는 사실만 보인다. 왜 막혔는지는 ModSecurity의 audit log에 따로 남는다.\naudit log는 막힌 요청만 기록한다 audit log 경로는 /etc/nginx/modsecurity.conf의 SecAuditLog 항목에서 확인할 수 있다.\n경로는 /var/log/nginx/modsec_audit.log였다. 이 파일을 tail -f로 열어 두면, 규칙에 걸리지 않는 요청을 보낼 때는 아무것도 찍히지 않다가 규칙에 걸리는 요청이 들어오는 순간 아래 로그가 나온다.\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-- ---AU9HhbBg---X-- 줄이 섹션 구분자이고, 뒤 글자가 스크린샷의 SecAuditLogParts ABIJDEFHZ와 대응한다. 읽을 곳은 세 군데다.\nB: 들어온 요청 헤더. HEAD /?data=test\nF: 돌려준 응답 헤더. HTTP/1.1 403\nH: 판정 이유. /etc/nginx/modsec/modsec_test.conf 1번째 줄의 규칙 id 1234가 ARGS:data 값에 test가 포함된 것을 보고(Contains) phase 1에서 403으로 끊었다. 메시지는 param contains test.\nphase 1은 요청 헤더를 받은 직후 단계다. 쿼리 스트링은 요청 줄에 들어 있으니 본문을 읽기도 전에 판정이 끝난다.\n테스트한 건 직접 만든 규칙 하나다 403이 나왔다고 WAF가 다 된 것은 아니다. 이번에 확인한 것은 modsec_test.conf에 둔 테스트 규칙(id 1234) 하나가 걸리는 장면이다. 앞에서 설치한 OWASP CRS의 규칙이 SQL Injection이나 XSS 같은 실제 공격 패턴을 막는지는 아직 보지 않았다. owasp-crs.load의 주석에도 \u0026ldquo;libapache2-mod-security2에서 Include된다\u0026quot;고 적혀 있으니 Nginx 쪽에서 CRS를 읽게 하는 연결은 따로 확인해야 한다.\n컨테이너를 이미지로 굳힐 때는 Nginx가 포그라운드에서 돌아야 컨테이너가 바로 종료되지 않는다. 아래 둘 중 하나를 쓴다.\n# 컨테이너 시작시 Nginx가 포그라운드에서 사용될 수 있도록 하는 명령어들(아래 중 한가지 선택) CMD [\u0026#34;nginx\u0026#34;, \u0026#34;-g\u0026#34;, \u0026#34;daemon off;\u0026#34;] # Dockerfile에 쓸 때 nginx -g \u0026#39;daemon off;\u0026#39; # 쉘에서 직접 띄울 때 정리 ModSecurity는 엔진과 규칙이 따로 온다. Nginx 모듈과 CRS를 각각 깔고 연결해야 한다.\naccess log는 403이라는 결과만, audit log는 어느 규칙이 왜 막았는지를 보여준다.\n이번에 막힌 건 테스트 규칙 하나다. CRS 규칙 검증은 다음 단계다.\n?data=test 하나 막는 데 생각보다 많은 파일을 거쳤다. 그래도 audit log의 H 섹션을 한 번 읽어 두면, 나중에 CRS가 정상 요청을 막았을 때 어디부터 볼지는 안다.\n참고자료 ModSecurity (owasp-modsecurity) — ModSecurity v3 엔진(libmodsecurity)\nModSecurity-nginx — Nginx 커넥터 모듈과 modsecurity, modsecurity_rules_file 지시어\nOWASP CRS — modsecurity-crs 패키지가 담고 있는 Core Rule Set\nModSecurity Reference Manual (v3.x) — SecAuditLog, SecAuditLogParts, phase 설명\n","permalink":"https://jaylab.blog/modsecurity-waf-with-ubuntu-setting/","summary":"Ubuntu 컨테이너의 Nginx에 ModSecurity를 붙이고, test가 든 요청만 403으로 막히는 것을 access log와 audit log로 확인했다.","title":"ModSecurity가 ?data=test를 403으로 막기까지"},{"content":"WAF를 간단히 실습해 보려고 Docker로 띄울 방법을 찾았는데, 웹에서 쓸 만한 레퍼런스를 끝내 찾지 못했다. 혹시나 해서 docker search modsecurity를 쳐 봤고, 거기서 실마리가 나왔다.\n이 글은 ModSecurity 컨테이너 하나를 띄워 SQL Injection 요청을 한 번 보내 보는 짧은 실습이다. 요청이 막히는 것 자체보다 막힌 이유가 로그에 어떻게 찍히는지를 중심으로 정리했다.\nWAF와 ModSecurity WAF(Web Application Firewall)는 XSS, SQL Injection, Command Execute처럼 웹 서비스를 통해 들어오는 공격을 막는 보안 모듈이다. 포트와 IP를 보는 네트워크 방화벽과 달리 HTTP 요청의 내용을 보고 판단하는 애플리케이션 계층(L7) 방화벽이다.\nModSecurity는 오픈소스 WAF다. 웹 클라이언트와 웹 서버 사이에서 요청을 검사하고, 악의적인 트래픽이면 차단이나 로깅처럼 미리 정의한 행동을 한다. 원래는 Apache 웹서버의 모듈로 시작했지만, 이번에 띄운 컨테이너의 로그를 보면 ModSecurity v3.0.12가 ModSecurity-nginx v1.0.3 connector를 통해 nginx에 붙어 있다. 엔진은 규칙을 실행할 뿐이고, 무엇을 막을지는 규칙 세트가 정한다. 이미지를 고를 때 규칙이 들어 있는지부터 본 이유다.\n규칙 세트가 들어 있는 이미지를 골랐다 docker search modsecurity 결과에서 관련 있어 보이는 이미지는 두 개였다. owasp/modsecurity는 설명에 \u0026ldquo;No CRS\u0026quot;가 붙어 있었고, owasp/modsecurity-crs는 OWASP Core Rule Set(CRS)이 함께 들어 있는 이미지였다. 엔진만 있는 이미지로는 막을 규칙이 없으니 후자를 골랐다.\nowasp/modsecurity-crs의 nginx 태그 이미지를 받아 컨테이너를 띄웠는데 첫 실행에서는 접속이 되지 않았다. 확인해 보니 컨테이너는 8080 포트를 리스닝하고 있었고, 포트 매핑을 8080:8080으로 고쳐 다시 띄웠다.\n# 기존 컨테이너 정리 docker stop modsecurity-crs docker rm modsecurity-crs # 컨테이너가 8080을 리스닝하므로 호스트 8080 → 컨테이너 8080으로 매핑 docker run -d -p 8080:8080 --name modsecurity-crs owasp/modsecurity-crs:nginx localhost:8080에 접속하면 nginx의 \u0026ldquo;An error occurred.\u0026rdquo; 페이지가 뜬다. WAF 뒤에 연결한 백엔드가 없으니 에러 페이지가 나오는 게 맞다. 여기서 확인할 것은 컨테이너가 요청을 받아 응답한다는 사실 하나였다.\nSQL Injection 한 줄을 보내 봤다 다른 터미널에서 docker logs -f modsecurity-crs로 로그를 띄워 두고, 쿼리 스트링에 전형적인 SQL Injection 문자열을 넣어 요청했다.\ncurl \u0026#34;http://localhost:8080/?test=\u0026#39;OR\u0026#39;1\u0026#39;=\u0026#39;1\u0026#34; 응답은 403이었고, 로그에는 이렇게 찍혔다.\n2024/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;}}]}} 로그의 두 규칙이 말해 주는 것 긴 로그지만 messages 안의 규칙 두 개만 보면 된다.\n규칙 ID 파일 한 일 942100 REQUEST-942-APPLICATION-ATTACK-SQLI.conf libinjection으로 ARGS:test 값 'OR'1'='1에서 SQL Injection 패턴(s\u0026amp;sos)을 탐지 949110 REQUEST-949-BLOCKING-EVALUATION.conf 누적 점수 TX:BLOCKING_INBOUND_ANOMALY_SCORE가 5 이상(Ge 5)이라 403으로 차단 942100은 요청을 막지 않았다. 탐지하고 점수를 올렸을 뿐이다. 실제로 막은 것은 그 뒤에 도는 949110이고, 이 규칙은 앞선 규칙들이 쌓은 점수가 임계값 5 이상인지만 본다. CRS는 규칙 하나가 걸렸다고 막지 않고, 규칙들이 쌓은 점수가 임계값에 이르면 막는다. 이번에는 942100 하나가 5점을 채워 Total Score: 5로 차단됐다.\n이 구조 덕분에 오탐이 나는 규칙이 있으면 그 규칙만 끄는 대신 임계값을 조정하는 선택지가 생긴다. 942100 태그의 paranoia-level/1도 같은 맥락이다. 탐지 강도를 단계로 나눠 두고, 기본값은 가장 느슨한 1단계다.\n실습은 여기까지다. 요청 하나가 막히는 걸 본 것에 가깝고, 실제 서비스 앞에 붙이는 구성은 다음 글에서 이어간다.\n정리 ModSecurity는 엔진이고, 무엇을 막을지는 OWASP CRS 같은 규칙 세트가 정한다. 그래서 owasp/modsecurity-crs 이미지를 골랐다.\n차단은 탐지 규칙이 아니라 점수 평가 규칙(949110)이 한다. 로그에서 942100과 949110을 나눠 읽으면 왜 막혔는지가 보인다.\n이미지의 nginx는 8080을 리스닝한다. 포트 매핑을 8080:8080으로 맞춰야 한다.\n참고 coreruleset/coreruleset — OWASP Core Rule Set. 942xxx(SQLi), 949xxx(차단 평가) 규칙 파일이 여기 있다\ncoreruleset/modsecurity-crs-docker — 실습에 쓴 owasp/modsecurity-crs 이미지의 소스와 환경변수 설명\n","permalink":"https://jaylab.blog/modsecurity-waf-docker-setting/","summary":"Docker로 ModSecurity WAF를 띄우고 SQL Injection 요청이 403으로 막히는 과정을 로그의 Anomaly Score로 따라가 본 실습 기록","title":"ModSecurity는 SQL Injection을 점수로 막는다"},{"content":"보통 하루 안에 끝나던 분석 배치가 하루를 넘기기 시작했다. 코드가 느려진 게 아니다. 분석해야 할 작업이 늘었는데, 그 작업을 처리하는 태스크는 여전히 하나였다.\n태스크를 10개로 고정하면 당장은 풀리지만, 작업 목록이 비어 있는 시간에도 10개가 돈다. 필요한 건 더 많은 태스크가 아니라, 일이 있을 때만 늘어나는 태스크였다. 이 글에서는 대기열에 남은 작업 수를 CloudWatch 커스텀 메트릭으로 보내고, 그 메트릭에 건 Alarm으로 ECS Fargate 태스크를 1개와 10개 사이에서 움직이게 만든 과정을 정리한다. 처음에 세웠던 \u0026ldquo;SDK로 직접 조절한다\u0026quot;는 계획을 버린 이유도 함께 적는다.\n태스크 하나가 대기열 전체를 받아내던 구조 시스템은 Spring 기반 MSA이고, 배치 전용 모듈이 두 개 있다. 모든 모듈은 ECS Fargate로 운영한다.\n모듈 A: 작업 목록을 가진 Queue 역할. Redshift 테이블을 조회해 작업 목록을 Queue에 적재한다.\n모듈 B: A의 Queue에서 작업 목록을 읽어 분석한다. 데이터가 있으면 Queue에서 pull하고, 분석이 끝나면 Redshift 특정 테이블의 해당 row를 delete한다.\n분석 속도는 B의 태스크 수에 비례한다. 그런데 B는 1개로만 운영되고 있었고, 필요한 분석 작업이 늘면서 배치가 하루를 넘기기 시작했다.\nSDK로 직접 조절하려던 계획을 버린 이유 처음 계획은 CloudWatch Alarm 없이, A 모듈이 AWS SDK로 B 서비스의 컴퓨팅 구성을 직접 바꾸는 것이었다. 대기열 상태를 가장 먼저 아는 쪽이 A이니 자연스러운 선택처럼 보였다.\n사전 테스트에서 문제가 나왔다. 조정 정책을 직접 컨트롤하면 ECS가 필요한 태스크를 모두 새로 만든 다음 기존 태스크를 전부 지웠다. 태스크 1개로 운용하다가 작업이 늘어 4개를 추가하면, 4개만 붙는 게 아니라 5개가 새로 생기고 기존 1개가 삭제됐다. 기존 태스크가 멀쩡히 분석을 돌리고 있었다면 그 작업은 뜬금없이 중단된다.\n5개를 만들고 1개를 지우는 동작은 서비스 배포 설정(최소 실행 작업 비율 100%, 최대 200%)으로 새 배포를 굴릴 때의 모습과 닮았다. 직접 호출이 태스크 수 조정이 아니라 새 배포로 처리됐을 가능성이 있는데, 당시 어떤 API를 어떻게 호출했는지는 기록에 남아 있지 않다.\n그래서 방향을 바꿨다. A는 숫자만 보내고, 태스크 수는 ECS Service Auto Scaling이 CloudWatch Alarm을 받아 조정하게 했다. A가 스케일링을 결정하지 않고 메트릭만 내보내면, 태스크를 늘리고 줄이는 일은 ECS가 정상 경로로 처리한다.\nflowchart LR A[\u0026#34;모듈 A\u0026lt;br/\u0026gt;@Scheduled 5분\u0026#34;] --\u0026gt;|PutMetricData\u0026lt;br/\u0026gt;RecommendationTargetCount| M[\u0026#34;CloudWatch\u0026lt;br/\u0026gt;ECS/CustomMetrics\u0026#34;] M --\u0026gt; H[\u0026#34;Alarm: High-RecommendationTargetCount\u0026lt;br/\u0026gt;최소값 \u0026gt;= 10, 2/2\u0026#34;] M --\u0026gt; E[\u0026#34;Alarm: Empty-RecommendationTargetCount\u0026lt;br/\u0026gt;최대값 \u0026lt;= 0, 2/2\u0026#34;] H --\u0026gt;|Set to 10 tasks| S[\u0026#34;ECS Service B\u0026lt;br/\u0026gt;cbp-inf-opti-aws-recm\u0026#34;] E --\u0026gt;|Set to 1 task| SA가 5분마다 보내는 숫자 하나 먼저 A에 CloudWatch SDK를 추가했다.\ndependencies { implementation \u0026#39;software.amazon.awssdk:cloudwatch:2.22.9\u0026#39; } CloudWatchClient를 빈으로 등록한다. 자격 증명은 DefaultCredentialsProvider에 맡겨 Fargate 태스크 역할을 그대로 쓴다.\nimport 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(); } } 5분 주기로 남은 작업 수를 세어 ECS/CustomMetrics 네임스페이스의 RecommendationTargetCount 메트릭으로 보낸다.\nimport 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(); // 대기열 테이블 레코드 개수 조회 // Queue에 남아 있으면 Queue 크기를, Queue가 비었으면 Redshift 대기열 테이블 건수를 보낸다 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); } } 눈여겨볼 곳은 삼항 연산자 한 줄이다. Queue가 비어도 바로 0을 보내지 않고 Redshift 대기열 테이블 건수로 넘어간다. B는 분석을 마친 뒤에 테이블 row를 지우므로, 테이블 건수에는 아직 처리 중인 작업이 포함된다. Queue가 비었다는 것과 일이 끝났다는 것은 다르다. 메트릭이 0이 되는 시점을 \u0026ldquo;Queue가 빈 때\u0026quot;가 아니라 \u0026ldquo;마지막 분석까지 끝난 때\u0026quot;로 미룬 셈이고, 이게 뒤에서 볼 Scale In이 작업을 끊지 않는 이유다.\n늘릴 때는 최소값, 줄일 때는 최대값을 본다 메트릭 하나에 Alarm 두 개를 걸었다. 둘 다 기간 5분, 데이터 포인트 2/2(10분 내 2개), 누락된 데이터는 무시(경보 상태 유지)다. 다른 건 통계와 조건이다.\nAlarm 통계 조건 연결한 조정 정책 High-RecommendationTargetCount 최소 \u0026gt;= 10 정확히 10 작업 Empty-RecommendationTargetCount 최대 \u0026lt;= 0 정확히 1 작업 Scale Out Alarm은 최소값을 보므로 5분 구간 안에서 가장 작은 값조차 10 이상이어야 한다. 잠깐 튄 값으로는 늘리지 않는다. Scale In Alarm은 반대로 최대값을 본다. 구간 안에서 가장 큰 값조차 0이어야 하니, 작업이 하나라도 남아 있던 흔적이 있으면 줄이지 않는다. 여기에 2/2 데이터 포인트가 겹쳐 두 조건 모두 10분 동안 유지돼야 움직인다. 늘릴 때도 줄일 때도, 확실할 때만 움직이도록 통계를 반대로 골랐다.\n원래 목표는 \u0026ldquo;대기열이 0이 아니면 Scale Out\u0026quot;이었지만 실제 임계값은 10이다. 작업이 몇 건 안 남았을 때 태스크 10개를 띄우는 건 낭비라서 이렇게 둔 것으로 보인다.\n작업목록 발생시 Scale Out 수행할 Alarm 작업목록 발생시 Scale In 수행할 Alarm 기본 1개는 Fargate, 늘어나는 9개는 Fargate Spot B 서비스 쪽에서는 서비스 자동 크기 조정을 켜고 최소 태스크 1개, 최대 10개로 범위를 잡았다. 조정 정책은 둘 다 단계 조정(Step Scaling)이고, 앞의 Alarm을 \u0026ldquo;기존 경보 사용\u0026quot;으로 연결했다.\nHigh-RecommendationTargetCount: 경보 조건 하한 10 ~ +infinity일 때 Set to 10 tasks\nEmpty-RecommendationTargetCount: 경보 조건 -infinity ~ 상한 0일 때 Set to 1 tasks\n휴지 기간은 둘 다 300초\n\u0026ldquo;몇 개 추가\u0026quot;가 아니라 \u0026ldquo;정확히 몇 개로\u0026quot;를 쓴 건 상태가 두 개뿐이기 때문이다. 일이 쌓여 있으면 10개, 없으면 1개다.\n비용은 용량 공급자 전략으로 줄였다. 원하는 태스크 1, 그리고 공급자 두 개를 다음처럼 뒀다.\n용량 공급자 기본(base) 가중치(weight) FARGATE 1 0 FARGATE_SPOT 0 1 base는 해당 공급자에 먼저 채울 최소 태스크 수이고, weight는 base를 채운 뒤 나머지를 나누는 비율이다. FARGATE의 weight가 0이니 첫 태스크 1개만 일반 Fargate에 뜨고, Scale Out으로 늘어나는 태스크는 전부 Fargate Spot으로 간다. 상시 도는 1개는 중단되지 않는 쪽에, 몰릴 때만 잠깐 쓰는 9개는 싼 쪽에 둔 것이다.\n10개까지 늘었다가 1개만 남았다 메트릭 송수신은 정상이었고, 대기열에 작업이 쌓이자 B 태스크가 Fargate Spot으로 늘었다. 서비스 이벤트에는 1개, 3개, 5개 순서로 모두 9개의 태스크가 시작됐다고 찍혔다. 기본 1개와 합쳐 최대치인 10개다.\n태스크 start\n작업 목록을 모두 처리해 대기열이 비자, 서비스는 실행 중이던 태스크 9개를 멈추고 Fargate 태스크 1개만 남겼다.\n태스크 stop\nScale In Alarm 그래프를 보면 흐름이 한눈에 보인다. RecommendationTargetCount는 03시 무렵 8.59k에서 시작해 08시 무렵 거의 바닥에 닿았다. 경보 상태는 In alarm에서 시작해 작업이 들어오자 OK로 바뀌었고, 대기열이 완전히 빈 뒤 다시 In alarm으로 돌아왔다.\nAlarm 모니터링\n아직 남은 문제 Spot 회수에 대한 대비가 글에 없다. 늘어난 9개는 전부 Fargate Spot이라 AWS가 회수하면 분석 도중 태스크가 멈출 수 있다. B가 Queue에서 pull한 작업을 처리 도중 잃었을 때 다시 대기열로 돌아가는지는 이 글의 범위에서 확인하지 않았다.\n상태가 두 개뿐이다. 10건 이상이면 10개, 0건이면 1개다. 1~9건이 남은 구간에서는 이미 떠 있는 태스크 수가 그대로 유지된다. 작업량에 비례해 늘리려면 단계를 더 쪼개거나 다른 조정 방식을 써야 한다.\n직접 조절이 왜 전체 교체를 일으켰는지 원인을 확정하지 못했다. 배포 설정 때문이라는 건 추정이다.\n정리 배치가 느려진 원인은 코드가 아니라 태스크 수였다. 작업은 늘었는데 B는 여전히 1개였다.\nA는 스케일링을 결정하지 않고 숫자만 보낸다. SDK로 직접 조절하면 기존 태스크까지 교체되는 문제가 있어서, 판단과 실행을 CloudWatch Alarm과 ECS Service Auto Scaling에 넘겼다.\nQueue가 비었다고 일이 끝난 건 아니다. Queue가 비면 처리 중인 작업까지 포함된 테이블 건수를 보내 Scale In 시점을 늦췄다.\nScale Out은 최소값 \u0026gt;= 10, Scale In은 최대값 \u0026lt;= 0. 두 Alarm 모두 10분 동안 조건이 유지될 때만 움직인다.\n기본 1개는 FARGATE(base 1), 늘어나는 태스크는 FARGATE_SPOT(weight 1)으로 비용을 눌렀다.\n대기열을 가장 잘 아는 쪽이 결정하는 게 맞다고 생각해서, 처음 떠올린 방법은 A가 직접 태스크 수를 바꾸는 것이었다. 결과적으로 태스크가 제대로 늘고 준 건 A가 결정을 내려놓은 뒤였다.\n참고자료 Publish custom metrics — PutMetricData로 커스텀 메트릭을 보내는 방법\nUsing Amazon CloudWatch alarms — 기간, 데이터 포인트, 누락 데이터 처리 등 Alarm 평가 방식\nAutomatically scale your Amazon ECS service — ECS Service Auto Scaling 개요와 조정 정책 종류\nAmazon ECS capacity providers for Fargate workloads — FARGATE / FARGATE_SPOT 용량 공급자와 base, weight\n","permalink":"https://jaylab.blog/cloudwatch-alarm-ecs-task-auto-scaling/","summary":"하루를 넘기던 분석 배치를 대기열 건수 메트릭과 CloudWatch Alarm으로 ECS 태스크 1~10개 사이에서 스케일링한 기록","title":"하루를 넘기던 분석 배치를 대기열 길이로 스케일아웃하기까지"},{"content":"고객사 업무를 하다 보니 MyBatis를 만지는 일이 늘었다. SQL 매핑 프레임워크의 흐름은 JPA와 Hibernate 쪽으로 기운 지 오래지만, 한국에서는 아직 MyBatis 비율이 높다. 정부 표준 프레임워크가 MyBatis를 채택한 게 한몫했다고 본다.\n이 글에서는 MyBatis로 SQL을 동적으로 만들 때 쓰는 \u0026lt;bind\u0026gt; 태그를 예제 세 개로 정리한다. 미리 말하면 앞의 두 예제는 쿼리만으로도 풀 수 있는 걸 번거롭게 만든 느낌이 있다. 실무에서 쓸모가 있는 건 세 번째다.\nbind는 SQL 앞에서 변수를 하나 만든다 \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; \u0026lt;bind\u0026gt;가 maxAge라는 변수를 만든다. 파라미터로 받은 age에 5를 더해 다섯 살 많은 사람까지 조회한다. 솔직히 이 예제는 #{age} + 5로 끝낼 일을 코드만 길게 만든 쪽에 가깝다. 그래도 방향은 보인다. 한 번 만든 변수를 쿼리 안에서 여러 번 재사용할 수 있고, 계산 로직을 SQL 본문 밖으로 빼니 쿼리도 읽기 쉬워진다.\nLIKE 패턴은 bind로 한 번만 만든다 \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; 검색 키워드 앞뒤에 %를 붙여 searchPattern을 만들고, 부분 일치 검색 두 곳에 같이 쓴다. 여기서 봐야 할 점은 \u0026lt;bind\u0026gt;의 value는 SQL이 실행되기 전에 Java 쪽에서 먼저 평가된다는 것이다. 문자열 연산만 되는 게 아니라 Java 코드를 부를 수 있다는 뜻이고, 세 번째 예제가 이걸 쓴다.\nEnum 메서드의 반환값을 SQL 안으로 끌어온다 AWS Savings Plans 구매 유형을 다루는 쿼리다. Savings Plans 적용 대상일 때 특정 구매 유형 코드를 조회에서 빼야 했다.\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; 구매 유형 코드는 이미 Enum에 정의되어 있다.\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))); } ...생략... // SQL에서 제외할 구매 유형 코드 목록을 돌려준다 public static List\u0026lt;String\u0026gt; getPurchaseCdListIncludedInSp() { return Arrays.asList(EnumPurchaseType.COMPUTE.getCd(), EnumPurchaseType.EC2_INSTANCE.getCd()); } } 흐름은 두 단계다.\n@패키지.클래스@메서드() 문법으로 com.a.b.c.common.constant.d 패키지의 EnumPurchaseType에서 getPurchaseCdListIncludedInSp를 실행하고, 반환값을 purchaseCdListIncludedInSp 변수에 담는다. 반환값은 [\u0026quot;ComputeSavingsPlans\u0026quot;, \u0026quot;EC2InstanceSavingsPlans\u0026quot;]다.\n그 리스트를 \u0026lt;foreach\u0026gt;로 풀어 NOT IN (...) 절을 만든다.\n그러면 이런 SQL이 나온다.\nAND IF(ATSP.AUTOSP_YN = \u0026#39;Y\u0026#39;, RCM.OFFR_TYP_NM NOT IN (\u0026#39;ComputeSavingsPlans\u0026#39;, \u0026#39;EC2InstanceSavingsPlans\u0026#39;), TRUE) #{item}으로 넣었으니 실제로는 ? 자리에 두 값이 바인딩된다. 위 SQL은 값이 들어간 모습을 풀어 쓴 것이다.\n\u0026lt;bind\u0026gt;의 가치는 이미 있는 Enum과 메서드를 SQL이 그대로 가져다 쓰게 하는 데 있다. 제외할 코드 두 개를 쿼리에 문자열로 적었다면, 구매 유형이 바뀔 때 Enum과 매퍼 XML을 둘 다 고쳐야 한다. 지금은 Enum 한 곳만 고치면 된다.\n","permalink":"https://jaylab.blog/mybatis-bind-tag-dynamic-sql-query/","summary":"MyBatis \u003cbind\u003e의 쓸모는 쿼리로도 되는 계산보다 Enum 메서드 반환값을 SQL로 끌어와 하드코딩을 없애는 데 있었다.","title":"MyBatis bind 태그는 Enum을 부를 때 쓸모가 있다"},{"content":"앞 글 \u0026ldquo;Nginx 한 대로 시험한 ELK를 Nginx Proxy Manager에 합치기\u0026ldquo;에서 만든 Docker ELK에 이번에는 Metricbeat를 붙였다.\n로그가 무슨 일이 있었는지를 남긴다면 Metricbeat는 서버가 지금 어떤 상태인지를 모은다. CPU 사용량, 남은 메모리, 디스크 공간 같은 지표를 주기적으로 수집해 Elasticsearch로 보내는 도구다. 컴퓨터 건강 검진을 자동으로 해주는 프로그램이라고 보면 된다.\n컨테이너로 붙이는 데 바꾼 파일 다섯 개 앞 글의 docker-compose.yml에 metricbeat 서비스를 추가했다. /var/run/docker.sock을 읽기 전용으로 마운트해서 docker 모듈이 컨테이너 지표를 읽게 한다.\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 elasticsearch/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에서는 system 모듈과 docker 모듈을 10초 주기로 켰다.\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; 마지막으로 metricbeat_internal이 쓸 롤을 setup/roles/metricbeat_writer.json에 둔다. 앞 글의 setup 서비스는 이미 METRICBEAT_INTERNAL_PASSWORD를 받고 있다.\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;] } ] } Mac에서는 컨테이너가 Mac을 보지 못한다 여기서 막혔다. Mac 환경에서는 Metricbeat 컨테이너가 Mac의 시스템 메트릭을 수집하는 데 제한이 있었다. 방법을 찾아봤지만 Mac에서 메트릭 수집을 시험하려면 결국 Metricbeat를 Mac 호스트에 직접 설치해야 했다.\nDocker Desktop은 컨테이너를 Mac 위가 아니라 Linux VM 안에서 돌린다. 그래서 컨테이너가 볼 수 있는 호스트는 Mac이 아니라 그 VM이다. 컨테이너 쪽 설정을 아무리 만져도 Mac의 CPU 사용량은 나오지 않는다.\nMac에 직접 설치하는 방법은 이렇다.\ncurl -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에는 아래 내용을 넣는다. 컨테이너용 설정보다 metricset을 넓게 잡고 CPU와 메모리 기준 상위 프로세스 5개씩을 따로 남긴다. 호스트에서 돌기 때문에 Elasticsearch와 Kibana는 localhost로 부른다.\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 실행 ./metricbeat -e Kibana에서 CPU 사용량 확인하기 http://localhost:5601에 접속한다.\n좌측 Stack Management \u0026gt; Data View \u0026gt; Create Data View\n인덱스 명: metricbeat (중요하지 않다)\n인덱스 패턴: metricbeat-* (Metricbeat가 기본으로 쓰는 인덱스 패턴)\nTime field: @timestamp\nKibana 초기 화면에서 Dashboard \u0026gt; Create dashboard \u0026gt; Add visualization으로 들어가 host.cpu.usage 필드를 차트로 끌어오면 시스템 메트릭이 수집되고 있는 게 보인다.\n리눅스 서버라고 컨테이너 구성을 그대로 쓸 수 있는 것도 아니다. 공식 문서를 보면 system 모듈이 호스트를 보려면 /proc, /sys/fs/cgroup, /를 /hostfs 아래로 마운트해야 한다. 지금 compose에는 docker.sock만 있으니 이건 다음에 다시 봐야겠다.\n","permalink":"https://jaylab.blog/docker-elk-metricbeat/","summary":"Docker ELK에 Metricbeat 컨테이너를 붙였지만 Mac 호스트 메트릭은 수집되지 않아, Mac에 직접 설치해 Kibana에서 CPU 사용량을 확인했다.","title":"Mac에서는 Metricbeat를 컨테이너 밖에 깔아야 했다"},{"content":"Docker로 ELK(Elasticsearch, Logstash, Kibana)를 띄우는 방법은 찾기 쉽다. docker-elk 저장소를 받아 docker compose up 하면 된다. 막힌 건 그다음이었다. 이미 따로 돌고 있는 애플리케이션의 Docker Compose에 ELK를 합친 사례는 찾지 못했다. 내 경우 그 애플리케이션은 Nginx Proxy Manager였고, 모으려던 로그는 그 앞에서 ModSecurity가 남기는 audit 로그였다.\n\u0026ldquo;Docker 컨테이너로 구성\u0026quot;과 \u0026ldquo;다른 애플리케이션에 결합\u0026rdquo;, 이 두 조건을 같이 만족하는 레퍼런스가 없어서 소스를 이것저것 뜯어보며 붙여야 했고, 시간이 제법 들었다. 일은 두 단계로 나눴다. 먼저 docker-elk 옆에 Nginx 컨테이너 하나를 붙여 access 로그가 Kibana까지 가는지 확인하고, 같은 구성을 Nginx Proxy Manager 쪽으로 옮겼다.\n두 단계의 핵심은 같았다. 로그를 쓰는 컨테이너와 Logstash가 같은 볼륨을 본다. 이 글에서는 테스트 구성과 통합 구성을 차례로 정리하고, 지금 다시 보니 손봐야 할 설정도 덧붙인다.\ndocker-elk 옆에 Nginx 한 대를 붙여 먼저 시험했다 출발점은 deviantony/docker-elk다.\ngit clone https://github.com/deviantony/docker-elk.git cd docker-elk 각 사용자의 비밀번호는 .env 파일에서 정한다. compose 파일의 ${ELASTIC_PASSWORD}, ${LOGSTASH_INTERNAL_PASSWORD} 같은 값이 여기서 온다.\n여기에 web(nginx:latest) 서비스를 추가했다. 기본 페이지에 접속하면 남는 access 로그를 Logstash가 읽어 Elasticsearch로 넘기는 구성이다.\nversion: \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 # 추가한 서비스: Nginx 로그 디렉토리를 web-logs 볼륨으로 내보낸다 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 # 새로운 볼륨 추가 (web과 같은 볼륨) 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: 원본에서 바뀐 곳은 web 서비스, Logstash의 web-logs 마운트, 맨 아래 web-logs 볼륨 선언 세 군데다. web과 logstash가 같은 web-logs:/var/log/nginx를 마운트한다는 점만 보면 된다.\ndocker-elk는 기본값이 trial 라이선스라서 basic으로 바꿨다.\nxpack.license.self_generated.type: trial -\u0026gt; basic Logstash 파이프라인에는 file input을 두 개 뒀다. 하나는 CSV 테스트 파일, 하나는 Nginx access 로그다.\ninput { 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 이미지는 기본 설정에서 host.access.log가 주석 처리되어 있다. 원래라면 설정 파일을 마운트해서 켜야 하지만, 시험 단계라 컨테이너에 들어가 직접 주석을 풀었다.\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 확인 순서는 이렇다.\nlocalhost:8081에 접속해 host.access.log에 access 로그가 쌓이는지 본다.\nKibana(localhost:5601) \u0026gt; Stack Management \u0026gt; Index management에서 test-input 인덱스를 찾는다.\nConsole에서 GET test-input/_search?pretty로 access 로그가 들어왔는지 본다.\n로그가 보였다. 여기까지는 금방 끝났다.\n로그는 네트워크가 아니라 볼륨으로 건넨다 통합으로 넘어가기 전에 무엇이 동작한 건지 짚어둔다. Logstash file input은 파일을 tail할 뿐이라, 애플리케이션 쪽에는 아무것도 설치하지 않아도 된다. 애플리케이션 컨테이너가 로그를 파일로 남기면 그 디렉토리를 named volume으로 만들어 Logstash 컨테이너에도 마운트한다. 이번 구성에는 Filebeat 같은 수집기를 따로 두지 않았다. Logstash와 Kibana는 같은 bridge 네트워크 안에서 서비스 이름(elasticsearch:9200)으로 Elasticsearch를 부른다.\n통합 구성에서는 흐름이 이렇게 된다.\ngraph LR NPM[\u0026#34;npm_core\u0026lt;br/\u0026gt;Nginx Proxy Manager + ModSecurity\u0026#34;] -- \u0026#34;modsec_audit.log 기록\u0026#34; --\u0026gt; V[(\u0026#34;npm_data 볼륨\u0026lt;br/\u0026gt;/data/logs\u0026#34;)] V -- \u0026#34;file input (tail)\u0026#34; --\u0026gt; LS[logstash] LS -- \u0026#34;elasticsearch:9200\u0026#34; --\u0026gt; ES[elasticsearch] KB[\u0026#34;kibana :5601\u0026#34;] --\u0026gt; ES하나 더 알아둘 것이 setup 서비스다. compose 주석에 적혀 있듯 setup은 logstash_internal, kibana_system 같은 내장 사용자와 롤을 .env의 비밀번호로 만드는 일회성 스크립트다. setup profile에 묶여 있어서 평소 docker compose up으로는 뜨지 않는다. 처음 한 번은 --profile setup을 붙여 올린다. 다시 돌리면 기존 사용자의 비밀번호와 롤이 .env 값으로 초기화된다.\n같은 구성을 Nginx Proxy Manager Compose로 옮겼다 docker-elk 디렉토리를 Nginx Proxy Manager 저장소 안 elk/로 옮기고 compose 파일은 docker/에 두었다. 테스트 구성과 비교하면 바뀐 건 대부분 경로와 이름이다.\n구분 테스트 구성 통합 구성 로그 출처 Nginx access 로그 /var/log/nginx/host.access.log ModSecurity audit 로그 /data/logs/modsec_audit.log 공유 볼륨 web-logs npm_data (이름 npm_core_data) 네트워크 elk nginx_proxy_manager (이름 npm_network) 인덱스 test-input modsecurity-audit-%{+YYYY.MM.dd} build context elasticsearch/ 등 ../elk/elasticsearch/ 등 결합 후 수정된 파일 목록 주요 파일은 세 개다. 먼저 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는 ModSecurity audit 로그 하나만 읽는다. start_position =\u0026gt; \u0026quot;end\u0026quot;라 이미 쌓인 로그는 건너뛰고 새로 붙는 줄부터 읽는다.\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에서 눈여겨볼 곳은 Logstash의 npm_data:/data:rw 한 줄이다. Nginx Proxy Manager(npm)가 쓰는 npm_data 볼륨을 Logstash도 그대로 마운트한다.\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 # Nginx Proxy Manager와 같은 볼륨 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 setup profile을 붙여 한 번에 올렸다 docker compose -f docker-compose.yml --profile setup up 몇 가지 시행착오 끝에 컨테이너가 모두 떴다. Nginx Proxy Manager 관리자 페이지(localhost:3081)에 이어 Kibana(localhost:5601)도 정상으로 열렸다.\nNginx Proxy Manager 관리자 페이지:\nhttp://localhost:3081 → ✅ 정상\nKibana 페이지:\nhttp://localhost:5601 → ✅ 정상\n→ ✅ logstash를 통해 modsec log 수집 성공 Logstash를 거쳐 ModSecurity 로그가 Elasticsearch에 쌓이는 것까지 확인했다.\nKibana에서 ModSecurity 로그를 보려면 Data View가 필요하다 모든 서비스가 뜨면 localhost:3080(추후 Nginx Proxy Manager 프록시를 거쳐갈 트래픽 대상 서비스) 접속에 대한 ModSecurity audit 로그가 Elasticsearch에 저장된다. 인덱스에 들어가 있을 뿐이니 Kibana에서 보려면 Data View를 하나 만들어야 한다.\nKibana(http://localhost:5601)에 접속한다. 초기 계정은 ID elastic, PASSWORD changeme다.\n좌측 네비게이션 \u0026gt; Stack Management \u0026gt; Data view \u0026gt; Create Data View\n인덱스 명: (아무 이름)\n인덱스 패턴: modsecurity-audit-*\n시간 필드: @timestamp\n이제 Discover 메뉴에서 http://localhost:3080 접속 로그를 보고 Visualize 메뉴에서 차트를 구성할 수 있다.\n수집은 됐지만 다시 보니 손볼 곳이 있다 돌아가는 것과 맞게 설정된 것은 다르다. 지금 설정을 다시 읽으면 걸리는 곳이 네 군데 있다.\nLogstash가 elastic 사용자에 LOGSTASH_INTERNAL_PASSWORD를 쓴다. 두 설정 모두 user =\u0026gt; \u0026quot;elastic\u0026quot;인데 비밀번호는 logstash_internal용 값이다. .env의 비밀번호가 모두 같은 초기값이라 통과한 것으로 보이는데, 비밀번호를 바꾸는 순간 깨진다. user를 logstash_internal로 맞추는 게 docker-elk의 의도다.\n테스트 구성의 sincedb_path =\u0026gt; \u0026quot;NUL\u0026quot;은 Windows의 null 장치다. 리눅스 컨테이너에서는 NUL이라는 파일이 생길 뿐이다. 읽은 위치를 저장하지 않으려던 거라면 /dev/null이 맞다.\nModSecurity audit 로그가 줄 단위로 쪼개져 들어간다. Discover 화면을 보면 ---VsiPw0KO---Z-- 같은 섹션 구분선이나 빈 message가 문서 하나씩 차지한다. audit 로그는 한 트랜잭션이 여러 줄이라 file input에 multiline codec을 걸어야 한 건으로 묶인다. 파싱도 덜 됐다. date 필터는 timestamp 필드를 찾는데 grok이 만드는 건 modsec_timestamp라서, 지금 @timestamp는 로그 시각이 아니라 수집 시각이다.\nssl_certificate_verification은 deprecated다. 실행 로그에 ssl_verification_mode로 바꾸라는 경고가 찍힌다.\n정리 ELK를 다른 애플리케이션에 붙이는 일은 결국 로그 볼륨을 같이 마운트하는 일이다. Logstash file input이 그 볼륨의 파일을 tail한다.\n작은 Nginx 테스트로 경로를 먼저 확인해 두니, 통합 단계에서 바꿀 것은 대부분 경로, 볼륨, 네트워크 이름이었다.\nsetup 서비스는 사용자와 롤을 만드는 일회성 작업이라 처음 한 번만 --profile setup으로 올린다.\n수집은 됐지만 파싱은 아직이다. multiline 처리와 날짜 필드가 다음 과제다.\n같은 조건의 레퍼런스를 찾지 못해 오래 걸린 작업이었다. 아무튼 성공이다.\n📎 Reference.\nhttps://github.com/deviantony/docker-elk\nhttps://blog.naver.com/gi_balja/223178660486\n","permalink":"https://jaylab.blog/elk-for-docker-setting/","summary":"Nginx 컨테이너로 로그 전달 경로를 먼저 확인하고, 같은 볼륨 공유 방식으로 Nginx Proxy Manager에 ELK를 합쳐 ModSecurity 로그를 Kibana에 쌓았다.","title":"Nginx 한 대로 시험한 ELK를 Nginx Proxy Manager에 합치기"},{"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.blog/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일에 끝난다. 그 전에 운영 중인 DB를 8.0으로 올려야 했다.\n준비하면서 가장 먼저 찾은 건 되돌리는 방법이었다. 최근 참석한 \u0026lsquo;Aurora MySQL 2 End of Life (EOL) 웨비나\u0026rsquo;에서도, 따로 찾아본 문서들에서도 답은 같았다. 롤백을 위한 서비스는 아직 지원되지 않는다. Blue/Green 배포는 다운타임을 줄여 줄 뿐, 되돌리는 길까지 만들어 주지는 않는다.\n이 글에서는 업그레이드 전에 알았으면 좋았을 것들을 정리한다. 두 배포 방식의 다운타임과 파라미터 그룹이 겹치는 순서, 8.0에서 달라지는 기본값 두 가지를 먼저 짚고, 직접 세운 롤백 절차와 작업이 끝난 뒤에야 보인 것들로 마무리한다.\nIn-Place도 Blue/Green도 다운타임은 있다 In-Place는 지금 돌고 있는 DB를 그대로 업그레이드한다. 단순한 대신 업그레이드하는 동안 다운타임이 생긴다.\nBlue/Green은 운영 환경(Blue)은 그대로 두고, 복제본인 스테이징 환경(Green)에서 버전을 올린 뒤 두 환경을 맞바꾼다. 무중단이 필요한 환경이라면 먼저 검토해 볼 방식이다. 그렇다고 다운타임이 아예 없지는 않고, 약 2분가량은 멈춘다. 멈추는 지점은 두 곳이다.\nBlue/Green을 만들기 전에 재부팅이 한 번 필요하다. Green은 Blue의 변경 사항을 계속 따라 받는데, 이 동기화를 위해 Aurora MySQL은 binlog_format 파라미터가 ROW로 켜져 있어야 한다. 파라미터를 바꾼 뒤 제대로 반영되게 하려면 인스턴스를 재부팅해야 하고, 여기서 첫 번째 다운타임이 생긴다.\nSwitch Over를 시작하면 쓰기가 멈춘다. Green이 만들어진 뒤로 스위칭 전까지는 Blue의 변경 사항이 Green에 그대로 동기화된다. 스위칭을 시작하면 동기화가 모두 끝날 때까지 쓰기 작업이 중단되므로 Switch Over는 트래픽이 가장 적은 시간대에 하라고 권장한다.\n구분 In-Place Blue/Green 방식 현재 DB를 직접 업그레이드 복제본(Green)을 업그레이드한 뒤 Blue와 교체 다운타임 업그레이드하는 동안 약 2분 (binlog_format 반영 재부팅, Switch Over 중 쓰기 중단) 롤백용 클러스터 업그레이드 전에 복제본을 따로 생성 Switch Over 후 남는 기존 Blue 파라미터 그룹은 네 겹으로 겹친다 메이저 업그레이드는 엔진 버전만 올리는 일이 아니다. 파라미터 그룹도 8.0용으로 바꿔야 하는데, 이 그룹은 클러스터 파라미터 그룹과 인스턴스 파라미터 그룹 두 가지로 나뉜다. 기존 5.7 DB라면 클러스터에는 5.7 클러스터 파라미터 그룹이, 인스턴스에는 5.7 인스턴스 파라미터 그룹이 붙어 있고, 둘 다 8.0 버전으로 교체해야 한다.\n헷갈리는 건 같은 파라미터가 양쪽에 다 있다는 점이다. 어느 쪽 값이 이기는지는 그 그룹이 Default냐 사용자 정의냐에 따라 달라진다. Default 파라미터 그룹은 클러스터를 만들 때 사용자 정의 그룹을 지정하지 않으면 붙는 그룹이고, 삭제할 수 없다.\n둘 다 Default면 인스턴스 파라미터 그룹이 우선한다. 클러스터 쪽 값은 무시된다.\n클러스터에 사용자 정의 클러스터 파라미터 그룹을 붙이면 그 값이 우선한다. 인스턴스 쪽에 겹치는 값은 무시된다.\n인스턴스에도 사용자 정의 인스턴스 파라미터 그룹을 붙이면 다시 그쪽이 우선한다.\nDefault Cluster PG \u0026lt; Default Instance PG \u0026lt; Custom Cluster PG \u0026lt; Custom Instance PG DB 인스턴스마다 서로 다른 설정을 줄 수 있게 설계했다고 보면 이 순서가 납득된다. 이걸 알고 나면 5.7 파라미터 그룹을 바탕으로 8.0 그룹을 어떻게 만들지 계획이 선다. 클러스터와 인스턴스 파라미터 그룹 각각에 대해 다음 순서로 확인했다.\n적용된 5.7 파라미터 그룹이 Default인지 아닌지 확인한다.\nDefault 파라미터 그룹과 비교해 값이 다른 파라미터를 찾는다. 값이 다르면 사용자가 정의한 파라미터다.\n그 파라미터가 8.0 Default 파라미터 그룹에도 있으면 사용자 정의 값으로 적용한다.\nBlue/Green으로 간다면 여기에 binlog_format을 ROW로 바꾸는 작업이 하나 더 붙는다.\n8.0에서 달라지는 두 파라미터 utf8은 utf8mb3의 별칭이고, 곧 사라진다 5.7 파라미터 그룹을 보면 문자셋 파라미터가 utf8로 잡혀 있다.\nutf8은 utf8mb3의 별칭이다. MySQL 공식 문서는 utf8(utf8mb3)을 8.0.x과 8.4.x LTS release까지만 지원하고, 이후 주요 릴리스에서 제거할 예정이라고 안내한다. 두 타입은 내부적으로 한 문자를 표현하는 데 쓰는 byte 크기가 다르다.\nutf8(utf8mb3): 3 byte\nutf8mb4: 4 byte\n어차피 사라질 문자셋이라면 메이저 업그레이드 때 같이 넘어가는 편이 낫다. 8.0 업그레이드는 utf8mb4로 설정해 테스트했고, 이상이 없는 것을 확인한 뒤 운영까지 utf8mb4로 적용했다.\nemoji나 외국어를 쓰는 서비스에는 utf8mb4가 특히 권장된다. 다만 이런 데이터가 이미 utf8 DB에 들어 있는데 업그레이드하면서 갑자기 utf8mb4로 바꾸면 문제가 생길 수 있으니, 이 경우는 충분히 테스트하고 넘어가야 한다.\nevent_scheduler는 8.0에서 켜진 채로 올라온다 event_scheduler 파라미터는 5.7과 8.0 모두 기본으로 \u0026lsquo;-\u0026rsquo;(Dynamic)로 설정되어 있지만, 실제로는 5.7에서 false(비활성), 8.0에서 true(활성)로 적용된다. 각 버전 DB에서 직접 조회해 보면 결과가 다르다.\n-- 5.7과 8.0에서 결과가 다르게 나온다 SHOW VARIABLES LIKE \u0026#39;event%\u0026#39;; -- 8.0에서는 event_scheduler Daemon이 계속 대기 중인 것이 보인다 SHOW PROCESSLIST; 롤백은 클러스터 이름을 맞바꾸는 방식으로 준비했다 롤백 서비스가 없으니 번거롭더라도 절차를 먼저 세워 두었다. 핵심은 애플리케이션이 바라보는 엔드포인트를 코드 수정 없이 이전 클러스터로 돌려놓는 것이다.\n업그레이드 전에 롤백용 클러스터를 확보한다. 일종의 백업이다. In-Place라면 업그레이드 전에 복제본을 생성한다.\nBlue/Green이라면 Switch Over 뒤 남는 기존 Blue 환경이 곧 롤백용 클러스터다. 스위칭이 끝나면 기존 Blue 클러스터명 뒤에 -old가 붙는다.\n업그레이드 후 롤백해야 한다고 판단하면 클러스터 이름을 맞바꾼다. 업그레이드된 클러스터를 A, 롤백용 클러스터를 B라고 하면 이렇다. A → A-bak # 업그레이드된 클러스터를 비켜 둔다 B → A # 롤백용 클러스터가 기존 이름, 즉 기존 엔드포인트를 넘겨받는다 Aurora의 클러스터 엔드포인트는 클러스터 이름으로 만들어진다. 이름을 넘겨받으면 엔드포인트도 함께 넘어오니 기존 A 엔드포인트를 바라보던 애플리케이션은 코드를 고치지 않고도 이전 DB로 돌아간다.\n이 방법에는 한계가 두 가지 있다.\n클러스터 이름을 바꾸는 동안 다운타임이 생긴다.\n업그레이드 후 롤백 시점까지 생긴 변경 사항은 롤백용 DB에 반영되지 않는다. 서비스 종류에 따라 다르지만 사실상 크리티컬한 문제다.\n그 사이 변경 사항까지 롤백용 DB로 동기화할 방법을 갖춰야 제대로 된 롤백이다. 이 부분은 더 찾아보려고 남겨 둔 숙제다.\n작업이 끝나고 나서 보인 것들 엔드포인트가 코드 곳곳에 하드코딩되어 있었다 작업 대상 DB와 같은 인프라 안의 애플리케이션들은 RDS 엔드포인트를 하드코딩해서 직접 바라보고 있었다. 그것도 여러 모듈의 아주 많은 코드에서였다. 프로젝트에 투입된 지 얼마 안 돼 애플리케이션 구성을 다 알지 못했고, 고쳤을 때의 영향도를 가늠할 수 없었다. 운영 DB 업그레이드 기간도 넉넉하지 않아서 인프라를 함부로 손볼 수도 없었다.\n작업 기간이 충분하고 인프라를 바꿀 수 있었다면 ELB와 PrivateLink를 구성해 트래픽을 제어하고, 사전 테스트도 더 제대로 해 봤을 것이다. 속도와 보안까지 같이 개선할 수 있었을 텐데, 이 부분은 아쉬움으로 남았다.\n고객사는 엔드포인트가 아니라 Private IP로 붙고 있었다 이번 업그레이드 대상 고객사는 자기 쪽 가상환경에서 Aurora MySQL에 연결할 때 엔드포인트가 아니라 IP를 쓰고 있었다. RDS의 IP는 동적으로 바뀐다. AWS도 IP가 아니라 엔드포인트로 DB에 연결하는 것을 모범 사례로 설명한다.\n그런데도 IP를 쓴 이유는 통신장비에서 RDS 엔드포인트 형식의 값을 쓸 수 없어서라고 했다. 콘솔에서는 확인할 수도 없는 Private IP를 아마 nslookup 같은 명령으로 찾아 써 왔던 것 같다. 지금까지는 운 좋게 IP가 한 번도 바뀌지 않았지만, 이번 업그레이드로 IP가 바뀌면서 결국 접속 문제가 생겼다. 다행히 크리티컬하지는 않았다.\n다음 업그레이드에는 체크포인트가 하나 늘었다. 고객사 쪽에서 RDS에 IP로 붙고 있지 않은지 먼저 확인하는 것이다. 그렇게 붙고 있다면 IP가 바뀔 수 있다고 미리 안내하고, 별도 프록시 서버 구성을 제안하는 것도 방법이다.\n실제로 걸린 시간 작업 소요 시간 복제본 생성 (스토리지 약 200GB, db.r6.xlarge 사이즈 Aurora MySQL) 약 10분 DB 버전 업그레이드 약 20분 재부팅 약 1분 정리 Blue/Green도 다운타임은 있다. binlog_format을 ROW로 반영하기 위한 재부팅, 그리고 Switch Over 중 쓰기 중단. 약 2분이다.\n파라미터 그룹은 Default Cluster \u0026lt; Default Instance \u0026lt; Custom Cluster \u0026lt; Custom Instance 순으로 우선한다. 5.7에서 Default와 값이 다른 것만 골라 8.0 그룹에 옮기면 된다.\n8.0은 utf8을 utf8mb4로 바꿀 기회이고, event_scheduler가 기본으로 켜진다.\n롤백은 직접 만들어야 한다. 클러스터 이름을 맞바꿔 엔드포인트를 되돌릴 수 있지만, 업그레이드 후 변경분은 따라오지 않는다.\n업그레이드 자체는 30분 남짓이었다. 정작 문제는 그 바깥, 코드에 박힌 엔드포인트와 고객사의 IP 접속에서 나왔다.\n","permalink":"https://jaylab.blog/aurora-mysql-upgrade-8-key-points/","summary":"Aurora MySQL 5.7에서 8.0으로 올리며 배포 방식별 다운타임, 파라미터 그룹 우선순위, 바뀐 기본값을 확인하고 롤백 절차를 직접 세운 기록","title":"Aurora MySQL을 8.0으로 올리기 전 알아두면 좋았을 주요 포인트 (feat. 롤백 설계)"},{"content":"Docker를 쓰다가 이런 에러를 만났다.\nexec user process caused \u0026#39;exec format error\u0026#39; 주로 운영체제 아키텍처 차이 때문에 나는 에러로, 이미지를 빌드한 머신과 실행하는 서버의 아키텍처가 다르면 이렇게 된다. M1 Mac(arm64)에서 빌드한 이미지를 amd64 서버에서 띄우려 할 때가 그렇다.\n해결책은 이미지를 실행할 곳의 아키텍처로 빌드하는 것이다. Docker Buildx는 대상 아키텍처를 지정해 이미지를 빌드할 수 있는 Docker CLI다. 이 글에서는 Buildx로 멀티 아키텍처 이미지를 빌드하는 방법과 그걸 GitHub Actions 워크플로에 넣은 설정을 정리한다.\nBuildx를 쓰기 전에 확인할 것 Docker 버전: Buildx는 Docker 19.03 이상에서 쓸 수 있다.\nDocker Desktop 사용자라면 Buildx가 기본으로 들어 있다.\n일부 하위 버전은 ~/.docker/config.json에 \u0026quot;experimental\u0026quot;: \u0026quot;enabled\u0026quot; 옵션을 추가해야 한다.\n빌더 인스턴스부터 만든다 멀티 아키텍처 이미지를 빌드하려면 먼저 새 빌더 인스턴스가 필요하다.\ndocker buildx create --name multiarch-builder --driver docker-container --use 옵션 의미 docker buildx create 새 빌더 인스턴스를 만드는 기본 명령어 --name multiarch-builder 빌더 이름. 다른 이름으로 바꿔도 된다 --driver docker-container 빌더가 쓸 드라이버. 새 Docker 컨테이너 안에서 빌드를 실행해 호스트와 격리된 환경에서 빌드한다 --use 새로 만든 빌더를 바로 현재 활성 빌더로 설정한다. 추가 명령 없이 바로 쓸 수 있다 --platform에 적은 아키텍처로 빌드된다 docker buildx build --platform=linux/amd64,linux/arm64 -t your-image:tag --push . --platform: 빌드할 이미지가 지원해야 할 아키텍처 (예: linux/amd64, linux/arm64)\n--push: 이미지를 빌드한 뒤 설정된 Docker 레지스트리로 push\n아키텍처는 빌드하는 머신이 아니라 --platform이 정한다. 두 개를 적으면 아키텍처별 이미지가 같은 태그 아래 묶여 레지스트리에 올라가고, pull하는 쪽은 자기 아키텍처에 맞는 이미지를 받는다.\n빌드된 이미지의 아키텍처는 컨테이너를 띄워서 확인한다.\n# libc6 패키지의 아키텍처 확인 (dpkg가 있는 Debian 계열 이미지) docker exec -it container-name dpkg -s libc6 | grep Arch # 컨테이너의 아키텍처 확인 docker exec [container-name] uname -m uname -m의 일반적인 결과 값은 다음과 같다.\n결과 아키텍처 x86_64 64비트 x86 (Intel 또는 AMD 프로세서) aarch64 64비트 ARM (예: Apple M1, Amazon Graviton) armv7l 32비트 ARM i686 32비트 x86 ppc64le 64비트 PowerPC (리틀 엔디안) s390x IBM System z 빌드는 GitHub Actions 러너에게 맡겼다 로컬에서도 buildx로 특정 아키텍처 이미지를 빌드해 push해 봤지만, 더 중요했던 건 GitHub Actions에 적용해 가상머신이 스스로 buildx로 Build \u0026amp; Push하게 만드는 일이었다. 누구 노트북에서 빌드하느냐에 따라 이미지 아키텍처가 달라지는 일을 없애려면 빌드하는 곳을 한 군데로 모으는 게 가장 확실하다.\n실무에 적용한 워크플로 구성 파일 일부다. 앞의 build job이 만든 jar를 받아 이미지로 굽고 ECR에 올린다.\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 ..(생략).. 로컬에서 치던 명령과 대응시키면 이렇다. docker/setup-buildx-action이 docker buildx create --use를, docker/build-push-action의 platforms와 push: true가 --platform과 --push를 대신한다. 태그에는 브랜치 이름과 커밋 SHA를 붙여 어떤 커밋에서 나온 이미지인지 바로 알 수 있게 했다.\n이 워크플로는 linux/amd64 하나만 빌드한다. arm64까지 함께 빌드하려면 Buildx 설정 앞에 docker/setup-qemu-action을 추가해 에뮬레이션을 켜야 한다.\n","permalink":"https://jaylab.blog/docker-buildx-multiarch-and-git-actions/","summary":"아키텍처 차이로 생긴 exec format error를 Docker Buildx의 \u0026ndash;platform으로 풀고, GitHub Actions에서 buildx로 빌드해 ECR에 푸시하도록 옮긴 기록","title":"exec format error 이후로 이미지는 GitHub Actions에서 빌드한다"},{"content":"Python 프로젝트의 패키지는 보통 requirements.txt로 관리한다. 필요한 패키지 이름과 버전을 한 줄씩 적어 두는 파일이다. 문제는 그게 전부라는 점이다. 개발할 때만 필요한 패키지와 실제 배포에 필요한 패키지가 한 목록에 섞이고, 프로젝트가 커질수록 어느 줄이 왜 들어 있는지 구분하기 어려워진다. 서로 다른 패키지가 같은 라이브러리의 다른 버전을 요구하면 의존성 충돌도 생긴다.\nPoetry는 의존성 관리, 가상 환경, 프로젝트 시작을 한 도구로 묶는다. 이 글에서는 Poetry가 requirements.txt의 어떤 부분을 대신하는지 짚고 자주 쓰는 명령어를 정리한다.\n(이 글을 쓴 뒤로 명령어가 몇 개 바뀐 것으로 보인다)\n파일 두 개가 목록 하나를 대신한다 Poetry는 requirements.txt 대신 파일 두 개를 쓴다.\npyproject.toml: 프로젝트 메타데이터와 의존성을 한 곳에 적는다. 개발용 패키지와 배포용 패키지도 여기서 나뉜다.\npoetry.lock: 의존성을 풀어서 나온 정확한 버전을 기록한다. 팀원이 같은 lock 파일로 설치하면 환경 차이가 줄어든다.\n사람이 적는 목록과 기계가 적는 기록을 분리한 것이 핵심이다. pyproject.toml에는 내가 원하는 패키지와 버전 범위를 적고, 그 범위를 풀어서 나온 정확한 결과는 poetry.lock이 갖는다. requirements.txt 하나로는 이 둘을 동시에 표현할 수 없다.\n설치와 프로젝트 시작 Mac OS 기준으로 터미널에서 설치한다.\ncurl -sSL https://install.python-poetry.org | python3 - Windows에서는 PowerShell로 설치한다. 설치가 끝나면 쉘을 재시작하거나 설치 스크립트가 안내하는 명령어를 실행해야 poetry가 잡힌다.\n(Invoke-WebRequest -Uri https://install.python-poetry.org -UseBasicParsing).Content | py - Poetry 자체를 최신 버전으로 올릴 때는 이렇게 한다.\npoetry self update 프로젝트를 시작하는 방법은 두 가지다. 이미 있는 디렉토리에 pyproject.toml을 만들려면 init, 새 디렉토리와 기본 구조까지 만들려면 new를 쓴다.\n# 기존 디렉토리 초기화 poetry init # 새 디렉토리와 기본 구조 자동 생성 poetry new 프로젝트이름 패키지 추가와 삭제 # 패키지 추가 poetry add 패키지명 # 개발 의존성으로 추가 poetry add --dev 패키지명 # 특정 버전 패키지 추가 poetry add requests@2.25.0 # 패키지 삭제 poetry remove 패키지명 --dev는 Poetry 1.2부터 deprecated되었고, 지금은 poetry add --group dev 패키지명이 같은 역할을 한다. 개발용 패키지는 pyproject.toml의 [tool.poetry.group.dev.dependencies] 아래로 들어간다.\n가상 환경, 내보내기, 배포 가상 환경을 켜고 끄는 명령어다.\n# 가상 환경 활성화 poetry shell # 비활성화 deactivate poetry shell은 가상 환경이 활성화된 새 쉘을 띄운다. deactivate는 가상 환경만 끄고 그 쉘에 남으니, 쉘까지 빠져나오려면 exit를 쓴다. 참고로 Poetry 2.0부터 shell 명령은 기본 설치에서 빠졌다. poetry-plugin-shell 플러그인을 추가하거나 eval $(poetry env activate)로 지금 쉘에서 가상 환경을 켠다.\n배포 환경이 아직 requirements.txt를 요구하면 의존성을 내보낼 수 있다. 이 명령도 Poetry 2.0부터는 poetry-plugin-export 플러그인이 있어야 한다.\npoetry export -f requirements.txt --output requirements.txt 패키지를 빌드하고 PyPI에 배포하는 명령어도 Poetry 안에 있다.\n# 빌드 poetry build # PyPI에 배포 poetry publish 정리 requirements.txt가 불편했던 건 파일 형식보다 한 파일이 너무 많은 역할을 떠안고 있어서였다. Poetry는 그걸 pyproject.toml과 poetry.lock으로 나눴다.\n🔗참고링크: https://teddylee777.github.io/poetry/poetry-tutorial/ ","permalink":"https://jaylab.blog/python-poetry-basic/","summary":"개발용과 배포용 패키지가 섞이는 requirements.txt 대신 Poetry로 의존성과 가상 환경을 관리하는 명령어를 정리했다.","title":"requirements.txt 대신 Poetry로 의존성 관리"},{"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.blog/argocd-index-lock-error/","summary":"ArgoCD Application 생성이 git index.lock 파일 추돌로 실패했을 때 설치 매니포스트 재적용으로 넘긴 기록","title":"[ArgoCD] Error: Unable to create ‘..git/index.lock’: File exists"},{"content":"이미지를 ECR에 올리는 것까지는 워크플로 몇 줄이면 된다. 그런데 이미지가 올라갔다고 클러스터가 바뀌지는 않는다. ArgoCD가 지켜보는 건 레지스트리가 아니라 Kubernetes manifest가 들어 있는 별도의 GitHub 저장소이기 때문이다.\nℹ️ArgoCD의 Image Updater라는 것을 사용하면 컨테이너 레지스트리의 이미지 변경을 감지하여 또 다른 방식으로 자동 배포 아키텍쳐를 구현해볼 수도 있다.\nSaaS 프로젝트를 하게되면서 실제로 사용해본 경험으로는 분명 유용한 기능이었지만 몇가지 아쉬운 부분도 있었다.\nGit Push 충돌이 발생하기 시작. 이에 대한 조치 방법도 있었지만, 그 당시 조치는 보류. 어쨌든 공수가 더 필요한 상황이었고, 만약 이대로 놔두면 서비스 규모가 커지고 협업 인원이 많아질수록 발생 빈도도 높아질 것이 예상되었음\n기본적인 설정 및 아키텍쳐로 구현했을 때에는 테넌트 최초 온보딩시에는 manifest에 설정된 버전으로 최초 배포 후 이미지 업데이터가 다시 새로운 신규 이미지를 찾아 업데이트하게되는 불필요한 흐름으로 구현되었다. 이 또한 조치하긴 했으나 결국 추가적인 공수가 발생된 포인트.\nJava 애플리케이션 Repo의 GitHub Actions에 필요한 일은 세 가지였다.\n애플리케이션을 Docker 이미지로 빌드한 뒤 ECR Private repository에 푸시\n애플리케이션 저장소와는 별개인 manifest 저장소에서 특정 파일의 image tag를 갱신\nmanifest 파일이 바뀌면 ArgoCD가 그걸 감지해 Kubernetes 클러스터에 동기화\n배포를 일으키는 건 이미지 푸시가 아니라 manifest 저장소에 들어가는 커밋 하나. 이 글에서는 그 커밋을 워크플로 마지막 job에서 만드는 방법을 중심으로, Docker Hub에 올리던 이전 설정과 멀티 플랫폼 빌드까지 정리한다.\nflowchart LR A[Repository develop push] --\u0026gt; B[build: Maven 패키징] B --\u0026gt; C[docker: 이미지 빌드 후 ECR 푸시] C --\u0026gt; D[update-deployment: manifest 저장소 이미지 태그 커밋] D --\u0026gt; E[ArgoCD 변경 감지] E --\u0026gt; F[Kubernetes Cluster Sync] 세 개의 job이 결과물을 차례로 넘긴다 아래는 Spring Boot 애플리케이션 저장소의 .github/workflows/action.yml\nGitHub 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 # 다음 job으로 jar를 넘긴다 - 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에 푸시. 태그는 \u0026lt;브랜치\u0026gt;-\u0026lt;커밋 SHA\u0026gt; - 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: # 앱 저장소가 아니라 manifest 저장소를 checkout한다 - 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_NAME }} git config user.email ${{ vars.SAAS_GITHUB_EMAIL }} 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 job은 needs로 이어져 있다. build가 만든 jar를 artifact로 올리면 docker가 내려받아 이미지로 굽고, docker가 끝나야 update-deployment가 실행된다. 이미지 태그를 develop-\u0026lt;커밋 SHA\u0026gt;로 붙인 덕분에, manifest 저장소 커밋만 보고도 어떤 앱 커밋이 배포됐는지 알 수 있다.\n다른 저장소를 고치려면 토큰이 따로 필요하다 핵심은 update-deployment job.\nactions/checkout은 기본으로 워크플로가 도는 저장소를 받는다. repository에 manifest 저장소를 적고, 워크플로 기본 토큰은 다른 저장소에 쓸 권한이 없어서 token에 그 저장소에 푸시할 수 있는 토큰을 따로 넣어야 한다. 여기서는 SAAS_GITHUB_TOKEN secret이 그 역할이다. ref로 develop 브랜치를 받는다.\n파일 수정은 sed 한 줄이다. kube/develop/saas-oil.yaml에서 \u0026lt;ECR URL\u0026gt;/로 시작하는 부분부터 줄 끝까지를 새 이미지 주소로 통째로 바꾼다. 구분자를 / 대신 |로 쓴 건 이미지 주소 안의 /와 부딪히지 않게 하려는 것이다. 커밋 작성자를 설정한 뒤 바뀐 파일만 add해 커밋·푸시한다. 이 푸시가 ArgoCD가 기다리던 변경이다.\n주의할 점 이 워크플로는 PR에도 돈다.. on에 push와 pull_request가 둘 다 있어서, develop을 향한 PR이 열리기만 해도 이미지가 푸시되고 manifest 태그가 바뀐다. 머지 전 코드가 develop 환경에 배포될 수 있다. PR에서는 build만 돌리고 나머지 job에 if: github.event_name == 'push'를 거는 식으로 나누는 편이 안전하다.\nmanifest 저장소 토큰은 쓰기 권한을 가진 별도 secret이다. 앱 저장소 쪽 권한 설정(permissions)과는 따로 관리한다.\nDocker Hub Private repo에 올리던 이전 설정 ECR로 옮기기 전에는 Docker Hub Private repo에 푸시했다. 그때 쓰던 docker job 일부다.\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에서 발급한다.\nECR은 OIDC로 역할을 받아 로그인하니 장기 자격 증명을 secret에 넣어 둘 필요가 없다. 반면 Docker Hub는 토큰을 secret으로 들고 있어야 한다.\n빌드와 푸시를 한 번에, 여러 아키텍처로 위 설정은 모두 docker build와 docker push를 따로 부른다. buildx를 쓰면 한 명령으로 끝난다.\ndocker buildx build --platform=linux/amd64,linux/arm64 -t YOUR_DOCKERHUB_ID/YOUR_TAG_NAME . --push 빌드 명령 뒤에 --push를 붙이면 별도 push 없이 빌드와 동시에 이미지를 푸시한다. --platform은 amd64와 arm64 양쪽에서 쓸 수 있는 이미지로 빌드하라는 뜻\n아키텍처가 왜 문제가 되냐면, runs-on: ubuntu-latest 러너에서 docker build로 만든 이미지는 기본적으로 amd64용이다. 이미지를 받는 컴퓨터가 x86-64라는 전제가 깔린 셈이다. 이 이미지를 로컬 테스트하겠다고 Apple Silicon 맥북에서 pull해 쓰면 exec format error를 볼 수 있다. Docker Desktop은 에뮬레이션으로 amd64 이미지를 띄워 주기도 해서, 환경에 따라서는 플랫폼 불일치 경고만 나오고 돌아갈 수도 있다.\n맥에서도 쓸 이미지라면 arm64까지 함께 빌드하면 된다. 워크플로에서는 docker/setup-buildx-action과 docker/build-push-action으로 바꾼다.\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 Docker 공식 예제는 Buildx 설정 앞에 docker/setup-qemu-action을 둔다. amd64 러너에서 arm64 이미지를 빌드하다 Dockerfile의 RUN 단계를 실행하려면 QEMU 에뮬레이션이 필요하기 때문.\n","permalink":"https://jaylab.blog/git-actions-build-push-update/","summary":"ECR에 올린 새 이미지를 ArgoCD가 알게 하려고, GitHub Actions 마지막 job에서 별도 manifest 저장소의 이미지 태그를 sed로 바꿔 커밋하게 만든 설정","title":"Repository Workflow를 활용한 manifest 업데이트 아키텍쳐"},{"content":"몇 번이고 잘 돌던 코드였다. Terraform으로 MySQL DB Instance를 만드는 평범한 main.tf인데, 이날은 apply를 할 때마다 같은 에러를 뱉었다.\naws_db_instance.example: Creating... │ 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 \u0026#34;aws_db_instance\u0026#34; \u0026#34;example\u0026#34;: │ 25: resource \u0026#34;aws_db_instance\u0026#34; \u0026#34;example\u0026#34; { 로그에 찍힌 vpc-05819890763cddf88은 이미 존재하지 않는 VPC였다. 이 글에서는 이 참조를 떼어 내려고 해 본 일들과 결국 먹힌 방법 하나를 정리한다.\n사라진 Default VPC의 흔적 짐작 가는 데가 있었다. 로그의 VPC ID는 팀원 중 한 분이 지운 기존 Default VPC로 보인다. 이날 콘솔을 보니 Default VPC가 없어서 내가 직접 새로 만들었는데, 아마 그때부터 존재하지 않는 VPC를 참조하게 된 것 같다.\nVPC는 새로 생겼는데 RDS는 여전히 옛 ID를 찾는다. 처음에는 Terraform이 이 변화를 모르고 state와 꼬였다고 생각했다.\nstate를 전부 지워도 에러는 그대로였다 Terraform 쪽에서 지울 수 있는 건 다 지웠다.\n모든 리소스 destroy\nstate 파일 삭제\n.terraform 디렉터리 삭제\n백엔드 상태 파일 저장소였던 S3 버킷까지 삭제\n더 할 수 있는 조치가 없었는데도 apply 결과는 똑같았다. 같은 문제를 겪은 사람들의 글도 여럿 찾았지만 명쾌한 답은 없었다.\ndb_subnet_group_name을 명시하니 풀렸다 긴 삽질 끝에 혼자 찾은 방법은 aws_db_instance에 db_subnet_group_name을 명시적으로 넣는 것이었다. Default VPC와 그 서브넷을 data로 읽어 DB subnet group을 직접 만들고, 인스턴스가 그 그룹을 쓰게 한다.\n# 지금 있는 Default VPC를 조회한다 data \u0026#34;aws_vpc\u0026#34; \u0026#34;default\u0026#34; { default = true } # 그 VPC에 속한 서브넷 목록 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] } } # RDS가 쓸 subnet group을 직접 만든다 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; ... } 범인은 state가 아니라 AWS 쪽 default subnet group이었다 당시에는 \u0026ldquo;이게 사람들이 말하는 Terraform의 미흡한 부분인가\u0026rdquo; 하고 넘어갔다. 글로 옮기며 에러 메시지를 다시 읽어 보니 답이 거기 있었다. 문제의 VPC는 \u0026ldquo;associated with the default subnet group\u0026rdquo;, 즉 RDS의 기본 DB subnet group에 묶여 있다고 적혀 있다.\ndb_subnet_group_name을 비워 두면 RDS는 인스턴스를 기본 DB subnet group에 만든다. 그 기본 subnet group은 Terraform이 만든 리소스가 아니라 AWS 계정 쪽에 남아 있는 리소스다. Default VPC가 지워진 뒤에도 subnet group은 옛 VPC ID를 든 채 남았고, Default VPC를 새로 만들어도 그 연결은 바뀌지 않았다. state 파일에는 애초에 이 subnet group이 없으니 state를 아무리 지워도 소용이 없었던 것이다.\n에러 메시지가 제시한 해결책도 두 가지다. default subnet group을 지우거나, 다른 subnet group을 지정하거나. 나는 두 번째를 골랐다. 기본값에 기대지 않고 어떤 VPC에 DB를 둘지 코드에 적어 두는 쪽이라 다음에 같은 일이 생겨도 영향을 받지 않는다.\n같은 이슈가 GitHub에도 있었다 문제를 해결하고 기록하려고 정리하다 보니 같은 증상을 다룬 이슈가 하나 더 보였다. 거기서 말하는 해결 방법도 내가 한 것과 같았다. 지금으로서는 subnet group을 명시하는 게 최선인 듯하다.\nhashicorp/terraform-provider-aws#4318 ","permalink":"https://jaylab.blog/terraform-error-create-intance-by-old-vpc-id/","summary":"잘 돌던 RDS 생성 코드가 사라진 VPC를 참조하며 실패했고, state를 다 지워도 소용없다가 db_subnet_group_name을 명시해 해결했다.","title":"Terraform으로 만든 RDS가 삭제된 VPC를 계속 찾던 이유"},{"content":"애플리케이션 빌드와 이미지 푸시는 GitHub Actions로 해결했다. 남은 건 새 이미지를 클러스터에 반영하는 일이다. 매번 kubectl apply를 손으로 치는 대신, Kubernetes manifest를 담은 Git 저장소를 기준으로 두고 ArgoCD가 그 저장소를 지켜보다가 바뀌면 클러스터를 맞추게 하고 싶었다.\n우선 로컬 Minikube에서 구성해 봤다. 이 글에서는 ArgoCD 설치부터 저장소 연결과 Application 생성을 거쳐 자동 동기화 확인까지 정리한다.\n실습 환경은 이렇다.\nMac Apple Silicon\n로컬 Minikube\n배포할 Java 애플리케이션: Kafka 테스트 앱. Deployment 4개, Service 4개 (zookeeper, kafka, kafka-ui, spring-boot-app)\n개발자는 Git에 올리고, 클러스터 반영은 ArgoCD가 한다 Developer \u0026amp; Engineer 관점의 ArgoCD 활용 플로우차트.\n클러스터에 무엇을 띄울지는 manifest 저장소가 정하고, ArgoCD는 그 상태를 따라간다. GitHub Actions는 이미지를 만들어 올리는 데서 역할이 끝나고, 클러스터를 직접 건드리지 않는다.\nArgoCD 설치와 NodePort 전환 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 # HA 구성 (노드 여러 개를 전제로 한다). 둘 중 하나만 적용한다 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 확인 brew install argocd는 로컬에 CLI를 까는 명령이고, 클러스터에 ArgoCD를 올리는 건 그 아래 kubectl apply다. 단일 노드인 Minikube에는 일반 install.yaml이면 충분하다.\nMinikube에서는 NodePort만으로 대시보드에 닿지 않는다 서비스를 NodePort로 바꿨는데도 브라우저에서 접속되지 않았다. Minikube에서는 ClusterIP든 NodePort든 그대로는 접근할 수 없고 터널링이 필요하다.\n사용법은 minikube service \u0026lt;service name\u0026gt; -n \u0026lt;namespace\u0026gt;다.\n~ » minikube service argocd-server -n argocd |-----------|---------------|-------------|---------------------------| | 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. 출력의 마지막 줄이 이유를 말해 준다. macOS에서 Docker 드라이버로 띄운 Minikube 노드는 Docker 네트워크 안에 있어서, 호스트는 192.168.49.2에 직접 닿지 못한다. 그래서 minikube service가 127.0.0.1의 임의 포트로 터널을 열어 주고, 그 터미널을 열어 둔 동안만 접속된다.\n초기 비밀번호 꺼내기 터널로 열린 URL에 접속하면 로그인 화면이 나온다.\nID는 기본값 admin이고, 비밀번호는 아래 명령으로 꺼낸다.\nkubectl -n argocd get secret argocd-initial-admin-secret -o jsonpath=\u0026#34;{.data.password}\u0026#34; | base64 -d; echo 지켜볼 저장소부터 등록한다 여기서 연결하는 GitHub 저장소는 ArgoCD가 관찰(observe)할 대상이다. 이 저장소의 manifest 파일이 바뀌면 ArgoCD가 그 내용을 K8S 클러스터에 반영한다.\nApplication은 저장소 경로와 클러스터를 잇는 한 줄이다 Application을 만들 때는 대상 K8S 클러스터와 앞에서 등록한 GitHub 저장소 정보를 입력한다.\n항목 설정 Application Name 임의 지정 Project Name 임의 지정 Sync Policy Automatic SOURCE \u0026gt; Repository URL 앞에서 등록한 GitHub 저장소 SOURCE \u0026gt; Revision ArgoCD가 모니터링할 브랜치 SOURCE \u0026gt; Path ArgoCD가 모니터링할 manifest 파일이 있는 폴더 경로 DESTINATION \u0026gt; Cluster URL 변경사항을 배포할 클러스터. Minikube에서 테스트 중이라 https://kubernetes.default.svc DESTINATION \u0026gt; Namespace 배포 목적지 네임스페이스 SOURCE의 세 칸(저장소, 브랜치, 경로)이 \u0026ldquo;무엇을\u0026quot;이고 DESTINATION의 두 칸이 \u0026ldquo;어디에\u0026quot;다. https://kubernetes.default.svc는 클러스터 안에서 자기 API 서버를 가리키는 주소라서, ArgoCD가 설치된 바로 그 Minikube 클러스터에 배포하겠다는 뜻이 된다. Sync Policy를 Automatic으로 두어야 저장소가 바뀔 때 사람이 Sync 버튼을 누르지 않아도 된다.\n저장소에 있는 그대로 클러스터가 섰다 연결된 GitHub 저장소의 manifest 파일을 ArgoCD가 참조해 구성한 Kubernetes 환경이 보인다. 이 상태에서 저장소 파일이 바뀌면 클러스터도 그에 맞춰 동기화된다.\n정리 빌드는 GitHub Actions, 클러스터 반영은 ArgoCD로 나눴다. 기준은 manifest 저장소다.\nmacOS의 Docker 드라이버 Minikube는 NodePort로 바로 닿지 않는다. minikube service로 터널을 열어 둔다.\nApplication은 저장소·브랜치·경로를 클러스터·네임스페이스에 잇는 설정이고, Sync Policy를 Automatic으로 둬야 자동으로 따라간다.\n남은 연결 고리는 새 이미지가 나올 때 manifest 저장소의 이미지 태그를 누가 바꾸느냐다. 그 부분은 GitHub Actions 워크플로에서 다른 저장소 파일을 고치는 방식으로 이어서 정리했다.\n","permalink":"https://jaylab.blog/git-actions-argocd-cicd-minikube/","summary":"빌드 다음 단계인 클러스터 반영을 ArgoCD에 맡기려고 로컬 Minikube에 설치하고, manifest 저장소 변경이 자동으로 동기화되는 것까지 확인한 기록","title":"Kubernetes 환경에 ArgoCD 활용 Git Repo CI/CD 아키텍쳐"},{"content":"ArgoCD로 로컬 Minikube 클러스터에 앱이 배포되도록 구성해 두었고, 여기까지는 Docker Hub의 public 이미지를 받아 쓰고 있었다. 이미지를 AWS ECR로 옮기자 Pod가 뜨지 않았다.\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 no basic auth credentials, 인증 정보가 없다는 말이다. public 이미지는 인증 없이 받을 수 있지만 ECR은 private 레지스트리라서 받는 쪽이 자격증명을 들고 가야 한다. 이 글에서는 그 자격증명을 누가 들고 있어야 했는지, 처음 시도는 왜 소용없었는지 정리한다.\n공식 문서의 docker login으로는 바뀌지 않았다 AWS 공식 문서가 안내하는 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 이 명령을 써도 에러 내용은 그대로였다. 처음 찾은 방법은 이 명령으로 ~/.docker/config.json에 자격증명이 기록되니, 그 파일로 Kubernetes Secret을 만들라는 것이었다. 파일에는 새로 기록된 내용 자체가 없었다.\n돌아보면 두 가지를 섞어 보고 있었다. docker login은 내 터미널의 Docker에 로그인하는 것이지 Minikube에 로그인하는 것이 아니다. 이미지를 받는 주체는 Minikube 노드 안의 컨테이너 런타임이고, 그쪽은 로컬 터미널의 로그인 상태를 모른다. Kubernetes에서 private 레지스트리 자격증명은 docker-registry 타입 Secret으로 클러스터 안에 넣고 Pod가 그 Secret을 참조하게 해야 한다.\ngraph LR A[로컬 터미널 docker login] -.-\u0026gt;|전달되지 않음| C[Minikube 노드] S[Secret ecr-registry] --\u0026gt;|imagePullSecrets| C B[ArgoCD] --\u0026gt;|Deployment 적용| C C --\u0026gt;|이미지 pull| E[AWS ECR] 파일에 내용이 비어 있던 이유는 확인하지 못했다. Docker Desktop처럼 자격증명을 config.json 대신 OS 키체인에 넣는 credential store를 쓰면 파일에는 인증 정보가 남지 않는데, 그 경우였을 수 있다.\ndocker-registry Secret을 직접 만들었다 두 번째로 찾은 방법도 Secret을 만드는 것은 같지만, 파일을 거치지 않고 바로 만든다.\nkubectl 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 재시작 kubectl create secret docker-registry: 레지스트리 자격증명을 Kubernetes Secret으로 만든다.\n--docker-server: ECR 레지스트리의 베이스 URL만 넣는다. 개별 이미지 이름이나 레포지토리 경로(/saas 같은)는 붙이지 않는다.\n--docker-username=AWS: ECR 토큰 인증의 사용자 이름은 항상 AWS다.\n--docker-password=$(aws ecr get-login-password): 서브쉘로 ECR 로그인 패스워드를 받아 바로 넘긴다. 파일에 기록될 필요가 없어서 앞의 문제를 피해 간다.\n--docker-email: 사용자 이메일 주소. Secret 생성에 꼭 필요하지는 않지만 오류를 피하려고 넣을 수 있다.\nSecret을 만든 뒤 Deployment를 재시작하자 이미지를 받아 왔다.\n이 Secret은 12시간 뒤에 만료된다 aws ecr get-login-password가 돌려주는 토큰은 12시간 동안만 유효하다. Secret 안의 비밀번호도 그 토큰이라서, 시간이 지나 Pod가 다시 이미지를 받아야 할 때 같은 에러가 다시 날 수 있다. 로컬 실습이라면 그때 Secret을 다시 만들면 되지만, 오래 두는 클러스터라면 토큰 갱신을 자동화할 방법을 따로 찾아야 한다. 이건 다음에 다시 봐야겠다.\n","permalink":"https://jaylab.blog/argocd-ecr-image-pull-error/","summary":"ArgoCD로 배포하던 Minikube 앱의 이미지를 ECR로 옮기자 no basic auth credentials가 났고, docker-registry Secret을 만들어 해결했다.","title":"docker login을 해도 Minikube는 ECR 이미지를 받지 못했다"},{"content":"트레이싱 의존성 두 개가 들어간 pom.xml을 IntelliJ에서 Maven Sync했더니 Resolve dependencies에서 에러가 났다. 메시지는 이렇다.\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 Try to run Maven import with -U flag (force update snapshots) opentelemetry-exporter-zipkin과 micrometer-tracing-bridge-otel 둘 다 resolve되지 않았다. 메시지에서 눈여겨볼 단서는 IDE가 권하는 -U와 좌표 끝의 jar:unknown, 두 가지다.\n실패가 캐시되어 있으면 -U로 다시 받는다 메시지 뒷부분을 보면 Maven은 이전에 이 의존성을 찾다 실패한 기록을 로컬 저장소에 캐시해 두고, central의 업데이트 주기가 지나거나 강제로 업데이트하기 전까지 다시 시도하지 않는다. 한 번 실패하면 네트워크가 돌아와도, 설정을 고쳐도 같은 에러가 반복될 수 있다는 뜻이다.\nMaven 명령어에 -U 플래그를 붙이면 로컬 캐시를 무시하고 원격 저장소에서 다시 받아 온다.\nmvn clean install -U IntelliJ에서는 에러 메시지 아래 Try to run Maven import with -U flag 링크를 누르면 같은 동작을 한다. 링크 문구는 스냅샷 강제 업데이트라고 되어 있지만, -U는 스냅샷 갱신과 함께 없던 릴리스도 원격 저장소에서 다시 확인한다.\nunknown은 버전이 비어 있다는 뜻이다 좌표의 버전 자리에 unknown이 찍혀 있으면, 저장소를 몇 번 다시 뒤져도 찾을 대상이 정해지지 않은 것이다. 이럴 때는 pom.xml의 해당 의존성에 \u0026lt;version\u0026gt; 태그가 있는지부터 본다. Maven 중앙 저장소에서 받으려면 구체적인 버전이 필요하다.\n수정 전에는 두 의존성 모두 버전이 없었다.\n\u0026lt;!-- 수정 전 --\u0026gt; \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; 버전을 적어 넣었다.\n\u0026lt;!-- 수정 후 --\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; 맞는 버전은 Maven Central에서 artifactId로 검색해 찾았다.\n메시지가 길어서 IDE가 띄워 주는 -U 링크부터 누르게 된다. 그런데 버전이 비어 있으면 캐시는 원인이 아니라 결과다. 좌표에 unknown이 보이면 pom.xml의 \u0026lt;version\u0026gt;부터 확인하는 게 빠르다.\n","permalink":"https://jaylab.blog/maven-build-error-not-found/","summary":"IntelliJ Maven Sync가 jar:unknown 의존성을 못 찾을 때 -U 강제 업데이트와 pom.xml 버전 명시로 푼 기록.","title":"Maven import 에러에서 먼저 볼 것은 버전이다"},{"content":"프록시 서버를 구성하면서 JVM 옵션에 프록시 서버 정보(IP, PORT)를 넣었다. 작업 환경은 Spring Framework 4.3.7이다.\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; JVM 전체에 거는 옵션이니 애플리케이션에서 나가는 HTTP 요청은 전부 프록시를 탈 것 같다. 그렇지 않다. 이 옵션을 읽는 건 JDK의 네트워크 스택이고, HTTP 클라이언트가 그 스택 위에 있느냐에 따라 결과가 갈린다. 이 글에서는 자주 쓰는 클라이언트 네 가지가 이 옵션을 따르는지 정리한다.\nHttpURLConnection은 시스템 프로퍼티를 그대로 따른다 HttpURLConnection은 JDK에 들어 있는 클래스다. 연결할 때 JVM 시스템 프로퍼티에서 프록시 설정을 가져오므로 -Dhttp.proxyHost와 -Dhttp.proxyPort로 지정한 프록시가 코드 수정 없이 자동으로 적용된다.\nURL url = new URL(\u0026#34;http://example.com\u0026#34;); // 별도 설정 없이도 -Dhttp.proxyHost/-Dhttp.proxyPort 프록시를 거친다 HttpURLConnection conn = (HttpURLConnection) url.openConnection(); Apache HttpClient는 기본적으로 무시한다 여기서 말하는 HttpClient는 JDK 클래스가 아니라 Apache HttpComponents 라이브러리의 클라이언트다. 자체 연결 계층을 쓰기 때문에 기본적으로 JVM 시스템 프로퍼티를 무시한다. 프록시를 쓰려면 빌더에서 직접 지정해야 한다.\nHttpClient httpClient = HttpClients.custom() .setDefaultRequestConfig(requestConfig) // JVM 옵션을 읽지 않으니 프록시를 코드에 명시한다 .setProxy(new HttpHost(\u0026#34;{프록시 서버 아이피}\u0026#34;, {프록시 서버 포트})) .build(); 프록시 주소를 코드에 두 번 적기 싫다면 HttpClientBuilder의 useSystemProperties()를 켜서 시스템 프로퍼티를 읽게 하는 방법도 있다.\nRestTemplate은 따르고, WebClient는 따르지 않는다 Spring이 HTTP 통신을 쉽게 하도록 감싼 RestTemplate과 WebClient도 결국 아래에 어떤 클라이언트가 있느냐로 갈린다.\nRestTemplate은 기본 설정에서 내부적으로 HttpURLConnection을 쓰므로 JVM 시스템 프로퍼티의 프록시 설정을 그대로 따른다.\nRestTemplate restTemplate = new RestTemplate(); // 기본 요청 팩토리가 HttpURLConnection이므로 JVM 프록시 옵션이 적용된다 String result = restTemplate.getForObject(\u0026#34;http://example.com\u0026#34;, String.class); WebClient는 HTTP 요청을 Reactor Netty 라이브러리로 처리한다. 이쪽은 JVM 시스템 프로퍼티의 프록시 설정을 무시하므로 따로 설정해야 한다.\n// 여기서 HttpClient는 Apache가 아니라 Reactor Netty의 HttpClient다 HttpClient 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(); 참고로 WebClient는 Spring 5부터 들어온 클래스라 이 글의 작업 환경인 Spring 4.3.7에서는 쓸 수 없다. 버전을 올릴 때를 위해 같이 적어 둔다.\n정리 클라이언트 내부 구현 JVM 프록시 옵션 HttpURLConnection JDK 따른다 Apache HttpClient Apache HttpComponents 무시한다 (코드에서 setProxy) RestTemplate (기본 설정) HttpURLConnection 따른다 WebClient Reactor Netty 무시한다 (코드에서 proxy) JVM 옵션에 프록시를 넣었는데 일부 요청만 프록시를 타지 않는다면, 그 요청을 보내는 클라이언트의 내부 구현부터 보면 된다.\n","permalink":"https://jaylab.blog/httpurlconnection-httpclient-compare/","summary":"JVM 옵션으로 프록시를 걸면 HttpURLConnection과 RestTemplate은 따르고, Apache HttpClient와 WebClient는 무시해 코드에서 따로 지정해야 한다.","title":"JVM 프록시 옵션은 모든 HTTP 클라이언트에 걸리지 않는다"},{"content":"minikube에 Kafka 애플리케이션을 올리면서(docker-compose로 돌던 Kafka 앱을 minikube에 구성하기) Pod 안에서 두 가지를 확인해야 했다. 다른 Pod의 포트로 연결이 되는지, 그리고 이 Pod의 애플리케이션이 포트를 리스닝하고 있는지. 앞의 것은 netcat(nc)으로, 뒤의 것은 netstat -tuln으로 봤다.\n번거로운 건 설치였다. 컨테이너 이미지마다 배포판이 다르고 배포판마다 패키지 매니저와 패키지 이름이 다르다. kafka-ui Pod에서는 apk를, Spring Boot Pod에서는 apt-get을 썼다. 매번 찾아보지 않으려고 주요 Linux 배포판별 명령을 한곳에 모아 둔다.\nnc -zv는 데이터 없이 연결만 시도한다 netcat은 네트워크 연결을 테스트하고 다른 서버의 포트와 통신이 되는지 확인하는 유틸리티다. 포트가 열려 있는지만 볼 때는 -zv 두 옵션이면 된다.\n# [usage] nc -zv {수신 target IP} {open 되어있는지 확인하고자 하는 port} nc -zv 10.244.0.74 9092 -z: 데이터를 보내지 않고 연결만 시도한다.\n-v: 자세한 출력(Verbose)을 켜서 연결 과정을 보여 준다.\n열려 있으면 succeeded, 닫혀 있으면 Connection refused가 나온다.\nConnection to 10.244.0.74 9092 port [tcp/*] succeeded! connect to 10.244.0.74 port 9094 (tcp) failed: Connection refused 배포판별 설치 사용법은 어디서나 위와 같고 설치 명령만 다르다.\n# Debian, Ubuntu (apt) sudo apt update \u0026amp;\u0026amp; sudo apt install netcat-openbsd # Fedora, CentOS (dnf 또는 yum) sudo dnf install nc sudo yum install nc # CentOS에서는 yum이 보편적 # Arch Linux (pacman) sudo pacman -Syu gnu-netcat # -Syu는 시스템 전체 업그레이드도 함께 한다 # openSUSE (zypper) sudo zypper install netcat-openbsd # Alpine Linux (apk) apk update \u0026amp;\u0026amp; apk add netcat-openbsd # Amazon Linux 2 (yum) sudo yum install nc # Amazon Linux 2023 (dnf) sudo dnf install nc netstat -tuln은 ss -tuln으로 대신한다 netstat은 네트워크 연결, 라우팅 테이블, 인터페이스 통계, masquerade 연결, 멀티캐스트 멤버십 등을 보여 주는 명령이다. 리스닝 중인 포트만 보려면 -tuln을 붙인다.\n-t, -u: TCP와 UDP 소켓\n-l: 리스닝 상태인 소켓만\n-n: 서비스 이름 대신 포트 번호를 숫자 그대로\nnetstat이 들어 있는 net-tools는 최근 대부분의 배포판에서 ss로 대체되고 있다. ss는 netstat보다 빠르고 효율적으로 정보를 보여 준다고 알려져 있다. 옵션도 -tuln을 그대로 쓴다. ss는 iproute2 패키지에 들어 있고, 최신 배포판에는 대부분 기본으로 깔려 있다. 없으면 아래처럼 설치한다.\n# Debian, Ubuntu sudo apt update \u0026amp;\u0026amp; sudo apt install iproute2 # Fedora, CentOS sudo dnf install iproute sudo yum install iproute # CentOS # Arch Linux sudo pacman -Syu iproute2 # openSUSE sudo zypper install iproute2 # Alpine Linux apk update \u0026amp;\u0026amp; apk add iproute2 # Amazon Linux 2 sudo yum install iproute # Amazon Linux 2023 sudo dnf install iproute 설치 후에는 어느 배포판이든 같다.\nss -tuln Red Hat 계열(Fedora, CentOS, Amazon Linux)만 패키지 이름이 iproute2가 아니라 iproute다. 설치가 실패하면 이 차이부터 의심하면 된다.\n결국 연결을 두드려 볼 때는 nc -zv, 리스닝 포트를 볼 때는 ss -tuln. 명령 자체는 배포판을 가리지 않고, 다른 건 패키지 이름뿐이다.\n","permalink":"https://jaylab.blog/netcat-and-netstat/","summary":"Pod 안에서 다른 서버 포트로 연결되는지, 내 앱이 포트를 리스닝하는지 확인할 때 쓰는 nc와 ss의 배포판별 설치 명령을 모았다.","title":"포트가 열렸는지 볼 때 쓰는 nc와 ss"},{"content":"zookeeper, kafka, kafka-ui에 Spring Boot 애플리케이션 하나. docker-compose로 잘 돌던 Kafka 예제를 로컬 k8s 클러스터에서도 돌려 보기로 했다. 환경은 Apple Silicon(arm64) Mac이다.\nmanifest 변환과 클러스터 구성은 도구가 거의 다 해 줬다. 막힌 곳은 한 군데였다. Registry에 올리지 않은 로컬 이미지를 minikube가 찾지 못했다. 이 글에서는 그 문제를 중심으로 클러스터를 띄우고 Pod끼리 통신과 외부 접속까지 확인한 과정을 정리한다.\ncompose 파일은 kompose로 변환했다 이미 있는 docker-compose.yml을 k8s용 yaml로 손으로 다시 쓸 이유는 없었다. kompose가 compose 파일을 읽어 Deployment와 Service로 바꿔 준다.\nbrew install kompose kompose convert -f docker-compose.yml --out converted.yaml 이 converted.yaml을 minikube 클러스터에 그대로 적용한다.\nminikube 클러스터를 띄운다 brew install minikube minikube start 클러스터를 시작하면 ~/.kube/config에 클러스터와 컨텍스트 정보가 자동으로 설정된다. 출력을 보면 docker 드라이버로 떠서 그 안에 쿠버네티스 v1.28.3이 Docker 24.0.7 런타임으로 올라간다. 이 \u0026ldquo;안쪽의 Docker\u0026quot;가 뒤에서 문제가 된다.\n선택 사항이지만, 앱 전용 Namespace를 만들어 현재 컨텍스트의 기본값으로 걸어 두면 이후 명령마다 -n을 붙이지 않아도 된다.\nkubectl create ns kafka-app kubectl config set-context --current --namespace=kafka-app 로컬에 있는 이미지를 minikube가 못 찾는 이유 yaml에 적힌 이미지는 먼저 로컬에서 찾고, 없으면 Registry에서 받아 온다. 그런데 Spring Boot 이미지는 분명 로컬에 있는데도 minikube는 Registry를 뒤지다 실패했고, Pod가 만들어지지 않았다.\n여기서 \u0026ldquo;로컬\u0026quot;은 내 Mac이 아니라 minikube 노드다. minikube는 자기 안에 별도의 Docker 데몬을 두고 돌아간다. 호스트에서 docker build로 만든 이미지는 호스트 데몬에만 있고, 노드의 kubelet은 노드 쪽 데몬만 본다. 그러니 노드 입장에서 그 이미지는 없는 이미지다.\nminikube docker-env는 지금 셸의 Docker 클라이언트가 minikube 안의 데몬을 바라보도록 환경 변수를 내준다. 이걸 eval로 적용한다.\neval $(minikube docker-env) 환경 변수라서 효과는 이 명령을 실행한 터미널에만 있다. 이미지 빌드는 반드시 같은 터미널에서 해야 이미지가 minikube 쪽 데몬에 생긴다.\n리소스를 만들고 이미지를 빌드한다 변환해 둔 yaml로 k8s 리소스를 만든다.\nkubectl apply -f converted.yaml zookeeper, kafka, kafka-ui 관련 Deployment와 Service가 생성된다.\nSpring Boot 애플리케이션은 Gradle로 빌드해 둔 jar를 Docker 이미지로 감싼다. arm64 Mac이라 베이스 이미지도 arm64v8/openjdk를 digest까지 고정해 썼다.\n# 베이스 이미지를 지정 # 이미지가 로컬에 존재하지 않을 경우에만 다운로드 # 이미지가 로컬에 있을 경우에는 다시 다운로드 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;] eval을 실행한 그 터미널에서 Dockerfile이 있는 경로로 가서 빌드한다.\n# [usage] docker build -t {생성할 image 명}:{지정할 tag 혹은 버전정보} . docker build -t my-spring-application:0.0.14 . 태그를 latest가 아닌 버전으로 준 것도 의미가 있다. k8s는 latest 태그나 태그 없는 이미지에는 기본 imagePullPolicy를 Always로 잡아서, 노드에 이미지가 있어도 Registry에서 다시 받으려 한다. 버전 태그면 기본값이 IfNotPresent라 노드에 있는 이미지를 그대로 쓴다.\n대시보드와 로그로 확인한다 minikube dashboard 프록시가 뜨고 출력된 URL로 접속하면 생성된 k8s 리소스를 볼 수 있다.\n화면 상단에서 Namespace가 앞에서 만든 kafka-app으로 잡혀 있는지 확인한다.\n로그는 kubectl logs로 본다.\nkubectl logs pod/kafka-ui-57f44b6bb8-cgwzq Pod끼리 통신되는지 nc로 두드려 본다 kafka-ui Pod에서 kafka Pod의 9092 포트로 연결이 되는지 확인했다. 이미지에 netcat이 없어서 Pod에 들어가 먼저 설치했다.\n# 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 -z: 데이터를 보내지 않고 연결만 시도한다.\n-v: 자세한 출력(Verbose)을 켜서 연결 과정을 보여 준다.\n결과는 이렇게 갈린다. 실패 예시는 열려 있지 않은 9094 포트로 시도했을 때다.\n# 성공 Connection to 10.244.0.74 9092 port [tcp/*] succeeded! # 실패 (9094 포트로 시도) connect to 10.244.0.74 port 9094 (tcp) failed: Connection refused 배포판별 netcat 설치 명령은 포트가 열렸는지 볼 때 쓰는 nc와 ss에 따로 모았다.\nkafka-ui를 로컬 브라우저에서 연다 클러스터 안의 Service는 그대로는 Mac 브라우저에서 닿지 않는다. minikube service가 로컬과 Service 사이에 터널을 열어 주는데, 이때 새 포트가 생기고 그 포트로 터널링된다.\n# 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 # 브라우저에서 접속 ps로 보면 minikube service가 띄운 ssh 프로세스가 있다. -L 64706:10.96.225.96:29092, 즉 로컬 64706 포트를 클러스터 안 Service의 IP와 포트로 넘기는 SSH 포트포워딩이다. 브라우저에서는 이 로컬 포트로 접속한다.\nSpring Boot Pod가 8080을 듣고 메시지를 받는지 본다 마지막으로 Spring Boot 애플리케이션 Pod에 들어가 8080 포트를 리스닝하는지 확인하고, 메시지를 한 건 보내 로그를 봤다.\n# 스프링 부트 애플리케이션 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 포트를 리스닝하고 있다. 다른 터미널에서 로그를 걸어 두고 메시지를 보낸다.\n# 다른터미널에서 스프링 부트 애플리케이션 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; compose 파일 변환과 클러스터 구성은 kompose와 minikube 명령 몇 줄로 끝났다. 막힌 곳은 이미지가 어느 Docker 데몬에 있느냐 하나였다. 이미지를 빌드하기 전에 eval $(minikube docker-env)부터 치는 습관을 들이면 같은 자리에서 다시 막히지 않는다.\n","permalink":"https://jaylab.blog/minikube-build-kafka-app/","summary":"로컬에만 있는 Spring Boot 이미지를 minikube가 찾지 못하던 문제를 docker-env로 풀고, compose 기반 Kafka 앱을 로컬 k8s에 올렸다.","title":"docker-compose로 돌던 Kafka 앱을 minikube에 구성하기"},{"content":"Kubernetes나 Docker를 쓰면 이미지 하나로 Pod와 컨테이너를 금방 띄울 수 있다. 번거로운 건 그 안에 들어가서 뭔가를 설치해야 할 때다. 배포판마다 쓸 수 있는 패키지 매니저가 달라서, 그 이미지가 어떤 배포판 위에 만들어졌는지부터 확인하고 거기 맞는 패키지 매니저를 찾아야 한다.\n다른 사람들은 이걸 어떻게 확인하나 싶어 찾아봤지만 \u0026ldquo;내가 쓰는 리눅스의 패키지 매니저를 쉽게 확인하는 방법\u0026rdquo; 같은 글은 나오지 않았다. 결국 GPT에 물어봤다. 계속 긴 스크립트만 돌려주길래 더 간단한 방법을 몇 번 되물었고, 그 끝에 얻은 게 아래 한 줄이다.\n후보 여섯 개를 한꺼번에 type에 넘긴다 type apt-get dnf yum pacman zypper apk 2\u0026gt;/dev/null type은 이름을 받아서 쉘이 그 이름을 무엇으로, 어디에서 찾는지 알려주는 쉘 내장 명령이다. 이름을 여러 개 받을 수 있어서 주요 배포판의 패키지 매니저를 나열해 두면 설치된 것만 경로와 함께 나온다. 뒤의 2\u0026gt;/dev/null은 찾지 못한 이름에 대한 에러 출력을 버리려고 붙였다.\n아래는 한 컨테이너에서 실행한 결과다.\n마지막 줄의 apk is /sbin/apk가 답이다. 이 컨테이너는 Alpine 계열이고, 필요한 도구는 apk로 설치하면 된다. 배포판별 설치 명령은 포트가 열렸는지 볼 때 쓰는 nc와 ss에서 netcat을 예로 모아 두었다.\n2\u0026gt;/dev/null을 붙였는데 not found가 찍힌 이유 결과를 다시 보면 2\u0026gt;/dev/null을 붙였는데도 apt-get: not found 같은 줄이 그대로 남아 있다. type이 \u0026ldquo;찾지 못했다\u0026quot;는 메시지를 어느 스트림에 쓰는지는 쉘마다 다르다.\nbash는 이 메시지를 표준 에러로 보내므로 2\u0026gt;/dev/null에 걸려 설치된 패키지 매니저 한 줄만 남는다. 반면 dash 계열 쉘은 nosuchcmd: not found를 표준 출력으로 내보낸다. Alpine의 기본 쉘인 BusyBox sh도 같은 쪽이라 리다이렉션을 빠져나온 것이다.\n# dash: not found가 표준 출력으로 나와 2\u0026gt;/dev/null에 걸리지 않는다 $ dash -c \u0026#39;type nosuchcmd ls 2\u0026gt;/dev/null\u0026#39; nosuchcmd: not found ls is /bin/ls # bash: not found가 표준 에러로 가서 사라진다 $ bash -c \u0026#39;type nosuchcmd ls 2\u0026gt;/dev/null\u0026#39; ls is /bin/ls 어느 쪽이든 is \u0026lt;경로\u0026gt;로 끝나는 줄만 보면 되니 쓰는 데 지장은 없다.\n정석은 아닐 수 있다 배포판 자체를 확인하는 방법으로는 /etc/os-release를 읽는 쪽이 더 널리 쓰인다. ID=alpine, ID=ubuntu 같은 값으로 배포판을 알려주는 파일이다. 다만 이건 배포판 이름을 알려줄 뿐이라 거기서 패키지 매니저를 한 번 더 떠올려야 한다. 당장 설치 명령이 필요할 때는 type 한 줄이 더 빠르다.\ncat /etc/os-release 정석은 아닌 것 같지만.. 더 간단한 방법이나 실무상 정석을 아는 분께서 보시게 된다면 조언 부탁드림다\n","permalink":"https://jaylab.blog/quick-guide-identify-package-manager/","summary":"이미지가 어떤 배포판인지 몰라 패키지 매니저를 헤맬 때, type 한 줄로 후보 여섯 개를 한 번에 확인한다.","title":"컨테이너 안에서 쓸 패키지 매니저를 찾는 한 줄"},{"content":"프로젝트에 객체를 Map\u0026lt;String, String\u0026gt;으로 바꿔 주는 유틸 메서드가 있었다. 열어 보니 Reflection으로 필드를 하나씩 훑고 있었다. 동작은 한다. 다만 Jackson이 이미 의존성에 있는데 같은 일을 손으로 하고 있었다.\n이 글에서는 기존 메서드가 무엇을 하는지 보고, ObjectMapper로 바꾼 코드와 바꿀 때 챙길 점을 정리한다.\n기존 메서드는 선언된 필드를 전부 연다 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; } 흐름은 세 단계다.\ngetDeclaredFields()로 필드 목록을 가져온다. getDeclaredXXX() 계열은 상속받은 클래스와 인터페이스를 빼고 그 클래스에 직접 정의된 요소만 가져온다. 대신 접근 제어자는 가리지 않아서 private, protected, public 필드가 전부 나온다.\n필드마다 setAccessible(true)로 접근 제한을 풀고, 값을 읽어 null이 아니면 toString() 결과를 Map에 넣는다.\n접근할 수 없는 필드를 읽으면 IllegalAccessException이 난다. 이 메서드는 그걸 printStackTrace()로 찍고 넘어간다.\n마지막 줄이 걸린다. 예외가 나면 호출한 쪽은 그때까지 채워진 Map을 받을 뿐, 변환이 실패했는지 알 방법이 없다.\nObjectMapper로 바꾸면 한 줄이다 public \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;() {}); } Jackson의 ObjectMapper는 JSON을 다룰 때 쓰는 도구지만 convertValue로 객체를 다른 타입으로 바로 옮길 수 있다. 필드 순회, 접근 권한, 예외 처리를 전부 Jackson에 넘긴다. 변환에 실패하면 IllegalArgumentException이 올라오니 삼켜지는 예외도 사라진다.\nTypeReference는 결과 타입을 명시하는 장치다. Java 제네릭은 런타임에 지워지기 때문에 Map.class만 넘기면 Jackson은 값 타입을 모른다. new TypeReference\u0026lt;Map\u0026lt;String, String\u0026gt;\u0026gt;() {}로 넘기면 값을 String으로 맞춰 담는다.\n바꿀 때 챙길 점 성능은 원래 이 리팩토링의 이유 중 하나였다. Jackson도 내부적으로는 Reflection을 쓰지만, 클래스를 한 번 분석한 결과를 ObjectMapper 인스턴스에 캐싱해 두고 재사용한다. ObjectMapper를 재사용해야 캐싱 효과가 난다. 위 코드처럼 호출마다 new ObjectMapper()를 만들면 매번 처음부터 분석한다. 필드로 한 번만 만들어 두거나, Spring Boot라면 이미 등록된 ObjectMapper Bean을 주입받는 편이 낫다.\n두 메서드가 돌려주는 Map도 같지 않다.\n기존 메서드는 선언된 필드를 읽고, Jackson은 기본 설정에서 getter와 public 필드를 읽는다. getter 없는 private 필드는 빠진다.\nnull 값을 기존 메서드는 건너뛰었고, Jackson은 기본적으로 키를 남긴 채 값에 null을 넣는다.\n상속받은 필드는 기존 메서드가 읽지 않았다. Jackson은 부모 클래스의 getter도 읽는다.\n호출하는 쪽이 이 차이에 기대고 있지 않은지 한 번 보고 바꾸는 게 안전하다.\n","permalink":"https://jaylab.blog/spring-refactoring-object-to-map/","summary":"필드를 Reflection으로 훑던 Object→Map 변환 메서드를 ObjectMapper.convertValue 한 줄로 줄이고, 바꾸면서 달라지는 동작을 짚었다.","title":"Object를 Map으로 바꾸는 데 Reflection까지 써야 할까"},{"content":"로컬에서 빌드한 애플리케이션 이미지를 Minikube 쿠버네티스에 올려 보는 중이었다. Deployment와 Service를 적은 yaml을 kubectl apply 하자 Pod 상태가 ErrImagePull로 떨어졌다. docker images로 보면 이미지는 분명 로컬에 있었다.\n같은 에러를 두 번 만났고, 원인은 두 번 다 달랐다. 이 글은 그 두 가지 기록이다.\n환경\nMac Apple Silicon\nMinikube로 구성한 K8S\n로컬에서 빌드한 애플리케이션과 Docker 이미지를 K8S로 배포 테스트\n첫 번째: Minikube는 내 Docker 데몬을 보지 않는다 에러 메시지는 이랬다.\npull access denied for cms, repository does not exist or may require \u0026#39;docker login\u0026#39;: denied: requested access to the resource is denied 원인은 간단했다. Minikube 안의 쿠버네티스는 내 로컬 Docker에 있는 이미지를 보지 못한다. Minikube 노드는 자기 Docker 데몬을 따로 갖고 있다. 로컬 데몬에 있는 cms 이미지는 그쪽에서 보면 없는 이미지이고, 없으니 레지스트리에서 받으려다 실패한 것이다.\n그래서 Minikube와 내 Docker 호스트를 연결했다.\neval $(minikube -p minikube docker-env) minikube docker-env는 DOCKER_HOST 같은 환경 변수를 출력하고, eval이 그걸 현재 쉘에 적용한다. 이후 이 쉘의 docker 명령은 Minikube 안의 데몬을 향하므로, 이 상태에서 이미지를 빌드해야 Minikube 쪽에 이미지가 생긴다. 환경 변수라서 터미널을 새로 열면 다시 실행해야 한다.\n두 번째: 이름이 cms 하나뿐인 이미지 이어서 Vue 프론트엔드 애플리케이션도 배포해야 했다. 앞에서 한 번 당해서 조심하고 있었는데도 또 이미지 pull 에러가 났다.\ndocker: error response from daemon: pull access denied for... 이번에는 docker login이 되어 있는데도 로그인을 요구했다. 여기서 두 번째 문제를 알게 됐다. 이미지 이름이 {사용자명}/{이미지명} 형식이 아니라 그냥 {이미지명}이었다.\nDocker Hub에서 받을 이미지도 아니었고 로컬 이미지만 쓰면 됐기 때문에 이름 형식이 문제가 될 줄은 몰랐다. 이미지 이름이 {사용자명}/{이미지명}이 되도록 관련 부분을 모두 고쳐 적용하자 해결됐다.\n돌아보면 에러 메시지에 힌트가 있었다. 레지스트리와 네임스페이스 없이 cms만 쓰면 Docker는 이를 docker.io/library/cms, 즉 Docker Hub 공식 이미지 자리로 해석한다. 그런 저장소는 없으니 \u0026ldquo;repository does not exist or may require \u0026lsquo;docker login\u0026rsquo;\u0026ldquo;이 나온다. 로그인 여부와는 상관없는 메시지였다.\n🤔태그가 없거나 :latest면 imagePullPolicy 기본값이 Always라서, 로컬에 이미지가 있어도 매번 레지스트리에서 받으려한다는 내용이 있다. 이름 변경만으로 풀린 이유는 이쪽일 수도 있다. ","permalink":"https://jaylab.blog/docker-pull-error-two-effective-solutions/","summary":"Minikube 배포에서 로컬 이미지인데도 ErrImagePull이 났고, docker-env 연결과 이미지 이름 변경으로 두 번 풀었다.","title":"로컬에 있는 이미지를 Minikube는 왜 못 찾을까"},{"content":"Terraform으로 Application Load Balancer를 만들면 리소스가 하나로 끝나지 않는다. aws_lb, aws_lb_listener, aws_lb_listener_rule, aws_lb_target_group 네 개가 서로의 arn을 주고받으며 이어진다. 코드만 보면 누가 누구를 참조하는지는 보여도 요청이 어떤 순서로 흘러가는지는 잘 안 보인다.\n그중 타겟 그룹은 리스너 규칙과 Auto Scaling Group(ASG), 두 곳에서 참조된다. 이 글에서는 요청이 들어오는 순서대로 네 리소스를 따라가고, 타겟 그룹이 ASG에서도 쓰이는 이유를 헬스체크 쪽에서 정리한다.\nflowchart LR C[\u0026#34;클라이언트 요청\u0026#34;] --\u0026gt; LB[\u0026#34;aws_lb\u0026lt;br\u0026gt;타입·서브넷·보안그룹\u0026#34;] LB --\u0026gt; L[\u0026#34;aws_lb_listener\u0026lt;br\u0026gt;포트·프로토콜, default action\u0026#34;] L --\u0026gt; R[\u0026#34;aws_lb_listener_rule\u0026lt;br\u0026gt;condition → action\u0026#34;] R --\u0026gt;|forward| TG[\u0026#34;aws_lb_target_group\u0026lt;br\u0026gt;헬스체크 명세\u0026#34;] ASG[\u0026#34;aws_autoscaling_group\u0026#34;] -.-\u0026gt;|EC2를 등록| TG요청은 로드밸런서, 리스너, 리스너 규칙 순으로 내려간다 로드밸런서 로드밸런서 리소스에서는 세 가지를 정한다. 로드밸런서 타입(Network 또는 Application), 어느 서브넷에 둘지, 어떤 보안그룹을 붙일지다.\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] } 리스너 리스너는 특정 로드밸런서를 참조하고, 들어온 요청의 포트와 프로토콜을 확인한다. default_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; # 규칙에 걸리지 않는 요청에는 기본으로 단순한 404 페이지를 돌려준다 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 } } } 리스너 규칙 리스너 규칙도 특정 리스너를 참조한다. 요청이 어떤 경로로 들어왔는지 패턴을 보고, 그 경로의 요청을 어느 타겟 그룹으로 보낼지 정한다. 리스너와 타겟 그룹을 실제로 이어 주는 건 리스너 규칙이다.\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: 요청이 condition을 만족할 때 수행할 동작 action { type = \u0026#34;forward\u0026#34; target_group_arn = aws_lb_target_group.asg.arn } } 이 예제는 path_pattern이 *라서 모든 경로가 규칙에 걸린다. 리스너의 404 default_action은 규칙을 경로별로 나눠야 비로소 쓰임새가 생긴다.\n경로마다 다른 타겟 그룹으로 보내려면 규칙을 나눈다 /static1/로 시작하는 요청은 대상 그룹 A로, /static2/로 시작하는 요청은 대상 그룹 B로 보내고 싶다고 하자. aws_lb_listener_rule 하나에 두 경로를 몰아넣는 게 아니라, 리스너 규칙 2개와 대상 그룹 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.http.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.http.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;] } } } 이렇게 나누면 /static1/도 /static2/도 아닌 요청은 어느 규칙에도 걸리지 않고, 앞에서 정한 404 default_action으로 떨어진다.\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 } } 이 설정이면 15초마다 /로 HTTP 요청을 보내고, 3초 안에 200이 오지 않으면 실패로 센다. 두 번 연속 실패하면 비정상, 두 번 연속 성공하면 정상이다. 계산상 인스턴스가 죽고 나서 비정상으로 판정되기까지 30초 안팎이 걸린다.\nASG가 EC2를 새로 만들면 그 EC2는 이 타겟 그룹에 포함된 채로 생성된다. 그래서 타겟 그룹은 리스너 규칙에서도, ASG에서도 참조된다.\nASG의 헬스체크 타입을 ELB로 두는 이유 헬스체크는 로드밸런서만 하는 게 아니라 ASG에도 자체 기능이 있다. 문제는 기본값인 \u0026ldquo;EC2 타입\u0026quot;이 무엇을 보느냐다.\nEC2 타입은 타겟 그룹의 상태를 보지 않고 인스턴스 자체의 상태로 판단한다. AWS가 EC2 인스턴스 상태를 주기적으로 확인하다가 인스턴스가 running 상태에서 벗어나면 문제가 있다고 본다. OS 수준에서 인스턴스가 살아 있는지는 알 수 있지만, 그 위의 애플리케이션이 응답하지 않는 상황은 잡아내지 못할 수 있다.\nELB 타입으로 두면 타겟 그룹 설정이 함께 필요하다. 이때 ASG는 인스턴스 상태에 더해 로드밸런서가 타겟 그룹 명세대로 수행한 헬스체크 결과까지 보고 판단한다. 애플리케이션이 죽어서 /가 200을 못 돌려주면, 인스턴스가 running이어도 ASG가 교체 대상으로 본다.\n구분 로드밸런서 헬스체크 ASG 헬스체크 (EC2 타입) ASG 헬스체크 (ELB 타입) 보는 것 타겟 그룹 명세대로 보낸 요청의 응답 인스턴스 상태(running 여부) 인스턴스 상태 + 로드밸런서 헬스체크 결과 비정상일 때 하는 일 그 인스턴스로 요청을 보내지 않는다 인스턴스를 교체한다 인스턴스를 교체한다 둘 다 헬스체크를 하지만 목적이 다르다. 로드밸런서는 요청을 처리할 수 있는 서버로만 요청을 보내는 데 집중한다. ASG 쪽은 그룹 안 인스턴스 상태를 지켜보다가 필요하면 교체한다. 둘이 맞물려야 가용성과 탄력성이 함께 올라간다.\n정리 요청은 aws_lb → aws_lb_listener → aws_lb_listener_rule → aws_lb_target_group 순으로 내려간다. 규칙에 걸리지 않은 요청은 리스너의 default_action이 받는다.\n경로별 분기는 규칙 하나가 아니라 규칙 여러 개와 타겟 그룹 여러 개로 만든다.\n타겟 그룹은 트래픽의 목적지이면서 헬스체크 명세서다. 그래서 리스너 규칙과 ASG가 같이 참조한다.\nASG 헬스체크를 ELB 타입으로 두면 애플리케이션 수준의 장애까지 인스턴스 교체로 이어진다.\n리소스 네 개를 한 번에 보면 복잡하지만, 요청 하나를 따라가면 한 줄로 이어진다.\n","permalink":"https://jaylab.blog/aws-tf-as-lb/","summary":"Terraform ALB 리소스 네 개를 요청이 흐르는 순서대로 따라가고, 타겟 그룹을 ASG에서도 참조하는 이유를 헬스체크 타입 차이로 정리했다","title":"타겟 그룹은 왜 ALB와 ASG 양쪽에서 참조될까"},{"content":"Mac에 Homebrew로 MySQL을 설치하고 두 가지를 손봤다. 기본 포트 3306을 3301로 바꿨고, GUI 클라이언트로 접속할 때 뜬 Public Key Retrieval is not allowed 에러를 풀었다. 둘 다 설정 한두 줄로 끝나는 일이다. 고칠 곳만 알면 된다.\nmy.cnf는 어디에 있나 포트를 바꾸려면 설정 파일 my.cnf부터 찾아야 한다. 위치는 설치 방식마다 다르니 추측하지 말고 mysqld에게 직접 물어보는 게 빠르다.\n# mysqld가 설정 파일을 읽는 경로와 순서 확인 mysqld --verbose --help | grep -A 1 \u0026#39;Default options\u0026#39; 출력은 /etc/my.cnf, /etc/mysql/my.cnf, /opt/homebrew/etc/my.cnf, ~/.my.cnf 순서다. 나열된 파일을 앞에서부터 차례로 읽으니, 같은 옵션이 여러 파일에 있으면 뒤에 읽힌 값이 남는다. Homebrew로 설치했다면 실제 파일은 /opt/homebrew/etc/my.cnf에 있다.\n포트를 3301로 바꾸기 파일을 열어 port 한 줄을 넣고 서버를 재시작하면 끝이다.\nvi /opt/homebrew/etc/my.cnf # [mysqld] 섹션 아래에 추가 port = 3301 # 재시작해야 적용된다 mysql.server restart 이후 접속하는 클라이언트도 포트를 3301로 맞춰야 한다.\nPublic Key Retrieval is not allowed GUI 클라이언트로 접속하려 하자 연결 단계에서 에러가 났다.\nMySQL 8의 기본 인증 플러그인은 caching_sha2_password. SSL 없이 접속하면 클라이언트는 비밀번호를 암호화하기 위해 서버의 RSA 공개키가 필요한데, MySQL Connector/J는 이 공개키를 서버에서 받아오는 동작을 기본으로 막아 둔다. 중간자가 가짜 공개키를 끼워 넣을 수 있기 때문이다.\n그래서 allowPublicKeyRetrieval 기본값이 false이고, 에러 메시지도 이 옵션 이름을 그대로 가리킨다.\n접속 설정의 [Driver properties] 탭에서 두 값만 변경\nallowPublicKeyRetrieval → true\nuseSSL → false\n로컬 개발용 DB라서 이 조합으로 충분했다. 원격 서버에 같은 설정을 쓰면 공개키를 검증 없이 받아오게 되니 그때는 SSL을 켜거나 공개키 파일을 클라이언트에 직접 지정하는 쪽이 권장된다.\n정리 my.cnf 위치는 mysqld --verbose --help로 확인한다. Homebrew 설치는 /opt/homebrew/etc/my.cnf다.\n포트는 port = 3301 한 줄과 mysql.server restart로 바뀐다.\nPublic Key Retrieval is not allowed는 드라이버가 공개키 요청을 막아서 나는 에러이고, 로컬이라면 allowPublicKeyRetrieval=true, useSSL=false로 넘어간다.\n참고링크 https://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.blog/mysql-set-default-port-and-public-key-error/","summary":"Homebrew MySQL의 기본 포트를 my.cnf에서 3301로 바꾸고, 클라이언트 접속 때 난 Public Key Retrieval 에러를 드라이버 속성으로 풀었다.","title":"로컬 MySQL 포트 변경과 Public Key Retrieval 에러"},{"content":"작업하던 디렉토리의 파일들이 IntelliJ 프로젝트 창에서 보이지 않았다. Finder로 같은 경로를 열어 보면 파일은 그대로 있었다. 파일이 지워진 게 아니라, IntelliJ가 그 디렉토리를 프로젝트의 일부로 보지 않고 있었던 것이다.\n프로젝트 창은 디스크가 아니라 모듈을 그린다 IntelliJ의 프로젝트 창은 디스크 폴더를 그대로 비추는 파일 탐색기가 아니다. 프로젝트에 등록된 모듈과 그 모듈의 content root를 기준으로 트리를 그린다. 디렉토리가 모듈로 잡혀 있지 않으면 디스크에 멀쩡히 있어도 프로젝트 창에서는 빠질 수 있다. 모듈 정보는 .idea 디렉토리와 .iml 파일에 들어 있어서, 이 설정이 어긋나면 Finder와 IntelliJ가 서로 다른 것을 보여준다.\nProject Structure에서 모듈을 다시 불러온다 그래서 할 일은 그 디렉토리를 모듈로 다시 등록하는 것이다. 상단 메뉴 File → Project Structure로 들어간다. 단축키는 ⌘;다.\n왼쪽 Project Settings 아래 Modules를 선택하고, 위쪽 +(Add, ⌘N)를 눌러 Import Module을 고른다.\n파일 선택 창이 뜨면 안 보이던 작업 디렉토리를 골라 Open하고, 이어지는 창에서 Create까지 누른다. 전체 경로를 한 줄로 적으면 이렇다.\nFile → Project Structure → Project Settings → Modules → + (Add) → Import Module → 작업 디렉토리 선택 → Open → Create 모듈로 등록되자 프로젝트 창에 디렉토리와 파일이 다시 나타났다.\n정리 파일이 IntelliJ에서만 안 보이면 파일보다 프로젝트 설정을 먼저 본다. Finder에 있으면 파일은 무사하고, Project Structure의 Modules에 그 디렉토리가 들어 있는지만 확인하면 된다. 원인은 따로 기록해 두지 못했는데, 다음에 같은 일이 생기면 그것부터 적어 둬야겠다.\n참고자료 Modules | IntelliJ IDEA Documentation — 모듈의 개념과 추가·Import 방법\nContent roots | IntelliJ IDEA Documentation — 모듈이 어떤 디렉토리를 자기 것으로 보는지 정하는 content root\n","permalink":"https://jaylab.blog/intellij-invisible-file/","summary":"Finder에는 있는 작업 디렉토리가 IntelliJ 프로젝트 창에서만 안 보여서, Project Structure에서 모듈로 다시 Import해 되찾았다.","title":"IntelliJ에서만 파일이 안 보일 때"},{"content":"EC2 안에서 인스턴스 ID나 리전을 알아낼 때는 169.254.169.254로 curl을 날리는 게 익숙했다. Amazon Linux 2에서는 그 한 줄이면 됐다. 2023년 3월 15일에 후속 버전으로 Amazon Linux 2023이 나왔고, 여기서는 같은 요청 앞에 토큰을 받는 단계가 하나 더 붙는다.\nAmazon Linux 2와 2023의 메타데이터 조회 방법을 나란히 놓고, 덤으로 Amazon Linux 2023에서 MySQL 클라이언트를 깔아 RDS에 붙는 과정까지 정리한다.\nAmazon Linux 2에서는 curl 한 줄이었다 /latest/meta-data/를 부르면 조회할 수 있는 항목 목록이 나오고, 그중 하나를 경로에 붙이면 값이 나온다.\ncurl 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 ID 조회 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에서는 토큰을 먼저 받는다 이 방식은 IMDSv1이라고 부른다. AWS 문서에 따르면 Amazon Linux 2023 AMI는 기본값이 IMDSv2 전용이라, 토큰 없이 보낸 요청에는 메타데이터를 내주지 않는다. IMDSv2는 PUT으로 세션 토큰을 먼저 받고, 이후 모든 조회에 그 토큰을 헤더로 붙이는 방식이다.\n# 1. 세션 토큰 발급 (유효시간 3600초) 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 # 2. 이후 조회마다 토큰을 헤더로 전달 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;) 토큰 요청은 GET이 아니라 PUT이고, 유효시간은 X-aws-ec2-metadata-token-ttl-seconds 헤더로 정한다. 받은 토큰은 변수에 담아 두고 TTL 안에서 계속 쓴다. 경로(instance-id, placement/region)는 Amazon Linux 2 때와 같아서 기존 스크립트라면 토큰 발급 한 줄과 헤더만 더하면 된다.\nMySQL 클라이언트는 mariadb105 패키지로 깐다 Amazon Linux 2023 서버에서 RDS에 붙어 볼 일도 있었다. AWS 공식 문서(DB 인스턴스에 연결)대로 패키지 매니저는 dnf이고, MySQL 클라이언트로는 mariadb105를 설치한다.\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 -p 뒤에 비밀번호를 바로 붙이면 쉘 히스토리에 남는다. 예시라서 그대로 적었지만, 실제로는 -p만 주고 프롬프트에서 입력하는 편이 낫다.\n접속한 뒤 데이터베이스와 테이블을 확인했다.\nMySQL [(none)]\u0026gt; show databases; +--------------------+ | Database | +--------------------+ | information_schema | | innodb | | mysql | | performance_schema | | sys | | tf | +--------------------+ MySQL [(none)]\u0026gt; use tf Database changed MySQL [tf]\u0026gt; show tables; Empty set (0.00 sec) MySQL [tf]\u0026gt; quit Bye tf 데이터베이스는 보이지만 테이블은 아직 비어 있다.\n결국 Amazon Linux 2023으로 넘어오면서 메타데이터 조회에 토큰 발급 한 단계가 늘었다. MySQL 클라이언트는 패키지 이름이 mariadb105라는 것만 기억.\n","permalink":"https://jaylab.blog/aws-get-metadata-al2023/","summary":"Amazon Linux 2023에서는 IMDSv2 토큰을 먼저 받아야 인스턴스 ID·리전을 조회할 수 있고, MySQL 클라이언트는 mariadb105로 설치한다.","title":"Amazon Linux 2023의 메타데이터 조회는 토큰부터"},{"content":"Nuxt 애플리케이션을 정적 사이트로 배포하려고 자료를 찾아보면 빌드 명령이 두 가지로 나온다. 하나는 npm run generate, 다른 하나는 npx nuxi generate다. 둘 다 \u0026ldquo;애플리케이션을 정적 파일로 빌드한다\u0026quot;고 설명되어 있다.\n둘이 어떻게 다른지 알려면 앞에 붙은 npm run과 npx가 각각 무슨 일을 하는지부터 봐야 한다. 이 글에서는 두 명령을 앞에서부터 한 조각씩 나눠 정리한다.\nnpm은 저장소이면서 CLI다 npm은 Node Package Manager의 약자이고, 두 가지를 함께 가리킨다.\nnpm 레지스트리: 전 세계 개발자가 만든 JavaScript 라이브러리와 애플리케이션을 호스팅하고 공유하는 중앙 저장소다. 필요한 패키지를 찾아 내려받고, 내가 만든 패키지를 올려 다른 사람과 나눈다. 웹 사이트로도, npm CLI로도 접근할 수 있다.\nnpm CLI: 패키지 설치, 업데이트, 삭제로 프로젝트의 의존성을 관리한다. package.json에 정의한 스크립트를 실행하는 것도 이 CLI다.\nnpm run generate는 package.json을 봐야 안다 npm run은 package.json의 scripts에 정의된 스크립트를 실행하는 명령이다. npm run generate가 실제로 무엇을 돌리는지는 package.json을 열어 봐야 알 수 있다.\ngenerate에는 nuxt generate가 걸려 있다. nuxt generate는 애플리케이션의 모든 페이지를 사전 렌더링해 HTML 파일로 바꾸고, 필요한 JavaScript, CSS, 이미지 파일을 만든다.\nnuxt generate는 Nuxt 2 시절부터 있던 명령이지만 Nuxt 3에서도 이름 그대로 쓴다. 위 scripts에 있는 \u0026quot;postinstall\u0026quot;: \u0026quot;nuxt prepare\u0026quot;는 Nuxt 3 프로젝트의 설정이다.\nnpx는 설치하지 않은 패키지도 실행한다 npx는 npm 5.2.0 버전부터 기본으로 들어 있는 패키지 러너다. 로컬이나 글로벌에 설치하지 않은 패키지도 npm 레지스트리에서 받아 바로 실행한다. 한 번만 쓰고 말 도구를 돌리거나, 여러 버전을 시험해 보거나, 프로젝트마다 버전이 다른 도구를 쓸 때 유용하다.\nnuxi는 Nuxt 3의 CLI다 Nuxt 3는 nuxi라는 CLI를 새로 도입했다. nuxi generate도 애플리케이션을 정적 파일로 빌드한다. 공식 문서에 따르면 nuxi build를 prerender 옵션을 켠 채로 실행하는 명령이고, 결과물은 .output/public에 쌓인다. 이 디렉토리가 정적 호스팅에 올릴 대상이다.\nnpx로 부르기 때문에 프로젝트에 nuxi가 설치되어 있지 않아도 실행된다.\n그렇다면 npm run generate와는 무엇이 다를까. Nuxt 3의 nuxt 패키지는 nuxt와 nuxi 두 이름으로 같은 CLI를 등록한다. Nuxt 3 프로젝트라면 npm run generate와 npx nuxi generate는 같은 빌드를 돌린다. 다른 것은 호출 경로뿐이다.\ngraph LR A[\u0026#34;npm run generate\u0026#34;] --\u0026gt; B[\u0026#34;package.json scripts\u0026#34;] B --\u0026gt; C[\u0026#34;nuxt generate\u0026#34;] D[\u0026#34;npx nuxi generate\u0026#34;] --\u0026gt; E[\u0026#34;nuxi CLI\u0026#34;] C --\u0026gt; E E --\u0026gt; F[\u0026#34;prerender 빌드\u0026#34;] F --\u0026gt; G[\u0026#34;.output/public\u0026#34;] 팀 프로젝트라면 npm run generate 쪽이 낫다고 본다. 빌드 명령이 package.json에 적혀 있으니 누가 돌려도 같은 명령이 실행된다. npx nuxi generate는 scripts를 거치지 않고 바로 부를 때 쓴다.\n정리 npm run은 package.json scripts를, npx는 설치 여부와 상관없이 패키지를 실행한다. Nuxt 3에서는 nuxt와 nuxi가 같은 CLI라서 두 명령의 결과물도 같다. 배포할 파일은 .output/public에서 찾으면 된다.\n","permalink":"https://jaylab.blog/how-to-build-static-web-nuxt/","summary":"Nuxt 정적 빌드 명령 npm run generate와 npx nuxi generate를 npm run, npx, nuxi 순서로 쪼개 보면 Nuxt 3에서는 같은 빌드다.","title":"npm run generate와 npx nuxi generate는 무엇이 다를까"},{"content":"Homebrew로 nvm을 설치하고 nvm -v를 쳤는데 에러가 났다.\n원인은 설치가 반만 끝났다는 데 있었다. Homebrew는 nvm 파일을 내려놓기만 하고, 셸이 그 파일을 불러오게 만드는 일은 사용자에게 남긴다. 이 글에서는 그 나머지 설정 세 가지를 정리한다.\nnvm은 셸이 불러와야 생기는 명령이다 nvm은 /usr/local/bin 같은 곳에 놓이는 실행 파일이 아니다. nvm.sh 스크립트를 셸에서 source하면 그때 nvm이라는 셸 함수가 정의된다. 그래서 nvm.sh를 읽지 않은 셸에서는 설치가 끝났어도 nvm이 없다. 아래 설정은 결국 새 셸이 뜰 때마다 이 스크립트를 읽게 하는 작업이다.\n버전을 담을 디렉터리부터 만든다 nvm이 설치한 Node 버전을 저장할 디렉터리가 아직 없다면 만든다.\nmkdir ~/.nvm nvm 스크립트는 Homebrew 경로에 있고 내려받은 Node 버전은 이 디렉터리에 따로 쌓인다.\n.zshrc에서 nvm.sh를 불러온다 새 셸 세션마다 nvm이 초기화되도록 셸 프로필 파일에 아래 내용을 추가한다. 프로필 파일은 셸에 따라 ~/.bash_profile, ~/.zshrc, ~/.profile 등이다. 나는 ~/.zshrc에 넣었다.\n# nvm이 Node 버전을 저장할 위치 export NVM_DIR=\u0026#34;$HOME/.nvm\u0026#34; # nvm.sh가 있으면 읽어서 nvm 함수를 정의한다 [ -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; [ -s 파일 ]은 파일이 있고 비어 있지 않은지 검사한다. 참이면 \u0026amp;\u0026amp; 뒤의 \\.(source와 같은 명령)으로 그 파일을 현재 셸에 읽어 들인다. 경로의 /opt/homebrew는 Apple Silicon Mac의 Homebrew 경로라서 Intel Mac이라면 brew --prefix nvm으로 실제 경로를 확인하고 바꿔 넣어야 한다.\n지금 열린 셸에 반영하고 확인한다 프로필을 고쳐도 이미 열려 있는 셸은 그대로다. 다시 읽어 들인다.\nsource ~/.zshrc 버전이 찍히면 끝이다.\nnvm --version 이후에는 이렇게 쓴다.\n사용 가능한 Node.js 버전 확인: nvm list-remote\n특정 Node.js 버전 설치: nvm install {node_version}\n정리 brew install nvm은 파일만 깔아 준다. ~/.nvm을 만들고 프로필에서 nvm.sh를 불러와야 비로소 nvm 명령이 생긴다. 참고로 nvm 공식 README는 Homebrew 설치를 지원하지 않는다고 적어 두었으니, 이 방법으로도 이상하면 공식 설치 스크립트로 다시 까는 것도 방법이다.\n참고자료 nvm-sh/nvm — nvm 공식 저장소. 설치 스크립트와 Homebrew 설치에 대한 안내\nHomebrew Formulae: nvm — brew로 설치할 때의 formula 정보\n","permalink":"https://jaylab.blog/nvm-error-action/","summary":"Homebrew로 설치한 nvm이 셸에서 동작하지 않아, ~/.nvm을 만들고 .zshrc에서 nvm 스크립트를 불러오게 해 해결했다.","title":"brew install nvm 다음에 해야 할 일"},{"content":"저장소는 하나였다. 안에 든 코드는 A라는 기존 템플릿에 B 템플릿을 합친 형태였다. 한 도메인 안에서 A 화면과 B 화면이 섞여 쓰여야 했고, 양쪽에 있는 스타일과 기능 코드를 서로 가져다 쓰기 편하게 하려고 아예 합쳐버린 것이다.\n합친 이유는 편의였지만, 애플리케이션이 커지면서 패키지 충돌이 점점 늘었다. 처음부터 다시 설계해 나누는 게 맞다고 판단했고, 그때 고른 구조가 Monorepo다. 이 글에서는 Monorepo가 무엇인지 짧게 짚고, Nx로 나눈 뒤 달라진 점과 아직 풀리지 않은 의문을 적는다.\n여러 프로젝트를 한 저장소에 두는 방식 Monorepo는 여러 프로젝트의 코드를 하나의 저장소에서 관리하는 방식이다. 큰 소프트웨어가 여러 서브 프로젝트로 나뉘어 있을 때, 서브 프로젝트마다 저장소를 따로 두는 대신 모든 코드를 한곳에 모은다.\n출처: Moving from multiple repositories to a lerna-js mono-repo\n그림으로 보면 차이가 분명하다. Monolith는 프로젝트 하나가 저장소 하나고, Multi-repo는 프로젝트마다 저장소가 따로 있다. Mono-repo는 저장소는 하나인데 그 안에서 프로젝트가 나뉜다. 우리 저장소는 A와 B가 경계 없이 뒤섞인 Monolith에 가까웠고, 옮겨 가려는 곳은 오른쪽 끝이었다.\n장점은 세 가지로 정리된다.\n버전 관리가 한곳에 모인다. 모든 코드가 한 저장소에 있으니 의존성 관리가 수월하고, 버전 충돌을 풀거나 코드베이스 전체를 한 번에 업데이트하기 쉽다. 동시에 서브 프로젝트로 경계가 나뉘기 때문에 프로젝트마다 다른 버전의 의존 패키지를 쓰는 것도 쉬워진다. 확장하기 좋은 구조라는 뜻이다.\n공통 코드를 재사용하기 쉽다. 여러 곳에서 쓰는 코드나 라이브러리를 공유할 수 있어 중복이 줄어든다.\n팀 간 협업이 편해진다. 다른 서브 프로젝트를 맡은 개발자도 필요한 정보를 찾고 변경 사항을 파악하기 쉽다.\n단점도 있다.\n규모가 커지면 관리가 복잡해진다. 저장소가 방대해질수록 빌드 시간도 늘어난다.\n접근 권한을 나누기 어렵다. 여러 프로젝트가 한 저장소에 있으면 특정 부분만 접근을 막기가 까다롭고, 그만큼 보안 리스크가 커질 수 있다.\n도구는 Nx로 골랐다 기존 애플리케이션을 Monorepo로 다시 짜는 데 쓸 수 있는 도구로는 Yarn workspaces, Lerna, Nx, Turborepo 등이 있다. 나는 이 중에서 Nx를 썼다.\nA와 B는 apps/ 아래 각각의 앱으로 나눴고, 루트 package.json의 scripts로 앱별 실행과 빌드를 묶었다.\n\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; }, 두 앱은 포트를 3005, 3006으로 나눠 따로 띄우고, 함께 띄울 때는 npm-run-all --parallel로 둘을 동시에 실행한다. 빌드는 nx run \u0026lt;앱\u0026gt;:build로 앱마다 따로 돌리는데, 정적 파일을 뽑는 build:generate만은 Nx를 거치지 않고 각 앱 디렉터리로 들어가 nuxi generate를 직접 부른다. A만 .env.production을 읽도록 --dotenv를 붙인 점도 눈여겨볼 부분이다.\n나누고 나서 달라진 것 처음 목적은 의존성 충돌을 줄이는 것뿐이었는데, 실제로 얻은 건 그보다 많았다. 서브 앱끼리 상호작용하는 지점을 빠르게 찾을 수 있게 되어 작업이 수월해졌다. 불필요한 코드 중복이 크게 줄었고, 전체 프로젝트의 코드 히스토리도 한눈에 들어온다. 빌드와 배포 역시 전보다 훨씬 단순해졌다.\n아직 남은 의문 모든 게 한 저장소로 모이다 보니 특정 서브 프로젝트의 커밋 히스토리만 따로 추적하는 일은 예전보다 번거로워질 것 같다.\n규모도 걸린다. 우리 프로젝트는 서브 프로젝트가 2~3개뿐이라 Monorepo의 이점을 크게 봤지만, 서브 프로젝트가 훨씬 많아져도 이 방식이 효율적일지는 아직 모르겠다. 그 정도 규모라면 Multi-repo가 더 나을 수도 있다고 본다.\n","permalink":"https://jaylab.blog/front-monorepo/","summary":"A·B 템플릿을 한 저장소에 합쳐 쓰다 패키지 충돌이 늘어, Nx 모노레포로 앱을 나눈 과정과 남은 의문을 정리했다.","title":"템플릿 두 개가 섞인 저장소를 Nx 모노레포로 나누기"},{"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.blog/git-upstream-merge/","summary":"어느 Github 저장소를 Fork하여 나의 repository로 옮긴 후 이 코드를 가지고 몇번의 commit \u0026amp; push 작업을 해왔다. 어느날 메인(Fork했던 대상 Repository) 저장소에서 그 동안 이루어진 수정내역을 나의 repository에 반영해줘야할 시기가 왔다. 이 때 vs code로 진행했던 작업과정을 순서대로 기록해보았다.","title":"Fork한 저장소에 원본 커밋 받아오기"},{"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.blog/git-crlf-lf-conflict/","summary":"인텔리제이를 통한 Git 작업시 종종 겪을 수 있는 CRLF/LF 개행 변경 관련 문제와 그 해결법","title":"줄바꿈 기호 하나로 파일 전체가 바뀌는 커밋"},{"content":"Nuxt3로 만든 화면에서 특정 영역만 PDF 파일로 내려받게 해야 했다. 페이지 전체를 인쇄하는 게 아니라, 화면 안의 한 블록만 잘라서 파일로 저장하는 기능이다.\n여기에는 html2pdf.js 패키지를 썼다. 이 글에서는 설치부터 다운로드 버튼, 옵션 구성까지 정리.\n기능구현에 사용된 패키지 : \u0026ldquo;html2pdf\u0026rdquo; 패키지 설치 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.blog/nuxt-html-to-pdf/","summary":"Nuxt3 화면의 특정 HTML 영역만 PDF로 내려주기 위해 html2pdf.js를 붙이고 옵션과 다운로드 버튼을 구성한 방법","title":"Nuxt3 화면 일부를 PDF로 내려받기"},{"content":"git push를 실행했는데 끝나지 않았다. 진행률 표시는 모든 파일이 100% 올라간 것처럼 보이는데, 그 상태로 계속 pushing에 머물렀다. 에러 로그도 없었다. 실패했다고 말해 주지 않으니 어디부터 봐야 할지 알 수 없는 악질적인 문제였다.\npush 자체가 막힌 건 아니었다 처음에는 인증 정보나 remote 설정을 의심했다. 확인하려고 수정사항을 원복한 뒤 아주 조금만 고쳐서 다시 push했더니 정상적으로 올라갔다. 작은 push가 되니 credentials나 remote 설정 문제는 아닌 것으로 봤다.\n그다음은 파일이다. 올라갈 수 없는 내용의 파일이 섞여 있는지 살펴봤지만, 그 정도로 의심 가는 파일은 없었다. 결국 남은 차이는 push의 크기뿐이었다.\n원인은 HTTP POST 버퍼 크기 제한 Stack Overflow의 같은 증상 질문에서 원인을 찾았다. HTTP로 push할 때 쓰는 POST 버퍼의 크기 제한 때문에 생기는 현상이었다.\nGit은 HTTP(S) remote에 push할 때 데이터를 POST로 보내고, 이때 http.postBuffer 크기의 버퍼를 쓴다. Git 문서에 따르면 기본값은 1 MiB다. 보낼 데이터가 이보다 크면 Git은 한 번에 보내지 않고 Transfer-Encoding: chunked로 나눠 보낸다. 서버나 중간 프록시가 이 chunked 전송을 제대로 처리하지 못하면, 클라이언트 쪽에서는 데이터를 다 보낸 것처럼 보이는데 응답이 오지 않는다. 작은 수정이 문제없이 올라간 것도 버퍼 안에 들어갔기 때문이라고 보면 설명이 된다.\nhttp.postBuffer를 늘려 해결 버퍼 크기를 늘렸다.\n# POST 버퍼를 150 MiB(157286400 bytes)로 늘린다 git config --global http.postBuffer 157286400 이후 push가 정상적으로 완료됐다.\n참고 Git 문서는 이 값을 올리는 것이 chunked 전송을 끄는 효과만 있다고 설명한다. 원격 서버나 프록시가 HTTP/1.0만 지원하거나 표준을 제대로 따르지 않을 때 쓰는 설정이지, 대부분의 push 문제를 푸는 일반적인 해결책은 아니라는 것이다. 작은 push에도 버퍼 전체가 할당되므로 메모리 사용량이 크게 늘 수 있다는 경고도 함께 있다.\n그래서 --global로 모든 저장소에 거는 것보다, 문제가 생긴 저장소에서만 --global 없이 설정하는 편이 부작용이 적다. 설정이 필요 없어지면 git config --global --unset http.postBuffer로 되돌릴 수 있다.\n","permalink":"https://jaylab.blog/git-push-infinite-loading/","summary":"진행률은 100%인데 에러 없이 끝나지 않던 git push를, http.postBuffer를 늘려 HTTP POST 버퍼 제한을 피하는 것으로 해결했다.","title":"100%에서 멈추는 git push와 http.postBuffer"},{"content":"meals_clone 앱에 BottomNavigationBar를 붙이면서 탭 화면 위젯을 State의 필드로 미리 만들어 두었다. 탭을 누르면 _selectPage에서 그중 하나를 activePage에 넣는 식이었다. 동작은 했지만 쓰지도 않는 화면까지 미리 만들어 두는 게 메모리 낭비처럼 보였다.\n그래서 build 안에서 선택된 인덱스를 보고 위젯을 고르도록 고쳤다. 고치고 나니 탭이 여러 개가 되면 어떤 방식이 맞는지, 방식마다 메모리는 어떻게 쓰이는지가 궁금해졌다. 찾아본 내용까지 같이 정리한다.\n선택된 탭을 두 군데에 들고 있던 코드 기존 코드다. _selectedPageIndex와 activePage가 같은 정보를 따로 들고 있다.\nimport \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; // ❌ 탭 화면을 필드로 미리 만들어 두고, 선택된 화면을 activePage에 따로 저장한다 Widget categoriesScreen = const CategoriesScreen(); Widget placeholder = const Placeholder(); Widget activePage = const CategoriesScreen(); void _selectPage(int index){ setState(() { _selectedPageIndex = index; }); // setState 바깥에서 activePage를 바꾼다 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, ), ); } } activePage 대입이 setState 콜백 바깥에 있어도 화면은 제대로 바뀐다. setState는 다음 프레임에 다시 그리도록 예약만 하고, 실제 build는 대입이 끝난 뒤에 돌기 때문이다. 다만 순서에 기대는 코드라서 인덱스만 바꾸고 activePage를 빠뜨리는 식의 실수가 끼어들 여지가 있다.\nbuild에서 인덱스로 고르게 바꾸기 수정한 코드는 activePage 필드를 없애고, build가 돌 때마다 _selectedPageIndex를 보고 위젯을 고른다.\nimport \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) { // ✓ 현재 선택된 인덱스에 따라 build 안에서 위젯을 고른다 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, ), ); } } 바뀐 점은 두 가지다.\ncategoriesScreen과 placeholder를 필드로 미리 만들어 두지 않는다. 필요할 때 build에서 만든다.\n_selectPage는 인덱스만 바꾸고, 어떤 페이지를 보여줄지는 build가 _selectedPageIndex를 보고 정한다. 선택된 탭이라는 상태가 인덱스 하나로 줄었다.\n메모리 차이는 생각보다 작다 처음에는 1번을 메모리를 줄이는 수정으로 봤다. 다시 따져 보면 이 코드에서 메모리 차이는 거의 없다.\nFlutter에서 Widget은 화면의 설정값을 담은 가벼운 불변 객체다. 실제로 메모리를 차지하고 상태를 들고 있는 쪽은 Widget이 트리에 붙을 때 만들어지는 Element와 State, 그리고 RenderObject다. 기존 코드도 body에 들어가는 건 activePage 하나라서 트리에 붙어 있는 탭 화면은 처음부터 하나뿐이었다. 게다가 const CategoriesScreen()은 컴파일 타임 상수다. 필드에서 만들든 build에서 만들든 같은 인스턴스 하나를 가리킨다.\n그래서 이 수정의 실제 효과는 메모리보다 상태를 한곳에 모은 데 있다. 메모리를 좌우하는 건 Widget 객체를 언제 만드느냐가 아니라, 몇 개의 탭이 동시에 트리에 붙어 있느냐다. 아래 세 방식도 이 기준으로 보면 차이가 선명해진다.\n탭이 여러 개일 때의 세 가지 방식 1. List\u0026lt;Widget\u0026gt;에 담아 두고 꺼내 쓰기 탭에 대응하는 위젯을 List에 미리 담아 두고, 선택된 인덱스의 위젯을 body에 넣는다.\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 정의 ), ); } } 탭 목록을 한 곳에서 정의하니 초기 코드 구성이 단순하다.\nWidget 객체는 미리 만들어 두지만, 트리에 붙는 건 선택된 탭 하나다. 미리 만든 Widget 객체만큼 메모리를 쓰긴 해도 앞 절에서 본 대로 그 몫은 작다.\n탭 상태는 유지되지 않는다. 탭을 바꾸면 이전 탭의 Element와 State가 트리에서 떨어져 나가고, 돌아오면 같은 Widget 객체로 State를 새로 만든다. 스크롤 위치 같은 상태는 초기화된다.\n2. IndexedStack으로 전부 붙여 두기 IndexedStack은 모든 자식을 트리에 붙여 두고, 그중 index에 해당하는 하나만 화면에 보여준다.\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 ... ), ); } } 탭 상태가 유지된다. 사용자가 스크롤한 위치가 그대로 남는다.\n이미 트리에 붙어 있는 화면을 보여주기만 하니 탭 전환이 빠르다.\n대신 첫 화면이 뜰 때 모든 탭이 한꺼번에 빌드되고, 그 뒤로도 계속 트리에 남는다. 세 방식 중 메모리를 가장 많이 쓰는 건 이 방식이다.\n3. 필요할 때 만들기 (Lazy Instantiation) 탭이 선택될 때 그 탭의 위젯을 만든다. 앞에서 수정한 TabScreen 코드가 이 방식이다.\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 ... ), ); } } 사용하지 않는 탭은 Widget 객체조차 만들지 않는다. 트리에 붙는 것도 선택된 탭 하나다.\n탭을 바꿀 때마다 새 Widget 인스턴스를 만든다.\n1번과 마찬가지로 탭 상태는 유지되지 않는다.\n세 방식을 한 표로 모으면 이렇다.\n구분 List\u0026lt;Widget\u0026gt; IndexedStack 지연 인스턴스화 Widget 객체 생성 시점 State 생성 시 한 번 build마다 전부 탭이 선택될 때 트리에 붙어 있는 탭 선택된 탭 하나 전부 선택된 탭 하나 탭 상태 유지 안 됨 됨 안 됨 1번과 3번은 Widget 객체를 언제 만드느냐만 다르고, 메모리와 상태 유지 면에서는 거의 같다. 실제로 고를 때 기준이 되는 건 탭 상태를 유지해야 하느냐다. 유지해야 하면 IndexedStack, 아니면 3번처럼 build에서 고르는 쪽이 코드도 짧고 메모리도 적게 쓴다.\n","permalink":"https://jaylab.blog/flutter-bottom-nav-memory/","summary":"탭 위젯을 필드에 미리 만들어 두던 코드를 build에서 고르도록 바꾸고, 탭이 늘어날 때 쓸 세 가지 방식을 메모리와 상태 유지 기준으로 비교했다.","title":"BottomNavigationBar 탭 위젯의 생성 시점과 메모리"},{"content":"public 저장소는 URL만 있으면 누구나 clone할 수 있다. private 저장소는 다르다. GitHub에 내가 이 저장소를 볼 수 있는 사람이라는 걸 먼저 증명해야 한다.\n방법을 알려주는 글은 많고, 대부분 명령어 대여섯 줄로 끝난다. 나는 그 명령어들이 각각 무엇을 만들고 어디에 등록하는지가 궁금했다. 이 글에서는 SSH 키를 만들고 GitHub에 등록해 private 저장소를 clone하기까지의 순서와 그 사이에 일어나는 인증을 정리한다.\n공개키는 GitHub에, 개인키는 내 컴퓨터에 SSH 키는 공개키(public key)와 개인키(private key) 한 쌍이다. 둘은 수학적으로 연결되어 있다.\n공개키는 공유해도 된다. GitHub 계정에 등록해 두면 GitHub가 이 키로 나를 확인한다.\n개인키는 로컬 컴퓨터에만 두고 절대 공유하지 않는다. 내가 나라는 증거는 이 키에서 나온다.\nGitHub, GitLab 같은 Git 서비스는 저장소에 접근하거나 push할 때 이 키로 인증을 요구한다. 사용자 이름과 비밀번호를 쓰는 방식보다 안전해서 널리 쓰인다.\nssh-keygen으로 키 한 쌍을 만든다 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}: 키 길이(bit)다. 지정하지 않으면 3072가 적용된다.\n명령을 입력하면 개인키에 비밀번호(passphrase)를 걸지 묻는다.\nEnter passphrase (empty for no passphrase): [여기에 비밀번호 입력] Enter same passphrase again: [비밀번호 재입력] 비밀번호는 선택이다. 걸어 두면 개인키 파일이 유출되어도 비밀번호 없이는 쓸 수 없지만, 키를 쓸 때마다 비밀번호를 물어 온다. 엔터만 누르면 비밀번호 없는 개인키가 만들어진다. 접속이 편해지는 대신 그만큼 보안 수준은 낮아진다.\n옵션 없이 ssh-keygen만 입력해도 된다. 그러면 홈 디렉토리 아래 .ssh 폴더에 RSA 키 쌍을 만들고 저장 위치와 비밀번호만 물은 뒤 나머지는 기본값으로 진행한다. 파일 이름은 id_rsa와 id_rsa.pub가 된다. 위에서 옵션을 붙인 건 키 타입, 주석, 파일 이름을 직접 정하기 위해서다.\n이름을 바꾼 키는 ssh-agent에 올려야 쓰인다 ssh-agent를 실행한다.\neval \u0026#34;$(ssh-agent -s)\u0026#34; 방금 만든 개인키를 에이전트에 추가한다.\nssh-add ~/.ssh/{프라이빗키 이름} ssh-agent는 개인키를 메모리에 들고 있다가 인증이 필요할 때 대신 처리해 주는 프로그램이다. 개인키에 비밀번호를 걸었다면 ssh-add를 실행할 때 한 번만 입력하면 되고, 이후 접속에서는 다시 묻지 않는다. 키가 여러 개여도 필요한 키를 모두 ssh-add로 올려 두면 에이전트가 맞는 키를 골라 쓴다.\n-f로 이름을 바꿨다면 이 단계를 건너뛸 수 없다. ssh는 id_rsa 같은 기본 이름의 키만 알아서 찾는다. 기본 이름으로 만든 키라면 ssh-add 없이도 쓰이지만, 이름을 바꾼 키는 에이전트에 올리거나 ~/.ssh/config의 IdentityFile로 경로를 알려줘야 한다.\n에이전트에 개인키가 올라갔는지는 이렇게 확인한다.\nssh-add -l GitHub에 공개키를 등록한다 공개키 내용을 출력해서 복사한다.\ncd ~/.ssh cat {생성한 공개키 이름}.pub 복사한 내용을 **[GitHub] → [Settings] → [SSH and GPG keys]**에서 New SSH key로 붙여 넣는다.\n여기까지 하면 SSH 주소로 private 저장소를 clone할 수 있다.\ngit clone git@github.com:{private repository 소유자 이름}/{private repository 이름}.git clone할 때 개인키는 내 컴퓨터를 떠나지 않는다 git clone git@github.com:...을 실행하면 뒤에서 SSH 인증이 먼저 돈다.\nsequenceDiagram participant L as 로컬 (ssh + ssh-agent) participant G as GitHub L-\u0026gt;\u0026gt;G: SSH 연결 요청 L-\u0026gt;\u0026gt;G: 개인키로 만든 서명 전송 G-\u0026gt;\u0026gt;G: 등록된 공개키로 서명 검증, 계정 확인 G-\u0026gt;\u0026gt;G: 그 계정이 저장소에 접근 권한이 있는지 확인 G--\u0026gt;\u0026gt;L: 권한이 있으면 저장소 내용 전송 개인키로 서명한다. 로컬의 ssh는 ssh-agent에 올라간 개인키(또는 ~/.ssh의 기본 이름 키)로 서명을 만들어 GitHub에 보낸다. 개인키 파일 자체는 전송되지 않는다. 에이전트에 올리지 않은 키에 비밀번호가 걸려 있다면 이때 비밀번호를 묻는다.\nGitHub는 공개키로 서명을 검증한다. 계정에 등록된 공개키로 서명을 확인하고, 짝이 맞으면 그 공개키가 등록된 계정을 요청자로 본다. 공개키와 개인키가 정확히 한 쌍이어야 이 검증이 통과한다.\n저장소 접근 권한을 확인한다. 인증이 끝나면 GitHub는 그 계정이 요청한 private 저장소에 접근 권한이 있는지 본다. 권한이 있으면 git clone이 저장소 내용을 로컬로 복제한다.\n인증과 권한은 따로 확인된다. 키 등록이 잘 되었어도 저장소에 초대받지 않은 계정이면 clone은 실패한다.\n명령어만 모으면 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 토큰을 URL에 넣는 방법도 있다 키 없이 Personal Access Token으로 clone하는 방법도 있다.\n**[GitHub] → [Settings] → [Developer Settings]**의 Personal access tokens → Tokens (classic)에서 토큰을 발급한다.\n토큰을 URL에 넣어 clone한다.\ngit clone https://{닉네임}:{토큰}@github.com/{저장소의 주인 닉네임}/{저장소 이름}.git 훨씬 간단하다. 처음 써 보고는 보안 측면에서 괜찮을지 의문이 들었는데, 걸리는 지점이 실제로 있다. URL에 넣은 토큰은 평문으로 남는다. clone에 쓴 URL은 그대로 .git/config의 remote.origin.url에 저장되고 셸 히스토리에도 남는다. 한 번 쓰고 버릴 환경이 아니라면 SSH 키 쪽이 낫다고 본다.\n정리 공개키는 GitHub에 등록하고, 개인키는 로컬에만 둔다.\n-f로 이름을 바꾼 키는 ssh-add로 에이전트에 올려야 ssh가 쓴다.\nclone할 때 개인키는 전송되지 않는다. GitHub는 서명을 공개키로 검증해 계정을 찾고, 그 계정의 저장소 권한을 따로 확인한다.\n토큰을 URL에 넣는 방법은 간단하지만 토큰이 .git/config와 셸 히스토리에 평문으로 남는다.\n","permalink":"https://jaylab.blog/git-clone-private-repo/","summary":"private 저장소를 clone하려고 SSH 키를 만들며, 개인키는 로컬에 남고 GitHub는 공개키로 서명만 확인하는 인증 흐름을 정리했다.","title":"SSH 키 한 쌍으로 private 저장소 clone"},{"content":"헤더와 푸터를 페이지마다 복사해 넣지 않으려면 레이아웃이 필요하다. Nuxt3는 이걸 layouts 디렉토리와 이름 규칙으로 처리한다. 페이지 vue 파일에 따로 설정하지 않으면 그 페이지는 default 레이아웃으로 렌더링된다.\n이 글에서는 기본 레이아웃부터 잡고, 페이지마다 다른 레이아웃을 지정하는 법과 실행 중에 레이아웃을 바꾸는 법으로 넘어간다.\ndefault.vue가 모든 페이지를 감싼다 ~/layouts/ 경로에 default.vue 파일을 추가하면 기본 레이아웃이 된다. 가장 단순한 형태는 이렇다.\n\u0026lt;template\u0026gt; \u0026lt;div\u0026gt; \u0026lt;slot /\u0026gt; \u0026lt;/div\u0026gt; \u0026lt;/template\u0026gt; 공식 문서 기준으로 레이아웃이 적용되려면 app.vue에 \u0026lt;NuxtLayout\u0026gt;이 있어야 한다.\n실무에서 쓰고 있는 레이아웃은 헤더와 푸터 컴포넌트 사이에 \u0026lt;slot /\u0026gt;을 둔 모양이다.\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;!-- 페이지 컴포넌트가 이 자리에 렌더링된다 --\u0026gt; \u0026lt;slot /\u0026gt; \u0026lt;FooterLayout1 /\u0026gt; \u0026lt;/div\u0026gt; \u0026lt;/template\u0026gt; \u0026lt;slot /\u0026gt;은 페이지를 렌더링하는 특수한 태그다. 레이아웃에 \u0026lt;slot /\u0026gt;이 없으면 페이지가 들어갈 자리가 없다. 이상하게 동작할 수 있으니 레이아웃에는 반드시 넣는다.\n페이지 영역을 숨길 때는 slot을 감싼다 레이아웃은 그대로 두고 페이지 영역만 숨겨야 할 때가 있다. 이때도 \u0026lt;slot /\u0026gt;을 지우지 말고 v-show 같은 디렉티브로 제어한다. 단, v-show는 \u0026lt;slot /\u0026gt;이 아니라 그 상위 Element에 붙여야 한다.\n\u0026lt;template\u0026gt; \u0026lt;div\u0026gt; \u0026lt;Header /\u0026gt; \u0026lt;!-- ✓ v-show는 slot을 감싼 div에 건다 --\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; \u0026lt;slot /\u0026gt; 자체는 DOM Element로 남지 않고 내용으로 치환된다. 그래서 디렉티브를 걸 대상은 실제로 렌더링되는 상위 Element여야 한다. (레이아웃에는 \u0026lt;slot /\u0026gt;이 꼭 있어야 한다. 숨길 때는 slot을 감싼 상위 Element에 v-show를 건다.)\ndefinePageMeta로 페이지마다 레이아웃을 고른다 기본값은 default이고, 각 페이지 컴포넌트에서 definePageMeta로 쓸 레이아웃을 바꾼다. layout에 적는 이름은 layouts 디렉토리 안의 파일 이름이다. 아래 예시라면 layouts/custom.vue를 쓴다.\n\u0026lt;script lang=\u0026#34;ts\u0026#34; setup\u0026gt; definePageMeta({ layout: \u0026#34;custom\u0026#34;, }); \u0026lt;/script\u0026gt; setPageLayout으로 실행 중에 바꾼다 어떤 조건에 따라 레이아웃을 동적으로 바꾸고 싶다면 setPageLayout 메서드를 쓴다.\n\u0026lt;script lang=\u0026#34;ts\u0026#34; setup\u0026gt; // ... setPageLayout(\u0026#39;custom2\u0026#39;) \u0026lt;/script\u0026gt; definePageMeta가 페이지에 붙는 고정값이라면 setPageLayout은 실행 중에 부르는 함수다. 공식 문서는 서버 쪽에서 레이아웃을 바꿀 때 Vue가 레이아웃을 렌더링하기 전, 즉 plugin이나 route middleware 안에서 호출하라고 안내한다. 그렇지 않으면 hydration mismatch가 날 수 있다. layouts 문서의 컴포넌트 예시도 setup에서 바로 부르지 않고 버튼 클릭 핸들러 안에서 호출한다. 위 코드처럼 setup에서 부를 때는 이 점을 한 번 확인해 두는 게 좋다.\n","permalink":"https://jaylab.blog/nuxt-layout-setting/","summary":"Nuxt3 페이지는 설정이 없으면 default 레이아웃을 쓴다. definePageMeta로 페이지마다 고정하고 setPageLayout으로 실행 중에 바꾼다.","title":"Nuxt3의 default 레이아웃과 페이지별 전환"},{"content":"Nuxt 3에서 useAsyncData 예시를 찾아보면 두 가지 코드가 섞여 나온다. 하나는 브라우저 표준 fetch를, 다른 하나는 $fetch를 쓴다. 둘 다 useAsyncData의 두 번째 인자로 들어가 있어서 얼핏 보면 같은 일을 하는 것 같다.\n두 코드를 나란히 놓고 무엇이 다른지, $fetch를 왜 useAsyncData로 감싸야 하는지 정리한다.\n같은 자리에 들어간 두 함수 먼저 fetch를 쓴 예시다.\n// Nuxt 3 정식판에서는 useAsyncData가 auto-import되므로 이 import 줄은 없어도 된다 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()) // 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;) // 파싱된 결과가 바로 온다 ) 두 코드 모두 useAsyncData의 두 번째 인자로 함수를 넘긴다. 이 함수가 데이터를 가져오는 비동기 작업을 맡고, 함수가 돌려준 Promise의 결과값이 useAsyncData의 data가 된다.\n차이는 응답을 다루는 부분에 있다. 첫 번째 코드는 fetch로 요청을 보낸 뒤 .then에서 response.json()을 직접 부른다. response.json()은 Fetch API가 제공하는 메서드로, 응답 본문을 JSON으로 파싱한다. 두 번째 코드에는 이 호출이 없다. $fetch는 내부적으로 fetch를 쓰면서 응답을 JSON으로 파싱하는 일까지 대신 한다.\nfetch는 표준, $fetch는 그 위에 얹은 것 fetch는 웹 브라우저와 Node.js에서 모두 쓸 수 있는 HTTP 요청 함수다. 서버에 데이터를 요청하거나 보낼 때 쓰고, Promise를 반환하므로 async/await로 다룰 수 있다.\n$fetch는 Nuxt 3가 전역으로 제공하는 함수다. 내부에서 fetch로 요청을 보내고, 그 위에 몇 가지 처리를 더한다. 자동 JSON 파싱이 그중 하나이고, 에러 처리도 들어 있다. 표준 fetch는 서버가 404나 500을 돌려줘도 Promise를 reject하지 않아서 response.ok를 직접 확인해야 한다. $fetch는 이런 응답을 에러로 던져 준다.\n서버에서 부를지 브라우저에서 부를지는 SSR이 정한다 API를 어디서 부르느냐에 따라 페이지가 만들어지는 흐름이 달라진다.\n브라우저에서 부르는 경우: Nuxt 웹서버가 클라이언트에게 페이지 제공 → 브라우저에서 API 호출 → API 결과로 CSR\nSSR 중 서버에서 부르는 경우: Nuxt 웹서버가 직접 API 호출 → API 결과값으로 SSR된 페이지를 클라이언트에 제공\n두 번째 흐름에서는 클라이언트가 API를 따로 부를 필요 없이 데이터가 채워진 페이지를 받으니 첫 로딩이 빠르다.\n다만 서버에서 호출되는 건 $fetch만의 성질이 아니다. SSR 동안 \u0026lt;script setup\u0026gt;은 서버에서 먼저 실행되니, 그 안에 있는 fetch든 $fetch든 서버에서 한 번 돈다. $fetch가 서버 쪽에서 더 해 주는 일은 따로 있다. 서버에서 /api/... 같은 Nuxt 내부 API 경로를 부르면 HTTP 요청을 새로 보내지 않고 해당 핸들러 함수를 바로 호출한다.\nuseAsyncData로 감싸지 않으면 두 번 호출된다 문제는 SSR이 끝난 뒤다. 컴포넌트에서 $fetch를 useAsyncData로 감싸지 않고 쓰면, 처음에는 서버에서 한 번, 하이드레이션 중에 클라이언트에서 또 한 번 데이터를 가져온다. $fetch는 서버에서 가져온 결과를 클라이언트로 넘기지 않기 때문이다. 서버에서 가져온 결과를 페이지에 실어 클라이언트로 넘기는 일은 useAsyncData가 한다.\nsequenceDiagram participant S as Nuxt 서버 participant B as 브라우저 participant A as API S-\u0026gt;\u0026gt;A: $fetch (SSR) A--\u0026gt;\u0026gt;S: 데이터 S-\u0026gt;\u0026gt;B: 렌더링된 HTML Note over B: 하이드레이션 B-\u0026gt;\u0026gt;A: $fetch 한 번 더 A--\u0026gt;\u0026gt;B: 같은 데이터 컴포넌트에서 $fetch를 쓸 때는 useAsyncData로 감싼다. useFetch는 이 조합의 줄임말이다.\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의 줄임말인 useFetch를 써도 된다 // (한 파일에 그대로 두면 data가 중복 선언되니, 셋 중 하나만 쓴다) const { data } = await useFetch(\u0026#39;/api/item\u0026#39;) \u0026lt;/script\u0026gt; useFetch와 useAsyncData가 서로 어떻게 다른지는 앞선 글에서 따로 다뤘다.\n정리하면,\n$fetch는 fetch 위에 JSON 파싱과 에러 처리를 얹은 함수라서 response.json()을 따로 부르지 않아도 된다.\nSSR 중 서버에서 호출되는 건 setup이 서버에서 먼저 돌기 때문이고, 결과를 클라이언트로 넘기는 건 useAsyncData의 몫이다.\n컴포넌트에서는 $fetch를 useAsyncData로 감싸거나 useFetch를 쓴다. 그냥 쓰면 같은 요청이 두 번 나간다.\n","permalink":"https://jaylab.blog/nuxt-about-fetch/","summary":"$fetch는 fetch에 JSON 파싱과 에러 처리를 얹은 함수이고, 컴포넌트에서는 useAsyncData로 감싸야 두 번 호출되지 않는다.","title":"Nuxt 3의 $fetch가 fetch보다 더 해 주는 일"},{"content":"Nuxt 3는 데이터를 가져오는 composable로 useAsyncData와 useFetch를 제공한다. 두 코드는 모양이 거의 같아서 처음 보면 언제 어느 쪽을 써야 하는지 헷갈린다.\n이 글의 처음 버전에서는 둘의 차이를 실행 시점으로 설명했다. useFetch는 created 이후에, useAsyncData는 created 이전에 돈다는 식이었다. 다시 확인해 보니 그건 Nuxt 2의 fetch 훅과 asyncData 이야기였다. 여기서는 Nuxt 3 기준으로 두 composable이 실제로 어떻게 다른지 보고, useAsyncData의 key 캐싱과 refresh를 정리한다.\n실행 시점은 둘이 같다 Nuxt 2에서는 시점 차이가 맞는 설명이었다. asyncData는 페이지 컴포넌트를 불러오기 전에 실행되므로 this에 접근할 수 없다. fetch 훅은 컴포넌트 인스턴스가 만들어진 뒤에 돌기 때문에 this를 쓸 수 있다.\nNuxt 3의 useAsyncData와 useFetch는 둘 다 setup 함수(또는 플러그인, 라우트 미들웨어) 안에서 호출하는 composable이다. Vue 3에서 setup은 beforeCreate, created보다 먼저 실행되므로 둘 사이에 created 이전·이후 같은 시점 차이는 없다. 같은 자리에서 호출하면 같은 때에 돈다.\nuseFetch는 useAsyncData를 감싼 것이다 차이는 시점이 아니라 감싼 정도에 있다. 공식 문서는 useFetch를 useAsyncData와 $fetch를 감싼 wrapper라고 설명한다. URL과 옵션으로 key를 자동으로 만들고, 요청은 $fetch로 보낸다.\n// useFetch: key를 URL과 옵션으로 자동 생성하고 $fetch로 요청한다 const { data: a } = await useFetch(\u0026#39;http://localhost:8080/api/user/1\u0026#39;) // 위 한 줄을 useAsyncData로 풀어 쓰면 대략 이렇다 const { data: b } = await useAsyncData(\u0026#39;user\u0026#39;, () =\u0026gt; $fetch(\u0026#39;http://localhost:8080/api/user/1\u0026#39;)) URL 하나로 끝나는 요청이면 useFetch로 충분하다. 여러 API를 묶거나 응답을 가공하느라 fetcher 함수를 직접 짜야 할 때 useAsyncData를 꺼낸다. $fetch 자체는 이 글에서 다루지 않는다.\n두 composable이 공통으로 해 주는 일도 있다. 공식 문서의 useAsyncData 설명을 옮기면 이렇다.\nuseAsyncData는 setup 함수, 플러그인 또는 라우트 미들웨어에서 직접 호출되는 컴포저블(composable)이다. 반응성을 갖는 값을 반환하며, 서버에서 받은 응답을 Nuxt payload에 담는다. 그래서 페이지가 hydrate될 때 클라이언트에서 같은 데이터를 다시 가져오지 않고 서버에서 받은 데이터를 그대로 쓴다.\n서버에서 한 번 받은 데이터를 클라이언트가 다시 요청하지 않는다는 점이 이 composable들을 쓰는 이유다. 컴포넌트 안에서 fetch를 그냥 부르면 서버 렌더링 때 한 번, 클라이언트에서 한 번, 두 번 요청이 나간다.\nuseAsyncData의 인자와 반환값 const { data, pending, error, refresh } = await useAsyncData(key, fetcher) 인자는 두 개다.\nkey: 캐시된 데이터를 가져오거나 저장할 때 쓰는 고유한 키\nfetcher: 비동기 작업을 수행하는 함수\n반환값은 네 개다.\ndata: fetcher 함수가 반환하는 값. 아직 데이터가 로드되지 않았거나 오류가 나면 null이다.\npending: 데이터를 로드 중인지 알려 주는 불리언. 가져오는 동안은 true, 그 외에는 false다.\nerror: 데이터를 가져오다 발생한 오류. 오류가 없으면 null이다.\nrefresh: 호출하면 fetcher를 다시 실행해 데이터를 새로 가져온다. 아래에서 따로 본다.\n실제로 쓰면 이런 모양이다.\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 } } } 'user'라는 key로 http://localhost:8080/api/user/1에서 사용자 정보를 가져온다. Nuxt 3에서는 useAsyncData가 auto-import되므로 import 문 없이 써도 된다.\nkey로 캐시하면 refresh로 갱신한다 'user'는 데이터를 캐싱하는 데 쓰는 고유 식별자다. 같은 key로 비동기 데이터 요청이 여러 번 일어나면 Nuxt 3는 캐시된 데이터를 돌려주고 중복 요청을 막는다. 이 key는 Nuxt 안에서만 쓰이고 API 서버로 보내는 요청에는 실리지 않는다.\n문제는 신선도다. 서버의 데이터가 바뀌어도 클라이언트의 캐시는 그대로라서 클라이언트는 최신 데이터를 보지 못한다. 캐시를 쓰는 대가로 갱신 시점은 직접 정해야 한다. 그때 쓰는 것이 refresh로, 호출하면 fetcher를 다시 실행해 캐시를 갱신하고 최신 데이터를 가져온다.\nimport { 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()) ) // 버튼을 누르면 fetcher를 다시 실행한다 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; ` } 사용자 정보를 조회해 이름과 이메일을 화면에 보여 준다. Refresh Data 버튼을 누르면 refreshData가 호출되고 서버에서 최신 데이터를 다시 받아 화면에 반영한다.\n","permalink":"https://jaylab.blog/nuxt-usefetch-useasyncdata/","summary":"useFetch는 useAsyncData에 $fetch와 자동 key를 얹은 것이고, key로 캐시된 데이터는 refresh로 다시 받는다.","title":"useFetch 안에 들어 있는 useAsyncData"},{"content":"고객사 인프라 구성이 바뀌면서 WAS가 여러 대가 됐고 세션을 WAS끼리 나눠 쓸 방법이 필요해졌다. 세션 클러스터링 자체는 어려운 작업이 아니다. 조건이 문제였다. Spring Framework 버전이 낮았고 세션을 담을 AWS ElastiCache for Redis에는 저장 중 암호화와 전송 중 암호화가 모두 켜져 있었다. 이 조합에서는 흔히 알려진 방법만으로는 세션 클러스터링이 동작하지 않았다.\n시행착오는 많았지만 이 글에서는 최종적으로 동작한 설정만 정리했다.\n작업 환경 AWS ElastiCache for Redis 포트: 6480\n저장 중 암호화, 전송 중 암호화: 모두 활성화\nSpring Framework: 4.3.7 (※ 스프링프레임워크 최신 버전에서는 더 쉬운 세션 클러스터링 방법이 있다..)\n세션은 애플리케이션이 아니라 Tomcat에서 가로챈다 고른 방법은 Redisson의 Tomcat Session Manager다. Tomcat은 세션 저장을 Manager라는 컴포넌트에 맡기는데, 이 Manager를 Redisson 구현체로 바꾸면 세션이 WAS 메모리 대신 Redis에 저장된다. 세션 저장소를 컨테이너 수준에서 바꾸기 때문에 애플리케이션 코드와 Spring 버전은 건드리지 않는다. 낮은 Spring 버전을 그대로 둬야 하는 상황에서 이 점이 맞았다.\ngraph LR U[사용자] --\u0026gt; LB[Load Balancer] LB --\u0026gt; W1[WAS 1\u0026lt;br/\u0026gt;RedissonSessionManager] LB --\u0026gt; W2[WAS 2\u0026lt;br/\u0026gt;RedissonSessionManager] W1 -- \u0026#34;rediss:// (TLS)\u0026#34; --\u0026gt; R[(ElastiCache for Redis\u0026lt;br/\u0026gt;:6480)] W2 -- \u0026#34;rediss:// (TLS)\u0026#34; --\u0026gt; R 암호화 설정 중 클라이언트 설정에 영향을 주는 쪽은 전송 중 암호화다. 저장 중 암호화는 ElastiCache가 디스크에 쓸 때 처리하는 일이라 클라이언트는 신경 쓸 게 없다. 반면 전송 중 암호화가 켜지면 TLS로만 접속할 수 있어서 클라이언트가 평문 redis://로 붙으면 연결이 되지 않는다. 뒤에 나오는 redisson.yaml의 SSL 설정이 이 때문에 필요하다.\n1. Redisson jar를 Tomcat lib에 넣는다 Redisson Tomcat README에서 두 파일을 받는다.\nredisson-all-3.22.1.jar\nredisson-tomcat-8-3.22.1.jar\n받은 파일을 설치된 Tomcat의 lib 폴더로 옮긴다. redisson-tomcat-8은 Tomcat 8용이므로 Tomcat 버전이 다르면 그에 맞는 jar를 받아야 한다.\n2. context.xml에 Session Manager를 지정한다 {tomcat_base}/context.xml의 Context 태그 안에 넣는다. Redisson README는 Manager를 붙이는 방법을 두 가지로 안내한다. 하나는 Manager가 configPath로 설정 파일을 직접 읽는 방식이고 다른 하나는 JNDI에 등록한 RedissonClient를 Manager가 가져다 쓰는 방식이다.\n\u0026lt;!-- 방법 A: Manager가 redisson.yaml을 직접 읽는다 --\u0026gt; \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;!-- 방법 B: server.xml에 등록한 JNDI 리소스(bean/redisson)를 가져다 쓴다 --\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; 두 방법은 하나만 고른다. Context 하나에 Manager가 둘 있으면 둘 중 하나만 적용된다. 아래 3단계의 server.xml 설정은 방법 B에 필요한 것이다.\nreadMode=\u0026quot;REDIS\u0026quot;는 세션 속성을 요청마다 Redis에서 읽는다는 뜻이다. README에 따르면 다른 값인 MEMORY는 로컬 세션에도 속성을 두고 Redis와 동기화한다.\n3. server.xml에 JNDI 리소스를 등록한다 {tomcat_base}/server.xml의 GlobalNamingResources 태그 안에 넣는다. 방법 B의 bean/redisson이 여기서 만들어진다.\n\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; 4. redisson.yaml에서 TLS로 접속하게 한다 {tomcat_base}/conf/redisson.yaml을 새로 만든다. 핵심은 주석을 단 두 곳, 주소의 rediss:// 스키마와 SSL 설정이다.\nsingleServerConfig: idleConnectionTimeout: 10000 connectTimeout: 10000 timeout: 10000 retryAttempts: 20 retryInterval: 2000 password: null subscriptionsPerConnection: 5 clientName: null # \u0026#34;rediss://\u0026#34; 스키마 사용 (s가 하나 더 붙으면 TLS 접속) address: \u0026#34;rediss://{생성된 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; sslEnableEndpointIdentification: false는 인증서의 호스트 이름 검증을 끈다. 접속은 편해지지만 검증 하나를 포기하는 설정이라, 가능하면 켠 상태로 붙는지 한 번 확인해 볼 만하다.\n각 WAS에 같은 설정을 마친 뒤 재시작하면 세션 클러스터링이 동작한다.\n정리 Spring 버전을 올릴 수 없는 상황이라면 세션 문제는 애플리케이션 밖, Tomcat의 Manager에서 푸는 편이 빠르다. 암호화된 ElastiCache에 붙을 때는 rediss://와 SSL 설정 두 줄만 기억하면 된다. 시행착오는 많았는데, 남은 설정은 결국 이 정도다.\n참고자료 Redisson Tomcat Session Manager README — jar 다운로드와 Manager 설정 두 가지 방법, readMode 설명\nElastiCache in-transit encryption (TLS) — 전송 중 암호화가 켜진 클러스터에 TLS로 접속해야 하는 이유\n","permalink":"https://jaylab.blog/redis-session-clustering/","summary":"Spring 4.3.7 기반 다중 WAS의 세션을, 전송 중 암호화가 켜진 ElastiCache for Redis에 Redisson Tomcat Session Manager로 모아 클러스터링했다.","title":"Spring 4.3 WAS의 세션을 암호화된 ElastiCache에 모으기"},{"content":"투입된 프로젝트의 서비스에 데이터를 조회하는 화면이 하나 있었다. 검색 조건을 고르고 검색어를 넣으면 그 값을 포함한 데이터가 나온다. 그런데 이 화면을 쓰는 업무는 여러 건을 함께 확인하는 일이 잦았고, 검색어를 하나밖에 못 받으니 일일이 한 건씩 조회해야 했다. 번거롭다는 요청이 들어왔다.\n입력창은 그대로 두고, 콤마나 공백으로 검색어를 여러 개 이어 넣으면 각각을 포함하는 데이터를 한 번에 돌려주도록 바꿨다. 이 글은 그때 작업한 내용 중 핵심만 추린 기록이다.\n기존 쿼리는 검색어 하나를 LIKE로 찾는다 \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; 검색 조건(method_id)에 따라 비교할 컬럼을 고르고, 화면에서 넘어온 문자열(method_info) 하나와 비교한다. GK ID만 =로 정확히 일치하는 값을 찾고, 부모 티켓 ID와 티켓 ID는 UPPER로 대소문자를 맞춘 뒤 LIKE로 포함 여부를 본다. GUID도 LIKE다.\nREPLACE(..., '_', '#_')와 ESCAPE '#'은 와일드카드 처리다. LIKE에서 _는 \u0026ldquo;아무 글자 하나\u0026quot;라는 뜻이라 ID 안에 있는 _를 글자 그대로 찾으려면 이스케이프해야 한다.\nIN이 아니라 OR로 묶는다 여러 값을 한 번에 찾을 때 먼저 떠오르는 건 IN (...)이다. 하지만 IN은 값이 정확히 같을 때만 걸린다. 이 화면은 검색어를 \u0026ldquo;포함하는\u0026rdquo; 데이터를 찾아야 하니 LIKE 조건 여러 개를 OR로 이어야한다. MyBatis에서는 \u0026lt;foreach\u0026gt;의 separator에 OR를 주었다.\n범위는 GK ID, 부모 티켓 ID, 티켓 ID 세 가지로 잡았다. GUID는 기존처럼 단일 검색으로 남겼다.\n서비스에서 검색어를 배열로 쪼갠다 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검색 조건이 세 가지 중 하나인지 확인한다.\n연속된 공백을 하나로 줄이고 앞뒤 공백을 걷어낸다.\nsplit(\u0026quot;[\\\\s,]+\u0026quot;)로 공백과 콤마를 구분자 삼아 자른다. 정규식 끝의 + 덕분에 A1, B2처럼 콤마 뒤에 공백이 붙어도 구분자 하나로 처리된다. 사실 이 +만으로도 연속 공백은 한 덩어리가 되니 바로 앞 줄의 replaceAll은 없어도 결과가 같다.\nGK ID면 값마다 GkUtils.getPlainGkId를 거친 뒤, 배열을 method_info_list라는 이름으로 파라미터 Map에 넣는다.\nforeach의 괄호가 삭제 필터를 지킨다 \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; \u0026lt;choose\u0026gt;로 배열에 값이 두 개 이상일 때만 \u0026lt;foreach\u0026gt;를 타고, 하나뿐이면 기존 조건을 그대로 쓴다. \u0026lt;foreach\u0026gt;는 배열을 돌며 조건을 하나씩 찍어 사이사이에 separator인 OR를 넣고, 전체를 open과 close의 괄호로 감싼다. 티켓 ID 조건으로 A1, B2 C3를 넣으면 WHERE 절은 이렇게 된다.\nWHERE DEL_YN_FG = \u0026#39;N\u0026#39; AND ( TKT_ID LIKE CONCAT(\u0026#39;%\u0026#39;, REPLACE(?, \u0026#39;_\u0026#39;, \u0026#39;#_\u0026#39;), \u0026#39;%\u0026#39;) ESCAPE \u0026#39;#\u0026#39; OR TKT_ID LIKE CONCAT(\u0026#39;%\u0026#39;, REPLACE(?, \u0026#39;_\u0026#39;, \u0026#39;#_\u0026#39;), \u0026#39;%\u0026#39;) ESCAPE \u0026#39;#\u0026#39; OR TKT_ID LIKE CONCAT(\u0026#39;%\u0026#39;, REPLACE(?, \u0026#39;_\u0026#39;, \u0026#39;#_\u0026#39;), \u0026#39;%\u0026#39;) ESCAPE \u0026#39;#\u0026#39; ) -- ?에는 A1, B2, C3가 차례로 바인딩된다 SQL에서는 AND가 OR보다 먼저 묶인다. 괄호 없이 DEL_YN_FG = 'N' AND a OR b OR c가 되면 실제로는 (DEL_YN_FG = 'N' AND a) OR b OR c로 해석된다. 괄호가 빠지면 b나 c에 걸리는 삭제된 데이터까지 조회된다.\n","permalink":"https://jaylab.blog/delimiter-multi-searching/","summary":"검색어 하나만 받던 조회 화면을 콤마·공백으로 쪼개 MyBatis foreach OR 조건으로 여러 건을 한 번에 찾게 바꾸고, 다시 보니 걸리는 점을 짚었다.","title":"콤마와 공백으로 여러 건 한 번에 검색하기"},{"content":"프로젝트의 스케줄 작업은 전부 코드 한 줄에 기대고 있었다. if(\u0026quot;1\u0026quot;.equals(GkConstant.WAS_NUMBER)). WAS 두 대가 똑같은 @Scheduled 메서드를 갖고 있지만, VM 옵션의 시스템 프로퍼티가 1인 서버에서만 실제로 배치가 돈다.\n인프라를 Autoscaling 구성으로 전환하는 일정이 잡히면서 이 한 줄이 문제가 됐다. 서버가 동적으로 생기고 사라지면 \u0026ldquo;1번 서버\u0026quot;라는 이름을 붙일 대상이 없다. 서버가 몇 대든 배치가 정확히 한 번 돌게 하려면, 실행 시점을 정하는 주체를 서버 밖으로 빼야 했다. 이 글에서는 그 판단과, EventBridge Scheduler와 Lambda로 트리거를 옮긴 과정을 정리한다.\n1번 서버라는 기준 기존 환경은 이랬다.\nAutoscaling이 적용되지 않은 WAS EC2 2대\nSpring 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;); } } } cron은 두 서버 모두에서 매일 09:30에 깨어나지만, 실제로 메일을 보내는 건 WAS_NUMBER가 1인 서버뿐이다. 서버 대수가 고정되어 있고 사람이 서버마다 번호를 붙여 두는 동안에는 문제가 없는 방식이다.\nAutoscaling이 이 조건을 깨는 방식 Autoscaling 그룹의 인스턴스는 같은 Launch Template으로 찍혀 나온다. 번호를 어떻게 붙이느냐에 따라 결과는 둘 중 하나다.\n모든 인스턴스가 1을 갖는다. 서버가 세 대로 늘면 같은 메일이 세 번 나간다.\n아무 인스턴스도 1을 갖지 않거나, 1을 가진 인스턴스가 스케일 인으로 종료된다. 배치가 아예 돌지 않는다.\n중복 실행이든 누락이든, 원인은 서버가 자기 자신이 실행 담당인지 스스로 판단한다는 데 있다. 서버 수가 고정일 때만 그 판단이 맞는다.\nShedLock 대신 트리거를 밖으로 뺀 이유 멀티 서버 환경에서 Spring 스케줄 중복 실행을 막는 흔한 방법은 ShedLock의 @SchedulerLock이다. 공유 저장소에 락을 걸어 한 서버만 실행하게 한다. 그런데 이 프로젝트는 Spring Framework 버전이 낮아서 이런 최신 라이브러리를 적용하기 어려웠다.\n그래서 방향을 바꿨다. 서버들이 \u0026ldquo;누가 실행할지\u0026quot;를 합의하게 만드는 대신, 서버 밖에서 정해진 시각에 API를 한 번 호출한다.\nAWS EventBridge Scheduler가 매일 09:30에 Lambda를 호출한다.\nLambda가 WAS 앞에 있는 Internal ALB의 DNS로 요청을 보낸다.\n요청을 받은 애플리케이션 서버가 (수정된) 스케줄 로직을 실행한다.\nflowchart LR S[\u0026#34;EventBridge Scheduler\u0026lt;br/\u0026gt;매일 09:30\u0026#34;] --\u0026gt;|\u0026#34;{\u0026#39;key\u0026#39;: \u0026#39;admin/...\u0026#39;}\u0026#34;| L[\u0026#34;Lambda\u0026lt;br/\u0026gt;gk-*-scheduling-lambda\u0026#34;] L --\u0026gt;|POST| A[\u0026#34;Internal ALB\u0026#34;] A --\u0026gt; W1[\u0026#34;WAS 1\u0026#34;] A -.-\u0026gt; W2[\u0026#34;WAS 2\u0026#34;] A -.-\u0026gt; W3[\u0026#34;WAS N\u0026#34;]이 방식이 되는 이유는 ALB에 있다. ALB는 요청 하나를 등록된 타겟 중 한 대에만 전달한다. 서버가 두 대든 다섯 대든 Lambda 호출 한 번은 배치 실행 한 번이 된다. 서버는 더 이상 자기 번호를 알 필요가 없고, 스케일 인으로 어떤 서버가 사라져도 남은 서버 중 하나가 요청을 받는다.\nLambda 함수: 경로를 받아 ALB에 POST 하나 Python 코드 로컬에 lambda_function.py를 만들고 아래 코드를 넣었다. ALB 주소는 환경변수 ALB_URL에서 읽고, 이벤트에 key가 있으면 그 값을 경로로 붙인다.\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;) } key 덕분에 Lambda 하나로 여러 배치를 부를 수 있다. 운영 로그를 보면 EventBridge 쪽에서 {'key': 'admin/deleteLongTermNoUseAdmin'}, {'key': 'admin/notifyLongTermNoUseAdmin'}처럼 배치마다 다른 경로를 넘긴다.\nrequests 라이브러리 패키징 Lambda의 Python 런타임에는 requests가 기본으로 들어 있지 않아서, 코드와 함께 압축해 올렸다.\n코드가 있는 경로에 requirements.txt를 만든다.\n내용은 한 줄이다.\nrequests==2.22.0 이어서 같은 경로에서 라이브러리를 현재 디렉토리에 설치하고, 전체를 zip으로 묶는다.\npip3 install -r requirements.txt -t . zip -r scheduler.zip . Lambda 생성과 설정 DEV 기준으로 gk-dev-scheduling-lambda를 만들었다.\n설정 값 런타임 Python 3.9 아키텍처 x86_64 VPC·서브넷 WAS가 속한 private 서브넷 2개 보안그룹 Gk_dev_lambda (dev ALB로 가는 443 포트 outbound 허용) Internal ALB는 VPC 밖에서 닿지 않으니 Lambda도 같은 VPC의 private 서브넷에 넣어야 한다. 보안그룹은 양쪽을 다 열어야 한다. Lambda 쪽 Gk_dev_lambda에 443 outbound를, ALB 쪽 보안그룹에 Gk_dev_lambda로부터의 inbound를 허용한다.\n생성이 끝나면 코드 탭에서 \u0026ldquo;.zip 파일\u0026quot;로 scheduler.zip을 업로드하면 소스와 라이브러리가 함께 올라간다.\n마지막으로 [구성] 탭의 [환경 변수]에서 키 ALB_URL, 값 {Internal-ALB-DNS}로 호출할 로드밸런서 주소를 등록한다.\n통신 테스트는 health check로 배치 API를 바로 호출하면 테스트 한 번에 실제 메일이 나갈 수 있으니, Lambda와 WAS 사이 연결은 health check 엔드포인트로 먼저 확인했다. 코드 안에 테스트용 블록을 주석으로 넣어 둔 이유다.\n아래 화면의 번호 순서대로 진행한다.\n1번 requests.post 줄을 주석 처리\n2번 CONNECTION TEST CODE 블록의 주석 풀기\n저장 후 3번 [Deploy]로 배포\n배포가 끝나면 4번 [Test]\nhealth check 응답에 \u0026ldquo;up\u0026rdquo; → 통신 정상\n✅통신 테스트가 끝나면 반드시 코드를 원복한다. 1번 코드의 주석을 풀고, 2번 코드를 다시 주석 처리한 뒤, 저장(Ctrl + S)하고 [Deploy]로 배포한다. 09:30에 한 번씩 실행됐는지 로그로 확인 운영 환경에서는 두 군데 로그를 봤다.\n먼저 Lambda 쪽이다. CloudWatch \u0026gt; 로그 그룹 \u0026gt; /aws/lambda/gk-prd-scheduling-lambda에 날마다 로그 스트림이 하나씩 쌓이고, 2023-08-31 스트림의 마지막 이벤트 시간은 09:30:40이다.\n스트림 안을 보면 09:30:22에 admin/deleteLongTermNoUseAdmin, 09:30:40에 admin/notifyLongTermNoUseAdmin 경로로 각각 한 번씩 호출됐다.\nWAS 쪽은 애플리케이션 로그 그룹에서 SUCCESS로 검색했다. deleteLongTermNoUseAdmin Batch와 notifyLongTermNoUseAdmin Batch의 성공 로그가 각각 한 줄씩 남아 있다.\n아직 남은 문제 Lambda는 API가 실패해도 정상 종료한다. 응답 코드가 200이 아니면 'API Fail'을 담은 dict를 반환할 뿐 예외를 던지지 않는다. Lambda 호출 자체는 성공으로 기록되니 EventBridge Scheduler의 재시도도 걸리지 않는다. 실패는 로그를 직접 봐야 알 수 있다.\nverify=False로 인증서 검증을 끄고 있다. 호출할 때마다 InsecureRequestWarning이 로그에 남는 것도 이 때문이다.\n배치 API는 VPC 안에서 누구나 호출할 수 있는 엔드포인트다. 보안그룹으로 ALB 접근을 좁혀 두긴 했지만, 요청 자체를 검증하는 장치는 코드에 없다.\n정리 Autoscaling에서는 \u0026ldquo;1번 서버\u0026quot;라는 기준이 성립하지 않는다. 같은 템플릿으로 찍힌 서버는 중복 실행하거나 아무도 실행하지 않는다.\n실행 시점을 서버 밖으로 빼면 중복 문제가 ALB의 라우팅으로 풀린다. EventBridge Scheduler가 Lambda를 부르고, Lambda가 Internal ALB에 요청 하나를 보내면 서버 한 대만 배치를 돈다.\n이벤트의 key를 경로로 붙이면 Lambda 하나로 여러 배치를 부를 수 있다.\n실패 감지와 재시도, 엔드포인트 보호는 아직 숙제다.\nSpring 버전만 받쳐 줬다면 @SchedulerLock 한 줄로 끝났을 일이다. 대신 스케줄이 애플리케이션 밖으로 나오면서, 언제 어떤 배치가 호출됐는지가 Lambda 로그 스트림에 날짜별로 남는다.\n","permalink":"https://jaylab.blog/scheduler-transition/","summary":"Autoscaling으로 1번 서버라는 기준이 사라지자, 스케줄 트리거를 EventBridge Scheduler와 Lambda로 옮겨 Internal ALB 호출 한 번이 배치 한 번이 되게 바꿨다","title":"Autoscaling 환경에서 @Scheduled 배치를 한 번만 실행하기"},{"content":"터미널에서 작업하다 보면 지금 있는 디렉토리를 VS Code로 바로 열고 싶을 때가 있다. Finder에서 폴더를 찾아 끌어다 놓거나, VS Code를 켜고 \u0026ldquo;열기\u0026quot;에서 경로를 다시 찾아가는 건 이미 터미널에 그 경로가 있는데도 같은 길을 두 번 걷는 일이다.\n이때 쓰는 명령이 code다. 현재 위치를 열 때는 code ., 다른 경로를 열 때는 경로를 넘긴다.\n# 현재 디렉토리를 VS Code로 연다 code . # 원하는 경로를 VS Code로 연다 code ~/열고싶은 디렉토리 경로 다만 VS Code를 설치했다고 이 명령이 바로 되지는 않는다. code는 VS Code 안에서 한 번 PATH에 설치해 줘야 쓸 수 있는 명령이다. 이 글에서는 그 설치 과정과 설치하다 만난 권한 에러를 정리한다.\nCommand Palette에서 code 명령을 PATH에 넣는다 VS Code를 열고 cmd + shift + p로 Command Palette를 띄운다.\ncode를 입력하고 Shell Command: Install 'code' command in PATH를 누른다.\n한국어 UI에서는 \u0026ldquo;셸 명령: PATH에 \u0026lsquo;code\u0026rsquo; 명령 설치\u0026quot;로 보인다. 바로 아래에 있는 \u0026ldquo;PATH에서 \u0026lsquo;code\u0026rsquo; 명령 제거\u0026quot;는 반대로 명령을 빼는 항목이다.\n이것으로 끝이다. 새 터미널에서 code .를 치면 현재 디렉토리가 VS Code로 열린다.\nEACCES 에러가 나면 기존 파일을 지우고 다시 설치한다 설치 항목을 눌렀는데 아래와 같은 에러가 뜰 때가 있다.\n메시지는 EACCES: permission denied, unlink '/usr/local/bin/code'다. unlink는 파일을 지우는 시스템 호출이다. 설치 명령이 새 파일을 만들기 전에 이미 있는 /usr/local/bin/code를 지우려다 권한이 없어 멈춘 것이다. 예전에 설치해 둔 code가 지금 사용자 권한으로는 지울 수 없는 상태로 남아 있다는 뜻이다.\n그러니 그 파일을 관리자 권한으로 직접 지우고, 위의 설치 과정을 다시 하면 된다.\n# 권한 때문에 VS Code가 지우지 못한 기존 code 파일을 직접 지운다 sudo rm -rf /usr/local/bin/code 지운 뒤 Command Palette에서 Shell Command: Install 'code' command in PATH를 다시 누른다. sudo와 rm -rf를 같이 쓰는 명령이라 경로는 한 번 더 보고 치는 편이 좋다.\n참고로 code .는 VS Code를 설치한다고 따라오는 명령이 아니라 Command Palette에서 한 번 PATH에 넣어 줘야 생긴다. 설치 중 EACCES 에러가 나면 기존 /usr/local/bin/code를 지우고 다시 설치하면 된다.\n","permalink":"https://jaylab.blog/vscode-open-command/","summary":"터미널에서 code .로 VS Code를 열려면 code 명령을 PATH에 설치해야 하고, 설치 중 EACCES 에러는 기존 파일을 지우면 풀린다.","title":"터미널에서 code . 로 VS Code 열기"},{"content":"input에 들어온 값을 대문자나 소문자로 바꾸는 코드는 toUpperCase() 한 줄이다. 어려운 건 그 한 줄이 사용자 손에 닿았을 때다. 입력하는 사람 입장에서 \u0026ldquo;이러면 좀 더 편하겠다\u0026rdquo; 싶은 지점을 떠올리고, 그게 다른 동작을 망가뜨리지 않는지까지 따져봐야 한다.\n최근 만든 기능에서 이런 순간이 있었다. 별것 아닌 요구사항이었는데 구현하다 보니 걸리는 데가 두 군데 나왔다. JavaScript로 화면을 만드는 한 비슷한 고민은 또 온다고 보고, 그때 무엇을 따졌는지와 최종 코드를 남겨 둔다.\nselect 값이 입력 규칙을 정한다 화면에는 select box 하나와 text input 하나가 있다. select에서 무엇을 고르느냐에 따라 input에 들어갈 값의 대소문자가 정해진다.\nselect에서 A를 고르면 input에 이미 들어 있는 값과 앞으로 입력할 값이 모두 소문자가 된다.\nselect에서 B를 고르면 같은 방식으로 모두 대문자가 된다.\ninput에 긴 값이 들어 있는 상태에서 커서를 중간으로 옮겨 입력해도, 앞뒤 값은 그대로 두고 새로 입력한 글자에만 규칙이 적용된다.\n앞의 두 줄은 금방 끝났고, 문제는 세 번째 줄에서 나왔다.\n화면은 바뀌었는데 값은 그대로였다 처음 만난 현상은 화면에는 대소문자가 제대로 바뀌어 보이는데 실제 데이터는 바뀌지 않는 것이었다. 이 화면은 Vue.js를 쓰고 있었고, input이 v-model=\u0026quot;method_info\u0026quot;로 묶여 있었다.\nv-model은 input의 input 이벤트를 듣고 있다가 그 값을 데이터에 옮겨 적는다. 사용자가 글자를 칠 때는 이벤트가 발생하니 데이터가 따라오지만, 코드에서 input.value에 변환한 값을 대입하는 건 이벤트를 일으키지 않는다. DOM에는 변환된 값이, 데이터에는 변환 전 값이 남는다. Vue를 쓰는 화면에서 input 값을 코드로 바꿀 때는 v-model에 묶인 프로퍼티도 직접 바꿔 줘야 한다. 최종 코드에서 input.value = inputValue 다음 줄에 data.method_info = inputValue가 붙어 있는 이유다.\n값을 통째로 바꾸면 커서는 끝으로 간다 두 번째 현상은 커서였다. select를 바꾸거나 타이핑할 때마다 커서가 맨 끝으로 강제로 이동했다. 긴 값의 중간을 고치려던 사용자에게는 한 글자 칠 때마다 커서가 도망가는 셈이다.\n원인은 변환 방식에 있다. toLowerCase()나 toUpperCase()로 만든 문자열을 value에 다시 넣으면 input의 값이 전체 단위로 교체되고, 그 순간 원래 커서 위치를 잃어버린다.\n해결에는 setSelectionRange를 썼다. 원래는 특정 구간의 글자를 마우스로 드래그한 것처럼 선택할 때 쓰는 함수인데, 시작과 끝을 같은 위치로 주면 선택 없이 커서만 그 자리에 놓인다. keyup 이벤트가 발생한 시점의 selectionStart, selectionEnd를 먼저 저장해 두고, 문자열을 치환한 직후 setSelectionRange(start, end)로 커서를 되돌렸다. 대소문자 변환은 글자 수를 바꾸지 않으니 저장한 위치가 치환 뒤에도 그대로 유효하다.\n이벤트 네 개로 묶은 handleInput 위 요구사항과 두 문제를 모두 반영한 코드다. 공통 함수 handleInput은 select, input 선택자와 데이터 객체를 받아 이벤트 네 개를 건다.\n// common.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); } 이벤트별로 하는 일을 나누면 이렇다.\n이벤트 함수 하는 일 select change convertByChange 이미 입력된 값을 앞뒤 공백을 지우고 소문자(gkId) 또는 대문자(parentTicketId, ticketId)로 바꾼다 input keyup convertByKeyup 커서 위치를 저장하고, 값을 변환해 DOM과 data.method_info에 함께 넣은 뒤 커서를 되돌린다 input blur convertInputString 공백을 쉼표로 바꿔 a, b, c 형태로 정리하고, 30개를 넘으면 알림 후 잘라낸다 input keydown (Enter) convertByEnter Enter를 누르면 blur와 같은 정리를 한다 select 변경은 데이터(data.method_info)만 바꾸고 input 표시는 Vue가 다시 그리게 두었다. 커서 복원은 타이핑을 처리하는 convertByKeyup에만 들어 있다.\n화면 쪽에서는 초기화 함수에서 한 번 호출한다. 세 번째 인자로 Vue 인스턴스(this)를 넘기므로 data.method_info가 곧 v-model에 묶인 method_info다.\n// Input핸들링대상화면.js 화면초기화함수 : function() { handleInput(\u0026#39;#method_id\u0026#39;,\u0026#39;#method_info\u0026#39;, this); } \u0026lt;!-- input핸들링대상화면.jsp --\u0026gt; \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 변경과 중간 입력에서의 동작 select에서 고른 option에 따라 입력값의 대소문자가 정해지고, option을 바꾸면 이미 입력된 값도 따라 바뀐다.\n긴 값의 중간에 커서를 두고 입력해도 새 글자에만 규칙이 적용되고 커서는 제자리에 남는다.\n지금 다시 본다면 Enter 판별에 쓴 event.keyCode는 MDN에서 deprecated로 분류하니 event.key === 'Enter'로 바꿔 두는 편이 낫겠다.\n","permalink":"https://jaylab.blog/js-input-upper-lower-case/","summary":"select 값에 따라 입력값을 대소문자로 강제하다 v-model 값이 안 바뀌고 커서가 끝으로 튀는 문제를 명시적 대입과 setSelectionRange로 풀었다.","title":"편의성을 위한 입력창 대소문자 변환과 커서 위치 지키기"},{"content":"IAM 사용자의 Access Key나 콘솔 비밀번호가 새어 나가면 그걸 가진 사람은 어디서든 AWS에 요청을 보낼 수 있다. 권한을 아무리 잘게 나눠도 \u0026ldquo;어디서 보낸 요청인가\u0026quot;는 따지지 않는다. 그래서 정해 둔 IP에서만 콘솔과 CLI를 쓸 수 있도록 IAM 정책을 하나 만들어 사용자에게 붙였다.\n허용하는 대신 나머지를 거부한다 IAM 콘솔에서 정책 \u0026gt; 정책 생성 \u0026gt; JSON 탭으로 들어가 아래 내용을 넣고 정책을 만든다. 그다음 대상 사용자의 권한 목록에 이 정책을 추가하면 끝이다.\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; } } } } {허용 대상 IP} 자리에 접속을 허용할 IP를 넣는다. /32는 그 주소 하나만이라는 뜻이고, 사무실 대역처럼 여러 주소를 열어야 하면 203.0.113.0/24 같은 CIDR로 적거나 배열에 항목을 더한다.\n읽는 법은 이렇다. 요청을 보낸 IP(aws:SourceIp)가 목록에 없고(NotIpAddress), 그 요청이 AWS 서비스가 대신 보낸 것이 아니라면(aws:ViaAWSService: false), 모든 리소스에 대한 모든 Action을 거부한다.\n\u0026ldquo;허용 IP에서만 Allow\u0026quot;가 아니라 \u0026ldquo;허용 IP 밖이면 Deny\u0026quot;로 쓴 데는 이유가 있다. IAM에서 명시적 Deny는 다른 어떤 Allow보다 먼저 적용된다. 사용자에게 AdministratorAccess 같은 넓은 권한이 붙어 있어도 이 정책 하나가 IP 조건으로 전부 덮는다. 기존 권한 정책은 손대지 않고 정책 하나만 더 붙이면 되는 것도 이 방식의 장점이다.\naws:ViaAWSService 조건이 없으면 곤란한 일이 생긴다. CloudFormation처럼 사용자의 요청을 받아 AWS 서비스가 다른 서비스를 대신 호출하면 그 호출은 내 IP가 아니라 AWS 쪽에서 나간다. 이 조건으로 그런 요청은 거부 대상에서 뺀다.\n적용 후 콘솔에서 보인 것 이미 콘솔에 로그인해 있던 세션에서는 바로 달라지지 않았다. 로그아웃하고 다시 들어간 뒤부터 정책이 제대로 걸렸다.\n허용하지 않은 IP에서도 콘솔 로그인 자체는 된다. 대신 로그인한 뒤 어떤 서비스 화면을 열어도 리소스를 보거나 건드릴 수 없다. 콘솔 화면도 결국 뒤에서 서비스 API를 부르는데, 그 호출이 전부 거부되기 때문이다.\nEC2 대시보드는 로드 밸런서, 보안 그룹, 볼륨 같은 리소스 칸마다 \u0026ldquo;API 오류\u0026quot;를 띄운다.\n인스턴스 목록에는 You are not authorized to perform this operation.이 뜬다. CLI도 같은 정책을 거치므로 허용하지 않은 IP에서 보낸 요청은 권한 오류로 끝난다.\n주의 사항 허용 IP를 잘못 넣으면 정책을 붙인 사용자는 자기 자신도 막힌다. 이 정책을 뗄 수 있는 다른 관리자 계정을 남겨 두고 적용한다.\nVPC 엔드포인트를 거쳐 들어오는 요청에는 aws:SourceIp가 공인 IP로 잡히지 않는다. EC2 안에서 VPC 엔드포인트로 AWS API를 부르는 사용자라면 이 정책에 막힐 수 있다.\n","permalink":"https://jaylab.blog/aws-access-deny-policy/","summary":"Deny와 NotIpAddress 조건을 묶은 IAM 정책으로, 허용한 IP 밖에서는 콘솔과 CLI로 AWS 리소스에 접근하지 못하게 막았다.","title":"허용 IP 밖의 AWS 요청을 거부하는 IAM 정책"},{"content":"목록 페이지에서 글 A, B, C를 Ctrl을 누른 채 연달아 클릭했다. 탭 세 개가 열렸는데 셋 다 C였다. 가끔은 A 탭만 제대로 나오고 나머지 두 탭은 데이터가 비어 있었다.\n당시 투입된 프로젝트는 화면 사이에 값을 넘길 때 document.cookie를 썼다. 쓰임새는 세 가지였다.\n다른 화면으로 파라미터를 넘긴다\n뒤로가기로 돌아왔을 때 이전 검색조건을 되살린다\nwindow.open으로 띄운 팝업 창에서 부모 창으로 값을 전달한다\n쿠키는 같은 브라우저의 모든 탭이 함께 보는 저장소다. 탭마다 다른 값을 넘기려면 key도 탭마다 달라야 한다. 이 글에서는 화면 카테고리마다 key 하나를 공유하던 구조를, 쿠키마다 고유 key를 만들어 URL로 넘기는 구조로 바꾼 과정을 정리한다.\n탭 세 개가 key 하나를 두고 경쟁했다 기존 코드는 화면 카테고리별로 같은 쿠키 key를 썼다. 목록에서 상세로 갈 때 그 key에 파라미터를 쓰고, 새로 열린 상세 탭이 같은 key를 읽은 뒤 지웠다.\n클릭이 한 번이면 문제가 없다. 짧은 간격으로 세 번이면 이렇게 된다.\nsequenceDiagram participant L as 목록 탭 participant K as 쿠키 (공통 key) participant A as 상세 탭 A participant B as 상세 탭 B L-\u0026gt;\u0026gt;K: A 파라미터 쓰기 L-\u0026gt;\u0026gt;K: B 파라미터로 덮어쓰기 L-\u0026gt;\u0026gt;K: C 파라미터로 덮어쓰기 A-\u0026gt;\u0026gt;K: 읽기 (C 파라미터가 나온다) B-\u0026gt;\u0026gt;K: 읽기 (C 파라미터가 나온다)탭 A가 열리고 쿠키를 읽기 전에 목록 탭이 이미 C의 파라미터로 덮어써 버린 것이다. 두 번째 증상도 같은 구조에서 나온다. 먼저 뜬 탭이 값을 읽고 쿠키를 지우면 뒤에 뜬 탭은 빈 쿠키를 읽는다. 쿠키의 \u0026ldquo;생성 \u0026gt; 조회 \u0026gt; 삭제\u0026quot;가 여러 탭에서 겹쳐 일어나면 어느 탭이 어떤 값을 볼지 보장할 수 없다.\n처음에는 이 현상을 document.cookie가 비동기로 동작해서 생긴다고 봤다. 다시 보면 정확한 설명이 아니다. 한 탭 안에서 document.cookie에 값을 쓰고 바로 읽으면 방금 쓴 값이 나온다. 원인은 쓰기와 읽기의 타이밍보다 여러 탭이 key 하나를 나눠 쓴 데 있다. 쿠키 쓰기가 아무리 즉시 반영되더라도 같은 key를 쓰는 한 마지막 값만 남는다.\nlocalStorage와 sessionStorage를 접은 이유 쿠키 대신 Web Storage로 옮기는 방법도 따져봤다.\nlocalStorage는 데이터를 브라우저에 영구적으로 저장하므로 흐름에 따라 지워지지 않고 남을 수 있다. 방어 코드와 만료 시간을 직접 붙이면 막을 수는 있지만, 모든 경우를 막으려면 공수가 커진다.\nsessionStorage는 새로고침에는 유지되고 탭을 닫으면 지워진다. 대신 사용 범위가 그 탭 하나로 한정된다. 목록 탭에서 쓴 값을 새로 열린 상세 탭에서 읽어야 하는 이번 상황과 맞지 않았다.\n쿠키를 편하게 다루는 라이브러리(js-cookie, react-cookie)를 들이는 것도 고민했지만 이미 많은 화면이 document.cookie로 짜여 있었다. 저장소를 바꾸기보다 기존 코드를 최대한 살리기로 하고 목표를 네 가지로 잡았다.\n만료 시간을 지정할 수 있을 것\n같은 글을 여러 탭으로 동시에 띄워도 파라미터가 탭마다 독립적으로 전달될 것\n파라미터 전달용 데이터와 검색조건 복원용 데이터가 서로 충돌하거나 덮어쓰지 않을 것\n공통 함수로 만들어 기존 로직을 최대한 그대로 쓸 것\nkey는 매번 새로 만들고 URL에 싣는다 바뀐 점을 표로 보면 이렇다.\n구분 기존 변경 후 쿠키 key 화면 카테고리별 고정 key 쿠키마다 새로 만든 랜덤 key key 전달 고정값이라 필요 없음 URL QueryString의 dataKey 쿠키 누적 고정 key라 누적 없음 생성할 때 총 크기를 보고, 기준을 넘으면 가장 오래된 쿠키 삭제 key가 고유하니 탭 A, B, C는 각자 다른 쿠키를 읽는다. 대신 상세 탭은 어떤 key를 읽어야 할지 알아야 하므로 key를 URL로 넘긴다. 화면에 들어올 때도, 뒤로가기로 돌아올 때도 URL이 key를 실어 나른다. 화면을 옮길 때마다 쿠키가 하나씩 새로 생기니 사용자가 탭을 계속 열면 쿠키가 쌓인다. 정리 로직이 따라붙는 이유다.\n고유 key를 만들고 URL에 기록한다 common.js의 쿠키 생성 부분이다.\n// 고유 key name 쿠키 생성 함수 // 여러 Tab 동시 오픈시 데이터 조회 에러 대응 \u0026gt; 탭끼리 같은 key를 덮어쓰던 현상 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; } function randomNum(){ let randomNum = String(Math.floor(Math.random() * 10000)); let currDateTime = String(new Date().getTime()).substring(3); return currDateTime + randomNum; } // 고유 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); } randomNum()은 밀리초 단위 현재 시각(13자리)에서 앞 세 자리를 잘라낸 10자리 뒤에 0~9999 난수를 붙인다. 앞 10자리가 시각이라서 뒤에서 볼 정리 로직이 이 부분으로 오래된 쿠키를 고른다.\nchangeBrowserLocation()은 history.replaceState로 현재 페이지 URL의 dataKey만 바꾼다. 페이지를 다시 불러오지 않고 히스토리 항목도 늘리지 않는다. 그래서 브라우저 뒤로가기로 목록에 돌아와도 목록 URL에 key가 남아 있다.\nURL의 key로 조회하고 삭제한다 // 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}`; } 조회와 삭제 모두 getDataKey()로 URL의 dataKey를 먼저 꺼낸다. 탭마다 URL이 다르니 읽는 쿠키도 다르다. getDataKeyUrl()은 화면을 옮길 때 현재 key를 구분자(? 또는 \u0026amp;)와 함께 붙여 주고, key가 없으면 빈 문자열을 돌려준다.\n쌓인 쿠키를 정리한다 // 쿠키의 총 크기 반환 함수 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); } checkAndDeleteCookie()는 총 크기가 8500을 넘는 동안 가장 오래된 쿠키를 하나씩 지운다. key가 숫자로만 된 쿠키만 대상으로 삼으므로 다른 용도의 쿠키는 건드리지 않는다. 지울 대상이 없으면 break로 빠져나와 무한 루프를 피한다.\n화면에서 부르는 쪽 목록 페이지는 진입할 때 쿠키가 있으면 검색조건을 되살리고 쿠키를 지운다. 상세로 갈 때는 넘길 데이터를 쿠키에 쓰고 key를 URL에 붙인다.\n// 리스트페이지.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; } 상세 페이지의 뒤로가기는 자기 URL의 key를 목록 URL에 그대로 붙인다. 같은 key가 상세로 갈 때 한 번, 목록으로 돌아올 때 한 번 쓰이는 셈이다.\n// 상세페이지.js: 뒤로가기 시 파라미터 설정 뒤로가기함수 : function() { window.location.href = \u0026#34;{목록페이지URL}\u0026#34; + getDataKeyUrl(\u0026#34;?\u0026#34;); } 주의 사항 getRareCookie()는 key를 부분 문자열로 찾는다. cookie.indexOf(cookieName)은 다른 쿠키의 이름이나 값 안에 같은 숫자열이 있어도 그 위치를 돌려준다. key가 11~14자리 숫자라 겹칠 일은 드물지만, split('; ')로 나눈 뒤 name= 부분을 정확히 비교하는 편이 안전하다.\nescape/unescape와 Date.prototype.toGMTString은 더 이상 권장되지 않는 API다. 새로 짠다면 encodeURIComponent/decodeURIComponent, toUTCString을 쓴다.\n쿠키는 path가 맞는 모든 요청에 실려 서버로 간다. path=/로 만든 파라미터 쿠키가 쌓일수록 요청 헤더도 그만큼 커진다.\n정리 여러 탭이 같은 쿠키 key를 쓰면 마지막에 쓴 값만 남는다. 원인은 비동기가 아니라 key 공유였다.\nlocalStorage는 정리 부담, sessionStorage는 탭 범위 때문에 대안이 되지 못했다.\n쿠키마다 랜덤 key를 만들고, key는 URL의 dataKey로 넘겼다.\n화면을 옮길 때마다 쿠키가 하나씩 쌓이므로 생성 시점에 총 크기를 보고 가장 오래된 것부터 지운다.\n저장소는 그대로 두고 key를 정하는 방식만 바꿨다. 새 탭이 몇 개 열리든 각 탭은 자기 URL에 적힌 key만 본다.\n","permalink":"https://jaylab.blog/js-cookie/","summary":"여러 탭이 같은 key의 쿠키를 덮어쓰던 문제를, 쿠키마다 고유 key를 만들어 URL로 넘기는 방식으로 풀었다.","title":"여러 탭이 같은 쿠키 key를 덮어쓰던 문제"},{"content":"Redis 서버에 붙어서 키가 제대로 들어갔는지, 만료시간이 걸렸는지, 큐에 뭐가 쌓였는지 확인할 때 결국 손이 가는 건 redis-cli다. 명령어가 많아 보이지만 규칙이 있다. 앞 글자가 어떤 자료구조를 다루는지 알려준다.\n이 글에서는 접속 옵션, 서버 상태 확인, 기본 타입인 String 명령어를 먼저 보고, Hash·List·Set·Sorted Set 네 자료구조를 차례로 정리한다. 운영 서버에서 조심해야 할 명령어는 마지막에 따로 모았다.\n접속은 옵션 다섯 개로 끝난다 먼저 Redis가 포트를 열고 있는지 확인한다.\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 - 접속 옵션은 포트(-p), 호스트(-h), DB 번호(-n), 비밀번호(-a, auth의 약어), 클러스터 모드(-c) 정도면 충분하다.\n# 6480번 포트의 redis 서버 접속 $ ./redis-cli -p 6480 127.0.0.1:6480\u0026gt; # 다른 서버에 있는 6480번 포트의 redis 서버 접속 $ ./redis-cli -h 192.168.124.xxx -p 6480 192.168.124.xxx:6480\u0026gt; # DB 번호가 0인 redis 서버 접속 $ ./redis-cli -n 0 -p 6480 127.0.0.1:6480\u0026gt; # 비밀번호가 test인 redis 서버 접속 $ ./redis-cli -a test -p 6480 127.0.0.1:6480\u0026gt; 클러스터에 접속할 때는 -c를 꼭 붙인다. 그래야 get을 했을 때 키가 있는 노드로 알아서 넘어간다.\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; 서버 상태는 info와 monitor로 본다 info는 서버 정보를 섹션별로 보여준다.\n$ ./redis-cli -p 6480 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 # 서버가 시작된 뒤 받아들인 전체 연결 수 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 지금 이 서버에 어떤 명령이 들어오는지 실시간으로 보려면 monitor, 주요 통계를 주기적으로 찍어 보려면 --stat을 쓴다.\n$ redis-cli -p 6480 monitor OK $ redis-cli -p 6379 --stat 프롬프트에 들어가지 않고 명령을 바로 넘길 수도 있다. 포트가 연달아 있는 여러 서버에 같은 명령을 돌릴 때는 셸 반복문과 묶는다.\n$ redis-cli -p 6379 keys \u0026#34;*\u0026#34; # 6001~6010번 포트 서버의 키 개수를 한 번에 확인 $ for i in {6001..6010}; do src/redis-cli -p $i dbsize; done 가장 기본인 String 명령어 접두어가 없는 명령어는 대부분 String을 다룬다. 저장·조회·삭제부터 보자.\n# 키, 값 저장 set {key} {value} # 특정 키 값 조회 get {key} # 여러 개의 key, value를 한 번에 저장 mset {key} {value} [{key} {value} ...] # 여러 개의 key에 해당하는 value를 한 번에 조회 mget {key} [{key} ...] # 특정 키 삭제 (여러 개를 한 번에 지울 수도 있다) del {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; 키 자체를 살피는 명령어도 있다.\nkeys * # 서버 내 모든 key 조회 keys *te* # 특정 문자열을 포함하는 key 조회 # 1) \u0026#34;test1\u0026#34; # 2) \u0026#34;test2\u0026#34; exists {key} # key 존재 여부 randomkey # 랜덤으로 key 하나 조회 type {key} # key의 타입 조회 dbsize # 서버 내 key 전체 개수 flushdb # 현재 선택된 DB의 모든 데이터 삭제 flushall # 서버 내 모든 데이터 삭제 만료시간 ttl로 남은 만료시간을 초 단위로 본다. -1은 만료시간이 없는 키, -2는 키가 없다는 뜻이다(만료되어 사라진 경우 포함).\nttl {key} # 30초 뒤 만료되도록 저장 set {key} {value} EX 30 # 값을 덮어쓰면서 남아 있던 만료시간은 유지 (Redis 6.0부터) set {key} {value} KEEPTTL # 이미 있는 키에 만료시간 설정 EXPIRE tutorialspoint 60 (integer) 1 # 설정 완료 (integer) 0 # 키가 없거나 만료시간을 설정할 수 없는 경우 옵션 없이 set으로 값을 덮어쓰면 걸려 있던 만료시간이 사라진다. 만료시간이 30초로 다시 시작되는 게 아니라 키가 만료되지 않는 상태가 된다. 값을 바꿔도 원래 만료 시점을 지키고 싶으면 KEEPTTL을 붙인다.\n조건부 저장과 숫자 증감 set a b nx # key a에 값이 없을 때만 저장 (있으면 수정하지 않음) set a b xx # key a에 값이 있을 때만 수정 (없으면 저장하지 않음) set 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; decrby a 10 # 10 감소 # 소수 증감은 INCRBYFLOAT 하나로 처리한다 INCRBYFLOAT a 2.5 # 2.5 증가 INCRBYFLOAT a -2.5 # 2.5 감소 문자열 이어 붙이기와 자르기 append는 현재 값 뒤에 문자열을 붙인다. 키가 없으면 새로 만든다.\n127.0.0.1:6480\u0026gt; APPEND greeting Hello # key가 없으면 생성 (integer) 5 127.0.0.1:6480\u0026gt; GET greeting \u0026#34;Hello\u0026#34; 127.0.0.1:6480\u0026gt; APPEND greeting World # 기존 key 뒤에 이어 붙임 (integer) 10 127.0.0.1:6480\u0026gt; GET greeting \u0026#34;HelloWorld\u0026#34; getrange, setrange는 각각 substring, replace에 해당한다.\n127.0.0.1:6379\u0026gt; SET hello world OK 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; 명령어 앞 글자가 자료구조를 알려준다 String 말고도 Redis 값은 여러 자료구조가 될 수 있다. 외울 게 많아 보이지만 명령어 앞 글자만 보면 어느 자료구조를 다루는지 바로 알 수 있다.\n접두어 자료구조 예 없음 String set, get, incr h Hash hset, hget, hgetall l / r List (왼쪽/오른쪽) lpush, rpop, lrange s Set sadd, smembers z Sorted Set zadd, zrange 어떤 키가 무슨 자료구조인지 모르겠으면 type {key}로 먼저 확인한다.\nHash 키 하나 아래에 field-value 쌍을 여러 개 담는다.\nhset user:1 name An city seoul # 초기화 # 특정 hash의 특정 field 값 조회 hget user:1 name # \u0026#34;An\u0026#34; hget user:1 city # \u0026#34;seoul\u0026#34; # 특정 hash의 특정 field 삭제 hdel user:1 name # 특정 hash의 모든 field-value 조회 hgetall user:1 # 특정 hash의 field만 조회 hkeys user:1 # 특정 hash의 value만 조회 hvals user:1 List Linked List 형태라서 양 끝에 넣고 빼는 게 자연스럽다. 값이나 인덱스로 데이터를 찾거나 삭제할 수도 있다. 최대 길이는 2^32 - 1(4,294,967,295)이다. 주로 Stack이나 Queue를 구현할 때 쓰고, 백그라운드 작업자 시스템의 대기열이 대표적인 예다.\nStack\nQueue\n넣는 쪽과 빼는 쪽을 어떻게 고르느냐에 따라 Queue도 되고 Stack도 된다.\n# 생성 lpush {queue name} {value 1} lpush {queue name} {value 1} {value 2} {value 3} ... # lpush로 넣었다면 rpop {queue name} # FIFO 추출 (Queue) lpop {queue name} # LIFO 추출 (Stack) rpush는 오른쪽에 값을 넣으므로 오른쪽으로 갈수록 최근에 들어온 값이다. rpush test-queue A B C라고 하면 A부터 차례로 들어가 [A, B, C]가 되고, 여기서 lpop을 하면 먼저 들어온 순서대로 나온다.\nrpush test-queue A B C # [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; 값을 꺼내지 않고 들여다보기만 하려면 llen과 lrange를 쓴다.\n127.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-name 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-name 0 0 1) \u0026#34;1\u0026#34; Set 순서가 없고 중복을 허용하지 않는다. 1 1 2 2 3 3 1 1을 넣어도 남는 건 세 개다.\n127.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 # 모든 원소 조회 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 # set-name2에서 1 삭제 (remove) 127.0.0.1:6480\u0026gt; srem set-name2 1 # 랜덤하게 원소 하나를 pop 127.0.0.1:6480\u0026gt; spop set-name2 Sorted Set 값마다 가중치(score)를 붙여 정렬해 두는 Set이다. 점수 순으로 줄을 세워 두니 랭킹 기능을 만들 때 먼저 떠올릴 만하다.\n# 기본 문법 zadd {SortedSet name} [가중치1] [key1] [가중치2] [key2] ... zadd test-name 30 a 10 b 15 c (integer) 3 # 전체 출력 (기본 오름차순) zrange test-name 0 -1 1) \u0026#34;b\u0026#34; 2) \u0026#34;c\u0026#34; 3) \u0026#34;a\u0026#34; # 전체 출력 (내림차순, rev 옵션은 Redis 6.2부터) zrange test-name 0 -1 rev 1) \u0026#34;a\u0026#34; 2) \u0026#34;c\u0026#34; 3) \u0026#34;b\u0026#34; # 가중치와 함께 내림차순 조회 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: 가중치가 가장 낮은 요소가 0위 (오름차순) zrank test-name a # (integer) 2 zrank test-name b # (integer) 0 zrank test-name c # (integer) 1 # zrevrank: 가중치가 가장 높은 요소가 0위 (내림차순) zrevrank test-name a # (integer) 0 zrevrank test-name b # (integer) 2 zrevrank test-name c # (integer) 1 # 가중치가 가장 높은 요소 꺼내기 zpopmax {SortedSet name} # 가중치가 가장 낮은 요소 꺼내기 zpopmin {SortedSet name} 운영 서버에서는 조심할 명령어 Redis는 명령을 한 스레드에서 하나씩 처리한다. 그래서 오래 걸리는 명령 하나가 그동안 들어온 다른 요청을 전부 기다리게 만든다. 키가 많은 서버의 keys *, 데이터를 통째로 지우는 flushall·flushdb, 필드가 많은 Hash의 hgetall이 그런 명령이다. 로컬에서 연습할 때는 편하지만 운영 환경에서는 쓰기 전에 한 번 더 생각한다. 키를 훑어봐야 한다면 커서로 조금씩 나눠 가져오는 scan이 대안이다.\n","permalink":"https://jaylab.blog/redis-cli-and-data-structure/","summary":"redis-cli 접속 옵션과 상태 확인, String·Hash·List·Set·Sorted Set 명령어를 자료구조별로 묶어 정리했다.","title":"redis-cli로 Redis 자료구조 손에 익히기"},{"content":"ElastiCache for Redis 클러스터를 만들고 로컬 터미널에서 redis-cli로 붙어보려 했다. 엔드포인트를 그대로 넣어서는 연결이 안 된다. ElastiCache는 외부 접근을 허용하지 않고, 같은 VPC 안의 인스턴스를 거쳐야만 접근할 수 있다.\n그래서 로컬과 Redis 사이에 Bastion Host를 두고 SSH 포트포워딩으로 길을 냈다. 이 글에서는 클러스터를 만들 때 고른 설정값, 포트포워딩, redis-cli --tls 접속, CLI 대신 쓸 수 있는 GUI 툴까지 순서대로 정리한다. 테스트 환경은 M1 Mac이다.\n클러스터를 만들 때 고른 설정값 클러스터 모드: 비활성화\n포트: 기본값 6379 대신 6480\n암호화: 저장 중 암호화, 전송 중 암호화 모두 활성화\n포트는 보안을 위해 기본값을 피해서 6480으로 바꿨다. 포트를 바꾸면 무작위 스캔이 조금 번거로워지는 정도이고, 실제로 접근을 막는 건 뒤에 나올 VPC 경계와 보안 그룹이다.\n나머지 두 설정은 접속 명령에 그대로 이어진다. 클러스터 모드를 껐으니 redis-cli에 -c가 필요 없고, 전송 중 암호화를 켰으니 --tls가 필요하다.\n로컬에서 Redis까지 SSH 터널 열기 Bastion Host는 Redis와 같은 VPC 안에 있으면서 로컬에서 SSH로 들어갈 수 있는 인스턴스다. ssh -L로 로컬 포트 하나를 Bastion 너머의 Redis 엔드포인트에 이어두면, 로컬에서는 localhost:6480에 붙는 것만으로 Redis에 닿는다.\nflowchart LR A[\u0026#34;로컬 Mac\u0026lt;br/\u0026gt;localhost:6480\u0026#34;] -- SSH --\u0026gt; B[\u0026#34;Bastion Host\u0026lt;br/\u0026gt;(같은 VPC)\u0026#34;] B -- \u0026#34;TCP 6480\u0026#34; --\u0026gt; C[\u0026#34;ElastiCache for Redis\u0026lt;br/\u0026gt;엔드포인트\u0026#34;] 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} # 예시 (엔드포인트, IP, SSH 포트는 예시 값) ssh -L 6480:master.example-redis.xxxxxx.euc1.cache.amazonaws.com:6480 {IPA ID}@10.0.1.10 -p 2222 -L 바로 뒤의 6480이 로컬에서 열 포트이고, 그 뒤가 Bastion 입장에서 찾아갈 Redis 주소와 포트다. 예시는 클러스터 포트를 6480으로 만들었기 때문에 두 숫자가 같을 뿐이다. 마지막 -p는 Redis 포트가 아니라 Bastion Host의 SSH 포트다.\n터널이 제대로 열렸는지는 로컬 포트가 LISTEN 상태인지로 확인한다.\n# 방법 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 \u0026ndash;tls로 접속하기 redis-cli는 brew install redis로 설치한다.\nredis-cli -c -h {redis-endpoint or host} --tls -p {port} # -c: 클러스터 모드가 \u0026#39;비활성화\u0026#39;면 생략 가능 # -h: 호스트가 localhost면 생략 가능 # -p: 기본 포트 6379면 생략 가능 # --tls: \u0026#39;전송 중 암호화\u0026#39;가 활성화된 클러스터라면 필요. TLS 프로토콜로 통신한다 # 예시 redis-cli -h localhost --tls -n 1 -p 6480 # SSH 터널로 포워딩된 로컬 6480 포트를 통해 Redis와 TLS로 통신한다 # -n 1: 1번 데이터베이스에 접속한다 (생략하면 0번) AWS ElastiCache for Redis 1번 DB 접속 성공.\n프롬프트가 localhost:6480[1]\u0026gt;로 바뀌면 접속된 것이다. 대괄호 안 숫자가 지금 선택한 DB 번호다.\nredis-cli에 \u0026ndash;tls 옵션이 없다면 M1 Mac에서 brew로 설치한 redis-cli는 --tls를 바로 지원했다. 그런데 Redis의 SSL/TLS는 버전 6부터 들어왔고, 컴파일할 때 켜야 하는 선택 기능이다. 다른 환경에서 설치한 redis-cli가 --tls를 모른다면 TLS를 켜서 다시 빌드해야 한다. 방법은 아래 참고자료의 AWS 문서에 있다.\nCLI 대신 GUI로 보기 CLI 말고 GUI 툴로 Redis를 확인하고 관리할 수도 있다. 써본 건 Medis2와 RedisInsight이고 둘 다 무료다.\nMedis2는 구성이 단순해서 다루기 쉽다. 다만 무료 버전은 클러스터를 하나만 연결할 수 있다. 업무에서 클러스터 두 개 이상을 같이 봐야 할 일이 생겨서 RedisInsight로 바꿨다. GUI도 결국 로컬에서 붙는 것이니 위의 SSH 터널을 열어둔 채로 localhost:6480에 연결하면 된다.\nMedis2 RedisInsight 참고 ElastiCache for Redis는 VPC 밖에서 바로 접근할 수 없어서, 같은 VPC의 Bastion Host로 ssh -L 터널을 연다.\n클러스터 설정이 명령 옵션을 정한다. 클러스터 모드 비활성화면 -c 생략, 전송 중 암호화 활성화면 --tls.\n터널은 lsof나 nc -zv로 먼저 확인하고, 그다음 redis-cli -h localhost --tls -p 6480으로 붙는다.\n","permalink":"https://jaylab.blog/redis-create-access/","summary":"VPC 밖에서 바로 붙을 수 없는 ElastiCache for Redis에 Bastion Host SSH 포트포워딩과 redis-cli \u0026ndash;tls로 접속한 과정","title":"Bastion Host를 거쳐 로컬에서 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\n(복제본(Replica, 읽기 전용 노드) 2개 + 원본(Master, 읽기/쓰기) 1개 = 총 3개의 노드가 구성)\n","permalink":"https://jaylab.blog/redis-component/","summary":"Components of \u0026lsquo;ElastiCache for Redis’","title":"[AWS] ElastiCache for Redis 구성요소"},{"content":"Redis를 소개하는 글은 대개 특징 목록으로 시작한다. Key-Value, In-Memory, Single Thread, 다양한 자료구조, Replication. 내 메모도 그랬다. 목록은 읽을 때는 다 아는 것 같아도 금방 잊힌다. 다시 정리해 보니 이 중 두 가지, 데이터를 메모리에 둔다는 것과 명령을 한 스레드에서 처리한다는 것에서 나머지 특징 대부분이 따라 나왔다.\n이 글에서는 Redis가 무엇인지, 그 두 성질이 어떤 장점과 주의점을 만드는지, 주로 어디에 쓰이는지를 정리한다. 명령어로 자료구조를 직접 다뤄 보는 이야기는 「redis-cli로 Redis 자료구조 손에 익히기」에, AWS ElastiCache에서 Redis를 구성하는 단위는 「ElastiCache for Redis의 노드, 샤드, 클러스터」에 따로 적었다.\nRemote Dictionary Server Redis는 REmote DIctionary Server의 줄임말이다. 이름대로 네트워크 너머에 있는 거대한 딕셔너리다. Key-Value 구조의 비정형 데이터를 저장하고 관리하는 오픈소스 NoSQL DBMS다. 스키마가 고정되어 있지 않아 키마다 원하는 내용만 저장할 수 있고, 값을 키로 바로 꺼내니 SQL 같은 쿼리를 쓸 필요도 없다.\n쓰임새는 넓다. 데이터베이스로 쓸 수도 있고, Message Queue나 여러 서버가 함께 보는 Shared Memory로 쓸 수도 있다. 그래도 가장 흔한 쓰임은 Cache Server다.\n디스크 대신 메모리에 둔다 인메모리(In-Memory)는 데이터를 하드디스크(SSD, HDD)가 아니라 컴퓨터의 주 기억장치인 RAM에 올려서 쓰는 방식이다. 메모리 계층에서 메인 메모리는 디스크보다 한 칸 위에 있고, 속도는 수백 배 이상 빠르다.\n데이터베이스를 두고도 Redis를 따로 쓰는 이유가 여기 있다. 데이터베이스는 물리 디스크에 직접 write하기 때문에 데이터가 손실되지 않는다. 대신 사용자가 많아질수록 디스크에 부하가 쌓인다. 한 번 읽어온 데이터를 Redis에 캐싱해 두면 같은 데이터를 다시 조회할 때 디스크까지 내려가지 않아도 되고, 그만큼 서비스가 빨라진다.\n빠른 대신 메모리는 휘발성이다. 영속화 설정 없이 쓰면 서버를 재시작할 때 데이터가 전부 사라진다. Redis는 이를 위해 디스크에 스냅샷이나 로그를 남기는 persistence 옵션을 제공한다. Replication으로 복제본을 두는 방법도 있지만, 사람이 실수로 지운 데이터는 복제본에도 그대로 반영되니 복제가 백업을 대신하지는 못한다. 중요한 데이터라면 백업을 따로 챙겨야 한다.\nCache로 쓸 때는 키마다 TTL(만료 시간)을 반드시 붙인다. 메모리는 한정되어 있고, 캐시는 시간이 지나면 원본과 어긋나기 때문이다.\n명령은 한 스레드에서 차례로 처리된다 Redis는 Single Thread로 명령을 처리한다. 여러 클라이언트가 동시에 요청을 보내도 명령은 하나씩 차례로 실행된다. 요청을 동시에 돌리지 않는 대신, 각 요청을 빠르게 끝내서 동시성을 감당한다.\n이 구조 덕분에 Redis의 명령 하나하나는 원자적(Atomic)으로 실행되고, 경쟁 상태(Race Condition)가 생길 여지가 작다. 두 클라이언트가 같은 카운터를 동시에 올려도 명령이 겹쳐 실행되지 않는다.\n반대로 한 클라이언트가 오래 걸리는 명령을 보내면 그동안 뒤에 줄 선 요청이 전부 기다리고, 이렇게 쌓인 요청은 장애로 이어질 수 있다. 싱글 스레드의 장점과 약점은 같은 곳에서 나온다.\n참고로 Redis 6.0부터 네트워크 I/O를 여러 스레드로 나누는 옵션이 생겼지만, 명령 실행 자체는 여전히 한 스레드에서 이루어진다.\n자료구조와 확장 Redis는 단순한 문자열만 담는 저장소가 아니다. String, List, Hash, Set, Sorted Set, Stream 같은 자료구조를 제공한다. 애플리케이션에서 직접 짜야 했던 로직을 Redis 명령 하나로 줄일 수 있어서 개발이 편해진다. 예전에 정리한 메모에는 list형 데이터의 입력·삭제가 MySQL보다 10배 빠르다고 적혀 있다.\n여러 대로 확장하는 방법도 있다. 읽기 성능을 높이려면 서버 측 Replication으로 Master-Slave(최근 문서에서는 primary-replica라고 부른다) 구성을 만들어 읽기를 복제본으로 나눈다. 쓰기 성능이 문제라면 데이터를 여러 서버에 나눠 담는 샤딩을 쓴다. 예전에는 클라이언트가 키를 보고 어느 서버로 보낼지 정하는 클라이언트 측 샤딩이 일반적이었고, Redis 3.0부터는 서버가 샤딩을 맡는 Redis Cluster도 쓸 수 있다.\n주로 어디에 쓰는가 여러 서버가 같은 데이터를 공유해야 할 때. 서버가 여러 대면 각 서버의 전역 변수는 서로 보이지 않는다. 서버가 한 대라 전역 변수로 충분해 보여도, 여러 스레드가 그 변수를 건드리면 동기화를 직접 챙겨야 한다. Redis는 명령이 원자적으로 실행되니 Thread Safe하게 쓸 수 있다.\n인증 토큰 저장. String이나 Hash에 담는다.\n랭킹 보드. 점수 순으로 정렬된 Sorted Set이 그대로 랭킹이 된다.\n유저별 API 호출 제한.\nJob Queue.\n참고 Redis는 네트워크 너머에 있는 Key-Value 딕셔너리이고, 주로 Cache Server로 쓴다.\n명령이 한 스레드에서 차례로 실행되니 원자적이지만, 오래 걸리는 명령 하나가 전체를 막는다.\n","permalink":"https://jaylab.blog/redis-basic-concepts/","summary":"Redis가 빠른 이유와 조심할 점을 인메모리 저장과 싱글 스레드 두 가지 성질에서 출발해 정리했다.","title":"메모리와 싱글 스레드로 정리한 Redis 기본 개념"},{"content":"IntelliJ에서 Tomcat으로 프로젝트를 띄워 놓고 HTML, CSS, JS를 고쳤는데 브라우저에는 그대로였다. 새로고침을 해도, 쿠키를 지워도 달라지지 않았다. 브라우저 쪽을 아무리 만져도 소용이 없었던 건 문제가 브라우저가 아니라 서버가 무엇을 읽고 있느냐에 있었기 때문.\nTomcat은 소스가 아니라 배포된 Artifact를 읽는다 IntelliJ가 Tomcat에 올리는 것은 src/main/webapp의 원본 파일이 아니라 빌드된 Artifact. Artifact가 war면 압축된 한 덩어리라서 안의 CSS 파일 하나만 바꿔 끼울 수 없고, 다시 패키징해서 다시 배포해야 한다.\nwar exploded는 같은 내용을 압축하지 않은 디렉토리로 둔 것이라 파일 단위로 갱신할 수 있다. 정적 리소스를 서버 재시작 없이 반영하려면 Artifact부터 exploded여야 한다. 뒤에 나오는 Update classes and resources 옵션이 목록에 안 보이는 것도 이 때문이다.\nRun Configuration의 업데이트 동작을 바꾼다 상단 실행 설정 목록에서 Edit Configurations...\nTomcat 설정의 Server 탭에서 두 항목을 모두 Update classes and resources로 변경.\nOn 'Update' action: Update 액션을 실행했을 때\nOn frame deactivation: IntelliJ 창에서 포커스가 빠질 때\n\u0026ldquo;자동 반영\u0026quot;의 정체는 두 번째 항목. 코드를 고치고 브라우저로 넘어가는 순간 IntelliJ 창이 비활성화되고, 그때 바뀐 리소스가 배포 디렉토리로 넘어간다. 브라우저에서는 새로고침만 하면 된다.\n옵션이 안 보이면 Artifact가 war로 잡혀 있다 Server 탭 드롭다운에 Update classes and resources가 없다면 Deployment 탭을 본다. Deploy at the server startup 목록에 {프로젝트명}:war가 들어 있을 것이다. 이걸 Remove하고 새 Artifact로 {프로젝트명}:war exploded를 추가한다.\n같은 탭에서 두 가지를 더 확인한다.\nApplication context가 /인지 본다. 아니면 /로 고친다.\n추가한 Artifact를 선택하고 연필 아이콘(Edit Artifact)을 눌러 Output directory를 {프로젝트경로}/src/main/webapp으로 바꾼다.\n설정 순서를 한데 모으면 이렇다.\nEdit Configurations → Deployment → {프로젝트명}:war Remove → + Artifact → {프로젝트명}:war exploded → Application context: / → Edit Artifact → Output directory: {프로젝트경로}/src/main/webapp Edit Configurations → Server → On \u0026#39;Update\u0026#39; action / On frame deactivation: Update classes and resources Output directory를 소스 경로로 두면 Tomcat이 읽는 곳과 내가 고치는 곳이 같은 디렉토리가 된다. 대신 빌드 산출물(WEB-INF/classes 같은 것)이 소스 트리 안에 생길 수 있으니 처음 적용한 뒤에는 git status로 커밋 대상에 섞이지 않았는지 한 번 확인해 두는 게 좋다.\n정리 정적 리소스가 반영되지 않으면 브라우저보다 배포 방식을 먼저 본다. war를 war exploded로 바꾸고 Server 탭의 두 업데이트 동작을 Update classes and resources로 두면 창을 전환하는 것만으로 수정 사항이 올라간다. Java 클래스 쪽은 같은 옵션에 HotSwap이 얽혀 있어서 따로 정리했다.\n","permalink":"https://jaylab.blog/intellij-resource-build/","summary":"IntelliJ에서 Tomcat으로 띄운 프로젝트의 HTML·CSS·JS 수정이 안 보여서, Artifact를 war exploded로 바꾸고 업데이트 동작을 설정해 해결했다.","title":"war를 exploded로 변경 및 정적 리소스 자동 반영"},{"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.blog/injellij-hotswap/","summary":"Apply changes to java files without restarting the server","title":"[IntelliJ] java 파일 서버 재실행 없이 변경사항 적용 (HotSwap)"},{"content":"서버를 띄우고 나면 IP 주소로 접속하게 된다. 퍼블릭 IP는 외워지지 않고, 인스턴스를 다시 만들면 바뀐다. 전날 정리한 AWS 인프라 구성에 이어, 그 서버들에 이름을 붙이는 과정을 정리한다.\nAWS에서 이 일을 맡는 서비스는 Route 53이다. Route 53은 두 가지 일을 한다. 도메인을 대신 등록해 주는 일과 도메인 이름에 IP 주소를 연결해 돌려주는 DNS 서버 일이다. 여기에 HTTPS를 쓰려면 필요한 SSL 서버 인증서 이야기를 덧붙인다.\n도메인 등록은 상위 도메인 관리 조직에 대신 요청해 주는 일이다 도메인을 등록한다는 것은 상위 도메인을 관리하는 조직에 내 도메인을 요청해서 받아 오는 것이다. 예를 들어 jpub.co.kr이라는 이름을 쓰려면 .co.kr을 관리하는 조직에 요청을 보내야 하고, 요청이 통과되면 그 도메인을 얻는다.\n이 요청과 관리를 대행하는 회사가 전 세계에 여럿 있고, Route 53을 쓰면 AWS가 그 대행을 맡는다.\nDNS 서버를 직접 세우는 방법도 있다. EC2 인스턴스에 오픈 소스 DNS 서버를 올리면 된다. 그래도 Route 53을 쓰는 이유는 안정성과 비용 대비 성능, 그리고 로드밸런서와 바로 연결된다는 점이다.\nDNS 서버는 퍼블릭과 프라이빗으로 나뉜다 Route 53이 DNS 서버 역할을 한다는 건, 특정 도메인 이름에 대한 IP를 관리하고 질의가 오면 그 IP를 돌려준다는 뜻이다. 이 역할은 두 종류로 나뉜다.\n항목 퍼블릭 DNS 프라이빗 DNS 도메인 이름 취득 필요 불필요 관리하는 IP 주소 퍼블릭 IP 주소 프라이빗 IP 주소 퍼블릭 DNS는 인터넷에서 찾아올 이름을, 프라이빗 DNS는 VPC 안에서만 부를 이름을 관리한다. 프라이빗 쪽은 밖으로 드러나지 않으니 도메인을 살 필요가 없고, home 같은 이름을 그냥 붙여도 된다.\n콘솔에서 보면 두 쪽 모두 구조가 같다. 호스팅 영역 목록에 도메인 이름이 있고, 도메인 하나를 열면 그 아래 레코드 목록이 나온다.\n퍼블릭 호스팅 영역: aws-intro-sample.com 레코드 이름 레코드 유형 값 bastion A bastion 서버의 퍼블릭 IP www A 생성한 로드밸런서 선택 www는 IP 대신 로드밸런서를 고른다. Route 53의 별칭(Alias) 레코드다. 앞에서 Route 53과 로드밸런서가 바로 연결된다고 한 장점이 이 부분이다.\n프라이빗 호스팅 영역: home 레코드 이름 레코드 유형 값 bastion A 프라이빗 IP web01 A 프라이빗 IP web02 A 프라이빗 IP db CNAME DB 엔드포인트 같은 bastion이 퍼블릭 쪽에서는 퍼블릭 IP로, 프라이빗 쪽에서는 프라이빗 IP로 등록된다. 밖에서는 bastion에 접속하고, 안에서는 web01, db 같은 이름으로 서버끼리 부르는 구성이다. db만 CNAME인 이유는 DB가 IP가 아니라 엔드포인트라는 이름으로 주어지기 때문이다.\nHTTPS에는 SSL 서버 인증서가 필요하다 HTTP와 달리 HTTPS는 데이터를 암호화한다. 브라우저가 웹 서버로 보낸 암호화된 데이터는 사이트 운영자가 가진 비밀키로 원래 데이터로 되돌린다.\n문제는 운영자가 아닌 누군가가 운영자로 위장한 피싱 사이트를 세워 데이터를 훔칠 수도 있다는 점이다. 암호화만으로는 상대가 진짜인지 알 수 없다. 그래서 위장하지 않았음을 보증하는 회사가 있고, 이를 인증기관이라 부른다. 그 보증을 증명하는 것이 SSL 서버 인증서다. 브라우저 주소창의 열쇠 아이콘을 누르면 이 인증서를 볼 수 있다.\nSSL 서버 인증서는 무엇까지 보증하느냐에 따라 세 단계로 나뉜다.\n도메인 검증(DV) 인증서: 도메인 이름이 올바름을 보증한다.\n조직 검증(OV) 인증서: 도메인 이름과 함께 도메인을 관리하는 회사명을 보증한다.\n확장 검증(EV) 인증서: 도메인을 관리하는 회사가 실제로 존재하는지, 신뢰할 만한지까지 보증한다.\n아래로 갈수록 조사가 엄격하고 비용도 오른다.\nAWS도 인증기관 기능을 갖고 있어서 AWS에서 구축하는 사이트에 쓸 SSL 서버 인증서를 무료로 발급한다. 다만 발급하는 것은 도메인 검증(DV) 인증서뿐이다.\n정리 Route 53은 도메인 등록 대행과 DNS 서버, 두 가지 일을 한다.\n퍼블릭 DNS는 도메인을 사서 퍼블릭 IP를, 프라이빗 DNS는 도메인 없이 프라이빗 IP를 관리한다.\n로드밸런서는 IP 대신 별칭 레코드로, DB는 엔드포인트를 CNAME으로 연결한다.\nAWS가 무료로 발급하는 SSL 서버 인증서는 DV 인증서뿐이다.\n퍼블릭과 프라이빗 호스팅 영역에 같은 이름을 두고 IP만 다르게 넣는 구성은 처음 보면 헷갈린다. 밖에서 부르는 이름과 안에서 부르는 이름을 나눠 두는 것이라고 보면 정리가 된다.\n참고자료 Amazon Route 53에 도메인 등록 — Route 53의 도메인 등록 기능\n프라이빗 호스팅 영역 작업 — VPC 안에서만 쓰는 호스팅 영역\n별칭 레코드와 비별칭 레코드 중 선택 — 로드밸런서를 레코드 값으로 고를 때의 별칭 레코드\nAWS Certificate Manager란 무엇입니까? — AWS에서 SSL/TLS 인증서를 발급하는 서비스\n","permalink":"https://jaylab.blog/aws-dns/","summary":"Route 53의 도메인 등록과 DNS 기능을 퍼블릭·프라이빗 호스팅 영역 레코드 예시로 정리하고, AWS가 무료로 발급하는 DV 인증서까지 짚는다.","title":"Route 53의 퍼블릭 DNS와 프라이빗 DNS"},{"content":"AWS 콘솔을 처음 열면 서비스 목록은 길고, 무엇부터 만들어야 하는지는 알려 주지 않는다. EC2부터 띄우고 싶어도 EC2를 넣을 서브넷이 없고, 서브넷을 만들어도 라우팅 테이블이 없으면 밖으로 나가지 못한다.\n책 한 권을 따라 VPC부터 S3까지 웹 서비스 하나가 돌아갈 만한 인프라를 콘솔로 직접 만들어 봤다. 이 글에서는 만든 순서대로 설정값을 정리하고, 중간중간 왜 그렇게 나누는지를 짧게 적는다.\n완성된 구성은 이렇다.\nflowchart TB U[\u0026#34;사용자\u0026#34;] --\u0026gt; IGW[\u0026#34;Internet Gateway\u0026#34;] Admin[\u0026#34;관리자 (SSH)\u0026#34;] --\u0026gt; IGW subgraph VPC[\u0026#34;VPC 10.0.0.0/16\u0026#34;] subgraph PUB[\u0026#34;퍼블릭 서브넷 1, 2\u0026#34;] ALB[\u0026#34;ALB (HTTP 80)\u0026#34;] BAS[\u0026#34;점프서버\u0026#34;] NAT1[\u0026#34;NAT Gateway 1\u0026#34;] NAT2[\u0026#34;NAT Gateway 2\u0026#34;] end subgraph PRI[\u0026#34;프라이빗 서브넷 1, 2\u0026#34;] WEB1[\u0026#34;web01 :3000\u0026#34;] WEB2[\u0026#34;web02 :3000\u0026#34;] RDS[\u0026#34;RDS MySQL\u0026#34;] end end IGW --\u0026gt; ALB IGW --\u0026gt; BAS ALB --\u0026gt; WEB1 ALB --\u0026gt; WEB2 BAS -. SSH .-\u0026gt; WEB1 BAS -. SSH .-\u0026gt; WEB2 WEB1 --\u0026gt; RDS WEB2 --\u0026gt; RDS WEB1 -. IAM 역할 .-\u0026gt; S3[(\u0026#34;S3 버킷 (VPC 밖)\u0026#34;)]네트워크: VPC, 서브넷, 게이트웨이 VPC의 CIDR은 10.0.0.0/16으로 잡았다.\n서브넷은 VPC의 IP 주소 범위를 나누는 단위다. 나누는 이유는 두 가지다. 외부에 공개하는 리소스와 그렇지 않은 리소스를 가르는 역할 분리, 그리고 AWS 안에서 물리적으로 이중화하는 기기 분리다. 퍼블릭과 프라이빗을 두 개씩 만든 것도 이 때문이다.\n서브넷 CIDR public1 10.0.0.0/20 public2 10.0.16.0/20 private1 10.0.64.0/20 private2 10.0.80.0/20 Internet Gateway를 만들어 VPC에 연결한다. NAT Gateway는 프라이빗 서브넷 두 개에 하나씩 대응하도록 두 개를 만들어 퍼블릭 서브넷에 하나씩 두고, NAT Gateway마다 EIP를 할당한다.\n퍼블릭과 프라이빗을 가르는 건 라우팅 테이블이다 서브넷 이름에 public, private을 붙였다고 성격이 정해지지 않는다. 서브넷 밖으로 나가는 경로가 Internet Gateway인지 NAT Gateway인지가 그 서브넷의 성격을 정한다. 그 경로를 적는 곳이 라우팅 테이블이다.\n퍼블릭 라우팅 테이블 1개\n라우팅: 0.0.0.0/0 → Internet Gateway\n서브넷 연결: 퍼블릭 서브넷 2개 모두\n프라이빗 라우팅 테이블 2개\n라우팅: 각각 0.0.0.0/0 → NAT Gateway\n서브넷 연결: 프라이빗 서브넷을 1개씩 나눠 맡는다\n라우팅 테이블과 서브넷은 1:n이라서 여러 서브넷이 라우팅 테이블 하나를 같이 쓸 수 있다. 퍼블릭은 나가는 곳이 Internet Gateway 하나뿐이라 테이블 하나로 충분하고, 프라이빗은 서브넷마다 NAT Gateway가 달라서 테이블을 두 개 만든다.\n라우팅 정보에 넣는 대상의 순서는 상관없다. AWS 라우팅 테이블은 목적지 CIDR 블록과 일치하는 경로 중 가장 구체적인 것을 찾아 쓰기 때문이다.\n일반적인 네트워크 설계에서는 이런 설정을 라우터(router)라는 기기에 하지만, AWS 콘솔에서는 라우터를 따로 만들 필요가 없다. 라우팅 테이블을 만들면 라우터에 해당하는 것도 함께 생긴다.\n보안그룹 두 개와 점프서버 보안그룹은 두 개를 만들었다. 둘 다 인바운드만 설정한다.\n보안그룹 인바운드 점프서버(bastion host)용 SSH(22) / Anywhere-IPv4(0.0.0.0/0) 로드밸런서용 HTTP(80), HTTPS(443) / Anywhere-IPv4(0.0.0.0/0) 보안그룹은 EC2, 로드밸런서, RDS 같은 리소스 단위로 거는 설정이고, 네트워크 ACL은 서브넷 단위로 걸려 그 안의 리소스 전부에 적용된다. 책의 저자는 설정 누락을 인프라 설계서나 CloudFormation 같은 IaC로 막고, 네트워크 ACL은 잘 쓰지 않는다고 한다.\n점프서버를 두는 이유는 이렇다. 외부에서 리소스로 들어오는 접속은 제한된 관리자만 할 수 있어야 하는데, 그 설정을 모든 리소스에 하나하나 하기는 어렵고 빠뜨리기도 쉽다. 그래서 입구를 하나 두고 그 서버를 거쳐야만 각 리소스에 들어갈 수 있게 한다. 목적한 리소스로의 통신 외에는 쓸 일이 없으니 성능이 낮아도 되고 OS도 가리지 않는다.\n실습이라 SSH를 0.0.0.0/0으로 열었지만, 실제로 쓸 서버라면 소스를 관리자 IP로 좁히는 편이 낫다.\n키페어(pem)를 하나 만들고 점프서버를 띄운다.\nAMI: Amazon Linux 2 AMI(HVM) - Kernel 5.10, SSD Volume Type\n키페어: 앞에서 만든 키페어\n네트워크: 퍼블릭 서브넷 01(이 EC2가 퍼블릭 서브넷 01 안에 생긴다), 퍼블릭 IP 자동 할당 활성화\n보안그룹: default, sg-bastion 두 개\n접속은 pem 키가 있는 위치에서 한다. 처음에는 키 권한이 너무 열려 있다며 거부당한다.\n# pem 키가 있는 위치에서 ssh -i {pem키 이름} ec2-user@{Bastion Public IP} # 키 권한이 너무 열려 있다는 에러가 난다(보호되지 않은 키). # 다른 사용자가 읽을 수 없게 권한을 줄인다 chmod 400 {pem키 이름} # 다시 접속하면 성공 ssh -i {pem키 이름} ec2-user@{Bastion Public IP} # 로그아웃은 logout, exit, Ctrl+D 중 하나 키페어가 GitHub 같은 저장소에 올라가는 걸 막아 주는 git-secrets라는 도구도 있다. 나중에 마이크로서비스 쪽에 관심이 생기면 AWS의 서버리스 컴퓨팅 유스케이스도 읽어 볼 생각이다.\n웹 서버 두 대와 로드밸런서 웹 서버는 점프서버와 같은 AMI, 같은 키페어로 만들되 네트워크 설정이 다르다.\n서브넷: 프라이빗 서브넷 01\n퍼블릭 IP 자동 할당: 비활성화\n보안그룹: default 하나\n같은 설정으로 한 대를 더 만들고 서브넷만 프라이빗 서브넷 02로 바꾼다. 이제 점프서버에는 default + SSH, 웹 서버에는 default만 붙어 있다.\n웹 서버에 SSH 규칙이 없는데도 점프서버에서 들어갈 수 있는 건 default 보안그룹 덕분이다. default 보안그룹의 기본 인바운드 규칙은 같은 보안그룹이 붙은 리소스끼리의 트래픽을 허용한다. 점프서버, 웹 서버, 로드밸런서가 모두 default를 달고 있으니 서로 통한다.\n로드밸런서가 하는 일 로드밸런서의 주요 역할은 세 가지다.\n요청 분산\nSSL(Secure Socket Layer) 처리. 외부에서 들어온 HTTPS 요청 패킷을 복호화해 웹 서버에 넘기고, 나가는 응답 패킷은 암호화한다. 웹 서버가 암복호화를 다 하면 부하 때문에 비효율적이다.\n부정 요청 대응\n여기에 요청 라우팅(Request Routing)이 붙는다. 외부에 공개한 프로토콜·포트 조합으로 들어온 요청을, 내부 웹 서버가 받는 프로토콜·포트로 바꿔 주는 기능이다. 하나의 로드밸런서에 대상 그룹을 여러 개 지정할 수도 있다.\n로드밸런서는 인터넷 게이트웨이로 가는 경로가 있는 서브넷, 여기서는 퍼블릭 서브넷에 두어야 한다. 서브넷을 잘못 고르면 외부에서 웹 서버에 도달하지 못한다.\n리스너를 만들다가 대상 그룹을 만든다 ALB 설정은 크게 두 덩어리다. 로드밸런서 쪽은 어떤 프로토콜(HTTP, HTTPS)로 받을지처럼 인터넷에서 로드밸런서로 들어올 때의 설정이고, 클라이언트 요청을 받는 \u0026ldquo;리스너\u0026quot;가 여기 있다. 대상 그룹은 그 요청을 넘겨받을 서버 쪽 설정이다.\n로드밸런서 설정:\nNetwork mapping: 퍼블릭 서브넷 2개\n보안그룹: default, 앞에서 만든 sj-sg-elb\nListeners and routing: HTTP, 80 포트를 설정하고 \u0026ldquo;Create target group\u0026quot;을 누른다\n리스너를 HTTP:80으로 두고 Create target group으로 넘어간다.\n대상 그룹 설정:\nChoose a target type: Instances\nProtocol: HTTP, Port: 3000. 외부에서 인터넷으로 들어올 때는 HTTP 80으로 받지만, 로드밸런서가 웹 서버로 넘길 때는 HTTP 3000을 쓴다.\nRegister targets: 웹 서버 2대를 고르고 \u0026ldquo;Include as pending below\u0026rdquo;\n웹 서버 두 대를 3000 포트로 골라 pending 목록에 넣는다.\n대상 그룹을 만든 뒤 로드밸런서 화면으로 돌아와 Listeners and routing의 Default action에 방금 만든 대상 그룹을 넣는다.\nDefault action이 새 대상 그룹으로 요청을 넘기도록 지정한다.\n헬스체크가 통과해야 요청이 흐른다 ssh web01, ssh web02로 각 웹 서버에 들어가 같은 작업을 한다.\n먼저 index.html 파일을 만든다.\n\u0026lt;html\u0026gt;\u0026lt;body\u0026gt;hello world\u0026lt;/body\u0026gt;\u0026lt;/html\u0026gt; 그다음 Amazon Linux에 기본으로 깔린 파이썬으로 HTTP 서버를 띄운다.\npython -m SimpleHTTPServer 3000 index.html이 있는 디렉터리에서 실행한다. 제대로 떴다면 로드밸런서가 헬스체크를 할 때마다 액세스 로그가 주기적으로 찍힌다.\n로드밸런서의 헬스체크 요청이 주기적으로 들어온다.\n여기까지 해도 로드밸런서는 곧바로 요청을 넘기지 않는다. 헬스체크를 몇 차례 보내 모두 성공한 뒤에야 라우팅을 시작한다. EC2 대시보드 \u0026gt; Target groups \u0026gt; 상세 페이지 \u0026gt; [Targets] 탭의 Health status가 healthy가 되면 준비가 끝난 것이다.\n로드밸런서를 눌러 DNS 이름을 확인하고, 그 주소로 브라우저에서 접속하면 hello world가 뜬다. 확인이 끝나면 파이썬 서버를 Ctrl+C로 끄고, 더는 필요 없는 index.html도 지운다.\nRDS는 그룹 세 개를 먼저 만든다 데이터베이스 인스턴스를 만들기 전에 그룹 세 개를 만든다.\n파라미터 그룹: 데이터베이스 엔진 고유의 설정. 성능 개선, 사용 현황 파악, 기능 추가, 사용하는 언어나 튜닝 설정을 여기서 한다.\n옵션 그룹: AWS를 이용한 데이터베이스 모니터링처럼 주로 RDS 고유의 설정을 한다.\n서브넷 그룹: 데이터베이스 서버를 여러 가용 영역에 나눠 둘 때 쓰는 설정이다. 데이터베이스도 서버를 여러 대 두면 신뢰성이나 성능을 높일 수 있다.\n가용 영역: VPC에서 쓰는 가용 영역\n서브넷: 각 가용 영역의 private 서브넷\n데이터베이스는 보안을 위해 기본적으로 퍼블릭 서브넷을 서브넷 그룹에 넣지 않는다.\n그다음 데이터베이스를 만든다.\n항목 값 데이터베이스 생성 방식 표준 생성 엔진 옵션 MySQL 템플릿 프리 티어 마스터 사용자 이름 admin 마스터 암호 Masterpw 최초 데이터베이스 이름 (공란) 마스터 암호에 특수문자를 넣었더니 마지막 연결 단계에서 Access denied for user로 실패했다. 문자로만 된 암호로 바꾸고 나서야 붙었다.\n연결은 웹 서버에서 확인한다. Amazon Linux 2에는 MySQL 명령어가 기본으로 없어서 따로 설치한다.\n# 1. 웹 서버 접속 ssh web01 # 2. MySQL 클라이언트 설치 sudo yum -y install mysql # 3. RDS 대시보드 \u0026gt; 데이터베이스 \u0026gt; 생성한 인스턴스 \u0026gt; \u0026#39;연결 \u0026amp; 보안\u0026#39; 탭에서 엔드포인트와 포트 확인 # 4. 비밀번호까지 입력해 \u0026#34;mysqld is alive\u0026#34;가 나오면 연결 확인 완료 mysqladmin ping -u admin -p -h {데이터베이스의 엔드포인트} S3는 VPC 밖에 있다 EC2 인스턴스는 SSD나 하드 디스크에 해당하는 스토리지를 EBS(Amazon Elastic Block Store)라는 이름으로 제공한다. EBS를 스토리지로 쓰면 이런 부담이 따른다.\nEC2 인스턴스의 OS 자체도 관리해야 한다(보안 대응 등).\n예측하지 못한 장애에 대비해야 한다.\nEC2 인스턴스를 쓰지 못하게 될 수 있다. SLA(서비스 품질 보증)에 따르면 1년에 약 5분 정도 서비스가 멈출 수 있다.\n이 문제를 풀려고 AWS가 제공하는 서비스가 S3다. 책에서는 S3가 내결함성과 비용 모두 EBS보다 압도적으로 낫다고 한다. 대신 어디까지나 외부 스토리지 서비스라서, EBS처럼 C 드라이브, D 드라이브로 쓰거나 리눅스·맥의 파일시스템처럼 마운트할 수는 없다.\n지금까지 만든 서비스는 전부 VPC 안에 있었다. S3는 VPC 밖에 생기므로, EC2에서 S3에 닿으려면 네트워크가 아니라 권한을 준비해야 한다. 접근 방법은 인터넷에서 직접 들어가는 것과 VPC에서 들어가는 것 두 가지이고, VPC에서 들어가려면 S3 버킷 접근 권한이 필요하다. 이 권한은 보통 IAM 역할(role)로 준다. S3 버킷에 접근하는 정책을 가진 역할을 만들고, 그 역할을 EC2에 붙인다.\n기술적으로는 S3 접근 정책을 가진 사용자를 만들고 그 사용자의 액세스 키로 버킷에 접근할 수도 있다. 다만 역할을 쓰는 쪽이 더 흔하다.\n버킷을 만들 때는 \u0026ldquo;이 버킷의 퍼블릭 액세스 차단 설정\u0026quot;에서 \u0026ldquo;모든 퍼블릭 액세스 차단\u0026rdquo;(기본값)을 그대로 둔다. 의도하지 않은 열람이나 변경을 막아 두고, 버킷을 만든 뒤 용도에 맞게 필요한 권한만 여는 게 좋다.\n버킷 하나에만 닿는 정책을 만든다 AmazonS3FullAccess 정책을 그대로 써도 되지만, 이번에는 특정 버킷에만 접근할 수 있는 정책을 만들었다. AmazonS3FullAccess의 내용은 이렇다.\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; } ] } 여기서 Resource만 원하는 버킷으로 좁힌다. 버킷 자체와 버킷 안의 객체(/*)를 둘 다 적는다.\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; ] } ] } 이 정책으로 역할을 만든다.\n신뢰할 수 있는 엔터티 유형: AWS 서비스 / EC2\n권한 추가 \u0026gt; 권한 정책: 방금 만든 정책\n그리고 EC2 콘솔 \u0026gt; 웹 서버 인스턴스 \u0026gt; 작업 \u0026gt; 보안 \u0026gt; IAM 역할 수정에서 이 역할을 붙인다.\n역할을 붙인 인스턴스에서 업로드해 본다 Amazon Linux 2에는 S3 같은 AWS 리소스를 다루는 명령어가 들어 있다. 웹 서버에서 파일 하나를 올려 봤다.\n# 웹 서버 홈 디렉터리에서 test.txt를 만든 뒤 aws s3 cp test.txt s3://{버킷이름} 업로드가 끝났다. 인스턴스에 액세스 키를 넣어 두지 않아도, 붙어 있는 역할이 권한을 준다.\n정리 서브넷의 성격은 라우팅 테이블이 정한다. 0.0.0.0/0이 Internet Gateway로 가면 퍼블릭, NAT Gateway로 가면 프라이빗이다.\n웹 서버와 DB는 프라이빗에 두고, 밖에서는 점프서버(SSH)와 로드밸런서(HTTP)로만 들어온다.\n로드밸런서는 80으로 받아 대상 그룹의 3000으로 넘기고, 헬스체크가 통과한 뒤에야 요청을 보낸다.\nRDS는 파라미터·옵션·서브넷 그룹을 먼저 만들고, 서브넷 그룹에는 프라이빗 서브넷만 넣는다.\nS3는 VPC 밖에 있어서 EC2에 버킷을 좁힌 IAM 역할을 붙여 접근한다.\n만든 순서를 다시 보면 네트워크, 입구, 서버, 데이터 순이다. 한 번 손으로 끝까지 만들어 보니 콘솔의 메뉴가 어떤 순서로 이어지는지가 보였다. 여기까지는 로드밸런서의 긴 DNS 이름으로 접속했고, 다음 글에서 도메인을 붙인다.\n","permalink":"https://jaylab.blog/aws-basic-infrastructure/","summary":"VPC·서브넷·NAT부터 점프서버, ALB, RDS, S3 역할까지 콘솔로 직접 만들며 무엇을 어떤 순서로 만들어야 하는지 정리했다.","title":"VPC에서 S3까지, 콘솔로 쌓은 웹 서비스 인프라"},{"content":"고객사 서비스 모듈마다 Jenkins 파이프라인을 새로 만들던 중이었다. 파이프라인에 옮겨 담으려고 기존 배포 절차를 받아 보니 이랬다. 로컬에서 ui5.war의 압축을 풀고, 풀린 파일을 서버의 webapps/ui5 디렉터리에 복사한 뒤 Tomcat을 재시작한다. 이 과정을 sh 스크립트로 돌리고 있었다.\n파이프라인으로 그대로 옮기기 전에 걸리는 게 있었다. WAR의 압축을 푸는 건 원래 Tomcat이 하는 일이다. 그런데 server.xml을 열어 보니 \u0026lt;Context\u0026gt; 태그와 \u0026lt;Host\u0026gt;의 appBase가 한 Host 안에 섞여 있었고, 기존 관리자는 \u0026lt;Context\u0026gt;를 쓰면 압축 해제를 사람이 해야 한다고 이해하고 있었다.\n이 글에서는 두 배포 방식이 섞인 설정에서 Tomcat이 실제로 무엇을 먼저 배포하는지 따라가 보고, 그 결과 배포 절차를 어떻게 바꿨는지 정리한다.\nContext와 appBase가 한 Host 안에 섞여 있었다 운영 서버의 server.xml은 다음과 같았다.\n\u0026lt;Host name=\u0026#34;localhost\u0026#34; appBase=\u0026#34;/home/manager/app/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/app/webapps/legacy\u0026#34; reloadable=\u0026#34;false\u0026#34; /\u0026gt; \u0026lt;Context path=\u0026#34;/v5\u0026#34; docBase=\u0026#34;/home/manager/app/webapps/ui5\u0026#34; reloadable=\u0026#34;false\u0026#34; /\u0026gt; \u0026lt;!-- ... AccessLogValve 설정 ... --\u0026gt; \u0026lt;/Host\u0026gt; 배포 경로가 두 갈래다. 하나는 \u0026lt;Context\u0026gt;로 경로와 디렉터리를 직접 묶는 명시적 배포다. /v5로 들어온 요청은 ui5 디렉터리가 받는다. 다른 하나는 appBase에 놓인 WAR나 디렉터리를 Tomcat이 알아서 올리는 자동 배포다. unpackWARs=\u0026quot;true\u0026quot;, autoDeploy=\u0026quot;true\u0026quot;가 둘 다 켜져 있으니 이쪽도 살아 있다.\n기존 절차는 앞의 갈래만 보고 있었다. \u0026lt;Context\u0026gt;가 ui5 디렉터리를 가리키니 그 디렉터리를 사람이 채워 넣어야 한다고 본 것이다. 뒤의 갈래가 같은 디렉터리를 대신 채워 줄 수 있다는 건 절차에 없었다.\nTomcat이 배포 대상을 고르는 순서 정말 Tomcat이 알아서 풀어 주는지 확인하려고 서버가 뜰 때의 로그부터 봤다.\n# Tomcat 로그에서 확인되는 초기화 순서 INFO [main] org.apache.catalina.startup.HostConfig.deployApps Deploying web application directory [/home/manager/app/webapps/ui5] INFO [main] org.apache.catalina.startup.HostConfig.deployApps Deployment of web application directory [/home/manager/app/webapps/ui5] has finished INFO [main] org.apache.catalina.startup.HostConfig.deployWARs Deploying web application archive [/home/manager/app/webapps/shop.war] INFO [main] org.apache.catalina.startup.HostConfig.deployWARs Deployment of web application archive [/home/manager/app/webapps/shop.war] has finished 로그에 찍힌 것은 전부 HostConfig다. HostConfig는 appBase를 훑어서 WAR와 디렉터리를 올리는 자동 배포 담당이다. server.xml의 \u0026lt;Context\u0026gt;는 이 로그에 나오지 않는다. 그쪽은 server.xml을 읽는 시점에 이미 Host의 자식으로 등록되기 때문이다.\n그래서 순서는 이렇게 된다. server.xml의 Context가 먼저 경로 이름을 차지하고, HostConfig는 appBase를 훑으면서 이미 차지된 이름을 건너뛴다.\nflowchart TD A[\u0026#34;server.xml 파싱\u0026#34;] --\u0026gt; B[\u0026#34;Context 등록\u0026lt;br/\u0026gt;\u0026#39;\u0026#39; → legacy, /v5 → ui5\u0026#34;] B --\u0026gt; C[\u0026#34;HostConfig가 appBase 스캔\u0026#34;] C --\u0026gt; D{\u0026#34;이미 등록된\u0026lt;br/\u0026gt;경로 이름인가?\u0026#34;} D --\u0026gt;|예| E[\u0026#34;건너뜀\u0026#34;] D --\u0026gt;|아니오| F[\u0026#34;WAR면 압축 해제 후 배포\u0026lt;br/\u0026gt;디렉터리면 그대로 배포\u0026#34;]처음에는 이 로그를 \u0026ldquo;appBase를 먼저 스캔해 WAR를 풀고, 그다음 Context 설정을 처리한다\u0026quot;로 읽었다. 하지만 로그에는 Context 처리가 찍히지 않으니 그렇게 읽을 근거가 없다. 결과는 같다. appBase에 WAR를 두면 Tomcat이 디렉터리로 풀고, \u0026lt;Context\u0026gt;는 그 디렉터리를 가리키기만 한다.\nWAR를 appBase에 두기만 하면 풀린다 appBase에 ui5.war를 복사하고 디렉터리가 어떻게 바뀌는지 봤다.\n# WAR 파일 배치 전 $ ls -la /home/manager/app/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/app/webapps/ # Tomcat이 자동으로 처리한 결과 $ ls -la /home/manager/app/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 ui5.war가 놓이자 Tomcat이 곧바로 ui5 디렉터리를 만들고 압축을 풀었다. 로컬에서 풀어 복사하던 두 단계가 통째로 필요 없었다.\n\u0026lt;Context\u0026gt;에 정의되지 않은 새 애플리케이션으로도 해 봤다. shop.war를 넣고 로그를 따라갔다.\n# shop.war 파일 배치 $ cp shop.war /home/manager/app/webapps/ # 실시간 로그 확인 $ tail -f /opt/tomcat/logs/catalina.out INFO [ContainerBackgroundProcessor[StandardEngine[Catalina]]] org.apache.catalina.startup.HostConfig.deployWAR Deploying web application archive [/home/manager/app/webapps/shop.war] INFO [ContainerBackgroundProcessor[StandardEngine[Catalina]]] org.apache.catalina.startup.HostConfig.deployWAR Deployment of web application archive [/home/manager/app/webapps/shop.war] has finished in [1,234] ms # 자동 생성된 디렉터리 확인 $ ls -la /home/manager/app/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 스레드 이름이 [main]이 아니라 ContainerBackgroundProcessor다. 서버 시작과 상관없이 백그라운드 스레드가 주기적으로 appBase를 보고 있다가 새 WAR를 잡아낸 것이다. 감지부터 압축 해제, 서비스 시작까지 1~2초 안에 끝났다.\n같은 이름이면 Context가 이긴다 두 방식이 같은 경로를 두고 부딪히면 어떻게 되는지도 확인했다. \u0026lt;Context path=\u0026quot;/v5\u0026quot;\u0026gt;가 있는 상태에서 appBase에 v5.war를 함께 둬 봤다.\n\u0026lt;Context path=\u0026#34;/v5\u0026#34; docBase=\u0026#34;/home/manager/app/webapps/ui5\u0026#34; /\u0026gt; # 요청 테스트 $ curl -I http://localhost:8080/v5/ # 결과: Context로 지정된 ui5 디렉터리에서 응답 # appBase의 v5.war는 완전히 무시됨 로그에도 v5.war를 처리한 흔적이 없었다. /v5라는 이름은 server.xml을 읽을 때 이미 \u0026lt;Context\u0026gt;가 가져갔고, HostConfig는 그 이름을 건너뛴다. 앞에서 본 순서 그대로다.\n바꾼 배포 절차 기존 절차와 바꾼 절차를 나란히 놓으면 차이가 분명하다.\n# ❌ 기존: 사람이 WAR를 풀어서 docBase 디렉터리에 복사 unzip ui5.war -d /tmp/ui5_temp cp -r /tmp/ui5_temp/* /home/manager/app/webapps/ui5/ systemctl restart tomcat # ✓ 변경: WAR를 appBase에 두고 압축 해제는 Tomcat에 맡긴다 cp ui5.war /home/manager/app/webapps/ # Tomcat이 ui5 디렉터리를 만들고 압축을 푼다 # /v5 Context는 그 디렉터리를 가리키기만 한다 두 방식 자체를 비교하면 이렇다.\n구분 appBase 자동 배포 Context 명시적 배포 배포 대상 Context에 정의되지 않은 모든 경로 server.xml에 명시된 고정 경로 경로 결정 WAR 파일명 기반 (shop.war → /shop) path 속성으로 임의 지정 가능 재시작 필요성 불필요 (Hot Deploy) 경우에 따라 필요 운영 편의성 매우 높음 중간 (설정 관리 필요) 서비스 안정성 중간 높음 (명시적 제어) \u0026lt;Context\u0026gt;를 버리지는 않았다. /v5처럼 WAR 이름과 다른 경로가 필요하면 \u0026lt;Context\u0026gt;가 맞다. 바뀐 건 \u0026lt;Context\u0026gt;는 경로를 정하고, 파일을 채우는 건 자동 배포에 맡긴다는 역할 분담이다.\n배포시간 30초로 단축 새 Jenkins 파이프라인을 만들면서 모듈마다 같은 방식으로 절차를 고쳤다. 사실 이보다 더 처참한 방식으로 배포되던 모듈도 있었다.\n구분 기존 변경 후 배포 시간 약 5~10분 약 30초 파일 권한 수동 압축 해제 과정에서 권한 문제 발생 해결 휴먼 에러 압축 해제·복사·재시작 단계마다 개입 최소화 배포 중에 서비스가 잠깐 끊기는 문제는 이 작업의 범위 밖이었다. 그건 나중에 \u0026ldquo;배포할 때마다 끊기던 서비스를 무중단으로: Jenkins Rolling Deployment에서 내린 네 가지 결정\u0026quot;에서 따로 다뤘다.\n아직 확인해야 할 것 ui5 디렉터리가 /ui5로도 한 번 더 배포되고 있을 수 있다. 시작 로그의 Deploying web application directory [.../ui5]는 HostConfig가 ui5 디렉터리를 자기 이름(/ui5)으로 올렸다는 뜻으로 읽힌다. \u0026lt;Context\u0026gt;의 docBase가 appBase 안에 있으면 같은 애플리케이션이 /v5와 /ui5로 두 번 뜰 수 있다고 Tomcat 문서도 경고한다. legacy도 같은 구조다. 막으려면 docBase를 appBase 밖으로 빼거나 Host의 deployIgnore로 해당 디렉터리를 제외한다.\nWAR를 교체할 때 /v5가 새 파일을 언제 반영하는지. /v5는 reloadable=\u0026quot;false\u0026quot;이고, 디렉터리를 새로 푸는 주체는 /ui5 쪽 자동 배포다. /v5가 재시작 없이 새 클래스를 읽는지, 교체 순간 디렉터리가 지워졌다 다시 생기면서 /v5 요청이 실패하지 않는지는 따로 확인해야 한다.\n정리 WAR의 압축 해제는 Tomcat의 일이다. unpackWARs와 autoDeploy가 켜져 있으면 appBase에 WAR를 두는 것으로 충분하다.\nserver.xml의 Context가 먼저 이름을 차지한다. HostConfig는 appBase를 훑을 때 이미 등록된 경로를 건너뛰므로, 같은 이름이면 Context가 이긴다.\nContext는 경로를, 자동 배포는 파일을 맡는다. 둘을 섞어 쓸 때는 역할을 이렇게 나누면 사람이 끼어들 단계가 사라진다.\ndocBase를 appBase 안에 두면 이중 배포가 생길 수 있으니 deployIgnore나 경로 분리를 같이 챙긴다.\n한 번 돌아가기 시작한 배포 스크립트는 좀처럼 다시 읽지 않게 된다. 파이프라인으로 옮기는 작업은 그 스크립트를 한 줄씩 다시 읽을 기회였다. 옮기기 전에 의심해 볼 만하다.\n","permalink":"https://jaylab.blog/tomcat-improve-cicd-pipeline/","summary":"WAR를 직접 풀어 복사하던 배포를 Tomcat 자동 배포 동작을 추적해 파일 복사 한 번으로 줄였고, 5~10분이던 배포가 30초가 됐다","title":"WAR를 손으로 풀어 올리던 Tomcat 배포프로세스 변경"},{"content":"파이썬 스크립트를 실행할 때 값을 바깥에서 넘기고 싶을 때가 있다. 값이 코드 안에 박혀 있으면 바꿀 때마다 파일을 열어야 한다. 표준 라이브러리의 argparse를 쓰면 python argparseTest.py -testArg=123처럼 실행 명령에 붙인 값을 스크립트 안에서 꺼내 쓸 수 있다.\n인자를 하나만 등록한 최소 예제를 돌려 봤는데, 정작 눈에 들어온 건 따로 만들지 않은 --help였다. 이 글에서는 그 예제와 출력을 정리한다.\n인자 하나를 받는 최소 예제 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 argparseTest.py -testArg={입력값} 형태로 입력하세요~\u0026#34;) # 입력받은 인자값을 args에 저장 (type: namespace) args = parser.parse_args() if args.testDest: print(\u0026#34;결과값: \u0026#34;, args.testDest) 흐름은 세 줄이다. ArgumentParser로 파서를 만들고 add_argument로 받을 인자를 등록한 다음, parse_args()가 실제 명령줄을 읽는다.\ndest는 읽은 값을 담을 속성 이름이다. 명령줄에서는 -testArg로 넘기지만 코드에서는 args.testDest로 꺼낸다. dest를 주지 않으면 옵션 이름에서 대시를 뗀 testArg가 속성 이름이 된다.\n실행해 보기 값을 넘기면 그대로 찍힌다.\n» python argparseTest.py -testArg=123 결과값: 123 -testArg=123 대신 -testArg 123처럼 띄어 써도 결과는 같다. 인자를 아예 주지 않으면 args.testDest가 None이라서 아무것도 출력하지 않고 끝난다.\n만들지 않은 \u0026ndash;help가 이미 있다 코드에는 -testArg 하나만 등록했는데 --help를 붙여 실행하면 사용법이 나온다.\n» python argparseTest.py --help usage: argparseTest.py [-h] [-testArg TESTDEST] 사용법 테스트입니다. optional arguments: -h, --help show this help message and exit -testArg TESTDEST python argparseTest.py -testArg={입력값} 형태로 입력하세요~ -h/--help는 ArgumentParser가 기본으로 넣어 주는 옵션이다. 출력의 재료는 모두 코드에 적은 값이다. description은 설명 문단으로, 각 인자의 help는 옵션 옆 설명으로 들어간다. TESTDEST는 dest 값을 대문자로 바꾼 자리표시자다. 스크립트 사용법을 README에 따로 적지 않아도 실행하는 사람이 --help로 확인할 수 있다.\n등록하지 않은 옵션을 넘기면 usage를 보여 주고 에러로 끝난다.\n» python argparseTest.py -x usage: argparseTest.py [-h] [-testArg TESTDEST] argparseTest.py: error: unrecognized arguments: -x 참고 위 --help 출력의 optional arguments:는 Python 3.9 이하의 표기다. 3.10부터는 같은 자리가 options:로 나온다. 버전이 달라 출력이 조금 다르게 보여도 동작은 같다.\n-testArg처럼 대시 하나에 긴 이름을 붙여도 동작은 한다. 다만 관례는 긴 이름에 대시 두 개(--test-arg), 한 글자 약어에 대시 하나(-t)다. 이렇게 등록하면 dest를 안 줘도 속성 이름이 test_arg로 정해진다.\n인자가 하나일 때는 sys.argv[1]로 바로 꺼내도 된다. 그래도 argparse를 쓰면 사용법 출력과 잘못된 인자 처리가 딸려 오니 남이 실행할 스크립트라면 처음부터 argparse로 시작하는 편이 낫겠다.\n","permalink":"https://jaylab.blog/python-argparse/","summary":"argparse로 파이썬 스크립트에 실행 인자를 넘기는 최소 예제를 돌려 보고, 인자 하나만 등록해도 \u0026ndash;help 사용법이 저절로 생기는 것을 확인했다.","title":"argparse가 대신 만들어 주는 --help"},{"content":"Python을 쓰다 보면 pip3 install {패키지명}을 습관처럼 치게 되는데, 이렇게 설치한 패키지는 전부 한곳에 쌓인다. 로컬의 다른 프로젝트가 같은 패키지의 다른 버전을 원하면 서로 부딪히고, 패키지끼리 의존성이 꼬이기도 한다.\nPython에 기본으로 들어 있는 venv 모듈은 이 \u0026ldquo;한곳\u0026quot;을 프로젝트마다 따로 만들어 준다. 작업 환경이 바뀌어도 같은 패키지를 같은 버전으로 다시 깔 수 있다. 이 글에서는 가상환경을 만들고 지우기까지의 명령어와, 놓치기 쉬운 VS Code 인터프리터 설정을 정리한다.\n가상환경은 폴더 하나다 먼저 지금 설치된 패키지 목록을 본다.\npip list 가상환경을 만든다.\n# venv 모듈을 실행해 myenv라는 가상환경을 만든다 python3 -m venv myenv -m venv는 venv라는 모듈을 실행하겠다는 뜻이다. myenv는 가상환경 이름이고 마음대로 정해도 된다. 명령이 끝나면 현재 디렉터리에 myenv 폴더가 생기고, 그 안에 Python 실행 파일과 패키지를 담을 site-packages가 따로 들어 있다. 가상환경의 실체는 이 폴더 하나이고, 패키지는 이제 이 안에만 설치된다.\n활성화한다.\nsource myenv/bin/activate activate는 현재 셸의 PATH 맨 앞에 myenv/bin을 끼워 넣으므로, 이후 python, pip3는 가상환경 안의 것이 잡힌다. 이 상태에서 pip list를 다시 쳐 보면 아무 패키지도 없는 깨끗한 환경이 나온다.\n터미널에서 켰다고 VS Code까지 켜진 건 아니다 여기서 놓치기 쉬운 부분이 있다. VS Code 터미널 프롬프트 앞에 (myenv)가 붙어 있어도, 그건 터미널 셸만 가상환경을 쓰고 있다는 뜻이다. VS Code가 코드를 실행하고 분석할 때 쓰는 인터프리터는 따로 설정된다. 터미널의 가상환경과 VS Code의 인터프리터는 별개라서 둘 다 맞춰야 한다.\n맞추는 방법은 세 가지다.\nVS Code 하단 상태 표시줄의 Python 버전 정보를 클릭하고, 만든 가상환경 이름을 고른다.\nctrl + shift + p(Windows) / cmd + shift + p(Mac)로 명령 팔레트를 열고 \u0026ldquo;Python: Select Interpreter\u0026quot;를 입력한 뒤, 가상환경 이름이 들어간 인터프리터를 고른다.\n가상환경을 만든 직후 뜨는 알림창에서 바로 선택한다.\n가상환경을 만들면 VS Code Python 확장이 이렇게 물어 온다. \u0026ldquo;예\u0026quot;를 누르면 작업 영역의 인터프리터가 새 가상환경으로 바뀐다.\n터미널 활성화와 인터프리터 선택은 순서가 상관없지만, 둘 중 하나만 해서는 안 된다.\n패키지 목록은 requirements.txt로 옮긴다 가상환경이 켜진 상태에서 설치하면 패키지는 myenv 안에만 들어간다.\npip3 install {패키지명} 설치한 목록은 pip3 freeze로 뽑는다. 패키지 이름과 버전이 이름==버전 형식으로 나온다.\npip3 freeze 출력 예시다.\n이 출력을 파일로 저장해 두면 백업이 되고, 다른 가상환경에서 같은 목록을 그대로 설치할 수 있다.\n# 현재 가상환경의 패키지 목록을 파일로 저장 pip3 freeze \u0026gt; requirements.txt # 다른 가상환경에서 같은 버전으로 설치 pip3 install -r requirements.txt 끄고 지우기 비활성화는 한 줄이다.\ndeactivate 삭제는 더 간단하다. 가상환경은 폴더 하나라서 myenv 폴더를 지우면 끝이다. 탐색기에서 우클릭해 삭제하거나 터미널에서 지운다.\nrm -rf myenv 시스템 패키지를 가져가고 싶을 때 새 가상환경은 아무 패키지도 없는 백지에서 시작한다. 로컬 시스템에 이미 깔린 패키지를 가상환경에서도 쓰고 싶다면 --system-site-packages 옵션을 붙여 만든다.\npython3 -m venv myenv --system-site-packages 이렇게 만든 뒤 pip3 list를 치면 시스템에 설치된 패키지까지 목록에 나온다. 이때 가상환경에 따로 설치한 패키지만 보고 싶으면 --local을 붙인다.\npip3 list --local 정리 정리\n가상환경은 프로젝트 폴더 안의 폴더 하나다. 만들 때 python3 -m venv, 지울 때는 폴더 삭제.\nactivate는 셸의 PATH만 바꾼다. VS Code 인터프리터는 따로 골라야 한다.\n패키지 목록은 pip3 freeze \u0026gt; requirements.txt로 남기고 pip3 install -r로 되살린다.\n","permalink":"https://jaylab.blog/python-venv/","summary":"pip install이 한곳에 쌓여 프로젝트끼리 버전이 부딪히는 문제를 venv로 나누고, VS Code 인터프리터와 requirements.txt까지 맞추는 절차를 정리했다.","title":"프로젝트마다 Python 패키지를 따로 두는 venv"},{"content":"프로젝트에서 사용된 코드가 왜 SQL Injection에 열려 있는지 짚고, 파라미터 바인딩으로 바꾸는 두 가지 방식과 바꾸면서 헷갈렸던 정수형·null 처리에 대해 정리하였다.\n따옴표 하나로 조건이 늘어난다 SQL Injection은 입력값에 SQL 조각을 섞어 넣어 개발자가 의도하지 않은 쿼리를 데이터베이스가 실행하게 만드는 공격이다.\n어떤 POST API가 정상적으로는 이런 인자를 받는다고 가정.\n{ testKey : \u0026#34;testValue\u0026#34; } 이 값이 쿼리에 들어가면 이렇게 된다.\nselect * from testTable where key = \u0026#39;testValue\u0026#39; 악의적인 사용자는 값 안에 따옴표를 넣어 보낸다.\n{ testKey : \u0026#34;testValue\u0026#39; and \u0026#39;해킹\u0026#39;=\u0026#39;해킹\u0026#34; } 같은 코드에 넣으면 쿼리가 이렇게 바뀐다.\nselect * from testTable where key = \u0026#39;testValue\u0026#39; and \u0026#39;해킹\u0026#39;=\u0026#39;해킹\u0026#39; 입력값의 첫 따옴표가 문자열을 닫고, 그 뒤는 SQL 문법으로 읽힌다. 값으로 받은 것이 쿼리의 구조가 되는 순간이 Injection이다. 문자열을 이어 붙이는 한 입력값과 SQL 문법 사이에 경계가 없다.\n대응 전: 입력값을 쿼리에 이어 붙이던 코드 실무에서 쓰던 코드는 이런 모양이었다. psycopg2로 PostgreSQL에 붙고, fetch 함수가 완성된 쿼리 문자열 하나를 받아 실행한다.\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): 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 쿼리 선언 # ❌ 입력값(pjt_no, item_type)이 그대로 쿼리 문자열이 된다 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; + item_type + \u0026#34;\u0026#39;\u0026#34; # DB 호출함수 실행 result = fetch(conn=conn, query=query) pjt_no나 item_type에 앞의 예시 같은 값이 들어오면 그대로 쿼리가 된다.\n쿼리와 값을 따로 넘긴다 고치는 방법은 쿼리 문자열에는 자리표시만 두고, 값은 cursor.execute()의 두 번째 인자로 따로 넘기는 것이다. psycopg2가 값을 SQL 리터럴로 바꿔 끼울 때 따옴표를 이스케이프하므로 입력값에 따옴표가 섞여 있어도 문자열 밖으로 나가지 못한다. 따옴표를 직접 치환하는 식으로 막을 수도 있겠지만 그 일은 드라이버가 이미 제대로 하고 있다.\nfetch 함수는 값을 받을 인자 하나만 늘리면 된다.\n# DB 호출함수 선언 def fetch(conn, query, sql_arg): result = [] cursor = conn.cursor(cursor_factory=RealDictCursor) cursor.execute(query, sql_arg) # ✓ 쿼리와 값을 분리해서 넘긴다 # DB조회 결과값 담기 raw = cursor.fetchall() for line in raw: result.append(line) return result 값을 넘기는 방식은 두 가지다.\n딕셔너리로 이름 붙여 넘기기 자리표시를 %(이름)s로 쓰고 같은 key를 가진 딕셔너리를 넘긴다.\n# 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) 튜플로 순서대로 넘기기 자리표시를 %s로 쓰고 순서에 맞춘 튜플을 넘긴다.\n# 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) 튜플은 짧지만 자리표시가 늘어나면 순서를 맞추기 어렵다. 같은 값을 쿼리에서 두 번 쓰거나 조건이 많으면 딕셔너리 쪽이 읽기 쉽다.\n%s에 따옴표를 붙이지 않는다는 점도 기억해 둔다. '%s'로 쓰면 psycopg2가 넣은 따옴표와 겹친다. 문자열을 이어 붙일 때는 줄 끝 공백도 챙겨야 한다. \u0026quot;... = %(pjt_no)s\u0026quot; 뒤에 공백 없이 \u0026quot;and ...\u0026quot;를 붙이면 %(pjt_no)sand가 된다.\n정수 컬럼에도 %s를 쓴다 처음에 헷갈린 부분이다. 파이썬 문자열 포맷팅에서 %s는 문자열, %d는 정수다. 그럼 DB 컬럼이 int일 때는 %(변수명)d나 %d로 받아야 할까.\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) 이 코드도 동작하지만 str() 변환이 꼭 필요하지는 않다. psycopg2의 %s는 파이썬 포맷 문자가 아니라 드라이버의 자리표시라서, 타입과 상관없이 항상 %s다. 공식 문서도 정수에 %d, 실수에 %f를 쓰지 말라고 적어 두었다. 정수를 그대로 넘기면 psycopg2가 SQL 정수 리터럴로 바꿔 넣는다.\n# 형변환 없이 넘겨도 된다 query_paramDict = { \u0026#39;str_test\u0026#39; : test } 빈 값을 null로 넣을 때 update나 insert에서 전달받은 값이 빈 문자열('')이면 테이블에는 null을 넣어야 하는 경우가 있었다. 당시에는 쿼리 문자열의 자리표시를 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) 바뀐 쿼리는 where key = 'null'(문자열 null)이 아니라 where key = null이 된다. 의도한 대로 null이 들어가긴 하지만 지금 보면 손볼 곳이 세 군데 있다.\nnull도 값으로 넘기면 된다. psycopg2는 파이썬 None을 SQL NULL로 바꿔 넣으므로 쿼리 문자열을 고칠 필요가 없다. 이때 str(None)은 문자열 'None'이 되니 str()로 감싸지 않는다.\nis ''는 == ''여야 한다. is는 값이 아니라 객체가 같은지 비교한다. 빈 문자열이 우연히 같은 객체라서 동작할 수는 있어도 보장되지 않고, Python 3.8부터는 리터럴과 is 비교에 SyntaxWarning이 뜬다.\n조회 조건에서는 = null이 아니라 is null이다. SQL에서 key = null은 참이 되지 않아 어떤 행도 고르지 못한다. 예시 쿼리가 select라서 짚어 둔다. update의 set key = null, insert의 values (null)은 문제없다.\n# ✓ 빈 문자열이면 None을 넘긴다. 쿼리 문자열은 그대로 둔다 query_paramDict = { \u0026#39;str_test\u0026#39; : test if test != \u0026#39;\u0026#39; else None } cursor.execute(query, query_paramDict) 정리 Injection은 값이 쿼리의 구조가 될 때 생긴다. 문자열을 이어 붙이면 입력값과 SQL 문법 사이에 경계가 없다.\n쿼리에는 %s나 %(이름)s 자리표시만 두고, 값은 cursor.execute(query, args)의 두 번째 인자로 넘긴다. 딕셔너리와 튜플 둘 다 된다.\n자리표시는 타입과 상관없이 %s다. 정수도, null(None)도 값으로 넘긴다.\n바꾼 건 fetch 함수의 인자 하나와 쿼리 몇 줄이다. 코드량으로 치면 작은 수정인데 쿼리 문자열을 손으로 고치던 null 처리까지 따라 정리됐다. 값은 값으로 넘긴다는 원칙 하나가 생각보다 많은 걸 덮는다.\n","permalink":"https://jaylab.blog/python-injection/","summary":"사용자 입력을 문자열로 이어 붙이던 psycopg2 쿼리를 파라미터 바인딩으로 바꾸고, 정수 컬럼과 null 처리에서 헷갈린 점을 정리했다.","title":"psycopg2 파라미터 바인딩과 SQL Injection"}]