E4.카드결제시스템실습프로젝트-IOS
프로젝트 개요
사내 수습 기간 실습 프로젝트로, Spring Boot를 통해 백엔드 서버를 구축하고, React.js를 통해 프론트엔드 애플리케이션을 개발했습니다.
선불 머니 충전, 상품 결제, 상품 판매 등의 기능이 구현되어 있습니다. Android와 SwiftUI를 통해 로그인과 회원가입을 구현하였고 그 외 기능은 Webview를 통해 구현했습니다.
구현 특징
- MVVM 모델을 사용해 View에 나타나는 데이터와 상태관리 관련된 로직을 ViewModel에 따로 분리하였으며 이로써 유지보수에 보다 용이하도록 했습니다.
- 공통되는 뷰들은 따로 추출하여 한 곳에 모아둠으로써 중복된 코드 작성을 최소화 하려 했습니다.
- 웹뷰를 사용할 때에 카메라와 갤러리 등 디바이스의 기능에 접근할 수 있다는 특징을 갖고 있는데 이러한 특징을 이용해보려 했습니다.
- One Source Multi Use를 위하여 하나의 뷰 혹은 하나의 함수를 여러 곳에서 여러 용도로도 사용할 수 있도록 시도 했습니다.
구현 기능
네트워크 통신
- Alamofire를 이용한 서버 통신
- Codable을 사용하여 데이터 송수신 구현
웹뷰
- JS Alert나 Confirm이 발생하였을 시 .alert, . confirmationDialog을 사용하여 앱에서 처리 (브릿지 함수)
회원가입
- 유효성 검사를 통과해야 다른 필드가 쌓이는 UI 구현
가맹점 등록, 상품 등록
- 웹뷰를 이용해 상품 이미지를 등록 할 시 카메라 혹은 갤러리에 접근하여 업로드
트러블슈팅 및 핵심 구현
회원가입 - 유효성 검사에 따른 가입 절차 진행
아이디 중복 검사, 비밀번호 확인, 정규식 검사 등 회원가입을 위한 유효성 검사를 통과하여야 다음 회원가입 절차를 진행할 수 있도록 했습니다.

//
// SignUpView.swift
// e4eduIos
//
// Created by 김기현 on 2022/12/21.
//
import SwiftUI
/*******************************************************************************************************
회원가입 뷰
4) 구성 : 아이디 중복검사를 통과 시 비밀번호를 치는 TextField가 나오고 그 다음엔 이름. 이렇게 순차적으로 회원 가입에 입력할 inputText가 나오는 구조. 요즘 은행 앱들은 이렇게들 하더라.
- 아이디, 비밀번호, 이름, 휴대전화 등 각 요소들을 컴포넌트뷰화 하였음 (~.common.CustomViews)
*/
struct SignUpView: View {
@StateObject var signUpVM: SignUpViewModel = SignUpViewModel()
var body: some View {
...
// {4} 시작
VStack(spacing: 30){
VStack(spacing: 30){
// 아이디 중복검사 후 - 비밀번호 및 비밀번호 확인
if signUpVM.validateId(){
// 비밀번호 확인 후 - 회원이름
if signUpVM.cPwdChecked{
// 이름 작성 후 - 휴대폰 인증
if signUpVM.nameChecked{
// 휴대폰 인증 후 - 이메일과 우편번호 및 주소
if signUpVM.validateMobileCertified(){
// 우편번호 작성 후 - 회원구분
if signUpVM.zipChecked{
// 회원 구분
RoleUserView(signUpVM: signUpVM)
}
// 이메일
EmailView(signUpVM: signUpVM)
// 우편번호 및 주소
ZipView(signUpVM: signUpVM)
}
// 휴대폰 인증
MobileCertifyView(signUpVM: signUpVM)
}
// 이름
NameView(signUpVM: signUpVM)
}
// 비밀번호 확인
PwdView(signUpVM: signUpVM)
}
// 아이디 및 중복체크 버튼
IdView(signUpVM: signUpVM)
}
}
// {4} 끝
}
...
}회원가입 - 정규식을 이용한 유효성 검사
NSPredicate와 Sting.range(of:options:)를 이용하여 정규표현식 검사를 하고, 이를 통과할 시 ViewModel의 값에 변화를 주어 View 화면을 re-rendering 시킵니다.


JS ↔ IOS 네이티브 Alert & Confirm 연동
웹뷰의 JS Alert나 Confirm이 작동될 때에 WebView 설정의 runJavascriptAlert/ConfirmwithMessage가 실행됩니다.
이 때 바인딩하여 넘겨준 State에 값을 지정하여 창을 띄우고 JS 함수의 메세지를 보여주고, confirmHandler를 통해 그 다음 액션을 결정할 수 있습니다.

//
// MainWebView.swift
// e4eduIos
//
// Created by 김기현 on 2023/01/04.
//
import SwiftUI
/*********************************************************
메인 웹뷰 - 메인 콘텐츠가 나온다. 이는 웹뷰로 한다.
...
6) JS의 alert이벤트를 ios로 가져와 작업한다. alertMessage와 showAlert는 WebView의 {3-4, 4-2}에서 쓰인다.
7) JS의 confirm이벤트를 ios로 가져와 작업한다. alertMessage와 showComfirm은 WebView의 {3-4, 4-2}에서 쓰인다.
*/
struct MainWebView: View {
...
var body: some View {
ZStack{
// {4}
WebView(url: URLInfo.getUrl(loginVM.loginByItsRole()), webViewModel: webViewModel, loginVM: loginVM, showAlert: $showAlert, showConfirm: $showConfirm, alertMessage: $alertMessage, confirmHandler: $confirmHandler)
if webViewModel.isLoading {
// {5}
ActivityIndicatorBar(loadingBarWidth: $webViewModel.loadingBarWidth, capsuleWidth: $webViewModel.capsuleWidth)
.frame(minWidth: 0, maxWidth: .infinity, minHeight: 0, maxHeight: .infinity)
}
}
// {6}
.alert(isPresented: self.$showAlert) { () -> Alert in
var alert = Alert(title: Text(alertMessage))
if(self.showAlert) {
alert = Alert(title: Text("알림"), message: Text(alertMessage), dismissButton: .default(Text("확인"), action: {
confirmHandler()
}))
}
return alert;
}
// {7}
.confirmationDialog(alertMessage, isPresented: $showConfirm){
Button("네", role: .destructive){
confirmHandler(true)
}
Button("cancel", role: .cancel){
confirmHandler(false)
}
} message: {
Text(alertMessage)
}
}
}//
// Webview.swift
// e4eduIos
//
// Created by 김기현 on 2022/12/31.
//
/*********************************************************
웹뷰
...
4) class NSObject: 대부분의 Objective-C 클래스 계층의 루트 클래스로, 하위 클래스가 런타임 시스템에 대한 기본 인터페이스와 Objective-C 개체로 작동하는 기능을 상속 -> 전체적인 기본 속성을 안고 출발한다.
protocol WKNavigationDelegate: 탐색 변경을 수락 또는 거부하고 탐색 요청의 진행 상황을 추적하는 메소드
...
4-2) 웹 사이트에서 alert이나 confirm이 발생하면 해당 함수가 실행되어 세부 내용을 맨 처음 선언한 alertMessage와 showAlert, confirmHandler에 할당한다. confirmHandler는 사용자가 confirm창에서 "예/아니오"를 선택했을 경우에 대해 처리하는 핸들러이다.
...
*/
/// {4-2} 시작
// alert : alertMessage == alert("ff")의 ff
// completionHandler == alert 이 뜨는 JS의 함수에 있는 동작들. 이 웹뷰를 부르는 곳에서 completionHandler(true)하면 만약 거기에 window.webkit.~~ 같이 ios에 어떤 메세지를 보내주는 이벤트가 있다면 그것을 실행하게 해준다.
func webView(_ webView: WKWebView, runJavaScriptAlertPanelWithMessage message: String, initiatedByFrame frame: WKFrameInfo, completionHandler: @escaping () -> Void) {
debugPrint("JS alert")
self.alertMessage.wrappedValue = message
self.showAlert.wrappedValue.toggle()
completionHandler()
}
// confirm alertMessage == confirm("ff")의 ff
// completionHandler == confirm 이 뜨고 "ok"클릭했을 때 JS의 함수에 있는 동작들. 이 웹뷰를 부르는 곳에서 completionHandler(true)하면 만약 거기에 window.webkit.~~ 같이 ios에 어떤 메세지를 보내주는 이벤트가 있다면 그것을 실행하게 해준다.
func webView(_ webView: WKWebView, runJavaScriptConfirmPanelWithMessage message: String, initiatedByFrame frame: WKFrameInfo, completionHandler: @escaping (Bool) -> Void) {
debugPrint("JS confirm")
self.alertMessage.wrappedValue = message
self.showConfirm.wrappedValue.toggle()
self.confirmHandler.wrappedValue = completionHandler
}
/// {4-2} 끝웹뷰에서 Galley & camera 접근
웹뷰에서 카메라에 접근이 가능하다는 점을 활용해 디바이스에서 직접 카메라를 찍거나 갤러리의 사진을 업로드 하는 기능을 구현했습니다.

FileReader를 사용하여 이미지를 문자열로 저장 후 이를 미리보기로 사용자에게 보여줄 수 있습니다.
// front/src/main/seller/register-goods/components/InputImg.jsx
import React, { memo, useCallback } from 'react'
import { useState } from 'react';
import ImgModal from './ImgModal';
const InputImg = ({setImage}) => {
// 1. 지금은 보여주기를 하기 위하여 setImgBase64를 하는 것이다.
// 2. submit을 할 때에 이미지를 formData에 담아야 한다. 이때 formData()에 이미지를 담고, 나머지를 append 한다.
// 2-1. 바뀔 때 새로운 별도의 state에 담는다. or ref로 갖고 있다가 submit 할 때에 fileRef.current.files[0]해서 goods에 담는다. or 바꿀때 formData한 거를 집어 넣는다. 어디에? realFileNm에.
// 2-2. 제출 한다.
// 2-3. submit했을 때 비트맵으로 된 이미지파일이 서버 resources 에 저장이되고, 그 파일 경로는 goodsImgPath에, 파일 이름은 바꾼 다음에 realFileNm에 저장된다.
// 2-4. 이 다음에 save 한다.
// setImgBase64: 미리보여주기 용
//
const [imgBase64, setImgBase64] = useState([]);
// 모달창 관리용
const [isOpen, setIsOpen] = useState(false);
const onToggleModal = () => {
setIsOpen(prev => !prev); // false > true
};
const handleChangeFile = (event) => {
// setImgBase64([]);
// for문은 multifile 할 때 하는 것. 지금은 파일을 하나만 선택하므로 필요가 없다.
// for(let i=0 ; i<event.target.files.length ; i++) {
// 만약 input에 파일이 첨부되었다면
if(event.target.files[0]) {
let reader = new FileReader();
// 사진을 Base64 데이터로 변환하고
reader.readAsDataURL(event.target.files[0]);
// 이 이벤트를 읽기 동작이 성공적으로 완료되었을 떄 실행된다.
reader.onloadend = () => {
const base64 = reader.result; // 비트맵 데이터 리턴, 이 데이터를 통해 파일 미리보기가 가능함
console.log("이거냐", base64)
// 사진이 비트맵데이터로 잘 리턴되었다면,
if(base64) {
// 이렇게 사진을 바이트타입 스트링으로 변환한다.
let base64Sub = base64.toString()
// setImgBase64(imgBase64 => [...imgBase64, base64Sub]); // 이 또한 여러개의 파일용
setImgBase64(() => base64Sub);
}
}
}
// }
setImage(() => event.target.files[0]);
}
// ImgModal 조건부 렌더링
const showImg = () => {
if (imgBase64.length > 0){
return(
<div style={{display:'block', paddingTop:"inherit", textAlign:"right"}}>
<button type="button" className="btn btn-danger showGoodsImgBtn"
id="showImgBtn" onClick={onToggleModal}>이미지 미리보기
{isOpen && (
<ImgModal show={isOpen} imgBase64={imgBase64} />
)}
</button>
</div>
);
}
}
return(
<td colSpan={"2"}>
<input type='file' className="form-control mr-3 my-0 lt-6 file"
accept="image/*"
name='img'
onChange={(event) => {
handleChangeFile(event);
}}
>
</input>
{showImg()}
</td>
);
}
export default memo(InputImg)// front/src/main/seller/register-goods/components/ImgModal.jsx
import { memo } from "react";
import { Modal, Button } from "react-bootstrap";
function ImgModal({show, imgBase64}){
return(
<Modal
show={show}
size="lg"
aria-labelledby="contained-modal-title-vcenter"
centered
>
<Modal.Header closeButton>
<Modal.Title id="contained-modal-title-vcenter">
이미지 미리보기
</Modal.Title>
</Modal.Header>
<Modal.Body style={{textAlign:"center"}}>
<img
key={imgBase64}
src={imgBase64}
alt={"미리보기 이미지"}
style={{width:"300px", height:"240px"}}
/>
</Modal.Body>
<Modal.Footer>
<Button className="btn btn-danger">Close</Button>
</Modal.Footer>
</Modal>
)
}
export default memo(ImgModal);이미지 저장
formData와 @RequestParam을 사용하여 이미지와 JSON을 함께 서버에 전송합니다.
그리고 서버가 이미지가 첨부되어 왔다면 /resources/static/images/에 uuid를 이용하여 이름을 변경 후 저장합니다.

// front/src/main/seller/register-goods/components/RegisterMerchantBtn.jsx
import React, { memo } from 'react'
import e4EduAxios from '../../../../lib/e4Edu_Axios';
import { jwtNotValidFunc, jwtNotValidFuncBeforePay } from '../../../../lib/jwt_not_valid_func';
const RegisterMerchantBtn = ({nmRef, mobileNoRef, merchant, mobileReg}) => {
const registerMerchant = () => {
jwtNotValidFuncBeforePay()
if (merchant.merchantNm === undefined || merchant.merchantNm.length < 1){
nmRef.current.focus();
return;
}else if (mobileReg === undefined || mobileReg === "inappropriate") {
mobileNoRef.current.focus();
return;
}
e4EduAxios.post("/seller/registermerchant", merchant,
{headers:{
"Content-Type": "application/json",
"Authorization": `Bearer ${sessionStorage.getItem("accessToken")}`
}})
.then(resp =>{
window.confirm(`${nmRef.current.value}등록에 성공하였습니다. 이제 상품 등록을 진행해주세요.`);
window.location.replace("/main/registerMerchant");
})
.catch(error => {
window.confirm(`가맹점 등록에 실패했습니다. 다시 시도해주세요.`);
jwtNotValidFunc(error);
})
}
return(
<button type="button" className="btn btn-danger btn-registerMerchant"
onClick={registerMerchant}>가맹점 등록하기</button>
);
}
export default memo(RegisterMerchantBtn)package net.e4net.demo.controller;
...
@RestController
@Slf4j
@RequiredArgsConstructor
public class RegisterGoodsController {
private final RegisterGoodsService goodsService;
// 두개 타입을 받을 수 있다.
// multipart/form-data 타입의 콘텐트 타입과 관련해서 사용할 수 있는 어노테이션인데, Multipart 데이터로 JSON과 이미지 파일 데이터를 함께 받을 수 있다.
// https://somuchthings.tistory.com/160?category=983431
@PostMapping(value="/seller/registergoods", consumes = { MediaType.APPLICATION_JSON_VALUE, MediaType.MULTIPART_FORM_DATA_VALUE })
public ResponseEntity<TbGoodsDTO> registerGoods(@RequestPart(value = "file", required = false) MultipartFile file,
@RequestPart("data") TbGoodsDTO tbGoodsDTO){
log.debug("요청이 왔는가? => {}", tbGoodsDTO);
log.debug("사진이 왔는가? => {}", file);
return ResponseEntity.ok(goodsService.registerGoods(file, tbGoodsDTO));
}
}package net.e4net.demo.service;
...
@Service
@RequiredArgsConstructor
@Slf4j
public class RegisterGoodsService {
@Transactional
public TbGoodsDTO registerGoods(MultipartFile file, TbGoodsDTO goodsDTO) {
if (file != null){
// not null 이면 파일을 여기 어딘가에 저장하고,
// dto.detfilename,
// dto.setfilepath 할것이다.
try {
goodsDTO = saveImgFile(file, goodsDTO);
} catch (IOException e) {
// TODO Auto-generated catch block
e.printStackTrace();
}
}
...
}
...
private TbGoodsDTO saveImgFile(MultipartFile file, TbGoodsDTO goodsDTO) throws IOException {
// 파일 원래 이름 추출 => REAL_FILE_NM에 저장될 것.
String origName = file.getOriginalFilename();
// 파일 이름으로 사용할 uuid 생성 / uuid? => 네트워크 상에서 고유성이 보장되는 id를 만들기 위한 표준 규약이다.
String uuid = UUID.randomUUID().toString();
// 확장자 추출 => png, jpeg... 등
String extension = origName.substring(origName.lastIndexOf("."));
// uuid과 확장자 결합 => GOODS_IMG_PATH에 저장 될 것.
String savedName = uuid + extension;
// 파일을 저장할 파일 경로
String savedPath = fileDir + savedName;
// 실제로 로컬에 uuid파일 정보 저장.
file.transferTo(new File(savedPath));
// 이제 dto에 저장 후 반환
goodsDTO.setRealFileNm(origName);
goodsDTO.setGoodsImgPath(savedName);
return goodsDTO;
}
}MVVM 디자인 패턴
MVVM패턴을 이용하여 Model은 오로지 JSON 값만 들어가도록, ViewModel에는 View 상태 관리나 비즈니스 로직이, View는 사용자에게 보여지는 화면을 담당하도록 역할을 분배하여 유지보수성을 높이고 View, ViewModel, Model 간의 의존성을 최소화 했습니다.
//
// LoginModel.swift
// e4eduIos
//
// Created by 김기현 on 2022/12/24.
//
import Foundation
/*********************************************************
로그인모델
모델은 오로지 JSON 값만 들어가도록 해야한다.
여기서는 바뀌는 값이 있으면 안된다. => let으로 설정한다.
1) 로그인 요청을 보내는 모델. 아이디와 비밀번호로 key가 구성되어 있다.
2) 로그인 응답을 받는 모델. 서버의 tokenDto의 멤버들을 key로 갖고 있다.
*/
// {1}
struct LoginReqModel: Codable {
let membId: String
let membPwd: String
}
// {2}
struct LoginRespModel: Codable {
let membSn: Int
let grantType: String
let accessToken: String
let authorities: String
let tokenExpiresIn: Int
}//
// LoginViewModel.swift
// e4eduIos
//
// Created by 김기현 on 2022/12/24.
//
import Foundation
/*********************************************************
로그인뷰모델
1) 상태 관리나 비즈니스 로직은 뷰 모델에서 한다.
2) @Published : 이 클래스를 @observedobject한 클래스와 함께 값을 즉시 공유 하는 어노테이션이다. @Published는 자동으로 바뀐 값을 렌더링해준다. 왜냐하면 공유하기 때문이다.
3) 로그인 화면의 id 값을 최근에 로그인을 성공하거나 회원가입에 성공한 아이디로 저장하는 setter이다.
4) AlamoFire를 통하여 Boot 서버와 Http 통신하여 로그인을 하기 위한 함수
4-성공-1) 로그인 성공을 의미하는 isLoginSucceed 를 true로, isLoginFailed를 False 로 한다. 그 이유는 LoginView 의 주석 "5,6)"를 참고할 것
4-성공-2) 최근에 로그인 한 아이디를 디바이스에 저장하기 위하여 UserDefaults에 저장한다. 이는 LoginView의 {7}에서 사용된다.
4-성공-3) 로그인을 성공한 값으로 돌아온 토큰값과 회원 번호를 UserDefaults에 저장한다. 이는 WebView의 {4-5-1} 에서 사용한다.
4-성공-4) UserDefaults.standard.synchronize()는 저장된 UserDefaults의 value들을 수동적으로 저장하는 것이다. 비록 캐시에 남아 자동으로 저장하긴 하지만 갑자기 예기치 못한상황에 종료되어 저장이 되지 못할 수도 있으므로.
4-실패-1)로그인 성공을 의미하는 isLoginSucceed 를 false로, isLoginFailed를 true 로 한다. 그 이유는 LoginView 의 주석 "5,6)"를 참고할 것
4-실패-2) 401에러는 아이디 비밀번호가 맞지 않다는 것이다. Bool이 아닌 String 값으로 지정해주어 사용자에게 왜 로그인이 실패했는지 알려주자.
5) 로그인 실패 alert를 띄울 때 loginFailMsg의 값에 따라서 어떤 메세지를 사용자에게 보여줄 지 결정하는 함수이다. 이는 LoginView의 {5}에서 사용된다.
*/
// {1}
@MainActor class LoginViewModel: ObservableObject{
// {2}
// 로그인 실패 여부
@Published var isLoginFailed: Bool = false
// 로그인 성공 여부
@Published var isLoginSucceed: Bool = false
// 로그인 실패 메세지
@Published var loginFailMsg: String = ""
// 첫 로그인인지
@Published var firstLogin: Bool = false
// 아이디
@Published var id: String = ""
// 비밀번호
@Published var pwd: String = ""
// {3}
func setId(_ idCurrentlyUesd: String){
id = idCurrentlyUesd
}
// {4}
func logIn(){
HttpClient<LoginRespModel>().alamofireNetWorking(.login, jwtToken: nil, method: .post, params: LoginReqModel(membId: id, membPwd: pwd),
onSuccess: {value in
// {4-성공-1}
self.isLoginFailed = false
self.isLoginSucceed = true
debugPrint(self.id)
// {4-성공-2}
UserDefaults.standard.set(self.id, forKey: "idCurrentlyUsed")
// {4-성공-3}
UserDefaults.standard.set(value.accessToken, forKey: "accessToken")
UserDefaults.standard.set(value.membSn, forKey: "membSn")
UserDefaults.standard.set(value.authorities, forKey: "authorities")
// {4-성공-4}
UserDefaults.standard.synchronize()
},
onFailure: { errorCode in
self.isLoginFailed = true
self.isLoginSucceed = false
// {4-실패-2}
if errorCode == 401{
self.loginFailMsg = "WRONG_ID_OR_PWD"
}else{
// {4-실패-2}
self.loginFailMsg = "SERVER_ERROR"
}
}
)
}
func loginFailMsgText() -> String{
if loginFailMsg == "WRONG_ID_OR_PWD"{
return "아이디 또는 패스워드가 잘못되었습니다."
}else{
return "서버오류! 다시 시도해주세요"
}
}
func loginByItsRole() -> UrlPurpose{
let authority = UserDefaults.standard.string(forKey: "authorities")
if authority == "ROLE_SELLER"{
return .webViewForSeller
}else{
return .webView
}
}
}//
// LoginView.swift
// e4eduIos
//
// Created by 김기현 on 2022/12/21.
//
import SwiftUI
/*********************************************************
로그인뷰
1) @StateObject : MVVM모델을 사용하여 View에 대한 상태관리값이나 데이터는 ViewModel이 갖고 있게 하였다. View는 ViewModel에서 데이터를 가져오는 구조이므로 @StateObject 어노테이션을 사용하였다.
@ObservedObject: 바인딩. LoginView의 소유는 아니고 다른데에서 빌려오는 개념 -> 이거만 쓰면 뷰가 렌더링 다시 될 때 원하는 대로 리렌더링이 안되는 경우가 있다. => 애니메이션이나 그런거 같이 비데이터 모데을 다룰 떄에 사용한다.
@StateObject: 데이터 뷰모델의 소유권을 LoginView가 갖고 있게 한다. 리액트의 State 같은 개념, a 컴포넌트가 변경 될 때 b 컴포넌트도 같이 변경되게끔 한다. 렌더링의 동기화를 위함.
=> 데이터모델을 다룰 때에는 StateObject를 사용한다.
3) ~.common.CustomViews에 여러 군데에서 쓰일 커스텀 뷰들이 있다. 그 커스텀 뷰 중에 TextField와 SecureFiled와 버튼을 가져와 사용하는 것. => 뷰를 재사용할 수 있다.
7) 로그인 성공 시 UserDefaults를 사용하여 가장 최근에 회원가입하거나 로그인한 아이디를 가져와 id 를 지정한다.
8) 로그인 버튼: LoginViewModel의 {4}
*/
struct LoginView: View{
// {1} - viewModel
@StateObject private var loginVM = LoginViewModel()
var body: some View {
...
VStack(spacing: 50){
VStack(spacing: 30){
// {3}
CutomTextField(placeHolder: "ID", imageName: "person.crop.circle", bColor: "TextColor", tOpacity: 0.6, value: $loginVM.id)
.onAppear{
// {7}
if let idCurrentlyUsed = UserDefaults.standard.string(forKey: "idCurrentlyUsed"){
print("idCurrentlyUsed => \(idCurrentlyUsed)")
loginVM.setId(idCurrentlyUsed)
}
}
VStack(alignment: .trailing){
// {3}
CutomTextField(placeHolder: "Password", imageName: "lock", bColor: "TextColor", tOpacity: 0.6 ,value: $loginVM.pwd)
.onSubmit {
loginVM.logIn() // viewModel 사용
}
VStack{
Text("비밀번호 찾기")
.fontWeight(.medium)
.foregroundColor(Color("GreyTextColor"))
}.padding(.horizontal, 20)
}
}
// {6}
NavigationLink(destination: MainWebView(loginVM: loginVM), isActive: $loginVM.isLoginSucceed){
// {8}
Button(action: {
loginVM.logIn() // viewModel 사용
}){
CustomButton(title: "로그인", bgColor: "CherryPinkColor")
}.padding(.horizontal, 20)
}
}
.onAppear{
loginVM.id = ""
loginVM.pwd = ""
}
}
Spacer()
// {4}
NavigationLink(destination: SignUpView(isShowJoinView: $isShowJoinView), isActive: $isShowJoinView){
// {9}
Button{
isShowJoinView.toggle()
} label: {
// {3}
CustomBottomButton(title: "회원가입", bgColor: Color("CherryPurpleColor"))
}
}
}
...
}공통 컴포넌트 활용
여러 View에서 공통으로 사용되는 ViewComponent는 CustomViews에 따로 모아두어 여러 곳에서 재사용할 수 있도록 했습니다. 이를 통해 통일된 UI를 제공하고, 코드 중복 사용을 최소화하고 최대한 짧은 코드로 가독성을 높이려 했습니다.


공통 유틸 활용 (URLInfo)
URLInfo.getUrl()은 여러 개의 함수를 만들 필요 없이 purpose라는 하나의 값을 이용하여 다른 url을 반환하도록 했습니다.

레슨런
공통 UI 컴포넌트의 모듈화와 재사용성 극대화
여러 화면에서 반복적으로 등장하는 UI 요소들은 CustomView로 추상화하여 각각 분리하였습니다. 덕분에 코드의 중복을 최소화하고, 하나의 뷰 함수를 다양한 맥락에 재사용하는 'One Source Multi Use' 방식을 실현할 수 있었습니다. 이러한 과정을 통해 전체 코드의 가독성과 유지보수성을 크게 향상시킨 귀중한 경험이 되었습니다.
MVVM 아키텍처 도입을 통한 결합도 최소화
UI 렌더링(뷰), 상태 관리 및 비즈니스 로직(ViewModel), 데이터(Model)의 역할을 명확히 구분하였습니다. 각 계층 간의 의존성을 줄이고, 기능 수정이나 확장이 필요할 때 발생할 수 있는 사이드 이펙트를 최소화하여 안정적인 아키텍처를 구축할 수 있었습니다.
하이브리드 앱(WebView) 구조의 장점 극대화
앱스토어 심사 없이 즉각적으로 UI/UX를 업데이트할 수 있는 WebView의 장점을 적극적으로 활용했습니다. 동시에, 순수 웹 브라우저에서는 접근이 어려운 카메라, 갤러리와 같은 네이티브 디바이스 기능들도 매끄럽게 연동하여, 웹과 앱 각각의 강점을 시너지를 내는 형태로 개발하였습니다. 이러한 방식으로 사용자 경험을 한층 더 끌어올릴 수 있게 된 것 같습니다.