블로그

볼드 UX

[인프런 워밍업 스터디 1기 디자인] 1주차 슬기롭게 보내기

발자국 1주차드디어 첫 주가 시작되었어요. 새로운 회사 일도 시작한 지 2주 차라 정신이 없었어요.조용하던 디자인 디스코드 채널이 드디어 활발해지기 시작했어요. 이번 주에는 코치로서 다음과 같은 일을 해야 했어요.폭발적인 강의 질문 대응하기미션 꼼꼼히 체크하기특별 강의 준비하기  첫째, 강의 질문 방에서는 수강생들이 강의를 듣기 시작하면서 질문이 쏟아졌어요. 조용하던 채널이 질문으로 가득 차는 것은 매우 바람직한 현상이에요. 둘째, 미션 제출이 시작되었어요. 평소에는 회사 일에 집중하고, 저녁 늦게나 새벽에 수강생들이 제출한 과제를 살펴봤어요. 처음에는 단순히 미션 여부만 확인하려고 했지만, 과제를 살펴보다가 몇 가지 흥미로운 점을 발견했어요.흥미로운 점 세 가지는 다음과 같아요:1. 같은 강의를 듣고도 실수를 반복하는 수강생이 있다는 것이에요. 이는 온라인 강의의 한계일 수 있어요.2. 이러한 실수가 일부 수강생에게만 나타나는 것이 아니라 반복적으로 나타난다는 점이고, 이것을 데이터로 정리해서 다른 수강생들에게도 요약 노트 등으로 알리면 좋을 것 같아요.3. 인프런 워밍업을 통해 수강생들의 작업 파일을 보고 코멘트를 남기며 피드백을 통해 서로 수정하고 올바르게 배울 수 있었어요. 셋째, 특별 강의를 준비했어요. 세 가지 주제로 구성하고, 강의 준비 과정은 쉽지 않았어요. 밤에 미션을 체크하고 남는 시간에 강의를 만들었어요. 특별 강의는 주로 새벽 5시에 일어나서 준비했고, 다음과 같은 주제로 구성되었어요.- 가장 많이 나오는 질문: 아이콘- 컴포넌트 네이밍 컨벤션- 멀티에딧 베리언츠인터랙티브한 강의를 만들기 위해 네이밍 컨벤션을 공유할 때는 혼동되는 용어에 대한 각 개인의 생각을 물어봤어요. 특별 강의는 기본 1시간을 넘어 20분 더 진행되었고, 많은 수강생이 마지막까지 남아 있었어요. 피드백을 부탁드렸는데, 마치고 나서 살펴보니 열심히 준비한 보람을 느꼈어요. (선정한 5개 수강평)  다음 주 월요일이 영국도 공휴일인이라 쉴 수 있어서 다행이에요. 그렇게 휴식을 취해야 회사 일과 인프런 코칭을 병행할 수 있을 것 같아요. 모두들 2주차도 파이팅입니다!

UX/UI인프런워밍업베리어블스터디디자인시스템

양성빈

[인프런 워밍업 스터디 클럽] 0기 백엔드 미션 - 어노테이션(Day1)

어노테이션 서론드디어 '인프런 워밍업 스터디 클럽 0기' 첫 날이 밝아왔다. 강의를 듣고 미션을 보니 어노테이션에 관련한 내용이었다.나는 이 미션을 보고 오히려 기쁜 마음이 들었다. 😆 내가 강의를 들으면서 어노테이션 부분이 많이 궁금하였는데 이렇게 공부하게 될 계기가 생긴 것 같아서 미션도 완성시키고 나 스스로 깊게 공부도 할 겸 미션을 시작할려고 한다. 미션 내용은 아래와 같다.진도표 1일차와 연결됩니다우리는 최초로 API를 만들어 보았습니다. GET API를 만들기 위해 사용했던 어노테이션에 익숙하지 않다면 자바 어노테이션에 대해서 몇 가지 블로그 글을 찾아보세요! 다음 질문을 생각하며 공부해보면 좋습니다! 😊 [질문]어노테이션을 사용하는 이유 (효과) 는 무엇일까?나만의 어노테이션은 어떻게 만들 수 있을까?내가 알아본 어노테이션의 정의나는 강의의 실습을 통하여 스프링 부트 프로젝트를 생성하고, GET API를 만들어보고 포스트맨을 통하여 테스트 작업도 해보았다. 나는 여기서 다양한 어노테이션들을 볼 수 있었다. @SpringBootApplication, @RestController, @GetMapping 등 여러 어노테이션들을 볼 수 있었다. 여기서 나는 어노테이션이 무엇일까 고민을 해보았다. 단순히 어노테이션은 @ 붙인거라고만 알고 있었기에 이번 기회에 미션도 수행할 겸 깊게 알아보는 것도 좋다 생각하여 공부해보기로 하겠다.먼저 어노테이션이 대체 어떤 정의가 있는지 구글링을 해보기로 하였다. 구글링을 해보니, 다양한 블로그들이 나왔지만 정의가 수록된 위키백과를 먼저 참조해보기로 하였다. 위키백과는 다음과 같이 정의를 내렸다. 자바 어노테이션은 자바 소스 코드에 추가하여 사용할 수 있는 메타데이터의 일종이다. 보통 @ 기호를 앞에 붙여서 사용한다. JDK 1.5 버전 이상에서 사용 가능하다. 자바 어노테이션은 클래스 파일에 임베디드되어 컴파일러에 의해 생성된 후 자바 가상머신에 포함되어 작동한다. 그리고 강의 중에 코치님께서도 어노테이션에 대해 아래와 같이 언급해주셨다. 어노테이션은 어노테이션마다 너무 다양한 역할을 한다. 또한 마법같은 일을 자동으로 해준다는 것이다.예를 들어서, @SpringBootApplication 어노테이션은 스프링을 실행시킬 때 다양한 설정이 필요한데 이 설정을 모두 자동으로 해준다. 또한 이런것이 가장 핵심적인 마법같은 일이다.위키사전, 코치님의 설명을 통해 어노테이션의 정의를 알 수 있었다. 좀 더 내가 설명한 식으로 풀어보자면 다음과 같다.자바의 어노테이션은 코드에 추가 정보를 제공하는 데 사용되며, 컴파일 시간, 배포 시간, 또는 실행 시간에 해당 정보를 활용할 수 있습니다. 이를 통해 개발자는 코드에 메타데이터를 추가하여 코드의 가독성, 유지 보수성을 향상시키고, 다양한 도구와 프레임워크에서 활용될 수 있는 정보를 제공할 수 있습니다.좀 더 자세히 풀어보자.어노테이션은 자바 5부터 도입된 기능으로, 코드에 대한 메타데이터를 제공하는 방법입니다. 어노테이션은 주석과 비슷하지만, 실제로 코드에 영향을 줄 수 있으며, 컴파일러에게 정보를 제공하거나 실행 시간에 특정 동작을 하도록 할 수 있습니다. 어노테이션은 선언적 형태로 코드 안에 포함되어, 클래스, 메소드, 변수 등 다양한 요소에 적용될 수 있습니다.이제 위의 내용을 좀 더 정리해보겠다. 어노테이션이란?자바를 개발한 사람들은 소스코드에 대한 문서를 따로 만들기보다 소스코드와 문서를 하나의 파일로 관리하는 것이 낫다고 생각했다. 그래서 소스코드의 주석에 소스코드에 대한 정보를 저장하고, 소스코드의 주석으로부터 HTML 문서를 생성해내는 프로그램(javadoc.exe)를 만들어 사용했다. 그런데 여기서 의문점이 하나 든다. 🙋🏻 왜 어노테이션이라는 것을 살펴보려 하는데 주석이라는 내용이 먼저 나올까? 프로그램의 소스코드 안에 다른 프로그램을 위한 정보를 미리 약속된 형식으로 포함시킨 것이 바로 어노테이션이다.어노테이션은 주석(comment)처럼 프로그래밍 언어에 영향을 미치지 않으면서도 다른 프로그램에게 유용한 정보를 제공할 수 있다는 장점이 있다. 📚 어노테이션(annotation)의 뜻은 주석, 주해, 메모이다.package org.example; public @interface SampleAnnotation { }위의 코드는 인텔리제이로 나의 어노테이션을 만든 코드이다.그럼 인텔리제이로 어노테이션을 만드는 것도 끝났으니 이제 끝인가? 나는 여기서 더 나아가서 이 어노테이션 코드가 .class파일로 컴파일 되었을 때 어떻게 나오는지 보고 싶어서 터미널로 컴파일을 해보았다. 컴파일 결과는 다음과 같다.public interface org.example.SampleAnnotation extends java.lang.annotation.Annotation { }컴파일 시점에 extends 한적 없는 java.lang.annotation.Annotation 이 extends 되어 있다. 이제 좀 더 자세한 어노테이션의 내용과 활용법을 알아가보자. 어노테이션은 JDK에서 기본적으로 제공하는 것과 다른 프로그램에서 제공하는 것들이 있는데, 어느 것이든 그저 약속된 형식의 정보를 제공하기만 하면 될 뿐이다.JDK에서 제공하는 표준 어노테이션은 주로 컴파일러를 위한 것으로 컴파일러에게 유용한 정보를 제공한다. 📚 JDK에서 제공하는 어노테이션은 'java.lang.annotation' 패키지에 포함되어 있다.어노테이션은 코드에 넣는 주석이다. 완전히 주석같지는 않지만 그 비슷한 부류이다.주석이기 때문에, 실행되는 코드라고 생각하면 안된다. 어노테이션은 기능을 가지고 있는 것이라 착각을 할 수 있지만 어노테이션은 마크, 표시 해놓는 주석이다. 어노테이션은 다이나믹하게 실행되는 코드는 들어가지 않는다.즉, 런타임에 알아내야 하는 것들은 못 들어간다.위의 내용을 좀 더 풀어쓰면 컴파일러 수준에서 해석이 되야 하거나, 완전히 정적이어야 한다는 말이다.이유를 아래 코드로 보여주겠다. package me.sungbin.controller; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.RestController; @RestController public class HelloController { private static final String hello = "/hello"; @GetMapping(hello) public String hello() { return "hello"; } }위와 같이 hello 변수는 정적 변수이므로 @GetMapping 어노테이션에 사용할 수 있다.하지만. hello 변수가 동적인 변수라면 컴파일 에러가 발생한다.아래의 코드를 보자. 컴파일 에러가 발생하는 것을 볼 수 있을 것이다. 간략한 어노테이션 정의 방법새로운 어노테이션을 정의하는 방법은 아래와 같다.'@'기호를 붙이는 것을 제외하면 인터페이스 정의와 동일하다. package me.sungbin; public @interface SampleAnnotation { 타입요소이름(); } 📚 타입요소등, 어노테이션 정의에 대한 자세한 정의방법과 내용들은 구체적인 내용들을 확인 후, 살펴보자.자바의 표준 어노테이션자바에서 기본적으로 제공하는 어노테이션들은 몇 개 없다.그나마 이들의 일부는 '메타 어노테이션(meta annotation)' 으로 어노테이션을 정의하는데 사용되는 어노테이션의 어노테이션이다. 표준 어노테이션과 메타 어노테이션@Override: 컴파일러에게 오바리이딩하는 메서드라는 것을 알린다.@Deprecated: 앞으로 사용하지 않을 것을 권장하는 대상에 붙인다.@SuppressWarnings: 컴파일러의 특정 경고메시지가 나타나지 않게 해준다.@SafeVarags: 제네릭스 타입의 가변인자에 사용한다. (JDK 1.7)@FunctionalInterface: 함수형 인터페이스라는 것을 알린다. (JDK 1.8)@Native: native 메서드에서 참조되는 상수 앞에 붙인다. (JDK 1.8)@Target*: 어노테이션이 적용가능한 대상을 지정하는데 사용한다.@Documented*: 어노테이션 정보가 javadoc으로 작성된 문서에 포함되게 한다.@Inherited*: 어노테이션이 자손 클래스에 상속되도록 한다.@Retention*: 어노테이션이 유지되는 범위를 지정하는데 사용한다.@Repeatable*: 어노테이션을 반복해서 사용할 수 있게 한다. (JDK 1.8)*이 붙은 것이 메타 어노네이션이다.📚 메타 어노테이션: 어노테이션을 정의하는데 사용하는 어노테이션의 어노테이션 @Override현재 메서드가 슈퍼 클래스의 메서드를 오버라이드한 것임을 컴파일러에게 명시해준다.메서드가 슈퍼클래스에 없다면 에러를 발생시기 때문에 오타와 같은 실수도 잡을 수 있다. @Deprecated마커 어노테이션으로 다음 버전에 지원되지 않을 수도 있기 때문에 앞으로 사용하지 말라고 경고를 알린다.@Deprecated를 붙인 메서드는 인텔리제이에서 아래의 사진과 같이 표시해준다. @SuppressWarning경고를 제거하는 어노테이션으로 개발자가 의도를 가지고 설계를 했는데 컴파일은 이를 알지 못하고 컴파일 경고를 띄울 수 있기 때문에 이를 제거하는 목적이다. @SafeVarargsJava 7이상에서 사용가능하고 제네릭같은 가변인자 매개변수 사용시 경고를 무시한다제네릭사용할 클래스,메서드 내부에서의 데이터타입을 외부에서 지정하는 기법 @FunctionalInterfaceJava 8이상에서 사용가능하고 컴파일러에게 함수형 인터페이스라는 것을 알리는 어노테이션이다.메타 어노테이션'어노테이션을 위한 어노테이션' 쯕, 어노테이션에 붙이는 어노테이션으로 어노테이션을 정의할 때 어노테이션의 적용대상(target) 이나 유지기간(retention)등을 지정하는데 사용된다. 📚 메타 어노테이션은 java.lang.annotation 패키지에 포함되어 있다. @Target어노테이션이 적용가능한 대상을 지정하는데 사용한다.아래 예제는 '@SuppressWarnings' 를 정의한 것인데, 이 어노테이션에 적용할 수 있는 대상을 '@Target' 으로 지정한다.여러 개의 값을 지정할 때는 배열처럼 괄호{} 를 이용하여 지정할 수 있다.package me.sungbin; import java.lang.annotation.Retention; import java.lang.annotation.RetentionPolicy; import java.lang.annotation.Target; import static java.lang.annotation.ElementType.*; @Target({TYPE, FIELD, METHOD, PARAMETER, CONSTRUCTOR, LOCAL_VARIABLE}) @Retention(RetentionPolicy.SOURCE) public @interface SuppressWarnings { String[] value(); } @Target으로 지정할 수 있는 어노테이션 적용대상의 종류ANNOTATION_TYPE: 어노테이션CONSTRUCTOR: 생성자FIELD: 필드(멤버 변수, ENUM 상수)LOCAL_VARIABLE: 지역변수METHOD: 메서드PACKAGE: 패키지PARAMETER: 매개변수TYPE: 타입(클래스, 인터페이스, ENUM)TYPE_PARAMETER: 타입 매개변수(JDK1.8)TYPE_USE: 타입이 사용되는 모든 곳(JDK1.8)📚 java.lang.annotation.ElementType 이라는 열거형에 정의되어 있다. static import문을 사용하면 ElementType.TYPE 이 아니라 TYPE 과 같이 간략히 사용할 수 있다. TYPE은 타입을 선언할 때 어노테이션을 붙일 수 있다는 뜻TYPE_USE는 해당 타입의 변수를 선언할 때 붙일 수 있다는 뜻이다.FIELD 는 기본형에 사용할 수 있고, TYPE_USE는 참조형에 사용된다는 점을 주의한다.타입 선언부제네릭 타입, 변수 타입, 매개변수 타입, 예외 타입...타입에 사용할 수 있으려면TYPE_PARAMETER : 타입 변수에만 사용할 수 있다.TYPE_USE : 타입 변수를 포함해서 모든 타입 선언부에 사용할 수 있다.package me.sungbin; import java.lang.annotation.Target; import static java.lang.annotation.ElementType.*; @Target({FIELD, TYPE, TYPE_USE}) public @interface MyAnnotation { } package me.sungbin; import me.sungbin.controller.HelloController; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; @SpringBootApplication @MyAnnotation public class AnnotationTestApplication { @MyAnnotation int i; @MyAnnotation HelloController helloController; public static void main(String[] args) { SpringApplication.run(AnnotationTestApplication.class, args); } } @Retention어노테이션 유지되는 기간을 지정하는데 사용한다. 어노테이션 유지정책의 종류SOURCE: 소스 파일에만 존재. 클래스파일에는 존재하지 않는다.CLASS: 클래스 파일에 존재. 실행 시에 사용 불가능하다. (기본값)RUNTIME: 클래스 파일에 존재하며 실행시에 사용 가능하다.SOURCE -> CLASS -> RUNTIMESOURCE는 소스코드만 유지하겠다.컴파일 시에만 사용하겠다는 것!컴파일하고 나면 어노테이션은 없어진다. -> 바이트코드에도 남아있지 않다.CLASS애노테이션에 대한 정보를 클래스 파일까지, 즉 바이트 코드에도 남겨 두겠다.클래스 정보를 읽어들이는 방법(바이트 코드를 읽어들이는)을 바탕으로 애노테이션 정보를 읽어와서 처리할 수 있다.예) BYTE BUDDY, ASM 활용바이트 코드엔 남아 있지만, 이 클래스파일을 JVM이 실행할 때 클래스에 대한 정보를 클래스로더가 읽어서 메모리에 적재하게되고, 이후 사용 시점에 메모리에서 읽어올 때 애노테이션 정보를 제외하고 읽어옴RUNTIME위 CLASS와 동일하지만, 메모리에 적재된 클래스 정보를 읽어올 때 애노테이션 정보를 그대로 포함하는 것이다.바이트코드에서 읽어오는게 빠를까?RetentionPolicy를 CLASS로 한 이후, 바이트코드를 읽어 처리하는 라이브러리를 활용?리플렉션으로 읽어오는게 빠를까?RetentionPolicy를 CLASS로 한 이후, 바이트코드를 읽어 처리하는 라이브러리를 활용? -> 리플렉션 자체가 부하가 존재한다.-> 바이트 코드의 양에 영향을 끼친다.-> 리플렉션은 메모리에 이미 올라와 있는 정보를 읽는다. 클래스 로더가 읽고 메모리에 적재시킨 후 읽어온다. 📚 커스텀하게 만든 애노테이션이 정말로 RUNTIME 까지 필요한 정보인가? RUNTIME 까지 사용할 필요가 없다면, CLASS 레벨로 내려가거나 SOURCE 레벨로 내려갈 수도 있을 것이다. 그냥, 의례적으로 RUNTIME으로 작성하는 경우가 있었다면? 그 역할을 다시 살펴보고 명확한 Retention Policy 를 정의하자. 표준 어노테이션 중 '@Override' 나 '@SuppressWarnings' 처럼 컴파일러가 사용하는 어노테이션은 유지 정책이 'SOURCE' 이다. -> 컴파일러를 직접 작성할 것이 아니면, SOURCE 이상의 유지정책을 가질 필요가 없다. 유지 정책을 RUNTIME 으로 한다면,실행 시에 리플렉션(Reflection) 을 통해 클래스 파일에 저장된 어노테이션의 정보를 읽어서 처리 할 수 있다.Retention 정책은 RUNTIME 으로 정의하고Target은 TYPE과 FIELD로 정의한다.package me.sungbin; import java.lang.annotation.Retention; import java.lang.annotation.RetentionPolicy; import java.lang.annotation.Target; import static java.lang.annotation.ElementType.*; @Target({TYPE, FIELD}) @Retention(RetentionPolicy.RUNTIME) public @interface MyAnnotation { } Target이 TYPE과 FIELD 임으로 클래스에도 애노테이션을 선언할 수 있고클래스 내부의 필드에도 애노테이션을 선언할 수 있다.package me.sungbin; @MyAnnotation public class TestClass { @MyAnnotation private String name; public String getName() { return name; } public void setName(String name) { this.name = name; } } TestClass 클래스에 선언된 Annotation을 리플렉션을 이용해 확인할 수 있다.package me.sungbin; import java.lang.reflect.Field; import java.util.Arrays; public class App { public static void main(String[] args) { Arrays.stream(TestClass.class.getAnnotations()).forEach(System.out::println); Field[] declaredFields = TestClass.class.getDeclaredFields(); for (Field declaredField : declaredFields) { Arrays.stream(declaredField.getAnnotations()).forEach(System.out::println); } } }  표준 어노테이션 중 '@FunctionalInterface' 는 '@Override' 처럼 컴파일러가 체크해주는 어노테이션이지만, 실행 시에도 사용되므로 유지 정책이 "RUNTIME"으로 되어 있다. @Documented @Retention(RetentionPolicy.RUNTIME) @Target(ElementType.TYPE) public @interface FunctionalInterface {} 유지 정책을 "CLASS" 으로 한다면컴파일러가 어노테이션의 정보를 클래스 파일에 저장할 수 있게 하지만,클래스 파일이 JVM에 로딩 될 때는 어노테이션의 정보가 무시되어 실행 시에 어노테이션에 대한 정보를 얻을 수 없다.→ CLASS 가 유지정책의 기본값임에도 불구하고 잘 사용되지 않는 이유 지역 변수에 붙은 어노테이션은 컴파일러만 인식할 수 있으므로, 유지 정책이 RUNTIME인 어노테이션을 지역변수에 붙여도 실행 시에는 인식되지 않는다. @Documented어노테이션에 대한 정보가 javadoc으로 작성한 문서에 포함되도록 한다.표준 어노테이션 중 Override와 SuppressWarnings를 제외하고 모두 Documented 메타 어노테이션이 붙어 있다. @Documented애노테이션 정보가 javadoc으로 작성된 문서에 포함된다고 한다. 이것이 무슨말일까? 내 코드가 자바docs에 올라간다는 말일까?정확히 말하면 자바docs에 올라간다는 말이 아니라,직접 javadoc을 만들 수 있다는 뜻이다.이런식으로 만들 수 있는데, Local 지역입력 ko_KRother command line arguments : 한글깨짐 방지-encoding UTF-8 -charset UTF-8 -docencoding UTF-8적절하게 내용을 채운뒤 output directory에 경로를 입력해주면 끝이다.그러면 @Documented를 붙인거와 안 붙인것을 비교해보자. 코드public class Korea implements Great{ @Override @Make public String country() { return "한국"; } } 없는거 있는거JavaDoc애노테이션을 알기 전에 JavaDoc에 대해 알아보자.JavaDoc은 Java코드에서 API문서를 HTML 형식으로 생성해주는 도구이다.HTML 형식이기 때문에 다른 API를 하이퍼 링크를 통해 접근이 가능하다. JavaDoc TagsJavaDoc은 여러 Tag를 작성하여 문서를 완성한다.Java 코드에서 애노테이션으로 추가한다.IDE에서 /** 입력 후 엔터를 치면 자동으로 형식이 생성된다.Javadoc Tags의 종류들@author@deprecated@exception@param@return@see@serial@serialData@serialField@since@throws@since@throws@version@Inherited어노테이션이 자손 클래스에 상속되도록 한다.'@Inherited' 가 붙은 어노테이션을 조상 클래스에 붙이면, 자손 클래스도 이 어노테이션이 붙은 것과 같이 인식된다.MyAnnotation은 Inherited 애노테이션을 통해 자손 클래스에도 인식되도록 정의한다.package me.sungbin; import java.lang.annotation.Inherited; import java.lang.annotation.Retention; import java.lang.annotation.RetentionPolicy; import java.lang.annotation.Target; import static java.lang.annotation.ElementType.*; @Retention(RetentionPolicy.RUNTIME) @Target({TYPE, FIELD}) @Inherited public @interface MyAnnotation { } 부모클래스인 Sungbin클래스에 MyAnnotation을 정의package me.sungbin; @MyAnnotation("hi") public class Sungbin { @MyAnnotation("yang sung bin") private String name; }  Sungbin 클래스의 자식 클래스인 Child 클래스에는 별도의 어노테이션 정의가 없다.package me.sungbin; public class Child extends Sungbin { } 리플렉션을 이용해 Child 클래스의 어노테이션을 확인해보자.→ ChildSson 클래스에는 정의한 애노테이션이 없지만,→ 부모 클래스인 Sson 클래스에 정의한 애노테이션이 확인됨을 볼 수 있다.→ Inherited 애노테이션을 통해 자식 클래스까지 전파될 수 있음을 확인할 수 있다. Inherited 애노테이션을 바탕으로 리플렉션을 활용해 자식클래스에서부모클래스에 정의되어 있는 Inherited 애노테이션을 확인할 수 있다. 📚 리플랙션의 getDeclaredFields(); 를 하면 클래스에 정의된(선언된) 것들을 가져와서 조작할 수 있다. public이던, private 이던,, Getter와 Setter에 대해 논의를 하며 큰 비용을 소모하는 것이 크게 가치가 없다.. 객체지향을 얘기하며 Getter, Setter의 정의 관련한 내용으로 얘기할 수 있겠지만, Getter와 Setter가 없더라도 리플랙션을 이용하면 충분히 가져오고 수정할 수 있기 때문이다. 중요한건 Getter, Setter 가 아닌것 같다. @Repeatable보통은 하나의 대상에 한 종류의 어노테이션을 붙이게 되는데,'@Repeatable'이 붙은 어노테이션은 여러 번 붙일 수 있다. 일반적인 어노테이션과 달리 같은 이름의 어노테이션이 어러 개가 하나의 대상에 적용될 수 있기 때문에, 이 어노테이션들을 하나로 묶어서 다룰 수 있는 어노테이션도 추가로 정의해야 한다. @Native네티이브 메서드(native method)에 의해 참조되는 '상수 필드(constant field)'에 붙이는 어노테이션이다.여기서, 네이티브 메서드는 JVM이 설치된 OS의 메서드를 말한다.네이티브 메서드는 보통 C언어로 작성되어 있는데, 자바에서는 메서드의 선언부만 정의하고 구현하지 않는다.그래서 추상 메서드처럼 선언부만 있고 구현부가 없다. 어노테이션 타입 정의어노테이션의 요소어노테이션 내에 선언된 메서드를 어노테이션의 요소라고 한다. 📚 어노테이션에도 인터페이스처럼 상수를 정의할 수 있지만, 디폴트 메서드는 정의할 수 있다. 어노테이션의 요소는 반환 값이 있고 매개변수는 없는 추상 메서드의 형태를 가진다.다만, 어노테이션을 적용할 때 이 요소들의 값을 빠짐없이 지정해주어야 한다.각 요소들은 기본값을 가질 수 있으며, 기본값이 있는 요소들은 어노테이션을 적용할 때 값을 지정하지 않으면 기본값이 사용된다.어노테이션의 요소가 오직 하나 뿐이고 이름이 value 인 경우, 어노테이션을 적용할 때 요소의 이름을 생략하고 값만 적어도 된다.요소 타입이 배열인 경우, 괄호{} 를 사용해 여러 개의 값을 지정할 수 있다.하나인 경우는 괄호{} 를 생략할 수 있다.java.lang.annotation.Annotation모든 어노테이션의 조상은 Annotation이다.그러나 어노테이션은 상속이 허용되지 않으므로 아래와 같이 명시적으로 Annotation을 조상으로 지정할 수 없다. @interface TestInfo extends Annotation{ // 에러. 허용되지 않는 표현이다. int count(); String testedBy(); ... } Annotation 을 살펴보면Annotation은 어노테이션이 아니라 일반적인 인터페이스로 정의되어 있다. 모든 어노테이션의 조상인 Annotation 인터페이스가 위와 같이 정의되어 있기 때문에모든 어노테이션 객체에 대해 equals(), hashCode(), toString() 과 같은 메서드를 호출하는 것이 가능하다.리플랙션(Reflection)을 이용해 특정 클래스에 선언된 애노테이션들을 조회하여 equals, hashCode, toString 메서드를 호출해본다.어노테이션 요소의 규칙어노테이션의 요소를 선언할 때 반드시 지켜야 하는 규칙요소 타입은 기본형, String, Enum, 어노테이션, Class 만 허용() 안에 매개변수를 선언할 수 없다.예외를 선언할 수 없다.요소를 타입 매개변수로 정의할 수 없다.마커 어노테이션 Marker Annotation값을 지정할 필요가 없는 경우,어노테이션의 요소를 하나도 정의하지 않을 수 있다.Serializable 이나 Cloneable 인터페이스처럼, 요소가 하나도 정의되지 않은 어노테이션을 마커 어노테이션이라 한다. 🙋🏻 이런 마커 어노테이션은 왜 사용될까? 글을 찾아보니 아래의 내용이 있었다.마커 어노테이션을 통해 코드 작성 시점, 컴파일 시점, 러타임 시점에 부가적인 작업을 추가할 수 있을 것이다.코드 작성 시점에 어노테이션 정보를 통해 부가적인 정보를 check 하여 컴파일에러를 발생시킬 수 있을 것이며컴파일하는 과정에서 어노테이션 정보를 바탕으로 부가적인 정보를 포함하여 컴파일된 결과를 내보낼 수도 있을 것이다.또한, 런타임 시점에 리플랙션을 이용하여 애노테이션 정보를 바탕으로 부가적인 작업을 할 수 있을 것이다.Java8 어노테이션 변화 애노테이션 관련 큰 변화 두가지자바 8 부터 애노테이션을 타입 선언부에도 사용할 수 있게 되었다.자바 8 부터 애노테이션을 중복해서 사용할 수 있게 되었다.타입 선언부제네릭 타입변수 타입매개변수 타입예외 타입...타입에 사용할 수 있으려면TYPE_PARAMETER : 타입 변수에만 사용할 수 있다.TYPE_USE : 타입 변수를 포함해서 모든 타입 선언부에 사용할 수 있다.중복 사용할 수 있는 애노테이션을 만들기@Repeatable애노테이션들을 감싸고 있을 컨테이너 애노테이션을 선언해야 한다.중복 사용할 애노테이션 만들기컨테이너 애노테이션은 중복 애노테이션과 @Retention 및 @Target 이 같거나 더 넓어야 한다.컨테이너이기 떄문에 , 이것은 접근 지시자의 범위와 유사한 개념이라고 볼 수 있다.@Retention : 애노테이션을 언제까지 유지할 것이냐?@Target : 애노테이션을 어디에 사용할 것이냐?애노테이션 프로세서애노테이션 프로세서는 소스코드 레벨에서 소스코드에 붙어있는애노테이션을 읽어서 컴파일러가 컴파일 하는 중에 새로은 소스코드를 생성하거나 기존 소스코드를 바꿀 수 있다.또는, 클래스(바이트코드) 도 생성할 수 있고 별개의 리소스파일을 생성할 수 있는 강력한 기능이다. 애노테이션 프로세서 사용 예롬복 (기존코드를 변경한다)AutoService (리소스 파일을 생성해준다.)java.util.ServiceLoader 용 파일 생성 유틸리티@Override애노테이션 프로세서 장점바이트코드에 대한 조작은 런타임에 발생되는 조작임으로 런타임에 대한 비용이 발생한다.but. 애노테이션 프로세서는 애플리케이션을 구동하는 런타임 시점이 아니라,컴파일 시점에 조작하여 사용함으로 런타임에 대한 비용이 제로가 된다.단점은 기존의 코드를 고치는 방법은 현재로써는 public 한 API 가 없다.롬복 같은 경우.. 기존 코드를 변경하는 방법이지만 public 한 api를 이용한 것이 아님으로 해킹이라고 할 수 도 있다.롬복(Lombok)의 동작원리Lombok@Getter @Setter, @Builder 등의 애노테이션과애노테이션 프로세서를 제공하여 표준적으로 작성해야 할 코드를 개발자 대신 생성해주는 라이브러리이다.사용하기의존성 추가IntelliJ Lombok 플로그인 설치Intellij Annotation Processing 옵션 활성화동작원리컴파일 시점에 "애노테이션 프로세서"를 사용하여 (자바가 제공하는 애노테이션 프로세서)소스코드의 AST(Abstract Syntax Tree) 를 조작한다.AST에 대한 참고 사이트 (아래 참조 참고)javax.annotation.processing || Interfaec Processor⇒ 소스코드의 AST를 원래는 참조만 할 수 있다. // 수정하지 못한다. 그리고 하면 안된다!⇒ 그러나 수정이 됬음을 알 수 있다.(컴파일 이후 바이트코드 확인)⇒ 참조만 해야 하는 것을 내부 클래스를 사용하여 기존 코드를 조작하는 것임으로 "해킹" 이라고 얘기하기도 한다. 논란 거리공개된 API가 아닌 컴파일러 내부 클래스를 사용하여 기존 소스 코드를 조작한다.특히 이클립스의 경우에는 Java Agent를 사용하여 컴파일러 클래스까지 조작하여 사용한다.해당 클래스들 역시 공개된 API가 아니다보니 버전 호환성에 문제가 생길 수도 있고 언제라도 그런 문제가 발생해도 이상하지 않다.그럼에도 불구하고 엄청난 편리함 때문에 널리 쓰이고 있으며, 대안이 몇가지 있지만 롬복의 모든 기능과 편의성을 대체하지 못하는 상황이다.AutoValueImmutables기존 Getter, Setter, equals, hasCode 등의 메소드를 생성하는 순간?해당 클래스는 이미 방대해진 모습을 볼 수 있다.해당 클래스를 위한 메소드들이 선언이 되어 있더라도 위 메소드들 사이에 파묻혀 있다면?개발자 입장에서 놓칠 수도 있다. (그래서 boilerplat 코드라는 개념도 나온다.)⇒ 롬복을 이용하여 쉽게, 그리고 가독성 높게 클래스를 구현할 수 있다. package me.sungbin; import lombok.Getter; import lombok.Setter; @Getter @Setter public class Member { private String name; private int age; }  위의 롬복이 적용된 코드를 컴파일하면 아래와 같이 나온다. package me.sungbin; public class Member { private String name; private int age; public String getName() { return name; } public void setName(String name) { this.name = name; } public int getAge() { return age; } public void setAge(int age) { this.age = age; } } 결론위에서 미션에 대해 다 애기한 듯 하다. 결론을 내보겠다.어노테이션을 사용하는 이유는 단순하다.코드의 가독성과 유지보수성 향상: 어노테이션을 사용하면 개발자가 코드의 의도를 더 명확하게 표현할 수 있습니다. 예를 들어, @Override 어노테이션은 메소드가 상위 클래스의 메소드를 오버라이드한다는 것을 명시합니다.컴파일 시간 검사: 어노테이션을 통해 코드에 대한 추가적인 검사를 수행할 수 있어, 잠재적인 오류를 컴파일 시간에 발견하고 수정할 수 있습니다.런타임 처리: 특정 어노테이션이 적용된 요소를 런타임에 검사하고 처리할 수 있어, 리플렉션을 사용한 동적 처리가 가능해집니다. 이는 프레임워크와 라이브러리에서 많이 활용됩니다.이런 이유로 사용이 되며 이로인하여 코드문서화, 컴파일러에 특정처리를 지시, 코드분석 도구 지원, 런타임처리등이 가능해지게 된다. 우리가 스프링의 의존성 주입을 할 때 @Autowired도 이런 기능처리를 해준다. 컴파일러에서의 처리:코드 검증: 컴파일러는 어노테이션을 사용하여 코드에 대한 추가적인 검증을 수행합니다. 예를 들어, @Override 어노테이션은 메서드가 실제로 상위 클래스나 인터페이스의 메서드를 오버라이드하는지 확인하는 데 사용됩니다. 만약 오버라이드하는 메서드가 없다면, 컴파일러는 에러를 발생시킵니다.정책 적용: 일부 어노테이션은 컴파일러에 특정 정책을 적용하도록 지시합니다. 예를 들어, @Deprecated 어노테이션이 적용된 요소를 사용하는 코드는 컴파일러 경고를 발생시키며, 이는 개발자에게 해당 요소가 더 이상 사용되지 않아야 함을 알립니다.소스 코드 변환: 어노테이션 프로세서를 사용하여 컴파일 시점에 소스 코드를 자동으로 생성하거나 수정할 수 있습니다. 이는 코드 생성 라이브러리나 프레임워크에서 흔히 사용되는 기법입니다.런타임에서의 처리:리플렉션을 통한 접근: 런타임에는 리플렉션 API를 사용하여 어노테이션 정보에 접근할 수 있습니다. 이를 통해 개발자는 실행 중인 프로그램에서 클래스, 메서드, 필드 등에 적용된 어노테이션을 검사하고, 해당 어노테이션에 지정된 정보를 바탕으로 동적인 처리를 수행할 수 있습니다.동적 처리: 런타임에 어노테이션을 기반으로 동적 처리를 하는 예로, Java EE와 Spring 프레임워크에서 의존성 주입을 구현하는 방법을 들 수 있습니다. 이러한 프레임워크는 특정 어노테이션(@EJB, @Autowired)이 붙은 필드나 메서드를 찾아, 런타임에 자동으로 의존성을 주입합니다.구성 관리: 어플리케이션의 구성 정보를 어노테이션을 통해 관리할 수 있습니다. 예를 들어, 웹 어플리케이션에서 서블릿이나 REST 엔드포인트를 정의할 때 사용되는 어노테이션들은 런타임에 웹 서버가 해당 구성 정보를 읽어들여 서비스를 구동하는 데 사용됩니다.이러한 방식으로 어노테이션은 컴파일 시점과 런타임에 다양한 목적으로 활용됩니다. 컴파일 시점에는 코드의 정확성을 보장하고, 런타임에는 코드의 동적인 행위를 제어하는 데 중요한 역할을 합니다.커스텀 어노테이션이것 또한 위에서 예제로 많이 보여드렸으므로 어노테이션 예제를 보여줌으로 이 글을 마치려고 한다. 정말 단순히 어노테이션부터 시작해서 리플렉션까지 갔는데 정말 험난한 여정이였지만 보람찬 공부가 되었다. package me.sungbin; import java.lang.annotation.Inherited; import java.lang.annotation.Retention; import java.lang.annotation.RetentionPolicy; import java.lang.annotation.Target; import static java.lang.annotation.ElementType.*; @Retention(RetentionPolicy.RUNTIME) @Target({TYPE, FIELD}) @Inherited public @interface MyAnnotation { String value(); }  📚 참조https://b-programmer.tistory.com/264http://javaparser.org/inspecting-an-ast/

백엔드인프런워킹업스터디클럽백엔드미션어노테이션

양성빈

[인프런 워밍업 스터디 클럽] 0기 세번째 발자국 (feat. 마지막 발자국 ㅠㅠ)

발자국어느덧 인프런 워밍업 스터디 클럽 마지막 주차가 다가왔다. 매우 즐겁기도 했지만 한편으로는 매우 아쉬운 마음이 너무 걸렸다. 이런 기회가 자주 있었으면 하는 마음으로 마지막 발자국(회고)를 시작해보겠다.강의 요약Day10. 객체지향과 JPA 연관관계조금 더 객체지향적으로 개발할 수 없을까?우리는 지난 시간까지 책 생성 API를 개발하고 대출과 반납기능까지를 개발완료하였다. 하지만 여기서 이런 의문사항이 들 수 있다. SQL 대신에 ORM을 사용하게 된 계기는 "DB 테이블과 객체는 패러다임이 다름" 때문이다. 우리가 사용하는 Java는 객체지향 언어이고 요즘 서비스 진행중인 웹 어플리케이션도 절차지향적이기 보단 객체지향적으로 구성되어 있는 코드들이 많을 것이다. (개인적인 뇌피셜) 그래서 우리가 20강에서 배운 스프링 컨테이너도 객체지향 설계라는 지점에서 출발하게 되었다. 즉, User 객체와 UserLoanHistory를 협업시킬 수 없을까? 즉, 대출기능을 개발할때 BookService가 UserLoanHistory 객체를 만들어 저장하고, 그것을 User객체가 가져오는 방식이였다. 뭔가 BookService를 거쳐가야한다는게 걸린다. 즉, BookService로직은 User객체가 가져와 사용하고 User객체가 직접 UserLoanHistory와 상호작용을 하면 좋을 것 같다. 반납기능도 대출기능과 동일하게 바꾸면 좋을 것 같다. 이렇게 바꾸려면 조건이 존재한다. User객체와 UserLoanHistory가 서로 존재한다는 것을 인지해야 한다. 이것을 위해 연관관계 개념이 등장하였다. 대표적으로 N:1 관계가 존재한다.🙋🏻 N:1 관계란?예시로 들어보자. 어느 한 교실에 여러명의 학생이 존재할 수 있다. 이 때 학생은 N이고 교실은 1이다 이것을 N:1관계라고 부를 수 있다.그럼 관계를 설정하고 나서 다음으로 할 일은 연관관계 주인이 누구인지 알아야한다. 현재 우리의 실습 소스에서 user와 user_loan_history의 테이블을 보면 아래와 같다.create table user ( id bigint auto_increment, name varchar(25), age int, primary key (id) );create table user_loan_history ( id bigint auto_increment, user_id bigint, book_name varchar(255), is_return tinyint(1), primary key (id) );여기서 연관관계 주인을 누구로 할까? 쉽게 생각해서 N:1관계에서 N쪽이 보통은 연관관계 주인이라고 생각하면 쉽다.그리고 연관관계 주인이 아닌쪽에는 mappedBy 속성을 추가해줘야 한다. mappedBy의 속성의 값으로는 관계에 설정된 클래스에 선언된 자신의 객체의 변수명을 적어주면 된다. 실제 코드를 살펴보면 아래와 같이 변경이 가능하다.User.java@Entity public class User { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id = null; @Column(nullable = false, length = 20) private String name; private Integer age; @OneToMany(mappedBy = "user") private List<UserLoanHistory> userLoanHistories = new ArrayList<>(); protected User() { } public User(String name, Integer age) { if (name == null || name.isBlank()) { throw new IllegalArgumentException(String.format("잘못된 name(%s)이 들어왔습니다.", name)); } this.name = name; this.age = age; } public Long getId() { return id; } public String getName() { return name; } public Integer getAge() { return age; } public void updateName(String name) { this.name = name; } }UserLoanHistory.java@Entity public class UserLoanHistory { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id = null; @ManyToOne private User user; private String bookName; private boolean isReturn; protected UserLoanHistory() { } public UserLoanHistory(User user, String bookName) { this.user = user; this.bookName = bookName; this.isReturn = false; } public void doReturn() { this.isReturn = true; } }위의 코드처럼 N쪽에 @ManyToOne 어노테이션을 붙여주고 관계를 맺는 객체를 선언해준다. 그리거 1쪽도 마찬가지로 관계를 맺는 객체를 선언해주고 위에 @OneToMany 어노테이션을 선언해준다. 이런 방식을 양방향 연관관계라고 부르며, 한쪽만 연관관계를 맺을 시 단방향 연관관계라고 부른다. 이렇게 연관관계의 주인의 값이 설정되어야만 진정한 데이터가 저장된다.그럼 BookService는 어떻게 변경을 하는지 살펴보자.BookService.java// 5. 유저와 책 정보를 기반으로 UserLoanHistory를 저장. this.userLoanHistoryRepository.save(new UserLoanHistory(user, book.getName()));이제 위와 같이 user의 id값을 저장하는게 아닌 user 객체를 직접 저장할 수 있다.JPA 연관관계에 대한 추가적인 기능들1:1 관계예를 들어 한 사람과 실거주지의 관계가 딱 1:1 관계이다. 그러면 연관관계 주인은 어느 객체일까? 설정하기 나름이지만 주어진 상황은 사람이 연관관계 주인이라 생각하는게 좋을 것이다. 그러면 코드로 표현하면 아래와 같을 것이다.Person.java@Entity public class Person { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id = null; private String name; @OneToOne private Address address; public Long getId() { return id; } public String getName() { return name; } public Address getAddress() { return address; } public void setAddress(Address address) { this.address = address; this.address.setPerson(this); } }Address.java @Entity public class Address { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; private String city; private String street; @OneToOne(mappedBy = "address") private Person person; public Long getId() { return id; } public String getCity() { return city; } public String getStreet() { return street; } public Person getPerson() { return person; } public void setPerson(Person person) { this.person = person; } }🔥 연관관계 주인 효과연관관계 주인을 설정하는 것은 객체가 연결되는 기준이 된다.1. 상대 테이블을 참조하고 있으면 연관관계의 주인2. 연관관계의 주인이 아니면 mappedBy를 사용3. 연관관계의 주인의 setter가 사용되어야만 테이블 연결즉, 아래처럼 setter를 이용하여 연결이 가능하다.@Transactional public void savePerson() { Person person = this.personRepository.save(new Person()); Address address = this.addressRepository.save(new Address()); person.setAddress(address); }⚠ 주의만약 트랜잭션이 끝나지 않았을 때 한쪽만 연결해두면 반대쪽은 알 수 없다. 그래서 위의 코드에서 address.getPerson()을 출력을 하면 null이 뜰 것이다. 왜냐하면 지금은 현재 person만 address를 연결해줬기 때문이다. address는 person을 연결해주지 않았기 때문이다.그럼 해결책은 없을까? 객체안에 연관관계 편의 메서드를 만들어 두 객체의 setter를 호출하면 해결이 된다.N : 1 관계 - @ManyToOne과 @OneToMany위에서 언급을 했지만 @ManyToOne과 @OneToMany는 둘다 양방향으로 연결을 할 수 있지만 단방향 연결도 가능하다. 또한 이 어노테이션들을 이용하면서 새롭게 배우는 어노테이션이 있는데 바로 @JoinColumn이다.@JoinColumn- 연관관계의 주인이 활용할 수 있는 어노테이션.- 필드의 이름이나 null 여부, 유일성 여부, 업데이트 여부 등을 지정- 일종의 @Column 어노테이션과 유사하다고 생각하면 좋다.N : M 관계 - @ManyToMany구조가 복잡하고, 테이블이 직관적으로 매핑되지 않아 사용하지 않는 것을 추천한다고 하셨다. 실제로 실무에 근무하는 분들한테 이야기를 들으면 N:M은 많이 사용하지 않고 꼭 이런식으로 처리해야할 경우면 N:1과 1:N으로 풀어쓴다고 하셨다.cascade 옵션 & orphanRemoval 옵션한 객체가 저장되거나 삭제될 때, 그 변경이 폭포처럼 흘러 연결되어 있는 객체도 함께 저장되거나 삭제되는 기능.JPA에는 Entity들 사이의 연관관계를 정의할 때 사용할 수 있는 영속성 전이라고 하는 Cascade 옵션이 있다. 이 옵션을 이용해서 부모에 가해지는 변화를 자식에게 전파할지에 대해 설정할 수 있다.@OneToMany로 자식들을 갖고 있는 부모 객체만 저장/삭제 해도 자식 객체도 함께 저장/삭제 된다던지, 하는 효과를 누릴 수 있다.JPA에는 Entity들 사이의 연관관계를 정의할 때 사용할 수 있는 옵션 중에 orphanRemoval 라는 것이 있다. 이 옵션을 이용하면 부모가 자식에 대한 참조를 끊을 때, 참조가 끊어진 자식 Entity(고아 객체)를 DB에서 삭제하도록 설정할 수 있다.만약 어떤 회원이 책 2권을 대출했다고 하자. 그리고 그 회원이. 갑자기 회원탈퇴를 해서 DB에서 사라졌다. 그럴 경우 많이 이상하게 책 2권이 연결되어 있던게 끊어진 상태가 된다. 이상한 구조일 것이다. 즉, 회원이 삭제될 때 유저 대출기록도 같이 삭제해두는게 좋을 것이다. 그리고 이와 같이 쓰는 옵션이 바로 orphanRemoval 옵션이다.@OneToMany(mappedBy = "user", cascade = CascadeType.ALL, orphanRemoval = true) private List<UserLoanHistory> userLoanHistories = new ArrayList<>();위의 코드처럼 사용하면 부모객체의 저장/삭제해도 자식 객체도 함께 전파되면 삭제시, 자식 객체도 같이 삭제된다.책 대출/반납 기능 리팩토링과 지연로딩이제 우리가 만든 대출과 반납기능을 리팩토링 해보자. 리팩토링 할 부분은 무엇일까? 현재 코드를 보면 도메인 계층에 비즈니스 로직이 들어가져 있다. 또한 여기서 영속성 컨텍스트 4번째 옵션이 나오는데 바로 지연로딩이다.데이터를 처음에 한번에 로딩을 안하고 꼭 필요한 순간에 데이터를 로딩시킨다. 바로 @OneToMany의 fetch옵션의 default 값이다. 지연 로딩을 사용하게 되면, 연결되어 있는 객체를 꼭 필요한 순간에만 가져온다.그러면 우린 이제까지 연관관계를 맺고 주인을 정하고 지연로딩, cascade, orphanRemoval옵션을 이용해서 리팩토링과정을 거쳐보았다. 이렇게 연관관계를 이용하면 뭐가 좋을까?📖 연관관계 장점1. 각자의 역할에 집중할 수 있다. = 응집성2. 새로운 개발자가 코드를 봤을 때 이해가 쉬워진다.3. 테스트 코드 작성에 용이하다.그러면 무조건 연관관계 맺는것이 좋을까? 그렇지는 않다! 연관관계를 남발해서 사용하면 지나치게 사용하면, 성능상의 문제가 생길 수도 있고 도메인 간의 복잡한 연결로 인해 시스템을 파악하기 어려워질 수 있다. 또한 관계가 복잡하면 하나의 테이블 수정 시, 다른 테이블까지 영향을 끼칠 수 있다. 강의중에서도 코치님께서 아래와 같이 말씀주셨다.비즈니스 요구사항, 기술적인 요구사항, 도메인 아키텍처 등 여러 부분을 고민해서 연관관계 사용을 선택해야 한다.Day11. 기본적인 배포를 위한 준비배포란 무엇인가?배포란 무엇일까? 배포는 제 3자의 사용자가 우리가 만든 서비스를 전달하는 과정이라고 볼 수 있다. 우리는 지금 현재 우리의 개인 PC에다가 개발을 하고 웹을 띄워보며 테스트를 해보았다. 하지만 영희가 우리의 서비스를 이용할려하면 어떻게 할까? 현재 상황에서는 나한테 연락을 하고 우리집에 방문해서 사용하고 가야할 것이다. 물론 영희 1명이고 내가 집에 있다고 한다면 가능하다. 하지만 영희 혼자가 아니라 100만명이 우리의 서비스를 이용한다면 정말 고민이 많을 것이다. 또한, 내가 잘때 갑자기 철수가 오겠다고 하면 나는 잠을 자지도 못하고 철수가 우리집에 올 때까지 기다려야 할 것이다.그래서 나는 좋은 생각을 한다. 제3의 컴퓨터를 빌려서 우리의 웹 어플리케이션을 띄우는 것이다. 그리고 나의 친구들에게 그 컴퓨터 IP주소를 알려주면 된다. 이 과정을 배포과정이라고 한다. 그러면 이 컴퓨터는 누구한테 빌릴까? 네이버, 구글등 다양한 컴퓨팅 서비스를 해주는 곳은 많지만 대부분 아마존을 이용한다. 또한 배포를 위해 컴퓨터를 빌릴때 운영체제를 선택도 해야한다.profile과 H2 DB여기서 우리는 문제를 직면한다. 우리의 코드를 제3의 컴퓨터에서 실행시킬 때 DB같은 자원정보를 변경해줘야 한다. 이런 불편함에 이런 생각을 하게 된다. 코드변경 없이 우리의 컴퓨터에서 실행할때 우리의 DB가 연결이 되고 제3의 컴퓨터에서 실행할때는 제3의 컴퓨터에 설치된 DB가 연결되어야 한다. 즉, 똑같은 코드로 실행환경에 따라 설정을 다르게 하고 싶다. 이때 바로 profile을 이용하는 것이다. 현재 우리는 지금 profile이라는 것을 사용하고 있다. 바로 "default" profile을 사용한다. 아무것도 설정을 안하면 해당 프로필이 자동으로 올라온다. 그럼 실제 우리의 코드에 profile을 적용해보자. 똑같은 서버 코드를 실행시키지만, local 이라는 profile을 입력하면, H2 DB를 사용하고 dev 라는 profile을 입력하면 MySQL DB를 사용하게 바꾸자.🤔 H2 DB란?경량 Database로, 개발 단계에서 많이 사용하며 디스크가 아닌 메모리에 데이터를 저장할 수 있다. 또한, 개발 단계에서는 테이블이 계속 변경되는데 어차피 데이터가 휘발되기 때문에 ddl-auto 옵션을 create로 주면 테이블을 신경쓰지 않고 코드에만 집중할 수 있다! 그래서 개발단계나 테스트에서 H2 DB를 많이 사용한다.그러면 적용한 yml은 아래와 같이 될 수 있다.pring: config: activate: on-profile: local datasource: url: "jdbc:h2:mem:library;MODE=MYSQL;NON_KEYWORDS=USER" username: sa password: driver-class-name: org.h2.Driver jpa: hibernate: ddl-auto: create properties: hibernate: show_sql: true format_sql: true h2: console: enabled: true path: /h2-console --- spring: config: activate: on-profile: dev datasource: url: "jdbc:mysql://localhost/library" username: "root" password: "" driver-class-name: com.mysql.cj.jdbc.Driver jpa: hibernate: ddl-auto: none properties: hibernate: show_sql: true format_sql: true여기서 --- 은 프로필을 구분하는 표시선이라 생각하면 좋다. 그리고 DB 접속 url에 MODE=MYSQL;NON_KEYWORDS=USER 해당 옵션을 붙인 이유는 DB의 키워드중에 USER라는 것이 있기에 키워드로 설정 안하고 모드를 MySQL과 유사하게 만들기 위한 옵션이다. 또한 h2.console.enabled와 h2.console.path 옵션은 해당 경로로 접속했을 때 h2 console을 사용할 수 있기 위해서이다.git과 github이란 무엇인가?!개발 관련 서적이나 자료를 찾다보면 한번쯤 보이는 주소가 있다. 바로 git이다. git이란 코드를 쉽게 관리할 수 있도록 해주는 버전 관리 프로그램이다. 이런 상황이 있다하자. A개발자와 B개발자가 협업을 하고 있다 하자. 그리고 각자 개발 후 소스코드를 합칠때 문제가 생긴다. 다른 코드들은 상관없지만 같은 파일의 코드들을 다르게 수정할 우려가 있기 때문이다. 그래서 이것을 일일이 수작업으로 확인하기엔 너무 힘들다. 이래서 git이 등장한 것이다. 또한 버전을 관리하기에 아래와 같은 사태 또한 일어나지 않을 것이다.그러면 github는 무엇일까? git으로 관리되는 프로젝트들을 관리해주는 저장소이다. 우리는 git으로 관리하는 프로젝트를 github에 저장할 수 있다. 그럼 왜 github에 저장할까? 자랑용, 공유로 저장할 수 있지만 배포가 가장 큰 이유로 볼 수 있다. 제3자의 컴퓨터에 우리의 서비스를 배포해야하는데 우리의 소스코드를 usb나 외장하드에 담아 제3자의 컴퓨터까지 가서 복사해서 할 수는 없을 것이다. 만약 집 근처면 참고 갈테지만 만약 미국의 제3자의 컴퓨터가 있다면 비행기값이 더 나올 것이다. 깃 명령어그럼 간단하게 깃 명령어를 알아보자.📚 용어git init : git 프로젝트 시작하기git remote add origin [각자 저장소 주소]: git 프로젝트의 github 저장소 설정하기git add . : 코드들을 담는다. 일종의 택배상자에 담는다고 보면 된다.git status: 현재 택배상자에 코드들이 잘 담겨져 있는지 확인하는 명령어git commit -m "메세지" : 택배상자에 송장 붙이는 명령어git push : 택배상자를 github에 보내기 택배상자를 github에 보낼 때 git push –set-upstream origin master 명령어를 최초 1번 해줘야 한다. AWS의 EC2 사용하기AWS의 회원가입 로그인 과정을 거쳐서 제3자의 컴퓨터를 빌려보는 실습을 해보았다.Day12. AWS와 EC2 배포EC2에 접속을 하려면 아래와 같은 준비물이 필요하다.1) 우리가 접속하려는 EC2의 IP 주소2) 이전 시간에 다운로드 받았던 키 페어3) 접속하기 위한 프로그램 (git CLI 혹은 Mac terminal)다운로드 받은 키 페어를 이용하는 방법ssh –i 경로/키페어이름.pem ec2-user@IP다음으로 키페어 권한을 변경해주자.chmod 400 경로/키페어이름.pem아니면 위와같은 과정이 불편하다면 AWS의 콘솔을 이용하는 방법도 있다.리눅스 명령어mkdir : 폴더를 만드는 명령어ls : 현재 위치에서 폴더나 파일을 확인하는 명령어ls –l : 조금 더 자세한 정보를 확인할 수 있다!cd : 폴더 안으로 들어가는 명령어pwd : 현재 위치를 확인하는 명령어cd .. : 상위 폴더로 올라가는 명령어rmdir : 비어 있는 폴더(디렉토리)를 제거하는 명령어프로그램 설치이제 EC2에 접속했으니 git, java, mysql을 설치해보자. 먼저 아래와 같이 리눅스 터미널에 명령어를 입력하자.sudo yum update위의 명령어의 sudo는 관리자 권한으로 실행한다는 의미이고 yum은 리눅스 패키지 관리 프로그램 (gradle과 비슷한 역할)이다. update는 현재 설치된 여러 프로그램들을 최신화한다는 의미이다.깃 설치sudo yum install gitJDK11 설치sudo yum install java-11-amazon-corretto -ymysqlsudo yum install mysql-community-server // 설치 sudo systemctl status mysqld // 현재 보이지 않는 프로그램을 관리하는 명령어 + mysql 상태 확인 sudo systemctl restart mysql // mysql 재시작 sudo cat /var/log/mysqld.log | grep “A temporary password” // mysql 임시 비밀번호 확인 mysql –u root –p // mysql 접속빌드와 실행git clone 명령어로 우리가 깃헙에 올린 프로젝트를 가져오자.git clone [github 저장소 주소]이제 빌드준비를 위해 gradlew의 권한을 변경하자chmod +x ./gradlew이제 빌드를 하자 (단, 테스트는 제외)./gradlew build –x test그럼 jar파일이 생겼을텐데, 아래와 같은 명령어로 실행시킨다.java –jar build/libs/library-app-0.0.1-SNAPSHOT.jar --spring.profiles.active=dev그럼 서버가 정상적으로 실행된다. 다음으로 서버를 중단해보자. ctrl + c를 누르면 중단된다.하지만 우리는 터미널을 닫아도 서버는 계속 실행되고 싶다. 즉, 백그라운드 재생을 하고 싶어한다. 아래와 같이 입력한다.nohup [명령어] &그러면 백그라운드로 재생된 우리의 프로그램을 어떻게 종료할까?ps aux | grep java위와 같이 현재 실행중인 프로그램 중 java가 들어가는 프로그램을 확인해서 pid값을 알아내 아래와 같이 입력한다.kill -9 프로그램번호또한 파일의 내용을 확인해 볼 수 있는 명령어도 알아보자.vi : 리눅스 편집기인 vim을 사용하여 파일을 연다.또한 vi말고도 cat 명령어도 있다.cat : 파일에 있는 내용물을 모두 출력하는 명령어또한 끝부분만 확인하고 싶을때 아래와 같이 입력한다.tail : 현재 파일의 끝 부분을 출력하는 명령어여기서 실시간으로 확인하고 싶을 경우 f옵션만 주면 된다.가비아를 이용한 도메인 구입가비아를 통해 도메인을 구입해보았다. 실제 과정은 매우 단순함으로 생략한다.Day13. SpringBoot 설정 및 버전업여기서는 내용을 조금 축약해서 작성해보겠다. 이전에 배웠던 개념이기도 하고 중요하지만 실습적인 부분은 아니기에 간략히 작성한다. 우리는 여기서 gradle의 구성에 대해 알아보았다. 이 gradle 파일안에는 플러그인 설정, 의존성 설정, 저장소 설정등을 확인할 수 있었다. 또한 스프링이 어떻게 생겨났는지 스프링부트는 또 어떻게 생겨났는지 이 둘의 차이는 무엇인지 알 수 있었다. 그리고 yaml 문법과 properties 문법에 대해 알아보았다. 다음 우리의 프로젝트에서 롬복을 적용해서 리팩토링과정도 알아보았다. 마지막으로 스프링부트 버전을 3.x로 바꾸어보았다. gradle에서 스프링부트 버전을 변경하고 빌드할때 달라진 부분들을 고쳐주는 작업을 해보았다.Day14. 마무리 및 꿀팁우리는 여기서 앞으로의 공부 방향성, AWS 비용계산방법, myBatis 적용, 정적파일 처리방법을 배웠다. 나는 여기서 느꼈던 점은 공부 방향성에 있어서 코치님 말씀대로 코틀린 및 스프링의 다양한 모듈에 대해 접근해볼 예정이다.또한 이 수업에서 myBatis를 적용해보았는데 개인적으로 내 스프링부트 버전이 myBatis starter 몇 버전을 쓰는지 복잡함을 느꼈다. 또한 여전히 문자열로 쿼리를 작성한다는게 나한테에 있어서 많은 불편함을 느꼈다. 하지만 여기서 코치님은 대용량 데이터를 insert할때 jdbcTemplate을 이용한 batch 쿼리 실습을 해주셨는데 시간차이를 보니 완전 신세계였다. 이 부분에 대해 좀 더 자세히 알아봐야겠다.미니 프로젝트나는 미니프로젝트를 개발해가면서 많은 어려움과 좌절을 맛 보았다. 1단계는 나름 간단해서 별거 없네라는 식으로 넘어갔다. 하지만 다른 러너분들과 리뷰과정에서 많이 고쳐야 할 점을 보았다. 또한 단계가 갈수록 코드가 점점 개판이 되어간다는 나 자신이 너무 싫었고 특히 마지막 단계는 밤을 꼬박 새워서 해결할 수 있었다. 공공데이터포털로 법정공휴일 api를 가져오려 했지만 이 api가 몇번 타임아웃이 발생한다. 이 문제때문에 시간을 쏟은것은 안 비밀!리뷰중에는 왜 이렇게 작성했냐부터 이렇게 바꾸는 것이 어떤가의 대해 의문점을 던져주셨고 이것을 깊이 통찰하는 시간이 나를 성장하는 계기를 만든 것 같다. 자세한 개발과정은 1단계와 똑같은 절차로 해결했으니 1단계 개발일지를 참조해보시면 좋을 것 같습니다. 개발일지https://inf.run/rF31s PR1단계: https://github.com/crispindeity/warming-up-study-mini/pull/82단계: https://github.com/crispindeity/warming-up-study-mini/pull/93단계: https://github.com/crispindeity/warming-up-study-mini/pull/134단계: https://github.com/crispindeity/warming-up-study-mini/pull/15 최종 머지한 내 프로젝트https://github.com/SungbinYang/warming-up-study-mini/tree/main/sungbin/mini회고드디어 스터디클럽의 여정이 끝났다. 정말 힘들고 출사표때 전달했던 많이 부딧혔고 깨졌다. 그러면서 나는 점점 성장을 해 나간것 같다. 비록 1달이라는 짧다면 짧은 여정이였지만 내 학습의 여정은 아직 끝이 안 났기에 계속 달려볼려고 한다. 이 클럽을 수료하더라도 혹은 1기로 다시 재 신청을 하더라도 내 본연의 학습여정은 계속 될 것이며 그 여정동안 많이 깨지고 부딪히면서 점점 성장하는 개발자 양성빈이 되어야겠다. 화이팅 🔥🔥🔥🔥 📚 참조http://www.jjal.today/bbs/board.php?bo_table=gallery&wr_id=94&sfl=wr_subject%7C%7Cwr_content%7C%7Cwr_4&stx=웃짤&sop=and&page=7

백엔드인프런워밍업스터디클럽발자국마지막

양성빈

[인프런 워밍업 스터디 클럽] 출사표 및 OT 후기

출사표 및 OT 후기 출사표현재 월드 스타인 손흥민 선수의 아버지인 손웅정 선생님께서 하신 말씀으로 글을 시작하려고 합니다.손웅정 선생님께서는 아이들을 가르치실 때 이런 말씀을 하셨습니다. "세계의 벽 절대 안 높아! 할수 있어! 남자는 자신감! 일단 붙어봐야될거아니야! 저질러보고! 깨지고! 박고! 가슴만 뛰는게 축구선수가 아니라 가슴이랑 내가 같이뛰어야돼!!!" 나는 현재 작은 중소기업의 웹 프론트엔드 업무를 맡고 있는 개발자입니다. 하지만 저의 원래 꿈은 웹 백엔드 개발자였습니다. 대학교 교수님 추천으로 이 회사에 입사하게 되었고 백엔드 업무를 맡기신다고 말씀하여 입사확정을 받았지만 정작 백엔드 팀은 존재하지 않았고 프론트 업무를 맡게 되었습니다. 이에 백엔드로 이직을 염두해두며 업무시간에는 프론트 기술들을 프로젝트에 적용하며 프론트 기술 적응을 해두었으며 집에 와서는 백엔드 공부를 계속 하였습니다. 그게 어느덧 3년이라는 시간이 흐르고 점점 제 자신에게 지칠 무렵, 인프런 배너에서 엄청난 것을 보게 되었습니다! 😲 워밍업 클럽이라는 말에 신청을 바로 하려 하였지만 상세소개 글에 걸리는 부분이 있었습니다. 🧐 '부트 캠프 참가자'라는 말에 이것을 신청해도 되는지 엄청 고민을 하였습니다. 그런던 어느날 '개발바닥' 유튜브 라방 을 보게 되었다. 마침 향로님이 계셔서 조심스레 여쭤보았고 직장인도 신청해도 된다는 말씀을 남겨주셨습니다! 나는 환호성을 외쳤고 바로 신청을 하게 되었습니다. 😆 서두에 손웅정 선생님 말씀처럼 조금 나태해진 저를 '인프런 워밍업 클럽 스터디'를 통하여 한번 저질러 보고 미션이나 강의를 들으면서 한번 깨저도 보고 가슴만 뛰는게 아닌 가슴과 제 학습곡선이 같이 뛰었으면 하는 바램으로 열심히 해보겠습니다! OT 후기전 날 '인프런 워밍업 스터디 클럽'의 커뮤니티를 가입하게 되었고 당일 온라인 라이브로 OT를 진행하게 되었습니다. 간단한 일정과 방법을 코치님이 말씀을 주셨고 간단한 자바의 역사에 대해 알려주시고 간단한 질문을 받은 뒤, 라이브는 종료되었습니다. 여기서 나는 일정이 빡빡하다는 것을 알고 평일에는 직장에 소모되다 보니 과제를 미리미리 해보자는 마음을 갖게 되었고 오늘 OT 들은 자바부터 상세히 파보기 시작했습니다. 그러면서 여러 자료를 찾다가 다른 러너분들과 공유되고 싶다는 글을 찾게 되었습니다. 이렇게 첫 시작으로 지금 마음가짐으로 끝까지 완주해서 우수러너까지 노려봤으면 좋겠습니다. 다들 응원 부탁드립니다. 🥳 📚 참고자료오라클 블로그 

백엔드인프런워밍업스터디클럽출사표발자국

[인프런 워밍업 클럽 BE] 참여 후기

이 후기글은 인프런 워밍업 클럽 0기 BE의 전체 소감문입니다.https://inf.run/Hywa 사실 후기글을 써본 경험이 거의 전무하기에, 어떻게 작성해야 하는지조차 감이 잘 잡히지 않는다. 그래서 느낀 감정들을 두서없이 그저 솔직하게 작성해볼까 한다. 참가 신청과 첫 주이번 최태현 코치님 강의는 이전에 절반만 들어놓고 반년 간 시간을 허비하며 지낸 그런 부끄러운 과거 속의 강의 중 하나였다 . 그래도 운이 좋게도 메일로 이번 워밍업 클럽 홍보 글이 날아온 것을 보았고 바로 신청했다. 나는 스스로의 실력에 확신이 차지 않으면 도전하지 못하는, 어찌보면 많이 소극적인 성격인 편이다. 그랬기에 그동안 인프런에서 팀 프로젝트를 시도해보려고 해도 개발자로서의 지식이 너무 얕은 것 같아 차마 도전해보기가 어려웠다. 이번 스터디 신청 자체가 나 나름대로의 하나의 도전이었던 셈이었다. 결과적으로는 잘한 선택이었다고 현재는 마음 깊이 생각하고 있다.디스코드에 처음 접속해 서로 인사말을 남기는데 사실 조금, 아니 상당히 당황스러웠다. 나처럼 초보자 분들이 많이 오는 것을 예상했으나 이미 현업 종사자분들도 다수 참가하시는 것을 보았다. 그때부터 발등에 불이 떨어진 기분이었다. 다른 분들의 질문의 깊이나 발자국, 과제 내용 등을 보고 압도되는 기분이었다. 😂😂그리고 대망의 첫 중간 점검 라이브 때, 일정 관리 실패로 나는 바보같이 라이브를 놓쳐버렸다. 완주 러너의 조건에 중간 점검 라이브를 모두 참가해야 한다는 조항이 있었기 때문에 일주일 만에 열심히 하겠다는 나름의 다짐이 완전히 망가졌다. 그 이후, 그리고 스터디 끝첫 라이브를 놓치고 하루는 기분이 약간 쳐지긴 했었다. 딱히 우수 러너를 노리거나 포인트를 꼭 받아야겠다! 는 아니었지만, 그래도 무언가를 완주했다는 그 성과 자체가 없어지는 것이니 뼈아픈 일이었다. (그리고 수료증을 못 받는 것도 아쉬웠고... ) 하지만 제 1의 목표는 어디까지나 학습이니 그 이후도 내 나름대로 착실히 수행했다. 완주 러너가 되지는 못하겠지만 과제도 꾸준히 제출했고 2차 점검 라이브도 모두 참석했다.이전 후기에서도 작성했듯이 미니 프로젝트 코드 리뷰도 진행해보았고 지금은 좋은 분들과 함께 사이드 프로젝트를 진행 중이다. 아직 본격적인 개발 단계 전이지만... 😊그리고 인프런과 코치님들의 배려로 완주 조건이 완화되어 무사히 완주 러너가 될 수 있었다!당연히 완주 러너가 못 될 것을 예상해 오프라인 수료식 참가 신청을 하지 않은 것은 후회하긴 했지만... 완주 러너가 된 것 만으로도 만족했다. 그리고 대망의 수료식 날, 정말 예상하지 못하게 우수 러너로 선정이 되었다. 선정해주신 코치님에게 몇 번이고 감사 인사를 드리고 싶었다. 우수 러너 혜택으로 인프콘 티켓과 코치님과의 1:1 멘토링을 얻게 되었으니 정말 너무 과분하고 감사한 보상이다. 🙇‍♀️🙇‍♀ 반성할 점과 이모저모람다 함수와 스트림 학습을 위해 새 블로그 글을 만든 것이 있는데 첫 날 이후 정말 하나도 갱신하지 못했다.😭 3/9에 SQLD 시험이 있었기에 강의, 과제, 자격증 시험 공부를 병행하면서 저 글까지 갱신하는 것은 아무래도 과한 목표였을지도 모른다. 사실 저 SQLD 시험도 진작에 공부했으면 됐겠지만... 이미 지나버렸으니 어쩔 수가 없다. 요즘은 학습한 내용을 모두 개인 노션 페이지에 정리하고 있기에 아마 추가 학습을 해도 저 블로그 글은 갱신이 안 되지 않을까 싶다.요즘은 영한님의 로드맵을 따라 쭉 강의를 듣고 있다. 사이드 프로젝트에 폐를 끼치지 않기 위해 본격적인 시작 전 최대한 기반 지식을 더 다지고 있다. 우수 러너 혜택으로 1:1 멘토링도 남아있어서 더 많은 것을 얻기 위해서라도 많은 지식을 쌓아놓아야 할 듯 하다.

백엔드스터디후기

양성빈

[인프런 워밍업 스터디 클럽] 미니프로젝트 - step1

미니 프로젝트 - 개발 일지드디어 미니프로젝트 시작이다.프로젝트 세팅언어: JDK21프레임워크: Spring Boot 3.2.3, Spring Data JPA라이브러리: 롬복DB: mysql테스트: junit5요구사항환경 설정1. profile 분리먼저 나는 프로필을 분리하기로 하였다. 개발환경 profile과 운영환경 profile 그리고 공통적인 부분을 묶어두었다. 또한 DB의 정보는 민감한 정보이므로 환경변수에 등록해두었다.application.ymlspring: profiles: group: dev: "dev, common" prod: "prod, common" active: dev --- spring: config: activate: on-profile: dev datasource: url: "jdbc:mysql://${DB_DEV_HOST}/${DB_DEV_SCHEMA}" username: ${DB_DEV_USERNAME} password: ${DB_DEV_PASSWORD} driver-class-name: com.mysql.cj.jdbc.Driver jpa: hibernate: ddl-auto: update properties: hibernate: show_sql: true format_sql: true open-in-view: false logging: level: sql: trace --- spring: config: activate: on-profile: prod --- spring: config: activate: on-profile: common2. Auditing 기능 개발다음으로 나는 spring data jpa에서 제공해주는 Auditing 기능을 먼저 이용하려고 한다. 기본 엔티티를 만들기 전에 추상클래스로 공통적인 속성들을 묶어서 만들기로 하였다.BaseDateTimeEntity.java@Getter @MappedSuperclass @EntityListeners(value = AuditingEntityListener.class) public abstract class BaseDateTimeEntity { @CreatedDate @Comment("생성 날짜") @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) @Column(nullable = false, updatable = false) private LocalDate createdAt; @LastModifiedDate @Comment("최종 수정 날짜") @Column(nullable = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) private LocalDate updatedAt; }BaseEntity.java@Getter @MappedSuperclass @EntityListeners(value = AuditingEntityListener.class) public class BaseEntity extends BaseDateTimeEntity { @CreatedBy @Comment("생성한 직원") @Column(nullable = false, updatable = false) private String createdBy; @LastModifiedBy @Comment("최종 수정한 직원") @Column(nullable = false) private String updatedBy; }먼저 위의 BaseDateTimeEntity와 같이 생성 날짜와 최종 수정 날짜를 정의하였고, BaseEntity에서는 추가적으로 생성한 직원 최종 수정한 직원 부분까지 더했다. 그 이유는 요구사항에는 BaseEntity 부분이 필요가 없겠지만 나중에 추후 확장성을 위해 사용하기로 하였다. 그리고 이를 위해 Auditing 설정 파일을 작성해주었다.AuditConfig.java@Configuration @EnableJpaAuditing public class AuditConfig { @Bean public AuditorAware<String> auditorAwareProvider() { return new AuditorAwareImpl(); } }AuditorAwareImpl.javapublic class AuditorAwareImpl implements AuditorAware<String> { @Override public Optional<String> getCurrentAuditor() { return Optional.of("tester"); } }공통 예외 부분우리가 예외를 처리하다보면 커스텀하게 예외를 던져야 할 경우가 생긴다. 그리고 예외가 던져졌을 때 에러 로그가 아니라 그에 대한 커스텀 응답을 받고 싶은 경우도 있을 것이다. 이에 따라 일련의 과정을 정리해본다.먼저 예외에 마다 특정 예외에 코드가 있다고 생각을 하였다. 그에 따른 인터페이스를 이와 같이 정의하였다.public interface ExceptionCode { HttpStatus getHttpStatus(); String getCode(); String getMessage(); }그리고 해당 인터페이스를 구현한 GlobalExceptionCode enum 클래스를 개발한다.@Getter @RequiredArgsConstructor public enum GlobalExceptionCode implements ExceptionCode { INVALID_INPUT_VALUE(HttpStatus.BAD_REQUEST, "G-001", "Invalid Input Value"), METHOD_NOT_ALLOWED(HttpStatus.METHOD_NOT_ALLOWED, "G-002", "Invalid Http Request Method"), ENTITY_NOT_FOUND(HttpStatus.NOT_FOUND, "G-003", "Resource Not Found"), INTERNAL_SERVER_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "F-001", "Server Error!"); private final HttpStatus httpStatus; private final String code; private final String message; }이제 커스텀 예외 응답 클래스를 개발하자. 이번엔 조금 디자인 패턴 중 정적 팩터리 메서드 패턴을 적용해보았다.@Getter @NoArgsConstructor(access = AccessLevel.PROTECTED) public class ExceptionResponse { private String message; private HttpStatus status; private String code; private List<ValidationException> errors; private LocalDateTime timestamp; private ExceptionResponse(final ExceptionCode exceptionCode) { this.message = exceptionCode.getMessage(); this.status = exceptionCode.getHttpStatus(); this.code = exceptionCode.getCode(); this.timestamp = LocalDateTime.now(); this.errors = new ArrayList<>(); } private ExceptionResponse(final ExceptionCode errorCode, final String message) { this.message = message; this.status = errorCode.getHttpStatus(); this.code = errorCode.getCode(); this.timestamp = LocalDateTime.now(); this.errors = new ArrayList<>(); } private ExceptionResponse(final ExceptionCode errorCode, final List<ValidationException> errors) { this.message = errorCode.getMessage(); this.status = errorCode.getHttpStatus(); this.code = errorCode.getCode(); this.timestamp = LocalDateTime.now(); this.errors = errors; } public static ExceptionResponse of(final ExceptionCode errorCode) { return new ExceptionResponse(errorCode); } public static ExceptionResponse of(final ExceptionCode errorCode, final String message) { return new ExceptionResponse(errorCode, message); } public static ExceptionResponse of(final ExceptionCode code, final BindingResult bindingResult) { return new ExceptionResponse(code, ValidationException.of(bindingResult)); } public static ExceptionResponse of(final ExceptionCode errorCode, final List<ValidationException> errors) { return new ExceptionResponse(errorCode, errors); } }다음으로 우리가 정의하지 않는 validation Exception부분도 처리해줄 필요가 있었다. 그래서 아래와 같이 커스텀하게 구성을 해보았다.@Getter @NoArgsConstructor(access = AccessLevel.PROTECTED) public class ValidationException { private String field; private String value; private String reason; private ValidationException(String field, String value, String reason) { this.field = field; this.value = value; this.reason = reason; } public static List<ValidationException> of(final String field, final String value, final String reason) { List<ValidationException> validationExceptions = new ArrayList<>(); validationExceptions.add(new ValidationException(field, value, reason)); return validationExceptions; } public static List<ValidationException> of(final BindingResult bindingResult) { final List<FieldError> validationExceptions = bindingResult.getFieldErrors(); return validationExceptions.stream() .map(error -> new ValidationException( error.getField(), error.getRejectedValue() == null ? "" : error.getRejectedValue().toString(), error.getDefaultMessage())) .collect(Collectors.toList()); } }마지막을 ExcpetionHandler를 통해 예외처리를 해두었다. 여기서 이제 커스텀 예외가 생길때 예외 클래스를 생성 후 RuntimeException을 상속받은 후에 해당 핸들러 클래스에 적용해두면 된다.@Slf4j @RestControllerAdvice public class GlobalExceptionHandler { /** * Java Bean Validation 예외 핸들링 */ @ExceptionHandler(MethodArgumentNotValidException.class) protected ResponseEntity<ExceptionResponse> handleMethodArgumentNotValidException(MethodArgumentNotValidException e) { log.error("handle MethodArgumentNotValidException"); return new ResponseEntity<>(ExceptionResponse.of(INVALID_INPUT_VALUE, e.getBindingResult()), INVALID_INPUT_VALUE.getHttpStatus()); } /** * EntityNotFound 예외 핸들링 */ @ExceptionHandler(EntityNotFoundException.class) protected ResponseEntity<ExceptionResponse> handleEntityNotFoundException(EntityNotFoundException e) { log.error("handle EntityNotFoundException"); return new ResponseEntity<>( ExceptionResponse.of(ENTITY_NOT_FOUND, e.getMessage()), ENTITY_NOT_FOUND.getHttpStatus()); } /** * 유효하지 않은 클라이언트의 요청 값 예외 처리 */ @ExceptionHandler(IllegalArgumentException.class) protected ResponseEntity<ExceptionResponse> handleIllegalArgumentException(IllegalArgumentException e) { log.error("handle IllegalArgumentException"); return new ResponseEntity<>( ExceptionResponse.of(INVALID_INPUT_VALUE, e.getMessage()), INVALID_INPUT_VALUE.getHttpStatus() ); } @ExceptionHandler(TeamAlreadyExistsException.class) protected ResponseEntity<ExceptionResponse> handleTeamAlreadyExistsException(TeamAlreadyExistsException e) { log.error("handle TeamAlreadyExistsException"); return new ResponseEntity<>( ExceptionResponse.of(INVALID_INPUT_VALUE, e.getMessage()), INVALID_INPUT_VALUE.getHttpStatus() ); } /** * 잘못된 HTTP Method 요청 예외 처리 */ @ExceptionHandler(HttpRequestMethodNotSupportedException.class) protected ResponseEntity<ExceptionResponse> handleHttpRequestMethodNotSupportedException(HttpRequestMethodNotSupportedException e) { log.error("handle HttpRequestMethodNotSupportedException"); return new ResponseEntity<>( ExceptionResponse.of(METHOD_NOT_ALLOWED), METHOD_NOT_ALLOWED.getHttpStatus() ); } /** * 잘못된 타입 변환 예외 처리 */ @ExceptionHandler(BindException.class) protected ResponseEntity<ExceptionResponse> handleBindException(BindException e) { log.error("handle BindException"); return new ResponseEntity<>( ExceptionResponse.of(INVALID_INPUT_VALUE, e.getBindingResult()), INVALID_INPUT_VALUE.getHttpStatus() ); } /** * 모든 예외를 처리 * 웬만해서 여기까지 오면 안됨 */ @ExceptionHandler(Exception.class) protected ResponseEntity<ExceptionResponse> handleException(Exception e) { log.error("handle Exception", e); return new ResponseEntity<>( ExceptionResponse.of(INTERNAL_SERVER_ERROR), INTERNAL_SERVER_ERROR.getHttpStatus() ); } }주요기능팀 등록 기능🤔 고려해볼 점1. 팀을 등록할 수 있어야 한다.2. 팀 이름이 null이거나 공란으로 요청이 갈 경우 예외처리3. 만약 이미 존재하는 팀이라면 예외를 던진다.요청 DTOspring boot starter validation을 통하여 요청 필드에 대하여 validation 처리DTO를 엔티티화 하는 로직부분을 해당 DTO안에 구현public record RegisterTeamRequestDto( @NotBlank(message = "이름은 공란일 수 없습니다.") @NotNull(message = "이름은 null일 수 없습니다.") String name ) { public Team toEntity() { return Team.builder() .name(name) .build(); } }서비스 레이어별 다른 것은 없고 insert 쿼리가 날려주는 작업으로 메서드에 트랜잭션 어노테이션을 붙여주었다.private 메서드로 해당 팀이 이미 존재하는지 확인하는 validation을 추가하였다. @Service @RequiredArgsConstructor @Transactional(readOnly = true) public class TeamService { private final TeamRepository teamRepository; @Transactional public void registerTeam(RegistrationTeamRequestDto requestDto) { validateTeam(requestDto); Team team = requestDto.toEntity(); this.teamRepository.save(team); } /** * 팀 유효성 검사 * @param requestDto */ private void validateTeam(RegistrationTeamRequestDto requestDto) { if (this.teamRepository.existsByName(requestDto.name())) { throw new TeamAlreadyExistsException("이미 존재하는 팀 이름입니다."); } } }요청으로 온 DTO의 이름으로 유효한 팀인지 검사 후, 해당 DTO를 엔티티로 변환하고 저장시킨다.컨트롤러 레이어package me.sungbin.domain.team.controller; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; import me.sungbin.domain.team.model.request.RegistrationTeamRequestDto; import me.sungbin.domain.team.model.response.TeamInfoResponseDto; import me.sungbin.domain.team.service.TeamService; import org.springframework.web.bind.annotation.*; import java.util.List; /** * @author : rovert * @packageName : me.sungbin.domain.team.controller * @fileName : TeamController * @date : 3/1/24 * @description : * =========================================================== * DATE AUTHOR NOTE * ----------------------------------------------------------- * 3/1/24 rovert 최초 생성 */ @RestController @RequestMapping("/api/team") @RequiredArgsConstructor public class TeamController { private final TeamService teamService; @PostMapping("/register") public void registerTeam(@RequestBody @Valid RegistrationTeamRequestDto requestDto) { this.teamService.registerTeam(requestDto); } }  테스트 결과성공(포스트맨)실패(이미 중복된 팀 이름)실패 (팀 이름이 공란이거나 null)테스트 코드package me.sungbin.domain.team.controller; import me.sungbin.domain.team.entity.Team; import me.sungbin.domain.team.model.request.RegistrationTeamRequestDto; import me.sungbin.domain.team.repository.TeamRepository; import me.sungbin.global.common.controller.BaseControllerTest; import me.sungbin.global.exception.GlobalExceptionCode; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.http.MediaType; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; /** * @author : rovert * @packageName : me.sungbin.domain.team.controller * @fileName : TeamControllerTest * @date : 3/1/24 * @description : * =========================================================== * DATE AUTHOR NOTE * ----------------------------------------------------------- * 3/1/24 rovert 최초 생성 */ class TeamControllerTest extends BaseControllerTest { @Autowired private TeamRepository teamRepository; @BeforeEach void setup() { Team team = new Team("개발팀"); this.teamRepository.save(team); } @Test @DisplayName("팀 등록 테스트 - 실패 (팀 이름이 공란)") void register_team_test_fail_caused_by_team_name_is_empty() throws Exception { RegistrationTeamRequestDto requestDto = new RegistrationTeamRequestDto(""); this.mockMvc.perform(post("/api/team/register") .contentType(MediaType.APPLICATION_JSON) .accept(MediaType.APPLICATION_JSON) .content(objectMapper.writeValueAsString(requestDto))) .andDo(print()) .andExpect(status().isBadRequest()) .andExpect(jsonPath("message").exists()) .andExpect(jsonPath("status").value(GlobalExceptionCode.INVALID_INPUT_VALUE.getHttpStatus().name())) .andExpect(jsonPath("code").value(GlobalExceptionCode.INVALID_INPUT_VALUE.getCode())) .andExpect(jsonPath("errors").exists()) .andExpect(jsonPath("errors").isNotEmpty()) .andExpect(jsonPath("timestamp").exists()); } @Test @DisplayName("팀 등록 테스트 - 실패 (이미 존재하는 팀)") void register_team_test_fail_caused_by_already_exists_team() throws Exception { RegistrationTeamRequestDto requestDto = new RegistrationTeamRequestDto("개발팀"); this.mockMvc.perform(post("/api/team/register") .contentType(MediaType.APPLICATION_JSON) .accept(MediaType.APPLICATION_JSON) .content(objectMapper.writeValueAsString(requestDto))) .andDo(print()) .andExpect(status().isBadRequest()) .andExpect(jsonPath("message").exists()) .andExpect(jsonPath("status").value(GlobalExceptionCode.INVALID_INPUT_VALUE.getHttpStatus().name())) .andExpect(jsonPath("code").value(GlobalExceptionCode.INVALID_INPUT_VALUE.getCode())) .andExpect(jsonPath("errors").exists()) .andExpect(jsonPath("errors").isEmpty()) .andExpect(jsonPath("timestamp").exists()); } @Test @DisplayName("팀 등록 테스트 - 성공") void register_team_test_success() throws Exception { RegistrationTeamRequestDto requestDto = new RegistrationTeamRequestDto("디자인팀"); this.mockMvc.perform(post("/api/team/register") .contentType(MediaType.APPLICATION_JSON) .accept(MediaType.APPLICATION_JSON) .content(objectMapper.writeValueAsString(requestDto))) .andDo(print()) .andExpect(status().isOk()); } @Test @DisplayName("팀 정보 조회 테스트 - 성공") void find_team_info_test_success() throws Exception { this.mockMvc.perform(get("/api/team") .contentType(MediaType.APPLICATION_JSON) .accept(MediaType.APPLICATION_JSON)) .andDo(print()) .andExpect(status().isOk()); } }기본으로 사용하는 어노테이션들을 아래의 어노테이션으로 묶음package me.sungbin.global.common.annotation; import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; import org.springframework.boot.test.context.SpringBootTest; import org.springframework.test.context.ActiveProfiles; import org.springframework.transaction.annotation.Transactional; import java.lang.annotation.ElementType; import java.lang.annotation.Retention; import java.lang.annotation.RetentionPolicy; import java.lang.annotation.Target; /** * @author : rovert * @packageName : me.sungbin.global.annotation * @fileName : IntegrationTest * @date : 3/1/24 * @description : * =========================================================== * DATE AUTHOR NOTE * ----------------------------------------------------------- * 3/1/24 rovert 최초 생성 */ @Transactional @SpringBootTest @AutoConfigureMockMvc @ActiveProfiles("test") @Target(ElementType.TYPE) @Retention(RetentionPolicy.RUNTIME) public @interface IntegrationTest { }이 어노테이션을 BaseControllerTest라는 클래스에 선언@Disabled @IntegrationTest public class BaseControllerTest { @Autowired protected MockMvc mockMvc; @Autowired protected ObjectMapper objectMapper; }추가적으로 test 디렉터리에도 resources 디렉터리 생성 후 해당 경로에 application.yml을 생성후 테스트 profile active시켜두었다.spring: profiles: active: test --- spring: config: activate: on-profile: test datasource: url: "jdbc:h2:mem:commutedb" username: sa password: driver-class-name: org.h2.Driver jpa: properties: hibernate: show_sql: true format_sql: true open-in-view: false threads: virtual: enabled: true직원 등록 기능🤔 고려점1. 직원을 먼저 생성한다. (필수 값들은 공란일 수 없음)2. 해당 직원을 팀에 등록 시킨다. (단, 등록할 직원이 매니저인 경우 해당 팀의 매니저가 없어야 한다.)3. 등록하려는 팀이 존재해야 한다.주요 코드를 보자. 먼저 연관관계 매핑을 해야한다.package me.sungbin.domain.employee.entity; import jakarta.persistence.*; import lombok.*; import me.sungbin.domain.employee.type.Role; import me.sungbin.domain.team.entity.Team; import me.sungbin.global.common.entity.BaseDateTimeEntity; import org.hibernate.annotations.Comment; import java.time.LocalDate; /** * @author : rovert * @packageName : me.sungbin.domain.member.entity * @fileName : Member * @date : 3/1/24 * @description : * =========================================================== * DATE AUTHOR NOTE * ----------------------------------------------------------- * 3/1/24 rovert 최초 생성 */ @Entity @Getter @EqualsAndHashCode(of = "id", callSuper = false) @NoArgsConstructor(access = AccessLevel.PROTECTED) @AttributeOverrides({ @AttributeOverride(name = "createdAt", column = @Column(name = "work_start_date", nullable = false, updatable = false)), @AttributeOverride(name = "updatedAt", column = @Column(name = "updated_at", nullable = false)) }) public class Employee extends BaseDateTimeEntity { @Id @Comment("직원 테이블 PK") @Column(name = "employee_id") @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; @Comment("직원 이름") @Column(name = "employee_name", nullable = false) private String name; @Comment("팀의 매니저인지 아닌지 여부") @Column(nullable = false) private boolean isManager; @Column(nullable = false) private LocalDate birthday; @Builder public Employee(String name, boolean isManager, LocalDate birthday) { this.name = name; this.isManager = isManager; this.birthday = birthday; } @ManyToOne(fetch = FetchType.LAZY) private Team team; public void updateTeam(Team team) { this.team = team; } public String getTeamName() { return this.team.getName(); } public String getRole() { return isManager ? Role.MANAGER.name() : Role.MEMBER.name(); } } package me.sungbin.domain.team.entity; import jakarta.persistence.*; import lombok.*; import me.sungbin.domain.employee.entity.Employee; import me.sungbin.global.common.entity.BaseDateTimeEntity; import org.hibernate.annotations.Comment; import java.util.ArrayList; import java.util.List; /** * @author : rovert * @packageName : me.sungbin.domain.team.entity * @fileName : Team * @date : 3/1/24 * @description : * =========================================================== * DATE AUTHOR NOTE * ----------------------------------------------------------- * 3/1/24 rovert 최초 생성 */ @Entity @Getter @EqualsAndHashCode(of = "id", callSuper = false) @NoArgsConstructor(access = AccessLevel.PROTECTED) @AttributeOverrides({ @AttributeOverride(name = "createdAt", column = @Column(name = "created_at", nullable = false, updatable = false)), @AttributeOverride(name = "updatedAt", column = @Column(name = "updated_at", nullable = false)) }) public class Team extends BaseDateTimeEntity { @Id @Comment("팀 테이블 PK") @Column(name = "team_id") @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; @Comment("팀 이름") @Column(name = "team_name", nullable = false, unique = true) private String name; @OneToMany(mappedBy = "team") private List<Employee> employees = new ArrayList<>(); @Builder public Team(String name) { this.name = name; } public void addEmployee(Employee employee) { this.employees.add(employee); employee.updateTeam(this); } public String getManagerName() { return employees.stream() .filter(Employee::isManager) .map(Employee::getName) .findFirst() .orElse(null); } public boolean hasManager() { return this.employees.stream().anyMatch(Employee::isManager); } public int getEmployeeCount() { return employees != null ? employees.size() : 0; } }위와 같이 연관관계 매핑을 해준다. 여기서 Employee의 getRole부분의 메서드의 Role은 enum타입으로 아래와 같이 되어 있다.package me.sungbin.domain.employee.type; import lombok.Getter; import lombok.RequiredArgsConstructor; import me.sungbin.global.common.type.EnumType; /** * @author : rovert * @packageName : me.sungbin.domain.member.type * @fileName : Role * @date : 3/1/24 * @description : * =========================================================== * DATE AUTHOR NOTE * ----------------------------------------------------------- * 3/1/24 rovert 최초 생성 */ @Getter @RequiredArgsConstructor public enum Role implements EnumType { MEMBER("MEMBER", "팀원"), MANAGER("MANAGER", "매니저"); private final String name; private final String description; }위의 코드를 보면 EnumType이라는 인터페이스가 있는데 그 안에는 아래와 같다.package me.sungbin.global.common.type; /** * @author : rovert * @packageName : me.sungbin.global.common.type * @fileName : EnumType * @date : 3/1/24 * @description : * =========================================================== * DATE AUTHOR NOTE * ----------------------------------------------------------- * 3/1/24 rovert 최초 생성 */ public interface EnumType { String name(); String getDescription(); }이렇게 한 이유는 나중의 확장성 때문에 구현을 해둔 것이다.@Transactional public void registerEmployee(RegistrationEmployeeRequestDto requestDto) { Employee employee = requestDto.toEntity(); Team team = this.teamRepository.findByName(requestDto.teamName()).orElseThrow(TeamNotFoundException::new); // 매니저가 이미 존재하는 경우 예외 발생 if (employee.isManager() && team.hasManager()) { throw new AlreadyExistsManagerException("이미 매니저가 해당 팀에 존재합니다."); } this.employeeRepository.save(employee); team.addEmployee(employee); this.teamRepository.save(team); }그리고 위와 같이 서비스 로직을 작성해준다. 해당 로직은 dto로부터 엔티티화 시키고 요청한 팀의 이름으로 팀이 존재하는지 찾는다.만약 없으면 예외를, 있다면 해당 팀에 매니저가 존재하는지 유무도 추가해두었다. 이미 있다면 예외를 없다면 해당 직원을 저장시킨다. 컨트롤러 레이어package me.sungbin.domain.employee.controller; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; import me.sungbin.domain.employee.model.request.EmployeesInfoResponseDto; import me.sungbin.domain.employee.model.request.RegistrationEmployeeRequestDto; import me.sungbin.domain.employee.service.EmployeeService; import org.springframework.web.bind.annotation.*; import java.util.List; /** * @author : rovert * @packageName : me.sungbin.domain.member.controller * @fileName : EmployeeController * @date : 3/1/24 * @description : * =========================================================== * DATE AUTHOR NOTE * ----------------------------------------------------------- * 3/1/24 rovert 최초 생성 */ @RestController @RequiredArgsConstructor @RequestMapping("/api/employee") public class EmployeeController { private final EmployeeService employeeService; @PostMapping("/register") public void registerEmployee(@RequestBody @Valid RegistrationEmployeeRequestDto requestDto) { this.employeeService.registerEmployee(requestDto); } }  테스트성공실패 (존재하는 팀이 없음)실패(이미 그 팀에 매니저가 있음)테스트코드class EmployeeControllerTest extends BaseControllerTest { @Autowired private TeamRepository teamRepository; @Autowired private EmployeeRepository employeeRepository; @BeforeEach void setup() { Team team = new Team("개발팀"); this.teamRepository.save(team); } @Test @DisplayName("직원 등록 테스트 - 실패 (잘못된 입력 값)") void register_employee_test_fail_caused_by_wrong_input() throws Exception { RegistrationEmployeeRequestDto requestDto = new RegistrationEmployeeRequestDto("", "", false, LocalDate.of(1996, 5, 22)); this.mockMvc.perform(post("/api/employee/register") .contentType(MediaType.APPLICATION_JSON) .accept(MediaType.APPLICATION_JSON) .content(objectMapper.writeValueAsString(requestDto))) .andDo(print()) .andExpect(status().isBadRequest()) .andExpect(jsonPath("message").exists()) .andExpect(jsonPath("status").value(GlobalExceptionCode.INVALID_INPUT_VALUE.getHttpStatus().name())) .andExpect(jsonPath("code").value(GlobalExceptionCode.INVALID_INPUT_VALUE.getCode())) .andExpect(jsonPath("errors").exists()) .andExpect(jsonPath("errors").isNotEmpty()) .andExpect(jsonPath("timestamp").exists()); } @Test @DisplayName("직원 등록 테스트 - 실패 (존재하지 않는 팀에 등록)") void register_employee_test_fail_caused_by_register_not_exists_team() throws Exception { RegistrationEmployeeRequestDto requestDto = new RegistrationEmployeeRequestDto("장그래", "영업팀", false, LocalDate.of(1992, 2, 22)); this.mockMvc.perform(post("/api/employee/register") .contentType(MediaType.APPLICATION_JSON) .accept(MediaType.APPLICATION_JSON) .content(objectMapper.writeValueAsString(requestDto))) .andDo(print()) .andExpect(status().isBadRequest()) .andExpect(jsonPath("message").exists()) .andExpect(jsonPath("status").value(GlobalExceptionCode.INVALID_INPUT_VALUE.getHttpStatus().name())) .andExpect(jsonPath("code").value(GlobalExceptionCode.INVALID_INPUT_VALUE.getCode())) .andExpect(jsonPath("errors").exists()) .andExpect(jsonPath("errors").isEmpty()) .andExpect(jsonPath("timestamp").exists()); } @Test @DisplayName("직원 등록 테스트 - 성공") void register_employee_test_success() throws Exception { RegistrationEmployeeRequestDto requestDto = new RegistrationEmployeeRequestDto("양성빈", "개발팀", false, LocalDate.of(1996, 5, 22)); this.mockMvc.perform(post("/api/employee/register") .contentType(MediaType.APPLICATION_JSON) .accept(MediaType.APPLICATION_JSON) .content(objectMapper.writeValueAsString(requestDto))) .andDo(print()) .andExpect(status().isOk()); } }팀 조회 기능서비스 레이어public List<TeamInfoResponseDto> findTeamInfo() { List<Team> teams = this.teamRepository.findAll(); return teams.stream().map(TeamInfoResponseDto::new).toList(); }해당 팀들을 findAll로 select한 이후로 응답 DTO로 매핑해준다.아래는 포스트맨 테스트 결과다.이제 테스트 코드를 살펴보자.@Test @DisplayName("팀 정보 조회 테스트 - 성공") void find_team_info_test_success() throws Exception { this.mockMvc.perform(get("/api/team") .contentType(MediaType.APPLICATION_JSON) .accept(MediaType.APPLICATION_JSON)) .andDo(print()) .andExpect(status().isOk()); }직원 조회 기능서비스 레이어public List<EmployeesInfoResponseDto> findEmployeesInfo() { List<Employee> employees = this.employeeRepository.findAll(); return employees.stream().map(EmployeesInfoResponseDto::new).toList(); }전체 직원을 select하여 stream 객체를 이용하여 응답 DTO와 매핑해주었다. 아래는 테스트 결과다.아래는 테스트 코드다.@Test @DisplayName("직원 정보 조회 테스트 - 성공") void find_employees_info_test_success() throws Exception { this.mockMvc.perform(get("/api/employee") .contentType(MediaType.APPLICATION_JSON) .accept(MediaType.APPLICATION_JSON)) .andDo(print()) .andExpect(status().isOk()); }회고1단계는 이제까지 우리가 배운 개념들로 충분히 개발 할 수 있는 것들이였다. 하지만 나는 여기서 더 나아가서 좀 더 예외상황을 생각해보고 더 발전시키도록 노력했다. 그리고 또한 다른 러너분들과 코드리뷰를 통해 내 코드를 리팩토링 해가면서 뭔가 실력이 점점 쌓여만 가는 것 같았다. 

백엔드인프런워밍업스터디클럽미니프로젝트

양성빈

[인프런 워밍업 스터디 클럽] 0기 두번째 발자국 (2 week)

발자국어느덧 인프런 워밍업 스터디 클럽을 시작한지도 2주째가 시작된다. 그리고 이번주 1주에 대한 회고를 시작해보려고 한다.이번주도 여러가지를 배우고 많은 경험이 된 한 주였다. 그럼 회고를 시작하겠다. 완주 및 우수러너를 위해 오늘도 달려본다.강의 요약Day 6. 스프링 컨테이너의 의미와 사용방법📖 UserController와 스프링 컨테이너상식적으로 static이 아닌 코드를 사용하려면 객체화(인스턴스화)가 필수적이다. 하지만 이전 학습의 UserController부분을 확인해보면 의아한 부분이 존재한다.private final UserService userService; public UserController(JdbcTemplate jdbcTemplate) { this.userService = new UserService(jdbcTemplate); }이렇게 UserService는 UserController 생성자 부분에서 인스턴스화를 하였지만, 정작 UserController부분은 인스턴스화를 해주지 않았지만 잘 작동하는 것을 알 수 있었다. 이로 인해 아래의 의문점이 남아진다.🙋🏻 그럼 누가 UserController부분을 인스턴스화 시켜준다는건데 누가 그런 걸 해주나요?또한 위의 코드에서 또 하나의 의문점이 남는다.🙋🏻 그리고 나는 JdbcTemplate 클래스를 따로 만져준 적이 없는데 UserController 클래스는 어떻게 이 클래스를 가져올 수 있을까요?바로 @RestController라는 어노테이션때문이다. 우리는 앞전에 @RestController라는 어노테이션이 API 진입점이라고 배웠다. 하지만 이 @RestController는 진입점의 역할과 더불어 UserController 클래스를 스프링 빈으로 등록을 시켜준다.🙋🏻 그럼 스프링 빈이 뭐에요? 빈은 영어니까 번역하면 콩인것 같은데 그럼 스프링 콩인가요?위의 질문이 나는 자연스럽게 떠올랐다. 그럼 정확히 스프링 빈이 무엇인지 알아보자.🫛 스프링 빈우리가 스프링 부트로 만든 프로젝트를 동작시키면, 우리가 만든 서버가 동작을 하는 것이다. 그러면 이 서버 내부에 거대한 컨테이너를 만들어준다. 그리고 컨테이너 안에는 빈으로 등록시킨 클래스 정보(이름, 타입)가 들어간다. 그리고 이 클래스를 인스턴스화 시켜준다. 이 때, 들어간 클래스를 스프링 빈이라고 부른다.🙋🏻 그런데 여기서 위의 코드를 보면 UserController를 인스턴스화할려면 JdbcTemplate가 필요하지 않나요?요놈은 어디서 가져오는 거에요?사실, JdbcTemplate 클래스도 빈으로 등록된 클래스이다.🙋🏻 그럼, 누가 JdbcTempalte을 인스턴스화 시켜줬어요?바로 build.gradle에 dependencies에 등록한 spring boot starter data jpa라는 것이 JdbcTemplate을 등록시켜줬다.dependencies { implementation 'org.springframework.boot:spring-boot-starter-data-jpa' }그래서 인텔리제이로 UserController 생성자 부분을 보면 책모양으로 아이콘이 있는데 이것이 빈으로 등록되었다는 의미이다.즉, 결론을 내보면 우리가 가져온 Dependency가 JdbcTemplate을 빈으로 등록시켜준다는 의미이다.그러면 여기서 또 하나 결론이 나온다. 스프링 컨테이너 안에 우리가 작성한 스프링 빈으로 등록한 클래스는 이 컨테이너 안에 들어가게 된다. 또한 필요한 의존성이 자동 설정된다.그럼 여기서 의문점이 든다. 우리가 이전에 작성한 UserRepository 클래스와 UserService 클래스도 JdbcTemplate의 의존성이 필요하고 이 JdbcTemplate을 가져오려면 이 두개의 클래스도 빈으로 등록이 되어있어야 한다. 하지만 이 2개의 클래스는 빈으로 등록되지 않았다. 인텔리제이 화면만 봐도 책 모양 아이콘이 존재하지 않는다. 그럼 2개의 클래스를 빈으로 등록시키자! 🫛 Repository와 Service 빈 등록시키기 & Controller 클래스 변경두개의 클래스를 빈으로 등록시키는 방법은 정말 간단하다. Repository 클래스는 @Repository 어노테이션을 클래스 위에 붙여주고, Service 클래스는 @Service 어노테이션을 클래스 위에 붙여주면 빈으로 등록이 된다. 그리고 Controller부분을 수정해본다. 그럼 아래와 같이 변경될 것이다. 코드는 일부만 표기하겠다.UserController.java@RestController public class UserController { private final UserService userService; public UserController(UserService userService) { this.userService = userService; } ... UserService.java@Service public class UserService { private final UserRepository userRepository; public UserService(UserRepository userRepository) { this.userRepository = userRepository; } ... UserRepository.java@Repository public class UserRepository { private final JdbcTemplate jdbcTemplate; public UserRepository(JdbcTemplate jdbcTemplate) { this.jdbcTemplate = jdbcTemplate; } ... 📚 정리그러면 한번 정리해보자. 스프링 서버가 시작이 되면 의존성에 의해 빈으로 등록된 JdbcTemplate이 스프링 컨테이너로 들어간다. 그리고 이 JdbcTemplate의 의존성을 가진 UserRepository가 빈으로 등록된다. 그러면 UserRepository를 의존하는 UserService가 빈으로 등록된다. 그리고 UserService를 의존하는 UserController가 빈으로 등록된다.그런데 아래의 의문점이 든다.🤔 아니 뭐가 좋아진거야? 그냥 new 연산자로 객체생성하면 안되는건가? 스프링 컨테이너 왜 쓰는데?이 의문점은 다음 강의에서 해소가 되었다.📖 스프링 컨테이너를 왜 사용할까?만약 아래의 요구사항이 있다고 하자.책 이름을 저장하는 API를 구현하라. 단, 이름 저장은 메모리에 저장시킨다.우리는 그럼 열심히 비즈니스 로직을 만들 것이다. 먼저 Book 객체부터 만들고, BookController, BookService, BookMemoryRepository를 만들 것이다. 그리고 BookMemoryRepository를 BookService는 아래와 같이 객체를 생성할 것이다.그런데 이렇게 열심히 만들고나니 추가 요구사항이 생겼다.public class BookService { private final BookMemoryRepository repository = new BookMemoryRepository(); } 생각해보니, 메모리가 아닌 MySQL과 같은 RDB에 저장시켜야해! 그리고 JdbcTemplate은 Repository가 바로 설정할 수 있다 하자.그러면 BookMySQLRepository를 만들고 BookService에 BookMemoryRepository가 아닌 BookMySQLRepository를 인스턴스화 해줘야 한다.public class BookService { private final BookMySQLRepository repository = new BookMySQLRepository(); }이런 과정을 하면서 우리는 불편함을 느꼈을 것이다. 우리는 repository의 기능적인 역할만 변경하였는데 서비스 코드까지 변경해야하는 경우가 생긴 것이다. 지금은 몇개 안되지만, 이 repository를 쓰는 서비스 코드가 수백개 클래스에 있다면 바로 오늘 야근을 해야하고 야근 신청서를 올려야 한다.🥲그러면 이런 야근을 피하기 위해서 repository를 변경하더라도 서비스 클래스는 변경을 안하는 방법은 없을까? 그래서 생각을 한 것이 java의 interface를 이용하는 방법이다. BookRepository라는 인터페이스를 만들고 BookMemoryRepository와 BookMySQLRepository를 구현하면 되는 것이다. 그러면 서비스 코드는 이런 식으로 변경 될 것이다.public class BookService { private final BookRepository repository = new BookMySQLRepository(); }하지만 그래도 서비스 코드는 repository 역할 변경에 다라 수정이 되긴 해야한다. 바로 new 연산자의 부분을 전부 변경해야 하기 때문이다. 또 야근 당첨이다 🥲 그러면 이걸 또 해결할 수 있는 방법은 없을까? 바로 스프링 컨테이너가 그래서 등장하였다.스프링 컨테이너가 BookService 대신 repository를 인스턴스화 해주고 그때 그때 알아서 어떤 repository 클래스를 쓸지 결정을 해줄 수 있다. 이런 방식을 제어의 역전(IoC, Inversion of Control)이라고 한다. 그리고 컨테이너가 repository 클래스를 선택해서 서브스 레이어에 넣어주는 과정의 의존성 주입(DI, Dependency Injection)라고 한다.그러면 어떤 Repository를 주입시켜줄까? 그것은 우리가 @Primary 어노테이션을 활용해 조절할 수 있다.@Primary: 우선권을 결정하는 어노테이션📖 스프링 컨테이너를 다루는 방법@Configuration: 클래스에 붙여주는 어노테이션, @Bean 어노테이션과 같이 사용@Bean: 보통은 메서드 위에 붙으며, 해당 메서드에서 반환되는 객체를 스프링 빈으로 등록시켜준다.그리고 아래의 의문사항이 든다. 그러면 우리가 이전에 @Service, @Repository 어노테이션을 붙여줬는데 이 어노테이션은 언제 사용해야할까? 위의 @Configuration + @Bean 어노테이션을 쓰면 안될까?요약하자면 다음과 같다.@Service나 @Repository 어노테이션은 개발자가 직접 만든 클래스를 빈으로 등록시키고 싶을 때 사용하며,@Configuration + @Bean 어노테이션은 외부 라이브러리나 프레임워크에서 만든 클래스를 등록시킬때 사용한다.다음으로 살펴 볼 어노테이션은 @Component 어노테이션이다.@Component: 주어진 클래스를 컴포넌트로 간주하며, 이 클래스들은 스프링 서버가 시작할 때 자동감지한다.@Component 어노테이션 덕분에 우리가 사용했던 어노테이션들이 감지가 된것이다.Service.javapackage org.springframework.stereotype; import java.lang.annotation.Documented; import java.lang.annotation.ElementType; import java.lang.annotation.Retention; import java.lang.annotation.RetentionPolicy; import java.lang.annotation.Target; import org.springframework.core.annotation.AliasFor; @Target({ElementType.TYPE}) @Retention(RetentionPolicy.RUNTIME) @Documented @Component public @interface Service { @AliasFor( annotation = Component.class ) String value() default ""; }Repository.javapackage org.springframework.stereotype; import java.lang.annotation.Documented; import java.lang.annotation.ElementType; import java.lang.annotation.Retention; import java.lang.annotation.RetentionPolicy; import java.lang.annotation.Target; import org.springframework.core.annotation.AliasFor; @Target({ElementType.TYPE}) @Retention(RetentionPolicy.RUNTIME) @Documented @Component public @interface Repository { @AliasFor( annotation = Component.class ) String value() default ""; }이렇게 각 어노테이션들의 내부구조를 보면 이렇게 @Component 어노테이션이 들어가져 있다.그럼 @Component 어노테이션은 언제 사용할까?컨트롤러, 서비스, 리포지토리가 모두 아니고 개발자가 직접 작성한 클래스를 스프링 빈으로 등록할 때 사용한다.🫛 빈 주입 받는 방법빈을 주입받는 방법은 3가지가 존재한다.생성자를 이용한 주입방법 (권장)setter와 @Autowired -> 누군가 setter를 사용하면서 오작동 가능성이 존재private final JdbcTemplate jdbcTemplate; @Autowired public void setJdbcTemplate(JdbcTemplate jdbcTemplate) { this.jdbcTemplate = jdbcTemplate; }필드에 직접 @Autowired 사용 -> 테스트 어려움@Autowired private JdbcTemplate jdbcTemplate;마지막으로 @Qualifier 어노테이션을 알아보자. @Primary와 유사하다.스프링 빈을 사용하는 쪽, 스프링 빈을 등록하는 쪽 모두 @Qualifier를 사용할 수 있다!스프링 빈을 사용하는 쪽에서만 쓰면, 빈의 이름을 적어주어야 한다. 양쪽 모두 사용하면, @Qualifier 끼리 연결된다!@Service @Qualifier("main") public class BananaService implements FruitService { }@RestController public class UserController { private final UserService userService; private final FruitService fruitService; public UserController(UserService userService, @Qualifier("main") FruitService fruitService) { this.userService = userService; this.fruitService = fruitService; }그러면 @Qualifier와 @Primary 어노테이션중에 누가 우선순위가 높을까?사용하는 쪽에서 직접 적어준 @Qualifier가 이긴다!📚섹션3 정리클린코드가 무엇이고, 우리의 코드를 레이어 아키텍쳐로 분리도 해보며, 스프링 컨테이너가 무엇이고 스프링 빈이 무엇인지 이해를 하며 어떤 어노테이션을 통해 주입을 받고 빈으로 등록할 수 있는지 알아보았다. Day7. Spring Data JPA를 사용한 데이터베이스 조작📖 문자열 SQL을 직접 사용하는 것이 너무 어렵다!!우리는 현재 레이어드 아키텍쳐로 코드를 작성하였고, 해당 빈들을 스프링 컨테이너가 관리를 하였고 포스트맨을 통하여 API를 호출하였다. 또한 repository 레이어로 mysql과 통신을 하였다. 그런데 repository 레이어에서는 DB 쿼리를 문자열로 작성하였다. 하지만 이렇게 문자열로 작성하면 아래와 같은 문제가 있을 수 있다.문자열로 작성 시, 오타가 날 수 있는 실수가 있다. 하지만 이 실수는 컴파일 타임에 발견되지 않고 런타임에 발견되는 안 좋은 점이 있다. 그래서 어플리케이션 운영 시점에 해당 API를 사용 시, 에러를 확인할 수 있기에 엄청 치명적이다.특정 DB에 종속적이다. 만약 우리가 MySQL을 쓰다가 어느 이유로 DB를 변경하게 된다면 해당 쿼리들을 변경하는 DB 쿼리 문법에 맞게 수정해줘야한다. 마이그레이션도 일이지만 해당 쿼리를 다 고쳐야한다면 야근 당첨일 것이다. 🥲반복 작업이 많아진다. 보통 테이블당 기본적으로 CRUD 쿼리를 작성해줘야 하는데, 단순 반복작업들이 이어질 수 있다.데이터베이스 테이블과 객체의 패러다임이 다르다. 쉽게 생각해서 연관관계 매칭을 할 때 양방향 매핑을 할 때 연관관계 갖는 테이블 A는 B를 가리키고 B또한 A클래스를 가리킬 수 있지만 실제 테이블은 한쪽만 가리키게 된다. 또한 상속개념은 자바는 존재하지만 DB는 상속개념을 구현하기 매우 힘들다. 그래서 JPA라는 것이 등장하였다. JPA는 ORM의 일종인데 이 두 용어를 살펴보면 아래와 같다.JPA(Java Persistence API) : 자바 영속성 API그럼 영속성은 무엇일까? 우리는 이전에 메모리에 회원 정보를 저장하는 코드를 작성했지만 이런 코드는 서버를 재부팅하면 데이터는 날라간다. 그 이유는 RAM에 데이터가 저장되기 때문이다. 그런데 영속성은 서버가 재부팅되어도 데이터는 영구적으로 저장되는 속성을 의미한다.그리고 API는 일종의 규칙이다. 그래서 이것을 풀어써보면 아래와 같다.JPA란, 데이터를 영구적으로 보관하기 위해 자바 진영에서 정해진 규칙을 뜻한다.그러면 ORM은 무엇일까? 자바코드와 DB의 테이블을 짝 지어준다는 의미이다.📚 요약 (JPA란?)객체와 관계형 DB의 테이블을 짝지어 데이터를 영구적으로 저장할 수 있도록 정해진 자바 진영의 규칙을 뜻한다.그런데 JPA를 검색해보면 연관검색으로 Hibernate가 나온다. 이 Hibernate란 무엇일까?JPA는 쉽게 규칙이라고 하였다. 이 규칙을 구현한 구현체가 Hibernate이다. 또한 Hibernate은 내부적으로 JDBC를 사용한다. 📖 유저 테이블에 대응되는 Entity Class 만들기이제 실제로 유저 테이블과 유저 클래스를 매핑시켜보자. 이를 위해선 어노테이션 @Entity를 붙여줘야 한다.🙋🏻 Entity란?저장되고 관리되어야 하는 데이터를 의미한다.유저 테이블은 위와 같이 구성되어 있다. 먼저, id를 primary key로 설정되어 있고 auto_increment가 적용되어 있다. 이것을 자바 코드에 적용하려면 @Id와 @GeneratedValue(strategy=GenerationType.IDENTY)를 설정해줘야 한다. 그렇게 적용한 코드는 아래와 같다.@Entity public class User { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id = null; .... } DB의 종류마다 자동 생성 전략이 다르다!우리는 MySQL의 auto_increment를 사용했고, 이는 IDENTITY 전략과 매칭된다. JPA를 사용하기 위해 기본 생성자가 반드시 필요하다.다음으로 name 부분을 짝 지어줘야 한다.이를 위해서 @Column 어노테이션을 통해 매핑해줘야 한다.@Column(nullable = false, length = 20, name = "name") private String name;여기서 nullable = false는 이 속성은 null이 불가능하다는 의미이며, length = 20은 DB로 보면 varchar(20)을 의미한다.또한 name = "name"은 이 속성은 테이블의 name 필드와 매핑시키겠다는 의미이다.⚠ 참고참고로 name은 필드이름과 동일할 경우 생략이 가능하다.그리고 이런 nullable, length등 이런 속성을 기본으로 쓸 때 @Column 어노테이션 자체를 생략이 가능하다.이제 application.yml로 JPA 설정을 해줘야 한다. jpa: hibernate: ddl-auto: none properties: hibernate: show_sql: true format_sql: true dialect: org.hibernate.dialect.MySQLDialectddl-auto: 스프링이 시작할 때 DB에 있는 테이블을 어떻게 처리할지create : 기존 테이블이 있다면 삭제 후 다시 생성create-drop : 스프링이 종료될 때 테이블을 모두 제거update : 객체와 테이블이 다른 부분만 변경validate : 객체와 테이블이 동일한지 확인none : 별다른 조치를 하지 않는다.show_sql: JPA를 사용해 DB에 SQL을 날릴 때 SQL을 보여줄 것인가format_sql: SQL을 보여줄 때 예쁘게 포맷팅 할 것인가, 여기서 예쁘게는 뭔가를 꾸미는게 아니라 우리가 쉽게 볼 수 있게 포맷팅을 해준다는 것이다.dialect: 방언(사투리), 이 옵션으로 DB를 특정하면 조금씩 다른 SQL을 수정해준다.⚠ 주의강좌에서는 방언 설정을 할 때 org.hibernate.dialect.MySQLDialect를 org.hibernate.dialect.MySQL8Dialect로 하셨다. 하지만 최근에 org.hibernate.dialect.MySQL8Dialect가 deprecated가 되었다는 warning이 발생한다. 그리고 org.hibernate.dialect.MySQLDialect로 변경하라고 써져있다.📖 Spring Data JPA를 이용해 자동으로 쿼리 날리기우리는 이제 직접 sql을 작성해주지 않고 JPA를 이용하여 유저의 생성/조회/업데이트 기능을 리팩토링할 것이다.먼저 아래와 같이 Repository 인터페이스를 만들어준다.public interface UserRepository extends JpaRepository<User, Long> { }그리고 서비스 코드에서 해당 UserRepository로 의존성 주입을 한다.다음으로 생성 부분 메서드를 만들어보자.public void saveUser(UserCreateRequest request) { this.userRepository.save(new User(request.getName(), request.getAge())); }여기서 save 메서드는 JpaRepository를 상속받은 Repository에 정의되어 있지 않지만 사용이 가능하다. 그 이유는 Spring Data JPA에서 기본으로 제공해주는 저장 로직이 담긴 로직이다. 해당 메서드를 실행하면 insert 쿼리가 날라간다.다음으로 조회 부분 메서드를 보자.public List<UserResponse> getUsers() { return this.userRepository.findAll() .stream().map(UserResponse::new) .collect(Collectors.toList()); }여기서 findAll 메서드도 기본으로 제공한다. 이 메서드의 반환은 List형태이다. 이 메서드를 실행하면 select * ~ 쿼리가 날라간다.다음으로 업데이트 기능으로 보자. 업데이트는 유저가 존재하는지 확인하고 있다면 update쿼리를 아니면 예외를 날린다.public void updateUser(UserUpdateRequest request) { User user = this.userRepository.findById(request.getId()).orElseThrow(IllegalArgumentException::new); user.updateName(request.getName()); this.userRepository.save(user); }먼저 findById라는 메서드를 호출한다. 이 메서드는 기본으로 제공해주는 메서드로 해당 메서드는 select * from user where id = ?의 쿼리를 날려준다. 이 메서드의 반환타입은 1개의 데이터를 가져오기 때문에 객체 단일 타입으로 반환된다. 여기선 User가 반환된다. 그리고 updateName이라는 메서드를 엔티티에 만들어준다. 이 메서드는 단순 setter의 역할이다. 마지막으로 setter로 속성 변경을 한 후 save로 저장을 시킨다.그럼 여기서 이렇게 메서드를 통해 쿼리 작성없이 쿼리가 날라갈 수 있는 이유는 JPA가 아닌 Spring Data JPA 때문이다.Spring Data JPA: 복잡한 JPA 코드를 스프링과 함께 쉽게 사용할 수 있도록 도와주는 라이브러리즉, 전체적인 구조를 보면 Spring Data JPA가 JPA라는 규칙을 사용하는데 이 규칙은 Hibernate가 이 규칙을 구현했고 Hibernate는 구현할때 JDBC를 사용한다고 볼 수 있다. 📖 Spring Data JPA를 이용해 다양한 쿼리 작성하기이제 삭제 기능을 Spring Data JPA로 변경해보자. 먼저 삭제는 요청으로 들어온 유저의 이름이 존재하는지 확인하고 있다면 삭제쿼리를 날리고 아니면 예외를 날린다.public void deleteUser(String name) { User user = this.userRepository.findByName(name).orElseThrow(IllegalArgumentException::new); this.userRepository.delete(user); }여기서 나온게 findByName과 delete 메서드이다. findByName은 기본으로 제공해준 메서드가 아니고 우리가 인터페이스에 정의를 해야한다.public interface UserRepository extends JpaRepository<User, Long> { Optional<User> findByName(String name); }이런식으로 정의를 하면 이 메서드를 사용할때 select * from user where name = ? 쿼리가 나간다.다음으로 delete 메서드는 기본으로 제공해주는 메서드이다. 이 메서드를 사용하면 delete SQL이 나간다.이제 구체적으로 findByName처럼 우리가 일정 규칙에 맞게 인터페이스에 정의를 하면 쿼리들을 제공해주는데 그 규칙들을 살펴보자.find : 1건을 가져온다. 반환 타입은 객체가 될 수도 있고, Optional<타입>이 될 수도 있다.findAll : 쿼리의 결과물이 N개인 경우 사용. List<타입> 반환.exists : 쿼리 결과가 존재하는지 확인. 반환 타입은boolean count : SQL의 결과 개수를 센다. 반환 타입은 long이다.이제 By뒤에 규칙을 알아볼 텐데 By뒤에는 where 조건을 적어주는 것처럼 적어주면 된다. 조건이 여러개일 경우 And 혹은 or 조건을 통해 규칙을 정해준다.🏛 예시List<User> findAllByNameAndAge() : select * from user where name = ? and age = ?그외에 아래와 같이 다양한 조건들을 붙일 수 있다.📚 By 뒤에 조건GreaterThan : 초과GreaterThanEqual : 이상LessThan : 미만LessThanEqual : 이하Between : 사이에StartsWith : ~로 시작하는EndsWith : ~로 끝나는 Day8. 트랜잭션과 영속성 컨텍스트📖 트랜잭션 이론편트랜잭션이란 무엇일까? 트랜잭션 말만 들어봤지 이게 정확히 무슨 의미인지 알지 못했다. 트랜잭션은 아래와 같이 말한다.트랜잭션: 쪼갤 수 없는 업무의 최소 단위 = 모두 성공시키거나, 모두 실패시킨다.상황을 살펴보자.쇼핑몰이 있다고 하자. 어떤 회원이 주문을 하는 상황을 생각해보자. 주문을 하면 주문내역이 저장되고 포인트가 저장되고 결제기록이 저장될 것이다. 이 비즈니스 로직은 하나의 메서드로 묶여 있다. 그러다가 어떠한 이유로 결제기록의 비즈니스 로직에서 에러가 발생했다고 하자. 그러면 주문내역과 포인트는 있는데 결제되었다는 사실이 없을 것이다. 이런 경우 특정 비즈니스 로직에 에러가 발생할 경우 모든 SQL을 실패시켜야 할 것이다. 물론 모두 성공할 경우 성공시켜야 할 것이다. 이것을 트랜잭션이 해결해준다.DB 쿼리로 트랜잭션 시작을 알리는 쿼리는 아래와 같다.start transaction;트랜잭션 정상 종료는 아래와 같다.commit;트랜잭션 실패 처리는 아래와 같다.rollback;이 실습을 통해 알게 된 점은 트랜잭션 안에 저장/업데이트/삭제 쿼리가 발생해도 commit 전까지 반영이 안 된다는 점이다. 📖트랜잭션 적용과 영속성 컨텍스트Spring Data JPA에서 트랜잭션 적용은 @Transactional 어노테이션으로 해결할 수 있다. 이 어노테이션은 서비스 레이어의 저장/업데이트/삭제 로직에 붙일 수 있다. 조회로직에는 @Transactional(readOnly = true)로 쓸 수 있다.그리고 강좌에서 아래와 같이 말씀하셨다.⚠ 주의CheckedException은 롤백이 일어나지 않는다.하지만 이 점이 궁금해서 알아본 결과 아래와 같다.RuntimeException이든 CheckedException이든 rollback을 할지 말지는 우리가 결정할 수 있다. 바로 @Transactional의 rollbackFor이라는 옵션을 통해서다. 다만, 기본적으로는 CheckedExcpetion은 rollback을 하지 않고 RuntimeExcpetion은 rollback을 해준다. 이점을 명심하자.영속성 컨텍스트는 테이블과 매핑된 Entity 객체를 관리/보관하는 역할을 한다. 즉, 쉽게 말해서 스프링에서는 트랜잭션을 사용하면 영속성 컨텍스트가 생겨나고, 트랜잭션이 종료되면 영속성 컨텍스트가 종료된다.영속성 컨텍스트에는 마치 초능력자처럼 능력을 몇가지 가지고 있다.변경감지(Dirty Check): 영속성 컨텍스트 안에서 불러와진 Entity는 명시적으로 save하지 않더라도, 변경을 감지해 자동으로 저장된다. 그래서 이전에 업데이트 로직에서 마지막에 save로직으로 저장을 했는데 @Transactional 어노테이션이 붙으면 아래와 같이 작성이 가능하다.@Transactional public void updateUser(UserUpdateRequest request) { User user = this.userRepository.findById(request.getId()).orElseThrow(IllegalArgumentException::new); user.updateName(request.getName()); }쓰기 지연: DB의 INSERT / UPDATE / DELETE SQL을 바로 날리는 것이 아니라, 트랜잭션이 commit될 때 모아서 한 번만 날린다. 이런 기능이 없다면 save 메서드가 3개가 있을 때 insert 쿼리를 일일이 3번 날리는게 아니라 일단 영속성 컨텍스트가 기억하고 한번에 날려준다.1차 캐싱: 똑같은 객체를 조회하는 로직이 있을 때 조회하는 만큼 일일이 조회쿼리를 날려주는게 아니라 처음에 영속성 컨텍스트가 해당 객체를 캐싱하고 다음 같은 객체 조회를 할때 이를 기억하고 한번의 쿼리만 날라간다.Day9. 조금 더 복잡한 기능을 API로 구성하기이번에는 실전으로 책을 생성하고 대출하고 반납하는 기능을 만들었다. 여기서 이제까지 배운 개념들을 적용했다. 물론 코치님께서 알려주시긴 하지만 나는 강좌를 멈추고 내 스스로 코드를 작성해본 다음에 코치님 설명과 비교를 했다. 여기서 대출기능을 할 때 나는 연관관계를 매핑해서 처리를 할려고 했지만 코치님께서는 일단은 대출관련 테이블을 만든 뒤에 그에 대한 엔티티, repository, service를 만드셨다. 그래서 나는 여기서 조금 깨달은 부분이 있었다. 무조건 연관관계를 짓는게 아니라 만약 실무에서 연관관계를 짓는게 불가하다면 이런 경우로 풀수도 있다는 사실을 깨달았다.미션 해결과정Day6이번 미션은 과제4에서 만들었던 Fruit관련 API를 3단분리하고, FruitRepository를 인터페이스로 만들고 해당 인터페이스를 구현한 FruitMemoryRepository와 FruitMysqlRepository를 만들어 @Primary 어노테이션을 통해 repository의 역할을 바꿔가며 해보는 과제였다.나는 먼저 기존 컨트롤러에 모여있는 비즈니스 로직을 저장, 수정, 조회기능은 repository레이어에 그리고 예외처리관련은 서비스 레이어에 분리하였다. 그리고 컨트롤러는 순수 HTTP 통신 관련만 구현해두었다. 그런 다음에 DB로직 관련 repository 클래스를 FruitMysqlRepository로 변경하고 FruitRepository 인터페이스를 생성 후 구현하고 나머지 FruitMemoryRepository를 생성하여 메모리 관련 로직을 작성해두었다. 다음 각각 클래스에 @Primary 어노테이션을 붙이고 각각 메서드에 Logback을 이용해 로그를 찍으면서 확인을 했다. 이를 통해 학습의 효과를 느낄 수 있었다. 학(강의 시청)으로 개념을 배우고 습(실습을 통한 체득)으로 체득을 함으로 좀 더 익숙하게 쓸 수 있게 되는 계기가 된 것 같다. 자세한 것은 아래 블로그를 통해 보시면 자세한 과정을 알 수 있다.https://inf.run/3EWwN피드백피드백 전까지 테스트코드도 나름 잘 작성하고 validation부분까지 잘 작성해서 나름 이번은 성공적이라고 느꼈다. 하지만 코치님께서 피드백을 주셨다. 서비스의 비즈니스 로직이 복잡할 때는 다른 내부 서비스 로직을 호출하기도 하지만 DTO와 도메인에 계산로직과 비즈니스 로직을 나눠서 넣기도 한다고 하였다. 내 코드를 보니 뭔가 DTO에도 처리할만한 부분이 있지 않았을 까 반성하게 되는 계기 된 것같다. Day7이번 미션은 과제6에서 만든 기능들을 JPA로 변경하는 부분이 있었다. 또한 다양한 쿼리메서드를 연습해볼 기회로 문제를 몇개 주셨다. 먼저 문제1에서 Spring Data JPA로 바꾸는 것은 그리 어려운 작업은 아니었던 것 같았다. 단순히 repository 인터페이스를 JpaRepository에 상속받고 엔티티를 연습했던것처럼 바꿔주면 되기 때문이다. 하지만 나는 여기서 더 나아갔다. 집계함수 부분을 Spring Data JPA로 변경할 때 좀 고민이 있었다. 집계함수를 제공해주는 쿼리메서드는 없었던 것 같았다. 그래서 집계함수를 이용하지 않고 select 쿼리를 이용해서 List<엔티티> 타입으로 반환해야하나 생각을 하던 결과 문듯 아이디어가 떠올랐다. 바로 @Query와 jpql이다. 그래서 나는 여기서 @Query 어노테이션을 이용하여 JPQL로 쿼리를 작성해보았다. 그리고 반환을 엔티티타입이 아닌 DTO로 반환해보았다. 그러니 서비스 레이어도 간단해졌다.그렇게 쉽게 바꿔서 문제1은 가볍게 해결했다. 그리고 문제2를 풀면서 다양한 쿼리 메서드를 테스트할 수 있었다. 먼저 count~로 시작하는 메서드를 만들어 count 쿼리를 작성할 수 있었다.마지막 문제3도 GreaterThanEqual, LessThanEqual의 조건을 이용하는 쿼리메서드를 작성하는 거였다.이번 미션도 테스트를 작성해보고 이번엔 진짜 잘했다고 느꼈다. JPQL을 통해 DTO로 직접 반환하는 부분까지 완벽했다고 자만했다. 하지만 피드백을 듣고 아직 많이 부족하다는 것을 느꼈다.피드백마지막 문제의 parameter GTE, LTE 부분을 enum 클래스로 관리할 수 있다고 하셨다. 이 말을 본 순간 "앗~"이라는 말이 절로 나왔다. enum을 아예 몰랐던것도 아니고 조금 반성하게 된 계기였다. 금방 과제가 끝났다고 끝까지 고민을 못해본 결과였다. Day8 ~Day8부터 미니프로젝트 과제이다. 아직은 미니프로젝트 미완성이므로, 해당 프로젝트가 단계별 완성시, 새로운 포스트로 남기겠다.회고오늘까지 나는 학습을 하면서 많은 것을 깨달았다. 물론 지식도 지식이지만 하나의 문제를 풀 때 수학처럼 다양한 방식으로 푸는 방법에 대해 깨달음을 얼었다. 무조건 좋은 방법으로 풀 수 없는 경우 우회를 해서 푸는 방식으로도 할 수 있다는 것을 알고 나 자신 스스로 반성하는 부분을 가졌다. 마음속으로 "이렇게 해서 우수러너가 될 수 있으며 원하는 기업으로 이직을 할 수 있을까?"라는 반성의 시간을 가지고 다른 열심히 하시는 러너분들을 생각해 더욱 자극을 받아서 우수러너가 되기까지 노력해보기로 생각을 하였다. 

백엔드인프런워밍업스터디클럽백엔드

양성빈

[인프런 워밍업 스터디 클럽] 0기 백엔드 미션 - JPA 테스트 (Day7)

미션어느덧 스터디 클럽 7일차가 되었다. 오늘은 이전에 JDBC를 이용한 서비스 로직을 JPA로 변경해보는 실습을 가졌다.그럼 이제 미션을 수행해보자.진도표 7일차와 연결됩니다우리는 JPA라는 개념을 배우고 유저 테이블에 JPA를 적용해 보았습니다. 몇 가지 문제를 통해 JPA를 연습해 봅시다! 🔥문제 1문제1의 요구사항은 과제6에서 만들었던 기능들을 JPA로 구현하라고 하셨다. 따라서 강의에서 코치님께서 보여주신 과정으로 진행해보려고 한다. step0. application.yml jpa 설정 추가spring: datasource: url: "jdbc:mysql://localhost/fruit" username: "root" password: "" driver-class-name: com.mysql.cj.jdbc.Driver jpa: hibernate: ddl-auto: none properties: hibernate: show_sql: true format_sql: true dialect: org.hibernate.dialect.MySQL8Dialectstep1. Fruit Entity를 JPA Entity화 하기!package me.sungbin.entity.fruit; import jakarta.persistence.*; import org.hibernate.annotations.Comment; import java.time.LocalDate; /** * @author : rovert * @packageName : me.sungbin.entity.fruit * @fileName : Fruit * @date : 2/26/24 * @description : * =========================================================== * DATE AUTHOR NOTE * ----------------------------------------------------------- * 2/26/24 rovert 최초 생성 */ @Entity public class Fruit { @Id @Comment("Fruit 테이블의 Primary key") @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; @Column(nullable = false) private String name; @Column(name = "warehousingDate", nullable = false) private LocalDate warehousingDate; @Column(nullable = false) private Long price; @Column(nullable = false) private boolean isSold = false; public Fruit() { } public Fruit(String name, LocalDate warehousingDate, Long price) { this.name = name; this.warehousingDate = warehousingDate; this.price = price; } public Fruit(String name, LocalDate warehousingDate, Long price, boolean isSold) { this.name = name; this.warehousingDate = warehousingDate; this.price = price; this.isSold = isSold; } public Fruit(Long id, String name, LocalDate warehousingDate, Long price, boolean isSold) { this.id = id; this.name = name; this.warehousingDate = warehousingDate; this.price = price; this.isSold = isSold; } public Long getId() { return id; } public String getName() { return name; } public LocalDate getWarehousingDate() { return warehousingDate; } public Long getPrice() { return price; } public boolean isSold() { return isSold; } public void updateSoldInfo(boolean isSold) { this.isSold = isSold; } } Entity 어노테이션을 붙여서 엔티티로 만들고 기본 primary key와 auto_increment를 설정한다.그 외에, 컬럼들의 null 여부도 설정하였다.또한 warehousingDate의 필드에 컬럼 이름을 다시 넣은 이유는 mysql 쿼리가 동작할 때 warehousingDate로 컬럼이 인식이 안되고 warehousing_date로 인식을 하기 때문에 name 필드를 넣었다.step2. JpaRepository를 상속받은 인터페이스 생성기존의 FruitRepository 인터페이스를 FruitJdbcRepository로 파일명을 변경한 후, FruitRepository 클래스를 만든다.FruitJPARepository로 만들어도 상관은 없지만, 통상적으로 편하게 FruitRepository로 해주는 것이다.package me.sungbin.repository; import me.sungbin.entity.fruit.Fruit; import org.springframework.data.jpa.repository.JpaRepository; /** * @author : rovert * @packageName : me.sungbin.repository * @fileName : FruitRepository * @date : 2/26/24 * @description : * =========================================================== * DATE AUTHOR NOTE * ----------------------------------------------------------- * 2/26/24 rovert 최초 생성 */ public interface FruitRepository extends JpaRepository<Fruit, Long> { } step3. DTO 코드 변경package me.sungbin.dto.fruit.request; import jakarta.validation.constraints.Min; import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotNull; import me.sungbin.entity.fruit.Fruit; import org.springframework.format.annotation.DateTimeFormat; import java.time.LocalDate; /** * @author : rovert * @packageName : me.sungbin.dto.fruit.request * @fileName : SaveFruitInfoRequestDto * @date : 2/26/24 * @description : * =========================================================== * DATE AUTHOR NOTE * ----------------------------------------------------------- * 2/26/24 rovert 최초 생성 */ public class SaveFruitInfoRequestDto { @NotBlank(message = "과일 이름이 공란일 수 없습니다.") @NotNull(message = "과일 이름이 null일 수는 없습니다.") private String name; @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) private LocalDate warehousingDate; @Min(value = 0, message = "가격이 음수가 될 수는 없습니다.") private long price; public SaveFruitInfoRequestDto(String name, LocalDate warehousingDate, long price) { this.name = name; this.warehousingDate = warehousingDate; this.price = price; } public String getName() { return name; } public LocalDate getWarehousingDate() { return warehousingDate; } public long getPrice() { return price; } public Fruit toEntity() { return new Fruit(name, warehousingDate, price, false); } }과일 정보를 저장할 때, toEntity() 메서드에 Fruit 생성자의 마지막애 false를 추가하였다. 왜냐하면 DTO에서 엔티티로 변경을 할 때 판매유무를 확실히 미판매로 해두려고 하기 때문이다. step4. 서비스 로직 수정기존 FruitService를 FruitJdbcService로 변경하고 FruitService를 새로 만든다.일단 먼저 서비스 코드를 전체 보여주겠다. package me.sungbin.service; import me.sungbin.dto.fruit.request.SaveFruitInfoRequestDto; import me.sungbin.dto.fruit.request.UpdateFruitRequestDto; import me.sungbin.dto.fruit.response.GetFruitResponseDto; import me.sungbin.entity.fruit.Fruit; import me.sungbin.repository.FruitRepository; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; /** * @author : rovert * @packageName : me.sungbin.service * @fileName : FruitService * @date : 2/26/24 * @description : * =========================================================== * DATE AUTHOR NOTE * ----------------------------------------------------------- * 2/26/24 rovert 최초 생성 */ @Service @Transactional(readOnly = true) public class FruitService { private final FruitRepository fruitRepository; public FruitService(FruitRepository fruitRepository) { this.fruitRepository = fruitRepository; } /** * 문제 1번 (과제6 문제 1) * @param requestDto */ @Transactional public void saveFruitInfo(SaveFruitInfoRequestDto requestDto) { Fruit fruit = requestDto.toEntity(); this.fruitRepository.save(fruit); } /** * 문제 1번 (과제 6 문제 2) * @param requestDto */ @Transactional public void updateFruitInfo(UpdateFruitRequestDto requestDto) { Fruit fruit = this.fruitRepository.findById(requestDto.getId()).orElseThrow(IllegalArgumentException::new); fruit.updateSoldInfo(true); } /** * 문제 1번 (과제 6 문제 3) * @param name * @return */ public GetFruitResponseDto getFruitInfo(String name) { GetFruitResponseDto fruitData = this.fruitRepository.getFruitSalesInfo(name); validateGetFruitAmount(fruitData.getSalesAmount(), fruitData.getNotSalesAmount()); return fruitData; } private void validateGetFruitAmount(long salesAmount, long notSalesAmount) { if (salesAmount == 0L && notSalesAmount == 0L) { throw new IllegalArgumentException("존재하는 과일이 없습니다."); } } }위의 코드를 보면 나머지는 대략 이해는 되는데 getFruitInfo의 getFruitSalesInfo 메서드는 처음 볼 것이다. 우리가 배운 범위에서getFruitSalesInfo는 data jpa에서 기본으로 제공해주는 함수는 아니기 때문이다. 바로 이것은 repository에 @Query 어노테이션과 사용자 정의 JPQL 쿼리를 사용하였다. 그 이유는 집계함수로 인하여 불기파 사용하였다. 아래는 수정된 repository 코드이다.package me.sungbin.repository; import me.sungbin.dto.fruit.response.GetFruitResponseDto; import me.sungbin.entity.fruit.Fruit; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; import org.springframework.transaction.annotation.Transactional; /** * @author : rovert * @packageName : me.sungbin.repository * @fileName : FruitRepository * @date : 2/26/24 * @description : * =========================================================== * DATE AUTHOR NOTE * ----------------------------------------------------------- * 2/26/24 rovert 최초 생성 */ @Transactional(readOnly = true) public interface FruitRepository extends JpaRepository<Fruit, Long> { @Query("SELECT new me.sungbin.dto.fruit.response.GetFruitResponseDto(SUM(case when f.isSold = true then f.price else 0 end), SUM(case when f.isSold = false then f.price else 0 end)) FROM Fruit f WHERE f.name = :name") GetFruitResponseDto getFruitSalesInfo(@Param("name") String name); // 과제 6 문제 3번 } step5. 테스트이제 위의 변경된 코드를 가지고 테스트를 해서 검증해보자. fruit 테이블을 조회하면 아래처럼 비어있다고 하자.생성 테스트그리고 몇개의 데이터를 만들고 테이블에 잘 insert 되었는지 확인해보았다.수정합산 조회현재 데이터의 테이블이 아래와 같다고 하자.그러면 테스트 해보자.step6. 테스트 코드이전과 같은 아래의 테스트코드로 실행 해보았다.package me.sungbin.controller.fruit; import com.fasterxml.jackson.databind.ObjectMapper; import me.sungbin.dto.fruit.request.SaveFruitInfoRequestDto; import me.sungbin.dto.fruit.request.UpdateFruitRequestDto; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; import org.springframework.boot.test.context.SpringBootTest; import org.springframework.http.MediaType; import org.springframework.test.web.servlet.MockMvc; import org.springframework.transaction.annotation.Transactional; import java.time.LocalDate; import static org.junit.jupiter.api.Assertions.*; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; /** * @author : rovert * @packageName : me.sungbin.controller.fruit * @fileName : FruitControllerTest * @date : 2/26/24 * @description : * =========================================================== * DATE AUTHOR NOTE * ----------------------------------------------------------- * 2/26/24 rovert 최초 생성 */ @Transactional @SpringBootTest @AutoConfigureMockMvc class FruitControllerTest { @Autowired private MockMvc mockMvc; @Autowired private ObjectMapper objectMapper; @Test @DisplayName("문제 1번 통합 테스트 - 실패 (가격이 음수거나 과일 이름이 공란)") void question1_test_fail_caused_by_price_is_minus_or_fruit_name_is_empty() throws Exception { SaveFruitInfoRequestDto requestDto = new SaveFruitInfoRequestDto("", LocalDate.of(2024, 1, 1), -1000); this.mockMvc.perform(post("/api/v1/fruit") .contentType(MediaType.APPLICATION_JSON) .content(objectMapper.writeValueAsString(requestDto))) .andDo(print()) .andExpect(status().isBadRequest()); } @Test @DisplayName("문제 1번 통합 테스트 - 성공") void question1_test_success() throws Exception { SaveFruitInfoRequestDto requestDto = new SaveFruitInfoRequestDto("파인애플", LocalDate.of(2024, 2, 2), 20000); this.mockMvc.perform(post("/api/v1/fruit") .contentType(MediaType.APPLICATION_JSON) .content(objectMapper.writeValueAsString(requestDto))) .andDo(print()) .andExpect(status().isOk()); } @Test @DisplayName("문제 2번 통합 테스트 - 성공") void question2_test_success() throws Exception { UpdateFruitRequestDto requestDto = new UpdateFruitRequestDto(1); this.mockMvc.perform(put("/api/v1/fruit") .contentType(MediaType.APPLICATION_JSON) .content(objectMapper.writeValueAsString(requestDto))) .andDo(print()) .andExpect(status().isOk()); } @Test @DisplayName("문제 3번 통합 테스트 - 성공") void question3_test_success() throws Exception { this.mockMvc.perform(get("/api/v1/fruit/stat") .param("name", "사과")) .andDo(print()) .andExpect(status().isOk()); } }문제 2요구사항은 우리가게를 거쳐갔던 과일의 개수를 구하는 문제이다. 여기서 의도는 거쳐갔던이므로 판매가 되었던 것중의 과일의 이름을 카운트해보겠다. step0. 응답 DTO 생성 package me.sungbin.dto.fruit.response; /** * @author : rovert * @packageName : me.sungbin.dto.fruit.response * @fileName : CountFruitNameResponseDto * @date : 2/26/24 * @description : * =========================================================== * DATE AUTHOR NOTE * ----------------------------------------------------------- * 2/26/24 rovert 최초 생성 */ public class CountFruitNameResponseDto { private final long count; public CountFruitNameResponseDto(long count) { this.count = count; } public long getCount() { return count; } }step1. 레파지토리에 jpa 메서드 선언package me.sungbin.repository; import me.sungbin.dto.fruit.response.GetFruitResponseDto; import me.sungbin.entity.fruit.Fruit; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; import org.springframework.transaction.annotation.Transactional; /** * @author : rovert * @packageName : me.sungbin.repository * @fileName : FruitRepository * @date : 2/26/24 * @description : * =========================================================== * DATE AUTHOR NOTE * ----------------------------------------------------------- * 2/26/24 rovert 최초 생성 */ @Transactional(readOnly = true) public interface FruitRepository extends JpaRepository<Fruit, Long> { @Query("SELECT new me.sungbin.dto.fruit.response.GetFruitResponseDto(SUM(case when f.isSold = true then f.price else 0 end), SUM(case when f.isSold = false then f.price else 0 end)) FROM Fruit f WHERE f.name = :name") GetFruitResponseDto getFruitSalesInfo(@Param("name") String name); // 과제 6 문제 3번 long countByNameAndIsSoldIsTrue(String name); } countByNameAndIsSoldIsTrue 메서드가 방금 작성한 코드이다.step2. 서비스 코드 작성package me.sungbin.service; import me.sungbin.dto.fruit.request.SaveFruitInfoRequestDto; import me.sungbin.dto.fruit.request.UpdateFruitRequestDto; import me.sungbin.dto.fruit.response.CountFruitNameResponseDto; import me.sungbin.dto.fruit.response.GetFruitResponseDto; import me.sungbin.entity.fruit.Fruit; import me.sungbin.repository.FruitRepository; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; /** * @author : rovert * @packageName : me.sungbin.service * @fileName : FruitService * @date : 2/26/24 * @description : * =========================================================== * DATE AUTHOR NOTE * ----------------------------------------------------------- * 2/26/24 rovert 최초 생성 */ @Service @Transactional(readOnly = true) public class FruitService { private final FruitRepository fruitRepository; public FruitService(FruitRepository fruitRepository) { this.fruitRepository = fruitRepository; } /** * 문제 1번 (과제6 문제 1) * @param requestDto */ @Transactional public void saveFruitInfo(SaveFruitInfoRequestDto requestDto) { Fruit fruit = requestDto.toEntity(); this.fruitRepository.save(fruit); } /** * 문제 1번 (과제 6 문제 2) * @param requestDto */ @Transactional public void updateFruitInfo(UpdateFruitRequestDto requestDto) { Fruit fruit = this.fruitRepository.findById(requestDto.getId()).orElseThrow(IllegalArgumentException::new); fruit.updateSoldInfo(true); this.fruitRepository.save(fruit); } /** * 문제 1번 (과제 6 문제 3) * @param name * @return */ public GetFruitResponseDto getFruitInfo(String name) { GetFruitResponseDto fruitData = this.fruitRepository.getFruitSalesInfo(name); validateGetFruitAmount(fruitData.getSalesAmount(), fruitData.getNotSalesAmount()); return fruitData; } /** * 문제 2번 * @param name * @return */ public CountFruitNameResponseDto countFruitName(String name) { long count = this.fruitRepository.countByNameAndIsSoldIsTrue(name); return new CountFruitNameResponseDto(count); } private void validateGetFruitAmount(long salesAmount, long notSalesAmount) { if (salesAmount == 0L && notSalesAmount == 0L) { throw new IllegalArgumentException("존재하는 과일이 없습니다."); } } } countFruitName 메서드가 내가 방금 작성한 메서드이다.step3. 컨트롤러 코드 작성package me.sungbin.controller.fruit; import jakarta.validation.Valid; import me.sungbin.dto.fruit.request.SaveFruitInfoRequestDto; import me.sungbin.dto.fruit.request.UpdateFruitRequestDto; import me.sungbin.dto.fruit.response.CountFruitNameResponseDto; import me.sungbin.dto.fruit.response.GetFruitResponseDto; import me.sungbin.service.FruitService; import org.springframework.web.bind.annotation.*; /** * @author : rovert * @packageName : me.sungbin.controller.fruit * @fileName : FruitController * @date : 2/26/24 * @description : * =========================================================== * DATE AUTHOR NOTE * ----------------------------------------------------------- * 2/26/24 rovert 최초 생성 */ @RestController @RequestMapping("/api/v1/fruit") public class FruitController { private final FruitService fruitService; public FruitController(FruitService fruitService) { this.fruitService = fruitService; } @PostMapping public void saveFruitInfo(@RequestBody @Valid SaveFruitInfoRequestDto requestDto) { this.fruitService.saveFruitInfo(requestDto); } @PutMapping public void updateFruitInfo(@RequestBody UpdateFruitRequestDto requestDto) { this.fruitService.updateFruitInfo(requestDto); } @GetMapping("/stat") public GetFruitResponseDto getFruitInfo(@RequestParam String name) { return this.fruitService.getFruitInfo(name); } @GetMapping("/count") public CountFruitNameResponseDto countFruitName(@RequestParam String name) { return this.fruitService.countFruitName(name); } }countFruitName이 방금 작성한 컨트롤러 코드이다.step4. 테스트아래와 같이 DB 데이터가 있다고 하자. 그리고 포스트맨으로 테스트해보자.step5. 테스트 코드@Test @DisplayName("과제 7번 문제 2번 통합 테스트 - 실패 (파라미터 존재 X)") void lesson7_question2_fail_test_caused_by_not_exists_request_parameter() throws Exception { this.mockMvc.perform(get("/api/v1/fruit/count")) .andDo(print()) .andExpect(status().isBadRequest()); } @Test @DisplayName("과제 7번 문제 2번 통합 테스트 - 성공") void lesson7_question2_test_success() throws Exception { this.mockMvc.perform(get("/api/v1/fruit/count") .param("name", "사과")) .andDo(print()) .andExpect(status().isOk()); }이번에는 실패 케이스와 성공 케이스 2개를 작성했으며 결과는 아래와 같다.문제 3문제 3은 아직 판매되지 않은 과일 정보 리스트 중에 특정 금액 이상 혹은 이하의 과일 목록을 받는 것이다. step0. 응답 DTO 생성package me.sungbin.dto.fruit.response; import java.time.LocalDate; /** * @author : rovert * @packageName : me.sungbin.dto.fruit.response * @fileName : FruitResponseDto * @date : 2/26/24 * @description : * =========================================================== * DATE AUTHOR NOTE * ----------------------------------------------------------- * 2/26/24 rovert 최초 생성 */ public class FruitResponseDto { private final String name; private final long price; private final LocalDate warehousingDate; public FruitResponseDto(String name, long price, LocalDate warehousingDate) { this.name = name; this.price = price; this.warehousingDate = warehousingDate; } public String getName() { return name; } public long getPrice() { return price; } public LocalDate getWarehousingDate() { return warehousingDate; } }step1. 요청 DTO 생성package me.sungbin.dto.fruit.request; import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotNull; /** * @author : rovert * @packageName : me.sungbin.dto.fruit.request * @fileName : FruitRequestDto * @date : 2/26/24 * @description : * =========================================================== * DATE AUTHOR NOTE * ----------------------------------------------------------- * 2/26/24 rovert 최초 생성 */ public class FruitRequestDto { @NotBlank(message = "option은 공란일 수 없습니다.") @NotNull(message = "option은 반드시 있어야 합니다.") private final String option; private final long price; public FruitRequestDto(String option, long price) { this.option = option; this.price = price; } public String getOption() { return option; } public long getPrice() { return price; } }요청 DTO에는 spring starter validation을 추가하여 예외 처리도 해두었다.step2. Repository의 쿼리 메서드 추가쿼리 메서드 대신에 @Query를 사용하여 DTO로 반환시킬 수 있다. 과제 7의 1번처럼 말이다. 하지만 본 과제의 취지와 맞지 않은 것 같기에 과제7의 1번(문제 3번)은 @Query로 사용했으니 이번엔 안 사용하고 해보겠다.package me.sungbin.repository; import me.sungbin.dto.fruit.response.FruitResponseDto; import me.sungbin.dto.fruit.response.GetFruitResponseDto; import me.sungbin.entity.fruit.Fruit; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; import org.springframework.transaction.annotation.Transactional; import java.util.List; /** * @author : rovert * @packageName : me.sungbin.repository * @fileName : FruitRepository * @date : 2/26/24 * @description : * =========================================================== * DATE AUTHOR NOTE * ----------------------------------------------------------- * 2/26/24 rovert 최초 생성 */ @Transactional(readOnly = true) public interface FruitRepository extends JpaRepository<Fruit, Long> { @Query("SELECT new me.sungbin.dto.fruit.response.GetFruitResponseDto(SUM(case when f.isSold = true then f.price else 0 end), SUM(case when f.isSold = false then f.price else 0 end)) FROM Fruit f WHERE f.name = :name") GetFruitResponseDto getFruitSalesInfo(@Param("name") String name); // 과제 6 문제 3번 long countByNameAndIsSoldIsTrue(String name); List<Fruit> findAllByPriceGreaterThanEqualAndIsSoldIsFalse(long price); List<Fruit> findAllByPriceLessThanEqualAndIsSoldIsFalse(long price); }step3. 서비스 코드 추가package me.sungbin.service; import me.sungbin.dto.fruit.request.FruitRequestDto; import me.sungbin.dto.fruit.request.SaveFruitInfoRequestDto; import me.sungbin.dto.fruit.request.UpdateFruitRequestDto; import me.sungbin.dto.fruit.response.CountFruitNameResponseDto; import me.sungbin.dto.fruit.response.FruitResponseDto; import me.sungbin.dto.fruit.response.GetFruitResponseDto; import me.sungbin.entity.fruit.Fruit; import me.sungbin.repository.FruitRepository; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import java.util.List; import java.util.Objects; import java.util.stream.Collectors; /** * @author : rovert * @packageName : me.sungbin.service * @fileName : FruitService * @date : 2/26/24 * @description : * =========================================================== * DATE AUTHOR NOTE * ----------------------------------------------------------- * 2/26/24 rovert 최초 생성 */ @Service @Transactional(readOnly = true) public class FruitService { private final FruitRepository fruitRepository; public FruitService(FruitRepository fruitRepository) { this.fruitRepository = fruitRepository; } /** * 문제 1번 (과제6 문제 1) * @param requestDto */ @Transactional public void saveFruitInfo(SaveFruitInfoRequestDto requestDto) { Fruit fruit = requestDto.toEntity(); this.fruitRepository.save(fruit); } /** * 문제 1번 (과제 6 문제 2) * @param requestDto */ @Transactional public void updateFruitInfo(UpdateFruitRequestDto requestDto) { Fruit fruit = this.fruitRepository.findById(requestDto.getId()).orElseThrow(IllegalArgumentException::new); fruit.updateSoldInfo(true); this.fruitRepository.save(fruit); } /** * 문제 1번 (과제 6 문제 3) * @param name * @return */ public GetFruitResponseDto getFruitInfo(String name) { GetFruitResponseDto fruitData = this.fruitRepository.getFruitSalesInfo(name); validateGetFruitAmount(fruitData.getSalesAmount(), fruitData.getNotSalesAmount()); return fruitData; } /** * 문제 2번 * @param name * @return */ public CountFruitNameResponseDto countFruitName(String name) { long count = this.fruitRepository.countByNameAndIsSoldIsTrue(name); return new CountFruitNameResponseDto(count); } public List<FruitResponseDto> findSoldFruitListOfPrice(FruitRequestDto requestDto) { if (Objects.equals(requestDto.getOption(), "GTE")) { return this.fruitRepository.findAllByPriceGreaterThanEqualAndIsSoldIsFalse(requestDto.getPrice()) .stream().map(fruit -> new FruitResponseDto(fruit.getName(), fruit.getPrice(), fruit.getWarehousingDate())) .collect(Collectors.toList()); } else if (Objects.equals(requestDto.getOption(), "LTE")) { return this.fruitRepository.findAllByPriceLessThanEqualAndIsSoldIsFalse(requestDto.getPrice()) .stream().map(fruit -> new FruitResponseDto(fruit.getName(), fruit.getPrice(), fruit.getWarehousingDate())) .collect(Collectors.toList()); } else { throw new IllegalArgumentException("옵션은 GTE 혹은 LTE이여야 합니다."); } } private void validateGetFruitAmount(long salesAmount, long notSalesAmount) { if (salesAmount == 0L && notSalesAmount == 0L) { throw new IllegalArgumentException("존재하는 과일이 없습니다."); } } } 옵션이 올바르지 못할 경우 런 타임 에러 발생step4. 컨트롤러 코드 추가package me.sungbin.controller.fruit; import jakarta.validation.Valid; import me.sungbin.dto.fruit.request.FruitRequestDto; import me.sungbin.dto.fruit.request.SaveFruitInfoRequestDto; import me.sungbin.dto.fruit.request.UpdateFruitRequestDto; import me.sungbin.dto.fruit.response.CountFruitNameResponseDto; import me.sungbin.dto.fruit.response.FruitResponseDto; import me.sungbin.dto.fruit.response.GetFruitResponseDto; import me.sungbin.service.FruitService; import org.springframework.web.bind.annotation.*; import java.util.List; /** * @author : rovert * @packageName : me.sungbin.controller.fruit * @fileName : FruitController * @date : 2/26/24 * @description : * =========================================================== * DATE AUTHOR NOTE * ----------------------------------------------------------- * 2/26/24 rovert 최초 생성 */ @RestController @RequestMapping("/api/v1/fruit") public class FruitController { private final FruitService fruitService; public FruitController(FruitService fruitService) { this.fruitService = fruitService; } @PostMapping public void saveFruitInfo(@RequestBody @Valid SaveFruitInfoRequestDto requestDto) { this.fruitService.saveFruitInfo(requestDto); } @PutMapping public void updateFruitInfo(@RequestBody UpdateFruitRequestDto requestDto) { this.fruitService.updateFruitInfo(requestDto); } @GetMapping("/stat") public GetFruitResponseDto getFruitInfo(@RequestParam String name) { return this.fruitService.getFruitInfo(name); } @GetMapping("/count") public CountFruitNameResponseDto countFruitName(@RequestParam String name) { return this.fruitService.countFruitName(name); } @GetMapping("/list") public List<FruitResponseDto> findSoldFruitListOfPrice(FruitRequestDto requestDto) { return this.fruitService.findSoldFruitListOfPrice(requestDto); } }  step5. 테스트현재 DB 데이터는 아래와 같다.그럴때 테스트를 해보겠다.GTELTEstep6. 테스트 코드이제 테스트 코드를 작성해보자. 아래는 전체 테스트 코드다!package me.sungbin.controller.fruit; import com.fasterxml.jackson.databind.ObjectMapper; import me.sungbin.dto.fruit.request.SaveFruitInfoRequestDto; import me.sungbin.dto.fruit.request.UpdateFruitRequestDto; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; import org.springframework.boot.test.context.SpringBootTest; import org.springframework.http.MediaType; import org.springframework.test.web.servlet.MockMvc; import org.springframework.transaction.annotation.Transactional; import java.time.LocalDate; import static org.junit.jupiter.api.Assertions.*; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; /** * @author : rovert * @packageName : me.sungbin.controller.fruit * @fileName : FruitControllerTest * @date : 2/26/24 * @description : * =========================================================== * DATE AUTHOR NOTE * ----------------------------------------------------------- * 2/26/24 rovert 최초 생성 */ @Transactional @SpringBootTest @AutoConfigureMockMvc class FruitControllerTest { @Autowired private MockMvc mockMvc; @Autowired private ObjectMapper objectMapper; @Test @DisplayName("문제 1번 통합 테스트 - 실패 (가격이 음수거나 과일 이름이 공란)") void question1_test_fail_caused_by_price_is_minus_or_fruit_name_is_empty() throws Exception { SaveFruitInfoRequestDto requestDto = new SaveFruitInfoRequestDto("", LocalDate.of(2024, 1, 1), -1000); this.mockMvc.perform(post("/api/v1/fruit") .contentType(MediaType.APPLICATION_JSON) .content(objectMapper.writeValueAsString(requestDto))) .andDo(print()) .andExpect(status().isBadRequest()); } @Test @DisplayName("문제 1번 통합 테스트 - 성공") void question1_test_success() throws Exception { SaveFruitInfoRequestDto requestDto = new SaveFruitInfoRequestDto("파인애플", LocalDate.of(2024, 2, 2), 20000); this.mockMvc.perform(post("/api/v1/fruit") .contentType(MediaType.APPLICATION_JSON) .content(objectMapper.writeValueAsString(requestDto))) .andDo(print()) .andExpect(status().isOk()); } @Test @DisplayName("문제 2번 통합 테스트 - 성공") void question2_test_success() throws Exception { UpdateFruitRequestDto requestDto = new UpdateFruitRequestDto(1); this.mockMvc.perform(put("/api/v1/fruit") .contentType(MediaType.APPLICATION_JSON) .content(objectMapper.writeValueAsString(requestDto))) .andDo(print()) .andExpect(status().isOk()); } @Test @DisplayName("문제 3번 통합 테스트 - 성공") void question3_test_success() throws Exception { this.mockMvc.perform(get("/api/v1/fruit/stat") .param("name", "사과")) .andDo(print()) .andExpect(status().isOk()); } @Test @DisplayName("과제 7번 문제 2번 통합 테스트 - 실패 (파라미터 존재 X)") void lesson7_question2_fail_test_caused_by_not_exists_request_parameter() throws Exception { this.mockMvc.perform(get("/api/v1/fruit/count")) .andDo(print()) .andExpect(status().isBadRequest()); } @Test @DisplayName("과제 7번 문제 2번 통합 테스트 - 성공") void lesson7_question2_test_success() throws Exception { this.mockMvc.perform(get("/api/v1/fruit/count") .param("name", "사과")) .andDo(print()) .andExpect(status().isOk()); } @Test @DisplayName("과제 7번 문제 2번 통합 테스트 - 실패 (파라미터 존재 X)") void lesson7_question3_fail_test_caused_by_not_exists_request_parameter() throws Exception { this.mockMvc.perform(get("/api/v1/fruit/list")) .andDo(print()) .andExpect(status().isBadRequest()); } @Test @DisplayName("과제 7번 문제 3번 통합 테스트 - 성공") void lesson7_question3_test_success() throws Exception { this.mockMvc.perform(get("/api/v1/fruit/list") .param("option", "GTE") .param("price", "3000")) .andDo(print()) .andExpect(status().isOk()); } }회고JPA의 편리함을 많이 깨닫는 하루였다. 하지만 쿼리메서드를 작성할 때 조건이 엄청 길어지는 것이 내가 보기엔 단점 같다.아래의 짤이 있다. JPA도 이런 취급을 받을 날이 안 왔으면 하는 마음에서 글을 마무리하려 한다. 📚 참고https://m.blog.naver.com/PostView.naver?blogId=190208&logNo=222145961004&categoryNo=51&proxyReferer=

백엔드인프런워밍업스터디클럽JPA

양성빈

[인프런 워밍업 스터디 클럽] 0기 첫번째 발자국 (1 week)

발자국어느덧 인프런 워밍업 스터디 클럽을 시작한지도 1주째가 다 되간다. 부트캠프 수료자 대상으로 하는 인프런 워밍업 스터디 클럽을 이직 및 직무전환을 고려하는 저에게 기회를 주셔서 정말 최선을 다해 열심히 해서 완주는 물론이고 우수러너에도 들어볼 생각이다. 그럼 본격적으로 발자국을 작성하고 1주간 회고를 시작해보겠다.강의 요약Day0인프런 워밍업 스터디 클럽을 본격적으로 시작하기 전에 OT를 온라인으로 참석하게 되었다. 살짝 기대 반 불안감 반으로 시작을 하였다. OT에는 코치님이 인프런 워밍업 스터디 클럽에 대한 전반적인 일정과 미션 제출 방법, 완주러너, 우수러너 선정방법등 전반적인 일정과 규칙에 대해 설명해주셨다. 또한 남은 시간 동안 코치님은 자바의 역사에 대해서 설명해주셨다. OT의 짧은 특강 (feat. 자바의 역사)자바 도대체 어떤 역사를 갖고 있는가?자바가 처음 발표되고 많은 사람들에게 관심을 받았지만 자바7이 되던 시점, 자바의 암흑기가 잠깐 찾아오게 되었다. 그 이유는 그 당시에 nodejs라던지, python이라던지 이런 언어들이 관심을 받게 되었고, 자바는 너무 오래된 언어, 뭔가 부족하다라는 인식이 생기게 되었습니다.그리고 2014년쯤 자바8이 발표가 되었다. 자바8은 대격변의 버전이였다. 아주 중요한 역할을 했으며 자바의 안 좋은 인식들을 걷어내게 된 계기가 된 버전이였다. 간략하게 보면, 자바8에는 람다, @FunctionalInterface등이 등장하며 함수형 프로그래밍이 가능하게 되었다. 또한 Stream이 등장하며 Stream 연산이 가능하게 되었다. 그리고 우리가 요즘 많이 쓰는 Optional도 자바8에 등장하였다. 이 외에도 날짜, 시간을 다루는 방법이 확장되게 되었다. 자바의 이후 역사이후 자바는 현재 자바21까지 나왔으며 현재 LTS 버전은 21이다. 그리고 JDK17이후부터 LTS 버전 주기가 2년으로 변경되었다. 아래의 오라클 블로그를 참고하기 바란다. 📚 오라클 블로그https://blogs.oracle.com/java/post/moving-the-jdk-to-a-two-year-lts-cadence 이렇게 OT 라이브 세션이 등장하였고 직장인인 저에게 이런 기회를 주셔서 정말 감사했으며 이에 부응하게 더욱 열심히 해서 완주러너를 넘어서 우수러너가 되기를 목표로 삼으며, 이 스터디 클럽이 끝날 때 성장이라는 것을 했으면 하는 마음이다. 아래는 내가 미리 작성해둔 출사표에 관련한 블로그이므로, 참고바랍니다. 📚 나의 출사표https://inf.run/jR96P Section01일차가 시작되기 전에 Section0 강의를 미리 수강해두기로 하였다. 그에 대한 내용을 요약해보겠다. Java, IntelliJ, Postman, MySQL, git 설치처음에는 Java, IntelliJ, Postman, MySQL, git을 운영체제별로 설치하는 방법을 알려주셨다. 나는 이전에 설치가 되었기에 이 부분은 쉽게 수강을 할 수 있었다. 스프링 프로젝트를 시작하는 첫 번째 방법강좌에 강의자료를 다운로드 받으면 PPT와 소스코드 압축파일이 있다. 압축파일을 해제 후에 인텔리제이의 open을 눌러서 해당 소스코드를 열었다. 초기에는 많은 것들을 다운로드 하기 때문에 조금 기다려줘야 한다. 다운로드가 완료되면 LibraryAppApplication 클래스를 찾는다. (src/main/java/패키지명/LibraryAppApplication.java) 이후에 이 클래스를 실행시킨다.이 부분 또한 이전에 학습을 개인적으로 했던 부분이라 쉽게 수강을 하였다. Java를 공부하기 전에 알아두면 좋을 것들! #1 (JVM, JDK - 유튜브)자바라는 언어를 어떻게 컴퓨터가 알아먹을까?컴퓨터는 0과 1밖에 모르는 바보다. 그래서 코드를 알아먹지 못한다. 이를 위해 코드를 컴파일이라는 과정을 거쳐서 컴퓨터가 알아먹을 수 있는 바이트코드(0과 1로 된 코드)로 번역해줘야 한다.컴파일: 인간이 이해하기 쉬운 언어를 기계어(바이트 코드)로 바꾸는 과정컴파일러: 컴파일하는 프로그램바이트코드: 0과 1로 이루어진 코드, 컴퓨터가 이해할 수 있는 코드0과 1은 운영체제마다 다르다. C언어 같은 경우 각각 운영체제별 다른 컴파일러가 필요하다. 하지만 자바는 특별하다. 자바는 하나의 컴파일러로 똑같은 바이트코드를 만든다. 그 이후, 운영체제 별 JVM에게 전달하고 이 JVM이 또 번역해서 각 운영체제에게 전달해준다. 원래는 운영체제마다 다른 '컴파일러'가 필요하지만 자바는 JVM이 0과 1을 운영체제에 맞게 번역을 해준다. 이 JVM은 인기가 상당해서 자바외에도 다른 언어들에도 사용된다.(ex. kotlin, groovy...) JVM자바 가상머신운영체제별 존재바이너리 코드를 읽고 검증 및 실행JRE자바 실행환경JRE = JVM + 자바 프로그램 실행에 필요한 라이브러리JVM의 실행환경 구현JDK자바 개발도구JDK = JRE + 개발도구컴파일러, 디버그등이 포함JDK를 설치하는 행위는 JDK만 설치되는 것이 아니라 그 안에 포함한 JRE + JVM이 같이 설치되는 것이다.LTS 버전LTS버전이란 오래써도 되는 버전을 말한다.JDK 종류Oracle: 개인은 무료, 기업은 유료open JDK: oracle JDK와 비슷한 성능, 언제나 무료Java를 공부하기 전에 알아두면 좋을 것들! #2 (빌드, 빌드툴 - 유튜브)빌드소스코드 파일을 컴퓨터에서 실행할 수 있는 독립 소프트웨어 가공물(Artifact)로 변환시키는 과정즉, 소스코드 파일을 Artifact로 만드는 과정1-1. 빌드 과정소스코드 컴파일테스트코드 컴파일테스트코드 실행테스트코드 리포트기타 추가 설정한 작업들 진행패키징최종 소프트웨어 결과물을 만들어낸다.🙋🏻 테스트코드란?내가 작성한 코드를 자동 테스트해주는 코드를 추가로 작성하는 코드실행내가 작성한 코드 (혹은 테스트 코드)를 컴파일을 거쳐 작동시켜 보는 것Artifact가 나올 수도 있고 안 나올 수도 있다.⚠ 주의인터프리터 언어는 컴파일이 필요 없다. 인터프리터의 대표적인 언어로는 파이썬, 자바스크립트가 있다.그런데 이런 빌드 과정이 이렇게 긴데 이것을 사람이 수동으로 하면 무조건 실수가 나오기 마련이다. 내가 생각해도 그럴 것이다. 현재 회사에선, 이런 과정을 일일이 한 경험이 있기 때문이다. 이런 경험 기반으로 간절했던 마음은 빌드 툴이라는 것을 사용했으면 하는 마음이었다. 물론 사내 보안 규칙으로 빌드툴은 사용이 안되었지만 이런 빌드툴로 인하여 우리가 이런 일련의 과정은 일일이 하지 않아도 되기 때문이다.빌드툴소스코드의 빌드 과정을 자동으로 처리해주는 프로그램외부 소스코드 자동 추가 관리빌드툴에는 Ant, Maven, Gradle이 있지만 유연함과 성능으로 Gradle이 압승으로 많은 사람들이 Gradle을 사용한다.Day1스프링 프로젝트를 시작하는 두 번째 방법스프링 프로젝트를 시작하는 두 번째 방법은 start.spring.io를 이용하는 방법이다. 즉, spring initializr를 이용하는 방법이다.이 방법 또한 나는 많이 사용해봤기 때문에 쉽게 수강을 할 수 있었다. 그래도 복습겸 열심히 들어봤다.처음에 빌드 툴을 설정하는게 나온다. 신규 프로젝트는 Gradle을 사용한다. 언어는 자바, 코틀린, 그루비를 선택하게 되어있는데 최신에는 코틀린을 많이 선택한다. 다음으로 스프링 부트 버전을 선택하는게 나오는데 여기서 알파벳이 붙은 버전은 오픈베타버전으로 가급적 알파벳을 붙이지 않는 것을 선택하는게 좋다. 다음으로 프로젝트 메타데이터를 작성하는게 나오는데 각각은 아래의 의미를 가진다.Group : 프로젝트 그룹Artifact : 최종 결과물의 이름Name : 프로젝트 이름Description : 프로젝트 설명Package name : 패키지 이름다음으로 패키징 방법을 선택하는게 나오는데 우리는 jar를 선택했다. 일종의 압축파일이다. 요즘 많이 사용하며, 특정 SI 프로젝트의 경우 War를 많이 사용하기도 한다. (내 경험담...)다음으로 자바 버전을 선택하는데 코치님은 자바17을 선택하셨지만 나는 21이 나온 시점이라 21을 선택하였다.다음으로 의존성 설정한다. 여기서 의존성이란, 프로젝트에서 사용하는 라이브러리 / 프레임워크를 의미한다. 📚 라이브러리란?프로그래밍을 개발할 때 미리 만들어져 있는 기능을 가져다 사용하는 것!코치님은 일종의 김치찌개로 비유하셨다. 김치찌개를 끓일 때 김치를 직접 농사해서 할 수 있고 마트에 살 수 있다. 여기서 마트의 김치를 라이브러리에 비유하셨다.나는 비유를 밀키트로 비유해보겠다. 떡볶이 밀키트가 있다하면 떡볶이를 직접 재료를 사서 조리를 할 수 있지만 밀키트를 사서 쉽게 끓여먹을 수 있다. 📚 프레임워크란?프로그래밍을 개발할 때 미리 만들어져 있는 구조에 코드를 가져다 끼워 넣는 것!이것도 김치찌개로 비유하셨는데, 여러 재료를 사서 만들 수도 있고 원데이 클래스에 가서 선생님이 시키는 것만 편하게 할 수도 있다. 여기서 원데이 클래스가 프레임워크라 하셨다. 마지막으로 의존성을 설정했으면 generate을 눌러서 압축파일을 다운 받고 아까 설명한 첫번째 방법을 이용하여 인텔리제이로 생성한 프로젝트를 켠다. @SpringBootApplication과 서버서버란? 내가 생각하는 서버는 영어로 serve는 "제공하다"라는 의미를 지닌다. 어떤 것을 제공하는 사람을 서버라고 부른다. 우리는 식당에 가면 종업원이 서빙을 한다. 즉 서버가 서빙을 하는 것이다. 즉, 기능을 제공하는 프로그램이여, 그 프로그램을 실행시키고 있는 컴퓨터를 서버라고 한다. 여기서 이런 의문사항이 있을 수 있다.🙋🏻 서버를 들었을 땐 엄청 크고 멋진 장치인데 그거랑 뭐가 다를까?서버라고 하면 엄청나게 큰 장치만 생각하며 막연하게 생각하신 분들이 많을 것이다. 컴퓨터의 외형으로 서버와 클라이언트를 나누는 것이 아니다. 서버는 단지 서비스를 제공해준다는 것만 기억하면 될 것이다. 우리가 사용하는 컴퓨터도 언제든지 서버가 될 수 있다.  나는 대학생 때 캡스톤 디자인으로 라즈베리파이라는 초소형 컴퓨터를 구입하여 서버로 이용하기도 했다. 손바닥만한 작은 크기지만 서버의 역할을 잘 수행하였다. 다만 대부분의 서버는 많은 클라이언트의 요청을 처리해야 하므로 성능이 중요하다. 따라서 하드웨어의 크기도 커진 것이다. 하지만 서버와 클라이언트에서 중요한 것은 하드웨어의 크기가 아니라 "누가 요청을 하고 누가 응답을 받는가"이다.여기서 클라이언트라는 말도 나온다. 클라이언트는 요청하는 사람 혹은 컴퓨터라고 한다. 그럼 이 클라이언트는 어떻게 서버에게 요청을 할까? 바로 인터넷을 통해 한다. 네트워크란 무엇인가?!네트워크를 이세계의 부족으로 설명해주셨다. 이세계 부족에는 주소체계와 택배시스템이 잘 되어 있다. 그래서 우리가 택배보내는 것처럼 아래와 같이 택배를 보낼 수 있다고 하셨다. B부족 감자동 곰로 13번길 2에 사는 둘째하지만 이렇게 주소체계를 우리도 마찬가지로 기억하는 사람은 많지 않을 것이다. 그냥 '파란색 집에 사는 둘째'라고 편히 부른다. 이제 현실세계도 마찬가지다. 현실세계에 컴퓨터는 고유의 IP를 가진다. 그리고 현실세계는 택배시스템처럼 인터넷이 잘 발달되어 있다. 그래서 우리는 인터넷을 통해 데이터를 주고 받을 수 있다. 아래와 같이 말이다.210.210.210.210 IP를 가진 PC에서 port 8080번으로 데이터 보내줘!파란색집 둘째가 port이고 자세한 주소가 IP 주소이다. 하지만 우리는 인터넷 접속할 때 일반적으로 IP주소와 port를 입력하지 않는다. 아래와 같이 도메인을 입력하고 접속할 것이다.https://www.spring.io:3000여기서 210.210.210.210이 spring.io일 것이다. HTTP와 API란 무엇인가?!HTTP와 API를 설명을 위해 또 다시 이 세계를 비유해주셨다. 택배를 보내려면 우리는 운송장이란 표준을 이용한다. 이세계의 운송장은 아래와 같다.내놓아라 파란집 둘째, 포션 빨강색 2개여기서 '내놓아라'는 운송장을 받는 사람에게 요청하는 행위이며, '파란 집'은 운송장이 가는 집을 말하고, '둘째'는 운송장을 실제 받는 사람, '포션'은 운송장을 받는 사람에게 원하는 자원이며, '빨강색 2개'는 자원의 세부조건을 의미한다. 여기서 행위와 자원은 빨간집에 운송장을 보내기 전에 약속해야 한다.현실세계에도 데이터를 받는 표준이 있는데 바로 HTTP이다. 일종의 약속이다. 아래와 같이 약속을 지켜 우리는 데이터를 보낸다.GET /portion?color=red&count=2Host: spring.io:3000여기서 GET은 HTTP 요청을 받는 컴퓨터에게 요청하는 행위이며, HTTP method라고 부른다. Host 부분은 HTTP 요청을 받는 컴퓨터와 프로그램 정보를 뜻한다. /portion은 HTTP 요청을 받는 컴퓨터에게 원하는 자원을 의미하며, path라고 부른다. ?은 구분기호이며 color=red는 자원의 세부조건, &는 구분기호, count=2 또한 자원의 세부조건을 뜻한다.행위와 자원은 HTTP 요청을 보내기 전에 약속해야 한다.그리고 이런 세부조건들을 고급용어로 쿼리스트링라고 부른다. 또한 이세계에서 아래와 같이 운송장을 작성할 경우도 있다.창고에 넣어라, 오크가죽, 창고에이것을 현실세계로 표현하면 다음과 같다.POST /oak/leatherHost: spring.io:3000오크가죽정보여기서 다른 것은 위와 동일하지만 '오크가죽정보'는 body라고 하고 호스트 부분과 한줄 내리고 시작을 한다. 요약을 하면 GET HTTP method는 데이터를 요청하는것으로 보통 쿼리스트링을 이용한다. (없는 경우도 있음) 하지만 POST는 데이터를 저장을 하는 것으로 바디를 이용한다. 이외에 PUT과 DELETE가 있는데 PUT은 데이터 수정을 요청하는 것으로 바디를 이용하고, DELETE는 데이터 삭제요청을 하는 것으로 쿼리스트링을 이용한다. 그럼 API는 무엇일까? API란, 정해진 약속을 하여, 특정 기능을 수행하는 것이다. 그래서 이 약속은 이전까지 썼던 방식으로 첫줄에는 HTTP method와 path, (쿼리)를 작성한다. 추가적으로 어디로 보낼 지 Host를 작성한다.(도메인 + 포트) 이런것을 헤더를 작성한다고 하고 헤더는 여러줄이 가능하다. 그 다음 body가 있을 경우 한 줄 띄고 body를 작성하며 여러줄 작성이 가능하다. 그래서 https://spring.io/portion?color=red&count=2 이런 형식을 URL이라고 부르고 작성 순서는 아래와 같다.프로토콜://도메인(혹은 IP:포트)/자원경로?쿼리(추가정보)그럼 요청을 보냈으니 응답을 보내줘야 한다. 예를 들어 200 OK 이런식으로 말이다. 요청에 대한 응답을 보내주는 컴퓨터를 서버라고 부른다. 그리고 요청을 한 컴퓨터를 클라이언트라고 부른다. 또한 응답에는 body를 담을 수도 있다. 응답은 요청 구조와 동일하다. 그리고 응답의 핵심은 상태코드인데 200, 201, 400, 404, 500등이 존재한다. GET API 개발하고 테스트하기API를 개발 전에는 항상 API Spec을 살펴봐야 한다. 즉, HTTP method와 path, 쿼리를 봐야하고 이에 대한 응답에 결과도 확인을 해봐야 한다. 그래서 실제 더하는 GET API를 실습을 해보았다.여기서 실습중에 @RestController라는 어노테이션도 학습을 했는데, 해당 클래스를 API의 진입점으로 만드는 어노테이션이라고 볼 수 있다. 그리고 @GetMapping("/path")이라는 어노테이션도 학습을 했는데 해당 메서드를 HTTP method가 GET이고 path가 /path인 API로 지정한다는 의미이다. 마지막으로 @RequestParam을 배웠는데 쿼리가 있을 시, 주어진 쿼리를 함수 파라미터로 넣을 수 있다. 그래서 단일 타입으로 넣을수도 있지만 request DTO를 만들어 객체를 넣을 수도 있는데 객체를 넣을 시, 어노테이션은 생략할 수 있다. 단, Spring Boot 3.2 이후 버전은 생략이 불가능했는데 빌드 툴을 Gradle로 변경하면 가능했었다. 왜 그런지는 내가 작성한 미션1에 대한 내용을 살펴보자. 📚 미션1https://inf.run/QKGsfDay2POST API 개발하고 테스트하기POST에는 어떻게 데이터를 전송하고 받을지에 대해 학습을 했다. POST에서는 GET과 달리 HTTP Body를 이용하였다. 그리고 HTTP body는 JSON 형태로 보낸다. 객체 표기법, 즉 무언가를 표현하기 위한 형식이다! Java로 비유해보자면, Map<Object, Object> 느낌이다.JSON의 표기 예는 아래와 같다.{ “name”: “양성빈”, “age”: 29, "stack": ["java", "javascript"], "house": { "address": "대한민국 경기도 시흥", "hasDoor": true } }그래서 POST HTTP method로 body를 넘겨 보낼 때 이런 형식으로 보낸다.그리고 실습을 통해 POST method를 실습했다. 여기서 나온 주요 어노테이션은 아래와 같다.@PostMapping("/path") : 아래 함수를 HTTP Method가 POST이고 Path가 /path인 API로 만든다!@RequestBody: HTTP Body로 들어오는 JSON을 파리마터로 넘긴 객체(DTO)로 바꿔준다. 그리고 DTO에는 json의 key값이 명시되어야 하며, 각 속성은 key값과 동일하게, 타입도 value에 타입에 따라서 작성한다.유저 생성 API 개발실제 프로젝트에 대한 기능 스펙을 제시해주셨으며 웹 UI까지 제공해주셨다. 그리고 우리가 배운 POST를 이용해 유저생성 API를 개발해보았다. 이 부분도 내가 아는거라 간단히 편하게 실습을 할 줄 알았지만, 내가 미쳐 생각지 못한 부분이 있었는데 이 부분을 다시한번 복습하는 계기로 실습을 하였다. 유저 조회 API 개발과 테스트이제 유저 조회 API를 실습해보았다. 이전에 배운 GET HTTP method를 이용하여 개발했다. GET API에서는 응답 반환이 있었는데 이 형태는 json이였으며 json으로 반환받으려면 파라미터로 넘기는 객체(DTO)에 getter가 반드시 있어야 json으로 받을 수 있다. 이 부분이 내가 배운 사실이었다.  📚 참고한 클래스 안에는 여러 API 추가 가능 정리. 다음으로!우리가 이렇게 GET과 POST API를 설계하고 개발하고 테스트까지 해보았다. 하지만 지금까지 만든 프로젝트에 큰 문제가 있다. 서버를 재시작하면 데이터가 날라갔다. 그 이유를 나는 잘 몰랐고 DB를 안 써서 그랬겠지라는 생각이었는데 코치님께서 아래와 같이 설명해주셨다.컴퓨터에는 1차 메모리와 2차 메모리가 있고 데이터가 날라가는 이유는 1차 메모리에 있었기 때문이다. 그래서 서버를 재시작해도 데이터가 남아있으려면 2차 메모리에 저장을 해두어야 하는데 우리는 2차 메모리에 저장보단, DB에 저장한다고 하셨다.Day3Database와 MySQL지난번에 우리는 서버를 재가동하면 데이터가 남아있지 않고 사라졌다. 그 이유는 유저 데이터가 램에 저장되어 있기 때문이다. 그래서 우리는 2차 메모리등에 저장하는 방법을 생각할 수 있다. 자바의 File이라는 클래스를 이용해 직접 디스크에 접근을 할 수 있지만 보통은 Database를 이용한다.Database란 데이터를 구조화시켜 저장하는 하는 것이라고 볼 수 있다. 마치 엑셀과 비슷하다고 생각하면 좋을 것 같다. 엑셀처럼 데이터를 표처럼 구조화하여 저장한다. 대표적으로 RDB의 MySQL이 그렇다. 그리고 이 표처럼 구조화된 데이터를 조회하는 언어를 SQL이라고 한다.MySQL에 접근하는 방법은 먼저 MySQL을 시작해야 한다. 인텔리제이 얼티밋 유료버전을 이용하면 IDE에서 직접 접근이 가능하지만 이 IDE를 이용할 수 없는 분들은 윈도우의 cmd창이나 유닉스의 터미널을 이용해야 한다. 동일하게 아래의 명령어를 작성하면 된다. $> mysql -uroot -p MySQL에서 테이블 만들기테이블 하나를 만든 다는 것은 엑셀파일을 만드는 것인데, 엑셀파일을 만들려면 엑셀파일을 담을 폴더를 생성 후에 폴더에 들어가 엑셀파일을 생성해야 한다. 그리고 엑셀에 헤더를 작성해야 한다. 그리고 헤더별로 서식을 설정한다. MySQL 테이블 생성도 이와 유사하다. 과정은 아래와 같다. 여기서 폴더는 데이터베이스(스키마)를 엑셀파일은 테이블을 엑셀파일의 헤더는 테이블의 필드를 정의한 것이다. 그리고 엑셀파일의 서식은 테이블의 필드의 타입을 설정하는 것이라 볼 수 있다. 그럼 이제 DB의 테이블을 직접 만들어보자.데이터베이스 만들기$> create database [데이터베이스 이름];데이터베이스 목록보기$> show databases;데이터베이스 지우기$> drop database [데이터베이스 이름];데이터베이스 접속하기$> use [데이터베이스 이름];테이블 목록보기$> show tables;테이블 만들기$> create table [테이블 이름] ( [필드1 이름] [타입] [부가조건], [필드2 이름] [타입] [부가조건], ... primary key ([필드이름]). );테이블 제거하기$> drop table [테이블 이름];💡 꿀팁1. auto_increment: 데이터를 명시적으로 넣지 않더라도 1부터 1씩 증가하며 자동 기록된다. 단, 데이터를 생성하고 삭제를 한 후 다시 생성시 1부터 생성되는게 아니라 삭제한 컬럼의 id값 다음 값으로 생성된다. 그리고 데이터 추가시에, auto_increment로 설정한 필드는 안 넣어도 자동으로 들어간다.2. primary key: 유일한 필드를 지정할 때 사용MySQL 타입정수타입: tinyint(1 byte), int(4byte), bigint(8byte)실수타입:double(8 byte)decimal(A, B): 소수점 B개를 가지고 있는 전체 A자릿 실수문자열 타입:char(A): A글자가 들어갈 수 있는 문자열varcher(A): 최대 A 글자가 들어갈 수 있는 문자열날짜, 시간 타입date : 날짜, yyyy-MM-ddtime : 시간, HH:mm:ssdatetime : 날짜와 시간을 합친 타입, yyyy-MM-dd HH:mm:ss지금까지 배운 SQL을 DDL(Data Definition Language)이라고 한다. 즉, 데이터 정의 언어라고 말한다. 테이블의 데이터를 조작하기데이터 넣기$> INSERT INTO [테이블 이름] (필드1이름, 필드2이름, ...) VALUES (값1, 값2, ...)데이터 조회하기$> SELECT * FROM [테이블 이름]; // * 대신에 필드 이름 여러개 넣을 수 있다.$> SELECT * FROM [테이블 이름] WHERE [조건]; // 특정 조건을 통해 조회. AND 또는 OR을 이용해 조건을 이어 붙일 수 있다! 조건에는 =, <= 외에도 !=, <, >, >=, between, in, not in 등이 있다.데이터 업데이트$> UPDATE [테이블 이름] SET 필드1이름=값, 필드2이름=값, ... WHERE [조건];⚠ 주의만약 [조건]을 붙이지 않으면, 모든 데이터가 업데이트된다!!!데이터 삭제하기$> DELETE FROM [테이블 이름] WHERE [조건];⚠ 주의만약 [조건]을 붙이지 않으면, 모든 데이터가 삭제된다!!지금까지 배운 SQL을 DML(Data Manipulation Language)이라고 한다. 즉, 데이터 조작 언어이다. Spring에서 Database 사용하기지금까지 사람이 DB에 직접 접근했으니 웹 어플리케이션이 DB에 접근하도록 하겠다.먼저 src/main/resources의 경로에 application.properties가 있을 것이다. 이것을 application.yml로 변경해준다. 단, 강의에 따라 변경을 한것이지 properties가 더 익숙하신 분이면 여기다가 DB설정정보를 기입해도 된다.아래와 같이 DB 설정정보를 기입한다.spring: datasource: url: "jdbc:mysql://localhost/library" username: "root" password: "1234" driver-class-name: com.mysql.cj.jdbc.Driverjdbc:mysql:// - jdbc를 이용해 mysql에 접근localhost – 접근하려는 mysql은 localhost에 있다./library – 접근하려는 DB는 library이다.root는 MySQL에 접근하려는 계정명1234는 MySQL에 접근하기 위한 비밀번호마지막으로 driver-class-name은 데이터베이스에 접근할 때 사용할 프로그램이 적혀있다.그럼 이전에 만든 프로젝트에 DB를 입히기 위해 유저 정보 테이블을 만든다.그후에, 유저 생성 API를 JdbcTemplate을 이용하여 SQL을 날린다. 생성자를 만들어 jdbcTemplate을 파라미터로 넣으면, 자동으로 들어온다. 그리고 SQL을 문자열로 입력 후, 값이 들어갈 부분에 ?을 넣는다. ?를 사용하면 값을 유동적으로 변경이 가능하다. 그리고 이 문자열을 JdbcTemplate의 update 메서드에 담는다. update 메서드는 insert, update, delete 쿼리에 적용이 가능하다.다음 유저 조회 API도 변경한다. 아래와 같이 변경이 가능하다.jdbcTemplate.query(sql, RowMapper 구현 익명클래스)구현 익명클래스 안에는 ResultSet에 getType(“필드이름”)을 사용해 실제 값을 가져올 수 있다. 그리고 이 익명클래스는 람다식을 이용하면 더 간단하게 표현이 가능하다.Day4유저 업데이트 API, 삭제 API 개발과 테스트이제 DB를 이용해 유저 업데이트와 삭제 API를 개발해보았다. 업데이트는 UPDATE 쿼리를 사용하여 jdbcTemplate의 update 메서드에 넘겨주어 실행을 하였고, 업데이트는 body를 넘기므로 @RequestBody를 사용하였다. 그리고 아래와 같은 추가 어노테이션도 학습하였다.@PutMapping("/path"): /path 경로로 PUT HTTP method를 전송한다.삭제 또한 마찬가지다. DELETE 쿼리를 이용하여 jdbcTemplate의 update 메서드에 넘겨주어 실행을 하였고 삭제는 파라미터를 넘기므로 @RequestParam을 사용하였다. 그리고 아래와 같은 추가 어노테이션도 학습을 하였다.@DeleteMapping("/path"): /path 경로로 DELETE HTTP method를 전송한다.유저 업데이트 API, 삭제 API 예외 처리 하기여기서 또 하나 꿀팁은 존재하지 않는 유저를 업데이트하고 삭제해도 200OK 응답이 나오는 것이 문제였다. 그래서 Exception을 던져서 500 INTERNAL SERVER ERROR가 나오게 하는것으로 변경을 하였다. 그래서 업데이트든 삭제든, select 쿼리를 전에 날려서 유저가 존재하는지 유무를 판단후, 있으면 각각 업데이트, 삭제 쿼리를 날리고 없다면 IllegalArgumentException을 날라기로 변경하였다. 아래와 같이 select 쿼리를 날리 수 있다.String readSql = "select * from user where id = ?"; return jdbcTemplate.query(readSql, (rs, rowNum) -> 0, id).isEmpty();이렇게 return된 boolean 변수를 통해 유무 판단을 하는 것이다. Section2 정리. 다음으로!지금 코드에도 문제가 존재한다. 바로 한 클래스인 Controller에 많은 역할을 하며 여러 비즈니스 로직이 통합되어 있다.이 문제는 만약 이 코드가 1000줄 이상만 되도 어느 기능을 수정할 때 상당한 워킹타임이 들 것이다. 이런 문제를 어떤 방법론으로 어떻게 변경할지 알아보자.Day5좋은 코드(Clean Code)는 왜 중요한가?!코드는 요구사항을 표현한 언어이다. 개발자는 요구사항을 구현하기 위해 코드를 읽고 작성한다. 여기서 핵심은 읽는다는 것이다. 예를 들어 몇천줄의 코드에 변수도 의미없는 이름을 짓고 로직도 한 곳에 모여있다면 유지보수하는 개발자는 읽기도 힘들 것이다. 또한 동시에 여러명이 수정이 힘들고, 어느 부분을 수정하더라도 다른 곳에 영향을 끼칠 수 있기에 지뢰코드가 된다. 당연히 단위테스트는 힘들 것이다. 또한 안 좋은 코드가 쌓이면 시간이 지날수록 생산성이 떨어진다. 즉, 유지보수 시간이 늦어지고 이것은 바로 돈과 관계가 되기에 클린코드는 정말 중요하다. 그래서 클린코드를 정의하면 아래와 같다.함수는 최대한 작게 만들고 한 가지 일만 수행하는 것이 좋다.클래스는 작아야 하며 하나의 책임만을 가져야 한다.우리의 컨트롤러 클래스도 API 진입점 역할, 유저의 유무를 판단하는 예외로직, SQL을 통한 DB통신으로 무려 3가지 역할을 한다. Controller를 3단 분리하기 – Service와 Repository우리의 컨트롤러는 API 진입점 역할, 유저의 유무를 판단하는 예외로직, SQL을 통한 DB통신으로 무려 3가지 역할을 한다. 이것을 분리해보는 시간을 가졌다. API 진입점 역할은 컨트롤러 레이어 역할로 예외로직은 서비스 레이어 역할로, SQL을 통한 DB통신은 레파지토리 레이어 역할로 분리하였다. 이렇게 레이어 분할을 한 구조를 Layered Architecture라고 한다.Live Q&A금요일에 Live Q&A를 참석했다. 커뮤니티에 올린 질문들을 코치님께서 성심 성의껏 말씀을 해주셔고 약간의 시간이 남아서 웹 어플리케이션 서버와 웹서버 차이를 역사를 통해 알려주셨다.초기에는 원격으로 메세지를 보내는 방식에서 시작했다가 이후에 클라이언트가 서버에게 정적 리소스를 요청하는 걸로 발전했다. 즉, 어떤 클라이언트가 요청을 하든지 똑같은 내용이 오는 것이다. 이것을 웹 서버라고 하고 대표적으로 Apache와 NginX가 있다. 이러다가 이런 생각도 하게 되었다. 클라이언트마다 다른 리소스를 받고 싶다는 생각을 하게 되었다. 그래서 클라이언트가 서버에 요청을 하면 서버는 요청을 확인해 그에 맞는 프로세스를 실행하여 파일을 그때 그때 바꾼다. 하지만 이런 과정은 성능적으로 좋지 않다. 그래서 이런식으로 변경을 했다. 클라이언트가 요청을 하면 서버는 요청을 받고 쓰레드를 생성한다. 쓰레드는 서블릿이라는 인터페이스 통해 알려준다. 즉, 여기서 서버가 생성한 프로세스는 Servlet Container라고 하고 쓰레드를 쓰레드 풀에 담아 관리한다.여기서 또 생각한 것은 서블릿은 여러 공통코드가 많아 우리가 개발을 할때 공통코드를 적느라 비효율적이라 느껐다. 그래서 서블릿을 그때 그때 사용하지 않고 하나로 퉁 치는 개념이 등장했는데 그것을 Dispatcher Servlet이 등장하게 되었다.미션 해결 과정Day1첫번째 미션은 어노테이션에 대한 학습을 하는 것이였다. 아래 질문을 통해서 말이다.어노테이션을 사용하는 이유 (효과) 는 무엇일까?나만의 어노테이션은 어떻게 만들 수 있을까?여기서 나는 이런 질문도 질문이지만, 단순히 @붙이는 걸로 파악하고 있었다. 그래서 이 어노테이션에 대한 기본 문법, 커스텀 어노테이션에 대해 알아보면서, 자바 표준 어노테이션은 무엇이 있고 각각 무슨 의미를 하는지 학습을 찾아봤으며, 찾다보니 자바의 리플렉션 개념까지 연관이 되었다. 그래서 리플렉션에 대한 학습까지 이어갔다. @Documented를 붙은 어노테이션과 아닌 어노테이션이 어떤 차이가 있는지 java doc을 직접 만들면서 확인을 해보았고 자바8에 어노테이션의 변화에 대해서도 학습을 마쳤다. 그리고 자주 사용하는 롬복 어노테이션들이 어떤식을 동작을 해보는지 궁금하여 찾아보고 어노테이션 프로세서를 이용하여 조작을 하는 걸 알게 되었다. 즉, 나의 학습방식은 아래와 같았다.어노테이션이 뭐야? 어떻게 사용해? 동작원리는 뭐야? 각각의 어노테이션이 붙은거랑 아닌거랑 어떻게 달라?📖 학습 방법 및 반성할 점위의 물음을 재차 물으며 학습했다. 하지만, 반성할 점도 있었다. 하나의 개념으로 여러 개념들을 파보는 것은 좋지만 뭔가 실습을 많이 해보면서 익히면 체득이 될텐데 그러지 못했다는 점을 반성하게 된다. ㅠㅠ📋 미션 블로그https://inf.run/QKGsfDay2두번째 미션은 GET과 POST API에 대한 실습을 문제로 내주셨다. 여기서 나는 이런 생각을 했다. 단순히 문제 푸는것에 의의를 두셔서 문제를 내주신게 아닐 것이다. 좀 더 깊이 파보았다.문제를 풀 때 일단 먼저 풀고, 비즈니스 로직들을 서비스 레이어로 분리하여 해보고, DTO를 클래스가 아닌 JDK17에 나온 record를 이용도 해보고, 이에 Spring Boot 3.2에 나타는 트러블 슈팅도 겪었다. 그리고 검증에 대한 로직을 spring boot starter validation을 이용해 예외를 처리하며, 테스트코드까지 작성함으로 조금 더 깊이있게 해보았다. 📖 학습 방법 및 반성할 점나는 미션을 제출하면서 완벽히 진행을 했다고 느꼈다. 그리고 다른 러너분들이 제출한 글을 보니 의외로 나와 비슷한 부분도 있지만 또 다른 방법으로 제출하신 러너분들을 볼 수 있었다. 이에 나는 아직 부족하다라는 생각을 하며 좀 더 열심히 해서 성장해야겠다는 생각을 하게 되었다. 📋 미션 블로그https://inf.run/fJXgxDay3세번째 미션은 익명 클래스 / 람다 / 함수형 프로그래밍 / @FunctionalInterface / 스트림 API / 메소드 레퍼런스 라는 키워드를 생각하여 람다식과 익명클래스를 공부하는 것이였다.[질문]자바의 람다식은 왜 등장했을까?람다식과 익명 클래스는 어떤 관계가 있을까? - 람다식의 문법은 어떻게 될까?이 또한 나는 익명클래스의 어떠한 불편함때문에 왜 람다식이 등장한지를 질문사항으로 공부를 먼저 시작했다. 다음 람다식이 무엇인지 정의를 내려보았다. 또한 람다식을 공부하니 함수형 프로그래밍과 함수형 인터페이스가 연관되었으며 이에 대해 또 문법을 공부하고 이번엔 실습도 해보았다. 그리고 익명클래스와 람다가 어떻게 다른지를 코드로만 보는게 아니라 바이트코드를 확인하여 살펴봤다. 또한 더 깊게 파다보니 INVOKEDYNAMIC 내부 동작을 확인하게 되었다. 정말 깊게 팔 수록 한도 끝도 없다고 느끼게 되었다. 📖 학습 방법 및 반성할 점람다에 대해 처음에는 왜 등장했을까? 부터 시작해서 익명클래스와 람다가 어떤 차이가 있을지 코드뿐만 아니라 바이트코드로 확인을 했으며 더 깊이 들어가 INVOKEDYNAMIC 내부 동작을 학습해보는 계기가 되었다. 이런 미션을 하면서 "나는 이제까지 아무것도 아니었구나"라는 생각을 하며 더욱 더 열심히 하게되는 계기였다.Day4네번째 미션은 DB를 연동하여 API를 생성하고 수정하고 조회하는 것을 해보았다.당연히, 일단 나는 문제를 컨트롤러 클래스에 비즈니스, 예외로직을 넣고 해결했다. 이후에 나는 리팩토링 작업을 거쳤다. 이런 로직들을 서비스, 레파지토리로 분리하고 나는 엔티티라는 것을 따로 만들어 request와 response는 dto로 처리하고 엔티티는 순수히 데이터를 받는 걸로만 처리하여 더욱 견고히 했다. 이렇게 작성한 이유는 아래와 같다.보안: DTO를 사용하면 민감한 정보를 숨기고 필요한 데이터만 클라이언트에 전달할 수 있습니다.추상화: DTO는 엔티티의 구조를 클라이언트에 그대로 노출하지 않고, API 응답을 통해 데이터의 표현 방식을 커스터마이징할 수 있게 해줍니다.유연성: 엔티티와 API 사이의 계약을 DTO를 통해 정의함으로써, 엔티티의 변경이 API 스펙에 직접적인 영향을 미치지 않도록 합니다.또한 당연히, 테스트코드로 검증까지 완료하였다. 📖 학습 방법 및 반성할 점문제3번에 SUM이라든지 GROUP BY를 알긴 알았지만 이런 집계함수에 대해 정확히 뭔지가 헷갈렸던 부분이 많았다. 그래서 나름 검색도 해보고 사용법도 익혀보았다. 이로 인해 내가 SQL 부분을 완전히 아는게 아니라는 생각을 가졌고 시간날때 틈틈이 SQL 공부도 해보면서 자격증 시험(SQLD)도 준비해보면 좋지 않을까라는 생각을 가지게 되었다.Day5다섯번째 미션은 하나의 코드를 클린코드 개념을 도입해 리팩토링 하는 것이였다.나는 그래서 클린코드에 대해 검색을 하면서 다른 유튜브 영상을 통해 학습을 했고 이를 바탕으로 총 4~5단계에 걸쳐서 리팩토링을 하였다. 1단계는 단순히 변수이름 변경 및 메서드로 분리였지만, OOP 개념을 도입하고 단일책임의 원칙을 적용하였으며 마지막에는 팩토리 디자인패턴과 테스트 코드로 마무리하였다. 📖 학습 방법 및 반성할 점정말 이번 미션이 나를 반성하게 하는 점이였다. 현업을 뛰는 나로서 현업(프론트엔드)에서 내가 얼마나 더러운 코드를 짰다는 생각이 많이 들었다. 그 동안 나는 쓰레기를 생산했다고 할 정도로... 그래서 나는 다음주 출근하자마자 시간이 된다면 바로 리팩토링 작업을 시작해야겠다고 느끼게 된 하루였다.회고이번주부터 정말 정신이 없었다. 직장다니면서 끝나자마자 회사 근처 카페에 가서 미션 수행하고, 정말 정신이 없었다. 심지어 어느 하루는 날밤을 세서 한 적도 있었다. 하지만 오히려 힘들고 불행했다기 보단 행복했다. 다른 분들은 이상하게 느낄 지 모르지만 뭔가 해결했다는 쾌감이 정말 감명 깊었고 지금의 마인드를 기억하면 다음주도 화이팅해서 성장해보겠다.

백엔드인프런워밍업스터디클럽발자국

양성빈

[인프런 워밍업 스터디 클럽] 0기 백엔드 미션 - DB연동 API 테스트 (Day4)

미션벌써 4일차가 되었다. 오늘은 지난 시간 DB연동을 통해 유저를 생성하고 조회하는 실습을 하였다면 오늘은 유저를 수정하고 삭제하고 예외처리를 정리하는 등 전반적인 CRUD를 적용시키는 실습을 했다. 이제 이것을 바탕으로 API 실습 미션을 진행해보도록 하자.문제1. 요구사항문제해결먼저 API를 개발하기 전에 우리 PC에 설치 된 MySQL에 접속하여, 데이터베이스와 테이블을 생성해야 한다. 1. 데이터베이스 생성CREATE DATABASE mission;위와 같이 데이터베이스를 생성한다. 나는 mission이라는 이름의 데이터베이스를 생성하였다. 2. 데이터 베이스 접속use mission; 3. 테이블 생성과일정보를 담는 테이블을 생성해야 한다. 아래와 같이 생성해보자. (속성들은 문제3번까지 확인 후 미리 한번에 만듬)CREATE TABLE fruit ( id bigint auto_increment, name varchar(20) not null, warehousingDate date not null, price bigint not null, is_sold boolean not null default false, primary key (id) );이제 아래의 sql로 테이블이 잘 생성 되었는지 확인해보자.show tables; 4. 스프링 부트 프로젝트에 DB연동 정보 기입이제 해당 DB와 우리의 스프링 부트 프로젝트를 연동할 차례이다. 프로젝트의 resources 디렉토리 아래에 application.yml에 설정정보를 기입하자. ⚠ 유의처음 resources 디렉토리 안으로 가보면 application.properties 파일이 있을 것이다. 여기다가 DB정보를 기입해도 좋지만, 하이라키 구조를 눈에 띄게 보고 싶고 yml에 익숙해서 나는 yml로 변경하여 작성하겠다. 아래와 같이 작성한다.spring: datasource: url: "jdbc:mysql://localhost/mission" username: "root" password: "" driver-class-name: com.mysql.cj.jdbc.Driver 5. 컨트롤러 클래스 개발이제 컨트롤러 클래스를 만들어보자.  package me.sungbin.controller.fruit; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; /** * @author : rovert * @packageName : me.sungbin.controller.fruit * @fileName : FruitController * @date : 2/22/24 * @description : * =========================================================== * DATE AUTHOR NOTE * ----------------------------------------------------------- * 2/22/24 rovert 최초 생성 */ @RestController @RequestMapping("/api/v1") public class FruitController { }@RequestMapping을 통하여 문제1~3번까지 제시된 API는 /api/v1으로 시작함으로 컨트롤러의 전체 매핑을 해준다.6. Entity 개발이제 DB 테이블 설계를 한 데로 그와 1:1 매칭이 되는 클래스를 만들어주겠다. (속성들은 문제3번까지 확인 후 미리 한번에 만듬)package me.sungbin.entity.fruit; import java.time.LocalDate; /** * @author : rovert * @packageName : me.sungbin.entity.fruit * @fileName : Fruit * @date : 2/22/24 * @description : * =========================================================== * DATE AUTHOR NOTE * ----------------------------------------------------------- * 2/22/24 rovert 최초 생성 */ public class Fruit { private long id; private String name; private LocalDate warehousingDate; private long price; private boolean isSold; public Fruit(String name, LocalDate warehousingDate, long price) { this.name = name; this.warehousingDate = warehousingDate; this.price = price; } public long getId() { return id; } public String getName() { return name; } public LocalDate getWarehousingDate() { return warehousingDate; } public long getPrice() { return price; } public boolean isSold() { return isSold; } }이 클래스는 실제 DB와 1:1되는 클래스이다. 다중생성자와 getter를 만들어 두었다. ⚠ 엔티티 클래스에는 Setter 지양?!setter 메서드는 항상 public으로 어디든 접근이 가능하다. 이로 인하여 의도치 않게 다른 곳에서 엔티티의 속성들의 값이 변경될 우려가 있으므로 setter를 지양하는 것이 좋다. 7. DTO 개발package me.sungbin.dto.fruit.request; import jakarta.validation.constraints.Min; import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotNull; import me.sungbin.entity.fruit.Fruit; import org.springframework.format.annotation.DateTimeFormat; import java.time.LocalDate; /** * @author : rovert * @packageName : me.sungbin.dto.fruit.request * @fileName : SaveFruitInfoRequestDto * @date : 2/22/24 * @description : * =========================================================== * DATE AUTHOR NOTE * ----------------------------------------------------------- * 2/22/24 rovert 최초 생성 */ public class SaveFruitInfoRequestDto { @NotBlank(message = "과일 이름이 공란일 수 없습니다.") @NotNull(message = "과일 이름이 null일 수는 없습니다.") private String name; @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) private LocalDate warehousingDate; @Min(value = 0, message = "가격이 음수가 될 수는 없습니다.") private long price; public SaveFruitInfoRequestDto(String name, LocalDate warehousingDate, long price) { this.name = name; this.warehousingDate = warehousingDate; this.price = price; } public String getName() { return name; } public LocalDate getWarehousingDate() { return warehousingDate; } public long getPrice() { return price; } public Fruit toEntity() { return new Fruit(name, warehousingDate, price); } }요청 DTO로 각 필드마다 제약조건을 추가해줬다. 이로 인해서 name이 null이거나 공란이거나 price가 음수거나 warehousingDate가 DATE형이 아닐 때 예외를 발생시키게 validation을 해주었다.마지막에 toEntity()로 DTO로 실제 엔티티를 변환하는 메서드를 만들었다. 📚 요청과 응답으로 Entity 대신에 DTO 사용!위와 같이하면 다음과 같은 이점이 존재한다.1. 엔티티 내부 구현을 캡슐화 할 수 있다.2. 필요한 데이터만 선별이 가능하다.3. 순환참조를 예방할 수 있다.4. validation코드와 모델링 코드를 분리할 수 있다.8. Repository interface와 구현체 개발Repository interfacepackage me.sungbin.repository.fruit; import me.sungbin.entity.fruit.Fruit; /** * @author : rovert * @packageName : me.sungbin.repository.fruit * @fileName : FruitRepository * @date : 2/22/24 * @description : * =========================================================== * DATE AUTHOR NOTE * ----------------------------------------------------------- * 2/22/24 rovert 최초 생성 */ public interface FruitRepository { void saveFruitInfo(Fruit fruit); }Repository 구현체package me.sungbin.repository.fruit; import me.sungbin.entity.fruit.Fruit; import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.stereotype.Repository; /** * @author : rovert * @packageName : me.sungbin.repository.fruit * @fileName : FruitJdbcRepository * @date : 2/22/24 * @description : * =========================================================== * DATE AUTHOR NOTE * ----------------------------------------------------------- * 2/22/24 rovert 최초 생성 */ @Repository public class FruitJdbcRepository implements FruitRepository { private final JdbcTemplate jdbcTemplate; public FruitJdbcRepository(JdbcTemplate jdbcTemplate) { this.jdbcTemplate = jdbcTemplate; } @Override public void saveFruitInfo(Fruit fruit) { String sql = "INSERT INTO fruit (name, warehousingDate, price) VALUES (?, ?, ?)"; jdbcTemplate.update(sql, fruit.getName(), fruit.getWarehousingDate(), fruit.getPrice()); } }POST요청이고 저장하는 요청으로 위와 같이 saveFruitInfo 메서드에 INSERT 쿼리를 작성 후, jdbcTemplate을 이용한다.그리고 파라미터로 넘어오는 Fruit의 name, warehousingDate, price값이 넘어와 '?'와 매칭되고 쿼리가 실행된다.📚 Repository를 이렇게 나눈 이유?1. 관심사의 분리(Separation of Concerns): 이 구조는 애플리케이션의 다른 부분에서 데이터 액세스 로직을 분리합니다. 이렇게 하면 애플리케이션의 유지 보수가 용이해지고, 코드의 가독성이 향상됩니다.2. 확장성 및 유연성(Extensibility and Flexibility): 인터페이스를 사용함으로써, 다양한 유형의 저장소 구현체(예: JdbcTemplate, JPA, Hibernate 등)를 손쉽게 교체하거나 추가할 수 있습니다. 이는 애플리케이션의 요구사항이 변경되었을 때 새로운 기술을 적용하기 용이하게 만듭니다.3. 테스트 용이성(Testability): 인터페이스를 사용하면 개발자가 단위 테스트를 작성할 때 실제 데이터베이스에 의존하지 않고도 모의 객체(Mock Objects)를 사용하여 테스트를 할 수 있습니다. 이는 테스트의 실행 속도를 높이고, 테스트 환경을 간소화합니다. 9. 서비스 레이어 클래스 개발package me.sungbin.service.fruit; import me.sungbin.dto.fruit.request.SaveFruitInfoRequestDto; import me.sungbin.entity.fruit.Fruit; import me.sungbin.repository.fruit.FruitRepository; import org.springframework.stereotype.Service; /** * @author : rovert * @packageName : me.sungbin.service.fruit * @fileName : FruitService * @date : 2/22/24 * @description : * =========================================================== * DATE AUTHOR NOTE * ----------------------------------------------------------- * 2/22/24 rovert 최초 생성 */ @Service public class FruitService { private final FruitRepository fruitRepository; public FruitService(FruitRepository fruitRepository) { this.fruitRepository = fruitRepository; } public void saveFruitInfo(SaveFruitInfoRequestDto requestDto) { Fruit fruit = requestDto.toEntity(); this.fruitRepository.saveFruitInfo(fruit); } }requestDto의 엔티티 변환 메서드를 실행하여 DTO를 엔티티 타입으로 변환한다.repository 구현체에 작성했던 저장 쿼리가 있는 메서드를 호출한다.10. 컨트롤러 코드 수정package me.sungbin.controller.fruit; import jakarta.validation.Valid; import me.sungbin.dto.fruit.request.SaveFruitInfoRequestDto; import me.sungbin.service.fruit.FruitService; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; /** * @author : rovert * @packageName : me.sungbin.controller.fruit * @fileName : FruitController * @date : 2/22/24 * @description : * =========================================================== * DATE AUTHOR NOTE * ----------------------------------------------------------- * 2/22/24 rovert 최초 생성 */ @RestController @RequestMapping("/api/v1") public class FruitController { private final FruitService fruitService; public FruitController(FruitService fruitService) { this.fruitService = fruitService; } @PostMapping("/fruit") public void saveFruitInfo(@RequestBody @Valid SaveFruitInfoRequestDto requestDto) { this.fruitService.saveFruitInfo(requestDto); } }DTO를 요청의 body로 보낸다. 따라서 @RequestBody 어노테이션을 추가그럼 DTO는 서비스 레이어 저장하는 로직이 담긴 saveFruitInfo 메서드의 파라미터로 담기고 이 dto가 서비스 레이어에서 엔티티로 변환되고 이 엔티티가 repository로 들어가 insert 쿼리에 필요한 정보를 가져올 수 있게 되는 것이다.body에 담기 전에 dto에 적어준 validation 어노테이션이 동작하려면 @Valid 어노테이션이 있어야 한다.실행결과 오류 응답그러면 만약에 가격이 음수고 이름이 공란이거나 null이면 어떻게 될까? 200 OK가 뜰까? 당연히 안 뜰것이고 뜨는게 이상할 것이다. 코치님이 강의 중에 말씀하신 부분과 동일하다. 즉, validation 부분에서 예외가 발생하면 MethodArgumentNotValidException이 발생하는데 이 예외는 400에러 코드를 가진다. 따라서 400 Bade Request가 나올 것이다.테스트 코드이제 테스트코드를 한번 확인해보자. 테스트코드는 실패 테스트와 성공테스트 2개를 할 것이며, Junit5를 이용하여 테스트해보겠다. 1. 실패코드 (가격이 음수거나 과일 이름이 공란)package me.sungbin.controller.fruit; import com.fasterxml.jackson.databind.ObjectMapper; import me.sungbin.dto.fruit.request.SaveFruitInfoRequestDto; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; import org.springframework.boot.test.context.SpringBootTest; import org.springframework.http.MediaType; import org.springframework.test.web.servlet.MockMvc; import java.time.LocalDate; import static org.junit.jupiter.api.Assertions.*; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; /** * @author : rovert * @packageName : me.sungbin.controller.fruit * @fileName : FruitControllerTest * @date : 2/22/24 * @description : * =========================================================== * DATE AUTHOR NOTE * ----------------------------------------------------------- * 2/22/24 rovert 최초 생성 */ @SpringBootTest @AutoConfigureMockMvc class FruitControllerTest { @Autowired private MockMvc mockMvc; @Autowired private ObjectMapper objectMapper; @Test @DisplayName("문제 1번 통합 테스트 - 실패 (가격이 음수거나 과일 이름이 공란)") void question1_test_fail_caused_by_price_is_minus_or_fruit_name_is_empty() throws Exception { SaveFruitInfoRequestDto requestDto = new SaveFruitInfoRequestDto("", LocalDate.of(2024, 1, 1), -1000); this.mockMvc.perform(post("/api/v1/fruit") .contentType(MediaType.APPLICATION_JSON) .content(objectMapper.writeValueAsString(requestDto))) .andDo(print()) .andExpect(status().isBadRequest()); } }결과2. 성공코드@Test @DisplayName("문제 1번 통합 테스트 - 성공") void question1_test_success() throws Exception { SaveFruitInfoRequestDto requestDto = new SaveFruitInfoRequestDto("파인애플", LocalDate.of(2024, 2, 2), 20000); this.mockMvc.perform(post("/api/v1/fruit") .contentType(MediaType.APPLICATION_JSON) .content(objectMapper.writeValueAsString(requestDto))) .andDo(print()) .andExpect(status().isOk()); }결과한걸음 더!자바에서 정수를 다루는 방법이 int와 long으로 2가지 존재한다. 그런데 이 2가지 방법중에 위의 API에서 long을 사용한 이유가 뭘까?간단하다. int는 자료형이 4byte로 4byte의 범위(-21억~21억)를 넘어가는 가격이 존재할 수 있을 것이다. 예를 들어, 은행에서 대기업과 대기업사이의 돈 송금을 할때도 충분히 자료형을 벗어날 법하다. 또한 테이블의 PK에도 long타입을 자바에서 작성했는데 그 또한 마찬가지다. 지금은 데이터가 몇건이 없지만 추후에 서비스가 커지고 큰 구조가 된다면 int형은 충분히 넘을 것이다. 즉, 확장성을 고려해서라도 설계때부터 long타입을 담아두는 것이다.문제2문제해결1. Body로 넘길 DTO 개발package me.sungbin.dto.fruit.request; /** * @author : rovert * @packageName : me.sungbin.dto.fruit.request * @fileName : UpdateFruitRequestDto * @date : 2/22/24 * @description : * =========================================================== * DATE AUTHOR NOTE * ----------------------------------------------------------- * 2/22/24 rovert 최초 생성 */ public class UpdateFruitRequestDto { private long id; public UpdateFruitRequestDto(long id) { this.id = id; } public long getId() { return id; } }body를 id 하나의 필드만 넘겨주므로 id 하나의 필드만 존재하는 request DTO 개발2. Repository와 Repository 구현체에 메서드 추가package me.sungbin.repository.fruit; import me.sungbin.entity.fruit.Fruit; /** * @author : rovert * @packageName : me.sungbin.repository.fruit * @fileName : FruitRepository * @date : 2/22/24 * @description : * =========================================================== * DATE AUTHOR NOTE * ----------------------------------------------------------- * 2/22/24 rovert 최초 생성 */ public interface FruitRepository { void saveFruitInfo(Fruit fruit); void updateFruitInfo(long id); } 과일 정보 업데이트 선언부 정의package me.sungbin.repository.fruit; import me.sungbin.entity.fruit.Fruit; import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.stereotype.Repository; /** * @author : rovert * @packageName : me.sungbin.repository.fruit * @fileName : FruitJdbcRepository * @date : 2/22/24 * @description : * =========================================================== * DATE AUTHOR NOTE * ----------------------------------------------------------- * 2/22/24 rovert 최초 생성 */ @Repository public class FruitJdbcRepository implements FruitRepository { private final JdbcTemplate jdbcTemplate; public FruitJdbcRepository(JdbcTemplate jdbcTemplate) { this.jdbcTemplate = jdbcTemplate; } @Override public void saveFruitInfo(Fruit fruit) { String sql = "INSERT INTO fruit (name, warehousingDate, price) VALUES (?, ?, ?)"; jdbcTemplate.update(sql, fruit.getName(), fruit.getWarehousingDate(), fruit.getPrice()); } @Override public void updateFruitInfo(long id) { validateForUpdate(id); String sql = "UPDATE fruit SET is_sold = 1 WHERE id = ?"; jdbcTemplate.update(sql, id); } /** * 존재하지 않는 과일정보가 있을 것을 대비해 DB에 id값으로 조회 후, true false 반환 * @param id * @return */ private boolean isNotExistsFruitInfo(long id) { String readSQL = "SELECT * FROM fruit WHERE id = ?"; return jdbcTemplate.query(readSQL, (rs, rowNum) -> 0, id).isEmpty(); } /** * 존재하지 않는 과일정보를 접근할 경우 Exception 발생 * @param id */ private void validate(long id) { if (isNotExistsFruitInfo(id)) { throw new IllegalArgumentException("존재하는 과일정보가 없습니다."); } } } 주석에 써 있듯이 private 메서드들은 유효하지 않는 과일정보 접근을 대비해 예외처리를 해준 것이다.isNotExistsFruitInfo 메서드는 한번 DB를 id값으로 조회해서 유효한 과일정보면 false를 아니면 true를 반환validate 메서드를 통해 유효하지 않는 과일정보를 접근하려 하면 IllegalArgumentException을 발생업데이트 로직 전에 유효성 검사를 통하여 유효한 과일정보만 업데이트.서비스 코드 작성package me.sungbin.service.fruit; import me.sungbin.dto.fruit.request.SaveFruitInfoRequestDto; import me.sungbin.dto.fruit.request.UpdateFruitRequestDto; import me.sungbin.entity.fruit.Fruit; import me.sungbin.repository.fruit.FruitRepository; import org.springframework.stereotype.Service; /** * @author : rovert * @packageName : me.sungbin.service.fruit * @fileName : FruitService * @date : 2/22/24 * @description : * =========================================================== * DATE AUTHOR NOTE * ----------------------------------------------------------- * 2/22/24 rovert 최초 생성 */ @Service public class FruitService { private final FruitRepository fruitRepository; public FruitService(FruitRepository fruitRepository) { this.fruitRepository = fruitRepository; } public void saveFruitInfo(SaveFruitInfoRequestDto requestDto) { Fruit fruit = requestDto.toEntity(); this.fruitRepository.saveFruitInfo(fruit); } public void updateFruitInfo(UpdateFruitRequestDto requestDto) { this.fruitRepository.updateFruitInfo(requestDto.getId()); } }요청 DTO의 getter로 id값을 가져와 repository 코드에 전달컨트롤러 코드 작성package me.sungbin.controller.fruit; import jakarta.validation.Valid; import me.sungbin.dto.fruit.request.SaveFruitInfoRequestDto; import me.sungbin.dto.fruit.request.UpdateFruitRequestDto; import me.sungbin.service.fruit.FruitService; import org.springframework.web.bind.annotation.*; /** * @author : rovert * @packageName : me.sungbin.controller.fruit * @fileName : FruitController * @date : 2/22/24 * @description : * =========================================================== * DATE AUTHOR NOTE * ----------------------------------------------------------- * 2/22/24 rovert 최초 생성 */ @RestController @RequestMapping("/api/v1") public class FruitController { private final FruitService fruitService; public FruitController(FruitService fruitService) { this.fruitService = fruitService; } @PostMapping("/fruit") public void saveFruitInfo(@RequestBody @Valid SaveFruitInfoRequestDto requestDto) { this.fruitService.saveFruitInfo(requestDto); } @PutMapping("/fruit") public void updateFruitInfo(@RequestBody UpdateFruitRequestDto requestDto) { this.fruitService.updateFruitInfo(requestDto); } }PUT HTTP method를 이용하여 long 타입의 id가 존재하는 객체 body로 전달결과기존의 데이터위와 같이 데이터가 있다고 했을 때, 파인애플이 팔렸다고 해보자. 그러면 포스트맨으로 실습하면 아래와 같다. DB도 정확히 반영이 완료되었다. 에러 상황만약에 3번 id를 접근한다면 어떻게 될까? 한번 포스트맨으로 확인해보자.예상대로 500 에러가 발생했다. 그리고 콘솔도 확인해보자. 내가 작성한 메세지가 잘 출력 된 것을 확인할 수 있다. 테스트 코드그럼 테스트코드를 작성해보자. 이번 테스트코드는 성공 케이스만 해보자.기존 테이블의 데이터가 아래와 같이 있다 하자. 이 때 테스트 코드는 아래와 같다.@Test @DisplayName("문제 2번 통합 테스트 - 성공") void question2_test_fail_caused_by_not_exists_fruit_id() throws Exception { UpdateFruitRequestDto requestDto = new UpdateFruitRequestDto(3); this.mockMvc.perform(put("/api/v1/fruit") .contentType(MediaType.APPLICATION_JSON) .content(objectMapper.writeValueAsString(requestDto))) .andDo(print()) .andExpect(status().isOk()); }결과문제3문제해결먼저 문제3에 맞게 데이터를 맞춰본다.INSERT INTO fruit (name, warehousingDate, price, is_sold) values ("사과", "2024-01-01", 3000, true); INSERT INTO fruit (name, warehousingDate, price, is_sold) values ("사과", "2024-01-02", 4000, false); INSERT INTO fruit (name, warehousingDate, price, is_sold) values ("사과", "2024-01-03", 3000, true);위의 insert 쿼리문을 이용하여 데이터를 넣는다.  1. 응답 DTO 개발package me.sungbin.dto.fruit.response; /** * @author : rovert * @packageName : me.sungbin.dto.fruit.response * @fileName : GetFruitResponseDto * @date : 2/22/24 * @description : * =========================================================== * DATE AUTHOR NOTE * ----------------------------------------------------------- * 2/22/24 rovert 최초 생성 */ public class GetFruitResponseDto { private long salesAmount; private long notSalesAmount; public GetFruitResponseDto(long salesAmount, long notSalesAmount) { this.salesAmount = salesAmount; this.notSalesAmount = notSalesAmount; } public long getSalesAmount() { return salesAmount; } public long getNotSalesAmount() { return notSalesAmount; } }요구조건데로 각 필드는 long 타입으로 생성자와 getter를 만들어 두었다.2. Repository, Repository 구현체 개발package me.sungbin.repository.fruit; import me.sungbin.dto.fruit.response.GetFruitResponseDto; import me.sungbin.entity.fruit.Fruit; /** * @author : rovert * @packageName : me.sungbin.repository.fruit * @fileName : FruitRepository * @date : 2/22/24 * @description : * =========================================================== * DATE AUTHOR NOTE * ----------------------------------------------------------- * 2/22/24 rovert 최초 생성 */ public interface FruitRepository { void saveFruitInfo(Fruit fruit); void updateFruitInfo(long id); GetFruitResponseDto getFruitInfo(String name); }package me.sungbin.repository.fruit; import me.sungbin.dto.fruit.response.GetFruitResponseDto; import me.sungbin.entity.fruit.Fruit; import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.stereotype.Repository; import java.util.HashMap; import java.util.List; import java.util.Map; /** * @author : rovert * @packageName : me.sungbin.repository.fruit * @fileName : FruitJdbcRepository * @date : 2/22/24 * @description : * =========================================================== * DATE AUTHOR NOTE * ----------------------------------------------------------- * 2/22/24 rovert 최초 생성 */ @Repository public class FruitJdbcRepository implements FruitRepository { private final JdbcTemplate jdbcTemplate; public FruitJdbcRepository(JdbcTemplate jdbcTemplate) { this.jdbcTemplate = jdbcTemplate; } @Override public void saveFruitInfo(Fruit fruit) { String sql = "INSERT INTO fruit (name, warehousingDate, price) VALUES (?, ?, ?)"; jdbcTemplate.update(sql, fruit.getName(), fruit.getWarehousingDate(), fruit.getPrice()); } @Override public void updateFruitInfo(long id) { validate(id); String sql = "UPDATE fruit SET is_sold = 1 WHERE id = ?"; jdbcTemplate.update(sql, id); } @Override public GetFruitResponseDto getFruitInfo(String name) { String salesAmountSQL = "SELECT price FROM fruit WHERE name = ? AND is_sold = 1"; List<Long> salesAmounts = jdbcTemplate.query(salesAmountSQL, new Object[]{name}, (rs, rowNum) -> rs.getLong("price")); long salesAmount = salesAmounts.stream().reduce(0L, Long::sum); String notSalesAmountSQL = "SELECT price FROM fruit WHERE name = ? AND is_sold = 0"; List<Long> notSalesAmounts = jdbcTemplate.query(notSalesAmountSQL, new Object[]{name}, (rs, rowNum) -> rs.getLong("price")); long notSalesAmount = notSalesAmounts.stream().reduce(0L, Long::sum); validateGetFruitAmount(salesAmount, notSalesAmount); return new GetFruitResponseDto(salesAmount, notSalesAmount); } /** * 과일이 존재하지 않을 때 * @param salesAmount * @param notSalesAmount */ private void validateGetFruitAmount(long salesAmount, long notSalesAmount) { if (salesAmount == 0L && notSalesAmount == 0L) { throw new IllegalArgumentException("존재하는 과일이 없습니다."); } } /** * 존재하지 않는 과일정보가 있을 것을 대비해 DB에 id값으로 조회 후, true false 반환 * @param id * @return */ private boolean isNotExistsFruitInfo(long id) { String readSQL = "SELECT * FROM fruit WHERE id = ?"; return jdbcTemplate.query(readSQL, (rs, rowNum) -> 0, id).isEmpty(); } /** * 존재하지 않는 과일정보를 접근할 경우 Exception 발생 * @param id */ private void validate(long id) { if (isNotExistsFruitInfo(id)) { throw new IllegalArgumentException("존재하는 과일정보가 없습니다."); } } }팔린 양에 대한 SQL과 팔리지 않는 SQL을 따로 분리하여 나온 각 데이터의 price를 stream API를 이용하여 합친 후, 각각을 응답객체로 전달또한 각각의 데이터 합이 0인 경우는 과일이 존재하지 않는 것으로 알 수 있어 예외처리 3. 서비스 레이어 코드 작성 package me.sungbin.service.fruit; import me.sungbin.dto.fruit.request.SaveFruitInfoRequestDto; import me.sungbin.dto.fruit.request.UpdateFruitRequestDto; import me.sungbin.dto.fruit.response.GetFruitResponseDto; import me.sungbin.entity.fruit.Fruit; import me.sungbin.repository.fruit.FruitRepository; import org.springframework.stereotype.Service; /** * @author : rovert * @packageName : me.sungbin.service.fruit * @fileName : FruitService * @date : 2/22/24 * @description : * =========================================================== * DATE AUTHOR NOTE * ----------------------------------------------------------- * 2/22/24 rovert 최초 생성 */ @Service public class FruitService { private final FruitRepository fruitRepository; public FruitService(FruitRepository fruitRepository) { this.fruitRepository = fruitRepository; } public void saveFruitInfo(SaveFruitInfoRequestDto requestDto) { Fruit fruit = requestDto.toEntity(); this.fruitRepository.saveFruitInfo(fruit); } public void updateFruitInfo(UpdateFruitRequestDto requestDto) { this.fruitRepository.updateFruitInfo(requestDto.getId()); } public GetFruitResponseDto calculateSalesAmountAndNotSalesAmount(String name) { return this.fruitRepository.getFruitInfo(name); } } calculateSalesAmountAndNotSalesAmount 함수는 repository 구현체가 만든 메서드를 컨트롤러 쪽으로 다시 반환한다.4. Controller 코드 작성package me.sungbin.controller.fruit; import jakarta.validation.Valid; import me.sungbin.dto.fruit.request.SaveFruitInfoRequestDto; import me.sungbin.dto.fruit.request.UpdateFruitRequestDto; import me.sungbin.dto.fruit.response.GetFruitResponseDto; import me.sungbin.service.fruit.FruitService; import org.springframework.web.bind.annotation.*; /** * @author : rovert * @packageName : me.sungbin.controller.fruit * @fileName : FruitController * @date : 2/22/24 * @description : * =========================================================== * DATE AUTHOR NOTE * ----------------------------------------------------------- * 2/22/24 rovert 최초 생성 */ @RestController @RequestMapping("/api/v1") public class FruitController { private final FruitService fruitService; public FruitController(FruitService fruitService) { this.fruitService = fruitService; } @PostMapping("/fruit") public void saveFruitInfo(@RequestBody @Valid SaveFruitInfoRequestDto requestDto) { this.fruitService.saveFruitInfo(requestDto); } @PutMapping("/fruit") public void updateFruitInfo(@RequestBody UpdateFruitRequestDto requestDto) { this.fruitService.updateFruitInfo(requestDto); } @GetMapping("/fruit/stat") public GetFruitResponseDto getFruitInfo(@RequestParam String name) { return this.fruitService.calculateSalesAmountAndNotSalesAmount(name); } }쿼리파라미터는 단순 1개이므로 DTO 형식이 아닌 일반 타입으로 받고 서비스 레이어의 메서드를 실행해서 반환한다.결과에러만약에 존재하지 않는 과일을 파라미터로 넘겨주면 어떻게 될까? 테스트해보자. 위에서 예외처리를 해두었으므로 테스트만 해보자.성공적으로 500에러가 잘 나온다.콘솔도 잘 찍히고 정의한 메세지도 잘 출력이 된다. 더 나아가기SQL의 SUM과 GROUP BY 키워드를 적용해보라는 미션이 추가적으로 있다.미션을 수행하기 전에 각각의 키워드가 무엇인지 찾아봤다. SUM: 집계함수로, 총 합계를 구해주는 키워드GROUP BY: 집계함수의 결과를 특정 컬럼을 기준으로 묶어 결과를 출력해주는 쿼리 그럼 이제 Repository 구현 코드를 변경해보자. @Override public GetFruitResponseDto getFruitInfo(String name) { long salesAmount = 0; long notSalesAmount = 0; String sql = "SELECT SUM(price) as total, is_sold FROM fruit WHERE name = ? GROUP BY is_sold"; Map<Boolean, Long> aggregatedData = jdbcTemplate.query(sql, new Object[]{name}, rs -> { HashMap<Boolean, Long> map = new HashMap<>(); while (rs.next()) { map.put(rs.getBoolean("is_sold"), rs.getLong("total")); } return map; }); if (aggregatedData.containsKey(true)) { salesAmount = aggregatedData.get(true); } if (aggregatedData.containsKey(false)) { notSalesAmount = aggregatedData.get(false); } validateGetFruitAmount(salesAmount, notSalesAmount); return new GetFruitResponseDto(salesAmount, notSalesAmount); } 이렇게 작성하니 쿼리가 정말 간단하게 나왔다. 결과는 위와 동일했다.집계함수는 SUM외에도 여러가지 있으니 추후에 찾아봐야겠다. 더욱 열심히 해보자! 🔥 테스트 코드테스트 코드를 작성해보자. 이번에도 성공하는 경우만 작성해보겠다.@Test @DisplayName("문제 3번 통합 테스트 - 성공") void question3_test_success() throws Exception { this.mockMvc.perform(get("/api/v1/fruit/stat") .param("name", "사과")) .andDo(print()) .andExpect(status().isOk()); }결과📚 참고https://inf.run/XKQg) 

백엔드인프런워밍업스터디클럽백엔드API

양성빈

[인프런 워밍업 스터디 클럽] 0기 백엔드 미션 - 람다식 (Day3)

과제진도표 3일차와 연결됩니다우리는 JdbcTemplate을 사용하는 과정에서 익명 클래스와 람다식이라는 자바 문법을 사용했습니다. 익명 클래스는 자바의 초창기부터 있던 기능이고, 람다식은 자바 8에서 등장한 기능입니다. 다음 키워드를 사용해 몇 가지 블로그 글을 찾아보세요! 아래 질문을 생각하며 공부해보면 좋습니다! 😊 [키워드]익명 클래스 / 람다 / 함수형 프로그래밍 / @FunctionalInterface / 스트림 API / 메소드 레퍼런스 [질문]자바의 람다식은 왜 등장했을까?람다식과 익명 클래스는 어떤 관계가 있을까? - 람다식의 문법은 어떻게 될까?람다벌써 스터디 클럽 3일차가 되었다. 이번 강의에서는 DB 쿼리들에 대해서 배우고 DB를 Spring Boot 프로젝트와 연동하여 JdbcTemplate을 이용하여 실습을 해보았다. 이 과정에서 람다식이 나왔고, 오늘은 람다식에 대해 다뤄보도록 하겠다. 자바 개발자를 위한 코틀린 입문 - 17강(람다)람다를 본격적으로 다루기 전에 강의 중에 코치님이 '자바 개발자를 위한 코틀린 입문편'에 람다를 보는 것을 추천드린다고 하셔서 학습을 해보았다. Java에서 람다를 다루기 위한 노력먼저 예시 코드를 살펴보자.package me.sungbin.lecture; public class Fruit { private final String name; private final int price; public Fruit(String name, int price) { this.name = name; this.price = price; } public String getName() { return name; } public int getPrice() { return price; } }  그리고 main 함수에 아래와 같이 작성한다. List<Fruit> fruits = Arrays.asList( new Fruit("사과", 1_000), new Fruit("사과", 1_200), new Fruit("사과", 1_200), new Fruit("사과", 1_500), new Fruit("바나나", 3_000), new Fruit("바나나", 3_200), new Fruit("바나나", 2_500), new Fruit("수박", 10_000) ); 여기서 어느 손님이 와서 "사과만 보여주세요~"라고 말한다. 그러면 우리는 개발자로서 이에 해당하는 메서드를 만들어 작성할 것이다. 그런데 갑자기 조건이 붙기 시작한다. 사과뿐만 아니라, 바나나도 보여주고 각각 가격은 5000원 이상인 것들만 보여달라고 주문한다. 🤔 그래서 우리는 고민을 하다가 일일이 메서드를 만드는 것은 불필요하기에 인터페이스를 이용하기로 한다. package me.sungbin.lecture; public interface FruitFilter { boolean isSelected(Fruit fruit); }  위와 같이 인터페이스를 만들고 아래와 같이 메서드 안에 인터페이스를 적용하고 이 메서드를 호출하는 쪽에서 익명클래스로 구현해주면 된다. package me.sungbin; import me.sungbin.lecture.Fruit; import me.sungbin.lecture.FruitFilter; import java.util.ArrayList; import java.util.Arrays; import java.util.List; /** * @author : rovert * @packageName : org.example * @fileName : ${NAME} * @date : 2/21/24 * @description : * =========================================================== * DATE AUTHOR NOTE * ----------------------------------------------------------- * 2/21/24 rovert 최초 생성 */ public class Main { public static void main(String[] args) { List<Fruit> fruits = Arrays.asList( new Fruit("사과", 1_000), new Fruit("사과", 1_200), new Fruit("사과", 1_200), new Fruit("사과", 1_500), new Fruit("바나나", 3_000), new Fruit("바나나", 3_200), new Fruit("바나나", 2_500), new Fruit("수박", 10_000) ); filterFruits(fruits, new FruitFilter() { @Override public boolean isSelected(Fruit fruit) { return Arrays.asList("사과", "바나나").contains(fruit.getName()) && fruit.getPrice() >= 5_000; } }); } private static List<Fruit> filterFruits(List<Fruit> fruits, FruitFilter fruitFilter) { List<Fruit> results = new ArrayList<>(); for (Fruit fruit : fruits) { if (fruitFilter.isSelected(fruit)) { results.add(fruit); } } return results; } } 🥲 익명 클래스 아쉬운 점1. 익명클래스를 사용하는 것은 딱 봐도 복잡해 보인다.2. 다양한 Filter가 필요할 수도 있다. ex) 과일 간의 무게 비교, n개의 과일을 한번에 비교등등... 이러한 이유로 JDK8부터 람다(이름 없는 함수) 등장하였다. 또한 FruitFilter와 같은 인터페이스와 같은 Predicate, Consumer등을 많이 만들어 두었다. 그래서 위의 코드는 아래와 같이 변경되었다. package me.sungbin; import me.sungbin.lecture.Fruit; import me.sungbin.lecture.FruitFilter; import java.util.ArrayList; import java.util.Arrays; import java.util.List; import java.util.function.Predicate; /** * @author : rovert * @packageName : org.example * @fileName : ${NAME} * @date : 2/21/24 * @description : * =========================================================== * DATE AUTHOR NOTE * ----------------------------------------------------------- * 2/21/24 rovert 최초 생성 */ public class Main { public static void main(String[] args) { List<Fruit> fruits = Arrays.asList( new Fruit("사과", 1_000), new Fruit("사과", 1_200), new Fruit("사과", 1_200), new Fruit("사과", 1_500), new Fruit("바나나", 3_000), new Fruit("바나나", 3_200), new Fruit("바나나", 2_500), new Fruit("수박", 10_000) ); filterFruits(fruits, fruit -> fruit.getName().equals("사과")); } private static List<Fruit> filterFruits(List<Fruit> fruits, Predicate<Fruit> fruitFilter) { List<Fruit> results = new ArrayList<>(); for (Fruit fruit : fruits) { if (fruitFilter.test(fruit)) { results.add(fruit); } } return results; } } 💡 람다로 변경되면서 바뀐 점1. 호출하는 부분이 filterFruits(fruits, fruit -> fruit.getName().equals("사과")); 처럼 바뀌었다.2. 그 다음에 함수는 Predicate로 통하여 리팩토링을 할 수 있다. 변수 -> 변수를 이용하는 함수 혹은 (변수1, 변수2) -> 변수1과 변수2를 이용한 함수 이런 형태가 람다다. 여기서 또 JDK8에 위의 for문과 if문을 간결하게 처리하기 위해 간결한 스트림이 등장했다. (병렬처리에도 강점)그래서 코드가 아래와 같이 변경되었다. package me.sungbin; import me.sungbin.lecture.Fruit; import java.util.Arrays; import java.util.List; import java.util.function.Predicate; import java.util.stream.Collectors; public class Main { public static void main(String[] args) { List<Fruit> fruits = Arrays.asList( new Fruit("사과", 1_000), new Fruit("사과", 1_200), new Fruit("사과", 1_200), new Fruit("사과", 1_500), new Fruit("바나나", 3_000), new Fruit("바나나", 3_200), new Fruit("바나나", 2_500), new Fruit("수박", 10_000) ); filterFruits(fruits, fruit -> fruit.getName().equals("사과")); } private static List<Fruit> filterFruits(List<Fruit> fruits, Predicate<Fruit> fruitFilter) { return fruits.stream().filter(fruitFilter).collect(Collectors.toList()); } } 또한 람다는 아래와 같이 메서드 레퍼런스를 활용이 가능하다. package me.sungbin; import me.sungbin.lecture.Fruit; import java.util.Arrays; import java.util.List; import java.util.function.Predicate; import java.util.stream.Collectors; /** * @author : rovert * @packageName : org.example * @fileName : ${NAME} * @date : 2/21/24 * @description : * =========================================================== * DATE AUTHOR NOTE * ----------------------------------------------------------- * 2/21/24 rovert 최초 생성 */ public class Main { public static void main(String[] args) { List<Fruit> fruits = Arrays.asList( new Fruit("사과", 1_000), new Fruit("사과", 1_200), new Fruit("사과", 1_200), new Fruit("사과", 1_500), new Fruit("바나나", 3_000), new Fruit("바나나", 3_200), new Fruit("바나나", 2_500), new Fruit("수박", 10_000) ); filterFruits(fruits, Fruit::isApple); } private static List<Fruit> filterFruits(List<Fruit> fruits, Predicate<Fruit> fruitFilter) { return fruits.stream().filter(fruitFilter).collect(Collectors.toList()); } }비즈니스 로직을 도메인 메서드로 빼주고 이런 형식으로도 관리가 가능하다. 이렇게 자바에서 메서드 자체를 직접 넘겨주는 것처럼 보이지만 실제로는 인터페이스를 받기 때문이다.이 말은 메서드는 변수에 할당하거나 파라미터로 전달할 수 없고 2급시민으로 간주한다.위의 설명으로 잔도표에 3일차에 미션은 금방 끝나게 될 것이다. 하지만 단순 강의로 과제를 할 수는 없기에 내가 한번 더 찾아보고 학습한 내용을 공유드린다. 익명 클래스익명 클래스가 등장한 이유는 위의 강의처럼 사용자의 요구 조건이 메서드로 처리하기엔 너무 많아지고 조건이 복잡해지면서 인터페이스 혹은 추상클래스를 이용할 때 조금 간편하게 하기 위해 등장한 것이다. 조금 더 간결히 이야기를 하면 익명 클래스는 인터페이스나 추상 클래스의 인스턴스를 간편하게 생성하기 위해 등장했습니다.익명 클래스는 이름 없이 선언과 동시에 객체를 생성할 수 있는 클래스로, 주로 단일 사용 인스턴스에 대한 정의에 사용됩니다. 이러한 클래스는 GUI 이벤트 처리나 작은 콜백 객체 같은 곳에 유용하게 쓰입니다. 익명 클래스의 주된 목적은 코드의 간결성을 높이고, 즉석에서 필요한 구현을 제공하여 별도의 클래스 파일을 만들지 않아도 되게 하는 것입니다.그럼 예시 코드를 보자. package me.sungbin.blog; /** * @author : rovert * @packageName : me.sungbin.blog * @fileName : AnonymousClass * @date : 2/21/24 * @description : * =========================================================== * DATE AUTHOR NOTE * ----------------------------------------------------------- * 2/21/24 rovert 최초 생성 */ public class AnonymousClass { public static void main(String[] args) { Thread myThread = new Thread(new Runnable() { @Override public void run() { System.out.println("익명 클래스를 사용한 스레드 실행"); } }); myThread.start(); // Thread start } } 람다식(Lambda Expression)자바가 1996년 등장안 이후 두번의 큰 변화가 있었다. 첫번째 JDK 1.5 부터 추가된 제네릭의 등장이고, 두번째 JDK 1.8 부터 추가된 람다식의 등장이다. 람다의 도입으로 인해 자바는 객체지향언어인 동시에 함수형 언어가 되었다. 람다식이란?람다식은 간단히 말해 메서드를 하나의 식(expression) 으로 표현한 것이다. 람다식은 함수를 간략하면서도 명확한 식으로 표현할 수 있게 해준다.메서드를 람다식으로 표현하면 메서드의 이름과 반환값이 없어지므로, 람다식을 '익명 함수(anonymous function)' 이라고도 한다. package me.sungbin.blog; import java.util.Arrays; /** * @author : rovert * @packageName : me.sungbin.blog * @fileName : LambdaBlogEx1 * @date : 2/21/24 * @description : * =========================================================== * DATE AUTHOR NOTE * ----------------------------------------------------------- * 2/21/24 rovert 최초 생성 */ public class LambdaBlogEx1 { public static void main(String[] args) { int[] arr = new int[5]; Arrays.setAll(arr, (i) -> (int)(Math.random() * 5) + 1); } }  위의 코드에서 () → (int)(Math.random() * 5) + 1 구문이 람다식이다. 이 람다식이 하는 일을 메서드로 굳이 표현하면 아래와 같다. int method(){ return (int)(Math.random() * 5) + 1; } 메서드 형태보다 람다식이 간결하고 이해가 쉽다.게다가 모든 메서드는 클래스에 포함되어야 하므로 클래스도 새로 만들어야 하고, 객체도 생성해야 이 메소드를 호출할 수 있지만, 람다식은 이 과정 없이 오직 람다식 자체만으로 이 메서드의 역할을 수행할 수 있는 것이 큰 장점이다. 🙋🏻 메서드와 함수 차이함수는 수학에서 따온 것이다. 수학의 함수와 개념이 유사하다. 그러나 OOP에서는 함수대신 객체의 행위나 동작을 의미하는 메서드라는 용어를 사용한다.메서드는 함수와 같은 의미지만, 특정 클래스에 반드시 속해야 한다는 제약이 있기 때문에 기존의 함수와 같은 의미를 다른 용어를 선택해서 사용한 것이다. 그러나 이제 다시 람다식을 통해 메서드가 하나의 독립적인 기능을 하기 때문에 함수라는 용어를 사용하게 되었다. 람다식 작성하기람다식은 '이름 없는 함수'답게 메서드에서 이름과 반환타입을 제거하고 매개변수 선언부와 몸통 {} 사이에 '->'를 추가한다. AS-IS 반환타입 메서드이름(매개변수 선언){ 문장들 } TO-BE (매개변수 선언) -> { 문장들 } AS-IS int max(int a, int b){ return a> b ? a: b; } TO-BE (int a, int b) -> { return a > b ? a: b; } TO-BE에서 반환 값이 있는 메서드의 경우 return문 대신 '식(expression)'으로 대신할 수 있다.식의 연산결과가 자동적으로 반환된다.문장이 아닌 식으로 끝에 ';'를 붙이지 않는다.(int a, int b) -> a > b ? a: b // TO-BE 선언된 매개변수의 타입은 추론이 가능한 경우 생략할 수 있다.람다식에 반환타입이 없는 이유도 항상 추론이 가능하기 때문이다.(a, b) -> a > b ? a : b // TO-BE 선언된 매개변수가 하나인 경우 괄호() 를 생략할 수 있다.단, 매개변수의 타입이 있으면 괄호()를 생략할 수 없다.TO-BE a -> a * a // 올바른 예 int a -> a * a // 잘못된 예괄호{} 안의 문장이 하나일 때는 괄호{}를 생략할 수 있다.문장의 끝에 ';'을 붙이지 않아야 한다는 것에 주의한다.AS-IS (String name, int i) -> { System.out.println(name+"="+i); } TO-BE (String name, int i) -> System.out.println(name+"="+i) 함수형 인터페이스자바에서 모든 메서드는 클래스 내에 포함되어야 하는데, 람다식은 어떤 클래스에 포함되는 것일까?람다식은 익명 클래스의 객체와 동등하다. (int a, int b) -> a > b ? a : b // 위(람다식)와 아래(익명 클래스의 객체 내부 메소드)와 같다 new Object(){ int max(int a, int b){ return a > b ? a : b; } } 그렇다면, 람다식으로 정의된 익명 객체의 메서드를 어떻게 호출할 수 있을 것인가?참주변수가 있어야 객체의 메서드를 호출할 수 있으니 이 익명 객체의 주소를 f 라는 참조변수에 저장해본다.타입 f = (int a, int b) -> a > b ? a : b; // 여기서 참조변수 f의 타입은??? 참조변수 f의 타입은 어떤 것이어야 할까?참조형이니깐 클래스 또는 인터페이스가 가능하다.그리고 람다식과 동등한 메서드가 정의되어 있는 것이어야 한다.그래야만 참조변수로 익명 객체(람다식)의 메서드를 호출할 수 있기 때문이다.위 내용을 바탕으로 예를 들어 max()라는 메서드가 정의된 MyFunction 인터페이스가 정의되어 있다고 가정해본다. public interface MyFunction{ public abstract int max(int a, int b); } 위 인터페이스를 구현한 익명 클래스의 객체는 아래와 같이 생성가능하다. MyFunction f = new MyFunction() { @Override public int max(int a, int b) { return a > b ? a: b; } }; int big = f.max(5, 3); System.out.println(big); 여기서 MyFunction인터페이스에 정의된 메서드 max() 는람다식 '(int a, int b) → a > b ? a: b' 와 일치한다.→ 익명 객체를 담다식으로 아래와 같이 대체할 수 있다.MyFunction f = (a, b) -> a > b ? a: b; int big = f.max(5, 3); System.out.println(big); 위와 같이 MyFunction 인터페이스를 구현한 익명 객체를 람다식으로 대체할 수 있는 이유는람다식도 실제로는 익명 객체이고, MyFunction 인터페이스를 구현한 익명 객체의 메서드 max()와 람다식의 개맥변수 타입과 개수 그리고 반환값이 일치하기 때문이다. 하나의 메서드가 선언된 인터페이스를 정의해서 람다식을 다루는 것은 기존의 자바의 규칙들을 어기지 않으면서도 자연스럽다.그렇기 때문에 인터페이스를 통해 람다식을 다루기로 결정되었으며,람다식을 다루기 위한 인터페이스를 함수형 인터페이스(functional interface)라 부르기로 했다.@FunctionalInterface public interface MyFunction{ public abstract int max(int a, int b); }단, 함수형 인터페이스에서는 오직 하나의 추상 메서드만 정의되어 있어야 한다는 제약이 있다.그래야 람다식과 인터페이스의 메서드가 1:1로 연결될 수 있기 때문이다.다만, static 메서드와 default 메서드의 개수에는 제약이 없다.함수형 인터페이스로 구현한 인터페이스라면 반드시 '@FunctionalInterface' 애노테이션을 정의하도록 하자.컴파일러가 함수형 인터페이스를 올바르게 정의하였는지 확인해주니깐📚 바이트코드로 확인 (참고. https://dreamchaser3.tistory.com/5)람다는 익명 내부 클래스와 다르다.예제로 살펴보자.FCOnline, Readypackage me.sungbin.blog; /** * @author : rovert * @packageName : me.sungbin.blog * @fileName : FCOnline * @date : 2/21/24 * @description : * =========================================================== * DATE AUTHOR NOTE * ----------------------------------------------------------- * 2/21/24 rovert 최초 생성 */ @FunctionalInterface interface Ready { void setup(); } public class FCOnline { public void playing(Ready ready) { ready.setup(); System.out.println("FC Online is playing"); } }FCOnlineExamplepackage me.sungbin.blog; /** * @author : rovert * @packageName : me.sungbin.blog * @fileName : FCOnlineExample * @date : 2/21/24 * @description : * =========================================================== * DATE AUTHOR NOTE * ----------------------------------------------------------- * 2/21/24 rovert 최초 생성 */ public class FCOnlineExample { public static void main(String[] args) { FCOnline fcOnline = new FCOnline(); fcOnline.playing(new Ready() { @Override public void setup() { System.out.println("FC Online is ready??"); } }); } }위의 기본 예제와 람다 예제로 작성하였을 시 결과는 동일한데 어떻게 실행되고바코드가 동일한지 궁금한지 확인해보았다. 예제에서 익명클래스인 FCOnlineExample$1 새로운 클래스를 생성하여 초기화를 해주고 Ready 인터페이스를 실행하는 것과 같이 확인이 된다.컴파일 시, 익명내부클래스는 $과 같은 클래스 파일이 생긴다.익명클래스는 INVOKESPECIAL 이란 OPCODE로 생성자를 호출하고, INVOKEVIRTUAL로 Setting을 호출한다. 익명클래스 & Function Type자바에서는 왜 람다를 내부적으로 익명클래스로 컴파일하지 않을까?Java8 이전 버전에서 람다를 쓰기 위한 retrolambda 같은 라이브러리나, kotlin 같은 언어에서는 컴파일 시점에 람다를 단순히 익명클래스로 치환이 된다.다만, 익명 클래스로 사용할 경우 아래와 같은 문제가 발생할 수 있다.항상 새로운 인스턴스로 할당한다.람다식마다 클래스가 하나씩 생기게 된다.람다 예제 바이트 코드 람다예제의 바이트코드에서는 기본예제의 바이트코드와 다른점이 있었다.새로운 메서드를 static으로 생성하고 있는 부분을 볼 수 있다.중간즈음 INVOKEDYNAMIC setup() ... 이라는 구문을 볼 수 있는데 이 부분이 바로INVOKEDYNAMIC CALL → INDY 이다.INDY가 호출되게되면 bootstrap 영역의 lambdafactory.metafactory()를 수행하게 된다.lambdafactory.metafactory() : java runtime library의 표준화 method어떤 방법으로 객체를 생성할지 dynamically 를 결정한다.클래스를 새로 생성, 재사용, 프록시, 래퍼클래스 등등 성능향상을 위한 최적화된 방법을 사용하게 된다.java.lang.invoke.CallSite 객체를 return 한다.LambdaMetaFactory ~ 이렇게 되어 있는 곳의 끝에 CallSite 객체를 리턴하게 된다.해당 lambda의 lambda factory, MethodHandle을 멤버변수로 가지게 된다.람다가 변환되는 함수 인터페이스의 인터페이스를 반환한다.한번만 생성되고 재호출시 재사용이 가능하다. 📚 더 알아보기INVOKEDYNAMIC으로 구현되어 있는 이유는 여러가지가 있지만 자바의 버전이 올라갈 때 인보크 다이나믹으로 구현한 부분은 하위호환성을 유지하면서 개선할 여지를 가지고 있다.중간 정리 함수형 인터페이스 (Functional Interface)추상 메소드를 딱 하나만 가지고 있는 인터페이스SAM(Single Abstract Method) 인터페이스@FunctionalInterface 에노테이션을 가지고 있는 인터페이스 람다 표현식 (Lambda Expressions)함수형 인터페이스의 인스턴스를 만드는 방법으로 쓰일 수 있다.코드를 줄일 수 있다.메소드 매개변수, 리턴파입, 변수로 만들어 사용할 수 있다.자바에서 함수형 프로그래밍함수를 First Class Object로 사용할 수 있다.순수 함수 (Pure Function)사이드 이팩트 만들 수 없다. (함수 밖에 있는 값을 변경하지 못한다.)상태가 없다. (함수 밖에 정의되어 있는)고차 함수 (High-Order Function)함수가 함수를 매개변수로 받을 수 있고 함수를 리턴할 수 있다.추상 메서드 하나만 있으면 함수형 인터페이스다.MyFunction.java Interface는 추상메서드 하나만 가지고 있기 때문에 함수형 인터페이스@FunctionalInterface public interface MyFunction { int max(int a, int b); }-> @FunctionalInterface 애노테이션을 정의하고 나서 추가적인 추상 메서드를 입력하면, 컴파일 시 에러가 발생된다. (why? 함수형 인터페이스가 아니게 됨으로) 인터페이스에 static, default 메소드를 선언할 수 있다.아래와 같이 다른 형태(static, default) 메소드가 있더라도, 추상 메소드 하나만 있다면 FunctionalInterface 이다.@FunctionalInterface public interface MyFunction { int max(int a, int b); static void printNumber() { System.out.println(1); } default void printDefaultNumber() { System.out.println(0); } } 위에서 정의한 함수형 인터페이스를 이용해보자. public class App { public static void main(String[] args) { MyFunction myFunction = new MyFunction() { @Override public void printAnyThing() { System.out.println("anything"); } }; } }위의 코드는 익명내부클래스를 정의하는 방식이다.-> 람다로 변경 public class App { public static void main(String[] args) { MyFunction myFunction = () -> System.out.println("anything"); } } public class App { public static void main(String[] args) { MyFunction myFunction = () -> { System.out.println("anything"); System.out.println("Lambda"); }; myFunction.printAnyThing(); } }함수형 인터페이스를 인라인으로 구현한 오브젝트로 볼 수 있다.위 예시와 같이 구현한 자체를 Return 하거나, 메소드의 파라미터로 전달할 수도 있다.@FunctionalInterface public interface MyFunction { void printAnyThing(String name); static void printNumber() { System.out.println(1); } default void printDefaultNumber() { System.out.println(0); } }package me.sungbin.blog; /** * @author : rovert * @packageName : me.sungbin.blog * @fileName : App * @date : 2/21/24 * @description : * =========================================================== * DATE AUTHOR NOTE * ----------------------------------------------------------- * 2/21/24 rovert 최초 생성 */ public class App { public static void main(String[] args) { MyFunction myFunction = name -> System.out.println(name); myFunction.printAnyThing("양성빈"); myFunction.printAnyThing("인프런"); } } 같은 값을 넣었을 때 같은 값이 나오는 것. pure한 함수그렇지 않으면, 함수형 프로그래밍이 X그렇지 않은 경우가 어떻게? → 함수 밖에 있는 값을 참조해서 사용하는 경우 (상태값에 의존한다는 의미) → 외부에 있는 값을 변경하려는 경우package me.sungbin.blog; /** * @author : rovert * @packageName : me.sungbin.blog * @fileName : App * @date : 2/21/24 * @description : * =========================================================== * DATE AUTHOR NOTE * ----------------------------------------------------------- * 2/21/24 rovert 최초 생성 */ public class App { public static void main(String[] args) { MyFunction myFunction = new MyFunction() { int baseNumber = 100; @Override public void printAnyThing(String name) { baseNumber++; System.out.println(baseNumber + name); } }; } }아래와 같은 경우 참조는 할 수 있지만, 변경할 수 없다.final 이라 가정하고 사용될 수 있는 경우이다.package me.sungbin.blog; /** * @author : rovert * @packageName : me.sungbin.blog * @fileName : App * @date : 2/21/24 * @description : * =========================================================== * DATE AUTHOR NOTE * ----------------------------------------------------------- * 2/21/24 rovert 최초 생성 */ public class App { public static void main(String[] args) { int baseNumber = 100; MyFunction myFunction = name -> System.out.println(baseNumber + name); } }  이러한 경우들은 함수형 프로그래밍과 거리가 멀다.Java에서 기본으로 제공하는 함수형 인터페이스ava.lang.function 패키지자바에서 미리 정의해둔 자주 사용할만한 함수 인터페이스Function<T, R>BiFunction<T, U, R>Consumer<T>Supplier<T>Predicate<T>UnaryOperator<T>BinaryOperator<T>....Function<T, R>값을 하나 받아서 리턴하는 일반적인 함수R apply<T>Plus.java → implements Function<Integer, Integer>Integer값을 받아서 Integer 값으로 반환하고자 함.package me.sungbin.blog; import java.util.function.Function; /** * @author : rovert * @packageName : me.sungbin.blog * @fileName : Plus * @date : 2/21/24 * @description : * =========================================================== * DATE AUTHOR NOTE * ----------------------------------------------------------- * 2/21/24 rovert 최초 생성 */ public class Plus implements Function<Integer, Integer> { @Override public Integer apply(Integer integer) { return integer + 100; } } package me.sungbin.blog; /** * @author : rovert * @packageName : me.sungbin.blog * @fileName : App * @date : 2/21/24 * @description : * =========================================================== * DATE AUTHOR NOTE * ----------------------------------------------------------- * 2/21/24 rovert 최초 생성 */ public class App { public static void main(String[] args) { int baseNumber = 100; MyFunction myFunction = name -> System.out.println(baseNumber + name); Plus plus = new Plus(); System.out.println(plus.apply(1)); } } 위와 같은 동작을 하는 함수를 Plus라는 별도 클래스 없이도 사용할 수 있다.Function<Integer, Integer> 함수형 인터페이스를 바로 구현package me.sungbin.blog; import java.util.function.Function; /** * @author : rovert * @packageName : me.sungbin.blog * @fileName : App * @date : 2/21/24 * @description : * =========================================================== * DATE AUTHOR NOTE * ----------------------------------------------------------- * 2/21/24 rovert 최초 생성 */ public class App { public static void main(String[] args) { int baseNumber = 100; MyFunction myFunction = name -> System.out.println(baseNumber + name); Function<Integer, Integer> plus10 = (number) -> number + 100; System.out.println(plus10.apply(1)); } }  함수의 조합도 가능하다.compose입력값을 가지고 먼저 뒤에 오는 함수를 적용한다.그 결과값을 다시 입력값으로 사용하는 것이다.package me.sungbin.blog; import java.util.function.Function; /** * @author : rovert * @packageName : me.sungbin.blog * @fileName : App * @date : 2/21/24 * @description : * =========================================================== * DATE AUTHOR NOTE * ----------------------------------------------------------- * 2/21/24 rovert 최초 생성 */ public class App { public static void main(String[] args) { Function<Integer, Integer> plus10 = (number) -> number + 10; Function<Integer, Integer> multiply2 = (number) -> number * 2; System.out.println(plus10.apply(1)); System.out.println(multiply2.apply(1)); Function<Integer, Integer> multiply2AndPlus10 = plus10.compose(multiply2); System.out.println(multiply2AndPlus10.apply(2)); } }andThencompose와 반대로 먼저 적용하고 뒤에 오는 함수를 적용한다.package me.sungbin.blog; import java.util.function.Function; /** * @author : rovert * @packageName : me.sungbin.blog * @fileName : App * @date : 2/21/24 * @description : * =========================================================== * DATE AUTHOR NOTE * ----------------------------------------------------------- * 2/21/24 rovert 최초 생성 */ public class App { public static void main(String[] args) { Function<Integer, Integer> plus10 = (number) -> number + 10; Function<Integer, Integer> multiply2 = (number) -> number * 2; System.out.println(plus10.apply(1)); System.out.println(multiply2.apply(1)); Function<Integer, Integer> plus10AndMultiply2 = plus10.andThen(multiply2); System.out.println(plus10AndMultiply2.apply(2)); } } BiFunction<T, U, R>Function<T, R> 과 유사하지만, 입력값을 2개를 받는 것이다.(T, U) → RR apply(T t, U u) Consumer<T>리턴이 없다. | 함수 조합용 메소드 : andThenvoid Accept(T t)Consumer<T> 함수형 인터페이스 사용 예시package me.sungbin.blog; import java.util.function.Consumer; import java.util.function.Function; /** * @author : rovert * @packageName : me.sungbin.blog * @fileName : App * @date : 2/21/24 * @description : * =========================================================== * DATE AUTHOR NOTE * ----------------------------------------------------------- * 2/21/24 rovert 최초 생성 */ public class App { public static void main(String[] args) { Consumer<Integer> printT = System.out::println; printT.accept(100); } } Supplier<T>T 타입의 값을 제공해주는 함수형 인터페이스T get() Supplier<T> 사용 예시package me.sungbin.blog; import java.util.function.Consumer; import java.util.function.Function; import java.util.function.Supplier; /** * @author : rovert * @packageName : me.sungbin.blog * @fileName : App * @date : 2/21/24 * @description : * =========================================================== * DATE AUTHOR NOTE * ----------------------------------------------------------- * 2/21/24 rovert 최초 생성 */ public class App { public static void main(String[] args) { Supplier<Integer> get100 = () -> 100; System.out.println(get100.get()); } } Predicate<T>T 타입의 값을 받아서 boolean 을 반환하는 함수 인터페이스boolean test(T t) 함수 조합용 메소드And, Or, NegatePredicate<T> 사용 예시package me.sungbin.blog; import java.util.function.Consumer; import java.util.function.Function; import java.util.function.Predicate; import java.util.function.Supplier; /** * @author : rovert * @packageName : me.sungbin.blog * @fileName : App * @date : 2/21/24 * @description : * =========================================================== * DATE AUTHOR NOTE * ----------------------------------------------------------- * 2/21/24 rovert 최초 생성 */ public class App { public static void main(String[] args) { Predicate<String> startsWithYang = (str) -> str.startsWith("yang"); Predicate<Integer> isEven = (i) -> i % 2 == 0; } } UnaryOperator<T>Function<T, R>의 특수한 형태입력값 하나를 받아서 동일한 타입을 리턴하는 함수 인터페이스입력/리턴 값이 같으므로 이전의 Function<T, R> 을 아래와 같이 변경할 수 있다.package me.sungbin.blog; import java.util.function.*; /** * @author : rovert * @packageName : me.sungbin.blog * @fileName : App * @date : 2/21/24 * @description : * =========================================================== * DATE AUTHOR NOTE * ----------------------------------------------------------- * 2/21/24 rovert 최초 생성 */ public class App { public static void main(String[] args) { UnaryOperator<Integer> plus10 = (number) -> number + 10; UnaryOperator<Integer> multiply2 = (number) -> number * 2; } } BinaryOperator<T>BiFunction<T, U, R> 의 특수한 형태동일한 타입의 입력값 2개를 받아서 리턴하는 함수 인터페이스3개의 타입이 다 같을 것이라는 가정으로 작성됨. 📚 자바 api에서 제공해주는 함수형 인터페이스의 별명Function별명 : 트랜스포머(변신로봇)이유 : 값을 변환하기 때문에!Consumer별명 : Spartan (스파르탄!)이유 : 모든 것을 빼앗고 아무것도 내주지 마라 !Predicate별명 : 판사이유 : 참 거짓으로 판단하기 때문Suppliers별명 : 게으른 공급자.이유 : 입력값이 존재하지 않는데, 내가 원하는 것을 미리 준비하기 때문람다(인자 리스트) → {바디}인자 리스트인자가 없을 때 : ()인자가 한개 일 때 : (one) 또는 one인자가 여러개 일 때 : (one, two)인자 타입은 생략 가능→ 컴파일러가 추론(infer)하지만 명시할 수도 있다. → (Integer one, Integer two)바디화살표 오른쪽에 함수 본문을 정의여러 줄인 경우 { } 을 사용하여 묶는다.한 줄인 경우 생략 가능, return 또한 생략 가능하다.변수 캡쳐 (Variable Capture)로컬 변수 캡쳐final이거나 effective final인 경우에만 참조할 수 있다.변수를 변경되도록 수정해보면 이것은 effective final 인지 아닌지 확인가능하다.변경이 되면 effective final 이 아니게 되며, 람다에서 사용할 수 없게 된다.그렇지 않을 경우 concurrency 문제가 생길 수 있어서 컴파일이 불가능하다.effective final이것 역시 자바 8 부터 지원하는 기능으로 "사실상" final인 변수.final 키워드 사용하지 않은 변수를 익명 클래스 구현체 또는 람다에서 참조할 수 있다.익명 클래스 구현체와 달리 "쉐도윙" 하지 않는다.익명 클래스는 새로 스콥을 만들지만, 람다는 람다를 감싸고 있는 스콥과 같다.변수 캡쳐 케이스 예.→ run() 메소드의 int baseNumber 는 IntConsumer 람다에서 참조되고 있다.package me.sungbin.blog; import java.util.function.*; /** * @author : rovert * @packageName : me.sungbin.blog * @fileName : BlogExample * @date : 2/21/24 * @description : * =========================================================== * DATE AUTHOR NOTE * ----------------------------------------------------------- * 2/21/24 rovert 최초 생성 */ public class BlogExample { public static void main(String[] args) { /** * 순수함수프로그래밍을 할려면 외부에 있는 값을 변경하거나 참조하면 안된다. 함수 내부 및 파라미터만 가지고 써야한다. */ // int baseNumber = 10; // RunSomething runSomething = number -> number + baseNumber; // System.out.println(runSomething.doIt(2)); // // UnaryOperator<Integer> plus10 = (i) -> i + 10; // UnaryOperator<Integer> multiply2 = (i) -> i * 2; // Function<Integer, Integer> multiply2AndPlus10 = plus10.compose(multiply2); // // Consumer<Integer> printT = System.out::println; // Supplier<Integer> get10 = () -> 10; // // Predicate<String> startsWithSungbin = (str) -> str.startsWith("sungbin"); // // System.out.println(plus10.apply(1)); // System.out.println(multiply2.apply(1)); // System.out.println(multiply2AndPlus10.apply(2)); // System.out.println(plus10.andThen(multiply2).apply(2)); // printT.accept(10); // System.out.println(get10.get()); // System.out.println(startsWithSungbin.test("sungbin")); UnaryOperator<Integer> plus10 = (i) -> i + 10; UnaryOperator<Integer> multiply2 = (i) -> i * 2; System.out.println(plus10.andThen(multiply2).apply(2)); Supplier<Integer> get10 = () -> 10; BinaryOperator<Integer> sum = Integer::sum; BlogExample example = new BlogExample(); example.run(); } private void run() { int baseNumber = 10; // 내부 클래스 :: 쉐도잉 가능 class LocalClass { void printBaseNumber() { int baseNumber = 11; System.out.println(baseNumber); } } // 익명 클래스 :: 쉐도잉 가능 Consumer<Integer> integerConsumer = new Consumer<Integer>() { @Override public void accept(Integer baseNumber) { System.out.println(baseNumber); } }; // 람다 IntConsumer printInt = (i) -> { System.out.println(i + baseNumber); }; printInt.accept(10); LocalClass localClass = new LocalClass(); localClass.printBaseNumber(); integerConsumer.accept(11); } }  로컬 클래스와 익명 클래스 <> 람다와 다른 점→ 쉐도윙 (가려지는 것, 덮어지는 것)로컬 클래스와 익명 클래스는 메소드 내에서 새로운 Scope 이다.→ 쉐도잉 발생-> 쉐도잉 발생 int baseNumber = 10; // 1. 로컬 클래스 class LocalClass{ void PrintBaseNumber(){ int baseNumber = 11; // baseNumber 값은 11이 찍힐 것이다. (scope) // run 메소드에서 선언한 baseNumber에 대해 쉐도잉이 발생 System.out.println(baseNumber); } } // 2. 익명 클래스 Consumer<Integer> integerConsumer = new Consumer<Integer>() { @Override public void accept(Integer baseNumber) { // 파라미터로 전달받은 baseNumber 가 찍힐 것이다. // run 메소드에서 선언한 baseNumber에 대해 쉐도잉이 발생 System.out.println(baseNumber); } }; 람다는 람다를 감싸고 있는 메소드와 같은 Scope이다. → 같은 이름의 변수를 선언할 수 없다.→ 람다에 들어있는 변수와 람다가 사용되고 있는 클래스의 변수들은 같은 Scope이다.int baseNumber = 10; // 3. 람다 IntConsumer printInt = (baseNumber) -> { System.out.println(baseNumber); };위와 같이 선언하게 되는경우 에러가 발생하게 된다.→ Variable 'baseNumber' is already defined in the scope Variable Capture의 자세한 설명 추가람다식의 실행 코드 블록 내에서 클래스의 멤버 필드와 멤버 메소드, 그리고 지역 변수를 사용할 수 있다.클래스의 멤버 필드와 멤버 메소드는 특별한 제약없이 사용 가능하지만, 지역변수를 사용함에 있어 제약이 존재한다.이 내용을 이해하기 위해서는 jvm 메모리에 대해 알아야 한다. 잠시 람다식이 아닌 다른 얘기를 해보자.멤버 메소드 내부에서 클래스의 객체를 생성해서 사용할 경우 다음과 같은 문제가 있다.익명 구현 객체를 푸함해서 객체를 생성할 경우 new 라는 키워드를 사용한다.new라는 키워드를 사용한다는 것은 동적 메모리 할당 영역(이하 heap)에 객체를 생성한다는 의미이다.이렇게 생성된 객체는 자신을 감싸고 있는 멤버 메소드의 실행이 끝난 이후에도 heap영역에 존재하므로 사용할 수 있지만, 이 멤버 메소드에 정의된 매개변수나 지역 변수는 런타임 스택 영역(이하 stack)에 할당되어 메소드 실행이 끝나면 해당 영역에서 사라져 더 이상 사용할 수 없게 된다.그렇기 때문에 멤버 메소드 내부에서 생성된 객체가 자신을 감싸고 있는 메소드의 매개변수나 지역변수를 사용하려 할 때 문제가 생길 수 있다.클래스의 멤버 메소드의 매개변수와 이 메소드 실행 블록 내부의 지역변수는 JVM의 STACK에 생성되고 실행이 끝나면 STACK에서 사라진다.new 연산자를 사용해서 생성한 객체는 JVM의 HEAP영역에 객체가 생성되고 GC(Garbage Collector)에 의해 관리되며, 더 이상 사용하지 않는 객체에 대해 필요한 경우 메모리에서 제거한다.heap에 생성된 객체가 stack의 변수를 사용하려고 하는데, 사용하는 시점에 stack에 더 이상 해당 변수가 존재하지 않을 수 있다는 것이다.왜냐하면 stack은 메소드 실행이 끝나면 매개변수나 지역변수에 대해 제거하기 때문이다.그래서 더 이상 존재하지 않는 변수를 사용하려 할 수 있기 떄문에 오류가 발생한다.→ 자바는 이 문제를 Variable Capture 라고 하는 값 복사를 사용해서 해결한다.즉, 컴파일 시점에 멤버 메소드의 매개변수나 지역변수를 멤버 메소드 내부에서 생성한 객체가 사용할 경우 객체 내부로 값을 복사해서 사용한다.하지 모든 값을 복사해서 사용할 수 있는 것은 아니다.여기에도 제약이 존재하는데 final 키워드로 작성되었거나 final 성격을 가져야 한다.final 키워드는 알겠는데 final 성격을 가져야한다는 것은 왜그럴까?final 성격을 가진다는 것은 final 키워드로 선언된 것은 아니지만 값이 한번만 할당되어 final 처럼 쓰이는 것을 뜻한다.복잡한 내용과 예제가 존재하지만, 쉽게 생각한다면 익명 구현 객체를 사용할 때와 람다식을 사용했을 때 다음과 같은 차이점이 있다는 것만이라도 기억해보자.람다식은 익명 구현 객체 처럼 별도의 객체를 생성하거나 컴파일 결과 별도의 클래스를 생성하지 않는 다는 것이다.람다식 내부에서 사용하는 변수는 Variable Capture가 발생하며, 이 값은 final이거나 final처럼 사용해야 한다는 것이다.익명 구현 객체에 대해서는 new를 사용해서 객체도 생성된 것으로 보이고 별도 클래스 파일이 생긴 것을 확인할 수 있을 것이다.람다식이 쓰인 부분에서는 INVOKEDYNAMIC 이라는 OPCODE를 사용했는데, JAVA8 부터 생긴 것으로 interface의 default method와 lambda 식에서 사용된다고 한다.메소드 레퍼런스람다가 하는 일이 기준 메소드 또는 생성자를 호출하는 것이라면, 메소드 레퍼런스를 사용해서 매우 간결하게 표현할 수 있다. 메소드 참조하는 방법스태틱 메소드 참조 → 타입::스태틱 메소드특정 객체의 인스턴스 메소드 참조 → 객체 래퍼런스::인스턴스 메소드임의 객체의 인스턴스 메소드 참조 → 타입::인스턴스 메소드생성자 참조 → 타입::new메소드 또는 생성자의 매개변수로 람다의 입력값을 받는다.리턴값 또는 생성한 객체는 람다의 리턴 값이다. package me.sungbin.blog; /** * @author : rovert * @packageName : me.sungbin.blog * @fileName : Eating * @date : 2/21/24 * @description : * =========================================================== * DATE AUTHOR NOTE * ----------------------------------------------------------- * 2/21/24 rovert 최초 생성 */ public class Eating { private String food; public Eating() { } public Eating(String food) { this.food = food; } public String eat(String food) { return "eating " + food; } public static String keepEat(String food) { return "keep eating " + food; } } Function<T, R> 을 이용해 구현 가능하지만, 동일한 작업을 하는 Greeting 객체의 메소드를 활용하여 아래와 같이 작업해볼 수 있다.메소드 레퍼런스 -> Eating::keepEatpackage me.sungbin.blog; import java.util.function.UnaryOperator; /** * @author : rovert * @packageName : me.sungbin.blog * @fileName : App * @date : 2/21/24 * @description : * =========================================================== * DATE AUTHOR NOTE * ----------------------------------------------------------- * 2/21/24 rovert 최초 생성 */ public class App { public static void main(String[] args) { UnaryOperator<String> keepEatFunction = (s) -> "hi " + s; UnaryOperator<String> keepEatingObject = Eating::keepEat; System.out.println(keepEatingObject.apply("햄버거")); } }인스턴스 메서드 사용package me.sungbin.blog; import java.util.function.UnaryOperator; /** * @author : rovert * @packageName : me.sungbin.blog * @fileName : App * @date : 2/21/24 * @description : * =========================================================== * DATE AUTHOR NOTE * ----------------------------------------------------------- * 2/21/24 rovert 최초 생성 */ public class App { public static void main(String[] args) { Eating eating = new Eating(); UnaryOperator<String> printEat = eating::eat; System.out.println(printEat.apply("햄버거")); } }  생성자 사용Supplier를 이용한 것과 Function을 이용한 생성자 호출은 엄연히 다르다. Supplier는 인자가 없고 Function은 인자가 있다.사용하는 부분인 메소드 레퍼런스만 보면 "Eating::new" 와 동일하지만 다르다는 점!package me.sungbin.blog; import java.util.function.Supplier; import java.util.function.UnaryOperator; /** * @author : rovert * @packageName : me.sungbin.blog * @fileName : App * @date : 2/21/24 * @description : * =========================================================== * DATE AUTHOR NOTE * ----------------------------------------------------------- * 2/21/24 rovert 최초 생성 */ public class App { public static void main(String[] args) { // 입력값은 없는데 반환값은 있는 함수형 인터페이스 > Supplier Supplier<Eating> newEating = Eating::new; Eating eating = newEating.get(); } }package me.sungbin.blog; import java.util.function.Function; import java.util.function.Supplier; import java.util.function.UnaryOperator; /** * @author : rovert * @packageName : me.sungbin.blog * @fileName : App * @date : 2/21/24 * @description : * =========================================================== * DATE AUTHOR NOTE * ----------------------------------------------------------- * 2/21/24 rovert 최초 생성 */ public class App { public static void main(String[] args) { // 입력값 T 를 받아 R 반환 함수형 인터페이스 > Function Function<String, Eating> hamburgerEating = Eating::new; Eating hamburger = hamburgerEating.apply("햄버거"); } } 임의의 객체를 참조하는 메소드 레퍼런스package me.sungbin.blog; import java.util.Arrays; /** * @author : rovert * @packageName : me.sungbin.blog * @fileName : App * @date : 2/21/24 * @description : * =========================================================== * DATE AUTHOR NOTE * ----------------------------------------------------------- * 2/21/24 rovert 최초 생성 */ public class App { public static void main(String[] args) { String[] names = {"A", "B", "C", "D"}; Arrays.sort(names, String::compareToIgnoreCase); System.out.println(Arrays.toString(names)); } }람다식의 타입과 형변환 정리!함수형 인터페이스로 람다식을 참조할 수 있는 것일 뿐이지 람다식의 타입이 함수형 인터페이스의 타입과 일치하는 것은 아니다.람다식은 익명 객체이고 익명 객체는 타입이 없다.정확히는 타입은 있지만 컴파일러가 임의로 이름을 정하기 때문에 알 수 없는 것이다.그래대입 연산자의 양변의 타입을 일치시키기 위해 형변환이 필요하다. MyFunction f = (MyFunction)(() -> {...});람다식은 MyFunction 인터페이스를 직접 구현하지 않았지만, 이 인터페이스를 구현한 클래스의 객체와 완전히 동일하기 때문에 위와 같은 형변환을 허용한다. 그리고 이 형변환은 생략가능하다.람다식은 이름이 없을 뿐 분명히 객체인데도, Object 타입으로 형변환할 수 없다.람다식은 오직 함수형 인터페이스로만 형변환이 가능하다. Object obj = (Object)( () -> { ... }); // ERROR. 함수형 인터페이스로만 가능하다. 굳이 변경하고자 한다면, 함수형 인터페이스로 변환하고 난 후 가능하다.다음 예제를 통해 컴파일러가 람다식의 타입을 어떤 형식으로 만들어내는지 알아보자. package me.sungbin.blog; /** * @author : rovert * @packageName : me.sungbin.blog * @fileName : MyFunction02 * @date : 2/21/24 * @description : * =========================================================== * DATE AUTHOR NOTE * ----------------------------------------------------------- * 2/21/24 rovert 최초 생성 */ @FunctionalInterface public interface MyFunction02 { void myMethod(); }package me.sungbin.blog; /** * @author : rovert * @packageName : me.sungbin.blog * @fileName : App * @date : 2/21/24 * @description : * =========================================================== * DATE AUTHOR NOTE * ----------------------------------------------------------- * 2/21/24 rovert 최초 생성 */ public class App { public static void main(String[] args) { MyFunction02 f = () -> {}; Object obj = (MyFunction02) (() -> {}); String str = ((Object) (MyFunction02) (() -> {})).toString(); System.out.println(f); System.out.println(obj); System.out.println(str); System.out.println((MyFunction02) (() -> {})); System.out.println(((Object) (MyFunction02) (() -> {})).toString()); } } 일반적인 익명 객체라면, 객체의 타입이 외부클래스이름$번호 와 같은 형식으로 타입이 결정되었을텐데, 람다식의 타입은 외부클래스이름$Lambda$번호 와 같은 형식으로 되어 있는 것을 확인할 수 있다. INDY 상세한 정리!INVOKEDYNAMIC 의 내부 동작 분석Java SE7 부터 등장한 새로운 바이트코드 셋이다.기존에는 invoke 시리즈는 4가지만 존재하였다. (invoke + virtual/static/interface/special)invokevirtual : instance 메소드를 디스패치 하기 위한 명령어invokestatic : static 메소드를 디스패치 하기 위한 명령어invokeinterface : 인터페이스를 통해서 method를 디스패치 하기 위한 명령어invokespecial : 생성자, 수퍼클래스, private method등 invoke-virtual이 아닌 메소드들을 디스패치 하기 위한 명령어저 명령어들을 보면 어느 한 메소드를 부르기 위해서는 아래와 같은 4가지 메소드 정보가java에 명확하게 선언되어 있어야 한다.메소드의 이름메소드 시그니처 + return type메소드가 정의되어 있는 클래스메소드를 실행할 수 있는 바이트 코드.즉, 메소드를 부르기 위해서는 위와 같은 정보들이 자바 lang을 통해 고정되어 있고 이미 모든 것이 구현되어 있고 컴파일까지 완벽하게 되어 있어서 JVM에서 바이트코드를 읽기만 하면 된다.Java SE7 에서는 언어의 로직들이 컴파일러가 번역해준 메소드 실행 로직이 아닌, 런타임에서 메소드를 실행할 CALL TARGET을 정할 수 있도록 INVOKEDYNAMIC이라는 바이트코드를 추가하였다. Java 코드로 작성된 Lambda랑 INVOKEDYNAMIC 과 무슨 상관일까?invokedynamic의 예시를 보면, 마치 다양한 언어를 JVM 위에서 돌릴 수 있도록 추가된 바이트코드처럼 보인다.람다는 Java로 쓰여져있고, 람다를 통해 생성된 바이트코드를 보면 모두 컴파일 시점에 자바 메소드로 재분해(desugar) 확인할 수 있다.그렇다면 람다가 굳이 invokedynamic을 사용하도록 컴파일되는 이유는 무엇일까? 특정 translation strategy (람다를 → 가용가능한 자바 로직으로 변환하는 과정 및 전략) 에는 두가지 고려사항이 존재한다.미래의 최적화를 위해서 특정 전략으로 고정하지 않는 것. (즉, 최대한 바이트코드로 고정되어 컴파일 되지 않게 하는 것)특정 translation strategy (실제 자바 실행 로직으로 변환하는 과정 및 전략)을 런타임에서 결정할 수 있다. 따라서, 전략이 미래에 변해서 JVM 스팩이 업데이트 되었다고 하더라도 소스코드 수정이나, 재컴파일 없이 그대로 실행 가능하다.만약 람다가 컴파일 타임에서 완벽하게 변환되었다고 한다면, 나중에 수정사항이 있을 경우 프로젝트를 모두 재컴파일 해야 하는 일이 발생한다.클래스 파일 표현에 안정성을 가지는 방법람다는 두가지의 이점을 가지기 위해,'람다라는 표현 () → {}' 을 '실제 자바 메소드 실행 로직' (바이트코드로 나온 실행 메소드)로 컴파일 타임이 아닌 INVOKEDYNAMIC 을 통해서 런타임에서 로직을 연결하고 실행한다.이를통해서, 실제 람다 표현을 실행하는 로직을 결정하는 전략을 런타임에서 LAZY 하게 셜정할 수 있다는 것이 장점이라고 생각된다. Invokedynamic + lambda 실행public class SimpleLambda{ public static void main(String[] args){ Runnable lambda = invokedynamic( bootstrap = LambdaMetafactory, staticargs = [Runnable, lambda$0], dynargs=[]); lambda.run(); } private static void lambda$0(){ System.out.println(1); } }📚 참고: https://d2.naver.com/helloworld/4911107?fbclid=IwAR2KrFe7ksfRr4cDQWWGqvFpQDB6B4MVCh_zlMjZFZh5NVD5KSWHg8nV46U invokedynamic()Bootstrap Method (or BSM)부트스트랩 메소드는 invokedynamic() + bootstrap method()를 통해 들어온 정보를 기반으로, 호출 대상을 찾아서 연결하고 CallSite 객체를 반환한다.CallSite가 반환되기 전을 unlaced 상태(실제 로직이 연결되지 않은 상태)라고 하며, invokedynamic() 콜이 제대로 불려 실제로 linkage가 일어나면 **laced(연결된) 상태**라고 불린다.VM이 bootstrap method를 부를 때에, CallSite를 lazy 하게 반환하며, 반환 후에는 CallSite를 통해 람다와 실제 함수 구현 부분이 연결된다.연결을 한번 한 뒤에는 계속 연결상태가 되기 때문에, 따로 링킹을 시도하지 않는다.Lambda는 LambdaMetafactory.metafactory 라는 부트스트랩 메소드를 부른다.1-1). CallSite & MethodHandle이 CallSite 객체에는 실제 메소드를 실행시킬 메소드 (아까 바이트코드에서 봤던 private static method) 포함되어 있다.📚 참조 https://www.slideshare.net/DanHeidinga/invokedynamic-evolution-of-a-language-feature CallSite 가 멤버변수로 가지고 있는 실제 로직을 가리키고 있는 메소드는 MethodHandle로 표현된다.아래의 코드 예제를 보면, 일반적으로 Reflection API를 통해 메소드를 Method 객체를 얻은것과 비슷해 보이지만 훨씬 더 VM 레벨에서 작동하는 메소드이다. public MethodHandle getToStringMH() { MethodHandle mh = null; // 메소드 타입 부터 첫번째 파라미터는 Return type, 두번째부터는 메소드 파라미터들.. MethodType mt = MethodType.methodType(String.class); // 메소드 handles의 lookup() = "lookup context" MethodHandles.Lookup lk = MethodHandles.lookup(); try { // mh를 통해 toString메소드를 찾음. mh = lk.findVirtual(getClass(), "toString", mt); } catch (NoSuchMethodException | IllegalAccessException mhx) { throw (AssertionError)new AssertionError().initCause(mhx); } return mh; } 2) staticargs, dynargsstaticargs : 바이트 코드로 변환된 람다 실제 메소드 정보dynargs : 람다 스코프 외 외부변수 참조 시, dynargs에 포함됨. invokedynamic 이 부트스트랩 메소드를 여러 인자 요소들(메소드 정보, 메소드 구현체 정보, 타입 정보 등..)과 함께 부른다. BootstrapMethod는 CallSite를 반환한다. CallSite는 target method의 정보를 가지고 있는 method handler를 멤버 변수로 가지고 있고, 이 method handler를 통해 실제 동작하는 메소드와 연결된다.즉, lambda로 인해 컴파일 시점에 추가된 static/instance 함수 (예 : private static void lambda$0)를 LambdaMetaFactory가 MethodHandler를 통해 관리해주고 있는 형태로 볼 수 있다.Stream 소개Streamsequence of elements supporting sequential and parallel aggregate operations데이터를 담고 있는 저장소(컬렉션)이 아니다.Functional in nature, 스트림이 처리하는 데이터 소스를 변경하지 않는다.→ Functional 하다.→ 결과가 또다른 stream이 되는 것이지, 전달받은 데이터 자체가 변경되는 것이 아니다.스트림으로 처리하는 데이터는 오직 한번만 처리한다.→ 컨베이어 밸트에 항목이 한번 지나가는 것이라고 보면된다. (한번 지나면 끝)무제한일 수도 있다. (Short Circuit 메소드를 사용해서 제한할 수 있다.)중개 오퍼레이션은 근본적으로 lazy 하다.→ stream에 사용하는 것은 2개로 나눌 수 있다. (중개, 종료)→ 중개 오퍼레이션은 lazy.. ???→ 중개형 오퍼레이션은 종료 오퍼레이션이 오기 전까지 실행되지 않는다. package me.sungbin.blog; import java.util.ArrayList; import java.util.List; import java.util.Locale; /** * @author : rovert * @packageName : me.sungbin.blog * @fileName : App * @date : 2/21/24 * @description : * =========================================================== * DATE AUTHOR NOTE * ----------------------------------------------------------- * 2/21/24 rovert 최초 생성 */ public class App { public static void main(String[] args) { List<String> names = new ArrayList<>(); names.add("손흥민"); names.add("박지성"); names.add("이강인"); names.add("김민재"); names.add("조규성"); names.stream().map(x -> { System.out.println(x); return x.toUpperCase(Locale.ROOT); }); System.out.println("=========="); names.forEach(System.out::println); } }.map() 바디에 선언한 System.out.println 은 찍히지 않는 것을 볼 수 있다.종료형 오퍼레이션이 반드시 한번 와야 하며, 종료형 오퍼레이션이 오지 않으면 중계형 오퍼레이터는 의미가 없다. (실행x) 손쉽게 병렬 처리 할 수 있다.병렬처리를 하는 것이 모두 빠른 것이 아니다. 더 느릴 수 있다. Thread를 만들어서 Thread별로 병렬로 처리하고 수집하는 일련의 과정이 발생된다.데이터가 정말 방대하게 큰 경우 유용하게 사용될 수 있으나, 그게 아니라면 stream 권장Stream Pipline0 또는 다수의 중개 오퍼레이션 (intermediate operation)과 한개의 종료 오퍼레이션(terminal operation)으로 구성한다.스트림의 데이터 소스는 오직 터미널 오퍼레이션을 실행할 때만 처리한다.중개 오퍼레이션Stream을 리턴한다.Stateless / Stateful 오퍼레이션으로 더 상세하게 구분할 수도 있다.대부분 Stateless 지만 distinct나 sorted 처럼 이전 소스 데이터를 참조해야 하는 오퍼레이션은 Stateful 오퍼레이션이다.filter, map, limit, skip, sorted ...종료 오퍼레이션Stream을 리턴하지 않는다.collect, allMatch, count, forEach, min, maxStream API 걸러내기Filter(Predicate)예) 이름이 3글자 이상인 데이터만 새로운 스트림으로변경하기Map(Function) 또는 FlatMap(Function)예) 각각의 Post인스턴스에서 String Title만 새로운 스트림으로예) List<STream<String>> 을 String의 스트림으로생성하기generate(Supplier) 또는 Iterate(T seed, UnaryOperator)예) 10부터 1씩 증가하는 무제한 숫자 스트림예) 랜덤 int 무제한 스트림제한하기limit(long) 또는 skip(long)예) 최대 5개의 요소가 담긴 스트림을 리턴한다.예) 앞에서 3개를 뺀 나머지 스트림을 리턴한다.package me.sungbin.blog; import java.util.ArrayList; import java.util.List; import java.util.Locale; /** * @author : rovert * @packageName : me.sungbin.blog * @fileName : App * @date : 2/21/24 * @description : * =========================================================== * DATE AUTHOR NOTE * ----------------------------------------------------------- * 2/21/24 rovert 최초 생성 */ public class App { public static void main(String[] args) { List<Blog> developBlogs = new ArrayList<>(); developBlogs.add(new Blog(1L, "자바", "스트림 API", true)); developBlogs.add(new Blog(1L, "mysql", "롤백", true)); developBlogs.add(new Blog(1L, "spring boot", "DI", false)); developBlogs.add(new Blog(1L, "spring data jpa", "트랜잭션", false)); developBlogs.add(new Blog(1L, "spring security", "cors", false)); System.out.println("spring 으로 시작하는 블로그"); System.out.println("공개 블로그"); System.out.println("블로그 이름만 만들어서 스트림 만들기"); } } 1. spring으로 시작하는 수업 만들기System.out.println("spring 으로 시작하는 수업"); List<Blog> blogsByTitleIsSpringStartWith = developBlogs.stream() .filter(x -> x.getTitle().startsWith("spring")) .collect(Collectors.toList()); blogsByTitleIsSpringStartWith.forEach(x -> System.out.println(" > " + x.getTitle())); 2. secret되지 않는 블로그System.out.println("공개 블로그"); List<Blog> notSecretBlog = developBlogs.stream() .filter(x -> !x.isSecret()) .collect(Collectors.toList()); notSecretBlog.forEach(x -> System.out.println(" > " + x.getTitle())); 3. 블로그 이름만 모아서 스트림 만들기List<String> blogsByTitle = developBlogs.stream() .map(Blog::getTitle) .collect(Collectors.toList()); blogsByTitle.forEach(x -> System.out.println(" > " + x));연습 https://www.inflearn.com/course/the-java-java8public class StreamApp { public static void main(String[] args) { List<OnlineClass> springClasses = new ArrayList<>(); springClasses.add(new OnlineClass(1, "spring boot", true)); springClasses.add(new OnlineClass(2, "spring data jpa", true)); springClasses.add(new OnlineClass(3, "spring mvc", false)); springClasses.add(new OnlineClass(4, "spring core", false)); springClasses.add(new OnlineClass(5, "rest api development", false)); List<OnlineClass> javaClasses = new ArrayList<>(); javaClasses.add(new OnlineClass(6, "The Java, Test", true)); javaClasses.add(new OnlineClass(7, "The Java, Code manipulation", true)); javaClasses.add(new OnlineClass(8, "The Java, 8 to 11", false)); List<List<OnlineClass>> ssonEvents = new ArrayList<>(); ssonEvents.add(springClasses); ssonEvents.add(javaClasses); System.out.println("두 수업 목록에 들어있는 모든 수업 아이디 출력"); // todo System.out.println("10부터 1씩 증가하는 무제한 스트림 중에서 앞에 10개 빼고 최대 10개 까지만"); // todo System.out.println("자바 수업 중에 Test가 들어있는 수업이 있는지 확인"); // todo System.out.println("스프링 수업 중에 제목에 spring이 들어간 것만 모아서 List로 만들기"); // todo } } 1. 두 수업 목록에 들어있는 모든 수업 아이디 출력리스트를 항목으로 갖고 있는 것을 Flat 하게 변형한다. → 안에 있는 것들을 다 꺼낸다(?)FlatMap → 모든 항목들을 풀어내는 것System.out.println("두 수업 목록에 들어있는 모든 수업 아이디 출력"); ssonEvents.stream().flatMap(Collection::stream) .forEach(oc -> System.out.println(oc.getId())); 2. 10부터 1씩 증가하는 무제한 스트림 중에서 앞에 10개 빼고 최대 10개 까지만Stream.iterator → skip, limitSystem.out.println("10부터 1씩 증가하는 무제한 스트림 중에서 앞에 10개 빼고 최대 10개 까지만"); Stream.iterate(10, i -> i + 1) .skip(10) .limit(10) .forEach(System.out::println); 3. 자바 수업 중에 Test가 들어있는 수업이 있는지 확인Match (any, all ,...)System.out.println("자바 수업 중에 Test가 들어있는 수업이 있는지 확인"); boolean isTestClasses = javaClasses.stream().anyMatch(x -> x.getTitle().contains("Test")); System.out.println("isTestClasses : " + isTestClasses);4. 스프링 수업 중에 제목에 spring이 들어간 타이틀만 모아서 List로 만들기System.out.println("스프링 수업 중에 제목에 spring이 들어간 제목만 모아서 List로 만들기"); List<String> springTitleClasses = springClasses.stream() .filter(x -> x.getTitle().contains("spring")) .map(OnlineClass::getTitle) .collect(Collectors.toList()); springTitleClasses.forEach(x -> System.out.println(" > " + x));🛠 마무리이렇게 익명클래스, 람다, 함수형 프로그래밍, @FunctionalInterface, 스트림 API, 메서드 레퍼런스에 대해 알아보고 깊게 파보았다.그러면 질문에 대해 대답해보자. Q.자바의 람다식은 왜 등장했을까?자바의 람다식은 함수형 프로그래밍을 자바에 통합하기 위해 자바 8에서 도입됐다. 이는 한 개의 추상 메소드를 가진 인터페이스를 간결하게 구현할 수 있게 해주며, 코드 양을 줄이고 가독성을 향상시키는 이점을 제공한다. 람다식과 익명 클래스는 모두 익명 함수를 구현하는 방식이지만, 람다식은 더 간결한 문법과 함수형 인터페이스의 직접적 사용을 가능하게 한다. 람다식의 도입으로 자바에서도 함수형 프로그래밍 패러다임을 효율적으로 적용할 수 있게 됐다.람다식은 자바 8에서 처음 소개됐으며, 함수형 프로그래밍 개념을 자바에 도입하기 위한 목적으로 만들어졌다. 익명 클래스에 비해 람다식은 코드를 더 간결하게 만들고, 함수형 인터페이스를 이용해 메소드를 보다 직관적으로 표현할 수 있다. 람다식은 (매개변수) -> { 표현식 } 형태로 작성되며, 이를 통해 인터페이스의 구현체를 더욱 간단하게 작성할 수 있다.함수형 인터페이스는 단 하나의 추상 메소드를 가지는 인터페이스로, @FunctionalInterface 어노테이션을 사용해 명시적으로 정의할 수 있다. 이 인터페이스는 람다식을 통해 구현될 수 있으며, 자바는 java.util.function 패키지를 통해 다양한 함수형 인터페이스를 제공한다. 이를 통해 개발자는 보다 함수적인 프로그래밍 방식을 자바에서도 적용할 수 있게 됐다.람다식의 도입은 자바의 내부적인 동작 방식에도 영향을 미쳤다. 람다식은 invokedynamic 바이트코드 명령을 사용해 동적으로 메소드 타입과 메소드 핸들을 결정한다. 이는 람다식이 실행될 때마다 인터페이스의 메소드 호출이 아니라, 실제로 실행되는 함수형 인터페이스의 구현체를 동적으로 생성하고 호출하는 메커니즘을 가능하게 한다. 이 과정은 자바 가상 머신(JVM)의 성능 최적화와 밀접하게 관련되어 있으며, 람다식을 통한 함수형 프로그래밍의 효율적인 실행을 지원한다.Q. 람다식과 익명 클래스는 어떤 관계가 있을까? - 람다식의 문법은 어떻게 될까?문법은 위에서 설명을 했으니 위의 질문 혹은 상단 블로그에 참조바랍니다.람다식과 익명 클래스 모두 인터페이스의 구현체를 생성하는데 사용될 수 있지만, 람다식은 함수형 인터페이스에 한정된다는 점에서 차이가 있다. 람다식은 문법적으로 더 간결하며, 코드를 더 읽기 쉽게 만들어 준다. 반면, 익명 클래스는 여러 메소드를 오버라이드해야 할 때 또는 함수형 인터페이스가 아닌 경우에 여전히 유용하다.람다식은 자바의 함수형 프로그래밍 패러다임을 강화하는데 기여했으며, 익명 클래스보다 더 간결하고 표현력 있는 코드 작성을 가능하게 한다. 하지만, 익명 클래스는 람다식으로 대체할 수 없는 경우에 여전히 그 가치를 지닌다. 예를 들어, 여러 메소드를 구현해야 하거나, 슈퍼 클래스의 생성자를 호출해야 하는 경우 익명 클래스를 사용해야 한다.람다식의 도입으로 자바 개발자들은 보다 함수적인 접근 방식을 취할 수 있게 되었고, 이는 자바 프로그래밍 언어의 발전에 중요한 역할을 했다. 익명 클래스와 람다식은 각각의 사용 사례에 따라 선택적으로 사용되어, 자바 프로그래밍의 유연성을 높이는 데 기여한다.📚 참조https://inf.run/XKQg)https://inf.run/r9oUhttps://dreamchaser3.tistory.com/5https://d2.naver.com/helloworld/4911107?fbclid=IwAR2KrFe7ksfRr4cDQWWGqvFpQDB6B4MVCh_zlMjZFZh5NVD5KSWHg8nV46Uhttps://www.slideshare.net/DanHeidinga/invokedynamic-evolution-of-a-language-featurehttps://www.inflearn.com/course/the-java-java8#https://tourspace.tistory.com/11?category=788398

백엔드인프런워킹업스터디클럽람다함수형프로그래밍

양성빈

[인프런 워밍업 스터디 클럽] 0기 백엔드 미션 - API 실습 (Day2)

API 실습벌써 2번째 미션을 진행할 차례가 되었다. 강의 중에 GET, POST API 개발을 해보았고 포스트맨으로 테스팅도 해보았다.또한 실제 프로젝트처럼 ui가 존재하는 화면과 연동하는 유저 생성 및 조회 API를 개발하면서 뭔가 실무를 체험하는 것과 같은 느낌이 들었다. 하지만, 아직 조금 부족하다고 많이 느끼게 되었다. 또한 많은 연습이 필요하다고 느꼈다. 그런데 마침 코치님께서 친절하게 미션을 통하여 API 연습을 하게 도와주셨다. 😆 그럼 미션을 통하여 나의 코드를 글로 표현해보겠다. 문제1요구조건해결과정당연하겠지만 스프링 프로젝트를 만든다. 나는 IntelliJ Ultimate를 사용하고 있는 관계로 start.spring.io를 통하여 프로젝트를 생성하지 않고 직접 인텔리제이를 통하여 프로젝트를 생성할 수 있다. 아래는 프로젝트를 세팅한 화면이다.controller 패키지 생성 후, 문제1에 대한 컨트롤러 클래스 생성package me.sungbin.mission.controller; public class MissionController { }API를 만들기 위해 코드를 작성한다. 나는 아래와 같이 작성하였다.package me.sungbin.mission.controller; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; @RestController @RequestMapping("/api/v1") public class MissionController { }📚 문제1~3까지 제시된 api는 /api/v1/으로 시작한다. 따라서 @RequestMapping을 통하여 공통된 api url부분을 제시해준다. 문제 1에 대한 API를 정의해야 한다. 제시된 조건은 /api/v1/calc의 path를 가지며, 쿼리 파리미터로 num1과 num2를 가진다. 이에 따라 정의를 해볼려고 한다. 그런데 문제는 응답하는 값이 json 형태로 반환되므로 DTO 객체를 통하여 반환하도록 하자. 그러면 DTO 응답 객체부터 만들자. DTO 응답 객체는 다음과 같다.package me.sungbin.mission.dto.response; public class CalculationResponseDto { private final int add; private final int minus; private final int multiply; public CalculationResponseDto(int add, int minus, int multiply) { this.add = add; this.minus = minus; this.multiply = multiply; } public int getAdd() { return add; } public int getMinus() { return minus; } public int getMultiply() { return multiply; } }  롬복을 통하여 생서자와 getter를 만들 수도 있고, JDK17 이상부터는 record를 이용하여 만들 수도 있다.하지만, 미션의 취지와 강의에 설명한 데로 생성해보겠다. 💡 record를 통하여 DTO 생성package me.sungbin.mission.dto.response; public record CalculateResponseRecordDto(int add, int minus, int multiply) { @Override public int add() { return add; } @Override public int minus() { return minus; } @Override public int multiply() { return multiply; } }  parameter를 객체를 통하여 전달주려고 한다. 물론 @RequestParam을 통하여 전달줄 수 있다. 아래와 같이 DTO 요청 객체를 만들었다.  package me.sungbin.mission.dto.request; public class CalculationRequestDto { private final int num1; private final int num2; public CalculationRequestDto(int num1, int num2) { this.num1 = num1; this.num2 = num2; } public int getNum1() { return num1; } public int getNum2() { return num2; } }  그리고 컨트롤러 클래스를 마저 작성하면 아래와 같다.package me.sungbin.mission.controller; import me.sungbin.mission.dto.request.CalculationRequestDto; import me.sungbin.mission.dto.response.CalculationResponseDto; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; @RestController @RequestMapping("/api/v1") public class MissionController { @GetMapping("/calc") public CalculationResponseDto calculate(CalculationRequestDto requestDto) { return new CalculationResponseDto( requestDto.getNum1() + requestDto.getNum2(), requestDto.getNum1() - requestDto.getNum2(), requestDto.getNum1() * requestDto.getNum2() ); } } 그리고 포스트맨으로 테스트를 해보니 아래와 같이 에러가 발생한다. 그래서 에러 내용을 보니 아래와 같다. ⚠ 트러블 슈팅그래서 대체 이유가 뭘까 고민을 하다가 name 속성을 줘서 풀어보니 정상동작을 하였다. 그래서 이런 문제는 공식문서에 있을법해서 구글링 및 공식문서 이슈사항을 보았다. 위의 공식문서에서 업데이트 기록에 나와있었다. Spring Boot 3.2에서 사용되는 Spring Framework 버전은 더 이상 바이트코드를 구문 분석하여 매개변수 이름을 추론하려고 시도하지 않습니다. 즉, 스프링 부트 3.2부터 자바 컴파일러에 -parameters 옵션을 넣어주어야 애노테이션의 이름을 생략할 수 있다. 또 한 가지 방법으로는 gradle을 사용해 빌드를 하고 실행하는 방법이 있다. 나는 Build and run using를 IntelliJ IDEA로 선택하였습니다. (체감상 Gradle보단 빨라서...) Gradle로 선택한 경우에는 Gradle이 컴파일 시점에 해당 옵션을 자동으로 적용해준다. 그래서 인텔리제이의 세팅에 Build, Execution, Deployment > Build Tools > Gradle 을 들어가서 아래 세팅처럼 Gradle로 변경한다. 초기세팅은 Gradle이다. 나처럼 IntelliJ로 변경한 사람만 적용하면 된다. 그 후에 다시 실행하면 정상적으로 결과가 나온다. 결과 확인리펙토링이제 컨트롤러에 있는 비즈니스 로직을 좀 더 리팩토링해보자. 여기서 든 생각은 더하기, 빼기, 곱하기 로직은 다른 클래스로 분리하면 좋을 것 같다는 생각이 들었다. 🤔 먼저 서비스 패키지를 구성하고 서비스 클래스를 만들어보자. 그리고 거기다가 로직을 추가해보자. package me.sungbin.mission.service; import me.sungbin.mission.dto.request.CalculationRequestDto; import org.springframework.stereotype.Service; @Service public class CalculationService { /** * 더하기 로직 * @param requestDto * @return */ public int add(CalculationRequestDto requestDto) { return requestDto.getNum1() + requestDto.getNum2(); } /** * 빼기 로직 * @param requestDto * @return */ public int minus(CalculationRequestDto requestDto) { return requestDto.getNum1() - requestDto.getNum2(); } /** * 곱하기 로직 * @param requestDto * @return */ public int multiply(CalculationRequestDto requestDto) { return requestDto.getNum1() * requestDto.getNum2(); } }  다음으로 컨트롤러 코드를 수정하자. package me.sungbin.mission.controller; import me.sungbin.mission.dto.request.CalculationRequestDto; import me.sungbin.mission.dto.response.CalculationResponseDto; import me.sungbin.mission.service.CalculationService; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; @RestController @RequestMapping("/api/v1") public class MissionController { private final CalculationService calculationService; public MissionController(CalculationService calculationService) { this.calculationService = calculationService; } @GetMapping("/calc") // GET /api/v1/calc public CalculationResponseDto calculate(CalculationRequestDto requestDto) { return new CalculationResponseDto(calculationService.add(requestDto), calculationService.minus(requestDto), calculationService.multiply(requestDto)); } }  그리고 포스트맨으로 실행해보면 정상적으로 결과가 나온다.  테스트 코드그러면 포스트맨으로 테스팅을 해보았지만, 테스트 코드를 통해 확실한 검증을 가보자.다만, 테스트 코드는 실패하는 로직과 성공하는 로직을 작성해야하지만 이번 문제는 성공하는 로직만 작성해보겠다.또한 비즈니스 로직은 단순 연산이므로 통합테스트로 과정설명없이 아래와 같이 작성했다. package me.sungbin.mission.controller; import me.sungbin.mission.dto.request.CalculationRequestDto; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; import org.springframework.boot.test.context.SpringBootTest; import org.springframework.test.web.servlet.MockMvc; import static org.junit.jupiter.api.Assertions.*; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; @SpringBootTest @AutoConfigureMockMvc class MissionControllerTest { @Autowired private MockMvc mockMvc; @Test @DisplayName("문제 1번 통합 테스트 - 성공") void calculate_test_success() throws Exception { CalculationRequestDto calculationRequestDto = new CalculationRequestDto(10, 5); this.mockMvc.perform(get("/api/v1/calc") .param("num1", String.valueOf(calculationRequestDto.getNum1())) .param("num2", String.valueOf(calculationRequestDto.getNum2()))) .andDo(print()) .andExpect(status().isOk()); } }결과는 아래와 같다. 문제2요구사항문제풀이기본적으로 IDE 열고 프로젝트 세팅은 생략하겠다.컨트롤러 클래스에 경로를 지정해주기 전에, 응답객체부터 먼저 만들어보자.응답객체는 아래와 같다. package me.sungbin.mission.dto.response; public class DayOfTheWeekResponseDto { private final String dayOfTheWeek; public DayOfTheWeekResponseDto(String dayOfTheWeek) { this.dayOfTheWeek = dayOfTheWeek; } public String getDayOfTheWeek() { return dayOfTheWeek; } } 다음으로 컨트롤러 코드의 비즈니스 로직 부분을 작성해보자. package me.sungbin.mission.controller; import me.sungbin.mission.dto.request.CalculationRequestDto; import me.sungbin.mission.dto.response.CalculationResponseDto; import me.sungbin.mission.dto.response.DayOfTheWeekResponseDto; import me.sungbin.mission.service.CalculationService; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; import java.time.LocalDate; import java.time.format.TextStyle; import java.util.Locale; @RestController @RequestMapping("/api/v1") public class MissionController { private final CalculationService calculationService; public MissionController(CalculationService calculationService) { this.calculationService = calculationService; } @GetMapping("/calc") // GET /api/v1/calc public CalculationResponseDto calculate(CalculationRequestDto requestDto) { return new CalculationResponseDto(calculationService.add(requestDto), calculationService.minus(requestDto), calculationService.multiply(requestDto)); } @GetMapping("/day-of-the-week") // GET /api/v1/day-of-the-week public DayOfTheWeekResponseDto findDayOfTheWeek(@RequestParam LocalDate date) { return new DayOfTheWeekResponseDto(date.getDayOfWeek().getDisplayName(TextStyle.SHORT, Locale.US).toUpperCase()); } }  결과 확인🙋🏻 처음에는 당황했다. 분명 제대로 로직이 갔는데 예시랑 다르기 때문이다. 하지만 달력을 확인해도 2023년 01월 01일은 일요일이 맞다! 📚 나는 문제를 단순 속성이 1개이기 때문에 단순 타입으로 받았지만 만약에 단순 타입이 아니라 객체로도 넘길 수 있다. @DateTimeFormat : 객체로 받을 시, 필드에다가 이 어노테이션을 붙여주고 패턴을 지정해줘야 한다. 왜냐하면 스프링의 기본 날짜/시간 파싱 규칙은 LocalDate의 경우 ISO 형식(예: yyyy-MM-dd)을 사용합니다. 따라서, 클라이언트 요청이 이 형식을 따른다면 @DateTimeFormat 어노테이션이 없어도 문제없이 파싱될 수 있습니다. 다만, 아래의 경우에 문제가 발생한다.날짜 형식 불일치: 클라이언트가 다른 형식(예: dd-MM-yyyy)을 사용하여 데이터를 보내면, 스프링은 이를 올바르게 파싱하지 못하고 오류를 반환합니다.명확성 부족: @DateTimeFormat 어노테이션을 사용하지 않으면, API를 사용하는 클라이언트 개발자들이 요구되는 정확한 날짜 형식을 명확하게 알 수 없습니다. 이는 API의 사용성을 저하시킬 수 있습니다.그러면 객체로 받는 예시도 보여주겠다. DTOpackage me.sungbin.mission.dto.request; import org.springframework.format.annotation.DateTimeFormat; import java.time.LocalDate; public class DayOfTheWeekRequestDto { @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) private LocalDate date; public DayOfTheWeekRequestDto(LocalDate date) { this.date = date; } public LocalDate getDate() { return date; } }  Controllerpackage me.sungbin.mission.controller; import me.sungbin.mission.dto.request.CalculationRequestDto; import me.sungbin.mission.dto.request.DayOfTheWeekRequestDto; import me.sungbin.mission.dto.response.CalculationResponseDto; import me.sungbin.mission.dto.response.DayOfTheWeekResponseDto; import me.sungbin.mission.service.CalculationService; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; import java.time.LocalDate; import java.time.format.TextStyle; import java.util.Locale; @RestController @RequestMapping("/api/v1") public class MissionController { private final CalculationService calculationService; public MissionController(CalculationService calculationService) { this.calculationService = calculationService; } @GetMapping("/calc") // GET /api/v1/calc public CalculationResponseDto calculate(CalculationRequestDto requestDto) { return new CalculationResponseDto(calculationService.add(requestDto), calculationService.minus(requestDto), calculationService.multiply(requestDto)); } @GetMapping("/day-of-the-week") // GET /api/v1/day-of-the-week public DayOfTheWeekResponseDto findDayOfTheWeek(DayOfTheWeekRequestDto requestDto) { return new DayOfTheWeekResponseDto(requestDto.getDate().getDayOfWeek().getDisplayName(TextStyle.SHORT, Locale.US).toUpperCase()); } }  리팩토링이제 비즈니스 로직을 서비스 클래스에 넣어서 좀 더 리팩토링 해보자. validation도 적용할려나 @DateTimeFormat을 이용하면 스프링에서 알아서 TypeMismatchException을 발생시켜준다. 따라서 @RestControllerAdvice를 이용하여 할 수 있다. /** * 요일 찾기 비즈니스 로직 * @param requestDto * @return */ public String findDayOfTheWeek(DayOfTheWeekRequestDto requestDto) { return requestDto.getDate().getDayOfWeek().getDisplayName(TextStyle.SHORT, Locale.US).toUpperCase(); }위의 코드는 요일 찾기 로직을 서비스 클래스에 옮긴 것이다. 다음으로 컨트롤러 클래스를 아래와 수정하자. package me.sungbin.mission.controller; import me.sungbin.mission.dto.request.CalculationRequestDto; import me.sungbin.mission.dto.request.DayOfTheWeekRequestDto; import me.sungbin.mission.dto.response.CalculationResponseDto; import me.sungbin.mission.dto.response.DayOfTheWeekResponseDto; import me.sungbin.mission.service.CalculationService; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; import java.time.LocalDate; import java.time.format.TextStyle; import java.util.Locale; @RestController @RequestMapping("/api/v1") public class MissionController { private final CalculationService calculationService; public MissionController(CalculationService calculationService) { this.calculationService = calculationService; } @GetMapping("/calc") // GET /api/v1/calc public CalculationResponseDto calculate(CalculationRequestDto requestDto) { return new CalculationResponseDto(this.calculationService.add(requestDto), calculationService.minus(requestDto), calculationService.multiply(requestDto)); } @GetMapping("/day-of-the-week") // GET /api/v1/day-of-the-week public DayOfTheWeekResponseDto findDayOfTheWeek(DayOfTheWeekRequestDto requestDto) { return new DayOfTheWeekResponseDto(this.calculationService.findDayOfTheWeek(requestDto)); } }📚 LocalDate 참고아래의 코드는 블로그를 통하여 유용한 LocalDate 함수를 사용한 것이다. 확인해보자.// 특정 날짜의 요일 구하기 LocalDate.of( 2022, 12, 12 ).getDayOfWeek(); // MONDAY // 특정 날짜로부터 일, 월, 주, 연 차이 나는 날짜 구하기 localDate.minusDays( 5 ); // @param long daysToSubtract localDate.minusMonths( 5 ); // @param long daysToSubtract localDate.minusWeeks( 5 ); // @param long daysToSubtract localDate.minusYears( 5 ); // @param long daysToSubtract // 특정 날짜로부터 몇 일 이후 날짜 구하기 ( 위와 유사 ) localDate.plusDays( 7 ); // @param long amountToAdd // 특정 날짜가 해당하는 주의 특정 요일 일자 구하기 localDate.with( DayOfWeek.FRIDAY); // 2022-12-16 ( @param DayOfWeek ) // 특정 날짜에서 특정 부분만 바꾸기 LocalDate localDate = LocalDate.now(); // 2022-12-12 localDate.withDayOfMonth( 31 ); // 2022-12-31 ( @param int dayOfMonth ) localDate.withMonth( 1 ); // 2022-01-12 ( @param int month ) localDate.withYear( 2023 ); // 2023-12-12 ( @param int year ) // 윤년 여부 localDate.isLeapYear(); // false // 해당 월의 첫째 날 구하기 localDate.withDayOfMonth( 1 ); // 해당 월의 마지막 날 구하기 localDate.withDayOfMonth( localDate.lengthOfMonth() ); // 두 날짜 사이의 간격 구하기 LocalDate start = LocalDate.of( 2021, 10, 1 ); LocalDate end = LocalDate.of( 2022, 12, 31 ); Period diff = Period.between( start, end ); diff.getYears(); // 1 diff.getMonths(); // 2 diff.getDays(); // 30 // ChronoUnit 을 이용한 두 날짜 사이 간격 구하기 long diffMonth = ChronoUnit.MONTHS.between( start, end ); // 14 long diffWeek = ChronoUnit.WEEKS.between( start, end ); // 65 long diffDay = ChronoUnit.DAYS.between( start, end ); // 456 테스트 코드이번엔 테스트 코드를 작성하자. 성공과 실패 케이스 둘 다 작성해보겠다.package me.sungbin.mission.controller; import me.sungbin.mission.dto.request.CalculationRequestDto; import me.sungbin.mission.dto.request.DayOfTheWeekRequestDto; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; import org.springframework.boot.test.context.SpringBootTest; import org.springframework.test.web.servlet.MockMvc; import java.time.LocalDate; import static org.junit.jupiter.api.Assertions.*; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; @SpringBootTest @AutoConfigureMockMvc class MissionControllerTest { @Autowired private MockMvc mockMvc; @Test @DisplayName("문제 1번 통합 테스트 - 성공") void calculate_test_success() throws Exception { CalculationRequestDto calculationRequestDto = new CalculationRequestDto(10, 5); this.mockMvc.perform(get("/api/v1/calc") .param("num1", String.valueOf(calculationRequestDto.getNum1())) .param("num2", String.valueOf(calculationRequestDto.getNum2()))) .andDo(print()) .andExpect(status().isOk()); } @Test @DisplayName("문제 2번 통합 테스트 - 성공") void find_day_of_the_week_test_success() throws Exception { DayOfTheWeekRequestDto requestDto = new DayOfTheWeekRequestDto(LocalDate.of(2023, 1, 1)); this.mockMvc.perform(get("/api/v1/day-of-the-week") .param("date", String.valueOf(requestDto.getDate()))) .andDo(print()) .andExpect(status().isOk()); } }결과를 보자. 문제3문제풀이이제는 익숙해졌을거라 보고 최종 코드만 확인해보겠다. DTOpackage me.sungbin.mission.dto.request; import com.fasterxml.jackson.annotation.JsonProperty; import jakarta.validation.constraints.NotEmpty; import jakarta.validation.constraints.NotNull; import java.util.List; public class ListNumberDataRequestDto { private final List<Integer> numbers; public ListNumberDataRequestDto(@JsonProperty("numbers") List<Integer> numbers) { this.numbers = numbers; } public List<Integer> getNumbers() { return numbers; } }  Controllerpackage me.sungbin.mission.controller; import jakarta.validation.Valid; import me.sungbin.mission.dto.request.CalculationRequestDto; import me.sungbin.mission.dto.request.DayOfTheWeekRequestDto; import me.sungbin.mission.dto.request.ListNumberDataRequestDto; import me.sungbin.mission.dto.response.CalculationResponseDto; import me.sungbin.mission.dto.response.DayOfTheWeekResponseDto; import me.sungbin.mission.service.CalculationService; import org.springframework.web.bind.annotation.*; import java.time.LocalDate; import java.time.format.TextStyle; import java.util.Locale; @RestController @RequestMapping("/api/v1") public class MissionController { private final CalculationService calculationService; public MissionController(CalculationService calculationService) { this.calculationService = calculationService; } @GetMapping("/calc") // GET /api/v1/calc public CalculationResponseDto calculate(CalculationRequestDto requestDto) { return new CalculationResponseDto(this.calculationService.add(requestDto), calculationService.minus(requestDto), calculationService.multiply(requestDto)); } @GetMapping("/day-of-the-week") // GET /api/v1/day-of-the-week public DayOfTheWeekResponseDto findDayOfTheWeek(DayOfTheWeekRequestDto requestDto) { return new DayOfTheWeekResponseDto(this.calculationService.findDayOfTheWeek(requestDto)); } @PostMapping("/sum-of-numbers-in-list") public Integer sumOfNumbersInList(@RequestBody @Valid ListNumberDataRequestDto requestDto) { return requestDto.getNumbers().stream().mapToInt(Integer::intValue).sum(); } }📚 비즈니스 로직 부분은 Java8에 나온 Stream API와 메서드 레퍼런스를 이용하여 만들었다. 이 API는 다음 미션때 자세히 보도록 하겠다. ⚠ 또한 나처럼 DTO의 필드를 final로 설정하면 생성자 부분에 @JsonProperty를 빼고 진행하면 에러가 발생한다.Cannot construct instance of me.sungbin.mission.dto.request.ListNumberDataRequestDto (no Creators, like default constructor, exist): cannot deserialize from Object value (no delegate- or property-based Creator) 트러블 슈팅이 오류 메시지는 스프링 부트와 Jackson 라이브러리가 ListNumberDataRequestDto 클래스의 인스턴스를 JSON 데이터로부터 역직렬화할 때 발생한다. 오류 메시지는 ListNumberDataRequestDto에 기본 생성자가 없거나, Jackson이 JSON 데이터를 객체의 필드에 매핑하기 위해 사용할 수 있는 적절한 생성자나 세터 메서드가 없음을 나타낸다. 이 경우, 클래스에는 파라미터를 받는 생성자만 정의되어 있으며, final 키워드로 선언된 numbers 필드 때문에 수정자(setter) 메서드를 추가할 수 없다. 따라서 Jackson이 객체를 역직렬화할 때 사용할 수 있는 "속성 기반 생성자"를 제공하기 위해, 생성자 파라미터에 @JsonProperty 어노테이션을 사용할 수 있다. 이 방법은 Jackson에게 JSON 데이터의 어떤 필드가 클래스 생성자의 어떤 파라미터와 매핑되는지 명확하게 지시한다. 아니면 final 키워드를 없애는 방법이 있다. 나는 이 예시를 보이기 위해 의도적으로 이렇게 작성하겠다. 결과를 보자.  리팩토링비즈니스 로직을 서비스 클래스에 옮기자. /** * 배열의 합 구하는 로직 * @param requestDto * @return */ public Integer sumOfNumbersInList(ListNumberDataRequestDto requestDto) { return requestDto.getNumbers().stream().mapToInt(Integer::intValue).sum(); }컨트롤러 코드도 수정하자. package me.sungbin.mission.controller; import jakarta.validation.Valid; import me.sungbin.mission.dto.request.CalculationRequestDto; import me.sungbin.mission.dto.request.DayOfTheWeekRequestDto; import me.sungbin.mission.dto.request.ListNumberDataRequestDto; import me.sungbin.mission.dto.response.CalculationResponseDto; import me.sungbin.mission.dto.response.DayOfTheWeekResponseDto; import me.sungbin.mission.service.CalculationService; import org.springframework.web.bind.annotation.*; import java.time.LocalDate; import java.time.format.TextStyle; import java.util.Locale; @RestController @RequestMapping("/api/v1") public class MissionController { private final CalculationService calculationService; public MissionController(CalculationService calculationService) { this.calculationService = calculationService; } @GetMapping("/calc") // GET /api/v1/calc public CalculationResponseDto calculate(CalculationRequestDto requestDto) { return new CalculationResponseDto(this.calculationService.add(requestDto), calculationService.minus(requestDto), calculationService.multiply(requestDto)); } @GetMapping("/day-of-the-week") // GET /api/v1/day-of-the-week public DayOfTheWeekResponseDto findDayOfTheWeek(DayOfTheWeekRequestDto requestDto) { return new DayOfTheWeekResponseDto(this.calculationService.findDayOfTheWeek(requestDto)); } @PostMapping("/sum-of-numbers-in-list") public Integer sumOfNumbersInList(@RequestBody @Valid ListNumberDataRequestDto requestDto) { return this.calculationService.sumOfNumbersInList(requestDto); } }  그리고 마지막으로 validation을 추가하자!물론 서비스 클래스에 아래와 같은 로직을 넣을 수 있지만 if (requestDto.getNumbers() == null || requestDto.getNumbers().isEmpty()) { throw new IllegalArgumentException("리스트는 공란이거나 null일 수 없습니다."); } 좀 편하게 spring-boot-starter-validation을 이용하여DTO 클래스를 변경해보자. package me.sungbin.mission.dto.request; import com.fasterxml.jackson.annotation.JsonProperty; import jakarta.validation.constraints.NotEmpty; import jakarta.validation.constraints.NotNull; import java.util.List; public class ListNumberDataRequestDto { @NotEmpty(message = "리스트의 적어도 하나의 원소가 존재해야 합니다.") @NotNull(message = "리스트는 null일 수 없습니다.") private final List<Integer> numbers; public ListNumberDataRequestDto(@JsonProperty("numbers") List<Integer> numbers) { this.numbers = numbers; } public List<Integer> getNumbers() { return numbers; } }  테스트 코드이번에는 테스트 실패와 성공케이스 둘다 적어보자. 실패@Test @DisplayName("문제 3번 통합 테스트 - 실패 (빈 List)") void sum_of_the_list_numbers_test_fail_caused_by_list_empty() throws Exception { List<Integer> list = new ArrayList<>(); ListNumberDataRequestDto requestDto = new ListNumberDataRequestDto(list); this.mockMvc.perform(post("/api/v1/sum-of-numbers-in-list") .contentType(MediaType.APPLICATION_JSON) .content(objectMapper.writeValueAsString(requestDto))) .andDo(print()) .andExpect(status().isBadRequest()); } @Test @DisplayName("문제 3번 통합 테스트 - 실패 (null인 List)") void sum_of_the_list_numbers_test_fail_caused_by_list_null() throws Exception { List<Integer> list = null; ListNumberDataRequestDto requestDto = new ListNumberDataRequestDto(list); this.mockMvc.perform(post("/api/v1/sum-of-numbers-in-list") .contentType(MediaType.APPLICATION_JSON) .content(objectMapper.writeValueAsString(requestDto))) .andDo(print()) .andExpect(status().isBadRequest()); }결과 (1,2번 실패 테스트는 response 동일)성공package me.sungbin.mission.controller; import com.fasterxml.jackson.databind.ObjectMapper; import me.sungbin.mission.dto.request.CalculationRequestDto; import me.sungbin.mission.dto.request.DayOfTheWeekRequestDto; import me.sungbin.mission.dto.request.ListNumberDataRequestDto; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; import org.springframework.boot.test.context.SpringBootTest; import org.springframework.http.MediaType; import org.springframework.test.web.servlet.MockMvc; import java.time.LocalDate; import java.util.ArrayList; import java.util.Arrays; import java.util.List; import static org.junit.jupiter.api.Assertions.*; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; @SpringBootTest @AutoConfigureMockMvc class MissionControllerTest { @Autowired private MockMvc mockMvc; @Autowired private ObjectMapper objectMapper; @Test @DisplayName("문제 1번 통합 테스트 - 성공") void calculate_test_success() throws Exception { CalculationRequestDto calculationRequestDto = new CalculationRequestDto(10, 5); this.mockMvc.perform(get("/api/v1/calc") .param("num1", String.valueOf(calculationRequestDto.getNum1())) .param("num2", String.valueOf(calculationRequestDto.getNum2()))) .andDo(print()) .andExpect(status().isOk()); } @Test @DisplayName("문제 2번 통합 테스트 - 성공") void find_day_of_the_week_test_success() throws Exception { DayOfTheWeekRequestDto requestDto = new DayOfTheWeekRequestDto(LocalDate.of(2023, 1, 1)); this.mockMvc.perform(get("/api/v1/day-of-the-week") .param("date", String.valueOf(requestDto.getDate()))) .andDo(print()) .andExpect(status().isOk()); } @Test @DisplayName("문제 3번 통합 테스트 - 실패 (빈 List)") void sum_of_the_list_numbers_test_fail_caused_by_list_empty() throws Exception { List<Integer> list = new ArrayList<>(); ListNumberDataRequestDto requestDto = new ListNumberDataRequestDto(list); this.mockMvc.perform(post("/api/v1/sum-of-numbers-in-list") .contentType(MediaType.APPLICATION_JSON) .content(objectMapper.writeValueAsString(requestDto))) .andDo(print()) .andExpect(status().isBadRequest()); } @Test @DisplayName("문제 3번 통합 테스트 - 실패 (null인 List)") void sum_of_the_list_numbers_test_fail_caused_by_list_null() throws Exception { List<Integer> list = null; ListNumberDataRequestDto requestDto = new ListNumberDataRequestDto(list); this.mockMvc.perform(post("/api/v1/sum-of-numbers-in-list") .contentType(MediaType.APPLICATION_JSON) .content(objectMapper.writeValueAsString(requestDto))) .andDo(print()) .andExpect(status().isBadRequest()); } @Test @DisplayName("문제 3번 통합 테스트 - 성공") void sum_of_the_list_numbers_test_success() throws Exception { List<Integer> list = Arrays.asList(1, 2, 3, 4, 5); ListNumberDataRequestDto requestDto = new ListNumberDataRequestDto(list); this.mockMvc.perform(post("/api/v1/sum-of-numbers-in-list") .contentType(MediaType.APPLICATION_JSON) .content(objectMapper.writeValueAsString(requestDto))) .andDo(print()) .andExpect(status().isOk()); } } 결과📚 참조자바의 정석https://github.com/spring-projects/spring-boot/wiki/Spring-Boot-3.2-Release-Notes

백엔드인프런워밍업스터디클럽백엔드API

학생

[3주차 발자국] 인프런 워밍업 클럽 스터디 0기 FE

회고벌써 마지막 발자국이다.3주를 달려오면서 느낀 것은, 시간 분배를 제대로 해야 한다는 것이다.초반에 자바스크립트에 시간을 쓰느라 리액트 파트를 느긋하게 하지 못하고 빠르게 돌았다.우선은 앞에서 풀리지 않은 문제가 있더라도 제때 진행해야 하는 진도를 끝내놓고 일요일쯤에 다시 돌아오는 편이 나았을 것이라는 생각이 들었다. 스터디가 끝나도 이 점은 기억하여 그날 해야하는 일은 마치고 나서 모르는 점을 보충하는 시간을 주말이나 저녁에 따로 가지는 것이 좋겠다.사실 자바스크립트부터 모두 이해하고 미션도 다 하고 싶었지만, 아무래도 기간 안에 전부 끝내지는 못할 것 같다.특히나 적어도 수료를 하려면 리액트 과제를 4개는 해야 하는데, 아직 1개 완료, 1개 진행중이다.내일까지 과제를 3개 완료할 수 있을지 모르겠다. 그래도 최대한 해볼 생각이다.지금 디즈니 플러스 앱의 github 로그인 버튼 기능을 구현하는것에서 막혀 머리를 싸매고 있다. 깃허브 계정 로그인까지는 되는데 자꾸 페이지 이동이 안된다.기본 개념부터 하나하나 느긋하게 보고 이해하기에는 시간이 부족하다고 느껴 쉽게 설명한 문서가 있는지 찾아다니는데에 시간을 꽤나 쓴 것 같다. 공식 문서부터 다시 보고 내일까지 성공하길 바라고 있다.스터디가 끝나면 강의에서 어려웠던 부분 혹은 시간을 들여 보지 못했던 부분을 다시 해보면서 역량을 쌓아야겠다.강의 요약이번주 강의 범위: 섹션6~7(React TDD), 섹션8(Next.js, TypeScript), 섹션9~10(Redux)React TDDNext.jsTypeScriptRedux React TDD: 테스트 주도 개발(TDD, Test Driven Development): 테스트 코드를 작성한 후 그것을 Pass할 수 있는 실제 코드를 작성하는 개발 방식장점: 소스코드 안정감 부여, 디버깅 및 개발 시간 감소, 클린 코드 가능성 높음 React Testing LibraryReact 컴포넌트를 테스트하는 가벼운 솔루션. 행위 주도 테스트(태그보다는 이벤트 발생 시 화면 변화 등의 테스트)Jest React Testing Library와 함께 React 테스트에 쓰이는 테스팅 프레임워크.test Case를 만들어 확인.단위 테스트를 위함 Jest 파일 구조describe: 여러 관련 테스트를 그룹화하는 블록을 만듦.it: ==test. 개별 테스트를 수행하는 곳.expect: 값을 테스트할때마다 사용됨. matcher와 함께 사용.matcher: 다른 방법으로 값을 테스트하도록 함.쿼리함수get: 요소가 없으면 오류 발생find: 요소가 없으면 null 반환query: 요소가 없으면 거부. 요소가 있으면 Promise 반환.테스팅 검사 관련 모듈ESLint: 문법오류 잡기Prettier: 코드 형식 맞추기Next.js: React의 SSR(Server Side Rendering) 구현을 도와주는 프레임워크. (React는 라이브러리)(리액트에서도 SSR을 지원하지만 구현하기에 굉장히 복잡. 따라서 NextJS 사용.)React는 CSR(Client Side Rendering) 이용CSR: JS가 다운로드된 후에야 화면 표시 및 기능 활성화됨첫페이지에서 빈 html을 가져와 JS파일을 해석하여 화면을 구성하기에 포털 검색에 거의 노출될 일이 없음. => 검색엔진 최적화(SEO) 불리SSR: 서버에서 Pre-Rendering된 HTML 제공사용자와 검색엔진 크롤러에게 바로 렌더링 된 페이지를 전달할 수 있게 됨.Pre-Rendering: initial Load(html보임) -> JS로드 -> Hydration(컴포넌트 활성화)Data FetchingReact에서 데이터를 가져오는 방법useEffect 내부에서 가져옴Nextjs에서 데이터를 가져오는 방법getStaticPropsgetStaticPathsgetServerSideProps TypeScript: JavaScript에 타입을 부여한 언어. 오픈소스.사용하는 이유단순화. 쉽게 읽고 디버그 가능코드 유형 검사를 통해 JavaScript에서의 일반적인 버그 피하는데에 도움Compile브라우저에서 실행하기 위해 파일을 변환해주는 것TypeScript에서 하는것. JavaScript에서는 안함.  TypeScript Typevalue가 가진 프로퍼티나 함수를 추론할 수 있는 방법TypeScript 유형Primitive types: string, number, boolean, null, undefined, symbolObject types: function, array, class, object추가 제공 타입: Tuple, Enum, Any, Void, Never, Uniontype annotation: 개발자가 타입스크립트에게 타입을 알려줌type inference: 타입스크립트가 타입을 추론하는 것type assertion: 타입스크립트의 추론을 막는다. 나의 주장에 대해 의심하지 마라.Redux: JavaScript Application을 위한 상태관리 라이브러리.Redux Data Flow Dispatch Action -> Call Reducer -> Update Store -> RenderAction: 자바스크립트 객체. 작업유형 지정하는 정보 들어있음.Reducer: 이전 state와 action을 받은 후 next state를 반환하여 store를 업데이트.Redux Store: 앱의 전체 상태 트리를 갖는 저장소. 몇 가지 Methods가 있는 객체.Provider컴포넌트들에서 Redux Store에 접근할 수 있도록 해줌.컴포넌트들을 둘러싸고 최상위 수준에서 렌더링.useSelector: useSelector Hooks를 사용해 스토어의 값을 가져옴.useDispatch: dispatch함수에 접근하는 Hooks. (Action을 보냄)미들웨어Action을 Dispatch하고 Reducer에 도달하는 순간 사전에 지정된 작업 실행 도와주는 중간자Reducer에 도달하기 전에 API와 통신을 하고 그것을 전달.Redux Thunk리덕스 미들웨어. 비동기 작업할 때 많이 쓰임.Thunk: 일부 지연된 작업을 수행하는 코드 조각Redux Toolkit로직을 작성하기 위한 공식 권장 접근 방식.모범 사례를 이용해 작업 단순화 및 실수 방지. 미션 해결 과정[1. 자바스크립트 미션 보강]미션4(Day5) 책 리스트 나열 앱의 보강이 필요했던 부분스타일 보강알림때문에 화면이 아래로 밀리는 문제 고치기 스타일 보강포인트 1. input의 넓이 키우기: 가로는 화면의 대부분을 가로지르도록, 세로는 h1정도로 크게포인트 2. 제출 버튼을 책 저자 input란 아래에 두어 보기 좋게 만들기(가로도 길게)포인트 3. hr로 입력Form과 책 리스트 Form 구분하기포인트 4. 책 리스트 Form 좀 더 보기 좋게 만들기(보강 전)(보강 후)input의 font size 조정을 이용해 포인트1 해결display: flex; flex-direction: column을 이용해 포인트2 해결<hr>을 삽입해 포인트3 해결위치 및 폰트 미세 조정하여 포인트4 해결알림때문에 화면이 아래로 밀리는 문제 고치기책이 추가되었습니다, 아이디가 입력되지 않았습니다 등의 알림을 뜨게 하면 아래 화면이 움직이는 것 때문에 불편함이 있었다. 예제 동영상을 다시 보니 이 부분은 나와 똑같이 구현되어있기는 했지만, 편의성을 위해 바꿔보았다.알림이 아래로 추가되도록 하지 않고, display: none을 지정했다 풀었다 하는 방식으로 겹쳐 표시setTimeout에 clearTimeout을 이용해 알림을 매번 새로 3초 지속시간 부여float: right 속성을 이용해 '책' title의 오른쪽에 배치 미션5(Day5) Github Finder 만들기의 보강이 필요했던 부분API 통신스타일API 통신github의 사용자들을 검색하려면 github에서 제공하는 api를 fetch하면 된다고 한다.기본적인 형태는 이런 식이다.async function logJSONData() { const response = await fetch("https://api.github.com/users"); const jsonData = await response.json(); console.log(jsonData); }https://developer.mozilla.org/ko/docs/Web/API/Fetch_API/Using_Fetch내가 이해한 부분은, fetch 안에 api url을 넣고, fetch가 반환하는 response에 .json()을 해주면 데이터를 가져올 수 있다는 것이다.github에서 사용자를 검색하는 데에는 특별한 인증 키가 필요 없다고 한다.https://www.daleseo.com/github-rest-api/진도가 밀리고 있기에 여기까지 이해하고 잠시 멈추고 리액트 공부를 시작했다. [2. React 미션 해결 과정]미션8(Day9) 예산 계산기 앱 만들기이 미션은 React 섹션 1~3을 차근차근 복습할 겸 따라하며 필요한 부분을 알맞게 바꿔 만들었다.첫 번째 커밋에서는 class component 방식으로, this.state와 this.setState를 사용해 App.js에 몽땅 코딩했다.총 지출을 출력하는 부분에서 계속 금액이 문자열로 지정되어 합쳐지는 것을 막기 위해 일일이 Number()처리를 했다.그리고 두 번째 커밋에서는 function component를 이용하는 React Hooks(class component 없이 state를 사용할 수 있도록 하는 기능)를 이용했다.즉 React Hooks에서는 component 분리(App.js, Input.js, Lists.js, List.js), useState 사용.항목에 마우스 올릴 시 크기가 커지는 효과: scale과 transition 조정(아래 블로그를 참고했다.)https://record-than-remember.tistory.com/entry/CSS-%EC%9D%B4%EB%AF%B8%EC%A7%80-hover-transformscale-%EC%9D%B4%EB%AF%B8%EC%A7%80-%EC%98%A4%EB%B2%84%EC%8B%9C-%ED%99%95%EB%8C%80수정 버튼을 구현하는 것이 가장 어려웠다.Input component에서 수정 기능을 구현하고 싶었지만 자꾸 에러가 떠서 list component에서 그냥 state를 만들고 구현했다. 이 부분은 다음에 시간 여유가 있을 때 보강해야겠다. 미션9(Day10) 디즈니 플러스 앱 만들기미션을 하면서 떠올린 단어는 다음과 같다. '막막하다'...물론 거의 대부분이 강의에서 다루었던 넷플릭스 클론 코딩과 같은 형태였지만,제대로 체화하지 못했을 뿐더러 CSS를 적용하고 왔다갔다하면서 수정하는 것은 내 노트북에게는 상당히 힘든 일이었다.(저장할때마다 체감상 기본 10초는 멈췄던 것 같다. 노트북을 당장 바꿔야겠다.)기간안에 전부 이해하고 처음부터 만들기는 힘들 것 같으므로, 넷플릭스 클론을 할 때 사용했던 코드를 수정하는 쪽으로 미션을 수행했다.해야하는 일은 다음과 같다.넷플릭스 흔적 지우고 로고, bgcolor, nav color 디즈니 비슷하게 바꾸기 (완료)LoginPage 구현하기 (완료)깃허브 로그인 버튼 구현하기 (진행중)Category 컴포넌트 만들기 (완료) 현재 깃허브 OAuth 토큰을 이용해 로그인해서 code를 받는것까지는 진행된 것 같은데, 로그인 후에 페이지 전환하는 것에서 막혔다. useState를 사용해 로그인이 되었을 경우와 안되었을 경우를 나누어 다른 컴포넌트를 라우팅하도록은 해놓았는데, 깃허브 액세스 토큰을 어떻게 사용하고 데이터를 어떻게 가져오는지 등을 아직 이해하지 못했다. 그리고 그 기능끼리 연결을 어떻게 시키는지 아직 모르겠다. 그래도 내일까지는 완성하고싶다.

프론트엔드인프런워밍업클럽스터디FE0기

도롱이

[인프런 워밍업 클럽_0기] 1주차, 첫 번째 발자국 #1

1주차, 첫 번째 발자국 1주차는 어려운 내용은 딱히 없었다! 어느정도 기반기가 있었다면 다들 어렵지 않게 해냈었을 것 같다.강의 요약은 강의를 들으면서 노션에 하나하나 요약했기 때문에 노션 링크를 남긴다.https://abalone-copper-ebe.notion.site/d2e9b3e27b3348abbde60994cf627ebd?pvs=4 그래도 너무 노션 링크만 띡 남기면 정 없으니 한 번 쭉 훑어보며 하루하루 대략적으로 어떤 것을 공부했고, 어떤 것들을 알게 되었나 작성해보자. Day2 02/19 서버 개발을 위한 환경 설정 및 네트워크 기초(1~5강 + 52강)첫 날은 프로젝트 소스를 다운받고, 프로젝트의 spring boot 버전을 2.7점대에서 3.0.x로 업데이트를 진행했다.Java, IntelliJ, PostMan, MySQL, Git은 이미 설치가 되어 있어서 따로 영상을 챙겨보진 않았다.52강을 들으면서 느낀 건 안 그래도 저번에 2점대 버전에서 3점대 버전으로 마이그레이션 하려는 시도를 했었었는데, 그때는 spring이라는 프레임워크를 잘 몰랐었던 때고, 3점대가 나온지 얼마 안돼서 정보도 그렇게 많지 않아 장렬히 실패했었던 기억이 있었다. 이번에도 에러가 엄청 날까봐 걱정을 많이 했는데 강의가 잘 정리되어 있어 어렵지 않게 마이그레이션 할 수 있었다. MySQL이 원래 깔려 있어서 비밀번호 입력하는 부분만 빼면 말이다! (MySQL 오류) 본격적으로 강의를 들어가기 전에 Java를 공부하기 전에 알아두면 좋을 것들!이라는 유튜브를 두 개 시청했다. 사실 Java를 공부한지는 꽤 됐는데 JVM의 이점 부분만 대략적으로 알았지 JRE나 JDK은 스킵하고 넘어갔었다. JVM이 제일 중요하다고 알려져있으니까. 이번 강의에서 본격적인 내용을 시작하기 전에 한 번 짚어주는 유튜브가 있어서 별 거 아닌데도 갑자기 많은 생각이 들기 시작했다.나는 왜 Java를 공부하면서 이런 것들도 몰랐을까?나는 Java를 잘 안다고 할 수 있을까?대충 공부함으로써 내가 얻을 수 있는 것들이 뭐였을까?라는 생각들이 스쳐지나 갔다...! 앞으로는 조금 더 꼼꼼한 사람이 돼야 겠다는 목표도 생겼다...! 본격적인 강의 시작에서는 Spring Boot 프로젝트를 실행하는 법과 네트워크, HTTP, API, GET API를 공부했다. 강사님이 최대한 이해하기 쉽게 이것 저것 비유해가면서 얘기해주셔서 이해가 잘 됐었던 거 같다.제일 기억에 남는 것은 함수 파라미터를 변수에서 객체로 변경한 이유가 기억에 남았다. 초보 입장에서는 이런 부분을 놓치는경우가 많고, 생각조차 안 나는 경우가 많은데 이렇게 사소한 것 까지도 짚어주시면서 강의를 진행해주시니 더 꼼꼼하게 코드를 작성할 수 있던 거 같다. 미션https://devhan.tistory.com/318어노테이션에 관한 미션이었다.어노테이션을 단순히 쓰라해서 사용하기만 했는데 어노테이션의 역할이 한 개만 있는 것이 아니라 목적에 따른 다양한 종류의 어노테이션이 있다는 걸 알게되었다!강사님의 코멘트어노테이션이 '마법' 같은 일을 해주기 위해서는 리플렉션이라는 기술이 사용된다.리플렉션은 라이브러리나 프레임워크를 개발할 때 간혹 사용되는 기술로, 코드를 직접적으로 호출하지 않고 코드를 제어하는 기술이다.   Day3 02/20 첫 HTTP API 개발 (6~10강) Day3에서는 GET API 이외에 POST API 개발, User 생성 API 개발, User 조회 API 개발, MySQL 사용에 대해서 공부했다.이번에도 기초적인 부분을 다루었기 때문에 딱히 어려운 것은 없었다. 강의를 따라가면서 느낀 건 API 스펙을 정하는 부분이 아주 좋았다! 다른 강의에서는 API 스펙을 정하는 부분이 없이 그냥 말로만 진행하는 강의도 다수 있었는데 이 강의에서는 미리 API 스펙을 알려주니 스펙을 보고 먼저 개발해본 다음에 강의를 들으면서 고치거나 할 수 있어서 좋았다. 미션https://devhan.tistory.com/319여태 했던 미션 중에 제일 오래 걸린 미션이 아닌가 싶다.. 왜냐면 미션 하는 중에 에러가 발생했기 때문!에러 내용은 @RequestBody 사용 시 해당 DTO 생성자에 파라미터가 한 개만 존재하는 생성자가 있고, 기본 생성자가 없어서 발생하는 에러였다.해결 방법은 @JsonCreator를 기존 생성자 메서드에 붙여주거나, 기본 생성자를 만들어주면 된다.강사님의 코멘트1번 - 본인이라면 DTO 쪽에 사칙 연산 기능을 넣었을 것이다. Service 계층의 코드를 깔끔하게 만들기 위해서는 일부 계산 로직을 DTO 쪽으로 넣는 방법을 사용할 수 있다.2번 - LocalDate를 사용! query parameter가 1개라서 바로 LocalDate를 사용해서 요청을 받을 것 같다.3번 - List를 받아보도록 연습! POST API + List 필드가 있는 DTO를 사용하면 쉽게 해결할 수 있다.  Day4 02/21 기본적인 데이터베이스 사용법 (11~13강)이번 강의에서는 MySQL에서 DDL, DML을 이용해 테이블을 생성 및 삭제, 데이터의 CRUD, Spring Boot에서 MySQL 연동을 해봤다. 이번 강의에서는 에러가 발생했다! MySQL 설정 시 발생하는 에러였는데 간단한 구글링을 통해 빠르게 해결할 수 있었다. (MySQL 연동 오류) 기본적인 SQL 문법을 간단하게 훑어 넘어가는 식으로 강의가 진행됐다. 기초가 없었으면 약간 따라가기 힘들었을 것 같기도 하다!그리고 User 테이블을 생성하고 Java 코드를 메모리에 저장하는 방식에서 데이터베이스(MySQL)에 저장하는 방식으로 변경하도록 코딩했다. 이번 강의에서 람다가 처음으로 나왔는데 람다에 대해서 따로 공부해본 적이 없어서 생소하게만 다가왔다. 이번에 람다를 보면서 OT때 강사님이 얘기했던 모던자바 인 액션 책을 꼭 공부해봐야겠다고 생각했다..! 미션https://devhan.tistory.com/320 익명 클래스와 람다에 대해 알아보는 시간이었다.이번 미션을 하면서 하루라도 빨리 모던자바 인 액션을 읽어야겠다고 생각해 책을 얼른 구매했다. Day5 02/22 데이터베이스를 사용해 만드는 API (14~16강)이번 강의에서는 JdbcTemplate을 사용한 API 개발을 구현하기 위해 기존에 있던 코드들을 변경하는 강의 내용이었다.User 업데이트, 삭제 부분을 코딩하는거였는데 14강에서는 단순히 변경만 했고 15강에서는 예외 상황을 대비해 예외 코드를 추가했다! 이 코드가 제일 신기했는데, 결과가 하나라도 있으면 0을 반환하게하는 코드이다. 그리고 최종적으로 0은 List로 반환된다.결과가 0건이면 빈 List가 반환된다! 미션https://devhan.tistory.com/321이번엔 Fruit 테이블을 생성하고, 요구사항에 맞는 API들을 개발하는 미션이었다.제일 고민이었던 건 판매 여부의 컬럼명과 데이터를 0과 1로 할지 아니면 Enum을 사용해서 String으로 저장할지 고민했는데 상태값이 두 개밖에 없어서 그냥 0과 1을 사용했다.강사님 코멘트select * from table을 사용하고 덧셈을 하는 경우는 데이터베이스에서 서버로 네트워크를 타고 모든 데이터가 넘어온 이후에 서버에서 직접 덧셈 -> 네트워크 대역폭도 많이 잡아 먹고 서버의 연산 비용도 들어감.반면 sum()을 사용하면 합산 결과만 네트워크를 타고 이동하며, 서버는 그 결과를 DTO로 감싸 전송만 하면 되기에 네트워크 및 연산 비용이 훨씬 저렴하다.이런 다양한 방법을 비교할 수 있으려면 1) 일차적으로는 방법들을 알아야하고 (지식의 넓이) 2) 다음으로는 각 방법의 매커니즘을 이해해야 함(지식의 깊이)Day6 02/23 클린코드의 개념과 첫 리팩토링 (17~18강)이번 강의에서는 좋은 코드(Clean Code)의 개념과 기존에 작성했던 코드를 Layered Architecture로 변경하는 작업을 했다.클린 코드는 아직 읽어보지 않았지만 워낙 유명한 책이라 강의에서 만난게 마치 오래전에 알던 친구를 만난 것처럼 재밌었다! 이 기회에 또 읽어봐야할 책이 하나 더 늘었다..!클린 코드에서 가장 기억에 남았던 건 유명 회사 앱이 클린 코드로 코드를 작성하지 않아 점차 망해가는 얘기였다. 그런 얘기가 떠돌아다닐 정도로 코드의 깔끔함은 앞으로의 유지보수에 있어 많은 부분에서 좋은 효과를 줄 수 있다는 걸 배웠다!클린 코드 얘기는 너무 많이 들었지만 어떻게 해야 깔끔하고 좋은 코드인지 가늠하기는 어려웠다. 나는 솔루션 회사에 재직해서 spring boot를 실무에서 쓸 일이 없어서 더욱 가늠이 안 갔던 거 같다. 이번 강의를 통해 조금이나마 클린 코드로 가는 틀을 잡을 수 있어서 좋았다!그리고 또 Layered Architecture란 이름을 알게되었다. Controller, Service, Repository로 구성된 애플리케이션은 여태 수도 없이 보았던 거 같은데 이런 명칭이 있는지는 처음 알았다. 대부분 그냥 MVC 패턴이라하며 갑자기 뭉뚱그려 넘어가서 몰랐었던 거 같다.  미션https://devhan.tistory.com/322작성된 주사위 놀이 코드에 클린 코드를 적용해 리팩토링해보는 미션이었다.제일 고민됐던 것은 Dice를 클래스로 따로 뺄지 말지였다.뭔가 빼면 과하게 빼는 거 같기도 하고,,? Main 메서드에 너무 아무것도 없는 거 같아 뭔가 심심해보이기도 했다.그리고 그 다음으로 고민했던 건 한 걸음 더! 내용이었다.주사위의 범위가 달라지더라도 코드를 적게 수정할 수 있도록 하는 거였는데 사용자에게 주사위 면체의 정보를 입력받을까 하다가 그런 얘기는 나와있지 않아서 그냥 Dice 클래스에 면체와 관련된 필드와 생성자를 추가해주었다..!마지막 1주차 느낀점 정리나는 되게 무언가를 대충 아는 정도였던 거 같다.하루빨리 자바8과 관련된 책을 읽고 지식을 습득해야 할 것 같다. (람다 관련 응용이 아예 안 되는 중이다.)클린 코드의 책도 읽고 클린 코드의 감을 잡아보도록 해야겠다.직장인이라 시간적 여유가 매우 부족해서 아쉬웠다. 저번주 주말에 미리미리 진도를 안빼놨었으면 진작에 수료 기준에 벗어날 뻔했다..! 직장인이니 남들보다 더 미리미리 진도를 나가야겠다. 이번주에만 글쎄 야근을 3일이나 해서 죽는 줄 알았다...생각보다 내가 강의를 잘 따라가고 있는 거 같다. 뭐 실력적으로 잘은 모르겠지만 그래도 꾸준히 놓치지 않고 하려는 모습이 약간은 기특해보일정도! 앞으로도 놓치지말고 꾸준히해서 이번 스터디를 완주했으면 좋겠다!   

백엔드인프런워밍업클럽스터디최태현자바와스프링부트로생애최초서버만들기SpringBootbackend

양성빈

[인프런 워밍업 스터디 클럽] 0기 백엔드 미션 - API 레이어 분리 테스트 (Day6)

과제진도표 6일차와 연결됩니다우리는 스프링 컨테이너의 개념을 배우고, 기존에 작성했던 Controller 코드를 3단 분리해보았습니다. 앞으로 API를 개발할 때는 이 계층에 맞게 각 코드가 작성되어야 합니다! 🙂과제 #4 에서 만들었던 API를 분리해보며, Controller - Service - Repository 계층에 익숙해져 봅시다! 👍문제 1과제4에서 만들었던 API를 Controller - Service - Repository로 분리하라고 하셨다.하지만 이전에 과제4를 진행하면서 나는 이미 레이어를 분리했지만 강의에 대한 복습 겸, 다시 진행해보기로 했다.step0. DB 생성 및 테이블 생성먼저 데이터베이스부터 다시 만들기로 하였다. 아래와 같이 쿼리를 작성하여 데이터베이스를 생성한다.create database fruit;다음으로 내가 생성한 fruit 데이터베이스에 접속한다.use fruit;그리고 테이블 목록을 조회해본다. 당연히 비어 있을 것이다.show tables;그러면 아래와 같이 테이블 목록들이 비어있는 것을 확인할 수 있을 것이다.그러면 이제 아래와 같이 쿼리를 작성해서 테이블을 만들어보자. 테이블 컬럼들은 기존과 동일하게 적용한다.CREATE TABLE fruit ( id bigint auto_increment, name varchar(20) not null, warehousingDate date not null, price bigint not null, is_sold boolean not null default false, primary key (id) );그리고 테이블이 잘 생성 되었는지 조회를 해서 확인해본다.show tables;step1. DB 설정 정보 적용이제 DB 연결정보를 Spring Boot 프로젝트와 연결해보자.프로젝트의 src/main/resources의 경로에 있는 application.properties를 application.yml로 변경하고 설정정보를 아래와 같이 작성한다.spring: datasource: url: "jdbc:mysql://localhost/fruit" username: "root" password: "" driver-class-name: com.mysql.cj.jdbc.Driver⚠ 주의username과 password는 본인에 따라 달리 작성한다.또한 굳이 application.yml 로 확장자 변경을 안하고 properties 확장자로 이용해도 무관하다.step2. 기존 컨트롤러 클래스 파일 가져오기나는 이미 과제4에서 레이어를 분리해두었다. 하지만 이번 과제의 취지에 맞게 기존에 파일들을 가져오기는 하지만 비즈니스 로직들을 컨트롤럴 클래스에 포함된 파일들로 가져오기로 하였다.Fruit.javapackage me.sungbin.entity.fruit; import java.time.LocalDate; /** * @author : rovert * @packageName : me.sungbin.controller.fruit * @fileName : Fruit * @date : 2/25/24 * @description : * =========================================================== * DATE AUTHOR NOTE * ----------------------------------------------------------- * 2/25/24 rovert 최초 생성 */ public class Fruit { private long id; private String name; private LocalDate warehousingDate; private long price; private boolean isSold; public Fruit() { } public Fruit(String name, LocalDate warehousingDate, long price) { this.name = name; this.warehousingDate = warehousingDate; this.price = price; } public Fruit(String name, LocalDate warehousingDate, long price, boolean isSold) { this.name = name; this.warehousingDate = warehousingDate; this.price = price; this.isSold = isSold; } public long getId() { return id; } public String getName() { return name; } public LocalDate getWarehousingDate() { return warehousingDate; } public long getPrice() { return price; } public boolean isSold() { return isSold; } }SaveFruitInfoRequestInfo.javapackage me.sungbin.dto.fruit.request; import jakarta.validation.constraints.Min; import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotNull; import me.sungbin.entity.fruit.Fruit; import org.springframework.format.annotation.DateTimeFormat; import java.time.LocalDate; /** * @author : rovert * @packageName : me.sungbin.dto.fruit.request * @fileName : SaveFruitInfoRequestDto * @date : 2/25/24 * @description : * =========================================================== * DATE AUTHOR NOTE * ----------------------------------------------------------- * 2/25/24 rovert 최초 생성 */ public class SaveFruitInfoRequestDto { @NotBlank(message = "과일 이름이 공란일 수 없습니다.") @NotNull(message = "과일 이름이 null일 수는 없습니다.") private String name; @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) private LocalDate warehousingDate; @Min(value = 0, message = "가격이 음수가 될 수는 없습니다.") private long price; public SaveFruitInfoRequestDto(String name, LocalDate warehousingDate, long price) { this.name = name; this.warehousingDate = warehousingDate; this.price = price; } public String getName() { return name; } public LocalDate getWarehousingDate() { return warehousingDate; } public long getPrice() { return price; } public Fruit toEntity() { return new Fruit(name, warehousingDate, price); } }UpdateFruitRequestDto.javapackage me.sungbin.dto.fruit.request; /** * @author : rovert * @packageName : me.sungbin.dto.fruit.request * @fileName : UpdateFruitRequestDto * @date : 2/25/24 * @description : * =========================================================== * DATE AUTHOR NOTE * ----------------------------------------------------------- * 2/25/24 rovert 최초 생성 */ public class UpdateFruitRequestDto { private long id; public UpdateFruitRequestDto() { } public UpdateFruitRequestDto(long id) { this.id = id; } public long getId() { return id; } }GetFruitResponseDto.javapackage me.sungbin.dto.fruit.response; /** * @author : rovert * @packageName : me.sungbin.dto.fruit.response * @fileName : GetFruitResponseDto * @date : 2/25/24 * @description : * =========================================================== * DATE AUTHOR NOTE * ----------------------------------------------------------- * 2/25/24 rovert 최초 생성 */ public class GetFruitResponseDto { private long salesAmount; private long notSalesAmount; public GetFruitResponseDto(long salesAmount, long notSalesAmount) { this.salesAmount = salesAmount; this.notSalesAmount = notSalesAmount; } public long getSalesAmount() { return salesAmount; } public long getNotSalesAmount() { return notSalesAmount; } }FruitController.javapackage me.sungbin.controller.fruit; import jakarta.validation.Valid; import me.sungbin.dto.fruit.request.SaveFruitInfoRequestDto; import me.sungbin.dto.fruit.response.GetFruitResponseDto; import me.sungbin.entity.fruit.Fruit; import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.web.bind.annotation.*; import java.util.HashMap; import java.util.Map; /** * @author : rovert * @packageName : me.sungbin.controller.fruit * @fileName : FruitController * @date : 2/25/24 * @description : * =========================================================== * DATE AUTHOR NOTE * ----------------------------------------------------------- * 2/25/24 rovert 최초 생성 */ @RestController @RequestMapping("/api/v1/fruit") public class FruitController { private final JdbcTemplate jdbcTemplate; public FruitController(JdbcTemplate jdbcTemplate) { this.jdbcTemplate = jdbcTemplate; } @PostMapping public void saveFruitInfo(@RequestBody @Valid Fruit fruit) { String sql = "INSERT INTO fruit (name, warehousingDate, price) VALUES (?, ?, ?)"; this.jdbcTemplate.update(sql, fruit.getName(), fruit.getWarehousingDate(), fruit.getPrice()); } @PutMapping public void updateFruitInfo(@RequestBody Fruit fruit) { String readSQL = "SELECT * FROM fruit WHERE id = ?"; boolean isNotExistsFruitInfo = jdbcTemplate.query(readSQL, (rs, rowNum) -> 0, fruit.getId()).isEmpty(); if (isNotExistsFruitInfo) { throw new IllegalArgumentException("존재하는 과일정보가 없습니다."); } String sql = "UPDATE fruit SET is_sold = 1 WHERE id = ?"; jdbcTemplate.update(sql, fruit.getId()); } @GetMapping("/stat") public GetFruitResponseDto getFruitInfo(@RequestParam String name) { long salesAmount = 0; long notSalesAmount = 0; String sql = "SELECT SUM(price) as total, is_sold FROM fruit WHERE name = ? GROUP BY is_sold"; Map<Boolean, Long> aggregatedData = jdbcTemplate.query(sql, new Object[]{name}, rs -> { HashMap<Boolean, Long> map = new HashMap<>(); while (rs.next()) { map.put(rs.getBoolean("is_sold"), rs.getLong("total")); } return map; }); if (aggregatedData.containsKey(true)) { salesAmount = aggregatedData.get(true); } if (aggregatedData.containsKey(false)) { notSalesAmount = aggregatedData.get(false); } if (salesAmount == 0L && notSalesAmount == 0L) { throw new IllegalArgumentException("존재하는 과일이 없습니다."); } return new GetFruitResponseDto(salesAmount, notSalesAmount); } }step3. 레이어 분리이제 레이어를 분리해보겠다. 일단 현재 컨트롤러에는 HTTP 통신하는 부분과 DB처리 관련 로직, 예외로직이 엄청 많다. 이것은 클린코드의 단일책임원칙에 위배가 되므로 서비스 레이어를 만들어 분리해보도록 하자.FruitService.javapackage me.sungbin.service.fruit; import me.sungbin.dto.fruit.response.GetFruitResponseDto; import me.sungbin.entity.fruit.Fruit; import org.springframework.jdbc.core.JdbcTemplate; import java.util.HashMap; import java.util.Map; /** * @author : rovert * @packageName : me.sungbin.service.fruit * @fileName : FruitService * @date : 2/25/24 * @description : * =========================================================== * DATE AUTHOR NOTE * ----------------------------------------------------------- * 2/25/24 rovert 최초 생성 */ public class FruitService { private final JdbcTemplate jdbcTemplate; public FruitService(JdbcTemplate jdbcTemplate) { this.jdbcTemplate = jdbcTemplate; } public void saveFruitInfo(Fruit fruit) { String sql = "INSERT INTO fruit (name, warehousingDate, price) VALUES (?, ?, ?)"; this.jdbcTemplate.update(sql, fruit.getName(), fruit.getWarehousingDate(), fruit.getPrice()); } public void updateFruitInfo(Fruit fruit) { String readSQL = "SELECT * FROM fruit WHERE id = ?"; boolean isNotExistsFruitInfo = jdbcTemplate.query(readSQL, (rs, rowNum) -> 0, fruit.getId()).isEmpty(); if (isNotExistsFruitInfo) { throw new IllegalArgumentException("존재하는 과일정보가 없습니다."); } String sql = "UPDATE fruit SET is_sold = 1 WHERE id = ?"; jdbcTemplate.update(sql, fruit.getId()); } public GetFruitResponseDto getFruitInfo(String name) { long salesAmount = 0; long notSalesAmount = 0; String sql = "SELECT SUM(price) as total, is_sold FROM fruit WHERE name = ? GROUP BY is_sold"; Map<Boolean, Long> aggregatedData = jdbcTemplate.query(sql, new Object[]{name}, rs -> { HashMap<Boolean, Long> map = new HashMap<>(); while (rs.next()) { map.put(rs.getBoolean("is_sold"), rs.getLong("total")); } return map; }); if (aggregatedData.containsKey(true)) { salesAmount = aggregatedData.get(true); } if (aggregatedData.containsKey(false)) { notSalesAmount = aggregatedData.get(false); } if (salesAmount == 0L && notSalesAmount == 0L) { throw new IllegalArgumentException("존재하는 과일이 없습니다."); } return new GetFruitResponseDto(salesAmount, notSalesAmount); } }FruitController.javapackage me.sungbin.controller.fruit; import jakarta.validation.Valid; import me.sungbin.dto.fruit.request.SaveFruitInfoRequestDto; import me.sungbin.dto.fruit.response.GetFruitResponseDto; import me.sungbin.entity.fruit.Fruit; import me.sungbin.service.fruit.FruitService; import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.web.bind.annotation.*; import java.util.HashMap; import java.util.Map; /** * @author : rovert * @packageName : me.sungbin.controller.fruit * @fileName : FruitController * @date : 2/25/24 * @description : * =========================================================== * DATE AUTHOR NOTE * ----------------------------------------------------------- * 2/25/24 rovert 최초 생성 */ @RestController @RequestMapping("/api/v1/fruit") public class FruitController { private final FruitService fruitService; public FruitController(JdbcTemplate jdbcTemplate) { this.fruitService = new FruitService(jdbcTemplate); } @PostMapping public void saveFruitInfo(@RequestBody @Valid Fruit fruit) { this.fruitService.saveFruitInfo(fruit); } @PutMapping public void updateFruitInfo(@RequestBody Fruit fruit) { this.fruitService.updateFruitInfo(fruit); } @GetMapping("/stat") public GetFruitResponseDto getFruitInfo(@RequestParam String name) { return this.fruitService.getFruitInfo(name); } }좀 더 컨트롤러 클래스가 깔끔해진 것을 볼 수 있다. 하지만 서비스 클래스에 DB 관련 처리과 더해 예외로직들이 있는 것은 클린코드에 위배되는 것 같다. 따라서 FruitService 코드도 레파지토리 레이어를 만들어서 분리해보도록 하자. 그리고 각각 리팩토링 작업도 거쳤다. 아래의 코드를 보자. FruitService.javapackage me.sungbin.service.fruit; import me.sungbin.dto.fruit.response.GetFruitResponseDto; import me.sungbin.entity.fruit.Fruit; import me.sungbin.repository.fruit.FruitRepository; import org.springframework.jdbc.core.JdbcTemplate; /** * @author : rovert * @packageName : me.sungbin.service.fruit * @fileName : FruitService * @date : 2/25/24 * @description : * =========================================================== * DATE AUTHOR NOTE * ----------------------------------------------------------- * 2/25/24 rovert 최초 생성 */ public class FruitService { private final FruitRepository fruitRepository; public FruitService(JdbcTemplate jdbcTemplate) { this.fruitRepository = new FruitRepository(jdbcTemplate); } public void saveFruitInfo(Fruit fruit) { this.fruitRepository.saveFruitInfo(fruit); } public void updateFruitInfo(Fruit fruit) { validate(fruit.getId()); this.fruitRepository.updateFruitInfo(fruit); } public GetFruitResponseDto getFruitInfo(String name) { GetFruitResponseDto fruitData = this.fruitRepository.getFruitInfo(name); validateGetFruitAmount(fruitData.getSalesAmount(), fruitData.getNotSalesAmount()); return fruitData; } private void validate(long id) { if (this.fruitRepository.isNotExistsFruitInfo(id)) { throw new IllegalArgumentException("존재하는 과일정보가 없습니다."); } } private void validateGetFruitAmount(long salesAmount, long notSalesAmount) { if (salesAmount == 0L && notSalesAmount == 0L) { throw new IllegalArgumentException("존재하는 과일이 없습니다."); } } }FruitRepository.javapackage me.sungbin.repository.fruit; import me.sungbin.dto.fruit.response.GetFruitResponseDto; import me.sungbin.entity.fruit.Fruit; import org.springframework.jdbc.core.JdbcTemplate; import java.util.HashMap; import java.util.Map; import java.util.Objects; /** * @author : rovert * @packageName : me.sungbin.repository.fruit * @fileName : FruitRepository * @date : 2/25/24 * @description : * =========================================================== * DATE AUTHOR NOTE * ----------------------------------------------------------- * 2/25/24 rovert 최초 생성 */ public class FruitRepository { private final JdbcTemplate jdbcTemplate; public FruitRepository(JdbcTemplate jdbcTemplate) { this.jdbcTemplate = jdbcTemplate; } public void saveFruitInfo(Fruit fruit) { String sql = "INSERT INTO fruit (name, warehousingDate, price) VALUES (?, ?, ?)"; this.jdbcTemplate.update(sql, fruit.getName(), fruit.getWarehousingDate(), fruit.getPrice()); } public void updateFruitInfo(Fruit fruit) { String sql = "UPDATE fruit SET is_sold = 1 WHERE id = ?"; jdbcTemplate.update(sql, fruit.getId()); } public GetFruitResponseDto getFruitInfo(String name) { String sql = "SELECT is_sold, SUM(price) AS total FROM fruit WHERE name = ? GROUP BY is_sold"; Map<Boolean, Long> aggregatedData = jdbcTemplate.query(sql, new Object[]{name}, rs -> { HashMap<Boolean, Long> map = new HashMap<>(); while (rs.next()) { map.put(rs.getBoolean("is_sold"), rs.getLong("total")); } return map; }); long salesAmount = Objects.requireNonNull(aggregatedData).getOrDefault(true, 0L); long notSalesAmount = Objects.requireNonNull(aggregatedData).getOrDefault(false, 0L); return new GetFruitResponseDto(salesAmount, notSalesAmount); } public boolean isNotExistsFruitInfo(long id) { String readSQL = "SELECT * FROM fruit WHERE id = ?"; return jdbcTemplate.query(readSQL, (rs, rowNum) -> 0, id).isEmpty(); } }하지만 뭔가 이상한 점을 발견할 수 있다. 현재 DB를 이용하는 것은 레파지토리 레이어이다. 즉, JdbcTemplate을 이용하는 것은 레파지토리 레이어뿐인 것이다. 하지만 코드를 보면 알 수 있듯이 컨트롤러, 서비스 레이어에도 전부 JdbcTemplate을 매개변수로 넣고 있다. 이런 것을 어떻게 해결할까? 바로 서비스와 레파지토리 레이어에 빈을 주입할 수 있는 어노테이션을 붙여준다. 이 부분은 오늘 강의시간에도 다뤘으니 적용해보자. FruitController.javapackage me.sungbin.controller.fruit; import jakarta.validation.Valid; import me.sungbin.dto.fruit.response.GetFruitResponseDto; import me.sungbin.entity.fruit.Fruit; import me.sungbin.service.fruit.FruitService; import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.web.bind.annotation.*; /** * @author : rovert * @packageName : me.sungbin.controller.fruit * @fileName : FruitController * @date : 2/25/24 * @description : * =========================================================== * DATE AUTHOR NOTE * ----------------------------------------------------------- * 2/25/24 rovert 최초 생성 */ @RestController @RequestMapping("/api/v1/fruit") public class FruitController { private final FruitService fruitService; public FruitController(FruitService fruitService) { this.fruitService = fruitService; } @PostMapping public void saveFruitInfo(@RequestBody @Valid Fruit fruit) { this.fruitService.saveFruitInfo(fruit); } @PutMapping public void updateFruitInfo(@RequestBody Fruit fruit) { this.fruitService.updateFruitInfo(fruit); } @GetMapping("/stat") public GetFruitResponseDto getFruitInfo(@RequestParam String name) { return this.fruitService.getFruitInfo(name); } }FruitService.javapackage me.sungbin.service.fruit; import me.sungbin.dto.fruit.response.GetFruitResponseDto; import me.sungbin.entity.fruit.Fruit; import me.sungbin.repository.fruit.FruitRepository; import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.stereotype.Service; /** * @author : rovert * @packageName : me.sungbin.service.fruit * @fileName : FruitService * @date : 2/25/24 * @description : * =========================================================== * DATE AUTHOR NOTE * ----------------------------------------------------------- * 2/25/24 rovert 최초 생성 */ @Service public class FruitService { private final FruitRepository fruitRepository; public FruitService(FruitRepository fruitRepository) { this.fruitRepository = fruitRepository; } public void saveFruitInfo(Fruit fruit) { this.fruitRepository.saveFruitInfo(fruit); } public void updateFruitInfo(Fruit fruit) { validate(fruit.getId()); this.fruitRepository.updateFruitInfo(fruit); } public GetFruitResponseDto getFruitInfo(String name) { GetFruitResponseDto fruitData = this.fruitRepository.getFruitInfo(name); validateGetFruitAmount(fruitData.getSalesAmount(), fruitData.getNotSalesAmount()); return fruitData; } private void validate(long id) { if (this.fruitRepository.isNotExistsFruitInfo(id)) { throw new IllegalArgumentException("존재하는 과일정보가 없습니다."); } } private void validateGetFruitAmount(long salesAmount, long notSalesAmount) { if (salesAmount == 0L && notSalesAmount == 0L) { throw new IllegalArgumentException("존재하는 과일이 없습니다."); } } } FruitRepository.javapackage me.sungbin.repository.fruit; import me.sungbin.dto.fruit.response.GetFruitResponseDto; import me.sungbin.entity.fruit.Fruit; import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.stereotype.Repository; import java.util.HashMap; import java.util.Map; import java.util.Objects; /** * @author : rovert * @packageName : me.sungbin.repository.fruit * @fileName : FruitRepository * @date : 2/25/24 * @description : * =========================================================== * DATE AUTHOR NOTE * ----------------------------------------------------------- * 2/25/24 rovert 최초 생성 */ @Repository public class FruitRepository { private final JdbcTemplate jdbcTemplate; public FruitRepository(JdbcTemplate jdbcTemplate) { this.jdbcTemplate = jdbcTemplate; } public void saveFruitInfo(Fruit fruit) { String sql = "INSERT INTO fruit (name, warehousingDate, price) VALUES (?, ?, ?)"; this.jdbcTemplate.update(sql, fruit.getName(), fruit.getWarehousingDate(), fruit.getPrice()); } public void updateFruitInfo(Fruit fruit) { String sql = "UPDATE fruit SET is_sold = 1 WHERE id = ?"; jdbcTemplate.update(sql, fruit.getId()); } public GetFruitResponseDto getFruitInfo(String name) { String sql = "SELECT is_sold, SUM(price) AS total FROM fruit WHERE name = ? GROUP BY is_sold"; Map<Boolean, Long> aggregatedData = jdbcTemplate.query(sql, new Object[]{name}, rs -> { HashMap<Boolean, Long> map = new HashMap<>(); while (rs.next()) { map.put(rs.getBoolean("is_sold"), rs.getLong("total")); } return map; }); long salesAmount = Objects.requireNonNull(aggregatedData).getOrDefault(true, 0L); long notSalesAmount = Objects.requireNonNull(aggregatedData).getOrDefault(false, 0L); return new GetFruitResponseDto(salesAmount, notSalesAmount); } public boolean isNotExistsFruitInfo(long id) { String readSQL = "SELECT * FROM fruit WHERE id = ?"; return jdbcTemplate.query(readSQL, (rs, rowNum) -> 0, id).isEmpty(); } }각각 레파지토리 레이어와 서비스 레이어에 @Repository, @Service 어노테이션을 붙여주었고 이 어노테이션들은 @Component 어노테이션들을 붙어 있어서 빈을 주입 받을 수 있다. 그래서 각각 생성자 주입 방식으로 주입을 받았다.step4. 엔티티 대신에 DTO로!검색을 해보면 request나 response로 받아주는 것을 DTO로 받는게 좋다고 했다. 그이유는 아래와 같다. 📚 엔티티 대신에 DTO를 사용하는 이유?DTO(Data Transfer Object)를 엔티티 대신 사용하는 이유는 여러 가지가 있다. 첫째, DTO를 사용하면 애플리케이션의 프레젠테이션 계층과 데이터 접근 계층 사이의 의존성을 줄일 수 있어, 애플리케이션의 확장성과 유지보수성이 향상된다. 각 계층이 서로에 대해 덜 알고 있기 때문에, 변경 사항이 한 계층에만 국한되어 다른 계층에는 영향을 주지 않는 경우가 많다.둘째, DTO를 사용하면 클라이언트에 전송되는 데이터의 양과 형식을 조정할 수 있어, 네트워크를 통한 데이터 전송량을 최적화하고, 클라이언트가 필요로 하는 데이터 형식을 맞춤 제공할 수 있다. 이는 특히 모바일 애플리케이션 개발이나 대역폭이 제한된 환경에서 중요하다.셋째, DTO를 사용하면 엔티티의 모든 정보를 클라이언트에 노출하지 않아도 된다. 이는 보안 측면에서 매우 중요한데, 예를 들어 사용자 엔티티에는 비밀번호와 같은 민감한 정보가 포함될 수 있으나, 이를 DTO를 통해 필터링하고 클라이언트에 필요한 정보만 전달할 수 있다.넷째, 엔티티의 경우 JPA와 같은 ORM 기술을 사용할 때 지연 로딩(Lazy Loading) 등의 문제로 인해 직렬화에 어려움이 있을 수 있습니다. DTO를 사용하면 이러한 문제를 피하고, 데이터 전송을 위해 최적화된 객체를 생성할 수 있습니다.이러한 이유로 한번 DTO로 변경해보자. 현재 DTO는 과제4에서 사용했던 DTO를 이용할 것이다. 그리고 이 DTO의 코드내용은 step2에서 보여줬으므로 이것을 이용해보자.FruitController.javapackage me.sungbin.controller.fruit; import jakarta.validation.Valid; import me.sungbin.dto.fruit.request.SaveFruitInfoRequestDto; import me.sungbin.dto.fruit.request.UpdateFruitRequestDto; import me.sungbin.dto.fruit.response.GetFruitResponseDto; import me.sungbin.entity.fruit.Fruit; import me.sungbin.service.fruit.FruitService; import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.web.bind.annotation.*; /** * @author : rovert * @packageName : me.sungbin.controller.fruit * @fileName : FruitController * @date : 2/25/24 * @description : * =========================================================== * DATE AUTHOR NOTE * ----------------------------------------------------------- * 2/25/24 rovert 최초 생성 */ @RestController @RequestMapping("/api/v1/fruit") public class FruitController { private final FruitService fruitService; public FruitController(FruitService fruitService) { this.fruitService = fruitService; } @PostMapping public void saveFruitInfo(@RequestBody @Valid SaveFruitInfoRequestDto requestDto) { this.fruitService.saveFruitInfo(requestDto); } @PutMapping public void updateFruitInfo(@RequestBody UpdateFruitRequestDto requestDto) { this.fruitService.updateFruitInfo(requestDto); } @GetMapping("/stat") public GetFruitResponseDto getFruitInfo(@RequestParam String name) { return this.fruitService.getFruitInfo(name); } }FruitService.javapackage me.sungbin.service.fruit; import me.sungbin.dto.fruit.request.SaveFruitInfoRequestDto; import me.sungbin.dto.fruit.request.UpdateFruitRequestDto; import me.sungbin.dto.fruit.response.GetFruitResponseDto; import me.sungbin.entity.fruit.Fruit; import me.sungbin.repository.fruit.FruitRepository; import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.stereotype.Service; /** * @author : rovert * @packageName : me.sungbin.service.fruit * @fileName : FruitService * @date : 2/25/24 * @description : * =========================================================== * DATE AUTHOR NOTE * ----------------------------------------------------------- * 2/25/24 rovert 최초 생성 */ @Service public class FruitService { private final FruitRepository fruitRepository; public FruitService(FruitRepository fruitRepository) { this.fruitRepository = fruitRepository; } public void saveFruitInfo(SaveFruitInfoRequestDto requestDto) { Fruit fruit = requestDto.toEntity(); this.fruitRepository.saveFruitInfo(fruit); } public void updateFruitInfo(UpdateFruitRequestDto requestDto) { validate(requestDto.getId()); this.fruitRepository.updateFruitInfo(requestDto.getId()); } public GetFruitResponseDto getFruitInfo(String name) { GetFruitResponseDto fruitData = this.fruitRepository.getFruitInfo(name); validateGetFruitAmount(fruitData.getSalesAmount(), fruitData.getNotSalesAmount()); return fruitData; } private void validate(long id) { if (this.fruitRepository.isNotExistsFruitInfo(id)) { throw new IllegalArgumentException("존재하는 과일정보가 없습니다."); } } private void validateGetFruitAmount(long salesAmount, long notSalesAmount) { if (salesAmount == 0L && notSalesAmount == 0L) { throw new IllegalArgumentException("존재하는 과일이 없습니다."); } } }FruitRepository.javapackage me.sungbin.repository.fruit; import me.sungbin.dto.fruit.response.GetFruitResponseDto; import me.sungbin.entity.fruit.Fruit; import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.stereotype.Repository; import java.util.HashMap; import java.util.Map; import java.util.Objects; /** * @author : rovert * @packageName : me.sungbin.repository.fruit * @fileName : FruitRepository * @date : 2/25/24 * @description : * =========================================================== * DATE AUTHOR NOTE * ----------------------------------------------------------- * 2/25/24 rovert 최초 생성 */ @Repository public class FruitRepository { private final JdbcTemplate jdbcTemplate; public FruitRepository(JdbcTemplate jdbcTemplate) { this.jdbcTemplate = jdbcTemplate; } public void saveFruitInfo(Fruit fruit) { String sql = "INSERT INTO fruit (name, warehousingDate, price) VALUES (?, ?, ?)"; this.jdbcTemplate.update(sql, fruit.getName(), fruit.getWarehousingDate(), fruit.getPrice()); } public void updateFruitInfo(long id) { String sql = "UPDATE fruit SET is_sold = 1 WHERE id = ?"; jdbcTemplate.update(sql, id); } public GetFruitResponseDto getFruitInfo(String name) { String sql = "SELECT is_sold, SUM(price) AS total FROM fruit WHERE name = ? GROUP BY is_sold"; Map<Boolean, Long> aggregatedData = jdbcTemplate.query(sql, new Object[]{name}, rs -> { HashMap<Boolean, Long> map = new HashMap<>(); while (rs.next()) { map.put(rs.getBoolean("is_sold"), rs.getLong("total")); } return map; }); long salesAmount = Objects.requireNonNull(aggregatedData).getOrDefault(true, 0L); long notSalesAmount = Objects.requireNonNull(aggregatedData).getOrDefault(false, 0L); return new GetFruitResponseDto(salesAmount, notSalesAmount); } public boolean isNotExistsFruitInfo(long id) { String readSQL = "SELECT * FROM fruit WHERE id = ?"; return jdbcTemplate.query(readSQL, (rs, rowNum) -> 0, id).isEmpty(); } }step5. postman 테스트이제 이렇게 리팩토링한 것을 postman을 이용해서 테스트해보자.현재 fruit 테이블은 아래와 같이 비어있다.과일 생성수정위의 생성 테스트가 잘 되었으니, 몇개의 데이터를 아래와 같이 만들었다.이제 2000원짜리 오렌지가 팔린 테스트를 해보겠다.조회 테스트이제 조회 테스트를 해보자. 오렌지가 팔린 금액과 안 팔린 금액을 조회해보자. step6. 테스트 코드이제 테스트 코드로 다시 한번 검증해보자. FruitControllerTest.javapackage me.sungbin.controller.fruit; import com.fasterxml.jackson.databind.ObjectMapper; import me.sungbin.dto.fruit.request.SaveFruitInfoRequestDto; import me.sungbin.dto.fruit.request.UpdateFruitRequestDto; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; import org.springframework.boot.test.context.SpringBootTest; import org.springframework.http.MediaType; import org.springframework.test.web.servlet.MockMvc; import java.time.LocalDate; import static org.junit.jupiter.api.Assertions.*; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; /** * @author : rovert * @packageName : me.sungbin.controller.fruit * @fileName : FruitControllerTest * @date : 2/25/24 * @description : * =========================================================== * DATE AUTHOR NOTE * ----------------------------------------------------------- * 2/25/24 rovert 최초 생성 */ @SpringBootTest @AutoConfigureMockMvc class FruitControllerTest { @Autowired private MockMvc mockMvc; @Autowired private ObjectMapper objectMapper; @Test @DisplayName("문제 1번 통합 테스트 - 실패 (가격이 음수거나 과일 이름이 공란)") void question1_test_fail_caused_by_price_is_minus_or_fruit_name_is_empty() throws Exception { SaveFruitInfoRequestDto requestDto = new SaveFruitInfoRequestDto("", LocalDate.of(2024, 1, 1), -1000); this.mockMvc.perform(post("/api/v1/fruit") .contentType(MediaType.APPLICATION_JSON) .content(objectMapper.writeValueAsString(requestDto))) .andDo(print()) .andExpect(status().isBadRequest()); } @Test @DisplayName("문제 1번 통합 테스트 - 성공") void question1_test_success() throws Exception { SaveFruitInfoRequestDto requestDto = new SaveFruitInfoRequestDto("파인애플", LocalDate.of(2024, 2, 2), 20000); this.mockMvc.perform(post("/api/v1/fruit") .contentType(MediaType.APPLICATION_JSON) .content(objectMapper.writeValueAsString(requestDto))) .andDo(print()) .andExpect(status().isOk()); } @Test @DisplayName("문제 2번 통합 테스트 - 성공") void question2_test_success() throws Exception { UpdateFruitRequestDto requestDto = new UpdateFruitRequestDto(1); this.mockMvc.perform(put("/api/v1/fruit") .contentType(MediaType.APPLICATION_JSON) .content(objectMapper.writeValueAsString(requestDto))) .andDo(print()) .andExpect(status().isOk()); } @Test @DisplayName("문제 3번 통합 테스트 - 성공") void question3_test_success() throws Exception { this.mockMvc.perform(get("/api/v1/fruit/stat") .param("name", "사과")) .andDo(print()) .andExpect(status().isOk()); } }문제2문제2는 FruitRepository를 FruitMemoryRepository 와 FruitMysqlRepository로 나누고 @Primary 어노테이션을 이용하여 두 Repository를 번갈아가며 동작시키는 것을 구현하시라고 하셨다. step1. FruitRepository 코드를 FruitMysqlRepository로 변경package me.sungbin.repository.fruit; import me.sungbin.dto.fruit.response.GetFruitResponseDto; import me.sungbin.entity.fruit.Fruit; import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.stereotype.Repository; import java.util.HashMap; import java.util.Map; import java.util.Objects; /** * @author : rovert * @packageName : me.sungbin.repository.fruit * @fileName : FruitRepository * @date : 2/25/24 * @description : * =========================================================== * DATE AUTHOR NOTE * ----------------------------------------------------------- * 2/25/24 rovert 최초 생성 */ @Repository public class FruitMysqlRepository { private final JdbcTemplate jdbcTemplate; public FruitMysqlRepository(JdbcTemplate jdbcTemplate) { this.jdbcTemplate = jdbcTemplate; } public void saveFruitInfo(Fruit fruit) { String sql = "INSERT INTO fruit (name, warehousingDate, price) VALUES (?, ?, ?)"; this.jdbcTemplate.update(sql, fruit.getName(), fruit.getWarehousingDate(), fruit.getPrice()); } public void updateFruitInfo(long id) { String sql = "UPDATE fruit SET is_sold = 1 WHERE id = ?"; jdbcTemplate.update(sql, id); } public GetFruitResponseDto getFruitInfo(String name) { String sql = "SELECT is_sold, SUM(price) AS total FROM fruit WHERE name = ? GROUP BY is_sold"; Map<Boolean, Long> aggregatedData = jdbcTemplate.query(sql, new Object[]{name}, rs -> { HashMap<Boolean, Long> map = new HashMap<>(); while (rs.next()) { map.put(rs.getBoolean("is_sold"), rs.getLong("total")); } return map; }); long salesAmount = Objects.requireNonNull(aggregatedData).getOrDefault(true, 0L); long notSalesAmount = Objects.requireNonNull(aggregatedData).getOrDefault(false, 0L); return new GetFruitResponseDto(salesAmount, notSalesAmount); } public boolean isNotExistsFruitInfo(long id) { String readSQL = "SELECT * FROM fruit WHERE id = ?"; return jdbcTemplate.query(readSQL, (rs, rowNum) -> 0, id).isEmpty(); } }step2. FruitRepository 인터페이스 생성package me.sungbin.repository.fruit; import me.sungbin.dto.fruit.response.GetFruitResponseDto; import me.sungbin.entity.fruit.Fruit; /** * @author : rovert * @packageName : me.sungbin.repository.fruit * @fileName : FruitRepository * @date : 2/25/24 * @description : * =========================================================== * DATE AUTHOR NOTE * ----------------------------------------------------------- * 2/25/24 rovert 최초 생성 */ public interface FruitRepository { void saveFruitInfo(Fruit fruit); // 과일 생성 void updateFruitInfo(long id); // 과일 정보 업데이트 GetFruitResponseDto getFruitInfo(String name); // 과일 조회 boolean isNotExistsFruitInfo(long id); } step3. FruitMemoryRepository 생성 및 로직 개발step3-1. Fruit 클래스에 다중 생성자 추가(메모리 용 때문에)package me.sungbin.entity.fruit; import java.time.LocalDate; /** * @author : rovert * @packageName : me.sungbin.controller.fruit * @fileName : Fruit * @date : 2/25/24 * @description : * =========================================================== * DATE AUTHOR NOTE * ----------------------------------------------------------- * 2/25/24 rovert 최초 생성 */ public class Fruit { private long id; private String name; private LocalDate warehousingDate; private long price; private boolean isSold; public Fruit() { } public Fruit(String name, LocalDate warehousingDate, long price) { this.name = name; this.warehousingDate = warehousingDate; this.price = price; } public Fruit(String name, LocalDate warehousingDate, long price, boolean isSold) { this.name = name; this.warehousingDate = warehousingDate; this.price = price; this.isSold = isSold; } public Fruit(long id, String name, LocalDate warehousingDate, long price, boolean isSold) { this.id = id; this.name = name; this.warehousingDate = warehousingDate; this.price = price; this.isSold = isSold; } public long getId() { return id; } public String getName() { return name; } public LocalDate getWarehousingDate() { return warehousingDate; } public long getPrice() { return price; } public boolean isSold() { return isSold; } }step3-2. FruitMemoryRepository 생성 및 로직 추가package me.sungbin.repository.fruit; import me.sungbin.dto.fruit.response.GetFruitResponseDto; import me.sungbin.entity.fruit.Fruit; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.context.annotation.Primary; import org.springframework.stereotype.Repository; import java.util.ArrayList; import java.util.List; import java.util.stream.Collectors; /** * @author : rovert * @packageName : me.sungbin.repository.fruit * @fileName : FruitMemoryRepository * @date : 2/25/24 * @description : * =========================================================== * DATE AUTHOR NOTE * ----------------------------------------------------------- * 2/25/24 rovert 최초 생성 */ @Primary @Repository public class FruitMemoryRepository implements FruitRepository { private final List<Fruit> fruits = new ArrayList<>(); private final Logger log = LoggerFactory.getLogger(this.getClass().getSimpleName()); @Override public void saveFruitInfo(Fruit fruit) { log.info("[FruitMemoryRepository] - saveFruitInfo"); fruits.add(fruit); System.out.println(fruits); } @Override public void updateFruitInfo(long id) { log.info("[FruitMemoryRepository] - updateFruitInfo"); for (int i = 0; i < fruits.size(); i++) { Fruit fruit = fruits.get(i); if (fruit.getId() == id) { // Assuming Fruit class has an appropriate constructor to handle this case Fruit updatedFruit = new Fruit(fruit.getId(), fruit.getName(), fruit.getWarehousingDate(), fruit.getPrice(), true); fruits.set(i, updatedFruit); break; } } System.out.println(fruits); } @Override public GetFruitResponseDto getFruitInfo(String name) { log.info("[FruitMemoryRepository] - getFruitInfo"); List<Fruit> filteredFruits = fruits.stream() .filter(fruit -> fruit.getName().equals(name)) .toList(); long salesAmount = filteredFruits.stream() .filter(Fruit::isSold) .mapToLong(Fruit::getPrice) .sum(); long notSalesAmount = filteredFruits.stream() .filter(fruit -> !fruit.isSold()) .mapToLong(Fruit::getPrice) .sum(); System.out.println(fruits); return new GetFruitResponseDto(salesAmount, notSalesAmount); } @Override public boolean isNotExistsFruitInfo(long id) { return fruits.stream().noneMatch(fruit -> fruit.getId() == id); } }step4. FruitMysqlRepository 수정package me.sungbin.repository.fruit; import me.sungbin.dto.fruit.response.GetFruitResponseDto; import me.sungbin.entity.fruit.Fruit; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.stereotype.Repository; import java.util.HashMap; import java.util.Map; import java.util.Objects; /** * @author : rovert * @packageName : me.sungbin.repository.fruit * @fileName : FruitRepository * @date : 2/25/24 * @description : * =========================================================== * DATE AUTHOR NOTE * ----------------------------------------------------------- * 2/25/24 rovert 최초 생성 */ @Repository public class FruitMysqlRepository implements FruitRepository { private final JdbcTemplate jdbcTemplate; private final Logger log = LoggerFactory.getLogger(this.getClass().getSimpleName()); public FruitMysqlRepository(JdbcTemplate jdbcTemplate) { this.jdbcTemplate = jdbcTemplate; } @Override public void saveFruitInfo(Fruit fruit) { log.info("[FruitMysqlRepository] - saveFruitInfo"); String sql = "INSERT INTO fruit (name, warehousingDate, price) VALUES (?, ?, ?)"; this.jdbcTemplate.update(sql, fruit.getName(), fruit.getWarehousingDate(), fruit.getPrice()); } @Override public void updateFruitInfo(long id) { log.info("[FruitMysqlRepository] - updateFruitInfo"); String sql = "UPDATE fruit SET is_sold = 1 WHERE id = ?"; jdbcTemplate.update(sql, id); } @Override public GetFruitResponseDto getFruitInfo(String name) { log.info("[FruitMysqlRepository] - getFruitInfo"); String sql = "SELECT is_sold, SUM(price) AS total FROM fruit WHERE name = ? GROUP BY is_sold"; Map<Boolean, Long> aggregatedData = jdbcTemplate.query(sql, new Object[]{name}, rs -> { HashMap<Boolean, Long> map = new HashMap<>(); while (rs.next()) { map.put(rs.getBoolean("is_sold"), rs.getLong("total")); } return map; }); long salesAmount = Objects.requireNonNull(aggregatedData).getOrDefault(true, 0L); long notSalesAmount = Objects.requireNonNull(aggregatedData).getOrDefault(false, 0L); return new GetFruitResponseDto(salesAmount, notSalesAmount); } @Override public boolean isNotExistsFruitInfo(long id) { String readSQL = "SELECT * FROM fruit WHERE id = ?"; return jdbcTemplate.query(readSQL, (rs, rowNum) -> 0, id).isEmpty(); } }step5. FruitService 수정package me.sungbin.service.fruit; import me.sungbin.dto.fruit.request.SaveFruitInfoRequestDto; import me.sungbin.dto.fruit.request.UpdateFruitRequestDto; import me.sungbin.dto.fruit.response.GetFruitResponseDto; import me.sungbin.entity.fruit.Fruit; import me.sungbin.repository.fruit.FruitMysqlRepository; import me.sungbin.repository.fruit.FruitRepository; import org.springframework.stereotype.Service; /** * @author : rovert * @packageName : me.sungbin.service.fruit * @fileName : FruitService * @date : 2/25/24 * @description : * =========================================================== * DATE AUTHOR NOTE * ----------------------------------------------------------- * 2/25/24 rovert 최초 생성 */ @Service public class FruitService { private final FruitRepository fruitRepository; public FruitService(FruitRepository fruitRepository) { this.fruitRepository = fruitRepository; } public void saveFruitInfo(SaveFruitInfoRequestDto requestDto) { Fruit fruit = requestDto.toEntity(); this.fruitRepository.saveFruitInfo(fruit); } public void updateFruitInfo(UpdateFruitRequestDto requestDto) { validate(requestDto.getId()); this.fruitRepository.updateFruitInfo(requestDto.getId()); } public GetFruitResponseDto getFruitInfo(String name) { GetFruitResponseDto fruitData = this.fruitRepository.getFruitInfo(name); validateGetFruitAmount(fruitData.getSalesAmount(), fruitData.getNotSalesAmount()); return fruitData; } private void validate(long id) { if (this.fruitRepository.isNotExistsFruitInfo(id)) { throw new IllegalArgumentException("존재하는 과일정보가 없습니다."); } } private void validateGetFruitAmount(long salesAmount, long notSalesAmount) { if (salesAmount == 0L && notSalesAmount == 0L) { throw new IllegalArgumentException("존재하는 과일이 없습니다."); } } }step6. postman 테스트 현재 @Primary 어노테이션을 FruitMemoryRepository로 붙여두고 테스트를 해보았다.생성 (메모리) 수정 (메모리)조회 (메모리)이제 FruitMysqlRepository로 이용해보자! FruitMemoryRepository의 @Primary 어노테이션을 지워주고 FruitMysqlRepository에 붙여주자!package me.sungbin.repository.fruit; import me.sungbin.dto.fruit.response.GetFruitResponseDto; import me.sungbin.entity.fruit.Fruit; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.context.annotation.Primary; import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.stereotype.Repository; import java.util.HashMap; import java.util.Map; import java.util.Objects; /** * @author : rovert * @packageName : me.sungbin.repository.fruit * @fileName : FruitRepository * @date : 2/25/24 * @description : * =========================================================== * DATE AUTHOR NOTE * ----------------------------------------------------------- * 2/25/24 rovert 최초 생성 */ @Primary @Repository public class FruitMysqlRepository implements FruitRepository { private final JdbcTemplate jdbcTemplate; private final Logger log = LoggerFactory.getLogger(this.getClass().getSimpleName()); public FruitMysqlRepository(JdbcTemplate jdbcTemplate) { this.jdbcTemplate = jdbcTemplate; } @Override public void saveFruitInfo(Fruit fruit) { log.info("[FruitMysqlRepository] - saveFruitInfo"); String sql = "INSERT INTO fruit (name, warehousingDate, price) VALUES (?, ?, ?)"; this.jdbcTemplate.update(sql, fruit.getName(), fruit.getWarehousingDate(), fruit.getPrice()); } @Override public void updateFruitInfo(long id) { log.info("[FruitMysqlRepository] - updateFruitInfo"); String sql = "UPDATE fruit SET is_sold = 1 WHERE id = ?"; jdbcTemplate.update(sql, id); } @Override public GetFruitResponseDto getFruitInfo(String name) { log.info("[FruitMysqlRepository] - getFruitInfo"); String sql = "SELECT is_sold, SUM(price) AS total FROM fruit WHERE name = ? GROUP BY is_sold"; Map<Boolean, Long> aggregatedData = jdbcTemplate.query(sql, new Object[]{name}, rs -> { HashMap<Boolean, Long> map = new HashMap<>(); while (rs.next()) { map.put(rs.getBoolean("is_sold"), rs.getLong("total")); } return map; }); long salesAmount = Objects.requireNonNull(aggregatedData).getOrDefault(true, 0L); long notSalesAmount = Objects.requireNonNull(aggregatedData).getOrDefault(false, 0L); return new GetFruitResponseDto(salesAmount, notSalesAmount); } @Override public boolean isNotExistsFruitInfo(long id) { String readSQL = "SELECT * FROM fruit WHERE id = ?"; return jdbcTemplate.query(readSQL, (rs, rowNum) -> 0, id).isEmpty(); } }  생성 (Mysql)수정 (MySQL)조회 (Mysql)회고오늘의 강의 핵심은 의존성 주입과 제어의 역전이었다. 나는 기존에 이런 개념들이 뭔지는 대강 알고는 있었지만 확실히 강의와 이렇게 실습함으로 뭔가 체득이 되었다. 아직 많이 부족한 부분이 있을테니 나 따로 더 연습을 해봐야겠다. 

백엔드인프런워밍업스터디클럽백엔드DIIoC

양성빈

[인프런 워밍업 스터디 클럽] 0기 백엔드 미션 - 클린코드 (Day5)

진도표 5일차와 연결됩니다우리는 <클린 코드>라는 개념을 배웠습니다. <클린 코드>에 대한 감각을 익히기 위해서는 어떤 코드가 좋은 코드이고, 어떤 코드가 좋지 않은 코드인지 이론적인 배경을 학습하는 것도 중요할 뿐 아니라, 다양한 코드를 읽어 보며 어떤 부분이 읽기 쉬웠는지, 어떤 부분이 읽기 어려웠는지, 읽기 어려운 부분은 어떻게 고치면 좋을지 경험해보는 과정이 필요합니다.이번 과제는 제시된 코드를 읽어보며, 코드를 더 좋은 코드로 고쳐나가는 과정입니다. 구글에 “클린 코드” 혹은 “클린 코드 정리”를 키워드로 검색해보면, 이론적인 배경을 충분히 찾아보실 수 있습니다. 🙂 그러한 내용들을 보며 제시된 코드를 더 좋은 코드로 바꿔보세요! (코드를 바꿀 때 왜 바뀐 코드가 더 좋은 코드인지 다른 사람에게 설명하신다고 생각해보시면 더욱 좋습니다.) [제시된 코드]여러 함수로 나누어도 좋습니다! 🙂여러 클래스로 나누어도 좋습니다! 🙂클린코드오늘이 벌써 5일차의 날이 밝았다. 이번에는 클린코드가 무엇인지, 어떤 코드가 좋은 코드이며, 어떠한 코드가 안 좋은 코드인지 알아보는 시간을 가졌다. 그리고 또한 모든 비즈니스 로직을 가지고 있는 하나의 controller 클래스를 service와 repository 레이어로 분리함으로 '단일책임의 원칙'을 지킬 수 있었다. 하지만 아직 더 궁금하고 공부하고 싶어서, 2개의 유튜브 영상을 시청하였다. 하나의 영상은 코치님이 올리신 영상이고 하나는 토스에서 올린 영상이였는데 먼저 시청을 해보기로 하였다. 영상 url은 아래 참고에 달아두기로 하겠다. 과제 전, 영상 학습 코치님 영상이 영상의 핵심은 왜 엔티티에서 setter 를 지양해야 하는지에 대한 영상이었습니다. 먼저 결론부터 이야기 해보면 setter는 지양하자라는 말이다. 그 이유는 아래와 같다. 📚Setter 지양 이유1. 변경의도가 파악하기 어렵다: 어느 엔티티에 setter가 있을 때 이 setter를 메서드에 묶어서 사용하는것은 함수의 이름으로도 확인이 가능하며, 이 안의 setter들이 함께 처리된다. 또한 코드가 몇 천줄 이상이 되었고 여기서 추가 요구사항이 있을 때 코드를 추적해서 변경해야 하는데 메서드로 묶으면 한 곳만 수정을 해주면 되겠지만 setter를 직접 해주면 어마 무시한 코드의 수정이 일어날 것이다. 즉, 메서드로 묶었을 때 코드의 응집성이 좋아진다.2. 객체 일관성 유지를 할 수 없다.3. 1번과 연관되지만 메서드로 묶지 않고 setter를 직접 사용하면 생산성 차이가 발생한다.위의 내용은 나에게 와 닿는 부분에 대해서 이야기를 했고 자세한 부분을 원하면 영상에 직접 들어가서 확인 바란다. 토스 영상 (feat. 진유림님)토스 SLASH21에서 진유림님이 발표한 영상을 참고해보았다. 이 영상에서는 지뢰코드를 예시로 들고 있다.🙋🏻 지뢰코드란?회사에서 '이거 건들지 않는게 좋을꺼에요.'의 코드들이 있을 것이다. 이런 코드들은 흐름 파악이 어렵고 도메인 맥락 표현이 안되어 있으며 동료에게 물어봐야 알 수 있는 코드를 뜻한다. 이 코드는 개발할 때 병목현상이 발생하고 유지보수할 때 많은 시간이 들고 심하면 기능추가도 되지 않을 뿐더러 성능도 떨어질 수 있다.그래서 클린코드를 도입하여 이런 코드의 유지보수 시간을 단축(코드 파악 단축, 디버깅 시간 단축, 코드리뷰 단축)할 수 있다. 안일한 코드 함정지뢰코드는 하나의 목적인 코드가 흩어져서 확인할려면 스크롤을 왔다갔다 해야하는 코드를 뜻한다. 우리 회사의 몇개의 코드들이 생각나는 부분이였다. 이런 이유가 된 것은 하나의 함수에 여러가지 일을 하기 때문이다. 그래서 세부구현을 모두 읽어야 함수의 역할을 알게 된다. 그래서 단일책임원칙등 이런 클린코드 개념들이 나왔다. 하지만 이 코드는 처음부터 지저분한 코드들은 아니었을 것이다. 즉, 지뢰코드도 그 당시에는 좋은 코드였을 것이다. 그러다가 이런저런 기능들을 무작정 추가가 되버리고 지뢰코드로 변화된 것일 것이다. 📚 결론클린코드는 단순히 짧은 코드가 아니다. 원하는 로직을 빠르게 찾을 수 있는 코드를 의미한다.또한 선언적 프로그래밍으로 가되, 명령형 프로그래밍 기법을 적절히 사용한다.핵심 기능만 보이게 하고 일부 세부기능은 일단 숨기자.블로그 글클린코드에 대해 구글링을 한 결과, 여러 블로그들이 나왔다. 그 중에, 어느 블로그에 정리가 잘 되어 있어서 일부만 발췌해서 정리해보았다. 자세한 글은 직접 가셔서 읽어보는 것을 추천한다. 📚클린코드 정리1. 객체의 생성에도 유의미한 이름을 사용하라2. 함수는 하나의 역할만 해야한다.3. 명령과 조회를 분리하라(Command와 Query의 분리)4. 오류코드 보다는 예외를 활용하자5. 여러 예외가 발생하는 경우 Wrapper 클래스로 감싸자6. 테스트 코드의 작성7. 변경하기 쉬운 클래스 + 클래스 응집도8. 디미터 법칙 🙋🏻 디미터 법칙: 디미터의 법칙은 어떤 모듈이 호출하는 객체의 속사정을 몰라야 한다는 것이다. 그렇기에 객체는 자료를 숨기고 함수를 공개해야 한다. 만약 자료를 그대로 노출하면 내부 구조가 드러나 결합도가 높아지게 된다.과제그럼 과제에서 제공한 코드를 클린코드의 원칙을 최대한 지키며 리팩토링 해보는 과정을 보여주겠다. package me.sungbin; package me.sungbin; import java.util.Scanner; public class Main { public static void main(String[] args) throws Exception { System.out.print("숫자를 입력하세요 : "); Scanner scanner = new Scanner(System.in); int a = scanner.nextInt(); int r1 = 0, r2 = 0, r3 = 0, r4 = 0, r5 = 0, r6 = 0; for (int i = 0; i < a; i++) { double b = Math.random() * 6; if (b >= 0 && b < 1) { r1++; } else if (b >= 1 && b < 2) { r2++; } else if (b >= 2 && b < 3) { r3++; } else if (b >= 3 && b < 4) { r4++; } else if (b >= 4 && b < 5) { r5++; } else if (b >= 5 && b < 6) { r6++; } } System.out.printf("1은 %d번 나왔습니다.\n", r1); System.out.printf("2은 %d번 나왔습니다.\n", r2); System.out.printf("3은 %d번 나왔습니다.\n", r3); System.out.printf("4은 %d번 나왔습니다.\n", r4); System.out.printf("5은 %d번 나왔습니다.\n", r5); System.out.printf("6은 %d번 나왔습니다.\n", r6); } } Step0. 코드 분석일단 먼저 눈에 고쳐야 할 부분이 보이는데 바로 아래와 같다. 1. 변수명 개선: r1, r2, r3... 이런 부분과 단순 알파벳 하나로 되어 있는 부분을 고쳐야 할 것 같다.변수를 이렇게 작성하여 코딩하면 위의 코드가 나중에 몇 천줄이 되고 유지보수를 할 때 이 변수는 대체 어떤 변수인지 파악이 어렵기 때문이다. 그래서 유의미한 이름으로 작성을 해보는 것이 좋을 것 같다.2. 매직 넘버 제거: 주사위 면 값을 상수로 정의한다. 이유는 나중에 이런 매직넘버가 여러 코드에 흩어져 있다고 가정하자. 그런데 어느날 주사위가 육면체가 아닌 36면체로 변경해야한다는 것이다. 그러면 이 매직넘버를 하나하나 찾아서 바꿔주야 하지만 상수로만 정의하면 상수 값만 바꾸면 되기 때문이다.3. 중복 제거: 주사위를 굴리는 로직과 출력 부분을 함수로 분리. 이것은 개발자면 당연하다고 느끼는 부분일 것이다. 현재 출력하는 부분과 주사위 굴리는 조건문과 증가 연산자 부분이 뭔가 중복되는 것 같다. 그래서 이 부분도 함수로 변경할 예정이다.4. 책임 분리: 주사위 굴리기와 결과 출력을 분리. 이는 위에서 설명한 클린코드의 단일책임의 원칙으로 분리하는 것이 유지보수성에 좋을 것이다. 왜 좋은지는 두 영상과 블로그를 참조하자.5. 배열사용: 주사위 눈금 횟수를 배열로 사용하는 것은 코드 가독성을 높이고 중복을 줄이며 유지 보수를 쉽게 만든다. 그러면 정리해보겠다. 클린 코드 원칙에 따라, 현재 코드는 여러 가지 방법으로 개선될 수 있다. 먼저, 'r1', 'r2', 'r3', 'r4', 'r5', 'r6'와 같은 변수명은 의미를 명확하게 전달하지 않는다. 더 명확한 변수명을 사용할 수 있습니다. 또한, 각각의 조건문은 매직 넘버(magic number)를 사용하고 있는데, 이는 읽는 사람이 코드의 의도를 바로 이해하기 어렵게 만듭니다. 상수를 사용하여 이러한 숫자에 이름을 붙여 가독성을 높일 수 있습니다.다음으로, 주사위의 각 면을 계산하는 부분은 반복되는 구조를 가지고 있으므로 이를 함수로 분리하여 중복을 줄이고 코드의 재사용성을 높일 수 있습니다. 또한, 현재 코드는 주사위를 굴린 후 결과를 출력하는 두 가지 작업을 동시에 수행하고 있습니다. 이를 분리하여 한 함수는 주사위를 굴리는 로직을, 다른 함수는 결과를 출력하는 로직을 담당하게 하는 것이 좋습니다.마지막으로, 주사위의 각 면이 나온 횟수를 저장하는 배열을 사용하면 변수를 줄일 수 있고, for 루프 안에서의 조건문을 간소화할 수 있습니다. 이렇게 코드를 개선하면 유지 관리가 용이해지고 다른 개발자가 이해하기 쉬운 코드가 됩니다. 🙋🏻 매직넘버란? 의미 있는 이름의 상수로 대체될 수 있는 숫자 Step1. 리팩토링 첫 단계위의 부분을 적용한 코드는 아래와 같다.package me.sungbin.step1; import java.util.Scanner; /** * @author : rovert * @packageName : me.sungbin.step1 * @fileName : Main * @date : 2/23/24 * @description : * =========================================================== * DATE AUTHOR NOTE * ----------------------------------------------------------- * 2/23/24 rovert 최초 생성 */ public class Main { private static final int SIDES_OF_DICE = 6; public static void main(String[] args) { System.out.print("숫자를 입력하세요 : "); Scanner scanner = new Scanner(System.in); int rolls = scanner.nextInt(); scanner.close(); int[] counts = rollDice(rolls); printResults(counts); } /** * 주사위를 굴리는 로직이 들어가는 메서드 * @param numberOfRolls * @return */ private static int[] rollDice(int numberOfRolls) { int[] counts = new int[SIDES_OF_DICE]; for (int i = 0; i < numberOfRolls; i++) { int result = (int) (Math.random() * SIDES_OF_DICE); counts[result]++; } return counts; } /** * 출력기능을 담당하는 메서드 * @param counts */ private static void printResults(int[] counts) { for (int i = 0; i < counts.length; i++) { System.out.printf("%d은 %d번 나왔습니다.\n", i + 1, counts[i]); } } }SIDES_OF_DIE 상수를 통해 주사위 면의 수를 명확하게 합니다.rollDice 함수는 주사위를 굴리는 작업만 수행하고, 그 결과를 int[] 배열에 저장합니다.printResults 함수는 주사위의 각 면이 나온 횟수를 출력합니다.int[] counts 배열은 0부터 5까지 각 면이 나온 횟수를 저장합니다. 배열의 인덱스는 주사위 면의 숫자에 해당합니다.Scanner 객체를 종료하는 부분이 누락되어 추가하였다.불필요한 Exception 던지는 부분을 제거한다.코드를 구조화함으로 각 부분의 책임이 명확해지고, 재사용성과 유지보수성이 향상된다. Step2. 리팩토링 2단계 (feat. 객체지향)위와 같이 static method로 분리하면 객체지향적이지 않은 것 같다는 생각을 했고, 하나의 파일에 모든 로직이 있게 되는 셈인 것 같았다. 그래서 객체지향적으로 작성을 위해 여러 파일들을 만들어 분리하기로 하였다. 적용한 코드는 아래와 같다. Dice.javapackage me.sungbin.step2; /** * @author : rovert * @packageName : me.sungbin.step2 * @fileName : Dice * @date : 2/23/24 * @description : * =========================================================== * DATE AUTHOR NOTE * ----------------------------------------------------------- * 2/23/24 rovert 최초 생성 */ public class Dice { private final int sides; public Dice(int sides) { this.sides = sides; } /** * 주사위 면의 숫자 구하기 * @return */ public int roll() { return (int) (Math.random() * sides); } public int getSides() { return sides; } } DiceRollHandler.javapackage me.sungbin.step2; /** * @author : rovert * @packageName : me.sungbin.step2 * @fileName : DiceRollHandler * @date : 2/23/24 * @description : * =========================================================== * DATE AUTHOR NOTE * ----------------------------------------------------------- * 2/23/24 rovert 최초 생성 */ public class DiceRollHandler { private final Dice dice; private final int[] counts; public DiceRollHandler(Dice dice, int numberOfRolls) { this.dice = dice; this.counts = new int[dice.getSides()]; for (int i = 0; i < numberOfRolls; i++) { this.counts[dice.roll()]++; } } public void rollAll() { for (int i = 0; i < counts.length; i++) { counts[dice.roll()]++; } } public void printResults() { for (int i = 0; i < counts.length; i++) { System.out.printf("%d은 %d번 나왔습니다.\n", i + 1, counts[i]); } } } DiceGame.javapackage me.sungbin.step2; import java.util.Scanner; /** * @author : rovert * @packageName : me.sungbin.step2 * @fileName : Main * @date : 2/23/24 * @description : * =========================================================== * DATE AUTHOR NOTE * ----------------------------------------------------------- * 2/23/24 rovert 최초 생성 */ public class DiceGame { private static final int SIDES_OF_DICE = 6; public static void main(String[] args) { System.out.print("숫자를 입력하세요 : "); Scanner scanner = new Scanner(System.in); int rolls = scanner.nextInt(); scanner.close(); Dice dice = new Dice(SIDES_OF_DICE); // 정해진 면의 수를 가진 주사위 객체 생성 DiceRollHandler handler = new DiceRollHandler(dice, rolls); // 주사위를 던지는 이벤트에 대한 핸들러 객체 생성 handler.rollAll(); // 주사위 던지기 이벤트 비즈니스 로직 handler.printResults(); // 출력 } }이 구조에서는 Dice 클래스가 주사위 자체를 나타내고, DiceRollHandler 클래스가 주사위를 굴리고 결과를 추적하는 역할을 한다. DiceGame 클래스의 main 메서드는 게임을 시작하는 진입점이다.Dice 클래스는 주사위의 기능(굴리기)과 속성(면의 수)을 캡슐화한다.DiceRollHandler 클래스는 주사위를 여러 번 굴리는 로직을 책임지고, 각 면이 나온 횟수를 저장합니다.주사위 게임의 진행과 결과 출력은 DiceRollHandler 클래스에서 담당한다.Main클래스로 되어있는 부분을 이름을 변경하였다. 일단 Main이라는 클래스 명은 이 프로젝트가 무엇인지 알기 어렵기 하기 때문이다. 이렇게 클래스를 분리하면 코드의 각 부분이 명확한 책임을 가지게 되어 유지보수가 용이하고, 다른 주사위 게임에 Dice 클래스나 DiceRollHandler 클래스를 재사용할 수 있는 가능성이 생깁니다.Step3. 리팩토링 3단계다음으로 코드를 더 클린하게 만들기 위해 여러 가지 최적화와 리팩토링을 수행해보겠다. 1. 메소드 분리와 책임의 명확화: 각 메서드가 하나의 작업만 수행하도록 만들어 코드의 가독성을 높인다.2. 입출력 분리: 사용자 입력과 출력을 처리하는 별도의 클래스를 만들어 로직과 UI를 분리한다.3. 예외 처리 개선: Scanner를 사용할 때 발생할 수 있는 예외를 적절히 처리합니다. Dice.javapackage me.sungbin.step3; /** * @author : rovert * @packageName : me.sungbin.step3 * @fileName : Dice * @date : 2/23/24 * @description : * =========================================================== * DATE AUTHOR NOTE * ----------------------------------------------------------- * 2/23/24 rovert 최초 생성 */ public class Dice { private final int sides; public Dice(int sides) { this.sides = sides; } public int roll() { return (int) (Math.random() * sides); } public int getSides() { return sides; } }DiceRollHandler.javapackage me.sungbin.step3; /** * @author : rovert * @packageName : me.sungbin.step3 * @fileName : DiceRollHandler * @date : 2/23/24 * @description : * =========================================================== * DATE AUTHOR NOTE * ----------------------------------------------------------- * 2/23/24 rovert 최초 생성 */ public class DiceRollHandler { private final Dice dice; private final int[] counts; public DiceRollHandler(Dice dice) { this.dice = dice; this.counts = new int[dice.getSides()]; } public void rollAll(int numberOfRolls) { for (int i = 0; i < numberOfRolls; i++) { counts[dice.roll()]++; } } public int[] getCounts() { return counts; } }InputHandler.javapackage me.sungbin.step3; import java.util.Scanner; /** * @author : rovert * @packageName : me.sungbin.step3 * @fileName : InputHandler * @date : 2/23/24 * @description : * =========================================================== * DATE AUTHOR NOTE * ----------------------------------------------------------- * 2/23/24 rovert 최초 생성 */ public class InputHandler { public static int getNumberOfRolls() { System.out.print("숫자를 입력하세요 : "); try (Scanner scanner = new Scanner(System.in)) { return scanner.nextInt(); } catch (Exception e) { throw new IllegalArgumentException("유효하지 않은 입력입니다."); } } }OutputHandler.javapackage me.sungbin.step3; /** * @author : rovert * @packageName : me.sungbin.step3 * @fileName : OutputHandler * @date : 2/23/24 * @description : * =========================================================== * DATE AUTHOR NOTE * ----------------------------------------------------------- * 2/23/24 rovert 최초 생성 */ public class OutputHandler { public static void printResults(int[] counts) { for (int i = 0; i < counts.length; i++) { System.out.printf("%d은 %d번 나왔습니다.\n", i + 1, counts[i]); } } }DiceGame.javapackage me.sungbin.step3; /** * @author : rovert * @packageName : me.sungbin.step3 * @fileName : DiceGame * @date : 2/23/24 * @description : * =========================================================== * DATE AUTHOR NOTE * ----------------------------------------------------------- * 2/23/24 rovert 최초 생성 */ public class DiceGame { private static final int SIDES_OF_DIE = 6; public static void main(String[] args) { int rolls = InputHandler.getNumberOfRolls(); Dice dice = new Dice(SIDES_OF_DIE); DiceRollHandler roller = new DiceRollHandler(dice); roller.rollAll(rolls); OutputHandler.printResults(roller.getCounts()); } }이러한 리팩토링을 통해 코드의 가독성과 관리 가능성이 향상되었다. 입력과 출력을 담당하는 InputHandler와 OutputHandler 클래스는 각각의 책임을 분명히 하며, DiceGame은 게임의 로직만을 담당하게 된다. 예외 처리를 통해 프로그램의 예외를 처리할 수 있게 하며, 견고해진 프로그램이 될 것 같다. Step4. 한걸음 더! 적용 (feat. 디자인 패턴)코치님이 한걸음 더에서 주사위의 숫자범위가 달라지더라도 코드를 적게 수정할 수 있도록 해보시라고 하셨다. 그래서 한번 고민을 해보았다. 지금도 주사위 최대 수를 상수로 빼두었기에 충분하다고 생각은 들었다. 또한 디자인패턴을 적용을 해봄으로 더 견고한 코드를 작성해보았다. DiceRollStrategy.javapackage me.sungbin.step4; /** * @author : rovert * @packageName : me.sungbin.step4 * @fileName : DiceRollStrategy * @date : 2/23/24 * @description : * =========================================================== * DATE AUTHOR NOTE * ----------------------------------------------------------- * 2/23/24 rovert 최초 생성 */ public interface DiceRollStrategy { int roll(); int getSides(); // 면 수를 반환하는 메소드 추가 } Dice.javapackage me.sungbin.step4; /** * @author : rovert * @packageName : me.sungbin.step4 * @fileName : Dice * @date : 2/23/24 * @description : * =========================================================== * DATE AUTHOR NOTE * ----------------------------------------------------------- * 2/23/24 rovert 최초 생성 */ public class Dice { private final DiceRollStrategy strategy; public Dice(DiceRollStrategy strategy) { this.strategy = strategy; } public int roll() { return strategy.roll(); } // 주사위의 면 수를 반환하는 메소드 추가 public int getSides() { return this.strategy.getSides(); } }  StandardDiceRollStrategy.javapackage me.sungbin.step4; /** * @author : rovert * @packageName : me.sungbin.step4 * @fileName : StandardDiceRollStrategy * @date : 2/23/24 * @description : * =========================================================== * DATE AUTHOR NOTE * ----------------------------------------------------------- * 2/23/24 rovert 최초 생성 */ public class StandardDiceRollStrategy implements DiceRollStrategy { private final int sides; public StandardDiceRollStrategy(int sides) { this.sides = sides; } @Override public int roll() { return (int) (Math.random() * sides) + 1; } @Override public int getSides() { return this.sides; } } InputHandler.javapackage me.sungbin.step4; import java.util.Scanner; /** * @author : rovert * @packageName : me.sungbin.step3 * @fileName : InputHandler * @date : 2/23/24 * @description : * =========================================================== * DATE AUTHOR NOTE * ----------------------------------------------------------- * 2/23/24 rovert 최초 생성 */ public class InputHandler { public static int getNumberOfRolls() { System.out.print("숫자를 입력하세요 : "); try (Scanner scanner = new Scanner(System.in)) { return scanner.nextInt(); } catch (Exception e) { throw new IllegalArgumentException("유효하지 않은 입력입니다."); } } } OutputHandler.javapackage me.sungbin.step4; /** * @author : rovert * @packageName : me.sungbin.step3 * @fileName : OutputHandler * @date : 2/23/24 * @description : * =========================================================== * DATE AUTHOR NOTE * ----------------------------------------------------------- * 2/23/24 rovert 최초 생성 */ public class OutputHandler { public static void printResults(int[] counts) { for (int i = 0; i < counts.length; i++) { System.out.printf("%d은 %d번 나왔습니다.\n", i + 1, counts[i]); } } } DiceRollHandler.javapackage me.sungbin.step4; /** * @author : rovert * @packageName : me.sungbin.step4 * @fileName : DiceRollHandler * @date : 2/23/24 * @description : * =========================================================== * DATE AUTHOR NOTE * ----------------------------------------------------------- * 2/23/24 rovert 최초 생성 */ public class DiceRollHandler { private final Dice dice; private final int[] counts; public DiceRollHandler(Dice dice) { this.dice = dice; this.counts = new int[dice.getSides()]; // 주사위 면 수에 맞는 크기로 배열 초기화 } public void rollAll(int numberOfRolls) { for (int i = 0; i < numberOfRolls; i++) { int result = dice.roll() - 1; // 0부터 시작하는 배열 인덱스에 맞추기 위해 1을 뺌 counts[result]++; } } public int[] getCounts() { return counts; } } DiceGame.javapackage me.sungbin.step4; /** * @author : rovert * @packageName : me.sungbin.step4 * @fileName : DiceGame * @date : 2/23/24 * @description : * =========================================================== * DATE AUTHOR NOTE * ----------------------------------------------------------- * 2/23/24 rovert 최초 생성 */ public class DiceGame { private static final int SIDES_OF_DIE = 6; // 기본값, 필요에 따라 변경 가능 public static void main(String[] args) { int rolls = InputHandler.getNumberOfRolls(); DiceRollStrategy strategy = new StandardDiceRollStrategy(SIDES_OF_DIE); // 전략 선택 Dice dice = new Dice(strategy); DiceRollHandler roller = new DiceRollHandler(dice); roller.rollAll(rolls); OutputHandler.printResults(roller.getCounts()); } } Step5. 테스트 코드이제 참고한 블로그에 따르면 테스트코드도 반드시 필요하다고 한다. 그럼 마지막으로 테스트 코드를 작성하고 마무리하자. 테스트 코드는 주요 로직들을 테스트하였다. package me.sungbin.step4; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import static org.junit.jupiter.api.Assertions.*; /** * @author : rovert * @packageName : me.sungbin.step4 * @fileName : DiceRollHandlerTest * @date : 2/23/24 * @description : * =========================================================== * DATE AUTHOR NOTE * ----------------------------------------------------------- * 2/23/24 rovert 최초 생성 */ class DiceRollHandlerTest { @Test @DisplayName("Dice 클래스의 주사위 롤링 기능이 1부터 6 사이의 값을 정확히 생성하는지 검증") public void testDiceRoll() { DiceRollStrategy strategy = new StandardDiceRollStrategy(6); Dice dice = new Dice(strategy); int result = dice.roll(); assertTrue(result >= 1 && result <= 6); } @Test @DisplayName("StandardDiceRollStrategy 클래스를 통해 생성된 주사위 값이 1부터 6 사이인지 확인") public void testStandardDiceRollStrategy() { StandardDiceRollStrategy strategy = new StandardDiceRollStrategy(6); int result = strategy.roll(); assertTrue(result >= 1 && result <= 6); } }회고이렇게 클린코드로 코드를 리팩토링 해보니, 많은 것을 찾아보고 나 자신이 발전한 느낌이 들었다. 이런 부분을 개인적으로도 자주 연습해봐야겠다. 📚 참고https://www.youtube.com/watch?v=5P7OZceQ69Qhttps://www.youtube.com/watch?v=edWbHp_k_9Yhttps://mangkyu.tistory.com/132

백엔드인프런워밍업스터디클럽백엔드클린코드리팩토링

[인프런 워밍업 클럽 BE]1-3일차 강의 정리

이 문서는자바와 스프링 부트로 생애 최초 서버 만들기, 누구나 쉽게 개발부터 배포까지! [서버 개발 올인원 패키지] 강의 - 인프런 (inflearn.com)강의와 연관 스터디 내용을 정리하였습니다. 1-3일차 강의 정리 시작하며작년 초, 학원에서 6개월 부트캠프(말이 부트캠프지 사실 매일 5시간의 정규 수업만 있었습니다...) 과정을 밟았지만 솔직히 말해서 기초 중에 기초만 겨우 깐 기분이었습니다. 그마저도 배운 스택이 최신 스택과는 거리가 멀었습니다. 그래서 9월부터 다시 인프런에서 스프링 강의를 들으며 처음부터 다시 시작해야겠다, 생각을 하고 공부를 시작했습니다. 하지만 시간이 지나며 점점 막막해지고 정신도 해이해지고 있었는데 이렇게 좋은 기회를 접하게 되었네요! 열심히 하는 분들을 보며 동기부여를 얻을 수 있을 것 같습니다! 사실 강의를 들으며 그 때 그 때 노션에서 정리 중이긴 하지만, 개인 페이지이기도 하고 깔끔하지 못해서 인프런 블로그에 한 번 더 정리하며 내용을 보강해볼 생각입니다. 글 재주가 없어 매일 작성은 어렵겠지만 적어도 3일에 한 번은 정리해 올리는 것을 목표로 삼고 있습니다. :)강의는 최대한 간결하게 이론 위주로 정리하려고 합니다(코드들은 노션 개인 페이지에 비공개로 올리고 있습니다!). 추가적인 내용은 여러 블로그, w3school, mozilla, oracle 등의 공식 문서와 간혹 chatGPT를 참고했습니다.2강.1강은 주로 프로젝트 생성에 관한 이야기이기에 넘어간다. 기본적으로 스프링부트 프로젝트를 생성하면src>main>java>com>{group name}>{…}>{…}Application 이란 파일이 있을 것이다.클래스 바로 윗줄에 @SpringBootApplication이란 어노테이션이 자동으로 붙어있는 걸 확인할 수 있다.@SpringBootApplication - 스프링을 실행할 때 필요한 다양한 작업과 설정을 자동으로 처리. 세 가지 기능을 수행한다.@Configuration - 스프링 애플리케이션의 구성 소스임을 명시@EnableAutoConfiguration - 스프링 부트 자동 구성 기능 활성화@ComponentScan - @Component 관련 어노테이션을 찾아 자동 빈 스캔 및 주입서버기능을 제공하는 '것'클라이언트로부터 요청을 받아 결과를 반환(응답)서버의 역할DB 관리비즈니스 로직 처리인증API 제공보안etc 3강.네트워크 강의에서 이세계의 택배시스템에 비유한 것을 정리한 표이다.IP(Internet Protocol) - 컴퓨터 네트워크에서 장치 식별IPv4 - 2011년에 모두 소진되어서 이제 할당되지 않는다. xxx.xxx.xxx.xxx 형IPv6 - 오늘날에 대중화. 128비트로 구성.포트(port) - 컴퓨터 내에서 프로세스 식별.DNS(Domain Name System) - IP주소를 외우는 것은 너무 불편하고 복잡해서 나온 시스템. naver.com, google.com 등...  IP를 아파트 한 동, 포트를 각각의 호수로 생각해보아도 될 것 같다. 4강.HTTP네트워크가 택배 시스템이라면 HTTP는 운송장 양식과 비유할 수 있다.운송장에 누구에게, 무엇을, 어디에 보내는지 적혀있는 것을 한 번쯤은 보았을 것이다. 이제 HTTP(HyperText Transfer Protocol)를 보자.GET /item?option1=***&option2=… Host: spring.com:3000요청하는 행위 - Get, Post, Put, Delete요청받은 자원(path)과 세부 조건요청을 받는 컴퓨터와 프로그램 정보Query&Body위에 있는 ?option1=***&option2=… 이 방식을 쿼리라고 한다.option을 key, '=' 뒤의 ***이 value가 된다.이 방식은 get, delete에서 사용된다.put과 post같은 경우는 body 형식을 사용하는데 이는 더 뒤에서 확인해보자.API(Application Programming Interface)=>정해진 약속을 하여 특정 기능을 수행하는 것GET /item?option1=***&option2=… Host: spring.com:3000첫 번째 줄 - 메소드 path 쿼리두 번째 줄 - 헤더(복수 줄 가능)다음 한 줄은 비운 채로 띈다.body(복수 줄 가능)URL(Uniform Resource Locator)https://www.notion.so/1-0fdc9fb58da247f4a1ae44166dd7bf8a현재 페이지의 url 주소를 보자.http - 약속된 프로토콜:// - 구분 기호notion.so - 도메인 이름그 다음은 path 경로가 나온다.클라이언트 - 서버 구조클라이언트 - 요청한 컴퓨터서버 - 요청 받고 응답하는 컴퓨터응답 구조 역시 요청과 동일하다.첫 번째 줄 - 상태 코드두 번째 줄 - 헤더(복수 줄 가능)다음 한 줄은 비운 채로 띈다.바디(복수 줄 가능)상태 코드서버 컴퓨터가 응답할 때 어떤 숫자를 보내는데, 이를 상태 코드라고 한다.200 - ok300 - moved permanently(요청한 리소스가 헤더에서 제공한 URL로 영구적 이동)404 - not found500 - internal server error이 외에도 다양한 상태코드가 있다.5강.API 개발 전에 해야 하는 것→ API specification(spec) API 개발을 위한 어노테이션@RequestMapping - 요청 방식에 관계없이 url이 일치하는 요청을 처리@RestController - api 개발을 위해 사용하는 어노테이션. 일반적인 MVC 형식(model view controller)의 컨트롤러는 @Controller 를 사용한다.@GetMapping - GET Method 매핑@PostMapping- POST Method 매핑@RequestParam- 요청 파라미터 앞에 적는다. 기본적으로 required=true 이다. int나 String 같은 단순 타입은 생략이 가능-파라미터 이름==변수 이름 => name속성 생략 가능(생략 시 required = false)-필수 파라미터 누락 => 400 에러 / 기본형 누락 => 500 에러6강.Post APIGET에서는 query 형식으로 데이터를 받았지만,POST에서는 데이터를 HTTP Body를 통해 받는다.근데 body에 특별한 체계 없이 개발자가 원하는 대로 넣으면 큰 혼란이 발생할 것이다. 이런 문제를 단번에 해결하는 문법이 있다.→JSON(JavaScript Object Notation) - 객체 표기법“key”: value 형식 (Java Map이 떠오른다!)속성 간 구분은 쉼표로!value는 배열도 들어갈 수 있다. []로 표기JSON 안에 다른 JSON이 들어갈 수도 있다.7-8강.DTO(Data Transfer Object)=데이터 전송 객체(java beans) => 프로세스 간 데이터를 전달하는 객체DB에서 데이터 얻음 -> Service 혹은 Controller로 전송VO(Value Object)=한 개 혹은 그 이상의 속성을 갖는 특정 값을 나타내는 객체Domain(Entity Class)실제 DB 테이블과 매칭되는 클래스DAO(Data Access Object)실제 DB에 접근하는 객체 = 흔히 말하는 repository...-service 계층과 db 사이의 계층-db에 접근 => sql 사용 7-8강의 코드는 생략하고 자연스럽게 나온 DAO와 그 연관 개념에 대해 작성했다. 이전부터 DTO와 domain이 혼란스러웠는데, 개념을 잡고 가는 것이 좋을 것 같다. 아래 블로그에서 domain과 DTO의 분리 이유도 설명하고 있어 유용하다고 생각한다.https://jihoon2723.tistory.com/entry/VO-DAODTO-Entity-Domain-%ED%81%B4%EB%9E%98%EC%8A%A4-%EB%B9%84%EA%B5%90%ED%95%98%EA%B8%B010강.이전 강의까지는 서버를 종료하고 재실행할 때마다 저장한 정보가 전부 날아가는 문제가 존재 -> DB의 존재가 절실!컴퓨터의 핵심 부품우선 이전에 우리가 만든 데이터는 왜 날아갔을까?CPU - 연산 장치RAM - 임시 기억 장치DISK - 장기 기억 장치서버 자체는 DISK에 잠들어 있다.서버 실행 시 코드 정보가 RAM으로 복사된다.API가 실행되면 연산이 수행되며 CPU와 RAM을 반복한다.POST API를 통해 생긴 유저 정보 역시 RAM에 있다!서버가 종료되면 RAM에 있는 모든 정보가 휘발된다.그렇다면 우리가 해야할 일은 우리가 저장한 내용을 DISK에 넣어야 한다! 이를 위해 사용하는 것이 Database(DB)이다.Database데이터를 구조화 시켜 저장RDB관계형 데이터베이스데이터를 표처럼 구조화SQL구조화된 데이터를 조회하는 언어11-13강.11강부터는 본격적으로 sql문을 학습한다.DB 생성create database[db 이름];DB 조회show databases;DB 삭제drop database [db 이름];DB 사용use [db 이름];Table 조회show tables;Table 생성create table [table 이름] ([필드 이름] [타입] [부가 조건], [필드 이름] [타입] [부가 조건], ... primary key([필드 이름]) );DB 조회show databases;필드 TYPEtinyint - 1바이트 정수(byte)smallint - 2바이트 정수 mediumint - 3바이트 정수int - 4바이트 정수(int)bigint - 8바이트(long)double - 8바이트 정수decimal(a,b) - 소수점 아래 자릿수가 b개인 전체 자릿수가 a개인 실수char(n) - n글자가 들어갈 수 있는 문자열varchar(n) - 길이가 n까지 허용되는 문자열text - 사이즈가 큰 문자열blob - 사이즈가 큰 문자열date - 날짜time - 시간datetime - 날짜와 시간timestamp - 형식은 datetime과 동일하나 time_zone에 의존❓varchar(n) vs text vs blob의 정확한 차이가 무엇일까. 추가적인 조사나 디스코드에 질문을 남겨봐야겠다... Table 필드 조회describe fruit; desc fruit;Table 삭제drop table [table 이름]DDL테이블 자체를 생성, 삭제, 변경, 초기화 하는 것을 DDL(Data Definition Language)이라 한다.CRUDCREATE - 넣기READ - 조회UPDATE - 수정DELETE - 삭제데이터 넣기insert into [table 이름]([필드 이름1], [필드 이름2], ...) values (값1, 값2, ...);데이터 전체 조회select * from [db 이름]; select [필드 이름1], [필드 이름2] from [db 이름];조건을 넣은 데이터 조회select * from [db 이름] where [조건];데이터 업데이트update [table 이름] set 필드1=값, 필드2=값,... where [조건];❗ 조건문을 작성하지 않으면 모든 데이터가 바뀌니 항상 주의하자!데이터 삭제delete from [table 이름] where [조건]; ❗ 조건문을 작성하지 않으면 모든 데이터가 삭제되니 항상 주의하자!DML테이블 데이터를 조작하는 sql문을 DML(Data Manipulation Language)이라 한다.후기아직 3일차라, 회고록이라고 부르기엔 영 민망하지만 그래도 나름대로 느낀 점이 많았다.이런 온라인 스터디에 참여해본 것은 처음인데, 저번 주의 자신보다는 열심히 하고 있구나 하고 체감이 된다는 것은 확실히 놀라운 일이었다. 사실 3월 초에 sqld 시험을 접수해놓은 상태라 두 가지를 제대로 병행할 수 있을까 걱정을 많이 했는데 다른 참여자 분들 중에서는 회사 생활 중이신데도 열심히 하시는 분들도 제법 보였다. 그걸 보고 아직 내 마음가짐이 어설프긴 했구나... 하는 약간의 반성을 하였다. 😂 

백엔드스터디

[인프런 워밍업 클럽 스터디] BE 스터디 1기 1주차 발자국

강의 수강일주일 동안 학습했던 내용을 요약해주세요.일주일 간의 학습 내용에 대한 간단한 회고를 작성해 주세요.섹션 0.Java, inteliiJ, Postman, MySQL, git 설치 방법에 대해 배웠습니다.Cmd를 통하여 Mysql에 접속하는 방법으로 데이터베이스, 테이블 조작을 할 수 있다는 것을 알게 되었습니다.MySQL CMD 접속 방법mysql -u [계정] -p [데이터베이스]저는 root 계정과 비밀번호를 설정하여mysql -u root -p 타이핑 후비밀번호 타이핑CMD에서 MySQL을 찾지 못하는 경우에는환경변수에 시스템 변수에 Mysql폴더의 bin폴더까지 주소를 복사하여 넣어주어야합니다. 섹션 0 회고다시 Java, inteliiJ, Postman, MySQL, git 설치 방법에 대해 보게 되니까 예전에 해봤던 기억이 떠오르고 미쳐 몰랐던 inteliiJ에 대해 알아가는 시간이 되었고 Postman을 저번 프로젝트를 할때는 자주 사용하지 않았는데 간편하게 사용할 수 있는 테스트 도구라서 자주 사용할 거 같습니다.  섹션 1.컴파일 : 인간이 이해하기 쉬운 언어를 기계어로 번역하는 과정컴파일러 : 컴파일을 하는 프로그램바이트 코드 : 0 , 1로 이루어진 코드, 컴퓨터가 이해하는 기계어java는 JVM을 통하여 기계어로 OS에 맞게 번역해준다.JVM(Java Virtual Machine) 자바 가상 머신의 약자OS 별로 존재바이너리 코드를 읽고 검증하고 실행 JRE(Java Runtime Environment)JVM + 자바 프로그램 실행에 필요한 라이브러리 파일 등JVM의 실행환경을 구현JDK(Java Development Kit)JRE + 개발을 위한 도구컴파일러, 디버그 도구 등이 포함 섹션 1 회고자바의 작동원리와 SpringBoot에 대한 기본적인 작동 흐름과 CRUD에서 CR에 대해 배우게 되었고, 잘 읽어야 다른 동작에 대해 쉽게 접근할 수 있을거 같아 배운것을 돌아보고 원리에 대해 되집어 보는 시간이 되었습니다. 섹션 2.Mysql에서 테이블만들고, 데이터를 조작하는 방법을 배움sql문을 통하여 intelliJ에서 Update, Delete에 관한 API를 작성섹션 2 회고inteliiJ와 Mysql을 연결하는 작업을 하였습니다. 연결하고 올바르게 데이터가 들어가는게 중요하다 생각이 들었고 sql문이 잘못 작성되어 곤란한 경우가 있었습니다. 찾는 것도 힘들었지만 sql문에 대해 좀 더 정확하게 작성하여 올바르게 전송되게끔 꼼꼼히 작성해보겠습니다.섹션 3. 클린코드에 대해 배우고 기존에 있던 코드를 리펙토링하며 더 분리시키고 깔끔한 코드로 작성하는 방법을 배웠습니다.  섹션 3 회고클린코드에 중요성과 작성하고 있는 코드에서 더 좋은 코드가 될 수 있을 지에 대한 고민이 많이 생긱나는 강의였고, 개발에는 끝없는 고민으로 시작되는거 같습니다.미션미션을 해결하는 과정을 요약해 주세요.미션 1 : https://www.notion.so/1-9ebaa9acab404522894cadf7aada5eb0 -> 구글 검색을 통해서 어노테이션에 대한 미션에 대한 정보들에 대해 보고 실습해보았습니다.미션 2 : https://www.notion.so/2-8e30c53ecab443e5bb3d3a1bcf8abcb4-> 강의를 보고 원하는 Get, Post라던지 관한 정보를 입력하고 어떻게 출력되는지 건드려보게 되었습니다.미션 3 : https://www.notion.so/3-7516e809e6134f0fbc255c3f57d239bb-> 검색을 통하여 람다식에 대해 알게 되었고 잘 활용만 한다면 코드를 단축시키고 클린 코드를 만들어 보게끔 연습을 해보아야겠다는 생각이 들었습니다. 

백엔드백엔드인프런스터디

crispin

[인프런 워밍업 스터디 클럽 1기_FE] 1주차 회고록 정리

0기에 백엔드 스터디에 이어 1기 프론트엔드 스터디를 신청하고, 참여하였다.신청 이유신청하게된 가장 큰 이유는 자바스크립트 랑 JQuery 를 조금씩 사용했었는데, 사실 자바스크립트 를 제대로 공부했던 적이 없고, 이직하는 곳에서는 백엔드와 프론트엔드가 명확하게 나뉘게 되어 이직하기전 프론트엔드 지식을 조금이라도 학습하면 협업을 할때 도움이 많이 될것 같아 신청하게 되었다.자바스크립트 미션아직 1단계 완료 후 2단계를 진행하고 있다. 생각외로 많이 어려워서 진행 속도가 많이 느리다. 리액트 부분으로 넘어가면 훨씬 어려워진다고 했는데 0기때 왜 프로트엔드에서 우수 러너가 적게 나왔는지 새삼 느끼고 있다. 1주차 느낀점하필 이직 시기와 겹치는 바람에(심지어 지역이동..) 거주지를 알아보고, 이직회사에서 사용하는 기술을 다시 한번 살펴보는 등 스터디외에도 할게 너무 많아 정신없이 보내고있다. 우선순위를 잘 정해서 2주차를 진행해야할것 같다.정리현재 서포터로도 참여하고 있을 정도로 애정이 정말 많이 가는 스터디다. 이런저런일로 많이 바쁘고 정신이 없지만 조금이라도 함께하는 러너분들에게 도움이 되기 위해 노력해야겠다.

프론트엔드워밍업클럽스터디프론트엔드1기회고발자국

희희

[인프런 워밍업 클럽 스터디 1기] BE 1주차 회고

첫번째 발자국 인프런 워밍업 클럽 스터디에 참여하여 일주일을 보낸 후 쓰는 첫번째 회고록. Section 1 - 생애 최초 API 만들기JVM자바 가상 머신의 약자os 별로 존재한다.바이너리 코드들을 읽고 검증한다.JRE자바 실행 환경의 약자JRE = JVM + 자바 프로그램 실행에 필요한 라이브러리 파일 등JVM의 실행 환경을 구현JDK자바 개발 도구의 약자JDK = JRE + 개발을 위한 도구(통합 개발 도구)컴파일러, 디버그 도구 등이 포함되어 있다.여러 버전이 있고, 각 버전별로 새로운 기능이 추가되거나 기존 기능이 사라진다.여러 종류가 있고, 기능 자체는 동일하나 성능과 비용에 약간의 차이가 있을 수 있다. 빌드 : 소스코드 파일을 컴퓨터에서 실행할 수 있는 독립 SW 가공물(Artifact, 독립적인 하나의 파일)로 변환시키는 과정실행 : 작성한 코드를 컴파일을 거쳐 작동시켜보는 것(독립 SW 가공물이 나올수도 있고, 나오지 않을 수도 있음)빌드 과정 자동화와 외부 라이브러리 관리를 위해 빌드 툴이 사용되며, 자주 사용되는 자바 빌드툴에는 maven, gradle이 있다. 어노테이션자동으로 설정/기능을 동작하게 하는 것@ + (어노테이션으로 만들어둔) 클래스 이름 서버 : 어떤 기능을 제공하는 프로그램. 또는 그 프로그램을 실행시키고 있는 컴퓨터클라이언트 : 서버에 어떤 기능을 요청하는 프로그램. 또는 그 프로그램을 실행시키고 있는 컴퓨터HTTP : 컴퓨터가 네트워크를 통해 다른 컴퓨터와 데이터를 주고받을 때 사용되는 통신 규약(중 하나)HTTP Method : HTTP 요청을 받는 컴퓨터에게 요청하는 행위(GET, POST, PUT, DELETE 등)요청을 받는 컴퓨터(IP 주소 / 도메인)와 프로그램(port) 정보Path : HTTP 요청을 받는 컴퓨터에게 원하는 자원Query : 자원의 세부 조건(조건이 여러개면 &로 구분)Body(JSON) : 저장할 자원의 정보JSON : 객체 표기법. 중괄호 안에 "key": value로 표기하며, 속성 각각은 ,로 구분한다. 요청에 대한 HTTP 응답200 OK (요청이 잘 처리됨)300 Moved Permanently (다른 곳으로 옮겨가라)404 NotFound (요청한 것을 찾을 수 없음)500 Internal Server Error (우리 내부에 문제가 생김)응답에는 추가 정보(바디)를 담을 수도 있음 API 정해진 약속을 하여 특정 기능을 수행하는 것클라이언트와 서버는 HTTP를 주고 받으며 기능을 동작하는데 이때 정해진 규칙API의 구성 요소(명세)HTTP MethodHTTP PathQuery(key와 value)API의 반환 결과 @RestController: 주어진 클래스를 Controller(API의 입구)로 등록한다 -> 현재 클래스를 API의 진입 지점으로 만들어줌@GetMapping("/add"): 아래 함수를 HTTP Method가 GET이고 HTTP Path가 /add인 API로 지정한다.@RequestParam: 주어지는 쿼리를 함수 파라미터에 넣는다 -> 같은 이름을 가진 쿼리의 값이 함수의 argument로 들어온다@RequestBody: HTTP Body 안에 담긴 JSON을 DTO 객체로 변환 (DTO : 정보를 전달하는 역할의 객체) Section 2 - Database 조작하기Database의 필요성: API를 통해 생성된 정보는 단기 기억장치인 RAM에 기록되는데, RAM에 기록된 정보는 서버가 종료되면 사라지기 때문에 DISK에 장기 저장하기 위해 DB를 사용한다. Database : 데이터를 구조화해서 저장하는 것RDB(Relational DB) - MySQL : 데이터를 표처럼 구조화해서 저장하는 DBSQL(Structured Query Language) : 표처럼 구조화된 데이터를 조회,저장 등 조작하는 언어-> MySQL을 사용 DDL(Data Definition Language) : 데이터를 정의하는 언어데이터베이스 만들기 : create database [데이터베이스 이름];데이터베이스 목록 보기 : show databases;데이터베이스 지우기 : drop database [데이터베이스 이름];데이터베이스 안으로 들어가기 : use [데이터베이스 이름];(특정 데이터베이스 안으로 들어간 후)테이블 목록 보기 : show tables;테이블 만들기 :create table [테이블 이름] ([필드1 이름] [타입] [부가조건],[필드2 이름] [타입] [부가조건],...primary key ([필드이름]));테이블 제거하기 : drop table [테이블 이름];DML(Data Manipulation Language) : 데이터를 조작하는 언어데이터를 넣는다 = 생성, create데이터를 조회한다 = 조회(읽기), retrieve of read데이터를 수정한다 = 업데이트, update데이터를 삭제한다 = 제거, delete-> CRUD데이터 넣기INSERT INTO [테이블 이름] (필드1이름, 필드2이름, ...)VALUDES(값1, 값2, ...)필드 이름과 값의 순서가 맞아야 한다명령어는 소문자로 써도 상관 없다auto_increment로 지정된 필드값은 지정해주지 않아도 자동으로 설정된다데이터 조회하기SELECT * FROM [테이블 이름];* 대신 필드 이름을 넣을 수 있다. 여러 개 넣는 것도 가능하다.SELECT * FROM [테이블 이름] WHERE [조건];where을 이용해 필터(조건)을 걸 수 있다.AND 또는 OR을 이용해 조건을 이어 붙일 수 있다.조건에는 =, <= 외에도 !=, <, >, >=, between, in, not in 등이 있다.BETWEEN [범위의 시작값] AND [범위의 끝값] : 양끝값을 포함한 범위 내의 값들만 가져온다.IN(조건1, 조건2) : 여러 조건을 한 번에 표시할 때 사용NOT IN() : 괄호 안 조건들에 포함되지 않는 데이터만 조회할 때 사용데이터 업데이트하기UPDATE [테이블 이름] SET 필드1이름=값, 필드2이름=값, ...WHERE [조건];where 절을 사용해 [조건]을 붙이지 않으면, 모든 데이터가 변경(업데이트)되니 주의해야 함데이터 삭제하기DELETE FROM [테이블 이름] WHERE [조건];[조건]을 붙이지 않으면 모든 데이터가 삭제되니 주의할 것!Spring에서 Database 사용하기사람이 아닌 스프링 서버가 MySQL 서버에 접근하게 하는 것application.yml 만들고 설정spring: datasource: url: "jdbc:mysql://localhost/library" username: "root" password: "5502" driver-class-name: com.mysql.cj.jdbc.DriverAPI 변경jdbcTemplate을 이용해 SQL을 날릴 수 있다. SQL을 만들어 문자열 변수로 저장한다. 이때 값이 들어가는 부분에 ?를 사용하면, 값을 유동적으로 넣을 수 있다.jdbcTemplate.update()는 INSERT, UPDATE, DELETE 쿼리에 사용할 수 있다(데이터에 변경을 주는 쿼리). 첫 파라미터로는 sql을 받고, ?를 데신할 값을 차례로 넣으면 된다.jdbcTemplate.query(sql, RowMapper 구현 익명클래스) : mapRow라는 함수를 override하고, override한 MapRow 안에서 sql의 실행 결과가 나오면 그 결과들의 데이터를 가져와 UserResponse(dto)로 바꿔준다. Section 3 - 역할의 분리와 스프링 컨테이너클린 코드가 중요한 이유 코드 : 요구사항을 표현하는 언어-> 개발자는 요구사항을 구현하기 위해 코드를 읽고 작성한다.-> 코드를 읽는 것은 필수적이고 피할 수 없으므로, 코드만 보고도 의미를 명확하게 파악할 수 있도록 코드를 작성해야 한다. 클린 코드함수는 최대한 작게 만들고 한 가지 일만 수행하는 것이 좋다.클래스는 작아야 하며 하나의 책임만을 가져야 한다.Controller의 함수 1개를 역할에 따라 분리API의 진입 지점으로써 HTTP Body를 객체로 변환하고 있다. -> Controller의 역할로 남겨둠현재 유저가 있는지, 없는지 등을 확인하고 예외 처리를 해준다. -> Service의 역할로 분리SQL을 통해 실제 DB와의 통신을 담당한다. -> Repository의 역할로 분리 3가지 역할로 구분된 구조controller : API와 HTTP 역할 담당service : 분기 처리, 로직 담당repository : DB와의 접근 담당* controller는 service를 사용하고, service는 repostory를 사용함** DTO는 계층 간의 정보를 전달하는 역할(여러 계층을 왔다갔다 함)-> 이런 구조를 Layered Architecture라고 한다!  회고인프런 워밍업 클럽 스터디에 참여한지 벌써 일주일이 지났다. 이번주에 다른 일들이 많이 겹쳐 내 생각보다 더 힘들었지만, 혼자서 공부하는 것보다 이점이 훨씬 많았다. 진도표에 매일 공부할 분량이 체계적으로 정해져 있어 내가 별도로 학습 계획을 짜지 않아도 계획적으로 공부할 수 있었다. 여러명이 함께 강의를 수강하는 스터디 방식이라 강의 듣는 것을 미루지 않고 진도표에 맞춰 학습할 동기부여가 되었다. 추가적으로 주어지는 과제를 통해 배운 내용에 대해 스스로 학습하고 정리할 수 있는 것도 좋았다. 강의 내용도 매우 흥미로웠다. 자바와 스프링에 대한 기초 지식부터, 백엔드 개발에 필요한 서버 관련 내용까지 차근차근 정리할 수 있었다. 또한 API 설계 및 개발 과정을 실습으로 진행하며 각 기능이 어떻게 동작하고 구현되는지 직접 코딩을 하며 익혔다. 다른 일들이 겹쳐 과제를 놓치기도 하고 실습 중 자바에 대한 이해가 부족하다는 것을 느껴 조금 아쉬운 1주차였지만, 2주차부터는 좀 더 많은 시간을 투자하고 자바에 대한 학습도 병행하며 더 충실한 일주일을 보낼 수 있도록 노력할 것이다.

백엔드인프런워밍업클럽스터디1기

김동현

[인프런 워밍업 클럽 1기/BE] 첫번째 발자국

Section 1 - 생애 최초 API 만들기 Java를 공부하기 전에 알아두면 좋을 것들 #1 #2JVM: 자바 가상 머신, 컴파일된 코드를 읽고 실행JDK: JVM을 포함해 자바 컴파일러(javac), 자바 실행 도구, 라이브러리 등 다양한 개발 도구 제공 HTTP, APIHTTP(Hypertext Transfer Protocol): 웹 브라우저가 웹 페이지를 요청하고 서버가 그에 대한 응답을 주는 기본적인 통신 규약API(Application Programming Interface): 소프트웨어 간의 상호작용을 위한 인터페이스를 제공정리컴퓨터간의 통신은 HTTP라는 표준화된 방식HTTP 요청은 HTTP Method (GET, POST)와 Path (/portion) 가 핵심요청에서 데이터를 전달하기 위한 2가지 방법은 쿼리와 바디HTTP 응답은 상태 코드가 핵심클라이언트와 서버는 HTTP를 주고 받으며 기능을 동작하는데 이때 정해진 규칙을 APIGET API, POST APIGET API는 쿼리로, POST API는 바디에 담아 보냄.(무조건은아님)DTO를 만들어서 구현가능 이경우 @RequestParam 제거 !과제 2일차 Section 2 - 생애 최초 Database 조작하기DBDB 없이 만들면 메모리에만 저장됨 => 데이터가 다날아감DB => 데이터를 구조화 시켜 저장 MySQL 연동application.yml 만들기JdbcTemplate 사용직접 sql을 연동하지 않고 JPA만 사용했던 경험이 있다.sql 쿼리를 직접 사용해서 db에 접근가능함도 알음  Section 3 - 역할 분리 클린코드코드만 보고 의미 파악할 수 있게 함함수는 최대한 작게 만들고 한 가지 일만 수행클래스는 작아야 하고 하나의 책임만 가져야 함유지보수도 좋음Controller 3단 분리Controller 역할 - API의 진입 지점으로써 HTTP Body를 객체로 변환Service 역할 - 현재 유저가 있는지, 없는지 등을 확인하고 예외 처리(메인 로직 수행)Repository 역할 - SQL을 사용해 실제 DB와의 통신을 담당Layered Architecture 1주차 회고스터디 시작전 모든 미션과 과제를 수행하고자 했는데 첫번째 과제부터 놓쳐버렸다... 남은 것들은 꼭 다하겠다.오랜만에 스프링을 다시 하는데 당시에 바로 jpa를 바로 사용해서 db랑 연동을 했었는데 쿼리로 연동하는 것도 알게 되었음. 기초가 중요하다.Java에 아직 덜 익숙함도 느꼈음. Java 다형성 부분에 대해서 다시 복습해야겠다.남은 스터디 기간에도 집중해서 꼭 많은 것을 얻어가는 시간이 되어야겠다.

백엔드워밍업백엔드스터디