앞서 객체지향의 4대 특성을 가지는 언어를 객체지향 언어라고 정의했다. 이제 객체 지향 프로그래밍을 할 수 있는 언어는 준비되었으니, 이를 잘 활용해 올바른 객체 지향 프로그래밍을 해야 할 차례이다. 좋은 방법이 있을까?

 

2000년 초반, 로버트 C. 마틴은 객체지향 언어를 잘 사용하기 위한 5가지 원칙, SOLID 원칙을 제안하였다. 현재 많은 객체지향 프로그램이 SOLID 원칙을 따르고 있으며, 이 원칙을 준수한다면 올바른 객체 지향 프로그램을 설계하는 데 큰 어려움이 없을 것이다. 물론 원칙에는 예외가 존재하며 몇몇 디자인 패턴을 보면 SOLID 원칙에 위반되는 것들이 있다. 하지만 테니스에 비유하자면, SOLID는 기본자세와 같다. 기본자세를 제대로 익혀야 변형된 자세에서도 정확하고 안전한 스트로크를 구사할 수 있듯, 기본은 언제나 중요하다. 이제 SOLID 원칙을 제대로 알아보도록 하자.

(요즘 필자는 테니스에 푹 빠져 있다. 테니스 비유가 다소 낯설게 느껴지는 분들께는 양해를 부탁드린다.)

 

 

SOLID 원칙 이란?

SOLID 원칙에는 SRP, OCP, LSP, ISP, DIP 가 있다. 객체지향 언어인 Java 예제 코드와 함께 알아보자.

 

1. 단일 책임 원칙(SRP: Single Responsibility Principle)

클래스는 하나의 책임만 가져야 한다. 즉, 클래스가 변경되는 이유는 하나여야 한다.

 

[잘못된 예]

class User {
    private String name;
    private String email;
	
    public void saveToDatabase() {
        // 데이터베이스에 사용자 정보 저장
    }

    public String getName() {
        return name;
    }

    public String getEmail() {
        return email;
    }
}

문제점: User 클래스가 데이터베이스 관련 책임까지 가지고 있다.

 

 

[SRP 적용 예]

class User {
    private String name;
    private String email;

    public String getName() {
        return name;
    }

    public String getEmail() {
        return email;
    }
}

class UserRepository {
    public void save(User user) {
        // 데이터베이스에 사용자 정보 저장
    }
}

 

 

2. 개방 폐쇄 원칙(OCP: Open Closed Principle)

클래스는 확장에는 열여 있어야 하고, 수정에는 닫혀 있어야 한다. 즉, 기존 코드를 변경하지 않고도 기능을 확장할 수 있어야 한다.

 

[잘못된 예]

class NotificationService {
    public void sendNotification(String message, String type) {
        if (type.equals("email")) {
            sendEmail(message);
        } else if (type.equals("sms")) {
            sendSms(message);
        }
    }
    
    private void sendEmail(String message) {
        System.out.println("Email Notification: " + message);
    }

    private void sendSms(String message) {
        System.out.println("SMS Notification: " + message);
    }
}

문제점: send 방식이 추가될 때마다 NotificationService의 코드가 수정되어야 한다.

 

[OCP 적용 예]

interface Notification {
    void send(String message);
}

class EmailNotification implements Notification {
    public void send(String message) {
        System.out.println("Email Notification: " + message);
    }
}

class SmsNotification implements Notification {
    public void send(String message) {
        System.out.println("SMS Notification: " + message);
    }
}

// 알림 서비스를 사용하는 클래스
class NotificationService {
    private final List<Notification> notificationMethods;

    public NotificationService(List<Notification> notificationMethods) {
        this.notificationMethods = notificationMethods;
    }
	
    public void sendNotifications(String message) {
        for (Notification notification : notificationMethods) {
            notification.send(message);
        }
    }
}

 

 

3. 리스코프 치환 원칙(LSP: Liskov Substitution Principle)

서브타입(자식타입)은 언제나 상위타입(부모타입)으로 교체할 수 있어야 한다. 

 

[잘못된 예]

class Bird {
    public void fly() {
        System.out.println("Bird is flying.");
    }
}

class Penguin extends Bird {
    @Override
    public void fly() {
        throw new UnsupportedOperationException("Penguins can't fly!");
    }
}

문제점: 모든 새는 날 수 있다는 가정하에 fly메서드를 추가했다. 하지만 이를 상속한 펭귄은 날 수 없다. 

 

[LSP 적용 예]

// 새의 일반적인 특징을 정의하는 Bird 클래스
class Bird {
    public void eat() {
        System.out.println("Bird is eating.");
    }
}

// 날 수 있는 새를 위한 인터페이스
interface Flyable {
    void fly();
}

// 참새는 날 수 있는 새로 정의
class Sparrow extends Bird implements Flyable {
    @Override
    public void fly() {
        System.out.println("Sparrow is flying.");
    }
}

// 펭귄은 날 수 없는 새로 정의
class Penguin extends Bird {
    // Penguin은 fly 메서드를 구현하지 않음
}

 

 

4. 인터페이스 분리 원칙(ISP: Interface Segregation Principle)

클라이언트는 사용하지 않는 인터페이스에 의존하지 않아야 한다. 인터페이스를 작게 유지하고, 특정한 기능을 위한 인터페이스를 여러 개로 분리하는 것이 좋다. 

 

[잘못된 예]

interface AllInOnePrinter {
    void print();
    void scan();
}

class AdvancedPrinter implements AllInOnePrinter {
    @Override
    public void print() {
        System.out.println("Printing document...");
    }

    @Override
    public void scan() {
        System.out.println("Scanning document...");
    }
}

class BasicPrinter implements AllInOnePrinter {
    @Override
    public void print() {
        System.out.println("Printing document...");
    }

    @Override
    public void scan() {
        // 이 프린터는 스캔 기능이 필요하지 않음
    }
}

문제점: 베이직 프린터는 스캔 기능이 필요하지 않다. 

 

[ISP 적용 예]

interface Printable {
    void print();
}

interface Scannable {
    void scan();
}

class AdvancedPrinter implements Printable, Scannable 
    @Override
    public void print() {
        System.out.println("Printing documents...");
    }

    @Override
    public void scan() {
        System.out.println("Scanning documents...");
    }
}

class BasicPrinter implements Printable {
    @Override
    public void print() {
        System.out.println("Printing documents...");
    }
}

 

 

5. 의존 역전 원칙(DIP: Dependency Inversion Principle)

고수준 모듈은 저수준 모듈에 의존해서는 안되며, 추상화에 의존해야 한다. 즉, 구체적인 구현이 아닌, 인터페이스나 추상 클래스에 의존하게 설계해야 한다. 

 

[잘못된 예]

class Keyboard {}
class Monitor {}

class Computer {
    private Keyboard keyboard;
    private Monitor monitor;

    public Computer() {
        this.keyboard = new Keyboard();
        this.monitor = new Monitor();
    }
}

문제점: 컴퓨터라는 클래스는 저수준 모듈인, Keyboard와 Monitor에 의존하고 있다.

 

[DIP 적용 예]

interface Keyboard {}
interface Monitor {}

class StandardKeyboard implements Keyboard {}
class HDMonitor implements Monitor {}

class Computer {
    private Keyboard keyboard;
    private Monitor monitor;

    public Computer(Keyboard keyboard, Monitor monitor) {
        this.keyboard = keyboard;
        this.monitor = monitor;
    }
}

 

정리

  • SOLID는 객체지향 언어로 올바른 객체지향 프로그램을 설계하기 위한 원칙이다.
  • 원칙에는 예외가 존재하며 몇몇 디자인패턴  SOLID 원칙에 위반하기도 한다. 
  • 하지만, SOLID는 객체지향 프로그래밍의 기본기이며 이를 잘 익히는 것은 중요하다.
  • 다음 시리즈는 디자인패턴이다. SOLID원칙을 기반으로 탄생한 디자인패턴에 대해 알아볼 예정이다.

객체지향 프로그래밍은 개발자에게 친숙하면서도 때로는 어려운 개념이지만, 반드시 이해하고 넘어가야 할 중요한 개념이다. 이 시리즈에서는 객체지향 프로그래밍의 탄생 배경을 시작으로, 객체 지향 언어가 가진 객체지향 4대 특성을 알아보고, 객체지향 언어로 올바른 설계를 이루기 위한 SOLID 원칙을 살펴보겠다. 마지막으로, SOLID 원칙을 기반으로 한 객체지향 베스트 프랙티스인 디자인 패턴은 이후 다른 시리즈에서 자세히 다룰 예정이다.

 

 

객체지향 프로그래밍의 탄생 배경에 대해 알아보자

프로그래밍 언어는 개발자가 이해하고 다루기 쉽도록 점차 발전해 왔다. 초기에는 사람이 이해하기 어려운 기계어에서 시작해, 어셈블리어를 거쳐 절차지향 언어로 발전하며 사용 편의성이 개선되었지만, 여전히 기계 중심의 사고에 기반한 방식이었다. 그러던 중, "현실 세계를 바라보는 방식 그대로 프로그램을 작성할 수는 없을까?"라는 질문에서 객체지향 프로그래밍의 개념이 탄생했다. 사실, 우리가 살아가는 세상은 수많은 객체들, 즉 사물들의 상호작용으로 이루어져 있다. 우리가 보고, 만지고, 인식하는 모든 것을 객체로 정의할 수 있으며, 이 객체들이 서로 소통하고 협력하면서 현실세계의 다양한 현상을 만들어낸다. 이러한 객체의 개념을 프로그래밍에 도입한 것이 객체지향 프로그래밍이다. OOP를 통해 현실 세계를 더 직관적으로 모델링할 수 있게 되면서, 인간의 사고방식에 맞는 자연스러운 프로그램 설계가 가능해지게 되었다.

 

 

객체지향 사상을 잘 반영하는 언어는 무엇일까?

대표적인 객체지향 언어는 Java, Kotlin, C#, Ruby, Python 등이 있으며, 그 외에도 많은 언어들이 객체지향 사상을 반영하고 있다. 

 

그렇다면, 어떠한 기준으로 우리는 이러한 언어들을 객체지향 언어라고 부를 수 있을까? 절대적인 기준은 없지만, 오랜 세월에 걸쳐 정립된  객체 지향 4대 특성을 가지고 있다면 객체지향 언어라고 부를 수 있다. 그렇다면 객체 지향 4대 특성이 무엇이진 알아보자.

 

 

객체 지향 4대 특성이란?

객체지향 4대 특성에는 캡슐화, 상속, 추상화, 다형성이 있다. 객체지향 언어인 Java 예제 코드와 함께 알아보자.

 

1. 캡슐화

객체의 내부 구조를 감추고 외부에서는 필요한 인터페이스만 제공한다

public class BankAccount {
    private double balance; // 외부 접근 불가

    public double getBalance() {
        return balance;
    }

    public void deposit(double amount) {
        if (amount > 0) balance += amount;
    }

    public void withdraw(double amount) {
        if (amount > 0 && amount <= balance) balance -= amount;
    }
}

 

 

2. 상속(확장)

기존의 클래스를 확장하여 새로운 클래스를 만드는 개념이다. 부모 클래스의 속성과 메서드를 자식 클래스가 물려받게 되고, 자식 클래스는 부모의 기능을 그대로 사용할 수 있고, 필요에 따라 확장하거나 재정의를 할 수 있다.

class Employee {
    protected String name;
    protected double salary;

    public void work() {
        System.out.println(name + " is working.");
    }
}

class Manager extends Employee {
    private int teamSize;

    public void manageTeam() {
        System.out.println(name + " is managing a team of " + teamSize);
    }
}

 

 

3. 추상화(모델링)

실제 사물을 정확히 복제하는 것이 아니라 목적에 맞게 관심 있는 특성만 추출해 단순하게 표현하는 것이다. 

// 추상 인터페이스: Database
interface Database {
    void connect();
    void disconnect();
    void executeQuery(String query);
}

// 구체적인 클래스: MySQLDatabase
class MySQLDatabase implements Database {
    @Override
    public void connect() {
        System.out.println("MySQL 데이터베이스에 연결합니다.");
    }

    @Override
    public void disconnect() {
        System.out.println("MySQL 데이터베이스 연결을 종료합니다.");
    }

    @Override
    public void executeQuery(String query) {
        System.out.println("MySQL에서 쿼리를 실행합니다: " + query);
    }
}

// 구체적인 클래스: OracleDatabase
class OracleDatabase implements Database {
    @Override
    public void connect() {
        System.out.println("Oracle 데이터베이스에 연결합니다.");
    }

    @Override
    public void disconnect() {
        System.out.println("Oracle 데이터베이스 연결을 종료합니다.");
    }

    @Override
    public void executeQuery(String query) {
        System.out.println("Oracle에서 쿼리를 실행합니다: " + query);
    }
}

 

 

4. 다형성

다형성은 객체가 여러 형태를 가질 수 있게 하는 개념으로, 상위 클래스 타입으로 하위 클래스의 객체를 참조하여 동일한 메서드를 호출할 때 다른 동작을 하도록 한다.

class Animal {
    public void sound() {
        System.out.println("Some sound...");
    }
}
class Dog extends Animal {
    @Override
    public void sound() {
        System.out.println("멍멍!");
    }
}
class Cat extends Animal {
    @Override
    public void sound() {
        System.out.println("야옹!");
    }
}

public class Main {
    public static void main(String[] args) {
        Animal[] animals = {new Dog(), new Cat()};
        for (Animal animal : animals) {
            animal.sound();
        }
    }
}
//(출력)
//멍멍!
//야옹!

 

 

정리

  • 객체지향 프로그래밍은 현실 세계를 바라보는 방식 그대로 프로그래밍하기 위해 탄생했다.
  • 객체지향 4대 특성을 가지는 언어를 객체지향 언어라고 한다. 
  • 객체 지향 4대 특성에는 캡슐화, 상속, 추상화, 다형성이 있다.
  • 다음 장에서는 객체지향 설계 5원칙인 SOLID에 대해 다루어 볼 것이다.

코루틴을 사용하더라도 Spring MVC의 블로킹 구조로 인해 쓰로풋을 향상시키기 어렵다는 사실을 이전 실험에서 다시 한번 확인했다. 그렇다면 Spring MVC의 블로킹 구조가 왜 쓰로풋 향상을 막는지, 반면에 Spring WebFlux는 어떻게 쓰로풋을 향상시킬 수 있는지 살펴보겠다. 본격적인 분석에 앞서 코루틴의 동작 방식에 대해 간단히 짚고 넘어가려 한다.

 

코루틴은 어떻게 동작하며 왜 사용하는 것일까?

코틀린의 코루틴은 탈출 지점을 기준으로 코드를 여러 조각(코드 블록)으로 나누고, 쓰레드 위에서 중단과 재개를 반복하며 실행할 수 있게 해준다. 이렇게 나눠진 코드 블록이 중단되었다가 다시 재개될 수 있는 이유는 코틀린 컴파일러가 CPS(Continuation-Passing Style) 패턴으로 변환하기 때문이다. CPS 패턴에서는 코드가 실행 도중 특정 지점에서 멈출 수 있으며, 이후 해당 지점부터 다시 시작할 수 있도록continuation을 전달하여 루틴(함수)이 탈출과 진입을 반복할 수 있게 해 준다.

(코틀린 컴파일러가 코루틴 함수를 어떻게 cps 패턴으로 변환하는지 알고 싶으면 Java로 decompile 해보는 걸 추천한다.)

 

이 방식 덕분에 코루틴은 쓰레드가 블로킹될 수 있는 구간을 탈출하고 다른 코드 블록을 실행하며 쓰레드가 놀고 있는 시간을 최소화할 수 있어, 보다 효율적으로 쓰레드를 활용할 수 있다. 이를 통해 적은 수의 쓰레드로도 높은 동시성을 유지할 수 있으며, 이것이 코루틴을 사용하는 주요한 이유이다.

추가적으로, 코루틴은 멀티스레드 환경에서 작업을 병렬로 분산하여 실행할 수 있으며, 동일한 코루틴 내의 코드 블록이라도 각 블록이 서로 다른 스레드에서 실행될 수 있다는 특징이 있다. (그림 2)

그림 1. 싱글쓰레드 기반

 

그림 2. 멀티 쓰레드 기반

 

 

 

MVC 환경에서 코루틴이 쓰로풋 향상에 도움이 되지 못한 이유

MVC 환경에서는 요청당 하나의 서블릿 스레드가 배정되어 처리된다. 서블릿 스레드는 요청 처리가 완료될 때까지 대기하며, 이는 서블릿이 블로킹 IO 모델을 사용하기 때문이다. 따라서, 코루틴을 사용해 비동기 논블로킹 코드를 구현하더라도 블로킹 구간이 존재하게 되어 쓰루풋을 높이는 데 한계가 생긴다.

  

 

 

 

WebFlux환경에서는 코루틴 사용이 쓰로풋을 향상 시킬 수 있었던 이유

WebFlux는 MVC와 달리 요청이 블로킹되는 구간을 없앨 수 있다. 이는 Netty의 이벤트 모델 덕분인데, 요청이 들어오면 채널이라는 객체를 생성해 이벤트 루프에 할당된다. 이후 채널로 요청이 들어오면, 이벤트 루프는 NIO Selector를 통해 이를 감지하고, 해당 요청을 이벤트 큐에 태스크로 등록한다. 등록된 태스크는 순서가 되면 이벤트 큐에서 꺼내져 처리된다. 이렇게 요청의 등록과 처리가 독립적으로 분리되어 비동기적으로 처리할 수 있게 된다. 또한, 요청 처리하는 부분이 코루틴으로 구현되어 블로킹이 발생하지 않기 때문에, 요청을 받고 처리한 후 응답하는 전체 과정에서 스레드가 블로킹되지 않아 쓰로풋이 크게 향상되게 된다.

 

 

 

 

추가적으로, 코루틴이 이벤트 루프를 통해 비동기 논블로킹을 구현하는 과정을 설명하자면, 코루틴으로 작성된 코드는 쓰레드 위에서 실행되다가 블로킹 지점에 도달하면, 재개할 태스크를 이벤트 큐에 등록한 후 쓰레드를 반환하게 된다. 반환된 쓰레드는 이벤트 큐에서 꺼내진 새로운 태스크가 할당되어 다시 실행되며, 이 과정이 반복되면서 블로킹 없이 연속적으로 태스크를 처리할 수 있게 된다.

 

 

(정리하면)

Netty의 이벤트 루프 + 코루틴을 사용하면,

1. 요청의 등록과 처리를 독립적으로 분리하여 비동기적으로 처리할 수 있고

2. 코루틴을 통해 요청에 대한 처리를 비동기 논블로킹으로 처리할 수 있어진다.

3. 따라서 요청을 처리하는 전 과정에서 블로킹이 일어나지 않아 쓰로풋이 향상될 수 있다.

(물론, 코루틴을 사용하더라도 논블로킹을 지원하지 않는 IO를 사용할 경우 블로킹이 발생하여 쓰로풋이 크게 저하될 수 있으니 주의해야 한다.)

 

마치며

이 글에서는 Netty의 이벤트 루프 모델과 코루틴이 만나 어떻게 쓰로풋을 향상시키는지 고수준에서 설명했으며, MVC 환경에서는 구조적인 문제로 인해 코루틴을 사용하더라도 쓰로풋 향상이 어려운 이유를 다루었다. 이를 통해 MVC 환경이 아닌 WebFlux 환경에서 코루틴 사용이 권장되는 이유를 명확히 이해하는 데 도움이 되었기를 바래본다.

 

마지막으로, Netty의 동작 방식은 실제로 훨씬 복잡하고 이해할 부분이 많다. Netty 공식 문서나 Netty In Action과 같은 참고할 만한 자료들이 있으니, 관심이 있다면 한 번 읽어보시기를 추천한다.

우리는 적은 리소스로도 높은 쓰로풋(Throughtput)을 달성하기 위해 비동기 프로그래밍을 활용한다. 스프링 개발자들은 주로 리엑터(Reactor)와 코틀린의 코루틴을 통해 비동기를 구현한다. 그러나 리엑터는 그 복잡성으로 인해, 많은 개발자들이 코루틴을 선호하는 경향이 있다. 코루틴은 주로 논블로킹 환경을 지원하는 Spring WebFlux와 함께 사용된다. 반면 Spring MVC와는 잘 사용되지 않는다. 그 이유는 코루틴을 사용하더라도 Spring MVC의 블로킹 구조로 인해 쓰로풋이 향상되기 어렵기 때문이다. 이제 간단한 테스트를 통해 이 차이점을 좀 더 자세히 설명을 해보겠다.

 
 

MVC와 WebFlux 환경에서의 쓰로풋 비교

테스트 개요

  1. 해당 테스트는 BFF 환경에서 User 서비스의 사용자 정보와 Order 서비스의 주문 정보를 조회하고, 이를 조합하여 mobile 클라이언트에 전달하는 과정을 재현하였다.
  2. Spring MVC를 사용하느냐 Spring WebFlux를 사용하느냐에 차이가 있을 뿐, 비즈니스 로직은 동일하다.
  3. Spring MVC는 Tomcat을 사용하고, 스레드 풀 크기는 기본 값인 200으로 설정하였다. 이는 스레드 수가 증가함에 따라 발생할 수 있는 컨텍스트 스위칭 비용이 요청 처리 성능에 부정적인 영향을 미치지 않도록 하기 위함이다. 물론, 필자가 작성한 테스트 코드는 복잡한 비즈니스 로직이 없기 때문에 스레드 풀 크기를 200 이상으로 설정하면 동시성은 높아질 수 있다. 하지만, 비즈니스 로직이 복잡한 서버에서는 오히려 성능 저하를 초래할 수 있으며, 일반적인 서버는 필자가 작성한 코드보다 더 복잡한 로직을 처리하는 경우가 많다. 이번 테스트는 그러한 일반적인 환경을 가정하고 동시성 및 성능을 측정하려는 목적이 있다.
  4. Spring WebFlux는 Netty를 사용하였으며 기본 설정을 적용하였다.

 

테스트 환경

서버 환경(Amazon Lightsail)
CPU 2.50GHz 2core
Memory 2GB
OS Amazon Linux 2023
테스트 도구(JMeter)
실행 위치 Mac M1 Pro (Local)

 

테스트 시 사용한 코드

(공통)

@RestController
class UserController(private val bffService: BffService) {
    @GetMapping("/mobile/users/{id}")
    suspend fun getUserDetails(@PathVariable id: Long): UserDetailsResponse {
        return bffService.getUserDetails(id)
    }
}
@Service
class BffService(private val userService: UserService, private val orderService: OrderService) {
    suspend fun getUserDetails(id: Long): UserDetailsResponse = coroutineScope {
        val userDeferred = async { userService.getUserById(id) }
        val ordersDeferred = async { orderService.getOrdersByUserId(id) }
        
        UserDetailsResponse(userDeferred.await(), ordersDeferred.await())
    }
}
@Service
class UserService {
    suspend fun getUserById(id: Long): User {
        delay(1000) // NIO 호출을 시뮬레이션하기 위한 1초 지연
        return User(id, "User$id")
    }
}
@Service
class OrderService {
    suspend fun getOrdersByUserId(userId: Long): List<Order> {
        delay(1000) // NIO 호출을 시뮬레이션하기 위한 1초 지연
        return listOf(
            Order(1, "Item A", 100),
            Order(2, "Item B", 200)
        )
    }
}
data class User(val id: Long, val name: String)
data class Order(val orderId: Long, val itemName: String, val price: Int)
data class UserDetailsResponse(val user: User, val orders: List<Order>)

 

(MVC)

dependencies {
	implementation("org.springframework.boot:spring-boot-starter-web")
}

 

(WebFlux)

dependencies {
	implementation("org.springframework.boot:spring-boot-starter-webflux")
}

 

테스트 결과

1. MVC 테스트

  • 유저: 200명 (200명 이상 설정 시 에러가 발생하여, 동시에 처리 가능한 요청 수가 200개로 추정됨)
  • 루프카운트: 250
  • 요청 처리량: 5만건
  • 실행 시간: 04:19
  • 쓰로풋: 192.6/sec

 

2. WebFlux 테스트

  • 유저: 5000명 (5000명 이상 설정 시 에러가 발생하여, 동시에 처리 가능한 요청 수가 5000개로 추정됨)
  • 루프카운트: 10
  • 요청 처리량: 5만건
  • 실행 시간: 00:18
  • 쓰로풋: 2709.7.sec

 

3. 결과 비교

코루틴을 MVC 환경에서 사용했을 때 쓰로풋은 192.6/sec, WebFlux 환경에서는 2709.7/sec로 나타났다. 단순히 쓰로풋만 비교했을 때, 약 14배의 차이가 발생했다.

 

 

 

 

 

**이러한 차이가 발생하는 이유에 대해서는 Spring MVC에서 코루틴 사용에 대한 생각 - 2편에서 계속 다루겠습니다.**

개발자는 "문제 해결 능력" 이 중요하다라는 말을 개발자라면 누구나 한번쯤은 들어봤을 것이다. 나 역시 많이 들었고, 문제 해결능력을 키우기 위해 알고리즘 문제나 시스템 디자인 문제를 주기적으로 풀며 다방면으로 노력해 왔다. 하지만 나의 문제 해결 능력이 발현되기 위해서는 선행되어야 할 게 있다는 사실을 깨달았고 이를 구독자들과 공유하고자 한다. 

 

실제로 문제 해결 능력은 언제 사용되고 있는가?

회사에서 개발을 하다보면 요구사항을 받게 되고 요구사항에 따라 개발을 하게 된다. 여기서 요구사항은 개발자에게는 문제이고 요구사항에 따라 개발하는 행위가 문제를 해결하는 것에 해당한다. 우리는 이런 식으로 평소에도 많은 문제들을 해결하고 있다. 그렇다면 유트브와 같은 스트리밍 플랫폼을 만든다고 가정해 보자. 여기서 '실시간 스트리밍 기능'과 같은 복잡한 기능을 구현해야 한다면, 이 문제를 잘 해결(구현)했다고 해서 그 개발자가 능력이 뛰어나다고 할 수 있을까? 필자가 최근 깨달은 것은 '아니다'이다. 단순히 구현만 잘 한다고해서 뛰어난 개발자라고 할 수는 없다. 중요한 것은 어떤 문제를 인지했는지에 있다. 뛰어난 개발자는 주어진 요구사항만 처리하는 데 그치치 않고 그 요구사항 뒤에 숨겨진 추가적인 문제를 인식하고 해결하는 능력을 갖추고 있다. 

 

그렇다면, 어떤 문제를 인지해야하는가?

실시간 스트림 기능 구현으로 돌아가서 생각해 보자. '영상 스트리밍 구현' 자체도 요구사항이지만, 이를 넘어 부가적인 문제가 존재한다. 예를 들어, 실시간으로 영상을 스트리밍 하려면 네트워크 상태에 따라 끊김 없이 동영상을 제공해야 한다. 즉, 어떻게 해야 네트워크 환경에 따라 끊김 없는 스트리밍을 제공할 수 있을까? 혹은, 사용자 수가 급격히 늘어나거나 서버가 장애를 겪었을 때 어떻게 대응할 것인가? 이런 추가적인 문제들이 개발자가 인지하고 스스로 정의해야 하는 부분이다.

 

문제가 정의되었다면 이후 문제를 해결하면 된다. 이때가 문제 해결 능력이 발현되는 구간이다. 예를 들어, 위와 같은 상황에서는 적응형 비트레이트(Adaptive Bitrate Streaming) 기술을 도입해 사용자의 네트워크 상태에 맞춰 비디오 품질을 동적으로 조정하도록 구현할 수 있고, 동영상 끊김 문제 외에도 서버의 과부하를 방지하고 전 세계 사용자에게 안정적인 서비스를 제공하기 위해 CDN을 사용하는 방법을 사용할 수 있다.

 

문제 정의를 잘하기 위해서는 어떻게 해야  할까?

트래픽이 많은 회사에 다니거나 서버 운영을 하다 보면 자연스럽게 다양한 문제를 마주하게 된다. 이러한 경험을 통해 문제를 인지하는 경우도 많지만, 모든 도메인에서 발생하는 문제를 직접 겪어보지는 못한다. 그렇기 때문에 추론하거나 창의력을 발휘해 문제를 정의하는 능력을 키워야 한다. 창의력을 발휘하려면 탄탄한 기본기가 필수적이다. 기본적인 기술 지식과 원리를 깊이 있게 이해하고 있어야, 개발자는 문제를 다양한 시각에서 바라볼 수 있으며, 창의적으로 문제를 스스로 정의할 수 있기 때문이다.

 

앞서 언급한 예시에서 지연 시간(latency)과 대역폭(bandwidth)의 개념을 이해하고 있었다면, 네트워크 상태가 불안정할 때 끊김 현상이 발생할 수 있음을 쉽게 예측할 수 있다. 특히, 지연 시간이 길어질 때는 끊김 없는 스트림을 제공하기 어려울 수 있음을 이해하게 되고, 이를 문제라고 인지할 수 있다.

 

결론

필자는 "문제를 정의할 수 있어야지 문제를 해결할 기회가 생긴다." 라고 생각한다. 따라서 우리는 문제 해결 능력 만큼이나 문제 정의 능력을 기르는데 집중해야 한다. 문제를 잘 정의하려면 추론 능력창의력이 요구되며, 이 두 가지 능력은 모두 탄탄한 기본기에서 비롯된다. 결국, 기본기를 충실히 다져야 좋은 개발자로 성장할 수 있을 것이다.

 

 
 

 

'의식의 흐름' 카테고리의 다른 글

리눅스 자격증 공부  (0) 2024.08.08

최근 회사에서 레거시 서비스 운영을 맡게 되었는데, 이 서비스로 말할 것 같으면 자그마치 13년 전에 네이버에서 개발한 사내 메신저 서비스이다. 채팅도 할 수 있고 쪽지도 보낼 수 있다. 대략 DAU는 7600명정도여서 부담스러운 트래픽은 아니다. 다만, NCP Classic 환경에 MSA(3개의 서비스와 25개 서버 인스턴스)로 구성되어 있지만 쿠버네티스와 같은 컨테이너 오케스트레이션 기술을 사용하지 않다 보니 소스 코드를 수정하거나 디비 점검을 위해서는 일일이 Application 서버들을 수동으로 멈추고 재배포해야 하는 번거움이 있었다.

최근 몇개월 간 이러한 레거시 서비스를 운영하다 보니, 자연스럽게 다양한 리눅스 명령어를 자주 사용하게 되었고 리눅스에 관심이 가지게 되었다. 그러다가 리눅스 자격증 공부를 하면 체계적으로 리눅스를  공부할 수 있을 거 같아 앞뒤 생각 안 하고 '리눅스마스터1급' 필기시험을 신청했다.

 
 
너무 갑자기 신청한 느낌이 없지 않아 있어 조금 쫄리긴 하지만, 난 소위 말하는 '위기주도 학습'을 믿는 사람으로서 일단 공부를 하지 않으면 안 되는 상황으로 몰아 집중력을 최대치로 끌어올리는 방법을 좋아하긴 한다. 물론 고통은 따르겠지만.. ㅠㅠ

 

결론(목표) 

이번 자격증 공부를 통해서, 리눅스에 대해 깊이 이해하고 서버 자원을 효율적으로 사용할 수 있도록 해야겠다. 내가 만든 Application이 어떤 환경에서 동작하는지 제대로 이해를 해야지 성능튜닝을 제대로 이루어낼 수 있다고 생각한다. 
 
 
 
 
 
 
 
 
 
 
 
 

'의식의 흐름' 카테고리의 다른 글

문제를 스스로 정의하고 스스로 해결하기  (1) 2024.09.20

+ Recent posts