volatile과 AtomicInteger
가시성과 원자성을 분리해 증가 손실을 재현하고, AtomicInteger 연산으로 공유 카운터의 선형화 지점과 확인 항목을 세웁니다.
두 스레드가 같은 int를 천 번씩 증가시키면 최종값이 2,000일 것이라 기대합니다.
그러나 value++는 읽기, 덧셈, 쓰기의 세 단계이며 두 스레드가 같은 이전 값을 읽으면 한 증가가 덮어써집니다.
volatile 쓰기는 같은 필드의 뒤따르는 읽기와 happens-before 관계를 만들지만, 읽기·덧셈·쓰기를 하나의 사건으로 합치지는 않습니다. 여기서 앞뒤는 동기화 순서를 기준으로 합니다.
공유 값이 단순 카운터라면 전체 메서드를 잠그기보다 AtomicInteger가 의도를 더 직접적으로 표현합니다.
원자 클래스의 증가 메서드는 한 연산처럼 관찰되는 선형화 지점을 제공합니다.
다만 카운터와 다른 필드를 함께 바꿔야 한다면 원자 변수 하나로 복합 불변식까지 보호되지는 않습니다.
volatile 증가와 값 손실
다음 코드는 두 작업자가 같은 시작 래치를 통과하게 합니다. 실제로 동시에 실행된다는 보장은 없습니다.
이번 실행은 expected=1000000, actual=686911을 출력했습니다. 손실량은 실행마다 달라질 수 있으며, 다른 실행에서 기대 총량이 나와도 ++의 원자성이 증명되는 것은 아닙니다.
import java.util.concurrent.CountDownLatch;
public final class VolatileLostUpdate {
private volatile int value;
void increment() { value++; }
public static void main(String[] args) throws InterruptedException {
VolatileLostUpdate counter = new VolatileLostUpdate();
CountDownLatch start = new CountDownLatch(1);
Runnable task = () -> {
try { start.await(); }
catch (InterruptedException e) { Thread.currentThread().interrupt(); return; }
for (int i = 0; i < 500_000; i++) {
counter.increment();
}
};
Thread a = Thread.ofPlatform().start(task);
Thread b = Thread.ofPlatform().start(task);
start.countDown();
a.join();
b.join();
System.out.println("expected=1000000 actual=" + counter.value);
}
}두 번의 증가가 1을 남기는 가정 순서
두 읽기 0 뒤 두 쓰기 1로 증가가 유실되는 가능한 끼어듦입니다. 각 스레드의 관측값과 공유 값 0→1→1을 단계로 추적합니다.
value가0인 상태에서 두 작업자가 각각0을읽고1을쓰는 가능한 끼어듦을 설명합니다. 실제 실행 추적이 아닙니다.
가능한 끼어듦 · 읽기와 쓰기
두 번의 증가가 1을 남기는 가정 순서
두 작업자가 같은 0을 읽고 각각 1을 쓰면 두 증가 뒤에도 값은 1입니다.
T1 읽기
- 각 작업자의 읽기·쓰기
- 0을 읽어 자기 계산에 사용
- 그 직후 공유 value
- 0
T2 읽기
- 각 작업자의 읽기·쓰기
- T1이 쓰기 전에 0을 읽음
- 그 직후 공유 value
- 0
T1 쓰기
- 각 작업자의 읽기·쓰기
- 읽었던 0에 1을 더해 기록
- 그 직후 공유 value
- 1
T2 쓰기
- 각 작업자의 읽기·쓰기
- 자신도 0에 1을 더해 기록
- 그 직후 공유 value
- 1 · 두 증가 중 하나가 반영되지 않음
원문은 두 작업자가 각 500,000번 증가한 최종값만 출력합니다. 이 네 단계는 가능한 끼어듦의 예이며, 실제 손실량이나 손실 발생을 고정하지 않습니다.
이 예의 조건
- 실제 로그가 아닌 가능한 끼어듦 순서입니다.
관계도의 보충 설명
| 구분 | 내용 |
|---|---|
| 2 | 시간은 위에서 아래로 흐른다. 화살표는 읽은 값 또는 쓴 값의 이동을 나타낸다. 공유 값 0에서 T1과 T2가 각각 0을 읽고 각각 1을 써서 최종 1을 남긴다. 실제 로그가 아닌 가능한 순서다. |
| 구분 | 내용 |
|---|---|
| 설명 | 시간은 위에서 아래로 흐른다. 화살표는 읽은 값 또는 쓴 값의 이동을 나타낸다. 공유 값 0에서 T1과 T2가 각각 0을 읽고 각각 1을 써서 최종 1을 남긴다. 실제 로그가 아닌 가능한 순서다. |
| 전체 읽기 정보 | 두 번의 증가가 1을 남기는 가정 순서 두 번의 증가가 1을 남기는 가정 순서 value가 0인 상태에서 두 작업자가 각각 0을 읽고 1을 쓰는 가능한 끼어듦을 설명합니다. 실제 실행 추적이 아닙니다. 가능한 끼어듦 · 읽기와 쓰기 두 번의 증가가 1을 남기는 가정 순서 두 작업자가 같은 0을 읽고 각각 1을 쓰면 두 증가 뒤에도 값은 1입니다. T1 읽기 각 작업자의 읽기·쓰기 0을 읽어 자기 계산에 사용 그 직후 공유 value 0 T2 읽기 각 작업자의 읽기·쓰기 T1이 쓰기 전에 0을 읽음 그 직후 공유 value 0 T1 쓰기 각 작업자의 읽기·쓰기 읽었던 0에 1을 더해 기록 그 직후 공유 value 1 T2 쓰기 각 작업자의 읽기·쓰기 자신도 0에 1을 더해 기록 그 직후 공유 value 1 · 두 증가 중 하나가 반영되지 않음 원문은 두 작업자가 각 500,000번 증가한 최종값만 출력합니다. 이 네 단계는 가능한 끼어듦의 예이며, 실제 손실량이나 손실 발생을 고정하지 않습니다. |
| 관찰·상황 | 값·보충 정보 | 변화·이유 |
|---|---|---|
| 두 읽기 T1과 T2가 쓰기 전에 공유 값 0을 각각 읽습니다. |
|
|
| 첫 쓰기 T1이 관측값에 1을 더해 씁니다. |
|
|
| 뒤 쓰기 T2도 1을 씁니다. |
|
|
두 번의 증가가 1을 남기는 가정 순서
두 읽기 0 뒤 두 쓰기 1로 증가가 유실되는 가능한 끼어듦입니다. 각 스레드의 관측값과 공유 값 0→1→1을 단계로 추적합니다.
value가0인 상태에서 두 작업자가 각각0을읽고1을쓰는 가능한 끼어듦을 설명합니다. 실제 실행 추적이 아닙니다.
가능한 끼어듦 · 읽기와 쓰기
두 번의 증가가 1을 남기는 가정 순서
두 작업자가 같은 0을 읽고 각각 1을 쓰면 두 증가 뒤에도 값은 1입니다.
T1 읽기
- 각 작업자의 읽기·쓰기
- 0을 읽어 자기 계산에 사용
- 그 직후 공유 value
- 0
T2 읽기
- 각 작업자의 읽기·쓰기
- T1이 쓰기 전에 0을 읽음
- 그 직후 공유 value
- 0
T1 쓰기
- 각 작업자의 읽기·쓰기
- 읽었던 0에 1을 더해 기록
- 그 직후 공유 value
- 1
T2 쓰기
- 각 작업자의 읽기·쓰기
- 자신도 0에 1을 더해 기록
- 그 직후 공유 value
- 1 · 두 증가 중 하나가 반영되지 않음
원문은 두 작업자가 각 500,000번 증가한 최종값만 출력합니다. 이 네 단계는 가능한 끼어듦의 예이며, 실제 손실량이나 손실 발생을 고정하지 않습니다.
이 예의 조건
- 실제 로그가 아닌 가능한 끼어듦 순서입니다.
관계도의 보충 설명
| 구분 | 내용 |
|---|---|
| 2 | 시간은 위에서 아래로 흐른다. 화살표는 읽은 값 또는 쓴 값의 이동을 나타낸다. 공유 값 0에서 T1과 T2가 각각 0을 읽고 각각 1을 써서 최종 1을 남긴다. 실제 로그가 아닌 가능한 순서다. |
| 구분 | 내용 |
|---|---|
| 설명 | 시간은 위에서 아래로 흐른다. 화살표는 읽은 값 또는 쓴 값의 이동을 나타낸다. 공유 값 0에서 T1과 T2가 각각 0을 읽고 각각 1을 써서 최종 1을 남긴다. 실제 로그가 아닌 가능한 순서다. |
| 전체 읽기 정보 | 두 번의 증가가 1을 남기는 가정 순서 두 번의 증가가 1을 남기는 가정 순서 value가 0인 상태에서 두 작업자가 각각 0을 읽고 1을 쓰는 가능한 끼어듦을 설명합니다. 실제 실행 추적이 아닙니다. 가능한 끼어듦 · 읽기와 쓰기 두 번의 증가가 1을 남기는 가정 순서 두 작업자가 같은 0을 읽고 각각 1을 쓰면 두 증가 뒤에도 값은 1입니다. T1 읽기 각 작업자의 읽기·쓰기 0을 읽어 자기 계산에 사용 그 직후 공유 value 0 T2 읽기 각 작업자의 읽기·쓰기 T1이 쓰기 전에 0을 읽음 그 직후 공유 value 0 T1 쓰기 각 작업자의 읽기·쓰기 읽었던 0에 1을 더해 기록 그 직후 공유 value 1 T2 쓰기 각 작업자의 읽기·쓰기 자신도 0에 1을 더해 기록 그 직후 공유 value 1 · 두 증가 중 하나가 반영되지 않음 원문은 두 작업자가 각 500,000번 증가한 최종값만 출력합니다. 이 네 단계는 가능한 끼어듦의 예이며, 실제 손실량이나 손실 발생을 고정하지 않습니다. |
| 관찰·상황 | 값·보충 정보 | 변화·이유 |
|---|---|---|
| 두 읽기 T1과 T2가 쓰기 전에 공유 값 0을 각각 읽습니다. |
|
|
| 첫 쓰기 T1이 관측값에 1을 더해 씁니다. |
|
|
| 뒤 쓰기 T2도 1을 씁니다. |
|
|
synchronized increment()도 해결책이지만 단일 수치 연산에는 원자 클래스가 더 좁은 책임을 갖습니다.
공유 상태 요구의 세 측면
- 가시성은 동기화 관계에 따라 다른 스레드의 쓰기를 어떤 읽기가 관찰할 수 있는지 다룬다.
- 원자성은 연산의 중간 단계에 다른 참여자가 끼어들 수 없는 하나의 사건을 뜻한다.
- 순서성은 컴파일러와 CPU 재배치에도 앞뒤 관계가 유지되는지 설명한다.
volatile읽기와 쓰기는 가시성과happens-before관계를 제공하지만 복합 증가를 묶지 않는다.AtomicInteger.incrementAndGet은 증가와 결과 반환을 한 원자 연산으로 제공한다.- 여러 필드의 합계 같은 불변식에는 공통 락이나 하나의 불변 객체 교체가 필요하다.
원자 변수의 메서드 이름도 결과 형식을 드러냅니다.
getAndIncrement는 이전 값을, incrementAndGet은 증가한 값을 반환합니다.
순번 발급에는 이전 값이 필요한지 새 값이 필요한지 먼저 정해야 경계값 오류를 막습니다.
원자 카운터와 총량 검증
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.atomic.AtomicInteger;
public final class AtomicRequestCounter {
private final AtomicInteger accepted = new AtomicInteger();
private final AtomicInteger rejected = new AtomicInteger();
void record(boolean success) {
if (success) accepted.incrementAndGet();
else rejected.incrementAndGet();
}
int total() { return accepted.get() + rejected.get(); }
public static void main(String[] args) throws InterruptedException {
AtomicRequestCounter counter = new AtomicRequestCounter();
List<Thread> workers = new ArrayList<>();
for (int worker = 0; worker < 4; worker++) {
int id = worker;
workers.add(Thread.ofPlatform().start(() -> {
for (int i = 0; i < 25_000; i++) {
counter.record((i + id) % 5 != 0);
}
}));
}
for (Thread worker : workers) {
worker.join();
}
System.out.println("accepted=" + counter.accepted.get());
System.out.println("rejected=" + counter.rejected.get());
System.out.println("total=" + counter.total());
}
}이번 출력은 accepted=80000, rejected=20000, total=100000입니다.
두 원자 변수의 개별 증가는 안전하지만 total()이 둘을 읽는 사이 다른 스레드가 기록할 수 있어 실행 중 스냅샷은 한 순간의 일관된 쌍이 아닐 수 있습니다.
모든 작업 종료 후 읽는 검증에서는 문제가 없고, 실시간 일관된 쌍이 필요하면 하나의 상태 객체를 원자적으로 교체합니다.
예시
실제 쌍이 (accepted=1, rejected=0) → (2, 0) → (2, 1)로 바뀐다고 가정하세요. total()이 첫 단계에서 accepted=1을 읽고 마지막 단계에서 rejected=1을 읽으면 합계는 2입니다. 두 값 각각은 유효한 원자 읽기이지만, 실제로 존재한 적 없는 (1, 1)을 합쳤습니다. AtomicOutcomeSnapshot에서는 Counts 참조 하나를 읽어 그 객체의 두 값을 사용하므로 이런 섞임을 피합니다. 원 main은 join 뒤 읽으므로 이 중간 순서를 강제하거나 관측한 예제는 아닙니다.
복합 상태의 불변 스냅샷 교체
import java.util.concurrent.atomic.AtomicReference;
public final class AtomicOutcomeSnapshot {
record Counts(long accepted, long rejected) {
Counts add(boolean success) {
return success ? new Counts(accepted + 1, rejected)
: new Counts(accepted, rejected + 1);
}
long total() { return accepted + rejected; }
}
private final AtomicReference<Counts> state =
new AtomicReference<>(new Counts(0, 0));
void record(boolean success) {
state.updateAndGet(current -> current.add(success));
}
Counts snapshot() { return state.get(); }
public static void main(String[] args) throws InterruptedException {
AtomicOutcomeSnapshot metrics = new AtomicOutcomeSnapshot();
Thread a = Thread.ofPlatform().start(() -> {
for (int i = 0; i < 10_000; i++) {
metrics.record(true);
}
});
Thread b = Thread.ofPlatform().start(() -> {
for (int i = 0; i < 2_000; i++) {
metrics.record(false);
}
});
a.join();
b.join();
Counts counts = metrics.snapshot();
System.out.println(counts);
System.out.println("total=" + counts.total());
}
}AtomicRequestCounter의 독립된 두 AtomicInteger 읽기와 AtomicOutcomeSnapshot의 하나의 불변 Counts 참조 읽기를 비교합니다.
원자적 읽기 · 상태의 묶음
두 개의 get과 하나의 상태 참조를 구별한다
두 카운터의 개별 읽기와 불변 Counts 한 개의 읽기는 서로 다른 경계입니다.
AtomicRequestCounter의 total()
- 원문이 읽는 값
- accepted.get() 뒤 rejected.get()
- 동시 갱신 중의 의미
- 두 읽기는 별도 연산이므로 한 시점의 쌍을 보장하지 않음
AtomicOutcomeSnapshot의 snapshot()
- 원문이 읽는 값
- state.get()이 반환한 Counts 한 개
- 동시 갱신 중의 의미
- accepted와 rejected가 같은 불변 객체에 들어 있음
두 main은 모든 작업자를 join한 뒤 최종값을 읽습니다. 실행 중 쌍의 불일치나 갱신 함수의 재시도 횟수를 관측하는 코드는 없습니다.
이번 출력은 Counts[accepted=10000, rejected=2000]과 total=12000입니다.
갱신 함수는 충돌 시 여러 번 실행될 수 있으므로 로그나 외부 I/O 같은 부수 효과를 넣으면 안 됩니다.
순수하게 새 값만 계산해야 합니다.
가시성·원자성·불변식에 맞는 도구 선택
| 요구 | 적합한 도구 | 이유 |
|---|---|---|
| 공유 설정 플래그 | volatile | 같은 필드의 읽기·쓰기 동기화 |
| 독립 카운터 증가 | AtomicInteger | 원자 증가 연산 |
| 여러 값 일관 스냅샷 | AtomicReference | 불변 상태 전체 교체 |
| 큰 임계 영역 | Lock | 복수 동작 묶기 |
| 경합이 많은 통계 누적 | LongAdder | 동시 갱신 중 sum은 원자 스냅샷이 아님 |
연습 문제
첫 순번이 100이고 최대 102까지만 발급되는 생성기를 작성하세요.
여러 스레드가 호출해도 100, 101, 102가 각각 한 번만 나오고 이후 호출은 빈 결과여야 합니다.
정답과 해설
상한 검사와 증가를 분리하면 두 스레드가 같은 마지막 값을 통과할 수 있습니다.
getAndUpdate가 반환한 이전 값이 범위 안인지 판단합니다. 발급 상한 102를 넘긴 다음 상태는 103으로 고정합니다.
import java.util.OptionalInt;
import java.util.concurrent.atomic.AtomicInteger;
public final class BoundedSequenceSolution {
private final AtomicInteger next = new AtomicInteger(100);
OptionalInt issue() {
int value = next.getAndUpdate(current -> current <= 102 ? current + 1 : current);
return value <= 102 ? OptionalInt.of(value) : OptionalInt.empty();
}
public static void main(String[] args) throws InterruptedException {
BoundedSequenceSolution sequence = new BoundedSequenceSolution();
Thread[] threads = new Thread[5];
for (int i = 0; i < threads.length; i++) {
threads[i] = Thread.ofPlatform().start(() -> System.out.println(sequence.issue()));
}
for (Thread thread : threads) {
thread.join();
}
}
}반환할 이전 값과 다음 상태 103을 나눈다
반환 100/101/102와 다음 상태 101/102/103, 이후 빈 결과·상태 유지입니다. 출력 순서와 분리된 선형화 상태를 같은 카운터로 추적합니다.
getAndUpdate가 반환하는 이전 값으로100부터102를발급하고103에서는빈결과를반환하는 답안의 경계를 보여줍니다.
발급 순서 · 이전 값과 다음 상태
반환할 이전 값과 다음 상태 103을 나눈다
100·101·102는 한 번씩 발급하고, 다음 상태 103에서는 더 발급하지 않습니다.
설명과 원래 예시 값
이전 값 100
- 갱신 뒤 next
- 101
- issue()의 반환
- OptionalInt[100]
이전 값 101
- 갱신 뒤 next
- 102
- issue()의 반환
- OptionalInt[101]
이전 값 102
- 갱신 뒤 next
- 103
- issue()의 반환
- OptionalInt[102]
이전 값 103
- 갱신 뒤 next
- 103 · 상태 유지
- issue()의 반환
- OptionalInt.empty
이번 출력은 빈 결과 두 개 뒤 102, 101, 100 순이었습니다. 표는 발급 연산의 상태 순서이며 println의 출력 순서가 아닙니다. 정상 완료한 5회 호출의 결과는 실값 세 개와 빈 결과 두 개입니다.
이 예의 조건
- 발급 연산 상태 순서이며 println 순서는 아닙니다. 정상 5회 호출입니다.
관계도의 보충 설명
| 구분 | 내용 |
|---|---|
| 2 | getAndUpdate가 반환하는 이전 값으로 100부터 102를 발급하고 103에서는 빈 결과를 반환하는 답안의 경계를 보여줍니다. |
| 구분 | 내용 |
|---|---|
| 설명 | |
| 전체 읽기 정보 | 반환할 이전 값과 다음 상태 103을 나눈다 반환할 이전 값과 다음 상태 103을 나눈다 getAndUpdate가 반환하는 이전 값으로 100부터 102를 발급하고 103에서는 빈 결과를 반환하는 답안의 경계를 보여줍니다. 발급 순서 · 이전 값과 다음 상태 반환할 이전 값과 다음 상태 103을 나눈다 100·101·102는 한 번씩 발급하고, 다음 상태 103에서는 더 발급하지 않습니다. 이전 값 100 갱신 뒤 next 101 issue()의 반환 OptionalInt[100] 이전 값 101 갱신 뒤 next 102 issue()의 반환 OptionalInt[101] 이전 값 102 갱신 뒤 next 103 issue()의 반환 OptionalInt[102] 이전 값 103 갱신 뒤 next 103 · 상태 유지 issue()의 반환 OptionalInt.empty 이번 출력은 빈 결과 두 개 뒤 102, 101, 100 순이었습니다. 표는 발급 연산의 상태 순서이며 println의 출력 순서가 아닙니다. 정상 완료한 5회 호출의 결과는 실값 세 개와 빈 결과 두 개입니다. |
| 관찰·상황 | 값·보충 정보 | 변화·이유 |
|---|---|---|
| 첫 두 발급 getAndUpdate의 이전 값을 반환합니다. |
|
|
| 마지막 실값 이전 102를 반환하고 next를 103으로 둡니다. |
|
|
| 빈 결과 이전 103에서 상태를 유지하며 두 빈 결과를 반환합니다. |
|
|
반환할 이전 값과 다음 상태 103을 나눈다
반환 100/101/102와 다음 상태 101/102/103, 이후 빈 결과·상태 유지입니다. 출력 순서와 분리된 선형화 상태를 같은 카운터로 추적합니다.
getAndUpdate가 반환하는 이전 값으로100부터102를발급하고103에서는빈결과를반환하는 답안의 경계를 보여줍니다.
발급 순서 · 이전 값과 다음 상태
반환할 이전 값과 다음 상태 103을 나눈다
100·101·102는 한 번씩 발급하고, 다음 상태 103에서는 더 발급하지 않습니다.
설명과 원래 예시 값
이전 값 100
- 갱신 뒤 next
- 101
- issue()의 반환
- OptionalInt[100]
이전 값 101
- 갱신 뒤 next
- 102
- issue()의 반환
- OptionalInt[101]
이전 값 102
- 갱신 뒤 next
- 103
- issue()의 반환
- OptionalInt[102]
이전 값 103
- 갱신 뒤 next
- 103 · 상태 유지
- issue()의 반환
- OptionalInt.empty
이번 출력은 빈 결과 두 개 뒤 102, 101, 100 순이었습니다. 표는 발급 연산의 상태 순서이며 println의 출력 순서가 아닙니다. 정상 완료한 5회 호출의 결과는 실값 세 개와 빈 결과 두 개입니다.
이 예의 조건
- 발급 연산 상태 순서이며 println 순서는 아닙니다. 정상 5회 호출입니다.
관계도의 보충 설명
| 구분 | 내용 |
|---|---|
| 2 | getAndUpdate가 반환하는 이전 값으로 100부터 102를 발급하고 103에서는 빈 결과를 반환하는 답안의 경계를 보여줍니다. |
| 구분 | 내용 |
|---|---|
| 설명 | |
| 전체 읽기 정보 | 반환할 이전 값과 다음 상태 103을 나눈다 반환할 이전 값과 다음 상태 103을 나눈다 getAndUpdate가 반환하는 이전 값으로 100부터 102를 발급하고 103에서는 빈 결과를 반환하는 답안의 경계를 보여줍니다. 발급 순서 · 이전 값과 다음 상태 반환할 이전 값과 다음 상태 103을 나눈다 100·101·102는 한 번씩 발급하고, 다음 상태 103에서는 더 발급하지 않습니다. 이전 값 100 갱신 뒤 next 101 issue()의 반환 OptionalInt[100] 이전 값 101 갱신 뒤 next 102 issue()의 반환 OptionalInt[101] 이전 값 102 갱신 뒤 next 103 issue()의 반환 OptionalInt[102] 이전 값 103 갱신 뒤 next 103 · 상태 유지 issue()의 반환 OptionalInt.empty 이번 출력은 빈 결과 두 개 뒤 102, 101, 100 순이었습니다. 표는 발급 연산의 상태 순서이며 println의 출력 순서가 아닙니다. 정상 완료한 5회 호출의 결과는 실값 세 개와 빈 결과 두 개입니다. |
| 관찰·상황 | 값·보충 정보 | 변화·이유 |
|---|---|---|
| 첫 두 발급 getAndUpdate의 이전 값을 반환합니다. |
|
|
| 마지막 실값 이전 102를 반환하고 next를 103으로 둡니다. |
|
|
| 빈 결과 이전 103에서 상태를 유지하며 두 빈 결과를 반환합니다. |
|
|
갱신 함수는 재실행되어도 외부 효과가 없으므로 CAS 재시도에 안전합니다.
공유 카운터의 최종 설계 기준
volatile을 보았다고 복합 연산이 안전하다고 결론 내리지 않습니다.
공유 작업을 읽기와 쓰기 단계로 펼쳐 다른 스레드가 끼어들 수 있는지 확인합니다.
단일 수치라면 Atomic, 여러 값의 공동 불변식이라면 잠금 또는 불변 스냅샷을 선택합니다.