SpringBoot(2) @Annotation, 자동 설정, starter 활용하기

Yeppi's 개발 일기·2022년 7월 4일
0

Spring&SpringBoot

목록 보기
10/16

1. 기본적인 SpringBoot 활용

@SpringBootApplication

  • 메인 실행 파일

    • 묵시적으로 해당 패키지(package com.rubypaper;)를 베이스 패키지로 지정하고 있다
    package com.rubypaper;
    
    .
    .
    .
    
    @SpringBootApplication
    public class Chapter01Application {
    
        public static void main(String[] args) {
            //SpringApplication.run(Chapter01Application.class, args);
            SpringApplication application = new SpringApplication(Chapter01Application.class); // 자기자신을 인자로 넘겨준다
            application.setWebApplicationType(WebApplicationType.SERVLET);
            application.setBannerMode(Banner.Mode.OFF);
            application.run(args);
    
        }
    }
  • @SpringBootApplication 은 아래 어노테이션들이 포함되어 실행되는 것이다.
    (아래 코드와 위 코드의 의미는 같다)
       @SpringBootConfiguration
       @EnableAutoConfiguration
       @ComponentScan(basePackages= {”해당 패키지 경로”}) // 컴포넌트 스캔 대상이 된다
    • 해당 패키지 경로 의 모든 클래스 중 @Controller 가 있는 클래스가 있다면?
      자동으로 메모리에 띄워준다.


@Controller

  • 메모리에 띄워지는 클래스
  • BoardController.java

    @Controller // 컴포넌트 스캔이 내부적으로 동작해서 메모리에 뜨게 한다.
    public class BoardController {
    
        public BoardController() {
            System.out.println("===> BoardController 생성");
        }
    
        @RequestMapping(value = "/hello.do", method = RequestMethod.GET)
        @ResponseBody // 리턴 값을 JSON 으로 변환하여 응답 프로토콜 Body에 출력한다.
        public String hello(String name) {
            System.out.println("---> hello() 실행");
            return "Hello : " + name;
        }
    }
    • @Controller 가 있는 클래스이다
    • @ResponseBody
      • return "Hello : " + name; 을 JSON 으로 변환해서 브라우저로 출력해준다
  • 브라우저에서 실행 하면


@RestController

JSON 장점

  • xml 로 변환해서 출력하면 무겁고, JSON 으로 변환하면 가볍다
  • jsp 파일을 따로 만들지 않아도 된다.

크롬에 jsonviewer 설치

  • 메모리에 띄워지는 클래스

  • BoardController.java

    package com.rubypaper.controller;
    .
    .
    .
    @RestController
    public class BoardController {
        public BoardController() {
            System.out.println("===> BoardController 생성");
        }
    
        @RequestMapping("/hello.do")
        public String hello(String name) {
            return "Hello: " + name;
        }
    
        @RequestMapping("/getBoard.do")
        public BoardVO getBoard() {
            BoardVO board = new BoardVO();
            board.setSeq(1);
            board.setTitle("테스트 제목");
            board.setWriter("테스터");
            board.setContent("테스트 내용");
            board.setCreateDate(new Date());
            board.setCnt(0);
            return board;
        }
    
    }
  • @RestController 를 사용하면?
    바로 위에서 사용했던 @ResponseBody 를 사용하지 않아도 됨

  • 정수나 실수 등 브라우저에 바로 JSON 형태로 출력 된다
    • String , BoardVO 등


2. spring-boot-starter

1) 라이브러리 설정

개별적으로 라이브러리 설정할 때

  • https://mvnrepository.com/
  • 해당 사이트를 참고하여 application-properties 파일에 dependency 를 복사해서 간편하게 설정해주면 된다.

전체적으로 라이브러리 설정할 때

  • spring-boot-starter 사용
  • 아래에서 살펴보자




2) starter 란?

관련된 라이브러리들의 묶음

  • 스프링 부트는 여러가지 스타터들을 제공
  • spring-boot-starter-모듈명
    • spring-boot-starter 라는 접두사가 붙고 뒤에 모듈 이름이 붙음
  • spring-boot-starter-data-jpa 로 관련된 많은 라이브러리를 추가할 수 있음

    • jpa를 이용해서 db 연동하면?
      자바 코드 한 줄로 가능, sql 도 자동 제공

    • hibernate 관련 jar 파일뿐만아니라, 다른 라이브러리와 트랜잭션관련 등 여러 라이브러리가 필요한 데,
      spring-boot-starter-data-jpa 사용하면? 필요한 jpa 라이브러리를 다 자동으로 제공해줌!
           <dependency>
           			<groupId>org.springframework.boot</groupId>
           			<artifactId>spring-boot-starter-data-jpa</artifactId>
           </dependency>

JPA 활용은 다른 시리즈에서 배운 것을 정리해보겠다





3) 사용자 정의 starter 적용하기

  • 스프링 부트 프로젝트가 아닌, maven 만을 이용한 프로젝트 만들어보기
  • ex. 우리 회사에서만 사용하는 라이브러리를 스타터로 만들어서 사용할 때
  • 아래처럼, 새로운 스타터 프로젝트를 만들었다면
  • 해당 스타터 프로젝트를 사용하는 프로젝트의 pom.xml에 아래처럼 설정하면 된다.
    <dependency>
                <groupId>com.rubypaper</groupId>
                <artifactId>board-spring-boot-starter</artifactId>
                <version>0.0.1-SNAPSHOT</version>
    </dependency>




4) 특정 라이브러리 변경하기

  • starter 로 많은 라이브러리를 다운 받았을 때, 그 중 특정 jar 파일만 바꾸기
  • pom.xml
    • <parent> 코드를 ctrl 키를 누르고 타고 들어가면,
      <spring-framework.version> 을 알 수 있다

    • properties 에 <spring-framework.version> 로 내가 원하는 버전으로 변경 가능
    <parent>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-parent</artifactId>
            <version>2.7.1</version>
            <relativePath/> <!-- lookup parent from repository -->
    </parent>
    .
    .
    .
    <properties>
            <java.version>17</java.version>
            <spring-framework.version>5.3.0</spring-framework.version> // 변경
    </properties>


3. 자동 설정

1) 자동 설정 이란?

SpringBoot 의 메인 클래스에서는 @SpringBootApplication 때문에
자동으로 웹 애플리케이션이 동작한다.

원래 Spring 기반의 애플리케이션 운영

  • 스프링 기반의 애플리케이션을 운영하기 위해 두 종류의 객체가 필요하다
    1. 디스패처 서블릿에서 제공하는 것(스프링 프레임워크가 제공하는 빈)→ MultipartResolver
    2. 사용자가 만든 클래스(사용자가 정의한 빈)→ Controller
  • 아키텍처에 해당하는 뼈를 프레임워크가 제공하고, 개발자는 살만 만들면 된다.
    • 뼈와 살을 연결해주는 xml 환경 설정 파일

SpringBoot 기반의 애플리케이션 운영

  • 스프링 부트는 객체 생성을 두 번 한다 = 스프링 컨테이너 생성이 두 번 된다.

    1. 스프링 프레임워크가 만든 것
    2. 내가 만든 것

    총 2개


  • 이 때 2개가 생성 될 때, @CoponentScan@EnableAutoConfiguration 이 필요하다

    • @CoponentScan

      • 내가 만든 클래스의 객체를 메모리에 띄울 때 사용한다.
    • @EnableAutoConfiguration

      • 자동으로 무언가를 생성해준다.
      • 스프링 컨테이너가 제공하는 클래스들의 객체를 생성할 때 사용한다.

    ❗ 중요한 것은 ❗
    내가 만든 @CoponentScan 이 먼저 동작하고,
    나중에 스프링이 제공하는 @EnableAutoConfiguration 가 실행된다는 것이다.


  • 코드 및 설명

    //@SpringBootApplication
    @SpringBootConfiguration
    @EnableAutoConfiguration
    @ComponentScan
    public class Chapter02Application {
    
        public static void main(String[] args) {
            SpringApplication.run(Chapter02Application.class, args);
        }
    
    }
  1. Chapter02Application 클래스가 동작하는 순간, @ComponentScan 스캔이 동작한다
    a. 내가 만든 클래스들을 컴포넌트 스캔으로 메모리에 올린다
  1. 그 다음 @EnableAutoConfiguration 이 동작해서, 스프링이 제공하는 클래스 빈의 객체가 메모리에 뜬다
  1. 객체 환경 설정을 담당하는 클래스라는 것을 @SpringBootConfiguration 으로 Bean Factory 에게 알려준다.
    a. @Bean 메서드가 있다면 메모리에 올려준다
  • @Configuration@SpringBootConfiguration 는 거의 같은 것




2) 자동 설정 @Annotation

@EnableAutoConfiguration

  • 위 코드 참조
  • spring.factories 파일을 무조건 로딩한다
    • 해당 파일에는 엄청 많은 자동 설정 클래스(~~~Configuation)가 들어 있다.
    • 아주 많은 객체들(많은 클래스들, 그리고 각 클래스 안에 수많은 @Bean 들)이 메모리에 뜬다
      ⇒ 메모리 낭비..
    • 해당 자동 설정 클래스의 @Annotation들을 아래에서 살펴보자


@ConditionalOnWebApplication

  • 메인 클래스의 웹애플리케이션 타입이 none 인지 servlet 인지

    • servlet : 디폴트 값, servlet 웹으로 실행
    • none : 일반 자바 어플리케이션으로 실행

    즉, 웹인지 자바 프로젝트로 실행하는 지 정하는 것


  • @ConditionalOnWebApplication(type = Type.SERVLET) public class 어떤자동설정클래스{. . .}

    • @EnableAutoConfiguration 가 적용된 어떤 클래스 에서 SERVLET 으로 실행할 때만,
      위 ‘어떤자동설정클래스’가 동작한다.

    • ❗ 자동 설정 클래스라고 해서 무조건 자동으로 다 뜨는 것은 아니다 ❗

      • NONE 으로 실행되면 자동으로 뜨지 않는다


@ConditionalOnClass

  • @ConditionalOnMissingBean

  • 특정 클래스가 클래스 패스에 있을 때만, 자동 설정 클래스를 동작시킨다

  • @ConditionalOnClass(DispatcherServlet.class) public class 어떤자동설정클래스{. . .}

    • DispatcherServlet 클래스가 있을 때만,
      DispatcherServlet 클래스가 클래스 패스 상에 존재하면?
      위 ‘어떤자동설정클래스’가 동작한다.


@ConditionalOnMissingBean

  • 어떤 클래스의 객체가 메모리에 없다면, 자동 설정 클래스를 동작시킨다

  • @ConditionalOnMissingBean(WebMvcConfigurationSupport.class) public class 어떤자동설정클래스{. . .}

    • WebMvcConfigurationSupport.class 클래스의 객체가 메모리에 없다면,
      위 ‘어떤자동설정클래스’를 동작한다.

    • 만약 WebMvcConfigurationSupport.class 클래스와 ‘어떤자동설정클래스’ 가 비슷한 객체들을 가지고 있다면?
      • WebMvcConfigurationSupport.class 클래스가 메모리에 안떴을 때,
        ‘어떤자동설정클래스’ 가 메모리에 뜨도록 해서
        ⇒ ❗ 메모리에 뜬 웹 관련 객체들이 중복되지 않도록 한다 ❗


@AutoConfigureOrder

  • 자동 설정 클래스의 우선 순위 지정


@AutoConfigureAfter

  • 현재의 설정 클래스가 지정된 다른 설정 클래스의 다음에 적용되도록 지정


🍑 Point 🍑

@EnableAutoConfiguration 에 의해서 엄청나게 많은 자동 설정 클래스들이 로딩 되는 데,
로딩이 됐다고 해서 전부 적용되는 것은 아니다!
위에서 설명한 것처럼 특정 조건을 만족해야만 자동설정클래스가 동작한다!





3) 자동 설정 로그 확인

  • log4j 와 비슷한 logback 이란 프레임워크 사용
  • logback 의 기본값은 INFO
  • DEBUG 로 설정하여, 디테일한 로그를 볼 수 있다




4) 📌실습📌

자동 설정 클래스 만들기

  • board-spring-boot-starter 프로젝트의 환경 설정 파일(pom.xml)에
    spring-boot-autoconfigure 을 추가한다
      <dependency>
      			<groupId>org.springframework.boot</groupId>
      			<artifactId>spring-boot-autoconfigure</artifactId>
      			<version>2.7.1</version>
      </dependency>

  • <dependency> 를 추가하면? 아래처럼 jar 파일이 생성된 것을 확인할 수 있다.
    • 해당 jar 파일안에 @EnableAutoConfiguration 이 들어있다.

  • scr/main/resources 폴더를 새로 만든 후, spring-factories 파일을 만들어 아래 코드를 넣어준다
      org.springframework.boot.autoconfigure.EnableAutoConfiguration=\
      com.rubypaper.jdbc.config.BoardAutoConfiguration

  • 원래 @EnableAutoConfiguration 은 해당 jar안에 META-INFspring.factories 파일을 무조건 로딩하도록 되어있다.(아래 두 번째 사진)
  • 그런데, jar 파일 안에 autoConfigure 패키지 안에 EnableAutoConfiguration.class (@EnableAutoConfiguration) 가
    src/main/resourcesMETA-INFspring.factories 파일도 로딩한다.(아래 첫 번째 사진)

👉 즉, 내가 많은 자동 설정 클래스도 이제 로딩한다.(첫 번째 사진의 spring.factories)




내가 만든 자동 설정 클래스 사용해보기

  • chapter02 프로젝트의 pom.xml 파일에 <dependency>
    위에서 내가 직접 만든 자동 설정 클래스를 등록해준다
      <dependency>
      			<groupId>com.rubypaper</groupId>
      			<artifactId>board-spring-boot-starter</artifactId>
      			<version>0.0.1-SNAPSHOT</version>
      </dependency>

  • chpater02 의 메인 애플리케이션을 실행해보면,
    아래처럼 내가 만든 자동설정클래스가 잘 동작하는 것을 확인할 수 있다.
    • board-spring-boot-starter 의 jar 파일에 동록된
      자동 설정 클래스(BoardAutoConfiguration.class)가 로딩이 되고,
      해당 클래스에 등록된 @Bean 이 메모리에 뜨게된다


    • 따라서 해당 jar 파일(board-spring-boot-starter) 을 다운 받으면?
      → 환경 설정 클래스(BoardAutoConfiguration.class)가 다운이 되고
      @EnableAutoConfiguration 이 해당 환경 설정 클래스를 로딩하면
      → com.rubypaper.jdbc.util 의 JDBCConnectionManger.class 객체가 자동으로 메모리에 뜬다


👉 이처럼, 해당 객체를 직접적으로 생성해주는 코드가 없더라도 알아서 생성해주는 것 = 자동 설정 클래스의 기능이다.




ApplicationRunner

package com.rubypaper.service;

.
.
.

@Service
public class JDBCConnectionManagerService implements ApplicationRunner{
	@Autowired
	private JDBCConnectionManager manager;
	
	@Override
	public void run(ApplicationArguments args) throws Exception {
		System.out.println("생성된 커넥션 매니저 : " + manager.toString());
		
	}

}
  • ApplicationRunner 을 implements 하고, run() 메서드를 오버라이딩하면?
    • JDBCConnectionManagerService 객체 생성 직후에 run() 메서드가 무조건 실행
    • 상식으로 기억해두자, 테스트 할 때 많이 필요하다
  • @Autowired private JDBCConnectionManager manager;
    • 이렇게 개발자가 일일히 객체를 생성하지 않아도,
      Spring이 해당 객체를 메모리에 띄워주기 때문에 우리는 편리하게 사용할 수 있다.


4. 중복 객체

1) 중복 객체 덮어쓰기

  • 위 '2) 자동 설정 @Annotation' 에서 설명했던 것 처럼,
    메모리에 동일한 객체가 떴을 때는 중복되지 않도록 해야한다.

  • 중복 객체가 발생했을 때, 덮어쓰는 실습을 진행해보자

  • chapter02 에 환경 설정 클래스 파일(NewBoardConfiguration.class)을 만들었다

    package com.rubypaper.config;
    .
    .
    .
    @Configuration
    public class NewBoardConfiguration {
    
       @Bean
       public JDBCConnectionManager jdbcConnectionManager() {
           JDBCConnectionManager manager = new JDBCConnectionManager();
           manager.setDriverClass("org.h2.Driver");
           manager.setUrl("jdbc:h2:tcp://localhost/~/test");
           manager.setUsername("sa");
           manager.setPassword("");
           return manager;
       }
    
    }
    • @Configuration@Componet 를 포함하고 있으므로,
      NewBoardConfiguration 은 컴포넌트 스캔이 되고,
      @Bean 설정으로 JDBCConnectionManager 객체가 메모리에 뜬다

  • 앞전 실습에서 board-spring-boot-starter 를 등록하여
    com.rubypaper.jdbc.util 의 JDBCConnectionManager.class 객체가 메모리에 이미 떠있는 상태였다.

  • 즉, board-spring-boot-starter 의 자동 설정 클래스( @EnableAutoConfiguration )에 의해서
    메모리에 떠있던 JDBCConnectionManager 객체(Oracle 연결)가
    바로 위의 새로운 JDBCConnectionManager 객체(h2 연결)로 인해 덮어씌워진다.
    👉 동일한 객체는 오버라이딩된다


  • 그런데 아무 설정 없이 그냥 실행해버리면, 오버라이딩이 되지 않고 에러가 출력되기 때문에
    appication.properties 파일에 아래처럼 설정해준다.
    # 중복 객체 덮어쓰기 설정
    spring.main.allow-bean-definition-overriding=true

  • 이제 실행하면 에러는 사라지지만, 덮어써진다고 생각한 h2 가 아닌 oracle 이 출력된다... 왜일까?
  • 실행되는 메인 코드

    package com.rubypaper;
    
    import org.springframework.boot.SpringApplication;
    import org.springframework.boot.autoconfigure.EnableAutoConfiguration;
    import org.springframework.context.annotation.ComponentScan;
    import org.springframework.context.annotation.Configuration;
    
    //@SpringBootApplication
    @Configuration
    @EnableAutoConfiguration
    @ComponentScan
    public class Chapter02Application {
    
        public static void main(String[] args) {
            SpringApplication.run(Chapter02Application.class, args);
        }
    
    }
    • 위 <3. 자동 설정> 에서 공부했던 개념 참고
  • 스프링 부트는 두 단계로 객체를 초기화한다.
    1. @ComponentScan 은 사용자가 정의한 객체(컨트롤러)를 초기화
    2. @EnableAutoConfiguration 은 프레임워크가 제공하는 객체(MultipartResolver)를 초기화
    👉 @ComponentScan@EnableAutoConfiguration 보다 먼저 동작한다

  • 이러한 이유 때문에
    내가 만든 클래스(위 1. 사용자가 정의한 객체)인 JDBCConnectionManager 객체(h2)가 @ComponentScan 으로 인해 메모리에 먼저 뜬 것이다.

    그 후 @EnableAutoConfiguration 로 앞 전에 등록한 자동 설정 클래스의 JDBCConnectionManager 객체(oracle)가 생성된다.

    👉 따라서 oracle 이 h2 를 덮어씌워버려서 oracle 이 출력되었다.


  • 이제 oracle 이 아닌, h2 가 출력되도록 해보자
    • oracle 이 출력되는 JDBCConnectionManager 객체를 h2로 덮어씌우자
  • @ConditionalOnMissingBean 을 사용하여, JDBCConnectionManager 객체(h2)가 메모리에 없을 때만 oracle 이 출력되도록 설정한다.

    package com.rubypaper.jdbc.config;
    .
    .
    .
    // 자동 설정 클래스 만들기
    @Configuration
    public class BoardAutoConfiguration {
    
        public BoardAutoConfiguration() {
            System.out.println("===> BoardAutoConfiguration 생성");
        }
    
        @Bean
        @ConditionalOnMissingBean // JDBCConnectionManager 타입의 객체가 메모리에 없다면
        public JDBCConnectionManager jdbcConnectionManager() {
            JDBCConnectionManager manager = new JDBCConnectionManager();
            manager.setDriverClass("oracle.jdbc.driver.OracleDriver");
            manager.setUrl("jdbc:oracle:thin:@localhost:1521:xe");
            manager.setUsername("scott");
            manager.setPassword("tiger");
            return manager;
        }
    
    }
    • 만약, JDBCConnectionManager 객체가 메모리에 있다면?
      해당 코드는 실행되지 않기 때문에 덮어씌워지지 않는다.




2) application.properties 로 중복 객체 변경

  • 위 '1) 중복 객체 덮어쓰기' 에서 만들었던 chapter02 의 NewBoardConfiguration.class 를 지우고,
    앞 전에 등록한 자동 설정 클래스의 JDBCConnectionManager 객체(oracle)만 뜨도록 세팅해두자
  • board-spring-boot-starter 에 JDBCConnectionManagerProperties.java 파일을 생성하여, @ConfigurationProperties 을 설정해준다

    • 접두사에 있는 properties 정보(해당 객체를 사용하는 쪽, 가장 아래글 참조)를 읽어서 맴버변수에 세팅해준다

      package com.rubypaper.jdbc.config;
      
      import org.springframework.boot.context.properties.ConfigurationProperties;
      
      import lombok.Data;
      
      @Data
      @ConfigurationProperties(prefix = "board.jdbc")
      public class JDBCConnectionManagerProperties {
          private String driverClassName;
          private String url;
          private String username;
          private String passwrod;
      
      }

  • 위 파일을 생성한 후, 노란색 경고창의 링크를 눌러서 pom.xml 에 자동으로 아래 코드를 추가해준다
    <dependency>
    			<groupId>org.springframework.boot</groupId>
    			<artifactId>spring-boot-configuration-processor</artifactId>
    			<version>2.6.6</version>
    			<optional>true</optional>
    </dependency>

  • 앞전 만들었던 BoardAutoConfiguration.java 클래스(원래 oracle 이 출력되던 파일)를 열어서 @EnableConfigurationProperties(JDBCConnectionManagerProperties.class) 를 설정하기위해 아래 코드처럼 바꿔준다

    package com.rubypaper.jdbc.config;
    .
    .
    .
    
    // 자동 설정 클래스 만들기
    @Configuration
    @EnableConfigurationProperties(JDBCConnectionManagerProperties.class)
    public class BoardAutoConfiguration {
    
        @Autowired
        private JDBCConnectionManagerProperties properties; // 의존성 주입
    
        public BoardAutoConfiguration() {
            System.out.println("===> BoardAutoConfiguration 생성");
        }
    
        @Bean
        @ConditionalOnMissingBean // JDBCConnectionManager 타입의 객체가 메모리에 없다면
        public JDBCConnectionManager jdbcConnectionManager() {
            JDBCConnectionManager manager = new JDBCConnectionManager();
            manager.setDriverClass(properties.getDriverClassName());
            manager.setUrl(properties.getUrl());
            manager.setUsername(properties.getUsername());
            manager.setPassword(properties.getPasswrod());
            return manager;
        }
    
    }

  • 이제 board-spring-boot-starter 를 사용하는 chapter02 프로젝트에서 application.properties 에 아래 처럼 설정해주면?

    # 데이터소스 : Oracle
    #board.jdbc.driverClassName=oracle.jdbc.driver.OracleDriver
    #board.jdbc.url=jdbc:oracle:thin:@localhost:1521:xe
    #board.jdbc.username=hr
    #board.jdbc.password=hr
    
    # 데이터소스 : H2
    board.jdbc.driverClassName=org.h2.Driver
    board.jdbc.url=jdbc:h2:tcp://localhost/~/test
    board.jdbc.username=sa
    board.jdbc.password=
    1. 해당 properties 정보들을, board-spring-boot-starter 의 JDBCConnectionManagerProperties 객체가 읽어들인 후
      set 메서드로 값을 전부 세팅해준다.

    2. 자동 설정 클래스인 BoardAutoConfiguration.java 클래스가 JDBCConnectionManagerProperties 객체를 @Autowired 로 의존성 주입을 한 후
      get 메서드로 1번에서 세팅된 값을 이용한다.


    3. 이때 세팅된 값은 주석으로 막혀있지 않은 H2 이므로, H2 가 출력될 것이다.
      oracle 로 출력하고 싶다면? 주석을 반대로 h2에 적용시킨 후 출력해보자


🧐 @Configuration VS @Configurable 🧐

  • @Configuration@Configurable 은 어떤 차이가 있나?
  • @Configuration@Componet-scan 안에 포함이 된다
    • @Componet-scan 안에 @Conponet, @Repository, @Service, @Controller, @RestController, @Configuration 이 포함된다.
  • @Configuration 는 객체 호출 시 같은 메모리 주소에서 값을 가져오고
    @Configurable 는 객체 호출할 때마다 다른 메모리 주소에서 값을 가져온다.

👉 따라서 @Configuration@Configurable 는 다르다!

profile
imaginative and free developer. 백엔드 / UX / DATA / 기획에 관심있지만 고양이는 없는 예비 개발자👋

0개의 댓글