| 일 | 월 | 화 | 수 | 목 | 금 | 토 |
|---|---|---|---|---|---|---|
| 1 | 2 | |||||
| 3 | 4 | 5 | 6 | 7 | 8 | 9 |
| 10 | 11 | 12 | 13 | 14 | 15 | 16 |
| 17 | 18 | 19 | 20 | 21 | 22 | 23 |
| 24 | 25 | 26 | 27 | 28 | 29 | 30 |
| 31 |
- naturalid
- OIDC
- awspring
- redis
- convertAndSendToUser
- oauth2.0
- N + 1
- Commit
- mockito
- MySQLTransactionRollbackException
- intellij
- injellij
- @controller
- spring
- Git
- 정적 팩터리 메서드
- assert
- websocket
- AWS
- ngrinder
- batch insert
- @RequestMapping
- @Transaction(readOnly=true)
- 이펙티브 자바
- 컨트리뷰터
- 프로젝트 이름 변경
- spring-cloud-starter-aws
- JPA
- fetch join
- Cannotacquirelockexception
- Today
- Total
정리정리
프롬프팅에 관한 고찰 본문
프롬프트 엔지니어링이란 단어를 처음 접하면 "엔지니어링"이라는 단어 때문에 조금 거창하게 느껴질 수 있다.
그런데 막상 들여다보면, 결국은 원하는 것을 상대방이 이해할 수 있게 전달하는 법을 이야기하고 있는 것과 크게 다르지 않다는 것을 알 수 있다.
이 글에서는 프롬프트를 어떤 식으로 작성하면 좋을지에 대해 공부하면서, 같은 주제를 두고 어떻게 쓰느냐에 따라 결과가 얼마나 달라지는지를 확인하고 정리해보려고 한다.
좋은 프롬프트를 구성하는 요소들
LLM과 관련된 여러 레퍼런스를 살펴보면, 효과적인 프롬프트에는 공통적으로 담기는 요소들이 있다. 물론 모든 요소를 매번 다 넣어야 하는 건 아니다. 중요한 건 이 요소들이 각각 어떤 역할을 하는지 이해하고, 상황에 맞게 골라서 쓰는 것이다.
맥락 (Context)
프롬프트를 작성할 때 가장 핵심이 되는 요소다. 실제로 요즘은 Prompt Engineering에 이어 Context Engineering이 더 중요해지고 있는 것 같다.
AI에게 좋은 맥락을 제공하려면 최대한 자세하고 명확하게 설명해야 한다. Anthropic 블로그에서는 Claude를 "맥락을 전혀 모르는 똑똑한 신입 직원"에 비유하면서, "이 프롬프트를 맥락을 잘 모르는 동료에게 보여줬을 때 헷갈린다면, AI도 헷갈린다" 고 말한다.
내가 당연하게 여기는 배경지식을 AI는 전혀 모를 수 있다는 점을 명심해야 한다.
역할 (Role)
"마케터로서", "시니어 개발자로서"처럼 AI에게 역할을 부여하는 방식이다. 결국 AI에게 어떤 관점으로 답해주길 원하는지 알려주는 요소다. 같은 질문이라도 역할에 따라 강조점과 깊이가 크게 달라진다.
프롬프트 기법
잘 알려진 프롬프트 기법을 상황에 맞게 활용하는 것도 도움이 된다.
Few-shot은 원하는 결과물의 형태를 먼저 예시로 보여주는 방식이다. AI가 원하는 포맷이나 톤을 정확히 이해하게 해준다.
예를 들어, promptingguide에 예제를 보면 다음과 같다.
[input]
A "whatpu" is a small, furry animal native to Tanzania. An example of a sentence that uses the word whatpu is:
We were traveling in Africa and we saw these very cute whatpus.
To do a "farduddle" means to jump up and down really fast. An example of a sentence that uses the word farduddle is:
[output]
When we won the game, we all started to farduddle in celebration.
예시를 한 개만 넣어줬을 뿐인데, AI는 길이와 구조, 어조를 그대로 따라간다.
Chain of Thought(CoT) 는 원하는 답을 바로 내놓기 전에 사고 과정을 먼저 거치도록 유도하는 방식이다. 복잡한 문제일수록 효과적이다.

"단계별로 생각해"라는 지시 하나만 추가했는데, AI가 중간 계산 과정을 명시적으로 보여주면서 실수를 줄이게 된다.
같은 주제, 두 가지 프롬프트
이를 바탕으로 "변수와 상수의 차이"라는 주제에 대해 질문했을 때, 프롬프트의 구성 요소에 따라 답변이 얼마나 달라지는지 직접 확인해봤다.
첫 번째 프롬프트 — 아무 맥락 없이
Java에서 변수와 상수의 차이를 설명해줘.
Java에서 변수(Variable)와 상수(Constant)의 차이점
변수 (Variable)
값을 변경할 수 있는 저장 공간입니다.
int age = 25;
age = 30; // ✅ 변경 가능
String name = "Alice";
name = "Bob"; // ✅ 변경 가능
상수 (Constant)
final 키워드를 사용해 값을 한 번만 할당할 수 있는 저장 공간입니다. 관례적으로 대문자 + 언더스코어로 이름을 짓습니다.
final int MAX_SIZE = 100;
MAX_SIZE = 200; // ❌ 컴파일 오류 발생!
final double PI = 3.14159;
PI = 3.0; // ❌ 컴파일 오류 발생!
핵심 비교
구분 변수 상수| 키워드 | 없음 | final |
| 값 변경 | 가능 | 불가능 |
| 네이밍 | camelCase | UPPER_SNAKE_CASE |
| 용도 | 변하는 데이터 | 고정된 값 (π, 최대값 등) |
클래스 상수 (static final)
클래스 전체에서 공유하는 상수는 static final을 함께 사용합니다.
public class AppConfig {
public static final String APP_NAME = "MyApp";
public static final int MAX_USERS = 1000;
}
// 어디서든 접근 가능
System.out.println(AppConfig.APP_NAME); // "MyApp"
언제 상수를 써야 할까?
- 매직 넘버 방지 — if (score >= 100) 대신 if (score >= MAX_SCORE)
- 재사용성 — 값을 한 곳에서만 관리
- 가독성 — 숫자보다 이름이 의미를 명확히 전달
역할도, 독자도, 목적도, 형식도 없기 때문에 AI는 가장 일반적인 답변을 한다. 변수와 상수의 정의, 간단한 Java 코드 예시, final 키워드의 용도 정도를 담은 깔끔하지만 평범한 답변이 나온다.
두 번째 프롬프트 — 맥락과 구조를 담아서
역할
너는 10년 이상의 경력을 가진 JVM 전문 엔지니어야. 너는 JVM 내부 동작 원리, 바이트코드 분석, 메모리 구조에 정통하며, 복잡한 개념을 명확한 코드 예시와 함께 설명하는 데 탁월해.
상황
나는 Java 변수와 상수의 표면적인 문법 차이는 알고 있지만, JVM이 내부적으로 이 둘을 어떻게 다르게 처리하는지 깊이 이해하고 싶어. 특히 성능 최적화 관점에서 상수와 변수의 차이점을 확인하고 싶어.
설명 순서
아래 순서대로 단계적으로 사고하며 설명해줘. 각 단계를 건너뛰지 말고, 이전 단계의 개념을 바탕으로 다음 단계를 설명해:
Step 1 — JVM 메모리 구조 매핑에서 각 선언 방식이 JVM의 Stack / Heap / Method Area(MetaSpace) / 상수 풀(Constant Pool) 중 어디에 저장되는지 설명하고, 다이어그램 형태로 시각화해줘.
Step 2 — 컴파일 타임 vs 런타임 처리에서 메모리 배치가 컴파일 시점에 어떻게 결정되는지, `javap -c`로 확인할 수 있는 바이트코드 예시를 들어 변수 처리 방식의 차이를 보여줘.
Step 3 — `final` 참조변수가 왜 진정한 상수가 아닌지 설명해줘. 참조 불변과 객체 상태 불변의 차이를 코드로 증명하고, 완전한 불변 객체(Immutable Object)를 만드는 방법을 제시해줘.
Step 4 — JIT 컴파일러가 상수를 어떻게 최적화하는지 설명해줘. Dead Code Elimination, Constant Folding 등 실제 최적화 기법과 변수 대비 성능 차이를 구체적인 수치나 예시로 보여줘.
출력 형식
- 각 Step의 시작에 "Step N에서 다루는 핵심 질문" 을 한 줄로 명시해줘
- 각 Step 말미에 "이 단계의 핵심 요약" 을 bullet point 3개 이내로 정리해줘
- 전체 답변 마지막에 4단계를 관통하는 한 줄 결론을 작성해줘
JVM 관점에서의 변수 vs 상수: 심층 분석
Step 1 — JVM 메모리 구조 매핑
Step 1에서 다루는 핵심 질문: 각 선언 방식(변수/상수)은 JVM의 어느 메모리 영역에 저장되는가?
분석 대상 코드
public class MemoryDemo {
static final int CONST = 100; // 컴파일 타임 상수
static final String LABEL = "hello"; // String 상수
static int staticVar = 200; // static 변수
int instanceVar = 300; // 인스턴스 변수
void method() {
final int localConst = 42; // 지역 상수
int localVar = 99; // 지역 변수
MemoryDemo obj = new MemoryDemo(); // 객체 생성
}
}
JVM 메모리 배치 다이어그램
┌─────────────────────────────────────────────────────────────────────┐
│ JVM Memory Architecture │
│ │
│ ┌──────────────────────────────────────────────────────────────┐ │
│ │ Method Area (MetaSpace, Java 8+) │ │
│ │ │ │
│ │ ┌─────────────────────────────────────────────────────┐ │ │
│ │ │ Runtime Constant Pool │ │ │
│ │ │ │ │ │
│ │ │ ┌──────────────────────────────────────────────┐ │ │ │
│ │ │ │ String Pool (Interned Strings) │ │ │ │
│ │ │ │ "hello" ──────────────────────────────┐ │ │ │ │
│ │ │ └──────────────────────────────────────────┘ │ │ │ │
│ │ │ │ │ │ │
│ │ │ [#1] CONST = 100 ← int 리터럴 직접 저장 │ │ │ │
│ │ │ [#2] LABEL ref ──────────────────────────────►│ │ │ │
│ │ │ [#3] staticVar = 200 ← 참조(런타임 변경 가능) │ │ │ │
│ │ └─────────────────────────────────────────────────┘ │ │
│ │ │ │
│ │ MemoryDemo.class 메타데이터 │ │
│ │ - 필드 정보: CONST, LABEL, staticVar, instanceVar │ │
│ │ - 메서드 바이트코드: method() │ │
│ └──────────────────────────────────────────────────────────┘ │
│ │
│ ┌──────────────────────────────────────────────────────────────┐ │
│ │ Heap │ │
│ │ │ │
│ │ ┌──────────────────────────────────────────┐ │ │
│ │ │ MemoryDemo 인스턴스 (new MemoryDemo()) │ │ │
│ │ │ ┌────────────────────────────────────┐ │ │ │
│ │ │ │ instanceVar = 300 (int, 4 bytes) │ │ │ │
│ │ │ │ [object header: 12~16 bytes] │ │ │ │
│ │ │ └────────────────────────────────────┘ │ │ │
│ │ └──────────────────────────────────────────┘ │ │
│ │ │ │
│ │ ※ static final int CONST → Heap에 객체 없음! │ │
│ │ 컴파일러가 리터럴로 인라이닝 처리 │ │
│ └──────────────────────────────────────────────────────────────┘ │
│ │
│ ┌──────────────────────────────────────────────────────────────┐ │
│ │ JVM Stack (Thread마다 독립) │ │
│ │ │ │
│ │ ┌──────────────────────────────────────────────────────┐ │ │
│ │ │ Stack Frame: method() 호출 │ │ │
│ │ │ ┌────────────────────────────────────────────────┐ │ │ │
│ │ │ │ Local Variable Array │ │ │ │
│ │ │ │ [0] this → Heap의 MemoryDemo 인스턴스 참조│ │ │ │
│ │ │ │ [1] localConst = 42 ← primitive, 값 직접 저장│ │ │ │
│ │ │ │ [2] localVar = 99 ← primitive, 값 직접 저장│ │ │ │
│ │ │ │ [3] obj → Heap의 새 인스턴스 참조 │ │ │ │
│ │ │ └────────────────────────────────────────────────┘ │ │ │
│ │ │ Operand Stack (연산 중간값) │ │ │
│ │ └──────────────────────────────────────────────────────┘ │ │
│ └──────────────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────────────┘
저장 위치 요약
┌────────────────────────┬────────────────────────────────────────┐
│ 선언 방식 │ 저장 위치 │
├────────────────────────┼────────────────────────────────────────┤
│ static final int │ 컴파일 후 사용처에 인라이닝 → │
│ │ Runtime Constant Pool 참조 자체가 제거 │
├────────────────────────┼────────────────────────────────────────┤
│ static final String │ String Pool (Heap 내 별도 관리 영역) │
├────────────────────────┼────────────────────────────────────────┤
│ static int (변수) │ Method Area (클래스 로딩 시 할당) │
├────────────────────────┼────────────────────────────────────────┤
│ int instanceVar │ Heap (객체 생성 시 함께 할당) │
├────────────────────────┼────────────────────────────────────────┤
│ final int localConst │ Stack Frame의 Local Variable Array │
├────────────────────────┼────────────────────────────────────────┤
│ int localVar │ Stack Frame의 Local Variable Array │
└────────────────────────┴────────────────────────────────────────┘
핵심 포인트: static final int 처럼 primitive + 리터럴 조합의 상수는 사용하는 클래스의 바이트코드에 값이 직접 박혀버린다(인라이닝). 런타임에 상수 풀을 참조하는 과정 자체가 없어진다.
이 단계의 핵심 요약
- primitive static final은 컴파일 타임에 인라이닝되어 런타임 메모리 참조가 발생하지 않음
- 인스턴스 변수는 Heap, 지역 변수/상수는 Stack에 저장되며 저장 위치는 타입이 아닌 선언 위치로 결정됨
- static final String은 String Pool을 통해 Heap에 단 하나의 인스턴스만 유지됨
Step 2 — 컴파일 타임 vs 런타임 처리
Step 2에서 다루는 핵심 질문: 상수 인라이닝은 바이트코드 레벨에서 실제로 어떻게 보이는가?
분석 대상 코드
public class BytecodeDemo {
static final int CONST_VAL = 100; // 컴파일 타임 상수
static int runtimeVar = 100; // 런타임 변수
public static int useConst() {
return CONST_VAL * 2; // 상수 사용
}
public static int useVar() {
return runtimeVar * 2; // 변수 사용
}
}
javap -c 바이트코드 비교
// ──────────────────────────────────────────────────
// javap -c BytecodeDemo
// ──────────────────────────────────────────────────
public static int useConst();
Code:
0: bipush 100 // ← CONST_VAL = 100이 리터럴로 직접 삽입됨!
// getstatic 명령어가 없다 = 메모리 참조 없음
2: iconst_2 // 상수 2를 스택에 push
3: imul // 곱셈 수행
4: ireturn
// ──────────────────────────────────────────────────
public static int useVar();
Code:
0: getstatic #7 // ← Field runtimeVar:I
// Method Area에서 값을 가져오는 명령어 실행
// 메모리 참조가 반드시 발생!
3: iconst_2
4: imul
5: ireturn
인라이닝의 파급 효과 — 크로스 클래스 컴파일
// 파일 1: Constants.java
public class Constants {
public static final int MAX = 500;
}
// 파일 2: App.java
public class App {
public static void main(String[] args) {
System.out.println(Constants.MAX); // 상수 참조
}
}
// App.class의 바이트코드 (javap -c App)
public static void main(java.lang.String[]);
Code:
0: getstatic #7 // PrintStream 참조
3: sipush 500 // ← Constants.MAX가 500으로 직접 인라이닝됨!
// Constants 클래스를 로드조차 안 할 수도 있다
6: invokevirtual #13 // println
9: return
⚠️ 실무 함정: Constants.MAX를 500 → 1000으로 바꾸고 Constants.java만 재컴파일하면, App.class에는 여전히 500이 박혀있다. App.java도 반드시 함께 재컴파일해야 한다.
컴파일 타임 상수 판별 조건
public class CompileTimeCheck {
// ✅ 컴파일 타임 상수 (인라이닝 O)
static final int A = 10;
static final int B = 20;
static final int C = A + B; // 상수끼리의 연산도 인라이닝
static final String S = "Hi" + "!"; // 문자열 리터럴 연결도 인라이닝
// ❌ 컴파일 타임 상수 아님 (인라이닝 X)
static final int D = new Random().nextInt(); // 런타임에 값 결정
static final String T = new String("Hi"); // new 키워드 사용
static final int[] ARR = {1, 2, 3}; // 배열 (참조 타입)
final int E; // 생성자에서 초기화 → 컴파일 타임에 값 미확정
}
// 바이트코드에서의 차이
// C = A + B → static final int C = 30
ldc #30 // 30으로 인라이닝
// D = new Random().nextInt()
new #Random
invokespecial <init>
invokevirtual nextInt // 매번 실행마다 메서드 호출
이 단계의 핵심 요약
- static final primitive/String 리터럴은 getstatic 없이 피연산자 스택에 값이 직접 push됨 → 메모리 참조 비용 제로
- 상수끼리의 산술/문자열 연결 연산도 컴파일러가 미리 계산해 단일 리터럴로 치환함
- 다른 클래스의 상수를 인라이닝한 후 원본만 재컴파일하면 불일치 버그 발생 → 전체 재빌드 필수
Step 3 — 불변성의 함정
Step 3에서 다루는 핵심 질문: final 참조변수는 왜 진정한 상수가 아닌가?
참조 불변 ≠ 객체 상태 불변
import java.util.ArrayList;
import java.util.List;
public class FinalTrap {
// final은 "이 변수가 다른 객체를 가리키는 것"을 막을 뿐이다
static final List<String> NAMES = new ArrayList<>();
public static void main(String[] args) {
// ❌ 참조 변경 → 컴파일 에러
// NAMES = new ArrayList<>(); // cannot assign a value to final variable
// ✅ 객체 내부 상태 변경 → 완벽하게 허용됨!
NAMES.add("Alice");
NAMES.add("Bob");
NAMES.remove("Alice");
System.out.println(NAMES); // [Bob]
// ──────────────────────────────────────────
// JVM 메모리 관점에서 보면:
//
// Stack/Method Area Heap
// ┌──────────────┐ ┌────────────────────┐
// │ NAMES = ref ─┼─────────►│ ArrayList 인스턴스 │
// │ (변경 불가) │ │ ["Bob"] │
// └──────────────┘ │ (변경 자유) │
// └────────────────────┘
//
// final이 잠그는 것: 화살표(참조값)
// final이 못 잠그는 것: 화살표가 가리키는 객체의 내부
// ──────────────────────────────────────────
}
}
완전한 불변 객체(Immutable Object) 설계
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
// 불변 객체의 5가지 원칙을 모두 적용한 예시
public final class ImmutableOrder { // 1. 클래스를 final → 상속 차단
private final String orderId; // 2. 모든 필드를 private final
private final int amount;
private final List<String> items; // 3. 가변 참조 타입 필드는 방어적 복사 필요
public ImmutableOrder(String orderId, int amount, List<String> items) {
this.orderId = orderId;
this.amount = amount;
// 4. 생성자에서 방어적 복사 (외부 리스트 변경이 내부에 영향 못 주도록)
this.items = new ArrayList<>(items);
}
public String getOrderId() { return orderId; }
public int getAmount() { return amount; }
// 5. getter에서도 방어적 복사 (내부 리스트를 직접 반환하지 않음)
public List<String> getItems() {
return Collections.unmodifiableList(items);
}
// ❌ setter 없음
}
// 방어적 복사의 필요성 증명
public class DefensiveCopyDemo {
public static void main(String[] args) {
List<String> original = new ArrayList<>(List.of("A", "B"));
ImmutableOrder order = new ImmutableOrder("ORD-001", 5000, original);
// 외부에서 original 리스트를 변조 시도
original.add("C");
// 방어적 복사 덕분에 order 내부는 변하지 않음
System.out.println(order.getItems()); // [A, B] ← C가 없다!
// getter가 반환한 리스트 변조 시도
List<String> returned = order.getItems();
try {
returned.add("D"); // UnsupportedOperationException 발생!
} catch (UnsupportedOperationException e) {
System.out.println("외부에서 변경 불가 확인");
}
}
}
record를 활용한 현대적 불변 객체 (Java 16+)
// record는 위의 5가지 원칙을 컴파일러가 자동 적용
// final 클래스, private final 필드, 생성자, getter 자동 생성
public record ImmutablePoint(int x, int y) {
// 가변 필드가 있다면 compact constructor로 방어적 복사
// public ImmutablePoint { items = List.copyOf(items); }
}
// 사용
ImmutablePoint p = new ImmutablePoint(3, 4);
// p.x = 10; ❌ 컴파일 에러
System.out.println(p.x()); // 3
이 단계의 핵심 요약
- final은 참조값(메모리 주소) 의 변경만 막으며, 참조가 가리키는 객체의 상태는 무방비 상태
- 진정한 불변 객체를 위해서는 final 클래스 + private final 필드 + 방어적 복사(생성자/getter) 3박자가 필요
- Java 16+ record는 불변 객체 보일러플레이트를 컴파일러 수준에서 자동화함
Step 4 — JIT 최적화와 성능
Step 4에서 다루는 핵심 질문: JIT 컴파일러는 상수를 활용해 런타임에 어떤 추가 최적화를 수행하는가?
JIT가 개입하는 시점
소스코드 → [javac] → 바이트코드(.class) → [인터프리터] → 실행
↓
호출 횟수 임계치 초과
(기본값 ~10,000회)
↓
[JIT 컴파일러]
→ 네이티브 머신코드
→ 최적화 적용
최적화 기법 1: Constant Folding (상수 접기)
public class ConstantFolding {
static final int WIDTH = 1920;
static final int HEIGHT = 1080;
public static int getPixelCount() {
// 개발자 눈에는 곱셈처럼 보이지만...
return WIDTH * HEIGHT;
}
}
// JIT 최적화 후 실제 실행되는 의사 코드
public static int getPixelCount() {
return 2073600; // 1920 * 1080을 컴파일/JIT 타임에 미리 계산
// 런타임 곱셈 연산: 0회
}
최적화 기법 2: Dead Code Elimination (죽은 코드 제거)
public class DeadCodeDemo {
static final boolean DEBUG = false; // 배포 환경
public static void process(String data) {
// JIT는 DEBUG가 절대 true가 될 수 없음을 알고 있다
// → 이 블록 전체를 네이티브 코드 생성 시 아예 제외
if (DEBUG) {
System.out.println("Processing: " + data); // ← 생성된 기계어 없음
validateInput(data); // ← 생성된 기계어 없음
}
// 실제 실행 코드만 네이티브로 변환됨
doActualWork(data);
}
}
// 변수였다면? 매 실행마다 분기 비용 발생
public class DeadCodeWithVar {
static boolean debug = false; // final 없음
public static void process(String data) {
// JIT가 "항상 false"를 보장할 수 없음
// → 분기문(branch instruction) 이 네이티브 코드에 반드시 포함됨
// → CPU 분기 예측 비용 발생 가능
if (debug) {
System.out.println("Processing: " + data);
}
doActualWork(data);
}
}
최적화 기법 3: Loop Unrolling + Constant 조합
public class LoopOptimization {
static final int BATCH_SIZE = 4; // 상수
public static long sumArray(int[] arr) {
long sum = 0;
for (int i = 0; i < arr.length; i++) {
sum += arr[i];
}
return sum;
}
}
// BATCH_SIZE 같은 상수를 JIT가 인지하면
// Loop Unrolling 최적화 적용 가능:
// 원본 루프 (4번 반복)
sum += arr[0]; sum += arr[1]; sum += arr[2]; sum += arr[3];
// JIT가 펼쳐서 생성한 의사 네이티브 코드:
// → 루프 조건 체크(i < length) 4번 → 1번으로 감소
// → 분기 명령어 4번 → 1번으로 감소
// → CPU 파이프라인 효율 극대화
성능 비교 벤치마크 (JMH 기반)
import org.openjdk.jmh.annotations.*;
import java.util.concurrent.TimeUnit;
@BenchmarkMode(Mode.AverageTime)
@OutputTimeUnit(TimeUnit.NANOSECONDS)
@State(Scope.Thread)
public class ConstVsVarBenchmark {
// 상수
static final int CONST_MULTIPLIER = 1000;
// 변수
static int varMultiplier = 1000;
int value = 42;
@Benchmark
public int multiplyConst() {
return value * CONST_MULTIPLIER; // JIT: Constant Folding 적용
}
@Benchmark
public int multiplyVar() {
return value * varMultiplier; // JIT: 매번 메모리에서 읽어야 함
}
}
// 일반적인 JMH 실행 결과 (참고값, 환경마다 다름)
//
// ┌───────────────────────────────┬──────────────┬────────────┐
// │ Benchmark │ Score(ns/op)│ 비고 │
// ├───────────────────────────────┼──────────────┼────────────┤
// │ multiplyConst (static final) │ ~0.3 ns │ 레지스터 │
// │ │ │ 상수 직접 │
// │ │ │ 연산 │
// ├───────────────────────────────┼──────────────┼────────────┤
// │ multiplyVar (static int) │ ~1.2 ns │ 메모리 │
// │ │ │ 접근 포함 │
// └───────────────────────────────┴──────────────┴────────────┘
//
// → 단순 산술에서도 약 4배 차이
// 핫루프(10억회 반복)에서는 이 차이가 초 단위로 누적됨
JIT 최적화 체인 전체 그림
static final int LIMIT = 100;
for (int i = 0; i < LIMIT; i++) {
if (LIMIT > 50) { // 항상 true
process(i);
}
}
↓ JIT 최적화 체인
① Constant Folding: LIMIT > 50 → true
② Dead Code Elim: if(true) 제거 → 분기문 삭제
③ Loop Unrolling: i < 100 반복 구조 펼치기
④ Inlining: process()가 단순하면 호출 오버헤드 제거
// 최종 네이티브 코드 의사 표현:
process(0); process(1); process(2); ... // 분기 없는 직선 코드
이 단계의 핵심 요약
- JIT는 static final을 통해 값이 절대 불변임을 확신하므로 Constant Folding, Dead Code Elimination을 공격적으로 적용 가능
- static 변수는 다른 스레드가 언제든 변경할 수 있어 JIT가 동일한 최적화를 적용하지 못함
- 최적화들은 단독이 아닌 체인으로 작동하며, 상수 하나가 이후 Inlining/Loop Unrolling까지 연쇄적으로 트리거함
전체 결론
상수(static final)는 단순한 문법 제약이 아니라, 컴파일러에게는 인라이닝 신호를, JVM 메모리 구조에는 참조 비용 제거를, JIT에게는 무제한 최적화 허가증을 동시에 부여하는 성능 계약이다. 단, final 참조 타입은 이 계약의 적용 범위 밖에 있으므로 방어적 복사까지 갖춰야 비로소 완전한 불변성이 달성된다.
결과는 비교할 수 없을 만큼 달라진다. JVM 메모리 구조 다이어그램, javap -c 바이트코드 비교, final 참조변수의 함정, JIT 최적화 체인까지 같은 주제인데도 전혀 다른 깊이의 답변이 나온다.
이 두 프롬프트의 차이를 만든 요소들을 정리하면 다음과 같다.
- 역할: 단순히 "시니어 개발자"가 아닌 "JVM 전문 엔지니어"로 특정지어, 답변의 관점을 JVM과 메모리로 한정했다.
- 상황 (맥락): 내가 어디까지 알고 있는지를 명확히 전달해 AI가 불필요한 기초 설명을 건너뛰고 핵심에 집중하게 했다.
- 설명 순서 (CoT): 단계적으로 사고하도록 유도해 각 개념이 유기적으로 연결되는 구조적인 답변을 이끌어냈다.
- 출력 형식: 원하는 형태를 미리 지정해 답변을 읽기 쉽고 참고하기 좋게 만들었다.
현실에서의 질문과 프롬프트
프롬프트 엔지니어링에 대한 글을 보다 보면, 처음 만나는 사람에게 질문하는 법과 본질적으로 다르지 않다는 생각이 든다.
질문이 제대로 전달되려면 최소한의 전제를 먼저 공유해야 한다. "이거 어떻게 해요?", “이거 왜 안 돼요?” 라는 질문은 맥락 없이는 아무것도 전달하지 못한다.
특히 AI는 처음 만난 사람과 같다고 생각한다. 나에 대한 맥락이 없기 때문에 내가 먼저 채워줘야 한다. 그리고 그 맥락을 잘 설정하려면 결국 내가 무엇을 알고 모르는지, 무엇을 알고싶어 하는지, 즉 메타인지가 중요한 것 같다.
참고자료
https://www.promptingguide.ai/techniques/fewshot
Few-Shot Prompting – Nextra
A Comprehensive Overview of Prompt Engineering
www.promptingguide.ai
Prompting best practices
Comprehensive guide to prompt engineering techniques for Claude's latest models, covering clarity, examples, XML structuring, thinking, and agentic systems.
platform.claude.com