
이력서 웹사이트를 만들기 시작하면서 본격적으로 TypeScript를 사용하기로 했습니다.
클래스도 만들고, 인터페이스도 정의하고... 코드를 짜다 보니 문득 이런 생각이 들었습니다.
"어? 이거 Kotlin이랑 비슷한데?"
비슷한 것도 있고, 완전 다른 것도 있더군요.
오늘은 그 차이점을 공유합니다.
"만약 경력을 표시하는 기능을 만든다면?"
가상의 시나리오입니다.
하지만 이 예시를 통해 Kotlin과 TypeScript의 다양한 차이점을 비교해보겠습니다.
먼저 Career 클래스부터 볼까요?
먼저 Career 클래스를 만들었습니다.
Kotlin:
data class Career(
val company: String,
val startDate: LocalDate,
val endDate: LocalDate?,
val position: String
) {
fun calculateMonths(): Int {
val end = endDate ?: LocalDate.now()
return ChronoUnit.MONTHS.between(startDate, end).toInt()
}
}
TypeScript:
class Career {
constructor(
public company: string,
public startDate: Date,
public endDate: Date | null,
public position: string
) {}
calculateMonths(): number {
const end = this.endDate ?? new Date();
return monthsBetween(this.startDate, end);
}
}
"어? public을 직접 써야 하네?"
"data class의 자동 생성 기능이 없구나"
경력 기간을 계산하는 로직을 인터페이스로 분리했습니다.
Kotlin:
interface CareerCalculator {
fun calculateMonths(career: Career): Int
}
TypeScript:
interface CareerCalculator {
calculateMonths(career: Career): number;
}
"인터페이스 문법이 똑같네!"
여러 포맷터의 공통 로직을 추상 클래스로 만들었습니다.
Kotlin:
abstract class BaseFormatter {
protected abstract val locale: String
protected fun formatNumber(num: Int): String {
// 숫자 포맷팅 공통 로직
return when (locale) {
"ko" -> "${num}개월"
"en" -> "${num} months"
else -> num.toString()
}
}
}
TypeScript:
abstract class BaseFormatter {
protected abstract locale: string;
protected formatNumber(num: number): string {
// 숫자 포맷팅 공통 로직
switch (this.locale) {
case "ko":
return `${num}개월`;
case "en":
return `${num} months`;
default:
return num.toString();
}
}
}
"abstract 키워드가 똑같네!"
"protected도 똑같고!"
위에서 만든 Calculator를 사용해봅시다.
추상 클래스는 is-a 관계입니다.
"CareerFormatter is a BaseFormatter"
하지만 요즘은 has-a 관계를 더 선호합니다.
"CareerFormatter has a Calculator"
왜냐하면 더 유연하고 테스트하기 쉽기 때문입니다.
Kotlin:
// has-a 관계 (Composition)
// 위에서 정의한 CareerCalculator 사용
// Formatter 구현
class CareerFormatter(
private val calculator: CareerCalculator // 주입받음!
) {
fun format(career: Career): String {
val months = calculator.calculateMonths(career)
return "${career.company} (${months}개월)"
}
}
TypeScript:
// has-a 관계 (Composition)
// 위에서 정의한 CareerCalculator 사용
// Formatter 구현
class CareerFormatter {
constructor(
private calculator: CareerCalculator // 주입받음!
) {}
format(career: Career): string {
const months = this.calculator.calculateMonths(career);
return `${career.company} (${months}개월)`;
}
}
"상속보다 조합!"
"유연하고 테스트하기 쉽네!"
여러 형식으로 경력을 표시하려고 했습니다.
"이력서마다 다른 형식이 필요하면?"
Kotlin:
// 같은 인터페이스, 다른 구현
interface CareerFormatter {
fun format(career: Career): String
}
class SimpleFormatter : CareerFormatter {
override fun format(career: Career): String {
val months = career.calculateMonths()
return "${career.company} (${months}개월)"
}
}
class DetailedFormatter : CareerFormatter {
override fun format(career: Career): String {
val months = career.calculateMonths()
val years = months / 12
val remainingMonths = months % 12
return "${career.company} - ${career.position} (${years}년 ${remainingMonths}개월)"
}
}
class EnglishFormatter : CareerFormatter {
override fun format(career: Career): String {
val months = career.calculateMonths()
return "${career.company} - ${career.position} (${months} months)"
}
}
// 사용할 때 - 상황에 따라 선택
fun displayCareer(career: Career, formatter: CareerFormatter) {
println(formatter.format(career))
}
displayCareer(career, SimpleFormatter()) // 간단하게
displayCareer(career, DetailedFormatter()) // 상세하게
displayCareer(career, EnglishFormatter()) // 영문으로
TypeScript:
// 같은 인터페이스, 다른 구현
interface CareerFormatter {
format(career: Career): string;
}
class SimpleFormatter implements CareerFormatter {
format(career: Career): string {
const months = career.calculateMonths();
return `${career.company} (${months}개월)`;
}
}
class DetailedFormatter implements CareerFormatter {
format(career: Career): string {
const months = career.calculateMonths();
const years = Math.floor(months / 12);
const remainingMonths = months % 12;
return `${career.company} - ${career.position} (${years}년 ${remainingMonths}개월)`;
}
}
class EnglishFormatter implements CareerFormatter {
format(career: Career): string {
const months = career.calculateMonths();
return `${career.company} - ${career.position} (${months} months)`;
}
}
// 사용할 때 - 상황에 따라 선택
function displayCareer(career: Career, formatter: CareerFormatter) {
console.log(formatter.format(career));
}
displayCareer(career, new SimpleFormatter()); // 간단하게
displayCareer(career, new DetailedFormatter()); // 상세하게
displayCareer(career, new EnglishFormatter()); // 영문으로
"implements 키워드 쓰네!"
"Kotlin은 : 하나로 끝나는데"
"새로운 Formatter 추가하기 쉽네!"
타입 체크:
Kotlin:
val formatter: CareerFormatter = getFormatter()
if (formatter is EnglishFormatter) {
println("영문 형식")
}
TypeScript:
const formatter: CareerFormatter = getFormatter();
if (formatter instanceof EnglishFormatter) {
console.log("영문 형식");
}
"is vs instanceof!"
1. Nullable 타입 선언
Kotlin:
val name: String? = null // nullable
val age: Int? = null
TypeScript:
const name: string | null = null; // nullable
const age: number | null = null;
"| null 이렇게 쓰네!"
2. Optional Chaining
Kotlin:
val userName = user?.name // user가 null이면 null 반환
val length = user?.name?.length
TypeScript:
const userName = user?.name; // user가 null이면 undefined 반환
const length = user?.name?.length;
"?. 문법이 똑같네!"
3. Nullish Coalescing
날짜를 다루려고 했습니다.
Kotlin:
val endDate: LocalDate? = career.endDate
val months = endDate?.let {
ChronoUnit.MONTHS.between(career.startDate, it)
} ?: ChronoUnit.MONTHS.between(career.startDate, LocalDate.now())
// 또는 간단하게
val name = user.name ?: "Guest"
TypeScript:
const endDate: Date | null = career.endDate;
const months = endDate
? monthsBetween(career.startDate, endDate)
: monthsBetween(career.startDate, new Date());
// 또는 간단하게
const name = user.name ?? "Guest";
"?: vs ??, 비슷하네!"
4. Non-null Assertion
Kotlin:
val name: String? = getName()
val length = name!!.length // null이면 에러!
TypeScript:
const name: string | null = getName();
const length = name!.length; // null이면 에러!
"!! vs !, 위험한 건 똑같네!"
5. null vs undefined (TypeScript만)
TypeScript:
let value1: string | null = null; // 명시적으로 없음
let value2: string | undefined; // 아직 할당 안 됨
let value3: string | null | undefined; // 둘 다 가능
// null과 undefined 차이
console.log(null == undefined); // true
console.log(null === undefined); // false
"null이랑 undefined가 다르구나!"
"Kotlin은 null만 있는데..."
Result 타입을 만들려고 했습니다.
Kotlin:
sealed class Result<out T> {
data class Success<T>(val data: T) : Result<T>()
data class Error(val message: String) : Result<Nothing>()
}
fun <T> handleResult(result: Result<T>): T {
return when (result) {
is Result.Success -> result.data
is Result.Error -> throw Exception(result.message)
}
}
TypeScript:
type Result<T> =
| { type: 'success'; data: T }
| { type: 'error'; message: string };
function handleResult<T>(result: Result<T>): T {
switch (result.type) {
case 'success':
return result.data;
case 'error':
throw new Error(result.message);
}
}
"제네릭 문법이 똑같네!"
"Sealed Class 대신 type 태그 쓰는구나"
캡슐화를 하려고 했습니다.
Kotlin:
class CareerService(
private val calculator: CareerCalculator
) {
fun getMonths(career: Career): Int {
return calculator.calculateMonths(career)
}
private fun clearCache() {
// private 메서드 예시
}
}
TypeScript:
class CareerService {
constructor(
private calculator: CareerCalculator
) {}
getMonths(career: Career): number {
return this.calculator.calculateMonths(career);
}
private clearCache(): void {
// private 메서드 예시
}
}
"private, public 똑같네!"
"Kotlin의 internal은 없구나"
Base Service를 만들려고 했습니다.
Kotlin:
open class BaseService<T> {
protected open fun validate(item: T): Boolean = true
fun process(item: T): Result<T> {
return if (validate(item)) {
Result.Success(item)
} else {
Result.Error("Validation failed")
}
}
}
class CareerService : BaseService<Career>() {
override fun validate(item: Career): Boolean {
return item.company.isNotEmpty()
}
}
TypeScript:
class BaseService<T> {
protected validate(item: T): boolean {
return true;
}
process(item: T): Result<T> {
return this.validate(item)
? { type: 'success', data: item }
: { type: 'error', message: 'Validation failed' };
}
}
class CareerService extends BaseService<Career> {
protected validate(item: Career): boolean {
return item.company.length > 0;
}
}
"override 키워드가 없네?"
"open도 없고"
유틸리티 함수를 만들려고 했습니다.
Kotlin:
class CareerUtils {
companion object {
fun formatDuration(months: Int): String {
val years = months / 12
val remainingMonths = months % 12
return "${years}년 ${remainingMonths}개월"
}
}
}
CareerUtils.formatDuration(25)
TypeScript:
class CareerUtils {
static formatDuration(months: number): string {
const years = Math.floor(months / 12);
const remainingMonths = months % 12;
return `${years}년 ${remainingMonths}개월`;
}
}
CareerUtils.formatDuration(25);
"static이 더 간단하네!"
String에 기능을 추가하고 싶었습니다.
Kotlin:
fun String.toCareerPosition(): String {
return this.split(" ")
.joinToString(" ") { it.capitalize() }
}
"senior developer".toCareerPosition()
TypeScript:
// Extension Function 없음!
function toCareerPosition(str: string): string {
return str.split(" ")
.map(word => word.charAt(0).toUpperCase() + word.slice(1))
.join(" ");
}
toCareerPosition("senior developer");
"Extension Function이 없네..."
런타임 버그가 발생했습니다.
API가 잘못된 데이터를 보냈습니다:
{
"company": 123,
"startDate": "2020-01-01"
}
TypeScript는 컴파일 OK, 런타임에 터짐!
해결:
import { z } from 'zod';
const CareerSchema = z.object({
company: z.string(),
startDate: z.string(),
endDate: z.string().nullable(),
position: z.string()
});
const career = CareerSchema.parse(data);
"아... 그래서 Zod를 쓰는구나"
위에서 배운 것들을 종합해서 "만약 경력 관리 시스템을 만든다면 어떻게 될까?" 생각해봤습니다.
가상의 시나리오입니다.
하지만 실제로 이렇게 만들 수 있습니다.
MVI + Clean Architecture 패턴을 사용합니다.
MVI란?
Clean Architecture란?
전체 구조:
// 1. Data Layer (데이터 가져오기)
interface CareerApi {
suspend fun getCareers(): List<CareerDto>
}
class CareerRepositoryImpl(
private val api: CareerApi
) : CareerRepository {
override suspend fun getCareers(): Result<List<Career>> {
return try {
val response = api.getCareers()
// DTO를 Domain Model로 변환 (Mapper 사용)
Result.success(response.map { it.toDomain() })
} catch (e: Exception) {
Result.failure(e)
}
}
}
// 2. Domain Layer (비즈니스 로직)
class GetCareersUseCase(
private val repository: CareerRepository
) {
suspend operator fun invoke(): Result<List<Career>> {
return repository.getCareers()
}
}
// 3. Presentation Layer (MVI 패턴)
// Intent: 사용자 행동
sealed class CareerIntent {
object LoadCareers : CareerIntent()
}
// State: 화면 상태
sealed class CareerState {
object Loading : CareerState()
data class Success(val careers: List<Career>) : CareerState()
data class Error(val message: String) : CareerState()
}
// ViewModel: Intent를 받아서 State 변경
class CareerViewModel(
private val useCase: GetCareersUseCase
) : ViewModel() {
private val _state = MutableStateFlow<CareerState>(CareerState.Loading)
val state = _state.asStateFlow()
fun onIntent(intent: CareerIntent) {
when (intent) {
is CareerIntent.LoadCareers -> loadCareers()
}
}
private fun loadCareers() {
viewModelScope.launch {
_state.value = CareerState.Loading
useCase().fold(
onSuccess = { _state.value = CareerState.Success(it) },
onFailure = { _state.value = CareerState.Error(it.message ?: "") }
)
}
}
}
// 4. UI Layer (Compose)
@Composable
fun CareerScreen(viewModel: CareerViewModel) {
val state by viewModel.state.collectAsState()
// State에 따라 다른 화면 표시
when (state) {
is CareerState.Loading -> LoadingView()
is CareerState.Success -> CareerList((state as CareerState.Success).careers)
is CareerState.Error -> ErrorView((state as CareerState.Error).message)
}
}
특징:
React Query + TypeScript 조합을 사용합니다.
React Query란?
TypeScript란?
전체 구조:
// 1. API Layer (데이터 가져오기 + 타입 검증)
import { z } from 'zod';
// Zod: 런타임 타입 검증 라이브러리
const CareerSchema = z.object({
company: z.string(),
startDate: z.string(),
endDate: z.string().nullable(),
position: z.string()
});
// TypeScript 타입 자동 생성
type Career = z.infer<typeof CareerSchema>;
async function fetchCareers(): Promise<Career[]> {
const response = await fetch('/api/careers');
const data = await response.json();
// Zod로 런타임 검증 (Type Erasure 해결)
return z.array(CareerSchema).parse(data);
}
// 2. Hooks Layer (React Query 사용)
function useCareers() {
// React Query가 자동으로:
// - 로딩 상태 관리
// - 에러 처리
// - 캐싱
return useQuery({
queryKey: ['careers'], // 캐시 키
queryFn: fetchCareers // 데이터 가져오는 함수
});
}
// 3. Component Layer (React)
function CareerScreen() {
// React Query Hook 사용
const { data, isLoading, error } = useCareers();
// 상태에 따라 다른 화면 표시
if (isLoading) return <LoadingView />;
if (error) return <ErrorView message={error.message} />;
return <CareerList careers={data} />;
}
특징:
| 항목 | Android (Kotlin) | Web (TypeScript) |
|---|---|---|
| 아키텍처 | MVI + Clean Architecture | React Query |
| 레이어 구조 | Data/Domain/Presentation 분리 | API/Hooks/Component |
| 상태 관리 | MVI (Intent → State) | React Query 자동 |
| 비동기 | Coroutines (suspend) | Promise (async/await) |
| 타입 검증 | 컴파일 타임 | Zod 런타임 |
| Mapper | 필수 (Nominal Typing) | 선택 (Structural Typing) |
| 특징 | 안전하지만 복잡 | 간단하지만 Zod 필요 |
Android:
Web:
Kotlin: JVM, 안전성 우선
TypeScript: Browser, 유연성 우선
하지만 목표는 같습니다:
경력 표시 기능을 예시로 들면서 많은 것을 배웠습니다.
Android 개발 경험이 있다면, TypeScript를 배울 때 도움이 될 겁니다.
클래스, 인터페이스, 제네릭, 상속... 이미 익숙한 개념들이 많으니까요.
물론 문법은 다르지만, "왜 이렇게 설계하는지"는 이미 알고 있습니다.
그 경험이 TypeScript를 이해하는 데 큰 힘이 됩니다.