Java 33
람다, Stream, Optional로 데이터 처리 의도 드러내기
for와 if로도 처리할 수 있는데 람다, filter, map, collect, Optional이 등장하면 코드가 더 추상적으로 보여 겁난다.
근거 · 교안 p244-p250
1장: for/if 코드, 읽기 어렵다면 ‘왜’? - 람다로 짧은 함수 표현
솔라는 노트북 화면에 떠 있는 코드 조각을 흐뭇하게 바라보았다. 직접 만든 작은 온라인 상점의 상품 목록에서, 특정 조건을 만족하는 상품만 골라내는 코드였다. 실행 버튼을 누를 때마다 결과는 정확했다. 하지만 코드를 다시 읽어볼 때마다 흐뭇함은 미묘한 찝찝함으로 바뀌었다. 분명히 내가 짠 코드인데도, 몇 분만 지나면 ‘그래서 여기서 뭘 하려고 했더라?’ 하고 한참을 들여다봐야 했다.
솔라는 노트북을 들고 거실에 있는 루나에게 다가갔다.
“언니, 이것 좀 봐줄래? 코드가 돌아가기는 하는데… 마음에 안 들어.”
루나가 화면을 들여다보았다. 화면에는 여러 Product 객체를 담고 있는 리스트에서 가격이 10000원 이상인 상품의 이름만 추출해 새로운 문자열 리스트를 만드는 자바 코드가 있었다.
List<Product> products = ... // 상품 목록
List<String> expensiveProductNames = new ArrayList<>();
for (Product product : products) {
if (product.getPrice() >= 10000) {
expensiveProductNames.add(product.getName());
}
}
“잘 동작하잖아? 뭐가 문제인 것 같아?”
“분명 내가 짰는데, 나중에 다시 보면 뭘 하려는지 한눈에 파악이 안 돼. ‘상품 목록을 순회해서’, ‘가격이 10000원 이상이면’, ‘이름을 가져와서’, ‘새 리스트에 추가한다’… 이 모든 생각이 코드 한 덩어리에 뒤섞여 있는 느낌이야.”
솔라는 스스로 말하면서 문제의 핵심을 깨달았다. for 반복문과 if 조건문으로 모든 걸 할 수 있다고 생각했지만, ‘처리’의 단계가 늘어날수록 코드의 ‘의도’는 점점 희미해졌다.
루나는 고개를 끄덕였다. “솔라 네가 방금 말한 그 ‘의도’들을 그대로 코드가 보여주지 못해서 그래. 지금 이 코드 덩어리는 ‘어떻게’ 할 건지에 대한 설명만 가득하거든. 컴퓨터에게 내리는 지시사항의 나열이지.”
“지시사항의 나열?”
“응. ‘새로운 빈 리스트를 하나 만들어라’, ‘상품 목록에서 하나씩 꺼내라’, ‘꺼낸 상품의 가격을 확인해라’, ‘만약 가격이 10000 이상이면 그 상품의 이름을 꺼내라’, ‘그리고 그 이름을 아까 만든 리스트에 넣어라’. 이 과정이 전부 섞여 있잖아.”
루나의 말을 듣고 보니, 정말 그랬다. 내가 하고 싶은 일은 ‘비싼 상품의 이름만 모으기’라는 단순한 목표인데, 코드는 그 목표를 위한 아주 세세하고 기계적인 절차만 늘어놓고 있었다.
“그럼 이 뒤섞인 의도를 어떻게 깔끔하게 분리할 수 있는데?” 솔라가 물었다.
“좋아, 잠시 원래 문제에서 벗어나서 다른 걸 한번 보자.”
루나는 새 파일을 열고 다른 코드를 보여주었다. 상품 목록을 가격순으로 정렬하는 코드였다.
products.sort(new Comparator<Product>() {
@Override
public int compare(Product p1, Product p2) {
return Integer.compare(p1.getPrice(), p2.getPrice());
}
});
“이건 상품을 정렬하는 코드야. 이 코드에서 진짜 핵심적인 ‘의도’는 어느 부분일까?”
솔라는 코드를 훑어보았다. new Comparator니, @Override니, compare 메서드니, 눈을 어지럽히는 것들이 많았다.
“음… 결국 p1의 가격과 p2의 가격을 ‘비교하는’ 부분 아닐까? 나머지는 그 비교를 하기 위해 필요한 문법적인 장치들 같아.”
“바로 그거야. 우리는 단지 ‘가격을 기준으로 비교한다’는 규칙 하나만 알려주고 싶은데, 그걸 위해 너무 많은 코드를 작성해야 하지. 만약 이 규칙을 더 짧게 표현할 수 있다면 어떨까?”
루나는 방금 본 코드를 지우고 다시 입력했다.
products.sort((p1, p2) -> Integer.compare(p1.getPrice(), p2.getPrice()));
코드는 충격적일 만큼 짧아져 있었다. 여섯 줄짜리 코드가 단 한 줄로 바뀌었다. 중간에 생소한 화살표(->) 기호가 보였다.
“어… 이게 뭐야? 갑자기 코드가 왜 이렇게 짧아져?”
“이 화살표(->)가 바로 ‘이 매개변수들을 받아서, 이 처리를 해’라는 뜻을 가진 작은 함수라고 생각해 봐. 이름도, 복잡한 선언도 필요 없는 임시 함수지. 이걸 ‘람다 표현식’이라고 불러.”
솔라는 두 코드를 번갈아 보았다. 길었던 익명 클래스 코드는 사라지고, 오직 핵심 의도였던 (p1, p2)를 받아서 가격을 비교하는 로직만 남아 있었다. 화살표 기호가 마치 ‘입력’과 ‘출력’을 연결하는 파이프처럼 보였다.
“아! 그럼 이 화살표는 그냥 이름 없는 작은 함수 같은 거네? 복잡한 문법 대신 ‘어떻게 비교할 건지’ 그 규칙만 딱 써놓는 거구나.”
솔라는 무릎을 쳤다. 추상적이고 어렵게만 느껴졌던 ‘람다’라는 단어가 눈앞의 구체적인 문제, 즉 ‘의도를 가리는 불필요한 코드들을 걷어내는 것’을 해결하는 간결한 도구로 보이기 시작했다. 정렬 규칙처럼, 코드 중간에 필요한 작은 ‘행위’나 ‘규칙’을 표현하는 데 아주 유용해 보였다.
하지만 기쁨도 잠시, 솔라는 다시 자신의 원래 코드로 시선을 돌렸다.
List<String> expensiveProductNames = new ArrayList<>();
for (Product product : products) {
if (product.getPrice() >= 10000) {
expensiveProductNames.add(product.getName());
}
}
“알겠어. 람다 표현식이 정렬 규칙 같은 걸 짧게 만드는 건 알겠는데….”
솔라는 잠시 말을 멈추고 화면을 응시했다.
“내 원래 코드, 그러니까 ‘목록을 훑고, 조건을 걸러내고, 원하는 값만 뽑아내는’ 이 복잡한 흐름은 이걸로 어떻게 간단하게 만들어? 이 화살표 하나만으로는 여전히 부족한 것 같은데….”
2장: 흐름을 ‘시작’하고 ‘걸러내기’ - Stream과 filter의 의도
솔라의 말에 루나는 잠시 생각하더니, 솔라의 노트북을 받는 대신 옆에 있던 메모지와 펜을 집어 들었다. 깨끗한 종이 위에 네모난 상자를 크게 그리고 ‘모든 상품(products)’이라고 적었다. 상자 아래쪽으로 화살표를 하나 그리고, 순서도에서 결정을 나타내는 마름모 도형을 그렸다. 그 안에는 물음표를 그려 넣었다. 마름모에서는 또 다른 화살표가 뻗어 나와 비어있는 작은 상자로 이어졌다.
루나는 펜으로 마름모를 툭툭 치며 솔라를 보았다. 그림으로 보니, 솔라가 아까부터 답답해했던 코드의 복잡한 흐름이 한눈에 들어왔다. ‘모든 상품’ 상자에서 데이터가 나와, 마름모에서 어떤 ‘조건’에 따라 걸러지고, 그 결과가 새로운 상자에 담기는 과정. 자신의 for문과 if문 덩어리가 바로 이 그림과 같았다.
“네가 말한 ‘복잡한 흐름’ 말이야.” 루나가 입을 열었다. “목록을 훑고(순회), 조건을 걸러내고(필터링), 원하는 값만 뽑아낸다(변환). 이 세 가지 일이 하나의 for 문 안에 뒤엉켜 있었어. 그중에서 ‘걸러내는’ 일은 여기, 이 마름모에 해당하겠지.”
솔라는 고개를 끄덕였다. “맞아. if (product.getPrice() >= 10000) 이 부분이 정확히 저기네. 람다 표현식이 저런 규칙을 짧게 만드는 건 알겠는데, 데이터가 목록 전체를 타고 흐르는 이 과정 자체를 간단하게 만들 방법이 떠오르질 않아.”
“좋은 지적이야. 람다는 ‘규칙’을 표현할 뿐, 데이터가 흘러가는 ‘파이프라인’을 만들진 않으니까.” 루나는 다시 노트북으로 손을 옮겼다. 솔라의 원래 코드를 지우는 대신, 그 아래에 새로운 코드를 작성하기 시작했다.
products.stream()
“이게 그 파이프라인의 시작이야. products라는 상품 컬렉션에 .stream()이라고 붙이면, 데이터가 하나씩 흘러나올 수 있는 ‘흐름’ 또는 ‘개울’이 만들어진다고 상상해 봐.”
솔라는 화면을 빤히 쳐다봤다. .stream()이라는 낯선 메서드 호출이 전부였다. ‘흐름’이라는 비유는 그럴듯했지만, 이것만으로는 for 반복문보다 더 복잡하게만 느껴졌다. ‘그냥 반복문 돌리면 될 것을, 굳이 흐름으로 만들어서 뭘 하려는 거지?’
루나는 솔라의 표정을 읽고는, 말을 잇지 않고 코드를 한 줄 더 추가했다.
products.stream()
.filter(p -> p.getPrice() >= 10000)
“그리고 이 흐름에 ‘필터’를 설치하는 거야. 이 필터는 자기가 가진 규칙에 맞는 데이터만 통과시켜. 이 규칙, 익숙하지 않아?”
순간 솔라의 눈이 커졌다. filter 메서드 안에 들어있는 것은 바로 직전에 배웠던 람다 표현식이었다. (p -> p.getPrice() >= 10000) 부분은 for문 안에서 if 조건문이 하던 역할, 즉 ‘가격이 10000원 이상인지 확인하는 규칙’과 정확히 일치했다.
“아…!” 솔라가 작은 탄성을 내뱉었다. “for 문 안에서는 if 문이 반복문 로직이랑 섞여 있었는데, 이건 .filter()라는 메서드가 ‘걸러내는 의도’를 명확하게 보여주네. 그리고 어떤 조건으로 걸러낼지는 람다로 간단하게 전달하고.”
솔라는 두 코드를 번갈아 보았다.
- 기존 코드:
for (Product product : products) { if (product.getPrice() >= 10000) { // ...이름을 꺼내서 리스트에 추가한다... } } - 새로운 코드:
products.stream() .filter(p -> p.getPrice() >= 10000)
기존 코드는 ‘어떻게’ 처리할지에 대한 컴퓨터의 지시사항 나열이었다. ‘products를 하나씩 돌면서, 만약 가격이 10000 이상이면…’ 하지만 새로운 코드는 ‘무엇을’ 하고 싶은지에 대한 선언처럼 보였다. ‘products 데이터를 흐름으로 만들어서, 가격이 10000 이상인 것만 걸러내 줘.’ 처리의 의도가 코드의 구조를 통해 드러나는 순간이었다. Stream은 데이터가 흐르는 컨베이어 벨트였고, filter는 그 위에서 기준 미달인 상품을 걸러내는 장치 같았다.
“이제 알겠어. Stream은 데이터 묶음을 그냥 흐르는 재료처럼 다루게 해주는구나. 그래서 ‘걸러내기’ 같은 처리 단계를 명확하게 구분해서 연결할 수 있는 거였어.”
솔라는 만족스럽게 고개를 끄덕였다. 뒤섞여 있던 코드 덩어리에서 ‘필터링’이라는 의도를 분리해내는 데 성공했다. 하지만 그 만족감은 길지 않았다. 그녀는 다시 화면을 가리켰다.
“좋아, 가격이 비싼 상품만 걸러내는 데는 성공했어. 그런데 원래 내가 원했던 건 그 상품들의 ‘이름’만 모은 리스트였잖아. 지금 이 코드는… 뭘 돌려주는 거지? 필터링된 Product 객체들이 아직 흐름 속에 들어있는 건가? 이걸 어떻게 이름만 뽑아서 새 리스트에 담아?”
솔라의 질문에 루나는 희미하게 미소 지었다. ‘걸러내기’라는 첫 번째 의도는 명확해졌지만, 그 결과물을 원하는 형태로 ‘변환’하는 다음 단계의 문제가 수면 위로 떠오른 것이다.
3장: 흐름 안에서 ‘변환’하기 - Stream과 map의 의도
솔라는 노트북 화면에 떠 있는 미완성 코드의 끝에서 깜박이는 커서를 노려보았다. 이전과는 다른 종류의 답답함이었다. for문과 if문이 뒤엉켜 있던 코드는 ‘의도’를 파악하기 어려워 답답했지만, 지금 눈앞의 코드는 의도가 너무 명확해서 오히려 막막했다.
products.stream().filter(p -> p.getPrice() >= 10000)
‘상품들(products)을 하나의 흐름(stream)으로 만들어서, 가격이 10000 이상인 것만 걸러낸다(filter).’ 여기까지는 완벽했다. 컨베이어 벨트가 상품들을 싣고 오면, filter라는 검사관이 가격표를 보고 기준 미달인 상품들을 솎아내는 그림이 선명하게 그려졌다. 하지만 솔라가 진짜 원했던 것은 그 다음이었다. 그녀는 필터링을 통과한 Product 객체가 아니라, 그 객체 안의 String 타입 ‘이름’이 필요했다.
“그래서… 걸러내기까지는 했는데.”
솔라가 입을 열었다. 그녀의 손가락이 화면의 코드 끝, filter(...) 괄호 바로 뒤를 톡톡 두드렸다.
“여기서 이름만 어떻게 꺼내? 이 흐름 속에는 아직도 Product 객체들이 통째로 둥둥 떠다니는 거잖아. 이걸 다시 for문이라도 돌려야 하나?”
솔라의 질문은 그녀의 낡은 사고방식이 드러나는 지점이었다. 일단 원하는 것들을 걸러낸 후, 그 결과물을 가지고 다시 처음부터 새로운 작업을 시작하는 것. 마치 생선을 손질할 때, 비늘을 다 벗겨낸 뒤에 그릇에 담아두고, 다시 도마로 가져와 내장을 제거하는 것처럼 단계를 나누는 방식이었다. 비효율적이라고 생각하면서도 손에 익은 유일한 방법이었다.
루나는 솔라의 말에 대답 대신, 이전의 컨베이어 벨트 비유를 다시 가져왔다.
“컨베이어 벨트 위에 가격표 검사를 통과한 상품 상자들이 지나가고 있어. 상자 자체는 바뀌지 않았지. 그런데 솔라, 네가 원하는 건 상자가 아니라 상자에 붙은 ‘이름표’뿐이잖아. 그럼 벨트 중간에 어떤 장치가 더 필요할까?”
“음… 상자를 열어서 이름표만 떼어내고, 원래 상자는 버리는 장치?”
“맞아. 내용물을 다른 걸로 ‘바꿔치기’하거나, 특정 부분만 ‘추출’해서 내보내는 새로운 장치. 스트림에서는 그 역할을 하는 중간 처리 연산이 있어.”
루나는 솔라가 가리키던 코드의 다음 줄에 새로운 코드를 타이핑했다.
products.stream()
.filter(p -> p.getPrice() >= 10000)
.map(p -> p.getName())
새로운 메서드, map이 등장했다. 그 안에는 filter에서 봤던 것과 똑같은 형태의 람다 표현식이 들어 있었다. p를 받아서 p.getName()을 돌려주는, 지극히 단순한 규칙.
솔라는 잠시 숨을 멈추고 새로운 코드를 원래의 for문과 비교했다.
- 기존 코드:
// ... if (product.getPrice() >= 10000) { // .filter()의 역할 expensiveProductNames.add(product.getName()); // .map()의 역할 + 최종 수집 } // ... - 새로운 코드:
.filter(p -> p.getPrice() >= 10000) // 걸러내기 .map(p -> p.getName()) // 변환하기
조각난 퍼즐이 맞춰지는 느낌이었다. for문 안에서는 if라는 필터링 조건과 product.getName()이라는 변환 로직, 그리고 add라는 수집 행위가 한 줄에 뒤섞여 있었다. 하지만 스트림 API는 그 의도들을 명확하게 분리했다. .filter()는 ‘어떤 것을 남길 것인가’라는 선택의 의도를, 그리고 .map()은 ‘남은 것을 어떤 형태로 바꿀 것인가’라는 변환의 의도를 명확하게 드러냈다.
“아! map이 그냥 값을 복사해서 새 리스트를 만드는 게 아니었구나.”
솔라는 무릎을 쳤다.
“흐름 자체를 바꾸는 거였어. Product 객체가 흐르던 개울에 map이라는 정수 장치를 설치해서, String 이름만 흐르는 맑은 개울물로 바꾸는 것처럼! ‘걸러내기’와 ‘변환하기’의 의도가 완전히 분리됐어.”
map을 쓴다고 해서 새로운 리스트가 뚝딱 생기는 것이 아니었다. filter가 Stream<Product>를 받아서 또 다른 Stream<Product>를 반환했던 것처럼, map은 Stream<Product>를 받아서 Stream<String>을 반환할 뿐이었다. 데이터의 흐름은 여전히 계속되고 있었다. 처리의 각 단계가 파이프처럼 연결되어, 데이터가 한쪽 끝에서 다른 쪽 끝으로 흘러가며 점진적으로 바뀌는 모습이 눈에 보였다.
이제 코드는 ‘무엇을 원하는지’에 대한 완벽한 선언문처럼 보였다. ‘상품 목록에서, 10000원 이상인 것만 골라서, 그것들의 이름으로 바꿔줘.’
솔라는 만족스러운 미소를 지었다. 뒤죽박죽이었던 자신의 머릿속 처리 순서가 코드에 그대로 반영된 것 같았다. 하지만 그 미소도 잠시, 그녀는 다시 고개를 갸웃했다.
“좋아. 이제 가격 비싼 상품들의 이름만 흐르는 스트림이 만들어졌어. Stream<String>이겠지. 그런데… 내 List<String> expensiveProductNames는 어디 있어? 이 흐름의 끝에서 물건들을 담을 상자가 없잖아. 이 멋진 파이프라인은 있는데, 마지막에 결과물을 받아낼 방법이 막막해.”
4장: 흐름을 ‘수집’하고 ‘완결’하기 - Stream과 collect의 의도
솔라는 완성 직전의 코드 앞에서 손가락을 멈췄다. 화면에는 이전보다 훨씬 우아해진 데이터 처리 흐름이 그려져 있었다.
products.stream()
.filter(p -> p.getPrice() >= 10000)
.map(p -> p.getName())
마치 잘 설계된 공장의 생산 라인 같았다. 상품(products) 더미가 컨베이어 벨트(stream)에 오르고, 가격 검사기(filter)를 통과한 뒤, 제품 이름표만 떼어내는 변환기(map)를 거친다. ‘걸러내고 변환한다’는 의도가 명확히 드러나는 구조였다. 하지만 결정적인 한 가지가 빠져 있었다. 이 멋진 라인의 끝에, 완성된 이름표들을 담을 상자가 없었다.
솔라는 이 흐름의 결과를 담기 위해 원래 만들려던 변수 선언부에 코드를 연결하려 했다.
List<String> expensiveProductNames = products.stream()
.filter(p -> p.getPrice() >= 10000)
.map(p -> p.getName()); // <-- Type mismatch: cannot convert from Stream<String> to List<String>
그러자 IDE는 곧바로 빨간 밑줄을 그으며 에러를 표시했다. Stream<String>은 List<String>이 아니라는 차가운 메시지였다. 데이터는 여전히 ‘흐름’ 속에 갇혀 있었고, 손에 잡히는 ‘목록’이 아니었다.
“아, 진짜… 다 된 것 같은데.”
솔라는 낮은 탄식을 뱉었다. 머릿속 그림은 완벽했다. 가격 비싼 상품의 이름만 흐르는 맑은 개울물. 하지만 그 물을 떠서 마실 그릇이 없었다. 그녀의 질문은 막막함 그 자체였다. “이 멋진 파이프라인은 있는데, 마지막에 결과물을 받아낼 방법이 없어. 흐름의 끝에서 물건들을 담을 상자가 없잖아.”
루나는 솔라의 화면을 조용히 들여다보았다. 그녀는 솔라의 코드를 지우는 대신, 깜박이는 커서가 있는 마지막 줄에 새로운 코드를 덧붙였다.
import java.util.stream.Collectors; // 상단에 추가
List<String> expensiveProductNames = products.stream()
.filter(p -> p.getPrice() >= 10000)
.map(p -> p.getName())
.collect(Collectors.toList());
.collect(Collectors.toList()) 라는, 조금은 길어 보이는 코드가 추가되자 마법처럼 빨간 밑줄이 사라졌다. 솔라는 코드를 실행했다. 원했던 대로 가격이 10000원 이상인 상품들의 이름만 담긴 리스트가 깔끔하게 출력되었다.
“오, 된다! 그럼 이 .collect() 라는 게 그냥 스트림의 내용물을 리스트로 만들어주는 건가?”
솔라는 잠시 안도했지만, 이내 의심스러운 표정을 지었다.
“그런데… 이게 원래 내가 썼던 for 루프랑 크게 다른가? 결국 ‘새 리스트를 만들고, 거기에 하나씩 담는다’는 일을 하는 거잖아. 오히려 코드가 더 길고 복잡해진 것 같기도 한데.”
솔라의 지적은 타당했다. 그녀의 원래 코드를 다시 떠올려보자.
List<String> expensiveProductNames = new ArrayList<>(); // 1. 빈 리스트 생성
for (Product product : products) {
if (product.getPrice() >= 10000) {
expensiveProductNames.add(product.getName()); // 2. 조건에 맞으면 추가
}
}
새로운 collect 구문은 이 new ArrayList<>()와 add()의 역할을 한 번에 해주는 것처럼 보였다. 하지만 그게 전부라면, 굳이 ‘스트림’이라는 새로운 개념까지 배울 필요가 있을까?
루나가 두 코드의 구조적 차이점을 짚었다.
“솔라, 네가 썼던 for문 코드를 다시 봐. ‘결과를 담을 빈 리스트를 만드는 일’이 데이터 처리 로직보다 먼저 시작돼. 그리고 ‘리스트에 요소를 추가하는 일’은 필터링과 변환 로직 한가운데에 섞여 있지.”
“그렇긴 하네. expensiveProductNames.add()가 if문 안에 있으니까.”
“응. 하지만 스트림 코드는 달라.” 루나가 스트림 파이프라인을 손가락으로 쭉 훑었다. “stream()으로 시작해서 filter, map까지는 전부 ‘중간 처리’ 단계야. 데이터가 어떤 모습으로 바뀔지, 어떤 기준으로 걸러질지를 정의할 뿐, 아직 아무런 결과물도 만들지 않아. 데이터는 여전히 흐르고 있는 상태지.”
그녀의 손가락이 마지막 collect에서 멈췄다.
“그리고 이 collect가 바로 ‘종결 처리’야. ‘이제 모든 흐름 처리가 끝났으니, 그 결과물들을 모아서(collect) 리스트로 만들어줘’ 라고 명시적으로 선언하는 거지. 데이터 처리의 ‘과정’과, 그 결과를 ‘어떻게 마무리할지’에 대한 의도를 완벽하게 분리하는 거야.”
그 말을 듣는 순간 솔라의 머릿속에서 두 코드의 그림이 완전히 다르게 보이기 시작했다.
for문은 뒤죽박죽 섞인 지시사항 덩어리였다. ‘리스트 만들어. 반복해. 조건 확인해. 맞으면 이름 꺼내서 아까 그 리스트에 넣어.’
반면 스트림 파이프라인은 하나의 잘 짜인 문장이었다. ‘상품들을(products) > 흐름으로 만들어(stream) > 가격이 10000원 이상인 것만 걸러내고(filter) > 그것들을 이름으로 바꾼 뒤(map) > 최종적으로 리스트로 수집해줘(collect).’
“아…! collect는 그냥 리스트를 만드는 기술이 아니었어. 스트림이라는 긴 이야기의 마침표를 찍는 거였구나. ‘처리 시작’, ‘중간 가공’, 그리고 ‘최종 수집’이라는 데이터 처리의 완결된 구조를 코드에 그대로 표현하는 거였네.”
솔라는 감탄했다. 이제 for문과 if문이 뒤섞여 ‘어떻게’를 나열하던 코드가, 스트림 API를 통해 ‘무엇을 원하는지’를 선언하는 코드로 완전히 탈바꿈했다. 데이터 처리의 모든 의도가 각자의 메서드 이름으로 명확하게 드러났다.
그녀는 만족스럽게 완성된 코드를 바라보았다. 하지만 문득, 이 완벽해 보이는 코드에도 보이지 않는 위험이 숨어있을 수 있다는 생각이 스쳤다.
“언니, 이 코드는 정말 깔끔하고 좋아. 그런데 만약에… 만약에 상품 중에 이름이 없는 게 있으면 어떻게 되지? 데이터베이스에 실수로 이름이 null로 저장된 상품이 있다면, 이 .map(p -> p.getName()) 부분에서 NullPointerException이 터지진 않을까? 아니면 null이 리스트에 그냥 담기나? 그것도 찜찜한데. ‘값이 없을 수도 있다’는 가능성을 이 코드에 어떻게 표현해야 하지?”
5장: ‘값이 없을 수도 있음’을 명시하기 - Optional의 의도
솔라는 방금 완성한 스트림 코드를 만족스럽게 바라보았다. 데이터 처리의 모든 단계가 명확하게 분리되어, 마치 잘 쓰인 한 편의 선언문 같았다. 하지만 그 만족감의 한구석에서, 작지만 끈질긴 불안감이 고개를 들었다. 그녀는 자신의 테스트 데이터 목록을 열어, 상품 하나를 일부러 ‘잘못’ 수정했다. name 필드에 null을 직접 집어넣은 것이다.
List<Product> products = List.of(
// ... 다른 상품들
new Product("프리미엄 기계식 키보드", 150000),
new Product(null, 50000), // <-- 이름이 null인 상품
new Product("4K 웹캠", 80000)
);
코드를 다시 실행하자, 예상대로 콘솔 창에 빨간색 에러 메시지가 뿜어져 나왔다. java.lang.NullPointerException. 이름이 null인 상품 객체의 .getName()을 호출하려다 발생한, 익숙한 재앙의 신호였다. 솔라는 한숨을 쉬며 루나를 돌아보았다.
“역시 NullPointerException이 나네. 이걸 피하려면 스트림 안에서 filter를 하나 더 추가할 수 있겠지. filter(p -> p.getName() != null) 이렇게.”
솔라는 스스로 해결책을 제시했지만, 표정은 전혀 밝지 않았다. “그런데 이건… 원래 for문 안에 if문을 덕지덕지 붙이던 거랑 똑같잖아. 데이터 처리의 핵심 의도랑, 예외 상황을 막기 위한 방어 코드가 다시 뒤섞이는 느낌이야. ‘값이 없을 수도 있다’는 이 찜찜한 가능성을, 이 우아한 코드의 어디에 어떻게 표현해야 할지 모르겠어.”
솔라는 문제의 핵심을 정확히 짚고 있었다. null 체크 로직이 코드 곳곳에 흩어지면, 가독성은 떨어지고 유지보수는 어려워진다. 스트림으로 애써 분리해 놓은 ‘의도’들이 다시 흐릿해지는 것이다.
루나는 솔라의 노트북 화면을 잠시 바라보다가, 다른 예시를 보여주었다. 솔라가 최근 공부하던 스프링 부트 프로젝트의 일부였다. 데이터베이스에서 ID로 데이터를 조회하는 Repository 인터페이스의 메서드였다.
// ProductRepository.java
Optional<Product> findById(Long id);
“솔라, 이 메서드의 반환 타입을 봐. Product가 아니라 Optional<Product>야. 왜 이렇게 설계했을까?”
솔라는 메서드 이름을 보았다. findById. ID로 상품을 찾는다는 뜻. 하지만 데이터베이스에 해당 ID의 상품이 항상 존재한다는 보장은 없다. 결과가 null일 수 있는 전형적인 상황이었다.
“아…! ID에 해당하는 상품이 ‘없을 수도 있다’는 걸, 메서드 이름이나 주석이 아니라 반환 타입 자체로 알려주는 거구나.”
Optional이라는 껍데기가 Product를 감싸고 있었다. 그것은 마치 ‘이 상자 안에는 상품이 들어있을 수도 있고, 그냥 텅 비어있을 수도 있습니다. 열어보기 전에 함부로 내용물이 있다고 단정하지 마세요’ 라고 말하는 경고문처럼 보였다.
루나는 고개를 끄덕였다. “Optional은 null을 없애는 마법이 아니야. 오히려 null이 발생할 수 있는 상황을 숨기지 않고, 코드의 가장 바깥, 즉 타입으로 명확하게 드러내서 호출하는 쪽이 반드시 처리하도록 강제하는 장치야.”
“그냥 if (product != null) 체크하는 거랑은 근본적으로 다르네. Optional을 쓰면 ‘값이 없을 수도 있다’는 사실 자체가 계약 조건이 되는 거구나.”
솔라는 자신의 스트림 코드로 돌아왔다. 만약에 Product 클래스의 getName() 메서드가 null 대신 Optional<String>을 반환했다면 어땠을까? 하지만 지금 당장 클래스를 바꾸긴 어려웠다. 그렇다면 이미 null일 수 있는 값을 어떻게 Optional로 안전하게 다룰 수 있을까?
루나는 null 상품을 조회하는 가상의 코드를 작성하며 Optional의 사용법을 보여주었다.
// productRepository.findById(id)가 null을 반환할 수 있는 상황을 가정
Product product = productRepository.findByName("없는 상품"); // null을 반환
// 1. Optional로 감싸기
Optional<Product> optionalProduct = Optional.ofNullable(product);
// 2. 값이 있을 때만 행동하기 (if-present)
optionalProduct.ifPresent(p -> {
System.out.println("상품 가격: " + p.getPrice());
});
// 3. 값이 없을 때 기본값 사용하기 (or-else)
String name = optionalProduct.map(Product::getName) // Optional<Product> -> Optional<String>
.orElse("이름 없음");
System.out.println("상품 이름: " + name); // "이름 없음" 출력
솔라는 코드를 보며 눈을 빛냈다. if (product != null) 로 분기하던 지저분한 코드가 ifPresent, orElse 같은 명확한 의도를 가진 메서드로 대체되었다.
ifPresent(...): ‘만약 값이 존재하면, 이 행동을 해라.’orElse(...): ‘값이 있으면 그걸 쓰고, 없으면 대신 이걸 써라.’
null을 체크하고 회피하던 방어적인 프로그래밍이, 값의 ‘있음’과 ‘없음’이라는 두 가지 시나리오를 동등하게 다루는 선언적인 코드로 바뀌는 순간이었다.
솔라는 마침내 모든 조각이 맞춰지는 것을 느꼈다. 그녀는 처음 자신이 짰던 for문 코드부터 시작해, 람다, filter, map, collect를 거쳐 Optional에 이르기까지의 여정을 머릿속으로 되짚었다. 모든 과정은 단 하나의 목표를 향하고 있었다. 바로 코드에 ‘데이터 처리 의도’를 명확하게 드러내는 것.
그녀는 이제 더 이상 람다, Stream, Optional을 복잡하고 추상적인 문법으로 보지 않았다. 그것들은 for와 if만으로는 표현하기 어려웠던 ‘무엇을 하고 싶은가’를 선언하는 강력하고 우아한 도구였다.
솔라는 자신의 스트림 코드를 최종적으로 수정하는 대신, Repository의 findById 메서드 시그니처를 다시 한번 바라보았다.
Optional<Product> findById(Long id);
이 한 줄의 코드가 모든 것을 말해주고 있었다. 이것은 단순한 메서드 선언이 아니었다. ‘데이터를 찾지만, 결과는 없을 수도 있다. 그 가능성을 인지하고, 그에 대한 처리 방안을 명확히 코드에 표현하라.’ 이것은 코드를 작성하는 개발자들 사이의 명확하고 안전한 약속이었다. 솔라는 깨달았다. 좋은 코드는 단지 잘 동작하는 코드가 아니라, 의도를 명확하게 드러내 다른 개발자와 소통하는 코드라는 것을. 이제 그녀는 null을 두려워하는 대신, Optional을 사용해 그 존재 가능성을 당당하게 드러낼 수 있게 되었다.