UI 테스트와 단위 테스트는 iOS 애플리케이션의 품질을 높이기 위해 사용되지만, 두 테스트는 서로 다른 목표와 방식으로 수행됩니다.
1. 단위(Unit) 테스트
단위 테스트는 애플리케이션의 특정 기능을 독립적으로 테스트하는 것을 목표로 하며, 보통 함수나 메서드 수준에서 작성됩니다.
특징:
- 독립적: 단위 테스트는 다른 코드에 의존하지 않고 독립적으로 실행되도록 설계됩니다.
- 격리된 테스트: 외부 의존성(예: 네트워크 요청, 데이터베이스 접근 등)이 있을 경우, 이를 실제로 호출하지 않고 모의 객체(Mock Object)를 사용하여 테스트를 격리시킵니다.
- 빠른 실행: 단위 테스트는 작은 코드 조각을 테스트하기 때문에 매우 빠르게 실행되며, 즉각적인 피드백을 제공하여 코드 수정 시 영향을 최소화합니다.
- 구현 검증: 각 함수가 예상대로 작동하는지를 확인하기 때문에, 특정 기능에 대한 의도한 구현이 올바른지 검증할 수 있습니다.
장점:
- 빠른 오류 검출: 작은 코드 조각의 문제를 빨리 발견할 수 있어, 전체 시스템에 문제가 퍼지기 전에 수정할 수 있습니다.
- 유지보수 용이: 코드 변경 시 영향 범위를 쉽게 파악할 수 있고, 리팩토링 후에도 기능이 올바르게 작동하는지 확인할 수 있습니다.
- 문서화 역할: 테스트 코드를 통해 기능의 의도나 사용법을 이해하기 쉬워집니다.
단위 테스트의 예:
- 계산기 앱에서 덧셈, 뺄셈 등의 함수를 개별적으로 테스트하여 함수가 정확히 동작하는지 확인.
- 네트워크 서비스의 응답 처리 로직을 테스트하기 위해 모의 응답(Mock Response)을 설정하여 데이터를 파싱 하는 로직이 잘 작동하는지 확인.
🔥 단위 태스트의 예로 그러면 함수의 결과를 궁금할 때 print() 문으로 어떤 결과가 나오는지 확인하는 것도 포함되는 거야?
print() 문으로 함수의 결과를 확인하는 것은 단위 테스트와는 다릅니다. 단위 테스트는 특정 기능의 동작을 자동화된 테스트 코드로 작성하여, 코드가 수정되더라도 동일한 테스트를 반복적으로 실행할 수 있도록 하는 것이 목표입니다.
차이점 정리
- print() 문:
- 일회성으로 특정 함수의 출력을 콘솔에 출력해 결과를 확인할 수 있지만, 테스트가 자동화되지 않기 때문에 매번 수동으로 결과를 확인해야 합니다.
- 코드 변경 후 다시 결과를 확인할 때 반복적으로 수동 확인이 필요하며, 코드에 영향을 주지 않고 임시로 확인할 때 주로 사용합니다.
- 단위 테스트:
- XCTest 등의 테스트 프레임워크를 사용하여 테스트 케이스를 코드로 작성합니다. 코드가 변경되어도 테스트가 자동으로 실행되기 때문에 코드가 예상대로 동작하는지 쉽게 확인할 수 있습니다.
- 각 함수가 기대한 대로 작동하는지 예상 결과와 실제 결과를 비교하여, 성공 여부를 자동으로 판별합니다.
- 예를 들어, XCTAssertEqual()을 사용해 함수의 결과가 예상 값과 같은지 확인하는 식으로 작성합니다.
import XCTest
class CalculatorTests: XCTestCase {
func testAddition() {
let result = add(2, 3)
XCTAssertEqual(result, 5, "2 + 3 should equal 5")
}
func add(_ a: Int, _ b: Int) -> Int {
return a + b
}
}
2. 사용자 인터페이스(UI) 테스트
UI 테스트는 사용자 관점에서 애플리케이션을 테스트하며, 사용자가 애플리케이션과 상호작용하는 모든 흐름을 자동화하여 테스트합니다.
특징:
- 실제 UI 상호작용: UI 테스트는 버튼 클릭, 텍스트 입력, 화면 전환 등 실제 UI 요소와 상호작용합니다. 사용자가 앱을 사용할 때의 경로와 흐름을 테스트할 수 있습니다.
- 전체적인 사용자 경험 확인: UI 테스트는 기능이 아닌 사용자의 경험을 확인하는 데 중점을 둡니다. 예를 들어, 화면이 올바르게 표시되는지, 버튼을 클릭했을 때 올바른 화면으로 이동하는지 등을 확인합니다.
- 시간 소요: UI 테스트는 전체 화면을 테스트하기 때문에 상대적으로 오래 걸리고, 다양한 디바이스 환경에서 테스트해야 할 수도 있습니다.
- XCTest와 XCUITest: iOS에서는 XCTest 프레임워크와 XCUITest를 사용하여 UI 테스트를 작성할 수 있습니다. UI 자동화가 가능해지면서, 애플리케이션의 흐름을 반복적으로 테스트하기 좋습니다.
장점:
- 사용자 관점에서의 검증: 사용자 경험에 기반한 검증을 통해 예상치 못한 오류나 사용자 불편 요소를 발견할 수 있습니다.
- 회귀 테스트에 유리: 새로운 기능을 추가하거나 기존 기능을 수정할 때, UI 테스트는 기존 UI가 안정적으로 작동하는지 확인하여 회귀 버그를 방지할 수 있습니다.
- 앱 배포 전 최종 확인: 배포 전에 실제 디바이스 환경과 유사한 조건에서 앱의 전반적인 동작을 확인하는 데 유용합니다.
UI 테스트의 예:
- 로그인 화면에서 올바른 사용자 이름과 비밀번호를 입력하고 로그인 버튼을 클릭했을 때 홈 화면으로 이동하는지 확인.
- 아이템을 클릭하면 상세 화면이 나타나고, 뒤로 가기 버튼이 정상적으로 작동하여 이전 화면으로 돌아오는지 확인.
단위 테스트와 UI 테스트의 주요 차이점 정리
구분 | 단위 (Unit) 테스트 | 사용자 인터페이스 (UI) 테스트 |
대상 | 특정 기능, 함수 또는 메서드 | 사용자 인터페이스와 전체 앱 흐름 |
목적 | 개별 기능 검증 (코드의 정확성 확인) | 사용자 경험 검증 (UI 흐름 및 사용성 확인) |
외부 의존성 | 외부 의존성을 모의 객체로 대체하여 격리 | 실제 UI 환경에서 실행 |
테스트 속도 | 매우 빠름 (로컬에서 실행) | 상대적으로 느림 (UI 요소를 실제로 렌더링) |
유지보수 비용 | 낮음 | 높음 (UI 변경 시 테스트 수정 필요) |
실행 빈도 | 자주 실행 (빌드할 때마다 또는 코드 변경 시) | 보통 배포 전 또는 주요 기능 변경 시 |
XCTest 프레임워크를 사용하여 테스트를 작성하는 방법은 무엇인가요?
단위 테스트를 진행할 때 별도의 프로젝트를 만들 필요는 없고, 기존 프로젝트 내에 테스트용 파일을 추가하여 테스트 코드를 작성하고 실행합니다. iOS 개발에서 단위 테스트를 작성할 때는 보통 Xcode의 XCTest 프레임워크를 사용하며, 다음 단계로 진행할 수 있습니다.
단위 테스트 진행 방법
- 테스트 타깃 추가하기:
- Xcode 프로젝트를 생성할 때 “Include Unit Tests” 옵션을 선택하면 자동으로 테스트 타깃이 추가됩니다.
- 만약 이미 프로젝트를 생성한 후에 테스트 타깃을 추가하고 싶다면, File > New > Target으로 이동해 Unit Testing Bundle을 선택해 추가할 수 있습니다.
- 테스트 파일 생성:
- 테스트 타깃을 추가하면 Xcode가 기본적으로 ProjectNameTests라는 폴더에 테스트 파일을 하나 생성합니다.
- 더 많은 테스트 파일이 필요하면, File > New > File로 이동해 Unit Test Case Class를 선택해 새로운 테스트 파일을 추가할 수 있습니다.
- 테스트 코드 작성하기:
- 테스트 파일이 생성되면, XCTestCase 클래스를 상속하는 테스트 클래스가 만들어집니다.
- 각 테스트 함수는 test로 시작하는 이름을 가져야 하며, 테스트할 코드를 함수 내에 작성합니다.
- 테스트 대상이 되는 코드는 별도의 파일로 분리된 프로젝트 코드 안에 위치하고, 이를 테스트 파일에서 가져와서 실행할 수 있습니다.
- XCTest의 메서드 사용:
- XCTAssertEqual, XCTAssertTrue, XCTAssertNil 등과 같은 XCTest의 다양한 메서드를 사용해 결과를 검증합니다.
예시
예를 들어, 프로젝트에 계산기 기능을 추가하고, Calculator.swift 파일에 add 함수를 작성했다고 가정해 보겠습니다.
// Calculator.swift
class Calculator {
func add(_ a: Int, _ b: Int) -> Int {
return a + b
}
}
이제 CalculatorTests.swift 파일에서 add 함수를 테스트할 수 있습니다.
// CalculatorTests.swift
import XCTest
@testable import YourProjectName
class CalculatorTests: XCTestCase {
func testAddition() {
let calculator = Calculator()
let result = calculator.add(2, 3)
XCTAssertEqual(result, 5, "2 + 3 should equal 5")
}
}
여기서 @testable import YourProjectName을 사용해 프로젝트 코드를 테스트 코드에서 접근할 수 있게 만듭니다.
테스트 실행하기
테스트 코드 작성 후, Xcode에서 Command + U를 눌러 테스트를 실행하거나, Xcode의 테스트 내비게이터에서 각 테스트 함수를 선택해 개별적으로 실행할 수 있습니다. 실행 후 초록색 체크 표시가 뜨면 테스트가 성공한 것이고, 빨간색 X 표시가 뜨면 실패한 것입니다.
정리
단위 테스트는 기존 프로젝트 내에서 테스트 파일을 추가하여 진행합니다. 별도의 프로젝트를 만들 필요 없이, Xcode의 XCTest 프레임워크를 활용해 단위 테스트용 파일을 생성하고, 테스트할 함수의 결과를 검증하는 코드를 작성해 자동화된 테스트를 반복적으로 실행할 수 있습니다.
테스트 주도 개발(TDD)의 장점은 무엇인가요?
테스트 주도 개발(TDD, Test-Driven Development)은 테스트를 먼저 작성하고, 그 테스트를 통과하기 위해 최소한의 코드를 작성하는 방식으로 개발하는 방법론입니다. TDD는 코드를 작성하기 전에 예상되는 결과와 요구 사항을 명확히 정의하게 해 주므로 여러 가지 장점을 가지고 있습니다.
TDD의 주요 장점
- 요구 사항 명확화 및 코드 품질 향상:
- 테스트를 먼저 작성하면서 애플리케이션이 정확히 어떤 기능을 해야 하는지, 요구사항이 무엇인지 구체적으로 정의하게 됩니다.
- 이로 인해 코드가 보다 명확해지고, 불필요한 코드 작성을 줄일 수 있어 코드 품질이 향상됩니다.
- 디버깅 시간 단축:
- 코드를 작성하면서 바로 테스트로 검증할 수 있기 때문에 오류를 조기에 발견하고 수정할 수 있습니다.
- 코드를 작성한 후 디버깅에 소모되는 시간을 크게 줄일 수 있어, 장기적으로 개발 효율성이 높아집니다.
- 안정적이고 신뢰성 있는 코드:
- 코드 작성과 동시에 테스트가 지속적으로 자동화되어 실행되기 때문에, 코드가 예상치 않게 변경되는 경우를 방지할 수 있습니다.
- 특히 리팩토링 과정에서 기존 기능에 영향을 미치지 않도록 확인할 수 있어, 코드의 신뢰성과 안정성을 보장합니다.
- 유지보수와 리팩토링의 용이성:
- TDD는 리팩토링을 쉽게 만들어줍니다. 코드의 구조를 개선할 때 기존 테스트가 이를 보호하는 역할을 해주기 때문에 기능이 깨지지 않는 상태에서 코드를 정리할 수 있습니다.
- 유지보수 시에도 테스트 케이스가 문서처럼 남아 있어 코드의 의도와 기능을 쉽게 파악할 수 있습니다.
- 더 나은 설계 유도:
- 테스트를 작성할 때 기능이 잘 분리되어야 테스트가 용이해지므로, 자연스럽게 객체지향적이고 모듈화된 설계가 이루어집니다.
- 이는 코드 재사용성이나 가독성을 높여줍니다. TDD는 지나치게 복잡한 설계를 피하고, 실제로 필요한 기능만 포함한 간결한 설계를 유도합니다.
- 개발 속도 향상:
- TDD는 초기에는 개발 속도가 느리게 느껴질 수 있지만, 오류를 조기에 잡아내고 수정할 수 있기 때문에 장기적으로는 개발 속도가 빨라집니다.
- 변경사항을 테스트하는 반복 작업이 자동화되어 매번 전체 시스템을 검증할 수 있기 때문에, 복잡한 애플리케이션에서 더 큰 장점을 제공합니다.
- 문서화 역할 수행:
- 테스트 코드는 일종의 문서 역할을 합니다. 코드의 동작 방식과 예상 결과를 보여주기 때문에, 개발자나 유지보수 담당자가 코드를 이해하는 데 도움이 됩니다.
- 또한 새 팀원이 프로젝트에 참여할 때, 테스트 코드를 통해 기능을 빠르게 파악할 수 있습니다.
예시로 보는 TDD 과정
TDD는 Red-Green-Refactor 사이클로 진행됩니다:
- Red (실패하는 테스트 작성): 요구사항에 맞는 테스트 케이스를 작성합니다. 이때 테스트는 반드시 실패해야 합니다.
- Green (테스트 통과하기 위한 최소한의 코드 작성): 테스트가 통과할 정도로만 코드를 작성합니다. 이 과정에서는 코드의 완성도보다는 테스트 통과에만 집중합니다.
- Refactor (코드 개선): 테스트가 통과했으면, 코드를 더 간결하고 유지보수하기 쉽게 리팩터링 합니다. 리팩토링 후에도 테스트가 여전히 통과해야 합니다.
결론
TDD는 처음에는 익숙해지는 데 시간이 걸리지만, 코드의 신뢰성을 높이고, 코드 품질과 유지보수를 크게 향상하는 장점이 있습니다. 코드의 안정성과 일관성을 보장하면서 애플리케이션의 성장과 확장을 도와주는 중요한 개발 방법론입니다.
의존성 주입(Dependency Injection)을 활용하여 테스트 가능한 코드를 작성하는 방법은 무엇인가요?
의존성 주입(Dependency Injection, DI)은 객체가 의존하는 다른 객체를 외부에서 주입해주는 설계 패턴입니다. 이를 통해 코드의 결합도를 낮추고, 테스트 가능성을 높일 수 있습니다. DI는 특히 테스트 환경에서 Mock 객체를 주입하여 외부 의존성과 독립적으로 코드를 검증할 수 있게 해 줍니다. 아래에서 DI를 활용하여 테스트 가능한 코드를 작성하는 방법을 살펴보겠습니다.
의존성 주입을 활용한 테스트 가능한 코드 작성 방법
1. DI 없이 작성한 코드의 문제점
다음은 네트워크를 통해 데이터를 가져오는 WeatherService를 사용하는 WeatherViewModel의 예시입니다. DI를 적용하지 않은 경우, WeatherViewModel은 직접 WeatherService 인스턴스를 생성합니다.
class WeatherService {
func fetchWeatherData() -> String {
return "Sunny"
}
}
class WeatherViewModel {
private let weatherService = WeatherService() // 직접 의존성 생성
func getWeather() -> String {
return weatherService.fetchWeatherData()
}
}
위 코드에서 WeatherViewModel은 WeatherService에 직접 의존합니다. 이 경우, WeatherViewModel을 테스트할 때 WeatherService가 실제 데이터를 반환해야 하므로 네트워크 호출이 필요한 환경이 설정되어야 합니다. 이로 인해 테스트가 느려지고 외부 시스템에 의존하게 됩니다.
2. DI 적용: 의존성 주입을 사용한 코드 구조 변경
의존성 주입을 사용하면 WeatherViewModel에서 WeatherService를 직접 생성하지 않고, 외부에서 주입받아 사용하게 됩니다. 이를 통해 WeatherViewModel은 구체적인 서비스의 구현이 아닌 프로토콜에 의존하게 하여 테스트가 가능해집니다.
protocol WeatherServiceProtocol {
func fetchWeatherData() -> String
}
class WeatherService: WeatherServiceProtocol {
func fetchWeatherData() -> String {
return "Sunny"
}
}
class WeatherViewModel {
private let weatherService: WeatherServiceProtocol // 프로토콜에 의존
// 생성자를 통한 의존성 주입
init(weatherService: WeatherServiceProtocol) {
self.weatherService = weatherService
}
func getWeather() -> String {
return weatherService.fetchWeatherData()
}
}
이제 WeatherViewModel은 WeatherServiceProtocol을 구현한 객체를 생성자로 주입받습니다. 이를 통해 테스트 시에는 실제 WeatherService 대신 Mock 객체를 주입할 수 있게 되었습니다.
🔥 위의 의존성 주입을 사용하여 테스트 가능한 코드에 대해서
1. WeatherServiceProtocol 프로토콜
protocol WeatherServiceProtocol {
func fetchWeatherData() -> String
}
- WeatherServiceProtocol은 WeatherService가 수행해야 하는 기능을 정의하는 프로토콜입니다.
- 이 프로토콜을 사용하여 WeatherService가 실제 구현된 서비스 객체임을 알릴 수 있으며, 나중에 WeatherService를 대신할 수 있는 Mock 객체(테스트용 객체)를 주입할 수 있습니다.
- 프로토콜을 정의하면 WeatherService의 구체적인 구현에 직접적으로 의존하지 않아도 되기 때문에 코드의 결합도를 낮출 수 있습니다.
2. WeatherService 클래스
class WeatherService: WeatherServiceProtocol {
func fetchWeatherData() -> String {
return "Sunny"
}
}
- WeatherService 클래스는 실제 날씨 데이터를 제공하는 서비스 객체입니다.
- WeatherServiceProtocol을 채택하고 있어, fetchWeatherData 메서드를 구현해야 합니다.
- 이 클래스에서는 fetchWeatherData 메서드가 "Sunny"라는 문자열을 반환하도록 설정되어 있습니다.
- 실제 서비스 클래스가 필요할 때 WeatherService를 사용할 수 있으며, 테스트 시에는 MockWeatherService와 같은 Mock 객체로 교체할 수 있습니다.
3. WeatherViewModel 클래스
class WeatherViewModel {
private let weatherService: WeatherServiceProtocol // 프로토콜에 의존
// 생성자를 통한 의존성 주입
init(weatherService: WeatherServiceProtocol) {
self.weatherService = weatherService
}
func getWeather() -> String {
return weatherService.fetchWeatherData()
}
}
- WeatherViewModel은 UI 레이어와 서비스 레이어를 연결하는 역할을 수행합니다.
- WeatherServiceProtocol 타입의 weatherService 속성을 가지고 있으며, WeatherService의 구체적인 구현에 의존하지 않고, 프로토콜에 의존하도록 설계되었습니다.
- 이 클래스의 생성자는 WeatherServiceProtocol을 구현한 객체를 받아 weatherService 속성에 주입합니다. 생성자를 통한 의존성 주입 방식으로 구현되어 있기 때문에, 외부에서 구체적인 의존성(서비스 객체)을 전달받을 수 있습니다.
- getWeather() 메서드는 weatherService.fetchWeatherData() 메서드를 호출하여 날씨 데이터를 반환합니다.
의존성 주입을 통한 테스트 가능성 향상
이 구조에서는 WeatherService를 직접 생성하지 않고, 외부에서 주입을 받기 때문에 테스트 시 다음과 같은 장점을 얻을 수 있습니다:
- Mock 객체 주입 가능: 테스트 환경에서는 WeatherService 대신 MockWeatherService와 같은 테스트용 객체를 weatherService에 주입할 수 있습니다. 이를 통해 네트워크 호출이나 외부 의존성을 제거하고, 코드의 독립적인 테스트가 가능합니다.
- 유연성: WeatherViewModel이 WeatherServiceProtocol이라는 추상화에 의존하기 때문에, 나중에 WeatherService가 바뀌어도 WeatherViewModel의 코드 변경이 최소화됩니다.
- 결합도 감소: WeatherViewModel은 WeatherService의 구체적인 구현에 의존하지 않기 때문에 코드의 결합도가 낮아지고, 리팩토링이나 유지보수가 더 쉬워집니다
전체 흐름
- 프로토콜 정의: WeatherServiceProtocol을 정의하여, WeatherService의 기능을 추상화합니다.
- 구현 클래스: WeatherService는 실제로 날씨 데이터를 제공하는 클래스이며, WeatherServiceProtocol을 구현합니다.
- 의존성 주입을 통한 유연한 설계: WeatherViewModel은 WeatherServiceProtocol에 의존하며, 생성자 주입 방식으로 의존성을 주입받습니다. 이를 통해 테스트 시에는 Mock 객체를 사용할 수 있게 되어 코드의 테스트 가능성을 높입니다.
이 구조는 코드의 테스트 가능성을 높이고, 유지보수를 용이하게 하며, 코드 결합도를 낮추는 장점이 있습니다
🔥 init(weatherService: WeatherServiceProtocol) { self.weatherService = weatherService } ?
WeatherViewModel의 생성자 init(weatherService:)는 WeatherServiceProtocol 타입의 객체를 외부에서 주입받아 weatherService 속성에 할당하는 역할을 합니다.
왜 init(weatherService:)를 사용하는 걸까?
WeatherViewModel은 날씨 데이터를 가져와야 합니다. 그래서 WeatherService라는 객체가 필요하지만, 직접 그 객체를 만들지 않고, 외부에서 생성해서 넣어주는 방식으로 설계되었습니다. 이게 바로 생성자를 통한 의존성 주입이에요.
이게 왜 유용할까?
생성자로 WeatherServiceProtocol을 받기 때문에, 실제 앱에서는 진짜 데이터를 주는 WeatherService 객체를 넣어줄 수 있고, 테스트에서는 가짜(Mock) 데이터를 주는 MockWeatherService 객체를 넣어줄 수 있습니다.
즉, 필요에 따라 원하는 객체를 넣어서 사용할 수 있게 됩니다.
왜 WeatherViewMode(weatherService: WeatherServiceProtocol) 이렇게 안 쓰는 거야?
사실 생성자(init)를 사용하지 않고 직접 메서드의 파라미터로 객체를 전달할 수도 있습니다.
생성자를 사용하는 이유
a. 초기화 시점에 필요한 의존성을 설정
생성자를 통해 WeatherServiceProtocol을 주입하면, WeatherViewModel이 생성되는 초기화 시점에서 필요한 의존성이 확실히 설정됩니다. 즉, WeatherViewModel을 사용할 때 반드시 WeatherServiceProtocol이 주입된 상태로 시작하게 되는 것이죠.
class WeatherViewModel {
private let weatherService: WeatherServiceProtocol
init(weatherService: WeatherServiceProtocol) {
self.weatherService = weatherService
}
func getWeather() -> String {
return weatherService.fetchWeatherData()
}
}
위 방식은 WeatherViewModel이 생성될 때 항상 올바른 weatherService가 설정된 상태로 보장되기 때문에 코드의 안정성을 높여줍니다.
b. 안정성 향상
생성자를 통한 의존성 주입을 사용하면, WeatherViewModel이 생성될 때 필요한 모든 의존성이 빠짐없이 설정되어 있음을 보장할 수 있습니다. 만약 생성자 없이 메서드 파라미터로 weatherService를 넘겨야 한다면, 다른 코드에서 실수로 이 의존성을 누락하거나, 필요할 때 주입하지 않는 상황이 생길 수 있습니다.
c. 간결하고 일관된 사용법
생성자를 사용하면, WeatherViewModel 인스턴스를 사용할 때마다 같은 방식으로 객체를 주입할 수 있습니다. 반면, 메서드의 파라미터로 매번 주입하는 방식은 코드가 여러 곳에서 반복될 수 있어 가독성과 유지보수가 어려워질 수 있습니다.
'정보 > 레벨 1' 카테고리의 다른 글
Swift의 제네릭(Generic)에 대해 설명해주세요. (0) | 2024.11.15 |
---|---|
의존성 주입을 활용하여 테스트 가능한 코드 작성 - 추가 (0) | 2024.11.15 |
상속(Inheritance)과 프로토콜(Protocol)의 차이점은 무엇인가요? (1) | 2024.11.14 |
ARC(Automatic Reference Counting)의 동작 원리는 무엇인가요? (0) | 2024.11.14 |
UIKit에서 TableView와 CollectionView의 차이점은 무엇인가요? (0) | 2024.11.14 |