기초강의가 끝나고 오늘부터 디스크포렌식2급실기공부를 하게되었다. 오늘 디지털 포렌식 강의에서 파티션이 정상적으로 열리지 않는 상황을 살펴봤다. 그 과정에서 FTK Imager에 [Recovered] Partition 이 표시되는 화면을 봤고, 강사님은 Autopsy나 EnCase로 확인해 보라는 취지로 설명하셨다. 그때 궁금해졌다. "같은 디스크 이미지인데, 다른 도구로 확인하면 무엇을 더 알 수 있을까?" 이 질문을 따라 AI와 대화하면서 MFT와 실제 파일 데이터, 그리고 파일 카빙이라는 개념을 공부했다. 처음에는 도구의 차이를 알고 싶었지만, 정작 가장 크게 배운 것은 파일을 관리하는 정보와 파일의 실제 내용은 서로 다르다는 사실이었다. Recovered Partition과 복구된 파일은 구분해야 한다 우선 강의 화면에 표시된 것은 개별 파일이 아니라 Recovered Partition이었다. 따라서 이 표시만 보고 "삭제된 파일을 복구했다"거나 "MFT가 덮어써져 카빙한 결과다"라고 판단해서는 안 된다. 이번에 공부한 MFT와 카빙의 원리도 해당 화면의 원인을 확정하는 설명이 아니라, 추가 분석이 왜 의미가 있는지 이해하는 배경 지식으로 정리했다. MFT는 파일의 주소록에 가깝다 NTFS의 $MFT는 파일과 폴더를 관리하는 핵심 구조다. MFT에는 파일 이름, 크기, 시간 정보, 속성, 데이터 위치를 찾는 데 필요한 정보 등이 기록된다. 이를 쉽게 비유하면 다음과 같다. MFT: 집의 이름과 위치가 적힌 주소록 실제 데이터 영역: 그 주소를 따라 찾아가는 집과 내부 내용물 예를 들어 용량이 큰 a.pptx 파일이라면, PPTX의 내용 전체가 MFT 레코드 하나에 들어가는 것은 아니다. 실제 내용은 별도의 클러스터에 저장되고, MFT에는 그 위치를 찾아갈 수 있는 정보가 들어간다. 정확히는 단순한 '섹터 번호 하나'보다는 파일 데이터가 저장된 클러스터 구간을 나타내는 정보로 이해하는 편이 좋다. 파일이 여러 곳에 나뉘어 저장될 수도 있기 때문이다. 다만 작은 파일은 내용 자체가 MFT 레코드 안에 저장되는 상주 데이터Resident Data 형태일 수 있다. 주소록과 내용물이 언제나 분리되어 있는 것은 아니다. MFT에 파일 내용 전체가 없어도 정상이다 처음에는 이런 의문이 들었다. "왜 MFT에는 디스크의 데이터가 전부 담겨 있지 않을까?" 하지만 이는 MFT의 역할을 잘못 이해한 질문이었다. MFT는 모든 파일 내용을 한곳에 모아 두는 보관함이 아니다. 파일을 관리하고, 필요한 데이터를 찾아갈 수 있게 해 주는 구조다. 일반적인 NTFS 구성에서 MFT 레코드 하나는 1KB지만, 실제 파일은 몇 MB 또는 몇 GB일 수 있다. 관리 기록의 크기와 파일 내용의 크기가 다른 것은 자연스러운 설계다. 비상주 데이터를 가진 파일에 접근할 때는 파일 시스템이 MFT의 위치 정보를 활용해 해당 데이터 클러스터를 찾는다. 다만 실제 읽기 과정에는 캐시 등도 관여하므로, 매번 반드시 디스크의 MFT부터 직접 읽는다고 이해할 필요는 없다. 주소록에서 사라졌다고 내용물까지 동시에 없어지는 것은 아니다 두 정보의 차이를 이해하는 데는 삭제된 파일의 예시가 도움이 됐다. 파일을 삭제하면 그 파일의 MFT 레코드와 사용하던 데이터 클러스터는 재사용 가능한 상태가 된다. 삭제 직후에도 기존 기록이나 내용이 남아 있을 수 있다. 이후 새 파일이 생성되면서 기존 MFT 레코드가 재사용되면, a.pptx를 찾아가던 정보가 사라질 수 있다. 그런데 실제 데이터가 있던 클러스터는 아직 재사용되지 않았을 수도 있다. 그렇다면 다음과 같은 상태가 가능하다. 주소록에서 기존 파일의 정보를 찾기 어려워졌지만, 실제 내용의 일부 또는 전부는 남아 있는 상태. 이는 파일 시스템이 잘못된 주소록을 계속 유지한다는 의미보다는, 현재의 유효한 파일 목록에는 없는 과거 데이터의 흔적이 남는 상황에 가깝다. 주소록을 이용한 분석과 내용을 직접 찾는 분석 이제 분석 방식의 차이도 이해할 수 있었다. 파일 시스템 정보를 이용하는 분석 MFT 등 파일 시스템 구조를 해석해 파일과 폴더를 찾는 방식이다. 필요한 정보가 남아 있다면 파일 이름, 경로, 크기, 데이터 위치 등을 연결해서 볼 수 있다. 삭제된 파일도 메타데이터가 남아 있다면 확인하거나 복구할 가능성이 있다. 하지만 해당 기록이 재사용되거나 심하게 손상됐다면, 그 기록만으로 원래 파일을 찾아가는 것은 어려워질 수 있다. 파일 카빙 파일 카빙은 파일 시스템 기록 대신 데이터 자체의 특징을 단서로 파일을 찾는 방식이다. 분석 대상 영역에서 파일의 시그니처나 내부 구조를 살펴보고, 파일로 추정되는 데이터를 추출한다. 주소록을 읽어 집을 찾아가는 대신, 현장을 직접 살펴보며 남아 있는 내용물을 찾는 것에 비유할 수 있다. 따라서 MFT에서 원래 파일 정보를 찾지 못하더라도, 실제 데이터가 남아 있다면 카빙으로 발견할 가능성이 있다. FTK Imager와 Autopsy의 차이도 이렇게 이해했다 처음에는 다음과 같이 단순하게 정리했다. "FTK Imager는 주소록만 보고, Autopsy는 디스크를 직접 훑는다." 하지만 이 표현은 정확하지 않다. FTK Imager는 이미징과 증거 미리보기 등에 사용하는 도구이며, 파일 목록 외에 원시 데이터나 비할당 영역도 확인할 수 있다. MFT만 보고 나머지 데이터를 무시하는 도구는 아니다. Autopsy 역시 파일 시스템 정보를 분석한다. 여기에 설정과 모듈에 따라 카빙 같은 추가 분석을 수행할 수 있다. 모든 경우에 자동으로 전체 영역을 카빙한다고 생각해서는 안 된다. 따라서 차이는 다음처럼 이해하는 것이 더 적절하다. 파일 시스템 기반 목록에서 확인되지 않는 데이터도, 별도로 카빙을 수행하면 발견될 수 있다. 같은 이미지라도 어떤 범위를 대상으로 어떤 분석 기능을 실행했는지에 따라 결과가 달라질 수 있다. 강의에서 다른 분석 도구로 확인하라는 설명을 들은 뒤, 나는 그 중요성을 조금 더 이해하게 됐다. 6 마무리 오늘의 출발점은 FTK Imager에 표시된 Recovered Partition과 강사님의 설명이었다. 그 화면만으로 정확한 원인을 확정할 수는 없지만, 궁금증을 따라가면서 중요한 구분을 배웠다. 1.파티션을 찾는 정보, 파일을 관리하는 정보, 실제 파일 내용은 같은 것이 아니다. 2.파일이 목록에 보이지 않는다고 해서 내용까지 반드시 사라진 것은 아니다. 3. 반대로 흔적을 발견했다고 해서 원본이 온전히 복구된 것도 아니다.
3. LLM은 문맥을 어떻게 계산할까? — Attention & Transformer 지금까지 우리는 LLM의 전체 흐름에서 두 단계를 지나왔다. Language → Number → Context → Generation ─────────────── 여기까지 1편에서는 Vector , Matrix , Tensor , Dot Product , Softmax 를 통해 LLM이 숫자를 어떻게 계산하는지 살펴봤다. 2편에서는: Text ↓ Tokenization ↓ Token ID ↓ Embedding ↓ Token Vector 를 따라가며 Language가 Number로 변환되는 과정 을 살펴봤다. 이제 문장은 숫자가 되었다. 예를 들어: 나는 → [0.21, -0.13, 0.72, ...] 오늘 → [0.44, 0.31, 0.08, ...] 학교 → [0.17, -0.52, 0.63, ...] 갔다 → [0.71, 0.19, 0.22, ...] 그런데 여기서 문제가 하나 남아 있다. Vector가 되었다고 해서 문맥을 이해한 것은 아니다. 다음 두 문장을 보자. I deposited money at the bank. I sat on the bank of the river. 두 문장의 bank 는 같은 표현이지만 의미는 다르다. bank 라는 Token 하나만 보는 것으로는 어떤 의미인지 결정하기 어렵다. 주변에 money , deposited 가 있는지, 아니면 river , sat 이 있는지를 함께 봐야 한다. 즉 LLM에는 다음 과정이 필요하다. Token Vector ↓ 다른 Token과의 관계 계산 ↓ Context 반영 ↓ Contextual Representation 이번 글은 README의 전체 흐름에서 바로 이 구간을 다룬다. Language → Number → Context → Generation └───────┘ 이번 글 그리고 이번에도 같은 방식으로 기술을 분해한다. WHY 왜 Token 사이의 관계를 계산해야 할까? ↓ HOW Attention은 그 관계를 어떻게 계산할까? ↓ CODE Q · KT → Scale → Softmax → V를 직접 계산해보자. ↓ LLM 이 연산은 어떻게 Transformer와 GPT가 될까? 3.0 WHY — Vector만으로는 왜 문맥을 알 수 없을까? 2편에서 Token ID를 Embedding Matrix에 넣어 Token Vector를 얻었다. Token ↓ Token ID ↓ Embedding Matrix ↓ Token Embedding 하지만 Token Embedding은 Transformer가 문맥 계산을 시작하기 위한 초기 표현 이다. 문장을 하나 보자. 나는 오늘 학교에서 친구를 만났다. "만났다" 를 이해하려면 주변 Token과의 관계가 필요하다. 나는 │ 오늘 ─────┐ │ │ 학교에서 ─┤ │ │ 친구를 ───┼──→ 만났다 누가 만났는가? → 나는 누구를 만났는가? → 친구를 어디에서 만났는가? → 학교에서 언제 만났는가? → 오늘 즉 문맥은 Token 하나 안에 독립적으로 존재하는 것이 아니라 Token 사이의 관계 에서 만들어진다. 따라서 다음 문제가 생긴다. 하나의 Token이 다른 Token 중 어떤 정보를 얼마나 참고해야 할까? Attention은 이 질문을 해결하기 위한 핵심 메커니즘이다. 3.1 WHY — Attention 이전에는 Sequence를 어떻게 처리했을까? Attention의 필요성을 이해하려면 먼저 기존 Sequence Model을 간단히 살펴볼 필요가 있다. Transformer 이전에는 자연어처럼 순서가 있는 데이터를 처리하기 위해 RNN(Recurrent Neural Network) 계열 모델이 널리 사용되었다. RNN은 Sequence를 순서대로 처리한다. 나는 → 오늘 → 학교에 → 갔다 각 시점에서는 현재 입력뿐 아니라 이전 시점의 정보를 담은 Hidden State 를 전달받는다. x1 → h1 │ ▼ x2 → h2 │ ▼ x3 → h3 │ ▼ x4 → h4 개념적으로: 현재 입력 + 이전까지의 정보 ↓ 새로운 Hidden State 를 반복하는 구조다. 이 방식은 Sequence 정보를 다루는 데 유용했지만 구조적인 제약도 있었다. Sequential Processing 앞의 계산 결과가 다음 계산에 필요하다. h1 → h2 → h3 → h4 따라서 Sequence의 여러 위치를 완전히 독립적으로 한 번에 계산하기 어렵다. Long-range Dependency Sequence가 길어질수록 멀리 떨어진 정보 사이의 관계를 효과적으로 유지하고 학습하는 것이 어려워질 수 있다. 철수가 어제 도서관에서 빌린 책을 친구에게 보여준 뒤 집으로 돌아와서 저녁에 그것을 읽었다. ↑ ↑ 멀리 떨어진 관계 LSTM과 GRU 같은 구조는 이러한 문제를 완화하기 위해 등장했다. 하지만 여기서 관점을 바꿔볼 수 있다. 정보를 계속 순서대로 전달하지 않고, 현재 Token이 필요한 Token을 직접 참고하게 할 수는 없을까? 이 질문에서 Attention의 핵심 아이디어를 이해할 수 있다. 3.2 HOW — Attention: 어떤 Token을 얼마나 볼 것인가? 다음 문장을 보자. 나는 어제 도서관에서 빌린 책을 오늘 반납했다. "반납했다" 의 표현을 만들 때 모든 Token의 정보가 동일하게 중요할 필요는 없다. 개념적으로: 나는 ── 낮은 관련도 어제 ── 관련 도서관에서 ── 관련 빌린 ── 높은 관련도 책을 ── 높은 관련도 오늘 ── 관련 반납했다 ← 현재 위치 Attention은 각 Token과 다른 Token 사이의 관련도를 계산하고 , 그 결과를 이용해 필요한 정보를 서로 다른 비중으로 결합한다. 전체 아이디어는 다음과 같다. Token A와 Token B의 관계 ↓ Score ↓ Weight ↓ 중요한 정보에 더 큰 비중 ↓ 새로운 Token Representation 즉 Attention을 한 문장으로 압축하면: 현재 Token의 표현을 만들 때 다른 Token의 정보를 얼마나 참고할지 계산하는 메커니즘 이라고 볼 수 있다. 그렇다면 모델은 Token 사이의 관련도를 어떻게 계산할까? 여기서 Query , Key , Value 가 등장한다. 3.3 HOW — Query, Key, Value는 왜 필요할까? 입력 Token Representation을 (X)라고 하자. Attention에서는 하나의 입력 표현으로부터 세 종류의 Vector를 만든다. [ Q = XW_Q ] [ K = XW_K ] [ V = XW_V ] 여기서: (W_Q) = Query Projection Weight (W_K) = Key Projection Weight (W_V) = Value Projection Weight 이며 모두 학습 가능한 Weight Matrix다. 구조로 보면: X ┌─────┼─────┐ ↓ ↓ ↓ WQ WK WV ↓ ↓ ↓ Q K V 왜 같은 Token에서 굳이 세 Vector를 만들까? 역할이 다르기 때문이다. Query 현재 Token이 어떤 정보를 찾고 있는지 를 나타내는 역할로 이해할 수 있다. Query "나는 어떤 정보와 관련되어 있는가?" Key 각 Token이 Query와 얼마나 관련되는지 비교하기 위한 표현 이다. Query ↔ Key ↓ Relation Score Value Attention Weight가 결정된 후 실제로 가져와 결합할 정보 다. Attention Weight × Value ↓ 실제 전달되는 정보 직관적으로 정리하면: Q : 무엇을 찾을 것인가? K : 나와 얼마나 관련 있는가? V : 선택되면 어떤 정보를 전달할 것인가? 다만 이 표현은 이해를 위한 직관이다. 실제로 Q, K, V가 사람이 직접 정의한 질문·키워드·내용인 것은 아니다. 모두 입력 Vector에 학습된 Matrix를 곱해 만들어지는 Vector Representation 이다. 3.4 HOW — Dot Product가 Attention Score가 된다 이제 1편에서 배운 Dot Product 가 다시 등장한다. 두 Vector (a), (b)의 Dot Product는: [ a \cdot b ] 였다. 여러 차원의 두 Vector 관계를 하나의 Scalar 값으로 만들 수 있었다. Attention에서는 이 연산을 Query와 Key 사이에 사용한다. [ QK^T ] 예를 들어 "반납했다" 의 Query가 다른 Token의 Key와 비교된다고 생각해보자. Query("반납했다") │ ├── Key("나는") → 0.8 │ ├── Key("도서관") → 1.4 │ ├── Key("책") → 4.7 │ └── Key("오늘") → 2.1 이 값들이 Attention Score의 기반이 된다. 즉 1편에서 배웠던: Vector A · Vector B ↓ Scalar ↓ Vector 관계 표현 이 Transformer에서는: Query · Key ↓ Attention Score 로 사용되는 것이다. 그래서 Dot Product를 먼저 배웠던 이유가 여기서 드러난다. 3.5 HOW — 왜 √dk로 나눌까? Attention에서는 Dot Product 결과를 바로 Softmax에 넣지 않는다. Query와 Key의 차원을 (d_k)라고 할 때: [ \frac{QK^T}{\sqrt{d_k}} ] 처럼 Scaling을 수행한다. 왜 필요할까? Vector의 차원이 커지면 Dot Product 값의 크기도 커질 수 있다. 지나치게 큰 값들이 Softmax에 들어가면 출력 분포가 매우 뾰족해질 수 있고, 학습 과정에서 Gradient가 지나치게 작아지는 구간이 생길 수 있다. 따라서 Dot Product 값을 적절한 범위로 조정한다. Q · KT ↓ 큰 Score가 만들어질 수 있음 ↓ ÷ √dk ↓ Scaled Attention Score 그래서 이 연산을 Scaled Dot-Product Attention 이라고 부른다. 3.6 HOW — Softmax: Score를 Weight로 바꾼다 Scaling까지 했지만 아직 값은 단순한 Score다. 예를 들어: 나는 0.8 도서관 1.4 책 4.7 오늘 2.1 이 Score를 상대적인 Attention Weight 로 바꾸기 위해 Softmax를 사용한다. Attention Scores ↓ Softmax ↓ Attention Weights 개념적으로: 나는 0.02 도서관 0.04 책 0.82 오늘 0.12 ──── 1.00 처럼 합이 1인 분포가 만들어진다. 따라서: Q · KT ↓ Scaling ↓ Softmax ↓ Attention Weight 가 된다. 여기서도 1편에서 배운 Softmax가 그대로 돌아왔다. 1편에서는: Scores ↓ Softmax ↓ Probability-like Distribution 을 배웠고, Attention에서는: Attention Scores ↓ Softmax ↓ Attention Weights 로 사용된다. 3.7 HOW — Value: 실제로 어떤 정보를 가져올까? 이제 어떤 Token을 얼마나 참고할지 결정했다. 다음 단계는 그 Token에서 실제 정보를 가져오는 것 이다. 여기서 Value가 사용된다. Attention Weight × Value 예를 들어: 0.02 × V("나는") 0.04 × V("도서관") 0.82 × V("책") 0.12 × V("오늘") 를 계산한 뒤 모두 합한다. 수식으로는: [ Output = \sum_i \alpha_i V_i ] 여기서 (\alpha_i)는 각 Token의 Attention Weight다. 따라서 Q/K/V의 역할을 다시 정리하면: Query + Key ↓ 얼마나 참고할 것인가? Value ↓ 무슨 정보를 가져올 것인가? 이제 Attention의 전체 구조를 조립할 수 있다. 3.8 HOW — Scaled Dot-Product Attention을 조립해보자 처음부터 Attention 공식을 외우는 대신 지금까지 만든 연산을 하나씩 합쳐보자. Step 1. Query와 Key의 관계를 계산한다 [ QK^T ] Query × Key ↓ Attention Score Step 2. Score를 Scaling한다 [ \frac{QK^T}{\sqrt{d_k}} ] Attention Score ↓ Scale ↓ Scaled Score Step 3. Softmax로 Weight를 만든다 [ softmax\left(\frac{QK^T}{\sqrt{d_k}}\right) ] Scaled Score ↓ Softmax ↓ Attention Weight Step 4. Value를 결합한다 [ softmax\left(\frac{QK^T}{\sqrt{d_k}}\right)V ] 결국: [ \boxed{ Attention(Q,K,V) = softmax\left( \frac{QK^T}{\sqrt{d_k}} \right)V } ] 가 된다. 하지만 중요한 것은 공식을 암기하는 것이 아니다. 이 식은 결국: 관계를 계산하고 ↓ Score 크기를 조정하고 ↓ 상대적 Weight를 만들고 ↓ 필요한 정보를 결합한다 라는 네 단계의 연산을 하나로 표현한 것이다. 3.9 HOW — Self-Attention은 무엇이 다를까? Attention 중에서도 Transformer의 핵심은 Self-Attention 이다. Self-Attention에서는 같은 Sequence의 Token Representation들로부터 Q, K, V를 만든다. X ┌─────┼─────┐ ↓ ↓ ↓ WQ WK WV ↓ ↓ ↓ Q K V \ │ / Attention ↓ Updated Tokens 예를 들어: 나는 / 오늘 / 학교에서 / 친구를 / 만났다 라는 Sequence가 있다면 각 Token은 다른 Token을 참고할 수 있다. 나는 ───────────────┐ 오늘 ───────────┐ │ 학교에서 ────┐ │ │ 친구를 ──────┼──┼───┤ ↓ 만났다 그리고 이 계산은 "만났다" 하나에만 이루어지는 것이 아니다. Sequence의 모든 Token 위치 에서 수행된다. 3.10 HOW — Attention Matrix: 모든 관계를 한 번에 계산한다 Token이 네 개 있다고 해보자. 나는 / 오늘 / 학교 / 갔다 각 Token이 다른 모든 Token과 관계를 계산하면 개념적으로 다음과 같은 Matrix를 만들 수 있다. Key 나는 오늘 학교 갔다 Query 나는 0.1 0.2 0.2 0.5 오늘 0.1 0.5 0.1 0.3 학교 0.2 0.1 0.5 0.2 갔다 0.2 0.2 0.5 0.1 행은 Query 위치, 열은 Key 위치를 나타낸다고 생각할 수 있다. Token 수가 (n)이면 Token 간 관계는 기본적으로: [ n \times n ] 형태의 Attention Matrix로 표현된다. 여기서 1편의 Matrix 연산 이 실제 Transformer 계산과 연결된다. 그리고 동시에 한 가지 문제도 보이기 시작한다. Sequence Length 증가 ↓ Attention Matrix 증가 ↓ 계산량과 Memory 사용 증가 기본적인 Self-Attention의 Score Matrix 계산은 Sequence Length에 대해 (O(n^2)) 규모로 커진다. 긴 Context를 효율적으로 처리하는 것이 현대 LLM에서 중요한 연구 주제인 이유 중 하나다. 3.11 HOW — GPT는 미래 Token을 보면 안 된다: Causal Mask 여기서 일반적인 Self-Attention과 GPT의 Attention 사이에 중요한 차이가 생긴다. GPT는 다음 Token을 예측하는 Autoregressive Language Model 이다. 예를 들어: 나는 오늘 학교에 ___ 다음 Token을 예측해야 하는데 학습 중 정답이 되는 미래 Token을 미리 참고하면 문제가 된다. 따라서 미래 위치에 대한 Attention을 막는다. 나는 오늘 학교 갔다 나는 ✓ X X X 오늘 ✓ ✓ X X 학교 ✓ ✓ ✓ X 갔다 ✓ ✓ ✓ ✓ 이를 Causal Mask 라고 한다. 따라서 각 위치에서는: Token 1 → Token 1 Token 2 → Token 1, 2 Token 3 → Token 1, 2, 3 Token 4 → Token 1, 2, 3, 4 까지만 볼 수 있다. 이 구조 덕분에 GPT는: 이전 Tokens ↓ 현재 Context Representation ↓ 다음 Token 예측 이라는 Autoregressive Generation 구조를 유지할 수 있다. Attention이 이제 Next Token Prediction 과 직접 연결되기 시작한다. 3.12 HOW — Multi-Head Attention: 하나의 관계만 보면 충분할까? 지금까지는 하나의 Attention 연산만 생각했다. 하지만 언어에는 다양한 관계가 존재한다. 예를 들어: 나는 오늘 학교에서 친구를 만났다. "만났다" 는 여러 Token과 서로 다른 방식으로 관련될 수 있다. 직관적으로 생각하면: 만났다 ↔ 친구 대상과의 관계 만났다 ↔ 오늘 시간과의 관계 만났다 ↔ 학교 장소와의 관계 하나의 Attention Head만 사용하는 대신 여러 개의 Head를 사용해 서로 다른 Projection 공간에서 관계를 계산 할 수 있다. Input │ ┌───────────┼───────────┐ ↓ ↓ ↓ Head 1 Head 2 Head 3 ↓ ↓ ↓ Attention Attention Attention └───────────┼───────────┘ ↓ Concat ↓ Linear Projection 이를 Multi-Head Attention 이라고 한다. 각 Head는 서로 다른 (W_Q), (W_K), (W_V) Projection을 사용한다. 따라서 같은 입력을 보더라도 서로 다른 표현 공간에서 Token 관계를 계산할 수 있다. 다만 여기서 주의해야 한다. Head 1은 문법, Head 2는 시간, Head 3은 장소를 담당한다는 식으로 고정된 역할이 사전에 지정되는 것은 아니다. 이런 예시는 Multi-Head의 직관을 설명하기 위한 것이다. 실제로 각 Head가 학습하는 패턴은 학습 과정에서 결정된다. 3.13 HOW — Attention만으로 Transformer가 완성될까? 여기까지 보면: Attention = Transformer 처럼 생각하기 쉽다. 하지만 Transformer Block에는 Attention 외에도 중요한 구성 요소들이 있다. 개념적으로 단순화하면: Input │ ▼ Multi-Head Self-Attention │ ▼ Residual Connection │ ▼ Normalization │ ▼ Feed-Forward Network │ ▼ Residual Connection │ ▼ Normalization │ ▼ Output 실제 모델에 따라 LayerNorm의 위치 등 세부 구조는 달라질 수 있지만, 핵심 구성 요소를 이해하기에는 이 흐름이 중요하다. 3.14 HOW — Residual Connection: 기존 정보를 버리지 않는다 Transformer처럼 Layer가 깊어지면 각 Layer에서 계속 새로운 변환이 이루어진다. 이때 입력 정보를 바로 버리는 대신 변환 결과에 원래 입력을 더해주는 구조를 사용할 수 있다. x ──────────────────┐ │ │ ▼ │ Attention │ │ │ ▼ │ Output ─────────────┤ ▼ + 개념적으로: [ y = x + F(x) ] 이다. 이를 Residual Connection 또는 Skip Connection 이라고 한다. Residual Connection은 깊은 Network의 학습을 돕고 원래 Representation이 다음 Layer로 전달될 수 있는 경로를 제공한다. 3.15 HOW — Layer Normalization: Representation을 안정적으로 다룬다 Transformer에서는 Layer Normalization(LayerNorm) 도 중요한 역할을 한다. 각 Layer를 거치며 Representation의 값이 계속 변화한다. LayerNorm은 Feature Dimension을 기준으로 값을 정규화하고 학습 가능한 scale과 bias를 적용해 학습을 안정화하는 데 도움을 준다. 개념적으로: Representation ↓ LayerNorm ↓ 정규화된 Representation Transformer 구현을 보면 LayerNorm 이 자주 등장하는 이유다.
도커(Docker)를 사용하면서 가장 신기하면서도 헷갈리는 순간 중 하나는 바로 "컨테이너끼리, 혹은 컨테이너는 외부와 어떻게 통신할까?" 하는 의문입니다. 우리는 평소에 아무렇지 않게 docker run -p 80:80 nginx 같은 명령어를 치고 웹서버에 접속하지만, 호스트의 네트워크 카드와 독립된 컨테이너 내부 네트워크 사이에서는 리눅스의 강력한 가상화 기술들이 움직이고 있습니다. 이번 글에서는 도커 네트워크의 기본이자 핵심인 Bridge 네트워크 모드 와 그 밑단에서 작동하는 veth pair , Linux Bridge 의 원리를 파헤쳐 보겠습니다. 1. 도커 네트워크의 기본 : Bridge 모드란? 도커를 설치하고 아무런 옵션없이 컨테이너를 띄우면, 도커 데몬은 기본적으로 bridge 라는 이름의 네트워크 드라이버에 컨테이너를 연결합니다. 호스트 터미널에서 ip a 명령어를 입력하면 신기한 인터페이스가 하나 눈에 띕니다. 바로 docker0 입니다. docker0 (Linux Bridge) : 호스트 OS 내부에 만들어지는 가상 스위치(Switch)입니다. 보통 172.17.0.1 같은 사설 IP를 할당받습니다. 컨테이너의 IP : 새로 띄운 컨테이너는 이 docker0 대역 안에서 순차적으로 IP( 172.17.0.2 , 172.17.0.3 등)를 부여받습니다. 즉, 같은 브릿지 네트워크에 속한 컨테이너끼리는 별도의 포트 포워딩 없이도 이 내부 사설 IP를 통해 서로 통신할 수 있습니다. 2. 가상 네트워크의 랜선 : veth pair와 Namespace 그렇다면 컨테이너 내부의 eth0 와 호스트의 docker0 스위치는 도대체 어떻게 연결되는 걸까요? 여기서 등장하는 개념이 바로 veth pair(Virtual Ethernet Pair)입니다. 리눅스 네임스페이스(Namespace) 기술로 인해 컨테이너는 자신만의 완전히 독립된 네트워크 세상(네트워크 인터페이스, 라우팅 테이블 등)을 가집니다. 외부와 단절된 이 컨테이너를 호스트와 연결하려면 가상의 랜선 이 필요합니다. 양끝이 연결된 랜선 : veth pair는 항상 두 개가 한 쌍(A와 B)으로 생성되는 가상 네트워크 장치입니다. 한쪽에서 패킷을 보내면 다른 한쪽으로 그대로 전달됩니다. 랜선의 양 끝단 꽂기 : veth pair의 한쪽 조각은 컨테이너 내부로 쏙 집어넣어 이름을 eth0 로 바꿉니다. 다른 한쪽 조각은 호스트 OS에 남겨두고 docker0 브릿지 스위치에 포트처럼 꽂아버립니다. 이 구조 덕분에 컨테이너는 마치 실제 물리 스위치에 랜선을 꽂은 것처럼 호스트의 docker0 와 통신할 수 있게 됩니다. 3. 외부와 통신하는 법 : iptables와 NAT (Port Forwarding) 컨테이너 내부가 172.17.0.x 같은 내부 사설 IP를 쓴다면, 내 노트북이나 외부 사용자는 어떻게 컨테이너에 접근할 수 있을까요? 여기서 그 유명한 -p 옵션(포트 포워딩)이 활약합니다. $ docker run -d -p 8080:80 nginx 이 명령어를 치면 도커는 리눅스 커널의 iptables 를 조작하여 NAT(Network Address Translation) 규칙을 생성합니다. 외부 사용자가 호스트의 8080 포트로 접속을 시도합니다. 호스트의 iptables 규칙이 이 패킷을 가로채어 목적지를 컨테이너의 내부 IP와 포트( 172.17.0.2:80 )로 바꿔치기( MASQUERADE / DNAT )합니다. 반대로 컨테이너가 응답할 때는 다시 호스트의 IP와 포트로 변환되어 사용자에게 전달됩니다. 이 정교한 패킷 후킹(Hooking) 덕분에 컨테이너는 호스트의 방패 뒤에 안전하게 숨어 있으면서도 외부와 자유롭게 통신할 수 있습니다. 4. 그 외 주요 도커 네트워크 모드들 Bridge 외에도 도커는 상황에 따라 다양한 네트워크 모드를 제공합니다. | 네트워크 모드 | 특징 및 동작 방식 | 주요 용도 | 네트워크 모드 특징 및 동작 방식 주요 용도 Bridge (기본) docker0 브릿지와 veth pair를 거치는 기본 모드 (NAT 사용) 일반적인 단일 컨테이너 격리 환경 Host 호스트의 네트워크 네임스페이스를 그대로 공유 (격리 없음) 최대 성능이 필요하거나 포트 충돌이 없는 시스템 데몬 None 네트워크 인터페이스를 아예 제공하지 않음 (오직 lo 만 존재) 보안이 극도로 중요하거나 직접 커스텀 네트워크를 짤 때 Overlay 여러 대의 도커 호스트(Swarm 등)에 있는 컨테이너들을 하나로 묶음 멀티 호스트 분산 클러스터 환경 🎯 마무리 도커 네트워크는 결국 리눅스 브릿지(Linux Bridge)와 veth pair , 그리고 iptables(NAT)라는 리눅스 커널의 기초 네트워크 기술들을 도커라는 도구가 쓰기 편하게 포장해 놓은 것입니다. 이 구조를 완벽하게 이해하고 나면, 나중에 쿠버네티스의 CNI(Container Network Interface)나 오픈스택의 Neutron(가상 네트워크)을 공부할 때도 "아, 결국 밑단에서는 이 가상 랜선들과 브릿지들이 확장된거구나!"하고 훨씬 쉽게 이해할 수 있습니다.
Buy Verified Facebook Profiles: 2026 EditionBuy Facebook Accounts In a digital age dominated by social media, the need for a strong online presence has never been more crucial. However, maintaining multiple Facebook accounts for various purposes can be a daunting task. Fear not, as this article delves into the realm of buying Facebook accounts – offering insight into why this practice may be beneficial and how to navigate through the process effectively. Explore with us as we uncover the hidden advantages of acquiring multiple Facebook accounts, provide guidance on selecting the right accounts for your needs, and share innovative ways to maximize their potential. Embrace the possibilities that come with owning various Facebook profiles, and embark on a journey towards enhancing your online reach and influence. If you want to more information just contact now. 24 Hours Reply/Contact ✅E-mail: smmseoit24h@gmail.com ✅Telegram: @smmseoit ✅Skype: SMM SEO IT ✅WhatsApp: +1(226) 785-3444 ✅Website: https://smmseoit.com/product/buy-facebook-accounts/ Why Buy Facebook Accounts? Buying Facebook accounts can offer a plethora of advantages, from expanding your social media presence to enhancing your business reach. By purchasing established accounts, you gain instant credibility and access to a wider audience. Whether you're an individual looking to boost your online influence or a business aiming to increase brand visibility, buying Facebook accounts can jumpstart your digital success. Furthermore, acquiring Facebook accounts allows you to tap into different demographics and target markets effortlessly. With diverse accounts at your disposal, you can tailor content for specific audiences, resulting in higher engagement and increased interaction. In today's competitive digital landscape, having multiple Facebook accounts opens up endless possibilities for networking, marketing opportunities, and overall growth. The Benefits of Owning Multiple Facebook Accounts Having multiple Facebook accounts can provide a range of benefits, both for personal and professional purposes. One key advantage is the ability to separate different aspects of your life, such as keeping your personal and professional network distinct. This allows for better organization and control over the content you share with each group, ensuring a tailored experience for your audience. Furthermore, owning multiple Facebook accounts opens up opportunities for targeted marketing and networking. By creating specialized profiles for specific interests or businesses, you can engage with different demographics more effectively. This strategic approach can lead to increased visibility, engagement, and ultimately, success in achieving your goals on the platform. Where to Buy Facebook Accounts There are numerous online platforms and marketplaces where you can buy Facebook accounts. Websites like Fiverr, Seoclerks, and various forums dedicated to digital marketing often have sellers offering authentic and verified Facebook accounts for sale. It is essential to do thorough research on the seller's reputation, customer reviews, and guarantees before making a purchase. Additionally, some specialized agencies and companies provide customized solutions for buying Facebook accounts in bulk or tailored to specific needs. These services often come with added security measures and ongoing support to ensure the smooth operation of your purchased accounts. How to Choose the Right Facebook Account for You When selecting a Facebook account to purchase, consider your specific needs and goals. Are you looking for an account with a large following to boost your online presence, or do you prefer a more niche account tailored to your interests? Take into account factors such as audience demographics, engagement levels, and content relevance. Remember that the right account should align with your long-term objectives and resonate with your personal brand. Furthermore, assess the reputation of the account seller. Look for reliable sources that offer genuine accounts with authentic engagement. Verify the credibility of the accounts by checking past performance metrics and user feedback. By choosing a reputable seller and a well-suited Facebook account, you can maximize the benefits of owning multiple accounts and enhance your social media strategy effectively. Tips for Maintaining and Using Multiple Facebook Accounts Managing multiple Facebook accounts can be a breeze with the right strategies in place. To keep your accounts organized, consider using separate browsers or browser profiles for each account. This way, you can easily switch between accounts without the hassle of logging in and out constantly. Additionally, setting unique profile pictures and cover photos for each account can help you differentiate between them at a glance. When posting content across your various accounts, be mindful of maintaining consistent branding and messaging to reflect your personal or business identity effectively. Utilize Facebook's scheduling feature to plan out posts in advance for each account, ensuring a steady stream of content without overwhelming yourself. Interact authentically with followers on all your accounts to cultivate genuine connections and foster engagement. Creative Ways to Utilize Multiple Facebook Accounts One creative way to utilize multiple Facebook accounts is by creating specialized personas for different interests or aspects of your life. For example, you can have a professional account for networking and career-related posts, a personal account for connecting with friends and family, and a hobby-specific account for sharing your passion projects. This allows you to tailor your content and interactions to specific audiences without mixing them all on one profile. Another innovative way to use multiple Facebook accounts is by leveraging them for targeted marketing purposes. By segmenting your audience across different accounts based on demographics or interests, you can craft tailored advertising campaigns that are more likely to resonate with each group. This strategy not only improves the effectiveness of your marketing efforts but also demonstrates a deep understanding of your audience's preferences, leading to increased engagement and conversions. Success Stories of Buying Facebook Accounts One success story of buying Facebook accounts involves a small business owner who purchased additional accounts to diversify their target audience. By creating specific profiles for different customer segments, they were able to tailor their marketing strategies effectively and significantly increase engagement and sales. Another inspiring success story is that of a social media influencer who bought established Facebook accounts to expand their reach and influence. Through these additional accounts, they were able to reach new followers, collaborate with more brands, and elevate their online presence to new heights. Overcoming Common Misconceptions About Buying Facebook Accounts One common misconception about buying Facebook accounts is that it is unethical or against Facebook's terms of service. However, when done correctly and within the guidelines set by Facebook, purchasing accounts can be a legitimate business strategy. By ensuring that the purchased accounts are active and authentic, users can expand their online presence without violating any rules. Another misconception is that buying Facebook accounts leads to instant success and popularity. While acquiring additional accounts can provide a boost in visibility, true success on social media requires consistent effort and engaging content. It's essential for users to view purchased accounts as tools to enhance their marketing strategies rather than shortcuts to fame. Conclusion As we come to the end of this exploration into the world of buying Facebook accounts, it is clear that there are numerous benefits and opportunities that come with owning multiple accounts. Embracing the idea of purchasing Facebook accounts can open up new doors for personal and professional growth, allowing individuals to expand their reach and connections in a digital landscape. By understanding how to effectively manage and utilize these accounts, one can truly harness the power of social media to enhance their online presence. Remember, each Facebook account purchased is not just a number on a screen but an opportunity waiting to be explored. Whether it's for marketing purposes or simply for personal networking, buying Facebook accounts can be a strategic move towards achieving your goals in the realm of social media. So, dare to think outside the box and consider the possibilities that multiple Facebook accounts can bring into your virtual world.
Buy Old Gmail Accounts Looking for reliable Gmail account setup and management solutions? UsaITsolution delivers secure, seamless support for business and personal use. Trusted by clients for our professional service, we ensure every setup meets high standards to help you work efficiently and scale with confidence. Our Service Features: ✔ 100% Satisfaction Guarantee ✔ All accounts are phone number verified ✔ Male & Female Profile Options ✔ 100% Valid Gmail Accounts Provided ✔ Global Reviews ✔ Budget-friendly rates ✔ Money-Back Guarantee ✔ Instant Delivery ✔ 24/7 Customer Support ✅✅✅✅✅✅✅✅✅✅✅✅✅✅✅✅✅✅✅✅✅✅✅ ** Contact us WhatsApp:+16062900774 Telegram:@usaitsolution Email: infousaitsolution@gmail.com Website: https://usaitsolution.com/product/buy-old-gmail-accounts/ ** ✅✅✅✅✅✅✅✅✅✅✅✅✅✅✅✅✅✅✅✅✅✅✅ Significance of Getting Old Gmail Accounts Purchase Old Gmail Accounts Old Gmail accounts have become very trendy and the primary choice of people and businesses who are looking to exist in digital world. There are several benefits in having these accounts than creating a new one, and can be helpful in different online purposes. Older Gmail accounts are more trusted by Google. As the accounts age, they also gain credibility Google’s algorithms are less likely to catch them as suspicious. This trust is especially important when, among other purposes, you want to use the account for email marketing, account recovery or business correspondence. Another advantage is the increased security. Older accounts are more trusted, and usually have been hardened by Google over time. They will also be less vulnerable to hacking or malicious activity compared with newly created accounts. To sum up, purchasing old Gmail accounts is certainly a great tactic for businesses and personal users to enhance their online visibility, increase email delivery rate and avoid new account’s risks. But, it’s crucial to purchase from reputable sources so that the accounts are valid and safe to use.
이전에는 JPA가 Entity를 어떻게 관리하는지 살펴봤다. EntityManager ↓ 영속성 컨텍스트 ↓ 1차 캐시 ↓ 쓰기 지연 ↓ 변경 감지 그리고 일반 JPA에서는 다음과 같이 EntityManager 를 직접 사용했다. em.persist(memo); Memo memo = em.find(Memo.class, id); em.remove(memo); 하지만 Spring Boot에서 매번 EntityManager 를 직접 다루면서 CRUD를 작성한다면 코드가 다시 많아질 수 있다. Spring에서는 JPA를 더 편리하게 사용할 수 있도록 Spring Data JPA 를 제공한다. 이번에는 Spring Data JPA ↓ Repository ↓ JpaRepository ↓ SimpleJpaRepository 의 관계와, 왜 Repository 인터페이스만 선언해도 CRUD를 사용할 수 있는지 정리해 보려고 한다. 1. Spring Data JPA란? Spring Data JPA는 JPA를 더 쉽게 사용할 수 있도록 만들어진 Spring의 모듈 이다. 강의에서는 Spring Data JPA가 JPA를 추상화한 Repository 인터페이스를 제공하며, 개발자는 이를 통해 JPA 기능을 간편하게 사용할 수 있다고 설명한다. :chatgpt-content-reference{index="0"} 지금까지 관계를 조금 더 확장하면 다음과 같다. Application ↓ Spring Data JPA ↓ JPA ↓ Hibernate ↓ JDBC ↓ DB 즉, Spring Data JPA가 JPA를 대체하는 것은 아니다. Spring Data JPA = JPA를 편하게 사용하도록 도와주는 계층 이라고 이해하면 된다. 2. JPA와 Spring Data JPA의 차이 이전까지 배운 JPA에서는 직접 EntityManager 를 사용했다. 예를 들어 저장하려면: em.persist(memo); 조회하려면: Memo memo = em.find(Memo.class, id); 삭제하려면: em.remove(memo); 즉, Service ↓ EntityManager ↓ JPA ↓ DB 형태였다. Spring Data JPA를 사용하면: Service ↓ Repository ↓ Spring Data JPA ↓ JPA ↓ DB 형태가 된다. 개발자는 EntityManager 를 직접 다루는 대신 Repository 인터페이스를 통해 JPA 기능을 사용한다. 3. JpaRepository Spring Data JPA에서 가장 자주 사용하는 인터페이스가 JpaRepository 다. 메모장 프로젝트에서는 다음과 같이 작성한다. public interface MemoRepository extends JpaRepository<Memo, Long> { } 강의에서도 JpaRepository 를 상속받는 인터페이스를 만들고, 제네릭에 Entity 클래스와 @Id 타입 을 지정하도록 설명한다. :chatgpt-content-reference{index="1"} 즉, JpaRepository<Memo, Long> 에서 Memo → 관리할 Entity 타입 Long → Memo의 @Id 타입 을 의미한다. 4. 여기서 <Memo, Long> 은 무엇일까? 이 부분은 Java Generics 다. 제네릭은 클래스나 인터페이스를 만들 때 사용할 타입을 미리 고정하지 않고, 실제 사용하는 시점에 타입을 지정하는 기능이다. 예를 들어: List<String> 에서는 List → 여러 타입에 재사용할 수 있는 구조 String → 현재 List에서 사용할 타입 이다. JpaRepository 도 같은 방식이다. JpaRepository<T, ID> 형태로 정의되어 있고, 실제 사용할 때: JpaRepository<Memo, Long> 처럼 타입을 결정한다. 즉, T → Entity 타입 ID → Entity의 식별자 타입 으로 이해하면 된다. 메모장에서는 @Entity public class Memo { @Id private Long id; } 이므로 JpaRepository<Memo, Long> 이 된다. 강의에서도 Memo 를 제네릭의 Entity 위치에 지정하면서 해당 Repository가 memo 테이블의 CRUD를 담당하게 된다고 설명한다. :chatgpt-content-reference{index="2"} 5. 그런데 인터페이스만 만들었는데 어떻게 동작할까? 다음 코드를 보면 조금 이상하다. public interface MemoRepository extends JpaRepository<Memo, Long> { } 우리는 인터페이스만 선언했다. 직접 구현 클래스를 만들지 않았다. public class MemoRepositoryImpl implements MemoRepository { ... } 같은 코드는 작성하지 않는다. 그런데 Service에서는 바로 사용할 수 있다. @Service public class MemoService { private final MemoRepository memoRepository; public MemoService( MemoRepository memoRepository ) { this.memoRepository = memoRepository; } } 어떻게 가능한 걸까? 6. Spring Data JPA가 구현체를 만들어 준다 Spring Data JPA는 애플리케이션이 실행될 때 JpaRepository 를 상속한 Repository 인터페이스를 찾아낸다. 그리고 해당 정보를 바탕으로 구현체를 만들어 Spring Bean으로 등록한다. 강의에서는 이를 SimpleJpaRepository 를 통해 설명한다. Spring Data JPA가 Repository 인터페이스 정보를 이용해 구현 클래스를 만들고 Bean으로 등록하기 때문에 개발자가 구현체를 직접 작성하지 않아도 된다고 설명한다. :chatgpt-content-reference{index="3"} 개념적으로 보면: MemoRepository 인터페이스 ↓ Spring Data JPA가 확인 ↓ SimpleJpaRepository 기반 구현체 ↓ Spring Bean 등록 ↓ Service에 DI 이다. 따라서 private final MemoRepository memoRepository; 에 실제 사용할 객체를 Spring이 주입할 수 있게 된다. 7. 결국 이전에 배운 IoC/DI와 연결된다 여기서 5편에서 배운 내용과 연결된다. Spring Data JPA ↓ Repository 구현체 생성 ↓ Bean 등록 ↓ Spring IoC Container ↓ Service에 DI 즉, Spring Data JPA가 Repository 구현체를 만들어 주고, Spring IoC Container가 그 객체를 관리하며, Service는 생성자 주입으로 받아 사용한다. @Service public class MemoService { private final MemoRepository memoRepository; public MemoService( MemoRepository memoRepository ) { this.memoRepository = memoRepository; } } 이렇게 지금까지 배웠던 개념들이 서로 연결된다. 8. save() Entity를 저장할 때는 save() 를 사용할 수 있다. Memo saveMemo = memoRepository.save(memo); 메모 생성 코드는 다음과 같은 형태다. public MemoResponseDto createMemo( MemoRequestDto requestDto ) { Memo memo = new Memo(requestDto); Memo saveMemo = memoRepository.save(memo); return new MemoResponseDto(saveMemo); } 강의에서도 save() 에 저장할 Entity를 전달해 데이터를 저장한다고 설명하며, 내부적으로 영속성 컨텍스트를 이용한다. :chatgpt-content-reference{index="4"} 즉, memoRepository.save(memo) ↓ Spring Data JPA ↓ JPA ↓ 영속성 컨텍스트 ↓ DB 과정을 거친다. 9. save() 내부에서도 JPA가 동작한다 이전에 일반 JPA에서 저장할 때는 em.persist(memo); 를 사용했다. 그런데 Spring Data JPA에서는 memoRepository.save(memo); 를 사용한다. 그렇다고 EntityManager 가 사라진 것은 아니다. 강의에서 살펴보는 SimpleJpaRepository 의 save() 는 Entity가 새 객체인지 판단한 뒤 persist() 또는 merge() 를 사용한다. :chatgpt-content-reference{index="5"} 개념적으로 보면: save(entity) 내부에서 새로운 Entity → em.persist() 기존 Entity → em.merge() 처럼 JPA가 사용된다. 즉, JpaRepository ↓ SimpleJpaRepository ↓ EntityManager ↓ JPA 구조가 그대로 존재한다. Spring Data JPA가 그 코드를 대신 작성해 준 것이다. 10. findAll() 전체 데이터를 조회할 때는 memoRepository.findAll(); 을 사용할 수 있다. 메모장에서는 다음과 같이 DTO로 변환할 수 있다. public List<MemoResponseDto> getMemos() { return memoRepository.findAll() .stream() .map(MemoResponseDto::new) .toList(); } 강의에서도 findAll() 을 이용해 테이블의 전체 데이터를 조회한다. :chatgpt-content-reference{index="6"} 흐름은: findAll() ↓ List<Memo> ↓ Stream ↓ MemoResponseDto로 변환 ↓ List<MemoResponseDto> 이다. 11. findById() 특정 ID를 가진 Entity를 찾고 싶다면: memoRepository.findById(id); 를 사용한다. 그런데 반환 타입을 보면 단순히 Memo 가 아니다. Optional<Memo> 이다. 강의에서도 SimpleJpaRepository 의 findById() 반환 타입이 Optional 이라고 설명한다. :chatgpt-content-reference{index="7"} 여기서 Optional 이 등장한다. 12. Optional 이란? Optional<T> 는 값이 있을 수도 있고 없을 수도 있다는 상황을 표현하는 컨테이너 라고 이해하면 된다. 예를 들어: Optional<Memo> 는 Memo가 존재할 수도 있음 또는 Memo가 존재하지 않을 수도 있음 을 의미한다. DB에서 findById(1L) 을 실행했는데 ID가 1인 Memo가 존재하지 않을 수도 있기 때문에 Optional<Memo> 를 반환하는 것이다. 13. 왜 그냥 null 을 반환하지 않을까? 만약 단순히 Memo 를 반환한다면 Memo memo = memoRepository.findById(id); 메모가 존재하지 않을 때 null 이 들어올 수 있다. 그 후 실수로 memo.getContents(); 를 호출하면 NullPointerException 이 발생할 수 있다. Optional 을 사용하면 값이 존재할 수도 있음 이라는 사실을 타입 자체에서 명확하게 표현할 수 있다. 따라서 개발자가 값이 없는 경우를 처리하도록 유도한다. 14. orElseThrow() 메모장 프로젝트에서는 다음과 같이 처리한다. private Memo findMemo(Long id) { return memoRepository .findById(id) .orElseThrow(() -> new IllegalArgumentException( "선택한 메모는 존재하지 않습니다." ) ); } 강의에서도 findById() 의 결과가 없으면 orElseThrow() 를 이용해 예외를 발생시키는 방식으로 처리한다. :chatgpt-content-reference{index="8"} 흐름을 보면: findById(id) ↓ Optional<Memo> ↓ 값 있음? ┌────┴────┐ Yes No ↓ ↓ Memo 예외 발생 이다. 15. Optional 과 제네릭을 같이 보면 여기에서도 제네릭이 사용된다. Optional<Memo> 은 Optional<T> T = Memo 라는 뜻이다. 즉, Optional 도 특정 타입 하나에만 사용하는 클래스가 아니라 다양한 타입을 담을 수 있도록 제네릭으로 설계되어 있다. 예를 들어: Optional<String> Optional<User> Optional<Memo> 처럼 사용할 수 있다. 이처럼 제네릭을 사용하면 하나의 클래스 구조를 여러 타입에 재사용하면서도 타입 안정성을 유지할 수 있다. 16. 수정에는 왜 update() 가 없을까? Spring Data JPA를 보면 save() findAll() findById() delete() 등은 있는데 일반적으로 다음과 같은 메서드를 찾지 않는다. update() 왜일까? 이전 편에서 배운 변경 감지(Dirty Checking) 때문이다. 강의에서도 SimpleJpaRepository 에는 별도의 update() 메서드가 없으며, Entity를 수정할 때 영속성 컨텍스트의 변경 감지를 사용한다고 설명한다. :chatgpt-content-reference{index="9"} 예를 들어: @Transactional public Long updateMemo( Long id, MemoRequestDto requestDto ) { Memo memo = findMemo(id); memo.update(requestDto); return id; } 이다. 17. 수정 흐름 다시 보기 먼저 Entity를 조회한다. Memo memo = findMemo(id); 트랜잭션 안에서 조회한 Entity는 영속 상태로 관리된다. findMemo() ↓ Memo 조회 ↓ Managed Entity 그다음 값을 바꾼다. memo.update(requestDto); 그러면: Entity 값 변경 ↓ Dirty Checking ↓ UPDATE SQL 생성 ↓ DB 반영 이 일어난다. 그래서 다음 코드를 다시 호출하지 않아도 된다. memoRepository.save(memo); 즉, JPA 수정에서 중요한 것은 영속 상태 Entity를 변경하면 JPA가 변경을 감지한다 는 점이다. 18. 왜 @Transactional 이 필요할까? 수정 메서드에는 다음과 같이 @Transactional 이 붙는다. @Transactional public Long updateMemo(...) { } 강의에서도 변경 감지가 적용될 수 있도록 수정 메서드에 @Transactional 을 사용한다. :chatgpt-content-reference{index="10"} 흐름을 보면: @Transactional ↓ 트랜잭션 시작 ↓ 영속성 컨텍스트 유지 ↓ Entity 조회 ↓ Entity 수정 ↓ Dirty Checking ↓ flush ↓ commit 이다. 지난 편에서 배운 영속성 컨텍스트가 Spring Data JPA에서도 그대로 사용되는 것이다. 19. delete() Entity를 삭제할 때는 다음과 같이 사용할 수 있다. memoRepository.delete(memo); 예를 들면: public Long deleteMemo(Long id) { Memo memo = findMemo(id); memoRepository.delete(memo); return id; } 강의에서도 delete() 에 삭제할 Entity 객체를 전달하는 방식으로 사용한다. :chatgpt-content-reference{index="11"} 일반 JPA에서는 em.remove(memo); 를 직접 사용했다면, Spring Data JPA에서는 memoRepository.delete(memo); 로 추상화된 것이다. 20. 결국 CRUD가 이렇게 단순해진다 Spring Data JPA를 적용하면 Repository는: public interface MemoRepository extends JpaRepository<Memo, Long> { } 이 정도로 끝날 수 있다. 그리고 Service에서는: save() findAll() findById() delete() 를 바로 사용할 수 있다. 정리하면: CREATE → save() READ 전체 → findAll() READ 하나 → findById() UPDATE → 영속 Entity 변경 + Dirty Checking DELETE → delete() 이다. 강의의 MemoService도 save , findAll , findById , delete , 그리고 변경 감지를 조합해 CRUD를 구현한다. :chatgpt-content-reference{index="12"} 21. 그런데 정렬해서 조회하려면? 기본 findAll() 은 단순히 전체 Entity를 조회한다. 하지만 메모장에서는 최근 수정된 메모부터 조회하고 싶을 수 있다. 예를 들어: modifiedAt DESC 형태다. SQL이라면 직접 다음과 비슷하게 작성할 수 있다. SELECT * FROM memo ORDER BY modified_at DESC; Spring Data JPA에서는 이런 경우에도 SQL을 직접 작성하지 않고 메서드 이름으로 쿼리를 만들 수 있다. 이 기능이 Query Methods 다. 22. Query Methods란? Spring Data JPA에서는 정해진 규칙에 맞춰 Repository의 메서드 이름을 작성하면 해당 이름을 분석해 쿼리를 만들어 준다. 강의에서는 이를 Query Methods 라고 설명한다. :chatgpt-content-reference{index="13"} 예를 들어: List<Memo> findAllByOrderByModifiedAtDesc(); 라는 메서드를 선언할 수 있다. 직접 구현 코드는 작성하지 않는다. public interface MemoRepository extends JpaRepository<Memo, Long> { List<Memo> findAllByOrderByModifiedAtDesc(); } 23. 메서드 이름을 나눠 보면 findAllByOrderByModifiedAtDesc() 를 나눠 보면 이해하기 쉽다. findAll → 전체 조회 By → 조건 표현 시작 OrderBy → 정렬 ModifiedAt → modifiedAt 필드 기준 Desc → 내림차순 따라서 의미는: modifiedAt 을 기준으로 내림차순 정렬하여 전체 Memo 조회 가 된다. 강의에서도 이 메서드가 수정 시간을 기준으로 전체 데이터를 내림차순으로 가져오는 쿼리를 만든다고 설명한다. :chatgpt-content-reference{index="14"} 24. 이제 findAll() 대신 Query Method 사용 기존에는: public List<MemoResponseDto> getMemos() { return memoRepository.findAll() .stream() .map(MemoResponseDto::new) .toList(); } 이었다면, 다음처럼 변경할 수 있다. public List<MemoResponseDto> getMemos() { return memoRepository .findAllByOrderByModifiedAtDesc() .stream() .map(MemoResponseDto::new) .toList(); } 이제 최근 수정된 메모가 먼저 조회된다. 25. 조건이 필요한 Query Method Query Method에는 매개변수도 전달할 수 있다. 예를 들어 특정 username을 가진 Memo를 찾고 싶다면: List<Memo> findAllByUsername( String username ); 처럼 선언할 수 있다. 강의에서도 ByUsername 에 필요한 값을 메서드 파라미터로 전달하는 형태를 설명한다. :chatgpt-content-reference{index="15"} 즉, 메서드 이름 → 어떤 조건으로 검색할 것인지 표현 매개변수 → 검색에 사용할 실제 값 이다. 26. 특정 문자열이 포함된 Memo 찾기 이번
도커(Docker)로 애플리케이션을 빌드하고 배포할 때 가장 흔하게 겪는 골칫거리 중 하나는 천정부지로 치솟는 도커 이미지 용량 입니다. 예를 들어 Go나 Java, Node.js 같은 언어로 애플리케이션을 만들 때, 소스코드를 컴파일하고 빌드하는 데 필요한 무거운 도구들(GCC, Maven, Node 빌드 패키지 등)을 이미지에 그대로 담으면 최종 이미지 용량이 수 GB를 훌쩍 넘어가기 일쑤입니다. 보안상 취약점도 늘어나고 레지스트리에 푸시/풀할 때 네트워크 자원도 낭비됩니다. 이 문제를 해결해 주는 핵심 기술이 바로 Multi-stage Build입니다. 1. 전통적인 방식의 문제점 (Sigle-stage Build) 기존에는 애플리케이션을 빌드하기 위해 하나의 거대한 Dockerfile 을 작성했습니다. FROM golang:1.22 WORKDIR /app COPY . . # 빌드 도구가 포함된 무거운 환경에서 컴파일 진행 RUN go build -o myapp . CMD ["./myapp"] 이 방식의 치명적인 단점은 빌드에만 필요하고 실행할 때는 필요 없는 수백 MB의 Go 컴파일러와 소스코드들이 최종 이미지 안에 고스란히 남아있게 된다 는 점입니다. 오직 실행 파일( myapp ) 하나만 있으면 되는데도 말입니다. 2. Multi-stage Build란? (개념과 구조) 멀티 스테이지 빌드는 하나의 Dockerfile 안에 여러 개의 FROM 문 을 사용하여 빌드 단계(Stage)를 명확하게 분리하는 기법입니다. 첫 번째 스테이지 (Builder) : 빌드 도구가 가득 잔뜩 들어있는 무거운 환경에서 소스코드를 컴파일하여 실행 파일을 만듭니다. 두 번째 스테이지 (Final/Runtime) : 오직 실행에 필요한 최소한의 런타임 환경(예 : 가벼운 Apline Linux 등)만 구성하고, 첫 번째 스테이지에서 만들어진 결과물( --from=builder )만 쏙 뽑아옵니다. 결과적으로 빌드 도구들은 최종 이미지에 포함되지에 포함되지 않으므로 이미지 용량이 수 GB에서 수십 MB 수준으로 획기적으로 줄어듭니다. 3. 실전 예시 코드 (Dockerfile 작성법) 실제로 Go 언어 애플리케이션을 멀티 스테이지 빌드로 작성한 Dockerfile 예시입니다. # --- [1단계: 빌드 스테이지] --- # 빌드에 필요한 모든 무거운 도구가 있는 이미지를 사용하고 'builder'라는 별칭을 붙입니다. FROM golang:1.22 AS builder WORKDIR /app COPY . . # 실행 파일(myapp)로 컴파일 RUN CGO_ENABLED=0 GOOS=linux go build -o myapp . # --- [2단계: 최종 런타임 스테이지] --- # 보안이 뛰어나고 용량이 매우 작은 알파인(Alpine) 리눅스를 베이스로 사용합니다. FROM alpine:latest WORKDIR /root/ # 핵심: 이전 스테이지(builder)에서 생성된 바이너리 파일만 복사해 옵니다! COPY --from=builder /app/myapp . EXPOSE 8080 CMD ["./myapp"] 4. 멀티 스테이지 빌드의 장점 획기적인 용량 절감 : 수 GB에 달하던 이미지를 10~50MB 내외로 다이어트할 수 있습니다. 보안성 향상 : 프로덕션 서버(운영 환경)에 컴파일러나 소스코드가 존재하지 않으므로 해커에게 탈취당할 공격 표면(Attack Surface)이 대폭 줄어듭니다. Dockerfile 관리 편의성 : 빌드용 Dockerfile과 배포용 Dockerfile을 따로 관리할 필요 없이, 파일 하나로 깔끔하게 통합 관리할 수 있습니다. 🎯 마무리 멀티 스테이지 빌드는 도커를 활용한 CI/CD 파이프라인 구축 시 선택이 아닌 필수 인 모범 사례(Best Practice)입니다. 빌드 도구와 런타임 환경을 분리하는 이 간단한 전략 하나만으로도 인프라 비용과 배포 시간을 크게 아낄 수 있습니다.
이전에는 ORM과 JPA의 관계를 살펴보고, Java 객체를 DB 테이블과 연결하는 Entity 에 대해 정리했다. ORM ↓ JPA ↓ Hibernate ↓ Entity ↓ DB 그런데 여기서 한 가지 궁금한 점이 생긴다. Memo memo = new Memo(); 이렇게 생성한 객체를 JPA는 어떻게 관리할까? 그리고 em.persist(memo); 를 호출하면 바로 DB에 INSERT SQL이 실행되는 걸까? JPA는 Entity를 효율적으로 관리하기 위해 영속성 컨텍스트(Persistence Context) 라는 공간을 사용한다. 강의에서도 영속성 컨텍스트를 Entity 객체를 효율적으로 관리하기 위해 만들어진 공간이라고 설명한다. :chatgpt-content-reference{index="0"} 이번에는 EntityManager ↓ 영속성 컨텍스트 ↓ 1차 캐시 ↓ 쓰기 지연 ↓ 변경 감지 의 흐름과 Entity의 상태 변화를 정리해 보려고 한다. 1. 영속성 컨텍스트란? 영속성 컨텍스트는 쉽게 말해 JPA가 Entity 객체를 관리하는 공간 이다. JPA는 우리가 사용하는 Entity 객체를 무조건 바로 DB에 보내는 것이 아니라, 영속성 컨텍스트에 저장하고 관리하면서 DB와 통신한다. :chatgpt-content-reference{index="1"} 전체적인 구조는 다음과 같이 볼 수 있다. Application ↓ EntityManager ↓ Persistence Context ↓ DB 즉, 개발자가 Entity를 직접 DB에 넣고 빼는 것이 아니라 개발자 ↓ EntityManager ↓ 영속성 컨텍스트 ↓ DB 형태로 JPA를 통해 관리하게 된다. 2. EntityManager란? 영속성 컨텍스트에 접근해서 Entity를 관리하려면 EntityManager 가 필요하다. 강의에서는 EntityManager를 Entity를 관리하는 관리자라고 설명하며, 이를 통해 Entity를 저장·조회·수정·삭제할 수 있다고 설명한다. :chatgpt-content-reference{index="2"} 예를 들어: em.persist(memo); Memo memo = em.find(Memo.class, 1L); em.remove(memo); 처럼 사용할 수 있다. 정리하면: EntityManager ↓ 영속성 컨텍스트 접근 ↓ Entity 관리 이다. 3. EntityManager는 어떻게 만들어질까? 일반 JPA에서는 EntityManagerFactory 를 통해 EntityManager 를 생성한다. EntityManagerFactory emf = Persistence.createEntityManagerFactory("memo"); EntityManager em = emf.createEntityManager(); 강의에서도 같은 순서로 EntityManagerFactory 를 만든 후 EntityManager 를 생성한다. :chatgpt-content-reference{index="3"} 즉, Persistence ↓ EntityManagerFactory ↓ EntityManager ↓ Persistence Context 로 이해할 수 있다. EntityManagerFactory 는 이름 그대로 EntityManager 를 만들어 주는 Factory다. 강의에서는 일반적으로 DB 하나당 하나의 EntityManagerFactory 를 생성해 애플리케이션 실행 중 사용한다고 설명한다. :chatgpt-content-reference{index="4"} 4. 영속성 컨텍스트의 핵심 기능 — 1차 캐시 영속성 컨텍스트 내부에는 1차 캐시 라는 저장 공간이 있다. 강의에서는 이를 Map 과 비슷한 구조로 설명한다. Key → @Id 값 Value → Entity 객체 예를 들어: 1차 캐시 Key Value --------------------- 1 Memo 객체 2 Memo 객체 3 Memo 객체 처럼 Entity를 관리한다. :chatgpt-content-reference{index="5"} 즉, Entity의 @Id 값이 식별자 역할을 한다. 5. persist() 를 호출하면 무슨 일이 일어날까? 다음 코드를 보자. Memo memo = new Memo(); memo.setId(1L); memo.setUsername("Robbie"); memo.setContents("Hello"); em.persist(memo); em.persist(memo) 를 호출하면 Entity가 영속성 컨텍스트의 1차 캐시에 저장된다. :chatgpt-content-reference{index="6"} 즉, Memo 객체 ↓ em.persist() ↓ Persistence Context ↓ 1차 캐시 저장 가 된다. 여기서 중요한 점은 persist() 의 핵심 의미는 Entity를 영속성 컨텍스트에서 관리되는 상태로 만드는 것 이라는 것이다. 6. Entity를 조회하면 먼저 1차 캐시를 확인한다 다음 코드를 생각해 보자. Memo memo = em.find(Memo.class, 1L); JPA는 무조건 DB부터 조회하지 않는다. 먼저 영속성 컨텍스트의 1차 캐시에서 id=1 인 Entity가 있는지 확인한다. em.find(Memo.class, 1L) ↓ 1차 캐시 확인 ↓ 있음? 있다면 바로 1차 캐시의 Entity를 반환한다. 1차 캐시 ↓ Entity 반환 없다면 DB에서 조회한다. 1차 캐시 확인 ↓ 없음 ↓ DB SELECT ↓ 조회한 Entity를 1차 캐시에 저장 ↓ Entity 반환 강의에서도 캐시에 조회하려는 ID가 없으면 DB에서 조회한 뒤 캐시에 저장한다고 설명한다. :chatgpt-content-reference{index="7"} 7. 같은 Entity를 두 번 조회하면? 예를 들어 다음 코드를 실행한다고 하자. Memo memo1 = em.find(Memo.class, 1L); Memo memo2 = em.find(Memo.class, 1L); 첫 번째 조회에서는 1차 캐시에 데이터가 없을 수 있다. memo1 조회 1차 캐시 없음 ↓ SELECT ↓ DB ↓ 1차 캐시 저장 두 번째 조회에서는 이미 같은 Entity가 1차 캐시에 있다. memo2 조회 1차 캐시 있음 ↓ DB 조회 X ↓ 캐시의 객체 반환 따라서 같은 영속성 컨텍스트 안에서는 memo1 == memo2 결과가 true 가 된다. 강의에서도 동일한 Entity를 두 번 조회했을 때 같은 객체가 반환되어 객체 동일성이 보장된다고 설명한다. :chatgpt-content-reference{index="8"} 이를 객체 동일성 보장 이라고 한다. 8. 1차 캐시의 장점 강의에서는 1차 캐시의 장점을 크게 두 가지로 설명한다. 1. DB 조회 횟수를 줄일 수 있음 2. 같은 DB Row에 대해 같은 객체가 사용되는 것을 보장 즉, DB 접근 감소 + 객체 동일성 보장 이라는 효과가 있다. :chatgpt-content-reference{index="9"} 다만 여기서 말하는 1차 캐시는 영속성 컨텍스트 내부에서 사용되는 캐시 라는 점을 기억해 두는 것이 중요하다. 9. JPA는 SQL을 바로 DB에 보내지 않을 수도 있다 JPA에는 쓰기 지연 저장소(ActionQueue) 라는 개념도 있다. Entity를 저장하거나 삭제하는 등 DB 변경 작업이 발생할 때 SQL을 모아 두었다가 트랜잭션과 함께 처리할 수 있다. 강의에서는 JPA가 SQL을 쓰기 지연 저장소에 모아 두었다가 트랜잭션이 커밋될 때 DB에 반영한다고 설명한다. :chatgpt-content-reference{index="10"} 개념적으로 보면 다음과 같다. em.persist(memo1) ↓ INSERT SQL 생성 ↓ 쓰기 지연 저장소 em.persist(memo2) ↓ INSERT SQL 생성 ↓ 쓰기 지연 저장소 쓰기 지연 저장소에는 INSERT memo1 INSERT memo2 처럼 SQL이 모인다. 그리고 트랜잭션의 커밋 과정에서 DB로 전달된다. 쓰기 지연 저장소 ↓ flush ↓ DB에 SQL 전달 ↓ commit 강의에서도 commit 전까지 SQL 요청이 없다가 이후 여러 INSERT SQL이 요청되는 과정을 확인한다. :chatgpt-content-reference{index="11"} 10. flush() 란? 여기서 등장하는 중요한 메서드가 em.flush(); 다. flush() 는 영속성 컨텍스트의 변경 내용을 DB에 반영하도록 SQL을 전달하는 과정 이라고 이해할 수 있다. 강의에서는 flush() 가 쓰기 지연 저장소에 있는 SQL을 DB로 요청하는 역할을 한다고 설명한다. :chatgpt-content-reference{index="12"} 흐름은 다음과 같다. Persistence Context 1차 캐시 + 쓰기 지연 저장소 ↓ flush() ↓ DB 11. flush() 와 commit() 은 같은 걸까? 같은 것은 아니다. 단순하게 구분하면: flush → SQL을 DB로 전달 commit → 트랜잭션의 변경 내용을 최종 확정 이라고 이해하면 된다. 강의에서도 flush() 를 직접 호출하면 그 시점에 쓰기 지연 저장소의 SQL이 DB에 요청된다고 확인한다. :chatgpt-content-reference{index="13"} 따라서 flush = DB에 SQL 반영 요청 commit = 트랜잭션 확정 으로 구분할 수 있다. 12. 트랜잭션은 왜 필요할까? JPA에서 데이터 변경 작업은 트랜잭션 안에서 처리된다. 예를 들어 일반 JPA에서는 다음과 같이 작성한다. EntityTransaction tx = em.getTransaction(); tx.begin(); try { em.persist(memo); tx.commit(); } catch (Exception e) { tx.rollback(); } 강의에서는 EntityTransaction 을 이용해 트랜잭션을 시작하고, 정상 처리되면 commit , 오류가 발생하면 rollback 한다고 설명한다. :chatgpt-content-reference{index="14"} 즉, begin ↓ DB 작업 ↓ 정상 ↓ commit 또는 begin ↓ DB 작업 ↓ 오류 ↓ rollback 으로 동작한다. 13. 그런데 UPDATE용 메서드는 어디 있을까? JPA를 처음 보면 다음과 같은 메서드가 있을 것 같다. em.update(memo); 하지만 실제로는 이런 방식으로 수정하지 않는다. JPA에서는 변경 감지(Dirty Checking) 를 이용한다. 강의에서도 em.update() 와 같은 메서드는 존재하지 않는다고 설명하면서 변경 감지 과정을 소개한다. :chatgpt-content-reference{index="15"} 14. 변경 감지(Dirty Checking) Entity가 영속성 컨텍스트에 들어올 때 JPA는 해당 Entity의 최초 상태를 함께 저장한다. 강의에서는 이를 LoadedState 라고 표현한다. :chatgpt-content-reference{index="16"} 예를 들어 처음 조회한 상태가 username = Robbie contents = Hello 라고 하자. JPA는 이 상태를 기억한다. LoadedState username = Robbie contents = Hello 그리고 코드에서 Entity를 변경한다. memo.setUsername("Update"); memo.setContents("변경 감지 확인"); 현재 상태는 Current State username = Update contents = 변경 감지 확인 가 된다. 15. JPA는 최초 상태와 현재 상태를 비교한다 트랜잭션이 커밋되고 flush() 가 일어나는 과정에서 JPA는 LoadedState ↕ 비교 Current State 를 확인한다. 변경 사항이 있다면 변경 발견 ↓ UPDATE SQL 생성 ↓ 쓰기 지연 저장소 ↓ DB 과정이 실행된다. 강의에서도 커밋 후 flush() 과정에서 최초 상태와 현재 상태를 비교하고, 차이가 있으면 UPDATE SQL 을 생성한다고 설명한다. :chatgpt-content-reference{index="17"} 이 과정을 변경 감지(Dirty Checking) 라고 한다. 16. 그래서 save() 를 다시 하지 않아도 수정된다 다음 코드를 보자. Memo memo = em.find(Memo.class, 1L); memo.setContents("수정된 내용"); 여기에는 em.update(memo); 도 없고 em.persist(memo); 를 다시 호출하지도 않는다. 그래도 해당 Entity가 영속 상태 라면 JPA가 변경을 감지할 수 있다. Entity 조회 ↓ 영속 상태 ↓ 필드 변경 ↓ Dirty Checking ↓ UPDATE SQL 강의의 예제에서도 Entity를 조회한 뒤 Setter로 값을 변경하고 commit() 만 수행해 UPDATE 가 발생한다. :chatgpt-content-reference{index="18"} 이 개념이 나중에 Spring Data JPA의 수정 코드에서 매우 중요해진다. 17. Entity에는 상태가 있다 JPA에서는 Entity가 항상 같은 상태인 것은 아니다. 강의에서는 크게 다음 상태들을 다룬다. 비영속 (Transient) 영속 (Managed) 준영속 (Detached) 삭제 (Removed) 각 상태는 JPA가 해당 Entity를 관리하고 있는가 를 기준으로 이해하면 쉽다. 18. 비영속(Transient) 다음과 같이 객체만 생성한 상태다. Memo memo = new Memo(); memo.setId(1L); memo.setUsername("Robbie"); memo.setContents("Hello"); 이 객체는 Java 객체로 존재하지만 아직 영속성 컨텍스트에 들어가지 않았다. 따라서: Java Heap에는 존재 하지만 Persistence Context에는 없음 이다. 강의에서는 new 로 생성했지만 아직 영속성 컨텍스트에 저장되지 않아 JPA의 관리를 받지 않는 상태를 비영속 상태라고 설명한다. :chatgpt-content-reference{index="19"} 즉, new Memo() ↓ Transient 이다. 19. 영속(Managed) 비영속 상태의 객체에 em.persist(memo); 를 호출하면 영속 상태가 된다. Transient ↓ persist() ↓ Managed 이제 해당 Entity는 영속성 컨텍스트에 저장되어 JPA의 관리를 받는다. 강의에서도 persist() 를 통해 비영속 Entity를 영속성 컨텍스트에 저장하면 관리되는 상태가 된다고 설명한다. :chatgpt-content-reference{index="20"} 영속 상태에서는 1차 캐시 변경 감지 쓰기 지연 등 영속성 컨텍스트의 기능을 사용할 수 있다. 20. 비영속 객체에서는 변경 감지가 일어나지 않는다 예를 들어 Memo memo = new Memo(); memo.setContents("A"); memo.setContents("B"); 라고 해도 이 객체가 영속성 컨텍스트에 들어간 적이 없다면 JPA는 관리하지 않는다. 따라서 Dirty Checking도 할 수 없다. 강의에서도 비영속 상태는 JPA가 관리하지 않기 때문에 데이터를 변경해도 변경 감지가 이루어지지 않는다고 설명한다. :chatgpt-content-reference{index="21"} 즉, Dirty Checking 영속 Entity → 가능 비영속 Entity → 불가능 이라고 볼 수 있다. 21. 준영속(Detached) 영속성 컨텍스트에서 관리되던 Entity가 관리 대상에서 분리될 수도 있다. 이 상태를 준영속(Detached) 이라고 한다. Managed ↓ detach() ↓ Detached 예를 들어: em.detach(memo); 를 실행하면 memo 는 영속성 컨텍스트에서 분리된다. 강의에서는 준영속 상태를 영속성 컨텍스트에서 관리되다가 분리된 상태라고 설명한다. :chatgpt-content-reference{index="22"} 22. 준영속 상태에서는 JPA의 관리를 받지 않는다 detach() 가 호출되면 해당 Entity는 1차 캐시에서도 제거된다. 따라서 1차 캐시 X 변경 감지 X 영속성 컨텍스트의 관리 X 가 된다. 강의에서도 준영속 상태로 전환된 Entity는 1차 캐시에서 제거되기 때문에 영속성 컨텍스트의 기능을 사용할 수 없다고 설명한다. :chatgpt-content-reference{index="23"} 즉, Managed → JPA 관리 O Detached → JPA 관리 X 다. 23. 준영속 Entity를 다시 영속 상태로 만들려면? 강의에서는 merge() 도 다룬다. Memo mergedMemo = em.merge(memo); 준영속 객체의 값을 영속 상태의 객체에 병합해서 새로운 영속 Entity를 반환한다. :chatgpt-content-reference{index="24"} 여기서 중요한 점은 merge(memo) 를 호출했다고 해서 기존 memo 객체 자체가 다시 영속 상태가 되는 것은 아니라는 점이다. 개념적으로는 Detached memo ↓ merge() ↓ 값을 병합한 새로운 Managed Entity 반환 으로 이해하면 된다. 24. 삭제(Removed) Entity를 삭제하려면 먼저 JPA가 관리하고 있는 Entity를 가져온 뒤 em.remove(memo); 를 호출한다. Managed ↓ remove() ↓ Removed 강의에서는 remove() 호출 시 Entity가 삭제 상태로 변경되고 이후 트랜잭션 커밋 과정에서 DELETE SQL 이 DB로 요청된다고 설명한다. :chatgpt-content-reference{index="25"} 즉, Memo memo = em.find(Memo.class, 1L); em.remove(memo); 처럼 사용하는 구조다. 25. Entity 상태 전체 흐름 지금까지의 상태 변화를 그림처럼 정리하면 다음과 같다. new ↓ Transient 비영속 ↓ persist() ↓ Managed 영속 ├──────────────→ remove() │ ↓ │ Removed │ 삭제 │ └──────────────→ detach() ↓ Detached 준영속 ↓ merge() ↓ Managed Entity 반환 핵심은 영속 상태인지 아닌지가 JPA의 관리 여부를 결정한다 는 것이다. 26. Spring Boot에서는 트랜잭션을 어떻게 사용할까? 지금까지 일반 JPA 예제에서는 직접 EntityTransaction tx = em.getTransaction(); tx.begin(); ... tx.commit(); 을 작성했다. 하지만 Spring에서는 트랜잭션을 더 편하게 사용할 수 있다. @Transactional public void updateMemo() { } 처럼 @Transactional 을 사용한다. 강의에서는 @Transactional 을 클래스나 메서드에 추가하면 해당 범위의 DB 연산을 하나의 트랜잭션으로 묶어 처리하고, 정상 수행되면 커밋하고 예외가 발생하면 롤백한다고 설명한다. :chatgpt-content-reference{index="26"} 즉, 메서드 시작 ↓ 트랜잭션 시작 ↓ DB 작업 ↓ 정상 종료 ↓ commit 이 자동으로 관리되는 것이다. 27. 수정 코드에서 @Transactional 이 중요한 이유 이제 실제 Spring 코드에서 다음과 같은 구조를 생각해 보자. @Transactional public Long updateMemo( Long id, MemoRequestDto requestDto ) { Memo memo = findMemo(id); memo.update(requestDto); return id; } 여기에는 memoRepository.save(memo); 가 없다. 그런데도 수정이 가능하다. 왜냐하면 트랜잭션 안에서 조회한 Entity가 영속 상태로 관리되고 있다면 Entity 조회 ↓ Managed 상태 ↓ memo.update() ↓ Entity의 값 변경 ↓ Dirty Checking ↓ UPDATE SQL ↓ commit 이 이루어지기 때문이다. 즉, JPA에서는 영속 상태 Entity의 값을 변경하면 변경 감지를 통해 UPDATE가 수행될 수 있다. 이게 처음 보면 가장 신기한 부분 중 하나다. 28. @Trans
샤오미의 HyperOS 운영체제는 커스텀 테마 설정을 지원합니다. 테마 파일은 MTZ 형식으로 직접 받거나 테마 스토어를 통해 설치할 수 있습니다. 실제 HyperOS 기기에서 테스트를 마친 4가지 테마의 제작자 정보, 파일 용량, 주요 기능을 정리했습니다. 1. VKOS HyperOS 테마 제작자: VK 파일 크기: 24.29 MB 지원 버전: HyperOS Global 주요 구성: iOS 스타일 잠금화면, 전용 아이콘 팩, 다크 모드 제어 센터 VKOS 테마는 깔끔한 iOS 스타일의 인터페이스를 제공합니다. 홈 화면 아이콘은 모서리가 둥근 사각형 디자인으로 변경됩니다. 제어 센터는 짙은 배경 패널과 시인성이 높은 토글 버튼으로 구성되어 있습니다. 테마 파일은 Mobile Lover VKOS 테마 에서 다운로드할 수 있습니다. 2. SE1 HyperOS 테마 제작자: suhel 파일 크기: 30.43 MB 지원 버전: HyperOS Global 주요 구성: 순정 안드로이드(AOSP) 디자인, 심플 시계 위젯, 바로가기 버튼 구글 픽셀과 같은 순정 스타일을 선호하는 사용자에게 적합한 테마입니다. 복잡한 효과를 줄이고 원형의 플랫 아이콘을 적용했습니다. 잠금화면에는 대형 디지털 시계와 빠른 앱 실행 바로가기가 배치되어 있습니다. 상세 스펙은 Mobile Lover SE1 테마 에서 확인할 수 있습니다. 3. OPBNB HyperOS 테마 제작자: maidul 파일 크기: 41.07 MB 지원 버전: HyperOS Global 주요 구성: 전용 제어 센터, 충전 링 애니메이션, 멀티 배경화면 OPBNB 테마는 동적 효과가 강조된 테마입니다. 충전 케이블을 연결하면 화면 중앙에 원형 링 충전 애니메이션이 표시됩니다. 잠금화면을 터치하여 5가지 내장 배경화면을 순차적으로 변경할 수 있습니다. 설치 정보는 Mobile Lover OPBNB 테마 에 정리되어 있습니다. 4. OX G5 글래스 UI 테마 제작자: Oxfury 파일 크기: 78.71 MB 지원 버전: HyperOS Global 주요 구성: 반투명 글래스 잠금화면 카드, iOS 스타일 위젯, 굵은 폰트 OX G5 테마는 고해상도 그래픽 에셋이 포함되어 있어 파일 용량이 상대적으로 큽니다. 잠금화면에 반투명 유리 느낌의 뮤직 플레이어, 캘린더, 날씨 카드 위젯이 표시됩니다. 전체 세부 정보는 Mobile Lover OX G5 테마 에서 볼 수 있습니다. MTZ 테마 설치 방법 다운로드한 MTZ 테마 파일을 기기에 적용하는 순서는 다음과 같습니다. 기기 저장소에 MTZ 파일을 다운로드합니다. 기본 테마(Themes) 앱을 실행합니다. 오른쪽 하단의 프로필 메뉴로 이동합니다. 테마 목록 하단의 가져오기(Import) 버튼을 누릅니다. 파일 탐색기에서 다운로드한 MTZ 파일을 선택합니다. 목록에 추가된 테마를 누르고 적용(Apply) 을 누릅니다. 더 많은 HyperOS 테마와 MTZ 파일 정보는 Mobile Lover 테마 모음 에서 확인할 수 있습니다.
[CTF/웹해킹] PHP 스트림 래퍼(php://) 핵심 정리 및 실전 공격 기법 1. php:// 스트림 래퍼란? http://나 file://처럼 URL 스키마 형식을 띠고 있지만, 네트워크 통신을 하지 않고 PHP 엔진 내부 메모리에서 동작하는 가상의 입출력 통로(파이프)입니다. PHP의 파일 입출력 함수(include, require, file_get_contents, fopen 등)는 일반 로컬 파일 경로뿐만 아니라 스트림 주소도 동일하게 받아들입니다. 웹 해킹 및 CTF에서는 주로 LFI(Local File Inclusion) 취약점과 연계하여 소스코드 유출이나 RCE(원격 코드 실행)를 달성하는 핵심 도구로 쓰입니다. 왜 주소(URI) 형태로 만들었을까요? (인터페이스 통일) PHP 설계자들이 프로그래머에게 편의를 주기 위해서입니다. 다른 프로그래밍 언어(C, Python, Java 등)에서는 로컬 파일 읽기(open), 네트워크 통신(requests/socket), 메모리 파이프(BytesIO)를 다룰 때 각각 완전히 다른 문법과 라이브러리를 써야 합니다. 하지만 PHP는 file_get_contents()나 fopen() 같은 함수 이름은 그대로 두고, 괄호 안에 전달하는 문자열 앞머리(http://, file://, php://)만 바꿔 끼우면 PHP 엔진이 알아서 네트워크, 디스크, 내부 메모리 파이프로 분기하도록 인터페이스를 하나로 통일해 둔 것입니다. 이것이 공격자에게는 취약점이 됩니다. 개발자가 include($_GET['page']); 처럼 로컬 파일 이름만 들어올 줄 알고 짰더라도, 공격자가 php:// 나 data:// 같은 스트림 주소를 주입하면 함수가 이를 그대로 해석해 버리기 때문입니다. 2. http:// vs file:// vs php:// 핵심 차이점 겉모습만 비슷한 주소 형태(URL 스키마)를 띨 뿐, 내부 동작 방식은 완전히 다릅니다. 네트워크 통신 vs 디스크 접근 vs 프로그램 내부 파이프 http:// (네트워크 통신) 실제로 인터넷/LAN 카드를 거쳐 외부 원격 서버로 패킷을 보냅니다. 프로토콜 규격에 따라 요청 헤더, 응답 헤더, 바디(본문) 등을 주고받는 네트워크 과정이 필요합니다. allow_url_fopen이 켜져 있을 때 RFI(원격 파일 포함) 공격에 주로 쓰입니다. file:// (로컬 파일 시스템 직접 접근) 네트워크를 타지 않고, OS의 파일 시스템 API를 직접 호출해 하드디스크에 저장된 파일을 그대로 읽어옵니다. 헤더나 변환 과정 없이 순수한 파일 내용만 읽어오며, file:///etc/passwd 는 /etc/passwd 를 직접 읽는 것과 사실상 동일합니다. php:// (메모리 내부 파이프) 네트워크를 전혀 타지 않습니다. 랜선이나 와이파이와 아무 상관이 없습니다. PHP 프로그램 자체(메모리) 안에서만 작동하는 가상의 소프트웨어 통로입니다. 리눅스 CLI에서 쓰는 파이프(|) 명령어를 PHP 엔진 내부 메모리로 옮겨놓은 것과 같습니다. 비교 요약 표 스키마 동작 영역 통신 방식 헤더/패킷 유무 보안 및 공격 관점 http:// 외부 원격 서버 TCP 네트워크 소켓 통신 HTTP 요청/응답 헤더 및 바디 존재 RFI (외부 서버의 악성 코드 원격 실행) file:// 로컬 디스크 (OS) OS 파일 시스템 시스템 콜 없음 (원시 바이트 직접 읽기) LFI (로컬 설정 파일, 시스템 파일 단순 열람) php:// PHP 프로세스 메모리 PHP 내부 C 스트림 처리기 없음 (메모리 버퍼 및 필터 체인 통과) LFI 소스코드 유출, 필터 체인 바이트 위조, POST 본문 RCE 3. 표준 입출력 래퍼 (stdin, stdout, stderr) 상세 분석 운영체제에서 모든 프로세스는 생성될 때 3개의 표준 입출력 채널(File Descriptor, FD)을 부여받습니다: FD 0번: 표준 입력 (STDIN) - 키보드 또는 파이프 입력 FD 1번: 표준 출력 (STDOUT) - 모니터 터미널 일반 출력 FD 2번: 표준 에러 (STDERR) - 모니터 터미널 에러 출력 PHP는 이 3개 통로를 파일처럼 읽고 쓸 수 있도록 php://stdin, php://stdout, php://stderr 래퍼를 제공합니다. (1) php://stdin (표준 입력 래퍼) 운영체제의 0번 통로(STDIN)에 직접 접근하는 읽기 전용 스트림입니다. 문법 구조 분해: 주소: php://stdin php:// : PHP 스트림 래퍼 선언 stdin : 운영체제의 표준 입력(FD 0번) 버퍼 채널을 지정 실제 PHP 개발 코드 예시: <?php // 방법 A: 한 줄씩 인터랙티브하게 읽기 $handle = fopen('php://stdin', 'r'); echo "이름을 입력하세요: "; $name = trim(fgets($handle)); echo "입력받은 값: " . $name . "\n"; // 방법 B: 버퍼 끝까지 통째로 읽기 (파이프나 파일 리디렉션용) $all_data = file_get_contents('php://stdin'); echo "전체 전달받은 데이터:\n" . $all_data; ?> - 실제로 데이터를 전달하는 3가지 방법: 1. 키보드로 직접 입력 (인터랙티브 모드) 터미널에서 `php test.php` 실행 후 키보드로 타이핑하고 엔터 입력 2. 리눅스 파이프(|) 연결 (가장 흔함) 다른 프로그램의 출력 결과를 stdin으로 꽂아 넣음: `echo "test_payload" | php test.php` 3. 파일 리디렉션(<) 연결 디스크에 있는 파일 내용을 통째로 표준 입력으로 밀어 넣음: `php test.php < payload.txt` - 웹 환경 vs CLI 환경: php://stdin 과 php://input 의 결정적 차이 1. 웹(Apache, Nginx + PHP-FPM) 환경: 웹 서버 프로세스는 클라이언트와 소켓으로 통신합니다. 이 환경에서 프로세스의 표준 입력(stdin)은 비어 있거나 닫혀 있어, 웹 요청 도중 php://stdin 을 호출하면 아무 데이터가 들어오지 않거나 프로그램이 무한 대기(블로킹)에 빠질 수 있습니다. → 따라서 웹 환경에서는 HTTP POST 본문을 안전하게 추출하기 위해 별도의 전용 통로인 php://input 을 사용해야 합니다. 2. CLI 환경 및 CTF 소켓 챌린지: 반면, 서버 터미널에서 실행되는 배치 도구이거나, CTF에서 `nc target.com 1337` 로 특정 포트를 열어두고 사용자의 터미널 입력을 백엔드 PHP 프로세스로 파이프 연결해 둔 환경에서는 원격 사용자가 입력하는 데이터가 그대로 프로세스의 0번(stdin)으로 들어옵니다. 따라서 이러한 CLI/소켓 문제에서는 php://stdin 이 공격자의 직접적인 입력 진입점이 됩니다. ### (2) php://stdout 및 php://stderr (표준 출력 및 에러 래퍼) 운영체제의 1번 통로(STDOUT)와 2번 통로(STDERR)에 직접 데이터를 쓰는 쓰기 전용 스트림입니다. - 문법 구조 분해: 주소: php://stdout 또는 php://stderr 1. php:// : PHP 스트림 래퍼 선언 2. stdout / stderr : 표준 출력 또는 표준 에러 버퍼 채널 지정 - 실제 PHP 개발 코드 예시: ```php <?php // 일반 화면 출력 $out = fopen('php://stdout', 'w'); fwrite($out, "정상 처리 결과입니다.\n"); // 에러 로그 출력 (표준 에러로 분리) $err = fopen('php://stderr', 'w'); fwrite($err, "경고: 오류가 발생했습니다.\n"); ?> 활용 및 특징: 리눅스 CLI에서 일반 출력과 에러 출력을 분리해서 파일로 저장할 때 쓰입니다: php test.php 1> result.log 2> error.log 4. php://filter 의 구조와 동작 원리 php:// 뒤에 오는 여러 기능 중, 데이터 변환(필터링) 전용 파이프라인 기능을 켜겠다는 의미입니다. 쉽게 말해 "데이터를 있는 그대로 주지 말고, 중간에 필터(변환기)를 통과시켜서 가져와라" 하고 명령하는 모드입니다. 주소 구조 분해 (정수기 비유) 예시 주소: php://filter/read=convert.base64-encode/resource=/etc/passwd php:// PHP 내부 스트림을 사용하겠다는 선언 filter/ 그중에서도 '필터 변환 파이프라인' 기능을 켜겠다는 선언 (정수기 본체 역할) read= (또는 write=) 데이터를 읽을 때 필터를 걸지, 쓸 때 필터를 걸지 지정 (읽기 작업 시 read= 는 생략 가능) convert.base64-encode 어떤 필터를 통과시킬지 지정하는 부분 (정수기 필터 카트리지 종류) | (파이프 기호) 여러 필터를 연속으로 꽂아 넣을 때 사용하는 구분자 (예: read=string.rot13|convert.base64-encode) /resource=/etc/passwd 변환을 통과시킬 대상 원본 데이터 (정수기에 통과시킬 원수 / 원본 파일) 내부에서 일어나는 일 (스텝 바이 스텝) 1단계: 접두사 확인 PHP 엔진은 주소 맨 앞의 php:// 를 보고 "외부 인터넷으로 나가지 말고 내장된 PHP 스트림 처리기(C 언어 모듈)로 보내라"고 내부 라우팅합니다. 2단계: 원본 데이터 로드 resource= 뒤에 적힌 로컬 파일(/etc/passwd)을 디스크에서 읽어 PHP 프로세스의 메모리 버퍼에 담습니다. (헤더 같은 부가 정보 없이 순수한 원본 바이트만 담깁니다.) 3단계: 메모리 상에서 필터 적용 지정된 필터(convert.base64-encode 또는 convert.iconv...) 함수를 호출해, 메모리에 올려둔 바이트들을 차례대로 실시간 변환합니다. 4단계: 최종 결과 반환 변환이 끝난 바이트 덩어리를 호출한 함수(예: file_get_contents 또는 mPDF의 이미지 로더)에게 반환합니다. 5. CTF 필수 스트림 래퍼 7선 (1) php://filter (소스코드 유출 및 체이닝) CTF에서 가장 압도적인 빈도로 등장하는 래퍼입니다. 파일을 읽거나 쓸 때 중간에서 데이터를 실시간으로 변환(필터링)하는 소프트웨어 파이프 역할을 합니다. 문법 구조 분해: 기본 형태: php://filter/read=필터명/resource=대상파일 php:// : 스트림 래퍼 선언 filter/ : 필터 파이프라인 모드 활성화 read= : 읽기 시점 필터 적용 선언 (생략 가능) convert.base64-encode : 필터 이름 | : 연속 필터 체이닝 구분자 /resource= : 대상 원본 파일 경로 꿀팁: 필터 이름을 전부 외워야 하나요? 외울 필요 없습니다. 규칙이 매우 직관적입니다: 카테고리.기능 형태: convert.base64-encode, convert.base64-decode, string.rot13, string.toupper 등 CTF에서 손으로 직접 입력하는 필터의 90% 이상은 오직 convert.base64-encode 하나뿐입니다. 현재 서버에서 지원하는 전체 필터 목록을 확인하고 싶다면 아래 명령어로 즉시 조회 가능합니다: php -r "print_r(stream_get_filters());" 핵심 활용 1: PHP 소스코드 열람 (LFI) 일반적으로 include 'config.php';를 하면 서버에서 코드가 실행되어 화면에 빈 화면이나 HTML 결과만 보입니다. 이때 base64 인코딩 필터를 거치게 만들면 코드가 실행되지 않고 base64 텍스트로 치환되어 출력됩니다. 페이로드: php://filter/convert.base64-encode/resource=config.php 출력된 base64 문자열을 디코딩하면 원래 PHP 소스코드를 그대로 볼 수 있습니다. 핵심 활용 2: 기타 문자열 조작 필터 string.rot13 : 텍스트를 ROT13 암복호화 처리 string.toupper / string.tolower : 대소문자 변환 convert.iconv.* : 문자셋 인코딩 변환 (UTF-8, UTF-16 등) 핵심 활용 3: PHP Filter Chain (고급 RCE 기법) iconv 변환 과정에서 발생하는 바이트 변형 특성을 수십~수백 번 체이닝하여, 임의의 바이트(웹쉘 코드나 가짜 이미지 헤더)를 메모리 상에서 직접 조립해 내는 기법입니다. (2) php://input (POST 본문을 통한 코드 실행) 클라이언트가 보낸 HTTP 요청 본문(Raw POST Data)을 직접 스트림으로 읽어오는 래퍼입니다. 문법 구조 분해: 요청 주소: http://target.com/index.php?page=php://input 요청 본문: php:// : 스트림 래퍼 선언 input : 클라이언트가 전송한 HTTP 요청 바디(Body)의 원시 데이터를 읽는 입력 채널 HTTP 요청 본문 (Body) : 파일 대신 include() 함수로 전달되어 즉시 실행될 PHP 페이로드 요구 조건: php.ini 설정에서 allow_url_include = On 상태여야 함 동작 원리: include($_GET['page']); 와 같은 취약한 코드에 ?page=php://input 을 넣고, 요청 바디에 PHP 코드를 전송하면 서버가 해당 코드를 그대로 include하여 실행합니다. (3) data:// (인라인 코드 주입) 엄밀히는 php://가 아닌 별도의 스키마이지만, CTF에서 세트로 묶여서 자주 나오는 스트림 래퍼입니다. 외부 파일을 참조할 필요 없이, URL 자체에 직접 실행할 코드를 담아 보낼 수 있습니다. 문법 구조 분해 (평문 형태): 주소: data://text/plain, data:// (또는 data:) : 인라인 데이터 스키마 선언 (RFC 2397) text/plain : 데이터 MIME 타입 , (쉼표) : 메타데이터와 실제 데이터 본문의 시작을 가르는 필수 구분자 : 서버에서 실행될 실제 코드 문법 구조 분해 (Base64 인코딩 형태): 주소: data://text/plain;base64,PD9waHAgc3lzdGVtKCdpZCcpOyA/Pg== data:// : Data URI 스키마 선언 text/plain : MIME 타입 ;base64 : 뒤따라오는 데이터가 Base64로 인코딩되어 있음을 알리는 선언 , (쉼표) : 구분자 PD9waHAg... : Base64 인코딩된 실제 코드 (공백, 따옴표, 괄호 등 WAF 필터링 우회용) 요구 조건: allow_url_include = On 및 allow_url_fopen = On (4) php://memory 및 php://temp (메모리 스트림) 디스크를 사용하지 않고 프로세스 메모리에 임시로 데이터를 읽고 쓸 수 있는 가상 스트림입니다. 문법 구조 분해: 기본 형태: php://memory 용량 제한 형태: php://temp/maxmemory:2097152 php://memory : 순수하게 RAM(메모리)에만 임시 입출력 스트림 생성 (디스크 I/O 없음) php://temp : 기본적으로 메모리에 쓰되, 용량이 초과되면 디스크의 임시 파일로 자동 전환 /maxmemory:2097152 : 메모리에 보관할 최대 크기를 바이트 단위로 지정 (기본값 2MB) CTF 활용: 디스크에 흔적을 남기지 않는 파일리스(Fileless) 공격이나 메모리 버퍼 조작 관련 문제에서 다뤄집니다. (5) phar:// (PHAR 아카이브 역직렬화 - RCE) PHP 아카이브(phar) 파일 내부의 메타데이터를 파싱할 때 발생하는 객체 역직렬화(Unserialize) 취약점을 이용하는 고난도 공격 기법입니다. 문법 구조 분해: 기본 형태: phar://디스크경로/아카이브내부파일 실제 페이로드 예시: phar:///var/www/uploads/evil.phar/test.txt phar:// : PHAR 스트림 래퍼 선언 /var/www/uploads/evil.phar : 서버 디스크에 업로드된 악성 phar 아카이브 파일의 실제 경로 /test.txt : phar 아카이브 내부에 존재하는 가상 파일명 (메타데이터 파싱 트리거용 더미 파일) 핵심 원리: include, require 같은 실행 함수뿐만 아니라, file_exists(), filesize(), md5_file(), is_dir() 같은 단순 파일 검사 함수에 phar:// 주소가 들어가기만 해도, PHP 엔진이 phar 파일의 메타데이터를 읽어들이면서 자동으로 unserialize()를 실행해 버립니다. 공격자가 악성 직렬화 객체(POP Chain)를 심어둔 phar 파일을 업로드한 뒤 파일 검사 함수에 경로를 찔러 넣으면 곧바로 RCE가 터집니다. (6) zip:// (압축 파일 내부 스크립트 실행 - 업로드 우회 + LFI) 서버가 .php 파일 업로드는 막고 .jpg 같은 이미지 파일만 허용할 때, 압축 파일로 우회하여 LFI로 실행시키는 테크닉입니다. 문법 구조 분해: 기본 형태: zip://압축파일절대경로#내부실행파일 URL 페이로드: ?page=zip:///var/www/uploads/avatar.jpg%23shell.php zip:// : ZIP 스트림 래퍼 선언 /var/www/uploads/avatar.jpg : 웹쉘(shell.php)을 zip으로 압축한 뒤 확장자만 .jpg로 변경하여 업로드한 파일 경로 (URL에서는 %23 으로 인코딩) : 압축 파일 경로와 압축 내부의 대상 파일을 구분하는 앵커 기호 shell.php : 압축 파일 안에 들어있는 실제 실행 대상 웹쉘 파일명 공격 흐름: 공격자가 로컬에서 shell.php 를 zip 압축하여 shell.zip 생성 확장자를 avatar.jpg 로 변경하여 서버의 프로필 이미지 업로드 기능으로 업로드 LFI 취약점이 있는 파라미터에 zip:///var/www/uploads/avatar.jpg%23shell.php 호출 PHP 엔진이 zip 파일의 압축을 풀고 내부의 shell.php 코드를 include하여 실행 (7) expect:// (즉시 쉘 명령어 실행) PHP의 PECL expect 확장 모듈이 활성화되어 있을 때 사용 가능한 래퍼로, 스트림 주소 자체가 곧바로 리눅스 쉘 명령어가 됩니다. 문법 구조 분해: 주소: expect://id 또는: expect://whoami expect:// : Expect 스트림 래퍼 선언 id : 운영체제에서 즉시 실행할 쉘 명령어 요구 조건: 기본 PHP 내장 기능이 아니며, php.ini에 extension=expect.so 가 활성화되어 있어야 함 (CTF 문제 출제자가 일부러 켜두는 경우가 많음) 공격 효과: include($_GET['page']); 에 ?page=expect://ls -la 를 넘기면 다른 우회 기법 없이 그 자리에서 쉘 명령어가 실행되고 결과가 화면에 출력됩니다. 6. 실전 공격 시나리오 요약 공격 상황 사용 래퍼 공격 목적 필요 환경 조건 include() 취약점 존재 php://filter 소스코드 유출 (.php 열람) 기본 설정에서 동작 가능 include() 취약점 존재 php://input RCE (POST 본문으로 코드 주입) allow_url_include = On include() 취약점 존재 data:// RCE (URL 내 인라인 코드 주입) allow_url_include = On 파일 검사 함수(file_exists 등) 존재 phar:// RCE (메타데이터 역직렬화 공격) 파일 업로드 가능 + POP Chain 존재 이미지 업로드만 허용 + LFI 존재 zip:// RCE (jpg 위장 압축 파일 내 웹쉘 실행) 로컬 파일 포함 취약점 include() 취약점 존재 expect:// RCE (직접 쉘 명령어 실행) PECL expect 확장 설치 필요 파일 파서/렌더러 악용 php://filter 체인 위조 헤더 주입 및 데이터 유출 특정 파서(mPDF 등) 존재 7. 방어 가이드 php.ini 설정 강화: allow_url_fopen = Off allow_url_include = Off 안전한 파일 호출: 사용자 입력값을 include, require, file_get_contents 등의 파일 함수에 직접 대입하지 말고, 화이트리스트 기반으로 지정된 파일만 열 수 있도록 통제해야 합니다.