iOS SwiftUI : diary – 로그인

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()
	}
...

Leave a Comment