강의/Java Spring Boot

Java Spring Framework 시작하기

studylida 2024. 7. 15. 21:00

애플리케이션 아키텍처는 지난 20년 동안 지속적으로 발전해왔음
- 기본 웹 애플리케이션 -> 웹 서비스 -> REST API -> Full Stack -> MSA

이렇게 발전한 애플리케이션을 구축하는데 여러 프레임워크를 사용함.

이러한 프레임워크를 사용하면 사용하지 않았을 때에 비해 훨씬 적은 코드로 많은 걸 할 수 있음.

우리는 그 중 Spring과 Spring Boot에 대해 배울 것.

이번 장의 커리큘럼은 아래와 같음.

가장 먼저 Spring Project를 만들어 볼 것.

가장 좋은 방법은 start.spring.io를 이용하는 것.

이번 강의에서는 Maven Project를 이용하고 있고, Java를 사용하며(필자는 Java 17 사용), Spring Boot 3을 사용함. SNAPSHOT은 개발중인 버전으로 학습에는 어울리지 않으니 SNAPSHOT 버전을 사용하는 걸 권하지 않는다.

더불어 강의에서는 IDE로 이클립스를 사용하지만, 필자는 인텔리제이를 사용한다.

Iteration 1

이번 단계에서는 GameRunner, MarioGame 클래스를 만들어볼 것이다.

만들 건데, 만들기 전에 이 클래스들이 실행될 기반 클래스를 하나 만들어보자.

package com.in28minutes.learnspringframework;

public class AppGamingBasicJava {

    public static void main(String[] args) {

        var marioGame = new MarioGame();
        var gameRunner = new GameRunner(marioGame);
        gameRunner.run();
    }

}

이렇게 만들면 당연하지만 컴파일 오류가 발생한다. 문제를 해결하기 위해 아까 만들지 않고 미뤄두었던 GameRunner, MarioGame 클래스를 만들어보자.

게임에 관련된 것들을 구분해놓기 위해 com.in28minutes.learnspringframework.game 패키지를 만들어 game 패키지에 담아놓을 것이다. 패키지가 무엇인지 모른다면 폴더라고 생각하면 좋다.
....\com\in28minutes\learn_spring_framework\game이라고 적어놓으면 이해하기 편할까?

하여 이렇게 패키지를 만들었다. 이 안에 GameRunner, MarioGame 클래스를 만들어보자.

package com.in28minutes.learnspringframework.game;

public class GameRunner {

    MarioGame game; // 멤버변수로 MarioGame 인스턴스를 가짐

    public GameRunner(MarioGame game) { // GameRunner가 생성될 때 MarioGame 인스턴스를 받아와서 멤버변수로 가짐.
        this.game = game;
    }

    public void run() { // 게임을 실행함. 실제로 게임을 실행할 때는 여러 내부 동작이 있겠지만, 지금은 그게 아니니까 신경쓰지 말고 그냥 sysout 정도만 적어놓자.
        System.out.println("Running game: " + game);
    }
}
package com.in28minutes.learnspringframework.game

public class MarioGame {

    // various method

}

이렇게 GameRunner 클래스와 MarioGame 클래스를 만들어보았다.

GameRunner 클래스를 보면, 지금은 MarioGame 클래스 하나밖에 없으니 MarioGame만을 멤버변수로 가지고, 생성자도 MarioGame 인스턴스를 매개변수로 받는다.

하지만 게임이 여러 개 있다면?

package com.in28minutes.learnspringframework.game;

public class GameRunner {

    // MarioGame game; // 멤버변수로 MarioGame 인스턴스를 가짐
    SuperContraGame game; // 멤버변수로 SuperContraGame 인스턴스를 가짐

    */
    public GameRunner(MarioGame game) { // GameRunner가 생성될 때 MarioGame 인스턴스를 받아와서 멤버변수로 가짐.
        this.game = game;
    } 
    */

    public GameRunner(SuperContraGame game) {
        this.game = game
    }

    public void run() { // 게임을 실행함. 실제로 게임을 실행할 때는 여러 내부 동작이 있겠지만, 지금은 그게 아니니까 신경쓰지 말고 그냥 sysout 정도만 적어놓자.
        System.out.println("Running game: " + game);
    }
}

큰일났다... 이렇게 되면 다른 게임 실행하려고 할 때마다 주석 처리해야 하는데 이럴 수는 없다.... 아니 가능은 하겠지만 매우 비효율적일 것이다.

때문에 여기서는 Java 인터페이스와 Spring 프레임워크의 도움을 받는다.

인터페이스는 특정 클래스 셋에서 수행할 수 있는 공통된 작업을 나타낸다. 이를 설명하기 위해 MarioGame 클래스와 SuperContraGame 클래스에 메소드를 작성해보겠다.

package com.in28minutes.learnspringframework.game

public class MarioGame {

    public void up() {
        System.out.println("Jump");
    }
    public void down() {
        System.out.println("Go into a hole");
    }
    public void left() {
        System.out.println("Go back");
    }
    public void right() {
        System.out.println("Accelerate");
    }
}
package com.in28minutes.learnspringframework.game

public class SuperContraGame{

    public void up() {
        System.out.println("Up");
    }
    public void down() {
        System.out.println("Sit down");
    }
    public void left() {
        System.out.println("Go back");
    }
    public void right() {
        System.out.println("Shoot a bullet");
    }
}

이 게임 클래스들을 보면 공통적으로 up(), down(), left(), right() 메소드를 가진다. 이를 묶어서 인터페이스에 작성해보자.

package com.in28minutes.learnspringframework.game;

public interface GamingConsole {

    void up();
    void down();
    void left();
    void right();
}

이렇게 만든 인터페이스를 GameRunner 클래스에서 이용하기 위해서는 아래와 같이 코드를 작성하면 된다.

package com.in28minutes.learnspringframework.game

public class MarioGame implements GamingConsole {
    public void up() {
        System.out.println("Jump");
    }
    public void down() {
        System.out.println("Go into a hole");
    }
    public void left() {
        System.out.println("Go back");
    }
    public void right() {
        System.out.println("Accelerate");
    }
}

 

package com.in28minutes.learnspringframework.game

public class SuperContraGame implements GamingConsole {

  public void up() {
      System.out.println("Up");
  }
  public void down() {
      System.out.println("Sit down");
  }
  public void left() {
      System.out.println("Go back");
  }
  public void right() {
      System.out.println("Shoot a bullet");
  }

}
package com.in28minutes.learn\_spring\_framework.game;

public class GameRunner {

  private GamingConsole game;

  public GameRunner(GamingConsole game) {
      this.game = game;
  }

  public void run() {
      System.out.println("Running game: " + game);
      game.up();
      game.down();
      game.left();
      game.right();
  }

}
package com.in28minutes.learnspringframework;

public class AppGamingBasicJava {

  public static void main(String[] args) {

      var marioGame = new MarioGame();
      var superContraGame = new SuperContraGame();

      var marioGameRunner = new GameRunner(marioGame);
      var superContraGameRunner = new GameRunner(superContraGame);

      marioGameRunner.run();
      superContraGameRunner.run(); 
  }

}

이렇게 하면 run() 메소드를 실행했을 때 MarioGame 인스턴스를 생성자에 넘겨줬다면 MarioGame의 up(), down(), left(), up() 메소드가 실행되고, SuperContraGame 인스턴스를 생성자에 넘겨줬다면 SuperContraGame의 메소드가 실행된다.

이렇게 특정 인터페이스와 결합되어 있다면 느슨한 결합이라고 하고, 반면 아까와 같이 특정 클래스에만 결합되어 있다면 이를 강한 결합이라고 한다.

하여 지금까지는 java 자체의 기능만으로 객체를 연결했다. 만약 이렇게 수동으로 객체를 생성, 관리, 실행하는 대신 spring 프레임워크에게 이걸 맡긴다면 어떨까?

일단 지금까지 배운것과 앞으로 배울 것을 구분하기 위해 다른 기반 클래스를 만들어보자. 이름은 App02HelloWorldSpring으로 하겠다. 이름에서 짐작할 수 있겠지만 지금부터 스프링의 기초적인 부분에 대해 학습할 것이다.

package com.in28minutes.learnspringframework;

public class App02HelloWorldSpring {

    public static void main(String[] args) {

    }

}

아까 Spring에게 객체의 생성, 관리, 실행을 맡기겠다고 했는데, 이걸 위해서는 가장 먼저 Spring 애플리케이션이나 Spring 컨텍스트를 실행해야 한다. 그 다음에 Spring 프레임워크가 관리하도록 할 것을 설정한다.

Spring 프레임워크가 관리할 것을 설정하는 방법 중 하나는 Configuration 클래스를 사용하는 방법이 있다.

그리고 이 Configuration 클래스를 이용해 Spring 컨텍스트를 시작할 수 있다.

일단 Configuration 클래스를 만들어보자. 이는 @Configuration 어노테이션을 추가하여 나타낼 수 있다. 어노테이션은 후에 더 자세히 설명하겠다.


package com.in28minutes.learnspringframework;

import org.springframework.context.annotation.Configuration;  
// Indicates that a class declares one or more @Bean methods and may be processed by the Spring container to generate bean definitions and service requests for those beans at runtime.

@Configuration  
public class HelloWorldConfiguration {

}

인텔리제이에서는 @Configuration을 입력 후 Alt + Enter를 누르면 자동으로 import 옵션이 나온다.

외에도 에러가 발생했을 때 Alt + Enter를 누르면 해결방법을 목록으로 제시하니 참고하자.

import 문 밑의 주석을 보면 Bean이라는 용어가 나오는데, Spring에서 관리하는 것들을 통틀어 Spring Bean이라고 한다.

Spring Bean은 Configuration 클래스에서 method를 정의하여 생성할 수 있다.

이전에 @Configuration 클래스로 Spring 컨텍스트를 시작할 수 있다고 했다. 이는 AnnotationConfig 클래스를 이용하여 가능하다.

package com.in28minutes.learnspringframework;

public class App02HelloWorldSpring {

    public static void main(String[] args) {
        var context = new AnnotationConfigApplicationContext(HelloWorldConfiguration.class);
    }

}

컨텍스트를 실행하는 걸 완료했으니, 이제 Spring에 Bean을 관리하라는 명령을 내려보자.

package com.in28minutes.learnspringframework;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

@Configuration
public class HelloWorldConfiguration {

    @Bean
    public String name() {
        return "Ranga";
    }
}

이제 name() method는 Bean을 생성하며, Bean은 Spring 컨테이너에 의해 관리된다.

해서 이렇게 관리는 시켰는데, 만약 Spring이 관리하는 객체를 확인하고 싶다면 어떻게 해야 할까? 우리는 이를 위해 getBean()을 사용할 수 있다.

package com.in28minutes.learnspringframework;

public class App02HelloWorldSpring {

    public static void main(String[] args) {
        var context = new AnnotationConfigApplicationContext(HelloWorldConfiguration.class);
    }

    System.out.println(context.getBean("name"));

}

이제 방법을 알았으니 Bean을 여러 개 만들고, getBean()을 사용해보자.

package com.in28minutes.learnspringframework;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

record Person(String name, int age) {};
record Address(String firstLine, String city) {};

@Configuration
public class HelloWorldConfiguration {

    @Bean
    public String name() {
        return "Ranga";
    }

    @Bean
    public int age() {
        return 15;
    }

    @Bean
    public Person person() {
        return new person = new Person("Ravi", 20); 

    }

    @Bean
    public Address address() {
        return new Address("Baker Street", London");
    }

}
package com.in28minutes.learnspringframework;

public class App02HelloWorldSpring {

    public static void main(String[] args) {
        var context = new AnnotationConfigApplicationContext(HelloWorldConfiguration.class);
    }

    System.out.println(context.getBean("name"));
    System.out.println(context.getBean("age"));
    System.out.println(context.getBean("person"));
    System.out.println(context.getBean("address"));

}

분명 Bean을 여러 개 만든다고 했는데, import문 밑을 보면 record라는 것이 있다. 이것은 JDK 16에서 추가된 기능으로, 일반적으로 Java 클래스를 만들 때 작성해야 하는 생성자, 게터, 세터 메서드 등을 자동으로 생성해주는 기능이다.

기반 클래스를 보면, getBean()을 사용할 때 클래스 이름의 첫자만 소문자로 바꾸어서 매개변수로 넘기는 것을 확인할 수 있다. 이는 Bean의 기본 이름으로, 따로 설정하지 않으면 이렇게 설정된다.

이름을 변경하기 위해서는 아래와 같이 코드를 작성하면 된다.

package com.in28minutes.learnspringframework;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

record Person(String name, int age) {};
record Address(String firstLine, String city) {};

@Configuration
public class HelloWorldConfiguration {

    @Bean
    public String name() {
        return "Ranga";
    }

    @Bean
    public int age() {
        return 15;
    }

    @Bean
    public Person person() {
        return new person = new Person("Ravi", 20); 

    }

    @Bean(name = "address2")
    public Address address() {
        return new Address("Baker Street", London");
    }

}
package com.in28minutes.learnspringframework;

public class App02HelloWorldSpring {

    public static void main(String[] args) {
        var context = new AnnotationConfigApplicationContext(HelloWorldConfiguration.class);
    }

    System.out.println(context.getBean("name"));
    System.out.println(context.getBean("age"));
    System.out.println(context.getBean("person"));
    System.out.println(context.getBean("address2"));
    System.out.println(context.getBean(Address.class);
}

getBean에 클래스를 매개변수로 넘겨줄 수도 있다. 다만 같은 클래스의 Bean이 여러 개 있을 경우 아래와 같이 @Primary나 @Qualifier를 사용하여 해당 클래스의 객체 중 어떤 것을 넘겨줄지 정해야 에러가 나지 않는다.

package com.in28minutes.learn_spring_framework.helloworld;

import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Primary;

@Configuration
public class HelloWolrdConfiguration {

    @Bean
    public String name() {
        return "default Name";
    }

    @Bean
    public int age() {
        return -1;
    }

    @Bean
    @Primary
    public Person person() {
        var person = new Person("Ravi", 20, new Address("Main Street", "Utrecht"));
        return person;
    }

    @Bean
    public Person person2MethodCall() {
        return new Person(name(), age(), address2()); // name, age
    }

    @Bean
    public Person person3Parameters(String name, int age, Address address3) {
        return new Person(name, age, address3); // name, age
    }

    @Bean
    // No qualifying bean of type 'com.in28minutes.learnspringframework.Address'
    // available: expected single matching bean but found 2: address2, address3
    public Person person4Parameters(String name, int age, Address address) {
        return new Person(name, age, address); // name, age
    }

    @Bean
    public Person person5Qualifier(String name, int age, @Qualifier("address3qualifier") Address address) {
        return new Person(name, age, address); // name, age
    }

    @Bean(name = "address2")
    @Primary
    public Address address2() {
        return new Address("default Street2", "default City2");
    }

    @Bean(name = "address3")
    @Qualifier("address3qualifier")
    public Address address3() {
        return new Address("default Street3", "default City3");
    }
}
/*
Spring Questions You Might Be Thinking About

Question 1: Spring Container  vs Spring Context vs IOC Container vs Application Context
Answer 1: Spring Container, Spring Context, and IOC Container are same thing
two type of IOC exist
1: Bean Factory: Basic Spring Container
2: Application Context: Advanced Spring Container with enterprise-specific features(We're using Application Context.)
 - Easy to use in web applications
 - Easy internationalization
 - Easy integration with Spring AOP

Most enterprise applications use Application Context

Question 2: Java Bean vs Spring Bean
Answer 2: Exploring Java Bean vs POJO vs Spring Bean

Java Bean: Classes adhering to 3 constraints:
1: Have public default(no argument) constructors
2: Allow access to their properties using getter and setter methods
3: Implement java.io.Serializable

POJO: Plain Old Java Object
 - No constraints
 - Any Java Object is a POJO

Spring Bean: Any Java Object that is managed by Spring
 - Spring uses IOC Container(Bean Factory or Application Context) to manage these objects


Question 3: How can I list all beans managed by Spring Framework
Answer 3: if you want to get a list of spring beans, the way you can do that is to ask the context.
So context.getBeanDefinitionNames returns the names of all the beans defined in this registry.
getBeanDefinitionCount returns the number of beans that are defined in the registry.

Question 4: What if multiple matching beans are available?
Answer 4: Below. 
sum: primary, Qualifier,  System.out.println(context.getBean("address2",Address.class));

Question 5: Spring is managing objects and performing auto-writing
 - But aren't we writing the code to create objects?
 - How do we get Spring to create objects for us?

Answer 5:
You're right, in traditional Java development, we would write the code to create and manage the objects ourselves. However, with Spring, the framework takes care of creating and managing the objects for us, which is known as Dependency Injection (DI).

Here's how Spring achieves this:

Configuration: In Spring, you define the objects (beans) that you want the framework to manage. This can be done either through XML configuration files or using Java-based configuration classes (annotated with @Configuration).

Dependency Injection: Spring will automatically create and inject the necessary objects (beans) into other objects that depend on them. This is done by either constructor injection, setter injection, or field injection.

Autowiring: Spring can automatically wire the dependencies between the beans based on their types or names. This is done using the @Autowired annotation.

For example, let's say you have a UserService class that depends on a UserRepository class. In traditional Java development, you would have to create an instance of UserRepository and pass it to the UserService constructor or setter method. With Spring, you can simply annotate the UserService class with @Service and the UserRepository class with @Repository, and Spring will take care of creating and injecting the UserRepository instance into the UserService instance.

*/


package com.in28minutes.learn_spring_framework.helloworld;

import org.springframework.context.annotation.AnnotationConfigApplicationContext;

// Eliminate verbosity in creating Java Beans
// Public accessor methods, constructor,
// equals, hashcode adn toString are automatically created.
// Released in JDK 16.

record Person (String name, int age, Address address) {

}

// Address - firstLine & city
record Address (String firstLine, String city) {

}

public class App02HelloWorldSpring {
    public static void main(String[] args) {
        // 1: Launch a Spring Context
        try(var context = new AnnotationConfigApplicationContext(HelloWolrdConfiguration.class)) {
            // 2: Configure the things that we want Spring to manage -
            // HelloWorldConfiguration - @Configuration

            // 3: Retrieving Beans managed by Spring
            System.out.println(context.getBean("name"));
            System.out.println(context.getBean("age"));
            System.out.println(context.getBean("address2"));
            System.out.println(context.getBean("person2MethodCall"));
            System.out.println(context.getBean("person3Parameters"));
            System.out.println(context.getBean("person"));
            System.out.println(context.getBean(Person.class));
            System.out.println(context.getBean(Address.class));
            System.out.println(context.getBean("address2",Address.class));
            // Because over here, we're looking for the type of the bean by getBean. The type of the bean is the class of the bean, which is the start class.
            System.out.println(context.getBean("person5Qualifier"));

            //Arrays.stream(context.getBeanDefinitionNames()).forEach(System.out::println); // 메서드 참조
        }




            /* Answer 4:
        두 개의 `Address` 객체가 존재하면 `context.getBean(Address.class)`를 사용하면 모호성 오류(Ambiguity error)가 발생할 수 있습니다. 이는 Spring이 어떤 `Address` 객체를 반환해야 할지 모르기 때문입니다.

                이를 해결하기 위해서는 다음과 같은 방법을 사용할 수 있습니다:

        1. **Bean 이름 지정**: 각 `Address` 객체에 고유한 이름을 지정하고 해당 이름을 사용하여 Bean을 가져옵니다.

        @Bean
        public Address address1() {
            return new Address("Address 1");
        }

        @Bean
        public Address address2() {
            return new Address("Address 2");
        }

        // 가져오기
        Address address1 = context.getBean("address1", Address.class);
        Address address2 = context.getBean("address2", Address.class);


        2. **Primary Bean 지정**: 기본적으로 사용할 `Address` 객체를 `@Primary` 어노테이션으로 지정할 수 있습니다.

        @Bean
        @Primary
        public Address primaryAddress() {
            return new Address("Primary Address");
        }

        @Bean
        public Address secondaryAddress() {
            return new Address("Secondary Address");
        }

        // 가져오기
        Address primaryAddress = context.getBean(Address.class);


        3. **Qualifier 사용**: `@Qualifier` 어노테이션을 사용하여 각 `Address` 객체를 구분할 수 있습니다.

        @Bean
        @Qualifier("address1")
        public Address address1() {
            return new Address("Address 1");
        }

        @Bean
        @Qualifier("address2")
        public Address address2() {
            return new Address("Address 2");
        }

        // 가져오기
        Address address1 = context.getBean("address1", Address.class);
        Address address2 = context.getBean("address2", Address.class);


        이와 같은 방법을 사용하면 모호성 오류를 해결할 수 있습니다.
            */
    }
}

좀 나눠서 해보려고 했는데, 보다 보니까 자바 조금 배웠으면 Spring이랑 관련없이 알잘딱하게 코드 보고 배울 수 있는 내용 같아서 코드에 다 적어놓았다. Question 1, 2, 3, 4까지는 위의 코드를 이리저리 뒤적거리면서 이해하면 된다. Question 5는 다음 게시글에서 자세히 다룰 거 같다.

이해를 돕기 위해 이전에 만들었던 게임 코드 또한 Bean을 이용해 작성해보았다.

package com.in28minutes.learn_spring_framework;

import com.in28minutes.learn_spring_framework.game.GameRunner;
import com.in28minutes.learn_spring_framework.game.GamingConsole;
import com.in28minutes.learn_spring_framework.game.PacmanGame;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

@Configuration
public class GamingConfiguration {


    @Bean
    public GamingConsole game() {
        var game = new PacmanGame();
        return game;
    }

    @Bean
    public GameRunner gameRunner(GamingConsole game) {
        var gameRunner = new GameRunner(game);
        return gameRunner;
    }

}
package com.in28minutes.learn_spring_framework;

import com.in28minutes.learn_spring_framework.game.GameRunner;
import com.in28minutes.learn_spring_framework.game.GamingConsole;
import com.in28minutes.learn_spring_framework.game.PacmanGame;
import org.springframework.context.annotation.AnnotationConfigApplicationContext;

public class App03GamingSpringBeans {
    public static void main(String[] args) {

        try(var context = new AnnotationConfigApplicationContext(GamingConfiguration.class)) {
            context.getBean(GamingConsole.class).up();

            context.getBean(GameRunner.class).run();

        }
    }
}

finish.