Profile img

Hi, I'm who's behind Fedify, Hollo, BotKit, and this website, Hackers' Pub! My main account is at @hongminhee洪 民憙 (Hong Minhee).

Fedify, Hollo, BotKit, 그리고 보고 계신 이 사이트 Hackers' Pub을 만들고 있습니다. 제 메인 계정은: @hongminhee洪 民憙 (Hong Minhee).

FedifyHolloBotKit、そしてこのサイト、Hackers' Pubを作っています。私のメインアカウントは「@hongminhee洪 民憙 (Hong Minhee)」に。

Website
hongminhee.org
GitHub
@dahlia
Hollo
@hongminhee@hollo.social
DEV
@hongminhee
velog
@hongminhee
Qiita
@hongminhee
Zenn
@hongminhee
Matrix
@hongminhee:matrix.org
X
@hongminhee

Revisiting Java's Checked Exceptions: An Underappreciated Type Safety Feature

洪 民憙 (Hong Minhee) @hongminhee@hackers.pub

Despite their bad reputation in the Java community, checked exceptions provide superior type safety comparable to Rust's Result<T, E> or Haskell's Either a b—we've been dismissing one of Java's best features all along.

Introduction

Few features in Java have been as consistently criticized as checked exceptions. Modern Java libraries and frameworks often go to great lengths to avoid them. Newer JVM languages like Kotlin have abandoned them entirely. Many experienced Java developers consider them a design mistake.

But what if this conventional wisdom is wrong? What if checked exceptions represent one of Java's most forward-thinking features?

In this post, I'll argue that Java's checked exceptions were ahead of their time, offering many of the same type safety benefits that are now celebrated in languages like Rust and Haskell. Rather than abandoning this feature, we should consider how to improve it to work better with modern Java's features.

Understanding Java's Exception Handling Model

To set the stage, let's review how Java's exception system works:

  • Unchecked exceptions (subclasses of RuntimeException or Error): These don't need to be declared or caught. They typically represent programming errors (NullPointerException, IndexOutOfBoundsException) or unrecoverable conditions (OutOfMemoryError).

  • Checked exceptions (subclasses of Exception but not RuntimeException): These must either be caught with try/catch blocks or declared in the method signature with throws. They represent recoverable conditions that are outside the normal flow of execution (IOException, SQLException).

Here's how this works in practice:

// Checked exception - compiler forces you to handle or declare it
public void readFile(String path) throws IOException {
    Files.readAllLines(Path.of(path));
}

// Unchecked exception - no compiler enforcement
public void processArray(int[] array) {
    int value = array[array.length + 1]; // May throw ArrayIndexOutOfBoundsException
}

The Type Safety Argument for Checked Exceptions

At their core, checked exceptions are a way of encoding potential failure modes into the type system via method signatures. This makes certain failure cases part of the API contract, forcing client code to explicitly handle these cases.

Consider this method signature:

public byte[] readFileContents(String filePath) throws IOException

The throws IOException clause tells us something critical: this method might fail in ways related to IO operations. The compiler ensures you can't simply ignore this fact. You must either:

  1. Handle the exception with a try-catch block
  2. Propagate it by declaring it in your own method signature

This type-level representation of potential failures aligns perfectly with principles of modern type-safe programming.

Automatic Propagation: A Hidden Advantage

One often overlooked advantage of Java's checked exceptions is their automatic propagation. Once you declare a method as throws IOException, any exception that occurs is automatically propagated to the caller without additional syntax.

Compare this with Rust, where you must use the ? operator every time you call a function that returns a Result:

// Rust requires explicit propagation with ? for each call
fn read_and_process(path: &str) -> Result<(), std::io::Error> {
    let content = std::fs::read_to_string(path)?;
    process_content(&content)?;
    Ok(())
}

// Java automatically propagates exceptions once declared
void readAndProcess(String path) throws IOException {
    String content = Files.readString(Path.of(path));
    processContent(content); // If this throws IOException, it's automatically propagated
}

In complex methods with many potential failure points, Java's approach leads to cleaner code by eliminating the need for repetitive error propagation markers.

Modern Parallels: Result Types in Rust and Haskell

The approach of encoding failure possibilities in the type system has been adopted by many modern languages, most notably Rust with its Result<T, E> type and Haskell with its Either a b type.

In Rust:

fn read_file_contents(file_path: &str) -> Result<Vec<u8>, std::io::Error> {
    std::fs::read(file_path)
}

When calling this function, you can't just ignore the potential for errors—you need to handle both the success case and the error case, often using the ? operator or pattern matching.

In Haskell:

readFileContents :: FilePath -> IO (Either IOException ByteString)
readFileContents path = try $ BS.readFile path

Again, the caller must explicitly deal with both possible outcomes.

This is fundamentally the same insight that motivated Java's checked exceptions: make failure handling explicit in the type system.

Valid Criticisms of Checked Exceptions

If checked exceptions are conceptually similar to these widely-praised error handling mechanisms, why have they fallen out of favor? There are several legitimate criticisms:

1. Excessive Boilerplate in the Call Chain

The most common complaint is the boilerplate required when propagating exceptions up the call stack:

void methodA() throws IOException {
    methodB();
}

void methodB() throws IOException {
    methodC();
}

void methodC() throws IOException {
    // Actual code that might throw IOException
}

Every method in the chain must declare the same exception, creating repetitive code. While automatic propagation works well within a method, the explicit declaration in method signatures creates overhead.

2. Poor Integration with Functional Programming

Java 8 introduced lambdas and streams, but checked exceptions don't play well with them:

// Won't compile because map doesn't expect functions that throw checked exceptions
List<String> fileContents = filePaths.stream()
    .map(path -> Files.readString(Path.of(path))) // Throws IOException
    .collect(Collectors.toList());

This forces developers to use awkward workarounds:

List<String> fileContents = filePaths.stream()
    .map(path -> {
        try {
            return Files.readString(Path.of(path));
        } catch (IOException e) {
            throw new UncheckedIOException(e); // Wrap in an unchecked exception
        }
    })
    .collect(Collectors.toList());

3. Interface Evolution Problems

Adding a checked exception to an existing method breaks all implementing classes and calling code. This makes evolving interfaces over time difficult, especially for widely-used libraries and frameworks.

4. Catch-and-Ignore Anti-Pattern

The strictness of checked exceptions can lead to the worst possible outcome—developers simply catching and ignoring exceptions to make the compiler happy:

try {
    // Code that might throw
} catch (Exception e) {
    // Do nothing or just log
}

This is worse than having no exception checking at all because it provides a false sense of security.

Improving Checked Exceptions Without Abandoning Them

Rather than abandoning checked exceptions entirely, Java could enhance the existing system to address these legitimate concerns. Here are some potential improvements that preserve the type safety benefits while addressing the practical problems:

1. Allow lambdas to declare checked exceptions

One of the biggest pain points with checked exceptions today is their incompatibility with functional interfaces. Consider how much cleaner this would be:

// Current approach - forced to handle or wrap exceptions inline
List<String> contents = filePaths.stream()
    .map(path -> {
        try {
            return Files.readString(Path.of(path));
        } catch (IOException e) {
            throw new RuntimeException(e);
        }
    })
    .collect(Collectors.toList());

// Potential future approach - lambdas can declare exceptions
List<String> contents = filePaths.stream()
    .map((String path) throws IOException -> Files.readString(Path.of(path)))
    .collect(Collectors.toList());

This would require updating functional interfaces to support exception declarations:

@FunctionalInterface
public interface Function<T, R, E extends Exception> {
    R apply(T t) throws E;
}

2. Generic exception types in throws clauses

Another powerful enhancement would be allowing generic type parameters in throws clauses:

public <E extends Exception> void processWithException(Supplier<Void, E> supplier) throws E {
    supplier.get();
}

This would enable much more flexible composition of methods that work with different exception types, bringing some of the flexibility of Rust's Result<T, E> to Java's existing exception system.

3. Better support for exception handling in functional contexts

Unlike Rust which requires the ? operator for error propagation, Java already automatically propagates checked exceptions when declared in the method signature. What Java needs instead is better support for checked exceptions in functional contexts:

// Current approach for handling exceptions in streams
List<String> contents = filePaths.stream()
    .map(path -> {
        try {
            return Files.readString(Path.of(path));
        } catch (IOException e) {
            throw new RuntimeException(e); // Lose type information
        }
    })
    .collect(Collectors.toList());

// Hypothetical improved API
List<String> contents = filePaths.stream()
    .mapThrowing(path -> Files.readString(Path.of(path))) // Preserves checked exception
    .onException(IOException.class, e -> logError(e))
    .collect(Collectors.toList());

4. Integration with Optional<T> and Stream<T> APIs

The standard library could be enhanced to better support operations that might throw checked exceptions:

// Hypothetical API
Optional<String> content = Optional.ofThrowable(() -> Files.readString(Path.of("file.txt")));
content.ifPresentOrElse(
    this::processContent,
    exception -> log.error("Failed to read file", exception)
);

Comparison with Other Languages' Approaches

It's worth examining how other languages have addressed the error handling problem:

Rust's Result<T, E> and ? operator

Rust's approach using Result<T, E> and the ? operator shows how propagation can be made concise while keeping the type safety benefits. The ? operator automatically unwraps a successful result or returns the error to the caller, making propagation more elegant.

However, Rust's approach requires explicit propagation at each step, which can be more verbose than Java's automatic propagation in certain scenarios.

Kotlin's Approach

Kotlin made all exceptions unchecked but provides functional constructs like runCatching that bring back some type safety in a more modern way:

val result = runCatching {
    Files.readString(Path.of("file.txt"))
}

result.fold(
    onSuccess = { content -> processContent(content) },
    onFailure = { exception -> log.error("Failed to read file", exception) }
)

This approach works well with Kotlin's functional programming paradigm but lacks compile-time enforcement.

Scala's Try[T], Either[A, B], and Effect Systems

Scala offers Try[T], Either[A, B], and various effect systems that encode errors in the type system while integrating well with functional programming:

import scala.util.Try

val fileContent: Try[String] = Try {
  Source.fromFile("file.txt").mkString
}

fileContent match {
  case Success(content) => processContent(content)
  case Failure(exception) => log.error("Failed to read file", exception)
}

This approach preserves type safety while fitting well with Scala's functional paradigm.

Conclusion

Java's checked exceptions were a pioneering attempt to bring type safety to error handling. While the implementation has shortcomings, the core concept aligns with modern type-safe approaches to error handling in languages like Rust and Haskell.

Copying Rust's Result<T, E> might seem like the obvious solution, but it would represent a radical departure from Java's established paradigms. Instead, targeted enhancements to the existing checked exceptions system—like allowing lambdas to declare exceptions and supporting generic exception types—could preserve Java's unique approach while addressing its practical limitations.

The beauty of such improvements is that they'd maintain backward compatibility while making checked exceptions work seamlessly with modern Java features like lambdas and streams. They would acknowledge that the core concept of checked exceptions was sound—the problem was in the implementation details and their interaction with newer language features.

So rather than abandoning checked exceptions entirely, perhaps we should recognize them as a forward-thinking feature that was implemented before its time. As Java continues to evolve, we have an opportunity to refine this system rather than replace it.

In the meantime, next time you're tempted to disparage checked exceptions, remember: they're not just an annoying Java quirk—they're an early attempt at the same type safety paradigm that newer languages now implement with much celebration.

What do you think? Could these improvements make checked exceptions viable for modern Java development? Or is it too late to salvage this controversial feature? I'm interested in hearing your thoughts in the comments.

Read more →
0
0
3

애플리케이션 개발 측면에서 본 Drizzle ORM 대 Kysely 비교

洪 民憙 (Hong Minhee) @hongminhee@hackers.pub

TypeScript로 백엔드 서버를 개발하면서 적절한 ORM 선택은 항상 중요한 결정 중 하나입니다. 최근 제 프로젝트에서 Drizzle ORM과 Kysely를 모두 사용해 볼 기회가 있었는데, 개인적으로는 Drizzle ORM이 더 편리하고 생산성이 높았던 경험을 공유하고자 합니다.

두 ORM에 대한 간략한 소개

Drizzle ORM은 TypeScript용 ORM으로, 타입 안전성과 직관적인 API를 강점으로 내세우고 있습니다. 스키마 정의부터 마이그레이션, 쿼리 빌더까지 풀스택 개발 경험을 제공합니다.

Kysely는 “타입 안전한 SQL 쿼리 빌더”로 자신을 소개하며, 타입스크립트의 타입 시스템을 활용해 쿼리 작성 시 타입 안전성을 보장합니다.

두 도구 모두 훌륭하지만, 제 개발 경험에 비추어 볼 때 Drizzle ORM이 몇 가지 측면에서 더 편리했습니다.

Drizzle ORM을 선호하게 된 이유

스키마 정의의 직관성

Drizzle ORM의 스키마 정의 방식은 매우 직관적이고 선언적입니다:

import { pgTable, serial, text, integer } from 'drizzle-orm/pg-core';

export const users = pgTable('users', {
  id: serial('id').primaryKey(),
  name: text('name').notNull(),
  email: text('email').unique().notNull(),
  age: integer('age')
});

Drizzle ORM은 이 스키마 정의로부터 자동으로 CREATE TABLE SQL을 생성할 수 있어, 스키마와 코드가 항상 동기화되어 있습니다.

반면 Kysely는 타입 정의에 더 중점을 두고 있어 스키마와 타입 정의가 분리되는 경향이 있습니다:

interface Database {
  users: {
    id: Generated<number>;
    name: string;
    email: string;
    age: number | null;
  };
}

이 타입 정의는 TypeScript 코드에서 타입 안전성을 제공하지만, 이 타입 정의만으로는 CREATE TABLE SQL을 생성할 수 없다는 것이 결정적인 단점입니다. 실제로 테이블을 생성하려면 별도의 SQL 스크립트나 마이그레이션 코드를 작성해야 합니다. 이는 타입과 실제 데이터베이스 스키마 간의 불일치 가능성을 높입니다.

Drizzle의 접근 방식이 데이터베이스 스키마와 TypeScript 타입을 더 긴밀하게 연결해주어 개발 과정에서 혼란을 줄여주었습니다.

마이그레이션 경험

Drizzle ORM의 마이그레이션 도구(drizzle-kit)는 정말 인상적이었습니다. 스키마 변경사항을 자동으로 감지하고 SQL 마이그레이션 파일을 생성해주는 기능이 개발 워크플로우를 크게 개선했습니다:

npx drizzle-kit generate:pg

이 명령어 하나로 스키마 변경사항에 대한 마이그레이션 파일이 생성되며, 이를 검토하고 적용하는 과정이 매우 간단했습니다.

반면 Kysely의 마이그레이션은 본질적으로 수동적입니다. 개발자가 직접 마이그레이션 파일을 작성해야 하며, 스키마 변경사항을 자동으로 감지하거나 SQL을 생성해주는 기능이 없습니다:

// Kysely의 마이그레이션 예시
async function up(db: Kysely<any>): Promise<void> {
  await db.schema
    .createTable('users')
    .addColumn('id', 'serial', (col) => col.primaryKey())
    .addColumn('name', 'text', (col) => col.notNull())
    .addColumn('email', 'text', (col) => col.unique().notNull())
    .addColumn('age', 'integer')
    .execute();
}

async function down(db: Kysely<any>): Promise<void> {
  await db.schema.dropTable('users').execute();
}

이러한 수동 방식은 복잡한 스키마 변경에서 실수할 가능성이 높아지고, 특히 큰 프로젝트에서는 작업량이 상당히 증가할 수 있었습니다.

하지만 Kysely의 마이그레이션에도 두 가지 중요한 장점이 있습니다:

  1. TypeScript 기반 마이그레이션: Kysely의 마이그레이션 스크립트는 TypeScript로 작성되기 때문에, 마이그레이션 로직에 애플리케이션 로직을 통합할 수 있습니다. 예를 들어, S3와 같은 오브젝트 스토리지의 데이터도 함께 마이그레이트하는 복잡한 시나리오를 구현할 수 있습니다. 반면 Drizzle ORM은 SQL 기반 마이그레이션이므로 이러한 통합이 불가능합니다.

  2. 양방향 마이그레이션: Kysely는 updown 함수를 모두 정의하여 업그레이드와 다운그레이드를 모두 지원합니다. 이는 특히 팀 협업 환경에서 중요한데, 다른 개발자의 변경사항과 충돌이 발생할 경우 롤백이 필요할 수 있기 때문입니다. Drizzle ORM은 현재 업그레이드만 지원하며, 다운그레이드 기능이 없어 협업 시 불편할 수 있습니다.

참고로, Python 생태계의 SQLAlchemy 마이그레이션 도구인 Alembic은 훨씬 더 발전된 형태의 마이그레이션을 제공합니다. Alembic은 비선형적인 마이그레이션 경로(브랜치포인트 생성 가능)를 지원하여 복잡한 팀 개발 환경에서도 유연하게 대응할 수 있습니다. 이상적으로는 JavaScript/TypeScript 생태계의 ORM도 이러한 수준의 마이그레이션 도구를 제공하는 것이 바람직합니다.

관계 설정의 용이성

Drizzle ORM에서 테이블 간 관계 설정이 매우 직관적이었습니다:

import { relations } from 'drizzle-orm';

export const usersRelations = relations(users, ({ one, many }) => ({
  profile: one(profiles, {
    fields: [users.id],
    references: [profiles.userId],
  }),
  posts: many(posts)
}));

이 방식은 데이터베이스 설계의 본질적인, 관계적인 측면을 명확하게 표현해주었습니다.

쿼리 작성의 편의성과 동일 이름 칼럼 문제 처리

두 ORM 모두 쿼리 작성을 위한 API를 제공하지만, Drizzle의 접근 방식이 더 직관적이고 관계형 모델을 활용하기 쉬웠습니다:

// Drizzle ORM - db.query 방식으로 관계 활용
const result = await db.query.posts.findMany({
  where: eq(posts.published, true),
  with: {
    user: true  // 게시물 작성자 정보를 함께 조회
  }
});

// 결과 접근이 직관적이고 타입 안전함
console.log(result[0].title);       // 게시물 제목
console.log(result[0].user.name);   // 작성자 이름 - 객체 구조로 명확하게 구분됨
console.log(result[0].user.id);     // 작성자 ID - 게시물 ID와 이름이 같아도 문제 없음

// Kysely
const result = await db
  .selectFrom('posts')
  .where('posts.published', '=', true)
  .leftJoin('users', 'posts.userId', 'users.id')
  .selectAll();

// 결과 접근 시 칼럼 이름 충돌 문제
console.log(result[0].id) // 오류: posts.id와 users.id 중 어떤 것인지 모호함
console.log(result[0].name) // 오류: 둘 다 name 칼럼이 있다면 모호함

Drizzle의 접근 방식이 테이블과 컬럼을 참조할 때 타입 안전성을 더 강력하게 보장하고, 관계를 활용한 쿼리 작성이 더 직관적이었습니다.

특히 여러 테이블 조인 시 동일한 이름의 칼럼 처리 부분에서 Drizzle ORM이 훨씬 더 편리했습니다. 이는 제 개발 경험에서 가장 중요한 차이점 중 하나였습니다.

// Drizzle ORM - 동일 이름 칼럼 처리
const result = await db.query.posts.findMany({
  with: {
    user: true  // posts.id와 users.id가 모두 있지만 자동으로 구분됨
  }
});

// 결과에 자연스럽게 접근 가능
console.log(result[0].id);        // 게시물 ID
console.log(result[0].user.id);   // 사용자 ID - 명확하게 구분됨
console.log(result[0].user.name); // 사용자 이름

// Kysely - 동일 이름 칼럼 처리를 위해 별칭 필요
const result = await db
  .selectFrom('posts')
  .leftJoin('users', 'posts.userId', 'users.id')
  .select([
    'posts.id as postId',       // 별칭 필수
    'posts.title',
    'posts.content',
    'users.id as userId',       // 별칭 필수
    'users.name as userName',   // 칼럼 이름이 같을 수 있으므로 별칭 필수
    'users.email as userEmail'  // 일관성을 위해 모든 사용자 관련 칼럼에 접두어 필요
  ]);

// 별칭을 통한 접근
console.log(result[0].postId);    // 게시물 ID
console.log(result[0].userId);    // 사용자 ID
console.log(result[0].userName);  // 사용자 이름

Drizzle ORM은 테이블과 칼럼을 객체로 참조하기 때문에, 동일한 이름의 칼럼이 있어도 자연스럽게 계층 구조로 처리되며 타입 추론도 정확하게 작동합니다. 반면 Kysely에서는 문자열 기반 접근 방식 때문에 별칭을 수동으로 지정해야 하는 경우가 많았고, 복잡한 조인에서 이런 작업이 번거로워졌습니다. 특히 여러 테이블에 같은 이름의 칼럼이 많을수록 모든 칼럼에 명시적인 별칭을 지정해야 하는 불편함이 있었습니다.

또한 Drizzle ORM은 결과 타입을 자동으로 정확하게 추론해주어 별도의 타입 지정 없이도 안전하게 결과를 사용할 수 있었습니다.

Kysely의 장점

물론 Kysely도 여러 강점이 있습니다:

  1. 더 가벼운 구조: 필요한 기능만 포함할 수 있는 모듈화된 구조
  2. SQL에 더 가까운 접근: SQL 구문에 매우 충실한 API 설계
  3. 유연성: 복잡한 쿼리에서 때로 더 유연한 작성이 가능

또한 앞서 언급했듯이, Kysely의 TypeScript 기반 마이그레이션과 양방향(up/down) 마이그레이션 지원은 특정 상황에서 Drizzle ORM보다 우위에 있는 기능입니다.

SQLAlchemy와의 비교 및 앞으로의 기대

JavaScript/TypeScript 생태계의 ORM을 이야기하기 전에, 여러 언어 중에서도 Python의 SQLAlchemy는 특별한 위치를 차지합니다. 개인적으로 여태 사용해본 다양한 언어의 ORM 중에서 SQLAlchemy가 가장 기능이 풍부하고 강력하다고 느꼈습니다. 복잡한 쿼리 구성, 고급 관계 매핑, 트랜잭션 관리, 이벤트 시스템 등 SQLAlchemy의 기능은 정말 방대합니다.

Drizzle ORM은 JavaScript 생태계에서 매우 인상적인 발전을 이루었지만, 아직 SQLAlchemy의 경지에는 이르지 못했다고 생각합니다. 특히 다음과 같은 부분에서 SQLAlchemy의 성숙도와 기능 풍부함이 돋보입니다:

  • 복잡한 서브쿼리와 윈도우 함수 지원
  • 다양한 이벤트 리스너와 훅
  • 다양한 상속 전략
  • 복잡한 트랜잭션 관리와 세션 관리
  • 대규모 프로젝트에서 검증된 안정성
  • Alembic을 통한 비선형적 마이그레이션 지원
  • 놀라울 정도로 방대하고 상세한 문서화

결론

두 ORM 모두 훌륭한 도구이지만, 제 개발 스타일과 프로젝트 요구사항에는 Drizzle ORM이 더 잘 맞았습니다. 특히 스키마 정의의 직관성, 강력한 마이그레이션 도구, 그리고 전반적인 개발자 경험 측면에서 Drizzle ORM이 더 생산적인 개발을 가능하게 해주었습니다.

동일 이름 칼럼 처리와 같은 실질적인 문제에서 Drizzle ORM의 객체 기반 접근 방식이 가져다주는 편리함은 실제 프로젝트에서 큰 차이를 만들었습니다.

ORM 선택은 결국 프로젝트 특성과 개인 선호도에 크게 좌우됩니다. 새로운 프로젝트를 시작한다면 두 도구 모두 간단히 테스트해보고 자신의 워크플로우에 더 적합한 것을 선택하는 것이 좋겠지만, 제 경우에는 Drizzle ORM이 명확한 승자였습니다.

앞으로 Drizzle ORM이 더욱 발전하여 SQLAlchemy 수준의 풍부한 기능과 유연성을 제공하게 되길 바랍니다. JavaScript/TypeScript 생태계에도 그런 수준의 강력한 ORM이 있으면 좋겠습니다. 다행히도 Drizzle ORM은 계속해서 발전하고 있으며, 그 발전 속도를 보면 기대가 큽니다.

여러분의 경험은 어떤가요? 다른 ORM 도구나 언어를 사용해보셨다면 의견을 공유해주세요!

Read more →
0

Hackers' Pub에서 DOT 언어(Graphviz)로 다이어그램 그리기

洪 民憙 (Hong Minhee) @hongminhee@hackers.pub

Hackers' Pub에 숨겨진 유용한 기능, 바로 Graphviz DOT 언어 지원에 대해 알아봅니다. 이 기능을 사용하면 복잡한 다이어그램을 간단하게 Markdown 코드 블록 안에 DOT 언어로 기술하여 표현할 수 있습니다. 특히 `graphviz` 언어 태그를 사용하여 ActivityStreams 데이터 전송 과정을 시각적으로 나타낼 수 있습니다. 서버 간의 상호작용을 명확하게 보여주는 다이어그램을 통해, 독자는 Mastodon과 Hackers' Pub 서버 간의 데이터 흐름을 쉽게 이해할 수 있습니다. 이 기능은 긴 게시글뿐만 아니라 짧은 글에서도 유용하게 활용될 수 있으며, 기술적인 내용을 시각적으로 표현하고자 하는 사용자에게 큰 도움이 될 것입니다.

Read more →
2

Monads: Beyond Simple Analogies—Reflections on Functional Programming Paradigms

洪 民憙 (Hong Minhee) @hongminhee@hackers.pub

While exploring functional programming languages, I've been reflecting on how different communities approach similar concepts. One pattern that seems particularly fascinating is how Haskell and OCaml communities differ in their embrace of monads as an abstraction tool.

The Elegant Power of Monads in Haskell

It's common to hear monads explained through analogies to concepts like JavaScript's Promise or jQuery chains. While these comparisons provide an entry point, they might miss what makes monads truly beautiful and powerful in Haskell's ecosystem.

The real strength appears to lie in the Monad typeclass itself. This elegant abstraction allows for creating generic functions and types that work with any type that shares the monad property. This seems to offer a profound unification of concepts that might initially appear unrelated:

  • You can write code once that works across many contexts (Maybe, [], IO, State, etc.)
  • Generic functions like sequence, mapM, and others become available across all monadic types
  • The same patterns and mental models apply consistently across different computational contexts

For example, a simple conditional function like this works beautifully in any monadic context:

whenM :: Monad m => m Bool -> m () -> m ()
whenM condition action = do
  result <- condition
  if result then action else return ()

Whether dealing with potentially missing values, asynchronous operations, or state transformations, the same function can be employed without modification. There's something genuinely satisfying about this level of abstraction and reuse.

OCaml's Different Approach

Interestingly, the OCaml community seems less enthusiastic about monads as a primary abstraction tool. This might stem from several factors related to language design:

Structural Differences

OCaml lacks built-in typeclass support, relying instead on its module system and functors. While powerful in its own right, this approach might not make monad abstractions feel as natural or convenient:

(* OCaml monad implementation requires more boilerplate *)
module type MONAD = sig
  type 'a t
  val return : 'a -> 'a t
  val bind : 'a t -> ('a -> 'b t) -> 'b t
end

module OptionMonad : MONAD with type 'a t = 'a option = struct
  type 'a t = 'a option
  let return x = Some x
  let bind m f = match m with
    | None -> None
    | Some x -> f x
end

OCaml also doesn't offer syntactic sugar like Haskell's do notation, which makes monadic code in Haskell considerably more readable and expressive:

-- Haskell's elegant do notation
userInfo = do
  name <- getLine
  age <- readLn
  return (name, age)

Compared to the more verbose OCaml equivalent:

let user_info =
  get_line >>= fun name ->
  read_ln >>= fun age ->
  return (name, age)

The readability difference becomes even more pronounced in more complex monadic operations.

Philosophical Differences

Beyond syntax, the languages differ in their fundamental approach to effects:

  • Haskell is purely functional, making monads essential for managing effects in a principled way
  • OCaml permits direct side effects, often making monadic abstractions optional

This allows OCaml programmers to write more direct code when appropriate:

(* Direct style in OCaml *)
let get_user_info () =
  print_string "Name: ";
  let name = read_line () in
  print_string "Age: ";
  let age = int_of_string (read_line ()) in
  (name, age)

OCaml's approach might favor pragmatism and directness in many cases, with programmers often preferring:

  • Direct use of option and result types
  • Module-level abstractions through functors
  • Continuation-passing style when needed

While this directness can be beneficial for immediate readability, it might come at the cost of some of the elegant uniformity that Haskell's monadic approach provides.

Reflections on Language Design

These differences highlight how programming language design shapes the idioms and patterns that emerge within their communities. Neither approach is objectively superior—they represent different philosophies about abstraction, explicitness, and the role of the type system.

Haskell's approach encourages a high level of abstraction and consistency across different computational contexts, which can feel particularly satisfying when working with complex, interconnected systems. There's something intellectually pleasing about solving a problem once and having that solution generalize across many contexts.

OCaml often favors more direct solutions that might be easier to reason about locally, though potentially at the cost of less uniformity across the codebase. This approach has its own virtues, particularly for systems where immediate comprehensibility is paramount.

After working with both paradigms, I find myself drawn to the consistent abstractions that Haskell's approach provides, while still appreciating the pragmatic clarity that OCaml can offer in certain situations. The typeclasses and syntactic support in Haskell seem to unlock a particularly elegant way of structuring code that, while perhaps requiring a steeper initial learning curve, offers a uniquely satisfying programming experience.

What patterns have you noticed in how different programming language communities approach similar problems? And have you found yourself drawn to the elegant abstractions of Haskell or the pragmatic approach of OCaml?

Read more →
0

Hackers' Pub Introduces Flexible Username Changes: Breaking the Fediverse Norm

洪 民憙 (Hong Minhee) @hongminhee@hackers.pub

Hackers' Pub, an ActivityPub-enabled social network, introduces a unique feature allowing users a one-time username change, a departure from the typical fediverse practice of permanent usernames. This policy acknowledges the evolving nature of personal identity, offering flexibility while maintaining network stability. When a username is changed, it becomes available for others, creating opportunities for new users to acquire desirable names. To prevent broken links, permalinks containing the original username will function until the username is claimed by someone else. This is made possible by using UUID-based actor URIs that don't include the username, unlike platforms like Mastodon where the username is embedded in the URI. Inspired by GitHub's username policy, Hackers' Pub aims to balance identity evolution with the benefits of federation. This approach allows users to correct initial username choices, evolve their online identity, and maintain their content history and social connections. This policy represents an experiment in fediverse identity management, testing whether flexibility can coexist with the stability required for federation, potentially influencing other platforms to adopt similar approaches.

Read more →
0
0
3

LogTape 0.9.0 Released: Synchronous Configuration and Better Runtime Compatibility

洪 民憙 (Hong Minhee) @hongminhee@hackers.pub

LogTape 0.9.0 introduces enhancements focused on flexibility and ease of configuration across diverse JavaScript environments. A key addition is the synchronous configuration API, providing `configureSync()`, `disposeSync()`, and `resetSync()` functions for simpler setup in scenarios where async operations are unnecessary. This version also improves runtime compatibility by moving file-system dependent components to a separate `@logtape/file` package, ensuring the core `@logtape/logtape` package works seamlessly across various JavaScript runtimes, including browsers and edge functions. Console sink enhancements include the `ConsoleSinkOptions.levelMap` option for customizing log levels. If you're using file sinks, you'll need to update your imports and install the new `@logtape/file` package. These updates reflect the project's commitment to providing a flexible and developer-friendly logging solution for JavaScript and TypeScript applications, with ongoing improvements to performance and compatibility.

Read more →
0

연합우주(fediverse)와 ActivityPub 프로토콜 이해하기: 개발자를 위한 가이드

洪 民憙 (Hong Minhee) @hongminhee@hackers.pub

연합우주란 무엇일까?

X(구 Twitter)나 Instagram 같은 중앙화된 소셜 미디어에 지치셨나요? 데이터 프라이버시, 알고리즘 추천, 그리고 끊임없는 광고가 걱정되시나요? 여기 대안이 있습니다. 바로 연합우주(fediverse)입니다.

페디버스(fediverse)는 “federated”(연합된)와 “universe”(우주)를 합친 말로, 한국어권에서는 주로 “연합우주”라고 불립니다. 연합우주는 하나의 거대한 플랫폼이 아닌, 서로 대화할 수 있는 독립적인 서버(인스턴스)들의 네트워크입니다.

이게 어떻게 가능할까요? 바로 ActivityPub이라는 프로토콜 덕분입니다. 이 프로토콜은 서로 다른 소셜 미디어 플랫폼이 정보를 교환할 수 있게 해주는 공통 언어 같은 것입니다.

연합우주는 어떻게 작동하나요?

연합우주를 이해하는 가장 쉬운 방법은 이메일 시스템과 비교하는 것입니다.

Gmail 사용자가 네이버 메일 사용자에게 이메일을 보낼 수 있는 것처럼, Mastodon 사용자는 Misskey나 PeerTube 사용자와 소통할 수 있습니다. (Mastodon, Misskey, PeerTube가 무엇인지는 아래에서 설명하겠습니다. Gmail과 네이버처럼 서로 다른 서비스라고 보시면 됩니다.) 이것이 가능한 이유는 이 서비스들이 모두 같은 언어인 ActivityPub 프로토콜로 대화하기 때문입니다.

연합우주에서 사용자 ID는 @사용자명@인스턴스.도메인 형식으로 되어 있습니다. 이메일 주소와 매우 비슷하죠? 예를 들면:

  • @honggildong@mastodon.social: mastodon.social 인스턴스 사용자
  • @kimcheolsu@pixelfed.social: pixelfed.social 인스턴스 사용자
  • @leeyeonghui@misskey.io: misskey.io 인스턴스 사용자

연합우주의 다양한 플랫폼 둘러보기

연합우주는 마치 여러 행성으로 이루어진 태양계 같습니다. 각 행성(플랫폼)은 고유한 특성을 가지고 있지만, 모두 같은 우주(연합우주)에 속해 있죠. 아래 표에서 주요 플랫폼들을 살펴봅시다:

플랫폼 설명 주요 인스턴스 특징
Mastodon X(구 Twitter)와 유사한 마이크로블로깅 플랫폼 • mastodon.social (공식 인스턴스)
• 우리.인생 (한국 중심)
500자 제한의 짧은 게시물, 해시태그, 컨텐츠 경고 기능
Misskey 일본에서 개발된 고도로 커스터마이징 가능한 마이크로블로깅 플랫폼 • misskey.io (가장 인기 있는 일본 인스턴스)
• 스텔라 (한국 중심)
리액션, 게임, 채팅 등 다양한 기능, 높은 커스터마이징 가능성
Pixelfed Instagram과 유사한 이미지 공유 플랫폼 • pixelfed.social (공식 인스턴스)
• 추억:사진 (한국 중심)
스토리, 필터, 발견 기능
PeerTube YouTube와 유사한 비디오 호스팅 플랫폼 • PeerTube.TV P2P 기술로 비디오 스트리밍, 채널, 재생목록
WriteFreely 미니멀한 블로그 플랫폼 • write.as Markdown 지원, 심플한 디자인
Lemmy Reddit과 유사한 링크 애그리게이터 및 토론 플랫폼 • lemmy.ml
• YuruLemmy (한국 중심)
커뮤니티(서브레딧과 유사), 투표, 토론

플랫폼 vs 인스턴스: 무슨 차이가 있을까?

연합우주를 이해할 때 흔히 혼동되는 개념이 있습니다. 바로 플랫폼(소프트웨어)과 인스턴스(서버)의 차이인데요.

플랫폼은 Mastodon, Misskey, Pixelfed와 같은 소프트웨어 자체를 의미합니다. 이들은 오픈 소스 소프트웨어로, 누구나 다운로드받아 설치할 수 있습니다.

인스턴스는 그 소프트웨어를 실행하는 개별 서버를 말합니다. mastodon.social과 우리.인생은 모두 Mastodon 플랫폼을 실행하는 별도의 인스턴스입니다.

Meta의 Threads 같은 일부 서비스는 플랫폼과 인스턴스가 동일합니다. 하지만 대부분의 연합우주 서비스는 여러 인스턴스로 구성되어 있습니다.

연합우주의 매력 포인트

연합우주가 갖는 몇 가지 매력적인 특징이 있습니다:

  1. 탈중앙화: 특정 기업이 모든 데이터와 규칙을 통제하지 않습니다. 각 인스턴스는 자체 규칙을 가질 수 있습니다.
  2. 데이터 주권: 자신의 데이터에 대한 더 많은 통제권을 가질 수 있습니다.
  3. 검열 저항성: 한 인스턴스가 차단되더라도 다른 인스턴스로 쉽게 이동할 수 있습니다.
  4. 커뮤니티 중심: 각 인스턴스는 특정 관심사나 지역 커뮤니티를 중심으로 형성됩니다.
  5. 다양성: 다양한 플랫폼과 인스턴스가 존재하여 선택의 폭이 넓습니다.

연합우주 시작하기

연합우주에 참여하는 것은 생각보다 쉽습니다:

  1. 자신의 관심사나 지역과 관련된 인스턴스를 선택합니다.
  2. 해당 인스턴스에 계정을 만듭니다.
  3. 다른 인스턴스의 사용자들을 팔로우하고 소통을 시작합니다!

한국 사용자라면 Mastodon 인스턴스인 우리.인생, Misskey 인스턴스인 스텔라 같은 한국어 중심 인스턴스를 추천합니다. 한국어 환경을 지원하고 한국 사용자들이 활발하게 활동하고 있어 시작하기 좋습니다.

아니면 이 글이 올라온 Hackers' Pub도 괜찮습니다. 소프트웨어 엔지니어들을 위한 소셜 미디어랍니다. 아직 개발중이라 공개적으로 가입을 받고 있지는 않습니다만, 홍민희에게 연락 주시면 계정을 생성해 드릴 수 있습니다.

ActivityPub: 연합우주의 심장

이제 개발자 관점에서 ActivityPub이 어떻게 작동하는지 자세히 살펴보겠습니다.

ActivityPub은 W3C에서 권장하는 표준 프로토콜로, 분산 소셜 네트워킹의 기반이 됩니다. ActivityStreams 2.0 데이터 형식을 기반으로 하며, 서로 다른 서버 간에 정보를 교환하는 방법을 정의합니다.

ActivityPub의 핵심 개념

ActivityPub은 몇 가지 핵심 개념으로 구성됩니다:

  1. 액터(actor): 사용자, 그룹 등 행동을 수행할 수 있는 주체입니다. 각 액터는 고유한 URL을 가지며, 수신함(inbox)과 발신함(outbox)을 가집니다.
  2. 액티비티(activity): 액터가 수행하는 행동으로, 게시물 작성, 댓글 좋아요, 다른 사용자 팔로우 등이 있습니다.
  3. 객체(object): 텍스트 게시물, 이미지, 비디오와 같이 생성되고 공유되는 콘텐츠입니다.

실제 작동 방식

홍길동(@honggildong@mastodon.social)이 게시물을 작성하고, 이영희(@leeyeonghui@misskey.io)가 이에 반응하는 과정을 살펴봅시다:

  1. 게시물 작성: 홍길동이 Mastodon에서 게시물을 작성합니다. Mastodon 서버는 이 게시물을 ActivityStreams 2.0 형식의 Create(Note) 액티비티로 변환합니다. 이 액티비티는 홍길동의 팔로워(이영희 포함)에게 전달됩니다.

  2. 게시물 수신: 이영희의 Misskey 서버는 이 액티비티를 받고 처리하여 이영희의 타임라인에 홍길동의 게시물을 표시합니다.

  3. 상호작용: 이영희가 게시물에 좋아요를 누르면, Misskey 서버는 Like(Note) 액티비티를 생성하여 홍길동의 Mastodon 서버로 보냅니다. 홍길동은 이영희가 자신의 게시물을 좋아했다는 알림을 받게 됩니다.

마치 다른 언어를 사용하는 사람들이 통역사를 통해 대화하는 것과 비슷하죠? ActivityPub이 바로 그 통역사 역할을 합니다.

ActivityPub의 실제 메시지 들여다보기

개발자로서 실제 ActivityPub 메시지가 어떻게 생겼는지 궁금하실 텐데요. 몇 가지 예시를 살펴봅시다:

1. 사용자 프로필(액터) 정보

{
  "@context": [
    "https://www.w3.org/ns/activitystreams",
    "https://w3id.org/security/v1"
  ],
  "id": "https://mastodon.social/users/honggildong",
  "type": "Person",
  "preferredUsername": "honggildong",
  "name": "홍길동",
  "summary": "연합우주의 개척자",
  "inbox": "https://mastodon.social/users/honggildong/inbox",
  "outbox": "https://mastodon.social/users/honggildong/outbox",
  "followers": "https://mastodon.social/users/honggildong/followers",
  "following": "https://mastodon.social/users/honggildong/following",
  "publicKey": {
    "id": "https://mastodon.social/users/honggildong#main-key",
    "owner": "https://mastodon.social/users/honggildong",
    "publicKeyPem": "-----BEGIN PUBLIC KEY-----\n...\n-----END PUBLIC KEY-----"
  },
  "icon": {
    "type": "Image",
    "mediaType": "image/jpeg",
    "url": "https://mastodon.social/system/accounts/avatars/000/000/001/original/avatar.jpg"
  }
}

이 JSON 데이터는 홍길동의 프로필 정보를 담고 있습니다. 사용자 이름, 소개, 프로필 사진 URL, 그리고 중요한 inboxoutbox URL이 포함되어 있죠.

2. 게시물 작성 액티비티

{
  "@context": "https://www.w3.org/ns/activitystreams",
  "id": "https://mastodon.social/users/honggildong/statuses/123456/activity",
  "type": "Create",
  "actor": "https://mastodon.social/users/honggildong",
  "published": "2025-02-21T14:30:00Z",
  "to": [
    "https://www.w3.org/ns/activitystreams#Public"
  ],
  "cc": [
    "https://mastodon.social/users/honggildong/followers"
  ],
  "object": {
    "id": "https://mastodon.social/users/honggildong/statuses/123456",
    "type": "Note",
    "content": "<p>연합우주에 오신 것을 환영합니다! #fediverse #연합우주</p>",
    "published": "2025-02-21T14:30:00Z",
    "attributedTo": "https://mastodon.social/users/honggildong",
    "to": [
      "https://www.w3.org/ns/activitystreams#Public"
    ],
    "cc": [
      "https://mastodon.social/users/honggildong/followers"
    ],
    "tag": [
      {
        "type": "Hashtag",
        "href": "https://mastodon.social/tags/fediverse",
        "name": "#fediverse"
      },
      {
        "type": "Hashtag",
        "href": "https://mastodon.social/tags/연합우주",
        "name": "#연합우주"
      }
    ]
  }
}

이것은 홍길동이 게시물을 작성했을 때 생성되는 Create(Note) 액티비티입니다. 게시물 내용, 해시태그, 공개 범위 등이 포함되어 있습니다.

3. 팔로우 액티비티

{
  "@context": "https://www.w3.org/ns/activitystreams",
  "id": "https://misskey.io/users/leeyeonghui/follow/1234",
  "type": "Follow",
  "actor": "https://misskey.io/users/leeyeonghui",
  "object": "https://mastodon.social/users/honggildong"
}

이영희가 홍길동을 팔로우할 때 생성되는 Follow 액티비티입니다. 단순하죠?

ActivityPub 서버 구현하기: 개발자를 위한 팁

직접 ActivityPub 서버를 구현하고 싶다면 다음 단계를 따라야 합니다:

  1. 액터 구현: 사용자 프로필 정보를 ActivityStreams 형식으로 제공합니다.
  2. 수신함과 발신함 설정: HTTP 엔드포인트를 만들어 액티비티를 받고 전송합니다.
  3. 서명 및 인증: HTTP Signatures를 사용하여 요청을 서명하고 검증합니다.
  4. 액티비티 처리: 다양한 액티비티 유형(Create, Follow, Like 등)을 처리하는 로직을 구현합니다.
  5. 데이터 저장: 사용자, 게시물, 액티비티 등의 정보를 데이터베이스에 저장합니다.
  6. 연합 정책 구현: 어떤 인스턴스와 연합할지, 어떤 컨텐츠를 허용할지 등을 설정합니다.

개발을 시작하기 전에 Mastodon, Misskey 같은 기존 구현체의 코드를 살펴보는 것이 도움이 됩니다. 처음부터 모든 것을 구현하는 것보다 Fedify 같은 프레임워크를 활용하는 것도 좋은 방법입니다.

WebFinger: 사용자를 찾는 방법

연합우주에서 @leeyeonghui@misskey.io 같은 사용자 ID를 어떻게 실제 ActivityPub 액터 URL로 변환할까요? 그 비밀은 WebFinger 프로토콜에 있습니다:

GET https://misskey.io/.well-known/webfinger?resource=acct:leeyeonghui@misskey.io

이 요청을 보내면 서버는 다음과 같은 응답을 반환합니다:

{
  "subject": "acct:leeyeonghui@misskey.io",
  "links": [
    {
      "rel": "self",
      "type": "application/activity+json",
      "href": "https://misskey.io/users/leeyeonghui"
    }
  ]
}

이제 https://misskey.io/users/leeyeonghui URL을 통해 사용자의 전체 프로필 정보를 얻을 수 있습니다. 마치 전화번호부에서 이름으로 전화번호를 찾는 것과 비슷하죠!

연합우주의 도전 과제와 미래

연합우주는 계속 성장하고 있지만, 몇 가지 도전 과제도 있습니다:

  1. 확장성: 수많은 서버 간의 통신을 효율적으로 처리하는 것은 쉽지 않습니다.
  2. 모더레이션: 각 인스턴스가 자체 규칙을 가지므로 콘텐츠 조정에 일관성이 부족할 수 있습니다.
  3. 발견성: 중앙화된 플랫폼에 비해 새로운 사용자나 콘텐츠를 찾기 어려울 수 있습니다.
  4. 사용자 경험: 일부 플랫폼은 아직 UI/UX 측면에서 개선이 필요합니다.

그러나 Threads와 같은 주요 서비스들이 ActivityPub을 채택하기 시작하면서, 연합우주의 미래는 밝아 보입니다. 개발자로서, 이런 성장하는 생태계에 참여할 수 있는 기회가 많이 있습니다.

마무리

연합우주와 ActivityPub은 중앙화된 소셜 미디어의 대안으로서 점점 더 주목받고 있습니다. 사용자에게 더 많은 통제권을 부여하고, 다양하고 풍부한 온라인 경험을 제공하는 연합우주의 세계는 계속해서 확장되고 있습니다.

개발자로서, 여러분은 이 새로운 탈중앙화된 웹의 생태계에 기여할 수 있습니다. 기존 애플리케이션에 ActivityPub 지원을 추가하거나, 완전히 새로운 서비스를 만들거나, 현재의 도전 과제를 해결하는 솔루션을 개발할 수 있습니다.

한국 개발자들의 참여가 늘어나면 한국 사용자들을 위한 더 다양하고 풍부한 서비스가 생길 것이고, 이는 더 건강하고 다양한 인터넷 문화를 만드는 데 기여할 것입니다.

그럼, 연합우주로의 여행을 시작해 보시는 건 어떨까요?

Read more →
5
3
3