서론
회사에서 코드 리팩토링을 하는 업무를 하고 있었습니다.
레거시 코드 중 중첩 Map<String, Map<String, DTO>> 형태로 반환하는 부분 때문에 프론트엔드에서는 불필요하게 반환된 객체의 key를 모두 읽고(Object.keys) 가장 최하단의 value에 직접 접근하는 로직을 항상 작성해야 했습니다.
그러다 보니 매번 프론트에서 작성되는 반복문 로직이 작성되어야만 했고 그 반복문 내에서 비즈니스 로직이라도 들어가게 되면 가독성을 매우 저하시키는 상황이 종종 생겼습니다.
그래서 이 중첩 Map 형태를 중첩이 없는 List형태로 반환하고자 코드를 수정하던 중, 누군가가 작성한 var 타입 추론을 만나게 됩니다.
간단히 눈으로 디버깅을 할 때 타입 추론은 IDE에 의존적임을 깨달았는데요, IDE가 없는 Github, GitLab 환경에서는 눈으로 직접 따라가며 이 타입을 확인해야 하는 번거로움이 있었고 이러한 현상이 되려 가독성을 떨어트린다고 생각했습니다.
하지만 분명 이 기능을 잘 쓰는 방법이 있을 것이라고 생각했는데요.
그래서 이번 기회에 이 기능을 언제 사용해야하는지, 반대로 사용하면 안 될 땐 언제인지
openJdk LVTI(Local Variable Type Inference) 가이드를 보며 어떻게 쓰는 것이 바람직한지 작성하겠습니다.
기능 소개
타입 추론 기능은 Java 10에 도입되었습니다.
이 기능 도입 이전엔 Java에서는 모든 변수에 명시적인 타입 선언을 해야했습니다. 하지만 도입된 이후 var를 이용해 명시적인 타입 선언을 하지 않더라도 변수를 선언할 수 있게 되었습니다.
하지만, 이 기능은 꽤 많은 논란이 있었습니다.
어떤 사람들은 이 기능으로 코드가 간결해질 것이라 말했고, 어떤 사람들은 가독성을 저해한다고 말했습니다.
이 두 말 모두 맞습니다.
중복적인 정보를 제거함으로써 코드 가독성을 올릴 수 있고, 반대로 유용한 정보를 생략함으로써 가독성을 떨어트릴 수 있습니다.
이 기능은 다른 기능들과 마찬가지로 무작정 사용하기 보다는 옳은 판단을 갖고 작성해야 합니다.
어떤 트레이드 오프가 있는지, var를 효과적으로 사용하는 방법은 무엇인지 설명하겠습니다.
가이드에서 안내하는 몇 가지 원칙
P1. 코드를 읽는 것은 코드를 작성하는 것보다 중요하다
코드는 작성하는 횟수보다 읽는 횟수가 많습니다. 또한, 코드를 작성할 때는 일반적으로 전체적인 코드 흐름을 이해하고 있을 것입니다.
하지만, 우리가 코드를 읽을 때를 생각해 봅시다.
실제 업무에서 내가 작성하지 않은 코드를 리팩토링 하는 순간도 있을 것이며, 코드 리뷰에서 상대가 작성한 코드를 빠르게 이해하고 피드백하는 순간이 자주 있습니다.
그래서 우리는 무작정 짧은 코드를 작성하기보다는 이해를 돕기 위해 코드가 조금 길어지더라도 명시적으로 작성하는 순간들이 종종 있습니다.
P2. 코드는 지역적 추론으로 명확히 알 수 있어야 한다
즉, var를 사용했을 때 코드 이해가 떨어지면 안 된다는 뜻입니다.
var를 선언했을 때 메서드나 변수 사용 위치를 계속 따라가며 추가적인 이해를 해야만 비로소 이해하는 상황이라면 var를 사용하기에 적합한 상황이 아닐 수 있습니다.
이런 상황은 어쩌면 코드 자체의 문제일 수도 있겠네요.
P3. 코드 가독성을 IDE에 의존해선 안된다
첫째, 코드는 종종 IDE 외부 환경에서도 읽힌다.
항상 IDE 내에서 코드가 읽히는 것은 아닙니다.
만약 코드 저장소에서 코드를 읽다가 이해하지 못해 IDE로 가져와 이해해야 한다면 이는 매우 비생산적입니다.
둘째, IDE에서 읽을 때도 변수에 대한 추가 정보를 확인하기 위해 명시적인 조치가 종종 필요합니다.
var로 선언한 변수에 커서를 올려 툴팁을 통해 타입을 확인하는 것을 의미합니다.
IntelliJ는 해당 없겠네요 사진처럼 타입이 바로 눈으로 보이기 때문이죠.
다른 IDE는 어떤지 모르겠습니다만, 원칙에 명시된 것으로 보아 모든 IDE가 이러한 기능을 지원해주지는 않을 것으로 예상됩니다.
즉, P3에서 말하고자 하는 것은 도구의 도움 없이도 그 자체로 이해할 수 있어야 한다는 것입니다.
P4. 명시적 타입은 trade-off이다
Java는 역사적으로 변수 선언이 명시적인 타입을 포함하도록 요구했습니다.
하지만, 명시적으로 타입을 선언하는 것은 때론 유용할 수 있지만 아닐 때도 있습니다.
즉, 명시적으로 타입 선언 하는 것을 숨겨도 무방한지 아닌지 판단하고 결정해야 한다는 것입니다.
가이드라인
G1. 유용한 정보를 제공하는 변수명 짓기
var처럼 타입 추론을 해야 하는 경우 이는 더더욱 중요해집니다.
// 원본
List<Customer> x = dbconn.executeQuery(query);
// 좋은 예
var custList = dbconn.executeQuery(query);
var를 사용하면서 아무 의미 없었던 변수 명이 어떤 변수인지 조금 명시적으로 표현했습니다.
변수 타입을 이름에 넣는 것은 때론 도움이 되지만 어쩔 땐 혼란을 주기도 합니다.
custList는 List가 반환된다는 것을 얘기합니다. 하지만 이는 중요하지 않을 수 있습니다.
변수의 이름이 정확한 것보다는 역할이나 성격을 표현하는 것이 더 나을 때도 있습니다.
아래처럼 말이죠.
// 원본
try (Stream<Customer> result = dbconn.executeQuery(query)) {
return result.map(...)
.filter(...)
.findAny();
}
// 좋은 예
try (var customers = dbconn.executeQuery(query)) {
return customers.map(...)
.filter(...)
.findAny();
}
G2. 로컬 변수의 범위를 최소화하라
이러한 내용은 Effective Java의 Item 57에도 안내되어 있습니다.
그리고 이 실천법은 var를 사용하는 경우 특히 중요합니다.
아래 예제에서 add 메서드는 어떤 항목을 목록 마지막 요소로 추가하므로 예상대로 마지막에 처리될 겁니다.
var items = new ArrayList<Item>(...);
items.add(MUST_BE_PROCESSED_LAST);
for (var item : items) ...
이제 중복 항목을 제거하기 위해 ArrayList 대신 HashSet을 사용하도록 수정한다고 가정해 보겠습니다.
var items = new HashSet<Item>(...);
items.add(MUST_BE_PROCESSED_LAST);
for (var item : items) ...
이제 이 코드는 버그를 갖고 있습니다.
왜냐면 Set은 순서를 보장하지 않기 때문입니다.
이러한 리팩토링을 했을 때 개발자는 아마 items 변수의 선언과 사용이 인접해 있으므로 이 버그를 즉시 수정할 것입니다.
하지만 아래의 경우는 어떨까요?
var items = new HashSet<Item>(...);
// ... 100줄의 코드 ...
items.add(MUST_BE_PROCESSED_LAST);
for (var item : items) ...
이제 이 버그를 쉽게 파악하기 힘듭니다. 왜냐면 선언부로부터 너무 멀리서 사용되고 있기 때문입니다.
즉, 이 버그는 장기간 방치되거나 장애가 발생해야만 인지할 수 있겠네요.
만약 items가 List<String>으로 명시적으로 선언되었다면 Set<String>으로 변경했을 때 개발자에게 타입 변경으로 인한 영향을 체크하도록 유도할 것입니다. (그렇지 않을 수도 있습니다)
즉 var의 사용은 이러한 유도를 제거하여 위험을 증가시킬 수 있습니다.
이 가이드는 var 사용을 지양하라는 말보다는, 로컬 변수의 범위를 줄이고 충분히 예측 가능하도록 변경하라는 뜻입니다.
그다음 var를 사용하는 것이 바람직합니다.
G3. 읽는 사람에게 충분한 정보를 제공한다면 var를 써라
로컬 변수는 종종 생성자를 사용해 초기화됩니다.
생성되는 클래스 이름은 종종 명시적 타입으로 좌변에 반복해서 사용됩니다.
// 원본
ByteArrayOutputStream outputStream = new ByteArrayOutputStream();
// 좋은 예
var outputStream = new ByteArrayOutputStream();
메서드 호출(생성자 대신 정적 팩토리 메서드 같은)로 초기화되는 경우에도 var를 사용하는 것은 합리적입니다.
그리고 그 이름에 충분한 타입 정보가 포함된 경우입니다.
// 원본
BufferedReader reader = Files.newBufferedReader(...);
List<String> stringList = List.of("a", "b", "c");
// 좋은 예
var reader = Files.newBufferedReader(...);
var stringList = List.of("a", "b", "c");
이러한 경우 메서드 이름은 특정 반환 타입을 강하게 의미하며 변수 타입을 추론하는 데 사용됩니다.
G4. var를 사용하여 체인 또는 중첩 표현식을 로컬 변수로 분리하라
문자열 컬렉션에서 가장 자주 나타나는 문자열을 찾는 코드를 생각해 봅시다.
return strings.stream()
.collect(groupingBy(s -> s, counting()))
.entrySet()
.stream()
.max(Map.Entry.comparingByValue())
.map(Map.Entry::getKey);
이 코드는 올바른 코드이긴 한데, 하나의 스트림 파이프라인처럼 보이기 때문에 혼동의 소지가 있습니다.
실제로는 첫 번째 스트림 결과물에 대한 두 번째 스트림이 따라오고, 두 번째 스트림의 Optional 결과를 매핑하는 것입니다.
이 코드를 가독성 좋게 바꾸면 두 개 또는 세 개의 문장으로 분리하는 것입니다.
Map<String, Long> freqMap = strings.stream()
.collect(groupingBy(s -> s, counting()));
Optional<Map.Entry<String, Long>> maxEntryOpt = freqMap.entrySet()
.stream()
.max(Map.Entry.comparingByValue());
return maxEntryOpt.map(Map.Entry::getKey);
그러나 작성자는 중간 변수 타입을 명시하는 것이 부담스러워 그렇게 하지 않았을 것입니다.
이때 var를 사용하면 명시적으로 선언하지 않더라도 코드를 더 자연스럽게 표현할 수 있습니다.
var freqMap = strings.stream()
.collect(groupingBy(s -> s, counting()));
var maxEntryOpt = freqMap.entrySet()
.stream()
.max(Map.Entry.comparingByValue());
return maxEntryOpt.map(Map.Entry::getKey);
이처럼 var의 올바른 사용은 무언가를 제거하는 것(명시적 타입)과 무언가를 추가하는 것(더 나은 변수 네이밍, 코드 구조화)을 모두 포함할 수 있습니다.
G5. 로컬 변수에서는 "Programming to the interface"에 걱정하지 마라
Java 프로그래밍에서 흔한 관용구는 구체적 타입 인스턴스를 생성하고 인터페이스 타입의 변수에 할당하는 것입니다.
흔히 아는 "추상화와 구현체"이고 향후 유지보수 과정에서 유연성을 유지할 수 있습니다.
// 원본
List<String> list = new ArrayList<>();
그러나 var를 사용하면 인터페이스 대신 구체적 타입이 추론됩니다.
// list의 추론된 타입은 ArrayList<String>입니다.
var list = new ArrayList<String>();
여기서 강조해야 할 점은 var는 로컬 변수에만 사용할 수 있다는 것입니다. 필드 타입, 메서드 매개변수 타입, 메서드 반환 타입을 추론하기 위해서는 사용할 수 없습니다.
"programming to the interface" 원칙은 그런 맥락에서 여전히 중요합니다.
주요 문제는 변수를 사용하는 코드가 구체적인 구현에 의존할 수 있다는 점입니다.
변수 초기화가 향후에 변경된다면, 추론된 타입이 변경되어 변수를 사용하는 후속 코드에서 오류나 버그가 발생할 수 있습니다.
G2에서 권장한 대로 로컬 변수 범위가 작다면 후속 코드에 영향을 미칠 수 있는 구체적인 구현의 누출로 인한 위험은 제한됩니다.
변수가 몇 줄 떨어진 코드에서만 사용된다면 문제를 피하거나 발생 시 완화하는 것이 쉬울 것입니다.
특히 이 경우엔 ArrayList는 List에 없는 ensureCapacity와 trimToSize라는 몇 가지 메서드만을 포함하고 있습니다.
이러한 메서드는 목록의 내용에 영향을 주지 않으므로 이들을 호출해도 프로그램의 정확성에 영향을 주지 않습니다.
이로 인해 추론된 타입이 인터페이스가 아닌 구체적인 구현이 되는 것의 영향이 더욱 줄어듭니다.
G6. var와 <> 또는 제네릭을 함께 사용할 때 주의하라
var와 <>는 이미 존재하는 정보로부터 명시적 타입 정보를 생략할 수 있게 해 줍니다.
PriorityQueue<Item> itemQueue = new PriorityQueue<Item>();
이는 <>또는 var를 사용해 타입 정보를 잃지 않고 다시 작성할 수 있습니다.
// OK: PriorityQueue<Item> 타입의 변수를 모두 선언
PriorityQueue<Item> itemQueue = new PriorityQueue<>();
var itemQueue = new PriorityQueue<Item>();
var와 <>를 모두 사용하는 것은 작성 자체엔 문제없지만, 추론 타입이 변경됩니다.
// DANGEROUS: PriorityQueue<Object>로 추론됨
var itemQueue = new PriorityQueue<>();
<>는 추론에는 타겟 타입(일반적으로 선언의 좌변) 또는 생성자의 인수 타입이 사용될 수 있습니다.
둘 다 제공되지 않으면 Object 같은 적용 가능한 타입으로 대체됩니다. 보통 이러한 현상은 의도한 바가 아닐 것입니다.
제네릭 메서드는 타입 추론을 잘 사용하여 개발자가 명시적 타입 인수를 제공하는 일은 매우 드뭅니다.
제네릭 메서드의 추론은 실제 메서드 인수로 충분한 타입 정보를 제공하지 않는 경우 타겟 타입에 의존합니다.
var 선언에서는 타겟 타입이 없으므로 <>와 비슷한 문제가 발생할 수 있습니다.
// DANGEROUS: List<Object>로 추론됨
var list = List.of();
<>와 제네릭 메서드 모두 생성자나 메서드의 실제 인수로 추가적 타입 정보를 제공하여 의도한 타입을 추론할 수 있습니다.
// OK: itemQueue가 PriorityQueue<String>로 추론됨
Comparator<String> comp = ... ;
var itemQueue = new PriorityQueue<>(comp);
// OK: List<BigInteger>로 추론됨
var list = List.of(BigInteger.ZERO);
var와 <> 또는 제네릭 메서드를 함께 사용하기로 했다면, 메서드나 생성자 인수가 충분한 타입 정보를 제공해 추론된 타입이 의도한 타입과 일치하도록 해야 합니다.
그렇지 않다면 이들을 함께 사용하는 것을 지양하세요.
G7. var를 리터럴과 함께 사용할 때 주의하라
원시 리터럴은 var 선언의 초기화 값으로 사용될 수 있습니다. 이러한 경우 var를 사용하는 것이 큰 이점을 제공하는 경우는 거의 없을 것입니다.
왜냐면 타입 이름이 일반적으로 짧기 때문이죠.
그러나 변수 이름을 정렬하는 등의 경우엔 var가 유용할 수 있습니다.
// 원본
boolean ready = true;
char ch = '\ufffd';
long sum = 0L;
String label = "wombat";
// 좋은 예
var ready = true;
var ch = '\ufffd';
var sum = 0L;
var label = "wombat";
특히 초기화 값이 숫자인 경우, 특히 정수 리터럴인 경우엔 특별한 주의가 필요합니다.
좌변에 명시적 타입이 있는 경우, 숫자는 int 외의 다른 타입으로 암묵적으로 넓히거나 좁힐 수 있습니다.
그러나 var를 사용하면 값이 int로 추론되어 의도하지 않은 결과를 초래할 수 있습니다.
// 원본
byte flags = 0;
short mask = 0x7fff;
long base = 17;
// 위험: 모두 int로 추론됨
var flags = 0;
var mask = 0x7fff;
var base = 17;
부동 소수점 리터럴은 대부분 모호하지 않습니다.
// 원본
float f = 1.0f;
double d = 2.0;
// 좋은 예
var f = 1.0f;
var d = 2.0;
float은 암묵적으로 double로 넓혀질 수 있습니다.
3.0f와 같이 명시적 float을 사용해 double 변수를 초기화하는 것은 어색할 수 있지만, float 필드에서 double 변수가 초기화되는 경우가 발생할 수 있기에 주의가 필요합니다.
// 원본
static final float INITIAL = 3.0f;
...
double temp = INITIAL;
// 위험: 이제 추론되는 타입은 float입니다.
var temp = INITIAL;
위 케이스는 G3를 위반합니다. 왜냐면 초기화 값을 보고 타입을 파악하는데 충분한 정보가 없기 때문입니다.
예제
var를 가장 효과적으로 사용할 수 있는 몇 가지 예제를 설명합니다.
아래 코드는 Map에서 최대 max개의 일치하는 항목을 제거합니다. 와일드카드 타입 바운드를 사용해 메서드 유연성을 향상하는 데 사용되며, 상당한 복잡성을 야기합니다.
이로 인해 Iterator의 타입은 중첩 와일드카드로 선언되어 더 번거로워집니다.
그리고 너무 길어져 for 구문의 헤더가 한 줄 작성이 불가하여 가독성을 더욱 떨어트립니다.
// 원본
void removeMatches(Map<? extends String, ? extends Number> map, int max) {
for (Iterator<? extends Map.Entry<? extends String, ? extends Number>> iterator =
map.entrySet().iterator(); iterator.hasNext();) {
Map.Entry<? extends String, ? extends Number> entry = iterator.next();
if (max > 0 && matches(entry)) {
iterator.remove();
max--;
}
}
}
여기서 var를 사용하면 로컬 변수의 불필요한 타입 선언이 제거됩니다. 이러한 종류의 루프에서 Iterator와 Map.Entry 로컬 변수에 대한 명시적 타입은 대부분 불필요합니다.
이로 인해 for 루프 제어문이 한 줄에 맞게 되어 가독성도 향상됩니다.
// 좋은 예
void removeMatches(Map<? extends String, ? extends Number> map, int max) {
for (var iterator = map.entrySet().iterator(); iterator.hasNext();) {
var entry = iterator.next();
if (max > 0 && matches(entry)) {
iterator.remove();
max--;
}
}
}
소켓에서 한 줄의 텍스트를 읽는 코드를 생각해 봅시다.
보통 try-with-resources 문을 사용해 구현할 것입니다. 구현해 보신 분들은 아시겠지만 타입 선언이 반복해서 일어나며 이름도 꽤 길기에 가독성을 떨어트릴 수 있습니다.
// 원본
try (InputStream is = socket.getInputStream();
InputStreamReader isr = new InputStreamReader(is, charsetName);
BufferedReader buf = new BufferedReader(isr)) {
return buf.readLine();
}
// 좋은 예
try (var inputStream = socket.getInputStream();
var reader = new InputStreamReader(inputStream, charsetName);
var bufReader = new BufferedReader(reader)) {
return bufReader.readLine();
}
결론
위 원칙과 가이드를 보고 느낀 점은 역시 "알고 쓰는 것이 중요하다"였습니다.
적재적소에 사용해야만 가독성과 개발자 편의성을 모두 가져갈 수 있습니다.
하지만, 너무 개발자 편의성에만 의존하면 미래에 그 코드를 보는 누군가는 그 코드를 보고 고통스러워 할 수 있습니다.
(보통 그 코드를 보는 사람은 자기 자신이더라구요..)
누가 봐도 이해하기 쉬운, 가독성 좋은 코드 작성이란 참 어려운 것 같습니다.
var 타입 추론도 CleanCode 내용과 일맥상통한다고 느꼈는데요, 결국 네이밍, 가독성, 코드 이해 이러한 키워드로 귀결되는 것을 느꼈습니다.
조금 보수적일 수 있지만 이 가이드를 보고 나서 "진짜 필요한 상황이 아니면 쓰지 말아야겠다"라고 생각이 들었습니다.
왜냐하면 가독성 좋은 코드를 작성하고 있는지에 대해서는 자기 자신이 스스로 답변하기가 어렵기 때문입니다.
"이미 변수명이나 코드 흐름이나 로직 분리에서 난 잘 작성하고 있으니까 여기선 var를 써야겠다"라고 생각했지만 내 코드를 읽는 사람들은 그렇게 생각하지 않을 수 있다는 것이죠..
이번 포스팅 제목은 다소 부정적일 수 있는데요, 조금 덧붙이자면 무분별하게 사용되는 var에 대해 부정적인 의미를 시사하고 싶었습니다.
오해 없으시길 바랍니다 ㅎㅎ
공식 가이드를 보고 번역하여 포스팅했지만, 아직 번역이 서툴러 매끄럽지 못한 부분이 있을 수 있습니다.
그런 부분은 언제나 편히 피드백 주시면 개선하도록 하겠습니다.
그럼 오늘도 즐거운 코딩 하세요!
감사합니다.
레퍼런스
'Tech > Java&Spring' 카테고리의 다른 글
String 함수 사용을 조심해야 하는 이유 (20) | 2024.03.31 |
---|---|
멀티스레드 분산 환경에서의 로깅(2) (0) | 2023.06.04 |
멀티스레드 분산 환경에서의 로깅(1) (0) | 2023.05.21 |
JPA saveAll이 Bulk INSERT 되지 않았던 이유 (3) | 2023.04.05 |
try-with-resources와 native 영역 (0) | 2023.03.11 |
인프런 지식공유자로 활동하고 있으며 MSA 전환이 취미입니다. 개발과 관련된 다양한 정보를 몰입감있게 전달합니다.