배운 점

각 단계를 마치고 제가 정리한 배운 점만 모았습니다. 진행 과정은 실습 로그에, 이해한 내용은 여기에 있습니다.

어떻게 쓴 글인가요?

진행 과정
실습하면서 한 작업과 트러블슈팅을 AI와 함께 정리했습니다.
배운 점
정리된 과정을 다시 보면서 제가 이해한 내용을 썼습니다. 초록색 상자로 따로 표시했습니다.

단계마다 제가 이해한 내용을 한 페이지에 모아 복습하는 용도로 씁니다. 직접 작성

AWS 인프라 10단계

배운 점 8편 · 기록 9개 중

AWS 1단계 · EC2 + Nginx 기본 배포

  • EC2 : AWS에서 빌려 쓰는 가상 서버. 물리 컴퓨터를 사는 대신 필요한 만큼 빌리고 쓴 시간만큼 낸다.
  • Nginx : 그 서버 위에서 도는 웹서버 프로그램. 브라우저가 보낸 요청을 받아서 해당 파일을 찾아 돌려준다.
    • 설치 명령어
      • sudo yum update -y : 설치된 패키지들 최신 버전 갱신
      • sudo yum install nginx -y : nginx 웹 서버 설치
      • sudo systemctl start nginx : nginx 실행
      • sudo systemctl enable nginx : 재부팅돼도 자동으로 켜지게 등록
        • start는 지금 켜는 것, enable은 부팅 때 켜지게 등록하는 것
    • 아마존 리눅스 2023은 dnf가 표준이고 yum은 호환용 링크이다.
      • yum을 입력해도 실제로는 dnf가 실행된다.
  • 접속 방식
    • 키 페어 (.pem)
      • 개인키 : 터미널에서 ssh -i 키.pem ec2-user@퍼블릭IP 로 접속
      • 공개키 : EC2 안에 가지고 있음
    • EC2 Instance Connect (브라우저)로 접속해서 .pem 은 사용하지 않음
      • Instance Connect : AWS가 접속할 때마다 임시 키를 서버에 넣어주는 방식
        • SSH를 내 IP로 제한하면 Instance Connect도 막힘 (AWS 쪽 IP에서 들어오기 때문)
  • 확인 방법
    • 브라우저에 http://퍼블릭IP : 밖에서 들어오는 길까지 확인

    • curl localhost : 서버 안에서만 확인

      —> 둘을 나눠서 본다면 nginx가 죽은 건지 네트워크가 막힌 건지 확인 가능

이 단계의 진행 과정 보기 →

AWS 2단계 · 커스텀 웹페이지 배포

  • sudo nginx -T | grep root
    • nginx -T : 설정 파일 전체를 합쳐서 출력하는 명령어
    • grep root : root 지시어만 걸러내는 명령어
      • root : nginx가 요청 받으면 파일을 찾으러 가는 디렉터리
        • 기본값 : /usr/share/nginx/html
  • 웹페이지 교체 방식
    • nginx root 디렉터리의 index.html을 바꾸면 됨
      • 파일만 바꾸었기 때문에 nginx 재시작 필요 X
      • 설정파일 (nginx.conf) 고쳤을 때 reload/restart 필요
  • 파일 소유자 , 권한
    • /usr/share/nginx/html은 root 소유자라서 sudo vi로 열어야 저장된다.
      • nginx는 요청 처리할 때 이 파일을 읽어야되는데 권한이 없으면 서버는 살아있지만 403이 뜬다
  • http:// public 주소 —> nginx 웹페이지 확인

이 단계의 진행 과정 보기 →

AWS 3단계 · 장애 발생 및 트러블슈팅

장애 1 : Nginx 설정 파일 문법 오류 (일부 줄 세미콜론 제거)

  • sudo nginx -t 로 사전 문법 오류 검사
    • nginx: [emerg] unexpected "}" in /etc/nginx/nginx.conf:15
      • 세미콜론이 없으면 지시어가 아직 안 끝났다 보고 다음 줄 까지 계속 읽는데 }를 만나는 순간 문법이 깨져서 오류.
        • 에러 알려주는 줄 번호는 터진 지점이고 원인 지점이 아님! —> 윗 줄까지 봐야함
      • emerg : 심각한 오류라는 뜻
  • sudo cat -n /etc/nginx/nginx.conf | sed -n '10,17p'
    • 10번째 줄 부터 17번째 줄까지 표시 (sed 문법)
  • sudo vi /etc/nginx/nginx.conf —> 들어가서 수정
  • sudo nginx -t → 성공 확인
    • nginx: the configuration file /etc/nginx/nginx.conf syntax is ok
      nginx: configuration file /etc/nginx/nginx.conf test is successful

장애 2 : 보안 그룹에 HTTP 포트 차단

  • curl localhost
    • 입력했을 때 내가 작성한 html 정상 출력
  • http:// public IP 입력했을 때 연결 실패
    • 여기서 알 수 있는 점은 curl 시도했을 때 살아있는 걸 보면 nginx 는 죽지 않았고 연결시켜주는 네트워크 접근 차단이 돼었다는 걸 알 수 있음
  • 오류
    • ERR_CONNECTION_TIMED_OUT → 요청이 서버에 도달조차 못함 (방화벽에서 차단)
      • 보안 그룹은 막힌 요청을 버리기만 하고 거절 응답을 보내지 않는다.
        • 브라우저가 기다리다 타임아웃

장애 3 : HTML 권한 오류

  • index.html 의 권한을 chmod 000 으로 제한해보았다.
  • 403 Forbidden
    nginx/1.30.3
    • 권한을 000으로 제한하면 403 오류가 나오는 것을 확인했다.
    • 403이 뜨면 nginx는 응답을 돌려줬다는 뜻으로 네트워크와 프로세스는 정상이지만 문제는 뒤쪽 파일이다.
      • nginx 워커 프로세스는 nginx 유저로 돌기 때문에 others 읽기 권한이 없으면 읽지 못 한다.
        • root 소유 파일이어도 644면 읽을 수 있다.

장애 4 : nginx 프로세스 중지

  • sudo systemctl status nginx
    • nginx가 활성화 돼있는지 알 수 있는 코드
    • 활성화 돼있으면 active로 표시
  • sudo systemctl stop nginx
    • nginx 비활성화
  • sudo systemctl status nginx
    sudo ss -lntp
    • nginx 는 inactive
    • 네트워크는 SSH만 살아있음
    • -lntp : 지금 이 서버에서 몇 번 포트를 누가 듣고 있는지 보는 명령어
      • -l : 들고 있는 소켓만
      • -n : 포트를 숫자로
      • -t : TCP만
      • -p : 그 포트를 쓰는 프로세스 이름
  • sudo systemctl start nginx
    sudo systemctl status nginx
    sudo ss -lntp
    • nginx 는 active 로 변경
    • 네트워크 80포트도 활성화
  • sudo systemctl is-enabled nginx
    • enabled 확인
  • 오류
    • ERR_CONNECTION_REFUSED → 서버까지는 도달했으나 80번 포트에 대기 중인 프로세스가 없어 즉시 거절

로그 위치

  • /var/log/nginx/error.log : 403의 이유가 여기 남음
  • /var/log/nginx/access.log : 요청이 들어왔는지 , 어떤 응답 코드로 끝났는지 남음
    • 브라우저 에러는 어디까지 갔는지만 알려주고 이유는 로그에 남음
에러 어디까지 도달? 원인
ERR_CONNECTION_TIMED_OUT (2번째) 서버에 도달 못함 보안 그룹·방화벽
ERR_CONNECTION_REFUSED (4번째) 서버는 도달, 프로세스 없음 서비스 중지
403 (3번째) Nginx가 응답까지 함 파일 권한
emerg (1번째) 서비스 시작 자체가 불가 설정 파일 문법

—> 에러 메시지를 먼저 범위를 네트워크 , 프로세스 , 파일 권한, 설정 중 하나로 좁히고 그 다음 로그로 원인을 확정한다

이 단계의 진행 과정 보기 →

AWS 4단계 · VPC·보안 설정

  • A : 네트워크 구조
    • VPC에는 4개의 서브넷 (2a 2b 2c 2d (2는 리전번호) ) 이 있는 걸 확인했다.
    • 현재 우리는 2c만 사용하고 있으며 라우팅 테이블에 IGW와 LOCAL 주소가 있는 것도 확인했다.
      • IGW는 0.0.0.0/0 으로 외부에서도 접속할 수 있게 해주는 출입이고 LOCAL 주소는 외부로 나가지 않고 VPC 내부에서 처리하는 규칙이다.
      • 외부에서 HTTP, HTTPS 로 처리할 수 있었던 이유는 IGW 규칙이 있었기 때문이다.
  • B : 탄력적 IP
    • 우리는 IPv4를 사용하고 있으며 Public IP 와 Private IP 두 개로 구분지을 수 있다.
    • 인스턴스를 계속 실행하고 있다면 두 개 IP다 변동이 없지만 중지시키고 다시 실행하면 Public IP 는 다른 IP로 변동되는 걸 확인했고 Private IP는 변하지 않았다.
      • 이 점을 해결하기 위해 탄력적 IP를 설정해주었다.
        • 설정 후 인스턴스에 할당을 하면 탄력적 IP 할당받은 주소가 Public IP가 된다.
        • 탄력적 IP를 할당하면 이제 재실행 해도 변하지않고 Public IP가 그대로인 걸 확인 할 수 있었다.
          • 탄력적 IP는 인스턴스에 연결되어 있지 않으면 요금이 발생하므로 사용하지 않을 때는 릴리스해야 한다.
  • C : 인바운드 규칙
    • 인바운드 (외부에서 서버로 오는 트래픽) 규칙은 총 3개가 있다.
      • HTTP(80) , HTTPS (443) , SSH (22).
    • 셋 다 IP는 0.0.0.0/0을 가르키고 있으며 HTTP와 HTTPS는 외부에서 접속해야하기 때문에 그냥 두었고 SSH은 보안문제로 인바운드 규칙을 변경해주었다.
      • anywhere IPv4 에서 내 IP로 변경하였고 주소는 Public IP로 자동으로 할당해주었다.
      • <퍼블릭 IP>/32 이 주소가 할당돼었고 , 내 서버의 22번 포트에, 내 공인 IP에서 오는 접속만 허용 이라는 뜻이다.
      • 문제점이 내 IP로 할당을 해서 EC2 터미널을 이용을 할 수 없었다.
        • 브라우저 터미널은 내 컴퓨터가 아니라 AWS 중계 서버가 접속하는 방식이기 때문에 접속이 안 됐다.
        • 그래서 EC2에 접속할 수 있는 13.209.1.56/29 ("AWS가 공개한 서울 리전 Instance Connect 대역) 을 규칙 밑에다가 추가해줬다.
  • 퍼블릭 IP = 인터넷에서 통용되는 주소. 서버든 니 집이든, 인터넷에 나가려면 다 하나씩 갖고 있어야 함.

이 단계의 진행 과정 보기 →

AWS 5단계 · ALB·Auto Scaling

  • A : AMI / 대상 그룹

    • AMI : 지금 돌아가는 서버의 이미지를 따둔 상태. 이 이미지로 찍어낸 서버는 Nginx가 이미 설치된 상태로 생성
      • 서버를 여러 대로 늘려야 하는데, 매번 EC2 만들고 Nginx 설치하는 걸 반복할 수 없어서 지금 상태를 통째로 이미지로 떠뒀다.
    • 그룹 대상 : ALB가 트랙픽을 보낼 서버 명단
      • 요청을 어떤 서버들에게, 몇 번 포트로 보내고 , 살아있는지 어떻게 확인할 것인가
      • ALB가 EC2에 바로 보내지않고 그룹대상을 걸치고 보낸다.
      • 대상 그룹을 만들 때 인스턴스를 직접 등록하지 않았다. ASG가 서버를 만들고 없앨 때마다 명단을 자동으로 갱신하기 때문에, 손으로 등록하면 오히려 어긋난다.
      • 상태 검사 경로를 /로 지정. ALB가 주기적으로 이 경로로 요청을 보내 서버가 살아있는지 확인한다.
  • B : ALB 구성 및 보안 그룹 재설정

    • ALB (Application Load Balancer) : 하나의 주소로 들어온 HTTP 요청을 여러 서버에 나눠서 전달하는 AWS의 로드 밸런서
      • Application : 애플리케이션 계층(HTTP/HTTPS)의 내용을 이해하고 처리한다는 뜻
      • Load Balancer : 부하(Load)를 여러 서버에 고르게 나누는(Balance) 장치
    • ALB의 보안그룹 : EC2와 공유하지 않으므로 따로 생성
    • ALB의 가용영역 : 2개 이상 설정
      • ALB가 AZ 한 곳에만 있으면, 그 AZ가 죽었을 때 뒤에 서버가 아무리 많아도 입구가 사라져 서비스가 멈춘다.
    • 리스너 : 몇 번 포트로 들어온 요청을 어디로 보낼지 정하는 규칙
      • 사용자 요청 (port : 80) —> 리스너 → 그룹 대상 (nginx-tg)
    • EC2의 보안그룹 재설정
      • HTTP 인바운드 설정을 ALB로 수정
        • 전에는 사용자가 EC2에 직접 접속해야 했기 때문에 80번을 전체에 열어둘 수밖에 없었지만, ALB를 붙인 뒤에는 모든 정상 요청이 ALB를 거쳐 오므로 소스를 ALB 보안 그룹으로 변경
        • EC2의 80번이 여전히 전체에 열려 있으면, 누군가 EC2의 IP를 알아내 ALB를 건너뛰고 직접 접속할 수 있다. 그러면 ALB에서 하는 검사나 제어를 전부 우회하게 된다.
      • 소스에 IP가 아니라 보안 그룹을 지정한 이유
        • ALB는 내부적으로 여러 IP를 쓰고 AWS가 바꾼다. IP로 지정하면 바뀔 때마다 고쳐야 하지만, 보안 그룹으로 지정하면 "이 그룹에 속한 것"이라는 관계로 판단하므로 손댈 필요가 없다.
  • C : Launch Template · Auto Scaling Group 구성

    • Launch Template : EC2 인스턴스를 만들 때 사용할 설정을 미리 정의해둔 설계도.
      • 새 서버를 만들려면 결정해야 하는 것들을 미리 적어두는 것 (AMI, 인스턴스 유형, 키페어, 보안그룹)
      • 무엇을 만들지만 정의, 서버를 어디에 둘 것은 ASG가 정의
    • ASG : Auto Scaling Group
      • 지정한 대수를 유지하도록 인스턴스를 자동으로 만들고, 없애고, 교체하는 그룹

      • 지정된 서브넷들에 최대한 고르게 나눠서 배치

        • 희망 용량 : 평소에 유지할 대수. 이보다 적어지면 자동으로 채운다
        • 최소 용량 : 부하가 없어도 이 아래로는 줄이지 않는다
        • 최대 용량 : 부하가 몰려도 이 위로는 늘리지 않는다 (비용 상한)
        희망 용량 서브넷 결과
        2 2a, 2b 각 AZ에 1대씩
        4 2a, 2b 각 AZ에 2대씩
        1 2a, 2b 둘 중 한 곳에 1대만
        2 2a만 2a에 2대 몰림
      • ELB 상태검사

        • ALB가 내린 Unhealthy 판정을 ASG가 받아들인다**.** 그래서 "인스턴스는 살아있지만 웹서버가 응답하지 않는" 상황에서도 서버를 교체할 수 있다.
    • [요청 흐름] 사용자 → ALB → 대상 그룹 ─┐
      ├→ EC2
      [생성 흐름] AMI → Launch Template → ASG ┘
  • D : 검증 및 리소스 정리

    • 트래픽 분산 : 2a, 2b 서버에 각각 index.html을 다르게 입력 후 웹페이지 새로고침하면서 확인 —> 변갈아가면서 바뀐 내용들 나오는 거 확인
    • 장애 자동 복구 : 2a 서버 중지시킴 → 502 오류 페이지랑 2b 서버 페이지 두 개가 번갈아가면서 나오다가 최종적으로 2b 서버 index.html만 나옴.
      • ALB의 헬스체크는 주기적으로 하고 연속 실패가 쌓여야 Unhealthy 판정을 내린다.
        • 판정 전까지는 죽은 서버에도 계속 트래픽을 보내서 502가 났고, 판정 후 제외되면서 사라졌다.
      • 대상그룹의 변화
        • 2a 서버 → Draining (처리 중인 요청만 마무리하고 명단에서 빠지는 중)
        • ASG가 새로 만든 서버 → 헬스체크 통과 후 Healthy로 명단에 합류
      • ASG가 새로 만든 서버에는 서버 A/B (다르게 입력한 구문) 구분 문구가 없었다.
        • AMI가 문구 수정 이전에 만들어졌기 때문. 서버에 손으로 고친 건 인스턴스가 교체되면 사라진다.

이 단계의 진행 과정 보기 →

AWS 6단계 · Terraform 자동화

A. Terraform 환경 구성 및 AWS 연결

  • Terraform 이란 이전까지 인프라의 수작업들을 코드로 작성해 둠으로써 추후에 인프라 만들 때 명령어로 쉽게 생성 및 제거하는 기술이다. 이 방식을 IaC (Infrastructure as Code) 라 불리며 HCL 코드로 작성된다. (확장자는 .tf)
  • 연결 확인 명령어
aws sts get-caller-identity
  • main.tf 작성
terraform {
  required_providers {
    aws = {
      source  = "hashicorp/aws"
      version = "~> 5.0"
    }
  }
}

provider "aws" {
  region = "ap-northeast-2"
}

data "aws_caller_identity" "current" {}

output "account_id" {
  value = data.aws_caller_identity.current.account_id
}

required_providers → 버전 범위 및 사용 provider 선언

provider “aws” → 리전 위치 설정

data → 조회전용, 읽기만

output → apply 후 화면에 출력하는 값

  • terraform 실행 명령어
    1. terraform init —> 플러그인 내려받음. provider 거침
      1. 생성파일
        1. .terraform/ : provider 플로그인이 저장되는 폴더
        2. .terraform.lock.hcl : provider 버전을 고정해 다른 환경에서도 동일한 버전 다운 받게 하는 파일
    2. terraform plan —> 적용 후 어떻게 적용되는지 보여주는 미리보기 단계
      1. data조회와 resource 계산 후 보여줌. 지금은 리소스가 없어 데이터 코드만 거친다.
    3. terraform apply —> 코드대로 리소스 생성, 추가, 변경, 삭제
      1. terraform.tfstate 생성 : terraform이 무엇을 만들었는지 기록해두는 파일 (장부)
        1. state 파일은 리소스의 식별자와 속성이 그대로 나타나 공개하면 그대로 노출됨.
        2. state가 없는 컴퓨터에서 apply 하면 리소스가 중복 생성된다.

B. 첫 리소스 생성 및 삭제 (보안그룹)

resource "aws_security_group" "web" {
  name        = "tf-web-sg"
  description = "Managed by Terraform"

  ingress {
    description = "HTTP from anywhere"
    from_port   = 80
    to_port     = 80
    protocol    = "tcp"
    cidr_blocks = ["0.0.0.0/0"]
  }

  egress {
    from_port   = 0
    to_port     = 0
    protocol    = "-1"
    cidr_blocks = ["0.0.0.0/0"]
  }

  tags = {
    Name = "tf-web-sg"
  }
}
  • resource → AWS에서 실제로 리소스를 만듦
  • 보안 그룹 코드 작성 : cat >> main.tf << ‘EOF’ ( >>는 이어쓰기, >는 덮어쓰기, EOF는 End Of File의 약자로 이 단어 나올 때 입력 종료라는 뜻 —> heredoc구조, 다른 단어도 가능)
    1. aws_security_group : 리소스 타입
      1. web : terraform 에서 사용하는 이름표. aws엔 전달 안 됨
    2. ingress : 들어오는 트래픽 (포트 번호, 프로토콜 ,ip 등을 정의)
    3. egress : 나가는 트래픽
  • 이후 plan → apply 하면 plan에서 known after apply 였던 ID가 실제값으로 채워진다.
    • EC2 console에 들어가 보안그룹을 확인하면 자동으로 생성된 걸 확인했다.
    • 후 destroy하면 자신의 state 기록된 리소스만 삭제하고 콘솔로 만든 다른 리소스는 안 건드린다.

C. EC2 코드화

data "aws_ami" "amazon_linux" {
  most_recent = true
  owners      = ["amazon"]

  filter {
    name   = "name"
    values = ["al2023-ami-*-x86_64"]
  }
}

resource "aws_instance" "web" {
  ami                    = data.aws_ami.amazon_linux.id
  instance_type          = "t3.micro"
  vpc_security_group_ids = [aws_security_group.web.id]

  tags = {
    Name = "tf-web-server"
  }
}

output "instance_public_ip" {
  value = aws_instance.web.public_ip
}
  • AMI 조회
    • data “aws_ami” “amazon_linux” —> aws_ami : 데이터 타입 / amazon_linux : 이름표
      • owner = [”amazon”] : 아마존이 발행한 공식 이미지만 대상
      • most_recent : 조건에 맞는 것 중 최신 버전
    • filter : name 이 value 패턴인 것을 필터 처리함.
    • AMI ID는 리전마다 다르고 이미지가 갱신될 때마다 바뀌기 때문에 ID를 직접 쓰지 않는다.
  • EC2 생성 (resource)
    • resource “aws_instance” “web”
      • ami : 위에서 조회한 AMI ID
      • instance type : t3.micro
      • vpc_security_group_ids : 앞에 정의한 보안 그룹 ID
    • output “instance_public_ip”
      • 생성된 EC2의 퍼블릭 IP
    • 참조 : 값을 쓰지 않고 다른 블록을 가르키는 것
      • terraform이 참조관계를 보고 생성 및 삭제 순서 자동으로 결정 (위에 정의한 web을 이용)
  • terraform plan 실행
    • data 조회 (지금까지 작성했던 data 코드들 조회, 실제 AWS에서 조회)
    • resource 계산 : data 결과 참조, 계산만 실행. 실제로 만들지는 않음
    • 아직 apply를 실행하지 않아서 보안 그룹과 인스턴스가 생성되지 않은 상태.
      • vpc_security_group_ids = (known after apply)
      • instance_public_ip = (known after apply)
  • terraform apply
    • 보안 그룹, 인스턴스 생성
    • output에 account_id, instance_public_ip 조회
    • 생성순서는 보안 그룹 → EC2 순서
  • terraform destroy
    • EC2 → 보안 그룹 순서로 삭제. 보안그룹이 EC2에 연결돼있으므로 EC2가 먼저 제거되어야 함.

이 단계의 진행 과정 보기 →

AWS 7단계 · Terraform 기반 Private RDS 구축

A 네트워크 및 RDS 코드 작성

  • 6단계 terraform 에서는 하나의 코드에서 해결했지만 7단계에선 역할 별로 코드를 나누었다.

    1. provider.tf

      1. 버전 범위 및 리전 위치
      terraform {
        required_providers {
          aws = {
            source  = "hashicorp/aws"
            version = "~> 5.0"
          }
        }
      }
      
      provider "aws" {
        region = "ap-northeast-2"
      }
      
    2. network.tf

      1. 가용 영역 목록 조회 (data)
        1. 리소스 타입 : aws_availability_zones
        2. 이름 : available
        3. 조회 조건 : state = available (사용 가능한 AZ만)
      2. VPC (resource) —> AWS 안에 내 네트워크 공간 생성
        1. 리소스 타입 : aws_vpc
        2. 이름 : main (vpc 내에서 main)
        3. 코드 해석
          1. cidr_block → VPC가 쓸 범위 10.0.0.0/16 —> 10.0.255.255 까지 사용가능 (내가 IP 쓸 범위, /16→ 앞 16비트 고정, 뒤 16비트 자유)
          2. enable_dns_support → VPC 안에서 도메인 이름을 IP로 바꿀 수 있게 함.
          3. enable_dns_hostnames → 리소스에 DNS 이름 부여.
          4. tags = aws console 에 표시될 이름
        4. VPC → public subnet / private subnet a,b / IGW / routing table
      3. IGW (resource) —> VPC와 인터넷을 연결 시키는 출입구
        1. 리소스 타입 : aws_internet_gateway
        2. 이름 : main
        3. vpc_id : VPC에 id참조해서 대입 —> 이 줄 때문에 VPC가 먼저 만들어지고 IGW가 나중에 만들어짐 (의존 관계)
      4. Public Subnet → VPC를 구역으로 쪼갠 것 (VPC범위 내, 겹치면 안 됨)
        1. vpc_id : vpc 참조 후 id 작성
        2. cidr_block : 10.0.0.0/16 중 10.0.1.0/24만 사용 (앞 24비트 고정, 뒤 8비트만 사용)
        3. availability_zone → 가용 영역 목록 조회하는 곳과 의존관계. 서브넷이 AZ를 걸칠 수 없어 하나에만 속함.
        4. map_public_ip_on_launch : 여기 뜨는 EC2는 자동으로 퍼블릭 IP를 받음
      5. Private Subnet A/B (map_public_ip_on_launch 줄 X)
        1. cidr_block : 세 번째 칸으로 서로 구분 (A : 11 , B : 12)
        2. availability_zone → 가능한지 검사
        3. RDS는 DB 서브넷 그룹에 서로 다른 AZ 서브넷 2개 이상을 요구한다.
      6. Routing table (나가는 패킷을 어디로 보내는지 적어둔 표)
        1. route 블록
          1. cidr_block : 목적지가 0.0.0.0/0 (=전부) 라면
          2. gateway : IGW로 보내라
      7. association
        1. 만든 표를 public subnet에 붙임.
          • AWS에서는 붙이는 행위 자체가 리소스기 때문이다.
          • private subnet a/b는 association을 안 만듦
            • VPC 만들 때 만들어지는 기본 라우팅 테이블이 자동으로 붙음 —> local만 존재, IGW로 나가는 길이 없어 인터넷으로 나갈 길이 없음 —> private)
      # 가용 영역 목록 조회
      data "aws_availability_zones" "available" {
        state = "available"
      }
      
      # VPC
      resource "aws_vpc" "main" {
        cidr_block           = "10.0.0.0/16"
        enable_dns_support   = true
        enable_dns_hostnames = true
      
        tags = {
          Name = "tf-step7-vpc"
        }
      }
      
      # 인터넷 게이트웨이
      resource "aws_internet_gateway" "main" {
        vpc_id = aws_vpc.main.id
      
        tags = {
          Name = "tf-step7-igw"
        }
      }
      
      # 퍼블릭 서브넷 (EC2용)
      resource "aws_subnet" "public" {
        vpc_id                  = aws_vpc.main.id
        cidr_block              = "10.0.1.0/24"
        availability_zone       = data.aws_availability_zones.available.names[0]
        map_public_ip_on_launch = true
      
        tags = {
          Name = "tf-step7-public"
        }
      }
      
      # 프라이빗 서브넷 A (RDS용)
      resource "aws_subnet" "private_a" {
        vpc_id            = aws_vpc.main.id
        cidr_block        = "10.0.11.0/24"
        availability_zone = data.aws_availability_zones.available.names[0]
      
        tags = {
          Name = "tf-step7-private-a"
        }
      }
      
      # 프라이빗 서브넷 B (RDS용, 다른 AZ)
      resource "aws_subnet" "private_b" {
        vpc_id            = aws_vpc.main.id
        cidr_block        = "10.0.12.0/24"
        availability_zone = data.aws_availability_zones.available.names[1]
      
        tags = {
          Name = "tf-step7-private-b"
        }
      }
      
      # 퍼블릭 라우팅 테이블
      resource "aws_route_table" "public" {
        vpc_id = aws_vpc.main.id
      
        route {
          cidr_block = "0.0.0.0/0"
          gateway_id = aws_internet_gateway.main.id
        }
      
        tags = {
          Name = "tf-step7-public-rt"
        }
      }
      
      # 퍼블릭 서브넷에 라우팅 테이블 연결
      resource "aws_route_table_association" "public" {
        subnet_id      = aws_subnet.public.id
        route_table_id = aws_route_table.public.id
      }
      
    3. security.tf

      1. 보안그룹이란?
        1. 리소스 하나하나에 붙는 개인 경비원
          • EX ) subnet - Routing table / EC2 - 보안 그룹
        2. 규칙
          1. ingress : 인바운드 (밖에서 안으로)
          2. egress : 아웃바운드 (내가 밖으로)
        3. EC2에서는?
          1. ingress : from_port , to_port , protocol , cidr_blocks
          2. cidr_blocks = 0.0.0.0/0 ⇒ 모든 IP 허용
      2. RDS security
        1. security_groups
          • ec2 에선 cidr_blocks로 모든 IP 허용
          • RDS는 EC2 보안 그룹 달고 있는 친구로 허용
      resource "aws_security_group" "ec2" {
        vpc_id = aws_vpc.main.id
      
        ingress {
          from_port   = 22
          to_port     = 22
          protocol    = "tcp"
          cidr_blocks = ["0.0.0.0/0"]
        }
      }
      
    4. variables.tf

      1. 값이 있다 라고 하는 것만 선언.
        1. 실제 값은 terraform.tfvars 에 작성
        2. 선언은 git에 올라가고 값만 빠짐
      2. description : 설명
      3. type : 문자열만 (string, list, number, bool …)
      4. sensitive : 화면에서 가리기
        • 없을 때 plan 실행 시 (db_username에는 없고 default값 존재)
          • + password = "<DB 비밀번호>"
        • 있을 때 plan 실행 시 (db_password에는 default값 존재 X)
          • + password = (sensitive value)
    variable "db_username" {
      description = "RDS master username"
      type        = string
      default     = "admin"
    }
    
    variable "db_password" {
      description = "RDS master password"
      type        = string
      sensitive   = true
    }
    
    1. rds.tf
      1. RDS → 운영 업무를 돈 주고 AWS에게 맡기는 것 (관리형 서비스)
        1. 백업, 보안패치, 장애 복구, 설치를 AWS가 자동으로 해줌, OS 레벨은 접근이 힘듦
        2. EC2에서 MySQL 직접 설치시 다 본인이 해야함
        3. 가격은 RDS 비싸고 MySQL은 저렴
      2. aws_db_subnet_group (RDS가 있을 그룹)
        1. RDS는 subnet을 직접 고르지 못 하므로 서브넷 묶음을 먼저 만들고 지정
        2. private_a.id , private_b.id (이 둘 중에서만 살기)
          • subnet을 두 개 만든 이유
      3. aws_db_instance (RDS 인스턴스)
        1. DB
          1. engine : MYSQL
          2. engine_version : 버전 지정
        2. 크기
          1. instance_class : DB 서버의 스펙
          2. allocated_storage : 디스크 크기
        3. 안에 만드는 정보
          1. db_name : DB 서버 안에 만들 데베 이름
          2. username, password : DB 마스터 계정 아이디, 비밀번호 (내가 SQL로 만드는 정보)
            1. var. 으로 가져옴
          3. db_subnet_group_name : 위치
          4. vpc_security_group_ids : RDS 보안그룹 지정 (security.tf)
        4. publicly_accessible = false
          1. public IP를 안 줌. —> 인터넷에서 이 DB의 주소 자체가 향하지 않음
            1. private subnet + publicly_accessible + 보안그룹 세 겹의 방어 체제
        5. skip_final_snapshot = true
          1. 건너뛸지의 여부 —> 건너뛰면 백업 없이 바로 삭제
    # =====================================
    # rds.tf
    # =====================================
    
    # ① DB 서브넷 그룹 — RDS가 살 수 있는 범위
    resource "aws_db_subnet_group" "main" {
      name       = "tf-step7-db-subnet-group"
      subnet_ids = [
        aws_subnet.private_a.id,   # AZ[0]
        aws_subnet.private_b.id,   # AZ[1]  ← 서로 다른 AZ 2개 필수
      ]
    
      tags = {
        Name = "tf-step7-db-subnet-group"
      }
    }
    
    
    # ② RDS 인스턴스
    resource "aws_db_instance" "main" {
      identifier = "tf-step7-mysql"          # AWS 콘솔에 표시될 이름
    
      # --- 어떤 DB인가 ---
      engine         = "mysql"
      engine_version = "8.0"                 # 메이저만 지정, 마이너는 AWS가 채움
    
      # --- 얼마나 큰가 ---
      instance_class    = "db.t3.micro"      # EC2 t3.micro급. 앞에 db. 붙음
      allocated_storage = 20                 # 20GB
    
      # --- 안에 뭘 만드는가 ---
      db_name  = "portfolio"                 # 생성될 데이터베이스 이름
      username = var.db_username             # variables.tf에서
      password = var.db_password             # terraform.tfvars에서 (sensitive)
    
      # --- 어디에 두는가 ---
      db_subnet_group_name   = aws_db_subnet_group.main.name   # ①번 참조
      vpc_security_group_ids = [aws_security_group.rds.id]     # security.tf 참조
      publicly_accessible    = false                           # 퍼블릭 IP 없음
    
      # --- 삭제 정책 ---
      skip_final_snapshot = true             # 최종 스냅샷 건너뛰고 바로 삭제
    
      tags = {
        Name = "tf-step7-mysql"
      }
    }
    
    
    # ③ 출력 — apply 후 터미널에 표시
    output "rds_endpoint" {
      value = "${aws_db_instance.main.endpoint}"
    }
    
    1. ec2.tf

      1. EC2의 역할 —> RDS에 접속하는 통로
        1. 6단계에선 웹 서버였음
      2. AMI 조회
        1. 6이랑 동일
        2. 최신 버전, amazon 버전 사용, 이름 패턴이 al2023-ami~~ 인 것
        3. apply 하기 전 data로 조회하면 apply 하는 시점에서 최신 AMI를 가져옴
      3. EC2 본체
        1. 위 조회한 결과의 ami 사용
        2. subnet_id
          1. 6단계에선 서브넷 안 적고 AWS가 만든 기본 VPC의 아무 서브넷 사용
          2. 지금은 내가 VPC를 만들었으니 직접 사용 (network.tf에 만든 public subnet 사용)
        3. 보안그룹
          1. vpc_security_group_ids 사용
      # =====================================
      # ec2.tf
      # =====================================
      
      # ① 최신 Amazon Linux 2023 AMI 조회
      data "aws_ami" "amazon_linux" {
        most_recent = true
        owners      = ["amazon"]
      
        filter {
          name   = "name"
          values = ["al2023-ami-*-x86_64"]
        }
      }
      
      
      # ② EC2 인스턴스
      resource "aws_instance" "main" {
        ami           = data.aws_ami.amazon_linux.id
        instance_type = "t3.micro"
      
        subnet_id              = aws_subnet.public.id            # ← 7단계에서 추가된 줄
        vpc_security_group_ids = [aws_security_group.ec2.id]
      
        tags = {
          Name = "tf-step7-ec2"
        }
      }
      
      
      # ③ 출력
      output "ec2_public_ip" {
        value = aws_instance.main.public_ip
      }
      
  • init

    • network.tf : 7개 (VPC, IGW, 서브넷 3, 라우팅 테이블 , association)
    • security.tf : 2개 (보안그룹 2개)
    • rds.tf : 2개 (DB 서브넷 그룹 , RDS )
    • ec2.tf : 1개 (EC2)
  • plan

B 배포 및 접속 검증

  • apply 후 생성 순서 (Terraform이 스스로 결정)
    1. VPC
    2. IGW , 서브넷 3개, EC2 보안그룹
    3. 라우팅 테이블, DB 서브넷 그룹, RDS 보안 그룹
    4. 라우팅 연결, RDS, EC2
  • EC2 VS RDS 비교
    • EC2 : AMI 복사 후 부팅
    • RDS : 서버 띄움 + MySQL 엔진 설치 + 초기화 + 백업 설정 + 파라미터 그룹 적용
  • 검증 설계
    1. 외부에서 접속

      1. 터미널에서 입력
        1. nc : netcat → 이 주소의 포트가 열려있는지 확인
        2. -z : 데이터를 안 보내고 연결만 시도
        3. -v : 결과 자세히 출력
        4. -w 5 : 5초 기다리고 포기
      nc -zv -w 5 <RDS 엔드포인트> 3306
      

      —> timed out : 돌아오는 IP가 private IP

    2. EC2에서 접속

      1. EC2 인스턴스에 EC2 Instance Connect 접속
        1. [ec2-user@<내부 IP> ~]$ —> 10.0.1.0/24 범위 안
      sudo dnf install -y mariadb105
      

      —> MySQL 클라이언트 설치 아마존 리눅스는 yum이 아니라 dnf 사용

      mysql -h <RDS 엔드포인트> -u admin -p
      

      —> EC2 에서 RDS 접속 성공

    3. 데이터 입력 및 조회

      SHOW DATABASES;
      
      USE portfolio;
      
      CREATE TABLE users (
        id INT AUTO_INCREMENT PRIMARY KEY,
        name VARCHAR(50),
        email VARCHAR(100),
        created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
      );
      
      INSERT INTO users (name, email) VALUES ('lee', '[email protected]');
      SELECT * FROM users;
      
    4. 삭제

      terraform destroy
      

      —> 생성의 역순

이 단계의 진행 과정 보기 →

AWS 8단계 · Docker·ECS

A. Docker 기초와 이미지 만들기

  • 방식의 변화
    • 지금까지의 방식
      • 가상컴퓨터 → 프로그램 설치 → 실행 (VM—> OS 전체)
        1. 느리다
        2. 무겁다
        3. 손으로 고치면 거기에만 있음.
    • 새로운 방식
      • 컨테이너 : 프로그램과 프로그램이 돌아가는 데 필요한 것들을 통째로 한 덩어리로 묶어 그대로 실행
        • VM은 OS를 들고다니고, 컨테이너는 호스트의 OS를 빌려 씀
        • 컨테이너 안에서 고친 건 컨테이너를 지우면 사라짐
          • 고칠 게 있으면 컨테이너가 아니라 이미지를 새로 만들어야 함
docker run -d -p 8080:80 --name test nginx
  • 컨테이너와 계층구조
    • docker : 도구 (만들고, 받아오고, 실행시켜주는 프로그램)
    • 이미지 : 파일 (층으로 쌓인 덩어리, 안 변함)
      • nginx : 틀 이름
    • 컨테이너 : 실행 상태 (이미지를 돌린 것, 프로세스 하나)
      • 지금 까지 EC2는 가상컴퓨터 한 대였다면, 컨테이너는 프로세스 (실행중인 프로그램 하나)
  • run -d -p 8080:80
    • 8080 : 내 맥 —> 맘대로 설정 가능
    • 80 : 컨테이너 —> 컨테이너 안 80번의 nginx가 응답
    • -p : 내 맥에서 컨테이너로 들어갈 수 있게 해주는 역할
    • run : 이미지로 컨테이너를 만들어 실행
    • -d : 백그라운드에서 실행
      • 백그라운드 : 상태 (터미널이 묶이냐 안 묶이냐의 상태) / 컨테이너를 돌리는 건 docker

B . ECR 이미지 푸시

  • A에서 만든 이미지는 내 Docker 안에서만 존재 —> ECR가 필요한 이유
    • ECR : 이미지를 올려둔 AWS 창고
    • ECS : 창고에서 이미지를 꺼내서 실제로 돌려주는 곳
      • 다른 곳에서 실행되기 때문에 이미지를 볼 수 없었다. 그래서 ECR이 필요
aws ecr create-repository --repository-name myweb --region ap-northeast-2
  • 리포지토리
    • 이미지 한 종류를 담는 칸
    • 다른 애플리케이션이면 다른 리포지토리 필요
aws ecr get-login-password --region ap-northeast-2 | docker login --username AWS --password-stdin <계정 ID>.dkr.ecr.ap-northeast-2.amazonaws.com
  • 인증
    • ECR은 인증없이 push 할 수 없다.
    • get-login-password
      • 12시간짜리 임시 토큰 발급 —> 파이프로 docker login에 넘김
        • 기존 aws ecr get-login-password --region ap-northeast-2 으로 로그인하게 되면 화면에 비밀번호가 뜬다.
          • docker login -u AWS -p <토큰> 으로 로그인하면 히스토리에 남음
        • 이 코드를 사용하면 화면을 비밀번호랑 입력하는 칸이 뜨지않고 바로 프로그램으로 넘어간다.
      • 사용자 이름이 AWS로 고정인 것도 ECR이 정해둔 값
docker tag myweb:1.0 <계정 ID>.dkr.ecr.ap-northeast-2.amazonaws.com/myweb:1.0
docker images | grep myweb
  • 태그
    • 이미지 이름 뒤 : 다음에 붙는 버전 표시
      • myweb : 1.0
    • 추후에 index.html 고쳐서 다시 빌드 —> myweb : 1.1 생김
      • 1.0 , 1.1 둘 다 있게 되면서 나중에 롤백할 때 가능
    • docker tag
      • 이미지를 복사하는 게 아니라 이름을 하나 더 붙이는 것
docker push <계정 ID>.dkr.ecr.ap-northeast-2.amazonaws.com/myweb:1.0
  • 푸시
    • 이름의 앞부분을 보고 목적지를 정한다.
    • 덩어리가 아닌 레이어 단위로 올라감

C . ECS Fargate 배포와 장애 주입 테스트

  • B에서 ECR에 올린 myweb:1.0 을 ECS Fargate로 실행
    • ALB를 통해 인터넷에 접속되는 구조를 Terraform으로 만듦
      • 인터넷 (port : 80) → ALB (public subnet 2개) → 타겟 그룹 → ECS Service → Task 2 (ECR의 myweb:1.0)
  • network.tf
    • ALB는 서로 다른 public subnet 2개를 요구.
      • 7단계 network.tf 랑 다른 점
      • AZ 한 곳이 죽어도 ALB는 살아있어야하기 때문에 2개를 요구
    • 전이랑 다른 점
      • cidr_block : 10.0.2.0/24
      • 기존 public subnet : 10.0.1.0/24
        • 새로 만들지 않고 7단계에서 만들었던 public routing table 그대로 연결
        • 둘 다 0.0.0.0/0 ⇒ IGW로 나가기 때문에 중복해서 만들이유 X

이 단계의 진행 과정 보기 →

Proxmox 홈랩

배운 점 5편 · 기록 6개 중

홈랩 1단계 · Proxmox 설치와 원격 접속 구성

A. Proxmox 설치와 초기 설정

  • Proxmox VE : 가상컴퓨터 (VM)를 만들고 관리하는 데 특화된 OS
    • Debian : 리눅스 종류 중 하나. Proxmox 는 Debian 위에 만들어짐
    • ISO : 설치 CD를 파일 하나 만든 것
      • balenaEtcher : ISO를 USB에 굽는 프로그램 —> Proxmox VE ISO를 이 프로그램에 넣어서 USB 자체를 설치 CD로 바꿈
  • BIOS : 전원 누르자마자 OS보다 먼저 켜지는 프로그램
    1. Advanced에서 VMX (Virtualization Technology) 활성화 : CPU의 가상 컴퓨터 만들기 기능 활성
      • proxmox는 이 cpu 기능을 빌려서 VM을 만듦. 꺼져있으면 빌리지를 못 함
    2. Boot 에서 Boot Option #1 을 USB를 1순위로 설정
      • UEFI : BIOS의 정식 이름.
    3. Security에서 Secure Boot를 끔 : 인증받은 OS만 켜지게 막는 보안기능을 끔. 윈도우 기준이라 우선 설정 끄고 시작
  • 설치
    • Install Proxmox VE (Graphical) —> 마우스로 누르는 화면 방식
      • Filesystem : ext4 → 디스크에 파일 정리하는 규칙. 리눅스는 ext4
      • IP : <내부 IP>/24 —> 집 안 컴퓨터 주소
      • Gateway : <공유기 IP> —> 공유기 주소 : 인터넷 나가는 문
    • 설치 후 재부팅 시 USB 제거 : 제거 안 하면 설치화면 다시
  • 웹 UI 접속
    • https://<내부 IP>:8006
      • <내부 IP> : 미니피씨 주소
      • 8006 : 컴퓨터 포트 → proxmox 관리화면은 8006 사용. 웹사이트는 보통 80/443 사용
    • root/LinuxPAM으로 로그인
      • root : 리눅스 최고 관리자 계정
      • LinuxPAM : 리눅스 기본 로그인 방식
  • 저장소 설정 및 업데이트
    • Repositories : 저장소 (프로그램, 업데이트 파일 받아오는 창고 서버)

      • 유료 회원으로 돼있는 설정들 변경
        • enterprise , ceph (저장장치 여러 대를 묶는 기능) 끔
        • no-Subscription : 킴
    • Refresh시 apt를 IPv6 연결 실패해서 IPv4 로 고정

      • Refresh : 창고에게 업데이트 여부 확인
      • apt : 창고에서 프로그램 받아오는 도구
      echo 'Acquire::ForceIPv4 "true";' > /etc/apt/apt.conf.d/99force-ipv4
      apt update
      
      • apt.conf.d/ : apt 설정 파일을 넣는 폴더
      • Acquire::ForceIPv4 "true" : apt가 받아올 때 IPv4만 쓰게 함
      • 파일 이름 앞 99 : 이 폴더는 파일을 이름순으로 읽어서, 99로 시작하면 마지막에 읽혀 다른 설정을 덮어씀
      • apt update : 바뀜 설정으로 저장소 다시 확인

B. Tailscale 원격 접속

  • 문제점 : <내부 IP> 는 집 안 (사설망)에만 통해서 밖에선 접속 불가
    • 사설망 : 집, 회사 안에서 쓰는 네트워크
    • 사설 IP : 집 안에서만 통하는 주소
  • Tailscale : 내 기기끼리만 쓰는 전용 통로 (VPN) 만들어 줌
    • VPN : 인터넷 위에 나만의 통로를 깔기
    • Tailscale : 그 VPN을 쉽게 만들어주는 서비스. 같은 계정끼리 묶어줌
    • 주소 : Tailscale을 깔면 기기마다 100.x.x.x 주소가 더 생김
      • 접속할 때 Tailscale 주소로 접속.
      • 같은 네트워크 내에선 기존 사설 IP로 접속 가능
      • 포트포워딩 : 밖에서 들어오는 원래 방법. (전 세계에 문을 여는 것) ↔ 메시 VPN : TailScale → 내 기기에게만 열림
  • 설치
    • pve shell 에서 명령어 입력
      • curl -fsSL https://tailscale.com/install.sh | sh —> 인터넷에서 설치 스크립트 받아서 바로 실행
        • curl : 인터넷에서 파일 받기
        • fsSL : 에러나면 멈추고 조용히 주소 바뀌면 따라가는 옵션 묶음
        • install.sh : 설치 스크립트
        • |: 앞 결과를 뒤로 넘기기
        • sh : 스크립트 실행
    • tailscale up 명령어
      • tailscale 실행 후 계정에 기기 등록 → 로그인 링크 나옴
    • tailscale ip -4
      • tailscale 주소 받기

C. Ubuntu Server VM 생성

  • ISO 업로드
    • local(pve) → ISO Images
      • local(pve) : proxmox 안의 파일 보관 창고
      • ISO Images : 창고 안의 설치 파일 칸
  • VM 생성
    • VM ID : 100 (VM ID, proxmox는 100부터 시작)
    • NAME : portfolio
    • Qemu Agent : proxmox랑 VM이 대화하는 통로
    • Disk : VM에게 줄 저장공간
    • local-lvm : proxmox 안의 VM 디스크 전용 창고
    • SCSI : 디스크 연결 방식
    • Type : host —> 미니 피씨 CPU 기능을 그대로 VM에게 넘김
    • vmbr0 : proxmox 가상 스위치
    • virtIO : VM 전용 랜카드 방식
  • Ubuntu 설치
    • Type of install : Ubuntu server : 기본판
    • Network : DHCP → <내부 IP>
      • DHCP : 공유기가 자동으로 주소 할당
      • proxmox(pve) : 설치 때 주소 직접 고정, VM : 자동으로 받음
    • OpenSSH server : 다른 컴퓨터에서 ssh로 원격 접속할 수 있게 받아주는 프로그램
  • 로그인 확인
    • 콘솔에서 로그인 확인
    • Ubuntu 26.04.1 LTS
      • LTS : Long Term Support : 5년 이상 보안 업데이트 해주는 안정판

트러블슈팅

  • USB로 부팅해도 윈도우로 넘어감
    • 원인 : ISO를 USB에 복사만 하고 안 구움
    • 해결 : balenaEtcher로 구움
  • 설치 직후 apt-get update 실패
    • 원인 : 기본 저장소가 유료(enterprise)
    • 해결 : enterprise 끄고 No-Subscription 켬
  • No-Subscription 저장소 연결 실패
    • 원인 : 집 네트워크에 IPv6 경로가 없는데 IPv6로 접속 시도
    • 해결 : apt를 IPv4로 고정 (99force-ipv4)
  • Tailscale 인증 링크 404
    • 원인 : 링크 복사하려고 Ctrl+C → 터미널에선 프로그램 중단이라 링크 만료
    • 해결 : tailscale up 다시 실행 후 건드리지 않고 바로 인증

이 단계의 진행 과정 보기 →

홈랩 2단계 · VM 원격 접속과 Docker 환경 구성

A. VM 원격 접속 (Tailscale·SSH)

  • Tailscale은 호스트 서버인 pve 서버에만 설치

    • host : VM을 품고 있는 본체 (pve)
    • BUT 각각의 VM에서도 Tailscale 필요
    • proxmox 콘솔 (noVNC) 은 복붙이 안 됨
      • proxmox가 브라우저로 보여주는 VM화면이라 모니터 꽂은 거랑 같아서 글자를 화면으로 보내는 거라 복붙이 X
  • VM 업데이트

    sudo apt update && sudo apt upgrade -y
    
    • apt update : 창고에 새 버전 목록만 가져오기
    • && : 앞에 거 성공하면 뒤에 거 실행
    • apt upgrade : 목록보고 실제로 설치
    • -y : 설치하는 질문에 미리 yes
  • VM에 Tailscale 설치 (1장이랑 동일)

    curl -fsSL https://tailscale.com/install.sh | sh
    sudo tailscale up
    tailscale ip -4 
    
  • 노트북에서 SSH 접속

    ssh <사용자>@<Tailscale IP>
    
    • <사용자> : 계정
    • <Tailscale IP> : 주소
  • 주소 정리

    1. pve : <Tailscale IP>
    2. portfolio : <Tailscale IP>
    3. ecg : <Tailscale IP>

B. Docker 설치

물리 서버 → 호스트 → VM → Docker 엔진 → 컨테이너 구조도

  • 기초 개념 (큰 거에서 작은 순으로)

    • 물리 서버 → 호스트 (pve) → VM → 게스트 OS → docker 엔진 → 컨테이너 → 애플리케이션
      • 하이퍼바이저 : 물리서버의 CPU, memory, disk를 나눠서 여러 VM에 주는 프로그램 —> proxmox VE
      • 호스트 : VM들을 품고있는 본체
      • VM : 가상머신 , 하이퍼바이저가 만든 가짜 컴퓨터. CPU, memory, disk를 할당받고 자기 OS를 따로 가짐
      • 게스트 OS : VM에 깔린 OS (ubuntu 26.04)
      • 커널 : 하드웨어랑 프로그램 사이의 통역사.
      • 컨테이너 엔진 : 이미지로 컨테이너를 만들고 실행, 관리하는 프로그램 (docker Engine) —> 컨테이너를 만들고 키고 끄고 지우는 관리자
      • 이미지 : 프로그램 + 실행에 필요한 파일을 묶은 설계도 (nginx 이미지)
      • 컨테이너 : 이미지를 실제로 실행한 것 —> 실행중인 상자
      • 애플리케이션 : 컨테이너 안에서 실제로 일하는 프로그램
  • docker 설치

    curl -fsSL https://get.docker.com | sudo sh
    
  • sudo 없이 쓰도록 권한 부여

    sudo usermod -aG docker $USER
    exit
    
    • docker 그룹에 현재 사용자 추가
      • usermod : 사용자 설정 바꿈
      • -aG : 그룹에 추가 (append, Group)
      • docker : 넣을 그룹 이름
      • $USER : 지금 로그인 한 나 —> <사용자>로 자동바뀜
    • exit : 그룹 권한은 로그인할 때 읽으므로 재접속
  • 동작 확인

    groups
    docker run hello-world
    
    • groups : 내가 가진 회원 목록 보기 (docker 있어야함)
    • docker run hello-world
      • hello-world 이미지 확인 —> 없으면 docker hub에서 받아옴 —> 그거로 컨테이너 만들어서 실행 —> Hello from docker! 출력하고 끝
        • docker 가 받고 만들고 실행 전부 되는지 테스트

C. 산학용 VM 추가와 디스크 확장

  • 산학용 VM : 앞 VM 설정과 같이 설정

    • memory만 6144MiB으로 설정 (부하테스트 실행 이유)
      • 부하테스트 : 일부로 일을 많이 줘서 얼마나 버티는지 재는 것
    • set up this disk as an LVM group 체크 해제 —> 루트에 39GB 전부할당
  • portfolio 디스크 확장

    • 단어 정리
      • 디스크 : 물리 저장 공간
      • LVM : 땅을 나중에 늘리거나 나눌 수 있게 관리하는 방식
      • 논리 볼륨 : LVM으로 울타리 친 구역
      • 파일 시스템 : ext4 : 파일 정리하는 규칙
      • 루트 : 리눅스의 최상위 폴더
    sudo lvextend -l +100%FREE -r /dev/ubuntu-vg/ubuntu-lv
    df -h /
    
    • lvextend : 논리 볼륨 늘리기
    • +100%FREE : 남은 공간 전부를 논리 볼륨에 붙임
    • -r : 파일 시스템 크기도 같이 맞춤
    • /dev/ubuntu-vg/ubuntu-lv : 늘릴 논리 볼륨 이름
    • df -h / : 루트(/)가 지금 크기가 몇 인지 확인 (h는 사람이 읽기 쉬운 단위인 GB로 표현)

D. 보안·운영 설정

  • SSH 키 인증과 비밀번호 로그인 차단
    • 비밀번호는 누구나 알면 들어올 수 있음.
    • 해결방법 : 키 방식
      • 공개키 : 서버에 걸어두는 쪽.
      • 개인키 : 내 컴퓨터에만 있는 쪽.
      • 키 인증 : 개인키로 공개키를 여는 접속 방식
    • ssh-keygen : 공개키와 개인키 한 쌍 만들기
    • -t ed25519 : 만드는 방식
      • 결과 : id_ed25519.pub (공개키, pub → public : 공개) , id_ed25519 (개인키)
    • ssh-copy-id <사용자>@<Tailscale IP>
      • 공개키를 자동으로 VM에 걸어주는 명령어
    • ~/.ssh/authorized_keys
      • 내 SSH 설정 폴더에 허락된 공개키 목록 파일
ssh-keygen -t ed25519
ssh-copy-id <사용자>@<Tailscale IP>
  • 비밀번호 로그인 차단 (기존 접속 창은 닫지 않고 새로운 창 띄워서 확인)

    echo -e "PasswordAuthentication no\nKbdInteractiveAuthentication no" | sudo tee /etc/ssh/sshd_config.d/00-disable-password.conf
    
    sudo sshd -t && sudo systemctl restart ssh
    
    • echo : 글자 출력
    • -e , \n : \n을 줄바꿈으로 인식
    • passwordAuthentication no : 비밀번호 로그인 끄기
    • KbdInteractiveAuthentication no : 비밀번호 다른 입력 방식 끄기
    • | sudo tee SSH서버 설정 폴더 : 글자를 관리자 권한으로 파일에 저장
      • 파일 이름 00으로 시작한 이유 : 00으로 시작하면 제일 먼저 읽혀서 다른 파일에 비밀번호가 허용되는 게 있어도 먼저 이게 읽힘
    • sshd : SSH 접속을 받아주는 서버 프로그램
    • -t : 문법 검사
  • 확인

    ssh -o PubkeyAuthentication=no <사용자>@<Tailscale IP>
    
    • 키를 쓰지 않고 접속 시도하면 permission denied (publickey)로 거부됨
      • 열쇠 없으면 안 된다는 줄 —> 차단 성공
      • pubkeyAuthentication=no : 열쇠없이 접속 시도
      • -o : 옵션 지정
  • 스냅샷 저장

    • 스냅샷 : VM의 지금 상태를 저장한 세이브 포인트
      • include RAM 해제 (디스크 상태만 저장)
      • LVM-thin : 스냅샷을 바뀐 부분만 저장
      • NOW : 지금 현재 상태 위치
  • QEMU Guest Agent

    • agent configured but not running? → 통로는 있는데 받는 사람이 없을 때 뜸
    • QEMU : proxmox가 VM을 돌릴 때 쓰는 엔진 이름
    • Guest Agent : VM 안에 있는 도우미 프로그램. proxmox 말을 VM에게 전달
    sudo apt install -y qemu-guest-agent
    
    • 스냅샷 때 깨끗하게 저장. proxmox에 VM IP 표시.

트러블슈팅

  • Proxmox 콘솔에 키보드 입력 안 됨
    • 원인 : 콘솔을 두 개 열어둬서 다른 쪽으로 입력됨
    • 해결 : 콘솔 하나만 열고 클릭 후 입력. 입력기는 영문으로
  • docker run 시 permission denied
    • 원인 : docker 그룹 추가가 적용 안 됨
    • 해결 : usermod 다시 실행 → exit 후 재접속 → groups로 확인
  • 디스크 40GiB 줬는데 18.5GB만 잡힘
    • 원인 : Ubuntu 설치 때 LVM이 일부만 루트에 할당
    • 해결 : lvextend로 남은 공간 붙임 (ecg는 설치 때 LVM 끔)
  • tailscale: command not found
    • 원인 : 설치 전에 명령어부터 실행
    • 해결 : 설치 후 다시 실행

이 단계의 진행 과정 보기 →

홈랩 3단계 · 포트폴리오 사이트 컨테이너 배포

A. Nginx 컨테이너로 테스트 페이지 띄우기

  • 실제 사이트 만들기 전 웹 서버 컨테이너가 동작하는 지 확인

    • 웹 서버 : 브라우저가 페이지 요청하면 파일 보내주는 프로그램
      • Nginx : 제일 많이 쓰는 웹 서버, 이미지 이름
        • VM에 없으면 Docker Hub에서 자동으로 받아옴
          • 2단계 hello-world랑 같은 원리
          • vm에는 따로 설치 안 함. 컨테이너 안에만 있음
  • 테스트 페이지 작성

    mkdir -p ~/site && echo '<h1>Hello from homelab</h1>' > ~/site/index.html
    
    • mkdir -p ~/site : home 폴더에 site 폴더 만들기 , -p는 이미 있어도 에러 안 내는 코드
    • > : 출력한 걸 파일에 저장
  • Nginx 컨테이너 실행

    docker run -d --name site -p 80:80 -v ~/site:/usr/share/nginx/html:ro --restart unless-stopped nginx
    
    • -d : 백그라운드 실행
    • --name site : 컨테이너 이름 지정
    • -p 80:80 ⇒ vm 80번 포트를 컨테이너 80 포트에 연결
      • 80번은 웹사이트 기본 창구.
      • VM과 컨테이너 연결시켜주는 선이 -p
    • -v ~~ : VM의 ~~ 폴더를 Nginx 기본 웹 폴더에 연결.
      • -v 왼쪽 : vm 폴더, 오른쪽 : 컨테이너 폴더
      • VM에서 파일만 바꾸면 컨테이너에 바로 반영.
    • ro : 읽기 전용
    • --restart unless-stopped : VM이 재부팅되도 자동으로 Nginx 재실행. 직접 멈춘 경우 제외
  • 접속 확인

    • http://<Tailscale IP>
      • s 없음 : 암호화 안 된 연결 (아직 인증서가 없음)
      • 포트 안 쓴 이유 : 80 기본이라
      • 접근 범위 : 외부 X / Tailscale 등록기기 및 집 WIFI 접속 가능

B. Hugo 사이트 구성과 GitHub 업로드

  • 큰 흐름 : 글 (.md) → Hugo 변환 → public/ → Nginx가 보여줌 → 브라우저

  • Hugo : 마크다운을 디자인, 메뉴가 붙은 HTML 사이트로 만들어주는 프로그램

    • hugo 만 칠 때 돌아감
    • 정적 사이트 생성기 : HTML을 미리 다 만들어둠 (DB, 서버 코드가 필요 없음)
    • 선택 이유 : 노션 글을 마크다운 형식으로 내보내 그대로 옮길 수 있어서 선택
    • AWS 실습과 다른 점
      • EC2에 Nginx 직접 깔아서 같은 역할로 사용
    • Nginx VS Hugo (헷갈렸던 점)
      • Hugo는 만드는 쪽(주방) / Nginx는 보내주는 쪽 (계산대)

    Hugo 사이트 폴더 구조: content/ themes/ hugo.toml, hugo가 만든 결과물 public/

    • public/ : Hugo 가 만든 완성품이 담기는 폴더
      • 그래서 nginx 컨테이너가 ~/portfolio-site/public 을 가리킴
  • Hugo 설치

    sudo snap install hugo
    hugo version
    
    • snap : 우분투 앱 설치 도구
  • 사이트 생성 및 테마

    hugo new site ~/portfolio-site && cd ~/portfolio-site
    git init
    git submodule add --depth=1 https://github.com/adityatelange/hugo-PaperMod.git themes/PaperMod
    
    • hugo new site : 사이트 뼈대 폴더 생성

      • 글은 content/, 설정은 hugo.toml
    • hugo.toml : Hugo 사이트 설정 파일 (핵심 줄)

      baseURL = "https://ihaewon.com/"
      title = "이해원 | Cloud Infra Portfolio"
      theme = "PaperMod"
      
      • baseURL : 사이트 주소. 링크를 만들 때 기준이 됨 (4단계에서 도메인 사고 바꿈)
      • title : 브라우저 탭 제목
      • theme : 쓸 테마. themes/ 폴더 안 이름이랑 같아야 함
    • git init

      • 이 폴더를 git이 관리하게 시작
    • 테마 (paperMod) : 디자인 묶음

      • css 직접 안 짜고 디자인 입힘
    • git submodule : 테마 코드를 복사하지 않고 원본 저장소 주소만 걸어둠

  • 빌드 및 반영

    hugo
    docker rm -f site
    docker run -d --name site -p 80:80 -v ~/portfolio-site/public:/usr/share/nginx/html:ro --restart unless-stopped nginx
    
    • hugo : public/ 에 완성된 사이트 만들기 —> 빌드
    • docker rm -f : A의 테스트 컨테이너 강제 삭제
    • A랑 같은 명령에 연결 폴더만 변경 (~/site —> ~/portfolio-site/public)
    • 이후에 글 고치고 hugo 만 치면 끝
      • nginx가 public/ 을 계속 보고 있어서 컨테이너 재시작 필요 X
  • GitHub 업로드

    • 목적 : 원본을 VM 밖에 보관. VM이 망가져도 추후 복구 가능.

    • .gitignore : 안 올릴 파일 목록

      public/
      resources/_gen/
      .hugo_build.lock
      infra/monitoring/prometheus/targets.local.yml
      
      • public/ 은 hugo로 언제든 다시 만들 수 있어서 안 올렸다
      • resources/_gen/, .hugo_build.lock : hugo가 빌드할 때 만드는 임시 파일
      • targets.local.yml : 실제 IP 파일. 7단계에서 추가함
  • Deploy Key ( VM 키 발급)

    ssh-keygen -t ed25519 -C "portfolio-vm" -f ~/.ssh/id_ed25519 -N ""
    cat ~/.ssh/id_ed25519.pub
    
    • Deploy Key : VM에서 GitHub로 나가는 열쇠

      • 열쇠 : 개인키가 있는 쪽 → 공개키를 걸어둔 쪽 으로만 열림.
      • 사용 이유 : 이 저장소 하나에만 열림. VM이 뚫려도 다른 저장소는 안전함
    • 코드

      • -C : 열쇠 이름표
      • -f : 저장위치 지정
      • -N “” : 비밀번호 없이 → 질문 안 나오게 미리 답함
    • 공개키를 github 저장소 setting에 deploy key 등록.

      • 쓰기 권한 허용
    • cat … pub : 공개키 내용을 화면에 출력 → 이걸 복사해서 GitHub에 등록

    • 확인

      • successfully authenticated 뜨면 성공
  • 업로드

    git add .
    git commit -m "Hugo 포트폴리오 사이트 초기 구성"
    git branch -M main
    git remote add origin [email protected]:GODnjs1007/portfolio-site.git
    git push -u origin main
    
    • add : 올릴 파일 담기
    • commit : 메모와 함께 저장
    • git branch -M main : 기본 브랜치 이름 main으로 설정
    • remote add origin : 올릴 주소 등록
    • push -u : 올리기 , 다음 부터는 git push 만

트러블슈팅

  • hugo --minify 빌드 실패
    • 원인 : Hugo 최신 버전과 테마 호환 문제
    • 해결 : --minify 빼고 hugo로 빌드
  • ssh-keygen 키 저장 실패
    • 원인 : 질문 칸에 다음 명령어까지 같이 붙여넣어짐
    • 해결 : -f, -N 옵션으로 질문 안 나오게 미리 답함
  • VM에서 hugo 빌드 실패
    • 원인 : 작업 환경(0.167)과 VM(0.165) Hugo 버전 차이
    • 해결 : 0.165에서 되는 문법으로 수정. 배포 전 hugo version 확인
  • 명령어를 엉뚱한 터미널에서 실행
    • 원인 : 윈도우 명령을 VM에, VM 명령을 윈도우에 입력
    • 해결 : 치기 전에 프롬프트(PS C: / <사용자>@portfolio) 먼저 보기
  • 데스크톱에서 VM SSH 접속 timeout
    • 원인 : 데스크톱에 Tailscale이 없음
    • 해결 : Tailscale 설치 → 데스크톱 키 만들고 맥북으로 접속해서 authorized_keys에 추가

이 단계의 진행 과정 보기 →

홈랩 4단계 · 도메인·HTTPS로 외부 공개

A. 도메인 구매

  • 도메인 : 숫자 IP 대신 쓰는 사람이 읽는 주소 (ihaewon.com)
    • 필요한 이유 : Tailscale 주소 (100.x.x.x) 는 내 기기에서만 열리기 때문에 외부용 주소가 필요
  • Cloudflare 에서 구매
    • 원가 판매 (년에 10달러) + DNS가 자동으로 Cloudflare에 연결
  • 자동갱신 ON
    • 갱신 놓치면 ihaewon.com 도메인을 다른 사람이 가져감
  • WHOIS : 도메인 주인 정보를 누구나 조회하는 명부
    • Cloudflare가 기본적으로 가려줌
      • 가려주지 않으면 사이트를 본 아무나 우리 집 주소 및 전화번호를 알 수 있음
      • whois ihaewon.com 쳐보면 다 나오는 걸 막아

B. cloudflared 설치

sudo mkdir -p --mode=0755 /usr/share/keyrings
curl -fsSL https://pkg.cloudflare.com/cloudflare-main.gpg | sudo tee /usr/share/keyrings/cloudflare-main.gpg >/dev/null
echo 'deb [signed-by=/usr/share/keyrings/cloudflare-main.gpg] https://pkg.cloudflare.com/cloudflared any main' | sudo tee /etc/apt/sources.list.d/cloudflared.list
sudo apt-get update && sudo apt-get install -y cloudflared
  • cloudflared : VM에 까는 터널 프로그램
  • 순서 : 서명키 받기 → 저장소 추가 → 설치
    • 서명키 : 진짜 cloudflare 패키지인지 확인하는 키
    • 1단계 proxmox 저장소 설정이랑 같은 원리
  • docker ps 로 사이트 컨테이너가 80번에 떠있는지 확인 → 터널이 연결할 대상

C. 터널 생성과 도메인 연결

  • VM이 먼저 cloudflare로 연결을 열어둠 (아웃바운드) → 방문자 요청이 그 연결을 타고 들어옴 → 공유기 포트 안 열어도 됨

    • 포트포워딩 : 공유기 문을 열어 밖에서 들어오게 하는 것 (집 IP 노출 및 공격위험성 농후)
  • 흐름 : 방문자 → Cloudflare(HTTPS) → 터널 → cloudflared → localhost:80 → nginx 컨테이너

    • VM의 cloudflared가 먼저 Cloudflare 서버로 연결을 걺.
      • 안에서 밖으로 나가는 거라 공유기가 막지 않음.
      • 이 연결을 끊지 않고 열어두는 것이 터널
        • 터널 : 안에서 밖으로 전화를 걸어두고 그 통화로 대화
    • 방문자가 ihaewon.com 에 접속하면 DNS가 Cloudflare로 가라고 안내.
      • 방문자는 집이 아니라 Cloudflare에 도착함
      • Cloudflare 은 열려있는 터널로 보냄.
      • DNS : 인터넷 전화번호부
        • 사람들은 도메인을 기억, 컴퓨터는 104.21.x.x 같은 숫자 (IP) 로만 찾아가고 그것을 연결시켜줌
    • cloudflared가 받아서 같은 VM안에 있는 Nginx(localhost:80)으로 넘기고 응답은 터널 타고 다시 돌아감
    cloudflared tunnel login
    cloudflared tunnel create portfolio
    
    • login : 브라우저에서 ihaewon.com 승인 → VM에 인증서 (cert.pem) 저장
    • create: 터널 생성 + 인증파일 (.json) 생성. .json 은 터널 비밀번호라 공유 금지
  • 설정파일 (/etc/cloudflared/config.yml)

    tunnel: <터널 ID>
    credentials-file: /etc/cloudflared/<터널 ID>.json
    ingress:
      - hostname: ihaewon.com
        service: http://localhost:80
      - hostname: www.ihaewon.com
        service: http://localhost:80
      - service: http_status:404
    
    • 터널 ID는 create 할 때 나온 긴 문자열 (보안상 적지 않음)
    • ingress : 들어온 주소별로 어떻게 보낼지 정하는 규칙
    • service : http_status : 404 —> 위에 없는 주소는 404로 거절
    cloudflared tunnel route dns portfolio ihaewon.com
    cloudflared tunnel route dns portfolio www.ihaewon.com
    sudo cloudflared --config /etc/cloudflared/config.yml tunnel ingress validate
    sudo cloudflared service install
    systemctl status cloudflared --no-pager
    
    • route dns : 도메인 → 터널로 가는 CNAME 기록을 자동으로 만듦 (터널을 가리키는 CNAME 기록을 두 개 만듦.)
      • DNS 기록 종류
        • A : 이름 → IP (ihaewon.com —> 104.21.x.x)
        • CNAME : 이름 → 다른 이름 (별명) (ihaewon.com → 터널ID.cfargotunnel.com)
          • 나 대신 저 이름에게 물어보라는 뜻
    • ingress validate : 설정 파일 문법 검사
    • service install : systemd에 등록 —> VM 재부팅해도 터널 자동 실행 (3단계 --restart랑 같은 목적)
    • HTTPS 인증서는 Cloudflare가 자동 관리 —> 1단계 proxmox 웹 UI 처럼 경고가 뜨지 않음
      • 외부에 열린 건 사이트 하나 뿐. SSH, proxmox 는 여전히 Tailscale로
    • systemctl status cloudflared --no-pager : cloudflared 서비스가 잘 돌아가고 있는지 확인
      • --no-pager : 결과를 한 번에 쭉 출력하고 끝내기
        • 이걸 붙이지 않으면 결과가 길 때 페이지 넘기는 화면이 떠서 q를 눌러야 빠져나옴
      • status : 상태 보기
      • systemctl : systemd (리눅스 프로그램 관리자) 한테 명령하는 도구
  • Cloudflare 단점

    • Cloudflare가 멈추면 사이트도 같이 멈춤 (의존성)

이 단계의 진행 과정 보기 →

홈랩 7단계 · 홈랩 통합 모니터링

A. 구성 설계

  • 모니터링 : 서버 상태를(CPU, memory, disk, network 사용) 계속 재서 문제를 사람보다 먼저 알아채는 것
  • 구조 : 중앙 (prometheus + Grafana) 한 곳 + 각 서버엔 node-exporter 만
    • node-exporter : 서버 상태를 숫자목록으로 내보내는 프로그램
      • 기본 포트 9100
    • Prometheus : 15초마다 node-exporter을 읽고(수집) , 시간순으로 저장하는 프로그램
      • 기본 포트 : 9090
    • Grafana : 기록을(prometheus에 저장된 숫자) 그래프,대시보드로 보여주는 프로그램
      • 기본 포트 : 3000
    • cAdvisor : 컨테이너별 서버 상태 사용량을 내보내는 프로그램
  • 흐름 : node-exporter/cAdvisor이 숫자 줌 → Prometheus가 15초마다 가져가 저장 → Grafana가 그래프로 표시
  • 중앙을 portfolio로 설정
    • 항상 켜져있는 서버
    • ecg VM은 추후 끌 예정
    • paper VM은 node-exporter만 설정
      • 메모리 1GB라 가벼운 것만
  • 설정 파일은 infra/monitoring/ 에 둠
    • VM에서 git pull 로 한 번에 배포
      • 3단계 사이트 배포랑 같은 흐름

B. 각 서버에 node-exporter 설치

  • 공통 명령어 (apt로 설치하는 서버)

    sudo apt update && sudo apt install -y prometheus-node-exporter
    curl -s localhost:9100/metrics | head -3
    
    • 9100 : node-exporter 기본 포트
    • curl … | head -3 : 숫자 목록 앞 3줄만 보기 —> #HELP … 나오면 성공
  • portfolio VM —> Prometheus 가 있는 서버

    • 별도 설치 X
      • node-exporter도 컨테이너로 Prometheus 옆에 같이 띄움
  • pve (Proxmox 호스트, VM 아님)

    • root 라 sudo 없이 설치 (proxmox 웹 → pve → shell)
  • paper VM (SSH 접속 후)

    • 위 명령어로 node-exporter 설치
  • ecg VM (SSH 접속 후 )

    • 산학용 node-exporter가 도커 안에만 있어서, 홈랩용 서버에 따로 설치
      • node-exporter : 컨테이너 —> 산학 도커 네트워크 안에서만 보임
      • VM에 apt로 설치해서 9100 열어줌

C. portfolio에 중앙 띄우기

cd ~/portfolio-site && git pull
cd infra/monitoring
GRAFANA_PASSWORD=새비번 docker compose up -d

infra/monitoring 폴더 구조

  • 파일 연결 흐름 (이 순서대로 아래에 설명)

    1. docker-compose.yml : 컨테이너 4개 띄움
    ├─ prometheus 컨테이너  ← prometheus/ 폴더 연결
    │    └─ 2. prometheus.yml : 얼마마다, 어디서 수집할지
    │         ├─ 3. targets.local.yml : 수집할 서버 4대 주소
    │         └─ alerts.yml : 알림 규칙 (→ D)
    ├─ grafana 컨테이너  ← grafana/ 폴더 연결
    │    └─ 4. provisioning/ 파일 2개 : 켜질 때 Prometheus 연결 + 대시보드 불러오기
    ├─ node-exporter : portfolio VM 상태
    └─ cadvisor : portfolio 컨테이너별 상태
    
  • 코드 이해

    • docker compose : 여러 컨테이너를 설정 파일 하나로 한 번에 띄우고 관리하는 도구 (설정파일 이름 : docker-compose.yml)
      • 3단계는 docker run으로 하나씩 띄움
        • 이번엔 4개 (prometheus, Grafana, node-exporter, cAdvisor)라 compose 사용
      • up -d : 파일에 적힌 컨테이너 전부 켜기 + 백그라운드
    • GRAFANA_PASSWORD : Grafana 관리자 비밀번호, 처음 켤 때만 씀
  1. docker-compose.yml : docker run 옵션을 파일에 칸별로 옮겨 적은 것

    docker run 옵션과 docker-compose.yml 항목 대응표

    • 위치 : portfolio-site/infra/monitoring/docker-compose.yml
    • 구성 : services 밑에 컨테이너 4개 : prometheus, grafana, node-exporter, cAdvisor , 맨 아래 volumes
      • prometheus

        services:
          prometheus:
            image: prom/prometheus:v2.54.1
            container_name: hl-prometheus
            command:
              - --config.file=/etc/prometheus/prometheus.yml
              - --storage.tsdb.retention.time=30d
            volumes:
              - ./prometheus:/etc/prometheus:ro
              - prometheus-data:/prometheus
            ports:
              - "9090:9090"
            restart: unless-stopped
        
        • services : 이 아래 띄울 컨테이너 적는 시작 표시
        • command : 컨테이너 켤 때 프로그램에 넘기는 옵션
          • --config.file : 설정 파일 위치
          • --storage.tsdb.retention.time=30d : 수집한 숫자 30일만 보관
        • ./prometheus:/etc/prometheus:ro : VM의 설정 폴더를 읽기 전용으로 연결
        • prometheus-data:/prometheus : 수집한 데이터 도커 볼륨에 저장
        • port 9090:9090 : VM 9090 → 컨테이너 9090에 연결
      • Grafana

        grafana:
          image: grafana/grafana:11.2.0
          container_name: hl-grafana
          environment:
            GF_SECURITY_ADMIN_PASSWORD: "${GRAFANA_PASSWORD:?GRAFANA_PASSWORD를 지정하세요}"
            GF_USERS_ALLOW_SIGN_UP: "false"
          volumes:
            - ./grafana/provisioning:/etc/grafana/provisioning:ro
            - ./grafana/dashboards:/var/lib/grafana/dashboards:ro
            - grafana-data:/var/lib/grafana
          ports:
            - "3000:3000"
          depends_on: [prometheus]
          restart: unless-stopped
        
        • environment : 컨테이너 안에 넣는 설정 값 (환경변수)
          • GF_SECURITY_ADMIN_PASSWORD : 관리자 비번
          • ${GRAFANA_PASSWORD:?} : 실행할 때 넘긴 값을 넣음. 값이 없으면 에러 내고 멈춤
          • GF_USERS_ALLOW_SIGN_UP: "false" : 다른 사람 회원가입 막기
        • provisioning : 켜질 때 데이터 출처(prometheus)를 자동 설정 (아래 4번)
        • dashboards : 미리 만든 홈랩 서버 전체 대시보드를 자동으로 불러옴
        • grafana-data : Grafana 설정 , 비번을 볼륨에 저장
        • depends_on : prometheus가 먼저 켜진 후 Grafana 켬
      • node-exporter

        node-exporter:
          image: prom/node-exporter:v1.8.2
          container_name: hl-node-exporter
          command: ["--path.rootfs=/host"]
          pid: host
          volumes:
            - /:/host:ro,rslave
          restart: unless-stopped
        
        • 컨테이너는 원래 VM 정보를 보지 못 함 → 마지막 세 줄로 볼 수 있게 함
          • volumes /:/host:ro : VM 전체 (/)를 컨테이너 안 /host에 읽기 전용으로 연결 (3단계 -v랑 같은 원리)
          • --path.rootfs=/host : VM 정보는 /host에서 읽으라고 지정
          • pid: host : VM의 프로세스 목록을 볼 수 있게 함
          • rslave : VM에 나중에 붙는 디스크도 따라 보이게 하는 옵션
          • ports 없음 : 밖으로 안 열어도 됨.
            • Prometheus가 같은 네트워크 안에서 node-exporter:9100으로 찾아옴
        • portfolio VM에는 별도 설치 X
          • docker-compose.yml에 컨테이너로 들어 있기 때문
          • ecg, paper, pve 만 별도 설치
      • cAdvisor

        cadvisor:
          image: gcr.io/cadvisor/cadvisor:v0.49.1
          container_name: hl-cadvisor
          privileged: true
          devices: ["/dev/kmsg"]
          volumes:
            - /sys:/sys:ro
            - /dev/disk/:/dev/disk:ro
          restart: unless-stopped
        
        • privileged true : 컨테이너 관리자급 권한.
          • 다른 컨테이너 사용량을 읽으려면 필요
        • device : [”/dev/kmsg”] : 커널 메세지 읽기
        • /sys:/sys:ro : 리눅스가 컨테이너별 CPU, 메모리, 사용량을 기록하는 곳
        • /dev/disk : 디스크 연결 정보
      • volumes

        volumes:
          prometheus-data:
          grafana-data:
        
        • 도커 볼륨 : 도커가 관리하는 저장 공간, 컨테이너를 지워도 데이터 유지
          • bind mount (-v폴더: 폴더) : 내 VM 폴더 연결
          • 볼륨 : 도커가 만든 공간
          • Grafana 비번이 처음 한 번 적용되는 이유 : 첫 비번이 grafana-data에 저장
        • 같은 compose로 띄운 컨테이너는 같은 도커 네트워크 —> 서비스 이름으로 서로 찾음
  2. prometheus/prometheus.yml

    • 얼마마다, 어디서 수집하고 어떤 알림 규칙 적용할지 정하는 파일
    global:
      scrape_interval: 15s
      evaluation_interval: 15s
    rule_files:
      - /etc/prometheus/alerts.yml
    scrape_configs:
      - job_name: prometheus
        static_configs: [{ targets: ["localhost:9090"], labels: { name: monitoring } }]
      - job_name: node
        file_sd_configs:
          - files: [/etc/prometheus/targets.local.yml]
            refresh_interval: 30s
      - job_name: cadvisor
        static_configs: [{ targets: ["cadvisor:8080"], labels: { name: portfolio } }]
    
    • scrape_interval 15s : 15초마다 수집
    • evaluation_interval 15s : 15초마다 알림 규칙 검사
    • rule_files : 알림 규칙 파일 위치 (alerts.yml → D)
    • scrape_configs : 수집 대상 묶음(job) 3개
      • prometheus : 자기 자신 상태
      • node : 서버 4대. 대상은 targets.local.yml에서 읽음 (file_sd_configs)
      • cadvisor : portfolio 컨테이너들
    • refresh_interval 30s : targets.local.yml을 30초마다 다시 읽음 → 서버 추가해도 재시작 없이 반영
    • 연결 정리 : docker-compose.yml이 prometheus/ 폴더 연결 → prometheus.yml이 targets.local.yml과 alerts.yml을 가리킴
  3. prometheus/targets.local.yml : 수집 대상 목록

    - targets: ["node-exporter:9100"]
      labels: { name: portfolio }
    - targets: ["<paper Tailscale IP>:9100"]
      labels: { name: paper }
    - targets: ["<ecg Tailscale IP>:9100"]
      labels: { name: ecg }
    - targets: ["<proxmox Tailscale IP>:9100"]
      labels: { name: proxmox }
    
    • target : prometheus 가 15초마다 찾아갈 주소
    • labels name : 대시보드에 표시될 이름
    • portfolio만 컨테이너 이름으로 적음
      • 같은 compose 안이라 도커 네트워크 → 이름으로 찾아감
      • 나머지는 다른 서버라 Tailscale IP 로 찾아감
    • 서버 추가 , 삭제 : 이 파일만 고치면 30초 안에 반영
    • IP 분리
      • 저장소가 공개라 실제 IP가 든 파일은 깃에서 제외
        • targets.example.yml : 깃에 올라감. IP자리에 <paper Tailscale IP> 같은 자리 표시
        • targets.local.yml : VM에만 있음 (.gitignore). 실제 IP
    • Prometheus 만 재시작 : docker restart hl-prometheus
      • docker compose restart 는 GRAFANA_PASSWORD 없으면 에러
  4. grafana/provisioning/ (파일 2개)

    • provisioning : Grafana가 켜질 때 자동으로 적용되는 설정. 화면에서 손으로 할 일을 파일로 대신함
    • 1의 grafana volumes 첫 줄(./grafana/provisioning:/etc/grafana/provisioning:ro)로 컨테이너에 연결됨
    • datasources/prometheus.yml : 데이터 출처
    apiVersion: 1
    datasources:
      - name: Prometheus
        uid: prom
        type: prometheus
        url: http://prometheus:9090
        isDefault: true
    
    • url : 데이터를 가져올 곳. 같은 compose라 서비스 이름(prometheus)으로 찾음
    • uid : 대시보드 파일이 이 출처를 가리킬 때 쓰는 ID
    • isDefault : 기본 데이터 출처로 지정
    • 이 파일이 없으면 Grafana 화면에서 Connections → Data sources로 들어가 Prometheus 주소를 손으로 입력해야 함
    • dashboards/homelab.yml : 대시보드 불러오기
    apiVersion: 1
    providers:
      - name: homelab
        folder: Homelab
        type: file
        options:
          path: /var/lib/grafana/dashboards
    
    • path : 대시보드 파일(homelab-overview.json)을 읽을 위치
      • 1의 grafana volumes 둘째 줄(./grafana/dashboards:/var/lib/grafana/dashboards:ro)로 연결된 폴더
    • folder : Grafana 안에서 넣을 폴더 이름 → Dashboards → Homelab에 "홈랩 서버 전체"가 보임

D. 확인과 대시보드·알림 규칙

  • 접속 확인

    • Prometheus : portfolio Tailscale IP:9090/targets
      • 수집 대상이 UP (초록색) (수집됨) 인지 DOWN인지 보는 화면
    • Grafana : portfolio Tailscale IP:3000 → Dashboards → Homelab → 홈랩 서버 전체
      • grafana/provisioning/ (C 4번) 코드로 손으로 설정 안 해도 대시보드 바로 볼 수 있음
    • 둘 다 Tailscale로 접속
      • tunnel에 연결하면 서버 상태가 인터넷에 공개
  • 알림 규칙 (prometheus/alerts.yml)

    • 어떤 조건이 얼마나 계속 되면 알림을 켤지 결정하는 파일
    • prometheus.yml 코드 (C 2번) rule_files로 prometheus가 읽음
    groups:
      - name: homelab
        rules:
          - alert: 서버다운
            expr: up{job="node"} == 0
            for: 2m
            labels: { severity: critical }
            annotations:
              summary: "{{ $labels.name }} 응답 없음 2분 이상"
    
          - alert: 디스크부족
            expr: 100 * (1 - node_filesystem_avail_bytes{mountpoint="/",fstype!~"tmpfs|overlay"} / node_filesystem_size_bytes{mountpoint="/",fstype!~"tmpfs|overlay"}) > 85
            for: 5m
            labels: { severity: warning }
            annotations:
              summary: "{{ $labels.name }} 디스크 {{ $value | printf \"%.0f\" }}% 사용"
    
          - alert: 메모리부족
            expr: 100 * (1 - node_memory_MemAvailable_bytes / node_memory_MemTotal_bytes) > 90
            for: 5m
            labels: { severity: warning }
            annotations:
              summary: "{{ $labels.name }} 메모리 {{ $value | printf \"%.0f\" }}% 사용"
    
          - alert: CPU과부하
            expr: 100 * (1 - avg by (name) (rate(node_cpu_seconds_total{mode="idle"}[5m]))) > 90
            for: 10m
            labels: { severity: warning }
            annotations:
              summary: "{{ $labels.name }} CPU {{ $value | printf \"%.0f\" }}% 10분 이상"
    
    • groups / rules : 알림 규칙 묶음. homelab 묶음 안에 규칙 4개
    • alert : 알림 이름
    • expr : 알림이 켜지는 조건 (Prometheus 계산식)
      • 서버다운 : up{job="node"} == 0
        • up : 수집 성공이면 1, 실패면 0
        • job="node" : C 2번에서 정한 서버 4대 묶음만
      • 디스크 : 100 × (1 − 남은 용량 / 전체 용량) = 사용률 % → 85 넘으면
        • 예 : 전체 100GB, 남은 20GB → 100 × (1 − 0.2) = 80%
        • mountpoint="/" : 루트 디스크만 봄
        • fstype!~"tmpfs|overlay" : 메모리용·도커용 가짜 디스크 제외
      • 메모리 : 100 × (1 − 쓸 수 있는 메모리 / 전체) = 사용률 % → 90 넘으면
      • CPU : 100 × (1 − 쉬는 시간 비율) = 사용률 % → 90 넘으면
        • idle : CPU가 쉬는 시간
        • rate(…[5m]) : 최근 5분 평균
        • avg by (name) : 코어 여러 개를 서버 하나로 평균
    • for : 조건이 이 시간 동안 계속돼야 알림 켜짐
      • 잠깐 튀는 건 무시 (예 : apt 업데이트할 때 CPU 순간 100%)
      • CPU만 10분 : 디스크·메모리보다 잠깐씩 튀는 일이 잦아서
    • labels severity : 심각도
      • critical : 서버다운
      • warning : 나머지
    • annotations summary : 알림 문구
      • {{ $labels.name }} : 서버 이름 (C 3번 targets의 name)
      • {{ $value | printf "%.0f" }} : 숫자를 소수점 없이 표시
  • 알림 확인

    • 지금은 prometheus 화면 :9090/alerts 에서 보기만 함

트러블슈팅

  • paper VM 로그인 실패
    • 원인 : 계정 이름이 다른 VM이랑 달랐음. 이름이 틀려도 "Login incorrect"라 비번 문제로 착각
    • 해결 : GRUB → recovery mode → ls /home으로 계정 확인 → passwd로 비번 재설정
  • 복구 모드에서 Proxmox 재부팅 버튼 안 먹힘
    • 원인 : 버튼은 ACPI(전원 신호)를 보내는데 복구 모드엔 받는 서비스가 없음
    • 해결 : 콘솔에서 sync → reboot
  • Grafana 비번 모름
    • 원인 : GRAFANA_PASSWORD= 뒤 예시 글자를 그대로 복사해서 실행
    • 해결 : docker exec로 grafana cli 비번 강제 재설정

이 단계의 진행 과정 보기 →

산학 실시간 심전도 처리 서버

배운 점 7편 · 기록 7개 중

산학 1단계 · 가상 환자 스트림 생성기

목표

  • 공개 심전도 데이터 (LTSTDB) 로 가상 환자 N명이 실시간으로 보내는 것 처럼 흉내내는 생성기 만듦

    • 생성기 : 데이터를 만들어 내보내는 프로그램 (가짜 환자 역할)
    • 실시간 : 실제 시간 흐름대로 조금씩 보냄
  • 기준

    • 데이터 : LTSTDB (PhysioNet) , 250Hz
      • Hz : 1초에 몇 번 , 250Hz = 1초에 250번 측정
      • LTSTDB : PhysioNet (병원, 연구소가 연구용으로 공개한 의료 데이터 사이트) 안의 심전도 묶음, 사람마다 하루치 기록
      • 4ms : 1 / 250 = 0.004초 (측정과 측정 사이 간격)
      • 샘플 : 측정한 숫자 하나, 그 순간의 전압값
        • 0.1초마다 25샘플씩 보냄 —> 250(1초) x 0.1초 = 25개 (묶으면 1초에 10번이라 훨씬 가벼워짐)
      • 환자마다 시작위치 다르게 세팅
  • 전체 흐름

    1단계 전체 흐름: PhysioNet 파일 → A 열어서 모양 확인 → 생성기 B·C → 3단계 TCP로 보냄 → 서버(4단계 받아서 처리)

  • 기초 용어

    • 샘플 (sample) : 1개 = 1/250초 (0.004초)의 점 하나.
      • 리드 3개면 숫자 3개
    • 청크 (chunk) : 0.1초 분량 25샘플을 잘라낸 덩어리. 코드의 CHUNK = 25
      • 우리는 이걸 상자라고 표현
      • 1개 = 0.1초 = 샘플 25개 [3,25]
    • 메시지 (message) : 청크에 환자 번호·순번·시각을 붙여서 보내는 한 건
      • 상자 보내는 단위
      • 청크 1개를 JSON으로 포장 → 생성기가 0.1초마다 보냄
    • 창 (Window) : 1개 = 5초 = 메시지 50개 = 샘플 1,250개 [3, 1250]
      • AI가 한 번 판정하는 단위. 처음 5초가 모이면 1번, 그 뒤로는 1초씩 밀며 1번씩
    • 메시지 형식 (스키마) : 메시지에 어떤 칸이 들어갈지 정한 양식 (= 송장 양식, 2단계 C)
    • 패킷과 다름 : 패킷은 네트워크가 내부적으로 데이터를 쪼개는 더 낮은 단계의 단위
  • 5초 묶음(창, AI가 한 번에 판정, 청크 50개 → 샘플 1250개) —> 1초 = 청크 10개 —> 청크 1개 = 0.1초 = 샘플 25개 —> 샘플 1개 4ms (0.004초) ——> 1초에 250개 샘플 (25 * 10 = 250)

  • 청크 1개 + 송장 = 메시지 → 0.1초마다 TCP로 보냄

A. 데이터 읽기 (read_sample.py)

  • 목적 : 공개 심전도 파일에서 앞 10초만 읽어오기
    • 몇 Hz인지, 리드가 몇 개인지, 표가 어떻게 생겼는지 알아야 B와 C에 사용한 코드에 잘라 보낼 수 있어서 사용함
  • 설명 : s20011 의 사람을 physioNet의 ltstdb에서 sampto 2500개 (=10초) 까지만 읽어서 데이터 모양 확인
    • 확인하는 것 : 250Hz, 2리드, 표 모양 2500줄 X 2칸 (한 줄 = 한 순간, 한 칸 = 리드 하나)
    • 리드 : 심전도를 재는 방향 (채널), 리드가 많을 수록 심장을 여러 각도에서 확인
    • 2리드 → 3리드 : 기업 기기가 3리드라서 3단계에서 3리드 데이터 변경
    • B,C는 같은 방식으로 1분치 (15,000개)를 읽어서 사용

B. 환자 1명 (stream_one.py)

  • 목적 : 환자 1명이 0.1초마다 심전도 25개씩 보내는 흉내 내기. 여러 명으로 늘리기 전에 1명으로 박자대로 잘 잘리는지 먼저 확인
    • stream : 한꺼번에 보내지 않고 조금씩 계속 흘려보내는 것
    • 1명부터 한 이유 : 여러 명으로 늘리기 전에 박자대로 잘 잘리는지 먼저 확인
  • 설명 : 1분치를 읽어두고 25개씩 잘라서 0.1초 박자로 10초 동안 반복 재생. 아직 보내지는 않음
    • 반복 재생 : 하루치 기록을 다 읽으면 무거워서 1분만 읽고 돌려씀
    • 아직 안 보내는 이유 : 받을 서버가 아직 없음. 보내는 건 3단계(TCP)

C. 환자 N명 (stream_multi.py)

  • 목적 : 환자 N명이 각자 다른 파형을 0.1초 박자에 맞춰 보내게 하기
    • 시작 위치를 다르게 한 이유 : 파일 하나로 여러 명을 흉내 내므로 다 처음부터 틀면 전원이 똑같은 박동을 보내게 됨
  • 설명 : B (1명)를 N명으로 늘리는 버전. 0.1초마다 N명 청크를 차례로 만들고, 끝나면 결과를 출력해서 생성기가 박자를 따라가는지 확인
    • 흐름 : 0.1초마다 환자 0번→1번…→ N-1번 순서로 25개씩 잘라 청크를 만듦. 한 바퀴 다 돌면 다음 박자까지 기다린다.
      • 6명이면 0.1초마다 청크 6개 , 1초에 60개
    • B에서 바뀐 것 : 환자 수를 실행할 때마다 정함, 환자마다 시작 위치를 다르게 한다, 끝나고 결과 출력한다.
      • 시작위치 : 환자 0은 3초부터 1은 17초부터 랜덤으로 뽑음. 25 간격으로 뽑아서 상자 경계에 맞춘다.
      • 실행 인자 : 프로그램 이름 뒤에 붙이는 값, 코드 고치지 않고 20명, 100명으로 바꿔 돌리기 가능
      • 출력 3개 : 목표 개수 (환자 X 250) / 실제 개수 / 최대 지각 → 목표랑 실제가 같고 지각이 작으면 생성기는 OK
        • 250 : 1초 동안 찍는 점의 개수
    • 최대 지각 : 정해둔 시각보다 가장 늦게 보낸 정도. 크면 생성기 자체가 느린 거라 측정을 못 믿음
    • 아직 만들기만 하고 보내진 않음 , 보내는 과정은 3단계에 수행

D. 결과

  • 목적 : 생성기가 환자 100명 분량을 박자 안 놓치고 만들어낼 수 있는지 확인
    • 생성기가 느리면 나중에 서버를 시험할 때 서버가 느린건지 생성기가 느린 건지 구분하기 힘들기 때문에 확인한다.
  • 설명 : C를 환자 100명으로 돌림 → 초당 목표 25,000 개 중 24,999 생성 —> 박자 놓치지 않음
    • python stream_multi.py 100 으로 실행 → 10초 동안 돌림 → 끝나고 출력 3개 (목표 개수, 실제 개수, 최대 지각) 확인
      • 25,000 : 목표 개수. 환자 100명 X 1초에 250개
      • 24,999 : 실제 개수.
      • 한계 : 만들기만 하고 아직 보내진 않음. 그래서 아직 서버가 100명을 버티는지 확인할 수 없음
        • 보내기 (3단계, TCP), 받아서 처리는 4단계

E. 코드 검토 (팀원)

  • 목적 : 팀원에게 생성기 코드를 검토받아서 고칠 점 찾기
  • 설명 : 메시지 형식이 정해져야 고칠 수 있는 건 보류
    • 메시지 형식 : 상자에 붙이는 송장 양식.
      • 누가 몇 번째로 언제 보냈는지 적는 칸.
  • 지적
    1. 생성기랑 서버를 같은 컴퓨터에 돌리면 서버 성능이 낮게 나올 수 있음
      1. 이유 : 둘이 CPU를 나눠 써서 서버가 실제보다 느릴 수 있음
      2. 장점 : 시계가 같아서 지연(받은시간 - 보낸시각) 계산이 정확함
      3. 결정 : 개발할 때는 같이 돌리고 최종 측정에만 컴퓨터를 분리함.
    2. 시작 위치를 랜덤으로 뽑으면 실행할 때마다 달라져 회차끼리 비교가 어려움 —> 고정값으로 바꿀 예정

이 단계의 진행 과정 보기 →

산학 2단계 · 서버 연결 방식 설계

목표

  • 생성기에서 나간 심전도가 서버까지 어떻게 갈지, 어떤 형식으로 보낼지 수신부 담당 팀원과 협의
    • 보내는 쪽과 받는 쪽이 같은 규칙을 써야 연결가능
    • 나 = 보내는 쪽 (생성기) , 팀원 = 받는 쪽 (수신부)
  • 전체 흐름 : 생성기 → TCP 8000 (NDJSON) → 수신부 → Redis Streams → 워커 → R파 검출 → AI판정
    • 수신부 : 상자를 받아서 검사하고 큐에 넣는 접수 창구
    • redis Stream : 상자를 줄 세워두는 큐
      • 상자가 한 번에 몰려와도 서버가 안 죽게 해줌
    • 워커 : 큐에서 상자를 꺼내 분석해줌
    • 채널 : 환자 한 명의 심전도 줄 (=환자)
    • R파 : 심전도에서 가장 높이 뾰족하게 솟은 봉우리.
      • 박동 하나씩 존재

A. 전송 방식 : TCP

  • 목적 : 생성기와 서버를 잇는 통신 방식 결정
    • 심전도는 중간에 빠지거나 순서가 섞이면 판정이 틀어진다.
      • 기업도 틀리는 것보다 놓치는 걸 더 치명적으로 봤다.
  • 설명 : 연결을 한 번 열어두고 그 위로 계속 보냄. 순서가 지켜지고 중간에 빠지면 다시 보냄
    • TCP : 컴퓨터끼리 데이터를 주고받는 규칙.
      • 도착 순서와 빠진 것을 재전송 보장
    • UDP : TCP의 반대
      • 빠르지만 빠져도 모름 —> 이번 프로젝트와는 어울리지 않음
    • 연결을 열어두는 이유 : 0.1초마다 새로 연결하면 연결하는 데 시간이 계속 듦
      • 연결을 열어둔다는 뜻은 TCP는 데이터를 보내기 전에 요청→응답→확인 과정을 거치는데 그 인사를 모든 상자에서 하지 않고 한 번 연결한 뒤 끊지 않고 계속 사용
    • 포트 8000 : 컴퓨터 안의 문 번호. (우리가 정한 번호)
      • 서버가 8000번 문을 열어두고 기다린다.
    • 문제 : 연결 하나로 여러 환자 상자를 이어보내니깐 상자 사이 경계가 필요함

B. NDJSON

  • 목적 : 연결 하나로 이어서 보내는 상자들 사이에 경계를 정함

    • TCP는 데이터를 순서대로 흘려보내기만 하고 상자 하나가 어디까진지 설명해주지 않는다.
      • 문제점 : 받는 쪽이 상자를 나누지 못 한다
  • 설명 : 상자 하나를 한 줄로 적고 줄바꿈을 상자의 끝 표시로 사용한다. 수신부에서도 줄바꿈이 나올 때마다 자르면 됨

    • JSON : {”이름”: 값} 모양으로 데이터를 적는 글자 형식
    • NDJSON : Newline Delimited JSON
      • 줄바꿈으로 구분한 JSON
      • 한 줄 = JSON 하나 = 상자 하나 = 환자 한 명의 0.1초치 (환자 1명 전체가 아님)
        • 환자 한 명이 0.1초마다 상자를 하나씩 계속 보냄 —> 1초에 10개
    • 줄바꿈(\n) : 화면엔 보이지 않지만 글자로는 들어가져 있음
  • EX

      {환자0, 0.0~0.1초}\n
      {환자1, 0.0~0.1초}\n
      {환자0, 0.1~0.2초}\n
      {환자1, 0.1~0.2초}\n
    
    • 받는 쪽은 \n 기준으로 잘라서 상자 4개로 나눔 (환자0 거 2개, 환자1 거 2개)
      • 줄바꿈은 상자를 나누고 , 환자는 channel_id로 나눔 (C단계)

C. 메시지 형식 (임시)

  • 목적 : 상자에 붙일 송장 양식을 수신부 팀원과 결정

    • 줄바꿈으로 상자는 나눌 수 있어도, 누구 건지, 몇 번째인지, 언제 보냈는지 확인 불가능
      • 받는 쪽이 분류하고 검사하고 측정할라면 필요
    • 임시안으로 결정. 문서 오고 추후 추가할거나 제거할 거 결정
  • 설명 : 상자 하나 (한 줄)에 정한 값 —> 스키마

    • channel_id : 환자 번호
      • 누구 건지 확인 가능. 워커가 환자별로 모을 때 사용
    • seq : 환자별 순번 0,1,2 …
      • 중간에 빠진 번호 있으면 유실
    • t_start : 이 상자가 심전도의 몇 초 지점부터인지
      • 순서 확인
    • sent_at : 보낸 시각
      • 받은 시각에서 빼면 지연 (D에서 사용)
    • fs, duration : 250Hz, 0.1초
      • 받는 쪽이 개수가 맞는지 검사
    • leads, samples : 리드 이름과 실제 값
      • R파 검출에 들어가는 값
      • 3리드라서 samples = [[25개], [25개], [25개]] = 리드 3개 X 25개
    • 스키마 : 이렇게 칸을 정한 양식을 부르는 공식 용어
      • 스키마 = 빈 양식 / 메시지 = 양식에 값을 채운 것
      • schema_version : 양식 버전 번호. 칸을 추가·삭제하거나 단위를 바꿀 때만 올림 (메시지마다 올라가는 건 seq)
        • 받는 쪽이 버전을 보고 옛날 양식이랑 새 양식을 구분해서 읽으려고 씀
  • EX

    {"channel_id": 0, "seq": 2, "t_start": 0.2, "sent_at": 1760000000.2, "fs": 250, "duration": 0.1, "leads": ["V4", "V3", "II"], "samples": [[25개], [25개], [25개]]}
    
    • 이 코드는 생성기 코드 (send 함수)가 0.1초마다 환자별로 자동으로 만들어서 보낸다.
      • 터미널에선 python stream_multi.py 100 만 작성
      • send 함수는 3단계에서 채움

D. 측정 방식

  • 목적 : 서버가 잘 버티는지 판단할 기준을 미리 정함
    • 기준 없이 돌리면 잘 되는 것처럼 보임. 기준을 정해야 5단계에서 몇 명까지 버티는지 말할 수 있음
  • 설명 : 지연이랑 유실 두 가지를 측정. 둘 다 sent_at, seq으로 계산
    • 지연 : 워커가 처리를 끝낸 시각 빼기 sent_at (보낸 시각) = 보내고 나서 처리까지 걸린 시간
      • ex. 10.00초에 보내고 10.35초에 끝나면 지연 0.35초
    • 유실 : 환자별로 seq가 빈 개수
      • ex. 환자 3번 상자가 0,1,2,4로 도착 —> 3빠졌으므로 유실 1
    • p95 : 100개를 빠른 순서로 줄 세웠을 때 95번째 값.
      • 느린 쪽 5%를 빼고 가장 느린 값
      • 평균을 사용하면 아주 느린 몇 개로 가려버려서 환자 몇 명이 늦게 받아도 평균은 괜찮아 보임
      • 기업도 부하테스트는 p95을 보라고 함
    • p50 : 중간값.
      • 절반은 이것보다 빠름
    • 같은 컴퓨터로 실행
      • 지연은 두 시각을 빼는 거므로 시계가 같아야 정확

이 단계의 진행 과정 보기 →

산학 3단계 · TCP 전송과 3리드 데이터

목표

  • 목적 : 1단계 생성기에 TCP 전송을 붙이고 (비어있는 send함수 채우기), 기업 장비에 맞춰 3리드 데이터로 채움
    • 단계 흐름 : 1단계 = 만들기만, 2단계 = 보내기 규칙 정하기 , 3단계 = 실제로 보내보기
  • 흐름 : A: 3리드 찾기 → B: 생성기에 TCP 전송 붙이고 → C: nc로 도착하는지 확인 → D: 측정용 수신기로 환자 수별 유실, 지연 측정

A. 3리드 데이터 조사

  • 목적 : 기업 장비가 3리드라서 공개 데이터 중에 3리드 기록 찾아봄
    • 1단계에서 사용한 s20011은 2리드
  • 설명 : LTSTDB 안에서 3리드 기록 18개 (s30661~s30801)을 찾아서 사용
    • 다른 공개 데이터도 같이 조사해서 용도 나눴음
    • 기록 이름 규칙 : 첫 숫자가 리드 수
      • s2xxxx : 2리드 / s3xxxx : 3리드
    • 3리드 : 전극 3개로 심장을 세 방향에서 본 그래프 3줄
    • 12리드 : 병원 정밀 검사용 12방향.
      • 여기서 3개 골라 사용 가능
    • 데이터별 용도
      • LTSTDB s3 기록 : 3리드, 250Hz, 하루치 → 스트림 / 부하테스트
      • INCART : 12리드 중 3개 사용, 257Hz , 30분 X 75개 —> AI 정확도 평가
      • PTB-XL : 12리드, 500Hz, 10초짜리 2만개 이상 —> AI 학습
      • 내 파트는 길게 흘려보내는 게 중요해서 LTSTDB사용
        • AI 팀은 정답이 달린 데이터가 필요해서 INCART, PTB-XL 사용
    • 라벨 : 박동마다 붙어 있는 정답 (정상인지, 어떤 부정맥인지)
      • AI를 학습시키고 채점하는 데 필요
    • 리샘플링 : 측정 간격이 다른 데이터를 250Hz로 맞추는 작업
      • INCART(257Hz), PTB-XL(500Hz)는 그대로 못 쓰고 맞춰야 함

B. TCP 생성기 (stream_tcp.py)

  • 목적 : 1단계 생성기에 비어있던 send() 함수를 채워서 실제로 TCP로 서버에 보내게 만듦
    • 1단계는 만들기만 하고 보내지 않음. 2단계에 정한 규칙으로 진짜 보내보는 단계
  • 설명 : 서버에 한 번 연결해두고 0.1초마다 환자 N명분 메시지를 만들어 한꺼번에 보내봄
    • 흐름 : 연결 한 번 → 0.1초마다 [환자별로 25개 X 3리드로 자름 → 송장 붙여서 메시지 만듦 → 글자로 바꾸고 \n 붙이기] → N명분을 이어 붙여 한 번에 전송 → 다음 박자까지 대기 (1단계 next_tick 그대로)

    • 실행

      python stream_tcp.py 100 --host 127.0.0.1 --port 8000 --record s30661 --seconds 10
      
      • --host : 보낼 주소. 127.0.0.1 = 자기 자신(같은 컴퓨터).
        • 다른 컴퓨터로 보내려면 그 컴퓨터 주소
      • --port : 보낼 문 번호 (8000)
      • --record : 재생할 기록 (3리드인 s30661)
      • --seconds : 몇 초 동안 보낼지
    • socket : 프로그램끼리 네트워크로 대화하는 창구

      • TCP를 사용하는 창구
      • 프로그램이 운영체제한테 이 주소와 문으로 연결해주게 부탁하는 창구
        • 연결의 한쪽 끝
    • sendall : 가진 데이터를 끝까지 다 보낼 때까지 보내는 함수

      • 창구에 대고 보내달라고 하는 명령
      • 소켓에 데이터를 넣으면 실제로 보내는 일은 운영체제가 TCP 규칙대로 함

      sendall(데이터) → 소켓 ═ TCP 연결 ═ 소켓 → readline(). 인사·순서·재전송은 운영체제가 처리

    • 한 번에 묶어 보내는 이유 : 환자마다 따로 보내면 0.1초에 100번 보내야 하기 때문에 묶으면 1번으로 가벼움

    • seq : 환자별로 보낼 때마다 1씩 늘림 —> 받는 쪽이 유실 확인

    • sent_at : 보내는 순간의 시각을 찍음 —> 받는 쪽이 지연 계산

    • 받는 쪽이 끊기면 : 멈추고 지금까지 보낸 개수 출력

  • 트러블슈팅
    • 시간 기준으로 반복했더니 10개 더 보내는 버그 —> 횟수 기준 (10초=100번)으로 바꿈

C. 확인 (nc)

  • 목적 : 팀원 수신부 없이도 생성기가 제대로 보내는지 먼저 확인
    • 받는 쪽까지 같이 돌리다 문제 생기면 보내는 쪽인지 받는 쪽인지 구분이 힘듦
  • 설명 : nc로 8000번 문을 대신 열어서 받는 척 하게 하고 생성기로 보내서 받은 줄 수를 셈
    • nc (netcat) : 받는 서버를 흉내 내는 도구

      • 문을 열고 들어온 걸 그대로 받아줌
    • 실행 : 터미널 탭 2개 (둘 다 VM 접속)

      • 탭 1 (받는 쪽) —> 8000번 열고 기다리다가 받은 줄 수를 셈

        nc -l 8000 | wc -l
        
        • -l : listen : 문열고 기다림
        • | (파이프) : 앞 명령의 결과를 뒤 명령으로 넘김
        • wc -l : 줄 수 세기.
          • NDJSON은 한 줄 = 메시지 1개라서 줄 수 = 받은 개수
      • 탭 2 (보내는 쪽)

        python stream_tcp.py 10 --seconds 5
        
    • 결과

      • 1명 X 2초 : 메시지가 형식 그대로 도착
      • 10명 X 5초 : 500개 보내고 500개 받음 —> 유실 0 (10명 X 1초에 10개 X 5초)
      • 100명 X 3초 : 3000개 보내고 3000개 받음 —> 최대 지각 18ms
    • 3리드 확인 : s30661을 읽어보니 리드 V4·V3·II, 250Hz, 표 모양 (2500, 3) = 10초 × 3리드

      • A에서 고른 기록이 VM에서 제대로 읽히는지 보내기 전에 확인. 250Hz라 생성기 박자는 그대로 씀
      • 리드 이름은 기록마다 다름 (다른 기록은 E-S, A-S, A-I) → 그래서 실제 장비 조합 확인이 남은 작업
    • ecg VM : 미니 PC 안에 만든 산학 전용 컴퓨터에서 테스트

      • 코드가 여기 있어서 이 공간에서 테스트
      • 실행 순서 : 노트북 터미널에서 ssh로 VM접속
      • host 127.0.0.1 : 생성기랑 받는 쪽이 둘 다 VM 안이라 자기 자신에게 보냄
        • 시계가 같아야 하기 때문에 같은 VM에서 돌림
    • 한계 : 도착과 몇 개인지만 확인. 지연과 유실을 seq, sent_at로 계산하지 않음

D. 측정용 수신기와 환자 수별 측정

  • 목적 : 받는 쪽에서 유실, 지연을 실제로 계산해서 환자수를 늘려도 보내는 구간이 버티는지 확인
    • C(nc)는 개수만 셌고 2단계에서 정한 기준 (유실, 지연 P95)을 실제 숫자로 뽑아서 확인
  • 설명 : 측정용 수신기 (measure_recv.py)를 직접 만들어 8000번에 받게 하고 환자 6,20,50,100 명으로 각각 10초씩 보냄
    • 측정용 수신기 : nc 대신 쓰는 받는 쪽 프로그램.
      • 받으면서 seq, sent_at을 보고 계산하고 끝나면 결과 출력
    • 실행 (둘 다 VM접속)
      • 탭 1 : python measure_recv.py (받는 쪽)
      • 탭 2 : python stream_tcp.py 100 --seconds 10 (보내는 쪽)
    • 출력하는 것
      • 지연 : 받은시간 - sent_at. p50 / p95 / 최대로 봄
      • 유실 : 환자별로 seq가 빠진 개수
      • 순서 뒤바뀜 : 5번 다음에 4번이 온 경우
        • TCP라 보통은 0임
      • 처리량 : 1초에 받은 상자 수
        • 환자수 X 10 이면 정상
    • 결과
      • 6명 : 600/600, 유실 0, p95 0.5ms
      • 20명 : 2,000/2,000, 유실 0, p95 0.9ms
      • 50명 : 5,000/5,000, 유실 0, p95 1.8ms
      • 100명 : 10,000/10,000, 유실 0, p95 3.2ms, 처리량 1,010개/초
      • 읽는 법 : 100명까지 하나도 빠지지 않았고 늦어도 3ms정도임. 환자가 늘수록 지연은 조금씩 늘지만 기준 (2초)에 비하면 아주 작은 편
      • ms : 밀리초 (1/1000초) —> 3.2ms = 0.0032초
    • 한계 : 보내고 받는 구간만 재고 큐, 워커 처리시간은 빠져있어서 실제 부하테스트는 워커 붙인 4단계 이후 다시 확인

이 단계의 진행 과정 보기 →

산학 4단계 · 수신부 연결과 전체 시연

목표

  • 목적 : 팀원이 만든 수신부, Redis, 워커를 테스트 서버 (ecg VM)에 띄우고 내 생성기랑 연결해서 끝까지 이어지는지 확인. 시연용으로 R파 검출과 임시 AI판정까지 붙였음
    • 단계 흐름 : 3단계에선 보내는 구간만, 4단계에선 받아서 처리하는 데까지 처음으로 전부 연결함
  • 흐름 : A: 수신부 , 큐, 워커 연결 → B: 시연용 R파 + 임시 AI → C: 시연 리허설
    • 서버 담당 팀원 : 수신부, Redis, 워커 코드
    • 나 : VM에 띄우기, 생성기 연결, 지연 측정, 시연용 R파 / 임시 AI, CNN 모델에 서버 붙이기
    • AI 담당 팀원 : 모델 학습
  • 큰 그림
    • 생성기 → TCP 8000 → 수신부 → Redis —>워커 w0, w1, w2 (지연 측정)

                                                                         —>데모 워커 ( R파 + 임시 AI)
      

A. 수신부, 큐, 워커 연결

  • 목적 : 팀원 수신부, Redis, 워커를 VM에 띄우고 내 생성기를 붙여서 상자가 워커까지 빠짐없이 가는지 확인함
    • 3단계는 nc, 측정용 수신기라 받는 과정까지만 확인
      • 실제 구조로 끝단까지 처음 재는 단계
  • 설명 : 도커 컴포즈로 3개를 한 번에 띄우고 생성기로 20명 X 10초 보냄 —> 2000개 전부 도착, 유실과 중복 검증 실패 0
    • 도커 : 프로그램을 필요한 환경째 상자 (컨테이너)에 담아서 어디서든 똑같이 돌리는 도구
    • 도커 컴포즈 : 컨테이너 여러 개를 파일 하나로 한 번에 켜고 끄는 도구
    • 워커 3개인 이유 : VM이 4코어, 워커 3개 + 나머지 1개는 수신부, Redis용
      • 코어 : CPU 안의 일꾼 수, 4코어면 동시에 4가지 일
    • 수신부가 하는 일 4가지
      1. 검사 : 필수 칸이 없거나 길이가 이상하면 세고 버리기. 멈추지 않는다.
      2. 유실 세기 : 환자별로 직전 seq 기억
        • 5다음 8이 오면 유실 2
      3. 워커 배정 : channel_id % 3 —>환자 0,3,6은 w0 / 1,4,7은 w1 / 2,5,8은 w2
      4. 묶어서 올리기 : 50건씩 모아서 Redis에 한 번에 넣음
        • 안 차도 5ms 지나면 보내기
    • 같은 환자는 항상 같은 워커인 이유 : R파 검출이 환자 평소 R파 크기를 기억하면서 돌아감. 워커가 바뀌면 그 기억이 끊김
      • 샤딩 : 데이터를 규칙에 따라 여러 갈래로 나눠 담는 것
      • 적응형 임계값 : R파로 볼 기준선을 그 환자 신호 크기에 맞춰 계속 조절하는 것
    • Redis Streams : 워커마다 벨트 하나씩 총 3개 (ecg: s0, s1, s2)
      • XADD : 벨트에 상자 올리기 (수신부)
      • XREADGROUP : 벨트에서 상자 가져가기 (워커)
      • XACK : 처리끝의 확인, 확인이 없는 건 남아있어서 워커가 죽어도 다시 처리 가능
      • 적체 : 벨트 위에 쌓인 상자 수
        • 계속 늘면 워커가 못 따라가는 것.
    • 결과 : 워커별 처리 700 / 700 / 600 , 끝단 지연 p95 23~27ms
    • 지연 두 종류
      • 보내는 구간 (3단계) : 생성기 → 받음
        • 100명 p95 : 3.2ms
      • 끝단 (4단계) : 생성기 → 수신부 → Redis 대기 → 워커 처리 끝
        • 20명 p95 : 23~27ms
      • 목표 기준 2초는 끝단 쪽.
        • 지금은 2000ms의 약 1.3%
    • 한계 : 워커가 아직 3ms동안 일하는 척만 하는 껍데기고 진짜 분석이 붙으면 다시 측정
  • 트러블슈팅
    • connection refused : 수신부 컨테이너가 다 뜨기 전에 생성기를 돌려서 뜸

B. 시연용 R파 검출 + 임시 AI 판정

  • 목적 : 정식 AI가 없어서 끝까지 이어지는 걸 보여주기 위해 시연용 R파 검출과 임시 AI를 붙였음
    • 정식 모델은 추후 ai담당 팀원이 만들어서 교체 예정.
  • 설명 : 데모 워커가 환자별로 0.1초 상자 50개 (=5초) 를 모으면 —> R파 찾기 —> 숫자 10개 뽑기 —> 임시 AI로 판정 —> 한 줄 출력
    • 흐름

      생성기 —> 수신부 —> Redis —> 워커 3개 (지연측정)

                                                  —> 데모워커 (5초 모음 → R파 → 임시 AI → 출력)
      
      • 워커 3개는 판정 안 함. 상자 받고 3ms 쉬고 끝. 확인만 함 → 지연 측정용
      • 판정은 데모 워커 1개가 환자 전부를 혼자 함 —> C에서 처리 시간이 늘어난 이유
    • 컨슈머 그룹 : 같은 큐를 구독하는 모임.

      • 그룹이 다르면 같은 상자를 각자 받음
      • 데모 워커를 다른 그룹으로 둬서 기존 지연 측정에는 영향 없음
    • 파이프라인 : 생성기부터 판정까지 데이터가 차례로 거치는 길 전체

    • 5초 창 : [3, 1250] = 리드 3줄 X 숫자 1250개

      • 5초 이유 : 실시간이면 시간 기준이 입력 크기, 지연 관리에 유리 (조교 피드백)
      • 박동 기준이면 심박마다 길이가 달라짐
    • Pan-Tompkins : R파 찾는 방법

      • 잡음 거르기 → 가파른 곳 키우기 → 0.15초씩 묶어 봉우리마다 언덕 하나 → 기준선 넘는 언덕 = R파
    • 임시 AI가 보는 숫자 10개 : 심박, 박동 간격이 고른지, 박동 모양이 비슷한지, 박동 크기 / 폭

      • 조기 수축은 박동이 너무 일찍 와서 간격이 너무 짧아져서 간격 숫자로 잡음
    • 랜덤 포레스트 : 스무고개 나무 300개의 다수결로 판정

      • ex ) 심박 100이 넘음? —> 간격이 짧음? …
      • 딥러닝은 아니며 가볍고 학습이 몇 분이면 끝나서 임시용
    • 라벨 : 5초 안에 이상 박동 (V, A, S 등)이 하나라도 있으면 “이상 창”

      • LTSTDB에서 의사가 박동 하나에 붙여둔 정답 기호
        • N : 정상박동
        • V : 심실 조기 수축 (PVC) : 심실에서 먼저 튀어나온 박동
        • A : 심방 조기 수축 (APC) : 심방에서 예정보다 일찍 뛴 박동
        • S : 상심실성 조기박동 : 심실 위쪽 (심방 등)에서 일찍 뛴 박동
          • 공통점 : 셋 다 조기 박동임. 예정보다 일찍 뛴 박동
            • AI가 박동 간격이 갑자기 짧아지는 걸 보고 잡을 수 있는 이유
            • V는 모양까지 달라서 박동 폭, 모양 숫자로도 잡힘
    • 학습 , 평가 : 3리드 18명 X 30분 = 창 6480개 , 13명으로 학습 , 처음 보는 5명으로 시험

      • 학습 목적 : 의사가 정답이라고 달아둔 데이터를 보고 AI가 이런 파형이면 이상이라는 기준을 스스로 만들게 함
      • 환자 단위로 나눈 이유 : 같은 사람으로 학습시키면 결과가 높아짐
      • 교차검증 : 학습용 13명을 4묶음으로 나눠 3묶음으로 배우고 1묶음으로 풀기를 해봄
      • 창 : AI가 한 번에 보고 판정하는 심전도 5초 묶음 (window)
        • 0.1초 상자 50개 = 1250개 샘플 X 3리드
    • 결과 : 민감도 0.93, 특이도 0.89

      • 민감도 : 실제 이상 중 잡은 비율 (175/188)
      • 특이도 : 실제 정상이고 정상이라고 한 비율 (1438/1612)
      • 미탐 : 이상인데 못 잡음 (13개)
      • 오탐 : 정상인데 잘못 울림 (174개)
      • 숫자는 전부 0~1비율. 1=100%
    • 판정 기준을 0.5에서 0.20으로 낮춘 이유 : 미탐이 오탐보다 치명적이라 해서 우선적으로 설정.

      • 이상 확률 : AI가 5초 창마다 내는 점수 (0~1)
      • 판정 기준 : 이상 확률이 몇 이상이면 이상으로 결정할지 정하는 기준
        • 판정 기준은 내가 정하는 값
        • 민감도는 돌려보고 나오는 결과 점수. 기준을 낮추면 민감도 상승
      • 0.5 : 기본값 (과반수) —> 거의 못 잡았음
      • 0.2 고른 방법 : 목표를 이상의 80퍼는 잡기로 정하고 교차검증으로 그걸 넘는 가장 높은 선 찾음 —> 0.2
        • 가장 높은 걸 고른 이유 : 선을 낮출수록 오탐이 늘어나서 목표만 넘으면 되는 최소한으로 잡음
    • 한계 : 오탐 174개라 실제 사용 수준이 안됨. 기록별 편차가 큼 (민감도 0.67~1.00) 18명으로는 일반화 판단 어려움

C. 시연 리허설

  • 목적 : 생성기 → 수신부 → Redis → R파 → 임시 AI까지 한 화면에서 이어지는 걸 확인
  • 설명 : 데모 워커를 켜고 환자 5명 X 30초를 보내서 5초마다 환자별 판정 줄이 찍히는 걸 확인
    • 실행 탭
      • 탭1 : python demo_worker.py --shards 3
      • 탭2 : python stream_tcp.py 5 --seconds 30 --record s30661 --start-min 1
    • 화면 읽는 법
      • 🔴 [환자 04] 5초 · R파 9개 · 심박 105 · 이상 의심 (0.59) · 처리 105ms
        • 누구 / 창 길이 / 박동 수 / 분당 박동 / 판정 / 이상 확률 / 마지막 상자 도착 —> 판정까지
        • 5초에 R파 9개면 심박 약 108, 숫자끼리 맞는지도 확인
    • 결과 : 30줄 중 이상 의심 12줄
      • 실제 이상 박동은 3개정도. 일부는 오탐
    • 처리 시간이 30 → 150ms로 증가
      • 5명 창이 동시에 차고 데모 워커 하나가 한 명씩 (약 25 ms) 처리해서 뒤 순서일수록 기다린 시간이 붙음 —> 그래서 워커를 여러 개로 나누는 구조의 필요
  • 트러블 슈팅
    • R파 위치가 늦게 찍힘 : Pan-Tompkins가 묶는 과정 때문에 0.1초 늦은 위치를 돌려줌 —> 찍힌 위치 앞쪽에서 가장 큰 점으로 보정
    • 임시 모델 민감도 0 : 기록마다 리드 구성이 달라서 매번 다른 리드를 봄 + 판정 기준이 0.5로 너무 보수적 —> 리드를 신호 기울기로 자동 선택 및 기준을 교차 검증으로 고름 —> 민감도 0.00 → 0.93

이 단계의 진행 과정 보기 →

산학 5단계 · 환자 수별 부하테스트와 모니터링

목표

  • 목적 : 환자 수를 6→20→50→100명으로 늘리면서 끝단 지연, 유실을 재고 서버가 어디서 못 버티는지 찾음. 숫자를 눈으로 보려고 모니터링 추가
    • 단계 흐름 : 4단계 = 처음 끝까지 연결 (20명) / 5단계 = 환자 수 늘리고 한계점 찾기
    • 측정 도구 : 보내는 구간은 3단계 D 측정용 수신기, 끝단은 워커 로그
  • 흐름 : A: 모니터링 붙이기 → B: 환자 수별 측정 → C: 100명에서 w0만 밀린 이유 → D 환자 상태 화면

A. 모니터링 붙이기

  • 목적 : 부하테스트 중에 CPU, 메모리, 큐 길이를 그래프로 보려고 모니터링을 붙임
    • 앱 코드는 안 건드리고 기존 도커 컴포즈 위에 얹음
  • 설명 : exporter들이 숫자를 내놓으면 → prometheus가 5초마다 가져가서 저장 → Grafana가 그래프로 보여줌
    • prometheus : 숫자를 5초마다 모아 저장하는 창고 (포트 9090)
    • Grafana : 창고 숫자를 그래프로 보여주는 화면 (포트 3000)
    • exporter : 숫자를 내놓는 프로그램
      • node-exporter : VM 전체 CPU , 메모리
      • cAdvisor : 컨테이너별 CPU, 메모리
      • redis-exporter : 큐 길이
    • 가져가는 방식 : prometheus가 각 주소의 /metrics 페이지를 직접 가지러 감.
      • exporter가 보내주는 게 아님
    • prometheus.yml : Prometheus가 어디서 숫자를 가져올지 적는 목록
      • job_name : 대상 이름
      • targets : 주소:포트
    • 접속 방법 : 맥북 브라우저에서 VM 주소:3000 으로 Grafana를 봄
      • ssh 접속 없어도 됨
        • ssh는 VM에서 내 명령어를 칠 때, 브라우저는 VM이 열어둔 화면만 볼때
        • Tailscale 내부망에서만 접속 가능
  • 트러블슈팅 : 컨테이너별 그래프만 비어있음 → Docker 29 가 저장방식을 바꿔서 cAdvisor가 컨테이너를 찾지 못함 → cAdvisor가 리눅스 cgroup (커널이 컨테이너별 자원 사용을 세는 곳)을 직접 읽게 바꿈

B. 환자 수별 측정

  • 목적 : 환자 수를 늘려가며 어디서부터 지연이 무너지는지 찾음
  • 설명 : 조건은 VM 4코어, 워커 3개, 워커는 메시지 1건에 3ms 일하는 척(가짜분석), 환자 수마다 30초
    • 결과 (끝단 지연 p95)
      • 6명 : 12~13ms, 유실 0
      • 20명 : 22~27ms, 유실 0
      • 50명 : 51~54ms, 유실 0
      • 100명 : w1·w2는 101~110ms인데, w0만 120ms → 1,010ms로 계속 늘어남
    • 100명 60초 다시 : w0 p95가 0.9초→ 1.9초까지 늘어남
      • VM CPU 약 78%, 워커 컨테이너 1코어 가득
    • 읽는 법 : 환자 수에 비례해서 지연이 늘다가 100명에서 w0 하나만 못 따라가고 무너짐 —> C에서 이유 찾음
    • 유실은 전부 0 —> 늦어져도 버리진 않고 큐에 쌓아둠

C. 100명에서 w0만 밀린 이유

  • 목적 : 100명에서 w0만 지연이 계속 늘어난 이유 찾기 (병목 찾기)
    • 병목 : 전체 흐름 중에 가장 느려서 전체를 막는 지점
  • 설명 : 환자를 환자번호 % 3 으로 나누니깐 w0만 1명 더 받았고 그 1명 때문에 1초에 할 일이 1초를 넘어버림
    • 계산 : 100명 → w0 34명, w1,w2 33명씩
      • w0 : 34명 x 초당 10건 x 3ms = 1.02초 분량을 1초에 해야함 —> 못 따라가서 큐에 쌓임
      • w1, w2 : 33명 —> 0.99초
    • 결론 : 워커 하나 (1코어) 당 한계는 약 33명
    • 개선 방향 : 코어 많은 장비로 워커 늘리기 + 묶어서 처리 (배치) + 건당 처리 시간 줄이기
    • 후속 확인 (서버 담당 팀원 분석) : 3ms 가정 틀림
      • 실제 판정은 메시지마다가 아니라 5초 창 단위. 처음 5초가 모이면 1번, 그 뒤로는 창을 밀며 1초마다 한 번
        • 분석 1회 약 2.5ms → 환자 1명당 초당 약 2.5ms
        • 3ms 가정은 환자 1명당 초당 30ms (10건 X 3ms) → 실제보다 약 12배 무거움
      • 실제 CNN을 붙여 다시 잰 100명 : 유실 0, 판정 p95 약 102ms (맥북 기준)
    • 그래서 B표는 워커 한계를 넘으면 큐가 계속 쌓이는 결과 확인한 거로 남겨둠
    • 남은 일 : ecg VM에서 실제 분석 기준으로 다시 측정

D. 환자 상태 화면 (시연용)

  • 목적 : 서버 상태가 아니라 환자 판정 결과를 한 눈에 보려고 만든 화면
    • A 모니터링 = 서버가 괜찮은지 / D 모니터링 = 환자가 괜찮은지
  • 설명 : 시연 워커가 9101번 포트로 환자 그리드 화면이랑 /metrics를 같이 내보냄
    • 그리드 : 환자마다 카드 하나
      • 정상이면 초록 / 이상 의심 빨강 / 판정 불가 회색 / 10초 넘게 신호 없으면 흐리게
    • Grafana ECG 환자 상태 (시연) 대시보드 : 이상 의심 환자 수, 환자별 상태, 상태 기록 타임 라인, 심박
    • 100명 띄워서 그리드 확인
    • 포트 정리
      • 9090 : Prometheus
      • 3000 : Grafana
      • 9101: 환자 그리드

트러블 슈팅

  • 적체, 중복 숫자가 실제와 다름
    • 증상 : 100명 측정이 끝났는데 적체가 52,800으로 계속 늘어남.
      • 유실은 0인데 중복이 27,800건
    • 원인 1 (적체) : 큐 하나를 측정용 워커 (cg 그룹)랑 시연 워커 (demo 그룹)가 따로 읽는데, 수신부가 두 그룹의 안 읽은 걸로 합쳐서 셈
      • 시연 워커가 꺼져 있으면 그쪽 미처리분이 전부 적체로 잡힘
    • 원인 2 (중복) : 생성기를 다시 돌리면 seq가 0부터 새로 시작
      • 수신부는 번호가 1000 넘게 뒤로 가면 재시작으로 봤는데 30초 실행은 300까지만 올라가서 재시작으로 안 보고 전부 중복으로 계산
    • 해결 : 적체는 그룹별로 나눠 세고 운영 적체는 cg만 봄 / seq가 0으로 돌아오면 재시작으로 셈 / 단위 테스트 7개
  • 100명 심박이 다 비슷함
    • 원인 : 100명 모두 같은 기록 (s30661) 1분을 시작 위치만 다르게 보냄.
      • 이상 의심도 환자 차이가 아니라 같은 파형의 다른 구간을 봄
    • 해결 : 생성기에 --records auto 옵션 추가
      • 환자마다 다른 3리드 기록을 보냄 —> 심박 범위 100~120 → 65~120

이 단계의 진행 과정 보기 →

산학 6단계 · 생성기 MQTT 송신

E. 생성기 MQTT 송신

  • 목적 : 기업 요청대로 TCP 말고 MQTT로도 보낼 수 있게 해서 비교 준비
    • 기업이 실제 제품에서 어느 방식을 사용할지 정하지 않아서 MQTT도 검토 중
    • 둘 다 만들어서 비교
  • 설명 : --transport mqtt 옵션 추가. 환자별 토픽 ecg/{환자번호}로 보내고 받는 쪽은 ecg/+로 전부 구독. 서버에서 10명 × 10초 유실 0, 지연 p95 1.3ms
    • MQTT : 브로커가 중간에서 메시지를 받아 구독자에게 나눠주는 방식.
      • IoT 기기에서 많이 사용
    • 토픽 : MQTT 메시지에 붙이는 주소
      • 받는 쪽은 토픽을 구독해서 해당 메시지만 받는다
      • ecg/+ 의 +는 ecg/0 , ecg/1, ecg/2 …. 모든 환자 메시지를 한 번에 받음
    • 브로커 : 메시지를 받아서 나눠주는 중계 서버 (Mosquitto)
    • TCP와 차이 :
      • TCP : 생성기와 수신부가 직접 연결
      • MQTT : 브로커를 거침
        • 메시지 끝 줄바꿈 같은 경계 표시 필요 없음

이 단계의 진행 과정 보기 →

산학 7단계 · 부하테스트용 기준 AI 모델

목표

  • 목적 : 임시 라벨로 만든 연결 테스트용 모델 대신, 의사 판독 라벨이 있는 공개 데이터(PTB-XL)로 기준 모델을 학습하고 정확도와 판정 속도를 서버 관점에서 비교
    • 단계 흐름 : 6단계까지 = 서버 쪽 (전송·부하테스트) / 7단계 = 테스트 서버에 올릴 진짜 모델 만들기
  • 흐름 : A 기존 AI 구조 이해 → B 학습 데이터 만들기 → C 시연·평가 데이터 분리 → E 학습·채점과 모델 비교

A. 기존 AI 구조 이해

  • 목적 : 팀에 있던 AI 코드가 어디까지 됐고 뭐가 비어 있는지 파악
  • 설명 : 모델·학습·판정·워커 연결 틀은 있었지만, 의사 판독 라벨이 있는 학습 데이터를 만드는 코드가 없어서 정식 학습이 된 적이 없었음
    • 학습 : 정답이 붙은 데이터를 보여주며 모델을 만드는 것. 미리 한 번, 결과물은 파일 하나(model.pt)
    • 판정(추론) : 학습한 모델에 처음 보는 파형을 넣어 정상인지 물어보는 것. 실시간, 환자마다 1초에 한 번
    • R파 검출과 AI는 따로 돎 : R파는 심박수·박동 위치, CNN은 5초 파형 [3, 1250]을 통째로 보고 이상 확률을 냄

B. 학습 데이터 만들기 (make_ptbxl_npz.py)

  • 목적 : 공개 데이터 PTB-XL을 학습 코드가 읽는 모양으로 바꾸기
  • 설명 : 12리드 중 3개만 고르고, 500Hz → 250Hz, 10초 → 5초 창 2개로 자른 뒤, 진단 코드로 정답(정상 0 / 이상 1)을 붙임
    • PTB-XL : 병원에서 누워서 잰 10초 12리드 심전도 약 2만 건. 의사 판독 라벨이 있음
    • npz : 넘파이 배열 여러 개를 파일 하나에 묶어 압축 저장하는 형식
    • 다운샘플링(decimate) : 샘플 수를 줄이는 것. 그냥 건너뛰면 높은 주파수가 낮은 주파수로 둔갑해서(에일리어싱) 필터를 먼저 걸고 줄임
    • fold 10 : PTB-XL이 같은 환자가 섞이지 않게 미리 나눠둔 10묶음 중 마지막. 시험용으로만 쓰고 학습에는 절대 안 씀
    • 환자 단위 분리 : 같은 환자 기록이 학습과 시험에 같이 들어가면 외운 걸로 점수가 부풀려짐

C. 시연·평가 데이터 분리 (LTSTDB → INCART)

  • 목적 : 부하테스트와 AI 시연에 쓸 데이터를 용도별로 나누기
  • 설명 : LTSTDB는 기록마다 리드가 달라서 모델이 대부분 거부함 → 부하테스트는 LTSTDB 그대로, AI 시연·평가는 12리드인 INCART로
    • 리드 : 전극 두 개 사이의 전압 차이. 같은 심장을 다른 방향에서 본 것
    • 리드 조합 하나 = 모델 하나 : 모델은 학습한 3리드 순서로만 판정할 수 있음
    • 리샘플링(resample_poly) : 257Hz → 250Hz처럼 비율로 늘렸다 줄이며 필터를 같이 걸어 파형이 안 깨지게 바꿈

E. 기준 CNN 학습·채점과 모델 비교 (train.py → eval_ptbxl.py)

  • 목적 : GPU 장비에서 학습하고 학습에 안 쓴 시험셋으로 진짜 성적을 잰 뒤, 정확도가 오른 만큼 판정 속도를 내줘도 되는지 서버 관점에서 비교
  • 설명 : 시험셋 5초 창 기준 민감도 0.929, 특이도 0.883, AUC 0.969, 창 1개 판정 CPU 0.40ms. 모델 6종 × 리드 2조합 × 시드 3번 비교에서 AUC는 0.9620.976으로 비슷한데 CPU 판정은 0.40ms6.55ms로 16배 차이 → 경량 CNN은 기준선, CNN+LSTM(BiLSTM)은 최종 후보
    • epoch : 학습 데이터 전체를 한 바퀴 다 본 것
    • val_loss : 학습에 안 쓴 검증 데이터에서 틀린 정도. 내려가다 다시 오르면 외우기 시작한 것(과적합)
    • 민감도 : 실제 이상 중에 이상이라고 잡은 비율 (미검출을 안 하는 능력)
    • 특이도 : 실제 정상 중에 정상이라고 맞힌 비율 (오검출을 안 하는 능력)
    • AUC : 임계값과 상관없이 정상과 이상을 얼마나 잘 가르는지. 1에 가까울수록 좋음
    • 임계값 : 이상 확률이 몇 이상이면 이상으로 판정할지 정하는 기준. 미검출이 더 위험해서 민감도 우선으로 낮게 잡음 (0.1)
    • 검증셋 vs 시험셋 : 검증셋은 임계값 고를 때 써서 점수가 후함. 시험셋은 한 번도 안 쓴 데이터라 진짜 성적
    • 시드 : 학습 시작점을 정하는 난수. 바꿔서 여러 번 돌려야 차이가 우연인지 알 수 있음
    • 채택 기준을 미리 정함 : 결과를 보고 기준을 바꾸면 원하는 결론에 끼워 맞추게 됨
    • 파라미터 : 모델이 학습으로 조정하는 숫자 개수. 많을수록 무겁고 느림
    • CNN+LSTM : 합성곱이 파형 모양(PVC)을, LSTM이 시간 순서·리듬(심방세동)을 봄
    • 데이터 증강 : 데이터를 변형해 양을 늘리는 것. 1초 겹쳐 자르기는 비슷한 창만 늘어 효과가 없었고, 진폭 조절·가우시안 노이즈 같은 다른 방법이 필요 (조교 피드백)

트러블 슈팅

  • 학습 데이터 다운로드가 하루 넘게 걸림
    • 원인 : physionet.org 직접 받기가 27KB/s
    • 해결 : PhysioNet 공식 AWS 미러(aws s3 sync --no-sign-request)로 바꿔 2.6GB를 약 10분에
  • "병목은 모델 크기"라는 가설이 틀림
    • 원인 : 데이터를 3배로 늘려도, 모델을 73배 키워도 AUC가 그대로였음
    • 해결 : 병목은 라벨(10초 라벨을 5초 창에 그대로 붙임) 쪽으로 판단. 학습보다 노이즈·외부 평가로 다음 단계를 바꿈

이 단계의 진행 과정 보기 →

Frozen Stiff · 융합캡스톤디자인 게임

배운 점 2편 · 기록 3개 중

캡스톤 1단계 · GodotSteam 검증

A. GodSteam 설치 찾기

  • 목적 : GodSteam을 프로젝트에 설치하려하는데 Godot 에디터 안에서 검색이 되지 않음
    • 왜 안 되는지 원인 찾고 우회해서 설치
  • 설명
    1. Godot 에디터 내장 애셋 라이브러리
      • Asset Library : 에디터 안에서 애드온 검색 및 설치하는 기능
        • 애드온 파일은 없고 저장소 주소만 등록
    2. 검색이 되지 않은 이유
      • GodSteam이 저장소를 GitHub → Codeberg로 옮김
      • 옛 깃허브 저장소 archived → 등록 주소가 끊김
        • 저장소 : 코드와 배포 파일을 모아두는 곳
        • archived : 관리 종료, 읽기 전용
    3. 우회 설치
      • Codeberg Releases에서 빌드 파일 직접 다운
        • releases : 완성본 파일을 버전별로 올려두는 곳

B. 버전 선택

  • 목적 : Godot 4.6.2에 맞는 GodotSteam 빌드 고르기
    • 4.6.2 딱 맞는 버전이 없음
  • 설명
    1. 최소 기준
      • GDExtension : Godot에 기능을 붙이는 확장 모듈
      • compatibility_minimum : 이 엔진 버전 이상이면 돌아간다는 최소 기준
    2. 하위호환
      • 최소 기준 < 엔진 버전 → 로딩됨
      • 최소 기준 > 엔진 버전 → 로딩 실패
    3. 결정 : g452
      • g452(4.5.2 이상) → 4.6.2에서 돎
      • g472(4.7.2 이상) → 4.6.2에서 안 돎

C. macOS Gatekeeper 차단 돌파

  • 목적 : Steam 설치
    • 직접 받은 설치 파일을 macOS가 계속 막음
  • 설명
    1. 막힌 이유
      • Gatekeeper : 애플이 서명 안 한 프로그램을 막는 macOS 보안 기능
      • 격리 속성 : 인터넷에서 받은 파일에 붙는 표시. 더 엄격하게 검사받음
    2. Homebrew로 설치
      • brew install --cask steam --force
        • Homebrew : 터미널 명령으로 프로그램 설치하는 도구
        • -cask : 창 있는 앱 설치 옵션
        • -force : 기존에 깨진 Steam.app 덮어쓰기

D. Steam 초기화·로비 생성 검증

  • 목적 : Steam 로그인과 로비 생성까지 실제로 되는지 코드로 확인
  • 설명
    1. 테스트 준비
      • 앱ID 480을 steam_appid.txt에 적음
        • 앱ID 480 : 밸브가 테스트용으로 공개한 앱(Spacewar)
    2. 스크립트 흐름 : 초기화 → 로그인 확인 → 로비 생성 요청 → 완료 콜백 받기
      • steamInit() : Steam과 연결 시작
      • createLobby() : 로비(방) 생성 요청
      • lobby_created : 로비가 만들어지면 오는 알림 (콜백)
      • run_callbacks() : Steam 알림을 게임에 넘겨줌. 매 프레임 불러야 해서 _process()에 넣음
    3. 결과
      • 초기화 true, 로그인 성공, 로비 생성 성공
      • 혼자 실행 기준으로 Godot 4.6에서 정상 동작
        • MultiplayerPeer : 멀티플레이 연결 전용 모듈. 4.5.1까지만 공식 지원이라 4.6은 조건부

이 단계의 진행 과정 보기 →

캡스톤 2단계 · 플레이 기록 수집 서버

A. 엔드포인트 설계

  • 목적 : 게임 없이도 돌아가는 서버로 사건 기록을 받고 저장하고 내보내기
  • 설명 :
    1. 서버 틀
      • FastAPI : 파이썬으로 웹 API 서버를 빠르게 만드는 프레임워크
      • 엔드포인트 : 서버가 요청을 받아주는 주소 하나하나
    2. 주소 세 개
      • POST /events : 게임이 보낸 기록 (JSON 배열)을 받아서 저장 —> 파일 생성 및 추가
      • GET /runs : 저장된 판 (run) 목록, 기록 수, 파일 크기 보기 —> data 폴더의 파일 목록을 보기
      • GET /runs/<run_id> : 한판의 기록 파일 통째로 내려 받기 —> 그 이름의 파일을 그대로 내려주기
        • run_id : 한 판을 구분하는 이름표
    3. 저장 방식
      • run_id별로 data/<run_id>.jsonl 파일에 한 줄씩 추가
        • JSON Lines : 한 줄에 기록 하나씩 적는 형식
        • 기록이 계속 쌓여도 파일 뒤에 붙이기만 하면 돼서 로그에 맞음
  • 흐름
    • 요청 도착 → 주소 확인
      • POST /events → 기록마다 run_id 읽기 → data/run_id.jsonl 열기 (없으면 생성) → 한 줄씩 추가 → saved 개수 응답
      • GET /runs → data 폴더의 .jsonl 파일 훑기 → 파일마다 이름/줄 수/크기 모아서 응답
      • GET /runs/run_id → data/run_id.jsonl 있는지 확인 → 있으면 파일 그대로 응답, 없으면 404

B. 토큰 인증 검사

  • 목적 : 아무나 서버에 기록을 못 보내게 막기
  • 설명 :
    1. 토큰 확인
      • 요청 헤더 X-Token 값이 서버의 토큰과 다르면 거절
        • 헤더 : 요청 본문과 별도로 붙는 부가 정보
        • 401 : 인증 안 됨을 뜻하는 응답 코드
    2. 토큰을 코드에 적지 않은 이유
      • 환경변수 PLAYLOG_TOKEN으로 실행할 때 넣음
        • 환경변수 : 코드 밖에서 실행 환경에 따로 넣어두는 값
        • 저장소에 올려도 토큰이 노출되지 않음
    3. run_id 검사
      • / 나 .. 이 들어가면 거부
        • 경로 조작 : 파일 경로에 .. 을 넣어 원래 폴더 밖 파일을 접근하는 공격
        • run_id 가 그대로 파일 이름이 되기 때문에 막아야 함
  • 흐름
    • 요청 도착 → X-Token 꺼내기 → 환경변수 PLAYLOG_TOKEN과 비교
      • 다르면 → 401 거절, 끝
      • 같으면 → run_id 검사 (/ 나 .. 있으면 400 거절) → 통과하면 A로 넘어감

C. 로컬 테스트

  • 목적 : 서버가 설계대로 동작하는지, Godot에서도 되는지 확인
  • 설명 :
    1. 서버에만 직접 호출 (맥)
      • 토큰 없이 보내기 —> 401
      • 토큰 넣고 10개 보내기 → saved 10, 조회/내려받기 정상
    2. Godot 에서 호출 (윈도우)
      • 서버 실행 : 토큰 지정 → uvicorn으로 127.0.0.1:8000에 띄움
        • uvicorn : FastAPI 서버를 실제로 실행해주는 프로그램
        • 127.0.0.1 : 내 PC 자신을 가리키는 주소
        • 8000 : 서버가 요청을 받는 포트 번호
      • Godot 빈 프로젝트에서 스크립트로 10개 보내기 → 저장 → 목록 → 내려받기 순서로 확인
        • HTTPRequest : Godot에서 HTTP 요청을 보내는 노드
        • await : 요청 결과가 올 때까지 기다렸다가 다음 줄 실행
    3. 결과
      • 세 요청 모두 200, saved 10, events 10, 10줄 그대로 내려받기
  • 흐름
    • 서버 실행 (토큰 지정 → uvicorn으로 127.0.0.1:8000)
      • 맥 : 토큰 없이 보내기 (401 확인) → 토큰 넣고 10개 (saved 10 확인)
      • 윈도우 Godot : 10개 보내기 → 목록 조회 → 내려받기
      • 세 요청 모두 200 + 10줄 그대로 오면 성공

D. 저장소 정리

  • 목적 : 결과물을 팀장이 받아서 바로 돌릴 수 있게 넘기기
  • 설명 :
    1. README
      • 실행 방법 (맥/윈도우) , 토큰 전달 방법, 내려받은 파일 예시, 인수인계
    2. Git으로 기록하고 올리기
      • git add → git commit → git push
        • commit : 고른 변경을 메시지와 함께 한 번의 기록으로 저장
        • Push : 내 PC의 기록을 Github에 올리기
    3. 인수인계
      • 외부 서비스 없이 PC + 포트 방식이라 계정 이전이 없음
      • 저장소는 팀장이 그 쪽을 원하면 이전

이 단계의 진행 과정 보기 →

여울목 · 계곡 급류 사전경보 서비스

배운 점 3편 · 기록 3개 중

여울목 1단계 · 깃허브 세팅

A. 깃허브 조직과 서버 저장소

  • 목적 : 팀원이랑 같이 쓰는 저장소를 한 사람 계정이 아닌 팀 이름 아래 둠
  • 설명
    • 조직 만든 이유
      • 개인 계정으로 레포를 만들면 그 레포는 내 거가 되고 팀원은 설정도 바꾸지 못 함
      • yeoulmok 아래 주인 두 명으로 설정
        • owner 두 명 (팀원, 나)
          • 팀원도 앱, 웹 저장소를 조직안에 직접 만들어서 권한 같게 함
    • 조직 (Organization) : 여러 사람이 저장소를 같이 소유하는 깃헙 팀 계정
    • Owner : 조직의 모든 저장소와 설정 바꿀 수 있는 최고 권한
    • .env.example : 필요한 키 이름만 적어둔 빈 양식
      • 실제 값은 서버에만 둠
      • API 키는 VM의 ~/secrets/yeoulmok.env에만 두고 저장소에는 키 이름만 적힌 .env.example만 올림
      • 깃에 이게 필요한 이유 : 실행용이 아니라 안내용.
        • 나중에 내가 새 서버 만들거나 팀원이 서버 돌려볼 때 어떤 키인지 알기 위해서

B. 서버 VM과 저장소 연결

ssh-keygen -t ed25519 -f ~/.ssh/yeoulmok_server        # 이 저장소 전용 키 한 쌍을 새로 만듦
cat ~/.ssh/yeoulmok_server.pub                          # 공개키 출력 → GitHub Deploy keys에 붙여넣음
git clone [email protected]:yeoulmok/server.git ~/yeoulmok # 서버 저장소를 VM으로 복사
ssh -T [email protected]                                   # 연결 확인 → "Hi yeoulmok/server!"면 성공
set -a; source ~/secrets/yeoulmok.env; set +a           # API 키 파일을 환경변수로 불러옴
  • 목적 : 홈랩 VM이 서버 저장소에 코드를 받고 (pull) 올릴 수 있게 (push) 그리고 API키는 저장소 밖에 두기 위함
  • 설명
    • 디스크 확장 : LVM으로 남은 공간까지 늘려서 할당한 저장공간 전부 활용
    • 배포키 사용 이유 : 내 개인 SSH키를 VM에 넣으면 VM이 털렸을 때 내 모든 저장소가 위험.
    • ~/.ssh/config에 키 저장한 이유 : 키가 여러 개면 SSH가 아무거나 내밀기 때문에 적어두고 헷갈리지 않게 해둠
    • API 키 저장소 밖에 둔 이유 : 저장소가 공개라 키는 VM 안에만 두고 chmod 600으로 나만 읽게 해둠
    • 도메인을 /api로 나눈 이유 : api.yeoulmok.ihaewon.com 처럼 두 번이 들어가면 Cloudflare 무료 인증서가 안 되기 때문에 yeoulmok.ihaewon.com 하나에 웹을 두고 API는 /api/… 경로로 나눔
  • 단어
    • ssh-keygen : SSH 키 한 쌍 (공개키, 비밀키)을 만드는 명령어
    • 배포 키 (Deploy Key) : 저장소 하나에만 접근하는 SSH키
    • LVM : 디스크 공간을 나중에 늘리거나 줄일 수 있게 묶어 관리하는 방식
    • set -a / set +a : 그 사이에 불러온 변수를 전부 환경변수로 등록 , 끄기

이 단계의 진행 과정 보기 →

여울목 2단계 · 기능 정의와 API 명세

A. 기능 정의

  1. 주기능은 계곡에 있을 때 실시간 위험 탐지
    1. 앱은 로그인 없이 계곡 구독 , 근처 계곡 확인 후 위험하면 알림
    2. 웹은 공무원이 로그인해서 계곡 상태 확인
  2. AI는 판정하지 않고 설명만
    1. AI는 왜 경보가 떴는지 쉽게 풀어줌
    2. 위험한 기준은 우리가 정해둔 규칙으로 판정
  3. 작업 순서
    1. 명세서를 화면 , 서버 전에 정하고 해야지 둘이 동시에 만들 수 있음
  4. 알림 3단계 (확실하게 정하지 않음)
    1. 주의 → 위험 → 재난 문자급 경고
  5. 웹 기능 2개
    1. 아직 정확하게 정하지 않음. 앱이 우선순위가 더 높아서 추후 토의
  • 규칙 엔진 : 정해둔 기준으로 판정하는 코드

B. Figma 학생 계정 및 팀 파일

  • 목적 : 클로드가 피그마 화면 읽고 쓸 수 있게 학생 플랜으로 연결 및 팀원과 같이 쓸 파일 만듦
  • 설명
    • 무료버전은 클로드가 읽어 코드로 바꾸는 기능 막혀있어서 학생계정으로 연동
      • 파일 개수 제한 없고 클로드랑 연결된 계정으로 파일을 내 팀에 만듦
    • 커넥터 : 클로드가 다른 서비스에 접속하게 해주는 연결
    • whoami : 지금 연결된 계정과 플랜 확인
    • 좌석 : 팀 안에서 그 사람이 쓸 수 있는 권한 등급

C. 깡통 화면 5장

  • 목적 : 기능 정의를 눈에 보이는 화면으로 옮겨서 팀원이 다듬고 나는 API목록을 뽑아 사용하기 위함
  • 설명
    • 와이어프레임 : 색, 디자인 없이 배치와 정보만 잡은 회색 화면
      • 디자인은 팀원 담당이라 어디에 정보 담을지만 표시
    • 화면 5장
      • 앱 : 홈 (구독 계곡) , 검색/구독 , 근처 계곡 (GPS) , 경보 상세
      • 웹 : 관할 계곡 상태
    • 경보 상세에 근거 보여주는 이유
      • 사전 경보만 띄우면 믿지 않기 때문에 관측소, 시각, 실측값, 기준값 같이 보여주고 AI가 풀어서 설명
    • 면책 문구 : 책임 범위를 밝히는 문장
      • 경보 판단을 돕는 정보지 공식 재난 경보가 아니기 때문에 책임범위 밝혀야 함
    • 모든 화면에 출처 넣기
      • 한강홍수통제소, 기상청 데이터는 출처 표시가 이용 조건
    • API 목록
      • 화면 마다 필요한 정보를 주는 API를 모으는 목록

D. 헬스체크 API와 도메인 연결

@app.get("/api/health")                             # 이 주소로 GET 요청이 오면
def health():
    return {"status": "ok", "service": "yeoulmok"}  # 이 JSON을 돌려줌
docker run -d --name api --restart unless-stopped \   # 백그라운드로, 재부팅해도 다시 켜지게
  -p 8000:8000 \                                      # VM 8000번 = 컨테이너 8000번
  -v ~/yeoulmok:/app -w /app \                        # VM 코드 폴더를 컨테이너 안에 연결
  --env-file ~/secrets/yeoulmok.env \                 # API 키를 환경변수로 넣음
  python:3.12-slim \                                  # 파이썬만 든 가벼운 이미지
  sh -c "pip install -r requirements.txt && uvicorn app.main:app --host 0.0.0.0 --port 8000"
ingress:
  - hostname: yeoulmok.ihaewon.com   # 이 주소로 들어오면
    service: http://localhost:8000   # VM 안 8000번(컨테이너)으로 보냄
  - service: http_status:404         # 나머지는 막음
  • 목적 : 팀원의 앱이 바깥 인터넷에서 부를 수 있는 서버 주소 만들기
  • 설명
    • 헬스체크 : 서버가 살아 있는지만 확인하는 가장 단순한 API
      • 진짜 기능 전에 길이 뚫렸는지 확인용도
      • 폰 → 인터넷 → Cloudflare → 집 VM → 서버
        • 이어지는지 확인했고 ok까지 확인
      • 나머지 API는 이 길 위에 얹기
    • 앱인데도 도메인이 필요한 이유
      • 앱도 인터넷으로 서버를 부르기 때문에 VM은 Tailscale 주소라 내 기기만 들어가기 때문에 공개 주소가 필요하고, 아이폰은 https만 허용해서 인증서도 필요 —> cloudflare이 해결
    • 터널을 쓰는 이유
      • 원래는 공유기 포트를 열어야 밖에서 들어오는데 그러면 집 IP가 드러나기 때문에 터널은 VM이 먼저 Cloudflare에 연결하고 요청을 받아오기 때문에 포트를 안 열어도 됨
    • venv 에서 Docker로 바꾼 이유
      • venv는 내 터미널이 서버를 붙잡고 있어서 SSH를 끄면 서버도 꺼짐
      • Docker은 docker가 대신 붙잡고 있어서 SSH를 끄거나 재부팅 해도 다시 켜짐
    • 터널을 프로젝트 별로 둠
      • 다 섞이면 하나 고장나면 다영향받음
    • 코드 바꾸면 git pull → docker restart api만 하면 됨
    • ingress : 들어온 주소별로 어디로 보낼지 정하는 규칙
    • 컨테이너 : 프로그램과 필요한 것들을 한 상자에 담아 따로 실행하는 것
    • -v : VM 폴더를 컨테이너 안 폴더에 연결

E. API 명세서 초안

앱:   GET /api/status?ids=1        ← 금대리 계곡 지금 상태 줘
서버: {"level": "pre_alert", "rain_1h_mm": 18.0, ...}
  • 목적 : 앱과 서버가 같은 약속을 보고 동시에 만들기 위함
  • 설명
    • API 명세서 : 앱과 서버가 어떤 주소로 뭘 보내고 뭘 받을지 정한 약속
      • 서로가 안을 몰라도 가능
    • 먼저 만드는 이유
      • 서버가 다 안 만들어져도 팀원은 응답 예시를 가짜 데이터를 넣고 화면을 먼저 만들어볼 수 있다.
    • API 목록 표
화면 이 화면에 필요한 것 그래서 만든 API
앱 홈 내가 구독한 계곡들 지금 상태 GET /api/status?ids=1,2
앱 검색 계곡 이름 목록 GET /api/valleys?q=금대
앱 근처 계곡 계곡 위치(위경도) GET /api/valleys
앱 경보 상세 경보 근거, AI 설명, 행동 요령 GET /api/valleys/{id}/alert
웹 관할 상태 요약 숫자 + 계곡 표 GET /api/admin/overview
  • 근처 계곡 API가 없는 이유
    • 서버는 계곡 위치 목록만 주고 폰이 자기 위치랑 비교해서 거리를 계산하기 때문
    • 사용자 위치가 서버로 안 가기 때문에 위치정보 신고 대상은 아님
  • 값이 없으면 0이 아니라 null
    • 0mm는 비 안 왔다는 의미고 null은 모른다는 의미다.
    • 0이라고 보내면 앱이 안전하다고 잘못 보여줄 수 있기 때문
  • level은 아직 임시
  • 푸시 토큰 : 서버가 특정 폰에 알림을 보낼 때 쓰는 그 폰의 주소

이 단계의 진행 과정 보기 →

여울목 3단계 · 관측 데이터 수집과 DB

A. 데이터 API 호출 확인

set -a; source ~/secrets/yeoulmok.env; set +a                                    # API 키를 환경변수로 불러옴
curl -s "http://api.hrfco.go.kr/$HRFCO_KEY/waterlevel/list/10M/1006664.json"     # 동부교 최신 수위 (10분 단위)
curl -s "http://api.hrfco.go.kr/$HRFCO_KEY/rainfall/list/1H/10064070/2025071900/2025071923.json"  # 서곡초교 사고일 강수 (1시간 단위)
{ "wlobscd": "1006664", "ymdhm": "202610311200", "wl": "0.08", "fw": " " }
  • 목적 : 코드 짜기 전 정부 API가 진짜 데이터를 주는지 확인
  • 설명
    • 사용하는 API
      • 한강홍수통제소 —> 수위 (물높이) + 강수 (2개의 API)
      • 기상청 —> 관측 강수랑 강수 예보
    • 주소 읽는 법 —> 틀리면 404 에러
      • waterlevel (수위)
      • list : 값 목록
      • 10M : 10분 단위
      • 1006664 : 관측 번호
    • 날짜 자릿수가 다름 —> 틀리면 빈칸으로 나옴
      • 10분 : 12자리 (202610311200) : 분까지
      • 1시간 : 10자리 (2025071923) : 시까지
    • 값이 약간 늦게 들어옴
      • 한강홍수통제소 : 약 10분
      • 기상청 : 약 4분
      • 그래서 수집할 때 지금시간에서 10분 여유를 둬야 함
    • 빈칸이 0이 아님
      • "fw": "" 처럼 빈칸이거나 기상청 -99.9는 관측 못함의 의미
      • 0으로 처리하면 비 안 오는 거로 착각해서 위험한데도 안전하다고 판단할 수 있음 → 판정 불가로 처리
    • 관측소마다 공식 기준 수위가 존재
      • 관심 → 주의 → 경보 → 심각
    • curl : 터미널에서 주소에 접속해 응답 받아오는 명령어
    • wl / fw / rf : 수위 (m) , 유량 (m³/s , 1초에 흐르는 물의 양) , 강수량 (mm)
    • 결측 : 관측소가 그 시각 값을 못 보내 비어 있는 것

B. 원주 관측소 찾기

curl -s -o rf_info.json "http://api.hrfco.go.kr/$HRFCO_KEY/rainfall/info.json"  # 강수 관측소 전체 목록을 파일로 저장
grep -B2 -A4 "obsnm.*원주" rf_info.json                                          # 이름에 '원주'가 들어간 것만 앞뒤 줄과 함께 출력
  • 목적 : 원주 안에서 우리가 쓸 강수, 수위 관측소 번호 찾기
  • 설명
    • 전체 목록에서 원주만 골라내지 못 함. 중간에 짤라냄
      • 정부 서버에서 큰 목록을 끝까지 보내지 않음
    • 강수 관측소는 운 좋게 잘린 앞부분 원주 8곳 존재
      • grep으로 골라냈고 , 그 중 서곡초교랑 신촌리 유의
        • grep : 파일에서 특정 글자가 들어간 줄만 찾는 명령
          • -B2 -A4 : 앞 2줄 뒤 4줄 같이
    • 수위 관측소는 잘린 부분에 있어서 홈페이지에서 직접 찾음
      • 실시간 수문 자료 → 수위에서 원주 11곳 찾음
      • obscd = 1006664 같은 숫자로 번호를 알아냄
        • obscd : 관측소 번호
    • 번호 하나만 찍어서 부르면 잘리지 않음
      • 응답이 작아서 잘리지 않음
      • 실제 서버는 우리 관측소 몇 개만 번호로 부르니 운영에 문제 X

C. 대상 계곡과 사고일 데이터

계곡 상류 강수 하류 수위
금대리 신촌리 (10064075) 동부교 (1006664)
서곡리 서곡초교 (10064070) X
  • 목적 : 경보를 낼 계곡 정하고 각 계곡에 어느 관측소 값 쓸지 짝 지음
  • 설명
    • 경보는 두 가지 데이터로 낸다.
      • 상류에 비가 많이 몰려오면 곧 물이 불어나니깐 미리 알림 (강수)
      • 하류 물이 실제로 빨리 차오르면 바로 알리기 (수위)
      • 그래서 계곡마다 위쪽 강수 관측소 + 아래쪽 수위 관측소 짝이 필요
    • 금대리는 짝이 맞지만 서곡리는 수위 짝이 없음
      • 서곡리 물 (서곡천) 은 원주 시내를 지나지 않고 서쪽 (흥업→호저)로 빠짐
        • 그래도 넣은 이유 : 실제 사고가 (2025.07.19)에 난 곳
          • 강수기록도 남아있음
        • 그래서 서곡리는 강수 경보만 하자고 제안
    • 사고일 기록 : 서곡초교 강수는 새벽 4~5시에 시간당 12,11mm (하루 약 56mm)
      • 근데 신촌리 강수량이랑 동부교 수위는 그 날 기록이 통째로 비어있음
        • 그래서 기준값은 반만 정할 수 있음
          • 강수기준 X 는 사고일 값으로 1차로 잡을 수 있다
            • 기준값 X : 상류 1시간 강수 기준
          • 수위 기준 Y는 다른 비 온 날 기록이나 관측소 공식 기준으로 정해야
            • 기준값 Y : 하류 30분 수위 상승폭 기준

D. 위치정보 법적 절차

기능 서버가 사람 위치를 받나 부담
앱 근처 계곡 안 받음 (폰에서 계산) 적음
앱 구독 알림 계곡 이름만 받음 적음
웹 실시간 사용자 수 받음 신고·동의 필요
웹 공무원 직접 알림 받음 신고·동의 필요
  • 목적 : 위치를 다루는 기능 중 법적 절차 필요한 걸 미리 확인
  • 설명
    • 기준은 하나임
      • 이 사람이 지금 어디에 있는지
        • 받으면 위치기반 서비스 신고 (방송미디어통신위원회)랑 약관, 동의 필요
    • 근처 계곡은 폰에서 계산해서 피함
      • 서버에서 계곡 위치만 주고 폰이 자기 위치랑 비교
        • 기능은 같은데 위치가 서버로 안 가니 신고 대상이 아님
    • 웹 두 기능은 위치로 받아야 함
      • 지금 계곡에 몇 명 있나를 알리려면 사람들 위치를 모아야함
    • 근거 : 2015년 기사라 추후 법 바뀌었는지 확인 필요

이 단계의 진행 과정 보기 →

커리어 내비 · 채용공고 기반 역량 갭 진단

배운 점 3편 · 기록 3개 중

커리어 내비 1단계 · 신청서 작성과 제출

A. 아이디어와 신청서

  • 목적
    • 커리어 내비 아이디어를 신청서 양식에 맞춰 정리 후 제출
  • 설명
    • 문제 정의
      • 학생은 목표 공고의 요구 역량을 지원 직전에야 앎
        • 늦은 인지
        • 연결의 부재 : 교내 프로그램이 어떤 직무 역량으로 이어지는지 모름
        • 경험 해석의 어려움
        • 방향 없는 스펙 쌓기
          • 근거
            • 기업의 96.2%가 직무 경험 / 역량을 봄 (고용노동부)
            • 구직자는 자격증 위주로 준비 (사람인)
    • 기존 서비스와의 차이
      • 취업 플랫폼 AI : 이미 쌓은 경험을 다듬어 줌 (자소서 코칭)
      • 공공 고용서비스 : 직업훈련 / 자격증 추천 중심 —> 학교 자원이랑 연결이 힘듦
      • 대학 추천 시스템 : 수강 이력 기준이라 특정 공고와 연결 X
      • 커리어 내비! : 목표 공고를 기준으로 부족한 역량을 찾고 학교 자원으로 졸업 전에 역량을 채우게 도와줌
    • 주요 기능
      1. 경험 카드화
        • 이력서 / 활동을 LLM이 STAR 카드로 정리
      2. 공고 역량 분석
        • 필수 / 우대 / 직무 역량으로 나눔
        • NCS : 국가직무능력표준
          • 직무별로 필요한 능력을 나라가 정리해둔 기준
      3. 역량 갭 진단
        • 임베딩 유사도로 경험과 역량을 매칭
          • 규칙으로 충족 / 부분충족 / 미충족 판정 —> 근거 문장 표시
          • 임베딩 : 문장을 숫자 목록으로 바꾼 것 / 뜻이 비슷하면 숫자도 가까움
      4. 교내 지원 추천
        • 교과목 / 비교과 / 현장실습 / 자격증을 RAG로 검색
          • RAG : 질문과 관련된 자료를 먼저 찾아서 AI 답변의 근거로 쓰는 방식
      5. What-If
        • 추천 항목을 이수하면 적합도가 얼마나 오르는지 계산
      6. 학기별 로드맵
        • 남은 학기에 맞춰 언제 무엇을 들을지 결정
    • 시스템 설계
      • 화면 : React (반응형 웹) / 서버 : Python FastAPI
        • 리엑트 : 화면(프론트) 만드는 자바스크립트 라이브러리
        • Fast API : 파이썬으로 만든 API 서버 만드는 프레임워크
      • LLM은 경험 카드화 / 역량 추출 , 결과는 JSON
        • 충족 여부와 적합도 점수는 규칙 기반 코드 (LLM X) → 환각 막고 기준 공개
          • 환각 (할루시네이션) : AI가 없는 사실을 그럴듯하게 지어냄
      • 교내 자원은 벡터 DB에 저장해서 검색
        • 벡터 DB : 임베딩 (숫자 목록)을 저장하고 비슷한 것끼리 빠르게 찾아주는 데이터베이스
    • 신뢰성 / 개인정보
      • 근거가 부족하면 판단보류로 표시 (억지로 판정 X)
      • 자소서 대필 기능 X
      • 업로드한 원본은 보관 X , 학교 통계는 익명으로
    • 검증 계획과 성과 지표
      • 검증 1 : 실제 공고 50건의 역량 추출 결과를 우리가 만든 정답지와 비교
      • 검증 2 : 재학생 사용성 테스트
      • 성과 지표 : 진단 후 비교과 / 현장실습 신청 전환율 / 로드맵 이행률 / 진단 전후 적합도 변화 만족도
      • 사용성 테스트 : 실제 사용자에게 써보게 하고 불편한 점 찾기

이 단계의 진행 과정 보기 →

커리어 내비 2단계 · 개발 환경과 프로젝트 뼈대

A. GitHub 레포와 뼈대 파일

  • 폴더 구조

    • frontend/ : react
    • backend/ : FastAPI
    • docs/ : API명세
  • API 명세 : 서버가 어떤 주소로 어떤 형식의 데이터를 주고 받는지 적는 문서 —> 진단 결과 한 건

    {
      "fit_score": 54,
      "competencies": [
        { "id": "c1", "name": "SQL 활용", "type": "필수", "status": "미충족",
          "evidence": { "posting": "SQL을 활용한 데이터 추출 경험", "experience": null } }
      ],
      "recommendations": [
        { "id": "r1", "competency_id": "c1", "kind": "교과목", "title": "데이터베이스",
          "semester": "3-2", "score_gain": 12 }
      ]
    }
    
    • competencies : 역량 목록
      • type : 필수 / 우대 / 직무
      • status : 충족 / 부분충족 / 미충족 / 판단보류
      • posting : 공고에서 찾은 문장
        • experience : 내 경험에서 찾은 문장
    • recommendations : 추천 목록
      • competency_id : 이 추천이 어느 역량을 위한 건지 (r1 → c1). 체크하면 그 역량이 충족으로 바뀜
      • kind : 교과목 / 비교과 / 현장실습 / 자격증
      • score_gain : 이걸 시행하면 몇 점 오르는지
    • null 규칙 : 값이 없을 때 문자열이나 “”이나 “없음”으로 쓰지 않고 null 쓰기로 함
    • 엔드포인트 : 서버에서 요청을 받는 주소 (ex : POST /api/diagnose)
  • 서버 뼈대 (backend/main.py)

    class Competency(BaseModel):          # api.md 형식을 파이썬 클래스로 옮김
        id: str
        type: Literal["필수", "우대", "직무"]  # 이 셋 중 하나만 허용
        status: Literal["충족", "부분충족", "미충족", "판단보류"]
    
    @app.post("/api/diagnose", response_model=DiagnosisResult)
    def diagnose(req: DiagnoseRequest):
        return MOCK                       # 고정된 가짜 결과 (54%)
    
    @app.get("/api/health")               # GET: 살아있는지 확인용
    def health():
        return {"status": "ok"}
    
    • 팀원 엔진 나오기 전 화면 개발 할 수 있게 고정된 가짜 결과 돌려주게 만듦
      • Literal : 정해진 값 중 하나만 허용
    • response_model 걸어두면 형식에 안 맞는 결과 나올 때 FastAPI가 에러나게 해줌
      • MOCK : 가짜 기능 대신 형식만 맞춘 고정 데이터 : 가짜 응답
    • Pydantic : 데이터 형식을 클래스로 정의하고 들어오고 나가는 값이 형식에 맞는지 검사해주는 라이브러리
    • HTTP 메서드 : 요청의 종류
      • GET : 조회 (데이터 안 실음)
        • /api/health
      • POST : 데이터 실어서 보냄
        • /api/diagnose
  • CORS 설정

    app.add_middleware(CORSMiddleware, allow_origins=["http://localhost:5173"])
    
    • 개발중인 화면 (5173)과 서버 (8000)가 주소가 달라서 서버가 5173에서 오는 요청 받겠다는 허용문
    • CORS : 브라우저가 다른 주소로 가는 요청을 막는 규칙
      • 서버가 허용 목록을 주면 통과
    • 미들웨어 : 모든 요청이 거쳐 가는 중간 처리 단계
  • .gitignore / 첫 push

    git init
    git add .
    git commit -m "init: 프로젝트 뼈대"
    git push -u origin main
    
    • .gitignore
      • .env , node_modules , .venv , *.pdf
        • API 키 , 개인정보, 설치 파일이 레포에 올라가지 않게 설정
      • 이후에 main에 바로 올리지 않고 브랜치 → PR → merge로 진행
        • PR (Pull Request) : 브랜치 작업을 main에 합쳐달라는 요청. 팀원 리뷰 가능
        • squash merge : 브랜치 커밋 여러 개를 하나로 합쳐서 main으로 넣는 방식

B. 로컬에서 FastAPI 서버 실행

  • 목적
    • 맥북에서 서버를 직접 띄워 뼈대가 돌아가고 가짜 결과 나오는지 확인
  • 설명
    • 로컬에서 실행

      • FastAPI 서버의 서버는 요청 받아주는 프로그램이란 뜻이지 VM이 아님.
        • 주소가 localhost:8000
    • 같은 코드가 맥북과 VM에 각각 있음

      • 맥북 → git push → GitHub → git pull → VM 순서
    • 가상환경 , 패키지 설치

      cd ~/career-navi/backend
      python3 -m venv .venv            # 이 프로젝트 전용 파이썬 환경
      source .venv/bin/activate        # 켜기 (터미널 앞에 (.venv) 뜸)
      pip install -r requirements.txt  # 목록대로 한 번에 설치
      
      • career-navi 폴더 다운 (클로드 이용)
        • .venv는 python3 -m venv 명령이 폴더까지 만들어줌
      • 프로젝트마다 환경 따로 두면 프로젝트랑 패키지 버전이랑 섞이지 않음
        • venv : 프로젝트 전용 파이썬 환경
          • 설치한 패키지가 이 폴더 안에만 들어감
        • requirements.txt : 필요한 패키지 이름 목록.
          • 팀원도 같은 걸로 설치하면 환경이 같아짐
    • 환경변수 파일

      cp .env.example .env
      
      • API 키 같은 비밀값은 .env에만
        • .env.example은 이런 키가 필요하다는 빈 양식
      • 환경변수 : 코드 밖에서 넣어주는 설정값
        • 코드를 바꾸지 않고 값만 바꿀 수 있음
    • 서버 실행

      uvicorn main:app --reload        # main.py의 app을 띄움
      
      • Application startup complete —> 뜨면 켜짐
      • uvicorn : FastAPI 앱을 실제로 실행해주는 서버 프로그램
        • Django의 runserver과 같은 역할
      • —reload : 코드를 저장하면 서버가 자동으로 켜짐.
    • Swagger로 확인

      • localhost:8000/docs를 열면 API 목록 화면에 뜸
      • POST /api/diagnose —> Try it out → execute → 응답 200
        • fit_score : 54 확인
        • 응답 200 : 요청이 정상 처리됐다는 HTTP 상태코드
        • POST : 데이터 실어서 보냄
      • GET /api/health → {”status”: “ok”}내
        • 서버가 살아있는지만 보는 용도
        • 배포 후에도 점검에 사용
        • GET : 데이터 조회 , 실어 보내진 않음
      • Swagger
        • 코드에서 자동으로 만들어지는 API 문서 겸 테스트 화면
        • 버튼으로 요청 보내볼 수 있음

C. 홈랩에 서비스용 VM 준비

  • 목적
    • 팀원이 들어와서 같이 쓸 서버를 홈랩 미니 PC에 VM으로 새로 만들고 SSH 세팅
  • 설명
    • 사양

      • 사양: 103 / careernavi / 2코어 / 4GB / 30GB / Ubuntu Server 26.04
        • RAM은 FAST API , Nginx , DB + 임베딩 모델 (약 2GB) 예상
    • 디스크 확장

      sudo lvextend -l +100%FREE -r /dev/ubuntu-vg/ubuntu-lv   # 남은 공간 전부 루트에
      df -h /                                                   # 확인
      
      • 30GB를 줬는데 설치 후 루트가 13.67GB만 잡혀 있어서 28GB로 늘림. Ubuntu 설치 기본값이 LVM 절반만 쓰는 게 원인
        • LVM : 디스크를 나중에 늘리거나 나눌 수 있게 논리적으로 관리하는 방식
          • lvextend : LVM 영역 늘리는 명령어
          • +100%FREE : 남은 공간 전부
          • -r : 파일 시스템까지 다 같이
    • Tailscale 연결

      curl -fsSL https://tailscale.com/install.sh | sh
      sudo tailscale up                 # 로그인 링크 뜸 → 내 계정에 등록
      
      • MagicDNS : IP 대신 기기 이름 (careernavi)으로 접속하게 해주는 기능
    • SSH 키 등록

      ssh-copy-id <사용자>@careernavi    # 내 공개키를 VM에 자동 등록
      
      • 비밀번호 없이 접속하게 해줌.
      • ssh-copy-id : 공개키를 서버의 authorized_keys에 자동 추가
        • authorized_keys : VM에 들어올 수 있는 공개키 목록 파일
    • Docker 설치

      curl -fsSL https://get.docker.com | sudo sh
      sudo usermod -aG docker $USER     # 나를 docker 그룹에 추가
      exit                              # 재접속해야 그룹 적용
      docker run hello-world            # 확인
      
      • usermod -aG : 사용자를 그룹에 추가
        • a : 기존 그룹 유지
        • G : 그룹 지정
      • hello-world : Docker가 이미지 받고 → 컨테이너 만들고 → 실행까지 되는지 보는 테스트

D. 뼈대 배포와 도메인 연결

  • 목적
    • 맥북에서 돌던 FastAPI를 VM에 올리고 내 도메인으로 밖에서 접속되게 만듦
  • 설명
    • 구조

      • 방문자 → careernavi.ihaewon.com → Cloudflare → tunnel → VM의 Cloudflare → localhost:8000 (FastAPI 컨테이너)
      • 서브도메인 : 도메인 앞에 이름을 붙인 하위 주소
    • 배포 키로 코드 받기

      ssh-keygen -t ed25519 -f ~/.ssh/github_deploy -N ""   # VM 전용 키
      cat ~/.ssh/github_deploy.pub                            # → GitHub 레포 Deploy keys에 등록
      git clone [email protected]:<내 계정>/career-navi.git ~/career-navi
      
      • 내 개인 GitHub 키를 VM에 두지 않고, 이 레포 하나만 읽을 수 있는 키를 따로 만듦
      • Allow write access 체크하지 않음
        • VM에서 push하지 못 함 —> VM에 직접 코드 고쳐서 push하면 맥북이랑 어긋나기 때문에 받기만 하는 곳으로 고정
          • 코드 수정은 맥북에서 push
          • VM은 git pull 로 받기만 하고 실행
      • ~/.ssh/config 에 IdentityFile ~/.ssh/github_deploy를 적어 깃허브 접속할 때 이 키를 쓰게 함
    • FastAPI 컨테이너

      docker run -d --name api --restart unless-stopped \
        -p 8000:8000 \
        -v ~/career-navi/backend:/app -w /app \
        python:3.12-slim \
        sh -c "pip install -r requirements.txt && uvicorn main:app --host 0.0.0.0 --port 8000"
      
      • 이미지를 따로 만들지 않고 공식 파이썬 이미지에 코드 폴더 연결해서 시작할 때 설치
        • 코드 바꾸면 git pull + docker restart api 로 변경
        • 대신 킬 때마다 설치해서 첫 응답까지 좀 걸림
      • -v : 볼륨, VM 폴더를 컨테이너 안 폴더에 연결
      • — restart unless-stopped : VM 재부팅돼도 자동 재시작
      • --host 0.0.0.0 : 컨테이너 밖에서 오는 요청도 받음.
        • 기본값이면 컨테이너 안에서만 됨
    • Cloudflare Tunnel

      cloudflared tunnel create careernavi
      cloudflared tunnel route dns careernavi careernavi.ihaewon.com   # DNS 자동 등록
      sudo cloudflared service install                                 # 부팅 시 자동 실행
      

      /etc/cloudflared.config.yml

      ingress:
        - hostname: careernavi.ihaewon.com
          service: http://localhost:8000
        - service: http_status:404
      
      • VM이 Cloudflare 쪽으로 먼저 연결 맺는 방식
      • 터널 인증 파일 (.json)은 VM에서만 실행
      • CNAME : 이 주소는 저 주소를 따라가라는 DNS 레코드
        • route dns가 자동으로 만듦
        • cloudflare 대시보드 → ihaewon.com → DNS에 존재
          • <터널ID>.cfargotunnel.com으로 가라고 적어두고 cloudflare가 터널 통해 VM으로 연결

E. React 화면과 Nginx 배포

  • 목표
    • 입력창 , 진단 버튼, 결과가 있는 화면을 만들고 Nginx로 화면과 API를 한 주소에서 서비스
  • 설명
    • 구조

      • 방문자 → 터널 → localhost:80 (nginx) → /는 react 빌드 파일, /api/는 FastAPI(8000)
      • Nginx 는 정문 역할 , 화면 파일은 직접 줌
      • /api/는 뒤의 FastAPI로 넘김
      • 리버스 프록시 : 받은 요청은 뒤쪽 다른 서버로 대신 전달
    • React 프로젝트

      npm create vite@latest frontend -- --template react
      cd frontend && npm install
      npm run dev                          # localhost:5173
      

      vite.config.js

      server: { proxy: { '/api': 'http://localhost:8000' } }
      
      • 화면 코드는 fetch(’/api/diagnose’) 처럼 상대 주소만 사용
        • 개발땐 Vite, 배포 때는 Nginx가 넘겨줘서 코드 수정없이 둘 다 동작
          • Vite : 리엑트 프로젝트 생성, 개발 서버, 빌드 도구
          • proxy : 개발중 /api 요청을 백엔드로 넘기는 설정
      • 화면이 서버 주소를 몰라도 실행되게 해둠
        • 화면은 /api/… 만 부르고 어디로 보낼지는 Vite(개발), Nginx(배포)가 결정 → 화면 코드 수정 없이 둘 다 동작
    • 빌드 (VM)

      docker run --rm -v ~/career-navi/frontend:/app -w /app node:22 \
        sh -c "npm install && npm run build"      # → frontend/dist
      
      • VM에 Node 깔지 않고 빌드할 때만 컨테이너를 잠깐 띄움 → 서버에 도구가 쌓이지 않음
        • 빌드 : 리엑트 코드를 브라우저가 바로 읽는 html, js, css로 묶기
        • —rm : 끝나면 컨테이너 자동 삭제
    • Nginx 설정 (~/nginx/careernavi.conf)

      server {
          listen 80;
          root /usr/share/nginx/html;
          location / { try_files $uri /index.html; }
          location /api/ { proxy_pass http://host.docker.internal:8000; }
      }
      
      docker run -d --name web --restart unless-stopped -p 80:80 \
        --add-host host.docker.internal:host-gateway \
        -v ~/career-navi/frontend/dist:/usr/share/nginx/html \
        -v ~/nginx/careernavi.conf:/etc/nginx/conf.d/default.conf nginx
      
      • try_files : 없는 경로는 리엑트 첫 화면 —> /demo 같은 주소도 리엑트가 받음
      • host.docker.internal : 컨테이너 안에서 자기를 가르킴
      • 설정 파일은 서버마다 달라서 레포가 아닌 VM에서만
    • 터널 대상 변경

      sudo sed -i 's#localhost:8000#localhost:80#' /etc/cloudflared/config.yml
      sudo systemctl restart cloudflared
      
      • 8000(FastAPI) → 80(Nginx)으로 바꿈.
        • 바꾸기 전에 VM 안에서 curl localhost로 html 나오는지 먼저 확인
        • D 과정에서는 화면이 없었고 FastAPI만 있어서 터널을 8000에 꽂았는데 E 과정에서 리엑트 화면이 생기면서 80이 정문이 됨
          • /api만 FastAPI로 넘기므로 터널 목적지를 바꿈
    • 반영

      cd ~/career-navi && git pull
      docker restart api                                  # 백엔드
      docker run --rm ... node:22 sh -c "npm run build"   # 화면
      docker restart web
      

이 단계의 진행 과정 보기 →

커리어 내비 3단계 · 웹 서비스 MVP

A. 엔진 연결 및 서버 잠금

  • 목적
    • 가짜 54% 대신 팀원 엔진 diagnose()를 연결 및 클로드 API 비용이 새지 않게 서버 잠금
  • 설명
    • Claud API 준비

      chmod 600 .env          # 나만 읽기
      docker restart api      # .env는 시작할 때만 읽음
      
      • 워크스페이스 : 키,사용량,한도를 묶어 관리하는 단위
      • 키는 VM backend/.env에만 저장
    • 엔진 연결

      from engine import diagnose as run_engine   # 팀원 엔진 함수를 가져옴. 내 함수도 diagnose라 이름 바꿈
      from engine.judge import fit_score           # 팀원 점수 계산 함수 (What-If에서 씀)
      
      @app.post("/api/diagnose", response_model=DiagnosisResult)
      def diagnose(req: DiagnoseRequest, request: Request):   # req: 보낸 텍스트 / request: 요청 자체(IP 확인용)
          check_rate_limit(request)                           # 횟수 제한 먼저 검사. 넘으면 여기서 429
          try:
              return run_engine(req.experience_text, req.posting_text)   # 팀원 엔진 실행 → 결과 돌려줌
          except Exception:                                   # 엔진이 뭐든 실패하면
              log.exception("engine 실패")                    # 자세한 에러는 서버 로그에만
              raise HTTPException(502, "AI 분석 중 문제가 생겼어요.")   # 화면엔 짧은 안내만
      
      • return MOCK 이었던 자리에서 return run engine(…)으로 변경
      • try / except : 안에서 에러나면 프로그램이 죽지 않고 except로 넘어감
        • Claude API 실패, 팀원 코드 버그, 응답 형식 오류 → 전부 502 하나로 처리
    • 서버 잠금

      _docs = os.getenv("ENABLE_DOCS") == "1"              # .env에 ENABLE_DOCS=1 있을 때만 True
      app = FastAPI(
          docs_url="/docs" if _docs else None,             # True면 /docs 열고, 아니면 아예 없음
          openapi_url="/openapi.json" if _docs else None,  # Swagger가 읽는 원본 파일도 같이 끔
      )
      
      • 서버 (VM)에는 ENABLE_DOCS가 없어서 /docs 치면 404
      class DiagnoseRequest(BaseModel):
          experience_text: str = Field(min_length=1, max_length=3000)   # 1~3000자만
          posting_text: str = Field(min_length=1, max_length=3000)
      
      • 길이 밖이면 FastAPI가 자동으로 422 → 내 코드까지 안옴
        • 토큰 3000자로 제한
        • 길이 밖이면 FastAPI 가 자동으로 422 → 내 코드까지 오지 않음
      PER_IP_LIMIT = int(os.getenv("RATE_PER_IP_PER_MIN", "5"))    # IP당 1분 5번
      DAILY_LIMIT = int(os.getenv("RATE_DAILY_TOTAL", "300"))      # 전체 하루 300번
      
      def _client_ip(request):
          return request.headers.get("cf-connecting-ip") or request.client.host
          # Cloudflare가 붙여준 진짜 방문자 IP. 없으면 직접 연결한 IP
      
      def check_rate_limit(request):
          hits = _ip_hits[_client_ip(request)]      # 이 IP의 요청 시각 목록
          while hits and now - hits[0] > 60:        # 1분 지난 기록은 버림
              hits.popleft()
          if len(hits) >= PER_IP_LIMIT:             # 1분 안에 5번 넘으면
              raise HTTPException(429, "요청이 너무 많아요.")
          hits.append(now)                          # 통과하면 기록
      
      • .env 에서 숫자를 바꿀 수 있게 os.getenv로 읽음
      • 기록은 서버 메모리에만 → 재시작하면 0부터
      • 터널 → Nginx → FastAPI 거치면 request.client.host 가 다 내부 주소라 cloudflare가 붙여주는 cf-connecting-ip 헤더를 봐야 진짜 방문자가 구분
    • pgvector DB

      docker run -d --name db --restart unless-stopped \
        -p 127.0.0.1:5432:5432 \                    # VM 안에서만 접속 (밖에서 안 보임)
        -e POSTGRES_PASSWORD=... \                  # 비밀번호는 .env 값
        -v pgdata:/var/lib/postgresql/data \        # 데이터는 볼륨에 → 컨테이너 지워도 남음
        pgvector/pgvector:pg16
      
      docker exec -it db psql -U postgres -c "CREATE EXTENSION IF NOT EXISTS vector;"   # 벡터 기능 켜기
      
      • -p 127.0.0.1:5432:5432 → 앞의 127.0.0.1이 핵심
        • 이거 없으면 0.0.0.0이라 VM밖에서도 열림

B. 진단 리포트 화면

  • 목적
    • 계획서 그림 3처럼 적합도 게이지, 역량 카드, What-if, 학기별 로드맵 만들기
  • 설명
    • What-if API

      class WhatIfRequest(BaseModel):
          competencies: list[Competency]      # 진단 결과의 역량 목록 그대로
          done_ids: list[str]                 # 체크한 역량 id들 (예: ["c2", "c5"])
      
      @app.post("/api/whatif", response_model=WhatIfResult)
      def whatif(req: WhatIfRequest):
          done = set(req.done_ids)            # 빠르게 찾으려고 set으로
          comps = [
              {"id": c.id, "type": c.type,
               "status": "충족" if c.id in done else c.status}   # 체크한 건 충족으로 바꿈
              for c in req.competencies
          ]
          return {"fit_score": fit_score(comps)}   # 팀원 judge.py 함수로 점수 계산
      
      • 화면이 이 역량들 중 c2, c5를 충족으로 치면 몇 점? 이라고 물어보는 것
      • 점수 공식을 따로 만들지 않고 팀원의 fir_score을 불러서 팀원이 규칙을 바꿔도 화면이 어긋나지 않음
      • LLM 쓰지않으니 비용, 요청제한이 없음
        • set : 중복 없는 목록
          • in으로 찾는게 list보다 빠름

이 단계의 진행 과정 보기 →

MiCAL · 학사·학과·장학금 일정 통합 캘린더

배운 점 2편 · 기록 3개 중

MiCAL 1단계 · 저장소와 서버 구축

A. GitHub 저장소와 프로젝트 뼈대

  • 목적
    • 팀 5명이 한 저장소에서 서로 안 부딪히고 작업할 수 있는 틀 만들기
  • 설명
    • 역할 : 백엔드 (Django API)

    • 크롤러와 DB가 주고받을 데이터 형식 정하기 (docs/data-format.md)

      1. source : scholarship —> 어디서 온 일정인지
      2. external_id : 12345 —> 원래 사이트 글 번호
      3. title : 국가장학금 2차
      4. start_at : “2026-11-03T00:00:00+09:00”
      5. raw : {…} —> 긁어온 원본 통째로
        • 가공 전 원본 데이터. 파싱 실수했을 때 다시 안 긁고 고칠 수 있음
      • 팀원은 이 모양대로만 넘기고 난 이 모양대로 받음
    • DB는 PostgreSQL

      • 크롤러랑 웹이 동시에 써도 안 막히고 중복이면 갱신하는 기능 (upsert)이 존재
      • upsert : 없으면 새로 넣고 (insert) 있으면 고치는 (update) 것
    • 비밀값은 깃에 올리지 않음

      • .env : 진짜 비번 —> 서버에
      • .env.example —> 깃 올림 (키 이름만)
    • 런어스는 iCal 주소 받음

      • 진짜 비번 받고 서버에 저장하는 건 위험
    • polling : 서버가 정해진 주기마다 가서 다시 받아오는 방식 (실시간 X)

B. 서버 VM 구축

  • 목적
    • 이 프로젝트만 쓰는 서버를 홈랩에 따로 만듦
  • 설명
    • VM 105 calendar 생성 (2코어, 4GB , 32GB)

    • 기초 세팅

      sudo apt update && sudo apt upgrade -y        # 패키지 최신화
      sudo apt install -y qemu-guest-agent git curl  # 필요한 도구 설치
      sudo timedatectl set-timezone Asia/Seoul       # 한국 시간으로
      sudo lvextend -r -l +100%FREE /dev/ubuntu-vg/ubuntu-lv  # 안 쓰던 디스크 절반까지 다 쓰게 늘림
      
      • Qemu Agent : VM 안에서 돌면서 Proxmox에 IP·상태를 알려주는 프로그램
      • lvextend : 디스크 파티션 크기를 늘리는 명령어
    • Docker 설치 후 sudo 없이 쓰게 함

      sudo usermod -aG docker haewonlee   # docker 그룹에 추가 (다시 로그인해야 적용)
      
      • 도커 그룹 : 여기 들어가야 sudo 없이 docker 명령어 사용 가능
    • 저장소 전용 배포 키

      ssh-keygen -t ed25519 -f ~/.ssh/campus_calendar   # 이 저장소 전용 키 만들기
      git clone git@github-calendar:<내 계정>/campus-calendar.git   # 아래 별칭으로 클론
      
      # ~/.ssh/config 파일 내용 (명령어 아님)
      Host github-calendar                  # 별칭
          HostName github.com
          IdentityFile ~/.ssh/campus_calendar   # 이 별칭으로 접속할 때 쓸 키
      
      • 한 VM에 여러 저장소 키가 있어도 별칭으로 구분
    • 다 끝낸 상태를 스냅샷 base-setup으로 저장

C. 시안 배포와 도메인 연결

  • 목적
    • 프론트 시안을 calendar.ihaewon.com으로 밖에서 볼 수 있게 만듦
  • 설명
    • nginx 컨테이너로 시안 띄움

      docker compose up -d frontend     # nginx 컨테이너 실행
      curl -I localhost:8080            # 200 OK면 정상
      
      • nginx가 frontend/ 폴더를 바로 읽어서, 시안 바뀌면 git pull만 하면 반영
    • 포트를 127.0.0.1에만 묶음

      frontend:
        image: nginx:alpine
        ports:
          - "127.0.0.1:8080:80"
      
      • Docker 설정 —> VM과 컨테이너 사이를 어떻게 연결할지 정하는 코드
        • 들어오는 길
          • 사용자 브라우저 (calendar.ihaewon.com) —> Cloudflare (터널) —> VM 안 cloudflared (localhost:8080) —> docker —> nginx 컨테이너 :80
          • 같은 VM 안의 cloudflared 만 접속 가능. 밖에선 터널로만 들어옴
      • 포트 매핑 : VM 포트와 컨테이너 포트를 이어주는 설정 (VM주소 : VM포트 : 컨테이너 포트)
    • Cloudflare Tunnel 연결

      calendar.ihaewon.com → http://localhost:8080   # Route 규칙
      
      • 공유기 포트를 안 열어도 외부 공개 가능. cloudflared를 서비스로 깔아서 재부팅해도 자동 연결
        • Route : 이 주소로 오면 터널 안 어느 서비스로 보낼지 정하는 규칙
        • CNAME : 이 주소는 저 주소의 별칭이라고 알려주는 DNS 기록
        • Cloudflare Tunnel : 서버가 Cloudflare 쪽으로 먼저 연결을 열어서 공유기 포트를 안 열고도 외부에 공개하는 방식

이 단계의 진행 과정 보기 →

MiCAL 2단계 · 백엔드 배포와 데이터 적재

A. 백엔드 뼈대와 서버 배포

  • 목적
    • 화면이 일정을 달라고 하면 DB에서 꺼내 주는 서버 (Django)와 DB (PostgreSQL)를 띄운다
  • 설명
    • 컨테이너 3개로 나눔 (docker-compose.yml)

      db:         # PostgreSQL. 일정·회원 정보 저장. 외부 포트 없음
      web:        # Django. 요청 받아서 DB 조회 후 JSON으로 응답
      frontend:   # nginx. 화면 파일 주고, /api/ 요청은 web으로 넘김
      
      restart: unless-stopped          # 서버 재부팅돼도 자동으로 다시 켜짐
      depends_on:
        db:
          condition: service_healthy   # DB가 준비된 뒤에 Django 켜기
      
      • 각자 역할이 달라서 따로 띄움
      • 컨테이너끼리는 이름으로 부름 (web:8000, db)
      • docker compose : 컨테이너 여러 개를 파일 하나로 묶어서 한 번에 키고 끄는 도구
      • healthcheck : 컨테이너 가 진짜 준비됐는지 주기적으로 확인
    • nginx가 주소 보고 길을 나눔 (infra/nginx/default.conf)

      location ~ ^/(api|admin|django-static)/ {
          proxy_pass http://web:8000;   # Django로
      }
      location / {
          try_files $uri $uri/ =404;    # 화면 파일
      }
      
      • /api/로 시작하면 장고로 보내고 나머지는 화면 파일을 줌
      • 주소 하나 (calendar.ihaewon.com)에 화면과 API가 같이 있음
      • proxy_pass : 받은 요청을 다른 서버로 넘겨주는 nginx 설정
      • API : 화면이 서버에 데이터를 달라고 하는 창구 (주소 + 규칙)
    • 일정 테이블 구조 (events/models.py)

      class Event(models.Model):
          owner = ForeignKey(User, null=True)  # 비면 공식 일정
          source = CharField()       # 어디서 온 일정
          external_id = CharField()  # 원래 사이트 글 번호
          title = CharField()
          start_at = DateTimeField()
          end_at = DateTimeField(null=True)    # 마감 없어도 됨
          raw = JSONField()          # 크롤링 원본
      
      UniqueConstraint(
          fields=["source", "external_id"],   # 둘 다 같으면 같은 일정
          condition=Q(owner__isnull=True),    # 공식 일정에만
      )
      

이 단계의 진행 과정 보기 →

반려동물 위탁돌봄 인수인계 점검 AI

배운 점 1편 · 기록 5개 중

돌봄 논문 1단계 · 연구 질문과 실험 설계

연구 질문과 실험 설계

A. AI가 하는 실수 두 종류

  1. 놓침 : 사람이 사료 주라고 말은 했지만 양은 말하지 않음. AI는 그냥 넘어가서 시터는 몇 g인지 모름
  2. 혼자 결정 : A는 6시 산책, B는 7시 산책이라 했지만 AI는 묻지않고 혼자 결정함

—> 돌봄에서는 둘 다 위험 —> 물어볼 때는 물어보는 AI가 목표

B. 연구 질문

  1. 답을 정해진 칸 (JSON)에 채우기만 해도 실수가 줄어듦?
  2. 그 칸을 코드로 한 번 더 검사하면 더 잡을 수 있음? 대신 어떤 걸 손해보는가

C. 비교한 세 방식

  • A : 자유요약 → 빈 종이에 알아서 메모하기
  • B : 양식 추출 → 칸이 정해진 양식지에 채우기 (JSON)
    • JSON : {”동물” : “콩이” , “양” : null} 처럼 이름표에 붙은 값을 넣는 형식
      • 비어있으면 null
  • C : 양식 + 코드 검사 → 양식지를 체크리스트로 한 번 더 확인
    • AI를 안 부르고 코드로 검사하는 이유 : AI는 부를 때마다 돈과 시간이 들고 답도 매번 달라지기 때문에 일관성있는 코드사용

D. 사례와 공정성

  • 개발용 10개 / 평가용 20개 : 개발용 10개로 먼저 실험 후 규칙을 만들고 나서 평가용을 돌린다
    • 규칙을 만들 때 개발용으로 채점하면 답을 외우고 시험을 보는 셈이라 처음 보는 사례로 해야 판가름이 나온다.
    • 평가용 20개 —> 물어봐야 하는 사례 6개 + 안 물어봐도 되는 사례 14개를 섞음
      • 물어봐야되는 사례만 넣으면 무조건 다 물어보는 AI가 100점을 받기 때문에 안 물어도 되는 사례를 섞어야 걸러짐
  • 실행 전에 고정값 : 결과를 보고 정답이나 규칙을 고치면 결과를 맘대로 바꾸는 것이기 때문에 고정
  • 10회 반복 : AI는 같은 질문을 해도 답이 조금씩 다르기 때문에 우연인지 잘 대답한 건지 확인하기 위해

E. 지표

  • 탐지율 : 물어봐야 할 사례에서 실제로 물어본 비율 (높을 수록 좋음 —> 안 놓침)
  • 과잉 질의율 : 안 물어도 되는 사례에서 물어본 비율 (낮을 수록 좋음)
  • 탐지율과 과잉 질의율 같이 봐야한다.
    • 무조건 다 물어보면 탐지율도 과잉 질의율도 100%임.

이 단계의 진행 과정 보기 →