스프링 입문 썸네일 이미지
BE

스프링 입문

2025. 02. 24


봄이다. 봄이 온 기념으로 나는 백엔드 공부를 시작해본다.


김영한의 스프링 입문편으로 시작을 할 생각이다. 따라가보자.

프로젝트 설정

버전은 11버전으로 진행하라고 한다. 오라클에 들어가 11.0.25버전을 설치해줬고, 기본 설정 버전을 변경해주었다.

인줄 알았으나, 그대로 했더니 빌드 오류가 떠서 17버전으로 바꿔주었다.

$ java -version # 현 버전 확인
$ /usr/libexec/java_home -V # 설치된 모든 jdk 버전 확인
$ vim ~/.zshrc # 환경변수를 추기하기 위해 vim 에디터 접속
export JAVA_HOME=$(/usr/libexec/java_home -v 1x.xx.xx) # vim 에디터에 환경변수 등록
source ~/.zshrc # 환경변수 등록
$ java -version # 버전 바뀌었는 지 학인

https://start.spring.io/에서 파일을 설정하고 다운받으면 cra처럼 개발을 위해 만들어진 폴더를 받을 수 있다.

gradle

java기반으로 프로젝트를 쉽게 빌드하고 관리할 수 있도록 도와주는 자동화도구이다.

종속성 관리, 빌드 및 배포 자동화, 유연한 설정 및 확장성 제공 등을 수행한다.

Welcome Page

static/index.html을 작성하면 왤컴페이지를 만들 수 있다.

Controller

MVC의 Controller이고 라우팅과 로직 연결을 담당한다.

@Controller// 기본 어노테이션 설정
public class HelloController {
    @GetMapping("hello") // url의 path 매칭(라우팅)
    public String hello(Model model) {
        model.addAttribute("data", "hello!!"); // k-v
        return "hello"; // resources/templates/@ 호출 (view 호출)
    }
}

HTML 템플릿

여기에도 html을 띄울 수 있다. php느낌의 thymeleaf를 사용한다. 자바기반의 php로 이해했다.

컨트롤러에서 데이터처리를 통해 뷰를 호출하면 여기서 값을 받는다.

<!--resources/templates/hello.html-->
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
  <!--th: thymeleaf 문법-->
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1" />
    <title>Document</title>
  </head>
  <body>
    <p th:text="'안녕하세요.' + ${data}">안녕하세요, 손님</p>
  </body>
</html>

html의 문법을 사용하고, th라는 thymeleaf문법을 사용한다.


기본적으로 java파일이 아닌 파일들은 resources에 위치하고, 라우팅에 의해 호출되는 파일들을 templates에 위치한다.

자동화

// build.gradle
dependencies {
  compileOnly 'org.springframework.boot:spring-boot-devtools'
}

를 추가해주고, 설정-컴파일러-automake, 고급설정-automake 옵션을 켜주면 자동화가 된다고 하나, FE처럼 바로바로 반영되지는 않았다.

빌드하고 실행하기

터미널로 가서 해당 폴더로 이동한 후

./gradlew build
cd build/libs
java -jar @.jar

를 실행하면 된다.

웹 개발 기초

정적컨텐츠

만들어진 html을 그대로 반환한다.

톰캣서버가 받으면 관련 컨트롤러가 있는지 확인하고, 없으니 resources/static/*.html을 확인하여 반환한다.

MVC와 템플릿엔진

php처럼 동작. 컨트롤러가 확인하여 view에 model을 넘긴다. 이를 템플릿엔진이 처리하여 반환한다.

@GetMapping("hello-mvc")
    public String helloMvc(@RequestParam("name") String name, Model model){
        model.addAttribute("name", name);
        return "hello-template";
    }

Controller는 viewReslover를 호출한다.

API

API를 넘겨준다.

@ResponseBody어노테이션을 사용하면 viewResolver대신 HttpMessageConverter가 동작한다. 즉 view를 반환하는게 아니라 데이터를 반환한다. HTTP의 body에 내용이 반환된다.

// 문자열을 반환
@GetMapping("hello-string")
@ResponseBody
public String helloString(@RequestParam("name") String name){
    return "hello " + name;
}
 
//json을 반환
@GetMapping("hello-api")
@ResponseBody
public Hello helloApi(@RequestParam("name") String name){
    Hello hello = new Hello();
    hello.setName(setName);
    return hello;
}

객체를 반환하면 spring은 디폴트로 json 형식을 반환한다.

일반적인 Web App

컨트롤러: MVC의 컨트롤러 역할

서비스: 핵심 비즈니스 로직 구현

리포지토리: 데이터베이스에 접근, 도메인 객체를 DB에 저장하고 관리

도메인: 비즈니스 도메인 객체. 주로 DB와 연동된다. (DTO는 주로 FE와 통신을 위해 쓰인다)

테스트

Test폴더에서 테스트를 진행할 수 있다.

    @Test
    public void save(){
        Member member = new Member();
        member.setName("spring");
        repo.save(member);
        Member result = repo.findById(member.getId()).get();
        Assertions.assertThat(result.getName()).isEqualTo(member.getName());
    }

Test 어노테이션을 활용한다.

System.out.println을 사용할 수도 있고, Assertions.assertThat을 사용할 수도 있는데 뭐가 더 나을지는 모르겠다.

AfterEach

MemoryMemberRepository memberRepository = new MemoryMemberRepository();
@AfterEach
    public void afterEach() {
        repo.clearStore();
    }

여러 개의 테스트케이스를 돌릴때 독립을 보장하기 위해서는 각 테스트케이스가 끝날때마다 호출되는 콜백메서드를 선언해줘야 한다.

AfterEach어노테이션을 활용해 설정해줄 수 있다.

DI

위 방식을 사용하면 MemoryMemberRepository객체를 새롭게 생성한다. 정적으로 선언된 레포지토리라면 문제가 되지 않을 수 있지만 그렇지 않다면 서로다른 인스턴스를 만들어 원치 않는 동작을 야기할 수 있다.


이를 위해 DI를 사용한다. BEAN을 등록시키고, 의존성을 주입한다.

MemberService memberService; MemoryMemberRepository memberRepository;
 
@BeforeEach // 메서드 시작되기 전
public void beforeEach() {
memberRepository = new MemoryMemberRepository();
memberService = new MemberService(memberRepository);
}
 
@AfterEach // 메서드 시작된 후
public void afterEach() {
memberRepository.clearStore();
}
 

메서드가 시작되기 전 beforeEach를 통해 객체를 주입해준다.

스프링이 관리하는 객체이다. 프론트에서 import, export하는 것과 비슷한가 했는데 좀 다르다. 그거는 파일을 export하고 다른 파일에서 import 하는 것.

스프링에서는 Ioc, 빈을 등록해놓으면 스프링이 등록된 빈(객체)를 관리하며 주입이 필요한 곳, AOP가 필요한 빈 등에 권한 등을 부여할 수 있다.


빈을 등록하는 방법은 두 가지다.

1. @Service, @Repository, @Autowired 이용하기

// service/MemberService
package spring.study1.service;
 
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import spring.study1.domain.Member;
import spring.study1.repository.MemberRepository;
 
import java.util.List;
import java.util.Optional;
 
// spring이 스프링 컨테이너에 MemberService를 등록해줌
@Service
public class MemberService {
    // 저장소
    private final MemberRepository memberRepository;
 
    // spring이 MemberRepository를 스프링 컨테이너에 있는 MemberRepository를 가져다 연결시켜줌
    @Autowired
    public MemberService(MemberRepository memberRepository) {
        this.memberRepository = memberRepository;
    }
 
    ...
 
}

빈을 등록하기 위해선 @Component이 필요한데, 이는 @Service에 내장되어있다. 컴포넌트 어노테이션이 달려있는 빈은 @ComponentScan에 의해 읽힌다.

등록을 했으면 @Autowired를 통해 주입해줄 수 있다.

2. 직접 빈 등록하기

@Configuration
public class SpringConfig {
 
    @Bean
    public MemberService memberService() {
        return new MemberService(memberRepository());
    }
 
    @Bean
    public MemberRepository memberRepository() {
        return new MemoryMemberRepository();
    }
 
}

@Service, @Repository를 사용하지 않고, 패키지 바로 아래에 SpringConfig 클래스를 만들고 @Configuration, @Bean을 붙여 등록해준다.


정형회된 방식, @Controller등에서는 1번의 방법을 사용하지만, 변화무쌍하거나 DB가 정해지지 않은 경우 등에는 2번 방법을 사용해 유동적으로 변경해줄 수 있다.

이 방법을 사용하면 레포지토리를 변경한다고 해도 코드의 대부분을 건드는 게 아니라 이 SpringConfig 부분만 바꿔주면 된다.

회원등록, 조회하기

회원 등록을 위한 MemberController를 만들어준다. /members/new로 GET 요청이 오면 컨트롤러에 의한 페이지를 넘기고, POST 요청이 오면 새로운 멤버객체를 만든다. 이를 위해 회원등록 컨트롤러 MemberForm를 만들어줘야 한다. POST요청이 오면 이를 MemberForm형식으로 받아 멤버객체를 만들어 MemberService에 넣어준다.

또한 /member로 요청이 오면 멤버조회를 해준다. MemberService에 등록되어 있는 멤버들을 View로 넘겨 html로 띄워준다.

...
//MemberController
@Controller // 스프링 컨테이너가 뜰 때 MemberController를 생성하고 관리 해줌
public class MemberController {
    private final MemberService memberService;
 
    // MemberController를 생성할 때 호출
    @Autowired // spring이 memberService를 스프링 컨테이너에 있는 memberService를 가져다 연결시켜줌(의존관계 주입)
    public MemberController(MemberService memberService) {
        this.memberService = memberService;
    }
 
    @GetMapping("/members/new") // 회원등록
    public String createForm() {
        return "members/createMemberForm";
    }
 
    @PostMapping("/members/new") // 회원등록 요청
    public String create(MemberForm form) { // form의 name-value 쌍을 이용했기 때문에 이렇게 사용(`@ModelAttribute` 디폴트). 만약 post의 body에 담겨있었다면 `@RequestBody`를 사용해야 한다.
        Member member = new Member(); // member 객체 생성
        member.setName(form.getName()); // 입력받은 정보가 담긴 form에서 정보를 꺼내 멤버객체에 저장
 
        memberService.join(member); // member 객체로 join(회원가입)
 
        return "redirect:/"; // 홈화면 이동
    }
 
    @GetMapping("/members") // 회원조회
    public String list(Model model) { // view에 전달하기 위해 model을 사용
        List<Member> members = memberService.findMembers(); // 회원 List 가져옴
 
        model.addAttribute("members",members); // 회원 List를 model에 넣음
        return "members/memberList"; // view에 전달
    }
}
// MemberForm
public class MemberForm {
    // createMemberForm의 input의 name 속성 값인 name과 같아야 함
    private String name;
 
    // getter
    public String getName() {
        return name;
    }
 
    // setter
    public void setName(String name) {
        this.name = name;
    }
}

DB 연결

지금까지 수행한 방법은 코드를 실행하고 메모리상에서 저장하고 있었다. 하지만 이 방법은 프로그램을 종료하면 날라가기때문에 DB에 연동해서 저장해야 한다. 이를 위해 JDBC를 사용한다.

여기서 OCP, 개방-페쇄 원칙을 활용하여 클래스를 바꿔끼워준다.

h2

데이터베이스로는 h2를 사용하였다. 온라인에서 간단하게 다운로드받을 수 있고 메모리 상에서 동작하는 디비라 테스트 디비로 적합하다.

$ cd h2 # 다운로드한 h2로 이동
$ cd bin
$ chmod 755 h2.sh
$ ./h2.sh

이후 디비명, 사용자명, 비밀번호를 등록하면 웹에서 디비가 열린다.

//SpringConfig
    @Bean
    public MemberRepository memberRepository() {
        // return new MemoryMemberRepository();
        // return new JdbcMemberRepository(dataSource);
        // return new JdbcTemplateMemberRepository(dataSource);
        return new JpaMemberRepository(em);
    }

레포지토리를 인터페이스로 만들었기 때문에, DB저장소를 변경할때 코드를 전부 바꿀 게 아니라 SpringConfig에 있는 멤버레포지토리의 반환값만 변경해준다(즉 변경이 필요한 부품만 갈아준다).

jdbc란

java에서 데이터베이스에 접근하고 조작하기 위한 표준 API

가장 쉬운 방법으로 jdbc template을 사용한다. 순수 jdbc보다 보일러 플레이트가 적지만 sql은 직접 작성해야 한다.

@Configuration
public class SpringConfig {
 
    // db와의 연결을 담당하는 jdbc의 db연결 객체
    private final DataSource dataSource;
 
    public SpringConfig(DataSource dataSource) {
        this.dataSource = dataSource;
    }
    ...
 
    @Bean
    public MemberRepository memberRepository() {
        return new JdbcTemplateMemberRepository(dataSource);
    }
}
# build/gradle
dependencies {
	...
	implementation 'org.springframework.boot:spring-boot-starter-jdbc'
	runtimeOnly 'com.h2database:h2'
	...
}
# resources/application.properties
spring.datasource.url=jdbc:h2:tcp://localhost/~/db이름
spring.datasource.driver-class-name=org.h2.Driver
spring.datasource.username=사용자이름
spring.datasource.password=비밀번호
//JdbcTemplateMemberRepository
public class JdbcTemplateMemberRepository implements MemberRepository {
    private final JdbcTemplate jdbcTemplate;
 
    @Autowired
    public JdbcTemplateMemberRepository(DataSource dataSource) {
        jdbcTemplate = new JdbcTemplate(dataSource);
    }
 
    @Override
    public Member save(Member member) {
        SimpleJdbcInsert jdbcInsert = new SimpleJdbcInsert(jdbcTemplate);
        jdbcInsert.withTableName("member").usingGeneratedKeyColumns("id");
 
        Map<String, Object> parameters = new HashMap<>();
        parameters.put("name", member.getName());
 
        Number key = jdbcInsert.executeAndReturnKey(new MapSqlParameterSource(parameters));
        member.setId(key.longValue());
        return member;
    }
 
    @Override
    public Optional<Member> findById(Long id) {
        List<Member> result =
                jdbcTemplate.query("select * from member where id = ?", memberRowMapper(), id);
        return result.stream().findAny();
    }
 
    @Override
    public Optional<Member> findByName(String name) {
        List<Member> result = jdbcTemplate.query("select * from member where name = ?", memberRowMapper(), name);
        return result.stream().findAny();
    }
 
    @Override
    public List<Member> findAll() {
        return jdbcTemplate.query("select * from member", memberRowMapper());
    }
 
    private RowMapper<Member> memberRowMapper() {
        return (rs, rowNum) -> {
            Member member = new Member();
            member.setId(rs.getLong("id"));
            member.setName(rs.getString("name"));
            return member;
        };
    }
}

JPA

보일러플레이트는 물론 sql도 JPA가 작성해준다. sql, 데이터중심 설계에서 객체중심 설계로 변화한다.

# build/gradle
dependencies {
	...
	implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
	runtimeOnly 'com.h2database:h2'
	...
}
# resources/application.properties
spring.jpa.show-sql=true
spring.jpa.hibernate.ddl-auto=none
// domain/member
@Entity // JPA가 관리하는 엔티티, 이 어노테이션을 통해 JPA가 클래스를 DB와 매핑해준다.
public class Member {
 
    @Id // pKey로 인식
    @GeneratedValue(strategy = GenerationType.IDENTITY) //자동 생성
 
	...
}
// service/memberService
@Transactional // jpa의 변경사항이 커밋되고 실패시 롤백된다
public class MemberService {
	...
}
// repository/JpaMemberRepository
public class JpaMemberRepository implements MemberRepository {
 
    // EntityManager
    private final EntityManager em;
 
    public JpaMemberRepository(EntityManager em) { // DI
        this.em = em;
    }
 
    @Override
    public Member save(Member member) { // JPA를 사용한 save
        em.persist(member); // save기능을 제공한다. 커밋이 될때 sql UPDATE문이 적용됨
        return member;
    }
 
    @Override
    public Optional<Member> findById(Long id) {
        Member member = em.find(Member.class, id); // pKey에 대해 사용할 수 있음. 리플렉션을 활용해 Member 클래스를 가져와 id값을 찾음
        return Optional.ofNullable(member);
    }
 
    @Override
    public Optional<Member> findByName(String name) {
        List<Member> result = em.createQuery("select m from Member m where m.name = :name",Member.class) // pKey가 아닌 값에 대해 쿼리를 만듦
                .setParameter("name",name)
                .getResultList();
        return result.stream().findAny();
    }
 
    @Override
    public List<Member> findAll() {
        return em.createQuery("select m from Member m", Member.class)
                .getResultList();
    }
}

스프링데이터 JPA

jdbc template의 역할처럼 자주쓰이는 CRUD, findById()등의 기본기능을 제공한다.

AOP

공통관심사항(cross-cutting concern)을 묶는다. 시간측정, 로그조회 등 모든 클래스에서 필요한 관심사항을 묶는다.

AOP를 만들게 되면 프록시가 생겨 이를 거치게 된다.

@Aspect
@Component
public class TraceAop {
 
    @Around("execution(* hello.hello_spring..*(..))") // AOP를 사용할 위치 설정
    public Object execute(ProceedingJoinPoint joinPoint) throws Throwable {
        long start = System.currentTimeMillis(); // 시작 시간 측정
        System.out.println("START: " + joinPoint.toString());
        try {
            return joinPoint.proceed(); // 다음 메서드로 진행
        } finally {
            long finish = System.currentTimeMillis(); // 종료 시간 측정
            long timeMs = finish - start; // 소요 시간 측정
 
            System.out.println("END: " + joinPoint.toString() + " " + timeMs + "ms");
        }
    }
}

커스텀 훅과 비슷한가 했더니 다르다. 커스텀 훅은 공통된 로직을 빼놓고 필요한 곳에서 호출해 사용하는 것인데 AOP는 필요한 곳에서 호출하지 않고 자동으로 붙는다.

즉 핵심로직과 분리된 공통적인 관심사항들을 자동으로 결합시킨다.