[ { "title": "Given-When-Then 자세히 이해하기", "url": "/posts/given-when-then/", "categories": "Development, Testing", "tags": "BDD, Given-When-Then, Gherkin, Cucumber, Test, 테스트", "date": "2026-07-03 07:30:00 +0900", "snippet": "최근 테스트 코드를 다시 정리하면서 Given-When-Then이라는 표현을 자주 보게 됐다. 단위 테스트를 작성할 때는 보통 given, when, then 주석을 나누거나, 테스트명을 한글로 길게 작성하는 정도로만 사용했는데, 이 방식이 정확히 어떤 배경에서 나온 것인지 궁금해졌다.그래서 Martin Fowler의 Given When Then 글을...", "content": "최근 테스트 코드를 다시 정리하면서 Given-When-Then이라는 표현을 자주 보게 됐다. 단위 테스트를 작성할 때는 보통 given, when, then 주석을 나누거나, 테스트명을 한글로 길게 작성하는 정도로만 사용했는데, 이 방식이 정확히 어떤 배경에서 나온 것인지 궁금해졌다.그래서 Martin Fowler의 Given When Then 글을 읽어보았다. 글 자체는 길지 않지만, 테스트를 단순 검증 코드가 아니라 시스템의 동작을 설명하는 예시로 바라보는 관점이 잘 담겨 있었다.이번 글에서는 Fowler의 글을 바탕으로 Given-When-Then이 무엇인지, BDD와 어떤 관련이 있는지, 그리고 실제 테스트 코드에서는 어떻게 적용하면 좋을지 정리해보자.Given-When-Then이란?Given-When-Then은 테스트나 시나리오를 아래 세 단계로 나누어 표현하는 방식이다.Given 어떤 상황이 주어졌을 때When 사용자가 어떤 행동을 하면Then 어떤 결과가 나와야 한다Fowler는 Given-When-Then을 테스트를 표현하는 하나의 스타일이라고 설명한다. 조금 더 BDD답게 말하면, 테스트를 통해 시스템의 행동을 명세하는 방식이라고 볼 수 있다.즉, 테스트를 단순히 “이 값이 맞는지 확인하는 코드”로만 보는 것이 아니라, 아래처럼 읽히게 만드는 것이다.이런 상황에서,이 행동을 하면,이런 결과가 나와야 한다.이 구조는 BDD, 즉 Behavior-Driven Development에서 자주 사용된다. Cucumber 같은 테스트 프레임워크에서도 많이 볼 수 있고, 꼭 Cucumber를 쓰지 않더라도 단위 테스트나 통합 테스트를 정리할 때 충분히 활용할 수 있다.Given: 테스트 시작 전 상태Given은 테스트하려는 행동이 실행되기 전에 이미 주어진 상태를 의미한다.예를 들어 알바 급여 계산 기능을 테스트한다고 해보자.Given 민수는 시급 10,000원인 알바생이다And 민수는 7월 1일에 8시간 근무했다And 해당 근무 기록은 승인되었다이 부분은 “테스트를 시작하기 전에 세상이 어떤 상태인가?”를 설명한다.개발 코드로 보면 보통 테스트 데이터를 준비하는 단계다.Employee employee = new Employee(\"민수\", 10_000);Worklog worklog = new Worklog(employee, LocalDate.of(2026, 7, 1), 8);worklog.approve();Fowler는 Given을 테스트의 사전 조건, 즉 pre-condition으로 생각할 수 있다고 설명한다. 다만 테스트 프레임워크 입장에서는 Given이 단순한 설명이 아니라, 시스템을 해당 상태로 만들기 위한 명령들의 집합처럼 동작할 수 있다.그래서 다른 테스트 패턴에서는 Given에 해당하는 부분을 setup 또는 arrange라고 부르기도 한다.When: 테스트하려는 행동When은 실제로 테스트하고 싶은 핵심 행동이다.When 7월 급여를 계산하면여기서 중요한 점은 When은 가능하면 하나의 행동에 집중해야 한다는 것이다.예를 들어 아래처럼 작성하면 테스트가 무엇을 검증하려는지 흐려질 수 있다.When 알바생을 등록하고And 근무 기록을 제출하고And 사장이 승인하고And 급여를 계산하면이 경우 알바생 등록, 근무 기록 제출, 승인, 급여 계산이 모두 테스트 대상처럼 보인다. 하지만 이 시나리오에서 검증하고 싶은 것이 “급여 계산”이라면 앞의 과정은 Given으로 보내는 것이 더 자연스럽다.Given 민수는 시급 10,000원인 알바생이다And 민수의 8시간 근무 기록이 승인되었다When 7월 급여를 계산하면Then 급여는 80,000원이어야 한다이렇게 나누면 테스트의 의도가 훨씬 분명해진다.Then: 기대하는 결과Then은 When에서 실행한 행동 때문에 기대되는 결과를 설명한다.Then 민수의 7월 급여는 80,000원이어야 한다And 급여 상태는 지급대기여야 한다개발 코드에서는 assertion에 해당한다.assertThat(payroll.getAmount()).isEqualTo(80_000);assertThat(payroll.getStatus()).isEqualTo(PENDING);Fowler는 Then에 해당하는 명령은 부작용이 없어야 한다고 말한다. 즉, 검증 단계에서 시스템 상태를 또 바꾸면 안 된다.반대로 아래처럼 검증 단계에서 상태를 변경하는 것은 좋지 않다.result.markAsPaid();assertThat(result.getStatus()).isEqualTo(PAID);Then은 어디까지나 확인하는 단계이지, 새로운 행동을 수행하는 단계가 아니다.BDD에서 왜 중요할까?Given-When-Then은 BDD에서 자주 등장한다. BDD는 테스트를 개발자만 보는 코드가 아니라, 기획자나 도메인 담당자도 이해할 수 있는 행동 명세로 바라본다.예를 들어 아래 테스트 코드는 개발자 입장에서는 충분히 이해할 수 있다.@Testvoid calculatePayroll() { Employee employee = new Employee(\"민수\", 10_000); Worklog worklog = new Worklog(employee, 8); worklog.approve(); Payroll payroll = payrollService.calculate(employee); assertThat(payroll.getAmount()).isEqualTo(80_000);}하지만 비개발자가 읽기에는 아무래도 부담스럽다. 반면 Given-When-Then으로 표현하면 비즈니스 규칙이 더 잘 드러난다.Given 민수는 시급 10,000원인 알바생이다And 민수는 8시간 근무했다And 근무 기록은 승인되었다When 민수의 급여를 계산하면Then 급여는 80,000원이어야 한다이렇게 작성하면 이 테스트는 단순한 코드가 아니라 하나의 요구사항처럼 읽힌다. 승인된 근무 기록이 있으면, 시급과 근무 시간에 따라 급여가 계산되어야 한다.이 점이 Given-When-Then의 가장 큰 장점이라고 느껴졌다.Cucumber와 GherkinFowler의 글에서도 Cucumber 이야기가 나온다. Cucumber는 비즈니스 시나리오를 테스트로 연결할 수 있게 해주는 도구이고, 이때 사용하는 DSL 이름이 Gherkin이다.Gherkin 문법에서는 아래와 같은 구조를 자주 볼 수 있다.Feature: 급여 계산Scenario: 승인된 근무 기록만 급여 계산에 포함된다 Given 민수는 시급 10,000원인 알바생이다 And 민수는 7월 1일에 8시간 근무했다 And 해당 근무 기록은 승인되었다 And 민수는 7월 2일에 5시간 근무했다 And 해당 근무 기록은 아직 승인되지 않았다 When 7월 급여를 계산하면 Then 민수의 급여는 80,000원이어야 한다 And 미승인 근무 기록은 급여에 포함되지 않아야 한다여기서 중요한 것은 Cucumber를 써야만 Given-When-Then을 사용할 수 있는 것은 아니라는 점이다.Fowler도 이 스타일은 Cucumber 같은 프레임워크에서 많이 쓰이지만, 어떤 종류의 테스트에도 사용할 수 있다고 설명한다. 실제로 JUnit 테스트 코드 안에서 주석으로 나눠 사용하는 것도 충분히 좋은 방식이다.@Testvoid 승인된_근무기록만_급여_계산에_포함된다() { // given Employee minsu = new Employee(\"민수\", 10_000); Worklog approvedWorklog = new Worklog(minsu, LocalDate.of(2026, 7, 1), 8); approvedWorklog.approve(); Worklog pendingWorklog = new Worklog(minsu, LocalDate.of(2026, 7, 2), 5); // when Payroll payroll = payrollService.calculateMonthlyPayroll(minsu, YearMonth.of(2026, 7)); // then assertThat(payroll.getAmount()).isEqualTo(80_000);}Arrange-Act-Assert와의 관계Given-When-Then은 완전히 새로운 개념이라기보다, 기존 테스트 구조를 더 비즈니스 친화적인 언어로 표현한 것에 가깝다.테스트 코드에서 많이 쓰는 Arrange-Act-Assert와 비교하면 아래처럼 대응된다. Given-When-Then Arrange-Act-Assert 의미 Given Arrange 테스트에 필요한 상태를 준비한다 When Act 테스트하려는 행동을 실행한다 Then Assert 기대한 결과를 검증한다 또 Fowler는 Four-Phase Test와도 연결해서 설명한다. Given-When-Then Four-Phase Test 의미 Given Setup 테스트 상태를 준비한다 When Exercise 테스트 대상 동작을 실행한다 Then Verify 결과를 검증한다 - Teardown 테스트 후 정리한다 BDD 스타일에서는 보통 Teardown이 겉으로 잘 드러나지 않는다. 테스트 프레임워크나 트랜잭션 롤백, 자동 정리 기능이 처리하는 경우가 많기 때문이다.실무에서 적용할 때 주의할 점Given에는 필요한 조건만 둔다테스트를 작성하다 보면 필요한 것보다 많은 데이터를 Given에 넣고 싶어질 때가 있다.Given 직원이 있고And 사업장이 있고And 사장이 있고And 직원의 전화번호가 있고And 직원의 주소가 있고And 직원의 생년월일이 있고When 급여를 계산하면Then 급여가 계산된다하지만 급여 계산에 전화번호, 주소, 생년월일이 필요 없다면 Given에 넣지 않는 것이 좋다. 불필요한 정보가 많아질수록 테스트의 핵심 의도가 흐려진다.더 좋은 방식은 아래처럼 테스트하려는 규칙에 필요한 조건만 남기는 것이다.Given 직원의 시급은 10,000원이다And 승인된 근무 시간이 8시간이다When 급여를 계산하면Then 급여는 80,000원이어야 한다When은 하나의 핵심 행동으로 둔다When에 여러 행동이 섞이면 테스트가 실패했을 때 원인을 파악하기 어렵다.When 알바생을 등록하고And 근무 기록을 만들고And 승인하고And 급여를 계산하면이런 경우에는 등록, 기록 생성, 승인은 Given으로 보내고, 실제 검증 대상인 급여 계산만 When에 두는 것이 좋다.Given 알바생이 등록되어 있다And 승인된 근무 기록이 있다When 급여를 계산하면Then 예상 급여가 계산되어야 한다Then은 상태 변경 없이 검증만 한다Then 단계에서는 결과 확인만 해야 한다. 검증 도중에 상태를 바꾸면 테스트가 읽기 어려워지고, 테스트 실패 원인도 모호해진다.// thenassertThat(payroll.getAmount()).isEqualTo(80_000);assertThat(payroll.getStatus()).isEqualTo(PENDING);Then은 결과를 만드는 곳이 아니라, 이미 만들어진 결과를 확인하는 곳이다.테스트 이름에 적용하기JUnit 테스트를 작성할 때도 Given-When-Then 흐름을 테스트 이름에 반영할 수 있다.@Testvoid givenApprovedWorklog_whenCalculatePayroll_thenReturnsCorrectAmount() { // given Employee employee = new Employee(\"민수\", 10_000); Worklog worklog = new Worklog(employee, 8); worklog.approve(); // when Payroll payroll = payrollService.calculate(employee); // then assertThat(payroll.getAmount()).isEqualTo(80_000);}다만 개인적으로는 한국어 테스트명도 꽤 괜찮다고 생각한다. 특히 도메인 규칙을 표현해야 하는 테스트라면 아래처럼 작성하는 편이 더 읽기 좋을 때가 많다.@Testvoid 승인된_근무기록이_있으면_급여를_정확히_계산한다() { // given Employee employee = new Employee(\"민수\", 10_000); Worklog worklog = new Worklog(employee, 8); worklog.approve(); // when Payroll payroll = payrollService.calculate(employee); // then assertThat(payroll.getAmount()).isEqualTo(80_000);}테스트명만 봐도 정책이 보인다. 승인된 근무 기록이 있으면 급여를 정확히 계산한다이런 테스트가 쌓이면 테스트 코드 자체가 문서 역할을 하게 된다.알바페이에 적용해본다면알바페이 같은 서비스에는 Given-When-Then이 꽤 잘 맞는다. 급여, 근무 기록, 승인, 반려, 지급일 같은 도메인 규칙이 시나리오로 표현하기 좋기 때문이다.예를 들어 아래와 같은 기능이 있다고 해보자. 알바생은 이번 달 예상 급여를 확인할 수 있다.이 요구사항을 Given-When-Then으로 풀면 아래처럼 정리할 수 있다.Feature: 예상 급여 조회Scenario: 승인된 근무 기록만 예상 급여에 포함된다 Given 민수는 시급 10,000원인 알바생이다 And 민수는 이번 달에 승인된 8시간 근무 기록을 가지고 있다 And 민수는 이번 달에 미승인 5시간 근무 기록을 가지고 있다 When 민수가 이번 달 예상 급여를 조회하면 Then 예상 급여는 80,000원이어야 한다 And 미승인 근무 기록은 예상 급여에 포함되지 않아야 한다이 시나리오를 먼저 정리하면 구현 전에 정책을 분명히 할 수 있다. 미승인 근무 기록을 포함할 것인가? 반려된 근무 기록은 어떻게 처리할 것인가? 지급 완료 후 추가 승인된 기록은 어느 달 급여에 반영할 것인가? 야간 수당, 주휴 수당, 휴게 시간은 어떤 순서로 계산할 것인가?결국 Given-When-Then은 테스트 작성 방식이기도 하지만, 요구사항을 더 구체적으로 만드는 도구이기도 하다.정리Martin Fowler의 Given-When-Then 글을 읽고 나서 가장 크게 느낀 점은, 이 방식이 단순한 테스트 주석 스타일이 아니라는 것이다.Given 상황을 준비하고When 행동을 실행하고Then 결과를 검증한다이 구조를 사용하면 테스트가 더 읽기 쉬워지고, 비즈니스 규칙도 더 선명하게 드러난다.특히 도메인 규칙이 중요한 서비스에서는 테스트 코드가 곧 문서가 될 수 있다. 테스트가 실패했을 때도 단순히 “코드가 깨졌다”가 아니라, “어떤 비즈니스 규칙이 깨졌다”에 가깝게 이해할 수 있다.앞으로 테스트를 작성할 때는 단순히 given, when, then 주석을 붙이는 데서 끝내지 않고, 각 단계가 정말 자기 역할을 하고 있는지 확인해봐야겠다. Given에는 필요한 사전 조건만 있는가? When은 하나의 핵심 행동인가? Then은 상태 변경 없이 결과만 검증하는가? 테스트 이름이 비즈니스 규칙을 설명하고 있는가?이 네 가지를 지키는 것만으로도 테스트의 가독성과 유지보수성이 꽤 좋아질 것 같다.참고 Martin Fowler - Given When Then Cucumber - Gherkin Reference" }, { "title": "알바페이 제작기 ep.5", "url": "/posts/%EC%95%8C%EB%B0%94%ED%8E%98%EC%9D%B4-%EC%A0%9C%EC%9E%91%EA%B8%B0-5/", "categories": "Project, Side Project", "tags": "앱인토스, Apps In Toss, Codex, Vibe Coding, 사이드프로젝트", "date": "2026-05-28 07:30:00 +0900", "snippet": "최근 작업을 진행하며 기능 추가보다 먼저 해결해야 할 부분이 눈에 띄었다. 프론트와 백엔드의 역할 구분 없이 하나의 프로젝트 내에서 모두 관리되고 있었고, 환경변수도 특별히 구분 없이 쓰이고 있는 상태였다. 앱인토스 오픈톡방에 물어보니, 리워드 앱 같은 경우가 아니라면 백엔드를 따로 구축하는 게 맞다고 했고, 내 생각도 같아서 그 작업을 추가로 진행하...", "content": "최근 작업을 진행하며 기능 추가보다 먼저 해결해야 할 부분이 눈에 띄었다. 프론트와 백엔드의 역할 구분 없이 하나의 프로젝트 내에서 모두 관리되고 있었고, 환경변수도 특별히 구분 없이 쓰이고 있는 상태였다. 앱인토스 오픈톡방에 물어보니, 리워드 앱 같은 경우가 아니라면 백엔드를 따로 구축하는 게 맞다고 했고, 내 생각도 같아서 그 작업을 추가로 진행하기로 했다.그리고 이전 포스팅에서 에이전트 친화적인 안전장치를 마련해두는 작업을 진행했는데, 그 연장선으로 카카오톡 개발자이신 황민호님(revfactory)의 리포를 참고해서 하네스를 구축하고자 한다. 적용하면서 느끼는 장단점과 트레이드오프도 함께 기록해보자.하네스 엔지니어링 구축하기 설치 방법과 사용 방법은 황민호님의 깃헙 리포를 참고하였다.기존 프로젝트에서 하네스 구축해줘. 시간이 조금 지난 뒤 아래와 같이 4개의 에이전트와 5개의 스킬이 만들어졌다.CLAUDE.md 기존에 있던 CLAUDE.md에 아래 내용이 추가되었다. 내용을 보니 이제부터 신규 기능 개발 등의 작업 시 albapay-feature 스킬을 사용하라고 한다.---## 하네스: 알바페이 기능 개발**목표:** 비즈니스 규칙 분석 → 엣지 케이스 검토 → 사용자 판단 → 구현 → QA를 에이전트 팀으로 자동화한다.**트리거:** 신규 기능 개발, 기능 수정, 구현 재시도 요청 시 `albapay-feature` 스킬을 사용하라. 단순 질문, 문구 수정, 간단한 버그 수정은 직접 응답 가능.**변경 이력:**| 날짜 | 변경 내용 | 대상 | 사유 ||------|----------|------|------|| 2026-05-28 | 초기 구성 | 전체 | 신규 구축 |albapay-feature 스킬(오케스트레이터) 살펴보기 이 스킬 자체를 오케스트레이터라고 보면 된다. 하나의 기능 개발을 함에 있어 직접 스킬을 다 작성하는 것이 아니라, 생성된 네 개의 에이전트(분석가, 리뷰어, 구현자, QA)에게 일을 나눠주고, 완료 상태에 따라 순서를 나누고 일을 다시 시키는 등 조율자의 역할을 한다. md 파일을 확인해보면 각각의 phase에 따라 실행 모드가 잘 구분되어 있고, 에러 핸들링에 대한 규약도 잘 작성되어 있다. 또한 완료 단계를 어떻게 관리하나 궁금했는데, 별도의 작업 디렉토리(_workspace)를 만들어서 그 안에서 각 phase가 완료됐는지를 판단하는 방식을 차용하고 있었다.---name: albapay-featuredescription: | 알바페이 신규 기능 개발 전체 워크플로우를 조율한다. 비즈니스 규칙 분석 → 엣지 케이스 검토 → 사용자 판단 → 구현 → QA의 4단계를 에이전트 팀으로 실행한다. \"기능 개발해줘\", \"새 기능 추가\", \"알바페이에 [기능] 만들어줘\", \"워크플로우 돌려줘\", \"다시 실행\", \"재실행\", \"이전 결과 기반으로 수정\", \"[기능] 구현 재시도\" 요청 시 반드시 이 스킬을 사용한다. CLAUDE.md의 신규 기능 작업 권장 순서를 자동화한다.argument-hint: \"&lt;기능 요청&gt;\"---# 알바페이 기능 개발 오케스트레이터**실행 모드**: 하이브리드 파이프라인 🚨 설명추가 : 이 문서에서는 하나의 기능 개발을 여러 단계로 나누되, 각 단계를 항상 같은 방식으로 실행하지 않고 섞어서 처리한다는 뜻- Phase 1 (분석): `analyst` 서브 에이전트- Phase 2 (검토): `analyst` + `edge-reviewer` 에이전트 팀 🚨 설명추가 : 여러 에이전트가 같은 산출물을 검토하는 구조야. 이 문서에서는 Phase 2에서 `analyst` 결과물을 `edge-reviewer`가 검토- Phase 3 (구현): `implementer` 서브 에이전트- Phase 4 (QA): `qa` 서브 에이전트---## Phase 0: 컨텍스트 확인워크플로우 시작 전 기존 작업물 존재 여부를 확인한다.```_workspace/ 존재 + 부분 수정 요청 → 부분 재실행 (해당 에이전트만 재호출)_workspace/ 존재 + 새 입력 제공 → 새 실행 (_workspace를 _workspace_prev/로 이동)_workspace/ 미존재 → 초기 실행````_workspace/` 디렉토리 확인 후 실행 모드를 결정하고 사용자에게 알린다.---## Phase 1: 도메인 분석 (서브 에이전트 모드)**실행 모드:** 서브 에이전트`analyst` 에이전트를 호출한다. `domain-analysis` 스킬을 사용하도록 지시한다.```Agent( subagent_type: \"general-purpose\", model: \"opus\", prompt: \"analyst.md 역할로 [기능 요청]을 분석하라. domain-analysis 스킬을 참조하여 비즈니스 규칙, 순수함수 인터페이스, 테스트 초안을 _workspace/01_analyst_output.md에 저장하라.\")```산출물: `_workspace/01_analyst_output.md`---## Phase 2: 엣지 케이스 검토 (에이전트 팀 모드)**실행 모드:** 에이전트 팀`analyst`와 `edge-reviewer`가 `_workspace/01_analyst_output.md`를 함께 검토한다.```Agent( subagent_type: \"general-purpose\", model: \"opus\", prompt: \"edge-reviewer.md 역할로 _workspace/01_analyst_output.md를 검토하라. edge-review 스킬의 체크리스트를 적용하여 누락된 엣지 케이스를 발굴하고 _workspace/02_edge_review.md에 저장하라.\")```산출물: `_workspace/02_edge_review.md`---## Phase 2.5: 사용자 판단 중단점Phase 2 완료 후 **반드시 사용자에게 결과를 보고하고 진행 여부를 확인한다.**보고 형식:```## 분석 결과 요약### 비즈니스 규칙[01_analyst_output.md의 규칙 요약]### 발견된 엣지 케이스 (우선순위별)P0: [금전 위험 케이스]P1: [처리 권장 케이스]P2: [정책 명문화 케이스]### 제안된 함수 인터페이스[함수 시그니처]---위 분석으로 구현을 시작할까요?엣지 케이스 중 반영하지 않을 항목이 있으면 알려주세요.```사용자 응답을 받은 후 Phase 3으로 진행한다.---## Phase 3: 구현 (서브 에이전트 모드)**실행 모드:** 서브 에이전트사용자 판단 메모를 포함하여 `implementer` 에이전트를 호출한다.```Agent( subagent_type: \"general-purpose\", model: \"opus\", prompt: \"implementer.md 역할로 구현하라. albapay-impl 스킬을 참조한다. _workspace/01_analyst_output.md의 인터페이스와 _workspace/02_edge_review.md의 엣지 케이스를 반영한다. 사용자 판단: [사용자가 제외한 케이스 또는 '모두 반영']. 구현 완료 후 _workspace/03_impl_summary.md에 변경 파일 목록을 저장하라.\")```산출물: 실제 코드 파일들 + `_workspace/03_impl_summary.md`---## Phase 4: QA 검증 (서브 에이전트 모드)**실행 모드:** 서브 에이전트```Agent( subagent_type: \"general-purpose\", model: \"opus\", prompt: \"qa.md 역할로 검증하라. albapay-test 스킬을 참조한다. 5개 CI 게이트를 모두 실행하고 결과를 _workspace/04_qa_report.md에 저장하라. 경계면 교차 검증(api/ ↔ src/store/)도 수행하라.\")```산출물: `_workspace/04_qa_report.md`---## Phase 4.5: 결과 보고QA 결과를 사용자에게 보고한다.**CI 통과 시:**```## 구현 완료[구현된 기능 요약]CI 게이트: ✅ 5/5 통과변경 파일: [목록]PR 올릴 준비가 됐습니다.```**실패 시:**```## QA 실패[실패한 게이트 + 원인][수정 방향]수정 후 재실행하시겠습니까?```실패 시 implementer 재호출 → QA 재실행 루프를 1회 시도한다. 재실패 시 실패 내용을 보고하고 사용자에게 직접 확인을 요청한다.---## 에러 핸들링| 상황 | 처리 ||------|------|| analyst 산출물 생성 실패 | 1회 재시도, 재실패 시 보고 후 중단 || 사용자 판단 중단점에서 취소 | 산출물 보존, 이후 \"재실행\" 요청 시 Phase 3부터 재개 || QA 실패 | implementer 재호출 1회, 재실패 시 수동 수정 요청 || typecheck 실패 | implementer에게 실패 내용 전달하여 수정 |---## 데이터 전달 프로토콜| 단계 | 전달 방식 | 파일 경로 ||------|---------|---------|| Phase 1 → Phase 2 | 파일 기반 | `_workspace/01_analyst_output.md` || Phase 2 → Phase 2.5 | 직접 요약 보고 | `_workspace/02_edge_review.md` || Phase 2.5 → Phase 3 | 사용자 판단 포함 프롬프트 | - || Phase 3 → Phase 4 | 파일 기반 | `_workspace/03_impl_summary.md` || Phase 4 → 사용자 | 직접 보고 | `_workspace/04_qa_report.md` |---## 테스트 시나리오### 정상 흐름```입력: \"알바생이 이번 달 자신의 총 급여 예상액을 확인할 수 있는 기능\"기대: - Phase 1: 비즈니스 규칙 + 함수 인터페이스 + 테스트 초안 생성 - Phase 2: 엣지 케이스 (0시간, 지급 후 추가 승인 등) 발굴 - 사용자 확인 후 Phase 3: src/lib/ 함수 + src/test/ 테스트 + UI 구현 - Phase 4: 5개 CI 게이트 통과```### 에러 흐름```입력: \"...\" → QA에서 typecheck 실패기대: - implementer 재호출 (실패 내용 포함) - 재구현 후 QA 재실행 - 통과 또는 사용자에게 수동 수정 요청```analyst.md 에이전트 살펴보기 SKILL이 각 에이전트가 임무를 수행할 때 꼭 참고해야 할 전문 지침이라고 보면 되고, 그것에 따라 일을 하는 것이 에이전트다. 페르소나와 역할이 부여된 것을 볼 수 있고, 팀 통신 프로토콜에 대한 내용도 작성되어 있다. 팀 통신 프로토콜: 하네스를 구축하게 되면 오케스트레이터에 의해 여러 에이전트가 상호작용하며 작업을 phase에 따라 진행하게 되는데, 그때 에이전트 간 데이터를 어떻게 주고받아야 하는지에 대한 규약을 나타낸다. ---name: albapay-analystdescription: 알바페이 신규 기능 요청에서 비즈니스 규칙을 분석하고 순수함수 인터페이스와 단위 테스트 초안을 설계하는 에이전트.model: opus---# 알바페이 도메인 분석가## 핵심 역할신규 기능 요청을 받아 비즈니스 규칙을 정리하고, `src/lib/`에 놓일 순수함수의 인터페이스와 단위 테스트 초안을 설계한다.## 작업 원칙1. **도메인 타입 우선 파악**: `src/lib/types.ts`의 Workplace, Worker, Worklog, Payout 타입을 기준으로 함수 시그니처를 설계한다.2. **순수함수 먼저**: DB 호출, API 호출 없이 입력 → 결과만 반환하는 함수로 설계한다. 외부 연동은 별도 레이어의 역할이다.3. **비즈니스 규칙 명문화**: \"언제\", \"어떤 조건에서\", \"어떤 예외가 있는지\"를 테스트 이름으로 표현한다.4. **기존 패턴 따르기**: `src/lib/payroll.ts`, `worklog.ts`, `worker.ts`의 함수 스타일을 참고한다.5. **가정은 명시**: 요청이 모호하면 가정을 명시하고 대안을 함께 제시한다.## 입력- 기능 요청 (자연어)- 관련 기존 코드 (`src/lib/`, `src/store/`, `api/`)## 출력`_workspace/01_analyst_output.md`에 저장:- 비즈니스 규칙 목록 (조건, 예외, 정책)- 순수함수 인터페이스 (함수명, 파라미터, 반환 타입)- 테스트 초안 (한글 테스트 이름 + 입력/기대 출력 명세)- 불확실한 가정 목록## 에러 핸들링- 기존 타입과 충돌이 예상되면 충돌 지점을 명시하고 진행한다.- `domain-analysis` 스킬을 참조하여 분석 절차를 따른다.## 협업- Phase 2에서 `edge-reviewer`와 에이전트 팀을 이뤄 설계를 검토받는다.- 팀 모드에서는 `SendMessage`로 설계 산출물을 edge-reviewer와 공유한다.## 팀 통신 프로토콜- **수신 대상**: 오케스트레이터- **발신 대상**: edge-reviewer (Phase 2 팀 모드에서 검토 요청)- **발신 내용**: `_workspace/01_analyst_output.md` 경로 + 검토 요청 메시지- **이전 산출물 처리**: `_workspace/01_analyst_output.md`가 존재하면 읽고 개선점을 반영한다.나머지 에이전트도 살펴보기implementer 구현 시 순수함수에 대한 내용이 잘 작성되어 있고, 이전에 만들어뒀던 TDS 스킬에 대한 우선 참고 내용도 제대로 들어가 있다. 이 부분은 내가 언급한 적이 없는데, 하네스를 적용해달라 했을 때 첫 번째로 진행했던 프로젝트 구조를 살펴보는 과정에서 AI가 스스로 판단한 내용인 듯하다.---name: albapay-implementerdescription: 확정된 설계를 바탕으로 알바페이의 순수함수, UI 컴포넌트, API route, Supabase 연동을 TypeScript strict 기준으로 구현하는 에이전트.model: opus---# 알바페이 구현자## 핵심 역할analyst + edge-reviewer가 확정한 설계를 코드로 구현한다. 순수함수 → 단위 테스트 → UI → 외부 연동 순서로 진행한다.## 작업 원칙1. **순수함수 먼저**: `src/lib/`에 DB 호출 없는 순수함수를 먼저 구현한다.2. **TypeScript strict 준수**: `as any` 우회 금지. 외부 입력(Supabase row, localStorage)은 mapper를 통과시킨다.3. **TdS 컴포넌트 우선**: UI 구현 시 `tds` 스킬을 참조하고 TdS 컴포넌트를 사용한다. 없을 때만 커스텀 구현한다.4. **외부 연동 마지막**: Supabase 쿼리, API route는 순수함수 구현 후 별도 레이어에서 연결한다.5. **미니멀 구현**: 요청된 것만 구현한다. 추측 기반 기능 추가 금지.6. **기존 패턴 준수**: `src/lib/payroll.ts`, `api/worklogs.ts` 등 기존 코드 스타일을 따른다....qa 이 부분은 이전 포스팅에서 작업해둔 안전장치들을 모두 잘 고려하여 만들어졌다. CLAUDE.md 파일에 개발 후 테스트에 대한 내용이 작성되어 있었기 때문에 그 부분들이 빠지지 않고 잘 들어가 있다.---name: albapay-qadescription: 알바페이 구현 후 5개 CI 게이트(format, lint, typecheck, test, e2e)를 실행하고 결과를 진단하는 에이전트.model: opussubagent_type: general-purpose---# 알바페이 QA 검증자## 핵심 역할구현 완료 후 모든 검증 명령을 실행하고 결과를 진단한다. 실패 원인을 분석하되 직접 수정은 하지 않는다.## 작업 원칙1. **게이트 전체 실행**: 5개 검증 명령을 모두 실행한다. 하나 실패해도 나머지를 계속 실행하여 전체 그림을 파악한다.2. **실패 원인 진단**: 실패 시 에러 메시지와 원인을 분석하고 수정 방향을 제시한다.3. **경계면 교차 검증**: API 응답 shape(`api/` 파일)과 프론트 훅/컨텍스트(`src/store/`)의 타입을 동시에 읽어 불일치를 찾는다.4. **테스트 품질 검증**: 테스트 이름이 한글로 비즈니스 규칙을 표현하는지 확인한다.## 실행 명령 순서```bashnpm run format:checknpm run lintnpm run typechecknpm testnpm run test:e2e```...다크모드 / 라이트모드 기능 추가해보기현재 앱에는 다크/라이트 모드가 적용되어 있지 않다. 토글 버튼 추가와 CSS 변경 정도면 될 것 같은데, 방금 만든 하네스가 잘 동작하는지 테스트해봤다. 시작과 동시에 _workspace 디렉토리를 만든 것을 볼 수 있고, 이미지 하단에 두 개의 에이전트가 실행 중인 것도 확인된다. 프롬프트 창에서 화살표를 사용해 analyst 에이전트로 이동해보면 아래 이미지처럼 작업을 하고 있다. TDS에 있는 토글도 잘 가져와 사용하는지 살펴보자. Phase 2.5에서 확인이 필요한 내용을 묻기 시작했다. 약 14분이 지나고 작업이 완료됐다. 결과 확인 테스트해보니 상단 Tab이랑 몇 가지 UI 요소가 제대로 구현되지 않았다. 다듬어야 할 부분이 남아있다. 작업을 진행하며…오늘 처음 적용해본 하네스였지만, 컨텍스트 사용량이 확실히 체감됐다. 메인에서 오케스트레이터로서 albapay-feature 스킬을 실행하고, 나머지 역할들은 서브 에이전트가 수행하니 컨텍스트 창이 늘어나는 속도가 이전보다 눈에 띄게 느려졌다.추후 다른 기능을 붙이면서 느끼는 점이 또 있겠지만, 에이전트 간 작업 상태를 공유하고 그 결과물에 따라 다음 phase의 에이전트가 임무를 이어받아 작업을 진행하는 흐름이 효율적이라고 느껴졌다." }, { "title": "알바페이 제작기 ep.4", "url": "/posts/%EC%95%8C%EB%B0%94%ED%8E%98%EC%9D%B4-%EC%A0%9C%EC%9E%91%EA%B8%B0-4/", "categories": "Project, Side Project", "tags": "앱인토스, Apps In Toss, Codex, Vibe Coding, 사이드프로젝트", "date": "2026-05-11 07:30:00 +0900", "snippet": "참고 영상 : https://www.youtube.com/watch?v=AuRDl5FGx-s 🤵 : 강사님의 프롬프트는 이 이모티콘을 사용했고요~ 🧠 : AI 응답은 이 이모티콘을 사용했습니다ep.3에 이어서…53분 30초 이후 이어지는 내용을 강사님을 따라 하며 제가 받은 결과물들을 포스팅하려 합니다. 강사님은 이미 이 코드베이스에 대해 어느 ...", "content": "참고 영상 : https://www.youtube.com/watch?v=AuRDl5FGx-s 🤵 : 강사님의 프롬프트는 이 이모티콘을 사용했고요~ 🧠 : AI 응답은 이 이모티콘을 사용했습니다ep.3에 이어서…53분 30초 이후 이어지는 내용을 강사님을 따라 하며 제가 받은 결과물들을 포스팅하려 합니다. 강사님은 이미 이 코드베이스에 대해 어느 정도 이해도가 있으니 바로바로 툭툭 넘어가는데, 코드베이스에 대한 이해가 부족하다면 AI가 해준 답변을 그냥 넘어가지 말고 제대로 다시 한번 물어보고 진행해야 합니다. 클로드에게 마지막 검증을 맡겼으니, 클로드가 참고했던 docs/safe-guard/test-strategy-codex.md는 지우고 다음 작업을 진행합니다. 마지막으로 검증돼 추가된 파일이 docs/safe-guard/test-strategy-claude.md인데 이 파일의 이름을 docs/safe-guard/test-strategy.md로 변경합니다.---title: 안전장치 강화 테스트 전략created: 2026-05-09updated: 2026-05-09---# 안전장치 강화 테스트 전략&gt; `goal.md`와 이 문서만으로 안전장치 강화 작업을 시작할 수 있어야 한다.---## 현재 상태 (2026-05-09 기준)| 항목 | 상태 || ---------------------- | ------------------------------------------------------------------ || 단위 테스트 | **102개 통과** (`npm test`) || E2E 테스트 | 9개 (owner 5 / worker 4), 로컬 실행 가능 || `npm run format:check` | 통과 || `npm run lint` | 통과 (에러 0, 기존 경고 2개) || `npm run typecheck` | 통과 || CI | **추가됨** — `.github/workflows/ci.yml` (PR/push 시 자동 실행) || TypeScript strict | `noUnusedLocals: true` 적용, 나머지 strict 옵션은 단계적 진행 예정 || pre-commit hook | `npx lint-staged` → `prettier --write` (staged files) |**현황 진단**: 1차 안전장치 작업(lint 정리, CI 추가, 비즈니스 규칙 테스트 명문화, mapper 테스트 추가, TypeScript strict 1단계)은 완료됐다. 남은 작업은 TypeScript strict 2~5단계 진행이다.---## 전략 원칙### 원칙 1. 게이트가 먼저다테스트가 있어도 CI와 pre-commit에서 강제되지 않으면 안전장치가 아니다. 검증 명령이 안정적으로 통과하는 상태를 만든 뒤 CI로 자동화한다.### 원칙 2. 단위 테스트는 비즈니스 명세다테스트 이름만 읽어도 이 앱의 업무 규칙이 이해되어야 한다.```ts// 좋은 테스트 이름it(\"승인된 근무일지만 이번 달 급여에 포함한다\");it(\"익일 마감이어도 시작 date 기준 월로 분류한다\");it(\"같은 worker + period에 지급 내역이 있으면 중복 지급을 막는다\");// 피해야 할 이름it(\"filterApprovedWorklogsForPeriod returns filtered array\");it(\"calculatePayForWorker calls calculateTotalHours\");```### 원칙 3. E2E는 smoke test다E2E는 \"사업주/알바생의 핵심 흐름이 브라우저에서 깨지지 않는다\"를 보장하는 수준에 제한한다. 현재 E2E는 Supabase 없이 localStorage 시드 기반으로 실행하며, 이는 DB 통합 검증이 아니다. 급여 계산 edge case, 시간 유효성, 중복 지급 판단은 단위 테스트에서만 검증한다.### 원칙 4. TypeScript strict는 단계적으로 강화한다strict 옵션을 한 번에 켜면 대량 타입 오류가 터지고, 그 수정이 안전장치 작업 자체를 위험하게 만든다. 한 단계씩 올리고, 단계마다 typecheck + test를 통과시킨다.### 원칙 5. pre-commit은 포맷만, ESLint 전체 검사는 CI에서pre-commit에서 `eslint --fix`를 실행하면 partial fix 후 ESLint가 여전히 에러를 남길 때 오류 상태의 파일이 그대로 커밋된다. pre-commit은 `prettier --write`만 실행하고, ESLint는 CI에서 전체를 검사한다.---## 작업 범위### 1차 작업 완료 항목 (2026-05-09)- ✅ lint 실패 해결 (`npm run format` 실행, Prettier 에러 18개 → 0개)- ✅ lint-staged에서 `eslint --fix` 제거 (`prettier --write`만 실행)- ✅ GitHub Actions CI 추가 (`.github/workflows/ci.yml`)- ✅ 비즈니스 규칙 단위 테스트 보강 - `payroll.test.ts`: 시급 정책 테스트 이름 수정, 중복 지급 정책 테스트 추가 - `worker.test.ts`: 최저시급 보정 정책 테스트 추가 - `mappers.test.ts`: 신규 생성 (20개)- ✅ TypeScript strict 1단계: `noUnusedLocals: true` 적용### 남은 작업- TypeScript strict 2~5단계 진행 (별도 PR로)- E2E를 main push CI에 정식 등록 (E2E 안정화 확인 후)### 전체 작업에서 제외- 모든 화면의 E2E 테스트 추가- Supabase 실 DB를 붙인 통합 테스트- DB migration 또는 RLS 재설계- UI 컴포넌트 단위 테스트- 커버리지 퍼센트 목표 강제---## 구현 계획### ✅ Phase 1. lint 초록색으로 만들기 (완료)**수행한 작업**:```bashnpm run format # Prettier 에러 18개 자동 수정````package.json` lint-staged 수정 — `eslint --fix` 제거:```json{ \"lint-staged\": { \"*.{ts,tsx,js,json,css,md}\": [\"prettier --write\"] }}```ESLint는 CI에서만 전체 검사한다. pre-commit은 Prettier 자동 포맷에 집중한다.**결과**: `npm run format:check`, `npm run lint`, `npm run typecheck`, `npm test` 전부 통과.---### ✅ Phase 2. GitHub Actions CI 추가 (완료)`.github/workflows/ci.yml` 생성 완료. PR과 main push마다 자동 실행된다.```yamlname: CIon: pull_request: push: branches: - mainjobs: check: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: node-version: 22 cache: npm - run: npm ci - run: npm run format:check - run: npm run lint - run: npm run typecheck - run: npm test e2e: runs-on: ubuntu-latest if: github.ref == 'refs/heads/main' steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: node-version: 22 cache: npm - run: npm ci - run: npx playwright install --with-deps chromium - run: npm run test:e2e```**E2E CI 전략**: E2E는 PR 필수로 넣지 않는다. main push에서만 별도 job으로 실행한다.이유: E2E는 selector 하나에도 깨질 수 있어서, PR마다 필수로 걸면 개발 속도를 크게 떨어뜨린다.E2E가 충분히 안정화되면 PR job으로 승격한다.**주의**: `playwright.config.ts`의 `webServer` 설정이 `npx vite --port 4173`을 자동 실행하므로, CI에서 별도 빌드 단계는 필요 없다.---### ✅ Phase 3. 비즈니스 규칙 단위 테스트 보강 (완료)**목표**: 금전적으로 위험한 정책을 테스트 명세로 고정하고, DB 연동 취약점인 mapper도 테스트로 잡는다.#### 3-A. 급여 계산 정책 명문화 (`payroll.test.ts`)**수정한 항목**: 기존 테스트 이름 `\"시급 변경은 이미 기록된 근무일지 계산에 영향을 주지 않는다\"` 를 수정했다.`calculatePayForWorker`는 시급을 파라미터로 받으므로, 시급이 바뀌면 결과가 달라지는 것이 정상이다.실제 정책 위험은 다른 곳에 있다: StoreContext가 지급 시점의 `getEffectiveHourlyWage`를 계산해 넘기므로, 시급이 변경되면 같은 period를 다시 계산했을 때 이전 지급금액과 달라질 수 있다. 이 함수 수준에서 해결할 수 없는 문제이므로, 테스트 이름을 현재 정책을 정확히 반영하도록 수정했다.```ts// 수정 전 (오해를 유발하는 이름)it(\"시급 변경은 이미 기록된 근무일지 계산에 영향을 주지 않는다\");// 수정 후 (정책을 정확히 서술)it( \"현재 정책: 급여 계산은 호출 시점에 전달된 시급을 사용하므로, 시급이 바뀌면 같은 기간 재계산 결과가 달라진다\");```**추가한 테스트**:```tsit(\"지급 완료 후 같은 period에 추가 승인된 근무일지가 생겨도 중복 지급 대상으로 본다\");// 현재 정책: workerId + period 단위로만 체크하므로, 지급 후 근무일지가 추가 승인돼도 막힌다```#### 3-B. 시급 결정 정책 명문화 (`worker.test.ts`)**추가한 테스트**:```tsit( \"worker 시급이 최저시급보다 낮아도 계산 함수는 보정하지 않는다 — 최저시급 미만 방어는 UI 입력 단계의 역할이다\");```최저시급 미만 입력은 UI 입력 단계에서 막는 것이 원칙이며, 계산 함수가 조용히 보정하지 않음을 명시한다.#### 3-C. mapper 단위 테스트 신규 추가 (`src/test/mappers.test.ts`)`src/lib/mappers.ts`는 Supabase DB row를 TypeScript 타입으로 변환한다. 컬럼명이 바뀌거나 null 처리가 틀리면 앱 전체가 조용히 깨진다.각 테스트는 실제 Supabase row 모양의 fixture를 입력으로 사용한다:```tsconst rawWorklogRow = { id: \"wl_1\", worker_id: \"worker_1\", workplace_id: \"wp_1\", date: \"2026-05-07\", end_date: null, start_time: \"09:00:00\", // DB는 HH:MM:SS, 앱은 HH:MM end_time: \"18:00:00\", memo: null, rejection_reason: null, status: \"approved\",};```검증하는 핵심 항목:- snake_case → camelCase 변환 정확성- null → undefined 변환 (optional 필드)- null → null 유지 (nullable 필드)- `start_time`/`end_time` HH:MM:SS → HH:MM 슬라이스- `end_date: null`이면 `endDate`를 `date`와 같은 값으로 설정---### ✅ Phase 4. E2E smoke test 안정적 유지 (완료)현재 E2E 9개는 로컬에서 통과한다. 앞으로 유지할 원칙만 명확히 한다.**유지할 사업주 happy path (5개)**:- `/`에서 세션 복원 후 `/owner`로 이동- 사업주 홈에 사업장명과 지급 예정액 표시- 대기 중인 근무일지 승인 가능- 급여 지급 예정액 확인 가능- 급여 지급 후 지급 완료 상태 표시**유지할 알바생 happy path (4개)**:- `/`에서 세션 복원 후 `/worker`로 이동- 알바생 홈에 소속 사업장과 예상 급여 표시- 근무일지 작성 및 제출 가능- 제출한 근무일지의 대기 상태 확인 가능**E2E 작성 규칙**:- role 기반 selector 우선 (`getByRole`, `getByText`)- localStorage seed는 `page.addInitScript`로 React 초기화 전에 주입- Supabase 환경변수 없이 실행 — 외부 DB 상태와 분리 (`playwright.config.ts`의 `webServer` 설정이 자동 처리)- 도메인 edge case(급여 계산, 시간 유효성, 중복 지급)는 E2E에 넣지 않음**로컬 실행**: `npm run test:e2e` 한 줄이면 된다. `playwright.config.ts`가 Supabase 없는 vite 개발 서버를 자동으로 띄운다.---### 🔲 Phase 5. TypeScript strict 점진 강화 (진행 중 — 1단계 완료)**목표**: 타입 시스템이 실제 안전망 역할을 하도록 만든다.**대상 파일**: `tsconfig.app.json` (src/ 코드에 적용되는 config)**진행 순서** (각 단계마다 `npm run typecheck &amp;&amp; npm test` 통과 확인, 별도 commit):| 단계 | 옵션 | 상태 || ---- | ---------------------------- | -------------------- || 1 | `\"noUnusedLocals\": true` | ✅ 완료 || 2 | `\"noUnusedParameters\": true` | 🔲 || 3 | `\"noImplicitAny\": true` | 🔲 변경량 클 수 있음 || 4 | `\"strictNullChecks\": true` | 🔲 변경량 클 수 있음 || 5 | `\"strict\": true` | 🔲 |**각 단계 작업 원칙**:- typecheck 실패한 오류만 최소한으로 수정한다- 주변 코드를 리팩토링하지 않는다- `as any`로 우회하지 않는다 — 타입 오류가 의미 있는 버그일 수 있다- localStorage parse 결과, Supabase row 등 외부 입력은 mapper를 통과시키거나 명시적으로 검증한다---### 🔲 Phase 6. Supabase 통합 검증 (트리거 기반 별도 작업)다음 조건 중 하나가 발생하면 별도 작업으로 진행한다. 현재 필수 범위가 아니다.- Supabase schema 컬럼명이 변경된다- mapper 관련 버그가 실제로 발생한다- 인증/세션 문제가 QA에서 반복된다- RLS 정책이 도입된다검증 방법 후보 (조건 발생 시 선택):- 테스트용 Supabase 프로젝트를 사용한 통합 테스트- 배포 환경변수 검증 스크립트- API route handler 단위 테스트---## 로컬 vs CI 역할 분리| 구간 | 역할 | 명령 || --------------- | --------------------------------- | -------------------------------------- || pre-commit | Prettier 자동 포맷 적용 | `prettier --write` (staged files) || 로컬 개발 | 빠른 단위 검증 | `npm run test:watch` || PR 전 로컬 확인 | 전체 검증 | 아래 5개 명령 || CI (PR/push) | 자동 게이트 | format:check + lint + typecheck + test || E2E | main push 시 자동, 또는 로컬 수동 | `npm run test:e2e` |**PR 전 로컬 확인 명령 (5개 모두 통과해야 CI도 통과)**:```bashnpm run format:checknpm run lintnpm run typechecknpm testnpm run test:e2e```---## 테스트 추가 기준새 기능을 추가할 때 아래 기준으로 테스트 위치를 결정한다.### 단위 테스트를 작성해야 하는 경우- 급여 금액이 달라질 수 있는 로직- 근무 시간 계산이 달라질 수 있는 로직- 승인/반려/대기 상태 판단- 중복 지급 여부 판단- worker와 workplace의 관계 판단- Supabase row → 앱 타입 변환 (mapper)- 날짜, 기간, 월 경계 판단### E2E 테스트를 추가해야 하는 경우- 사용자가 반드시 완료해야 하는 핵심 흐름이 새로 생긴 경우- 라우팅, 세션 복원, 화면 간 이동이 핵심인 기능- 단위 테스트로는 검증하기 어려운 브라우저 상태 변화### 테스트를 추가하지 않아도 되는 경우- 단순 문구 변경- 시각적 간격 조정- shadcn/ui 컴포넌트의 내부 동작- 테스트된 순수 함수를 단순히 화면에 표시하는 얇은 UI 변경---## 위험도별 우선순위### P0. 반드시 자동화해야 하는 영역 (테스트 존재, 유지 필수)- 급여 계산 금액 (`calculatePay`, `calculatePayForWorker`)- 승인된 근무만 급여에 포함하는 규칙 (`filterApprovedWorklogsForPeriod`)- workerId별 근무 분리- period별 지급 분리- 중복 지급 방지 (`isAlreadyPaidForPeriod`)- 익일 마감과 월 경계 처리### P1. 1차 작업에서 보강 완료- 시급 결정 우선순위 명문화 (worker → workplace → 최저시급)- `calculatePayForWorker` 시급 정책 테스트 이름 수정- 지급 후 추가 승인 근무의 중복 지급 정책 명문화- mapper 변환 정확성 (`mappers.test.ts` 신규)- 시간 입력 유효성 (`validateWorklogTime`)- worker 상태 판단### P2. 필요할 때 추가하는 영역- 상세 UI 컴포넌트 동작- 빈 상태 화면 렌더링- Supabase 네트워크 실패 처리- API timeout/retry---## 이 전략이 달성하는 것과 달성하지 못하는 것### 달성하는 것- 급여 계산 로직의 핵심 규칙이 코드가 아닌 테스트로 명문화된다- 코드 수정 시 CI가 자동으로 회귀를 감지한다- Supabase row → 앱 타입 변환 오류를 DB 없이 잡을 수 있다- 브라우저 수준의 기본 사용자 흐름이 깨지지 않음을 smoke test로 보장한다### 달성하지 못하는 것- Supabase 실 DB와의 통합 오류 (RLS, 네트워크, 인증 흐름)- 배포 환경변수 누락 문제- 시급 이력 관리 — 지급 시점 이후 시급이 변경되면 재계산 결과가 달라지는 문제. 이는 `Worklog`에 당시 시급을 저장하는 스키마 변경이 필요한 별도 기능 작업이다코드베이스 보강하기🤵docs/safe-guard/goal.md와 docs/safe-guard/test-strategy.md 문서를 참고해서, 이 프로젝트의 안전장치를 보강하자. 문서 내용을 정확히 파악한 뒤 오차 없이 하나하나 진행해라. context가 모자라지 않도록 적절한 최적화 기법을 사용하며 진행하라. 🤵좋아. 기존에 정의했던 규칙에 맞게 commit도 잘 해두자. 참고로 커밋 메시지는 최근의 것들을 참고해서 알아서 잘 어색하지 않고 잘 어우러지게 작성해줘. 요즘 하네스를 어떻게 만들지 고민을 많이 하는데, 이렇게 안전장치를 여러 개 잘 만들어 두는 게 더 선행되어야 한다. 코드베이스가 안전하지 않은 상태에서 하네스를 깎아도 크게 의미가 없다고 생각한다. 코드베이스의 문제를 과도한 프롬프트나 과도한 검증 루프로 덮어버릴 가능성이 크다. 코드베이스가 더럽다는 이유로 너무 기괴하고 거대한 토큰을 잡아먹는 괴물이 탄생할지도 모른다.🤵좋아. 그럼 이제 완수하지 않은 작업들을 이어서 하자. 적절한 시점에 커밋도 하고, 검증도 잘 해라. 애초에 설계나 전략 문서를 디테일하게 작성해서, 이 작성 시점에 내가 줄 수 있는 의견을 최대한 담아 놓은 상태에서 시작하면, 이후 구현 단계에서는 딱히 검증할 게 없도록 만드는 게 포인트다.클로드에게 검토 요청이전 단계에서 코덱스가 goal.md와 test-strategy.md를 토대로 구현한 결과물이 문서에 명시된 조건을 충족하는지 클로드에게 검토를 요청한다.🤵@docs/safe-guard/goal.md와 @docs/safe-guard/test-strategy.md에 명시된 작업을 진행하였다. 진행했던 내용은 commit history를 참고하면 된다. 이들이 정확히 구현되었으며, 기존 작동에 버그를 만들지는 않았는지 점검을 부탁합니다.코덱스에서 마무리 작업 진행하기🤵좋아. 지금까지의 진행 상황과 남은 작업들에 대한 설명을 별도 문서로 남겨두자. 클로드의 마지막 점검🤵@docs/safe-guard/goal.md 와 @docs/safe-guard/test-strategy.md 에 명시된 작업을 진행하였다. 진행했던 내용은 commit history를 참고하면 된다.이들이 정확히 구현되었으며, 기존 작동에 버그를 만들지는 않았는지 점검을 부탁합니다.신규 PR🤵지금까지 @docs/safe-guard/goal.md, @docs/safe-guard/test-strategy.md에 따라 작업을 해왔다. 신규 브랜치를 만들고, 한 작업에 대한 PR을 생성하자. main에 merge마무리하며…결과물로 4,910줄이 추가되고 1,214줄이 삭제되었다.사실 코드베이스를 조금 안정화하는 첫 번째 작업이 완료된 것이고, 저 정도 라인이 추가되어 main에 머지됐을 때 서비스가 안정적으로 돌아갈 거라 보장할 수는 없다. 몇 가지 안전장치를 더 두어야 한다고 강사님이 말씀하셨는데, 아마 다음 라이브 때 이곳에서 이어서 이를 좀 더 강화하는 영상을 해주실 것 같다. 그때 좀 더 보강하며 기능을 붙여보고자 하고, 오늘 적용한 것들을 활용해서 기능을 계속 이어나가 보겠다. 감사합니다, 강사님~" }, { "title": "알바페이 제작기 ep.3", "url": "/posts/%EC%95%8C%EB%B0%94%ED%8E%98%EC%9D%B4-%EC%A0%9C%EC%9E%91%EA%B8%B0-3/", "categories": "Project, Side Project", "tags": "앱인토스, Apps In Toss, Codex, Vibe Coding, 사이드프로젝트", "date": "2026-05-09 07:30:00 +0900", "snippet": "참고 영상 : https://www.youtube.com/watch?v=AuRDl5FGx-s 🤵 : 강사님의 프롬프트는 이 이모티콘을 사용했고요~ 🧠 : AI 응답은 이 이모티콘을 사용했습니다들어가며회사에 신규 직원이 들어왔을 때 온보딩 프로세스가 필요하듯, 어떤 AI가 내 코드베이스를 보더라도 쉽게 파악하고 작업할 수 있도록 코드를 정돈하는 게 ...", "content": "참고 영상 : https://www.youtube.com/watch?v=AuRDl5FGx-s 🤵 : 강사님의 프롬프트는 이 이모티콘을 사용했고요~ 🧠 : AI 응답은 이 이모티콘을 사용했습니다들어가며회사에 신규 직원이 들어왔을 때 온보딩 프로세스가 필요하듯, 어떤 AI가 내 코드베이스를 보더라도 쉽게 파악하고 작업할 수 있도록 코드를 정돈하는 게 이번 시간의 목표입니다.바이브마피아님의 영상에 따르면, 코드베이스를 정돈하고 검증 도구를 세팅한 뒤 모든 항목이 통과되면 OK가 되는 방식으로 개발을 진행한다고 합니다. 마치 에어백처럼 여러 겹의 안전장치를 미리 마련해두는 것이 이번 작업의 핵심입니다.코드베이스 판단하기🤵이 코드베이스에 대해서, 기능 추가 또는 수정이 되었을때 해당 변경이 정말로 오류없이 잘 구현되었음을 보장하기 위한 안전장치가 어떻게 구현되어있는가? 아무것도 안 되어 있었구나…필수적으로 필요한 코드베이스 검증 도구 code formatter (prettier) linter (eslint) test : unit -&gt; integration -&gt; e2e (vitest) commit lint git hook (=husky)검증도구로서 아래 내가 제시한 것들 중 적용가능한 것들 셋팅 바로 진행해줘. - code formatter (prettier) - linter (eslint) - test (vitest) - commit hook - git hook (=husky) 최소한의 방어책은 마련했다.테스트를 위한 프롬프트 수행🤵e2e 테스트는 너무 무거우니 happy path가 정상작동함을 검증하는 수준으로만 사용하자.그리고, core logic들의 edge case에 대해서는 이 주요 도메인 로직들을 순수 함수들로 분리해낸 뒤에, 이들을 unit test로서 검증하고 싶어. 이 test case가 이 서비스의 기획명세와도 같은 역할을 할 수 있을 것이다. 이를 적용하기 위해 각각의 test유형에 대해 어떤 것들을 cover해야할지 구체화 해보자. 🧠⏺ 도메인 로직과 기존 코드를 파악한 뒤 구체화하겠습니다.⏺ Explore(Codebase domain logic exploration) ⎿ Done (37 tool uses · 72.8k tokens · 1m 10s) (ctrl+o to expand)⏺ 분석 완료. 코드베이스 전체를 파악했으니 테스트 전략을 구체화합니다. --- 테스트 전략 핵심 구조 문제 먼저 StoreContext에 비즈니스 로직이 Supabase 호출과 뒤섞여 있어서 먼저 순수 함수 분리가 선행됩니다. src/lib/ format.ts ← 이미 순수함수 (hoursBetween 등) identity.ts ← 이미 순수함수 (generateInviteCode 등) payroll.ts ← 신규 추출 필요 (급여 계산 로직) worklog.ts ← 신규 추출 필요 (근무일지 검증 로직) worker.ts ← 신규 추출 필요 (worker 상태 로직) --- Unit Test — 추출할 순수함수 + 검증할 케이스 1. payroll.ts — 급여 계산 추출할 함수: calculatePayForWorklogs(worklogs: Worklog[], hourlyWage: number): number filterWorklogsForPeriod(worklogs: Worklog[], period: string): Worklog[] isAlreadyPaidForPeriod(payouts: Payout[], workerId: string, period: string): boolean 테스트 케이스 (= 기획 명세): calculatePayForWorklogs ✓ 승인된 근무만 합산한다 (submitted, rejected 제외) ✓ 4시간 근무 × 시급 10,000원 = 40,000원 ✓ 4.5시간 근무 (09:00~13:30) × 시급 10,000원 = 45,000원 ✓ 익일 마감 근무 (22:00~02:00) = 4시간으로 계산한다 ✓ 근무일지가 없으면 0원을 반환한다 ✓ 모든 근무가 미승인이면 0원을 반환한다 ✓ 시급 변경은 이미 기록된 근무일지에 영향 없다 (파라미터 분리 구조로 보장) filterWorklogsForPeriod ✓ \"2026-05\"를 넘기면 2026년 5월 근무일지만 반환한다 ✓ 익일 마감 근무 (date=\"2026-04-30\", endDate=\"2026-05-01\")는 date 기준으로 분류한다 ✓ 빈 배열이면 빈 배열을 반환한다 isAlreadyPaidForPeriod ✓ 같은 period + 같은 workerId의 payout이 있으면 true ✓ 다른 period면 false ✓ 다른 workerId면 false ✓ payout이 없으면 false --- 2. format.ts — 시간 계산 (이미 일부 존재, 케이스 보강) hoursBetween ✓ 09:00 ~ 13:30 (같은 날) = 4.5시간 ✓ 22:00 ~ 02:00 (익일) = 4시간 ✓ 00:00 ~ 00:00 (자정 ~ 자정, 익일) = 24시간 ✓ 23:30 ~ 00:30 (30분 야간) = 1시간 ✓ start === end (같은 날) = 0시간 ✓ 분 단위 소수점 (08:00 ~ 09:20) = 1.333... (절삭 안 함, 호출자가 반올림) nextYMD ✓ \"2026-01-31\" → \"2026-02-01\" (월말 처리) ✓ \"2026-12-31\" → \"2027-01-01\" (연말 처리) ✓ \"2026-02-28\" → \"2026-03-01\" (평년) ✓ \"2024-02-28\" → \"2024-02-29\" (윤년) --- 3. worklog.ts — 근무일지 검증 추출할 함수: canWorkerSubmitWorklog(worker: Worker): boolean validateWorklogTime(start: string, end: string, isOvernight: boolean): { valid: boolean; error?: string } 테스트 케이스: canWorkerSubmitWorklog ✓ status=\"approved\" + workplaceId 있음 → true ✓ status=\"pending\" → false (승인 전) ✓ workplaceId 없음 → false (사업장 미소속) validateWorklogTime ✓ 09:00 ~ 18:00, isOvernight=false → valid ✓ 09:00 ~ 09:00, isOvernight=false → invalid (\"시작과 종료가 같습니다\") ✓ 09:00 ~ 08:59, isOvernight=false → invalid (\"종료가 시작보다 이릅니다\") ✓ 22:00 ~ 06:00, isOvernight=true → valid ✓ 22:00 ~ 22:00, isOvernight=true → valid (24시간 근무, 허용) --- 4. worker.ts — Worker 상태 전이 추출할 함수: getEffectiveHourlyWage(worker: Worker, workplace: Workplace): number isWorkerPending(worker: Worker): boolean 테스트 케이스: getEffectiveHourlyWage ✓ worker.hourlyWage &gt; 0 이면 worker 시급 사용 ✓ worker.hourlyWage가 없으면 workplace.hourlyWageDefault 사용 ✓ 둘 다 없으면 최저시급 기본값(10,030) 사용 isWorkerPending ✓ status=\"pending\" → true ✓ status=\"approved\" → false --- 5. identity.ts — 코드 생성 generateInviteCode ✓ 항상 6자리를 반환한다 ✓ O, I, 0, 1이 포함되지 않는다 (혼동 방지) ✓ 대문자와 숫자(2-9)로만 구성된다 ✓ 같은 코드가 연속 생성되지 않는다 (랜덤성 확인) --- E2E Test — Happy Path만 (Playwright 또는 Vitest + MSW) E2E는 \"이 앱이 기본적으로 동작한다\"를 보장하는 수준. 각 역할당 핵심 플로우 1개씩. Owner 플로우 시나리오: 사업주가 알바생 급여를 지급한다 1. 로그인 (사업자번호 + 비밀번호) 2. 홈에서 대기 중인 근무일지 확인 3. 근무일지 승인 4. 급여 탭에서 지급 예정액 확인 5. 지급 완료 처리 → 기대: 지급 내역에 기록 생성됨 Worker 플로우 시나리오: 알바생이 근무일지를 제출한다 1. 초대코드로 사업장 합류 2. (Owner가 승인 - 별도 트리거) 3. 근무일지 작성 (날짜, 09:00~18:00) 4. 제출 → 기대: 홈에서 예상 급여가 계산되어 보임 --- 요약 — 어디부터 시작할까 ┌──────┬───────────────────────────────┬──────────────────────────────┐ │ 순서 │ 작업 │ 이유 │ ├──────┼───────────────────────────────┼──────────────────────────────┤ │ 1 │ payroll.ts 추출 + 유닛 테스트 │ 금전 계산 오류가 가장 치명적 │ ├──────┼───────────────────────────────┼──────────────────────────────┤ │ 2 │ format.ts 케이스 보강 │ 이미 있는 인프라, 비용 낮음 │ ├──────┼───────────────────────────────┼──────────────────────────────┤ │ 3 │ worklog.ts + worker.ts 추출 │ 상태 로직 명세화 │ ├──────┼───────────────────────────────┼──────────────────────────────┤ │ 4 │ E2E happy path │ 가장 나중 (인프라 필요) │ └──────┴───────────────────────────────┴──────────────────────────────┘ 1번부터 바로 시작할까요?시간이 꽤 걸리긴 했지만, 아래와 같이 모두 성공했다. 이어서 진행해보자.최종 목적 문서화 &amp; 교차검증을 위한 문서화🤵지금까지 구체화한 내용을 `/docs` 경로에 적절한 이름의 마크다운 파일로 저장하자. 이 파일에는 구체적인 결정사항을 포함해야하며, 왜 그러한 결정을 하였는지 너와 나의 의도가 정확히 담길 수 있도록 작성해야한다. 작성자(=Claude), 작성시점과 같은 frontmatter도 포함해라. 아 잠깐만, 이렇게 하자. `/docs/safe-guard`라는 폴더를 만들고, 그 하위에 내가 이 작업을 하는 맥락 및 이유를 `goal.md`문서로 저장하고, 너가 결정한 작업 범위나 테스트 구현 방식등은 `test-strategy-claude.md`문서로 정리하자. 생성된 goal.md 클로드로 작성된 문서를 이후에 코덱스로 교차 검증할 건데, 순수 내가 지금 작업하고 있는 목적(배경)은 AI의 개입 없이 오로지 사람인 나의 의도만 반영되어 있어야 한다. 이 말의 뜻은, 내가 프롬프트로 위와 같이 명령했기 때문에 goal.md에 내가 지금 작업을 진행하고자 밝혔던 나의 의도 외에 AI의 의견이 반영되었을 수도 있다는 것. 이 점은 추후 코덱스가 “사람(나)이 이런 의도를 갖고 있었구나? 짝짝짝…” 하며 클로드한테 맞장구만 치는 상황이 발생할 수 있다. 그래서 AI가 추가해준 내용을 모두 삭제하고 순수 내가 직접 말했던 이 작업의 의도만 goal.md에 남긴다. 어떤 문서를 작성하든 AI가 작성한 문서는 곧이곧대로 사용하면 안 된다! 순수 나의 의견과 AI가 부풀려준 의견을 명확히 구분할 줄 알아야 한다. 수정 전 / 후---title: 안전장치 도입 목표 및 배경author: chankim (프로젝트 오너)created: 2026-05-09---# 안전장치 도입 목표 및 배경## 왜 시작했는가알바페이는 사이드 프로젝트로 빠르게 기능을 추가해오고 있다.초기에는 \"일단 돌아가면 된다\"는 기조로 진행했고, 덕분에 개발 속도는 빨랐다.하지만 시간이 지날수록 **기능을 추가하거나 수정했을 때 기존 동작이 깨지지 않는다는 보장이 전혀 없다**는 문제가 명확해졌다.특히 이 앱은 급여 계산이라는 금전적으로 민감한 도메인을 다루기 때문에, 로직 오류가 실제 사용자의 급여에 영향을 줄 수 있다.진단 결과 확인된 현황은 다음과 같았다:- TypeScript는 켜져 있지만 `strict: false`로 사실상 형식적- ESLint는 `no-unused-vars`도 꺼진 느슨한 상태- Vitest 인프라는 있지만 실질적인 테스트는 `example.test.ts` 1개- CI/CD 파이프라인 없음 (`.github/` 폴더 자체가 없음)- Pre-commit hook 없음- **유일한 안전장치는 개발자의 수동 확인**## 무엇을 원했는가단순히 \"테스트를 추가하자\"가 아니라, 아래 두 가지 목표를 함께 달성하고 싶었다.### 1. 기능 변경 시 회귀를 막는 자동화된 안전망코드를 수정했을 때 \"이게 제대로 동작하는가?\"를 사람이 매번 확인하지 않아도 되는 구조.commit 또는 CI 단계에서 자동으로 잡아줘야 한다.### 2. 테스트가 기획 명세 역할을 하도록테스트 코드를 보면 이 서비스가 어떻게 동작해야 하는지 알 수 있어야 한다.특히 급여 계산 로직처럼 \"익일 마감 근무는 date 기준으로 분류한다\", \"승인된 근무만 급여에 반영한다\"같은 비즈니스 규칙이 코드 주석이 아닌 테스트로 명문화되어야 한다.## 범위 결정의 원칙이 작업은 사이드 프로젝트이므로, 과도한 테스트 인프라로 개발 속도를 희생하지 않는 균형이 중요했다.| 유형 | 범위 결정 ||---|---|| E2E 테스트 | Happy path만. \"이 앱이 기본적으로 동작한다\"를 보장하는 수준 || Unit 테스트 | 비즈니스 로직의 edge case를 전부 커버. 기획 명세 역할 || 포매팅/린팅 | commit 시 자동 적용. 코드 리뷰 비용 절감 |E2E를 heavy하게 가져가지 않은 이유는, 이 앱의 핵심 위험은 UI 상호작용보다 **급여 계산, 근무 시간 산정, 지급 중복 방지 같은 순수 로직**에 있기 때문이다. 이 로직들은 unit test로 훨씬 빠르고 안정적으로 검증할 수 있다.---title: 안전장치 도입 목표 및 배경author: chankim (프로젝트 오너)created: 2026-05-09---# 안전장치 도입 목표 및 배경## 왜 시작했는가알바페이는 사이드 프로젝트로 빠르게 기능을 추가해오고 있다.앱인토스에 출시하기까지 \"일단 돌아가면 된다\"는 기조로 진행했고, 덕분에 개발은 빠르게 진행할 수 있었다.하지만 앞으로 서비스를 고도화할 생각을 하니 본 서비스에 **기능을 추가하거나 수정했을 때 기존 동작이 깨지지 않는다는 보장이 전혀 없다**는 의심이 들었다.이 앱은 급여 계산이라는 금전적으로 민감한 도메인을 다루기 때문에, 로직 오류가 실제 사용자의 급여에 영향을 줄 수 있다.이 문제가 심각해지기 전에 조치를 취해야겠다고 판단하여 본 작업을 시작했다.## 현황 진단작업을 시작하기 전 진단 결과는 다음과 같았다:부연 설명 : 러버블로 프로토타입을 받고, 바로 바이브코딩을 시작하면서 아래의 것들을 고려하지 않고 기능구현에만 몰두하여 진행하였고, 이에 아래와 같은 여러가지 빈틈이 발생하였다.- TypeScript는 켜져 있지만 `strict: false`로 사실상 형식적- ESLint는 `no-unused-vars`도 꺼진 느슨한 상태- Vitest 인프라는 있지만 실질적인 테스트는 `example.test.ts` 1개- CI/CD 파이프라인 없음 (`.github/` 폴더 자체가 없음)- Pre-commit hook 없음## 무엇을 원했는가### 1. 기능 변경 시 회귀를 막는 자동화된 안전망코드를 수정했을 때 \"이게 제대로 동작하는가?\"를 사람이 매번 확인하지 않아도 되는 구조.commit 또는 CI 단계에서 자동으로 잡아줘야 한다.### 2. 테스트가 기획 명세 역할을 하도록이전까지 테스트 코드가 작성되지 않았지만 추후엔 TDD를 적용하고 싶다.또한 테스트 코드를 보면 이 서비스가 어떻게 동작해야 하는지 알 수 있어야 한다.특히 정말 중요한 급여 계산 로직처럼 \"익일 마감 근무는 date 기준으로 분류한다\", \"승인된 근무만 급여에 반영한다\"같은 비즈니스 규칙이 코드 주석이 아닌 테스트로 명문화되어야 한다.## 범위 결정의 원칙| 유형 | 범위 결정 || ----------- | ----------------------------------------------------------- || E2E 테스트 | Happy path만. \"이 앱이 기본적으로 동작한다\"를 보장하는 수준 || Unit 테스트 | 비즈니스 로직의 edge case를 전부 커버. 기획 명세 역할 || 포매팅/린팅 | commit 시 자동 적용. 코드 리뷰 비용 절감 |생성된 test-strategy-claude.md 내가 클로드와 대화를 나누며 구현하기로 했던 결정사항들이 문서로 만들어졌다.---title: 테스트 전략 및 구현 결정사항author: Claude (claude-sonnet-4-6)created: 2026-05-09---# 테스트 전략 및 구현 결정사항## 도구 선택### 포맷터 / 린터 / 커밋 훅| 도구 | 선택 이유 ||---|---|| **Prettier** | 코드 스타일을 의견 없이 통일. 리뷰에서 포맷 논쟁 제거 || **ESLint + eslint-config-prettier** | 기존 ESLint를 유지하되 Prettier와 충돌 제거 || **Husky + lint-staged** | commit 시 변경된 파일만 포맷+린트 자동 적용. 전체 파일 실행 대비 속도 확보 |commit 훅에서 테스트를 실행하지 않은 이유: 단위 테스트 80개가 현재 700ms 수준이지만, 향후 테스트가 늘어나면 commit 체감 속도에 영향을 준다. 테스트는 CI에서 돌리는 것이 맞다는 판단.### 단위 테스트**Vitest**를 그대로 유지했다. 이미 설치되어 있었고, Vite 기반 프로젝트에서 별도 설정 없이 `import.meta.env`와 path alias를 처리할 수 있다.### E2E 테스트**Playwright**를 선택했다. 이유:- Vitest + Testing Library는 DOM 단위 테스트에 적합하지만, 실제 라우팅(React Router)과 localStorage 세션 복원 흐름을 검증하기 어렵다.- Playwright는 실제 브라우저를 구동하므로 세션 복원 → 자동 redirect → 페이지 렌더링 전 과정을 한 번에 검증할 수 있다.---## 순수함수 추출 결정### 왜 추출이 먼저였나기존 `StoreContext.tsx`는 Supabase 호출, React 상태 관리, 비즈니스 로직이 혼재되어 있었다. 이 상태에서는 비즈니스 로직만 독립적으로 테스트할 수 없다. 순수함수로 분리하는 것이 테스트 작성보다 선행되어야 하는 이유다.### 추출된 파일과 책임```src/lib/ payroll.ts — 급여 계산 (필터링, 합산, 원 단위 변환, 중복 지급 체크) worklog.ts — 근무일지 제출 가능 여부 판단, 시간 유효성 검증 worker.ts — Worker 상태 판단, 유효 시급 결정````format.ts`는 이미 순수함수였으므로 추출 없이 테스트만 보강했다.### StoreContext의 변화추출 전:```typescript// StoreContext 안에 중복 로직이 두 곳에 산재const hours = worklogs .filter((wl) =&gt; wl.workerId === workerId &amp;&amp; wl.status === \"approved\" &amp;&amp; wl.date.startsWith(period)) .reduce((acc, wl) =&gt; acc + hoursBetween(...), 0);```추출 후:```typescriptconst { hours, amount } = calculatePayForWorker(worklogs, workerId, wage, period);````payoutForWorkerThisPeriod`와 `payWorker` 두 곳에 흩어진 동일 계산 로직이 하나의 함수로 통합되었다.---## 단위 테스트 설계 원칙### 테스트가 명세다테스트 케이스의 설명문(it 문자열)은 비개발자도 읽을 수 있는 수준으로 작성했다.```typescriptit(\"익일 마감 근무를 정확히 계산한다 (22:00 ~ 02:00 = 4시간)\")it(\"다른 worker의 근무는 합산에 포함하지 않는다\")it(\"중복 지급을 방지한다 — 같은 worker의 이전 달 지급은 이번 달에 영향 없다\")```이 문장들이 곧 비즈니스 규칙의 명문화다.### Fixture 패턴각 테스트 파일에 `makeWorklog`, `makeWorker`, `makePayout` 헬퍼를 두었다. 기본값을 가지면서 `overrides`로 테스트별 관심사만 지정하는 패턴이다.```typescriptconst makeWorklog = (overrides: Partial&lt;Worklog&gt;): Worklog =&gt; ({ id: \"wl_test\", workerId: \"worker_1\", status: \"approved\", // ...기본값 ...overrides,});```이렇게 하면 테스트 코드에서 \"이 테스트가 관심을 갖는 필드\"만 명시적으로 드러난다.### 커버리지 방향테스트가 없던 상태에서 시작했으므로, 가장 치명적인 영역부터 우선순위를 잡았다:1. **급여 계산** — 금전 오류는 가장 치명적. workerId 필터, 월 경계, 익일 마감 모두 포함.2. **시간 계산** — 익일 마감, 월말/연말 경계를 명시적으로 커버.3. **근무일지 검증** — 제출 가능 조건, 시간 유효성.4. **Worker 상태** — 시급 우선순위 결정 로직.---## E2E 테스트 설계 결정사항### Supabase 비활성화 전략**가장 중요한 설계 결정이다.**E2E 테스트에서 Supabase가 활성화된 상태로 실행하면, 앱이 실제 Supabase에서 데이터를 fetch해 테스트에서 주입한 localStorage 픽스처를 덮어쓴다. 이로 인해 테스트가 개발 환경의 실제 DB 상태에 의존하게 된다.해결 방법: E2E 전용 서버(port 4173)를 Supabase 환경변수 없이 실행.```typescript// playwright.config.tswebServer: { command: \"VITE_SUPABASE_URL='' VITE_SUPABASE_ANON_KEY='' npx vite --port 4173\", reuseExistingServer: false, // 항상 신선한 서버 사용}````reuseExistingServer: false`로 설정한 이유는, 개발 서버(8080)를 재사용하면 Supabase가 활성화된 상태가 섞일 수 있기 때문이다.### localStorage 시드 전략`page.addInitScript(fn, arg)`를 사용해 React 앱이 초기화되기 전 localStorage를 주입한다. 직렬화된 JSON 문자열로 넘기는 이유는 TypeScript 타입(`satisfies` 등)이 런타임에 존재하지 않아 직접 객체를 넘길 때 발생할 수 있는 직렬화 문제를 방지하기 위해서다.```typescriptawait page.addInitScript( ({ store, session, device }) =&gt; { localStorage.setItem(\"albapay_store_v2\", store); // 이미 JSON.stringify된 문자열 localStorage.setItem(\"albapay_current_user\", session); localStorage.setItem(\"albapay_device_id\", device); localStorage.removeItem(\"albapay_logged_out\"); }, { store: JSON.stringify(fixtures.store), session: JSON.stringify(fixtures.owner.currentUser), ... });```### 세션 복원 흐름 처리앱은 `loadState === \"loading\"` 동안 스피너를 보여주고, 세션 복원 후 `/` → `/owner` (또는 `/worker`)로 자동 redirect한다. 따라서 E2E 테스트는 직접 `/owner`로 navigate하는 대신, `/`에서 시작해 redirect를 기다린다.```typescriptasync function gotoOwnerHome(page) { await page.goto(\"/\"); await page.waitForURL(\"**/owner\", { timeout: 8000 });}```---## 리팩토링 과정에서 발견된 버그순수함수 추출 과정에서 `payoutForWorkerThisPeriod`에 **workerId 필터가 누락되는 버그**를 직접 도입했고, 이를 발견해 수정했다.원래 StoreContext:```typescriptworklogs.filter((wl) =&gt; wl.workerId === workerId &amp;&amp; ...)```리팩토링 후 최초 버전 (버그):```typescript// workerId 필터 없이 전체 근무 합산filterApprovedWorklogsForPeriod(worklogs, period)```수정 후:```typescriptfilterApprovedWorklogsForPeriod(worklogs, period).filter((wl) =&gt; wl.workerId === workerId)```이 버그를 잡은 것은 \"다른 worker의 근무는 합산에 포함하지 않는다\" 테스트 케이스였다. 테스트가 없었다면 멀티 worker 환경에서 급여가 잘못 계산되는 버그가 실제 서비스에 배포될 수 있었다. 순수함수 추출 + 테스트의 가치를 직접 보여주는 사례다.---## 테스트 현황 요약```Unit Tests (Vitest) 80개 ~700ms payroll.test.ts 29개 급여 계산 기획 명세 format.test.ts 25개 시간/날짜 유틸 엣지케이스 worklog.test.ts 13개 근무일지 검증 규칙 worker.test.ts 8개 Worker 상태 로직 example.test.ts 5개 인프라 확인용E2E Tests (Playwright) 9개 ~6s owner-happy-path.spec.ts 5개 사업주 핵심 플로우 worker-happy-path.spec.ts 4개 알바생 핵심 플로우```## 남은 과제- `src/lib/identity.ts`의 `generateInviteCode` 테스트 (O, I, 0, 1 제외 검증)- GitHub Actions CI 파이프라인 추가 (PR 시 unit test + typecheck 자동 실행)- TypeScript `strict: true` 활성화 (현재 `strict: false`)Codex에게 검증해달라고 해보기 한쪽에 코덱스를 같이 켰고, 프롬프트의 목적은 클로드랑 이런 목적으로 계획까지 짰는데, 최대한 코덱스 네가 깐깐하게 검토해줘라… 하는 것. 🤵내가 이 프로젝트에 대해 안전장치를 강화하는 작업을 하려해. 가장 먼저 나의 의도는 docs/safe-guard/goal.md를 읽어서 파악해줘. 이 작업에 대해 claude가 docs/safe-guard/test-strategy-claude.md와 같이 계획 및 전략을 세워줬거든? 근데 난 이게 정말 합리적이면서도 내 코드베이스를 안전하게 만들어줄만한 신뢰도 높은, 전문적인 전략일지 의심돼. 너무 과하게 작성해서 현실성이 없거나, 아니면 너무 부족해서 이걸 마쳐도 내 코드베이스가 딱히 안전해지지 않을까봐 걱정이야. claude의 계획은 최대한 비판적으로 검토하고, 개선해야할만한 부분을 정리해서 일단 내게 한번 알려줘. 그 후 티키타카하면서 최선의 방향성이 무엇일지 논의해보자. 코덱스의 검증 결과 🧠 • 검토 결론: Claude의 방향은 꽤 합리적입니다. 특히 StoreContext에서 급여/근무일지 판단 로직을 순수 함수로 빼고 Vitest로 명세화한 건 이 프로젝트 목표와 잘 맞습니다. 다만 \"이 정도면 안전장치가 생겼다\"고 보기엔 아직 부족합니다. 핵심 결함은 테스트 전략보다 게이트가 실제로 막아주는 구조가 아직 완성되지 않았다는 점입니다. 가장 중요한 지적 1. CI가 없어서 자동 안전망이 아직 없다 .github/가 없습니다. 즉 PR/merge 시 test, typecheck, lint가 강제되지 않습니다. 목표 문서의 \"commit 또는 CI 단계에서 자동으로 잡아줘야 한다\" 기준에는 아직 미달입니다. 2. 현재 npm run lint가 실패합니다 Prettier 오류 16개로 lint가 깨집니다. Unit test 80개와 E2E 9개는 통과했지만, lint 게이트는 아직 작동 가능한 상태가 아닙니다. 이 상태로 CI를 추가하면 바로 빨간불입니 다. 3. TypeScript 안전망은 여전히 약합니다 tsconfig.app.json:1에 strict: false, noImplicitAny: false, noUnusedLocals: false가 남아 있습니다. npm run typecheck는 통과하지만, 현재 설정상 \"많은 실수를 잡지 않도록 설정된 typecheck가 통과한 것\"에 가깝습니다. 4. E2E의 Supabase 비활성화 전략은 현실적이지만 한계가 큽니다 localStorage 시드 기반 E2E는 빠르고 안정적인 smoke test로 좋습니다. 하지만 실제 Supabase read/write, mapper, API 응답 shape, 인 증/세션/배포 환경 문제는 거의 못 잡습니다. 따라서 Claude 문서의 E2E는 \"앱 기본 화면과 주요 흐름이 깨지지 않는다\" 수준이지, \"프로덕션 통합이 안전하다\" 수준은 아닙니다. 5. 급여 도메인 테스트 방향은 좋지만, 아직 빠진 고위험 규칙이 있습니다 현재 payroll, worklog, worker, format 테스트는 좋은 출발입니다. 다만 금전 도메인이라면 추가로 봐야 할 영역이 있습니다: - 최저시급 미만 입력/표시/계산 정책 - 시급 변경 시 과거 근무분을 어떤 시급으로 계산할지 - 지급 후 근무일지 수정/승인 취소 가능 여부 - 월 경계, 한국 시간 기준 날짜 문제 - Supabase 저장값과 localStorage 타입 변환 차이 - 중복 지급 방지를 클라이언트 로직만으로 볼지, DB constraint까지 둘지 제 판단은 이렇습니다. Claude 계획은 과하지 않습니다. 오히려 현재 프로젝트 상태와 금전 도메인을 생각하면 단위 테스트 + happy path E2E는 적절한 최소선입니 다. 하지만 부족한 부분이 명확합니다. 특히 CI, lint 통과 상태, strict 점진 활성화, Supabase 통합 검증이 빠져 있어서 \"끝내면 안전해진 다\"보다는 \"이제 안전장치의 1층을 깔았다\"가 정확합니다. 내가 추천하는 방향성은: 1. 먼저 현재 변경분을 정리해서 format:check, lint, typecheck, test가 모두 통과하게 만든다. 2. 바로 GitHub Actions CI를 추가해서 PR마다 npm ci, format:check, lint, typecheck, test를 강제한다. 3. E2E는 지금처럼 happy path만 유지하되, CI에서는 비용을 보고 test:e2e를 PR 필수 또는 수동/주요 브랜치 필수로 정한다. 4. TypeScript strict: true는 한 번에 켜기보다 noImplicitAny, strictNullChecks 순서로 점진 적용한다. 5. 급여/지급/승인/시급 변경 정책을 먼저 문서로 확정하고, 그 정책만 unit test로 추가한다. 검증 결과: npm test 통과, npm run typecheck 통과, npm run test:e2e 통과. npm run lint는 Prettier 오류로 실패했습니다. 좋아. 그럼 너의 의견을 전부 반영한 최종문서를 `test-strategy-codex.md`로 작성해줘. 피드백 내용만 작성하지 말고, `goal.md`와 새로 생성될 테스트 전략 문서만으로도, 안전장치 강화 작업을 시작할 수 있을만큼 구체적이고 빠지는 내용 없이 작성해줘. 작업이 완료되면 처음에 클로드가 만들어준 docs/safe-guard/test-strategy-claude.md 파일은 더 이상 필요가 없다. 삭제! → 코덱스가 이를 모두 보완한 파일을 방금 만들어줬기 때문. 코덱스가 만든 문서를 다시 클로드에게 검증해달라고 하기 새로운 세션으로 진행하기 위해 /clear 수행 코덱스의 결과물이 걱정되니, 클로드 네가 다시 검증해줘 하는 게 목적🤵내가 이 프로젝트에 대해 안전장치를 강화하는 작업을 하려해. 가장 먼저 나의 의도는 `docs/safe-guard/goal.md`를 읽어서 파악해줘.이 작업에 대해 codex가 `docs/safe-guard/test-strategy-codex.md`와 같이 계획 및 전략을 세워줬거든? 근데 난 이게 정말 합리적이면서도 내 코드베이스를 안전하게 만들어줄만한 신뢰도 높은, 전문적인 전략일지 의심돼. 너무 과하게 작성해서 현실성이 없거나, 아니면 너무 부족해서 이걸 마쳐도 내 코드베이스가 딱히 안전해지지 않을까봐 걱정이야.codex의 계획은 최대한 비판적으로 검토하고, 개선해야할만한 부분을 정리해서 일단 내게 한번 알려줘. 그 후 티키타카하면서 최선의 방향성이 무엇일지 논의해보자.---좋아. 그럼 너의 의견을 전부 반영한 최종문서를 `test-strategy-claude.md`로 작성해줘. 피드백 내용만 작성하지 말고, `goal.md`와 새로 생성될 테스트 전략 문서만으로도, 안전장치 강화 작업을 시작할 수 있을만큼 구체적이고 빠지는 내용 없이 작성해줘. 결과물 확인 🤵좋아. 일단 너의 권장 수정 방향을 수용할게. 나와 논의해야할만한 부분을 쉽게 하나하나 설명해줘. 매번 이렇게 멀티 에이전트 턴으로 해야만 하는가? 는 잘 모르겠고, 다만 에이전트가 코드 수정을 쉽게쉽게 할 수 있도록 안전장치를 코드베이스에 잘 해놔야 한다고 강사님이 말씀하셨다. 전부 추천대로 해줘. 보강🤵해당 문서가 피드백 내용만 작성하지 말고, `goal.md`와 새로 생성될 테스트 전략 문서만으로도, 안전장치 강화 작업을 시작할 수 있을만큼 구체적이고 빠지는 내용 없이 구체적으로 작성되었는가? 클로드에게 코덱스가 만들어준 문서를 최종적으로 나의 목적에 맞게 작성되었는지 물어보았다. 클로드의 의견은 두 가지 문제가 있었고, 이를 자체적으로 수정해줬다.정리53분 30초까지 보고 포스팅을 다음 편으로 나눠보려 한다. 내용이 길어지는 것 같아서.일단 여기까지, goal.md에 맞는 test-strategy를 클로드와 코덱스를 번갈아 활용하며 재차 검증하고 보강해 나갔다. 영상 속 강사님은 이미 코드베이스가 어느 정도 다져진 상태라 내가 AI에게 받은 답변과 다른 부분이 있긴 하지만, 큰 틀에서의 목적은 같다. 이번에 코드베이스를 위한 에어백을 잘 만들어두면 앞으로 에이전틱 코딩을 조금 더 수월하게 할 수 있을 것 같다는 생각이 든다. 남은 내용은 다음 편에서 마저 정리하겠습니다!" }, { "title": "알바페이 제작기 ep.2", "url": "/posts/%EC%95%8C%EB%B0%94%ED%8E%98%EC%9D%B4-%EC%A0%9C%EC%9E%91%EA%B8%B0-2/", "categories": "Project, Side Project", "tags": "앱인토스, Apps In Toss, Codex, Vibe Coding, 사이드프로젝트", "date": "2026-05-08 07:30:00 +0900", "snippet": "출시까지 마무리가 되었다출시하면서…ep.1에서 여러 기능을 붙이며 작업을 진행하고 있었는데, 진행이 조금 미뤄진 이유가 있었다. 이번 달부터 청년수당을 받기 시작해서 사업자를 낼 수 없었고, 초기 기획이었던 토스 로그인 연동이나 광고 수익 발생 등의 기능을 구현하기 어렵게 됐다. 그래서 일단 구현하고 배포하는 방향으로 전환했고, 어제와 그제 작업 후에...", "content": "출시까지 마무리가 되었다출시하면서…ep.1에서 여러 기능을 붙이며 작업을 진행하고 있었는데, 진행이 조금 미뤄진 이유가 있었다. 이번 달부터 청년수당을 받기 시작해서 사업자를 낼 수 없었고, 초기 기획이었던 토스 로그인 연동이나 광고 수익 발생 등의 기능을 구현하기 어렵게 됐다. 그래서 일단 구현하고 배포하는 방향으로 전환했고, 어제와 그제 작업 후에 배포를 마칠 수 있었다.배포하는 과정에서 느낀 것생각보다 바이브 코딩만으로 배포하는 게 너무 편리하게 되어 있어서 특별히 어려운 점이 없었다. AI와 여러 방향을 고민하며 나눴던 대화들이 기획에 반영되고 나니 배포까지 굉장히 수월했고, 위의 캡처처럼 검토 단계에서 내 휴대폰으로 직접 테스트도 해볼 수 있었다. 다시금 토스가 정말 대단한 회사라는 생각이 들었던 순간이었다.이후 작업할 것들빠른 배포를 목적으로 했기 때문에 다양한 케이스를 테스트해보지 못했다. 바이브 코딩을 하다 보니 막상 배포하고 나서야 “아! 맞다!” 싶은 것들이 많이 떠올랐다. 이제부터 그런 작업들을 Phase를 나눠 하나씩 구현해볼 생각이다.앱인토스를 보니 사장님들을 위한 이런 앱들이 여러 개 있었는데, 벤치마킹할 수 있는 것들은 최대한 참고하되, 무엇보다 고용주 연령대와 상황을 고려해서 가장 편한 UI/UX를 제공하는 것에 초점을 맞춰 진행할 예정이다. 물론 혼자 할 수 없으니 현재 구독 중인 Claude와 Codex를 적극 이용할 예정이다.어차피 취업을 다시 하거나 청년수당을 받는 앞으로의 5개월 동안은 이 앱으로 수익화를 하기 어려울 것 같아서, 본 프로젝트의 주 목적을 포트폴리오로 삼고 일했던 가게 사장님에게 적극적인 피드백을 구해볼 예정이다. 그러고 나서 현재 가입해 있는 앱인토스 오픈톡방에 홍보를 하거나, 주변 가게 사장님들에게 사용 사례를 소개해서 올해 말에는 우리 동네 가게 10군데 정도가 이 앱을 사용하면 좋겠다는 생각도 가지고 있다.지금 당장 생각나는 개선점들기능 관련 알바생의 경우 최초 진입 단계에서 근무 중인 가게를 선택할 수 있도록 구현 초대 코드로 근무지에 알바생을 추가하는 방식이므로, 고용주 화면에서 근무자 추가 기능 삭제 초대 코드를 받은 근무자가 본인 휴대폰으로 로그인해야 근무지와 매핑이 됨 근무자 추가 / 근무 일지 등록 / 승인 및 반려 시 각자에게 푸시 알림 발송 문서 관리 (근로계약서 / 보건증)편의 관련 고용주분들이 대체로 연령대가 높은 경우가 많아, 글꼴 크기 변경 기능 추가 다크모드 / 라이트모드 토글 기능 앱 새로고침 없이도 DB에 반영된 변경 사항을 화면에 바로 표시할 수 있도록 API 호출 방식 변경 앱 전반에 TDS 적용 TDS 관련 스킬을 별도로 만들어볼 예정 Phase 잘 나누기일단 MVP는 구현이 된 것 같다. 자동 이체나 광고 수입 발생 등 아직 구현되지 않은 부분이 있지만, 위의 개선점들을 차차 반영하고 사용해보면서 에이전틱 코딩으로 이 프로젝트를 갈고닦을 예정이다. 잘 굴러가기 시작하면 수익화 방향도 자연스럽게 생길 것 같기도 하고.이 프로젝트를 생각하게 된 계기는, 학생 때 아르바이트를 많이 해봤던 경험도 있고, 2026년에도 여전히 많은 가게에서 수기로 근무 일지를 작성하고 사장님이 직접 계산기를 두들기며 급여를 계산해 이체한다는 이야기를 들었기 때문이다. 가장 가까운 예가 우리 가게 사장님이었고, “큰돈 벌려는 게 아니니 경험 삼아 서비스를 만들어보자. 여기에 수요가 한두 명이라도 있으면 저기 어딘가에 몇 명 더 있지 않을까?” 하는 생각이었다.암튼, 이런 마음을 가지고 좀 더 진행해보려 한다.Phase를 잘 나누기 위해 고려해야 할 것들도 많다. 스킬을 덕지덕지 붙이고 하네스를 갖다 붙인다고 다 좋은 게 아니라는 이야기를 유튜브에서 자주 들어서, 어제 본 바이브마피아님 유튜브 영상을 참고해 에이전트 친화적인 코드베이스로 먼저 리팩토링하려고 한다.에이전틱 코딩을 할 때 여러 안전장치를 사전에 마련해두는 것이 좋다는 내용을 중점으로 다룬 라이브 영상인데, 집에서 차분히 따라해보면서 이 프로젝트에 적용해볼 예정이다. 그 이후에 위에서 말한 기능들을 하나씩 붙여 나가야겠다.마무리하며일단 배포는 됐고, 앱인토스 홈페이지를 보니 5월에 또 ‘귀여움’을 주제로 300만 원이 걸린 챌린지를 진행하고 있던데, 해볼 만한 아이디어가 뭐가 있을까 하는 생각이 들었다. 무엇보다 취준이 우선이라는 생각이 커서, 부담이 가지 않는 선에서 준비해보는 것도 좋지 않을까 싶다. 그러면서 이 앱도 가다듬어야겠다. 여기서 이 앱의 변천사를 기록하고, 개발하며 느낀 여러 가지를 남길 예정인데, 배포하기 참 편한 인프라를 토스에서 마련해줬으니 겸손하게 잘 이용해보고자 한다." }, { "title": "AI 수익화 전략 세미나 with 서울청년센터 도봉", "url": "/posts/AI-%EC%88%98%EC%9D%B5%ED%99%94-%EC%A0%84%EB%9E%B5-%EC%84%B8%EB%AF%B8%EB%82%98/", "categories": "Review, Seminar", "tags": "AI, 수익화, 세미나, 생성형AI, 음악AI, 블로그수익화", "date": "2026-04-30 07:30:00 +0900", "snippet": "창작 도구 기준 생성형 AI 글, 대화 AI 이미지 AI 나노바나나의 획기적이었던 점 : 이미지를 생성할 때 캐릭터의 일관성 덕테잎의 장점 (글 작성 기준 가장 좋은 성능) : 한글이 깨지지 않고 이미지 생성 성능이 너무 좋아짐 영상 AI 음악 AI자율 실행하는 AI 기준 AI 에이전트 - 범용형 업무 자...", "content": "창작 도구 기준 생성형 AI 글, 대화 AI 이미지 AI 나노바나나의 획기적이었던 점 : 이미지를 생성할 때 캐릭터의 일관성 덕테잎의 장점 (글 작성 기준 가장 좋은 성능) : 한글이 깨지지 않고 이미지 생성 성능이 너무 좋아짐 영상 AI 음악 AI자율 실행하는 AI 기준 AI 에이전트 - 범용형 업무 자동화 AI 코딩 보조 AI 앱, 웹사이트 자동 제작 AI돈을 버는데… 허들이 작은 툴을 사용하면 살짝 낮은 레벨 수준까진 가능하고, 여기에 코딩을 할 줄 아는 어느 정도의 베이스가 있다면 거기에 몇 단계 레벨이 더 올라갈 수 있을거라고 생각 강사님은 앱 웹사이트 자동 제작 쪽에 관심을 두고 있고 공부하고 있다고 함 코딩을 알 필요가 있냐? 아니, 그냥 창작의 영역에서 자기가 하고 싶은 걸 한다 하면 몰라도 된다. 강사님은 자신이 이걸 가지고 강의하면서 돈을 벌어야 하니까 필요에 의해 공부한다고 함 생성형 AI의 경향 너무 빠르게 변화가 진행되고 있고 빠른 유료화와 전문 AI가 계속 세분화 되고 있음 AI는 도구가 아니라 ‘수익 구조’ 개인이 AI로 팀을 생성하고 자동화 구조를 만드는데 수익화 구조로 이어짐 내가 가지고 있는 콘텐츠가 곧 자산 모든 콘텐츠를 멀티모달로 제작해야 함 브랜드도 AI로 제작 생성 가능왜 지금, AI 수익화인가? 콘텐츠 수익화 디지털 제품으로 판매 서비스 제공네이버 블로그에 대해서 키워드 리서치 Chat GPT 글 생성 이미지 생성 SEO 최적화 발행 돈을 벌려면… Question. 돈을 버는 거에 기본이 뭘까요? 사람이 와야 함. 내가 500만원짜리든, 1000만원짜리든 내가 파는 걸 사줄 수 있는 찐팬이 100명만 있어도 먹고 살 수 있다. 그 100명이 또 그들의 지인 10명을 데려오고, 그 10명이 또 10명을 데려오고… 이렇게 된다는 가정 하에 그러면 돈을 벌 수 있다. 사람 모으기부터 시작하는 게 중요하다. 자신이 가진 재능을 생각해보고 살 사람을 모으는 방법 크몽, 숨고, 탈잉 등… (해외 Etsy, Gumroad), 캔바나 미리캔버스에 기여자로 등록 후기를 만들어야 함 (순진하게 후기를 누가 자발적으로 쌓아 줄 거라고 생각하지 마라.) AI 정보 따라잡기 국내외 AI 뉴스레터 읽어보고, 개인에 따라 욕심이 있다면 자동화해서 피드를 받아볼 수 있도록 AI를 써서 만들어 볼 수도 있을 것 같다. AI 공모전 &amp; 해커톤 참여해보기 DACON, 위비티, 올콘, 링커리어 Kaggle, Alcrowd, Devpost, HuggingFace 장애인 대상 동화책 만들기, 이미지 만들기 등등… 특히나 작은 지자체(군)은 경쟁률이 좀 작아서 오히려 거기서 가능성이 있을 수 있다. 내가 평소에 즐기는 걸로 수익화 네이버 블로그 글쓰기 -&gt; 체험단 -&gt; 브랜딩 -&gt; 대행사 카톡 이모티콘 제작 판매, 오픈톡방 활용 트래픽 웹소설 / 웹툰 스토리 제작 / 웹툰 이미지 제작 인터넷 신문 기사 작성 / 마케팅 홍보 인스타 피드, 릴스 작성 -&gt; 체험단 -&gt; 공구 -&gt; 인플루언서 너무 멀리 있는 걸 잡으려 하지 말고, 내가 할 수 있는 것들을 기반으로 추려보기. 남들 못하는 것보다, 이미 할 줄 아는 걸 가지고 그것의 생산자가 되도록 해야 함보통 사람들… 난이도가 낮은 플랫폼에서 먼저 시작해서 등록 단계에서 멈추는 게 아니라 수익이 발생될 때까지 꾸준히 하는 것플레이리스트 채널 기획 책 볼 때 일하거나 공부할 때… 듣는 것AI 음원의 강점 = 내 건물 저작권 100% 내 소유 : 편집, 유통, 리믹스, 라이센스 다 확보 가능 ( 저작권 등록은 안됨, 우리나라의 경우 ) 롱폼 광고 단가가 높음 : 숏폼 대비 20배 누적 재생 수익 : 한번 제작 =&gt; 오랫동안 수익 글로벌 확장성 : 언어장벽이 낮고, 전 세계 유통 가능다양한 음원 수익화 방법 유튜브를 통해 유통사를 통해 멜론, 지니, 스포티파이 등 음원 플랫폼 수익 분배 저작권료 배경음악 사용료, 공연 사용료 등의 권리 수익 음악 라이센스 판매 배경음악, 게임음악 판매 등등… 플랫폼 RouteNote DistroKid TuneCore Pond5 AudioJungle / Envato Elements Bandcamp 음악 기본 재료(건축 요소) 장르 / 멜로디 건축 양식 (집의 외관) 재즈 / 어쿠스틱 / 트로트 / 신스팝 / K팝 / 오케스트라 리듬 / 템포 (생활의 속도 / 동네 분위기) : 분위기와 속도를 정하는 BPM 화성 / 악기 (인테리어와 가구) 피아노 / 스트링 / 브라스 / 베이스 송 폼 구조(집의 평면도(설계도)) Intro =&gt; Verse =&gt; Chorus =&gt; Bridge =&gt; Outro 이걸 잘 지키면서 SUNO.AI에게 시켜야 길이가 어느 정도 나온다. 프롬프트 황금공식장르 -&gt; 분위기/감정 -&gt; 템포 느낌 -&gt; 핵심 악기 -&gt; 보컬 유무 &amp; 톤 -&gt; 가사 주제 / 키워드 -&gt; 길이 / 용도예시1) 로파이 힙합(공부용 BGM)아늑하고 따뜻한 힙합, 느린 템포 느낌공부용 BGM + 아날로그 질감 + 4분예시2) K-pop 댄스 (훅 강조)K-pop dance + 에너지 넘침 + 빠른 느낌 + EDM synth, punchy kick + 여성 남성 보컬 (파워풀) + 여름 페스티벌 주제 + 현대적 믹스 + 3분 구체적 형용사와 악기, 용도를 명시 노래 제목을 넣는 건 되지만, 가수 이름을 명시하는 건 좋지 않다. " }, { "title": "알바페이 제작기 ep.1", "url": "/posts/%EC%95%8C%EB%B0%94%ED%8E%98%EC%9D%B4-%EC%A0%9C%EC%9E%91%EA%B8%B0-1/", "categories": "Project, Side Project", "tags": "앱인토스, Apps In Toss, Codex, Vibe Coding, 사이드프로젝트", "date": "2026-04-28 07:30:00 +0900", "snippet": "최근에 앱인토스 웨비나를 보고 실습을 한번 해보았다!러버블을 사용해 프로토타입 제작하기프롬프트\"알바페이\"라는 이름을 가진 토스 안의 미니앱인 앱인토스 앱을 하나 만들어보고자 해.일단 MVP 단계까지만 기능을 구현하려 하고, 점차 앱을 발전시키며 여러 기능을 붙이고자 해.* 앱 이름 : 알바페이* 앱 설명 : 고용주가 알바생에게 급여를 자동으로 계산하고...", "content": "최근에 앱인토스 웨비나를 보고 실습을 한번 해보았다!러버블을 사용해 프로토타입 제작하기프롬프트\"알바페이\"라는 이름을 가진 토스 안의 미니앱인 앱인토스 앱을 하나 만들어보고자 해.일단 MVP 단계까지만 기능을 구현하려 하고, 점차 앱을 발전시키며 여러 기능을 붙이고자 해.* 앱 이름 : 알바페이* 앱 설명 : 고용주가 알바생에게 급여를 자동으로 계산하고 지급할 수 있는 앱* 타겟 사용자: - 자영업자 (고용주) - 아르바이트생0. 이 앱은 뭐하는 앱인가 : 앱인토스에 출시할 미니앱으로, 한마디로 별다른 수고 없이 고용주가 알바생에게 급여를 자동 지급하는 앱1. pain point : 한국에서 자영업을 하시는 분들은 여러 아르바이트생을 고용해서 사용하는데, 개개인에게 급여를 지급할 때 수기로 작성하는 경우가 많기 때문에 계산상 오류가 발생할 수 있고, 심지어 알바생이 여러 명이며 급여 날짜도 다르다면 매달 이러한 번거로운 과정을 거쳐야 한다.2. targetting app customer : 사업장을 운영 중이고 아르바이트생을 고용한 고용주, 그리고 그들에게 급여를 받는 아르바이트생3. mvp 단계에서 구현되어야 할 것1) 고용주 입장 - 사업장 등록 - 고용자(알바생) 추가 - 급여 정보 등록 ( 급여지급일, 시급 등 ) - 근무 기록 (알바생이 근무일지 등록하면 시간 확인 후 승인) - 급여 지급 버튼 (승인된 근무일지에 따라 고용주가 알바생별 지급 금액 확인 후 버튼 누르면 이체)2) 알바생 입장 - 사업장 검색 - 근무일지 기록 (일자, 시간 기록 가능) - 계좌정보 등록4. 사용자 흐름[고용주]회원가입 → 사업장 등록 → 알바생 추가 → 급여 설정 → 근무일지 확인 → 승인 → 급여 지급[알바생]회원가입 → 사업장 검색 → 등록 요청 → 승인 → 근무일지 작성 → 급여 수령5. 사용자 역할- 고용주 (사업장 관리자)- 알바생 (근무자)각 역할에 따라 화면과 기능이 다르게 보여야 함6. 데이터 구조 (간단)- 사업장 - 이름 - 사업자 정보 - 소속 알바생 리스트- 알바생 - 이름 - 계좌정보 - 소속 사업장- 근무일지 - 날짜 - 근무 시작/종료 시간 - 승인 여부- 급여정보 - 시급 - 지급일7. 상태 관리:- 근무일지 상태: 작성됨 / 승인됨 / 반려됨- 급여 상태: 지급 예정 / 지급 완료8. 디자인:- 모바일 UX 최적화- 직관적이고 간단한 UI =&gt; 앱인토스 디자인 가이드 최대한 따르길 권장 - 참고 : https://developers-apps-in-toss.toss.im/design/components.html9. 요청:- MVP 수준의 클릭 가능한 프로토타입 생성- 화면 간 이동이 자연스럽게 연결- 실제 서비스처럼 흐름이 이어지도록 구성- 급여 지급 버튼 관련(중요): - 고용주가 승인한 알바생이 기입한 근무일지 기록을 기반으로 \"지급 예정 금액\" 계산 - 지급 버튼 클릭 시 실제 이체는 앱인토스에서 처리함MVP 개발 명세 작성하기러버블 크레딧을 충전해야 해서, 일단 무료로 진행해보기 위해 최초 프로토타입을 테스트해봤는데 괜찮게 뽑혀서 디자인 수정은 요청하지 않았다. GitHub에 소스를 업로드하고 아래의 개발 명세만 뽑아달라고 요청했다.개발 명세서1. 핵심 기능 (Feature Overview)👥 공통 회원가입 / 로그인 (이름, 전화번호 기반) 역할 선택 (고용주 / 알바생, 가입 시 1회 확정)🧑‍💼 고용주 (Owner)사업장 관리 사업장 등록 (상호명, 사업자번호, 주소)알바생 관리 알바생 등록 / 초대 등록 요청 승인 / 거절 시급 / 지급일 설정근무일지 관리 알바생 근무일지 조회 승인 / 반려 처리급여 지급 승인된 근무일지 기반 자동 계산 지급 금액 확인 지급 버튼 클릭 → 앱인토스 이체 화면 호출👷 알바생 (Worker)사업장 연결 사업장 검색 등록 요청 및 승인 대기근무일지 날짜 / 시작 / 종료 시간 수기 입력 메모 작성 상태 확인 (작성됨 / 승인됨 / 반려됨)급여 이번 달 예상 급여 확인 지급 완료 내역 조회계좌 은행 / 계좌번호 등록2. 데이터 구조 (Core Models)type Role = \"owner\" | \"worker\";🏢 사업장 (Workplace)type Workplace = { id: string; name: string; bizNumber: string; address: string; ownerId: string;};👷 알바생 (Worker)type Worker = { id: string; name: string; phone: string; workplaceId: string; hourlyWage: number; payday: number; // 1~31 status: \"pending\" | \"approved\"; bank?: string; account?: string;};📝 근무일지 (Worklog)type Worklog = { id: string; workerId: string; workplaceId: string; date: string; // YYYY-MM-DD start: string; // HH:mm end: string; // HH:mm memo?: string; status: \"submitted\" | \"approved\" | \"rejected\";};💰 급여 지급 (Payout)type Payout = { id: string; workerId: string; workplaceId: string; amount: number; paidAt: string; // ISO period: string; // YYYY-MM};👤 현재 사용자 (Session)type CurrentUser = { id: string; name: string; phone: string; role: Role; workplaceId?: string; workerId?: string;};3. API 설계 (Backend Required)🔐 Auth POST /auth/register POST /auth/login GET /auth/me🏢 Workplace POST /workplaces GET /workplaces/:id👷 Worker POST /workers GET /workers/:id PATCH /workers/:id POST /workers/:id/approve📝 Worklog POST /worklogs GET /worklogs POST /worklogs/:id/approve POST /worklogs/:id/reject💰 Payout GET /payouts GET /payouts/calculate POST /payouts/pay🟦 App-in-Toss POST /toss/transfer → 실제 이체는 SDK로 처리4. 화면 구조 (Routes)📱 공통 / → 스플래시 /onboarding /signup /role /demo /settings🧑‍💼 고용주 (하단 탭) /owner → 홈 /owner/workplace/new /owner/workers /owner/workers/new /owner/workers/:id /owner/worklogs /owner/payroll👷 알바생 (하단 탭) /worker → 홈 /worker/search /worker/worklogs /worker/payroll /worker/me5. 핵심 로직💰 급여 계산지급 예정 금액 = Σ (승인된 근무일지의 근무시간 × 시급)🔄 상태 흐름 근무일지: submitted → approved / rejected 급여: 예정 → 지급 완료💳 지급 프로세스 고용주가 금액 확인 지급 버튼 클릭 앱인토스 이체 화면 호출 성공 시 payout 생성6. MVP 범위 (확정)포함 전체 UI 흐름 역할 분리 급여 계산 지급 플로우 (목업)제외 실제 인증 실제 DB 실제 이체 푸시 알림 외부 API앱인토스에 앱 등록해보기일단 등록하긴 했는데, 돈이 왔다 갔다 하는 기능이라 심사에 통과할 수 있을지 모르겠다. 일단 기다려본다!이후는 터미널에서 작업앱인토스 프레임워크 설치npm install @apps-in-toss/web-framework@2.3.0npx ait init이렇게 하면 개발 준비가 완료된다.코덱스로 작업 시작1. 그라나이트 파일 셋팅granite.config.ts 파일을 수정해줘. 한글 앱네임은 알바페이로 수정하고, icon이미지 주소는 https://static.toss.im/appsintoss/13053/b8e8d740-9807-4607-8798-e42f1098dd81.png로 수정하고 우리 앱을 분석해서 만약 필요한 permission이 있다면 적절하게 설명해주세요.아래 공식 문서를 반드시 참고해서:https://developers-apps-in-toss.toss.im/1. granite.config.ts 설정 작성2. 필요한 permission 정리3. 앱 실행 구조 설명공식 문서를 기준으로 정확하게 작성해줘.모르면 추측하지 말고 필요한 부분을 질문해줘. 2. 스킬 준비1) Apps In Toss Skills 설치 가이드 docs-search와 project-validator 스킬 설치# 아래 소스를 코덱스 프롬프트창에 복사해서 실행하기install GitHub repo toss/apps-in-toss-skills path apps-in-toss• 저장소에는 apps-in-toss 디렉터리가 없고, 설치 가능한 스킬은 skills/docs-search와 skills/project-validator 두 개입니다. 요청 의도에 맞게 이 저장소의 실제 스킬 두 개를 설치하겠습니다.✔ You approved codex to run python3 /Users/chankim/.codex/skills/.system/skill-installer/scripts/install-... this time• Ran python3 /Users/chankim/.codex/skills/.system/skill-installer/scripts/install-skill-from-github.py --repo toss/apps-in-toss-skills --path skills/docs-search skills/project-validator │ --method git └ Installed docs-search to /Users/chankim/.codex/skills/docs-search Installed project-validator to /Users/chankim/.codex/skills/project-validator• Waited for background terminal설치 완료. 설치된 스킬: - docs-search → /Users/chankim/.codex/skills/docs-search - project-validator → /Users/chankim/.codex/skills/project-validator 참고로 요청하신 apps-in-toss 경로는 저장소에 없어서, 실제 존재하는 skills/docs-search와 skills/project-validator를 설치했습니다. Restart Codex to pick up new skills.2) 앱인토스 로그인 스킬 만들기 skill-creator라는 코덱스 제공 스킬을 사용해서 만들어봤다. 대충 붙여넣어도 알아서 다 만들어준다. 몇 가지 오류가 발생하지만 proceed에 yes 해주면 알아서 진행한다. 가이드에서 제공한 프롬프트인데, 사용할 스킬을 선언한 뒤 그 스킬을 만들 때 참고해야 할 자료를 제공하고, 그 후에 세부 사항을 가이드해주는 방식이다. (다른 스킬 만들 때 참고!)$skill-creator https://developers-apps-in-toss.toss.im/login/intro.html https://developers-apps-in-toss.toss.im/login/console.html https://developers-apps-in-toss.toss.im/login/develop.html https://developers-apps-in-toss.toss.im/login/qa.html https://developers-apps-in-toss.toss.im/api/refreshOauth2Token.html https://developers-apps-in-toss.toss.im/api/generateOauth2Token.html https://developers-apps-in-toss.toss.im/api/removeByUserKey.html https://developers-apps-in-toss.toss.im/api/removeByAccessToken.html https://developers-apps-in-toss.toss.im/api/loginMe.html https://developers-apps-in-toss.toss.im/bedrock/reference/framework/로그인/appLogin.html https://developers-apps-in-toss.toss.im/bedrock/reference/framework/로그인/getIsTossLoginIntegratedService.html , 클로드스킬베스트프랙티스 https://platform.claude.com/docs/en/agents-and-tools/agent-skills/best-practices 준수, 앱인토스 모든 로그인 공식문서를 e2e 준수하여 새로운 앱인토스 로그인 전용 스킬을 생성하라.반드시 askuserquestion을 포함시켜서 충분한 컨텍스트를 수집하거나 반대로 유저가 토스로그인에대한 지식이 부족할경우에는 처음부터 끝까지 상세한 안내를하며 가이드역할까지 수행할 수 있게 하라.유저의 mTLS파일은 루트경로에 , 복호화키는 .env에 저장하라고 알려주고 민감한정보들이니 안전하게 보관하여 로그인구현에 사용하라. 또한 서버는 vercel, 백엔드는 supabase를 사용하며 사용자가 vercel과 supabase 를 잘모를경우 둘다 가입부터 사용방법을 친절하게 안내하고 vercel은 반드시 새로운 신규 프로젝트를 생성하는것을 명시하고 편의상 터미널에서 vercel login 을 쳐서 vercel login 된상태로 ai에게 전권을주고 supabase는 supabase mcp 설치방법을 안내하여 supabase mcp에 인증 후 supabase mcp를 사용하여 이용자 테이블 생성 및 마이그레이션까지 모두 수행함을 전제로한다.또한 토스콘솔에서 로그인기능 활성화에 필요한 약관작성 및 등록(url필요) , vercel 과 supabase 모두 배포완료후 basic auth 헤더와 콜백 url을 콘솔에 등록하는 등 유저 콜백처리등에 대한 안내도 상세하게 안내하여 연결끊기 콜백 테스트에 성공할 수 있게 안내하는등 단순히 토스로그인을 구현하는것에서 그치지않고 비개발자도 처음부터 끝까지 잘몰라도 구현할 수 있도록 안내하는 종합 가이드 역할까지 수행하게 하라. 이를위해 가능한 필요한 모든 토스공식문서들을 레퍼런스에 등록하여 항상 참조하게 하라……이렇게 새로운 스킬이 하나 만들어졌다!3) 에이전틱 코딩을 위한 superpower + andrej-karpathy-skills 셋팅해두기 프로젝트 루트에 AGENTS.md 파일을 만들고 내용을 붙여넣는다. ---name: karpathy-guidelinesdescription: Behavioral guidelines to reduce common LLM coding mistakes. Use when writing, reviewing, or refactoring code to avoid overcomplication, make surgical changes, surface assumptions, and define verifiable success criteria.license: MIT--- # Karpathy Guidelines Behavioral guidelines to reduce common LLM coding mistakes, derived from [Andrej Karpathy's observations](https://x.com/karpathy/status/2015883857489522876) on LLM coding pitfalls. **Tradeoff:** These guidelines bias toward caution over speed. For trivial tasks, use judgment. ## 1. Think Before Coding **Don't assume. Don't hide confusion. Surface tradeoffs.** Before implementing:- State your assumptions explicitly. If uncertain, ask.- If multiple interpretations exist, present them - don't pick silently.- If a simpler approach exists, say so. Push back when warranted.- If something is unclear, stop. Name what's confusing. Ask. ## 2. Simplicity First **Minimum code that solves the problem. Nothing speculative.** - No features beyond what was asked.- No abstractions for single-use code.- No \"flexibility\" or \"configurability\" that wasn't requested.- No error handling for impossible scenarios.- If you write 200 lines and it could be 50, rewrite it. Ask yourself: \"Would a senior engineer say this is overcomplicated?\" If yes, simplify. ## 3. Surgical Changes **Touch only what you must. Clean up only your own mess.** When editing existing code:- Don't \"improve\" adjacent code, comments, or formatting.- Don't refactor things that aren't broken.- Match existing style, even if you'd do it differently.- If you notice unrelated dead code, mention it - don't delete it. When your changes create orphans:- Remove imports/variables/functions that YOUR changes made unused.- Don't remove pre-existing dead code unless asked. The test: Every changed line should trace directly to the user's request. ## 4. Goal-Driven Execution **Define success criteria. Loop until verified.** Transform tasks into verifiable goals:- \"Add validation\" → \"Write tests for invalid inputs, then make them pass\"- \"Fix the bug\" → \"Write a test that reproduces it, then make it pass\"- \"Refactor X\" → \"Ensure tests pass before and after\" For multi-step tasks, state a brief plan:```1. [Step] → verify: [check]2. [Step] → verify: [check]3. [Step] → verify: [check]``` Strong success criteria let you loop independently. Weak criteria (\"make it work\") require constant clarification. superpower 플러그인 설치 프롬프트창에 /plugins를 치고 superpowers를 검색하면 나온다. 사용 예제 andrej-karpathy-skills는 AGENTS.md에 명시해뒀기 때문에 에이전트가 이를 항상 참조하고, 방금 추가한 superpowers 스킬을 사용해서 요구사항을 요청하면 된다. $superpowers-coding 과 $app-in-toss-docs 를 사용해. 알바페이 MVP에서 고용주가 알바생을 등록하고 근무시간을 입력하면 예상 급여를 계산하는 화면을 구현해줘. 규칙:- 먼저 AGENTS.md를 따르고- 앱인토스 관련 API는 app-in-toss-docs를 확인한 뒤 사용하고- 문서에 없는 API는 추측하지 말고 TODO로 남겨- 최소 변경으로 구현하고- 마지막에 lint/build 가능한지 확인해줘 3. 로그인 스킬 사용하기 처음에는 공식 스킬 하나로 시작하고, 시간이 지남에 따라 필요한 스킬을 직접 만들어 쓰는 방식을 권장한다. 참고 자료 https://github.com/obra/superpowers https://github.com/msitarzewski/agency-agents https://github.com/VoltAgent/awesome-claude-code-subagents $apps-in-toss-login 로그인 기능구현 시작해줘" }, { "title": "Harness 맛보기", "url": "/posts/Harness-%EB%A7%9B%EB%B3%B4%EA%B8%B0/", "categories": "Study, AI Tools", "tags": "Harness, AI, Agent, LLM, Prompt Engineering", "date": "2026-04-22 07:30:00 +0900", "snippet": " ‘바이브마피아 VibeMafia’님 라이브 영상을 보면서 직접 하네스 제작을 실습해보았다. 하네스(Harness) 엔지니어링이란?AI 에이전트가 일관된 흐름으로 작업을 수행할 수 있도록 파이프라인(단계, 역할, 실행 방식)을 설계하는 것을 말한다. 단순히 프롬프트를 잘 쓰는 것을 넘어, 에이전트 세션을 어떻게...", "content": " ‘바이브마피아 VibeMafia’님 라이브 영상을 보면서 직접 하네스 제작을 실습해보았다. 하네스(Harness) 엔지니어링이란?AI 에이전트가 일관된 흐름으로 작업을 수행할 수 있도록 파이프라인(단계, 역할, 실행 방식)을 설계하는 것을 말한다. 단순히 프롬프트를 잘 쓰는 것을 넘어, 에이전트 세션을 어떻게 구성하고 오케스트레이션할지를 체계화하는 개념이다.왜 하네스가 필요한가단순한 작업은 AI에게 바로 요청해도 된다. 하지만 작업이 복잡해질수록 문제가 생긴다. 한 번의 세션에 너무 많은 작업이 몰리면 AI의 집중도와 정확도가 떨어진다. 이전 세션의 결과를 다음 세션으로 자연스럽게 이어가기 어렵다. 어느 단계에서 실패했는지 추적하기 힘들다. 같은 작업을 반복할 때마다 처음부터 다시 설명해야 한다.하네스는 이런 문제를 해결하기 위해, 작업을 단계별로 쪼개고 각 단계의 입출력을 명확히 정의하는 방식으로 AI를 구조적으로 활용하는 접근이다.접근 순서전제 하네스 엔지니어링은 특정 직군을 위한 기술이 아니라, AI가 적용되는 모든 분야에서 이제 필요한 역량이라고 생각한다. 처음부터 너무 많은 스킬을 사용하기보다, 하나의 스킬로 시작해 필요한 부분이 생길 때마다 파이프라인을 고도화해 나가는 것이 올바른 방향이라고 생각한다.거쳐야 할 단계실습을 통해 정리한 하네스 파이프라인의 기본 흐름이다. 모든 단계를 처음부터 완벽하게 구현할 필요는 없고, 이 흐름을 염두에 두면서 필요한 단계부터 하나씩 붙여나가면 된다.1. Clarify — 요구사항 이해사용자의 요구사항을 정확히 ‘이해’하는 단계다. 이 단계는 반드시 거치는 것이 좋다. 특정 요구사항에 대해 여러 관점에서의 논의점을 제안해달라고 한 뒤, “이게 최선인가?”를 함께 검토하는 과정을 반복하는 것이 중요하다. 이 단계를 건너뛰면 나중에 잘못된 방향으로 구현하고 처음부터 다시 하는 일이 생긴다.# Clarify 프롬프트 예시\"다음 요구사항을 구현하기 전에, 내가 놓칠 수 있는 엣지 케이스나모호한 부분이 있으면 먼저 질문해줘. 확인 없이 바로 구현하지 말고.\"2. Context Gather — 관련 컨텍스트 수집요구사항을 명확히 이해한 뒤, 관련된 과거 기록이나 코드를 찾아보게 하는 단계다. 이 단계를 AI에게 통째로 맡기면 정작 참조해야 할 것을 놓치는 경우가 생긴다. 문서에 사용된 단어나 폴더명을 명확하게 정리해두는 것만으로도 이 문제를 상당 부분 해소할 수 있다. 어떤 파일을 읽어야 하는지, 어떤 문서를 참고해야 하는지를 명시적으로 알려주는 것이 효율적이다.# Context Gather 프롬프트 예시\"구현 전에 먼저 아래 파일들을 읽어줘:- src/payment/PaymentService.java (기존 결제 로직)- docs/api-spec.md (API 스펙 문서)읽고 나서, 새 기능과 충돌할 수 있는 부분이 있는지 먼저 알려줘.\"3. Plan — 구현 계획 수립바로 코드를 작성하지 않고 먼저 계획을 세우게 한다. 계획을 먼저 보고 방향이 맞는지 확인한 뒤 구현으로 넘어가는 것이 훨씬 안전하다. 계획 단계에서 잘못된 방향을 잡으면 구현 이후에 수정하기 훨씬 어렵다.# Plan 프롬프트 예시\"구현하기 전에 어떻게 접근할지 계획만 먼저 작성해줘.코드는 아직 작성하지 말고, 내가 계획을 검토한 뒤 진행할게.\"4. Generate — 구현계획이 확정된 후 실제 코드를 작성한다. 한 번에 너무 많은 것을 구현하려 하지 말고, 단위별로 나눠서 진행하는 것이 좋다.5. Evaluate — 검증구현된 결과물을 검토하는 단계다. 단순히 “잘 됐나?”가 아니라 구체적인 기준으로 검토해야 한다.# Evaluate 프롬프트 예시\"방금 구현한 코드를 아래 기준으로 검토해줘:1. 요구사항을 모두 충족했는가?2. 예외 처리가 빠진 부분은 없는가?3. 기존 코드 스타일과 일관성이 있는가?문제가 있으면 수정하고, 없으면 완료라고 말해줘.\"task/phase 구조를 사용하는 이유규모가 큰 작업은 한 번의 호흡으로 전부 구현하기 어렵다. 작업을 작게 쪼개 두고, 여러 에이전트 세션이 이어서 작업하도록 구성하기 위해서다.각 phase의 결과물을 파일로 저장해두면, 다음 세션에서 이어받아 작업할 때 컨텍스트를 다시 설명할 필요가 없다. 중간에 실패해도 해당 단계만 재실행하면 된다.스크립트를 사용하는 이유서브 에이전트에게 작업을 위임하면, 그 오케스트레이션(1번 끝나면 2번 호출, 실패 시 재시도, 중간 상태 저장 등)은 메인 세션의 책임으로 남는다. 이를 Python 스크립트로 분리하면 메인 세션은 스크립트를 실행하기만 하면 되고, 스크립트가 순서대로 에이전트를 호출하고 결과를 기록해준다.# harness.py 예시 구조def run_phase(phase_name, prompt, input_file=None): \"\"\"각 phase를 실행하고 결과를 파일로 저장\"\"\" result = call_agent(prompt, input_file) save_result(f\"output/{phase_name}.md\", result) return resultdef main(): # Phase 1: Clarify requirements = run_phase(\"clarify\", CLARIFY_PROMPT) # Phase 2: Plan plan = run_phase(\"plan\", PLAN_PROMPT, input_file=\"output/clarify.md\") # Phase 3: Generate code = run_phase(\"generate\", GENERATE_PROMPT, input_file=\"output/plan.md\") # Phase 4: Evaluate run_phase(\"evaluate\", EVALUATE_PROMPT, input_file=\"output/generate.md\")if __name__ == \"__main__\": main()추가적인 장점도 있다. 메인 세션에 오케스트레이션 관련 컨텍스트가 쌓이지 않아 깔끔하게 유지되고, 오케스트레이션 방식을 스크립트로 고정해두면 모델이 바뀌어도 동일한 흐름으로 작업이 진행된다.어떤 작업에 하네스를 써야 할까모든 작업에 하네스가 필요한 건 아니다. 간단한 질문이나 단발성 코드 수정은 직접 요청하는 게 빠르다. 하네스는 아래 상황에서 효과가 크다. 여러 파일에 걸쳐 변경이 일어나는 작업 반복적으로 실행해야 하는 자동화 작업 단계별 검토가 필요한 복잡한 기능 구현 팀원이 여럿인 프로젝트에서 AI 작업 방식을 표준화할 때참고 자료 바이브마피아 라이브 영상 하네스 엔지니어링 개념 영상 실습 GitHub" }, { "title": "Rails 자기 참조 연관관계(Self-referential Association) 훑어보기", "url": "/posts/%EB%A0%88%EC%9D%BC%EC%8A%A4-%EB%AA%A8%EB%8D%B8-%EB%A7%9B%EB%B3%B4%EA%B8%B0/", "categories": "Study, Rails", "tags": "루비온레일즈, Rails", "date": "2026-03-24 07:30:00 +0900", "snippet": "Rails를 공부하다 보면 트리 구조의 데이터를 다뤄야 할 때가 있습니다. 대표적인 예가 카테고리처럼 부모-자식 관계가 재귀적으로 이어지는 구조입니다. 이번 글에서는 자기 참조 연관관계(Self-referential Association)를 사용한 Category 모델 코드를 분석해 보겠습니다.아래 코드가 이번 글에서 살펴볼 전체 예시입니다.class...", "content": "Rails를 공부하다 보면 트리 구조의 데이터를 다뤄야 할 때가 있습니다. 대표적인 예가 카테고리처럼 부모-자식 관계가 재귀적으로 이어지는 구조입니다. 이번 글에서는 자기 참조 연관관계(Self-referential Association)를 사용한 Category 모델 코드를 분석해 보겠습니다.아래 코드가 이번 글에서 살펴볼 전체 예시입니다.class Category &lt; ApplicationRecord belongs_to :parent, class_name: \"Category\", optional: true has_many :children, class_name: \"Category\", foreign_key: \"parent_id\", dependent: :destroy validate :prevent_circular_reference scope :roots, -&gt; { where(parent_id: nil) } # Category.roots -&gt; 최상위 카테고리들 def all_children children + children.map(&amp;:all_children).flatten end def ancestors return [] unless parent [ parent ] + parent.ancestors endend1. 클래스 선언class Category &lt; ApplicationRecord ApplicationRecord를 상속받는 Ruby 클래스입니다. Rails에서 DB 테이블(categories)과 자동으로 연결됩니다. Java Spring의 @Entity + JPA Repository 기능이 합쳐진 개념으로 이해하면 됩니다.2. 자기 참조 연관관계 (Self-referential Association)belongs_to :parent, class_name: \"Category\", optional: truehas_many :children, class_name: \"Category\", foreign_key: \"parent_id\", dependent: :destroyDB 구조로 보면 이렇습니다:categories 테이블┌────┬──────────┬───────────┐│ id │ name │ parent_id │├────┼──────────┼───────────┤│ 1 │ 전자제품 │ NULL │ ← 최상위│ 2 │ 노트북 │ 1 │ ← 전자제품의 자식│ 3 │ 삼성노트북 │ 2 │ ← 노트북의 자식└────┴──────────┴───────────┘belongs_to :parent 이 카테고리의 부모를 가리킵니다. class_name: \"Category\" → 연관 대상이 다른 모델이 아니라 자기 자신 클래스입니다. optional: true → parent_id가 NULL 허용 (최상위 카테고리는 부모가 없으므로) Spring으로 치면 @ManyToOne(optional = true)에 해당합니다.has_many :children 이 카테고리의 자식 목록을 가리킵니다. foreign_key: \"parent_id\" → 자식들이 갖고 있는 FK 컬럼명을 명시합니다. dependent: :destroy → 부모 삭제 시 자식도 연쇄 삭제됩니다. Spring으로 치면 @OneToMany(cascade = CascadeType.REMOVE)에 해당합니다.3. 유효성 검사validate :prevent_circular_reference prevent_circular_reference라는 커스텀 validator 메서드를 호출합니다. 순환 참조 방지 용도입니다 (A→B→C→A 같은 상황). 단, 이 코드엔 해당 메서드 구현이 없으므로 실제로는 동작하지 않습니다. 실무에서는 메서드를 별도로 정의해야 합니다. Spring의 @Valid + 커스텀 ConstraintValidator 개념과 유사합니다. 참고: 속성값을 검증할 때는 validates :name, presence: true처럼 validates(복수)를 쓰고, 커스텀 메서드를 호출할 때는 validate(단수)를 씁니다.4. 스코프 (Scope)scope :roots, -&gt; { where(parent_id: nil) } 자주 쓰는 쿼리 조건에 이름을 붙여두는 것입니다. -&gt; { } 는 Ruby의 람다(lambda) 문법입니다. where(parent_id: nil) → SQL의 WHERE parent_id IS NULL에 해당합니다.# 사용 예시Category.roots # SELECT * FROM categories WHERE parent_id IS NULLCategory.roots.first # 최상위 카테고리 중 첫번째Category.roots.count # 최상위 카테고리 개수Spring의 @Query나 QueryDSL로 만든 메서드를 Repository에 등록해두는 것과 같은 개념입니다.5. 인스턴스 메서드all_children — 모든 하위 자식을 재귀로 가져오기def all_children children + children.map(&amp;:all_children).flattenend 코드 설명 children 직계 자식 목록 (ActiveRecord 배열) children.map(&amp;:all_children) 각 자식마다 all_children 재귀 호출 &amp;:all_children { \\|c\\| c.all_children } 의 축약 문법 .flatten 중첩 배열을 1차원으로 펼침 + 두 배열을 합침 동작 예시:전자제품.all_children → [노트북] + [삼성노트북, LG노트북, ...] → [노트북, 삼성노트북, LG노트북, ...] ⚠️ N+1 문제 주의 — 카테고리가 많으면 재귀 호출마다 DB 쿼리가 날아가므로, 실무에서는 includes로 eager loading 하거나 재귀 CTE 쿼리를 씁니다.ancestors — 모든 상위 부모를 재귀로 가져오기def ancestors return [] unless parent [ parent ] + parent.ancestorsend 코드 설명 return [] unless parent 부모가 없으면(최상위면) 빈 배열 반환 unless if !parent 와 동일, Ruby스러운 부정 조건 [ parent ] 부모 하나를 배열로 감쌈 + parent.ancestors 부모의 조상들을 재귀로 붙임 동작 예시:삼성노트북.ancestors → [노트북] + [전자제품] + [] → [노트북, 전자제품]전체 구조 한눈에 보기전자제품 (parent_id: nil) ← roots 스코프 └── 노트북 (parent_id: 1) ├── 삼성노트북 (parent_id: 2) └── LG노트북 (parent_id: 2)삼성노트북.ancestors → [노트북, 전자제품]전자제품.all_children → [노트북, 삼성노트북, LG노트북]전자제품.children → [노트북] ← 직계만Spring JPA 백그라운드가 있으시다면 아래 대응표가 도움이 될 겁니다. Rails Spring/JPA belongs_to @ManyToOne has_many @OneToMany dependent: :destroy CascadeType.REMOVE scope Repository 커스텀 쿼리 메서드 validate @Valid + ConstraintValidator optional: true (optional = true) " }, { "title": "Ruby on Rails 8 MVC Architecture", "url": "/posts/%EB%A0%88%EC%9D%BC%EC%A6%88-MVC/", "categories": "Study, Rails", "tags": "루비온레일즈, MVC, ActiveRecord, Rails", "date": "2026-03-12 07:30:00 +0900", "snippet": " 출처: [심화] 인디해커를 위한 루비온레일즈 8 완벽 강의Model데이터와 비즈니스 로직을 담당합니다. 레일즈의 모델은 ActiveRecord라는 ORM의 구현체입니다. DB에서 하나의 row(데이터)를 가져오면 그게 객체가 되어, 해당 객체에서 attribute에 바로 접근할 수 있습니다. 객체를 통해 수정도 가능하기 때문에 직접 쿼리를 작...", "content": " 출처: [심화] 인디해커를 위한 루비온레일즈 8 완벽 강의Model데이터와 비즈니스 로직을 담당합니다. 레일즈의 모델은 ActiveRecord라는 ORM의 구현체입니다. DB에서 하나의 row(데이터)를 가져오면 그게 객체가 되어, 해당 객체에서 attribute에 바로 접근할 수 있습니다. 객체를 통해 수정도 가능하기 때문에 직접 쿼리를 작성할 필요가 거의 없습니다.ActiveRecord 주요 기능Associations (연관 관계)모델 간의 관계를 선언적으로 표현합니다. Spring의 @OneToMany, @ManyToOne 같은 역할을 단 한 줄로 처리합니다.class Post &lt; ApplicationRecord belongs_to :user has_many :comments, dependent: :destroy has_many :tags, through: :post_tagsendValidations (유효성 검증)DB에 데이터를 저장하기 전 유효성을 검증합니다. Spring의 @Valid, @NotNull 어노테이션과 같은 역할입니다.class User &lt; ApplicationRecord validates :email, presence: true, uniqueness: true validates :name, length: { minimum: 2, maximum: 50 } validates :age, numericality: { greater_than_or_equal_to: 0 }endScopes (재사용 가능한 쿼리)자주 사용하는 쿼리 조건을 메서드처럼 체이닝해서 사용할 수 있습니다. JPA의 @Query나 QueryDSL과 유사하지만, 체이닝이 훨씬 자연스럽습니다.class Post &lt; ApplicationRecord scope :published, -&gt; { where(published: true) } scope :recent, -&gt; { order(created_at: :desc) } scope :by_author, -&gt;(user) { where(user: user) }end# 체이닝 사용Post.published.recent.by_author(current_user)Callbacks레코드의 생명주기(저장 전, 저장 후, 삭제 전 등)에 훅을 걸 수 있습니다.class Order &lt; ApplicationRecord before_create :set_order_number after_update :notify_status_change, if: :saved_change_to_status? private def set_order_number self.order_number = SecureRandom.hex(8) endendFat Model레일즈는 Fat Model 패턴을 지향합니다. 비즈니스 로직을 모델에 집중시키는 방식입니다. 처음에는 모델에 로직이 많지 않지만, 시간이 지날수록 여러 모델이 추가되고 복잡도가 높아집니다. 복잡한 쿼리, 유효성 검증, 재사용 가능한 메서드를 모델에 정의합니다. 실무에서는 모델이 비대해지는 것을 방지하기 위해 Service Object, Concern, Query Object 같은 패턴을 함께 활용하기도 합니다.Service Object 패턴모델이 너무 비대해지면 Service Object로 분리합니다. Spring의 @Service 레이어와 같은 역할입니다. 여러 모델에 걸친 복잡한 트랜잭션 처리나 외부 API 호출이 들어갈 때 특히 유용합니다.# app/services/order_completion_service.rbclass OrderCompletionService def initialize(order, user) @order = order @user = user end def call ActiveRecord::Base.transaction do @order.update!(status: :completed) @user.increment!(:points, @order.points_earned) OrderMailer.completion_email(@order).deliver_later end endend# 컨트롤러에서 호출OrderCompletionService.new(@order, current_user).callView사용자 인터페이스를 담당합니다. 데이터를 어떻게 표현할지를 결정합니다. 레일즈의 기본 템플릿 언어는 ERB(Embedded Ruby)로, HTML 안에 Ruby 코드를 삽입할 수 있습니다.&lt;%# app/views/posts/index.html.erb %&gt;&lt;h1&gt;게시글 목록&lt;/h1&gt;&lt;% @posts.each do |post| %&gt; &lt;div class=\"post\"&gt; &lt;h2&gt;&lt;%= post.title %&gt;&lt;/h2&gt; &lt;p&gt;작성자: &lt;%= post.user.name %&gt;&lt;/p&gt; &lt;%= link_to '상세보기', post_path(post) %&gt; &lt;/div&gt;&lt;% end %&gt;&lt;% %&gt; 는 Ruby 코드 실행, &lt;%= %&gt; 는 실행 결과를 출력합니다.Partial (파셜)반복되는 뷰 컴포넌트를 별도 파일로 분리해 재사용하는 방식입니다. React의 컴포넌트 개념과 유사하며, 파일명은 반드시 언더스코어(_)로 시작합니다.&lt;%# app/views/posts/_post_card.html.erb %&gt;&lt;div class=\"post-card\"&gt; &lt;h2&gt;&lt;%= post.title %&gt;&lt;/h2&gt; &lt;p&gt;&lt;%= truncate(post.body, length: 100) %&gt;&lt;/p&gt;&lt;/div&gt;&lt;%# 사용하는 곳에서 %&gt;&lt;%= render 'post_card', post: @post %&gt;&lt;%# 컬렉션 렌더링 (N+1 주의) %&gt;&lt;%= render partial: 'post_card', collection: @posts %&gt; 레일즈의 뷰 레이어를 사용하지 않고 React, Vue 같은 별도 프론트엔드 프레임워크와 API 형태로 연동하는 경우도 많습니다. Rails 7 이후에는 Hotwire(Turbo + Stimulus)를 내장하여, JavaScript 없이도 동적인 UX를 구현할 수 있는 방향을 권장하고 있습니다.ControllerModel과 View 사이의 중개자 역할을 합니다.Thin Controller레일즈는 Thin Controller 패턴을 지향합니다. 컨트롤러는 간결하게 유지하고, 복잡한 로직은 모델로 위임합니다.class PostsController &lt; ApplicationController before_action :authenticate_user! before_action :set_post, only: [:show, :edit, :update, :destroy] def index @posts = Post.published.recent end def create @post = current_user.posts.build(post_params) if @post.save redirect_to @post, notice: '게시글이 작성되었습니다.' else render :new, status: :unprocessable_entity end end private def set_post @post = Post.find(params[:id]) end def post_params params.require(:post).permit(:title, :body, :published) endendbefore_action: 특정 액션 전에 공통 로직을 실행합니다. Spring의 HandlerInterceptor나 AOP와 유사한 역할입니다.Strong Parameters: 허용된 파라미터만 통과시켜 Mass Assignment 취약점을 방지합니다. 허용하지 않은 필드는 자동으로 걸러지기 때문에, 실수로 민감한 컬럼이 수정되는 상황을 막을 수 있습니다.Rails MVC 요청 흐름 브라우저에서 요청 발생 (GET /posts) 라우터가 요청을 적절한 컨트롤러 액션으로 전달 (PostsController#index) 컨트롤러가 필요한 모델 데이터 로드 @posts = Post.all # Post.all은 클래스 레벨 메서드 # @posts는 인스턴스 변수로, 뷰에서 접근 가능 모델이 데이터베이스에서 데이터 조회 컨트롤러가 뷰에 데이터 전달 뷰가 HTML 생성 컨트롤러가 HTML 응답을 브라우저로 반환Convention over Configuration“설정보다 관례(CoC)”는 레일즈의 핵심 철학입니다. 명시적 설정 없이도 이름 규칙만 따르면 레일즈가 알아서 연결해줍니다. 요소 명명 규칙 예시 모델 단수형, 카멜케이스 Post 테이블 복수형, 스네이크케이스 posts 컨트롤러 복수형, 카멜케이스 PostsController 뷰 폴더 컨트롤러명 복수형 app/views/posts/ 뷰 파일 액션명.html.erb app/views/posts/index.html.erb Spring에서는 @Controller, @Repository, @RequestMapping 등을 명시적으로 설정해야 하지만, 레일즈는 이름 규칙만 맞추면 별도 설정 없이 자동으로 연결됩니다.RESTful 라우팅 관례routes.rb에서 resources :posts 한 줄이면 7개의 RESTful 라우트가 자동 생성됩니다.Rails.application.routes.draw do resources :postsend HTTP 메서드 경로 컨트롤러#액션 용도 GET /posts posts#index 목록 GET /posts/new posts#new 작성 폼 POST /posts posts#create 생성 GET /posts/:id posts#show 상세 GET /posts/:id/edit posts#edit 수정 폼 PATCH/PUT /posts/:id posts#update 수정 DELETE /posts/:id posts#destroy 삭제 rake routes 명령으로 현재 정의된 모든 라우트를 확인할 수 있습니다." }, { "title": "한 걸음 앞선 개발자가 지금 꼭 알아야 할 클로드코드를 읽고 나서...", "url": "/posts/%ED%95%9C%EA%B1%B8%EC%9D%8C-%EC%95%9E%EC%84%A0-%EA%B0%9C%EB%B0%9C%EC%9E%90%EA%B0%80-%EC%A7%80%EA%B8%88-%EA%BC%AD-%EC%95%8C%EC%95%84%EC%95%BC%ED%95%A0-%ED%81%B4%EB%A1%9C%EB%93%9C-%EC%BD%94%EB%93%9C%EB%A5%BC-%EC%9D%BD%EA%B3%A0-%EB%82%98%EC%84%9C/", "categories": "Review, Book", "tags": "Claude, Claude Code", "date": "2026-03-11 07:30:00 +0900", "snippet": " 한 걸음 앞선 개발자가 지금 꼭 알아야 할 클로드코드 저자: 조훈, 정찬훈 출판사: 길벗 출간일: 2025.09.12 ISBN: 9791140715725 클로드 코드를 처음 접한 것은 코드팩토리라는 유튜버님의 영상에서였다. ChatGPT에 구현해야 할 기능을 물어보고 거기에 대한 소스를 받으면 복사 붙여넣기를 한 ...", "content": " 한 걸음 앞선 개발자가 지금 꼭 알아야 할 클로드코드 저자: 조훈, 정찬훈 출판사: 길벗 출간일: 2025.09.12 ISBN: 9791140715725 클로드 코드를 처음 접한 것은 코드팩토리라는 유튜버님의 영상에서였다. ChatGPT에 구현해야 할 기능을 물어보고 거기에 대한 소스를 받으면 복사 붙여넣기를 한 뒤, 기능이 제대로 동작하는지 체크하고 수정하고 배포하는 식을 경험해본 나로서는 정말 다른 신세계가 여기 있었구나 싶었다.사실 취업을 다시 준비하는 입장에서 추가 비용을 들여가며 이렇게 무언가를 사용한다는 게 부담스럽긴 했지만 월 20달러에 이 정도 사용성을 보장한다면, 단순히 에이전트 코딩 이외에도 공부용으로도 많이 사용될 수 있겠다 싶어 개인적으로는 만족하며 사용하는 중이다.이 책은 4주라는 시간 동안 클로드 코드의 여러 활용법에 대해서 말하고 있다. 설치부터, 맨 마지막에서는 회사를 위한 자체 MCP 서버 구축이 필요한 이유와 메인 에이전트를 돕는 서브 에이전트의 역할과 사용법까지 심도 깊은 내용을 말하고 있다. 마지막 주차에 가까워질수록 실습이 조금 줄어드는 느낌이 들긴 했지만 전반적으로 내가 클로드 코드를 사용하면 앞으로 이러이러한 것들을 해볼 수 있겠구나 가이드를 잡는 데 좋은 역할을 해준 책이었단 생각이 든다.개인적으로 책을 끝까지 읽고 느낀 건… 요새 조금 불안하긴 했다. AI가 세상에 너무 널리 퍼지면서 나의 일자리가 이젠 진짜 없어질 수도 있겠다라는 생각이 들었기 때문이다. 이제 주니어 개발자도 AI를 활용해서 쉽게 기능을 구현하고, 코드 리뷰까지 전문가 수준의 동료를 자기 옆에 24시간 두고 일을 할 수 있게 됐고, 아이디어가 있는 누구나 자기가 원하는 것을 만들 수 있는 세상이 됐기 때문에 그런 생각이 들었던 거 같다. 그러면서 저자는 책의 마지막 장에서 “인공지능은 완벽할 수 없다는 점”을 다시 한번 말하고 있다. 세상에 개발자는 많고 앞으로 회사는 단순히 개발만 하는 사람보다 이제 AI에게 올바르게 일을 시킬 수 있는 개발자를 뽑을 것이다라는 이야기와 함께 따라서 많은 분이 걱정하고 우려하는 ‘나의 일자리가 사라진다’라는 것은 ‘나의 일자리의 형태가 바뀐다’로 보는 것이 좀 더 적합하며, 사실 이를 위해서 더 많은 범위의 학습이 필요합니다.라고 말하고 있다. 일자리가 없어질 거라 고민하는 나에 대한 당근인가…AI가 개발직을 포함해 다양한 직종에 앞으로 더더욱 많은 영향을 미치겠지만, 99%의 답을 준다고 해도 그것을 판단하고 실행하고 책임져야 하는 몫은 여전히 인간에게 있다라는 말을 뒷받침해주는 듯 하다. 그렇듯 꾸준히 이쪽에 관심을 두고 자신이 어떻게 활용하여 자신의 업무 효율성을 늘릴 수 있을 것인가. 단순히 오늘내일 동일하게, 비슷하게 살아가려는 마음가짐보다 꾸준히 자신에게 적용해본다면 그럴 걱정은 아직 하지 않아도 된다고 말하고 있는 듯했다.클로드 코드를 비롯해 바이브코딩, 에이전트 AI 등 여러 가지 키워드로 요새 책이 여러 가지 많이 나오고 있는 것 같은데, 사실 코드팩토리님이 작성하신 클로드 코드 관련 책은 밀리의 서재에서 읽는 게 가능해 이 책을 구매했고, 지금 당장 책의 모든 내용을 적용한다기보다 기억해 뒀다가 내가 앞으로 진행할 여러 가지 일들에 조금씩 사용해보면 좋을 거 같다." }, { "title": "(무료 Live) 2026 백엔드 개발자 취업 전략 설명회 보고나서...", "url": "/posts/%EB%B0%B1%EC%97%94%EB%93%9C-%EA%B0%9C%EB%B0%9C%EC%9E%90-%EC%B7%A8%EC%97%85-%EC%A0%84%EB%9E%B5-%EC%84%A4%EB%AA%85%ED%9A%8C-%EB%B3%B4%EA%B3%A0%EB%82%98%EC%84%9C/", "categories": "Review, Seminar", "tags": "Backend, Job", "date": "2026-03-11 07:30:00 +0900", "snippet": " 📌 보러가기 시니님 AI의 활용과 튼튼한 기본기 누구나 AI를 곁에… 얼마나 이것을 잘 쓰는지가 중요 그래서 AI… 이 도구를 얼마나 잘 쓸 수 있냐? 내 역량이 50인데 이것을 사용하면 어디까지 끌어올릴 수 있냐? 이제 면접관은 이게 궁금할 수도 ...", "content": " 📌 보러가기 시니님 AI의 활용과 튼튼한 기본기 누구나 AI를 곁에… 얼마나 이것을 잘 쓰는지가 중요 그래서 AI… 이 도구를 얼마나 잘 쓸 수 있냐? 내 역량이 50인데 이것을 사용하면 어디까지 끌어올릴 수 있냐? 이제 면접관은 이게 궁금할 수도 거인의 어깨에 올라타라 but, 복붙이 답은 아니다. 코드가 빠르고 그럴듯하게 짜는건 이미 AI가 다 하는걸 누구나 (+면접관) 알고 있다. 중요한건, 근본… 문제가 발생했을때 어떻게 해결할 것인가. 취업 전략… 일단은 AI를 사용해서 퀄리티를 최대한 올릴 것 그리고 사용하면서 결정한 것들에 대해서 왜 이걸 선택했는지 치열하게 고민해 볼것 지원 전략 wanted, programmers, rallit, saramin -&gt; 여기에 올라온 기업들은 사실 전체 기업의 1%정도밖에 안된다 ( 실제로 여기 올리는 기업들은 그만큼의 중개 수수료를 지불할 역량이 있다는 것 ) 가고 싶은 회사를 딱 5개 추리기 대기업이 아니어도 됨, 중견 + 스타트업 수시로 들어가서 상시채용을 노리는것도 전략 자사 사이트에 공고를 올리는 곳도 많다. 독취사 + 전산실사람들 =&gt; 여기도 비공식 루트로 올라오는 채용공고가 많다. 멘토님 느낌상… 채용이 안되는 사람들 보면 자신이 올라운더가 되려고함. 면접관 입장에서는 필요한 인력이 올라운더가 아닐 수도 있다. 키워드 &amp; 프로젝트 키워드 취업은 소개팅과 같다… 나의 키워드와 회사가 원하는 키워드가 찰떡같이 맞아야 한다. 그래서 100개 200개 지원하는게 아니라, 우대사항 채용공고의 기술스택 꼼꼼히 뜯어보고 매칭이 많이 되는 곳을 지원하는 게 중요하다! 불합격하는애들 특, 걍 지원하는 애들… 프로젝트 멘토님 보기에는… 취업 못하는 사람들 특징을 보고 그 사람이 하는걸 안하면 됨 합격자 특징은? 가고싶은 채용공고 싹 다 분석 여기서 이력서에 어필할 스택을 뽑아냄 이력서에 작성할 스토리를 만들고! 그걸 이뤄낼 수 있는 맞춤형 프로젝트를 진행 여기에 AI를 적극적으로 사용 ( 여기서 특히 복붙해서 끝내는게 아니라, 내것으로 만드는게 중요 ) 제이온님 면접관은 어떤 문제를 어떻게 해결했는지? 궁금하다. 문제정의 -&gt; 해결과정 -&gt; 성과가 잘 보여야 합격률이 높아진다. AI가 작성해준 소스를 내가 지속가능한 소스로 만들어낼 수 있는지가 중요함…. 직무면접 Keyword 문제 해결력 실무에서 주어지는 문제가 언제나 명확하지 않다. 모호함…ambiguity 모호한 문제를 질문을 통해 점점 좁혀하고, 해결가능한 방법을 찾아내는것. 거기엔 여러 옵션이 있을 수 있는데 면접관은 여러가지 선택지 중 왜 최적의 가성비인지 설명할 줄 알아야 한다. 그럼 왜 면접관이 문제해결력을 보냐? AI, 내가 모두 이해할 수 있도록 sync를 맞춤 문제를 작게 쪼개서 해결하려 한다. 이거 책에서 본 내용인데? chain of thought, sequential thinking AI가 다 아는데 CS 공부를 해야하나? AI가 답변한 내용을 검증하려면 결국 CS 지식이 중요… AI답변 검증의 3가지 기준 자료구조 &amp; 알고리즘 OS &amp; 운영체제 데이터베이스 CS지식이 깊을수록,,, AI를 사용해도 더 깊은(정교한) 질문을 할 수 있다. 마무리 박재성님 대부분의 취준생들이 잘 모르는 쉽게 취업하는 루트 2026년엔? 운영 및 유지보수 경험이 유리한 스펙 AI 가 등장하면서 일일이 구현하는 비중이 낮아지고, “실제 서비스를 운영하며 만날 수 있는 문제를 어떻게 해결했는지… “ 이는 결국 경력직을 뽑는 추세와 비슷한 맥락이다. 완전 신생 스타트업에 들어가서 운영을 경험해보는게 좋을 수도 있다. 후기불안하고 공백기가 너무 길어서, 뭐라도 봐야겠다 해서 보게된 세미나였는데 Q&amp;A 시간의 여러 질문들을 보니 나와 같은 고민을 하고 계신분들이 많았다.심지어 나보다 더 나이가 있으신분 같은데 이전 직장에서 나와 개발자로서의 삶을 살아보려 도전하시는 분들도 많았고, 어떤 친구는 이제 막 졸업한 학생같은데 어디서부터 어떻게 준비해야 할지 막막해 하는 사람들도 있었고, 아무튼 참 여러가지 고민을 가진 사람들이 세미나를 보러 온 것 같았다.세미나를 보면서 공통의 관심사였다고 생각이 드는 것은 “이제 다들 AI를 활용해 개발을 하는데, 이력서 준비는 어떻게 해야하고 다른 사람들과 차별점을 어떻게 만들어야 하는지?” 이 부분을 많은 분들이 궁금해 하는 것 같았다.강사님들은 CS와 같은 근본을 중요시 하기도 했고, 누구나 구현하는게 맞으니 이제 그 구현된걸 자신이 유지보수 할 수 있는 역량이 있는지 포커스를 맞출 필요가 있다는 얘기도 했다. 요즘 하도 바이브코딩 ~ 바이브코딩 하다보니 간간히 여러 유튜브를 봐도 이게 참 너무 좋은데 막상 손대보려 하면 코드가 너무 뒤죽박죽 섞여 있고, 그렇다보니 한쪽을 수정했을때 그 영향도가 어디까지 뻗쳐 나갈지 짐작이 안간다는 거였다. 개발자가 아니셨던 분들은 이제 운영은 좀 되는거같은데 어느 한 부분을 손을 대려 하니 어디서부터 손대야 하지 막막해서 그런 부분에 하소연하시던 영상을 많이 봤는데 개발자인 사람도 마찬가지 아닌가? 하는 생각이 들었다. 어느 한 친구 질문은 이제 AI도움 없이는 스프링 소스 한줄도 어디서부터 작성해야 할지 모르겠다 하는 얘기가 있었는데 그럴법도 하지 않을까 했다.그니까 중요한건 퍼포먼스는 이제 누구나 어느 평균선까지 올리는건 어렵지 않으니, 그걸 좀 더 효율적으로 운영하고 유지보수 하는 것! 거기서 더 나아가 회사입장에서 중요한 비용과 효율을 고려한 그런 것들이 개개인의 매력포인트가 아닐까? 하는 생각이 들었다.…저녁 늦게 들어와서 밥먹고 샤워하다가 배가 너무 고파서 나중에 볼까 하다가, 잠깐 보는 사이에 앉아서 끝까지 봐버렸다. 나름대로 알찬 내용이었고 실제로 취업준비 뿐만 아니라 프로젝트를 진행하는데 있어서도 도움을 받을 내용이 많아서 좋았던 세미나였다고 생각한다. 박재성님 강의를 이전에 몇개 본 적 있는데 수강생들이 듣기 편한 말투와 속도로 강의를 하시는거 같았는데 역시나 이번에도 다른 멘토 분들과 좋은 세미나를 만들어 주셨네…영상을 보고 일말의 희망을 얻긴했지만 대충 4~6개월? 정도 안되면, 개발을 어떻게 해야할지 고민을 하고 있다. 남들처럼 자리도 잡고 이것저것 해야 할 나이인데 92년생 35살인 지금 많이 늦지 않았나 생각이 들기 때문이다. 마지막 몇개월을 좀더 효율적으로 시간을 보내보자. 이것저것 많이 보고 소화하고 이해하고, 스펙트럼을 너무 넓힌다기보다 일단의 취업을 위해 강점을 빠른 시일내에 만드는데 집중해야 할 것 같다." }, { "title": "Red-Green-Refactor 사이클, TDD by Claude", "url": "/posts/red-green-refactor/", "categories": "Study, AI Tools", "tags": "TDD, Red-Green-Refactor, Claude, Claude Code", "date": "2026-03-10 07:30:00 +0900", "snippet": " “한걸음 앞선 개발자가 지금 꼭 알아야할 클로드코드” 책을 읽으면서 정리한 내용입니다.TDD란테스트는 소프트웨어 품질을 보장하는 핵심 요소 중 하나다. TDD(Test-Driven Development)는 테스트를 먼저 작성하고, 그 테스트를 통과하는 코드를 구현하는 개발 방법론이다. Claude Code를 활용하면 테스트 전략을 효과적으로 수립하...", "content": " “한걸음 앞선 개발자가 지금 꼭 알아야할 클로드코드” 책을 읽으면서 정리한 내용입니다.TDD란테스트는 소프트웨어 품질을 보장하는 핵심 요소 중 하나다. TDD(Test-Driven Development)는 테스트를 먼저 작성하고, 그 테스트를 통과하는 코드를 구현하는 개발 방법론이다. Claude Code를 활용하면 테스트 전략을 효과적으로 수립하고, 작성하는 데 걸리는 시간을 크게 줄일 수 있다.Red-Green-Refactor 사이클TDD의 핵심은 아래 세 단계를 반복하는 것이다. Red — 실패하는 테스트 작성아직 구현되지 않은 기능에 대한 테스트를 먼저 작성한다. 테스트는 당연히 실패한다. 이 단계의 목적은 요구사항을 명확하게 정의하는 것이다. Green — 최소한의 코드로 테스트 통과시키기테스트를 통과시키는 데 필요한 최소한의 코드만 작성한다. 코드 품질보다 테스트 통과가 우선이다. Refactor — 코드 품질 개선테스트가 통과하는 상태에서 코드의 구조와 품질을 개선한다. 테스트가 안전망 역할을 하기 때문에 안심하고 리팩터링할 수 있다. Claude Code에게 실전 예제 요청해보기Claude Code에 직접 TDD 실전 예제를 요청해봤다. 아래는 그 결과다. 사이드 프로젝트에 바로 적용해봐야겠다.TDD Red-Green-Refactor 실전 예제시나리오: 게시판 게시물 작성 API (POST /boards/{boardId}/posts) 시니어 개발자의 관점에서, 하나의 기능을 TDD로 구현하는 전체 흐름을 보여준다. 각 사이클마다 Red → Green → Refactor가 어떻게 돌아가는지에 집중해보자.요구사항 특정 게시판(boardId)에 게시물을 작성한다. 제목과 본문은 필수이며, 제목은 100자 이내여야 한다. 존재하지 않는 게시판에 글을 쓸 수 없다. 성공 시 201 Created와 함께 생성된 게시물을 반환한다.프로젝트 구조 (Clean Architecture)src/├── main/java/com/example/board/│ ├── controller/PostController.java│ ├── service/PostService.java│ ├── domain/Post.java│ ├── dto/CreatePostRequest.java│ └── repository/PostRepository.java└── test/java/com/example/board/ ├── service/PostServiceTest.java ← 단위 테스트 (우리의 주 무대) └── controller/PostControllerTest.java ← 통합 테스트사이클 1: 게시물 생성의 기본 흐름Red — 실패하는 테스트 작성가장 기본적인 성공 케이스부터 시작한다. “제목과 본문이 주어지면, 게시물이 생성되어야 한다.”@ExtendWith(MockitoExtension.class)class PostServiceTest { @Mock private PostRepository postRepository; @Mock private BoardRepository boardRepository; @InjectMocks private PostService postService; @Test @DisplayName(\"유효한 요청이 주어지면 게시물이 생성된다\") void shouldCreatePost_WhenRequestIsValid() { // given Long boardId = 1L; CreatePostRequest request = new CreatePostRequest(\"첫 번째 글\", \"안녕하세요, 반갑습니다.\"); given(boardRepository.existsById(boardId)).willReturn(true); given(postRepository.save(any(Post.class))) .willAnswer(invocation -&gt; { Post post = invocation.getArgument(0); return Post.builder() .id(1L) .boardId(post.getBoardId()) .title(post.getTitle()) .content(post.getContent()) .build(); }); // when Post result = postService.createPost(boardId, request); // then assertThat(result.getId()).isEqualTo(1L); assertThat(result.getBoardId()).isEqualTo(boardId); assertThat(result.getTitle()).isEqualTo(\"첫 번째 글\"); assertThat(result.getContent()).isEqualTo(\"안녕하세요, 반갑습니다.\"); then(postRepository).should().save(any(Post.class)); }}이 시점에서는 컴파일조차 안 된다. PostService, CreatePostRequest, Post 전부 없으니까. 이것이 정상이다. Red는 “실패”를 확인하는 단계다.Green — 테스트를 통과시키는 최소한의 코드컴파일 에러를 해결하고, 테스트가 통과할 만큼만 구현한다.// CreatePostRequest.javapublic record CreatePostRequest( String title, String content) {}// Post.java@Getter@Builder@AllArgsConstructorpublic class Post { private Long id; private Long boardId; private String title; private String content;}// PostService.java@Service@RequiredArgsConstructorpublic class PostService { private final PostRepository postRepository; private final BoardRepository boardRepository; public Post createPost(Long boardId, CreatePostRequest request) { Post post = Post.builder() .boardId(boardId) .title(request.title()) .content(request.content()) .build(); return postRepository.save(post); }}테스트 결과: ✅ GREEN boardRepository.existsById() 검증 로직은 아직 없다. Green 단계에서는 현재 테스트를 통과시키는 것에만 집중한다. 게시판 존재 여부 검증은 다음 사이클에서 별도 테스트로 추가한다.Refactor — 현 시점에서는 할 것 없음코드가 워낙 단순하므로 리팩터링 대상이 없다. Refactor는 “반드시 해야 하는 것”이 아니다. 필요할 때만 한다.사이클 2: 존재하지 않는 게시판 예외 처리Red@Test@DisplayName(\"존재하지 않는 게시판이면 예외가 발생한다\")void shouldThrowException_WhenBoardNotFound() { // given Long invalidBoardId = 999L; CreatePostRequest request = new CreatePostRequest(\"제목\", \"본문\"); given(boardRepository.existsById(invalidBoardId)).willReturn(false); // when &amp; then assertThatThrownBy(() -&gt; postService.createPost(invalidBoardId, request)) .isInstanceOf(BoardNotFoundException.class) .hasMessageContaining(\"999\");}테스트 결과: ❌ RED — BoardNotFoundException 클래스도 없고, 검증 로직도 없으니 당연히 실패한다.Green// BoardNotFoundException.javapublic class BoardNotFoundException extends RuntimeException { public BoardNotFoundException(Long boardId) { super(\"게시판을 찾을 수 없습니다. boardId=\" + boardId); }}// PostService.java — 검증 로직 추가public Post createPost(Long boardId, CreatePostRequest request) { if (!boardRepository.existsById(boardId)) { // ← 추가 throw new BoardNotFoundException(boardId); // ← 추가 } Post post = Post.builder() .boardId(boardId) .title(request.title()) .content(request.content()) .build(); return postRepository.save(post);}테스트 결과: ✅ GREEN (사이클 1 테스트도 여전히 통과)Refactor — 여전히 깔끔하므로 패스사이클 3: 제목 빈 값 검증Red@Test@DisplayName(\"제목이 비어있으면 예외가 발생한다\")void shouldThrowException_WhenTitleIsBlank() { // given Long boardId = 1L; CreatePostRequest request = new CreatePostRequest(\"\", \"본문\"); given(boardRepository.existsById(boardId)).willReturn(true); // when &amp; then assertThatThrownBy(() -&gt; postService.createPost(boardId, request)) .isInstanceOf(IllegalArgumentException.class) .hasMessageContaining(\"제목\");}테스트 결과: ❌ RED — 빈 제목이 그대로 통과해버린다.Green// PostService.javapublic Post createPost(Long boardId, CreatePostRequest request) { if (!boardRepository.existsById(boardId)) { throw new BoardNotFoundException(boardId); } if (request.title() == null || request.title().isBlank()) { // ← 추가 throw new IllegalArgumentException(\"제목은 필수입니다.\"); // ← 추가 } Post post = Post.builder() .boardId(boardId) .title(request.title()) .content(request.content()) .build(); return postRepository.save(post);}테스트 결과: ✅ GREENRefactor — 아직은 참는다검증 로직이 2개인데, 아직 리팩터링하기엔 이르다. Rule of Three — 패턴이 3번 반복될 때까지 기다린다.사이클 4: 제목 길이 제한 (100자)Red@Test@DisplayName(\"제목이 100자를 초과하면 예외가 발생한다\")void shouldThrowException_WhenTitleExceedsMaxLength() { // given Long boardId = 1L; String longTitle = \"가\".repeat(101); CreatePostRequest request = new CreatePostRequest(longTitle, \"본문\"); given(boardRepository.existsById(boardId)).willReturn(true); // when &amp; then assertThatThrownBy(() -&gt; postService.createPost(boardId, request)) .isInstanceOf(IllegalArgumentException.class) .hasMessageContaining(\"100자\");}테스트 결과: ❌ REDGreen// PostService.javapublic Post createPost(Long boardId, CreatePostRequest request) { if (!boardRepository.existsById(boardId)) { throw new BoardNotFoundException(boardId); } if (request.title() == null || request.title().isBlank()) { throw new IllegalArgumentException(\"제목은 필수입니다.\"); } if (request.title().length() &gt; 100) { // ← 추가 throw new IllegalArgumentException(\"제목은 100자 이내여야 합니다.\"); // ← 추가 } Post post = Post.builder() .boardId(boardId) .title(request.title()) .content(request.content()) .build(); return postRepository.save(post);}테스트 결과: ✅ GREENRefactor — 이제 할 때가 됐다검증 로직이 3개로 늘어났다. Service에 검증 책임이 비대해지고 있다. 도메인 객체에 검증 책임을 이동시킨다.// Post.java — 도메인 객체가 자기 자신을 검증@Getterpublic class Post { private Long id; private Long boardId; private String title; private String content; @Builder public Post(Long id, Long boardId, String title, String content) { validateTitle(title); validateContent(content); this.id = id; this.boardId = boardId; this.title = title; this.content = content; } private void validateTitle(String title) { if (title == null || title.isBlank()) { throw new IllegalArgumentException(\"제목은 필수입니다.\"); } if (title.length() &gt; 100) { throw new IllegalArgumentException(\"제목은 100자 이내여야 합니다.\"); } } private void validateContent(String content) { if (content == null || content.isBlank()) { throw new IllegalArgumentException(\"본문은 필수입니다.\"); } }}// PostService.java — 검증 로직이 사라져 훨씬 깔끔해졌다@Service@RequiredArgsConstructorpublic class PostService { private final PostRepository postRepository; private final BoardRepository boardRepository; public Post createPost(Long boardId, CreatePostRequest request) { if (!boardRepository.existsById(boardId)) { throw new BoardNotFoundException(boardId); } Post post = Post.builder() .boardId(boardId) .title(request.title()) .content(request.content()) .build(); return postRepository.save(post); }}리팩터링 후 전체 테스트 실행: ✅ 4개 모두 GREEN 이것이 Refactor의 핵심이다. 동작은 바꾸지 않고, 구조만 개선한다. 테스트가 보호막 역할을 하기 때문에 리팩터링이 안전하다.사이클 5: Controller 통합 테스트Service 로직이 안정되었으니, 이제 HTTP 레이어를 테스트한다.Red@WebMvcTest(PostController.class)class PostControllerTest { @Autowired private MockMvc mockMvc; @MockBean private PostService postService; @Autowired private ObjectMapper objectMapper; @Test @DisplayName(\"POST /boards/{boardId}/posts → 201 Created\") void shouldReturn201_WhenPostCreated() throws Exception { // given Long boardId = 1L; CreatePostRequest request = new CreatePostRequest(\"제목\", \"본문\"); Post savedPost = Post.builder() .id(1L).boardId(boardId).title(\"제목\").content(\"본문\") .build(); given(postService.createPost(eq(boardId), any(CreatePostRequest.class))) .willReturn(savedPost); // when &amp; then mockMvc.perform(post(\"/boards/{boardId}/posts\", boardId) .contentType(MediaType.APPLICATION_JSON) .content(objectMapper.writeValueAsString(request))) .andExpect(status().isCreated()) .andExpect(jsonPath(\"$.id\").value(1L)) .andExpect(jsonPath(\"$.title\").value(\"제목\")) .andExpect(jsonPath(\"$.content\").value(\"본문\")); } @Test @DisplayName(\"POST /boards/{boardId}/posts → 404 Not Found (존재하지 않는 게시판)\") void shouldReturn404_WhenBoardNotFound() throws Exception { // given Long boardId = 999L; CreatePostRequest request = new CreatePostRequest(\"제목\", \"본문\"); given(postService.createPost(eq(boardId), any(CreatePostRequest.class))) .willThrow(new BoardNotFoundException(boardId)); // when &amp; then mockMvc.perform(post(\"/boards/{boardId}/posts\", boardId) .contentType(MediaType.APPLICATION_JSON) .content(objectMapper.writeValueAsString(request))) .andExpect(status().isNotFound()); }}테스트 결과: ❌ RED — PostController 자체가 없으니까.Green// PostController.java@RestController@RequiredArgsConstructor@RequestMapping(\"/boards/{boardId}/posts\")public class PostController { private final PostService postService; @PostMapping public ResponseEntity&lt;Post&gt; createPost( @PathVariable Long boardId, @RequestBody CreatePostRequest request) { Post post = postService.createPost(boardId, request); return ResponseEntity.status(HttpStatus.CREATED).body(post); }}// GlobalExceptionHandler.java@RestControllerAdvicepublic class GlobalExceptionHandler { @ExceptionHandler(BoardNotFoundException.class) public ResponseEntity&lt;String&gt; handleBoardNotFound(BoardNotFoundException e) { return ResponseEntity.status(HttpStatus.NOT_FOUND).body(e.getMessage()); } @ExceptionHandler(IllegalArgumentException.class) public ResponseEntity&lt;String&gt; handleIllegalArgument(IllegalArgumentException e) { return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(e.getMessage()); }}테스트 결과: ✅ GREENRefactorController 응답을 Post 도메인 객체 대신 PostResponse DTO로 분리할 수 있다. 하지만 현 시점에서는 YAGNI(You Ain’t Gonna Need It) 원칙에 따라, 다음 요구사항이 생길 때까지 보류한다.전체 흐름 요약사이클 1: 기본 생성 🔴 컴파일 에러 → 🟢 최소 구현 → 🔵 패스사이클 2: 게시판 존재 검증 🔴 예외 미발생 → 🟢 if문 추가 → 🔵 패스사이클 3: 제목 빈 값 검증 🔴 예외 미발생 → 🟢 if문 추가 → 🔵 참는다 (Rule of Three)사이클 4: 제목 길이 검증 🔴 예외 미발생 → 🟢 if문 추가 → 🔵 도메인으로 검증 이동사이클 5: Controller 통합 🔴 컴파일 에러 → 🟢 Controller 구현 → 🔵 DTO 분리는 보류 (YAGNI)시니어 관점에서의 핵심 포인트1. 테스트가 설계를 이끈다사이클 4에서 검증 로직이 3개가 되자 자연스럽게 “이건 도메인의 책임이다”라는 판단이 내려졌다. TDD를 하면 설계 결정이 감이 아닌 코드의 냄새(Code Smell)로부터 나온다.2. Red에서 “얼마나 작게” 시작하느냐가 기술이다“게시물 생성 + 검증 + 예외처리”를 한 번에 테스트하지 않았다. 한 테스트에 하나의 행위. 이것이 사이클을 빠르게 돌릴 수 있는 비결이다.3. Refactor는 “안 해도 되는 단계”가 아니라 “때를 기다리는 단계”사이클 2~3에서 참았다가 사이클 4에서 한 번에 리팩터링한 것처럼, 충분한 패턴이 보일 때까지 기다렸다가 확신이 생기면 움직인다.4. 테스트가 없는 리팩터링은 도박이다사이클 4에서 검증 로직의 위치를 Service → Domain으로 옮겼지만, 기존 4개의 테스트가 전부 통과했기 때문에 안전하게 구조를 바꿀 수 있었다." }, { "title": "WBS와 ADR, 개발 프로젝트 설계 문서를 체계적으로 작성하는 법", "url": "/posts/WBS-ADR/", "categories": "Study, AI Tools", "tags": "WBS, ADR, Claude, Claude Code", "date": "2026-03-06 00:00:00 +0900", "snippet": " “한걸음 앞선 개발자가 지금 꼭 알아야할 클로드코드” 보는 중…프로젝트를 설계하다 보면 작성해야 할 문서가 생각보다 많다.특히 기존 팀원이 아닌 신규 인원이 투입될 때마다 온보딩에 상당한 시간이 소요된다. 이 비용을 줄이려면 설계 단계에서부터 문서를 체계적으로 정리해두는 습관이 중요하다.Claude를 활용하면 기획 초기 단계부터 기술 스택 선정, ...", "content": " “한걸음 앞선 개발자가 지금 꼭 알아야할 클로드코드” 보는 중…프로젝트를 설계하다 보면 작성해야 할 문서가 생각보다 많다.특히 기존 팀원이 아닌 신규 인원이 투입될 때마다 온보딩에 상당한 시간이 소요된다. 이 비용을 줄이려면 설계 단계에서부터 문서를 체계적으로 정리해두는 습관이 중요하다.Claude를 활용하면 기획 초기 단계부터 기술 스택 선정, 예상 리스크 검토 등 다양한 부분에서 도움을 받을 수 있다.이 포스트에서는 평소 낯설었던 WBS와 ADR의 개념을 정리하고, Claude에게 프로젝트 아키텍처 설계를 부탁해본 결과를 공유한다.WBS (Work Breakdown Structure)WBS는 프로젝트의 전체 범위를 관리 가능한 단위로 분해하는 기법이다.작업을 잘게 나눔으로써 일정 예측이 정교해지고, 리소스 배분을 균형 있게 조율할 수 있다. 과거에는 PM이 중심이 되어 수행하던 역할이지만, 지금은 Claude 같은 AI 도구가 초안 작성을 상당 부분 도와줄 수 있다.ADR (Architecture Decision Record)ADR은 아키텍처상의 주요 결정을 체계적으로 기록하는 방법이다.시스템 설계 과정에서는 수많은 기술적 선택이 이루어진다. 이를 기록해두지 않으면, 시간이 지나 “왜 이 구조를 선택했지?”라는 의문이 생길 때 답을 찾기 어렵다. 이는 불필요한 리팩토링이나 기술 부채로 이어질 수 있다.ADR이 주는 이점 항목 설명 트레이드오프 명시 Y-Statement 형식: “X를 선택함으로써 우리는 Y를 얻는 대신 Z를 감수한다.” 미래의 팀을 위한 기록 결정 당시의 맥락이 사라지지 않도록 보존 온보딩 속도 향상 신규 인원이 설계 의도를 빠르게 파악 기술 부채 관리 의사결정 근거가 명확하면 리팩토링 범위를 합리적으로 판단 가능 모든 기술적 결정에는 트레이드오프가 존재한다. ADR은 그 트레이드오프를 직시하고 기록하는 습관을 만들어준다.Claude에게 프로젝트 아키텍처 설계 부탁하기요청결과Claude가 마이크로서비스 기반의 온라인 쇼핑몰 아키텍처를 Mermaid 다이어그램으로 생성해주었다.⏺ 온라인 쇼핑몰 마이크로서비스 아키텍처 전체 시스템 아키텍처 graph TB Client[\"🖥️ Client\\n(Web / Mobile)\"] subgraph Gateway[\"API Gateway Layer\"] AG[\"API Gateway\\n(Spring Cloud Gateway)\"] LB[\"Load Balancer\"] end subgraph Auth[\"인증 서비스\"] AS[\"Auth Service\\n:8001\"] AuthDB[(\"Auth DB\\nPostgreSQL\")] Redis[(\"Redis\\nSession/Token\")] end subgraph Product[\"상품 서비스\"] PS[\"Product Service\\n:8002\"] ProductDB[(\"Product DB\\nPostgreSQL\")] ES[\"Elasticsearch\\n(검색)\"] S3[\"S3\\n(이미지)\"] end subgraph Order[\"주문 서비스\"] OS[\"Order Service\\n:8003\"] OrderDB[(\"Order DB\\nPostgreSQL\")] end subgraph Payment[\"결제 서비스\"] PAY[\"Payment Service\\n:8004\"] PayDB[(\"Payment DB\\nPostgreSQL\")] PG[\"PG사\\n(외부 결제)\"] end subgraph Delivery[\"배송 서비스\"] DS[\"Delivery Service\\n:8005\"] DeliveryDB[(\"Delivery DB\\nPostgreSQL\")] Carrier[\"배송사 API\\n(외부)\"] end subgraph Notification[\"알림 서비스\"] NS[\"Notification Service\\n:8006\"] Email[\"Email\"] SMS[\"SMS / Push\"] end subgraph Infra[\"인프라\"] MQ[\"Message Broker\\n(Kafka)\"] SD[\"Service Discovery\\n(Eureka)\"] CF[\"Config Server\"] end Client --&gt; LB --&gt; AG AG --&gt; AS &amp; PS &amp; OS &amp; PAY &amp; DS AS --- AuthDB &amp; Redis PS --- ProductDB &amp; ES &amp; S3 OS --- OrderDB PAY --- PayDB &amp; PG DS --- DeliveryDB &amp; Carrier OS --&gt;|\"order.created\"| MQ MQ --&gt;|\"order.created\"| PAY PAY --&gt;|\"payment.completed\"| MQ MQ --&gt;|\"payment.completed\"| DS MQ --&gt;|\"payment.completed\"| NS DS --&gt;|\"delivery.updated\"| MQ MQ --&gt;|\"delivery.updated\"| NS NS --- Email &amp; SMS AG --- SD AS &amp; PS &amp; OS &amp; PAY &amp; DS &amp; NS --- SD AS &amp; PS &amp; OS &amp; PAY &amp; DS &amp; NS --- CF 주문 처리 플로우sequenceDiagram actor User participant GW as API Gateway participant Auth as Auth Service participant Order as Order Service participant Product as Product Service participant Payment as Payment Service participant Delivery as Delivery Service participant Kafka as Kafka participant Notify as Notification User-&gt;&gt;GW: 주문 요청 GW-&gt;&gt;Auth: JWT 검증 Auth--&gt;&gt;GW: 인증 완료 GW-&gt;&gt;Order: 주문 생성 요청 Order-&gt;&gt;Product: 재고 확인 &amp; 차감 Product--&gt;&gt;Order: 재고 확인 완료 Order-&gt;&gt;Order: 주문 저장 (PENDING) Order-&gt;&gt;Kafka: order.created 이벤트 발행 Order--&gt;&gt;GW: 주문번호 반환 GW--&gt;&gt;User: 주문 접수 완료 Kafka-&gt;&gt;Payment: order.created 이벤트 수신 Payment-&gt;&gt;Payment: 결제 처리 Payment-&gt;&gt;Kafka: payment.completed 이벤트 발행 par 병렬 처리 Kafka-&gt;&gt;Order: 주문 상태 업데이트 (PAID) Kafka-&gt;&gt;Delivery: 배송 준비 시작 Kafka-&gt;&gt;Notify: 결제 완료 알림 end Notify--&gt;&gt;User: 이메일 / SMS 알림 Delivery-&gt;&gt;Delivery: 배송 처리 Delivery-&gt;&gt;Kafka: delivery.updated 이벤트 발행 Kafka-&gt;&gt;Notify: 배송 상태 알림 Notify--&gt;&gt;User: 배송 현황 알림 서비스별 책임 정의mindmap root((쇼핑몰\\nMSA)) Auth Service 회원가입 / 로그인 JWT 발급 / 갱신 OAuth2 소셜 로그인 권한 관리 RBAC Product Service 상품 CRUD 카테고리 관리 재고 관리 검색 Elasticsearch 이미지 업로드 S3 Order Service 장바구니 관리 주문 생성 / 취소 주문 이력 조회 주문 상태 관리 Payment Service 결제 요청 / 승인 환불 처리 결제 수단 관리 PG사 연동 Delivery Service 배송 등록 배송 상태 추적 배송사 API 연동 반품 처리 Notification Service 이메일 발송 SMS 발송 푸시 알림 알림 이력 관리mermaid.live에서 확인Mermaid 코드를 mermaid.live에 붙여넣으면 다이어그램을 바로 시각화할 수 있다.프로젝트 설계 단계에서 고민해야 할 내용들을 Claude가 베스트 프랙티스를 기반으로 정리해주기 때문에, 시행착오를 줄이고 검토 시간을 확보하는 데 실질적인 도움이 된다. WBS·ADR과 같은 문서화 습관, 그리고 AI를 설계 파트너로 활용하는 방식을 함께 갖추면 팀 전체의 생산성에도 긍정적인 영향을 줄 수 있다고 생각한다." }, { "title": "MCP 살펴보기", "url": "/posts/MCP/", "categories": "Study, AI Tools", "tags": "MCP, Claude, Claude Code", "date": "2026-03-06 00:00:00 +0900", "snippet": "터미널 도구와 MCP, 무엇이 다를까터미널 기반으로 명령어를 작성하며 Claude를 활용하는 것도 여러 도구와 상호작용할 수 있다. 하지만 MCP를 사용하면 AI가 외부 리소스와 더 안전하고 체계적으로 연동할 수 있다.터미널 도구들이 명령어 기반의 자동화라면, MCP는 구조화된 연동에 특화되어 있다.개발 패러다임의 변화: 프롬프트 엔지니어링 → 컨텍스...", "content": "터미널 도구와 MCP, 무엇이 다를까터미널 기반으로 명령어를 작성하며 Claude를 활용하는 것도 여러 도구와 상호작용할 수 있다. 하지만 MCP를 사용하면 AI가 외부 리소스와 더 안전하고 체계적으로 연동할 수 있다.터미널 도구들이 명령어 기반의 자동화라면, MCP는 구조화된 연동에 특화되어 있다.개발 패러다임의 변화: 프롬프트 엔지니어링 → 컨텍스트 엔지니어링MCP는 단순한 편의기능이 아니다. “프롬프트 엔지니어링”에서 “컨텍스트 엔지니어링”으로의 패러다임 전환을 상징한다.어떻게 질문할 것인가가 아닌, 어떤 데이터와 도구에 접근할 수 있게 할 것인가로 관점이 이동하는 것이다. 프롬프트 엔지니어링은 “AI에게 어떻게 질문을 잘 쓸까”에 집중하는 방식이다. 컨텍스트 엔지니어링은 관점이 다르다. 질문을 잘 쓰는 게 아니라, AI가 접근할 수 있는 환경 자체를 설계하는 것이다. 백엔드 비유로 설명하면, 프롬프트 엔지니어링이 “API 요청 body를 잘 작성하는 것”이라면, 컨텍스트 엔지니어링은 “API 서버가 어떤 DB와 외부 서비스에 연결되는지를 설계하는 것”에 가깝다.실제 예시로 비교하면 차이가 명확하다. 프롬프트 엔지니어링 시대: “이 에러 로그를 봐줘” → 사용자가 로그를 복사·붙여넣기 → AI가 분석 컨텍스트 엔지니어링 시대: AI가 MCP로 로그 시스템에 직접 연결 → 스스로 로그를 조회하고, Git 히스토리도 보고, Jira 이슈도 확인해서 종합적으로 분석MCP(Model Context Protocol)란?AI 모델이 외부 리소스와 안전하게 상호작용할 수 있도록 설계된 개방형 표준이다. Anthropic이 제안했으며, 구조화된 메시지를 주고받기 때문에 정밀하고 안정적인 데이터 처리가 가능하다.MCP의 구조MCP는 클라이언트-서버 구조로 동작한다. MCP Host: Claude Code, Claude Desktop 같이 AI를 실행하는 애플리케이션 MCP Client: Host 내에서 MCP 서버와 통신하는 컴포넌트 MCP Server: 외부 리소스(DB, API, 파일 시스템 등)에 접근하는 서버Claude Code (Host) └── MCP Client ├── PostgreSQL MCP Server → 데이터베이스 ├── GitHub MCP Server → 저장소, 이슈 └── Slack MCP Server → 채널, 메시지AI가 “DB에서 최근 주문을 조회해줘”라고 요청받으면, MCP 클라이언트가 PostgreSQL MCP 서버를 통해 실제 DB에 쿼리를 날리고 결과를 가져오는 방식이다.왜 MCP가 필요한가AI가 외부 시스템에 접근하는 방법은 MCP 이전에도 있었다. 브라우저 조작, 터미널 명령어 등을 통해 간접적으로 연동할 수 있었다. 하지만 이런 방식은 비구조적이고 불안정했다.MCP는 표준화된 프로토콜이기 때문에: 어떤 도구가 어떤 기능을 제공하는지 AI가 정확히 알 수 있다 권한 범위를 명확하게 제어할 수 있다 서버를 한 번 만들면 여러 AI 모델에서 재사용할 수 있다주요 MCP 서버 생태계현재 다양한 MCP 서버가 공개되어 있다. 서버 연동 대상 할 수 있는 것 @modelcontextprotocol/server-postgres PostgreSQL 쿼리 실행, 스키마 조회 @modelcontextprotocol/server-github GitHub 저장소 탐색, 이슈/PR 조회 @modelcontextprotocol/server-slack Slack 메시지 조회, 채널 탐색 @modelcontextprotocol/server-filesystem 로컬 파일 파일 읽기/쓰기 @modelcontextprotocol/server-brave-search Brave Search 웹 검색 PostgreSQL MCP 실습설정Claude에게 자연어로 MCP 설정을 요청한다.$ claude \"postgres MCP를 설정해줘. DB 주소는 postgresql://mycafe_user:mycafe_password@localhost:5432/mycafe_db를 써\"Claude가 내부적으로 claude mcp add 명령을 실행하여 MCP 서버를 등록하고, 프로젝트 루트에 .mcp.json 설정 파일을 자동으로 생성한다.⏺ Bash(claude mcp add postgres -s project -- npx -y @modelcontextprotocol/server-postgres postgresql://mycafe_user:mycafe_password@localhost:5432/mycafe_db) ⎿ Added stdio MCP server postgres with command: npx -y @modelcontextprotocol/server-postgres postgresql://mycafe_user:mycafe_password@localhost:5432/mycafe_db to project config File modified: /Users/chankim/github/_Book_Claude-Code/mcp_test/.mcp.json생성된 .mcp.json은 아래와 같은 구조다.{ \"mcpServers\": { \"postgres\": { \"command\": \"npx\", \"args\": [ \"-y\", \"@modelcontextprotocol/server-postgres\", \"postgresql://mycafe_user:mycafe_password@localhost:5432/mycafe_db\" ] } }}재시작 후 연결 확인Claude를 재시작하면 MCP가 활성화된다. 정상적으로 연결되면 아래와 같이 확인할 수 있다.설치된 MCP 서버 목록도 함께 확인된다.연결 후 활용 예시MCP가 연결되면 자연어로 DB 작업이 가능해진다.\"orders 테이블에서 최근 7일간 금액이 10만원 이상인 주문을 조회해줘\"\"users 테이블의 스키마를 보여줘\"\"이번 달 일별 매출 합계를 집계해줘\"AI가 직접 SQL을 작성하고 실행한 뒤 결과를 분석해준다. 쿼리 결과를 복사해서 붙여넣는 과정이 없어지는 것이다.보안 고려사항MCP는 편리하지만 그만큼 주의가 필요하다. 읽기 전용 계정 사용: 실수로 데이터를 수정하거나 삭제하는 것을 방지하기 위해, MCP용 DB 계정은 SELECT 권한만 부여하는 것이 안전하다. 민감한 정보 노출 주의: .mcp.json에 DB 비밀번호가 평문으로 들어가므로, 이 파일은 절대 Git에 올리면 안 된다. .gitignore에 추가해두자. 프로젝트 범위 제한: project 스코프로 설정하면 해당 프로젝트에서만 MCP가 활성화된다. 전체 적용이 필요한 경우만 global 스코프를 사용하자." }, { "title": "[K8s] 02. 쿠버네티스 핵심 오브젝트: Pod, Service, Volume", "url": "/posts/K8s-02.-%EC%BF%A0%EB%B2%84%EB%84%A4%ED%8B%B0%EC%8A%A4-%ED%95%B5%EC%8B%AC-%EC%98%A4%EB%B8%8C%EC%A0%9D%ED%8A%B8-Pod,-Service,-Volume/", "categories": "Study, Kubernetes", "tags": "Kubernetes, K8s, Pod, Service, Volume, ClusterIP, NodePort, LoadBalancer, PV, PVC", "date": "2026-03-01 00:00:00 +0900", "snippet": "Pod인프런_대세는 쿠버네티스Pod는 쿠버네티스에서 배포할 수 있는 가장 작은 단위입니다. 하나의 Pod 안에는 하나 이상의 컨테이너가 존재하며, 각 컨테이너는 독립적인 서비스를 구동합니다.컨테이너와 포트 각 컨테이너는 서비스에 연결될 수 있도록 포트를 가지고 있다. 한 컨테이너가 여러 개의 포트를 가질 수 있지만, 같은 Pod 내에서 포트가 중복...", "content": "Pod인프런_대세는 쿠버네티스Pod는 쿠버네티스에서 배포할 수 있는 가장 작은 단위입니다. 하나의 Pod 안에는 하나 이상의 컨테이너가 존재하며, 각 컨테이너는 독립적인 서비스를 구동합니다.컨테이너와 포트 각 컨테이너는 서비스에 연결될 수 있도록 포트를 가지고 있다. 한 컨테이너가 여러 개의 포트를 가질 수 있지만, 같은 Pod 내에서 포트가 중복될 수는 없다. 중복된 포트로 컨테이너 생성을 요청하면 로그에서 포트 중복 오류를 확인할 수 있다.Pod의 IP Pod가 생성될 때 고유의 IP가 할당된다. 이 IP는 쿠버네티스 클러스터 내에서만 접근 가능하며, 외부에서는 접근할 수 없다. Pod가 재생성되면 IP도 새로 할당된다. 따라서 Pod의 IP를 직접 사용하는 것은 신뢰성이 떨어진다.Label Pod뿐만 아니라 모든 쿠버네티스 오브젝트에 부여할 수 있다. Key: Value 쌍으로 구성되며, 오브젝트를 분류하고 선택하는 데 사용된다. Service를 생성할 때 Label의 셀렉터를 통해 특정 Pod를 선택하여 연결할 수 있다.Node Schedule Pod는 여러 노드 중 하나의 노드에 배치되어야 한다. 배치 방법은 두 가지다. 직접 선택 : nodeSelector를 사용하여 특정 노드를 지정 스케줄러에 의한 자동 선택 : 쿠버네티스 스케줄러가 각 노드의 자원 상황을 평가하여 최적의 노드를 결정 Pod 생성 시 요청 메모리(requests.memory)를 지정할 수 있으며, 스케줄러는 이 값을 기준으로 충분한 자원이 있는 노드를 선택한다.Service인프런_대세는 쿠버네티스Service는 Pod에 안정적으로 접근하기 위한 추상화 계층이다. Service는 자체적으로 클러스터 IP를 가지며, 이 IP를 통해 연결된 Pod에 접근할 수 있다.왜 Service를 통해 접근해야 할까?Pod는 시스템 장애나 스케일링 등의 이유로 언제든 재생성될 수 있고, 그때마다 IP가 변경된다. 반면 Service는 사용자가 명시적으로 삭제하지 않는 한 IP가 유지되므로, 안정적인 연결을 보장할 수 있다.ClusterIP가장 기본적인 Service 타입이다. (type: ClusterIP는 기본값이므로 생략 가능) 클러스터 내부에서만 접근할 수 있으며, 외부에서는 접근이 불가능하다. 여러 개의 Pod를 연결하면 Service가 자동으로 트래픽을 분산시켜 준다. 인가된 사용자(운영자, 내부 시스템)만 접근해야 하는 서비스에 적합하다.실습 결과클러스터 내부에서 Service IP에 접근하면 정상적으로 응답을 받을 수 있다. 하지만 브라우저에서 10.96.x.x로 접근하면, 내부망이긴 하지만 클러스터 내부는 아니기 때문에 접근할 수 없다.NodePortClusterIP의 기능을 포함하면서, 외부에서 노드의 IP와 지정된 포트를 통해 접근할 수 있도록 해주는 타입이다. 클러스터에 연결된 모든 노드에 동일한 포트가 할당되어, 어느 노드로든 해당 포트로 접근하면 Service에 연결된다. 기본적으로 트래픽이 들어온 노드와 관계없이 다른 노드의 Pod에도 트래픽을 전달할 수 있다. externalTrafficPolicy: Local 옵션을 설정하면, 트래픽이 들어온 노드에 있는 Pod에만 요청을 전달한다. 보통 내부망 안에서의 일시적인 외부 연결 용도로 사용된다.실습 NodePort 타입의 Service 생성 apiVersion: v1kind: Servicemetadata: name: svc-2spec: selector: app: pod ports: - port: 9000 targetPort: 8080 nodePort: 30001 type: NodePort 내부 엔드포인트를 보면 두 개의 엔드포인트가 있다. svc-2:9000 TCP는 클러스터 IP로 접근할 때 사용하는 포트이고, svc-2:30001 TCP는 노드를 통해 외부에서 접근할 때 사용하는 노드 포트이다. Node의 InternalIP 확인 k8s-worker1 노드의 InternalIP: 192.168.56.31 k8s-worker2 노드의 InternalIP: 192.168.56.32 Local PC에서 접근 테스트 각 노드의 포트를 통해 Pod에 정상적으로 접근되는 것을 확인할 수 있다. Pod를 추가한 뒤 트래픽 분산 확인 apiVersion: v1kind: Podmetadata: name: pod-2 labels: app: podspec: nodeSelector: kubernetes.io/hostname: k8s-worker2 containers: - name: container image: kubetm/app ports: - containerPort: 8080 192.168.56.31:30001/hostname으로 요청을 보내면, k8s-worker1 노드로 트래픽이 들어가지만 응답에는 pod-1과 pod-2가 번갈아 나타난다. Service가 트래픽을 분산시키고 있음을 확인할 수 있다. LoadBalancerNodePort의 기능을 포함하면서, 앞단에 로드밸런서가 추가되어 각 노드로 들어오는 트래픽을 분산시켜 준다. 로드밸런서에 접근하기 위한 외부 IP(External IP)는 별도의 플러그인이 필요하다. 쿠버네티스를 직접 설치한 환경에서는 자동으로 생기지 않는다. GCP, AWS 같은 클라우드 환경에서는 클라우드 프로바이더가 외부 IP를 자동으로 할당해 준다. 실제로 외부에 서비스를 노출해야 할 때 사용한다.실습 결과별도의 플러그인이 설치되지 않아 EXTERNAL-IP가 &lt;pending&gt; 상태임을 확인할 수 있다.Volume인프런_대세는 쿠버네티스쿠버네티스에서 Volume은 컨테이너의 데이터를 보존하거나 공유하기 위한 저장소이다. 볼륨의 종류에 따라 데이터의 생명주기와 접근 범위가 달라진다.emptyDir 같은 Pod 내의 컨테이너들끼리 데이터를 공유하기 위해 사용하는 볼륨이다. 이름 그대로 볼륨이 최초 생성될 때 내용이 비어 있다. Pod 내부에 생성되므로, Pod가 삭제되면 볼륨도 함께 삭제된다. 언제 삭제되어도 상관없는 임시 데이터를 보관하는 데 적합하다.hostPath Pod가 올라가 있는 노드의 파일 시스템 경로를 볼륨으로 사용하는 방식이다. emptyDir와 달리 Pod가 삭제되어도 노드에 데이터가 남아 있다. 문제점 : Pod가 재생성될 때 스케줄러에 의해 다른 노드에 배치될 수 있다. 이 경우 기존 노드의 볼륨 데이터에 접근할 수 없게 된다. 운영자가 리눅스의 마운트 시스템을 사용해 노드 간 볼륨을 연결할 수는 있지만, 자동화 흐름에 사람이 개입한다는 점에서 권장하지 않는다. PVC / PVPod에 영속성 있는 볼륨을 제공하기 위한 구조이다. PV(Persistent Volume) : 실제 볼륨(로컬 디스크, NFS, 클라우드 스토리지 등)을 쿠버네티스 오브젝트로 등록한 것 PVC(Persistent Volume Claim) : Pod가 PV를 사용하기 위해 요청하는 오브젝트PV와 PVC를 분리한 이유는 쿠버네티스가 볼륨 관리의 역할을 Admin 영역과 User 영역으로 구분하기 때문이다. Admin(쿠버네티스 운영자) : PV를 생성하고 실제 스토리지를 관리 User(서비스 담당자) : PVC를 통해 필요한 볼륨 스펙을 요청하고 Pod에 연결이러한 분리 덕분에 서비스 담당자는 실제 스토리지의 세부 구현을 알 필요 없이, 필요한 용량과 접근 방식만 명시하면 된다.참고 자료 대세는 쿠버네티스 - 인프런" }, { "title": "프롬프트 엔지니어링", "url": "/posts/%ED%94%84%EB%A1%AC%ED%94%84%ED%8A%B8-%EC%97%94%EC%A7%80%EB%8B%88%EC%96%B4%EB%A7%81/", "categories": "Study, AI Tools", "tags": "Prompt Engineering, Claude, Claude Code", "date": "2026-02-27 00:00:00 +0900", "snippet": " 프롬프트 엔지니어링은 AI에게 원하는 결과를 얻기 위해 질문이나 지시를 어떻게 표현할지 고민하는 과정이다.같은 내용을 요청하더라도 표현 방식에 따라 결과물의 질이 크게 달라진다.핵심 원칙단순히 “무엇을 만들어 달라”는 것이 아니라, 원하는 출력 형식과 고려해야 할 제약 조건을 함께 제시하는 것이 좋다. AI는 모호한 요청에도 그럴듯한 답변을 내놓지...", "content": " 프롬프트 엔지니어링은 AI에게 원하는 결과를 얻기 위해 질문이나 지시를 어떻게 표현할지 고민하는 과정이다.같은 내용을 요청하더라도 표현 방식에 따라 결과물의 질이 크게 달라진다.핵심 원칙단순히 “무엇을 만들어 달라”는 것이 아니라, 원하는 출력 형식과 고려해야 할 제약 조건을 함께 제시하는 것이 좋다. AI는 모호한 요청에도 그럴듯한 답변을 내놓지만, 구체적일수록 실무에 바로 쓸 수 있는 결과물이 나온다.프롬프트의 구성 요소1. 컨텍스트 (Context)AI가 문제를 이해하는 데 필요한 배경 정보를 의미한다.대화가 길어질수록 컨텍스트가 비대해지므로, 아래 명령어를 적절히 활용하자. 명령어 설명 사용 시점 /compact 현재 세션의 컨텍스트를 간소화 대화가 길어져 응답이 느려질 때 /clear 세션 초기화 (완전히 새로 시작) 완전히 다른 주제로 넘어갈 때 /status 현재 세션 상태 확인 세션 상태가 궁금할 때 2. 구체적인 요구 사항 (Specific Requirements)맥락만으로는 부족하다. 구체적으로 충족해야 하는 조건을 명시해야 한다.예를 들어 “코드를 작성해줘”보다는 “Java 17 기준으로, 예외 처리를 포함한 REST API 엔드포인트를 작성해줘”가 훨씬 좋은 결과를 만든다.3. 예상 결과물 (Expected Output)구체적인 출력 형식을 제공해야 실무에 바로 쓸 수 있는 답변을 받을 수 있다.“설명해줘” 대신 “마크다운 테이블로 비교 정리해줘”, “코드 블록에 주석을 포함해줘” 등 형식을 지정하면 결과물의 활용도가 크게 올라간다.주요 프롬프트 기법페르소나 부여 (Role Assignment)AI에게 특정 역할을 부여하면 그에 맞는 관점과 깊이로 답변한다.# 효과 낮음\"이 코드 리뷰해줘\"# 효과 높음\"너는 10년 경력의 시니어 백엔드 개발자야.Spring Security 관점에서 이 코드의 보안 취약점을 찾아줘.발견한 문제는 심각도(높음/중간/낮음)로 분류해서 설명해줘.\"Few-shot 프롬프팅원하는 결과물의 예시를 먼저 보여주는 방식이다. 형식이 중요한 작업(커밋 메시지, 에러 메시지, 문서 등)에 특히 효과적이다.# 아래 형식으로 커밋 메시지를 작성해줘.예시1:feat: 사용자 로그인 기능 추가- JWT 토큰 발급 구현- 리프레시 토큰 처리 추가예시2:fix: 결제 금액 계산 오류 수정- 할인 쿠폰 중복 적용 방지지금 변경한 코드: [코드 붙여넣기]단계적 사고 유도 (Chain-of-Thought)복잡한 문제는 AI에게 단계별로 생각하도록 유도하면 정확도가 올라간다.# 단순 요청\"이 버그의 원인이 뭐야?\"# 단계적 사고 유도\"이 버그를 분석할 때:1. 먼저 에러 메시지와 스택 트레이스를 분석해줘2. 그 다음 코드에서 문제가 될 수 있는 부분을 찾아줘3. 마지막으로 수정 방법을 제안해줘\"제약 조건 명시원하지 않는 결과를 제외하려면 명시적으로 금지 조건을 추가한다.\"이 기능을 리팩터링해줘.단, 외부 라이브러리는 추가하지 말고,기존 테스트가 깨지지 않도록 해줘.성능보다 가독성을 우선으로 해줘.\"컨텍스트 최적화 전략작업 범위를 명확히 제한하기단계적 접근법(Narrow Down) 을 활용한다.큰 그림 → 의심 영역 → 구체적 원인 순으로 좁혀가며 질문하면, 한 번에 모든 것을 요청할 때보다 더 정확한 답변을 얻을 수 있다.CLAUDE.md로 영구 컨텍스트 관리Claude Code에서는 프로젝트 루트에 CLAUDE.md 파일을 두면, 매 세션마다 자동으로 읽혀 컨텍스트가 유지된다. 반복적으로 설명해야 하는 프로젝트 배경, 코딩 컨벤션, 주의사항 등을 여기에 정리해두면 효율이 크게 올라간다.# CLAUDE.md 예시## 프로젝트 개요Spring Boot 3.x 기반 결제 API 서버## 코딩 컨벤션- 변수명은 camelCase- 서비스 레이어에서만 트랜잭션 처리- 모든 외부 API 호출은 재시도 로직 포함## 주의사항- 고객 개인정보는 로그에 절대 출력하지 않음- prod 환경 설정은 절대 수정하지 않음토큰 관리와 세션 최적화토큰이란 AI가 텍스트를 처리하는 기본 단위다. 영어보다 한글이 토큰을 더 많이 소모한다는 점도 참고하자.불필요한 대화가 쌓이면 컨텍스트 윈도우를 낭비하게 되므로, 적절한 시점에 /compact나 /clear를 활용하는 것이 좋다.피해야 할 패턴 나쁜 예 이유 좋은 예 “코드 짜줘” 너무 모호함 “Java 17로 Spring Boot REST API 엔드포인트 작성해줘” “이게 왜 안 돼?” 문제 정보 없음 에러 메시지 + 관련 코드 + 시도한 것을 함께 전달 “다 고쳐줘” 범위 불명확 “성능 문제만 수정해줘, 기능 변경은 하지 말고” 긴 대화 중 새 주제 컨텍스트 오염 /clear 후 새 세션 시작 정리프롬프트 엔지니어링의 핵심은 AI가 판단해야 할 모호한 부분을 최소화하는 것이다. 역할, 요구사항, 제약조건, 출력 형식을 명확하게 줄수록 원하는 결과물에 가까워진다. 처음에는 번거롭게 느껴지지만, 한 번 잘 짜놓은 프롬프트 템플릿은 반복 작업에서 시간을 크게 절약해준다." }, { "title": "[K8s] 01. 쿠버네티스 시작하기: 컨테이너 오케스트레이션의 이해", "url": "/posts/K8s-01.-%EC%BF%A0%EB%B2%84%EB%84%A4%ED%8B%B0%EC%8A%A4-%EC%8B%9C%EC%9E%91%ED%95%98%EA%B8%B0-%EC%BB%A8%ED%85%8C%EC%9D%B4%EB%84%88-%EC%98%A4%EC%BC%80%EC%8A%A4%ED%8A%B8%EB%A0%88%EC%9D%B4%EC%85%98%EC%9D%98-%EC%9D%B4%ED%95%B4/", "categories": "Study, Kubernetes", "tags": "Kubernetes, K8s, Container, Orchestration, Intro", "date": "2026-02-26 00:00:00 +0900", "snippet": "들어가며Docker는 단일 서비스를 컨테이너로 가상화하는 데 탁월하지만, 수많은 컨테이너를 효율적으로 관리하고 운영하는 기능은 제공하지 않습니다. 이러한 한계를 극복하기 위해 등장한 것이 바로 컨테이너 오케스트레이터인 쿠버네티스(Kubernetes)입니다.쿠버네티스는 Auto Scaling, Auto Healing, 배포 자동화 등 대규모 컨테이너 환...", "content": "들어가며Docker는 단일 서비스를 컨테이너로 가상화하는 데 탁월하지만, 수많은 컨테이너를 효율적으로 관리하고 운영하는 기능은 제공하지 않습니다. 이러한 한계를 극복하기 위해 등장한 것이 바로 컨테이너 오케스트레이터인 쿠버네티스(Kubernetes)입니다.쿠버네티스는 Auto Scaling, Auto Healing, 배포 자동화 등 대규모 컨테이너 환경 운영에 필요한 다양한 편의 기능을 제공하여, 현대적인 클라우드 네이티브 애플리케이션 개발의 핵심 도구로 자리잡았습니다.Docker만으로 부족한 이유Docker Compose로 여러 컨테이너를 함께 운영할 수 있지만, 한계가 있습니다. 상황 Docker (단독) 쿠버네티스 컨테이너가 죽으면 수동으로 재시작 자동 재시작 (Auto Healing) 트래픽이 갑자기 늘면 수동으로 컨테이너 추가 자동 스케일 아웃 (Auto Scaling) 새 버전 배포 시 서비스 중단 발생 무중단 배포 (Rolling Update) 여러 서버에 분산 운영 별도 구성 필요 클러스터로 자동 관리 실습 환경 구성인프런 - 대세는 쿠버네티스본 실습에서는 Linux, Docker, Kubernetes 환경에서 간단한 Node.js 애플리케이션을 배포하는 과정을 단계별로 진행합니다.1. Linux 환경 Node.js를 설치하고 8000번 포트로 Hello World 애플리케이션을 실행합니다.2. Docker 환경 Docker를 이용하여 컨테이너를 생성합니다. Docker Hub에서 공식 Node.js 컨테이너 이미지를 가져옵니다. Hello World 애플리케이션을 컨테이너에 포함시킵니다. 컨테이너를 실행하여 외부에서 8100번 포트로 접근할 수 있도록 설정합니다.3. Kubernetes 환경 앞서 생성한 컨테이너 이미지를 Docker Hub에 업로드합니다. Kubernetes에서 Pod와 Container를 생성할 때 Docker Hub의 이미지를 참조합니다. Pod를 외부에 노출시키기 위해 Service 리소스를 사용합니다. External IP를 통해 외부에서 애플리케이션에 접근할 수 있습니다.쿠버네티스 아키텍처인프런 - 대세는 쿠버네티스클러스터 구조쿠버네티스는 하나의 마스터 노드와 여러 개의 워커 노드로 구성된 클러스터 형태로 운영됩니다. Master 노드 (Control Plane) 클러스터 전반의 의사결정을 담당합니다. kube-apiserver: 모든 요청의 진입점. kubectl 명령도 여기를 거칩니다. etcd: 클러스터 상태를 저장하는 분산 키-값 저장소. kube-scheduler: 새로 생성된 Pod를 어느 노드에 배치할지 결정합니다. kube-controller-manager: 원하는 상태(Desired State)를 유지하도록 계속 감시합니다. Worker 노드 실제 애플리케이션 컨테이너가 실행되는 자원을 제공합니다. kubelet: 마스터 노드의 지시를 받아 Pod를 실제로 실행합니다. kube-proxy: 네트워크 트래픽을 적절한 Pod로 라우팅합니다. 주요 구성 요소Pod쿠버네티스에서 배포 가능한 가장 작은 단위입니다. 하나 이상의 컨테이너를 포함하며, 같은 Pod 안의 컨테이너는 네트워크와 스토리지를 공유합니다.# Pod 정의 예시apiVersion: v1kind: Podmetadata: name: my-appspec: containers: - name: app image: my-app:1.0 ports: - containerPort: 8080Pod는 단독으로 사용하기보다 Controller를 통해 관리하는 것이 일반적입니다. Pod 자체는 죽으면 자동으로 재시작되지 않기 때문입니다.Namespace쿠버네티스 오브젝트들을 논리적으로 독립된 공간으로 분리합니다. 팀별, 프로젝트별, 환경별(개발/스테이징/운영)로 리소스를 격리할 수 있습니다.# Namespace 생성kubectl create namespace production# 특정 Namespace에서 Pod 목록 확인kubectl get pods -n productionVolumePod가 재생성되면 컨테이너 내부의 데이터가 소실됩니다. 데이터 영속성을 보장하기 위해 Volume을 사용합니다. DB 데이터, 업로드 파일 등 영구 보존이 필요한 데이터는 Volume에 저장해야 합니다.Controller컨트롤러는 Pod의 생명주기를 관리하는 핵심 구성 요소입니다. 원하는 상태(Desired State)를 정의하면 컨트롤러가 실제 상태를 그에 맞게 유지합니다.ReplicaSet Pod가 종료되면 자동으로 감지하여 재생성합니다. (Auto Healing) replicas 값을 조정하여 Pod 개수를 동적으로 늘리거나 줄입니다. (Scale In/Out)Deployment실무에서 가장 자주 사용하는 컨트롤러입니다. ReplicaSet을 래핑하여 배포 기능을 추가합니다.apiVersion: apps/v1kind: Deploymentmetadata: name: my-appspec: replicas: 3 selector: matchLabels: app: my-app template: metadata: labels: app: my-app spec: containers: - name: app image: my-app:2.0 # 버전 변경 시 Rolling Update 자동 진행 Rolling Update: 새 버전 Pod를 하나씩 교체하며 무중단으로 배포합니다. Rollback: 배포 후 문제가 생기면 kubectl rollout undo 명령으로 이전 버전으로 즉시 돌아갈 수 있습니다.DaemonSet 각 노드에 Pod가 정확히 하나씩만 실행되도록 보장합니다. 새 노드가 클러스터에 추가되면 자동으로 해당 Pod도 생성됩니다. 주로 로그 수집(Fluentd), 모니터링 에이전트(Prometheus Node Exporter) 등에 사용됩니다.Job / CronJob Job: 특정 작업을 수행하고 종료해야 하는 배치 작업에 사용합니다. 작업이 완료되면 Pod가 종료됩니다. CronJob: Job을 주기적으로 실행합니다. Linux의 Cron과 동일한 스케줄 문법(0 2 * * *)을 사용합니다.# 매일 새벽 2시에 실행되는 CronJobapiVersion: batch/v1kind: CronJobmetadata: name: daily-reportspec: schedule: \"0 2 * * *\" jobTemplate: spec: template: spec: containers: - name: report image: report-generator:1.0 restartPolicy: OnFailure실습 환경 네트워크 구성인프런 - 대세는 쿠버네티스 실습 환경 설치 방법은 강의 자료와 강사님의 네이버 카페를 참고하시기 바랍니다.자주 쓰는 kubectl 명령어# Pod 목록 확인kubectl get pods# Pod 상세 정보 (문제 발생 시 원인 파악에 유용)kubectl describe pod &lt;pod-name&gt;# Pod 로그 확인kubectl logs &lt;pod-name&gt;# Deployment 목록 확인kubectl get deployments# 이미지 버전 업데이트 (Rolling Update 자동 진행)kubectl set image deployment/my-app app=my-app:2.0# 이전 버전으로 롤백kubectl rollout undo deployment/my-app마치며쿠버네티스는 처음 접하면 구성 요소가 많아 복잡하게 느껴지지만, 핵심은 단순합니다. “원하는 상태를 선언하면, 쿠버네티스가 그 상태를 유지해준다”는 것입니다. 다음 글에서는 실제 쿠버네티스 클러스터에서 Pod와 Service를 생성하여 애플리케이션을 배포하는 실습을 진행하겠습니다.참고 자료 대세는 쿠버네티스 - 인프런" }, { "title": "루비 맛보기", "url": "/posts/%ED%81%B4%EC%BD%94%EC%99%80-%ED%95%A8%EA%BB%98-%EB%A3%A8%EB%B9%84-%EB%A7%9B%EB%B3%B4%EA%B8%B0/", "categories": "Study, Rails", "tags": "루비", "date": "2026-02-24 00:00:00 +0900", "snippet": " 출처: The Odin Project — Full Stack Ruby on Rails / Ruby Course 대상 독자: Java/Spring 경험자 /목표: Rails 8 개발을 위한 Ruby 핵심 이해 /특징: Java 비교 설명, 실전 예제, 연습 문제, 요약 정리표목차 Part 1: Ruby 기초 문법 Ch 1. Rub...", "content": " 출처: The Odin Project — Full Stack Ruby on Rails / Ruby Course 대상 독자: Java/Spring 경험자 /목표: Rails 8 개발을 위한 Ruby 핵심 이해 /특징: Java 비교 설명, 실전 예제, 연습 문제, 요약 정리표목차 Part 1: Ruby 기초 문법 Ch 1. Ruby 소개 및 환경 설정 Ch 2. 기본 데이터 타입 Ch 3. 변수와 상수 Ch 4. 입출력 (I/O) Ch 5. 조건 로직 Ch 6. 반복문 (Loops) Ch 7. 배열 (Arrays) Ch 8. 해시 (Hashes) Ch 9. 메서드 (Methods) Ch 10. 디버깅 Part 2: 컬렉션과 Enumerable Ch 11. 기본 Enumerable 메서드 Ch 12. Predicate Enumerable 메서드 Ch 13. 중첩 컬렉션 (Nested Collections) Part 3: 객체지향 프로그래밍 (OOP) Ch 14. 클래스와 객체 Ch 15. 상속 (Inheritance) Ch 16. 모듈과 믹스인 (Modules &amp; Mixins) Ch 17. 접근 제어와 캡슐화 Part 4: 고급 Ruby Ch 18. 파일 처리와 직렬화 Ch 19. 패턴 매칭 (Pattern Matching) Ch 20. 블록, Proc, Lambda Part 5: 컴퓨터 과학 기초 Ch 21. 재귀 (Recursion) Ch 22. 시간 복잡도 Ch 23. 공간 복잡도 Ch 24. 자료 구조 및 알고리즘 Part 1: Ruby 기초 문법Ch 1. Ruby 소개 및 환경 설정1.1 Ruby란?Ruby는 1995년 일본의 마츠모토 유키히로(Matz)가 만든 프로그래머의 행복을 위해 설계된 언어입니다. “I hope to see Ruby help every programmer in the world to be productive, and to enjoy programming, and to be happy.” — MatzRuby의 핵심 철학: 최소 놀람의 원칙(POLA): 코드가 예상한 대로 동작한다 모든 것이 객체: 숫자, 문자열, nil, true/false 모두 객체 표현력: 영어에 가까운 읽기 쉬운 문법 DRY(Don’t Repeat Yourself): 코드 중복 최소화1.2 Java와의 핵심 차이점 항목 Java Ruby 타입 시스템 정적 타입 (컴파일 시 검사) 동적 타입 (런타임 검사) 세미콜론 필수 (;) 불필요 타입 선언 String name = \"Alice\" name = \"Alice\" 접근제어자 public, private, protected public, private, protected (문법 상이) 상속 class Dog extends Animal class Dog &lt; Animal 패키지/모듈 package com.example module MyModule null 개념 null nil 예외 처리 try/catch/finally begin/rescue/ensure 람다/익명함수 (x) -&gt; x * 2 -&gt; (x) { x * 2 } 또는 -&gt;(x) { x * 2 } 문자열 보간 \"Hello \" + name \"Hello #{name}\" 컬렉션 순회 for (String s : list) list.each { |s| ... } 출력 System.out.println() puts 1.3 설치 및 환경 설정# rbenv 를 이용한 Ruby 설치 (권장)brew install rbenv ruby-build # macOSrbenv install 3.3.0rbenv global 3.3.0# 버전 확인ruby -v # ruby 3.3.0 ...irb # 대화형 Ruby 셸 실행1.4 IRB(Interactive Ruby)IRB는 Ruby의 REPL(Read-Eval-Print Loop)로, Node.js의 node 명령과 유사합니다.irb3.3.0 :001 &gt; puts \"Hello, Ruby!\"Hello, Ruby!=&gt; nil3.3.0 :002 &gt; 2 + 3=&gt; 53.3.0 :003 &gt; \"hello\".upcase=&gt; \"HELLO\"1.5 Ruby 파일 실행# hello.rbputs \"Hello, World!\"name = \"chan\"puts \"안녕하세요, #{name}님!\"ruby hello.rb# Hello, World!# 안녕하세요, chan님!Ch 2. 기본 데이터 타입Ruby의 모든 것은 객체입니다. 심지어 42, true, nil도 객체이며 메서드를 호출할 수 있습니다.2.1 숫자 (Numbers)Integer (정수)# 정수 리터럴age = 30big_number = 1_000_000 # 가독성을 위한 언더스코어 사용 (Java와 동일)hex = 0xFF # 16진수: 255binary = 0b1010 # 2진수: 10octal = 0o777 # 8진수: 511# 기본 산술10 + 3 # =&gt; 1310 - 3 # =&gt; 710 * 3 # =&gt; 3010 / 3 # =&gt; 3 (정수 나눗셈 → 정수 결과, Java와 동일!)10 % 3 # =&gt; 1 (나머지)10 ** 2 # =&gt; 100 (거듭제곱, Java의 Math.pow(10, 2) 대신)# 정수 메서드 (Java에서는 없는 기능!)10.even? # =&gt; true10.odd? # =&gt; false10.zero? # =&gt; false0.zero? # =&gt; true-5.abs # =&gt; 542.to_f # =&gt; 42.0 (Float으로 변환)42.to_s # =&gt; \"42\" (String으로 변환) ⚠️ Java 개발자 주의: Ruby에서 정수 나눗셈은 Java와 동일하게 정수를 반환합니다. 10 / 3 # =&gt; 3 (소수점 버림) 소수점 결과가 필요하면 하나를 Float으로 변환: 10.0 / 3 # =&gt; 3.333...Float (부동소수점)pi = 3.14159gravity = 9.8# Float 연산1.5 + 2.5 # =&gt; 4.010.0 / 3 # =&gt; 3.3333333333333335# 형 변환3.14.to_i # =&gt; 3 (소수점 버림, Java의 (int) 캐스팅과 유사)3.14.ceil # =&gt; 4 (올림)3.14.floor # =&gt; 3 (내림)3.14.round # =&gt; 3 (반올림)3.75.round # =&gt; 4# 유용한 상수Float::INFINITY # =&gt; Infinity-Float::INFINITY # =&gt; -InfinityFloat::NAN # =&gt; NaN숫자 관련 요약 정리표 메서드 설명 예시 +, -, *, / 기본 사칙연산 10 / 3 # =&gt; 3 ** 거듭제곱 2 ** 10 # =&gt; 1024 % 나머지(모듈로) 10 % 3 # =&gt; 1 .to_i 정수로 변환 3.9.to_i # =&gt; 3 .to_f 부동소수점으로 변환 5.to_f # =&gt; 5.0 .to_s 문자열로 변환 42.to_s # =&gt; \"42\" .even? 짝수 여부 4.even? # =&gt; true .odd? 홀수 여부 3.odd? # =&gt; true .abs 절댓값 -5.abs # =&gt; 5 .round 반올림 3.5.round # =&gt; 4 .ceil 올림 3.1.ceil # =&gt; 4 .floor 내림 3.9.floor # =&gt; 3 2.2 문자열 (Strings)Ruby의 문자열은 Java와 달리 변경 가능(mutable)합니다 (기본적으로).# 문자열 리터럴single_quote = 'Hello, World!' # 작은따옴표: 보간 없음double_quote = \"Hello, World!\" # 큰따옴표: 보간 지원# 문자열 보간 (Java의 String.format() 대신)name = \"chan\"age = 31puts \"이름: #{name}, 나이: #{age}\" # =&gt; 이름: chan, 나이: 31puts \"계산: #{1 + 2}\" # =&gt; 계산: 3 (표현식 사용 가능!)# 작은따옴표는 보간 안 됨puts '이름: #{name}' # =&gt; 이름: #{name} (그대로 출력)# 이스케이프 문자puts \"줄바꿈\\n탭\\t따옴표\\\"\"# 줄바꿈# 탭 따옴표\"# 여러 줄 문자열 (Heredoc) - Java에서 Java 15+ TextBlock과 유사sql = &lt;&lt;~SQL SELECT * FROM users WHERE active = trueSQL# 문자열 연결\"Hello\" + \" \" + \"World\" # =&gt; \"Hello World\"\"Ha\" * 3 # =&gt; \"HaHaHa\" (Java에서 불가능한 문법!)# 중요 문자열 메서드str = \"Hello, Ruby!\"str.length # =&gt; 12str.size # =&gt; 12 (length의 별칭)str.upcase # =&gt; \"HELLO, RUBY!\"str.downcase # =&gt; \"hello, ruby!\"str.reverse # =&gt; \"!ybuR ,olleH\"str.include?(\"Ruby\") # =&gt; truestr.start_with?(\"Hello\") # =&gt; truestr.end_with?(\"!\") # =&gt; truestr.split(\", \") # =&gt; [\"Hello\", \"Ruby!\"]str.strip # =&gt; 앞뒤 공백 제거str.chomp # =&gt; 끝 줄바꿈 제거str.gsub(\"Ruby\", \"World\") # =&gt; \"Hello, World!\" (전체 치환)str.sub(\"l\", \"L\") # =&gt; \"HeLlo, Ruby!\" (첫 번째로 찾은 문자열만 치환)str[0] # =&gt; \"H\" (인덱스 접근)str[0, 5] # =&gt; \"Hello\" (시작, 길이)str[0..4] # =&gt; \"Hello\" (범위)# 형 변환\"42\".to_i # =&gt; 42\"3.14\".to_f # =&gt; 3.14\"hello\".to_sym # =&gt; :hello42.to_s # =&gt; \"42\"# 문자열 포맷 (Java의 String.format과 유사)\"%.2f\" % 3.14159 # =&gt; \"3.14\"\"%d개 항목\" % 5 # =&gt; \"5개 항목\" (% 연산자를 포맷으로 사용하는 독특한 문법)Bang(!) 메서드 - Java에 없는 Ruby 개념Ruby에서 !로 끝나는 메서드는 원본 객체를 변경(mutate)합니다. Java에서 문자열은 불변객체, 하지만 위에서 Ruby의 문자열은 변경가능하다고 했다.str = \"hello\"str.upcase # =&gt; \"HELLO\" (원본 변경 없음, 새 문자열 반환)puts str # =&gt; \"hello\" (그대로)str.upcase! # =&gt; \"HELLO\" (원본 변경!)puts str # =&gt; \"HELLO\"# Rails에서 자주 사용name = \" chan \"name.strip! # 원본에서 공백 제거2.3 심볼 (Symbols)심볼은 Ruby에서만 볼 수 있는 독특한 타입으로, 콜론(:)으로 시작하는 불변 식별자입니다.# 심볼 생성:hello:user_name:\"hello world\" # 공백 포함 시# 문자열 vs 심볼\"hello\".object_id # =&gt; 70123456789 (매번 다른 ID, 새 객체 생성)\"hello\".object_id # =&gt; 70987654321 (다른 ID!):hello.object_id # =&gt; 12345 (항상 같은 ID, 하나의 객체만 존재):hello.object_id # =&gt; 12345 (동일!)# 심볼은 메모리 효율적# → 해시 키로 사용 시 성능 우수# 변환:hello.to_s # =&gt; \"hello\"\"hello\".to_sym # =&gt; :hello Java 개발자를 위한 비유: 심볼은 Java의 enum 상수와 비슷합니다. 변경 불가능하고, 동일한 심볼은 메모리에 하나만 존재합니다. Rails에서는 해시 키, 메서드 이름 참조 등에 광범위하게 사용됩니다.2.4 Booleans와 Nil# Booleantrue.class # =&gt; TrueClassfalse.class # =&gt; FalseClass# Ruby의 진리값 규칙 (Java와 중요한 차이!)# Ruby에서 false와 nil만이 거짓, 나머지는 모두 참!if 0 then \"0 is truthy in Ruby!\" end # 실행됨! (Java에서는 오류)if \"\" then \"empty string is truthy!\" end # 실행됨!if [] then \"empty array is truthy!\" end # 실행됨!if nil then \"이건 안 됨\" endif false then \"이것도 안 됨\" end# nil: Java의 null에 해당name = nilname.nil? # =&gt; truename.class # =&gt; NilClass# nil 처리value = nilvalue || \"기본값\" # =&gt; \"기본값\" (nil 대체 패턴, Java의 Optional.orElse와 유사)value.to_s # =&gt; \"\" (nil.to_s는 빈 문자열)value.to_i # =&gt; 0 (nil.to_i는 0)value.to_a # =&gt; [] (nil.to_a는 빈 배열)데이터 타입 요약 타입 예시 Java 대응 Integer 42, 1_000 int, long, Integer Float 3.14, 1.0e5 double, float String \"hello\", 'hi' String Symbol :name, :status enum (유사) TrueClass true boolean (true) FalseClass false boolean (false) NilClass nil null 연습 문제 — Ch 2 정수 17이 홀수인지 확인하는 코드를 작성하세요. \"Hello, World!\"에서 \"World\" 부분을 \"Ruby\"로 교체하세요. 1부터 100까지의 합을 구하세요 (힌트: (1..100).sum). :active_record를 문자열로 변환하고, 대문자로 출력하세요. nil, 0, \"\", [] 중 Ruby에서 거짓(falsy)인 것은 무엇인가요? 정답 보기```ruby# 1.puts 17.odd? # =&gt; true# 2.puts \"Hello, World!\".gsub(\"World\", \"Ruby\") # =&gt; \"Hello, Ruby!\"# 3.puts (1..100).sum # =&gt; 5050# 4.puts :active_record.to_s.upcase # =&gt; \"ACTIVE_RECORD\"# 5. nil만 거짓, 나머지는 모두 참```Ch 3. 변수와 상수3.1 변수 종류Ruby는 변수 이름의 접두사(prefix)로 변수의 범위(scope)를 나타냅니다. Java와 완전히 다른 개념입니다.# 지역 변수 (Local Variable): 소문자 또는 _로 시작local_var = \"나는 지역 변수\"_also_local = \"이것도 지역 변수\"# 전역 변수 (Global Variable): $로 시작 (사용 지양!)$global_var = \"나는 전역 변수\"# 인스턴스 변수 (Instance Variable): @로 시작 (클래스 내부)@instance_var = \"나는 인스턴스 변수\"# 클래스 변수 (Class Variable): @@로 시작@@class_var = \"나는 클래스 변수\"# 상수 (Constant): 대문자로 시작MAX_SIZE = 100PI = 3.14159변수 범위 요약 변수 종류 접두사 범위 Java 대응 지역 변수 (없음) 현재 메서드/블록 지역 변수 전역 변수 $ 프로그램 전체 static 전역 인스턴스 변수 @ 해당 객체 this.field 클래스 변수 @@ 클래스 전체 static 멤버 변수 상수 대문자 클래스/모듈 static final 3.2 변수 할당의 특징# 다중 할당 (Java에서 불가능!)a, b, c = 1, 2, 3puts a # =&gt; 1puts b # =&gt; 2puts c # =&gt; 3# 스왑 (임시 변수 없이!)a, b = b, aputs a # =&gt; 2puts b # =&gt; 1# 배열에서 다중 할당first, *rest = [1, 2, 3, 4, 5]puts first # =&gt; 1puts rest # =&gt; [2, 3, 4, 5]*beginning, last = [1, 2, 3, 4, 5]puts last # =&gt; 5puts beginning # =&gt; [1, 2, 3, 4]# 조건부 할당x = nilx ||= \"기본값\" # x가 nil 또는 false이면 할당puts x # =&gt; \"기본값\"y = 10y ||= \"기본값\" # y가 이미 값이 있으므로 변경 없음puts y # =&gt; 10# Rails에서 자주 보이는 패턴@user ||= User.find(params[:id]) # 이미 로드됐으면 재사용3.3 상수MAX_USERS = 1000DATABASE_URL = \"postgresql://localhost/mydb\"# 상수 재할당 시 경고 (에러는 아님, Java와 다름!)MAX_USERS = 2000 # warning: already initialized constant MAX_USERS# Rails에서 상수 사용 예class ApplicationController &lt; ActionController::Base ALLOWED_FORMATS = [:json, :html].freeze # .freeze로 변경 방지endCh 4. 입출력 (I/O)4.1 출력 메서드# puts: 줄바꿈 포함 출력 (Java의 System.out.println)puts \"Hello\" # Hello\\nputs 42 # 42\\nputs [1, 2, 3] # 1\\n2\\n3\\n (배열은 각 원소를 한 줄씩!)puts nil # (빈 줄)# print: 줄바꿈 없이 출력 (Java의 System.out.print)print \"Hello \"print \"World\" # Hello World (줄바꿈 없음)# p: 디버그 출력 (값의 타입 정보 포함)p \"hello\" # =&gt; \"hello\" (따옴표 포함)p 42 # =&gt; 42p nil # =&gt; nilp [1, 2, 3] # =&gt; [1, 2, 3]# pp: 구조화된 디버그 출력 (Pretty Print)require 'pp'pp({ name: \"Alice\", scores: [95, 87, 92] })# {:name=&gt;\"Alice\", :scores=&gt;[95, 87, 92]} 언제 무엇을 쓸까? puts: 사용자에게 보여주는 출력 p: 개발 중 디버깅 (값의 실제 형태 확인) print: 줄바꿈 없이 이어서 출력할 때 4.2 입력 받기# gets: 줄바꿈 포함하여 입력받음 (Java의 Scanner.nextLine)print \"이름을 입력하세요: \"name = gets # \"chan\\n\" (줄바꿈 포함)puts \"안녕하세요, #{name.chomp}!\" # chomp로 줄바꿈 제거# gets.chomp: 줄바꿈 없이 입력받기print \"나이를 입력하세요: \"age = gets.chomp.to_i # 문자열 → 정수 변환puts \"내년이면 #{age + 1}살이 됩니다.\"4.3 형 변환# 문자열 → 다른 타입\"42\".to_i # =&gt; 42\"3.14\".to_f # =&gt; 3.14\"hello\".to_sym # =&gt; :hello\"123abc\".to_i # =&gt; 123 (숫자 부분까지만, Java와 다름!)\"abc\".to_i # =&gt; 0 (숫자 없으면 0)# Integer(), Float(): 엄격한 변환 (실패 시 예외 발생)Integer(\"42\") # =&gt; 42Integer(\"abc\") # ArgumentError 발생!Float(\"3.14\") # =&gt; 3.14# 다른 타입 → 문자열42.to_s # =&gt; \"42\"3.14.to_s # =&gt; \"3.14\":hello.to_s # =&gt; \"hello\"[1,2,3].to_s # =&gt; \"[1, 2, 3]\"nil.to_s # =&gt; \"\"true.to_s # =&gt; \"true\"Ch 5. 조건 로직5.1 if / elsif / else# 기본 ifscore = 85if score &gt;= 90 puts \"A\"elsif score &gt;= 80 puts \"B\"elsif score &gt;= 70 puts \"C\"else puts \"F\"end# =&gt; \"B\"# Java와의 차이: end 키워드 사용, () 불필요# Java: if (score &gt;= 90) { ... }# Ruby: if score &gt;= 90 ... end# 한 줄 if (후치 if)puts \"성인입니다\" if age &gt;= 18puts \"양수입니다\" if number &gt; 0# unless: if not과 동일 (Ruby 특유의 표현)unless score &lt; 60 puts \"합격!\"end# 또는puts \"합격!\" unless score &lt; 605.2 비교 연산자와 논리 연산자# 비교 연산자5 &gt; 3 # =&gt; true5 &lt; 3 # =&gt; false5 &gt;= 5 # =&gt; true5 &lt;= 4 # =&gt; false5 == 5 # =&gt; true (동등 비교)5 != 3 # =&gt; true (부등 비교)# 우주선 연산자 (Spaceship Operator) — Java에 없음!1 &lt;=&gt; 2 # =&gt; -1 (왼쪽이 작음)2 &lt;=&gt; 2 # =&gt; 0 (같음)3 &lt;=&gt; 2 # =&gt; 1 (왼쪽이 큼)# 정렬, 비교에 자주 사용# 논리 연산자true &amp;&amp; false # =&gt; false (AND)true || false # =&gt; true (OR)!true # =&gt; false (NOT)# 영어 버전 (낮은 우선순위, 조건문 제어에 사용)true and false # &amp;&amp; 와 유사하나 우선순위 다름true or false # || 와 유사하나 우선순위 다름not true # ! 와 유사하나 우선순위 다름# 문자열 비교\"apple\" == \"apple\" # =&gt; true\"apple\" &lt;=&gt; \"banana\" # =&gt; -1 (알파벳 순서)5.3 case / when (Java의 switch-case)# Java의 switch-case에 해당grade = \"B\"case gradewhen \"A\" puts \"Excellent!\"when \"B\" puts \"Good job!\"when \"C\" puts \"Average\"when \"D\", \"F\" # 여러 조건 가능 puts \"Need improvement\"else puts \"Invalid grade\"end# 범위(Range)를 사용하는 casescore = 85result = case scorewhen 90..100 then \"A\" # .. : 경계 포함when 80..89 then \"B\"when 70..79 then \"C\"when 0..69 then \"F\"else \"Invalid\"endputs result # =&gt; \"B\"# 타입 체크에도 사용def describe(value) case value when String then \"문자열: #{value}\" when Integer then \"정수: #{value}\" when Array then \"배열 (#{value.length}개 원소)\" when NilClass then \"nil이에요\" endendputs describe(\"hello\") # =&gt; \"문자열: hello\"puts describe(42) # =&gt; \"정수: 42\"puts describe([1,2,3]) # =&gt; \"배열 (3개 원소)\"5.4 삼항 연산자와 조건부 할당# 삼항 연산자 (Java와 동일)age = 20status = age &gt;= 18 ? \"성인\" : \"미성년자\"puts status # =&gt; \"성인\"# 조건부 할당x = nilx ||= 10 # x가 nil/false이면 10 할당puts x # =&gt; 10y = 5y &amp;&amp;= y * 2 # y가 truthy이면 y * 2 할당puts y # =&gt; 10연습 문제 — Ch 5 score가 60 미만이면 “불합격”, 그 이상이면 “합격”을 출력하는 코드를 작성하세요. case/when을 사용하여 월(1~12)을 계절(봄/여름/가을/겨울)로 변환하는 메서드를 작성하세요. a = nil일 때, a ||= \"기본\"의 결과는? 정답 보기```ruby# 1.score = 75puts score &gt;= 60 ? \"합격\" : \"불합격\"# 2.def season(month) case month when 3, 4, 5 then \"봄\" when 6, 7, 8 then \"여름\" when 9, 10, 11 then \"가을\" when 12, 1, 2 then \"겨울\" else \"잘못된 월\" endend# 3.a = nila ||= \"기본\"puts a # =&gt; \"기본\"```Ch 6. 반복문 (Loops)6.1 loop (무한 루프)# 기본 무한 루프i = 0loop do i += 1 puts i break if i &gt;= 5 # break로 탈출end# 1, 2, 3, 4, 56.2 while / until# while: 조건이 true인 동안 반복 (Java와 동일)i = 0while i &lt; 5 puts i i += 1end# until: 조건이 false인 동안 반복 (while의 반대)i = 0until i &gt;= 5 puts i i += 1end# 후치 while/untili = 0i += 1 while i &lt; 56.3 for 루프와 times/upto/downto# for...in (잘 사용하지 않음, each 권장)for i in 1..5 puts iend# times: N번 반복5.times { puts \"Hello\" }5.times { |i| puts \"반복 #{i}\" } # 0, 1, 2, 3, 4# upto / downto1.upto(5) { |i| print \"#{i} \" } # 1 2 3 4 55.downto(1){ |i| print \"#{i} \" } # 5 4 3 2 1# step: 증가값 지정1.step(10, 2) { |i| print \"#{i} \" } # 1 3 5 7 96.4 each (가장 Ruby다운 반복문)# 배열 순회 (Java의 for-each에 해당)[1, 2, 3, 4, 5].each do |num| puts numend# 한 줄 블록[1, 2, 3].each { |n| puts n * 2 }# 해시 순회{name: \"Alice\", age: 30}.each do |key, value| puts \"#{key}: #{value}\"end# 인덱스 포함 순회[\"a\", \"b\", \"c\"].each_with_index do |item, index| puts \"#{index}: #{item}\"end# 0: a# 1: b# 2: c6.5 루프 제어# break: 루프 즉시 탈출 (Java와 동일)[1, 2, 3, 4, 5].each do |n| break if n == 3 puts nend# 1, 2# next: 다음 반복으로 건너뜀 (Java의 continue)[1, 2, 3, 4, 5].each do |n| next if n.even? puts nend# 1, 3, 5# redo: 현재 반복 재실행 (잘 사용하지 않음)반복문 선택 가이드 상황 권장 방법 컬렉션 순회 each N번 반복 times 변환 결과 필요 map 필터링 select / reject 누적 계산 reduce / inject 무한 루프 loop Ch 7. 배열 (Arrays)7.1 배열 생성# 기본 생성arr = [1, 2, 3, 4, 5]mixed = [1, \"hello\", :symbol, true, nil, [1, 2]] # 혼합 타입 가능!# Array.newArray.new(3) # =&gt; [nil, nil, nil]Array.new(3, 0) # =&gt; [0, 0, 0]Array.new(3) { |i| i * 2 } # =&gt; [0, 2, 4]# 문자열 배열 단축 표기words = %w[apple banana cherry] # =&gt; [\"apple\", \"banana\", \"cherry\"]syms = %i[red green blue] # =&gt; [:red, :green, :blue]7.2 배열 접근 및 수정arr = [10, 20, 30, 40, 50]# 접근arr[0] # =&gt; 10 (첫 번째)arr[-1] # =&gt; 50 (마지막, Java에서 불가!)arr[1..3] # =&gt; [20, 30, 40] (범위)arr[1, 3] # =&gt; [20, 30, 40] (시작 인덱스, 길이)arr.first # =&gt; 10arr.last # =&gt; 50arr.first(2) # =&gt; [10, 20]arr.last(2) # =&gt; [40, 50]# 수정arr[0] = 99 # =&gt; [99, 20, 30, 40, 50]arr.push(60) # 끝에 추가 (Java의 add)arr &lt;&lt; 70 # &lt;&lt; 연산자로 추가 (Ruby 특유!)arr.unshift(0) # 앞에 추가arr.pop # 마지막 제거 및 반환arr.shift # 첫 번째 제거 및 반환arr.delete(30) # 특정 값 제거arr.delete_at(1) # 인덱스로 제거7.3 주요 배열 메서드arr = [3, 1, 4, 1, 5, 9, 2, 6, 5, 3]# 정보arr.length # =&gt; 10arr.size # =&gt; 10arr.empty? # =&gt; falsearr.include?(5) # =&gt; truearr.count(5) # =&gt; 2 (5가 몇 개?)# 정렬arr.sort # =&gt; [1, 1, 2, 3, 3, 4, 5, 5, 6, 9]arr.sort.reverse # =&gt; [9, 6, 5, 5, 4, 3, 3, 2, 1, 1]arr.sort_by { |n| -n } # 내림차순# 중복 제거arr.uniq # =&gt; [3, 1, 4, 5, 9, 2, 6]# 평탄화[[1, 2], [3, 4], [5]].flatten # =&gt; [1, 2, 3, 4, 5][[1, [2, 3]], [4]].flatten(1) # =&gt; [1, [2, 3], 4] (1단계만)# 합집합, 교집합, 차집합[1, 2, 3] | [3, 4, 5] # =&gt; [1, 2, 3, 4, 5] (합집합)[1, 2, 3] &amp; [2, 3, 4] # =&gt; [2, 3] (교집합)[1, 2, 3] - [2, 3] # =&gt; [1] (차집합)# 합치기[1, 2] + [3, 4] # =&gt; [1, 2, 3, 4][1, 2, 3].concat([4, 5]) # =&gt; [1, 2, 3, 4, 5]# 변환arr.map { |n| n * 2 } # 각 원소를 변환한 새 배열arr.select { |n| n &gt; 3 } # 조건에 맞는 원소만arr.reject { |n| n &gt; 3 } # 조건에 안 맞는 원소만arr.min # =&gt; 1arr.max # =&gt; 9arr.sum # =&gt; 39arr.sample # 랜덤 원소 반환arr.shuffle # 무작위 섞기# zip: 배열 병합[1, 2, 3].zip([4, 5, 6]) # =&gt; [[1, 4], [2, 5], [3, 6]]# flatten + map의 합체: flat_map[[1, 2], [3, 4]].flat_map { |a| a.map { |n| n * 2 } }# =&gt; [2, 4, 6, 8] 26.03.01 추가… 메서드가 이해가 안가서 # [[1, 2], [3, 4]].map { |a| print a } # =&gt; [1, 2][3, 4][[1, 2], [3, 4]].map { |a| a.map { |n| n * 2 } } # =&gt; [[2, 4], [6, 8]][[1, 2], [3, 4]].flat_map { |a| a.map { |n| n * 2 } } # =&gt; [2, 4, 6, 8] **동작 흐름 정리**```[[1,2], [3,4]] ↓ 블록 실행 (각 요소에 *2)[[2,4], [6,8]] ← map 결과 ↓ flatten (한 겹 펼치기)[2, 4, 6, 8] ← 최종 결과 클로드에 설명에 따르면… List.of(List.of(1,2), List.of(3,4)) .stream() .flatMap(a -&gt; a.stream().map(n -&gt; n * 2)) .collect(toList());// =&gt; [2, 4, 6, 8] 7.4 Rails에서의 배열 활용 예시# Active Record 쿼리 결과는 배열처럼 동작users = User.where(active: true) # ActiveRecord::Relation (Enumerable 포함)users.map { |u| u.email } # 이메일 목록users.select { |u| u.admin? } # 관리자만 필터링users.first(5) # 상위 5명# 자주 쓰는 패턴user_ids = [1, 2, 3, 4, 5]User.where(id: user_ids) # IN 쿼리로 자동 변환!Ch 8. 해시 (Hashes)8.1 해시 생성해시는 Ruby에서 가장 많이 사용하는 자료구조 중 하나로, Java의 HashMap에 해당합니다.# 옛날 방식 (=&gt; 사용, 하시 로켓)old_style = { \"name\" =&gt; \"Alice\", \"age\" =&gt; 30 }# 심볼 키 방식 (권장! Rails에서 주로 사용)person = { name: \"Alice\", age: 30, city: \"Seoul\" }# 이는 { :name =&gt; \"Alice\", :age =&gt; 30 } 와 동일# 빈 해시empty = {}empty = Hash.new # 기본값 nilempty = Hash.new(0) # 기본값 0 (카운터에 유용)8.2 해시 접근 및 수정person = { name: \"Alice\", age: 30 }# 접근person[:name] # =&gt; \"Alice\"person[:age] # =&gt; 30person[:missing] # =&gt; nil (없는 키, Java의 get은 null)# 기본값 설정person.fetch(:name) # =&gt; \"Alice\"person.fetch(:missing) # KeyError 발생!person.fetch(:missing, \"없음\") # =&gt; \"없음\" (기본값)person.fetch(:missing) { \"없음\" } # 블록으로 기본값# 수정person[:name] = \"Bob\" # 값 변경person[:email] = \"b@x.com\" # 새 키 추가person.delete(:age) # 키 삭제# 키/값 확인person.key?(:name) # =&gt; trueperson.has_key?(:age) # =&gt; false (위에서 삭제됨)person.value?(\"Bob\") # =&gt; true, key말고 value중에 이런 값이 있냐? 그런거 물어보는거임.person.include?(:name) # =&gt; true8.3 해시 순회user = { name: \"Alice\", age: 30, city: \"Seoul\" }# 전체 순회user.each do |key, value| puts \"#{key}: #{value}\"end# 키만user.keys # =&gt; [:name, :age, :city]user.each_key { |k| puts k }# 값만user.values # =&gt; [\"Alice\", 30, \"Seoul\"]user.each_value { |v| puts v }# map/select/rejectuser.map { |k, v| [k, v.to_s] }.to_h# =&gt; {name: \"Alice\", age: \"30\", city: \"Seoul\"}user.select { |k, v| v.is_a?(String) } # is_a : 특정 클래스/모듈의 인스턴스인지 확인하는 메서드# =&gt; {name: \"Alice\", city: \"Seoul\"}user.select { |k, v| v.is_a?(Integer) }# =&gt; {age: 30}user.reject { |k, v| v.is_a?(Integer) }# =&gt; {name: \"Alice\", city: \"Seoul\"}user.reject { |k, v| v.is_a?(String) }# =&gt; {age: 30}8.4 해시 변환 및 병합# 배열 ↔ 해시 변환arr = [[:a, 1], [:b, 2], [:c, 3]]hash = arr.to_h # =&gt; {a: 1, b: 2, c: 3}# 또는arr.each_with_object({}) { |(k, v), h| h[k] = v }# 해시 → 배열{a: 1, b: 2}.to_a # =&gt; [[:a, 1], [:b, 2]]# 병합 (merge)h1 = { a: 1, b: 2 }h2 = { b: 3, c: 4 }h1.merge(h2) # =&gt; {a: 1, b: 3, c: 4} (h2가 우선)h2.merge(h1) # =&gt; {b: 2, c: 4, a: 1} (h1이 우선)# 충돌 처리 블록h1.merge(h2) { |key, old_val, new_val| old_val + new_val }# =&gt; {a: 1, b: 5, c: 4}# transform_keys / transform_values (Ruby 2.5+){ name: \"Alice\", age: 30 }.transform_values { |v| v.to_s }# =&gt; {name: \"Alice\", age: \"30\"}{name: \"Alice\", age: 30}.transform_values { |v| v.to_s + \"xxx\" }# =&gt; {name: \"Alicexxx\", age: \"30xxx\"}{ \"name\" =&gt; \"Alice\" }.transform_keys(&amp;:to_sym)# =&gt; {name: \"Alice\"} 26/03/01 추가 each_with_object : 누적 객체를 유지하면서 순회하는 메서드 arr = [[:a, 1], [:b, 2], [:c, 3]] arr.each_with_object({}) { |(k, v), h| h[k] = v }# ^^ ^^^^^^^^ ^^^^^^^^# 초기값 블록 인자 동작 # (k, v) → 현재 요소 [:a, 1]을 구조분해# h → 계속 누적되는 Hash 객체 (처음엔 {}) arr = [[:a, 1], [:b, 2], [:c, 3]] # 1회차: 요소 [:a, 1]# k = :a, v = 1, h = {}# h[:a] = 1 → h = {a: 1} # 2회차: 요소 [:b, 2]# k = :b, v = 2, h = {a: 1}# h[:b] = 2 → h = {a: 1, b: 2} # 3회차: 요소 [:c, 3]# k = :c, v = 3, h = {a: 1, b: 2}# h[:c] = 3 → h = {a: 1, b: 2, c: 3} 8.5 Rails에서의 해시 활용# 컨트롤러에서 Strong Parametersdef user_params params.require(:user).permit(:name, :email, :password)end# 옵션 해시 패턴 (Rails에서 매우 흔한 패턴!)def create_user(name:, email:, role: :user) # 키워드 인수 User.create(name: name, email: email, role: role)endcreate_user(name: \"Alice\", email: \"a@x.com\")# 해시를 이용한 조건 빌딩conditions = { active: true }conditions[:admin] = true if current_user.super_admin?User.where(conditions)연습 문제 — Ch 7 &amp; 8 배열 [5, 2, 8, 1, 9, 3]을 오름차순으로 정렬하고, 5보다 큰 숫자만 선택하세요. { a: 1, b: 2, c: 3 } 해시에서 모든 값을 2배로 만드세요. 문자열 배열 [\"apple\", \"banana\", \"cherry\"]에서 각 단어의 글자 수를 값으로 가지는 해시를 만드세요. 정답 보기```ruby# 1.[5, 2, 8, 1, 9, 3].sort.select { |n| n &gt; 5 } # =&gt; [8, 9]# 2.{ a: 1, b: 2, c: 3 }.transform_values { |v| v * 2 }# =&gt; {a: 2, b: 4, c: 6}# 3.[\"apple\", \"banana\", \"cherry\"].each_with_object({}) do |word, hash| hash[word] = word.lengthend# =&gt; {\"apple\"=&gt;5, \"banana\"=&gt;6, \"cherry\"=&gt;6}# 또는:[\"apple\", \"banana\", \"cherry\"].map { |w| [w, w.length] }.to_h```Ch 9. 메서드 (Methods)9.1 메서드 정의# 기본 메서드def greet puts \"Hello!\"endgreet # =&gt; Hello!# 인수가 있는 메서드def add(a, b) a + b # 마지막 표현식이 자동으로 return값 (implicit return)endputs add(3, 5) # =&gt; 8# 명시적 return (중간에 반환할 때)def divide(a, b) return \"0으로 나눌 수 없습니다\" if b == 0 a / bend Java 개발자 주의: Ruby는 return 키워드 없이 마지막 표현식의 값이 자동으로 반환됩니다!9.2 기본값 인수def greet(name, greeting = \"Hello\") \"#{greeting}, #{name}!\"endgreet(\"Alice\") # =&gt; \"Hello, Alice!\"greet(\"Bob\", \"Hi\") # =&gt; \"Hi, Bob!\"9.3 키워드 인수 (Keyword Arguments)# 키워드 인수 (Rails에서 매우 자주 사용!)def create_post(title:, body:, published: false) \"제목: #{title}, 내용: #{body}, 공개: #{published}\"endcreate_post(title: \"Ruby 가이드\", body: \"내용...\")create_post(body: \"내용\", title: \"제목\") # 순서 무관!create_post(title: \"제목\", body: \"내용\", published: true)9.4 가변 인수# 스플랫 연산자 (*) — Java의 varargsdef sum(*numbers) numbers.sumendsum(1, 2, 3) # =&gt; 6sum(1, 2, 3, 4, 5) # =&gt; 15# 더블 스플랫 (**) — 해시 인수def log(message, **options) puts \"#{message} | #{options}\"endlog(\"에러 발생\", level: :error, code: 500)# =&gt; 에러 발생 | {:level=&gt;:error, :code=&gt;500}9.5 ? 메서드와 ! 메서드# ? 메서드: Boolean 반환 (관례)[].empty? # =&gt; true\"hello\".include?(\"ell\") # =&gt; true5.zero? # =&gt; false5.nil? # =&gt; falsenil.nil? # =&gt; true# ! 메서드: 객체 자체를 변경 (관례)str = \"hello\"str.upcase # =&gt; \"HELLO\" (str은 그대로)str.upcase! # str을 \"HELLO\"로 변경arr = [3, 1, 2]sorted = arr.sort # arr은 그대로, 새 배열 반환arr.sort! # arr 자체를 정렬9.6 메서드를 객체처럼 다루기# method 메서드로 참조def double(n) n * 2endm = method(:double)m.call(5) # =&gt; 10[1, 2, 3].map(&amp;method(:double)) # =&gt; [2, 4, 6]# to_proc으로 변환:upcase.to_proc.call(\"hello\") # =&gt; \"HELLO\"[\"hello\", \"world\"].map(&amp;:upcase) # =&gt; [\"HELLO\", \"WORLD\"]# 이 패턴을 Rails에서 매우 자주 사용! Ruby의 method 메서드와 to_proc 변환 상세 설명 - 26/03/04 추가 1. method(:double) — 메서드를 객체로 참조 def double(n) n * 2end Ruby에서 def로 정의한 메서드는 그냥 호출만 할 수 있는 것이 기본입니다.하지만 method(:double)을 사용하면 메서드 자체를 객체로 “꺼내서” 변수에 담을 수 있습니다. m = method(:double)# m은 이제 Method 객체# double이라는 메서드 자체를 가리키고 있음m.call(5) # =&gt; 10# .call()로 실행 Java로 비유하면: // Java에서는 메서드를 직접 변수에 담을 수 없어서// 함수형 인터페이스로 래핑해야 했음Function&lt;Integer, Integer&gt; double = n -&gt; n * 2; Ruby는 method(:이름) 하나로 간단하게 해결합니다. 2. &amp; 연산자 — proc/메서드를 블록으로 변환 [1, 2, 3].map(&amp;method(:double)) # =&gt; [2, 4, 6] map은 원래 블록({ } 또는 do...end) 을 받습니다: [1, 2, 3].map { |n| double(n) } # 일반적인 방식 &amp;를 앞에 붙이면 Method 객체 → 블록으로 자동 변환해서 넘겨줍니다. &amp;method(:double) ↓{ |n| double(n) } 와 동일하게 동작 즉, &amp;는 “이 객체를 블록처럼 써라” 라는 신호입니다. 3. Symbol#to_proc — 심볼을 proc으로 변환 :upcase.to_proc.call(\"hello\") # =&gt; \"HELLO\" :upcase는 그냥 심볼(문자열 이름표)입니다..to_proc을 호출하면 아래와 같은 proc이 생성됩니다: # :upcase.to_proc 의 내부 동작 원리proc { |obj| obj.upcase } 그래서: :upcase.to_proc.call(\"hello\")# =&gt; \"hello\".upcase# =&gt; \"HELLO\" 4. &amp;:upcase — Rails/Ruby에서 가장 자주 보는 패턴 [\"hello\", \"world\"].map(&amp;:upcase) # =&gt; [\"HELLO\", \"WORLD\"] &amp; + Symbol 조합이 일어나는 과정을 단계별로 보면: &amp;:upcase 1단계: :upcase.to_proc 자동 호출 → proc { |obj| obj.upcase } 생성 2단계: proc → 블록으로 변환 → { |obj| obj.upcase } 와 동일 결국 아래 세 줄은 완전히 동일한 동작입니다: [\"hello\", \"world\"].map { |s| s.upcase } # 풀어쓴 방식[\"hello\", \"world\"].map { |s| s.send(:upcase) } # send 방식[\"hello\", \"world\"].map(&amp;:upcase) # 심볼 shortcut 5. Rails에서 자주 쓰는 실전 패턴 # 문자열 처리[\" hello \", \" world \"].map(&amp;:strip)# =&gt; [\"hello\", \"world\"]# 타입 변환[\"1\", \"2\", \"3\"].map(&amp;:to_i)# =&gt; [1, 2, 3]# nil 제거 (select/reject과 함께)[1, nil, 2, nil, 3].reject(&amp;:nil?)# =&gt; [1, 2, 3]# ActiveRecord에서User.all.map(&amp;:email)# =&gt; [\"a@a.com\", \"b@b.com\", ...]# User 객체 배열에서 email만 뽑아낼 때Post.all.select(&amp;:published?)# published? 가 true인 것만 필터링 핵심 정리 문법 하는 일 method(:foo) 메서드를 Method 객체로 꺼냄 &amp;method(:foo) Method 객체를 블록으로 변환해서 전달 :foo.to_proc 심볼을 proc { \\|obj\\| obj.foo } 로 변환 &amp;:foo to_proc 자동 호출 + 블록 변환 (shortcut) &amp;:메서드명 패턴은 “각 요소에 이 메서드를 호출해라” 라는 뜻으로 기억하면 Rails 코드 읽을 때 매우 편합니다.Ch 10. 디버깅10.1 기본 디버깅# p를 이용한 값 확인def calculate(x, y) result = x * y p result # 개발 중 확인 resultend# pp를 이용한 구조 확인data = { users: [{name: \"Alice\", age: 30}] }pp data# puts 디버깅puts \"--- DEBUG ---\"puts \"x = #{x.inspect}\"10.2 예외 처리 (begin/rescue/ensure)# Java의 try/catch/finally에 해당begin # 위험한 코드 result = 10 / 0rescue ZeroDivisionError =&gt; e puts \"0으로 나눌 수 없습니다: #{e.message}\"rescue ArgumentError, TypeError =&gt; e puts \"잘못된 인수: #{e.message}\"rescue =&gt; e # 그 외 모든 예외 puts \"예기치 않은 오류: #{e.message}\"ensure # 항상 실행 (Java의 finally) puts \"정리 작업\"end# raise로 예외 발생def fetch_user(id) raise ArgumentError, \"ID는 양수여야 합니다\" unless id &gt; 0 # ...endJava vs Ruby 예외 처리 비교 Java Ruby try { } begin ... end catch (Exception e) { } rescue Exception =&gt; e finally { } ensure throw new Exception() raise Exception.new(...) throw new RuntimeException() raise \"메시지\" (RuntimeError) 10.3 Pry / Byebug (개발 도구)# Gemfile에 추가gem 'pry', group: :developmentgem 'byebug', group: :development# 코드 내 중단점 설정def my_method x = 10 binding.pry # 여기서 실행 멈추고 IRB처럼 디버깅 가능 y = x * 2endPart 2: 컬렉션과 EnumerableCh 11. 기본 Enumerable 메서드Enumerable은 Ruby의 강력한 모듈로, 컬렉션(배열, 해시 등)에 포함되어 있습니다. Java의 Stream API와 유사하지만 더 간결합니다.11.1 map / collect (변환)# 배열의 각 원소를 변환하여 새 배열 반환 (Java의 Stream.map에 해당)numbers = [1, 2, 3, 4, 5]squared = numbers.map { |n| n ** 2 }# =&gt; [1, 4, 9, 16, 25]# 문자열 변환names = [\"alice\", \"bob\", \"charlie\"]names.map(&amp;:capitalize) # =&gt; [\"Alice\", \"Bob\", \"Charlie\"]names.map(&amp;:upcase) # =&gt; [\"ALICE\", \"BOB\", \"CHARLIE\"]# &amp;:메서드명 패턴: Rails에서 매우 자주 사용!# 해시에서 사용users = [{name: \"Alice\", age: 30}, {name: \"Bob\", age: 25}]users.map { |u| u[:name] } # =&gt; [\"Alice\", \"Bob\"]users.map { |u| \"#{u[:name]}(#{u[:age]})\" }# =&gt; [\"Alice(30)\", \"Bob(25)\"]# Java 비교# Java: list.stream().map(n -&gt; n * n).collect(Collectors.toList())# Ruby: list.map { |n| n * n }11.2 select / filter (필터링)numbers = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]# 짝수만 선택numbers.select { |n| n.even? } # =&gt; [2, 4, 6, 8, 10]numbers.select(&amp;:even?) # 축약 표현# 5보다 큰 수만numbers.select { |n| n &gt; 5 } # =&gt; [6, 7, 8, 9, 10]# reject: select의 반대numbers.reject { |n| n.even? } # =&gt; [1, 3, 5, 7, 9]# Java 비교# Java: list.stream().filter(n -&gt; n % 2 == 0).collect(Collectors.toList())# Ruby: list.select { |n| n.even? }11.3 reduce / inject (누적)# 합계 계산[1, 2, 3, 4, 5].reduce(:+) # =&gt; 15[1, 2, 3, 4, 5].reduce(0, :+) # 초기값 0으로 합계[1, 2, 3, 4, 5].inject { |sum, n| sum + n } # =&gt; 15# 최대/최솟값 (reduce로 구현)[3, 1, 4, 1, 5].reduce { |max, n| n &gt; max ? n : max } # =&gt; 5# 배열을 해시로 변환words = [\"apple\", \"banana\", \"cherry\"]words.reduce({}) do |hash, word| hash[word] = word.length hashend# =&gt; {\"apple\"=&gt;5, \"banana\"=&gt;6, \"cherry\"=&gt;6}# Java 비교# Java: list.stream().reduce(0, Integer::sum)# Ruby: list.reduce(:+) 또는 list.reduce(0) { |sum, n| sum + n } reduce / inject 상세 설명 - 26/03/04 추가 reduce와 inject는 완전히 같은 메서드입니다 (alias 관계). 기본 동작 원리 배열의 요소들을 왼쪽부터 하나씩 누적(accumulate) 해가는 메서드입니다. [1, 2, 3, 4, 5].reduce(:+)1단계: 1 + 2 = 32단계: 3 + 3 = 63단계: 6 + 4 = 104단계: 10 + 5 = 15 ↑ 최종 반환값 Case 1: reduce(:+) — 심볼로 연산자 지정 [1, 2, 3, 4, 5].reduce(:+) # =&gt; 15 :+는 + 연산자를 심볼로 표현한 것 초기값 없음 → 배열의 첫 번째 요소(1)가 초기 누산값이 됨 앞서 배운 &amp;:upcase 패턴과 같은 원리 (심볼 → 메서드 호출) 누산값(acc) | 현재값(n) | 결과 1 | 2 | 3 3 | 3 | 6 6 | 4 | 10 10 | 5 | 15 Case 2: reduce(0, :+) — 초기값 지정 [1, 2, 3, 4, 5].reduce(0, :+) # =&gt; 15 첫 번째 인자 0이 초기 누산값으로 설정됨 배열의 첫 번째 요소부터 연산에 참여 누산값(acc) | 현재값(n) | 결과 0 | 1 | 1 ← 초기값 0에서 시작 1 | 2 | 3 3 | 3 | 6 6 | 4 | 10 10 | 5 | 15 초기값이 중요한 경우: [].reduce(:+) # =&gt; nil (빈 배열이면 nil 반환)[].reduce(0, :+) # =&gt; 0 (초기값이 있으면 안전) Case 3: inject { |sum, n| sum + n } — 블록으로 직접 작성 [1, 2, 3, 4, 5].inject { |sum, n| sum + n } # =&gt; 15 |sum, n| → sum은 누산값, n은 현재 요소 블록의 반환값이 다음 단계의 sum 이 됨 :+ 심볼 방식과 완전히 동일한 결과 블록 방식은 복잡한 로직을 쓸 때 유용합니다: # 짝수만 더하기[1, 2, 3, 4, 5].reduce(0) { |sum, n| n.even? ? sum + n : sum }# =&gt; 6 (2 + 4)# 최댓값 구하기[3, 1, 4, 1, 5, 9].reduce { |max, n| n &gt; max ? n : max }# =&gt; 9 실전 활용 패턴 # 곱셈[1, 2, 3, 4, 5].reduce(:*) # =&gt; 120# 문자열 합치기[\"Hello\", \" \", \"World\"].reduce(:+) # =&gt; \"Hello World\"# Hash 만들기 (Rails에서 자주 사용)[[:a, 1], [:b, 2], [:c, 3]].reduce({}) do |hash, (key, value)| hash[key] = value hashend# =&gt; {a: 1, b: 2, c: 3}# 중첩 배열 합산orders = [{price: 100}, {price: 200}, {price: 300}]orders.reduce(0) { |sum, order| sum + order[:price] }# =&gt; 600 핵심 정리 방식 초기값 특징 reduce(:+) 첫 번째 요소 가장 간결 reduce(0, :+) 직접 지정 빈 배열에 안전 reduce { |acc, n| ... } 첫 번째 요소 복잡한 로직 가능 reduce(초기값) { |acc, n| ... } 직접 지정 가장 유연 reduce/inject는 “배열 → 단일 값” 으로 줄일 때 사용한다고 기억하면 됩니다.11.4 each_with_object (해시/배열 빌드)# reduce보다 해시/배열 생성에 편리result = [1, 2, 3, 4, 5].each_with_object({}) do |n, hash| hash[n] = n ** 2end# =&gt; {1=&gt;1, 2=&gt;4, 3=&gt;9, 4=&gt;16, 5=&gt;25}# 배열에 누적result = [1, 2, 3].each_with_object([]) do |n, arr| arr &lt;&lt; n * 2 if n.odd?end# =&gt; [2, 6] each_with_object 상세 설명 - 26/03/04 추가 기본 개념 누적 객체를 직접 수정하면서 순회하는 메서드입니다. reduce와 비슷하지만 핵심 차이가 있습니다. 배열.each_with_object(누적객체) do |현재요소, 누적객체| # 누적객체를 직접 수정end 블록 인자 순서에 주의: reduce : |누산값, 현재요소| ← 누적값이 첫 번째each_with_object : |현재요소, 누적객체| ← 현재요소가 첫 번째 Case 1: Hash 만들기 result = [1, 2, 3, 4, 5].each_with_object({}) do |n, hash| hash[n] = n ** 2end# =&gt; {1=&gt;1, 2=&gt;4, 3=&gt;9, 4=&gt;16, 5=&gt;25} 단계별 진행: 초기 hash = {}n=1: hash[1] = 1**2 → {1=&gt;1}n=2: hash[2] = 2**2 → {1=&gt;1, 2=&gt;4}n=3: hash[3] = 3**2 → {1=&gt;1, 2=&gt;4, 3=&gt;9}n=4: hash[4] = 4**2 → {1=&gt;1, 2=&gt;4, 3=&gt;9, 4=&gt;16}n=5: hash[5] = 5**2 → {1=&gt;1, 2=&gt;4, 3=&gt;9, 4=&gt;16, 5=&gt;25} {}를 초기값으로 넘기고, 블록 안에서 직접 키/값을 추가합니다. Case 2: 조건부 배열 만들기 result = [1, 2, 3].each_with_object([]) do |n, arr| arr &lt;&lt; n * 2 if n.odd?end# =&gt; [2, 6] 단계별 진행: 초기 arr = []n=1: 1.odd? → true → arr &lt;&lt; 2 → [2]n=2: 2.odd? → false → 아무것도 안 함 → [2]n=3: 3.odd? → true → arr &lt;&lt; 6 → [2, 6] select + map을 한 번에 처리할 수 있습니다: # 위 코드와 동일한 결과, 다른 방식[1, 2, 3].select(&amp;:odd?).map { |n| n * 2 } # =&gt; [2, 6] reduce와의 차이점 # reduce — 블록의 반환값이 다음 누산값이 됨[1, 2, 3].reduce({}) do |hash, n| hash[n] = n ** 2 hash # ← 반드시 hash를 마지막에 반환해야 함!end# each_with_object — 객체를 직접 수정, 반환값 신경 안 써도 됨[1, 2, 3].each_with_object({}) do |n, hash| hash[n] = n ** 2 # 반환값 신경 안 써도 됨end reduce로 Hash/Array를 만들 때 마지막에 객체를 반환하는 실수를 자주 합니다. each_with_object는 그 실수를 방지해줍니다. 실전 활용 패턴 # 단어 빈도수 세기[\"apple\", \"banana\", \"apple\", \"cherry\", \"banana\", \"apple\"] .each_with_object(Hash.new(0)) do |word, counts| counts[word] += 1 end# =&gt; {\"apple\"=&gt;3, \"banana\"=&gt;2, \"cherry\"=&gt;1}# 그룹핑 (group_by 대신)[1, 2, 3, 4, 5].each_with_object({odd: [], even: []}) do |n, hash| n.odd? ? hash[:odd] &lt;&lt; n : hash[:even] &lt;&lt; nend# =&gt; {odd: [1, 3, 5], even: [2, 4]}# Rails — ActiveRecord 결과를 Hash로 변환User.all.each_with_object({}) do |user, hash| hash[user.id] = user.emailend# =&gt; {1=&gt;\"a@a.com\", 2=&gt;\"b@b.com\", ...} 핵심 정리   reduce each_with_object 블록 인자 순서 |누산값, 요소| |요소, 객체| 블록 반환값 다음 누산값으로 사용됨 무시됨 Hash/Array 만들 때 마지막에 객체 반환 필요 그냥 수정만 하면 됨 숫자 누산 적합 부적합 Hash나 Array를 만들 때 → each_with_object 숫자/문자열 하나로 줄일 때 → reduce11.5 flat_map, group_by, sort_by, min_by, max_by# flat_map: map + flatten[[1, 2], [3, 4], [5]].flat_map { |a| a.map { |n| n * 2 } }# =&gt; [2, 4, 6, 8, 10]# group_by: 그룹 분류[1, 2, 3, 4, 5, 6].group_by { |n| n.even? ? :even : :odd }# =&gt; {:odd=&gt;[1, 3, 5], :even=&gt;[2, 4, 6]}users = [{name: \"Alice\", dept: \"개발\"}, {name: \"Bob\", dept: \"기획\"}, {name: \"Carol\", dept: \"개발\"}]users.group_by { |u| u[:dept] }# =&gt; {\"개발\"=&gt;[...Alice, Carol...], \"기획\"=&gt;[...Bob...]}# sort_by: 기준으로 정렬users.sort_by { |u| u[:name] } # 이름 알파벳순users.sort_by { |u| u[:name].length } # 이름 길이순# min_by / max_byusers.min_by { |u| u[:name].length } # 이름 가장 짧은 사람users.max_by { |u| u[:name].length } # 이름 가장 긴 사람# tally: 빈도 계산 (Ruby 2.7+)[\"apple\", \"banana\", \"apple\", \"cherry\", \"banana\", \"apple\"].tally# =&gt; {\"apple\"=&gt;3, \"banana\"=&gt;2, \"cherry\"=&gt;1}Ch 12. Predicate Enumerable 메서드12.1 any?, all?, none?, one?numbers = [1, 2, 3, 4, 5]# any?: 하나라도 조건 만족하면 truenumbers.any? { |n| n &gt; 4 } # =&gt; true (5 &gt; 4)numbers.any?(&amp;:zero?) # =&gt; false# all?: 모두 조건 만족하면 truenumbers.all? { |n| n &gt; 0 } # =&gt; truenumbers.all? { |n| n &gt; 2 } # =&gt; false# none?: 하나도 조건 만족하지 않으면 truenumbers.none? { |n| n &gt; 10 } # =&gt; truenumbers.none? { |n| n &gt; 4 } # =&gt; false# one?: 정확히 하나만 조건 만족하면 truenumbers.one? { |n| n == 3 } # =&gt; truenumbers.one? { |n| n &gt; 3 } # =&gt; false (5, 4, 5가 해당되어 여러 개)# count: 조건 만족하는 개수numbers.count { |n| n.even? } # =&gt; 2numbers.count # =&gt; 5 (전체 개수)12.2 find / detect# 첫 번째로 조건을 만족하는 원소 반환[1, 2, 3, 4, 5].find { |n| n &gt; 3 } # =&gt; 4[1, 2, 3, 4, 5].detect { |n| n.even? } # =&gt; 2# 없으면 nil 반환[1, 2, 3].find { |n| n &gt; 10 } # =&gt; nil# Java의 stream().filter().findFirst()에 해당# Java: list.stream().filter(n -&gt; n &gt; 3).findFirst().orElse(null)# Ruby: list.find { |n| n &gt; 3 }Ch 13. 중첩 컬렉션 (Nested Collections)13.1 중첩 배열# 2차원 배열matrix = [ [1, 2, 3], [4, 5, 6], [7, 8, 9]]matrix[0] # =&gt; [1, 2, 3]matrix[0][1] # =&gt; 2matrix[1][2] # =&gt; 6# 중첩 배열 순회matrix.each do |row| row.each do |cell| print \"#{cell} \" end putsend# 평탄화matrix.flatten # =&gt; [1, 2, 3, 4, 5, 6, 7, 8, 9]matrix.flatten(1) # 1단계만 (이미 1단계라 같은 결과)# 중첩 mapmatrix.map { |row| row.map { |n| n * 2 } }# =&gt; [[2,4,6], [8,10,12], [14,16,18]]13.2 중첩 해시# JSON과 유사한 구조user = { name: \"Alice\", address: { city: \"Seoul\", district: \"강남구\" }, scores: [95, 87, 92]}user[:address][:city] # =&gt; \"Seoul\"user[:scores][0] # =&gt; 95# dig: 안전한 중첩 접근 (Ruby 2.3+)user.dig(:address, :city) # =&gt; \"Seoul\"user.dig(:contact, :phone) # =&gt; nil (KeyError 없이)# 중첩 해시 수정user[:address][:city] = \"Busan\"user[:address][:zipcode] = \"12345\" # 새 키 추가# 중첩 해시 순회user.each do |key, value| if value.is_a?(Hash) puts \"#{key}:\" value.each { |k, v| puts \" #{k}: #{v}\" } else puts \"#{key}: #{value}\" endendPart 3: 객체지향 프로그래밍 (OOP)Ch 14. 클래스와 객체14.1 기본 클래스 정의# Java와 비교하며 학습# Java:# public class Person {# private String name;# private int age;# public Person(String name, int age) { this.name = name; this.age = age; }# public String getName() { return name; }# }# Ruby:class Person def initialize(name, age) # 생성자 (Java의 constructor) @name = name # 인스턴스 변수 (Java의 this.name) @age = age end def name # getter @name end def name=(new_name) # setter (Java의 setName) @name = new_name end def introduce \"안녕하세요, #{@name}입니다. #{@age}살이에요.\" end def to_s # Java의 toString() \"Person(#{@name}, #{@age})\" endend# 객체 생성 (Java의 new Person(\"Alice\", 30))alice = Person.new(\"Alice\", 30)alice.name # =&gt; \"Alice\"alice.name = \"Bob\" # setter 호출puts alice.introduce # =&gt; \"안녕하세요, Bob입니다. 30살이에요.\"14.2 attr_accessor / attr_reader / attr_writerRuby는 반복적인 getter/setter를 자동으로 생성하는 기능을 제공합니다.class Person attr_accessor :name, :age # getter + setter 모두 생성 attr_reader :id # getter만 attr_writer :email # setter만 def initialize(id, name, age) @id = id @name = name @age = age endend# Java의 Lombok @Getter @Setter @Data와 유사!alice = Person.new(1, \"Alice\", 30)alice.name # getteralice.name = \"Bob\" # setteralice.id # getter only# alice.id = 2 # NoMethodError! (setter 없음) Ruby Java (Lombok) attr_reader :name @Getter attr_writer :name @Setter attr_accessor :name @Getter @Setter 14.3 클래스 메서드와 클래스 변수class Counter @@count = 0 # 클래스 변수 (Java의 static 변수) def initialize @@count += 1 @id = @@count end def self.count # 클래스 메서드 (Java의 static 메서드) @@count end def self.reset @@count = 0 end def id @id endendc1 = Counter.newc2 = Counter.newc3 = Counter.newCounter.count # =&gt; 3 (Java의 Counter.count() 대신 Counter.count)c1.id # =&gt; 1c3.id # =&gt; 314.4 Comparable 모듈class Rectangle include Comparable # Comparable 모듈 포함 attr_accessor :width, :height def initialize(width, height) @width = width @height = height end def area @width * @height end def &lt;=&gt;(other) # 우주선 연산자 구현 필수 area &lt;=&gt; other.area end def to_s \"#{@width}x#{@height}(넓이: #{area})\" endendrects = [Rectangle.new(3, 4), Rectangle.new(2, 8), Rectangle.new(1, 1)]rects.sort # area 기준 정렬 자동으로!rects.min # 가장 작은 것rects.max # 가장 큰 것 &lt;=&gt; (우주선 연산자) - 26/03/05 추가 두 값을 비교해서 -1, 0, 1 중 하나를 반환합니다. 1 &lt;=&gt; 2 # =&gt; -1 (왼쪽이 작음)2 &lt;=&gt; 2 # =&gt; 0 (같음)3 &lt;=&gt; 2 # =&gt; 1 (왼쪽이 큼) Java로 치면 Comparator.compare(a, b) 랑 똑같은 개념입니다. sort, min, max가 area 기준으로 동작하는 이유 ① include Comparable 선언 include Comparable Comparable 모듈을 include하면 sort, min, max, between? 등을 자동으로 쓸 수 있게 됩니다. ② 단, 조건이 하나 있음Comparable은 내부적으로 &lt;=&gt; 를 호출해서 동작합니다.그래서 &lt;=&gt;만 직접 정의해주면 나머지는 알아서 됩니다. ③ &lt;=&gt; 를 area 기준으로 정의했으므로 def &lt;=&gt;(other) area &lt;=&gt; other.area # area끼리 비교end sort, min, max가 내부적으로 &lt;=&gt;를 호출할 때, area를 기준으로 비교하게 됩니다. 전체 흐름 요약 rects.sort 호출 ↓Comparable이 내부적으로 &lt;=&gt; 를 호출 ↓개발자가 정의한 &lt;=&gt; 실행 → area끼리 비교 ↓area 기준으로 정렬 완료 Java의 Comparable&lt;T&gt; 인터페이스에서 compareTo()를 오버라이드하는 것과 정확히 같은 패턴입니다. // Javaclass Rectangle implements Comparable { @Override public int compareTo(Rectangle other) { return Integer.compare(this.area(), other.area()); }} # Ruby - 완전히 같은 개념def &lt;=&gt;(other) area &lt;=&gt; other.areaend Ch 15. 상속 (Inheritance)15.1 기본 상속# 부모 클래스class Animal attr_reader :name, :sound def initialize(name, sound) @name = name @sound = sound end def speak \"#{@name}가 #{@sound}하고 울어요.\" end def breathe \"호흡 중...\" endend# 자식 클래스 (Java의 extends 대신 &lt;)class Dog &lt; Animal def initialize(name) super(name, \"멍멍\") # 부모 생성자 호출 (Java의 super()와 동일) end def fetch(item) \"#{@name}가 #{item}을 가져와요!\" end # 메서드 오버라이딩 def speak super + \" (꼬리를 흔들며)\" # 부모 메서드 호출 후 추가 endendclass Cat &lt; Animal def initialize(name) super(name, \"야옹\") end def purr \"그르르르...\" endenddog = Dog.new(\"바둑이\")dog.speak # =&gt; \"바둑이가 멍멍하고 울어요. (꼬리를 흔들며)\"dog.breathe # =&gt; \"호흡 중...\" (상속된 메서드)dog.fetch(\"공\") # =&gt; \"바둑이가 공을 가져와요!\"puts dog.is_a?(Dog) # =&gt; trueputs dog.is_a?(Animal) # =&gt; true (상속 관계)puts dog.class # =&gt; Dogputs dog.superclass # NoMethodError! superclass는 클래스 메서드puts Dog.superclass # =&gt; AnimalCh 16. 모듈과 믹스인 (Modules &amp; Mixins)Ruby는 다중 상속을 지원하지 않지만, 모듈을 통한 믹스인(Mixin)으로 유연한 기능 공유가 가능합니다.16.1 모듈 정의와 include# ※이렇게 모듈을 만들어서 다른 파일에서도 사용할 수 있게 만들어줍니다.module Greetable def greet \"안녕하세요, 저는 #{name}입니다.\" end def farewell \"안녕히 가세요!\" endendmodule Serializable # 직렬화 def to_json # 간단한 구현 vars = instance_variables.map do |var| # puts var =&gt; var가 @name, @email 처럼 출력되기 때문에 아래에서 [1..] 처리를해 @를 잘라준다. \"\\\"#{var.to_s[1..]}\\\": \\\"#{instance_variable_get(var)}\\\"\" end \"{ #{vars.join(', ')} }\" endendclass User include Greetable include Serializable attr_reader :name, :email def initialize(name, email) @name = name @email = email endendclass Admin include Greetable attr_reader :name, :level def initialize(name, level) @name = name @level = level endenduser = User.new(\"Alice\", \"a@x.com\")puts user.greet # =&gt; 안녕하세요, 저는 Alice입니다.puts user.to_json # =&gt; {\"name\": \"Alice\", \"email\": \"a@x.com\"}admin = Admin.new(\"Bob\", 5)puts admin.greet # =&gt; 안녕하세요, 저는 Bob입니다.16.2 extend (클래스 메서드로 포함)module ClassMethods def create_table \"테이블 생성: #{name}\" endendclass Post extend ClassMethods # 클래스 메서드로 포함endPost.create_table # =&gt; \"테이블 생성: Post\"16.3 메서드 탐색 순서 (Method Lookup Path)module M1 def hello \"M1의 hello\" endendmodule M2 def hello \"M2의 hello\" endendclass MyClass include M1 include M2 # 나중에 include된 것이 우선순위 높음 def hello \"MyClass의 hello\" endend# 탐색 순서 확인MyClass.ancestors# =&gt; [MyClass, M2, M1, Object, Kernel, BasicObject]obj = MyClass.newobj.hello # =&gt; \"MyClass의 hello\" (직접 정의된 것이 우선)16.4 Java 인터페이스 vs Ruby 모듈 Java 인터페이스 Ruby 모듈(Mixin) interface Flyable module Flyable implements Flyable include Flyable 메서드 시그니처만 정의 메서드 구현 포함 가능 다중 implements 가능 다중 include 가능 상태(필드) 없음 상태(변수) 없음 Java 8부터 default 메서드 기본적으로 구현 포함 Ch 17. 접근 제어와 캡슐화17.1 public / private / protectedclass BankAccount attr_reader :balance def initialize(owner, balance) @owner = owner @balance = balance end # public 메서드 (기본값) def deposit(amount) validate_amount!(amount) # private 메서드 호출 @balance += amount log_transaction(\"입금\", amount) end def withdraw(amount) validate_amount!(amount) raise \"잔액 부족\" if amount &gt; @balance @balance -= amount log_transaction(\"출금\", amount) end def transfer_to(other_account, amount) withdraw(amount) other_account.receive_transfer(amount) # protected 호출 end protected # 같은 클래스 또는 하위 클래스에서만 호출 가능 def receive_transfer(amount) @balance += amount end private # 클래스 내부에서만 호출 가능 (Java와 동일 의미) def validate_amount!(amount) raise ArgumentError, \"금액은 양수여야 합니다\" unless amount &gt; 0 end def log_transaction(type, amount) puts \"[로그] #{@owner}: #{type} #{amount}원\" endendacc = BankAccount.new(\"Alice\", 1000)acc.deposit(500) # OKacc.withdraw(200) # OKacc.balance # =&gt; 1300# acc.validate_amount!(100) # NoMethodError: private method# acc.receive_transfer(100) # NoMethodError: protected method Ruby에서 !는 문법적 강제가 아니라 개발자들 사이의 관례(convention) 입니다. - 26/03/05 추가 의미 “이 메서드는 위험하거나, 예외를 발생시킬 수 있다” 를 호출하는 쪽에 경고하는 네이밍 컨벤션입니다. 이 코드에서의 의미 ruby def validate_amount!(amount) raise ArgumentError, \"금액은 양수여야 합니다.\" unless amount &gt; 0end raise로 예외를 던질 수 있는 메서드라는 걸 이름만 봐도 알 수 있게 합니다. ! 없이 validate_amount라고 지어도 동작은 완전히 동일합니다. 주로 쓰이는 두 가지 패턴 ① 예외를 발생시키는 경우 (이 코드) ruby def validate_amount!(amount) raise ArgumentError unless amount &gt; 0 # 예외 발생 가능end ② 원본을 직접 변경하는 경우 (Ruby 표준 라이브러리) ruby str = \"hello\"str.upcase # =&gt; \"HELLO\" (원본 유지)str.upcase! # =&gt; \"HELLO\" (원본 자체를 변경)puts str # =&gt; \"HELLO\" 정리   설명 ! 있음 위험하다, 예외가 날 수 있다, 원본을 바꾼다는 경고 ! 없음 비교적 안전한 메서드 강제성 없음 - 순수 컨벤션 Java로 치면 메서드명에 OrThrow를 붙이는 관례랑 비슷합니다. java // Java 관례findById(id) // null 반환 가능findByIdOrThrow(id) // 없으면 예외 던짐 Part 4: 고급 RubyCh 18. 파일 처리와 직렬화18.1 파일 읽기/쓰기# 26/03/06 주석 추가!# File.exist?(\"hello.txt\"): \"hello.txt\"라는 파일이 현재 폴더에 있는지 확인합니다.# ! (not): 파일이 없을 때만 아래 코드를 실행하라는 뜻입니다.# File.write: 파일을 생성하고 내용을 적습니다. 이미 내용이 있다면 덮어씁니다.if !File.exist?(\"hello.txt\") File.write(\"hello.txt\", \"Hello, Ruby!\\n두 번째 줄!\")end# File.read: 파일의 전체 내용을 하나의 큰 **문자열(String)**로 가져옵니다. 파일 크기가 아주 클 때는 메모리를 많이 사용할 수 있으니 주의해야 합니다.content = File.read(\"hello.txt\")puts contentputs# File.foreach: 파일을 한 줄씩 읽어서 블록(do...end)으로 넘겨줍니다. 메모리 효율이 좋아 대용량 파일을 읽을 때 권장되는 방식입니다.# .chomp: 각 줄 끝에 포함된 줄바꿈 문자(\\n)를 제거하여 출력 시 줄 간격이 벌어지지 않게 합니다.File.foreach(\"hello.txt\") do |line| puts line.chompendputs# File.open(..., \"w\"): 파일을 쓰기 모드로 엽니다.# 블록(do...end) 사용: 블록이 끝나면 루비가 자동으로 파일을 닫아(close) 주기 때문에 안전하고 편리합니다.# puts vs write: puts는 자동으로 끝에 줄바꿈을 붙여주고, write는 입력한 문자열만 그대로 적습니다.File.open(\"output.txt\", \"w\") do |file| file.puts \"첫 번째 줄\" file.puts \"두 번째 줄\" file.write \"세 번째 줄\"endputs# \"r\": 읽기 전용 모드로 파일을 엽니다. 위에서 만든 \"output.txt\"의 내용을 출력합니다.File.open(\"output.txt\", \"r\") do |file| puts file.readendputs File.exist?(\"hello.txt\") # 파일 존재 여부 (true/false)puts File.size(\"hello.txt\") # 파일 크기 (바이트 단위)puts File.directory?(\".\") # 해당 경로가 디렉토리(폴더)인지 확인18.2 JSON 직렬화require 'json'# Ruby 객체 → JSON 문자열data = { name: \"Alice\", age: 30, scores: [95, 87, 92] }json_str = JSON.generate(data)# =&gt; '{\"name\":\"Alice\",\"age\":30,\"scores\":[95,87,92]}'# 보기 좋게 출력puts JSON.pretty_generate(data)# {# \"name\": \"Alice\",# \"age\": 30,# \"scores\": [95, 87, 92]# }# JSON 문자열 → Ruby 객체parsed = JSON.parse(json_str)puts parsed[\"name\"] # =&gt; \"Alice\"puts parsed[\"scores\"] # =&gt; [95, 87, 92]# 파일로 저장File.write(\"data.json\", JSON.pretty_generate(data))# 파일에서 읽기loaded = JSON.parse(File.read(\"data.json\"))# Rails에서 API 응답으로 JSON 사용# render json: @user# render json: { status: :ok, data: @users }18.3 YAML 직렬화require 'yaml'# YAML 주요 사용처: Rails 설정 파일 (database.yml, config/application.yml)data = { database: { host: \"localhost\", port: 5432, name: \"myapp_development\" }, redis_url: \"redis://localhost:6379\"}# Ruby → YAMLyaml_str = YAML.dump(data)puts yaml_str# ---# :database:# :host: localhost# ...# YAML → Rubyconfig = YAML.safe_load(yaml_str, permitted_classes: [Symbol])# 파일로 저장/읽기File.write(\"config.yml\", YAML.dump(data))config = YAML.load_file(\"config.yml\")18.4 Marshal (바이너리 직렬화)class GameState attr_accessor :level, :score, :items def initialize(level, score, items) @level = level @score = score @items = items endendstate = GameState.new(5, 1500, [\"검\", \"방패\", \"물약\"])# Marshal.dump(state): 루비 객체인 state를 파일에 쓸 수 있는 **이진 바이트 스트림(Byte Stream)**으로 변환합니다.# \"wb\" 모드: 파일을 Write(쓰기) 하되, 텍스트가 아닌 Binary(이진) 데이터로 다루겠다는 뜻입니다.# 이렇게 하면 메모리에 있던 객체의 모습 그대로 save.dat라는 파일에 박제(!)됩니다.File.open(\"save.dat\", \"wb\") { |f| f.write(Marshal.dump(state)) }# File.binread(\"save.dat\"): 이진 파일의 내용을 통째로 읽어옵니다.# Marshal.load(...): 읽어온 이진 데이터를 분석해 다시 원래의 루비 객체(GameState 인스턴스)로 재구성합니다.# 이제 loaded 변수는 저장하기 전의 state와 똑같은 속성값들을 가진 객체가 됩니다.loaded = Marshal.load(File.binread(\"save.dat\"))puts loaded.levelputs loaded.scoreputs loaded.items# 💡 Marshal 사용 시 주의할 점 (꿀팁)# 속도와 편리함: 별도로 JSON이나 CSV 형식을 설계할 필요 없이 객체를 통째로 던지면 되므로 매우 빠르고 편리합니다.# 보안 주의: Marshal.load는 신뢰할 수 없는 소스(예: 인터넷에서 다운로드한 파일)로부터 데이터를 불러올 때 위험할 수 있습니다. 악의적인 코드가 실행될 수 있기 때문입니다.# 버전 호환성: 루비 버전이 크게 바뀌거나 클래스 구조가 수정되면 예전에 저장한 Marshal 파일을 읽어오지 못할 수도 있습니다.직렬화 포맷 비교 포맷 속도 가독성 사용 사례 Marshal 가장 빠름 바이너리(불가) Ruby 내부 캐시 JSON 빠름 좋음 API 통신, 웹 YAML 느림 매우 좋음 설정 파일, 소형 데이터 Ch 19. 패턴 매칭 (Pattern Matching)Ruby 2.7+에서 도입된 패턴 매칭은 복잡한 조건 분기를 우아하게 처리합니다.19.1 기본 패턴 매칭response = { status: 200, body: { user: { name: \"Alice\", age: 30 } }}# 1. 구조 확인: response[:status]가 200인지 확인합니다.# 2. 중첩 탐색: body 키 안에 user 키가 있고, 그 안에 다시 name 키가 있는지 깊숙이 들어갑니다.# 3. 타입 검사 및 할당 (String =&gt; name):# - name 키의 값이 String 타입인지 확인합니다.# - 조건이 맞다면, 그 값을 name이라는 변수에 **바인딩(할당)**합니다.# 4. 결과: response[:body][:user][:name]이 \"Alice\"이므로 조건이 충족되어 \"사용자: Alice\"가 출력됩니다.case responsein { status: 200, body: { user: { name: String =&gt; name } } } puts \"사용자: #{name}\"in { status: 404 } # 단순히 status가 404인지만 확인합니다. body가 무엇이든 상관없이 status가 일치하면 매칭됩니다. puts \"찾을 수 없음\"in { status: 500, body: { error: String =&gt; msg } } # 상태가 500일 때, body[:error]의 내용을 msg라는 변수에 담아 출력합니다. 에러 메시지를 동적으로 처리할 때 유용합니다. puts \"서버 오류: #{msg}\"end# Java switch expression과 비교# Java (14+): switch(status) { case 200 -&gt; ...; case 404 -&gt; ...; }# Ruby: case status; in 200 then ...; in 404 then ...19.2 배열 패턴# 배열 분해case [1, 2, 3]in [Integer =&gt; first, *rest] puts \"첫 번째: #{first}, 나머지: #{rest}\"end# =&gt; 첫 번째: 1, 나머지: [2, 3]# Find 패턴 (배열 내에서 찾기)case [1, \"hello\", 3, \"world\", 5]in [*, String =&gt; s, *] puts \"첫 번째 문자열: #{s}\"end# =&gt; 첫 번째 문자열: hello19.3 해시 패턴# API 응답 처리 예시 (Rails에서 매우 유용!)def handle_api_response(response) case response in { success: true, data: { users: [*, { name:, email: }, *] } } puts \"사용자: #{name} (#{email})\" in { success: false, error: { code: Integer =&gt; code, message: } } puts \"오류 #{code}: #{message}\" in { success: true, data: nil } puts \"데이터 없음\" endendhandle_api_response({ success: true, data: { users: [{ name: \"Alice\", email: \"a@x.com\" }] }})19.4 한 줄 패턴 매칭 (Ruby 3.0+)# =&gt; 연산자로 분해 할당{ name: \"Alice\", age: 30 } =&gt; { name: }puts name # =&gt; \"Alice\"# in 연산자 (true/false 반환)case_result = { status: 200 } in { status: 200 }puts case_result # =&gt; trueCh 20. 블록, Proc, Lambda20.1 블록 (Blocks)# do...end 블록[1, 2, 3].each do |n| puts n * 2end# {} 블록 (한 줄)[1, 2, 3].each { |n| puts n * 2 }# yield: 메서드 안에서 블록 실행def execute puts \"시작\" yield # 블록 실행 puts \"끝\"endexecute { puts \"블록 실행!\" }# 시작# 블록 실행!# 끝# yield에 인수 전달def repeat(n) n.times { |i| yield i }endrepeat(3) { |i| puts \"반복 #{i}\" }# 반복 0, 반복 1, 반복 2# 블록 있는지 확인def maybe_yield if block_given? yield else \"블록 없음\" endend20.2 Proc# Proc: 블록을 객체로 저장greeter = Proc.new { |name| puts \"Hello, #{name}!\" }greeter.call(\"Alice\") # =&gt; Hello, Alice!greeter.(\"Bob\") # =&gt; Hello, Bob! (call 생략)greeter[\"Charlie\"] # =&gt; Hello, Charlie! ([] 표기법)# 메서드에 전달def run_proc(p) p.call(\"World\")endrun_proc(greeter)# &amp;로 블록에서 Proc 변환 및 전달def capture(&amp;block) block # &amp;block → Proc 객체endmy_proc = capture { |x| x * 2 }my_proc.call(5) # =&gt; 10# Proc은 인수 개수에 관대함loose = Proc.new { |a, b| puts \"#{a}, #{b}\" }loose.call(1) # =&gt; \"1, \" (b는 nil)loose.call(1, 2, 3) # =&gt; \"1, 2\" (3은 무시)20.3 Lambda# Lambda: 엄격한 Proc (메서드처럼 동작)double = lambda { |n| n * 2 }double.call(5) # =&gt; 10# 화살표 표기법 (Lambda shorthand)triple = -&gt;(n) { n * 3 }triple.call(5) # =&gt; 15triple.(5) # =&gt; 15add = -&gt;(a, b) { a + b }add.call(3, 4) # =&gt; 7# Lambda는 인수 개수에 엄격strict = -&gt;(a, b) { puts \"#{a}, #{b}\" }strict.call(1, 2) # OK# strict.call(1) # ArgumentError: wrong number of arguments# 배열과 함께 사용[1, 2, 3, 4, 5].map { |n| n * 2 }# 또는doubler = -&gt;(n) { n * 2 }[1, 2, 3, 4, 5].map(&amp;doubler) # =&gt; [2, 4, 6, 8, 10]20.4 Proc vs Lambda 비교# return 동작 차이def proc_return p = Proc.new { return \"Proc에서 return\" } p.call \"메서드 끝\" # 실행 안 됨!enddef lambda_return l = -&gt; { return \"Lambda에서 return\" } l.call \"메서드 끝\" # 실행됨!endputs proc_return # =&gt; \"Proc에서 return\" (메서드 즉시 종료)puts lambda_return # =&gt; \"메서드 끝\" (lambda는 자신만 return) 특성 Proc Lambda 인수 개수 관대함 엄격함 (메서드처럼) return 동작 포함한 메서드 종료 lambda 자체만 종료 생성 방법 Proc.new { } lambda { } 또는 -&gt;{ } 타입 Proc Proc (lambda?) 용도 블록을 객체로 저장 함수형 프로그래밍 20.5 실전 활용 패턴# Symbol#to_proc: Rails에서 가장 많이 사용하는 패턴users = [\"alice\", \"bob\", \"charlie\"]users.map(&amp;:upcase) # =&gt; [\"ALICE\", \"BOB\", \"CHARLIE\"]users.map(&amp;:capitalize) # =&gt; [\"Alice\", \"Bob\", \"Charlie\"]# :메서드명 → proc으로 변환# &amp;:upcase 는 { |s| s.upcase } 와 동일# Method 참조def double(n) n * 2end[1, 2, 3].map(&amp;method(:double)) # =&gt; [2, 4, 6]# 함수 합성 (Ruby 2.6+)double = -&gt;(n) { n * 2 }increment = -&gt;(n) { n + 1 }double_then_increment = double &gt;&gt; increment # (n * 2) + 1increment_then_double = double &lt;&lt; increment # (n + 1) * 2double_then_increment.call(5) # =&gt; 11increment_then_double.call(5) # =&gt; 12Part 5: 컴퓨터 과학 기초Ch 21. 재귀 (Recursion)21.1 재귀의 기본재귀는 메서드가 자기 자신을 호출하는 기법입니다. 반드시 종료 조건(Base Case)을 가져야 합니다.# 팩토리얼 (반복 vs 재귀)# 반복문 방식def factorial_iterative(n) result = 1 n.downto(1) { |i| result *= i } resultend# 재귀 방식def factorial_recursive(n) return 1 if n &lt;= 1 # Base case: 종료 조건 n * factorial_recursive(n - 1) # Recursive caseendfactorial_iterative(5) # =&gt; 120factorial_recursive(5) # =&gt; 120# 실행 흐름:# factorial(5)# = 5 * factorial(4)# = 5 * (4 * factorial(3))# = 5 * (4 * (3 * factorial(2)))# = 5 * (4 * (3 * (2 * factorial(1))))# = 5 * (4 * (3 * (2 * 1)))# = 12021.2 피보나치 수열# 단순 재귀 (비효율적)def fib_simple(n) return n if n &lt;= 1 fib_simple(n - 1) + fib_simple(n - 2)end# 메모이제이션 (캐싱으로 최적화)def fib_memo(n, cache = {}) return n if n &lt;= 1 cache[n] ||= fib_memo(n - 1, cache) + fib_memo(n - 2, cache)endfib_simple(10) # 느림 (2^n 호출)fib_memo(40) # 빠름 (n 호출)21.3 실전 재귀: 파일 시스템 탐색# 디렉토리 구조를 재귀로 탐색def list_files(path, depth = 0) indent = \" \" * depth if File.directory?(path) puts \"#{indent}📁 #{File.basename(path)}\" Dir.entries(path).each do |entry| next if entry.start_with?(\".\") # . 과 .. 제외 list_files(File.join(path, entry), depth + 1) end else puts \"#{indent}📄 #{File.basename(path)}\" endendCh 22. 시간 복잡도22.1 Big-O 표기법시간 복잡도는 입력 크기 n에 따른 알고리즘의 실행 시간 증가율을 나타냅니다. Big-O 이름 예시 O(1) 상수 시간 해시 키 접근, 배열 인덱스 접근 O(log n) 로그 시간 이진 탐색, 균형 이진 트리 O(n) 선형 시간 배열 순회, 선형 탐색 O(n log n) 선형 로그 병합 정렬, 퀵 정렬 O(n²) 이차 시간 버블 정렬, 중첩 루프 O(2ⁿ) 지수 시간 재귀 피보나치, 브루트포스 # O(1) - 상수 시간def first_element(arr) arr[0] # 배열 크기와 무관end# O(n) - 선형 시간def find_max(arr) max = arr[0] arr.each { |n| max = n if n &gt; max } # n번 순회 maxend# O(n²) - 이차 시간def bubble_sort(arr) n = arr.length n.times do |i| (n - i - 1).times do |j| # 중첩 루프 → O(n²) arr[j], arr[j+1] = arr[j+1], arr[j] if arr[j] &gt; arr[j+1] end end arrend# O(log n) - 이진 탐색def binary_search(arr, target) low, high = 0, arr.length - 1 while low &lt;= high mid = (low + high) / 2 if arr[mid] == target return mid elsif arr[mid] &lt; target low = mid + 1 else high = mid - 1 end end -1 # 없으면 -1endCh 23. 공간 복잡도# O(1) 공간 - 추가 메모리 최소def sum_in_place(arr) total = 0 # 변수 하나만 추가 arr.each { |n| total += n } totalend# O(n) 공간 - 입력 크기에 비례def double_all(arr) arr.map { |n| n * 2 } # 새 배열 생성 → O(n) 공간end# 재귀의 공간 복잡도 (콜 스택)def factorial(n) return 1 if n &lt;= 1 n * factorial(n - 1) # n번의 스택 프레임 → O(n) 공간endCh 24. 자료 구조 및 알고리즘24.1 연결 리스트 (Linked List)class Node attr_accessor :value, :next_node def initialize(value, next_node = nil) @value = value @next_node = next_node endendclass LinkedList def initialize @head = nil end def prepend(value) @head = Node.new(value, @head) end def append(value) if @head.nil? @head = Node.new(value) else current = @head current = current.next_node until current.next_node.nil? current.next_node = Node.new(value) end end def find(value) current = @head until current.nil? return current if current.value == value current = current.next_node end nil end def to_s result = [] current = @head until current.nil? result &lt;&lt; current.value current = current.next_node end result.join(\" -&gt; \") endendlist = LinkedList.newlist.append(1)list.append(2)list.append(3)puts list.to_s # =&gt; \"1 -&gt; 2 -&gt; 3\"24.2 스택과 큐# 스택 (Stack) - LIFO: 배열로 구현stack = []stack.push(1) # pushstack.push(2)stack.push(3)stack.pop # =&gt; 3 (마지막 삽입이 먼저 나옴)stack.last # =&gt; 2 (peek)# 큐 (Queue) - FIFO: 배열로 구현queue = []queue.push(1) # enqueuequeue.push(2)queue.push(3)queue.shift # =&gt; 1 (먼저 삽입된 것이 먼저 나옴)# Ruby 표준 라이브러리의 Queuerequire 'thread'q = Queue.newq.push(\"작업1\")q.push(\"작업2\")q.pop # =&gt; \"작업1\"24.3 해시맵 (HashMap)# Ruby의 Hash가 HashMap에 해당# 평균 O(1) 삽입/조회/삭제# 빈도 카운터 (파이썬의 Counter와 유사)def frequency_count(arr) arr.tally # Ruby 2.7+: { \"a\"=&gt;3, \"b\"=&gt;2, ... }end# Hash.new(0)을 이용한 카운터def word_count(str) counts = Hash.new(0) str.split.each { |word| counts[word] += 1 } countsendputs word_count(\"hello world hello ruby world hello\").inspect# =&gt; {\"hello\"=&gt;3, \"world\"=&gt;2, \"ruby\"=&gt;1}24.4 이진 탐색 트리 (BST)class BSTNode attr_accessor :value, :left, :right def initialize(value) @value = value @left = nil @right = nil endendclass BinarySearchTree def initialize @root = nil end def insert(value) @root = insert_node(@root, value) end def include?(value) search_node(@root, value) end private def insert_node(node, value) return BSTNode.new(value) if node.nil? if value &lt; node.value node.left = insert_node(node.left, value) elsif value &gt; node.value node.right = insert_node(node.right, value) end node end def search_node(node, value) return false if node.nil? return true if node.value == value if value &lt; node.value search_node(node.left, value) else search_node(node.right, value) end endendbst = BinarySearchTree.new[5, 3, 7, 1, 4, 6, 8].each { |n| bst.insert(n) }bst.include?(4) # =&gt; truebst.include?(9) # =&gt; false부록: Rails 8 준비 체크리스트Rails 8을 시작하기 전에 아래 개념들을 확실히 이해했는지 점검해보자.필수 Ruby 개념 체크리스트기초 문법 동적 타입 시스템과 모든 것이 객체임을 이해 nil의 truthy/falsy 동작 (특히 Java와의 차이) 문자열 보간 \"#{}\" 사용 심볼(:symbol)의 역할과 해시 키로의 활용 ||= (조건부 할당) 패턴 이해컬렉션 map, select, reject, reduce 활용 &amp;:method_name 단축 표현 이해 해시 심볼 키 표기법 ({name: \"Alice\"}) dig 메서드로 중첩 해시 안전 접근OOP attr_accessor, attr_reader, attr_writer 활용 initialize 생성자 상속과 super 키워드 include로 모듈 믹스인 private/protected 접근 제어고급 블록, Proc, Lambda 차이 yield로 블록 실행 JSON 파싱 (JSON.parse, JSON.generate) 예외 처리 (begin/rescue/ensure) 패턴 매칭 기초Rails 8에서 자주 만날 Ruby 패턴# 1. 심볼 + 해시 (모든 곳에서 사용)render json: { status: :ok, data: @users }# 2. &amp;:method 패턴@users.map(&amp;:email)@posts.select(&amp;:published?)# 3. 조건부 할당@user ||= User.find(params[:id])# 4. 블록으로 설정Rails.application.routes.draw do resources :usersend# 5. 체이닝User.where(active: true) .order(:name) .limit(10) .map(&amp;:email)# 6. 키워드 인수def update_user(user, name:, email:, role: :user) user.update!(name: name, email: email, role: role)end# 7. Proc/Lambda as 필터ADMIN_FILTER = -&gt;(user) { user.admin? }active_admins = users.select(&amp;ADMIN_FILTER)추가 학습 자료 The Odin Project — Ruby Course Ruby 공식 문서 Ruby 스타일 가이드 RubyGems — Ruby 패키지 생태계 이 글은 The Odin Project Ruby 커리큘럼을 기반으로, Java/Spring 백엔드 경험자를 위해 정리한 내용입니다. Rails 8 학습을 시작하기 전에 각 챕터의 연습 문제를 꼭 풀어보세요." }, { "title": "인터페이스 기반 리포지토리 설계", "url": "/posts/%EC%9D%B8%ED%84%B0%ED%8E%98%EC%9D%B4%EC%8A%A4-%EA%B8%B0%EB%B0%98-%EB%A6%AC%ED%8F%AC%EC%A7%80%ED%86%A0%EB%A6%AC-%EC%84%A4%EA%B3%84/", "categories": "Study, Spring", "tags": "스프링, 인터페이스 기반 설계, DIP, OCP", "date": "2026-02-24 00:00:00 +0900", "snippet": "출발점김영한 강사님이 MemberRepository를 작성할 때 이런 말씀을 하셨다. “이걸 인터페이스로 구현하면 좋겠죠. 그렇게하면 나중에 여러분의 구현체로 DB의 회원을 관리할 수도 있고, 메모리의 회원을 관리할 수도 있고 뭐 이렇게 할 수 있으니까요?”이 말의 핵심은 “인터페이스에 의존하면 구현체를 자유롭게 바꿀 수 있다”는 것이다.이것은 객체...", "content": "출발점김영한 강사님이 MemberRepository를 작성할 때 이런 말씀을 하셨다. “이걸 인터페이스로 구현하면 좋겠죠. 그렇게하면 나중에 여러분의 구현체로 DB의 회원을 관리할 수도 있고, 메모리의 회원을 관리할 수도 있고 뭐 이렇게 할 수 있으니까요?”이 말의 핵심은 “인터페이스에 의존하면 구현체를 자유롭게 바꿀 수 있다”는 것이다.이것은 객체지향 설계의 핵심 원칙인 의존성 역전 원칙(DIP)과 개방-폐쇄 원칙(OCP)을 따르는 방식이다.설계 단계별 예시1단계: 인터페이스 정의package hello.login.domain.member;import java.util.List;import java.util.Optional;/** * 회원 리포지토리 인터페이스 * 구체적인 저장 방식(메모리, DB, 파일 등)에 무관한 추상화 */public interface MemberRepository { Member save(Member member); Optional&lt;Member&gt; findById(Long id); Optional&lt;Member&gt; findByLoginId(String loginId); List&lt;Member&gt; findAll(); void clearStore();}2단계: 메모리 기반 구현체 (학습/개발용)package hello.login.domain.member;import lombok.extern.slf4j.Slf4j;import org.springframework.stereotype.Repository;import java.util.*;@Slf4j@Repositorypublic class MemoryMemberRepository implements MemberRepository { private static Map&lt;Long, Member&gt; store = new HashMap&lt;&gt;(); private static long sequence = 0L; @Override public Member save(Member member) { member.setId(++sequence); store.put(member.getId(), member); log.info(\"회원 저장 완료 (메모리): {}\", member); return member; } @Override public Optional&lt;Member&gt; findById(Long id) { return Optional.ofNullable(store.get(id)); } @Override public Optional&lt;Member&gt; findByLoginId(String loginId) { return store.values().stream() .filter(m -&gt; m.getLoginId().equals(loginId)) .findFirst(); } @Override public List&lt;Member&gt; findAll() { return new ArrayList&lt;&gt;(store.values()); } @Override public void clearStore() { store.clear(); }}특징: HashMap을 사용한 인메모리 저장 애플리케이션 재시작 시 데이터가 소실됨 빠른 개발 및 테스트에 유리 별도의 DB 설정이 필요 없음3단계: DB 기반 구현체 (실제 운영용)package hello.login.domain.member;import lombok.RequiredArgsConstructor;import lombok.extern.slf4j.Slf4j;import org.springframework.stereotype.Repository;import javax.sql.DataSource;import java.sql.*;import java.util.ArrayList;import java.util.List;import java.util.Optional;@Slf4j// @Repository // 실제로 사용할 때만 활성화@RequiredArgsConstructorpublic class JdbcMemberRepository implements MemberRepository { private final DataSource dataSource; @Override public Member save(Member member) { String sql = \"INSERT INTO member(login_id, name, password) VALUES (?, ?, ?)\"; try (Connection con = dataSource.getConnection(); PreparedStatement pstmt = con.prepareStatement(sql, Statement.RETURN_GENERATED_KEYS)) { pstmt.setString(1, member.getLoginId()); pstmt.setString(2, member.getName()); pstmt.setString(3, member.getPassword()); pstmt.executeUpdate(); ResultSet rs = pstmt.getGeneratedKeys(); if (rs.next()) { member.setId(rs.getLong(1)); } log.info(\"회원 저장 완료 (DB): {}\", member); return member; } catch (SQLException e) { throw new RuntimeException(e); } } @Override public Optional&lt;Member&gt; findById(Long id) { String sql = \"SELECT * FROM member WHERE id = ?\"; try (Connection con = dataSource.getConnection(); PreparedStatement pstmt = con.prepareStatement(sql)) { pstmt.setLong(1, id); ResultSet rs = pstmt.executeQuery(); if (rs.next()) { Member member = new Member(); member.setId(rs.getLong(\"id\")); member.setLoginId(rs.getString(\"login_id\")); member.setName(rs.getString(\"name\")); member.setPassword(rs.getString(\"password\")); return Optional.of(member); } return Optional.empty(); } catch (SQLException e) { throw new RuntimeException(e); } } @Override public Optional&lt;Member&gt; findByLoginId(String loginId) { String sql = \"SELECT * FROM member WHERE login_id = ?\"; try (Connection con = dataSource.getConnection(); PreparedStatement pstmt = con.prepareStatement(sql)) { pstmt.setString(1, loginId); ResultSet rs = pstmt.executeQuery(); if (rs.next()) { Member member = new Member(); member.setId(rs.getLong(\"id\")); member.setLoginId(rs.getString(\"login_id\")); member.setName(rs.getString(\"name\")); member.setPassword(rs.getString(\"password\")); return Optional.of(member); } return Optional.empty(); } catch (SQLException e) { throw new RuntimeException(e); } } @Override public List&lt;Member&gt; findAll() { String sql = \"SELECT * FROM member\"; List&lt;Member&gt; members = new ArrayList&lt;&gt;(); try (Connection con = dataSource.getConnection(); PreparedStatement pstmt = con.prepareStatement(sql)) { ResultSet rs = pstmt.executeQuery(); while (rs.next()) { Member member = new Member(); member.setId(rs.getLong(\"id\")); member.setLoginId(rs.getString(\"login_id\")); member.setName(rs.getString(\"name\")); member.setPassword(rs.getString(\"password\")); members.add(member); } return members; } catch (SQLException e) { throw new RuntimeException(e); } } @Override public void clearStore() { String sql = \"DELETE FROM member\"; try (Connection con = dataSource.getConnection(); PreparedStatement pstmt = con.prepareStatement(sql)) { pstmt.executeUpdate(); } catch (SQLException e) { throw new RuntimeException(e); } }}특징: 실제 데이터베이스에 저장 데이터 영속성 보장 트랜잭션 처리 가능 동시성 제어 가능4단계: 서비스 계층에서 사용package hello.login.domain.member;import lombok.RequiredArgsConstructor;import org.springframework.stereotype.Service;@Service@RequiredArgsConstructorpublic class MemberService { // 핵심: 인터페이스 타입으로 의존한다. // 구체적인 구현체(MemoryMemberRepository 또는 JdbcMemberRepository)를 // 알 필요가 없다. private final MemberRepository memberRepository; public Long join(Member member) { validateDuplicateMember(member); Member savedMember = memberRepository.save(member); return savedMember.getId(); } public Member login(String loginId, String password) { return memberRepository.findByLoginId(loginId) .filter(m -&gt; m.getPassword().equals(password)) .orElse(null); } private void validateDuplicateMember(Member member) { memberRepository.findByLoginId(member.getLoginId()) .ifPresent(m -&gt; { throw new IllegalStateException(\"이미 존재하는 회원입니다.\"); }); }}이 설계가 주는 이점구현체 교체가 간단하다@Configurationpublic class RepositoryConfig { @Bean public MemberRepository memberRepository() { // 환경에 따라 바꾸기만 하면 됨 // return new MemoryMemberRepository(); // 개발 환경 return new JdbcMemberRepository(dataSource()); // 운영 환경 // MemberService 코드는 전혀 수정할 필요 없음 }}테스트가 쉬워진다@Testvoid 로그인_테스트() { // Mock 객체로 쉽게 교체 가능 MemberRepository mockRepository = mock(MemberRepository.class); MemberService service = new MemberService(mockRepository); // given when(mockRepository.findByLoginId(\"testId\")) .thenReturn(Optional.of(testMember)); // when &amp; then Member result = service.login(\"testId\", \"password\"); assertThat(result).isNotNull();}OCP(개방-폐쇄 원칙) 준수확장에는 열려있고, 수정에는 닫혀있는 구조가 된다.MemberService (변경 없음) ↓ 의존MemberRepository (인터페이스) ↑ 구현├── MemoryMemberRepository (메모리 저장)├── JdbcMemberRepository (JDBC 사용)├── JpaMemberRepository (JPA 사용) ← 추가 가능├── MyBatisMemberRepository (MyBatis 사용) ← 추가 가능└── RedisMemberRepository (Redis 사용) ← 추가 가능새로운 구현체를 추가해도 MemberService 코드는 전혀 수정하지 않아도 된다.실무 적용 시나리오시나리오 1: 개발에서 운영으로 전환// 개발 단계: 빠른 개발을 위해 메모리 사용@Profile(\"dev\")@Repositorypublic class MemoryMemberRepository implements MemberRepository { ... }// 운영 단계: 실제 DB 사용@Profile(\"prod\")@Repositorypublic class JdbcMemberRepository implements MemberRepository { ... }# application-dev.propertiesspring.profiles.active=dev# application-prod.propertiesspring.profiles.active=prod시나리오 2: 성능 개선을 위한 캐시 추가// 기존 DB Repository를 감싸는 캐시 레이어@Primary@Repository@RequiredArgsConstructorpublic class CachedMemberRepository implements MemberRepository { private final JdbcMemberRepository jdbcRepository; private final Map&lt;Long, Member&gt; cache = new ConcurrentHashMap&lt;&gt;(); @Override public Optional&lt;Member&gt; findById(Long id) { // 캐시 먼저 확인 if (cache.containsKey(id)) { return Optional.of(cache.get(id)); } // 캐시 미스면 DB 조회 후 캐시에 저장 Optional&lt;Member&gt; member = jdbcRepository.findById(id); member.ifPresent(m -&gt; cache.put(id, m)); return member; } // 나머지 메서드들...}시나리오 3: 멀티 DB 환경// 읽기 전용 DB@Qualifier(\"reader\")@Repositorypublic class ReadOnlyMemberRepository implements MemberRepository { ... }// 쓰기 전용 DB@Qualifier(\"writer\")@Repositorypublic class WriteOnlyMemberRepository implements MemberRepository { ... }// 서비스에서 선택적 사용@Servicepublic class MemberService { private final MemberRepository readerRepo; private final MemberRepository writerRepo; public MemberService( @Qualifier(\"reader\") MemberRepository readerRepo, @Qualifier(\"writer\") MemberRepository writerRepo) { this.readerRepo = readerRepo; this.writerRepo = writerRepo; }}정리 인터페이스 = 계약(Contract). “이런 기능들을 제공해야 한다”는 약속이다. 구현체 = 계약의 실제 이행 방법. 메모리, DB, 파일, 네트워크 등 다양한 방식으로 구현할 수 있다. 사용하는 쪽(Service, Controller) = 계약만 알면 된다. 구체적인 구현 방법은 몰라도 되고, 나중에 구현체가 바뀌어도 코드를 수정할 필요가 없다. 이것이 바로 유연한 설계의 핵심이다." }, { "title": "스프링 의존성 주입 동작원리", "url": "/posts/%EC%8A%A4%ED%94%84%EB%A7%81-%EC%9D%98%EC%A1%B4%EC%84%B1-%EC%A3%BC%EC%9E%85-%EB%8F%99%EC%9E%91%EC%9B%90%EB%A6%AC/", "categories": "Study, Spring", "tags": "스프링, 의존성주입, DI, Spring Container", "date": "2026-02-24 00:00:00 +0900", "snippet": "의문에서 출발하기컨트롤러에 private final MemberRepository memberRepository;만 작성해두면, MemoryMemberRepository가 구현체로 들어오는 걸까?결론부터 말하면, 그렇다. 스프링이 자동으로 적절한 구현체를 찾아서 주입해준다.동작 원리1. 스프링 컨테이너의 빈 등록 과정애플리케이션 시작 ↓스프링이 ...", "content": "의문에서 출발하기컨트롤러에 private final MemberRepository memberRepository;만 작성해두면, MemoryMemberRepository가 구현체로 들어오는 걸까?결론부터 말하면, 그렇다. 스프링이 자동으로 적절한 구현체를 찾아서 주입해준다.동작 원리1. 스프링 컨테이너의 빈 등록 과정애플리케이션 시작 ↓스프링이 @Component, @Repository, @Service, @Controller 스캔 ↓해당 클래스들을 스프링 빈으로 등록 ↓의존성 주입이 필요한 곳에 자동으로 주입2. 실제 예시 코드MemoryMemberRepository (구현체)package hello.login.domain.member;import org.springframework.stereotype.Repository;import java.util.*;@Repository // 이 애노테이션으로 스프링 빈에 등록된다public class MemoryMemberRepository implements MemberRepository { private static Map&lt;Long, Member&gt; store = new HashMap&lt;&gt;(); private static long sequence = 0L; @Override public Member save(Member member) { member.setId(++sequence); store.put(member.getId(), member); return member; } @Override public Optional&lt;Member&gt; findById(Long id) { return Optional.ofNullable(store.get(id)); } @Override public Optional&lt;Member&gt; findByLoginId(String loginId) { return store.values().stream() .filter(m -&gt; m.getLoginId().equals(loginId)) .findFirst(); } // ... 기타 메서드}MemberController (사용하는 쪽)package hello.login.web.member;import hello.login.domain.member.Member;import hello.login.domain.member.MemberRepository;import lombok.RequiredArgsConstructor;import org.springframework.stereotype.Controller;import org.springframework.web.bind.annotation.*;@Controller@RequiredArgsConstructor // final 필드에 대한 생성자를 자동 생성public class MemberController { // 인터페이스 타입으로 선언 private final MemberRepository memberRepository; // @RequiredArgsConstructor가 아래 생성자를 자동 생성해준다: // public MemberController(MemberRepository memberRepository) { // this.memberRepository = memberRepository; // } @PostMapping(\"/members/add\") public String save(@ModelAttribute Member member) { memberRepository.save(member); return \"redirect:/\"; } @GetMapping(\"/members/{id}\") public String findMember(@PathVariable Long id) { Member member = memberRepository.findById(id) .orElseThrow(() -&gt; new IllegalArgumentException(\"회원 없음\")); // ... return \"member\"; }}3. 스프링의 자동 주입 흐름1. MemberController 생성 시도 ↓2. 생성자에 MemberRepository 타입 파라미터 발견 ↓3. 스프링 컨테이너에서 MemberRepository 타입의 빈 검색 ↓4. MemoryMemberRepository 발견 (@Repository가 붙어있고, MemberRepository를 구현) ↓5. MemoryMemberRepository 인스턴스를 생성자에 주입 ↓6. MemberController 생성 완료여러 구현체가 있을 때는?상황: 구현체가 2개 이상@Repositorypublic class MemoryMemberRepository implements MemberRepository { // 메모리 구현}@Repositorypublic class JdbcMemberRepository implements MemberRepository { // DB 구현}이렇게 되면 스프링이 어떤 구현체를 주입해야 할지 판단할 수 없기 때문에 오류가 발생한다.***************************APPLICATION FAILED TO START***************************Description:Field memberRepository in hello.login.web.member.MemberController required a single bean,but 2 were found:\t- memoryMemberRepository: defined in file [...]\t- jdbcMemberRepository: defined in file [...]해결 방법 1: @Primary 사용@Repository@Primary // 이 구현체를 기본으로 사용public class MemoryMemberRepository implements MemberRepository { // 메모리 구현}@Repositorypublic class JdbcMemberRepository implements MemberRepository { // DB 구현}@Primary가 붙은 MemoryMemberRepository가 우선적으로 주입된다.해결 방법 2: @Qualifier 사용@Repository@Qualifier(\"memory\")public class MemoryMemberRepository implements MemberRepository { // 메모리 구현}@Repository@Qualifier(\"jdbc\")public class JdbcMemberRepository implements MemberRepository { // DB 구현}컨트롤러에서 명시적으로 선택할 수 있다.@Controllerpublic class MemberController { private final MemberRepository memberRepository; public MemberController(@Qualifier(\"memory\") MemberRepository memberRepository) { this.memberRepository = memberRepository; }}해결 방법 3: 하나만 @Repository 붙이기@Repository // 이것만 빈으로 등록public class MemoryMemberRepository implements MemberRepository { // 메모리 구현}// @Repository 없음 — 빈으로 등록되지 않음public class JdbcMemberRepository implements MemberRepository { // DB 구현}가장 단순한 방법이다. 학습 단계에서는 이 방법이 적합하다.해결 방법 4: @Profile 사용실무에서는 환경별로 구현체를 분리하는 경우가 많다. @Profile을 사용하면 활성화된 프로파일에 따라 등록되는 빈이 달라진다.@Repository@Profile(\"dev\") // 개발 환경에서만 활성화public class MemoryMemberRepository implements MemberRepository { // 메모리 구현}@Repository@Profile(\"prod\") // 운영 환경에서만 활성화public class JdbcMemberRepository implements MemberRepository { // DB 구현}application.properties에서 프로파일을 지정한다.spring.profiles.active=dev # 개발 시# spring.profiles.active=prod # 운영 시전체 흐름 정리// 1. 구현체에 @Repository 붙이기@Repositorypublic class MemoryMemberRepository implements MemberRepository { // 구현...}// 2. 컨트롤러에서 인터페이스 타입으로 의존성 선언@Controller@RequiredArgsConstructorpublic class MemberController { private final MemberRepository memberRepository; // 인터페이스 타입 // 3. 스프링이 자동으로 MemoryMemberRepository를 주입}왜 이렇게 동작하는가? @Repository — 스프링에게 “나를 빈으로 등록해달라”고 알리는 애노테이션이다. 실제로는 @Component의 특수한 형태이다. @RequiredArgsConstructor — Lombok이 final 필드에 대한 생성자를 자동으로 만들어준다. 스프링은 생성자가 하나만 있으면 @Autowired 없이도 의존성을 주입한다. 타입 기반 매칭 — 스프링은 MemberRepository 타입의 빈을 컨테이너에서 찾는다. MemoryMemberRepository가 이 인터페이스를 구현하고 있으므로 매칭에 성공한다. 로그로 확인하기application.properties에 다음을 추가하면 주입 과정을 로그로 확인할 수 있다.logging.level.org.springframework.beans=DEBUG애플리케이션 시작 시 아래와 같은 로그가 출력된다.Creating shared instance of singleton bean 'memoryMemberRepository'...Autowiring by type from bean name 'memberController'via constructor to bean named 'memoryMemberRepository'핵심 정리 구현체가 1개만 있으면 스프링이 자동으로 찾아서 주입한다. 구현체가 여러 개이면 @Primary, @Qualifier, @Profile 등으로 어떤 구현체를 사용할지 명시해야 한다. 컨트롤러는 인터페이스에만 의존하기 때문에, 나중에 구현체가 바뀌어도 컨트롤러 코드를 수정할 필요가 없다.이것이 스프링 의존성 주입(Dependency Injection)의 핵심이다." }, { "title": "세션매니저 동작원리", "url": "/posts/%EC%84%B8%EC%85%98%EB%A7%A4%EB%8B%88%EC%A0%80-%EB%8F%99%EC%9E%91%EC%9B%90%EB%A6%AC/", "categories": "Study, Spring", "tags": "스프링, 세션, 쿠키, HTTP", "date": "2026-02-24 00:00:00 +0900", "snippet": "개요SessionManager는 서버에서 직접 세션을 관리하는 커스텀 세션 매니저이다. HTTP의 무상태(Stateless) 특성을 극복하기 위해 쿠키와 서버 저장소를 활용하여 사용자의 로그인 상태를 유지한다.전체 구조@Componentpublic class SessionManager { public static final String SESSI...", "content": "개요SessionManager는 서버에서 직접 세션을 관리하는 커스텀 세션 매니저이다. HTTP의 무상태(Stateless) 특성을 극복하기 위해 쿠키와 서버 저장소를 활용하여 사용자의 로그인 상태를 유지한다.전체 구조@Componentpublic class SessionManager { public static final String SESSION_COOKIE_NAME = \"mySessionId\"; private Map&lt;String, Object&gt; sessionStore = new ConcurrentHashMap&lt;&gt;(); public void createSession(Object value, HttpServletResponse response) { ... } public Object getSession(HttpServletRequest request) { ... }}핵심 구성 요소 SESSION_COOKIE_NAME: 쿠키의 이름 (\"mySessionId\") sessionStore: 세션 데이터를 저장하는 Map Key: sessionId (UUID) Value: 저장할 객체 (예: Member 객체) 세션 생성 과정 (createSession)단계별 흐름1. 사용자 로그인 성공 ↓2. SessionManager.createSession(memberObject, response) 호출 ↓3. UUID 생성: sessionId = \"abc-123-def-456\" ↓4. sessionStore에 저장: sessionStore.put(\"abc-123-def-456\", memberObject) ↓5. 쿠키 생성 및 응답: Cookie(name=\"mySessionId\", value=\"abc-123-def-456\") ↓6. 클라이언트 브라우저에 쿠키 저장시각적 표현서버 메모리 (SessionManager)┌─────────────────────────────────────┐│ sessionStore (ConcurrentHashMap) ││ ┌───────────────┬──────────────┐ ││ │ Key (String) │ Value (Object)│ ││ ├───────────────┼──────────────┤ ││ │abc-123-def... │ Member{ │ ││ │ │ id=1, │ ││ │ │ name=\"김철수\"│ ││ │ │ } │ ││ └───────────────┴──────────────┘ │└─────────────────────────────────────┘ ↓ 쿠키 전송클라이언트 브라우저┌─────────────────────────────────────┐│ Cookie Storage ││ ┌─────────────────────────────────┐ ││ │ mySessionId=abc-123-def-456 │ ││ └─────────────────────────────────┘ │└─────────────────────────────────────┘코드 분석public void createSession(Object value, HttpServletResponse response) { // 1. 세션 ID 생성 (추정 불가능한 랜덤 값) String sessionId = UUID.randomUUID().toString(); // 예: \"550e8400-e29b-41d4-a716-446655440000\" // 2. 서버 메모리에 세션 데이터 저장 sessionStore.put(sessionId, value); // Key: 세션ID, Value: 로그인한 사용자 정보 (Member 객체) // 3. 세션 ID를 쿠키로 만들어서 클라이언트에 전달 Cookie mySessionCookie = new Cookie(SESSION_COOKIE_NAME, sessionId); response.addCookie(mySessionCookie); // 브라우저는 이 쿠키를 저장하고 다음 요청부터 자동으로 포함시킨다}세션 조회 과정 (getSession)단계별 흐름1. 클라이언트가 HTTP 요청 (쿠키 자동 포함: mySessionId=abc-123-def-456) ↓2. SessionManager.getSession(request) 호출 ↓3. request.getCookies()로 모든 쿠키 배열 가져오기 ↓4. 쿠키 배열을 순회하며 name이 \"mySessionId\"인 쿠키 찾기 ↓5. 찾은 쿠키의 value 추출: \"abc-123-def-456\" ↓6. sessionStore.get(\"abc-123-def-456\")로 Member 객체 조회 ↓7. Member 객체 반환상세 시각적 표현HTTP Request┌────────────────────────────────────────────┐│ GET /items HTTP/1.1 ││ Host: localhost:8080 ││ Cookie: mySessionId=abc-123-def-456; ││ otherCookie=xyz │└────────────────────────────────────────────┘ ↓┌────────────────────────────────────────────┐│ request.getCookies() 실행 ││ ││ Cookie[] cookies = [ ││ Cookie(name=\"mySessionId\", ││ value=\"abc-123-def-456\"), ← 이것 ││ Cookie(name=\"otherCookie\", ││ value=\"xyz\"), ││ ... ││ ] │└────────────────────────────────────────────┘ ↓┌────────────────────────────────────────────┐│ for 문으로 쿠키 배열 순회 ││ ││ if (cookie.getName().equals(\"mySessionId\"))││ → 찾았다! ││ ││ String sessionId = cookie.getValue(); ││ → \"abc-123-def-456\" │└────────────────────────────────────────────┘ ↓┌────────────────────────────────────────────┐│ sessionStore.get(\"abc-123-def-456\") ││ ││ Map에서 Key로 검색: ││ ┌───────────────┬──────────────┐ ││ │ Key │ Value │ ││ ├───────────────┼──────────────┤ ││ │abc-123-def... │ Member{ │ ← 반환 ││ │ │ id=1, │ ││ │ │ name=\"김철수\" │ ││ │ │ } │ ││ └───────────────┴──────────────┘ │└────────────────────────────────────────────┘코드 분석public Object getSession(HttpServletRequest request) { // 1. 요청에 포함된 모든 쿠키 가져오기 Cookie[] cookies = request.getCookies(); if (cookies == null) { return null; // 쿠키가 하나도 없으면 null 반환 } // 2. 쿠키 배열을 순회하며 세션 쿠키 찾기 for (Cookie cookie : cookies) { if (cookie.getName().equals(SESSION_COOKIE_NAME)) { // 3. 쿠키의 이름이 \"mySessionId\"인 것을 찾음 // 4. 쿠키의 값(세션ID)으로 sessionStore에서 조회 return sessionStore.get(cookie.getValue()); // sessionStore.get(\"abc-123-def-456\") // → Member 객체 반환 } } return null; // 세션 쿠키를 찾지 못하면 null 반환}핵심 이해 포인트1. 쿠키와 세션 데이터의 역할 분리쿠키 (클라이언트 저장)┌─────────────────────────────────────────────┐│ - 이름: mySessionId ││ - 값: abc-123-def-456 (단순 식별자) ││ - 민감한 정보 없음 (UUID만 저장) │└─────────────────────────────────────────────┘세션 데이터 (서버 메모리 저장)┌─────────────────────────────────────────────┐│ - Key: abc-123-def-456 ││ - Value: Member { id=1, loginId=\"user1\", ││ name=\"김철수\", ... } ││ - 민감한 정보는 서버에만 보관 │└─────────────────────────────────────────────┘2. sessionStore.get()의 동작 원리// Map의 기본 동작Map&lt;String, Object&gt; map = new HashMap&lt;&gt;();// 저장 (createSession 시)map.put(\"abc-123\", memberObject);// 조회 (getSession 시)Object result = map.get(\"abc-123\"); // memberObject 반환getSession에서의 적용:// cookie.getValue() = \"abc-123-def-456\" (쿠키에서 꺼낸 sessionId)sessionStore.get(cookie.getValue())// = sessionStore.get(\"abc-123-def-456\")// = Member 객체3. 이 설계의 이유보안성 — 클라이언트에는 의미 없는 UUID만 저장된다. 실제 사용자 정보는 서버 메모리에만 존재하므로, 쿠키를 탈취당하더라도 sessionId만 노출될 뿐이다.무상태성 극복 — HTTP는 기본적으로 이전 요청을 기억하지 못한다. 쿠키를 통해 매 요청마다 “나는 이 세션의 사용자”라고 알려주고, 서버는 sessionId로 해당 사용자를 식별한다.전체 시퀀스[클라이언트] [서버 - SessionManager] [sessionStore] 로그인 요청 │──────────────→ createSession(member) │ │ │ UUID 생성 │ \"abc-123-def\" │ │ │ │───────────→ put(\"abc-123\", member) │ │ │ │ │ 저장 완료 │ │←───────────────────┘ │ │ │ 쿠키 응답 (abc-123) │←──────────────│ │ 쿠키 저장 │ │ 다음 요청 (쿠키 포함) │──────────────→ getSession(request) │ │ │ 쿠키 배열 순회 │ \"mySessionId\" 찾기 │ │ │ │───────────→ get(\"abc-123\") │ │ │ │ │ member 조회 │ │←───────────────────┘ │ Member 객체 반환 │←──────────────│ │비유로 이해하기은행 금고 시스템으로 비유하면 이해가 쉽다. sessionStore = 은행 금고실 sessionId (UUID) = 금고 열쇠 번호 (예: #12345) Member 객체 = 금고 안의 귀중품 (사용자 정보)로그인(createSession):은행원이 귀중품(Member)을 금고에 넣고, 금고 번호(#12345)를 고객에게 알려준다.고객은 그 번호를 수첩(쿠키)에 적어둔다.다음 방문(getSession):고객이 “제 금고 #12345 열어주세요”라고 말하면, 은행원이 수첩(쿠키)에서 번호를 확인하고 금고실(sessionStore)에서 해당 금고를 열어 귀중품(Member)을 꺼내준다.주의사항 및 한계1. 메모리 기반 저장소private Map&lt;String, Object&gt; sessionStore = new ConcurrentHashMap&lt;&gt;();서버를 재시작하면 모든 세션 데이터가 소실된다. 실제 운영 환경에서는 Redis, 데이터베이스 등의 외부 저장소를 사용한다.2. 세션 만료 미구현현재 구현에는 세션 타임아웃 기능이 없다. 실무에서는 일정 시간이 지나면 세션을 자동으로 삭제해야 한다.3. 보안 강화 필요 HTTPS 사용 (쿠키 탈취 방지) Secure, HttpOnly 플래그 설정 CSRF 토큰 사용참고: Spring HttpSession과의 비교직접 구현한 SessionManager// 세션 생성sessionManager.createSession(member, response);// 세션 조회Member member = (Member) sessionManager.getSession(request);Spring의 HttpSession// 세션 생성HttpSession session = request.getSession();session.setAttribute(\"member\", member);// 세션 조회Member member = (Member) session.getAttribute(\"member\");차이점: SessionManager는 교육용으로, 세션의 동작 원리를 이해하기 위한 구현이다. HttpSession은 실무용으로, 세션 만료, 클러스터링 등 고급 기능을 제공한다.정리 세션 ID는 단순 식별자다. 쿠키에는 UUID만 저장된다. 실제 데이터는 서버에 보관된다. sessionStore Map에 Key-Value 형태로 저장된다. 쿠키는 자동 전송된다. 브라우저가 매 요청마다 쿠키를 자동으로 포함시킨다. Map의 get/put이 핵심이다. sessionId를 Key로 사용하여 데이터를 저장하고 조회한다.이 방식을 이해하면 Spring의 HttpSession, JWT, OAuth 같은 인증 메커니즘도 훨씬 쉽게 이해할 수 있다." } ]
