코딩마을방범대

코틀린 기초 (3) - 코틀린에서의 FP 본문

💡 백엔드/Kotlin

코틀린 기초 (3) - 코틀린에서의 FP

신짱구 5세 2025. 8. 17. 11:22
728x90

 

 

 

코틀린에서 배열과 컬렉션을 다루는 방법

 

1. 배열

  • JAVA :
int[] array = {100, 200};
for (int i=0; i<array.length; i++){
    System.out.println("%s %s", i, array[i]);
}
  • 코틀린:
val array = arrayOf(100, 200)
// 인덱스만 받기
for (i in array.indices){
    println("${i} ${array[i]}")
}
// 인덱스와 value 같이 받기
for((idx, value) in array.withIndex()){
    println("$idx $value")
}
// 배열에 값 추가
array.plus(300)

 

 


 

 

 

 

2. 컬렉션

① 리스트

* 가변(Mutable) 컬렉션: 컬렉션에 요소를 추가하거나 삭제할 수 있다.
* 불변 컬렉션: 컬렉션이 생성된 후에는 요소를 추가하거나 삭제할 수 없다.
     - Reference Type인 Element의 필드는 바꿀 수 있다.
// 가상의 Product 클래스
data class Product(var name: String, var price: Int)

// 불변 리스트 생성
val products = listOf(Product("Apple", 100), Product("Banana", 200))

// 불변 리스트 자체는 변경 불가
// products.add(Product("Orange", 300)) // 컴파일 오류

// 하지만 리스트 내부 객체의 필드는 변경 가능
products[0].price = 150
println(products[0].price) // 150 출력

 

  • JAVA:
// 가변
List<Integer> numbers = Arrays.asList(100,200);
// 불변
List<Integer> immutableNumbers1 = Collections.unmodifiableList(numbers);
List<Integer> immutableNumbers2 = List.of(100,200);

// 하나를 가져오기
numbers.get(0);

// for Each
for(int number : numbers){
    ...
}

// 전통적인 for 문
for (int i=0; i<numbers.size(); i++){
    ...
}
  • 코틀린:
// 가변
val numbers = mutableListOf(100, 200)
numbers.add(300) // 자바와 사용 방법 동일
// 불변
val numbers = listOf(100, 200)

// 하나 가져오기
numbers.get(0)
// 대괄호를 통해서도 가능
numbers[0]

// for Each
for(number in numbers){
    ...
}

// 전통적인 for 문
for ((idx, value) in numbers.withIndex()){
    ...
}

// 빈 리스트 선언: 타입 추론이 불가하기 때문에 타입을 직접 명시해줘야함.
val emptyList = emptyList<Int>()
// 타입 추론 가능하다면 생략 가능
fun main(){
    useNumbers(emptyList())
}
private fun useNumbers(numbers: List<Int>){}

 


② Set

// 가변
val numbers = mutableSetOf(100, 200)
// 불변
val numbers = setOf(100, 200)

// for each
for (number in numbers){
    ...
}

// 전통적인 for 문
for ((index, number) in numbers.withIndex()){
    ...
}

 

 


 

 

③ Map

  • JAVA:
// JDK 8까지
Map<Integer, String> map = new HashMap<>();
map.put(1, "MONDAY");
map.put(2, "TUESDAY");

// JDK 9부터
Map.of(1, "MONDAY", 2, "TUESDAY");

// key 가져오기
for (int key : map.keySet()){
    map.get(key);
}

// key와 value 함께 가져오기
for (Map.Entry<Integer, String> entry : map.entrySet()){
    entry.getKey();
    entry.getValue();
}
  • 코틀린:
val map = mutableMapOf<Int, String>()
map.put(1, "MONDAY")
// 아래도 동일한 명령어임
map[1] = "MONDAY"

mapOf(1 to "MONDAY", 2 to "TUESDAY")

// key 가져오기
for (key in map.keys){
    map.get(key)
    map[key]
}

// key와 value 함께 가져오기
for ((key, value) in map.entries){
    key
    value
}

 

 


 

 

 

 

3. 컬렉션의 null 가능성, JAVA와 함께 사용하기

  • List<Int?>: 리스트에 null이 들어갈 수 있지만, 리스트는 절대 null이 아님
  • List<Int>?: 리스트에 null이 들어갈 수 없지만, 리스트는 null일 수 있음
  • List<Int?>?: 리스트에 null이 들어갈 수 있고, 리스트는 절대 null일 수 있음

 


 

 

💡 자바는 읽기 전용 컬렉션과 변경 가능 컬렉션을 구분하지 않는다.

코틀린 쪽의 컬렉션이 자바에서 호출되면 컬렉션 내용이 변할 수 있음을 감안해야 한다.

코틀린 쪽에서 방어 로직을 짠다거나, Collections.unmodifableXXX() 를 활용하면 변경 자체를 막을 수는 있다.

 

  1. 코틀린에서 만든 불변 리스트를 자바가 가져다 쓸 때 요소를 추가하게 되면 코틀린에서는 오동작을 일으킬 수 있음
  2. 코틀린에 null 이 들어갈 수 없는 리스트에 자바가 쓸 때 null 을 추가할 경우, 코틀린은 오동작을 일으킬 수 있음
  3. 코틀린에서 자바 컬렉션을 가져다 사용할 때 플랫폼 타입을 신경써야 한다.
    • ex. List<Int?> 인지 List<Int>? 인지 List<Int?>? 인지

 

 


 

 

 

 

코틀린에서 다양한 함수를 다루는 방법

 

1. 확장함수

기존에 있는 클래스의 코드를 직접 수정하지 않고도 새로운 메서드를 추가하는 기능이다.

자바나 다른 언어로 만들어진 라이브러리를 사용할 때, 그 클래스에 기능을 추가하고 싶지만 원본 코드를 수정할 수 없을 때 유용하다.

 

확장 함수의 원리 

  1. 클래스 외부에서 정의: 클래스 외부에서 함수 코드 작성
  2. .(점)을 사용하여 호출: 마치 클래스 내부에 있는 멤버 함수처럼 .(점)을 사용하여 호출
  3. this를 통한 접근: 함수 내부에서는 this 키워드를 통해 클래스의 인스턴스에 접근할 수 있음
// String 클래스 확장
fun String.lastChar(): Char {
    // 함수 안에서는 this를 통해 인스턴스에 접근 가능함
    return this[this.length - 1]
}
// 호출
"Hello".lastChar()

// 예시
// this=수신객체, 확장하려는 클래스=수신객체 타입
fun 확장하려는클래스.함수이름(파라미터): 리턴타입{
    // this를 이용해 실제 클래스 안의 값에 접근
}

 




의문점

1. 확장함수가 public이고, 확장함수에서 수신객체클래스의 private 함수를 가져오면 캡슐화가 깨지는거 아닌가?

  • 확장함수는 클래스에 있는 private, protected 멤버를 가져올 수 없다

 

 

2. 멤버함수와 확장함수의 시그니처가 같다면?

  • 멤버함수가 우선적으로 호출됨
// String 클래스에 이미 존재하는 멤버 함수
// fun String.plus(other: Any?): String

// 우리가 정의한 확장 함수
fun String.plus(other: Any?): String {
    return this + " " + other
}

fun main() {
    val myString = "Hello"
    val result = myString.plus("Kotlin") // 멤버 함수와 확장 함수의 시그니처가 같음

    println(result) // 결과: HelloKotlin
}

 

 

 

3. 확장함수가 override 될 경우?

  • 현재 타입을 기준으로 호출됨
open class Train(
    val name: String = "새마을기차",
    val price: Int = 5_000
)

fun Train.isExpensive(): Boolean {
    println("Train의 확장함수")
    return this.price >= 10000
}

class Srt : Train("SRT", 40_000)
fun Str.isExpensive(): Boolean {
    println("Srt의 확장함수")
    return this.price >= 10000
}

// 호출
val train: Train = Train()
train.isExpensive() // Train의 확장함수 호출

val str1: Train = Srt()
str1.isExpensive() // Train의 확장함수 호출

val str2: Srt = Srt()
srt2.isExpensive() // Srt의 확장함수 호출

 

 

4. 자바에서 코틀린 확장함수를 가져다 사용할 수 있나?

  • 정적 메소드를 부르는 것처럼 사용 가능
// StringUtils.kt 파일
fun String.lastChar(): Char {
    return this[this.length - 1]
}
// 자바 코드
StringUtilsKt.lastChar("ABC") //로 사용 가능

 

 

5. 확장 프로퍼티 원리는 확장함수 + custom getter와 동일하다

 

코틀린 컴파일러가 확장 프로퍼티를 내부적으로 확장 함수처럼 처리한다는 뜻이다.
즉, 확장 프로퍼티는 실제 필드를 추가하는 것이 아니라, 기존 클래스에 새로운 '가상의 프로퍼티'를 만들어주는 문법적인 편리함이라고 볼 수 있다.

여기서 중요한 점은 val String.lastChar는 실제 필드를 추가하는 것이 아니라, get() 블록에 정의된 로직을 실행하는 함수처럼 작동한다는 것이다.

  • 확장 함수: str.lastChar()를 호출하면 lastChar() 함수가 실행됩니다.
  • 확장 프로퍼티: str.lastChar를 호출하면 컴파일러가 자동으로 get() 블록의 코드를 실행하여 값을 반환합니다.
// 확장 함수
fun String.lastChar(): Char {
    return this[this.length - 1]
}
// 확장 프로퍼티와 커스텀 게터
val String.lastChar: Char
    get() = this[this.length - 1]

 

 


2. infix 함수 (중위함수)

  • 함수를 호출하는 새로운 방법
  • downTo, step 도 중위 호출 함수이다.
  • '변수.함수이름(argument)' 대신 '변수 함수이름 argument'로 사용
// ex. 해당 객체 이름이 test일 경우
// 확장 함수
fun Int.add(other: Int): Int {
    return this + other
}
// 중위함수
infix fun Int.add2(other: Int): Int{
    return this + other
}

// 확장 함수 호출
test.add(4)
// 중위 함수 호출
test.add2(4)
test add2 4

 

 


 

 

 

 

3. inline 함수

  • 함수가 호출되는 대신, 함수를 호출한 지점에 함수 본문을 그대로 복붙하고 싶은 경우
  • 인라인 함수는 컴파일 시점에 호출된 곳에 함수 본문이 직접 삽입되어 오버헤드를 줄이는 특징을 가지고 있다.
fun main() {
    3.add(4)
}
inline fun Int.add(other: Int): Int {
    return this + other
}

// 바이트 코드로 변경 시
public static final void main(){
    byte $this$add$iv = 3;
    int other$iv = 4;
    int $i$f$add = false;
    int var10000 = $this$add$iv + other$iv;
}

 

 


 

 

 

 

4. 지역함수

  • 함수 안에 함수를 선언할 수 있다. (이 함수를 지금 함수 내에서만 사용하고 싶을 때)
  • depth가 깊어지기도 하고, 코드가 그렇게 깔끔하지는 않다.
"depth(깊이)가 깊어진다"는 것은 복잡한 중첩 구조를 의미하며, 이는 코드의 가독성을 떨어뜨리는 주요 원인 중 하나이다.

 

 


 

 

 


코틀린에서 다양한 람다를 다루는 방법

* 람다: 이름이 없는 함수

람다(Lambda)와 익명 함수(Anonymous Function)의 차이
특징 람다 익명 함수
문법 a, b -> a + b fun(a, b): Int { return a + b }
return사용 return을 사용하면 함수를 감싼 외부 함수에서 반환합니다. return을 사용하면 익명 함수 자체에서 반환합니다.
호출 방식 { }안에 코드를 작성하고 매개변수와 반환 타입을 추론합니다. fun키워드를 사용하고 매개변수와 반환 타입을 명시합니다.
코틀린은 람다를 우선적으로 사용하여 코드를 간결하게 작성하도록 권장한다.
하지만 람다의 return 규칙이 혼란스러울 수 있는 경우에는 익명 함수를 사용하여 명확성을 높이는 것이 좋다.

 

1. JAVA에서 람다를 다루기 위한 노력 

1. 익명 함수를 이용한 방법

// 인터페이스
public interface FruitFilter {
    boolean isSelected(Fruit fruit);
}
// 검증 메소드
private List<Fruit> filterFruits(List<Fruit> fruits, FruitFilter fruitFilter){
    List<Fruit> results = new ArrayList<>();
    for (Fruit fruit : fruits) {
        if (fruitFilter.isSelected(fruit)){
            results.add(fruit);
        }
    }
    return results;
}
// 인터페이스를 구현하는 익명함수
filterFruits(fruits, new FruitFilter(){
    @Override
    public boolean isSelected(Fruit fruit){
        return Arrays.asList("사과", "바나나").contains(fruit.getName()) && fruit.getPrice() > 5_000;
    }
}

 

2. Predicate 를 이용한 방법

// 검증 메소드
private List<Fruit> filterFruits(List<Fruit> fruits, Predicate<Fruit> fruitFilter){
    List<Fruit> results = new ArrayList<>();
    for (Fruit fruit : fruits) {
        if (fruitFilter.test(fruit)){
            results.add(fruit);
        }
    }
    return results;
}
// 함수 호출
filterFruits(fruits, fruit -> fruit.getName().equals("사과"));

// Predicate와 Collectors(스트림) 를 이용한 방법
// 검증 메소드
private static List<Fruit> filterFruits(List<Fruit> fruits, Predicate<Fruit> fruitFilter){
    return fruits.stream()
        .filter(fruitFilter)
        .collect(Collectors.toList());
}
// 함수 호출(메소드 레퍼런스 활용)
// 메소드 자체를 직접 넘겨주는 것처럼 사용할 수 있다.
filterFruits(fruits, Fruit::isApple);
자바의 함수는 변수에 할당되거나 파라미터로 전달할 수 없다. (자바에선 함수를 2급 시민으로 간주)
1급 시민: 객체와 동등하게 다뤄짐
2급 시민: 객체와 다르게 다뤄짐 

 

 


 

 

 

 

2. 코틀린에서의 람다

  • 코틀린에서는 함수가 그 자체로 값이 될 수 있다.(변수, 파라미터에 할당 가능 = 1급 시민이기 때문)
var fruits = listOf(
    Fruit("사과", 1_000),
    Fruit("바나나", 2_000)
)

// 람다 함수로 선언 (함수명만 빠짐)
// isApple 은 Fruit를 파라미터 타입으로 받아 Boolean을 반환하는 함수
// 함수의 타입 표기 방법 = (파라미터 타입...) -> 반환 타입
// val isApple: (Fruit) -> Boolean
val isApple = fun(fruit: Fruit): Boolean{
    return fruit.name == "사과"
}
// 람다 함수 화살표로 선언 (중괄호와 화살표 이용)
val isApple2 = { fruit: Fruit -> fruit.name == "사과" }

// 함수 호출
isApple(fruits[0])
isApple.invoke(fruits[0])

 

* invoke의 핵심 요약
함수처럼 호출 가능: invoke 덕분에 sum(3, 5)처럼 괄호만 사용해서 함수 객체를 실행할 수 있다. / 함수 객체를 일반 함수처럼 호출할 수 있게 해주는 특별한 연산자
생략 가능: 코틀린 컴파일러가 invoke 호출을 자동으로 처리해주기 때문에, 개발자가 직접 sum.invoke()를 쓸 필요는 거의 없다.
연산자 오버로딩: invoke는 연산자 오버로딩의 한 종류이다. 덕분에 클래스의 인스턴스를 함수처럼 호출하는 것도 가능하다.

 

자바의 filterFruit 따라하기

var fruits = listOf(
    Fruit("사과", 1_000),
    Fruit("바나나", 2_000)
)

// 검증 함수
private fun filterFruits(fruits: List<Fruit>, filter: (Fruit) -> Boolean): List<Fruit> {
    val results = mutableListOf<Fruit>()
    for (fruit in fruits){
        if (filter(fruit)){
            results.add(fruit)
        }
    }
    return results
}

// 필터 함수
val isApple = fun(fruit: Fruit): Boolean{
    return fruit.name == "사과"
}

// 함수 호출
filterFruits(fruits, isApple)
filterFruits(fruits, { fruit: Fruit -> fruit.name == "사과" })

// 화살표 함수를 넘길 때 함수가 파라미터의 마지막 위치에 있다면 소괄호 밖으로 빼기 가능
filterFruits(fruits) { fruit: Fruit -> fruit.name == "사과" }
// 타입 추론 가능하므로 Fruit 키워드도 생략 가능
filterFruits(fruits) { fruit -> fruit.name == "사과" }
// 람다를 여러줄 작성 가능하며, return을 붙이지 않아도 마지막 줄의 결과가 람다의 반환값이다.
filterFruits(fruits) { fruit ->
    println("사과만 받는다..!")
    fruit.name == "사과"
}
// 익명 함수 사용 시 파라미터가 한 개라면, 파라미터명과 화살표 생략 후 it 으로 명시 가능
filterFruits(fruits) { it.name == "사과" }

 

 


 

 

 

 

3. Closure

  • JAVA: 람다를 쓸 때 사용할 수 있는 변수 제약이 있다: final 인 변수 혹은 실질적으로 final인 변수만 사용 가능
    • 'Variable used in lambda expression should be final or effectively final' 오류 발생
// 바나나를 수박으로 바꿨기 때문에 불가능
String targetFruitName = "바나나";
targetFruitName = "수박";
filterFruits(fruits, (fruit) -> targetFruitName.equals(fruit.getName()));

 

  • 코틀린: 오류가 발생하지 않음
    • 코틀린에서는 람다가 시작하는 지점에 참조하고 있는 변수들을 모두 포획하여 그 정보를 가지고 있다.
      이렇게 해야만 람다를 진정한 일급 시민으로 간주할 수 있다. 이 데이터 구조를 Closure 라고 부른다.
var targetFruitName = "바나나"
targetFruitName = "수박"
filterFruits(fruits) { it.name == targetFruitName }

 

 


 

 

 

 

4. try with resources

fun readFile(path: String) {
    BufferedReader(FileReader(path)).use { reader ->
        println(reader.readLine())
    }
}

 


 

 

use 함수 파헤치기

public inline fun <T : Closeable?, R> T.use(block: (T) -> R): R {
  1. Closeable 구현체에 대한 확장 함수이다.
    • use는 java.io.Closeable 인터페이스를 구현한 객체(예: BufferedReader, InputStream)에 대해서만 사용할 수 있다.
      use 함수의 시그니처를 보면 <T : Closeable?, R> T.use(...)로 되어 있는데, 이는 T 타입이 Closeable의 하위 타입이어야 함을 의미한다.
  2. inline 함수이다.
    • use 함수가 호출될 때, 함수의 본문(body)이 호출 위치에 직접 복사된다.
      이는 함수 호출에 대한 오버헤드를 줄여 성능을 향상시킨다.
  3. 람다를 받게 만들어진 함수이다. (받고 있는 파라미터가 block 이라는 이름을 가진 함수)
    • use 함수는 block: (T) -> R이라는 매개변수를 받는다.
      이는 T 타입의 객체를 인수로 받고 R을 반환하는 람다 함수를 받는다는 의미이다.
      개발자는 이 람다 안에 리소스(reader)를 사용하는 코드를 작성할 수 있다.
      use 함수는 이 람다를 실행한 후, 람다의 실행이 끝나면 리소스를 자동으로 닫아준다.






use함수의 동작 원리 

BufferedReader와 같은 리소스는 사용 후 반드시 닫아주어야 메모리 누수나 파일 손상을 막을 수 있다.

자바에서는 이를 위해 try-with-resources 문법을 사용한다.

코틀린의 use 함수는 이 과정을 훨씬 더 간결하게 만들어 준다.

 


 

 

 

 

코틀린에서 컬렉션을 함수형으로 다루는 방법

* fruits 변수는 리스트라는 가정 하에 테스트 코드 작성

 

1. 필터와 맵

 

ex1. 사과만 주세요

val apples = fruits.filter { fruit -> fruit.name == "사과" }

 


 

 

ex2. 필터에 인덱스가 필요한 경우

val apples = fruits.filterIndexed { idx, fruit -> fruit.name == "사과" }

 


 

 

ex3. 사과의 가격들을 알려주세요

val apples = fruits.filter { fruit -> fruit.name == "사과" }
	.map { fruit -	> fruit.currentPrice }

 


 

 

ex4. 맵에서 인덱스가 필요하다면?

val apples = fruits.filter { fruit -> fruit.name == "사과" }
    .mapIndexed { idx, fruit ->
        fruit.currentPrice
    }

 


 

 

ex5. Mapping의 결과가 null이 아닌 것만 가져오고 싶다면?

val apples = fruits.filter { fruit -> fruit.name == "사과" }
    .mapNotNull { fruit -> fruit.nullOrValue() }

 

 


 

 

 

 

2. 다양한 컬렉션 처리 기능  

  • all : 조건을 모두 만족하면 true, 아니면 false
val isAllApple = fruits.all { fruit -> fruit.name == "사과" }

 


 

 

  • none : 조건을 모두 불만족하면 true, 아니면 false
val isNoApple = fruits.none { fruit -> fruit.name == "사과" }

 


 

 

  • any : 조건을 하나라도 만족하면 true, 아니면 false
val isNoApple = fruits.any { fruit -> fruit.factoryPrice >= 10_000 }

 

 


 

 

  • count : 개수를 센다
val fruitCount = fruits.count()

 


 

 

  • sortedBy : (오름차순) 정렬을 한다 
val fruitCount = fruits.sortedBy { fruit -> fruit.currentPrice }

 


 

 

  • sortedByDescending : (오름차순) 정렬을 한다
val fruitCount = fruits.sortedByDescending { fruit -> fruit.currentPrice }

 


 

 

  • distinctBy : 변형된 값을 기준으로 중복을 제거한다.    
// name 중복값을 제거한 후, map 을 사용해 이름만 남김
val distinctFruitNames = fruits.distinctBy { fruit -> fruit.name }
    .map { fruit -> fruit.name }

 


 

 

  • first : 첫번째 값을 가져온다 (무조건 Null이 아니어야함)
fruits.first()

 


 

 

  • firstOrNull : 첫번째 값 또는 null 을 가져온다
fruits.firstOrNull()

 


 

 

  • last : 마지막 값을 가져온다 (무조건 Null이 아니어야함)
fruits.last()

 


 

 

  • lastOrNull : 마지막 값 또는 null 을 가져온다
fruits.lastOrNull()

 

 


 

 



3. List를 Map으로

  • groupBy : 주어진 값을 키로 설정하여 value가 리스트인 Map으로 생성됨
val map: Map<String, List<Fruit>> = fruits.groupBy { fruit -> fruit.name }

 

* value를 지정하고 싶은 경우
val map: Map<String, List<Long>> = fruits
    .groupBy({ fruit -> fruit.name }, { fruit -> fruit.factoryPrice })

 


 

 

  • associateBy : 주어진 값을 키로 설정하여 value가 단일객체인 Map이 생성됨
          중복되지 않는 키를 가지고 Map을 만들 때 사용
val map: Map<Long, Fruit> = fruits.associateBy { fruit -> fruit.id }
* value를 지정하고 싶은 경우  
val map: Map<Long, Long> = fruits
    .associateBy({ fruit -> fruit.id }, { fruit -> fruit.factoryPrice })

 


 

 

  • Map에서도 Filter 사용 가능
val map: Map<String, List<Fruit>> = fruits.groupBy { fruit -> fruit.name }
    .filter{ (key, value) -> key == "사과" }

 

 


 

 

 

 

4. 중첩된 컬렉션 처리

* flatMap vs. map
     - map: 각 요소에 함수를 적용하여 새로운 동일한 구조의 컬렉션을 만듦
     - flatMap: 각 요소에 함수를 적용한 후, 그 결과를 하나의 평평한 컬렉션으로 합침
val nestedList = listOf(listOf(1, 2), listOf(3, 4))

// map => List<List>
val mappedList = nestedList.map { it.map { num -> num + 1 } }
// mappedList 결과: [[2, 3], [4, 5]]

// flatMap => List
val flatMappedList = nestedList.flatMap { it.map { num -> num + 1 } }
// flatMappedList 결과: [2, 3, 4, 5]​

 

 

  • flatten : 중첩돼있는 컬렉션을 중첩 해제 시킴

ex. 출고가와 현재가가 동일한 과일을 골라주세요!

// 조건 = fruitsInList: List<List<Fruit>>
val samePriceFruits = fruitsInList.flatMap { list ->
    list.filter { fruit -> fruit.factoryPrice == fruit.currentPrice }
}

// 리팩토링 시
val samePriceFruits = fruitsInList.flatMap { list -> list.samePriceFilter }

val List<Fruit>.samePriceFilter: List<Fruit>
    get() = this.filter(Fruit::isSamePrice)

data class Fruit(
    val id: Long,
    val name: String,
    val factoryPrice: Long,
    val currentPrice: Long
) {
    val isSamePrice: Boolean
        get() = factoryPrice == currentPrice
}

 

  •  

 

 

 

728x90