flutter와 spring boot를 이용해 간단하게 로그인 페이지를 만들어보도록 하겠습니다. 정말 단순한 기능의 로그인이기 때문에 암호화 토큰 이런거 없습니다 ㅋㅋㅋ
flutter로 앱을 만들고 싶은데 서버가 있어야겠으니 서버를 spring boot로 사용할 예정입니다. 일단 spring boot에서 회원가입과 로그인을 위한 기능들을 만들고, flutter로 화면을 만들어봅시다.
github : https://github.com/mychew-blog/LoginApp
스프링부트 프로젝트 세팅
프로젝트 디펜던시는 뭐가 필요할까 고민을 해봤는데 아래 정도면 되지 않을까요?
Spring Data JPA
Spring Boot DevTools
MySQL Driver
Spring Web
로그인과 관련된 oauth2나 security 같은 dependency도 있긴 한데 아직 어떻게 사용될지는 모르니 설치하지 않겠습니다.
저는 자바보다는 코틀린을 선호하는데 (간지 나니까요..) 코틀린으로 개발할 때 적어줘야 하는 게 있습니다. allopen과 noarg인데요, 코틀린에서 몇몇 어노테이션을 사용하면 기본적으로 final이 추가되면서 클래스가 생성되는데 allopen을 설정하게 되면 open형태로 생성됩니다. noarg의 경우 entity의 기본 생성자를 argument가 없는 생성자로 생성하는 옵션인데, JPA를 활용할 때 필요한 패키지입니다. 요 두 개는 아마 코틀린으로 스프링을 사용할 때 많이 나오는 내용이라 검색해보시면 보다 자세한 내용을 확인할 수 있습니다.
// build.gradle.kts
import org.jetbrains.kotlin.gradle.tasks.KotlinCompile
plugins {
id("org.springframework.boot") version "2.6.6"
id("io.spring.dependency-management") version "1.0.11.RELEASE"
kotlin("jvm") version "1.6.10"
kotlin("plugin.spring") version "1.6.10"
kotlin("plugin.jpa") version "1.6.10"
}
noArg {
annotation("javax.persistence.Entity")
}
allOpen{
annotation("javax.persistence.Entity")
annotation("javax.persistence.MappedSuperclass")
annotation("javax.persistence.Embeddable")
}
group = "com.mychew"
version = "0.0.1-SNAPSHOT"
java.sourceCompatibility = JavaVersion.VERSION_11
repositories {
mavenCentral()
}
dependencies {
implementation("org.springframework.boot:spring-boot-starter-data-jpa")
implementation("org.springframework.boot:spring-boot-starter-web")
implementation("com.fasterxml.jackson.module:jackson-module-kotlin")
implementation("org.jetbrains.kotlin:kotlin-reflect")
implementation("org.jetbrains.kotlin:kotlin-stdlib-jdk8")
developmentOnly("org.springframework.boot:spring-boot-devtools")
runtimeOnly("mysql:mysql-connector-java")
testImplementation("org.springframework.boot:spring-boot-starter-test")
}
tasks.withType<KotlinCompile> {
kotlinOptions {
freeCompilerArgs = listOf("-Xjsr305=strict")
jvmTarget = "11"
}
}
tasks.withType<Test> {
useJUnitPlatform()
}
build.gradle.kts에 noArg {}와 allOpen {}을 추가했습니다.
그리고 일단 패키지 구조를 나누었는데 controller, model, repository, service로 나누었습니다. 요 프로젝트 구조는 회사마다 다르고 프로젝트마다 다른데 대충 요 4개의 역할은 분리되는 것 같아요.
컨트롤러 만들어주기
controller가 맨 앞단이기 때문에 여기에 url을 매핑해줍니다. Django로 따지면 urls.py 느낌?
일단 회원가입이랑 로그인만 만들어 봅니다. join이랑 login으로 하면 될려낭? url을 ~/members/login, ~/members/join 이런 식으로 할 예정인데 members는 공통이니까 클래스에 RequestMapping이라는 어노테이션으로 정의할 수 있습니다.
// ~/src/main/kotlin/com.mychew.loginapp.controller.MemberController.kt
package com.mychew.loginapp.controller
import org.springframework.http.ResponseEntity
import org.springframework.web.bind.annotation.PostMapping
import org.springframework.web.bind.annotation.RequestMapping
import org.springframework.web.bind.annotation.RestController
@RestController
@RequestMapping("/members")
class MemberController {
@PostMapping("/login")
fun login(): ResponseEntity<Any> {
return ResponseEntity.ok().body(null)
}
@PostMapping("/join")
fun join(): ResponseEntity<Any> {
return ResponseEntity.ok().body(null)
}
}
일단은 위처럼 login과 Join을 만들었습니다. 안에서는 아무것도 안 하고 바로 리턴합니다. ResponseEntity는 응답 타입인데 따로 응답 entity를 구현하지 않았기 때문에 그냥 클래스채로 써줍니다. ResponseEntity에 대해 궁금하면 타고 들어가 보면(윈도면 ctrl + 클릭, 맥은 command + 클릭이었나..) HttpEntity를 확장한 클래스로 HttpStatus값을 가질 수 있는 클래스라고 나옵니다.
아무튼 이렇게 요청을 하면 성공 응답을 주기로 했으니 한번 실행해볼까요?
DB설정을 안 했다고 프로젝트가 빌드가 안되는군요 ㅋㅋㅋ 저희는 mysql을 쓰기로 했었는데, DB설정을 한번 해보고 실행하도록 하겠습니다.
저희는 mysql을 사용하기로 했기 때문에 mysql을 띄워봅시다. 컴퓨터에 mysql이 돌아가고 있으면 좋긴 한데, 저희는 일단 docker로 mysql을 띄우고 거기에 연결하도록 하겠습니다. (docker 사용법에 대해서는 생략~)
윈도우 컴을 쓸 때는 도커를 안 쓰는 편이긴 한데.. (윈도우 도커 데스크탑이 너무 후져서) 일단 사용하기만 하면 mysql 띄우는 거는 너모너모 쉽습니다.
docker run --name mysql -p 3306:3306 -e MYSQL_ROOT_PASSWORD=asdf -d --rm mysql:8.0
요렇게 하면 mysql 8.0 버전을 알아서 설치해서 띄워줍니다. docker ps를 입력하면 현재 떠있는 mysql을 확인할 수 있습니다.
--name : 도커 컨테이너 이름
-p : 포트 연결. 외부의 3306을 도커의 3306으로 연결. 포트 포워딩이라고 생각하면 됨
-e : 환경변수 설정. 루트 패스워드만 설정해주었습니다.
-d : 데몬 모드. 이거 없으면 콘솔에서 직접 실행됩니다.
--rm : 도커 이미지 내리면 자동으로 컨테이너를 삭제합니다. 테스트용이니 계속 있을 필요 없어요~
mysql:8.0 : 사용할 이미지입니다. mysql 8.0이 점점 많이 사용돼서 이걸로 해봄 ㅎㅎ
자 이렇게 mysql이 떴으면 하나 해줄 게 있는데 DB에 들어가서 database를 하나 만들어주는 겁니다. 지금은 깡통 mysql이기 때문이죠.
저는 datagrip이라는 툴을 사용하기 때문에 이 툴을 이용해서 localhost의 db에 접속합니다.
비밀번호는 아까 도커를 띄울 때 asdf로 설정했기 때문에 root / asdf로 접속합니다. 그리고 DB를 하나 만들어요~
음 대충 kopring으로 합시다.
이제 스프링부트에서도 이 DB를 바라볼 수 있도록 설정합니다. DB 설정은 application.properties에 하는데, 요즘에 properties로 쓰는 것보다 yaml로 쓰는 게 대세라서 application.properties를 application.yml로 바꿔줍니다.
application.yml에는 DB설정뿐만 아니라 다양한 설정을 할 수 있습니다. 위에서 jpa설정은 아직 볼 필요 없고 datasource 쪽을 보시면 mysql을 사용하고 username과 password가 root/asdf로 입력되어있고, url이 로컬로 되어있다는 것만 확인하면 됩니다.
이제 프로젝트를 실행해봅시다.
잘 뜨네요.
원래 우리가 하려고 했던 ~/members/login 과 ~/members/join을 호출해봅시다. post로 만들었기 때문에 브라우저에서 호출하면 안 되고, postman이나 insomnia 같은 툴을 사용합시다.
login과 join 모두 200 ok가 잘 오는군요. 일단 API를 날려서 응답 오는 것까지는 완료했습니다.
회원 모델 만들기
회원가입과 로그인을 하기 위해 회원 테이블이 있어야겠죠? 저희는 JPA를 사용해서 ORM형식으로 테이블을 만들어보도록 하겠습니다. 이전에 application.yml에는 JPA에 대한 설정을 해놓았기 때문에 모델만 만들면 테이블이 자동으로 만들어집니다.
먼저 위에서 만들어놓은 model 패키지 안에 Member 클래스를 만들어줍니다.
package com.mychew.loginapp.model
import org.hibernate.annotations.CreationTimestamp
import java.time.LocalDateTime
import javax.persistence.Column
import javax.persistence.Entity
import javax.persistence.GeneratedValue
import javax.persistence.Id
@Entity
class Member(
var email: String,
var password: String
) {
@Id
@GeneratedValue
var id: Long? = null
@CreationTimestamp
var createdAt: LocalDateTime = LocalDateTime.now()
fun checkPassword(input_password:String): Boolean{
return (password == input_password)
}
}
data class를 사용할 수도 있는데, 저는 그냥 class로, @Entity 어노테이션을 적어주셔야 JPA가 인식합니다.
기본적인 id값과 email, password 컬럼을 가지고 있고, 만들어진 순간인 craetedAt 컬럼을 가지고 있습니다. 요기까지만 만들고 프로젝트를 다시 실행해보겠습니다.
아까와 다르게 중간에 Hibernate란 놈이 테이블을 자동으로 생성하는 것을 볼 수 있습니다. 이 Hibernate는 JPA에 몸통인 놈으로 실제적으로 작업을 하는 녀석입니다. datagrip으로 가보면 실제로 테이블이 만들어져 있습니다.
그런데 hibernate_sequence라는 저희가 모르는 테이블이 하나 있습니다. 요거는 저희 코드에서 @GeneratedValue 어노테이션과 연관이 있는데, id값, 여기서는 회원의 id가 아니라 테이블 row의 index, 를 설정하는 어노테이션인데 아무것도 설정하지 않으면 AUTO라는 전략을 선택합니다. 여기서 전략이란 id를 어떻게 자동으로 생성해줄지에 대한 방법입니다. 요것도 타고 들어가면 여러 가지 전략을 볼 수 있습니다.
저희는 그냥 어노테이션만 적었기 때문에 AUTO로 입력되었고, mysql에서는 저렇게 sequence테이블을 만들어서 데이터가 생성될 때 id값에 어떤 값이 들어갈지 관리하고 있습니다. 지금 보시면 1로 돼있는데 만약 테이블에 데이터가 하나 생기게 되면 id값이 1로 생성되고 sequence테이블에는 2가 저장될 겁니다. 나중에 한번 확인해보세요~!
여기까지 모델을 만들었는데, 아이디를 email로 사용하고 비밀번호를 password로 사용하고 있습니다. 정말 간단히 로직만 볼 거라서 암호화나 중복제거 같은 옵션들은 다 건너뛰고 만들었습니다. 실제로 회원 테이블을 만들 때는 더 잘 만들어야 하기 때문에 여기서는 그냥 패스~!
Repository, Service 만들기
model과 controller를 만들었으니 이제 repository와 service를 만들 차례입니다. 이 두 개의 성격이 유사한데, 둘 다 로직을 처리하는 코드입니다. Repository는 DB에 접근하는 로직을 담당하며, Service는 그 외 비즈니스 로직을 담당합니다. 개발자에 의해 분리되어 관리되는 코드이기 때문에 처음에 짤 때 잘 짜야 나중에 운영할 때 좋아요.
부제목을 Repository와 Service 만들기라고 정하긴 했는데, 사실 그 외에 만들게 더 많습니다 ㅋㅋㅋ 자 일단 Repository를 먼저 만들어볼게요
// ~/src/main/kotlin/com/mychew/loginapp/repository/MemberRepository.kt
package com.mychew.loginapp.repository
import com.mychew.loginapp.model.Member
import org.springframework.data.jpa.repository.JpaRepository
interface MemberRepository: JpaRepository<Member, Long> {
fun findByEmail(email:String): Member?
}
Repository는 생각보다 간단합니다. JpaRepository를 상속받아서 interface를 만들고 그 안에 DB와 연동할 작업들을 적으면 됩니다. 기본적인 DB 연결 작업들은 다 만들어져 있기 때문에 JpaRepository를 상속하면 웬만하면 다 쓸 수 있습니다. 궁금하시면 JpaRepository를 타고 들어가 보세요. JpaRepository는 PagingAndSortingRepository와 QueryByExampleExecutor를 상속받고, PagingAndSortingRepository는 CrudRepository를 상속받습니다. CrudRepository안에 crud에 필요한 대부분의 메서드가 구현되어 있습니다.
제가 따로 구현한 것은 findByEmail인데, 간단하게 findByEmail이라고만 적고 email:String 인풋 적고 리턴은 Member?라고 선언했습니다. DB에서 email로 찾으라는 내용이 없는데 어떻게 동작하는 걸까요? 이것 역시 상속받은 Repository에 다 정의되어 있기 때문에 findBy라는 prefix를 사용하면 그 뒤에 필드로 알아서 찾게 됩니다. 인텔리제이를 사용하시면 JPA관련 플러그인이 설치될 텐데 JPA Inspector에서 findByEmail에 커서를 올리고 Actions를 누르면 Extract JPQL Query라는 메뉴가 있습니다. 요걸 누르면 쿼리가 짠 하고 생겨요.
findByEmail이 실행되면 위와 같이 select m from Member m where m.email = ?1 이라는 쿼리가 자동으로 실행되는 것을 알 수 있습니다. 신기방기~ 참고로 findByEmail은 로그인을 할 때 사용할 메서드입니다.
이제 Service를 만들 차례인데, Service를 만들기 위해 추가적으로 만들어야 할게 무지 많습니다.
일단 한번 보시죠
// src/main/kotlin/com/mychew/loginapp/service/MemberService.kt
package com.mychew.loginapp.service
import com.mychew.loginapp.common.BaseException
import com.mychew.loginapp.common.BaseRes
import com.mychew.loginapp.common.BaseResponseCode
import com.mychew.loginapp.model.Member
import com.mychew.loginapp.repository.MemberRepository
import org.springframework.http.ResponseEntity
import org.springframework.stereotype.Service
@Service
class MemberService(
private val memberRepository: MemberRepository
) {
fun login(email: String, password: String): ResponseEntity<Any>{
val member:Member? = memberRepository.findByEmail(email)
member ?: throw BaseException(BaseResponseCode.USER_NOT_FOUND)
if(!member.checkPassword(password)){
throw BaseException(BaseResponseCode.INVALID_PASSWORD)
}
return ResponseEntity.ok().body(member)
}
fun join(email: String, password: String): ResponseEntity<Any>{
val member:Member? = memberRepository.findByEmail(email)
member?.let { throw BaseException(BaseResponseCode.DUPLICATE_USER) }
return ResponseEntity.ok().body(memberRepository.save(Member(email, password)))
}
}
import가 겁나 많은 게 보이시나요? ㅋㅋㅋ
일단 Service 안에는 MemberRepository를 기본적으로 가지고 있고, login과 join이라는 function이 있습니다. 각각 응답으로는 ResponseEntity를 반환하고 있는데, Join을 먼저 보면 memberRepository에서 아까 만든 findByEmail을 사용해서 기존에 회원 가입되어있는지 확인하고 있습니다. 중복체크는 이렇게 소스단에서 해도 되고 DB 단에서 해도 되는데 일단 간단하게 소스단에서 체크하도록 만들었습니다. 만약 중복되는 아이디가 있다면 에러 처리를 해줘야겠죠? 여기서 ExceptionHandler의 필요성이 생깁니다. 그럼 일단 BaseException이라는 클래스를 만듭니다.
// src/main/kotlin/com/mychew/loginapp/common/BaseException.kt
package com.mychew.loginapp.common
class BaseException(baseResponseCode: BaseResponseCode): RuntimeException() {
public val baseResponseCode: BaseResponseCode = baseResponseCode
}
이렇게 BaseException을 만들었는데 예외 처리해주는 공통된 응답을 만들기 위해서 BaseResponseCode라는 예외 코드를 정의한 클래스를 만들어줍니다.
// src/main/kotlin/com/mychew/loginapp/common/BaseResponseCode.kt
package com.mychew.loginapp.common
import org.springframework.http.HttpStatus
enum class BaseResponseCode(status: HttpStatus, message: String) {
USER_NOT_FOUND(HttpStatus.NOT_FOUND, "사용자를 찾을 수 없습니다."),
INVALID_PASSWORD(HttpStatus.BAD_REQUEST, "잘못된 비밀번호입니다."),
DUPLICATE_USER(HttpStatus.BAD_REQUEST, "중복된 사용자 입니다.");
val status: HttpStatus = status
val message: String = message
}
이번에 사용할 예외는 3가지인데, 로그인할 때 사용자를 찾을 수 없거나, 비밀번호가 틀린 경우, 회원 가입할 때 중복된 사용자가 있는 경우 3가지입니다. 각각마다 HttpStatus를 매핑해주고 메시지를 적습니다. 이제 Exception과 Code들을 만들었으니 ExceptionHandler를 만들어줍니다.
// src/main/kotlin/com/mychew/loginapp/common/ExceptionHandler.kt
package com.mychew.loginapp.common
import org.springframework.http.ResponseEntity
import org.springframework.web.bind.annotation.ExceptionHandler
import org.springframework.web.bind.annotation.RestControllerAdvice
@RestControllerAdvice
class ExceptionHandler {
@ExceptionHandler(BaseException::class)
protected fun handleBaseException(e: BaseException): ResponseEntity<BaseRes>{
return ResponseEntity.status(e.baseResponseCode.status)
.body(BaseRes(e.baseResponseCode.status, e.baseResponseCode.message))
}
}
ExceptionHandler에 @RestControllerAdvice라는 어노테이션을 걸면 RestController라는 어노테이션을 건 곳에서 Exception처리를 할 수 있습니다. 여기서 한 가지 더 만들어야 하는 게 있는데 BaseRes라는 클래스입니다. 요건 공통된 응답 포맷을 말하는 건데, 일단 Httpstatus와 message를 내려주기 때문에 이 두 개를 가지고 있는 data class로 만들어줍니다.
// src/main/kotlin/com/mychew/loginapp/common/BaseRes.kt
package com.mychew.loginapp.common
import org.springframework.http.HttpStatus
data class BaseRes(
val status: HttpStatus,
val message: String?
)
자 이제 common이라는 곳에 4개의 소스를 추가했습니다. Exception을 추가하려고 한 건데 타고 타고 가다 보니 여러 개를 만들게 되었네요 ㅎㅎ 다시 Service 소스로 돌아가 보면
// src/main/kotlin/com/mychew/loginapp/service/MemberService.kt
package com.mychew.loginapp.service
import com.mychew.loginapp.common.BaseException
import com.mychew.loginapp.common.BaseRes
import com.mychew.loginapp.common.BaseResponseCode
import com.mychew.loginapp.model.Member
import com.mychew.loginapp.repository.MemberRepository
import org.springframework.http.ResponseEntity
import org.springframework.stereotype.Service
@Service
class MemberService(
private val memberRepository: MemberRepository
) {
fun login(email: String, password: String): ResponseEntity<Any>{
val member:Member? = memberRepository.findByEmail(email)
member ?: throw BaseException(BaseResponseCode.USER_NOT_FOUND)
if(!member.checkPassword(password)){
throw BaseException(BaseResponseCode.INVALID_PASSWORD)
}
return ResponseEntity.ok().body(member)
}
fun join(email: String, password: String): ResponseEntity<Any>{
val member:Member? = memberRepository.findByEmail(email)
member?.let { throw BaseException(BaseResponseCode.DUPLICATE_USER) }
return ResponseEntity.ok().body(memberRepository.save(Member(email, password)))
}
}
중복된 사용자가 있는 경우 throw BaseException(BaseResponseCode.DUPLICATE_USER)가 실행되면서 우리가 정의한 BaseRes 형태에 맞춰 응답이 내려가게 됩니다. 중복되지 않는다면 회원가입을 시켜주면 되기 때문에 ResponseEntity.ok() 응답을 주고 body에는 Member Entity를 반환하는 memberRepository.save()를 실행합니다. save에 인자 값으로는 Member엔티티를 넣어야 합니다.
로그인의 경우에는 일단 findByEmail로 입력된 Email과 동일한 사용자를 찾고, Password를 비교해서 올바른 비밀번호인지 체크합니다. 패스워드 비교 로직은 model안으로 넣었기 때문에 checkPassword라는 메서드로 검증해줍니다. 성공하면 역시 ok()와 MemberEntity를 넘겨주도록 해놨습니다.
자 이제 서비스를 만들었으니까 Controller에 적용해봅시다.
// src/main/kotlin/com/mychew/loginapp/controller/MemberController.kt
package com.mychew.loginapp.controller
import com.mychew.loginapp.dto.MemberRequest
import com.mychew.loginapp.service.MemberService
import org.springframework.http.ResponseEntity
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.RequestParam
import org.springframework.web.bind.annotation.ResponseBody
import org.springframework.web.bind.annotation.RestController
@RestController
@RequestMapping("/members")
class MemberController(
private val memberService: MemberService
) {
@PostMapping("/login")
fun login(
@RequestBody memberRequest: MemberRequest
): ResponseEntity<Any> {
return memberService.login(memberRequest.email, memberRequest.password)
}
@PostMapping("/join")
fun join(
@RequestBody memberRequest: MemberRequest
): ResponseEntity<Any> {
return memberService.join(memberRequest.email, memberRequest.password)
}
}
Controller에 RequestBody를 추가했습니다. input으로 올라오는 값들인데, JSON형태로 올라오기 때문에 dataclass로 만들어서 RequestBody라는 어노테이션으로 태깅해주었습니다. MemberRequest는 아래와 같습니다.
// src/main/kotlin/com/mychew/loginapp/dto/MemberRequest.kt
package com.mychew.loginapp.dto
data class MemberRequest(
val email: String,
val password: String
)
자 이제 진짜 모든 게 끝났습니다. 서버를 실행해볼까요?
테스트해보기
회원가입도 해보고 로그인도 해보고 틀린 것도 입력하면서 응답이 어떻게 오는지 확인해봅시다. DB에 데이터도 확인해보고요~!
스프링쪽은 이제 다 만들었으니 flutter로 클라이언트를 만들어봅시다.