map에 해당하는 글 3

모던 자바 인 액션 내용 정리

JAVA/고급 자바|2020. 4. 12. 16:46

 

포킹 

자바 8에서 추가된 스트림 api에서 데이터를 필터링, 추출, 그룹화 등의 기능을 진행할 수 있다. 이러한 동작들을 병렬화 할 수 있어 여러 cpu에서 작업을 분산해서 처리할 수 있다. 이런 작업을 포킹 단계라고 한다.

 

 

함수형 인터페이스

- 하나의 추상메서드를 가지고 있는 함수형 인터페이스지만 상속을 받은 인터페이스는 추상메서드를 하나만 가지고 있다고 하여도 함수형 인터페이스가 아니다.

- 디폴트 메소드가 아무리 많아도 추상 메소드가 하나이면 함수형 인터페이스이다.

- @FunctionalInterfeace 애노테이션을 붙이면 함수형 인터페이스가 아닌 경우 컴파일 에러를 발생 시킬 수 있다.

 

 

람다에서 지역변수를 final로 제약하는 이유 

람다에서 지역변수가 final로 사용되는지 궁금한데 이는 인터페이스 변수는 힙에 저장되고 지역변수는 스택에 저장되는 이유가 대표적이다. 람다에서 지역변수에 바로 접근할 수 있다는 가정하에 람다가 스레드에서 실행된다면 변수가 할당한 스레드가 사라져서 변수 할당이 해제되었는데도 람다를 실행하는 스레드에서는 해당 변수에 접근하려 할 수 있다. 그렇기 때문에 람다 내부에서 사용하는 값은 복사본으로써 그 값은 변경되면 안된다는 제약이 존재한다. (그래서  무조건 final로 제약하는 것)

 

 

predicate에는 여러 and, or, negete등이 사용이 가능하다.

and와 or로 predicate를 연결 할 수 있고 negete로 반대의 값을 가져올수도 있다.

 

List<String> dish = Arrays.asList("banana", "pizza", "chicken");

Predicate<String> isBanana = new Predicate<String>() {
	@Override
	public boolean test(String s) {
    	return s.equals("banana");
	}
};

Predicate<String> isChicken = new Predicate<String>() {
	@Override
	public boolean test(String s) {
    	return s.equals("chicken");
	}
};

Predicate<String> isNotPizza = new Predicate<String>() {
	@Override
	public boolean test(String s) {
    	return !s.equals("pizza");
	}
};

// predicate or
System.out.println(dish.stream().filter(isBanana.or(isChicken)).collect(Collectors.joining(", ")));

// predicate and
System.out.println(dish.stream().filter(isBanana.and(isChicken)).collect(Collectors.joining(", ")));

// predicate negate
System.out.println(dish.stream().filter(isBanana.negate()).collect(Collectors.joining(", ")));

 

 

Map의 문제 FlatMap으로 해결

- 문제상황

["hello", "world"] 두개의 단어에서 알파벳 중복된 걸 빼고 하나의 알파벳 배열로 합쳐 보자. 구하고자 하는 결과물은 다음과 같다. ["H", "e", "l", "o", "W", "r", "d"]

 

- Map으로 진행

List<String> str = Arrays.asList("hello", "world");

str.stream()
    .map(data -> data.split(""))
    .distinct()
    .collect(Collectors.toList());

이렇게 하면 각 문자열에서 문자로 쪼개고 거기서 distinct작업을 한뒤 합쳐서 성공적으로 하나의 list안에 중복된 문자가 없을 것 같지만 실제로는 ["hello"], ["world"]가 노출 된다. 

 

왜냐하면 실제로 동작하는은 다음과 같이 진행되기 때문이다.

hello → ["h","e","l","l","o"] (split하면 문자열 배열로 반환) 
world → ["w", "o", "r", "l", "d"] (split하면 문자열 배열로 반환) 

최종
→ distinct 대상이 string[] 결국 두개가 합쳐져 Stream<String[]>이 된다.

그럼 이를 해결 하기 위해서 map에서 생성된 배열이 dinstinct로 넘어갈 때 각 값을 다른 스트림으로 만든 다음 모든 스트림을 하나의 스트림으로 연결해야 하는데 이를 flatMap으로 해결이 가능하다.

 

List<String> str = Arrays.asList("hello", "world");
        System.out.println(str.stream()
            .map(data -> data.split(""))
            .flatMap(Arrays::stream)
            .distinct()
            .collect(Collectors.toList()));

map으로 반환된 Stream<String[]>에서 각 String[]을 Stream으로 변형한 후 하나의 stream으로 합쳐서 distinct를 진행하면 정상적인 결과가 도출된다.

 

flatmap은 각 요소를 별도의 스트림으로 만든 후 다시 합쳐준다. 또한 flatMap은 스트림을 받아 다른 스트림으로 변경해주는 역할을 한다.

예를 들어 (1, 2, 3) 배열과 (4, 5) 배열이 있을 때 이 두개를 합쳐서 (1, 4), (1, 5), (2, 4), (2, 5), (3, 4), (3,5) 이렇게 묶고 싶으면 다음과 같이 flatMap을 사용하여 두개를 합칠 수 있다.

List<Integer> data1 = Arrays.asList(1, 2, 3);
List<Integer> data2 = Arrays.asList(4, 5);
data1.stream()
	.flatMap(
		data -> data2.stream().map(j -> new int[] {data, j})
	)
	.collect(Collectors.toList());

만약 map(data → data.2stream().map(j → new int[] {data, })로 사용했으면 결국 최종적으로 Stream<Stream<int[]>>가 만들어지는 아쉬운 결과가 나올 수 있다.

 

 

findFirst와 findAny 차이

findFirst와 findAny는 같은 결과를 가져오지만 병렬 스트림에서는 findFirst로 첫 번째 요소를 가져오는게 어렵기 때문에 그런 경우에는 findAny를 사용한다.

 

 

parallel stream과 fork/join

parallel을 사용하여 fork/join 병렬을 진행 할 수 있다. 병렬 스트림은 기본적으로 ForkJoinPool을 사용하는데 이는 프로세스 수 즉 Runtime.getRuntime().availableProcessor()가 반환하는 값에 상응하는 스레드를 갖는다. 이 fork/join의 스레드 수는 조절이 가능하다.

 

 

Optional Serialize

Optional의 경우에 serialize를 구현하지 않았기 때문에 이는 직렬화 될 수 없다.

 

 

옳지 못한 상속 차단

코드 양이 많고 동작이 변경 되기를 원치 않는 클래스의 경우 final로 선언 또는 private 생성자를 지정한다. 이렇게 되어 있는 클래스의 기능을 이용하기 위해서는 델리게이션 즉 멤버 변수로 이용해서 클래스에서 필요한 메서드를 직접 호출하는 메서드를 작성하는 것이 좋다.

 

 

다중 상속 상황에서 동일한 이름의 메소드 우선순위

1. 클래스가 무조건 이긴다.

2. 서브 인터페이스가 이긴다. 예를 들어 B 클래스가 A 클래스를 상속받는다면 B 클래스가 무조건 A 클래스를 이긴다.

3. 이래도 불명확한 경우에는 여러 인터페이스를 상속받은 클래스에서 명시적으로 받고자 하는 곳을 지정할 수 있다.

 

 

CompletableFuture와 리액티브 프로그래밍 컨셉

값이 있을 때는 onNext, 도중에 에러가 발생했을 때는 onError, 값을 다 소진했거나 에러가 발생해서 더 이상 처리할 데이터가 없을 때 onComplete 등 각각의 콜백이 호출 된다. 이런 이벤트는 보통 순서의 개의치 않는다.

 

CompletableFuture 클래스의 join는 Future 인터페이스의 get 메소드와 동일한 의미로써 작업이 끝날 때 까지 기다린다. 다만 join은 어떠한 예외도 발생시키지 않는다는 차이점이 존재한다.

 

publisher는 subscriber가 자신을 구독하는 경우 최초로 onSubscribe를 호출하여 SubScription 객체를 전달한다. 이 Subscription 인터페이스는 publisher에게 이벤트 요청을 알리는 request 메소드가 있고 더 이상 받지 않겠다고 하는 cancel이 존대한다. 

 

publisher는 반드시 Subscription의 request 메소드에 정의된 개수 이하의 요소만 Subscriber에게 전달 할 수 있다. 해당 요청을 받은 publisher는 subscriber에게 onNext를 통해 여러번에 통해 데이터를 전달 할 수 있고 전달된 값에 따라 onComplete, onError를 통해 publisher에게 데이터 전달 성공 유무를 전달 할 수 있다. 이 대화는 publisher가 subscriber에게 전달한 subscription을 통해서 이루어진다.

 

 

 

 

 

책 전체가 조금 재미없게 구성되어 있다. 그래도 정리할 수 있어 좋았다.

댓글()

DFS로 미로 탈출하기

JAVA/알고리즘|2019. 5. 6. 10:19

최단거리 알고리즘을 공부하면서 예전해 만들었었던 미로 찾기를 다시한번 해봤다.

초년생때 이런문제가 어려웠는데 다시해보니 크게 어렵지는 않은것 같다.

DTO


package dto;

/**
 * Maze 블록의 정보를 보관하는 DTO
 * 
 * @author rokki
 *
 */
public class MazeBlock {
	private int x; // x 좌표
	private int y; // y 좌표
	private int count; // 카운트
	
	public MazeBlock(int x, int y, int count) {
		this.x = x;
		this.y = y;
		this.count = count;
	}

	public int getX() {
		return x;
	}

	public void setX(int x) {
		this.x = x;
	}

	public int getY() {
		return y;
	}

	public void setY(int y) {
		this.y = y;
	}

	public int getCount() {
		return count;
	}

	public void setCount(int count) {
		this.count = count;
	}
}

package dto;

/**
 * 거리와 맵을 저장하고 있는 클래스
 * 
 * @author rokki
 *
 */
public class ResultDto {
	private int resultCount;		// 경로 카운트
	private char[][] result;		// 최단 거리 맵
	
	public ResultDto(int resultCount, char[][] result) {
		this.resultCount = resultCount;
		this.result = result;
	}

	public int getResultCount() {
		return resultCount;
	}

	public void setResultCount(int resultCount) {
		this.resultCount = resultCount;
	}

	public char[][] getResult() {
		return result;
	}

	public void setResult(char[][] result) {
		this.result = result;
	}

}

 

Service


package service;

import java.io.IOException;
import java.nio.ByteBuffer;
import java.nio.channels.FileChannel;
import java.nio.charset.Charset;
import java.nio.file.Paths;
import java.nio.file.StandardOpenOption;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import java.util.stream.Stream;

import dto.MazeBlock;
import dto.ResultDto;
import serviceI.MazeService;

/**
 * 맵 경로를 찾는 서비스
 *
 * @author wedul
 *
 */
public class MazeServiceImpl implements MazeService {
	
	private char map[][];
	private boolean visited[][];
	private int maxX;
	private int maxY;
	private int currCount;
	private List<ResultDto> result = new ArrayList<>();

	@Override
	public void setMap(String path) throws IOException {
		List<String> mapList = readFile(path);
		this.maxY = mapList.size();
		this.maxX = mapList.get(0).length();
		this.map = new char[maxX][maxY];
		this.visited = new boolean[maxX][maxY];
		
		readFile(path).forEach((data) -> {
			for (int i = 0; i < data.length(); i++) {
				map[i][currCount] = data.charAt(i);
				visited[i][currCount] = false;
			}
			currCount++;
		});
	}

	@Override
	public void printMap() {
		for (int i = 0; i < maxY; i++) {
			for (int j = 0; j < maxX; j++) {
				System.out.print(map[j][i]);
			}
		}
	}
	
	public void printMap(char[][] paramMap) {
		for (int i = 0; i < maxY; i++) {
			for (int j = 0; j < maxX; j++) {
				System.out.print(paramMap[j][i]);
			}
		}
	}

	@Override
	public void findRoute() {
		// dfs 진행 (재귀)
		move(new MazeBlock(0, 1, 0));
	}
	
	/**
	 * map 나가기
	 * 
	 * @param dto
	 */
	private void move(MazeBlock dto) {
		int x = dto.getX();
		int y = dto.getY();
		visited[x][y] = true;
		int count = dto.getCount() + 1;
		
		// 위로 이동
		if (enableGo(new MazeBlock(x , y - 1, count))) {
			move(new MazeBlock(x, y - 1, count));
		}
		
		// 오른쪽으로 이동
		if (enableGo(new MazeBlock(x + 1, y, count))) {
			move(new MazeBlock(x + 1, y, count));
		}
		
		// 아래로 이동
		if (enableGo(new MazeBlock(x, y + 1, count))) {
			move(new MazeBlock(x, y + 1, count));
		}
		
		// 왼쪽으로 이동
		if (enableGo(new MazeBlock(x - 1, y, count))) {
			move(new MazeBlock(x - 1, y, count));
		}
		
		visited[x][y] = false;
	}
	
	/**
	 * 더 나아갈 수 있는지 확인
	 * 
	 * @param x
	 * @param y
	 * @return
	 */
	private boolean enableGo(MazeBlock dto) {
		int x = dto.getX();
		int y = dto.getY();
		
		// 경로 이탈 확인
		if (x < 0 || y < 0 || x > maxX || y > maxY) {
			return false;
		} 
		
		// 종점에 도착할 시 출려
		if (map[x][y] == 'G') {
			setResult(dto.getCount());
		}
		
		return map[x][y] == ' ' && !visited[x][y];
	}
	
	/**
	 * 경로들을 저장
	 * 
	 * @param count
	 */
	private void setResult(int count) {
		// map 복제
		char[][] cloneMap = new char[maxX][maxY];
		
		for (int i = 0 ; i < maxY; i++) {
			for (int j = 0; j < maxX; j++) {
				cloneMap[j][i] = map[j][i];
			}
		}
		
		for (int i = 0 ; i < maxY; i++) {
			for (int j = 0; j < maxX; j++) {
				if (visited[j][i]) {
					cloneMap[j][i] = '*';
				}
			}
		}
		
		result.add(new ResultDto(count, cloneMap));
	}
	
	@Override
	public void printResult() {
		ResultDto shortResult = result.get(0); // 최단경로 객체
		
		for (ResultDto dto : result) {
			System.out.println("GOAL===================" + dto.getResultCount());
			if (shortResult.getResultCount() > dto.getResultCount()) {
				shortResult = dto;
			}
		}
		
		System.out.println();
		System.out.println("short length : " + shortResult.getResultCount());
		printMap(shortResult.getResult());
	}
	
	/**
	 * nio를 사용하여 파일 읽기
	 * 
	 * @throws IOException 
	 */
	private List<String> readFile(String path) throws IOException {
		List<String> datas = new ArrayList<>();
		FileChannel fileChannel = FileChannel.open(Paths.get(path), StandardOpenOption.READ);
		 
        ByteBuffer buffer = ByteBuffer.allocate(1024);

        Charset charset = Charset.defaultCharset();
        String data = "";
 
        int byteCount;
 
        while (true) {
            byteCount = fileChannel.read(buffer);
 
            if (byteCount == -1)
                break;
 
            buffer.flip();
            data += charset.decode(buffer).toString();
            buffer.clear();
        }
 
        fileChannel.close();
        
        Stream<String> stream = Arrays.stream(data.split("\\n"));
        stream.forEach((eachData) -> {
        	datas.add(eachData);
        });
        
        return datas;
	}

}


package serviceI;

import java.io.IOException;

public interface MazeService {
	
	/**
	 * 파일에서 Map을 읽어 출력
	 * 
	 * @param path
	 * @throws IOException
	 */
	void setMap(String path) throws IOException;
	
	/**
	 * 맵 출력
	 */
	void printMap();
	
	/**
	 * 최단거리 찾기
	 */
	void findRoute();
	
	/**
	 * 최단거리 결과 출력
	 */
	void printResult();

}

package main;

public class Main {
	public static void main(String[] args) {
		MazeService service = new MazeServiceImpl();
		try {
			service.setMap("Map/maze.txt");
			service.findRoute();
			service.printResult();
		} catch (Exception e) {
			System.out.println("Fail Find Map");
		}
	}
}

 

결과


GOAL===================33 
GOAL===================31 
GOAL===================41 
GOAL===================39 
GOAL===================27 
GOAL===================25 
GOAL===================35 
GOAL===================33 
GOAL===================25 
GOAL===================23 
GOAL===================25 
GOAL===================23 

short length : 23 
################### 
********                     # 
#######*##### ## ## 
#      ******                 # 
## ##### ###*###### 
 # #  ## # #*# #  # 
   #                ******G 
###################

댓글()

Java8 스트림(Stream) API

JAVA/Java 8|2018. 5. 31. 07:35

Java8의 람다식, 인터페이스의 변화에 이어서

더 좋은 기능이 바로 스트림이다.

스트림(Stream) 정의

- 스트림이란 리눅스에서 사용되는 파이프 라인 처럼 한번에    만들어지는 연속적인 데이터 항목들의 모임이다.
Ex) ps -ef | grep pnp | grep -v drop
 
조금  자세히 설명하면집계 연산을 지원하는 요소의 순서(a sequence of elements from a source that supports aggregate operations라고 소개 되어 있다.

- Stream은 정의된 엘리먼트의 속성에 따라서 처리할 수 있는 인터페이스를 제공하지만 실제 엘리먼트들을 저장하지 않고 계산하는 데만 쓰인다.

- 스트림은 컬렉션배열, I/O 리소스 등에서 제공받은 데이터를 가지고 작업을 처리 한다.
Stream 함수형 프로그래밍 같은 처리 방법도 지원한다. (filter, map, reduce, find, match, sorted )
 
기존의 Collection 처리 방법과 Stream 처리방식과 구분되는 기본적인 특징이 두가지가 있다
Internal iteration : 명시적으로 반복작업을 수행해야 되는 Collection과 비교 하면 Stream 작업은 내부에서 처리된다

Pipelining : 많은 Stream 기능들이 Stream 자기 자신을 리턴 한다이 방식은 처리 작업이 체인처럼 연결되어 큰 파이프라인처럼 동작 하도록 한다이를 통해 앞에 연산 동작이 종료  후에야 다음 동작이 진행 되도록 하는 laziness 방식을 이용하여 효율적인 방식으로 코딩을   있다.



스트림(Stream) 소개와 Collection 단점
 
Java 8에서 스트림이 나오기 전에 Collection 이용하여 자료를 데이터를 만들고 처리하는데 사용하였다.
 
극단적인 예를 들어 현재 과일 상품들의 가격을 정렬하여 보고 싶을 경우 기존의 경우에는 Collection 사용하여 다음과 같이
 
많은 작업이 필요했다.



1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
public static void main(String args[]) {        
        WedulObject[] temp = {
            new WedulObject("kim"1"manage"), new WedulObject("jung"2"insa"), new WedulObject("jun"3"dev")
        };
        
        List<WedulObject> objects = Arrays.asList(temp);
        
        // 기존의 방식
        Collections.sort(objects, new Comparator<Object>() {
            @Override
            public int compare(Object obj1, Object obj2) {
                WedulObject ob1 = (WedulObject) obj1;
                WedulObject ob2 = (WedulObject) obj2;
                return ob1.getName().compareTo(ob2.getName());
            }
        });
        
        for (WedulObject obj : objects) {
            System.out.println(obj.getName());
        }
    }
cs




그러나 java8에서 스트림을 통해 편리하게 작업을 진행   있다.



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
public static void main(String args[]) {
        
        WedulObject[] temp = {
            new WedulObject("kim"1"manage"), new WedulObject("jung"2"insa"), new WedulObject("jun"3"dev")
        };
        
        List<WedulObject> objects = Arrays.asList(temp);
        
        // 기존의 방식
        Collections.sort(objects, new Comparator<Object>() {
            @Override
            public int compare(Object obj1, Object obj2) {
                WedulObject ob1 = (WedulObject) obj1;
                WedulObject ob2 = (WedulObject) obj2;
                return ob1.getName().compareTo(ob2.getName());
            }
        });
        
        for (WedulObject obj : objects) {
            System.out.println(obj.getName());
        }
        
        //strem을 이용한 정렬, 추출, 반환 
        List rare = objects.stream().sorted(Comparator.comparing(WedulObject::getAge)).map(WedulObject::getName).collect(Collectors.toList());
        rare.stream().forEach(System.out::println);
}
cs



품목(content)로 부터 stream()메서드를 사용해서 stream을 가져오고 그 다음에 여러가지 기능(sortedmap, collect)를 체인 처럼 엮어서 데이터를 처리하여 Collection 사용할  보다 더욱 간편하게 데이터를 처리   있게 되었다





또한 기존의 Collection 이용할 때의
 
기존의 코드는  못된 점은 없지만 코드의 병렬화가 어렵다는 한계점이 있다.

그러나 stream()에서는 
 
stream() parallelStream()으로 변경함으로써 병렬처리가 가능하게 된다. Stream API는 내부적으로 멀티코어로 동작하도록 처리하고 있다.







이런 스트림 문장을 생성하는 규칙이 존재한다.
 
Stream 처리는 중개 연산(intermediate operations)라고 불리는 것을 통해 서로 연결 될 수 있다이들의 리턴 타입은 stream이기 때문에 서로 연결될 수 있다. 
 
위의 예시로 보여줬던 Stream 보면 sorted, map 통해 전달 받는 Stream값을 이용하여 서로 값을 전달 하는 것을   있다.
 
  작업은 종단 연산(terminal operations) 통해 작업을 종료할  있다처리된 결과를 List, Integer void 형태 등으로 받을  있다.
 
 단계의 스트림은 요소들을 보관하지 않고 필요할  생성하며제공 받은 데이터를 변경하는 것이 아니라 연산으로 부터 생성된 스트림을 반환한다.





중계연산 메소드 소개
Filter
->  주어진 조건값을 충족하는  만을 추출하고자   사용되는 메서드 이다.
옵션 :
- distinct : 중복을 제거한 유니크 엘리먼트를 리턴 한다.
- limit(n) : 주어진 사이즈(n)에 까지의 stream을 리턴 한다. (filter 걸러진 데이터 중에 지정한 개수
                만큼만 출력)
- skip(n) : 주어진 엘리먼트 길이 까지 제외한 stream을 리턴 한다.(처음 나온 데이터 3개를 제외하고 
                출력)






Mapping
Map : 전달받은 스트림의 값을 특정 방식으로 변경하고 싶을  사용한다
-> 입력 받은 String Stream 값을 소문자로 변경  출력 




Reduction 메소드(종단 연산)
-> 중단 연산을 통해 데이터를 생성하고 변환하였다면종단 연산을 통해 변형되고 추출된 데이터를 통해 결과 데이터를 얻을  있게 해준다.
 
종단연산을 통해서 void, boolean, list, Optional<T> 형태의 데이터를 추출   있다.
 
Optional<T> 데이터 형식
기존의 데이터가 없음을 표시할  많이 사용되던 Null NPE(Null Point Exception)오류를 발생 시켜Runtime Exception 일으키는 경우가 많이 발생한다.
Null값인 데이터를 접근해서 발생하는 이유가  다수지만 null 자체의 모호함으로 인해 발생되는 경우도 있다.


1
2
3
4
Map<String, String> map = new HashMap<String, String>();
map.put("hello", null);
map.get("hello");   // "hello" key의 value인 null을 return
map.get("nice");    // "nice" key가 없으므로 null을 return
cs



위의 경우를 보면 key에 대한 value가 null인지, key가 없어서 null을 return 한건지 return 받은 null 값을 가지고는 그 의미가 모호해져 버그를 발생시킬 때도 있다.
  
그래서 java8에서 나온 Optional 클래스는 nullable T non-null 값으로 대체시키기 위해 포함된 방법이다.

, Optional 객체는 명시적으로 null 값을 갖지 않는다.

Optional 상태
– absent : 아무 것도 포함하고 있지 않은 상태
– present : non-null 값을 갖은 상태

Optional.of(T)
– T로 받은 non-null 값을 포함하는 Optional 객체 반환, T가 null 일 경우 NPE 발생







Optional.empty()
– 아무것도 포함하고 있지 않는 absent Optional 객체 반환
Optional.ofNullable(T)
– T로 받은 값이 non-null일 경우 present로, null일 경우 absent로 처리한 Optional 객체 반환
-> 값이 null 경우 비어 있는 Optional 객체를 반환하기 때문에 NPE 오류를 예방할  있다.




boolean isPresent()
– Optional 객체가 non-null 인스턴스를 포함할 경우 true 반환




T get()
– Optional 객체가 present 일 경우 포함하고 있는 인스턴스를 반환, absent일 경우 NoSuchElementException발생




T orElse(T)
– Optional 객체의 present 값을 반환만일 값이 없을 경우 명시한 T 반환 (기본값)



댓글()