이펙티브 자바 완벽 공략 1부
아이템 7. 다 쓴 객체 참조를 해제하라
카테고리 : 이펙티브 자바 완벽 공략 1부
아이템 7. 다 쓴 객체 참조를 해제하라
- 아이템 7. 다 쓴 객체 참조를 해제하라
핵심 정리
- 어떤 객체에 대한 레퍼런스가 남아있다면 해당 객체는 가비지 컬렉션의 대상이 되지 않는다
- 자기 메모리를 직접 관리하는 클래스라면 메모리 누수에 주의해야 한다.
- 예) 스택, 캐시, 리스너 또는 콜백
- 참조 객체를 null 처리하는 일은 예외적인 경우이며 가장 좋은 방법은 유효 범위 밖으로 밀어내는 것이다.
Stack 예제
import java.util.Arrays;
// 코드 7-1 메모리 누수가 일어나는 위치는 어디인가? (36쪽)
public class Stack {
private Object[] elements;
private int size = 0;
private static final int DEFAULT_INITIAL_CAPACITY = 16;
public Stack() {
elements = new Object[DEFAULT_INITIAL_CAPACITY];
}
public void push(Object e) {
ensureCapacity();
elements[size++] = e;
}
// public Object pop() {
// if (size == 0)
// throw new EmptyStackException();
// return elements[--size];
// }
/**
* 원소를 위한 공간을 적어도 하나 이상 확보한다.
* 배열 크기를 늘려야 할 때마다 대략 두 배씩 늘린다.
*/
private void ensureCapacity() {
if (elements.length == size)
elements = Arrays.copyOf(elements, 2 * size + 1);
}
// 코드 7-2 제대로 구현한 pop 메서드 (37쪽)
public Object pop() {
if (size == 0)
throw new EmptyStackException();
Object result = elements[--size];
elements[size] = null; // 다 쓴 참조 해제
return result;
}
public static void main(String[] args) {
Stack stack = new Stack();
for (String arg : args)
stack.push(arg);
while (true)
System.err.println(stack.pop());
}
}
- 가비지 컬렉션이 대상이 되지 않는 그런 예외적인 경우들이 대표적으로 스택처럼 직접 배열이나 리스트나 셋이나 맵이나 이런 걸로 우리가 직접 뭔가를 메모리를 우리가 직접 관리하는 경우에는 메모리누수에 주의를 해야한다
- 직접 뭔가를 어딘가에 세트, 배열, 리스트에 쌓아놓는 경우에 그때 쌓인 객체들이 언제 사라져야 되는가 언제 이것들이 빠질 수가 있는가 언제 우리가 널로 직접 처리를 해줘야 되는가 이것들을 항상 염두하면서 생각을 해야 되는데 이렇게 지금 널로 만드는 것은 다 쓴 객체 참조를 해제하는 한 가지 방법 중에 하나이다 굉장히 명시적이다
CACHE 예제
- 캐시 역시 메모리 누수를 일으키는 주범이다 그리고 운 좋게 캐시 외부에서 키를 참조하는 동안만 엔트리가 살아있는 캐시가 필요한 상황이라면 위크 해시 맵을 사용해 캐시를 만들자
import java.util.HashMap;
import java.util.Map;
import java.util.WeakHashMap;
public class PostRepository {
private Map<CacheKey, Post> cache;
public PostRepository() {
this.cache = new WeakHashMap<>();
}
public Post getPostById(CacheKey key) {
if (cache.containsKey(key)) {
return cache.get(key);
} else {
// TODO DB에서 읽어오거나 REST API를 통해 읽어올 수 있습니다.
Post post = new Post();
cache.put(key, post);
return post;
}
}
public Map<CacheKey, Post> getCache() {
return cache;
}
}
- 위크 해시맵은 위크 레퍼런스를 키로 가지는 맵이다 위크 레퍼런스라는 것은 우리가 레퍼런스에는 스트롱, 소프트, 위크, 팬텀 이렇게 4가지가 있는데 이 중에서 우리가 위크 레퍼런스를 사용하는 것이다 위크 레퍼런스를 키로 사용하는 맵이 위크 해쉬 맵이다
- 위크 해시맵의 특징은 어떤 키가 더 이상 참조가 안되면 위크 레퍼런스 빼고 참조가 되지 않으면 그 키로 가지고 있는 Value를 맵에서 빼준다 해당하는 Key도 같이 빠진다 그러니까 Strong한 Reference가 없으면 Garbage Collection 할 때 지워진다
- Weak Reference 빼고 그보다 더 강한 레퍼런스로 위크 레퍼런스 안에 담고 있는 데이터를 참조하는 레퍼런스가 없다면 가비지 컬렉션이 일어날때 해당하는 데이터가 맵에서 사라진다
- 다른 방법으로 추천하는 방법은 우리가 객체를 넣거나 빼거나 할 때 직접 관리하는 것이다 넣거나 빼거나 할 때 직접 이 맵에서 제일 오래된 거 찾아가지고 그때그때 삭제해 주는 것이다 또는 LRU(The latest recently used) 캐시라는 게 있다. 가장 최근에 사용된 캐시라고 해서 가장 최근에 사용된 몇 개까지만 캐시를 하겠다라는 자료구조이다
BackgroundThread
- 네 번째 방법이 백그라운드 스레드를 써 가지고 주기적으로 클린업을 하는 작업을 실행하는 것이다 책에서 소개해준 스케줄드 스레드풀 엑스큐터를 활용할 수 있다.
import org.junit.jupiter.api.Test;
import java.util.*;
import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.TimeUnit;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
class PostRepositoryTest {
@Test
void cache() throws InterruptedException {
PostRepository postRepository = new PostRepository();
CacheKey key1 = new CacheKey(1);
postRepository.getPostById(key1);
assertFalse(postRepository.getCache().isEmpty());
key1 = null;
// TODO run gc
System.out.println("run gc");
System.gc();
System.out.println("wait");
Thread.sleep(3000L);
assertTrue(postRepository.getCache().isEmpty());
}
@Test
void backgroundThread() throws InterruptedException {
ScheduledExecutorService executor = Executors.newScheduledThreadPool(1);
PostRepository postRepository = new PostRepository();
CacheKey key1 = new CacheKey(1);
postRepository.getPostById(key1);
Runnable removeOldCache = () -> {
System.out.println("running removeOldCache task");
Map<CacheKey, Post> cache = postRepository.getCache();
Set<CacheKey> cacheKeys = cache.keySet();
Optional<CacheKey> key = cacheKeys.stream().min(Comparator.comparing(CacheKey::getCreated));
key.ifPresent((k) -> {
System.out.println("removing " + k);
cache.remove(k);
});
};
System.out.println("The time is : " + new Date());
executor.scheduleAtFixedRate(removeOldCache,
1, 3, TimeUnit.SECONDS);
Thread.sleep(20000L);
executor.shutdown();
}
}
- 백그라운드 스레드를 사용해서 정리하는 방법은 주기적으로 정리하는 작업이다 캐시에서 가장 오래된 것을 찾아서 삭제해주는 작업을 오퍼레이션이 호출될 때마다 하는 게 아니라 백그라운드 스레드로 돌리는 것이다
- Runnable로 정의를 했고 이 작업을 처음에 1초 있다가 매 3초마다 실행하도록 스케줄드 엑스큐터 서비스로 만들어서 실행한다
- 이렇게 되면 제가 20초 동안 이 애플리케이션을 돌리고 있는 동안 별도의 스레드가 계속해서 3초마다 이 작업을 수행하면서 하나씩 삭제를 한다 제일 뒤에 있는 거 하나씩 삭제를 한다
Listener
import java.lang.ref.WeakReference;
import java.util.ArrayList;
import java.util.List;
import java.util.Objects;
public class ChatRoom {
private List<WeakReference<User>> users;
public ChatRoom() {
this.users = new ArrayList<>();
}
public void addUser(User user) {
this.users.add(new WeakReference<>(user));
}
public void sendMessage(String message) {
users.forEach(wr -> Objects.requireNonNull(wr.get()).receive(message));
}
public List<WeakReference<User>> getUsers() {
return users;
}
}
- 리스너나 콜백 리스트를 만들어 놓거나 또는 맵으로 만들어 놓거나 Weak Listener나 콜백을 담아놓은 다음에 어떤 이벤트가 발생했을 때 그 리스너나 콜백들을 쭉 실행을 해준다 어떤 이벤트, 메시지가 왔다거나 특정한 버튼을 클릭했다거나 그럴 때마다 리스너의 목록을 순회하면서 특정한 메소드를 호출해 준다 그런데 여기서도 리스너나 콜백을 제거해주는 기능이 없으면 막상 유저는 어떠한 이유로 메소드 범위를 벗어났건 명시적으로 채팅룸을 나갔으면 지워줄 수 있겠지만 채팅룸에서 나가는 기능을 수행하지 않고 그냥 유저가 떠났다면 콜렉션은 그대로 남아있을 것이다 위크 레퍼런스를 사용하면 이전에 설명드린 대로 더 이상 강력하게 참조하는 레퍼런스가 없기 때문에 제거가 된다 그치만 위에 처럼 사용하면 안되다 이유는 아래 내용에서 설명하겠다
완벽 공략
- p37, NullPointerException
- p38, WeakHashMap
- p38, 약한 참조 (weak reference)
- p39, 백그라운드 쓰레드
- p39, ScheduledThreadPoolExecutor
완벽 공략 19. NullPointerException
Java 8 Optional을 활용해서 NPE를 최대한 피하자
- NullPointerException을 만나는 이유
- 메소드에서 null을 리턴하기 때문에 && null 체크를 하지 않았기 때문에
- 메소드에서 적절한 값을 리턴할 수 없는 경우에 선택할 수 있는 대안
- 예외를 던진다
- null을 리턴한다
- Optional을 리턴한다
- Optional 사용시 주의 할 점
- 리턴값으로만 쓰기를 권장한다. (메소드 매개변수 타입, 맵의 키 타입, 인스턴스 필드 타입으로 쓰지 말자.) 왜?
- Optional을 리턴하는 메소드에서 null을 리턴하지 말자.
- 프리미티브 타입용 Optional을 따로 있다. OptionalInt, OptionalLong,…
- Collection, Map, Stream Array, Optional은 Opiontal로 감싸지 말 것.
- 아이템 55, Optional 반환은 신중히 하라.
완벽 공략 20. WeakHashMap
더이상 사용하지 않는 객체를 GC할 때 자동으로 삭제해주는 Map
- 원래 기존의 맵이나 리스트는 그 안에 들어가 있는 객체들을 GC가 일어난다고 해서 바로 없애주지 않는다
- GC가 일어난다고 해서 리스트가 자동으로 비워지지 않는다
- 캐시를 구현하는데 사용할 수 있지만, 캐시를 직접 구현하는 것은 권장하지 않는다.
- WeakHashMap 그 안에 들어있는 엔트리, 키-값의 쌍을 엔트리라고 부르겠는데 키가 Key가 더이상 강하게 레퍼런스되는 곳이 없고 Weak Reference로 참조되는 경우에는 GC가 일어날 때 해당한 엔트리가 자동으로 제거가 된다 그래서 WeakHashMap을 사용해서 캐시를 구현하기도 한다
- 맵의 엔트리를 맵의 Value가 아니라 Key에 의존해야 하는 경우에 사용할 수 있다.
- 맵에 넣는 데이터의 중요성이 어디에 달려있느냐가 중요한데 나는 Value를 보존하는데 조금 더 집중이 되겠다 Value가 유효한 동안에는 키도 유효해야 된다 그런 경우가 대부분이다 그래서 평상시에는 WeakHashMap을 잘 안 쓸 텐데 반대의 경우가 가끔씩 있을 수 있다
- 키가 중요한 경우 더 이상 키가 유효하지 않거나 키가 더 이상 레퍼런스 되는 곳이 없으면 Value도 무의미해지는 경우가 있을 수 있다 그런 경우에 WeakHashMap을을 쓰면 좋다
- WeakHashMap을 쓰면 키를 별도의 Reference로 생성 해야 한다 인티저나 프리미티브 타입이나 스트링이나 이런 데이터를 쓰면 일부의 값들이 캐싱이 된다 그래서 아무리 그 값을 레퍼런스 하는 변수를 널로 만든다 하더라도 지워지질 않는다 Strong Reference가 계속 남아있다고 생각하기 때문에 지워지질 않는다
private List<WeakReference<User>> users;
WeakReference를 이런식으로 사용하면 안된다- 위크 레퍼런스를 삭제해주는 그 기능은 WeakHashMap을에 들어있는 기능이다
- WeakReference가 더 이상 없어지면 WeakReference 참조하고 있는 개체가 없어졌으면 WeakReference도 만약 없애고 싶다 그러면 우리가 리스트를 만들어야 한다
레퍼런스 종류
- Strong, Soft, Weak, Phantom
Strong Reference
- Object에 할당을 하면 Strong Reference 이다
- Strong Reference가 더이상 유용해지지 않는 경우는 null을 할당 할 때이다
Soft Reference
- 새로운 Soft Reference 한 다음에 생성자 안에 Soft Reference로 가리킬 Strong Reference를 넣어주면 된다
public class SoftReferenceExample {
public static void main(String[] args) throws InterruptedException {
Object strong = new Object();
SoftReference<Object> soft = new SoftReference<>(strong);
strong = null;
System.gc();
Thread.sleep(3000L);
// TODO 거의 안 없어집니다.
// 왜냐면 메모리가 충분해서.. 굳이 제거할 필요가 없으니까요.
System.out.println(soft.get());
}
}
- Object가 있고 Strong 레퍼런스가 있고 Soft Reference가 하나 더 New Object를 보는 것이다
- 소프트 레퍼런스는 어떤 특징이 있냐면 더 이상 Strong 하게 레퍼런스 하는 애가 없고 Soft 레퍼런스 레벨로 레퍼런스만 하고 있으면 어떤 오브젝트를 레퍼런스 하는 애들만 남았으면 그런 경우에 이 오브젝트는 GC의 대상이 된다
- 언제? 메모리가 필요한 상황에만 GC를 한다고 해서 무조건 이 오브젝트를 수거해가는게 아니라 정말 여기는 치워야겠다 정말 더 이상 뭘 놓을 공간이 없다 이제 진짜 치워야 돼 그런 경우에 치운다 이런 순간에 그때 Soft 레퍼런스가 없어진다
- 예를 들면 새로운 Soft 레퍼런스를 만들고 Strong Reference를 없애버리면 이 오브젝트에 대한 Soft 레퍼런스 밖에 없다 GC를 한다고 해서 바로 없어지 않는다 왜냐하면 메모리 공간이 충분하기 때문에 소프트 레퍼런스에서는 쉽사리 잘 안 없어진다
Weak Reference
public class WeakReferenceExample {
public static void main(String[] args) throws InterruptedException {
Object strong = new Object();
WeakReference<Object> weak = new WeakReference<>(strong);
strong = null;
System.gc();
Thread.sleep(3000L);
// TODO 거의 없어집니다.
// 왜냐면 약하니까(?)...
System.out.println(weak.get());
}
}
- 소프트는 부드럽게라도 강철이 아니라 실로 묶어놨다고 치면 위크 레퍼런스는 진짜 약하게 연결되어 있는 것
- Weak 레퍼런스는 GC가 일어날 때 무조건 없어진다
Phantom Reference
public class PhantomReferenceExample {
public static void main(String[] args) throws InterruptedException {
BigObject strong = new BigObject();
ReferenceQueue<BigObject> rq = new ReferenceQueue<>();
BigObjectReference<BigObject> phantom = new BigObjectReference<>(strong, rq);
strong = null;
System.gc();
Thread.sleep(3000L);
// TODO 팬텀은 유령이니까..
// 죽었지만.. 사라지진 않고 큐에 들어갑니다.
System.out.println(phantom.isEnqueued());
Reference<? extends BigObject> reference = rq.poll();
BigObjectReference bigObjectCleaner = (BigObjectReference) reference;
bigObjectCleaner.cleanUp();
reference.clear();
}
}
- Phantom은 조금 어려운데 이게 무엇이냐면 유령 레퍼런스라고 보면 된다
- Phantom 레퍼런스가 이 Strong 인스턴스 대신에 남아있다고 보면 된다
- ReferenceQueue를 넘겨준 이유는 팬텀 오브젝트, 팬텀 레퍼런스는 팬텀 레퍼런스만 남은 경우에 Gc가 일어나면 이 본래 가지고 있던 오브젝트는 정리를 하고 당연히 널이니까 팬텀 오브젝트를 Queue에다 넣어준다 바로 없어지는 게 아니다 나중에 이 ReferenceQueue에서 꺼내서 정리를 할 수 있다
- Phantom 레퍼런스는 크게 두 가지 용도가 있는데 첫 번째 용도는 자원 정리할 때 쓸 수 있다 자바에는 Finalize라는 메소드가 있는데 Finalize 사용을 권장하지 않는다
- Finalize는 최악의 방법, 절대 쓰면 안 되는 방법, 그 다음에 Phantom Reference를 써서 자원을 반납하는 방법이 있고, 그거보다 더 좋은 방법은 Try Resource를 쓰는 것
- 두 번째 이유는 언제 무거운 객체가 메모리가 해제가 되는지를 알 수 있다 그게 정확하게 언제 이 커다란 객체가 사라졌는가? 사라진 것과 동시에 팬텀 레퍼런스가 큐에 들어간다
- 큐에 해당 오브젝트가 들어있는지 확인하면 메모리 공간이 큰 공간이 하나 사라졌구나 그럼 내가 이제 뭔가 더 큰 작업을 할 수 있겠네 라는 식으로 활용 물론 이런 경우가 거의 없다 정말 메모리에 민감한 애플리케이션을 정말 큰 오브젝트를 다루거나 아주 큰 오브젝트를 메모리에 올려야 되는 경우에는 그런 로직을 작성해야 될 수도 있다
완벽 공략 21. ScheduledThreadPoolExecutor
Thread와 Runnable을 학습했다면 그 다음은 Executor.
- Thread, Runnable, ExecutorService
- 쓰레드풀의 개수를 정할 때 주의할 것
- CPU, I/O
- 쓰레드툴의 종류
- Single, Fixed, Cached, Scheduled
- Runnable, Callable, Future
- CompletableFuture, ForkJoinPool
public class ExecutorsExample {
public static void main(String[] args) throws ExecutionException, InterruptedException {
ExecutorService service = Executors.newFixedThreadPool(10);
Future<String> submit = service.submit(new Task());
System.out.println(Thread.currentThread() + " hello");
System.out.println(submit.get());
service.shutdown();
}
static class Task implements Callable<String> {
@Override
public String call() throws Exception {
Thread.sleep(2000L);
return Thread.currentThread() + " world";
}
}
}
Executor
- 스레드를 만약에 엄청 많이 만들어야 된다 그러면 시스템 리소스를 너무 많이 쓰는 것이다
- 스레드를 만드는 거는 시스템 리소스를 많이 먹는다
- 근데 만약에 작업을 쓰레드를 더 조금만 쓰고도 할 수 있다면 쓰레드풀을 만들고 비동기적으로 처리를 할 수가 있다
- 쓰레드풀을 Executor 서비스로 편리하게 만들 수가 있다
쓰레드풀의 개수
- 스레드풀의 개수를 조정할 때 크게 두 가지를 신경 써야 한다 CPU의 집중적인 작업이냐? 아니면 I/O의 집중적인 작업이냐?
- 만약에 CPU Intensive하다 CPU를 많이 쓰는 작업이다 라고 한다면 아무리 Thread 개수를 늘려도 CPU 개수를 이미 넘어가면 어차피 막힌다
- CPU를 굉장히 많이 써야 된다 CPU를 10초 동안 써야 된다 해시를 돌린다거나 블록체인 계산을 한다거나 마이닝을 한다거나 채굴을 한다거나 이미지를 분석한다거나 엄청난 많은 계산을 요하는 그런 작업들은 CPU를 오래 잡아먹는다 CPU 개수보다 많이 줘봤자 어차피 스레드를 다 처리를 못한다 스레드가 어차피 기다려야 한다
- 그래서 CPU의 Intensive 작업들은 결국은 CPU 개수만큼만 만들게 될 것이다
- CPU 개수를 구하는 방법
- Available Processor로 가능한 CPU 개수를 알 수가 있다
int numberOfCpu = Runtime.getRuntime().availableProcessors();
- Available Processor로 가능한 CPU 개수를 알 수가 있다
- I/O Intensive 작업 DB에 액세스해서 어떤 데이터를 가져온다거나 네트워크로 HTTP 콜해서 어떤 값을 가져와야 되는데 그때 이제 딜레이가 있다 IO에서 블럭이 된다 IO 단위에서 인프나 오픈 처리하느라고 딜레이가 될 텐데 그러면 만약에 CPU 개수만 4개만 정했다고 치면 이미 IO 때문에 4개가 기다리는데 CPU 리소스는 남는다 근데 더이상 IO 기다리느냐고 일을 안 하는 것이다
- I/O Intensive 작업은 기본적으로 개수를 조금 더 많이 잡아야 한다 근데 얼마만큼 많이 잡아야 되냐는 정답이 없다 그건 상황마다 다 재봐야 된다 DB에서 어떤 오퍼레이션을 실행할 때 이게 몇 초가 걸리나 지금 몇 개의 스레드를 잡는 게 적절한가 그래서 이제 네이버에 있는 많은 직원들이나 대규모 회사에 있는 많은 분들이 성능 튜닝이라는 걸 하는 것이다 DB 딴 또는 네트워크 딴의 그 지연 시간에 따라 적절한 스레드 풀의 개수를 조정하고 또 스레드마다의 어떤 몇 초만큼 스레드를 또 무한정 만들 수는 없다 무한정 만들면 스레드 때문에 out of memory가 나서 시스템이 오히려 죽는다 그러니까 이 적절한을 정의하느라 많은 시간을 보내고 거기에 대한 많은 노하우를 쌓고 또 공부를 하고 연구를 한다
Cached ThreadPool
ExecutorService service = Executors.newCachedThreadPool();
- Cached ThreadPool은 필요한 만큼 쓰레드를 만든다
- 기존에 쓰레드가 있으면 재사용하고 지금 바쁘면 새로 만든다 그리고 너무 오랫동안 아무 일도 안하고 있는 쓰레드가 있으면 60초가 지나면 자동으로 그 쓰레드를 없애준다
- 이걸 쓰면 좀 빠르다 대신에 아마 굉장히 많은 쓰레드를 만들 것이다
- newCachedThreadPool은 newFixedThreadPool이랑은 많이 다르다 newFixedThreadPool은 내부적으로 쓰는 블럭킹 큐라고 해서 작업을 큐에 넣는데 블럭킹 큐는 Concurrent하게 접근이 가능하다 데이터를 넣고 뺄 수 있다 일반적인 ArrayList나 해시맵은 Concurrent에 안전하지가 않다 동시 접근하는데 사용할 만한 안전한 컬렉션이 아니다 Concurrent Modification Exception이라고 동시다발적으로 어떤 여러 멀티스레드가 동일한 ArrayList나 HashMap에 접근해서 엘리먼트들을 빼거나 조작하거나 이러면 Concurrent Modification Exception이 발생한다
- newFixedThreadPool 같은 경우는 블럭킹 큐라는 데이터 구조를 사용하고 newCachedThreadPool은 작업을 위한 공간이 하나이다 이 큐를 들어오자마자 바로 쓰레드에 주는데 줄만한 쓰레드가 없으면 새로 만든다 그래서 쓰레드가 계속 늘어날 수가 있다 조심히 써야 하는 쓰레드풀 중 하나이다 안그러면 자원이 쭉 낭비가 될 수 있다
Single ThreadPool
ExecutorService service = Executors.newSingleThreadExecutor();
- 싱글 스레드이기 때문에 Thread을 딱 하나만 만들어서 작업을 하나 가지고 다 처리하는 것이다 그래서 비효율 적이다
Scheduled ThreadPool
ExecutorService service = Executors.newScheduledThreadPool(10);
- 특이한 자료 구조를 쓴다 순차적으로 작업이 들어오지 않는다 스케줄을 감안해서 순서가 조금 바뀔 수 있다
- 어떤 작업을 몇 초 뒤에 딜레이 시켜서 실행한다거나 아니면 주기적으로 실행한다거나 그럴 때 쓸 수 있는 특별한 용도의 스레드이다
Runnable, Callable, Future
- Runnable 같은 경우는 리턴 타입이 없다 작업만 호출하고 끝인데 별도의 쓰레드에서 그냥 하다가 작업이 끝나면 쓰레드 끝내면 그만이다
- Callable는 별도의 쓰레드에서 처리한 이 작업의 결과로 어떤 리턴을 받고 싶어서 생긴 게 Callable이다 그때의 리턴 타입으로 Future를 받을 있는 것이다