iOS SwiftUI 프로젝트로 diary 앱을 제작하는데, 가장 먼저 만들어볼 기능은 이메일주소와 패스워드를 입력해서 로그인하는 기능이다.
우선 화면 레이아웃을 디자인해보자.
SwiftUI View 템플릿을 선택하고 LoginView.swift 라는 이름으로 새 파일을 추가한다.
//
// LoginView.swift
// iosdiary
//
// Created by woohaha on 2024/04/22.
//
import SwiftUI
struct LoginView: View {
var body: some View {
Text("Hello, World!")
}
}
#Preview {
LoginView()
}
Text 컴포넌트는 괄호 안의 텍스트를 표시하기 위한 용도의 컴포넌트이다.
여기에서 이메일 주소와 패스워드를 입력받기 위해서는 Text 가 아닌 TextEditor 컴포넌트를 사용할 수 있다. TextEditor 컴포넌트는 텍스트 형식의 데이터를 입력받는 기능이 있다.
TextEditor 컴포넌트 2개를 수직으로 배치하고, 맨 아래에 로그인 기능을 실행하기 위한 버튼 컴포넌트를 배치해본다.
import SwiftUI
struct LoginView: View {
var body: some View {
TextEditor(text: .constant("UserID"))
TextEditor(text: .constant("Password"))
Button("로그인") {
}
}
}
이렇게 배치한 결과는 다음과 같다.
UserID 와 Password 를 직접 입력할 수 있을 것으로 기대했지만, .constant 라는 속성으로 전달한 값은 수정이 불가능했다. TextEditor 에 어떤 값을 입력하여 수정할 수 있게 하려면 바인딩을 설정해야만 한다.
그리고 UI 레이아웃을 보면 UserID 와 Password 가 여러 줄의 데이터를 입력받을 필요가 없는데, TextEditor 는 여러 줄의 데이터를 입력받기 위한 목적의 컴포넌트이기에 전체적인 레이아웃 디자인을 해치고 있다.
UserID, Password 와 같은 값을 입력받을 때 사용할 수 있는 컴포넌트로 TextField 가 있다. TextEditor 를 TextField 로 수정해보겠다.
import SwiftUI
struct LoginView: View {
var body: some View {
//TextEditor(text: .constant("UserID"))
TextField("UserID", text: .constant("UserID"))
//TextEditor(text: .constant("Password"))
TextField("Password", text: .constant("Password"))
Button("로그인") {
}
}
}
TextEditor 대신 TextField 를 배치한 결과는 다음과 같다.
1줄의 데이터만 입력할 정도의 높이로 배치되므로 UI 레이아웃을 해치지 않으며, .constant 속성으로 설정한 데이터를 직접 수정할 수도 있다.
TextEditor 에 입력한 값을 얻기 위해서는 바인딩을 사용해야 한다고 했는데, TextField 역시 마찬가지로 입력된 값을 가져오기 위해서는 바인딩을 사용해야 한다.
import SwiftUI
struct LoginView: View {
@State var userid: String
@State var password: String
var body: some View {
//TextEditor(text: .constant("UserID"))
//TextField("UserID", text: .constant("UserID"))
TextField("UserID", text: $userid)
//TextEditor(text: .constant("Password"))
//TextField("Password", text: .constant("Password"))
TextField("Password", text: $password)
Button("로그인") {
}
}
}
#Preview {
//LoginView()
LoginView(userid: "", password: "")
}
LoginView 의 멤버로 String 형의 userid 와 password 를 선언하고 @State 라는 속성 래퍼(Wrapper)를 선언해 주었다. 그리고 TextField 의 text 속성에 바인딩 변수로서 $를 붙인 변수를 사용한다.
이렇게 하면 userid, password 변수에는 UI 에서 입력받은 값이 저장되기도 하고, 변수에 저장된 값이 UI 에 표시되기도 한다.
#Preview 섹션에서 LoginView 의 userid, password 파라미터 초기값을 각각 “” 으로 설정해주고 있는데, userid 와 password 변수의 선언시 초기값을 설정하지 않았기 때문에 필요한 작업이다. 아래와 같이 선언과 초기값 대입을 동시에 해준다면, LoginView 사용시 초기값을 설정하지 않아도 된다.
struct LoginView: View {
@State var userid: String = ""
@State var password: String = ""
var body: some View {
//TextEditor(text: .constant("UserID"))
//TextField("UserID", text: .constant("UserID"))
TextField("UserID", text: $userid)
//TextEditor(text: .constant("Password"))
//TextField("Password", text: .constant("Password"))
TextField("Password", text: $password)
Button("로그인") {
}
}
}
#Preview {
LoginView()
//LoginView(userid: "", password: "")
}
password 입력 UI 는 입력하는 password 문자가 * 로 마스킹되어 보이는게 좋겠다. 하지만, 지금은 TextField 컴포넌트를 사용하고 있기 때문에 아래 그림과 같이 입력된 문자가 그대로 노출되고 있다.
TextField 를 SecureField 로 변경하면 입력된 문자가 * 로 마스킹되어 표시된다.
import SwiftUI
struct LoginView: View {
@State var userid: String = ""
@State var password: String = ""
var body: some View {
//TextEditor(text: .constant("UserID"))
//TextField("UserID", text: .constant("UserID"))
TextField("UserID", text: $userid)
//TextEditor(text: .constant("Password"))
//TextField("Password", text: .constant("Password"))
//TextField("Password", text: $password)
SecureField("Password", text: $password)
Button("로그인") {
}
}
}
#Preview {
LoginView()
//LoginView(userid: "", password: "")
}
아래 그림은 TextField 대신 SecureField 컴포넌트를 사용한 결과이다.
TextField 와 SecureField 에 대해서 padding 수정자와 textFieldStyle 수정자를 사용하여 디자인적인 요소를 개선해보자.
import SwiftUI
struct LoginView: View {
@State var userid: String = ""
@State var password: String = ""
var body: some View {
VStack {
TextField("UserID", text: $userid)
.padding()
.textFieldStyle(.roundedBorder)
.autocapitalization(.none)
SecureField("Password", text: $password)
.padding()
.textFieldStyle(.roundedBorder)
Button("로그인") {
}
Spacer()
}
}
}
#Preview {
LoginView()
//LoginView(userid: "", password: "")
}
padding() 으로 상하좌우 여백을 추가했고, .roundedBorder 값을 textFieldStyle 에 설정함으로써 둥근사각형 테두리로 표현하고 있다. VStack 과 Spacer() 로 컴포넌트들을 화면 위쪽으로 배치되게 했다.
UserID 입력용 TextField 에 autocapitalization(.none) 으로 첫문자 자동 대문자 설정을 해제시켰다.
이제 “로그인” 버튼을 눌렀을 때 처리해야 할 로직을 작성해보자.
import SwiftUI
struct LoginView: View {
@State var userid: String = ""
@State var password: String = ""
var body: some View {
VStack {
TextField("UserID", text: $userid)
.padding()
.textFieldStyle(.roundedBorder)
.autocapitalization(.none)
SecureField("Password", text: $password)
.padding()
.textFieldStyle(.roundedBorder)
Button("로그인") {
loginAction()
}
Spacer()
}
}
func loginAction() {
}
}
“로그인” 버튼의 액션을 loginAction() 함수를 호출하는 것으로 작성했고, 아래쪽에 loginAction 몸체를 작성했다.
loginAction 에서 필요로 하는 것은 사용자가 입력한 사용자ID 와 password 이다. 2개의 값을 받을 수 있도록 아래와 같이 파라미터를 추가해본다.
...
func loginAction(userid: String, password: String) {
print("userid=" + userid + ", password=" + password)
}
...
Button 에 작성한 loginAction 함수 호출시 2개의 파라미터가 전달되도록 수정한다.
...
Button("로그인") {
loginAction(userid: userid, password: password)
}
...
Preview 에서 무언가를 입력하고 로그인 버튼을 클릭하면 콘솔에 입력된 값이 표시된다.
이 값을 로그인 API 에 태워보내보도록 하겠다.
안드로이드앱 개발 과정 포스트 중에서 안드로이드 : diary – RestAPI (Retrofit) 에 로그인 API 에 대한 설계가 나와있다.
전송 데이터 는 email 과 password 이고,
수신 데이터는 body, errorCode, errorMessage 이다. body 에 jwt 토큰값이 내려오도록 설계했었다.
이번 iOS diary 앱에서는 userid 값을 email 로 사용할 것이다. 입력된 email 주소의 유효성 검증 로직은 나중에 추가해볼 예정이다.
swift 로 서버에 POST data 를 전송하기 위해서는 URLSession 을 사용한다.
우선 대상 서버 주소를 설정해야 하겠다.
...
func loginAction(userid: String, password: String) {
print("userid=" + userid + ", password=" + password)
let url = URL(string: "http://diary.woohahaapps.com:8080/api/v1/login")
}
...
대상서버주소가 https 가 아닌 http 를 사용하는 경우에 .info 에 아래 속성을 추가해주어야 한다. (개발테스트가 용이한 설정이므로 실제 앱 개발시에는 https 통신이 가능하게 수정해야 한다는 사실을 잊지 말자)
이번에는 대상 서버에 대한 요청을 URLRequest 로 정의해줄 차례이다.
우선 요청방법(Method)을 설정해야 한다.
...
func loginAction(userid: String, password: String) {
print("userid=" + userid + ", password=" + password)
let url = URL(string: "http://diary.woohahaapps.com:8080/api/v1/login")!
var request = URLRequest(url: url)
request.httpMethod = "POST"
}
...
앞에서 swagger UI 를 통해서도 볼 수 있듯이 로그인 API 는 POST 방식으로 데이터를 전송해야 하므로 httpMethod 를 “POST”로 설정했다.
이번에는 데이터를 설정해줄 차례이다.
데이터는 json 형식으로 email 과 password 키와 값으로 구성한다. 로그인 데이터를 위해서 아래와 같이 구조체를 선언한다.
...
struct loginRequest: Encodable {
let email: String
let password: String
}
func loginAction(userid: String, password: String) {
...
이 구조체형의 데이터가 json 형식으로 인코딩되어야 하기 때문에 Encodable 프로토콜을 채택하였다.
이제 사용자가 입력한 값을 loginRequest 구조체형 변수로 만들어서 json 데이터형식으로 인코딩해본다.
...
struct loginRequest: Encodable {
let email: String
let password: String
}
func loginAction(userid: String, password: String) {
print("userid=" + userid + ", password=" + password)
let url = URL(string: "http://diary.woohahaapps.com:8080/api/v1/login")!
var request = URLRequest(url: url)
request.httpMethod = "POST"
let loginData = loginRequest(email: userid, password: password)
let requestData = try! JSONEncoder().encode(loginData)
request.httpBody = requestData
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
}
...
사용자가 입력한 email 주소와 password 값이 서버로 전송되게 하기 위해서는 json 형식으로 인코딩되어야 하기 때문에 JSONEncoder().encode() 함수를 사용하여 requestData 로 인코딩을 시도했다. encode() 함수가 예외를 발생할 수 있도록 정의되어 있기 때문에 반드시 try 를 함께 사용해야 하고, 인코딩하는 동안 예외가 발생하지 않을 것으로 확신한다는 의미에서 try! 를 사용했다. 실제 코드에서는 적절한 예외처리를 해주어야 한다.
최종적으로 구해진 json 데이터형식의 값을 요청의 httpBody 프로퍼티에 할당했다. 그리고 이 데이터형식이 json 이라는 것을 Content-Type 헤더에 명시해 주고 있다.
이제 대상 서버와 대상에 대한 요청 명세를 설정하였으니 서버로 요청을 전송할 수 있는 상태가 되었다. 서버로 요청을 전송하는 작업은 URLSession 을 사용한다.
...
struct loginRequest: Encodable {
let email: String
let password: String
}
func loginAction(userid: String, password: String) {
print("userid=" + userid + ", password=" + password)
let url = URL(string: "http://diary.woohahaapps.com:8080/api/v1/login")!
var request = URLRequest(url: url)
request.httpMethod = "POST"
let loginData = loginRequest(email: userid, password: password)
let requestData = try! JSONEncoder().encode(loginData)
request.httpBody = requestData
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
let task = URLSession.shared.dataTask(with: request) { data, response, error in
let statusCode = (response as! HTTPURLResponse).statusCode
if statusCode == 200 {
print("SUCCESS")
let responseData = try! JSONSerialization.jsonObject(with: data!) as! [String: Any]
} else {
print("FAILURE")
}
}
task.resume()
}
}
URLSession.shared.dataTask 를 통해서 요청객체(URLRequest)를 넘겨주면 task 가 생성되는데, 이 task 에 대해서 resume() 를 호출해야만 실제 작업이 실행된다.
dataTask 에 대한 요청이 실행되면 완료핸들러로 data, response, error 의 값이 넘어오는데, data 는 서버에서 보내준 값, response 는 status code 등과 같은 응답 메시지 관련 데이터, error 는 에러가 발생했을 때의 데이터이다.
(response as! HTTPURLResponse).statusCode 를 통해서 응답코드를 구해서 분기처리할 수 있고, 정상적인 응답코드를 수신받은 경우 서버에서 보내준 데이터인 data 를 처리할 수 있다.
diary 웹 프로젝트에서 로그인 API의 경우 정상적인 데이터는 다음과 같이 내려온다.
JSONSerialization.jsonObject(with: data!) 를 사용하여 응답데이터를 json 형식으로부터 Dictionary 형식으로 파싱하고 있다.
responseData 의 body 키에 해당하는 값은 애플리케이션에 저장되었다가 다른 API 요청시에 Header 에 담아 보내주어야 한다.
...
struct loginRequest: Encodable {
let email: String
let password: String
}
func loginAction(userid: String, password: String) {
print("userid=" + userid + ", password=" + password)
let url = URL(string: "http://diary.woohahaapps.com:8080/api/v1/login")!
var request = URLRequest(url: url)
request.httpMethod = "POST"
let loginData = loginRequest(email: userid, password: password)
let requestData = try! JSONEncoder().encode(loginData)
request.httpBody = requestData
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
let task = URLSession.shared.dataTask(with: request) { data, response, error in
let statusCode = (response as! HTTPURLResponse).statusCode
if statusCode == 200 {
print("SUCCESS")
let responseData = try! JSONSerialization.jsonObject(with: data!) as! [String: Any]
let token = responseData["body"] as! String
UserDefaults.standard.set(token, forKey:"TOKEN")
print(UserDefaults.standard.object(forKey:"TOKEN") as! String)
} else {
print("FAILURE")
}
}
task.resume()
}
...