apps/web-e2e 를 next dev (동적) 단일 기반에서 static-server.mjs (정적) 병행 기반으로 바꾸는 과정에서 겪은 이슈들과 대응, 남은 항목을 정리한다. 작업 진행 순서를 참고하되, 내용은 주제별로 묶었다. 1. 배경 — 왜 static 기반이 필요했나 이 프로젝트( output: "export" , 정적 익스포트)는 프로덕션엔 서버가 없다. 그런데 e2e는 지금까지 next dev (라이브 서버) 기준으로만 돌아갔다. 팀 내 피드백으로는: "이전에 이 프로젝트는 SSR 사용했었음 → 모든 e2e 환경은 여기에 맞춰져있음 → static으로 바꾸면서 e2e에서 오류가 보인 것" 즉 next dev (동적)로는 "실제 배포에서 사용자가 잘못된 URL로 들어왔을 때" 같은 시나리오를 정확히 재현할 수 없었고, 이게 not-found 관련 문제의 근본 원인이었다. 2. 구축한 것 — static 기반 e2e 파이프라인 파일 역할 apps/web-e2e/static-server.mjs apps/web/out (빌드 산출물)을 그대로 서빙하는 최소 정적 파일 서버. 없는 경로는 실제 404.html 을 404 상태코드로 반환 apps/web-e2e/playwright.config.ts E2E_STATIC=true 면 static-server.mjs , 아니면 기존 next dev 를 띄우도록 webServer.command 분기 apps/web-e2e/project.json e2e:local 타겟 추가 — E2E_STATIC=true 로 playwright 실행 루트 package.json pnpm e2e:local 스크립트 추가 검증 결과(static 기반) : 전체 스위트 반복 실행 시 not-found·navigator 포함 모두 안정적으로 통과(자세한 내용은 5절). 3. not-found(500 ↔ 404) 이슈 — 해결됨 3-1. 환경별로 다른 게 정답인 이유 환경 정답 이유 next dev 500 Next 자체 안전장치( next-dev-server.js )가 output:"export" + generateStaticParams() 에 없는 id를 감지해 의도적으로 런타임 에러를 던짐 static( static-server.mjs ) 404 파일이 실제로 없어서 나는 진짜 404 — 실제 배포와 동일 왜 dev에서는 404가 아니라 500이 뜨는가 — 상세 generateStaticParams() 는 빌드 시점에 "이 동적 라우트( [id] )에 대해 어떤 id들의 파일을 실제로 만들지" 목록을 정한다. 이 목록에 없는 id는 정적 빌드에서 파일 자체가 안 만들어지므로, 실제 배포에서는 그냥 파일이 없어서 404가 뜨는 게 정상이다. 문제는 next dev 가 이 상황을 다르게 처리한다는 점이다. next dev 는 기술적으로는 목록에 없는 id도 그냥 렌더링해서 보여줄 수 있다(코드 자체는 있으니까). 그런데 일부러 그렇게 하지 않고 런타임 에러를 던지도록 만들어져 있다: // node_modules/next/dist/server/dev/next-dev-server.js:585-594 (요약) if (this.nextConfig.output === 'export') { if (!prerenderedRoutes.some((item) => item.pathname === urlPathname)) { throw new Error(`Page "${page}" is missing param "${pathname}" in "generateStaticParams()"...`); } } 이 체크는 dynamicParams 설정과 무관하게, 오직 output === 'export' 인지만 보고 무조건 실행 된다. 의도는 이렇다 — 만약 dev가 이 상황에서도 친절하게 페이지를 보여주면, 개발자는 "잘 되네?"라고 착각하고 넘어간다. 근데 실제 빌드·배포하면 그 페이지는 파일 자체가 없어서 조용히 깨진다. 그래서 Next는 "나중에 조용히 깨지느니 지금 시끄럽게 알려주자"는 의도로, dev 단계에서 일부러 크게 에러(500)를 던지도록 설계했다. 즉 이 500은 코드 버그도, dev의 결함도 아니라 Next.js의 의도된 안전장치다. 테스트 스펙에 환경별 기대값을 분기하는 코드로 반영: const expectedStatus = process.env.E2E_STATIC === 'true' ? 404 : 500; expect(response?.status()).toBe(expectedStatus); 3-2. dev 전체 스위트에서만 나던 flaky — 원인과 해결 증상 : 이 테스트를 단독으로 돌리면 항상 500(정상). 근데 dev로 전체 스위트 를 같이 돌리면 가끔 404가 떴다(실패로 표시). 원인 : next dev 는 라우트를 요청 시점에 한 번만 컴파일하고 캐시한다. 다른 스펙( product-detail.spec.ts 등)이 같은 [id] 라우트를 유효한 id로 먼저 방문하면, 그 라우트가 컴파일되어버려서 — 그 뒤로는 잘못된 id로 접근해도 안전장치(500)가 안 걸리고 그냥 404가 남. 서버 프로세스의 영구적 상태 변화 라 재시도로 해결 불가능. 왜 "수직(격리) 구조"를 택했는가 — 다른 flaky 이슈(5절)에서 나온 개념을 여기 적용한 것 이 테스트는 CPU 타이밍 문제가 아니라 "다른 테스트가 먼저 그 라우트를 건드렸는가"라는 이진 상태 문제 다. 그래서 retries/workers/타임아웃 조정(5절 이슈에 썼던 방법들) 자체가 원천적으로 안 통한다 — 같은 서버에 재시도해봤자 여전히 같은 서버, 여전히 404. "수직구조로 떼어낸다"는 개념 자체는 원래 5절의 flaky 이슈를 놓고 나온 팀 내 피드백 이었다 — "이 테스트만 타임아웃을 주게 되면 다른건 병렬구조인데 얘만 수직구조가 될 것, 스케줄을 top/bottom 어디에 둘 것인지"라는 우려, 그리고 나중 피드백("진행 중인 것들 finish된 걸 보고, 환경 클리어한 이후 일정 시간 지나서 테스트")은 CPU 경합을 완화하려는 취지 였다(다른 작업이 다 끝나서 CPU 여유가 생긴 뒤에 타이밍 민감한 테스트를 돌리자는 것). 이 개념(전체와 분리해서 따로 돌린다)을 원인이 다른 not-found에도 같은 틀로 가져와 적용 한 것이다. not-found에선 왜 top/bottom 순서 자체가 중요하지 않은지 : 격리 실행이 실제로는 sh -c '...; ...' 로 완전히 별도의 playwright test 프로세스 를 띄우는 방식이라(첫 번째 명령이 끝나면 그 웹서버는 종료되고, 두 번째 명령이 자기 서버를 완전히 새로 켠다) — 이 테스트가 메인 스위트보다 먼저 도나 나중에 도나, 어차피 한 번도 안 건드려진 새 서버를 상대하는 건 똑같다. 그래서 "나중(bottom)"으로 둔 건 지시를 그대로 따른 게 아니라, 그냥 메인 스위트를 먼저 보여주고 이 격리 확인을 뒤에 덧붙이는 게 자연스러워서 고른 순서 일 뿐, 순서 자체가 결과에 영향을 주는 요인은 아니다(둘 다 서버가 항상 새것이라 어느 쪽이든 500이 보장됨, 실제로 검증도 이 순서로만 했다). 5절 이슈 쪽은 다르다 : "환경 클리어 후 나중에" 라는 그 원래 취지대로 테스트해봤지만(완전 격리, workers=1), 5절에서 보듯 여전히 60% 실패 가 나와 이 추론이 그쪽에서는 실제로 검증되지 않았다. 해결 : 이 테스트를 전체 스위트에서 완전히 분리 — e2e 실행 타겟을 2단계로 재구성: "command": "sh -c 'playwright test --grep-invert \"존재하지 않는 상품\"; e1=$?; playwright test --grep \"존재하지 않는 상품\"; e2=$?; exit $((e1>e2?e1:e2))'" 1단계: 이 테스트를 제외한 나머지 실행 2단계: 이 테스트만 단독 실행(항상 컴파일 안 된 새 서버 상대) sh -c 로 감싸 두 단계 모두 항상 실행되고, 둘 중 하나라도 실패하면 전체 종료 코드에 반영됨 pnpm e2e 한 번 으로 자동 실행됨(수동으로 두 번 돌릴 필요 없음). 검증 완료 — 반복 실행 시 이 테스트가 항상 500/passed로 격리 확인됨. 주의 : 이 격리 구조는 pnpm e2e (nx 타겟)를 통해서만 적용된다. pnpm exec playwright test --ui 처럼 nx를 거치지 않고 직접 실행하면 이 분리 효과가 없다 — 같은 효과를 원하면 --grep-invert "존재하지 않는 상품" 을 직접 붙여야 한다. 실제 사용 명령어 정리 상황 명령어 평소 전체 확인(자동, 신뢰 가능) pnpm e2e — 격리 구조 자동 적용됨 --ui 로 전체 다 보고 싶을 때(이 테스트는 가끔 빨갛게 뜰 수 있음, 이유 있는 것이니 무시 가능) pnpm exec playwright test --ui --ui 로 이 테스트만 빼고 나머지 안정적으로 보고 싶을 때 pnpm exec playwright test --ui --grep-invert "존재하지 않는 상품" 이 테스트만 단독 확인 pnpm exec playwright test --ui --grep "존재하지 않는" static 모드로 전체(둘 다 자동으로 안정적) E2E_STATIC=true pnpm exec playwright test --ui --ui 는 대화형 도구라 pnpm e2e 처럼 "끝나면 자동으로 이어서 실행"하는 체이닝이 안 된다 — 필요하면 직접 두 번(위 표의 grep-invert → grep) 나눠서 확인해야 한다. 4. goBackWithMarker 타이밍 버그 — 해결됨 증상 : static 모드로 전체 스위트를 돌릴 때만 웹 진입 시나리오 테스트의 "critical user journey" 항목에서 Execution context was destroyed, most likely because of a navigation 에러. 원인 : goBackWithMarker 헬퍼가 page.goBack() 직후 곧바로 이전 문서를 대상으로 page.evaluate() 를 호출했다. next dev 는 응답이 느려서 이 사이 시간이 우연히 확보됐는데, static-server.mjs 는 응답이 훨씬 빨라 그 여유가 사라지면서 원래 있던 타이밍 결함이 드러났다. 해결 : 이 evaluate() 는 판정 로직과 무관한 순수 시각 표시(Inspector 하이라이트 제거)라, 실패해도 무시하도록 처리: await page.evaluate(() => { ... }).catch(() => {}); 5. navigator.spec.ts CPU 경합 flaky — 미해결 5-1. 무엇이 문제인가 레이아웃 리플로우 감지 관련 테스트는 requestAnimationFrame 기반 리플로우 감지를 MutationObserver 로 관찰한다. 이건 "언젠가 참이 되면 통과"하는 일반 assert와 달리, 그 순간에 실제로 스케줄된 이벤트가 발화하는가 를 재는 방식이라 CPU 경합에 유난히 취약하다 — 브라우저 프로세스가 CPU를 못 받으면 이벤트 자체가 통째로 무산될 수 있다(그냥 "늦게 오는" 게 아니라). 참고로 CPU 경합 자체는 이 테스트만의 문제는 아니다 — 이 테스트를 붙잡고 있다가 알게 된 것뿐이고, 비슷한 실시간 타이밍 방식으로 짜인 테스트라면 다른 곳에서도 같은 현상이 생길 수 있다. 5-2. 시도했지만 안 됐던 것들 시도 결과 retries 0→1 부분 개선 retries 1→2 개선 없음/악화(재시도가 워커 점유 시간을 늘려 경합 창을 넓히는 역효과) workers 6→4 개선 없음 expect.poll 타임아웃 5초→10초 악화(같은 이유) 배경 앱(Cursor, Claude 데스크톱 앱, Slack) 종료 부분 개선(load average 27→11.5) — 완전 해결 아님 완전 격리(workers=1, 이 테스트만 단독) 여전히 5번 중 3번(60%) 완전 실패 — "격리하면 항상 성공"이라는 초반 결론 자체가 표본 부족으로 틀렸음이 나중에 드러남 5-3. 배경 CPU 조사 — 새로 발견한 것 테스트 도는 동안 load average가 유휴 대비 급증(4→27)했고, 이 중 상당 부분이 Playwright/Chromium이 아니라 회사 보안·관리 에이전트 , macOS 자체 서비스(Spotlight mds , system_profiler )였다. 유휴 상태에서는 이 프로세스들이 거의 안 보이다가, 테스트가 도는 동안에만 뚜렷하게 치솟는 패턴을 확인(우연이 아니라는 근거로 유휴-테스트중 비교 실측함). 이건 로컬(회사 보안 소프트웨어) 요인이라 직접 끄거나 조정할 수 없다. 5-4. 유일하게 통했던 것 — static 모드 static 모드( E2E_STATIC=true )로 전체 스위트를 반복 실행하면 이 테스트를 포함해 전부 안정적으로 통과했다(3회 이상 연속 클린 확인). 추정 이유: static은 응답이 거의 즉시라 전체 스위트가 훨씬 빨리 끝나고, dev처럼 "요청마다 컴파일하느라 오래 걸리는" 시간 동안 배경 프로세스들의 경합이 쌓일 기회 자체가 없다. 주의 : not-found를 격리해도 이 테스트의 실패율(60%)엔 변화가 없었다(5회 반복 검증) — 두 문제가 서로 얽혀있던 게 아니라 독립적이다. 5-5. 팀 피드백과의 관계 "workers=1로 잡으면 CPU 경합 자체가 안 생길 것"이라는 예상이 있었는데, 실측(완전 격리 후에도 60% 실패)이 이를 뒷받침하지 못했다 — Playwright 자체 워커 격리는 되지만, Playwright 바깥의 배경 프로세스(에디터, 보안 에이전트 등)까지는 격리 못 하기 때문 으로 추정. 6. 작업 진행 순서 (요약) 정적 빌드 산출물 기반 e2e 환경 추가( E2E_STATIC ) goBack 직후 실행 컨텍스트 파괴로 인한 flaky 수정 not-found 시나리오를 dev/static 양쪽 환경 자동 검증으로 확장 CPU 경합 flaky 완화용 로컬 retries 1회 적용 dev 전체 스위트에서 not-found 500 테스트를 격리 실행하도록 재구성 7. 남은 것 navigator CPU 경합 flaky — 최종 해결 방법 미정, 현재 실용적 대응: static 모드를 주력 검증 경로로 사용, dev 모드에서 이 테스트만 실패하면 재실행으로 확인
#include <stdio.h> // (standard input-output header file) int test(int x) { // 함수 선언 printf("x는 %d입니다.\n", x); // %d (decimal 10진수) return x; } int main(void) { int a = 10; // 변수 선언 -> 메모리 할당 printf("a는 %d입니다.\n", a); // %d (decimal 10진수) , \n -> 줄바꿈 char b = 'A'; // 변수 선언 -> 메모리 할당 printf("b는 %c입니다.", b); // %c (character 문자) char c[4] = "ABCD"; // 문자열 선언 -> 메모리 할당 printf("c는 %s입니다.", c); // %s (string 문자열) int d; puts("Hello World!"); // puts() 함수는 문자열을 출력하고 줄바꿈을 수행한다. scanf("%d", &d); // scanf() 함수는 키보드로부터 입력을 받는다. &d -> d의 주소값, 앰퍼센트(&) printf("입력된 값은 %d입니다.", d); float e; scanf("%f", &e); // %f (float 실수) printf("입력된 실수는 %.2f입니다.", e); //.2 -> 소수점 2자리까지 입력받음 printf("%f",(float)a); // 정수형 a를 실수형으로 형변환하여 출력 a = ++a; // ++a(전위 연산자)는 값을 먼저 1 증가시킨 뒤에 연산에 사용하는 반면 a = a++; // a++(후위 연산자)는 현재 값을 먼저 연산에 사용한 후에 값을 1 증가시킵니다 if(a > 10) { // 조건 printf("a는 10보다 큽니다."); // 실행문 1 } else if(a == 10) { printf("a는 10과 같습니다."); // 실행문 2 } else { printf("a는 10보다 작습니다."); // 실행문 3 } while(a < 20) { // 조건 printf("a는 20보다 작습니다.\n"); // 실행문 a++; // a를 1 증가시킴 } do { // 1회는 무조건 실행 printf("a는 20보다 작습니다.\n"); // 실행문 a++; // a를 1 증가시킴 } while(a < 20); // 조건 switch(a) { // 조건 case 10: // a가 10일 때 printf("a는 10입니다."); // 실행문 break; // switch문 종료 case 20: // a가 20일 때 printf("a는 20입니다."); // 실행문 break; // switch문 종료 default: // 위의 조건에 해당하지 않을 때 printf("a는 10도 아니고 20도 아닙니다."); // 실행문 } for(int i = 0; i < 10; i++) { // 초기값, 조건식, 증감값 printf("i는 %d입니다.\n", i); // 실행문 } return 0; // main() 함수 종료 } // 관계 연산자 // == : 같다, // != : 같지 않다, // > : 크다, // < : 작다, // >= : 크거나 같다, // <= : 작거나 같다 // 논리 연산자 // && : 그리고, // || : 또는, // ! : 아니다 // 변수명 규칙 // 1. 변수명은 영문자(대소문자), 숫자, 언더스코어(_)로 구성되어야 한다. // 2. 변수명은 숫자로 시작할 수 없다. // 3. 이름 사이에는 공백을 사용할 수 없다. // 4. C언어에서 미리 정의된 예약어(키워드)는 변수명으로 사용할 수 없다. // 진법 변환 , 비트연산 // 10진수 -> 2진수 : %b (2로 나눈 나머지를 역순으로 나열) // 2진수 -> 8진수 : %o (3자리씩 끊어서 8진수로 변환, 3자리당 최대 7까지 표현 가능 1,2,4/ 3자리마다 2진수 1인 값 더하기) // 2진수 -> 16진수 : %x (4자리씩 끊어서 16진수로 변환, 4자리당 최대 15까지 표현 가능 1,2,4,8/ 4자리마다 2진수 1인 값 더하기) // 비트 연산자 // & : 비트 AND 연산자, 두 비트가 모두 1이면 1, 아니면 0 // | : 비트 OR 연산자, 두 비트 중 하나라도 1이면 1, 아니면 0 // ^ : 비트 XOR 연산자, 두 비트가 서로 다르면 1, 같으면 0 // ~ : 비트 NOT 연산자, 양수일 경우 +1 후에 부호를 바꾼다. 음수일 경우 -1 후에 부호를 바꾼다. // << : 비트 왼쪽 시프트 연산자, 비트를 왼쪽으로 이동시키고 오른쪽에 0을 채움 // >> : 비트 오른쪽 시프트 연산자, 비트를 오른쪽으로 이동시키고 왼쪽에 0을 채움
Michael Lynch(전 Google, Microsoft 소프트웨어 엔지니어)가 제시한 '효과적인 소프트웨어 설계 문서(Software Design Document) 작성법'의 핵심 내용 분석 및 체계 요약임. 1. 설계 문서 작성 판단 기준 및 리스크 평가 설계 문서는 복잡도와 실패 리스크가 임계치를 넘는 프로젝트에서 개발 리소스 낭비를 방지하기 위한 조정 도구임. 작성 여부 결정 체크리스트 구현 단계에 2인 이상의 협업이 필요한가 개발 기간이 풀타임 기준 3개월 이상 소요되는가 프로덕션 환경에서 수년간 유지보수될 시스템인가 타 팀과의 교차 협업(Cross-team collaboration)이 수반되는가 프로젝트 목표 및 요구사항이 모호한가 설계 단계에서 사전 차단해야 하는 치명적 리스크(보안 취약점, 법적 규제 등)가 존재하는가 의사결정 기준: 1개 이상 해당 시 문서 작성 권장, 2개 이상 해당 시 필수 작성 대상임. 2. 포함 대상 판별 원칙: 실패 비용 (Cost of Getting It Wrong) 설계 문서는 세부 구현 명세서가 아니며, 핵심 판별 기준은 "해당 결정이 틀렸을 때 치러야 할 페널티"임. 포함 대상: 기술 스택 선정, 데이터 스토리지 아키텍처, 서비스 간 통신 프로토콜 등 사후 변경 비용이 극도로 높거나 시스템 전면 재작성을 초래하는 결정. 제외 대상: UI의 세부 배치, 페이지네이션 방식(예: '더보기' 버튼 vs 무한 스크롤) 등 피드백 수집 후 수 시간 내 수정 가능한 가역적 구현 상세. 3. 설계 문서 구성 요소 (Components) 범주 구성 섹션 기술 목적 및 핵심 요구사항 메타데이터 & 기본 정보 Title 3단어 내외의 고유하고 직관적인 프로젝트 식별 명칭 Metadata 작성자(이메일 포함), 작성일자, 표준 URL(사내 shortlink), 승인자(Sign-off) 및 승인일 Objective 프로젝트의 최종 목표를 평이한 언어로 요약한 단일 문장 (문서 1면에 배치) Background 추진 배경, 해결 과제, 이전 시도의 실패 원인 기술 (외부 맥락 없이도 이해 가능하도록 서술) Related docs 테스트 계획서, 기능 명세서(PRD), 선행 시스템 설계 문서 링크 연결 범위 및 시나리오 Goals 구현 완료 시 나타나는 엔드포인트 임팩트 (사용자/팀/비즈니스 중심, 구현 상세 지양) Non-goals 의도적으로 범위에서 제외하는 항목 명시 (이해관계자의 스코프 오판 차단) Scenarios 시스템 완료 시 사용자의 실제 엔드투엔드 상호작용 흐름 예시 시스템 아키텍처 Diagrams 데이터 흐름, 컴포넌트 결합도, 통신 프로토콜 도식화 (수정 용이한 툴/다이어그램 코드 기반 관리) Glossary 신규 입사자 및 타 팀을 위한 사내 고유 도구/용어 정의 (가능한 인라인 설명 권장) Constraints 인프라, 하드웨어 아키텍처(예: RISC-V), 예산 등 변경 불가능한 제약 조건 신뢰성 및 운영 SLOs 가용성(Uptime), 지연 시간(p50/p99 Latency), 처리 용량(Scale)의 정량적 목표 지표 Monitoring / Alerting 시스템 장애 및 급격한 성능 저하 감지 매커니즘, 알림 임계치 기준 Timeline 주요 마일스톤 단위의 인도 일정 및 구현 순서 Interfaces & Dependencies 통신 규격, 언어, 런타임 환경, 영구 저장소, 외부 서드파티 라이브러리 명세 보안 및 미결 과제 Security & Privacy 공격 표면(Attack surface), 신뢰 경계(Trust boundaries), 민감 데이터 보관 주기 및 암호화 방식 Logging 중요 이벤트 기록 기준, 로그 레벨 체계, 보존 주기, 개인정보/민감정보 제외 정책 Open / Resolved Issues 미해결 쟁점(문제, 선택지, 해결을 위한 후속 액션) 및 논의 완료된 결정 내역 추적 Alternatives Considered 검토 후 채택하지 않은 대안 기술/접근법 및 배제 사유 명시 4. 설계 검토(Review) 및 운영 단계 단계적 피드백 수집: 초안 작성 완료 후 핵심 이해관계자 및 파트너 팀을 대상으로 단계적 리뷰 진행. 불확실성 관리: 설계 도중 발생하는 공백은 숨기지 않고 Open issues 섹션에 공식 등재하여 논의를 집중 유도함. 살아있는 문서(Living Document) 지양: 설계 문서는 구현 개시 전 의사결정과 정렬을 위한 도구이며, 코드베이스 완성 후에는 시스템 실제 명세(Wiki/API 문서)로 역할을 이관하는 것이 원칙임.
🏗 예제 도메인 모델 순수 JPA로 리포지토리를 직접 구현해보고, Spring Data JPA의 공통 인터페이스가 어떻게 그 반복을 없애주는지, 내부적으로 어떻게 동작하는지 알아보도록 하자. ⛓️ Member와 Team의 관계 예제에서 사용할 도메인은 단순하다. 회원( Member )은 하나의 팀( Team )에 소속될 수 있고, 팀은 여러 회원을 가질 수 있다. 즉, 다대일(N:1) 관계다. 엔티티 필드 Member id (PK), username, age, team (FK) Team id (PK), name, members 🙋🏻♂️ Member 엔티티 @Entity @Getter @Setter @NoArgsConstructor(access = AccessLevel.PROTECTED) @ToString(of = {"id", "username", "age"}) public class Member { @Id @GeneratedValue @Column(name = "member_id") private Long id; private String username; private int age; @ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "team_id") private Team team; public Member(String username, int age) { ... } public Member(String username, int age, Team team) { ... } public void changeTeam(Team team) { this.team = team; team.getMembers().add(this); } } 👫 Team 엔티티 @Entity @Getter @NoArgsConstructor(access = AccessLevel.PROTECTED) @ToString(of = {"id", "name"}) public class Team { @Id @GeneratedValue @Column(name = "team_id") private Long id; private String name; @OneToMany(mappedBy = "team") List<Member> members = new ArrayList<>(); } ❗️ 엔티티 설계 시 챙겨야 할 것들 @NoArgsConstructor(access = AccessLevel.PROTECTED) : JPA는 내부적으로 리플렉션을 사용해 기본 생성자를 호출한다. 완전히 막을 수는 없지만 PROTECTED 로 설정해두면 외부에서 new Member() 로 직접 생성하는 실수를 막을 수 있다. @ToString 에는 연관관계 필드 넣지 않기: team 을 @ToString 에 포함시키면 Member → Team → Member → ... 무한 루프로 StackOverflowError 가 난다. 반드시 기본 타입 필드만 포함해야 한다. @Setter 는 실무에서 지양: Setter를 열어두면 어디서든 엔티티 상태를 바꿀 수 있어 추적이 어려워진다. 상태 변경이 필요하다면 changeTeam() 처럼 의도가 명확한 메서드를 따로 만드는 게 좋다. 지금은 학습의 편의를 위해 임시로 사용했다. 연관관계 편의 메서드: 양방향 관계에서 한쪽만 설정하면 반대편이 동기화되지 않는다. changeTeam() 에서 this.team = team 과 team.getMembers().add(this) 를 함께 처리해두면 실수를 줄일 수 있다. 모든 연관관계는 지연 로딩( LAZY )으로 설정하는 게 기본이다. EAGER 는 예상치 못한 쿼리가 발생하기 쉽고, 특히 JPQL에서 N+1 문제의 원인이 된다. 💥 순수 JPA 리포지토리의 문제점 Spring Data JPA를 쓰기 전에, 순수 JPA로 리포지토리를 직접 구현해보자. EntityManager 를 사용해 기본 CRUD를 작성하면 아래와 같다. @Repository public class MemberJpaRepository { @PersistenceContext private EntityManager em; public Member save(Member member) { em.persist(member); return member; } public void delete(Member member) { em.remove(member); } public List<Member> findAll() { return em.createQuery("select m from Member m", Member.class) .getResultList(); } public Optional<Member> findById(Long id) { Member member = em.find(Member.class, id); return Optional.ofNullable(member); } public long count() { return em.createQuery("select count(m) from Member m", Long.class) .getSingleResult(); } } 잘 동작한다. 근데 팀 리포지토리도 만들어야 한다. 그럼 TeamJpaRepository 에도 save , delete , findAll , findById , count ... 똑같은 코드가 반복될 것이다. 엔티티가 늘어날수록 이 반복은 계속된다. CRUD 코드가 엔티티마다 그대로 복붙된다 는 게 핵심 문제다. ✨ Spring Data JPA 공통 인터페이스 Spring Data JPA는 이런 반복을 단 한 줄로 해결해준다. // 구현체를 따로 만들 필요가 없음 public interface MemberRepository extends JpaRepository<Member, Long> { } JpaRepository<Member, Long> 을 상속하는 것만으로 위에서 직접 구현했던 save , delete , findAll , findById , count 가 모두 제공된다. 🤔 어떻게 동작하는 걸까? 애플리케이션이 로딩될 때 Spring Data JPA가 JpaRepository 를 상속한 인터페이스들을 스캔한다. 그리고 각 인터페이스에 대한 프록시 구현체를 자동으로 생성 해서 스프링 컨테이너에 빈으로 등록해준다. 1. 애플리케이션 로딩 2. Spring Data JPA가 JpaRepository 상속 인터페이스 스캔 3. 각 인터페이스에 대한 프록시 구현체 자동 생성 4. 스프링 컨테이너에 빈으로 등록 5. @Autowired로 주입받아 사용 실제로 주입된 객체를 출력해보면 직접 만든 클래스가 아니라 Spring Data JPA가 만든 프록시 객체 임을 확인할 수 있다. System.out.println(memberRepository.getClass()); // class com.sun.proxy.$ProxyXXX 😁 @Repository 애노테이션 생략 가능 Spring Data JPA 리포지토리는 @Repository 를 붙이지 않아도 동작한다. 컴포넌트 스캔과 JPA 예외 → Spring 공통 예외 변환 을 Spring Data JPA가 자동으로 처리해주기 때문이다. 자동 스캔 범위는 @SpringBootApplication 이 선언된 패키지와 그 하위 패키지다. 리포지토리가 다른 패키지에 있다면 @EnableJpaRepositories(basePackages = "...") 를 직접 지정해야 한다. 🔍 JPA 영속성 컨텍스트와 테스트 Spring Data JPA를 사용하다 보면 테스트에서 자주 막히는 지점이 있다. JPA의 영속성 컨텍스트 개념을 모르면 이유를 알 수 없는 실패들이다. 👥 1차 캐시와 엔티티 동일성 같은 트랜잭션 안에서 동일한 ID로 조회하면 DB가 아닌 1차 캐시 에서 같은 인스턴스를 반환한다. Member a = memberRepository.findById(1L).get(); Member b = memberRepository.findById(1L).get(); assertThat(a).isEqualTo(b); // true — DB 쿼리는 1번만 실행 JPA는 동일 트랜잭션 내에서 같은 PK로 조회한 엔티티는 항상 같은 인스턴스 임을 보장한다( a == b ). 별도로 equals() 를 구현하지 않아도 이 보장 덕분에 isEqualTo() 테스트가 통과한다. 🫣 @Transactional이 없으면 테스트가 실패하는 이유 @Transactional 이 없으면 save() 와 findById() 가 서로 다른 트랜잭션 으로 실행된다. findById() 호출 시점에 이전 영속성 컨텍스트는 이미 닫혀 있고, DB에서 새 인스턴스 를 만들어 반환한다. Member 에 equals() 가 없으면 Object.equals() (참조 비교)로 fallback되어 테스트가 실패한다. // @Transactional이 없으면 실패 @Test void test() { Member saved = memberRepository.save(new Member("kim", 20)); Member found = memberRepository.findById(saved.getId()).orElseThrow(); assertThat(found).isEqualTo(saved); // 다른 인스턴스 → 실패 } 테스트 클래스에 @Transactional 을 붙이면 각 테스트가 하나의 트랜잭션 안에서 실행되고, 끝나면 자동으로 롤백된다. 🚨 변경 감지 (Dirty Checking) JPA에서 수정은 별도의 update 메서드가 필요 없다. 트랜잭션 안에서 엔티티 필드를 직접 변경하면, 트랜잭션 커밋 시점에 자동으로 UPDATE SQL이 실행된다. @Transactional void update(Long id, String newName) { Member member = memberRepository.findById(id).orElseThrow(); member.changeUsername(newName); // 이것만으로 UPDATE 실행됨 // memberRepository.save(member) 불필요 } 동작 원리는 아래와 같다. 엔티티 조회 시 원본 스냅샷 저장 트랜잭션 커밋 직전 flush() 자동 호출 현재 상태와 스냅샷 비교 달라진 필드가 있으면 UPDATE SQL 실행 🎭 flush() vs clear() 메서드 동작 em.flush() 변경 내용을 DB에 반영 (트랜잭션은 유지) em.clear() 1차 캐시 초기화 → 이후 조회 시 DB에서 다시 로딩 테스트에서 DB 반영 결과를 캐시 없이 검증하고 싶을 때 flush() + clear() 를 함께 쓴다. em.flush(); // DB에 반영 em.clear(); // 1차 캐시 비우기 Member fresh = memberRepository.findById(id).get(); // DB에서 새로 조회 📐 JpaRepository 계층 구조 JpaRepository 가 제공하는 기능들이 어떻게 구성되어 있는지 계층 구조를 보면 이해하기 쉽다. <<스프링 데이터>> Repository ← 마커 인터페이스 (기능 없음) └── CrudRepository ← save, findById, delete, count 등 기본 CRUD └── PagingAndSortingRepository ← findAll(Sort), findAll(Pageable) │ <<스프링 데이터 JPA>> └── JpaRepository ← JPA 특화 기능 추가 (flush, deleteInBatch 등) Repository 부터 PagingAndSortingRepository 까지는 Spring Data Commons 에 속한다. JPA 말고 MongoDB, Redis 등 다른 저장소에서도 똑같은 인터페이스를 쓸 수 있도록 추상화되어 있다. JpaRepository 는 거기에 JPA에 특화된 기능을 얹은 것이다. 🖋️ 제네릭 타입 JpaRepository<T, ID> 타입 파라미터 의미 T 엔티티 타입 ID 식별자(PK) 타입 S 엔티티와 그 자식 타입 (save 등에서 사용) 📖 주요 메서드 메서드 설명 save(S) 새 엔티티면 persist , 이미 있으면 merge findById(ID) Optional<T> 반환. 내부적으로 em.find() 호출 getReferenceById(ID) 프록시 반환. 실제 필드 접근 시에 SELECT 실행. em.getReference() 호출 findAll(...) 전체 조회. Sort 나 Pageable 파라미터 가능 delete(T) 내부적으로 em.remove() 호출 count() 전체 개수 반환 existsById(ID) 존재 여부 확인 🎏 findById vs getReferenceById 연관관계를 설정할 때 굳이 엔티티 전체를 조회할 필요가 없다. ID만 필요한 경우 getReferenceById() 를 쓰면 SELECT 없이 프록시만 받아올 수 있다. // findById: SELECT 즉시 실행 Member member = memberRepository.findById(memberId).orElseThrow(); // getReferenceById: SELECT 없음. 연관관계 설정 등 ID만 필요한 경우에 유용 Member memberRef = memberRepository.getReferenceById(memberId); order.setMember(memberRef); // 여기서도 SELECT 안 함 😂 공통 인터페이스의 한계 JpaRepository 는 모든 엔티티에 공통으로 적용할 수 있는 기능만 제공한다. username 으로 회원을 찾는 것처럼 도메인에 특화된 쿼리는 제공하지 않는다. 이걸 보완하는 게 쿼리 메서드 다. 메서드 이름 규칙만 지키면 Spring Data JPA가 자동으로 쿼리를 만들어준다. // 선언만 해도 동작 List<Member> findByUsernameAndAgeGreaterThan(String username, int age); 📝 정리 단계 핵심 포인트 도메인 모델 연관관계는 LAZY, @ToString에 연관관계 필드 제외, 편의 메서드로 양방향 동기화 순수 JPA EntityManager로 직접 구현 — 엔티티마다 CRUD 코드 반복 Spring Data JPA 인터페이스 선언만으로 프록시 구현체 자동 생성, @Repository 생략 가능 영속성 컨텍스트 1차 캐시로 동일 트랜잭션 내 엔티티 동일성 보장, 변경 감지로 update 불필요 테스트 @Transactional 없으면 엔티티 동일성 테스트 실패 JpaRepository Repository → CrudRepository → PagingAndSortingRepository → JpaRepository 계층 구조
4주차에는 객체 포인터와 객체 배열, 동적 메모리 할당을 학습했다. new 와 delete 를 이용하여 실행 중에 필요한 만큼 메모리를 할당하고 반환하는 방법과 this 포인터, string 클래스의 주요 함수에 대해서도 학습했다. 1. 객체 포인터 객체 포인터는 객체의 주소를 저장하는 포인터 변수 이다. 일반 변수의 주소를 포인터에 저장했던 것처럼 객체의 주소 역시 해당 클래스 타입의 포인터에 저장할 수 있다. 예시 Circle donut; Circle *p; p = &donut; p 에는 donut 객체의 주소가 저장되므로 p 를 이용하여 donut 객체의 멤버에 접근할 수 있다. . 과 -> 일반 객체에서 멤버에 접근할 때는 . 연산자를 사용하지만, 객체 포인터를 통해 멤버에 접근할 때는 -> 연산자 를 사용한다. 예시 Circle donut; Circle *p = &donut; donut.getArea(); p->getArea(); (*p).getArea(); p->getArea() 와 (*p).getArea() 는 같은 의미이다. 객체 → . 객체 포인터 → -> 2. 객체 배열 같은 클래스의 객체를 여러 개 저장해야 할 때 객체 배열 을 사용할 수 있다. 일반적인 배열과 선언 방법은 동일하지만, 배열의 각 원소가 하나의 객체가 된다는 차이가 있다. 예시 int n[3]; // int형 배열 Circle circles[3]; // Circle 객체 배열 circles[0] , circles[1] , circles[2] 가 각각 하나의 Circle 객체가 된다. 객체 배열과 생성자 객체 배열을 생성하면 배열의 각 원소에 대해 생성자가 호출된다. 예시 Circle circles[3]; 위와 같이 객체 배열을 생성하면 세 객체에 대해 기본 생성자가 각각 호출된다. circles[0] → Circle() circles[1] → Circle() circles[2] → Circle() 따라서 객체 배열을 생성하기 위해서는 호출할 수 있는 기본 생성자 가 필요하다. 예시 class Circle { public: Circle(int r) { radius = r; } }; Circle circles[3]; // 오류 Circle(int r) 만 직접 정의하면 컴파일러가 기본 생성자 Circle() 을 자동으로 만들어주지 않는다. 따라서 Circle circles[3] 에서 각 객체를 생성할 기본 생성자가 없어 오류가 발생한다. 배열을 생성하면서 각 객체가 사용할 생성자를 직접 지정할 수도 있다. 예시 Circle circles[3] = { Circle(10), Circle(20), Circle() }; 객체 배열이 소멸될 때는 각 객체의 소멸자가 생성의 반대 순서 로 호출된다. 생성 : circles[0] → circles[1] → circles[2] 소멸 : circles[2] → circles[1] → circles[0] 3. 객체 배열과 포인터 배열의 이름이 첫 번째 원소의 주소를 나타내는 것처럼 객체 배열도 포인터를 이용하여 접근할 수 있다. 예시 Circle circles[3]; Circle *p = circles; cout << p->getArea() << endl; p++; cout << p->getArea() << endl; 처음에는 p 가 circles[0] 을 가리키고 있다. p++ 을 실행하면 다음 객체인 circles[1] 을 가리키게 된다. circles[0] circles[1] circles[2] ↑ p p++ circles[0] circles[1] circles[2] ↑ p 4. 동적 메모리 할당 프로그램을 작성하는 시점에 필요한 메모리의 크기를 미리 정하는 것을 정적 할당 이라고 한다. 반면 필요한 메모리의 크기를 미리 알 수 없는 경우에는 프로그램 실행 중에 필요한 만큼 메모리를 할당할 수 있다. 이를 동적 메모리 할당 이라고 한다. C++에서는 동적 메모리를 할당하고 반환하기 위해 new 와 delete 연산자를 사용한다. new : 힙(heap) 메모리에 필요한 공간을 할당 delete : new 로 할당받은 메모리를 반환 예시 int *p = new int; *p = 5; cout << *p << endl; delete p; new int 를 통해 int 하나를 저장할 공간을 동적으로 할당하고, 그 주소를 p 에 저장한다. 사용이 끝난 뒤에는 delete 를 이용하여 해당 메모리를 반환한다. 5. 배열의 동적 할당 배열 역시 new 를 이용하여 동적으로 생성할 수 있다. 예시 int n; cin >> n; int *arr = new int[n]; 배열의 크기 n 을 사용자에게 입력받기 때문에 프로그램을 작성할 때 배열의 크기를 미리 정하지 않아도 된다. 동적으로 생성한 배열을 반환할 때는 일반 delete 가 아니라 delete[] 를 사용해야 한다. 예시 int *p = new int[5]; // 배열 사용 delete[] p; 하나의 데이터를 동적 할당했다면 delete , 배열을 동적 할당했다면 delete[] 를 사용한다. new ↔ delete new [] ↔ delete [] new 로 할당하지 않은 메모리를 delete 하거나 이미 반환한 메모리를 다시 delete 해서는 안 된다. 잘못된 예시 int n; int *p = &n; delete p; // X p 가 가리키는 공간은 new 를 이용하여 동적으로 할당받은 공간이 아니기 때문에 delete 할 수 없다. 6. 객체의 동적 생성 기본 자료형뿐만 아니라 객체도 동적으로 생성 할 수 있다. 예시 Circle *p = new Circle(30); cout << p->getArea() << endl; delete p; new Circle(30) 을 실행하면 객체를 위한 메모리가 할당되고 Circle(int r) 생성자가 호출된다. 이후 delete p 를 실행하면 객체의 소멸자가 호출된 뒤 메모리가 반환된다. new Circle(30) ↓ 메모리 할당 ↓ 생성자 호출 delete p ↓ 소멸자 호출 ↓ 메모리 반환 일반적인 지역 객체는 범위를 벗어나면 자동으로 소멸하지만, new 를 이용하여 생성한 객체는 사용이 끝난 뒤 직접 delete 를 해주어야 한다. 7. 객체 배열의 동적 생성 객체 배열 역시 동적으로 생성할 수 있다. 예시 int n; cin >> n; Circle *circles = new Circle[n]; 사용자가 입력한 n 의 값만큼 Circle 객체가 생성된다. 각 객체에 대해서는 기본 생성자가 호출된다. 동적으로 생성한 객체 배열은 일반 객체 배열과 동일하게 사용할 수 있다. 예시 circles[0].setRadius(10); circles[1].setRadius(20); cout << circles[0].getArea() << endl; 포인터 방식으로도 접근할 수 있다. circles->setRadius(10); (circles + 1)->setRadius(20); 사용이 끝나면 delete[] 를 이용하여 반환한다. delete[] circles; 이때 배열에 있는 각 객체의 소멸자가 배열의 마지막 객체부터 역순으로 호출 된다. 8. 메모리 누수 동적으로 할당한 메모리를 더 이상 사용할 수 없는데도 반환하지 않은 상태를 메모리 누수(memory leak) 라고 한다. 예시 Circle *p = new Circle(10); // 객체 사용 // delete p;가 없음 new 를 이용하여 메모리를 할당했지만 delete 로 반환하지 않았다. 따라서 동적으로 할당한 메모리는 더 이상 필요하지 않을 때 적절하게 반환해야 한다. 9. this 포인터 this 는 현재 객체 자신을 가리키는 포인터 이다. 개발자가 직접 선언하는 변수가 아니라 클래스의 멤버 함수에서 사용할 수 있도록 컴파일러가 제공한다. 예시 class Circle { private: int radius; public: void setRadius(int radius) { this->radius = radius; } }; 여기서 this->radius → 현재 객체의 멤버 변수 radius → 함수의 매개변수 를 의미한다. this가 필요한 경우 특히 멤버 변수와 매개변수의 이름이 같은 경우 this 를 사용하면 둘을 명확하게 구분할 수 있다. 예시 void setRadius(int radius) { radius = radius; } 이렇게 작성하면 두 radius 모두 매개변수를 의미한다. 따라서 멤버 변수에 값을 저장하려면 다음과 같이 작성해야 한다. void setRadius(int radius) { this->radius = radius; } 각 객체는 자신의 this 를 가지므로 같은 멤버 함수를 호출하더라도 this 가 가리키는 객체는 서로 다르다. 10. string 클래스 C++의 string 클래스는 문자열을 저장하고 처리하기 위한 다양한 기능을 제공한다. 기본적인 문자열 입출력은 앞선 주차에서도 사용했기 때문에 이번에는 문자열 처리에 사용하는 주요 멤버 함수를 중심으로 정리했다. length() length() 는 문자열의 길이를 반환한다. 예시 string str = "Hello"; cout << str.length() << endl; 결과는 5 가 된다. substr() substr() 는 문자열의 일부를 잘라 새로운 문자열로 반환한다. 예시 string str = "Hello, Everyone!"; cout << str.substr(0, 5) << endl; cout << str.substr(7) << endl; substr(x, y) 는 x번째 인덱스부터 y개의 문자 를 반환한다. substr(x) 는 x번째 인덱스부터 문자열의 마지막까지 반환한다. string 역시 객체이기 때문에 포인터를 이용하거나 동적으로 생성할 수 있다. 예시 string *p = new string("C++"); p->append(" Great!!"); cout << *p << endl; delete p; p 가 string 객체를 가리키므로 멤버 함수에 접근할 때 -> 를 사용한다. Lab 4_1 - 동적 Dog 객체 배열 첫 번째 과제에서는 Dog 클래스를 정의하고 사용자가 입력한 강아지 수에 따라 Dog 객체 배열을 동적으로 생성 했다. 이번 과제는 3주차에서 학습한 클래스, 생성자와 소멸자, Getter/Setter에 이번 주에 배운 this , 객체 배열, 동적 메모리 할당을 함께 적용하는 문제였다. Dog 클래스 Dog 클래스에는 다음 네 가지 정보를 private 멤버 변수로 저장했다. class Dog { private: string name; int age; double weight; bool boosterShot; // ... }; 강아지의 예방접종 여부는 입력할 때는 y/n 문자열을 사용하지만, 클래스 내부에서는 bool 타입으로 저장한다. this를 이용한 Setter 이번 과제에서는 모든 함수 정의에서 this 를 사용했다. Setter에서도 매개변수와 멤버 변수의 이름이 같기 때문에 this 를 이용하여 둘을 구분할 수 있다. void Dog::setName(string name) { this->name = name; } void Dog::setAge(int age) { this->age = age; } void Dog::setWeight(double weight) { this->weight = weight; } void Dog::setBoosterShot(bool boosterShot) { this->boosterShot = boosterShot; } Getter에서도 현재 객체의 멤버에 접근할 때 this 를 사용할 수 있다. string Dog::getName() { return this->name; } int Dog::getAge() { return this->age; } 예방접종 여부 처리 예방접종 여부는 사용자에게 문자열 y/n 으로 입력받은 뒤 bool 값으로 변환하여 저장했다. string booster; cin >> booster; if (booster.compare("y") == 0) this->boosterShot = true; else this->boosterShot = false; 문자열 비교에는 compare() 를 사용했다. str1.compare(str2) == 0 이면 두 문자열이 같다는 의미이다. 출력할 때는 bool 값을 그대로 출력하는 대신 접종 여부에 따라 O 또는 X 를 출력하도록 했다. if (this->boosterShot) cout << "O" << endl; else cout << "X" << endl; Dog 객체 배열 동적 생성 이번 과제의 핵심은 사용자가 입력한 강아지 수만큼 객체를 생성하는 부분이었다. int n; cout << "How many dogs?"; cin >> n; Dog *dogs = new Dog[n]; 강아지 수가 프로그램을 실행하기 전에는 정해져 있지 않기 때문에 고정된 크기의 객체 배열 대신 new 를 이용하여 필요한 만큼 동적으로 생성했다. 각 객체의 정보는 반복문을 이용하여 입력받을 수 있다. for (int i = 0; i < n; i++) { dogs[i].inforInput(i + 1); } 출력 역시 반복문을 이용하여 각 객체의 writeOutput() 을 호출한다. for (int i = 0; i < n; i++) { dogs[i].writeOutput(); } 프로그램의 전체적인 흐름은 다음과 같다. 강아지 수 n 입력 ↓ new Dog[n] ↓ Dog 객체 n개 생성 ↓ 반복문으로 정보 입력 ↓ 반복문으로 정보 출력 ↓ 조건에 맞는 Dog 탐색 ↓ delete[] dogs 조건에 맞는 객체 찾기 마지막에는 두 살보다 많고 예방접종을 하지 않은 강아지 의 이름을 출력했다. for (int i = 0; i < n; i++) { if (dogs[i].getAge() > 2 && dogs[i].getBoosterShot() == false) { cout << dogs[i].getName() << endl; } } 객체의 멤버 변수가 private 이기 때문에 main() 에서는 직접 접근하지 않고 Getter를 이용하여 조건을 확인했다. 동적으로 생성한 객체 배열을 모두 사용한 뒤에는 메모리를 반환한다. delete[] dogs; 이 과정에서 배열에 포함된 각 Dog 객체의 소멸자가 호출된다. Lab 4_2 - string과 객체 포인터 두 번째 과제에서는 문자열을 입력받아 length() 와 substr() 을 이용하여 문자열을 처리했다. 첫 번째 문자열은 일반적인 string 객체를 이용하고, 두 번째 문자열은 string 객체의 포인터 를 이용하여 같은 작업을 수행했다. 문자열에 공백이 포함될 수 있기 때문에 입력에는 getline() 을 사용했다. string str; getline(cin, str); substr()을 이용한 문자열 처리 substr() 을 이용하면 문자열을 두 부분으로 나누어 다시 연결할 수 있다. str.substr(i) + str.substr(0, i) 예를 들어 다음 문자열이 있다고 하자. Hello i = 2 라면, str.substr(2) // "llo" str.substr(0, 2) // "He" 가 되므로 두 문자열을 연결하면 lloHe 가 된다. 이를 반복문과 함께 사용하면 시작 위치를 한 칸씩 이동시키며 문자열을 출력할 수 있다. for (int i = 0; i < str.length(); i++) { cout << str.substr(i) + str.substr(0, i) << endl; } string 객체 포인터 두 번째 문자열에서는 일반 객체가 아니라 string 객체의 포인터를 이용했다. string *p = new string; getline(cin, *p); p 는 string 객체를 가리키고 있기 때문에 length() 와 substr() 에 접근할 때 -> 를 사용한다. for (int i = 0; i < p->length(); i++) { cout << p->substr(i) + p->substr(0, i) << endl; } 사용이 끝난 뒤에는 동적으로 생성한 string 객체를 반환한다. delete p; 첫 번째 문자열과 두 번째 문자열은 같은 작업을 수행하지만 접근 방법에는 다음과 같은 차이가 있다. string 객체 str.length() str.substr() ↓ string 객체 포인터 p->length() p->substr() 이를 통해 객체 자체를 사용할 때와 객체 포인터를 사용할 때 멤버에 접근하는 방법의 차이를 다시 확인할 수 있었다. 마무리 4주차에는 3주차에서 배운 클래스와 객체를 확장하여 객체 포인터와 객체 배열을 다루는 방법을 학습했다. 일반 객체에서는 . 을 사용하지만 객체 포인터에서는 -> 를 사용하여 멤버에 접근 한다는 점과, 객체 배열을 생성하면 배열의 각 객체마다 생성자와 소멸자가 호출된다는 점을 확인했다. 또한 new 와 delete 를 이용 하면 프로그램 실행 중 필요한 만큼 메모리를 동적으로 할당하고 반환 할 수 있다는 것을 배웠다. 특히 하나의 객체에는 delete , 배열에는 delete[] 를 사용 해야 하며 동적으로 할당한 메모리를 적절히 반환하지 않으면 메모리 누수가 발생할 수 있다는 점이 중요했다. 첫 번째 과제에서는 사용자 입력에 따라 Dog 객체 배열을 동적으로 생성하면서 this , Getter/Setter, 객체 배열, new 와 delete[] 를 함께 적용했다. 두 번째 과제에서는 ** string 의 length() 와 substr() 을 활용 하고 같은 문자열 처리 작업을 일반 객체와 객체 포인터 방식으로 각각 구현했다. 이번 주차를 통해 하나의 객체를 정의하고 사용하는 것에서 나아가, 여러 객체를 효율적으로 관리하고 실행 중 필요한 만큼 객체를 동적으로 생성하는 방법 까지 확장하여 이해할 수 있었다.
GitHub 링크 Notion 링크 오늘 보고서부터 기존에 ‘TODO’ 항목으로 정리하던 향후 개선 및 검토 사항은 노션 보고서에 기록하고, 본 게시물에는 실제 개발 내용과 개발 과정에서 새롭게 알게 된 내용만 작성할 예정이다. 적 캐릭터의 공격 스킬 추가 위처럼 Special Attack 타입의 PerformAttack 노드를 만들어주고, 위와 같이 Selector를 하나 더 두고 Decorator를 Selector로 올려준다. 이제 Special Attack의 발동 조건을 설정해주자. 반경과 발동 확률을 기준으로 Decorator를 설정해줄 예정이다. 위와 같이 Decorator들을 만들어준다. 첫번째는 확률을 체크하는 Decorator이고, 또 다른 하나는 반경을 확인하는 Decorator이다. BTDecorator_Chance.h #pragma once #include "CoreMinimal.h" #include "BehaviorTree/BTDecorator.h" #include "BTDecorator_Chance.generated.h" /** * */ UCLASS() class SOULLIKEGAME_API UBTDecorator_Chance : public UBTDecorator { GENERATED_BODY() protected: UPROPERTY(EditAnywhere, BlueprintReadWrite) int32 ChanceRate = 30; protected: virtual bool CalculateRawConditionValue(UBehaviorTreeComponent& OwnerComp, uint8* NodeMemory) const override; }; BTDecorator_Chance.cpp #include "AI/Decorator/BTDecorator_Chance.h" bool UBTDecorator_Chance::CalculateRawConditionValue(UBehaviorTreeComponent& OwnerComp, uint8* NodeMemory) const { return ChanceRate > FMath::RandRange(1, 100); } BTDecorator_InRangeCheck.h #pragma once #include "CoreMinimal.h" #include "BehaviorTree/BTDecorator.h" #include "BTDecorator_InRangeCheck.generated.h" /** * */ UCLASS() class SOULLIKEGAME_API UBTDecorator_InRangeCheck : public UBTDecorator { GENERATED_BODY() protected: UPROPERTY(EditAnywhere, BlueprintReadWrite) FBlackboardKeySelector TargetBlackboardKey; UPROPERTY(EditAnywhere, BlueprintReadWrite) float RangeMin = 100.f; UPROPERTY(EditAnywhere, BlueprintReadWrite) float RangeMax = 200.f; protected: virtual bool CalculateRawConditionValue(UBehaviorTreeComponent& OwnerComp, uint8* NodeMemory) const override; }; BTDecorator_InRangeCheck.cpp #include "AI/Decorator/BTDecorator_InRangeCheck.h" #include "AIController.h" #include "BehaviorTree/BlackboardComponent.h" #include "Kismet/KismetMathLibrary.h" bool UBTDecorator_InRangeCheck::CalculateRawConditionValue(UBehaviorTreeComponent& OwnerComp, uint8* NodeMemory) const { const APawn* ControlledPawn = OwnerComp.GetAIOwner()->GetPawn(); if (!ControlledPawn) { return false; } const AActor* TargetActor = Cast<AActor>(OwnerComp.GetBlackboardComponent()->GetValueAsObject(TargetBlackboardKey.SelectedKeyName)); if (!TargetActor) { return false; } const float Distance = ControlledPawn->GetDistanceTo(TargetActor); return UKismetMathLibrary::InRange_FloatFloat(Distance, RangeMin, RangeMax); } 위와 같이 Decorator를 설정해준다. CalculateRawConditionValue() 함수를 오버라이딩해서 구현하면 된다. 실행해보면 스킬 자체는 사용하고 있는데, 방향을 제대로 못잡고 있는 것을 확인할 수 있다. 그전에 적의 무기 DA를 따로 만들어주자. 적 캐릭터의 행동 패턴 구현 Contents
예를들어, memberRepository.findById() 가 실행될 때, Spring 은 매번 DB 와 새로운 네트워크 연결을 만드는걸까 ? -> 보통은 그렇지않다, JPA 를 사용하는 경우 HikariCP 가 미리 관리하고 있는 DB Connection 을 빌려서 사용하고, 다시 반환한다. 보통 코드를 구현할때 아래와 같이 코드를 작성하고 끝낸다. @Transactional public void changeNickname(Long memberId, String nickname) { Member member = memberRepository.findById(memberId) .orElseThrow(); member.changeNickname(nickname); } 위에있는 코드가 서비스 코드에 있다고하면 위 코드를 실행한다면, 대략 아래와 같은 과정이 일어나게 된다. Service ↓ JPA / EntityManager ↓ Hibernate ↓ JDBC ↓ DataSource ↓ HikariCP ↓ Connection 대여 ↓ MySQL 오늘은 HikariCP ↕ JDBC Connection ↕ MySQL 위 과정을 중점적으러 보자. 일반적으로 DB Connection 을 새로 만드는것은 일반 JAVA 객체를 만드는것에 비해, 비용이 훨씬 많이든다. 새로운 DB Connection 을 생성하기 위해서는, Application ↓ 네트워크 연결 ↓ MySQL 접속 ↓ 인증 ↓ 세션 생성 ↓ Connection 사용 가능 위와같은 과정들이 필요하다. 이를, 매번 DB 요청마다 DB Connection 을 생성하고, 다 사용하면 없애는 식으로 하게되면 비용적으로 엄청난 손해이다. 이를 해결하기위해 ( 매번 새로운 DB Connection 을 생성 ) HikariCP의 Connection Pool 을 사용한다. HikariCP DB Connection Pool ┌───────────────┐ │ Connection 1 │ │ Connection 2 │ │ Connection 3 │ │ Connection 4 │ │ ... │ └───────────────┘ Spring 요청 → 빌림 → 사용 → 반환 HikariCP DB Connection Pool는, 이미 만들어놓은 DB Connection 을 보관하고 있다. 그래서, 요청이 들어오면 이미 만들어놓은 DB Connection 을 넘겨준다. 이를 사용하고나서, 작업들이 종료되게 되면 빌려준 DB Connection 을 받고 다시 Connection Pool 에 넣어놓는다. 흔히, DB 를 사용한다고 하면 설정정보 파일에 아래와 같은 정보들을 넣는다. spring: datasource: url: jdbc:... DB URL username: DB username password: DB password Spring Boot 가 해당 설정들을 읽는다. Spring Boot ↓ DataSource ↓ HikariDataSource ↓ HikariPool 이후, HikariCP는, DB Connection 들을 관리한다. 해당 요청이 들어왔다고 하자. @Transactional public void changeNickname(Long memberId, String nickname) { Member member = memberRepository.findById(memberId) .orElseThrow(); member.changeNickname(nickname); } DB작업이 필요해진다. memberRepository.findById(memberId); ( 영속성 컨텍스트에 해당 객체가 없다고 가정 ) Repository ↓ EntityManager ↓ Hibernate ↓ JDBC Hibernate 가 SQL 을 실행하려면, JDBC Connection 이 필요하다. 예를들어, SQL 이 다음과 같이 실행된다면 .. SELECT * FROM member WHWERE member_id = ? JDBC Driver 가, DB 에 이 SQL 을 보내야한다. ( DB -> MySQL ... ) 그럼, Connection 을 어디서 가져오는걸까? 여기서, HikariCP 가 등장한다. Connection 을 가지고 오기위해, Hibernate 가 DataSource 에게 Connection 을 요청을 한다. Connection connection = dataSource.getConnection(); dataSource 가, HikariDataSource 라면, dataSource.getConnection() ↓ HikariCP ↓ Pool에서 사용 가능한 Connection 검색 이 된다. 예를들어 혅재 Pool 이 Connection Pool C1 → 사용 중 C2 → 사용 가능 C3 → 사용 중 C4 → 사용 가능 이런식으로 있다면, 사용가능한 C2 / C4 중 1개를 빌려주게 된다. C2 를 빌려준경우 아래와 같은 과정이 된다. Application ↓ C2 ↓ DB ( MySQL ... ) 즉, 애플리케이션은 C2 ( DB Connection ) 을 통해, DB 에 SQL 을 보낼 수 있게된다. 여기에서 중요한것은, DB Connection 을 새로 만들어서 반환해주는게아니라, 미리 만들어놓은것중 사용가능한 DB Connection 을 반환한다는것이다. 이제, Hibernate 가 DB 에 SQL 을 실행한다. SELECT * FROM member WHWERE member_id = ? SQL 을 실행하고서, 결과가 있다면 결과를 반환해줄것이다. MySQL ↓ JDBC ResultSet (데이터베이스 쿼리 실행 결과를 저장하는 JAVA 인터페이스) ↓ Hibernate ↓ Member Entity ↓ Persistence Context 이후에는, 이전에 배웠던 JPA 내용과 연결된다. member.changeNickname(nickname); 현재 Member Entity 는 영속 상태이다. ( findById -> Member 를 찾아왔다고 하자. ) 따라서 아래와 같은 상황이 된다. Persistence Context Member nickname: old → Hoon 스냅샷을 통해서, 이전값과 현재값이 다른것을 확인했으므로 트랜잭션 종료 시점에 ( commit 전, flush ) Dirty Checking ↓ flush ↓ UPDATE SQL 생성 그리고, UPDATE SQL 도, 같은 트랜잭션안에서 사용하는 Connection 을 통해 실행이된다. ( 위 예제에서는 C2 를 사용했기에, 업데이트문도 C2 를 통해서 ... ) @Transactional 종료 ↓ Hibernate flush ↓ UPDATE ↓ Connection ↓ COMMIT Spring 의 TransactionManager 가 트랜잭션을 Commit 을 한다. JDBC 수준에서는, 현재 Connection 과 연결된 DB 트랜잭션이 commit 된다. Connection.close() 일반적으로, 서버에서 자원을 끌어다가 사용한 경우 반드시 해당 자원을 닫아줘야만 한다. Connection Pool 을 사용하지 않는 일반적인 Connection 이였다면, 반드시 connection.close() 를 호출해줘야 한다. 그래야, 실제 DB 연결 종료를 의미한다. 하지만, HikariCP 에서 빌린 Connection 의 close() 는 연결을 종료하는 의미가 아니다. HikariCP 에서 빌린 Connection close() -> 커넥션을 Connection Pool 에 반납. connection.close() ↓ HikariCP ↓ Connection Pool에 반환 즉, close -> 현재 예시에서는 C2 ( Connection 2 ) 를 Connection Pool 에 반납해서, 해당 Connection ( Connection 2 ) 는 사용가능하다라는 상태가 된다. C2 사용 중 ↓ close() ↓ 사용 가능 이후에, 또 다른 요청이 들어오면 C2 는 사용가능하기때문에 해당 요청에 Connection 을 넘겨줄 수 있다. 이전에 배웠던 내용들과 합쳐서, 전체적인 흐름은 다음과 같을것이다. HTTP Request ↓ Tomcat Thread ↓ Filter ... ( Spring Security ) ↓ DispatcherServlet ↓ Controller ↓ Service Proxy ↓ @Transactional ↓ JpaTransactionManager ↓ EntityManager ↓ Hibernate ↓ JDBC ↓ HikariDataSource ↓ HikariCP ↓ Connection 대여 ↓ MySQL ↓ SELECT / UPDATE ↓ COMMIT ↓ Connection 반환 Connection Pool 을 사용하면서 문제가 발생할 수 있다. Connection Pool 은 Connection 을 어느정도 미리 만들어놓고 사용하도록 반환해주기때문에, 사용중인 Connection 보다 새로운 요청이 더 많게되면, 새로운 요청은 Connection 을 얻지 못할수도있다. 예를들어, HikariCP Connection Pool 에 Connection 이 10개 있다고 하자. HikariCP C1 C2 C3 ... C10 동시에 DB 작업을 수행하는 요청들이 Connection 을 모두 잡았다고 하자. C1 사용 중 C2 사용 중 ... C10 사용 중 즉, 현재 Connection Pool 에 있는 Connection 들을 사용할 수 없다. 이러한 상황에서 11번째 요청이 들어왔다고 하자. 그렇다면, 해당 요청은 요청을 하고서 바로 Connection 을 얻을 수 없다. Request 11 ↓ getConnection() ↓ 사용 가능한 Connection 없음 ↓ 대기 . . . 이렇게 대기를 하게되는데 ... 일정시간동안 대기하고도 Connection 을 얻지못한다면 timeout 이 발생할 수 있다. 해당 문제는 주로, 다음과 같은 상황에서 발생한다. ( 트랜잭션 안에서 외부 API 를 호출하는 ) @Transactional public void process() { Member member = memberRepository.findById(1L) .orElseThrow(); externalApi.call(); ( 해당 작업이 5초가 걸린다고 가정. ) member.update(); } (1L 의 Member 가 현재, 영속성 컨텍스트에 없는 상황이라고 가정.) 위에 코드에서 Connection 을 확복한 상태라고하자. 그렇다면, 외부 API 를 호출하는 코드에서 5초동안 기다려야 하므로, DB Connection 을 마찬가지로 5초동안 가지고있는다. Connection 획득 ↓ SELECT ↓ 외부 API 호출 ████████ 5초 ████████ ↓ UPDATE ↓ COMMIT ↓ Connection 반환 만약, Connection Pool 에 Connection 이 10개만 존재하고, Connection 10개가 모두 해당 과정을 거친다고 하자. 그렇다면, 해당 작업들이 진행되고 있는동안에는 사용할 수 있는 Connection 이 존재하지 않기때문에, 이후 요청들은 모두 대기하는 상태가 된다. 그래서, 트랜잭션은 필요한 DB 작업 중심으로 짧게 유지한다. 라는것이 중요하다. 그렇다면, Connection Pool 안에, Connection 이 부족하지 않은 상황을 만들면 되는거아닌가? 라고 생각할 수 있다. 즉, Connection Pool 안에 Connection 들을 충분히 많이 만들어놓는것이다. spring: datasource: hikari: maximum-pool-size: 500 500개의 Connection 을 만들었다고하자. Connection 은 DB 입장에서도, HikariCP 입장에서도, 자원이다. 결국, 해당 Connection 들을 모두 관리해줘야 하기에 관리비용도 많이 들어갈것이다. 또한, DB 가 감당해야할 연결 수가 많아질것이고, 동시 작업도 커지게된다. 그렇기에, 적당한 Pool 크기를 잡아야한다. ( 이는 운영환경마다 다 다르니까 ... )