스프링 3.1 - 스프링의 목적과 활용

김법우·2023년 5월 14일
0

Spring

목록 보기
3/4

스프링의 정의

들어가며

이 책의 저자는 스프링의 개발 철학을 이해하고 사용하는 것이 매우 중요하다고 말한다.

왜 만들어졌는지, 추구하는 가치는 무엇인지를 이해하고 사용하는 것이 단순히 주어진 기능들을 기계처럼 사용하는 것보다 훨씬 주체적이고 발전 가능성 높은 개발을 할 수 있기 때문이다.

Node.js 가 작고 실용적인 소프트웨어 개발을 지향하고 (노드는 프레임워크보다는 런타임이지만) 이 철학에 따라 개발할때 가벼우면서 빠르게 보급 가능한 소프트웨어를 만들 수 있듯이 스프링이 추구하는 가치와 등장 배경에 대해 알고 개발을 하는 것이 훨씬 스프링스러운, 그렇기에 자연스럽게 스프링이 주는 가치를 활용하는 개발을 할 수 있을 것이다.

미리 스프링의 정의의 컨셉을 이야기하자면, “비즈니스 로직과 엔터프라이즈 기술의 분리를 통한 애플리케이션 개발/유지보수 복잡함의 해결”이다. 즉 순수한 비즈니스 로직을 가지는 POJO 에 엔터프라이즈 기술을 적용할 수 있도록 하는 프레임워크라고 할 수 있겠다.

자바 엔터프라이즈 개발을 쉽게 해주는 오픈소스 경량급 애플리케이션 프레임워크

애플리케이션 프레임워크

애플리케이션 프레임워크는 특정 계층 혹은 업무, 기술 분야에 국한되지 않고 애플리케이션의 전 영역을 포괄하는 프레임워크를 말한다.

  • 개발의 전 과정을 빠르고 편리하게 진행하기 위해 만들어짐
  • 다양한 기술의 모음집 x , 애플리케이션의 전 영역을 관통하는 일관된 프로그래밍 모델과 핵심 기술로 각 분야의 필요 사항을 채워줌
💡 스프링의 탄생 탄생 일화가 재미있다. 로드 존슨이라는 개발자가 자바 엔터프라이즈 개발에 대한 경험을 바탕으로 모든 영역에 대한 개발 전략을 제시하며, 이것을 증명하기 위한 3만 라인 가량의 예제 코드를 가져왔는데 이것이 원시 스프링이 되었다.

경량급

Node.js 개발을 하다오니 이 부분에 대해서 의문을 가지게 된다. 그 무겁고 큰 스프링이 경량급이라고?

저자가 말하는 경량급은 불필요하게 무겁지 않다는 의미이다. 실제로는 수십만 라인에 달하는 복잡하고 방대한 규모의 프레임워크다. 여기서 불필요한 부분은 코드에 불필요하게 등장하는 (침투적인) 프레임워크와 서버환경에 의존적인 부분을 말한다.

타 자바 엔터프라이즈 프레임워크에서 기술과 환경을 지원하기 위해 필요하던 수없이 반복되던 코드를 제거함으로서 스프링이 동일 기능을 제공하기 위해 필요한 로직 이외의 것들이 가볍다는 의미로 해석하는 것이 가장 올바른것 같다. (스프링 이외에 자바 엔터프라이즈 프레임워크를 써보지 않아 크게 와닿지는 않지만 순수한 자바 객체지향을 활용하지 못한 이전 프레임워크 예시를 생각하면 이해하기 쉽다.)

자바 엔터프라이즈 개발을 “쉽게”

엔터프라이즈 개발은 복잡하다. 코드를 작성하는 것도 일이고 다양한 이유로 인해 수정해야할 일이 생긴다. 요구사항을 구현하기 위해 새로운 기술이나 추가적인 기술 스택이 필요할때도 있다.

“쉽게” 라는 의미는 복잡하고 실수하기 쉬운 로우레벨 기술에 신경쓰지 않고 요구사항의 구현에 더욱 집중하여 빠르고 효과적으로 구현하는 것을 말한다.

오픈소스

스프링은 아파치 라이선스 버전 2.0 을 가진 오픈소스 라이센스를 가지고 있다. Node.js 가 오픈소스 유저랜드를 기반으로 기술이 아닌 사용자의 관점에서 폭 넓은 해결책을 실험할 자유를 제공함으로써 발전해온 것처럼 스프링도 개발 과정에 많은 사람들이 직/간접적으로 참여하며 발전시키고 토론 할 수 있다.

엔터프라이즈 개발의 복잡함과 스프링

스프링이 복잡함을 해결하는 방식

엔터프라이즈 개발이 복잡하다는 사실은 누구나 동의하는 부분이므로 결론부터 이야기하면,

스프링은 비즈니스 로직과 엔터프라이즈 기술을 분리해 비즈니스 로직이 객체지향적 관점에서 설계 될 수 있는 걸림돌을 제거하는 것으로 복잡함을 해결한다.

객체지향은 현시대 가장 성공적인 프로그래밍 패러다임이다. 그럼에도 불구하고 기존 프레임워크들이 고수준의 엔터프라이즈 기술을 제공하기 위해 침투적인 방법으로 기능을 제공함으로서 객체지향 설계를 자유롭게 하지 못하도록 막아버려 자바의 본질적인 장점을 앗아가 가중시킨 복잡함을 해결한 것이다.

💡 **비침투적인 기술** 특정 기술의 적용 사실이 코드에 직접적으로 반영되지 않는 특징을 가진다. 반대로 꼭 필요한 코드도 아닌데 특정 기술을 사용하기 위해 인터페이스, 코드, 특정 클래스 상속 등을 강제하는 경우 침투적인 기술이라고 이야기한다.

엔터프라이즈 기술이 적용됨으로써 생기는 복잡함이란?

특정 기술에 대한 접근 방식이 일관성이 없고 특정 환경에 종속적인 경우 발생하게된다.

동일한 목적의 API 가 사용 방법과 접근 방식이 다르며 서버환경, 표준/비표준, 다양한 오픈소스 등의 혼재는 끔찍한 개발 경험을 만들어낸다.

스프링은 기술 접근에 일관성이 없는 문제를 서비스 추상화를 통해 해결했다. 트랜잭션 추상화, OXM 추상화, 데이터 엑세스 기술에 독립적인 트랜잭션 동기화 기법 등은 로우레벨의 기술 구현 변경과 사용하는 측의 수정 주기를 독립적으로 가져가 테스트 편의를 증가시키고 기술과 세부 사항에 독립적인 비즈니스 로직을 구현하게 해준다.

이 책에서 제공하는 프로그래밍적 트랜잭션에서부터 선언적 트랜잭션을 적용하기까지가 가장 대표적인 예가 아닐까 싶다. 프록시 패턴을 적용해 타깃 클래스의 비즈니스 로직에 트랜잭션을 적용할때 트랜잭션이 적용된다는 사실을 타깃 클래스는 모른다. 서로 독립적인 것이다.

이렇게 할 수 있는 근본적인 기저 기술은 추상화된 트랜잭션 동기화 기술과 트랜잭션 서비스 추상화에서 온다. 이러한 기술과 기능들을 스프링에서 제공하는 것이다.

const queryRunner = this.dataSource.createQueryRunner();
await queryRunner.startTransaction();

try {
	func1(.., queryRunenr);
  func2(.., queryRunner);
  ...

  await queryRunner.commitTransaction();
} catch(e) {
	..
  await queryRunner.rollbackTransaction();
} finally {
  await queryRunner.release();
}

내가 사용하는 Nest.js 와 TypeORM 을 사용한 경우의 트랜잭션 코드이다. 기능마다 저렇게 트랜잭션을 적용하기 위한 코드가 반복된다. 코드의 중복이 유지보수와 가독성을 해칠뿐만 아니라 TypeORM 이 아닌 다른 ORM 을 사용하고자 하는 것은 불가능에 가깝다.

@Transactional
public interface UserService {

    void upgradeLevels();

    void add(User user);

    @Transactional(readOnly = true)
    User get(String id);
    ...

그에 반해 스프링의 트랜잭션 적용은 데코레이터 하나로 데이터 엑세스 기술에 무관하게 적용할 수 있다. 심지어 클래스가 아닌 인터페이스에도 적용이 가능하다.

POJO 프로그래밍

스프링은 POJO 프로그래밍을 하게 최선을 다해 도와줄뿐이다.

앞서 말했듯이 스프링은 엔터프라이즈 개발의 복잡함을 비즈니스 로직과 엔터프라이즈 기술의 분리를 통해 해결했다. 엔터프라이즈 기술은 비침투적인 방식으로 스프링이 제공해주지만, 마찬가지로 엔터프라이즈 개발을 복잡하게 하는 원인인 복잡한 요구사항 구현은 전부 해결해주지 않는다.

즉, 개발자가 POJO 프로그래밍을 함으로써 해결해야하는 것이다. 스프링의 모든 기술과 전략은 객체지향이라는 자바 언어의 가장 강력한 도구를 극도로 활용할 수 있도록 돕기 위해 존재할뿐이다.

POJO 프로래밍을 가장 잘 나타내는 도식이다. POJO 를 중심에 두고 IoC/DI , PSA, AOP 가 둘러싼 삼각형과 설계 정보가 합쳐진 모습이다. 여기서 IoC/DI, PSA, AOP 는 POJO 프로그래밍을 할 수 있게 해주는 가능 기술이라고 불린다. 이러한 가능 기술을 POJO 프레임워크인 스프링이 제공해주는 것이다.

POJO와 POJO 프레임워크

💡POJO 란? Plain Old Java Object 라는 뜻으로 3가지 조건을 갖춘 자바 오브젝트이다.
  1. 특정 규약에 종속되지 않는다.

    자바 언어와 꼭 필요한 API 이외에는 종속되지 않는다. 특정 기능 사용을 위해 어떤 클래스를 상속해야만 한다거나 특정 규약에 따라 코딩을 해야하는 경우 POJO 라고 할 수 없다.

    ⇒ 객체지향 설계의 자유가 보장되어야한다.

  2. 특정 환경에 종속되지 않는다.

    특정 OS, 특정 런타임, 특정 서버 서비스에 종속하는 오브젝트는 POJO 라고 할 수 없다.

    ⇒ 환경에 독립적이어야한다.

  3. 객체지향적인 자바 언어의 기본에 충실하게 만들어져야한다.

    무엇보다도 중요하다. 규약에 종속되지 않고 환경에 종속되지 않는 자바 오브젝트지만 객체지향 원칙을 지키지 않은 오브젝트는 유지보수하기 힘들고 재사용하기 힘든 객체가 된다.


POJO 프로그래밍이 가능하도록 기술적 기반을 제공하는 프레임워크를 POJO 프레임워크라고 한다.

스프링은 자신이 꼭 필요한 기술영역에 최소한으로 관여하면서 기술을 제공함으로서 애플리케이션을 POJO 로 개발 할 수 있도록 돕는다.

스프링은 앞의 도식에 나왓듯 3가지 가능 기술을 통해 POJO 프로그래밍을 위한 기술적 기반을 제공한다. 하나씩 자세히 알아보자.

가능 기술 (1) : 제어 역전(IoC), 의존관계 주입(DI)

가장 기본이자 핵심 개발 원칙이다. PSA, AOP 또한 IoC/DI 에 바탕을 두고 있다.

특정 오브젝트가 의존하는 객체에 대한 관계 설정 작업과 생성을 해당 오브젝트가 수행하지 않고 외부에서 수행하는 것으로 오브젝트의 생명주기 관리에 대한 제어를 역전시킨다.

스프링 컨테이너는 이렇게 역전된 관계를 기반으로 DI 를 통해 의존할 객체를 생성하고 관계를 설정하는 것이다.

내가 객체를 직접 생성하지 않고 외부로 부터 주입받아 사용하는 것뿐. 이게 왜 핵심 개발 원칙인걸까?

엔터프라이즈 소프트웨어는 끊임없이 변화한다. 있던 기능이 없어지거나 변경되어야하고, 하루가 다르게 추가해야 할 기능이 쏟아져 나온다. 이런 과정과 함께 객체가 처리하는 요구사항은 늘어가고 의존하는 객체도 기하급수적으로 늘어나 시스템의 복잡도를 증가시킨다.

이 시점에서 특정 객체 기능의 수정이나 추가가 드러나지 않는 영향을 곳곳에 미치는 일은 개발자에게 지옥과도 같은 경험을 준다. 엄청난 시간을 들여 수행한 테스트 단계에서 확인이 된다면 차악인 상황이지만, 그 마저도 뚫어버리는 귀신같은 버그를 볼 수 도 있다.

하지만 비즈니스 로직의 복잡함을 근본적으로 해결하는 것은 불가능에 가깝다. 퇴행하는 서비스가 아닌 경우에야 복잡함은 당연히 증가한다. 그렇다면 개발자인 우리가 해야할 일은 더 이상 건드릴 수 없는 죽어버린 소프트웨어를 만들어내는 것이 아니라 쉽게 수정 할 수 있고, 확장 할 수 있고, 테스팅을 할 수 있는 소프트웨어를 만들어 내는 일이다.

IoC/DI 가 핵심 원칙인 이유는 객체지향 언어를 통해 우리가 해야할, 쉽게 수정할 수 있고 확장할 수 있으며 테스팅 할 수 있는 좋은 소프트웨어를 만드는 기술이자 사고 방식이기 때문이다.

사고 방식이라고 표현한 이유는 단순히 두 객체간의 관계를 역전시키고 외부에서 주입받아 관계를 설정했다고 해서 앞서 말한 좋은 소프트웨어가 되는 것이 아니기 때문이다. 수정의 이유가 하나인, 자신의 일만 잘하는 객체들과 요구사항의 핵심을 꿰뚫고 정의할 인터페이스가 존재하지 않는다면 이것은 무의미하다.

즉, IoC/DI 가 무엇을 왜 하는지를 인지해야지만 앞서 말한 좋은 코드들을 만들어 낼 수 있는 것이다. 그렇기에 핵심 가치이며, 스프링이 제공하는 가장 기본이 되는 기술이라고 생각한다.

DI 의 활용

핵심 기능의 변경

의존 대상의 구현을 바꾸는 것이다. 사용하는 측에서는 특정 인터페이스를 구현한 주입받은 객체만 사용하고 있으므로 해당 인터페이스를 구현하는 어떠한 객체로 갈아끼워지든 사용하는 측은 수정될 일이 없다.

최근 메세지를 전송하는 기능에서 메세지의 종류에 따라 다양한 도메인 객체를 의존해 메세지 내용을 구성하고, 전후 처리를 수행해야하는 로직이 있었는데 전송을 수행하는 객체에 메세지를 구성하는 객체를 DI 하도록 처리해보았다.

이후에 최초 2배 가까이되는 서로 다른 종류의 메세지가 추가되었지만 전송하는 과정에서 처리해야할 복잡한 전후 과정에는 전혀 수정없이 기능을 확장 할 수 있었고 테스트 또한 각 메세지의 생성 과정에 대해서만 추가적으로 검증하는 것으로 검증 할 수 있었다.

이처럼 핵심 기능을 교체해버릴 수 있도록 하는 기저 설계 혹은 최후의 보루를 만드는 작업이 DI 인 것이다.

부가 기능의 추가

데코레이터 패턴이 가장 쉬운 설명이 될 것 같다. 타깃 객체의 기능에 얹어 투명하게 존재하며 부가 기능을 제공하는 것. 타깃 객체의 핵심 로직에도 영향이 가지 않으며 기존에 관계를 가지던 사용측에도 영향을 주지 않는다.

해당 활용방법을 좀 더 범용적인 기능으로 다수의 객체에게 일반화해 적용하면 AOP 라고 할 수 있다.

인터페이스의 변경

사용할려는 객체가 구현한 인터페이스가 호환 되지 않는 경우가 있다. 예를 들어 A 가 C 오브젝트를 사용하려 하지만 A 는 기존에 B 인터페이스를 사용하도록 만들어져있을 때이다. 이런 경우 B 인터페이스를 구현하면서 C 의 기능을 위임하는 B` 를 구현해 A 에게 주입해주어 A 를 재사용 할 수 있다.

이런 케이스는 Nest.js 를 사용할때도 마주한 상황인데, 다른 서비스 클래스를 사용하고자 할 때 기존 코드를 불가피하게 수정해야하는 상황에 주로 사용한다.

일종의 어댑터 객체를 기존 객체 대신 주입해 사용하는 것으로 요약 할 수 있겠다. 이러한 인터페이스의 변경에 대해 강해지기 위해서는 당연히 추상 레이어를 통해 일반화된 접근이 가능해야한다.

템플릿/콜백

템플릿/콜백의 핵심은 변하지 않는 작업 흐름을 템플릿으로, 자주 변하는 로직을 콜백으로 분리 설계하는 작업이다. 이렇게 설계된 경우 사용하는 측에서 콜백을 템플릿에 DI 해줌으로서 OCP 를 지키는 구조를 구현 할 수 있다.

싱글톤과 오브젝트 스코프

IoC 를 위해 DI 를 프레임워크가 수행하는 경우 오브젝트의 생명 주기를 컨테이너가 관리한다. 소수의 오브젝트가 수많은 클라이언트를 상대로 고성능 서비스를 해주기 위해서는 엔터프라이즈에서 매우 중요하다.

즉, 여러 요청을 동시에 정상적으로 처리하기 위해서는 가장 기본적으로 싱글톤 스코프를 관리해야하며 DI 프레임워크에서 이것을 수행하게된다. (물론 싱글톤이 아닌 다양한 스코프를 가지는 객체도 생성해 관리 할 수 있따.)

테스트

단위 테스트를 효과적으로 할 수 있도록하는 기저는 테스트 대상의 고립이다. 그리고 대상을 고립시키기 위해서는 의존 오브젝트를 대신하는 스텁, 목 오브젝트를 활용해야한다.

이때도 DI 가 맹활약을 하는데, 오브젝트의 생명주기와 관계 설정에 대한 코드가 대상 오브젝트에 직접적으로 드러나지 않도록 설계된 경우 쉽게 목 오브젝트를 만들어 테스트 대상에 DI 해줌으로서 고립 시킬 수 있다.

심지어는 관계 설정에 대해 명시한 설정 파일을 테스트용으로 아예 갈아끼워 고립시킬 수 도 있다. 스프링에서는 지금까지 ApplicationContext 를 만들기 위한 자바 클래스 혹은 XML 파일을 사용했고 Nest.js 에서는 앵귤러에서 영향 받은 타입스크립트 클래스 모듈 시스템을 사용한다.

describe('AService', () => {
  let service: AService;
  ...

  beforeEach(async () => {
    const module: TestingModule = await Test.createTestingModule({
      providers: [
				AService,
        {
          // A 가 의존하는 B 인터페이스에 Mock 객체를 주입
          provide: BModuleInjector.B_SERVICE_TOKEN,
          useValue: getMockBService()
        },

두 경우 모두 프레임워크에서 제공하는 애플리케이션 컨택스트나 모듈 시스템을 활용한다면 쉽게 고립된 테스트를 작성 할 수 있도록 만들어준다.

가능 기술 (2) : AOP

리마인드하자면 AOP 는 OOP 와 상호 배타적인 개념이 절대 아니다. AOP 는 OOP 다움을 유지할 수 있도록 하는 패러다임에 가깝다.

그 자체로는 핵심 기능이 아니지만 여러 객체에 공통적으로 드러나는 부가 기능을 응집시켜 OOP 의 장점을 살리기 위한 설계인 것이다. 스프링에서는 AOP 를 크게 2가지 방법으로 적용 할 수 있는데 그 중 다이나믹 프록시를 사용하는 방법에서는 앞서 말한 DI 가 기저 기술로 사용된다.

  • 다이내믹 프록시
    • 기존 코드에 영향 주지 않고 기능 확장하는 데코레이터 패턴의 응용
    • 기존 자바 코드를 사용하기에 적용과 개발이 간편하다는 장점
    • 부가기능의 적용 지점이 메서드 호출 지점만 가능하다는 단점
  • 언어의 확장
    • AspectJ 가 대표적
    • 프록시 방식의 AOP 에서는 불가능한 다양한 조인 포인트를 만들 수 있다는 것이 장점
    • 자바 언어와 JDK 만으로는 불가능하며 별도의 컴파일러를 사용한빌드 과정 혹은 바이트 코드 조작을 위한 작업이 필요하다는 것이 단점

가능 기술 (3) : PSA - Portable Service Abstraction

앞서 말했듯이 POJO 가 되기 위한 조건 중 특정 환경이나 구현 방법에 종속되어서는 안된다는 것이 있었다.

이것을 다른 방식으로 이야기하면 “환경과 세부 기술의 변화에 관계없이 일관된 방식으로 기술에 접근” 이라고 할 수 있겠다. 자바 엔터프라이즈 개발이 복잡했고 스프링이 어떻게 해결했는지의 핵심 중 하나가 바로 PSA 이다. 비즈니스 로직과 엔터프라이즈 기술을 분리한 뒤 엔터프라이즈 기술에 대한 접근 루트를 일반화 즉 추상화해 제공하는 것이 PSA 이기 때문이다.

사실 스프링의 경우 대부분의 엔터프라이즈 기술이 이미 아주 잘 추상화되어 만들어져있다. 트랜잭션이나 캐싱, 보안 등등 API 서버 애플리케이션을 설계하는 단계에서 필요한 기술들이 거의 다 있다.

하지만 Nest.js 의 경우에는 그렇지 않아 필요한 기능이 있다면 직접 발품을 팔아 라이브러리를 찾고 어떻게 녹여낼지에 대해 고민하고 설계해야한다. 이 과정에서 기술의 변화 없이 일관된 방식으로 접근한다는 것이 어떤 것인지 어렴풋이 경험하게 되었는데 거기에 대해 이야기해보고자 한다.

조금 더, 핵심에 집중하기

특정 기술에 대해 일관된 방식으로 접근하기 위해서는 단순하게 제공할 기능을 담은 함수의 파라미터나 출력값을 잘 설계해 만드는 것도 방법일 수 있다.

하지만, 이렇게 만드는 경우에는 말 그대로 특정 기술을 재사용하기 편하도록 만든 객체에 불과하다. 만약 기술이 바뀐다면? 아니면 해당 기술이 특정 환경에서는 작동하지 않는다면?

일례로 S3 스토리지에 파일을 업로드하는 모듈을 만들었다. 만들 당시에는 “어떻게 해당 기술을 잘 활용해 이미지를 저장하고, 복사하고, 삭제하고, 다운로드하지”에 대해 깊은 고민을 하고 만들었다. 하지만 해당 모듈을 사용하는 수많은 서비스가 생긴 지금에서야 기술에 의존하는 것이 얼마나 위험한지에 대해 몸으로 깨닫고 있는 중이다. S3 가 아닌 로컬 스토리지나 외부 API 서버를 통해 저장하는 일이 생겼기 때문이다.

그렇다면 어떻게 설계를 해야 이런 미래를 미리 예측하고 대비할 수 있는 걸까?

정답은 핵심에 집중하는 에 있다.

기본적으로 요구사항을 바탕으로 관련이 있는 기능을 잘 모듈화하는 것은 비교적 쉽다. 하지만 이 과정에서 기술에 조금 더 치우쳐 설계를 하는 경향이 생길 수 있는데, 그 당시에는 기술 자체를 잘 활용해 기능을 빠르게 구현하는데 목적이 있었기 때문이다.

기술을 활용헤 구현하는데 중심을 두지 않고, 기능 즉 요구사항에 중심을 두어야한다. 특정 API 호출을 통해 이미지가 업로드, 복사, 삭제, 다운로드 되야하는 경우 우리는 어떻게 구현할지에 대해 설계를 할 것이다. 이때 파일을 업로드, 삭제 , 복사, 다운로드하는 기능을 노출시키는 인터페이스를 먼저 정의하고, 어떻게 구현할건지를 담은 즉 기술적인 의존사항이 포함되는 구현체는 해당 인터페이스를 구현해 만들어야 한다.

파일 관리 기술에 대한 접근을 일반화하는 것이다. 이렇게 만들어낸 파일 관리 기능들은 재사용성, 테스트, 확장, 수정에 모두 견고하게 설계될 수 있다.

이게 바뀔까? 하는 생각으로 만든 설계는 독약이다. 소프트웨어 개발에서는 무엇이든 바뀔 수 있고 바뀌어야한다. 따라서 바뀔지에 대한 가능성의 확률을 계산하는데 들이는 노력보다 쉽게 바꾸고 확장하는데 소모하는 노력과 시간이 더 값진 것이다.

한 줄 정리하기

스프링의 목적은 POJO 를 이용해 엔터프라이즈 개발을 쉽게 하도록 지원하는 가능 기술들을 제공하지만, POJO 를 설계하고 만드는 것은 개발자의 역량이다!

그러니까 많이 설계하고 많이 고쳐봐야 실력이 느는 것.

profile
개발을 사랑하는 개발자. 끝없이 꼬리를 물며 답하고 찾는 과정에서 공부하는 개발자 입니다. 잘못된 내용 혹은 더해주시고 싶은 이야기가 있다면 부디 가르침을 주세요!

0개의 댓글